querysub 0.554.0 → 0.555.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.554.0",
3
+ "version": "0.555.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",
@@ -1,8 +1,9 @@
1
1
  import { qreact } from "../../4-dom/qreact";
2
2
  import { Querysub } from "../../4-querysub/Querysub";
3
+ import { t } from "../../2-proxy/schema2";
3
4
  import { css } from "typesafecss";
4
5
  import { sort } from "socket-function/src/misc";
5
- import { formatNumber, formatDateTime, formatTime } from "socket-function/src/formatting/format";
6
+ import { formatNumber, formatDateTime, formatTime, formatDateTimeDetailed } from "socket-function/src/formatting/format";
6
7
  import { parseHostedUrl, parseBackblazeUrl } from "sliftutils/storage/remoteStorage/remoteConfig";
7
8
  import { SocketFunction } from "socket-function/SocketFunction";
8
9
  import { FULL_VALID_WINDOW, FULL_ROUTE } from "sliftutils/storage/IArchives";
@@ -28,8 +29,13 @@ const TIME_REFRESH_INTERVAL = 60 * 1000;
28
29
  const WATCH_POLL_INTERVAL = 60 * 1000;
29
30
  const WATCH_ACTIVE_COLOR = { h: 195, s: 60, l: 85 };
30
31
  const WINDOW_HEADER_SIZE = 13;
32
+ const WINDOW_TIME_SIZE = 14;
33
+ const INACTIVE_WINDOW_OPACITY = 0.5;
34
+ const EXCESS_COLOR = { h: 265, s: 40, l: 88 };
35
+ const WINDOW_TIME_LIGHTNESS = 12;
31
36
  const BUCKET_TITLE_SIZE = 15;
32
- const GROUP_BODY_INDENT = 16;
37
+ const GROUP_BODY_INDENT = 64;
38
+ const WINDOW_BODY_INDENT = 64;
33
39
  const TAG_FONT_SIZE = 11;
34
40
  const JSON_INDENT = 4;
35
41
 
@@ -82,6 +88,11 @@ function formatWindowTime(time: number): string {
82
88
  if (time >= FULL_VALID_WINDOW[1]) return "forever";
83
89
  return formatDateTime(time);
84
90
  }
91
+ function formatWindowTimeDetailed(time: number): string {
92
+ if (time <= FULL_VALID_WINDOW[0]) return "the beginning";
93
+ if (time >= FULL_VALID_WINDOW[1]) return "forever";
94
+ return formatDateTimeDetailed(time);
95
+ }
85
96
 
86
97
  // A missing version sorts below any explicit one, matching getConfigVersion
87
98
  function getSortVersion(variant: ConfigVariant): number {
@@ -117,27 +128,34 @@ type WindowCluster = {
117
128
  sources: Source[];
118
129
  };
119
130
 
120
- /** Sources whose valid windows overlap describe one configuration of the bucket, so they merge into a single displayed window. Sources keep their config order within a cluster - the order sources are listed in decides which one shadows which. */
121
- function getWindowClusters(sources: Source[]): WindowCluster[] {
122
- let byStart = [...sources];
123
- sort(byStart, x => x.validWindow[0]);
124
- let windows: [number, number][] = [];
125
- for (let source of byStart) {
126
- let last = windows[windows.length - 1];
127
- if (last && windowsOverlap(last, source.validWindow)) {
128
- last[0] = Math.min(last[0], source.validWindow[0]);
129
- last[1] = Math.max(last[1], source.validWindow[1]);
130
- continue;
131
- }
132
- windows.push([...source.validWindow]);
133
- }
134
- let clusters: WindowCluster[] = windows.map(window => ({ window, sources: [] }));
131
+ /** Every distinct valid window in the config, latest-ending first, each listing every source that overlaps it. A source spanning several windows appears under each of them - what matters is what is valid during a window, not which window a source "belongs" to. Sources keep their config order, since that order decides which one shadows which. */
132
+ function getWindowRanges(sources: Source[]): WindowCluster[] {
133
+ let byKey = new Map<string, [number, number]>();
135
134
  for (let source of sources) {
136
- let cluster = clusters.find(x => windowsOverlap(x.window, source.validWindow));
137
- if (!cluster) continue;
138
- cluster.sources.push(source);
135
+ let key = source.validWindow.join("|");
136
+ if (byKey.has(key)) continue;
137
+ byKey.set(key, [source.validWindow[0], source.validWindow[1]]);
139
138
  }
140
- return clusters;
139
+ let windows = [...byKey.values()];
140
+ // Sorted by start first, so the (stable) sort by end leaves later starts first within one end time
141
+ sort(windows, x => -x[0]);
142
+ sort(windows, x => -x[1]);
143
+ return windows.map(window => ({
144
+ window,
145
+ sources: sources.filter(x => windowsOverlap(x.validWindow, window)),
146
+ }));
147
+ }
148
+
149
+ /** How far a source reaches past the window it is listed under, on each side. */
150
+ function getWindowExcess(sourceWindow: [number, number], window: [number, number]): { icon: string; text: string }[] {
151
+ let excess: { icon: string; text: string }[] = [];
152
+ if (sourceWindow[0] < window[0]) {
153
+ excess.push({ icon: "←", text: `starts ${formatTime(window[0] - sourceWindow[0])} earlier` });
154
+ }
155
+ if (sourceWindow[1] > window[1]) {
156
+ excess.push({ icon: "→", text: `ends ${formatTime(sourceWindow[1] - window[1])} later` });
157
+ }
158
+ return excess;
141
159
  }
142
160
 
143
161
  export type ConfigVariant = {
@@ -218,8 +236,22 @@ type LiveWatch = {
218
236
  updatedTime?: number;
219
237
  };
220
238
 
221
- // Watches only live as long as the page is open - nothing about them is persisted
222
- const liveWatchData = Querysub.createLocalSchema<{ watches: { [key: string]: LiveWatch } }>("storageLiveWatches");
239
+ // Watches only live as long as the page is open, so they're plain memory. Only the counter is synchronized - an untyped schema map doesn't report keys being added, which is exactly what the views need to re-render on.
240
+ const liveWatches = new Map<string, LiveWatch>();
241
+ const liveWatchVersion = Querysub.createLocalSchema("storageLiveWatchVersion", {
242
+ version: t.number,
243
+ });
244
+
245
+ /** Reading this in a render subscribes it to every watch change. */
246
+ function watchesChanged(): void {
247
+ Querysub.localCommit(() => liveWatchVersion().version++);
248
+ }
249
+ function readWatches(): [string, LiveWatch][] {
250
+ // Subscribes the caller, so adding or refreshing a watch re-renders it
251
+ liveWatchVersion().version;
252
+ // Copied so the views see a new object identity per change - the stored ones are mutated in place
253
+ return [...liveWatches].map(([key, watch]) => [key, { ...watch }]);
254
+ }
223
255
 
224
256
  function getWatchKey(serverUrl: string, bucketName: string): string {
225
257
  return `${serverUrl}|${bucketName}`;
@@ -237,18 +269,11 @@ function getServerUrl(source: Source): string | undefined {
237
269
  }
238
270
 
239
271
  async function refreshWatch(key: string): Promise<void> {
240
- // 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
241
- let target = Querysub.localRead(() => {
242
- let watch = liveWatchData().watches[key];
243
- if (!watch) return undefined;
244
- return { serverUrl: watch.serverUrl, bucketName: watch.bucketName };
245
- });
246
- if (!target) return;
247
- let { serverUrl, bucketName } = target;
248
- Querysub.commit(() => {
249
- let current = liveWatchData().watches[key];
250
- if (current) current.loading = true;
251
- });
272
+ let watch = liveWatches.get(key);
273
+ if (!watch) return;
274
+ let { serverUrl, bucketName } = watch;
275
+ watch.loading = true;
276
+ watchesChanged();
252
277
  try {
253
278
  // Proxied through our server, which holds the identity the storage servers authenticate against
254
279
  let live = await StorageSynced(SocketFunction.browserNodeId()).getActiveBucket.promise(serverUrl, bucketName);
@@ -260,55 +285,48 @@ async function refreshWatch(key: string): Promise<void> {
260
285
  } else {
261
286
  result = live.routing;
262
287
  }
263
- Querysub.commit(() => {
264
- let current = liveWatchData().watches[key];
265
- // The watch may have been removed while the call was in flight
266
- if (!current) return;
288
+ // The watch may have been removed while the call was in flight
289
+ let current = liveWatches.get(key);
290
+ if (current) {
267
291
  current.routing = result;
268
292
  current.error = error;
269
293
  current.updatedTime = Date.now();
270
- });
294
+ }
271
295
  } finally {
272
296
  // Without this a throw would leave the watch stuck showing "refreshing..."
273
- Querysub.commit(() => {
274
- let current = liveWatchData().watches[key];
275
- if (current) current.loading = false;
276
- });
297
+ let current = liveWatches.get(key);
298
+ if (current) current.loading = false;
299
+ watchesChanged();
277
300
  }
278
301
  }
279
302
 
280
303
  function isWatched(serverUrl: string, bucketName: string): boolean {
281
- return !!liveWatchData().watches[getWatchKey(serverUrl, bucketName)];
304
+ // Reads the counter so the button re-renders when its own watch is added or removed
305
+ liveWatchVersion().version;
306
+ return liveWatches.has(getWatchKey(serverUrl, bucketName));
282
307
  }
283
308
 
284
309
  function toggleWatch(serverUrl: string, bucketName: string): void {
285
310
  let key = getWatchKey(serverUrl, bucketName);
286
- let existed = Querysub.localRead(() => !!liveWatchData().watches[key]);
287
- Querysub.commit(() => {
288
- if (existed) {
289
- delete liveWatchData().watches[key];
290
- return;
291
- }
292
- liveWatchData().watches[key] = { serverUrl, bucketName, loading: true };
293
- });
294
- if (existed) return;
311
+ if (liveWatches.has(key)) {
312
+ liveWatches.delete(key);
313
+ watchesChanged();
314
+ return;
315
+ }
316
+ liveWatches.set(key, { serverUrl, bucketName, loading: true });
317
+ watchesChanged();
295
318
  void refreshWatch(key);
296
319
  }
297
320
 
298
321
  function refreshAllWatches(): void {
299
- // Fired from a timer, so the key list has to be read through localRead
300
- let keys = Querysub.localRead(() => Object.keys(liveWatchData().watches));
301
- for (let key of keys) {
322
+ for (let key of [...liveWatches.keys()]) {
302
323
  void refreshWatch(key);
303
324
  }
304
325
  }
305
326
 
306
327
  function clearWatches(): void {
307
- Querysub.commit(() => {
308
- for (let key of Object.keys(liveWatchData().watches)) {
309
- delete liveWatchData().watches[key];
310
- }
311
- });
328
+ liveWatches.clear();
329
+ watchesChanged();
312
330
  }
313
331
 
314
332
  function showConfigModal(bucketName: string, rawConfig: RemoteConfig): void {
@@ -324,13 +342,13 @@ function showConfigModal(bucketName: string, rawConfig: RemoteConfig): void {
324
342
  });
325
343
  }
326
344
 
327
- class Tag extends qreact.Component<{ icon: string; text: string; warning?: boolean; color?: { h: number; s: number; l: number } }> {
345
+ class Tag extends qreact.Component<{ icon: string; text: string; warning?: boolean; color?: { h: number; s: number; l: number }; title?: string; }> {
328
346
  render() {
329
347
  let color = this.props.color || this.props.warning && WARNING_COLOR || TAG_COLOR;
330
348
  return <div className={
331
349
  css.hbox(4).pad2(6, 1).fontSize(TAG_FONT_SIZE).whiteSpace("nowrap")
332
350
  .hsl(color.h, color.s, color.l).bord2(color.h, color.s, color.l - 20)
333
- }>
351
+ } title={this.props.title}>
334
352
  <span>{this.props.icon}</span>
335
353
  <span>{this.props.text}</span>
336
354
  </div>;
@@ -370,14 +388,15 @@ class WatchButton extends qreact.Component<{ source: Source; bucketName: string
370
388
  }
371
389
  }
372
390
 
373
- class SourceRow extends qreact.Component<{ source: Source; window: [number, number]; ownBucketName: string }> {
391
+ class SourceRow extends qreact.Component<{ source: Source; window: [number, number]; ownBucketName: string; now: number }> {
374
392
  render() {
375
- let { source, window, ownBucketName } = this.props;
393
+ let { source, window, ownBucketName, now } = this.props;
376
394
  let [routeStart, routeEnd] = source.route || FULL_ROUTE;
377
395
  let [start, end] = source.validWindow;
378
- // A source that stops short of the window it's displayed under has a gap where the bucket has no configuration
379
- let partial = start > window[0] || end < window[1];
380
- return <div className={css.fillWidth}>
396
+ let excess = getWindowExcess(source.validWindow, window);
397
+ // Faded when this source is not valid right now, whichever window it happens to be listed under
398
+ let active = start <= now && now < end;
399
+ return <div className={css.fillWidth + (!active && css.opacity(INACTIVE_WINDOW_OPACITY) || "")}>
381
400
  <div className={
382
401
  css.marginLeft(`${routeStart * 100}%`).width(`${(routeEnd - routeStart) * 100}%`)
383
402
  .hbox(6).pad2(8, 4).boxSizing("border-box").whiteSpace("nowrap")
@@ -389,16 +408,52 @@ class SourceRow extends qreact.Component<{ source: Source; window: [number, numb
389
408
  route {routeStart} – {routeEnd}
390
409
  </div>}
391
410
  {getSourceTags(source).map(tag => <Tag key={tag.text} icon={tag.icon} text={tag.text} />)}
392
- {partial && <Tag
393
- icon="⚠"
394
- warning={true}
395
- text={`only valid ${formatWindowTime(start)} → ${formatWindowTime(end)}`}
396
- />}
411
+ {excess.map(tag => <Tag
412
+ key={tag.text}
413
+ icon={tag.icon}
414
+ text={tag.text}
415
+ color={EXCESS_COLOR}
416
+ title={`valid ${formatWindowTimeDetailed(start)} → ${formatWindowTimeDetailed(end)}`}
417
+ />)}
397
418
  </div>
398
419
  </div>;
399
420
  }
400
421
  }
401
422
 
423
+ /** The config's sources, listed once under every distinct valid window they overlap. */
424
+ class WindowRanges extends qreact.Component<{ sources: Source[]; ownBucketName: string }> {
425
+ render() {
426
+ let { sources, ownBucketName } = this.props;
427
+ let ranges = getWindowRanges(sources);
428
+ let now = Querysub.timeDelayed(TIME_REFRESH_INTERVAL);
429
+ return <>
430
+ {ranges.map((range, index) => {
431
+ let status = getWindowStatus(range.window, now);
432
+ return <div key={index} className={css.vbox(6).fillWidth}>
433
+ <div className={css.hbox(6).wrap.alignItems("center")}>
434
+ <div
435
+ className={css.boldStyle.fontSize(WINDOW_TIME_SIZE).colorhsl(0, 0, WINDOW_TIME_LIGHTNESS)}
436
+ title={`${formatWindowTimeDetailed(range.window[0])} → ${formatWindowTimeDetailed(range.window[1])}`}
437
+ >
438
+ {formatWindowTime(range.window[0])} → {formatWindowTime(range.window[1])}
439
+ </div>
440
+ <Tag icon={status.icon} text={status.text} color={status.color} />
441
+ </div>
442
+ <div className={css.vbox(6).fillWidth.paddingLeft(WINDOW_BODY_INDENT).boxSizing("border-box")}>
443
+ {range.sources.map(source => <SourceRow
444
+ key={getSourceKey(source, ownBucketName)}
445
+ source={source}
446
+ window={range.window}
447
+ ownBucketName={ownBucketName}
448
+ now={now}
449
+ />)}
450
+ </div>
451
+ </div>;
452
+ })}
453
+ </>;
454
+ }
455
+ }
456
+
402
457
  class ConflictWarning extends qreact.Component<{ consensus: ConfigVariant; conflict: ConfigConflict }> {
403
458
  render() {
404
459
  let { consensus, conflict } = this.props;
@@ -431,15 +486,13 @@ class RouteConfigGroupView extends qreact.Component<{ group: RouteConfigGroup }>
431
486
  render() {
432
487
  let { buckets, consensus, conflicts } = this.props.group;
433
488
  let ownBucketName = buckets[0].bucketName;
434
- let clusters = getWindowClusters(consensus.sources);
435
- let now = Querysub.timeDelayed(TIME_REFRESH_INTERVAL);
436
489
  return <div className={css.vbox(10).fillWidth}>
437
490
  <div className={css.hbox(8).wrap.fontSize(WINDOW_HEADER_SIZE)}>
438
491
  <div className={css.hbox(4).wrap}>
439
492
  {buckets.map((bucket, index) => <div key={bucket.bucketName} className={css.hbox(4)}>
440
493
  {index > 0 && <div className={css.colorhsl(0, 0, 60)}>|</div>}
441
494
  <Button
442
- flavor="tiny"
495
+ flavor="large"
443
496
  className={css.fontSize(BUCKET_TITLE_SIZE)}
444
497
  onClick={() => showConfigModal(bucket.bucketName, bucket.rawConfig)}
445
498
  >
@@ -453,21 +506,7 @@ class RouteConfigGroupView extends qreact.Component<{ group: RouteConfigGroup }>
453
506
  </div>
454
507
  <div className={css.vbox(10).fillWidth.paddingLeft(GROUP_BODY_INDENT).boxSizing("border-box")}>
455
508
  {conflicts.map((conflict, index) => <ConflictWarning key={index} consensus={consensus} conflict={conflict} />)}
456
- {clusters.map((cluster, index) => {
457
- let status = getWindowStatus(cluster.window, now);
458
- return <div key={index} className={css.vbox(6).fillWidth}>
459
- <div className={css.hbox(6).wrap.colorhsl(0, 0, 35).fontSize(TAG_FONT_SIZE)}>
460
- <div>{formatWindowTime(cluster.window[0])} → {formatWindowTime(cluster.window[1])}</div>
461
- <Tag icon={status.icon} text={status.text} color={status.color} />
462
- </div>
463
- {cluster.sources.map(source => <SourceRow
464
- key={getSourceKey(source, ownBucketName)}
465
- source={source}
466
- window={cluster.window}
467
- ownBucketName={ownBucketName}
468
- />)}
469
- </div>;
470
- })}
509
+ <WindowRanges sources={consensus.sources} ownBucketName={ownBucketName} />
471
510
  </div>
472
511
  </div>;
473
512
  }
@@ -479,11 +518,13 @@ class LiveWatchView extends qreact.Component<{ watchKey: string; watch: LiveWatc
479
518
  let { watchKey, watch } = this.props;
480
519
  let now = Querysub.timeDelayed(TIME_REFRESH_INTERVAL);
481
520
  let sources = (watch.routing?.sources || []).map(normalizeSource);
482
- let clusters = getWindowClusters(sources);
483
521
  return <div className={css.vbox(10).fillWidth.pad2(10, 8).bord2(WATCH_ACTIVE_COLOR.h, WATCH_ACTIVE_COLOR.s, WATCH_ACTIVE_COLOR.l - 25)}>
484
522
  <div className={css.hbox(8).wrap.fontSize(WINDOW_HEADER_SIZE)}>
485
523
  <div className={css.boldStyle}>👁 {watch.bucketName}</div>
486
524
  <div className={css.colorhsl(0, 0, 45)}>live on {watch.serverUrl}</div>
525
+ {watch.routing && <div className={css.colorhsl(0, 0, 45)}>
526
+ version {String(watch.routing.version ?? "none")}
527
+ </div>}
487
528
  <div className={css.colorhsl(0, 0, 45)}>
488
529
  {watch.loading && "refreshing..."
489
530
  || watch.updatedTime && `updated ${formatTime(now - watch.updatedTime)} ago`
@@ -497,21 +538,7 @@ class LiveWatchView extends qreact.Component<{ watchKey: string; watch: LiveWatc
497
538
  ⚠ {watch.error}
498
539
  </div>}
499
540
  <div className={css.vbox(10).fillWidth.paddingLeft(GROUP_BODY_INDENT).boxSizing("border-box")}>
500
- {clusters.map((cluster, index) => {
501
- let status = getWindowStatus(cluster.window, now);
502
- return <div key={index} className={css.vbox(6).fillWidth}>
503
- <div className={css.hbox(6).wrap.colorhsl(0, 0, 35).fontSize(TAG_FONT_SIZE)}>
504
- <div>{formatWindowTime(cluster.window[0])} → {formatWindowTime(cluster.window[1])}</div>
505
- <Tag icon={status.icon} text={status.text} color={status.color} />
506
- </div>
507
- {cluster.sources.map(source => <SourceRow
508
- key={getSourceKey(source, watch.bucketName)}
509
- source={source}
510
- window={cluster.window}
511
- ownBucketName={watch.bucketName}
512
- />)}
513
- </div>;
514
- })}
541
+ <WindowRanges sources={sources} ownBucketName={watch.bucketName} />
515
542
  </div>
516
543
  </div>;
517
544
  }
@@ -532,7 +559,7 @@ export class RouteConfigView extends qreact.Component<{ servers: StorageServerBu
532
559
  }
533
560
  render() {
534
561
  let groups = getRouteConfigGroups(this.props.servers);
535
- let watches = Object.entries(liveWatchData().watches);
562
+ let watches = readWatches();
536
563
  if (!groups.length && !watches.length) return undefined;
537
564
  return <div className={css.vbox(20).fillWidth}>
538
565
  {watches.length > 0 && <div className={css.vbox(10).fillWidth}>
@@ -28,7 +28,9 @@ import { getOwnThreadId } from "../../-f-node-discovery/NodeDiscovery";
28
28
  import { decodeNodeId } from "sliftutils/misc/https/certs";
29
29
  import { showModal } from "../../5-diagnostics/Modal";
30
30
 
31
-
31
+ // Trimmed well below the limit, so trimming is rare - each trim jumps the relative scroll position
32
+ const OUTPUT_BUFFER_LIMIT = 1_000_000;
33
+ const OUTPUT_BUFFER_KEPT = 100_000;
32
34
 
33
35
  export class ServiceDetailPage extends qreact.Component {
34
36
  state = t.state({
@@ -43,6 +45,8 @@ export class ServiceDetailPage extends qreact.Component {
43
45
  isWatching: t.type(false),
44
46
  data: t.type(""),
45
47
  callbackId: t.type(""),
48
+ // The launch the buffered output belongs to. A new launch is a new process, so its output must not be appended to the previous one's.
49
+ launchTime: t.number(0),
46
50
  }),
47
51
  // Milliseconds; 0 means no scheduled time picked yet (use Deploy Now instead)
48
52
  switchTime: t.number(0),
@@ -66,37 +70,43 @@ export class ServiceDetailPage extends qreact.Component {
66
70
  nodeId: string;
67
71
  key: string;
68
72
  index: number;
73
+ launchTime: number;
69
74
  }) {
70
- const { nodeId, key, index } = config;
75
+ const { nodeId, key, index, launchTime } = config;
71
76
  const outputKey = getPathStr2(key, index + "");
72
77
  let callbackId = nextId();
73
78
 
79
+ // Drop the previous watch first: two live callbacks writing to one buffer is what interlaced the output of the old and new processes
80
+ let previousCallbackId = Querysub.localRead(() => this.state.watchingOutputs[outputKey].callbackId);
81
+ if (previousCallbackId) {
82
+ await stopWatchingScreenOutput({ callbackId: previousCallbackId });
83
+ }
84
+
74
85
  Querysub.commit(() => {
75
- this.state.watchingOutputs[outputKey] = { isWatching: true, data: "", callbackId };
86
+ // Cleared, so the buffer only ever holds the output of the launch it is watching
87
+ this.state.watchingOutputs[outputKey] = { isWatching: true, data: "", callbackId, launchTime };
76
88
  });
77
89
 
78
- try {
79
- await watchScreenOutput({
80
- nodeId,
81
- key,
82
- index,
83
- callbackId,
84
- onData: async (data: string) => {
85
- Querysub.localCommit(() => {
86
- let fullData = this.state.watchingOutputs[outputKey].data + data;
87
- // Don't trim every time, otherwise the relative scroll position changes by too much
88
- if (fullData.length > 1_000_000) {
89
- fullData = fullData.slice(-100_000);
90
- }
91
- this.state.watchingOutputs[outputKey].data = fullData;
92
- });
93
- }
94
- });
95
- } catch (error: any) {
96
- Querysub.localCommit(() => {
97
- this.state.watchingOutputs[outputKey].data = `Error watching output: ${error.stack || String(error)}`;
98
- });
99
- }
90
+ await watchScreenOutput({
91
+ nodeId,
92
+ key,
93
+ index,
94
+ callbackId,
95
+ onData: async (data: string, dataConfig?: { reset?: boolean }) => {
96
+ Querysub.localCommit(() => {
97
+ let watchingState = this.state.watchingOutputs[outputKey];
98
+ // A callback that outlived its watch (a restart raced with in-flight data) must not write into the new process's buffer
99
+ if (watchingState.callbackId !== callbackId) return;
100
+ // The screen's process changed, so what we have belongs to a process that is gone
101
+ let fullData = (dataConfig?.reset && "" || watchingState.data) + data;
102
+ // Don't trim every time, otherwise the relative scroll position changes by too much
103
+ if (fullData.length > OUTPUT_BUFFER_LIMIT) {
104
+ fullData = fullData.slice(-OUTPUT_BUFFER_KEPT);
105
+ }
106
+ watchingState.data = fullData;
107
+ });
108
+ }
109
+ });
100
110
  }
101
111
 
102
112
  private async stopWatchingOutput(key: string, index: number) {
@@ -104,8 +114,11 @@ export class ServiceDetailPage extends qreact.Component {
104
114
 
105
115
  let callbackId = Querysub.localRead(() => {
106
116
  const watchingState = this.state.watchingOutputs[outputKey];
117
+ let previousCallbackId = watchingState.callbackId;
107
118
  watchingState.isWatching = false;
108
- return watchingState.callbackId;
119
+ // Clearing this stops any in-flight data from landing in the buffer, and marks that there is no watch to stop next time
120
+ watchingState.callbackId = "";
121
+ return previousCallbackId;
109
122
  });
110
123
 
111
124
  Querysub.onCommitFinished(async () => {
@@ -427,6 +440,8 @@ export class ServiceDetailPage extends qreact.Component {
427
440
  let outputData = this.state.watchingOutputs[outputKey].data;
428
441
  const screenName = getScreenName({ serviceKey: key, index });
429
442
 
443
+ let launchTime = serviceInfo?.lastLaunchedTime || 0;
444
+
430
445
  return <div key={machineId}
431
446
  className={css.pad2(12).vbox(10).bord2(0, 0, 20).fillWidth + backgroundColor}
432
447
  >
@@ -505,7 +520,7 @@ export class ServiceDetailPage extends qreact.Component {
505
520
  if (isWatching) {
506
521
  void this.stopWatchingOutput(key, index);
507
522
  } else {
508
- void this.startWatchingOutput({ nodeId: applyNodeId, key, index });
523
+ void this.startWatchingOutput({ nodeId: applyNodeId, key, index, launchTime });
509
524
  }
510
525
  });
511
526
  }}
@@ -7,7 +7,7 @@ import preact from "preact";
7
7
  import { Querysub } from "../../4-querysub/Querysub";
8
8
  import { listServerBuckets, clearServerWriteStats, activateServerBucket, getServerActiveBucket } from "sliftutils/storage/remoteStorage/createArchives";
9
9
  import type { ServerBucketInfo, BucketDiskInfo, BucketWriteStats, ActiveBucketInfo } from "sliftutils/storage/remoteStorage/storageServerState";
10
- import type { ArchivesConfig } from "sliftutils/storage/IArchives";
10
+ import type { ArchivesConfig, SyncActivity } from "sliftutils/storage/IArchives";
11
11
  import { UsageBar, getUsageThresholds } from "../../library-components/UsageBar";
12
12
  import { getSyncedController } from "../../library-components/SyncedController";
13
13
  import { assertIsManagementUser } from "../../diagnostics/managementPages";
@@ -347,6 +347,66 @@ class IndexSourcesCell extends qreact.Component<{ sources: ArchivesConfig["index
347
347
  }
348
348
  }
349
349
 
350
+ const SYNC_PROGRESS_COLOR = { h: 130, s: 60, l: 50 };
351
+ const SYNC_PROGRESS_OPACITY = 0.25;
352
+ const SYNC_TYPE_COLORS: { [type in SyncActivity["type"]]: { h: number; s: number; l: number } } = {
353
+ metadataScan: { h: 265, s: 40, l: 88 },
354
+ fullSync: { h: 205, s: 50, l: 85 },
355
+ };
356
+ const SYNC_DETAIL_FONT_SIZE = 11;
357
+ const PERCENT_DECIMALS = 0;
358
+
359
+ /** How far along a scan is, by files when it knows the file count and by bytes otherwise. Undefined until the total is known - a scan that hasn't finished listing has no denominator yet. */
360
+ function getSyncFraction(activity: SyncActivity): number | undefined {
361
+ if (activity.totalFiles) return (activity.doneFiles || 0) / activity.totalFiles;
362
+ if (activity.totalBytes) return (activity.doneBytes || 0) / activity.totalBytes;
363
+ return undefined;
364
+ }
365
+
366
+ class SyncingCell extends qreact.Component<{ syncing: ArchivesConfig["syncing"] }> {
367
+ render() {
368
+ let syncing = this.props.syncing || [];
369
+ if (!syncing.length) return undefined;
370
+ let now = Querysub.nowDelayed(timeInSecond);
371
+ return <div className={css.vbox(3).fillWidth}>
372
+ {syncing.map((activity, activityIndex) => {
373
+ let fraction = getSyncFraction(activity);
374
+ let elapsed = now - activity.startTime;
375
+ let typeColor = SYNC_TYPE_COLORS[activity.type];
376
+ // Only meaningful once something has actually been done, otherwise the rate is a divide by zero
377
+ let remaining = fraction && fraction > 0 && elapsed * (1 - fraction) / fraction || undefined;
378
+ return <div key={activityIndex} className={css.relative.fillWidth.pad2(4, 3)}>
379
+ {fraction !== undefined && <div className={
380
+ css.absolute.top(0).left(0).size(`${fraction * 100}%`, "100%")
381
+ .hsla(SYNC_PROGRESS_COLOR.h, SYNC_PROGRESS_COLOR.s, SYNC_PROGRESS_COLOR.l, SYNC_PROGRESS_OPACITY)
382
+ } />}
383
+ <div className={css.relative.vbox(2)}>
384
+ <div className={css.hbox(4).wrap}>
385
+ <div className={css.pad2(5, 0).fontSize(SOURCE_PART_FONT_SIZE).whiteSpace("nowrap").hsl(typeColor.h, typeColor.s, typeColor.l).bord2(typeColor.h, typeColor.s, typeColor.l - 20)}>
386
+ {activity.type}
387
+ </div>
388
+ <SourceNameParts debugName={activity.sourceDebugName} />
389
+ </div>
390
+ <div className={css.hbox(6).wrap.fontSize(SYNC_DETAIL_FONT_SIZE).colorhsl(0, 0, 35)}>
391
+ <div className={css.whiteSpace("nowrap")}>running {formatTime(elapsed)}</div>
392
+ {fraction !== undefined && <div className={css.whiteSpace("nowrap")}>
393
+ {(fraction * 100).toFixed(PERCENT_DECIMALS)}%
394
+ </div>}
395
+ {remaining !== undefined && <div className={css.whiteSpace("nowrap")}>~{formatTime(remaining)} left</div>}
396
+ {activity.totalFiles !== undefined && <div className={css.whiteSpace("nowrap")}>
397
+ {formatNumber(activity.doneFiles || 0)}/{formatNumber(activity.totalFiles)} files
398
+ </div>}
399
+ {activity.totalBytes !== undefined && <div className={css.whiteSpace("nowrap")}>
400
+ {formatNumber(activity.doneBytes || 0)}B/{formatNumber(activity.totalBytes)}B
401
+ </div>}
402
+ </div>
403
+ </div>
404
+ </div>;
405
+ })}
406
+ </div>;
407
+ }
408
+ }
409
+
350
410
  const DEPLOY_PENDING_HUE = { h: 35, s: 80 };
351
411
  const DEPLOY_OVERLAP_HUE = { h: 280, s: 60 };
352
412
  const DEPLOY_NOTICE_FONT_SIZE = 14;
@@ -498,7 +558,7 @@ export class StoragePage extends qreact.Component {
498
558
  readerDiskLimit: { title: "Read cache limit" },
499
559
  writes: { title: "Writes" },
500
560
  written: { title: "Written" },
501
- writeGain: { title: "Coalescing gain" },
561
+ writeGain: { title: "Fast gain" },
502
562
  disk: {
503
563
  title: "Drive",
504
564
  formatter: (disk, context) => {
@@ -514,12 +574,7 @@ export class StoragePage extends qreact.Component {
514
574
  diskError: null,
515
575
  syncing: {
516
576
  title: "Syncing",
517
- formatter: syncing => <div className={css.vbox(2)}>
518
- {(syncing || []).map((activity, activityIndex) => <div key={activityIndex}>
519
- {activity.type} {activity.sourceDebugName}
520
- {activity.totalFiles !== undefined && ` (${formatNumber(activity.doneFiles || 0)}/${formatNumber(activity.totalFiles)} files)`}
521
- </div>)}
522
- </div>
577
+ formatter: syncing => <SyncingCell syncing={syncing} />
523
578
  },
524
579
  error: { title: "Error" },
525
580
  }}
@@ -81,11 +81,12 @@ class MachineControllerBase {
81
81
  await streamScreenOutput({
82
82
  key: config.key,
83
83
  index: config.index,
84
- onData: async (data) => {
84
+ onData: async (data, dataConfig) => {
85
85
  await MachineControllerClient.nodes[caller.nodeId].onScreenOutput({
86
86
  key: config.key,
87
87
  index: config.index,
88
88
  data,
89
+ reset: dataConfig?.reset,
89
90
  callbackId: config.callbackId,
90
91
  });
91
92
  },
@@ -152,13 +153,14 @@ export const MachineController = getSyncedController(SocketFunction.register(
152
153
  reads: {},
153
154
  });
154
155
 
155
- let callbacks = new Map<string, (data: string) => Promise<void>>();
156
+ let callbacks = new Map<string, (data: string, config?: { reset?: boolean }) => Promise<void>>();
156
157
  export async function watchScreenOutput(config: {
157
158
  nodeId: string;
158
159
  key: string;
159
160
  index: number;
160
161
  callbackId: string;
161
- onData: (data: string) => Promise<void>;
162
+ /** reset means the process behind the screen changed: replace what you have with this, don't append it. */
163
+ onData: (data: string, config?: { reset?: boolean }) => Promise<void>;
162
164
  }) {
163
165
  let callbackId = config.callbackId;
164
166
  callbacks.set(callbackId, config.onData);
@@ -181,6 +183,7 @@ class MachineControllerClientBase {
181
183
  key: string;
182
184
  index: number;
183
185
  data: string;
186
+ reset?: boolean;
184
187
  callbackId: string;
185
188
  }): Promise<void> {
186
189
  let forwardToNodeId = forwardedCallbacks.get(config.callbackId);
@@ -189,6 +192,7 @@ class MachineControllerClientBase {
189
192
  key: config.key,
190
193
  index: config.index,
191
194
  data: config.data,
195
+ reset: config.reset,
192
196
  callbackId: config.callbackId,
193
197
  });
194
198
  return;
@@ -198,7 +202,7 @@ class MachineControllerClientBase {
198
202
  if (!callback) {
199
203
  throw new Error(`Callback ${config.callbackId} not found (likely removed)`);
200
204
  }
201
- await callback(config.data);
205
+ await callback(config.data, { reset: config.reset });
202
206
  }
203
207
  }
204
208
  // NOTE: THis is secure, because callbackId is random.