querysub 0.547.0 → 0.549.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.549.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.35",
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,13 @@ 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_ACTIVE_COLOR = { h: 195, s: 60, l: 85 };
27
31
  const WINDOW_HEADER_SIZE = 13;
28
32
  const BUCKET_TITLE_SIZE = 15;
29
33
  const GROUP_BODY_INDENT = 16;
30
34
  const TAG_FONT_SIZE = 11;
31
35
  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
36
 
35
37
  /** String sources are the shorthand for an always-valid, unsharded backblaze bucket. */
36
38
  function normalizeSource(source: RemoteConfigBase): Source {
@@ -38,23 +40,37 @@ function normalizeSource(source: RemoteConfigBase): Source {
38
40
  return { type: "backblaze", url: source, validWindow: FULL_VALID_WINDOW };
39
41
  }
40
42
 
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;
43
+ /** Where a source lives, with the bucket the config belongs to masked out so buckets that differ only by their own name group together. */
44
+ function getUrlIdentity(source: Source, ownBucketName: string): unknown {
45
+ let maskBucket = (bucketName: string) => bucketName === ownBucketName && "" || bucketName;
44
46
  try {
45
47
  if (source.type === "backblaze") {
46
- return `b2 ${maskBucket(parseBackblazeUrl(source.url).bucketName)}`;
48
+ return { type: "backblaze", bucketName: maskBucket(parseBackblazeUrl(source.url).bucketName) };
47
49
  }
48
50
  let { address, port, account, bucketName } = parseHostedUrl(source.url);
51
+ return { type: "remote", address, port, account, bucketName: maskBucket(bucketName) };
52
+ } catch {
53
+ return source.url;
54
+ }
55
+ }
56
+
57
+ /** 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. */
58
+ function describeUrl(source: Source, ownBucketName: string): string {
59
+ try {
60
+ if (source.type === "backblaze") {
61
+ let { bucketName } = parseBackblazeUrl(source.url);
62
+ return bucketName === ownBucketName && "b2" || `b2 ${bucketName}`;
63
+ }
64
+ let { address, port, bucketName } = parseHostedUrl(source.url);
49
65
  let host = port === DEFAULT_HTTPS_PORT && address || `${address}:${port}`;
50
- return `${host} ${account}/${maskBucket(bucketName)}`;
66
+ return bucketName === ownBucketName && host || `${host} ${bucketName}`;
51
67
  } catch {
52
68
  return source.url;
53
69
  }
54
70
  }
55
71
 
56
72
  function getSourceKey(source: Source, ownBucketName: string): string {
57
- return JSON.stringify({ ...source, url: describeUrl(source, ownBucketName) });
73
+ return JSON.stringify({ ...source, url: getUrlIdentity(source, ownBucketName) });
58
74
  }
59
75
 
60
76
  /** Identifies a config independently of which bucket it configures, so identical configs on different buckets collapse into one display. */
@@ -194,6 +210,100 @@ export function getRouteConfigGroups(servers: StorageServerBuckets[]): RouteConf
194
210
  return results;
195
211
  }
196
212
 
213
+ type LiveWatch = {
214
+ serverUrl: string;
215
+ bucketName: string;
216
+ loading: boolean;
217
+ routing?: RemoteConfig;
218
+ error?: string;
219
+ updatedTime?: number;
220
+ };
221
+
222
+ // Watches only live as long as the page is open - nothing about them is persisted
223
+ const liveWatchData = Querysub.createLocalSchema<{ watches: { [key: string]: LiveWatch } }>("storageLiveWatches");
224
+
225
+ function getWatchKey(serverUrl: string, bucketName: string): string {
226
+ return `${serverUrl}|${bucketName}`;
227
+ }
228
+
229
+ /** 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). */
230
+ function getServerUrl(source: Source): string | undefined {
231
+ if (source.type !== "remote") return undefined;
232
+ try {
233
+ let { address, port } = parseHostedUrl(source.url);
234
+ return `https://${address}:${port}`;
235
+ } catch {
236
+ return undefined;
237
+ }
238
+ }
239
+
240
+ async function refreshWatch(key: string): Promise<void> {
241
+ // Reads outside a synchronized context have to go through localRead, and the plain values are pulled out here so nothing proxied escapes into the async work
242
+ let target = Querysub.localRead(() => {
243
+ let watch = liveWatchData().watches[key];
244
+ if (!watch) return undefined;
245
+ return { serverUrl: watch.serverUrl, bucketName: watch.bucketName };
246
+ });
247
+ if (!target) return;
248
+ let { serverUrl, bucketName } = target;
249
+ Querysub.commit(() => {
250
+ let current = liveWatchData().watches[key];
251
+ if (current) current.loading = true;
252
+ });
253
+ let live = await getServerActiveBucket({ url: serverUrl, account: STORAGE_ACCOUNT, bucketName });
254
+ // "Not loaded here" comes back as a string - it's a normal result, not an error
255
+ let error: string | undefined;
256
+ let result: RemoteConfig | undefined;
257
+ if (typeof live === "string") {
258
+ error = live;
259
+ } else {
260
+ result = live.routing;
261
+ }
262
+ Querysub.commit(() => {
263
+ let current = liveWatchData().watches[key];
264
+ // The watch may have been removed while the call was in flight
265
+ if (!current) return;
266
+ current.loading = false;
267
+ current.routing = result;
268
+ current.error = error;
269
+ current.updatedTime = Date.now();
270
+ });
271
+ }
272
+
273
+ function isWatched(serverUrl: string, bucketName: string): boolean {
274
+ return !!liveWatchData().watches[getWatchKey(serverUrl, bucketName)];
275
+ }
276
+
277
+ function toggleWatch(serverUrl: string, bucketName: string): void {
278
+ let key = getWatchKey(serverUrl, bucketName);
279
+ let existed = Querysub.localRead(() => !!liveWatchData().watches[key]);
280
+ Querysub.commit(() => {
281
+ if (existed) {
282
+ delete liveWatchData().watches[key];
283
+ return;
284
+ }
285
+ liveWatchData().watches[key] = { serverUrl, bucketName, loading: true };
286
+ });
287
+ if (existed) return;
288
+ void refreshWatch(key);
289
+ }
290
+
291
+ function refreshAllWatches(): void {
292
+ // Fired from a timer, so the key list has to be read through localRead
293
+ let keys = Querysub.localRead(() => Object.keys(liveWatchData().watches));
294
+ for (let key of keys) {
295
+ void refreshWatch(key);
296
+ }
297
+ }
298
+
299
+ function clearWatches(): void {
300
+ Querysub.commit(() => {
301
+ for (let key of Object.keys(liveWatchData().watches)) {
302
+ delete liveWatchData().watches[key];
303
+ }
304
+ });
305
+ }
306
+
197
307
  function showConfigModal(bucketName: string, rawConfig: RemoteConfig): void {
198
308
  let close = showModal({
199
309
  content: <FullscreenModal onCancel={() => close.close()}>
@@ -235,6 +345,24 @@ function getSourceTags(source: Source): { icon: string; text: string }[] {
235
345
  return tags;
236
346
  }
237
347
 
348
+ /** 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. */
349
+ class WatchButton extends qreact.Component<{ source: Source; bucketName: string }> {
350
+ render() {
351
+ const serverUrl = getServerUrl(this.props.source);
352
+ // Only storage servers have a live in-memory state to read
353
+ if (!serverUrl) return undefined;
354
+ let watching = isWatched(serverUrl, this.props.bucketName);
355
+ return <Button
356
+ flavor="tiny"
357
+ className={watching && css.hsl(WATCH_ACTIVE_COLOR.h, WATCH_ACTIVE_COLOR.s, WATCH_ACTIVE_COLOR.l) || ""}
358
+ title={watching && "Stop watching this server's live config" || "Watch this server's live config"}
359
+ onClick={() => toggleWatch(serverUrl, this.props.bucketName)}
360
+ >
361
+ 👁
362
+ </Button>;
363
+ }
364
+ }
365
+
238
366
  class SourceRow extends qreact.Component<{ source: Source; window: [number, number]; ownBucketName: string }> {
239
367
  render() {
240
368
  let { source, window, ownBucketName } = this.props;
@@ -248,6 +376,7 @@ class SourceRow extends qreact.Component<{ source: Source; window: [number, numb
248
376
  .hbox(6).pad2(8, 4).boxSizing("border-box").whiteSpace("nowrap")
249
377
  .hsl(0, 0, 100).bord2(SOURCE_BORDER_COLOR.h, SOURCE_BORDER_COLOR.s, SOURCE_BORDER_COLOR.l)
250
378
  }>
379
+ <WatchButton source={source} bucketName={ownBucketName} />
251
380
  <div className={css.boldStyle}>{describeUrl(source, ownBucketName)}</div>
252
381
  {source.route && <div className={css.fontSize(TAG_FONT_SIZE).colorhsl(0, 0, 40)}>
253
382
  route {routeStart} – {routeEnd}
@@ -337,11 +466,72 @@ class RouteConfigGroupView extends qreact.Component<{ group: RouteConfigGroup }>
337
466
  }
338
467
  }
339
468
 
469
+ /** One watched server's own live, in-memory view of a bucket - which is what shows a server disagreeing with everyone else. */
470
+ class LiveWatchView extends qreact.Component<{ watchKey: string; watch: LiveWatch }> {
471
+ render() {
472
+ let { watchKey, watch } = this.props;
473
+ let now = Querysub.timeDelayed(TIME_REFRESH_INTERVAL);
474
+ let sources = (watch.routing?.sources || []).map(normalizeSource);
475
+ let clusters = getWindowClusters(sources);
476
+ return <div className={css.vbox(10).fillWidth.pad2(10, 8).bord2(WATCH_ACTIVE_COLOR.h, WATCH_ACTIVE_COLOR.s, WATCH_ACTIVE_COLOR.l - 25)}>
477
+ <div className={css.hbox(8).wrap.fontSize(WINDOW_HEADER_SIZE)}>
478
+ <div className={css.boldStyle}>👁 {watch.bucketName}</div>
479
+ <div className={css.colorhsl(0, 0, 45)}>live on {watch.serverUrl}</div>
480
+ <div className={css.colorhsl(0, 0, 45)}>
481
+ {watch.loading && "refreshing..."
482
+ || watch.updatedTime && `updated ${formatTime(now - watch.updatedTime)} ago`
483
+ || ""}
484
+ </div>
485
+ <div className={css.flexGrow(1)} />
486
+ <Button flavor="tiny" onClick={() => void refreshWatch(watchKey)}>Refresh</Button>
487
+ <Button flavor="tiny" onClick={() => toggleWatch(watch.serverUrl, watch.bucketName)}>Stop watching</Button>
488
+ </div>
489
+ {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)}>
490
+ ⚠ {watch.error}
491
+ </div>}
492
+ <div className={css.vbox(10).fillWidth.paddingLeft(GROUP_BODY_INDENT).boxSizing("border-box")}>
493
+ {clusters.map((cluster, index) => {
494
+ let status = getWindowStatus(cluster.window, now);
495
+ return <div key={index} className={css.vbox(6).fillWidth}>
496
+ <div className={css.hbox(6).wrap.colorhsl(0, 0, 35).fontSize(TAG_FONT_SIZE)}>
497
+ <div>{formatWindowTime(cluster.window[0])} → {formatWindowTime(cluster.window[1])}</div>
498
+ <Tag icon={status.icon} text={status.text} color={status.color} />
499
+ </div>
500
+ {cluster.sources.map(source => <SourceRow
501
+ key={getSourceKey(source, watch.bucketName)}
502
+ source={source}
503
+ window={cluster.window}
504
+ ownBucketName={watch.bucketName}
505
+ />)}
506
+ </div>;
507
+ })}
508
+ </div>
509
+ </div>;
510
+ }
511
+ }
512
+
340
513
  export class RouteConfigView extends qreact.Component<{ servers: StorageServerBuckets[] }> {
514
+ private pollTimer: ReturnType<typeof setInterval> | undefined;
515
+ componentDidMount() {
516
+ this.pollTimer = setInterval(refreshAllWatches, WATCH_POLL_INTERVAL);
517
+ }
518
+ componentWillUnmount() {
519
+ if (this.pollTimer !== undefined) {
520
+ clearInterval(this.pollTimer);
521
+ this.pollTimer = undefined;
522
+ }
523
+ // Watches are only meaningful while the page is open, and leaving them would keep polling on the next mount
524
+ clearWatches();
525
+ }
341
526
  render() {
342
527
  let groups = getRouteConfigGroups(this.props.servers);
343
- if (!groups.length) return undefined;
528
+ let watches = Object.entries(liveWatchData().watches);
529
+ if (!groups.length && !watches.length) return undefined;
344
530
  return <div className={css.vbox(20).fillWidth}>
531
+ {watches.length > 0 && <div className={css.vbox(10).fillWidth}>
532
+ <h3>Live configs</h3>
533
+ {watches.map(([key, watch]) => <LiveWatchView key={key} watchKey={key} watch={watch} />)}
534
+ </div>}
345
535
  <h3>Routing configs</h3>
346
536
  {groups.map((group, index) => <RouteConfigGroupView key={index} group={group} />)}
347
537
  </div>;
@@ -701,7 +701,7 @@ export class ServiceDetailPage extends qreact.Component {
701
701
  Cancel Scheduled Deploy
702
702
  </button>
703
703
  <button
704
- className={css.pad2(12, 8).button.bord2(0, 70, 40).hsl(0, 70, 90)}
704
+ className={css.pad2(12, 8).button.bord2(45, 80, 35).hsl(45, 85, 82)}
705
705
  disabled={this.state.isDeploying}
706
706
  title="Changes the scheduled deploy to go live immediately"
707
707
  onClick={() => {
@@ -818,8 +818,9 @@ export class ServiceDetailPage extends qreact.Component {
818
818
  </span>}
819
819
  <div className={css.hbox(8)}>
820
820
  <button
821
- className={css.pad2(12, 8).button.bord2(0, 70, 40).hsl(0, 70, 90)}
821
+ className={css.pad2(12, 8).button.bord2(45, 80, 35).hsl(45, 85, 82)}
822
822
  disabled={this.state.isDeploying}
823
+ title="Deploys immediately; the old instances keep running for their configured overlap before shutting down"
823
824
  onClick={() => {
824
825
  this.confirmForceDeployNow(makeDeployConfig(Date.now()));
825
826
  }}>
@@ -1,13 +1,18 @@
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 { getLiveServiceParameters, getMachineTargets, applyCommandTemplate, ServiceParameters, ServiceConfig, MachineServiceController, DEFAULT_OVERLAP_TIME } from "../machineSchema";
15
+ import { isDefined } from "../../misc";
11
16
  import { STORAGE_ACCOUNT } from "../../-a-archives/archives2";
12
17
  import { Table } from "../../5-diagnostics/Table";
13
18
  import { RouteConfigView } from "./RouteConfigView";
@@ -16,13 +21,24 @@ module.hotreload = true;
16
21
 
17
22
  const STORAGE_COMMAND_PREFIX = "yarn storageserve";
18
23
  const URL_ARG_REGEX = /--url\s+(\S+)/g;
19
- const ERROR_TEXT_LIMIT = 500;
20
24
 
21
- export type StorageServerBuckets = {
25
+ /** The release a storage service is in the middle of. Read straight from the service configs, so it resolves without contacting any storage server - which is exactly when it matters most, since a deploy is the usual reason the servers don't answer. */
26
+ export type StorageService = {
22
27
  serviceKey: string;
28
+ serviceTitle: string;
29
+ releaseTime?: number;
30
+ overlapTime: number;
31
+ hasOldParameters: boolean;
32
+ };
33
+
34
+ export type StorageServer = StorageService & {
23
35
  url: string;
36
+ };
37
+
38
+ export type StorageServerBuckets = StorageServer & {
39
+ /** Still waiting on this server's own endpoint - each server loads independently */
40
+ loading?: boolean;
24
41
  buckets?: ServerBucketInfo[];
25
- error?: string;
26
42
  };
27
43
 
28
44
  function getStorageUrls(parameters: ServiceParameters): string[] {
@@ -37,27 +53,43 @@ function getStorageUrls(parameters: ServiceParameters): string[] {
37
53
  return urls;
38
54
  }
39
55
 
40
- class StoragePageControllerBase {
41
- public async getStorageBuckets(): Promise<StorageServerBuckets[]> {
42
- let configs = await getEffectiveServiceConfigs();
43
- let serviceKeyByUrl = new Map<string, string>();
44
- for (let config of configs) {
45
- let allParameters = [getLiveServiceParameters(config), config.parameters, config.oldParameters];
46
- for (let parameters of allParameters) {
47
- if (!parameters) continue;
48
- for (let url of getStorageUrls(parameters)) {
49
- if (serviceKeyByUrl.has(url)) continue;
50
- serviceKeyByUrl.set(url, parameters.key);
51
- }
56
+ /** The storage servers a set of service configs runs, worked out on the client - it already has the configs, so asking the server to re-derive them would just make the whole page wait on one uncacheable call. */
57
+ export function getStorageServers(configs: ServiceConfig[]): StorageServer[] {
58
+ let serviceByUrl = new Map<string, StorageServer>();
59
+ for (let config of configs) {
60
+ let allParameters = [getLiveServiceParameters(config), config.parameters, config.oldParameters];
61
+ for (let parameters of allParameters) {
62
+ if (!parameters) continue;
63
+ for (let url of getStorageUrls(parameters)) {
64
+ if (serviceByUrl.has(url)) continue;
65
+ serviceByUrl.set(url, {
66
+ url,
67
+ serviceKey: parameters.key,
68
+ serviceTitle: config.info.title,
69
+ releaseTime: config.parameters.releaseTime,
70
+ // The overlap always comes from the newest parameters, even though it governs how long the old instances outlive the release
71
+ overlapTime: config.parameters.overlapTime ?? DEFAULT_OVERLAP_TIME,
72
+ hasOldParameters: !!config.oldParameters,
73
+ });
52
74
  }
53
75
  }
54
- return await Promise.all([...serviceKeyByUrl].map(async ([url, serviceKey]) => {
55
- try {
56
- return { serviceKey, url, buckets: await listServerBuckets({ url, account: STORAGE_ACCOUNT }) };
57
- } catch (e) {
58
- return { serviceKey, url, error: String((e as Error).stack ?? e).slice(0, ERROR_TEXT_LIMIT) };
59
- }
60
- }));
76
+ }
77
+ return [...serviceByUrl.values()];
78
+ }
79
+
80
+ export function getStorageServices(servers: StorageServer[]): StorageService[] {
81
+ let byKey = new Map<string, StorageService>();
82
+ for (let server of servers) {
83
+ if (byKey.has(server.serviceKey)) continue;
84
+ byKey.set(server.serviceKey, server);
85
+ }
86
+ return [...byKey.values()];
87
+ }
88
+
89
+ class StoragePageControllerBase {
90
+ /** Purely a forwarder - browsers can't authenticate to a storage server themselves. One call per server, so each caches and refreshes on its own. */
91
+ public async getServerBuckets(url: string): Promise<ServerBucketInfo[]> {
92
+ return await listServerBuckets({ url, account: STORAGE_ACCOUNT });
61
93
  }
62
94
  }
63
95
 
@@ -65,7 +97,7 @@ export const StoragePageController = SocketFunction.register(
65
97
  "StoragePageController-4f1c93ab-77e2-4d15-9a30-1c6b8f5d2e04",
66
98
  new StoragePageControllerBase(),
67
99
  () => ({
68
- getStorageBuckets: {},
100
+ getServerBuckets: {},
69
101
  }),
70
102
  () => ({
71
103
  hooks: [assertIsManagementUser],
@@ -79,36 +111,61 @@ const StorageSynced = getSyncedController(StoragePageController, {
79
111
  writes: {},
80
112
  });
81
113
 
82
- type BucketConfig = NonNullable<ServerBucketInfo["config"]>;
83
-
84
114
  type BucketRow = {
85
115
  server: string;
116
+ /** Kept even on the rows that blank out the server column, so the row's buttons know which server to call */
117
+ serverUrl: string;
86
118
  bucket: string;
87
119
  state: string;
88
120
  files: string;
89
121
  bytes: string;
90
- indexSources: BucketConfig["indexSources"];
122
+ indexSources: ArchivesConfig["indexSources"];
91
123
  readerDiskLimit: string;
92
- syncing: BucketConfig["syncing"];
124
+ writes: string;
125
+ written: string;
126
+ writeGain: string;
127
+ disk?: BucketDiskInfo;
128
+ diskError: string;
129
+ syncing: ArchivesConfig["syncing"];
93
130
  error: string;
94
131
  };
95
132
 
133
+ const GAIN_DECIMALS = 1;
134
+ const DISK_BAR_TYPE = "DISK";
135
+ const ACTIVE_STATE = "active";
136
+ const INACTIVE_STATE = "inactive";
137
+
138
+ /** How much fast-mode coalescing saved: every accepted write over the ones that actually reached a source. */
139
+ function getWriteGain(stats: BucketWriteStats | undefined): string {
140
+ if (!stats) return "";
141
+ if (!stats.flushedWrites || !stats.flushedBytes) return "";
142
+ let writeGain = stats.originalWrites / stats.flushedWrites;
143
+ let byteGain = stats.originalBytes / stats.flushedBytes;
144
+ return `${writeGain.toFixed(GAIN_DECIMALS)}X / ${byteGain.toFixed(GAIN_DECIMALS)}X B`;
145
+ }
146
+
96
147
  function getBucketRows(servers: StorageServerBuckets[]): BucketRow[] {
97
148
  let rows: BucketRow[] = [];
98
149
  for (let server of servers) {
99
150
  let baseRow: BucketRow = {
100
151
  server: server.url,
152
+ serverUrl: server.url,
101
153
  bucket: "",
102
154
  state: "",
103
155
  files: "",
104
156
  bytes: "",
105
157
  indexSources: undefined,
106
158
  readerDiskLimit: "",
159
+ writes: "",
160
+ written: "",
161
+ writeGain: "",
162
+ disk: undefined,
163
+ diskError: "",
107
164
  syncing: undefined,
108
165
  error: "",
109
166
  };
110
- if (server.error) {
111
- rows.push({ ...baseRow, error: server.error });
167
+ if (server.loading) {
168
+ rows.push({ ...baseRow, bucket: "Loading..." });
112
169
  continue;
113
170
  }
114
171
  let buckets = [...server.buckets || []];
@@ -123,11 +180,16 @@ function getBucketRows(servers: StorageServerBuckets[]): BucketRow[] {
123
180
  ...baseRow,
124
181
  server: index === 0 && server.url || "",
125
182
  bucket: bucket.bucketName,
126
- state: bucket.active && "active" || "inactive",
183
+ state: bucket.active && ACTIVE_STATE || INACTIVE_STATE,
127
184
  files: config?.index && formatNumber(config.index.fileCount) || "",
128
185
  bytes: config?.index && formatNumber(config.index.byteCount) + "B" || "",
129
186
  indexSources: config?.indexSources,
130
187
  readerDiskLimit: config?.readerDiskLimit && formatNumber(config.readerDiskLimit) + "B" || "",
188
+ writes: bucket.writeStats && formatNumber(bucket.writeStats.originalWrites) || "",
189
+ written: bucket.writeStats && formatNumber(bucket.writeStats.originalBytes) + "B" || "",
190
+ writeGain: getWriteGain(bucket.writeStats),
191
+ disk: bucket.disk,
192
+ diskError: bucket.diskError || "",
131
193
  syncing: config?.syncing,
132
194
  error: bucket.error || "",
133
195
  });
@@ -136,6 +198,58 @@ function getBucketRows(servers: StorageServerBuckets[]): BucketRow[] {
136
198
  return rows;
137
199
  }
138
200
 
201
+ const ACTIVATE_ERROR_COLOR = { h: 0, s: 60, l: 40 };
202
+
203
+ /** An inactive bucket exists on the server's disk but isn't loaded, so it isn't synchronizing. Activating loads it, which starts synchronization. */
204
+ class ActivateBucketButton extends qreact.Component<{ serverUrl: string; bucketName: string }> {
205
+ state = {
206
+ activating: false,
207
+ error: "",
208
+ };
209
+ render() {
210
+ return <div className={css.hbox(6).wrap}>
211
+ <div className={css.whiteSpace("nowrap")}>{INACTIVE_STATE}</div>
212
+ <button
213
+ className={css.pad2(6, 1).button.bord2(0, 0, 20).hsl(0, 0, 100).whiteSpace("nowrap")}
214
+ disabled={this.state.activating}
215
+ onClick={() => {
216
+ // Props and state are synchronized, so everything the async work needs is copied into plain locals HERE, in the tracked part
217
+ let serverUrl = this.props.serverUrl;
218
+ let bucketName = this.props.bucketName;
219
+ this.state.activating = true;
220
+ this.state.error = "";
221
+ Querysub.onCommitFinished(async () => {
222
+ let result = await activateServerBucket({ url: serverUrl, account: STORAGE_ACCOUNT, bucketName });
223
+ // A refusal comes back as a string - it's a normal result, not an error
224
+ let refusal = typeof result === "string" && result || "";
225
+ if (refusal) {
226
+ console.log(`Activating bucket ${bucketName} on ${serverUrl} was refused: ${refusal}`);
227
+ } else {
228
+ console.log(`Activated bucket ${bucketName} on ${serverUrl}`);
229
+ }
230
+ Querysub.commit(() => {
231
+ this.state.activating = false;
232
+ this.state.error = refusal;
233
+ // Only this server's buckets changed
234
+ if (!refusal) {
235
+ StorageSynced(SocketFunction.browserNodeId()).getServerBuckets.reset(serverUrl);
236
+ }
237
+ });
238
+ });
239
+ }}
240
+ >
241
+ {this.state.activating && "Activating..." || "Activate"}
242
+ </button>
243
+ {this.state.error && <div
244
+ className={css.colorhsl(ACTIVATE_ERROR_COLOR.h, ACTIVATE_ERROR_COLOR.s, ACTIVATE_ERROR_COLOR.l).ellipsis}
245
+ title={this.state.error}
246
+ >
247
+ {this.state.error}
248
+ </div>}
249
+ </div>;
250
+ }
251
+ }
252
+
139
253
  const SOURCE_BAR_COLOR = { h: 210, s: 65, l: 55 };
140
254
  const REMOTE_SOURCE_BAR_COLOR = { h: 0, s: 75, l: 55 };
141
255
  const SOURCE_BAR_OPACITY = 0.3;
@@ -181,7 +295,7 @@ class SourceNameParts extends qreact.Component<{ debugName: string }> {
181
295
  }
182
296
  }
183
297
 
184
- class IndexSourcesCell extends qreact.Component<{ sources: BucketConfig["indexSources"] }> {
298
+ class IndexSourcesCell extends qreact.Component<{ sources: ArchivesConfig["indexSources"] }> {
185
299
  render() {
186
300
  let sources = [...this.props.sources || []];
187
301
  if (!sources.length) return undefined;
@@ -214,32 +328,148 @@ class IndexSourcesCell extends qreact.Component<{ sources: BucketConfig["indexSo
214
328
  }
215
329
  }
216
330
 
217
- export class StoragePage extends qreact.Component {
331
+ const DEPLOY_PENDING_HUE = { h: 35, s: 80 };
332
+ const DEPLOY_OVERLAP_HUE = { h: 280, s: 60 };
333
+ const DEPLOY_NOTICE_FONT_SIZE = 14;
334
+ const DEPLOY_NOTICE_TEXT_LIGHTNESS = 30;
335
+ const DEPLOY_NOTICE_BORDER_LIGHTNESS = 55;
336
+ const DEPLOY_NOTICE_BACKGROUND_LIGHTNESS = 94;
337
+
338
+ class DeployNotice extends qreact.Component<{
339
+ hue: { h: number; s: number };
340
+ icon: string;
341
+ title: string;
342
+ detail: string;
343
+ tooltip: string;
344
+ }> {
218
345
  render() {
219
- let servers = StorageSynced(SocketFunction.browserNodeId()).getStorageBuckets();
220
- if (!servers) return <div>Loading storage servers...</div>;
221
- if (!servers.length) {
222
- return <div className={css.vbox(16)}>
223
- <h2>Storage</h2>
224
- <div>No services with a command starting with {JSON.stringify(STORAGE_COMMAND_PREFIX)} were found.</div>
225
- </div>;
346
+ let { hue, icon, title, detail, tooltip } = this.props;
347
+ return <div
348
+ className={
349
+ css.hbox(8).pad2(12, 8).fontSize(DEPLOY_NOTICE_FONT_SIZE).alignItems("center")
350
+ .hsl(hue.h, hue.s, DEPLOY_NOTICE_BACKGROUND_LIGHTNESS)
351
+ .bord2(hue.h, hue.s, DEPLOY_NOTICE_BORDER_LIGHTNESS)
352
+ .colorhsl(hue.h, hue.s, DEPLOY_NOTICE_TEXT_LIGHTNESS)
353
+ }
354
+ title={tooltip}
355
+ >
356
+ <div className={css.fontSize(DEPLOY_NOTICE_FONT_SIZE + 4)}>{icon}</div>
357
+ <div className={css.vbox(2)}>
358
+ <div className={css.boldStyle}>{title}</div>
359
+ <div className={css.fontSize(DEPLOY_NOTICE_FONT_SIZE - 2)}>{detail}</div>
360
+ </div>
361
+ </div>;
362
+ }
363
+ }
364
+
365
+ /** The services behind these storage servers may be mid-release, which moves (or duplicates) the servers the page just read from. */
366
+ class DeployNotices extends qreact.Component<{ services: StorageService[] }> {
367
+ render() {
368
+ let now = Querysub.nowDelayed(timeInSecond);
369
+ let notices: preact.ComponentChild[] = [];
370
+ for (let service of this.props.services) {
371
+ let serviceKey = service.serviceKey;
372
+ let releaseTime = service.releaseTime || 0;
373
+ // The old instances run out their overlap purely on time - we don't verify they're actually still up
374
+ if (!releaseTime || !service.hasOldParameters) continue;
375
+ let killTime = releaseTime + service.overlapTime;
376
+ if (now < releaseTime) {
377
+ notices.push(<DeployNotice
378
+ key={serviceKey + "pending"}
379
+ hue={DEPLOY_PENDING_HUE}
380
+ icon="🕐"
381
+ title={`Deploys in ${formatTime(releaseTime - now)}`}
382
+ detail={`${service.serviceTitle} (${serviceKey})`}
383
+ tooltip={`Deploys at ${formatDateTimeDetailed(releaseTime)}`}
384
+ />);
385
+ continue;
386
+ }
387
+ if (now < killTime) {
388
+ notices.push(<DeployNotice
389
+ key={serviceKey + "overlap"}
390
+ hue={DEPLOY_OVERLAP_HUE}
391
+ icon="🔀"
392
+ title={`Old version still running for ${formatTime(killTime - now)}`}
393
+ detail={`${service.serviceTitle} (${serviceKey})`}
394
+ tooltip={`Old instances shut down at ${formatDateTimeDetailed(killTime)}`}
395
+ />);
396
+ }
226
397
  }
227
- return <div className={css.vbox(16)}>
398
+ if (!notices.length) return undefined;
399
+ return <div className={css.hbox(8).wrap}>{notices}</div>;
400
+ }
401
+ }
402
+
403
+ async function resetWriteStats(servers: StorageServerBuckets[]): Promise<void> {
404
+ await Promise.all(servers.map(async server => {
405
+ if (server.loading) return;
406
+ let { clearedBuckets } = await clearServerWriteStats({ url: server.url, account: STORAGE_ACCOUNT });
407
+ console.log(`Cleared write stats for ${clearedBuckets} buckets on ${server.url}`);
408
+ }));
409
+ Querysub.commit(() => {
410
+ StorageSynced(SocketFunction.browserNodeId()).getServerBuckets.resetAll();
411
+ });
412
+ }
413
+
414
+ export class StoragePage extends qreact.Component {
415
+ render() {
416
+ const controller = StorageSynced(SocketFunction.browserNodeId());
417
+ const machineController = MachineServiceController(SocketFunction.browserNodeId());
418
+ const serviceList = machineController.getServiceList();
419
+ // The configs are already synchronized to the client, so the deploy state needs no round trip at all
420
+ const configs = (serviceList || []).map(serviceId => machineController.getServiceConfig(serviceId)).filter(isDefined);
421
+ const storageServers = getStorageServers(configs);
422
+ const servers: StorageServerBuckets[] = storageServers.map(server => {
423
+ let buckets = controller.getServerBuckets(server.url);
424
+ if (!buckets) return { ...server, loading: true };
425
+ return { ...server, buckets };
426
+ });
427
+ const header = <>
228
428
  <div className={css.hbox(12)}>
229
429
  <h2 className={css.flexGrow(1)}>Storage</h2>
230
430
  <button className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 0, 100)}
231
431
  onClick={() => {
232
- StorageSynced(SocketFunction.browserNodeId()).getStorageBuckets.resetAll();
432
+ void resetWriteStats(servers);
433
+ }}>
434
+ Reset write stats
435
+ </button>
436
+ <button className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 0, 100)}
437
+ onClick={() => {
438
+ controller.getServerBuckets.resetAll();
233
439
  }}>
234
440
  Refresh
235
441
  </button>
236
442
  </div>
443
+ <DeployNotices services={getStorageServices(storageServers)} />
444
+ </>;
445
+ if (!serviceList) {
446
+ return <div className={css.vbox(16)}>
447
+ {header}
448
+ <div>Loading services...</div>
449
+ </div>;
450
+ }
451
+ if (!servers.length) {
452
+ return <div className={css.vbox(16)}>
453
+ {header}
454
+ <div>No services with a command starting with {JSON.stringify(STORAGE_COMMAND_PREFIX)} were found.</div>
455
+ </div>;
456
+ }
457
+ return <div className={css.vbox(16)}>
458
+ {header}
237
459
  <Table
238
460
  rows={getBucketRows(servers)}
239
461
  columns={{
240
462
  server: { title: "Server" },
241
463
  bucket: { title: "Bucket" },
242
- state: { title: "State" },
464
+ serverUrl: null,
465
+ state: {
466
+ title: "State",
467
+ formatter: (state, context) => {
468
+ let row = context?.row;
469
+ if (!row || !row.bucket || state !== INACTIVE_STATE) return state;
470
+ return <ActivateBucketButton serverUrl={row.serverUrl} bucketName={row.bucket} />;
471
+ }
472
+ },
243
473
  files: { title: "Files" },
244
474
  bytes: { title: "Bytes" },
245
475
  indexSources: {
@@ -247,6 +477,22 @@ export class StoragePage extends qreact.Component {
247
477
  formatter: sources => <IndexSourcesCell sources={sources} />
248
478
  },
249
479
  readerDiskLimit: { title: "Read cache limit" },
480
+ writes: { title: "Writes" },
481
+ written: { title: "Written" },
482
+ writeGain: { title: "Coalescing gain" },
483
+ disk: {
484
+ title: "Drive",
485
+ formatter: (disk, context) => {
486
+ if (!disk) return context?.row?.diskError || "";
487
+ return <UsageBar
488
+ label={DISK_BAR_TYPE}
489
+ value={disk.usedBytes}
490
+ max={disk.totalBytes}
491
+ {...getUsageThresholds(DISK_BAR_TYPE)}
492
+ />;
493
+ }
494
+ },
495
+ diskError: null,
250
496
  syncing: {
251
497
  title: "Syncing",
252
498
  formatter: syncing => <div className={css.vbox(2)}>
@@ -85,7 +85,7 @@ export class UpdateButtons extends qreact.Component<{
85
85
  : "All services release immediately. Their old instances keep running for their configured overlap before shutting down."}</div>
86
86
  <div className={css.hbox(10)}>
87
87
  <button
88
- className={css.pad2(12, 8).button.bord2(0, 70, 40).hsl(0, 70, 90)}
88
+ className={css.pad2(12, 8).button + (noOverlap && css.bord2(0, 90, 30).hsl(0, 85, 65).colorhsl(0, 0, 100).fontWeight("bold") || css.bord2(45, 80, 35).hsl(45, 85, 82))}
89
89
  onClick={() => {
90
90
  modal && modal.close();
91
91
  this.deployAll(outdatedServices, latestRef, noOverlap ? "nowNoOverlap" : "now");
@@ -174,7 +174,7 @@ export class UpdateButtons extends qreact.Component<{
174
174
  </div>)}
175
175
  </button>
176
176
  <button
177
- className={buttonStyle.hsl(0, 70, 90)}
177
+ className={buttonStyle.hsl(45, 85, 82)}
178
178
  disabled={this.state.isDeploying}
179
179
  title="Deploys immediately; the old instances keep running for their configured overlap before shutting down"
180
180
  onClick={() => {