querysub 0.548.0 → 0.550.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.550.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.36",
|
|
75
75
|
"socket-function": "^1.2.26",
|
|
76
76
|
"terser": "^5.31.0",
|
|
77
77
|
"typenode": "^6.6.1",
|
|
@@ -4,14 +4,13 @@ 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 {
|
|
8
|
-
import { STORAGE_ACCOUNT } from "../../-a-archives/archives2";
|
|
7
|
+
import { SocketFunction } from "socket-function/SocketFunction";
|
|
9
8
|
import { FULL_VALID_WINDOW, FULL_ROUTE } from "sliftutils/storage/IArchives";
|
|
10
9
|
import type { RemoteConfig, RemoteConfigBase, HostedConfig, BackblazeConfig } from "sliftutils/storage/IArchives";
|
|
11
10
|
import { showModal } from "../../5-diagnostics/Modal";
|
|
12
11
|
import { FullscreenModal } from "../../5-diagnostics/FullscreenModal";
|
|
13
12
|
import { Button } from "../../library-components/Button";
|
|
14
|
-
import type
|
|
13
|
+
import { StorageSynced, type StorageServerBuckets } from "./StoragePage";
|
|
15
14
|
|
|
16
15
|
module.hotreload = true;
|
|
17
16
|
|
|
@@ -27,7 +26,6 @@ const FUTURE_COLOR = { h: 45, s: 80, l: 85 };
|
|
|
27
26
|
// Windows are days or hours wide, so the countdown only has to be roughly right
|
|
28
27
|
const TIME_REFRESH_INTERVAL = 60 * 1000;
|
|
29
28
|
const WATCH_POLL_INTERVAL = 60 * 1000;
|
|
30
|
-
const WATCH_ERROR_LIMIT = 500;
|
|
31
29
|
const WATCH_ACTIVE_COLOR = { h: 195, s: 60, l: 85 };
|
|
32
30
|
const WINDOW_HEADER_SIZE = 13;
|
|
33
31
|
const BUCKET_TITLE_SIZE = 15;
|
|
@@ -239,36 +237,44 @@ function getServerUrl(source: Source): string | undefined {
|
|
|
239
237
|
}
|
|
240
238
|
|
|
241
239
|
async function refreshWatch(key: string): Promise<void> {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
240
|
+
// Reads outside a synchronized context have to go through localRead, and the plain values are pulled out here so nothing proxied escapes into the async work
|
|
241
|
+
let target = Querysub.localRead(() => {
|
|
242
|
+
let watch = liveWatchData().watches[key];
|
|
243
|
+
if (!watch) return undefined;
|
|
244
|
+
return { serverUrl: watch.serverUrl, bucketName: watch.bucketName };
|
|
245
|
+
});
|
|
246
|
+
if (!target) return;
|
|
247
|
+
let { serverUrl, bucketName } = target;
|
|
248
|
+
Querysub.commit(() => {
|
|
246
249
|
let current = liveWatchData().watches[key];
|
|
247
250
|
if (current) current.loading = true;
|
|
248
251
|
});
|
|
249
|
-
let result: RemoteConfig | undefined;
|
|
250
|
-
let error: string | undefined;
|
|
251
252
|
try {
|
|
252
|
-
|
|
253
|
-
|
|
253
|
+
// Proxied through our server, which holds the identity the storage servers authenticate against
|
|
254
|
+
let live = await StorageSynced(SocketFunction.browserNodeId()).getActiveBucket.promise(serverUrl, bucketName);
|
|
255
|
+
// "Not loaded here" comes back as a string - it's a normal result, not an error
|
|
256
|
+
let error: string | undefined;
|
|
257
|
+
let result: RemoteConfig | undefined;
|
|
254
258
|
if (typeof live === "string") {
|
|
255
259
|
error = live;
|
|
256
260
|
} else {
|
|
257
261
|
result = live.routing;
|
|
258
262
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
263
|
+
Querysub.commit(() => {
|
|
264
|
+
let current = liveWatchData().watches[key];
|
|
265
|
+
// The watch may have been removed while the call was in flight
|
|
266
|
+
if (!current) return;
|
|
267
|
+
current.routing = result;
|
|
268
|
+
current.error = error;
|
|
269
|
+
current.updatedTime = Date.now();
|
|
270
|
+
});
|
|
271
|
+
} finally {
|
|
272
|
+
// Without this a throw would leave the watch stuck showing "refreshing..."
|
|
273
|
+
Querysub.commit(() => {
|
|
274
|
+
let current = liveWatchData().watches[key];
|
|
275
|
+
if (current) current.loading = false;
|
|
276
|
+
});
|
|
262
277
|
}
|
|
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
278
|
}
|
|
273
279
|
|
|
274
280
|
function isWatched(serverUrl: string, bucketName: string): boolean {
|
|
@@ -277,8 +283,8 @@ function isWatched(serverUrl: string, bucketName: string): boolean {
|
|
|
277
283
|
|
|
278
284
|
function toggleWatch(serverUrl: string, bucketName: string): void {
|
|
279
285
|
let key = getWatchKey(serverUrl, bucketName);
|
|
280
|
-
let existed = !!liveWatchData().watches[key];
|
|
281
|
-
Querysub.
|
|
286
|
+
let existed = Querysub.localRead(() => !!liveWatchData().watches[key]);
|
|
287
|
+
Querysub.commit(() => {
|
|
282
288
|
if (existed) {
|
|
283
289
|
delete liveWatchData().watches[key];
|
|
284
290
|
return;
|
|
@@ -290,13 +296,15 @@ function toggleWatch(serverUrl: string, bucketName: string): void {
|
|
|
290
296
|
}
|
|
291
297
|
|
|
292
298
|
function refreshAllWatches(): void {
|
|
293
|
-
|
|
299
|
+
// Fired from a timer, so the key list has to be read through localRead
|
|
300
|
+
let keys = Querysub.localRead(() => Object.keys(liveWatchData().watches));
|
|
301
|
+
for (let key of keys) {
|
|
294
302
|
void refreshWatch(key);
|
|
295
303
|
}
|
|
296
304
|
}
|
|
297
305
|
|
|
298
306
|
function clearWatches(): void {
|
|
299
|
-
Querysub.
|
|
307
|
+
Querysub.commit(() => {
|
|
300
308
|
for (let key of Object.keys(liveWatchData().watches)) {
|
|
301
309
|
delete liveWatchData().watches[key];
|
|
302
310
|
}
|
|
@@ -347,7 +355,7 @@ function getSourceTags(source: Source): { icon: string; text: string }[] {
|
|
|
347
355
|
/** 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
356
|
class WatchButton extends qreact.Component<{ source: Source; bucketName: string }> {
|
|
349
357
|
render() {
|
|
350
|
-
|
|
358
|
+
const serverUrl = getServerUrl(this.props.source);
|
|
351
359
|
// Only storage servers have a live in-memory state to read
|
|
352
360
|
if (!serverUrl) return undefined;
|
|
353
361
|
let watching = isWatched(serverUrl, this.props.bucketName);
|
|
@@ -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
|
}}>
|
|
@@ -5,13 +5,14 @@ import { formatNumber, formatTime, formatDateTimeDetailed } from "socket-functio
|
|
|
5
5
|
import { sort, timeInSecond } from "socket-function/src/misc";
|
|
6
6
|
import preact from "preact";
|
|
7
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";
|
|
8
|
+
import { listServerBuckets, clearServerWriteStats, activateServerBucket, getServerActiveBucket } from "sliftutils/storage/remoteStorage/createArchives";
|
|
9
|
+
import type { ServerBucketInfo, BucketDiskInfo, BucketWriteStats, ActiveBucketInfo } from "sliftutils/storage/remoteStorage/storageServerState";
|
|
10
10
|
import type { ArchivesConfig } from "sliftutils/storage/IArchives";
|
|
11
11
|
import { UsageBar, getUsageThresholds } from "../../library-components/UsageBar";
|
|
12
12
|
import { getSyncedController } from "../../library-components/SyncedController";
|
|
13
13
|
import { assertIsManagementUser } from "../../diagnostics/managementPages";
|
|
14
|
-
import {
|
|
14
|
+
import { getLiveServiceParameters, getMachineTargets, applyCommandTemplate, ServiceParameters, ServiceConfig, MachineServiceController, DEFAULT_OVERLAP_TIME } from "../machineSchema";
|
|
15
|
+
import { isDefined } from "../../misc";
|
|
15
16
|
import { STORAGE_ACCOUNT } from "../../-a-archives/archives2";
|
|
16
17
|
import { Table } from "../../5-diagnostics/Table";
|
|
17
18
|
import { RouteConfigView } from "./RouteConfigView";
|
|
@@ -20,18 +21,24 @@ module.hotreload = true;
|
|
|
20
21
|
|
|
21
22
|
const STORAGE_COMMAND_PREFIX = "yarn storageserve";
|
|
22
23
|
const URL_ARG_REGEX = /--url\s+(\S+)/g;
|
|
23
|
-
const ERROR_TEXT_LIMIT = 500;
|
|
24
24
|
|
|
25
|
-
|
|
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 = {
|
|
26
27
|
serviceKey: string;
|
|
27
28
|
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
29
|
releaseTime?: number;
|
|
30
30
|
overlapTime: number;
|
|
31
31
|
hasOldParameters: boolean;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type StorageServer = StorageService & {
|
|
32
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;
|
|
33
41
|
buckets?: ServerBucketInfo[];
|
|
34
|
-
error?: string;
|
|
35
42
|
};
|
|
36
43
|
|
|
37
44
|
function getStorageUrls(parameters: ServiceParameters): string[] {
|
|
@@ -46,35 +53,53 @@ function getStorageUrls(parameters: ServiceParameters): string[] {
|
|
|
46
53
|
return urls;
|
|
47
54
|
}
|
|
48
55
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
let
|
|
54
|
-
for (let
|
|
55
|
-
|
|
56
|
-
for (let
|
|
57
|
-
if (
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
});
|
|
68
|
-
}
|
|
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
|
+
});
|
|
69
74
|
}
|
|
70
75
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
+
/** Every storage call is proxied through here: they authenticate with this machine's identity CA, which the browser has no access to. */
|
|
90
|
+
class StoragePageControllerBase {
|
|
91
|
+
/** One call per server, so each caches and refreshes on its own. */
|
|
92
|
+
public async getServerBuckets(url: string): Promise<ServerBucketInfo[]> {
|
|
93
|
+
return await listServerBuckets({ url, account: STORAGE_ACCOUNT });
|
|
94
|
+
}
|
|
95
|
+
public async activateBucket(url: string, bucketName: string): Promise<ActiveBucketInfo | string> {
|
|
96
|
+
return await activateServerBucket({ url, account: STORAGE_ACCOUNT, bucketName });
|
|
97
|
+
}
|
|
98
|
+
public async getActiveBucket(url: string, bucketName: string): Promise<ActiveBucketInfo | string> {
|
|
99
|
+
return await getServerActiveBucket({ url, account: STORAGE_ACCOUNT, bucketName });
|
|
100
|
+
}
|
|
101
|
+
public async clearWriteStats(url: string): Promise<{ clearedBuckets: number }> {
|
|
102
|
+
return await clearServerWriteStats({ url, account: STORAGE_ACCOUNT });
|
|
78
103
|
}
|
|
79
104
|
}
|
|
80
105
|
|
|
@@ -82,7 +107,10 @@ export const StoragePageController = SocketFunction.register(
|
|
|
82
107
|
"StoragePageController-4f1c93ab-77e2-4d15-9a30-1c6b8f5d2e04",
|
|
83
108
|
new StoragePageControllerBase(),
|
|
84
109
|
() => ({
|
|
85
|
-
|
|
110
|
+
getServerBuckets: {},
|
|
111
|
+
activateBucket: {},
|
|
112
|
+
getActiveBucket: {},
|
|
113
|
+
clearWriteStats: {},
|
|
86
114
|
}),
|
|
87
115
|
() => ({
|
|
88
116
|
hooks: [assertIsManagementUser],
|
|
@@ -91,7 +119,7 @@ export const StoragePageController = SocketFunction.register(
|
|
|
91
119
|
}
|
|
92
120
|
);
|
|
93
121
|
|
|
94
|
-
const StorageSynced = getSyncedController(StoragePageController, {
|
|
122
|
+
export const StorageSynced = getSyncedController(StoragePageController, {
|
|
95
123
|
reads: {},
|
|
96
124
|
writes: {},
|
|
97
125
|
});
|
|
@@ -149,8 +177,8 @@ function getBucketRows(servers: StorageServerBuckets[]): BucketRow[] {
|
|
|
149
177
|
syncing: undefined,
|
|
150
178
|
error: "",
|
|
151
179
|
};
|
|
152
|
-
if (server.
|
|
153
|
-
rows.push({ ...baseRow,
|
|
180
|
+
if (server.loading) {
|
|
181
|
+
rows.push({ ...baseRow, bucket: "Loading..." });
|
|
154
182
|
continue;
|
|
155
183
|
}
|
|
156
184
|
let buckets = [...server.buckets || []];
|
|
@@ -191,30 +219,6 @@ class ActivateBucketButton extends qreact.Component<{ serverUrl: string; bucketN
|
|
|
191
219
|
activating: false,
|
|
192
220
|
error: "",
|
|
193
221
|
};
|
|
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
222
|
render() {
|
|
219
223
|
return <div className={css.hbox(6).wrap}>
|
|
220
224
|
<div className={css.whiteSpace("nowrap")}>{INACTIVE_STATE}</div>
|
|
@@ -222,7 +226,35 @@ class ActivateBucketButton extends qreact.Component<{ serverUrl: string; bucketN
|
|
|
222
226
|
className={css.pad2(6, 1).button.bord2(0, 0, 20).hsl(0, 0, 100).whiteSpace("nowrap")}
|
|
223
227
|
disabled={this.state.activating}
|
|
224
228
|
onClick={() => {
|
|
225
|
-
|
|
229
|
+
// Props and state are synchronized, so everything the async work needs is copied into plain locals HERE, in the tracked part
|
|
230
|
+
let serverUrl = this.props.serverUrl;
|
|
231
|
+
let bucketName = this.props.bucketName;
|
|
232
|
+
this.state.activating = true;
|
|
233
|
+
this.state.error = "";
|
|
234
|
+
Querysub.onCommitFinished(async () => {
|
|
235
|
+
try {
|
|
236
|
+
let result = await StorageSynced(SocketFunction.browserNodeId()).activateBucket.promise(serverUrl, bucketName);
|
|
237
|
+
// A refusal comes back as a string - it's a normal result, not an error
|
|
238
|
+
let refusal = typeof result === "string" && result || "";
|
|
239
|
+
if (refusal) {
|
|
240
|
+
console.log(`Activating bucket ${bucketName} on ${serverUrl} was refused: ${refusal}`);
|
|
241
|
+
} else {
|
|
242
|
+
console.log(`Activated bucket ${bucketName} on ${serverUrl}`);
|
|
243
|
+
}
|
|
244
|
+
Querysub.commit(() => {
|
|
245
|
+
this.state.error = refusal;
|
|
246
|
+
// Only this server's buckets changed
|
|
247
|
+
if (!refusal) {
|
|
248
|
+
StorageSynced(SocketFunction.browserNodeId()).getServerBuckets.reset(serverUrl);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
} finally {
|
|
252
|
+
// Without this a throw would leave the button disabled and stuck on "Activating..."
|
|
253
|
+
Querysub.commit(() => {
|
|
254
|
+
this.state.activating = false;
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
});
|
|
226
258
|
}}
|
|
227
259
|
>
|
|
228
260
|
{this.state.activating && "Activating..." || "Activate"}
|
|
@@ -315,43 +347,71 @@ class IndexSourcesCell extends qreact.Component<{ sources: ArchivesConfig["index
|
|
|
315
347
|
}
|
|
316
348
|
}
|
|
317
349
|
|
|
318
|
-
const
|
|
319
|
-
const
|
|
350
|
+
const DEPLOY_PENDING_HUE = { h: 35, s: 80 };
|
|
351
|
+
const DEPLOY_OVERLAP_HUE = { h: 280, s: 60 };
|
|
320
352
|
const DEPLOY_NOTICE_FONT_SIZE = 14;
|
|
353
|
+
const DEPLOY_NOTICE_TEXT_LIGHTNESS = 30;
|
|
354
|
+
const DEPLOY_NOTICE_BORDER_LIGHTNESS = 55;
|
|
355
|
+
const DEPLOY_NOTICE_BACKGROUND_LIGHTNESS = 94;
|
|
356
|
+
|
|
357
|
+
class DeployNotice extends qreact.Component<{
|
|
358
|
+
hue: { h: number; s: number };
|
|
359
|
+
icon: string;
|
|
360
|
+
title: string;
|
|
361
|
+
detail: string;
|
|
362
|
+
tooltip: string;
|
|
363
|
+
}> {
|
|
364
|
+
render() {
|
|
365
|
+
let { hue, icon, title, detail, tooltip } = this.props;
|
|
366
|
+
return <div
|
|
367
|
+
className={
|
|
368
|
+
css.hbox(8).pad2(12, 8).fontSize(DEPLOY_NOTICE_FONT_SIZE).alignItems("center")
|
|
369
|
+
.hsl(hue.h, hue.s, DEPLOY_NOTICE_BACKGROUND_LIGHTNESS)
|
|
370
|
+
.bord2(hue.h, hue.s, DEPLOY_NOTICE_BORDER_LIGHTNESS)
|
|
371
|
+
.colorhsl(hue.h, hue.s, DEPLOY_NOTICE_TEXT_LIGHTNESS)
|
|
372
|
+
}
|
|
373
|
+
title={tooltip}
|
|
374
|
+
>
|
|
375
|
+
<div className={css.fontSize(DEPLOY_NOTICE_FONT_SIZE + 4)}>{icon}</div>
|
|
376
|
+
<div className={css.vbox(2)}>
|
|
377
|
+
<div className={css.boldStyle}>{title}</div>
|
|
378
|
+
<div className={css.fontSize(DEPLOY_NOTICE_FONT_SIZE - 2)}>{detail}</div>
|
|
379
|
+
</div>
|
|
380
|
+
</div>;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
321
383
|
|
|
322
384
|
/** 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<{
|
|
385
|
+
class DeployNotices extends qreact.Component<{ services: StorageService[] }> {
|
|
324
386
|
render() {
|
|
325
387
|
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
388
|
let notices: preact.ComponentChild[] = [];
|
|
332
|
-
for (let
|
|
389
|
+
for (let service of this.props.services) {
|
|
390
|
+
let serviceKey = service.serviceKey;
|
|
333
391
|
let releaseTime = service.releaseTime || 0;
|
|
334
392
|
// The old instances run out their overlap purely on time - we don't verify they're actually still up
|
|
335
393
|
if (!releaseTime || !service.hasOldParameters) continue;
|
|
336
394
|
let killTime = releaseTime + service.overlapTime;
|
|
337
395
|
if (now < releaseTime) {
|
|
338
|
-
notices.push(<
|
|
396
|
+
notices.push(<DeployNotice
|
|
339
397
|
key={serviceKey + "pending"}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
398
|
+
hue={DEPLOY_PENDING_HUE}
|
|
399
|
+
icon="🕐"
|
|
400
|
+
title={`Deploys in ${formatTime(releaseTime - now)}`}
|
|
401
|
+
detail={`${service.serviceTitle} (${serviceKey})`}
|
|
402
|
+
tooltip={`Deploys at ${formatDateTimeDetailed(releaseTime)}`}
|
|
403
|
+
/>);
|
|
345
404
|
continue;
|
|
346
405
|
}
|
|
347
406
|
if (now < killTime) {
|
|
348
|
-
notices.push(<
|
|
407
|
+
notices.push(<DeployNotice
|
|
349
408
|
key={serviceKey + "overlap"}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
409
|
+
hue={DEPLOY_OVERLAP_HUE}
|
|
410
|
+
icon="🔀"
|
|
411
|
+
title={`Old version still running for ${formatTime(killTime - now)}`}
|
|
412
|
+
detail={`${service.serviceTitle} (${serviceKey})`}
|
|
413
|
+
tooltip={`Old instances shut down at ${formatDateTimeDetailed(killTime)}`}
|
|
414
|
+
/>);
|
|
355
415
|
}
|
|
356
416
|
}
|
|
357
417
|
if (!notices.length) return undefined;
|
|
@@ -361,28 +421,29 @@ class DeployNotices extends qreact.Component<{ servers: StorageServerBuckets[] }
|
|
|
361
421
|
|
|
362
422
|
async function resetWriteStats(servers: StorageServerBuckets[]): Promise<void> {
|
|
363
423
|
await Promise.all(servers.map(async server => {
|
|
364
|
-
if (server.
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
}
|
|
424
|
+
if (server.loading) return;
|
|
425
|
+
let { clearedBuckets } = await StorageSynced(SocketFunction.browserNodeId()).clearWriteStats.promise(server.url);
|
|
426
|
+
console.log(`Cleared write stats for ${clearedBuckets} buckets on ${server.url}`);
|
|
371
427
|
}));
|
|
372
|
-
|
|
428
|
+
Querysub.commit(() => {
|
|
429
|
+
StorageSynced(SocketFunction.browserNodeId()).getServerBuckets.resetAll();
|
|
430
|
+
});
|
|
373
431
|
}
|
|
374
432
|
|
|
375
433
|
export class StoragePage extends qreact.Component {
|
|
376
434
|
render() {
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
435
|
+
const controller = StorageSynced(SocketFunction.browserNodeId());
|
|
436
|
+
const machineController = MachineServiceController(SocketFunction.browserNodeId());
|
|
437
|
+
const serviceList = machineController.getServiceList();
|
|
438
|
+
// The configs are already synchronized to the client, so the deploy state needs no round trip at all
|
|
439
|
+
const configs = (serviceList || []).map(serviceId => machineController.getServiceConfig(serviceId)).filter(isDefined);
|
|
440
|
+
const storageServers = getStorageServers(configs);
|
|
441
|
+
const servers: StorageServerBuckets[] = storageServers.map(server => {
|
|
442
|
+
let buckets = controller.getServerBuckets(server.url);
|
|
443
|
+
if (!buckets) return { ...server, loading: true };
|
|
444
|
+
return { ...server, buckets };
|
|
445
|
+
});
|
|
446
|
+
const header = <>
|
|
386
447
|
<div className={css.hbox(12)}>
|
|
387
448
|
<h2 className={css.flexGrow(1)}>Storage</h2>
|
|
388
449
|
<button className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 0, 100)}
|
|
@@ -393,12 +454,27 @@ export class StoragePage extends qreact.Component {
|
|
|
393
454
|
</button>
|
|
394
455
|
<button className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 0, 100)}
|
|
395
456
|
onClick={() => {
|
|
396
|
-
|
|
457
|
+
controller.getServerBuckets.resetAll();
|
|
397
458
|
}}>
|
|
398
459
|
Refresh
|
|
399
460
|
</button>
|
|
400
461
|
</div>
|
|
401
|
-
<DeployNotices
|
|
462
|
+
<DeployNotices services={getStorageServices(storageServers)} />
|
|
463
|
+
</>;
|
|
464
|
+
if (!serviceList) {
|
|
465
|
+
return <div className={css.vbox(16)}>
|
|
466
|
+
{header}
|
|
467
|
+
<div>Loading services...</div>
|
|
468
|
+
</div>;
|
|
469
|
+
}
|
|
470
|
+
if (!servers.length) {
|
|
471
|
+
return <div className={css.vbox(16)}>
|
|
472
|
+
{header}
|
|
473
|
+
<div>No services with a command starting with {JSON.stringify(STORAGE_COMMAND_PREFIX)} were found.</div>
|
|
474
|
+
</div>;
|
|
475
|
+
}
|
|
476
|
+
return <div className={css.vbox(16)}>
|
|
477
|
+
{header}
|
|
402
478
|
<Table
|
|
403
479
|
rows={getBucketRows(servers)}
|
|
404
480
|
columns={{
|
|
@@ -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={() => {
|