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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4206-settings-backups-now-drill-error-and-res → main 2 commits fork 2 threads · 2 unresolved
4 files
changed +182
−34
src/web/pages/backups.ts +33 −33
| @@ -41,7 +41,7 @@ import { field, readForm, type Form } from "../form.ts"; | ||
| 41 | 41 | import { html, when, type Html } from "../html.ts"; |
| 42 | 42 | import { liveRegionsJs } from "../client.ts"; |
| 43 | 43 | 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"; | |
| 45 | 45 | import { accountSections } from "./origins.ts"; |
| 46 | 46 | |
| 47 | 47 | function requireAdmin(ctx: Ctx): Response | undefined { |
| @@ -87,16 +87,16 @@ function keyNote( | ||
| 87 | 87 | ? " The last snapshot carried the key unwrapped, as older snapshots did; take a new one." |
| 88 | 88 | : " The last snapshot carried no key."; |
| 89 | 89 | 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 | |
| 92 | 92 | <code>FORGE_SNAPSHOT_PASSPHRASE</code> and travels with each |
| 93 | 93 | snapshot.${last} Keep that passphrase somewhere other than this bucket: |
| 94 | 94 | without it a restore brings back repositories and issues, but every |
| 95 | 95 | sealed value (secrets, app variables, origin tokens) is lost and must be |
| 96 | 96 | set again. |
| 97 | 97 | </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 | |
| 100 | 100 | data together, and a restore will bring back repositories and issues but |
| 101 | 101 | not sealed values, which must be set again.${last} To carry the key |
| 102 | 102 | wrapped, set <code>FORGE_SNAPSHOT_PASSPHRASE</code> and restart — and |
| @@ -124,24 +124,24 @@ function drillLine(ctx: Ctx): Html { | ||
| 124 | 124 | const timing = d === undefined ? "" : drillTiming(d); |
| 125 | 125 | const line = |
| 126 | 126 | 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>` | |
| 128 | 128 | : 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 — | |
| 131 | 131 | snapshot <code>${d.snapshotId ?? ""}</code>, key opened — |
| 132 | 132 | ${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>` | |
| 134 | 134 | : ""} |
| 135 | 135 | </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 | |
| 138 | 138 | <code>${d.snapshotId ?? "none"}</code> failed at |
| 139 | 139 | ${d.failedStep ?? "an unknown step"} after ${d.ms} ms${d.reason |
| 140 | 140 | ? html`: ${d.reason}` |
| 141 | : ""}.${timing ? html` <span class="backups-timing">${timing}.</span>` : ""} | |
| 141 | : ""}.${timing ? html` <span class="muted small">${timing}.</span>` : ""} | |
| 142 | 142 | </p>`; |
| 143 | 143 | return html`${line} |
| 144 | <form method="post" action="/settings/backups/drill" class="backups-now"> | |
| 144 | <form method="post" action="/settings/backups/drill" class="mt"> | |
| 145 | 145 | ${btn("Run a drill", { type: "submit", icon: "clock" })} |
| 146 | 146 | </form>`; |
| 147 | 147 | } |
| @@ -192,8 +192,8 @@ export function recoverySentence(r: Recovery): string { | ||
| 192 | 192 | function recoveryLine(r: Recovery): Html { |
| 193 | 193 | const sentence = recoverySentence(r); |
| 194 | 194 | 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> | |
| 197 | 197 | </p>`; |
| 198 | 198 | } |
| 199 | 199 | |
| @@ -335,8 +335,7 @@ async function movingPart( | ||
| 335 | 335 | now, |
| 336 | 336 | }); |
| 337 | 337 | 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>` | |
| 340 | 339 | : null; |
| 341 | 340 | return html`<div id="backups-live" data-live="backup.*" data-live-src="/settings/backups/live">${recovery}${card( |
| 342 | 341 | html`<p> |
| @@ -349,8 +348,8 @@ bun run src/snapshot/restore.ts ${snapshots[0].name} /path/to/empty/data</pre>` | ||
| 349 | 348 | ${when( |
| 350 | 349 | svc.progress(), |
| 351 | 350 | (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, | |
| 354 | 353 | ${fileSize(p.bytes)} so far, ${Math.round(p.elapsedMs / 1000)} s |
| 355 | 354 | in. |
| 356 | 355 | </p>`, |
| @@ -358,20 +357,20 @@ bun run src/snapshot/restore.ts ${snapshots[0].name} /path/to/empty/data</pre>` | ||
| 358 | 357 | ${when( |
| 359 | 358 | failing, |
| 360 | 359 | (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 | |
| 363 | 362 | since ${time(f.since)}: ${f.error} |
| 364 | 363 | </p>`, |
| 365 | 364 | )} |
| 366 | 365 | ${when( |
| 367 | 366 | listing, |
| 368 | 367 | (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} | |
| 371 | 370 | </p>`, |
| 372 | 371 | )} |
| 373 | 372 | ${drillLine(ctx)} |
| 374 | <form method="post" action="/settings/backups/now" class="backups-now"> | |
| 373 | <form method="post" action="/settings/backups/now" class="mt"> | |
| 375 | 374 | ${btn("Snapshot now", { |
| 376 | 375 | type: "submit", |
| 377 | 376 | variant: "primary", |
| @@ -414,17 +413,18 @@ bun run src/snapshot/restore.ts ${snapshots[0].name} /path/to/empty/data</pre>` | ||
| 414 | 413 | </div> |
| 415 | 414 | </li>`, |
| 416 | 415 | )} |
| 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>`, | |
| 418 | 424 | { |
| 419 | 425 | pad: false, |
| 420 | 426 | head: html`${icon("history")} |
| 421 | 427 | <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}`, | |
| 428 | 428 | }, |
| 429 | 429 | ) |
| 430 | 430 | } |
| @@ -532,8 +532,8 @@ function mirrorCard(ctx: Ctx): Html { | ||
| 532 | 532 | every snapshot is copied there within the hour.</p>` |
| 533 | 533 | : state === undefined |
| 534 | 534 | ? 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 | |
| 537 | 537 | ${state.reachable ? "reachable" : "unreachable"} (checked |
| 538 | 538 | ${time(state.attemptAt)}). |
| 539 | 539 | ${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 | ||
| 92 | 92 | const moving = await fetch(`${servers.url}/settings/backups`, { headers: { cookie: alice } }); |
| 93 | 93 | const text = await moving.text(); |
| 94 | 94 | expect(text).not.toContain('http-equiv="refresh"'); |
| 95 | expect(text).toContain("A snapshot is being taken"); | |
| 96 | expect(text).not.toMatch(/class="[^"]*\bbackups-/); | |
| 95 | 97 | expect(text).toContain('id="backups-live"'); |
| 96 | 98 | expect(text).toContain('data-live="backup.*"'); |
| 97 | 99 | 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 |
||
| 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", () => { | ||
| 102 | 102 | // The terminal's dashboard prints the forge's needs: the card is there too. |
| 103 | 103 | expect(forgeNeeds(app, admin)?.some((l) => l.includes("The last snapshot failed"))).toBe(true); |
| 104 | 104 | 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"); | |
| 106 | 106 | expect(page.replace(/\s+/g, " ")).toContain("The last snapshot failed"); |
| 107 | 107 | expect(page).toContain("no answer in 180 s"); |
| 108 | 108 |
new above
offline
backups-timingis still a .backups-* class in the markup (also on the red drill line). Usemuted smalland drop the class.