querysub 0.616.0 → 0.617.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.
@@ -0,0 +1,524 @@
1
+ module.allowclient = true;
2
+
3
+ import { qreact } from "../../4-dom/qreact";
4
+ import { css } from "typesafecss";
5
+ import { getBrowserUrlNode } from "../../-f-node-discovery/NodeDiscovery";
6
+ import { TrafficStats } from "../../-f-node-discovery/TrafficTracking";
7
+ import { getDomain } from "../../config";
8
+ import { decodeNodeId } from "sliftutils/misc/https/certs";
9
+ import { sort, timeInMinute } from "socket-function/src/misc";
10
+ import { getFunctionRunnerIndex, FunctionRunnerNodeInfo } from "../../4-querysub/FunctionRunnerTracking";
11
+ import { formatTime, formatNumber } from "socket-function/src/formatting/format";
12
+ import { LatencyGraph, LatencyGraphNode, LatencyGraphLink, LatencyGraphLabelLine } from "../../library-components/LatencyGraph";
13
+ import { URLParam } from "../../library-components/URLParam";
14
+ import { mainResets } from "../../library-components/urlResetGroups";
15
+ import { FUNCTION_RUNNER_COLOR, PATHVALUE_COLOR } from "../../misc/nodeCategoryColors";
16
+ import { ID_CHARS, NodeAuthorityInfo, RoutingTableSynced, machineIdOf, threadLabel } from "./RoutingTablePage";
17
+
18
+ const LATENCY_GRAPH_HEIGHT_PX = 720;
19
+ const MACHINE_LATENCY_WIDTH_PX = 235;
20
+
21
+ // Clicking a machine node in the graph (or a row) sorts that machine's nodes to the top of the table.
22
+ const selectedMachineParam = new URLParam("rtSelectedMachine", "", { reset: [mainResets] });
23
+ const trafficWindowParam = new URLParam<number>("rtTrafficWindow", timeInMinute * 5, { reset: [mainResets] });
24
+
25
+ // The per-node table columns, in order. `color` tints the function/path-value/querysub columns to match the graph;
26
+ // `perMachine` columns (the IP and machine id) only print on the first row of each machine group.
27
+ const NODE_TABLE_COLUMNS: { key: keyof NodeRow; label: string; width: number; color?: string; perMachine?: boolean; }[] = [
28
+ { key: "ip", label: "IP", width: 130, perMachine: true },
29
+ { key: "networks", label: "Networks", width: 180, color: FUNCTION_RUNNER_COLOR, perMachine: true },
30
+ { key: "machineShort", label: "Machine", width: 96, perMachine: true },
31
+ { key: "threadPort", label: "Thread:Port", width: 130 },
32
+ { key: "name", label: "Name", width: 160 },
33
+ { key: "data", label: "Data", width: 210 },
34
+ { key: "fn", label: "Function", width: 380, color: FUNCTION_RUNNER_COLOR },
35
+ { key: "pv", label: "Path Values", width: 250, color: PATHVALUE_COLOR },
36
+ ];
37
+
38
+ type NodeRow = {
39
+ nodeId: string;
40
+ machineId: string;
41
+ ip: string;
42
+ networks: string;
43
+ machineShort: string;
44
+ threadPort: string;
45
+ name: string;
46
+ data: string;
47
+ fn: string;
48
+ pv: string;
49
+ };
50
+
51
+ function nodeTotalTraffic(traffic: TrafficStats): { sent: number; received: number; outside: number; } {
52
+ let outsideSent = traffic.outside?.sent || 0;
53
+ let outsideReceived = traffic.outside?.received || 0;
54
+ let sent = outsideSent;
55
+ let received = outsideReceived;
56
+ for (let d of Object.values(traffic.perNode || {})) {
57
+ sent += d?.sent || 0;
58
+ received += d?.received || 0;
59
+ }
60
+ return { sent, received, outside: outsideSent + outsideReceived };
61
+ }
62
+
63
+ // min · median · max of one source node's latencies to a machine's threads (plus a tooltip naming the farthest
64
+ // thread), and the node's total path values sent/received to that machine (summed — the per-thread split isn't interesting).
65
+ function machineLatencyCell(config: {
66
+ sourceLatencies: { [nodeId: string]: number } | undefined;
67
+ threads: string[];
68
+ traffic: TrafficStats | undefined;
69
+ }): { text: string; tooltip: string } {
70
+ let { sourceLatencies, threads, traffic } = config;
71
+ if (!sourceLatencies) return { text: "", tooltip: "" };
72
+ let entries: { thread: string; ms: number }[] = [];
73
+ for (let thread of threads) {
74
+ let ms = sourceLatencies[thread];
75
+ if (Number.isFinite(ms)) entries.push({ thread, ms });
76
+ }
77
+ if (!entries.length) return { text: "", tooltip: "" };
78
+ sort(entries, entry => entry.ms);
79
+ let farthest = entries[entries.length - 1];
80
+ let text = `${formatTime(entries[0].ms)} · ${formatTime(entries[Math.floor(entries.length / 2)].ms)} · ${formatTime(farthest.ms)}`;
81
+ let pvSent = 0;
82
+ let pvReceived = 0;
83
+ for (let thread of threads) {
84
+ let data = traffic?.perNode?.[thread];
85
+ if (!data) continue;
86
+ pvSent += data.pathValuesSent || 0;
87
+ pvReceived += data.pathValuesReceived || 0;
88
+ }
89
+ if (pvSent || pvReceived) {
90
+ text += ` ↑${formatNumber(pvSent)}/s ↓${formatNumber(pvReceived)}/s values`;
91
+ }
92
+ return { text, tooltip: `${text}\nfarthest: ${threadLabel(farthest.thread)} (${formatTime(farthest.ms)})` };
93
+ }
94
+
95
+ // Summed traffic metrics across a set of nodes (a machine's threads).
96
+ function sumTraffic(threads: string[], trafficMaps: Map<string, TrafficStats>) {
97
+ let dataSent = 0;
98
+ let dataReceived = 0;
99
+ let valuesSent = 0;
100
+ let valuesReceived = 0;
101
+ let calls = 0;
102
+ let addCalls = 0;
103
+ for (let id of threads) {
104
+ let traffic = trafficMaps.get(id);
105
+ if (!traffic) continue;
106
+ let totals = nodeTotalTraffic(traffic);
107
+ dataSent += totals.sent;
108
+ dataReceived += totals.received;
109
+ valuesSent += traffic.pathValuesSent || 0;
110
+ valuesReceived += traffic.pathValuesReceived || 0;
111
+ calls += traffic.functionsExecuted || 0;
112
+ addCalls += traffic.querysubCalls || 0;
113
+ }
114
+ return { dataSent, dataReceived, valuesSent, valuesReceived, calls, addCalls };
115
+ }
116
+
117
+ // A machine's graph label: the IP first (most important), then its function-runner networks, then the same per-piece
118
+ // info as the table, summed across all its threads and colored the same.
119
+ function machineLabelLines(config: {
120
+ machineId: string;
121
+ threads: string[];
122
+ trafficMaps: Map<string, TrafficStats>;
123
+ ip: string | undefined;
124
+ networks: string[];
125
+ }): LatencyGraphLabelLine[] {
126
+ let { machineId, threads, ip, networks } = config;
127
+ let totals = sumTraffic(threads, config.trafficMaps);
128
+ let lines: LatencyGraphLabelLine[] = [];
129
+ if (ip) {
130
+ lines.push({ text: ip });
131
+ }
132
+ if (networks.length) {
133
+ lines.push({ text: networks.join(" | "), color: FUNCTION_RUNNER_COLOR });
134
+ }
135
+ lines.push({ text: `${machineId.slice(0, ID_CHARS)} ${threads.length} threads` });
136
+ if (totals.dataSent + totals.dataReceived > 0) {
137
+ lines.push({ text: `↑${formatNumber(totals.dataSent)}B/s ↓${formatNumber(totals.dataReceived)}B/s` });
138
+ }
139
+ if (totals.valuesSent || totals.valuesReceived) {
140
+ lines.push({ text: `↑${formatNumber(totals.valuesSent)}/s ↓${formatNumber(totals.valuesReceived)}/s values`, color: PATHVALUE_COLOR });
141
+ }
142
+ if (totals.calls) {
143
+ lines.push({ text: `${formatNumber(totals.calls)}/s calls`, color: FUNCTION_RUNNER_COLOR });
144
+ }
145
+ if (totals.addCalls) {
146
+ lines.push({ text: `${formatNumber(totals.addCalls)}/s querysub addCalls`, color: FUNCTION_RUNNER_COLOR });
147
+ }
148
+ return lines;
149
+ }
150
+
151
+ // One table row per thread node: each piece of info that used to live on the graph label becomes its own column.
152
+ function buildNodeRow(config: {
153
+ nodeId: string;
154
+ runner: FunctionRunnerNodeInfo | undefined;
155
+ info: NodeAuthorityInfo | undefined;
156
+ traffic: TrafficStats | undefined;
157
+ machineIp: Map<string, string>;
158
+ machineNetworks: Map<string, string[]>;
159
+ }): NodeRow {
160
+ let { nodeId, runner, info, traffic, machineIp, machineNetworks } = config;
161
+ let parts = decodeNodeId(nodeId, getDomain(), "allowMissingThreadId");
162
+ let thread = (parts?.threadId || "?").slice(0, ID_CHARS);
163
+ let machineId = parts?.machineId || nodeId;
164
+ let entryPoint = info?.entryPoint || runner?.entryPoint;
165
+ let name = entryPoint && entryPoint.split(/[\\/]/).filter(Boolean).at(-1) || "";
166
+
167
+ let data = "";
168
+ if (traffic) {
169
+ let totals = nodeTotalTraffic(traffic);
170
+ let sum = totals.sent + totals.received;
171
+ if (sum > 0) {
172
+ let outsidePct = Math.round((totals.outside / sum) * 100);
173
+ data = `↑${formatNumber(totals.sent)}B/s ↓${formatNumber(totals.received)}B/s ${outsidePct}%⊘`;
174
+ }
175
+ }
176
+
177
+ let fnParts: string[] = [];
178
+ if (runner) {
179
+ if (runner.networks.length) fnParts.push(runner.networks.join(" "));
180
+ for (let shard of runner.shards) {
181
+ fnParts.push(`${shard.shardRange.startFraction.toFixed(3)}-${shard.shardRange.endFraction.toFixed(3)}`);
182
+ }
183
+ fnParts.push(`${formatNumber(traffic?.functionsExecuted ?? 0)}/s calls`);
184
+ if (!runner.isPublic) fnParts.push("PRIVATE");
185
+ }
186
+ // A slightly different source than function calls, but still function calls, so it lives in the function column
187
+ if (traffic?.querysubCalls) {
188
+ fnParts.push(`${formatNumber(traffic.querysubCalls)}/s querysub addCalls`);
189
+ }
190
+
191
+ let pvParts: string[] = [];
192
+ let spec = info?.spec;
193
+ if (spec && spec.routeStart >= 0 && spec.routeEnd >= 0) {
194
+ pvParts.push(`${spec.routeStart.toFixed(3)}-${spec.routeEnd.toFixed(3)}`);
195
+ }
196
+ if (traffic && (traffic.pathValuesSent || traffic.pathValuesReceived)) {
197
+ pvParts.push(`↑${formatNumber(traffic.pathValuesSent)}/s ↓${formatNumber(traffic.pathValuesReceived)}/s values`);
198
+ }
199
+
200
+ return {
201
+ nodeId,
202
+ machineId,
203
+ ip: machineIp.get(machineId) || "",
204
+ networks: (machineNetworks.get(machineId) || []).join(" | "),
205
+ machineShort: machineId.slice(0, ID_CHARS),
206
+ threadPort: `${thread}:${parts?.port ?? "?"}`,
207
+ name,
208
+ data,
209
+ fn: fnParts.join(" "),
210
+ pv: pvParts.join(" "),
211
+ };
212
+ }
213
+
214
+ class NodeInfoTable extends qreact.Component<{
215
+ rows: NodeRow[];
216
+ selectedMachine: string;
217
+ machineColumns: { id: string; label: string }[];
218
+ nodeMachineLatency: Map<string, { [machineId: string]: { text: string; tooltip: string } }>;
219
+ }> {
220
+ render() {
221
+ let rows = this.props.rows;
222
+ let selected = this.props.selectedMachine;
223
+ let machineColumns = this.props.machineColumns;
224
+ let nodeMachineLatency = this.props.nodeMachineLatency;
225
+ return <div className={css.vbox(0).fillWidth.overflowAuto.bord2(0, 0, 85)}>
226
+ <div className={css.hbox(0).hsl(0, 0, 96).colorhsl(0, 0, 20).boldStyle}>
227
+ {NODE_TABLE_COLUMNS.map(col =>
228
+ <div className={css.width(col.width).flexShrink0.pad2(6).ellipsis}>{col.label}</div>
229
+ )}
230
+ {machineColumns.map(col =>
231
+ <div className={css.width(MACHINE_LATENCY_WIDTH_PX).flexShrink0.pad2(6).ellipsis} title={`latency and path values to ${col.label}`}>→ {col.label}</div>
232
+ )}
233
+ </div>
234
+ {rows.map((row, i) => {
235
+ let firstOfMachine = i === 0 || rows[i - 1].machineId !== row.machineId;
236
+ let isSelected = row.machineId === selected;
237
+ let latencies = nodeMachineLatency.get(row.nodeId) || {};
238
+ return <div
239
+ className={css.hbox(0).button.fillWidth.hsl(0, 0, isSelected ? 92 : 99)
240
+ .borderTop(firstOfMachine ? "1px solid hsl(0, 0%, 80%)" : "1px solid hsl(0, 0%, 93%)")}
241
+ onClick={() => selectedMachineParam.value = selected === row.machineId ? "" : row.machineId}
242
+ >
243
+ {NODE_TABLE_COLUMNS.map(col => {
244
+ // Per-machine columns (IP, machine id) only print on the first row of each group, so it reads clearly.
245
+ let text = col.perMachine && !firstOfMachine ? "" : row[col.key];
246
+ return <div
247
+ className={css.width(col.width).flexShrink0.pad2(6).ellipsis.color(col.color || "hsl(0, 0%, 25%)")}
248
+ title={text}
249
+ >{text}</div>;
250
+ })}
251
+ {machineColumns.map(col => {
252
+ let cell = latencies[col.id];
253
+ return <div
254
+ className={css.width(MACHINE_LATENCY_WIDTH_PX).flexShrink0.pad2(6).ellipsis.colorhsl(0, 0, 25)}
255
+ title={cell?.tooltip || ""}
256
+ >{cell?.text || ""}</div>;
257
+ })}
258
+ </div>;
259
+ })}
260
+ </div>;
261
+ }
262
+ }
263
+
264
+ // Nodes that never reported their latencies — all we have is their nodeId (and possibly a DNS-resolved machine IP), so this is a much narrower table than NodeInfoTable, still grouped by machine.
265
+ class UnresponsiveNodesTable extends qreact.Component<{ nodeIds: string[]; machineIp: Map<string, string> }> {
266
+ render() {
267
+ let rows = this.props.nodeIds.map(nodeId => ({ nodeId, machineId: machineIdOf(nodeId) }));
268
+ let sep = String.fromCharCode(1);
269
+ sort(rows, row => `${row.machineId}${sep}${threadLabel(row.nodeId)}`);
270
+ return <div className={css.vbox(0).fillWidth.overflowAuto.bord2(0, 0, 85)}>
271
+ <div className={css.hbox(0).hsl(0, 0, 96).colorhsl(0, 0, 20).boldStyle}>
272
+ <div className={css.width(130).flexShrink0.pad2(6).ellipsis}>IP</div>
273
+ <div className={css.width(96).flexShrink0.pad2(6).ellipsis}>Machine</div>
274
+ <div className={css.width(130).flexShrink0.pad2(6).ellipsis}>Thread:Port</div>
275
+ <div className={css.width(520).flexShrink0.pad2(6).ellipsis}>Node Id</div>
276
+ </div>
277
+ {rows.map((row, i) => {
278
+ let firstOfMachine = i === 0 || rows[i - 1].machineId !== row.machineId;
279
+ return <div
280
+ className={css.hbox(0).fillWidth.hsl(0, 0, 99)
281
+ .borderTop(firstOfMachine ? "1px solid hsl(0, 0%, 80%)" : "1px solid hsl(0, 0%, 93%)")}
282
+ >
283
+ <div className={css.width(130).flexShrink0.pad2(6).ellipsis}>{firstOfMachine && this.props.machineIp.get(row.machineId) || ""}</div>
284
+ <div className={css.width(96).flexShrink0.pad2(6).ellipsis}>{firstOfMachine && row.machineId.slice(0, ID_CHARS) || ""}</div>
285
+ <div className={css.width(130).flexShrink0.pad2(6).ellipsis}>{threadLabel(row.nodeId)}</div>
286
+ <div className={css.width(520).flexShrink0.pad2(6).ellipsis.colorhsl(0, 0, 45)} title={row.nodeId}>{row.nodeId}</div>
287
+ </div>;
288
+ })}
289
+ </div>;
290
+ }
291
+ }
292
+
293
+ export class LatencyGraphSection extends qreact.Component<{ nodeIds: string[]; infos: NodeAuthorityInfo[] }> {
294
+ render() {
295
+ let nodeIds = this.props.nodeIds;
296
+ let synced = RoutingTableSynced(getBrowserUrlNode());
297
+
298
+ let index = getFunctionRunnerIndex();
299
+ let runnerByNode = new Map<string, FunctionRunnerNodeInfo>();
300
+ for (let node of index?.nodes ?? []) {
301
+ runnerByNode.set(node.nodeId, node);
302
+ }
303
+ let infoByNode = new Map(this.props.infos.map(info => [info.nodeId, info]));
304
+
305
+ // One synced call per node for each data source; each resolves independently so the graph fills in progressively.
306
+ let trafficWindow = trafficWindowParam.value;
307
+ let latencyMaps = new Map<string, { [nodeId: string]: number }>();
308
+ let trafficMaps = new Map<string, TrafficStats>();
309
+ for (let nodeId of nodeIds) {
310
+ let latencies = synced.getNodeLatencies(nodeId);
311
+ if (latencies) latencyMaps.set(nodeId, latencies);
312
+ let traffic = synced.getNodeTrafficRates(nodeId, trafficWindow);
313
+ if (traffic) trafficMaps.set(nodeId, traffic);
314
+ }
315
+ // Only graph nodes that reported their latencies — ones that never respond probably don't exist.
316
+ let respondedSet = new Set(latencyMaps.keys());
317
+
318
+ // Group every thread by its machine; the graph shows one node per machine that has a responding thread.
319
+ let machineAllThreads = new Map<string, string[]>();
320
+ let machineRespondedThreads = new Map<string, string[]>();
321
+ let pushInto = (map: Map<string, string[]>, key: string, value: string) => {
322
+ let list = map.get(key);
323
+ if (!list) {
324
+ list = [];
325
+ map.set(key, list);
326
+ }
327
+ list.push(value);
328
+ };
329
+ for (let nodeId of nodeIds) {
330
+ let machineId = machineIdOf(nodeId);
331
+ pushInto(machineAllThreads, machineId, nodeId);
332
+ if (respondedSet.has(nodeId)) {
333
+ pushInto(machineRespondedThreads, machineId, nodeId);
334
+ }
335
+ }
336
+
337
+ // A machine's IP is the most common IP across its threads (they should all match, but resolve robustly).
338
+ let nodeIps = synced.getNodeIps(nodeIds);
339
+ let machineIp = new Map<string, string>();
340
+ for (let [machineId, threads] of machineAllThreads) {
341
+ let counts = new Map<string, number>();
342
+ for (let thread of threads) {
343
+ let ip = nodeIps?.[thread];
344
+ if (!ip) continue;
345
+ counts.set(ip, (counts.get(ip) || 0) + 1);
346
+ }
347
+ let bestIp = "";
348
+ let bestCount = 0;
349
+ for (let [ip, count] of counts) {
350
+ if (count > bestCount) {
351
+ bestCount = count;
352
+ bestIp = ip;
353
+ }
354
+ }
355
+ if (bestIp) machineIp.set(machineId, bestIp);
356
+ }
357
+
358
+ // The unique set of function-runner networks running on each machine (usually one, human-readable).
359
+ let machineNetworks = new Map<string, string[]>();
360
+ for (let [machineId, threads] of machineAllThreads) {
361
+ let unique = new Set<string>();
362
+ for (let thread of threads) {
363
+ for (let network of runnerByNode.get(thread)?.networks || []) {
364
+ unique.add(network);
365
+ }
366
+ }
367
+ if (unique.size) machineNetworks.set(machineId, [...unique]);
368
+ }
369
+
370
+ // One latency column per machine: each row (node) gets its own min · median · max latency to that machine's
371
+ // threads (a machine has several threads, so a cell is a range, not one number).
372
+ let machineColumns = [...machineAllThreads.keys()].map(id => {
373
+ let networks = machineNetworks.get(id) || [];
374
+ let label = networks.length ? `${id.slice(0, ID_CHARS)} (${networks.join(" | ")})` : id.slice(0, ID_CHARS);
375
+ return { id, label, threads: machineAllThreads.get(id) || [] };
376
+ });
377
+ sort(machineColumns, column => column.id);
378
+ let nodeMachineLatency = new Map<string, { [machineId: string]: { text: string; tooltip: string } }>();
379
+ for (let nodeId of nodeIds) {
380
+ let sourceLatencies = latencyMaps.get(nodeId);
381
+ let traffic = trafficMaps.get(nodeId);
382
+ let byMachine: { [machineId: string]: { text: string; tooltip: string } } = {};
383
+ for (let column of machineColumns) {
384
+ let cell = machineLatencyCell({ sourceLatencies, threads: column.threads, traffic });
385
+ if (cell.text) byMachine[column.id] = cell;
386
+ }
387
+ nodeMachineLatency.set(nodeId, byMachine);
388
+ }
389
+
390
+ // Total bytes on each thread-pair link (both directions). Both endpoints report the same total, so take the max.
391
+ let pairKey = (a: string, b: string) => a < b ? `${a}|${b}` : `${b}|${a}`;
392
+ let pairTraffic = new Map<string, number>();
393
+ // Path values per thread pair, split by direction (fwd = lower id → higher id). Both endpoints report each
394
+ // direction (one as sent, one as received), so take the max of the two estimates.
395
+ let pairPv = new Map<string, { fwd: number; back: number }>();
396
+ for (let [reporter, traffic] of trafficMaps) {
397
+ for (let [peer, data] of Object.entries(traffic.perNode || {})) {
398
+ if (reporter === peer || !respondedSet.has(peer)) continue;
399
+ let key = pairKey(reporter, peer);
400
+ pairTraffic.set(key, Math.max(pairTraffic.get(key) || 0, (data?.sent || 0) + (data?.received || 0)));
401
+ let pv = pairPv.get(key);
402
+ if (!pv) {
403
+ pv = { fwd: 0, back: 0 };
404
+ pairPv.set(key, pv);
405
+ }
406
+ let sentRate = data?.pathValuesSent || 0;
407
+ let receivedRate = data?.pathValuesReceived || 0;
408
+ if (reporter < peer) {
409
+ pv.fwd = Math.max(pv.fwd, sentRate);
410
+ pv.back = Math.max(pv.back, receivedRate);
411
+ } else {
412
+ pv.fwd = Math.max(pv.fwd, receivedRate);
413
+ pv.back = Math.max(pv.back, sentRate);
414
+ }
415
+ }
416
+ }
417
+
418
+ // Machine-to-machine latency = minimum over all cross-machine thread-pair latencies; traffic = summed pair traffic.
419
+ let machinePairLatency = new Map<string, number>();
420
+ for (let [sourceId, latencies] of latencyMaps) {
421
+ let sourceMachine = machineIdOf(sourceId);
422
+ for (let [destId, latencyMs] of Object.entries(latencies)) {
423
+ if (!respondedSet.has(destId) || !Number.isFinite(latencyMs)) continue;
424
+ let destMachine = machineIdOf(destId);
425
+ if (sourceMachine === destMachine) continue;
426
+ let key = pairKey(sourceMachine, destMachine);
427
+ let existing = machinePairLatency.get(key);
428
+ machinePairLatency.set(key, existing === undefined ? latencyMs : Math.min(existing, latencyMs));
429
+ }
430
+ }
431
+ let machinePairTraffic = new Map<string, number>();
432
+ for (let [key, weight] of pairTraffic) {
433
+ let [a, b] = key.split("|");
434
+ let ma = machineIdOf(a);
435
+ let mb = machineIdOf(b);
436
+ if (ma === mb) continue;
437
+ let mk = pairKey(ma, mb);
438
+ machinePairTraffic.set(mk, (machinePairTraffic.get(mk) || 0) + weight);
439
+ }
440
+ // Sum thread-pair path value flows up to machine pairs, keeping direction (fwd = lower machine id → higher).
441
+ let machinePairPv = new Map<string, { fwd: number; back: number }>();
442
+ for (let [key, pv] of pairPv) {
443
+ let [a, b] = key.split("|");
444
+ let ma = machineIdOf(a);
445
+ let mb = machineIdOf(b);
446
+ if (ma === mb) continue;
447
+ let mk = pairKey(ma, mb);
448
+ let entry = machinePairPv.get(mk);
449
+ if (!entry) {
450
+ entry = { fwd: 0, back: 0 };
451
+ machinePairPv.set(mk, entry);
452
+ }
453
+ // The thread pair's fwd direction may be flipped relative to the machine pair's ordering.
454
+ if (ma < mb) {
455
+ entry.fwd += pv.fwd;
456
+ entry.back += pv.back;
457
+ } else {
458
+ entry.fwd += pv.back;
459
+ entry.back += pv.fwd;
460
+ }
461
+ }
462
+
463
+ let nodes: LatencyGraphNode[] = [...machineRespondedThreads.keys()].map(machineId => {
464
+ let allThreads = machineAllThreads.get(machineId) || [];
465
+ let totals = sumTraffic(allThreads, trafficMaps);
466
+ return {
467
+ id: machineId,
468
+ labelLines: machineLabelLines({ machineId, threads: allThreads, trafficMaps, ip: machineIp.get(machineId), networks: machineNetworks.get(machineId) || [] }),
469
+ weight: totals.valuesSent + totals.valuesReceived,
470
+ };
471
+ });
472
+ let links: LatencyGraphLink[] = [];
473
+ for (let [key, latencyMs] of machinePairLatency) {
474
+ let [source, destination] = key.split("|");
475
+ let pv = machinePairPv.get(key);
476
+ let extraLabel: LatencyGraphLabelLine | undefined;
477
+ if (pv && (pv.fwd || pv.back)) {
478
+ extraLabel = { text: `↑${formatNumber(pv.fwd)}/s ↓${formatNumber(pv.back)}/s values`, color: PATHVALUE_COLOR };
479
+ }
480
+ links.push({ source, destination, latencyMs, weight: machinePairTraffic.get(key) || 0, valueWeight: pv && pv.fwd + pv.back || 0, extraLabel });
481
+ }
482
+
483
+ // One row per thread node, grouped so a machine's threads sit together; the selected machine sorts to the top.
484
+ // Single composite key: selected-first, then machine, then thread — the low separator keeps segments ordered.
485
+ let rows = nodeIds.filter(nodeId => respondedSet.has(nodeId)).map(nodeId => buildNodeRow({ nodeId, runner: runnerByNode.get(nodeId), info: infoByNode.get(nodeId), traffic: trafficMaps.get(nodeId), machineIp, machineNetworks }));
486
+ let selected = selectedMachineParam.value;
487
+ let sep = String.fromCharCode(1);
488
+ sort(rows, row => `${row.machineId === selected ? 0 : 1}${sep}${row.machineId}${sep}${row.threadPort}`);
489
+
490
+ let unresponsiveNodeIds = nodeIds.filter(nodeId => !respondedSet.has(nodeId));
491
+
492
+ return <div className={css.vbox(8).fillWidth}>
493
+ <div className={css.hbox(14)}>
494
+ <h2 className={css.margin(0)}>Latency Graph ({machineRespondedThreads.size} machines · {respondedSet.size}/{nodeIds.length} nodes reported)</h2>
495
+ <div className={css.hbox(0).bord2(0, 0, 70)}>
496
+ {([timeInMinute * 5, timeInMinute * 60]).map(windowSize => {
497
+ let selected = trafficWindow === windowSize;
498
+ return <div
499
+ className={css.pad2(12, 6).cursor("pointer")
500
+ .hsl(210, selected ? 70 : 0, selected ? 45 : 96).colorhsl(0, 0, selected ? 100 : 30)}
501
+ onMouseDown={() => trafficWindowParam.value = windowSize}
502
+ >{formatTime(windowSize)}</div>;
503
+ })}
504
+ </div>
505
+ </div>
506
+ <div className={css.relative.fillWidth.height(LATENCY_GRAPH_HEIGHT_PX).bord2(0, 0, 85)}>
507
+ <LatencyGraph
508
+ nodes={nodes}
509
+ links={links}
510
+ formatWeight={weight => formatNumber(weight) + "B/s"}
511
+ selectedId={selected || undefined}
512
+ onSelectNode={id => selectedMachineParam.value = selectedMachineParam.value === id ? "" : id}
513
+ />
514
+ </div>
515
+ <div className={css.colorhsl(0, 0, 50)}>Click a node to sort its information to the top of the table below.</div>
516
+ <h2 className={css.margin(0)}>Nodes ({rows.length})</h2>
517
+ <NodeInfoTable rows={rows} selectedMachine={selected} machineColumns={machineColumns} nodeMachineLatency={nodeMachineLatency} />
518
+ {unresponsiveNodeIds.length > 0 && <>
519
+ <h2 className={css.margin(0)}>Unresponsive Nodes ({unresponsiveNodeIds.length})</h2>
520
+ <UnresponsiveNodesTable nodeIds={unresponsiveNodeIds} machineIp={machineIp} />
521
+ </>}
522
+ </div>;
523
+ }
524
+ }
@@ -0,0 +1,130 @@
1
+ module.allowclient = true;
2
+
3
+ import { qreact } from "../../4-dom/qreact";
4
+ import { css } from "typesafecss";
5
+ import { getBrowserUrlNode } from "../../-f-node-discovery/NodeDiscovery";
6
+ import { sort } from "socket-function/src/misc";
7
+ import { formatNumber } from "socket-function/src/formatting/format";
8
+ import { PATHVALUE_COLOR } from "../../misc/nodeCategoryColors";
9
+ import { Table } from "../../5-diagnostics/Table";
10
+ import type { SummaryEntry } from "sliftutils/treeSummary";
11
+ import { PathValueDirection, PathValueSummaryState } from "../../0-path-value-core/PathValueStats";
12
+ import { AuthorityRangeBar, ID_CHARS, NodeAuthorityInfo, RoutingTableSynced, machineIdOf, threadLabel } from "./RoutingTablePage";
13
+
14
+ const PATH_SUMMARY_LIMIT = 100;
15
+ const MACHINE_CELL_WIDTH_CH = ID_CHARS + 4;
16
+
17
+ // One direction's top-N path breakdown for a server, heaviest first. Only mounted when a server row is expanded, so
18
+ // the (heavy) tree is only ever fetched on demand.
19
+ class PathSummaryTable extends qreact.Component<{ nodeId: string; direction: PathValueDirection }> {
20
+ render() {
21
+ let { nodeId, direction } = this.props;
22
+ let summaries: SummaryEntry<PathValueSummaryState>[] | undefined;
23
+ try {
24
+ summaries = RoutingTableSynced(getBrowserUrlNode()).getNodePathSummary(nodeId, direction, PATH_SUMMARY_LIMIT);
25
+ } catch (e: any) {
26
+ return <div className={css.colorhsl(0, 60, 40)}>{e.stack ?? String(e)}</div>;
27
+ }
28
+ if (!summaries) return <div className={css.colorhsl(0, 0, 50)}>Loading...</div>;
29
+ if (!summaries.length) return <div className={css.colorhsl(0, 0, 50)}>(none)</div>;
30
+ // getSummaries returns path-sorted; we want the heaviest paths first.
31
+ sort(summaries, entry => -entry.weight);
32
+ let rows = summaries.map(entry => ({
33
+ path: entry.path,
34
+ count: formatNumber(entry.weight),
35
+ }));
36
+ return <Table
37
+ rows={rows}
38
+ columns={{
39
+ path: { title: "Path" },
40
+ count: { title: "Count" },
41
+ }}
42
+ />;
43
+ }
44
+ }
45
+
46
+ // One row per path-value server, grouped by machine. Reads/writes lead the line; expanding reveals that server's
47
+ // per-path breakdown for both directions.
48
+ class AuthorityNodeRow extends qreact.Component<{ info: NodeAuthorityInfo; firstOfMachine: boolean }> {
49
+ state = {
50
+ expanded: false,
51
+ };
52
+ render() {
53
+ let info = this.props.info;
54
+ let spec = info.spec!;
55
+ let expanded = this.state.expanded;
56
+ let totals = RoutingTableSynced(getBrowserUrlNode()).getNodePathTotals(info.nodeId);
57
+ let machineId = machineIdOf(info.nodeId);
58
+ return <div
59
+ className={css.button.vbox(6).pad2(10).fillWidth.hsl(0, 0, 99)
60
+ .borderTop(this.props.firstOfMachine ? "2px solid hsl(0, 0%, 78%)" : "1px solid hsl(0, 0%, 90%)")}
61
+ onClick={() => this.state.expanded = !expanded}
62
+ >
63
+ <div className={css.hbox(10).fillWidth}>
64
+ <span>{expanded ? "▼" : "▶"}</span>
65
+ <span className={css.width(`${MACHINE_CELL_WIDTH_CH}ch`).flexShrink0.color(PATHVALUE_COLOR)} title="path values received / sent">
66
+ ↓{totals ? formatNumber(totals.received) : "…"} ↑{totals ? formatNumber(totals.sent) : "…"}
67
+ </span>
68
+ <span className={css.width(`${MACHINE_CELL_WIDTH_CH}ch`).flexShrink0.colorhsl(0, 0, 45)} title={machineId}>
69
+ {this.props.firstOfMachine ? machineId.slice(0, ID_CHARS) : ""}
70
+ </span>
71
+ <span className={css.boldStyle} title={info.nodeId}>{threadLabel(info.nodeId)}</span>
72
+ <span className={css.colorhsl(0, 0, 45)}>{spec.routeStart.toFixed(4)} - {spec.routeEnd.toFixed(4)}</span>
73
+ <AuthorityRangeBar start={spec.routeStart} end={spec.routeEnd} />
74
+ {spec.excludeDefault && <span className={css.colorhsl(0, 70, 35)}>(excludes default)</span>}
75
+ </div>
76
+ {expanded &&
77
+ <div className={css.hbox(16).wrap.fillWidth.alignItems("flex-start")} onClick={e => e.stopPropagation()}>
78
+ <div className={css.vbox(4)}>
79
+ <div className={css.boldStyle.color(PATHVALUE_COLOR)}>Received (↓) — top {PATH_SUMMARY_LIMIT} paths</div>
80
+ <PathSummaryTable nodeId={info.nodeId} direction="received" />
81
+ </div>
82
+ <div className={css.vbox(4)}>
83
+ <div className={css.boldStyle.color(PATHVALUE_COLOR)}>Sent (↑) — top {PATH_SUMMARY_LIMIT} paths</div>
84
+ <PathSummaryTable nodeId={info.nodeId} direction="sent" />
85
+ </div>
86
+ </div>
87
+ }
88
+ </div>;
89
+ }
90
+ }
91
+
92
+ // Invalidate: drop every cached synced call for this node so the whole page's data disappears and refetches. Clear:
93
+ // wipe the in-memory path-value stats (totals and trees) on every node, then invalidate so the now-empty stats reload.
94
+ function invalidateRoutingTable(): void {
95
+ RoutingTableSynced(getBrowserUrlNode()).resetAll();
96
+ }
97
+ async function clearAllPathStats(nodeIds: string[]): Promise<void> {
98
+ let synced = RoutingTableSynced(getBrowserUrlNode());
99
+ await Promise.all(nodeIds.map(nodeId => synced.clearNodePathStats.promise(nodeId).catch(() => { })));
100
+ synced.resetAll();
101
+ }
102
+
103
+ export class PathValueServersSection extends qreact.Component<{ infos: NodeAuthorityInfo[] }> {
104
+ render() {
105
+ let infos = this.props.infos;
106
+ let allNodeIds = infos.map(x => x.nodeId);
107
+ let routableInfos = infos.filter(x => x.spec && x.spec.routeStart >= 0 && x.spec.routeEnd >= 0);
108
+ // Group each server's threads under their machine (machine, then thread), so a machine's servers sit together.
109
+ let sep = String.fromCharCode(1);
110
+ sort(routableInfos, x => `${machineIdOf(x.nodeId)}${sep}${threadLabel(x.nodeId)}`);
111
+
112
+ let buttonClass = css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 0, 100).whiteSpace("nowrap");
113
+ return <div className={css.vbox(8).fillWidth}>
114
+ <div className={css.hbox(12).alignItems("center")}>
115
+ <h2 className={css.margin(0)}>Path Value Servers ({routableInfos.length})</h2>
116
+ <button className={buttonClass} onClick={() => invalidateRoutingTable()}>Invalidate</button>
117
+ <button className={buttonClass} onClick={() => void clearAllPathStats(allNodeIds)}>Clear stats</button>
118
+ </div>
119
+ <div className={css.fontSize(13).colorhsl(0, 0, 45)}>
120
+ Path values each server has received (↓) and sent (↑) since startup or the last clear. Expand a server for its top {PATH_SUMMARY_LIMIT} paths in each direction.
121
+ </div>
122
+ <div className={css.vbox(0).fillWidth.bord2(0, 0, 88)}>
123
+ {routableInfos.map((info, i) => {
124
+ let firstOfMachine = i === 0 || machineIdOf(routableInfos[i - 1].nodeId) !== machineIdOf(info.nodeId);
125
+ return <AuthorityNodeRow key={info.nodeId} info={info} firstOfMachine={firstOfMachine} />;
126
+ })}
127
+ </div>
128
+ </div>;
129
+ }
130
+ }