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