querysub 0.460.0 → 0.461.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 +2 -2
- package/src/-b-authorities/dnsAuthority.ts +23 -15
- package/src/-g-core-values/NodeCapabilities.ts +3 -0
- package/src/-h-path-value-serialize/PathValueSerializer.ts +11 -3
- package/src/0-path-value-core/PathRouter.ts +6 -0
- package/src/0-path-value-core/PathWatcher.ts +1 -1
- package/src/0-path-value-core/pathValueCore.ts +4 -7
- package/src/1-path-client/RemoteWatcher.ts +8 -3
- package/src/1-path-client/pathValueClientWatcher.ts +3 -0
- package/src/2-proxy/PathValueProxyWatcher.ts +1 -1
- package/src/2-proxy/TransactionDelayer.ts +1 -1
- package/src/3-path-functions/PathFunctionHelpers.ts +13 -8
- package/src/3-path-functions/PathFunctionRunner.ts +2 -0
- package/src/4-querysub/Querysub.ts +0 -1
- package/src/4-querysub/QuerysubController.ts +1 -7
- package/src/config.ts +6 -0
- package/src/config2.ts +7 -1
- package/src/deployManager/components/MachinePicker.tsx +40 -0
- package/src/deployManager/components/ServiceDetailPage.tsx +2 -5
- package/src/deployManager/components/ServicesListPage.tsx +2 -0
- package/src/deployManager/components/Tools.tsx +165 -0
- package/src/deployManager/setupMachineMain.ts +65 -23
- package/src/diagnostics/charts/Chart.tsx +240 -0
- package/src/diagnostics/grossStats/GrossStatsPage.tsx +48 -83
- package/src/diagnostics/logs/IndexedLogs/IndexedLogs.ts +3 -3
- package/src/diagnostics/logs/IndexedLogs/MCPIndexedLogs.ts +18 -3
- package/src/diagnostics/logs/IndexedLogs/MCPIndexedLogsEntry.ts +1 -0
- package/src/diagnostics/managementPages.tsx +58 -58
- package/src/diagnostics/misc-pages/DNSPage.tsx +344 -0
- package/test.ts +29 -170
- package/src/diagnostics/AuditLogPage.tsx +0 -147
- package/src/diagnostics/NodeConnectionsPage.tsx +0 -167
|
@@ -12,7 +12,32 @@ Querysub;
|
|
|
12
12
|
|
|
13
13
|
const pinnedNodeVersion = 22;
|
|
14
14
|
|
|
15
|
-
|
|
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 =
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
|
84
|
-
for (let
|
|
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
|
|
94
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if (
|
|
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
|
-
|
|
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).
|
|
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).
|
|
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
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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).
|
|
273
|
-
<span>peak: {formatNumber(
|
|
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
|
-
<
|
|
276
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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(
|
|
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(
|
|
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;
|
|
@@ -56,6 +56,7 @@ Note: each segment between operators ideally has at least 4 contiguous character
|
|
|
56
56
|
columns: { type: "array", items: { type: "string" }, description: "Which fields to project onto each row. Use [] to get just metadata; use allColumns from a prior result to pick more." },
|
|
57
57
|
limit: { type: "number", default: 100 },
|
|
58
58
|
logTypes: { type: "string", description: "Optional pipe-separated list restricting which log streams to scan. Allowed values: log, info, warn, error. Examples: \"warn|error\", \"log\". Omit (default) to search all four." },
|
|
59
|
+
forceRefresh: { type: "boolean", description: "If true, bypass the path-cache (TimeFileTree.findAllPaths) and re-walk the archive folders. Use this when recent log files appear missing because the cache is stale." },
|
|
59
60
|
},
|
|
60
61
|
required: ["query", "machine", "startTime", "endTime", "direction", "columns"],
|
|
61
62
|
},
|