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

The machines page shows one quiet line per disk, with the rated figures on open #10707

Merged opened this

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

Merged

torque/forge:torque/10466-the-machines-page-shows-one-quiet-line-p → main 5 commits fork 5 threads · 5 unresolved

  • Merged. torque/10466-the-machines-page-shows-one-quiet-line-p was merged into main as 4946ee4.

Review

Sign in to review this change.
10 files changed +413 −10
docs/design/areas/machines.md +9
@@ -22,3 +22,12 @@ rows: Machines and Last sessions are each a `<section class="settings-block">` w
2222 page is); each session a `row()` in `#machine-sessions`, a running one marked by a ● glyph and a "running" badge, an ended
2323 one by a dash and its reason in words, so state is never colour alone. With no session, one plain sentence says so.
2424 `test/machine-shell-page.test.ts` holds the shape.
25
26 Disks (#10466), the owner's alone: each machine's disks hang under its own row as `row()` `children` (a
27 `ul.row-children`), so no heading of their own. A disk is a folded `row()`: the caret as its lead, device and
28 model as the title, written of rated TBW and the temperature as the meta — two lines that each wrap in a
29 `.setting-list`, so 360px never scrolls sideways. Open, a `dl.row-facts` gives every figure beside its rating. An
30 alert adds a "needs a look" badge as the trail and its reasons, in words, on a muted line in the row's `after`
31 slot — outside the `<details>`, so they read closed. A disk behind a controller is a plain row saying so; a machine
32 with no reading is one muted row. No new class: one scoped rule, `.row-children > li[data-disk] > .row-after`,
33 insets that reasons line to the title (`src/web/style/components.ts`). `test/machines-disks.test.ts` holds it.
docs/features/machines-disks.md added +26
@@ -0,0 +1,26 @@
1 # Disks on the machines page (#10466)
2
3 Settings → Machines lists, under each declared machine's own row, its disks
4 from the readings the forge keeps (`docs/features/disk-readings.md`). One quiet
5 row per disk: device and model, then written of rated TBW with its percentage
6 and the temperature. Open
7 it for every figure beside its rating: percentage used (alert at the assessment's
8 threshold), spare against its threshold, media errors, minutes above warning and
9 critical, power-on hours, unsafe shutdowns, projected TBW date.
10
11 - An alert adds a "needs a look" badge and its reasons in words on a muted line
12 under the row, readable without opening it; nothing is red when nothing is
13 wrong.
14 - A disk behind a controller reads "behind a controller; SMART not visible".
15 - A model not in the catalog shows its numbers and "no spec sheet" for ratings.
16 - A machine with no reading says "No disk reading yet."
17
18 Who: readings belong to the host and are keyed by machine name alone, so the
19 section is shown to the owner (a forge admin who is a person) and to no other
20 writer; a name two repositories share never carries disks from one to the other.
21 The page itself is writers' only. This is narrower than the issue's "writers"
22 on purpose: a reading is keyed by host, not by repository.
23
24 The terminal: `:` → "Machines of a repository" → pick the repository; the owner
25 sees each machine's disk lines under the sessions. `:` → "Disks" lists every
26 machine with a reading.
src/spawn/preview-looks.ts +11
@@ -139,6 +139,15 @@ export const PREVIEW_SETTINGS_PAGES: readonly string[] = [
139139 "/settings/usage",
140140 ];
141141
142 /**
143 * The sample's Settings › Machines with the disks the seed records for its
144 * `standard` machine (#10466): healthy, alerting and behind a controller,
145 * closed; and the alerting line opened by its figures' fragment.
146 */
147 export const PREVIEW_MACHINES_PAGE = `${PREVIEW_REPO_HOME}/settings/machines`;
148 export const PREVIEW_DISK_ALERT = "nvme1";
149 export const PREVIEW_DISK_OPEN_LOOK = `${PREVIEW_MACHINES_PAGE}#disk-standard-${PREVIEW_DISK_ALERT}-figures`;
150
142151 export interface OwnerLook {
143152 readonly page: string;
144153 readonly kind: LookKind;
@@ -168,6 +177,8 @@ export const PREVIEW_OWNER_LOOKS: readonly OwnerLook[] = [
168177 PREVIEW_ISSUE_EDIT_PAGE,
169178 PREVIEW_RESILIENCE_PAGE,
170179 PREVIEW_SEARCH_PAGE,
180 PREVIEW_MACHINES_PAGE,
181 PREVIEW_DISK_OPEN_LOOK,
171182 ...PREVIEW_SETTINGS_PAGES,
172183 ].flatMap((page) => (["desktop", "phone"] as const).map((kind) => ({ page, kind })));
173184
src/spawn/preview-seed.ts +57
@@ -47,16 +47,19 @@ import { createToken, listTokens } from "../core/tokens.ts";
4747 import { recordUsage } from "../core/usage.ts";
4848 import { getUserSetting, setUserSetting } from "../core/settings.ts";
4949 import { createUser, getUserByName, type User } from "../core/users.ts";
50 import { diskSummary, recordReading } from "../machines/disk_readings.ts";
5051 import { err, ok, type Result } from "../kernel/result.ts";
5152 import { PREVIEW_CONFIG_FILES } from "./preview-config.ts";
5253 import {
5354 PREVIEW_ADMIN,
5455 PREVIEW_APP,
5556 PREVIEW_APP_PAGE,
57 PREVIEW_DISK_ALERT,
5658 PREVIEW_FORKS,
5759 PREVIEW_HELD_PAGE,
5860 PREVIEW_HELD_THEMES,
5961 PREVIEW_ITERATIONS_PAGE,
62 PREVIEW_MACHINES_PAGE,
6063 PREVIEW_PULL,
6164 PREVIEW_PULL_PAGE,
6265 PREVIEW_MOVED,
@@ -140,6 +143,56 @@ async function seedMoveSection(app: App, admin: User): Promise<Result<void, stri
140143 }
141144 }
142145
146 /** One NVMe reading in smartctl's JSON shape (`smartctl -j -a`), as a host files it. */
147 function nvmeReading(o: { name: string; units: number; spare: number; used: number }): string {
148 return JSON.stringify({
149 smartctl: { version: [7, 4] },
150 device: { name: o.name, type: "nvme", protocol: "NVMe" },
151 model_name: "Samsung SSD 980 PRO 1TB",
152 firmware_version: "5B2QGXA7",
153 user_capacity: { bytes: 1_000_204_886_016 },
154 nvme_smart_health_information_log: {
155 critical_warning: 0, temperature: 41, available_spare: o.spare, available_spare_threshold: 10,
156 percentage_used: o.used, data_units_written: o.units, media_errors: 0, power_on_hours: 1234,
157 unsafe_shutdowns: 7, warning_temp_time: 3, critical_comp_time: 0,
158 },
159 });
160 }
161
162 /**
163 * Disk readings for the sample's `standard` machine (#10466), once: a healthy
164 * disk, one that needs a look (spare under its threshold, wear at 85%), and
165 * one behind a controller. Fake numbers in a preview only, so the owner's
166 * look of Settings › Machines has every kind of line. Refused unless the page
167 * answers 200 and draws the alerting line.
168 */
169 async function seedDisks(app: App, admin: User): Promise<Result<void, string>> {
170 if (diskSummary(app.db, "standard").devices.length === 0) {
171 const now = Date.now();
172 // 1e8 units × 512 000 B = 51.2 TB of the 980 PRO's 600 TBW.
173 const filed = [
174 recordReading(app.db, "standard", "nvme0", nvmeReading({ name: "/dev/nvme0", units: 100_000_000, spare: 100, used: 4 }), now),
175 recordReading(app.db, "standard", PREVIEW_DISK_ALERT, nvmeReading({ name: "/dev/nvme1", units: 1_000_000_000, spare: 5, used: 85 }), now),
176 recordReading(app.db, "standard", "sda", JSON.stringify({ smartctl: { version: [7, 4] }, device: { name: "/dev/sda", type: "megaraid,0" } }), now),
177 ];
178 const refused = filed.find((r) => !r.ok);
179 if (refused !== undefined && !refused.ok) return err(`the sample disk readings: ${refused.reason}`);
180 }
181 const http = forgeHttp(app);
182 const session = createSession(app.db, admin.id);
183 try {
184 const res = await http.handle(
185 new Request(`http://localhost${PREVIEW_MACHINES_PAGE}`, { headers: { cookie: `forge_session=${session.token}` } }),
186 );
187 if (res.status !== 200) return err(`${PREVIEW_MACHINES_PAGE} answers ${String(res.status)}, not 200`);
188 if (!(await res.text()).includes(`data-disk="${PREVIEW_DISK_ALERT}"`)) return err(`${PREVIEW_MACHINES_PAGE} draws no disk line`);
189 } finally {
190 destroySession(app.db, session.token);
191 http.close();
192 }
193 return ok();
194 }
195
143196 /** The seeded wave's version and headline, which the owner's /waves look shows. */
144197 export const PREVIEW_WAVE = "0.1.0";
145198 export const PREVIEW_WAVE_HEADLINE = "The sample says hello";
@@ -200,6 +253,8 @@ export async function seedPreview(
200253 if (!settings.ok) return settings;
201254 const moveSection = await seedMoveSection(app, admin);
202255 if (!moveSection.ok) return moveSection;
256 const disks = await seedDisks(app, admin);
257 if (!disks.ok) return disks;
203258 const files = await seedConfigFiles(app, admin);
204259 return files.ok ? ok({ seeded: false }) : files;
205260 }
@@ -210,6 +265,8 @@ export async function seedPreview(
210265 if (!settings.ok) return settings;
211266 const moveSection = await seedMoveSection(app, admin);
212267 if (!moveSection.ok) return moveSection;
268 const disks = await seedDisks(app, admin);
269 if (!disks.ok) return disks;
213270 const files = await seedConfigFiles(app, admin);
214271 return files.ok ? ok({ seeded: true }) : files;
215272 }
src/tui/screens/machines.ts +19 −2
@@ -6,6 +6,7 @@
66 * draw, so a writer who loses it sees nothing on the next frame.
77 */
88
9 import { readMachine } from "../../actions/disk_readings.ts";
910 import { canWrite, type Repo } from "../../core/repos.ts";
1011 import { repoMachines } from "../../crew/machines_read.ts";
1112 import type { Machine } from "../../crew/machines.ts";
@@ -16,6 +17,7 @@ import { style } from "../term/style.ts";
1617 import { truncate } from "../term/width.ts";
1718 import type { Session } from "../session.ts";
1819 import { fill, len, split } from "../ui/layout.ts";
20 import { diskLines } from "./disks.ts";
1921 import { ListState, drawList, type ListRow } from "./common.ts";
2022
2123 /** One line per session, as the page's table reads. */
@@ -38,28 +40,43 @@ export function shellStatesLine(states: ReadonlyMap<string, MachineState>): stri
3840 return parts.length === 0 ? undefined : `Your shell — ${parts.join("; ")}`;
3941 }
4042
43 /** The disk lines of each declared machine, the owner's alone (`mayHandleDisks`): nothing for any other writer. */
44 export function repoDiskLines(f: Session, list: readonly Machine[]): string[] {
45 return list.flatMap((m) => {
46 const r = readMachine(f.app, f.user, m.name);
47 return r.ok ? diskLines(r.value) : [];
48 });
49 }
50
4151 export function machinesScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
4252 const state = new ListState();
4353 let machines: readonly Machine[] = [];
54 // Read once in `load`, not per frame: a reading is a database read per machine.
55 let disks: string[] = [];
4456 const may = (): boolean => f.user !== undefined && canWrite(f.db, repo, f.user);
4557 const rows = (): SessionRecord[] => (may() ? lastSessions(f.db, repo.id) : []);
4658 return {
4759 title: "Machines",
4860 keys: [binding(["j", "k"], "move cursor"), binding(["esc"], "back")],
4961 async load() {
50 if (may()) machines = (await repoMachines(f.app, repo)).machines;
62 if (may()) {
63 machines = (await repoMachines(f.app, repo)).machines;
64 disks = repoDiskLines(f, machines);
65 }
5166 ctx.invalidate();
5267 },
5368 render(area, s) {
5469 const c = ctx.theme.c;
55 const [head, list] = split(area, "v", [len(4), fill(1)]);
5670 const listed = rows();
71 const shown = may() ? disks : [];
72 const [head, list] = split(area, "v", [len(4 + shown.length), fill(1)]);
5773 const lines = !may()
5874 ? ["A repository's machines and shell sessions are its writers'."]
5975 : [
6076 machinesLine(machines),
6177 listed.length === 0 ? "No shell has been opened here yet." : "Last sessions, newest first.",
6278 ...(shellStatesLine(machineStates(f.app, repo.id, f.user!.id)) ?? []),
79 ...shown,
6380 ];
6481 lines.forEach((line, i) =>
6582 s.text(head!.x, head!.y + i, truncate(line, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w),
src/web/pages/machines.ts +87 −8
@@ -32,6 +32,9 @@ import {
3232 import { page } from "../layout.ts";
3333 import { html } from "../html.ts";
3434 import { badge, btn, icon, row, time } from "../ui.ts";
35 import { readMachine } from "../../actions/disk_readings.ts";
36 import { WEAR_ALERT_PERCENT } from "../../machines/disk_health.ts";
37 import type { DeviceSummary } from "../../machines/disk_readings.ts";
3538 import { ghosttyWasmAsset, machineTermAsset } from "../machine-term-asset.ts";
3639 import { settingsPageResponse } from "./settings.ts";
3740
@@ -59,7 +62,76 @@ export async function machinesSettingsPage(ctx: Ctx): Promise<Response> {
5962 const config = await timePhaseAsync(ctx.request, "machines", () => repoMachines(ctx.app, repo));
6063 const sessions = timePhase(ctx.request, "sessions", () => lastSessions(ctx.app.db, repo.id));
outdated

High: readings are keyed by machine name alone, so this lists disks recorded for a same-named machine of another repository. That is data leaking across repositories. Scope the lookup to this repository and test it with two repositories that share a machine name.

6164 const states = machineStates(ctx.app, repo.id, ctx.auth.user!.id);
62 return timePhase(ctx.request, "render", () => render(ctx, repo, config.machines, sessions, states));
65 // Readings are the host's, keyed by machine name alone, so they are shown
66 // to the owner (`mayHandleDisks`) and to no other writer: a name two
67 // repositories share must not carry one repository's disks to the other.
68 const disks = timePhase(ctx.request, "disks", () => {
69 const seen = new Map<string, readonly DeviceSummary[]>();
70 for (const m of config.machines) {
71 const r = readMachine(ctx.app, ctx.auth.user, m.name);
72 if (r.ok) seen.set(m.name, r.value.devices);
73 }
74 return seen;
75 });
outdated

Both doors: the terminal has no disks screen. Add one in src/tui/ with its entry where AGENTS.md says, gated like this page, and write docs/features/<name>.md for the how-to.

76 return timePhase(ctx.request, "render", () => render(ctx, repo, config.machines, sessions, states, disks));
77 }
78
79 /** Terabytes as vendors state them (1 TB = 10^12 bytes), to two places. */
80 const tb = (bytes: number): string => (bytes / 1e12).toFixed(2);
81
82 /**
83 * One disk, a `row()` under its machine's row: device and model as the title,
84 * written of rated TBW with its percentage and the temperature as the meta
85 * (each wraps on its own in a `.setting-list`, so 360px never scrolls
86 * sideways). The row folds open to every figure beside its rating. An alert
87 * adds a badge, and its reasons in words on a muted line in the row's `after`
outdated

Principle 1 and the single-source rule: '(alert at 80%)' repeats a threshold that assessDisk owns. Export the constant and use it here, or the page will disagree with the reasons when the threshold changes.

88 * slot, outside the `<details>`, so they read with the line closed; nothing
89 * else changes colour.
90 */
91 export function diskRow(machine: string, d: DeviceSummary) {
92 if (d.kind === "behind-controller")
93 return row({ title: d.device, meta: html`behind a controller; SMART not visible`, data: { disk: d.device } });
94 const r = d.reading;
95 const spec = d.spec;
96 const written = spec
97 ? `${tb(r.bytesWritten)} of ${spec.ratedTbw} TBW (${((r.bytesWritten / (spec.ratedTbw * 1e12)) * 100).toFixed(1)}%)`
98 : `${tb(r.bytesWritten)} TB written · no spec sheet`;
99 const alert = d.assessment.level === "alert";
100 const rated = (v: string, r2: string | undefined) => (r2 === undefined ? html`${v} <span class="muted">(no spec sheet)</span>` : html`${v} <span class="muted">(rated ${r2})</span>`);
101 const figures: [string, ReturnType<typeof html>][] = [
102 ["Percentage used", html`${r.percentageUsed}% <span class="muted">(alert at ${WEAR_ALERT_PERCENT}%)</span>`],
103 ["Available spare", html`${r.availableSpare}% <span class="muted">(threshold ${r.spareThreshold}%)</span>`],
104 ["Media errors", html`${r.mediaErrors}`],
105 ["Minutes above warning", rated(`${r.warnTempMinutes}`, spec ? `warning ${spec.warnTempC} °C` : undefined)],
outdated

Principle 1: 'No machines declared.' says again what the Machines block above already says. Render nothing for the Disks section when there are no machines.

106 ["Minutes above critical", rated(`${r.critTempMinutes}`, spec ? `critical ${spec.critTempC} °C` : undefined)],
107 ["Power-on hours", html`${r.powerOnHours}`],
108 ["Unsafe shutdowns", html`${r.unsafeShutdowns}`],
109 ["Written", rated(`${tb(r.bytesWritten)} TB`, spec ? `${spec.ratedTbw} TBW, ${spec.warrantyYears}-year warranty` : undefined)],
110 ["Projected TBW date", html`${d.ratedReachedAt ?? (spec ? "no projection yet" : "no spec sheet")}`],
111 ];
112 return row({
113 lead: icon("chevronRight"),
114 title: `${d.device} · ${r.model}`,
115 meta: html`${written} · ${r.temperatureC} °C`,
116 ...(alert ? { trail: badge("needs a look", { tone: "attn" }) } : {}),
117 // The body's id is a link to the line open: a fragment that names
118 // something inside a closed <details> opens it (HTML's ancestor-details
119 // revealing), so `#disk-<machine>-<device>-figures` shares it opened.
120 fold: {
121 id: `disk-${machine}-${d.device}`,
122 open: false,
123 body: html`<div class="card-body" id="disk-${machine}-${d.device}-figures"><dl class="row-facts" data-disk-figures>${figures.map(([k, v]) => html`<div><dt>${k}</dt><dd>${v}</dd></div>`)}</dl></div>`,
124 },
125 ...(alert ? { after: html`<p class="muted small" data-disk-reasons>${d.assessment.reasons.join("; ")}</p>` } : {}),
126 data: { disk: d.device },
127 });
128 }
129
130 /** A machine's disk rows, or one line saying there is no reading yet; nothing when its disks are not this person's to see. */
131 function diskChildren(name: string, devices: readonly DeviceSummary[] | undefined) {
132 if (devices === undefined) return undefined;
133 if (devices.length === 0) return [row({ title: html`<span class="muted">No disk reading yet.</span>`, data: { "disks-empty": name } })];
134 return devices.map((d) => diskRow(name, d));
63135 }
outdated

Dead default: render has one caller and always passes disks. Remove = new Map().

64136
65137 /** The state line as muted text in a row's meta, which wraps; nothing when no shell is live. */
@@ -70,25 +142,32 @@ function stateMeta(state: MachineState | undefined) {
70142 }
71143
72144 /** One data-nav link per machine, to its shell; none when there are no machines. */
73 export function machineRows(repo: Repo, list: readonly Machine[], states: ReadonlyMap<string, MachineState> = new Map()) {
74 return html`${list.map((m) =>
75 row({
145 export function machineRows(
146 repo: Repo,
147 list: readonly Machine[],
148 states: ReadonlyMap<string, MachineState> = new Map(),
149 disks: ReadonlyMap<string, readonly DeviceSummary[]> = new Map(),
150 ) {
151 return html`${list.map((m) => {
152 const children = diskChildren(m.name, disks.get(m.name));
153 return row({
76154 lead: icon("terminal"),
77155 title: m.name,
78156 meta: html`Tools: ${m.tools.length === 0 ? "none" : m.tools.join(", ")} · Reach: ${m.reach.length === 0 ? "the forge only" : "the forge and its apps"}${stateMeta(states.get(m.name))}`,
79157 href: shellPath(repo, m.name),
80158 nav: true,
81159 data: { machine: m.name },
82 }),
83 )}`;
160 ...(children === undefined ? {} : { children }),
161 });
162 })}`;
84163 }
85164
86 function render(ctx: Ctx, repo: Repo, list: readonly Machine[], sessions: readonly SessionRecord[], states: ReadonlyMap<string, MachineState>): Response {
165 function render(ctx: Ctx, repo: Repo, list: readonly Machine[], sessions: readonly SessionRecord[], states: ReadonlyMap<string, MachineState>, disks: ReadonlyMap<string, readonly DeviceSummary[]>): Response {
87166 const machines = html`<section class="settings-block">
88167 <h2>Machines</h2>
89168 <p class="muted">A machine is where the crew shell runs; each writer gets a directory and home of their own on it.</p>
90169 <ul class="list setting-list" id="machines">
91 ${machineRows(repo, list, states)}
170 ${machineRows(repo, list, states, disks)}
92171 </ul>
93172 </section>`;
94173 // The mark is not colour alone: a running shell carries the word in a badge,
src/web/style/components.ts +3
@@ -510,6 +510,9 @@ a.row:hover .row-title { color: var(--accent); }
510510 /* An origin's error, notes and acts (Settings › Origins): a block of their own under the row, at every width. */
511511 .setting-list[data-origins] > li.has-after > .row-after, .setting-list[data-bets] > li.has-after > .row-after { flex-basis: 100%; display: block; min-width: 0; padding: 0 14px 12px; overflow-wrap: anywhere; }
512512 .list > li.has-after > .row-children { flex-basis: 100%; }
513 /* A disk's alert reasons (Settings › Machines, docs/design/areas/machines.md): a line under its folded row, inset to the title. */
514 .row-children > li[data-disk] > .row-after { display: block; padding: 0 14px 10px 40px; overflow-wrap: anywhere; }
515 .row-children > li[data-disk] > .row-after > p { margin: 0; }
513516 .archived-list .row-title, .archived-list .row-meta { white-space: normal; overflow-wrap: anywhere; }
514517 .moved-from { margin: 8px 0 0; overflow-wrap: anywhere; }
515518 @media (max-width: 640px) {
test/machines-disks.test.ts added +133
@@ -0,0 +1,133 @@
1 /** The machines page lists each machine's disks (#10466): healthy, alerting, behind a controller, unknown model, no reading. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtempSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createRepo } from "../src/core/repos.ts";
8 import { createSession } from "../src/core/sessions.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11 import { recordReading } from "../src/machines/disk_readings.ts";
12 import { quietLogs } from "./helpers/globals.ts";
13
14 quietLogs();
15
16 const DAY = 86_400_000;
17 const T0 = Date.parse("2026-10-01T00:00:00Z");
18 function smart(o: { units: number; model?: string; spare?: number; used?: number; name?: string }) {
19 return JSON.stringify({
20 smartctl: { version: [7, 4] },
21 device: { name: "/dev/nvme0", type: "nvme", protocol: "NVMe" },
22 model_name: o.model ?? "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: 41, available_spare: o.spare ?? 100, available_spare_threshold: 10,
27 percentage_used: o.used ?? 1, data_units_written: o.units, media_errors: 0, power_on_hours: 1234,
28 unsafe_shutdowns: 7, warning_temp_time: 3, critical_comp_time: 0,
29 },
30 });
31 }
32 const controller = JSON.stringify({ smartctl: { version: [7, 4] }, device: { name: "/dev/sda", type: "megaraid,0" } });
33
34 let app: App;
35 let servers: Servers;
36 const cookies: Record<string, string> = {};
37
38 beforeAll(async () => {
39 app = createApp({ dataDir: mkdtempSync(join(tmpdir(), "machines-disks-")), port: 0 });
40 const ids: Record<string, string> = {};
41 for (const name of ["alice", "bob", "carol"]) {
42 const made = await createUser(app.db, { username: name, email: `${name}@x.test`, password: "correct horse battery", origin: "seed", trusted: true });
43 if (!made.ok) throw new Error(String(made.error));
44 ids[name] = made.value.id;
45 cookies[name] = `forge_session=${createSession(app.db, made.value.id).token}`;
46 }
47 app.db.query("UPDATE user SET is_admin = 1 WHERE username = 'alice'").run();
48 // Two repositories declare the same built-in machine name, "standard".
49 for (const [owner, name] of [["alice", "lab"], ["bob", "lab"]] as const) {
50 const repo = await createRepo(app.db, app.reposRoot, { ownerId: ids[owner]!, name, visibility: "public" });
51 if (!repo.ok) throw new Error(String(repo.error));
52 }
53 servers = await serve(app);
54 });
55 afterAll(() => {
56 servers.stop();
57 app.close();
58 });
59 const get = (path: string, who: string) => fetch(`${servers.url}${path}`, { headers: { cookie: cookies[who]! } });
60 const page = async (path = "/alice/lab/settings/machines", who = "alice") => (await get(path, who)).text();
61
62 test("no reading says so in one line", async () => {
63 expect(await page()).toContain('data-disks-empty="standard"');
64 });
65
66 test("healthy, alerting, behind-controller and unknown-model disks read as lines", async () => {
67 const db = app.db;
68 // 1e6 units × 512 000 B = 5.12e11 B = 0.51 TB; of 600 TBW = 0.085% → "0.1%". 2e9 units = 1.024e15 B = 1024.00 TB.
69 recordReading(db, "standard", "nvme0", smart({ units: 1_000_000 }), T0);
70 recordReading(db, "standard", "nvme1", smart({ units: 10, spare: 5, used: 85 }), T0);
71 recordReading(db, "standard", "sda", controller, T0);
72 recordReading(db, "standard", "nvme2", smart({ units: 2_000_000_000, model: "Mystery <b>X</b>" }), T0 + DAY);
73 const body = await page();
74 const li = (dev: string) => body.split(`data-disk="${dev}"`)[1]!.split("</li>")[0]!;
75 const healthy = li("nvme0");
76 expect(healthy).toContain("nvme0 · Samsung SSD 980 PRO 1TB");
77 expect(healthy).toContain("0.51 of 600 TBW (0.1%) · 41 °C");
78 expect(healthy).not.toContain("data-disk-reasons");
79 expect(healthy).not.toContain("badge");
80 expect(healthy).toContain("<details");
81 expect(healthy).toContain("rated 600 TBW, 5-year warranty");
82 expect(healthy).toContain("threshold 10%");
83 expect(healthy).toContain("rated warning 82 °C");
84 expect(healthy).toContain("Unsafe shutdowns");
85 expect(healthy).toContain("no projection yet");
86 const alerting = li("nvme1");
87 expect(alerting).toContain('class="badge"');
88 expect(alerting).toContain("available spare 5% is below its threshold 10%");
89 expect(alerting).toContain("percentage used 85% is at or above 80%");
90 // The reasons read with the line closed: after the <details>, not inside it.
91 const reasons = alerting.split("</details>")[1]!;
92 expect(reasons).toContain("data-disk-reasons");
93 expect(reasons).toContain("available spare 5% is below its threshold 10%");
94 expect(alerting.split("</details>")[0]).not.toContain("data-disk-reasons");
95 expect(li("sda")).toContain("behind a controller; SMART not visible");
96 const unknown = li("nvme2");
97 expect(unknown).toContain("1024.00 TB written · no spec sheet");
98 expect(unknown).toContain("Mystery &lt;b&gt;X&lt;/b&gt;");
99 expect(unknown).not.toContain("<b>X");
100 expect(body).not.toContain('data-disks-empty="standard"');
101 });
102
103 test("each machine's disks sit under its own row in the Machines list", async () => {
104 const body = await page();
105 const machines = body.split('id="machines"')[1]!.split("</section>")[0]!;
106 const standard = machines.split('data-machine="standard"')[1]!;
107 expect(standard).toContain('<ul class="row-children">');
108 expect(standard).toContain('data-disk="nvme0"');
109 expect(body).not.toContain("<h2>Disks</h2>");
110 expect(body).not.toContain("<h3>");
111 });
112
113 test("a projected date shows when the rated TBW is reached at the 30-day rate", async () => {
114 // 1e9 units = 512 TB at T0, 1.1e9 = 563.2 TB 30 d later: 51.2 TB per 30 d.
115 // Remaining 600 − 563.2 = 36.8 TB ÷ (51.2/30 TB/d) = 21.5625 d after 2026-10-31 → 2026-11-21.
116 recordReading(app.db, "standard", "nvme3", smart({ units: 1_000_000_000 }), T0);
117 recordReading(app.db, "standard", "nvme3", smart({ units: 1_100_000_000 }), T0 + 30 * DAY);
118 const row = (await page()).split('data-disk="nvme3"')[1]!.split("</li>")[0]!;
119 expect(row).toContain("Projected TBW date</dt><dd>2026-11-21");
120 expect(row).toContain("alert at 80%");
121 });
122
123 test("a writer who is not the owner sees no disks, though the machine name is shared", async () => {
124 const body = await page("/bob/lab/settings/machines", "bob");
125 expect(body).toContain('id="machines"');
126 expect(body).not.toContain("data-disk");
127 expect(body).not.toContain("Samsung");
128 expect(body).not.toContain("<h2>Disks</h2>");
129 });
130
131 test("a reader is refused the page", async () => {
132 expect((await get("/alice/lab/settings/machines", "carol")).status).toBe(403);
133 });
test/preview-disks.test.ts added +54
@@ -0,0 +1,54 @@
1 /** A forge preview seeds disk readings for the sample's machine and looks at them as the owner, closed and open (#10466). */
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 { createApp, type App } from "../src/app.ts";
7 import { createSession } from "../src/core/sessions.ts";
8 import { serve, type Servers } from "../src/http/server.ts";
9 import { PREVIEW_DISK_OPEN_LOOK, PREVIEW_MACHINES_PAGE, PREVIEW_OWNER_LOOKS } from "../src/spawn/preview-looks.ts";
10 import { previewAdmin, seedPreview } from "../src/spawn/preview-seed.ts";
11 import { quietLogs } from "./helpers/globals.ts";
12
13 quietLogs();
14
15 let dir: string;
16 let app: App;
17 let servers: Servers;
18 beforeAll(async () => {
19 dir = await mkdtemp(join(tmpdir(), "forge-preview-disks-"));
20 app = createApp({ dataDir: dir, port: 0 });
21 const seeded = await seedPreview(app, { FORGE_APP_ENV: "preview" });
22 if (!seeded.ok) throw new Error(seeded.error);
23 servers = await serve(app);
24 }, 30_000);
25 afterAll(async () => {
26 servers.stop();
27 app.close();
28 await rm(dir, { recursive: true, force: true });
29 });
30
31 test("the machines page and its open alerting line are owner looks at desk and phone", () => {
32 for (const page of [PREVIEW_MACHINES_PAGE, PREVIEW_DISK_OPEN_LOOK])
33 expect(PREVIEW_OWNER_LOOKS.filter((l) => l.page === page).map((l) => l.kind).sort()).toEqual(["desktop", "phone"]);
34 });
35
36 test("the owner sees a healthy, an alerting and a behind-controller line, and the open look's fragment is inside the alerting fold", async () => {
37 const cookie = `forge_session=${createSession(app.db, previewAdmin(app)!.id).token}`;
38 const res = await fetch(`${servers.url}${PREVIEW_MACHINES_PAGE}`, { headers: { cookie } });
39 expect(res.status).toBe(200);
40 const html = await res.text();
41 for (const dev of ["nvme0", "nvme1", "sda"]) expect(html).toContain(`data-disk="${dev}"`);
42 const alerting = html.split('data-disk="nvme1"')[1]!.split("</li>")[0]!;
43 const fragment = PREVIEW_DISK_OPEN_LOOK.split("#")[1]!;
44 expect(alerting.split("</details>")[0]).toContain(`id="${fragment}"`);
45 expect(alerting.split("</details>")[1]).toContain("data-disk-reasons");
46 });
47
48 test("a second seed records no second reading", async () => {
49 const count = () => (app.db.query("SELECT COUNT(*) AS n FROM disk_reading WHERE machine = 'standard'").get() as { n: number }).n;
50 const before = count();
51 const again = await seedPreview(app, { FORGE_APP_ENV: "preview" });
52 expect(again.ok).toBe(true);
53 expect(count()).toBe(before);
54 });
test/tui-machines.test.ts +14
@@ -10,6 +10,7 @@ import { join } from "node:path";
1010 import { createApp, type App as Forge } from "../src/app.ts";
1111 import { createRepo, type Repo } from "../src/core/repos.ts";
1212 import { createUser, type User } from "../src/core/users.ts";
13 import { recordReading } from "../src/machines/disk_readings.ts";
1314 import { recordEnd, recordStart } from "../src/machines/shell.ts";
1415 import { createForgeApp } from "../src/tui/forge.ts";
1516 import type { TuiIdentity } from "../src/tui/session.ts";
@@ -79,3 +80,16 @@ test("a reader sees neither", async () => {
7980 expect(text).toContain("writers'");
8081 expect(text).not.toContain("standard · alice");
8182 });
83
84 test("the owner sees the disk lines under the machines; another writer sees none", async () => {
85 recordReading(forge.db, "standard", "sda", '{"smartctl":{"version":[7,4]},"device":{"name":"/dev/sda","type":"megaraid,0"}}', Date.now());
86 forge.db.query("UPDATE user SET is_admin = 1 WHERE username = 'alice'").run();
87 try {
88 const owner = await machinesAs({ ...people["alice"]!, isAdmin: true });
89 expect(owner).toContain("sda: behind a controller");
90 } finally {
91 forge.db.query("UPDATE user SET is_admin = 0 WHERE username = 'alice'").run();
92 }
93 const writer = await machinesAs(people["alice"]!);
94 expect(writer).not.toContain("behind a controller");
95 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs