Public: anyone can read this; the crew can change it.
Home's KPI row shows users, visitors and p75 page load and INP #3037
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2713-home-s-kpi-row-shows-users-visitors-and → main 2 commits fork 2 threads · 2 unresolved
13 files
changed +277
−37
README.md +9 −3
| @@ -254,7 +254,7 @@ settings until asked. `/?all=1` is the whole home, and its "Just my apps" link | ||
| 254 | 254 | comes back. The terminal's first screen opens with the same lines, from the |
| 255 | 255 | same query (`src/core/client-apps.ts`). |
| 256 | 256 | |
| 257 | Home shows six figures first, from `src/flow/kpis.ts`: shipped per hour (with | |
| 257 | Home shows nine figures first, from `src/flow/kpis.ts`: shipped per hour (with | |
| 258 | 258 | a 24 h sparkline), the live version and since when, the median check time, the crew builders |
| 259 | 259 | running of their slots, and the crew's spend today of the forge's daily |
| 260 | 260 | ceiling (`FORGE_CREW_CEILING_USD`; without one, the spend alone and |
| @@ -263,10 +263,16 @@ snapshot, `mirror/state.json`; `3 h ago`, the exact time in its title), linking | ||
| 263 | 263 | `/settings/backups`. The spend tile links to `/settings/crew`, where the meter is. |
| 264 | 264 | The backup tile past 24 hours, or with none (`never`), takes the card's existing |
| 265 | 265 | `data-tone="attn"` and says overdue in its label. |
| 266 | The last three (`src/flow/reach.ts`, the `reach` board source's rows) are | |
| 267 | visitors (`5 today`, `31 in 7 days`), accounts (`1 of 2`, signed in today) and | |
| 268 | page speed (the slowest quarter's page load over 7 days, and `reacts in 180 ms`, | |
| 269 | the same for the delay after a tap), from the page-view beacon. Each links to | |
| 270 | the Reach starting board (`/boards/reach`). The visitor hash rotates daily, so | |
| 271 | the 7-day visitors is each day's distinct count summed. | |
| 266 | 272 | They count every repository, so only a site admin sees them, on both doors; |
| 267 | 273 | nobody else does, and neither does the lean client home. On the web each is a |
| 268 | card linking to `/board`, `/releases` or `/boards`. The terminal's first screen | |
| 269 | shows the first five as lines above the forge strip (`kpiLines` in | |
| 274 | card linking to `/board`, `/releases`, `/boards` or `/boards/reach`. The terminal's first screen | |
| 275 | shows the same nine as lines above the forge strip (`kpiLines` in | |
| 270 | 276 | `src/flow/kpi_words.ts`). They are read when the page or screen loads and |
| 271 | 277 | do not refresh on their own. |
| 272 | 278 |
src/boards/sources.ts +2 −1
| @@ -230,6 +230,7 @@ export const SOURCES: Readonly<Record<string, Source>> = { | ||
| 230 | 230 | owner: "admin", |
| 231 | 231 | select: [ |
| 232 | 232 | `SELECT 'visitors' AS metric, '' AS route, ${DAY_AT} AS at, COUNT(DISTINCT visitor) AS value FROM page_vital GROUP BY day`, |
| 233 | percentile("load_p75", "load", 75), | |
| 233 | 234 | percentile("lcp_p75", "lcp", 75), |
| 234 | 235 | percentile("inp_p75", "inp", 75), |
| 235 | 236 | percentile("server_p50", "ttfb", 50), |
| @@ -242,7 +243,7 @@ export const SOURCES: Readonly<Record<string, Source>> = { | ||
| 242 | 243 | columns: { metric: "text", route: "text", at: "time", value: "number" }, |
| 243 | 244 | docs: { |
| 244 | 245 | metric: |
| 245 | "visitors (distinct browsers that day), lcp_p75 and inp_p75 (ms), server_p50 and server_p95 " + | |
| 246 | "visitors (distinct browsers that day), load_p75 (page load), lcp_p75 and inp_p75 (ms), server_p50 and server_p95 " + | |
| 246 | 247 | "(ms, time to first byte), accounts_active_1d (signed in today), accounts_active_7d, accounts_total", |
| 247 | 248 | route: "the route pattern, never the path; empty for visitors and accounts", |
| 248 | 249 | at: "the day (UTC) for visitors and the percentiles; now for the account counts, which are not kept per day", |
src/boards/starting.ts +18 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | /** |
| 2 | * The boards the forge offers a new install: Usage, Crew, Releases, Apps, Security, Throughput and Forges. They are plain | |
| 2 | * The boards the forge offers a new install: Usage, Crew, Releases, Apps, Security, Throughput, Forges and Reach. 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 |
| @@ -35,6 +35,11 @@ const failed = { | ||
| 35 | 35 | filter: [{ column: "pattern", op: "eq", value: "/signin" }, { column: "method", op: "eq", value: "POST" }, { column: "status", op: "eq", value: "4xx" }], |
| 36 | 36 | }; |
| 37 | 37 | |
| 38 | const reach = (metric: string, aggregate: string, groupBy?: Record<string, unknown>) => ({ | |
| 39 | source: "reach", aggregate, column: "value", ...(groupBy === undefined ? {} : { groupBy }), | |
| 40 | filter: [{ column: "metric", op: "eq", value: metric }], | |
| 41 | }); | |
| 42 | ||
| 38 | 43 | export const STARTING_BOARDS: Readonly<Record<string, Board>> = { |
| 39 | 44 | usage: { |
| 40 | 45 | title: "Usage", |
| @@ -112,6 +117,18 @@ export const STARTING_BOARDS: Readonly<Record<string, Board>> = { | ||
| 112 | 117 | panel("Stages by hour", "heat", { ...STAGES, groupBy: HOUR, series: "stage" }, 2), |
| 113 | 118 | ], |
| 114 | 119 | }, |
| 120 | reach: { | |
| 121 | title: "Reach", | |
| 122 | range: "30d", | |
| 123 | refresh: "1m", | |
| 124 | panels: [ | |
| 125 | panel("Visitors per day", "bar", reach("visitors", "max", DAY), 2), | |
| 126 | panel("Accounts signed in today", "number", reach("accounts_active_1d", "max")), | |
| 127 | panel("All accounts", "number", reach("accounts_total", "max")), | |
| 128 | panel("Page load per day (ms, slowest quarter)", "line", reach("load_p75", "max", DAY), 2), | |
| 129 | panel("Slow to react per day (ms, slowest quarter)", "line", reach("inp_p75", "max", DAY), 2), | |
| 130 | ], | |
| 131 | }, | |
| 115 | 132 | forges: { |
| 116 | 133 | title: "Forges", |
| 117 | 134 | range: "30d", |
src/flow/kpi_words.ts +33 −2
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | 6 | import type { Kpis } from "./kpis.ts"; |
| 7 | import type { Reach } from "./reach.ts"; | |
| 7 | 8 | |
| 8 | 9 | /** A duration the way a person reads a check: `45s`, `3m 05s`, `1h 02m`. */ |
| 9 | 10 | export function checkTime(ms: number | null): string { |
| @@ -68,16 +69,46 @@ export function backupFull(at: string | null): string { | ||
| 68 | 69 | return at === null || Number.isNaN(Date.parse(at)) ? "No off-box backup yet" : at; |
| 69 | 70 | } |
| 70 | 71 | |
| 72 | /** A page speed the way a person reads it: `850 ms`, `1.2 s`, or an em dash for none. */ | |
| 73 | export function speedWords(ms: number | null): string { | |
| 74 | if (ms === null) return "—"; | |
| 75 | return ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(1)} s`; | |
| 76 | } | |
| 77 | ||
| 78 | /** Accounts signed in today of all accounts: `3 of 12`. */ | |
| 79 | export function usersValue(r: Reach): string { | |
| 80 | return `${r.usersToday} of ${r.usersTotal}`; | |
| 81 | } | |
| 82 | ||
| 83 | /** Visitors today, in words: `5 today`. */ | |
| 84 | export function visitorsValue(r: Reach): string { | |
| 85 | return `${r.visitorsToday} today`; | |
| 86 | } | |
| 87 | ||
| 88 | /** The week behind it: `31 in 7 days`. */ | |
| 89 | export function visitorsNote(r: Reach): string { | |
| 90 | return `${r.visitors7d} in 7 days`; | |
| 91 | } | |
| 92 | ||
| 93 | /** How quick the pages feel, over 7 days: `reacts in 180 ms`, or why there is no figure. */ | |
| 94 | export function reactNote(r: Reach): string { | |
| 95 | return r.inpP75 === null ? "no taps measured yet" : `reacts in ${speedWords(r.inpP75)}`; | |
| 96 | } | |
| 97 | ||
| 71 | 98 | /** |
| 72 | * The terminal's row: the same five figures in the same words, as plain | |
| 99 | * The terminal's row: the same figures in the same words, as plain | |
| 73 | 100 | * lines. Forge-wide like the web's, so the caller shows them to an admin only. |
| 74 | 101 | */ |
| 75 | export function kpiLines(k: Kpis, live: { sha: string; since: string } | null): string[] { | |
| 102 | export function kpiLines(k: Kpis, live: { sha: string; since: string } | null, now: Date = new Date()): string[] { | |
| 76 | 103 | return [ |
| 77 | 104 | `Shipped per hour ${(k.landed / k.landedHours.length).toFixed(1)} ${sparkText(k.landedHours)} ${k.landed} ${k.landed === 1 ? "change" : "changes"} in 24 h`, |
| 78 | 105 | `Live version ${live === null ? "— nothing live yet" : `${live.sha.slice(0, 7)}, live ${live.since}`}`, |
| 79 | 106 | `Median check time ${checkTime(k.medianCheckMs)}`, |
| 80 | 107 | `Builders running ${buildersValue(k.builders)}${k.builders.slots === 0 ? ", no slots set" : ""}`, |
| 81 | 108 | `Crew spend today ${spendValue(k.spend)}${k.spend.ceilingUsd === null ? ", no ceiling" : ""}`, |
| 109 | `Last off-box backup ${backupAge(k.backupAt, now)}${backupOverdue(k.backupAt, now) ? ", overdue" : ""}`, | |
| 110 | `Visitors ${visitorsValue(k.reach)}, ${visitorsNote(k.reach)}`, | |
| 111 | `Accounts ${usersValue(k.reach)} signed in today`, | |
| 112 | `Page speed ${k.reach.loadP75 === null ? "no page views measured yet" : `loads in ${speedWords(k.reach.loadP75)}`}, ${reactNote(k.reach)}`, | |
| 82 | 113 | ]; |
| 83 | 114 | } |
src/flow/kpis.ts +4
| @@ -16,6 +16,7 @@ | ||
| 16 | 16 | import type { Database } from "bun:sqlite"; |
| 17 | 17 | import { spentTodayEverywhere } from "../crew/autonomy.ts"; |
| 18 | 18 | import { lastMirror } from "../snapshot/mirror.ts"; |
| 19 | import { readReach, type Reach } from "./reach.ts"; | |
| 19 | 20 | import { HOURS, median } from "./throughput.ts"; |
| 20 | 21 | |
| 21 | 22 | const HOUR_MS = 3_600_000; |
| @@ -35,6 +36,8 @@ export interface Kpis { | ||
| 35 | 36 | readonly spend: { readonly todayUsd: number; readonly ceilingUsd: number | null }; |
| 36 | 37 | /** When the off-box mirror last finished holding a snapshot; null when it never has. */ |
| 37 | 38 | readonly backupAt: string | null; |
| 39 | /** Accounts, visitors and page speed (`reach.ts`). */ | |
| 40 | readonly reach: Reach; | |
| 38 | 41 | } |
| 39 | 42 | |
| 40 | 43 | export function readKpis( |
| @@ -96,6 +99,7 @@ export function readKpis( | ||
| 96 | 99 | return { |
| 97 | 100 | backupAt, |
| 98 | 101 | builders, |
| 102 | reach: readReach(db, now), | |
| 99 | 103 | spend: { todayUsd, ceilingUsd: forge.ceilingUsd }, |
| 100 | 104 | landedHours, |
| 101 | 105 | landed: landedHours.reduce((a, b) => a + b, 0), |
src/flow/reach.ts added +66
| @@ -0,0 +1,66 @@ | ||
| 1 | /** | |
| 2 | * Who comes and how fast it feels, as the home row's four figures (#2713): | |
| 3 | * accounts active today of all accounts, visitors today and over 7 days, and | |
| 4 | * the 75th percentile page load and INP over 7 days. Same rows as the `reach` | |
| 5 | * board source: `session` for accounts, the beacon's `page_vital` for the rest. | |
| 6 | * | |
| 7 | * The visitor hash rotates daily and cannot be joined across days, so the | |
| 8 | * 7-day figure is the sum of each day's distinct visitors (a browser seen on | |
| 9 | * three days counts three times). Percentiles are nearest-rank, as the board's. | |
| 10 | */ | |
| 11 | ||
| 12 | import type { Database } from "bun:sqlite"; | |
| 13 | ||
| 14 | const DAY_MS = 86_400_000; | |
| 15 | ||
| 16 | export interface Reach { | |
| 17 | readonly usersToday: number; | |
| 18 | readonly usersTotal: number; | |
| 19 | readonly visitorsToday: number; | |
| 20 | readonly visitors7d: number; | |
| 21 | /** Milliseconds; null when no page view in 7 days measured it. */ | |
| 22 | readonly loadP75: number | null; | |
| 23 | readonly inpP75: number | null; | |
| 24 | } | |
| 25 | ||
| 26 | export const NO_REACH: Reach = { usersToday: 0, usersTotal: 0, visitorsToday: 0, visitors7d: 0, loadP75: null, inpP75: null }; | |
| 27 | ||
| 28 | /** The 75th percentile, nearest rank, of a column over the days since `fromDay`. */ | |
| 29 | function p75(db: Database, column: "load" | "inp", fromDay: string): number | null { | |
OFFSET scan per home load, for load and inp. Fine at this size; cache briefly before page_vital gets large. |
||
| 30 | const n = db | |
| 31 | .query<{ n: number }, [string]>(`SELECT COUNT(${column}) AS n FROM page_vital WHERE day >= ?1`) | |
| 32 | .get(fromDay)?.n ?? 0; | |
| 33 | if (n === 0) return null; | |
| 34 | const rank = Math.ceil(n * 0.75); | |
| 35 | return ( | |
| 36 | db | |
| 37 | .query<{ v: number }, [string, number]>( | |
| 38 | `SELECT ${column} AS v FROM page_vital WHERE day >= ?1 AND ${column} IS NOT NULL ORDER BY ${column} LIMIT 1 OFFSET ?2`, | |
| 39 | ) | |
| 40 | .get(fromDay, rank - 1)?.v ?? null | |
| 41 | ); | |
| 42 | } | |
| 43 | ||
| 44 | export function readReach(db: Database, now: Date): Reach { | |
| 45 | const today = now.toISOString().slice(0, 10); | |
| 46 | const weekFrom = new Date(now.getTime() - 6 * DAY_MS).toISOString().slice(0, 10); | |
| 47 | const users = db | |
| 48 | .query<{ today: number; total: number }, [string]>( | |
| 49 | `SELECT (SELECT COUNT(DISTINCT user_id) FROM session WHERE last_seen_at >= ?1) AS today, | |
| 50 | (SELECT COUNT(*) FROM user) AS total`, | |
| 51 | ) | |
| 52 | .get(`${today}T00:00:00Z`) ?? { today: 0, total: 0 }; | |
| 53 | const days = db | |
| 54 | .query<{ day: string; n: number }, [string, string]>( | |
| 55 | "SELECT day, COUNT(DISTINCT visitor) AS n FROM page_vital WHERE day >= ?1 AND day <= ?2 GROUP BY day", | |
| 56 | ) | |
| 57 | .all(weekFrom, today); | |
| 58 | return { | |
| 59 | usersToday: users.today, | |
| 60 | usersTotal: users.total, | |
| 61 | visitorsToday: days.find((d) => d.day === today)?.n ?? 0, | |
| 62 | visitors7d: days.reduce((a, d) => a + d.n, 0), | |
| 63 | loadP75: p75(db, "load", weekFrom), | |
| 64 | inpP75: p75(db, "inp", weekFrom), | |
| 65 | }; | |
| 66 | } |
src/web/studio/kpi_row.ts +18 −7
| @@ -1,14 +1,15 @@ | ||
| 1 | 1 | /** |
| 2 | * Home's row of six 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, the crew builders running of their slots, and the crew's spend today | |
| 5 | * of the daily ceiling, and the age of the last off-box backup. Server-rendered, one fixed-height card each, so | |
| 6 | * nothing moves when it paints. Only the studio's words: shipped, change, | |
| 7 | * check time. | |
| 2 | * Home's row of nine figures (#2871, #2713), from `flow/kpis.ts`: shipped per | |
| 3 | * hour with a day's sparkline, the live version and since when, the median | |
| 4 | * check time, the crew builders running of their slots, the crew's spend | |
| 5 | * today of the daily ceiling, the age of the last off-box backup, visitors, | |
| 6 | * accounts signed in, and page speed. Server-rendered, one fixed-height card | |
| 7 | * each, so nothing moves when it paints. Only the studio's words: shipped, | |
| 8 | * change, check time. | |
| 8 | 9 | */ |
| 9 | 10 | |
| 10 | 11 | import type { Database } from "bun:sqlite"; |
| 11 | import { backupAge, backupFull, backupOverdue, buildersValue, checkTime, spendFull, spendValue } from "../../flow/kpi_words.ts"; | |
| 12 | import { backupAge, backupFull, backupOverdue, buildersValue, checkTime, reactNote, speedWords, spendFull, spendValue, usersValue, visitorsNote, visitorsValue } from "../../flow/kpi_words.ts"; | |
| 12 | 13 | import { readKpis, type Kpis } from "../../flow/kpis.ts"; |
| 13 | 14 | import { html, raw, type Html } from "../html.ts"; |
| 14 | 15 | import { BOOTED } from "../../release/gate.ts"; |
| @@ -101,6 +102,16 @@ export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null, now | ||
| 101 | 102 | null, |
| 102 | 103 | overdue ? "attn" : null, |
| 103 | 104 | )} |
| 105 | ${tile("Visitors", "/boards/reach", visitorsValue(k.reach), `${visitorsValue(k.reach)}, ${visitorsNote(k.reach)}`, visitorsNote(k.reach), "Distinct browsers per day, summed over 7 days")} | |
| 106 | ${tile("Accounts", "/boards/reach", usersValue(k.reach), usersValue(k.reach), "signed in today", "Accounts signed in today (UTC), of all accounts")} | |
| 107 | ${tile( | |
| 108 | "Page speed", | |
| 109 | "/boards/reach", | |
| 110 | speedWords(k.reach.loadP75), | |
| 111 | speedWords(k.reach.loadP75), | |
| 112 | reactNote(k.reach), | |
| 113 | "Page load, and delay after a tap, for the slowest quarter of views over 7 days", | |
| 114 | )} | |
| 104 | 115 | </section>`; |
| 105 | 116 | } |
| 106 | 117 |
test/boards-starting.test.ts +4 −4
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | /** |
| 2 | * The forge offers the seven starting boards on boot: every panel runs, an | |
| 2 | * The forge offers the eight starting boards on boot: every panel runs, an | |
| 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 the seven starting boards, and every panel runs", async () => { | |
| 40 | expect((await offerStartingBoards(app)).sort()).toEqual(["apps", "crew", "forges", "releases", "security", "throughput", "usage"]); | |
| 39 | test("a fresh boot writes the eight starting boards, and every panel runs", async () => { | |
| 40 | expect((await offerStartingBoards(app)).sort()).toEqual(["apps", "crew", "forges", "reach", "releases", "security", "throughput", "usage"]); | |
| 41 | 41 | const boards = await listBoards(app.reposRoot); |
| 42 | expect(boards.map((b) => b.slug)).toEqual(["apps", "crew", "forges", "releases", "security", "throughput", "usage"]); | |
| 42 | expect(boards.map((b) => b.slug)).toEqual(["apps", "crew", "forges", "reach", "releases", "security", "throughput", "usage"]); | |
| 43 | 43 | let panels = 0; |
| 44 | 44 | for (const { board } of boards) { |
| 45 | 45 | for (const p of board.panels) { |
test/helpers/reach-tables.ts added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | import type { Database } from "bun:sqlite"; | |
| 2 | ||
| 3 | /** The three tables `readReach` reads, bare, for tests that build their own small database. */ | |
| 4 | export function reachTables(db: Database): void { | |
| 5 | db.run("CREATE TABLE session (user_id TEXT, last_seen_at TEXT)"); | |
| 6 | db.run("CREATE TABLE user (id TEXT)"); | |
| 7 | db.run("CREATE TABLE page_vital (day TEXT, visitor TEXT, load REAL, inp REAL)"); | |
| 8 | } |
test/home-kpi-page.test.ts +21 −2
| @@ -25,7 +25,7 @@ afterAll(async () => { | ||
| 25 | 25 | await rm(dataDir, { recursive: true, force: true }); |
| 26 | 26 | }); |
| 27 | 27 | |
| 28 | test("home renders the six tiles; only an admin sees them", async () => { | |
| 28 | test("home renders the nine 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", |
| @@ -48,11 +48,30 @@ test("home renders the six tiles; only an admin sees them", async () => { | ||
| 48 | 48 | }) |
| 49 | 49 | ).text(); |
| 50 | 50 | |
| 51 | // Seeded reach: 4 page views by 3 visitors today, 1 yesterday; sessions seen today. | |
| 52 | const day = new Date().toISOString().slice(0, 10); | |
| 53 | const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10); | |
| 54 | const view = (d: string, visitor: string, load: number | null, inp: number | null) => | |
| 55 | app.db.run("INSERT INTO page_vital (day, at, route, visitor, ttfb, load, lcp, inp, cls) VALUES (?, ?, '/', ?, 10, ?, 100, ?, 0)", [d, Date.now(), visitor, load, inp]); | |
| 56 | view(day, "a", 100, 40); | |
| 57 | view(day, "b", 200, 80); | |
| 58 | view(day, "c", 300, 120); | |
| 59 | view(day, "a", 1900, null); | |
| 60 | view(yesterday, "z", 400, 160); | |
| 51 | 61 | const a = await home(admin); |
| 52 | 62 | expect(a).toContain('class="kpis"'); |
| 53 | expect((a.match(/class="card kpi"/g) ?? []).length).toBe(6); | |
| 63 | expect((a.match(/class="card kpi"/g) ?? []).length).toBe(9); | |
| 54 | 64 | for (const href of ["/board", "/releases", "/boards", "/settings/crew"]) expect(a).toContain(`class="card kpi" href="${href}"`); |
| 55 | 65 | |
| 66 | // 2 accounts, the admin alone with a session today; visitors 3 today and 4 over 7 days; | |
| 67 | // load p75 of [100,200,300,400,1900] is 400 ms, INP p75 of [40,80,120,160] is 120 ms. | |
| 68 | expect(a).toContain(">1 of 2<"); | |
| 69 | expect(a).toContain(">3 today<"); | |
| 70 | expect(a).toContain(">4 in 7 days<"); | |
| 71 | expect(a).toContain(">400 ms<"); | |
| 72 | expect(a).toContain(">reacts in 120 ms<"); | |
| 73 | expect((a.match(/class="card kpi" href="\/boards\/reach"/g) ?? []).length).toBe(3); | |
| 74 | ||
| 56 | 75 | // The figures count every repository, so a non-admin sees none. |
| 57 | 76 | expect(a).toContain("no ceiling"); |
| 58 | 77 | expect(await home(bob)).not.toContain('class="kpis"'); |
test/home-kpi-row.test.ts +89 −14
| @@ -1,4 +1,6 @@ | ||
| 1 | 1 | /** Home's figures row (#2871): the five tiles, their links, studio words only, a sparkline of 24 points. */ |
| 2 | import { reachTables } from "./helpers/reach-tables.ts"; | |
| 3 | import { NO_REACH, readReach } from "../src/flow/reach.ts"; | |
| 2 | 4 | import { describe, expect, test } from "bun:test"; |
| 3 | 5 | import { checkTime, kpiLines, sparkText } from "../src/flow/kpi_words.ts"; |
| 4 | 6 | import { running } from "../src/kernel/version.ts"; |
| @@ -10,7 +12,7 @@ const hours = (n: number) => Array.from({ length: 24 }, (_, i) => (i === 3 ? n : | ||
| 10 | 12 | describe("home kpi row", () => { |
| 11 | 13 | test("five tiles with their links and figures", () => { |
| 12 | 14 | const out = kpiRowFor( |
| 13 | { landedHours: hours(6), landed: 6, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 185_000, backupAt: null }, | |
| 15 | { landedHours: hours(6), landed: 6, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 185_000, backupAt: null, reach: NO_REACH }, | |
| 14 | 16 | { sha: "abcdef0123456", at: "2026-10-19T11:00:00.000Z" }, |
| 15 | 17 | ).toString(); |
| 16 | 18 | expect(out).toContain('<span class="sha">abcdef0</span>'); |
| @@ -25,7 +27,7 @@ describe("home kpi row", () => { | ||
| 25 | 27 | |
| 26 | 28 | test("the whole figure is in the title and the link's label, rounded and in studio words", () => { |
| 27 | 29 | const out = kpiRowFor( |
| 28 | { landedHours: hours(7), landed: 7, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 185_000, backupAt: null }, | |
| 30 | { landedHours: hours(7), landed: 7, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 185_000, backupAt: null, reach: NO_REACH }, | |
| 29 | 31 | { sha: "abcdef0123456", at: "2026-10-19T11:00:00.000Z" }, |
| 30 | 32 | ).toString(); |
| 31 | 33 | expect(out).toContain('title="0.29"'); |
| @@ -37,13 +39,13 @@ describe("home kpi row", () => { | ||
| 37 | 39 | }); |
| 38 | 40 | |
| 39 | 41 | 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, backupAt: null }, null).toString(); | |
| 42 | const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: null, backupAt: null, reach: NO_REACH }, null).toString(); | |
| 41 | 43 | expect(out).toContain("Nothing live yet"); |
| 42 | 44 | expect(out).toContain("No check finished in 24 h"); |
| 43 | 45 | }); |
| 44 | 46 | |
| 45 | 47 | 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, backupAt: null }, null).toString(); | |
| 48 | const out = kpiRowFor({ landedHours: hours(1), landed: 1, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 1000, backupAt: null, reach: NO_REACH }, null).toString(); | |
| 47 | 49 | expect(out).not.toMatch(/commit|merge|branch|repositor|pull request|checks (passed|failed)/i); |
| 48 | 50 | }); |
| 49 | 51 | |
| @@ -67,7 +69,7 @@ describe("home kpi row", () => { | ||
| 67 | 69 | }); |
| 68 | 70 | |
| 69 | 71 | 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, backupAt: null }, null).toString(); | |
| 72 | const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: null, backupAt: null, reach: NO_REACH }, null).toString(); | |
| 71 | 73 | expect(out).toContain('title="No check finished in 24 h"'); |
| 72 | 74 | }); |
| 73 | 75 | |
| @@ -80,7 +82,7 @@ describe("home kpi row", () => { | ||
| 80 | 82 | }); |
| 81 | 83 | |
| 82 | 84 | 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, backupAt: null, medianCheckMs: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString(); | |
| 85 | const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach: NO_REACH, medianCheckMs: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString(); | |
| 84 | 86 | expect(out).toContain('href="/settings/crew"'); |
| 85 | 87 | expect(out).toContain('class="kpi-value num" title="2 of 4">2 of 4<'); |
| 86 | 88 | expect(out).toContain('title="Crew builders running now, of 4 slots"'); |
| @@ -88,14 +90,15 @@ describe("home kpi row", () => { | ||
| 88 | 90 | }); |
| 89 | 91 | |
| 90 | 92 | 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, backupAt: null, medianCheckMs: null, builders: { running: 0, slots: 0 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString(); | |
| 93 | const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach: NO_REACH, medianCheckMs: null, builders: { running: 0, slots: 0 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString(); | |
| 92 | 94 | expect(out).toContain('class="kpi-value num" title="—">—<'); |
| 93 | 95 | expect(out).toContain('title="No slots set">No slots set<'); |
| 94 | expect(out).not.toContain("0 of 0"); | |
| 96 | expect(out).toContain(`aria-label="Builders running: —, No slots set"`); | |
| 97 | expect(out).not.toContain("Builders running: 0 of 0"); | |
| 95 | 98 | }); |
| 96 | 99 | |
| 97 | 100 | test("the builders note does not repeat the value", () => { |
| 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(); | |
| 101 | const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach: NO_REACH, medianCheckMs: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString(); | |
| 99 | 102 | expect(out).toContain(">running now<"); |
| 100 | 103 | }); |
| 101 | 104 | |
| @@ -114,6 +117,7 @@ describe("the builders figure from the database", () => { | ||
| 114 | 117 | 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)"); |
| 115 | 118 | db.run("CREATE TABLE pull (merged_at TEXT, merged_oid TEXT, base_oid TEXT, base_branch TEXT, issue_id TEXT)"); |
| 116 | 119 | db.run("CREATE TABLE issue (id TEXT, repo_id TEXT)"); |
| 120 | reachTables(db); | |
| 117 | 121 | db.run("INSERT INTO repo VALUES ('autonomous', 3, 'main', 'a'), ('autonomous', 1, 'main', 'b'), ('off', 8, 'main', 'c')"); |
| 118 | 122 | db.run("INSERT INTO run (id, kind, state) VALUES ('1','agent','running'), ('2','agent','running'), ('3','agent','queued'), ('4','check','running')"); |
| 119 | 123 | const { readKpis } = await import("../src/flow/kpis.ts"); |
| @@ -122,10 +126,11 @@ describe("the builders figure from the database", () => { | ||
| 122 | 126 | }); |
| 123 | 127 | |
| 124 | 128 | 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, backupAt: null }; | |
| 126 | test("the same five figures in the same words", () => { | |
| 129 | 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, reach: NO_REACH }; | |
| 130 | test("the same nine figures in the same words", () => { | |
| 127 | 131 | const lines = kpiLines(k, { sha: "abcdef0123456", since: "5m ago" }); |
| 128 | expect(lines).toHaveLength(5); | |
| 132 | expect(lines).toHaveLength(9); | |
| 133 | expect(lines).toContain("Last off-box backup never, overdue"); | |
| 129 | 134 | expect(lines[4]).toBe("Crew spend today $12 of $40"); |
| 130 | 135 | expect(kpiLines({ ...k, spend: { todayUsd: 0.4, ceilingUsd: null } }, null)[4]).toBe("Crew spend today $0.40, no ceiling"); |
| 131 | 136 | expect(lines[3]).toBe("Builders running 2 of 4"); |
| @@ -145,7 +150,7 @@ describe("the terminal's figures", () => { | ||
| 145 | 150 | |
| 146 | 151 | describe("the spend tile", () => { |
| 147 | 152 | const row = (spend: { todayUsd: number; ceilingUsd: number | null }) => |
| 148 | kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, medianCheckMs: null, builders: { running: 0, slots: 0 }, spend }, null).toString(); | |
| 153 | kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach: NO_REACH, medianCheckMs: null, builders: { running: 0, slots: 0 }, spend }, null).toString(); | |
| 149 | 154 | |
| 150 | 155 | test("shows today's spend of the ceiling, the cents in the title, linking to the crew page", () => { |
| 151 | 156 | const out = row({ todayUsd: 12.34, ceilingUsd: 40 }); |
| @@ -176,6 +181,7 @@ describe("the spend figure from the database", () => { | ||
| 176 | 181 | 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)"); |
| 177 | 182 | db.run("CREATE TABLE pull (merged_at TEXT, merged_oid TEXT, base_oid TEXT, base_branch TEXT, issue_id TEXT)"); |
| 178 | 183 | db.run("CREATE TABLE issue (id TEXT, repo_id TEXT)"); |
| 184 | reachTables(db); | |
| 179 | 185 | db.run( |
| 180 | 186 | `INSERT INTO run (id, kind, cost_usd, created_at) VALUES |
| 181 | 187 | ('1','agent',5,'2026-10-19T00:00:00.000Z'), ('2','agent',7.5,'2026-10-19T14:00:00.000Z'), |
| @@ -190,7 +196,7 @@ describe("home kpi row: the last off-box backup", () => { | ||
| 190 | 196 | const NOW = new Date("2026-10-20T12:00:00.000Z"); |
| 191 | 197 | const hoursAgo = (h: number) => new Date(NOW.getTime() - h * 3_600_000).toISOString(); |
| 192 | 198 | 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(); | |
| 199 | kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt, reach: NO_REACH, builders: { running: 0, slots: 0 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: null }, null, NOW).toString(); | |
| 194 | 200 | const tile = (out: string) => /<a class="card kpi"[^>]*href="\/settings\/backups"[^>]*>/.exec(out)![0]; |
| 195 | 201 | |
| 196 | 202 | test("a backup 2 h old reads its age, with the exact time, and is not flagged", () => { |
| @@ -227,8 +233,77 @@ describe("home kpi row: the last off-box backup", () => { | ||
| 227 | 233 | 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 | 234 | db.run("CREATE TABLE pull (merged_at TEXT, merged_oid TEXT, base_oid TEXT, base_branch TEXT, issue_id TEXT)"); |
| 229 | 235 | db.run("CREATE TABLE issue (id TEXT, repo_id TEXT)"); |
| 236 | reachTables(db); | |
| 230 | 237 | expect(readKpis(db, NOW, { ceilingUsd: null, dataDir: dir }).backupAt).toBeNull(); |
| 231 | 238 | recordMirror(dir, { where: "b2", attemptAt: hoursAgo(1), reachable: true, newest: "s1", newestAt: hoursAgo(3) }); |
| 232 | 239 | expect(readKpis(db, NOW, { ceilingUsd: null, dataDir: dir }).backupAt).toBe(hoursAgo(3)); |
| 233 | 240 | }); |
| 234 | 241 | }); |
| 242 | ||
| 243 | describe("the reach tiles", () => { | |
| 244 | test("say what they count in words, each linking to the reach board", () => { | |
| 245 | const reach = { usersToday: 3, usersTotal: 12, visitorsToday: 5, visitors7d: 31, loadP75: 1250, inpP75: 180 }; | |
| 246 | const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach, medianCheckMs: null, builders: { running: 0, slots: 0 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString(); | |
| 247 | for (const v of ["5 today", "31 in 7 days", "3 of 12", "signed in today", "1.3 s", "reacts in 180 ms"]) expect(out).toContain(`>${v}<`); | |
| 248 | expect((out.match(/class="card kpi" href="\/boards\/reach"/g) ?? []).length).toBe(3); | |
| 249 | expect(out).not.toContain("INP"); | |
| 250 | expect(out).not.toContain("p75"); | |
| 251 | }); | |
| 252 | ||
| 253 | test("the terminal says the same", () => { | |
| 254 | const lines = kpiLines({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach: { usersToday: 3, usersTotal: 12, visitorsToday: 5, visitors7d: 31, loadP75: 1250, inpP75: 180 }, medianCheckMs: null, builders: { running: 0, slots: 0 }, spend: { todayUsd: 0, ceilingUsd: null } }, null); | |
| 255 | expect(lines).toContain("Visitors 5 today, 31 in 7 days"); | |
| 256 | expect(lines).toContain("Accounts 3 of 12 signed in today"); | |
| 257 | expect(lines).toContain("Page speed loads in 1.3 s, reacts in 180 ms"); | |
| 258 | }); | |
| 259 | }); | |
| 260 | ||
| 261 | describe("readReach", () => { | |
| 262 | const NOW = new Date("2026-10-19T15:00:00.000Z"); | |
| 263 | const make = async () => { | |
| 264 | const { Database } = await import("bun:sqlite"); | |
| 265 | const db = new Database(":memory:"); | |
| 266 | reachTables(db); | |
| 267 | return db; | |
| 268 | }; | |
| 269 | const view = (db: import("bun:sqlite").Database, day: string, visitor: string, load: number | null, inp: number | null) => | |
| 270 | db.run("INSERT INTO page_vital VALUES (?, ?, ?, ?)", [day, visitor, load, inp]); | |
| 271 | ||
| 272 | test("nothing seeded: zeros and no speed", async () => { | |
| 273 | expect(readReach(await make(), NOW)).toEqual(NO_REACH); | |
| 274 | }); | |
| 275 | ||
| 276 | test("the 7-day window starts six days back; today is the UTC day", async () => { | |
| 277 | const db = await make(); | |
| 278 | view(db, "2026-10-19", "a", 100, 10); | |
| 279 | view(db, "2026-10-13", "a", 100, 10); // first day inside | |
| 280 | view(db, "2026-10-12", "b", 9999, 9999); // one day outside | |
| 281 | const r = readReach(db, NOW); | |
| 282 | expect(r.visitorsToday).toBe(1); | |
| 283 | expect(r.visitors7d).toBe(2); | |
| 284 | expect(r.loadP75).toBe(100); | |
| 285 | // Just after midnight the same rows are a day older. | |
| 286 | expect(readReach(db, new Date("2026-10-20T00:00:01.000Z")).visitorsToday).toBe(0); | |
| 287 | expect(readReach(db, new Date("2026-10-20T00:00:01.000Z")).visitors7d).toBe(1); | |
| 288 | }); | |
| 289 | ||
| 290 | test("p75 is nearest rank and skips nulls", async () => { | |
| 291 | const db = await make(); | |
| 292 | for (const [i, v] of [10, 20, 30, 40, 50].entries()) view(db, "2026-10-19", `v${i}`, v, null); | |
| 293 | view(db, "2026-10-19", "n", null, 70); | |
| 294 | const r = readReach(db, NOW); | |
| 295 | expect(r.loadP75).toBe(40); // rank ceil(5 * .75) = 4 | |
| 296 | expect(r.inpP75).toBe(70); // one value: itself | |
| 297 | }); | |
| 298 | ||
| 299 | test("a visitor seen twice in a day counts once; accounts count distinct users since UTC midnight", async () => { | |
| 300 | const db = await make(); | |
| 301 | view(db, "2026-10-19", "a", 1, 1); | |
| 302 | view(db, "2026-10-19", "a", 2, 2); | |
| 303 | db.run("INSERT INTO user VALUES ('u1'), ('u2'), ('u3')"); | |
| 304 | db.run("INSERT INTO session VALUES ('u1', '2026-10-19T00:00:00Z'), ('u1', '2026-10-19T09:00:00Z'), ('u2', '2026-10-18T23:59:59Z')"); | |
| 305 | const r = readReach(db, NOW); | |
| 306 | expect(r.visitorsToday).toBe(1); | |
| 307 | expect([r.usersToday, r.usersTotal]).toEqual([1, 3]); | |
| 308 | }); | |
| 309 | }); |
test/home-kpis.test.ts +4 −3
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * counted, and an empty forge gives zeros and a null median. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { NO_REACH } from "../src/flow/reach.ts"; | |
| 7 | 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 8 | 9 | import { mkdtemp, rm } from "node:fs/promises"; |
| 9 | 10 | import { tmpdir } from "node:os"; |
| @@ -131,7 +132,7 @@ describe("home kpis", () => { | ||
| 131 | 132 | |
| 132 | 133 | describe("home kpi row titles", () => { |
| 133 | 134 | 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, backupAt: null }, | |
| 135 | { 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, reach: NO_REACH }, | |
| 135 | 136 | { sha: "abcdef0123456789", at: "2026-10-19T11:00:00.000Z" }, |
| 136 | 137 | ).toString(); |
| 137 | 138 | |
| @@ -144,8 +145,8 @@ describe("home kpi row titles", () => { | ||
| 144 | 145 | }); |
| 145 | 146 | |
| 146 | 147 | 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(12); | |
| 148 | expect(full.match(/class="kpi-(value num|note small muted)" title=/g)).toHaveLength(12); | |
| 148 | expect(full.match(/class="kpi-(value num|note small muted)"/g)).toHaveLength(18); | |
| 149 | expect(full.match(/class="kpi-(value num|note small muted)" title=/g)).toHaveLength(18); | |
| 149 | 150 | expect(full).not.toMatch(/commit|merge|branch|repositor|pull request/i); |
| 150 | 151 | }); |
| 151 | 152 | }); |
test/tui-kpis.test.ts +1
| @@ -87,4 +87,5 @@ test("the admin's figures carry the configured daily ceiling, and say so when th | ||
| 87 | 87 | const capped = { ...forge, config: { ...forge.config, crewCeilingUsd: 40 } }; |
| 88 | 88 | expect(await firstScreen(alice, capped)).toContain("Crew spend today $0.00 of $40"); |
| 89 | 89 | expect(await firstScreen(alice)).toContain("Crew spend today $0.00, no ceiling"); |
| 90 | expect(await firstScreen(alice)).toContain("Visitors 0 today, 0 in 7 days"); | |
| 90 | 91 | }); |
new above
offline
The terminal does not render these figures (
kpiLinesis unchanged). Both doors must show them, and the README should match.