Public: anyone can read this; the crew can change it.
Boards, step 6b: the Releases and Apps starting boards #2473
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2459-boards-step-6b-the-releases-and-apps-sta → main 4 commits fork 5 threads · 5 unresolved
README.md +1 −1
| @@ -1247,7 +1247,7 @@ work here. | ||
| 1247 | 1247 | `forge/boards`, a repo the forge owns and makes on the first boot with an admin; every save is a commit |
| 1248 | 1248 | by the admin who made it, and a file that does not validate is not shown. Make, rename, |
| 1249 | 1249 | duplicate, delete, add/edit/remove/reorder panels, or pin the query on `/boards/data`. |
| 1250 | The first boot also writes four starting boards, **Usage**, **Crew**, **Security** (sign-ins, failed sign-ins, tokens by scope, admin actions, from the admin-only `signins`, `tokens` and `adminActions` sources and `usage`; no token text, hash, payload or IP) and **Throughput** (items entering each stage from issue to live, per hour, from the `stages` source), once each: `.offered` in that repo records them, so an edit stays and a deletion sticks. | |
| 1250 | The first boot also writes six starting boards, **Usage**, **Crew**, **Releases**, **Apps**, **Security** and **Throughput**. 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. | |
| 1251 | 1251 | A board with a refresh redraws its grid on that beat through the live-region swap |
| 1252 | 1252 | (`/boards/:slug/live`, etag'd). The same doors are in the API: `GET /api/v1/boards`, `GET|PUT|DELETE /api/v1/boards/:slug` |
| 1253 | 1253 | (an agent or anyone but an admin is 403; a panel whose query cannot run is refused with 422). The same four doors are MCP tools (`boards`, `board_write`, `board_delete`; `boards` with a `slug` reads one) and, for the same person, a **Boards** screen in the terminal that lists boards and shows a board's panels as text. No dependency was added. |
src/boards/sources.ts +16
| @@ -82,6 +82,22 @@ export const SOURCES: Readonly<Record<string, Source>> = { | ||
| 82 | 82 | preview_data: "text", created_at: "time", |
| 83 | 83 | }, |
| 84 | 84 | }, |
| 85 | // One row per awake interval of an app environment (`core/meter.ts`): it | |
| 86 | // wakes when the row begins and sleeps when it ends, so the two are two | |
| 87 | // sources over one table, each timed by its own edge. | |
| 88 | wakes: { | |
| 89 | table: "meter", | |
| 90 | owner: "repo_id", | |
| 91 | time: "started_at", | |
| 92 | columns: { repo_id: "text", placement: "text", started_at: "time", ended_at: "time" }, | |
| 93 | }, | |
| 94 | sleeps: { | |
| 95 | table: "meter", | |
| 96 | owner: "repo_id", | |
| 97 | where: "ended_at IS NOT NULL", | |
| 98 | time: "ended_at", | |
| 99 | columns: { repo_id: "text", placement: "text", started_at: "time", ended_at: "time" }, | |
| 100 | }, | |
| 85 | 101 | decisions: { |
| 86 | 102 | table: "decision", |
| 87 | 103 | owner: "repo_id", |
src/boards/starting.ts +28 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | /** |
| 2 | * The boards the forge offers a new install: Usage, Crew, Security and Throughput. They are plain | |
| 2 | * The boards the forge offers a new install: Usage, Crew, Releases, Apps, Security and Throughput. They are plain | |
| 3 | 3 | * boards in `forge/boards` once written, so a person edits or deletes them |
| 4 | 4 | * like any other; `.offered` (a JSON list of slugs, committed with the |
| 5 | 5 | * boards) records what was offered so a deletion sticks and an edit is |
| @@ -43,6 +43,33 @@ export const STARTING_BOARDS: Readonly<Record<string, Board>> = { | ||
| 43 | 43 | panel("Requests per hour", "bar", { source: "usage", aggregate: "sum", column: "n", groupBy: "hour", series: "status" }, 2), |
| 44 | 44 | ], |
| 45 | 45 | }, |
| 46 | releases: { | |
| 47 | title: "Releases", | |
| 48 | range: "30d", | |
| 49 | refresh: "1m", | |
| 50 | panels: [ | |
| 51 | panel("Deploys per day", "bar", { source: "deploys", aggregate: "count", groupBy: { bucket: "day" }, series: "state" }, 2), | |
| 52 | panel("Deploys by state", "bar", { source: "deploys", aggregate: "count", groupBy: "state" }), | |
| 53 | panel("Deploys gone live per day", "line", { | |
| 54 | source: "deploys", aggregate: "count", groupBy: { bucket: "day" }, filter: [{ column: "serving_at", op: "gt", value: "" }], | |
| 55 | }), | |
| 56 | panel("Slowest decision per day (ms)", "line", { source: "decisions", aggregate: "max", column: "ms", groupBy: { bucket: "day" } }, 2), | |
| 57 | panel("Decider time per hour (ms)", "line", { source: "decisions", aggregate: "avg", column: "ms", groupBy: HOUR, series: "kind" }, 2), | |
| 58 | ], | |
| 59 | }, | |
outdated
Series by |
||
| 60 | apps: { | |
| 61 | title: "Apps", | |
| 62 | range: "7d", | |
| 63 | refresh: "1m", | |
| 64 | panels: [ | |
| 65 | panel("Apps made per day", "bar", { source: "apps", aggregate: "count", groupBy: { bucket: "day" }, series: "access" }), | |
| 66 | panel("Apps by access", "bar", { source: "apps", aggregate: "count", groupBy: "access" }), | |
outdated
Repo ids as group labels are opaque on a phone. This is fine for now because the title says so, but a name column would be the first improvement. |
||
| 67 | panel("Wakes per app (by repo id)", "bar", { source: "wakes", aggregate: "count", groupBy: "repo_id", series: "placement" }, 2), | |
| 68 | panel("Sleeps per app (by repo id)", "bar", { source: "sleeps", aggregate: "count", groupBy: "repo_id", series: "placement" }, 2), | |
| 69 | panel("Wakes per hour", "line", { source: "wakes", aggregate: "count", groupBy: HOUR }, 2), | |
Repo ids on the axis are opaque to a person. Once a source exposes a safe app name, group by that and drop "(by repo id)" from the titles. |
||
| 70 | panel("Requests per hour by surface", "bar", { source: "usage", aggregate: "sum", column: "n", groupBy: "hour", series: "surface" }, 2), | |
| 71 | ], | |
| 72 | }, | |
| 46 | 73 | crew: { |
| 47 | 74 | title: "Crew", |
| 48 | 75 | range: "7d", |
test/boards-query.test.ts +28
| @@ -354,3 +354,31 @@ describe("boards API", () => { | ||
| 354 | 354 | expect(((await res.json()) as { error: string }).error).toBe("too_large"); |
| 355 | 355 | }); |
| 356 | 356 | }); |
| 357 | ||
| 358 | describe("wakes and sleeps", () => { | |
| 359 | const wakes = { source: "wakes", aggregate: "count", groupBy: "repo_id" }; | |
| 360 | const sleeps = { source: "sleeps", aggregate: "count", groupBy: "repo_id" }; | |
| 361 | beforeAll(() => { | |
| 362 | db.run( | |
| 363 | `INSERT INTO meter (repo_id, env, placement, started_at, beat_at, ended_at) VALUES | |
| 364 | ('r1','production','owned','2026-10-05T10:00:00.000Z','2026-10-05T11:00:00.000Z','2026-10-05T11:00:00.000Z'), | |
| 365 | ('r1','pull-1','owned','2026-10-06T10:00:00.000Z','2026-10-06T10:30:00.000Z',NULL), | |
| 366 | ('r2','production','cloud','2026-10-05T12:00:00.000Z','2026-10-05T13:00:00.000Z','2026-10-05T13:00:00.000Z')`, | |
| 367 | ); | |
| 368 | }); | |
| 369 | test("a wake counts every interval, open or ended, by when it began", () => { | |
| 370 | expect(runQuery(db, wakes, ALL)).toEqual({ ok: true, rows: [{ group: "r1", value: 2 }, { group: "r2", value: 1 }] }); | |
| 371 | expect(runQuery(db, { ...wakes, from: "2026-10-06T00:00:00Z" }, ALL)).toEqual({ ok: true, rows: [{ group: "r1", value: 1 }] }); | |
| 372 | }); | |
| 373 | test("a sleep counts only the ended interval, by when it ended", () => { | |
| 374 | expect(runQuery(db, sleeps, ALL)).toEqual({ ok: true, rows: [{ group: "r1", value: 1 }, { group: "r2", value: 1 }] }); | |
| 375 | expect(runQuery(db, { ...sleeps, from: "2026-10-05T12:00:00Z" }, ALL)).toEqual({ ok: true, rows: [{ group: "r2", value: 1 }] }); | |
| 376 | }); | |
| 377 | test("the repo scope narrows both", () => { | |
| 378 | expect(runQuery(db, wakes, R1)).toEqual({ ok: true, rows: [{ group: "r1", value: 2 }] }); | |
| 379 | expect(runQuery(db, sleeps, R1)).toEqual({ ok: true, rows: [{ group: "r1", value: 1 }] }); | |
| 380 | }); | |
| 381 | test("the environment slug is not a column", () => { | |
| 382 | expect(runQuery(db, { ...wakes, series: "env" }, ALL).ok).toBe(false); | |
| 383 | }); | |
| 384 | }); |
test/boards-starting.test.ts +4 −4
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | /** |
| 2 | * The forge offers the Usage, Crew, Security and Throughput boards on boot: every panel runs, an | |
| 2 | * The forge offers the six starting boards on boot: every panel runs, an | |
outdated
Typo: 'the the four starting boards boards'. Also add a test of the wakes and sleeps sources against seeded meter rows. |
||
| 3 | 3 | * edit survives the next boot, and a deletion is not undone. |
| 4 | 4 | */ |
| 5 | 5 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| @@ -36,10 +36,10 @@ test("a forge with no person to found the repo writes nothing", async () => { | ||
| 36 | 36 | expect(await offerStartingBoards(bare)).toEqual([]); |
| 37 | 37 | }); |
| 38 | 38 | |
| 39 | test("a fresh boot writes Usage, Crew, Security and Throughput, and every panel runs", async () => { | |
| 40 | expect((await offerStartingBoards(app)).sort()).toEqual(["crew", "security", "throughput", "usage"]); | |
| 39 | test("a fresh boot writes the six starting boards, and every panel runs", async () => { | |
| 40 | expect((await offerStartingBoards(app)).sort()).toEqual(["apps", "crew", "releases", "security", "throughput", "usage"]); | |
| 41 | 41 | const boards = await listBoards(app.reposRoot); |
| 42 | expect(boards.map((b) => b.slug)).toEqual(["crew", "security", "throughput", "usage"]); | |
| 42 | expect(boards.map((b) => b.slug)).toEqual(["apps", "crew", "releases", "security", "throughput", "usage"]); | |
| 43 | 43 | let panels = 0; |
| 44 | 44 | for (const { board } of boards) { |
| 45 | 45 | for (const p of board.panels) { |
envis a free-text environment slug, which the header of this file says is never listed. Remove it here and insleepsbelow.