querysub 0.459.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/.claude/settings.local.json +2 -1
- 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 +9 -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 +74 -23
- package/src/diagnostics/charts/Chart.tsx +240 -0
- package/src/diagnostics/grossStats/GrossStatsPage.tsx +48 -83
- package/src/diagnostics/logs/IndexedLogs/BufferIndex.ts +22 -35
- package/src/diagnostics/logs/IndexedLogs/BufferUnitIndex.ts +39 -47
- 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 +46 -70
- package/src/diagnostics/AuditLogPage.tsx +0 -147
- package/src/diagnostics/NodeConnectionsPage.tsx +0 -167
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import preact from "preact";
|
|
2
|
+
import { SocketFunction } from "socket-function/SocketFunction";
|
|
3
|
+
import { qreact } from "../../4-dom/qreact";
|
|
4
|
+
import { css } from "typesafecss";
|
|
5
|
+
import { t } from "../../2-proxy/schema2";
|
|
6
|
+
import { Querysub } from "../../4-querysub/QuerysubController";
|
|
7
|
+
import { MachineServiceController, ServiceConfig } from "../machineSchema";
|
|
8
|
+
import { MachinePicker } from "./MachinePicker";
|
|
9
|
+
import { isDefined } from "../../misc";
|
|
10
|
+
|
|
11
|
+
module.hotreload = true;
|
|
12
|
+
|
|
13
|
+
type ToolEntry = {
|
|
14
|
+
key: string;
|
|
15
|
+
title: string;
|
|
16
|
+
render: () => preact.ComponentChild;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export class Tools extends qreact.Component {
|
|
20
|
+
state = t.state({
|
|
21
|
+
expanded: t.atomic<boolean>(false),
|
|
22
|
+
expandedTools: t.lookup(t.boolean),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
render() {
|
|
26
|
+
let tools: ToolEntry[] = [
|
|
27
|
+
{
|
|
28
|
+
key: "rename-machine",
|
|
29
|
+
title: "Rename Machine",
|
|
30
|
+
render: () => <RenameMachine />,
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
return <div className={css.vbox(8)}>
|
|
35
|
+
<div
|
|
36
|
+
className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 0, 95)}
|
|
37
|
+
onClick={() => {
|
|
38
|
+
this.state.expanded = !this.state.expanded;
|
|
39
|
+
}}
|
|
40
|
+
>
|
|
41
|
+
<span className={css.boldStyle}>{this.state.expanded ? "▼" : "▶"} Tools</span>
|
|
42
|
+
</div>
|
|
43
|
+
{this.state.expanded && <div className={css.vbox(6).paddingLeft(12)}>
|
|
44
|
+
{tools.map(tool => {
|
|
45
|
+
let isExpanded = !!this.state.expandedTools[tool.key];
|
|
46
|
+
return <div className={css.vbox(6)} key={tool.key}>
|
|
47
|
+
<div
|
|
48
|
+
className={css.pad2(10, 6).button.bord2(0, 0, 20).hsl(0, 0, 98)}
|
|
49
|
+
onClick={() => {
|
|
50
|
+
if (this.state.expandedTools[tool.key]) {
|
|
51
|
+
delete this.state.expandedTools[tool.key];
|
|
52
|
+
} else {
|
|
53
|
+
this.state.expandedTools[tool.key] = true;
|
|
54
|
+
}
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
<span>{isExpanded ? "▼" : "▶"} {tool.title}</span>
|
|
58
|
+
</div>
|
|
59
|
+
{isExpanded && <div className={css.pad2(10).bord2(0, 0, 20).hsl(0, 0, 100)}>
|
|
60
|
+
{tool.render()}
|
|
61
|
+
</div>}
|
|
62
|
+
</div>;
|
|
63
|
+
})}
|
|
64
|
+
</div>}
|
|
65
|
+
</div>;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
class RenameMachine extends qreact.Component {
|
|
70
|
+
state = t.state({
|
|
71
|
+
fromMachineId: t.string,
|
|
72
|
+
toMachineId: t.string,
|
|
73
|
+
isRunning: t.atomic<boolean>(false),
|
|
74
|
+
resultMessage: t.string,
|
|
75
|
+
errorMessage: t.string,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
render() {
|
|
79
|
+
let controller = MachineServiceController(SocketFunction.browserNodeId());
|
|
80
|
+
|
|
81
|
+
let { fromMachineId, toMachineId } = this.state;
|
|
82
|
+
let canRun = !!fromMachineId && !!toMachineId && fromMachineId !== toMachineId && !this.state.isRunning;
|
|
83
|
+
|
|
84
|
+
return <div className={css.vbox(10)}>
|
|
85
|
+
<div className={css.colorhsl(0, 0, 40)}>
|
|
86
|
+
Rewrites every service config: any occurrence of "From" in its <code>machineIds</code> becomes "To".
|
|
87
|
+
</div>
|
|
88
|
+
<MachinePicker
|
|
89
|
+
label="From"
|
|
90
|
+
singleOption
|
|
91
|
+
picked={fromMachineId ? [fromMachineId] : []}
|
|
92
|
+
addPicked={(value) => { this.state.fromMachineId = value; }}
|
|
93
|
+
removePicked={() => { this.state.fromMachineId = ""; }}
|
|
94
|
+
/>
|
|
95
|
+
<MachinePicker
|
|
96
|
+
label="To"
|
|
97
|
+
singleOption
|
|
98
|
+
picked={toMachineId ? [toMachineId] : []}
|
|
99
|
+
addPicked={(value) => { this.state.toMachineId = value; }}
|
|
100
|
+
removePicked={() => { this.state.toMachineId = ""; }}
|
|
101
|
+
/>
|
|
102
|
+
<div className={css.hbox(8)}>
|
|
103
|
+
<button
|
|
104
|
+
className={css.pad2(12, 8).button.bord2(0, 0, 20)
|
|
105
|
+
+ (canRun ? css.hsl(0, 0, 100) : css.hsl(0, 0, 85).colorhsl(0, 0, 50))}
|
|
106
|
+
disabled={!canRun}
|
|
107
|
+
onClick={() => {
|
|
108
|
+
if (!canRun) return;
|
|
109
|
+
let from = fromMachineId;
|
|
110
|
+
let to = toMachineId;
|
|
111
|
+
let confirmed = confirm(`Rename machine "${from}" → "${to}" across all service configs?\n\nThis updates every ServiceConfig that references "${from}".`);
|
|
112
|
+
if (!confirmed) return;
|
|
113
|
+
|
|
114
|
+
Querysub.commit(() => {
|
|
115
|
+
this.state.isRunning = true;
|
|
116
|
+
this.state.resultMessage = "";
|
|
117
|
+
this.state.errorMessage = "";
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
Querysub.onCommitFinished(async () => {
|
|
121
|
+
try {
|
|
122
|
+
let serviceIds = await controller.getServiceList.promise();
|
|
123
|
+
let configs = await Promise.all(
|
|
124
|
+
serviceIds.map(id => controller.getServiceConfig.promise(id))
|
|
125
|
+
);
|
|
126
|
+
let toUpdate: ServiceConfig[] = [];
|
|
127
|
+
for (let config of configs.filter(isDefined)) {
|
|
128
|
+
if (!config.machineIds.includes(from)) continue;
|
|
129
|
+
let newMachineIds = config.machineIds.map(m => m === from ? to : m);
|
|
130
|
+
toUpdate.push({
|
|
131
|
+
...config,
|
|
132
|
+
machineIds: newMachineIds,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
if (toUpdate.length === 0) {
|
|
136
|
+
Querysub.commit(() => {
|
|
137
|
+
this.state.resultMessage = `No service configs referenced "${from}".`;
|
|
138
|
+
});
|
|
139
|
+
} else {
|
|
140
|
+
await controller.setServiceConfigs.promise(toUpdate);
|
|
141
|
+
Querysub.commit(() => {
|
|
142
|
+
this.state.resultMessage = `Updated ${toUpdate.length} service config(s).`;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
} catch (err) {
|
|
146
|
+
console.error(`RenameMachine failed:`, (err as Error).stack ?? err);
|
|
147
|
+
Querysub.commit(() => {
|
|
148
|
+
this.state.errorMessage = (err as Error).stack ?? String(err);
|
|
149
|
+
});
|
|
150
|
+
} finally {
|
|
151
|
+
Querysub.commit(() => {
|
|
152
|
+
this.state.isRunning = false;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}}
|
|
157
|
+
>
|
|
158
|
+
{this.state.isRunning ? "Renaming..." : "Rename"}
|
|
159
|
+
</button>
|
|
160
|
+
</div>
|
|
161
|
+
{this.state.resultMessage && <div className={css.colorhsl(120, 50, 30)}>{this.state.resultMessage}</div>}
|
|
162
|
+
{this.state.errorMessage && <pre className={css.colorhsl(0, 60, 40).whiteSpace("pre-wrap")}>{this.state.errorMessage}</pre>}
|
|
163
|
+
</div>;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -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> {
|
|
@@ -164,6 +206,15 @@ async function main() {
|
|
|
164
206
|
// Test command to verify ssh credentials work
|
|
165
207
|
await runPromise(`ssh ${sshRemote} whoami`);
|
|
166
208
|
|
|
209
|
+
// Detect Hetzner rescue system — if we're still in rescue, installimage hasn't been run yet
|
|
210
|
+
let rescueProbe = await runPromise(`ssh ${sshRemote} "hostname; command -v installimage || true"`, { nothrow: true });
|
|
211
|
+
if (/(^|\n)rescue(\s|$)/i.test(rescueProbe) || /installimage/.test(rescueProbe)) {
|
|
212
|
+
console.error(`❌ Remote ${sshRemote} appears to be running the Hetzner rescue system (no OS installed yet).`);
|
|
213
|
+
console.error(` Run \`installimage\` on the remote first to provision the OS, reboot into the installed system, then re-run \`yarn setup-machine ${sshRemote}\`.`);
|
|
214
|
+
console.error(` Detected:\n${rescueProbe.trim()}`);
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
217
|
+
|
|
167
218
|
// Setup swap space if not already configured
|
|
168
219
|
console.log("Checking swap configuration...");
|
|
169
220
|
const swapInfo = await runPromise(`ssh ${sshRemote} "free -m | grep Swap"`);
|
|
@@ -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
|
+
}
|