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

Boards, step 3: a board is a JSON file in a forge-owned repo, made, renamed, duplicated and deleted from /boards #1767

Merged opened this

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

Merged

scout/forge:scout/1561-boards-step-3-a-board-is-a-json-file-in → main 1 commit fork 4 threads · 4 unresolved

  • Merged. scout/1561-boards-step-3-a-board-is-a-json-file-in was merged into main as be0e141.

Review

Sign in to review this change.
14 files changed +1000 −35
README.md +7
@@ -725,6 +725,13 @@ work here.
725725 gets a 404): the query is the URL (`?source=runs&aggregate=count&by=day&fcolumn=state&fvalue=failed`),
726726 drawn as a server-rendered line chart with its points in a table; a point opens the rows
727727 behind it, keeping your filter.
728 `/boards` holds the boards themselves: a board is one JSON file
729 (`{title, range, refresh: off|10s|1m, panels: [{title, chart, query, w, h}]}`) in
730 `forge/boards`, a repo the forge owns and makes on the first board; every save is a commit
731 by the admin who made it, and a file that does not validate is not shown. Make, rename,
732 duplicate, delete, add/edit/remove/reorder panels, or pin the query on `/boards/data`.
733 A board with a refresh redraws its grid on that beat through the live-region swap
734 (`/boards/:slug/live`, etag'd). No dependency was added.
728735 - **Issues** — labels, threaded comments, close/reopen, markdown.
729736 - **Pull requests** — open from a branch, a **beautiful diff viewer** (syntax
730737 highlighting composed with intra-line word highlighting, mobile-first,
scripts/gate.ts +1
@@ -35,6 +35,7 @@ export const GATE: readonly string[] = [
3535 "test/body-limits.test.ts",
3636 "test/boards-query.test.ts",
3737 "test/boards-page.test.ts",
38 "test/boards-store.test.ts",
3839 "test/turnstile.test.ts",
3940 "test/variants-deck.test.ts",
4041 "test/variants-surfaces.test.ts",
src/actions/boards.ts added +129
@@ -0,0 +1,129 @@
1 /**
2 * Boards, made and changed by a forge admin who is a person (an agent never
3 * edits what the admins look at). Everyone else is told `not_found`. A board
4 * is a file in `forge/boards` (`boards/store.ts`); the repo and the `forge`
5 * organization that owns it are made by the first save, so a forge that
6 * never makes a board never has either. Each change is one commit authored
7 * as the admin who made it, and leaves a `board.saved` or `board.deleted`
8 * event.
9 */
10 import type { App } from "../app.ts";
11 import { applyEdit, type Edit } from "../boards/edit.ts";
12 import { runQuery } from "../boards/query.ts";
13 import {
14 BOARDS_OWNER, BOARDS_REPO, emptyBoard, isSlug, listBoards, readBoard, removeBoard, saveBoard, slugOf,
15 type Board, type Stored, type StoreError,
16 } from "../boards/store.ts";
17 import { commitIdentity } from "../core/commit_identity.ts";
18 import { createOrg } from "../core/orgs.ts";
19 import { createRepo, getRepo } from "../core/repos.ts";
20 import { getUserByName, type User } from "../core/users.ts";
21 import { err, ok, type Result } from "../kernel/result.ts";
22
23 export type BoardError = StoreError | "not_found" | "no_slug" | "repo" | { refused: string };
24
25 function admin(actor: User | undefined): Result<User, "not_found"> {
26 return actor?.isAdmin === true && actor.kind !== "agent" ? ok(actor) : err("not_found");
27 }
28
29 /** The forge's boards repo, made if it is not there yet. */
30 async function ensureRepo(app: App, who: User): Promise<boolean> {
31 if (getRepo(app.db, BOARDS_OWNER, BOARDS_REPO)) return true;
32 let owner = getUserByName(app.db, BOARDS_OWNER);
33 if (!owner) {
34 const org = createOrg(app.db, BOARDS_OWNER, who.id, { allowReserved: true });
35 if (!org.ok) return false;
36 owner = org.value;
37 }
38 if (owner.kind !== "org") return false;
39 const made = await createRepo(app.db, app.reposRoot, {
40 ownerId: owner.id,
41 name: BOARDS_REPO,
42 description: "The boards of this forge: one JSON file each.",
43 });
44 return made.ok;
45 }
46
47 export async function listAllBoards(app: App, actor: User | undefined): Promise<Result<Stored[], "not_found">> {
48 const who = admin(actor);
49 if (!who.ok) return who;
50 return ok(getRepo(app.db, BOARDS_OWNER, BOARDS_REPO) ? await listBoards(app.reposRoot) : []);
51 }
52
53 export async function showBoard(app: App, actor: User | undefined, slug: string): Promise<Result<Board, "not_found">> {
54 const who = admin(actor);
55 if (!who.ok) return who;
56 const board = await readBoard(app.reposRoot, slug);
57 return board ? ok(board) : err("not_found");
58 }
59
60 /** Commit, then say so on the outbox. */
61 async function save(
62 app: App, who: User, slug: string, board: unknown, message: string, mode: "create" | "update",
63 ): Promise<Result<Board, BoardError>> {
64 // A panel that cannot run is refused on the way in, not drawn as an error later.
65 const parsed = (board ?? {}) as { panels?: { title?: string; query?: unknown }[] };
66 for (const p of parsed.panels ?? []) {
67 const ran = runQuery(app.db, p.query, { repoIds: "all" });
68 if (!ran.ok) return err({ refused: `${p.title ?? "panel"}: ${ran.error}` });
69 }
70 const done = await saveBoard(app.reposRoot, slug, board, commitIdentity(app.config, who), message, mode);
71 if (!done.ok) return done;
72 app.bus.append({ kind: "board.saved", actorId: who.id, payload: { slug } });
73 return done;
74 }
75
76 /** Make an empty board from a title; its slug is the title's, with a number if taken. */
77 export async function makeBoard(app: App, actor: User | undefined, title: string): Promise<Result<string, BoardError>> {
78 const who = admin(actor);
79 if (!who.ok) return who;
80 const base = slugOf(title);
81 if (base === "") return err("no_slug");
82 if (!(await ensureRepo(app, who.value))) return err("repo");
83 const board = emptyBoard(title);
84 for (let n = 1; n <= 20; n++) {
85 const slug = n === 1 ? base : `${base.slice(0, 36)}-${n}`;
86 const done = await save(app, who.value, slug, board, `Make board ${slug}`, "create");
87 if (done.ok) return ok(slug);
88 if (done.error !== "exists") return done;
89 }
90 return err("exists");
91 }
92
93 /** Apply one edit to a board and commit it. */
94 export async function changeBoard(
95 app: App, actor: User | undefined, slug: string, edit: Edit,
96 ): Promise<Result<Board, BoardError>> {
97 const who = admin(actor);
98 if (!who.ok) return who;
99 const board = await readBoard(app.reposRoot, slug);
100 if (!board) return err("not_found");
101 const next = applyEdit(board, edit);
102 if (!next.ok) return err({ refused: next.error });
103 return save(app, who.value, slug, next.value, `Change board ${slug}: ${edit.kind}`, "update");
104 }
105
106 /** Copy a board under a new slug (`<slug>-copy`, numbered if taken). */
107 export async function duplicateBoard(app: App, actor: User | undefined, slug: string): Promise<Result<string, BoardError>> {
108 const who = admin(actor);
109 if (!who.ok) return who;
110 const board = await readBoard(app.reposRoot, slug);
111 if (!board) return err("not_found");
112 const copy: Board = { ...board, title: `${board.title} (copy)`.slice(0, 80) };
113 for (let n = 1; n <= 20; n++) {
114 const to = `${slug.slice(0, 33)}-copy${n === 1 ? "" : `-${n}`}`;
115 if (!isSlug(to)) return err("no_slug");
116 const done = await save(app, who.value, to, copy, `Duplicate board ${slug} as ${to}`, "create");
117 if (done.ok) return ok(to);
118 if (done.error !== "exists") return done;
119 }
120 return err("exists");
121 }
122
123 export async function deleteBoard(app: App, actor: User | undefined, slug: string): Promise<Result<true, BoardError>> {
124 const who = admin(actor);
125 if (!who.ok) return who;
126 const done = await removeBoard(app.reposRoot, slug, commitIdentity(app.config, who.value), `Delete board ${slug}`);
127 if (done.ok) app.bus.append({ kind: "board.deleted", actorId: who.value.id, payload: { slug } });
128 return done;
129 }
src/boards/edit.ts added +57
@@ -0,0 +1,57 @@
1 /**
2 * The ways a board changes, as one pure function: a board and an edit in, the
3 * new board or the reason it cannot be out. Nothing here reads or writes the
4 * repo; `store.ts` judges the result again before it is committed.
5 */
6 import { err, ok, type Result } from "../kernel/result.ts";
7 import { parseBoard, parsePanel, type Board } from "./store.ts";
8
9 export type Edit =
10 | { kind: "title"; title: unknown }
11 | { kind: "settings"; range: unknown; refresh: unknown }
12 | { kind: "add"; panel: unknown }
13 | { kind: "edit"; index: number; panel: unknown }
14 | { kind: "remove"; index: number }
15 | { kind: "move"; index: number; by: -1 | 1 };
16
17 export function applyEdit(board: Board, edit: Edit): Result<Board, string> {
18 const panels = [...board.panels];
19 const at = "index" in edit ? edit.index : -1;
20 if ("index" in edit && (!Number.isInteger(at) || at < 0 || at >= panels.length)) {
21 return err("there is no such panel");
22 }
23 let next: unknown;
24 switch (edit.kind) {
25 case "title":
26 next = { ...board, title: edit.title };
27 break;
28 case "settings":
29 next = { ...board, range: edit.range, refresh: edit.refresh };
30 break;
31 case "add": {
32 const p = parsePanel(edit.panel);
33 if (!p.ok) return p;
34 next = { ...board, panels: [...panels, p.value] };
35 break;
36 }
37 case "edit": {
38 const p = parsePanel(edit.panel);
39 if (!p.ok) return p;
40 panels[at] = p.value;
41 next = { ...board, panels };
42 break;
43 }
44 case "remove":
45 panels.splice(at, 1);
46 next = { ...board, panels };
47 break;
48 case "move": {
49 const to = at + edit.by;
50 if (to < 0 || to >= panels.length) return ok(board);

Low: ok(board) goes back to changeBoard, which commits it, so 'Up' on the first panel adds a commit that changes nothing. Return a no-op marker, or compare before saving, and skip the commit.

51 [panels[at], panels[to]] = [panels[to]!, panels[at]!];
52 next = { ...board, panels };
53 break;
54 }
55 }
56 return parseBoard(next);
57 }
src/boards/store.ts added +174
@@ -0,0 +1,174 @@
1 /**
2 * A board is one JSON file in `forge/boards`, a repo the forge owns: the
3 * file's name is the board's slug, each save is a commit by the admin who
4 * made it, and git is the only place a board lives. `parseBoard` is the one
5 * door in and out — a file that does not pass it is not a board, whoever
6 * wrote it (a push can put anything in the repo).
7 */
8 import { repoDir } from "../git/repos.ts";
9 import { listTree, readerFor } from "../git/read.ts";
10 import { commitFiles, type CommitFilesError } from "../git/write.ts";
11 import { err, ok, type Result } from "../kernel/result.ts";
12 import { SOURCES } from "./sources.ts";
13
14 export const BOARDS_OWNER = "forge";
15 export const BOARDS_REPO = "boards";
16 const BRANCH = "main";
17
18 export const RANGES = ["1h", "24h", "7d", "30d", "all"] as const;
19 export const REFRESHES = ["off", "10s", "1m"] as const;
20 export const CHARTS = ["line"] as const;
21 export type Range = (typeof RANGES)[number];
22 export type Refresh = (typeof REFRESHES)[number];
23
24 export type Panel = {
25 title: string;
26 chart: (typeof CHARTS)[number];
27 query: Record<string, unknown>;
28 w: number;
29 h: number;
30 };
31 export type Board = { title: string; range: Range; refresh: Refresh; panels: Panel[] };
32
33 export const MAX_TITLE = 80;
34 export const MAX_PANELS = 24;
35 export const MAX_W = 3;
36 export const MAX_H = 3;
37 const MAX_QUERY_BYTES = 4096;
38 const MAX_FILE = 256 * 1024;
39 const MAX_BOARDS = 200;
40
41 const has = (o: object, k: string) => Object.prototype.hasOwnProperty.call(o, k);
42 const isObject = (v: unknown): v is Record<string, unknown> =>
43 typeof v === "object" && v !== null && !Array.isArray(v);
44 const oneOf = <T extends string>(list: readonly T[], v: unknown): v is T =>
45 typeof v === "string" && (list as readonly string[]).includes(v);
46 const title = (v: unknown): v is string =>
47 typeof v === "string" && v.trim().length > 0 && v.length <= MAX_TITLE && !/[\u0000-\u001f\u007f]/.test(v);
48 const span = (v: unknown, max: number): v is number =>
49 typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= max;
50
51 /** A slug is a file name: lowercase, short, and never the `data` page's. */
52 export const isSlug = (s: unknown): s is string =>
53 typeof s === "string" && /^[a-z0-9][a-z0-9-]{0,39}$/.test(s) && s !== "data";
54
55 /** A board's slug from its title, or "" when the title has no letters or digits. */
56 export function slugOf(t: string): string {
57 const s = t.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/, "");
58 return isSlug(s) ? s : s === "data" ? "data-board" : "";
59 }
60
61 /** A panel, judged: the plain value or the reason it is not one. */
62 export function parsePanel(raw: unknown): Result<Panel, string> {
63 if (!isObject(raw)) return err("a panel must be an object");
64 if (!title(raw.title)) return err("a panel needs a title of up to 80 characters");
65 if (!oneOf(CHARTS, raw.chart)) return err(`chart must be one of ${CHARTS.join(", ")}`);
66 if (!isObject(raw.query)) return err("a panel's query must be an object");
67 if (typeof raw.query.source !== "string" || !has(SOURCES, raw.query.source)) {
68 return err("a panel's query needs a known source");
69 }
70 if (JSON.stringify(raw.query).length > MAX_QUERY_BYTES) return err("a panel's query is too long");
71 if (!span(raw.w, MAX_W)) return err(`w must be a whole number from 1 to ${MAX_W}`);
72 if (!span(raw.h, MAX_H)) return err(`h must be a whole number from 1 to ${MAX_H}`);
73 return ok({ title: raw.title.trim(), chart: raw.chart, query: raw.query, w: raw.w, h: raw.h });
74 }
75
76 /** A board, judged: the plain value (unknown keys dropped) or the reason it is not one. */
77 export function parseBoard(raw: unknown): Result<Board, string> {
78 if (!isObject(raw)) return err("a board must be an object");
79 if (!title(raw.title)) return err("a board needs a title of up to 80 characters");
80 if (!oneOf(RANGES, raw.range)) return err(`range must be one of ${RANGES.join(", ")}`);
81 if (!oneOf(REFRESHES, raw.refresh)) return err(`refresh must be one of ${REFRESHES.join(", ")}`);
82 if (!Array.isArray(raw.panels) || raw.panels.length > MAX_PANELS) {
83 return err(`a board holds at most ${MAX_PANELS} panels`);
84 }
85 const panels: Panel[] = [];
86 for (const p of raw.panels) {
87 const one = parsePanel(p);
88 if (!one.ok) return one;
89 panels.push(one.value);
90 }
91 return ok({ title: raw.title.trim(), range: raw.range, refresh: raw.refresh, panels });
92 }
93
94 export const emptyBoard = (t: string): Board => ({ title: t.trim(), range: "7d", refresh: "off", panels: [] });
95
96 export type StoreError = CommitFilesError["kind"] | "invalid" | "not_found" | "exists" | "too_many";
97 export type Stored = { slug: string; board: Board };
98 export type Author = { name: string; email: string };
99
100 const file = (slug: string) => `${slug}.json`;
101 const dirOf = (reposRoot: string) => repoDir(reposRoot, BOARDS_OWNER, BOARDS_REPO);
102
103 /** One board by slug, or undefined when there is none or the file is not a board. */
104 export async function readBoard(reposRoot: string, slug: string): Promise<Board | undefined> {
105 if (!isSlug(slug)) return undefined;
106 const got = await readerFor(dirOf(reposRoot)).object(`${BRANCH}:${file(slug)}`);
107 if (!got.ok || got.value.type !== "blob" || got.value.size > MAX_FILE) return undefined;
108 try {
109 const parsed = parseBoard(JSON.parse(new TextDecoder().decode(got.value.body)));
110 return parsed.ok ? parsed.value : undefined;
111 } catch {
112 return undefined;
113 }
114 }
115
116 /** Every board in the repo, by slug; files that are not boards are left out. */
117 export async function listBoards(reposRoot: string): Promise<Stored[]> {
118 const tree = await listTree(dirOf(reposRoot), BRANCH, "");
119 if (!tree.ok) return [];
120 const slugs = tree.value
121 .filter((e) => e.type === "blob" && e.name.endsWith(".json") && isSlug(e.name.slice(0, -5)))
122 .map((e) => e.name.slice(0, -5))
123 .sort()
124 .slice(0, MAX_BOARDS);
125 const read = await Promise.all(slugs.map(async (slug) => ({ slug, board: await readBoard(reposRoot, slug) })));
126 return read.filter((s): s is Stored => s.board !== undefined);
127 }
128
129 /**
130 * Validate and commit `board` as `slug`. `create` refuses a slug already in
131 * use; without it the board must already exist. One commit, one file, the
132 * author's name on it; the branch moving under us is an error, not a merge.
133 */
134 export async function saveBoard(
135 reposRoot: string,
136 slug: string,
137 board: unknown,
138 author: Author,
139 message: string,
140 mode: "create" | "update" = "update",
141 ): Promise<Result<Board, StoreError>> {
142 if (!isSlug(slug)) return err("invalid");
143 const parsed = parseBoard(board);
144 if (!parsed.ok) return err("invalid");
145 const there = (await readBoard(reposRoot, slug)) !== undefined;
146 if (mode === "create" && there) return err("exists");
147 if (mode === "update" && !there) return err("not_found");
148 if (mode === "create" && (await listBoards(reposRoot)).length >= MAX_BOARDS) return err("too_many");
149 const done = await commitFiles(dirOf(reposRoot), {
150 branch: BRANCH,
151 message,
152 files: [{ path: file(slug), content: `${JSON.stringify(parsed.value, null, 2)}\n` }],
153 author,
154 });
155 return done.ok ? ok(parsed.value) : err(done.error.kind);
156 }
157
158 /** Delete a board's file in one commit. */
159 export async function removeBoard(
160 reposRoot: string,
161 slug: string,
162 author: Author,
163 message: string,
164 ): Promise<Result<true, StoreError>> {
165 if (!isSlug(slug)) return err("invalid");
166 if ((await readBoard(reposRoot, slug)) === undefined) return err("not_found");
167 const done = await commitFiles(dirOf(reposRoot), {
168 branch: BRANCH,
169 message,
170 files: [{ path: file(slug), content: null }],
171 author,
172 });
173 return done.ok ? ok(true) : err(done.error.kind);
174 }
src/http/server.ts +8
@@ -11,6 +11,8 @@ 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";
1313 import { boardsPage } from "../web/pages/boards.ts";
14 import { boardNew, boardPin, boardPost } from "../web/pages/boards-edit.ts";
15 import { boardLive, boardShow, boardsList } from "../web/pages/boards-view.ts";
1416 import {
1517 closeStreamsOf,
1618 closeStreamsOfSession,
@@ -698,7 +700,13 @@ export function buildRouter(): Router<Handler> {
698700 router.add("POST", "/settings/cloud/remove", cloudRemove);
699701 router.add("GET", "/settings/capacity", capacityPage);
700702 router.add("POST", "/settings/capacity", capacitySet);
703 router.add("GET", "/boards", boardsList);
701704 router.add("GET", "/boards/data", boardsPage);
705 router.add("POST", "/boards/new", boardNew);
706 router.add("POST", "/boards/pin", boardPin);
707 router.add("GET", "/boards/:slug", boardShow);
708 router.add("GET", "/boards/:slug/live", boardLive);
709 router.add("POST", "/boards/:slug/:op", boardPost);
702710 router.add("GET", "/admin/limits", limitsPage);
703711 router.add("POST", "/admin/limits", limitsSet);
704712 router.add("GET", "/settings/backups", backupsPage);
src/web/client.ts +7
@@ -1985,6 +1985,13 @@ export const liveRegionsJs = /* js */ `
19851985 if (hit && timer === null)
19861986 timer = setTimeout(refresh, Math.max(600, last + MIN_GAP - Date.now()));
19871987 }
1988 // A region with data-live-every="<seconds>" is also due on that beat, for
1989 // what no event announces; it rides the same fetch, etag and swap.
1990 Array.prototype.forEach.call(document.querySelectorAll('[data-live-every]'), function (r) {
1991 var s = parseInt(r.getAttribute('data-live-every'), 10);
1992 if (!r.id || !(s > 0)) return;
1993 window.setInterval(function () { due[r.id] = true; if (timer === null) timer = setTimeout(refresh, 0); }, s * 1000);

Low: the 10s/1m beat is new behaviour, but the tests only check that the attribute is rendered. Nothing would fail if this interval were removed. Pausing it while document.hidden would also stop a background tab from polling a board with 24 panels every 10s.

1994 });
19881995 connect();
19891996 // Back online: reconnect from the last id and look at every region once,
19901997 // in case the replay window no longer reaches back that far.
src/web/layout.ts +1 −1
@@ -212,7 +212,7 @@ function userMenu(ctx: Ctx, user: User, signOut: MenuItem): Html {
212212 { label: "Crew", href: "/settings/crew", icon: "bot" as const },
213213 ...(user.kind === "agent"
214214 ? []
215 : [{ label: "Boards", href: "/boards/data", icon: "activity" as const }]),
215 : [{ label: "Boards", href: "/boards", icon: "activity" as const }]),
216216 ]
217217 : []),
218218 { separator: true, label: "" },
src/web/pages/boards-edit.ts added +102
@@ -0,0 +1,102 @@
1 /**
2 * The forms of `/boards`: make a board, pin a query to one, and the changes
3 * `/boards/:slug/:op` takes (rename, settings, add, edit, remove, move,
4 * duplicate, delete). Each one is same-origin checked by `readForm` before
5 * anything is read, answers with a redirect and a flash, and leaves the
6 * work to `actions/boards.ts`.
7 */
8 import {
9 changeBoard, deleteBoard, duplicateBoard, makeBoard, type BoardError,
10 } from "../../actions/boards.ts";
11 import type { Edit } from "../../boards/edit.ts";
12 import { isSlug } from "../../boards/store.ts";
13 import type { Ctx } from "../../http/context.ts";
14 import { notFoundPage } from "../chrome.ts";
15 import { field, readForm, type Form } from "../form.ts";
16 import { flashTo } from "../layout.ts";
17 import { readParams, toQuery } from "./boards.ts";
18
19 const num = (v: string) => (/^-?\d{1,4}$/.test(v) ? Number(v) : NaN);
20
21 function sentence(e: BoardError): string {
22 if (typeof e === "object") return `That was refused: ${e.refused}.`;
23 const words: Partial<Record<string, string>> = {
24 invalid: "That is not a board.",
25 exists: "A board with that name is already there.",
26 no_slug: "A title needs letters or digits.",
27 too_many: "There are too many boards already.",
28 branch_moved: "The boards changed while you were saving; try again.",
29 repo: "The boards repo could not be made.",
30 };
31 return words[e] ?? "That could not be saved.";
32 }
33
34 /** A panel as the form says it: title, size, and the query the shared fields build. */
35 function panelOf(form: Form): Record<string, unknown> {
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")) };
41 }
42
43 function editOf(op: string, form: Form): Edit | undefined {
44 switch (op) {
45 case "rename": return { kind: "title", title: field(form, "title") };
46 case "settings": return { kind: "settings", range: field(form, "range"), refresh: field(form, "refresh") };
47 case "add": return { kind: "add", panel: panelOf(form) };
48 case "edit": return { kind: "edit", index: num(field(form, "index")), panel: panelOf(form) };
49 case "remove": return { kind: "remove", index: num(field(form, "index")) };
50 case "move": {
51 const by = num(field(form, "by"));
52 return by === 1 || by === -1 ? { kind: "move", index: num(field(form, "index")), by } : undefined;
53 }
54 default: return undefined;
55 }
56 }
57
58 const done = (ctx: Ctx, to: string, message: string) => flashTo(ctx, to, { message });
59 const failed = (ctx: Ctx, to: string, e: BoardError) =>
60 e === "not_found" ? notFoundPage(ctx) : flashTo(ctx, to, { tone: "danger", message: sentence(e) });
61
62 // POST /boards/new
63 export async function boardNew(ctx: Ctx): Promise<Response> {
64 const form = await readForm(ctx, "admin");
65 if (form instanceof Response) return form;
66 const made = await makeBoard(ctx.app, ctx.auth.user, field(form, "title"));
67 if (!made.ok) return failed(ctx, "/boards", made.error);
68 return done(ctx, `/boards/${made.value}`, "Board made.");
69 }
70
71 // POST /boards/pin — a query from /boards/data, added to the board `slug`
72 export async function boardPin(ctx: Ctx): Promise<Response> {
73 const form = await readForm(ctx, "admin");
74 if (form instanceof Response) return form;
75 const slug = field(form, "slug");
76 if (!isSlug(slug)) return notFoundPage(ctx);
77 const changed = await changeBoard(ctx.app, ctx.auth.user, slug, { kind: "add", panel: panelOf(form) });
78 if (!changed.ok) return failed(ctx, "/boards/data", changed.error);
79 return done(ctx, `/boards/${slug}`, "Panel added.");
80 }
81
82 // POST /boards/:slug/:op
83 export async function boardPost(ctx: Ctx): Promise<Response> {
84 const form = await readForm(ctx, "admin");
85 if (form instanceof Response) return form;
86 const slug = ctx.params.slug ?? "";
87 const op = ctx.params.op ?? "";
88 if (!isSlug(slug)) return notFoundPage(ctx);
89 const back = `/boards/${slug}`;
90 if (op === "duplicate") {
91 const copy = await duplicateBoard(ctx.app, ctx.auth.user, slug);
92 return copy.ok ? done(ctx, `/boards/${copy.value}`, "Board duplicated.") : failed(ctx, back, copy.error);
93 }
94 if (op === "delete") {
95 const gone = await deleteBoard(ctx.app, ctx.auth.user, slug);
96 return gone.ok ? done(ctx, "/boards", "Board deleted.") : failed(ctx, back, gone.error);
97 }
98 const edit = editOf(op, form);
99 if (!edit) return notFoundPage(ctx);
100 const changed = await changeBoard(ctx.app, ctx.auth.user, slug, edit);
101 return changed.ok ? done(ctx, back, "Saved.") : failed(ctx, back, changed.error);
102 }
src/web/pages/boards-view.ts added +185
@@ -0,0 +1,185 @@
1 /**
2 * Boards, drawn: `/boards` lists them and makes one, `/boards/:slug` is the
3 * grid with each panel's chart and, under it, the panels' management (edit,
4 * move, remove) and the board's own (rename, range, refresh, duplicate,
5 * delete). The grid is a live region with an address of its own
6 * (`/boards/:slug/live`): a board that refreshes is refetched on its beat by
7 * the same swap the wall uses, no client framework. Every form posts to
8 * `boards-edit.ts`. Anyone but a person who is an admin gets the not-found page.
9 */
10 import { listAllBoards, showBoard } from "../../actions/boards.ts";
11 import { lineChart } from "../../boards/chart.ts";
12 import { runQuery } from "../../boards/query.ts";
13 import { MAX_H, MAX_PANELS, MAX_TITLE, MAX_W, RANGES, REFRESHES, type Board, type Panel } from "../../boards/store.ts";
14 import type { App } from "../../app.ts";
15 import type { Ctx } from "../../http/context.ts";
16 import { liveRegionsJs } from "../client.ts";
17 import { notFoundPage } from "../chrome.ts";
18 import { html, raw, when, type Html } from "../html.ts";
19 import { fragment, page } from "../layout.ts";
20 import { btn, card, empty } from "../ui.ts";
21 import { paramsOf, queryFields, readParams } from "./boards.ts";
22
23 const RANGE_MS = { "1h": 3_600_000, "24h": 86_400_000, "7d": 604_800_000, "30d": 2_592_000_000 } as const;
24 const EVERY = { "10s": 10, "1m": 60 } as const;
25
26 const option = (value: string, selected: boolean) =>
27 html`<option value="${value}" ${selected ? raw("selected") : null}>${value}</option>`;
28
29 /** The panel's query as asked, with the board's range as its start when it names none. */
30 function ranged(query: Record<string, unknown>, range: Board["range"]): Record<string, unknown> {
31 if (range === "all" || query.from !== undefined) return query;
32 return { ...query, from: new Date(Date.now() - RANGE_MS[range]).toISOString() };
33 }
34
35 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);
38 const body = !answer.ok
39 ? 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>`;
46 const explore = new URLSearchParams(Object.entries(q).filter(([k, v]) => v !== "" && k !== "rows"));
47 return html`<div class="card bp-w${String(panel.w)} bp-h${String(panel.h)}">
48 <div class="card-head"><h2>${panel.title}</h2>
49 <a class="muted small" href="/boards/data?${explore.toString()}">explore</a></div>
50 <div class="card-body">${body}</div>
51 </div>`;
52 }
53
54 /** The grid alone: what the page shows and what the live address answers with. */
55 function grid(app: App, slug: string, board: Board): Html {
56 const every = board.refresh === "off" ? null : EVERY[board.refresh];
57 return html`<section
58 class="bgrid"
59 id="board-grid"
60 ${every === null
61 ? null
62 : html`data-live="board.none" data-live-src="/boards/${slug}/live" data-live-every="${String(every)}"`}
63 >
64 ${board.panels.length === 0
65 ? html`<p class="muted">No panels yet. Add one below.</p>`
66 : board.panels.map((p) => drawPanel(app, board, p))}
67 </section>`;
68 }
69
70 const denied = (ctx: Ctx) => {

Nit: showBoard already makes the same admin check, so denied repeats the rule in a second place. Drop it, or have actions/boards.ts export the one check.

71 const u = ctx.auth.user;
72 return u?.isAdmin !== true || u.kind === "agent";
73 };
74
75 // GET /boards
76 export async function boardsList(ctx: Ctx): Promise<Response> {
77 const listed = await listAllBoards(ctx.app, ctx.auth.user);
78 if (!listed.ok) return notFoundPage(ctx);
79 const make = html`<form method="post" action="/boards/new" class="row-actions">
80 <input class="input" name="title" required maxlength="${String(MAX_TITLE)}" placeholder="New board's title" aria-label="Title of the new board" />
81 ${btn("Make board", { variant: "primary" })}
82 </form>`;
83 const body =
84 listed.value.length === 0
85 ? empty({ icon: "activity", title: "No boards yet", note: "Make one above; it is saved as a file in forge/boards." })
86 : card(
87 html`<ul class="list">
88 ${listed.value.map(
89 ({ slug, board }) => html`<li class="row">
90 <a href="/boards/${slug}"><strong>${board.title}</strong></a>
91 <span class="muted small">${String(board.panels.length)} panel${board.panels.length === 1 ? "" : "s"} · ${board.range} · refresh ${board.refresh}</span>
92 </li>`,
93 )}
94 </ul>`,
95 { pad: false },
96 );
97 return page({
98 ctx,
99 title: "Boards",
100 body: html`<div class="page-head"><h1>Boards</h1>
101 ${btn("Explore the data", { href: "/boards/data", variant: "ghost" })}</div>
102 ${card(make)} ${body}`,
103 });
104 }
105
106 function panelForm(slug: string, op: string, p: Panel | undefined, index: number, fields: Html): Html {
107 return html`<form method="post" action="/boards/${slug}/${op}" class="row-actions">
108 ${op === "edit" ? html`<input type="hidden" name="index" value="${String(index)}" />` : null}
109 <input class="input" name="title" required maxlength="${String(MAX_TITLE)}" value="${p?.title ?? ""}" placeholder="Panel title" aria-label="Panel title" />
110 <select class="input" name="w" aria-label="Width in columns">
111 ${[1, 2, 3].slice(0, MAX_W).map((n) => html`<option value="${String(n)}" ${n === (p?.w ?? 1) ? raw("selected") : null}>${String(n)} wide</option>`)}
112 </select>
113 <select class="input" name="h" aria-label="Height">
114 ${[1, 2, 3].slice(0, MAX_H).map((n) => html`<option value="${String(n)}" ${n === (p?.h ?? 1) ? raw("selected") : null}>${String(n)} high</option>`)}
115 </select>
116 ${fields}
117 ${btn(op === "edit" ? "Save panel" : "Add panel", { variant: "primary" })}
118 </form>`;
119 }
120
121 function manage(slug: string, board: Board, adding: Html): Html {
122 const one = (p: Panel, i: number) => html`<li class="row bmanage">
123 <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)))}
126 </details>
127 <form method="post" action="/boards/${slug}/move" class="row-actions">
128 <input type="hidden" name="index" value="${String(i)}" />
129 ${btn("Up", { size: "sm", name: "by", value: "-1", label: `Move ${p.title} up` })}
130 ${btn("Down", { size: "sm", name: "by", value: "1", label: `Move ${p.title} down` })}
131 </form>
132 <form method="post" action="/boards/${slug}/remove">
133 <input type="hidden" name="index" value="${String(i)}" />
134 ${btn("Remove", { size: "sm", variant: "danger", label: `Remove ${p.title}` })}
135 </form>
136 </li>`;
137 return html`${card(
138 html`<ul class="list">${board.panels.map(one)}</ul>`,
139 { head: html`<h2>Panels</h2>`, pad: false },
140 )}
141 ${when(
142 board.panels.length < MAX_PANELS,
143 () => card(panelForm(slug, "add", undefined, 0, adding), { head: html`<h2>Add a panel</h2>` }),
144 )}
145 ${card(
146 html`<form method="post" action="/boards/${slug}/rename" class="row-actions">
147 <input class="input" name="title" required maxlength="${String(MAX_TITLE)}" value="${board.title}" aria-label="Board title" />
148 ${btn("Rename")}
149 </form>
150 <form method="post" action="/boards/${slug}/settings" class="row-actions">
151 <select class="input" name="range" aria-label="Range">${RANGES.map((r) => option(r, r === board.range))}</select>
152 <select class="input" name="refresh" aria-label="Refresh">${REFRESHES.map((r) => option(r, r === board.refresh))}</select>
153 ${btn("Save settings")}
154 </form>
155 <form method="post" action="/boards/${slug}/duplicate" class="row-actions">${btn("Duplicate")}</form>
156 <form method="post" action="/boards/${slug}/delete" class="row-actions">${btn("Delete this board", { variant: "danger" })}</form>`,
157 { head: html`<h2>Board</h2>` },
158 )}`;
159 }
160
161 // GET /boards/:slug
162 export async function boardShow(ctx: Ctx): Promise<Response> {
163 const slug = ctx.params.slug ?? "";
164 const shown = await showBoard(ctx.app, ctx.auth.user, slug);
165 if (!shown.ok) return notFoundPage(ctx);
166 const board = shown.value;
167 return page({
168 ctx,
169 title: board.title,
170 body: html`<div class="page-head"><h1>${board.title}</h1>
171 ${btn("All boards", { href: "/boards", variant: "ghost" })}</div>
172 ${grid(ctx.app, slug, board)}
173 ${manage(slug, board, queryFields(readParams(ctx.url.searchParams)))}`,
174 scriptJs: liveRegionsJs,
175 });
176 }
177
178 // GET /boards/:slug/live
179 export async function boardLive(ctx: Ctx): Promise<Response> {
180 if (denied(ctx)) return fragment(html``, { status: 404 });
181 const slug = ctx.params.slug ?? "";
182 const shown = await showBoard(ctx.app, ctx.auth.user, slug);
183 if (!shown.ok) return fragment(html``, { status: 404 });
184 return fragment(grid(ctx.app, slug, shown.value), { request: ctx.request });
185 }
src/web/pages/boards.ts +80 −32
@@ -9,6 +9,7 @@
99 import { lineChart } from "../../boards/chart.ts";
1010 import { runQuery, type Query } from "../../boards/query.ts";
1111 import { SOURCES } from "../../boards/sources.ts";
12 import { listAllBoards } from "../../actions/boards.ts";
1213 import type { Ctx } from "../../http/context.ts";
1314 import { notFoundPage } from "../chrome.ts";
1415 import { html, raw } from "../html.ts";
@@ -24,8 +25,8 @@ type Bucket = keyof typeof BUCKET_MS;
2425 const isBucket = (s: string): s is Bucket => Object.hasOwn(BUCKET_MS, s);
2526
2627 /** 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);
28 export function readParams(search: URLSearchParams) {
29 const get = (k: string, d = "") => (search.get(k) ?? d).slice(0, MAX_PARAM);
2930 return {
3031 source: get("source", "runs"),
3132 aggregate: get("aggregate", "count"),
@@ -41,9 +42,9 @@ function readParams(url: URL) {
4142 rows: get("rows") === "1" ? "1" : "",
4243 };
4344 }
44 type Params = ReturnType<typeof readParams>;
45 export type Params = ReturnType<typeof readParams>;
4546
46 function toQuery(p: Params): Query {
47 export function toQuery(p: Params): Query {
4748 const src = Object.hasOwn(SOURCES, p.source) ? SOURCES[p.source]! : undefined;
4849 const q: Query = { source: p.source, aggregate: p.aggregate };
4950 if (p.column !== "") q.column = p.column;
@@ -92,11 +93,61 @@ function behind(p: Params, group: string | number | null): string | null {
9293 const option = (value: string, label: string, selected: boolean) =>
9394 html`<option value="${value}" ${selected ? raw("selected") : null}>${label}</option>`;
9495
96 /** The inputs that say a query, filled from `p`; the page's form and a panel's share them. */
97 export function queryFields(p: Params) {
98 const src = Object.hasOwn(SOURCES, p.source) ? SOURCES[p.source]! : undefined;
99 const cols = src ? Object.keys(src.columns) : [];
100 return html`
101 <select class="input" name="source" aria-label="Source">
102 ${Object.keys(SOURCES).map((n) => option(n, n, n === p.source))}
103 </select>
104 <select class="input" name="aggregate" aria-label="Aggregate">
105 ${AGGREGATES.map((a) => option(a, a, a === p.aggregate))}
106 </select>
107 <select class="input" name="column" aria-label="Column to aggregate">
108 ${option("", "(rows)", p.column === "")}
109 ${cols.filter((c) => src!.columns[c] === "number").map((c) => option(c, c, c === p.column))}
110 </select>
111 <select class="input" name="by" aria-label="Group by">
112 ${(Object.keys(BUCKET_MS) as Bucket[]).map((b) => option(b, `per ${b}`, b === p.by))}
113 ${cols.map((c) => option(c, `by ${c}`, c === p.by))}
114 </select>
115 <input class="input mono" name="from" value="${p.from}" placeholder="from 2026-10-01" aria-label="From" maxlength="${MAX_PARAM}" />
116 <input class="input mono" name="to" value="${p.to}" placeholder="to 2026-10-08" aria-label="To" maxlength="${MAX_PARAM}" />
117 <select class="input" name="fcolumn" aria-label="Filter column">
118 ${option("", "(no filter)", p.fcolumn === "")}
119 ${cols.map((c) => option(c, c, c === p.fcolumn))}
120 </select>
121 <select class="input" name="fop" aria-label="Filter operator">
122 ${OPS.map((o) => option(o, o, o === p.fop))}
123 </select>
124 <input class="input mono" name="fvalue" value="${p.fvalue}" placeholder="value" aria-label="Filter value" maxlength="${MAX_PARAM}" />
125 `;
126 }
127
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 {
130 const text = (v: unknown) => (typeof v === "string" || typeof v === "number" ? String(v) : "");
131 const g = q.groupBy;
132 const f = Array.isArray(q.filter) ? (q.filter[0] as Record<string, unknown> | undefined) : undefined;
133 const base = readParams(new URLSearchParams());
134 return {
135 ...base,
136 source: text(q.source) || base.source,
137 aggregate: text(q.aggregate) || base.aggregate,
138 column: text(q.column),
139 by: typeof g === "string" ? g : typeof g === "object" && g !== null ? text((g as { bucket?: unknown }).bucket) : base.by,
140 from: text(q.from),
141 to: text(q.to),
142 ...(f ? { fcolumn: text(f.column), fop: text(f.op) || "eq", fvalue: text(f.value) } : {}),
143 };
144 }
145
95146 // GET /boards/data
96 export function boardsPage(ctx: Ctx): Response {
147 export async function boardsPage(ctx: Ctx): Promise<Response> {
97148 const user = ctx.auth.user;
98149 if (user?.isAdmin !== true || user.kind === "agent") return notFoundPage(ctx);
99 const p = readParams(ctx.url);
150 const p = readParams(ctx.url.searchParams);
100151 const src = Object.hasOwn(SOURCES, p.source) ? SOURCES[p.source]! : undefined;
101152 const answer = runQuery(ctx.app.db, toQuery(p), { repoIds: "all" });
102153 const cols = src ? Object.keys(src.columns) : [];
@@ -115,30 +166,7 @@ export function boardsPage(ctx: Ctx): Response {
115166
116167 const form = card(
117168 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}" />
169 ${queryFields(p)}
142170 ${btn("Draw", { variant: "primary" })}
143171 </form>`,
144172 { head: html`<h2>Query</h2>` },
@@ -174,10 +202,30 @@ export function boardsPage(ctx: Ctx): Response {
174202 </table>`;
175203 }
176204
205 const listed = await listAllBoards(ctx.app, user);

Low: every render of /boards/data now reads and parses up to 200 board blobs just to fill a select. Listing the tree and reading only titles, or caching by the branch oid, keeps this page fast.

206 const boards = listed.ok ? listed.value : [];
207 const pin =
208 boards.length === 0 || !answer.ok
209 ? null
210 : card(
211 html`<form method="post" action="/boards/pin" class="row-actions">
212 ${["source", "aggregate", "column", "by", "from", "to", "fcolumn", "fop", "fvalue"].map(
213 (k) => html`<input type="hidden" name="${k}" value="${p[k as keyof Params]}" />`,
214 )}
215 <input type="hidden" name="w" value="1" /><input type="hidden" name="h" value="1" />
216 <select class="input" name="slug" aria-label="Board">
217 ${boards.map((b) => html`<option value="${b.slug}">${b.board.title}</option>`)}
218 </select>
219 <input class="input" name="title" required maxlength="80" value="${`${p.aggregate} of ${p.source}`}" aria-label="Panel title" />
220 ${btn("Pin to board")}
221 </form>`,
222 { head: html`<h2>Pin this query</h2>` },
223 );
224
177225 return page({
178226 ctx,
179227 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}`,
228 body: html`<div class="page-head"><h1>Data</h1>${btn("All boards", { href: "/boards", variant: "ghost" })}</div>
229 ${form} ${card(result, { head: html`<h2>${p.source}</h2>`, extra: "boards-result" })} ${pin} ${sources}`,
182230 });
183231 }
src/web/style/content.ts +9
@@ -378,6 +378,15 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); }
378378 .chart-box text { fill: var(--muted-fg); font-family: var(--mono); font-size: 10px; }
379379 .chart-box circle { fill: var(--accent); }
380380 .boards-result .table { margin-top: 12px; }
381 .bgrid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
382 .bp-w2 { grid-column: span 2; }
383 .bp-w3 { grid-column: span 3; }
384 .bgrid .chart-box { max-width: none; }
385 .bp-h2 .chart-box { aspect-ratio: 720 / 400; }
386 .bp-h3 .chart-box { aspect-ratio: 720 / 540; }
387 .bgrid h2 { font-size: 14px; }
388 .bmanage { display: grid; gap: 8px; }
389 @media (max-width: 720px) { .bgrid { grid-template-columns: minmax(0, 1fr); } .bp-w2, .bp-w3 { grid-column: auto; } }
381390 .fleet-svg { display: block; width: 100%; height: auto; aspect-ratio: 600 / 230; }
382391 .fleet-grid line { stroke: var(--border); stroke-width: 1; }
383392 .fleet-ticks text { fill: var(--muted-fg); font-size: 10px; text-anchor: end; font-family: var(--mono); }
test/boards-page.test.ts +2 −2
@@ -117,8 +117,8 @@ describe("/boards/data", () => {
117117 test("a non-admin and a stranger get 404; the admin's menu links Boards", async () => {
118118 expect((await get("/boards/data", "bob")).status).toBe(404);
119119 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"');
120 expect(await (await get("/", "admin")).text()).toContain('href="/boards"');
121 expect(await (await get("/", "bob")).text()).not.toContain('href="/boards"');
122122 });
123123 });
124124
test/boards-store.test.ts added +238
@@ -0,0 +1,238 @@
1 /**
2 * Boards as files in forge/boards: a board with three panels is saved,
3 * reloaded from the repo and draws the same points; a file that is not a
4 * board is not listed; and /boards makes, renames, duplicates, reorders and
5 * deletes them for an admin who is a person, same-origin only.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { changeBoard, deleteBoard, duplicateBoard, listAllBoards, makeBoard, showBoard } from "../src/actions/boards.ts";
12 import { createApp } from "../src/app.ts";
13 import { applyEdit } from "../src/boards/edit.ts";
14 import { runQuery } from "../src/boards/query.ts";
15 import { listBoards, parseBoard, readBoard, slugOf, saveBoard } from "../src/boards/store.ts";
16 import { createToken } from "../src/core/tokens.ts";
17 import { createUser, type User } from "../src/core/users.ts";
18 import { commitLog } from "../src/git/read.ts";
19 import { commitFiles } from "../src/git/write.ts";
20 import { repoDir } from "../src/git/repos.ts";
21 import { serve } from "../src/http/server.ts";
22
23 let dir: string;
24 let app: ReturnType<typeof createApp>;
25 let servers: Awaited<ReturnType<typeof serve>>;
26 let alice: User;
27 let bob: User;
28 const tok: Record<string, string> = {};
29
30 const panel = (title: string, query: Record<string, unknown>, w = 1, h = 1) => ({ title, chart: "line", query, w, h });
31 const PANELS = [
32 panel("Runs per day", { source: "runs", aggregate: "count", groupBy: { bucket: "day" } }, 2),
33 panel("Runs by kind", { source: "runs", aggregate: "count", groupBy: "kind" }),
34 panel("Turns by state", { source: "runs", aggregate: "sum", column: "turns", groupBy: "state" }),
35 ];
36 const LINE = /<path d="M[^"]*" fill="none" stroke="currentColor" stroke-width="2"/g;
37 const asAdmin = (path: string, init: RequestInit = {}) =>
38 fetch(servers.url + path, { redirect: "manual", ...init, headers: { authorization: `Bearer ${tok.admin}`, ...init.headers } });
39 const post = (path: string, fields: Record<string, string>, who = "admin", headers: Record<string, string> = {}) =>
40 fetch(servers.url + path, {
41 method: "POST", redirect: "manual", body: new URLSearchParams(fields),
42 headers: { authorization: `Bearer ${tok[who]}`, ...headers },
43 });
44
45 beforeAll(async () => {
46 dir = await mkdtemp(join(tmpdir(), "forge-boards-store-"));
47 app = createApp({ dataDir: dir, port: 0 });
48 servers = await serve(app);
49 const mk = async (username: string, adminIfFirst?: boolean) => {
50 const r = await createUser(app.db, {
51 trusted: true, username, email: `${username}@forge.local`,
52 password: "a-good-passphrase", ...(adminIfFirst ? { adminIfFirst } : {}),
53 });
54 if (!r.ok) throw new Error(r.error);
55 return r.value;
56 };
57 alice = await mk("alice", true);
58 bob = await mk("bob");
59 tok.admin = createToken(app.db, alice.id, "t", "admin").token;
60 tok.bob = createToken(app.db, bob.id, "t", "admin").token;
61 app.db.run("INSERT INTO repo (id, owner_id, name) VALUES ('rb', ?, 'n')", [alice.id]);
62 const ins = app.db.prepare("INSERT INTO run (id, repo_id, kind, state, turns, created_at) VALUES (?, 'rb', ?, ?, ?, ?)");
63 ins.run("a", "agent", "ok", 2, new Date(Date.now() - 86_400_000 * 2).toISOString());
64 ins.run("b", "deploy", "failed", 3, new Date(Date.now() - 86_400_000).toISOString());
65 ins.run("c", "check", "ok", 5, new Date(Date.now() - 3_600_000).toISOString());
66 });
67
68 afterAll(async () => {
69 servers.stop();
70 app.close();
71 await rm(dir, { recursive: true, force: true });
72 });
73
74 describe("the store", () => {
75 test("a board of three panels is saved, reloaded from the repo and finds the same points", async () => {
76 const made = await makeBoard(app, alice, "Fleet health");
77 expect(made).toEqual({ ok: true, value: "fleet-health" });
78 for (const p of PANELS) {
79 const added = await changeBoard(app, alice, "fleet-health", { kind: "add", panel: p });
80 expect(added.ok).toBe(true);
81 }
82 const before = PANELS.map((p) => runQuery(app.db, p.query, { repoIds: "all" }));
83 const loaded = await readBoard(app.reposRoot, "fleet-health");
84 expect(loaded?.panels.length).toBe(3);
85 const after = loaded!.panels.map((p) => runQuery(app.db, p.query, { repoIds: "all" }));
86 expect(after).toEqual(before);
87 expect(after.every((a) => a.ok && a.rows.length > 0)).toBe(true);
88 expect(loaded).toEqual({ title: "Fleet health", range: "7d", refresh: "off", panels: PANELS } as typeof loaded);
89 });
90
91 test("each save is a commit by the admin who made it, in forge/boards", async () => {
92 const log = await commitLog(repoDir(app.reposRoot, "forge", "boards"), "main", { limit: 10 });
93 expect(log.ok).toBe(true);
94 if (!log.ok) return;
95 expect(log.value.length).toBe(4);
96 expect(log.value.every((c) => c.author === "alice")).toBe(true);
97 });
98
99 test("a file that is not a board is not listed, and the store refuses to write one", async () => {
100 const bad = await commitFiles(repoDir(app.reposRoot, "forge", "boards"), {
101 branch: "main", message: "pushed by hand", author: { name: "x", email: "[email protected]" },
102 files: [{ path: "junk.json", content: '{"title":"x","range":"forever","refresh":"off","panels":[]}' }, { path: "bad.json", content: "{" }],
103 });
104 expect(bad.ok).toBe(true);
105 expect((await listBoards(app.reposRoot)).map((b) => b.slug)).toEqual(["fleet-health"]);
106 const author = { name: "alice", email: "[email protected]" };
107 const board = (await readBoard(app.reposRoot, "fleet-health"))!;
108 expect(await saveBoard(app.reposRoot, "x", { ...board, refresh: "5s" }, author, "m", "create")).toEqual({ ok: false, error: "invalid" });
109 expect(await saveBoard(app.reposRoot, "../x", board, author, "m", "create")).toEqual({ ok: false, error: "invalid" });
110 expect(await saveBoard(app.reposRoot, "fleet-health", board, author, "m", "create")).toEqual({ ok: false, error: "exists" });
111 expect(await saveBoard(app.reposRoot, "nope", board, author, "m")).toEqual({ ok: false, error: "not_found" });
112 });
113
114 test("parseBoard judges every field", () => {
115 const ok = { title: "t", range: "1h", refresh: "10s", panels: [PANELS[0]] };
116 expect(parseBoard(ok).ok).toBe(true);
117 for (const bad of [
118 null, [], { ...ok, title: "" }, { ...ok, title: "x".repeat(81) }, { ...ok, range: "2d" }, { ...ok, refresh: "1s" },
119 { ...ok, panels: "no" }, { ...ok, panels: Array(25).fill(PANELS[0]) },
120 { ...ok, panels: [{ ...PANELS[0], w: 4 }] }, { ...ok, panels: [{ ...PANELS[0], h: 1.5 }] },
121 { ...ok, panels: [{ ...PANELS[0], chart: "pie" }] },
122 { ...ok, panels: [{ ...PANELS[0], query: { source: "users; drop" } }] },
123 { ...ok, panels: [{ ...PANELS[0], query: [] }] },
124 ]) expect(parseBoard(bad).ok).toBe(false);
125 });
126
127 test("slugs never name the data page or escape the repo", () => {
128 expect(slugOf("Fleet Health!")).toBe("fleet-health");
129 expect(slugOf("data")).toBe("data-board");
130 expect(slugOf("../../etc")).toBe("etc");
131 expect(slugOf("***")).toBe("");
132 });
133
134 test("edits: reorder, remove and a bad index", () => {
135 const b = { title: "t", range: "7d" as const, refresh: "off" as const, panels: PANELS.map((p) => parseBoard({ title: "t", range: "7d", refresh: "off", panels: [p] }).ok ? p : p) };
136 const moved = applyEdit(b as never, { kind: "move", index: 0, by: 1 });
137 expect(moved.ok && moved.value.panels.map((p) => p.title)).toEqual(["Runs by kind", "Runs per day", "Turns by state"]);
138 const first = applyEdit(b as never, { kind: "move", index: 0, by: -1 });
139 expect(first.ok && first.value.panels[0]!.title).toBe("Runs per day");
140 const gone = applyEdit(b as never, { kind: "remove", index: 1 });
141 expect(gone.ok && gone.value.panels.length).toBe(2);
142 expect(applyEdit(b as never, { kind: "remove", index: 9 }).ok).toBe(false);
143 });
144
145 test("a panel whose query cannot run is refused at save; non-admins and agents are not_found", async () => {
146 const bad = await changeBoard(app, alice, "fleet-health", { kind: "add", panel: panel("x", { source: "runs", aggregate: "median" }) });
147 expect(bad.ok).toBe(false);
148 expect((await changeBoard(app, bob, "fleet-health", { kind: "title", title: "mine" }))).toEqual({ ok: false, error: "not_found" });
149 expect(await makeBoard(app, bob, "x")).toEqual({ ok: false, error: "not_found" });
150 expect(await listAllBoards(app, undefined)).toEqual({ ok: false, error: "not_found" });
151 expect(await showBoard(app, bob, "fleet-health")).toEqual({ ok: false, error: "not_found" });
152 });
153 });
154
155 describe("/boards", () => {
156 test("the list links a board; the board draws one chart per panel", async () => {
157 const list = await (await asAdmin("/boards")).text();
158 expect(list).toContain('href="/boards/fleet-health"');
159 expect(list).toContain("Fleet health");
160 const res = await asAdmin("/boards/fleet-health");
161 expect(res.status).toBe(200);
162 const body = await res.text();
163 expect(body.match(LINE)?.length).toBe(3);
164 expect(body).toContain("bp-w2");
165 expect(body).not.toContain('data-live-src="/boards');
166 });
167
168 test("settings turn on refresh, and the grid answers alone with an etag", async () => {
169 const saved = await post("/boards/fleet-health/settings", { range: "24h", refresh: "10s" });
170 expect(saved.status).toBe(303);
171 const body = await (await asAdmin("/boards/fleet-health")).text();
172 expect(body).toContain('data-live-every="10"');
173 expect(body).toContain('data-live-src="/boards/fleet-health/live"');
174 const live = await asAdmin("/boards/fleet-health/live");
175 expect(live.status).toBe(200);
176 const text = await live.text();
177 expect(text.startsWith("<section")).toBe(true);
178 expect(text.match(LINE)?.length).toBe(3);
179 const again = await asAdmin("/boards/fleet-health/live", { headers: { "if-none-match": live.headers.get("etag")! } });
180 expect(again.status).toBe(304);
181 expect((await post("/boards/fleet-health/settings", { range: "weeks", refresh: "10s" })).headers.get("set-cookie")).toContain("danger");
182 });
183
184 test("make, rename, move, add, edit, remove, duplicate and delete through the forms", async () => {
185 const made = await post("/boards/new", { title: "Scratch <b>" });
186 expect(made.headers.get("location")).toBe("/boards/scratch-b");
187 expect((await post("/boards/scratch-b/rename", { title: "Scratch two" })).status).toBe(303);
188 const q = { source: "runs", aggregate: "count", column: "", by: "day", from: "", to: "", fcolumn: "state", fop: "eq", fvalue: "ok" };
189 expect((await post("/boards/scratch-b/add", { ...q, title: "ok runs", w: "3", h: "2" })).status).toBe(303);
190 expect((await post("/boards/scratch-b/add", { ...q, title: "second", w: "1", h: "1", fvalue: "failed" })).status).toBe(303);
191 await post("/boards/scratch-b/move", { index: "1", by: "-1" });
192 let board = (await readBoard(app.reposRoot, "scratch-b"))!;
193 expect(board.title).toBe("Scratch two");
194 expect(board.panels.map((p) => p.title)).toEqual(["second", "ok runs"]);
195 expect(board.panels[1]).toMatchObject({ w: 3, h: 2, query: { filter: [{ column: "state", op: "eq", value: "ok" }] } });
196 await post("/boards/scratch-b/edit", { ...q, index: "0", title: "renamed", w: "2", h: "1" });
197 await post("/boards/scratch-b/remove", { index: "1" });
198 board = (await readBoard(app.reposRoot, "scratch-b"))!;
199 expect(board.panels.map((p) => p.title)).toEqual(["renamed"]);
200 const copy = await post("/boards/scratch-b/duplicate", {});
201 expect(copy.headers.get("location")).toBe("/boards/scratch-b-copy");
202 expect((await readBoard(app.reposRoot, "scratch-b-copy"))?.title).toBe("Scratch two (copy)");
203 const page = await (await asAdmin("/boards/scratch-b")).text();
204 expect(page).not.toContain("<b>");
205 expect((await post("/boards/scratch-b/delete", {})).headers.get("location")).toBe("/boards");
206 await deleteBoard(app, alice, "scratch-b-copy");
207 expect(await readBoard(app.reposRoot, "scratch-b")).toBeUndefined();
208 expect((await asAdmin("/boards/scratch-b")).status).toBe(404);
209 });
210
211 test("a pinned query lands on the board", async () => {
212 const data = await (await asAdmin("/boards/data?source=runs&by=kind")).text();
213 expect(data).toContain('action="/boards/pin"');
214 const pinned = await post("/boards/pin", { slug: "fleet-health", title: "pinned", source: "runs", aggregate: "count", by: "kind", fop: "eq", w: "1", h: "1" });
215 expect(pinned.headers.get("location")).toBe("/boards/fleet-health");
216 expect((await readBoard(app.reposRoot, "fleet-health"))!.panels.at(-1)!.title).toBe("pinned");
217 });
218
219 test("a cross-origin post changes nothing, and duplicates are refused as unnamed", async () => {
220 const before = await readBoard(app.reposRoot, "fleet-health");
221 for (const path of ["/boards/fleet-health/rename", "/boards/fleet-health/delete", "/boards/new", "/boards/pin"]) {
222 const res = await post(path, { title: "evil", slug: "fleet-health" }, "admin", { origin: "https://evil.example" });
223 expect(res.status).toBe(403);
224 }
225 expect(await readBoard(app.reposRoot, "fleet-health")).toEqual(before);
226 expect((await duplicateBoard(app, alice, "nope")).ok).toBe(false);
227 });
228
229 test("a non-admin and a stranger get 404 on every board route", async () => {
230 for (const path of ["/boards", "/boards/fleet-health", "/boards/fleet-health/live"]) {
231 expect((await fetch(servers.url + path, { headers: { authorization: `Bearer ${tok.bob}` } })).status).toBe(404);
232 expect((await fetch(servers.url + path)).status).toBe(404);
233 }
234 expect((await post("/boards/fleet-health/delete", {}, "bob")).status).toBe(404);
235 expect((await post("/boards/new", { title: "x" }, "bob")).status).toBe(404);
236 expect(await readBoard(app.reposRoot, "fleet-health")).toBeDefined();
237 });
238 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs