querysub 0.519.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 +2 -2
- package/src/-f-node-discovery/TrafficTracking.ts +7 -0
- package/src/0-path-value-core/PathValueController.ts +2 -1
- package/src/deployManager/components/ServiceDetailPage.tsx +32 -8
- package/src/diagnostics/misc-pages/RoutingTablePage.tsx +284 -54
- package/src/library-components/LatencyGraph.tsx +187 -592
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "querysub",
|
|
3
|
-
"version": "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.
|
|
73
|
+
"socket-function": "^1.2.12",
|
|
74
74
|
"terser": "^5.31.0",
|
|
75
75
|
"typenode": "^6.6.1",
|
|
76
76
|
"typesafecss": "^0.32.0",
|
|
@@ -25,6 +25,7 @@ export type NodeDataTraffic = { sent: number; received: number; };
|
|
|
25
25
|
export type TrafficStats = {
|
|
26
26
|
functionsExecuted: number;
|
|
27
27
|
pathValuesSent: number;
|
|
28
|
+
pathValuesReceived: number;
|
|
28
29
|
querysubCalls: number;
|
|
29
30
|
// Bytes to/from each known network node, plus one aggregate for everything outside the node list.
|
|
30
31
|
perNode: { [nodeId: string]: NodeDataTraffic };
|
|
@@ -41,6 +42,7 @@ function oldestBucket() {
|
|
|
41
42
|
// bucket => count
|
|
42
43
|
let functionsExecuted = new Map<number, number>();
|
|
43
44
|
let pathValuesSent = new Map<number, number>();
|
|
45
|
+
let pathValuesReceived = new Map<number, number>();
|
|
44
46
|
let querysubCalls = new Map<number, number>();
|
|
45
47
|
// raw connection nodeId => (bucket => bytes)
|
|
46
48
|
let bytesSent = new Map<string, Map<number, number>>();
|
|
@@ -66,6 +68,10 @@ export function recordPathValuesSent(count: number) {
|
|
|
66
68
|
if (count <= 0) return;
|
|
67
69
|
addSimple(pathValuesSent, count);
|
|
68
70
|
}
|
|
71
|
+
export function recordPathValuesReceived(count: number) {
|
|
72
|
+
if (count <= 0) return;
|
|
73
|
+
addSimple(pathValuesReceived, count);
|
|
74
|
+
}
|
|
69
75
|
export function recordQuerysubCall() {
|
|
70
76
|
addSimple(querysubCalls, 1);
|
|
71
77
|
}
|
|
@@ -131,6 +137,7 @@ export function getTrafficStats(): TrafficStats {
|
|
|
131
137
|
return {
|
|
132
138
|
functionsExecuted: sumRecent(functionsExecuted),
|
|
133
139
|
pathValuesSent: sumRecent(pathValuesSent),
|
|
140
|
+
pathValuesReceived: sumRecent(pathValuesReceived),
|
|
134
141
|
querysubCalls: sumRecent(querysubCalls),
|
|
135
142
|
perNode: data.perNode,
|
|
136
143
|
outside: data.outside,
|
|
@@ -12,7 +12,7 @@ import { debugNodeId, debugNodeThread } from "../-c-identity/IdentityController"
|
|
|
12
12
|
import { getSlowdown, isDiskAudit, getDomain } from "../config";
|
|
13
13
|
import { decodeNodeId } from "sliftutils/misc/https/certs";
|
|
14
14
|
import { areNodeIdsEqual, isOwnNodeId } from "../-f-node-discovery/NodeDiscovery";
|
|
15
|
-
import { recordPathValuesSent } from "../-f-node-discovery/TrafficTracking";
|
|
15
|
+
import { recordPathValuesSent, recordPathValuesReceived } from "../-f-node-discovery/TrafficTracking";
|
|
16
16
|
import { getNodeIdIP } from "socket-function/src/nodeCache";
|
|
17
17
|
import { authorityLookup } from "./AuthorityLookup";
|
|
18
18
|
import { timeoutToError } from "../errors";
|
|
@@ -148,6 +148,7 @@ export class PathValueControllerBase {
|
|
|
148
148
|
if (valueBuffers) {
|
|
149
149
|
values = await pathValueSerializer.deserialize(valueBuffers);
|
|
150
150
|
ActionsHistory.OnRead(values);
|
|
151
|
+
recordPathValuesReceived(values.length);
|
|
151
152
|
}
|
|
152
153
|
|
|
153
154
|
if (initialCreation) {
|
|
@@ -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
|
-
|
|
173
|
-
if (templateVariables.length === 0 && !anyVariablesSet) return undefined;
|
|
172
|
+
if (targets.length === 0) return undefined;
|
|
174
173
|
|
|
175
|
-
const
|
|
174
|
+
const updateTargets = (change: (targets: ReturnType<typeof getMachineTargets>) => void) => {
|
|
176
175
|
let updated = deepCloneJSON(config);
|
|
177
176
|
let updatedTargets = getMachineTargets(updated.parameters);
|
|
178
|
-
|
|
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,69 +206,162 @@ class FunctionRunnersSection extends qreact.Component {
|
|
|
149
206
|
}
|
|
150
207
|
}
|
|
151
208
|
|
|
152
|
-
function
|
|
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
|
-
|
|
158
|
-
|
|
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
|
|
162
|
-
let lines: LatencyGraphLabelLine[] = [{ text: `${thread}:${parts?.port ?? "?"}` }];
|
|
163
|
-
// The last segment of the entry-point path is a good human name, and more useful than the machine id.
|
|
287
|
+
let machineId = parts?.machineId || nodeId;
|
|
164
288
|
let entryPoint = info?.entryPoint || runner?.entryPoint;
|
|
165
|
-
let name = entryPoint
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
lines.push({ text: machine });
|
|
170
|
-
// 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 = "";
|
|
171
292
|
if (traffic) {
|
|
172
293
|
let totals = nodeTotalTraffic(traffic);
|
|
173
294
|
let sum = totals.sent + totals.received;
|
|
174
295
|
if (sum > 0) {
|
|
175
296
|
let outsidePct = Math.round((totals.outside / sum) * 100);
|
|
176
|
-
|
|
297
|
+
data = `↑${formatNumber(totals.sent)}B ↓${formatNumber(totals.received)}B ${outsidePct}%⊘`;
|
|
177
298
|
}
|
|
178
299
|
}
|
|
300
|
+
|
|
301
|
+
let fnParts: string[] = [];
|
|
179
302
|
if (runner) {
|
|
180
|
-
|
|
181
|
-
for (let network of runner.networks) {
|
|
182
|
-
lines.push({ text: network, color: FUNCTION_COLOR });
|
|
183
|
-
}
|
|
303
|
+
if (runner.networks.length) fnParts.push(runner.networks.join(" "));
|
|
184
304
|
for (let shard of runner.shards) {
|
|
185
|
-
|
|
186
|
-
}
|
|
187
|
-
lines.push({ text: `${formatNumber(traffic?.functionsExecuted ?? 0)} calls`, color: FUNCTION_COLOR });
|
|
188
|
-
if (!runner.isPublic) {
|
|
189
|
-
lines.push({ text: "PRIVATE" });
|
|
305
|
+
fnParts.push(`fn ${shard.shardRange.startFraction.toFixed(3)}-${shard.shardRange.endFraction.toFixed(3)}`);
|
|
190
306
|
}
|
|
307
|
+
fnParts.push(`${formatNumber(traffic?.functionsExecuted ?? 0)} calls`);
|
|
308
|
+
if (!runner.isPublic) fnParts.push("PRIVATE");
|
|
191
309
|
}
|
|
192
|
-
|
|
310
|
+
|
|
311
|
+
let pvParts: string[] = [];
|
|
193
312
|
let spec = info?.spec;
|
|
194
313
|
if (spec && spec.routeStart >= 0 && spec.routeEnd >= 0) {
|
|
195
|
-
|
|
314
|
+
pvParts.push(`pv ${spec.routeStart.toFixed(3)}-${spec.routeEnd.toFixed(3)}`);
|
|
196
315
|
}
|
|
197
|
-
if (traffic
|
|
198
|
-
|
|
316
|
+
if (traffic && (traffic.pathValuesSent || traffic.pathValuesReceived)) {
|
|
317
|
+
pvParts.push(`↑${formatNumber(traffic.pathValuesSent)} ↓${formatNumber(traffic.pathValuesReceived)} values`);
|
|
199
318
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
+
};
|
|
205
333
|
}
|
|
206
334
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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>;
|
|
213
364
|
}
|
|
214
|
-
return { sent, received, outside: traffic.outside.sent + traffic.outside.received };
|
|
215
365
|
}
|
|
216
366
|
|
|
217
367
|
class LatencyGraphSection extends qreact.Component<{ nodeIds: string[]; infos: NodeAuthorityInfo[] }> {
|
|
@@ -235,11 +385,50 @@ class LatencyGraphSection extends qreact.Component<{ nodeIds: string[]; infos: N
|
|
|
235
385
|
let traffic = synced.getNodeTrafficStats(nodeId);
|
|
236
386
|
if (traffic) trafficMaps.set(nodeId, traffic);
|
|
237
387
|
}
|
|
238
|
-
// Only
|
|
388
|
+
// Only graph nodes that reported their latencies — ones that never respond probably don't exist.
|
|
239
389
|
let respondedSet = new Set(latencyMaps.keys());
|
|
240
390
|
|
|
241
|
-
//
|
|
242
|
-
|
|
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.
|
|
243
432
|
let pairKey = (a: string, b: string) => a < b ? `${a}|${b}` : `${b}|${a}`;
|
|
244
433
|
let pairTraffic = new Map<string, number>();
|
|
245
434
|
for (let [reporter, traffic] of trafficMaps) {
|
|
@@ -250,28 +439,69 @@ class LatencyGraphSection extends qreact.Component<{ nodeIds: string[]; infos: N
|
|
|
250
439
|
}
|
|
251
440
|
}
|
|
252
441
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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);
|
|
256
473
|
return {
|
|
257
|
-
id:
|
|
258
|
-
labelLines:
|
|
259
|
-
weight: totals
|
|
474
|
+
id: machineId,
|
|
475
|
+
labelLines: machineLabelLines({ machineId, threads: allThreads, trafficMaps, ip: machineIp.get(machineId) }),
|
|
476
|
+
weight: totals.dataSent + totals.dataReceived,
|
|
260
477
|
};
|
|
261
478
|
});
|
|
262
479
|
let links: LatencyGraphLink[] = [];
|
|
263
|
-
for (let [
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
links.push({ source: sourceId, destination: destId, latencyMs, weight: pairTraffic.get(pairKey(sourceId, destId)) || 0 });
|
|
267
|
-
}
|
|
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 });
|
|
268
483
|
}
|
|
269
484
|
|
|
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}`);
|
|
491
|
+
|
|
270
492
|
return <div className={css.vbox(8).fillWidth}>
|
|
271
|
-
<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>
|
|
272
494
|
<div className={css.relative.fillWidth.height(LATENCY_GRAPH_HEIGHT_PX).bord2(0, 0, 85)}>
|
|
273
|
-
<LatencyGraph
|
|
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
|
+
/>
|
|
274
502
|
</div>
|
|
503
|
+
<h2 className={css.margin(0)}>Nodes ({rows.length})</h2>
|
|
504
|
+
<NodeInfoTable rows={rows} selectedMachine={selected} />
|
|
275
505
|
</div>;
|
|
276
506
|
}
|
|
277
507
|
}
|