Public: anyone can read this; the crew can change it.
The terminal's resilience screen takes a snapshot now with `n` and brings the mirror level with `l`, after a confirm, as MCP `snapshot_now` and `mirror_now` do #7358
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7318-the-terminal-s-resilience-screen-takes-a → main 4 commits fork 5 threads · 5 unresolved
docs/TOUR.md +2
| @@ -205,6 +205,8 @@ A writer of the app's repository redeploys production from the terminal's **Depl | ||
| 205 | 205 | |
| 206 | 206 | The app's admin also sees its own domains on that screen, one line with each domain's state (`live` or `waiting for DNS`), and presses `n` for the domains screen: `+` adds one, `c` checks the selected one's DNS proof now and says the record still needed, `-` removes it after `remove <host>? y/n`. They are the settings page's and MCP `app_domains`' actions (`src/actions/domains.ts`), answered in the page's words (`src/actions/domain_words.ts`); anyone `listAppDomains` refuses sees neither the domains nor `n`. |
| 207 | 207 | |
| 208 | A forge admin takes a snapshot from the terminal's **Resilience** dialog (`ctrl+k`, Resilience) with `n` and brings the off-box mirror level with `l`, each after a `y/n` asked on the dialog's first line. They are the Backups page's buttons' and MCP `snapshot_now`'s and `mirror_now`'s actions (`src/actions/backups_now.ts`), so all three doors say the same words; the dialog lists the three newest snapshots, newest first (or that they could not be read), and re-reads them after each. While one runs, neither key is offered. Nothing names a key or a secret. Anyone else has no Resilience command, and the actions refuse them with `not_found`. | |
| 209 | ||
| 208 | 210 | The `app_vital` board source (`src/boards/sources.ts`) charts those reports: one row per app per day with `app` (the app's id), `day`, `load_p75`, `inp_p75` (ms) and `reports` (rows kept that day), the percentiles nearest-rank per app per day as `reach` does. `max` or `avg` over several days summarises daily percentiles, not the percentile of the whole range. Its rows belong to the app's repository, so a query sees only the repos in its scope. Today boards query with `repoIds: "all"` (`src/web/pages/boards.ts`) and only a site admin reaches them; no non-admin scope exists, so nothing yet applies the settings page's `canAdmin` gate per repository, and whoever builds one must. Days older than the 90-day trim are gone, not zero. The slug and the visitor hash are not columns. No dependency was added. Left out: a starting board for it; pick `app_vital` in the `/boards` source picker. |
| 209 | 211 | |
| 210 | 212 | `reach` is a boards source for an admin only (anyone else gets 403, an agent too): `visitors` per day, `lcp_p75` and `inp_p75` per route per day, `server_p50` and `server_p95` per route per day (the beacon's time to first byte, the only per-route server timing the forge keeps), and `accounts_active_1d`, `accounts_active_7d` and `accounts_total` as of now. Filter by `metric`, group by `route` or a time bucket. Percentiles are nearest-rank within one route and day, so a `max` or `avg` over several days is a summary of daily percentiles, not a percentile of the period. Account activity comes from a session's last-seen time, so it is a count as of now, not a history. No dependency was added. |
src/actions/backups_now.ts added +105
| @@ -0,0 +1,105 @@ | ||
| 1 | /** | |
| 2 | * The Backups page's two buttons as actions: take a snapshot now, and bring | |
| 3 | * the off-box mirror level now. A forge admin's only; anyone else is told | |
| 4 | * `not_found`. Each answer carries `says`, worded as the page's flash, so | |
| 5 | * MCP `snapshot_now` / `mirror_now` and the terminal say the same thing. | |
| 6 | * Nothing here ever names a key or a secret: only the store's name. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { App } from "../app.ts"; | |
| 10 | import type { User } from "../core/users.ts"; | |
| 11 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 12 | import { snapshotsOf } from "../snapshot/service.ts"; | |
| 13 | import { fileSize } from "../web/ui.ts"; | |
| 14 | ||
| 15 | const isAdmin = (actor: User | undefined): boolean => actor?.isAdmin === true; | |
| 16 | ||
| 17 | export interface SnapshotTaken { | |
| 18 | readonly name: string; | |
| 19 | readonly repos: number; | |
| 20 | readonly apps: number; | |
| 21 | readonly bytes: number; | |
| 22 | readonly says: string; | |
| 23 | } | |
| 24 | ||
| 25 | export type SnapshotRefusal = | |
| 26 | | { readonly code: "not_found"; readonly says: string } | |
| 27 | | { readonly code: "no_store"; readonly says: string } | |
| 28 | | { readonly code: "failed"; readonly says: string }; | |
| 29 | ||
| 30 | /** Take a snapshot now; waits for any in flight. */ | |
| 31 | export async function snapshotNow( | |
| 32 | app: App, | |
| 33 | actor: User | undefined, | |
| 34 | ): Promise<Result<SnapshotTaken, SnapshotRefusal>> { | |
| 35 | if (!isAdmin(actor)) return err({ code: "not_found", says: "Only a forge admin takes a snapshot." }); | |
| 36 | const svc = snapshotsOf(app); | |
| 37 | if (svc?.store === undefined) | |
| 38 | return err({ code: "no_store", says: "This forge has nowhere to put a snapshot." }); | |
| 39 | try { | |
| 40 | const m = await svc.now(); | |
| 41 | return ok({ | |
| 42 | name: m.name, | |
| 43 | repos: m.repos, | |
| 44 | apps: m.apps, | |
| 45 | bytes: m.bytes, | |
| 46 | says: `Snapshot ${m.name} taken: ${m.repos} repositories, ${m.apps} apps, ${fileSize(m.bytes)}.`, | |
| 47 | }); | |
| 48 | } catch (error) { | |
| 49 | return err({ code: "failed", says: `The snapshot failed: ${String(error)}` }); | |
| 50 | } | |
| 51 | } | |
| 52 | ||
| 53 | export interface MirrorLevel { | |
| 54 | readonly level: boolean; | |
| 55 | readonly newest?: string; | |
| 56 | readonly error?: string; | |
| 57 | readonly says: string; | |
| 58 | } | |
| 59 | ||
| 60 | /** Bring the mirror level now; says in words whether it reached the mirror. */ | |
| 61 | export async function mirrorNow( | |
| 62 | app: App, | |
| 63 | actor: User | undefined, | |
| 64 | ): Promise<Result<MirrorLevel, { readonly code: "not_found"; readonly says: string }>> { | |
| 65 | if (!isAdmin(actor)) return err({ code: "not_found", says: "Only a forge admin brings the mirror level." }); | |
| 66 | const state = await snapshotsOf(app)?.mirrorNow(); | |
| 67 | if (state === undefined) return ok({ level: false, says: "This forge has no mirror to bring level." }); | |
| 68 | if (state.sourceError !== undefined) | |
| 69 | return ok({ | |
| 70 | level: false, | |
| 71 | error: state.sourceError, | |
| 72 | says: `The snapshots could not be read from the destination, so the mirror was not touched: ${state.sourceError}`, | |
| 73 | }); | |
| 74 | if (state.failingSince === undefined) | |
| 75 | return ok({ | |
| 76 | level: true, | |
| 77 | ...(state.newest ? { newest: state.newest } : {}), | |
| 78 | says: `The mirror is level${state.newest ? ` at ${state.newest}` : ""}.`, | |
| 79 | }); | |
| 80 | return ok({ | |
| 81 | level: false, | |
| 82 | ...(state.newest ? { newest: state.newest } : {}), | |
| 83 | error: state.error ?? "unknown reason", | |
| 84 | says: `The mirror is not level: ${state.error ?? "unknown reason"}`, | |
| 85 | }); | |
| 86 | } | |
| 87 | ||
| 88 | /** | |
| 89 | * The newest snapshots first, one line each: `name · size`; empty when there | |
| 90 | * is no store. A store that cannot be listed is `unreadable`, never an empty | |
| 91 | * list: a broken store must not read as one with nothing kept. | |
| 92 | */ | |
| 93 | export async function recentSnapshots( | |
| 94 | app: App, | |
| 95 | actor: User | undefined, | |
| 96 | limit: number, | |
| 97 | ): Promise<Result<string[], "not_found" | "unreadable">> { | |
| 98 | if (!isAdmin(actor)) return err("not_found"); | |
outdated
This swallows a list failure as |
||
| 99 | try { | |
| 100 | const kept = (await snapshotsOf(app)?.list()) ?? []; | |
| 101 | return ok(kept.slice(0, limit).map((m) => `${m.name} · ${fileSize(m.bytes)}`)); | |
| 102 | } catch { | |
| 103 | return err("unreadable"); | |
| 104 | } | |
| 105 | } |
src/http/mcp/tools/mirror_now.ts +5 −24
| @@ -1,5 +1,5 @@ | ||
| 1 | import { snapshotsOf } from "../../../snapshot/service.ts"; | |
| 2 | import { good, type Tool } from "../shared.ts"; | |
| 1 | import { mirrorNow } from "../../../actions/backups_now.ts"; | |
| 2 | import { bad, good, type Tool } from "../shared.ts"; | |
| 3 | 3 | |
| 4 | 4 | export const tool: Tool = { |
| 5 | 5 | name: "mirror_now", |
| @@ -9,27 +9,8 @@ export const tool: Tool = { | ||
| 9 | 9 | writes: true, |
| 10 | 10 | adminOnly: true, |
| 11 | 11 | needsAdminScope: true, |
| 12 | async run(ctx) { | |
| 13 | const state = await snapshotsOf(ctx.app)?.mirrorNow(); | |
| 14 | if (state === undefined) | |
| 15 | return good({ level: false, says: "This forge has no mirror to bring level." }); | |
| 16 | if (state.sourceError !== undefined) | |
| 17 | return good({ | |
| 18 | level: false, | |
| 19 | error: state.sourceError, | |
| 20 | says: `The snapshots could not be read from the destination, so the mirror was not touched: ${state.sourceError}`, | |
| 21 | }); | |
| 22 | if (state.failingSince === undefined) | |
| 23 | return good({ | |
| 24 | level: true, | |
| 25 | ...(state.newest ? { newest: state.newest } : {}), | |
| 26 | says: `The mirror is level${state.newest ? ` at ${state.newest}` : ""}.`, | |
| 27 | }); | |
| 28 | return good({ | |
| 29 | level: false, | |
| 30 | ...(state.newest ? { newest: state.newest } : {}), | |
| 31 | error: state.error ?? "unknown reason", | |
| 32 | says: `The mirror is not level: ${state.error ?? "unknown reason"}`, | |
| 33 | }); | |
| 12 | async run(ctx, user) { | |
| 13 | const done = await mirrorNow(ctx.app, user); | |
| 14 | return done.ok ? good(done.value) : bad("not_found", "no such tool here"); | |
| 34 | 15 | }, |
| 35 | 16 | }; |
src/http/mcp/tools/snapshot_now.ts +3 −17
| @@ -1,6 +1,5 @@ | ||
| 1 | import { snapshotsOf } from "../../../snapshot/service.ts"; | |
| 1 | import { snapshotNow } from "../../../actions/backups_now.ts"; | |
| 2 | 2 | import { scopeAllows } from "../../../core/tokens.ts"; |
| 3 | import { fileSize } from "../../../web/ui.ts"; | |
| 4 | 3 | import { good, bad, type Tool } from "../shared.ts"; |
| 5 | 4 | |
| 6 | 5 | export const tool: Tool = { |
| @@ -13,20 +12,7 @@ export const tool: Tool = { | ||
| 13 | 12 | if (!user.isAdmin) return bad("not_found", "no such tool here"); |
| 14 | 13 | if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) |
| 15 | 14 | return bad("forbidden", "a snapshot needs an admin-scope token"); |
| 16 | const svc = snapshotsOf(ctx.app); | |
| 17 | if (svc?.store === undefined) | |
| 18 | return bad("no_store", "This forge has nowhere to put a snapshot."); | |
| 19 | try { | |
| 20 | const m = await svc.now(); | |
| 21 | return good({ | |
| 22 | name: m.name, | |
| 23 | repos: m.repos, | |
| 24 | apps: m.apps, | |
| 25 | bytes: m.bytes, | |
| 26 | says: `Snapshot ${m.name} taken: ${m.repos} repositories, ${m.apps} apps, ${fileSize(m.bytes)}.`, | |
| 27 | }); | |
| 28 | } catch (error) { | |
| 29 | return bad("failed", `The snapshot failed: ${String(error)}`); | |
| 30 | } | |
| 15 | const taken = await snapshotNow(ctx.app, user); | |
| 16 | return taken.ok ? good(taken.value) : bad(taken.error.code, taken.error.says); | |
| 31 | 17 | }, |
| 32 | 18 | }; |
src/tui/screens/resilience.ts +100 −21
| @@ -1,30 +1,43 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The Resilience command's dialog, a forge admin's: one line per THREATS.md |
| 3 | 3 | * protection with its standing in the last gate, as /resilience says it. |
| 4 | * Enter, Esc or q closes it. A forge admin who is a person also sees where the | |
| 5 | * off-box snapshot mirror is, `M` seals it (five prompts, the secret masked, | |
| 6 | * a blank keeps what was sealed) and `F` forgets it after a confirm; the | |
| 7 | * rules are `actions/mirror.ts`, the Backups page's and MCP `mirror_set`'s. | |
| 8 | * No key is ever drawn or read back. | |
| 4 | * Enter, Esc or q closes it. | |
| 5 | * | |
| 6 | * Above the protections, the newest snapshots, newest first. A forge admin | |
| 7 | * takes one now with `n` and brings the mirror level with `l`, each after a | |
| 8 | * `y/n` asked on the dialog's first line, through `actions/backups_now.ts` — | |
| 9 | * MCP `snapshot_now`'s and `mirror_now`'s actions — and the answer is said | |
| 10 | * there in the page's words. While one runs, neither key is offered. The | |
| 11 | * command is a forge admin's only (`tui/forge.ts`), and the actions refuse | |
| 12 | * anyone else again. | |
| 13 | * | |
| 14 | * A forge admin who is a person also sees where the off-box snapshot mirror | |
| 15 | * is, `M` seals it (five prompts, the secret masked, a blank keeps what was | |
| 16 | * sealed) and `F` forgets it after a confirm; the rules are | |
| 17 | * `actions/mirror.ts`, the Backups page's and MCP `mirror_set`'s. No key is | |
| 18 | * ever drawn or read back. | |
| 9 | 19 | */ |
| 10 | 20 | |
| 21 | import { mirrorNow, recentSnapshots, snapshotNow } from "../../actions/backups_now.ts"; | |
| 11 | 22 | import { forgetMirror, saveMirror } from "../../actions/mirror.ts"; |
| 12 | 23 | import type { DestinationInput } from "../../actions/destination.ts"; |
| 13 | 24 | import { snapshotsOf } from "../../snapshot/service.ts"; |
| 14 | import type { Session } from "../session.ts"; | |
| 15 | import { confirmScreen, later, promptScreen } from "./common.ts"; | |
| 16 | 25 | import { readResilience } from "../../checks/resilience.ts"; |
| 17 | 26 | import type { Ctx, Screen } from "../app/app.ts"; |
| 18 | 27 | import { binding } from "../app/keys.ts"; |
| 28 | import type { Session } from "../session.ts"; | |
| 19 | 29 | import { pad, wrap } from "../term/width.ts"; |
| 20 | 30 | import { dialog } from "../ui/widgets.ts"; |
| 31 | import { confirmScreen, later, promptScreen, runAsync } from "./common.ts"; | |
| 21 | 32 | |
| 22 | 33 | const WIDTH = 72; |
| 34 | const INNER = WIDTH - 6; | |
| 35 | /** How many of the newest snapshots the dialog lists. */ | |
| 36 | const SNAPSHOTS = 3; | |
| 23 | 37 | |
| 24 | 38 | export function resilienceLines(dataDir: string): string[] { |
| 25 | 39 | const rows = readResilience(dataDir); |
| 26 | const inner = WIDTH - 6; | |
| 27 | return rows.flatMap(({ p, s }) => wrap(`${pad(s, 11)} ${p.text}`, inner)); | |
| 40 | return rows.flatMap(({ p, s }) => wrap(`${pad(s, 11)} ${p.text}`, INNER)); | |
outdated
The one caller always passes the session and is admin-gated. Make it required ( |
||
| 28 | 41 | } |
| 29 | 42 | |
| 30 | 43 | /** Where the mirror is, or that there is none; never a key. */ |
| @@ -41,25 +54,82 @@ const FIELDS = [ | ||
| 41 | 54 | ["secretKey", "secret key", true], |
| 42 | 55 | ] as const; |
outdated
Nothing stops a second |
||
| 43 | 56 | |
| 57 | type Asking = "snapshot" | "mirror"; | |
| 58 | ||
| 59 | const QUESTION: Record<Asking, string> = { | |
| 60 | snapshot: "take a snapshot now? y/n", | |
| 61 | mirror: "bring the mirror level now? y/n", | |
| 62 | }; | |
| 63 | ||
| 44 | 64 | export function resilienceScreen(f: Session, ctx: Ctx): Screen { |
| 45 | 65 | const mayMirror = f.user?.isAdmin === true && f.user.kind === "user"; |
| 66 | /** The newest snapshots, `unreadable` when the store could not be listed; undefined until read. */ | |
| 67 | let snapshots: string[] | "unreadable" | undefined; | |
| 68 | let asking: Asking | undefined; | |
| 69 | // While an action runs, `n` and `l` are ignored: a second would queue behind it. | |
| 70 | let busy = false; | |
| 71 | let said = ""; | |
outdated
Unreachable: a non-admin never gets this screen (forge.ts only offers the command to |
||
| 46 | 72 | let note = ""; |
| 73 | ||
| 74 | const read = async (): Promise<void> => { | |
| 75 | const listed = await recentSnapshots(f.app, f.user, SNAPSHOTS); | |
| 76 | snapshots = listed.ok ? listed.value : "unreadable"; | |
| 77 | ctx.invalidate(); | |
| 78 | }; | |
| 79 | ||
| 80 | const act = (what: Asking): void => { | |
| 81 | busy = true; | |
| 82 | said = what === "snapshot" ? "Taking a snapshot…" : "Bringing the mirror level…"; | |
| 83 | ctx.invalidate(); | |
| 84 | runAsync(ctx, async () => { | |
| 85 | try { | |
| 86 | if (what === "snapshot") { | |
| 87 | const taken = await snapshotNow(f.app, f.user); | |
| 88 | said = taken.ok ? taken.value.says : taken.error.says; | |
| 89 | } else { | |
| 90 | const level = await mirrorNow(f.app, f.user); | |
| 91 | said = level.ok ? level.value.says : level.error.says; | |
| 92 | } | |
| 93 | await read(); | |
| 94 | } finally { | |
| 95 | busy = false; | |
| 96 | ctx.invalidate(); | |
| 97 | } | |
| 98 | }); | |
| 99 | }; | |
| 100 | ||
| 101 | const body = (): string[] => { | |
| 102 | // The question and the answer come first: a long list must not clip them. | |
| 103 | const out: string[] = []; | |
| 104 | if (asking !== undefined) out.push(QUESTION[asking], ""); | |
| 105 | else if (said !== "") out.push(...wrap(said, INNER), ""); | |
| 106 | out.push(...wrap(mirrorLine(f), INNER)); | |
| 107 | if (mayMirror) out.push("M sets the mirror, F forgets it."); | |
| 108 | if (note !== "") out.push(...wrap(note, INNER)); | |
| 109 | out.push("", "Snapshots, newest first:"); | |
| 110 | if (snapshots === undefined) out.push(" reading…"); | |
| 111 | else if (snapshots === "unreadable") out.push(" the snapshots could not be read"); | |
| 112 | else if (snapshots.length === 0) out.push(" none kept"); | |
| 113 | else out.push(...snapshots.map((s) => ` ${s}`)); | |
| 114 | out.push("", ...resilienceLines(f.app.config.dataDir)); | |
| 115 | return out; | |
| 116 | }; | |
| 117 | ||
| 47 | 118 | const screen: Screen = { |
| 48 | 119 | title: "Resilience", |
| 49 | keys: [ | |
| 50 | ...(mayMirror ? [binding(["m"], "set the mirror"), binding(["f"], "forget the mirror")] : []), | |
| 51 | binding(["enter", "esc", "q"], "close"), | |
| 52 | ], | |
| 120 | get keys() { | |
| 121 | if (asking !== undefined) return [binding(["y"], "yes"), binding(["n", "esc"], "no")]; | |
| 122 | return [ | |
| 123 | ...(busy ? [] : [binding(["n"], "take a snapshot now"), binding(["l"], "bring the mirror level now")]), | |
| 124 | ...(mayMirror ? [binding(["m"], "set the mirror"), binding(["f"], "forget the mirror")] : []), | |
| 125 | binding(["enter", "esc", "q"], "close"), | |
| 126 | ]; | |
| 127 | }, | |
| 128 | load: read, | |
| 53 | 129 | render(area, s) { |
| 54 | 130 | const buttons = dialog(s, area, ctx.theme.c, { |
| 55 | 131 | title: "Resilience", |
| 56 | body: [ | |
| 57 | ...wrap(mirrorLine(f), WIDTH - 6), | |
| 58 | ...(mayMirror ? ["M sets the mirror, F forgets it."] : []), | |
| 59 | ...(note === "" ? [] : wrap(note, WIDTH - 6)), | |
| 60 | "", | |
| 61 | ...resilienceLines(f.app.config.dataDir), | |
| 62 | ], | |
| 132 | body: body(), | |
| 63 | 133 | actions: [{ label: "Close" }], |
| 64 | 134 | selected: 0, |
| 65 | 135 | width: WIDTH, |
| @@ -68,7 +138,16 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen { | ||
| 68 | 138 | }, |
| 69 | 139 | key(e) { |
| 70 | 140 | if (e.ctrl || e.alt) return true; |
| 71 | if (e.key === "enter" || e.key === "esc" || e.key === "q") ctx.pop(); | |
| 141 | if (asking !== undefined) { | |
| 142 | const was = asking; | |
| 143 | asking = undefined; | |
| 144 | if (e.key === "y") act(was); | |
| 145 | return true; | |
| 146 | } | |
| 147 | if ((e.key === "n" || e.key === "l") && !busy) { | |
| 148 | asking = e.key === "n" ? "snapshot" : "mirror"; | |
| 149 | said = ""; | |
| 150 | } else if (e.key === "enter" || e.key === "esc" || e.key === "q") ctx.pop(); | |
| 72 | 151 | else if (mayMirror && e.key === "m") ctx.push(ask(0, {})); |
| 73 | 152 | else if (mayMirror && e.key === "f") |
| 74 | 153 | ctx.push( |
src/web/pages/backups.ts +13 −28
| @@ -28,6 +28,7 @@ import { formatMinutes, formatSpan, timeByPart, type DrillResult } from "../../s | ||
| 28 | 28 | import { lastDrill } from "../../snapshot/drills.ts"; |
| 29 | 29 | import type { Store } from "../../snapshot/store.ts"; |
| 30 | 30 | import { snapshotsOf, type SnapshotsHandle } from "../../snapshot/service.ts"; |
| 31 | import { mirrorNow, snapshotNow } from "../../actions/backups_now.ts"; | |
| 31 | 32 | import { lastSnapshotOutcome } from "../../snapshot/outcome.ts"; |
| 32 | 33 | import type { Ctx } from "../../http/context.ts"; |
| 33 | 34 | import { |
| @@ -626,23 +627,12 @@ export async function backupsNow(ctx: Ctx): Promise<Response> { | ||
| 626 | 627 | if (denied) return denied; |
| 627 | 628 | const form = await readForm(ctx, "admin"); |
| 628 | 629 | if (form instanceof Response) return form; |
| 629 | const svc = snapshotsOf(ctx.app); | |
| 630 | if (svc === undefined) | |
| 631 | return flashTo(ctx, "/settings/backups", { | |
| 632 | tone: "danger", | |
| 633 | message: "This forge has nowhere to put a snapshot.", | |
| 634 | }); | |
| 635 | try { | |
| 636 | const m = await svc.now(); | |
| 637 | return flashTo(ctx, "/settings/backups", { | |
| 638 | message: `Snapshot ${m.name} taken: ${m.repos} repositories, ${m.apps} apps, ${fileSize(m.bytes)}.`, | |
| 639 | }); | |
| 640 | } catch (error) { | |
| 641 | return flashTo(ctx, "/settings/backups", { | |
| 642 | tone: "danger", | |
| 643 | message: `The snapshot failed: ${String(error)}`, | |
| 644 | }); | |
| 645 | } | |
| 630 | const taken = await snapshotNow(ctx.app, ctx.auth.user); | |
| 631 | return flashTo( | |
| 632 | ctx, | |
| 633 | "/settings/backups", | |
| 634 | taken.ok ? { message: taken.value.says } : { tone: "danger", message: taken.error.says }, | |
| 635 | ); | |
| 646 | 636 | } |
| 647 | 637 | |
| 648 | 638 | /** POST /settings/backups/drill — restore the newest snapshot, keep what came of it. */ |
| @@ -708,19 +698,14 @@ export async function backupsMirrorNow(ctx: Ctx): Promise<Response> { | ||
| 708 | 698 | if (denied) return denied; |
| 709 | 699 | const form = await readForm(ctx, "admin"); |
| 710 | 700 | if (form instanceof Response) return form; |
| 711 | const state = await snapshotsOf(ctx.app)?.mirrorNow(); | |
| 712 | if (state === undefined) | |
| 713 | return flashTo(ctx, "/settings/backups", { tone: "danger", message: "This forge has no mirror to bring level." }); | |
| 714 | if (state.sourceError !== undefined) | |
| 715 | return flashTo(ctx, "/settings/backups", { | |
| 716 | tone: "danger", | |
| 717 | message: `The snapshots could not be read from the destination, so the mirror was not touched: ${state.sourceError}`, | |
| 718 | }); | |
| 701 | const level = await mirrorNow(ctx.app, ctx.auth.user); | |
| 719 | 702 | return flashTo( |
| 720 | 703 | ctx, |
| 721 | 704 | "/settings/backups", |
| 722 | state.failingSince === undefined | |
| 723 | ? { message: `The mirror is level${state.newest ? ` at ${state.newest}` : ""}.` } | |
| 724 | : { tone: "danger", message: `The mirror is not level: ${state.error ?? "unknown reason"}` }, | |
| 705 | !level.ok | |
| 706 | ? { tone: "danger", message: level.error.says } | |
| 707 | : level.value.level | |
| 708 | ? { message: level.value.says } | |
| 709 | : { tone: "danger", message: level.value.says }, | |
| 725 | 710 | ); |
| 726 | 711 | } |
test/tui-resilience-now.test.ts added +182
| @@ -0,0 +1,182 @@ | ||
| 1 | /** | |
| 2 | * The terminal's Resilience dialog takes a snapshot with `n` and brings the | |
| 3 | * mirror level with `l`, each after a y/n (#7318), through the actions MCP | |
| 4 | * `snapshot_now` and `mirror_now` use; a non-admin is refused by them. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 11 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 12 | import { mirrorNow, snapshotNow } from "../src/actions/backups_now.ts"; | |
| 13 | import { startSnapshots, type SnapshotsHandle } from "../src/snapshot/service.ts"; | |
| 14 | import { dirStore, type Store } from "../src/snapshot/store.ts"; | |
| 15 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 16 | import type { TuiIdentity } from "../src/tui/session.ts"; | |
| 17 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 18 | import { quietLogs } from "./helpers/globals.ts"; | |
| 19 | import { settleApp } from "./helpers/tui.ts"; | |
| 20 | ||
| 21 | quietLogs(); | |
| 22 | ||
| 23 | describe("snapshot and mirror from the terminal's Resilience dialog", () => { | |
| 24 | let dataDir: string; | |
| 25 | let forge: Forge; | |
| 26 | let svc: SnapshotsHandle; | |
| 27 | ||
| 28 | function boot(username: string) { | |
| 29 | const terminal = headlessTerminal(140, 40); | |
| 30 | const user = getUserByName(forge.db, username)!; | |
| 31 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user }; | |
| 32 | const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false }); | |
| 33 | app.start(); | |
| 34 | const send = (k: string, ctrl = false) => | |
| 35 | app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false }); | |
| 36 | const press = async (...keys: string[]) => { | |
| 37 | for (const k of keys) send(k); | |
| 38 | await settleApp(app); | |
| 39 | }; | |
| 40 | const open = async () => { | |
| 41 | send("k", true); | |
| 42 | await settleApp(app); | |
| 43 | for (const ch of "Resilience") send(ch); | |
| 44 | await settleApp(app); | |
| 45 | const listed = app.screen.toText(); | |
| 46 | await press("enter"); | |
| 47 | return listed; | |
| 48 | }; | |
| 49 | // Keys without waiting for what they start: to see a screen mid-action. | |
| 50 | const sendOnly = (...keys: string[]) => { | |
| 51 | for (const k of keys) send(k); | |
| 52 | app.frame(); | |
| 53 | }; | |
| 54 | return { text: () => app.screen.toText(), press, sendOnly, open, stop: () => app.stop() }; | |
| 55 | } | |
| 56 | ||
| 57 | beforeAll(async () => { | |
| 58 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-resilience-now-")); | |
| 59 | forge = createApp({ dataDir, port: 0 }); | |
| 60 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) { | |
| 61 | const made = await createUser(forge.db, { | |
| 62 | origin: "seed", | |
| 63 | trusted: true, | |
| 64 | username, | |
| 65 | email: `${username}@forge.local`, | |
| 66 | password: "a-good-passphrase", | |
| 67 | adminIfFirst, | |
| 68 | }); | |
| 69 | if (!made.ok) throw new Error(made.error); | |
| 70 | } | |
| 71 | svc = startSnapshots(forge, dirStore(join(dataDir, "store")), { version: "t", nightly: false }); | |
| 72 | }, 30_000); | |
| 73 | ||
| 74 | afterAll(async () => { | |
| 75 | await svc.stop(); | |
| 76 | forge.close(); | |
| 77 | await rm(dataDir, { recursive: true, force: true }); | |
| 78 | }); | |
| 79 | ||
| 80 | test("an admin is offered n and l, and n then y adds a snapshot shown on top", async () => { | |
| 81 | const h = boot("alice"); | |
| 82 | await h.open(); | |
| 83 | expect(h.text()).toContain("Snapshots, newest first:"); | |
| 84 | expect(h.text()).toContain("none kept"); | |
| 85 | expect(h.text()).toContain("n take a snapshot now"); | |
| 86 | expect(h.text()).toContain("l bring the mirror level now"); | |
| 87 | ||
| 88 | await h.press("n"); | |
| 89 | expect(h.text()).toContain("take a snapshot now? y/n"); | |
| 90 | await h.press("x"); | |
| 91 | expect(h.text()).not.toContain("take a snapshot now? y/n"); | |
| 92 | expect(await svc.list()).toEqual([]); | |
| 93 | ||
| 94 | await h.press("n", "y"); | |
| 95 | for (let i = 0; i < 100 && !h.text().includes(" taken: "); i++) { | |
| 96 | await Bun.sleep(20); | |
| 97 | await h.press(); | |
| 98 | } | |
| 99 | const kept = await svc.list(); | |
| 100 | expect(kept.length).toBe(1); | |
| 101 | const lines = h.text().split("\n"); | |
| 102 | const head = lines.findIndex((l) => l.includes("Snapshots, newest first:")); | |
| 103 | expect(lines[head + 1]).toContain(kept[0]!.name); | |
| 104 | expect(h.text()).toContain(`Snapshot ${kept[0]!.name} taken: `); | |
| 105 | h.stop(); | |
| 106 | }); | |
| 107 | ||
| 108 | test("l then y says in words that there is no mirror, and draws no secret", async () => { | |
| 109 | const h = boot("alice"); | |
| 110 | await h.open(); | |
| 111 | await h.press("l"); | |
| 112 | expect(h.text()).toContain("bring the mirror level now? y/n"); | |
| 113 | await h.press("y"); | |
| 114 | for (let i = 0; i < 100 && !h.text().includes("no mirror"); i++) { | |
| 115 | await Bun.sleep(20); | |
| 116 | await h.press(); | |
| 117 | } | |
| 118 | expect(h.text()).toContain("This forge has no mirror to bring level."); | |
| 119 | expect(h.text()).not.toContain("SNAPSHOT_MIRROR_S3"); | |
| 120 | h.stop(); | |
| 121 | }); | |
| 122 | ||
| 123 | test("a non-admin has no Resilience command, and both actions refuse them", async () => { | |
| 124 | const h = boot("bob"); | |
| 125 | expect(await h.open()).not.toContain("each protection green, red or untested"); | |
| 126 | expect(h.text()).not.toContain("Snapshots, newest first:"); | |
| 127 | h.stop(); | |
| 128 | const bob = getUserByName(forge.db, "bob")!; | |
| 129 | const before = (await svc.list()).length; | |
| 130 | const taken = await snapshotNow(forge, bob); | |
| 131 | expect(taken.ok).toBe(false); | |
| 132 | if (!taken.ok) expect(taken.error.code).toBe("not_found"); | |
| 133 | expect((await mirrorNow(forge, bob)).ok).toBe(false); | |
| 134 | expect((await svc.list()).length).toBe(before); | |
| 135 | }); | |
| 136 | ||
| 137 | test("n then y pressed twice while the first runs takes one snapshot, and the keys leave the footer", async () => { | |
| 138 | const h = boot("alice"); | |
| 139 | await h.open(); | |
| 140 | // Two snapshots in one second share a name, so count the asks, not the store. | |
| 141 | let asked = 0; | |
| 142 | const take = svc.now; | |
| 143 | (svc as { now: SnapshotsHandle["now"] }).now = () => { | |
| 144 | asked++; | |
| 145 | return take(); | |
| 146 | }; | |
| 147 | h.sendOnly("n", "y", "n", "y", "l", "y"); | |
| 148 | expect(h.text()).toContain("Taking a snapshot…"); | |
| 149 | expect(h.text()).not.toContain("take a snapshot now? y/n"); | |
| 150 | expect(h.text()).not.toContain("n take a snapshot now"); | |
| 151 | for (let i = 0; i < 100 && !h.text().includes(" taken: "); i++) { | |
| 152 | await Bun.sleep(20); | |
| 153 | await h.press(); | |
| 154 | } | |
| 155 | (svc as { now: SnapshotsHandle["now"] }).now = take; | |
| 156 | expect(asked).toBe(1); | |
| 157 | expect(h.text()).toContain("n take a snapshot now"); | |
| 158 | h.stop(); | |
| 159 | }); | |
| 160 | ||
| 161 | test("a store that cannot be listed says so, never none kept", async () => { | |
| 162 | await svc.stop(); | |
| 163 | const inner = dirStore(join(dataDir, "broken")); | |
| 164 | const broken: Store = { | |
| 165 | ...inner, | |
| 166 | list: async () => { | |
| 167 | throw new Error("list refused"); | |
| 168 | }, | |
| 169 | names: async () => { | |
| 170 | throw new Error("names refused"); | |
| 171 | }, | |
| 172 | }; | |
| 173 | svc = startSnapshots(forge, broken, { version: "t", nightly: false }); | |
| 174 | const h = boot("alice"); | |
| 175 | await h.open(); | |
| 176 | expect(h.text()).toContain("the snapshots could not be read"); | |
| 177 | expect(h.text()).not.toContain("none kept"); | |
| 178 | expect(h.text()).not.toContain("list refused"); | |
| 179 | expect(h.text()).not.toContain("names refused"); | |
| 180 | h.stop(); | |
| 181 | }); | |
| 182 | }); |
The page still has its own copy of both handlers (
src/web/pages/backups.tsbackupsNow/backupsMirrorNow). Route them through these actions so the words live in one place; they already differ onsvcvssvc.store.