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

/settings/backups' now, drill, error and restore parts draw §5's card, row, badge and btn, long ids still wrap, and .backups-* leave the markup #4208

Merged opened this

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

Merged

scout/forge:scout/4206-settings-backups-now-drill-error-and-res → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/4206-settings-backups-now-drill-error-and-res was merged into main as 293f801.

Review

Sign in to review this change.
4 files changed +182 −34
src/web/pages/backups.ts +33 −33
@@ -41,7 +41,7 @@ import { field, readForm, type Form } from "../form.ts";
4141 import { html, when, type Html } from "../html.ts";
4242 import { liveRegionsJs } from "../client.ts";
4343 import { flashTo, fragment, page, redirect } from "../layout.ts";
44 import { btn, card, empty, fileSize, icon, row, time } from "../ui.ts";
44 import { badge, btn, card, empty, fileSize, icon, row, time } from "../ui.ts";
4545 import { accountSections } from "./origins.ts";
4646
4747 function requireAdmin(ctx: Ctx): Response | undefined {
@@ -87,16 +87,16 @@ function keyNote(
8787 ? " The last snapshot carried the key unwrapped, as older snapshots did; take a new one."
8888 : " The last snapshot carried no key.";
8989 return wraps
90 ? html`<p>
91 ${icon("check")} The key is wrapped under
90 ? html`<p class="break">
91 ${badge("Ready", { tone: "success", icon: "check" })} The key is wrapped under
9292 <code>FORGE_SNAPSHOT_PASSPHRASE</code> and travels with each
9393 snapshot.${last} Keep that passphrase somewhere other than this bucket:
9494 without it a restore brings back repositories and issues, but every
9595 sealed value (secrets, app variables, origin tokens) is lost and must be
9696 set again.
9797 </p>`
98 : html`<p class="backups-error">
99 ${icon("alert")} Snapshots carry no key: the bucket never holds key and
98 : html`<p class="break">
99 ${badge("At risk", { tone: "danger", icon: "alert" })} Snapshots carry no key: the bucket never holds key and
100100 data together, and a restore will bring back repositories and issues but
101101 not sealed values, which must be set again.${last} To carry the key
102102 wrapped, set <code>FORGE_SNAPSHOT_PASSPHRASE</code> and restart — and
@@ -124,24 +124,24 @@ function drillLine(ctx: Ctx): Html {
124124 const timing = d === undefined ? "" : drillTiming(d);
125125 const line =
126126 d === undefined
127 ? html`<p class="backups-drill">No restore drill has run yet.</p>`
127 ? html`<p class="break">No restore drill has run yet.</p>`
128128 : d.verdict === "green"
129 ? html`<p class="backups-drill">
130 ${icon("check")} Last drill: ${time(d.at)}, ${d.ms} ms, all counts matched —
129 ? html`<p class="break">
130 ${badge("Ready", { tone: "success", icon: "check" })} Last drill: ${time(d.at)}, ${d.ms} ms, all counts matched —
131131 snapshot <code>${d.snapshotId ?? ""}</code>, key opened —
132132 ${d.counts.users} users, ${d.counts.repositories} repositories, ${d.counts.issues} issues, ${d.counts.pulls} pulls, ${d.counts.apps} apps.${timing
133 ? html` <span class="backups-timing">${timing}.</span>`
133 ? html` <span class="muted small">${timing}.</span>`
outdated

backups-timing is still a .backups-* class in the markup (also on the red drill line). Use muted small and drop the class.

134134 : ""}
135135 </p>`
136 : html`<p class="backups-error backups-drill">
137 ${icon("alert")} Last drill ${time(d.at)}: snapshot
136 : html`<p class="break">
137 ${badge("At risk", { tone: "danger", icon: "alert" })} Last drill ${time(d.at)}: snapshot
138138 <code>${d.snapshotId ?? "none"}</code> failed at
139139 ${d.failedStep ?? "an unknown step"} after ${d.ms} ms${d.reason
140140 ? html`: ${d.reason}`
141 : ""}.${timing ? html` <span class="backups-timing">${timing}.</span>` : ""}
141 : ""}.${timing ? html` <span class="muted small">${timing}.</span>` : ""}
142142 </p>`;
143143 return html`${line}
144 <form method="post" action="/settings/backups/drill" class="backups-now">
144 <form method="post" action="/settings/backups/drill" class="mt">
145145 ${btn("Run a drill", { type: "submit", icon: "clock" })}
146146 </form>`;
147147 }
@@ -192,8 +192,8 @@ export function recoverySentence(r: Recovery): string {
192192 function recoveryLine(r: Recovery): Html {
193193 const sentence = recoverySentence(r);
194194 const ok = sentence === "Recoverable now";
195 return html`<p class="backups-recovery${ok ? "" : " backups-error"}" role="status">
196 ${icon(ok ? "check" : "alert")} <strong>${sentence}</strong>
195 return html`<p class="break" role="status">
196 ${ok ? badge("Ready", { tone: "success", icon: "check" }) : badge("At risk", { tone: "danger", icon: "alert" })} <strong>${sentence}</strong>
197197 </p>`;
198198 }
199199
@@ -335,8 +335,7 @@ async function movingPart(
335335 now,
336336 });
337337 const restore = snapshots[0]
338 ? html`<pre class="backups-restore">
339 bun run src/snapshot/restore.ts ${snapshots[0].name} /path/to/empty/data</pre>`
338 ? html`<pre class="domain-records" tabindex="0" aria-label="Restore command">bun run src/snapshot/restore.ts ${snapshots[0].name} /path/to/empty/data</pre>`
340339 : null;
341340 return html`<div id="backups-live" data-live="backup.*" data-live-src="/settings/backups/live">${recovery}${card(
342341 html`<p>
@@ -349,8 +348,8 @@ bun run src/snapshot/restore.ts ${snapshots[0].name} /path/to/empty/data</pre>`
349348 ${when(
350349 svc.progress(),
351350 (p) =>
352 html`<p class="backups-progress">
353 ${icon("clock")} A snapshot is being taken: ${p.objects} objects,
351 html`<p class="break">
352 ${badge("Running", { icon: "clock", variant: "secondary" })} A snapshot is being taken: ${p.objects} objects,
354353 ${fileSize(p.bytes)} so far, ${Math.round(p.elapsedMs / 1000)} s
355354 in.
356355 </p>`,
@@ -358,20 +357,20 @@ bun run src/snapshot/restore.ts ${snapshots[0].name} /path/to/empty/data</pre>`
358357 ${when(
359358 failing,
360359 (f) =>
361 html`<p class="backups-error" role="alert">
362 ${icon("alert")} The last snapshot failed ${time(f.at)}, failing
360 html`<p class="break" role="alert">
361 ${badge("Failed", { tone: "danger", icon: "alert" })} The last snapshot failed ${time(f.at)}, failing
363362 since ${time(f.since)}: ${f.error}
364363 </p>`,
365364 )}
366365 ${when(
367366 listing,
368367 (error) =>
369 html`<p class="backups-error">
370 ${icon("alert")} The store could not be listed just now: ${error}
368 html`<p class="break">
369 ${badge("Failed", { tone: "danger", icon: "alert" })} The store could not be listed just now: ${error}
371370 </p>`,
372371 )}
373372 ${drillLine(ctx)}
374 <form method="post" action="/settings/backups/now" class="backups-now">
373 <form method="post" action="/settings/backups/now" class="mt">
375374 ${btn("Snapshot now", {
376375 type: "submit",
377376 variant: "primary",
@@ -414,17 +413,18 @@ bun run src/snapshot/restore.ts ${snapshots[0].name} /path/to/empty/data</pre>`
414413 </div>
415414 </li>`,
416415 )}
417 </ul>`,
416 </ul>
417 <div class="card-body">
418 <p class="muted small break">
419 To restore, stop a forge, point it at an empty data directory,
420 and run this with the same S3_* variables or FORGE_SNAPSHOT_DIR:
421 </p>
422 ${restore}
423 </div>`,
418424 {
419425 pad: false,
420426 head: html`${icon("history")}
421427 <h2>Snapshots</h2>`,
422 foot: html`<span class="muted small"
423 >To restore, stop a forge, point it at an empty data
424 directory, and run this with the same S3_* variables or
425 FORGE_SNAPSHOT_DIR:</span
426 >
427 ${restore}`,
428428 },
429429 )
430430 }
@@ -532,8 +532,8 @@ function mirrorCard(ctx: Ctx): Html {
532532 every snapshot is copied there within the hour.</p>`
533533 : state === undefined
534534 ? html`<p>Mirroring to <code>${svc!.mirror!.where}</code>; the first pass has not run yet.</p>`
535 : html`<p class="${need ? "backups-error" : ""}">
536 ${icon(need ? "alert" : "check")} <code>${state.where}</code> is
535 : html`<p class="break">
536 ${need ? badge("At risk", { tone: "danger", icon: "alert" }) : badge("Ready", { tone: "success", icon: "check" })} <code>${state.where}</code> is
537537 ${state.reachable ? "reachable" : "unreachable"} (checked
538538 ${time(state.attemptAt)}).
539539 ${state.newest
test/backups-live.test.ts +2
@@ -92,6 +92,8 @@ test("a backup moving emits its events, and /backups does not reload itself mean
9292 const moving = await fetch(`${servers.url}/settings/backups`, { headers: { cookie: alice } });
9393 const text = await moving.text();
9494 expect(text).not.toContain('http-equiv="refresh"');
95 expect(text).toContain("A snapshot is being taken");
96 expect(text).not.toMatch(/class="[^"]*\bbackups-/);
9597 expect(text).toContain('id="backups-live"');
9698 expect(text).toContain('data-live="backup.*"');
9799 expect(text).toContain('data-live-src="/settings/backups/live"');
test/backups-parts.test.ts added +146
@@ -0,0 +1,146 @@
1 /**
2 * /settings/backups draws its now, drill, error and restore parts with the
3 * design system's card, row, badge and btn: no `.backups-*` class of the old
4 * markup, every sentence that can carry a long id wraps, the restore command
5 * is a scrollable pre in the card body, and every form posts what it did.
6 */
7 import { afterAll, beforeAll, expect, test } from "bun:test";
8 import { mkdtempSync, rmSync } from "node:fs";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { createUser } from "../src/core/users.ts";
13 import { serve, type Servers } from "../src/http/server.ts";
14 import { startSnapshots, type SnapshotsHandle } from "../src/snapshot/service.ts";
15 import { recordDrill } from "../src/snapshot/drills.ts";
16 import { dirStore } from "../src/snapshot/store.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18
19 quietLogs();
20
21 let root = "";
22 let app: App;
23 let svc: SnapshotsHandle;
24 let servers: Servers;
25 let cookie = "";
26
27 beforeAll(async () => {
28 root = mkdtempSync(join(tmpdir(), "forge-backups-parts-"));
29 app = createApp({ dataDir: root, port: 0 });
30 const u = await createUser(app.db, {
31 origin: "seed",
32 trusted: true,
33 username: "alice",
34 email: "[email protected]",
35 password: "a-good-passphrase",
36 adminIfFirst: true,
37 });
38 if (!u.ok) throw new Error(u.error);
39 svc = startSnapshots(app, dirStore(join(root, "store")), { version: "t", nightly: false });
40 servers = await serve(app);
41 const r = await fetch(`${servers.url}/signin`, {
42 method: "POST",
43 headers: { origin: servers.url },
44 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
45 redirect: "manual",
46 });
47 cookie = (r.headers.get("set-cookie") ?? "").split(";")[0]!;
48 });
49 afterAll(async () => {
50 servers.stop();
51 await svc.stop();
52 app.close();
53 rmSync(root, { recursive: true, force: true });
54 });
55
56 const get = async (): Promise<string> =>
57 (await fetch(`${servers.url}/settings/backups`, { headers: { cookie } })).text();
58
59 const OLD_CLASS = /class="[^"]*\bbackups-/;
60
61 test("the page carries none of the old classes, and keeps its words, links and forms", async () => {
62 await svc.now();

OLD lists four names, so backups-timing and backups-progress slip through. Assert against a regex for any class="...backups-..." instead.

63 const page = await get();
64 expect(page).not.toMatch(OLD_CLASS);
65 expect(page).toContain('id="backups-live"');
66 expect(page).toContain('class="badge"');
67 expect(page).toContain("Snapshot now");
68 expect(page).toContain("Run a drill");
69 expect(page).toContain("No restore drill has run yet.");
70 expect(page).toContain('action="/settings/backups/now"');
71 expect(page).toContain('action="/settings/backups/drill"');
72 expect(page).toContain('action="/settings/backups/destination"');
73 expect(page).toContain('href="/settings/backups"');
74 });
75
76 test("the restore command is a scrollable pre in the card body, not in the foot", async () => {
77 const page = await get();
78 const pre = /<pre[^>]*>[^]*?<\/pre>/.exec(page)?.[0] ?? "";
79 expect(pre).toContain('tabindex="0"');
80 expect(pre).toContain("aria-label=");
81 expect(pre).toContain("bun run src/snapshot/restore.ts");
82 const at = page.indexOf(pre);
83 const body = page.lastIndexOf('class="card-body"', at);
84 const foot = page.lastIndexOf('class="card-foot"', at);
85 expect(body).toBeGreaterThan(foot);
86 });
87
88 test("the paragraphs that can carry a long id, reason or step wrap", async () => {
89 const long = "x".repeat(120);
90 const wraps = (page: string, needle: string): boolean => {
91 const at = page.indexOf(needle);
92 const open = page.lastIndexOf("<p", at);
93 return page.slice(open, page.indexOf(">", open)).includes('class="break"');
94 };
95 const counts = { users: 1, repositories: 1, issues: 1, pulls: 1, apps: 1 };
96 const base = { at: new Date().toISOString(), ms: 5, counts, health: "ok", phases: [], budgetMs: 0 } as const;
97 recordDrill(app.db, { ...base, snapshotId: long, verdict: "green", failedStep: null, reason: null });
98 expect(wraps(await get(), long)).toBe(true);
99 recordDrill(app.db, {
100 ...base,
101 snapshotId: long + "y",
102 verdict: "red",
103 failedStep: "restore" as never,
104 reason: long + "reason",
105 });
106 const page = await get();
107 expect(wraps(page, long + "y")).toBe(true);
108 expect(wraps(page, long + "reason")).toBe(true);
109 });
110
111 test("a failed snapshot and a failed listing wrap too", async () => {
112 const long = "z".repeat(120);
113 const bad = startSnapshots(app, { ...dirStore(join(root, "bad")), put: async () => { throw new Error(long); } }, {
114 version: "t",
115 nightly: false,
116 });
117 await expect(bad.now()).rejects.toThrow();
118 const page = await get();
119 await bad.stop();
120 const open = page.lastIndexOf("<p", page.indexOf(long));
121 expect(page.slice(open, page.indexOf(">", open))).toContain('class="break"');
122 });
123
124 test("a listing that fails is a badge and a wrapping paragraph, and no old class", async () => {
125 const long = "q".repeat(120);
126 const inner = dirStore(join(root, "unlistable"));
127 const broken = startSnapshots(
128 app,
129 {
130 ...inner,
131 list: async (): Promise<string[]> => {
132 throw new Error(long);
133 },
134 names: async (): Promise<string[]> => {
135 throw new Error(long);
136 },
137 },
138 { version: "t", nightly: false },
139 );
140 const page = await get();
141 await broken.stop();
142 expect(page).not.toMatch(OLD_CLASS);
143 const open = page.lastIndexOf("<p", page.indexOf(long));
144 expect(page.slice(open, page.indexOf(">", open))).toContain('class="break"');
145 expect(page.slice(open, page.indexOf("</p>", open))).toContain("The store could not be listed");
146 });
test/snapshot-outcome.test.ts +1 −1
@@ -102,7 +102,7 @@ describe("a nightly that fails", () => {
102102 // The terminal's dashboard prints the forge's needs: the card is there too.
103103 expect(forgeNeeds(app, admin)?.some((l) => l.includes("The last snapshot failed"))).toBe(true);
104104 const page = await (await fetch(`${servers.url}/settings/backups`, { headers: { cookie } })).text();
105 expect(page).toContain("backups-error");
105 expect(page).not.toContain("backups-error");
106106 expect(page.replace(/\s+/g, " ")).toContain("The last snapshot failed");
107107 expect(page).toContain("no answer in 180 s");
108108

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs