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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1561-boards-step-3-a-board-is-a-json-file-in → main 1 commit fork 4 threads · 4 unresolved
README.md +7
| @@ -725,6 +725,13 @@ work here. | ||
| 725 | 725 | gets a 404): the query is the URL (`?source=runs&aggregate=count&by=day&fcolumn=state&fvalue=failed`), |
| 726 | 726 | drawn as a server-rendered line chart with its points in a table; a point opens the rows |
| 727 | 727 | 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. | |
| 728 | 735 | - **Issues** — labels, threaded comments, close/reopen, markdown. |
| 729 | 736 | - **Pull requests** — open from a branch, a **beautiful diff viewer** (syntax |
| 730 | 737 | highlighting composed with intra-line word highlighting, mobile-first, |
scripts/gate.ts +1
| @@ -35,6 +35,7 @@ export const GATE: readonly string[] = [ | ||
| 35 | 35 | "test/body-limits.test.ts", |
| 36 | 36 | "test/boards-query.test.ts", |
| 37 | 37 | "test/boards-page.test.ts", |
| 38 | "test/boards-store.test.ts", | |
| 38 | 39 | "test/turnstile.test.ts", |
| 39 | 40 | "test/variants-deck.test.ts", |
| 40 | 41 | "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); | |
| 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"; | ||
| 11 | 11 | import { flowPage } from "../web/pages/flow.ts"; |
| 12 | 12 | import { limitsPage, limitsSet } from "../web/pages/admin-limits.ts"; |
| 13 | 13 | 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"; | |
| 14 | 16 | import { |
| 15 | 17 | closeStreamsOf, |
| 16 | 18 | closeStreamsOfSession, |
| @@ -698,7 +700,13 @@ export function buildRouter(): Router<Handler> { | ||
| 698 | 700 | router.add("POST", "/settings/cloud/remove", cloudRemove); |
| 699 | 701 | router.add("GET", "/settings/capacity", capacityPage); |
| 700 | 702 | router.add("POST", "/settings/capacity", capacitySet); |
| 703 | router.add("GET", "/boards", boardsList); | |
| 701 | 704 | 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); | |
| 702 | 710 | router.add("GET", "/admin/limits", limitsPage); |
| 703 | 711 | router.add("POST", "/admin/limits", limitsSet); |
| 704 | 712 | router.add("GET", "/settings/backups", backupsPage); |
src/web/client.ts +7
| @@ -1985,6 +1985,13 @@ export const liveRegionsJs = /* js */ ` | ||
| 1985 | 1985 | if (hit && timer === null) |
| 1986 | 1986 | timer = setTimeout(refresh, Math.max(600, last + MIN_GAP - Date.now())); |
| 1987 | 1987 | } |
| 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 |
||
| 1994 | }); | |
| 1988 | 1995 | connect(); |
| 1989 | 1996 | // Back online: reconnect from the last id and look at every region once, |
| 1990 | 1997 | // 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 { | ||
| 212 | 212 | { label: "Crew", href: "/settings/crew", icon: "bot" as const }, |
| 213 | 213 | ...(user.kind === "agent" |
| 214 | 214 | ? [] |
| 215 | : [{ label: "Boards", href: "/boards/data", icon: "activity" as const }]), | |
| 215 | : [{ label: "Boards", href: "/boards", icon: "activity" as const }]), | |
| 216 | 216 | ] |
| 217 | 217 | : []), |
| 218 | 218 | { 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: |
||
| 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 @@ | ||
| 9 | 9 | import { lineChart } from "../../boards/chart.ts"; |
| 10 | 10 | import { runQuery, type Query } from "../../boards/query.ts"; |
| 11 | 11 | import { SOURCES } from "../../boards/sources.ts"; |
| 12 | import { listAllBoards } from "../../actions/boards.ts"; | |
| 12 | 13 | import type { Ctx } from "../../http/context.ts"; |
| 13 | 14 | import { notFoundPage } from "../chrome.ts"; |
| 14 | 15 | import { html, raw } from "../html.ts"; |
| @@ -24,8 +25,8 @@ type Bucket = keyof typeof BUCKET_MS; | ||
| 24 | 25 | const isBucket = (s: string): s is Bucket => Object.hasOwn(BUCKET_MS, s); |
| 25 | 26 | |
| 26 | 27 | /** 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); | |
| 29 | 30 | return { |
| 30 | 31 | source: get("source", "runs"), |
| 31 | 32 | aggregate: get("aggregate", "count"), |
| @@ -41,9 +42,9 @@ function readParams(url: URL) { | ||
| 41 | 42 | rows: get("rows") === "1" ? "1" : "", |
| 42 | 43 | }; |
| 43 | 44 | } |
| 44 | type Params = ReturnType<typeof readParams>; | |
| 45 | export type Params = ReturnType<typeof readParams>; | |
| 45 | 46 | |
| 46 | function toQuery(p: Params): Query { | |
| 47 | export function toQuery(p: Params): Query { | |
| 47 | 48 | const src = Object.hasOwn(SOURCES, p.source) ? SOURCES[p.source]! : undefined; |
| 48 | 49 | const q: Query = { source: p.source, aggregate: p.aggregate }; |
| 49 | 50 | if (p.column !== "") q.column = p.column; |
| @@ -92,11 +93,61 @@ function behind(p: Params, group: string | number | null): string | null { | ||
| 92 | 93 | const option = (value: string, label: string, selected: boolean) => |
| 93 | 94 | html`<option value="${value}" ${selected ? raw("selected") : null}>${label}</option>`; |
| 94 | 95 | |
| 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 | ||
| 95 | 146 | // GET /boards/data |
| 96 | export function boardsPage(ctx: Ctx): Response { | |
| 147 | export async function boardsPage(ctx: Ctx): Promise<Response> { | |
| 97 | 148 | const user = ctx.auth.user; |
| 98 | 149 | if (user?.isAdmin !== true || user.kind === "agent") return notFoundPage(ctx); |
| 99 | const p = readParams(ctx.url); | |
| 150 | const p = readParams(ctx.url.searchParams); | |
| 100 | 151 | const src = Object.hasOwn(SOURCES, p.source) ? SOURCES[p.source]! : undefined; |
| 101 | 152 | const answer = runQuery(ctx.app.db, toQuery(p), { repoIds: "all" }); |
| 102 | 153 | const cols = src ? Object.keys(src.columns) : []; |
| @@ -115,30 +166,7 @@ export function boardsPage(ctx: Ctx): Response { | ||
| 115 | 166 | |
| 116 | 167 | const form = card( |
| 117 | 168 | 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)} | |
| 142 | 170 | ${btn("Draw", { variant: "primary" })} |
| 143 | 171 | </form>`, |
| 144 | 172 | { head: html`<h2>Query</h2>` }, |
| @@ -174,10 +202,30 @@ export function boardsPage(ctx: Ctx): Response { | ||
| 174 | 202 | </table>`; |
| 175 | 203 | } |
| 176 | 204 | |
| 205 | const listed = await listAllBoards(ctx.app, user); | |
Low: every render of |
||
| 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 | ||
| 177 | 225 | return page({ |
| 178 | 226 | ctx, |
| 179 | 227 | 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}`, | |
| 182 | 230 | }); |
| 183 | 231 | } |
src/web/style/content.ts +9
| @@ -378,6 +378,15 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); } | ||
| 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 | 380 | .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; } } | |
| 381 | 390 | .fleet-svg { display: block; width: 100%; height: auto; aspect-ratio: 600 / 230; } |
| 382 | 391 | .fleet-grid line { stroke: var(--border); stroke-width: 1; } |
| 383 | 392 | .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", () => { | ||
| 117 | 117 | test("a non-admin and a stranger get 404; the admin's menu links Boards", async () => { |
| 118 | 118 | expect((await get("/boards/data", "bob")).status).toBe(404); |
| 119 | 119 | expect((await get("/boards/data", null)).status).toBe(404); |
| 120 | expect(await (await get("/", "admin")).text()).toContain('href="/boards/data"'); | |
| 121 | expect(await (await get("/", "bob")).text()).not.toContain('href="/boards/data"'); | |
| 120 | expect(await (await get("/", "admin")).text()).toContain('href="/boards"'); | |
| 121 | expect(await (await get("/", "bob")).text()).not.toContain('href="/boards"'); | |
| 122 | 122 | }); |
| 123 | 123 | }); |
| 124 | 124 |
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 | }); |
Low:
ok(board)goes back tochangeBoard, 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.