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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/10465-disk-readings-are-kept-per-machine-with → main 8 commits fork 1 thread · 1 unresolved
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 @@ | ||
| 15 | 15 | "src/flow/review_rounds.ts", |
| 16 | 16 | "src/flow/shared_red.ts", |
| 17 | 17 | "src/flow/stale_fold.ts", |
| 18 | "src/machines/disk_catalog.ts", | |
| 19 | "src/machines/disk_health.ts", | |
| 20 | 18 | "src/spawn/pod.ts", |
| 21 | 19 | "src/tui/gallery.ts", |
| 22 | 20 | "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"; | ||
| 41 | 41 | export { route as getApiV1AdminInvites } from "./getApiV1AdminInvites.ts"; |
| 42 | 42 | export { route as getApiV1AdminKpisProbe } from "./getApiV1AdminKpisProbe.ts"; |
| 43 | 43 | export { route as getApiV1AdminLimits } from "./getApiV1AdminLimits.ts"; |
| 44 | export { route as getApiV1AdminMachinesMachineDisks } from "./getApiV1AdminMachinesMachineDisks.ts"; | |
| 44 | 45 | export { route as getApiV1AdminMaintenance } from "./getApiV1AdminMaintenance.ts"; |
| 45 | 46 | export { route as getApiV1AdminPeople } from "./getApiV1AdminPeople.ts"; |
| 46 | 47 | export { route as getApiV1AdminPinned } from "./getApiV1AdminPinned.ts"; |
| @@ -359,6 +360,7 @@ export { route as postApiV1Users } from "./postApiV1Users.ts"; | ||
| 359 | 360 | export { route as postMcp } from "./postMcp.ts"; |
| 360 | 361 | export { route as putApiV1AdminCapacityKey } from "./putApiV1AdminCapacityKey.ts"; |
| 361 | 362 | export { route as putApiV1AdminLimitsKey } from "./putApiV1AdminLimitsKey.ts"; |
| 363 | export { route as putApiV1AdminMachinesMachineDisksDevice } from "./putApiV1AdminMachinesMachineDisksDevice.ts"; | |
| 362 | 364 | export { route as putApiV1AdminQuotasKey } from "./putApiV1AdminQuotasKey.ts"; |
| 363 | 365 | export { route as putApiV1AdminShield } from "./putApiV1AdminShield.ts"; |
| 364 | 366 | 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"; | ||
| 360 | 360 | import { tokenRoutes } from "./api/tokens.ts"; |
| 361 | 361 | import { accountAdmin, adminRoutes } from "./api/admin.ts"; |
| 362 | 362 | import { maintenanceRoutes } from "./api/maintenance.ts"; |
| 363 | import { diskReadingRoutes } from "./api/disk_readings.ts"; | |
| 363 | 364 | import { TEMPLATE_ERRORS } from "../apps/template_tip.ts"; |
| 364 | 365 | import { flagsRoutes } from "./api/flags.ts"; |
| 365 | 366 | import { appTemplateRoutes } from "./api/app_templates.ts"; |
| @@ -1247,6 +1248,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1247 | 1248 | forgeOriginRoutes(router); |
| 1248 | 1249 | adminRoutes(router); |
| 1249 | 1250 | maintenanceRoutes(router); |
| 1251 | diskReadingRoutes(router); | |
| 1250 | 1252 | flagsRoutes(router); |
| 1251 | 1253 | appTemplateRoutes(router); |
| 1252 | 1254 |
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 | /** | |
| 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"; | ||
| 25 | 25 | import { entry as crewSplits } from "./crewsplits.ts"; |
| 26 | 26 | import { entry as knobs } from "./knobs.ts"; |
| 27 | 27 | import { entry as displays } from "./displays.ts"; |
| 28 | import { entry as disks } from "./disks.ts"; | |
| 28 | 29 | import { entry as domains } from "./domains.ts"; |
| 29 | 30 | import { entry as edgeOnly } from "./edgeonly.ts"; |
| 30 | 31 | import { entry as traffic } from "./trafficview.ts"; |
| @@ -148,6 +149,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 148 | 149 | forgeView, |
| 149 | 150 | repos, |
| 150 | 151 | displays, |
| 152 | disks, | |
| 151 | 153 | newIssue, |
| 152 | 154 | ]; |
| 153 | 155 |
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> = { | ||
| 53 | 53 | "/:owner/:name/issues/:number": "/alice/forge/issues/1", |
| 54 | 54 | "/:owner/:name/pulls/:number": "/alice/forge/pulls/2", |
| 55 | 55 | "/:owner/:name/display": "/alice/forge/display", |
| 56 | "/api/v1/admin/machines/:machine/disks": "/api/v1/admin/machines/rack1/disks", | |
| 56 | 57 | }; |
| 57 | 58 | |
| 58 | 59 | function registeredGets(): Set<string> { |
test/palette-roster.test.ts +1
| @@ -106,6 +106,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 106 | 106 | ["system", "System", "the machine now: CPU, memory, disks, processes"], |
| 107 | 107 | ["valves", "Valves", "the week's budget as a valve per repository; turn one and the crew obeys"], |
| 108 | 108 | ["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"], | |
| 109 | 110 | ["telemetry", "Telemetry", "which agents may read flow and system; enter flips the grant"], |
| 110 | 111 | ["capacity", "Capacity", "concurrency and guards, live; e changes a settable one"], |
| 111 | 112 | ["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"; | ||
| 20 | 20 | /** Routes that answer 200 without a `session` phase → why. */ |
| 21 | 21 | const UNTIMED: Record<string, string> = { |
| 22 | 22 | "/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", | |
| 23 | 24 | "/metrics": "Prometheus text exposition, scraped by token; not a page read", |
| 24 | 25 | }; |
| 25 | 26 |
test/tui-asks-first.test.ts +1
| @@ -51,6 +51,7 @@ const allowed: readonly { file: string; action: string; reason: string }[] = [ | ||
| 51 | 51 | { file: "src/tui/screens/deploys.ts", action: "stopEnv", reason: "in `act`, called from `go` after the Stop confirm" }, |
| 52 | 52 | { file: "src/tui/screens/boards.ts", action: "deleteBoard", reason: "in `remove`, called only on the y of the delete y/n" }, |
| 53 | 53 | { 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" }, | |
| 54 | 55 | ]; |
| 55 | 56 | |
| 56 | 57 | /** How many lines above a call a `y` key check still guards it. */ |
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.