querysub 0.456.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 +1 -1
- package/src/-a-archives/archivesBackBlaze.ts +15 -12
- package/src/-h-path-value-serialize/PathValueSerializer.ts +17 -15
- package/src/0-path-value-core/PathValueController.ts +10 -1
- package/src/0-path-value-core/PathWatcher.ts +1 -0
- package/src/0-path-value-core/pathValueCore.ts +1 -1
- 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 +259 -0
- package/src/diagnostics/logs/IndexedLogs/BufferIndex.ts +26 -4
- package/src/diagnostics/logs/IndexedLogs/BufferUnitIndex.ts +22 -4
- package/src/diagnostics/logs/IndexedLogs/IndexedLogs.ts +20 -4
- package/src/diagnostics/logs/IndexedLogs/LogViewer3.tsx +15 -18
- package/src/diagnostics/logs/IndexedLogs/MCPIndexedLogs.ts +49 -69
- package/src/diagnostics/logs/diskLogger.ts +4 -0
- package/src/diagnostics/managementPages.tsx +8 -0
package/package.json
CHANGED
|
@@ -516,8 +516,9 @@ export class ArchivesBackblaze {
|
|
|
516
516
|
fnc: (api: B2Api) => Promise<T>,
|
|
517
517
|
retries = 3
|
|
518
518
|
): Promise<T> {
|
|
519
|
-
let api
|
|
519
|
+
let api: B2Api | undefined;
|
|
520
520
|
try {
|
|
521
|
+
api = await this.getBucketAPI();
|
|
521
522
|
return await fnc(api);
|
|
522
523
|
} catch (err: any) {
|
|
523
524
|
if (retries <= 0) throw err;
|
|
@@ -570,19 +571,21 @@ export class ArchivesBackblaze {
|
|
|
570
571
|
}
|
|
571
572
|
|
|
572
573
|
if (err.stack.includes(`getaddrinfo ENOTFOUND`)) {
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
574
|
+
if (api) {
|
|
575
|
+
let urlObj = new URL(api.apiUrl);
|
|
576
|
+
let hostname = urlObj.hostname;
|
|
577
|
+
let lookupAddresses = await new Promise(resolve => {
|
|
578
|
+
dns.lookup(hostname, (err, addresses) => {
|
|
579
|
+
resolve(addresses);
|
|
580
|
+
});
|
|
578
581
|
});
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
582
|
+
let resolveAddresses = await new Promise(resolve => {
|
|
583
|
+
dns.resolve4(hostname, (err, addresses) => {
|
|
584
|
+
resolve(addresses);
|
|
585
|
+
});
|
|
583
586
|
});
|
|
584
|
-
|
|
585
|
-
|
|
587
|
+
console.error(`[${context}] getaddrinfo ENOTFOUND ${hostname}`, { lookupAddresses, resolveAddresses, apiUrl: api.apiUrl, fullError: err.stack });
|
|
588
|
+
}
|
|
586
589
|
}
|
|
587
590
|
|
|
588
591
|
// NOTE: The AI thought case that happens when we run out of retries, that's stupid. This obviously isn't the case. This is the case when it's a normal error, as in the file doesn't exist, we need to throw. We absolutely should not warn here. Warning here wouldn't be anything. It would just be saying, oh, we checked if a file and it didn't, which is normal, which is why we check if a file exists.
|
|
@@ -997,23 +997,23 @@ class PathValueSerializer {
|
|
|
997
997
|
public getPathValue(pathValue: PathValue | undefined, noMutate?: "noMutate"): unknown {
|
|
998
998
|
if (!pathValue) return undefined;
|
|
999
999
|
if (pathValue.isValueLazy) {
|
|
1000
|
-
// NOTE: If this throws, it likely means you used atomicObjectWrite.
|
|
1001
|
-
// Use atomicObjectWriteNoFreeze or doAtomicWrites instead.
|
|
1002
|
-
if (!noMutate) {
|
|
1003
|
-
try {
|
|
1004
|
-
pathValue.isValueLazy = false;
|
|
1005
|
-
} catch { }
|
|
1006
|
-
}
|
|
1007
1000
|
let buffer = this.lazyValues.get(pathValue.value as {});
|
|
1008
1001
|
if (!buffer) {
|
|
1009
|
-
|
|
1010
|
-
return pathValue.value;
|
|
1002
|
+
throw new Error(`Expected lazy value to have a buffer, but it didn't. Lazy ref has a typeof ${typeof pathValue.value} (${String(pathValue.value)})`);
|
|
1011
1003
|
}
|
|
1012
1004
|
let newValue = recursiveFreeze(cbor.decode(buffer));
|
|
1013
|
-
if (!noMutate) {
|
|
1005
|
+
if (!noMutate && !pathValue.isValueLazy) {
|
|
1006
|
+
// NOTE: If this throws, it likely means you used atomicObjectWrite.
|
|
1007
|
+
// Use atomicObjectWriteNoFreeze or doAtomicWrites instead.
|
|
1014
1008
|
try {
|
|
1009
|
+
pathValue.isValueLazy = false;
|
|
1015
1010
|
pathValue.value = newValue;
|
|
1016
|
-
} catch {
|
|
1011
|
+
} catch {
|
|
1012
|
+
// In theory, it could be that you can set the is value lazy property but not the value property. In which case we want to reset the isValazy property back. Otherwise, we will no longer try to deserialize it, and we will emit the lazy placeholder instead of the actual value next time.
|
|
1013
|
+
try {
|
|
1014
|
+
pathValue.isValueLazy = true;
|
|
1015
|
+
} catch { }
|
|
1016
|
+
}
|
|
1017
1017
|
}
|
|
1018
1018
|
return newValue;
|
|
1019
1019
|
}
|
|
@@ -1027,8 +1027,7 @@ class PathValueSerializer {
|
|
|
1027
1027
|
// NOTE: Did you pass a raw PathValue and then try to use PathValueSerializer with it?
|
|
1028
1028
|
// - Instead you should pass a buffer serialized with pathValueSerializer.serialize and
|
|
1029
1029
|
// deserialized with pathValueSerializer.deserialize.
|
|
1030
|
-
|
|
1031
|
-
return pathValue.value;
|
|
1030
|
+
throw new Error(`Expected lazy value to have a buffer, but it didn't. Lazy ref has a typeof ${typeof pathValue.value} (${String(pathValue.value)})`);
|
|
1032
1031
|
}
|
|
1033
1032
|
return buffer;
|
|
1034
1033
|
}
|
|
@@ -1036,8 +1035,11 @@ class PathValueSerializer {
|
|
|
1036
1035
|
}
|
|
1037
1036
|
|
|
1038
1037
|
private getBuffer(pathValue: PathValue): Buffer {
|
|
1039
|
-
|
|
1040
|
-
|
|
1038
|
+
if (pathValue.isValueLazy) {
|
|
1039
|
+
let buffer = this.lazyValues.get(pathValue.value as {});
|
|
1040
|
+
if (!buffer) throw new Error(`Expected lazy value to have a buffer, but it didn't. Lazy ref has a typeof ${typeof pathValue.value} (${String(pathValue.value)})`);
|
|
1041
|
+
return buffer;
|
|
1042
|
+
}
|
|
1041
1043
|
return cborEncoder().encode(pathValue.value);
|
|
1042
1044
|
}
|
|
1043
1045
|
public compareValuePaths(a: PathValue | undefined, b: PathValue | undefined) {
|
|
@@ -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 });
|
|
@@ -746,7 +746,7 @@ class AuthorityPathValueStorage {
|
|
|
746
746
|
|
|
747
747
|
if (isDebugLogEnabled() && !config?.doNotArchive) {
|
|
748
748
|
for (let value of newValues) {
|
|
749
|
-
auditLog("INGEST VALUE", { path: value.path, timeId: value.time.time });
|
|
749
|
+
auditLog("INGEST VALUE", { path: value.path, timeId: value.time.time, batchTime: now });
|
|
750
750
|
}
|
|
751
751
|
}
|
|
752
752
|
|
|
@@ -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
|
+
}
|