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

Settings › Backups speaks the home page's language #9754

Merged opened this

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

Merged

scout/forge:scout/9708-settings-backups-speaks-the-home-page-s → main 3 commits fork 1 thread · 1 unresolved

  • Merged. scout/9708-settings-backups-speaks-the-home-page-s was merged into main as 5a702d7.

Review

Sign in to review this change.
3 files changed +121 −87
docs/design/areas/backups.md +5
@@ -3,3 +3,8 @@
33 - `.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.
44
55 - 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";
4343 import { html, when, type Html } from "../html.ts";
4444 import { liveRegionsJs } from "../client.ts";
4545 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";
4747 import { accountSections } from "./origins.ts";
4848
4949 function requireAdmin(ctx: Ctx): Response | undefined {
@@ -232,16 +232,12 @@ function domainsCard(ctx: Ctx): Html | null {
232232 });
233233 });
234234 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">
237238 ${rows}
238 </ul>`,
239 {
240 head: html`${icon("activity")}
241 <h2>Failure domains</h2>`,
242 pad: false,
243 },
244 )}
239 </ul>
240 </section>
245241 </div>`;
246242 }
247243
@@ -272,11 +268,10 @@ export async function backupsPage(ctx: Ctx): Promise<Response> {
272268 reachable: false,
273269 unreachable: "no snapshot store is set",
274270 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}`,
280275 ),
281276 );
282277 }
@@ -339,8 +334,9 @@ async function movingPart(
339334 const restore = snapshots[0]
340335 ? 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>`
341336 : 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>
344340 Every night at 08:00 UTC the forge writes what cannot be rebuilt — its
345341 database, its keys, every repository, every app's production data, the
346342 crew's logs — to <code>${store.where}</code>, and keeps the newest
@@ -378,57 +374,35 @@ async function movingPart(
378374 variant: "primary",
379375 icon: "download",
380376 })}
381 </form>`,
382 {
383 head: html`${icon("download")}
384 <h2>Where and when</h2>`,
385 },
386 )}
377 </form>
378 </section>
387379 ${
388380 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 }),
417398 )}
418399 </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>`
432406 }
433407 </div>`;
434408 }
@@ -464,8 +438,9 @@ function destinationCard(ctx: Ctx): Html {
464438 spellcheck="false"
465439 placeholder="${mark(sealed) || opts.placeholder || ""}"
466440 /></label>`;
467 return card(
468 html`<p>
441 return html`<section class="settings-block">
442 <h2>Destination</h2>
443 <p>
469444 In effect:
470445 <strong
471446 >${dest ? SOURCE[dest.source] : "nothing — no snapshots are taken"}</strong
@@ -503,12 +478,8 @@ function destinationCard(ctx: Ctx): Html {
503478 Clear sealed destination
504479 </button>
505480 </div>
506 </form>`,
507 {
508 head: html`${icon("lock")}
509 <h2>Destination</h2>`,
510 },
511 );
481 </form>
482 </section>`;
512483 }
513484
514485 /** 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 {
544515 : html`It holds no snapshot yet.`}
545516 ${need ? html`<br />${need.sentence}` : ""}
546517 </p>`;
547 return card(
548 html`${status}
518 return html`<section class="settings-block">
519 <h2>Off-box mirror</h2>
520 ${status}
549521 <form method="post" action="/settings/backups/mirror" class="stack">
550522 ${field_("endpoint", "Endpoint", MIRROR_SEALED.endpoint)}
551523 ${field_("bucket", "Bucket", MIRROR_SEALED.bucket)}
@@ -561,9 +533,8 @@ function mirrorCard(ctx: Ctx): Html {
561533 Clear sealed mirror
562534 </button>
563535 </div>
564 </form>`,
565 { head: html`${icon("download")}<h2>Off-box mirror</h2>` },
566 );
536 </form>
537 </section>`;
567538 }
568539
569540 function destinationInput(form: Form): DestinationInput {
@@ -647,8 +618,9 @@ export function lanesCard(ctx: Ctx): Html | "" {
647618 // The glyph is the mark that is not colour alone: ok, !, and a dash for a lane that is retired.
648619 const mark = { green: "✓", red: "!", retired: "–" } as const;
649620 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">
652624 ${lanes.map((l) =>
653625 row({
654626 lead: html`<span aria-hidden="true">${mark[l.state]}</span>`,
@@ -659,9 +631,8 @@ export function lanesCard(ctx: Ctx): Html | "" {
659631 data: { lane: l.lane, "lane-state": l.state },
660632 }),
661633 )}
662 </ul>`,
663 { head: html`<strong>Lanes</strong>` },
664 );
634 </ul>
635 </section>`;
665636 }
666637
667638 /** 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";
1313 import { serve, type Servers } from "../src/http/server.ts";
1414 import { startSnapshots, type SnapshotsHandle } from "../src/snapshot/service.ts";
1515 import { recordDrill } from "../src/snapshot/drills.ts";
16 import { forgetReads, keepReads } from "../src/snapshot/bucket.ts";
1617 import { dirStore } from "../src/snapshot/store.ts";
1718 import { quietLogs } from "./helpers/globals.ts";
1819
@@ -73,7 +74,7 @@ test("the page carries none of the old classes, and keeps its words, links and f
7374 expect(page).toContain('href="/settings/backups"');
7475 });
7576
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 () => {
7778 const page = await get();
7879 const pre = /<pre[^>]*>[^]*?<\/pre>/.exec(page)?.[0] ?? "";
7980 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"
8182 expect(pre).toContain('tabindex="0"');
8283 expect(pre).toContain("aria-label=");
8384 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>");
outdated

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.

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 }
88146 });
89147
90148 test("the paragraphs that can carry a long id, reason or step wrap", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs