From a4dbbff1b682020dd38079fd3d49786f83dc2a0f Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 15 Oct 2026 09:46:46 +0200 Subject: [PATCH 1/4] feat(ui): the CPU, memory and disk gauges colour over a threshold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CPU and disk gauges were always grey: the worker sends no threshold for them, so the panel had nothing to judge them against. A machine at 97% busy across all twelve cores looked no different from one idling at 5%. The console holds the numbers itself now, in one file. LOAD_THRESHOLDS is amber at 85% full and red at 95%, and levelOf compares the figure the gauge is drawn from and nothing else — no earlier readings, no hold, no state carried between renders. A figure below 85% stays neutral, so a colour always says a threshold was crossed rather than "healthy, as far as we guessed". Memory keeps its colour from the worker's own pressure word where it sends one: that is the worker's judgement of the same fact and outranks ours. Where it sends none — every Linux worker — memory falls back to the threshold like the other two. The lane's colour test rewrites the memory figures it serves as well as the pressure word, so all six cases expect the same colour on a CI machine of any free-memory level, and one case pins that the word outranks the threshold. The unit seam owns the table itself. How the test says it changed too. Reading the memory fields needed a route helper of its own and the shared per-gauge claim an assertion of its own — the test's arrow had grown past Fallow's cyclomatic limit — and separating them showed the per-gauge loop never needed to re-check which token a tone class resolves to: the memory poll already proves that for all four classes in both themes. So the loop now asserts only what it alone can see, that every gauge carries a tone class and none of them the accent, and a mutation confirmed it can still fail. Closes #580 --- e2e/console/workers.spec.ts | 122 ++++++++++++++++++---------- ui/src/load-thresholds.test.ts | 27 ++++++ ui/src/load-thresholds.ts | 17 ++++ ui/src/views/worker-detail.test.tsx | 45 +++++++--- ui/src/views/worker-detail.tsx | 16 +++- 5 files changed, 170 insertions(+), 57 deletions(-) create mode 100644 ui/src/load-thresholds.test.ts create mode 100644 ui/src/load-thresholds.ts diff --git a/e2e/console/workers.spec.ts b/e2e/console/workers.spec.ts index 7dc87dc5..8bc37ce2 100644 --- a/e2e/console/workers.spec.ts +++ b/e2e/console/workers.spec.ts @@ -39,10 +39,45 @@ interface WorkerView { }; } -// why: the test rewrites one field of this route's own answer, so it names the one path it touches -// rather than the whole view; everything else passes through as the daemon sent it. interface WorkerPayload { - load?: { memory?: { pressure?: string } } | undefined; + load?: + | { + memory?: { availableBytes?: number; pressure?: string; totalBytes?: number } | undefined; + } + | undefined; +} + +// why: the two fields a colour case has to state; everything else in the reading stays the +// daemon's. +interface MemoryClaim { + readonly pressure?: string; + readonly usedPercent: number; +} + +/** + * why: only macOS reports a memory pressure, and how full a CI machine's memory happens to be is + * nobody's to predict — so the colour test states the memory reading it wants and this writes just + * those fields into the daemon's own answer, leaving every other field, its headers and its `Date` + * real. The route is asked again per request, so the reading stays as fresh as the daemon's own. + */ +async function answerMemory(page: Page, claim: () => MemoryClaim): Promise { + await page.route( + (url) => url.pathname === "/v1/workers", + async (route: Route) => { + const response = await route.fetch(); + const listed = (await response.json()) as { workers: WorkerPayload[] }; + for (const worker of listed.workers) { + const memory = worker.load?.memory; + const total = memory?.totalBytes; + if (memory === undefined || typeof total !== "number" || total <= 0) continue; + const wanted = claim(); + memory.availableBytes = Math.round((total * (100 - wanted.usedPercent)) / 100); + if (wanted.pressure === undefined) delete memory.pressure; + else memory.pressure = wanted.pressure; + } + await route.fulfill({ json: listed, response }); + }, + ); } interface Status { @@ -176,6 +211,24 @@ function fillsMatch(painted: readonly PaintedGauge[], percents: readonly number[ ); } +interface PaintedTone { + readonly colour: string; + readonly tone: string; +} + +// why: what the memory poll cannot see, because it watches one gauge: that every other gauge on +// the panel carries a tone class at all, and wears none of them in the accent. Which token a tone +// class resolves to is the poll's claim, proven for all four in both themes. +function gaugeToneFaults(painted: readonly PaintedTone[], accent: string): string[] { + const faults: string[] = []; + for (const fill of painted) { + if (fill.tone === "") faults.push("a gauge carries no tone class"); + else if (fill.colour === accent) + faults.push(`the ${fill.tone} gauge is painted with the accent`); + } + return faults; +} + /** A device's row in a worker's device table. */ function deviceRow(page: Page, id: string): Locator { return page.getByRole("row").filter({ has: page.getByRole("cell", { name: id, exact: true }) }); @@ -578,26 +631,13 @@ test.describe("the workers views", () => { await openConsole(page, daemon); const shown = card(page, host?.id ?? ""); await expect(fact(shown, "Load")).toHaveText(/^CPU .*memory .*disk /); - // why: only macOS reports a memory pressure, so a Linux lane can never see a warn or critical - // gauge from a live reading. This answers the page's own read of the worker list with one field - // of the reading changed and every other field left as the daemon sent it, so what is painted - // is the real component's doing. The list is read again per request, so the reading stays as - // fresh as the daemon's own. - let pressure: string | undefined; - await page.route( - (url) => url.pathname === "/v1/workers", - async (route: Route) => { - const response = await route.fetch(); - const listed = (await response.json()) as { workers: WorkerPayload[] }; - for (const worker of listed.workers) { - const memory = worker.load?.memory; - if (memory === undefined) continue; - if (pressure === undefined) delete memory.pressure; - else memory.pressure = pressure; - } - await route.fulfill({ json: listed, response }); - }, - ); + // why: only macOS reports a memory pressure, and how full a CI machine's memory happens to be + // is nobody's to predict. This answers the page's own read of the worker list with the memory + // reading's own fields set to the level the case asks for and every other field left as the + // daemon sent it, so what is painted is the real component's doing and the same colour is + // expected on any machine. + let claim: MemoryClaim = { usedPercent: 50 }; + await answerMemory(page, () => claim); await shown.getByRole("link", { name: host?.id ?? "" }).click(); const panel = page.getByRole("region", { name: "Load", exact: true }); @@ -621,40 +661,38 @@ test.describe("the workers views", () => { }; }); - // why: the four levels are the four tone rules, so each one is seen in each theme by the real - // component; a rule deleted, swapped or given the accent fails a named assertion here. + // why: these six are the whole colour rule for a memory gauge — the worker's three words, that + // the word outranks the console's threshold, and the threshold on its own at either end. Each is + // seen in each theme by the real component, so a tone rule deleted, swapped, or given the accent + // fails a named assertion here. for (const colorScheme of ["light", "dark"] as const) { await page.emulateMedia({ colorScheme }); const { accent, tones } = await readStatuses(page); - for (const [level, tone] of [ - ["normal", "ok"], - ["warn", "warn"], - ["critical", "error"], - [undefined, "idle"], + for (const [level, usedPercent, tone] of [ + ["normal", 50, "ok"], + ["warn", 50, "warn"], + ["critical", 50, "error"], + ["normal", 97, "ok"], + [undefined, 97, "error"], + [undefined, 50, "idle"], ] as const) { - pressure = level; + claim = { usedPercent, ...(level === undefined ? {} : { pressure: level }) }; const want = tones[TONES.indexOf(tone)]; + const at = `${usedPercent}%${level === undefined ? " with no word" : ` and ${level}`}`; await expect .poll(() => memoryFill(), { timeout: 5_000, - message: `the ${level ?? "absent"} pressure to paint the ${tone} gauge in ${colorScheme}`, + message: `${at} to paint the ${tone} gauge in ${colorScheme}`, }) .toEqual({ tone, colour: want }); expect(want, `the ${tone} gauge in ${colorScheme}`).not.toBe(accent); - // why: every gauge on the panel, not only the one driven above: each must show the colour of - // the tone class it carries, so none is painted by a stray inline colour or by the accent. + // why: every gauge on the panel, not only the one driven above, so a rule that coloured a + // gauge by something other than its own tone class fails here too. const fills = await paintedFills(); expect(fills.length).toBeGreaterThan(1); - for (const fill of fills) { - const context = `${fill.tone} gauge in ${colorScheme}`; - expect(fill.tone, context).not.toBe(""); - expect(fill.colour, context).toBe( - tones[TONES.indexOf(fill.tone as (typeof TONES)[number])], - ); - expect(fill.colour, context).not.toBe(accent); - } + expect(gaugeToneFaults(fills, accent)).toEqual([]); } } }); diff --git a/ui/src/load-thresholds.test.ts b/ui/src/load-thresholds.test.ts new file mode 100644 index 00000000..bb34a9ff --- /dev/null +++ b/ui/src/load-thresholds.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { LOAD_THRESHOLDS, levelOf } from "./load-thresholds"; + +describe("the console's own load thresholds", () => { + it("colours a figure at or over each threshold, and nothing below it", () => { + expect(levelOf(LOAD_THRESHOLDS.errorPercent)).toBe("error"); + expect(levelOf(LOAD_THRESHOLDS.warnPercent)).toBe("warn"); + expect(levelOf(LOAD_THRESHOLDS.warnPercent - 1)).toBe("idle"); + }); + + it("reads each figure the same way round, however full", () => { + expect(levelOf(0)).toBe("idle"); + expect(levelOf(84.9)).toBe("idle"); + expect(levelOf(90)).toBe("warn"); + expect(levelOf(100)).toBe("error"); + }); + + it("stays neutral for a figure the reading does not carry", () => { + expect(levelOf(undefined)).toBe("idle"); + }); + + it("keeps the two thresholds apart and in order", () => { + expect(LOAD_THRESHOLDS.warnPercent).toBeLessThan(LOAD_THRESHOLDS.errorPercent); + expect(LOAD_THRESHOLDS.errorPercent).toBeLessThanOrEqual(100); + }); +}); diff --git a/ui/src/load-thresholds.ts b/ui/src/load-thresholds.ts new file mode 100644 index 00000000..41c43649 --- /dev/null +++ b/ui/src/load-thresholds.ts @@ -0,0 +1,17 @@ +import type { Tone } from "./status"; + +// why: the worker sends no thresholds, so the CPU and disk gauges would have nothing to be +// coloured by. These are the console's own opinion, read off the one figure each gauge is drawn +// from; nothing here is remembered between readings, so a bar always shows the current reading's +// verdict. `simlock status` reports the same figures without one. +export const LOAD_THRESHOLDS = { errorPercent: 95, warnPercent: 85 } as const; + +// why: below `warnPercent` a figure stays in the neutral tone, so a colour always says a threshold +// was crossed rather than "healthy, as far as we guessed". A figure the reading does not carry has +// nothing to judge. +export function levelOf(percent: number | undefined): Tone { + if (percent === undefined) return "idle"; + if (percent >= LOAD_THRESHOLDS.errorPercent) return "error"; + if (percent >= LOAD_THRESHOLDS.warnPercent) return "warn"; + return "idle"; +} diff --git a/ui/src/views/worker-detail.test.tsx b/ui/src/views/worker-detail.test.tsx index ed68ed70..662e2178 100644 --- a/ui/src/views/worker-detail.test.tsx +++ b/ui/src/views/worker-detail.test.tsx @@ -616,21 +616,44 @@ describe("the Load gauges", () => { expect(memoryBar(LOAD.memory)).toContain(bar("ok", 69)); expect(memoryBar({ ...LOAD.memory, pressure: "warn" })).toContain(bar("warn", 69)); expect(memoryBar({ ...LOAD.memory, pressure: "critical" })).toContain(bar("error", 69)); + // why: the pressure is the worker's own word and outranks the console's threshold — it colours + // this bar at 69% full, where the threshold would have left it neutral. expect(memoryBar(noPressure)).toContain(bar("idle", 69)); }); - it("a worker's detail keeps the CPU and disk gauges idle at any level, a full disk included", () => { - const panel = panelOf({ - ...RUNNING, - load: { - ...LOAD, - cpu: { busyPercent: 99, cores: 10, limited: false }, - disk: { freeBytes: 0, totalBytes: 494.4 * GIB }, - }, - }); + it("a worker's detail falls back to the console's threshold for memory the worker gives no word for", () => { + const { pressure: _pressure, ...noPressure } = LOAD.memory; + const almostGone = { ...noPressure, availableBytes: 1.5 * GIB }; + const plenty = { ...noPressure, availableBytes: 30 * GIB }; + + expect(row(panelOf({ ...RUNNING, load: { ...LOAD, memory: almostGone } }), "Memory")).toContain( + bar("error", 95), + ); + expect(row(panelOf({ ...RUNNING, load: { ...LOAD, memory: plenty } }), "Memory")).toContain( + bar("idle", 6), + ); + }); + + it("a worker's detail colours the CPU and disk gauges by the console's own thresholds", () => { + type Cpu = NonNullable["cpu"]>; + type Disk = NonNullable["disk"]>; + const rowFor = (label: string, cpu: Partial, disk: Partial) => + row( + panelOf({ + ...RUNNING, + load: { ...LOAD, cpu: { ...LOAD.cpu, ...cpu }, disk: { ...LOAD.disk, ...disk } }, + }), + label, + ); - expect(row(panel, "CPU")).toContain(bar("idle", 99)); - expect(row(panel, "Disk")).toContain(bar("idle", 100)); + // why: the worker sends no threshold for either figure, so the console's own decides. The disk + // here is 494.4 GiB: no free byte is 100% used, 100 GiB free is 80%. + expect(rowFor("CPU", { busyPercent: 84 }, { freeBytes: 0 })).toContain(bar("idle", 84)); + expect(rowFor("CPU", { busyPercent: 85 }, { freeBytes: 0 })).toContain(bar("warn", 85)); + expect(rowFor("CPU", { busyPercent: 95 }, { freeBytes: 0 })).toContain(bar("error", 95)); + expect(rowFor("Disk", { busyPercent: 0 }, { freeBytes: 100 * GIB })).toContain(bar("idle", 80)); + expect(rowFor("Disk", { busyPercent: 0 }, { freeBytes: 50 * GIB })).toContain(bar("warn", 90)); + expect(rowFor("Disk", { busyPercent: 0 }, { freeBytes: 0 })).toContain(bar("error", 100)); }); it("a worker's detail says each figure's number once, the gauge carrying none of its own", () => { diff --git a/ui/src/views/worker-detail.tsx b/ui/src/views/worker-detail.tsx index 46eb0856..65e5751e 100644 --- a/ui/src/views/worker-detail.tsx +++ b/ui/src/views/worker-detail.tsx @@ -5,6 +5,7 @@ import { Link } from "../router"; import { deviceStateStatus, installStatus, pressureStatus, Status, type Tone } from "../status"; import { type Column, DataTable } from "../table"; import { WorkerLeases } from "./leases"; +import { levelOf } from "../load-thresholds"; import { formatBusyPercent, formatBytes, formatCores, loadGauges } from "./load-model"; import { WorkerFacts } from "./worker-facts"; import { @@ -332,16 +333,22 @@ function LoadFacts({ now, worker }: { readonly worker: WorkerView; readonly now: const { cpu, disk, memory } = load; const age = formatDuration(Math.ceil((now - load.readAt) / 1000) * 1000); const gauges = loadGauges(load); - // why: a gauge only shades the figure beside it. The console takes thresholds from the daemon - // and invents none, so CPU and disk have no level to be wrong at and stay in the neutral tone; - // memory has one the worker sends — its pressure — and takes that tone. - const memoryTone = memory?.pressure === undefined ? "idle" : pressureStatus(memory.pressure).tone; + // why: a gauge only shades the figure beside it. Memory has a level the worker sends — its + // pressure — and takes that tone; where the worker sends none, which is every Linux worker, it + // falls back to the console's own threshold like the other two. Those two have nothing to ask the + // worker for, so `load-thresholds.ts` decides, and a figure stays in the neutral tone until one + // is crossed. + const memoryTone = + memory?.pressure === undefined ? levelOf(gauges.memory) : pressureStatus(memory.pressure).tone; + const cpuTone = levelOf(gauges.cpu); + const diskTone = levelOf(gauges.disk); return (
( <> {formatBusyPercent(part.busyPercent)} busy, {formatCores(part.cores)} @@ -379,6 +386,7 @@ function LoadFacts({ now, worker }: { readonly worker: WorkerView; readonly now: label="Disk" part={disk} gauge={gauges.disk} + tone={diskTone} figure={(part) => `${formatBytes(part.freeBytes)} of ${formatBytes(part.totalBytes)} free`} />
From 6e553d406abc639587cff35f314f4d0694d62d5b Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 15 Oct 2026 09:46:51 +0200 Subject: [PATCH 2/4] docs: the gauge's colour is the console's, and the docs say whose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONSOLE.md said the CPU and disk bars stay neutral because the worker sends no threshold to judge them against. They no longer do: the console supplies its own. The paragraph now states the numbers, says they read the one figure beside the bar, and says where memory's pressure word wins instead — plus that the figures stay numbers and `simlock status` reports them with no verdict, so a reader who never opens the console is not told a different story. DESIGN.md's Load gauges entry claimed a gauge shades "a level the console has a word for and nothing else". For CPU and disk that is now false, so the entry says so plainly and bounds the exception: the one place in the console where a colour says something no word does, kept on a gauge and nowhere else — not in a status, not in a table, not in the card's line of text. Closes #580 --- docs/CONSOLE.md | 14 ++++++++------ docs/internal/DESIGN.md | 18 ++++++++++++------ 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/docs/CONSOLE.md b/docs/CONSOLE.md index bf16ab0e..c384135b 100644 --- a/docs/CONSOLE.md +++ b/docs/CONSOLE.md @@ -172,12 +172,14 @@ Select a worker to open its page. It adds: memory available of total, swap used, memory pressure (`normal`, `warn` or `critical`, left out where the system does not report it), and disk free of total. CPU, memory and disk each have a bar under the figure showing how full - it is, for scanning at a glance: the memory bar takes the colour of its - pressure, and is itself neutral where no pressure is reported. The other two - stay neutral because the worker sends no CPU or disk threshold for the console - to judge them against — a `(limit)` beside a figure is the worker's own limit - on that figure, not a level to be judged against. A part the worker could not - read says `Unavailable`, and has no bar. **Read** says how + it is, for scanning at a glance. A bar is neutral until the figure is + **85% full or more**, amber to 95%, red from there — the console's own rule, + read off the one figure it is drawn from and nothing else. Where memory + reports a pressure, that word wins instead: its bar is green, amber or red for + `normal`, `warn` or `critical` however full it is. Only the bar is coloured + this way: the figures themselves are printed as numbers, and `simlock status` + reports them without a verdict. A part the worker could not read says + `Unavailable`, and has no bar. **Read** says how long ago the reading was taken, and it ticks up until the next one. A worker that sent no load says "Not reported.", and a disconnected one "No reading while disconnected." diff --git a/docs/internal/DESIGN.md b/docs/internal/DESIGN.md index 9a25e544..8c757d99 100644 --- a/docs/internal/DESIGN.md +++ b/docs/internal/DESIGN.md @@ -189,12 +189,18 @@ scale; never a value between. the attribute. So a gauge is drawn by the console's own code and never by server-rendered output. A gauge is never the only place its number is, so it carries no accessible name and - takes no focus — the figure above it is the text. Colour is a second signal - here too: a gauge shades a level the console has a word for and nothing else, - so CPU and disk stay `--status-idle` until the worker sends a threshold they - can be judged against. A gauge belongs to this panel alone: the worker's card - prints the same load as one line of text, and charts are drawn by their own - rule above. + takes no focus — the figure above it is the text. Where the worker sends a + memory pressure, that word colours its bar, so there colour is a second signal + for a fact the panel prints as a word. Every figure without a word to echo — + CPU and disk always, memory on a worker that reports no pressure — takes + `LOAD_THRESHOLDS` from `ui/src/load-thresholds.ts`: amber at 85% full, red at + 95%, neutral below, the current reading only, nothing remembered between them. + That is the one place in the console where a colour says something no word + does, so it is kept where a gauge is — beside the figure it judges — and + nowhere else: not in a status, not in a table, not in the card's one line of + text. A worker sends no thresholds, so these are the console's opinion alone + and `simlock status` reports the same figures without one. A gauge belongs to + this panel alone. ## Layout From 9784863eab7f124d5b90b2404dab161045163b93 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 15 Oct 2026 10:21:31 +0200 Subject: [PATCH 3/4] test(ui): prove the thresholds at both edges, and stop claiming what the lane no longer checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four review findings, all of them about a claim outrunning its proof. The error threshold's lower edge was never tested, at either seam: moving it from 95 to 92 left 41 unit tests green. Both seams now assert one step under each edge, and the module's own test names the two numbers as literals, since the console's manual prints them to a person and a relative assertion cannot hold a constant to a promise about its value. The memory row's title still said a view with no pressure is idle. It has been false since this branch's own change: no word means the threshold decides, so such a memory goes amber and red like the other two. The word cases and the fallback cases now carry titles that say which one each proves, and the fallback tests the spec's own 97% figure rather than the 95% edge alone. The lane's per-gauge comment promised a gauge coloured by something other than its own tone class would fail there. That stopped being true when the redundant token comparison came out — the memory poll already proves all four classes in both themes — and the comment survived the edit that made it false. It now states what the predicate actually watches. --- e2e/console/workers.spec.ts | 4 ++-- ui/src/load-thresholds.test.ts | 9 +++++++- ui/src/views/worker-detail.test.tsx | 34 +++++++++++++++++------------ 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/e2e/console/workers.spec.ts b/e2e/console/workers.spec.ts index 8bc37ce2..748ca3d7 100644 --- a/e2e/console/workers.spec.ts +++ b/e2e/console/workers.spec.ts @@ -688,8 +688,8 @@ test.describe("the workers views", () => { .toEqual({ tone, colour: want }); expect(want, `the ${tone} gauge in ${colorScheme}`).not.toBe(accent); - // why: every gauge on the panel, not only the one driven above, so a rule that coloured a - // gauge by something other than its own tone class fails here too. + // why: the poll above watches the memory gauge; this watches the rest of the panel, for the + // one thing a poll of a single gauge cannot see. const fills = await paintedFills(); expect(fills.length).toBeGreaterThan(1); expect(gaugeToneFaults(fills, accent)).toEqual([]); diff --git a/ui/src/load-thresholds.test.ts b/ui/src/load-thresholds.test.ts index bb34a9ff..67311009 100644 --- a/ui/src/load-thresholds.test.ts +++ b/ui/src/load-thresholds.test.ts @@ -5,6 +5,9 @@ import { LOAD_THRESHOLDS, levelOf } from "./load-thresholds"; describe("the console's own load thresholds", () => { it("colours a figure at or over each threshold, and nothing below it", () => { expect(levelOf(LOAD_THRESHOLDS.errorPercent)).toBe("error"); + // why: one step under each edge, or a threshold moved down by one still colours the whole + // range this file names and stays green. + expect(levelOf(LOAD_THRESHOLDS.errorPercent - 1)).toBe("warn"); expect(levelOf(LOAD_THRESHOLDS.warnPercent)).toBe("warn"); expect(levelOf(LOAD_THRESHOLDS.warnPercent - 1)).toBe("idle"); }); @@ -20,7 +23,11 @@ describe("the console's own load thresholds", () => { expect(levelOf(undefined)).toBe("idle"); }); - it("keeps the two thresholds apart and in order", () => { + it("keeps the numbers the console's docs promise, apart and in order", () => { + // why: these two are printed to a person in the console's own manual, so a change here is a + // change to what the docs say; the literals hold the pair to that promise. + expect(LOAD_THRESHOLDS.warnPercent).toBe(85); + expect(LOAD_THRESHOLDS.errorPercent).toBe(95); expect(LOAD_THRESHOLDS.warnPercent).toBeLessThan(LOAD_THRESHOLDS.errorPercent); expect(LOAD_THRESHOLDS.errorPercent).toBeLessThanOrEqual(100); }); diff --git a/ui/src/views/worker-detail.test.tsx b/ui/src/views/worker-detail.test.tsx index 662e2178..504af47f 100644 --- a/ui/src/views/worker-detail.test.tsx +++ b/ui/src/views/worker-detail.test.tsx @@ -607,26 +607,28 @@ describe("the Load gauges", () => { expect(row(panel, "Memory")).not.toContain("load-bar"); }); - it("a worker's detail colours the memory gauge with its pressure, and idle where the view has none", () => { + it("a worker's detail colours the memory gauge with its pressure word", () => { type Memory = NonNullable["memory"]>; - const { pressure: _pressure, ...noPressure } = LOAD.memory; const memoryBar = (memory: Memory) => row(panelOf({ ...RUNNING, load: { ...LOAD, memory } }), "Memory"); expect(memoryBar(LOAD.memory)).toContain(bar("ok", 69)); expect(memoryBar({ ...LOAD.memory, pressure: "warn" })).toContain(bar("warn", 69)); expect(memoryBar({ ...LOAD.memory, pressure: "critical" })).toContain(bar("error", 69)); - // why: the pressure is the worker's own word and outranks the console's threshold — it colours - // this bar at 69% full, where the threshold would have left it neutral. - expect(memoryBar(noPressure)).toContain(bar("idle", 69)); }); it("a worker's detail falls back to the console's threshold for memory the worker gives no word for", () => { const { pressure: _pressure, ...noPressure } = LOAD.memory; - const almostGone = { ...noPressure, availableBytes: 1.5 * GIB }; + // why: 97% of a 32 GiB worker is the figure the spec names; 1.5 GiB available lands on the + // error threshold itself, and a full disk on the one step under it. + const almostGone = { ...noPressure, availableBytes: 0.96 * GIB }; + const atTheEdge = { ...noPressure, availableBytes: 1.5 * GIB }; const plenty = { ...noPressure, availableBytes: 30 * GIB }; expect(row(panelOf({ ...RUNNING, load: { ...LOAD, memory: almostGone } }), "Memory")).toContain( + bar("error", 97), + ); + expect(row(panelOf({ ...RUNNING, load: { ...LOAD, memory: atTheEdge } }), "Memory")).toContain( bar("error", 95), ); expect(row(panelOf({ ...RUNNING, load: { ...LOAD, memory: plenty } }), "Memory")).toContain( @@ -646,14 +648,18 @@ describe("the Load gauges", () => { label, ); - // why: the worker sends no threshold for either figure, so the console's own decides. The disk - // here is 494.4 GiB: no free byte is 100% used, 100 GiB free is 80%. - expect(rowFor("CPU", { busyPercent: 84 }, { freeBytes: 0 })).toContain(bar("idle", 84)); - expect(rowFor("CPU", { busyPercent: 85 }, { freeBytes: 0 })).toContain(bar("warn", 85)); - expect(rowFor("CPU", { busyPercent: 95 }, { freeBytes: 0 })).toContain(bar("error", 95)); - expect(rowFor("Disk", { busyPercent: 0 }, { freeBytes: 100 * GIB })).toContain(bar("idle", 80)); - expect(rowFor("Disk", { busyPercent: 0 }, { freeBytes: 50 * GIB })).toContain(bar("warn", 90)); - expect(rowFor("Disk", { busyPercent: 0 }, { freeBytes: 0 })).toContain(bar("error", 100)); + // why: the worker sends no threshold for either figure, so the console's own decides, one step + // under each edge included. The disk is 500 GiB so each case lands on a whole percent: 84, 85, + // 94 and 95% used. + const disk = (free: number) => ({ freeBytes: free * GIB, totalBytes: 500 * GIB }); + expect(rowFor("CPU", { busyPercent: 84 }, disk(500))).toContain(bar("idle", 84)); + expect(rowFor("CPU", { busyPercent: 85 }, disk(500))).toContain(bar("warn", 85)); + expect(rowFor("CPU", { busyPercent: 94 }, disk(500))).toContain(bar("warn", 94)); + expect(rowFor("CPU", { busyPercent: 95 }, disk(500))).toContain(bar("error", 95)); + expect(rowFor("Disk", { busyPercent: 0 }, disk(80))).toContain(bar("idle", 84)); + expect(rowFor("Disk", { busyPercent: 0 }, disk(75))).toContain(bar("warn", 85)); + expect(rowFor("Disk", { busyPercent: 0 }, disk(30))).toContain(bar("warn", 94)); + expect(rowFor("Disk", { busyPercent: 0 }, disk(25))).toContain(bar("error", 95)); }); it("a worker's detail says each figure's number once, the gauge carrying none of its own", () => { From 51ef86e74ec9960ab781bd69714ff67986e13149 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 15 Oct 2026 10:27:16 +0200 Subject: [PATCH 4/4] test(ui): say what the memory fallback test proves, not what another one does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment above the fallback cases ended "and a full disk on the one step under it". No disk appears in that test — the memory fixture carries the disk from the shared reading at 77% used — and the one-step-under cases live in the CPU and disk test, at 94% on both figures. A why-comment that points at another test's cases is how a reader ends up trusting a proof that is not there. --- ui/src/views/worker-detail.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/views/worker-detail.test.tsx b/ui/src/views/worker-detail.test.tsx index 504af47f..78544003 100644 --- a/ui/src/views/worker-detail.test.tsx +++ b/ui/src/views/worker-detail.test.tsx @@ -619,8 +619,8 @@ describe("the Load gauges", () => { it("a worker's detail falls back to the console's threshold for memory the worker gives no word for", () => { const { pressure: _pressure, ...noPressure } = LOAD.memory; - // why: 97% of a 32 GiB worker is the figure the spec names; 1.5 GiB available lands on the - // error threshold itself, and a full disk on the one step under it. + // why: 97% of a 32 GiB worker is the figure the spec names, over the edge; 1.5 GiB available is + // 95% used, the error threshold itself. The one step under it is the CPU and disk test's job. const almostGone = { ...noPressure, availableBytes: 0.96 * GIB }; const atTheEdge = { ...noPressure, availableBytes: 1.5 * GIB }; const plenty = { ...noPressure, availableBytes: 30 * GIB };