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.
Files changed (35) hide show
  1. package/.claude/settings.local.json +2 -1
  2. package/package.json +2 -2
  3. package/src/-b-authorities/dnsAuthority.ts +23 -15
  4. package/src/-g-core-values/NodeCapabilities.ts +3 -0
  5. package/src/-h-path-value-serialize/PathValueSerializer.ts +11 -3
  6. package/src/0-path-value-core/PathRouter.ts +6 -0
  7. package/src/0-path-value-core/PathWatcher.ts +1 -1
  8. package/src/0-path-value-core/pathValueCore.ts +4 -7
  9. package/src/1-path-client/RemoteWatcher.ts +8 -3
  10. package/src/1-path-client/pathValueClientWatcher.ts +3 -0
  11. package/src/2-proxy/PathValueProxyWatcher.ts +1 -1
  12. package/src/2-proxy/TransactionDelayer.ts +1 -1
  13. package/src/3-path-functions/PathFunctionHelpers.ts +13 -8
  14. package/src/3-path-functions/PathFunctionRunner.ts +2 -0
  15. package/src/4-querysub/Querysub.ts +0 -1
  16. package/src/4-querysub/QuerysubController.ts +1 -7
  17. package/src/config.ts +9 -0
  18. package/src/config2.ts +7 -1
  19. package/src/deployManager/components/MachinePicker.tsx +40 -0
  20. package/src/deployManager/components/ServiceDetailPage.tsx +2 -5
  21. package/src/deployManager/components/ServicesListPage.tsx +2 -0
  22. package/src/deployManager/components/Tools.tsx +165 -0
  23. package/src/deployManager/setupMachineMain.ts +74 -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/BufferIndex.ts +22 -35
  27. package/src/diagnostics/logs/IndexedLogs/BufferUnitIndex.ts +39 -47
  28. package/src/diagnostics/logs/IndexedLogs/IndexedLogs.ts +3 -3
  29. package/src/diagnostics/logs/IndexedLogs/MCPIndexedLogs.ts +18 -3
  30. package/src/diagnostics/logs/IndexedLogs/MCPIndexedLogsEntry.ts +1 -0
  31. package/src/diagnostics/managementPages.tsx +58 -58
  32. package/src/diagnostics/misc-pages/DNSPage.tsx +344 -0
  33. package/test.ts +46 -70
  34. package/src/diagnostics/AuditLogPage.tsx +0 -147
  35. 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
- 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> {
@@ -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
+ }