Public: anyone can read this; the crew can change it.
Design: fold resilience into backups #2755
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/2694-design-fold-resilience-into-backups → main 2 commits fork 1 thread · 1 unresolved
10 files
changed +83
−97
README.md +3 −3
Diff not shown — a line 4,279 characters wide (limit 2,000). +3 −3
View filedocs/design/baseline.json −4
| @@ -23,10 +23,6 @@ | ||
| 23 | 23 | "src/web/style/components.ts": { |
| 24 | 24 | "colour": 3, |
| 25 | 25 | "size": 2 |
| 26 | }, | |
| 27 | "src/web/style/content.ts": { | |
| 28 | "colour": 19, | |
| 29 | "size": 31 | |
| 30 | 26 | } |
| 31 | 27 | }, |
| 32 | 28 | "classes": [ |
src/http/server.ts +2 −2
| @@ -20,7 +20,6 @@ import { telemetryPage, telemetrySet } from "../web/pages/telemetry.ts"; | ||
| 20 | 20 | import { contributePage } from "../web/pages/contribute.ts"; |
| 21 | 21 | import { flagsPage, flagsSet } from "../web/pages/flags.ts"; |
| 22 | 22 | import { usagePage } from "../web/pages/usage.ts"; |
| 23 | import { domainsPage } from "../web/pages/domains.ts"; | |
| 24 | 23 | import { resiliencePage } from "../web/pages/resilience.ts"; |
| 25 | 24 | import { limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } from "../web/pages/admin-limits.ts"; |
| 26 | 25 | import { boardsPage } from "../web/pages/boards.ts"; |
| @@ -144,6 +143,7 @@ import { | ||
| 144 | 143 | backupsNow, |
| 145 | 144 | backupsPage, |
| 146 | 145 | backupsTest, |
| 146 | domainsMoved, | |
| 147 | 147 | } from "../web/pages/backups.ts"; |
| 148 | 148 | import { releasesPage, rollbackSubmit } from "../web/pages/releases.ts"; |
| 149 | 149 | import { |
| @@ -804,7 +804,7 @@ export function buildRouter(): Router<Handler> { | ||
| 804 | 804 | router.add("POST", "/settings/cloud/rename", cloudRename); |
| 805 | 805 | router.add("POST", "/settings/cloud/remove", cloudRemove); |
| 806 | 806 | router.add("GET", "/settings/access", accessPage); |
| 807 | router.add("GET", "/settings/resilience", domainsPage); | |
| 807 | router.add("GET", "/settings/resilience", domainsMoved); | |
| 808 | 808 | router.add("GET", "/settings/capacity", capacityPage); |
| 809 | 809 | router.add("POST", "/settings/capacity", capacitySet); |
| 810 | 810 | router.add("GET", "/boards", boardsList); |
src/tui/forge.ts +1 −1
| @@ -455,7 +455,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 455 | 455 | hint: "provider tokens, what they are used for, today's calls", |
| 456 | 456 | run: () => ctx.push(cloudScreen(session, ctx)), |
| 457 | 457 | }); |
| 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). | |
| 459 | 459 | if (session.user?.isAdmin === true && session.user.kind !== "agent") |
| 460 | 460 | commands.push({ |
| 461 | 461 | id: "domains", |
src/tui/screens/domains.ts +1 −1
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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. | |
| 4 | 4 | * Who may read is the action's call, not this screen's: anyone else gets an |
| 5 | 5 | * empty screen that says nothing exists. |
| 6 | 6 | */ |
src/tui/screens/owner.ts +1 −1
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * What needs the owner, in the terminal, a forge admin's: the forge's needs, |
| 3 | 3 | * 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 | |
| 5 | 5 | * and Backups. Anyone else gets an empty screen that says nothing exists. |
| 6 | 6 | */ |
| 7 | 7 |
src/web/pages/backups.ts +60 −3
| @@ -6,6 +6,11 @@ | ||
| 6 | 6 | * |
| 7 | 7 | * Restoring is a command, not a button: it needs an empty data directory |
| 8 | 8 | * 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. | |
| 9 | 14 | */ |
| 10 | 15 | |
| 11 | 16 | import { |
| @@ -15,6 +20,7 @@ import { | ||
| 15 | 20 | type DestinationInput, |
| 16 | 21 | } from "../../actions/destination.ts"; |
| 17 | 22 | import { forgetMirror, saveMirror } from "../../actions/mirror.ts"; |
| 23 | import { listResilience, type ResilienceRow } from "../../actions/resilience.ts"; | |
| 18 | 24 | import { mirrorSealedSet, mirrorNeed, MIRROR_SEALED } from "../../snapshot/mirror.ts"; |
| 19 | 25 | import { resolveDestination, sealedSet } from "../../snapshot/destination.ts"; |
| 20 | 26 | import { keyOf } from "../../snapshot/snapshot.ts"; |
| @@ -26,8 +32,8 @@ import type { Ctx } from "../../http/context.ts"; | ||
| 26 | 32 | import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; |
| 27 | 33 | import { field, readForm, type Form } from "../form.ts"; |
| 28 | 34 | 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"; | |
| 31 | 37 | import { accountSections } from "./origins.ts"; |
| 32 | 38 | |
| 33 | 39 | function requireAdmin(ctx: Ctx): Response | undefined { |
| @@ -177,11 +183,62 @@ function recoveryLine(r: Recovery): Html { | ||
| 177 | 183 | </p>`; |
| 178 | 184 | } |
| 179 | 185 | |
| 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 { | |
| 234 | return redirect("/settings/backups#domains"); | |
| 235 | } | |
| 236 | ||
| 180 | 237 | export async function backupsPage(ctx: Ctx): Promise<Response> { |
| 181 | 238 | const denied = requireAdmin(ctx); |
| 182 | 239 | if (denied) return denied; |
| 183 | 240 | const svc = snapshotsOf(ctx.app); |
| 184 | const form = html`${destinationCard(ctx)}${mirrorCard(ctx)}`; | |
| 241 | const form = html`${destinationCard(ctx)}${mirrorCard(ctx)}${domainsCard(ctx)}`; | |
| 185 | 242 | const now = Date.now(); |
| 186 | 243 | if (svc === undefined || svc.store === undefined) |
| 187 | 244 | 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 = | ||
| 86 | 86 | | "capacity" |
| 87 | 87 | | "telemetry" |
| 88 | 88 | | "access" |
| 89 | | "resilience" | |
| 90 | 89 | | "limits" |
| 91 | 90 | | "flags" |
| 92 | 91 | | "cloud"; |
| @@ -158,12 +157,6 @@ export function accountSections( | ||
| 158 | 157 | icon: "activity" as const, |
| 159 | 158 | current: current === "access", |
| 160 | 159 | }, |
| 161 | { | |
| 162 | label: "Failure domains", | |
| 163 | href: "/settings/resilience", | |
| 164 | icon: "activity" as const, | |
| 165 | current: current === "resilience", | |
| 166 | }, | |
| 167 | 160 | { |
| 168 | 161 | label: "Limits", |
| 169 | 162 | href: "/admin/limits", |
test/resilience-page.test.ts +15 −8
| @@ -7,7 +7,7 @@ import { recordDrill } from "../src/core/domain_drills.ts"; | ||
| 7 | 7 | import { createUser } from "../src/core/users.ts"; |
| 8 | 8 | import { serve, type Servers } from "../src/http/server.ts"; |
| 9 | 9 | |
| 10 | describe("Settings → Failure domains", () => { | |
| 10 | describe("Settings → Backups → Failure domains", () => { | |
| 11 | 11 | let dataDir: string; |
| 12 | 12 | let app: App; |
| 13 | 13 | let servers: Servers; |
| @@ -54,7 +54,7 @@ describe("Settings → Failure domains", () => { | ||
| 54 | 54 | }); |
| 55 | 55 | |
| 56 | 56 | 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`, { | |
| 58 | 58 | headers: { cookie: await login("alice") }, |
| 59 | 59 | }); |
| 60 | 60 | expect(res.status).toBe(200); |
| @@ -65,18 +65,25 @@ describe("Settings → Failure domains", () => { | ||
| 65 | 65 | expect(text).not.toContain("<b>late</b>"); |
| 66 | 66 | expect(text).toContain("Filling or detaching the live volume would lose writes."); |
| 67 | 67 | 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"); | |
| 71 | 77 | }); |
| 72 | 78 | |
| 73 | 79 | 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`, { | |
| 75 | 81 | headers: { cookie: await login("bob") }, |
| 76 | 82 | }); |
| 77 | 83 | 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" }); | |
| 79 | 86 | 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"); | |
| 81 | 88 | }); |
| 82 | 89 | }); |
new above
offline
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.