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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1560-boards-step-2-boards-data-draws-a-query → main 6 commits fork 3 threads · 3 unresolved
README.md +4
| @@ -703,6 +703,10 @@ work here. | ||
| 703 | 703 | commit history) with README rendering and **syntax highlighting**, protected |
| 704 | 704 | default branches (force-push and deletion refused, honest fast-forwards |
| 705 | 705 | 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. | |
| 706 | 710 | - **Issues** — labels, threaded comments, close/reopen, markdown. |
| 707 | 711 | - **Pull requests** — open from a branch, a **beautiful diff viewer** (syntax |
| 708 | 712 | highlighting composed with intra-line word highlighting, mobile-first, |
scripts/gate.ts +1
| @@ -34,6 +34,7 @@ export const GATE: readonly string[] = [ | ||
| 34 | 34 | "test/apps-idle.test.ts", |
| 35 | 35 | "test/body-limits.test.ts", |
| 36 | 36 | "test/boards-query.test.ts", |
| 37 | "test/boards-page.test.ts", | |
| 37 | 38 | "test/turnstile.test.ts", |
| 38 | 39 | "test/variants-deck.test.ts", |
| 39 | 40 | "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[] = [ | ||
| 63 | 63 | "audit.csv", |
| 64 | 64 | "big", |
| 65 | 65 | "board", |
| 66 | "boards", | |
| 66 | 67 | "events", |
| 67 | 68 | "explore", |
| 68 | 69 | "fleet", |
src/http/server.ts +2
| @@ -10,6 +10,7 @@ import { unsealReleased } from "../actions/sealed.ts"; | ||
| 10 | 10 | import { capacityPage, capacitySet } from "../web/pages/capacity.ts"; |
| 11 | 11 | import { flowPage } from "../web/pages/flow.ts"; |
| 12 | 12 | import { limitsPage, limitsSet } from "../web/pages/admin-limits.ts"; |
| 13 | import { boardsPage } from "../web/pages/boards.ts"; | |
| 13 | 14 | import { |
| 14 | 15 | closeStreamsOf, |
| 15 | 16 | closeStreamsOfSession, |
| @@ -695,6 +696,7 @@ export function buildRouter(): Router<Handler> { | ||
| 695 | 696 | router.add("POST", "/settings/cloud/remove", cloudRemove); |
| 696 | 697 | router.add("GET", "/settings/capacity", capacityPage); |
| 697 | 698 | router.add("POST", "/settings/capacity", capacitySet); |
| 699 | router.add("GET", "/boards/data", boardsPage); | |
| 698 | 700 | router.add("GET", "/admin/limits", limitsPage); |
| 699 | 701 | router.add("POST", "/admin/limits", limitsSet); |
| 700 | 702 | 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 { | ||
| 208 | 208 | { label: "SSH keys", href: "/settings/keys", icon: "lock" }, |
| 209 | 209 | { label: "Origins", href: "/settings/origins", icon: "globe" }, |
| 210 | 210 | ...(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 | ] | |
| 212 | 217 | : []), |
| 213 | 218 | { separator: true, label: "" }, |
| 214 | 219 | 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 }); | |
| 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 |
||
| 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 |
||
| 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); } | ||
| 373 | 373 | .fleet-windows { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 380px), 1fr)); gap: 16px; margin: 0 0 16px; } |
| 374 | 374 | .fleet-window { margin: 0; } |
| 375 | 375 | .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; } | |
| 376 | 381 | .fleet-svg { display: block; width: 100%; height: auto; aspect-ratio: 600 / 230; } |
| 377 | 382 | .fleet-grid line { stroke: var(--border); stroke-width: 1; } |
| 378 | 383 | .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("&", "&"), "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("&", "&")); | |
| 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("<b>&"); | |
| 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 | }); |
Low: on a number column,
Number("")is0. So choosingturnsas the filter column with the value left blank quietly filtersturns = 0instead of being refused or ignored. Fix: skip the filter whenvalue === "", or passNaNsorunQueryrefuses it and the page shows why.