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 diff --git a/e2e/console/workers.spec.ts b/e2e/console/workers.spec.ts index 7dc87dc5..748ca3d7 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: 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); - 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..67311009 --- /dev/null +++ b/ui/src/load-thresholds.test.ts @@ -0,0 +1,34 @@ +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"); + // 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"); + }); + + 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 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/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..78544003 100644 --- a/ui/src/views/worker-detail.test.tsx +++ b/ui/src/views/worker-detail.test.tsx @@ -607,30 +607,59 @@ 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)); - 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; + // 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 }; + + 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( + 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, 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", () => { 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`} />