querysub 0.460.0 → 0.462.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.
Files changed (33) hide show
  1. package/package.json +2 -2
  2. package/src/-b-authorities/dnsAuthority.ts +23 -15
  3. package/src/-g-core-values/NodeCapabilities.ts +3 -0
  4. package/src/-h-path-value-serialize/PathValueSerializer.ts +11 -3
  5. package/src/0-path-value-core/PathRouter.ts +6 -0
  6. package/src/0-path-value-core/PathWatcher.ts +1 -1
  7. package/src/0-path-value-core/pathValueCore.ts +4 -7
  8. package/src/1-path-client/RemoteWatcher.ts +8 -3
  9. package/src/1-path-client/pathValueClientWatcher.ts +3 -0
  10. package/src/2-proxy/PathValueProxyWatcher.ts +1 -1
  11. package/src/2-proxy/TransactionDelayer.ts +1 -1
  12. package/src/3-path-functions/PathFunctionHelpers.ts +13 -8
  13. package/src/3-path-functions/PathFunctionRunner.ts +2 -0
  14. package/src/4-querysub/Querysub.ts +0 -1
  15. package/src/4-querysub/QuerysubController.ts +1 -7
  16. package/src/config.ts +6 -0
  17. package/src/config2.ts +7 -1
  18. package/src/deployManager/components/MachinePicker.tsx +40 -0
  19. package/src/deployManager/components/ServiceDetailPage.tsx +2 -5
  20. package/src/deployManager/components/ServicesListPage.tsx +2 -0
  21. package/src/deployManager/components/Tools.tsx +165 -0
  22. package/src/deployManager/machineApplyMainCode.ts +2 -2
  23. package/src/deployManager/setupMachineMain.ts +65 -23
  24. package/src/diagnostics/charts/Chart.tsx +240 -0
  25. package/src/diagnostics/grossStats/GrossStatsPage.tsx +48 -83
  26. package/src/diagnostics/logs/IndexedLogs/IndexedLogs.ts +3 -3
  27. package/src/diagnostics/logs/IndexedLogs/MCPIndexedLogs.ts +18 -3
  28. package/src/diagnostics/logs/IndexedLogs/MCPIndexedLogsEntry.ts +1 -0
  29. package/src/diagnostics/managementPages.tsx +58 -58
  30. package/src/diagnostics/misc-pages/DNSPage.tsx +344 -0
  31. package/test.ts +29 -170
  32. package/src/diagnostics/AuditLogPage.tsx +0 -147
  33. package/src/diagnostics/NodeConnectionsPage.tsx +0 -167
@@ -30,8 +30,8 @@ import { fsExistsAsync } from "../fs";
30
30
  // to PIPE_FILE_LINE_LIMIT lines it is truncated down to PIPE_FILE_LINE_KEEP. The
31
31
  // file size is checked every PIPE_FILE_LINE_KEEP lines, so the file stays within
32
32
  // [PIPE_FILE_LINE_KEEP, PIPE_FILE_LINE_LIMIT] lines.
33
- const PIPE_FILE_LINE_LIMIT = 2_000;
34
- const PIPE_FILE_LINE_KEEP = 1_000;
33
+ const PIPE_FILE_LINE_LIMIT = 10_000;
34
+ const PIPE_FILE_LINE_KEEP = 5_000;
35
35
 
36
36
 
37
37
  const getMemoryInfo = measureWrap(async function getMemoryInfo(): Promise<{ value: number; max: number } | undefined> {
@@ -12,7 +12,32 @@ Querysub;
12
12
 
13
13
  const pinnedNodeVersion = 22;
14
14
 
15
- async function getGitHubApiKey(repoUrl: string, sshRemote: string): Promise<string> {
15
+ function getGitHubKeyCachePath(repoUrl: string): string {
16
+ let repoOwner = "";
17
+ let repoName = "";
18
+ const sshMatch = repoUrl.match(/git@github\.com:([^/]+)\/(.+)\.git$/);
19
+ const httpsMatch = repoUrl.match(/https:\/\/github\.com\/([^/]+)\/(.+)\.git$/);
20
+ if (sshMatch) {
21
+ repoOwner = sshMatch[1];
22
+ repoName = sshMatch[2];
23
+ } else if (httpsMatch) {
24
+ repoOwner = httpsMatch[1];
25
+ repoName = httpsMatch[2];
26
+ }
27
+ return os.homedir() + `/githubkey_${repoOwner}_${repoName}.json`;
28
+ }
29
+
30
+ async function verifyGitHubApiKey(apiKey: string): Promise<boolean> {
31
+ const response = await fetch("https://api.github.com/user", {
32
+ headers: {
33
+ "Authorization": `Bearer ${apiKey}`,
34
+ "Accept": "application/vnd.github+json"
35
+ }
36
+ });
37
+ return response.ok;
38
+ }
39
+
40
+ async function getGitHubApiKey(repoUrl: string, sshRemote: string, forceRefresh = false): Promise<string> {
16
41
  // Parse repository info from URL
17
42
  let repoOwner = "";
18
43
  let repoName = "";
@@ -30,15 +55,18 @@ async function getGitHubApiKey(repoUrl: string, sshRemote: string): Promise<stri
30
55
  repoName = httpsMatch[2];
31
56
  }
32
57
 
33
- const cacheFile = os.homedir() + `/githubkey_${repoOwner}_${repoName}.json`;
58
+ const cacheFile = getGitHubKeyCachePath(repoUrl);
34
59
 
35
60
  // Check if we have a cached key
36
- if (await fsExistsAsync(cacheFile)) {
61
+ if (!forceRefresh && await fsExistsAsync(cacheFile)) {
37
62
  try {
38
63
  const cached = JSON.parse(fs.readFileSync(cacheFile, "utf8"));
39
64
  if (cached.apiKey) {
40
- console.log(`✅ Using cached GitHub API key from ${cacheFile}`);
41
- return cached.apiKey;
65
+ if (await verifyGitHubApiKey(cached.apiKey)) {
66
+ console.log(`✅ Using cached GitHub API key from ${cacheFile}`);
67
+ return cached.apiKey;
68
+ }
69
+ console.warn(`⚠️ Cached GitHub API key at ${cacheFile} failed verification (401 Bad credentials) — requesting a new one`);
42
70
  }
43
71
  } catch {
44
72
  // Invalid cache file, we'll ask for a new key
@@ -84,8 +112,6 @@ async function getGitHubApiKey(repoUrl: string, sshRemote: string): Promise<stri
84
112
  }
85
113
 
86
114
  async function addDeployKeyToGitHub(sshPublicKey: string, keyTitle: string, repoUrl: string, sshRemote: string): Promise<void> {
87
- const apiKey = await getGitHubApiKey(repoUrl, sshRemote);
88
-
89
115
  // Parse repository info from URL to get owner/repo for the API endpoint
90
116
  let repoOwner = "";
91
117
  let repoName = "";
@@ -104,26 +130,42 @@ async function addDeployKeyToGitHub(sshPublicKey: string, keyTitle: string, repo
104
130
 
105
131
  let url = `https://api.github.com/repos/${repoOwner}/${repoName}/keys`;
106
132
  console.log(url);
107
- const response = await fetch(url, {
108
- method: "POST",
109
- headers: {
110
- "Authorization": `Bearer ${apiKey}`,
111
- "Accept": "application/vnd.github+json",
112
- "Content-Type": "application/json"
113
- },
114
- body: JSON.stringify({
115
- title: keyTitle,
116
- key: sshPublicKey,
117
- read_only: true
118
- })
119
- });
120
133
 
121
- if (!response.ok) {
134
+ let forceRefresh = false;
135
+ while (true) {
136
+ const apiKey = await getGitHubApiKey(repoUrl, sshRemote, forceRefresh);
137
+ const response = await fetch(url, {
138
+ method: "POST",
139
+ headers: {
140
+ "Authorization": `Bearer ${apiKey}`,
141
+ "Accept": "application/vnd.github+json",
142
+ "Content-Type": "application/json"
143
+ },
144
+ body: JSON.stringify({
145
+ title: keyTitle,
146
+ key: sshPublicKey,
147
+ read_only: true
148
+ })
149
+ });
150
+
151
+ if (response.ok) {
152
+ console.log("✅ Deploy key added to GitHub repository");
153
+ return;
154
+ }
155
+
122
156
  const errorText = await response.text();
157
+ if (response.status === 401 && !forceRefresh) {
158
+ console.warn(`⚠️ GitHub API rejected credentials (401). Invalidating cached token and re-prompting.`);
159
+ try {
160
+ fs.unlinkSync(getGitHubKeyCachePath(repoUrl));
161
+ } catch {
162
+ // Cache file may not exist; ignore
163
+ }
164
+ forceRefresh = true;
165
+ continue;
166
+ }
123
167
  throw new Error(`Failed to add deploy key to GitHub repository: ${response.status} ${errorText}`);
124
168
  }
125
-
126
- console.log("✅ Deploy key added to GitHub repository");
127
169
  }
128
170
 
129
171
  async function setupRepositoryOnRemote(sshRemote: string, gitURLLive: string, gitRefLive: string): Promise<void> {
@@ -0,0 +1,240 @@
1
+ import preact from "preact";
2
+ import { qreact } from "../../4-dom/qreact";
3
+ import { css } from "../../4-dom/css";
4
+ import { JSXFormatter, formatValue } from "../../5-diagnostics/GenericFormat";
5
+ import { MeasuredDiv } from "../../library-components/MeasuredDiv";
6
+
7
+ export type FieldDef = {
8
+ title?: string;
9
+ /** If this value is sampled, for example, checking the current CPU usage, disk usage, etc, this field must be set to true. */
10
+ isSampled?: boolean;
11
+ isDateType?: boolean;
12
+ formatter?: JSXFormatter;
13
+ };
14
+
15
+ export type ChartData<T> = {
16
+ rows: T[];
17
+ columns: { [columnName in keyof T]?: FieldDef };
18
+ };
19
+
20
+ const DEFAULT_BAR_WIDTH_PX = 100;
21
+ const Y_LABEL_TARGET_COUNT = 6;
22
+ const Y_LABEL_MAX_WIDTH_PX = 120;
23
+ const X_LABEL_MAX_WIDTH_PX = 140;
24
+ const X_LABEL_FONT_SIZE_PX = 11;
25
+ const Y_LABEL_FONT_SIZE_PX = 11;
26
+ const GRID_LINE_COLOR = "hsla(0, 0%, 0%, 0.12)";
27
+ const BAR_GAP_PX = 2;
28
+ const BAR_COLOR = "hsl(210, 60%, 55%)";
29
+ const MIN_VISIBLE_BAR_FRAC = 0.005;
30
+
31
+ function getNiceNumber(num: number): number {
32
+ if (num <= 0) return 1;
33
+ let zeros = Math.floor(Math.log10(num));
34
+ let fraction = num / 10 ** zeros;
35
+ if (fraction <= 1) fraction = 1;
36
+ else if (fraction <= 2) fraction = 2;
37
+ else if (fraction <= 5) fraction = 5;
38
+ else fraction = 10;
39
+ return fraction * 10 ** zeros;
40
+ }
41
+
42
+ export class ChartBar<T> extends qreact.Component<{
43
+ data: ChartData<T>;
44
+ xAxis: keyof T;
45
+ yAxis: keyof T;
46
+ // Defaults to 100px
47
+ // - is only roughly followed. At render time, we determine the width of the entire screen, and then we assume that we're going to fill the entire width, and then we divide that by this and round up, and that's how many bars we try to show. That way if they resize the window, we can utilize CSS to dynamically scale so nothing gets misaligned.
48
+ barWidthPx?: number;
49
+ }> {
50
+ state = {
51
+ availableWidth: typeof window !== "undefined" ? window.innerWidth : 1200,
52
+ };
53
+
54
+ render() {
55
+ const { data, xAxis, yAxis } = this.props;
56
+ const barWidthPx = this.props.barWidthPx ?? DEFAULT_BAR_WIDTH_PX;
57
+ const rows = data.rows;
58
+
59
+ const xCol = data.columns[xAxis];
60
+ const yCol = data.columns[yAxis];
61
+ const yFormatter: JSXFormatter = yCol?.formatter ?? "guess";
62
+ const xFormatter: JSXFormatter = xCol?.formatter ?? (xCol?.isDateType ? "date" : "guess");
63
+
64
+ const targetBarCount = Math.max(1, Math.ceil(this.state.availableWidth / barWidthPx));
65
+ // Aggregate rows into at most targetBarCount bars (sum y values inside each bucket).
66
+ let displayBars: { xValue: unknown; yValue: number; rowsCount: number }[] = [];
67
+ if (rows.length <= targetBarCount) {
68
+ for (let r of rows) {
69
+ displayBars.push({ xValue: r[xAxis], yValue: Number(r[yAxis]) || 0, rowsCount: 1 });
70
+ }
71
+ } else {
72
+ // Bucket adjacent rows together. Each bucket gets ceil(rows.length / targetBarCount) rows.
73
+ let perBucket = Math.ceil(rows.length / targetBarCount);
74
+ for (let i = 0; i < rows.length; i += perBucket) {
75
+ let slice = rows.slice(i, i + perBucket);
76
+ let sum = 0;
77
+ for (let r of slice) sum += Number(r[yAxis]) || 0;
78
+ // Use the middle row's x value as the bucket label.
79
+ let mid = slice[Math.floor(slice.length / 2)];
80
+ displayBars.push({ xValue: mid[xAxis], yValue: sum, rowsCount: slice.length });
81
+ }
82
+ }
83
+
84
+ let maxY = 0;
85
+ for (let b of displayBars) {
86
+ if (b.yValue > maxY) maxY = b.yValue;
87
+ }
88
+ if (maxY <= 0) maxY = 1;
89
+ maxY = getNiceNumber(maxY);
90
+
91
+ // Build y labels from 0 to maxY, descending so top-of-chart is maxY.
92
+ let yLabelValues: number[] = [];
93
+ for (let i = 0; i < Y_LABEL_TARGET_COUNT; i++) {
94
+ let frac = 1 - i / (Y_LABEL_TARGET_COUNT - 1);
95
+ yLabelValues.push(frac * maxY);
96
+ }
97
+
98
+ const formatY = (v: number) => formatValue(v, yFormatter, { columnName: yAxis as string });
99
+ const formatX = (v: unknown) => formatValue(v, xFormatter, { columnName: xAxis as string });
100
+
101
+ const yLabelMaxWidth = `min(${Y_LABEL_MAX_WIDTH_PX}px, 15vw)` as const;
102
+ const xLabelMaxWidth = `min(${X_LABEL_MAX_WIDTH_PX}px, 12vw)` as const;
103
+
104
+ // Outer grid: [yLabels | chartArea] / [ | xLabels]
105
+ // yLabels column: shrinks to content but capped via max-width on labels.
106
+ // chartArea: contains horizontal grid lines (absolute) and a nested grid of bars.
107
+
108
+ // Y-axis labels: each label sits ON its grid line. We render them as a flex column
109
+ // with space-between and translate each by -50% so the text is centered on the line.
110
+ let yAxisLabelsNode = (
111
+ <div class={
112
+ css.relative.fillHeight.vbox0.justifyContent("space-between").alignEnd
113
+ .paddingRight(8)
114
+ .fontSize(Y_LABEL_FONT_SIZE_PX)
115
+ }>
116
+ {yLabelValues.map((v, i) => (
117
+ <div class={
118
+ css.relative
119
+ .maxWidth(yLabelMaxWidth as any)
120
+ .overflowHidden
121
+ .whiteSpace("nowrap")
122
+ .textOverflow("ellipsis")
123
+ .textAlign("right")
124
+ .top(i === 0 ? 0 : i === yLabelValues.length - 1 ? 0 : "-0.5em" as any)
125
+ } title={String(v)}>
126
+ {formatY(v)}
127
+ </div>
128
+ ))}
129
+ </div>
130
+ );
131
+
132
+ // Horizontal grid lines, one per label position. Use a vertical flex with
133
+ // space-between, just like the labels, so they line up exactly.
134
+ let gridLinesNode = (
135
+ <div class={
136
+ css.absolute.pos(0, 0).fillBoth.pointerEvents("none")
137
+ .vbox0.justifyContent("space-between")
138
+ }>
139
+ {yLabelValues.map(() => (
140
+ <div class={css.fillWidth.height(1).background(GRID_LINE_COLOR)} />
141
+ ))}
142
+ </div>
143
+ );
144
+
145
+ let barsNode = (
146
+ <div class={
147
+ css.relative.fillBoth.display("grid")
148
+ .gridTemplateColumns(`repeat(${displayBars.length}, 1fr)`)
149
+ .alignItems("end")
150
+ .columnGap(BAR_GAP_PX)
151
+ }>
152
+ {displayBars.map(bar => {
153
+ let frac = bar.yValue / maxY;
154
+ if (!isFinite(frac) || frac < 0) frac = 0;
155
+ if (frac > 1) frac = 1;
156
+ // Make tiny non-zero bars at least minimally visible.
157
+ if (bar.yValue > 0 && frac < MIN_VISIBLE_BAR_FRAC) frac = MIN_VISIBLE_BAR_FRAC;
158
+ let xStr = (() => {
159
+ let f = formatX(bar.xValue);
160
+ if (typeof f === "string") return f;
161
+ if (typeof f === "number") return String(f);
162
+ return String(bar.xValue);
163
+ })();
164
+ let yStr = (() => {
165
+ let f = formatY(bar.yValue);
166
+ if (typeof f === "string") return f;
167
+ if (typeof f === "number") return String(f);
168
+ return String(bar.yValue);
169
+ })();
170
+ return (
171
+ <div
172
+ class={
173
+ css.fillWidth
174
+ .height(`${frac * 100}%` as any)
175
+ .background(BAR_COLOR)
176
+ .filter("brightness(1.15)", "hover")
177
+ }
178
+ title={`${xCol?.title ?? String(xAxis)}: ${xStr}\n${yCol?.title ?? String(yAxis)}: ${yStr}`}
179
+ />
180
+ );
181
+ })}
182
+ </div>
183
+ );
184
+
185
+ // X-axis labels: same column grid as bars so each label sits under its bar.
186
+ let xAxisLabelsNode = (
187
+ <div class={
188
+ css.display("grid")
189
+ .gridTemplateColumns(`repeat(${displayBars.length}, 1fr)`)
190
+ .columnGap(BAR_GAP_PX)
191
+ .paddingTop(4)
192
+ .fontSize(X_LABEL_FONT_SIZE_PX)
193
+ }>
194
+ {displayBars.map(bar => {
195
+ let xStr = (() => {
196
+ let f = formatX(bar.xValue);
197
+ if (typeof f === "string") return f;
198
+ if (typeof f === "number") return String(f);
199
+ return String(bar.xValue);
200
+ })();
201
+ return (
202
+ <div class={
203
+ css.textAlign("center")
204
+ .maxWidth(xLabelMaxWidth as any)
205
+ .overflowHidden
206
+ .whiteSpace("nowrap")
207
+ .textOverflow("ellipsis")
208
+ } title={xStr}>
209
+ {formatX(bar.xValue)}
210
+ </div>
211
+ );
212
+ })}
213
+ </div>
214
+ );
215
+
216
+ return (
217
+ <MeasuredDiv
218
+ onNewSize={(width) => {
219
+ if (width !== this.state.availableWidth) {
220
+ this.state.availableWidth = width;
221
+ }
222
+ }}
223
+ class={
224
+ css.fillBoth.display("grid")
225
+ .gridTemplateColumns(`auto 1fr`)
226
+ .gridTemplateRows(`1fr auto`)
227
+ .minHeight(200)
228
+ }
229
+ >
230
+ {yAxisLabelsNode}
231
+ <div class={css.relative.fillBoth.overflowHidden}>
232
+ {gridLinesNode}
233
+ {barsNode}
234
+ </div>
235
+ <div />
236
+ {xAxisLabelsNode}
237
+ </MeasuredDiv>
238
+ );
239
+ }
240
+ }
@@ -3,9 +3,8 @@ import { qreact } from "../../4-dom/qreact";
3
3
  import { css } from "../../4-dom/css";
4
4
  import { Querysub } from "../../4-querysub/QuerysubController";
5
5
  import { SocketFunction } from "socket-function/SocketFunction";
6
- import { formatNumber } from "socket-function/src/formatting/format";
6
+ import { formatDateTime, formatNumber } from "socket-function/src/formatting/format";
7
7
  import { timeInHour, timeInMinute } from "socket-function/src/misc";
8
- import { cacheArgsEqual } from "socket-function/src/caching";
9
8
  import { t } from "../../2-proxy/schema2";
10
9
  import { getSyncedController } from "../../library-components/SyncedController";
11
10
  import { GrossStatsController } from "./GrossStatsController";
@@ -14,12 +13,12 @@ import {
14
13
  GrossStatsField,
15
14
  GrossStatsBucket,
16
15
  } from "../../5-diagnostics/gross-stats/grossStats";
16
+ import { ChartBar, ChartData } from "../charts/Chart";
17
17
  export { GrossStatsController } from "./GrossStatsController";
18
18
 
19
19
  module.hotreload = true;
20
20
 
21
21
  const REFRESH_INTERVAL_MS = 5 * timeInMinute;
22
- const CHART_WIDTH = 1200;
23
22
  const CHART_HEIGHT = 300;
24
23
 
25
24
  const TIME_RANGES: { label: string; ms: number }[] = [
@@ -58,65 +57,33 @@ function shortNodeId(nodeId: string): string {
58
57
 
59
58
  let grossStatsController = getSyncedController(GrossStatsController);
60
59
 
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((
60
+ type ChartRow = { time: number; value: number };
61
+
62
+ function buildChartRows(
66
63
  selectedField: GrossStatsField,
67
64
  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
-
65
+ bucketsArrays: GrossStatsBucket[][],
66
+ ): { rows: ChartRow[]; peak: number } {
80
67
  let now = Date.now();
81
68
  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];
69
+ // Sum across selected nodes, keyed by bucket time (each bucket is one minute).
70
+ let sumByTime = new Map<number, number>();
71
+ for (let buckets of bucketsArrays) {
90
72
  if (!buckets) continue;
91
73
  for (let bucket of buckets) {
92
74
  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;
75
+ let v = bucket.deltas[selectedField] || 0;
76
+ sumByTime.set(bucket.time, (sumByTime.get(bucket.time) || 0) + v);
100
77
  }
101
78
  }
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
- }
79
+ let times = Array.from(sumByTime.keys()).sort((a, b) => a - b);
80
+ let rows: ChartRow[] = times.map(t => ({ time: t, value: sumByTime.get(t)! }));
81
+ let peak = 0;
82
+ for (let r of rows) {
83
+ if (r.value > peak) peak = r.value;
116
84
  }
117
-
118
- return { pngUrl: canvas.toDataURL(), maxTotal };
119
- }, 5);
85
+ return { rows, peak };
86
+ }
120
87
 
121
88
  export class GrossStatsPage extends qreact.Component {
122
89
  refreshTimer: ReturnType<typeof setInterval> | undefined;
@@ -137,7 +104,7 @@ export class GrossStatsPage extends qreact.Component {
137
104
  <span>Range:</span>
138
105
  {TIME_RANGES.map(r =>
139
106
  <button
140
- className={css.pad2(8, 4) + (state.rangeMs === r.ms ? " " + css.hsl(210, 70, 60).hslcolor(0, 0, 100).bold : "")}
107
+ className={css.pad2(8, 4) + (state.rangeMs === r.ms ? " " + css.hsl(210, 70, 60).hslcolor(0, 0, 100).boldStyle : "")}
141
108
  onClick={() => Querysub.commit(() => { state.rangeMs = r.ms; })}
142
109
  >{r.label}</button>
143
110
  )}
@@ -146,7 +113,7 @@ export class GrossStatsPage extends qreact.Component {
146
113
  <span>Field:</span>
147
114
  {GROSS_STATS_FIELDS.map(f =>
148
115
  <button
149
- className={css.pad2(8, 4) + (state.selectedField === f ? " " + css.hsl(140, 60, 50).hslcolor(0, 0, 100).bold : "")}
116
+ className={css.pad2(8, 4) + (state.selectedField === f ? " " + css.hsl(140, 60, 50).hslcolor(0, 0, 100).boldStyle : "")}
150
117
  onClick={() => Querysub.commit(() => { state.selectedField = f; })}
151
118
  >{f}</button>
152
119
  )}
@@ -254,44 +221,42 @@ export class GrossStatsPage extends qreact.Component {
254
221
  let bucketsArrays: GrossStatsBucket[][] = selectedNodeIds.map(n => bucketsByNode.get(n) ?? []);
255
222
  let anyLoading = !result;
256
223
 
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;
224
+ let { rows, peak } = buildChartRows(state.selectedField, state.rangeMs, bucketsArrays);
225
+
226
+ let chartData: ChartData<ChartRow> = {
227
+ rows,
228
+ columns: {
229
+ time: {
230
+ title: "Time",
231
+ isDateType: true,
232
+ formatter: (v) => formatDateTime(Number(v)),
233
+ },
234
+ value: {
235
+ title: state.selectedField,
236
+ formatter: (v) => formatNumber(Number(v)),
237
+ },
238
+ },
239
+ };
267
240
 
268
241
  return <div className={css.vbox(8).pad2(8).fillWidth}>
269
242
  <h2>Cluster Stats</h2>
270
243
  {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>
244
+ <div className={css.vbox(4).fillWidth}>
245
+ <div className={css.hbox(8).boldStyle}>
246
+ <span>peak: {formatNumber(peak)} {state.selectedField} / minute</span>
274
247
  </div>
275
- <img
276
- src={chart.pngUrl}
277
- width={CHART_WIDTH}
278
- height={CHART_HEIGHT}
279
- className={css.hsl(0, 0, 100)}
248
+ <div
249
+ className={css.fillWidth.height(CHART_HEIGHT).hsl(0, 0, 100)}
280
250
  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>
251
+ >
252
+ <ChartBar<ChartRow>
253
+ data={chartData}
254
+ xAxis="time"
255
+ yAxis="value"
256
+ />
286
257
  </div>
287
258
  </div>
288
259
  {this.renderTable(selectedNodeIds, bucketsArrays)}
289
260
  </div>;
290
261
  }
291
262
  }
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
- }
@@ -286,11 +286,11 @@ export class IndexedLogs<T> {
286
286
  let added = false;
287
287
  const tryAdd = async (nodeId: string, preferredOnly = false) => {
288
288
  if (added) return;
289
- let hasLogger = await timeoutToUndefinedSilent(2500, IndexedLogShimController.nodes[nodeId].hasLogger(this.config.name));
289
+ let hasLogger = await timeoutToUndefinedSilent(10_000, IndexedLogShimController.nodes[nodeId].hasLogger(this.config.name));
290
290
  if (!hasLogger) return false;
291
291
  // NOTE: Prefer to do the searching on the move logs service. However, if it's not available, any service can do searching. It just might lag that server...
292
292
  if (preferredOnly) {
293
- let metadata = await timeoutToUndefinedSilent(2500, NodeCapabilitiesController.nodes[nodeId].getMetadata());
293
+ let metadata = await timeoutToUndefinedSilent(10_000, NodeCapabilitiesController.nodes[nodeId].getMetadata());
294
294
  if (!metadata?.entryPoint.includes("movelogs")) return false;
295
295
  }
296
296
  added = true;
@@ -400,13 +400,13 @@ export class IndexedLogs<T> {
400
400
  onResult: (match: T) => void;
401
401
  onResults?: (results: IndexedLogResults) => Promise<boolean>;
402
402
  }): Promise<IndexedLogResults> {
403
-
404
403
  let { params } = config;
405
404
  if (params.pathOverrides && config.params.only === "public") {
406
405
  // Fine, if they provided path overrides, and they are all public, just read them, as they'll resolve the same no matter where we read from
407
406
  } else if (config.params.forceReadProduction && !isPublic()) {
408
407
  let machineNodes = await this.getMachineNodes();
409
408
  if (machineNodes.length === 0) throw new Error(`Cannot find any public nodes to read from`);
409
+ console.log(`Picking machine node ${machineNodes[0]} to read from`);
410
410
  return await this.clientFind({
411
411
  ...config,
412
412
  nodeId: machineNodes[0],
@@ -157,6 +157,7 @@ export class MCPIndexedLogs {
157
157
  direction: Direction;
158
158
  columns: string[];
159
159
  limit?: number;
160
+ forceRefresh?: boolean;
160
161
  }): Promise<SearchResult> {
161
162
  let limit = config.limit ?? 100;
162
163
  let startTime = normalizeTime(config.startTime, "startTime");
@@ -176,7 +177,7 @@ export class MCPIndexedLogs {
176
177
  let machineId = config.machine === "local" ? getOwnMachineId() : config.machine;
177
178
 
178
179
  let moveStart = Date.now();
179
- let moveOutcome = await this.ensureMovedThrough(machineId, endTime);
180
+ let moveOutcome = await this.ensureMovedThrough(machineId, endTime, config.forceRefresh);
180
181
  console.log(`[search] ensureMovedThrough ${moveOutcome} in ${formatTime(Date.now() - moveStart)}`);
181
182
 
182
183
  let loggers = await getLoggers2Async();
@@ -203,6 +204,7 @@ export class MCPIndexedLogs {
203
204
  loggerName,
204
205
  startTime,
205
206
  endTime,
207
+ forceRefresh: config.forceRefresh,
206
208
  });
207
209
  totalPathsSeen += paths.length;
208
210
 
@@ -479,9 +481,9 @@ export class MCPIndexedLogs {
479
481
  // (e.g. older versions still running). Records moved-through up to
480
482
  // now - MOVE_GRACE so we skip this on subsequent calls covering the same
481
483
  // window.
482
- private async ensureMovedThrough(machineId: string, endTime: number): Promise<"cached" | "no-node" | "moved"> {
484
+ private async ensureMovedThrough(machineId: string, endTime: number, forceRefresh?: boolean): Promise<"cached" | "no-node" | "moved"> {
483
485
  let lastMoved = this.movedThroughByMachine.get(machineId) ?? 0;
484
- if (lastMoved >= endTime) return "cached";
486
+ if (!forceRefresh && lastMoved >= endTime) return "cached";
485
487
 
486
488
  let nodeIds = await this.findRemoteNodesOnMachine(machineId);
487
489
  if (nodeIds.length === 0) {
@@ -493,6 +495,14 @@ export class MCPIndexedLogs {
493
495
  let answered = false;
494
496
  for (let nodeId of nodeIds) {
495
497
  try {
498
+ if (forceRefresh) {
499
+ console.log(`MCPIndexedLogs: forceRefresh — flushing ${loggerName} on ${nodeId} unconditionally`);
500
+ await IndexedLogShimController.nodes[nodeId].forceMoveLogsToPublic({
501
+ indexedLogsName: loggerName,
502
+ });
503
+ answered = true;
504
+ break;
505
+ }
496
506
  let hasPending = await timeoutToUndefinedSilent(
497
507
  5000,
498
508
  IndexedLogShimController.nodes[nodeId].hasPendingInRange({
@@ -564,6 +574,7 @@ export class MCPIndexedLogs {
564
574
  loggerName: LoggerName;
565
575
  startTime: number;
566
576
  endTime: number;
577
+ forceRefresh?: boolean;
567
578
  }): Promise<TimeFilePath[]> {
568
579
  let bucketStart = Math.floor(config.startTime / timeInHour) * timeInHour;
569
580
  let bucketEnd = Math.ceil(config.endTime / timeInHour) * timeInHour;
@@ -575,6 +586,10 @@ export class MCPIndexedLogs {
575
586
  if (now - v.time > PATHS_CACHE_TTL) this.pathsCache.delete(k);
576
587
  }
577
588
 
589
+ if (config.forceRefresh) {
590
+ this.pathsCache.delete(key);
591
+ }
592
+
578
593
  let cached = this.pathsCache.get(key);
579
594
  if (cached && now - cached.time <= PATHS_CACHE_TTL) {
580
595
  return cached.paths;