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

Disk readings are kept per machine, with a 30-day write rate and a projected date for rated TBW #10665

Merged opened this

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

Merged

torque/forge:torque/10465-disk-readings-are-kept-per-machine-with → main 8 commits fork 1 thread · 1 unresolved

  • Merged. torque/10465-disk-readings-are-kept-per-machine-with was merged into main as e162c05.

Review

Sign in to review this change.
19 files changed +754 −2
docs/features/disk-readings.md added +40
@@ -0,0 +1,40 @@
1 # Disk readings per machine (#10465)
2
3 The forge keeps `smartctl --json` readings of NVMe disks per machine and
4 device, and shows the bytes written in the last 30 days and the date the
5 vendor's rated TBW is reached at that rate. The rating comes from
6 `src/machines/disk_catalog.ts` (vendor figures, not measured); the date is a
7 projection, never an alert. A model not in the catalog gets a rate and no date;
8 a drive behind a RAID controller is recorded as such and not assessed.
9
10 Who: the owner, a forge admin who is a person. A member, an agent account
11 (admin or not) and a stranger get 404 on both doors, and nothing is stored.
12
13 ## File a reading
14
15 ```
16 smartctl --json -a /dev/nvme0 | jq -n --slurpfile s /dev/stdin \
17 '{smartctl: $s[0], cpu: "AMD EPYC 7302", memoryBytes: 64000000000}' |
18 curl -X PUT -H "authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
19 --data @- "$FORGE_URL/api/v1/admin/machines/rack1/disks/nvme0"
20 ```
21
22 `cpu` and `memoryBytes` are optional and update the machine's inventory. A
23 reading that does not parse, a `cpu` with a control character, or a new
24 machine past 256 (a new device past 64 on one machine) answers 422 with the
25 reason and stores nothing. Each write prunes that device's readings older than
26 60 days, always keeping its newest two.
27 The token needs `admin` scope.
28
29 ## Read
30
31 - `GET /api/v1/admin/machines/rack1/disks` (any scope of the owner's token):
32 per device the latest reading, `bytesWrittenInWindow` over `spanDays` (≤ 30:
33 from the oldest reading inside the 30 days before the latest, so a figure for
34 30 days only once readings go back that far),
35 `ratedReachedAt` and the assessment. A counter that went backwards gives no
36 rate and no date.
37 - The terminal: `:` → "Disks", pick a machine.
38
39 Rate: latest reading against the oldest one inside the 30 days before it,
40 bytes per day = Δbytes ÷ Δdays; date = latest + (rated TBW − written) ÷ rate.
scripts/unreached-allow.json −2
@@ -15,8 +15,6 @@
1515 "src/flow/review_rounds.ts",
1616 "src/flow/shared_red.ts",
1717 "src/flow/stale_fold.ts",
18 "src/machines/disk_catalog.ts",
19 "src/machines/disk_health.ts",
2018 "src/spawn/pod.ts",
2119 "src/tui/gallery.ts",
2220 "src/tui/ui/qr.ts",
src/actions/disk_readings.ts added +41
@@ -0,0 +1,41 @@
1 /**
2 * Who writes and who reads a machine's disk readings (#10465). Both are the
3 * owner — a forge admin who is a person: a reading is a claim about hardware
4 * that decides what the owner replaces, so an agent account neither files nor
5 * reads one. Anyone else gets "not_found" here; over the API a signed-in
6 * non-admin meets the admin gate's 403 first (`http/admin_gate.ts`).
7 */
8 import type { App } from "../app.ts";
9 import type { User } from "../core/users.ts";
10 import { err, ok, type Result } from "../kernel/result.ts";
11 import { diskSummary, machineNames, recordReading, type Inventory, type MachineSummary } from "../machines/disk_readings.ts";
12
13 export const mayHandleDisks = (user: User | undefined): user is User => user !== undefined && user.isAdmin && user.kind !== "agent";
14
15 export type FileError = { code: "not_found" } | { code: "invalid"; says: string };
16
17 /** Store one reading from smartctl's JSON text for `machine` and `device`. */
18 export function fileReading(
19 app: App,
20 actor: User | undefined,
21 machine: string,
22 device: string,
23 smartJsonText: unknown,
24 inventory?: Inventory,
25 ): Result<{ kind: "nvme" | "behind-controller"; takenAt: number }, FileError> {
26 if (!mayHandleDisks(actor)) return err({ code: "not_found" });
27 const r = recordReading(app.db, machine, device, smartJsonText, Date.now(), inventory);
28 if (!r.ok) return err({ code: "invalid", says: `Reading refused: ${r.reason}${r.field ? ` (${r.field})` : ""}.` });
29 return ok({ kind: r.kind, takenAt: r.takenAt });
30 }
31
32 /** What the readings of one machine say; nothing for anyone but the owner. */
33 export function readMachine(app: App, actor: User | undefined, machine: string): Result<MachineSummary, "not_found"> {
34 if (!mayHandleDisks(actor)) return err("not_found");
35 return ok(diskSummary(app.db, machine));
36 }
37
38 /** The machines with readings; nothing for anyone but the owner. */
39 export function readMachines(app: App, actor: User | undefined): Result<string[], "not_found"> {
40 return mayHandleDisks(actor) ? ok(machineNames(app.db)) : err("not_found");
41 }
src/contract/openapi/routes/getApiV1AdminMachinesMachineDisks.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/admin/machines/{machine}/disks",
6 summary:
7 "Read a machine's disk readings (a person admin's token, any scope): per device the latest reading, bytesWrittenInWindow over spanDays (the oldest reading within 30 days of the latest to the latest, so ≤ 30 days, not always 30), ratedReachedAt and the assessment",
8 auth: true,
9 refusals: { not_found: "the token is not a person who administers the forge, or the machine has no readings" },
10 };
src/contract/openapi/routes/index.ts +2
@@ -41,6 +41,7 @@ export { route as getApiV1AdminDrills } from "./getApiV1AdminDrills.ts";
4141 export { route as getApiV1AdminInvites } from "./getApiV1AdminInvites.ts";
4242 export { route as getApiV1AdminKpisProbe } from "./getApiV1AdminKpisProbe.ts";
4343 export { route as getApiV1AdminLimits } from "./getApiV1AdminLimits.ts";
44 export { route as getApiV1AdminMachinesMachineDisks } from "./getApiV1AdminMachinesMachineDisks.ts";
4445 export { route as getApiV1AdminMaintenance } from "./getApiV1AdminMaintenance.ts";
4546 export { route as getApiV1AdminPeople } from "./getApiV1AdminPeople.ts";
4647 export { route as getApiV1AdminPinned } from "./getApiV1AdminPinned.ts";
@@ -359,6 +360,7 @@ export { route as postApiV1Users } from "./postApiV1Users.ts";
359360 export { route as postMcp } from "./postMcp.ts";
360361 export { route as putApiV1AdminCapacityKey } from "./putApiV1AdminCapacityKey.ts";
361362 export { route as putApiV1AdminLimitsKey } from "./putApiV1AdminLimitsKey.ts";
363 export { route as putApiV1AdminMachinesMachineDisksDevice } from "./putApiV1AdminMachinesMachineDisksDevice.ts";
362364 export { route as putApiV1AdminQuotasKey } from "./putApiV1AdminQuotasKey.ts";
363365 export { route as putApiV1AdminShield } from "./putApiV1AdminShield.ts";
364366 export { route as putApiV1AdminTeamsSlug } from "./putApiV1AdminTeamsSlug.ts";
src/contract/openapi/routes/putApiV1AdminMachinesMachineDisksDevice.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "put",
5 path: "/api/v1/admin/machines/{machine}/disks/{device}",
6 summary: "File a smartctl reading for one disk of a machine (a person admin's token, admin scope); answers 201",
7 auth: true,
8 body: { smartctl: "smartctl JSON, as text or object", cpu: "string, optional", memoryBytes: "integer, optional" },
9 refusals: { invalid: "the reading does not parse (422)", not_found: "the token is not a person who administers the forge" },
10 };
src/core/migration-pins/20261110110000-disk-readings.pin added +1
@@ -0,0 +1,1 @@
1 6424c1dd3f5515eb89c572a418072d0eefce8b4ea3a291d89cbb7514a2b87052
src/core/migrations/20261110110000-disk-readings.ts added +20
@@ -0,0 +1,20 @@
1 // Disk health readings, one row per machine and device and time (#10465), and
2 // the machine's own CPU and memory. A behind-controller device keeps a row with
3 // no reading. Additive.
4 export default `
5 CREATE TABLE disk_reading (
6 id INTEGER PRIMARY KEY AUTOINCREMENT,
7 machine TEXT NOT NULL,
8 device TEXT NOT NULL,
9 taken_at INTEGER NOT NULL,
10 kind TEXT NOT NULL CHECK (kind IN ('nvme','behind-controller')),
11 reading TEXT
12 );
13 CREATE INDEX disk_reading_by_device ON disk_reading (machine, device, taken_at);
14 CREATE TABLE machine_inventory (
15 machine TEXT PRIMARY KEY,
16 cpu TEXT NOT NULL,
17 memory_bytes INTEGER NOT NULL,
18 updated_at INTEGER NOT NULL
19 );
20 `;
src/http/api.ts +2
@@ -360,6 +360,7 @@ import { businessPlanRoutes } from "./api/business_plan.ts";
360360 import { tokenRoutes } from "./api/tokens.ts";
361361 import { accountAdmin, adminRoutes } from "./api/admin.ts";
362362 import { maintenanceRoutes } from "./api/maintenance.ts";
363 import { diskReadingRoutes } from "./api/disk_readings.ts";
363364 import { TEMPLATE_ERRORS } from "../apps/template_tip.ts";
364365 import { flagsRoutes } from "./api/flags.ts";
365366 import { appTemplateRoutes } from "./api/app_templates.ts";
@@ -1247,6 +1248,7 @@ export function apiRoutes(router: Router<Handler>): void {
12471248 forgeOriginRoutes(router);
12481249 adminRoutes(router);
12491250 maintenanceRoutes(router);
1251 diskReadingRoutes(router);
12501252 flagsRoutes(router);
12511253 appTemplateRoutes(router);
12521254
src/http/api/disk_readings.ts added +42
@@ -0,0 +1,42 @@
1 /**
2 * Disk readings over the API (#10465): `PUT /api/v1/admin/machines/:machine/disks/:device`
3 * files smartctl's JSON (body `{smartctl, cpu?, memoryBytes?}`, `smartctl` the
4 * text or the object); `GET /api/v1/admin/machines/:machine/disks` reads the
5 * summary. The owner's admin-scope credential writes, any scope of it reads.
6 */
7 import { fileReading, readMachine } from "../../actions/disk_readings.ts";
8 import { crossOrigin, type Handler } from "../context.ts";
9 import { apiError, boundedBody, forbidden, json, notFound, requireScope, unauthorized } from "../respond.ts";
10 import type { Router } from "../router.ts";
11
12 /** smartctl's text is capped at 4 MB by the parser; room for the JSON wrapping it. */
13 const BODY_MAX = 4_300_000;
14
15 export function diskReadingRoutes(router: Router<Handler>): void {
16 router.add("GET", "/api/v1/admin/machines/:machine/disks", (ctx) => {
17 if (!ctx.auth.user) return unauthorized();
18 if (!ctx.auth.user.isAdmin) return notFound();
19 const denied = requireScope(ctx, "ro");
20 if (denied) return denied;
21 const r = readMachine(ctx.app, ctx.auth.user, ctx.params.machine ?? "");
22 return r.ok ? json(r.value) : notFound();
23 });
24 router.add("PUT", "/api/v1/admin/machines/:machine/disks/:device", async (ctx) => {
25 if (!ctx.auth.user) return unauthorized();
26 if (!ctx.auth.user.isAdmin) return notFound();
27 const denied = requireScope(ctx, "admin");
28 if (denied) return denied;
29 if (ctx.auth.via === "session" && crossOrigin(ctx.request, ctx.url, ctx.app.log)) return forbidden();
30 const input = await boundedBody(ctx.request, BODY_MAX);
31 if (input === "too_large") return apiError("too_large", 413, "The request is too large.");
32 const smart = input["smartctl"];
33 const text = typeof smart === "string" ? smart : smart !== null && typeof smart === "object" ? JSON.stringify(smart) : "";
34 const inventory =
35 input["cpu"] === undefined && input["memoryBytes"] === undefined
36 ? undefined
37 : { cpu: input["cpu"] as string, memoryBytes: input["memoryBytes"] as number };
38 const r = fileReading(ctx.app, ctx.auth.user, ctx.params.machine ?? "", ctx.params.device ?? "", text, inventory);
39 if (r.ok) return json(r.value, 201);
40 return r.error.code === "invalid" ? apiError("invalid", 422, r.error.says) : notFound();
41 });
42 }
src/machines/disk_readings.ts added +189
@@ -0,0 +1,189 @@
1 /**

Unreached module: allow-listed instead of wired. Add the caller (intake with identity check, gated read, TUI screen, docs/features page) or hold this back until it exists.

2 * Disk readings kept per machine and device (#10465), in the forge's own
3 * database. A reading is stored only after `parseSmartJson` accepts it.
4 *
5 * Write rate: the latest reading against the oldest one inside the 30 days
6 * before it; bytes per day = Δbytes written ÷ Δdays (a rate over the span
7 * actually covered, not a guess beyond it). Projected date: the latest time +
8 * (rated TBW × 10^12 B − bytes written) ÷ rate. Ratings are vendor figures
9 * (see disk_catalog.ts); the date is a projection, never an alert.
10 */
11
12 import type { Database } from "bun:sqlite";
13 import { diskSpec, type DiskSpec } from "./disk_catalog.ts";
14 import { assessDisk, parseSmartJson, type Assessment, type DiskReading } from "./disk_health.ts";
15
16 const DAY_MS = 86_400_000;
17 export const WINDOW_DAYS = 30;
18 const NAME = /^[A-Za-z0-9][A-Za-z0-9._:\/-]{0,99}$/;
19 /** A device keeps readings this many days back (twice the window), and always its newest two. */
20 export const KEEP_DAYS = 60;
21 const KEEP_NEWEST = 2;
22 /** At most this many machines per forge and devices per machine; a new name past either is refused. */
23 export const MAX_MACHINES = 256;
24 export const MAX_DEVICES = 64;
25 /** C0, DEL and C1 controls: any of them could steer the terminal that prints the text. */
26 const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
27 /** Text as a terminal may print it: every control character dropped. */
28 export const printable = (text: string): string => text.replace(new RegExp(CONTROL.source, "g"), "");
29
30 export type Inventory = { cpu: string; memoryBytes: number };
31 export type RecordResult =
32 | { ok: true; kind: "nvme" | "behind-controller"; takenAt: number }
33 | { ok: false; reason: string; field?: string };
34
35 /** Store a reading from smartctl's JSON text; a refusal names its reason and stores nothing. */
36 export function recordReading(
37 db: Database,
38 machine: string,
39 device: string,
40 smartJsonText: unknown,
41 now: number = Date.now(),
42 inventory?: Inventory,
43 ): RecordResult {
44 if (typeof machine !== "string" || !NAME.test(machine)) return { ok: false, reason: "bad-machine" };
45 if (typeof device !== "string" || !NAME.test(device)) return { ok: false, reason: "bad-device" };
46 if (!Number.isSafeInteger(now) || now < 0) return { ok: false, reason: "bad-time" };
47 if (inventory !== undefined) {
48 const okInv =
49 typeof inventory.cpu === "string" && inventory.cpu.length > 0 && inventory.cpu.length <= 200 && !CONTROL.test(inventory.cpu) &&
50 Number.isSafeInteger(inventory.memoryBytes) && inventory.memoryBytes > 0;
51 if (!okInv) return { ok: false, reason: "bad-inventory" };
52 }
53 const parsed = parseSmartJson(smartJsonText);
54 if (parsed.kind === "refused") return { ok: false, reason: parsed.reason, ...(parsed.field ? { field: parsed.field } : {}) };
55 let refused: string | null = null;
56 db.transaction(() => {
57 const known = db.query("SELECT 1 FROM disk_reading WHERE machine = ? AND device = ? LIMIT 1").get(machine, device);
58 if (!known) {
59 const machineKnown = db.query("SELECT 1 FROM disk_reading WHERE machine = ? LIMIT 1").get(machine);
60 if (!machineKnown) {
61 const { n } = db.query("SELECT COUNT(DISTINCT machine) AS n FROM disk_reading").get() as { n: number };
62 if (n >= MAX_MACHINES) return void (refused = "too-many-machines");
63 } else {
64 const { n } = db.query("SELECT COUNT(DISTINCT device) AS n FROM disk_reading WHERE machine = ?").get(machine) as { n: number };
65 if (n >= MAX_DEVICES) return void (refused = "too-many-devices");
66 }
67 }
68 db.query("INSERT INTO disk_reading (machine, device, taken_at, kind, reading) VALUES (?, ?, ?, ?, ?)").run(
69 machine, device, now, parsed.kind, parsed.kind === "nvme" ? JSON.stringify(parsed) : null,
70 );
71 if (inventory)
72 db.query(
73 `INSERT INTO machine_inventory (machine, cpu, memory_bytes, updated_at) VALUES (?, ?, ?, ?)
74 ON CONFLICT(machine) DO UPDATE SET cpu = excluded.cpu, memory_bytes = excluded.memory_bytes, updated_at = excluded.updated_at`,
75 ).run(machine, inventory.cpu, inventory.memoryBytes, now);
76 // Prune: the summary reads only the newest two and the oldest inside the window.
77 db.query(
78 `DELETE FROM disk_reading WHERE machine = ?1 AND device = ?2 AND taken_at < ?3
79 AND id NOT IN (SELECT id FROM disk_reading WHERE machine = ?1 AND device = ?2 ORDER BY taken_at DESC, id DESC LIMIT ?4)`,
80 ).run(machine, device, now - KEEP_DAYS * DAY_MS, KEEP_NEWEST);
81 })();
82 if (refused) return { ok: false, reason: refused };
83 return { ok: true, kind: parsed.kind, takenAt: now };
84 }
85
86 export type DeviceSummary =
87 | { device: string; kind: "behind-controller"; takenAt: number; reading: null; spec: null; assessment: null; bytesWrittenInWindow: null; ratedReachedAt: null }
88 | {
89 device: string;
90 kind: "nvme";
91 takenAt: number;
92 reading: DiskReading;
93 inventory: { model: string; firmware: string; capacityBytes: number };
94 spec: DiskSpec | null;
95 assessment: Assessment;
96 /**
97 * Bytes written between the oldest reading in the 30 days before the latest and the latest:
98 * a 30-day figure only when spanDays is 30. Null with under two readings in the window.
99 */
100 bytesWrittenInWindow: number | null;
101 /** Days that figure spans (≤ 30). */
102 spanDays: number | null;
103 /** ISO date (UTC) rated TBW is reached at that rate; null with no spec, under two readings, or no growth. */
104 ratedReachedAt: string | null;
105 };
106
107 export type MachineSummary = { machine: string; cpu: string | null; memoryBytes: number | null; devices: DeviceSummary[] };
108
109 type Row = { id: number; taken_at: number; kind: string; reading: string | null };
110 const COLS = "id, taken_at, kind, reading";
111
112 export function diskSummary(db: Database, machine: string): MachineSummary {
113 const inv = db.query("SELECT cpu, memory_bytes FROM machine_inventory WHERE machine = ?").get(machine) as
114 | { cpu: string; memory_bytes: number }
115 | null;
116 const devices = (db.query("SELECT DISTINCT device FROM disk_reading WHERE machine = ? ORDER BY device").all(machine) as { device: string }[]).map(
117 (d) => d.device,
118 );
119 const out: DeviceSummary[] = [];
120 for (const device of devices) {
121 // Three bounded reads on (machine, device, taken_at): the latest row, the
122 // newest two NVMe readings, and the oldest NVMe reading inside the window.
123 const latest = db
124 .query(`SELECT ${COLS} FROM disk_reading WHERE machine = ? AND device = ? ORDER BY taken_at DESC, id DESC LIMIT 1`)
125 .get(machine, device) as Row;
126 if (latest.kind !== "nvme" || latest.reading === null) {
127 out.push({ device, kind: "behind-controller", takenAt: latest.taken_at, reading: null, spec: null, assessment: null, bytesWrittenInWindow: null, ratedReachedAt: null });
128 continue;
129 }
130 const nvme = db
131 .query(
132 `SELECT ${COLS} FROM disk_reading WHERE machine = ? AND device = ? AND kind = 'nvme' AND reading IS NOT NULL
133 ORDER BY taken_at DESC, id DESC LIMIT 2`,
134 )
135 .all(machine, device) as Row[];
136 const reading = JSON.parse(nvme[0]!.reading!) as DiskReading;
137 const previous = nvme[1] ? (JSON.parse(nvme[1].reading!) as DiskReading) : null;
138 const spec = diskSpec(reading.model);
139
140 const windowStart = latest.taken_at - WINDOW_DAYS * DAY_MS;
141 const base = db
142 .query(
143 `SELECT ${COLS} FROM disk_reading WHERE machine = ? AND device = ? AND kind = 'nvme' AND reading IS NOT NULL
144 AND taken_at >= ? AND id != ? ORDER BY taken_at ASC, id ASC LIMIT 1`,
145 )
146 .get(machine, device, windowStart, nvme[0]!.id) as Row | null;
147 let bytes30: number | null = null;
148 let spanDays: number | null = null;
149 let reached: string | null = null;
150 if (base && base.taken_at < latest.taken_at) {
151 const baseReading = JSON.parse(base.reading!) as DiskReading;
152 const delta = reading.bytesWritten - baseReading.bytesWritten;
153 if (delta >= 0) {
154 bytes30 = delta;
155 spanDays = (latest.taken_at - base.taken_at) / DAY_MS;
156 if (spec) {
157 const remaining = spec.ratedTbw * 1e12 - reading.bytesWritten;
158 if (remaining <= 0) reached = new Date(latest.taken_at).toISOString().slice(0, 10);
159 else if (delta > 0) {
160 const perDay = delta / spanDays;
161 const at = new Date(latest.taken_at + (remaining / perDay) * DAY_MS);
162 // A rate so small the date leaves the calendar is no projection.
163 if (at.getUTCFullYear() <= 9999) reached = at.toISOString().slice(0, 10);
164 }
165 }
166 }
167 }
168 out.push({
169 device,
170 kind: "nvme",
171 takenAt: latest.taken_at,
172 reading,
173 inventory: { model: reading.model, firmware: reading.firmware, capacityBytes: reading.capacityBytes },
174 spec,
175 assessment: assessDisk(reading, previous, spec),
176 bytesWrittenInWindow: bytes30,
177 spanDays,
178 ratedReachedAt: reached,
179 });
180 }
181 return { machine, cpu: inv?.cpu ?? null, memoryBytes: inv?.memory_bytes ?? null, devices: out };
182 }
183
184 /** Machine names that have a reading or an inventory, A to Z. */
185 export function machineNames(db: Database): string[] {
186 return (
187 db.query("SELECT machine FROM disk_reading UNION SELECT machine FROM machine_inventory ORDER BY machine").all() as { machine: string }[]
188 ).map((r) => r.machine);
189 }
src/tui/screens/disks.ts added +85
@@ -0,0 +1,85 @@
1 /**
2 * Disk readings in the terminal, the owner's (#10465): pick a machine, then a
3 * row a device — model, bytes written in the last 30 days, the date rated TBW
4 * is projected at that rate, and what the assessment says. Ratings are vendor
5 * figures; the date is a projection, not an alert. Read-only; the gate is
6 * `mayHandleDisks`.
7 */
8
9 import { mayHandleDisks, readMachine, readMachines } from "../../actions/disk_readings.ts";
10 import { printable, type MachineSummary } from "../../machines/disk_readings.ts";
11 import type { Ctx, Screen } from "../app/app.ts";
12 import { binding } from "../app/keys.ts";
13 import type { Session } from "../session.ts";
14 import { style } from "../term/style.ts";
15 import { Scroll, blank, drawLines, hitScroll, pickerScreen } from "./common.ts";
16 import type { PaletteEntry } from "./entries.ts";
17
18 const tb = (bytes: number): string => `${(bytes / 1e12).toFixed(2)} TB`;
19
20 /**
21 * Header lines, then one line per device with its reasons beneath; every line stripped of
22 * control characters, since model, firmware and cpu are a machine's own text.
23 */
24 export function diskLines(s: MachineSummary): string[] {
25 const lines = [`${s.machine}: ${s.cpu ?? "cpu unknown"}, ${s.memoryBytes === null ? "memory unknown" : `${(s.memoryBytes / 1e9).toFixed(0)} GB`}`];
26 if (s.devices.length === 0) lines.push("No readings yet.");
27 for (const d of s.devices) {
28 if (d.kind === "behind-controller") {
29 lines.push(`${d.device}: behind a controller; not readable here`);
30 continue;
31 }
32 const rate = d.bytesWrittenInWindow === null ? "no rate yet" : `${tb(d.bytesWrittenInWindow)} in ${d.spanDays!.toFixed(1)} d`;
33 const date = d.ratedReachedAt === null ? "no projected date" : `rated TBW ${d.ratedReachedAt}`;
34 lines.push(`${d.device}: ${d.inventory.model} ${d.inventory.firmware} · ${rate} · ${date} · ${d.assessment.level}`);
35 for (const r of d.assessment.reasons) lines.push(` ${r}`);
36 }
37 return lines.map(printable);
38 }
39
40 function readingScreen(session: Session, ctx: Ctx, machine: string): Screen {
41 const scroll = new Scroll();
42 const lines = (): string[] => {
43 const r = readMachine(session.app, session.user, machine);
44 return r.ok ? diskLines(r.value) : [];
45 };
46 return {
47 title: `Disks: ${machine}`,
48 keys: [binding(["j", "k"], "scroll"), binding(["esc", "q"], "back")],
49 render(area, s) {
50 const c = ctx.theme.c;
51 const all = lines();
52 drawLines(s, area, c, scroll, all.map((text, i) => [{ text, style: style(i === 0 ? c.fg : c.mutedFg, c.bg) }]));
53 hitScroll(ctx, area, scroll, all.length);
54 },
55 key(e) {
56 if (scroll.key(e, lines().length)) return true;
57 if (e.key === "esc" || e.key === "q") {
58 ctx.pop();
59 return true;
60 }
61 return false;
62 },
63 };
64 }
65
66 export const entry: PaletteEntry = {
67 id: "disks",
68 title: "Disks",
69 hint: "machines' disk readings: 30-day write rate and projected rated-TBW date",
70 after: ["system"],
71 offered: (session) => mayHandleDisks(session.user),
72 run: (session, ctx) => {
73 const names = readMachines(session.app, session.user);
74 if (!names.ok || names.value.length === 0) return ctx.toast("No disk readings yet");
75 ctx.push(
76 pickerScreen(ctx, blank("Disks"), {
77 title: "Disks: machine",
78 items: names.value.map((m) => ({ id: m, label: m, hint: "" })),
79 onPick: ([m]) => {
80 if (m) ctx.push(readingScreen(session, ctx, m));
81 },
82 }),
83 );
84 },
85 };
src/tui/screens/entries.ts +2
@@ -25,6 +25,7 @@ import { entry as crewHandover } from "./crewhandover.ts";
2525 import { entry as crewSplits } from "./crewsplits.ts";
2626 import { entry as knobs } from "./knobs.ts";
2727 import { entry as displays } from "./displays.ts";
28 import { entry as disks } from "./disks.ts";
2829 import { entry as domains } from "./domains.ts";
2930 import { entry as edgeOnly } from "./edgeonly.ts";
3031 import { entry as traffic } from "./trafficview.ts";
@@ -148,6 +149,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
148149 forgeView,
149150 repos,
150151 displays,
152 disks,
151153 newIssue,
152154 ];
153155
test/disk-intake.test.ts added +139
@@ -0,0 +1,139 @@
1 /** Disk readings reach the owner by API and terminal; nobody else files or reads them (#10465). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createAgent } from "../src/actions/crew.ts";
7 import { createApp, type App } from "../src/app.ts";
8 import { createToken } from "../src/core/tokens.ts";
9 import { createUser, type User } from "../src/core/users.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11 import { createForgeApp, forgeCommands } from "../src/tui/forge.ts";
12 import { diskLines } from "../src/tui/screens/disks.ts";
13 import { diskSummary } from "../src/machines/disk_readings.ts";
14 import { headlessTerminal } from "../src/tui/term/terminal.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16
17 quietLogs();
18
19 const smart = (units: number) => ({
20 smartctl: { version: [7, 4] },
21 device: { name: "/dev/nvme0", type: "nvme", protocol: "NVMe" },
22 model_name: "Samsung SSD 980 PRO 1TB",
23 firmware_version: "5B2QGXA7",
24 user_capacity: { bytes: 1_000_204_886_016 },
25 nvme_smart_health_information_log: {
26 critical_warning: 0, temperature: 40, available_spare: 100, available_spare_threshold: 10,
27 percentage_used: 1, data_units_written: units, media_errors: 0, power_on_hours: 100,
28 unsafe_shutdowns: 2, warning_temp_time: 0, critical_comp_time: 0,
29 },
30 });
31
32 let dir: string;
33 let app: App;
34 let servers: Servers;
35 let admin: string;
36 let ro: string;
37 let member: string;
38 let alice: User;
39 let bob: User;
40 let bot: User;
41 let agentToken: string;
42
43 beforeAll(async () => {
44 dir = await mkdtemp(join(tmpdir(), "disk-intake-"));
45 app = createApp({ dataDir: dir, port: 0 });
46 servers = await serve(app);
47 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
48 if (!made.ok) throw new Error(made.error);
49 alice = made.value;
50 admin = createToken(app.db, alice.id, "a", "admin").token;
51 ro = createToken(app.db, alice.id, "r", "ro").token;
52 const m = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
53 if (!m.ok) throw new Error(m.error);
54 bob = m.value;
55 member = createToken(app.db, bob.id, "m", "admin").token;
56 const agent = await createAgent(app, alice, { username: "diskbot", brief: "x" });
57 if (!agent.ok) throw new Error(String(agent.error));
58 app.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(agent.value.user.id);
59 bot = { ...agent.value.user, isAdmin: true };
60 agentToken = createToken(app.db, bot.id, "bot", "admin").token;
61 }, 60_000);
62
63 afterAll(async () => {
64 servers.stop();
65 app.close();
66 await rm(dir, { recursive: true, force: true });
67 });
68
69 const put = (token: string, machine: string, body: unknown) =>
70 fetch(`${servers.url}/api/v1/admin/machines/${machine}/disks/nvme0`, {
71 method: "PUT",
72 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
73 body: JSON.stringify(body),
74 });
75 const get = (token: string, machine: string) =>
76 fetch(`${servers.url}/api/v1/admin/machines/${machine}/disks`, { headers: { authorization: `Bearer ${token}` } });
77
78 test("the owner's admin token files a reading and reads the summary", async () => {
79 const res = await put(admin, "rack1", { smartctl: smart(1_000), cpu: "AMD EPYC", memoryBytes: 64e9 });
80 expect(res.status).toBe(201);
81 expect(((await res.json()) as { kind: string }).kind).toBe("nvme");
82 // The text form is accepted too.
83 expect((await put(admin, "rack1", { smartctl: JSON.stringify(smart(2_000)) })).status).toBe(201);
84 const read = await get(ro, "rack1");
85 expect(read.status).toBe(200);
86 const s = (await read.json()) as { cpu: string; devices: { device: string; bytesWrittenInWindow: number }[] };
87 expect(s.cpu).toBe("AMD EPYC");
88 expect(s.devices[0]!.device).toBe("nvme0");
89 expect(s.devices[0]!.bytesWrittenInWindow).toBe(1_000 * 512_000);
90 });
91
92 test("a refused reading is a 422 and stores nothing; a read-only token cannot write", async () => {
93 const before = app.db.query("SELECT COUNT(*) AS n FROM disk_reading").get();
94 const bad = await put(admin, "rack2", { smartctl: "not json" });
95 expect(bad.status).toBe(422);
96 expect(((await bad.json()) as { error: { message?: string } | string }).error).toBeDefined();
97 expect((await put(admin, "bad name", { smartctl: smart(1) })).status).toBe(422);
98 expect((await put(admin, "rack2", { smartctl: smart(1), cpu: "", memoryBytes: 1 })).status).toBe(422);
99 expect((await put(ro, "rack2", { smartctl: smart(1) })).status).toBe(403);
100 expect(app.db.query("SELECT COUNT(*) AS n FROM disk_reading").get()).toEqual(before);
101 });
102
103 test("a member, an agent and no one at all file and read nothing", async () => {
104 // A member is turned away by the admin gate (403, not_admin) before the handler; an agent admin passes it and the handler hides the route.
105 expect((await put(member, "rack3", { smartctl: smart(1) })).status).toBe(403);
106 expect((await get(member, "rack1")).status).toBe(403);
107 expect((await put(agentToken, "rack3", { smartctl: smart(1) })).status).toBe(404);
108 expect((await get(agentToken, "rack1")).status).toBe(404);
109 expect((await fetch(`${servers.url}/api/v1/admin/machines/rack1/disks`)).status).toBe(401);
110 expect((await fetch(`${servers.url}/api/v1/admin/machines/rack3/disks/nvme0`, { method: "PUT", body: "{}" })).status).toBe(401);
111 expect(diskSummary(app.db, "rack3").devices).toEqual([]);
112 });
113
114 const identity = (user?: User) => ({ sshUsername: user?.username ?? "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user });
115 const doors = (user?: User): string[] => {
116 const { app: tui, session } = createForgeApp(app, identity(user), { terminal: headlessTerminal(80, 24), exitHooks: false });
117 const ids = forgeCommands(session)(tui.ctx).map((c) => c.id);
118 tui.stop?.();
119 return ids;
120 };
121
122 test("the Disks screen is the owner's door, and says what the API stored", () => {
123 expect(doors(alice)).toContain("disks");
124 for (const who of [bob, bot, undefined]) expect(doors(who)).not.toContain("disks");
125 const lines = diskLines(diskSummary(app.db, "rack1"));
126 expect(lines[0]).toBe("rack1: AMD EPYC, 64 GB");
127 expect(lines[1]).toContain("nvme0: Samsung SSD 980 PRO 1TB");
128 expect(lines[1]).toContain("0.00 TB");
129 });
130
131 test("a cpu with a control character is refused; model and firmware reach the terminal without theirs", async () => {
132 expect((await put(admin, "rack4", { smartctl: smart(1), cpu: "EPYC\u001b]0;pwned\u0007", memoryBytes: 1 })).status).toBe(422);
133 expect((await put(admin, "rack4", { smartctl: smart(1), cpu: "EPYC\u009b2J", memoryBytes: 1 })).status).toBe(422);
134 const evil = { ...smart(1), model_name: "Samsung\u001b[2J SSD", firmware_version: "5B2\u001b]52;c;eA==\u0007QGXA7" };
135 expect((await put(admin, "rack4", { smartctl: evil })).status).toBe(201);
136 const lines = diskLines(diskSummary(app.db, "rack4"));
137 expect(lines.join("\n")).not.toMatch(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/);
138 expect(lines[1]).toContain("Samsung[2J SSD 5B2]52;c;eA==QGXA7");
139 });
test/disk-readings.test.ts added +167
@@ -0,0 +1,167 @@
1 /** Disk readings per machine (#10465): rate, projected date, refusals, alerts. Hand calculation in comments. */
2 import { expect, test } from "bun:test";
3 import { migrations } from "../src/core/schema.ts";
4 import { openDb } from "../src/kernel/db.ts";
5 import { diskSummary, MAX_DEVICES, MAX_MACHINES, recordReading } from "../src/machines/disk_readings.ts";
6
7 const DAY = 86_400_000;
8 const T0 = Date.parse("2026-10-01T00:00:00Z");
9
10 function smart(units: number, mediaErrors = 0, model = "Samsung SSD 980 PRO 1TB") {
11 return JSON.stringify({
12 smartctl: { version: [7, 4] },
13 device: { name: "/dev/nvme0", type: "nvme", protocol: "NVMe" },
14 model_name: model,
15 firmware_version: "5B2QGXA7",
16 user_capacity: { bytes: 1_000_204_886_016 },
17 nvme_smart_health_information_log: {
18 critical_warning: 0, temperature: 40, available_spare: 100, available_spare_threshold: 10,
19 percentage_used: 1, data_units_written: units, media_errors: mediaErrors, power_on_hours: 100,
20 unsafe_shutdowns: 2, warning_temp_time: 0, critical_comp_time: 0,
21 },
22 });
23 }
24 const db = () => openDb(":memory:", migrations);
25
26 test("two readings 30 days apart give the rate and the date", () => {
27 const d = db();
28 // 1e9 data units × 512 000 B = 5.12e14 B; 2e9 units = 1.024e15 B. Δ = 5.12e14 B over 30 d.
29 recordReading(d, "m1", "nvme0", smart(1_000_000_000), T0, { cpu: "AMD EPYC", memoryBytes: 64e9 });
30 recordReading(d, "m1", "nvme0", smart(2_000_000_000), T0 + 30 * DAY);
31 const s = diskSummary(d, "m1");
32 expect(s.cpu).toBe("AMD EPYC");
33 expect(s.memoryBytes).toBe(64e9);
34 const dev = s.devices[0]!;
35 if (dev.kind !== "nvme") throw new Error("kind");
36 expect(dev.bytesWrittenInWindow).toBe(512_000_000_000_000);
37 expect(dev.inventory.model).toBe("Samsung SSD 980 PRO 1TB");
38 // Remaining: 600e12 − 1.024e15 < 0 → already past rated: date is the latest reading, 2026-10-31.
39 expect(dev.ratedReachedAt).toBe("2026-10-31");
40 });
41
42 test("projects forward: remaining ÷ rate", () => {
43 const d = db();
44 // 100e6 units = 5.12e13 B; 200e6 units = 1.024e14 B; Δ = 5.12e13 over 30 d.
45 // Remaining = 6e14 − 1.024e14 = 4.976e14; ÷ (5.12e13/30 per day) = 291.5625 d after 2026-10-31.
46 recordReading(d, "m1", "nvme0", smart(100_000_000), T0);
47 recordReading(d, "m1", "nvme0", smart(200_000_000), T0 + 30 * DAY);
48 const dev = diskSummary(d, "m1").devices[0]!;
49 const expected = new Date(T0 + 30 * DAY + 291.5625 * DAY).toISOString().slice(0, 10);
50 expect(dev.ratedReachedAt).toBe(expected);
51 });
52
53 test("one reading gives no projection; unknown model gives no date", () => {
54 const d = db();
55 recordReading(d, "m1", "nvme0", smart(5), T0);
56 const one = diskSummary(d, "m1").devices[0]!;
57 expect(one.bytesWrittenInWindow).toBeNull();
58 expect(one.ratedReachedAt).toBeNull();
59 recordReading(d, "m2", "nvme0", smart(5, 0, "Mystery"), T0);
60 recordReading(d, "m2", "nvme0", smart(9, 0, "Mystery"), T0 + DAY);
61 const two = diskSummary(d, "m2").devices[0]!;
62 if (two.kind !== "nvme") throw new Error("kind");
63 expect(two.spec).toBeNull();
64 expect(two.bytesWrittenInWindow).toBe(4 * 512_000);
65 expect(two.ratedReachedAt).toBeNull();
66 });
67
68 test("a refused reading stores nothing and names why", () => {
69 const d = db();
70 expect(recordReading(d, "m1", "nvme0", "nope", T0)).toEqual({ ok: false, reason: "not-json" });
71 expect(recordReading(d, "m1", "nvme0", '{"a":1}', T0)).toEqual({ ok: false, reason: "not-smartctl-output" });
72 expect(recordReading(d, "bad name", "nvme0", smart(1), T0)).toEqual({ ok: false, reason: "bad-machine" });
73 expect(recordReading(d, "m1", "nvme0", smart(1), T0, { cpu: "", memoryBytes: 1 })).toEqual({ ok: false, reason: "bad-inventory" });
74 expect(d.query("SELECT COUNT(*) AS n FROM disk_reading").get()).toEqual({ n: 0 });
75 expect(d.query("SELECT COUNT(*) AS n FROM machine_inventory").get()).toEqual({ n: 0 });
76 expect(diskSummary(d, "m1").devices).toEqual([]);
77 });
78
79 test("rising media errors between two stored readings alert", () => {
80 const d = db();
81 recordReading(d, "m1", "nvme0", smart(10, 0), T0);
82 const first = diskSummary(d, "m1").devices[0]!;
83 if (first.kind !== "nvme") throw new Error("kind");
84 expect(first.assessment.level).toBe("ok");
85 recordReading(d, "m1", "nvme0", smart(11, 3), T0 + DAY);
86 const dev = diskSummary(d, "m1").devices[0]!;
87 if (dev.kind !== "nvme") throw new Error("kind");
88 expect(dev.assessment.level).toBe("alert");
89 expect(dev.assessment.reasons[0]).toContain("media errors rose from 0 to 3");
90 });
91
92 test("a counter that goes backwards gives no rate and no date", () => {
93 const d = db();
94 recordReading(d, "m1", "nvme0", smart(200_000_000), T0);
95 recordReading(d, "m1", "nvme0", smart(100_000_000), T0 + DAY); // a replaced or reset drive
96 const dev = diskSummary(d, "m1").devices[0]!;
97 if (dev.kind !== "nvme") throw new Error("kind");
98 expect(dev.bytesWrittenInWindow).toBeNull();
99 expect(dev.spanDays).toBeNull();
100 expect(dev.ratedReachedAt).toBeNull();
101 });
102
103 test("readings older than 30 days are left out of the base", () => {
104 const d = db();
105 // Latest at T0+60 d. The reading at T0 is 60 d back: outside the window.
106 // Base is the oldest inside it, T0+40 d (20 d back): Δ = 3e7 − 1e7 units = 2e7 × 512 000 B = 1.024e13 B over 20 d.
107 recordReading(d, "m1", "nvme0", smart(1_000), T0);
108 recordReading(d, "m1", "nvme0", smart(10_000_000), T0 + 40 * DAY);
109 recordReading(d, "m1", "nvme0", smart(30_000_000), T0 + 60 * DAY);
110 const dev = diskSummary(d, "m1").devices[0]!;
111 if (dev.kind !== "nvme") throw new Error("kind");
112 expect(dev.bytesWrittenInWindow).toBe(20_000_000 * 512_000);
113 expect(dev.spanDays).toBe(20);
114 // Only the old reading and the latest: nothing inside the window to compare with.
115 const e = db();
116 recordReading(e, "m1", "nvme0", smart(1_000), T0);
117 recordReading(e, "m1", "nvme0", smart(30_000_000), T0 + 60 * DAY);
118 const lone = diskSummary(e, "m1").devices[0]!;
119 if (lone.kind !== "nvme") throw new Error("kind");
120 expect(lone.bytesWrittenInWindow).toBeNull();
121 });
122
123 test("a device behind a controller comes back as such, with no projection", () => {
124 const d = db();
125 const raid = JSON.stringify({ smartctl: {}, device: { name: "/dev/sda", type: "megaraid,0", protocol: "SCSI" } });
126 expect(recordReading(d, "m1", "sda", raid, T0)).toMatchObject({ ok: true, kind: "behind-controller" });
127 const dev = diskSummary(d, "m1").devices[0]!;
128 expect(dev.kind).toBe("behind-controller");
129 expect(dev.ratedReachedAt).toBeNull();
130 expect(dev.reading).toBeNull();
131 });
132
133 const count = (d: ReturnType<typeof db>, device = "nvme0"): number =>
134 (d.query("SELECT COUNT(*) AS n FROM disk_reading WHERE device = ?").get(device) as { n: number }).n;
135
136 test("a write prunes the device's readings older than 60 days, keeping its newest two", () => {
137 const d = db();
138 // A reading a day for 100 days: the last write at T0+99 d keeps T0+39 d … T0+99 d (≥ T0+39 d), 61 rows.
139 for (let i = 0; i < 100; i++) recordReading(d, "m1", "nvme0", smart(1_000 + i * 1_000), T0 + i * DAY);
140 expect(count(d)).toBe(61);
141 // The summary is unchanged by the prune: base T0+69 d, Δ = 30 × 1 000 units × 512 000 B over 30 d.
142 const dev = diskSummary(d, "m1").devices[0]!;
143 if (dev.kind !== "nvme") throw new Error("kind");
144 expect(dev.bytesWrittenInWindow).toBe(30 * 1_000 * 512_000);
145 expect(dev.spanDays).toBe(30);
146 // A device read twice a year still keeps its newest two, so the alert has a previous reading.
147 const e = db();
148 recordReading(e, "m1", "nvme0", smart(1), T0);
149 recordReading(e, "m1", "nvme0", smart(2), T0 + 200 * DAY);
150 recordReading(e, "m1", "nvme0", smart(3), T0 + 400 * DAY);
151 expect(count(e)).toBe(2);
152 // Another device's old readings are not this write's to prune.
153 recordReading(e, "m1", "nvme1", smart(1), T0);
154 recordReading(e, "m1", "nvme0", smart(4), T0 + 600 * DAY);
155 expect(count(e, "nvme1")).toBe(1);
156 });
157
158 test("a new machine past the cap, or a new device past the cap on one machine, is refused", () => {
159 const d = db();
160 for (let i = 0; i < MAX_MACHINES; i++) expect(recordReading(d, `m${i}`, "nvme0", smart(1), T0).ok).toBe(true);
161 expect(recordReading(d, "one-more", "nvme0", smart(1), T0)).toEqual({ ok: false, reason: "too-many-machines" });
162 for (let i = 1; i < MAX_DEVICES; i++) expect(recordReading(d, "m0", `nvme${i}`, smart(1), T0).ok).toBe(true);
163 expect(recordReading(d, "m0", "one-more", smart(1), T0)).toEqual({ ok: false, reason: "too-many-devices" });
164 // A known machine and device still file.
165 expect(recordReading(d, "m0", "nvme0", smart(2), T0 + DAY).ok).toBe(true);
166 expect((d.query("SELECT COUNT(*) AS n FROM disk_reading WHERE device = 'one-more'").get() as { n: number }).n).toBe(0);
167 });
test/pages-budget.test.ts +1
@@ -53,6 +53,7 @@ const FIXTURE: Record<string, string> = {
5353 "/:owner/:name/issues/:number": "/alice/forge/issues/1",
5454 "/:owner/:name/pulls/:number": "/alice/forge/pulls/2",
5555 "/:owner/:name/display": "/alice/forge/display",
56 "/api/v1/admin/machines/:machine/disks": "/api/v1/admin/machines/rack1/disks",
5657 };
5758
5859 function registeredGets(): Set<string> {
test/palette-roster.test.ts +1
@@ -106,6 +106,7 @@ test("a site-admin person is offered the whole roster", () => {
106106 ["system", "System", "the machine now: CPU, memory, disks, processes"],
107107 ["valves", "Valves", "the week's budget as a valve per repository; turn one and the crew obeys"],
108108 ["stalls", "Main-thread stalls", "the last hour's stall ms by route"],
109 ["disks", "Disks", "machines' disk readings: 30-day write rate and projected rated-TBW date"],
109110 ["telemetry", "Telemetry", "which agents may read flow and system; enter flips the grant"],
110111 ["capacity", "Capacity", "concurrency and guards, live; e changes a settable one"],
111112 ["resilience", "Resilience", "where snapshots go and the mirror; each protection green, red or untested"],
test/route-timing-coverage.test.ts +1
@@ -20,6 +20,7 @@ import { seedAdmin } from "./helpers/admin.ts";
2020 /** Routes that answer 200 without a `session` phase → why. */
2121 const UNTIMED: Record<string, string> = {
2222 "/api/v1/conversations": "a person's conversation list, one indexed query; no page to phase",
23 "/api/v1/admin/machines/:machine/disks": "an owner's per-machine disk summary, one indexed read; no page to phase",
2324 "/metrics": "Prometheus text exposition, scraped by token; not a page read",
2425 };
2526
test/tui-asks-first.test.ts +1
@@ -51,6 +51,7 @@ const allowed: readonly { file: string; action: string; reason: string }[] = [
5151 { file: "src/tui/screens/deploys.ts", action: "stopEnv", reason: "in `act`, called from `go` after the Stop confirm" },
5252 { file: "src/tui/screens/boards.ts", action: "deleteBoard", reason: "in `remove`, called only on the y of the delete y/n" },
5353 { file: "src/tui/screens/about.ts", action: "detachFromParent", reason: "the first call is the dry run; the real one is in onChoose" },
54 { file: "src/tui/screens/devices.ts", action: "revoke", reason: "passed to `askApp`, run only from the slug prompt's onSubmit" },
5455 ];
5556
5657 /** How many lines above a call a `y` key check still guards it. */

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs