Public: anyone can read this; the crew can change it.
A green restore drill keeps how long each phase took, and /settings/backups shows it against the budget #2630
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2622-a-green-restore-drill-keeps-how-long-eac → main 3 commits fork 2 threads · 2 unresolved
12 files
changed +348
−27
README.md +3 −1
| @@ -978,7 +978,9 @@ The terminal has no backups view, so it has no readout. | ||
| 978 | 978 | |
| 979 | 979 | **Run a drill** on the same page (admins only) restores the newest snapshot |
| 980 | 980 | into scratch, opens it, counts what is there and unseals a value; the page |
| 981 | waits for it, up to 10 minutes, and the last drill shows there as one line. | |
| 981 | waits for it, up to 10 minutes, and the last drill shows there as one line, | |
| 982 | ending with the drill's time against its budget and each phase's time ("6 min | |
| 983 | 12 s of its 14 min budget: files 58 s, …"); drills from before this show none. | |
| 982 | 984 | Only one runs at a time. See `docs/SNAPSHOT.md`. |
| 983 | 985 | |
| 984 | 986 | The forge also runs one itself every Sunday 09:00 UTC (a forge that was down |
src/core/migrations/20261019120000-drill-phases.ts added +5
| @@ -0,0 +1,5 @@ | ||
| 1 | // A restore drill keeps how long each restore phase took and the budget it had | |
| 2 | // (#2622): JSON {phases, budgetMs}. An older drill has NULL and reads as none. | |
| 3 | export default ` | |
| 4 | ALTER TABLE drill ADD COLUMN phases TEXT; | |
| 5 | `; |
src/snapshot/drill.ts +32
| @@ -65,6 +65,22 @@ export function formatBytes(bytes: number): string { | ||
| 65 | 65 | return `${Math.round(v * 10) / 10} ${units[i]}`; |
| 66 | 66 | } |
| 67 | 67 | |
| 68 | /** A span for people: "6 min 12 s", "58 s", "340 ms". */ | |
| 69 | export function formatSpan(ms: number): string { | |
| 70 | if (!Number.isFinite(ms) || ms < 0) return "0 ms"; | |
| 71 | if (ms < 1_000) return `${Math.round(ms)} ms`; | |
| 72 | const s = Math.round(ms / 1_000); | |
| 73 | if (s < 60) return `${s} s`; | |
| 74 | const m = Math.floor(s / 60); | |
| 75 | return s % 60 === 0 ? `${m} min` : `${m} min ${s % 60} s`; | |
| 76 | } | |
| 77 | ||
| 78 | /** One restore phase and the milliseconds it took. */ | |
| 79 | export interface PhaseTime { | |
| 80 | readonly phase: Progress["phase"]; | |
| 81 | readonly ms: number; | |
| 82 | } | |
| 83 | ||
| 68 | 84 | export type DrillStep = "fetch" | "restore" | "open" | "count" | "unseal" | "health"; |
| 69 | 85 | |
| 70 | 86 | export interface DrillDeps { |
| @@ -91,6 +107,10 @@ export interface DrillResult { | ||
| 91 | 107 | readonly health: "ok" | "failed" | "unreached"; |
| 92 | 108 | readonly verdict: "green" | "red"; |
| 93 | 109 | readonly failedStep: DrillStep | null; |
| 110 | /** How long each restore phase took, in the order they began; empty when none was reported. */ | |
| 111 | readonly phases: readonly PhaseTime[]; | |
| 112 | /** The time the drill was given, in ms. */ | |
| 113 | readonly budgetMs: number; | |
| 94 | 114 | /** Why it is red, for the person reading; never a value. */ |
| 95 | 115 | readonly reason: string | null; |
| 96 | 116 | } |
| @@ -128,6 +148,9 @@ export async function runDrill(deps: DrillDeps): Promise<DrillResult> { | ||
| 128 | 148 | let scratch: string | undefined; |
| 129 | 149 | let reason: string | null = null; |
| 130 | 150 | let progress: Progress | undefined; |
| 151 | // When each restore phase was first reported, and when the restore ended. | |
| 152 | const began: { phase: Progress["phase"]; at: number }[] = []; | |
| 153 | let restoreEnd: number | undefined; | |
| 131 | 154 | let health: DrillResult["health"] = "unreached"; |
| 132 | 155 | try { |
| 133 | 156 | // One clock for the whole drill, so a hang in any step ends it red. |
| @@ -148,11 +171,13 @@ export async function runDrill(deps: DrillDeps): Promise<DrillResult> { | ||
| 148 | 171 | passphrase: deps.passphrase, |
| 149 | 172 | onProgress: (p) => { |
| 150 | 173 | progress = p; |
| 174 | if (began[began.length - 1]?.phase !== p.phase) began.push({ phase: p.phase, at: performance.now() }); | |
| 151 | 175 | }, |
| 152 | 176 | }), |
| 153 | 177 | left(), |
| 154 | 178 | budget, |
| 155 | 179 | ); |
| 180 | restoreEnd = performance.now(); | |
| 156 | 181 | |
| 157 | 182 | step = "open"; |
| 158 | 183 | const { Database } = await import("bun:sqlite"); |
| @@ -229,6 +254,11 @@ export async function runDrill(deps: DrillDeps): Promise<DrillResult> { | ||
| 229 | 254 | return finish(null); |
| 230 | 255 | |
| 231 | 256 | function finish(failedStep: DrillStep | null): DrillResult { |
| 257 | const end = restoreEnd ?? performance.now(); | |
| 258 | const phases = began.map((b, i) => ({ | |
| 259 | phase: b.phase, | |
| 260 | ms: Math.max(0, Math.round((began[i + 1]?.at ?? end) - b.at)), | |
| 261 | })); | |
| 232 | 262 | return { |
| 233 | 263 | at, |
| 234 | 264 | snapshotId, |
| @@ -237,6 +267,8 @@ export async function runDrill(deps: DrillDeps): Promise<DrillResult> { | ||
| 237 | 267 | health, |
| 238 | 268 | verdict: failedStep === null ? "green" : "red", |
| 239 | 269 | failedStep, |
| 270 | phases, | |
| 271 | budgetMs, | |
| 240 | 272 | reason, |
| 241 | 273 | }; |
| 242 | 274 | } |
src/snapshot/drills.ts +50 −23
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import type { Database } from "bun:sqlite"; |
| 8 | import type { DrillResult, DrillStep } from "./drill.ts"; | |
| 8 | import type { DrillResult, DrillStep, PhaseTime } from "./drill.ts"; | |
| 9 | 9 | |
| 10 | 10 | interface Row { |
| 11 | 11 | at: string; |
| @@ -19,12 +19,36 @@ interface Row { | ||
| 19 | 19 | verdict: "green" | "red"; |
| 20 | 20 | failed_step: DrillStep | null; |
| 21 | 21 | reason: string | null; |
| 22 | phases: string | null; | |
| 23 | } | |
| 24 | ||
| 25 | const PHASES = new Set(["manifest", "files", "unpack", "bundles", "key"]); | |
| 26 | ||
| 27 | /** The stored JSON as phases; anything that is not a list of known phases with whole ms reads as none. */ | |
| 28 | export function parsePhases(raw: string | null): { phases: PhaseTime[]; budgetMs: number } { | |
| 29 | const none = { phases: [], budgetMs: 0 }; | |
| 30 | if (raw === null) return none; | |
| 31 | try { | |
| 32 | const v: unknown = JSON.parse(raw); | |
| 33 | if (typeof v !== "object" || v === null) return none; | |
| 34 | const { phases, budgetMs } = v as { phases?: unknown; budgetMs?: unknown }; | |
| 35 | if (!Array.isArray(phases) || typeof budgetMs !== "number" || !Number.isFinite(budgetMs) || budgetMs < 0) return none; | |
| 36 | const out: PhaseTime[] = []; | |
| 37 | for (const p of phases) { | |
| 38 | const { phase, ms } = (p ?? {}) as { phase?: unknown; ms?: unknown }; | |
| 39 | if (typeof phase !== "string" || !PHASES.has(phase) || typeof ms !== "number" || !Number.isFinite(ms) || ms < 0) return none; | |
| 40 | out.push({ phase: phase as PhaseTime["phase"], ms }); | |
| 41 | } | |
| 42 | return { phases: out, budgetMs }; | |
| 43 | } catch { | |
| 44 | return none; | |
| 45 | } | |
| 22 | 46 | } |
| 23 | 47 | |
| 24 | 48 | export function recordDrill(db: Database, result: DrillResult): void { |
| 25 | 49 | db.query( |
| 26 | `INSERT INTO drill (at, snapshot_id, ms, repositories, issues, users, pulls, apps, verdict, failed_step, reason) | |
| 27 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, | |
| 50 | `INSERT INTO drill (at, snapshot_id, ms, repositories, issues, users, pulls, apps, verdict, failed_step, reason, phases) | |
| 51 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, | |
| 28 | 52 | ).run( |
| 29 | 53 | result.at, |
| 30 | 54 | result.snapshotId, |
| @@ -37,6 +61,7 @@ export function recordDrill(db: Database, result: DrillResult): void { | ||
| 37 | 61 | result.verdict, |
| 38 | 62 | result.failedStep, |
| 39 | 63 | result.reason, |
| 64 | JSON.stringify({ phases: result.phases, budgetMs: result.budgetMs }), | |
| 40 | 65 | ); |
| 41 | 66 | } |
| 42 | 67 | |
| @@ -45,24 +70,26 @@ export function lastDrill(db: Database): DrillResult | undefined { | ||
| 45 | 70 | const row = db |
| 46 | 71 | .query<Row, []>("SELECT * FROM drill ORDER BY id DESC LIMIT 1") |
| 47 | 72 | .get(); |
| 48 | return row === null | |
| 49 | ? undefined | |
| 50 | : { | |
| 51 | at: row.at, | |
| 52 | snapshotId: row.snapshot_id, | |
| 53 | ms: row.ms, | |
| 54 | counts: { | |
| 55 | repositories: row.repositories, | |
| 56 | issues: row.issues, | |
| 57 | users: row.users, | |
| 58 | pulls: row.pulls, | |
| 59 | apps: row.apps, | |
| 60 | }, | |
| 61 | // Not stored: green means health read ok; a red one failed at it or never got there. | |
| 62 | health: | |
| 63 | row.verdict === "green" ? "ok" : row.failed_step === "health" ? "failed" : "unreached", | |
| 64 | verdict: row.verdict, | |
| 65 | failedStep: row.failed_step, | |
| 66 | reason: row.reason, | |
| 67 | }; | |
| 73 | if (row === null) return undefined; | |
| 74 | const kept = parsePhases(row.phases); | |
| 75 | return { | |
| 76 | at: row.at, | |
| 77 | snapshotId: row.snapshot_id, | |
| 78 | ms: row.ms, | |
| 79 | counts: { | |
| 80 | repositories: row.repositories, | |
| 81 | issues: row.issues, | |
| 82 | users: row.users, | |
| 83 | pulls: row.pulls, | |
| 84 | apps: row.apps, | |
| 85 | }, | |
| 86 | // Not stored: green means health read ok; a red one failed at it or never got there. | |
| 87 | health: | |
| 88 | row.verdict === "green" ? "ok" : row.failed_step === "health" ? "failed" : "unreached", | |
| 89 | verdict: row.verdict, | |
| 90 | failedStep: row.failed_step, | |
| 91 | reason: row.reason, | |
| 92 | phases: kept.phases, | |
| 93 | budgetMs: kept.budgetMs, | |
| 94 | }; | |
| 68 | 95 | } |
src/web/pages/backups.ts +14 −2
| @@ -18,6 +18,7 @@ import { forgetMirror, saveMirror } from "../../actions/mirror.ts"; | ||
| 18 | 18 | import { mirrorSealedSet, mirrorNeed, MIRROR_SEALED } from "../../snapshot/mirror.ts"; |
| 19 | 19 | import { resolveDestination, sealedSet } from "../../snapshot/destination.ts"; |
| 20 | 20 | import { keyOf } from "../../snapshot/snapshot.ts"; |
| 21 | import { formatBudget, formatSpan, type DrillResult } from "../../snapshot/drill.ts"; | |
| 21 | 22 | import { lastDrill } from "../../snapshot/drills.ts"; |
| 22 | 23 | import { snapshotsOf } from "../../snapshot/service.ts"; |
| 23 | 24 | import { lastSnapshotOutcome } from "../../snapshot/outcome.ts"; |
| @@ -89,9 +90,18 @@ function keyNote( | ||
| 89 | 90 | </p>`; |
| 90 | 91 | } |
| 91 | 92 | |
| 93 | /** "6 min 12 s of its 14 min budget: files 58 s, ..."; empty for a drill that kept no phases. */ | |
| 94 | function drillTiming(d: DrillResult): string { | |
| 95 | if (d.phases.length === 0 || d.budgetMs <= 0) return ""; | |
| 96 | return `${formatSpan(d.ms)} of its ${formatBudget(d.budgetMs)} budget: ${d.phases | |
| 97 | .map((p) => `${p.phase} ${formatSpan(p.ms)}`) | |
| 98 | .join(", ")}`; | |
| 99 | } | |
| 100 | ||
| 92 | 101 | /** The last drill in one line, and the button to run another. */ |
| 93 | 102 | function drillLine(ctx: Ctx): Html { |
| 94 | 103 | const d = lastDrill(ctx.app.db); |
| 104 | const timing = d === undefined ? "" : drillTiming(d); | |
| 95 | 105 | const line = |
| 96 | 106 | d === undefined |
| 97 | 107 | ? html`<p class="backups-drill">No restore drill has run yet.</p>` |
| @@ -99,14 +109,16 @@ function drillLine(ctx: Ctx): Html { | ||
| 99 | 109 | ? html`<p class="backups-drill"> |
| 100 | 110 | ${icon("check")} Last drill: ${time(d.at)}, ${d.ms} ms, all counts matched — |
| 101 | 111 | snapshot <code>${d.snapshotId ?? ""}</code>, key opened — |
| 102 | ${d.counts.users} users, ${d.counts.repositories} repositories, ${d.counts.issues} issues, ${d.counts.pulls} pulls, ${d.counts.apps} apps. | |
| 112 | ${d.counts.users} users, ${d.counts.repositories} repositories, ${d.counts.issues} issues, ${d.counts.pulls} pulls, ${d.counts.apps} apps.${timing | |
| 113 | ? html` <span class="backups-timing">${timing}.</span>` | |
| 114 | : ""} | |
| 103 | 115 | </p>` |
| 104 | 116 | : html`<p class="backups-error backups-drill"> |
| 105 | 117 | ${icon("alert")} Last drill ${time(d.at)}: snapshot |
| 106 | 118 | <code>${d.snapshotId ?? "none"}</code> failed at |
| 107 | 119 | ${d.failedStep ?? "an unknown step"} after ${d.ms} ms${d.reason |
| 108 | 120 | ? html`: ${d.reason}` |
| 109 | : ""}. | |
| 121 | : ""}.${timing ? html` <span class="backups-timing">${timing}.</span>` : ""} | |
| 110 | 122 | </p>`; |
| 111 | 123 | return html`${line} |
| 112 | 124 | <form method="post" action="/settings/backups/drill" class="backups-now"> |
src/web/style/content.ts +1
| @@ -381,6 +381,7 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); } | ||
| 381 | 381 | .flag-form .input { min-width: 0; flex: 1 1 8rem; } |
| 382 | 382 | } |
| 383 | 383 | .backups-now { margin-top: 12px; } |
| 384 | .backups-drill { overflow-wrap: anywhere; } | |
| 384 | 385 | /* The fleet graph: a selection's spend and runs, hour by hour and day by day. |
| 385 | 386 | Series colours are Okabe–Ito (told apart with common colour-vision |
| 386 | 387 | deficiencies); the eighth, the folded "others", is grey. */ |
test/domains.test.ts +4 −1
| @@ -66,7 +66,10 @@ test("a nearly full disk says the state's words", () => { | ||
| 66 | 66 | test("a red restore drill says the snapshot store's words", () => { |
| 67 | 67 | recordVerdict(dataDir, { |
| 68 | 68 | at: new Date().toISOString(), snapshotId: null, ms: 1, counts: { repositories: 0, issues: 0, users: 0, pulls: 0, apps: 0 }, |
| 69 | health: "unreached", verdict: "red", failedStep: "fetch", reason: "no bucket", | |
| 69 | health: "unreached", verdict: "red", failedStep: "fetch", | |
| 70 | phases: [], | |
| 71 | budgetMs: 0, | |
| 72 | reason: "no bucket", | |
| 70 | 73 | }); |
| 71 | 74 | for (const s of said("snapshots")) expect(read()).toContain(s); |
| 72 | 75 | }); |
test/drill-board-line.test.ts +2
| @@ -27,6 +27,8 @@ const drill = (at: number, red: boolean): DrillResult => ({ | ||
| 27 | 27 | health: red ? "failed" : "ok", |
| 28 | 28 | verdict: red ? "red" : "green", |
| 29 | 29 | failedStep: red ? "unseal" : null, |
| 30 | phases: [], | |
outdated
Put phases and budgetMs on their own lines, matching the other fields. |
||
| 31 | budgetMs: 0, | |
| 30 | 32 | reason: red ? "a sealed value does not open with the restored key" : null, |
| 31 | 33 | }); |
| 32 | 34 |
test/drill-verdict.test.ts +4
| @@ -139,6 +139,8 @@ describe("the drill's verdict", () => { | ||
| 139 | 139 | health: "unreached", |
| 140 | 140 | verdict: "red", |
| 141 | 141 | failedStep: "fetch", |
| 142 | phases: [], | |
| 143 | budgetMs: 0, | |
| 142 | 144 | reason: "the store holds no snapshot", |
| 143 | 145 | }); |
| 144 | 146 | expect(text).toContain("FAILED at fetch"); |
| @@ -226,6 +228,8 @@ describe("telling the recovery issue", () => { | ||
| 226 | 228 | health: "ok", |
| 227 | 229 | verdict: "green", |
| 228 | 230 | failedStep: null, |
| 231 | phases: [], | |
| 232 | budgetMs: 0, | |
| 229 | 233 | reason: null, |
| 230 | 234 | }, |
| 231 | 235 | async (b) => (posts.push(b), "posted: 201"), |
test/drill-weekly.test.ts +6
| @@ -77,6 +77,8 @@ describe("the service runs a due drill once", () => { | ||
| 77 | 77 | health: "ok", |
| 78 | 78 | verdict: "green", |
| 79 | 79 | failedStep: null, |
| 80 | phases: [], | |
| 81 | budgetMs: 0, | |
| 80 | 82 | reason: null, |
| 81 | 83 | }; |
| 82 | 84 | }, |
| @@ -115,6 +117,8 @@ describe("the service runs a due drill once", () => { | ||
| 115 | 117 | health: verdict === "red" ? "unreached" : "ok", |
| 116 | 118 | verdict, |
| 117 | 119 | failedStep: verdict === "red" ? "restore" : null, |
| 120 | phases: [], | |
| 121 | budgetMs: 0, | |
| 118 | 122 | reason: verdict === "red" ? "restore timed out: no answer in 600000 ms" : null, |
| 119 | 123 | }; |
| 120 | 124 | }, |
| @@ -145,6 +149,8 @@ describe("the service's own clock drills a red one again", () => { | ||
| 145 | 149 | health: verdict === "red" ? "unreached" : "ok", |
| 146 | 150 | verdict, |
| 147 | 151 | failedStep: verdict === "red" ? "restore" : null, |
| 152 | phases: [], | |
| 153 | budgetMs: 0, | |
| 148 | 154 | reason: verdict === "red" ? "restore timed out: no answer in 600000 ms" : null, |
| 149 | 155 | }); |
| 150 | 156 |
test/snapshot-drill-page.test.ts +2
| @@ -68,6 +68,8 @@ describe("the drill on the Backups page", () => { | ||
| 68 | 68 | health: verdict === "green" ? "ok" : "unreached", |
| 69 | 69 | verdict, |
| 70 | 70 | failedStep: verdict === "green" ? null : "unseal", |
| 71 | phases: [], | |
| 72 | budgetMs: 0, | |
| 71 | 73 | reason: verdict === "green" ? null : redReason, |
| 72 | 74 | }; |
| 73 | 75 | }, |
test/snapshot-drill-phases.test.ts added +225
| @@ -0,0 +1,225 @@ | ||
| 1 | /** | |
| 2 | * A drill keeps how long each restore phase took and its budget (#2622): | |
| 3 | * green and red alike, round-tripped through the drill row, an older row | |
| 4 | * reading as no phases, and the Backups page showing the line escaped. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { createUser } from "../src/core/users.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | import { formatSpan, runDrill, type DrillResult } from "../src/snapshot/drill.ts"; | |
| 14 | import { lastDrill, parsePhases, recordDrill } from "../src/snapshot/drills.ts"; | |
| 15 | import { restoreSnapshot } from "../src/snapshot/restore.ts"; | |
| 16 | import { startSnapshots, type SnapshotsHandle } from "../src/snapshot/service.ts"; | |
| 17 | import { takeSnapshot } from "../src/snapshot/snapshot.ts"; | |
| 18 | import { dirStore } from "../src/snapshot/store.ts"; | |
| 19 | import { quietLogs } from "./helpers/globals.ts"; | |
| 20 | ||
| 21 | quietLogs(); | |
| 22 | ||
| 23 | const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); | |
| 24 | ||
| 25 | describe("drill phases", () => { | |
| 26 | let root = ""; | |
| 27 | let app: App; | |
| 28 | ||
| 29 | beforeAll(async () => { | |
| 30 | root = mkdtempSync(join(tmpdir(), "forge-drillphases-")); | |
| 31 | app = createApp({ dataDir: root, port: 0 }); | |
| 32 | const u = await createUser(app.db, { | |
| 33 | origin: "seed", | |
| 34 | trusted: true, | |
| 35 | username: "alice", | |
| 36 | email: "[email protected]", | |
| 37 | password: "a-good-passphrase", | |
| 38 | adminIfFirst: true, | |
| 39 | }); | |
| 40 | if (!u.ok) throw new Error(u.error); | |
| 41 | await takeSnapshot( | |
| 42 | { db: app.db, dataDir: root, reposRoot: app.reposRoot, version: "t" }, | |
| 43 | dirStore(join(root, "store")), | |
| 44 | new Date("2026-10-18T08:00:00Z"), | |
| 45 | ); | |
| 46 | }); | |
| 47 | afterAll(() => { | |
| 48 | app.close(); | |
| 49 | rmSync(root, { recursive: true, force: true }); | |
| 50 | }); | |
| 51 | ||
| 52 | const drill = (restore: typeof restoreSnapshot, timeoutMs?: number) => | |
| 53 | runDrill({ | |
| 54 | store: dirStore(join(root, "store")), | |
| 55 | dataDir: join(root, "drill-data"), | |
| 56 | boot: async () => ({ ok: true, reason: "" }), | |
| 57 | restore, | |
| 58 | timeoutMs, | |
| 59 | }); | |
| 60 | ||
| 61 | test("a green drill keeps the phases a stubbed restore reported, and the budget", async () => { | |
| 62 | const r = await drill((async (s: never, n: never, t: never, o: { onProgress?: (p: unknown) => void }) => { | |
| 63 | o.onProgress?.({ phase: "files", done: 0, total: 2, bytes: 0 }); | |
| 64 | await sleep(30); | |
| 65 | o.onProgress?.({ phase: "files", done: 2, total: 2, bytes: 9 }); | |
| 66 | o.onProgress?.({ phase: "unpack", done: 0, total: 1, bytes: 0 }); | |
| 67 | await sleep(30); | |
| 68 | o.onProgress?.({ phase: "key", done: 0, total: 1, bytes: 0 }); | |
| 69 | return restoreSnapshot(s, n, t, {}); | |
| 70 | }) as never); | |
| 71 | expect(r.verdict).toBe("green"); | |
| 72 | expect(r.budgetMs).toBeGreaterThanOrEqual(600_000); | |
| 73 | expect(r.budgetMs).toBeLessThan(601_000); | |
| 74 | expect(r.phases.map((p) => p.phase)).toEqual(["files", "unpack", "key"]); | |
| 75 | expect(r.phases[0]!.ms).toBeGreaterThanOrEqual(25); | |
| 76 | expect(r.phases[1]!.ms).toBeGreaterThanOrEqual(25); | |
| 77 | }); | |
| 78 | ||
| 79 | test("a red drill keeps them too", async () => { | |
| 80 | const r = await drill( | |
| 81 | (async (_s: never, _n: never, _t: never, o: { onProgress?: (p: unknown) => void }) => { | |
| 82 | o.onProgress?.({ phase: "bundles", done: 1, total: 9, bytes: 1 }); | |
| 83 | await new Promise(() => {}); | |
| 84 | }) as never, | |
| 85 | 300, | |
| 86 | ); | |
| 87 | expect(r.verdict).toBe("red"); | |
| 88 | expect(r.budgetMs).toBe(300); | |
| 89 | expect(r.phases.map((p) => p.phase)).toEqual(["bundles"]); | |
| 90 | }); | |
| 91 | ||
| 92 | test("a stored row round-trips, and an older row reads as no phases", () => { | |
| 93 | const base: DrillResult = { | |
| 94 | at: "2026-10-18T09:00:00.000Z", | |
| 95 | snapshotId: "s", | |
| 96 | ms: 5, | |
| 97 | counts: { repositories: 1, issues: 2, users: 3, pulls: 4, apps: 5 }, | |
| 98 | health: "ok", | |
| 99 | verdict: "green", | |
| 100 | failedStep: null, | |
| 101 | phases: [ | |
| 102 | { phase: "files", ms: 58_000 }, | |
| 103 | { phase: "bundles", ms: 290_000 }, | |
| 104 | ], | |
| 105 | budgetMs: 840_000, | |
| 106 | reason: null, | |
| 107 | }; | |
| 108 | recordDrill(app.db, base); | |
| 109 | expect(lastDrill(app.db)).toEqual(base); | |
| 110 | app.db.query("INSERT INTO drill (at, ms, repositories, issues, verdict) VALUES ('2026-10-19T00:00:00Z', 1, 0, 0, 'green')").run(); | |
| 111 | const old = lastDrill(app.db)!; | |
| 112 | expect(old.phases).toEqual([]); | |
| 113 | expect(old.budgetMs).toBe(0); | |
| 114 | }); | |
| 115 | ||
| 116 | test("stored JSON that is not phases reads as none", () => { | |
| 117 | for (const bad of ["x", "[]", '{"phases":[{"phase":"<b>","ms":1}],"budgetMs":5}', '{"phases":[{"phase":"files","ms":-1}],"budgetMs":5}', '{"phases":"a","budgetMs":5}']) | |
| 118 | expect(parsePhases(bad)).toEqual({ phases: [], budgetMs: 0 }); | |
| 119 | }); | |
| 120 | ||
| 121 | test("spans read as people say them", () => { | |
| 122 | expect(formatSpan(372_000)).toBe("6 min 12 s"); | |
| 123 | expect(formatSpan(58_000)).toBe("58 s"); | |
| 124 | expect(formatSpan(240_000)).toBe("4 min"); | |
| 125 | expect(formatSpan(340)).toBe("340 ms"); | |
| 126 | }); | |
| 127 | }); | |
| 128 | ||
| 129 | describe("the Backups page line", () => { | |
| 130 | let root = ""; | |
| 131 | let app: App; | |
| 132 | let svc: SnapshotsHandle; | |
| 133 | let servers: Servers; | |
| 134 | ||
| 135 | beforeAll(async () => { | |
| 136 | root = mkdtempSync(join(tmpdir(), "forge-drillline-")); | |
| 137 | app = createApp({ dataDir: root, port: 0 }); | |
| 138 | const u = await createUser(app.db, { | |
| 139 | origin: "seed", | |
| 140 | trusted: true, | |
| 141 | username: "alice", | |
| 142 | email: "[email protected]", | |
| 143 | password: "a-good-passphrase", | |
| 144 | adminIfFirst: true, | |
| 145 | }); | |
| 146 | if (!u.ok) throw new Error(u.error); | |
| 147 | svc = startSnapshots(app, dirStore(join(root, "store")), { version: "t", nightly: false }); | |
| 148 | servers = await serve(app); | |
| 149 | }); | |
| 150 | afterAll(async () => { | |
| 151 | servers.stop(); | |
| 152 | await svc.stop(); | |
| 153 | app.close(); | |
| 154 | rmSync(root, { recursive: true, force: true }); | |
| 155 | }); | |
| 156 | ||
| 157 | const kept = (over: Partial<DrillResult>): DrillResult => ({ | |
| 158 | at: new Date().toISOString(), | |
| 159 | snapshotId: "snap-1", | |
| 160 | ms: 372_000, | |
| 161 | counts: { repositories: 1, issues: 1, users: 1, pulls: 0, apps: 0 }, | |
| 162 | health: "ok", | |
| 163 | verdict: "green", | |
| 164 | failedStep: null, | |
| 165 | phases: [ | |
| 166 | { phase: "files", ms: 58_000 }, | |
| 167 | { phase: "unpack", ms: 20_000 }, | |
| 168 | { phase: "bundles", ms: 290_000 }, | |
| 169 | { phase: "key", ms: 1_000 }, | |
| 170 | ], | |
| 171 | budgetMs: 840_000, | |
| 172 | reason: null, | |
| 173 | ...over, | |
| 174 | }); | |
| 175 | const LINE = "6 min 12 s of its 14 min budget: files 58 s, unpack 20 s, bundles 4 min 50 s, key 1 s"; | |
| 176 | const backups = async (): Promise<string> => { | |
| 177 | const r = await fetch(`${servers.url}/signin`, { | |
| 178 | method: "POST", | |
| 179 | headers: { origin: servers.url }, | |
| 180 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 181 | redirect: "manual", | |
| 182 | }); | |
| 183 | const cookie = (r.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 184 | return (await fetch(`${servers.url}/settings/backups`, { headers: { cookie } })).text(); | |
| 185 | }; | |
| 186 | ||
| 187 | test("a green drill shows the time against the budget with each phase", async () => { | |
| 188 | recordDrill(app.db, kept({})); | |
| 189 | expect(await backups()).toContain(LINE); | |
| 190 | }); | |
| 191 | ||
| 192 | test("a red drill shows it too, beside its reason", async () => { | |
| 193 | recordDrill( | |
| 194 | app.db, | |
| 195 | kept({ | |
| 196 | verdict: "red", | |
| 197 | health: "unreached", | |
| 198 | failedStep: "restore", | |
| 199 | reason: "restore timed out in bundles <b>x</b>", | |
| 200 | }), | |
| 201 | ); | |
| 202 | const html = await backups(); | |
| 203 | expect(html).toContain("failed at"); | |
| 204 | expect(html).toContain("restore timed out in bundles <b>x</b>"); | |
| 205 | expect(html).toContain(LINE); | |
| 206 | }); | |
| 207 | ||
| 208 | test("a drill with no phases shows no timing", async () => { | |
| 209 | for (const verdict of ["green", "red"] as const) { | |
| 210 | recordDrill( | |
| 211 | app.db, | |
| 212 | kept({ | |
| 213 | verdict, | |
| 214 | failedStep: verdict === "red" ? "restore" : null, | |
| 215 | phases: [], | |
| 216 | budgetMs: 0, | |
| 217 | }), | |
| 218 | ); | |
| 219 | const html = await backups(); | |
| 220 | expect(html).toContain("snap-1"); | |
| 221 | expect(html).not.toContain("budget:"); | |
| 222 | expect(html).not.toContain("backups-timing"); | |
| 223 | } | |
| 224 | }); | |
| 225 | }); |
new above
offline
The red branch renders the timing span, but no test covers it. Add one with a red drill that has phases.