querysub 0.520.0 → 0.521.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.520.0",
3
+ "version": "0.521.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
@@ -70,7 +70,7 @@
70
70
  "pako": "^2.1.0",
71
71
  "peggy": "^5.0.6",
72
72
  "sliftutils": "^1.7.5",
73
- "socket-function": "^1.2.11",
73
+ "socket-function": "^1.2.12",
74
74
  "terser": "^5.31.0",
75
75
  "typenode": "^6.6.1",
76
76
  "typesafecss": "^0.32.0",
@@ -169,20 +169,24 @@ export class ServiceDetailPage extends qreact.Component {
169
169
  private renderTemplateVariables(config: ServiceConfig) {
170
170
  let templateVariables = getCommandTemplateVariables(config.parameters.command);
171
171
  let targets = getMachineTargets(config.parameters);
172
- let anyVariablesSet = targets.some(target => Object.keys(target.variables).length > 0);
173
- if (templateVariables.length === 0 && !anyVariablesSet) return undefined;
172
+ if (targets.length === 0) return undefined;
174
173
 
175
- const setVariable = (entryIndex: number, name: string, value: string) => {
174
+ const updateTargets = (change: (targets: ReturnType<typeof getMachineTargets>) => void) => {
176
175
  let updated = deepCloneJSON(config);
177
176
  let updatedTargets = getMachineTargets(updated.parameters);
178
- if (value) {
179
- updatedTargets[entryIndex].variables[name] = value;
180
- } else {
181
- delete updatedTargets[entryIndex].variables[name];
182
- }
177
+ change(updatedTargets);
183
178
  setMachineTargets(updated.parameters, updatedTargets);
184
179
  this.updateEditorState(updated);
185
180
  };
181
+ const setVariable = (entryIndex: number, name: string, value: string) => {
182
+ updateTargets(targets => {
183
+ if (value) {
184
+ targets[entryIndex].variables[name] = value;
185
+ } else {
186
+ delete targets[entryIndex].variables[name];
187
+ }
188
+ });
189
+ };
186
190
 
187
191
  let dupIndexes = new Map<string, number>();
188
192
  return <div className={css.vbox(10).fillWidth.pad2(12).bord2(0, 0, 20)}>
@@ -201,6 +205,26 @@ export class ServiceDetailPage extends qreact.Component {
201
205
  onChangeValue={value => setVariable(entryIndex, name, value)}
202
206
  />;
203
207
  })}
208
+ <button
209
+ className={css.pad2(10, 4).button.bord2(0, 0, 20).hsl(120, 70, 90)}
210
+ title="Duplicate this entry (same machine and variables)"
211
+ onClick={() => {
212
+ updateTargets(targets => {
213
+ targets.splice(entryIndex + 1, 0, deepCloneJSON(targets[entryIndex]));
214
+ });
215
+ }}>
216
+ +
217
+ </button>
218
+ <button
219
+ className={css.pad2(10, 4).button.bord2(0, 0, 20).hsl(0, 70, 90)}
220
+ title="Remove this entry"
221
+ onClick={() => {
222
+ updateTargets(targets => {
223
+ targets.splice(entryIndex, 1);
224
+ });
225
+ }}>
226
+
227
+ </button>
204
228
  </div>;
205
229
  })}
206
230
  </div>;
@@ -8,6 +8,8 @@ import { LatencyController } from "../../-f-node-discovery/LatencyTracking";
8
8
  import { TrafficController, TrafficStats } from "../../-f-node-discovery/TrafficTracking";
9
9
  import { getDomain } from "../../config";
10
10
  import { decodeNodeId } from "sliftutils/misc/https/certs";
11
+ import { getNodeIdDomainMaybeUndefined } from "socket-function/src/nodeCache";
12
+ import dns from "dns";
11
13
  import { getSyncedController } from "../../library-components/SyncedController";
12
14
  import { assertIsManagementUser } from "../managementPages";
13
15
  import { NodeCapabilitiesController } from "../../-g-core-values/NodeCapabilities";
@@ -17,6 +19,7 @@ import type { AuthoritySpec } from "../../0-path-value-core/PathRouter";
17
19
  import { getFunctionRunnerIndex, FunctionRunnerNodeInfo } from "../../4-querysub/FunctionRunnerTracking";
18
20
  import { formatTime, formatNumber } from "socket-function/src/formatting/format";
19
21
  import { LatencyGraph, LatencyGraphNode, LatencyGraphLink, LatencyGraphLabelLine } from "../../library-components/LatencyGraph";
22
+ import { URLParam } from "../../library-components/URLParam";
20
23
 
21
24
  const ID_CHARS = 8;
22
25
  // Green means querysub, blue means path value, purple means function runner.
@@ -29,13 +32,66 @@ const RANGE_BAR_WIDTH_PX = 360;
29
32
  const RANGE_BAR_HEIGHT_PX = 14;
30
33
  const LATENCY_GRAPH_HEIGHT_PX = 720;
31
34
 
35
+ // Clicking a machine node in the graph (or a row) sorts that machine's nodes to the top of the table.
36
+ const selectedMachineParam = new URLParam("rtSelectedMachine", "");
37
+
38
+ // The per-node table columns, in order. `color` tints the function/path-value/querysub columns to match the graph;
39
+ // `perMachine` columns (the IP and machine id) only print on the first row of each machine group.
40
+ const NODE_TABLE_COLUMNS: { key: keyof NodeRow; label: string; width: number; color?: string; perMachine?: boolean; }[] = [
41
+ { key: "ip", label: "IP", width: 130, perMachine: true },
42
+ { key: "machineShort", label: "Machine", width: 96, perMachine: true },
43
+ { key: "threadPort", label: "Thread:Port", width: 130 },
44
+ { key: "name", label: "Name", width: 160 },
45
+ { key: "data", label: "Data", width: 210 },
46
+ { key: "fn", label: "Function", width: 320, color: FUNCTION_COLOR },
47
+ { key: "pv", label: "Path Values", width: 250, color: PATHVALUE_COLOR },
48
+ { key: "qs", label: "Querysub", width: 120, color: QUERYSUB_COLOR },
49
+ ];
50
+
51
+ type NodeRow = {
52
+ nodeId: string;
53
+ machineId: string;
54
+ ip: string;
55
+ machineShort: string;
56
+ threadPort: string;
57
+ name: string;
58
+ data: string;
59
+ fn: string;
60
+ pv: string;
61
+ qs: string;
62
+ };
63
+
32
64
  type NodeAuthorityInfo = {
33
65
  nodeId: string;
34
66
  entryPoint?: string;
35
67
  spec?: AuthoritySpec;
36
68
  };
37
69
 
70
+ // A machine's IP never changes, so we cache each hostname's resolved IP forever (keyed by hostname, shared across all
71
+ // the machine's threads). DNS resolution only runs on the server node this controller executes on.
72
+ const nodeIpCache = new Map<string, string>();
73
+
74
+ async function resolveNodeIp(nodeId: string): Promise<string | undefined> {
75
+ let hostname = getNodeIdDomainMaybeUndefined(nodeId);
76
+ if (!hostname) return undefined;
77
+ let cached = nodeIpCache.get(hostname);
78
+ if (cached) return cached;
79
+ let result = await timeoutToUndefinedSilent(PROBE_TIMEOUT_MS, dns.promises.lookup(hostname));
80
+ if (!result) return undefined;
81
+ nodeIpCache.set(hostname, result.address);
82
+ return result.address;
83
+ }
84
+
38
85
  class RoutingTablePageControllerBase {
86
+ // Resolves every node's IP in parallel (cached). A machine's IP is then the most common IP across its threads.
87
+ public async getNodeIps(nodeIds: string[]): Promise<{ [nodeId: string]: string }> {
88
+ let entries = await Promise.all(nodeIds.map(async nodeId => [nodeId, await resolveNodeIp(nodeId)] as const));
89
+ let result: { [nodeId: string]: string } = {};
90
+ for (let [nodeId, ip] of entries) {
91
+ if (ip) result[nodeId] = ip;
92
+ }
93
+ return result;
94
+ }
39
95
  public async getAllNodeAuthoritySpecs(): Promise<NodeAuthorityInfo[]> {
40
96
  let nodes = await getAllNodeIds();
41
97
  return Promise.all(nodes.map(async (nodeId): Promise<NodeAuthorityInfo> => {
@@ -61,6 +117,7 @@ export const RoutingTablePageController = SocketFunction.register(
61
117
  getAllNodeAuthoritySpecs: {},
62
118
  getNodeLatencies: {},
63
119
  getNodeTrafficStats: {},
120
+ getNodeIps: {},
64
121
  }),
65
122
  () => ({
66
123
  hooks: [assertIsManagementUser],
@@ -149,68 +206,162 @@ class FunctionRunnersSection extends qreact.Component {
149
206
  }
150
207
  }
151
208
 
152
- function nodeLabelLines(config: {
209
+ function nodeTotalTraffic(traffic: TrafficStats): { sent: number; received: number; outside: number; } {
210
+ let sent = traffic.outside.sent;
211
+ let received = traffic.outside.received;
212
+ for (let d of Object.values(traffic.perNode)) {
213
+ sent += d.sent;
214
+ received += d.received;
215
+ }
216
+ return { sent, received, outside: traffic.outside.sent + traffic.outside.received };
217
+ }
218
+
219
+ function machineIdOf(nodeId: string): string {
220
+ let parts = decodeNodeId(nodeId, getDomain(), "allowMissingThreadId");
221
+ return parts?.machineId || nodeId;
222
+ }
223
+
224
+ // Summed traffic metrics across a set of nodes (a machine's threads).
225
+ function sumTraffic(threads: string[], trafficMaps: Map<string, TrafficStats>) {
226
+ let dataSent = 0;
227
+ let dataReceived = 0;
228
+ let valuesSent = 0;
229
+ let valuesReceived = 0;
230
+ let calls = 0;
231
+ let addCalls = 0;
232
+ for (let id of threads) {
233
+ let traffic = trafficMaps.get(id);
234
+ if (!traffic) continue;
235
+ let totals = nodeTotalTraffic(traffic);
236
+ dataSent += totals.sent;
237
+ dataReceived += totals.received;
238
+ valuesSent += traffic.pathValuesSent;
239
+ valuesReceived += traffic.pathValuesReceived;
240
+ calls += traffic.functionsExecuted;
241
+ addCalls += traffic.querysubCalls;
242
+ }
243
+ return { dataSent, dataReceived, valuesSent, valuesReceived, calls, addCalls };
244
+ }
245
+
246
+ // A machine's graph label: the IP first (most important), then the same per-piece info as the table, summed across all
247
+ // its threads and colored the same.
248
+ function machineLabelLines(config: {
249
+ machineId: string;
250
+ threads: string[];
251
+ trafficMaps: Map<string, TrafficStats>;
252
+ ip: string | undefined;
253
+ }): LatencyGraphLabelLine[] {
254
+ let { machineId, threads, ip } = config;
255
+ let totals = sumTraffic(threads, config.trafficMaps);
256
+ let lines: LatencyGraphLabelLine[] = [];
257
+ if (ip) {
258
+ lines.push({ text: ip });
259
+ }
260
+ lines.push({ text: `${machineId.slice(0, ID_CHARS)} ${threads.length} threads` });
261
+ if (totals.dataSent + totals.dataReceived > 0) {
262
+ lines.push({ text: `↑${formatNumber(totals.dataSent)}B ↓${formatNumber(totals.dataReceived)}B` });
263
+ }
264
+ if (totals.valuesSent || totals.valuesReceived) {
265
+ lines.push({ text: `↑${formatNumber(totals.valuesSent)} ↓${formatNumber(totals.valuesReceived)} values`, color: PATHVALUE_COLOR });
266
+ }
267
+ if (totals.calls) {
268
+ lines.push({ text: `${formatNumber(totals.calls)} calls`, color: FUNCTION_COLOR });
269
+ }
270
+ if (totals.addCalls) {
271
+ lines.push({ text: `${formatNumber(totals.addCalls)} addCalls`, color: QUERYSUB_COLOR });
272
+ }
273
+ return lines;
274
+ }
275
+
276
+ // One table row per thread node: each piece of info that used to live on the graph label becomes its own column.
277
+ function buildNodeRow(config: {
153
278
  nodeId: string;
154
279
  runner: FunctionRunnerNodeInfo | undefined;
155
280
  info: NodeAuthorityInfo | undefined;
156
281
  traffic: TrafficStats | undefined;
157
- }): LatencyGraphLabelLine[] {
158
- let { nodeId, runner, info, traffic } = config;
282
+ machineIp: Map<string, string>;
283
+ }): NodeRow {
284
+ let { nodeId, runner, info, traffic, machineIp } = config;
159
285
  let parts = decodeNodeId(nodeId, getDomain(), "allowMissingThreadId");
160
286
  let thread = (parts?.threadId || "?").slice(0, ID_CHARS);
161
- let machine = (parts?.machineId || nodeId).slice(0, ID_CHARS);
162
- let lines: LatencyGraphLabelLine[] = [{ text: `${thread}:${parts?.port ?? "?"}` }];
163
- // The last segment of the entry-point path is a good human name; shown on one line with the server (machine) id.
287
+ let machineId = parts?.machineId || nodeId;
164
288
  let entryPoint = info?.entryPoint || runner?.entryPoint;
165
- let name = entryPoint ? entryPoint.split(/[\\/]/).filter(Boolean).at(-1) : undefined;
166
- lines.push({ text: name ? `${name} ${machine}` : machine });
167
- // Total data this node sent / received, and the percent of it that went outside our known node list.
289
+ let name = entryPoint && entryPoint.split(/[\\/]/).filter(Boolean).at(-1) || "";
290
+
291
+ let data = "";
168
292
  if (traffic) {
169
293
  let totals = nodeTotalTraffic(traffic);
170
294
  let sum = totals.sent + totals.received;
171
295
  if (sum > 0) {
172
296
  let outsidePct = Math.round((totals.outside / sum) * 100);
173
- lines.push({ text: `↑${formatNumber(totals.sent)}B ↓${formatNumber(totals.received)}B ${outsidePct}%⊘` });
297
+ data = `↑${formatNumber(totals.sent)}B ↓${formatNumber(totals.received)}B ${outsidePct}%⊘`;
174
298
  }
175
299
  }
176
- // All function-runner info on one purple line: networks, shard ranges, calls, private.
300
+
301
+ let fnParts: string[] = [];
177
302
  if (runner) {
178
- let fnParts: string[] = [];
179
303
  if (runner.networks.length) fnParts.push(runner.networks.join(" "));
180
304
  for (let shard of runner.shards) {
181
305
  fnParts.push(`fn ${shard.shardRange.startFraction.toFixed(3)}-${shard.shardRange.endFraction.toFixed(3)}`);
182
306
  }
183
307
  fnParts.push(`${formatNumber(traffic?.functionsExecuted ?? 0)} calls`);
184
308
  if (!runner.isPublic) fnParts.push("PRIVATE");
185
- lines.push({ text: fnParts.join(" "), color: FUNCTION_COLOR });
186
309
  }
187
- // All path-value info on one blue line: shard range (only if real) and values sent.
188
- let spec = info?.spec;
310
+
189
311
  let pvParts: string[] = [];
312
+ let spec = info?.spec;
190
313
  if (spec && spec.routeStart >= 0 && spec.routeEnd >= 0) {
191
314
  pvParts.push(`pv ${spec.routeStart.toFixed(3)}-${spec.routeEnd.toFixed(3)}`);
192
315
  }
193
316
  if (traffic && (traffic.pathValuesSent || traffic.pathValuesReceived)) {
194
317
  pvParts.push(`↑${formatNumber(traffic.pathValuesSent)} ↓${formatNumber(traffic.pathValuesReceived)} values`);
195
318
  }
196
- if (pvParts.length) {
197
- lines.push({ text: pvParts.join(" "), color: PATHVALUE_COLOR });
198
- }
199
- // Querysub activity: how many function calls were submitted to this node's QuerysubController.
200
- if (traffic?.querysubCalls) {
201
- lines.push({ text: `${formatNumber(traffic.querysubCalls)} addCalls`, color: QUERYSUB_COLOR });
202
- }
203
- return lines;
319
+
320
+ let qs = traffic?.querysubCalls ? `${formatNumber(traffic.querysubCalls)} addCalls` : "";
321
+ return {
322
+ nodeId,
323
+ machineId,
324
+ ip: machineIp.get(machineId) || "",
325
+ machineShort: machineId.slice(0, ID_CHARS),
326
+ threadPort: `${thread}:${parts?.port ?? "?"}`,
327
+ name,
328
+ data,
329
+ fn: fnParts.join(" "),
330
+ pv: pvParts.join(" "),
331
+ qs,
332
+ };
204
333
  }
205
334
 
206
- function nodeTotalTraffic(traffic: TrafficStats): { sent: number; received: number; outside: number; } {
207
- let sent = traffic.outside.sent;
208
- let received = traffic.outside.received;
209
- for (let d of Object.values(traffic.perNode)) {
210
- sent += d.sent;
211
- received += d.received;
335
+ class NodeInfoTable extends qreact.Component<{ rows: NodeRow[]; selectedMachine: string }> {
336
+ render() {
337
+ let rows = this.props.rows;
338
+ let selected = this.props.selectedMachine;
339
+ return <div className={css.vbox(0).fillWidth.overflowAuto.bord2(0, 0, 85)}>
340
+ <div className={css.hbox(0).hsl(0, 0, 96).colorhsl(0, 0, 20).boldStyle}>
341
+ {NODE_TABLE_COLUMNS.map(col =>
342
+ <div className={css.width(col.width).flexShrink0.pad2(6).ellipsis}>{col.label}</div>
343
+ )}
344
+ </div>
345
+ {rows.map((row, i) => {
346
+ let firstOfMachine = i === 0 || rows[i - 1].machineId !== row.machineId;
347
+ let isSelected = row.machineId === selected;
348
+ return <div
349
+ className={css.hbox(0).button.fillWidth.hsl(0, 0, isSelected ? 92 : 99)
350
+ .borderTop(firstOfMachine ? "1px solid hsl(0, 0%, 80%)" : "1px solid hsl(0, 0%, 93%)")}
351
+ onClick={() => selectedMachineParam.value = selected === row.machineId ? "" : row.machineId}
352
+ >
353
+ {NODE_TABLE_COLUMNS.map(col => {
354
+ // Per-machine columns (IP, machine id) only print on the first row of each group, so it reads clearly.
355
+ let text = col.perMachine && !firstOfMachine ? "" : row[col.key];
356
+ return <div
357
+ className={css.width(col.width).flexShrink0.pad2(6).ellipsis.color(col.color || "hsl(0, 0%, 25%)")}
358
+ title={text}
359
+ >{text}</div>;
360
+ })}
361
+ </div>;
362
+ })}
363
+ </div>;
212
364
  }
213
- return { sent, received, outside: traffic.outside.sent + traffic.outside.received };
214
365
  }
215
366
 
216
367
  class LatencyGraphSection extends qreact.Component<{ nodeIds: string[]; infos: NodeAuthorityInfo[] }> {
@@ -234,11 +385,50 @@ class LatencyGraphSection extends qreact.Component<{ nodeIds: string[]; infos: N
234
385
  let traffic = synced.getNodeTrafficStats(nodeId);
235
386
  if (traffic) trafficMaps.set(nodeId, traffic);
236
387
  }
237
- // Only render nodes that reported their latencies — ones that never respond probably don't exist.
388
+ // Only graph nodes that reported their latencies — ones that never respond probably don't exist.
238
389
  let respondedSet = new Set(latencyMaps.keys());
239
390
 
240
- // Total bytes on each node-pair link (both directions). Both endpoints report the same link total, so we take
241
- // the max instead of summing. Circle size = a node's total traffic.
391
+ // Group every thread by its machine; the graph shows one node per machine that has a responding thread.
392
+ let machineAllThreads = new Map<string, string[]>();
393
+ let machineRespondedThreads = new Map<string, string[]>();
394
+ let pushInto = (map: Map<string, string[]>, key: string, value: string) => {
395
+ let list = map.get(key);
396
+ if (!list) {
397
+ list = [];
398
+ map.set(key, list);
399
+ }
400
+ list.push(value);
401
+ };
402
+ for (let nodeId of nodeIds) {
403
+ let machineId = machineIdOf(nodeId);
404
+ pushInto(machineAllThreads, machineId, nodeId);
405
+ if (respondedSet.has(nodeId)) {
406
+ pushInto(machineRespondedThreads, machineId, nodeId);
407
+ }
408
+ }
409
+
410
+ // A machine's IP is the most common IP across its threads (they should all match, but resolve robustly).
411
+ let nodeIps = synced.getNodeIps(nodeIds);
412
+ let machineIp = new Map<string, string>();
413
+ for (let [machineId, threads] of machineAllThreads) {
414
+ let counts = new Map<string, number>();
415
+ for (let thread of threads) {
416
+ let ip = nodeIps?.[thread];
417
+ if (!ip) continue;
418
+ counts.set(ip, (counts.get(ip) || 0) + 1);
419
+ }
420
+ let bestIp = "";
421
+ let bestCount = 0;
422
+ for (let [ip, count] of counts) {
423
+ if (count > bestCount) {
424
+ bestCount = count;
425
+ bestIp = ip;
426
+ }
427
+ }
428
+ if (bestIp) machineIp.set(machineId, bestIp);
429
+ }
430
+
431
+ // Total bytes on each thread-pair link (both directions). Both endpoints report the same total, so take the max.
242
432
  let pairKey = (a: string, b: string) => a < b ? `${a}|${b}` : `${b}|${a}`;
243
433
  let pairTraffic = new Map<string, number>();
244
434
  for (let [reporter, traffic] of trafficMaps) {
@@ -249,55 +439,69 @@ class LatencyGraphSection extends qreact.Component<{ nodeIds: string[]; infos: N
249
439
  }
250
440
  }
251
441
 
252
- let nodes: LatencyGraphNode[] = [...respondedSet].map(nodeId => {
253
- let traffic = trafficMaps.get(nodeId);
254
- let totals = traffic ? nodeTotalTraffic(traffic) : undefined;
442
+ // Machine-to-machine latency = mean over all cross-machine thread-pair latencies; traffic = summed pair traffic.
443
+ let machinePairLatency = new Map<string, { sum: number; count: number; }>();
444
+ for (let [sourceId, latencies] of latencyMaps) {
445
+ let sourceMachine = machineIdOf(sourceId);
446
+ for (let [destId, latencyMs] of Object.entries(latencies)) {
447
+ if (!respondedSet.has(destId) || !Number.isFinite(latencyMs)) continue;
448
+ let destMachine = machineIdOf(destId);
449
+ if (sourceMachine === destMachine) continue;
450
+ let key = pairKey(sourceMachine, destMachine);
451
+ let agg = machinePairLatency.get(key);
452
+ if (!agg) {
453
+ agg = { sum: 0, count: 0 };
454
+ machinePairLatency.set(key, agg);
455
+ }
456
+ agg.sum += latencyMs;
457
+ agg.count++;
458
+ }
459
+ }
460
+ let machinePairTraffic = new Map<string, number>();
461
+ for (let [key, weight] of pairTraffic) {
462
+ let [a, b] = key.split("|");
463
+ let ma = machineIdOf(a);
464
+ let mb = machineIdOf(b);
465
+ if (ma === mb) continue;
466
+ let mk = pairKey(ma, mb);
467
+ machinePairTraffic.set(mk, (machinePairTraffic.get(mk) || 0) + weight);
468
+ }
469
+
470
+ let nodes: LatencyGraphNode[] = [...machineRespondedThreads.keys()].map(machineId => {
471
+ let allThreads = machineAllThreads.get(machineId) || [];
472
+ let totals = sumTraffic(allThreads, trafficMaps);
255
473
  return {
256
- id: nodeId,
257
- labelLines: nodeLabelLines({ nodeId, runner: runnerByNode.get(nodeId), info: infoByNode.get(nodeId), traffic }),
258
- weight: totals ? totals.sent + totals.received : 0,
474
+ id: machineId,
475
+ labelLines: machineLabelLines({ machineId, threads: allThreads, trafficMaps, ip: machineIp.get(machineId) }),
476
+ weight: totals.dataSent + totals.dataReceived,
259
477
  };
260
478
  });
261
479
  let links: LatencyGraphLink[] = [];
262
- for (let [sourceId, latencies] of latencyMaps) {
263
- for (let [destId, latencyMs] of Object.entries(latencies)) {
264
- if (!respondedSet.has(destId)) continue;
265
- links.push({ source: sourceId, destination: destId, latencyMs, weight: pairTraffic.get(pairKey(sourceId, destId)) || 0 });
266
- }
480
+ for (let [key, agg] of machinePairLatency) {
481
+ let [source, destination] = key.split("|");
482
+ links.push({ source, destination, latencyMs: agg.sum / agg.count, weight: machinePairTraffic.get(key) || 0 });
267
483
  }
268
484
 
269
- // Per-cluster summary: sum the same per-node metrics across the cluster's members.
270
- let clusterSummary = (memberIds: string[]): LatencyGraphLabelLine[] => {
271
- let sent = 0;
272
- let received = 0;
273
- let calls = 0;
274
- let addCalls = 0;
275
- let dataSent = 0;
276
- let dataReceived = 0;
277
- for (let id of memberIds) {
278
- let traffic = trafficMaps.get(id);
279
- if (!traffic) continue;
280
- sent += traffic.pathValuesSent;
281
- received += traffic.pathValuesReceived;
282
- calls += traffic.functionsExecuted;
283
- addCalls += traffic.querysubCalls;
284
- let totals = nodeTotalTraffic(traffic);
285
- dataSent += totals.sent;
286
- dataReceived += totals.received;
287
- }
288
- let summary: LatencyGraphLabelLine[] = [];
289
- summary.push({ text: `↑${formatNumber(dataSent)}B ↓${formatNumber(dataReceived)}B` });
290
- summary.push({ text: `↑${formatNumber(sent)} ↓${formatNumber(received)} values`, color: PATHVALUE_COLOR });
291
- summary.push({ text: `${formatNumber(calls)} calls`, color: FUNCTION_COLOR });
292
- summary.push({ text: `${formatNumber(addCalls)} addCalls`, color: QUERYSUB_COLOR });
293
- return summary;
294
- };
485
+ // One row per thread node, grouped so a machine's threads sit together; the selected machine sorts to the top.
486
+ // Single composite key: selected-first, then machine, then thread — the low separator keeps segments ordered.
487
+ let rows = nodeIds.map(nodeId => buildNodeRow({ nodeId, runner: runnerByNode.get(nodeId), info: infoByNode.get(nodeId), traffic: trafficMaps.get(nodeId), machineIp }));
488
+ let selected = selectedMachineParam.value;
489
+ let sep = String.fromCharCode(1);
490
+ sort(rows, row => `${row.machineId === selected ? 0 : 1}${sep}${row.machineId}${sep}${row.threadPort}`);
295
491
 
296
492
  return <div className={css.vbox(8).fillWidth}>
297
- <h2 className={css.margin(0)}>Latency Graph ({respondedSet.size}/{nodeIds.length} nodes reported)</h2>
493
+ <h2 className={css.margin(0)}>Latency Graph ({machineRespondedThreads.size} machines · {respondedSet.size}/{nodeIds.length} nodes reported)</h2>
298
494
  <div className={css.relative.fillWidth.height(LATENCY_GRAPH_HEIGHT_PX).bord2(0, 0, 85)}>
299
- <LatencyGraph nodes={nodes} links={links} formatWeight={weight => formatNumber(weight) + "B"} clusterSummary={clusterSummary} />
495
+ <LatencyGraph
496
+ nodes={nodes}
497
+ links={links}
498
+ formatWeight={weight => formatNumber(weight) + "B"}
499
+ selectedId={selected || undefined}
500
+ onSelectNode={id => selectedMachineParam.value = selectedMachineParam.value === id ? "" : id}
501
+ />
300
502
  </div>
503
+ <h2 className={css.margin(0)}>Nodes ({rows.length})</h2>
504
+ <NodeInfoTable rows={rows} selectedMachine={selected} />
301
505
  </div>;
302
506
  }
303
507
  }