querysub 0.490.0 → 0.492.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/.claude/settings.local.json +2 -1
- package/package.json +1 -1
- package/src/-e-certs/certAuthority.ts +13 -3
- package/src/2-proxy/PathValueProxyWatcher.ts +43 -21
- package/src/3-path-functions/PathFunctionRunner.ts +21 -1
- package/src/3-path-functions/functionCapture.ts +213 -0
- package/src/3-path-functions/functionCaptureFormat.ts +103 -0
- package/src/3-path-functions/functionReplay.ts +272 -0
- package/src/4-querysub/Querysub.ts +5 -2
- package/src/diagnostics/managementPages.tsx +11 -0
- package/src/diagnostics/misc-pages/FunctionCaptureAnalyzePage.tsx +220 -0
- package/src/diagnostics/misc-pages/FunctionCapturePage.tsx +192 -0
- package/tsconfig.json +14 -1
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { epochTime, PathValue } from "../0-path-value-core/pathValueCore";
|
|
2
|
+
import { pathValueSerializer } from "../-h-path-value-serialize/PathValueSerializer";
|
|
3
|
+
import { isTransparentValue, PathValueReadSource, proxyWatcher, runWithPathValueReadSource } from "../2-proxy/PathValueProxyWatcher";
|
|
4
|
+
import { getPathFromStr, getParentPathStr } from "../path";
|
|
5
|
+
import { CaptureRecord } from "./functionCaptureFormat";
|
|
6
|
+
import { CallSpec, FunctionSpec, overrideCurrentCall } from "./PathFunctionRunner";
|
|
7
|
+
import { getModuleFromSpec } from "./pathFunctionLoader";
|
|
8
|
+
import { parseArgs } from "./PathFunctionHelpers";
|
|
9
|
+
|
|
10
|
+
export function makeReplayCallSpec(record: CaptureRecord): CallSpec {
|
|
11
|
+
return {
|
|
12
|
+
DomainName: record.functionSpec.DomainName,
|
|
13
|
+
ModuleId: record.functionSpec.ModuleId,
|
|
14
|
+
CallId: record.callId,
|
|
15
|
+
FunctionId: record.functionSpec.FunctionId,
|
|
16
|
+
argsEncoded: record.argsEncoded,
|
|
17
|
+
callerMachineId: record.callerMachineId,
|
|
18
|
+
callerIP: record.callerIP,
|
|
19
|
+
runAtTime: record.runAtTime,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function makePathValue(path: string, value: unknown, time: PathValue["time"]): PathValue {
|
|
24
|
+
return {
|
|
25
|
+
path,
|
|
26
|
+
value,
|
|
27
|
+
time,
|
|
28
|
+
valid: true,
|
|
29
|
+
locks: [],
|
|
30
|
+
lockCount: 0,
|
|
31
|
+
isValueLazy: false,
|
|
32
|
+
isTransparent: isTransparentValue(value),
|
|
33
|
+
canGCValue: value === undefined,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A PathValueReadSource backed entirely by a single captured run's reads. Because the capture
|
|
38
|
+
// recorded every value the run actually read, everything is treated as synced, so a replay
|
|
39
|
+
// finishes in a single synchronous pass with no real database.
|
|
40
|
+
export function buildReplayReadSource(record: CaptureRecord): PathValueReadSource {
|
|
41
|
+
let values = new Map<string, PathValue>();
|
|
42
|
+
for (let read of record.reads) {
|
|
43
|
+
values.set(read.path, makePathValue(read.path, read.value, read.time));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Direct-parent -> children index, so getKeys()/Object.keys() enumeration replays identically.
|
|
47
|
+
let parentIndex = new Map<string, Set<string>>();
|
|
48
|
+
let addToParentIndex = (path: string) => {
|
|
49
|
+
let parent = getParentPathStr(path);
|
|
50
|
+
let set = parentIndex.get(parent);
|
|
51
|
+
if (!set) {
|
|
52
|
+
set = new Set();
|
|
53
|
+
parentIndex.set(parent, set);
|
|
54
|
+
}
|
|
55
|
+
set.add(path);
|
|
56
|
+
};
|
|
57
|
+
for (let read of record.reads) addToParentIndex(read.path);
|
|
58
|
+
for (let read of record.undefinedReads) addToParentIndex(read.path);
|
|
59
|
+
|
|
60
|
+
let overrideStack: Map<string, PathValue>[] = [];
|
|
61
|
+
let readWithOverrides = (path: string): PathValue | undefined => {
|
|
62
|
+
for (let i = overrideStack.length - 1; i >= 0; i--) {
|
|
63
|
+
let value = overrideStack[i].get(path);
|
|
64
|
+
if (value) return value;
|
|
65
|
+
}
|
|
66
|
+
return values.get(path);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
getValueAtOrBeforeTime(pathStr) {
|
|
71
|
+
let stored = readWithOverrides(pathStr);
|
|
72
|
+
if (stored) return stored;
|
|
73
|
+
// Mirror real storage: a synced-but-empty path resolves to an epoch transparent value
|
|
74
|
+
// (equivalent to "no value", but synced), so uncaptured reads don't stall the run.
|
|
75
|
+
return { path: pathStr, valid: true, time: epochTime, locks: [], lockCount: 0, value: undefined, canGCValue: true, isTransparent: true };
|
|
76
|
+
},
|
|
77
|
+
isSynced() {
|
|
78
|
+
return true;
|
|
79
|
+
},
|
|
80
|
+
isParentSynced() {
|
|
81
|
+
return true;
|
|
82
|
+
},
|
|
83
|
+
getPathsFromParent(pathStr) {
|
|
84
|
+
return parentIndex.get(pathStr);
|
|
85
|
+
},
|
|
86
|
+
temporaryOverride(overrides, code) {
|
|
87
|
+
if (!overrides || overrides.length === 0) return code();
|
|
88
|
+
let map = new Map<string, PathValue>();
|
|
89
|
+
for (let override of overrides) map.set(override.path, override);
|
|
90
|
+
overrideStack.push(map);
|
|
91
|
+
try {
|
|
92
|
+
return code();
|
|
93
|
+
} finally {
|
|
94
|
+
overrideStack.pop();
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
DEBUG_hasAnyValues(pathStr) {
|
|
98
|
+
return values.has(pathStr);
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface ReplayResult {
|
|
104
|
+
writes: PathValue[];
|
|
105
|
+
result: unknown;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Runs the function against a captured run's data in complete isolation, using a dry-run watcher so
|
|
109
|
+
// nothing is committed. Fully synchronous (the captured data is all "synced").
|
|
110
|
+
export function replayCapturedCall(config: {
|
|
111
|
+
record: CaptureRecord;
|
|
112
|
+
baseFunction: Function;
|
|
113
|
+
args: unknown[];
|
|
114
|
+
}): ReplayResult {
|
|
115
|
+
let { record, baseFunction, args } = config;
|
|
116
|
+
let readSource = buildReplayReadSource(record);
|
|
117
|
+
let callSpec = makeReplayCallSpec(record);
|
|
118
|
+
|
|
119
|
+
let captured: ReplayResult | { error: string } | undefined;
|
|
120
|
+
runWithPathValueReadSource(readSource, () => {
|
|
121
|
+
proxyWatcher.createWatcher({
|
|
122
|
+
dryRun: true,
|
|
123
|
+
canWrite: true,
|
|
124
|
+
runImmediately: true,
|
|
125
|
+
allowUnsyncedReads: true,
|
|
126
|
+
temporary: true,
|
|
127
|
+
skipPermissionsCheck: true,
|
|
128
|
+
nestedCalls: "ignore",
|
|
129
|
+
runAtTime: record.runAtTime,
|
|
130
|
+
maxLocksOverride: Number.MAX_SAFE_INTEGER,
|
|
131
|
+
debugName: `replay ${record.functionSpec.ModuleId}.${record.functionSpec.FunctionId} ${record.callId}`,
|
|
132
|
+
watchFunction: () => {
|
|
133
|
+
overrideCurrentCall({ spec: callSpec, fnc: record.functionSpec }, () => {
|
|
134
|
+
baseFunction(...args);
|
|
135
|
+
});
|
|
136
|
+
},
|
|
137
|
+
onResultUpdated: (result, writes) => {
|
|
138
|
+
if ("error" in result) {
|
|
139
|
+
captured = { error: result.error };
|
|
140
|
+
} else {
|
|
141
|
+
captured = { writes: writes ?? [], result: result.result };
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
if (!captured) {
|
|
148
|
+
throw new Error(`Replay produced no result for ${record.callId}`);
|
|
149
|
+
}
|
|
150
|
+
if ("error" in captured) {
|
|
151
|
+
throw new Error(`Replay threw for ${record.functionSpec.ModuleId}.${record.functionSpec.FunctionId} (${record.callId}): ${captured.error}`);
|
|
152
|
+
}
|
|
153
|
+
return captured;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface ReplayComparison {
|
|
157
|
+
identical: boolean;
|
|
158
|
+
matched: number;
|
|
159
|
+
mismatched: { path: string; captured: unknown; replayed: unknown }[];
|
|
160
|
+
// In the capture, but the replay didn't write it.
|
|
161
|
+
missing: { path: string; captured: unknown }[];
|
|
162
|
+
// The replay wrote it, but the capture didn't have it.
|
|
163
|
+
extra: { path: string; replayed: unknown }[];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function stableStringify(value: unknown): string {
|
|
167
|
+
return JSON.stringify(value, (_key, val) => {
|
|
168
|
+
if (val && typeof val === "object" && !Array.isArray(val)) {
|
|
169
|
+
let sorted: { [key: string]: unknown } = {};
|
|
170
|
+
for (let key of Object.keys(val).sort()) {
|
|
171
|
+
sorted[key] = (val as { [key: string]: unknown })[key];
|
|
172
|
+
}
|
|
173
|
+
return sorted;
|
|
174
|
+
}
|
|
175
|
+
return val;
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Compares the writes a replay produced against the writes originally captured. Times/locks are
|
|
180
|
+
// ignored (they're assigned fresh on every run); only path -> value output state is compared.
|
|
181
|
+
export function compareCapturedWrites(record: CaptureRecord, replayWrites: PathValue[]): ReplayComparison {
|
|
182
|
+
let capturedMap = new Map<string, unknown>();
|
|
183
|
+
for (let write of record.writes) capturedMap.set(write.path, write.value);
|
|
184
|
+
|
|
185
|
+
let replayMap = new Map<string, unknown>();
|
|
186
|
+
for (let write of replayWrites) replayMap.set(write.path, pathValueSerializer.getPathValue(write));
|
|
187
|
+
|
|
188
|
+
let matched = 0;
|
|
189
|
+
let mismatched: ReplayComparison["mismatched"] = [];
|
|
190
|
+
let missing: ReplayComparison["missing"] = [];
|
|
191
|
+
let extra: ReplayComparison["extra"] = [];
|
|
192
|
+
|
|
193
|
+
for (let [path, capturedValue] of capturedMap) {
|
|
194
|
+
if (!replayMap.has(path)) {
|
|
195
|
+
missing.push({ path, captured: capturedValue });
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
let replayedValue = replayMap.get(path);
|
|
199
|
+
if (stableStringify(capturedValue) === stableStringify(replayedValue)) {
|
|
200
|
+
matched++;
|
|
201
|
+
} else {
|
|
202
|
+
mismatched.push({ path, captured: capturedValue, replayed: replayedValue });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
for (let [path, replayedValue] of replayMap) {
|
|
206
|
+
if (!capturedMap.has(path)) {
|
|
207
|
+
extra.push({ path, replayed: replayedValue });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
identical: mismatched.length === 0 && missing.length === 0 && extra.length === 0,
|
|
213
|
+
matched,
|
|
214
|
+
mismatched,
|
|
215
|
+
missing,
|
|
216
|
+
extra,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function loadBaseFunction(spec: FunctionSpec): Promise<Function> {
|
|
221
|
+
let module = await getModuleFromSpec(spec);
|
|
222
|
+
let exportObj: any = module.exports;
|
|
223
|
+
for (let part of getPathFromStr(spec.exportPathStr)) {
|
|
224
|
+
exportObj = exportObj?.[part];
|
|
225
|
+
}
|
|
226
|
+
if (typeof exportObj !== "function") {
|
|
227
|
+
throw new Error(`Export at ${JSON.stringify(spec.exportPathStr)} in ${spec.FilePath} was not a function (it was ${typeof exportObj}). The captured code may not be loadable in this environment.`);
|
|
228
|
+
}
|
|
229
|
+
return exportObj as Function;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export interface ReplayRecordReport {
|
|
233
|
+
callId: string;
|
|
234
|
+
functionName: string;
|
|
235
|
+
ok: boolean;
|
|
236
|
+
error?: string;
|
|
237
|
+
comparison?: ReplayComparison;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export interface ReplaySummary {
|
|
241
|
+
total: number;
|
|
242
|
+
identical: number;
|
|
243
|
+
divergent: number;
|
|
244
|
+
errored: number;
|
|
245
|
+
reports: ReplayRecordReport[];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Loads each captured function and replays it in isolation, comparing the replayed writes against
|
|
249
|
+
// the originally captured writes. Requires the app's modules to be loadable (import its deploy.ts
|
|
250
|
+
// before calling this, so development modules are registered).
|
|
251
|
+
export async function replayCaptureRecords(records: CaptureRecord[]): Promise<ReplaySummary> {
|
|
252
|
+
let reports: ReplayRecordReport[] = [];
|
|
253
|
+
for (let record of records) {
|
|
254
|
+
let functionName = `${record.functionSpec.ModuleId}.${record.functionSpec.FunctionId}`;
|
|
255
|
+
try {
|
|
256
|
+
let baseFunction = await loadBaseFunction(record.functionSpec);
|
|
257
|
+
let args = parseArgs(makeReplayCallSpec(record));
|
|
258
|
+
let replay = replayCapturedCall({ record, baseFunction, args });
|
|
259
|
+
let comparison = compareCapturedWrites(record, replay.writes);
|
|
260
|
+
reports.push({ callId: record.callId, functionName, ok: comparison.identical, comparison });
|
|
261
|
+
} catch (e: any) {
|
|
262
|
+
reports.push({ callId: record.callId, functionName, ok: false, error: String(e?.stack || e) });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
total: reports.length,
|
|
267
|
+
identical: reports.filter(r => r.ok).length,
|
|
268
|
+
divergent: reports.filter(r => !r.ok && !r.error).length,
|
|
269
|
+
errored: reports.filter(r => r.error).length,
|
|
270
|
+
reports,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
@@ -18,7 +18,7 @@ import { RequireController, setRequireBootRequire } from "socket-function/requir
|
|
|
18
18
|
import { cache, cacheLimited, lazy } from "socket-function/src/caching";
|
|
19
19
|
import { getIdentityCAPromise, getOwnMachineId, getOwnThreadId, getThreadKeyCert, verifyMachineIdForPublicKey } from "sliftutils/misc/https/certs";
|
|
20
20
|
import { getHostedIP, getSNICerts, publishMachineARecords } from "../-e-certs/EdgeCertController";
|
|
21
|
-
import { debugCoreMode, registerGetCompressNetwork, authorityStorage, enableDebugRejections } from "../0-path-value-core/pathValueCore";
|
|
21
|
+
import { debugCoreMode, registerGetCompressNetwork, authorityStorage, enableDebugRejections, getNextTime } from "../0-path-value-core/pathValueCore";
|
|
22
22
|
import { clientWatcher, ClientWatcher } from "../1-path-client/pathValueClientWatcher";
|
|
23
23
|
import { SyncWatcher, proxyWatcher, specialObjectWriteValue, isSynced, PathValueProxyWatcher, atomic, doAtomicWrites, noAtomicSchema, undeleteFromLookup, registerSchemaPrefix, WatcherOptions, doProxyOptions } from "../2-proxy/PathValueProxyWatcher";
|
|
24
24
|
import { isInProxyDatabase, rawSchema } from "../2-proxy/pathDatabaseProxyBase";
|
|
@@ -906,7 +906,7 @@ export class Querysub {
|
|
|
906
906
|
globalThis.remapImportRequestsClientside = globalThis.remapImportRequestsClientside || [];
|
|
907
907
|
globalThis.remapImportRequestsClientside.push(async (args) => {
|
|
908
908
|
try {
|
|
909
|
-
let key: typeof identityStorageKey = "
|
|
909
|
+
let key: typeof identityStorageKey = "machineCA_14";
|
|
910
910
|
let storageValueJSON = localStorage.getItem(key);
|
|
911
911
|
if (!storageValueJSON) return args;
|
|
912
912
|
let storageValue = JSON.parse(storageValueJSON) as IdentityStorageType;
|
|
@@ -1204,6 +1204,9 @@ function getSyncedTimeUnique() {
|
|
|
1204
1204
|
}
|
|
1205
1205
|
|
|
1206
1206
|
function getSyncedTime() {
|
|
1207
|
+
if (!Querysub.isInSyncedCall()) {
|
|
1208
|
+
return Date.now();
|
|
1209
|
+
}
|
|
1207
1210
|
let setTime = getSetTime();
|
|
1208
1211
|
if (setTime !== undefined) {
|
|
1209
1212
|
return setTime;
|
|
@@ -123,6 +123,17 @@ export async function registerManagementPages2(config: {
|
|
|
123
123
|
controllerName: "DNSPageController",
|
|
124
124
|
getModule: () => import("./misc-pages/DNSPage"),
|
|
125
125
|
});
|
|
126
|
+
inputPages.push({
|
|
127
|
+
title: "Fnc Capture",
|
|
128
|
+
componentName: "FunctionCapturePage",
|
|
129
|
+
controllerName: "FunctionCaptureProxyController",
|
|
130
|
+
getModule: () => import("./misc-pages/FunctionCapturePage"),
|
|
131
|
+
});
|
|
132
|
+
inputPages.push({
|
|
133
|
+
title: "Capture Analyzer",
|
|
134
|
+
componentName: "FunctionCaptureAnalyzePage",
|
|
135
|
+
getModule: () => import("./misc-pages/FunctionCaptureAnalyzePage"),
|
|
136
|
+
});
|
|
126
137
|
inputPages.push({
|
|
127
138
|
title: "LOG VIEWER",
|
|
128
139
|
componentName: "LogViewer3",
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
module.allowclient = true;
|
|
2
|
+
|
|
3
|
+
import { qreact } from "../../4-dom/qreact";
|
|
4
|
+
import { css } from "typesafecss";
|
|
5
|
+
import { Querysub, t } from "../../4-querysub/Querysub";
|
|
6
|
+
import { Button } from "../../library-components/Button";
|
|
7
|
+
import { formatNumber, formatTime, formatDateTime } from "socket-function/src/formatting/format";
|
|
8
|
+
import { green, red } from "socket-function/src/formatting/logColors";
|
|
9
|
+
import { getPathStr2 } from "../../path";
|
|
10
|
+
import { sort } from "socket-function/src/misc";
|
|
11
|
+
import { CaptureRecord, CaptureFileHeader, decodeCaptureFile } from "../../3-path-functions/functionCaptureFormat";
|
|
12
|
+
|
|
13
|
+
// How many rows of a read/write list we show before requiring a "Show more" click.
|
|
14
|
+
const ROW_LIMIT = 50;
|
|
15
|
+
|
|
16
|
+
interface ParsedCapture {
|
|
17
|
+
header: CaptureFileHeader;
|
|
18
|
+
records: CaptureRecord[];
|
|
19
|
+
fileName: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class FunctionCaptureAnalyzePage extends qreact.Component {
|
|
23
|
+
state = t.state({
|
|
24
|
+
error: t.string(""),
|
|
25
|
+
data: t.atomic<ParsedCapture>(),
|
|
26
|
+
expandedGroups: t.lookup(t.boolean),
|
|
27
|
+
expandedCalls: t.lookup(t.boolean),
|
|
28
|
+
// Section id => how many rows to show.
|
|
29
|
+
rowLimits: t.lookup(t.number),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
private async loadFile(file: File | undefined) {
|
|
33
|
+
if (!file) return;
|
|
34
|
+
try {
|
|
35
|
+
let buffer = Buffer.from(await file.arrayBuffer());
|
|
36
|
+
let { header, records } = decodeCaptureFile(buffer);
|
|
37
|
+
Querysub.localCommit(() => {
|
|
38
|
+
this.state.data = { header, records, fileName: file.name };
|
|
39
|
+
this.state.error = "";
|
|
40
|
+
for (let key of Object.keys(this.state.expandedGroups)) delete this.state.expandedGroups[key];
|
|
41
|
+
for (let key of Object.keys(this.state.expandedCalls)) delete this.state.expandedCalls[key];
|
|
42
|
+
});
|
|
43
|
+
console.log(green(`Loaded capture ${file.name}: ${records.length} records`));
|
|
44
|
+
} catch (e: any) {
|
|
45
|
+
Querysub.localCommit(() => {
|
|
46
|
+
this.state.error = String(e?.stack || e);
|
|
47
|
+
});
|
|
48
|
+
console.error(red(`Failed to parse capture: ${e.stack}`));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
render() {
|
|
53
|
+
let data = this.state.data;
|
|
54
|
+
return <div className={css.pad2(10).vbox(14).fillWidth}>
|
|
55
|
+
<h1>Function Capture Analyzer</h1>
|
|
56
|
+
{this.renderDropZone()}
|
|
57
|
+
{this.state.error && <div className={css.colorhsl(0, 70, 60).whiteSpace("pre-wrap")}>{this.state.error}</div>}
|
|
58
|
+
{data && this.renderData(data)}
|
|
59
|
+
</div>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private renderDropZone() {
|
|
63
|
+
return <div
|
|
64
|
+
className={css.fillWidth.pad2(24).bord2(210, 30, 45, 2, "dashed").hsl(210, 40, 88).button.textAlign("center")}
|
|
65
|
+
onDragOver={e => e.preventDefault()}
|
|
66
|
+
onDrop={e => {
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
void this.loadFile(e.dataTransfer?.files?.[0]);
|
|
69
|
+
}}
|
|
70
|
+
onClick={() => {
|
|
71
|
+
let input = document.createElement("input");
|
|
72
|
+
input.type = "file";
|
|
73
|
+
input.accept = ".qsfncap";
|
|
74
|
+
input.onchange = () => void this.loadFile(input.files?.[0]);
|
|
75
|
+
input.click();
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
Drop a .qsfncap capture file here, or click to choose one.
|
|
79
|
+
</div>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private renderData(data: ParsedCapture) {
|
|
83
|
+
let groups = groupRecords(data.records);
|
|
84
|
+
return <div className={css.vbox(12).fillWidth}>
|
|
85
|
+
<div className={css.vbox(2)}>
|
|
86
|
+
<div className={css.fontWeight("bold")}>{data.fileName}</div>
|
|
87
|
+
<div className={css.fontSize(12).colorhsl(0, 0, 60)}>
|
|
88
|
+
version {data.header.version} · {formatNumber(data.records.length)} calls · {data.header.domainName || "?"} · created {formatDateTime(data.header.createdAt)}
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
<div className={css.vbox(1).fillWidth}>
|
|
92
|
+
{groups.map(group => this.renderGroup(group))}
|
|
93
|
+
</div>
|
|
94
|
+
</div>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private renderGroup(group: FunctionGroup) {
|
|
98
|
+
let expanded = !!this.state.expandedGroups[group.key];
|
|
99
|
+
return <div className={css.vbox(1).fillWidth.borderBottom("1px solid hsla(0,0%,0%,0.15)")}>
|
|
100
|
+
<div
|
|
101
|
+
className={css.hbox(16).fillWidth.pad2(8, 6).button}
|
|
102
|
+
onClick={() => Querysub.localCommit(() => { this.state.expandedGroups[group.key] = !expanded; })}
|
|
103
|
+
>
|
|
104
|
+
<div className={css.width(16)}>{expanded ? "▾" : "▸"}</div>
|
|
105
|
+
<div className={css.width(420).vbox(2)}>
|
|
106
|
+
<div className={css.fontWeight("bold")}>{group.functionName}</div>
|
|
107
|
+
<div className={css.fontSize(11).colorhsl(0, 0, 50)}>{group.filePath}</div>
|
|
108
|
+
</div>
|
|
109
|
+
<div className={css.width(90)}>{formatNumber(group.records.length)} calls</div>
|
|
110
|
+
<div className={css.width(140)}>avg {formatTime(group.avgEvalTime)}</div>
|
|
111
|
+
</div>
|
|
112
|
+
{expanded &&
|
|
113
|
+
<div className={css.vbox(1).fillWidth.pad2(24, 0)}>
|
|
114
|
+
{group.records.map((record, i) => this.renderCall(group.key, record, i))}
|
|
115
|
+
</div>
|
|
116
|
+
}
|
|
117
|
+
</div>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private renderCall(groupKey: string, record: CaptureRecord, index: number) {
|
|
121
|
+
let callKey = getPathStr2(groupKey, record.callId);
|
|
122
|
+
let expanded = !!this.state.expandedCalls[callKey];
|
|
123
|
+
return <div className={css.vbox(2).fillWidth.borderBottom("1px solid hsla(0,0%,0%,0.1)")}>
|
|
124
|
+
<div
|
|
125
|
+
className={css.hbox(16).fillWidth.pad2(6, 4).button}
|
|
126
|
+
onClick={() => Querysub.localCommit(() => { this.state.expandedCalls[callKey] = !expanded; })}
|
|
127
|
+
>
|
|
128
|
+
<div className={css.width(16)}>{expanded ? "▾" : "▸"}</div>
|
|
129
|
+
<div className={css.width(220).ellipsis}>call #{index + 1} · {record.callId}</div>
|
|
130
|
+
<div className={css.width(120)}>{formatNumber(record.reads.length)} reads</div>
|
|
131
|
+
<div className={css.width(120)}>{formatNumber(record.writes.length)} writes</div>
|
|
132
|
+
<div className={css.width(120)}>eval {formatTime(record.evalTime)}</div>
|
|
133
|
+
<div className={css.width(180)}>runAt {formatDateTime(record.runAtTime.time)}</div>
|
|
134
|
+
</div>
|
|
135
|
+
{expanded && this.renderCallDetail(callKey, record)}
|
|
136
|
+
</div>;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private renderCallDetail(callKey: string, record: CaptureRecord) {
|
|
140
|
+
return <div className={css.vbox(10).fillWidth.pad2(24, 6)}>
|
|
141
|
+
<div className={css.fontSize(12).colorhsl(0, 0, 60)}>args: {shortValue(record.argsEncoded)}</div>
|
|
142
|
+
{this.renderSection(getPathStr2(callKey, "reads"), "Reads", record.reads.map(r => `${r.path} = ${shortValue(r.value)}`))}
|
|
143
|
+
{record.undefinedReads.length > 0 &&
|
|
144
|
+
this.renderSection(getPathStr2(callKey, "undef"), "Undefined reads", record.undefinedReads.map(r => r.path))}
|
|
145
|
+
{record.parentKeyReads.length > 0 &&
|
|
146
|
+
this.renderSection(getPathStr2(callKey, "keys"), "Enumerated (getKeys)", record.parentKeyReads)}
|
|
147
|
+
{this.renderSection(getPathStr2(callKey, "writes"), "Writes", record.writes.map(w => `${w.path} = ${shortValue(w.value)}${w.isTransparent ? " (transparent)" : ""}`))}
|
|
148
|
+
</div>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private renderSection(sectionId: string, title: string, rows: string[]) {
|
|
152
|
+
let limit = ROW_LIMIT;
|
|
153
|
+
if (sectionId in this.state.rowLimits) {
|
|
154
|
+
limit = Number(this.state.rowLimits[sectionId]);
|
|
155
|
+
}
|
|
156
|
+
let shown = rows.slice(0, limit);
|
|
157
|
+
let remaining = rows.length - shown.length;
|
|
158
|
+
return <div className={css.vbox(2).fillWidth}>
|
|
159
|
+
<div className={css.fontWeight("bold")}>{title} ({formatNumber(rows.length)})</div>
|
|
160
|
+
<div className={css.vbox(1).fillWidth.fontFamily("monospace").fontSize(12)}>
|
|
161
|
+
{shown.map(row => <div className={css.whiteSpace("pre-wrap").wordBreak("break-all")}>{row}</div>)}
|
|
162
|
+
</div>
|
|
163
|
+
{remaining > 0 &&
|
|
164
|
+
<Button onClick={() => Querysub.localCommit(() => { this.state.rowLimits[sectionId] = limit + ROW_LIMIT; })}>
|
|
165
|
+
Show {Math.min(ROW_LIMIT, remaining)} more ({formatNumber(remaining)} remaining)
|
|
166
|
+
</Button>
|
|
167
|
+
}
|
|
168
|
+
</div>;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
interface FunctionGroup {
|
|
173
|
+
key: string;
|
|
174
|
+
functionName: string;
|
|
175
|
+
filePath: string;
|
|
176
|
+
avgEvalTime: number;
|
|
177
|
+
records: CaptureRecord[];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function groupRecords(records: CaptureRecord[]): FunctionGroup[] {
|
|
181
|
+
let map = new Map<string, CaptureRecord[]>();
|
|
182
|
+
for (let record of records) {
|
|
183
|
+
let key = getPathStr2(record.functionSpec.ModuleId, record.functionSpec.FunctionId);
|
|
184
|
+
let list = map.get(key);
|
|
185
|
+
if (!list) {
|
|
186
|
+
list = [];
|
|
187
|
+
map.set(key, list);
|
|
188
|
+
}
|
|
189
|
+
list.push(record);
|
|
190
|
+
}
|
|
191
|
+
let groups: FunctionGroup[] = [];
|
|
192
|
+
for (let [key, list] of map) {
|
|
193
|
+
let spec = list[0].functionSpec;
|
|
194
|
+
let totalEval = list.reduce((sum, r) => sum + r.evalTime, 0);
|
|
195
|
+
let exportName = spec.exportPathStr ? spec.exportPathStr.split("/").slice(1).join(".") : spec.FunctionId;
|
|
196
|
+
groups.push({
|
|
197
|
+
key,
|
|
198
|
+
functionName: `${spec.ModuleId} · ${exportName}`,
|
|
199
|
+
filePath: spec.FilePath,
|
|
200
|
+
avgEvalTime: totalEval / list.length,
|
|
201
|
+
records: list,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
sort(groups, group => -group.records.length);
|
|
205
|
+
return groups;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function shortValue(value: unknown): string {
|
|
209
|
+
let text: string;
|
|
210
|
+
try {
|
|
211
|
+
text = typeof value === "string" ? value : JSON.stringify(value);
|
|
212
|
+
} catch {
|
|
213
|
+
text = String(value);
|
|
214
|
+
}
|
|
215
|
+
if (text === undefined) text = String(value);
|
|
216
|
+
if (text.length > 300) {
|
|
217
|
+
text = text.slice(0, 300) + `… (${formatNumber(text.length)} chars)`;
|
|
218
|
+
}
|
|
219
|
+
return text;
|
|
220
|
+
}
|