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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/10466-the-machines-page-shows-one-quiet-line-p → main 5 commits fork 5 threads · 5 unresolved
docs/design/areas/machines.md +9
| @@ -22,3 +22,12 @@ rows: Machines and Last sessions are each a `<section class="settings-block">` w | ||
| 22 | 22 | page is); each session a `row()` in `#machine-sessions`, a running one marked by a ● glyph and a "running" badge, an ended |
| 23 | 23 | one by a dash and its reason in words, so state is never colour alone. With no session, one plain sentence says so. |
| 24 | 24 | `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[] = [ | ||
| 139 | 139 | "/settings/usage", |
| 140 | 140 | ]; |
| 141 | 141 | |
| 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 | ||
| 142 | 151 | export interface OwnerLook { |
| 143 | 152 | readonly page: string; |
| 144 | 153 | readonly kind: LookKind; |
| @@ -168,6 +177,8 @@ export const PREVIEW_OWNER_LOOKS: readonly OwnerLook[] = [ | ||
| 168 | 177 | PREVIEW_ISSUE_EDIT_PAGE, |
| 169 | 178 | PREVIEW_RESILIENCE_PAGE, |
| 170 | 179 | PREVIEW_SEARCH_PAGE, |
| 180 | PREVIEW_MACHINES_PAGE, | |
| 181 | PREVIEW_DISK_OPEN_LOOK, | |
| 171 | 182 | ...PREVIEW_SETTINGS_PAGES, |
| 172 | 183 | ].flatMap((page) => (["desktop", "phone"] as const).map((kind) => ({ page, kind }))); |
| 173 | 184 |
src/spawn/preview-seed.ts +57
| @@ -47,16 +47,19 @@ import { createToken, listTokens } from "../core/tokens.ts"; | ||
| 47 | 47 | import { recordUsage } from "../core/usage.ts"; |
| 48 | 48 | import { getUserSetting, setUserSetting } from "../core/settings.ts"; |
| 49 | 49 | import { createUser, getUserByName, type User } from "../core/users.ts"; |
| 50 | import { diskSummary, recordReading } from "../machines/disk_readings.ts"; | |
| 50 | 51 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 51 | 52 | import { PREVIEW_CONFIG_FILES } from "./preview-config.ts"; |
| 52 | 53 | import { |
| 53 | 54 | PREVIEW_ADMIN, |
| 54 | 55 | PREVIEW_APP, |
| 55 | 56 | PREVIEW_APP_PAGE, |
| 57 | PREVIEW_DISK_ALERT, | |
| 56 | 58 | PREVIEW_FORKS, |
| 57 | 59 | PREVIEW_HELD_PAGE, |
| 58 | 60 | PREVIEW_HELD_THEMES, |
| 59 | 61 | PREVIEW_ITERATIONS_PAGE, |
| 62 | PREVIEW_MACHINES_PAGE, | |
| 60 | 63 | PREVIEW_PULL, |
| 61 | 64 | PREVIEW_PULL_PAGE, |
| 62 | 65 | PREVIEW_MOVED, |
| @@ -140,6 +143,56 @@ async function seedMoveSection(app: App, admin: User): Promise<Result<void, stri | ||
| 140 | 143 | } |
| 141 | 144 | } |
| 142 | 145 | |
| 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 | ||
| 143 | 196 | /** The seeded wave's version and headline, which the owner's /waves look shows. */ |
| 144 | 197 | export const PREVIEW_WAVE = "0.1.0"; |
| 145 | 198 | export const PREVIEW_WAVE_HEADLINE = "The sample says hello"; |
| @@ -200,6 +253,8 @@ export async function seedPreview( | ||
| 200 | 253 | if (!settings.ok) return settings; |
| 201 | 254 | const moveSection = await seedMoveSection(app, admin); |
| 202 | 255 | if (!moveSection.ok) return moveSection; |
| 256 | const disks = await seedDisks(app, admin); | |
| 257 | if (!disks.ok) return disks; | |
| 203 | 258 | const files = await seedConfigFiles(app, admin); |
| 204 | 259 | return files.ok ? ok({ seeded: false }) : files; |
| 205 | 260 | } |
| @@ -210,6 +265,8 @@ export async function seedPreview( | ||
| 210 | 265 | if (!settings.ok) return settings; |
| 211 | 266 | const moveSection = await seedMoveSection(app, admin); |
| 212 | 267 | if (!moveSection.ok) return moveSection; |
| 268 | const disks = await seedDisks(app, admin); | |
| 269 | if (!disks.ok) return disks; | |
| 213 | 270 | const files = await seedConfigFiles(app, admin); |
| 214 | 271 | return files.ok ? ok({ seeded: true }) : files; |
| 215 | 272 | } |
src/tui/screens/machines.ts +19 −2
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | * draw, so a writer who loses it sees nothing on the next frame. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { readMachine } from "../../actions/disk_readings.ts"; | |
| 9 | 10 | import { canWrite, type Repo } from "../../core/repos.ts"; |
| 10 | 11 | import { repoMachines } from "../../crew/machines_read.ts"; |
| 11 | 12 | import type { Machine } from "../../crew/machines.ts"; |
| @@ -16,6 +17,7 @@ import { style } from "../term/style.ts"; | ||
| 16 | 17 | import { truncate } from "../term/width.ts"; |
| 17 | 18 | import type { Session } from "../session.ts"; |
| 18 | 19 | import { fill, len, split } from "../ui/layout.ts"; |
| 20 | import { diskLines } from "./disks.ts"; | |
| 19 | 21 | import { ListState, drawList, type ListRow } from "./common.ts"; |
| 20 | 22 | |
| 21 | 23 | /** One line per session, as the page's table reads. */ |
| @@ -38,28 +40,43 @@ export function shellStatesLine(states: ReadonlyMap<string, MachineState>): stri | ||
| 38 | 40 | return parts.length === 0 ? undefined : `Your shell — ${parts.join("; ")}`; |
| 39 | 41 | } |
| 40 | 42 | |
| 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 | ||
| 41 | 51 | export function machinesScreen(f: Session, ctx: Ctx, repo: Repo): Screen { |
| 42 | 52 | const state = new ListState(); |
| 43 | 53 | let machines: readonly Machine[] = []; |
| 54 | // Read once in `load`, not per frame: a reading is a database read per machine. | |
| 55 | let disks: string[] = []; | |
| 44 | 56 | const may = (): boolean => f.user !== undefined && canWrite(f.db, repo, f.user); |
| 45 | 57 | const rows = (): SessionRecord[] => (may() ? lastSessions(f.db, repo.id) : []); |
| 46 | 58 | return { |
| 47 | 59 | title: "Machines", |
| 48 | 60 | keys: [binding(["j", "k"], "move cursor"), binding(["esc"], "back")], |
| 49 | 61 | 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 | } | |
| 51 | 66 | ctx.invalidate(); |
| 52 | 67 | }, |
| 53 | 68 | render(area, s) { |
| 54 | 69 | const c = ctx.theme.c; |
| 55 | const [head, list] = split(area, "v", [len(4), fill(1)]); | |
| 56 | 70 | const listed = rows(); |
| 71 | const shown = may() ? disks : []; | |
| 72 | const [head, list] = split(area, "v", [len(4 + shown.length), fill(1)]); | |
| 57 | 73 | const lines = !may() |
| 58 | 74 | ? ["A repository's machines and shell sessions are its writers'."] |
| 59 | 75 | : [ |
| 60 | 76 | machinesLine(machines), |
| 61 | 77 | listed.length === 0 ? "No shell has been opened here yet." : "Last sessions, newest first.", |
| 62 | 78 | ...(shellStatesLine(machineStates(f.app, repo.id, f.user!.id)) ?? []), |
| 79 | ...shown, | |
| 63 | 80 | ]; |
| 64 | 81 | lines.forEach((line, i) => |
| 65 | 82 | 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 { | ||
| 32 | 32 | import { page } from "../layout.ts"; |
| 33 | 33 | import { html } from "../html.ts"; |
| 34 | 34 | 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"; | |
| 35 | 38 | import { ghosttyWasmAsset, machineTermAsset } from "../machine-term-asset.ts"; |
| 36 | 39 | import { settingsPageResponse } from "./settings.ts"; |
| 37 | 40 | |
| @@ -59,7 +62,76 @@ export async function machinesSettingsPage(ctx: Ctx): Promise<Response> { | ||
| 59 | 62 | const config = await timePhaseAsync(ctx.request, "machines", () => repoMachines(ctx.app, repo)); |
| 60 | 63 | const sessions = timePhase(ctx.request, "sessions", () => lastSessions(ctx.app.db, repo.id)); |
| 61 | 64 | 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 |
||
| 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 |
||
| 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)); | |
| 63 | 135 | } |
outdated
Dead default: |
||
| 64 | 136 | |
| 65 | 137 | /** 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) { | ||
| 70 | 142 | } |
| 71 | 143 | |
| 72 | 144 | /** 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({ | |
| 76 | 154 | lead: icon("terminal"), |
| 77 | 155 | title: m.name, |
| 78 | 156 | 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))}`, |
| 79 | 157 | href: shellPath(repo, m.name), |
| 80 | 158 | nav: true, |
| 81 | 159 | data: { machine: m.name }, |
| 82 | }), | |
| 83 | )}`; | |
| 160 | ...(children === undefined ? {} : { children }), | |
| 161 | }); | |
| 162 | })}`; | |
| 84 | 163 | } |
| 85 | 164 | |
| 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 { | |
| 87 | 166 | const machines = html`<section class="settings-block"> |
| 88 | 167 | <h2>Machines</h2> |
| 89 | 168 | <p class="muted">A machine is where the crew shell runs; each writer gets a directory and home of their own on it.</p> |
| 90 | 169 | <ul class="list setting-list" id="machines"> |
| 91 | ${machineRows(repo, list, states)} | |
| 170 | ${machineRows(repo, list, states, disks)} | |
| 92 | 171 | </ul> |
| 93 | 172 | </section>`; |
| 94 | 173 | // 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); } | ||
| 510 | 510 | /* An origin's error, notes and acts (Settings › Origins): a block of their own under the row, at every width. */ |
| 511 | 511 | .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; } |
| 512 | 512 | .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; } | |
| 513 | 516 | .archived-list .row-title, .archived-list .row-meta { white-space: normal; overflow-wrap: anywhere; } |
| 514 | 517 | .moved-from { margin: 8px 0 0; overflow-wrap: anywhere; } |
| 515 | 518 | @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 <b>X</b>"); | |
| 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"; | ||
| 10 | 10 | import { createApp, type App as Forge } from "../src/app.ts"; |
| 11 | 11 | import { createRepo, type Repo } from "../src/core/repos.ts"; |
| 12 | 12 | import { createUser, type User } from "../src/core/users.ts"; |
| 13 | import { recordReading } from "../src/machines/disk_readings.ts"; | |
| 13 | 14 | import { recordEnd, recordStart } from "../src/machines/shell.ts"; |
| 14 | 15 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 15 | 16 | import type { TuiIdentity } from "../src/tui/session.ts"; |
| @@ -79,3 +80,16 @@ test("a reader sees neither", async () => { | ||
| 79 | 80 | expect(text).toContain("writers'"); |
| 80 | 81 | expect(text).not.toContain("standard · alice"); |
| 81 | 82 | }); |
| 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 | }); |
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.