querysub 0.547.0 → 0.548.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.547.0",
3
+ "version": "0.548.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
@@ -71,7 +71,7 @@
71
71
  "node-forge": "https://github.com/sliftist/forge#e618181b469b07bdc70b968b0391beb8ef5fecd6",
72
72
  "pako": "^2.1.0",
73
73
  "peggy": "^5.0.6",
74
- "sliftutils": "^1.7.33",
74
+ "sliftutils": "^1.7.34",
75
75
  "socket-function": "^1.2.26",
76
76
  "terser": "^5.31.0",
77
77
  "typenode": "^6.6.1",
@@ -4,6 +4,8 @@ import { css } from "typesafecss";
4
4
  import { sort } from "socket-function/src/misc";
5
5
  import { formatNumber, formatDateTime, formatTime } from "socket-function/src/formatting/format";
6
6
  import { parseHostedUrl, parseBackblazeUrl } from "sliftutils/storage/remoteStorage/remoteConfig";
7
+ import { getServerActiveBucket } from "sliftutils/storage/remoteStorage/createArchives";
8
+ import { STORAGE_ACCOUNT } from "../../-a-archives/archives2";
7
9
  import { FULL_VALID_WINDOW, FULL_ROUTE } from "sliftutils/storage/IArchives";
8
10
  import type { RemoteConfig, RemoteConfigBase, HostedConfig, BackblazeConfig } from "sliftutils/storage/IArchives";
9
11
  import { showModal } from "../../5-diagnostics/Modal";
@@ -24,13 +26,14 @@ const PAST_COLOR = { h: 0, s: 0, l: 90 };
24
26
  const FUTURE_COLOR = { h: 45, s: 80, l: 85 };
25
27
  // Windows are days or hours wide, so the countdown only has to be roughly right
26
28
  const TIME_REFRESH_INTERVAL = 60 * 1000;
29
+ const WATCH_POLL_INTERVAL = 60 * 1000;
30
+ const WATCH_ERROR_LIMIT = 500;
31
+ const WATCH_ACTIVE_COLOR = { h: 195, s: 60, l: 85 };
27
32
  const WINDOW_HEADER_SIZE = 13;
28
33
  const BUCKET_TITLE_SIZE = 15;
29
34
  const GROUP_BODY_INDENT = 16;
30
35
  const TAG_FONT_SIZE = 11;
31
36
  const JSON_INDENT = 4;
32
- // Stands in for the bucket a config belongs to, so buckets that differ only by their own name group together
33
- const OWN_BUCKET_PLACEHOLDER = "*";
34
37
 
35
38
  /** String sources are the shorthand for an always-valid, unsharded backblaze bucket. */
36
39
  function normalizeSource(source: RemoteConfigBase): Source {
@@ -38,23 +41,37 @@ function normalizeSource(source: RemoteConfigBase): Source {
38
41
  return { type: "backblaze", url: source, validWindow: FULL_VALID_WINDOW };
39
42
  }
40
43
 
41
- /** The bucket coordinates of a source URL, so the display doesn't have to show the whole routing-file URL. A source pointing at the bucket the config belongs to shows the placeholder instead of repeating the name. */
42
- function describeUrl(source: Source, ownBucketName: string): string {
43
- let maskBucket = (bucketName: string) => bucketName === ownBucketName && OWN_BUCKET_PLACEHOLDER || bucketName;
44
+ /** Where a source lives, with the bucket the config belongs to masked out so buckets that differ only by their own name group together. */
45
+ function getUrlIdentity(source: Source, ownBucketName: string): unknown {
46
+ let maskBucket = (bucketName: string) => bucketName === ownBucketName && "" || bucketName;
44
47
  try {
45
48
  if (source.type === "backblaze") {
46
- return `b2 ${maskBucket(parseBackblazeUrl(source.url).bucketName)}`;
49
+ return { type: "backblaze", bucketName: maskBucket(parseBackblazeUrl(source.url).bucketName) };
47
50
  }
48
51
  let { address, port, account, bucketName } = parseHostedUrl(source.url);
52
+ return { type: "remote", address, port, account, bucketName: maskBucket(bucketName) };
53
+ } catch {
54
+ return source.url;
55
+ }
56
+ }
57
+
58
+ /** Just the host - the account and the source's own bucket name are already in the table above, so repeating them here is noise. A source pointing at a different bucket keeps that name, since it's the one part the table doesn't already say. */
59
+ function describeUrl(source: Source, ownBucketName: string): string {
60
+ try {
61
+ if (source.type === "backblaze") {
62
+ let { bucketName } = parseBackblazeUrl(source.url);
63
+ return bucketName === ownBucketName && "b2" || `b2 ${bucketName}`;
64
+ }
65
+ let { address, port, bucketName } = parseHostedUrl(source.url);
49
66
  let host = port === DEFAULT_HTTPS_PORT && address || `${address}:${port}`;
50
- return `${host} ${account}/${maskBucket(bucketName)}`;
67
+ return bucketName === ownBucketName && host || `${host} ${bucketName}`;
51
68
  } catch {
52
69
  return source.url;
53
70
  }
54
71
  }
55
72
 
56
73
  function getSourceKey(source: Source, ownBucketName: string): string {
57
- return JSON.stringify({ ...source, url: describeUrl(source, ownBucketName) });
74
+ return JSON.stringify({ ...source, url: getUrlIdentity(source, ownBucketName) });
58
75
  }
59
76
 
60
77
  /** Identifies a config independently of which bucket it configures, so identical configs on different buckets collapse into one display. */
@@ -194,6 +211,98 @@ export function getRouteConfigGroups(servers: StorageServerBuckets[]): RouteConf
194
211
  return results;
195
212
  }
196
213
 
214
+ type LiveWatch = {
215
+ serverUrl: string;
216
+ bucketName: string;
217
+ loading: boolean;
218
+ routing?: RemoteConfig;
219
+ error?: string;
220
+ updatedTime?: number;
221
+ };
222
+
223
+ // Watches only live as long as the page is open - nothing about them is persisted
224
+ const liveWatchData = Querysub.createLocalSchema<{ watches: { [key: string]: LiveWatch } }>("storageLiveWatches");
225
+
226
+ function getWatchKey(serverUrl: string, bucketName: string): string {
227
+ return `${serverUrl}|${bucketName}`;
228
+ }
229
+
230
+ /** The storage server behind a source URL, which is what the live-state calls connect to (the source URL itself points at the routing file inside the bucket). */
231
+ function getServerUrl(source: Source): string | undefined {
232
+ if (source.type !== "remote") return undefined;
233
+ try {
234
+ let { address, port } = parseHostedUrl(source.url);
235
+ return `https://${address}:${port}`;
236
+ } catch {
237
+ return undefined;
238
+ }
239
+ }
240
+
241
+ async function refreshWatch(key: string): Promise<void> {
242
+ let watch = liveWatchData().watches[key];
243
+ if (!watch) return;
244
+ let { serverUrl, bucketName } = watch;
245
+ Querysub.localCommit(() => {
246
+ let current = liveWatchData().watches[key];
247
+ if (current) current.loading = true;
248
+ });
249
+ let result: RemoteConfig | undefined;
250
+ let error: string | undefined;
251
+ try {
252
+ let live = await getServerActiveBucket({ url: serverUrl, account: STORAGE_ACCOUNT, bucketName });
253
+ // The server reports "not loaded here" as a string rather than throwing
254
+ if (typeof live === "string") {
255
+ error = live;
256
+ } else {
257
+ result = live.routing;
258
+ }
259
+ } catch (e) {
260
+ error = String((e as Error).stack ?? e).slice(0, WATCH_ERROR_LIMIT);
261
+ console.error(`Reading the live config of ${bucketName} on ${serverUrl} failed:`, (e as Error).stack ?? e);
262
+ }
263
+ Querysub.localCommit(() => {
264
+ let current = liveWatchData().watches[key];
265
+ // The watch may have been removed while the call was in flight
266
+ if (!current) return;
267
+ current.loading = false;
268
+ current.routing = result;
269
+ current.error = error;
270
+ current.updatedTime = Date.now();
271
+ });
272
+ }
273
+
274
+ function isWatched(serverUrl: string, bucketName: string): boolean {
275
+ return !!liveWatchData().watches[getWatchKey(serverUrl, bucketName)];
276
+ }
277
+
278
+ function toggleWatch(serverUrl: string, bucketName: string): void {
279
+ let key = getWatchKey(serverUrl, bucketName);
280
+ let existed = !!liveWatchData().watches[key];
281
+ Querysub.localCommit(() => {
282
+ if (existed) {
283
+ delete liveWatchData().watches[key];
284
+ return;
285
+ }
286
+ liveWatchData().watches[key] = { serverUrl, bucketName, loading: true };
287
+ });
288
+ if (existed) return;
289
+ void refreshWatch(key);
290
+ }
291
+
292
+ function refreshAllWatches(): void {
293
+ for (let key of Object.keys(liveWatchData().watches)) {
294
+ void refreshWatch(key);
295
+ }
296
+ }
297
+
298
+ function clearWatches(): void {
299
+ Querysub.localCommit(() => {
300
+ for (let key of Object.keys(liveWatchData().watches)) {
301
+ delete liveWatchData().watches[key];
302
+ }
303
+ });
304
+ }
305
+
197
306
  function showConfigModal(bucketName: string, rawConfig: RemoteConfig): void {
198
307
  let close = showModal({
199
308
  content: <FullscreenModal onCancel={() => close.close()}>
@@ -235,6 +344,24 @@ function getSourceTags(source: Source): { icon: string; text: string }[] {
235
344
  return tags;
236
345
  }
237
346
 
347
+ /** Watching a source pins that server's own live view of the bucket to the top of the page, so disagreements between servers are visible directly. */
348
+ class WatchButton extends qreact.Component<{ source: Source; bucketName: string }> {
349
+ render() {
350
+ let serverUrl = getServerUrl(this.props.source);
351
+ // Only storage servers have a live in-memory state to read
352
+ if (!serverUrl) return undefined;
353
+ let watching = isWatched(serverUrl, this.props.bucketName);
354
+ return <Button
355
+ flavor="tiny"
356
+ className={watching && css.hsl(WATCH_ACTIVE_COLOR.h, WATCH_ACTIVE_COLOR.s, WATCH_ACTIVE_COLOR.l) || ""}
357
+ title={watching && "Stop watching this server's live config" || "Watch this server's live config"}
358
+ onClick={() => toggleWatch(serverUrl, this.props.bucketName)}
359
+ >
360
+ 👁
361
+ </Button>;
362
+ }
363
+ }
364
+
238
365
  class SourceRow extends qreact.Component<{ source: Source; window: [number, number]; ownBucketName: string }> {
239
366
  render() {
240
367
  let { source, window, ownBucketName } = this.props;
@@ -248,6 +375,7 @@ class SourceRow extends qreact.Component<{ source: Source; window: [number, numb
248
375
  .hbox(6).pad2(8, 4).boxSizing("border-box").whiteSpace("nowrap")
249
376
  .hsl(0, 0, 100).bord2(SOURCE_BORDER_COLOR.h, SOURCE_BORDER_COLOR.s, SOURCE_BORDER_COLOR.l)
250
377
  }>
378
+ <WatchButton source={source} bucketName={ownBucketName} />
251
379
  <div className={css.boldStyle}>{describeUrl(source, ownBucketName)}</div>
252
380
  {source.route && <div className={css.fontSize(TAG_FONT_SIZE).colorhsl(0, 0, 40)}>
253
381
  route {routeStart} – {routeEnd}
@@ -337,11 +465,72 @@ class RouteConfigGroupView extends qreact.Component<{ group: RouteConfigGroup }>
337
465
  }
338
466
  }
339
467
 
468
+ /** One watched server's own live, in-memory view of a bucket - which is what shows a server disagreeing with everyone else. */
469
+ class LiveWatchView extends qreact.Component<{ watchKey: string; watch: LiveWatch }> {
470
+ render() {
471
+ let { watchKey, watch } = this.props;
472
+ let now = Querysub.timeDelayed(TIME_REFRESH_INTERVAL);
473
+ let sources = (watch.routing?.sources || []).map(normalizeSource);
474
+ let clusters = getWindowClusters(sources);
475
+ return <div className={css.vbox(10).fillWidth.pad2(10, 8).bord2(WATCH_ACTIVE_COLOR.h, WATCH_ACTIVE_COLOR.s, WATCH_ACTIVE_COLOR.l - 25)}>
476
+ <div className={css.hbox(8).wrap.fontSize(WINDOW_HEADER_SIZE)}>
477
+ <div className={css.boldStyle}>👁 {watch.bucketName}</div>
478
+ <div className={css.colorhsl(0, 0, 45)}>live on {watch.serverUrl}</div>
479
+ <div className={css.colorhsl(0, 0, 45)}>
480
+ {watch.loading && "refreshing..."
481
+ || watch.updatedTime && `updated ${formatTime(now - watch.updatedTime)} ago`
482
+ || ""}
483
+ </div>
484
+ <div className={css.flexGrow(1)} />
485
+ <Button flavor="tiny" onClick={() => void refreshWatch(watchKey)}>Refresh</Button>
486
+ <Button flavor="tiny" onClick={() => toggleWatch(watch.serverUrl, watch.bucketName)}>Stop watching</Button>
487
+ </div>
488
+ {watch.error && <div className={css.pad2(10, 8).hsl(WARNING_COLOR.h, WARNING_COLOR.s, WARNING_COLOR.l).bord2(WARNING_COLOR.h, WARNING_COLOR.s, WARNING_COLOR.l - 25)}>
489
+ ⚠ {watch.error}
490
+ </div>}
491
+ <div className={css.vbox(10).fillWidth.paddingLeft(GROUP_BODY_INDENT).boxSizing("border-box")}>
492
+ {clusters.map((cluster, index) => {
493
+ let status = getWindowStatus(cluster.window, now);
494
+ return <div key={index} className={css.vbox(6).fillWidth}>
495
+ <div className={css.hbox(6).wrap.colorhsl(0, 0, 35).fontSize(TAG_FONT_SIZE)}>
496
+ <div>{formatWindowTime(cluster.window[0])} → {formatWindowTime(cluster.window[1])}</div>
497
+ <Tag icon={status.icon} text={status.text} color={status.color} />
498
+ </div>
499
+ {cluster.sources.map(source => <SourceRow
500
+ key={getSourceKey(source, watch.bucketName)}
501
+ source={source}
502
+ window={cluster.window}
503
+ ownBucketName={watch.bucketName}
504
+ />)}
505
+ </div>;
506
+ })}
507
+ </div>
508
+ </div>;
509
+ }
510
+ }
511
+
340
512
  export class RouteConfigView extends qreact.Component<{ servers: StorageServerBuckets[] }> {
513
+ private pollTimer: ReturnType<typeof setInterval> | undefined;
514
+ componentDidMount() {
515
+ this.pollTimer = setInterval(refreshAllWatches, WATCH_POLL_INTERVAL);
516
+ }
517
+ componentWillUnmount() {
518
+ if (this.pollTimer !== undefined) {
519
+ clearInterval(this.pollTimer);
520
+ this.pollTimer = undefined;
521
+ }
522
+ // Watches are only meaningful while the page is open, and leaving them would keep polling on the next mount
523
+ clearWatches();
524
+ }
341
525
  render() {
342
526
  let groups = getRouteConfigGroups(this.props.servers);
343
- if (!groups.length) return undefined;
527
+ let watches = Object.entries(liveWatchData().watches);
528
+ if (!groups.length && !watches.length) return undefined;
344
529
  return <div className={css.vbox(20).fillWidth}>
530
+ {watches.length > 0 && <div className={css.vbox(10).fillWidth}>
531
+ <h3>Live configs</h3>
532
+ {watches.map(([key, watch]) => <LiveWatchView key={key} watchKey={key} watch={watch} />)}
533
+ </div>}
345
534
  <h3>Routing configs</h3>
346
535
  {groups.map((group, index) => <RouteConfigGroupView key={index} group={group} />)}
347
536
  </div>;
@@ -1,13 +1,17 @@
1
1
  import { SocketFunction } from "socket-function/SocketFunction";
2
2
  import { qreact } from "../../4-dom/qreact";
3
3
  import { css } from "typesafecss";
4
- import { formatNumber } from "socket-function/src/formatting/format";
5
- import { sort } from "socket-function/src/misc";
6
- import { listServerBuckets } from "sliftutils/storage/remoteStorage/createArchives";
7
- import type { ServerBucketInfo } from "sliftutils/storage/remoteStorage/storageServerState";
4
+ import { formatNumber, formatTime, formatDateTimeDetailed } from "socket-function/src/formatting/format";
5
+ import { sort, timeInSecond } from "socket-function/src/misc";
6
+ import preact from "preact";
7
+ import { Querysub } from "../../4-querysub/Querysub";
8
+ import { listServerBuckets, clearServerWriteStats, activateServerBucket } from "sliftutils/storage/remoteStorage/createArchives";
9
+ import type { ServerBucketInfo, BucketDiskInfo, BucketWriteStats } from "sliftutils/storage/remoteStorage/storageServerState";
10
+ import type { ArchivesConfig } from "sliftutils/storage/IArchives";
11
+ import { UsageBar, getUsageThresholds } from "../../library-components/UsageBar";
8
12
  import { getSyncedController } from "../../library-components/SyncedController";
9
13
  import { assertIsManagementUser } from "../../diagnostics/managementPages";
10
- import { getEffectiveServiceConfigs, getLiveServiceParameters, getMachineTargets, applyCommandTemplate, ServiceParameters } from "../machineSchema";
14
+ import { getEffectiveServiceConfigs, getLiveServiceParameters, getMachineTargets, applyCommandTemplate, ServiceParameters, DEFAULT_OVERLAP_TIME } from "../machineSchema";
11
15
  import { STORAGE_ACCOUNT } from "../../-a-archives/archives2";
12
16
  import { Table } from "../../5-diagnostics/Table";
13
17
  import { RouteConfigView } from "./RouteConfigView";
@@ -20,6 +24,11 @@ const ERROR_TEXT_LIMIT = 500;
20
24
 
21
25
  export type StorageServerBuckets = {
22
26
  serviceKey: string;
27
+ serviceTitle: string;
28
+ /** The release the service is in the middle of, so the page can warn that these servers are about to move (or just did) */
29
+ releaseTime?: number;
30
+ overlapTime: number;
31
+ hasOldParameters: boolean;
23
32
  url: string;
24
33
  buckets?: ServerBucketInfo[];
25
34
  error?: string;
@@ -40,22 +49,30 @@ function getStorageUrls(parameters: ServiceParameters): string[] {
40
49
  class StoragePageControllerBase {
41
50
  public async getStorageBuckets(): Promise<StorageServerBuckets[]> {
42
51
  let configs = await getEffectiveServiceConfigs();
43
- let serviceKeyByUrl = new Map<string, string>();
52
+ type ServiceForUrl = Omit<StorageServerBuckets, "url" | "buckets" | "error">;
53
+ let serviceByUrl = new Map<string, ServiceForUrl>();
44
54
  for (let config of configs) {
45
55
  let allParameters = [getLiveServiceParameters(config), config.parameters, config.oldParameters];
46
56
  for (let parameters of allParameters) {
47
57
  if (!parameters) continue;
48
58
  for (let url of getStorageUrls(parameters)) {
49
- if (serviceKeyByUrl.has(url)) continue;
50
- serviceKeyByUrl.set(url, parameters.key);
59
+ if (serviceByUrl.has(url)) continue;
60
+ serviceByUrl.set(url, {
61
+ serviceKey: parameters.key,
62
+ serviceTitle: config.info.title,
63
+ releaseTime: config.parameters.releaseTime,
64
+ // The overlap always comes from the newest parameters, even though it governs how long the old instances outlive the release
65
+ overlapTime: config.parameters.overlapTime ?? DEFAULT_OVERLAP_TIME,
66
+ hasOldParameters: !!config.oldParameters,
67
+ });
51
68
  }
52
69
  }
53
70
  }
54
- return await Promise.all([...serviceKeyByUrl].map(async ([url, serviceKey]) => {
71
+ return await Promise.all([...serviceByUrl].map(async ([url, service]) => {
55
72
  try {
56
- return { serviceKey, url, buckets: await listServerBuckets({ url, account: STORAGE_ACCOUNT }) };
73
+ return { ...service, url, buckets: await listServerBuckets({ url, account: STORAGE_ACCOUNT }) };
57
74
  } catch (e) {
58
- return { serviceKey, url, error: String((e as Error).stack ?? e).slice(0, ERROR_TEXT_LIMIT) };
75
+ return { ...service, url, error: String((e as Error).stack ?? e).slice(0, ERROR_TEXT_LIMIT) };
59
76
  }
60
77
  }));
61
78
  }
@@ -79,31 +96,56 @@ const StorageSynced = getSyncedController(StoragePageController, {
79
96
  writes: {},
80
97
  });
81
98
 
82
- type BucketConfig = NonNullable<ServerBucketInfo["config"]>;
83
-
84
99
  type BucketRow = {
85
100
  server: string;
101
+ /** Kept even on the rows that blank out the server column, so the row's buttons know which server to call */
102
+ serverUrl: string;
86
103
  bucket: string;
87
104
  state: string;
88
105
  files: string;
89
106
  bytes: string;
90
- indexSources: BucketConfig["indexSources"];
107
+ indexSources: ArchivesConfig["indexSources"];
91
108
  readerDiskLimit: string;
92
- syncing: BucketConfig["syncing"];
109
+ writes: string;
110
+ written: string;
111
+ writeGain: string;
112
+ disk?: BucketDiskInfo;
113
+ diskError: string;
114
+ syncing: ArchivesConfig["syncing"];
93
115
  error: string;
94
116
  };
95
117
 
118
+ const GAIN_DECIMALS = 1;
119
+ const DISK_BAR_TYPE = "DISK";
120
+ const ACTIVE_STATE = "active";
121
+ const INACTIVE_STATE = "inactive";
122
+
123
+ /** How much fast-mode coalescing saved: every accepted write over the ones that actually reached a source. */
124
+ function getWriteGain(stats: BucketWriteStats | undefined): string {
125
+ if (!stats) return "";
126
+ if (!stats.flushedWrites || !stats.flushedBytes) return "";
127
+ let writeGain = stats.originalWrites / stats.flushedWrites;
128
+ let byteGain = stats.originalBytes / stats.flushedBytes;
129
+ return `${writeGain.toFixed(GAIN_DECIMALS)}X / ${byteGain.toFixed(GAIN_DECIMALS)}X B`;
130
+ }
131
+
96
132
  function getBucketRows(servers: StorageServerBuckets[]): BucketRow[] {
97
133
  let rows: BucketRow[] = [];
98
134
  for (let server of servers) {
99
135
  let baseRow: BucketRow = {
100
136
  server: server.url,
137
+ serverUrl: server.url,
101
138
  bucket: "",
102
139
  state: "",
103
140
  files: "",
104
141
  bytes: "",
105
142
  indexSources: undefined,
106
143
  readerDiskLimit: "",
144
+ writes: "",
145
+ written: "",
146
+ writeGain: "",
147
+ disk: undefined,
148
+ diskError: "",
107
149
  syncing: undefined,
108
150
  error: "",
109
151
  };
@@ -123,11 +165,16 @@ function getBucketRows(servers: StorageServerBuckets[]): BucketRow[] {
123
165
  ...baseRow,
124
166
  server: index === 0 && server.url || "",
125
167
  bucket: bucket.bucketName,
126
- state: bucket.active && "active" || "inactive",
168
+ state: bucket.active && ACTIVE_STATE || INACTIVE_STATE,
127
169
  files: config?.index && formatNumber(config.index.fileCount) || "",
128
170
  bytes: config?.index && formatNumber(config.index.byteCount) + "B" || "",
129
171
  indexSources: config?.indexSources,
130
172
  readerDiskLimit: config?.readerDiskLimit && formatNumber(config.readerDiskLimit) + "B" || "",
173
+ writes: bucket.writeStats && formatNumber(bucket.writeStats.originalWrites) || "",
174
+ written: bucket.writeStats && formatNumber(bucket.writeStats.originalBytes) + "B" || "",
175
+ writeGain: getWriteGain(bucket.writeStats),
176
+ disk: bucket.disk,
177
+ diskError: bucket.diskError || "",
131
178
  syncing: config?.syncing,
132
179
  error: bucket.error || "",
133
180
  });
@@ -136,6 +183,60 @@ function getBucketRows(servers: StorageServerBuckets[]): BucketRow[] {
136
183
  return rows;
137
184
  }
138
185
 
186
+ const ACTIVATE_ERROR_COLOR = { h: 0, s: 60, l: 40 };
187
+
188
+ /** An inactive bucket exists on the server's disk but isn't loaded, so it isn't synchronizing. Activating loads it, which starts synchronization. */
189
+ class ActivateBucketButton extends qreact.Component<{ serverUrl: string; bucketName: string }> {
190
+ state = {
191
+ activating: false,
192
+ error: "",
193
+ };
194
+ private async activate() {
195
+ this.state.activating = true;
196
+ this.state.error = "";
197
+ try {
198
+ let result = await activateServerBucket({
199
+ url: this.props.serverUrl,
200
+ account: STORAGE_ACCOUNT,
201
+ bucketName: this.props.bucketName,
202
+ });
203
+ // The server reports a refusal as a string instead of throwing
204
+ if (typeof result === "string") {
205
+ this.state.error = result;
206
+ console.error(`Activating bucket ${this.props.bucketName} on ${this.props.serverUrl} was refused: ${result}`);
207
+ return;
208
+ }
209
+ console.log(`Activated bucket ${this.props.bucketName} on ${this.props.serverUrl}`);
210
+ StorageSynced(SocketFunction.browserNodeId()).getStorageBuckets.resetAll();
211
+ } catch (e) {
212
+ this.state.error = String((e as Error).stack ?? e).slice(0, ERROR_TEXT_LIMIT);
213
+ console.error(`Activating bucket ${this.props.bucketName} on ${this.props.serverUrl} failed:`, (e as Error).stack ?? e);
214
+ } finally {
215
+ this.state.activating = false;
216
+ }
217
+ }
218
+ render() {
219
+ return <div className={css.hbox(6).wrap}>
220
+ <div className={css.whiteSpace("nowrap")}>{INACTIVE_STATE}</div>
221
+ <button
222
+ className={css.pad2(6, 1).button.bord2(0, 0, 20).hsl(0, 0, 100).whiteSpace("nowrap")}
223
+ disabled={this.state.activating}
224
+ onClick={() => {
225
+ void this.activate();
226
+ }}
227
+ >
228
+ {this.state.activating && "Activating..." || "Activate"}
229
+ </button>
230
+ {this.state.error && <div
231
+ className={css.colorhsl(ACTIVATE_ERROR_COLOR.h, ACTIVATE_ERROR_COLOR.s, ACTIVATE_ERROR_COLOR.l).ellipsis}
232
+ title={this.state.error}
233
+ >
234
+ {this.state.error}
235
+ </div>}
236
+ </div>;
237
+ }
238
+ }
239
+
139
240
  const SOURCE_BAR_COLOR = { h: 210, s: 65, l: 55 };
140
241
  const REMOTE_SOURCE_BAR_COLOR = { h: 0, s: 75, l: 55 };
141
242
  const SOURCE_BAR_OPACITY = 0.3;
@@ -181,7 +282,7 @@ class SourceNameParts extends qreact.Component<{ debugName: string }> {
181
282
  }
182
283
  }
183
284
 
184
- class IndexSourcesCell extends qreact.Component<{ sources: BucketConfig["indexSources"] }> {
285
+ class IndexSourcesCell extends qreact.Component<{ sources: ArchivesConfig["indexSources"] }> {
185
286
  render() {
186
287
  let sources = [...this.props.sources || []];
187
288
  if (!sources.length) return undefined;
@@ -214,9 +315,66 @@ class IndexSourcesCell extends qreact.Component<{ sources: BucketConfig["indexSo
214
315
  }
215
316
  }
216
317
 
318
+ const DEPLOY_PENDING_COLOR = { h: 35, s: 80, l: 45 };
319
+ const DEPLOY_OVERLAP_COLOR = { h: 280, s: 60, l: 45 };
320
+ const DEPLOY_NOTICE_FONT_SIZE = 14;
321
+
322
+ /** The services behind these storage servers may be mid-release, which moves (or duplicates) the servers the page just read from. */
323
+ class DeployNotices extends qreact.Component<{ servers: StorageServerBuckets[] }> {
324
+ render() {
325
+ let now = Querysub.nowDelayed(timeInSecond);
326
+ let byService = new Map<string, StorageServerBuckets>();
327
+ for (let server of this.props.servers) {
328
+ if (byService.has(server.serviceKey)) continue;
329
+ byService.set(server.serviceKey, server);
330
+ }
331
+ let notices: preact.ComponentChild[] = [];
332
+ for (let [serviceKey, service] of byService) {
333
+ let releaseTime = service.releaseTime || 0;
334
+ // The old instances run out their overlap purely on time - we don't verify they're actually still up
335
+ if (!releaseTime || !service.hasOldParameters) continue;
336
+ let killTime = releaseTime + service.overlapTime;
337
+ if (now < releaseTime) {
338
+ notices.push(<div
339
+ key={serviceKey + "pending"}
340
+ className={css.pad2(10, 6).fontSize(DEPLOY_NOTICE_FONT_SIZE).boldStyle.colorhsl(DEPLOY_PENDING_COLOR.h, DEPLOY_PENDING_COLOR.s, DEPLOY_PENDING_COLOR.l).bord2(DEPLOY_PENDING_COLOR.h, DEPLOY_PENDING_COLOR.s, DEPLOY_PENDING_COLOR.l + 35)}
341
+ title={`Deploys at ${formatDateTimeDetailed(releaseTime)}`}
342
+ >
343
+ 🕐 {service.serviceTitle} ({serviceKey}) deploys in {formatTime(releaseTime - now)}
344
+ </div>);
345
+ continue;
346
+ }
347
+ if (now < killTime) {
348
+ notices.push(<div
349
+ key={serviceKey + "overlap"}
350
+ className={css.pad2(10, 6).fontSize(DEPLOY_NOTICE_FONT_SIZE).boldStyle.colorhsl(DEPLOY_OVERLAP_COLOR.h, DEPLOY_OVERLAP_COLOR.s, DEPLOY_OVERLAP_COLOR.l).bord2(DEPLOY_OVERLAP_COLOR.h, DEPLOY_OVERLAP_COLOR.s, DEPLOY_OVERLAP_COLOR.l + 35)}
351
+ title={`Old instances shut down at ${formatDateTimeDetailed(killTime)}`}
352
+ >
353
+ 🔀 {service.serviceTitle} ({serviceKey}) still has an old version running for {formatTime(killTime - now)}
354
+ </div>);
355
+ }
356
+ }
357
+ if (!notices.length) return undefined;
358
+ return <div className={css.hbox(8).wrap}>{notices}</div>;
359
+ }
360
+ }
361
+
362
+ async function resetWriteStats(servers: StorageServerBuckets[]): Promise<void> {
363
+ await Promise.all(servers.map(async server => {
364
+ if (server.error) return;
365
+ try {
366
+ let { clearedBuckets } = await clearServerWriteStats({ url: server.url, account: STORAGE_ACCOUNT });
367
+ console.log(`Cleared write stats for ${clearedBuckets} buckets on ${server.url}`);
368
+ } catch (e) {
369
+ console.error(`Clearing write stats on ${server.url} failed:`, (e as Error).stack ?? e);
370
+ }
371
+ }));
372
+ StorageSynced(SocketFunction.browserNodeId()).getStorageBuckets.resetAll();
373
+ }
374
+
217
375
  export class StoragePage extends qreact.Component {
218
376
  render() {
219
- let servers = StorageSynced(SocketFunction.browserNodeId()).getStorageBuckets();
377
+ const servers = StorageSynced(SocketFunction.browserNodeId()).getStorageBuckets();
220
378
  if (!servers) return <div>Loading storage servers...</div>;
221
379
  if (!servers.length) {
222
380
  return <div className={css.vbox(16)}>
@@ -227,6 +385,12 @@ export class StoragePage extends qreact.Component {
227
385
  return <div className={css.vbox(16)}>
228
386
  <div className={css.hbox(12)}>
229
387
  <h2 className={css.flexGrow(1)}>Storage</h2>
388
+ <button className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 0, 100)}
389
+ onClick={() => {
390
+ void resetWriteStats(servers);
391
+ }}>
392
+ Reset write stats
393
+ </button>
230
394
  <button className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 0, 100)}
231
395
  onClick={() => {
232
396
  StorageSynced(SocketFunction.browserNodeId()).getStorageBuckets.resetAll();
@@ -234,12 +398,21 @@ export class StoragePage extends qreact.Component {
234
398
  Refresh
235
399
  </button>
236
400
  </div>
401
+ <DeployNotices servers={servers} />
237
402
  <Table
238
403
  rows={getBucketRows(servers)}
239
404
  columns={{
240
405
  server: { title: "Server" },
241
406
  bucket: { title: "Bucket" },
242
- state: { title: "State" },
407
+ serverUrl: null,
408
+ state: {
409
+ title: "State",
410
+ formatter: (state, context) => {
411
+ let row = context?.row;
412
+ if (!row || !row.bucket || state !== INACTIVE_STATE) return state;
413
+ return <ActivateBucketButton serverUrl={row.serverUrl} bucketName={row.bucket} />;
414
+ }
415
+ },
243
416
  files: { title: "Files" },
244
417
  bytes: { title: "Bytes" },
245
418
  indexSources: {
@@ -247,6 +420,22 @@ export class StoragePage extends qreact.Component {
247
420
  formatter: sources => <IndexSourcesCell sources={sources} />
248
421
  },
249
422
  readerDiskLimit: { title: "Read cache limit" },
423
+ writes: { title: "Writes" },
424
+ written: { title: "Written" },
425
+ writeGain: { title: "Coalescing gain" },
426
+ disk: {
427
+ title: "Drive",
428
+ formatter: (disk, context) => {
429
+ if (!disk) return context?.row?.diskError || "";
430
+ return <UsageBar
431
+ label={DISK_BAR_TYPE}
432
+ value={disk.usedBytes}
433
+ max={disk.totalBytes}
434
+ {...getUsageThresholds(DISK_BAR_TYPE)}
435
+ />;
436
+ }
437
+ },
438
+ diskError: null,
250
439
  syncing: {
251
440
  title: "Syncing",
252
441
  formatter: syncing => <div className={css.vbox(2)}>