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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.606.0",
3
+ "version": "0.607.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",
@@ -40,8 +40,6 @@ function createSourceWindows(
40
40
  }
41
41
 
42
42
  function archiveBuilder(bucket: string, overrides: Partial<RemoteConfigBase>) {
43
- // REMEMBER TO INCREMENT VERSION WHEN YOU MAKE CHANGES!
44
- // REMEMBER TO RERUN initArchives2 if you change this (It's probably something you should call in your entry point.
45
43
  const ONTARIO = `https://99-250-124-91.querysubtest.com:5234/file/${STORAGE_ACCOUNT}/${bucket}/storage/storagerouting.json`;
46
44
  const HETZNER = `https://65-109-93-113.querysubtest.com:5234/file/${STORAGE_ACCOUNT}/${bucket}/storage/storagerouting.json`;
47
45
  const BACKBLAZE = `https://f002.backblazeb2.com/file/${bucket}/storage/storagerouting.json`;
@@ -58,7 +56,10 @@ function archiveBuilder(bucket: string, overrides: Partial<RemoteConfigBase>) {
58
56
  return sources;
59
57
  }
60
58
 
59
+ // IMPORTANT! If we start running into problems with archives, we can always comment out all of our sources, switch back to Backblaze, create a new version, and then call initArchives2. This should make everything work again. It'll be slower because it's using back plays, and it will be flaky because it's using backblaze, but it should generally work, And it should be something that we can do with absolutely none of our infrastructure up. So it's a good way to get things running again if things break.
61
60
 
61
+ // REMEMBER TO INCREMENT VERSION WHEN YOU MAKE CHANGES!
62
+ // REMEMBER TO RERUN initArchives2 if you change this (It's probably something you should call in your entry point.
62
63
  let sources = createSourceWindows(overrides, [
63
64
  {
64
65
  startTime: +new Date("2026-07-20 00:00:00-04:00"),
@@ -72,7 +73,7 @@ function archiveBuilder(bucket: string, overrides: Partial<RemoteConfigBase>) {
72
73
  ]);
73
74
 
74
75
  return createArchives({
75
- version: 18,
76
+ version: 20,
76
77
  sources,
77
78
  });
78
79
  }
@@ -139,6 +140,10 @@ export function getArchives2Buffered(folder: string, bufferDelay = timeInMinute
139
140
  return nestBucket(folder, archivesBuilderCache(getSafeDomain() + "-fast", { noFullSync: true, fast: true, writeDelay: bufferDelay }));
140
141
  }
141
142
 
143
+ export function getArchives2BufferedPublic(folder: string, bufferDelay = timeInMinute * 5) {
144
+ return nestBucket(folder, archivesBuilderCache(getSafeDomain() + "-fast", { noFullSync: true, fast: true, writeDelay: bufferDelay, public: true }));
145
+ }
146
+
142
147
  export function getArchives2PrivateImmutable(folder: string) {
143
148
  return nestBucket(folder, archivesBuilderCache(getSafeDomain() + "-private-immutable", { noFullSync: true, immutable: true }));
144
149
  }
@@ -499,6 +499,14 @@ async function edgeNodeFunction(config: {
499
499
  };
500
500
  autoCandidates.sort((a, b) => edgePickPenalty(a) - edgePickPenalty(b));
501
501
 
502
+ // Prefer nodes that have heartbeat within the last 5 minutes, keeping the ordering above among them. Everything staler goes after, ordered by most-recent heartbeat first (past 5 minutes they're probably dead anyway, so exact order barely matters).
503
+ const EDGE_RECENT_HEARTBEAT_TIME = 5 * 60_000;
504
+ const lastHeartbeat = (node: EdgeNodeConfig) => node.heartbeatTime || node.bootTime;
505
+ let recentlyHeartbeat = autoCandidates.filter(x => Date.now() - lastHeartbeat(x) < EDGE_RECENT_HEARTBEAT_TIME);
506
+ let staleHeartbeat = autoCandidates.filter(x => Date.now() - lastHeartbeat(x) >= EDGE_RECENT_HEARTBEAT_TIME);
507
+ staleHeartbeat.sort((a, b) => lastHeartbeat(b) - lastHeartbeat(a));
508
+ autoCandidates = [...recentlyHeartbeat, ...staleHeartbeat];
509
+
502
510
  // Probe the pickable candidates first (the first batch sets the decision window), then everything else purely for the dropdown's stats
503
511
  let pickableHosts = new Set(autoCandidates.map(x => x.host));
504
512
  let probeOrder = [...autoCandidates, ...edgeNodes.filter(x => !pickableHosts.has(x.host))];
@@ -2,12 +2,13 @@ import { waitForFirstTimeSync } from "socket-function/time/trueTimeShim";
2
2
  import { getOwnMachineId } from "sliftutils/misc/https/certs";
3
3
  import { devDebugbreak, getDomain, isLocal, isPublic, noSyncing } from "../config";
4
4
  import child_process from "child_process";
5
- import { getAllNodeIds, getOwnNodeId } from "../-f-node-discovery/NodeDiscovery";
6
- import { getArchives2Public } from "../-a-archives/archives2";
5
+ import { getOwnNodeId } from "../-f-node-discovery/NodeDiscovery";
6
+ import { getArchives2Buffered, getArchives2BufferedPublic, getArchives2Public } from "../-a-archives/archives2";
7
7
  import { SocketFunction } from "socket-function/SocketFunction";
8
- import { delay, runInSerial, runInfinitePoll, runInfinitePollCallAtStart } from "socket-function/src/batching";
8
+ import { delay, runInParallel, runInSerial, runInfinitePoll, runInfinitePollCallAtStart } from "socket-function/src/batching";
9
9
  import { compare, compareArray, isNodeTrue, sort, timeInMinute, timeInSecond } from "socket-function/src/misc";
10
- import { cacheLimited, lazy } from "socket-function/src/caching";
10
+ import { formatDateTime, formatTime } from "socket-function/src/formatting/format";
11
+ import { lazy } from "socket-function/src/caching";
11
12
  import { canHaveChildren } from "socket-function/src/types";
12
13
  import { getModuleFromConfig } from "../3-path-functions/pathFunctionLoader";
13
14
  import path from "path";
@@ -25,31 +26,20 @@ import { getGitRefLive, getGitURLLive } from "./git";
25
26
  import { DeployProgress } from "./deployFunctions";
26
27
  import { PromiseObj } from "../promise";
27
28
  import type { FunctionRunnerIndex } from "../4-querysub/FunctionRunnerTracking";
28
- import { unique } from "../misc";
29
-
30
29
  const UPDATE_POLL_INTERVAL = timeInMinute * 15;
31
- const DEAD_NODE_COUNT_THRESHOLD = 15;
30
+ const HEARTBEAT_INTERVAL = timeInMinute;
31
+ const EDGE_NODE_DELETE_THRESHOLD = timeInMinute * 15;
32
+ const EDGE_NODE_READ_PARALLEL = 64;
32
33
 
33
- const edgeNodeStorage = isNodeTrue() && getArchives2Public("edgenodes/");
34
- const edgeNodeIndexFile = "edge-nodes-index.json";
34
+ export const edgeNodeStorage = isNodeTrue() && getArchives2BufferedPublic("edgenodes2/");
35
+ export const edgeNodeIndexFile = "edge-nodes-index.json";
35
36
 
36
- const getEdgeNodeConfig = cacheLimited(10000, async (fileName: string): Promise<EdgeNodeConfig | undefined> => {
37
- let edgeNodeConfig = await edgeNodeStorage.get(fileName);
38
- if (!edgeNodeConfig) return undefined;
39
- let obj = JSON.parse(edgeNodeConfig.toString());
40
- obj.fileName = fileName;
41
- return obj;
42
- });
43
- let nextNodeNum = 1;
44
37
  let registeredNodePaths = new Set<string>();
45
38
  function getNextNodePath() {
46
- let path = "node" + (nextNodeNum++) + "_" + getOwnNodeId();
39
+ let path = "node0_" + getOwnNodeId();
47
40
  registeredNodePaths.add(path);
48
41
  return path;
49
42
  }
50
- function getNodeIdFromPath(path: string) {
51
- return path.split("_").slice(1).join("_");
52
- }
53
43
 
54
44
  let edgeShutdown = false;
55
45
 
@@ -59,8 +49,7 @@ export type EdgeNodesIndex = {
59
49
  };
60
50
 
61
51
 
62
- // IMPORTANT! The EdgeNodeConfig MUST be immutable, otherwise every node has to read every EdgeNodeConfig
63
- // every poll, which is too much reading.
52
+ // Kept small and mostly-immutable so clients can cache configs by nodeId; heartbeatTime and scheduledShutdownTime are the deliberate mutable exceptions, refreshed in place. Re-reading every config each poll is fine now that edgeNodeStorage is buffered.
64
53
  export type EdgeNodeConfig = {
65
54
  // NOTE: Only information needed for routing should be added here. The rest can be obtained by talking
66
55
  // to the node itself (after picking the edgeNode node).
@@ -71,6 +60,8 @@ export type EdgeNodeConfig = {
71
60
  host: string;
72
61
  gitHash: string;
73
62
  bootTime: number;
63
+ // Refreshed every HEARTBEAT_INTERVAL. Clients prefer recently-heartbeat nodes; the index update evicts nodes that stop.
64
+ heartbeatTime: number;
74
65
  entryPaths: string[];
75
66
  public: boolean;
76
67
  // Set once this node has been told it will shut down (scheduled switchover). Clients deprioritize these nodes when picking an edge node.
@@ -181,6 +172,7 @@ export async function registerEdgeNode(config: {
181
172
  host,
182
173
  gitHash,
183
174
  bootTime: Date.now(),
175
+ heartbeatTime: Date.now(),
184
176
  entryPaths: config.entryPaths,
185
177
  public: isPublic(),
186
178
  functionRunnerIndex: await getInitialFunctionRunnerIndex(),
@@ -232,6 +224,7 @@ const loadEntryPointsByHash = runInSerial(async function loadEntryPointsByHash(c
232
224
  host: config.host,
233
225
  gitHash,
234
226
  bootTime: Date.now(),
227
+ heartbeatTime: Date.now(),
235
228
  entryPaths,
236
229
  public: isPublic(),
237
230
  functionRunnerIndex: await getInitialFunctionRunnerIndex(),
@@ -259,7 +252,6 @@ async function publishOwnScheduledShutdown(time: number) {
259
252
  if (config.scheduledShutdownTime === time) continue;
260
253
  config.scheduledShutdownTime = time;
261
254
  await edgeNodeStorage.set(nodePath, Buffer.from(JSON.stringify(config)));
262
- getEdgeNodeConfig.clearKey(nodePath);
263
255
  }
264
256
  await updateEdgeNodesFile();
265
257
  }
@@ -273,34 +265,31 @@ const startUpdateLoop = lazy(async () => {
273
265
  startEdgeNotifier();
274
266
  SocketFunction.expose(EdgeNodeController);
275
267
  await getEdgeNodeConfigURLs();
268
+ await runInfinitePollCallAtStart(HEARTBEAT_INTERVAL, heartbeatLoop);
276
269
  await runInfinitePollCallAtStart(UPDATE_POLL_INTERVAL * (1 + Math.random() * 0.1), updateLoop);
277
270
  });
278
271
 
279
- async function updateLoop() {
272
+ async function heartbeatLoop() {
280
273
  if (edgeShutdown) return;
281
- await runEdgeNodesAliveCheck();
274
+ let nodePath = getNextNodePath();
275
+ let buffer = await edgeNodeStorage.get(nodePath);
276
+ if (!buffer) return;
277
+ let config: EdgeNodeConfig = JSON.parse(buffer.toString());
278
+ config.heartbeatTime = Date.now();
279
+ await edgeNodeStorage.set(nodePath, Buffer.from(JSON.stringify(config)));
280
+ // Push the fresh heartbeat into the index so clients see it (they read only the index, not the per-node files).
282
281
  await updateEdgeNodesFile();
283
282
  }
284
283
 
285
- let deadCounts = new Map<string, number>();
286
- async function runEdgeNodesAliveCheck() {
287
- let edgeNodeNodeIds = await edgeNodeStorage.find("node", { type: "files" });
288
- let nodeIds = new Set(await getAllNodeIds());
289
- for (let fileName of edgeNodeNodeIds) {
290
- let nodeId = getNodeIdFromPath(fileName);
291
- if (!nodeIds.has(nodeId)) {
292
- deadCounts.set(fileName, (deadCounts.get(fileName) ?? 0) + 1);
293
- }
294
- }
295
-
296
- for (let [fileName, count] of Array.from(deadCounts)) {
297
- if (count < DEAD_NODE_COUNT_THRESHOLD) continue;
298
- console.log(`Node is dead. Removing from edgeNodes.`, { fileName, count });
299
- deadCounts.delete(fileName);
300
- await edgeNodeStorage.del(fileName);
301
- }
284
+ async function updateLoop() {
285
+ if (edgeShutdown) return;
286
+ await updateEdgeNodesFile();
302
287
  }
288
+
303
289
  async function updateEdgeNodesFile() {
290
+ // Snapshot the time BEFORE reading anything below. Staleness is judged against when we STARTED, so if the reads block we can never mass-evict live nodes just because wall-clock advanced while we were stuck.
291
+ let now = Date.now();
292
+
304
293
  let prevEdgeNodeFile = await edgeNodeStorage.get(edgeNodeIndexFile);
305
294
  let prevEdgeNodeIndex: EdgeNodesIndex = {
306
295
  edgeNodes: [],
@@ -314,9 +303,6 @@ async function updateEdgeNodesFile() {
314
303
  console.error(`Failed to parse ${edgeNodeIndexFile}`, { error: e, prevEdgeNodeFile });
315
304
  }
316
305
 
317
- let edgeNodeFiles = await edgeNodeStorage.find("node", { type: "files" });
318
- let edgeNodeNodeIds = unique(edgeNodeFiles.map(getNodeIdFromPath));
319
-
320
306
  let liveHash = "";
321
307
  if (!noSyncing()) {
322
308
  liveHash = await Querysub.commitAsync(() => {
@@ -329,47 +315,57 @@ async function updateEdgeNodesFile() {
329
315
  }
330
316
  }
331
317
 
332
- let diff = !!compareArray(edgeNodeNodeIds.sort(), prevEdgeNodeIndex.edgeNodes.map(x => x.nodeId).sort());
333
- if (diff) {
334
- console.log(`Detected edgeNodes changed. Updating ${edgeNodeIndexFile}`, { edgeNodeNodeIds, liveHash });
335
- }
336
- if (liveHash !== prevEdgeNodeIndex.liveHash) {
337
- diff = true;
338
- console.log(`Detected live hash changed. Updating ${edgeNodeIndexFile}`, { liveHash, prevHash: prevEdgeNodeIndex.liveHash });
339
- }
340
- if (!diff) {
341
- // Node configs are otherwise immutable, but the scheduled shutdown time is written into them after the fact, and clients need it to avoid picking dying edge nodes.
342
- let prevShutdownTimes = new Map(prevEdgeNodeIndex.edgeNodes.map(x => [x.nodeId, x.scheduledShutdownTime]));
343
- for (let nodeFile of edgeNodeFiles) {
344
- let config = await getEdgeNodeConfig(nodeFile);
345
- if (!config) continue;
346
- if (prevShutdownTimes.get(config.nodeId) !== config.scheduledShutdownTime) {
347
- diff = true;
348
- console.log(`Detected scheduled shutdown time changed for ${config.nodeId}. Updating ${edgeNodeIndexFile}`);
349
- break;
350
- }
318
+ let edgeNodeFiles = await edgeNodeStorage.find("node", { type: "files" });
319
+ // Reads are independent, so run them in parallel (bounded): with many files a serial pass would take files × read-latency, which could be minutes.
320
+ let readEdgeNodeFile = runInParallel({ parallelCount: EDGE_NODE_READ_PARALLEL }, async (nodeFile: string) => {
321
+ // Read directly, not through a cache: heartbeatTime mutates every minute, so a cached config would report a stale heartbeat and wrongly evict a live peer.
322
+ let buffer = await edgeNodeStorage.get(nodeFile);
323
+ if (!buffer) return undefined;
324
+ let config: EdgeNodeConfig = JSON.parse(buffer.toString());
325
+ let lastHeartbeat = config.heartbeatTime || config.bootTime;
326
+ if (now - lastHeartbeat > EDGE_NODE_DELETE_THRESHOLD) {
327
+ console.log(`Edge node ${config.nodeId} hasn't heartbeat since ${formatDateTime(lastHeartbeat)} (${formatTime(now - lastHeartbeat)} ago, over the ${formatTime(EDGE_NODE_DELETE_THRESHOLD)} limit). Removing ${nodeFile} from edgeNodes.`);
328
+ await edgeNodeStorage.del(nodeFile);
329
+ return undefined;
330
+ }
331
+ return config;
332
+ });
333
+ let configs = await Promise.all(edgeNodeFiles.map(readEdgeNodeFile));
334
+
335
+ let bestByNodeId = new Map<string, EdgeNodeConfig>();
336
+ for (let config of configs) {
337
+ if (!config) continue;
338
+ // One entry per nodeId (re-registrations can leave more than one), keeping the freshest heartbeat.
339
+ let existing = bestByNodeId.get(config.nodeId);
340
+ if (!existing || (config.heartbeatTime || config.bootTime) > (existing.heartbeatTime || existing.bootTime)) {
341
+ bestByNodeId.set(config.nodeId, config);
351
342
  }
352
343
  }
353
- if (!diff) return;
354
-
355
-
356
344
 
345
+ let newEdgeNodes = sort(Array.from(bestByNodeId.values()), x => -(x.heartbeatTime || x.bootTime));
357
346
  let newEdgeNodeIndex: EdgeNodesIndex = {
358
- edgeNodes: [],
347
+ edgeNodes: newEdgeNodes,
359
348
  liveHash,
360
349
  };
361
- for (let nodeFile of edgeNodeFiles) {
362
- let edgeNodeConfig = await getEdgeNodeConfig(nodeFile);
363
- if (!edgeNodeConfig) continue;
364
- newEdgeNodeIndex.edgeNodes.push(edgeNodeConfig);
350
+
351
+ let prevNodeIds = prevEdgeNodeIndex.edgeNodes.map(x => x.nodeId).sort();
352
+ let newNodeIds = newEdgeNodes.map(x => x.nodeId).sort();
353
+ if (compareArray(newNodeIds, prevNodeIds)) {
354
+ console.log(`Detected edgeNodes membership changed. Updating ${edgeNodeIndexFile}`, { prevNodeIds, newNodeIds, liveHash });
365
355
  }
356
+ if (liveHash !== prevEdgeNodeIndex.liveHash) {
357
+ console.log(`Detected live hash changed from ${prevEdgeNodeIndex.liveHash} to ${liveHash}. Updating ${edgeNodeIndexFile}`);
358
+ }
359
+
360
+ // Heartbeats advance every minute, so the serialized index differs on almost every call. Writing that often is fine (edgeNodeStorage is buffered); we deliberately only LOG the rare membership / live-hash changes above, never the routine heartbeat refresh.
361
+ if (JSON.stringify(newEdgeNodeIndex) === JSON.stringify(prevEdgeNodeIndex)) return;
366
362
 
367
- console.log(magenta(`Updating ${edgeNodeIndexFile} with ${JSON.stringify(newEdgeNodeIndex)}`));
368
363
  await edgeNodeStorage.set(edgeNodeIndexFile, Buffer.from(JSON.stringify(newEdgeNodeIndex)));
369
364
  await delay(Math.random() * 1000);
370
365
  async function verify() {
371
366
  let buffer = await edgeNodeStorage.get(edgeNodeIndexFile);
372
- let index = JSON.parse(buffer!.toString()) as EdgeNodesIndex;
367
+ if (!buffer) return false;
368
+ let index = JSON.parse(buffer.toString()) as EdgeNodesIndex;
373
369
  let ownNodeId = getOwnNodeId();
374
370
  return index.edgeNodes.some(x => x.nodeId === ownNodeId);
375
371
  }
@@ -9,7 +9,7 @@ import { ServiceDetailPage } from "./components/ServiceDetailPage";
9
9
  import { MachineDetailPage } from "./components/MachineDetailPage";
10
10
  import { Anchor } from "../library-components/ATag";
11
11
  import { DeployPage } from "./components/DeployPage";
12
- import { StoragePage } from "./components/StoragePage";
12
+ import { StoragePage } from "./components/storage/StoragePage";
13
13
 
14
14
  export class MachinesPage extends qreact.Component {
15
15
  private renderTabs() {
@@ -1,6 +1,6 @@
1
- import { qreact } from "../../4-dom/qreact";
2
- import { Querysub } from "../../4-querysub/Querysub";
3
- import { t } from "../../2-proxy/schema2";
1
+ import { qreact } from "../../../4-dom/qreact";
2
+ import { Querysub } from "../../../4-querysub/Querysub";
3
+ import { t } from "../../../2-proxy/schema2";
4
4
  import { css } from "typesafecss";
5
5
  import { sort } from "socket-function/src/misc";
6
6
  import { formatNumber, formatDateTime, formatTime, formatDateTimeDetailed } from "socket-function/src/formatting/format";
@@ -9,11 +9,12 @@ import { resolveIntermediateSources, getIntermediateSources } from "sliftutils/s
9
9
  import { SocketFunction } from "socket-function/SocketFunction";
10
10
  import { FULL_VALID_WINDOW, FULL_ROUTE } from "sliftutils/storage/IArchives";
11
11
  import type { RemoteConfig, RemoteConfigBase, HostedConfig, BackblazeConfig } from "sliftutils/storage/IArchives";
12
- import { showModal } from "../../5-diagnostics/Modal";
13
- import { FullscreenModal } from "../../5-diagnostics/FullscreenModal";
14
- import { Button } from "../../library-components/Button";
12
+ import { showModal } from "../../../5-diagnostics/Modal";
13
+ import { FullscreenModal } from "../../../5-diagnostics/FullscreenModal";
14
+ import { Button } from "../../../library-components/Button";
15
15
  import { Tag, TAG_FONT_SIZE, TAG_WARNING_COLOR, getSourceTags } from "./Tag";
16
- import { StorageSynced, type StorageServerBuckets } from "./StoragePage";
16
+ import { StorageSynced } from "./storageController";
17
+ import { type StorageServerBuckets } from "./storageServers";
17
18
 
18
19
  module.hotreload = true;
19
20
 
@@ -574,7 +575,7 @@ export class RouteConfigView extends qreact.Component<{ servers: StorageServerBu
574
575
  <h3>Live configs</h3>
575
576
  {watches.map(([key, watch]) => <LiveWatchView key={key} watchKey={key} watch={watch} />)}
576
577
  </div>}
577
- <h3>Routing configs</h3>
578
+ <h2>Routing configs</h2>
578
579
  {groups.map((group, index) => <RouteConfigGroupView key={index} group={group} />)}
579
580
  </div>;
580
581
  }
@@ -0,0 +1,151 @@
1
+ import { SocketFunction } from "socket-function/SocketFunction";
2
+ import { qreact } from "../../../4-dom/qreact";
3
+ import { css } from "typesafecss";
4
+ import { Querysub } from "../../../4-querysub/Querysub";
5
+ import { isDefined } from "../../../misc";
6
+ import { MachineServiceController } from "../../machineSchema";
7
+ import { STORAGE_COMMAND_PREFIX } from "../../serviceCategories";
8
+ import { UsageBar, getUsageThresholds } from "../../../library-components/UsageBar";
9
+ import { Table } from "../../../5-diagnostics/Table";
10
+ import { getStorageServers, getStorageServices, type StorageServerBuckets } from "./storageServers";
11
+ import { StorageSynced } from "./storageController";
12
+ import { getBucketRows, INACTIVE_STATE } from "./bucketRows";
13
+ import { ActivateBucketButton, IndexSourcesCell, SyncingCell } from "./bucketCells";
14
+ import { DeployNotices } from "./deployNotices";
15
+ import { OperationsCell, OperationsSection, refreshOperationsData } from "./operationsView";
16
+ import { RouteConfigView } from "./RouteConfigView";
17
+ import { Tag } from "./Tag";
18
+
19
+ module.hotreload = true;
20
+
21
+ const DISK_BAR_TYPE = "DISK";
22
+
23
+ // These columns only ever hold a short single token; the default pre-wrap makes them wrap mid-value in a narrow column, which reads badly, so they opt out of wrapping and let the column widen instead.
24
+ const nowrapCell = (value: string) => <span className={css.whiteSpace("nowrap")}>{value}</span>;
25
+
26
+ async function resetWriteStats(servers: StorageServerBuckets[]): Promise<void> {
27
+ await Promise.all(servers.map(async server => {
28
+ if (server.loading) return;
29
+ let { clearedBuckets } = await StorageSynced(SocketFunction.browserNodeId()).clearWriteStats.promise(server.url);
30
+ console.log(`Cleared write stats for ${clearedBuckets} buckets on ${server.url}`);
31
+ }));
32
+ Querysub.commit(() => {
33
+ StorageSynced(SocketFunction.browserNodeId()).getServerBuckets.resetAll();
34
+ });
35
+ // Clearing write stats also clears the server's access stats, so the operations breakdown has to refetch to match.
36
+ refreshOperationsData();
37
+ }
38
+
39
+ export class StoragePage extends qreact.Component {
40
+ render() {
41
+ const controller = StorageSynced(SocketFunction.browserNodeId());
42
+ const machineController = MachineServiceController(SocketFunction.browserNodeId());
43
+ const serviceList = machineController.getServiceList();
44
+ // The configs are already synchronized to the client, so the deploy state needs no round trip at all
45
+ const configs = (serviceList || []).map(serviceId => machineController.getServiceConfig(serviceId)).filter(isDefined);
46
+ const storageServers = getStorageServers(configs);
47
+ const servers: StorageServerBuckets[] = storageServers.map(server => {
48
+ // Every other server still has something worth showing, so one that cannot be read becomes a row saying so instead of an empty page
49
+ try {
50
+ let buckets = controller.getServerBuckets(server.url);
51
+ if (!buckets) return { ...server, loading: true };
52
+ return { ...server, buckets };
53
+ } catch (e: any) {
54
+ return { ...server, error: e.stack ?? String(e) };
55
+ }
56
+ });
57
+ const header = <>
58
+ <div className={css.hbox(12).alignItems("center")}>
59
+ <h2 className={css.flexGrow(1)}>Storage</h2>
60
+ <button className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 0, 100)}
61
+ onClick={() => {
62
+ void resetWriteStats(servers);
63
+ }}>
64
+ Reset write stats
65
+ </button>
66
+ <button className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 0, 100)}
67
+ onClick={() => {
68
+ controller.getServerBuckets.resetAll();
69
+ refreshOperationsData();
70
+ }}>
71
+ Refresh
72
+ </button>
73
+ </div>
74
+ <DeployNotices services={getStorageServices(storageServers)} />
75
+ </>;
76
+ if (!serviceList) {
77
+ return <div className={css.vbox(16)}>
78
+ {header}
79
+ <div>Loading services...</div>
80
+ </div>;
81
+ }
82
+ if (!servers.length) {
83
+ return <div className={css.vbox(16)}>
84
+ {header}
85
+ <div>No services with a command starting with {JSON.stringify(STORAGE_COMMAND_PREFIX)} were found.</div>
86
+ </div>;
87
+ }
88
+ return <div className={css.vbox(16)}>
89
+ {header}
90
+ <Table
91
+ rows={getBucketRows(servers)}
92
+ columns={{
93
+ server: { title: "Server" },
94
+ bucket: { title: "Bucket" },
95
+ serverUrl: null,
96
+ state: {
97
+ title: "State",
98
+ formatter: (state, context) => {
99
+ let row = context?.row;
100
+ if (!row || !row.bucket || state !== INACTIVE_STATE) return state;
101
+ return <ActivateBucketButton serverUrl={row.serverUrl} bucketName={row.bucket} />;
102
+ }
103
+ },
104
+ files: { title: "Files", formatter: nowrapCell },
105
+ bytes: { title: "Bytes", formatter: nowrapCell },
106
+ flags: {
107
+ title: "Flags",
108
+ // Only flags that are set produce a tag, so an empty cell means every default
109
+ formatter: flags => <div className={css.hbox(4).wrap}>
110
+ {(flags || []).map(tag => <Tag key={tag.text} icon={tag.icon} text={tag.text} />)}
111
+ </div>
112
+ },
113
+ indexSources: {
114
+ title: "Index sources",
115
+ formatter: sources => <IndexSourcesCell sources={sources} />
116
+ },
117
+ readerDiskLimit: { title: "Read cache limit", formatter: nowrapCell },
118
+ operationsServerUrl: {
119
+ title: "Operations",
120
+ formatter: url => url && <OperationsCell serverUrl={url} /> || "",
121
+ },
122
+ writeGain: { title: "Fast gain", formatter: nowrapCell },
123
+ disk: {
124
+ title: "Drive",
125
+ formatter: (disk, context) => {
126
+ if (!disk) return context?.row?.diskError || "";
127
+ return <UsageBar
128
+ label={DISK_BAR_TYPE}
129
+ value={disk.usedBytes}
130
+ max={disk.totalBytes}
131
+ unit="B"
132
+ {...getUsageThresholds(DISK_BAR_TYPE)}
133
+ />;
134
+ }
135
+ },
136
+ diskError: null,
137
+ syncing: {
138
+ title: "Syncing",
139
+ formatter: syncing => <SyncingCell syncing={syncing} />
140
+ },
141
+ error: { title: "Error" },
142
+ }}
143
+ />
144
+ <OperationsSection
145
+ serverUrls={servers.map(server => server.url)}
146
+ onResetWriteStats={() => void resetWriteStats(servers)}
147
+ />
148
+ <RouteConfigView servers={servers} />
149
+ </div>;
150
+ }
151
+ }
@@ -1,4 +1,4 @@
1
- import { qreact } from "../../4-dom/qreact";
1
+ import { qreact } from "../../../4-dom/qreact";
2
2
  import { css } from "typesafecss";
3
3
  import { formatNumber, formatTime } from "socket-function/src/formatting/format";
4
4
  import type { HostedConfig, BackblazeConfig } from "sliftutils/storage/IArchives";