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 +1 -1
- package/src/deployManager/components/RouteConfigView.tsx +136 -109
- package/src/deployManager/components/ServiceDetailPage.tsx +42 -27
- package/src/deployManager/components/StoragePage.tsx +63 -8
- package/src/deployManager/machineApplyMainCode.ts +0 -0
- package/src/deployManager/machineController.ts +8 -4
package/package.json
CHANGED
|
@@ -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 =
|
|
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
|
-
/**
|
|
121
|
-
function
|
|
122
|
-
let
|
|
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
|
|
137
|
-
if (
|
|
138
|
-
|
|
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
|
-
|
|
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 -
|
|
222
|
-
const
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
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
|
-
|
|
308
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
{
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
text={
|
|
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="
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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: "
|
|
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 => <
|
|
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
|
}}
|
|
Binary file
|
|
@@ -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
|
-
|
|
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.
|