querysub 0.514.0 → 0.516.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/-f-node-discovery/NodeDiscovery.ts +1 -1
- package/src/3-path-functions/PathFunctionRunner.ts +7 -3
- package/src/3-path-functions/PathFunctionRunnerMain.ts +1 -0
- package/src/4-deploy/edgeBootstrap.ts +170 -60
- package/src/4-deploy/edgeNodes.ts +24 -0
- package/src/4-querysub/FunctionRunnerTracking.ts +72 -41
- package/src/4-querysub/Querysub.ts +1 -0
- package/src/config.ts +1 -1
- package/src/deployManager/components/ServiceDetailPage.tsx +1 -1
- package/src/deployManager/components/ServicesListPage.tsx +28 -4
- package/src/deployManager/components/deployButtons.tsx +32 -1
- package/src/library-components/EdgeNodeSelector.tsx +78 -0
- package/src/library-components/NetworkSelector.tsx +27 -21
package/package.json
CHANGED
|
@@ -423,7 +423,7 @@ async function fastMemorySync() {
|
|
|
423
423
|
let checkNodes = shuffle(Array.from(aliveNodes), Date.now()).slice(0, API_AUDIT_COUNT);
|
|
424
424
|
let perPeerNodes = await Promise.all(
|
|
425
425
|
checkNodes.map(async peerNodeId => {
|
|
426
|
-
let nodes = await timeoutToUndefinedSilent(
|
|
426
|
+
let nodes = await timeoutToUndefinedSilent(2000, NodeDiscoveryController.nodes[peerNodeId].getAllNodeIds());
|
|
427
427
|
if (!nodes) {
|
|
428
428
|
deadNodes.set(peerNodeId, Date.now());
|
|
429
429
|
}
|
|
@@ -350,6 +350,12 @@ export class PathFunctionRunner {
|
|
|
350
350
|
public static DEBUG_WATCHES_THRESHOLD = 5;
|
|
351
351
|
public static MAX_WATCH_LOOPS = 1000;
|
|
352
352
|
|
|
353
|
+
public static hostControllers = lazy(() => {
|
|
354
|
+
SocketFunction.expose(FunctionPreloadController);
|
|
355
|
+
SocketFunction.expose(FunctionCaptureController);
|
|
356
|
+
SocketFunction.expose(FunctionRunnerInfoController);
|
|
357
|
+
});
|
|
358
|
+
|
|
353
359
|
constructor(private config: {
|
|
354
360
|
domainName: string;
|
|
355
361
|
shardRange: { startFraction: number, endFraction: number };
|
|
@@ -358,9 +364,7 @@ export class PathFunctionRunner {
|
|
|
358
364
|
// The networks we listen to calls on. Unset is equivalent to ["default"].
|
|
359
365
|
networks?: string[];
|
|
360
366
|
}) {
|
|
361
|
-
|
|
362
|
-
SocketFunction.expose(FunctionCaptureController);
|
|
363
|
-
SocketFunction.expose(FunctionRunnerInfoController);
|
|
367
|
+
PathFunctionRunner.hostControllers();
|
|
364
368
|
debugFunctionRunnerShards.push({
|
|
365
369
|
domainName: config.domainName,
|
|
366
370
|
shardRange: config.shardRange,
|
|
@@ -35,6 +35,7 @@ async function main() {
|
|
|
35
35
|
PathFunctionRunner.DEBUG_CALLS = true;
|
|
36
36
|
// debugCoreMode();
|
|
37
37
|
|
|
38
|
+
PathFunctionRunner.hostControllers();
|
|
38
39
|
await Querysub.hostService("PathFunctionRunnerMain");
|
|
39
40
|
|
|
40
41
|
// Use a fairly high stick time (the default is 10s), because having wait to sync data is very slow,
|
|
@@ -354,7 +354,161 @@ async function edgeNodeFunction(config: {
|
|
|
354
354
|
return true;
|
|
355
355
|
});
|
|
356
356
|
|
|
357
|
-
//
|
|
357
|
+
// Probes ALL the given nodes (even private / non-live ones), writing latencies to globalThis.EDGE_NODE_STATS (so the edge node dropdown can show them), and returns a latency-weighted random pick among `pickableHosts` (undefined if none respond).
|
|
358
|
+
async function probeAndPick(probeNodes: EdgeNodeConfig[], pickableHosts: Set<string>): Promise<EdgeNodeConfig | undefined> {
|
|
359
|
+
// Having this much lower latency than another node means we pick it 100% of the time (the weight scales linearly, from 1 at the best latency down to 0 at the best latency + this)
|
|
360
|
+
const LATENCY_WEIGHT_WINDOW = 1000;
|
|
361
|
+
// How many probes are in flight at once. The first batch fires simultaneously, so anything that responds more than LATENCY_WEIGHT_WINDOW after the first response has a latency at least that much higher than it, and so would never have been picked anyway.
|
|
362
|
+
const PROBE_BATCH_SIZE = 5;
|
|
363
|
+
|
|
364
|
+
let stats = probeNodes.map(node => ({
|
|
365
|
+
host: node.host,
|
|
366
|
+
nodeId: node.nodeId,
|
|
367
|
+
public: node.public,
|
|
368
|
+
live: node.gitHash === edgeIndex.liveHash,
|
|
369
|
+
latency: undefined as number | undefined,
|
|
370
|
+
alive: undefined as boolean | undefined,
|
|
371
|
+
finished: false,
|
|
372
|
+
}));
|
|
373
|
+
let statsObj = { nodes: stats, pickedHost: "", autoPickedHost: "" };
|
|
374
|
+
globalThis.EDGE_NODE_STATS = statsObj;
|
|
375
|
+
|
|
376
|
+
let responded: { node: EdgeNodeConfig; latency: number }[] = [];
|
|
377
|
+
let queue = probeNodes.slice();
|
|
378
|
+
let outstanding = 0;
|
|
379
|
+
let timerStarted = false;
|
|
380
|
+
let decideResolve = () => { };
|
|
381
|
+
let decidePromise = new Promise<void>(resolve => { decideResolve = resolve; });
|
|
382
|
+
|
|
383
|
+
function launchProbes() {
|
|
384
|
+
while (outstanding < PROBE_BATCH_SIZE && queue.length > 0) {
|
|
385
|
+
let node = queue.shift();
|
|
386
|
+
if (!node) break;
|
|
387
|
+
void launchProbe(node);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
async function launchProbe(node: EdgeNodeConfig) {
|
|
391
|
+
outstanding++;
|
|
392
|
+
let start = Date.now();
|
|
393
|
+
let alive = false;
|
|
394
|
+
try {
|
|
395
|
+
alive = await isNodeAlive(node);
|
|
396
|
+
} catch {
|
|
397
|
+
alive = false;
|
|
398
|
+
}
|
|
399
|
+
outstanding--;
|
|
400
|
+
let latency = Date.now() - start;
|
|
401
|
+
let stat = stats.find(x => x.host === node.host);
|
|
402
|
+
if (stat) {
|
|
403
|
+
stat.finished = true;
|
|
404
|
+
stat.alive = alive;
|
|
405
|
+
stat.latency = latency;
|
|
406
|
+
}
|
|
407
|
+
if (alive && pickableHosts.has(node.host)) {
|
|
408
|
+
responded.push({ node, latency });
|
|
409
|
+
if (!timerStarted) {
|
|
410
|
+
timerStarted = true;
|
|
411
|
+
// Once the first node responds we decide shortly after (anything slower than the window would never be picked anyway). The probing keeps going in the background though, purely for the stats.
|
|
412
|
+
setTimeout(decideResolve, LATENCY_WEIGHT_WINDOW);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (outstanding === 0 && queue.length === 0) {
|
|
416
|
+
decideResolve();
|
|
417
|
+
}
|
|
418
|
+
launchProbes();
|
|
419
|
+
}
|
|
420
|
+
launchProbes();
|
|
421
|
+
if (outstanding === 0) return undefined;
|
|
422
|
+
await decidePromise;
|
|
423
|
+
|
|
424
|
+
if (responded.length === 0) return undefined;
|
|
425
|
+
let bestLatency = Math.min(...responded.map(x => x.latency));
|
|
426
|
+
let weighted = responded.map(x => ({ node: x.node, weight: Math.max(0, 1 - (x.latency - bestLatency) / LATENCY_WEIGHT_WINDOW) }));
|
|
427
|
+
let totalWeight = weighted.reduce((sum, x) => sum + x.weight, 0);
|
|
428
|
+
let remaining = Math.random() * totalWeight;
|
|
429
|
+
let picked = responded[0].node;
|
|
430
|
+
for (let obj of weighted) {
|
|
431
|
+
remaining -= obj.weight;
|
|
432
|
+
if (remaining <= 0) {
|
|
433
|
+
picked = obj.node;
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
statsObj.autoPickedHost = picked.host;
|
|
438
|
+
return picked;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// What the automatic pick chooses from: public nodes on the live hash (falling back to the newest hash), best candidates first. Never a private node by default (they can still be picked explicitly, via the url override / the edge node dropdown).
|
|
442
|
+
let autoCandidates = edgeNodes.filter(x => x.public);
|
|
443
|
+
{
|
|
444
|
+
let liveNodes = autoCandidates.filter(x => x.gitHash === edgeIndex.liveHash);
|
|
445
|
+
if (liveNodes.length === 0 && !liveHashForced && autoCandidates.length > 0) {
|
|
446
|
+
let latestHash = autoCandidates[0].gitHash;
|
|
447
|
+
console.warn(`Could not find any live nodes (${edgeIndex.liveHash}), falling back to latest hash: ${latestHash}`);
|
|
448
|
+
liveNodes = autoCandidates.filter(x => x.gitHash === latestHash);
|
|
449
|
+
}
|
|
450
|
+
autoCandidates = liveNodes;
|
|
451
|
+
}
|
|
452
|
+
autoCandidates = shuffle(autoCandidates);
|
|
453
|
+
// Deprioritize (but don't exclude, in case there is nothing else) nodes that are scheduled to shut down, and nodes that just booted (they might still be warming up, and if they crash on startup we would follow them down). This only orders the probing (the best candidates go in the first batch), the actual pick is weighted by latency.
|
|
454
|
+
const EDGE_MIN_UP_TIME = 60_000;
|
|
455
|
+
const edgePickPenalty = (node: EdgeNodeConfig) => {
|
|
456
|
+
let penalty = 0;
|
|
457
|
+
if (node.scheduledShutdownTime) penalty += 2;
|
|
458
|
+
if (Date.now() - node.bootTime < EDGE_MIN_UP_TIME) penalty += 1;
|
|
459
|
+
return penalty;
|
|
460
|
+
};
|
|
461
|
+
autoCandidates.sort((a, b) => edgePickPenalty(a) - edgePickPenalty(b));
|
|
462
|
+
|
|
463
|
+
// Probe the pickable candidates first (the first batch sets the decision window), then everything else purely for the dropdown's stats
|
|
464
|
+
let pickableHosts = new Set(autoCandidates.map(x => x.host));
|
|
465
|
+
let probeOrder = [...autoCandidates, ...edgeNodes.filter(x => !pickableHosts.has(x.host))];
|
|
466
|
+
// Started once, even when the pick is overridden (so the dropdown still gets latencies AND what we would have picked automatically)
|
|
467
|
+
let probePromise: Promise<EdgeNodeConfig | undefined> | undefined;
|
|
468
|
+
function ensureProbeStarted() {
|
|
469
|
+
if (!probePromise) {
|
|
470
|
+
probePromise = probeAndPick(probeOrder, pickableHosts);
|
|
471
|
+
}
|
|
472
|
+
return probePromise;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// The url override wins over everything (including the IP domain default). If the host isn't in the list, it is ignored.
|
|
476
|
+
{
|
|
477
|
+
let overrideHost = new URL(document.location.href).searchParams.get("edgenode");
|
|
478
|
+
if (overrideHost) {
|
|
479
|
+
let overrideNode = edgeNodes.find(x => x.host === overrideHost);
|
|
480
|
+
if (overrideNode) {
|
|
481
|
+
// We wait longer than the regular decision window for a forcefully selected node, as we really want to obey the override. If it still doesn't respond, we fall back to the automatic pick (which the dropdown shows a warning about).
|
|
482
|
+
const FORCED_PROBE_TIMEOUT = 5000;
|
|
483
|
+
console.log(`MATCH url edgenode override: ${getNodeDebugString(overrideNode)}`);
|
|
484
|
+
void ensureProbeStarted();
|
|
485
|
+
let statsObj = globalThis.EDGE_NODE_STATS;
|
|
486
|
+
if (statsObj) {
|
|
487
|
+
statsObj.requestedHost = overrideNode.host;
|
|
488
|
+
}
|
|
489
|
+
let alive = false;
|
|
490
|
+
try {
|
|
491
|
+
alive = await Promise.race([
|
|
492
|
+
isNodeAlive(overrideNode),
|
|
493
|
+
new Promise<boolean>(resolve => setTimeout(() => resolve(false), FORCED_PROBE_TIMEOUT)),
|
|
494
|
+
]);
|
|
495
|
+
} catch {
|
|
496
|
+
alive = false;
|
|
497
|
+
}
|
|
498
|
+
if (alive) {
|
|
499
|
+
if (statsObj) {
|
|
500
|
+
statsObj.pickedHost = overrideNode.host;
|
|
501
|
+
}
|
|
502
|
+
return overrideNode;
|
|
503
|
+
}
|
|
504
|
+
console.warn(`The url edgenode override ${overrideNode.host} did not respond within ${FORCED_PROBE_TIMEOUT}ms, falling back to the automatic pick`);
|
|
505
|
+
} else {
|
|
506
|
+
console.warn(`The url edgenode override ${JSON.stringify(overrideHost)} is not in the edge node list, ignoring it`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// If they are using an IP domain, that's the default node (we still need to pick the node
|
|
358
512
|
// though, as we need to know the entryPaths to import)
|
|
359
513
|
{
|
|
360
514
|
let curHost = document.location.host;
|
|
@@ -365,6 +519,10 @@ async function edgeNodeFunction(config: {
|
|
|
365
519
|
let exactNode = edgeNodes.find(x => x.host === curHost);
|
|
366
520
|
if (exactNode) {
|
|
367
521
|
console.log(`MATCH exact host: ${getNodeDebugString(exactNode)}`);
|
|
522
|
+
void ensureProbeStarted();
|
|
523
|
+
if (globalThis.EDGE_NODE_STATS) {
|
|
524
|
+
globalThis.EDGE_NODE_STATS.pickedHost = exactNode.host;
|
|
525
|
+
}
|
|
368
526
|
return exactNode;
|
|
369
527
|
}
|
|
370
528
|
|
|
@@ -374,69 +532,21 @@ async function edgeNodeFunction(config: {
|
|
|
374
532
|
}
|
|
375
533
|
}
|
|
376
534
|
|
|
377
|
-
|
|
378
|
-
edgeNodes = edgeNodes.filter(x => x.public);
|
|
379
|
-
if (edgeNodes.length === 0) {
|
|
535
|
+
if (edgeNodes.filter(x => x.public).length === 0) {
|
|
380
536
|
throw new Error(`No public nodes found`);
|
|
381
537
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
if (liveNodes.length === 0) {
|
|
385
|
-
if (liveHashForced) {
|
|
386
|
-
throw new Error(`Could not find any live nodes (${edgeIndex.liveHash}), and the live hash is forced.`);
|
|
387
|
-
}
|
|
388
|
-
let latestHash = edgeNodes[0].gitHash;
|
|
389
|
-
console.warn(`Could not find any live nodes (${edgeIndex.liveHash}), falling back to latest hash: ${latestHash}`);
|
|
390
|
-
liveNodes = edgeNodes.filter(x => x.gitHash === latestHash);
|
|
538
|
+
if (autoCandidates.length === 0) {
|
|
539
|
+
throw new Error(`Could not find any live nodes (${edgeIndex.liveHash}), and the live hash is forced.`);
|
|
391
540
|
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
// Deprioritize (but don't exclude, in case there is nothing else) nodes that are scheduled to shut down, and nodes that just booted (they might still be warming up, and if they crash on startup we would follow them down).
|
|
400
|
-
const EDGE_MIN_UP_TIME = 60_000;
|
|
401
|
-
const edgePickPenalty = (node: EdgeNodeConfig) => {
|
|
402
|
-
let penalty = 0;
|
|
403
|
-
if (node.scheduledShutdownTime) penalty += 2;
|
|
404
|
-
if (Date.now() - node.bootTime < EDGE_MIN_UP_TIME) penalty += 1;
|
|
405
|
-
return penalty;
|
|
406
|
-
};
|
|
407
|
-
edgeNodes.sort((a, b) => edgePickPenalty(a) - edgePickPenalty(b));
|
|
408
|
-
|
|
409
|
-
// We check multiple at a time, so a node being unresponsive doesn't cause lag
|
|
410
|
-
let PARALLEL_FACTOR = 3;
|
|
411
|
-
for (let i = 0; i < edgeNodes.length; i += PARALLEL_FACTOR) {
|
|
412
|
-
let node = await new Promise<EdgeNodeConfig | undefined>(resolve => {
|
|
413
|
-
let finished = 0;
|
|
414
|
-
for (let j = 0; j < PARALLEL_FACTOR; j++) {
|
|
415
|
-
((async () => {
|
|
416
|
-
try {
|
|
417
|
-
let node = edgeNodes[i + j];
|
|
418
|
-
if (!node) return;
|
|
419
|
-
let alive = await isNodeAlive(node);
|
|
420
|
-
if (alive) {
|
|
421
|
-
resolve(node);
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
catch { }
|
|
425
|
-
finally {
|
|
426
|
-
finished++;
|
|
427
|
-
if (finished >= PARALLEL_FACTOR) {
|
|
428
|
-
resolve(undefined);
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
return undefined;
|
|
432
|
-
})()).catch(() => { });
|
|
433
|
-
}
|
|
434
|
-
});
|
|
435
|
-
if (node) {
|
|
436
|
-
return node;
|
|
437
|
-
}
|
|
541
|
+
let picked = await ensureProbeStarted();
|
|
542
|
+
if (!picked) {
|
|
543
|
+
throw new Error(`No alive nodes found`);
|
|
544
|
+
}
|
|
545
|
+
if (globalThis.EDGE_NODE_STATS) {
|
|
546
|
+
globalThis.EDGE_NODE_STATS.pickedHost = picked.host;
|
|
438
547
|
}
|
|
439
|
-
|
|
548
|
+
console.log(`PICKED edge node by latency: ${getNodeDebugString(picked)}`);
|
|
549
|
+
return picked;
|
|
440
550
|
}
|
|
441
551
|
|
|
442
552
|
function shuffle<T>(array: T[]): T[] {
|
|
@@ -79,6 +79,30 @@ export type EdgeNodeConfig = {
|
|
|
79
79
|
// The function runner index at registration time, so clients have it immediately at startup (they refresh it by polling QuerysubController.getFunctionRunnerIndex).
|
|
80
80
|
functionRunnerIndex?: FunctionRunnerIndex;
|
|
81
81
|
};
|
|
82
|
+
|
|
83
|
+
export type EdgeNodeStat = {
|
|
84
|
+
host: string;
|
|
85
|
+
nodeId: string;
|
|
86
|
+
public: boolean;
|
|
87
|
+
live: boolean;
|
|
88
|
+
// Only set once the probe finishes
|
|
89
|
+
latency?: number;
|
|
90
|
+
alive?: boolean;
|
|
91
|
+
finished: boolean;
|
|
92
|
+
};
|
|
93
|
+
export type EdgeNodeStats = {
|
|
94
|
+
nodes: EdgeNodeStat[];
|
|
95
|
+
/** The node we actually booted from */
|
|
96
|
+
pickedHost: string;
|
|
97
|
+
/** What the picking algorithm chose (or would have chosen, when overridden) on its own. Empty until its probe decides. */
|
|
98
|
+
autoPickedHost: string;
|
|
99
|
+
/** Set when a url override requested a specific host, even if we couldn't connect to it (compare with pickedHost to detect that) */
|
|
100
|
+
requestedHost?: string;
|
|
101
|
+
};
|
|
102
|
+
declare global {
|
|
103
|
+
// Written by the edge bootstrapper as it probes the edge nodes, so the edge node dropdown can show what there was to pick from (and their latencies)
|
|
104
|
+
var EDGE_NODE_STATS: EdgeNodeStats | undefined;
|
|
105
|
+
}
|
|
82
106
|
let registeredEdgeNode: { host: string; entryPaths: string[] } | boolean | undefined;
|
|
83
107
|
export async function registerEdgeNode(config: {
|
|
84
108
|
host: string;
|
|
@@ -7,20 +7,21 @@
|
|
|
7
7
|
import { SocketFunction } from "socket-function/SocketFunction";
|
|
8
8
|
import { timeInMinute, timeInSecond, sort } from "socket-function/src/misc";
|
|
9
9
|
import { lazy } from "socket-function/src/caching";
|
|
10
|
-
import { runInfinitePollCallAtStart } from "socket-function/src/batching";
|
|
10
|
+
import { delay, runInfinitePollCallAtStart } from "socket-function/src/batching";
|
|
11
11
|
import { isClient } from "../config2";
|
|
12
12
|
import { t } from "../2-proxy/schema2";
|
|
13
13
|
import { createLocalSchema } from "./schemaHelpers";
|
|
14
14
|
import { Querysub } from "./Querysub";
|
|
15
|
-
import { getAllNodeIds, getOwnNodeId } from "../-f-node-discovery/NodeDiscovery";
|
|
15
|
+
import { getAllNodeIds, getOwnNodeId, watchDeltaNodeIds } from "../-f-node-discovery/NodeDiscovery";
|
|
16
16
|
import { FunctionRunnerInfoController, FunctionStatsSummary } from "../3-path-functions/PathFunctionRunner";
|
|
17
17
|
import { URLParam } from "../library-components/URLParam";
|
|
18
|
-
import { timeoutToUndefinedSilent } from "../errors";
|
|
18
|
+
import { logErrors, timeoutToUndefined, timeoutToUndefinedSilent } from "../errors";
|
|
19
19
|
import { spreadCallsOverTime } from "../misc";
|
|
20
|
-
import { formatTime } from "socket-function/src/formatting/format";
|
|
21
|
-
import type { DebugFunctionShardInfo } from "../3-path-functions/PathFunctionRunner";
|
|
20
|
+
import { formatDateTime, formatTime } from "socket-function/src/formatting/format";
|
|
21
|
+
import type { DebugFunctionShardInfo, FunctionRunnerInfo } from "../3-path-functions/PathFunctionRunner";
|
|
22
22
|
import type { EdgeNodeConfig } from "../4-deploy/edgeNodes";
|
|
23
23
|
import { isPublic } from "../config";
|
|
24
|
+
import { blue, green, yellow } from "socket-function/src/formatting/logColors";
|
|
24
25
|
|
|
25
26
|
const POLL_INTERVAL = timeInMinute;
|
|
26
27
|
const INDEX_POLL_INTERVAL = timeInMinute * 5;
|
|
@@ -144,47 +145,24 @@ export const startFunctionRunnerTracking = lazy(async () => {
|
|
|
144
145
|
console.log(`Starting function runner tracking (polling every ${formatTime(POLL_INTERVAL)})`);
|
|
145
146
|
await runInfinitePollCallAtStart(POLL_INTERVAL, pollFunctionRunners);
|
|
146
147
|
finishedStartup = true;
|
|
148
|
+
// Pull newly discovered nodes immediately, instead of waiting up to a full poll interval to find out they are function runners
|
|
149
|
+
watchDeltaNodeIds(({ newNodeIds }) => {
|
|
150
|
+
for (let nodeId of newNodeIds) {
|
|
151
|
+
if (polledNodeIds.has(nodeId)) continue;
|
|
152
|
+
console.log(blue(`[${formatDateTime(Date.now())}] New node ${nodeId} discovered, immediately checking if it is a function runner`), { nodeId });
|
|
153
|
+
logErrors(pollFunctionRunner(nodeId));
|
|
154
|
+
}
|
|
155
|
+
});
|
|
147
156
|
});
|
|
148
157
|
|
|
149
158
|
let finishedStartup = false;
|
|
159
|
+
// Every node we have already pulled (runner or not), so the new-node watcher only pulls genuinely new nodes
|
|
160
|
+
let polledNodeIds = new Set<string>();
|
|
150
161
|
async function pollFunctionRunners() {
|
|
151
|
-
let now = Date.now();
|
|
152
162
|
let nodeIds = await getAllNodeIds();
|
|
153
|
-
await spreadCallsOverTime(nodeIds, finishedStartup ? POLL_INTERVAL : 0,
|
|
154
|
-
let start = Date.now();
|
|
155
|
-
// Only function runners expose this, so a failed call just means the node isn't a runner
|
|
156
|
-
let runnerInfo = await timeoutToUndefinedSilent(POLL_INTERVAL, FunctionRunnerInfoController.nodes[nodeId].getRunnerInfo({
|
|
157
|
-
writeNodeId: getOwnNodeId(),
|
|
158
|
-
}));
|
|
159
|
-
if (!runnerInfo) return;
|
|
160
|
-
let latency = Date.now() - start;
|
|
161
|
-
if (runnerInfo.shards.length === 0) return;
|
|
162
|
-
|
|
163
|
-
let networks = new Set<string>();
|
|
164
|
-
for (let shard of runnerInfo.shards) {
|
|
165
|
-
for (let network of shard.networks) {
|
|
166
|
-
networks.add(network);
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
let prev = nodeInfos.get(nodeId);
|
|
171
|
-
let sampleCount = Math.min(prev?.latencySampleCount || 0, LATENCY_SAMPLE_LIMIT);
|
|
172
|
-
nodeInfos.set(nodeId, {
|
|
173
|
-
nodeId,
|
|
174
|
-
entryPoint: runnerInfo.entryPoint,
|
|
175
|
-
startupTime: runnerInfo.startupTime,
|
|
176
|
-
lastSeen: now,
|
|
177
|
-
isPublic: runnerInfo.shards.some(x => x.isPublic),
|
|
178
|
-
networks: Array.from(networks),
|
|
179
|
-
shards: runnerInfo.shards,
|
|
180
|
-
averageLatency: ((prev?.averageLatency || 0) * sampleCount + latency) / (sampleCount + 1),
|
|
181
|
-
latencySampleCount: sampleCount + 1,
|
|
182
|
-
scheduledShutdownTime: runnerInfo.scheduledShutdownTime,
|
|
183
|
-
});
|
|
184
|
-
nodeTimings.set(nodeId, runnerInfo.timings);
|
|
185
|
-
watchRunnerDisconnect(nodeId);
|
|
186
|
-
});
|
|
163
|
+
await spreadCallsOverTime(nodeIds, finishedStartup ? POLL_INTERVAL : 0, pollFunctionRunner);
|
|
187
164
|
|
|
165
|
+
let now = Date.now();
|
|
188
166
|
for (let [nodeId, info] of Array.from(nodeInfos)) {
|
|
189
167
|
if (info.lastSeen < now - NODE_EXPIRY_TIME) {
|
|
190
168
|
nodeInfos.delete(nodeId);
|
|
@@ -197,6 +175,59 @@ async function pollFunctionRunners() {
|
|
|
197
175
|
}
|
|
198
176
|
}
|
|
199
177
|
|
|
178
|
+
async function pollFunctionRunner(nodeId: string, logWarnings?: "logWarnings" | number) {
|
|
179
|
+
// Wait a bit so the node has time to set itself up, also to stagger the polling a little bit more.
|
|
180
|
+
await delay(2000 + Math.random() * 3000);
|
|
181
|
+
polledNodeIds.add(nodeId);
|
|
182
|
+
let start = Date.now();
|
|
183
|
+
// Only function runners expose this, so a failed call just means the node isn't a runner
|
|
184
|
+
let runnerInfoPromise = FunctionRunnerInfoController.nodes[nodeId].getRunnerInfo({
|
|
185
|
+
writeNodeId: getOwnNodeId(),
|
|
186
|
+
}) as Promise<FunctionRunnerInfo | undefined>;
|
|
187
|
+
if (logWarnings !== "logWarnings") {
|
|
188
|
+
runnerInfoPromise = timeoutToUndefinedSilent(POLL_INTERVAL, runnerInfoPromise);
|
|
189
|
+
} else {
|
|
190
|
+
runnerInfoPromise = timeoutToUndefined(POLL_INTERVAL, runnerInfoPromise);
|
|
191
|
+
}
|
|
192
|
+
let runnerInfo = await runnerInfoPromise;
|
|
193
|
+
if (!runnerInfo || !runnerInfo.shards.length) {
|
|
194
|
+
if (nodeInfos.has(nodeId) || nodeId.includes("bfbtchfql2fgrelem62qe")) {
|
|
195
|
+
runnerInfoPromise.catch(e => console.error("bfbtchfql2fgrelem62qe", e.stack));
|
|
196
|
+
console.log(yellow(`[${formatDateTime(Date.now())}] Function runner ${nodeId} disconnected, removing it from the runner index`), { nodeId });
|
|
197
|
+
nodeInfos.delete(nodeId);
|
|
198
|
+
}
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
let latency = Date.now() - start;
|
|
202
|
+
|
|
203
|
+
let networks = new Set<string>();
|
|
204
|
+
for (let shard of runnerInfo.shards) {
|
|
205
|
+
for (let network of shard.networks) {
|
|
206
|
+
networks.add(network);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let prev = nodeInfos.get(nodeId);
|
|
211
|
+
let sampleCount = Math.min(prev?.latencySampleCount || 0, LATENCY_SAMPLE_LIMIT);
|
|
212
|
+
if (!nodeInfos.has(nodeId)) {
|
|
213
|
+
console.log(green(`[${formatDateTime(Date.now())}] Function runner ${nodeId} discovered, adding it to the runner index`), { nodeId });
|
|
214
|
+
}
|
|
215
|
+
nodeInfos.set(nodeId, {
|
|
216
|
+
nodeId,
|
|
217
|
+
entryPoint: runnerInfo.entryPoint,
|
|
218
|
+
startupTime: runnerInfo.startupTime,
|
|
219
|
+
lastSeen: Date.now(),
|
|
220
|
+
isPublic: runnerInfo.shards.some(x => x.isPublic),
|
|
221
|
+
networks: Array.from(networks),
|
|
222
|
+
shards: runnerInfo.shards,
|
|
223
|
+
averageLatency: ((prev?.averageLatency || 0) * sampleCount + latency) / (sampleCount + 1),
|
|
224
|
+
latencySampleCount: sampleCount + 1,
|
|
225
|
+
scheduledShutdownTime: runnerInfo.scheduledShutdownTime,
|
|
226
|
+
});
|
|
227
|
+
nodeTimings.set(nodeId, runnerInfo.timings);
|
|
228
|
+
watchRunnerDisconnect(nodeId);
|
|
229
|
+
}
|
|
230
|
+
|
|
200
231
|
// Remove disconnected runners immediately, otherwise we would keep recommending their networks for up to NODE_EXPIRY_TIME after every runner on them is gone. If the runner comes back, the poll re-adds it (and re-registers this watch).
|
|
201
232
|
let disconnectWatchedNodes = new Set<string>();
|
|
202
233
|
function watchRunnerDisconnect(nodeId: string) {
|
|
@@ -206,7 +237,7 @@ function watchRunnerDisconnect(nodeId: string) {
|
|
|
206
237
|
disconnectWatchedNodes.delete(nodeId);
|
|
207
238
|
if (nodeInfos.delete(nodeId)) {
|
|
208
239
|
nodeTimings.delete(nodeId);
|
|
209
|
-
console.log(`Function runner ${nodeId} disconnected, removing it from the runner index immediately`);
|
|
240
|
+
console.log(yellow(`[${formatDateTime(Date.now())}] Function runner ${nodeId} disconnected, removing it from the runner index immediately`), { nodeId });
|
|
210
241
|
}
|
|
211
242
|
}, "iKnowThatServerNodeIdsMayReconnect_andIHandleReconnections");
|
|
212
243
|
}
|
package/src/config.ts
CHANGED
|
@@ -181,7 +181,7 @@ export function getAuthorityPrefix() {
|
|
|
181
181
|
|
|
182
182
|
export function isPublic() {
|
|
183
183
|
if (!isNode()) {
|
|
184
|
-
return !location.hostname.startsWith("127-0-0-1.");
|
|
184
|
+
return !(globalThis.BOOTED_EDGE_NODE?.host || location.hostname).startsWith("127-0-0-1.");
|
|
185
185
|
}
|
|
186
186
|
return !!yargObj.public;
|
|
187
187
|
}
|
|
@@ -149,7 +149,7 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
149
149
|
let modal: { close: () => void } | undefined;
|
|
150
150
|
modal = showModal({
|
|
151
151
|
content: <div className={css.fixed.pos(0, 0).size("100vw", "100vh").hsla(0, 0, 0, 0.5).display("flex").alignItems("center").justifyContent("center")}>
|
|
152
|
-
<div className={css.vbox(12).pad2(20).hsl(0, 0,
|
|
152
|
+
<div className={css.vbox(12).pad2(20).hsl(0, 0, 12).colorhsl(0, 0, 90).bord2(0, 0, 40) + " keepModalsOpen"}>
|
|
153
153
|
<div className={css.boldStyle.fontSize(16)}>⚡ Force deploy now?</div>
|
|
154
154
|
<div>This config becomes live immediately. The old instances are shut down right away, with no overlap.</div>
|
|
155
155
|
<div className={css.hbox(10)}>
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { SocketFunction } from "socket-function/SocketFunction";
|
|
2
2
|
import { qreact } from "../../4-dom/qreact";
|
|
3
|
-
import { DEFAULT_OVERLAP_TIME, MachineServiceController, ServiceConfig } from "../machineSchema";
|
|
3
|
+
import { DEFAULT_OVERLAP_TIME, MachineServiceController, ServiceConfig, getLiveServiceParameters } from "../machineSchema";
|
|
4
4
|
import { css } from "typesafecss";
|
|
5
5
|
import { t } from "../../2-proxy/schema2";
|
|
6
6
|
import { Querysub } from "../../4-querysub/Querysub";
|
|
7
7
|
import { currentViewParam, selectedServiceIdParam } from "../urlParams";
|
|
8
|
-
import { formatNiceDateTime, formatTime, formatVeryNiceDateTime } from "socket-function/src/formatting/format";
|
|
9
|
-
import { sort, timeInMinute } from "socket-function/src/misc";
|
|
8
|
+
import { formatDateTimeDetailed, formatNiceDateTime, formatTime, formatVeryNiceDateTime } from "socket-function/src/formatting/format";
|
|
9
|
+
import { sort, timeInMinute, timeInSecond } from "socket-function/src/misc";
|
|
10
10
|
import { cache } from "socket-function/src/caching";
|
|
11
11
|
import { Anchor } from "../../library-components/ATag";
|
|
12
12
|
import { isPublic } from "../../config";
|
|
13
|
-
import { UpdateButtons, UpdateServiceButtons } from "./deployButtons";
|
|
13
|
+
import { PendingDeployInfo, UpdateButtons, UpdateServiceButtons } from "./deployButtons";
|
|
14
14
|
import { isDefined } from "../../misc";
|
|
15
15
|
import { formatDateJSX } from "../../misc/formatJSX";
|
|
16
16
|
import { Tools } from "./Tools";
|
|
@@ -26,6 +26,8 @@ export class ServicesListPage extends qreact.Component {
|
|
|
26
26
|
|
|
27
27
|
if (!serviceList) return <div>Loading services...</div>;
|
|
28
28
|
|
|
29
|
+
let now = Querysub.nowDelayed(timeInSecond);
|
|
30
|
+
|
|
29
31
|
let services = serviceList.map(serviceId => [serviceId, controller.getServiceConfig(serviceId)] as const);
|
|
30
32
|
sort(services, x => -(x[1]?.info.lastUpdatedTime || Date.now()) - (x[1]?.parameters.deploy && (Number.MAX_SAFE_INTEGER / 2) || 0));
|
|
31
33
|
|
|
@@ -140,10 +142,32 @@ export class ServicesListPage extends qreact.Component {
|
|
|
140
142
|
<div className={css.vbox(4)}>
|
|
141
143
|
<div>Updated {formatDateJSX(config.info.lastUpdatedTime)} AGO</div>
|
|
142
144
|
{config.parameters.overlapTime && <div className={css.colorhsl(210, 50, 50).fontSize(14).fontWeight("bold")}>Deploy overlap: {formatTime(config.parameters.overlapTime)}</div> || undefined}
|
|
145
|
+
{(() => {
|
|
146
|
+
let releaseTime = config.parameters.releaseTime || 0;
|
|
147
|
+
if (!releaseTime || releaseTime <= now || !config.oldParameters) return undefined;
|
|
148
|
+
let live = getLiveServiceParameters(config) as Record<string, unknown>;
|
|
149
|
+
let next = config.parameters as Record<string, unknown>;
|
|
150
|
+
let diffLines: string[] = [];
|
|
151
|
+
for (let key of new Set([...Object.keys(live), ...Object.keys(next)])) {
|
|
152
|
+
if (key === "releaseTime") continue;
|
|
153
|
+
let oldValue = JSON.stringify(live[key]);
|
|
154
|
+
let newValue = JSON.stringify(next[key]);
|
|
155
|
+
if (oldValue !== newValue) {
|
|
156
|
+
diffLines.push(`${key}: ${oldValue} -> ${newValue}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return <div
|
|
160
|
+
className={css.colorhsl(35, 80, 45).fontSize(14).fontWeight("bold")}
|
|
161
|
+
title={`Deploys at ${formatDateTimeDetailed(releaseTime)}\n\n${diffLines.join("\n")}`}
|
|
162
|
+
>
|
|
163
|
+
🕐 Deploys in {formatTime(releaseTime - now)}
|
|
164
|
+
</div>;
|
|
165
|
+
})()}
|
|
143
166
|
</div>
|
|
144
167
|
</div>
|
|
145
168
|
</Anchor>
|
|
146
169
|
<UpdateServiceButtons service={config} />
|
|
170
|
+
<PendingDeployInfo service={config} />
|
|
147
171
|
</div>
|
|
148
172
|
;
|
|
149
173
|
})}
|
|
@@ -5,7 +5,8 @@ import { Querysub } from "../../4-querysub/Querysub";
|
|
|
5
5
|
import { DEFAULT_OVERLAP_TIME, MachineServiceController, MachineInfo, ServiceConfig } from "../machineSchema";
|
|
6
6
|
import { showFullscreenModal } from "../../5-diagnostics/FullscreenModal";
|
|
7
7
|
import { css } from "../../4-dom/css";
|
|
8
|
-
import { formatTime } from "socket-function/src/formatting/format";
|
|
8
|
+
import { formatDateTimeDetailed, formatTime } from "socket-function/src/formatting/format";
|
|
9
|
+
import { timeInSecond } from "socket-function/src/misc";
|
|
9
10
|
import { formatDateJSX } from "../../misc/formatJSX";
|
|
10
11
|
import { MachineController } from "../machineController";
|
|
11
12
|
import { deepCloneJSON } from "socket-function/src/misc";
|
|
@@ -192,6 +193,36 @@ export class UpdateServiceButtons extends qreact.Component<{
|
|
|
192
193
|
}
|
|
193
194
|
}
|
|
194
195
|
|
|
196
|
+
/** Shows a service's scheduled (not yet live) deploy, in the same style as the update buttons (but blue, and not clickable): when it goes live, and the new vs currently live commit. */
|
|
197
|
+
export class PendingDeployInfo extends qreact.Component<{
|
|
198
|
+
service: ServiceConfig;
|
|
199
|
+
}> {
|
|
200
|
+
render() {
|
|
201
|
+
let service = this.props.service;
|
|
202
|
+
let releaseTime = service.parameters.releaseTime || 0;
|
|
203
|
+
let now = Querysub.nowDelayed(timeInSecond);
|
|
204
|
+
const oldParameters = service.oldParameters;
|
|
205
|
+
if (!releaseTime || releaseTime <= now || !oldParameters) return undefined;
|
|
206
|
+
|
|
207
|
+
return <div
|
|
208
|
+
className={css.pad2(12, 8).bord2(210, 60, 40).fontWeight("bold").vbox(2).alignItems("center").alignSelf("stretch").hsl(210, 70, 90)}
|
|
209
|
+
title={formatDateTimeDetailed(releaseTime)}
|
|
210
|
+
>
|
|
211
|
+
<div>
|
|
212
|
+
{bigEmoji("🕐", -4)} <span>Deploys in {formatTime(releaseTime - now)}</span>
|
|
213
|
+
</div>
|
|
214
|
+
<div className={css.hbox(5)}>
|
|
215
|
+
<b>New</b>
|
|
216
|
+
<RenderGitRefInfo gitRef={service.parameters.gitRef} />
|
|
217
|
+
</div>
|
|
218
|
+
<div className={css.hbox(5)}>
|
|
219
|
+
<b>Current</b>
|
|
220
|
+
<RenderGitRefInfo gitRef={oldParameters.gitRef} />
|
|
221
|
+
</div>
|
|
222
|
+
</div>;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
195
226
|
export class DeployMachineButtons extends qreact.Component<{
|
|
196
227
|
machines: MachineInfo[];
|
|
197
228
|
}> {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { css } from "typesafecss";
|
|
2
|
+
import { qreact } from "../4-dom/qreact";
|
|
3
|
+
import { Querysub } from "../4-querysub/Querysub";
|
|
4
|
+
import { timeInSecond } from "socket-function/src/misc";
|
|
5
|
+
import { formatTime } from "socket-function/src/formatting/format";
|
|
6
|
+
import { isCurrentUserSuperUser } from "../user-implementation/userData";
|
|
7
|
+
import type { EdgeNodeConfig, EdgeNodeStat } from "../4-deploy/edgeNodes";
|
|
8
|
+
|
|
9
|
+
const EDGE_NODE_URL_PARAM = "edgenode";
|
|
10
|
+
|
|
11
|
+
/** A dropdown showing all the edge nodes the bootstrapper had to pick from (with their probed latencies), which one we booted from, and allowing forcing a specific edge node. Forcing sets a url parameter and refreshes the page, as the edge node is used by the bootstrapper, so it can only apply on a fresh page load. Super users only. */
|
|
12
|
+
export class EdgeNodeSelector extends qreact.Component<{}> {
|
|
13
|
+
render() {
|
|
14
|
+
if (!isCurrentUserSuperUser()) return undefined;
|
|
15
|
+
// The stats are a plain global the bootstrapper updates as its probes finish, so we re-read them every second
|
|
16
|
+
Querysub.nowDelayed(timeInSecond);
|
|
17
|
+
let stats = globalThis.EDGE_NODE_STATS;
|
|
18
|
+
const booted = (globalThis as any).BOOTED_EDGE_NODE as EdgeNodeConfig | undefined;
|
|
19
|
+
if (!booted) return undefined;
|
|
20
|
+
let forced = new URL(document.location.href).searchParams.get(EDGE_NODE_URL_PARAM) || "";
|
|
21
|
+
|
|
22
|
+
const setEdgeNode = (host: string) => {
|
|
23
|
+
let url = new URL(document.location.href);
|
|
24
|
+
if (host) {
|
|
25
|
+
url.searchParams.set(EDGE_NODE_URL_PARAM, host);
|
|
26
|
+
} else {
|
|
27
|
+
url.searchParams.delete(EDGE_NODE_URL_PARAM);
|
|
28
|
+
}
|
|
29
|
+
document.location.href = url.toString();
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
let nodes = stats?.nodes || [];
|
|
33
|
+
if (!nodes.some(x => x.host === booted.host)) {
|
|
34
|
+
nodes = [...nodes, { host: booted.host, nodeId: booted.nodeId, public: booted.public, live: true, finished: false }];
|
|
35
|
+
}
|
|
36
|
+
let autoStat = nodes.find(x => x.host === stats?.autoPickedHost);
|
|
37
|
+
|
|
38
|
+
return <div className={css.hbox(6)}>
|
|
39
|
+
<span className={css.opacity(0.7)}>Edge</span>
|
|
40
|
+
<select
|
|
41
|
+
value={booted.host}
|
|
42
|
+
className={css.pad2(4, 2).hsl(0, 0, 16).colorhsl(0, 0, 90).bord2(0, 0, 30)}
|
|
43
|
+
onChange={e => {
|
|
44
|
+
setEdgeNode(e.currentTarget.value);
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
{nodes.map(node => <option value={node.host}>{formatStatLabel(node)}</option>)}
|
|
48
|
+
</select>
|
|
49
|
+
{stats?.requestedHost && stats.requestedHost !== booted.host && <span className={css.pad2(6, 2).bord2(0, 85, 55).colorhsl(0, 85, 55).boldStyle}>
|
|
50
|
+
⚠️ couldn't connect to {stats.requestedHost}, using {booted.host}
|
|
51
|
+
</span>}
|
|
52
|
+
{forced && <span
|
|
53
|
+
className={css.button.pad2(6, 2).hsl(35, 60, 22).colorhsl(0, 0, 90).bord2(35, 60, 40)}
|
|
54
|
+
title="The edge node is forced. Click to restore the automatically picked edge node (refreshes the page)."
|
|
55
|
+
onMouseDown={() => setEdgeNode("")}
|
|
56
|
+
>
|
|
57
|
+
restore {autoStat && formatStatLabel(autoStat) || stats?.autoPickedHost || "auto"}
|
|
58
|
+
</span>}
|
|
59
|
+
</div>;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function formatStatLabel(stat: EdgeNodeStat): string {
|
|
64
|
+
let label = stat.host;
|
|
65
|
+
if (stat.finished) {
|
|
66
|
+
label += ` (${stat.alive && `${formatTime(stat.latency || 0)} latency` || "dead"}`;
|
|
67
|
+
} else {
|
|
68
|
+
label += ` (checking...`;
|
|
69
|
+
}
|
|
70
|
+
if (!stat.public) {
|
|
71
|
+
label += `, private`;
|
|
72
|
+
}
|
|
73
|
+
if (!stat.live) {
|
|
74
|
+
label += `, old version`;
|
|
75
|
+
}
|
|
76
|
+
label += `)`;
|
|
77
|
+
return label;
|
|
78
|
+
}
|
|
@@ -2,11 +2,12 @@ import { css } from "typesafecss";
|
|
|
2
2
|
import { qreact } from "../4-dom/qreact";
|
|
3
3
|
import { formatTime } from "socket-function/src/formatting/format";
|
|
4
4
|
import { isCurrentUserSuperUser } from "../user-implementation/userData";
|
|
5
|
-
import { forcedNetworkURL, getAutoSelectedNetwork, getNetworkSelectionInfos, getSelectedNetwork } from "../4-querysub/FunctionRunnerTracking";
|
|
5
|
+
import { forcedNetworkURL, getAutoSelectedNetwork, getNetworkSelectionInfos, getSelectedNetwork, NetworkSelectionInfo } from "../4-querysub/FunctionRunnerTracking";
|
|
6
6
|
|
|
7
|
-
/** A dropdown showing all the function runner networks, which one our function calls are being put on, and allowing forcing a specific network (and unforcing it, going back to the automatic selection). */
|
|
7
|
+
/** A dropdown showing all the function runner networks, which one our function calls are being put on, and allowing forcing a specific network (and unforcing it, going back to the automatic selection). Super users only. */
|
|
8
8
|
export class NetworkSelector extends qreact.Component<{}> {
|
|
9
9
|
render() {
|
|
10
|
+
if (!isCurrentUserSuperUser()) return undefined;
|
|
10
11
|
let infos = getNetworkSelectionInfos();
|
|
11
12
|
let forced = forcedNetworkURL.value;
|
|
12
13
|
let selected = getSelectedNetwork({ noThrow: true });
|
|
@@ -37,35 +38,22 @@ export class NetworkSelector extends qreact.Component<{}> {
|
|
|
37
38
|
>
|
|
38
39
|
{networks.map(network => {
|
|
39
40
|
let info = infos.find(x => x.network === network);
|
|
40
|
-
let label = network;
|
|
41
|
-
if (info) {
|
|
42
|
-
label += ` (${info.nodeCount} runner${info.nodeCount !== 1 && "s" || ""}, ${formatTime(info.averageLatency)} latency`;
|
|
43
|
-
if (info.averageCallTime) {
|
|
44
|
-
label += `, ${formatTime(info.averageCallTime)} avg call`;
|
|
45
|
-
}
|
|
46
|
-
if (!info.isPublic) {
|
|
47
|
-
label += `, non-public`;
|
|
48
|
-
}
|
|
49
|
-
if (info.dying) {
|
|
50
|
-
label += `, shutting down`;
|
|
51
|
-
}
|
|
52
|
-
label += `)`;
|
|
53
|
-
}
|
|
54
41
|
let disabled = !!info && !info.isPublic && !isSuperUser;
|
|
55
|
-
return <option value={network} disabled={disabled}>{
|
|
42
|
+
return <option value={network} disabled={disabled}>{networkLabel(network, info)}</option>;
|
|
56
43
|
})}
|
|
57
44
|
</select>
|
|
58
45
|
{forced && <span
|
|
59
46
|
className={css.button.pad2(6, 2).hsl(35, 60, 22).colorhsl(0, 0, 90).bord2(35, 60, 40)}
|
|
60
|
-
title="The network is forced. Click to
|
|
47
|
+
title="The network is forced. Click to restore the automatically picked network."
|
|
61
48
|
onMouseDown={() => forcedNetworkURL.value = ""}
|
|
62
49
|
>
|
|
63
|
-
|
|
50
|
+
restore {auto && networkLabel(auto, infos.find(x => x.network === auto)) || "auto"}
|
|
64
51
|
</span>}
|
|
65
52
|
{(() => {
|
|
66
53
|
let selectedInfo = infos.find(x => x.network === selected);
|
|
67
|
-
|
|
68
|
-
|
|
54
|
+
// The selection infos only contain networks that have at least one runner, so a missing entry means the selected network doesn't exist at all
|
|
55
|
+
if (!selectedInfo) {
|
|
56
|
+
return <span className={css.pad2(6, 2).bord2(0, 85, 55).colorhsl(0, 85, 55).boldStyle}>⛔ the network {JSON.stringify(selected)} does not exist (no function runner is on it)</span>;
|
|
69
57
|
}
|
|
70
58
|
if (selectedInfo.dying) {
|
|
71
59
|
return <span className={css.colorhsl(35, 85, 55)}>⚠️ this network is shutting down</span>;
|
|
@@ -75,3 +63,21 @@ export class NetworkSelector extends qreact.Component<{}> {
|
|
|
75
63
|
</div>;
|
|
76
64
|
}
|
|
77
65
|
}
|
|
66
|
+
|
|
67
|
+
function networkLabel(network: string, info: NetworkSelectionInfo | undefined): string {
|
|
68
|
+
let label = network;
|
|
69
|
+
if (info) {
|
|
70
|
+
label += ` (${info.nodeCount} runner${info.nodeCount !== 1 && "s" || ""}, ${formatTime(info.averageLatency)} latency`;
|
|
71
|
+
if (info.averageCallTime) {
|
|
72
|
+
label += `, ${formatTime(info.averageCallTime)} avg call`;
|
|
73
|
+
}
|
|
74
|
+
if (!info.isPublic) {
|
|
75
|
+
label += `, non-public`;
|
|
76
|
+
}
|
|
77
|
+
if (info.dying) {
|
|
78
|
+
label += `, shutting down`;
|
|
79
|
+
}
|
|
80
|
+
label += `)`;
|
|
81
|
+
}
|
|
82
|
+
return label;
|
|
83
|
+
}
|