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

Design: fold resilience into backups #2755

Merged opened this

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

Merged

mason/forge:mason/2694-design-fold-resilience-into-backups → main 2 commits fork 1 thread · 1 unresolved

  • Merged. mason/2694-design-fold-resilience-into-backups was merged into main as 909f8e0.

Review

Sign in to review this change.
10 files changed +83 −97
README.md +3 −3

Diff not shown — a line 4,279 characters wide (limit 2,000). +3 −3

View file
docs/design/baseline.json −4
@@ -23,10 +23,6 @@
2323 "src/web/style/components.ts": {
2424 "colour": 3,
2525 "size": 2
26 },
27 "src/web/style/content.ts": {
28 "colour": 19,
29 "size": 31
3026 }
3127 },
3228 "classes": [
src/http/server.ts +2 −2
@@ -20,7 +20,6 @@ import { telemetryPage, telemetrySet } from "../web/pages/telemetry.ts";
2020 import { contributePage } from "../web/pages/contribute.ts";
2121 import { flagsPage, flagsSet } from "../web/pages/flags.ts";
2222 import { usagePage } from "../web/pages/usage.ts";
23 import { domainsPage } from "../web/pages/domains.ts";
2423 import { resiliencePage } from "../web/pages/resilience.ts";
2524 import { limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } from "../web/pages/admin-limits.ts";
2625 import { boardsPage } from "../web/pages/boards.ts";
@@ -144,6 +143,7 @@ import {
144143 backupsNow,
145144 backupsPage,
146145 backupsTest,
146 domainsMoved,
147147 } from "../web/pages/backups.ts";
148148 import { releasesPage, rollbackSubmit } from "../web/pages/releases.ts";
149149 import {
@@ -804,7 +804,7 @@ export function buildRouter(): Router<Handler> {
804804 router.add("POST", "/settings/cloud/rename", cloudRename);
805805 router.add("POST", "/settings/cloud/remove", cloudRemove);
806806 router.add("GET", "/settings/access", accessPage);
807 router.add("GET", "/settings/resilience", domainsPage);
807 router.add("GET", "/settings/resilience", domainsMoved);
808808 router.add("GET", "/settings/capacity", capacityPage);
809809 router.add("POST", "/settings/capacity", capacitySet);
810810 router.add("GET", "/boards", boardsList);
src/tui/forge.ts +1 −1
@@ -455,7 +455,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
455455 hint: "provider tokens, what they are used for, today's calls",
456456 run: () => ctx.push(cloudScreen(session, ctx)),
457457 });
458 // Each failure domain and its last drill, as Settings → Failure domains says it (#1945).
458 // Each failure domain and its last drill, as Settings → Backups → Failure domains says it (#1945).
459459 if (session.user?.isAdmin === true && session.user.kind !== "agent")
460460 commands.push({
461461 id: "domains",
src/tui/screens/domains.ts +1 −1
@@ -1,6 +1,6 @@
11 /**
22 * Failure domains in the terminal, a forge admin's: each failure domain and its
3 * last drill — `listResilience`, the action Settings → Failure domains reads.
3 * last drill — `listResilience`, the action Settings → Backups → Failure domains reads.
44 * Who may read is the action's call, not this screen's: anyone else gets an
55 * empty screen that says nothing exists.
66 */
src/tui/screens/owner.ts +1 −1
@@ -1,7 +1,7 @@
11 /**
22 * What needs the owner, in the terminal, a forge admin's: the forge's needs,
33 * where snapshots go and the newest one, and every failure domain with what
4 * the owner does — `ownerView`, the action behind Settings → Failure domains
4 * the owner does — `ownerView`, the action behind Settings → Backups → Failure domains
55 * and Backups. Anyone else gets an empty screen that says nothing exists.
66 */
77
src/web/pages/backups.ts +60 −3
@@ -6,6 +6,11 @@
66 *
77 * Restoring is a command, not a button: it needs an empty data directory
88 * and a stopped forge, and a person at a terminal is the one who has both.
9 *
10 * Below the snapshots, each failure domain (`kernel/domains.ts`): what
11 * breaks, the words the forge says, and its last drill with the verdict —
12 * or the sentence on why it is not drilled (`actions/resilience.ts`). It
13 * was /settings/resilience, which now sends here.
914 */
1015
1116 import {
@@ -15,6 +20,7 @@ import {
1520 type DestinationInput,
1621 } from "../../actions/destination.ts";
1722 import { forgetMirror, saveMirror } from "../../actions/mirror.ts";
23 import { listResilience, type ResilienceRow } from "../../actions/resilience.ts";
1824 import { mirrorSealedSet, mirrorNeed, MIRROR_SEALED } from "../../snapshot/mirror.ts";
1925 import { resolveDestination, sealedSet } from "../../snapshot/destination.ts";
2026 import { keyOf } from "../../snapshot/snapshot.ts";
@@ -26,8 +32,8 @@ import type { Ctx } from "../../http/context.ts";
2632 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
2733 import { field, readForm, type Form } from "../form.ts";
2834 import { html, when, type Html } from "../html.ts";
29 import { flashTo, page } from "../layout.ts";
30 import { btn, card, empty, fileSize, icon, time } from "../ui.ts";
35 import { flashTo, page, redirect } from "../layout.ts";
36 import { btn, card, empty, fileSize, icon, row, time } from "../ui.ts";
3137 import { accountSections } from "./origins.ts";
3238
3339 function requireAdmin(ctx: Ctx): Response | undefined {
@@ -177,11 +183,62 @@ function recoveryLine(r: Recovery): Html {
177183 </p>`;
178184 }
179185
186 function drilled(d: ResilienceRow): Html {
187 if (d.drill.name === null)
188 return html`<span class="muted small">Not drilled: ${d.drill.why}</span>`;
189 const last = d.drill.last;
190 if (last === null)
191 return html`<span class="muted small">${d.drill.name}: never run.</span>`;
192 return html`<span class="small"
193 >${d.drill.name}: ${time(last.at)} ·
194 <strong class="${last.green ? "ok" : "bad"}"
195 >${last.green ? "green" : "red"}</strong
196 >
197 — ${last.why} · back in ${String(last.backSeconds)}s (budget
198 ${String(d.backMinutes)} min)</span
199 >`;
200 }
201
202 /** Each failure domain and its last drill; nothing when the viewer may not read them. */
203 function domainsCard(ctx: Ctx): Html | null {
204 if (!ctx.auth.user) return null;
205 const listed = listResilience(ctx.app, ctx.auth.user);
206 if (!listed.ok) return null;
207 const rows = listed.value.map((d) => {
208 const says =
209 d.says.length === 0
210 ? "Says nothing yet."
211 : d.says.map((s) => `“${s}”`).join(" · ");
212 return row({
213 lead: icon("activity"),
214 title: html`${d.name}`,
215 meta: html`${d.breaks}<br />${says}<br />${drilled(d)}`,
216 });
217 });
218 return html`<div id="domains">
219 ${card(
220 html`<ul class="list" id="resilience-list">
221 ${rows}
222 </ul>`,
223 {
224 head: html`${icon("activity")}
225 <h2>Failure domains</h2>`,
226 pad: false,
227 },
228 )}
229 </div>`;
230 }
231
232 // GET /settings/resilience — folded into this page (#2694); old links land on the card.
233 export function domainsMoved(): Response {

Good: a 303 to a fixed target keeps old bookmarks working, the target's own gate decides who sees anything, and nothing from the request goes into the Location header.

234 return redirect("/settings/backups#domains");
235 }
236
180237 export async function backupsPage(ctx: Ctx): Promise<Response> {
181238 const denied = requireAdmin(ctx);
182239 if (denied) return denied;
183240 const svc = snapshotsOf(ctx.app);
184 const form = html`${destinationCard(ctx)}${mirrorCard(ctx)}`;
241 const form = html`${destinationCard(ctx)}${mirrorCard(ctx)}${domainsCard(ctx)}`;
185242 const now = Date.now();
186243 if (svc === undefined || svc.store === undefined)
187244 return shell(
src/web/pages/domains.ts deleted −67
@@ -1,67 +0,0 @@
1 /**
2 * Failure domains, for a forge admin: each failure domain (`kernel/domains.ts`),
3 * what breaks, the words the forge says, and its last drill with the verdict
4 * — or the sentence on why it is not drilled (`actions/resilience.ts`).
5 */
6
7 import { listResilience, type ResilienceRow } from "../../actions/resilience.ts";
8 import type { Ctx } from "../../http/context.ts";
9 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
10 import { html, type Html } from "../html.ts";
11 import { page } from "../layout.ts";
12 import { card, icon, row, time } from "../ui.ts";
13 import { accountSections } from "./origins.ts";
14
15 function drilled(d: ResilienceRow): Html {
16 if (d.drill.name === null)
17 return html`<span class="muted small">Not drilled: ${d.drill.why}</span>`;
18 const last = d.drill.last;
19 if (last === null)
20 return html`<span class="muted small">${d.drill.name}: never run.</span>`;
21 return html`<span class="small"
22 >${d.drill.name}: ${time(last.at)} ·
23 <strong class="${last.green ? "ok" : "bad"}"
24 >${last.green ? "green" : "red"}</strong
25 >
26 — ${last.why} · back in ${String(last.backSeconds)}s (budget
27 ${String(d.backMinutes)} min)</span
28 >`;
29 }
30
31 // GET /settings/resilience (named "Failure domains"; /resilience is THREATS.md)
32 export function domainsPage(ctx: Ctx): Response {
33 if (!ctx.auth.user) return signInFirst(ctx);
34 const listed = listResilience(ctx.app, ctx.auth.user);
35 if (!listed.ok) return notFoundPage(ctx);
36 const rows = listed.value.map((d) => {
37 const says =
38 d.says.length === 0
39 ? "Says nothing yet."
40 : d.says.map((s) => `“${s}”`).join(" · ");
41 return row({
42 lead: icon("activity"),
43 title: html`${d.name}`,
44 meta: html`${d.breaks}<br />${says}<br />${drilled(d)}`,
45 });
46 });
47 const body = card(
48 html`<ul class="list" id="resilience-list">
49 ${rows}
50 </ul>`,
51 {
52 head: html`${icon("activity")}
53 <h2>Failure domains</h2>`,
54 pad: false,
55 },
56 );
57 return page({
58 ctx,
59 title: "Failure domains",
60 body: html`<div class="page-head"><h1>Failure domains</h1></div>
61 ${settingsLayout(
62 accountSections("resilience", { admin: true }),
63 body,
64 "Account settings",
65 )}`,
66 });
67 }
src/web/pages/origins.ts −7
@@ -86,7 +86,6 @@ export type AccountSection =
8686 | "capacity"
8787 | "telemetry"
8888 | "access"
89 | "resilience"
9089 | "limits"
9190 | "flags"
9291 | "cloud";
@@ -158,12 +157,6 @@ export function accountSections(
158157 icon: "activity" as const,
159158 current: current === "access",
160159 },
161 {
162 label: "Failure domains",
163 href: "/settings/resilience",
164 icon: "activity" as const,
165 current: current === "resilience",
166 },
167160 {
168161 label: "Limits",
169162 href: "/admin/limits",
test/resilience-page.test.ts +15 −8
@@ -7,7 +7,7 @@ import { recordDrill } from "../src/core/domain_drills.ts";
77 import { createUser } from "../src/core/users.ts";
88 import { serve, type Servers } from "../src/http/server.ts";
99
10 describe("Settings → Failure domains", () => {
10 describe("Settings → Backups → Failure domains", () => {
1111 let dataDir: string;
1212 let app: App;
1313 let servers: Servers;
@@ -54,7 +54,7 @@ describe("Settings → Failure domains", () => {
5454 });
5555
5656 test("an admin sees a green and a red drill, escaped, and why the rest are not drilled", async () => {
57 const res = await fetch(`${servers.url}/settings/resilience`, {
57 const res = await fetch(`${servers.url}/settings/backups`, {
5858 headers: { cookie: await login("alice") },
5959 });
6060 expect(res.status).toBe(200);
@@ -65,18 +65,25 @@ describe("Settings → Failure domains", () => {
6565 expect(text).not.toContain("<b>late</b>");
6666 expect(text).toContain("Filling or detaching the live volume would lose writes.");
6767 expect(text).toContain("never run");
68 expect(text).toContain('href="/settings/resilience"');
69 expect(text).toContain("<title>Failure domains");
70 expect(text).toContain("<h1>Failure domains</h1>");
68 expect(text).toContain('id="domains"');
69 expect(text).toContain("<h2>Failure domains</h2>");
70 expect(text).not.toContain('href="/settings/resilience"');
71 });
72
73 test("the old address sends to the card on the backups page", async () => {
74 const res = await fetch(`${servers.url}/settings/resilience`, { redirect: "manual" });
75 expect(res.status).toBe(303);
76 expect(res.headers.get("location")).toBe("/settings/backups#domains");
7177 });
7278
7379 test("a non-admin is refused, an anonymous visitor sent to sign in", async () => {
74 const bob = await fetch(`${servers.url}/settings/resilience`, {
80 const bob = await fetch(`${servers.url}/settings/backups`, {
7581 headers: { cookie: await login("bob") },
7682 });
7783 expect(bob.status).toBe(404);
78 const anon = await fetch(`${servers.url}/settings/resilience`, { redirect: "manual" });
84 expect(await bob.text()).not.toContain("Failure domains");
85 const anon = await fetch(`${servers.url}/settings/backups`, { redirect: "manual" });
7986 expect(anon.status).toBe(303);
80 expect(anon.headers.get("location")).toBe("/signin?next=%2Fsettings%2Fresilience");
87 expect(anon.headers.get("location")).toBe("/signin?next=%2Fsettings%2Fbackups");
8188 });
8289 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs