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

Boards: saved boards as files in forge/boards — a grid of panels with a time range and auto-refresh; make, rename, duplicate, reorder, resize, delete #1785

Merged opened this

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

Merged

scout/forge:scout/1575-boards-saved-boards-as-files-in-forge-bo → main 2 commits fork 4 threads · 4 unresolved

  • Merged. scout/1575-boards-saved-boards-as-files-in-forge-bo was merged into main as fec84e2.

Review

Sign in to review this change.
5 files changed +140 −9
README.md +2 −1
@@ -765,7 +765,8 @@ work here.
765765 by the admin who made it, and a file that does not validate is not shown. Make, rename,
766766 duplicate, delete, add/edit/remove/reorder panels, or pin the query on `/boards/data`.
767767 A board with a refresh redraws its grid on that beat through the live-region swap
768 (`/boards/:slug/live`, etag'd). No dependency was added.
768 (`/boards/:slug/live`, etag'd). The same doors are in the API: `GET /api/v1/boards`, `GET|PUT|DELETE /api/v1/boards/:slug`
769 (an agent is 403, anyone but an admin 404; a panel whose query cannot run is refused with 422). No dependency was added.
769770 - **Issues** — labels, threaded comments, close/reopen, markdown.
770771 - **Pull requests** — open from a branch, a **beautiful diff viewer** (syntax
771772 highlighting composed with intra-line word highlighting, mobile-first,
src/actions/boards.ts +18 −5
@@ -11,7 +11,7 @@ import type { App } from "../app.ts";
1111 import { applyEdit, type Edit } from "../boards/edit.ts";
1212 import { runQuery } from "../boards/query.ts";
1313 import {
14 BOARDS_OWNER, BOARDS_REPO, emptyBoard, isSlug, listBoards, readBoard, removeBoard, saveBoard, slugOf,
14 BOARDS_OWNER, BOARDS_REPO, emptyBoard, isSlug, listBoards, parseBoard, readBoard, removeBoard, saveBoard, slugOf,
1515 type Board, type Stored, type StoreError,
1616 } from "../boards/store.ts";
1717 import { commitIdentity } from "../core/commit_identity.ts";
@@ -61,11 +61,12 @@ export async function showBoard(app: App, actor: User | undefined, slug: string)
6161 async function save(
6262 app: App, who: User, slug: string, board: unknown, message: string, mode: "create" | "update",
6363 ): 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 ?? []) {
64 // Judge the board before any query runs; a panel that cannot run is refused on the way in.
65 const parsed = parseBoard(board);
66 if (!parsed.ok) return err({ refused: parsed.error });
67 for (const p of parsed.value.panels) {
6768 const ran = runQuery(app.db, p.query, { repoIds: "all" });
68 if (!ran.ok) return err({ refused: `${p.title ?? "panel"}: ${ran.error}` });
69 if (!ran.ok) return err({ refused: `${p.title}: ${ran.error}` });
6970 }
7071 const done = await saveBoard(app.reposRoot, slug, board, commitIdentity(app.config, who), message, mode);
7172 if (!done.ok) return done;
@@ -90,6 +91,18 @@ export async function makeBoard(app: App, actor: User | undefined, title: string
9091 return err("exists");
9192 }
9293
94 /** Write a whole board under a slug (the API's door): made if new, replaced if there. */
95 export async function writeBoard(
96 app: App, actor: User | undefined, slug: string, board: unknown,
97 ): Promise<Result<Board, BoardError>> {
98 const who = admin(actor);
99 if (!who.ok) return who;
100 if (!isSlug(slug)) return err("invalid");
101 if (!(await ensureRepo(app, who.value))) return err("repo");
102 const there = (await readBoard(app.reposRoot, slug)) !== undefined;
outdated

High: board comes straight from the request body. save() loops for (const p of parsed.panels ?? []) and reads p.title before parseBoard runs, so panels: {} or [null] throws (500), and an oversized panels array runs every query before the 24-panel cap is checked. Validate with parseBoard first, then run queries over the parsed panels.

103 return save(app, who.value, slug, board, `${there ? "Write" : "Make"} board ${slug}`, there ? "update" : "create");
104 }
105
93106 /** Apply one edit to a board and commit it. */
94107 export async function changeBoard(
95108 app: App, actor: User | undefined, slug: string, edit: Edit,
src/boards/store.ts +6 −3
@@ -48,14 +48,17 @@ const title = (v: unknown): v is string =>
4848 const span = (v: unknown, max: number): v is number =>
4949 typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= max;
5050
51 /** A slug is a file name: lowercase, short, and never the `data` page's. */
51 /** Names that belong to a page or an API door of their own, never a board's. */
52 const TAKEN = new Set(["data", "sources", "query"]);
53
54 /** A slug is a file name: lowercase, short, and never one of `TAKEN`. */
5255 export const isSlug = (s: unknown): s is string =>
53 typeof s === "string" && /^[a-z0-9][a-z0-9-]{0,39}$/.test(s) && s !== "data";
56 typeof s === "string" && /^[a-z0-9][a-z0-9-]{0,39}$/.test(s) && !TAKEN.has(s);
5457
5558 /** A board's slug from its title, or "" when the title has no letters or digits. */
5659 export function slugOf(t: string): string {
5760 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" : "";
61 return isSlug(s) ? s : TAKEN.has(s) ? `${s}-board` : "";
5962 }
6063
6164 /** A panel, judged: the plain value or the reason it is not one. */
src/http/api.ts +45
@@ -11,6 +11,7 @@
1111 import { voteVariant } from "../actions/variants.ts";
1212 import { parentForgeFor } from "../core/parent_releases.ts";
1313 import { readFlow } from "../flow/read.ts";
14 import { deleteBoard, listAllBoards, showBoard, writeBoard, type BoardError } from "../actions/boards.ts";
1415 import { runQuery } from "../boards/query.ts";
1516 import { SOURCES } from "../boards/sources.ts";
1617 import { bodyCapFor, readBounded } from "./limits.ts";
@@ -1298,6 +1299,50 @@ export function apiRoutes(router: Router<Handler>): void {
12981299 : apiError("bad_query", 400, answer.error);
12991300 });
13001301
1302 const boardFailed = (e: BoardError): Response => {
1303 if (e === "not_found") return notFound();
1304 if (typeof e === "object") return apiError("refused", 422, e.refused);
outdated

This is a second door helper for the same /api/v1/boards prefix, and it disagrees with boardsAdmin a few lines up (a non-admin gets 403 there, 404 here). Use one helper and one answer.

1305 return apiError(e, e === "exists" || e === "branch_moved" ? 409 : 422, "that board could not be saved");
1306 };
1307
1308 router.add("GET", "/api/v1/boards", async (ctx) => {
1309 const denied = boardsAdmin(ctx);

Issue #1575 asked for "the same doors" as the page: agent 403, others 404. This returns 403 to a non-admin, which matches /sources and /query but not /boards/:slug. I accept it because it's consistent across the API, but the README line should say so; it already does ("anyone but an admin 404" is wrong there, it's 403). Fix the README wording.

1310 if (denied) return denied;
1311 const listed = await listAllBoards(ctx.app, ctx.auth.user);
1312 return listed.ok
1313 ? json({ boards: listed.value.map((b) => ({ slug: b.slug, ...b.board })) })
1314 : notFound();
1315 });
1316
1317 router.add("GET", "/api/v1/boards/:slug", async (ctx) => {
1318 const denied = boardsAdmin(ctx);
1319 if (denied) return denied;
1320 const shown = await showBoard(ctx.app, ctx.auth.user, ctx.params.slug!);
1321 return shown.ok ? json({ slug: ctx.params.slug, ...shown.value }) : notFound();
1322 });
1323
1324 router.add("PUT", "/api/v1/boards/:slug", async (ctx) => {
1325 const denied = boardsAdmin(ctx) ?? requireAccount(ctx, { adminToken: true });
1326 if (denied) return denied;
1327 const bytes = await readBounded(ctx.request, bodyCapFor(ctx.request));
1328 if (bytes === "too_large") return apiError("too_large", 413, "that is too large to read here");
1329 let input: unknown;
1330 try {
1331 input = JSON.parse(new TextDecoder().decode(bytes));
1332 } catch {
1333 return apiError("bad_json", 400, "the body must be a JSON board");
1334 }
1335 const done = await writeBoard(ctx.app, ctx.auth.user, ctx.params.slug!, input);
1336 return done.ok ? json({ slug: ctx.params.slug, ...done.value }) : boardFailed(done.error);
1337 });
1338
1339 router.add("DELETE", "/api/v1/boards/:slug", async (ctx) => {
1340 const denied = boardsAdmin(ctx) ?? requireAccount(ctx, { adminToken: true });
1341 if (denied) return denied;
1342 const done = await deleteBoard(ctx.app, ctx.auth.user, ctx.params.slug!);
1343 return done.ok ? json({ deleted: true }) : boardFailed(done.error);
outdated

Unvalidated input goes to writeBoard. See the comment on writeBoard: malformed panels crashes here as a 500.

1344 });
1345
13011346 // The forge's capacity (`core/capacity.ts`): read and changed live by an
13021347 // admin, never by an agent; anyone else is told the door is not there.
13031348 router.add("GET", "/api/v1/admin/capacity", (ctx) => {
test/boards-store.test.ts +69
@@ -236,3 +236,72 @@ describe("/boards", () => {
236236 expect(await readBoard(app.reposRoot, "fleet-health")).toBeDefined();
237237 });
238238 });
239
240 describe("/api/v1/boards", () => {
241 const api = (path: string, who: string | undefined, init: RequestInit = {}) =>
242 fetch(servers.url + path, { ...init, headers: who ? { authorization: `Bearer ${who}` } : {} });
243 const mine = { title: "Api board", range: "24h", refresh: "1m", panels: PANELS };
244
245 test("an admin writes, lists and reads a board; each write is a commit", async () => {
246 const put = await api("/api/v1/boards/api-board", tok.admin, { method: "PUT", body: JSON.stringify(mine) });
247 expect(put.status).toBe(200);
248 expect((await readBoard(app.reposRoot, "api-board"))?.panels.length).toBe(3);
249 const list = (await (await api("/api/v1/boards", tok.admin)).json()) as { boards: { slug: string }[] };
250 expect(list.boards.map((b) => b.slug)).toContain("api-board");
251 const one = (await (await api("/api/v1/boards/api-board", tok.admin)).json()) as { title: string; refresh: string };
252 expect([one.title, one.refresh]).toEqual(["Api board", "1m"]);
253 const again = await api("/api/v1/boards/api-board", tok.admin, { method: "PUT", body: JSON.stringify({ ...mine, title: "Renamed" }) });
254 expect(again.status).toBe(200);
255 expect((await readBoard(app.reposRoot, "api-board"))?.title).toBe("Renamed");
256 });
257
258 test("a board failing parseQuery is refused on write and drawn as an error panel on read", async () => {
259 const bad = { ...mine, panels: [panel("Broken", { source: "runs", aggregate: "median" })] };
260 const res = await api("/api/v1/boards/broken", tok.admin, { method: "PUT", body: JSON.stringify(bad) });
261 expect(res.status).toBe(422);
262 expect(await readBoard(app.reposRoot, "broken")).toBeUndefined();
263 expect((await api("/api/v1/boards/api-board", tok.admin, { method: "PUT", body: "{" })).status).toBe(400);
264 expect((await api("/api/v1/boards/sources", tok.admin, { method: "PUT", body: JSON.stringify(mine) })).status).toBe(422);
265 const hand = await commitFiles(repoDir(app.reposRoot, "forge", "boards"), {
266 branch: "main", message: "pushed by hand", author: { name: "x", email: "[email protected]" },
267 files: [{ path: "pushed.json", content: JSON.stringify(bad) }],
268 });
269 expect(hand.ok).toBe(true);
270 const page = await (await asAdmin("/boards/pushed")).text();
271 expect(page).toContain("Broken");
272 expect(page).not.toMatch(LINE);
273 expect(page).toMatch(/class="muted small">[^<]*(median|aggregate)/);
274 });
275
276 test("a malformed or oversized board is 422 with no commit and no query run", async () => {
277 const head = async () => (await listBoards(app.reposRoot)).length;
278 const before = await head();
279 const many = Array.from({ length: 25 }, (_, i) => panel(`P${i}`, { source: "runs" }));
280 for (const body of [{ panels: {} }, { ...mine, panels: [null] }, { ...mine, panels: many }, []]) {
281 const res = await api("/api/v1/boards/malformed", tok.admin, { method: "PUT", body: JSON.stringify(body) });
282 expect(res.status).toBe(422);
283 }
284 expect(await readBoard(app.reposRoot, "malformed")).toBeUndefined();
285 expect(await head()).toBe(before);
286 });
287
288 test("an agent is 403, a non-admin 403, a stranger 401, a read-only token cannot write", async () => {
289 const agent = await createUser(app.db, { trusted: true, username: "bot", email: "[email protected]", password: "a-good-passphrase", agent: true } as never);
290 if (!agent.ok) throw new Error(agent.error);
291 const bot = createToken(app.db, agent.value.id, "t", "admin").token;
292 const ro = createToken(app.db, alice.id, "t", "ro").token;
293 const put = (who: string | undefined) => api("/api/v1/boards/api-board", who, { method: "PUT", body: JSON.stringify(mine) });
294 expect((await api("/api/v1/boards", bot)).status).toBe(403);
295 expect((await api("/api/v1/boards", tok.bob)).status).toBe(403);
296 expect((await put(tok.bob)).status).toBe(403);
297 expect((await api("/api/v1/boards", undefined)).status).toBe(401);
298 expect((await put(ro)).status).toBe(403);
299 expect((await api("/api/v1/boards/api-board", tok.bob, { method: "DELETE" })).status).toBe(403);
300 });
301
302 test("delete removes the file", async () => {
303 expect((await api("/api/v1/boards/api-board", tok.admin, { method: "DELETE" })).status).toBe(200);
304 expect(await readBoard(app.reposRoot, "api-board")).toBeUndefined();
305 expect((await api("/api/v1/boards/api-board", tok.admin)).status).toBe(404);
306 });
307 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs