querysub 0.605.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 +1 -1
- package/src/-a-archives/archives2.ts +8 -3
- package/src/-a-archives/archivesJSONT.ts +12 -12
- package/src/4-deploy/edgeBootstrap.ts +8 -0
- package/src/4-deploy/edgeNodes.ts +71 -75
- package/src/deployManager/MachinesPage.tsx +1 -1
- package/src/deployManager/components/{RouteConfigView.tsx → storage/RouteConfigView.tsx} +9 -8
- package/src/deployManager/components/storage/StoragePage.tsx +151 -0
- package/src/deployManager/components/{Tag.tsx → storage/Tag.tsx} +1 -1
- package/src/deployManager/components/storage/bucketCells.tsx +170 -0
- package/src/deployManager/components/storage/bucketRows.ts +145 -0
- package/src/deployManager/components/storage/deployNotices.tsx +81 -0
- package/src/deployManager/components/storage/operationsView.tsx +217 -0
- package/src/deployManager/components/storage/sourceNames.tsx +43 -0
- package/src/deployManager/components/storage/storageController.ts +76 -0
- package/src/deployManager/components/storage/storageServers.ts +73 -0
- package/src/deployManager/machineSchema.ts +14 -11
- package/src/library-components/UsageBar.tsx +5 -2
- package/src/deployManager/components/StoragePage.tsx +0 -652
package/package.json
CHANGED
|
@@ -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:
|
|
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
|
}
|
|
@@ -6,15 +6,15 @@ import { SetConfig } from "sliftutils/storage/IArchives";
|
|
|
6
6
|
|
|
7
7
|
export type ArchiveT<T> = {
|
|
8
8
|
get(key: string): Promise<T | undefined>;
|
|
9
|
-
set(key: string, value: T
|
|
9
|
+
set(key: string, value: T): Promise<void>;
|
|
10
10
|
delete(key: string): Promise<void>;
|
|
11
|
-
keys(
|
|
12
|
-
values(
|
|
11
|
+
keys(): Promise<string[]>;
|
|
12
|
+
values(): Promise<T[]>;
|
|
13
13
|
entries(): Promise<[string, T][]>;
|
|
14
14
|
[Symbol.asyncIterator](): AsyncIterator<[string, T]>;
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
-
export function archiveJSONT<T>(archives: () => IArchives): ArchiveT<T> {
|
|
17
|
+
export function archiveJSONT<T>(archives: () => IArchives, config?: { fallbacks?: boolean }): ArchiveT<T> {
|
|
18
18
|
archives = lazy(archives);
|
|
19
19
|
|
|
20
20
|
let valuesCache = new Map<string, {
|
|
@@ -28,21 +28,21 @@ export function archiveJSONT<T>(archives: () => IArchives): ArchiveT<T> {
|
|
|
28
28
|
if (!buffer) return undefined;
|
|
29
29
|
return JSON.parse(buffer.toString()) as T;
|
|
30
30
|
}
|
|
31
|
-
async function set(key: string, value: T
|
|
31
|
+
async function set(key: string, value: T) {
|
|
32
32
|
let a = archives();
|
|
33
33
|
console.log(`In archiveJSONT ${a.getDebugName()}, setting ${key} to ${JSON.stringify(value)}`);
|
|
34
|
-
await a.set(key, Buffer.from(JSON.stringify(value)),
|
|
34
|
+
await a.set(key, Buffer.from(JSON.stringify(value)), config);
|
|
35
35
|
}
|
|
36
36
|
async function deleteFnc(key: string) {
|
|
37
37
|
let a = archives();
|
|
38
38
|
console.log(`In archiveJSONT ${a.getDebugName()}, deleting ${key}`);
|
|
39
39
|
await a.del(key);
|
|
40
40
|
}
|
|
41
|
-
async function keys(
|
|
42
|
-
return (await archives().find("",
|
|
41
|
+
async function keys() {
|
|
42
|
+
return (await archives().find("", config)).map(value => value.toString());
|
|
43
43
|
}
|
|
44
|
-
async function values(
|
|
45
|
-
let infos = await archives().findInfo("",
|
|
44
|
+
async function values() {
|
|
45
|
+
let infos = await archives().findInfo("", config);
|
|
46
46
|
|
|
47
47
|
let needsUpdate = false;
|
|
48
48
|
let currentKeys = new Set(infos.map(info => info.path));
|
|
@@ -64,7 +64,7 @@ export function archiveJSONT<T>(archives: () => IArchives): ArchiveT<T> {
|
|
|
64
64
|
let maxRetries = 10;
|
|
65
65
|
let updated = false;
|
|
66
66
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
67
|
-
infos = await archives().findInfo("",
|
|
67
|
+
infos = await archives().findInfo("", config);
|
|
68
68
|
|
|
69
69
|
let newCache = new Map<string, {
|
|
70
70
|
createTime: number;
|
|
@@ -93,7 +93,7 @@ export function archiveJSONT<T>(archives: () => IArchives): ArchiveT<T> {
|
|
|
93
93
|
|
|
94
94
|
if (!allFound) continue;
|
|
95
95
|
|
|
96
|
-
let newInfos = await archives().findInfo("",
|
|
96
|
+
let newInfos = await archives().findInfo("", config);
|
|
97
97
|
if (newInfos.length !== infos.length) continue;
|
|
98
98
|
function anyChanged() {
|
|
99
99
|
for (let i = 0; i < newInfos.length; i++) {
|
|
@@ -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 {
|
|
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 {
|
|
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
|
|
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() &&
|
|
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 = "
|
|
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
|
-
//
|
|
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
|
|
272
|
+
async function heartbeatLoop() {
|
|
280
273
|
if (edgeShutdown) return;
|
|
281
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
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 "
|
|
2
|
-
import { Querysub } from "
|
|
3
|
-
import { t } from "
|
|
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 "
|
|
13
|
-
import { FullscreenModal } from "
|
|
14
|
-
import { Button } from "
|
|
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
|
|
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
|
-
<
|
|
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 "
|
|
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";
|