lakebed 0.0.9 → 0.0.11

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.
@@ -257,6 +257,7 @@ function quotaLimitForBucket(bucket, deploy) {
257
257
  }
258
258
 
259
259
  const USER_LIMIT_OVERRIDE_KEYS = ["requestsPerDay", "mutationsPerDay"];
260
+ const USER_BOOST_MULTIPLIER = 20;
260
261
 
261
262
  function normalizeUserId(value) {
262
263
  const userId = String(value ?? "").trim();
@@ -277,6 +278,25 @@ function normalizeLimitOverrideValue(value, key) {
277
278
  return parsed;
278
279
  }
279
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
+
280
300
  function normalizeUserLimitOverrides(value = {}) {
281
301
  if (!value || typeof value !== "object" || Array.isArray(value)) {
282
302
  throw new Error("limits must be a JSON object.");
@@ -299,9 +319,19 @@ function normalizeUserLimitOverrides(value = {}) {
299
319
  return overrides;
300
320
  }
301
321
 
302
- 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) {
303
333
  return {
304
- ...baseLimits,
334
+ ...limitsWithBoost(baseLimits, boost),
305
335
  ...normalizeUserLimitOverrides(limitOverrides ?? {})
306
336
  };
307
337
  }
@@ -310,6 +340,7 @@ function userFromOwner(owner, current = {}) {
310
340
  const id = normalizeUserId(owner?.id ?? current.id);
311
341
  return {
312
342
  avatarUrl: owner?.avatarUrl ?? current.avatarUrl ?? null,
343
+ boost: Boolean(current.boost),
313
344
  createdAt: current.createdAt ?? now(),
314
345
  displayName: owner?.displayName ?? owner?.login ?? current.displayName ?? id,
315
346
  id,
@@ -327,13 +358,15 @@ function adminUserSummary({ activeDeployCount = 0, deployCount = 0, usage = [],
327
358
  return {
328
359
  activeDeployCount,
329
360
  avatarUrl: user.avatarUrl,
361
+ boost: Boolean(user.boost),
362
+ boostMultiplier: USER_BOOST_MULTIPLIER,
330
363
  createdAt: user.createdAt,
331
364
  defaultLimits: DEFAULT_ANONYMOUS_LIMITS,
332
365
  deployCount,
333
366
  displayName: user.displayName ?? user.login ?? user.id,
334
367
  id: user.id,
335
368
  limitOverrides,
336
- limits: limitsWithOverrides(DEFAULT_ANONYMOUS_LIMITS, limitOverrides),
369
+ limits: limitsWithOverrides(DEFAULT_ANONYMOUS_LIMITS, limitOverrides, user.boost),
337
370
  login: user.login,
338
371
  provider: user.provider,
339
372
  providerId: user.providerId,
@@ -657,6 +690,15 @@ function adminDeploySummary({ artifact, artifactBytes = 0, deploy, logBytes = 0,
657
690
  logBytes,
658
691
  logEntries,
659
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,
660
702
  ownerId: deploy.ownerId,
661
703
  slug: deploy.slug,
662
704
  stateBytes,
@@ -678,16 +720,17 @@ function adminHtml() {
678
720
  <style>
679
721
  :root {
680
722
  color-scheme: dark;
681
- --bg: #10100f;
682
- --panel: #181816;
683
- --line: #34342f;
684
- --line-strong: #575247;
685
- --text: #f3efe2;
686
- --muted: #aaa28f;
687
- --accent: #9ad66b;
688
- --warn: #e9bc5d;
689
- --bad: #ee7d71;
690
- --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;
691
734
  }
692
735
 
693
736
  * {
@@ -695,15 +738,11 @@ function adminHtml() {
695
738
  }
696
739
 
697
740
  body {
698
- margin: 0;
699
- background:
700
- linear-gradient(90deg, rgba(154, 214, 107, 0.06) 1px, transparent 1px),
701
- linear-gradient(180deg, rgba(154, 214, 107, 0.04) 1px, transparent 1px),
702
- var(--bg);
703
- background-size: 34px 34px;
741
+ background: var(--bg);
704
742
  color: var(--text);
705
- font-family: "DIN Alternate", "Avenir Next", "Helvetica Neue", sans-serif;
743
+ font-family: Inter, "Avenir Next", "Helvetica Neue", Arial, sans-serif;
706
744
  letter-spacing: 0;
745
+ margin: 0;
707
746
  }
708
747
 
709
748
  button,
@@ -723,105 +762,164 @@ function adminHtml() {
723
762
 
724
763
  .shell {
725
764
  margin: 0 auto;
726
- max-width: 1280px;
765
+ max-width: 1440px;
727
766
  min-height: 100vh;
728
- padding: 24px 28px;
767
+ padding: 18px 22px;
729
768
  }
730
769
 
731
770
  .topbar {
732
771
  align-items: center;
733
772
  display: flex;
734
- gap: 18px;
773
+ gap: 16px;
735
774
  justify-content: space-between;
736
- margin-bottom: 24px;
775
+ margin-bottom: 14px;
737
776
  }
738
777
 
739
778
  .brand {
740
779
  display: grid;
741
- gap: 4px;
780
+ gap: 2px;
742
781
  }
743
782
 
744
- .eyebrow {
745
- color: var(--accent);
783
+ .eyebrow,
784
+ .mono,
785
+ small,
786
+ th,
787
+ label,
788
+ .status-line,
789
+ .pager {
790
+ color: var(--muted);
746
791
  font-family: "SFMono-Regular", Consolas, monospace;
747
792
  font-size: 12px;
748
793
  }
749
794
 
750
- h1 {
751
- font-size: clamp(24px, 3vw, 36px);
752
- font-weight: 700;
753
- line-height: 1;
795
+ h1,
796
+ h2,
797
+ h3 {
754
798
  margin: 0;
755
799
  }
756
800
 
757
- .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;
758
819
  display: flex;
759
- flex-wrap: wrap;
760
- gap: 10px;
820
+ gap: 8px;
821
+ }
822
+
823
+ .actions {
761
824
  justify-content: flex-end;
762
825
  }
763
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
+
764
838
  .button {
765
839
  background: var(--accent);
766
840
  border: 1px solid var(--accent);
767
- border-radius: 6px;
768
841
  color: var(--ink);
769
- cursor: pointer;
770
842
  font-weight: 700;
771
- min-height: 40px;
772
- padding: 0 14px;
843
+ min-height: 34px;
844
+ padding: 0 12px;
773
845
  }
774
846
 
775
- .button.secondary {
847
+ .button.secondary,
848
+ .tab,
849
+ .row-action,
850
+ .icon-button {
776
851
  background: transparent;
852
+ border: 1px solid var(--line-strong);
777
853
  color: var(--text);
778
854
  }
779
855
 
780
- .metrics {
781
- display: grid;
782
- gap: 1px;
783
- grid-template-columns: repeat(6, minmax(130px, 1fr));
784
- margin-bottom: 26px;
856
+ .button:disabled,
857
+ .tab:disabled,
858
+ .row-action:disabled,
859
+ .icon-button:disabled {
860
+ cursor: default;
861
+ opacity: 0.48;
785
862
  }
786
863
 
787
- .metric {
788
- background: rgba(24, 24, 22, 0.94);
789
- border: 1px solid var(--line);
790
- min-height: 96px;
791
- 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;
792
875
  }
793
876
 
794
- .metric:first-child {
795
- 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;
796
890
  }
797
891
 
798
- .metric:last-child {
799
- 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;
800
898
  }
801
899
 
802
900
  .metric span {
803
901
  color: var(--muted);
804
902
  display: block;
805
903
  font-family: "SFMono-Regular", Consolas, monospace;
806
- font-size: 12px;
807
- margin-bottom: 12px;
904
+ font-size: 11px;
905
+ margin-bottom: 7px;
808
906
  }
809
907
 
810
908
  .metric strong {
811
909
  display: block;
812
- font-size: 26px;
910
+ font-size: 20px;
813
911
  line-height: 1.1;
814
912
  }
815
913
 
816
914
  .panel {
817
- background: rgba(24, 24, 22, 0.96);
915
+ background: var(--panel);
818
916
  border: 1px solid var(--line);
819
- border-radius: 8px;
917
+ border-radius: 6px;
820
918
  overflow: hidden;
821
919
  }
822
920
 
823
921
  .panel + .panel {
824
- margin-top: 18px;
922
+ margin-top: 12px;
825
923
  }
826
924
 
827
925
  .panel-head {
@@ -830,18 +928,16 @@ function adminHtml() {
830
928
  display: flex;
831
929
  gap: 12px;
832
930
  justify-content: space-between;
833
- padding: 14px 16px;
931
+ padding: 10px 12px;
834
932
  }
835
933
 
836
- .panel-head h2 {
837
- font-size: 16px;
838
- margin: 0;
934
+ .panel-head.tight {
935
+ align-items: flex-start;
839
936
  }
840
937
 
841
- .status-line {
842
- color: var(--muted);
843
- font-family: "SFMono-Regular", Consolas, monospace;
844
- font-size: 12px;
938
+ .panel-title {
939
+ display: grid;
940
+ gap: 3px;
845
941
  }
846
942
 
847
943
  .table-wrap {
@@ -850,27 +946,32 @@ function adminHtml() {
850
946
 
851
947
  table {
852
948
  border-collapse: collapse;
853
- min-width: 1220px;
949
+ min-width: 900px;
854
950
  width: 100%;
855
951
  }
856
952
 
953
+ .deploy-table {
954
+ min-width: 960px;
955
+ }
956
+
957
+ .user-table {
958
+ min-width: 840px;
959
+ }
960
+
857
961
  th,
858
962
  td {
859
963
  border-bottom: 1px solid var(--line);
860
- padding: 12px 14px;
964
+ padding: 8px 10px;
861
965
  text-align: left;
862
- vertical-align: top;
966
+ vertical-align: middle;
863
967
  }
864
968
 
865
969
  th {
866
- color: var(--muted);
867
- font-family: "SFMono-Regular", Consolas, monospace;
868
- font-size: 12px;
869
970
  font-weight: 600;
870
971
  }
871
972
 
872
973
  td {
873
- font-size: 14px;
974
+ font-size: 13px;
874
975
  white-space: nowrap;
875
976
  }
876
977
 
@@ -878,185 +979,215 @@ function adminHtml() {
878
979
  border-bottom: 0;
879
980
  }
880
981
 
881
- .deploy-name {
982
+ .stack {
882
983
  display: grid;
883
- gap: 4px;
884
- min-width: 240px;
984
+ gap: 2px;
885
985
  }
886
986
 
887
- .deploy-name strong {
888
- font-size: 15px;
987
+ .primary-text {
988
+ color: var(--text);
989
+ font-weight: 700;
889
990
  }
890
991
 
891
- .mono,
892
- .deploy-name small {
893
- color: var(--muted);
894
- font-family: "SFMono-Regular", Consolas, monospace;
895
- font-size: 12px;
992
+ .name-cell {
993
+ display: grid;
994
+ gap: 3px;
995
+ min-width: 160px;
896
996
  }
897
997
 
898
- .deploy-name small {
998
+ .name-cell small {
899
999
  white-space: normal;
900
1000
  }
901
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
+
902
1032
  .pill {
903
1033
  border: 1px solid var(--line-strong);
904
1034
  border-radius: 999px;
905
1035
  display: inline-flex;
906
1036
  font-family: "SFMono-Regular", Consolas, monospace;
907
- font-size: 12px;
908
- padding: 3px 8px;
1037
+ font-size: 11px;
1038
+ line-height: 1;
1039
+ padding: 4px 7px;
909
1040
  }
910
1041
 
911
1042
  .pill.active {
912
- border-color: rgba(154, 214, 107, 0.75);
1043
+ border-color: #65758a;
913
1044
  color: var(--accent);
914
1045
  }
915
1046
 
916
1047
  .pill.expired {
917
- border-color: rgba(238, 125, 113, 0.75);
1048
+ border-color: rgba(240, 128, 128, 0.75);
918
1049
  color: var(--bad);
919
1050
  }
920
1051
 
921
1052
  .pill.terminated {
922
- border-color: rgba(233, 188, 93, 0.75);
1053
+ border-color: rgba(224, 181, 95, 0.75);
923
1054
  color: var(--warn);
924
1055
  }
925
1056
 
926
- .status-cell {
927
- align-items: center;
928
- display: flex;
929
- gap: 8px;
930
- }
931
-
932
1057
  .row-action {
933
- background: transparent;
934
- border: 1px solid rgba(238, 125, 113, 0.8);
935
- border-radius: 6px;
936
- color: var(--bad);
937
- cursor: pointer;
938
- font-family: "SFMono-Regular", Consolas, monospace;
939
- font-size: 12px;
940
- min-height: 32px;
941
- padding: 0 10px;
1058
+ min-height: 30px;
1059
+ padding: 0 9px;
942
1060
  }
943
1061
 
944
- .row-action:disabled {
945
- border-color: var(--line);
946
- color: var(--muted);
947
- cursor: default;
948
- opacity: 0.62;
1062
+ .icon-button {
1063
+ height: 30px;
1064
+ padding: 0;
1065
+ width: 30px;
949
1066
  }
950
1067
 
951
- .row-action.neutral {
952
- border-color: var(--line-strong);
953
- color: var(--text);
1068
+ .icon-button svg {
1069
+ height: 15px;
1070
+ width: 15px;
954
1071
  }
955
1072
 
956
- .row-action.primary {
957
- border-color: rgba(154, 214, 107, 0.8);
958
- color: var(--accent);
1073
+ .icon-button.danger {
1074
+ border-color: rgba(240, 128, 128, 0.62);
1075
+ color: var(--bad);
959
1076
  }
960
1077
 
961
- .user-name {
1078
+ .limit-cell {
962
1079
  display: grid;
963
1080
  gap: 4px;
964
- min-width: 220px;
1081
+ min-width: 112px;
965
1082
  }
966
1083
 
967
- .user-name strong {
968
- font-size: 15px;
1084
+ .limit-combo {
1085
+ display: grid;
1086
+ gap: 4px;
1087
+ min-width: 220px;
969
1088
  }
970
1089
 
971
- .limit-cell {
1090
+ .limit-row {
1091
+ align-items: center;
972
1092
  display: grid;
973
1093
  gap: 6px;
974
- min-width: 150px;
1094
+ grid-template-columns: 16px 86px 1fr;
975
1095
  }
976
1096
 
977
- .limit-cell input {
978
- min-height: 34px;
979
- padding: 0 10px;
1097
+ .limit-row input {
1098
+ min-height: 28px;
1099
+ padding: 0 7px;
980
1100
  }
981
1101
 
982
- .limit-form {
983
- align-items: end;
984
- display: grid;
985
- gap: 8px;
986
- grid-template-columns: minmax(180px, 1fr) 132px 132px auto;
987
- width: min(100%, 720px);
1102
+ .usage-cell {
1103
+ min-width: 128px;
988
1104
  }
989
1105
 
990
- .limit-form .field {
991
- 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%;
992
1115
  }
993
1116
 
994
- .limit-form input {
995
- min-height: 36px;
1117
+ input:focus {
1118
+ border-color: #69778a;
996
1119
  }
997
1120
 
998
- .limit-actions {
999
- display: flex;
1000
- gap: 8px;
1121
+ input[type="checkbox"] {
1122
+ height: 16px;
1123
+ min-height: 0;
1124
+ width: 16px;
1001
1125
  }
1002
1126
 
1003
- th:nth-child(8),
1004
- td:nth-child(8),
1005
- th:nth-child(9),
1006
- td:nth-child(9) {
1007
- min-width: 124px;
1127
+ .checkbox {
1128
+ align-items: center;
1129
+ color: var(--text);
1130
+ display: inline-flex;
1131
+ gap: 7px;
1132
+ min-height: 30px;
1008
1133
  }
1009
1134
 
1010
- .login {
1011
- align-items: center;
1012
- display: flex;
1013
- min-height: calc(100vh - 56px);
1135
+ .limit-form,
1136
+ .detail-form {
1137
+ align-items: end;
1138
+ display: grid;
1139
+ gap: 8px;
1014
1140
  }
1015
1141
 
1016
- .login-panel {
1017
- background: rgba(24, 24, 22, 0.98);
1018
- border: 1px solid var(--line);
1019
- border-radius: 8px;
1020
- max-width: 460px;
1021
- padding: 24px;
1022
- width: 100%;
1142
+ .limit-form {
1143
+ grid-template-columns: minmax(160px, 0.7fr) 100px 100px auto auto;
1144
+ width: min(100%, 650px);
1023
1145
  }
1024
1146
 
1025
- .login-panel h1 {
1026
- font-size: 34px;
1027
- margin-bottom: 20px;
1147
+ .detail-form {
1148
+ grid-template-columns: auto 128px 128px auto auto;
1149
+ width: min(100%, 700px);
1028
1150
  }
1029
1151
 
1030
1152
  .field {
1031
1153
  display: grid;
1032
- gap: 8px;
1154
+ gap: 4px;
1033
1155
  }
1034
1156
 
1035
- label {
1036
- color: var(--muted);
1037
- font-family: "SFMono-Regular", Consolas, monospace;
1038
- 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;
1039
1162
  }
1040
1163
 
1041
- input {
1042
- background: #0c0d0b;
1043
- 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);
1044
1177
  border-radius: 6px;
1045
- color: var(--text);
1046
- min-height: 44px;
1047
- outline: none;
1048
- padding: 0 12px;
1178
+ max-width: 420px;
1179
+ padding: 22px;
1049
1180
  width: 100%;
1050
1181
  }
1051
1182
 
1052
- input:focus {
1053
- border-color: var(--accent);
1183
+ .login-panel h1 {
1184
+ margin-bottom: 18px;
1054
1185
  }
1055
1186
 
1056
1187
  .form-row {
1057
1188
  display: flex;
1058
- gap: 10px;
1059
- margin-top: 16px;
1189
+ gap: 8px;
1190
+ margin-top: 14px;
1060
1191
  }
1061
1192
 
1062
1193
  .error {
@@ -1068,13 +1199,14 @@ function adminHtml() {
1068
1199
  display: none;
1069
1200
  }
1070
1201
 
1071
- @media (max-width: 860px) {
1202
+ @media (max-width: 960px) {
1072
1203
  .shell {
1073
- padding: 18px;
1204
+ padding: 14px;
1074
1205
  }
1075
1206
 
1076
1207
  .topbar,
1077
- .panel-head {
1208
+ .panel-head,
1209
+ .panel-head.tight {
1078
1210
  align-items: flex-start;
1079
1211
  flex-direction: column;
1080
1212
  }
@@ -1083,19 +1215,14 @@ function adminHtml() {
1083
1215
  justify-content: flex-start;
1084
1216
  }
1085
1217
 
1086
- .limit-form {
1087
- grid-template-columns: 1fr;
1088
- width: 100%;
1089
- }
1090
-
1091
1218
  .metrics {
1092
1219
  grid-template-columns: repeat(2, minmax(0, 1fr));
1093
1220
  }
1094
1221
 
1095
- .metric,
1096
- .metric:first-child,
1097
- .metric:last-child {
1098
- border-radius: 8px;
1222
+ .limit-form,
1223
+ .detail-form {
1224
+ grid-template-columns: 1fr;
1225
+ width: 100%;
1099
1226
  }
1100
1227
  }
1101
1228
  </style>
@@ -1124,12 +1251,18 @@ function adminHtml() {
1124
1251
  <h1>Deploy monitor</h1>
1125
1252
  </div>
1126
1253
  <div class="actions">
1254
+ <span class="status-line" id="status-line">Loading</span>
1127
1255
  <button class="button secondary" id="refresh-button" type="button">Refresh</button>
1128
1256
  <button class="button secondary" id="logout-button" type="button">Lock</button>
1129
1257
  </div>
1130
1258
  </header>
1131
1259
 
1132
- <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">
1133
1266
  <div class="metric"><span>deploys</span><strong id="metric-deploys">0</strong></div>
1134
1267
  <div class="metric"><span>artifact bytes</span><strong id="metric-artifacts">0 B</strong></div>
1135
1268
  <div class="metric"><span>state bytes</span><strong id="metric-state">0 B</strong></div>
@@ -1138,35 +1271,40 @@ function adminHtml() {
1138
1271
  <div class="metric"><span>mutations today</span><strong id="metric-mutations">0</strong></div>
1139
1272
  </section>
1140
1273
 
1141
- <section class="panel">
1274
+ <section class="panel" id="deployments-view">
1142
1275
  <div class="panel-head">
1143
- <h2>Deploy resource table</h2>
1144
- <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>
1145
1280
  </div>
1146
1281
  <div class="table-wrap">
1147
- <table>
1282
+ <table class="deploy-table">
1148
1283
  <thead>
1149
1284
  <tr>
1150
1285
  <th>Deploy</th>
1286
+ <th>User</th>
1151
1287
  <th>Status</th>
1152
1288
  <th>Created</th>
1153
1289
  <th>Expires</th>
1154
- <th>Artifact</th>
1155
- <th>State</th>
1156
- <th>Logs</th>
1157
- <th>Requests</th>
1158
- <th>Mutations</th>
1159
- <th>Connections</th>
1290
+ <th>Usage</th>
1291
+ <th>Storage</th>
1292
+ <th>Conn</th>
1293
+ <th></th>
1160
1294
  </tr>
1161
1295
  </thead>
1162
1296
  <tbody id="deploy-rows"></tbody>
1163
1297
  </table>
1164
1298
  </div>
1299
+ <div class="pager" id="deploy-pager"></div>
1165
1300
  </section>
1166
1301
 
1167
- <section class="panel">
1168
- <div class="panel-head">
1169
- <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>
1170
1308
  <form class="limit-form" id="new-user-limit-form">
1171
1309
  <div class="field">
1172
1310
  <label for="new-user-id">User id</label>
@@ -1180,40 +1318,121 @@ function adminHtml() {
1180
1318
  <label for="new-user-mutations">Mutations</label>
1181
1319
  <input id="new-user-mutations" name="mutationsPerDay" inputmode="numeric" min="1" step="1" type="number" />
1182
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>
1183
1325
  <button class="button secondary" type="submit">Save</button>
1184
1326
  </form>
1185
1327
  </div>
1186
1328
  <div class="table-wrap">
1187
- <table>
1329
+ <table class="user-table">
1188
1330
  <thead>
1189
1331
  <tr>
1190
1332
  <th>User</th>
1333
+ <th>Created</th>
1334
+ <th>Boost</th>
1191
1335
  <th>Deploys</th>
1192
- <th>Requests today</th>
1193
- <th>Mutations today</th>
1194
- <th>Request limit</th>
1195
- <th>Mutation limit</th>
1336
+ <th>Usage</th>
1337
+ <th>Limits</th>
1196
1338
  <th>Actions</th>
1197
1339
  </tr>
1198
1340
  </thead>
1199
1341
  <tbody id="user-rows"></tbody>
1200
1342
  </table>
1201
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>
1202
1404
  </section>
1203
1405
  </section>
1204
1406
  </main>
1205
1407
 
1206
1408
  <script>
1409
+ const pageSize = 20;
1207
1410
  const loginView = document.getElementById("login-view");
1208
1411
  const dashboardView = document.getElementById("dashboard-view");
1209
1412
  const loginForm = document.getElementById("login-form");
1210
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");
1211
1418
  const rows = document.getElementById("deploy-rows");
1212
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");
1213
1424
  const newUserLimitForm = document.getElementById("new-user-limit-form");
1214
- const statusLine = document.getElementById("status-line");
1215
- let terminatingDeployId = null;
1216
- 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
+ };
1217
1436
 
1218
1437
  function show(view) {
1219
1438
  loginView.classList.toggle("hidden", view !== "login");
@@ -1240,21 +1459,104 @@ function adminHtml() {
1240
1459
  return "unknown";
1241
1460
  }
1242
1461
  return new Intl.DateTimeFormat(undefined, {
1243
- dateStyle: "medium",
1462
+ dateStyle: "short",
1244
1463
  timeStyle: "short"
1245
1464
  }).format(new Date(value));
1246
1465
  }
1247
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
+
1248
1475
  function setMetric(id, value) {
1249
1476
  document.getElementById(id).textContent = value;
1250
1477
  }
1251
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
+
1252
1555
  function parseLimitValue(value, name) {
1253
1556
  const trimmed = String(value || "").trim();
1254
1557
  if (!trimmed) {
1255
1558
  return null;
1256
1559
  }
1257
-
1258
1560
  const parsed = Number(trimmed);
1259
1561
  if (!Number.isSafeInteger(parsed) || parsed < 1) {
1260
1562
  throw new Error(name + " must be a positive integer.");
@@ -1262,87 +1564,241 @@ function adminHtml() {
1262
1564
  return parsed;
1263
1565
  }
1264
1566
 
1265
- function textCell(value, className) {
1266
- const td = document.createElement("td");
1267
- td.textContent = value;
1268
- if (className) {
1269
- 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;
1270
1589
  }
1271
- 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);
1272
1610
  }
1273
1611
 
1274
1612
  function deployCell(deploy) {
1275
1613
  const td = document.createElement("td");
1276
1614
  const wrap = document.createElement("div");
1277
- const name = document.createElement("strong");
1278
- const link = document.createElement("a");
1615
+ const name = document.createElement("a");
1279
1616
  const id = document.createElement("small");
1280
- wrap.className = "deploy-name";
1281
- link.href = deploy.url;
1282
- link.textContent = deploy.name || deploy.slug;
1283
- name.appendChild(link);
1284
- 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;
1285
1623
  wrap.appendChild(name);
1286
1624
  wrap.appendChild(id);
1287
1625
  td.appendChild(wrap);
1288
1626
  return td;
1289
1627
  }
1290
1628
 
1291
- function statusCell(deploy) {
1629
+ function deployUserCell(deploy) {
1292
1630
  const td = document.createElement("td");
1293
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");
1294
1665
  const pill = document.createElement("span");
1295
- wrap.className = "status-cell";
1296
1666
  pill.className = "pill " + deploy.status;
1297
1667
  pill.textContent = deploy.status;
1298
- wrap.appendChild(pill);
1299
-
1300
- if (deploy.status === "active") {
1301
- const button = document.createElement("button");
1302
- button.className = "row-action";
1303
- button.type = "button";
1304
- button.textContent = terminatingDeployId === deploy.id ? "Terminating" : "Terminate";
1305
- button.disabled = terminatingDeployId === deploy.id;
1306
- button.addEventListener("click", () => {
1307
- void terminateDeploy(deploy).catch((error) => {
1308
- statusLine.textContent = error instanceof Error ? error.message : String(error);
1309
- });
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);
1310
1707
  });
1311
- 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;
1312
1722
  }
1313
1723
 
1314
- td.appendChild(wrap);
1315
- 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
+ }
1316
1737
  }
1317
1738
 
1318
1739
  function userCell(user) {
1319
1740
  const td = document.createElement("td");
1320
1741
  const wrap = document.createElement("div");
1321
- const name = document.createElement("strong");
1742
+ const line = document.createElement("div");
1743
+ const name = document.createElement("a");
1322
1744
  const id = document.createElement("small");
1323
- wrap.className = "user-name";
1324
- if (user.url) {
1325
- const link = document.createElement("a");
1326
- link.href = user.url;
1327
- link.textContent = user.login || user.displayName || user.id;
1328
- name.appendChild(link);
1329
- } else {
1330
- name.textContent = user.login || user.displayName || user.id;
1331
- }
1332
- 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;
1333
1752
  id.textContent = user.id;
1334
- 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);
1335
1759
  wrap.appendChild(id);
1336
1760
  td.appendChild(wrap);
1337
1761
  return td;
1338
1762
  }
1339
1763
 
1340
- function limitInputCell(user, key, label) {
1764
+ function boostCell(user) {
1341
1765
  const td = document.createElement("td");
1342
- 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");
1343
1797
  const input = document.createElement("input");
1344
1798
  const effective = document.createElement("small");
1345
- wrap.className = "limit-cell";
1799
+ row.className = "limit-row";
1800
+ prefix.className = "mono";
1801
+ prefix.textContent = shortLabel;
1346
1802
  input.inputMode = "numeric";
1347
1803
  input.min = "1";
1348
1804
  input.step = "1";
@@ -1351,10 +1807,19 @@ function adminHtml() {
1351
1807
  input.value = user.limitOverrides[key] ? String(user.limitOverrides[key]) : "";
1352
1808
  input.placeholder = String(user.defaultLimits[key]);
1353
1809
  input.setAttribute("aria-label", label + " override for " + user.id);
1354
- effective.className = "mono";
1355
- effective.textContent = "effective " + formatNumber(user.limits[key]);
1356
- wrap.appendChild(input);
1357
- 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"));
1358
1823
  td.appendChild(wrap);
1359
1824
  return td;
1360
1825
  }
@@ -1365,21 +1830,24 @@ function adminHtml() {
1365
1830
  const save = document.createElement("button");
1366
1831
  const clear = document.createElement("button");
1367
1832
  wrap.className = "limit-actions";
1368
- save.className = "row-action primary";
1833
+ save.className = "row-action";
1369
1834
  save.type = "button";
1370
- save.textContent = savingUserId === user.id ? "Saving" : "Save";
1371
- save.disabled = savingUserId === user.id;
1835
+ save.textContent = state.savingUserId === user.id ? "Saving" : "Save";
1836
+ save.disabled = state.savingUserId === user.id;
1372
1837
  save.addEventListener("click", () => {
1373
- void saveUserLimits(user.id, save.closest("tr")).catch((error) => {
1838
+ void saveUserSettings(user.id, save.closest("tr")).catch((error) => {
1374
1839
  statusLine.textContent = error instanceof Error ? error.message : String(error);
1375
1840
  });
1376
1841
  });
1377
- clear.className = "row-action neutral";
1842
+ clear.className = "row-action";
1378
1843
  clear.type = "button";
1379
1844
  clear.textContent = "Clear";
1380
- clear.disabled = savingUserId === user.id;
1845
+ clear.disabled = state.savingUserId === user.id;
1381
1846
  clear.addEventListener("click", () => {
1382
- 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) => {
1383
1851
  statusLine.textContent = error instanceof Error ? error.message : String(error);
1384
1852
  });
1385
1853
  });
@@ -1389,7 +1857,7 @@ function adminHtml() {
1389
1857
  return td;
1390
1858
  }
1391
1859
 
1392
- function render(summary) {
1860
+ function renderMetrics(summary) {
1393
1861
  setMetric("metric-deploys", formatNumber(summary.deployCount));
1394
1862
  setMetric("metric-artifacts", formatBytes(summary.totals.artifactBytes));
1395
1863
  setMetric("metric-state", formatBytes(summary.totals.stateBytes));
@@ -1397,42 +1865,128 @@ function adminHtml() {
1397
1865
  setMetric("metric-requests", formatNumber(summary.totals.requestsToday));
1398
1866
  setMetric("metric-mutations", formatNumber(summary.totals.mutationsToday));
1399
1867
  statusLine.textContent = "Updated " + formatTime(summary.generatedAt);
1400
- rows.replaceChildren();
1868
+ }
1401
1869
 
1402
- 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) {
1403
1890
  const tr = document.createElement("tr");
1404
- tr.appendChild(deployCell(deploy));
1405
- tr.appendChild(statusCell(deploy));
1406
- tr.appendChild(textCell(formatTime(deploy.createdAt)));
1407
- tr.appendChild(textCell(formatTime(deploy.expiresAt)));
1408
- tr.appendChild(textCell(formatBytes(deploy.artifactBytes), "mono"));
1409
- tr.appendChild(textCell(formatBytes(deploy.stateBytes) + " / " + formatNumber(deploy.stateRows) + " rows", "mono"));
1410
- tr.appendChild(textCell(formatBytes(deploy.logBytes) + " / " + formatNumber(deploy.logEntries) + " entries", "mono"));
1411
- tr.appendChild(textCell(formatNumber(deploy.requestsToday) + " / " + formatNumber(deploy.limits.requestsPerDay), "mono"));
1412
- tr.appendChild(textCell(formatNumber(deploy.mutationsToday) + " / " + formatNumber(deploy.limits.mutationsPerDay), "mono"));
1413
- tr.appendChild(textCell(formatNumber(deploy.connections), "mono"));
1414
- 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);
1415
1895
  }
1416
1896
 
1417
- userRows.replaceChildren();
1418
- for (const user of summary.users || []) {
1897
+ for (const user of page.items) {
1419
1898
  const tr = document.createElement("tr");
1420
1899
  tr.appendChild(userCell(user));
1900
+ tr.appendChild(textCell(formatTime(user.createdAt), "mono"));
1901
+ tr.appendChild(boostCell(user));
1421
1902
  tr.appendChild(textCell(formatNumber(user.activeDeployCount) + " / " + formatNumber(user.deployCount), "mono"));
1422
- tr.appendChild(textCell(formatNumber(user.requestsToday) + " / " + formatNumber(user.limits.requestsPerDay), "mono"));
1423
- tr.appendChild(textCell(formatNumber(user.mutationsToday) + " / " + formatNumber(user.limits.mutationsPerDay), "mono"));
1424
- tr.appendChild(limitInputCell(user, "requestsPerDay", "Request limit"));
1425
- tr.appendChild(limitInputCell(user, "mutationsPerDay", "Mutation limit"));
1903
+ tr.appendChild(userUsageCell(user));
1904
+ tr.appendChild(userLimitsCell(user));
1426
1905
  tr.appendChild(userActionCell(user));
1427
1906
  userRows.appendChild(tr);
1428
1907
  }
1429
1908
 
1430
- if (!summary.users?.length) {
1431
- const tr = document.createElement("tr");
1432
- const td = textCell("No claimed users or manual overrides yet.", "mono");
1433
- td.colSpan = 7;
1434
- tr.appendChild(td);
1435
- 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);
1436
1990
  }
1437
1991
  }
1438
1992
 
@@ -1450,21 +2004,47 @@ function adminHtml() {
1450
2004
  if (!response.ok) {
1451
2005
  throw new Error(await response.text());
1452
2006
  }
1453
- render(await response.json());
2007
+ state.summary = await response.json();
2008
+ state.userDetail = null;
1454
2009
  show("dashboard");
2010
+ renderCurrent();
1455
2011
  }
1456
2012
 
1457
- async function saveUserLimits(userId, row, explicitLimits) {
1458
- const limits = explicitLimits || {
1459
- requestsPerDay: parseLimitValue(row.querySelector('input[data-key="requestsPerDay"]').value, "Request limit"),
1460
- 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
+ }
1461
2038
  };
2039
+ }
1462
2040
 
1463
- savingUserId = userId;
1464
- 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;
1465
2045
  try {
1466
2046
  const response = await fetch("/admin/api/users/" + encodeURIComponent(userId) + "/limits", {
1467
- body: JSON.stringify({ limits }),
2047
+ body: JSON.stringify(settings),
1468
2048
  headers: { "Content-Type": "application/json" },
1469
2049
  method: "PUT"
1470
2050
  });
@@ -1478,7 +2058,7 @@ function adminHtml() {
1478
2058
  throw new Error(await response.text());
1479
2059
  }
1480
2060
  } finally {
1481
- savingUserId = null;
2061
+ state.savingUserId = null;
1482
2062
  }
1483
2063
  await loadSummary();
1484
2064
  }
@@ -1488,12 +2068,12 @@ function adminHtml() {
1488
2068
  return;
1489
2069
  }
1490
2070
 
1491
- terminatingDeployId = deploy.id;
2071
+ state.terminatingDeployId = deploy.id;
1492
2072
  statusLine.textContent = "Terminating " + deploy.id;
1493
2073
  const response = await fetch("/admin/api/deploys/" + encodeURIComponent(deploy.id) + "/terminate", {
1494
2074
  method: "POST"
1495
2075
  });
1496
- terminatingDeployId = null;
2076
+ state.terminatingDeployId = null;
1497
2077
 
1498
2078
  if (response.status === 401) {
1499
2079
  show("login");
@@ -1535,16 +2115,20 @@ function adminHtml() {
1535
2115
  }
1536
2116
 
1537
2117
  try {
2118
+ const boost = document.getElementById("new-user-boost").checked;
1538
2119
  const requestsPerDay = parseLimitValue(form.get("requestsPerDay"), "Request limit");
1539
2120
  const mutationsPerDay = parseLimitValue(form.get("mutationsPerDay"), "Mutation limit");
1540
- if (!requestsPerDay && !mutationsPerDay) {
1541
- statusLine.textContent = "Enter at least one custom limit.";
2121
+ if (!boost && !requestsPerDay && !mutationsPerDay) {
2122
+ statusLine.textContent = "Enter a custom limit or turn on boost.";
1542
2123
  return;
1543
2124
  }
1544
2125
 
1545
- await saveUserLimits(userId, null, {
1546
- requestsPerDay,
1547
- mutationsPerDay
2126
+ await saveUserSettings(userId, null, {
2127
+ boost,
2128
+ limits: {
2129
+ requestsPerDay,
2130
+ mutationsPerDay
2131
+ }
1548
2132
  });
1549
2133
  newUserLimitForm.reset();
1550
2134
  } catch (error) {
@@ -1552,15 +2136,60 @@ function adminHtml() {
1552
2136
  }
1553
2137
  });
1554
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"));
1555
2173
  document.getElementById("refresh-button").addEventListener("click", () => {
1556
2174
  void loadSummary();
1557
2175
  });
1558
-
1559
2176
  document.getElementById("logout-button").addEventListener("click", async () => {
1560
2177
  await fetch("/admin/api/logout", { method: "POST" });
1561
2178
  show("login");
1562
2179
  });
1563
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
+
1564
2193
  void loadSummary().catch((error) => {
1565
2194
  statusLine.textContent = error.message;
1566
2195
  show("dashboard");
@@ -1602,7 +2231,7 @@ function developerHtml({ authConfigured, deploys = [], user }) {
1602
2231
  const rows = deploys
1603
2232
  .map(
1604
2233
  (deploy) => `<tr>
1605
- <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>
1606
2235
  <td><span class="pill ${escapeHtml(deploy.status)}">${escapeHtml(deploy.status)}</span></td>
1607
2236
  <td>${escapeHtml(new Date(deploy.createdAt).toLocaleString())}</td>
1608
2237
  <td>${escapeHtml(new Date(deploy.expiresAt).toLocaleString())}</td>
@@ -1845,9 +2474,18 @@ export class MemoryAnonymousStore {
1845
2474
  return normalizeUserLimitOverrides(this.users.get(normalizeUserId(userId))?.limitOverrides ?? {});
1846
2475
  }
1847
2476
 
1848
- 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 } = {}) {
1849
2486
  const id = normalizeUserId(userId);
1850
2487
  const current = this.users.get(id) ?? {
2488
+ boost: false,
1851
2489
  createdAt: now(),
1852
2490
  displayName: id,
1853
2491
  id,
@@ -1855,28 +2493,36 @@ export class MemoryAnonymousStore {
1855
2493
  };
1856
2494
  const user = {
1857
2495
  ...current,
2496
+ boost: boost === undefined ? Boolean(current.boost) : Boolean(boost),
1858
2497
  displayName: current.displayName ?? current.login ?? id,
1859
2498
  id,
1860
- limitOverrides: normalizeUserLimitOverrides(limitOverrides),
2499
+ limitOverrides:
2500
+ limitOverrides === undefined
2501
+ ? normalizeUserLimitOverrides(current.limitOverrides ?? {})
2502
+ : normalizeUserLimitOverrides(limitOverrides),
1861
2503
  updatedAt: now()
1862
2504
  };
1863
2505
  this.users.set(id, user);
1864
2506
  return this.getAdminUser(id);
1865
2507
  }
1866
2508
 
2509
+ async setUserLimitOverrides(userId, limitOverrides) {
2510
+ return this.setAdminUserSettings(userId, { limitOverrides });
2511
+ }
2512
+
1867
2513
  async deployWithUserLimitOverrides(deploy) {
1868
2514
  if (!deploy?.ownerId) {
1869
2515
  return deploy;
1870
2516
  }
1871
2517
 
1872
- const limitOverrides = await this.getUserLimitOverrides(deploy.ownerId);
1873
- if (Object.keys(limitOverrides).length === 0) {
2518
+ const { boost, limitOverrides } = await this.getUserLimitSettings(deploy.ownerId);
2519
+ if (!boost && Object.keys(limitOverrides).length === 0) {
1874
2520
  return deploy;
1875
2521
  }
1876
2522
 
1877
2523
  return {
1878
2524
  ...deploy,
1879
- limits: limitsWithOverrides(deploy.limits, limitOverrides)
2525
+ limits: limitsWithOverrides(deploy.limits, limitOverrides, boost)
1880
2526
  };
1881
2527
  }
1882
2528
 
@@ -1919,7 +2565,11 @@ export class MemoryAnonymousStore {
1919
2565
 
1920
2566
  return users
1921
2567
  .filter(Boolean)
1922
- .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
+ );
1923
2573
  }
1924
2574
 
1925
2575
  storeArtifact({ artifact, artifactHash, clientBundleBase64, clientBundleHash, createdAt }) {
@@ -2362,15 +3012,17 @@ export class PostgresAnonymousStore {
2362
3012
  display_name text,
2363
3013
  avatar_url text,
2364
3014
  url text,
3015
+ boost boolean not null default false,
2365
3016
  limit_overrides_json jsonb not null default '{}',
2366
3017
  created_at timestamptz not null,
2367
3018
  updated_at timestamptz not null
2368
3019
  )
2369
3020
  `);
3021
+ await this.query("alter table users add column if not exists boost boolean not null default false");
2370
3022
  await this.query(`
2371
3023
  insert into users(
2372
3024
  id, provider, provider_id, login, display_name, avatar_url, url,
2373
- limit_overrides_json, created_at, updated_at
3025
+ boost, limit_overrides_json, created_at, updated_at
2374
3026
  )
2375
3027
  select distinct on (owner_id)
2376
3028
  owner_id,
@@ -2380,6 +3032,7 @@ export class PostgresAnonymousStore {
2380
3032
  coalesce(owner_json->>'displayName', owner_json->>'login', owner_id),
2381
3033
  owner_json->>'avatarUrl',
2382
3034
  owner_json->>'url',
3035
+ false,
2383
3036
  '{}'::jsonb,
2384
3037
  coalesce(claimed_at, created_at, now()),
2385
3038
  coalesce(claimed_at, created_at, now())
@@ -2412,6 +3065,7 @@ export class PostgresAnonymousStore {
2412
3065
 
2413
3066
  return {
2414
3067
  avatarUrl: row.avatar_url ?? null,
3068
+ boost: Boolean(row.boost),
2415
3069
  createdAt: new Date(row.created_at).toISOString(),
2416
3070
  displayName: row.display_name ?? row.login ?? row.id,
2417
3071
  id: row.id,
@@ -2463,35 +3117,54 @@ export class PostgresAnonymousStore {
2463
3117
  return normalizeUserLimitOverrides(result.rows[0]?.limit_overrides_json ?? {});
2464
3118
  }
2465
3119
 
2466
- 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 } = {}) {
2467
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);
2468
3136
  const updatedAt = now();
2469
3137
  await this.query(
2470
3138
  `
2471
- insert into users(id, display_name, limit_overrides_json, created_at, updated_at)
2472
- 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)
2473
3141
  on conflict(id) do update set
3142
+ boost = excluded.boost,
2474
3143
  limit_overrides_json = excluded.limit_overrides_json,
2475
3144
  updated_at = excluded.updated_at
2476
3145
  `,
2477
- [id, id, JSON.stringify(normalizeUserLimitOverrides(limitOverrides)), updatedAt]
3146
+ [id, id, nextBoost, JSON.stringify(nextLimitOverrides), updatedAt]
2478
3147
  );
2479
3148
  return this.getAdminUser(id);
2480
3149
  }
2481
3150
 
3151
+ async setUserLimitOverrides(userId, limitOverrides) {
3152
+ return this.setAdminUserSettings(userId, { limitOverrides });
3153
+ }
3154
+
2482
3155
  async deployWithUserLimitOverrides(deploy) {
2483
3156
  if (!deploy?.ownerId) {
2484
3157
  return deploy;
2485
3158
  }
2486
3159
 
2487
- const limitOverrides = await this.getUserLimitOverrides(deploy.ownerId);
2488
- if (Object.keys(limitOverrides).length === 0) {
3160
+ const { boost, limitOverrides } = await this.getUserLimitSettings(deploy.ownerId);
3161
+ if (!boost && Object.keys(limitOverrides).length === 0) {
2489
3162
  return deploy;
2490
3163
  }
2491
3164
 
2492
3165
  return {
2493
3166
  ...deploy,
2494
- limits: limitsWithOverrides(deploy.limits, limitOverrides)
3167
+ limits: limitsWithOverrides(deploy.limits, limitOverrides, boost)
2495
3168
  };
2496
3169
  }
2497
3170
 
@@ -2580,7 +3253,7 @@ export class PostgresAnonymousStore {
2580
3253
  where d.owner_id is not null
2581
3254
  group by d.owner_id
2582
3255
  ) q on q.owner_id = u.id
2583
- order by coalesce(u.login, u.id) asc
3256
+ order by u.created_at desc, coalesce(u.login, u.id) asc
2584
3257
  `,
2585
3258
  [windowStart]
2586
3259
  );
@@ -3207,12 +3880,16 @@ export async function startAnonymousServer({
3207
3880
  return counts;
3208
3881
  }
3209
3882
 
3210
- async function adminSummary() {
3883
+ async function adminDeploysWithConnections() {
3211
3884
  const connections = activeConnectionCounts();
3212
- const deploys = (await resolvedStore.listDeployResourceUsage()).map((deploy) => ({
3885
+ return (await resolvedStore.listDeployResourceUsage()).map((deploy) => ({
3213
3886
  ...deploy,
3214
3887
  connections: connections.get(deploy.id) ?? 0
3215
3888
  }));
3889
+ }
3890
+
3891
+ async function adminSummary() {
3892
+ const deploys = await adminDeploysWithConnections();
3216
3893
  const users = await resolvedStore.listAdminUsers();
3217
3894
  const totals = deploys.reduce(
3218
3895
  (acc, deploy) => ({
@@ -3247,6 +3924,21 @@ export async function startAnonymousServer({
3247
3924
  };
3248
3925
  }
3249
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
+
3250
3942
  function currentDeveloper(req) {
3251
3943
  return developerFromRequest(req, resolvedDeveloperSessionSecret);
3252
3944
  }
@@ -3499,7 +4191,14 @@ export async function startAnonymousServer({
3499
4191
  return;
3500
4192
  }
3501
4193
 
3502
- 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
+ ) {
3503
4202
  sendText(res, 200, adminHtml(), { "Content-Type": "text/html; charset=utf-8" });
3504
4203
  return;
3505
4204
  }
@@ -3540,6 +4239,29 @@ export async function startAnonymousServer({
3540
4239
  return;
3541
4240
  }
3542
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
+
3543
4265
  const userLimitsMatch =
3544
4266
  req.method === "PUT" ? requestUrl.pathname.match(/^\/admin\/api\/users\/([^/]+)\/limits$/) : null;
3545
4267
  if (userLimitsMatch) {
@@ -3555,14 +4277,22 @@ export async function startAnonymousServer({
3555
4277
 
3556
4278
  const body = await readJsonBody(req, 4096);
3557
4279
  let limitOverrides;
4280
+ let boost;
3558
4281
  try {
3559
- 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");
3560
4285
  } catch (error) {
3561
4286
  sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
3562
4287
  return;
3563
4288
  }
3564
4289
 
3565
- 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 ?? {}));
3566
4296
  await refreshOwnerSubscriptions(user.id);
3567
4297
  sendJson(res, 200, { user });
3568
4298
  return;