querysub 0.606.0 → 0.607.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.
@@ -1,652 +0,0 @@
1
- import { SocketFunction } from "socket-function/SocketFunction";
2
- import { qreact } from "../../4-dom/qreact";
3
- import { css } from "typesafecss";
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, getServerActiveBucket } from "sliftutils/storage/remoteStorage/createArchives";
9
- import type { ServerBucketInfo, BucketWriteStats, ActiveBucketInfo } from "sliftutils/storage/remoteStorage/storageServerState";
10
- import type { ArchivesConfig, SyncActivity, RemoteConfig, HostedConfig } from "sliftutils/storage/IArchives";
11
- import { parseHostedUrl } from "sliftutils/storage/remoteStorage/remoteConfig";
12
- import { UsageBar, getUsageThresholds } from "../../library-components/UsageBar";
13
- import { getSyncedController } from "../../library-components/SyncedController";
14
- import { assertIsManagementUser } from "../../diagnostics/managementPages";
15
- import { getLiveServiceParameters, getMachineTargets, applyCommandTemplate, ServiceParameters, ServiceConfig, MachineServiceController, DEFAULT_OVERLAP_TIME } from "../machineSchema";
16
- import { isDefined } from "../../misc";
17
- import { STORAGE_ACCOUNT } from "../../-a-archives/archives2";
18
- import { Table } from "../../5-diagnostics/Table";
19
- import { RouteConfigView } from "./RouteConfigView";
20
- import { Tag, SourceTag, getSourceTags } from "./Tag";
21
- import { STORAGE_COMMAND_PREFIX } from "../serviceCategories";
22
- import { BucketDiskInfo } from "sliftutils/storage/remoteStorage/bucketDisk";
23
-
24
- module.hotreload = true;
25
-
26
- const URL_ARG_REGEX = /--url\s+(\S+)/g;
27
-
28
- /** The release a storage service is in the middle of. Read straight from the service configs, so it resolves without contacting any storage server - which is exactly when it matters most, since a deploy is the usual reason the servers don't answer. */
29
- export type StorageService = {
30
- serviceKey: string;
31
- serviceTitle: string;
32
- releaseTime?: number;
33
- overlapTime: number;
34
- hasOldParameters: boolean;
35
- };
36
-
37
- export type StorageServer = StorageService & {
38
- url: string;
39
- };
40
-
41
- export type StorageServerBuckets = StorageServer & {
42
- /** Still waiting on this server's own endpoint - each server loads independently */
43
- loading?: boolean;
44
- buckets?: ServerBucketInfo[];
45
- /** This server could not be read at all. Kept per server rather than thrown, so one bad server costs its own row and nothing else. */
46
- error?: string;
47
- };
48
-
49
- function getStorageUrls(parameters: ServiceParameters): string[] {
50
- if (!parameters.command.trimStart().startsWith(STORAGE_COMMAND_PREFIX)) return [];
51
- let urls: string[] = [];
52
- for (let target of getMachineTargets(parameters)) {
53
- let command = applyCommandTemplate(parameters.command, target.variables);
54
- for (let match of command.matchAll(URL_ARG_REGEX)) {
55
- urls.push(match[1]);
56
- }
57
- }
58
- return urls;
59
- }
60
-
61
- /** The storage servers a set of service configs runs, worked out on the client - it already has the configs, so asking the server to re-derive them would just make the whole page wait on one uncacheable call. */
62
- export function getStorageServers(configs: ServiceConfig[]): StorageServer[] {
63
- let serviceByUrl = new Map<string, StorageServer>();
64
- for (let config of configs) {
65
- let allParameters = [getLiveServiceParameters(config)];
66
- for (let parameters of allParameters) {
67
- if (!parameters) continue;
68
- for (let url of getStorageUrls(parameters)) {
69
- if (serviceByUrl.has(url)) continue;
70
- serviceByUrl.set(url, {
71
- url,
72
- serviceKey: parameters.key,
73
- serviceTitle: config.info.title,
74
- releaseTime: config.parameters.releaseTime,
75
- // The overlap always comes from the newest parameters, even though it governs how long the old instances outlive the release
76
- overlapTime: config.parameters.overlapTime ?? DEFAULT_OVERLAP_TIME,
77
- hasOldParameters: !!config.oldParameters,
78
- });
79
- }
80
- }
81
- }
82
- return [...serviceByUrl.values()];
83
- }
84
-
85
- export function getStorageServices(servers: StorageServer[]): StorageService[] {
86
- let byKey = new Map<string, StorageService>();
87
- for (let server of servers) {
88
- if (byKey.has(server.serviceKey)) continue;
89
- byKey.set(server.serviceKey, server);
90
- }
91
- return [...byKey.values()];
92
- }
93
-
94
- /** Every storage call is proxied through here: they authenticate with this machine's identity CA, which the browser has no access to. */
95
- class StoragePageControllerBase {
96
- /** One call per server, so each caches and refreshes on its own. */
97
- public async getServerBuckets(url: string): Promise<ServerBucketInfo[]> {
98
- return await listServerBuckets({ url, account: STORAGE_ACCOUNT });
99
- }
100
- public async activateBucket(url: string, bucketName: string): Promise<ActiveBucketInfo | string> {
101
- return await activateServerBucket({ url, account: STORAGE_ACCOUNT, bucketName });
102
- }
103
- public async getActiveBucket(url: string, bucketName: string): Promise<ActiveBucketInfo | string> {
104
- return await getServerActiveBucket({ url, account: STORAGE_ACCOUNT, bucketName });
105
- }
106
- public async clearWriteStats(url: string): Promise<{ clearedBuckets: number }> {
107
- return await clearServerWriteStats({ url, account: STORAGE_ACCOUNT });
108
- }
109
- }
110
-
111
- export const StoragePageController = SocketFunction.register(
112
- "StoragePageController-4f1c93ab-77e2-4d15-9a30-1c6b8f5d2e04",
113
- new StoragePageControllerBase(),
114
- () => ({
115
- getServerBuckets: {},
116
- activateBucket: {},
117
- getActiveBucket: {},
118
- clearWriteStats: {},
119
- }),
120
- () => ({
121
- hooks: [assertIsManagementUser],
122
- }),
123
- {
124
- }
125
- );
126
-
127
- export const StorageSynced = getSyncedController(StoragePageController, {
128
- reads: {},
129
- writes: {},
130
- });
131
-
132
- type BucketRow = {
133
- server: string;
134
- /** Kept even on the rows that blank out the server column, so the row's buttons know which server to call */
135
- serverUrl: string;
136
- bucket: string;
137
- state: string;
138
- files: string;
139
- bytes: string;
140
- flags: SourceTag[];
141
- indexSources: ArchivesConfig["indexSources"];
142
- readerDiskLimit: string;
143
- writes: string;
144
- written: string;
145
- writeGain: string;
146
- disk?: BucketDiskInfo;
147
- diskError: string;
148
- syncing: ArchivesConfig["syncing"];
149
- error: string;
150
- };
151
-
152
- const GAIN_DECIMALS = 1;
153
- const DEFAULT_HTTPS_PORT = 443;
154
- const DISK_BAR_TYPE = "DISK";
155
- const ACTIVE_STATE = "active";
156
- const INACTIVE_STATE = "inactive";
157
-
158
- /** The flags this server's own entries in the bucket's routing config set - how it holds the bucket, which is not something the bucket's own state reports. */
159
- function getBucketFlags(serverUrl: string, bucketName: string, remoteConfig: RemoteConfig | undefined): SourceTag[] {
160
- let host: URL;
161
- try {
162
- host = new URL(serverUrl);
163
- } catch {
164
- return [];
165
- }
166
- let selfEntries: HostedConfig[] = [];
167
- for (let source of remoteConfig?.sources || []) {
168
- if (typeof source === "string" || source.type !== "remote") continue;
169
- try {
170
- let parsed = parseHostedUrl(source.url);
171
- if (parsed.address !== host.hostname) continue;
172
- if (String(parsed.port) !== (host.port || String(DEFAULT_HTTPS_PORT))) continue;
173
- if (parsed.bucketName !== bucketName) continue;
174
- selfEntries.push(source);
175
- } catch {
176
- // A malformed url in the config is not this column's problem
177
- }
178
- }
179
- // A server can hold several entries for one bucket (different windows or route shards), so the flags are the union of theirs, deduped by what they say
180
- let flags = new Map<string, SourceTag>();
181
- for (let entry of selfEntries) {
182
- for (let tag of getSourceTags(entry)) {
183
- if (flags.has(tag.text)) continue;
184
- flags.set(tag.text, tag);
185
- }
186
- }
187
- return [...flags.values()];
188
- }
189
-
190
- /** How much fast-mode coalescing saved: every accepted write over the ones that actually reached a source. */
191
- function getWriteGain(stats: BucketWriteStats | undefined): string {
192
- if (!stats) return "";
193
- if (!stats.flushedWrites || !stats.flushedBytes) return "";
194
- let writeGain = stats.originalWrites / stats.flushedWrites;
195
- let byteGain = stats.originalBytes / stats.flushedBytes;
196
- return `${writeGain.toFixed(GAIN_DECIMALS)}X / ${byteGain.toFixed(GAIN_DECIMALS)}X B`;
197
- }
198
-
199
- function getBucketRows(servers: StorageServerBuckets[]): BucketRow[] {
200
- let rows: BucketRow[] = [];
201
- for (let server of servers) {
202
- let baseRow: BucketRow = {
203
- server: server.url,
204
- serverUrl: server.url,
205
- bucket: "",
206
- state: "",
207
- files: "",
208
- bytes: "",
209
- flags: [],
210
- indexSources: undefined,
211
- readerDiskLimit: "",
212
- writes: "",
213
- written: "",
214
- writeGain: "",
215
- disk: undefined,
216
- diskError: "",
217
- syncing: undefined,
218
- error: "",
219
- };
220
- if (server.error) {
221
- rows.push({ ...baseRow, error: server.error });
222
- continue;
223
- }
224
- if (server.loading) {
225
- rows.push({ ...baseRow, bucket: "Loading..." });
226
- continue;
227
- }
228
- let buckets = [...server.buckets || []];
229
- sort(buckets, x => x.bucketName);
230
- if (!buckets.length) {
231
- rows.push({ ...baseRow, bucket: "No buckets" });
232
- continue;
233
- }
234
- for (let [index, bucket] of buckets.entries()) {
235
- let config = bucket.config;
236
- // Per bucket for the same reason it is per server: a single unparseable config costs its own row, not the table
237
- try {
238
- rows.push({
239
- ...baseRow,
240
- server: index === 0 && server.url || "",
241
- bucket: bucket.bucketName,
242
- state: bucket.active && ACTIVE_STATE || INACTIVE_STATE,
243
- files: config?.index && formatNumber(config.index.fileCount) || "",
244
- bytes: config?.index && formatNumber(config.index.byteCount) + "B" || "",
245
- flags: getBucketFlags(server.url, bucket.bucketName, config?.remoteConfig),
246
- indexSources: config?.indexSources,
247
- readerDiskLimit: config?.readerDiskLimit && formatNumber(config.readerDiskLimit) + "B" || "",
248
- writes: bucket.writeStats && formatNumber(bucket.writeStats.originalWrites) || "",
249
- written: bucket.writeStats && formatNumber(bucket.writeStats.originalBytes) + "B" || "",
250
- writeGain: getWriteGain(bucket.writeStats),
251
- disk: bucket.disk,
252
- diskError: bucket.diskError || "",
253
- syncing: config?.syncing,
254
- error: bucket.error || "",
255
- });
256
- } catch (e: any) {
257
- rows.push({
258
- ...baseRow,
259
- server: index === 0 && server.url || "",
260
- bucket: bucket.bucketName,
261
- error: e.stack ?? String(e),
262
- });
263
- }
264
- }
265
- }
266
- return rows;
267
- }
268
-
269
- const ACTIVATE_ERROR_COLOR = { h: 0, s: 60, l: 40 };
270
-
271
- /** An inactive bucket exists on the server's disk but isn't loaded, so it isn't synchronizing. Activating loads it, which starts synchronization. */
272
- class ActivateBucketButton extends qreact.Component<{ serverUrl: string; bucketName: string }> {
273
- state = {
274
- activating: false,
275
- error: "",
276
- };
277
- render() {
278
- return <div className={css.hbox(6).wrap}>
279
- <div className={css.whiteSpace("nowrap")}>{INACTIVE_STATE}</div>
280
- <button
281
- className={css.pad2(6, 1).button.bord2(0, 0, 20).hsl(0, 0, 100).whiteSpace("nowrap")}
282
- disabled={this.state.activating}
283
- onClick={() => {
284
- // Props and state are synchronized, so everything the async work needs is copied into plain locals HERE, in the tracked part
285
- let serverUrl = this.props.serverUrl;
286
- let bucketName = this.props.bucketName;
287
- this.state.activating = true;
288
- this.state.error = "";
289
- Querysub.onCommitFinished(async () => {
290
- try {
291
- let result = await StorageSynced(SocketFunction.browserNodeId()).activateBucket.promise(serverUrl, bucketName);
292
- // A refusal comes back as a string - it's a normal result, not an error
293
- let refusal = typeof result === "string" && result || "";
294
- if (refusal) {
295
- console.log(`Activating bucket ${bucketName} on ${serverUrl} was refused: ${refusal}`);
296
- } else {
297
- console.log(`Activated bucket ${bucketName} on ${serverUrl}`);
298
- }
299
- Querysub.commit(() => {
300
- this.state.error = refusal;
301
- // Only this server's buckets changed
302
- if (!refusal) {
303
- StorageSynced(SocketFunction.browserNodeId()).getServerBuckets.reset(serverUrl);
304
- }
305
- });
306
- } finally {
307
- // Without this a throw would leave the button disabled and stuck on "Activating..."
308
- Querysub.commit(() => {
309
- this.state.activating = false;
310
- });
311
- }
312
- });
313
- }}
314
- >
315
- {this.state.activating && "Activating..." || "Activate"}
316
- </button>
317
- {this.state.error && <div
318
- className={css.colorhsl(ACTIVATE_ERROR_COLOR.h, ACTIVATE_ERROR_COLOR.s, ACTIVATE_ERROR_COLOR.l).ellipsis}
319
- title={this.state.error}
320
- >
321
- {this.state.error}
322
- </div>}
323
- </div>;
324
- }
325
- }
326
-
327
- const SOURCE_BAR_COLOR = { h: 210, s: 65, l: 55 };
328
- const REMOTE_SOURCE_BAR_COLOR = { h: 0, s: 75, l: 55 };
329
- const SOURCE_BAR_OPACITY = 0.3;
330
- const SOURCE_ROW_PADDING = { horizontal: 4, vertical: 3 };
331
- const DISK_SOURCE_TYPE = "disk";
332
- const REMOTE_SOURCE_TOOLTIP = "Values stored on another server. If this other server goes down, we'll lose access to the data.";
333
- const SOURCE_TYPE_COLOR = { h: 265, s: 40, l: 88 };
334
- const SOURCE_PART_COLOR = { h: 0, s: 0, l: 96 };
335
- const SOURCE_PART_FONT_SIZE = 11;
336
-
337
- const DEBUG_NAME_PATTERNS: { regex: RegExp; parts: string[] }[] = [
338
- { regex: /^remoteStorage (\S+) account (\S+) bucket (.+)$/, parts: ["remoteStorage"] },
339
- { regex: /^localBucket account (\S+) bucket (.+)$/, parts: ["localBucket"] },
340
- { regex: /^backblaze (.+)$/, parts: ["backblaze"] },
341
- { regex: /^disk (.+)$/, parts: ["disk"] },
342
- ];
343
-
344
- /** Splits an IArchives debug name into its type and the identifying pieces after it, so each piece can be boxed separately. */
345
- function parseSourceDebugName(debugName: string): { type: string; parts: string[] } {
346
- for (let pattern of DEBUG_NAME_PATTERNS) {
347
- let match = pattern.regex.exec(debugName);
348
- if (!match) continue;
349
- return { type: pattern.parts[0], parts: match.slice(1) };
350
- }
351
- return { type: debugName, parts: [] };
352
- }
353
-
354
- class SourceNameParts extends qreact.Component<{ debugName: string }> {
355
- render() {
356
- let { type, parts } = parseSourceDebugName(this.props.debugName);
357
- let partStyle = css.pad2(5, 0).fontSize(SOURCE_PART_FONT_SIZE).whiteSpace("nowrap");
358
- return <div className={css.hbox(3).wrap}>
359
- <div className={partStyle.hsl(SOURCE_TYPE_COLOR.h, SOURCE_TYPE_COLOR.s, SOURCE_TYPE_COLOR.l).bord2(SOURCE_TYPE_COLOR.h, SOURCE_TYPE_COLOR.s, SOURCE_TYPE_COLOR.l - 20)}>
360
- {type}
361
- </div>
362
- {parts.map(part => <div
363
- key={part}
364
- className={partStyle.hsl(SOURCE_PART_COLOR.h, SOURCE_PART_COLOR.s, SOURCE_PART_COLOR.l).bord2(SOURCE_PART_COLOR.h, SOURCE_PART_COLOR.s, SOURCE_PART_COLOR.l - 20)}
365
- >
366
- {part}
367
- </div>)}
368
- </div>;
369
- }
370
- }
371
-
372
- class IndexSourcesCell extends qreact.Component<{ sources: ArchivesConfig["indexSources"] }> {
373
- render() {
374
- let sources = [...this.props.sources || []];
375
- if (!sources.length) return undefined;
376
- sort(sources, x => -x.byteCount);
377
- let totalBytes = sources.reduce((sum, x) => sum + x.byteCount, 0);
378
- return <div className={css.vbox(2).fillWidth}>
379
- {sources.map(source => {
380
- let fraction = totalBytes && source.byteCount / totalBytes || 0;
381
- // Only the server's own disk holds bytes we can't lose to another machine going down
382
- let remote = parseSourceDebugName(source.debugName).type !== DISK_SOURCE_TYPE;
383
- let barColor = remote && REMOTE_SOURCE_BAR_COLOR || SOURCE_BAR_COLOR;
384
- return <div
385
- key={source.debugName}
386
- className={css.relative.fillWidth.pad2(SOURCE_ROW_PADDING.horizontal, SOURCE_ROW_PADDING.vertical)}
387
- title={remote && REMOTE_SOURCE_TOOLTIP || undefined}
388
- >
389
- <div className={
390
- css.absolute.top(0).left(0).size(`${fraction * 100}%`, "100%")
391
- .hsla(barColor.h, barColor.s, barColor.l, SOURCE_BAR_OPACITY)
392
- } />
393
- <div className={css.relative.hbox(6).wrap}>
394
- <div className={css.whiteSpace("nowrap")}>
395
- {formatNumber(source.byteCount)}B, {formatNumber(source.fileCount)} files
396
- </div>
397
- <SourceNameParts debugName={source.debugName} />
398
- </div>
399
- </div>;
400
- })}
401
- </div>;
402
- }
403
- }
404
-
405
- const SYNC_PROGRESS_COLOR = { h: 130, s: 60, l: 50 };
406
- const SYNC_PROGRESS_OPACITY = 0.25;
407
- const SYNC_TYPE_COLORS: { [type in SyncActivity["type"]]: { h: number; s: number; l: number } } = {
408
- metadataScan: { h: 265, s: 40, l: 88 },
409
- fullSync: { h: 205, s: 50, l: 85 },
410
- };
411
- const SYNC_DETAIL_FONT_SIZE = 11;
412
- const PERCENT_DECIMALS = 0;
413
-
414
- /** 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. */
415
- function getSyncFraction(activity: SyncActivity): number | undefined {
416
- if (activity.totalFiles) return (activity.doneFiles || 0) / activity.totalFiles;
417
- if (activity.totalBytes) return (activity.doneBytes || 0) / activity.totalBytes;
418
- return undefined;
419
- }
420
-
421
- class SyncingCell extends qreact.Component<{ syncing: ArchivesConfig["syncing"] }> {
422
- render() {
423
- let syncing = this.props.syncing || [];
424
- if (!syncing.length) return undefined;
425
- let now = Querysub.nowDelayed(timeInSecond);
426
- return <div className={css.vbox(3).fillWidth}>
427
- {syncing.map((activity, activityIndex) => {
428
- let fraction = getSyncFraction(activity);
429
- let elapsed = now - activity.startTime;
430
- let typeColor = SYNC_TYPE_COLORS[activity.type];
431
- // Only meaningful once something has actually been done, otherwise the rate is a divide by zero
432
- let remaining = fraction && fraction > 0 && elapsed * (1 - fraction) / fraction || undefined;
433
- return <div key={activityIndex} className={css.relative.fillWidth.pad2(4, 3)}>
434
- {fraction !== undefined && <div className={
435
- css.absolute.top(0).left(0).size(`${fraction * 100}%`, "100%")
436
- .hsla(SYNC_PROGRESS_COLOR.h, SYNC_PROGRESS_COLOR.s, SYNC_PROGRESS_COLOR.l, SYNC_PROGRESS_OPACITY)
437
- } />}
438
- <div className={css.relative.vbox(2)}>
439
- <div className={css.hbox(4).wrap}>
440
- <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)}>
441
- {activity.type}
442
- </div>
443
- <SourceNameParts debugName={activity.sourceDebugName} />
444
- </div>
445
- <div className={css.hbox(6).wrap.fontSize(SYNC_DETAIL_FONT_SIZE).colorhsl(0, 0, 35)}>
446
- <div className={css.whiteSpace("nowrap")}>running {formatTime(elapsed)}</div>
447
- {fraction !== undefined && <div className={css.whiteSpace("nowrap")}>
448
- {(fraction * 100).toFixed(PERCENT_DECIMALS)}%
449
- </div>}
450
- {remaining !== undefined && <div className={css.whiteSpace("nowrap")}>~{formatTime(remaining)} left</div>}
451
- {activity.totalFiles !== undefined && <div className={css.whiteSpace("nowrap")}>
452
- {formatNumber(activity.doneFiles || 0)}/{formatNumber(activity.totalFiles)} files
453
- </div>}
454
- {activity.totalBytes !== undefined && <div className={css.whiteSpace("nowrap")}>
455
- {formatNumber(activity.doneBytes || 0)}B/{formatNumber(activity.totalBytes)}B
456
- </div>}
457
- </div>
458
- </div>
459
- </div>;
460
- })}
461
- </div>;
462
- }
463
- }
464
-
465
- const DEPLOY_PENDING_HUE = { h: 35, s: 80 };
466
- const DEPLOY_OVERLAP_HUE = { h: 280, s: 60 };
467
- const DEPLOY_NOTICE_FONT_SIZE = 14;
468
- const DEPLOY_NOTICE_TEXT_LIGHTNESS = 30;
469
- const DEPLOY_NOTICE_BORDER_LIGHTNESS = 55;
470
- const DEPLOY_NOTICE_BACKGROUND_LIGHTNESS = 94;
471
-
472
- class DeployNotice extends qreact.Component<{
473
- hue: { h: number; s: number };
474
- icon: string;
475
- title: string;
476
- detail: string;
477
- tooltip: string;
478
- }> {
479
- render() {
480
- let { hue, icon, title, detail, tooltip } = this.props;
481
- return <div
482
- className={
483
- css.hbox(8).pad2(12, 8).fontSize(DEPLOY_NOTICE_FONT_SIZE).alignItems("center")
484
- .hsl(hue.h, hue.s, DEPLOY_NOTICE_BACKGROUND_LIGHTNESS)
485
- .bord2(hue.h, hue.s, DEPLOY_NOTICE_BORDER_LIGHTNESS)
486
- .colorhsl(hue.h, hue.s, DEPLOY_NOTICE_TEXT_LIGHTNESS)
487
- }
488
- title={tooltip}
489
- >
490
- <div className={css.fontSize(DEPLOY_NOTICE_FONT_SIZE + 4)}>{icon}</div>
491
- <div className={css.vbox(2)}>
492
- <div className={css.boldStyle}>{title}</div>
493
- <div className={css.fontSize(DEPLOY_NOTICE_FONT_SIZE - 2)}>{detail}</div>
494
- </div>
495
- </div>;
496
- }
497
- }
498
-
499
- /** The services behind these storage servers may be mid-release, which moves (or duplicates) the servers the page just read from. */
500
- class DeployNotices extends qreact.Component<{ services: StorageService[] }> {
501
- render() {
502
- let now = Querysub.nowDelayed(timeInSecond);
503
- let notices: preact.ComponentChild[] = [];
504
- for (let service of this.props.services) {
505
- let serviceKey = service.serviceKey;
506
- let releaseTime = service.releaseTime || 0;
507
- // The old instances run out their overlap purely on time - we don't verify they're actually still up
508
- if (!releaseTime || !service.hasOldParameters) continue;
509
- let killTime = releaseTime + service.overlapTime;
510
- if (now < releaseTime) {
511
- notices.push(<DeployNotice
512
- key={serviceKey + "pending"}
513
- hue={DEPLOY_PENDING_HUE}
514
- icon="🕐"
515
- title={`Deploys in ${formatTime(releaseTime - now)}`}
516
- detail={`${service.serviceTitle} (${serviceKey})`}
517
- tooltip={`Deploys at ${formatDateTimeDetailed(releaseTime)}`}
518
- />);
519
- continue;
520
- }
521
- if (now < killTime) {
522
- notices.push(<DeployNotice
523
- key={serviceKey + "overlap"}
524
- hue={DEPLOY_OVERLAP_HUE}
525
- icon="🔀"
526
- title={`Old version still running for ${formatTime(killTime - now)}`}
527
- detail={`${service.serviceTitle} (${serviceKey})`}
528
- tooltip={`Old instances shut down at ${formatDateTimeDetailed(killTime)}`}
529
- />);
530
- }
531
- }
532
- if (!notices.length) return undefined;
533
- return <div className={css.hbox(8).wrap}>{notices}</div>;
534
- }
535
- }
536
-
537
- async function resetWriteStats(servers: StorageServerBuckets[]): Promise<void> {
538
- await Promise.all(servers.map(async server => {
539
- if (server.loading) return;
540
- let { clearedBuckets } = await StorageSynced(SocketFunction.browserNodeId()).clearWriteStats.promise(server.url);
541
- console.log(`Cleared write stats for ${clearedBuckets} buckets on ${server.url}`);
542
- }));
543
- Querysub.commit(() => {
544
- StorageSynced(SocketFunction.browserNodeId()).getServerBuckets.resetAll();
545
- });
546
- }
547
-
548
- export class StoragePage extends qreact.Component {
549
- render() {
550
- const controller = StorageSynced(SocketFunction.browserNodeId());
551
- const machineController = MachineServiceController(SocketFunction.browserNodeId());
552
- const serviceList = machineController.getServiceList();
553
- // The configs are already synchronized to the client, so the deploy state needs no round trip at all
554
- const configs = (serviceList || []).map(serviceId => machineController.getServiceConfig(serviceId)).filter(isDefined);
555
- const storageServers = getStorageServers(configs);
556
- const servers: StorageServerBuckets[] = storageServers.map(server => {
557
- // Every other server still has something worth showing, so one that cannot be read becomes a row saying so instead of an empty page
558
- try {
559
- let buckets = controller.getServerBuckets(server.url);
560
- if (!buckets) return { ...server, loading: true };
561
- return { ...server, buckets };
562
- } catch (e: any) {
563
- return { ...server, error: e.stack ?? String(e) };
564
- }
565
- });
566
- const header = <>
567
- <div className={css.hbox(12)}>
568
- <h2 className={css.flexGrow(1)}>Storage</h2>
569
- <button className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 0, 100)}
570
- onClick={() => {
571
- void resetWriteStats(servers);
572
- }}>
573
- Reset write stats
574
- </button>
575
- <button className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 0, 100)}
576
- onClick={() => {
577
- controller.getServerBuckets.resetAll();
578
- }}>
579
- Refresh
580
- </button>
581
- </div>
582
- <DeployNotices services={getStorageServices(storageServers)} />
583
- </>;
584
- if (!serviceList) {
585
- return <div className={css.vbox(16)}>
586
- {header}
587
- <div>Loading services...</div>
588
- </div>;
589
- }
590
- if (!servers.length) {
591
- return <div className={css.vbox(16)}>
592
- {header}
593
- <div>No services with a command starting with {JSON.stringify(STORAGE_COMMAND_PREFIX)} were found.</div>
594
- </div>;
595
- }
596
- return <div className={css.vbox(16)}>
597
- {header}
598
- <Table
599
- rows={getBucketRows(servers)}
600
- columns={{
601
- server: { title: "Server" },
602
- bucket: { title: "Bucket" },
603
- serverUrl: null,
604
- state: {
605
- title: "State",
606
- formatter: (state, context) => {
607
- let row = context?.row;
608
- if (!row || !row.bucket || state !== INACTIVE_STATE) return state;
609
- return <ActivateBucketButton serverUrl={row.serverUrl} bucketName={row.bucket} />;
610
- }
611
- },
612
- files: { title: "Files" },
613
- bytes: { title: "Bytes" },
614
- flags: {
615
- title: "Flags",
616
- // Only flags that are set produce a tag, so an empty cell means every default
617
- formatter: flags => <div className={css.hbox(4).wrap}>
618
- {(flags || []).map(tag => <Tag key={tag.text} icon={tag.icon} text={tag.text} />)}
619
- </div>
620
- },
621
- indexSources: {
622
- title: "Index sources",
623
- formatter: sources => <IndexSourcesCell sources={sources} />
624
- },
625
- readerDiskLimit: { title: "Read cache limit" },
626
- writes: { title: "Writes" },
627
- written: { title: "Written" },
628
- writeGain: { title: "Fast gain" },
629
- disk: {
630
- title: "Drive",
631
- formatter: (disk, context) => {
632
- if (!disk) return context?.row?.diskError || "";
633
- return <UsageBar
634
- label={DISK_BAR_TYPE}
635
- value={disk.usedBytes}
636
- max={disk.totalBytes}
637
- {...getUsageThresholds(DISK_BAR_TYPE)}
638
- />;
639
- }
640
- },
641
- diskError: null,
642
- syncing: {
643
- title: "Syncing",
644
- formatter: syncing => <SyncingCell syncing={syncing} />
645
- },
646
- error: { title: "Error" },
647
- }}
648
- />
649
- <RouteConfigView servers={servers} />
650
- </div>;
651
- }
652
- }