Public: anyone can read this; the crew can change it.
Any /boards panel can be pinned to home, and pinned panels show under the KPIs in the owner's order #2913
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2741-any-boards-panel-can-be-pinned-to-home-a → main 6 commits fork 5 threads · 5 unresolved
README.md +5
| @@ -1574,6 +1574,11 @@ work here. | ||
| 1574 | 1574 | `forge/boards`, a repo the forge owns and makes on the first boot with an admin; every save is a commit |
| 1575 | 1575 | by the admin who made it, and a file that does not validate is not shown. Make, rename, |
| 1576 | 1576 | duplicate, delete, add/edit/remove/reorder panels, or pin the query on `/boards/data`. |
| 1577 | Any panel has **Pin to home** / **Unpin** on its board page (a same-origin POST); pins are per admin, kept in `board_pin` (slug + panel title + position, `boards/pins.ts`), and home draws them under the KPI row in that person's order with Up, Down and Unpin beside each (`web/studio/pinned.ts`). A pin whose panel is gone is skipped, does not count toward the limit of 24, and is dropped at that person's next pin; nobody sees another's pins. All doors go through `actions/board-pins.ts` (an admin who is a person; anyone else gets not-found): | |
| 1578 | - the page (Pin to home / Unpin on `/boards/:slug`, Up / Down / Unpin on home); | |
| 1579 | - the terminal: on a board's screen `[` `]` mark a panel, `p` pins or unpins it, `<` `>` move a pinned one; the dashboard lists the pinned panels by title; | |
| 1580 | - `GET /api/v1/boards/pins` (the list, in order) and `POST /api/v1/boards/pins` with `{"op":"pin|unpin|move","slug":"…","title":"…","by":-1|1}` (`by` for move only); | |
| 1581 | - the `board_pins` MCP tool (no `op` lists; `op` pin, unpin or move changes). `pins` is therefore not a board slug. | |
| 1577 | 1582 | The first boot also writes seven starting boards, **Usage**, **Crew**, **Releases**, **Apps**, **Security**, **Throughput** and **Forges**. Apps' per-app panels show repo ids until a source exposes a safe name; Security (sign-ins, failed sign-ins, tokens by scope, admin actions, from the admin-only `signins`, `tokens` and `adminActions` sources and `usage`) shows no token text, hash, payload or IP; Throughput is items entering each stage from issue to live, per hour, from the `stages` source. Each is written once: `.offered` in that repo records them, so an edit stays and a deletion sticks. |
| 1578 | 1583 | A live board (any `refresh` but `off`; the form offers off or on, and `10s` and `1m` are still accepted and mean the same) redraws its grid when a run, pull, check, issue or usage event arrives, through the live-region swap |
| 1579 | 1584 | (`/boards/:slug/live`, etag'd, one fetch per burst). The same doors are in the API: `GET /api/v1/boards`, `GET|PUT|DELETE /api/v1/boards/:slug` |
src/actions/board-pins.ts added +73
| @@ -0,0 +1,73 @@ | ||
| 1 | /** | |
| 2 | * Pinned panels, for a forge admin who is a person (anyone else is told | |
| 3 | * `not_found`, as for `showBoard`): pin a board's panel to home, unpin it, | |
| 4 | * move it one step in the owner's order, list what is pinned. The web | |
| 5 | * forms, the terminal, `/api/v1/boards/pins` and the `board_pins` MCP tool | |
| 6 | * all come through here. A pin counts toward the limit only while its panel | |
| 7 | * still exists: a pin whose board was deleted or panel renamed is dropped | |
| 8 | * the next time its owner pins. | |
| 9 | */ | |
| 10 | import type { App } from "../app.ts"; | |
| 11 | import { MAX_PINS, listPins, movePin, pin, pinOf, unpin, type Pin } from "../boards/pins.ts"; | |
| 12 | import { readBoard, type Board, type Panel } from "../boards/store.ts"; | |
| 13 | import type { User } from "../core/users.ts"; | |
| 14 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 15 | ||
| 16 | export type PinError = "not_found" | "invalid" | "too_many"; | |
| 17 | export type Pinned = Pin & { board: Board; panel: Panel }; | |
| 18 | ||
| 19 | const person = (actor: User | undefined): User | undefined => | |
| 20 | actor?.isAdmin === true && actor.kind !== "agent" ? actor : undefined; | |
| 21 | ||
| 22 | /** The actor's pins that still name a panel, in their order, with the board and panel. */ | |
| 23 | async function resolved(app: App, who: User): Promise<{ live: Pinned[]; gone: Pin[] }> { | |
| 24 | const boards = new Map<string, Board | undefined>(); | |
| 25 | const live: Pinned[] = []; | |
| 26 | const gone: Pin[] = []; | |
| 27 | for (const p of listPins(app.db, who.id)) { | |
| 28 | if (!boards.has(p.slug)) boards.set(p.slug, await readBoard(app.reposRoot, p.slug)); | |
| 29 | const board = boards.get(p.slug); | |
| 30 | const panel = board?.panels.find((x) => x.title === p.title); | |
| 31 | if (board && panel) live.push({ ...p, board, panel }); | |
| 32 | else gone.push(p); | |
| 33 | } | |
| 34 | return { live, gone }; | |
| 35 | } | |
| 36 | ||
| 37 | export async function listPinned(app: App, actor: User | undefined): Promise<Result<Pinned[], "not_found">> { | |
| 38 | const who = person(actor); | |
| 39 | return who ? ok((await resolved(app, who)).live) : err("not_found"); | |
| 40 | } | |
| 41 | ||
| 42 | export async function pinPanel(app: App, actor: User | undefined, slug: unknown, title: unknown): Promise<Result<Pin, PinError>> { | |
| 43 | const who = person(actor); | |
| 44 | if (!who) return err("not_found"); | |
| 45 | const which = pinOf(slug, title); | |
| 46 | if (!which) return err("invalid"); | |
| 47 | const board = await readBoard(app.reposRoot, which.slug); | |
| 48 | if (!board?.panels.some((p) => p.title === which.title)) return err("not_found"); | |
| 49 | const { live, gone } = await resolved(app, who); | |
| 50 | for (const g of gone) unpin(app.db, who.id, g); | |
| 51 | if (live.length >= MAX_PINS && !live.some((p) => p.slug === which.slug && p.title === which.title)) return err("too_many"); | |
| 52 | pin(app.db, who.id, which); | |
| 53 | return ok(which); | |
| 54 | } | |
| 55 | ||
| 56 | export function unpinPanel(app: App, actor: User | undefined, slug: unknown, title: unknown): Result<Pin, PinError> { | |
| 57 | const who = person(actor); | |
| 58 | if (!who) return err("not_found"); | |
| 59 | const which = pinOf(slug, title); | |
| 60 | if (!which) return err("invalid"); | |
| 61 | unpin(app.db, who.id, which); | |
| 62 | return ok(which); | |
| 63 | } | |
| 64 | ||
| 65 | export function movePinned(app: App, actor: User | undefined, slug: unknown, title: unknown, by: unknown): Result<Pin, PinError> { | |
| 66 | const who = person(actor); | |
| 67 | if (!who) return err("not_found"); | |
| 68 | const which = pinOf(slug, title); | |
| 69 | if (!which || (by !== 1 && by !== -1)) return err("invalid"); | |
| 70 | if (!listPins(app.db, who.id).some((x) => x.slug === which.slug && x.title === which.title)) return err("not_found"); | |
| 71 | movePin(app.db, who.id, which, by); | |
| 72 | return ok(which); | |
| 73 | } |
src/boards/pins.ts added +59
| @@ -0,0 +1,59 @@ | ||
| 1 | /** | |
| 2 | * Pinned panels: a person's own list of panels from /boards that home draws | |
| 3 | * under the KPI row. A pin names a board's slug and a panel's title and sits | |
| 4 | * at a position in its owner's order; it is only ever read or changed with | |
| 5 | * its owner's id, so one person's pins are never another's. | |
| 6 | */ | |
| 7 | import type { Database } from "bun:sqlite"; | |
| 8 | import { isSlug, MAX_TITLE } from "./store.ts"; | |
| 9 | ||
| 10 | export type Pin = { slug: string; title: string }; | |
| 11 | export const MAX_PINS = 24; | |
| 12 | ||
| 13 | /** A pin as a form says it, judged: the plain value or undefined. */ | |
| 14 | export function pinOf(slug: unknown, title: unknown): Pin | undefined { | |
| 15 | if (!isSlug(slug) || typeof title !== "string") return undefined; | |
| 16 | if (title.trim() === "" || title.length > MAX_TITLE || /[\u0000-\u001f\u007f]/.test(title)) return undefined; | |
| 17 | return { slug, title: title.trim() }; | |
| 18 | } | |
| 19 | ||
| 20 | /** The owner's pins in their order. */ | |
| 21 | export function listPins(db: Database, userId: string): Pin[] { | |
| 22 | return db | |
| 23 | .query<Pin, [string]>("SELECT slug, title FROM board_pin WHERE user_id = ? ORDER BY pos, rowid") | |
| 24 | .all(userId); | |
| 25 | } | |
| 26 | ||
| 27 | export const isPinned = (pins: readonly Pin[], slug: string, title: string) => | |
| 28 | pins.some((p) => p.slug === slug && p.title === title); | |
| 29 | ||
| 30 | /** Pin at the end of the owner's order; false when they already hold the most. Pinning twice changes nothing. */ | |
| 31 | export function pin(db: Database, userId: string, p: Pin): boolean { | |
| 32 | const have = listPins(db, userId); | |
| 33 | if (isPinned(have, p.slug, p.title)) return true; | |
| 34 | if (have.length >= MAX_PINS) return false; | |
|
||
| 35 | db.run("INSERT INTO board_pin (user_id, slug, title, pos) VALUES (?, ?, ?, ?)", [userId, p.slug, p.title, have.length]); | |
| 36 | return true; | |
| 37 | } | |
| 38 | ||
| 39 | export function unpin(db: Database, userId: string, p: Pin): void { | |
| 40 | db.run("DELETE FROM board_pin WHERE user_id = ? AND slug = ? AND title = ?", [userId, p.slug, p.title]); | |
| 41 | renumber(db, userId, listPins(db, userId)); | |
| 42 | } | |
| 43 | ||
| 44 | /** Move one step earlier (-1) or later (1); the ends stay put. */ | |
| 45 | export function movePin(db: Database, userId: string, p: Pin, by: -1 | 1): void { | |
| 46 | const list = listPins(db, userId); | |
| 47 | const i = list.findIndex((x) => x.slug === p.slug && x.title === p.title); | |
| 48 | const j = i + by; | |
| 49 | if (i < 0 || j < 0 || j >= list.length) return; | |
| 50 | [list[i], list[j]] = [list[j]!, list[i]!]; | |
| 51 | renumber(db, userId, list); | |
| 52 | } | |
| 53 | ||
| 54 | function renumber(db: Database, userId: string, list: readonly Pin[]): void { | |
| 55 | db.transaction(() => { | |
| 56 | list.forEach((x, pos) => | |
| 57 | db.run("UPDATE board_pin SET pos = ? WHERE user_id = ? AND slug = ? AND title = ?", [pos, userId, x.slug, x.title])); | |
| 58 | })(); | |
| 59 | } |
src/boards/store.ts +1 −1
| @@ -49,7 +49,7 @@ const span = (v: unknown, max: number): v is number => | ||
| 49 | 49 | typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= max; |
| 50 | 50 | |
| 51 | 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"]); | |
| 52 | const TAKEN = new Set(["data", "sources", "query", "pins"]); | |
| 53 | 53 | |
| 54 | 54 | /** A slug is a file name: lowercase, short, and never one of `TAKEN`. */ |
| 55 | 55 | export const isSlug = (s: unknown): s is string => |
src/core/migrations/20261019180000-board-pins.ts added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | // A panel on /boards can be pinned to its owner's home (#2741). A pin is the | |
| 2 | // owner, the board's slug and the panel's title; `pos` is the owner's order. | |
| 3 | // Deleting the owner deletes their pins. A pin whose board or panel has since | |
| 4 | // gone is kept and simply not drawn. | |
| 5 | export default ` | |
| 6 | CREATE TABLE board_pin ( | |
| 7 | user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, | |
| 8 | slug TEXT NOT NULL, | |
| 9 | title TEXT NOT NULL, | |
| 10 | pos INTEGER NOT NULL, | |
| 11 | PRIMARY KEY (user_id, slug, title) | |
| 12 | ); | |
| 13 | CREATE INDEX board_pin_order ON board_pin (user_id, pos); | |
| 14 | `; |
src/http/api.ts +35
| @@ -16,6 +16,7 @@ import { storageLimitMessage } from "../core/storage.ts"; | ||
| 16 | 16 | import { voteVariant } from "../actions/variants.ts"; |
| 17 | 17 | import { parentForgeFor } from "../core/parent_releases.ts"; |
| 18 | 18 | import { readFlow } from "../flow/read.ts"; |
| 19 | import { listPinned as listPinnedPanels, movePinned, pinPanel, unpinPanel, type PinError } from "../actions/board-pins.ts"; | |
| 19 | 20 | import { deleteBoard, listAllBoards, showBoard, writeBoard, type BoardError } from "../actions/boards.ts"; |
| 20 | 21 | import { runQuery } from "../boards/query.ts"; |
| 21 | 22 | import { recordDrill } from "../core/domain_drills.ts"; |
| @@ -1494,6 +1495,40 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1494 | 1495 | : notFound(); |
| 1495 | 1496 | }); |
| 1496 | 1497 | |
| 1498 | // The signed-in admin's pins for home: GET lists them in order, POST | |
| 1499 | // {op: pin|unpin|move, slug, title, by?} changes them. Above `:slug`. | |
| 1500 | router.add("GET", "/api/v1/boards/pins", async (ctx) => { | |
| 1501 | const denied = boardsAdmin(ctx); | |
| 1502 | if (denied) return denied; | |
| 1503 | const listed = await listPinnedPanels(ctx.app, ctx.auth.user); | |
| 1504 | return listed.ok ? json({ pins: listed.value.map((p) => ({ slug: p.slug, title: p.title, panel: p.panel })) }) : notFound(); | |
| 1505 | }); | |
| 1506 | ||
| 1507 | router.add("POST", "/api/v1/boards/pins", async (ctx) => { | |
| 1508 | const denied = boardsAdmin(ctx) ?? requireAccount(ctx, { adminToken: true }); | |
| 1509 | if (denied) return denied; | |
| 1510 | const bytes = await readBounded(ctx.request, bodyCapFor(ctx.request)); | |
| 1511 | if (bytes === "too_large") return apiError("too_large", 413, "that is too large to read here"); | |
| 1512 | let input: Record<string, unknown>; | |
| 1513 | try { | |
| 1514 | const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes)); | |
| 1515 | if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("not an object"); | |
| 1516 | input = parsed as Record<string, unknown>; | |
| 1517 | } catch { | |
| 1518 | return apiError("bad_json", 400, "the body must be a JSON object"); | |
| 1519 | } | |
| 1520 | const { op, slug, title, by } = input; | |
| 1521 | const done = | |
| 1522 | op === "pin" ? await pinPanel(ctx.app, ctx.auth.user, slug, title) | |
| 1523 | : op === "unpin" ? unpinPanel(ctx.app, ctx.auth.user, slug, title) | |
| 1524 | : op === "move" ? movePinned(ctx.app, ctx.auth.user, slug, title, by) | |
| 1525 | : undefined; | |
| 1526 | if (!done) return apiError("invalid", 422, "op must be pin, unpin or move"); | |
| 1527 | if (done.ok) return json({ ...done.value }); | |
| 1528 | const e: PinError = done.error; | |
| 1529 | return e === "not_found" ? notFound() : apiError(e, 422, e === "too_many" ? "too many pins" : "that is not a panel"); | |
| 1530 | }); | |
| 1531 | ||
| 1497 | 1532 | router.add("GET", "/api/v1/boards/:slug", async (ctx) => { |
| 1498 | 1533 | const denied = boardsAdmin(ctx); |
| 1499 | 1534 | if (denied) return denied; |
src/http/mcp.ts +32
| @@ -186,6 +186,7 @@ import type { Router } from "./router.ts"; | ||
| 186 | 186 | import { mayReadRuns, runForReader } from "../actions/runs.ts"; |
| 187 | 187 | import { manifestFor } from "../actions/here.ts"; |
| 188 | 188 | import { clientBucket } from "./peer.ts"; |
| 189 | import { listPinned as listPinnedPanels, movePinned, pinPanel, unpinPanel } from "../actions/board-pins.ts"; | |
| 189 | 190 | import { deleteBoard, listAllBoards, showBoard, writeBoard, type BoardError } from "../actions/boards.ts"; |
| 190 | 191 | import { fileReport } from "../actions/reports.ts"; |
| 191 | 192 | import { BODY_MAX, REQUEST_MAX, TITLE_MAX } from "../core/reports.ts"; |
| @@ -2974,6 +2975,37 @@ const TOOLS: readonly Tool[] = [ | ||
| 2974 | 2975 | return done.ok ? good({ slug: str(args["slug"]), ...done.value }) : boardFailed(done.error); |
| 2975 | 2976 | }, |
| 2976 | 2977 | }, |
| 2978 | { | |
| 2979 | name: "board_pins", | |
No test calls this tool; the MCP test only checks that the name is listed. Add a call test covering list, pin, move and unpin as an admin who is a person, plus an agent being refused. |
||
| 2980 | description: | |
| 2981 | "The panels pinned to the caller's own home, for a forge admin who is a person (an agent is refused). With no `op`, lists them in order. With `op` pin, unpin or move, changes them: `slug` and `title` name a panel of a board; `by` is -1 or 1 for move. The same as GET and POST /api/v1/boards/pins.", | |
| 2982 | inputSchema: { | |
| 2983 | type: "object", | |
| 2984 | properties: { | |
| 2985 | op: { type: "string", enum: ["pin", "unpin", "move"] }, | |
| 2986 | slug: { type: "string", maxLength: 40 }, | |
| 2987 | title: { type: "string", maxLength: 80 }, | |
| 2988 | by: { type: "integer", enum: [-1, 1] }, | |
| 2989 | }, | |
| 2990 | }, | |
| 2991 | writes: true, | |
| 2992 | async run(ctx, user, args) { | |
| 2993 | if (!mayBoards(user)) return boardRefused(); | |
| 2994 | const op = args["op"]; | |
| 2995 | if (op === undefined) { | |
| 2996 | const listed = await listPinnedPanels(ctx.app, user); | |
| 2997 | return listed.ok ? good({ pins: listed.value.map((p) => ({ slug: p.slug, title: p.title })) }) : boardRefused(); | |
| 2998 | } | |
| 2999 | const [slug, title] = [args["slug"], args["title"]]; | |
| 3000 | const done = | |
| 3001 | op === "pin" ? await pinPanel(ctx.app, user, slug, title) | |
| 3002 | : op === "unpin" ? unpinPanel(ctx.app, user, slug, title) | |
| 3003 | : op === "move" ? movePinned(ctx.app, user, slug, title, args["by"]) | |
| 3004 | : undefined; | |
| 3005 | if (!done) return bad("invalid", "op must be pin, unpin or move"); | |
| 3006 | return done.ok ? good({ ...done.value }) : bad(done.error, "that could not be pinned"); | |
| 3007 | }, | |
| 3008 | }, | |
| 2977 | 3009 | { |
| 2978 | 3010 | name: "board_delete", |
| 2979 | 3011 | description: |
src/http/server.ts +2 −1
| @@ -25,7 +25,7 @@ import { resiliencePage } from "../web/pages/resilience.ts"; | ||
| 25 | 25 | import { limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } from "../web/pages/admin-limits.ts"; |
| 26 | 26 | import { measureSubmit } from "./measure.ts"; |
| 27 | 27 | import { boardsPage } from "../web/pages/boards.ts"; |
| 28 | import { boardNew, boardPin, boardPost } from "../web/pages/boards-edit.ts"; | |
| 28 | import { boardNew, boardPin, boardPost, pinPost } from "../web/pages/boards-edit.ts"; | |
| 29 | 29 | import { boardLive, boardShow, boardsList } from "../web/pages/boards-view.ts"; |
| 30 | 30 | import { |
| 31 | 31 | closeStreamsOf, |
| @@ -830,6 +830,7 @@ export function buildRouter(): Router<Handler> { | ||
| 830 | 830 | router.add("GET", "/boards/data", boardsPage); |
| 831 | 831 | router.add("POST", "/boards/new", boardNew); |
| 832 | 832 | router.add("POST", "/boards/pin", boardPin); |
| 833 | for (const op of ["pin-panel", "unpin-panel", "move-pin"]) router.add("POST", `/boards/${op}`, pinPost); | |
| 833 | 834 | router.add("GET", "/boards/:slug", boardShow); |
| 834 | 835 | router.add("GET", "/boards/:slug/live", boardLive); |
| 835 | 836 | router.add("POST", "/boards/:slug/:op", boardPost); |
src/tui/screens/boards.ts +46 −11
| @@ -3,11 +3,13 @@ | ||
| 3 | 3 | * keeps, then one board's panels as text. It reads through the same |
| 4 | 4 | * `listAllBoards` and `showBoard` the page and the API do, so the gate is the |
| 5 | 5 | * action's (an agent or anyone else is told nothing); the palette offers the |
| 6 | * door only to that person. Read-only: a chart is not drawn, a panel is said | |
| 7 | * as its title, chart, size and the query that feeds it. | |
| 6 | * door only to that person. Reading only, but for pins: a chart is not drawn, a panel is said | |
| 7 | * as its title, chart, size and the query that feeds it. `[`/`]` mark a panel, | |
| 8 | * `p` pins or unpins it to home and `<`/`>` move a pinned one (`actions/board-pins.ts`). | |
| 8 | 9 | */ |
| 9 | 10 | |
| 10 | 11 | import type { Board } from "../../boards/store.ts"; |
| 12 | import { listPinned, movePinned, pinPanel, unpinPanel } from "../../actions/board-pins.ts"; | |
| 11 | 13 | import { listAllBoards, showBoard } from "../../actions/boards.ts"; |
| 12 | 14 | import type { Ctx, Screen } from "../app/app.ts"; |
| 13 | 15 | import { binding } from "../app/keys.ts"; |
| @@ -19,7 +21,7 @@ import { truncate } from "../term/width.ts"; | ||
| 19 | 21 | import { ListState, Scroll, drawList, drawLines, hitList, hitScroll, metaOf, plural, runAsync, type ListRow } from "./common.ts"; |
| 20 | 22 | |
| 21 | 23 | /** One line of plain text: a title or a query value may carry escapes the terminal must not obey. */ |
| 22 | const plain = (text: string): string => | |
| 24 | export const plain = (text: string): string => | |
| 23 | 25 | // eslint-disable-next-line no-control-regex |
| 24 | 26 | text.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim(); |
| 25 | 27 | |
| @@ -39,27 +41,53 @@ export async function boardRows(session: Session): Promise<BoardRow[]> { | ||
| 39 | 41 | })); |
| 40 | 42 | } |
| 41 | 43 | |
| 42 | /** A board, panel by panel, as lines of text. */ | |
| 43 | export function boardLines(board: Board): string[] { | |
| 44 | /** A board, panel by panel, as lines of text; the panel at `at` is marked, and a pinned one says so. */ | |
| 45 | export function boardLines(board: Board, at = -1, pinned: readonly string[] = []): string[] { | |
| 44 | 46 | const lines = [ |
| 45 | 47 | plain(board.title), |
| 46 | 48 | `range ${board.range} · refresh ${board.refresh} · ${plural(board.panels.length, "panel")}`, |
| 47 | 49 | ]; |
| 48 | for (const p of board.panels) { | |
| 50 | for (const [i, p] of board.panels.entries()) { | |
| 49 | 51 | const query = Object.entries(p.query) |
| 50 | 52 | .map(([k, v]) => `${plain(k)}=${plain(typeof v === "string" ? v : JSON.stringify(v))}`) |
| 51 | 53 | .join(" "); |
| 52 | lines.push("", plain(p.title), ` ${p.chart} · ${p.w}×${p.h}`, ` ${query === "" ? "no query" : query}`); | |
| 54 | lines.push("", `${i === at ? "▸ " : ""}${plain(p.title)}${pinned.includes(p.title) ? " [pinned to home]" : ""}`, ` ${p.chart} · ${p.w}×${p.h}`, ` ${query === "" ? "no query" : query}`); | |
| 53 | 55 | } |
| 54 | 56 | return lines; |
| 55 | 57 | } |
| 56 | 58 | |
| 57 | function boardScreen(ctx: Ctx, board: Board): Screen { | |
| 59 | function boardScreen(ctx: Ctx, f: Session, slug: string, board: Board): Screen { | |
| 58 | 60 | const scroll = new Scroll(); |
| 59 | const lines = boardLines(board); | |
| 61 | let at = board.panels.length > 0 ? 0 : -1; | |
| 62 | let pinned: string[] = []; | |
| 63 | let lines = boardLines(board, at, pinned); | |
| 64 | const read = async (): Promise<void> => { | |
| 65 | const listed = await listPinned(f.app, f.user); | |
| 66 | pinned = listed.ok ? listed.value.filter((p) => p.slug === slug).map((p) => p.title) : []; | |
| 67 | lines = boardLines(board, at, pinned); | |
| 68 | ctx.invalidate(); | |
| 69 | }; | |
| 70 | /** Pin the marked panel, or unpin it when it is; `by` moves a pinned one in the home order. */ | |
| 71 | const act = (by?: -1 | 1): void => { | |
| 72 | const panel = board.panels[at]; | |
| 73 | if (panel === undefined) return; | |
| 74 | runAsync(ctx, async () => { | |
| 75 | const there = pinned.includes(panel.title); | |
| 76 | const done = | |
| 77 | by !== undefined ? movePinned(f.app, f.user, slug, panel.title, by) | |
|
||
| 78 | : there ? unpinPanel(f.app, f.user, slug, panel.title) | |
| 79 | : await pinPanel(f.app, f.user, slug, panel.title); | |
| 80 | ctx.toast(done.ok ? (by !== undefined ? "moved on home" : there ? "unpinned" : "pinned to home") : done.error === "too_many" ? "too many pins" : done.error === "not_found" && by !== undefined ? "that panel is not pinned" : "could not pin that", done.ok ? "success" : "danger"); | |
| 81 | await read(); | |
| 82 | }); | |
| 83 | }; | |
| 60 | 84 | return { |
| 61 | 85 | title: plain(board.title), |
| 62 | keys: [binding(["j", "k"], "scroll"), binding(["esc", "q"], "back")], | |
| 86 | keys: [ | |
| 87 | binding(["j", "k"], "scroll"), binding(["[", "]"], "panel"), binding(["p"], "pin / unpin"), | |
| 88 | binding(["<", ">"], "move pin"), binding(["esc", "q"], "back"), | |
| 89 | ], | |
| 90 | load: read, | |
| 63 | 91 | render(area, s) { |
| 64 | 92 | const c = ctx.theme.c; |
| 65 | 93 | const spans = lines.map((text, i) => [{ text, style: style(i === 0 ? c.fg : c.mutedFg, c.bg) }]); |
| @@ -68,6 +96,13 @@ function boardScreen(ctx: Ctx, board: Board): Screen { | ||
| 68 | 96 | }, |
| 69 | 97 | key(e) { |
| 70 | 98 | if (scroll.key(e, lines.length)) return true; |
| 99 | if (e.key === "[" || e.key === "]") { | |
| 100 | if (board.panels.length > 0) at = (at + (e.key === "]" ? 1 : board.panels.length - 1)) % board.panels.length; | |
| 101 | lines = boardLines(board, at, pinned); | |
| 102 | return true; | |
| 103 | } | |
| 104 | if (e.key === "p") return act(), true; | |
| 105 | if (e.key === "<" || e.key === ">") return act(e.key === ">" ? 1 : -1), true; | |
| 71 | 106 | if (e.key === "esc" || e.key === "q") { |
| 72 | 107 | ctx.pop(); |
| 73 | 108 | return true; |
| @@ -90,7 +125,7 @@ export function boardsScreen(f: Session, ctx: Ctx): Screen { | ||
| 90 | 125 | runAsync(ctx, async () => { |
| 91 | 126 | const shown = await showBoard(f.app, f.user, row.slug); |
| 92 | 127 | if (!shown.ok) ctx.toast("That board is gone", "default"); |
| 93 | else ctx.push(boardScreen(ctx, shown.value)); | |
| 128 | else ctx.push(boardScreen(ctx, f, row.slug, shown.value)); | |
| 94 | 129 | }); |
| 95 | 130 | }; |
| 96 | 131 | return { |
src/tui/screens/dashboard.ts +17 −1
| @@ -24,6 +24,7 @@ import { nextStepsFor } from "../../core/next-steps.ts"; | ||
| 24 | 24 | import { agentsLine } from "../../web/studio/client_home.ts"; |
| 25 | 25 | import { mayView } from "../../core/sealed.ts"; |
| 26 | 26 | import { applyFleetIds, previewFleetIds, undoFleetOp } from "../../actions/fleet.ts"; |
| 27 | import { plain } from "./boards.ts"; | |
| 27 | 28 | import { pinRepo, unpinRepo } from "../../actions/origins.ts"; |
| 28 | 29 | import { getProposal } from "../../core/proposals.ts"; |
| 29 | 30 | import { getIssueById, listAssignedTo, type Issue } from "../../core/issues.ts"; |
| @@ -90,6 +91,7 @@ import { BOOTED } from "../../release/gate.ts"; | ||
| 90 | 91 | import { running } from "../../kernel/version.ts"; |
| 91 | 92 | import { deciderLine, deciderScreen } from "./decider.ts"; |
| 92 | 93 | import { issueScreen } from "./issue.ts"; |
| 94 | import { listPinned } from "../../actions/board-pins.ts"; | |
| 93 | 95 | import { newRepoScreen } from "./newrepo.ts"; |
| 94 | 96 | import { pullScreen } from "./pull.ts"; |
| 95 | 97 | import { repoScreen } from "./repo.ts"; |
| @@ -162,6 +164,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 162 | 164 | let decider: string | null = null; |
| 163 | 165 | /** Home's three figures, as the web's row says them; empty unless an admin looks (they count every repository). */ |
| 164 | 166 | let figures: string[] = []; |
| 167 | /** The panels this admin pinned to the web home, by title and board, in their order. */ | |
| 168 | let panelLines: string[] = []; | |
| 165 | 169 | const filter = new Filter(); |
| 166 | 170 | /** One cursor per pane: moving in one must never move another. */ |
| 167 | 171 | const cursors: Record<PaneId, ListState> = { |
| @@ -186,6 +190,13 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 186 | 190 | ctx.invalidate(); |
| 187 | 191 | }; |
| 188 | 192 | |
| 193 | const loadPanelLines = async (): Promise<void> => { | |
| 194 | const listed = await listPinned(f.app, f.user); | |
| 195 | const titles = listed.ok ? listed.value.map((p) => `${plain(p.panel.title)} (${plain(p.board.title)})`) : []; | |
| 196 | panelLines = titles.length === 0 ? [] : [`Pinned: ${titles.join(" · ")}`]; | |
| 197 | ctx.invalidate(); | |
| 198 | }; | |
| 199 | ||
| 189 | 200 | const reload = (): void => { |
| 190 | 201 | // The server remembers the *choice* and nothing about the laptop, so |
| 191 | 202 | // this is a list of decisions — never a status that would be stale. |
| @@ -761,7 +772,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 761 | 772 | // functions of the outbox, so one subscription refreshes all of them. |
| 762 | 773 | live(f, signal, {}, reload); |
| 763 | 774 | reload(); |
| 764 | return loadClientLines(); | |
| 775 | return Promise.all([loadClientLines(), loadPanelLines()]).then(() => undefined); | |
| 765 | 776 | }, |
| 766 | 777 | render(area, s) { |
| 767 | 778 | const c = ctx.theme.c; |
| @@ -792,6 +803,11 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 792 | 803 | ); |
| 793 | 804 | body = rest!; |
| 794 | 805 | } |
| 806 | if (panelLines.length > 0) { | |
| 807 | const [strip, rest] = split(body, "v", [len(1), fill(1)]); | |
| 808 | s.text(strip!.x, strip!.y, truncate(panelLines[0]!, strip!.w), style(c.mutedFg, c.bg), strip!.x + strip!.w); | |
| 809 | body = rest!; | |
| 810 | } | |
| 795 | 811 | if (decider !== null) { |
| 796 | 812 | const [strip, rest] = split(body, "v", [len(1), fill(1)]); |
| 797 | 813 | s.text( |
src/web/pages/boards-edit.ts +25 −1
| @@ -9,10 +9,11 @@ import { | ||
| 9 | 9 | changeBoard, deleteBoard, duplicateBoard, makeBoard, type BoardError, |
| 10 | 10 | } from "../../actions/boards.ts"; |
| 11 | 11 | import type { Edit } from "../../boards/edit.ts"; |
| 12 | import { movePinned, pinPanel, unpinPanel } from "../../actions/board-pins.ts"; | |
| 12 | 13 | import { isSlug } from "../../boards/store.ts"; |
| 13 | 14 | import type { Ctx } from "../../http/context.ts"; |
| 14 | 15 | import { notFoundPage } from "../chrome.ts"; |
| 15 | import { field, readForm, type Form } from "../form.ts"; | |
| 16 | import { backTo, field, readForm, type Form } from "../form.ts"; | |
| 16 | 17 | import { flashTo } from "../layout.ts"; |
| 17 | 18 | import { PANEL_FIELDS, readParams, toQuery } from "./boards.ts"; |
| 18 | 19 | |
| @@ -99,3 +100,26 @@ export async function boardPost(ctx: Ctx): Promise<Response> { | ||
| 99 | 100 | const changed = await changeBoard(ctx.app, ctx.auth.user, slug, edit); |
| 100 | 101 | return changed.ok ? done(ctx, back, "Saved.") : failed(ctx, back, changed.error); |
| 101 | 102 | } |
| 103 | ||
| 104 | /** | |
| 105 | * POST /boards/pin-panel, /boards/unpin-panel, /boards/move-pin — the | |
| 106 | * signed-in admin's own pins for home, through `actions/board-pins.ts`. | |
| 107 | */ | |
| 108 | export async function pinPost(ctx: Ctx): Promise<Response> { | |
| 109 | const form = await readForm(ctx, "admin"); | |
outdated
This is the only door to pins. Move this logic into an action. Then call it from here, from the terminal ( |
||
| 110 | if (form instanceof Response) return form; | |
| 111 | const op = ctx.url.pathname.slice("/boards/".length); | |
| 112 | const [slug, title] = [field(form, "slug"), field(form, "title")]; | |
| 113 | const back = backTo(form, "/"); | |
| 114 | const by = num(field(form, "by")); | |
| 115 | const run = { | |
| 116 | "pin-panel": () => pinPanel(ctx.app, ctx.auth.user, slug, title), | |
| 117 | "unpin-panel": () => unpinPanel(ctx.app, ctx.auth.user, slug, title), | |
| 118 | "move-pin": () => movePinned(ctx.app, ctx.auth.user, slug, title, by), | |
| 119 | }[op]; | |
| 120 | if (!run) return notFoundPage(ctx); | |
| 121 | const done = await run(); | |
| 122 | if (done.ok) return flashTo(ctx, back, { message: { "pin-panel": "Pinned to home.", "unpin-panel": "Unpinned.", "move-pin": "Moved." }[op]! }); | |
| 123 | if (done.error === "not_found") return notFoundPage(ctx); | |
| 124 | return flashTo(ctx, back, { tone: "danger", message: done.error === "too_many" ? "That is too many pins." : "That is not a panel." }); | |
| 125 | } |
src/web/pages/boards-view.ts +16 −3
| @@ -19,6 +19,7 @@ import { html, raw, when, type Html } from "../html.ts"; | ||
| 19 | 19 | import { fragment, page } from "../layout.ts"; |
| 20 | 20 | import { btn, card, empty } from "../ui.ts"; |
| 21 | 21 | import { flowPanel, flowPanelCss } from "./flow-panel.ts"; |
| 22 | import { isPinned, listPins } from "../../boards/pins.ts"; | |
| 22 | 23 | import { cellsOf, chartBox, paramsOf, queryFields, readParams, runFor } from "./boards.ts"; |
| 23 | 24 | |
| 24 | 25 | const RANGE_MS = { "1h": 3_600_000, "24h": 86_400_000, "7d": 604_800_000, "30d": 2_592_000_000 } as const; |
| @@ -34,7 +35,7 @@ function ranged(query: Record<string, unknown>, range: Board["range"]): Record<s | ||
| 34 | 35 | return { ...query, from: new Date(Date.now() - RANGE_MS[range]).toISOString() }; |
| 35 | 36 | } |
| 36 | 37 | |
| 37 | function drawPanel(app: App, board: Board, panel: Panel): Html { | |
| 38 | export function drawPanel(app: App, board: Board, panel: Panel, aside: Html | null = null): Html { | |
| 38 | 39 | const answer = runFor(app.db, panel.chart, ranged(panel.query, board.range)); |
| 39 | 40 | const q = paramsOf(panel.query, panel.chart); |
| 40 | 41 | const body = !answer.ok |
| @@ -45,6 +46,7 @@ function drawPanel(app: App, board: Board, panel: Panel): Html { | ||
| 45 | 46 | <div class="card-head"><h2>${panel.title}</h2> |
| 46 | 47 | <a class="muted small" href="/boards/data?${explore.toString()}">explore</a></div> |
| 47 | 48 | <div class="card-body">${body}</div> |
| 49 | ${aside} | |
| 48 | 50 | </div>`; |
| 49 | 51 | } |
| 50 | 52 | |
| @@ -115,12 +117,23 @@ function panelForm(slug: string, op: string, p: Panel | undefined, index: number | ||
| 115 | 117 | </form>`; |
| 116 | 118 | } |
| 117 | 119 | |
| 118 | function manage(slug: string, board: Board, adding: Html): Html { | |
| 120 | /** Pin to home / Unpin: one same-origin POST, back to this board. */ | |
| 121 | function pinForm(slug: string, title: string, pinned: boolean): Html { | |
| 122 | const verb = pinned ? "Unpin" : "Pin to home"; | |
| 123 | return html`<form method="post" action="/boards/${pinned ? "unpin-panel" : "pin-panel"}" class="row-actions"> | |
| 124 | <input type="hidden" name="slug" value="${slug}" /><input type="hidden" name="title" value="${title}" /> | |
| 125 | <input type="hidden" name="back" value="/boards/${slug}" /> | |
| 126 | ${btn(verb, { size: "sm", label: `${verb}: ${title}` })} | |
| 127 | </form>`; | |
| 128 | } | |
| 129 | ||
| 130 | function manage(slug: string, board: Board, adding: Html, pins: ReturnType<typeof listPins>): Html { | |
| 119 | 131 | const one = (p: Panel, i: number) => html`<li class="row bmanage"> |
| 120 | 132 | <details> |
| 121 | 133 | <summary><strong>${p.title}</strong> <span class="muted small">${CHART_NAMES[p.chart]} · ${String(p.w)}×${String(p.h)}</span></summary> |
| 122 | 134 | ${panelForm(slug, "edit", p, i, queryFields(paramsOf(p.query, p.chart)))} |
| 123 | 135 | </details> |
| 136 | ${pinForm(slug, p.title, isPinned(pins, slug, p.title))} | |
| 124 | 137 | <form method="post" action="/boards/${slug}/move" class="row-actions"> |
| 125 | 138 | <input type="hidden" name="index" value="${String(i)}" /> |
| 126 | 139 | ${btn("Up", { size: "sm", name: "by", value: "-1", label: `Move ${p.title} up` })} |
| @@ -167,7 +180,7 @@ export async function boardShow(ctx: Ctx): Promise<Response> { | ||
| 167 | 180 | body: html`<div class="page-head"><h1>${board.title}</h1> |
| 168 | 181 | ${btn("All boards", { href: "/boards", variant: "ghost" })}</div> |
| 169 | 182 | ${grid(ctx.app, slug, board)} |
| 170 | ${manage(slug, board, queryFields(readParams(ctx.url.searchParams)))}`, | |
| 183 | ${manage(slug, board, queryFields(readParams(ctx.url.searchParams)), listPins(ctx.app.db, ctx.auth.user?.id ?? ""))}`, | |
| 171 | 184 | scriptJs: liveRegionsJs, |
| 172 | 185 | }); |
| 173 | 186 | } |
src/web/studio/home.ts +2
| @@ -49,6 +49,7 @@ import { needsYouSection } from "./asks_page.ts"; | ||
| 49 | 49 | import { flowCards } from "./flow_card.ts"; |
| 50 | 50 | import { composer, feedJs, feedNote, feedRegion } from "./feed.ts"; |
| 51 | 51 | import { kpiRow } from "./kpi_row.ts"; |
| 52 | import { pinnedPanels } from "./pinned.ts"; | |
| 52 | 53 | import { homeTabs } from "./home_tabs.ts"; |
| 53 | 54 | import { crewKeyNote } from "./crew.ts"; |
| 54 | 55 | import { everydayCard } from "../pages/everyday.ts"; |
| @@ -249,6 +250,7 @@ export async function studioHome( | ||
| 249 | 250 | const kpiOpts = ceilingUsd === undefined ? {} : { ceilingUsd }; |
| 250 | 251 | const body = html`<h1 class="sr-only">Home</h1> |
| 251 | 252 | ${ask} ${address} ${figures ? kpiRow(ctx.app.db, kpiOpts) : liveLine(ctx)} |
| 253 | ${figures ? await pinnedPanels(ctx.app, user) : null} | |
| 252 | 254 | ${when( |
| 253 | 255 | steps, |
| 254 | 256 | () => html`<p class="small muted" id="lean-home"> |
src/web/studio/pinned.ts added +29
| @@ -0,0 +1,29 @@ | ||
| 1 | /** | |
| 2 | * The panels a person pinned from /boards, drawn under home's KPI row in | |
| 3 | * their order (#2741), each with Up, Down and Unpin beside it. Only the | |
| 4 | * viewer's own pins are read. A pin whose board or panel is gone is skipped. | |
| 5 | */ | |
| 6 | import type { App } from "../../app.ts"; | |
| 7 | import { listPinned } from "../../actions/board-pins.ts"; | |
| 8 | import type { Pin } from "../../boards/pins.ts"; | |
| 9 | import type { User } from "../../core/users.ts"; | |
| 10 | import { html, type Html } from "../html.ts"; | |
| 11 | import { btn } from "../ui.ts"; | |
| 12 | import { drawPanel } from "../pages/boards-view.ts"; | |
| 13 | ||
| 14 | function controls(p: Pin): Html { | |
| 15 | const hidden = html`<input type="hidden" name="slug" value="${p.slug}" /><input type="hidden" name="title" value="${p.title}" /><input type="hidden" name="back" value="/" />`; | |
| 16 | return html`<div class="row-actions"> | |
| 17 | <form method="post" action="/boards/move-pin" class="row-actions">${hidden} | |
| 18 | ${btn("Up", { size: "sm", name: "by", value: "-1", label: `Move ${p.title} up` })} | |
| 19 | ${btn("Down", { size: "sm", name: "by", value: "1", label: `Move ${p.title} down` })} | |
| 20 | </form> | |
| 21 | <form method="post" action="/boards/unpin-panel" class="row-actions">${hidden}${btn("Unpin", { size: "sm", label: `Unpin ${p.title}` })}</form> | |
| 22 | </div>`; | |
| 23 | } | |
| 24 | ||
| 25 | export async function pinnedPanels(app: App, user: User): Promise<Html | null> { | |
| 26 | const listed = await listPinned(app, user); | |
| 27 | if (!listed.ok || listed.value.length === 0) return null; | |
| 28 | return html`<section class="bgrid" aria-label="Pinned panels">${listed.value.map((p) => drawPanel(app, p.board, p.panel, controls(p)))}</section>`; | |
| 29 | } |
test/board-pins.test.ts added +140
| @@ -0,0 +1,140 @@ | ||
| 1 | /** Pinned panels (#2741): pin shows on home under the KPIs in the owner's order, unpin removes, nobody else's pins show. */ | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { changeBoard, deleteBoard, writeBoard } from "../src/actions/boards.ts"; | |
| 7 | import { pinPanel } from "../src/actions/board-pins.ts"; | |
| 8 | import { MAX_PINS } from "../src/boards/pins.ts"; | |
| 9 | import { createApp } from "../src/app.ts"; | |
| 10 | import { listPins, pinOf } from "../src/boards/pins.ts"; | |
| 11 | import { createToken } from "../src/core/tokens.ts"; | |
| 12 | import { createUser, type User } from "../src/core/users.ts"; | |
| 13 | import { serve } from "../src/http/server.ts"; | |
| 14 | ||
| 15 | let dir: string; | |
| 16 | let app: ReturnType<typeof createApp>; | |
| 17 | let servers: Awaited<ReturnType<typeof serve>>; | |
| 18 | let alice: User; | |
| 19 | let carol: User; | |
| 20 | const tok: Record<string, string> = {}; | |
| 21 | ||
| 22 | const panel = (title: string) => ({ title, chart: "line", query: { source: "runs", aggregate: "count", groupBy: "kind" }, w: 1, h: 1 }); | |
| 23 | const post = (path: string, fields: Record<string, string>, who = "alice", headers: Record<string, string> = {}) => | |
| 24 | fetch(servers.url + path, { | |
| 25 | method: "POST", redirect: "manual", body: new URLSearchParams(fields), | |
| 26 | headers: { authorization: `Bearer ${tok[who]}`, ...headers }, | |
| 27 | }); | |
| 28 | const home = async (who: string) => | |
| 29 | (await fetch(servers.url + "/", { headers: { authorization: `Bearer ${tok[who]}` } })).text(); | |
| 30 | const titles = (h: string) => [...h.matchAll(/<h2>(Pin [ABC])<\/h2>/g)].map((m) => m[1]); | |
| 31 | ||
| 32 | beforeAll(async () => { | |
| 33 | dir = await mkdtemp(join(tmpdir(), "forge-board-pins-")); | |
| 34 | app = createApp({ dataDir: dir, port: 0 }); | |
| 35 | servers = await serve(app); | |
| 36 | const mk = async (username: string, adminIfFirst?: boolean) => { | |
| 37 | const r = await createUser(app.db, { | |
| 38 | origin: "seed", trusted: true, username, email: `${username}@forge.local`, | |
| 39 | password: "a-good-passphrase", ...(adminIfFirst ? { adminIfFirst } : {}), | |
| 40 | }); | |
| 41 | if (!r.ok) throw new Error(r.error); | |
| 42 | return r.value; | |
| 43 | }; | |
| 44 | alice = await mk("alice", true); | |
| 45 | carol = await mk("carol"); | |
| 46 | app.db.run("UPDATE user SET is_admin = 1 WHERE id = ?", [carol.id]); | |
| 47 | const bob = await mk("bob"); | |
| 48 | tok.alice = createToken(app.db, alice.id, "t", "admin").token; | |
| 49 | tok.carol = createToken(app.db, carol.id, "t", "admin").token; | |
| 50 | tok.bob = createToken(app.db, bob.id, "t", "admin").token; | |
| 51 | const w = await writeBoard(app, alice, "mine", { title: "Mine", range: "all", refresh: "off", panels: [panel("Pin A"), panel("Pin B"), panel("Pin C")] }); | |
| 52 | if (!w.ok) throw new Error(JSON.stringify(w.error)); | |
| 53 | }); | |
| 54 | ||
| 55 | afterAll(async () => { | |
| 56 | servers.stop(); | |
| 57 | app.close(); | |
| 58 | await rm(dir, { recursive: true, force: true }); | |
| 59 | }); | |
| 60 | ||
| 61 | const pinIt = (t: string, who = "alice") => post("/boards/pin-panel", { slug: "mine", title: t, back: "/" }, who); | |
| 62 | ||
| 63 | describe("pinned panels", () => { | |
| 64 | test("pinning shows on home in the owner's order; moving reorders; unpinning removes", async () => { | |
| 65 | expect(titles(await home("alice"))).toEqual([]); | |
| 66 | for (const t of ["Pin A", "Pin B", "Pin C"]) expect((await pinIt(t)).status).toBe(303); | |
| 67 | await pinIt("Pin A"); // twice changes nothing | |
| 68 | expect(titles(await home("alice"))).toEqual(["Pin A", "Pin B", "Pin C"]); | |
| 69 | await post("/boards/move-pin", { slug: "mine", title: "Pin C", by: "-1" }); | |
| 70 | await post("/boards/move-pin", { slug: "mine", title: "Pin A", by: "-1" }); // at the top: stays | |
| 71 | expect(titles(await home("alice"))).toEqual(["Pin A", "Pin C", "Pin B"]); | |
| 72 | await post("/boards/unpin-panel", { slug: "mine", title: "Pin C" }); | |
| 73 | expect(titles(await home("alice"))).toEqual(["Pin A", "Pin B"]); | |
| 74 | }); | |
| 75 | ||
| 76 | test("the pin button is on the board page and flips to Unpin", async () => { | |
| 77 | const page = await (await fetch(servers.url + "/boards/mine", { headers: { authorization: `Bearer ${tok.alice}` } })).text(); | |
| 78 | expect(page).toContain("Unpin: Pin A"); | |
| 79 | expect(page).toContain("Pin to home: Pin C"); | |
| 80 | }); | |
| 81 | ||
| 82 | test("another person's pins never show", async () => { | |
| 83 | expect(titles(await home("carol"))).toEqual([]); | |
| 84 | await pinIt("Pin C", "carol"); | |
| 85 | expect(titles(await home("carol"))).toEqual(["Pin C"]); | |
| 86 | expect(titles(await home("alice"))).toEqual(["Pin A", "Pin B"]); | |
| 87 | await post("/boards/unpin-panel", { slug: "mine", title: "Pin A" }, "carol"); // not carol's to unpin | |
| 88 | expect(listPins(app.db, alice.id).map((p) => p.title)).toEqual(["Pin A", "Pin B"]); | |
| 89 | }); | |
| 90 | ||
| 91 | test("refusals: cross-origin, a stranger, an unknown panel, a bad slug", async () => { | |
| 92 | expect((await post("/boards/pin-panel", { slug: "mine", title: "Pin C" }, "alice", { origin: "https://evil.example" })).status).toBe(403); | |
| 93 | expect((await pinIt("Pin C", "bob")).status).toBe(404); | |
| 94 | expect((await pinIt("No such panel")).status).toBe(404); | |
| 95 | expect((await post("/boards/pin-panel", { slug: "../x", title: "Pin A" })).status).toBe(303); // flashed back, nothing stored | |
| 96 | expect(listPins(app.db, alice.id)).toHaveLength(2); | |
| 97 | expect(pinOf("mine", "a\u0000b")).toBeUndefined(); | |
| 98 | }); | |
| 99 | ||
| 100 | test("a pin whose panel has gone is skipped, not an error", async () => { | |
| 101 | app.db.run("INSERT INTO board_pin (user_id, slug, title, pos) VALUES (?, 'gone', 'x', 9)", [alice.id]); | |
| 102 | expect(titles(await home("alice"))).toEqual(["Pin A", "Pin B"]); | |
| 103 | }); | |
| 104 | ||
| 105 | test("pins you can't see do not count toward the limit", async () => { | |
| 106 | const many = Array.from({ length: MAX_PINS - 2 }, (_, i) => panel(`Old ${i}`)); | |
| 107 | const w = await writeBoard(app, alice, "old", { title: "Old", range: "all", refresh: "off", panels: many }); | |
| 108 | expect(w.ok).toBe(true); | |
| 109 | for (const p of many) expect((await pinPanel(app, alice, "old", p.title)).ok).toBe(true); | |
| 110 | expect((await pinPanel(app, alice, "mine", "Pin C")).ok).toBe(false); // full: refused | |
| 111 | await deleteBoard(app, alice, "old"); // every one of those pins now names nothing | |
| 112 | const again = await pinPanel(app, alice, "mine", "Pin C"); | |
| 113 | expect(again.ok).toBe(true); | |
| 114 | expect(listPins(app.db, alice.id).map((p) => p.title)).toEqual(["Pin A", "Pin B", "Pin C"]); | |
| 115 | const renamed = await changeBoard(app, alice, "mine", { kind: "edit", index: 2, panel: panel("Pin C2") }); | |
| 116 | expect(renamed.ok).toBe(true); | |
| 117 | expect((await pinPanel(app, alice, "mine", "Pin C2")).ok).toBe(true); | |
| 118 | expect(listPins(app.db, alice.id).map((p) => p.title)).toEqual(["Pin A", "Pin B", "Pin C2"]); | |
| 119 | }); | |
| 120 | ||
| 121 | test("the JSON API lists, pins, moves and unpins; others are refused", async () => { | |
| 122 | const api = (who: string, method: string, body?: unknown) => | |
| 123 | fetch(servers.url + "/api/v1/boards/pins", { | |
| 124 | method, headers: { authorization: `Bearer ${tok[who]}`, "content-type": "application/json" }, | |
| 125 | ...(body === undefined ? {} : { body: JSON.stringify(body) }), | |
| 126 | }); | |
| 127 | const titlesOf = async (who: string) => ((await (await api(who, "GET")).json()) as { pins: { title: string }[] }).pins.map((p) => p.title); | |
| 128 | expect(await titlesOf("alice")).toEqual(["Pin A", "Pin B", "Pin C2"]); | |
| 129 | expect((await api("alice", "POST", { op: "move", slug: "mine", title: "Pin C2", by: -1 })).status).toBe(200); | |
| 130 | expect(await titlesOf("alice")).toEqual(["Pin A", "Pin C2", "Pin B"]); | |
| 131 | expect((await api("alice", "POST", { op: "unpin", slug: "mine", title: "Pin A" })).status).toBe(200); | |
| 132 | expect((await api("alice", "POST", { op: "pin", slug: "mine", title: "Pin A" })).status).toBe(200); | |
| 133 | expect(await titlesOf("alice")).toEqual(["Pin C2", "Pin B", "Pin A"]); | |
| 134 | expect((await api("alice", "POST", { op: "pin", slug: "mine", title: "nope" })).status).toBe(404); | |
| 135 | expect((await api("alice", "POST", { op: "dance" })).status).toBe(422); | |
| 136 | expect((await api("bob", "GET")).status).toBe(403); | |
| 137 | expect((await api("bob", "POST", { op: "pin", slug: "mine", title: "Pin A" })).status).toBe(403); | |
| 138 | expect(await titlesOf("carol")).toEqual([]); // her "Pin C" was renamed away: not shown | |
| 139 | }); | |
| 140 | }); |
test/mcp.test.ts +33
| @@ -239,6 +239,7 @@ describe("the forge over MCP", () => { | ||
| 239 | 239 | "signup", |
| 240 | 240 | "boards", |
| 241 | 241 | "board_write", |
| 242 | "board_pins", | |
| 242 | 243 | "board_delete", |
| 243 | 244 | "report", |
| 244 | 245 | ]); |
| @@ -820,6 +821,38 @@ describe("the forge over MCP", () => { | ||
| 820 | 821 | expect(limitChanges(app.db)[0]).toMatchObject({ key }); |
| 821 | 822 | }); |
| 822 | 823 | |
| 824 | test("board_pins: list, pin, move, unpin for an admin person; an agent and a non-admin are refused", async () => { | |
| 825 | const board = { title: "Pinnable", range: "24h", refresh: "off", panels: [ | |
| 826 | { title: "One", chart: "number", query: { source: "runs", aggregate: "count" }, w: 1, h: 1 }, | |
| 827 | { title: "Two", chart: "number", query: { source: "runs", aggregate: "count" }, w: 1, h: 1 }, | |
| 828 | ] }; | |
| 829 | expect((await tool(rw, "board_write", { slug: "pinnable", board })).value).toMatchObject({ slug: "pinnable" }); | |
| 830 | const titles = async () => (await tool(rw, "board_pins")).value.pins.map((p: any) => p.title); | |
| 831 | expect(await titles()).toEqual([]); | |
| 832 | expect((await tool(rw, "board_pins", { op: "pin", slug: "pinnable", title: "One" })).value).toMatchObject({ slug: "pinnable", title: "One" }); | |
| 833 | await tool(rw, "board_pins", { op: "pin", slug: "pinnable", title: "Two" }); | |
| 834 | expect(await titles()).toEqual(["One", "Two"]); | |
| 835 | await tool(rw, "board_pins", { op: "move", slug: "pinnable", title: "Two", by: -1 }); | |
| 836 | expect(await titles()).toEqual(["Two", "One"]); | |
| 837 | expect((await tool(rw, "board_pins", { op: "move", slug: "pinnable", title: "Nope", by: 1 })).error).toContain("not_found"); | |
| 838 | expect((await tool(rw, "board_pins", { op: "pin", slug: "pinnable", title: "Nope" })).error).toContain("not_found"); | |
| 839 | await tool(rw, "board_pins", { op: "unpin", slug: "pinnable", title: "One" }); | |
| 840 | expect(await titles()).toEqual(["Two"]); | |
| 841 | await tool(rw, "board_pins", { op: "unpin", slug: "pinnable", title: "Two" }); | |
| 842 | expect(await titles()).toEqual([]); | |
| 843 | ||
| 844 | const made = await createAgent(app, alice, { username: "pinbot", brief: "x" }); | |
| 845 | if (!made.ok) throw new Error(String(made.error)); | |
| 846 | app.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(made.value.user.id); | |
| 847 | const botToken = createToken(app.db, made.value.user.id, "bot", "rw").token; | |
| 848 | for (const who of [bobRw, botToken]) { | |
| 849 | expect((await tool(who, "board_pins")).error).toContain("forbidden"); | |
| 850 | expect((await tool(who, "board_pins", { op: "pin", slug: "pinnable", title: "One" })).error).toContain("forbidden"); | |
| 851 | } | |
| 852 | expect(await titles()).toEqual([]); | |
| 853 | await tool(rw, "board_delete", { slug: "pinnable" }); | |
| 854 | }); | |
| 855 | ||
| 823 | 856 | test("boards: list, read, write and delete for an admin person; an agent and a non-admin are refused", async () => { |
| 824 | 857 | const board = { title: "Runs", range: "24h", refresh: "off", panels: [{ title: "Failed", chart: "number", query: { source: "runs", aggregate: "count" }, w: 1, h: 1 }] }; |
| 825 | 858 | const made = await createAgent(app, alice, { username: "boardbot", brief: "x" }); |
test/tui-boards.test.ts +63 −2
| @@ -7,11 +7,13 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | ||
| 7 | 7 | import { mkdtemp, rm } from "node:fs/promises"; |
| 8 | 8 | import { tmpdir } from "node:os"; |
| 9 | 9 | import { join } from "node:path"; |
| 10 | import { listPinned, pinPanel, unpinPanel } from "../src/actions/board-pins.ts"; | |
| 10 | 11 | import { writeBoard } from "../src/actions/boards.ts"; |
| 11 | 12 | import { createAgent } from "../src/actions/crew.ts"; |
| 12 | 13 | import { createApp, type App } from "../src/app.ts"; |
| 13 | 14 | import { createUser, type User } from "../src/core/users.ts"; |
| 14 | 15 | import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts"; |
| 16 | import { dashboardScreen } from "../src/tui/screens/dashboard.ts"; | |
| 15 | 17 | import { boardRows, boardsScreen } from "../src/tui/screens/boards.ts"; |
| 16 | 18 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 17 | 19 | import { quietLogs } from "./helpers/globals.ts"; |
| @@ -38,8 +40,9 @@ async function screenText(user?: User, keys = ""): Promise<string> { | ||
| 38 | 40 | tui.start(); |
| 39 | 41 | tui.ctx.push(boardsScreen(session, tui.ctx)); |
| 40 | 42 | await settleApp(tui); |
| 41 | if (keys !== "") { | |
| 42 | terminal.feed(keys); | |
| 43 | // One key at a time: a key that opens a screen must land before the next is read. | |
| 44 | for (const key of keys) { | |
| 45 | terminal.feed(key); | |
| 43 | 46 | await settleApp(tui); |
| 44 | 47 | } |
| 45 | 48 | const text = tui.screen.toText(); |
| @@ -106,4 +109,62 @@ describe("tui boards", () => { | ||
| 106 | 109 | } |
| 107 | 110 | expect(doors(carol)).toContain("boards"); |
| 108 | 111 | }); |
| 112 | ||
| 113 | test("p pins the marked panel, the dashboard lists it by title, p again unpins", async () => { | |
| 114 | const pinned = async () => { | |
| 115 | const listed = await listPinned(app, carol); | |
| 116 | return listed.ok ? listed.value.map((p) => p.title) : []; | |
| 117 | }; | |
| 118 | expect(await pinned()).toEqual([]); | |
| 119 | const text = await screenText(carol, "\rp"); | |
| 120 | expect(text).toContain("[pinned to home]"); | |
| 121 | expect(await pinned()).toEqual(["Failed runs"]); | |
| 122 | ||
| 123 | const terminal = headlessTerminal(120, 30); | |
| 124 | const { app: tui, session } = createForgeApp(app, identity(carol), { terminal, exitHooks: false }); | |
| 125 | tui.start(); | |
| 126 | tui.ctx.push(dashboardScreen(session, tui.ctx)); | |
| 127 | await settleApp(tui); | |
| 128 | expect(tui.screen.toText()).toContain("Pinned: Failed runs (Runs this week)"); | |
| 129 | tui.stop?.(); | |
| 130 | ||
| 131 | await screenText(carol, "\rp"); | |
| 132 | expect(await pinned()).toEqual([]); | |
| 133 | // Nobody else can pin from the terminal. | |
| 134 | await screenText(bob, "\rp"); | |
| 135 | const other = await listPinned(app, bob); | |
| 136 | expect(other.ok).toBe(false); | |
| 137 | }); | |
| 138 | ||
| 139 | test("> and < move the marked pinned panel in the home order; an unpinned one does not move", async () => { | |
| 140 | const wrote = await writeBoard(app, carol, "runs", { | |
| 141 | title: "Runs this week", | |
| 142 | range: "7d", | |
| 143 | refresh: "off", | |
| 144 | panels: [ | |
| 145 | { title: "Failed runs", chart: "number", query: { source: "runs", aggregate: "count" }, w: 1, h: 1 }, | |
| 146 | { title: "Slow runs", chart: "number", query: { source: "runs", aggregate: "count" }, w: 1, h: 1 }, | |
| 147 | ], | |
| 148 | }); | |
| 149 | expect(wrote.ok).toBe(true); | |
| 150 | const order = async () => { | |
| 151 | const listed = await listPinned(app, carol); | |
| 152 | return listed.ok ? listed.value.map((p) => p.title) : []; | |
| 153 | }; | |
| 154 | expect(await order()).toEqual([]); | |
| 155 | // Not pinned: nothing moves and nothing is claimed. | |
| 156 | expect(await screenText(carol, "\r>")).not.toContain("moved on home"); | |
| 157 | await pinPanel(app, carol, "runs", "Failed runs"); | |
| 158 | await pinPanel(app, carol, "runs", "Slow runs"); | |
| 159 | expect(await order()).toEqual(["Failed runs", "Slow runs"]); | |
| 160 | await screenText(carol, "\r>"); | |
| 161 | expect(await order()).toEqual(["Slow runs", "Failed runs"]); | |
| 162 | await screenText(carol, "\r<"); | |
| 163 | expect(await order()).toEqual(["Failed runs", "Slow runs"]); | |
| 164 | // At the top already: it stays. | |
| 165 | await screenText(carol, "\r<"); | |
| 166 | expect(await order()).toEqual(["Failed runs", "Slow runs"]); | |
| 167 | unpinPanel(app, carol, "runs", "Failed runs"); | |
| 168 | unpinPanel(app, carol, "runs", "Slow runs"); | |
| 169 | }); | |
| 109 | 170 | }); |
This returns
ok(which)even when the panel is not pinned, becausemovePindoes nothing in that case. The page then says "Moved.", the API answers 200 and the terminal says "moved on home". Returnerr("not_found")when the pin is not inlistPins(app.db, who.id).