lakebed 0.0.8 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,7 @@ import {
12
12
  validateAnonymousDeployPayload
13
13
  } from "./anonymous.js";
14
14
  import { authFromUrl as resolveAuthFromUrl, createGuestAuth, requestOrigin, shooBaseUrlFromEnv } from "./auth.js";
15
+ import { createSourceRuntimeFromEnv } from "./source-runtime.js";
15
16
  import { WebSocketServer } from "ws";
16
17
 
17
18
  function now() {
@@ -256,6 +257,7 @@ function quotaLimitForBucket(bucket, deploy) {
256
257
  }
257
258
 
258
259
  const USER_LIMIT_OVERRIDE_KEYS = ["requestsPerDay", "mutationsPerDay"];
260
+ const USER_BOOST_MULTIPLIER = 20;
259
261
 
260
262
  function normalizeUserId(value) {
261
263
  const userId = String(value ?? "").trim();
@@ -276,6 +278,25 @@ function normalizeLimitOverrideValue(value, key) {
276
278
  return parsed;
277
279
  }
278
280
 
281
+ function normalizeOptionalBoolean(value, key) {
282
+ if (value === undefined) {
283
+ return undefined;
284
+ }
285
+ if (typeof value === "boolean") {
286
+ return value;
287
+ }
288
+ if (typeof value === "string") {
289
+ const normalized = value.trim().toLowerCase();
290
+ if (normalized === "true") {
291
+ return true;
292
+ }
293
+ if (normalized === "false") {
294
+ return false;
295
+ }
296
+ }
297
+ throw new Error(`${key} must be a boolean.`);
298
+ }
299
+
279
300
  function normalizeUserLimitOverrides(value = {}) {
280
301
  if (!value || typeof value !== "object" || Array.isArray(value)) {
281
302
  throw new Error("limits must be a JSON object.");
@@ -298,9 +319,19 @@ function normalizeUserLimitOverrides(value = {}) {
298
319
  return overrides;
299
320
  }
300
321
 
301
- function limitsWithOverrides(baseLimits, limitOverrides) {
322
+ function limitsWithBoost(baseLimits, boost = false) {
323
+ if (!boost) {
324
+ return { ...baseLimits };
325
+ }
326
+
327
+ return Object.fromEntries(
328
+ Object.entries(baseLimits).map(([key, value]) => [key, Number.isFinite(value) ? value * USER_BOOST_MULTIPLIER : value])
329
+ );
330
+ }
331
+
332
+ function limitsWithOverrides(baseLimits, limitOverrides, boost = false) {
302
333
  return {
303
- ...baseLimits,
334
+ ...limitsWithBoost(baseLimits, boost),
304
335
  ...normalizeUserLimitOverrides(limitOverrides ?? {})
305
336
  };
306
337
  }
@@ -309,6 +340,7 @@ function userFromOwner(owner, current = {}) {
309
340
  const id = normalizeUserId(owner?.id ?? current.id);
310
341
  return {
311
342
  avatarUrl: owner?.avatarUrl ?? current.avatarUrl ?? null,
343
+ boost: Boolean(current.boost),
312
344
  createdAt: current.createdAt ?? now(),
313
345
  displayName: owner?.displayName ?? owner?.login ?? current.displayName ?? id,
314
346
  id,
@@ -326,13 +358,15 @@ function adminUserSummary({ activeDeployCount = 0, deployCount = 0, usage = [],
326
358
  return {
327
359
  activeDeployCount,
328
360
  avatarUrl: user.avatarUrl,
361
+ boost: Boolean(user.boost),
362
+ boostMultiplier: USER_BOOST_MULTIPLIER,
329
363
  createdAt: user.createdAt,
330
364
  defaultLimits: DEFAULT_ANONYMOUS_LIMITS,
331
365
  deployCount,
332
366
  displayName: user.displayName ?? user.login ?? user.id,
333
367
  id: user.id,
334
368
  limitOverrides,
335
- limits: limitsWithOverrides(DEFAULT_ANONYMOUS_LIMITS, limitOverrides),
369
+ limits: limitsWithOverrides(DEFAULT_ANONYMOUS_LIMITS, limitOverrides, user.boost),
336
370
  login: user.login,
337
371
  provider: user.provider,
338
372
  providerId: user.providerId,
@@ -656,6 +690,15 @@ function adminDeploySummary({ artifact, artifactBytes = 0, deploy, logBytes = 0,
656
690
  logBytes,
657
691
  logEntries,
658
692
  name: artifact?.name ?? "Lakebed Capsule",
693
+ owner: deploy.owner
694
+ ? {
695
+ avatarUrl: deploy.owner.avatarUrl ?? null,
696
+ displayName: deploy.owner.displayName ?? deploy.owner.login ?? deploy.owner.id,
697
+ id: deploy.owner.id,
698
+ login: deploy.owner.login ?? null,
699
+ url: deploy.owner.url ?? null
700
+ }
701
+ : null,
659
702
  ownerId: deploy.ownerId,
660
703
  slug: deploy.slug,
661
704
  stateBytes,
@@ -677,16 +720,17 @@ function adminHtml() {
677
720
  <style>
678
721
  :root {
679
722
  color-scheme: dark;
680
- --bg: #10100f;
681
- --panel: #181816;
682
- --line: #34342f;
683
- --line-strong: #575247;
684
- --text: #f3efe2;
685
- --muted: #aaa28f;
686
- --accent: #9ad66b;
687
- --warn: #e9bc5d;
688
- --bad: #ee7d71;
689
- --ink: #0f120d;
723
+ --bg: #0f1115;
724
+ --panel: #16191f;
725
+ --panel-soft: #1b2028;
726
+ --line: #2a303a;
727
+ --line-strong: #3c4654;
728
+ --text: #e7eaf0;
729
+ --muted: #8f9aaa;
730
+ --accent: #d9e1ec;
731
+ --bad: #f08080;
732
+ --warn: #e0b55f;
733
+ --ink: #0c0e12;
690
734
  }
691
735
 
692
736
  * {
@@ -694,15 +738,11 @@ function adminHtml() {
694
738
  }
695
739
 
696
740
  body {
697
- margin: 0;
698
- background:
699
- linear-gradient(90deg, rgba(154, 214, 107, 0.06) 1px, transparent 1px),
700
- linear-gradient(180deg, rgba(154, 214, 107, 0.04) 1px, transparent 1px),
701
- var(--bg);
702
- background-size: 34px 34px;
741
+ background: var(--bg);
703
742
  color: var(--text);
704
- font-family: "DIN Alternate", "Avenir Next", "Helvetica Neue", sans-serif;
743
+ font-family: Inter, "Avenir Next", "Helvetica Neue", Arial, sans-serif;
705
744
  letter-spacing: 0;
745
+ margin: 0;
706
746
  }
707
747
 
708
748
  button,
@@ -722,105 +762,164 @@ function adminHtml() {
722
762
 
723
763
  .shell {
724
764
  margin: 0 auto;
725
- max-width: 1280px;
765
+ max-width: 1440px;
726
766
  min-height: 100vh;
727
- padding: 24px 28px;
767
+ padding: 18px 22px;
728
768
  }
729
769
 
730
770
  .topbar {
731
771
  align-items: center;
732
772
  display: flex;
733
- gap: 18px;
773
+ gap: 16px;
734
774
  justify-content: space-between;
735
- margin-bottom: 24px;
775
+ margin-bottom: 14px;
736
776
  }
737
777
 
738
778
  .brand {
739
779
  display: grid;
740
- gap: 4px;
780
+ gap: 2px;
741
781
  }
742
782
 
743
- .eyebrow {
744
- color: var(--accent);
783
+ .eyebrow,
784
+ .mono,
785
+ small,
786
+ th,
787
+ label,
788
+ .status-line,
789
+ .pager {
790
+ color: var(--muted);
745
791
  font-family: "SFMono-Regular", Consolas, monospace;
746
792
  font-size: 12px;
747
793
  }
748
794
 
749
- h1 {
750
- font-size: clamp(24px, 3vw, 36px);
751
- font-weight: 700;
752
- line-height: 1;
795
+ h1,
796
+ h2,
797
+ h3 {
753
798
  margin: 0;
754
799
  }
755
800
 
756
- .actions {
801
+ h1 {
802
+ font-size: 28px;
803
+ line-height: 1.05;
804
+ }
805
+
806
+ h2 {
807
+ font-size: 15px;
808
+ }
809
+
810
+ h3 {
811
+ font-size: 14px;
812
+ }
813
+
814
+ .actions,
815
+ .tabs,
816
+ .pager,
817
+ .limit-actions {
818
+ align-items: center;
757
819
  display: flex;
758
- flex-wrap: wrap;
759
- gap: 10px;
820
+ gap: 8px;
821
+ }
822
+
823
+ .actions {
760
824
  justify-content: flex-end;
761
825
  }
762
826
 
827
+ .button,
828
+ .tab,
829
+ .row-action,
830
+ .icon-button {
831
+ align-items: center;
832
+ border-radius: 6px;
833
+ cursor: pointer;
834
+ display: inline-flex;
835
+ justify-content: center;
836
+ }
837
+
763
838
  .button {
764
839
  background: var(--accent);
765
840
  border: 1px solid var(--accent);
766
- border-radius: 6px;
767
841
  color: var(--ink);
768
- cursor: pointer;
769
842
  font-weight: 700;
770
- min-height: 40px;
771
- padding: 0 14px;
843
+ min-height: 34px;
844
+ padding: 0 12px;
772
845
  }
773
846
 
774
- .button.secondary {
847
+ .button.secondary,
848
+ .tab,
849
+ .row-action,
850
+ .icon-button {
775
851
  background: transparent;
852
+ border: 1px solid var(--line-strong);
776
853
  color: var(--text);
777
854
  }
778
855
 
779
- .metrics {
780
- display: grid;
781
- gap: 1px;
782
- grid-template-columns: repeat(6, minmax(130px, 1fr));
783
- margin-bottom: 26px;
856
+ .button:disabled,
857
+ .tab:disabled,
858
+ .row-action:disabled,
859
+ .icon-button:disabled {
860
+ cursor: default;
861
+ opacity: 0.48;
784
862
  }
785
863
 
786
- .metric {
787
- background: rgba(24, 24, 22, 0.94);
788
- border: 1px solid var(--line);
789
- min-height: 96px;
790
- padding: 16px;
864
+ .tabs {
865
+ border-bottom: 1px solid var(--line);
866
+ margin-bottom: 14px;
867
+ }
868
+
869
+ .tab {
870
+ border-bottom-left-radius: 0;
871
+ border-bottom-right-radius: 0;
872
+ border-color: transparent;
873
+ min-height: 34px;
874
+ padding: 0 12px;
791
875
  }
792
876
 
793
- .metric:first-child {
794
- border-radius: 8px 0 0 8px;
877
+ .tab.active {
878
+ background: var(--panel);
879
+ border-color: var(--line);
880
+ border-bottom-color: var(--panel);
881
+ color: var(--text);
882
+ margin-bottom: -1px;
883
+ }
884
+
885
+ .metrics {
886
+ display: grid;
887
+ gap: 8px;
888
+ grid-template-columns: repeat(6, minmax(120px, 1fr));
889
+ margin-bottom: 14px;
795
890
  }
796
891
 
797
- .metric:last-child {
798
- border-radius: 0 8px 8px 0;
892
+ .metric {
893
+ background: var(--panel);
894
+ border: 1px solid var(--line);
895
+ border-radius: 6px;
896
+ min-height: 68px;
897
+ padding: 10px 12px;
799
898
  }
800
899
 
801
900
  .metric span {
802
901
  color: var(--muted);
803
902
  display: block;
804
903
  font-family: "SFMono-Regular", Consolas, monospace;
805
- font-size: 12px;
806
- margin-bottom: 12px;
904
+ font-size: 11px;
905
+ margin-bottom: 7px;
807
906
  }
808
907
 
809
908
  .metric strong {
810
909
  display: block;
811
- font-size: 26px;
910
+ font-size: 20px;
812
911
  line-height: 1.1;
813
912
  }
814
913
 
815
914
  .panel {
816
- background: rgba(24, 24, 22, 0.96);
915
+ background: var(--panel);
817
916
  border: 1px solid var(--line);
818
- border-radius: 8px;
917
+ border-radius: 6px;
819
918
  overflow: hidden;
820
919
  }
821
920
 
822
921
  .panel + .panel {
823
- margin-top: 18px;
922
+ margin-top: 12px;
824
923
  }
825
924
 
826
925
  .panel-head {
@@ -829,18 +928,16 @@ function adminHtml() {
829
928
  display: flex;
830
929
  gap: 12px;
831
930
  justify-content: space-between;
832
- padding: 14px 16px;
931
+ padding: 10px 12px;
833
932
  }
834
933
 
835
- .panel-head h2 {
836
- font-size: 16px;
837
- margin: 0;
934
+ .panel-head.tight {
935
+ align-items: flex-start;
838
936
  }
839
937
 
840
- .status-line {
841
- color: var(--muted);
842
- font-family: "SFMono-Regular", Consolas, monospace;
843
- font-size: 12px;
938
+ .panel-title {
939
+ display: grid;
940
+ gap: 3px;
844
941
  }
845
942
 
846
943
  .table-wrap {
@@ -849,27 +946,32 @@ function adminHtml() {
849
946
 
850
947
  table {
851
948
  border-collapse: collapse;
852
- min-width: 1220px;
949
+ min-width: 900px;
853
950
  width: 100%;
854
951
  }
855
952
 
953
+ .deploy-table {
954
+ min-width: 960px;
955
+ }
956
+
957
+ .user-table {
958
+ min-width: 840px;
959
+ }
960
+
856
961
  th,
857
962
  td {
858
963
  border-bottom: 1px solid var(--line);
859
- padding: 12px 14px;
964
+ padding: 8px 10px;
860
965
  text-align: left;
861
- vertical-align: top;
966
+ vertical-align: middle;
862
967
  }
863
968
 
864
969
  th {
865
- color: var(--muted);
866
- font-family: "SFMono-Regular", Consolas, monospace;
867
- font-size: 12px;
868
970
  font-weight: 600;
869
971
  }
870
972
 
871
973
  td {
872
- font-size: 14px;
974
+ font-size: 13px;
873
975
  white-space: nowrap;
874
976
  }
875
977
 
@@ -877,185 +979,215 @@ function adminHtml() {
877
979
  border-bottom: 0;
878
980
  }
879
981
 
880
- .deploy-name {
982
+ .stack {
881
983
  display: grid;
882
- gap: 4px;
883
- min-width: 240px;
984
+ gap: 2px;
884
985
  }
885
986
 
886
- .deploy-name strong {
887
- font-size: 15px;
987
+ .primary-text {
988
+ color: var(--text);
989
+ font-weight: 700;
888
990
  }
889
991
 
890
- .mono,
891
- .deploy-name small {
892
- color: var(--muted);
893
- font-family: "SFMono-Regular", Consolas, monospace;
894
- font-size: 12px;
992
+ .name-cell {
993
+ display: grid;
994
+ gap: 3px;
995
+ min-width: 160px;
895
996
  }
896
997
 
897
- .deploy-name small {
998
+ .name-cell small {
898
999
  white-space: normal;
899
1000
  }
900
1001
 
1002
+ .user-cell {
1003
+ display: grid;
1004
+ gap: 3px;
1005
+ min-width: 150px;
1006
+ }
1007
+
1008
+ .name-line {
1009
+ align-items: center;
1010
+ display: flex;
1011
+ gap: 6px;
1012
+ }
1013
+
1014
+ .github-link {
1015
+ align-items: center;
1016
+ color: var(--muted);
1017
+ display: inline-flex;
1018
+ height: 18px;
1019
+ justify-content: center;
1020
+ width: 18px;
1021
+ }
1022
+
1023
+ .github-link:hover {
1024
+ color: var(--accent);
1025
+ }
1026
+
1027
+ .github-link svg {
1028
+ height: 15px;
1029
+ width: 15px;
1030
+ }
1031
+
901
1032
  .pill {
902
1033
  border: 1px solid var(--line-strong);
903
1034
  border-radius: 999px;
904
1035
  display: inline-flex;
905
1036
  font-family: "SFMono-Regular", Consolas, monospace;
906
- font-size: 12px;
907
- padding: 3px 8px;
1037
+ font-size: 11px;
1038
+ line-height: 1;
1039
+ padding: 4px 7px;
908
1040
  }
909
1041
 
910
1042
  .pill.active {
911
- border-color: rgba(154, 214, 107, 0.75);
1043
+ border-color: #65758a;
912
1044
  color: var(--accent);
913
1045
  }
914
1046
 
915
1047
  .pill.expired {
916
- border-color: rgba(238, 125, 113, 0.75);
1048
+ border-color: rgba(240, 128, 128, 0.75);
917
1049
  color: var(--bad);
918
1050
  }
919
1051
 
920
1052
  .pill.terminated {
921
- border-color: rgba(233, 188, 93, 0.75);
1053
+ border-color: rgba(224, 181, 95, 0.75);
922
1054
  color: var(--warn);
923
1055
  }
924
1056
 
925
- .status-cell {
926
- align-items: center;
927
- display: flex;
928
- gap: 8px;
929
- }
930
-
931
1057
  .row-action {
932
- background: transparent;
933
- border: 1px solid rgba(238, 125, 113, 0.8);
934
- border-radius: 6px;
935
- color: var(--bad);
936
- cursor: pointer;
937
- font-family: "SFMono-Regular", Consolas, monospace;
938
- font-size: 12px;
939
- min-height: 32px;
940
- padding: 0 10px;
1058
+ min-height: 30px;
1059
+ padding: 0 9px;
941
1060
  }
942
1061
 
943
- .row-action:disabled {
944
- border-color: var(--line);
945
- color: var(--muted);
946
- cursor: default;
947
- opacity: 0.62;
1062
+ .icon-button {
1063
+ height: 30px;
1064
+ padding: 0;
1065
+ width: 30px;
948
1066
  }
949
1067
 
950
- .row-action.neutral {
951
- border-color: var(--line-strong);
952
- color: var(--text);
1068
+ .icon-button svg {
1069
+ height: 15px;
1070
+ width: 15px;
953
1071
  }
954
1072
 
955
- .row-action.primary {
956
- border-color: rgba(154, 214, 107, 0.8);
957
- color: var(--accent);
1073
+ .icon-button.danger {
1074
+ border-color: rgba(240, 128, 128, 0.62);
1075
+ color: var(--bad);
958
1076
  }
959
1077
 
960
- .user-name {
1078
+ .limit-cell {
961
1079
  display: grid;
962
1080
  gap: 4px;
963
- min-width: 220px;
1081
+ min-width: 112px;
964
1082
  }
965
1083
 
966
- .user-name strong {
967
- font-size: 15px;
1084
+ .limit-combo {
1085
+ display: grid;
1086
+ gap: 4px;
1087
+ min-width: 220px;
968
1088
  }
969
1089
 
970
- .limit-cell {
1090
+ .limit-row {
1091
+ align-items: center;
971
1092
  display: grid;
972
1093
  gap: 6px;
973
- min-width: 150px;
1094
+ grid-template-columns: 16px 86px 1fr;
974
1095
  }
975
1096
 
976
- .limit-cell input {
977
- min-height: 34px;
978
- padding: 0 10px;
1097
+ .limit-row input {
1098
+ min-height: 28px;
1099
+ padding: 0 7px;
979
1100
  }
980
1101
 
981
- .limit-form {
982
- align-items: end;
983
- display: grid;
984
- gap: 8px;
985
- grid-template-columns: minmax(180px, 1fr) 132px 132px auto;
986
- width: min(100%, 720px);
1102
+ .usage-cell {
1103
+ min-width: 128px;
987
1104
  }
988
1105
 
989
- .limit-form .field {
990
- gap: 5px;
1106
+ input {
1107
+ background: #10131a;
1108
+ border: 1px solid var(--line-strong);
1109
+ border-radius: 6px;
1110
+ color: var(--text);
1111
+ min-height: 30px;
1112
+ outline: none;
1113
+ padding: 0 9px;
1114
+ width: 100%;
991
1115
  }
992
1116
 
993
- .limit-form input {
994
- min-height: 36px;
1117
+ input:focus {
1118
+ border-color: #69778a;
995
1119
  }
996
1120
 
997
- .limit-actions {
998
- display: flex;
999
- gap: 8px;
1121
+ input[type="checkbox"] {
1122
+ height: 16px;
1123
+ min-height: 0;
1124
+ width: 16px;
1000
1125
  }
1001
1126
 
1002
- th:nth-child(8),
1003
- td:nth-child(8),
1004
- th:nth-child(9),
1005
- td:nth-child(9) {
1006
- min-width: 124px;
1127
+ .checkbox {
1128
+ align-items: center;
1129
+ color: var(--text);
1130
+ display: inline-flex;
1131
+ gap: 7px;
1132
+ min-height: 30px;
1007
1133
  }
1008
1134
 
1009
- .login {
1010
- align-items: center;
1011
- display: flex;
1012
- min-height: calc(100vh - 56px);
1135
+ .limit-form,
1136
+ .detail-form {
1137
+ align-items: end;
1138
+ display: grid;
1139
+ gap: 8px;
1013
1140
  }
1014
1141
 
1015
- .login-panel {
1016
- background: rgba(24, 24, 22, 0.98);
1017
- border: 1px solid var(--line);
1018
- border-radius: 8px;
1019
- max-width: 460px;
1020
- padding: 24px;
1021
- width: 100%;
1142
+ .limit-form {
1143
+ grid-template-columns: minmax(160px, 0.7fr) 100px 100px auto auto;
1144
+ width: min(100%, 650px);
1022
1145
  }
1023
1146
 
1024
- .login-panel h1 {
1025
- font-size: 34px;
1026
- margin-bottom: 20px;
1147
+ .detail-form {
1148
+ grid-template-columns: auto 128px 128px auto auto;
1149
+ width: min(100%, 700px);
1027
1150
  }
1028
1151
 
1029
1152
  .field {
1030
1153
  display: grid;
1031
- gap: 8px;
1154
+ gap: 4px;
1032
1155
  }
1033
1156
 
1034
- label {
1035
- color: var(--muted);
1036
- font-family: "SFMono-Regular", Consolas, monospace;
1037
- font-size: 12px;
1157
+ .pager {
1158
+ border-top: 1px solid var(--line);
1159
+ justify-content: flex-end;
1160
+ min-height: 40px;
1161
+ padding: 7px 10px;
1038
1162
  }
1039
1163
 
1040
- input {
1041
- background: #0c0d0b;
1042
- border: 1px solid var(--line-strong);
1164
+ .pager button {
1165
+ min-height: 28px;
1166
+ }
1167
+
1168
+ .login {
1169
+ align-items: center;
1170
+ display: flex;
1171
+ min-height: calc(100vh - 36px);
1172
+ }
1173
+
1174
+ .login-panel {
1175
+ background: var(--panel);
1176
+ border: 1px solid var(--line);
1043
1177
  border-radius: 6px;
1044
- color: var(--text);
1045
- min-height: 44px;
1046
- outline: none;
1047
- padding: 0 12px;
1178
+ max-width: 420px;
1179
+ padding: 22px;
1048
1180
  width: 100%;
1049
1181
  }
1050
1182
 
1051
- input:focus {
1052
- border-color: var(--accent);
1183
+ .login-panel h1 {
1184
+ margin-bottom: 18px;
1053
1185
  }
1054
1186
 
1055
1187
  .form-row {
1056
1188
  display: flex;
1057
- gap: 10px;
1058
- margin-top: 16px;
1189
+ gap: 8px;
1190
+ margin-top: 14px;
1059
1191
  }
1060
1192
 
1061
1193
  .error {
@@ -1067,13 +1199,14 @@ function adminHtml() {
1067
1199
  display: none;
1068
1200
  }
1069
1201
 
1070
- @media (max-width: 860px) {
1202
+ @media (max-width: 960px) {
1071
1203
  .shell {
1072
- padding: 18px;
1204
+ padding: 14px;
1073
1205
  }
1074
1206
 
1075
1207
  .topbar,
1076
- .panel-head {
1208
+ .panel-head,
1209
+ .panel-head.tight {
1077
1210
  align-items: flex-start;
1078
1211
  flex-direction: column;
1079
1212
  }
@@ -1082,19 +1215,14 @@ function adminHtml() {
1082
1215
  justify-content: flex-start;
1083
1216
  }
1084
1217
 
1085
- .limit-form {
1086
- grid-template-columns: 1fr;
1087
- width: 100%;
1088
- }
1089
-
1090
1218
  .metrics {
1091
1219
  grid-template-columns: repeat(2, minmax(0, 1fr));
1092
1220
  }
1093
1221
 
1094
- .metric,
1095
- .metric:first-child,
1096
- .metric:last-child {
1097
- border-radius: 8px;
1222
+ .limit-form,
1223
+ .detail-form {
1224
+ grid-template-columns: 1fr;
1225
+ width: 100%;
1098
1226
  }
1099
1227
  }
1100
1228
  </style>
@@ -1123,12 +1251,18 @@ function adminHtml() {
1123
1251
  <h1>Deploy monitor</h1>
1124
1252
  </div>
1125
1253
  <div class="actions">
1254
+ <span class="status-line" id="status-line">Loading</span>
1126
1255
  <button class="button secondary" id="refresh-button" type="button">Refresh</button>
1127
1256
  <button class="button secondary" id="logout-button" type="button">Lock</button>
1128
1257
  </div>
1129
1258
  </header>
1130
1259
 
1131
- <section class="metrics" aria-label="Deploy resource totals">
1260
+ <nav class="tabs" aria-label="Admin sections">
1261
+ <button class="tab" id="deployments-tab" type="button">Deployments</button>
1262
+ <button class="tab" id="users-tab" type="button">Users</button>
1263
+ </nav>
1264
+
1265
+ <section class="metrics" aria-label="Resource totals">
1132
1266
  <div class="metric"><span>deploys</span><strong id="metric-deploys">0</strong></div>
1133
1267
  <div class="metric"><span>artifact bytes</span><strong id="metric-artifacts">0 B</strong></div>
1134
1268
  <div class="metric"><span>state bytes</span><strong id="metric-state">0 B</strong></div>
@@ -1137,35 +1271,40 @@ function adminHtml() {
1137
1271
  <div class="metric"><span>mutations today</span><strong id="metric-mutations">0</strong></div>
1138
1272
  </section>
1139
1273
 
1140
- <section class="panel">
1274
+ <section class="panel" id="deployments-view">
1141
1275
  <div class="panel-head">
1142
- <h2>Deploy resource table</h2>
1143
- <div class="status-line" id="status-line">Loading</div>
1276
+ <div class="panel-title">
1277
+ <h2>Deployments</h2>
1278
+ <span class="mono" id="deployments-count">0 deploys</span>
1279
+ </div>
1144
1280
  </div>
1145
1281
  <div class="table-wrap">
1146
- <table>
1282
+ <table class="deploy-table">
1147
1283
  <thead>
1148
1284
  <tr>
1149
1285
  <th>Deploy</th>
1286
+ <th>User</th>
1150
1287
  <th>Status</th>
1151
1288
  <th>Created</th>
1152
1289
  <th>Expires</th>
1153
- <th>Artifact</th>
1154
- <th>State</th>
1155
- <th>Logs</th>
1156
- <th>Requests</th>
1157
- <th>Mutations</th>
1158
- <th>Connections</th>
1290
+ <th>Usage</th>
1291
+ <th>Storage</th>
1292
+ <th>Conn</th>
1293
+ <th></th>
1159
1294
  </tr>
1160
1295
  </thead>
1161
1296
  <tbody id="deploy-rows"></tbody>
1162
1297
  </table>
1163
1298
  </div>
1299
+ <div class="pager" id="deploy-pager"></div>
1164
1300
  </section>
1165
1301
 
1166
- <section class="panel">
1167
- <div class="panel-head">
1168
- <h2>User limit overrides</h2>
1302
+ <section class="panel hidden" id="users-view">
1303
+ <div class="panel-head tight">
1304
+ <div class="panel-title">
1305
+ <h2>User limit overrides</h2>
1306
+ <span class="mono" id="users-count">0 users</span>
1307
+ </div>
1169
1308
  <form class="limit-form" id="new-user-limit-form">
1170
1309
  <div class="field">
1171
1310
  <label for="new-user-id">User id</label>
@@ -1179,40 +1318,121 @@ function adminHtml() {
1179
1318
  <label for="new-user-mutations">Mutations</label>
1180
1319
  <input id="new-user-mutations" name="mutationsPerDay" inputmode="numeric" min="1" step="1" type="number" />
1181
1320
  </div>
1321
+ <label class="checkbox" for="new-user-boost">
1322
+ <input id="new-user-boost" name="boost" type="checkbox" aria-label="Boost new user" />
1323
+ Boost
1324
+ </label>
1182
1325
  <button class="button secondary" type="submit">Save</button>
1183
1326
  </form>
1184
1327
  </div>
1185
1328
  <div class="table-wrap">
1186
- <table>
1329
+ <table class="user-table">
1187
1330
  <thead>
1188
1331
  <tr>
1189
1332
  <th>User</th>
1333
+ <th>Created</th>
1334
+ <th>Boost</th>
1190
1335
  <th>Deploys</th>
1191
- <th>Requests today</th>
1192
- <th>Mutations today</th>
1193
- <th>Request limit</th>
1194
- <th>Mutation limit</th>
1336
+ <th>Usage</th>
1337
+ <th>Limits</th>
1195
1338
  <th>Actions</th>
1196
1339
  </tr>
1197
1340
  </thead>
1198
1341
  <tbody id="user-rows"></tbody>
1199
1342
  </table>
1200
1343
  </div>
1344
+ <div class="pager" id="user-pager"></div>
1345
+ </section>
1346
+
1347
+ <section class="hidden" id="user-detail-view">
1348
+ <section class="panel">
1349
+ <div class="panel-head tight">
1350
+ <div class="panel-title">
1351
+ <a class="mono" data-route="users" href="/admin/users">Back to users</a>
1352
+ <div class="name-line">
1353
+ <h2 id="detail-title">User</h2>
1354
+ <span id="detail-github"></span>
1355
+ </div>
1356
+ <span class="mono" id="detail-meta"></span>
1357
+ </div>
1358
+ <form class="detail-form" id="user-detail-form">
1359
+ <label class="checkbox" for="detail-boost">
1360
+ <input id="detail-boost" name="boost" type="checkbox" aria-label="Boost selected user" />
1361
+ Boost
1362
+ </label>
1363
+ <div class="field">
1364
+ <label for="detail-requests">Requests</label>
1365
+ <input id="detail-requests" name="requestsPerDay" inputmode="numeric" min="1" step="1" type="number" />
1366
+ </div>
1367
+ <div class="field">
1368
+ <label for="detail-mutations">Mutations</label>
1369
+ <input id="detail-mutations" name="mutationsPerDay" inputmode="numeric" min="1" step="1" type="number" />
1370
+ </div>
1371
+ <button class="button secondary" type="submit">Save</button>
1372
+ <button class="row-action" id="detail-clear-button" type="button">Clear</button>
1373
+ </form>
1374
+ </div>
1375
+ </section>
1376
+
1377
+ <section class="panel">
1378
+ <div class="panel-head">
1379
+ <div class="panel-title">
1380
+ <h2>Deployments</h2>
1381
+ <span class="mono" id="user-deployments-count">0 deploys</span>
1382
+ </div>
1383
+ </div>
1384
+ <div class="table-wrap">
1385
+ <table class="deploy-table">
1386
+ <thead>
1387
+ <tr>
1388
+ <th>Deploy</th>
1389
+ <th>User</th>
1390
+ <th>Status</th>
1391
+ <th>Created</th>
1392
+ <th>Expires</th>
1393
+ <th>Usage</th>
1394
+ <th>Storage</th>
1395
+ <th>Conn</th>
1396
+ <th></th>
1397
+ </tr>
1398
+ </thead>
1399
+ <tbody id="user-deploy-rows"></tbody>
1400
+ </table>
1401
+ </div>
1402
+ <div class="pager" id="user-deploy-pager"></div>
1403
+ </section>
1201
1404
  </section>
1202
1405
  </section>
1203
1406
  </main>
1204
1407
 
1205
1408
  <script>
1409
+ const pageSize = 20;
1206
1410
  const loginView = document.getElementById("login-view");
1207
1411
  const dashboardView = document.getElementById("dashboard-view");
1208
1412
  const loginForm = document.getElementById("login-form");
1209
1413
  const loginError = document.getElementById("login-error");
1414
+ const statusLine = document.getElementById("status-line");
1415
+ const deploymentsView = document.getElementById("deployments-view");
1416
+ const usersView = document.getElementById("users-view");
1417
+ const userDetailView = document.getElementById("user-detail-view");
1210
1418
  const rows = document.getElementById("deploy-rows");
1211
1419
  const userRows = document.getElementById("user-rows");
1420
+ const userDeployRows = document.getElementById("user-deploy-rows");
1421
+ const deployPager = document.getElementById("deploy-pager");
1422
+ const userPager = document.getElementById("user-pager");
1423
+ const userDeployPager = document.getElementById("user-deploy-pager");
1212
1424
  const newUserLimitForm = document.getElementById("new-user-limit-form");
1213
- const statusLine = document.getElementById("status-line");
1214
- let terminatingDeployId = null;
1215
- let savingUserId = null;
1425
+ const userDetailForm = document.getElementById("user-detail-form");
1426
+ const state = {
1427
+ deployPage: 1,
1428
+ loadingUserId: null,
1429
+ savingUserId: null,
1430
+ summary: null,
1431
+ terminatingDeployId: null,
1432
+ userDeployPage: 1,
1433
+ userDetail: null,
1434
+ userPage: 1
1435
+ };
1216
1436
 
1217
1437
  function show(view) {
1218
1438
  loginView.classList.toggle("hidden", view !== "login");
@@ -1239,21 +1459,104 @@ function adminHtml() {
1239
1459
  return "unknown";
1240
1460
  }
1241
1461
  return new Intl.DateTimeFormat(undefined, {
1242
- dateStyle: "medium",
1462
+ dateStyle: "short",
1243
1463
  timeStyle: "short"
1244
1464
  }).format(new Date(value));
1245
1465
  }
1246
1466
 
1467
+ function plural(count, label) {
1468
+ return formatNumber(count) + " " + label + (count === 1 ? "" : "s");
1469
+ }
1470
+
1471
+ function shortId(value) {
1472
+ return String(value || "").slice(0, 8);
1473
+ }
1474
+
1247
1475
  function setMetric(id, value) {
1248
1476
  document.getElementById(id).textContent = value;
1249
1477
  }
1250
1478
 
1479
+ function textCell(value, className) {
1480
+ const td = document.createElement("td");
1481
+ td.textContent = value;
1482
+ if (className) {
1483
+ td.className = className;
1484
+ }
1485
+ return td;
1486
+ }
1487
+
1488
+ function stackCell(lines, className) {
1489
+ const td = document.createElement("td");
1490
+ const wrap = document.createElement("div");
1491
+ wrap.className = "stack" + (className ? " " + className : "");
1492
+ for (const line of lines) {
1493
+ const item = document.createElement("span");
1494
+ item.textContent = line;
1495
+ wrap.appendChild(item);
1496
+ }
1497
+ td.appendChild(wrap);
1498
+ return td;
1499
+ }
1500
+
1501
+ function externalLink(link) {
1502
+ link.target = "_blank";
1503
+ link.rel = "noopener noreferrer";
1504
+ return link;
1505
+ }
1506
+
1507
+ function githubProfileLink(url, label) {
1508
+ if (!url) {
1509
+ return null;
1510
+ }
1511
+ const link = document.createElement("a");
1512
+ link.className = "github-link";
1513
+ link.href = url;
1514
+ link.title = "Open GitHub profile";
1515
+ link.setAttribute("aria-label", "Open GitHub profile for " + label);
1516
+ link.innerHTML = '<svg viewBox="0 0 16 16" aria-hidden="true" fill="currentColor"><path d="M8 0C3.58 0 0 3.67 0 8.19c0 3.62 2.29 6.69 5.47 7.78.4.08.55-.18.55-.4 0-.2-.01-.84-.01-1.53-2.01.38-2.53-.5-2.69-.96-.09-.24-.48-.96-.82-1.16-.28-.15-.68-.53-.01-.54.63-.01 1.08.59 1.23.84.72 1.24 1.87.89 2.33.68.07-.53.28-.89.51-1.1-1.78-.21-3.64-.91-3.64-4.04 0-.89.31-1.62.82-2.19-.08-.21-.36-1.04.08-2.16 0 0 .67-.22 2.2.84A7.37 7.37 0 0 1 8 3.98c.68 0 1.36.09 2 .27 1.53-1.06 2.2-.84 2.2-.84.44 1.12.16 1.95.08 2.16.51.57.82 1.3.82 2.19 0 3.14-1.87 3.83-3.65 4.04.29.26.54.75.54 1.52 0 1.1-.01 1.98-.01 2.25 0 .22.15.48.55.4A8.06 8.06 0 0 0 16 8.19C16 3.67 12.42 0 8 0Z"/></svg>';
1517
+ return externalLink(link);
1518
+ }
1519
+
1520
+ function routeFor(view, userId) {
1521
+ if (view === "user") {
1522
+ return "/admin/users/" + encodeURIComponent(userId);
1523
+ }
1524
+ if (view === "users") {
1525
+ return "/admin/users";
1526
+ }
1527
+ return "/admin/deployments";
1528
+ }
1529
+
1530
+ function parseRoute() {
1531
+ const userMatch = window.location.pathname.match(/^\\/admin\\/users\\/(.+)$/);
1532
+ if (userMatch) {
1533
+ return { userId: decodeURIComponent(userMatch[1]), view: "user" };
1534
+ }
1535
+ if (window.location.pathname === "/admin/users") {
1536
+ return { view: "users" };
1537
+ }
1538
+ if (window.location.pathname === "/admin/deployments") {
1539
+ return { view: "deployments" };
1540
+ }
1541
+ if (new URLSearchParams(window.location.search).get("tab") === "deployments") {
1542
+ return { view: "deployments" };
1543
+ }
1544
+ if (new URLSearchParams(window.location.search).get("tab") === "users") {
1545
+ return { view: "users" };
1546
+ }
1547
+ return { view: "users" };
1548
+ }
1549
+
1550
+ function navigate(view, userId) {
1551
+ history.pushState({}, "", routeFor(view, userId));
1552
+ renderCurrent();
1553
+ }
1554
+
1251
1555
  function parseLimitValue(value, name) {
1252
1556
  const trimmed = String(value || "").trim();
1253
1557
  if (!trimmed) {
1254
1558
  return null;
1255
1559
  }
1256
-
1257
1560
  const parsed = Number(trimmed);
1258
1561
  if (!Number.isSafeInteger(parsed) || parsed < 1) {
1259
1562
  throw new Error(name + " must be a positive integer.");
@@ -1261,87 +1564,241 @@ function adminHtml() {
1261
1564
  return parsed;
1262
1565
  }
1263
1566
 
1264
- function textCell(value, className) {
1265
- const td = document.createElement("td");
1266
- td.textContent = value;
1267
- if (className) {
1268
- td.className = className;
1567
+ function pageCount(total) {
1568
+ return Math.max(1, Math.ceil(total / pageSize));
1569
+ }
1570
+
1571
+ function clampPage(page, total) {
1572
+ return Math.min(Math.max(1, page), pageCount(total));
1573
+ }
1574
+
1575
+ function pageItems(items, page) {
1576
+ const currentPage = clampPage(page, items.length);
1577
+ const start = (currentPage - 1) * pageSize;
1578
+ return {
1579
+ currentPage,
1580
+ items: items.slice(start, start + pageSize),
1581
+ start
1582
+ };
1583
+ }
1584
+
1585
+ function renderPager(container, total, page, onPage) {
1586
+ container.replaceChildren();
1587
+ if (total <= pageSize) {
1588
+ return;
1269
1589
  }
1270
- return td;
1590
+ const currentPage = clampPage(page, total);
1591
+ const start = (currentPage - 1) * pageSize + 1;
1592
+ const end = Math.min(total, currentPage * pageSize);
1593
+ const label = document.createElement("span");
1594
+ const previous = document.createElement("button");
1595
+ const next = document.createElement("button");
1596
+ label.textContent = formatNumber(start) + "-" + formatNumber(end) + " of " + formatNumber(total);
1597
+ previous.className = "row-action";
1598
+ previous.type = "button";
1599
+ previous.textContent = "Prev";
1600
+ previous.disabled = currentPage <= 1;
1601
+ previous.addEventListener("click", () => onPage(currentPage - 1));
1602
+ next.className = "row-action";
1603
+ next.type = "button";
1604
+ next.textContent = "Next";
1605
+ next.disabled = currentPage >= pageCount(total);
1606
+ next.addEventListener("click", () => onPage(currentPage + 1));
1607
+ container.appendChild(label);
1608
+ container.appendChild(previous);
1609
+ container.appendChild(next);
1271
1610
  }
1272
1611
 
1273
1612
  function deployCell(deploy) {
1274
1613
  const td = document.createElement("td");
1275
1614
  const wrap = document.createElement("div");
1276
- const name = document.createElement("strong");
1277
- const link = document.createElement("a");
1615
+ const name = document.createElement("a");
1278
1616
  const id = document.createElement("small");
1279
- wrap.className = "deploy-name";
1280
- link.href = deploy.url;
1281
- link.textContent = deploy.name || deploy.slug;
1282
- name.appendChild(link);
1283
- id.textContent = deploy.id + " / " + deploy.slug;
1617
+ wrap.className = "name-cell";
1618
+ name.className = "primary-text";
1619
+ name.href = deploy.url;
1620
+ name.textContent = deploy.name || deploy.slug;
1621
+ externalLink(name);
1622
+ id.textContent = shortId(deploy.id) + " / " + deploy.slug;
1284
1623
  wrap.appendChild(name);
1285
1624
  wrap.appendChild(id);
1286
1625
  td.appendChild(wrap);
1287
1626
  return td;
1288
1627
  }
1289
1628
 
1290
- function statusCell(deploy) {
1629
+ function deployUserCell(deploy) {
1291
1630
  const td = document.createElement("td");
1292
1631
  const wrap = document.createElement("div");
1632
+ wrap.className = "user-cell";
1633
+ if (!deploy.ownerId) {
1634
+ const anon = document.createElement("span");
1635
+ anon.className = "mono";
1636
+ anon.textContent = "anonymous";
1637
+ wrap.appendChild(anon);
1638
+ td.appendChild(wrap);
1639
+ return td;
1640
+ }
1641
+
1642
+ const line = document.createElement("div");
1643
+ const link = document.createElement("a");
1644
+ const id = document.createElement("small");
1645
+ const label = deploy.owner?.login || deploy.owner?.displayName || deploy.ownerId;
1646
+ line.className = "name-line";
1647
+ link.className = "primary-text";
1648
+ link.dataset.route = "user";
1649
+ link.href = routeFor("user", deploy.ownerId);
1650
+ link.textContent = label;
1651
+ id.textContent = deploy.ownerId;
1652
+ line.appendChild(link);
1653
+ const github = githubProfileLink(deploy.owner?.url, label);
1654
+ if (github) {
1655
+ line.appendChild(github);
1656
+ }
1657
+ wrap.appendChild(line);
1658
+ wrap.appendChild(id);
1659
+ td.appendChild(wrap);
1660
+ return td;
1661
+ }
1662
+
1663
+ function statusCell(deploy) {
1664
+ const td = document.createElement("td");
1293
1665
  const pill = document.createElement("span");
1294
- wrap.className = "status-cell";
1295
1666
  pill.className = "pill " + deploy.status;
1296
1667
  pill.textContent = deploy.status;
1297
- wrap.appendChild(pill);
1298
-
1299
- if (deploy.status === "active") {
1300
- const button = document.createElement("button");
1301
- button.className = "row-action";
1302
- button.type = "button";
1303
- button.textContent = terminatingDeployId === deploy.id ? "Terminating" : "Terminate";
1304
- button.disabled = terminatingDeployId === deploy.id;
1305
- button.addEventListener("click", () => {
1306
- void terminateDeploy(deploy).catch((error) => {
1307
- statusLine.textContent = error instanceof Error ? error.message : String(error);
1308
- });
1668
+ td.appendChild(pill);
1669
+ return td;
1670
+ }
1671
+
1672
+ function usageCell(deploy) {
1673
+ return stackCell(
1674
+ [
1675
+ "R " + formatNumber(deploy.requestsToday) + " / " + formatNumber(deploy.limits.requestsPerDay),
1676
+ "M " + formatNumber(deploy.mutationsToday) + " / " + formatNumber(deploy.limits.mutationsPerDay)
1677
+ ],
1678
+ "mono"
1679
+ );
1680
+ }
1681
+
1682
+ function storageCell(deploy) {
1683
+ return stackCell(
1684
+ [
1685
+ formatNumber(deploy.stateRows) + " rows / " + formatBytes(deploy.stateBytes),
1686
+ "artifact " + formatBytes(deploy.artifactBytes)
1687
+ ],
1688
+ "mono"
1689
+ );
1690
+ }
1691
+
1692
+ function actionCell(deploy) {
1693
+ const td = document.createElement("td");
1694
+ if (deploy.status !== "active") {
1695
+ return td;
1696
+ }
1697
+ const button = document.createElement("button");
1698
+ button.className = "icon-button danger";
1699
+ button.type = "button";
1700
+ button.title = "Terminate deploy";
1701
+ button.setAttribute("aria-label", "Terminate deploy " + deploy.id);
1702
+ button.disabled = state.terminatingDeployId === deploy.id;
1703
+ button.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>';
1704
+ button.addEventListener("click", () => {
1705
+ void terminateDeploy(deploy).catch((error) => {
1706
+ statusLine.textContent = error instanceof Error ? error.message : String(error);
1309
1707
  });
1310
- wrap.appendChild(button);
1708
+ });
1709
+ td.appendChild(button);
1710
+ return td;
1711
+ }
1712
+
1713
+ function renderDeployRows(tbody, deploys, emptyText) {
1714
+ tbody.replaceChildren();
1715
+ if (!deploys.length) {
1716
+ const tr = document.createElement("tr");
1717
+ const td = textCell(emptyText, "mono");
1718
+ td.colSpan = 9;
1719
+ tr.appendChild(td);
1720
+ tbody.appendChild(tr);
1721
+ return;
1311
1722
  }
1312
1723
 
1313
- td.appendChild(wrap);
1314
- return td;
1724
+ for (const deploy of deploys) {
1725
+ const tr = document.createElement("tr");
1726
+ tr.appendChild(deployCell(deploy));
1727
+ tr.appendChild(deployUserCell(deploy));
1728
+ tr.appendChild(statusCell(deploy));
1729
+ tr.appendChild(textCell(formatTime(deploy.createdAt), "mono"));
1730
+ tr.appendChild(textCell(formatTime(deploy.expiresAt), "mono"));
1731
+ tr.appendChild(usageCell(deploy));
1732
+ tr.appendChild(storageCell(deploy));
1733
+ tr.appendChild(textCell(formatNumber(deploy.connections), "mono"));
1734
+ tr.appendChild(actionCell(deploy));
1735
+ tbody.appendChild(tr);
1736
+ }
1315
1737
  }
1316
1738
 
1317
1739
  function userCell(user) {
1318
1740
  const td = document.createElement("td");
1319
1741
  const wrap = document.createElement("div");
1320
- const name = document.createElement("strong");
1742
+ const line = document.createElement("div");
1743
+ const name = document.createElement("a");
1321
1744
  const id = document.createElement("small");
1322
- wrap.className = "user-name";
1323
- if (user.url) {
1324
- const link = document.createElement("a");
1325
- link.href = user.url;
1326
- link.textContent = user.login || user.displayName || user.id;
1327
- name.appendChild(link);
1328
- } else {
1329
- name.textContent = user.login || user.displayName || user.id;
1330
- }
1331
- id.className = "mono";
1745
+ const label = user.login || user.displayName || user.id;
1746
+ wrap.className = "user-cell";
1747
+ line.className = "name-line";
1748
+ name.className = "primary-text";
1749
+ name.dataset.route = "user";
1750
+ name.href = routeFor("user", user.id);
1751
+ name.textContent = label;
1332
1752
  id.textContent = user.id;
1333
- wrap.appendChild(name);
1753
+ line.appendChild(name);
1754
+ const github = githubProfileLink(user.url, label);
1755
+ if (github) {
1756
+ line.appendChild(github);
1757
+ }
1758
+ wrap.appendChild(line);
1334
1759
  wrap.appendChild(id);
1335
1760
  td.appendChild(wrap);
1336
1761
  return td;
1337
1762
  }
1338
1763
 
1339
- function limitInputCell(user, key, label) {
1764
+ function boostCell(user) {
1340
1765
  const td = document.createElement("td");
1341
- const wrap = document.createElement("div");
1766
+ const label = document.createElement("label");
1767
+ const input = document.createElement("input");
1768
+ label.className = "checkbox";
1769
+ input.type = "checkbox";
1770
+ input.dataset.key = "boost";
1771
+ input.checked = Boolean(user.boost);
1772
+ input.setAttribute("aria-label", "Boost " + user.id);
1773
+ input.addEventListener("change", () => {
1774
+ void saveUserSettings(user.id, input.closest("tr")).catch((error) => {
1775
+ statusLine.textContent = error instanceof Error ? error.message : String(error);
1776
+ });
1777
+ });
1778
+ label.appendChild(input);
1779
+ label.appendChild(document.createTextNode("20x"));
1780
+ td.appendChild(label);
1781
+ return td;
1782
+ }
1783
+
1784
+ function userUsageCell(user) {
1785
+ return stackCell(
1786
+ [
1787
+ "R " + formatNumber(user.requestsToday) + " / " + formatNumber(user.limits.requestsPerDay),
1788
+ "M " + formatNumber(user.mutationsToday) + " / " + formatNumber(user.limits.mutationsPerDay)
1789
+ ],
1790
+ "mono usage-cell"
1791
+ );
1792
+ }
1793
+
1794
+ function limitControl(user, key, label, shortLabel) {
1795
+ const row = document.createElement("div");
1796
+ const prefix = document.createElement("span");
1342
1797
  const input = document.createElement("input");
1343
1798
  const effective = document.createElement("small");
1344
- wrap.className = "limit-cell";
1799
+ row.className = "limit-row";
1800
+ prefix.className = "mono";
1801
+ prefix.textContent = shortLabel;
1345
1802
  input.inputMode = "numeric";
1346
1803
  input.min = "1";
1347
1804
  input.step = "1";
@@ -1350,10 +1807,19 @@ function adminHtml() {
1350
1807
  input.value = user.limitOverrides[key] ? String(user.limitOverrides[key]) : "";
1351
1808
  input.placeholder = String(user.defaultLimits[key]);
1352
1809
  input.setAttribute("aria-label", label + " override for " + user.id);
1353
- effective.className = "mono";
1354
- effective.textContent = "effective " + formatNumber(user.limits[key]);
1355
- wrap.appendChild(input);
1356
- wrap.appendChild(effective);
1810
+ effective.textContent = formatNumber(user.limits[key]);
1811
+ row.appendChild(prefix);
1812
+ row.appendChild(input);
1813
+ row.appendChild(effective);
1814
+ return row;
1815
+ }
1816
+
1817
+ function userLimitsCell(user) {
1818
+ const td = document.createElement("td");
1819
+ const wrap = document.createElement("div");
1820
+ wrap.className = "limit-combo";
1821
+ wrap.appendChild(limitControl(user, "requestsPerDay", "Request limit", "R"));
1822
+ wrap.appendChild(limitControl(user, "mutationsPerDay", "Mutation limit", "M"));
1357
1823
  td.appendChild(wrap);
1358
1824
  return td;
1359
1825
  }
@@ -1364,21 +1830,24 @@ function adminHtml() {
1364
1830
  const save = document.createElement("button");
1365
1831
  const clear = document.createElement("button");
1366
1832
  wrap.className = "limit-actions";
1367
- save.className = "row-action primary";
1833
+ save.className = "row-action";
1368
1834
  save.type = "button";
1369
- save.textContent = savingUserId === user.id ? "Saving" : "Save";
1370
- save.disabled = savingUserId === user.id;
1835
+ save.textContent = state.savingUserId === user.id ? "Saving" : "Save";
1836
+ save.disabled = state.savingUserId === user.id;
1371
1837
  save.addEventListener("click", () => {
1372
- void saveUserLimits(user.id, save.closest("tr")).catch((error) => {
1838
+ void saveUserSettings(user.id, save.closest("tr")).catch((error) => {
1373
1839
  statusLine.textContent = error instanceof Error ? error.message : String(error);
1374
1840
  });
1375
1841
  });
1376
- clear.className = "row-action neutral";
1842
+ clear.className = "row-action";
1377
1843
  clear.type = "button";
1378
1844
  clear.textContent = "Clear";
1379
- clear.disabled = savingUserId === user.id;
1845
+ clear.disabled = state.savingUserId === user.id;
1380
1846
  clear.addEventListener("click", () => {
1381
- void saveUserLimits(user.id, null, { requestsPerDay: null, mutationsPerDay: null }).catch((error) => {
1847
+ void saveUserSettings(user.id, null, {
1848
+ boost: Boolean(user.boost),
1849
+ limits: { requestsPerDay: null, mutationsPerDay: null }
1850
+ }).catch((error) => {
1382
1851
  statusLine.textContent = error instanceof Error ? error.message : String(error);
1383
1852
  });
1384
1853
  });
@@ -1388,7 +1857,7 @@ function adminHtml() {
1388
1857
  return td;
1389
1858
  }
1390
1859
 
1391
- function render(summary) {
1860
+ function renderMetrics(summary) {
1392
1861
  setMetric("metric-deploys", formatNumber(summary.deployCount));
1393
1862
  setMetric("metric-artifacts", formatBytes(summary.totals.artifactBytes));
1394
1863
  setMetric("metric-state", formatBytes(summary.totals.stateBytes));
@@ -1396,42 +1865,128 @@ function adminHtml() {
1396
1865
  setMetric("metric-requests", formatNumber(summary.totals.requestsToday));
1397
1866
  setMetric("metric-mutations", formatNumber(summary.totals.mutationsToday));
1398
1867
  statusLine.textContent = "Updated " + formatTime(summary.generatedAt);
1399
- rows.replaceChildren();
1868
+ }
1400
1869
 
1401
- for (const deploy of summary.deploys) {
1870
+ function renderDeployments() {
1871
+ const deploys = state.summary?.deploys || [];
1872
+ state.deployPage = clampPage(state.deployPage, deploys.length);
1873
+ const page = pageItems(deploys, state.deployPage);
1874
+ document.getElementById("deployments-count").textContent = plural(deploys.length, "deploy");
1875
+ renderDeployRows(rows, page.items, "No deployments yet.");
1876
+ renderPager(deployPager, deploys.length, state.deployPage, (nextPage) => {
1877
+ state.deployPage = nextPage;
1878
+ renderDeployments();
1879
+ });
1880
+ }
1881
+
1882
+ function renderUsers() {
1883
+ const users = state.summary?.users || [];
1884
+ state.userPage = clampPage(state.userPage, users.length);
1885
+ const page = pageItems(users, state.userPage);
1886
+ document.getElementById("users-count").textContent = plural(users.length, "user");
1887
+ userRows.replaceChildren();
1888
+
1889
+ if (!page.items.length) {
1402
1890
  const tr = document.createElement("tr");
1403
- tr.appendChild(deployCell(deploy));
1404
- tr.appendChild(statusCell(deploy));
1405
- tr.appendChild(textCell(formatTime(deploy.createdAt)));
1406
- tr.appendChild(textCell(formatTime(deploy.expiresAt)));
1407
- tr.appendChild(textCell(formatBytes(deploy.artifactBytes), "mono"));
1408
- tr.appendChild(textCell(formatBytes(deploy.stateBytes) + " / " + formatNumber(deploy.stateRows) + " rows", "mono"));
1409
- tr.appendChild(textCell(formatBytes(deploy.logBytes) + " / " + formatNumber(deploy.logEntries) + " entries", "mono"));
1410
- tr.appendChild(textCell(formatNumber(deploy.requestsToday) + " / " + formatNumber(deploy.limits.requestsPerDay), "mono"));
1411
- tr.appendChild(textCell(formatNumber(deploy.mutationsToday) + " / " + formatNumber(deploy.limits.mutationsPerDay), "mono"));
1412
- tr.appendChild(textCell(formatNumber(deploy.connections), "mono"));
1413
- rows.appendChild(tr);
1891
+ const td = textCell("No claimed users or manual overrides yet.", "mono");
1892
+ td.colSpan = 7;
1893
+ tr.appendChild(td);
1894
+ userRows.appendChild(tr);
1414
1895
  }
1415
1896
 
1416
- userRows.replaceChildren();
1417
- for (const user of summary.users || []) {
1897
+ for (const user of page.items) {
1418
1898
  const tr = document.createElement("tr");
1419
1899
  tr.appendChild(userCell(user));
1900
+ tr.appendChild(textCell(formatTime(user.createdAt), "mono"));
1901
+ tr.appendChild(boostCell(user));
1420
1902
  tr.appendChild(textCell(formatNumber(user.activeDeployCount) + " / " + formatNumber(user.deployCount), "mono"));
1421
- tr.appendChild(textCell(formatNumber(user.requestsToday) + " / " + formatNumber(user.limits.requestsPerDay), "mono"));
1422
- tr.appendChild(textCell(formatNumber(user.mutationsToday) + " / " + formatNumber(user.limits.mutationsPerDay), "mono"));
1423
- tr.appendChild(limitInputCell(user, "requestsPerDay", "Request limit"));
1424
- tr.appendChild(limitInputCell(user, "mutationsPerDay", "Mutation limit"));
1903
+ tr.appendChild(userUsageCell(user));
1904
+ tr.appendChild(userLimitsCell(user));
1425
1905
  tr.appendChild(userActionCell(user));
1426
1906
  userRows.appendChild(tr);
1427
1907
  }
1428
1908
 
1429
- if (!summary.users?.length) {
1430
- const tr = document.createElement("tr");
1431
- const td = textCell("No claimed users or manual overrides yet.", "mono");
1432
- td.colSpan = 7;
1433
- tr.appendChild(td);
1434
- userRows.appendChild(tr);
1909
+ renderPager(userPager, users.length, state.userPage, (nextPage) => {
1910
+ state.userPage = nextPage;
1911
+ renderUsers();
1912
+ });
1913
+ }
1914
+
1915
+ function fillDetailForm(user) {
1916
+ userDetailForm.dataset.userId = user.id;
1917
+ document.getElementById("detail-boost").checked = Boolean(user.boost);
1918
+ document.getElementById("detail-requests").value = user.limitOverrides.requestsPerDay ? String(user.limitOverrides.requestsPerDay) : "";
1919
+ document.getElementById("detail-requests").placeholder = String(user.defaultLimits.requestsPerDay);
1920
+ document.getElementById("detail-mutations").value = user.limitOverrides.mutationsPerDay ? String(user.limitOverrides.mutationsPerDay) : "";
1921
+ document.getElementById("detail-mutations").placeholder = String(user.defaultLimits.mutationsPerDay);
1922
+ }
1923
+
1924
+ function renderUserDetail(userId) {
1925
+ const detail = state.userDetail?.user?.id === userId ? state.userDetail : null;
1926
+ if (!detail) {
1927
+ document.getElementById("detail-title").textContent = "Loading user";
1928
+ document.getElementById("detail-github").replaceChildren();
1929
+ document.getElementById("detail-meta").textContent = userId;
1930
+ renderDeployRows(userDeployRows, [], "Loading deployments.");
1931
+ userDeployPager.replaceChildren();
1932
+ if (state.loadingUserId !== userId) {
1933
+ void loadUserDetail(userId).catch((error) => {
1934
+ statusLine.textContent = error instanceof Error ? error.message : String(error);
1935
+ });
1936
+ }
1937
+ return;
1938
+ }
1939
+
1940
+ const user = detail.user;
1941
+ const deploys = detail.deploys || [];
1942
+ state.userDeployPage = clampPage(state.userDeployPage, deploys.length);
1943
+ const page = pageItems(deploys, state.userDeployPage);
1944
+ const userLabel = user.login || user.displayName || user.id;
1945
+ const detailGithub = document.getElementById("detail-github");
1946
+ document.getElementById("detail-title").textContent = userLabel;
1947
+ detailGithub.replaceChildren();
1948
+ const github = githubProfileLink(user.url, userLabel);
1949
+ if (github) {
1950
+ detailGithub.appendChild(github);
1951
+ }
1952
+ document.getElementById("detail-meta").textContent =
1953
+ user.id +
1954
+ " · created " +
1955
+ formatTime(user.createdAt) +
1956
+ " · " +
1957
+ formatNumber(user.activeDeployCount) +
1958
+ " active / " +
1959
+ formatNumber(user.deployCount) +
1960
+ " total";
1961
+ document.getElementById("user-deployments-count").textContent = plural(deploys.length, "deploy");
1962
+ fillDetailForm(user);
1963
+ renderDeployRows(userDeployRows, page.items, "This user has no deployments.");
1964
+ renderPager(userDeployPager, deploys.length, state.userDeployPage, (nextPage) => {
1965
+ state.userDeployPage = nextPage;
1966
+ renderUserDetail(user.id);
1967
+ });
1968
+ }
1969
+
1970
+ function renderCurrent() {
1971
+ const route = parseRoute();
1972
+ const isUsers = route.view === "users" || route.view === "user";
1973
+ document.getElementById("deployments-tab").classList.toggle("active", route.view === "deployments");
1974
+ document.getElementById("users-tab").classList.toggle("active", isUsers);
1975
+ deploymentsView.classList.toggle("hidden", route.view !== "deployments");
1976
+ usersView.classList.toggle("hidden", route.view !== "users");
1977
+ userDetailView.classList.toggle("hidden", route.view !== "user");
1978
+
1979
+ if (!state.summary) {
1980
+ return;
1981
+ }
1982
+
1983
+ renderMetrics(state.summary);
1984
+ if (route.view === "deployments") {
1985
+ renderDeployments();
1986
+ } else if (route.view === "users") {
1987
+ renderUsers();
1988
+ } else {
1989
+ renderUserDetail(route.userId);
1435
1990
  }
1436
1991
  }
1437
1992
 
@@ -1449,21 +2004,47 @@ function adminHtml() {
1449
2004
  if (!response.ok) {
1450
2005
  throw new Error(await response.text());
1451
2006
  }
1452
- render(await response.json());
2007
+ state.summary = await response.json();
2008
+ state.userDetail = null;
1453
2009
  show("dashboard");
2010
+ renderCurrent();
1454
2011
  }
1455
2012
 
1456
- async function saveUserLimits(userId, row, explicitLimits) {
1457
- const limits = explicitLimits || {
1458
- requestsPerDay: parseLimitValue(row.querySelector('input[data-key="requestsPerDay"]').value, "Request limit"),
1459
- mutationsPerDay: parseLimitValue(row.querySelector('input[data-key="mutationsPerDay"]').value, "Mutation limit")
2013
+ async function loadUserDetail(userId) {
2014
+ state.loadingUserId = userId;
2015
+ try {
2016
+ const response = await fetch("/admin/api/users/" + encodeURIComponent(userId));
2017
+ if (response.status === 401) {
2018
+ show("login");
2019
+ return;
2020
+ }
2021
+ if (!response.ok) {
2022
+ throw new Error(await response.text());
2023
+ }
2024
+ state.userDetail = await response.json();
2025
+ } finally {
2026
+ state.loadingUserId = null;
2027
+ }
2028
+ renderCurrent();
2029
+ }
2030
+
2031
+ function settingsFromRow(row) {
2032
+ return {
2033
+ boost: row.querySelector('input[data-key="boost"]').checked,
2034
+ limits: {
2035
+ requestsPerDay: parseLimitValue(row.querySelector('input[data-key="requestsPerDay"]').value, "Request limit"),
2036
+ mutationsPerDay: parseLimitValue(row.querySelector('input[data-key="mutationsPerDay"]').value, "Mutation limit")
2037
+ }
1460
2038
  };
2039
+ }
1461
2040
 
1462
- savingUserId = userId;
1463
- statusLine.textContent = "Saving limits for " + userId;
2041
+ async function saveUserSettings(userId, row, explicitSettings) {
2042
+ const settings = explicitSettings || settingsFromRow(row);
2043
+ state.savingUserId = userId;
2044
+ statusLine.textContent = "Saving " + userId;
1464
2045
  try {
1465
2046
  const response = await fetch("/admin/api/users/" + encodeURIComponent(userId) + "/limits", {
1466
- body: JSON.stringify({ limits }),
2047
+ body: JSON.stringify(settings),
1467
2048
  headers: { "Content-Type": "application/json" },
1468
2049
  method: "PUT"
1469
2050
  });
@@ -1477,7 +2058,7 @@ function adminHtml() {
1477
2058
  throw new Error(await response.text());
1478
2059
  }
1479
2060
  } finally {
1480
- savingUserId = null;
2061
+ state.savingUserId = null;
1481
2062
  }
1482
2063
  await loadSummary();
1483
2064
  }
@@ -1487,12 +2068,12 @@ function adminHtml() {
1487
2068
  return;
1488
2069
  }
1489
2070
 
1490
- terminatingDeployId = deploy.id;
2071
+ state.terminatingDeployId = deploy.id;
1491
2072
  statusLine.textContent = "Terminating " + deploy.id;
1492
2073
  const response = await fetch("/admin/api/deploys/" + encodeURIComponent(deploy.id) + "/terminate", {
1493
2074
  method: "POST"
1494
2075
  });
1495
- terminatingDeployId = null;
2076
+ state.terminatingDeployId = null;
1496
2077
 
1497
2078
  if (response.status === 401) {
1498
2079
  show("login");
@@ -1534,16 +2115,20 @@ function adminHtml() {
1534
2115
  }
1535
2116
 
1536
2117
  try {
2118
+ const boost = document.getElementById("new-user-boost").checked;
1537
2119
  const requestsPerDay = parseLimitValue(form.get("requestsPerDay"), "Request limit");
1538
2120
  const mutationsPerDay = parseLimitValue(form.get("mutationsPerDay"), "Mutation limit");
1539
- if (!requestsPerDay && !mutationsPerDay) {
1540
- statusLine.textContent = "Enter at least one custom limit.";
2121
+ if (!boost && !requestsPerDay && !mutationsPerDay) {
2122
+ statusLine.textContent = "Enter a custom limit or turn on boost.";
1541
2123
  return;
1542
2124
  }
1543
2125
 
1544
- await saveUserLimits(userId, null, {
1545
- requestsPerDay,
1546
- mutationsPerDay
2126
+ await saveUserSettings(userId, null, {
2127
+ boost,
2128
+ limits: {
2129
+ requestsPerDay,
2130
+ mutationsPerDay
2131
+ }
1547
2132
  });
1548
2133
  newUserLimitForm.reset();
1549
2134
  } catch (error) {
@@ -1551,15 +2136,60 @@ function adminHtml() {
1551
2136
  }
1552
2137
  });
1553
2138
 
2139
+ userDetailForm.addEventListener("submit", async (event) => {
2140
+ event.preventDefault();
2141
+ const userId = userDetailForm.dataset.userId;
2142
+ if (!userId) {
2143
+ return;
2144
+ }
2145
+ try {
2146
+ await saveUserSettings(userId, null, {
2147
+ boost: document.getElementById("detail-boost").checked,
2148
+ limits: {
2149
+ requestsPerDay: parseLimitValue(document.getElementById("detail-requests").value, "Request limit"),
2150
+ mutationsPerDay: parseLimitValue(document.getElementById("detail-mutations").value, "Mutation limit")
2151
+ }
2152
+ });
2153
+ } catch (error) {
2154
+ statusLine.textContent = error instanceof Error ? error.message : String(error);
2155
+ }
2156
+ });
2157
+
2158
+ document.getElementById("detail-clear-button").addEventListener("click", () => {
2159
+ const userId = userDetailForm.dataset.userId;
2160
+ if (!userId) {
2161
+ return;
2162
+ }
2163
+ void saveUserSettings(userId, null, {
2164
+ boost: document.getElementById("detail-boost").checked,
2165
+ limits: { requestsPerDay: null, mutationsPerDay: null }
2166
+ }).catch((error) => {
2167
+ statusLine.textContent = error instanceof Error ? error.message : String(error);
2168
+ });
2169
+ });
2170
+
2171
+ document.getElementById("deployments-tab").addEventListener("click", () => navigate("deployments"));
2172
+ document.getElementById("users-tab").addEventListener("click", () => navigate("users"));
1554
2173
  document.getElementById("refresh-button").addEventListener("click", () => {
1555
2174
  void loadSummary();
1556
2175
  });
1557
-
1558
2176
  document.getElementById("logout-button").addEventListener("click", async () => {
1559
2177
  await fetch("/admin/api/logout", { method: "POST" });
1560
2178
  show("login");
1561
2179
  });
1562
2180
 
2181
+ document.addEventListener("click", (event) => {
2182
+ const link = event.target.closest("a[data-route]");
2183
+ if (!link) {
2184
+ return;
2185
+ }
2186
+ event.preventDefault();
2187
+ history.pushState({}, "", link.href);
2188
+ renderCurrent();
2189
+ });
2190
+
2191
+ window.addEventListener("popstate", renderCurrent);
2192
+
1563
2193
  void loadSummary().catch((error) => {
1564
2194
  statusLine.textContent = error.message;
1565
2195
  show("dashboard");
@@ -1601,7 +2231,7 @@ function developerHtml({ authConfigured, deploys = [], user }) {
1601
2231
  const rows = deploys
1602
2232
  .map(
1603
2233
  (deploy) => `<tr>
1604
- <td><a href="${escapeHtml(deploy.url)}">${escapeHtml(deploy.name)}</a><small>${escapeHtml(deploy.deployId)} / ${escapeHtml(deploy.slug)}</small></td>
2234
+ <td><a href="${escapeHtml(deploy.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(deploy.name)}</a><small>${escapeHtml(deploy.deployId)} / ${escapeHtml(deploy.slug)}</small></td>
1605
2235
  <td><span class="pill ${escapeHtml(deploy.status)}">${escapeHtml(deploy.status)}</span></td>
1606
2236
  <td>${escapeHtml(new Date(deploy.createdAt).toLocaleString())}</td>
1607
2237
  <td>${escapeHtml(new Date(deploy.expiresAt).toLocaleString())}</td>
@@ -1844,9 +2474,18 @@ export class MemoryAnonymousStore {
1844
2474
  return normalizeUserLimitOverrides(this.users.get(normalizeUserId(userId))?.limitOverrides ?? {});
1845
2475
  }
1846
2476
 
1847
- async setUserLimitOverrides(userId, limitOverrides) {
2477
+ async getUserLimitSettings(userId) {
2478
+ const user = this.users.get(normalizeUserId(userId));
2479
+ return {
2480
+ boost: Boolean(user?.boost),
2481
+ limitOverrides: normalizeUserLimitOverrides(user?.limitOverrides ?? {})
2482
+ };
2483
+ }
2484
+
2485
+ async setAdminUserSettings(userId, { boost, limitOverrides } = {}) {
1848
2486
  const id = normalizeUserId(userId);
1849
2487
  const current = this.users.get(id) ?? {
2488
+ boost: false,
1850
2489
  createdAt: now(),
1851
2490
  displayName: id,
1852
2491
  id,
@@ -1854,28 +2493,36 @@ export class MemoryAnonymousStore {
1854
2493
  };
1855
2494
  const user = {
1856
2495
  ...current,
2496
+ boost: boost === undefined ? Boolean(current.boost) : Boolean(boost),
1857
2497
  displayName: current.displayName ?? current.login ?? id,
1858
2498
  id,
1859
- limitOverrides: normalizeUserLimitOverrides(limitOverrides),
2499
+ limitOverrides:
2500
+ limitOverrides === undefined
2501
+ ? normalizeUserLimitOverrides(current.limitOverrides ?? {})
2502
+ : normalizeUserLimitOverrides(limitOverrides),
1860
2503
  updatedAt: now()
1861
2504
  };
1862
2505
  this.users.set(id, user);
1863
2506
  return this.getAdminUser(id);
1864
2507
  }
1865
2508
 
2509
+ async setUserLimitOverrides(userId, limitOverrides) {
2510
+ return this.setAdminUserSettings(userId, { limitOverrides });
2511
+ }
2512
+
1866
2513
  async deployWithUserLimitOverrides(deploy) {
1867
2514
  if (!deploy?.ownerId) {
1868
2515
  return deploy;
1869
2516
  }
1870
2517
 
1871
- const limitOverrides = await this.getUserLimitOverrides(deploy.ownerId);
1872
- if (Object.keys(limitOverrides).length === 0) {
2518
+ const { boost, limitOverrides } = await this.getUserLimitSettings(deploy.ownerId);
2519
+ if (!boost && Object.keys(limitOverrides).length === 0) {
1873
2520
  return deploy;
1874
2521
  }
1875
2522
 
1876
2523
  return {
1877
2524
  ...deploy,
1878
- limits: limitsWithOverrides(deploy.limits, limitOverrides)
2525
+ limits: limitsWithOverrides(deploy.limits, limitOverrides, boost)
1879
2526
  };
1880
2527
  }
1881
2528
 
@@ -1918,7 +2565,11 @@ export class MemoryAnonymousStore {
1918
2565
 
1919
2566
  return users
1920
2567
  .filter(Boolean)
1921
- .sort((left, right) => String(left.login ?? left.id).localeCompare(String(right.login ?? right.id)));
2568
+ .sort(
2569
+ (left, right) =>
2570
+ String(right.createdAt).localeCompare(String(left.createdAt)) ||
2571
+ String(left.login ?? left.id).localeCompare(String(right.login ?? right.id))
2572
+ );
1922
2573
  }
1923
2574
 
1924
2575
  storeArtifact({ artifact, artifactHash, clientBundleBase64, clientBundleHash, createdAt }) {
@@ -2361,15 +3012,17 @@ export class PostgresAnonymousStore {
2361
3012
  display_name text,
2362
3013
  avatar_url text,
2363
3014
  url text,
3015
+ boost boolean not null default false,
2364
3016
  limit_overrides_json jsonb not null default '{}',
2365
3017
  created_at timestamptz not null,
2366
3018
  updated_at timestamptz not null
2367
3019
  )
2368
3020
  `);
3021
+ await this.query("alter table users add column if not exists boost boolean not null default false");
2369
3022
  await this.query(`
2370
3023
  insert into users(
2371
3024
  id, provider, provider_id, login, display_name, avatar_url, url,
2372
- limit_overrides_json, created_at, updated_at
3025
+ boost, limit_overrides_json, created_at, updated_at
2373
3026
  )
2374
3027
  select distinct on (owner_id)
2375
3028
  owner_id,
@@ -2379,6 +3032,7 @@ export class PostgresAnonymousStore {
2379
3032
  coalesce(owner_json->>'displayName', owner_json->>'login', owner_id),
2380
3033
  owner_json->>'avatarUrl',
2381
3034
  owner_json->>'url',
3035
+ false,
2382
3036
  '{}'::jsonb,
2383
3037
  coalesce(claimed_at, created_at, now()),
2384
3038
  coalesce(claimed_at, created_at, now())
@@ -2411,6 +3065,7 @@ export class PostgresAnonymousStore {
2411
3065
 
2412
3066
  return {
2413
3067
  avatarUrl: row.avatar_url ?? null,
3068
+ boost: Boolean(row.boost),
2414
3069
  createdAt: new Date(row.created_at).toISOString(),
2415
3070
  displayName: row.display_name ?? row.login ?? row.id,
2416
3071
  id: row.id,
@@ -2462,35 +3117,54 @@ export class PostgresAnonymousStore {
2462
3117
  return normalizeUserLimitOverrides(result.rows[0]?.limit_overrides_json ?? {});
2463
3118
  }
2464
3119
 
2465
- async setUserLimitOverrides(userId, limitOverrides) {
3120
+ async getUserLimitSettings(userId) {
3121
+ const result = await this.query("select boost, limit_overrides_json from users where id = $1", [normalizeUserId(userId)]);
3122
+ return {
3123
+ boost: Boolean(result.rows[0]?.boost),
3124
+ limitOverrides: normalizeUserLimitOverrides(result.rows[0]?.limit_overrides_json ?? {})
3125
+ };
3126
+ }
3127
+
3128
+ async setAdminUserSettings(userId, { boost, limitOverrides } = {}) {
2466
3129
  const id = normalizeUserId(userId);
3130
+ const current = await this.query("select boost, limit_overrides_json from users where id = $1", [id]);
3131
+ const nextBoost = boost === undefined ? Boolean(current.rows[0]?.boost) : Boolean(boost);
3132
+ const nextLimitOverrides =
3133
+ limitOverrides === undefined
3134
+ ? normalizeUserLimitOverrides(current.rows[0]?.limit_overrides_json ?? {})
3135
+ : normalizeUserLimitOverrides(limitOverrides);
2467
3136
  const updatedAt = now();
2468
3137
  await this.query(
2469
3138
  `
2470
- insert into users(id, display_name, limit_overrides_json, created_at, updated_at)
2471
- values($1, $2, $3::jsonb, $4, $4)
3139
+ insert into users(id, display_name, boost, limit_overrides_json, created_at, updated_at)
3140
+ values($1, $2, $3, $4::jsonb, $5, $5)
2472
3141
  on conflict(id) do update set
3142
+ boost = excluded.boost,
2473
3143
  limit_overrides_json = excluded.limit_overrides_json,
2474
3144
  updated_at = excluded.updated_at
2475
3145
  `,
2476
- [id, id, JSON.stringify(normalizeUserLimitOverrides(limitOverrides)), updatedAt]
3146
+ [id, id, nextBoost, JSON.stringify(nextLimitOverrides), updatedAt]
2477
3147
  );
2478
3148
  return this.getAdminUser(id);
2479
3149
  }
2480
3150
 
3151
+ async setUserLimitOverrides(userId, limitOverrides) {
3152
+ return this.setAdminUserSettings(userId, { limitOverrides });
3153
+ }
3154
+
2481
3155
  async deployWithUserLimitOverrides(deploy) {
2482
3156
  if (!deploy?.ownerId) {
2483
3157
  return deploy;
2484
3158
  }
2485
3159
 
2486
- const limitOverrides = await this.getUserLimitOverrides(deploy.ownerId);
2487
- if (Object.keys(limitOverrides).length === 0) {
3160
+ const { boost, limitOverrides } = await this.getUserLimitSettings(deploy.ownerId);
3161
+ if (!boost && Object.keys(limitOverrides).length === 0) {
2488
3162
  return deploy;
2489
3163
  }
2490
3164
 
2491
3165
  return {
2492
3166
  ...deploy,
2493
- limits: limitsWithOverrides(deploy.limits, limitOverrides)
3167
+ limits: limitsWithOverrides(deploy.limits, limitOverrides, boost)
2494
3168
  };
2495
3169
  }
2496
3170
 
@@ -2579,7 +3253,7 @@ export class PostgresAnonymousStore {
2579
3253
  where d.owner_id is not null
2580
3254
  group by d.owner_id
2581
3255
  ) q on q.owner_id = u.id
2582
- order by coalesce(u.login, u.id) asc
3256
+ order by u.created_at desc, coalesce(u.login, u.id) asc
2583
3257
  `,
2584
3258
  [windowStart]
2585
3259
  );
@@ -3185,6 +3859,7 @@ export async function startAnonymousServer({
3185
3859
  publicRootUrl,
3186
3860
  quiet = false,
3187
3861
  shooBaseUrl = shooBaseUrlFromEnv(),
3862
+ sourceRuntime,
3188
3863
  store
3189
3864
  } = {}) {
3190
3865
  const resolvedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
@@ -3193,6 +3868,7 @@ export async function startAnonymousServer({
3193
3868
  const resolvedDeveloperSessionSecret =
3194
3869
  developerSessionSecret || resolvedGithubOAuth?.sessionSecret || resolvedGithubOAuth?.clientSecret || adminPassword || "";
3195
3870
  const resolvedStore = store ?? (await createAnonymousStoreFromEnv());
3871
+ const resolvedSourceRuntime = sourceRuntime === undefined ? createSourceRuntimeFromEnv() : sourceRuntime;
3196
3872
  await resolvedStore.initialize();
3197
3873
  const subscriptions = new Map();
3198
3874
 
@@ -3204,12 +3880,16 @@ export async function startAnonymousServer({
3204
3880
  return counts;
3205
3881
  }
3206
3882
 
3207
- async function adminSummary() {
3883
+ async function adminDeploysWithConnections() {
3208
3884
  const connections = activeConnectionCounts();
3209
- const deploys = (await resolvedStore.listDeployResourceUsage()).map((deploy) => ({
3885
+ return (await resolvedStore.listDeployResourceUsage()).map((deploy) => ({
3210
3886
  ...deploy,
3211
3887
  connections: connections.get(deploy.id) ?? 0
3212
3888
  }));
3889
+ }
3890
+
3891
+ async function adminSummary() {
3892
+ const deploys = await adminDeploysWithConnections();
3213
3893
  const users = await resolvedStore.listAdminUsers();
3214
3894
  const totals = deploys.reduce(
3215
3895
  (acc, deploy) => ({
@@ -3244,6 +3924,21 @@ export async function startAnonymousServer({
3244
3924
  };
3245
3925
  }
3246
3926
 
3927
+ async function adminUserDetail(userId) {
3928
+ const user = await resolvedStore.getAdminUser(userId);
3929
+ if (!user) {
3930
+ return null;
3931
+ }
3932
+
3933
+ const deploys = (await adminDeploysWithConnections()).filter((deploy) => deploy.ownerId === user.id);
3934
+ return {
3935
+ deployCount: deploys.length,
3936
+ deploys,
3937
+ generatedAt: now(),
3938
+ user
3939
+ };
3940
+ }
3941
+
3247
3942
  function currentDeveloper(req) {
3248
3943
  return developerFromRequest(req, resolvedDeveloperSessionSecret);
3249
3944
  }
@@ -3304,6 +3999,7 @@ export async function startAnonymousServer({
3304
3999
  auth: subscription.auth,
3305
4000
  deployId,
3306
4001
  name,
4002
+ sourceRuntime: resolvedSourceRuntime,
3307
4003
  state: resolvedStore
3308
4004
  });
3309
4005
  websocketSend(ws, { data, name, op: "query.result" });
@@ -3495,7 +4191,14 @@ export async function startAnonymousServer({
3495
4191
  return;
3496
4192
  }
3497
4193
 
3498
- if (req.method === "GET" && (requestUrl.pathname === "/admin" || requestUrl.pathname === "/admin/")) {
4194
+ if (
4195
+ req.method === "GET" &&
4196
+ (requestUrl.pathname === "/admin" ||
4197
+ requestUrl.pathname === "/admin/" ||
4198
+ requestUrl.pathname === "/admin/deployments" ||
4199
+ requestUrl.pathname === "/admin/users" ||
4200
+ /^\/admin\/users\/[^/]+$/.test(requestUrl.pathname))
4201
+ ) {
3499
4202
  sendText(res, 200, adminHtml(), { "Content-Type": "text/html; charset=utf-8" });
3500
4203
  return;
3501
4204
  }
@@ -3536,6 +4239,29 @@ export async function startAnonymousServer({
3536
4239
  return;
3537
4240
  }
3538
4241
 
4242
+ const adminUserDetailMatch =
4243
+ req.method === "GET" ? requestUrl.pathname.match(/^\/admin\/api\/users\/([^/]+)$/) : null;
4244
+ if (adminUserDetailMatch) {
4245
+ if (!isAdminConfigured(adminPassword)) {
4246
+ sendJson(res, 503, { error: "Lakebed admin is not configured. Set LAKEBED_ADMIN_PASSWORD." });
4247
+ return;
4248
+ }
4249
+
4250
+ if (!isAdminAuthenticated(req, adminPassword)) {
4251
+ sendJson(res, 401, { error: "Admin authentication required." });
4252
+ return;
4253
+ }
4254
+
4255
+ const detail = await adminUserDetail(decodeURIComponent(adminUserDetailMatch[1]));
4256
+ if (!detail) {
4257
+ sendJson(res, 404, { error: "Unknown user." });
4258
+ return;
4259
+ }
4260
+
4261
+ sendJson(res, 200, detail);
4262
+ return;
4263
+ }
4264
+
3539
4265
  const userLimitsMatch =
3540
4266
  req.method === "PUT" ? requestUrl.pathname.match(/^\/admin\/api\/users\/([^/]+)\/limits$/) : null;
3541
4267
  if (userLimitsMatch) {
@@ -3551,14 +4277,22 @@ export async function startAnonymousServer({
3551
4277
 
3552
4278
  const body = await readJsonBody(req, 4096);
3553
4279
  let limitOverrides;
4280
+ let boost;
3554
4281
  try {
3555
- limitOverrides = normalizeUserLimitOverrides(body.limits ?? body.limitOverrides ?? {});
4282
+ const rawLimitOverrides = body.limits ?? body.limitOverrides;
4283
+ limitOverrides = rawLimitOverrides === undefined ? undefined : normalizeUserLimitOverrides(rawLimitOverrides);
4284
+ boost = normalizeOptionalBoolean(body.boost, "boost");
3556
4285
  } catch (error) {
3557
4286
  sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
3558
4287
  return;
3559
4288
  }
3560
4289
 
3561
- const user = await resolvedStore.setUserLimitOverrides(decodeURIComponent(userLimitsMatch[1]), limitOverrides);
4290
+ const user = await (typeof resolvedStore.setAdminUserSettings === "function"
4291
+ ? resolvedStore.setAdminUserSettings(decodeURIComponent(userLimitsMatch[1]), {
4292
+ boost,
4293
+ limitOverrides
4294
+ })
4295
+ : resolvedStore.setUserLimitOverrides(decodeURIComponent(userLimitsMatch[1]), limitOverrides ?? {}));
3562
4296
  await refreshOwnerSubscriptions(user.id);
3563
4297
  sendJson(res, 200, { user });
3564
4298
  return;
@@ -3762,6 +4496,7 @@ export async function startAnonymousServer({
3762
4496
  auth: subscription.auth,
3763
4497
  deployId: subscription.deploy.id,
3764
4498
  name: message.name,
4499
+ sourceRuntime: resolvedSourceRuntime,
3765
4500
  state: resolvedStore
3766
4501
  });
3767
4502
  websocketSend(ws, { data, id: message.id, name: message.name, ok: true, op: "query.result" });
@@ -3779,7 +4514,9 @@ export async function startAnonymousServer({
3779
4514
  artifact: subscription.artifact,
3780
4515
  auth: subscription.auth,
3781
4516
  deployId: subscription.deploy.id,
4517
+ limits: subscription.deploy.limits,
3782
4518
  name: message.name,
4519
+ sourceRuntime: resolvedSourceRuntime,
3783
4520
  state: resolvedStore
3784
4521
  });
3785
4522
  websocketSend(ws, { id: message.id, ok: true, op: "mutation.result", result });