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.
@@ -0,0 +1,259 @@
1
+ import preact from "preact";
2
+ import { qreact } from "../../4-dom/qreact";
3
+ import { css } from "../../4-dom/css";
4
+ import { Querysub } from "../../4-querysub/QuerysubController";
5
+ import { SocketFunction } from "socket-function/SocketFunction";
6
+ import { formatNumber } from "socket-function/src/formatting/format";
7
+ import { timeInHour, timeInMinute } from "socket-function/src/misc";
8
+ import { cacheArgsEqual } from "socket-function/src/caching";
9
+ import { t } from "../../2-proxy/schema2";
10
+ import { getSyncedController } from "../../library-components/SyncedController";
11
+ import { GrossStatsController } from "./GrossStatsController";
12
+ import {
13
+ GROSS_STATS_FIELDS,
14
+ GrossStatsField,
15
+ GrossStatsBucket,
16
+ } from "../../5-diagnostics/gross-stats/grossStats";
17
+ export { GrossStatsController } from "./GrossStatsController";
18
+
19
+ module.hotreload = true;
20
+
21
+ const REFRESH_INTERVAL_MS = 5 * timeInMinute;
22
+ const CHART_WIDTH = 1200;
23
+ const CHART_HEIGHT = 300;
24
+
25
+ const TIME_RANGES: { label: string; ms: number }[] = [
26
+ { label: "1h", ms: timeInHour },
27
+ { label: "6h", ms: 6 * timeInHour },
28
+ { label: "24h", ms: 24 * timeInHour },
29
+ { label: "7d", ms: 7 * 24 * timeInHour },
30
+ ];
31
+
32
+ let pageSchema = Querysub.createLocalSchema("grossStatsPage", {
33
+ rangeMs: t.atomic<number>(timeInHour),
34
+ excludedNodes: t.atomic<Set<string>>(new Set()),
35
+ selectedField: t.atomic<GrossStatsField>("functionCallsInner"),
36
+ });
37
+
38
+ const NODE_COLORS = [
39
+ "hsl(0, 70%, 55%)",
40
+ "hsl(30, 75%, 50%)",
41
+ "hsl(55, 80%, 45%)",
42
+ "hsl(120, 50%, 45%)",
43
+ "hsl(170, 60%, 45%)",
44
+ "hsl(210, 60%, 55%)",
45
+ "hsl(250, 55%, 60%)",
46
+ "hsl(290, 55%, 55%)",
47
+ "hsl(330, 65%, 55%)",
48
+ ];
49
+ function colorForNode(index: number): string {
50
+ return NODE_COLORS[index % NODE_COLORS.length];
51
+ }
52
+
53
+ function shortNodeId(nodeId: string): string {
54
+ let parts = nodeId.split(".");
55
+ if (parts.length >= 2) return `${parts[0].slice(0, 6)}…${parts[1].slice(0, 6)}`;
56
+ return nodeId.slice(0, 12);
57
+ }
58
+
59
+ let grossStatsController = getSyncedController(GrossStatsController);
60
+
61
+ // Render a stacked bar chart to a PNG data URL. Cached by argument identity:
62
+ // selectedField/rangeMs/width/height are primitives; nodeIds and bucketsArrays are
63
+ // expected to be stable references (the bucketsArrays come from syncedController and
64
+ // only change when refreshed).
65
+ const renderChartPNG = cacheArgsEqual((
66
+ selectedField: GrossStatsField,
67
+ rangeMs: number,
68
+ width: number,
69
+ height: number,
70
+ nodeIds: readonly string[],
71
+ bucketsArrays: readonly GrossStatsBucket[][],
72
+ ): string => {
73
+ let canvas = document.createElement("canvas");
74
+ canvas.width = width;
75
+ canvas.height = height;
76
+ let ctx = canvas.getContext("2d")!;
77
+ ctx.fillStyle = "hsl(0, 0%, 97%)";
78
+ ctx.fillRect(0, 0, width, height);
79
+
80
+ let now = Date.now();
81
+ let windowStart = now - rangeMs;
82
+
83
+ let perPixel: { perNode: number[]; total: number; }[] = [];
84
+ for (let x = 0; x < width; x++) {
85
+ perPixel.push({ perNode: new Array(nodeIds.length).fill(0), total: 0 });
86
+ }
87
+ let maxTotal = 0;
88
+ for (let i = 0; i < nodeIds.length; i++) {
89
+ let buckets = bucketsArrays[i];
90
+ if (!buckets) continue;
91
+ for (let bucket of buckets) {
92
+ if (bucket.time < windowStart) continue;
93
+ let xFloat = (bucket.time - windowStart) / rangeMs * width;
94
+ let x = Math.min(width - 1, Math.max(0, Math.floor(xFloat)));
95
+ let cell = perPixel[x];
96
+ let val = bucket.deltas[selectedField] || 0;
97
+ cell.perNode[i] += val;
98
+ cell.total += val;
99
+ if (cell.total > maxTotal) maxTotal = cell.total;
100
+ }
101
+ }
102
+ if (maxTotal <= 0) maxTotal = 1;
103
+
104
+ for (let x = 0; x < width; x++) {
105
+ let cell = perPixel[x];
106
+ if (cell.total === 0) continue;
107
+ let y = height;
108
+ for (let i = 0; i < nodeIds.length; i++) {
109
+ let val = cell.perNode[i];
110
+ if (!val) continue;
111
+ let segHeight = (val / maxTotal) * height;
112
+ ctx.fillStyle = colorForNode(i);
113
+ ctx.fillRect(x, y - segHeight, 1, segHeight);
114
+ y -= segHeight;
115
+ }
116
+ }
117
+
118
+ return canvas.toDataURL();
119
+ }, 5);
120
+
121
+ export class GrossStatsPage extends qreact.Component {
122
+ refreshTimer: ReturnType<typeof setInterval> | undefined;
123
+
124
+ componentDidMount() {
125
+ this.refreshTimer = setInterval(() => {
126
+ grossStatsController.refreshAll();
127
+ }, REFRESH_INTERVAL_MS);
128
+ }
129
+ componentWillUnmount() {
130
+ if (this.refreshTimer) clearInterval(this.refreshTimer);
131
+ }
132
+
133
+ private renderControls(nodeIds: string[]) {
134
+ let state = pageSchema();
135
+ return <div className={css.vbox(8).pad2(8)}>
136
+ <div className={css.hbox(6)}>
137
+ <span>Range:</span>
138
+ {TIME_RANGES.map(r =>
139
+ <span
140
+ className={css.button.pad2(4, 2) + (state.rangeMs === r.ms ? " " + css.hsl(210, 70, 60).hslcolor(0, 0, 100) : "")}
141
+ onClick={() => Querysub.commit(() => { state.rangeMs = r.ms; })}
142
+ >{r.label}</span>
143
+ )}
144
+ </div>
145
+ <div className={css.hbox(6).wrap}>
146
+ <span>Field:</span>
147
+ {GROSS_STATS_FIELDS.map(f =>
148
+ <span
149
+ className={css.button.pad2(4, 2) + (state.selectedField === f ? " " + css.hsl(140, 60, 50).hslcolor(0, 0, 100) : "")}
150
+ onClick={() => Querysub.commit(() => { state.selectedField = f; })}
151
+ >{f}</span>
152
+ )}
153
+ </div>
154
+ <div className={css.hbox(8).wrap}>
155
+ <span>Servers:</span>
156
+ {nodeIds.map((nodeId, i) =>
157
+ <label className={css.hbox(4)}>
158
+ <input
159
+ type="checkbox"
160
+ checked={!state.excludedNodes.has(nodeId)}
161
+ onChange={() => {
162
+ Querysub.commit(() => {
163
+ let next = new Set(state.excludedNodes);
164
+ if (next.has(nodeId)) next.delete(nodeId);
165
+ else next.add(nodeId);
166
+ state.excludedNodes = next;
167
+ });
168
+ }}
169
+ />
170
+ <span className={css.hslcolor(0, 0, 100).pad2(2)} style={{ background: colorForNode(i) }}>
171
+ {shortNodeId(nodeId)}
172
+ </span>
173
+ </label>
174
+ )}
175
+ </div>
176
+ </div>;
177
+ }
178
+
179
+ private renderTable(nodeIds: string[], bucketsArrays: GrossStatsBucket[][]) {
180
+ let state = pageSchema();
181
+ let now = Date.now();
182
+ let windowStart = now - state.rangeMs;
183
+
184
+ let perNodeTotals: Record<GrossStatsField, number>[] = [];
185
+ let totalsRow: Record<GrossStatsField, number> = {} as Record<GrossStatsField, number>;
186
+ for (let f of GROSS_STATS_FIELDS) totalsRow[f] = 0;
187
+
188
+ for (let i = 0; i < nodeIds.length; i++) {
189
+ let totals = {} as Record<GrossStatsField, number>;
190
+ for (let f of GROSS_STATS_FIELDS) totals[f] = 0;
191
+ for (let bucket of bucketsArrays[i] ?? []) {
192
+ if (bucket.time < windowStart) continue;
193
+ for (let f of GROSS_STATS_FIELDS) {
194
+ totals[f] += bucket.deltas[f];
195
+ totalsRow[f] += bucket.deltas[f];
196
+ }
197
+ }
198
+ perNodeTotals.push(totals);
199
+ }
200
+
201
+ return <table className={css.fillWidth}>
202
+ <thead>
203
+ <tr>
204
+ <th className={css.textAlign("left").pad2(4)}>Server</th>
205
+ {GROSS_STATS_FIELDS.map(f => <th className={css.textAlign("right").pad2(4)}>{f}</th>)}
206
+ </tr>
207
+ </thead>
208
+ <tbody>
209
+ <tr className={css.boldStyle}>
210
+ <td className={css.pad2(4)}>TOTAL</td>
211
+ {GROSS_STATS_FIELDS.map(f => <td className={css.textAlign("right").pad2(4)}>{formatNumber(totalsRow[f])}</td>)}
212
+ </tr>
213
+ {nodeIds.map((nodeId, i) =>
214
+ <tr>
215
+ <td className={css.pad2(4)} style={{ borderLeft: `4px solid ${colorForNode(i)}` }}>
216
+ {shortNodeId(nodeId)}
217
+ </td>
218
+ {GROSS_STATS_FIELDS.map(f =>
219
+ <td className={css.textAlign("right").pad2(4)}>{formatNumber(perNodeTotals[i][f])}</td>
220
+ )}
221
+ </tr>
222
+ )}
223
+ </tbody>
224
+ </table>;
225
+ }
226
+
227
+ render() {
228
+ let state = pageSchema();
229
+ let result = grossStatsController(SocketFunction.browserNodeId()).getGrossStats();
230
+ let bucketsByNode = result?.bucketsByNode ?? new Map<string, GrossStatsBucket[]>();
231
+ let allNodeIds = Array.from(bucketsByNode.keys()).sort();
232
+
233
+ let selectedNodeIds = allNodeIds.filter(n => !state.excludedNodes.has(n));
234
+ let bucketsArrays: GrossStatsBucket[][] = selectedNodeIds.map(n => bucketsByNode.get(n) ?? []);
235
+ let anyLoading = !result;
236
+
237
+ let chartPNG = renderChartPNG(
238
+ state.selectedField,
239
+ state.rangeMs,
240
+ CHART_WIDTH,
241
+ CHART_HEIGHT,
242
+ selectedNodeIds,
243
+ bucketsArrays,
244
+ );
245
+
246
+ return <div className={css.vbox(8).pad2(8).fillWidth}>
247
+ <h2>Cluster Stats</h2>
248
+ {this.renderControls(allNodeIds)}
249
+ <img
250
+ src={chartPNG}
251
+ width={CHART_WIDTH}
252
+ height={CHART_HEIGHT}
253
+ className={css.hsl(0, 0, 100)}
254
+ style={anyLoading ? { opacity: 0.5 } : undefined}
255
+ />
256
+ {this.renderTable(selectedNodeIds, bucketsArrays)}
257
+ </div>;
258
+ }
259
+ }
@@ -300,7 +300,10 @@ export class BufferIndex {
300
300
  dataReader: Reader;
301
301
  params: SearchParams;
302
302
  keepIterating: () => boolean;
303
- onResult: (match: Buffer) => void;
303
+ // Returns true iff the caller actually retained the value. We use that
304
+ // to drive the per-file matchCount cap below: see the note at the
305
+ // `matchesPattern(buffer)` call for why we can't blindly count emits.
306
+ onResult: (match: Buffer) => boolean;
304
307
  results: IndexedLogResults;
305
308
  allSearchUnits: Unit[][];
306
309
  matchesPattern: (buffer: Buffer) => boolean;
@@ -395,8 +398,24 @@ export class BufferIndex {
395
398
 
396
399
  let buffer = buffers[bufferIndex];
397
400
  if (matchesPattern(buffer)) {
398
- config.onResult(buffer);
399
- matchCount++;
401
+ // Only count matches the caller actually kept. `onResult`
402
+ // routes through `FindProgressTracker.addResult`, which
403
+ // can reject for reasons we don't see from here — most
404
+ // notably time-range filtering (an entry whose time is
405
+ // outside the search window matched the text pattern
406
+ // but isn't a real hit). Counting rejected emits would
407
+ // let a stretch of out-of-window matches at the start
408
+ // of a file blow the per-file cap and short-circuit the
409
+ // scan before we reach the in-window region.
410
+ //
411
+ // The cost is that we keep matching and calling onResult
412
+ // through those out-of-window blocks (mild inefficiency).
413
+ // We can't skip ahead — buffers are scanned linearly and
414
+ // we don't know up front which entries the caller will
415
+ // reject — so this is the best we can do here.
416
+ if (config.onResult(buffer)) {
417
+ matchCount++;
418
+ }
400
419
  }
401
420
  }
402
421
  } catch (e: any) {
@@ -531,7 +550,10 @@ export class BufferIndex {
531
550
  params: SearchParams;
532
551
 
533
552
  keepIterating: () => boolean;
534
- onResult: (match: Buffer) => void;
553
+ // See the note on `findLocal.onResult` return value drives the
554
+ // per-file matchCount cap so out-of-window emits don't short-circuit
555
+ // the scan.
556
+ onResult: (match: Buffer) => boolean;
535
557
  results: IndexedLogResults;
536
558
  }): Promise<{
537
559
  blockSearchTime: number;
@@ -505,7 +505,10 @@ export class BufferUnitIndex {
505
505
  params: SearchParams;
506
506
  allSearchUnits: Unit[][];
507
507
  keepIterating: () => boolean;
508
- onResult: (match: Buffer) => void;
508
+ // Returns true iff the caller actually retained the value. Drives the
509
+ // `matchCounts` cap below — see the comment at the `matchesPattern`
510
+ // call for why we can't blindly count emits.
511
+ onResult: (match: Buffer) => boolean;
509
512
  index: Buffer;
510
513
  reader: Reader;
511
514
  results: IndexedLogResults;
@@ -582,9 +585,24 @@ export class BufferUnitIndex {
582
585
 
583
586
  const buffer = await this.getBufferFromBlock(blockReader, i);
584
587
  if (matchesPattern(buffer)) {
585
- config.onResult(buffer);
586
- matchCount++;
587
- matchCounts[blockIndex]++;
588
+ // Only count matches the caller actually kept.
589
+ // `onResult` routes through
590
+ // `FindProgressTracker.addResult`, which can reject
591
+ // for reasons opaque to us — most notably
592
+ // time-range filtering. Counting rejected emits
593
+ // would let a stretch of out-of-window matches at
594
+ // the start of a file blow the per-file cap and
595
+ // short-circuit the scan before we reach the
596
+ // in-window region. The cost is that we keep
597
+ // matching and calling onResult through those
598
+ // out-of-window blocks (mild inefficiency); we
599
+ // can't skip ahead because buffers are scanned
600
+ // linearly and we don't know up front which
601
+ // entries the caller will reject.
602
+ if (config.onResult(buffer)) {
603
+ matchCount++;
604
+ matchCounts[blockIndex]++;
605
+ }
588
606
  }
589
607
  }
590
608
  } catch (e: any) {
@@ -620,12 +620,28 @@ export class IndexedLogs<T> {
620
620
  dataReader,
621
621
  params: {
622
622
  ...config.params,
623
- limit: config.params.limit - results.matchCount,
623
+ // Per-file cap = global `limit`, NOT `limit - matchCount`.
624
+ // Subtracting the running count would starve parallel
625
+ // files: once one file fills matchCount, every other
626
+ // in-flight file gets limit=0 and stops scanning, even
627
+ // though `isSourceRelevant` may still want their earlier
628
+ // matches to displace the kept top-K. Within a single
629
+ // file blocks are time-ordered in the scan direction,
630
+ // so capping at `limit` per file is safe — anything
631
+ // past the first `limit` matches in this file couldn't
632
+ // survive the top-K sort.
633
+ limit: config.params.limit,
624
634
  },
625
635
  keepIterating: () => !results.cancel && progressTracker.isSourceRelevant(path),
626
- onResult: (match: Buffer) => {
627
- // NOTE: We absolutely cannot do any limiting here, as that would just result in a lot of values that might be newer than the old values being discarded. Each loop itself has to do some kind of limiting as it knows how many it's found in its specific loop, and it knows the direction it's iterating and the direction of the values.
628
- progressTracker.addResult(match, path);
636
+ onResult: (match: Buffer): boolean => {
637
+ // Return value drives the per-file matchCount cap inside
638
+ // BufferIndex.find / BufferUnitIndex.find. addResult
639
+ // returns false when it rejects (time outside the
640
+ // search range, or past the kept top-K cutoff); the
641
+ // scanner uses that to avoid counting rejected emits
642
+ // against the cap. See the comment on those scanners'
643
+ // `matchesPattern` calls for the full reasoning.
644
+ return progressTracker.addResult(match, path);
629
645
  },
630
646
  results,
631
647
  });
@@ -38,7 +38,7 @@ import { startTimeParam, endTimeParam } from "../TimeRangeSelector";
38
38
  import { formatSearchString } from "./LogViewerParams";
39
39
 
40
40
  let excludePendingResults = new URLParam("excludePendingResults", false);
41
- let limitURL = new URLParam("limit", 16);
41
+ let limitURL = new URLParam("limit", 200);
42
42
  let enableLogsURL = new URLParam("enableLogs", true);
43
43
  let enableInfosURL = new URLParam("enableInfos", true);
44
44
  let enableWarningsURL = new URLParam("enableWarnings", true);
@@ -46,7 +46,6 @@ let enableErrorsURL = new URLParam("enableErrors", true);
46
46
 
47
47
  let savedPathsURL = new URLParam("savedPaths", "");
48
48
  let selectedFieldsURL = new URLParam("selectedFields", {} as Record<string, boolean>);
49
- let useRelativeTimeURL = new URLParam("useRelativeTime", true);
50
49
 
51
50
  // NOTE: Because this doesn't cache properly, it lags a lot, so we shouldn't try to use it.
52
51
  let showLifecycleColumnURL = new URLParam("showlifecycle", false);
@@ -269,7 +268,8 @@ export class LogViewer3 extends qreact.Component {
269
268
  let updateResults = throttleFunction(100, () => {
270
269
  if (this.searchSequenceNumber !== currentSequenceNumber) return;
271
270
  Querysub.commitLocal(() => {
272
- this.state.results = results;
271
+ // LIMIT, as the other values will be fragmented and so are confusing.
272
+ this.state.results = results.slice(0, limitURL.value);
273
273
  });
274
274
  });
275
275
 
@@ -495,17 +495,19 @@ export class LogViewer3 extends qreact.Component {
495
495
 
496
496
  for (let field of selectedFields) {
497
497
  let column: ColumnType<unknown, LogDatum> = {};
498
- if (field === "time") {
499
- column.formatter = (x: unknown) => useRelativeTimeURL.value ? formatDateJSX(Number(x)) : <span title={formatDateTimeDetailed(Number(x))}>{formatDateTime(Number(x))}</span>;
500
- } else if (field === "__machineId") {
501
- column.formatter = (x: unknown, context) => {
502
- if (!context?.row || !context.row.__machineId) return <ObjectDisplay value={x} />;
503
- return <MachineThreadInfo machineId={context.row.__machineId} threadId={context.row.__threadId || undefined} />;
504
- };
505
- }
506
- if (!column.formatter) {
507
- column.formatter = (x: unknown) => <ObjectDisplay value={x} />;
498
+ if (field === "time" || field === "timeId") {
499
+ column.formatter = (x: unknown) => <span title={formatDateTimeDetailed(Number(x))}>{formatDateTimeDetailed(Number(x))}</span>;
508
500
  }
501
+ // ObjectDisplay is too slow.
502
+ // else if (field === "__machineId") {
503
+ // column.formatter = (x: unknown, context) => {
504
+ // if (!context?.row || !context.row.__machineId) return <ObjectDisplay value={x} />;
505
+ // return <MachineThreadInfo machineId={context.row.__machineId} threadId={context.row.__threadId || undefined} />;
506
+ // };
507
+ // }
508
+ // if (!column.formatter) {
509
+ // column.formatter = (x: unknown) => <ObjectDisplay value={x} />;
510
+ // }
509
511
  columns[field] = column;
510
512
  }
511
513
 
@@ -622,11 +624,6 @@ export class LogViewer3 extends qreact.Component {
622
624
  selectedFieldsURL.value = newValues;
623
625
  }}
624
626
  />
625
- <InputLabelURL
626
- label="Use Relative Time"
627
- checkbox
628
- url={useRelativeTimeURL}
629
- />
630
627
  <InputLabelURL
631
628
  label="Show Lifecycles"
632
629
  checkbox