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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/1562-boards-step-4-the-other-panel-types-bar → main 1 commit fork
11 files
changed +487
−87
README.md +6 −2
| @@ -768,8 +768,12 @@ work here. | ||
| 768 | 768 | allowed). |
| 769 | 769 | - **Boards** — `/boards/data`, for a forge admin who is a person (anyone else |
| 770 | 770 | 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. | |
| 773 | 777 | `/boards` holds the boards themselves: a board is one JSON file |
| 774 | 778 | (`{title, range, refresh: off|10s|1m, panels: [{title, chart, query, w, h}]}`) in |
| 775 | 779 | `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 @@ | ||
| 1 | 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. | |
| 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. | |
| 7 | 9 | */ |
| 8 | 10 | import { escapeXml } from "../web/og/card.ts"; |
| 9 | 11 | |
| 10 | 12 | 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"; | |
| 11 | 23 | |
| 12 | 24 | export const CHART_WIDTH = 720; |
| 13 | 25 | export const CHART_HEIGHT = 260; |
| @@ -35,44 +47,230 @@ export function formatValue(v: number): string { | ||
| 35 | 47 | return num(v); |
| 36 | 48 | } |
| 37 | 49 | |
| 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>`; | |
| 46 | 58 | |
| 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; | |
| 53 | 76 | const gy = y(v); |
| 54 | 77 | out.push( |
| 55 | 78 | `<line x1="${LEFT}" x2="${CHART_WIDTH - RIGHT}" y1="${num(gy)}" y2="${num(gy)}" stroke="currentColor" stroke-opacity="0.2"/>`, |
| 56 | 79 | `<text x="${LEFT - 6}" y="${num(gy + 4)}" text-anchor="end">${escapeXml(formatValue(v))}</text>`, |
| 57 | 80 | ); |
| 58 | 81 | } |
| 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) => { | |
| 65 | 89 | 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>`); | |
| 69 | 91 | }); |
| 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); | |
| 70 | 103 | const d = points.map((p, i) => `${i === 0 ? "M" : "L"}${num(x(i))} ${num(y(p.value))}`).join(" "); |
| 71 | 104 | out.push(`<path d="${d}" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>`); |
| 72 | 105 | 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>`)); | |
| 75 | 107 | }); |
| 76 | 108 | out.push("</svg>"); |
| 77 | 109 | return out.join(""); |
| 78 | 110 | } |
| 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 = { | ||
| 29 | 29 | groupBy?: string | { bucket: string }; |
| 30 | 30 | aggregate: string; |
| 31 | 31 | column?: string; |
| 32 | /** A second grouping column: each group is split into one row per value (stacked area, heat strip). */ | |
| 33 | series?: string; | |
| 32 | 34 | }; |
| 33 | 35 | |
| 34 | 36 | /** Whose rows the asker may see: these repos, or every repo (an admin). */ |
| 35 | 37 | export type Scope = { repoIds: readonly string[] | "all" }; |
| 36 | 38 | |
| 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 }; | |
| 38 | 40 | export type Refusal = { ok: false; error: string }; |
| 39 | 41 | export type Answer = { ok: true; rows: Row[] } | Refusal; |
| 40 | 42 | |
| @@ -121,10 +123,18 @@ export function runQuery(db: Database, q: unknown, scope: Scope): Answer { | ||
| 121 | 123 | groupParam = BUCKETS[b as Bucket]; |
| 122 | 124 | } |
| 123 | 125 | |
| 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 | ||
| 124 | 134 | 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)}` + | |
| 126 | 136 | (where.length ? ` WHERE ${where.join(" AND ")}` : "") + |
| 127 | (groupSql === "NULL" ? "" : ` GROUP BY 1 ORDER BY 1`) + | |
| 137 | (grouped === "" ? "" : ` GROUP BY ${grouped} ORDER BY ${grouped}`) + | |
| 128 | 138 | ` LIMIT ${MAX_ROWS}`; |
| 129 | 139 | const bound = groupParam === undefined ? params : [groupParam, ...params]; |
| 130 | 140 | 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"; | ||
| 9 | 9 | import { listTree, readerFor } from "../git/read.ts"; |
| 10 | 10 | import { commitFiles, type CommitFilesError } from "../git/write.ts"; |
| 11 | 11 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 12 | import { CHARTS, type Chart } from "./chart.ts"; | |
| 12 | 13 | import { SOURCES } from "./sources.ts"; |
| 13 | 14 | |
| 14 | 15 | export const BOARDS_OWNER = "forge"; |
| @@ -17,13 +18,12 @@ const BRANCH = "main"; | ||
| 17 | 18 | |
| 18 | 19 | export const RANGES = ["1h", "24h", "7d", "30d", "all"] as const; |
| 19 | 20 | export const REFRESHES = ["off", "10s", "1m"] as const; |
| 20 | export const CHARTS = ["line"] as const; | |
| 21 | 21 | export type Range = (typeof RANGES)[number]; |
| 22 | 22 | export type Refresh = (typeof REFRESHES)[number]; |
| 23 | 23 | |
| 24 | 24 | export type Panel = { |
| 25 | 25 | title: string; |
| 26 | chart: (typeof CHARTS)[number]; | |
| 26 | chart: Chart; | |
| 27 | 27 | query: Record<string, unknown>; |
| 28 | 28 | w: number; |
| 29 | 29 | h: number; |
src/web/pages/boards-edit.ts +5 −6
| @@ -14,7 +14,7 @@ import type { Ctx } from "../../http/context.ts"; | ||
| 14 | 14 | import { notFoundPage } from "../chrome.ts"; |
| 15 | 15 | import { field, readForm, type Form } from "../form.ts"; |
| 16 | 16 | import { flashTo } from "../layout.ts"; |
| 17 | import { readParams, toQuery } from "./boards.ts"; | |
| 17 | import { PANEL_FIELDS, readParams, toQuery } from "./boards.ts"; | |
| 18 | 18 | |
| 19 | 19 | const num = (v: string) => (/^-?\d{1,4}$/.test(v) ? Number(v) : NaN); |
| 20 | 20 | |
| @@ -31,13 +31,12 @@ function sentence(e: BoardError): string { | ||
| 31 | 31 | return words[e] ?? "That could not be saved."; |
| 32 | 32 | } |
| 33 | 33 | |
| 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. */ | |
| 35 | 35 | function panelOf(form: Form): Record<string, unknown> { |
| 36 | 36 | 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")) }; | |
| 41 | 40 | } |
| 42 | 41 | |
| 43 | 42 | function editOf(op: string, form: Form): Edit | undefined { |
src/web/pages/boards-view.ts +7 −13
| @@ -8,8 +8,7 @@ | ||
| 8 | 8 | * `boards-edit.ts`. Anyone but a person who is an admin gets the not-found page. |
| 9 | 9 | */ |
| 10 | 10 | 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"; | |
| 13 | 12 | import { MAX_H, MAX_PANELS, MAX_TITLE, MAX_W, RANGES, REFRESHES, type Board, type Panel } from "../../boards/store.ts"; |
| 14 | 13 | import type { App } from "../../app.ts"; |
| 15 | 14 | import type { Ctx } from "../../http/context.ts"; |
| @@ -18,7 +17,7 @@ import { notFoundPage } from "../chrome.ts"; | ||
| 18 | 17 | import { html, raw, when, type Html } from "../html.ts"; |
| 19 | 18 | import { fragment, page } from "../layout.ts"; |
| 20 | 19 | 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"; | |
| 22 | 21 | |
| 23 | 22 | const RANGE_MS = { "1h": 3_600_000, "24h": 86_400_000, "7d": 604_800_000, "30d": 2_592_000_000 } as const; |
| 24 | 23 | const EVERY = { "10s": 10, "1m": 60 } as const; |
| @@ -33,16 +32,11 @@ function ranged(query: Record<string, unknown>, range: Board["range"]): Record<s | ||
| 33 | 32 | } |
| 34 | 33 | |
| 35 | 34 | 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); | |
| 38 | 37 | const body = !answer.ok |
| 39 | 38 | ? 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); | |
| 46 | 40 | const explore = new URLSearchParams(Object.entries(q).filter(([k, v]) => v !== "" && k !== "rows")); |
| 47 | 41 | return html`<div class="card bp-w${String(panel.w)} bp-h${String(panel.h)}"> |
| 48 | 42 | <div class="card-head"><h2>${panel.title}</h2> |
| @@ -121,8 +115,8 @@ function panelForm(slug: string, op: string, p: Panel | undefined, index: number | ||
| 121 | 115 | function manage(slug: string, board: Board, adding: Html): Html { |
| 122 | 116 | const one = (p: Panel, i: number) => html`<li class="row bmanage"> |
| 123 | 117 | <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)))} | |
| 126 | 120 | </details> |
| 127 | 121 | <form method="post" action="/boards/${slug}/move" class="row-actions"> |
| 128 | 122 | <input type="hidden" name="index" value="${String(i)}" /> |
src/web/pages/boards.ts +68 −24
| @@ -1,18 +1,20 @@ | ||
| 1 | 1 | /** |
| 2 | 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 | |
| 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 | |
| 5 | 6 | * query is a link. Each point links to the rows behind it: the same query |
| 6 | 7 | * narrowed to that point's window and listed by the source's first column. |
| 7 | 8 | * Anyone else gets the not-found page. |
| 8 | 9 | */ |
| 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"; | |
| 11 | 13 | import { SOURCES } from "../../boards/sources.ts"; |
| 12 | 14 | import { listAllBoards } from "../../actions/boards.ts"; |
| 13 | 15 | import type { Ctx } from "../../http/context.ts"; |
| 14 | 16 | import { notFoundPage } from "../chrome.ts"; |
| 15 | import { html, raw } from "../html.ts"; | |
| 17 | import { html, raw, when } from "../html.ts"; | |
| 16 | 18 | import { page } from "../layout.ts"; |
| 17 | 19 | import { btn, card, empty } from "../ui.ts"; |
| 18 | 20 | |
| @@ -20,18 +22,24 @@ const BUCKET_MS = { minute: 60_000, hour: 3_600_000, day: 86_400_000 } as const; | ||
| 20 | 22 | const AGGREGATES = ["count", "sum", "avg", "max"] as const; |
| 21 | 23 | const OPS = ["eq", "ne", "lt", "lte", "gt", "gte"] as const; |
| 22 | 24 | 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; | |
| 23 | 27 | |
| 24 | 28 | type Bucket = keyof typeof BUCKET_MS; |
| 25 | 29 | 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); | |
| 26 | 31 | |
| 27 | 32 | /** What the URL asks for: plain strings, bounded; `runQuery` judges them. */ |
| 28 | 33 | export function readParams(search: URLSearchParams) { |
| 29 | 34 | const get = (k: string, d = "") => (search.get(k) ?? d).slice(0, MAX_PARAM); |
| 35 | const chart = get("chart", "line"); | |
| 30 | 36 | return { |
| 37 | chart: isChart(chart) ? chart : "line", | |
| 31 | 38 | source: get("source", "runs"), |
| 32 | 39 | aggregate: get("aggregate", "count"), |
| 33 | 40 | column: get("column"), |
| 34 | 41 | by: get("by", "day"), |
| 42 | series: get("series"), | |
| 35 | 43 | from: get("from"), |
| 36 | 44 | to: get("to"), |
| 37 | 45 | fcolumn: get("fcolumn"), |
| @@ -51,6 +59,7 @@ export function toQuery(p: Params): Query { | ||
| 51 | 59 | if (p.from !== "") q.from = p.from; |
| 52 | 60 | if (p.to !== "") q.to = p.to; |
| 53 | 61 | q.groupBy = isBucket(p.by) ? { bucket: p.by } : p.by; |
| 62 | if (p.series !== "") q.series = p.series; | |
| 54 | 63 | const filter: NonNullable<Query["filter"]> = []; |
| 55 | 64 | const add = (column: string, op: string, value: string) => { |
| 56 | 65 | const numeric = src?.columns[column] === "number"; |
| @@ -62,6 +71,30 @@ export function toQuery(p: Params): Query { | ||
| 62 | 71 | return q; |
| 63 | 72 | } |
| 64 | 73 | |
| 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 | ||
| 65 | 98 | const link = (p: Params) => { |
| 66 | 99 | const s = new URLSearchParams(); |
| 67 | 100 | for (const [k, v] of Object.entries(p)) if (v !== "") s.set(k, v); |
| @@ -75,18 +108,22 @@ const link = (p: Params) => { | ||
| 75 | 108 | * rows view counts rows by the source's first column, so it lists that |
| 76 | 109 | * column's values, not whole rows. |
| 77 | 110 | */ |
| 78 | function behind(p: Params, group: string | number | null): string | null { | |
| 111 | function behind(p: Params, row: Row): string | null { | |
| 112 | const { group } = row; | |
| 79 | 113 | 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: "" }; | |
| 81 | 115 | 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; | |
| 83 | 118 | return link({ ...rows, gcolumn: p.by, gvalue: String(group) }); |
| 84 | 119 | } |
| 85 | 120 | if (typeof group !== "string") return null; |
| 121 | if (row.series === null) return null; | |
| 86 | 122 | const start = new Date(group); |
| 87 | 123 | return link({ |
| 88 | 124 | ...rows, |
| 89 | 125 | from: start.toISOString(), to: new Date(start.getTime() + BUCKET_MS[p.by]).toISOString(), |
| 126 | ...(row.series === undefined ? {} : { gcolumn: p.series, gvalue: String(row.series) }), | |
| 90 | 127 | }); |
| 91 | 128 | } |
| 92 | 129 | |
| @@ -98,7 +135,10 @@ export function queryFields(p: Params) { | ||
| 98 | 135 | const src = Object.hasOwn(SOURCES, p.source) ? SOURCES[p.source]! : undefined; |
| 99 | 136 | const cols = src ? Object.keys(src.columns) : []; |
| 100 | 137 | 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"> | |
| 102 | 142 | ${Object.keys(SOURCES).map((n) => option(n, n, n === p.source))} |
| 103 | 143 | </select> |
| 104 | 144 | <select class="input" name="aggregate" aria-label="Aggregate"> |
| @@ -112,6 +152,10 @@ export function queryFields(p: Params) { | ||
| 112 | 152 | ${(Object.keys(BUCKET_MS) as Bucket[]).map((b) => option(b, `per ${b}`, b === p.by))} |
| 113 | 153 | ${cols.map((c) => option(c, `by ${c}`, c === p.by))} |
| 114 | 154 | </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> | |
| 115 | 159 | <input class="input mono" name="from" value="${p.from}" placeholder="from 2026-10-01" aria-label="From" maxlength="${MAX_PARAM}" /> |
| 116 | 160 | <input class="input mono" name="to" value="${p.to}" placeholder="to 2026-10-08" aria-label="To" maxlength="${MAX_PARAM}" /> |
| 117 | 161 | <select class="input" name="fcolumn" aria-label="Filter column"> |
| @@ -125,18 +169,20 @@ export function queryFields(p: Params) { | ||
| 125 | 169 | `; |
| 126 | 170 | } |
| 127 | 171 | |
| 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 { | |
| 130 | 174 | const text = (v: unknown) => (typeof v === "string" || typeof v === "number" ? String(v) : ""); |
| 131 | 175 | const g = q.groupBy; |
| 132 | 176 | const f = Array.isArray(q.filter) ? (q.filter[0] as Record<string, unknown> | undefined) : undefined; |
| 133 | 177 | const base = readParams(new URLSearchParams()); |
| 134 | 178 | return { |
| 135 | 179 | ...base, |
| 180 | chart, | |
| 136 | 181 | source: text(q.source) || base.source, |
| 137 | 182 | aggregate: text(q.aggregate) || base.aggregate, |
| 138 | 183 | column: text(q.column), |
| 139 | 184 | by: typeof g === "string" ? g : typeof g === "object" && g !== null ? text((g as { bucket?: unknown }).bucket) : base.by, |
| 185 | series: text(q.series), | |
| 140 | 186 | from: text(q.from), |
| 141 | 187 | to: text(q.to), |
| 142 | 188 | ...(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> { | ||
| 149 | 195 | if (user?.isAdmin !== true || user.kind === "agent") return notFoundPage(ctx); |
| 150 | 196 | const p = readParams(ctx.url.searchParams); |
| 151 | 197 | 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" }); | |
| 153 | 199 | const cols = src ? Object.keys(src.columns) : []; |
| 154 | 200 | |
| 155 | 201 | const sources = card( |
| 156 | 202 | html`<ul class="list"> |
| 157 | 203 | ${Object.entries(SOURCES).map( |
| 158 | 204 | ([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> | |
| 160 | 206 | <span class="muted small mono">${Object.entries(s.columns).map(([c, t]) => `${c}:${t}`).join(" ")}</span> |
| 161 | 207 | </li>`, |
| 162 | 208 | )} |
| @@ -178,28 +224,26 @@ export async function boardsPage(ctx: Ctx): Promise<Response> { | ||
| 178 | 224 | } else if (answer.rows.length === 0) { |
| 179 | 225 | result = empty({ icon: "activity", title: "No rows", note: "Nothing matches this query." }); |
| 180 | 226 | } 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; | |
| 187 | 230 | const chart = |
| 188 | 231 | 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}`) | |
| 190 | 233 | : 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>`; |
| 191 | 234 | 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> | |
| 194 | 237 | <tbody> |
| 195 | 238 | ${answer.rows.map( |
| 196 | 239 | (r, i) => html`<tr> |
| 197 | 240 | <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} | |
| 198 | 242 | <td class="num mono">${String(r.value ?? "")}</td> |
| 199 | 243 | </tr>`, |
| 200 | 244 | )} |
| 201 | 245 | </tbody> |
| 202 | </table>`; | |
| 246 | </table>`)}`; | |
| 203 | 247 | } |
| 204 | 248 | |
| 205 | 249 | const listed = await listAllBoards(ctx.app, user); |
| @@ -209,7 +253,7 @@ export async function boardsPage(ctx: Ctx): Promise<Response> { | ||
| 209 | 253 | ? null |
| 210 | 254 | : card( |
| 211 | 255 | 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( | |
| 213 | 257 | (k) => html`<input type="hidden" name="${k}" value="${p[k as keyof Params]}" />`, |
| 214 | 258 | )} |
| 215 | 259 | <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); } | ||
| 377 | 377 | .chart-box .chart { display: block; width: 100%; height: auto; } |
| 378 | 378 | .chart-box text { fill: var(--muted-fg); font-family: var(--mono); font-size: 10px; } |
| 379 | 379 | .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; } | |
| 380 | 384 | .boards-result .table { margin-top: 12px; } |
| 381 | 385 | .bgrid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; } |
| 382 | 386 | .bp-w2 { grid-column: span 2; } |
test/boards-page.test.ts +115 −7
| @@ -1,14 +1,15 @@ | ||
| 1 | 1 | /** |
| 2 | 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. | |
| 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. | |
| 5 | 6 | */ |
| 6 | 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 7 | 8 | import { mkdtemp, rm } from "node:fs/promises"; |
| 8 | 9 | import { tmpdir } from "node:os"; |
| 9 | 10 | import { join } from "node:path"; |
| 10 | 11 | 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"; | |
| 12 | 13 | import { createToken } from "../src/core/tokens.ts"; |
| 13 | 14 | import { createUser } from "../src/core/users.ts"; |
| 14 | 15 | import { serve } from "../src/http/server.ts"; |
| @@ -55,6 +56,12 @@ afterAll(async () => { | ||
| 55 | 56 | await rm(dir, { recursive: true, force: true }); |
| 56 | 57 | }); |
| 57 | 58 | |
| 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 | ||
| 58 | 65 | describe("/boards/data", () => { |
| 59 | 66 | test("seeded runs per day draw one path with one point per day", async () => { |
| 60 | 67 | const res = await get("/boards/data?source=runs&by=day", "admin"); |
| @@ -76,10 +83,6 @@ describe("/boards/data", () => { | ||
| 76 | 83 | |
| 77 | 84 | const pointLinks = (body: string) => |
| 78 | 85 | [...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 | 86 | |
| 84 | 87 | test("a column point keeps the user's filter and adds its group", async () => { |
| 85 | 88 | 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", () => { | ||
| 122 | 125 | }); |
| 123 | 126 | }); |
| 124 | 127 | |
| 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("&", "&")); | |
| 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("<b>&"); | |
| 230 | }); | |
| 231 | }); | |
| 232 | ||
| 125 | 233 | describe("lineChart", () => { |
| 126 | 234 | test("escapes labels and holds its size", () => { |
| 127 | 235 | const svg = lineChart([{ label: "<b>&", value: 1 }, { label: "x", value: 2 }], "t"); |
test/boards-query.test.ts +23
| @@ -126,6 +126,29 @@ describe("runQuery", () => { | ||
| 126 | 126 | }); |
| 127 | 127 | }); |
| 128 | 128 | |
| 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 | ||
| 129 | 152 | describe("scope", () => { |
| 130 | 153 | const count = { source: "runs", aggregate: "count" }; |
| 131 | 154 | test("a guest scope sees none of a private repo's runs", () => { |
test/boards-store.test.ts +16
| @@ -216,6 +216,22 @@ describe("/boards", () => { | ||
| 216 | 216 | expect((await readBoard(app.reposRoot, "fleet-health"))!.panels.at(-1)!.title).toBe("pinned"); |
| 217 | 217 | }); |
| 218 | 218 | |
| 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 | ||
| 219 | 235 | test("a cross-origin post changes nothing, and duplicates are refused as unnamed", async () => { |
| 220 | 236 | const before = await readBoard(app.reposRoot, "fleet-health"); |
| 221 | 237 | for (const path of ["/boards/fleet-health/rename", "/boards/fleet-health/delete", "/boards/new", "/boards/pin"]) { |
new above
offline