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

Boards, step 2: /boards/data draws a query as a line chart an admin can edit in place #1746

Merged opened this

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

Merged

scout/forge:scout/1560-boards-step-2-boards-data-draws-a-query → main 6 commits fork 3 threads · 3 unresolved

  • Merged. scout/1560-boards-step-2-boards-data-draws-a-query was merged into main as 8420be1.

Review

Sign in to review this change.
9 files changed +415 −1
README.md +4
@@ -703,6 +703,10 @@ work here.
703703 commit history) with README rendering and **syntax highlighting**, protected
704704 default branches (force-push and deletion refused, honest fast-forwards
705705 allowed).
706 - **Boards** — `/boards/data`, for a forge admin who is a person (anyone else
707 gets a 404): the query is the URL (`?source=runs&aggregate=count&by=day&fcolumn=state&fvalue=failed`),
708 drawn as a server-rendered line chart with its points in a table; a point opens the rows
709 behind it, keeping your filter.
706710 - **Issues** — labels, threaded comments, close/reopen, markdown.
707711 - **Pull requests** — open from a branch, a **beautiful diff viewer** (syntax
708712 highlighting composed with intra-line word highlighting, mobile-first,
scripts/gate.ts +1
@@ -34,6 +34,7 @@ export const GATE: readonly string[] = [
3434 "test/apps-idle.test.ts",
3535 "test/body-limits.test.ts",
3636 "test/boards-query.test.ts",
37 "test/boards-page.test.ts",
3738 "test/turnstile.test.ts",
3839 "test/variants-deck.test.ts",
3940 "test/variants-surfaces.test.ts",
src/boards/chart.ts added +78
@@ -0,0 +1,78 @@
1 /**
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.
7 */
8 import { escapeXml } from "../web/og/card.ts";
9
10 export type Point = { label: string; value: number; href?: string };
11
12 export const CHART_WIDTH = 720;
13 export const CHART_HEIGHT = 260;
14 const LEFT = 56;
15 const RIGHT = 16;
16 const TOP = 16;
17 const BOTTOM = 40;
18 const MAX_LABEL = 16;
19 const X_TICKS = 6;
20
21 const clean = (s: string) =>
22 s.replace(/[^\u0009\u000A\u000D -퟿-�]/gu, "").replace(/\s+/g, " ").trim();
23 const clamp = (s: string, n = MAX_LABEL) => {
24 const c = clean(s);
25 return c.length > n ? `${c.slice(0, n - 1)}…` : c;
26 };
27 const num = (n: number) => String(Math.round(n * 100) / 100);
28
29 /** A short, readable number for an axis. */
30 export function formatValue(v: number): string {
31 const a = Math.abs(v);
32 if (a >= 1e9) return `${num(v / 1e9)}G`;
33 if (a >= 1e6) return `${num(v / 1e6)}M`;
34 if (a >= 1e4) return `${num(v / 1e3)}k`;
35 return num(v);
36 }
37
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;
46
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;
53 const gy = y(v);
54 out.push(
55 `<line x1="${LEFT}" x2="${CHART_WIDTH - RIGHT}" y1="${num(gy)}" y2="${num(gy)}" stroke="currentColor" stroke-opacity="0.2"/>`,
56 `<text x="${LEFT - 6}" y="${num(gy + 4)}" text-anchor="end">${escapeXml(formatValue(v))}</text>`,
57 );
58 }
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) => {
65 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 );
69 });
70 const d = points.map((p, i) => `${i === 0 ? "M" : "L"}${num(x(i))} ${num(y(p.value))}`).join(" ");
71 out.push(`<path d="${d}" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>`);
72 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>`);
75 });
76 out.push("</svg>");
77 return out.join("");
78 }
src/core/reserved.ts +1
@@ -63,6 +63,7 @@ export const SYSTEM_NAMES: readonly string[] = [
6363 "audit.csv",
6464 "big",
6565 "board",
66 "boards",
6667 "events",
6768 "explore",
6869 "fleet",
src/http/server.ts +2
@@ -10,6 +10,7 @@ import { unsealReleased } from "../actions/sealed.ts";
1010 import { capacityPage, capacitySet } from "../web/pages/capacity.ts";
1111 import { flowPage } from "../web/pages/flow.ts";
1212 import { limitsPage, limitsSet } from "../web/pages/admin-limits.ts";
13 import { boardsPage } from "../web/pages/boards.ts";
1314 import {
1415 closeStreamsOf,
1516 closeStreamsOfSession,
@@ -695,6 +696,7 @@ export function buildRouter(): Router<Handler> {
695696 router.add("POST", "/settings/cloud/remove", cloudRemove);
696697 router.add("GET", "/settings/capacity", capacityPage);
697698 router.add("POST", "/settings/capacity", capacitySet);
699 router.add("GET", "/boards/data", boardsPage);
698700 router.add("GET", "/admin/limits", limitsPage);
699701 router.add("POST", "/admin/limits", limitsSet);
700702 router.add("GET", "/settings/backups", backupsPage);
src/web/layout.ts +6 −1
@@ -208,7 +208,12 @@ function userMenu(ctx: Ctx, user: User, signOut: MenuItem): Html {
208208 { label: "SSH keys", href: "/settings/keys", icon: "lock" },
209209 { label: "Origins", href: "/settings/origins", icon: "globe" },
210210 ...(user.isAdmin
211 ? [{ label: "Crew", href: "/settings/crew", icon: "bot" as const }]
211 ? [
212 { label: "Crew", href: "/settings/crew", icon: "bot" as const },
213 ...(user.kind === "agent"
214 ? []
215 : [{ label: "Boards", href: "/boards/data", icon: "activity" as const }]),
216 ]
212217 : []),
213218 { separator: true, label: "" },
214219 signOut,
src/web/pages/boards.ts added +183
@@ -0,0 +1,183 @@
1 /**
2 * 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
5 * query is a link. Each point links to the rows behind it: the same query
6 * narrowed to that point's window and listed by the source's first column.
7 * Anyone else gets the not-found page.
8 */
9 import { lineChart } from "../../boards/chart.ts";
10 import { runQuery, type Query } from "../../boards/query.ts";
11 import { SOURCES } from "../../boards/sources.ts";
12 import type { Ctx } from "../../http/context.ts";
13 import { notFoundPage } from "../chrome.ts";
14 import { html, raw } from "../html.ts";
15 import { page } from "../layout.ts";
16 import { btn, card, empty } from "../ui.ts";
17
18 const BUCKET_MS = { minute: 60_000, hour: 3_600_000, day: 86_400_000 } as const;
19 const AGGREGATES = ["count", "sum", "avg", "max"] as const;
20 const OPS = ["eq", "ne", "lt", "lte", "gt", "gte"] as const;
21 const MAX_PARAM = 256;
22
23 type Bucket = keyof typeof BUCKET_MS;
24 const isBucket = (s: string): s is Bucket => Object.hasOwn(BUCKET_MS, s);
25
26 /** What the URL asks for: plain strings, bounded; `runQuery` judges them. */
27 function readParams(url: URL) {
28 const get = (k: string, d = "") => (url.searchParams.get(k) ?? d).slice(0, MAX_PARAM);
29 return {
30 source: get("source", "runs"),
31 aggregate: get("aggregate", "count"),
32 column: get("column"),
33 by: get("by", "day"),
34 from: get("from"),
35 to: get("to"),
36 fcolumn: get("fcolumn"),
37 fop: get("fop", "eq"),
38 fvalue: get("fvalue"),
39 gcolumn: get("gcolumn"),
40 gvalue: get("gvalue"),
41 rows: get("rows") === "1" ? "1" : "",
42 };
43 }
44 type Params = ReturnType<typeof readParams>;
45
46 function toQuery(p: Params): Query {
47 const src = Object.hasOwn(SOURCES, p.source) ? SOURCES[p.source]! : undefined;
48 const q: Query = { source: p.source, aggregate: p.aggregate };
49 if (p.column !== "") q.column = p.column;
50 if (p.from !== "") q.from = p.from;
51 if (p.to !== "") q.to = p.to;
52 q.groupBy = isBucket(p.by) ? { bucket: p.by } : p.by;
53 const filter: NonNullable<Query["filter"]> = [];
54 const add = (column: string, op: string, value: string) => {
55 const numeric = src?.columns[column] === "number";
56 filter.push({ column, op, value: numeric ? Number(value) : value });

Low: on a number column, Number("") is 0. So choosing turns as the filter column with the value left blank quietly filters turns = 0 instead of being refused or ignored. Fix: skip the filter when value === "", or pass NaN so runQuery refuses it and the page shows why.

57 };
58 if (p.fcolumn !== "") add(p.fcolumn, p.fop, p.fvalue);
59 if (p.gcolumn !== "") add(p.gcolumn, "eq", p.gvalue);
60 if (filter.length > 0) q.filter = filter;
61 return q;
62 }
63
64 const link = (p: Params) => {
65 const s = new URLSearchParams();
66 for (const [k, v] of Object.entries(p)) if (v !== "") s.set(k, v);
67 return `/boards/data?${s}`;
outdated

For a numeric or null group, this branch builds a link without any filter, so the "rows behind" are the whole source. For a string group, fcolumn/fop/fvalue replace the user's existing filter instead of adding to it. Keep the user's filter and add by = group beside it (as a number when the column is a number). Add a test that groups by kind under a state filter and asserts which ids the rows view lists.

68 };
69
70 /**
71 * The rows behind one point, or null when the point has no rows to name (a
72 * null group). The user's own filter stays; a column group joins it as a
73 * second filter (`gcolumn`/`gvalue`), a bucket group as a time window. The
74 * rows view counts rows by the source's first column, so it lists that
75 * column's values, not whole rows.
76 */
77 function behind(p: Params, group: string | number | null): string | null {
78 const first = Object.keys(SOURCES[p.source]!.columns)[0]!;
79 const rows = { ...p, rows: "1", by: first, aggregate: "count", column: "" };
80 if (!isBucket(p.by)) {
81 if (group === null) return null;
82 return link({ ...rows, gcolumn: p.by, gvalue: String(group) });
83 }
84 if (typeof group !== "string") return null;
85 const start = new Date(group);
86 return link({
87 ...rows,
88 from: start.toISOString(), to: new Date(start.getTime() + BUCKET_MS[p.by]).toISOString(),
89 });
90 }
91
92 const option = (value: string, label: string, selected: boolean) =>
93 html`<option value="${value}" ${selected ? raw("selected") : null}>${label}</option>`;
94
95 // GET /boards/data
96 export function boardsPage(ctx: Ctx): Response {
97 const user = ctx.auth.user;
98 if (user?.isAdmin !== true || user.kind === "agent") return notFoundPage(ctx);
99 const p = readParams(ctx.url);
100 const src = Object.hasOwn(SOURCES, p.source) ? SOURCES[p.source]! : undefined;
101 const answer = runQuery(ctx.app.db, toQuery(p), { repoIds: "all" });
102 const cols = src ? Object.keys(src.columns) : [];
103
104 const sources = card(
105 html`<ul class="list">
106 ${Object.entries(SOURCES).map(
107 ([name, s]) => html`<li class="row">
108 <a href="${link({ ...p, source: name, by: "day", column: "", fcolumn: "", fvalue: "", gcolumn: "", gvalue: "", rows: "" })}"><strong>${name}</strong></a>
109 <span class="muted small mono">${Object.entries(s.columns).map(([c, t]) => `${c}:${t}`).join(" ")}</span>
110 </li>`,
111 )}
112 </ul>`,
113 { head: html`<h2>Sources</h2>`, pad: false },
114 );
115
116 const form = card(
117 html`<form method="get" action="/boards/data" class="row-actions">
118 <select class="input" name="source" aria-label="Source">
119 ${Object.keys(SOURCES).map((n) => option(n, n, n === p.source))}
120 </select>
121 <select class="input" name="aggregate" aria-label="Aggregate">
122 ${AGGREGATES.map((a) => option(a, a, a === p.aggregate))}
123 </select>
124 <select class="input" name="column" aria-label="Column to aggregate">
125 ${option("", "(rows)", p.column === "")}
126 ${cols.filter((c) => src!.columns[c] === "number").map((c) => option(c, c, c === p.column))}
127 </select>
128 <select class="input" name="by" aria-label="Group by">
129 ${(Object.keys(BUCKET_MS) as Bucket[]).map((b) => option(b, `per ${b}`, b === p.by))}
130 ${cols.map((c) => option(c, `by ${c}`, c === p.by))}
131 </select>
132 <input class="input mono" name="from" value="${p.from}" placeholder="from 2026-10-01" aria-label="From" maxlength="${MAX_PARAM}" />
133 <input class="input mono" name="to" value="${p.to}" placeholder="to 2026-10-08" aria-label="To" maxlength="${MAX_PARAM}" />
134 <select class="input" name="fcolumn" aria-label="Filter column">
135 ${option("", "(no filter)", p.fcolumn === "")}
136 ${cols.map((c) => option(c, c, c === p.fcolumn))}
137 </select>
138 <select class="input" name="fop" aria-label="Filter operator">
139 ${OPS.map((o) => option(o, o, o === p.fop))}
140 </select>
141 <input class="input mono" name="fvalue" value="${p.fvalue}" placeholder="value" aria-label="Filter value" maxlength="${MAX_PARAM}" />
142 ${btn("Draw", { variant: "primary" })}
143 </form>`,
144 { head: html`<h2>Query</h2>` },
145 );
146
147 let result;
148 if (!answer.ok) {
149 result = empty({ icon: "activity", title: "That query was refused", note: answer.error });
outdated

Both branches of isBucket(p.by) ? p.by : p.by are the same: dead code. Use p.by.

150 } else if (answer.rows.length === 0) {
151 result = empty({ icon: "activity", title: "No rows", note: "Nothing matches this query." });
152 } else {
153 const hrefs = answer.rows.map((r) => (p.rows === "" ? behind(p, r.group) : null));
154 const points = answer.rows.map((r, i) => ({
155 label: String(r.group ?? "all"),
156 value: r.value ?? 0,
157 ...(hrefs[i] ? { href: hrefs[i]! } : {}),
158 }));
159 const chart =
160 p.rows === ""
161 ? html`<div class="chart-box">${raw(lineChart(points, `${p.aggregate} of ${p.source}`))}</div>`
162 : 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>`;
163 result = html`${chart}
164 <table class="table">
165 <thead><tr><th scope="col">${p.by}</th><th scope="col" class="num">${p.aggregate}</th></tr></thead>
166 <tbody>
167 ${answer.rows.map(
168 (r, i) => html`<tr>
169 <td class="mono">${hrefs[i] ? html`<a href="${hrefs[i]!}">${points[i]!.label}</a>` : points[i]!.label}</td>
170 <td class="num mono">${String(r.value ?? "")}</td>
171 </tr>`,
172 )}
173 </tbody>
174 </table>`;
175 }
176
177 return page({
178 ctx,
179 title: "Boards",
180 body: html`<div class="page-head"><h1>Boards</h1></div>
181 ${form} ${card(result, { head: html`<h2>${p.source}</h2>`, extra: "boards-result" })} ${sources}`,
182 });
183 }
src/web/style/content.ts +5
@@ -373,6 +373,11 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); }
373373 .fleet-windows { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 380px), 1fr)); gap: 16px; margin: 0 0 16px; }
374374 .fleet-window { margin: 0; }
375375 .fleet-window figcaption { font-size: 13px; font-weight: 600; margin: 0 0 4px; display: flex; gap: 8px; align-items: baseline; }
376 .chart-box { color: var(--accent); aspect-ratio: 720 / 260; max-width: 720px; }
377 .chart-box .chart { display: block; width: 100%; height: auto; }
378 .chart-box text { fill: var(--muted-fg); font-family: var(--mono); font-size: 10px; }
379 .chart-box circle { fill: var(--accent); }
380 .boards-result .table { margin-top: 12px; }
376381 .fleet-svg { display: block; width: 100%; height: auto; aspect-ratio: 600 / 230; }
377382 .fleet-grid line { stroke: var(--border); stroke-width: 1; }
378383 .fleet-ticks text { fill: var(--muted-fg); font-size: 10px; text-anchor: end; font-family: var(--mono); }
test/boards-page.test.ts added +135
@@ -0,0 +1,135 @@
1 /**
2 * /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.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp } from "../src/app.ts";
11 import { lineChart } from "../src/boards/chart.ts";
12 import { createToken } from "../src/core/tokens.ts";
13 import { createUser } from "../src/core/users.ts";
14 import { serve } from "../src/http/server.ts";
15
16 let dir: string;
17 let app: ReturnType<typeof createApp>;
18 let servers: Awaited<ReturnType<typeof serve>>;
19 const tok: Record<string, string> = {};
20 const get = (path: string, who: string | null) =>
21 fetch(servers.url + path, { headers: who ? { authorization: `Bearer ${tok[who]}` } : {} });
22
23 beforeAll(async () => {
24 dir = await mkdtemp(join(tmpdir(), "forge-boards-page-"));
25 app = createApp({ dataDir: dir, port: 0 });
26 servers = await serve(app);
27 const mk = async (username: string, adminIfFirst?: boolean) => {
28 const r = await createUser(app.db, {
29 trusted: true, username, email: `${username}@forge.local`,
30 password: "a-good-passphrase", ...(adminIfFirst ? { adminIfFirst } : {}),
31 });
32 if (!r.ok) throw new Error(r.error);
33 return r.value;
34 };
35 const alice = await mk("alice", true);
36 const bob = await mk("bob");
37 tok.admin = createToken(app.db, alice.id, "t", "admin").token;
38 tok.bob = createToken(app.db, bob.id, "t", "admin").token;
39 app.db.run("INSERT INTO repo (id, owner_id, name) VALUES ('rb', ?, 'n')", [alice.id]);
40 const ins = app.db.prepare("INSERT INTO run (id, repo_id, kind, state, created_at) VALUES (?, 'rb', 'agent', 'ok', ?)");
41 ins.run("a", "2026-10-01T10:05:00.000Z");
42 ins.run("b", "2026-10-02T10:05:00.000Z");
43 ins.run("c", "2026-10-02T11:05:00.000Z");
44 ins.run("d", "2026-10-04T11:05:00.000Z");
45 const mix = app.db.prepare("INSERT INTO run (id, repo_id, kind, state, turns, created_at) VALUES (?, 'rb', ?, ?, ?, '2026-10-04T12:00:00.000Z')");
46 mix.run("m1", "deploy", "failed", 3);
47 mix.run("m2", "deploy", "ok", 3);
48 mix.run("m3", "check", "failed", 3);
49 mix.run("m4", "check", "ok", 5);
50 });
51
52 afterAll(async () => {
53 servers.stop();
54 app.close();
55 await rm(dir, { recursive: true, force: true });
56 });
57
58 describe("/boards/data", () => {
59 test("seeded runs per day draw one path with one point per day", async () => {
60 const res = await get("/boards/data?source=runs&by=day", "admin");
61 expect(res.status).toBe(200);
62 const body = await res.text();
63 expect(body.match(/<path d="M[^"]*" fill="none" stroke="currentColor" stroke-width="2"/g)?.length).toBe(1);
64 expect(body.match(/<circle cx="[^"]*" cy="[^"]*" r="4">/g)?.length).toBe(3);
65 expect(body).toContain("2026-10-02T00:00:00Z");
66 });
67
68 test("a point links to the rows behind it", async () => {
69 const body = await (await get("/boards/data?source=runs&by=day", "admin")).text();
70 const m = body.match(/href="(\/boards\/data\?[^"]*rows=1[^"]*)"/);
71 expect(m).not.toBeNull();
72 const rows = await (await get(m![1]!.replaceAll("&amp;", "&"), "admin")).text();
73 expect(rows).not.toContain('r="4">');
74 expect(rows).toContain("<table");
75 });
76
77 const pointLinks = (body: string) =>
78 [...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 };
83
84 test("a column point keeps the user's filter and adds its group", async () => {
85 const body = await (await get("/boards/data?source=runs&by=kind&fcolumn=state&fvalue=failed&from=2026-10-04T12:00:00Z", "admin")).text();
86 const links = pointLinks(body);
87 expect(links.length).toBe(2);
88 const mixk = links.find((l) => l.includes("gvalue=deploy"))!;
89 expect(await listed(mixk)).toEqual(["m1"]);
90 expect(await listed(links.find((l) => l.includes("gvalue=check"))!)).toEqual(["m3"]);
91 });
92
93 test("a numeric point is narrowed as a number; a null point has no link", async () => {
94 const body = await (await get("/boards/data?source=runs&by=turns&from=2026-10-04T12:00:00Z", "admin")).text();
95 const five = pointLinks(body).find((l) => l.includes("gvalue=5"))!;
96 expect(await listed(five)).toEqual(["m4"]);
97 const nul = await (await get("/boards/data?source=runs&by=issue_id", "admin")).text();
98 expect(pointLinks(nul)).toEqual([]);
99 });
100
101 test("a bad query is refused on the page, not thrown", async () => {
102 const res = await get("/boards/data?source=nope", "admin");
103 expect(res.status).toBe(200);
104 expect(await res.text()).toContain("That query was refused");
105 });
106
107 test("a filter narrows the points", async () => {
108 const body = await (await get("/boards/data?source=runs&by=day&from=2026-10-02&to=2026-10-03", "admin")).text();
109 expect(body.match(/<circle cx="[^"]*" cy="[^"]*" r="4">/g)?.length).toBe(1);
110 });
111
112 test("hostile params are escaped", async () => {
113 const body = await (await get('/boards/data?from="><script>x</script>', "admin")).text();
114 expect(body).not.toContain("<script>x</script>");
115 });
116
117 test("a non-admin and a stranger get 404; the admin's menu links Boards", async () => {
118 expect((await get("/boards/data", "bob")).status).toBe(404);
119 expect((await get("/boards/data", null)).status).toBe(404);
120 expect(await (await get("/", "admin")).text()).toContain('href="/boards/data"');
121 expect(await (await get("/", "bob")).text()).not.toContain('href="/boards/data"');
122 });
123 });
124
125 describe("lineChart", () => {
126 test("escapes labels and holds its size", () => {
127 const svg = lineChart([{ label: "<b>&", value: 1 }, { label: "x", value: 2 }], "t");
128 expect(svg).not.toContain("<b>");
129 expect(svg).toContain("&lt;b&gt;&amp;");
130 expect(svg).toContain('viewBox="0 0 720 260"');
131 });
132 test("no points still draws", () => {
133 expect(lineChart([], "t")).toContain("No points");
134 });
135 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs