Public: anyone can read this; the crew can change it.

Boards, step 4: the other panel types — bar, stacked area, number with sparkline, table, heat strip #1818

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/1562-boards-step-4-the-other-panel-types-bar → main 1 commit fork

  • Merged. torque/1562-boards-step-4-the-other-panel-types-bar was merged into main as a8e529a.

Review

Sign in to review this change.
11 files changed +487 −87
README.md +6 −2
@@ -768,8 +768,12 @@ work here.
768768 allowed).
769769 - **Boards** — `/boards/data`, for a forge admin who is a person (anyone else
770770 gets a 404): the query is the URL (`?source=runs&aggregate=count&by=day&fcolumn=state&fvalue=failed`),
771 drawn as a server-rendered line chart with its points in a table; a point opens the rows
772 behind it, keeping your filter.
771 drawn server-side as the chart chosen beside it (`chart=line|bar|area|number|table|heat`)
772 with its points in a table; a point opens the rows behind it, keeping your filter.
773 `series=<column>` splits each group by a second column: stacked area draws one band per
774 value (eight at most, the rest folded into "other"), the heat strip one row per value
775 (twelve at most) with time across, the table a series column; line, bar and number set
776 it aside. Number is the latest group's value over a sparkline of all of them.
773777 `/boards` holds the boards themselves: a board is one JSON file
774778 (`{title, range, refresh: off|10s|1m, panels: [{title, chart, query, w, h}]}`) in
775779 `forge/boards`, a repo the forge owns and makes on the first board; every save is a commit
src/boards/chart.ts +228 −30
@@ -1,13 +1,25 @@
11 /**
2 * A line chart as an SVG string: a pure function from points to markup, no
3 * I/O, no dependencies, drawn the way `web/og/card.ts` draws. The viewBox is
4 * fixed and the element carries its aspect ratio, so the space is held
5 * before the first paint and nothing shifts. Colour is `currentColor`; every
6 * label is stripped of what XML cannot carry, clamped, then escaped.
2 * A panel's chart as a string: pure functions from points to markup, no I/O,
3 * no dependencies, drawn the way `web/og/card.ts` draws. Line, bar, stacked
4 * area, a single number with a sparkline and a heat strip are SVG; a table is
5 * HTML. The viewBox is fixed and the element carries its aspect ratio, so the
6 * space is held before the first paint and nothing shifts. Colour is
7 * `currentColor` (series are told apart by opacity and named in a legend);
8 * every label is stripped of what XML cannot carry, clamped, then escaped.
79 */
810 import { escapeXml } from "../web/og/card.ts";
911
1012 export type Point = { label: string; value: number; href?: string };
13 /** A point of a grouped query: `label` is the x (a time bucket), `series` the category. */
14 export type Cell = Point & { series?: string };
15
16 export const CHARTS = ["line", "bar", "area", "number", "table", "heat"] as const;
17 export type Chart = (typeof CHARTS)[number];
18 export const CHART_NAMES: Readonly<Record<Chart, string>> = {
19 line: "line", bar: "bar", area: "stacked area", number: "number + sparkline", table: "table", heat: "heat strip",
20 };
21 /** The charts that read a query's `series`; the others are drawn from one value per group. */
22 export const usesSeries = (c: Chart) => c === "area" || c === "heat" || c === "table";
1123
1224 export const CHART_WIDTH = 720;
1325 export const CHART_HEIGHT = 260;
@@ -35,44 +47,230 @@ export function formatValue(v: number): string {
3547 return num(v);
3648 }
3749
38 export function lineChart(points: readonly Point[], title: string): string {
39 const w = CHART_WIDTH - LEFT - RIGHT;
40 const h = CHART_HEIGHT - TOP - BOTTOM;
41 const max = Math.max(0, ...points.map((p) => p.value));
42 const min = Math.min(0, ...points.map((p) => p.value));
43 const span = max - min || 1;
44 const x = (i: number) => LEFT + (points.length < 2 ? w / 2 : (i * w) / (points.length - 1));
45 const y = (v: number) => TOP + h - ((v - min) / span) * h;
50 const W = CHART_WIDTH - LEFT - RIGHT;
51 const H = CHART_HEIGHT - TOP - BOTTOM;
52 const MAX_SERIES = 8;
53 const MAX_ROWS = 12;
54 const t = (s: string, n = MAX_LABEL) => escapeXml(clamp(s, n));
55 const linked = (href: string | undefined, inner: string) =>
56 href === undefined ? inner : `<a href="${escapeXml(href)}">${inner}</a>`;
57 const tip = (label: string, v: number) => `<title>${t(label, 40)}: ${escapeXml(formatValue(v))}</title>`;
4658
47 const out: string[] = [
48 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${CHART_WIDTH} ${CHART_HEIGHT}" width="${CHART_WIDTH}" height="${CHART_HEIGHT}" role="img" aria-label="${escapeXml(clamp(title, 120))}" class="chart" preserveAspectRatio="xMidYMid meet" font-size="11" font-family="inherit" fill="currentColor">`,
49 `<title>${escapeXml(clamp(title, 120))}</title>`,
50 ];
51 for (const t of [0, 0.5, 1]) {
52 const v = min + span * t;
59 /** The `<svg>` open tag and its title. */
60 const open = (title: string) => [
61 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${CHART_WIDTH} ${CHART_HEIGHT}" width="${CHART_WIDTH}" height="${CHART_HEIGHT}" role="img" aria-label="${t(title, 120)}" class="chart" preserveAspectRatio="xMidYMid meet" font-size="11" font-family="inherit" fill="currentColor">`,
62 `<title>${t(title, 120)}</title>`,
63 ];
64 const nothing = (out: string[]) => {
65 out.push(`<text x="${CHART_WIDTH / 2}" y="${CHART_HEIGHT / 2}" text-anchor="middle">No points</text></svg>`);
66 return out.join("");
67 };
68
69 /** A y scale over [min(0, lo), max(0, hi)] with three gridlines drawn into `out`. */
70 function yAxis(out: string[], lo: number, hi: number) {
71 const min = Math.min(0, lo);
72 const span = Math.max(0, hi) - min || 1;
73 const y = (v: number) => TOP + H - ((v - min) / span) * H;
74 for (const f of [0, 0.5, 1]) {
75 const v = min + span * f;
5376 const gy = y(v);
5477 out.push(
5578 `<line x1="${LEFT}" x2="${CHART_WIDTH - RIGHT}" y1="${num(gy)}" y2="${num(gy)}" stroke="currentColor" stroke-opacity="0.2"/>`,
5679 `<text x="${LEFT - 6}" y="${num(gy + 4)}" text-anchor="end">${escapeXml(formatValue(v))}</text>`,
5780 );
5881 }
59 if (points.length === 0) {
60 out.push(`<text x="${CHART_WIDTH / 2}" y="${CHART_HEIGHT / 2}" text-anchor="middle">No points</text></svg>`);
61 return out.join("");
62 }
63 const step = Math.max(1, Math.ceil(points.length / X_TICKS));
64 points.forEach((p, i) => {
82 return y;
83 }
84
85 /** At most X_TICKS labels under the plot, at `x(i)`. */
86 function xLabels(out: string[], labels: readonly string[], x: (i: number) => number) {
87 const step = Math.max(1, Math.ceil(labels.length / X_TICKS));
88 labels.forEach((l, i) => {
6589 if (i % step !== 0) return;
66 out.push(
67 `<text x="${num(x(i))}" y="${CHART_HEIGHT - 16}" text-anchor="middle">${escapeXml(clamp(p.label))}</text>`,
68 );
90 out.push(`<text x="${num(x(i))}" y="${CHART_HEIGHT - 16}" text-anchor="middle">${t(l)}</text>`);
6991 });
92 }
93
94 const lo = (ps: readonly Point[]) => Math.min(...ps.map((p) => p.value));
95 const hi = (ps: readonly Point[]) => Math.max(...ps.map((p) => p.value));
96
97 export function lineChart(points: readonly Point[], title: string): string {
98 const out = open(title);
99 const y = yAxis(out, lo(points), hi(points));
100 if (points.length === 0) return nothing(out);
101 const x = (i: number) => LEFT + (points.length < 2 ? W / 2 : (i * W) / (points.length - 1));
102 xLabels(out, points.map((p) => p.label), x);
70103 const d = points.map((p, i) => `${i === 0 ? "M" : "L"}${num(x(i))} ${num(y(p.value))}`).join(" ");
71104 out.push(`<path d="${d}" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>`);
72105 points.forEach((p, i) => {
73 const dot = `<circle cx="${num(x(i))}" cy="${num(y(p.value))}" r="4"><title>${escapeXml(clamp(p.label, 40))}: ${escapeXml(formatValue(p.value))}</title></circle>`;
74 out.push(p.href === undefined ? dot : `<a href="${escapeXml(p.href)}">${dot}</a>`);
106 out.push(linked(p.href, `<circle cx="${num(x(i))}" cy="${num(y(p.value))}" r="4">${tip(p.label, p.value)}</circle>`));
75107 });
76108 out.push("</svg>");
77109 return out.join("");
78110 }
111
112 /** One bar per point, from zero; a slot is W / n wide and the bar 70% of it. */
113 export function barChart(points: readonly Point[], title: string): string {
114 const out = open(title);
115 const y = yAxis(out, lo(points), hi(points));
116 if (points.length === 0) return nothing(out);
117 const slot = W / points.length;
118 const bw = Math.max(1, slot * 0.7);
119 const x = (i: number) => LEFT + slot * i + slot / 2;
120 xLabels(out, points.map((p) => p.label), x);
121 points.forEach((p, i) => {
122 const top = Math.min(y(p.value), y(0));
123 const h = Math.abs(y(p.value) - y(0));
124 out.push(linked(p.href,
125 `<rect class="bar" x="${num(x(i) - bw / 2)}" y="${num(top)}" width="${num(bw)}" height="${num(h)}">${tip(p.label, p.value)}</rect>`));
126 });
127 out.push("</svg>");
128 return out.join("");
129 }
130
131 /**
132 * Cells as a grid: x labels in the order first seen (a grouped query is
133 * ordered by its group), series sorted, a missing cell is 0. Past `keep`
134 * series the smallest totals fold into one named "other".
135 */
136 export function gridOf(cells: readonly Cell[], keep: number) {
137 const xs: string[] = [];
138 const at = new Map<string, number>();
139 const totals = new Map<string, number>();
140 for (const c of cells) {
141 if (!at.has(c.label)) at.set(c.label, xs.push(c.label) - 1);
142 const s = c.series ?? "all";
143 totals.set(s, (totals.get(s) ?? 0) + Math.abs(c.value));
144 }
145 let names = [...totals.keys()].sort();
146 const fold = names.length > keep
147 ? new Set([...totals.entries()].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)).slice(keep - 1).map(([n]) => n))
148 : new Set<string>();
149 if (fold.size > 0) names = [...names.filter((n) => !fold.has(n)), "other"];
150 const row = new Map(names.map((n, i) => [n, i]));
151 const values = names.map(() => xs.map(() => 0));
152 const hrefs: (string | undefined)[][] = names.map(() => xs.map(() => undefined));
153 for (const c of cells) {
154 const s = c.series ?? "all";
155 const r = row.get(fold.has(s) ? "other" : s)!;
156 const i = at.get(c.label)!;
157 values[r]![i]! += c.value;
158 hrefs[r]![i] = fold.has(s) ? undefined : c.href;
159 }
160 return { xs, names, values, hrefs };
161 }
162
163 /** Series opacity: the first is the strongest, each next one lighter. */
164 const shade = (i: number, n: number) => num(n < 2 ? 0.8 : 0.85 - (0.6 * i) / (n - 1));
165
166 function legend(out: string[], names: readonly string[]) {
167 const each = W / Math.max(1, names.length);
168 names.forEach((n, i) => {
169 const lx = LEFT + each * i;
170 out.push(
171 `<rect class="key" x="${num(lx)}" y="${CHART_HEIGHT - 10}" width="8" height="8" fill-opacity="${shade(i, names.length)}"/>`,
172 `<text x="${num(lx + 12)}" y="${CHART_HEIGHT - 2}">${t(n, 14)}</text>`,
173 );
174 });
175 }
176
177 /**
178 * Series stacked on each other, one filled path per series. Negative values
179 * are drawn as 0 — a stack of signed values has no single reading — and the
180 * scale is the tallest stack.
181 */
182 export function stackedArea(cells: readonly Cell[], title: string): string {
183 const out = open(title);
184 const { xs, names, values } = gridOf(cells, MAX_SERIES);
185 const pos = values.map((r) => r.map((v) => Math.max(0, v)));
186 const tops = xs.map((_, i) => pos.reduce((a, r) => a + r[i]!, 0));
187 const y = yAxis(out, 0, Math.max(0, ...tops));
188 if (xs.length === 0) return nothing(out);
189 const x = (i: number) => LEFT + (xs.length < 2 ? W / 2 : (i * W) / (xs.length - 1));
190 xLabels(out, xs, x);
191 const base = xs.map(() => 0);
192 names.forEach((n, s) => {
193 const upper = base.map((b, i) => b + pos[s]![i]!);
194 const fwd = upper.map((v, i) => `${i === 0 ? "M" : "L"}${num(x(i))} ${num(y(v))}`);
195 const back = base.map((v, i) => `L${num(x(i))} ${num(y(v))}`).reverse();
196 const total = pos[s]!.reduce((a, v) => a + v, 0);
197 out.push(`<path class="area" d="${[...fwd, ...back, "Z"].join(" ")}" fill-opacity="${shade(s, names.length)}" stroke="currentColor" stroke-width="1">${tip(n, total)}</path>`);
198 upper.forEach((v, i) => (base[i] = v));
199 });
200 legend(out, names);
201 out.push("</svg>");
202 return out.join("");
203 }
204
205 /**
206 * The latest point's value, large, with every point as a sparkline under it.
207 * "Latest" is the last group in the query's order — the newest bucket when
208 * the query is grouped by time.
209 */
210 export function numberChart(points: readonly Point[], title: string): string {
211 const out = open(title);
212 const last = points[points.length - 1];
213 if (last === undefined) return nothing(out);
214 out.push(
215 linked(last.href, `<text class="big" x="${CHART_WIDTH / 2}" y="110" text-anchor="middle" font-size="72">${escapeXml(formatValue(last.value))}</text>`),
216 `<text x="${CHART_WIDTH / 2}" y="140" text-anchor="middle">${t(`latest of ${points.length}: ${last.label}`, 60)}</text>`,
217 );
218 const lo0 = lo(points);
219 const span = hi(points) - lo0 || 1;
220 const x = (i: number) => LEFT + (points.length < 2 ? W / 2 : (i * W) / (points.length - 1));
221 const y = (v: number) => CHART_HEIGHT - 16 - ((v - lo0) / span) * 80;
222 const d = points.map((p, i) => `${i === 0 ? "M" : "L"}${num(x(i))} ${num(y(p.value))}`).join(" ");
223 out.push(`<path class="spark" d="${d}" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>`, "</svg>");
224 return out.join("");
225 }
226
227 /**
228 * Time across, one row per category, each cell's opacity its share of the
229 * largest cell (0 is faint, never invisible, so the grid reads as a grid).
230 * Negative values are drawn as 0.
231 */
232 export function heatStrip(cells: readonly Cell[], title: string): string {
233 const out = open(title);
234 const { xs, names, values, hrefs } = gridOf(cells, MAX_ROWS);
235 if (xs.length === 0) return nothing(out);
236 const max = Math.max(0, ...values.flat()) || 1;
237 const cw = W / xs.length;
238 const ch = H / names.length;
239 xLabels(out, xs, (i) => LEFT + cw * i + cw / 2);
240 names.forEach((n, r) => {
241 out.push(`<text x="${LEFT - 6}" y="${num(TOP + ch * r + ch / 2 + 4)}" text-anchor="end">${t(n, 8)}</text>`);
242 xs.forEach((l, i) => {
243 const v = values[r]![i]!;
244 const o = 0.06 + 0.94 * (Math.max(0, v) / max);
245 out.push(linked(hrefs[r]![i],
246 `<rect class="cell" x="${num(LEFT + cw * i)}" y="${num(TOP + ch * r)}" width="${num(Math.max(1, cw - 1))}" height="${num(Math.max(1, ch - 1))}" fill-opacity="${num(o)}">${tip(`${n} · ${l}`, v)}</rect>`));
247 });
248 });
249 out.push("</svg>");
250 return out.join("");
251 }
252
253 /** The points as an HTML table: one row each, a series column when there is one. */
254 export function tableChart(cells: readonly Cell[], title: string): string {
255 const split = cells.some((c) => c.series !== undefined);
256 const cell = (c: Cell, s: string) => `<td class="mono">${linked(c.href, escapeXml(clean(s)))}</td>`;
257 return [
258 `<table class="table chart-table"><caption class="sr-only">${t(title, 120)}</caption>`,
259 `<thead><tr><th scope="col">group</th>${split ? `<th scope="col">series</th>` : ""}<th scope="col" class="num">value</th></tr></thead><tbody>`,
260 ...cells.map((c) => `<tr>${cell(c, c.label)}${split ? `<td class="mono">${escapeXml(clean(c.series ?? ""))}</td>` : ""}<td class="num mono">${escapeXml(num(c.value))}</td></tr>`),
261 cells.length === 0 ? `<tr><td colspan="${split ? 3 : 2}" class="muted">No points</td></tr>` : "",
262 "</tbody></table>",
263 ].join("");
264 }
265
266 /** One chart by name: the one door a page draws a panel through. */
267 export function drawChart(chart: Chart, cells: readonly Cell[], title: string): string {
268 switch (chart) {
269 case "line": return lineChart(cells, title);
270 case "bar": return barChart(cells, title);
271 case "area": return stackedArea(cells, title);
272 case "number": return numberChart(cells, title);
273 case "heat": return heatStrip(cells, title);
274 case "table": return tableChart(cells, title);
275 }
276 }
src/boards/query.ts +13 −3
@@ -29,12 +29,14 @@ export type Query = {
2929 groupBy?: string | { bucket: string };
3030 aggregate: string;
3131 column?: string;
32 /** A second grouping column: each group is split into one row per value (stacked area, heat strip). */
33 series?: string;
3234 };
3335
3436 /** Whose rows the asker may see: these repos, or every repo (an admin). */
3537 export type Scope = { repoIds: readonly string[] | "all" };
3638
37 export type Row = { group: string | number | null; value: number | null };
39 export type Row = { group: string | number | null; value: number | null; series?: string | number | null };
3840 export type Refusal = { ok: false; error: string };
3941 export type Answer = { ok: true; rows: Row[] } | Refusal;
4042
@@ -121,10 +123,18 @@ export function runQuery(db: Database, q: unknown, scope: Scope): Answer {
121123 groupParam = BUCKETS[b as Bucket];
122124 }
123125
126 let seriesSql = "";
127 const s = query.series;
128 if (s !== undefined) {
129 if (typeof s !== "string" || !has(cols, s)) return refuse(`unknown column: ${JSON.stringify(s)}`);
130 seriesSql = `, ${ident(s)} AS "series"`;
131 }
132 const grouped = [groupSql === "NULL" ? "" : "1", seriesSql === "" ? "" : "3"].filter(Boolean).join(", ");
133
124134 const sql =
125 `SELECT ${groupSql} AS "group", ${valueSql} AS "value" FROM ${ident(src.table)}` +
135 `SELECT ${groupSql} AS "group", ${valueSql} AS "value"${seriesSql} FROM ${ident(src.table)}` +
126136 (where.length ? ` WHERE ${where.join(" AND ")}` : "") +
127 (groupSql === "NULL" ? "" : ` GROUP BY 1 ORDER BY 1`) +
137 (grouped === "" ? "" : ` GROUP BY ${grouped} ORDER BY ${grouped}`) +
128138 ` LIMIT ${MAX_ROWS}`;
129139 const bound = groupParam === undefined ? params : [groupParam, ...params];
130140 return { ok: true, rows: db.query(sql).all(...bound) as Row[] };
src/boards/store.ts +2 −2
@@ -9,6 +9,7 @@ import { repoDir } from "../git/repos.ts";
99 import { listTree, readerFor } from "../git/read.ts";
1010 import { commitFiles, type CommitFilesError } from "../git/write.ts";
1111 import { err, ok, type Result } from "../kernel/result.ts";
12 import { CHARTS, type Chart } from "./chart.ts";
1213 import { SOURCES } from "./sources.ts";
1314
1415 export const BOARDS_OWNER = "forge";
@@ -17,13 +18,12 @@ const BRANCH = "main";
1718
1819 export const RANGES = ["1h", "24h", "7d", "30d", "all"] as const;
1920 export const REFRESHES = ["off", "10s", "1m"] as const;
20 export const CHARTS = ["line"] as const;
2121 export type Range = (typeof RANGES)[number];
2222 export type Refresh = (typeof REFRESHES)[number];
2323
2424 export type Panel = {
2525 title: string;
26 chart: (typeof CHARTS)[number];
26 chart: Chart;
2727 query: Record<string, unknown>;
2828 w: number;
2929 h: number;
src/web/pages/boards-edit.ts +5 −6
@@ -14,7 +14,7 @@ import type { Ctx } from "../../http/context.ts";
1414 import { notFoundPage } from "../chrome.ts";
1515 import { field, readForm, type Form } from "../form.ts";
1616 import { flashTo } from "../layout.ts";
17 import { readParams, toQuery } from "./boards.ts";
17 import { PANEL_FIELDS, readParams, toQuery } from "./boards.ts";
1818
1919 const num = (v: string) => (/^-?\d{1,4}$/.test(v) ? Number(v) : NaN);
2020
@@ -31,13 +31,12 @@ function sentence(e: BoardError): string {
3131 return words[e] ?? "That could not be saved.";
3232 }
3333
34 /** A panel as the form says it: title, size, and the query the shared fields build. */
34 /** A panel as the form says it: title, size, chart, and the query the shared fields build. */
3535 function panelOf(form: Form): Record<string, unknown> {
3636 const search = new URLSearchParams();
37 for (const k of ["source", "aggregate", "column", "by", "from", "to", "fcolumn", "fop", "fvalue"]) {
38 search.set(k, field(form, k));
39 }
40 return { title: field(form, "title"), chart: "line", query: toQuery(readParams(search)), w: num(field(form, "w")), h: num(field(form, "h")) };
37 for (const k of PANEL_FIELDS) search.set(k, field(form, k));
38 const p = readParams(search);
39 return { title: field(form, "title"), chart: p.chart, query: toQuery(p), w: num(field(form, "w")), h: num(field(form, "h")) };
4140 }
4241
4342 function editOf(op: string, form: Form): Edit | undefined {
src/web/pages/boards-view.ts +7 −13
@@ -8,8 +8,7 @@
88 * `boards-edit.ts`. Anyone but a person who is an admin gets the not-found page.
99 */
1010 import { listAllBoards, showBoard } from "../../actions/boards.ts";
11 import { lineChart } from "../../boards/chart.ts";
12 import { runQuery } from "../../boards/query.ts";
11 import { CHART_NAMES } from "../../boards/chart.ts";
1312 import { MAX_H, MAX_PANELS, MAX_TITLE, MAX_W, RANGES, REFRESHES, type Board, type Panel } from "../../boards/store.ts";
1413 import type { App } from "../../app.ts";
1514 import type { Ctx } from "../../http/context.ts";
@@ -18,7 +17,7 @@ import { notFoundPage } from "../chrome.ts";
1817 import { html, raw, when, type Html } from "../html.ts";
1918 import { fragment, page } from "../layout.ts";
2019 import { btn, card, empty } from "../ui.ts";
21 import { paramsOf, queryFields, readParams } from "./boards.ts";
20 import { cellsOf, chartBox, paramsOf, queryFields, readParams, runFor } from "./boards.ts";
2221
2322 const RANGE_MS = { "1h": 3_600_000, "24h": 86_400_000, "7d": 604_800_000, "30d": 2_592_000_000 } as const;
2423 const EVERY = { "10s": 10, "1m": 60 } as const;
@@ -33,16 +32,11 @@ function ranged(query: Record<string, unknown>, range: Board["range"]): Record<s
3332 }
3433
3534 function drawPanel(app: App, board: Board, panel: Panel): Html {
36 const answer = runQuery(app.db, ranged(panel.query, board.range), { repoIds: "all" });
37 const q = paramsOf(panel.query);
35 const answer = runFor(app.db, panel.chart, ranged(panel.query, board.range));
36 const q = paramsOf(panel.query, panel.chart);
3837 const body = !answer.ok
3938 ? html`<p class="muted small">${answer.error}</p>`
40 : html`<div class="chart-box">${raw(
41 lineChart(
42 answer.rows.map((r) => ({ label: String(r.group ?? "all"), value: r.value ?? 0 })),
43 panel.title,
44 ),
45 )}</div>`;
39 : chartBox(panel.chart, cellsOf(answer.rows), panel.title);
4640 const explore = new URLSearchParams(Object.entries(q).filter(([k, v]) => v !== "" && k !== "rows"));
4741 return html`<div class="card bp-w${String(panel.w)} bp-h${String(panel.h)}">
4842 <div class="card-head"><h2>${panel.title}</h2>
@@ -121,8 +115,8 @@ function panelForm(slug: string, op: string, p: Panel | undefined, index: number
121115 function manage(slug: string, board: Board, adding: Html): Html {
122116 const one = (p: Panel, i: number) => html`<li class="row bmanage">
123117 <details>
124 <summary><strong>${p.title}</strong> <span class="muted small">${p.chart} · ${String(p.w)}×${String(p.h)}</span></summary>
125 ${panelForm(slug, "edit", p, i, queryFields(paramsOf(p.query)))}
118 <summary><strong>${p.title}</strong> <span class="muted small">${CHART_NAMES[p.chart]} · ${String(p.w)}×${String(p.h)}</span></summary>
119 ${panelForm(slug, "edit", p, i, queryFields(paramsOf(p.query, p.chart)))}
126120 </details>
127121 <form method="post" action="/boards/${slug}/move" class="row-actions">
128122 <input type="hidden" name="index" value="${String(i)}" />
src/web/pages/boards.ts +68 −24
@@ -1,18 +1,20 @@
11 /**
22 * Boards, for a forge admin who is a person: `GET /boards/data` lists the
3 * sources and their columns, and draws one query as a line chart with the
4 * table of its points under it. The query is the URL (GET params), so a
3 * sources and their columns, and draws one query as the chart chosen beside
4 * it (line, bar, stacked area, number, table, heat strip) with the table of
5 * its points under it. The query is the URL (GET params), so a
56 * query is a link. Each point links to the rows behind it: the same query
67 * narrowed to that point's window and listed by the source's first column.
78 * Anyone else gets the not-found page.
89 */
9 import { lineChart } from "../../boards/chart.ts";
10 import { runQuery, type Query } from "../../boards/query.ts";
10 import type { Database } from "bun:sqlite";
11 import { CHART_NAMES, CHARTS, drawChart, usesSeries, type Cell, type Chart } from "../../boards/chart.ts";
12 import { runQuery, type Answer, type Query, type Row } from "../../boards/query.ts";
1113 import { SOURCES } from "../../boards/sources.ts";
1214 import { listAllBoards } from "../../actions/boards.ts";
1315 import type { Ctx } from "../../http/context.ts";
1416 import { notFoundPage } from "../chrome.ts";
15 import { html, raw } from "../html.ts";
17 import { html, raw, when } from "../html.ts";
1618 import { page } from "../layout.ts";
1719 import { btn, card, empty } from "../ui.ts";
1820
@@ -20,18 +22,24 @@ const BUCKET_MS = { minute: 60_000, hour: 3_600_000, day: 86_400_000 } as const;
2022 const AGGREGATES = ["count", "sum", "avg", "max"] as const;
2123 const OPS = ["eq", "ne", "lt", "lte", "gt", "gte"] as const;
2224 const MAX_PARAM = 256;
25 /** The fields a panel is made from: its chart and its query. */
26 export const PANEL_FIELDS = ["chart", "source", "aggregate", "column", "by", "series", "from", "to", "fcolumn", "fop", "fvalue"] as const;
2327
2428 type Bucket = keyof typeof BUCKET_MS;
2529 const isBucket = (s: string): s is Bucket => Object.hasOwn(BUCKET_MS, s);
30 const isChart = (s: string): s is Chart => (CHARTS as readonly string[]).includes(s);
2631
2732 /** What the URL asks for: plain strings, bounded; `runQuery` judges them. */
2833 export function readParams(search: URLSearchParams) {
2934 const get = (k: string, d = "") => (search.get(k) ?? d).slice(0, MAX_PARAM);
35 const chart = get("chart", "line");
3036 return {
37 chart: isChart(chart) ? chart : "line",
3138 source: get("source", "runs"),
3239 aggregate: get("aggregate", "count"),
3340 column: get("column"),
3441 by: get("by", "day"),
42 series: get("series"),
3543 from: get("from"),
3644 to: get("to"),
3745 fcolumn: get("fcolumn"),
@@ -51,6 +59,7 @@ export function toQuery(p: Params): Query {
5159 if (p.from !== "") q.from = p.from;
5260 if (p.to !== "") q.to = p.to;
5361 q.groupBy = isBucket(p.by) ? { bucket: p.by } : p.by;
62 if (p.series !== "") q.series = p.series;
5463 const filter: NonNullable<Query["filter"]> = [];
5564 const add = (column: string, op: string, value: string) => {
5665 const numeric = src?.columns[column] === "number";
@@ -62,6 +71,30 @@ export function toQuery(p: Params): Query {
6271 return q;
6372 }
6473
74 /**
75 * Run `query` for `chart`: a chart that draws one value per group is asked
76 * without the query's series, so its groups are not split. The run is
77 * scoped to every repo — only an admin reaches a board.
78 */
79 export function runFor(db: Database, chart: Chart, query: Record<string, unknown>): Answer {
80 if (usesSeries(chart) || query.series === undefined) return runQuery(db, query, { repoIds: "all" });
81 const { series: _, ...rest } = query;
82 return runQuery(db, rest, { repoIds: "all" });
83 }
84
85 /** Rows as the cells a chart draws: a null group or series reads "all"/"none". */
86 export const cellsOf = (rows: readonly Row[], hrefs: readonly (string | null)[] = []): Cell[] =>
87 rows.map((r, i) => ({
88 label: String(r.group ?? "all"),
89 value: r.value ?? 0,
90 ...(r.series !== undefined ? { series: String(r.series ?? "none") } : {}),
91 ...(hrefs[i] ? { href: hrefs[i]! } : {}),
92 }));
93
94 /** A drawn chart in the box that holds its space. */
95 export const chartBox = (chart: Chart, cells: readonly Cell[], title: string) =>
96 html`<div class="chart-box${chart === "table" ? " is-table" : ""}">${raw(drawChart(chart, cells, title))}</div>`;
97
6598 const link = (p: Params) => {
6699 const s = new URLSearchParams();
67100 for (const [k, v] of Object.entries(p)) if (v !== "") s.set(k, v);
@@ -75,18 +108,22 @@ const link = (p: Params) => {
75108 * rows view counts rows by the source's first column, so it lists that
76109 * column's values, not whole rows.
77110 */
78 function behind(p: Params, group: string | number | null): string | null {
111 function behind(p: Params, row: Row): string | null {
112 const { group } = row;
79113 const first = Object.keys(SOURCES[p.source]!.columns)[0]!;
80 const rows = { ...p, rows: "1", by: first, aggregate: "count", column: "" };
114 const rows = { ...p, rows: "1", by: first, aggregate: "count", column: "", series: "" };
81115 if (!isBucket(p.by)) {
82 if (group === null) return null;
116 // A column group and a series would be two filters beside the user's; the rows view says one.
117 if (group === null || row.series !== undefined) return null;
83118 return link({ ...rows, gcolumn: p.by, gvalue: String(group) });
84119 }
85120 if (typeof group !== "string") return null;
121 if (row.series === null) return null;
86122 const start = new Date(group);
87123 return link({
88124 ...rows,
89125 from: start.toISOString(), to: new Date(start.getTime() + BUCKET_MS[p.by]).toISOString(),
126 ...(row.series === undefined ? {} : { gcolumn: p.series, gvalue: String(row.series) }),
90127 });
91128 }
92129
@@ -98,7 +135,10 @@ export function queryFields(p: Params) {
98135 const src = Object.hasOwn(SOURCES, p.source) ? SOURCES[p.source]! : undefined;
99136 const cols = src ? Object.keys(src.columns) : [];
100137 return html`
101 <select class="input" name="source" aria-label="Source">
138 <select class="input" name="chart" aria-label="Chart">
139 ${CHARTS.map((c) => option(c, CHART_NAMES[c], c === p.chart))}
140 </select>
141 <select class="input" name="source" aria-label="Source">
102142 ${Object.keys(SOURCES).map((n) => option(n, n, n === p.source))}
103143 </select>
104144 <select class="input" name="aggregate" aria-label="Aggregate">
@@ -112,6 +152,10 @@ export function queryFields(p: Params) {
112152 ${(Object.keys(BUCKET_MS) as Bucket[]).map((b) => option(b, `per ${b}`, b === p.by))}
113153 ${cols.map((c) => option(c, `by ${c}`, c === p.by))}
114154 </select>
155 <select class="input" name="series" aria-label="Series (stacked area, heat strip, table)">
156 ${option("", "(one series)", p.series === "")}
157 ${cols.map((c) => option(c, `split by ${c}`, c === p.series))}
158 </select>
115159 <input class="input mono" name="from" value="${p.from}" placeholder="from 2026-10-01" aria-label="From" maxlength="${MAX_PARAM}" />
116160 <input class="input mono" name="to" value="${p.to}" placeholder="to 2026-10-08" aria-label="To" maxlength="${MAX_PARAM}" />
117161 <select class="input" name="fcolumn" aria-label="Filter column">
@@ -125,18 +169,20 @@ export function queryFields(p: Params) {
125169 `;
126170 }
127171
128 /** The form's fields back from a stored query; what a form cannot say is dropped. */
129 export function paramsOf(q: Record<string, unknown>): Params {
172 /** The form's fields back from a stored query and its chart; what a form cannot say is dropped. */
173 export function paramsOf(q: Record<string, unknown>, chart: Chart = "line"): Params {
130174 const text = (v: unknown) => (typeof v === "string" || typeof v === "number" ? String(v) : "");
131175 const g = q.groupBy;
132176 const f = Array.isArray(q.filter) ? (q.filter[0] as Record<string, unknown> | undefined) : undefined;
133177 const base = readParams(new URLSearchParams());
134178 return {
135179 ...base,
180 chart,
136181 source: text(q.source) || base.source,
137182 aggregate: text(q.aggregate) || base.aggregate,
138183 column: text(q.column),
139184 by: typeof g === "string" ? g : typeof g === "object" && g !== null ? text((g as { bucket?: unknown }).bucket) : base.by,
185 series: text(q.series),
140186 from: text(q.from),
141187 to: text(q.to),
142188 ...(f ? { fcolumn: text(f.column), fop: text(f.op) || "eq", fvalue: text(f.value) } : {}),
@@ -149,14 +195,14 @@ export async function boardsPage(ctx: Ctx): Promise<Response> {
149195 if (user?.isAdmin !== true || user.kind === "agent") return notFoundPage(ctx);
150196 const p = readParams(ctx.url.searchParams);
151197 const src = Object.hasOwn(SOURCES, p.source) ? SOURCES[p.source]! : undefined;
152 const answer = runQuery(ctx.app.db, toQuery(p), { repoIds: "all" });
198 const answer = p.rows === "" ? runFor(ctx.app.db, p.chart, toQuery(p)) : runQuery(ctx.app.db, toQuery(p), { repoIds: "all" });
153199 const cols = src ? Object.keys(src.columns) : [];
154200
155201 const sources = card(
156202 html`<ul class="list">
157203 ${Object.entries(SOURCES).map(
158204 ([name, s]) => html`<li class="row">
159 <a href="${link({ ...p, source: name, by: "day", column: "", fcolumn: "", fvalue: "", gcolumn: "", gvalue: "", rows: "" })}"><strong>${name}</strong></a>
205 <a href="${link({ ...p, source: name, by: "day", column: "", series: "", fcolumn: "", fvalue: "", gcolumn: "", gvalue: "", rows: "" })}"><strong>${name}</strong></a>
160206 <span class="muted small mono">${Object.entries(s.columns).map(([c, t]) => `${c}:${t}`).join(" ")}</span>
161207 </li>`,
162208 )}
@@ -178,28 +224,26 @@ export async function boardsPage(ctx: Ctx): Promise<Response> {
178224 } else if (answer.rows.length === 0) {
179225 result = empty({ icon: "activity", title: "No rows", note: "Nothing matches this query." });
180226 } else {
181 const hrefs = answer.rows.map((r) => (p.rows === "" ? behind(p, r.group) : null));
182 const points = answer.rows.map((r, i) => ({
183 label: String(r.group ?? "all"),
184 value: r.value ?? 0,
185 ...(hrefs[i] ? { href: hrefs[i]! } : {}),
186 }));
227 const hrefs = answer.rows.map((r) => (p.rows === "" ? behind(p, r) : null));
228 const points = cellsOf(answer.rows, hrefs);
229 const split = answer.rows[0]?.series !== undefined;
187230 const chart =
188231 p.rows === ""
189 ? html`<div class="chart-box">${raw(lineChart(points, `${p.aggregate} of ${p.source}`))}</div>`
232 ? chartBox(p.chart, points, `${p.aggregate} of ${p.source}`)
190233 : html`<p class="muted small">The rows behind a point, as the values of ${cols[0] ?? ""} with how many rows have each. <a href="${link({ ...p, rows: "" })}">Back to the chart</a>.</p>`;
191234 result = html`${chart}
192 <table class="table">
193 <thead><tr><th scope="col">${p.by}</th><th scope="col" class="num">${p.aggregate}</th></tr></thead>
235 ${when(p.rows !== "" || p.chart !== "table", () => html`<table class="table">
236 <thead><tr><th scope="col">${p.by}</th>${split ? html`<th scope="col">${p.series}</th>` : null}<th scope="col" class="num">${p.aggregate}</th></tr></thead>
194237 <tbody>
195238 ${answer.rows.map(
196239 (r, i) => html`<tr>
197240 <td class="mono">${hrefs[i] ? html`<a href="${hrefs[i]!}">${points[i]!.label}</a>` : points[i]!.label}</td>
241 ${split ? html`<td class="mono">${points[i]!.series ?? ""}</td>` : null}
198242 <td class="num mono">${String(r.value ?? "")}</td>
199243 </tr>`,
200244 )}
201245 </tbody>
202 </table>`;
246 </table>`)}`;
203247 }
204248
205249 const listed = await listAllBoards(ctx.app, user);
@@ -209,7 +253,7 @@ export async function boardsPage(ctx: Ctx): Promise<Response> {
209253 ? null
210254 : card(
211255 html`<form method="post" action="/boards/pin" class="row-actions">
212 ${["source", "aggregate", "column", "by", "from", "to", "fcolumn", "fop", "fvalue"].map(
256 ${PANEL_FIELDS.map(
213257 (k) => html`<input type="hidden" name="${k}" value="${p[k as keyof Params]}" />`,
214258 )}
215259 <input type="hidden" name="w" value="1" /><input type="hidden" name="h" value="1" />
src/web/style/content.ts +4
@@ -377,6 +377,10 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); }
377377 .chart-box .chart { display: block; width: 100%; height: auto; }
378378 .chart-box text { fill: var(--muted-fg); font-family: var(--mono); font-size: 10px; }
379379 .chart-box circle { fill: var(--accent); }
380 .chart-box .big { fill: var(--fg); font-family: inherit; font-size: 72px; }
381 .chart-box.is-table { aspect-ratio: auto; height: 260px; overflow: auto; }
382 .bp-h2 .chart-box.is-table { height: 400px; }
383 .bp-h3 .chart-box.is-table { height: 540px; }
380384 .boards-result .table { margin-top: 12px; }
381385 .bgrid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
382386 .bp-w2 { grid-column: span 2; }
test/boards-page.test.ts +115 −7
@@ -1,14 +1,15 @@
11 /**
22 * /boards/data: seeded runs draw a path with one point per bucket, a query
3 * is a link, points link to their rows, and anyone but a person who is an
4 * admin gets the not-found page.
3 * is a link, points link to their rows, every chart type draws the same
4 * seeded query with the elements it should, and anyone but a person who is
5 * an admin gets the not-found page.
56 */
67 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
78 import { mkdtemp, rm } from "node:fs/promises";
89 import { tmpdir } from "node:os";
910 import { join } from "node:path";
1011 import { createApp } from "../src/app.ts";
11 import { lineChart } from "../src/boards/chart.ts";
12 import { barChart, drawChart, CHARTS, gridOf, heatStrip, lineChart, numberChart, stackedArea, tableChart } from "../src/boards/chart.ts";
1213 import { createToken } from "../src/core/tokens.ts";
1314 import { createUser } from "../src/core/users.ts";
1415 import { serve } from "../src/http/server.ts";
@@ -55,6 +56,12 @@ afterAll(async () => {
5556 await rm(dir, { recursive: true, force: true });
5657 });
5758
59 /** The seeded `m` runs a rows link lists. */
60 const listed = async (url: string) => {
61 const t = await (await get(url, "admin")).text();
62 return [...t.matchAll(/<td class="mono">([a-z0-9]+)<\/td>/g)].map((m) => m[1]!).filter((x) => /^m\d$/.test(x)).sort();
63 };
64
5865 describe("/boards/data", () => {
5966 test("seeded runs per day draw one path with one point per day", async () => {
6067 const res = await get("/boards/data?source=runs&by=day", "admin");
@@ -76,10 +83,6 @@ describe("/boards/data", () => {
7683
7784 const pointLinks = (body: string) =>
7885 [...body.matchAll(/<a href="(\/boards\/data\?[^"]*rows=1[^"]*)"><circle/g)].map((m) => m[1]!.replaceAll("&amp;", "&"));
79 const listed = async (url: string) => {
80 const t = await (await get(url, "admin")).text();
81 return [...t.matchAll(/<td class="mono">([a-z0-9]+)<\/td>/g)].map((m) => m[1]!).filter((x) => /^m\d$/.test(x)).sort();
82 };
8386
8487 test("a column point keeps the user's filter and adds its group", async () => {
8588 const body = await (await get("/boards/data?source=runs&by=kind&fcolumn=state&fvalue=failed&from=2026-10-04T12:00:00Z", "admin")).text();
@@ -122,6 +125,111 @@ describe("/boards/data", () => {
122125 });
123126 });
124127
128 describe("every chart type from one seeded query", () => {
129 // runs per day split by kind: 10-01 agent 1; 10-02 agent 2; 10-04 agent 1, check 2, deploy 2.
130 // Three days, three kinds, five non-empty cells.
131 const q = "/boards/data?source=runs&by=day&series=kind";
132 const drawn = async (chart: string) => {
133 const res = await get(`${q}&chart=${chart}`, "admin");
134 expect(res.status).toBe(200);
135 return res.text();
136 };
137 const count = (body: string, re: RegExp) => body.match(re)?.length ?? 0;
138
139 test("line draws one point per day, the series set aside", async () => {
140 expect(count(await drawn("line"), /<circle cx="[^"]*" cy="[^"]*" r="4">/g)).toBe(3);
141 });
142 test("bar draws one bar per day", async () => {
143 expect(count(await drawn("bar"), /<rect class="bar"/g)).toBe(3);
144 });
145 test("stacked area draws one band per kind, with a legend", async () => {
146 const body = await drawn("area");
147 expect(count(body, /<path class="area"/g)).toBe(3);
148 expect(count(body, /<rect class="key"/g)).toBe(3);
149 });
150 test("number draws the latest day's count and a three-point sparkline", async () => {
151 const body = await drawn("number");
152 expect(body).toMatch(/<text class="big"[^>]*>5<\/text>/);
153 expect(body).toContain("latest of 3: 2026-10-04T00:00:00Z");
154 const spark = body.match(/<path class="spark" d="([^"]*)"/)![1]!;
155 expect(spark.split(" L").length).toBe(3);
156 });
157 test("table draws one row per day and kind, and no second table under it", async () => {
158 const body = await drawn("table");
159 const table = body.match(/<table class="table chart-table">[\s\S]*?<\/table>/)![0];
160 expect(count(table, /<tr>/g)).toBe(1 + 5);
161 expect(count(body, /<table/g)).toBe(1);
162 });
163 test("heat strip draws days × kinds cells; each filled cell links to its rows", async () => {
164 const body = await drawn("heat");
165 expect(count(body, /<rect class="cell"/g)).toBe(9);
166 const links = [...body.matchAll(/<a href="([^"]*)"><rect class="cell"/g)].map((m) => m[1]!.replaceAll("&amp;", "&"));
167 expect(links.length).toBe(5);
168 const check = links.find((l) => l.includes("gvalue=check"))!;
169 expect(await listed(check)).toEqual(["m3", "m4"]);
170 });
171 test("an unknown chart falls back to line", async () => {
172 expect(count(await drawn("pie"), /<circle cx="[^"]*" cy="[^"]*" r="4">/g)).toBe(3);
173 });
174 test("the chart is chosen beside the query", async () => {
175 const body = await drawn("heat");
176 expect(body).toMatch(/<select class="input" name="chart"[^>]*>/);
177 expect(body).toContain('<option value="heat" selected>heat strip</option>');
178 expect(body).toContain('<option value="kind" selected>split by kind</option>');
179 });
180 });
181
182 describe("chart geometry", () => {
183 // The plot is 648 × 204 (720 − 56 − 16 by 260 − 16 − 40), its top-left at (56, 16).
184 test("bars: two slots of 324, bars 70% wide, heights in proportion", () => {
185 const svg = barChart([{ label: "a", value: 1 }, { label: "b", value: 3 }], "t");
186 // scale 204/3 = 68 per unit; slot centres 218 and 542; bar 226.8 wide.
187 expect(svg).toContain('<rect class="bar" x="104.6" y="152" width="226.8" height="68">');
188 expect(svg).toContain('<rect class="bar" x="428.6" y="16" width="226.8" height="204">');
189 });
190 test("stacked area: bands sit on the ones before them", () => {
191 const cells = [
192 { label: "t1", series: "a", value: 1 }, { label: "t1", series: "b", value: 2 },
193 { label: "t2", series: "a", value: 3 }, { label: "t2", series: "b", value: 1 },
194 ];
195 const svg = stackedArea(cells, "t");
196 // tallest stack 4 → y(v) = 220 − 51v; x is 56 and 704.
197 expect(svg).toContain('d="M56 169 L704 67 L704 220 L56 220 Z"');
198 expect(svg).toContain('d="M56 67 L704 16 L704 67 L56 169 Z"');
199 });
200 test("heat: opacity is 0.06 + 0.94 × value / largest", () => {
201 const svg = heatStrip([
202 { label: "t1", series: "a", value: 1 }, { label: "t1", series: "b", value: 2 },
203 { label: "t2", series: "a", value: 3 },
204 ], "t");
205 const ops = [...svg.matchAll(/fill-opacity="([^"]*)"/g)].map((m) => m[1]);
206 // a: 1/3 → 0.37, 3/3 → 1; b: 2/3 → 0.69, missing → 0.06
207 expect(ops).toEqual(["0.37", "1", "0.69", "0.06"]);
208 });
209 test("past the cap the smallest series fold into other", () => {
210 const g = gridOf([
211 { label: "t", series: "big", value: 9 }, { label: "t", series: "mid", value: 5 }, { label: "t", series: "low", value: 1 },
212 ], 2);
213 expect(g.names).toEqual(["big", "other"]);
214 expect(g.values).toEqual([[9], [6]]);
215 });
216 test("number with one point, and every type with none, still draws", () => {
217 expect(numberChart([{ label: "x", value: 12345 }], "t")).toContain(">12.35k</text>");
218 for (const c of CHARTS) expect(drawChart(c, [], "t")).toContain("No points");
219 });
220 test("every type escapes its labels and series", () => {
221 const cells = [{ label: "<b>&", series: "<i>", value: 1, href: "/x?<z>" }];
222 for (const c of CHARTS) {
223 const out = drawChart(c, cells, "<t>");
224 expect(out, c).not.toContain("<b>");
225 expect(out, c).not.toContain("<i>");
226 expect(out, c).not.toContain("<t>");
227 expect(out, c).not.toContain("<z>");
228 }
229 expect(tableChart(cells, "t")).toContain("&lt;b&gt;&amp;");
230 });
231 });
232
125233 describe("lineChart", () => {
126234 test("escapes labels and holds its size", () => {
127235 const svg = lineChart([{ label: "<b>&", value: 1 }, { label: "x", value: 2 }], "t");
test/boards-query.test.ts +23
@@ -126,6 +126,29 @@ describe("runQuery", () => {
126126 });
127127 });
128128
129 describe("series", () => {
130 test("splits each group by a second column, ordered by group then series", () => {
131 const a = runQuery(db, { source: "runs", aggregate: "count", groupBy: { bucket: "hour" }, series: "kind" });
132 expect(a).toEqual({
133 ok: true,
134 rows: [
135 { group: "2026-10-01T10:00:00Z", value: 2, series: "agent" },
136 { group: "2026-10-01T11:00:00Z", value: 1, series: "deploy" },
137 ],
138 });
139 });
140 test("without a group it is the series alone", () => {
141 const a = runQuery(db, { source: "runs", aggregate: "sum", column: "cost_usd", series: "state" });
142 expect(a).toEqual({ ok: true, rows: [{ group: null, value: 2, series: "failed" }, { group: null, value: 5, series: "ok" }] });
143 });
144 test("an unknown or injected series is refused by name", () => {
145 expect(runQuery(db, { source: "runs", aggregate: "count", series: "kind\" FROM run; --" })).toEqual({
146 ok: false, error: 'unknown column: "kind\\" FROM run; --"',
147 });
148 expect(runQuery(db, { source: "runs", aggregate: "count", series: 3 }).ok).toBe(false);
149 });
150 });
151
129152 describe("scope", () => {
130153 const count = { source: "runs", aggregate: "count" };
131154 test("a guest scope sees none of a private repo's runs", () => {
test/boards-store.test.ts +16
@@ -216,6 +216,22 @@ describe("/boards", () => {
216216 expect((await readBoard(app.reposRoot, "fleet-health"))!.panels.at(-1)!.title).toBe("pinned");
217217 });
218218
219 test("a panel keeps its chart and series: chosen in the form, saved in the file, drawn on the board", async () => {
220 await post("/boards/new", { title: "Charts" });
221 const q = { source: "runs", aggregate: "count", column: "", by: "day", series: "kind", from: "", to: "", fcolumn: "", fop: "eq", fvalue: "" };
222 expect((await post("/boards/charts/add", { ...q, chart: "heat", title: "heat", w: "1", h: "1" })).status).toBe(303);
223 expect((await post("/boards/charts/add", { ...q, chart: "bar", title: "bar", w: "1", h: "1" })).status).toBe(303);
224 const board = (await readBoard(app.reposRoot, "charts"))!;
225 expect(board.panels.map((p) => [p.chart, p.query.series])).toEqual([["heat", "kind"], ["bar", "kind"]]);
226 const page = await (await asAdmin("/boards/charts")).text();
227 // runs in range: three days × three kinds for the heat strip; three days of bars, the series set aside
228 expect(page.match(/<rect class="cell"/g)?.length).toBe(9);
229 expect(page.match(/<rect class="bar"/g)?.length).toBe(3);
230 expect(page).toContain('<option value="heat" selected>heat strip</option>');
231 expect(parseBoard({ ...board, panels: [{ ...board.panels[0], chart: "pie" }] }).ok).toBe(false);
232 await deleteBoard(app, alice, "charts");
233 });
234
219235 test("a cross-origin post changes nothing, and duplicates are refused as unnamed", async () => {
220236 const before = await readBoard(app.reposRoot, "fleet-health");
221237 for (const path of ["/boards/fleet-health/rename", "/boards/fleet-health/delete", "/boards/new", "/boards/pin"]) {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs