Public: anyone can read this; the crew can change it.
Home's KPI row shows crew builders running of slots, linking to the crew page #2972
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2967-home-s-kpi-row-shows-crew-builders-runni → main 3 commits fork 2 threads · 2 unresolved
6 files
changed +84
−22
src/flow/kpi_words.ts +7 −1
| @@ -26,8 +26,13 @@ export function sparkText(hours: readonly number[]): string { | ||
| 26 | 26 | .join(""); |
| 27 | 27 | } |
| 28 | 28 | |
| 29 | /** Builders running of their slots: `2 of 4`, or an em dash when no slots are set. */ | |
| 30 | export function buildersValue(b: Kpis["builders"]): string { | |
| 31 | return b.slots === 0 ? "—" : `${b.running} of ${b.slots}`; | |
| 32 | } | |
| 33 | ||
| 29 | 34 | /** |
| 30 | * The terminal's row: the same three figures in the same words, as plain | |
| 35 | * The terminal's row: the same four figures in the same words, as plain | |
| 31 | 36 | * lines. Forge-wide like the web's, so the caller shows them to an admin only. |
| 32 | 37 | */ |
| 33 | 38 | export function kpiLines(k: Kpis, live: { sha: string; since: string } | null): string[] { |
| @@ -35,5 +40,6 @@ export function kpiLines(k: Kpis, live: { sha: string; since: string } | null): | ||
| 35 | 40 | `Shipped per hour ${(k.landed / k.landedHours.length).toFixed(1)} ${sparkText(k.landedHours)} ${k.landed} ${k.landed === 1 ? "change" : "changes"} in 24 h`, |
| 36 | 41 | `Live version ${live === null ? "— nothing live yet" : `${live.sha.slice(0, 7)}, live ${live.since}`}`, |
| 37 | 42 | `Median check time ${checkTime(k.medianCheckMs)}`, |
| 43 | `Builders running ${buildersValue(k.builders)}${k.builders.slots === 0 ? ", no slots set" : ""}`, | |
| 38 | 44 | ]; |
| 39 | 45 | } |
src/flow/kpis.ts +10
| @@ -27,6 +27,8 @@ export interface Kpis { | ||
| 27 | 27 | readonly live: { readonly sha: string; readonly at: string } | null; |
| 28 | 28 | /** Median check duration in milliseconds over checks that finished in 24 h; null for none. */ |
| 29 | 29 | readonly medianCheckMs: number | null; |
| 30 | /** Crew builders running now, of the slots the autonomous repositories are set to. */ | |
| 31 | readonly builders: { readonly running: number; readonly slots: number }; | |
| 30 | 32 | } |
| 31 | 33 | |
| 32 | 34 | export function readKpis(db: Database, now: Date): Kpis { |
| @@ -70,7 +72,15 @@ export function readKpis(db: Database, now: Date): Kpis { | ||
| 70 | 72 | .all(since, to) |
| 71 | 73 | .map((r) => Math.round(r.ms)); |
| 72 | 74 | |
| 75 | const builders = db | |
| 76 | .query<{ running: number; slots: number }, []>( | |
| 77 | `SELECT (SELECT COUNT(*) FROM run WHERE kind = 'agent' AND state = 'running') AS running, | |
| 78 | (SELECT COALESCE(SUM(autonomy_slots), 0) FROM repo WHERE autonomy != 'off') AS slots`, | |
| 79 | ) | |
| 80 | .get() ?? { running: 0, slots: 0 }; | |
| 81 | ||
| 73 | 82 | return { |
| 83 | builders, | |
| 74 | 84 | landedHours, |
| 75 | 85 | landed: landedHours.reduce((a, b) => a + b, 0), |
| 76 | 86 | live: live ?? null, |
src/web/studio/kpi_row.ts +14 −5
| @@ -1,12 +1,13 @@ | ||
| 1 | 1 | /** |
| 2 | * Home's row of three figures (#2871), from `flow/kpis.ts`: shipped per hour | |
| 3 | * with a day's sparkline, the live version and since when, and the median | |
| 4 | * check time. Server-rendered, one fixed-height card each, so nothing moves | |
| 5 | * when it paints. Only the studio's words: shipped, change, check time. | |
| 2 | * Home's row of four figures (#2871), from `flow/kpis.ts`: shipped per hour | |
| 3 | * with a day's sparkline, the live version and since when, the median check | |
| 4 | * time, and the crew builders running of their slots. Server-rendered, one | |
| 5 | * fixed-height card each, so nothing moves when it paints. Only the studio's | |
| 6 | * words: shipped, change, check time. | |
| 6 | 7 | */ |
| 7 | 8 | |
| 8 | 9 | import type { Database } from "bun:sqlite"; |
| 9 | import { checkTime } from "../../flow/kpi_words.ts"; | |
| 10 | import { buildersValue, checkTime } from "../../flow/kpi_words.ts"; | |
| 10 | 11 | import { readKpis, type Kpis } from "../../flow/kpis.ts"; |
| 11 | 12 | import { html, raw, type Html } from "../html.ts"; |
| 12 | 13 | import { BOOTED } from "../../release/gate.ts"; |
| @@ -72,6 +73,14 @@ export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null): Ht | ||
| 72 | 73 | k.medianCheckMs === null ? "No check finished in 24 h" : "over the last 24 h", |
| 73 | 74 | k.medianCheckMs === null ? "No check finished in 24 h" : "over the last 24 h", |
| 74 | 75 | )} |
| 76 | ${tile( | |
| 77 | "Builders running", | |
| 78 | "/settings/crew", | |
| 79 | buildersValue(k.builders), | |
| 80 | buildersValue(k.builders), | |
outdated
A note of "slots in use now" under "2 of 4" repeats the value. Use "running now" and "No slots set" for zero. Also show an em dash for the value when slots is 0. |
||
| 81 | k.builders.slots === 0 ? "No slots set" : "running now", | |
| 82 | k.builders.slots === 0 ? "No slots set" : `Crew builders running now, of ${k.builders.slots} ${k.builders.slots === 1 ? "slot" : "slots"}`, | |
| 83 | )} | |
| 75 | 84 | </section>`; |
| 76 | 85 | } |
| 77 | 86 |
test/home-kpi-page.test.ts +3 −3
| @@ -25,7 +25,7 @@ afterAll(async () => { | ||
| 25 | 25 | await rm(dataDir, { recursive: true, force: true }); |
| 26 | 26 | }); |
| 27 | 27 | |
| 28 | test("home renders the three tiles; only an admin sees them", async () => { | |
| 28 | test("home renders the four tiles; only an admin sees them", async () => { | |
| 29 | 29 | const mk = async (username: string, first = false): Promise<User> => { |
| 30 | 30 | const r = await createUser(app.db, { |
| 31 | 31 | origin: "seed", |
| @@ -50,8 +50,8 @@ test("home renders the three tiles; only an admin sees them", async () => { | ||
| 50 | 50 | |
| 51 | 51 | const a = await home(admin); |
| 52 | 52 | expect(a).toContain('class="kpis"'); |
| 53 | expect((a.match(/class="card kpi"/g) ?? []).length).toBe(3); | |
| 54 | for (const href of ["/board", "/releases", "/boards"]) expect(a).toContain(`class="card kpi" href="${href}"`); | |
| 53 | expect((a.match(/class="card kpi"/g) ?? []).length).toBe(4); | |
| 54 | for (const href of ["/board", "/releases", "/boards", "/settings/crew"]) expect(a).toContain(`class="card kpi" href="${href}"`); | |
| 55 | 55 | |
| 56 | 56 | // The figures count every repository, so a non-admin sees none. |
| 57 | 57 | expect(await home(bob)).not.toContain('class="kpis"'); |
test/home-kpi-row.test.ts +47 −10
| @@ -1,4 +1,4 @@ | ||
| 1 | /** Home's figures row (#2871): the three tiles, their links, studio words only, a sparkline of 24 points. */ | |
| 1 | /** Home's figures row (#2871): the four tiles, their links, studio words only, a sparkline of 24 points. */ | |
| 2 | 2 | import { describe, expect, test } from "bun:test"; |
| 3 | 3 | import { checkTime, kpiLines, sparkText } from "../src/flow/kpi_words.ts"; |
| 4 | 4 | import { running } from "../src/kernel/version.ts"; |
| @@ -8,9 +8,9 @@ import { kpiRowFor, liveNow, sparkline } from "../src/web/studio/kpi_row.ts"; | ||
| 8 | 8 | const hours = (n: number) => Array.from({ length: 24 }, (_, i) => (i === 3 ? n : 0)); |
| 9 | 9 | |
| 10 | 10 | describe("home kpi row", () => { |
| 11 | test("three tiles with their links and figures", () => { | |
| 11 | test("four tiles with their links and figures", () => { | |
| 12 | 12 | const out = kpiRowFor( |
| 13 | { landedHours: hours(6), landed: 6, live: null, medianCheckMs: 185_000 }, | |
| 13 | { landedHours: hours(6), landed: 6, live: null, builders: { running: 2, slots: 4 }, medianCheckMs: 185_000 }, | |
| 14 | 14 | { sha: "abcdef0123456", at: "2026-10-19T11:00:00.000Z" }, |
| 15 | 15 | ).toString(); |
| 16 | 16 | expect(out).toContain('<span class="sha">abcdef0</span>'); |
| @@ -25,7 +25,7 @@ describe("home kpi row", () => { | ||
| 25 | 25 | |
| 26 | 26 | test("the whole figure is in the title and the link's label, rounded and in studio words", () => { |
| 27 | 27 | const out = kpiRowFor( |
| 28 | { landedHours: hours(7), landed: 7, live: null, medianCheckMs: 185_000 }, | |
| 28 | { landedHours: hours(7), landed: 7, live: null, builders: { running: 2, slots: 4 }, medianCheckMs: 185_000 }, | |
| 29 | 29 | { sha: "abcdef0123456", at: "2026-10-19T11:00:00.000Z" }, |
| 30 | 30 | ).toString(); |
| 31 | 31 | expect(out).toContain('title="0.29"'); |
| @@ -37,13 +37,13 @@ describe("home kpi row", () => { | ||
| 37 | 37 | }); |
| 38 | 38 | |
| 39 | 39 | test("empty figures say so", () => { |
| 40 | const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, medianCheckMs: null }, null).toString(); | |
| 40 | const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, builders: { running: 2, slots: 4 }, medianCheckMs: null }, null).toString(); | |
| 41 | 41 | expect(out).toContain("Nothing live yet"); |
| 42 | 42 | expect(out).toContain("No check finished in 24 h"); |
| 43 | 43 | }); |
| 44 | 44 | |
| 45 | 45 | test("no git words render, not even in a title or label", () => { |
| 46 | const out = kpiRowFor({ landedHours: hours(1), landed: 1, live: null, medianCheckMs: 1000 }, null).toString(); | |
| 46 | const out = kpiRowFor({ landedHours: hours(1), landed: 1, live: null, builders: { running: 2, slots: 4 }, medianCheckMs: 1000 }, null).toString(); | |
| 47 | 47 | expect(out).not.toMatch(/commit|merge|branch|repositor|pull request|checks (passed|failed)/i); |
| 48 | 48 | }); |
| 49 | 49 | |
| @@ -67,7 +67,7 @@ describe("home kpi row", () => { | ||
| 67 | 67 | }); |
| 68 | 68 | |
| 69 | 69 | test("a truncated note carries its whole text as a title", () => { |
| 70 | const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, medianCheckMs: null }, null).toString(); | |
| 70 | const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, builders: { running: 2, slots: 4 }, medianCheckMs: null }, null).toString(); | |
| 71 | 71 | expect(out).toContain('title="No check finished in 24 h"'); |
| 72 | 72 | }); |
| 73 | 73 | |
| @@ -79,6 +79,26 @@ describe("home kpi row", () => { | ||
| 79 | 79 | expect(rule(".kpi")).toContain("grid-template-rows: 20px 32px 40px 24px"); |
| 80 | 80 | }); |
| 81 | 81 | |
| 82 | test("the builders tile says running of slots, titles it, and links to the crew", () => { | |
| 83 | const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, medianCheckMs: null, builders: { running: 2, slots: 4 } }, null).toString(); | |
| 84 | expect(out).toContain('href="/settings/crew"'); | |
| 85 | expect(out).toContain('class="kpi-value num" title="2 of 4">2 of 4<'); | |
| 86 | expect(out).toContain('title="Crew builders running now, of 4 slots"'); | |
| 87 | expect(out).toContain('aria-label="Builders running: 2 of 4, Crew builders running now, of 4 slots"'); | |
| 88 | }); | |
| 89 | ||
| 90 | test("with no slots set the builders tile shows a dash and says so", () => { | |
| 91 | const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, medianCheckMs: null, builders: { running: 0, slots: 0 } }, null).toString(); | |
| 92 | expect(out).toContain('class="kpi-value num" title="—">—<'); | |
| 93 | expect(out).toContain('title="No slots set">No slots set<'); | |
| 94 | expect(out).not.toContain("0 of 0"); | |
| 95 | }); | |
| 96 | ||
| 97 | test("the builders note does not repeat the value", () => { | |
| 98 | const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, medianCheckMs: null, builders: { running: 2, slots: 4 } }, null).toString(); | |
| 99 | expect(out).toContain(">running now<"); | |
| 100 | }); | |
| 101 | ||
| 82 | 102 | test("check time reads as a person would", () => { |
| 83 | 103 | expect(checkTime(null)).toBe("—"); |
| 84 | 104 | expect(checkTime(45_000)).toBe("45s"); |
| @@ -86,11 +106,28 @@ describe("home kpi row", () => { | ||
| 86 | 106 | }); |
| 87 | 107 | }); |
| 88 | 108 | |
| 109 | describe("the builders figure from the database", () => { | |
| 110 | test("counts running agent runs against the autonomous repositories' slots", async () => { | |
| 111 | const { Database } = await import("bun:sqlite"); | |
| 112 | const db = new Database(":memory:"); | |
| 113 | db.run("CREATE TABLE repo (autonomy TEXT, autonomy_slots INTEGER, default_branch TEXT, id TEXT)"); | |
| 114 | db.run("CREATE TABLE run (id TEXT, kind TEXT, state TEXT, started_at TEXT, finished_at TEXT, serving_at TEXT, env TEXT, commit_oid TEXT)"); | |
| 115 | db.run("CREATE TABLE pull (merged_at TEXT, merged_oid TEXT, base_oid TEXT, base_branch TEXT, issue_id TEXT)"); | |
| 116 | db.run("CREATE TABLE issue (id TEXT, repo_id TEXT)"); | |
| 117 | db.run("INSERT INTO repo VALUES ('autonomous', 3, 'main', 'a'), ('autonomous', 1, 'main', 'b'), ('off', 8, 'main', 'c')"); | |
| 118 | db.run("INSERT INTO run (id, kind, state) VALUES ('1','agent','running'), ('2','agent','running'), ('3','agent','queued'), ('4','check','running')"); | |
| 119 | const { readKpis } = await import("../src/flow/kpis.ts"); | |
| 120 | expect(readKpis(db, new Date()).builders).toEqual({ running: 2, slots: 4 }); | |
| 121 | }); | |
| 122 | }); | |
| 123 | ||
| 89 | 124 | describe("the terminal's figures", () => { |
| 90 | const k = { landedHours: hours(6), landed: 6, live: { sha: "abcdef0123456", at: new Date().toISOString() }, medianCheckMs: 185_000 }; | |
| 91 | test("the same three figures in the same words", () => { | |
| 125 | const k = { landedHours: hours(6), landed: 6, live: { sha: "abcdef0123456", at: new Date().toISOString() }, builders: { running: 2, slots: 4 }, medianCheckMs: 185_000 }; | |
| 126 | test("the same four figures in the same words", () => { | |
| 92 | 127 | const lines = kpiLines(k, { sha: "abcdef0123456", since: "5m ago" }); |
| 93 | expect(lines).toHaveLength(3); | |
| 128 | expect(lines).toHaveLength(4); | |
| 129 | expect(lines[3]).toBe("Builders running 2 of 4"); | |
| 130 | expect(kpiLines({ ...k, builders: { running: 0, slots: 0 } }, null)[3]).toBe("Builders running —, no slots set"); | |
| 94 | 131 | expect(lines[0]).toContain("Shipped per hour 0.3"); |
| 95 | 132 | expect(lines[0]).toContain("6 changes in 24 h"); |
| 96 | 133 | expect(lines[1]).toContain("Live version abcdef0"); |
test/home-kpis.test.ts +3 −3
| @@ -131,7 +131,7 @@ describe("home kpis", () => { | ||
| 131 | 131 | |
| 132 | 132 | describe("home kpi row titles", () => { |
| 133 | 133 | const full = kpiRowFor( |
| 134 | { landedHours: Array.from({ length: 24 }, (_, i) => (i === 0 ? 6 : 0)), landed: 6, live: null, medianCheckMs: 185_000 }, | |
| 134 | { landedHours: Array.from({ length: 24 }, (_, i) => (i === 0 ? 6 : 0)), landed: 6, live: null, builders: { running: 2, slots: 4 }, medianCheckMs: 185_000 }, | |
| 135 | 135 | { sha: "abcdef0123456789", at: "2026-10-19T11:00:00.000Z" }, |
| 136 | 136 | ).toString(); |
| 137 | 137 | |
| @@ -144,8 +144,8 @@ describe("home kpi row titles", () => { | ||
| 144 | 144 | }); |
| 145 | 145 | |
| 146 | 146 | test("no value or note lacks a title, and none brings back a git word", () => { |
| 147 | expect(full.match(/class="kpi-(value num|note small muted)"/g)).toHaveLength(6); | |
| 148 | expect(full.match(/class="kpi-(value num|note small muted)" title=/g)).toHaveLength(6); | |
| 147 | expect(full.match(/class="kpi-(value num|note small muted)"/g)).toHaveLength(8); | |
| 148 | expect(full.match(/class="kpi-(value num|note small muted)" title=/g)).toHaveLength(8); | |
| 149 | 149 | expect(full).not.toMatch(/commit|merge|branch|repositor|pull request/i); |
| 150 | 150 | }); |
| 151 | 151 | }); |
new above
offline
kpi_words.ts kpiLines does not render builders, so the terminal never shows this figure. Add it there.