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

Merged opened this

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

Merged

scout/forge:scout/2146-the-backups-page-opens-with-one-line-rec → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/2146-the-backups-page-opens-with-one-line-rec was merged into main as 45c55d4.

Review

Sign in to review this change.
2 files changed +141 −2
src/web/pages/backups.ts +68 −2
@@ -113,15 +113,71 @@ function drillLine(ctx: Ctx): Html {
113113 </form>`;
114114 }
115115
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
116167 export async function backupsPage(ctx: Ctx): Promise<Response> {
117168 const denied = requireAdmin(ctx);
118169 if (denied) return denied;
119170 const svc = snapshotsOf(ctx.app);
120171 const form = html`${destinationCard(ctx)}${mirrorCard(ctx)}`;
172 const now = Date.now();
121173 if (svc === undefined || svc.store === undefined)
122174 return shell(
123175 ctx,
124 html`${empty({
176 html`${recoveryLine({
177 reachable: false,
178 unreachable: "no snapshot store is set",
179 now,
180 })}${empty({
125181 icon: "download",
126182 title: "No snapshots are taken",
127183 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> {
137193 } catch (error) {
138194 listing = String(error);
139195 }
196 const d = lastDrill(ctx.app.db);
197 const recovery = recoveryLine({
198 reachable: listing === undefined,
199 unreachable: "the snapshot store could not be listed",

The listing error is dropped here. The line only says "could not be listed"; consider including a short reason.

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 });
140206 const restore = snapshots[0]
141207 ? html`<pre class="backups-restore">
142208 bun run src/snapshot/restore.ts ${snapshots[0].name} /path/to/empty/data</pre>`
143209 : null;
144210 return shell(
145211 ctx,
146 html`${card(
212 html`${recovery}${card(
147213 html`<p>
148214 Every night at 08:00 UTC the forge writes what cannot be rebuilt — its
149215 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", () => {
139139 expect(rows() - rowsBefore).toBe(1);
140140 });
141141 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs