querysub 0.521.0 → 0.523.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.
@@ -2,8 +2,6 @@
2
2
 
3
3
  process.argv.push("--nobreak");
4
4
  process.argv.push("--public");
5
- process.argv.push("--network");
6
- process.argv.push("public");
7
5
 
8
6
  require("typenode");
9
7
  require("../src/3-path-functions/PathFunctionRunnerMain");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.521.0",
3
+ "version": "0.523.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",
@@ -2,9 +2,14 @@
2
2
  Per-server, in-memory traffic counters (never written to the database). Everything is just an integer increment
3
3
  into a 5-minute bucket; we keep the last 60 minutes (12 buckets) and drop older ones, so memory is bounded.
4
4
 
5
+ Reads return PER-SECOND RATES: the window's total divided by how long data has ACTUALLY been collecting in that
6
+ window — from the later of the window's start and when we first saw any data, up to now (so it's dynamic, and on a
7
+ server that just started it divides by the real short duration, not the full window). Under a second of data falls
8
+ back to the window length so we never divide by ~0.
9
+
5
10
  Byte traffic comes from SocketFunction.trackMessageSizes (upload/download per connection). The connection nodeId is
6
11
  sometimes a raw client-connection id, so we map it to the nice node id (debugNodeId) lazily at read time — the hot
7
- path is just a Map increment keyed by the raw id. At read we split traffic into per-known-node buckets and a single
12
+ path is just a Map increment keyed by the raw id. At read we split traffic into per-known-node rates and a single
8
13
  "outside the network" aggregate for anything not in our cached node list (clients, etc.).
9
14
  */
10
15
 
@@ -15,44 +20,40 @@ import { requiresNetworkTrustHook } from "../-d-trust/NetworkTrust2";
15
20
  import { getCachedNodeIds } from "./NodeDiscovery";
16
21
  import { debugNodeId } from "../-c-identity/IdentityController";
17
22
 
18
- const BUCKET_MS = timeInMinute * 5;
19
- const BUCKET_COUNT = 12;
23
+ const BUCKET_SIZE = timeInMinute * 5;
24
+ const BUCKET_HISTORY = BUCKET_SIZE * 20;
20
25
  // Sentinel key for traffic that isn't a network node (client connections, etc.). Aggregated so memory stays O(nodes),
21
26
  // not O(client connections) — clients churn through many raw ids per hour and we never want a bucket per client.
22
27
  const OUTSIDE_KEY = "";
23
28
 
24
29
  export type NodeDataTraffic = { sent: number; received: number; };
30
+ // All numbers are PER-SECOND rates (see file header).
25
31
  export type TrafficStats = {
26
32
  functionsExecuted: number;
27
33
  pathValuesSent: number;
28
34
  pathValuesReceived: number;
29
35
  querysubCalls: number;
30
- // Bytes to/from each known network node, plus one aggregate for everything outside the node list.
36
+ // Byte rates to/from each known network node, plus one aggregate for everything outside the node list.
31
37
  perNode: { [nodeId: string]: NodeDataTraffic };
32
38
  outside: NodeDataTraffic;
33
39
  };
34
40
 
35
- function currentBucket() {
36
- return Math.floor(Date.now() / BUCKET_MS);
37
- }
38
- function oldestBucket() {
39
- return currentBucket() - BUCKET_COUNT + 1;
40
- }
41
+ // bucket index => amount accumulated in that bucket
42
+ type BucketMap = Map<number, number>;
41
43
 
42
- // bucket => count
43
- let functionsExecuted = new Map<number, number>();
44
- let pathValuesSent = new Map<number, number>();
45
- let pathValuesReceived = new Map<number, number>();
46
- let querysubCalls = new Map<number, number>();
47
- // raw connection nodeId => (bucket => bytes)
48
- let bytesSent = new Map<string, Map<number, number>>();
49
- let bytesReceived = new Map<string, Map<number, number>>();
44
+ let functionsExecuted: BucketMap = new Map();
45
+ let pathValuesSent: BucketMap = new Map();
46
+ let pathValuesReceived: BucketMap = new Map();
47
+ let querysubCalls: BucketMap = new Map();
48
+ // raw connection nodeId => buckets
49
+ let bytesSent = new Map<string, BucketMap>();
50
+ let bytesReceived = new Map<string, BucketMap>();
50
51
 
51
- function addSimple(buckets: Map<number, number>, amount: number) {
52
- let bucket = currentBucket();
52
+ function addSimple(buckets: BucketMap, amount: number) {
53
+ let bucket = Math.floor(Date.now() / BUCKET_SIZE) * BUCKET_SIZE;
53
54
  buckets.set(bucket, (buckets.get(bucket) || 0) + amount);
54
55
  }
55
- function addKeyed(map: Map<string, Map<number, number>>, key: string, amount: number) {
56
+ function addKeyed(map: Map<string, BucketMap>, key: string, amount: number) {
56
57
  let buckets = map.get(key);
57
58
  if (!buckets) {
58
59
  buckets = new Map();
@@ -84,29 +85,39 @@ function trafficKey(nodeId: string): string {
84
85
  SocketFunction.trackMessageSizes.upload.push((size, nodeId) => addKeyed(bytesSent, trafficKey(nodeId), size));
85
86
  SocketFunction.trackMessageSizes.download.push((size, nodeId) => addKeyed(bytesReceived, trafficKey(nodeId), size));
86
87
 
87
- function sumRecent(buckets: Map<number, number>): number {
88
- let oldest = oldestBucket();
88
+ // Per-second rate: total within the window divided by the actual span the matched buckets cover (oldest bucket's start
89
+ // time now). Also prunes expired buckets as it goes.
90
+ function bucketRate(buckets: BucketMap, windowSize: number): number {
91
+ let now = Date.now();
92
+ let retentionOldest = now - BUCKET_HISTORY;
93
+ let targetTime = now - windowSize;
89
94
  let total = 0;
95
+ let oldestTime = now;
90
96
  for (let [bucket, count] of Array.from(buckets)) {
91
- if (bucket < oldest) {
97
+ if (bucket < retentionOldest) {
92
98
  buckets.delete(bucket);
93
99
  continue;
94
100
  }
101
+ if (bucket < targetTime) continue;
102
+ oldestTime = Math.min(oldestTime, bucket);
95
103
  total += count;
96
104
  }
97
- return total;
105
+ if (oldestTime === now) {
106
+ oldestTime = targetTime;
107
+ }
108
+ return total / ((now - oldestTime) / 1000);
98
109
  }
99
110
 
100
- function aggregateData(): { perNode: { [nodeId: string]: NodeDataTraffic }; outside: NodeDataTraffic; } {
111
+ function aggregateData(windowSize: number): { perNode: { [nodeId: string]: NodeDataTraffic }; outside: NodeDataTraffic; } {
101
112
  let known = new Set(getCachedNodeIds());
102
113
  let perNode: { [nodeId: string]: NodeDataTraffic } = {};
103
114
  let outside: NodeDataTraffic = { sent: 0, received: 0 };
104
115
  // At most (node count + 1) keys, so this whole pass is O(nodes). debugNodeId (a linear lookup) is only used for
105
116
  // the rare server-style id that isn't already a known node.
106
- let attribute = (rawNodeId: string, field: "sent" | "received", amount: number) => {
107
- if (amount <= 0) return;
117
+ let attribute = (rawNodeId: string, field: "sent" | "received", rate: number) => {
118
+ if (rate <= 0) return;
108
119
  if (rawNodeId === OUTSIDE_KEY) {
109
- outside[field] += amount;
120
+ outside[field] += rate;
110
121
  return;
111
122
  }
112
123
  let nice = known.has(rawNodeId) ? rawNodeId : debugNodeId(rawNodeId);
@@ -116,37 +127,37 @@ function aggregateData(): { perNode: { [nodeId: string]: NodeDataTraffic }; outs
116
127
  entry = { sent: 0, received: 0 };
117
128
  perNode[nice] = entry;
118
129
  }
119
- entry[field] += amount;
130
+ entry[field] += rate;
120
131
  } else {
121
- outside[field] += amount;
132
+ outside[field] += rate;
122
133
  }
123
134
  };
124
135
  for (let [rawNodeId, buckets] of Array.from(bytesSent)) {
125
- attribute(rawNodeId, "sent", sumRecent(buckets));
136
+ attribute(rawNodeId, "sent", bucketRate(buckets, windowSize));
126
137
  if (buckets.size === 0) bytesSent.delete(rawNodeId);
127
138
  }
128
139
  for (let [rawNodeId, buckets] of Array.from(bytesReceived)) {
129
- attribute(rawNodeId, "received", sumRecent(buckets));
140
+ attribute(rawNodeId, "received", bucketRate(buckets, windowSize));
130
141
  if (buckets.size === 0) bytesReceived.delete(rawNodeId);
131
142
  }
132
143
  return { perNode, outside };
133
144
  }
134
145
 
135
- export function getTrafficStats(): TrafficStats {
136
- let data = aggregateData();
146
+ export function getTrafficStats(windowSize: number): TrafficStats {
147
+ let data = aggregateData(windowSize);
137
148
  return {
138
- functionsExecuted: sumRecent(functionsExecuted),
139
- pathValuesSent: sumRecent(pathValuesSent),
140
- pathValuesReceived: sumRecent(pathValuesReceived),
141
- querysubCalls: sumRecent(querysubCalls),
149
+ functionsExecuted: bucketRate(functionsExecuted, windowSize),
150
+ pathValuesSent: bucketRate(pathValuesSent, windowSize),
151
+ pathValuesReceived: bucketRate(pathValuesReceived, windowSize),
152
+ querysubCalls: bucketRate(querysubCalls, windowSize),
142
153
  perNode: data.perNode,
143
154
  outside: data.outside,
144
155
  };
145
156
  }
146
157
 
147
158
  class TrafficControllerBase {
148
- public async getTrafficStats(): Promise<TrafficStats> {
149
- return getTrafficStats();
159
+ public async getTrafficStats(windowSize: number): Promise<TrafficStats> {
160
+ return getTrafficStats(windowSize);
150
161
  }
151
162
  }
152
163
  export const TrafficController = SocketFunction.register(
@@ -34,7 +34,7 @@ export async function startupAuthority(spec: AuthoritySpec) {
34
34
  pathValues: values,
35
35
  parentSyncs: [],
36
36
  initialTriggers: { values: new Set(), parentPaths: new Set() },
37
- doNotArchive: new Set(values),
37
+ doNotArchive: true,
38
38
  });
39
39
  console.log(blue(`Finished ingesting values from source ${source.nodeId} (values: ${formatNumber(values.length)}`), { nodeId: source.nodeId, spec: debugSpec(source), values: values.length });
40
40
  }
@@ -62,7 +62,7 @@ export async function startupAuthority(spec: AuthoritySpec) {
62
62
  let flat = Object.values(snapshot.values).flat();
63
63
  console.log(blue(`Loaded snapshot in memory, found ${formatNumber(flat.length)} values`), { spec: debugSpec(spec), values: flat.length });
64
64
  // NOTE: If it's on disk, it can't be rejected, so there's no point in passing it through our validStateComputer (and passing it through is VERY slow).
65
- authorityStorage.ingestValues(flat, { doNotArchive: new Set(flat) });
65
+ authorityStorage.ingestValues(flat, { doNotArchive: true });
66
66
  console.log(blue(`Finished ingesting values from snapshot, found ${formatNumber(flat.length)} values`), { spec: debugSpec(spec), values: flat.length });
67
67
  })();
68
68
 
@@ -21,7 +21,7 @@ import { getOwnNodeId } from "../-f-node-discovery/NodeDiscovery";
21
21
  import { getScheduledShutdownTime } from "../-g-core-values/scheduledShutdown";
22
22
  import { SocketFunction } from "socket-function/SocketFunction";
23
23
  import { requiresNetworkTrustHook } from "../-d-trust/NetworkTrust2";
24
- import { getDomain, isPublic, DEFAULT_NETWORK } from "../config";
24
+ import { getDomain, isPublic } from "../config";
25
25
  import { getGitRefSync, getGitURLSync } from "../4-deploy/git";
26
26
  import type { DeployProgress } from "../4-deploy/deployFunctions";
27
27
  import { getRoutingOverride, getRoutingOverridePart } from "../0-path-value-core/PathRouterRouteOverride";
@@ -363,14 +363,15 @@ export class PathFunctionRunner {
363
363
  secondaryShardRange?: { startFraction: number, endFraction: number };
364
364
  PermissionsChecker: PermissionsCheckType | undefined;
365
365
  // The networks we listen to calls on. Unset is equivalent to ["default"].
366
- networks?: string[];
366
+ networks: string[];
367
367
  }) {
368
+ if (!config.networks.length) throw new Error(`networks is required`);
368
369
  PathFunctionRunner.hostControllers();
369
370
  debugFunctionRunnerShards.push({
370
371
  domainName: config.domainName,
371
372
  shardRange: config.shardRange,
372
373
  secondaryShardRange: config.secondaryShardRange,
373
- networks: config.networks && config.networks.length > 0 && config.networks || [DEFAULT_NETWORK],
374
+ networks: config.networks,
374
375
  isPublic: isPublic(),
375
376
  });
376
377
  logErrors(this.startWatching());
@@ -401,7 +402,7 @@ export class PathFunctionRunner {
401
402
 
402
403
  let outstandingCalls = 0;
403
404
 
404
- let networks = this.config.networks && this.config.networks.length > 0 && this.config.networks || [DEFAULT_NETWORK];
405
+ let networks = this.config.networks;
405
406
 
406
407
  let watchModuleCalls = cache((moduleId: string) => {
407
408
  for (let network of networks) {
@@ -691,7 +692,7 @@ export class PathFunctionRunner {
691
692
  }
692
693
 
693
694
  let callNetwork = callSpec.network;
694
- let ourNetworks = this.config.networks && this.config.networks.length > 0 && this.config.networks || [DEFAULT_NETWORK];
695
+ let ourNetworks = this.config.networks;
695
696
  if (!ourNetworks.includes(callNetwork)) {
696
697
  return;
697
698
  }
@@ -15,7 +15,7 @@ import { SocketFunction } from "socket-function/SocketFunction";
15
15
  import { getThreadKeyCert } from "sliftutils/misc/https/certs";
16
16
  import { ClientWatcher } from "../1-path-client/pathValueClientWatcher";
17
17
  import { timeInMinute } from "socket-function/src/misc";
18
- import { getDomain, getNetworks, isLocal, isPublic, DEFAULT_NETWORK } from "../config";
18
+ import { getDomain, getNetworks, isLocal, isPublic } from "../config";
19
19
  import { green, magenta } from "socket-function/src/formatting/logColors";
20
20
  import path from "path";
21
21
  import { IndexedLogs } from "../diagnostics/logs/IndexedLogs/IndexedLogs";
@@ -32,6 +32,9 @@ async function main() {
32
32
  // ClientWatcher.DEBUG_TRIGGERS = "heavy";
33
33
  // authorityStorage.DEBUG_UNWATCH = true;
34
34
 
35
+ let networks = getNetworks();
36
+ if (!networks.length) throw new Error(`No networks found. Use --network to specify the networks this function runner should listen on.`);
37
+
35
38
  PathFunctionRunner.DEBUG_CALLS = true;
36
39
  // debugCoreMode();
37
40
 
@@ -60,8 +63,6 @@ async function main() {
60
63
  console.log(green(`Sharding from ${shardStart} to ${shardEnd}`));
61
64
  }
62
65
 
63
- let networks = getNetworks();
64
-
65
66
  new PathFunctionRunner({
66
67
  domainName: getDomain(),
67
68
  shardRange: { startFraction: shardStart, endFraction: shardEnd },
@@ -76,9 +77,5 @@ async function main() {
76
77
  let deployPath = path.resolve("./deploy.ts");
77
78
  await import(deployPath);
78
79
  }
79
-
80
- if (networks.length !== 1 || networks[0] !== DEFAULT_NETWORK) {
81
- console.log(magenta(`Only running functions on the network(s): ${networks.join(", ")}. Use --network ${networks[0]} in your http server to route calls here.`));
82
- }
83
80
  }
84
81
  logErrors(main());
@@ -308,7 +308,7 @@ function predictCallBase(config: {
308
308
  pathValues: predictions.writes,
309
309
  parentSyncs: [],
310
310
  initialTriggers: { values: new Set(), parentPaths: new Set() },
311
- doNotArchive: new Set(predictions.writes),
311
+ doNotArchive: true,
312
312
  });
313
313
 
314
314
  if (Querysub.DEBUG_PREDICTIONS) {
@@ -337,7 +337,7 @@ function predictCallBase(config: {
337
337
  pathValues: rejectedWrites,
338
338
  parentSyncs: [],
339
339
  initialTriggers: { values: new Set(), parentPaths: new Set() },
340
- doNotArchive: new Set(rejectedWrites),
340
+ doNotArchive: true,
341
341
  });
342
342
  }
343
343
 
package/src/config.ts CHANGED
@@ -44,8 +44,6 @@ if (isNode()) {
44
44
  }
45
45
  }
46
46
 
47
- export const DEFAULT_NETWORK = "default";
48
-
49
47
  export function expandHomePath(path: string): string {
50
48
  if (path === "~" || path.startsWith("~/") || path.startsWith("~\\")) {
51
49
  return os.homedir() + path.slice(1);
@@ -76,7 +74,7 @@ export function getNetworks(): string[] {
76
74
  result = [...result, ...networkFileNetworks()];
77
75
  }
78
76
  result = Array.from(new Set(result));
79
- if (result.length === 0) return [DEFAULT_NETWORK];
77
+ if (result.length === 0) return [];
80
78
  return result;
81
79
  }
82
80
 
@@ -14,7 +14,7 @@ import { getSyncedController } from "../../library-components/SyncedController";
14
14
  import { assertIsManagementUser } from "../managementPages";
15
15
  import { NodeCapabilitiesController } from "../../-g-core-values/NodeCapabilities";
16
16
  import { timeoutToUndefinedSilent } from "../../errors";
17
- import { sort } from "socket-function/src/misc";
17
+ import { sort, timeInMinute } from "socket-function/src/misc";
18
18
  import type { AuthoritySpec } from "../../0-path-value-core/PathRouter";
19
19
  import { getFunctionRunnerIndex, FunctionRunnerNodeInfo } from "../../4-querysub/FunctionRunnerTracking";
20
20
  import { formatTime, formatNumber } from "socket-function/src/formatting/format";
@@ -34,11 +34,13 @@ const LATENCY_GRAPH_HEIGHT_PX = 720;
34
34
 
35
35
  // Clicking a machine node in the graph (or a row) sorts that machine's nodes to the top of the table.
36
36
  const selectedMachineParam = new URLParam("rtSelectedMachine", "");
37
+ const trafficWindowParam = new URLParam<number>("rtTrafficWindow", timeInMinute * 60);
37
38
 
38
39
  // The per-node table columns, in order. `color` tints the function/path-value/querysub columns to match the graph;
39
40
  // `perMachine` columns (the IP and machine id) only print on the first row of each machine group.
40
41
  const NODE_TABLE_COLUMNS: { key: keyof NodeRow; label: string; width: number; color?: string; perMachine?: boolean; }[] = [
41
42
  { key: "ip", label: "IP", width: 130, perMachine: true },
43
+ { key: "networks", label: "Networks", width: 180, color: QUERYSUB_COLOR, perMachine: true },
42
44
  { key: "machineShort", label: "Machine", width: 96, perMachine: true },
43
45
  { key: "threadPort", label: "Thread:Port", width: 130 },
44
46
  { key: "name", label: "Name", width: 160 },
@@ -52,6 +54,7 @@ type NodeRow = {
52
54
  nodeId: string;
53
55
  machineId: string;
54
56
  ip: string;
57
+ networks: string;
55
58
  machineShort: string;
56
59
  threadPort: string;
57
60
  name: string;
@@ -104,9 +107,10 @@ class RoutingTablePageControllerBase {
104
107
  public async getNodeLatencies(nodeId: string): Promise<{ [nodeId: string]: number } | undefined> {
105
108
  return timeoutToUndefinedSilent(PROBE_TIMEOUT_MS, LatencyController.nodes[nodeId].getLatencies());
106
109
  }
107
- // Renamed (was getNodeTraffic) because the returned shape changed a new name busts any stale synced-call cache.
108
- public async getNodeTrafficStats(nodeId: string): Promise<TrafficStats | undefined> {
109
- return timeoutToUndefinedSilent(PROBE_TIMEOUT_MS, TrafficController.nodes[nodeId].getTrafficStats());
110
+ // Values are now per-second rates over the window (were near-totals). Renamed so a stale synced-call cache from the
111
+ // old meaning is dropped instead of being served forever.
112
+ public async getNodeTrafficRates(nodeId: string, windowSize: number): Promise<TrafficStats | undefined> {
113
+ return timeoutToUndefinedSilent(PROBE_TIMEOUT_MS, TrafficController.nodes[nodeId].getTrafficStats(windowSize));
110
114
  }
111
115
  }
112
116
 
@@ -116,7 +120,7 @@ export const RoutingTablePageController = SocketFunction.register(
116
120
  () => ({
117
121
  getAllNodeAuthoritySpecs: {},
118
122
  getNodeLatencies: {},
119
- getNodeTrafficStats: {},
123
+ getNodeTrafficRates: {},
120
124
  getNodeIps: {},
121
125
  }),
122
126
  () => ({
@@ -207,13 +211,15 @@ class FunctionRunnersSection extends qreact.Component {
207
211
  }
208
212
 
209
213
  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;
214
+ let outsideSent = traffic.outside?.sent || 0;
215
+ let outsideReceived = traffic.outside?.received || 0;
216
+ let sent = outsideSent;
217
+ let received = outsideReceived;
218
+ for (let d of Object.values(traffic.perNode || {})) {
219
+ sent += d?.sent || 0;
220
+ received += d?.received || 0;
215
221
  }
216
- return { sent, received, outside: traffic.outside.sent + traffic.outside.received };
222
+ return { sent, received, outside: outsideSent + outsideReceived };
217
223
  }
218
224
 
219
225
  function machineIdOf(nodeId: string): string {
@@ -235,40 +241,44 @@ function sumTraffic(threads: string[], trafficMaps: Map<string, TrafficStats>) {
235
241
  let totals = nodeTotalTraffic(traffic);
236
242
  dataSent += totals.sent;
237
243
  dataReceived += totals.received;
238
- valuesSent += traffic.pathValuesSent;
239
- valuesReceived += traffic.pathValuesReceived;
240
- calls += traffic.functionsExecuted;
241
- addCalls += traffic.querysubCalls;
244
+ valuesSent += traffic.pathValuesSent || 0;
245
+ valuesReceived += traffic.pathValuesReceived || 0;
246
+ calls += traffic.functionsExecuted || 0;
247
+ addCalls += traffic.querysubCalls || 0;
242
248
  }
243
249
  return { dataSent, dataReceived, valuesSent, valuesReceived, calls, addCalls };
244
250
  }
245
251
 
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.
252
+ // A machine's graph label: the IP first (most important), then its function-runner networks, then the same per-piece
253
+ // info as the table, summed across all its threads and colored the same.
248
254
  function machineLabelLines(config: {
249
255
  machineId: string;
250
256
  threads: string[];
251
257
  trafficMaps: Map<string, TrafficStats>;
252
258
  ip: string | undefined;
259
+ networks: string[];
253
260
  }): LatencyGraphLabelLine[] {
254
- let { machineId, threads, ip } = config;
261
+ let { machineId, threads, ip, networks } = config;
255
262
  let totals = sumTraffic(threads, config.trafficMaps);
256
263
  let lines: LatencyGraphLabelLine[] = [];
257
264
  if (ip) {
258
265
  lines.push({ text: ip });
259
266
  }
267
+ if (networks.length) {
268
+ lines.push({ text: networks.join(" | "), color: QUERYSUB_COLOR });
269
+ }
260
270
  lines.push({ text: `${machineId.slice(0, ID_CHARS)} ${threads.length} threads` });
261
271
  if (totals.dataSent + totals.dataReceived > 0) {
262
- lines.push({ text: `↑${formatNumber(totals.dataSent)}B ↓${formatNumber(totals.dataReceived)}B` });
272
+ lines.push({ text: `↑${formatNumber(totals.dataSent)}B/s ↓${formatNumber(totals.dataReceived)}B/s` });
263
273
  }
264
274
  if (totals.valuesSent || totals.valuesReceived) {
265
- lines.push({ text: `↑${formatNumber(totals.valuesSent)} ↓${formatNumber(totals.valuesReceived)} values`, color: PATHVALUE_COLOR });
275
+ lines.push({ text: `↑${formatNumber(totals.valuesSent)}/s ↓${formatNumber(totals.valuesReceived)}/s values`, color: PATHVALUE_COLOR });
266
276
  }
267
277
  if (totals.calls) {
268
- lines.push({ text: `${formatNumber(totals.calls)} calls`, color: FUNCTION_COLOR });
278
+ lines.push({ text: `${formatNumber(totals.calls)}/s calls`, color: FUNCTION_COLOR });
269
279
  }
270
280
  if (totals.addCalls) {
271
- lines.push({ text: `${formatNumber(totals.addCalls)} addCalls`, color: QUERYSUB_COLOR });
281
+ lines.push({ text: `${formatNumber(totals.addCalls)}/s addCalls`, color: QUERYSUB_COLOR });
272
282
  }
273
283
  return lines;
274
284
  }
@@ -280,8 +290,9 @@ function buildNodeRow(config: {
280
290
  info: NodeAuthorityInfo | undefined;
281
291
  traffic: TrafficStats | undefined;
282
292
  machineIp: Map<string, string>;
293
+ machineNetworks: Map<string, string[]>;
283
294
  }): NodeRow {
284
- let { nodeId, runner, info, traffic, machineIp } = config;
295
+ let { nodeId, runner, info, traffic, machineIp, machineNetworks } = config;
285
296
  let parts = decodeNodeId(nodeId, getDomain(), "allowMissingThreadId");
286
297
  let thread = (parts?.threadId || "?").slice(0, ID_CHARS);
287
298
  let machineId = parts?.machineId || nodeId;
@@ -294,7 +305,7 @@ function buildNodeRow(config: {
294
305
  let sum = totals.sent + totals.received;
295
306
  if (sum > 0) {
296
307
  let outsidePct = Math.round((totals.outside / sum) * 100);
297
- data = `↑${formatNumber(totals.sent)}B ↓${formatNumber(totals.received)}B ${outsidePct}%⊘`;
308
+ data = `↑${formatNumber(totals.sent)}B/s ↓${formatNumber(totals.received)}B/s ${outsidePct}%⊘`;
298
309
  }
299
310
  }
300
311
 
@@ -304,7 +315,7 @@ function buildNodeRow(config: {
304
315
  for (let shard of runner.shards) {
305
316
  fnParts.push(`fn ${shard.shardRange.startFraction.toFixed(3)}-${shard.shardRange.endFraction.toFixed(3)}`);
306
317
  }
307
- fnParts.push(`${formatNumber(traffic?.functionsExecuted ?? 0)} calls`);
318
+ fnParts.push(`${formatNumber(traffic?.functionsExecuted ?? 0)}/s calls`);
308
319
  if (!runner.isPublic) fnParts.push("PRIVATE");
309
320
  }
310
321
 
@@ -314,14 +325,15 @@ function buildNodeRow(config: {
314
325
  pvParts.push(`pv ${spec.routeStart.toFixed(3)}-${spec.routeEnd.toFixed(3)}`);
315
326
  }
316
327
  if (traffic && (traffic.pathValuesSent || traffic.pathValuesReceived)) {
317
- pvParts.push(`↑${formatNumber(traffic.pathValuesSent)} ↓${formatNumber(traffic.pathValuesReceived)} values`);
328
+ pvParts.push(`↑${formatNumber(traffic.pathValuesSent)}/s ↓${formatNumber(traffic.pathValuesReceived)}/s values`);
318
329
  }
319
330
 
320
- let qs = traffic?.querysubCalls ? `${formatNumber(traffic.querysubCalls)} addCalls` : "";
331
+ let qs = traffic?.querysubCalls ? `${formatNumber(traffic.querysubCalls)}/s addCalls` : "";
321
332
  return {
322
333
  nodeId,
323
334
  machineId,
324
335
  ip: machineIp.get(machineId) || "",
336
+ networks: (machineNetworks.get(machineId) || []).join(" | "),
325
337
  machineShort: machineId.slice(0, ID_CHARS),
326
338
  threadPort: `${thread}:${parts?.port ?? "?"}`,
327
339
  name,
@@ -377,12 +389,13 @@ class LatencyGraphSection extends qreact.Component<{ nodeIds: string[]; infos: N
377
389
  let infoByNode = new Map(this.props.infos.map(info => [info.nodeId, info]));
378
390
 
379
391
  // One synced call per node for each data source; each resolves independently so the graph fills in progressively.
392
+ let trafficWindow = trafficWindowParam.value;
380
393
  let latencyMaps = new Map<string, { [nodeId: string]: number }>();
381
394
  let trafficMaps = new Map<string, TrafficStats>();
382
395
  for (let nodeId of nodeIds) {
383
396
  let latencies = synced.getNodeLatencies(nodeId);
384
397
  if (latencies) latencyMaps.set(nodeId, latencies);
385
- let traffic = synced.getNodeTrafficStats(nodeId);
398
+ let traffic = synced.getNodeTrafficRates(nodeId, trafficWindow);
386
399
  if (traffic) trafficMaps.set(nodeId, traffic);
387
400
  }
388
401
  // Only graph nodes that reported their latencies — ones that never respond probably don't exist.
@@ -428,14 +441,26 @@ class LatencyGraphSection extends qreact.Component<{ nodeIds: string[]; infos: N
428
441
  if (bestIp) machineIp.set(machineId, bestIp);
429
442
  }
430
443
 
444
+ // The unique set of function-runner networks running on each machine (usually one, human-readable).
445
+ let machineNetworks = new Map<string, string[]>();
446
+ for (let [machineId, threads] of machineAllThreads) {
447
+ let unique = new Set<string>();
448
+ for (let thread of threads) {
449
+ for (let network of runnerByNode.get(thread)?.networks || []) {
450
+ unique.add(network);
451
+ }
452
+ }
453
+ if (unique.size) machineNetworks.set(machineId, [...unique]);
454
+ }
455
+
431
456
  // Total bytes on each thread-pair link (both directions). Both endpoints report the same total, so take the max.
432
457
  let pairKey = (a: string, b: string) => a < b ? `${a}|${b}` : `${b}|${a}`;
433
458
  let pairTraffic = new Map<string, number>();
434
459
  for (let [reporter, traffic] of trafficMaps) {
435
- for (let [peer, data] of Object.entries(traffic.perNode)) {
460
+ for (let [peer, data] of Object.entries(traffic.perNode || {})) {
436
461
  if (reporter === peer || !respondedSet.has(peer)) continue;
437
462
  let key = pairKey(reporter, peer);
438
- pairTraffic.set(key, Math.max(pairTraffic.get(key) || 0, data.sent + data.received));
463
+ pairTraffic.set(key, Math.max(pairTraffic.get(key) || 0, (data?.sent || 0) + (data?.received || 0)));
439
464
  }
440
465
  }
441
466
 
@@ -472,7 +497,7 @@ class LatencyGraphSection extends qreact.Component<{ nodeIds: string[]; infos: N
472
497
  let totals = sumTraffic(allThreads, trafficMaps);
473
498
  return {
474
499
  id: machineId,
475
- labelLines: machineLabelLines({ machineId, threads: allThreads, trafficMaps, ip: machineIp.get(machineId) }),
500
+ labelLines: machineLabelLines({ machineId, threads: allThreads, trafficMaps, ip: machineIp.get(machineId), networks: machineNetworks.get(machineId) || [] }),
476
501
  weight: totals.dataSent + totals.dataReceived,
477
502
  };
478
503
  });
@@ -484,22 +509,35 @@ class LatencyGraphSection extends qreact.Component<{ nodeIds: string[]; infos: N
484
509
 
485
510
  // One row per thread node, grouped so a machine's threads sit together; the selected machine sorts to the top.
486
511
  // 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 }));
512
+ let rows = nodeIds.map(nodeId => buildNodeRow({ nodeId, runner: runnerByNode.get(nodeId), info: infoByNode.get(nodeId), traffic: trafficMaps.get(nodeId), machineIp, machineNetworks }));
488
513
  let selected = selectedMachineParam.value;
489
514
  let sep = String.fromCharCode(1);
490
515
  sort(rows, row => `${row.machineId === selected ? 0 : 1}${sep}${row.machineId}${sep}${row.threadPort}`);
491
516
 
492
517
  return <div className={css.vbox(8).fillWidth}>
493
- <h2 className={css.margin(0)}>Latency Graph ({machineRespondedThreads.size} machines · {respondedSet.size}/{nodeIds.length} nodes reported)</h2>
518
+ <div className={css.hbox(14)}>
519
+ <h2 className={css.margin(0)}>Latency Graph ({machineRespondedThreads.size} machines · {respondedSet.size}/{nodeIds.length} nodes reported)</h2>
520
+ <div className={css.hbox(0).bord2(0, 0, 70)}>
521
+ {([timeInMinute * 5, timeInMinute * 60]).map(windowSize => {
522
+ let selected = trafficWindow === windowSize;
523
+ return <div
524
+ className={css.pad2(12, 6).cursor("pointer")
525
+ .hsl(210, selected ? 70 : 0, selected ? 45 : 96).colorhsl(0, 0, selected ? 100 : 30)}
526
+ onMouseDown={() => trafficWindowParam.value = windowSize}
527
+ >{formatTime(windowSize)}</div>;
528
+ })}
529
+ </div>
530
+ </div>
494
531
  <div className={css.relative.fillWidth.height(LATENCY_GRAPH_HEIGHT_PX).bord2(0, 0, 85)}>
495
532
  <LatencyGraph
496
533
  nodes={nodes}
497
534
  links={links}
498
- formatWeight={weight => formatNumber(weight) + "B"}
535
+ formatWeight={weight => formatNumber(weight) + "B/s"}
499
536
  selectedId={selected || undefined}
500
537
  onSelectNode={id => selectedMachineParam.value = selectedMachineParam.value === id ? "" : id}
501
538
  />
502
539
  </div>
540
+ <div className={css.colorhsl(0, 0, 50)}>Click a node to sort its information to the top of the table below.</div>
503
541
  <h2 className={css.margin(0)}>Nodes ({rows.length})</h2>
504
542
  <NodeInfoTable rows={rows} selectedMachine={selected} />
505
543
  </div>;
@@ -23,11 +23,12 @@ export type LatencyGraphProps = {
23
23
  const DEFAULT_CONNECTIONS = 4;
24
24
  // Nearest neighbors drawn per node (the layout still uses every measured latency; this only thins the drawn lines).
25
25
  const connectionsParam = new URLParam("lgConnections", DEFAULT_CONNECTIONS);
26
- const DEFAULT_LATENCY_EXPONENT = 1;
26
+ const DEFAULT_LATENCY_EXPONENT = 0.5;
27
27
  // Exponent on (latency / reference) when mapping to layout distance, so far nodes push apart harder as it grows.
28
28
  const latencyExponentParam = new URLParam("lgLatencyExponent", DEFAULT_LATENCY_EXPONENT);
29
29
  const geoParam = new URLParam("lgGeo", false);
30
- const showLatenciesParam = new URLParam("lgShowLatencies", false);
30
+ // On by default: each drawn connection shows its latency and (when a weight formatter is given) its traffic.
31
+ const showLatenciesParam = new URLParam("lgShowLatencies", true);
31
32
  // The config panel is collapsed by default so it doesn't eat the canvas; clicking the header expands it.
32
33
  const configOpenParam = new URLParam("lgConfigOpen", false);
33
34
  // Pixels per degree of latitude/longitude in geographic mode (equirectangular projection).
@@ -45,6 +46,9 @@ const POWER_EPS = 1e-9;
45
46
  // with a floor. Normalized to the median so the overall scale is latency-magnitude independent.
46
47
  const DIST_MIN_PX = 8;
47
48
  const DIST_UNIT_PX = 90;
49
+ // A touch of deterministic jitter on the MDS init breaks symmetry, so a handful of nodes settle into a spread (a
50
+ // triangle for three) instead of collapsing onto a single line.
51
+ const INIT_JITTER_PX = 15;
48
52
 
49
53
  const MIN_OPACITY = 0.05;
50
54
  const NODE_RADIUS = 6;
@@ -65,9 +69,6 @@ const FIT_PAD = 60;
65
69
  const FIT_PAD_TOP = 160;
66
70
  // Auto-fit leaves a bit of extra breathing room by not zooming all the way in to fill the padded box.
67
71
  const FIT_ZOOM = 0.7;
68
- // Cursor must be within this many screen pixels of a node to hover/click it.
69
- const HOVER_HIT_PX = 60;
70
- const CLICK_HIT_PX = 40;
71
72
 
72
73
  type Edge = { a: number; b: number; latency: number; };
73
74
  type SolveEdge = { a: number; b: number; target: number; weight: number; };
@@ -293,6 +294,11 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
293
294
  });
294
295
  this.positionsX = pos.x;
295
296
  this.positionsY = pos.y;
297
+ // Deterministic per-node jitter (different frequencies for x/y so it isn't itself collinear) to break symmetry.
298
+ for (let i = 0; i < n; i++) {
299
+ this.positionsX[i] += Math.sin(i * 12.9898 + 1) * INIT_JITTER_PX;
300
+ this.positionsY[i] += Math.cos(i * 78.233 + 1) * INIT_JITTER_PX;
301
+ }
296
302
  }
297
303
 
298
304
  // Classical MDS on an m-point set given a squared-target-distance function; top-2 eigenvectors.
@@ -622,6 +628,7 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
622
628
  let hover = this.hoverNode;
623
629
  let connections = (this.neighbors[hover] || []).map(nb => nb.edge);
624
630
 
631
+ // Just highlight the hovered node's connections — their latency/traffic labels are already drawn all the time.
625
632
  ctx.lineWidth = 1.5;
626
633
  for (let edge of connections) {
627
634
  ctx.strokeStyle = "hsla(40, 90%, 60%, 0.6)";
@@ -630,19 +637,6 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
630
637
  ctx.lineTo(toScreenX(this.positionsX[edge.b]), toScreenY(this.positionsY[edge.b]));
631
638
  ctx.stroke();
632
639
  }
633
- ctx.font = "12px sans-serif";
634
- ctx.textAlign = "center";
635
- ctx.textBaseline = "middle";
636
- for (let edge of connections) {
637
- let midX = (toScreenX(this.positionsX[edge.a]) + toScreenX(this.positionsX[edge.b])) / 2;
638
- let midY = (toScreenY(this.positionsY[edge.a]) + toScreenY(this.positionsY[edge.b])) / 2;
639
- let label = formatTime(edge.latency);
640
- let textWidth = ctx.measureText(label).width;
641
- ctx.fillStyle = "hsla(0, 0%, 0%, 0.75)";
642
- ctx.fillRect(midX - textWidth / 2 - 3, midY - 8, textWidth + 6, 16);
643
- ctx.fillStyle = "hsl(40, 90%, 75%)";
644
- ctx.fillText(label, midX, midY);
645
- }
646
640
  // Redraw the hovered node's own label last so it always sits above the connection labels.
647
641
  let hoverLines = this.nodes[hover].labelLines;
648
642
  if (hoverLines && hoverLines.length) {
@@ -720,7 +714,7 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
720
714
  this.lastPointerY = e.clientY;
721
715
  };
722
716
 
723
- // Nearest node to a screen point, with its distance in pixels.
717
+ // Index of the node closest to a screen point (undefined only when there are no nodes).
724
718
  nearestNodeTo(mx: number, my: number) {
725
719
  let scale = this.viewScale;
726
720
  let bestDist = Infinity;
@@ -728,13 +722,13 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
728
722
  for (let i = 0; i < this.nodes.length; i++) {
729
723
  let sx = this.viewWidth / 2 + (this.positionsX[i] - this.centroidX) * scale + this.panX;
730
724
  let sy = this.viewHeight / 2 + (this.positionsY[i] - this.centroidY) * scale + this.panY;
731
- let dist = Math.sqrt((sx - mx) ** 2 + (sy - my) ** 2);
725
+ let dist = (sx - mx) ** 2 + (sy - my) ** 2;
732
726
  if (dist < bestDist) {
733
727
  bestDist = dist;
734
728
  best = i;
735
729
  }
736
730
  }
737
- return { node: best, dist: bestDist };
731
+ return best;
738
732
  }
739
733
 
740
734
  onMouseMove = (e: MouseEvent) => {
@@ -754,8 +748,8 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
754
748
  let my = e.clientY - rect.top;
755
749
  this.mouseX = mx;
756
750
  this.mouseY = my;
757
- let nearest = this.nearestNodeTo(mx, my);
758
- this.hoverNode = nearest.dist <= HOVER_HIT_PX ? nearest.node : undefined;
751
+ // Always hover whichever node the cursor is closest to, so moving anywhere explores the graph.
752
+ this.hoverNode = this.nearestNodeTo(mx, my);
759
753
  this.scheduleFrame();
760
754
  };
761
755
 
@@ -764,8 +758,8 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
764
758
  if (!canvas || !this.onSelectNode) return;
765
759
  let rect = canvas.getBoundingClientRect();
766
760
  let nearest = this.nearestNodeTo(e.clientX - rect.left, e.clientY - rect.top);
767
- if (nearest.node === undefined || nearest.dist > CLICK_HIT_PX) return;
768
- this.onSelectNode(this.nodes[nearest.node].id);
761
+ if (nearest === undefined) return;
762
+ this.onSelectNode(this.nodes[nearest].id);
769
763
  };
770
764
 
771
765
  onMouseLeave = () => {
@@ -827,22 +821,27 @@ export class LatencyGraph extends qreact.Component<LatencyGraphProps> {
827
821
  this.builtSig = sig;
828
822
  this.builtWeightSig = weightSig;
829
823
  this.build();
830
- } else if (weightSig !== this.builtWeightSig) {
831
- // Traffic weights changed but the node/link set didn't just refresh sizing, no re-layout.
832
- this.builtWeightSig = weightSig;
833
- this.computeWeights();
834
- } else if (latencyExponentParam.value !== this.builtLatencyExponent) {
835
- this.computeSolveEdges();
836
- this.applyLayout();
837
- } else if (Math.max(1, Math.floor(connectionsParam.value)) !== this.builtConnections) {
838
- this.computeRenderEdges();
824
+ } else {
825
+ // Same node/link counts (same order): refresh the node objects so label text that changed as data streamed
826
+ // in — or when the traffic window toggled — shows up, without disturbing the settled layout/positions.
827
+ this.nodes = this.props.nodes.slice();
828
+ if (weightSig !== this.builtWeightSig) {
829
+ this.builtWeightSig = weightSig;
830
+ this.computeWeights();
831
+ }
832
+ if (latencyExponentParam.value !== this.builtLatencyExponent) {
833
+ this.computeSolveEdges();
834
+ this.applyLayout();
835
+ } else if (Math.max(1, Math.floor(connectionsParam.value)) !== this.builtConnections) {
836
+ this.computeRenderEdges();
837
+ }
839
838
  }
840
839
  this.scheduleFrame();
841
840
 
842
841
  return <div className={css.relative.fillBoth.overflowHidden}>
843
842
  <canvas
844
843
  ref={elem => this.mountCanvas(elem ?? undefined)}
845
- className={css.absolute.pos(0, 0).fillBoth}
844
+ className={css.absolute.pos(0, 0).fillBoth.cursor("pointer")}
846
845
  />
847
846
  <div className={css.vbox(10).pad2(12).absolute.pos(0, 0).width(260).zIndex(2).hsla(0, 0, 8, 0.9).colorhsl(0, 0, 85)}>
848
847
  <div