Public: anyone can read this; the crew can change it.
The Backups page opens with one line: "Recoverable now", or the first thing that stands in the way #2153
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2146-the-backups-page-opens-with-one-line-rec → main 2 commits fork 2 threads · 2 unresolved
2 files
changed +141
−2
src/web/pages/backups.ts +68 −2
| @@ -113,15 +113,71 @@ function drillLine(ctx: Ctx): Html { | ||
| 113 | 113 | </form>`; |
| 114 | 114 | } |
| 115 | 115 | |
| 116 | const SNAPSHOT_FRESH_MS = 26 * 3_600_000; | |
| 117 | const DRILL_FRESH_MS = 8 * 86_400_000; | |
| 118 | ||
| 119 | function ago(ms: number): string { | |
| 120 | const hours = Math.floor(ms / 3_600_000); | |
| 121 | if (hours < 48) return hours === 1 ? "1 hour" : `${hours} hours`; | |
| 122 | return `${Math.floor(hours / 24)} days`; | |
| 123 | } | |
| 124 | ||
| 125 | export interface Recovery { | |
| 126 | /** Whether the store answered; false when there is no store or listing failed. */ | |
| 127 | readonly reachable: boolean; | |
| 128 | readonly unreachable?: string | undefined; | |
| 129 | readonly newest?: { finishedAt: string; key: "wrapped" | "none" | "raw" } | undefined; | |
| 130 | readonly drill?: { at: string; verdict: "green" | "red" } | undefined; | |
| 131 | readonly now: number; | |
| 132 | } | |
| 133 | ||
| 134 | /** "Recoverable now", or the first of four facts that stands in the way. */ | |
| 135 | export function recoverySentence(r: Recovery): string { | |
| 136 | const no = (why: string): string => `Not recoverable now: ${why}`; | |
| 137 | if (!r.reachable) return no(r.unreachable ?? "the snapshot store cannot be reached"); | |
| 138 | if (r.newest === undefined) return no("there is no snapshot yet"); | |
| 139 | const age = r.now - Date.parse(r.newest.finishedAt); | |
| 140 | if (!(age < SNAPSHOT_FRESH_MS)) | |
| 141 | return no( | |
| 142 | Number.isNaN(age) | |
| 143 | ? "the newest snapshot has no readable date" | |
| 144 | : `the newest snapshot is ${ago(age)} old`, | |
| 145 | ); | |
| 146 | if (r.newest.key !== "wrapped") return no("the newest snapshot does not carry a wrapped key"); | |
| 147 | if (r.drill === undefined) return no("no restore drill has run yet"); | |
| 148 | if (r.drill.verdict !== "green") return no("the last restore drill failed"); | |
| 149 | const drillAge = r.now - Date.parse(r.drill.at); | |
| 150 | if (!(drillAge < DRILL_FRESH_MS)) | |
| 151 | return no( | |
| 152 | Number.isNaN(drillAge) | |
| 153 | ? "the last restore drill has no readable date" | |
| 154 | : `the last restore drill is ${ago(drillAge)} old`, | |
| 155 | ); | |
| 156 | return "Recoverable now"; | |
| 157 | } | |
| 158 | ||
| 159 | function recoveryLine(r: Recovery): Html { | |
| 160 | const sentence = recoverySentence(r); | |
| 161 | const ok = sentence === "Recoverable now"; | |
| 162 | return html`<p class="backups-recovery${ok ? "" : " backups-error"}" role="status"> | |
| 163 | ${icon(ok ? "check" : "alert")} <strong>${sentence}</strong> | |
| 164 | </p>`; | |
| 165 | } | |
| 166 | ||
| 116 | 167 | export async function backupsPage(ctx: Ctx): Promise<Response> { |
| 117 | 168 | const denied = requireAdmin(ctx); |
| 118 | 169 | if (denied) return denied; |
| 119 | 170 | const svc = snapshotsOf(ctx.app); |
| 120 | 171 | const form = html`${destinationCard(ctx)}${mirrorCard(ctx)}`; |
| 172 | const now = Date.now(); | |
| 121 | 173 | if (svc === undefined || svc.store === undefined) |
| 122 | 174 | return shell( |
| 123 | 175 | ctx, |
| 124 | html`${empty({ | |
| 176 | html`${recoveryLine({ | |
| 177 | reachable: false, | |
| 178 | unreachable: "no snapshot store is set", | |
| 179 | now, | |
| 180 | })}${empty({ | |
| 125 | 181 | icon: "download", |
| 126 | 182 | title: "No snapshots are taken", |
| 127 | 183 | note: "This forge was given nowhere to put them. On the platform an app's bucket (S3_*) is the place; on a machine, FORGE_SNAPSHOT_DIR names a directory. Seal a bucket below, or restart with one set, and a snapshot is taken every night at 08:00 UTC.", |
| @@ -137,13 +193,23 @@ export async function backupsPage(ctx: Ctx): Promise<Response> { | ||
| 137 | 193 | } catch (error) { |
| 138 | 194 | listing = String(error); |
| 139 | 195 | } |
| 196 | const d = lastDrill(ctx.app.db); | |
| 197 | const recovery = recoveryLine({ | |
| 198 | reachable: listing === undefined, | |
| 199 | unreachable: "the snapshot store could not be listed", | |
| 200 | ...(snapshots[0] | |
| 201 | ? { newest: { finishedAt: snapshots[0].finishedAt, key: keyOf(snapshots[0]) } } | |
| 202 | : {}), | |
| 203 | ...(d ? { drill: { at: d.at, verdict: d.verdict } } : {}), | |
| 204 | now, | |
| 205 | }); | |
| 140 | 206 | const restore = snapshots[0] |
| 141 | 207 | ? html`<pre class="backups-restore"> |
| 142 | 208 | bun run src/snapshot/restore.ts ${snapshots[0].name} /path/to/empty/data</pre>` |
| 143 | 209 | : null; |
| 144 | 210 | return shell( |
| 145 | 211 | ctx, |
| 146 | html`${card( | |
| 212 | html`${recovery}${card( | |
| 147 | 213 | html`<p> |
| 148 | 214 | Every night at 08:00 UTC the forge writes what cannot be rebuilt — its |
| 149 | 215 | database, its keys, every repository, every app's production data, the |
test/snapshot-drill-page.test.ts +73
| @@ -139,3 +139,76 @@ describe("the drill on the Backups page", () => { | ||
| 139 | 139 | expect(rows() - rowsBefore).toBe(1); |
| 140 | 140 | }); |
| 141 | 141 | }); |
| 142 | ||
| 143 | import { recoverySentence, type Recovery } from "../src/web/pages/backups.ts"; | |
| 144 | ||
Move this import to the top of the file with the others. |
||
| 145 | describe("the recovery line", () => { | |
| 146 | const now = Date.parse("2026-10-16T12:00:00Z"); | |
| 147 | const hoursAgo = (h: number) => new Date(now - h * 3_600_000).toISOString(); | |
| 148 | const good: Recovery = { | |
| 149 | reachable: true, | |
| 150 | newest: { finishedAt: hoursAgo(5), key: "wrapped" }, | |
| 151 | drill: { at: hoursAgo(24), verdict: "green" }, | |
| 152 | now, | |
| 153 | }; | |
| 154 | const say = (over: Partial<Recovery>) => recoverySentence({ ...good, ...over }); | |
| 155 | ||
| 156 | test("all four facts hold", () => { | |
| 157 | expect(say({})).toBe("Recoverable now"); | |
| 158 | }); | |
| 159 | test("the store is unreachable", () => { | |
| 160 | expect(say({ reachable: false })).toBe( | |
| 161 | "Not recoverable now: the snapshot store cannot be reached", | |
| 162 | ); | |
| 163 | }); | |
| 164 | test("the newest snapshot is old", () => { | |
| 165 | expect(say({ newest: { finishedAt: hoursAgo(72), key: "wrapped" } })).toBe( | |
| 166 | "Not recoverable now: the newest snapshot is 3 days old", | |
| 167 | ); | |
| 168 | expect(say({ newest: undefined })).toBe("Not recoverable now: there is no snapshot yet"); | |
| 169 | }); | |
| 170 | test("the key is not carried", () => { | |
| 171 | expect(say({ newest: { finishedAt: hoursAgo(5), key: "none" } })).toBe( | |
| 172 | "Not recoverable now: the newest snapshot does not carry a wrapped key", | |
| 173 | ); | |
| 174 | }); | |
| 175 | test("the drill is missing, red or old", () => { | |
| 176 | expect(say({ drill: undefined })).toBe("Not recoverable now: no restore drill has run yet"); | |
| 177 | expect(say({ drill: { at: hoursAgo(1), verdict: "red" } })).toBe( | |
| 178 | "Not recoverable now: the last restore drill failed", | |
| 179 | ); | |
| 180 | expect(say({ drill: { at: hoursAgo(24 * 9), verdict: "green" } })).toBe( | |
| 181 | "Not recoverable now: the last restore drill is 9 days old", | |
| 182 | ); | |
| 183 | }); | |
| 184 | test("the first failing fact wins", () => { | |
| 185 | expect(say({ reachable: false, drill: undefined })).toContain("cannot be reached"); | |
| 186 | }); | |
| 187 | }); | |
| 188 | ||
| 189 | describe("the recovery line on the page", () => { | |
| 190 | test("is the first thing on the page, escaped", async () => { | |
| 191 | const root = mkdtempSync(join(tmpdir(), "forge-recov-")); | |
| 192 | const app = createApp({ dataDir: root, port: 0 }); | |
| 193 | const u = await createUser(app.db, { | |
| 194 | trusted: true, username: "alice", email: "[email protected]", | |
| 195 | password: "a-good-passphrase", adminIfFirst: true, | |
| 196 | }); | |
| 197 | if (!u.ok) throw new Error(u.error); | |
| 198 | const servers = await serve(app); | |
| 199 | try { | |
| 200 | const r = await fetch(`${servers.url}/signin`, { | |
| 201 | method: "POST", headers: { origin: servers.url }, | |
| 202 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 203 | redirect: "manual", | |
| 204 | }); | |
| 205 | const cookie = (r.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 206 | const html = await (await fetch(`${servers.url}/settings/backups`, { headers: { cookie } })).text(); | |
| 207 | expect(html).toContain("Not recoverable now: no snapshot store is set"); | |
| 208 | } finally { | |
| 209 | servers.stop(); | |
| 210 | app.close(); | |
| 211 | rmSync(root, { recursive: true, force: true }); | |
| 212 | } | |
| 213 | }); | |
| 214 | }); |
new above
offline
The listing error is dropped here. The line only says "could not be listed"; consider including a short reason.