Public: anyone can read this; the crew can change it.

Home's KPI row shows the age of the last good off-box backup, flagged past 24 hours, linking to /backups #3001

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/2969-home-s-kpi-row-shows-the-age-of-the-last → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/2969-home-s-kpi-row-shows-the-age-of-the-last was merged into main as debc849.

Review

Sign in to review this change.
8 files changed +125 −28
README.md +7 −3
@@ -254,15 +254,19 @@ settings until asked. `/?all=1` is the whole home, and its "Just my apps" link
254254 comes back. The terminal's first screen opens with the same lines, from the
255255 same query (`src/core/client-apps.ts`).
256256
257 Home shows five figures first, from `src/flow/kpis.ts`: shipped per hour (with
257 Home shows six figures first, from `src/flow/kpis.ts`: shipped per hour (with
258258 a 24 h sparkline), the live version and since when, the median check time, the crew builders
259259 running of their slots, and the crew's spend today of the forge's daily
260260 ceiling (`FORGE_CREW_CEILING_USD`; without one, the spend alone and
261 `no ceiling`). The spend tile links to `/settings/crew`, where the meter is.
261 `no ceiling`), and the age of the last off-box backup (the mirror's newest
262 snapshot, `mirror/state.json`; `3 h ago`, the exact time in its title), linking to
263 `/settings/backups`. The spend tile links to `/settings/crew`, where the meter is.
264 The backup tile past 24 hours, or with none (`never`), takes the card's existing
265 `data-tone="attn"` and says overdue in its label.
262266 They count every repository, so only a site admin sees them, on both doors;
263267 nobody else does, and neither does the lean client home. On the web each is a
264268 card linking to `/board`, `/releases` or `/boards`. The terminal's first screen
265 shows the same five as lines above the forge strip (`kpiLines` in
269 shows the first five as lines above the forge strip (`kpiLines` in
266270 `src/flow/kpi_words.ts`). They are read when the page or screen loads and
267271 do not refresh on their own.
268272
src/flow/kpi_words.ts +23
@@ -45,6 +45,29 @@ export function spendFull(s: Kpis["spend"]): string {
4545 return s.ceilingUsd === null ? cents(s.todayUsd) : `${cents(s.todayUsd)} of ${cents(s.ceilingUsd)}`;
4646 }
4747
48 const DAY_MS = 24 * 3_600_000;
49
50 /** Past a day, or with no backup at all, the last off-box backup is overdue. */
51 export function backupOverdue(at: string | null, now: Date): boolean {
52 return at === null || !(now.getTime() - Date.parse(at) <= DAY_MS);
53 }
54
55 /** The backup's age as a person reads it: `3 h ago`, `2 d ago`, or `never`. */
56 export function backupAge(at: string | null, now: Date): string {
57 const t = at === null ? NaN : Date.parse(at);
58 if (Number.isNaN(t)) return "never";
59 const min = Math.max(0, Math.floor((now.getTime() - t) / 60_000));
60 if (min < 1) return "just now";
61 if (min < 60) return `${min} min ago`;
62 if (min < 2880) return `${Math.floor(min / 60)} h ago`;
63 return `${Math.floor(min / 1440)} d ago`;
64 }
65
66 /** The exact time, for the title. */
67 export function backupFull(at: string | null): string {
68 return at === null || Number.isNaN(Date.parse(at)) ? "No off-box backup yet" : at;
69 }
70
4871 /**
4972 * The terminal's row: the same five figures in the same words, as plain
5073 * lines. Forge-wide like the web's, so the caller shows them to an admin only.
src/flow/kpis.ts +11 −1
@@ -15,6 +15,7 @@
1515
1616 import type { Database } from "bun:sqlite";
1717 import { spentTodayEverywhere } from "../crew/autonomy.ts";
18 import { lastMirror } from "../snapshot/mirror.ts";
1819 import { HOURS, median } from "./throughput.ts";
1920
2021 const HOUR_MS = 3_600_000;
@@ -32,9 +33,15 @@ export interface Kpis {
3233 readonly builders: { readonly running: number; readonly slots: number };
3334 /** What the crew's runs cost since UTC midnight, and the forge's daily ceiling (null: none set). */
3435 readonly spend: { readonly todayUsd: number; readonly ceilingUsd: number | null };
36 /** When the off-box mirror last finished holding a snapshot; null when it never has. */
37 readonly backupAt: string | null;
3538 }
3639
37 export function readKpis(db: Database, now: Date, forge: { readonly ceilingUsd: number | null } = { ceilingUsd: null }): Kpis {
40 export function readKpis(
41 db: Database,
42 now: Date,
43 forge: { readonly ceilingUsd: number | null; readonly dataDir?: string } = { ceilingUsd: null },
44 ): Kpis {
3845 const end = now.getTime();
3946 const to = now.toISOString();
4047 const since = new Date(end - HOURS * HOUR_MS).toISOString();
@@ -84,7 +91,10 @@ export function readKpis(db: Database, now: Date, forge: { readonly ceilingUsd:
8491
8592 const todayUsd = spentTodayEverywhere(db, now);
8693
94 const backupAt = forge.dataDir === undefined ? null : (lastMirror(forge.dataDir)?.newestAt ?? null);
95
8796 return {
97 backupAt,
8898 builders,
8999 spend: { todayUsd, ceilingUsd: forge.ceilingUsd },
90100 landedHours,
src/web/studio/home.ts +1 −1
@@ -246,7 +246,7 @@ export async function studioHome(
246246 const ask = firstSteps ?? everydayCard(ctx, user);
247247 const figures = user.isAdmin && steps === undefined;
248248 const ceilingUsd = ctx.app.config.crewCeilingUsd;
249 const kpiOpts = ceilingUsd === undefined ? {} : { ceilingUsd };
249 const kpiOpts = { dataDir: ctx.app.config.dataDir, ...(ceilingUsd === undefined ? {} : { ceilingUsd }) };
250250 const body = html`<h1 class="sr-only">Home</h1>
251251 ${ask} ${address} ${figures ? kpiRow(ctx.app.db, kpiOpts) : liveLine(ctx)}
252252 ${when(
src/web/studio/kpi_row.ts +21 −8
@@ -1,14 +1,14 @@
11 /**
2 * Home's row of five figures (#2871), from `flow/kpis.ts`: shipped per hour
2 * Home's row of six figures (#2871), from `flow/kpis.ts`: shipped per hour
33 * with a day's sparkline, the live version and since when, the median check
44 * time, the crew builders running of their slots, and the crew's spend today
5 * of the daily ceiling. Server-rendered, one fixed-height card each, so
5 * of the daily ceiling, and the age of the last off-box backup. Server-rendered, one fixed-height card each, so

This line runs far past the wrapping of the lines around it; re-wrap the comment.

66 * nothing moves when it paints. Only the studio's words: shipped, change,
77 * check time.
88 */
99
1010 import type { Database } from "bun:sqlite";
11 import { buildersValue, checkTime, spendFull, spendValue } from "../../flow/kpi_words.ts";
11 import { backupAge, backupFull, backupOverdue, buildersValue, checkTime, spendFull, spendValue } from "../../flow/kpi_words.ts";
1212 import { readKpis, type Kpis } from "../../flow/kpis.ts";
1313 import { html, raw, type Html } from "../html.ts";
1414 import { BOOTED } from "../../release/gate.ts";
@@ -31,8 +31,8 @@ export function sparkline(hours: readonly number[]): Html {
3131 );
3232 }
3333
34 function tile(label: string, href: string, value: Html | string, full: string, note: string | Html, title: string, extra: Html | null = null): Html {
35 return html`<a class="card kpi" href="${href}" aria-label="${label}: ${full}, ${title}">
34 function tile(label: string, href: string, value: Html | string, full: string, note: string | Html, title: string, extra: Html | null = null, tone: "attn" | null = null): Html {
35 return html`<a class="card kpi" href="${href}"${tone === null ? "" : raw(` data-tone="${tone}"`)} aria-label="${label}: ${full}, ${title}">
3636 <span class="small muted">${label}</span>
3737 <span class="kpi-value num" title="${full}">${value}</span>
3838 <span class="kpi-note small muted" title="${title}">${note}</span>
@@ -46,7 +46,8 @@ export function liveNow(): { sha: string; at: string } | null {
4646 return commit === null ? null : { sha: commit, at: BOOTED };
4747 }
4848
49 export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null): Html {
49 export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null, now: Date = new Date()): Html {
50 const overdue = backupOverdue(k.backupAt, now);
5051 const rate = k.landed / k.landedHours.length;
5152 return html`<section class="kpis" aria-label="The last 24 hours, as of this page load">
5253 ${tile(
@@ -90,10 +91,22 @@ export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null): Ht
9091 k.spend.ceilingUsd === null ? "no ceiling" : "of the daily ceiling",
9192 k.spend.ceilingUsd === null ? "No daily ceiling is set" : "Crew spend today (UTC), of the daily ceiling",
9293 )}
94 ${tile(
95 "Last off-box backup",
96 "/settings/backups",
97 backupAge(k.backupAt, now),
98 backupFull(k.backupAt),
99 k.backupAt === null ? "no backup off the machine yet" : overdue ? "overdue, more than 24 h" : "off the machine",
100 overdue ? "Overdue: the last off-box backup is more than 24 h old" : "Age of the last off-box backup",
101 null,
102 overdue ? "attn" : null,
103 )}
93104 </section>`;
94105 }
95106
96107 /** The figures count every repository, so the caller shows this to an admin only. */
97 export function kpiRow(db: Database, opts: { ceilingUsd?: number; now?: Date } = {}): Html {
98 return kpiRowFor(readKpis(db, opts.now ?? new Date(), { ceilingUsd: opts.ceilingUsd ?? null }), liveNow());
108 export function kpiRow(db: Database, opts: { ceilingUsd?: number; dataDir?: string; now?: Date } = {}): Html {
109 const now = opts.now ?? new Date();
110 const forge = { ceilingUsd: opts.ceilingUsd ?? null, ...(opts.dataDir === undefined ? {} : { dataDir: opts.dataDir }) };
111 return kpiRowFor(readKpis(db, now, forge), liveNow(), now);
99112 }
test/home-kpi-page.test.ts +2 −2
@@ -25,7 +25,7 @@ afterAll(async () => {
2525 await rm(dataDir, { recursive: true, force: true });
2626 });
2727
28 test("home renders the five tiles; only an admin sees them", async () => {
28 test("home renders the six tiles; only an admin sees them", async () => {
2929 const mk = async (username: string, first = false): Promise<User> => {
3030 const r = await createUser(app.db, {
3131 origin: "seed",
@@ -50,7 +50,7 @@ test("home renders the five tiles; only an admin sees them", async () => {
5050
5151 const a = await home(admin);
5252 expect(a).toContain('class="kpis"');
53 expect((a.match(/class="card kpi"/g) ?? []).length).toBe(5);
53 expect((a.match(/class="card kpi"/g) ?? []).length).toBe(6);
5454 for (const href of ["/board", "/releases", "/boards", "/settings/crew"]) expect(a).toContain(`class="card kpi" href="${href}"`);
5555
5656 // The figures count every repository, so a non-admin sees none.
test/home-kpi-row.test.ts +57 −10
@@ -10,7 +10,7 @@ const hours = (n: number) => Array.from({ length: 24 }, (_, i) => (i === 3 ? n :
1010 describe("home kpi row", () => {
1111 test("five tiles with their links and figures", () => {
1212 const out = kpiRowFor(
13 { landedHours: hours(6), landed: 6, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 185_000 },
13 { landedHours: hours(6), landed: 6, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 185_000, backupAt: null },
1414 { sha: "abcdef0123456", at: "2026-10-19T11:00:00.000Z" },
1515 ).toString();
1616 expect(out).toContain('<span class="sha">abcdef0</span>');
@@ -25,7 +25,7 @@ describe("home kpi row", () => {
2525
2626 test("the whole figure is in the title and the link's label, rounded and in studio words", () => {
2727 const out = kpiRowFor(
28 { landedHours: hours(7), landed: 7, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 185_000 },
28 { landedHours: hours(7), landed: 7, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 185_000, backupAt: null },
2929 { sha: "abcdef0123456", at: "2026-10-19T11:00:00.000Z" },
3030 ).toString();
3131 expect(out).toContain('title="0.29"');
@@ -37,13 +37,13 @@ describe("home kpi row", () => {
3737 });
3838
3939 test("empty figures say so", () => {
40 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: null }, null).toString();
40 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: null, backupAt: null }, null).toString();
4141 expect(out).toContain("Nothing live yet");
4242 expect(out).toContain("No check finished in 24 h");
4343 });
4444
4545 test("no git words render, not even in a title or label", () => {
46 const out = kpiRowFor({ landedHours: hours(1), landed: 1, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 1000 }, null).toString();
46 const out = kpiRowFor({ landedHours: hours(1), landed: 1, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 1000, backupAt: null }, null).toString();
4747 expect(out).not.toMatch(/commit|merge|branch|repositor|pull request|checks (passed|failed)/i);
4848 });
4949
@@ -67,7 +67,7 @@ describe("home kpi row", () => {
6767 });
6868
6969 test("a truncated note carries its whole text as a title", () => {
70 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: null }, null).toString();
70 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: null, backupAt: null }, null).toString();
7171 expect(out).toContain('title="No check finished in 24 h"');
7272 });
7373
@@ -80,7 +80,7 @@ describe("home kpi row", () => {
8080 });
8181
8282 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 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString();
83 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, medianCheckMs: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString();
8484 expect(out).toContain('href="/settings/crew"');
8585 expect(out).toContain('class="kpi-value num" title="2 of 4">2 of 4<');
8686 expect(out).toContain('title="Crew builders running now, of 4 slots"');
@@ -88,14 +88,14 @@ describe("home kpi row", () => {
8888 });
8989
9090 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 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString();
91 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, medianCheckMs: null, builders: { running: 0, slots: 0 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString();
9292 expect(out).toContain('class="kpi-value num" title="—">—<');
9393 expect(out).toContain('title="No slots set">No slots set<');
9494 expect(out).not.toContain("0 of 0");
9595 });
9696
9797 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 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString();
98 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, medianCheckMs: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString();
9999 expect(out).toContain(">running now<");
100100 });
101101
@@ -122,7 +122,7 @@ describe("the builders figure from the database", () => {
122122 });
123123
124124 describe("the terminal's figures", () => {
125 const k = { landedHours: hours(6), landed: 6, live: { sha: "abcdef0123456", at: new Date().toISOString() }, builders: { running: 2, slots: 4 }, spend: { todayUsd: 12, ceilingUsd: 40 }, medianCheckMs: 185_000 };
125 const k = { landedHours: hours(6), landed: 6, live: { sha: "abcdef0123456", at: new Date().toISOString() }, builders: { running: 2, slots: 4 }, spend: { todayUsd: 12, ceilingUsd: 40 }, medianCheckMs: 185_000, backupAt: null };
126126 test("the same five figures in the same words", () => {
127127 const lines = kpiLines(k, { sha: "abcdef0123456", since: "5m ago" });
128128 expect(lines).toHaveLength(5);
@@ -145,7 +145,7 @@ describe("the terminal's figures", () => {
145145
146146 describe("the spend tile", () => {
147147 const row = (spend: { todayUsd: number; ceilingUsd: number | null }) =>
148 kpiRowFor({ landedHours: hours(0), landed: 0, live: null, medianCheckMs: null, builders: { running: 0, slots: 0 }, spend }, null).toString();
148 kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, medianCheckMs: null, builders: { running: 0, slots: 0 }, spend }, null).toString();
149149
150150 test("shows today's spend of the ceiling, the cents in the title, linking to the crew page", () => {
151151 const out = row({ todayUsd: 12.34, ceilingUsd: 40 });
@@ -185,3 +185,50 @@ describe("the spend figure from the database", () => {
185185 expect(readKpis(db, now).spend).toEqual({ todayUsd: 12.5, ceilingUsd: null });
186186 });
187187 });
188
189 describe("home kpi row: the last off-box backup", () => {
190 const NOW = new Date("2026-10-20T12:00:00.000Z");
191 const hoursAgo = (h: number) => new Date(NOW.getTime() - h * 3_600_000).toISOString();
192 const row = (backupAt: string | null) =>
193 kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt, builders: { running: 0, slots: 0 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: null }, null, NOW).toString();
194 const tile = (out: string) => /<a class="card kpi"[^>]*href="\/settings\/backups"[^>]*>/.exec(out)![0];
195
196 test("a backup 2 h old reads its age, with the exact time, and is not flagged", () => {
197 const out = row(hoursAgo(2));
198 expect(out).toContain("2 h ago");
199 expect(out).toContain(`title="${hoursAgo(2)}"`);
200 expect(tile(out)).not.toContain("data-tone");
201 expect(tile(out)).not.toContain("Overdue");
202 });
203
204 test("a backup 25 h old is flagged and its label says overdue", () => {
205 const t = tile(row(hoursAgo(25)));
206 expect(t).toContain('data-tone="attn"');
207 expect(t).toContain("Overdue");
208 });
209
210 test("no backup at all reads never and is flagged", () => {
211 const out = row(null);
212 expect(out).toContain(">never<");
213 expect(tile(out)).toContain('data-tone="attn"');
214 expect(tile(out)).toContain("Overdue");
215 });
216
217 test("readKpis reads the mirror's newest time from the data directory", async () => {
218 const { mkdtempSync } = await import("node:fs");
219 const { join } = await import("node:path");
220 const { tmpdir } = await import("node:os");
221 const { Database } = await import("bun:sqlite");
222 const { recordMirror } = await import("../src/snapshot/mirror.ts");
223 const { readKpis } = await import("../src/flow/kpis.ts");
224 const dir = mkdtempSync(join(tmpdir(), "kpi-backup-"));
225 const db = new Database(":memory:");
226 db.run("CREATE TABLE repo (autonomy TEXT, autonomy_slots INTEGER, default_branch TEXT, id TEXT)");
227 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, cost_usd REAL, created_at TEXT)");
228 db.run("CREATE TABLE pull (merged_at TEXT, merged_oid TEXT, base_oid TEXT, base_branch TEXT, issue_id TEXT)");
229 db.run("CREATE TABLE issue (id TEXT, repo_id TEXT)");
230 expect(readKpis(db, NOW, { ceilingUsd: null, dataDir: dir }).backupAt).toBeNull();
231 recordMirror(dir, { where: "b2", attemptAt: hoursAgo(1), reachable: true, newest: "s1", newestAt: hoursAgo(3) });
232 expect(readKpis(db, NOW, { ceilingUsd: null, dataDir: dir }).backupAt).toBe(hoursAgo(3));
233 });
234 });
test/home-kpis.test.ts +3 −3
@@ -131,7 +131,7 @@ describe("home kpis", () => {
131131
132132 describe("home kpi row titles", () => {
133133 const full = kpiRowFor(
134 { landedHours: Array.from({ length: 24 }, (_, i) => (i === 0 ? 6 : 0)), landed: 6, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 185_000 },
134 { landedHours: Array.from({ length: 24 }, (_, i) => (i === 0 ? 6 : 0)), landed: 6, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 185_000, backupAt: null },
135135 { sha: "abcdef0123456789", at: "2026-10-19T11:00:00.000Z" },
136136 ).toString();
137137
@@ -144,8 +144,8 @@ describe("home kpi row titles", () => {
144144 });
145145
146146 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(10);
148 expect(full.match(/class="kpi-(value num|note small muted)" title=/g)).toHaveLength(10);
147 expect(full.match(/class="kpi-(value num|note small muted)"/g)).toHaveLength(12);
148 expect(full.match(/class="kpi-(value num|note small muted)" title=/g)).toHaveLength(12);
149149 expect(full).not.toMatch(/commit|merge|branch|repositor|pull request/i);
150150 });
151151 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs