querysub 0.457.0 → 0.459.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 +1 -1
- package/src/0-path-value-core/PathValueController.ts +10 -1
- package/src/0-path-value-core/PathWatcher.ts +1 -0
- package/src/3-path-functions/PathFunctionRunner.ts +7 -0
- package/src/4-querysub/QuerysubController.ts +1 -0
- package/src/5-diagnostics/gross-stats/grossStats.ts +102 -0
- package/src/deployManager/machineApplyMainCode.ts +1 -7
- package/src/diagnostics/auditDiskValues.ts +1 -1
- package/src/diagnostics/grossStats/GrossStatsController.ts +111 -0
- package/src/diagnostics/grossStats/GrossStatsInfo.tsx +95 -0
- package/src/diagnostics/grossStats/GrossStatsPage.tsx +297 -0
- package/src/diagnostics/logs/IndexedLogs/BufferIndex.ts +36 -5
- package/src/diagnostics/logs/IndexedLogs/BufferUnitIndex.ts +32 -5
- package/src/diagnostics/logs/IndexedLogs/IndexedLogs.ts +9 -3
- package/src/diagnostics/logs/diskLogger.ts +4 -0
- package/src/diagnostics/managementPages.tsx +8 -0
package/package.json
CHANGED
|
@@ -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 });
|
|
@@ -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
|
}
|
|
@@ -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,
|
|
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,297 @@
|
|
|
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
|
+
): { pngUrl: string; maxTotal: number } => {
|
|
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 { pngUrl: canvas.toDataURL(), maxTotal };
|
|
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
|
+
<button
|
|
140
|
+
className={css.pad2(8, 4) + (state.rangeMs === r.ms ? " " + css.hsl(210, 70, 60).hslcolor(0, 0, 100).bold : "")}
|
|
141
|
+
onClick={() => Querysub.commit(() => { state.rangeMs = r.ms; })}
|
|
142
|
+
>{r.label}</button>
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
<div className={css.hbox(6).wrap}>
|
|
146
|
+
<span>Field:</span>
|
|
147
|
+
{GROSS_STATS_FIELDS.map(f =>
|
|
148
|
+
<button
|
|
149
|
+
className={css.pad2(8, 4) + (state.selectedField === f ? " " + css.hsl(140, 60, 50).hslcolor(0, 0, 100).bold : "")}
|
|
150
|
+
onClick={() => Querysub.commit(() => { state.selectedField = f; })}
|
|
151
|
+
>{f}</button>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
<div className={css.hbox(8).wrap}>
|
|
155
|
+
<span>Servers:</span>
|
|
156
|
+
<button
|
|
157
|
+
className={css.pad2(8, 4)}
|
|
158
|
+
onClick={() => Querysub.commit(() => { state.excludedNodes = new Set(); })}
|
|
159
|
+
>Select all</button>
|
|
160
|
+
<button
|
|
161
|
+
className={css.pad2(8, 4)}
|
|
162
|
+
onClick={() => Querysub.commit(() => { state.excludedNodes = new Set(nodeIds); })}
|
|
163
|
+
>Select none</button>
|
|
164
|
+
{nodeIds.map((nodeId, i) =>
|
|
165
|
+
<label className={css.hbox(4)}>
|
|
166
|
+
<input
|
|
167
|
+
type="checkbox"
|
|
168
|
+
checked={!state.excludedNodes.has(nodeId)}
|
|
169
|
+
onChange={() => {
|
|
170
|
+
Querysub.commit(() => {
|
|
171
|
+
let next = new Set(state.excludedNodes);
|
|
172
|
+
if (next.has(nodeId)) next.delete(nodeId);
|
|
173
|
+
else next.add(nodeId);
|
|
174
|
+
state.excludedNodes = next;
|
|
175
|
+
});
|
|
176
|
+
}}
|
|
177
|
+
/>
|
|
178
|
+
<span className={css.hslcolor(0, 0, 100).pad2(2)} style={{ background: colorForNode(i) }}>
|
|
179
|
+
{shortNodeId(nodeId)}
|
|
180
|
+
</span>
|
|
181
|
+
</label>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
</div>;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private renderTable(nodeIds: string[], bucketsArrays: GrossStatsBucket[][]) {
|
|
188
|
+
let state = pageSchema();
|
|
189
|
+
let now = Date.now();
|
|
190
|
+
let windowStart = now - state.rangeMs;
|
|
191
|
+
|
|
192
|
+
let perNodeTotals: Record<GrossStatsField, number>[] = [];
|
|
193
|
+
let totalsRow: Record<GrossStatsField, number> = {} as Record<GrossStatsField, number>;
|
|
194
|
+
for (let f of GROSS_STATS_FIELDS) totalsRow[f] = 0;
|
|
195
|
+
|
|
196
|
+
for (let i = 0; i < nodeIds.length; i++) {
|
|
197
|
+
let totals = {} as Record<GrossStatsField, number>;
|
|
198
|
+
for (let f of GROSS_STATS_FIELDS) totals[f] = 0;
|
|
199
|
+
for (let bucket of bucketsArrays[i] ?? []) {
|
|
200
|
+
if (bucket.time < windowStart) continue;
|
|
201
|
+
for (let f of GROSS_STATS_FIELDS) {
|
|
202
|
+
totals[f] += bucket.deltas[f];
|
|
203
|
+
totalsRow[f] += bucket.deltas[f];
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
perNodeTotals.push(totals);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let maxPerField = {} as Record<GrossStatsField, number>;
|
|
210
|
+
for (let f of GROSS_STATS_FIELDS) {
|
|
211
|
+
maxPerField[f] = 0;
|
|
212
|
+
for (let totals of perNodeTotals) {
|
|
213
|
+
if (totals[f] > maxPerField[f]) maxPerField[f] = totals[f];
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
let highlightStyle = css.hsl(60, 90, 75);
|
|
217
|
+
|
|
218
|
+
return <table className={css.fillWidth}>
|
|
219
|
+
<thead>
|
|
220
|
+
<tr>
|
|
221
|
+
<th className={css.textAlign("left").pad2(4)}>Server</th>
|
|
222
|
+
{GROSS_STATS_FIELDS.map(f => <th className={css.textAlign("right").pad2(4)}>{f}</th>)}
|
|
223
|
+
</tr>
|
|
224
|
+
</thead>
|
|
225
|
+
<tbody>
|
|
226
|
+
<tr className={css.boldStyle}>
|
|
227
|
+
<td className={css.pad2(4)}>TOTAL</td>
|
|
228
|
+
{GROSS_STATS_FIELDS.map(f => <td className={css.textAlign("right").pad2(4)}>{formatNumber(totalsRow[f])}</td>)}
|
|
229
|
+
</tr>
|
|
230
|
+
{nodeIds.map((nodeId, i) =>
|
|
231
|
+
<tr>
|
|
232
|
+
<td className={css.pad2(4)} style={{ borderLeft: `4px solid ${colorForNode(i)}` }}>
|
|
233
|
+
{shortNodeId(nodeId)}
|
|
234
|
+
</td>
|
|
235
|
+
{GROSS_STATS_FIELDS.map(f => {
|
|
236
|
+
let isMax = perNodeTotals[i][f] > 0 && perNodeTotals[i][f] === maxPerField[f];
|
|
237
|
+
return <td className={css.textAlign("right").pad2(4) + (isMax ? " " + highlightStyle : "")}>
|
|
238
|
+
{formatNumber(perNodeTotals[i][f])}
|
|
239
|
+
</td>;
|
|
240
|
+
})}
|
|
241
|
+
</tr>
|
|
242
|
+
)}
|
|
243
|
+
</tbody>
|
|
244
|
+
</table>;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
render() {
|
|
248
|
+
let state = pageSchema();
|
|
249
|
+
let result = grossStatsController(SocketFunction.browserNodeId()).getGrossStats();
|
|
250
|
+
let bucketsByNode = result?.bucketsByNode ?? new Map<string, GrossStatsBucket[]>();
|
|
251
|
+
let allNodeIds = Array.from(bucketsByNode.keys()).sort();
|
|
252
|
+
|
|
253
|
+
let selectedNodeIds = allNodeIds.filter(n => !state.excludedNodes.has(n));
|
|
254
|
+
let bucketsArrays: GrossStatsBucket[][] = selectedNodeIds.map(n => bucketsByNode.get(n) ?? []);
|
|
255
|
+
let anyLoading = !result;
|
|
256
|
+
|
|
257
|
+
let chart = renderChartPNG(
|
|
258
|
+
state.selectedField,
|
|
259
|
+
state.rangeMs,
|
|
260
|
+
CHART_WIDTH,
|
|
261
|
+
CHART_HEIGHT,
|
|
262
|
+
selectedNodeIds,
|
|
263
|
+
bucketsArrays,
|
|
264
|
+
);
|
|
265
|
+
let now = Date.now();
|
|
266
|
+
let windowStart = now - state.rangeMs;
|
|
267
|
+
|
|
268
|
+
return <div className={css.vbox(8).pad2(8).fillWidth}>
|
|
269
|
+
<h2>Cluster Stats</h2>
|
|
270
|
+
{this.renderControls(allNodeIds)}
|
|
271
|
+
<div className={css.vbox(4)}>
|
|
272
|
+
<div className={css.hbox(8).bold}>
|
|
273
|
+
<span>peak: {formatNumber(chart.maxTotal)} {state.selectedField} / minute</span>
|
|
274
|
+
</div>
|
|
275
|
+
<img
|
|
276
|
+
src={chart.pngUrl}
|
|
277
|
+
width={CHART_WIDTH}
|
|
278
|
+
height={CHART_HEIGHT}
|
|
279
|
+
className={css.hsl(0, 0, 100)}
|
|
280
|
+
style={anyLoading ? { opacity: 0.5 } : undefined}
|
|
281
|
+
/>
|
|
282
|
+
<div className={css.hbox(0).fillWidth}>
|
|
283
|
+
<span className={css.flexShrink0}>{formatLocalTime(windowStart)}</span>
|
|
284
|
+
<span className={css.fillBoth}></span>
|
|
285
|
+
<span className={css.flexShrink0}>{formatLocalTime(now)}</span>
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
{this.renderTable(selectedNodeIds, bucketsArrays)}
|
|
289
|
+
</div>;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function formatLocalTime(ms: number): string {
|
|
294
|
+
let d = new Date(ms);
|
|
295
|
+
let pad = (n: number) => String(n).padStart(2, "0");
|
|
296
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
297
|
+
}
|
|
@@ -300,7 +300,10 @@ export class BufferIndex {
|
|
|
300
300
|
dataReader: Reader;
|
|
301
301
|
params: SearchParams;
|
|
302
302
|
keepIterating: () => boolean;
|
|
303
|
-
|
|
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;
|
|
@@ -390,13 +393,38 @@ export class BufferIndex {
|
|
|
390
393
|
const bufferStep = iterateForward ? 1 : -1;
|
|
391
394
|
|
|
392
395
|
for (let bufferIndex = bufferStartIdx; iterateForward ? bufferIndex < bufferEndIdx : bufferIndex > bufferEndIdx; bufferIndex += bufferStep) {
|
|
393
|
-
|
|
396
|
+
// No `matchCount >= params.limit` cap inside the block.
|
|
397
|
+
// Buffer order within a block is not guaranteed to follow
|
|
398
|
+
// the search direction (blocks are time-ordered, buffers
|
|
399
|
+
// inside them are not), so stopping mid-block on a match
|
|
400
|
+
// count would drop earlier-time buffers we haven't reached
|
|
401
|
+
// yet. The block-level cap above is the only safe stop;
|
|
402
|
+
// here we only honor cross-file `keepIterating` (which
|
|
403
|
+
// applies to the whole file at once, so it's safe at any
|
|
404
|
+
// granularity).
|
|
405
|
+
if (!config.keepIterating()) break;
|
|
394
406
|
await config.results.limitGroup?.wait();
|
|
395
407
|
|
|
396
408
|
let buffer = buffers[bufferIndex];
|
|
397
409
|
if (matchesPattern(buffer)) {
|
|
398
|
-
|
|
399
|
-
|
|
410
|
+
// Only count matches the caller actually kept. `onResult`
|
|
411
|
+
// routes through `FindProgressTracker.addResult`, which
|
|
412
|
+
// can reject for reasons we don't see from here — most
|
|
413
|
+
// notably time-range filtering (an entry whose time is
|
|
414
|
+
// outside the search window matched the text pattern
|
|
415
|
+
// but isn't a real hit). Counting rejected emits would
|
|
416
|
+
// let a stretch of out-of-window matches at the start
|
|
417
|
+
// of a file blow the per-file cap and short-circuit the
|
|
418
|
+
// scan before we reach the in-window region.
|
|
419
|
+
//
|
|
420
|
+
// The cost is that we keep matching and calling onResult
|
|
421
|
+
// through those out-of-window blocks (mild inefficiency).
|
|
422
|
+
// We can't skip ahead — buffers are scanned linearly and
|
|
423
|
+
// we don't know up front which entries the caller will
|
|
424
|
+
// reject — so this is the best we can do here.
|
|
425
|
+
if (config.onResult(buffer)) {
|
|
426
|
+
matchCount++;
|
|
427
|
+
}
|
|
400
428
|
}
|
|
401
429
|
}
|
|
402
430
|
} catch (e: any) {
|
|
@@ -531,7 +559,10 @@ export class BufferIndex {
|
|
|
531
559
|
params: SearchParams;
|
|
532
560
|
|
|
533
561
|
keepIterating: () => boolean;
|
|
534
|
-
onResult
|
|
562
|
+
// See the note on `findLocal.onResult` — return value drives the
|
|
563
|
+
// per-file matchCount cap so out-of-window emits don't short-circuit
|
|
564
|
+
// the scan.
|
|
565
|
+
onResult: (match: Buffer) => boolean;
|
|
535
566
|
results: IndexedLogResults;
|
|
536
567
|
}): Promise<{
|
|
537
568
|
blockSearchTime: number;
|
|
@@ -505,7 +505,10 @@ export class BufferUnitIndex {
|
|
|
505
505
|
params: SearchParams;
|
|
506
506
|
allSearchUnits: Unit[][];
|
|
507
507
|
keepIterating: () => boolean;
|
|
508
|
-
|
|
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;
|
|
@@ -577,14 +580,38 @@ export class BufferUnitIndex {
|
|
|
577
580
|
const step = iterateForward ? 1 : -1;
|
|
578
581
|
|
|
579
582
|
for (let i = startIdx; iterateForward ? i < endIdx : i > endIdx; i += step) {
|
|
580
|
-
|
|
583
|
+
// No matchCount-based cap inside the block. Buffer
|
|
584
|
+
// order within a block is not guaranteed to follow the
|
|
585
|
+
// search direction (blocks are time-ordered, buffers
|
|
586
|
+
// inside them are not), so a mid-block stop on
|
|
587
|
+
// `relevantCount >= limit` would drop earlier-time
|
|
588
|
+
// buffers we haven't reached yet. Block-level
|
|
589
|
+
// `stopIterating` is the safe granularity; here we
|
|
590
|
+
// only honor cross-file `keepIterating`, which applies
|
|
591
|
+
// to the whole file at once.
|
|
592
|
+
if (!keepIterating()) break;
|
|
581
593
|
await results.limitGroup?.wait();
|
|
582
594
|
|
|
583
595
|
const buffer = await this.getBufferFromBlock(blockReader, i);
|
|
584
596
|
if (matchesPattern(buffer)) {
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
597
|
+
// Only count matches the caller actually kept.
|
|
598
|
+
// `onResult` routes through
|
|
599
|
+
// `FindProgressTracker.addResult`, which can reject
|
|
600
|
+
// for reasons opaque to us — most notably
|
|
601
|
+
// time-range filtering. Counting rejected emits
|
|
602
|
+
// would let a stretch of out-of-window matches at
|
|
603
|
+
// the start of a file blow the per-file cap and
|
|
604
|
+
// short-circuit the scan before we reach the
|
|
605
|
+
// in-window region. The cost is that we keep
|
|
606
|
+
// matching and calling onResult through those
|
|
607
|
+
// out-of-window blocks (mild inefficiency); we
|
|
608
|
+
// can't skip ahead because buffers are scanned
|
|
609
|
+
// linearly and we don't know up front which
|
|
610
|
+
// entries the caller will reject.
|
|
611
|
+
if (config.onResult(buffer)) {
|
|
612
|
+
matchCount++;
|
|
613
|
+
matchCounts[blockIndex]++;
|
|
614
|
+
}
|
|
588
615
|
}
|
|
589
616
|
}
|
|
590
617
|
} 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
|
-
//
|
|
638
|
-
|
|
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 &&
|