Public: anyone can read this; the crew can change it.
Settings › Backups speaks the home page's language #9754
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9708-settings-backups-speaks-the-home-page-s → main 3 commits fork 1 thread · 1 unresolved
3 files
changed +121
−87
docs/design/areas/backups.md +5
| @@ -3,3 +3,8 @@ | ||
| 3 | 3 | - `.restore-command` — the one-line restore command on /settings/backups: a `<pre>` that wraps, scrolls with the keyboard (`tabindex="0"`) and takes its height from its text, so a one-liner is not a log pane. |
| 4 | 4 | |
| 5 | 5 | - The lanes card on /settings/backups adds no class: each lane is a `row()` in a `.setting-list`, its lead glyph (✓, !, –) coloured by the row's state (green is `open`, red `closed`, retired `draft`), a retired lane also carrying an outline `badge`, so no state is colour alone. |
| 6 | ||
| 7 | Settings › Backups (`src/web/pages/backups.ts`) adds no component class. As on the home page it is headings over rows: each | |
| 8 | part (Lanes, Where and when, Snapshots, Destination, Off-box mirror, Failure domains) is a `<section class="settings-block">` | |
| 9 | with an `<h2>`; the snapshots are `row()`s in `<ul class="list setting-list" id="snapshots">`; with none, one plain sentence | |
| 10 | says so. `#backups-live` and its `data-live` attributes are unchanged. `test/backups-parts.test.ts` holds the shape. |
src/web/pages/backups.ts +53 −82
| @@ -43,7 +43,7 @@ import { field, readForm, type Form } from "../form.ts"; | ||
| 43 | 43 | import { html, when, type Html } from "../html.ts"; |
| 44 | 44 | import { liveRegionsJs } from "../client.ts"; |
| 45 | 45 | import { flashTo, fragment, page, redirect } from "../layout.ts"; |
| 46 | import { badge, btn, card, empty, fileSize, icon, row, time } from "../ui.ts"; | |
| 46 | import { badge, btn, fileSize, icon, row, time } from "../ui.ts"; | |
| 47 | 47 | import { accountSections } from "./origins.ts"; |
| 48 | 48 | |
| 49 | 49 | function requireAdmin(ctx: Ctx): Response | undefined { |
| @@ -232,16 +232,12 @@ function domainsCard(ctx: Ctx): Html | null { | ||
| 232 | 232 | }); |
| 233 | 233 | }); |
| 234 | 234 | return html`<div id="domains"> |
| 235 | ${card( | |
| 236 | html`<ul class="list" id="resilience-list"> | |
| 235 | <section class="settings-block"> | |
| 236 | <h2>Failure domains</h2> | |
| 237 | <ul class="list setting-list" id="resilience-list"> | |
| 237 | 238 | ${rows} |
| 238 | </ul>`, | |
| 239 | { | |
| 240 | head: html`${icon("activity")} | |
| 241 | <h2>Failure domains</h2>`, | |
| 242 | pad: false, | |
| 243 | }, | |
| 244 | )} | |
| 239 | </ul> | |
| 240 | </section> | |
| 245 | 241 | </div>`; |
| 246 | 242 | } |
| 247 | 243 | |
| @@ -272,11 +268,10 @@ export async function backupsPage(ctx: Ctx): Promise<Response> { | ||
| 272 | 268 | reachable: false, |
| 273 | 269 | unreachable: "no snapshot store is set", |
| 274 | 270 | now: Date.now(), |
| 275 | })}${empty({ | |
| 276 | icon: "download", | |
| 277 | title: "No snapshots are taken", | |
| 278 | 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.", | |
| 279 | })}${form}`, | |
| 271 | })}<section class="settings-block"> | |
| 272 | <h2>No snapshots are taken</h2> | |
| 273 | <p class="small">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.</p> | |
| 274 | </section>${form}`, | |
| 280 | 275 | ), |
| 281 | 276 | ); |
| 282 | 277 | } |
| @@ -339,8 +334,9 @@ async function movingPart( | ||
| 339 | 334 | const restore = snapshots[0] |
| 340 | 335 | ? html`<pre class="restore-command" tabindex="0" aria-label="Restore command">bun run src/snapshot/restore.ts ${snapshots[0].name} /path/to/empty/data</pre>` |
| 341 | 336 | : null; |
| 342 | return html`<div id="backups-live" data-live="backup.*" data-live-src="/settings/backups/live">${recovery}${lanesCard(ctx)}${card( | |
| 343 | html`<p> | |
| 337 | return html`<div id="backups-live" data-live="backup.*" data-live-src="/settings/backups/live">${recovery}${lanesCard(ctx)}<section class="settings-block"> | |
| 338 | <h2>Where and when</h2> | |
| 339 | <p> | |
| 344 | 340 | Every night at 08:00 UTC the forge writes what cannot be rebuilt — its |
| 345 | 341 | database, its keys, every repository, every app's production data, the |
| 346 | 342 | crew's logs — to <code>${store.where}</code>, and keeps the newest |
| @@ -378,57 +374,35 @@ async function movingPart( | ||
| 378 | 374 | variant: "primary", |
| 379 | 375 | icon: "download", |
| 380 | 376 | })} |
| 381 | </form>`, | |
| 382 | { | |
| 383 | head: html`${icon("download")} | |
| 384 | <h2>Where and when</h2>`, | |
| 385 | }, | |
| 386 | )} | |
| 377 | </form> | |
| 378 | </section> | |
| 387 | 379 | ${ |
| 388 | 380 | snapshots.length === 0 |
| 389 | ? empty({ | |
| 390 | icon: "clock", | |
| 391 | title: "None yet", | |
| 392 | note: "The first is taken tonight, or now with the button above.", | |
| 393 | }) | |
| 394 | : card( | |
| 395 | html`<ul class="list"> | |
| 396 | ${snapshots.map( | |
| 397 | (m) => | |
| 398 | html`<li> | |
| 399 | <div class="row"> | |
| 400 | <span class="row-lead">${icon("download")}</span> | |
| 401 | <span class="row-main"> | |
| 402 | <span class="row-title">${m.name}</span> | |
| 403 | <span class="row-meta" | |
| 404 | >${time(m.finishedAt)} · forge ${m.version} · | |
| 405 | ${ | |
| 406 | m.repos === 1 | |
| 407 | ? "one repository" | |
| 408 | : `${m.repos} repositories` | |
| 409 | }, | |
| 410 | ${m.apps === 1 ? "one app" : `${m.apps} apps`}, | |
| 411 | ${m.files.length} files</span | |
| 412 | > | |
| 413 | </span> | |
| 414 | <span class="row-trail muted">${fileSize(m.bytes)}</span> | |
| 415 | </div> | |
| 416 | </li>`, | |
| 381 | ? html`<section class="settings-block"> | |
| 382 | <h2>Snapshots</h2> | |
| 383 | <p class="small">None yet. The first is taken tonight, or now with the button above.</p> | |
| 384 | </section>` | |
| 385 | : html`<section class="settings-block"> | |
| 386 | <h2>Snapshots</h2> | |
| 387 | <ul class="list setting-list" id="snapshots"> | |
| 388 | ${snapshots.map((m) => | |
| 389 | row({ | |
| 390 | lead: icon("download"), | |
| 391 | title: m.name, | |
| 392 | meta: html`${time(m.finishedAt)} · forge ${m.version} · | |
| 393 | ${m.repos === 1 ? "one repository" : `${m.repos} repositories`}, | |
| 394 | ${m.apps === 1 ? "one app" : `${m.apps} apps`}, | |
| 395 | ${m.files.length} files`, | |
| 396 | trail: html`<span class="muted">${fileSize(m.bytes)}</span>`, | |
| 397 | }), | |
| 417 | 398 | )} |
| 418 | 399 | </ul> |
| 419 | <div class="card-body"> | |
| 420 | <p class="muted small break"> | |
| 421 | To restore, stop a forge, point it at an empty data directory, | |
| 422 | and run this with the same S3_* variables or FORGE_SNAPSHOT_DIR: | |
| 423 | </p> | |
| 424 | ${restore} | |
| 425 | </div>`, | |
| 426 | { | |
| 427 | pad: false, | |
| 428 | head: html`${icon("history")} | |
| 429 | <h2>Snapshots</h2>`, | |
| 430 | }, | |
| 431 | ) | |
| 400 | <p class="muted small break"> | |
| 401 | To restore, stop a forge, point it at an empty data directory, | |
| 402 | and run this with the same S3_* variables or FORGE_SNAPSHOT_DIR: | |
| 403 | </p> | |
| 404 | ${restore} | |
| 405 | </section>` | |
| 432 | 406 | } |
| 433 | 407 | </div>`; |
| 434 | 408 | } |
| @@ -464,8 +438,9 @@ function destinationCard(ctx: Ctx): Html { | ||
| 464 | 438 | spellcheck="false" |
| 465 | 439 | placeholder="${mark(sealed) || opts.placeholder || ""}" |
| 466 | 440 | /></label>`; |
| 467 | return card( | |
| 468 | html`<p> | |
| 441 | return html`<section class="settings-block"> | |
| 442 | <h2>Destination</h2> | |
| 443 | <p> | |
| 469 | 444 | In effect: |
| 470 | 445 | <strong |
| 471 | 446 | >${dest ? SOURCE[dest.source] : "nothing — no snapshots are taken"}</strong |
| @@ -503,12 +478,8 @@ function destinationCard(ctx: Ctx): Html { | ||
| 503 | 478 | Clear sealed destination |
| 504 | 479 | </button> |
| 505 | 480 | </div> |
| 506 | </form>`, | |
| 507 | { | |
| 508 | head: html`${icon("lock")} | |
| 509 | <h2>Destination</h2>`, | |
| 510 | }, | |
| 511 | ); | |
| 481 | </form> | |
| 482 | </section>`; | |
| 512 | 483 | } |
| 513 | 484 | |
| 514 | 485 | /** The mirror: its age and reachability from the last pass, and the write-only form that seals it. */ |
| @@ -544,8 +515,9 @@ function mirrorCard(ctx: Ctx): Html { | ||
| 544 | 515 | : html`It holds no snapshot yet.`} |
| 545 | 516 | ${need ? html`<br />${need.sentence}` : ""} |
| 546 | 517 | </p>`; |
| 547 | return card( | |
| 548 | html`${status} | |
| 518 | return html`<section class="settings-block"> | |
| 519 | <h2>Off-box mirror</h2> | |
| 520 | ${status} | |
| 549 | 521 | <form method="post" action="/settings/backups/mirror" class="stack"> |
| 550 | 522 | ${field_("endpoint", "Endpoint", MIRROR_SEALED.endpoint)} |
| 551 | 523 | ${field_("bucket", "Bucket", MIRROR_SEALED.bucket)} |
| @@ -561,9 +533,8 @@ function mirrorCard(ctx: Ctx): Html { | ||
| 561 | 533 | Clear sealed mirror |
| 562 | 534 | </button> |
| 563 | 535 | </div> |
| 564 | </form>`, | |
| 565 | { head: html`${icon("download")}<h2>Off-box mirror</h2>` }, | |
| 566 | ); | |
| 536 | </form> | |
| 537 | </section>`; | |
| 567 | 538 | } |
| 568 | 539 | |
| 569 | 540 | function destinationInput(form: Form): DestinationInput { |
| @@ -647,8 +618,9 @@ export function lanesCard(ctx: Ctx): Html | "" { | ||
| 647 | 618 | // The glyph is the mark that is not colour alone: ok, !, and a dash for a lane that is retired. |
| 648 | 619 | const mark = { green: "✓", red: "!", retired: "–" } as const; |
| 649 | 620 | const lead = { green: "open", red: "closed", retired: "draft" } as const; |
| 650 | return card( | |
| 651 | html`<ul id="backup-lanes" class="list setting-list"> | |
| 621 | return html`<section class="settings-block"> | |
| 622 | <h2>Lanes</h2> | |
| 623 | <ul id="backup-lanes" class="list setting-list"> | |
| 652 | 624 | ${lanes.map((l) => |
| 653 | 625 | row({ |
| 654 | 626 | lead: html`<span aria-hidden="true">${mark[l.state]}</span>`, |
| @@ -659,9 +631,8 @@ export function lanesCard(ctx: Ctx): Html | "" { | ||
| 659 | 631 | data: { lane: l.lane, "lane-state": l.state }, |
| 660 | 632 | }), |
| 661 | 633 | )} |
| 662 | </ul>`, | |
| 663 | { head: html`<strong>Lanes</strong>` }, | |
| 664 | ); | |
| 634 | </ul> | |
| 635 | </section>`; | |
| 665 | 636 | } |
| 666 | 637 | |
| 667 | 638 | /** POST /settings/backups/drill — restore the newest snapshot, keep what came of it. */ |
test/backups-parts.test.ts +63 −5
| @@ -13,6 +13,7 @@ import { createUser } from "../src/core/users.ts"; | ||
| 13 | 13 | import { serve, type Servers } from "../src/http/server.ts"; |
| 14 | 14 | import { startSnapshots, type SnapshotsHandle } from "../src/snapshot/service.ts"; |
| 15 | 15 | import { recordDrill } from "../src/snapshot/drills.ts"; |
| 16 | import { forgetReads, keepReads } from "../src/snapshot/bucket.ts"; | |
| 16 | 17 | import { dirStore } from "../src/snapshot/store.ts"; |
| 17 | 18 | import { quietLogs } from "./helpers/globals.ts"; |
| 18 | 19 | |
| @@ -73,7 +74,7 @@ test("the page carries none of the old classes, and keeps its words, links and f | ||
| 73 | 74 | expect(page).toContain('href="/settings/backups"'); |
| 74 | 75 | }); |
| 75 | 76 | |
| 76 | test("the restore command is a scrollable pre in the card body, not in the foot", async () => { | |
| 77 | test("the restore command is a scrollable pre in the Snapshots block, not in a box", async () => { | |
| 77 | 78 | const page = await get(); |
| 78 | 79 | const pre = /<pre[^>]*>[^]*?<\/pre>/.exec(page)?.[0] ?? ""; |
| 79 | 80 | expect(pre).toContain('class="restore-command"'); |
| @@ -81,10 +82,67 @@ test("the restore command is a scrollable pre in the card body, not in the foot" | ||
| 81 | 82 | expect(pre).toContain('tabindex="0"'); |
| 82 | 83 | expect(pre).toContain("aria-label="); |
| 83 | 84 | expect(pre).toContain("bun run src/snapshot/restore.ts"); |
| 84 | const at = page.indexOf(pre); | |
| 85 | const body = page.lastIndexOf('class="card-body"', at); | |
| 86 | const foot = page.lastIndexOf('class="card-foot"', at); | |
| 87 | expect(body).toBeGreaterThan(foot); | |
| 85 | const from = page.indexOf("<h2>Snapshots</h2>"); | |
| 86 | expect(from).toBeGreaterThan(-1); | |
| 87 | const block = page.slice(from, page.indexOf("</section>", from)); | |
| 88 | expect(block).toContain(pre); | |
| 89 | expect(block).toContain('id="snapshots"'); | |
| 90 | expect(page).not.toContain('class="card'); | |
| 91 | }); | |
| 92 | ||
| 93 | test("snapshots are rows under a heading, and no section is a card (#9708)", async () => { | |
| 94 | await svc.now(); | |
| 95 | const page = await get(); | |
| 96 | const main = page.slice(page.indexOf("<main"), page.indexOf("</main>")); | |
| 97 | expect(main).toContain('<ul class="list setting-list" id="snapshots">'); | |
| 98 | expect(main).toContain('class="row-title"'); | |
| 99 | for (const h of ["Where and when", "Snapshots", "Destination", "Off-box mirror"]) expect(main).toContain(`<h2>${h}</h2>`); | |
| 100 | for (const boxed of ['class="card', "card-head", "card-body", 'class="empty']) expect(main).not.toContain(boxed); | |
| 101 | expect(main).toContain('id="backups-live" data-live="backup.*" data-live-src="/settings/backups/live"'); | |
| 102 | }); | |
| 103 | ||
| 104 | test("Lanes and Failure domains are headed sections, not cards (#9708)", async () => { | |
| 105 | keepReads(root, [ | |
| 106 | { lane: "primary", where: "bucket/forge", offBox: true, newestAt: new Date().toISOString(), newestName: "n", newestBytes: 5, count: 1, readAt: new Date().toISOString() }, | |
| 107 | ]); | |
| 108 | try { | |
| 109 | const page = await get(); | |
| 110 | const lanes = page.slice(page.indexOf("<h2>Lanes</h2>")); | |
| 111 | expect(page).toContain("<h2>Lanes</h2>"); | |
| 112 | expect(lanes.slice(0, lanes.indexOf("</section>"))).toContain('id="backup-lanes"'); | |
| 113 | const domains = page.slice(page.indexOf("<h2>Failure domains</h2>")); | |
| 114 | expect(page).toContain("<h2>Failure domains</h2>"); | |
| 115 | expect(domains.slice(0, domains.indexOf("</section>"))).toContain('id="resilience-list"'); | |
| 116 | expect(page).not.toContain('class="card'); | |
| 117 | } finally { | |
| 118 | forgetReads(root); | |
| 119 | } | |
| 120 | }); | |
| 121 | ||
| 122 | test("with no snapshot store the page says so under a plain heading, not in an empty-state box (#9708)", async () => { | |
| 123 | const bare = mkdtempSync(join(tmpdir(), "forge-backups-bare-")); | |
| 124 | const other = createApp({ dataDir: bare, port: 0 }); | |
| 125 | const made = await createUser(other.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 126 | if (!made.ok) throw new Error(made.error); | |
| 127 | const srv = await serve(other); | |
| 128 | try { | |
| 129 | const r = await fetch(`${srv.url}/signin`, { | |
| 130 | method: "POST", | |
| 131 | headers: { origin: srv.url }, | |
| 132 | body: new URLSearchParams({ username: "bob", password: "a-good-passphrase" }), | |
| 133 | redirect: "manual", | |
| 134 | }); | |
| 135 | const own = (r.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 136 | const page = await (await fetch(`${srv.url}/settings/backups`, { headers: { cookie: own } })).text(); | |
| 137 | const from = page.indexOf("<h2>No snapshots are taken</h2>"); | |
| 138 | expect(from).toBeGreaterThan(-1); | |
| 139 | expect(page.slice(from, page.indexOf("</section>", from))).toContain("This forge was given nowhere to put them."); | |
| 140 | for (const boxed of ['class="card', 'class="empty', "card-body"]) expect(page).not.toContain(boxed); | |
| 141 | } finally { | |
| 142 | srv.stop(); | |
| 143 | other.close(); | |
| 144 | rmSync(bare, { recursive: true, force: true }); | |
| 145 | } | |
| 88 | 146 | }); |
| 89 | 147 | |
| 90 | 148 | test("the paragraphs that can carry a long id, reason or step wrap", async () => { |
Principle 6/7 test is vacuous: lastIndexOf of the section opener is compared to lastIndexOf("<h2>Snapshots</h2>") - 100, which passes for almost any markup (and an earlier section opener satisfies it). Assert that the pre sits after <h2>Snapshots</h2> and before the next </section>, e.g. slice from the Snapshots h2 to the following </section> and expect it to contain the pre.