querysub 0.548.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.548.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.34",
74
+ "sliftutils": "^1.7.35",
75
75
  "socket-function": "^1.2.26",
76
76
  "terser": "^5.31.0",
77
77
  "typenode": "^6.6.1",
@@ -27,7 +27,6 @@ const FUTURE_COLOR = { h: 45, s: 80, l: 85 };
27
27
  // Windows are days or hours wide, so the countdown only has to be roughly right
28
28
  const TIME_REFRESH_INTERVAL = 60 * 1000;
29
29
  const WATCH_POLL_INTERVAL = 60 * 1000;
30
- const WATCH_ERROR_LIMIT = 500;
31
30
  const WATCH_ACTIVE_COLOR = { h: 195, s: 60, l: 85 };
32
31
  const WINDOW_HEADER_SIZE = 13;
33
32
  const BUCKET_TITLE_SIZE = 15;
@@ -239,28 +238,28 @@ function getServerUrl(source: Source): string | undefined {
239
238
  }
240
239
 
241
240
  async function refreshWatch(key: string): Promise<void> {
242
- let watch = liveWatchData().watches[key];
243
- if (!watch) return;
244
- let { serverUrl, bucketName } = watch;
245
- Querysub.localCommit(() => {
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(() => {
246
250
  let current = liveWatchData().watches[key];
247
251
  if (current) current.loading = true;
248
252
  });
249
- let result: RemoteConfig | undefined;
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
250
255
  let error: string | undefined;
251
- try {
252
- let live = await getServerActiveBucket({ url: serverUrl, account: STORAGE_ACCOUNT, bucketName });
253
- // The server reports "not loaded here" as a string rather than throwing
254
- if (typeof live === "string") {
255
- error = live;
256
- } else {
257
- result = live.routing;
258
- }
259
- } catch (e) {
260
- error = String((e as Error).stack ?? e).slice(0, WATCH_ERROR_LIMIT);
261
- console.error(`Reading the live config of ${bucketName} on ${serverUrl} failed:`, (e as Error).stack ?? e);
256
+ let result: RemoteConfig | undefined;
257
+ if (typeof live === "string") {
258
+ error = live;
259
+ } else {
260
+ result = live.routing;
262
261
  }
263
- Querysub.localCommit(() => {
262
+ Querysub.commit(() => {
264
263
  let current = liveWatchData().watches[key];
265
264
  // The watch may have been removed while the call was in flight
266
265
  if (!current) return;
@@ -277,8 +276,8 @@ function isWatched(serverUrl: string, bucketName: string): boolean {
277
276
 
278
277
  function toggleWatch(serverUrl: string, bucketName: string): void {
279
278
  let key = getWatchKey(serverUrl, bucketName);
280
- let existed = !!liveWatchData().watches[key];
281
- Querysub.localCommit(() => {
279
+ let existed = Querysub.localRead(() => !!liveWatchData().watches[key]);
280
+ Querysub.commit(() => {
282
281
  if (existed) {
283
282
  delete liveWatchData().watches[key];
284
283
  return;
@@ -290,13 +289,15 @@ function toggleWatch(serverUrl: string, bucketName: string): void {
290
289
  }
291
290
 
292
291
  function refreshAllWatches(): void {
293
- for (let key of Object.keys(liveWatchData().watches)) {
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) {
294
295
  void refreshWatch(key);
295
296
  }
296
297
  }
297
298
 
298
299
  function clearWatches(): void {
299
- Querysub.localCommit(() => {
300
+ Querysub.commit(() => {
300
301
  for (let key of Object.keys(liveWatchData().watches)) {
301
302
  delete liveWatchData().watches[key];
302
303
  }
@@ -347,7 +348,7 @@ function getSourceTags(source: Source): { icon: string; text: string }[] {
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. */
348
349
  class WatchButton extends qreact.Component<{ source: Source; bucketName: string }> {
349
350
  render() {
350
- let serverUrl = getServerUrl(this.props.source);
351
+ const serverUrl = getServerUrl(this.props.source);
351
352
  // Only storage servers have a live in-memory state to read
352
353
  if (!serverUrl) return undefined;
353
354
  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(0, 70, 40).hsl(0, 70, 90)}
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(0, 70, 40).hsl(0, 70, 90)}
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
  }}>
@@ -11,7 +11,8 @@ 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 { getEffectiveServiceConfigs, getLiveServiceParameters, getMachineTargets, applyCommandTemplate, ServiceParameters, DEFAULT_OVERLAP_TIME } from "../machineSchema";
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
- export type StorageServerBuckets = {
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,43 @@ function getStorageUrls(parameters: ServiceParameters): string[] {
46
53
  return urls;
47
54
  }
48
55
 
49
- class StoragePageControllerBase {
50
- public async getStorageBuckets(): Promise<StorageServerBuckets[]> {
51
- let configs = await getEffectiveServiceConfigs();
52
- type ServiceForUrl = Omit<StorageServerBuckets, "url" | "buckets" | "error">;
53
- let serviceByUrl = new Map<string, ServiceForUrl>();
54
- for (let config of configs) {
55
- let allParameters = [getLiveServiceParameters(config), config.parameters, config.oldParameters];
56
- for (let parameters of allParameters) {
57
- if (!parameters) continue;
58
- for (let url of getStorageUrls(parameters)) {
59
- if (serviceByUrl.has(url)) continue;
60
- serviceByUrl.set(url, {
61
- serviceKey: parameters.key,
62
- serviceTitle: config.info.title,
63
- releaseTime: config.parameters.releaseTime,
64
- // The overlap always comes from the newest parameters, even though it governs how long the old instances outlive the release
65
- overlapTime: config.parameters.overlapTime ?? DEFAULT_OVERLAP_TIME,
66
- hasOldParameters: !!config.oldParameters,
67
- });
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
- return await Promise.all([...serviceByUrl].map(async ([url, service]) => {
72
- try {
73
- return { ...service, url, buckets: await listServerBuckets({ url, account: STORAGE_ACCOUNT }) };
74
- } catch (e) {
75
- return { ...service, url, error: String((e as Error).stack ?? e).slice(0, ERROR_TEXT_LIMIT) };
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
+ 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 });
78
93
  }
79
94
  }
80
95
 
@@ -82,7 +97,7 @@ export const StoragePageController = SocketFunction.register(
82
97
  "StoragePageController-4f1c93ab-77e2-4d15-9a30-1c6b8f5d2e04",
83
98
  new StoragePageControllerBase(),
84
99
  () => ({
85
- getStorageBuckets: {},
100
+ getServerBuckets: {},
86
101
  }),
87
102
  () => ({
88
103
  hooks: [assertIsManagementUser],
@@ -149,8 +164,8 @@ function getBucketRows(servers: StorageServerBuckets[]): BucketRow[] {
149
164
  syncing: undefined,
150
165
  error: "",
151
166
  };
152
- if (server.error) {
153
- rows.push({ ...baseRow, error: server.error });
167
+ if (server.loading) {
168
+ rows.push({ ...baseRow, bucket: "Loading..." });
154
169
  continue;
155
170
  }
156
171
  let buckets = [...server.buckets || []];
@@ -191,30 +206,6 @@ class ActivateBucketButton extends qreact.Component<{ serverUrl: string; bucketN
191
206
  activating: false,
192
207
  error: "",
193
208
  };
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
209
  render() {
219
210
  return <div className={css.hbox(6).wrap}>
220
211
  <div className={css.whiteSpace("nowrap")}>{INACTIVE_STATE}</div>
@@ -222,7 +213,29 @@ class ActivateBucketButton extends qreact.Component<{ serverUrl: string; bucketN
222
213
  className={css.pad2(6, 1).button.bord2(0, 0, 20).hsl(0, 0, 100).whiteSpace("nowrap")}
223
214
  disabled={this.state.activating}
224
215
  onClick={() => {
225
- void this.activate();
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
+ });
226
239
  }}
227
240
  >
228
241
  {this.state.activating && "Activating..." || "Activate"}
@@ -315,43 +328,71 @@ class IndexSourcesCell extends qreact.Component<{ sources: ArchivesConfig["index
315
328
  }
316
329
  }
317
330
 
318
- const DEPLOY_PENDING_COLOR = { h: 35, s: 80, l: 45 };
319
- const DEPLOY_OVERLAP_COLOR = { h: 280, s: 60, l: 45 };
331
+ const DEPLOY_PENDING_HUE = { h: 35, s: 80 };
332
+ const DEPLOY_OVERLAP_HUE = { h: 280, s: 60 };
320
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
+ }> {
345
+ render() {
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
+ }
321
364
 
322
365
  /** The services behind these storage servers may be mid-release, which moves (or duplicates) the servers the page just read from. */
323
- class DeployNotices extends qreact.Component<{ servers: StorageServerBuckets[] }> {
366
+ class DeployNotices extends qreact.Component<{ services: StorageService[] }> {
324
367
  render() {
325
368
  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
369
  let notices: preact.ComponentChild[] = [];
332
- for (let [serviceKey, service] of byService) {
370
+ for (let service of this.props.services) {
371
+ let serviceKey = service.serviceKey;
333
372
  let releaseTime = service.releaseTime || 0;
334
373
  // The old instances run out their overlap purely on time - we don't verify they're actually still up
335
374
  if (!releaseTime || !service.hasOldParameters) continue;
336
375
  let killTime = releaseTime + service.overlapTime;
337
376
  if (now < releaseTime) {
338
- notices.push(<div
377
+ notices.push(<DeployNotice
339
378
  key={serviceKey + "pending"}
340
- className={css.pad2(10, 6).fontSize(DEPLOY_NOTICE_FONT_SIZE).boldStyle.colorhsl(DEPLOY_PENDING_COLOR.h, DEPLOY_PENDING_COLOR.s, DEPLOY_PENDING_COLOR.l).bord2(DEPLOY_PENDING_COLOR.h, DEPLOY_PENDING_COLOR.s, DEPLOY_PENDING_COLOR.l + 35)}
341
- title={`Deploys at ${formatDateTimeDetailed(releaseTime)}`}
342
- >
343
- 🕐 {service.serviceTitle} ({serviceKey}) deploys in {formatTime(releaseTime - now)}
344
- </div>);
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
+ />);
345
385
  continue;
346
386
  }
347
387
  if (now < killTime) {
348
- notices.push(<div
388
+ notices.push(<DeployNotice
349
389
  key={serviceKey + "overlap"}
350
- className={css.pad2(10, 6).fontSize(DEPLOY_NOTICE_FONT_SIZE).boldStyle.colorhsl(DEPLOY_OVERLAP_COLOR.h, DEPLOY_OVERLAP_COLOR.s, DEPLOY_OVERLAP_COLOR.l).bord2(DEPLOY_OVERLAP_COLOR.h, DEPLOY_OVERLAP_COLOR.s, DEPLOY_OVERLAP_COLOR.l + 35)}
351
- title={`Old instances shut down at ${formatDateTimeDetailed(killTime)}`}
352
- >
353
- 🔀 {service.serviceTitle} ({serviceKey}) still has an old version running for {formatTime(killTime - now)}
354
- </div>);
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
+ />);
355
396
  }
356
397
  }
357
398
  if (!notices.length) return undefined;
@@ -361,28 +402,29 @@ class DeployNotices extends qreact.Component<{ servers: StorageServerBuckets[] }
361
402
 
362
403
  async function resetWriteStats(servers: StorageServerBuckets[]): Promise<void> {
363
404
  await Promise.all(servers.map(async server => {
364
- if (server.error) return;
365
- try {
366
- let { clearedBuckets } = await clearServerWriteStats({ url: server.url, account: STORAGE_ACCOUNT });
367
- console.log(`Cleared write stats for ${clearedBuckets} buckets on ${server.url}`);
368
- } catch (e) {
369
- console.error(`Clearing write stats on ${server.url} failed:`, (e as Error).stack ?? e);
370
- }
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}`);
371
408
  }));
372
- StorageSynced(SocketFunction.browserNodeId()).getStorageBuckets.resetAll();
409
+ Querysub.commit(() => {
410
+ StorageSynced(SocketFunction.browserNodeId()).getServerBuckets.resetAll();
411
+ });
373
412
  }
374
413
 
375
414
  export class StoragePage extends qreact.Component {
376
415
  render() {
377
- const servers = StorageSynced(SocketFunction.browserNodeId()).getStorageBuckets();
378
- if (!servers) return <div>Loading storage servers...</div>;
379
- if (!servers.length) {
380
- return <div className={css.vbox(16)}>
381
- <h2>Storage</h2>
382
- <div>No services with a command starting with {JSON.stringify(STORAGE_COMMAND_PREFIX)} were found.</div>
383
- </div>;
384
- }
385
- return <div className={css.vbox(16)}>
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 = <>
386
428
  <div className={css.hbox(12)}>
387
429
  <h2 className={css.flexGrow(1)}>Storage</h2>
388
430
  <button className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 0, 100)}
@@ -393,12 +435,27 @@ export class StoragePage extends qreact.Component {
393
435
  </button>
394
436
  <button className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 0, 100)}
395
437
  onClick={() => {
396
- StorageSynced(SocketFunction.browserNodeId()).getStorageBuckets.resetAll();
438
+ controller.getServerBuckets.resetAll();
397
439
  }}>
398
440
  Refresh
399
441
  </button>
400
442
  </div>
401
- <DeployNotices servers={servers} />
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}
402
459
  <Table
403
460
  rows={getBucketRows(servers)}
404
461
  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, 70, 40).hsl(0, 70, 90)}
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(0, 70, 90)}
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={() => {