querysub 0.457.0 → 0.458.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.457.0",
3
+ "version": "0.458.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",
@@ -23,6 +23,9 @@ import { debugGetAllCallFactories } from "socket-function/src/nodeCache";
23
23
  import { delay } from "socket-function/src/batching";
24
24
  export { pathValueCommitter };
25
25
 
26
+ let pathValueSendCount = 0;
27
+ export function getPathValueSendCount() { return pathValueSendCount; }
28
+
26
29
  // ONLY returns non-canGCValues that are valid
27
30
  export type AuditSnapshotEntry = {
28
31
  path: string;
@@ -47,6 +50,7 @@ export class PathValueControllerBase {
47
50
  const { Querysub } = await import("../4-querysub/Querysub");
48
51
  let { pathValues, nodeId } = config;
49
52
 
53
+ pathValueSendCount += pathValues.length;
50
54
  let serializedValues = await pathValueSerializer.serialize(pathValues, { compress: Querysub.COMPRESS_NETWORK });
51
55
  if (isDebugLogEnabled()) {
52
56
  for (let value of pathValues) {
@@ -70,6 +74,7 @@ export class PathValueControllerBase {
70
74
  nodeId: string;
71
75
  pathValues: PathValue[];
72
76
  initialTriggers?: { values: Set<string>; parentPaths: Set<string> },
77
+ reason: string;
73
78
  }) {
74
79
  let { nodeId, pathValues, initialTriggers } = config;
75
80
  if (isDebugLogEnabled()) {
@@ -88,6 +93,7 @@ export class PathValueControllerBase {
88
93
  initialTrigger: initialTriggers?.values.has(pathValue.path),
89
94
  triggerId: nextId(),
90
95
  totalChanges: pathValues.length,
96
+ reason: config.reason,
91
97
  });
92
98
  }
93
99
  }
@@ -96,14 +102,16 @@ export class PathValueControllerBase {
96
102
  nodeId: string;
97
103
  pathValues: PathValue[];
98
104
  initialTriggers?: { values: Set<string>; parentPaths: Set<string> },
105
+ reason: string;
99
106
  }) {
100
107
  let changes = config.pathValues;
101
108
  let { nodeId, initialTriggers } = config;
109
+ pathValueSendCount += changes.length;
102
110
  let buffers = await pathValueSerializer.serialize(changes, {
103
111
  noLocks: true,
104
112
  compress: getCompressNetwork(),
105
113
  });
106
- this.logSendValues({ nodeId, pathValues: changes, initialTriggers, });
114
+ this.logSendValues({ nodeId, pathValues: changes, initialTriggers, reason: config.reason });
107
115
  return await PathValueController.nodes[nodeId].sendData({
108
116
  valueBuffers: buffers,
109
117
  initialTriggers: initialTriggers,
@@ -201,6 +209,7 @@ export class PathValueControllerBase {
201
209
  await timeoutToError(timeInSecond * 15, PathValueControllerBase.sendValues({
202
210
  nodeId: otherAuthority,
203
211
  pathValues: values,
212
+ reason: "authorityShareValues",
204
213
  }), () => new Error(`Timeout forwarding shared values (${values.length} values) to authority ${otherAuthority}`));
205
214
  } catch (error: any) {
206
215
  console.error(error.message, { otherAuthority, count: values.length });
@@ -494,6 +494,7 @@ class PathWatcher {
494
494
  nodeId: watcher,
495
495
  pathValues: changes,
496
496
  initialTriggers: initialTriggers,
497
+ reason: "triggerLatestWatcher",
497
498
  });
498
499
  })());
499
500
  }
@@ -26,6 +26,11 @@ import { getRoutingOverride, getRoutingOverridePart } from "../0-path-value-core
26
26
  import { PathRouter } from "../0-path-value-core/PathRouter";
27
27
  setImmediate(() => import("../4-querysub/Querysub"));
28
28
 
29
+ let functionCallOuterCount = 0;
30
+ let functionCallInnerCount = 0;
31
+ export function getFunctionCallOuterCount() { return functionCallOuterCount; }
32
+ export function getFunctionCallInnerCount() { return functionCallInnerCount; }
33
+
29
34
  export const functionSchema = rawSchema<{
30
35
  [domainName: string]: {
31
36
  PathFunctionRunner: {
@@ -520,6 +525,7 @@ export class PathFunctionRunner {
520
525
 
521
526
  stats.isInsideRunCall = true;
522
527
  stats.outerLoopCount++;
528
+ functionCallOuterCount++;
523
529
  try {
524
530
  await this.runCallBase(callSpec, functionSpec, stats);
525
531
  } finally {
@@ -647,6 +653,7 @@ export class PathFunctionRunner {
647
653
  runCount++;
648
654
  stats.lastInternalLoopCount = runCount;
649
655
  stats.totalInternalLoopCount++;
656
+ functionCallInnerCount++;
650
657
  if (PathFunctionRunner.DEBUG_CALLS) {
651
658
  console.log(`Evaluating (try count ${runCount}) ${debugNameColored}`);
652
659
  }
@@ -488,6 +488,7 @@ export class QuerysubControllerBase {
488
488
  values: new Set(undefinedValues.map(value => value.path)),
489
489
  parentPaths: new Set(newParentPathsNotAllowed)
490
490
  },
491
+ reason: "QuerysubController.watch disallowed paths (permissions)",
491
492
  }));
492
493
  }
493
494
 
@@ -0,0 +1,102 @@
1
+ // Gross cluster statistics — per-minute counters of high-level activity, kept for a
2
+ // week per-node and polled by the management UI. See managementPages.tsx GrossStatsInfo / GrossStatsPage.
3
+ //
4
+ // Sources of stats live in their own modules and just maintain monotonic counters; this file
5
+ // imports those counters and samples them once a minute, recording deltas against the
6
+ // previous sample. Sources never depend on this file.
7
+
8
+ import { SocketFunction } from "socket-function/SocketFunction";
9
+ import { runInfinitePoll } from "socket-function/src/batching";
10
+ import { timeInMinute } from "socket-function/src/misc";
11
+ import { isNode } from "typesafecss";
12
+ import { getFunctionCallInnerCount, getFunctionCallOuterCount } from "../../3-path-functions/PathFunctionRunner";
13
+ import { getPathValueSendCount } from "../../0-path-value-core/PathValueController";
14
+ import { getLogWriteCount } from "../../diagnostics/logs/diskLogger";
15
+
16
+ export const GROSS_STATS_FIELDS = [
17
+ "functionCallsInner",
18
+ "functionCallsOuter",
19
+ "pathValuesSent",
20
+ "socketCalls",
21
+ "socketUploadBytes",
22
+ "socketDownloadBytes",
23
+ "socketCallTimeMs",
24
+ "logsWritten",
25
+ ] as const;
26
+ export type GrossStatsField = typeof GROSS_STATS_FIELDS[number];
27
+
28
+ export type GrossStatsBucket = {
29
+ // Epoch ms of the END of the minute this bucket covers
30
+ time: number;
31
+ deltas: Record<GrossStatsField, number>;
32
+ };
33
+
34
+ const BUCKETS_PER_WEEK = 60 * 24 * 7;
35
+
36
+ function zeroCounts(): Record<GrossStatsField, number> {
37
+ let out = {} as Record<GrossStatsField, number>;
38
+ for (let field of GROSS_STATS_FIELDS) out[field] = 0;
39
+ return out;
40
+ }
41
+
42
+ // socketUploadBytes / socketDownloadBytes / socketCallTimeMs come from SocketFunction callbacks
43
+ // rather than a foreign module's counter, so we maintain monotonic running totals locally.
44
+ let socketTotals = { uploadBytes: 0, downloadBytes: 0, callTimeMs: 0 };
45
+ let socketHooksInstalled = false;
46
+ function installSocketHooks() {
47
+ if (socketHooksInstalled) return;
48
+ socketHooksInstalled = true;
49
+ SocketFunction.trackMessageSizes.upload.push(size => {
50
+ socketTotals.uploadBytes += size;
51
+ });
52
+ SocketFunction.trackMessageSizes.download.push(size => {
53
+ socketTotals.downloadBytes += size;
54
+ });
55
+ SocketFunction.trackMessageSizes.callTimes.push(({ start, end }) => {
56
+ socketTotals.callTimeMs += Math.max(0, end - start);
57
+ });
58
+ }
59
+
60
+ function readCurrentTotals(): Record<GrossStatsField, number> {
61
+ return {
62
+ functionCallsInner: getFunctionCallInnerCount(),
63
+ functionCallsOuter: getFunctionCallOuterCount(),
64
+ pathValuesSent: getPathValueSendCount(),
65
+ socketCalls: SocketFunction.TOTAL_CALLS,
66
+ socketUploadBytes: socketTotals.uploadBytes,
67
+ socketDownloadBytes: socketTotals.downloadBytes,
68
+ socketCallTimeMs: socketTotals.callTimeMs,
69
+ logsWritten: getLogWriteCount(),
70
+ };
71
+ }
72
+
73
+ let prevSnapshot = zeroCounts();
74
+ let buckets: GrossStatsBucket[] = [];
75
+
76
+ function sampleMinute() {
77
+ let snapshot = readCurrentTotals();
78
+ let deltas = {} as Record<GrossStatsField, number>;
79
+ for (let field of GROSS_STATS_FIELDS) {
80
+ deltas[field] = snapshot[field] - prevSnapshot[field];
81
+ }
82
+ prevSnapshot = snapshot;
83
+ buckets.push({ time: Date.now(), deltas });
84
+ if (buckets.length > BUCKETS_PER_WEEK) {
85
+ buckets.splice(0, buckets.length - BUCKETS_PER_WEEK);
86
+ }
87
+ }
88
+
89
+ if (isNode()) {
90
+ installSocketHooks();
91
+ prevSnapshot = readCurrentTotals();
92
+ void runInfinitePoll(timeInMinute, sampleMinute);
93
+ }
94
+
95
+ export function getGrossStatsBuckets(sinceTime?: number): GrossStatsBucket[] {
96
+ if (sinceTime === undefined) return buckets;
97
+ let i = 0;
98
+ while (i < buckets.length && buckets[i].time <= sinceTime) {
99
+ i++;
100
+ }
101
+ return i === 0 ? buckets : buckets.slice(i);
102
+ }
@@ -984,13 +984,7 @@ export async function machineApplyMain() {
984
984
  await Querysub.hostService("machine-apply");
985
985
  onServiceConfigChange(resyncServices);
986
986
 
987
- runInfinitePoll(DEBUGGER_WEDGE_POLL_INTERVAL, async () => {
988
- try {
989
- await unwedgeStuckDebuggerScreens();
990
- } catch (e: any) {
991
- console.error(`Error in debugger-wedge watcher: ${e.stack ?? e}`);
992
- }
993
- });
987
+ runInfinitePoll(DEBUGGER_WEDGE_POLL_INTERVAL, unwedgeStuckDebuggerScreens);
994
988
 
995
989
  runInfinitePoll(timeInMinute * 3, async () => {
996
990
  //console.log(magenta(`Quick outdated check at ${new Date().toISOString()}`));
@@ -184,7 +184,7 @@ export async function auditDiskValues(spec: AuthoritySpec) {
184
184
  if (valuesToSync.length > 0) {
185
185
  let valuesPerAuthority = PathRouter.getAllAuthoritiesForValues(valuesToSync);
186
186
  for (let [authorityNodeId, values] of valuesPerAuthority) {
187
- await PathValueControllerBase.sendValues({ nodeId: authorityNodeId, pathValues: values });
187
+ await PathValueControllerBase.sendValues({ nodeId: authorityNodeId, pathValues: values, reason: "auditDiskValues" });
188
188
  }
189
189
  }
190
190
  let timeSync = Date.now() - timeStartSync;
@@ -0,0 +1,111 @@
1
+ import { SocketFunction } from "socket-function/SocketFunction";
2
+ import { lazy } from "socket-function/src/caching";
3
+ import { runInfinitePollCallAtStart } from "socket-function/src/batching";
4
+ import { timeInHour, timeInMinute, timeInSecond } from "socket-function/src/misc";
5
+ import { assertIsManagementUser } from "../managementPages";
6
+ import {
7
+ getGrossStatsBuckets,
8
+ GrossStatsBucket,
9
+ GrossStatsField,
10
+ GROSS_STATS_FIELDS,
11
+ } from "../../5-diagnostics/gross-stats/grossStats";
12
+ import { getOwnNodeId, isOwnNodeId } from "../../-f-node-discovery/NodeDiscovery";
13
+ import { authorityLookup } from "../../0-path-value-core/AuthorityLookup";
14
+ import { timeoutToUndefinedSilent } from "../../errors";
15
+
16
+ const POLL_INTERVAL = timeInMinute;
17
+ const PER_NODE_TIMEOUT = 30 * timeInSecond;
18
+ const HEADER_WINDOW_MS = 60 * timeInMinute;
19
+ const RETENTION_MS = 7 * 24 * timeInHour;
20
+
21
+ let polledBuckets: Map<string, GrossStatsBucket[]> = new Map();
22
+
23
+ async function listClusterNodeIds(): Promise<string[]> {
24
+ let topology = await authorityLookup.getTopology();
25
+ let ids = new Set<string>();
26
+ for (let entry of topology) ids.add(entry.nodeId);
27
+ ids.add(getOwnNodeId());
28
+ return Array.from(ids);
29
+ }
30
+
31
+ async function pollOnce() {
32
+ let nodeIds = await listClusterNodeIds();
33
+ let cutoff = Date.now() - RETENTION_MS;
34
+ await Promise.all(nodeIds.map(async nodeId => {
35
+ if (isOwnNodeId(nodeId)) {
36
+ polledBuckets.set(nodeId, getGrossStatsBuckets().filter(b => b.time >= cutoff));
37
+ return;
38
+ }
39
+ let prev = polledBuckets.get(nodeId);
40
+ let sinceTime = prev && prev.length > 0 ? prev[prev.length - 1].time : undefined;
41
+
42
+ let result = await timeoutToUndefinedSilent(
43
+ PER_NODE_TIMEOUT,
44
+ GrossStatsController.nodes[nodeId].getGrossStatsLocal({ sinceTime }),
45
+ );
46
+ let merged = prev ? (result ? prev.concat(result.buckets) : prev) : (result?.buckets ?? []);
47
+ merged = merged.filter(b => b.time >= cutoff);
48
+ polledBuckets.set(nodeId, merged);
49
+ }));
50
+ }
51
+
52
+ let ensurePolling = lazy(() => {
53
+ void runInfinitePollCallAtStart(POLL_INTERVAL, pollOnce);
54
+ });
55
+
56
+ class GrossStatsControllerBase {
57
+ public async getGrossStatsLocal(config?: { sinceTime?: number }): Promise<{
58
+ nodeId: string;
59
+ buckets: GrossStatsBucket[];
60
+ }> {
61
+ return {
62
+ nodeId: getOwnNodeId(),
63
+ buckets: getGrossStatsBuckets(config?.sinceTime),
64
+ };
65
+ }
66
+
67
+ /** Trampoline: returns the latest polled buckets for every node in the cluster.
68
+ * Starts the background poller on first call. */
69
+ public async getGrossStats(): Promise<{
70
+ bucketsByNode: Map<string, GrossStatsBucket[]>;
71
+ }> {
72
+ ensurePolling();
73
+ return { bucketsByNode: new Map(polledBuckets) };
74
+ }
75
+
76
+ /** Trampoline: computes the cluster-wide per-second sum over the last hour from polled buckets.
77
+ * Cheap — at ~100 nodes × ~60 buckets/hour = ~6000 numbers per field. */
78
+ public async getGrossStatsHeaderSummary(): Promise<{
79
+ clusterSum: Record<GrossStatsField, number>;
80
+ }> {
81
+ ensurePolling();
82
+ let windowStart = Date.now() - HEADER_WINDOW_MS;
83
+ let windowSec = HEADER_WINDOW_MS / 1000;
84
+
85
+ let clusterSum = {} as Record<GrossStatsField, number>;
86
+ for (let f of GROSS_STATS_FIELDS) clusterSum[f] = 0;
87
+ for (let buckets of polledBuckets.values()) {
88
+ for (let bucket of buckets) {
89
+ if (bucket.time < windowStart) continue;
90
+ for (let f of GROSS_STATS_FIELDS) {
91
+ clusterSum[f] += bucket.deltas[f];
92
+ }
93
+ }
94
+ }
95
+ for (let f of GROSS_STATS_FIELDS) clusterSum[f] /= windowSec;
96
+ return { clusterSum };
97
+ }
98
+ }
99
+
100
+ export const GrossStatsController = SocketFunction.register(
101
+ "GrossStatsController-0193f8aa-7b91-7402-a4be-91a4b1f9c93d",
102
+ new GrossStatsControllerBase(),
103
+ () => ({
104
+ getGrossStatsLocal: { compress: true },
105
+ getGrossStats: { compress: true },
106
+ getGrossStatsHeaderSummary: {},
107
+ }),
108
+ () => ({
109
+ hooks: [assertIsManagementUser],
110
+ }),
111
+ );
@@ -0,0 +1,95 @@
1
+ import { qreact } from "../../4-dom/qreact";
2
+ import { css } from "../../4-dom/css";
3
+ import { formatNumber } from "socket-function/src/formatting/format";
4
+ import { SocketFunction } from "socket-function/SocketFunction";
5
+ import { timeInMinute } from "socket-function/src/misc";
6
+ import { isCurrentUserSuperUser } from "../../user-implementation/userData";
7
+ import { ATag } from "../../library-components/ATag";
8
+ import { managementPageURL, showingManagementURL } from "../managementPages";
9
+ import { getSyncedController } from "../../library-components/SyncedController";
10
+ import { GrossStatsController } from "./GrossStatsController";
11
+ import { GrossStatsField } from "../../5-diagnostics/gross-stats/grossStats";
12
+
13
+ module.hotreload = true;
14
+
15
+ const REFRESH_INTERVAL_MS = 5 * timeInMinute;
16
+
17
+ const HEADER_FIELDS: GrossStatsField[] = [
18
+ "functionCallsInner",
19
+ "pathValuesSent",
20
+ "socketCalls",
21
+ "socketUploadBytes",
22
+ "socketDownloadBytes",
23
+ "socketCallTimeMs",
24
+ "logsWritten",
25
+ ];
26
+ const FIELD_GLYPHS: Partial<Record<GrossStatsField, string>> = {
27
+ functionCallsInner: "⚡",
28
+ pathValuesSent: "📤",
29
+ socketCalls: "📞",
30
+ socketUploadBytes: "⬆",
31
+ socketDownloadBytes: "⬇",
32
+ socketCallTimeMs: "⏱",
33
+ logsWritten: "📝",
34
+ };
35
+ const FIELD_TITLES: Partial<Record<GrossStatsField, string>> = {
36
+ functionCallsInner: "Function calls per second, last hour",
37
+ pathValuesSent: "Path values sent per second, last hour",
38
+ socketCalls: "SocketFunction calls per second, last hour",
39
+ socketUploadBytes: "Upload bytes per second, last hour",
40
+ socketDownloadBytes: "Download bytes per second, last hour",
41
+ socketCallTimeMs: "SocketFunction call time ms per second, last hour",
42
+ logsWritten: "Log entries per second, last hour",
43
+ };
44
+
45
+ function formatField(field: GrossStatsField, value: number): string {
46
+ if (field === "socketUploadBytes" || field === "socketDownloadBytes") {
47
+ if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(2)}MB`;
48
+ if (value >= 1024) return `${(value / 1024).toFixed(1)}KB`;
49
+ return `${value.toFixed(0)}B`;
50
+ }
51
+ if (field === "socketCallTimeMs") {
52
+ return `${value.toFixed(1)}ms`;
53
+ }
54
+ return formatNumber(value);
55
+ }
56
+
57
+ let grossStatsController = getSyncedController(GrossStatsController);
58
+
59
+ export class GrossStatsInfo extends qreact.Component {
60
+ refreshTimer: ReturnType<typeof setInterval> | undefined;
61
+ componentDidMount() {
62
+ this.refreshTimer = setInterval(() => {
63
+ grossStatsController.refreshAll();
64
+ }, REFRESH_INTERVAL_MS);
65
+ }
66
+ componentWillUnmount() {
67
+ if (this.refreshTimer) clearInterval(this.refreshTimer);
68
+ }
69
+ render() {
70
+ if (!isCurrentUserSuperUser()) return undefined;
71
+
72
+ let result = grossStatsController(SocketFunction.browserNodeId()).getGrossStatsHeaderSummary();
73
+ if (!result) return undefined;
74
+
75
+ let parts: preact.ComponentChild[] = [];
76
+ for (let field of HEADER_FIELDS) {
77
+ parts.push(
78
+ <span title={FIELD_TITLES[field]}>
79
+ {formatField(field, result.clusterSum[field])}{FIELD_GLYPHS[field]}
80
+ </span>
81
+ );
82
+ }
83
+
84
+ return <ATag
85
+ className={css.button.hbox(7).pad2(4)}
86
+ values={[
87
+ showingManagementURL.getOverride(true),
88
+ managementPageURL.getOverride("GrossStatsPage"),
89
+ ]}
90
+ title="Cluster activity per second over the past hour. Click for detail."
91
+ >
92
+ {parts}
93
+ </ATag>;
94
+ }
95
+ }
@@ -0,0 +1,259 @@
1
+ import preact from "preact";
2
+ import { qreact } from "../../4-dom/qreact";
3
+ import { css } from "../../4-dom/css";
4
+ import { Querysub } from "../../4-querysub/QuerysubController";
5
+ import { SocketFunction } from "socket-function/SocketFunction";
6
+ import { formatNumber } from "socket-function/src/formatting/format";
7
+ import { timeInHour, timeInMinute } from "socket-function/src/misc";
8
+ import { cacheArgsEqual } from "socket-function/src/caching";
9
+ import { t } from "../../2-proxy/schema2";
10
+ import { getSyncedController } from "../../library-components/SyncedController";
11
+ import { GrossStatsController } from "./GrossStatsController";
12
+ import {
13
+ GROSS_STATS_FIELDS,
14
+ GrossStatsField,
15
+ GrossStatsBucket,
16
+ } from "../../5-diagnostics/gross-stats/grossStats";
17
+ export { GrossStatsController } from "./GrossStatsController";
18
+
19
+ module.hotreload = true;
20
+
21
+ const REFRESH_INTERVAL_MS = 5 * timeInMinute;
22
+ const CHART_WIDTH = 1200;
23
+ const CHART_HEIGHT = 300;
24
+
25
+ const TIME_RANGES: { label: string; ms: number }[] = [
26
+ { label: "1h", ms: timeInHour },
27
+ { label: "6h", ms: 6 * timeInHour },
28
+ { label: "24h", ms: 24 * timeInHour },
29
+ { label: "7d", ms: 7 * 24 * timeInHour },
30
+ ];
31
+
32
+ let pageSchema = Querysub.createLocalSchema("grossStatsPage", {
33
+ rangeMs: t.atomic<number>(timeInHour),
34
+ excludedNodes: t.atomic<Set<string>>(new Set()),
35
+ selectedField: t.atomic<GrossStatsField>("functionCallsInner"),
36
+ });
37
+
38
+ const NODE_COLORS = [
39
+ "hsl(0, 70%, 55%)",
40
+ "hsl(30, 75%, 50%)",
41
+ "hsl(55, 80%, 45%)",
42
+ "hsl(120, 50%, 45%)",
43
+ "hsl(170, 60%, 45%)",
44
+ "hsl(210, 60%, 55%)",
45
+ "hsl(250, 55%, 60%)",
46
+ "hsl(290, 55%, 55%)",
47
+ "hsl(330, 65%, 55%)",
48
+ ];
49
+ function colorForNode(index: number): string {
50
+ return NODE_COLORS[index % NODE_COLORS.length];
51
+ }
52
+
53
+ function shortNodeId(nodeId: string): string {
54
+ let parts = nodeId.split(".");
55
+ if (parts.length >= 2) return `${parts[0].slice(0, 6)}…${parts[1].slice(0, 6)}`;
56
+ return nodeId.slice(0, 12);
57
+ }
58
+
59
+ let grossStatsController = getSyncedController(GrossStatsController);
60
+
61
+ // Render a stacked bar chart to a PNG data URL. Cached by argument identity:
62
+ // selectedField/rangeMs/width/height are primitives; nodeIds and bucketsArrays are
63
+ // expected to be stable references (the bucketsArrays come from syncedController and
64
+ // only change when refreshed).
65
+ const renderChartPNG = cacheArgsEqual((
66
+ selectedField: GrossStatsField,
67
+ rangeMs: number,
68
+ width: number,
69
+ height: number,
70
+ nodeIds: readonly string[],
71
+ bucketsArrays: readonly GrossStatsBucket[][],
72
+ ): string => {
73
+ let canvas = document.createElement("canvas");
74
+ canvas.width = width;
75
+ canvas.height = height;
76
+ let ctx = canvas.getContext("2d")!;
77
+ ctx.fillStyle = "hsl(0, 0%, 97%)";
78
+ ctx.fillRect(0, 0, width, height);
79
+
80
+ let now = Date.now();
81
+ let windowStart = now - rangeMs;
82
+
83
+ let perPixel: { perNode: number[]; total: number; }[] = [];
84
+ for (let x = 0; x < width; x++) {
85
+ perPixel.push({ perNode: new Array(nodeIds.length).fill(0), total: 0 });
86
+ }
87
+ let maxTotal = 0;
88
+ for (let i = 0; i < nodeIds.length; i++) {
89
+ let buckets = bucketsArrays[i];
90
+ if (!buckets) continue;
91
+ for (let bucket of buckets) {
92
+ if (bucket.time < windowStart) continue;
93
+ let xFloat = (bucket.time - windowStart) / rangeMs * width;
94
+ let x = Math.min(width - 1, Math.max(0, Math.floor(xFloat)));
95
+ let cell = perPixel[x];
96
+ let val = bucket.deltas[selectedField] || 0;
97
+ cell.perNode[i] += val;
98
+ cell.total += val;
99
+ if (cell.total > maxTotal) maxTotal = cell.total;
100
+ }
101
+ }
102
+ if (maxTotal <= 0) maxTotal = 1;
103
+
104
+ for (let x = 0; x < width; x++) {
105
+ let cell = perPixel[x];
106
+ if (cell.total === 0) continue;
107
+ let y = height;
108
+ for (let i = 0; i < nodeIds.length; i++) {
109
+ let val = cell.perNode[i];
110
+ if (!val) continue;
111
+ let segHeight = (val / maxTotal) * height;
112
+ ctx.fillStyle = colorForNode(i);
113
+ ctx.fillRect(x, y - segHeight, 1, segHeight);
114
+ y -= segHeight;
115
+ }
116
+ }
117
+
118
+ return canvas.toDataURL();
119
+ }, 5);
120
+
121
+ export class GrossStatsPage extends qreact.Component {
122
+ refreshTimer: ReturnType<typeof setInterval> | undefined;
123
+
124
+ componentDidMount() {
125
+ this.refreshTimer = setInterval(() => {
126
+ grossStatsController.refreshAll();
127
+ }, REFRESH_INTERVAL_MS);
128
+ }
129
+ componentWillUnmount() {
130
+ if (this.refreshTimer) clearInterval(this.refreshTimer);
131
+ }
132
+
133
+ private renderControls(nodeIds: string[]) {
134
+ let state = pageSchema();
135
+ return <div className={css.vbox(8).pad2(8)}>
136
+ <div className={css.hbox(6)}>
137
+ <span>Range:</span>
138
+ {TIME_RANGES.map(r =>
139
+ <span
140
+ className={css.button.pad2(4, 2) + (state.rangeMs === r.ms ? " " + css.hsl(210, 70, 60).hslcolor(0, 0, 100) : "")}
141
+ onClick={() => Querysub.commit(() => { state.rangeMs = r.ms; })}
142
+ >{r.label}</span>
143
+ )}
144
+ </div>
145
+ <div className={css.hbox(6).wrap}>
146
+ <span>Field:</span>
147
+ {GROSS_STATS_FIELDS.map(f =>
148
+ <span
149
+ className={css.button.pad2(4, 2) + (state.selectedField === f ? " " + css.hsl(140, 60, 50).hslcolor(0, 0, 100) : "")}
150
+ onClick={() => Querysub.commit(() => { state.selectedField = f; })}
151
+ >{f}</span>
152
+ )}
153
+ </div>
154
+ <div className={css.hbox(8).wrap}>
155
+ <span>Servers:</span>
156
+ {nodeIds.map((nodeId, i) =>
157
+ <label className={css.hbox(4)}>
158
+ <input
159
+ type="checkbox"
160
+ checked={!state.excludedNodes.has(nodeId)}
161
+ onChange={() => {
162
+ Querysub.commit(() => {
163
+ let next = new Set(state.excludedNodes);
164
+ if (next.has(nodeId)) next.delete(nodeId);
165
+ else next.add(nodeId);
166
+ state.excludedNodes = next;
167
+ });
168
+ }}
169
+ />
170
+ <span className={css.hslcolor(0, 0, 100).pad2(2)} style={{ background: colorForNode(i) }}>
171
+ {shortNodeId(nodeId)}
172
+ </span>
173
+ </label>
174
+ )}
175
+ </div>
176
+ </div>;
177
+ }
178
+
179
+ private renderTable(nodeIds: string[], bucketsArrays: GrossStatsBucket[][]) {
180
+ let state = pageSchema();
181
+ let now = Date.now();
182
+ let windowStart = now - state.rangeMs;
183
+
184
+ let perNodeTotals: Record<GrossStatsField, number>[] = [];
185
+ let totalsRow: Record<GrossStatsField, number> = {} as Record<GrossStatsField, number>;
186
+ for (let f of GROSS_STATS_FIELDS) totalsRow[f] = 0;
187
+
188
+ for (let i = 0; i < nodeIds.length; i++) {
189
+ let totals = {} as Record<GrossStatsField, number>;
190
+ for (let f of GROSS_STATS_FIELDS) totals[f] = 0;
191
+ for (let bucket of bucketsArrays[i] ?? []) {
192
+ if (bucket.time < windowStart) continue;
193
+ for (let f of GROSS_STATS_FIELDS) {
194
+ totals[f] += bucket.deltas[f];
195
+ totalsRow[f] += bucket.deltas[f];
196
+ }
197
+ }
198
+ perNodeTotals.push(totals);
199
+ }
200
+
201
+ return <table className={css.fillWidth}>
202
+ <thead>
203
+ <tr>
204
+ <th className={css.textAlign("left").pad2(4)}>Server</th>
205
+ {GROSS_STATS_FIELDS.map(f => <th className={css.textAlign("right").pad2(4)}>{f}</th>)}
206
+ </tr>
207
+ </thead>
208
+ <tbody>
209
+ <tr className={css.boldStyle}>
210
+ <td className={css.pad2(4)}>TOTAL</td>
211
+ {GROSS_STATS_FIELDS.map(f => <td className={css.textAlign("right").pad2(4)}>{formatNumber(totalsRow[f])}</td>)}
212
+ </tr>
213
+ {nodeIds.map((nodeId, i) =>
214
+ <tr>
215
+ <td className={css.pad2(4)} style={{ borderLeft: `4px solid ${colorForNode(i)}` }}>
216
+ {shortNodeId(nodeId)}
217
+ </td>
218
+ {GROSS_STATS_FIELDS.map(f =>
219
+ <td className={css.textAlign("right").pad2(4)}>{formatNumber(perNodeTotals[i][f])}</td>
220
+ )}
221
+ </tr>
222
+ )}
223
+ </tbody>
224
+ </table>;
225
+ }
226
+
227
+ render() {
228
+ let state = pageSchema();
229
+ let result = grossStatsController(SocketFunction.browserNodeId()).getGrossStats();
230
+ let bucketsByNode = result?.bucketsByNode ?? new Map<string, GrossStatsBucket[]>();
231
+ let allNodeIds = Array.from(bucketsByNode.keys()).sort();
232
+
233
+ let selectedNodeIds = allNodeIds.filter(n => !state.excludedNodes.has(n));
234
+ let bucketsArrays: GrossStatsBucket[][] = selectedNodeIds.map(n => bucketsByNode.get(n) ?? []);
235
+ let anyLoading = !result;
236
+
237
+ let chartPNG = renderChartPNG(
238
+ state.selectedField,
239
+ state.rangeMs,
240
+ CHART_WIDTH,
241
+ CHART_HEIGHT,
242
+ selectedNodeIds,
243
+ bucketsArrays,
244
+ );
245
+
246
+ return <div className={css.vbox(8).pad2(8).fillWidth}>
247
+ <h2>Cluster Stats</h2>
248
+ {this.renderControls(allNodeIds)}
249
+ <img
250
+ src={chartPNG}
251
+ width={CHART_WIDTH}
252
+ height={CHART_HEIGHT}
253
+ className={css.hsl(0, 0, 100)}
254
+ style={anyLoading ? { opacity: 0.5 } : undefined}
255
+ />
256
+ {this.renderTable(selectedNodeIds, bucketsArrays)}
257
+ </div>;
258
+ }
259
+ }
@@ -300,7 +300,10 @@ export class BufferIndex {
300
300
  dataReader: Reader;
301
301
  params: SearchParams;
302
302
  keepIterating: () => boolean;
303
- onResult: (match: Buffer) => void;
303
+ // Returns true iff the caller actually retained the value. We use that
304
+ // to drive the per-file matchCount cap below: see the note at the
305
+ // `matchesPattern(buffer)` call for why we can't blindly count emits.
306
+ onResult: (match: Buffer) => boolean;
304
307
  results: IndexedLogResults;
305
308
  allSearchUnits: Unit[][];
306
309
  matchesPattern: (buffer: Buffer) => boolean;
@@ -395,8 +398,24 @@ export class BufferIndex {
395
398
 
396
399
  let buffer = buffers[bufferIndex];
397
400
  if (matchesPattern(buffer)) {
398
- config.onResult(buffer);
399
- matchCount++;
401
+ // Only count matches the caller actually kept. `onResult`
402
+ // routes through `FindProgressTracker.addResult`, which
403
+ // can reject for reasons we don't see from here — most
404
+ // notably time-range filtering (an entry whose time is
405
+ // outside the search window matched the text pattern
406
+ // but isn't a real hit). Counting rejected emits would
407
+ // let a stretch of out-of-window matches at the start
408
+ // of a file blow the per-file cap and short-circuit the
409
+ // scan before we reach the in-window region.
410
+ //
411
+ // The cost is that we keep matching and calling onResult
412
+ // through those out-of-window blocks (mild inefficiency).
413
+ // We can't skip ahead — buffers are scanned linearly and
414
+ // we don't know up front which entries the caller will
415
+ // reject — so this is the best we can do here.
416
+ if (config.onResult(buffer)) {
417
+ matchCount++;
418
+ }
400
419
  }
401
420
  }
402
421
  } catch (e: any) {
@@ -531,7 +550,10 @@ export class BufferIndex {
531
550
  params: SearchParams;
532
551
 
533
552
  keepIterating: () => boolean;
534
- onResult: (match: Buffer) => void;
553
+ // See the note on `findLocal.onResult` return value drives the
554
+ // per-file matchCount cap so out-of-window emits don't short-circuit
555
+ // the scan.
556
+ onResult: (match: Buffer) => boolean;
535
557
  results: IndexedLogResults;
536
558
  }): Promise<{
537
559
  blockSearchTime: number;
@@ -505,7 +505,10 @@ export class BufferUnitIndex {
505
505
  params: SearchParams;
506
506
  allSearchUnits: Unit[][];
507
507
  keepIterating: () => boolean;
508
- onResult: (match: Buffer) => void;
508
+ // Returns true iff the caller actually retained the value. Drives the
509
+ // `matchCounts` cap below — see the comment at the `matchesPattern`
510
+ // call for why we can't blindly count emits.
511
+ onResult: (match: Buffer) => boolean;
509
512
  index: Buffer;
510
513
  reader: Reader;
511
514
  results: IndexedLogResults;
@@ -582,9 +585,24 @@ export class BufferUnitIndex {
582
585
 
583
586
  const buffer = await this.getBufferFromBlock(blockReader, i);
584
587
  if (matchesPattern(buffer)) {
585
- config.onResult(buffer);
586
- matchCount++;
587
- matchCounts[blockIndex]++;
588
+ // Only count matches the caller actually kept.
589
+ // `onResult` routes through
590
+ // `FindProgressTracker.addResult`, which can reject
591
+ // for reasons opaque to us — most notably
592
+ // time-range filtering. Counting rejected emits
593
+ // would let a stretch of out-of-window matches at
594
+ // the start of a file blow the per-file cap and
595
+ // short-circuit the scan before we reach the
596
+ // in-window region. The cost is that we keep
597
+ // matching and calling onResult through those
598
+ // out-of-window blocks (mild inefficiency); we
599
+ // can't skip ahead because buffers are scanned
600
+ // linearly and we don't know up front which
601
+ // entries the caller will reject.
602
+ if (config.onResult(buffer)) {
603
+ matchCount++;
604
+ matchCounts[blockIndex]++;
605
+ }
588
606
  }
589
607
  }
590
608
  } catch (e: any) {
@@ -633,9 +633,15 @@ export class IndexedLogs<T> {
633
633
  limit: config.params.limit,
634
634
  },
635
635
  keepIterating: () => !results.cancel && progressTracker.isSourceRelevant(path),
636
- onResult: (match: Buffer) => {
637
- // NOTE: We absolutely cannot do any limiting here, as that would just result in a lot of values that might be newer than the old values being discarded. Each loop itself has to do some kind of limiting as it knows how many it's found in its specific loop, and it knows the direction it's iterating and the direction of the values.
638
- progressTracker.addResult(match, path);
636
+ onResult: (match: Buffer): boolean => {
637
+ // Return value drives the per-file matchCount cap inside
638
+ // BufferIndex.find / BufferUnitIndex.find. addResult
639
+ // returns false when it rejects (time outside the
640
+ // search range, or past the kept top-K cutoff); the
641
+ // scanner uses that to avoid counting rejected emits
642
+ // against the cap. See the comment on those scanners'
643
+ // `matchesPattern` calls for the full reasoning.
644
+ return progressTracker.addResult(match, path);
639
645
  },
640
646
  results,
641
647
  });
@@ -10,6 +10,9 @@ import { isPublic } from "../../config";
10
10
  import type { IndexedLogs } from "./IndexedLogs/IndexedLogs";
11
11
  // IMPORTANT! We can't have any real imports here, because we are depended on so early in startup!
12
12
 
13
+ let logWriteCount = 0;
14
+ export function getLogWriteCount() { return logWriteCount; }
15
+
13
16
  if (isNode()) {
14
17
  // Delayed setup, as we depend on diskLogger early, and we don't want to force high level
15
18
  // modules to be required before their level
@@ -150,6 +153,7 @@ export function logDisk(type: "log" | "warn" | "info" | "error", ...args: unknow
150
153
  } else {
151
154
  errorLogs.append(logObj);
152
155
  }
156
+ logWriteCount++;
153
157
  }
154
158
 
155
159
  } catch (e: any) {
@@ -167,6 +167,12 @@ export async function registerManagementPages2(config: {
167
167
  controllerName: "SyncTestController",
168
168
  getModule: () => import("./SyncTestPage"),
169
169
  });
170
+ inputPages.push({
171
+ title: "Gross Stats",
172
+ componentName: "GrossStatsPage",
173
+ controllerName: "GrossStatsController",
174
+ getModule: () => import("./grossStats/GrossStatsPage"),
175
+ });
170
176
  inputPages.push(...config.pages);
171
177
 
172
178
  // NOTE: We don't store the UI in the database (here, or anywhere else, at least
@@ -313,6 +319,7 @@ export function renderIsManagementUser() {
313
319
 
314
320
  const ErrorWarning = createLazyComponent(() => import("./logs/errorNotifications2/ErrorWarning"))("ErrorWarning");
315
321
  const LaunchTrackingHeader = createLazyComponent(() => import("../deployManager/LaunchTrackingHeader"))("LaunchTrackingHeader");
322
+ const GrossStatsInfo = createLazyComponent(() => import("./grossStats/GrossStatsInfo"))("GrossStatsInfo");
316
323
 
317
324
  class ManagementRoot extends qreact.Component {
318
325
  state = {
@@ -386,6 +393,7 @@ class ManagementRoot extends qreact.Component {
386
393
  <PathDistributionInfo />
387
394
  <ValuePathWarning />
388
395
  <LaunchTrackingHeader />
396
+ <GrossStatsInfo />
389
397
  </div>}
390
398
  </div>
391
399
  {currentPage &&