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