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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1575-boards-saved-boards-as-files-in-forge-bo → main 2 commits fork 4 threads · 4 unresolved
README.md +2 −1
| @@ -765,7 +765,8 @@ work here. | ||
| 765 | 765 | by the admin who made it, and a file that does not validate is not shown. Make, rename, |
| 766 | 766 | duplicate, delete, add/edit/remove/reorder panels, or pin the query on `/boards/data`. |
| 767 | 767 | 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. | |
| 769 | 770 | - **Issues** — labels, threaded comments, close/reopen, markdown. |
| 770 | 771 | - **Pull requests** — open from a branch, a **beautiful diff viewer** (syntax |
| 771 | 772 | 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"; | ||
| 11 | 11 | import { applyEdit, type Edit } from "../boards/edit.ts"; |
| 12 | 12 | import { runQuery } from "../boards/query.ts"; |
| 13 | 13 | 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, | |
| 15 | 15 | type Board, type Stored, type StoreError, |
| 16 | 16 | } from "../boards/store.ts"; |
| 17 | 17 | import { commitIdentity } from "../core/commit_identity.ts"; |
| @@ -61,11 +61,12 @@ export async function showBoard(app: App, actor: User | undefined, slug: string) | ||
| 61 | 61 | async function save( |
| 62 | 62 | app: App, who: User, slug: string, board: unknown, message: string, mode: "create" | "update", |
| 63 | 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 ?? []) { | |
| 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) { | |
| 67 | 68 | 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}` }); | |
| 69 | 70 | } |
| 70 | 71 | const done = await saveBoard(app.reposRoot, slug, board, commitIdentity(app.config, who), message, mode); |
| 71 | 72 | if (!done.ok) return done; |
| @@ -90,6 +91,18 @@ export async function makeBoard(app: App, actor: User | undefined, title: string | ||
| 90 | 91 | return err("exists"); |
| 91 | 92 | } |
| 92 | 93 | |
| 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; | |
| 103 | return save(app, who.value, slug, board, `${there ? "Write" : "Make"} board ${slug}`, there ? "update" : "create"); | |
| 104 | } | |
| 105 | ||
| 93 | 106 | /** Apply one edit to a board and commit it. */ |
| 94 | 107 | export async function changeBoard( |
| 95 | 108 | 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 => | ||
| 48 | 48 | const span = (v: unknown, max: number): v is number => |
| 49 | 49 | typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= max; |
| 50 | 50 | |
| 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`. */ | |
| 52 | 55 | 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); | |
| 54 | 57 | |
| 55 | 58 | /** A board's slug from its title, or "" when the title has no letters or digits. */ |
| 56 | 59 | export function slugOf(t: string): string { |
| 57 | 60 | 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` : ""; | |
| 59 | 62 | } |
| 60 | 63 | |
| 61 | 64 | /** A panel, judged: the plain value or the reason it is not one. */ |
src/http/api.ts +45
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | import { voteVariant } from "../actions/variants.ts"; |
| 12 | 12 | import { parentForgeFor } from "../core/parent_releases.ts"; |
| 13 | 13 | import { readFlow } from "../flow/read.ts"; |
| 14 | import { deleteBoard, listAllBoards, showBoard, writeBoard, type BoardError } from "../actions/boards.ts"; | |
| 14 | 15 | import { runQuery } from "../boards/query.ts"; |
| 15 | 16 | import { SOURCES } from "../boards/sources.ts"; |
| 16 | 17 | import { bodyCapFor, readBounded } from "./limits.ts"; |
| @@ -1298,6 +1299,50 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1298 | 1299 | : apiError("bad_query", 400, answer.error); |
| 1299 | 1300 | }); |
| 1300 | 1301 | |
| 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 |
||
| 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 |
||
| 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 |
||
| 1344 | }); | |
| 1345 | ||
| 1301 | 1346 | // The forge's capacity (`core/capacity.ts`): read and changed live by an |
| 1302 | 1347 | // admin, never by an agent; anyone else is told the door is not there. |
| 1303 | 1348 | router.add("GET", "/api/v1/admin/capacity", (ctx) => { |
test/boards-store.test.ts +69
| @@ -236,3 +236,72 @@ describe("/boards", () => { | ||
| 236 | 236 | expect(await readBoard(app.reposRoot, "fleet-health")).toBeDefined(); |
| 237 | 237 | }); |
| 238 | 238 | }); |
| 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 | }); |
High:
boardcomes straight from the request body.save()loopsfor (const p of parsed.panels ?? [])and readsp.titlebeforeparseBoardruns, sopanels: {}or[null]throws (500), and an oversized panels array runs every query before the 24-panel cap is checked. Validate withparseBoardfirst, then run queries over the parsed panels.