From 8b461388a0a9c71317624f3e4c17b015d5a4c50c Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 14 Oct 2026 21:06:31 +0200 Subject: [PATCH 01/10] feat(ui): the console draws each worker's load as gauges on its detail page Each of the Load panel's CPU, memory and disk figures gains a bar in a line of its own underneath, filled to that figure's own percent. Memory takes the colour of the pressure it already prints; CPU and disk stay neutral because the worker reports no limit for them that the console could judge them against. Swap gains none: the wire sends swap used and no swap total. The gauge is plain CSS rather than the charts library, because the string render the Load tests already use draws an empty div from Recharts, so a library gauge's fill could only be asserted in the browser lane. Moving the fill's width costs 0.13ms of layout per change, measured, over a panel that changes once every 15 seconds; the slide uses the existing --duration token, which prefers-reduced-motion already zeroes. Closes #569 --- docs/CONSOLE.md | 6 +- docs/internal/DESIGN.md | 14 ++- e2e/console/workers.spec.ts | 130 ++++++++++++++++++++++++++++ ui/src/styles.css | 46 ++++++++++ ui/src/views/load-model.test.ts | 60 ++++++++++++- ui/src/views/load-model.ts | 26 ++++++ ui/src/views/worker-detail.test.tsx | 95 ++++++++++++++++++++ ui/src/views/worker-detail.tsx | 79 ++++++++++++++--- 8 files changed, 440 insertions(+), 16 deletions(-) diff --git a/docs/CONSOLE.md b/docs/CONSOLE.md index e3cf328c..5d653883 100644 --- a/docs/CONSOLE.md +++ b/docs/CONSOLE.md @@ -171,7 +171,11 @@ Select a worker to open its page. It adds: - **Load**: how busy the machine is right now: CPU busy share and cores, 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. A part the worker could not read says `Unavailable`. **Read** says how + 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 the other two stay neutral because the worker reports no CPU or + disk limit for the console to judge them against. 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 6f4d0e6c..d2ac412e 100644 --- a/docs/internal/DESIGN.md +++ b/docs/internal/DESIGN.md @@ -179,6 +179,15 @@ scale; never a value between. nothing is visible only in the picture. A chart can be focused, and the arrow keys step through it. Its focus ring goes round the whole chart, for keyboard focus only: a click on a chart draws none. +- **Load gauges.** A figure of how full something is carries a bar in a line of + its own under it: 8px high, `--field` track, `max-width: 20rem`, square, and + never the accent. Its fill takes a status colour by class; its level arrives + inline, which the Content-Security-Policy allows for a style attribute. 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 limit they can be + judged against. ## Layout @@ -208,7 +217,10 @@ scale; never a value between. ## Motion Almost none. Colour changes on hover take 120ms (`--duration`). Nothing slides, -fades or bounces, charts included. With `prefers-reduced-motion: reduce`, there +fades or bounces, charts included. The one exception is a load gauge's fill: it +takes 120ms (`--duration`) to reach a new level rather than jumping, because a +new reading lands every 15 seconds and a jump reads as a glitch. Nothing else +moves, and nothing ever loops. With `prefers-reduced-motion: reduce`, there are no transitions at all. ## Voice diff --git a/e2e/console/workers.spec.ts b/e2e/console/workers.spec.ts index 2b68758a..99c19fd7 100644 --- a/e2e/console/workers.spec.ts +++ b/e2e/console/workers.spec.ts @@ -31,6 +31,12 @@ interface WorkerView { readonly android: { readonly running: number; readonly limit: number }; }; readonly leases: readonly { readonly deviceId: string; readonly grantedAt: number }[]; + readonly load?: { + readonly readAt: number; + readonly cpu?: { readonly busyPercent: number; readonly cores: number }; + readonly memory?: { readonly availableBytes: number; readonly totalBytes: number }; + readonly disk?: { readonly freeBytes: number; readonly totalBytes: number }; + }; } interface Status { @@ -444,6 +450,130 @@ test.describe("the workers views", () => { await expect(fact(panel, "Memory")).toContainText("available"); }); + test("a worker's detail draws a bar under each figure, filled to that figure's own percent", async ({ + daemon, + page, + }) => { + const [host] = await workerList(daemon); + await openConsole(page, daemon); + const shown = card(page, host?.id ?? ""); + await expect(fact(shown, "Load")).toHaveText(/^CPU .*memory .*disk /); + await shown.getByRole("link", { name: host?.id ?? "" }).click(); + + const panel = page.getByRole("region", { name: "Load", exact: true }); + // why: the worker's reading turns over every 15 s, so this measures around it and retries when + // it changes mid-measurement — otherwise the bar and the figure compared are different readings. + await waitFor( + async () => { + const before = (await workerList(daemon))[0]?.load; + if (before?.cpu === undefined || before.memory === undefined || before.disk === undefined) + return false; + const painted = await panel.locator(".load-bar").evaluateAll((bars) => + bars.map((bar) => { + const fill = bar.firstElementChild as HTMLElement; + return { + track: bar.getBoundingClientRect().width, + fill: fill.getBoundingClientRect().width, + }; + }), + ); + const after = (await workerList(daemon))[0]?.load; + if (JSON.stringify(before) !== JSON.stringify(after)) return false; + + const whole = (percent: number) => Math.round(Math.min(100, Math.max(0, percent))); + const wanted = [ + whole(before.cpu.busyPercent), + whole((1 - before.memory.availableBytes / before.memory.totalBytes) * 100), + whole((1 - before.disk.freeBytes / before.disk.totalBytes) * 100), + ]; + return ( + painted.length === wanted.length && + painted.every((bar, index) => { + const expected = (bar.track * wanted[index]) / 100; + return Math.abs(bar.fill - expected) <= 1; + }) + ); + }, + { interval: 500, timeout: 40_000, label: () => "the fill to match the figure" }, + ); + }); + + test("a load gauge's fill takes its colour from the stylesheet and never the accent, in both themes", async ({ + daemon, + page, + }) => { + const [host] = await workerList(daemon); + await openConsole(page, daemon); + const shown = card(page, host?.id ?? ""); + await expect(fact(shown, "Load")).toHaveText(/^CPU .*memory .*disk /); + await shown.getByRole("link", { name: host?.id ?? "" }).click(); + const panel = page.getByRole("region", { name: "Load", exact: true }); + await expect(panel.locator(".load-bar").first()).toBeVisible(); + + for (const colorScheme of ["light", "dark"] as const) { + await page.emulateMedia({ colorScheme }); + const { accent, tones } = await readStatuses(page); + const fills = await panel.locator(".load-bar").evaluateAll((bars) => + bars.map((bar) => ({ + tone: /load-bar-(\w+)/.exec(bar.className)?.[1] ?? "", + colour: getComputedStyle(bar.firstElementChild as HTMLElement).backgroundColor, + })), + ); + + expect(fills.length).toBeGreaterThan(0); + 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); + } + } + }); + + test("a load gauge's fill slides to a new level over --duration, and not at all under reduced motion", async ({ + browserName, + daemon, + page, + }) => { + const [host] = await workerList(daemon); + await openConsole(page, daemon); + const shown = card(page, host?.id ?? ""); + await expect(fact(shown, "Load")).toHaveText(/^CPU .*memory .*disk /); + await shown.getByRole("link", { name: host?.id ?? "" }).click(); + + const panel = page.getByRole("region", { name: "Load", exact: true }); + const fill = panel.locator(".load-bar-fill").first(); + await expect(fill).toBeVisible(); + // why: the stylesheet moves the fill's width over the document's own duration token, which is + // what `prefers-reduced-motion: reduce` already sets to zero. Both sides are read back as + // computed values and compared as milliseconds: a browser writes the token's `120ms` as `.12s`. + const asMs = (value: string) => + value.trim().endsWith("ms") ? Number.parseFloat(value) : Number.parseFloat(value) * 1_000; + await expect + .poll(async () => { + const style = await fill.evaluate((element) => { + const computed = getComputedStyle(element); + return { + duration: computed.transitionDuration, + property: computed.transitionProperty, + token: getComputedStyle(document.documentElement).getPropertyValue("--duration").trim(), + }; + }); + return style.property === "width" && asMs(style.duration) === asMs(style.token); + }) + .toBe(true); + + if (browserName === "chromium") { + // why: emulated in Chromium alone — it is the one engine whose reduced-motion emulation was + // verified on the machine that wrote this test, and a guard must not hide a real failure in + // the other two. + await page.emulateMedia({ reducedMotion: "reduce" }); + await expect(fill).toHaveCSS("transition-duration", "0s"); + } + }); + test("a disconnected worker's Load row says No reading while disconnected within a second", async ({ page, }) => { diff --git a/ui/src/styles.css b/ui/src/styles.css index df9eb27b..56bb7f05 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -986,6 +986,52 @@ input[aria-invalid="true"] { margin-bottom: var(--space-3); } +/* Load gauges: how full one machine figure is, as a bar in a line of its own under the figure + * it belongs to. Every colour comes from here by class, so both themes apply (ADR 0011 §4); only + * the level arrives inline, which the Content-Security-Policy allows (style-src governs style + * elements, not style attributes). A gauge only shades what its figure already says, so it is + * square, quiet, and never the accent. */ + +.load-figure { + display: grid; + gap: var(--space-1); +} + +/* why: the bar stops where a reading is readable rather than filling the panel — a full-width + * band would outweigh the one line of text it shades. */ +.load-bar { + display: block; + height: 8px; + max-width: 20rem; + background: var(--field); +} + +/* why: a new reading lands every 15 seconds, so the fill slides to its level over --duration + * rather than jumping; --duration is already zero under `prefers-reduced-motion: reduce`. Moving + * the width costs 0.13ms of layout per change, measured, and no long task either way. */ +.load-bar-fill { + display: block; + height: 100%; + width: var(--load-bar, 0%); + transition: width var(--duration); +} + +.load-bar-ok .load-bar-fill { + background: var(--status-ok); +} + +.load-bar-warn .load-bar-fill { + background: var(--status-warn); +} + +.load-bar-error .load-bar-fill { + background: var(--status-error); +} + +.load-bar-idle .load-bar-fill { + background: var(--status-idle); +} + /* Tables: a row per item, in a panel. Headers are labels, rows are roomy, numbers sit right. * Under 768px each row stacks into labelled lines, so the page never scrolls sideways. */ diff --git a/ui/src/views/load-model.test.ts b/ui/src/views/load-model.test.ts index 9c1f7e4b..ccdb2247 100644 --- a/ui/src/views/load-model.test.ts +++ b/ui/src/views/load-model.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { formatBusyPercent, formatBytes, formatCores, loadSummary } from "./load-model"; +import { formatBusyPercent, formatBytes, formatCores, loadGauges, loadSummary } from "./load-model"; const MIB = 1024 ** 2; const GIB = 1024 ** 3; @@ -64,3 +64,61 @@ describe("the card summary", () => { expect(loadSummary(noDisk)).toBe("CPU 34% · memory 9.8 of 32.0 GiB · disk unavailable"); }); }); + +describe("how full each figure is", () => { + it("answers each figure's fullness as a whole percent", () => { + expect(loadGauges(FULL)).toEqual({ cpu: 34, disk: 77, memory: 69 }); + }); + + it("rounds a gauge's percent the way the figure above it does", () => { + const shown = (percent: number) => Number(formatBusyPercent(percent).replace("%", "")); + + for (const busyPercent of [34.4, 34.5, 0.5, 99.5]) { + const cpu = { busyPercent, cores: 10, limited: false }; + + expect(loadGauges({ ...FULL, cpu }).cpu).toBe(shown(busyPercent)); + } + }); + + it("fills the bar for a figure at or over its own total and empties it for one with nothing used", () => { + const full = loadGauges({ + ...FULL, + cpu: { busyPercent: 100, cores: 10, limited: false }, + disk: { freeBytes: 0, totalBytes: 100 * GIB }, + memory: { ...FULL.memory, availableBytes: 0 }, + }); + const over = loadGauges({ + cpu: { busyPercent: 137, cores: 10, limited: false }, + disk: { freeBytes: -20 * GIB, totalBytes: 100 * GIB }, + memory: { ...FULL.memory, availableBytes: -5 * GIB }, + readAt: 0, + }); + const empty = loadGauges({ + ...FULL, + cpu: { busyPercent: -4, cores: 10, limited: false }, + disk: { freeBytes: 100 * GIB, totalBytes: 100 * GIB }, + memory: { ...FULL.memory, availableBytes: 34 * GIB, totalBytes: 32 * GIB }, + }); + + expect(full).toEqual({ cpu: 100, disk: 100, memory: 100 }); + expect(over).toEqual({ cpu: 100, disk: 100, memory: 100 }); + expect(empty).toEqual({ cpu: 0, disk: 0, memory: 0 }); + }); + + it("answers nothing for a figure the reading gives no denominator for", () => { + const { cpu: _cpu, ...noCpu } = FULL; + const { memory: _memory, ...noMemory } = FULL; + const { disk: _disk, ...noDisk } = FULL; + const zeroTotals = { + ...FULL, + disk: { freeBytes: 12 * GIB, totalBytes: 0 }, + memory: { ...FULL.memory, totalBytes: 0 }, + }; + + expect(loadGauges(noCpu)).toEqual({ disk: 77, memory: 69 }); + expect(loadGauges(noMemory)).toEqual({ cpu: 34, disk: 77 }); + expect(loadGauges(noDisk)).toEqual({ cpu: 34, memory: 69 }); + expect(loadGauges(zeroTotals)).toEqual({ cpu: 34 }); + expect(loadGauges({ readAt: 0 })).toEqual({}); + }); +}); diff --git a/ui/src/views/load-model.ts b/ui/src/views/load-model.ts index c3910b95..a5507d8b 100644 --- a/ui/src/views/load-model.ts +++ b/ui/src/views/load-model.ts @@ -30,6 +30,32 @@ export function formatCores(cores: number): string { return `${figure} ${cores === 1 ? "core" : "cores"}`; } +export interface LoadGauges { + readonly cpu?: number | undefined; + readonly disk?: number | undefined; + readonly memory?: number | undefined; +} + +export function loadGauges(load: WorkerLoad): LoadGauges { + const { cpu, disk, memory } = load; + return { + ...(cpu !== undefined && { cpu: clampPercent(cpu.busyPercent) }), + ...(memory !== undefined && { + memory: usedPercent(memory.availableBytes, memory.totalBytes), + }), + ...(disk !== undefined && { disk: usedPercent(disk.freeBytes, disk.totalBytes) }), + }; +} + +function clampPercent(percent: number): number { + return Math.round(Math.min(100, Math.max(0, percent))); +} + +function usedPercent(available: number, total: number): number | undefined { + if (!(total > 0)) return undefined; + return clampPercent((1 - available / total) * 100); +} + export function loadSummary(load: WorkerLoad): string { const { cpu, disk, memory } = load; const cpuPart = diff --git a/ui/src/views/worker-detail.test.tsx b/ui/src/views/worker-detail.test.tsx index 5ed3bc05..4f37d37c 100644 --- a/ui/src/views/worker-detail.test.tsx +++ b/ui/src/views/worker-detail.test.tsx @@ -557,3 +557,98 @@ describe("a worker's page shows its load", () => { expect(html.match(/\(limit\)<\/span>/g)).toHaveLength(2); }); }); + +describe("the Load gauges", () => { + const htmlAt = (worker: WorkerView, now = NOW) => + renderToStaticMarkup(); + const panelOf = (worker: WorkerView) => loadPanel(htmlAt(worker)); + const bar = (tone: string, percent: number) => + ``; + const row = (html: string, label: string) => { + const start = html.indexOf(`
${label}
`); + if (start === -1) throw new Error(`the panel has no ${label} row`); + const next = html.indexOf("
", start + 1); + return html.slice(start, next === -1 ? undefined : next); + }; + + it("a worker's detail draws one gauge in each of its CPU, Memory and Disk rows and none in Swap, Pressure or Read", () => { + const panel = panelOf({ ...RUNNING, load: LOAD }); + + expect(row(panel, "CPU")).toContain(bar("idle", 34)); + expect(row(panel, "Memory")).toContain(bar("ok", 69)); + expect(row(panel, "Disk")).toContain(bar("idle", 77)); + for (const label of ["Swap", "Pressure", "Read"]) { + expect(row(panel, label)).not.toContain("load-bar"); + } + expect(panel.match(/class="load-bar /g)).toHaveLength(3); + }); + + it("a worker's detail draws no gauge in the row of a figure that says Unavailable", () => { + const { cpu: _cpu, ...noCpu } = LOAD; + const { memory: _memory, ...noMemory } = LOAD; + const { disk: _disk, ...noDisk } = LOAD; + const zeroTotal = { + ...LOAD, + memory: { ...LOAD.memory, availableBytes: 12 * GIB, totalBytes: 0 }, + }; + + expect(row(panelOf({ ...RUNNING, load: noCpu }), "CPU")).toContain("Unavailable"); + expect(row(panelOf({ ...RUNNING, load: noCpu }), "CPU")).not.toContain("load-bar"); + expect(row(panelOf({ ...RUNNING, load: noMemory }), "Memory")).not.toContain("load-bar"); + expect(row(panelOf({ ...RUNNING, load: noDisk }), "Disk")).not.toContain("load-bar"); + expect(row(panelOf({ ...RUNNING, load: zeroTotal }), "Memory")).not.toContain("load-bar"); + }); + + it("a worker's detail colours the memory gauge with its pressure, and idle where the view has none", () => { + 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 }, + }, + }); + + expect(row(panel, "CPU")).toContain(bar("idle", 99)); + expect(row(panel, "Disk")).toContain(bar("idle", 100)); + }); + + it("a worker's detail says each figure's number once, the gauge carrying none of its own", () => { + const panel = text(htmlAt({ ...RUNNING, load: LOAD })); + + expect(loadPanel(panel).match(/34%/g)).toHaveLength(1); + expect(loadPanel(panel)).not.toContain("69%"); + expect(loadPanel(panel)).not.toContain("77%"); + const gauges = panelOf({ ...RUNNING, load: LOAD }).match( + /class="load-bar load-bar-\w+" aria-hidden="true"/g, + ); + + expect(gauges).toHaveLength(3); + }); + + it("a worker's detail draws no gauge at all where it says Not reported. or No reading while disconnected.", () => { + expect(panelOf(RUNNING)).not.toContain("load-bar"); + expect(panelOf({ ...RUNNING, connection: "disconnected", load: LOAD })).not.toContain( + "load-bar", + ); + }); + + it("the worker's card prints its Load row as one line of text with no gauge", () => { + const card = renderToStaticMarkup(); + + expect(text(card)).toContain("Load CPU 34% · memory 9.8 of 32.0 GiB · disk 112.4 GiB free"); + expect(card).not.toContain("load-bar"); + }); +}); diff --git a/ui/src/views/worker-detail.tsx b/ui/src/views/worker-detail.tsx index 851742d9..75cfc831 100644 --- a/ui/src/views/worker-detail.tsx +++ b/ui/src/views/worker-detail.tsx @@ -1,10 +1,11 @@ +import type { CSSProperties, ReactNode } from "react"; import { Id, PageHeader, Panel } from "../layout"; import { formatDuration } from "../live/time"; import { Link } from "../router"; -import { deviceStateStatus, installStatus, pressureStatus, Status } from "../status"; +import { deviceStateStatus, installStatus, pressureStatus, Status, type Tone } from "../status"; import { type Column, DataTable } from "../table"; import { WorkerLeases } from "./leases"; -import { formatBusyPercent, formatBytes, formatCores } from "./load-model"; +import { formatBusyPercent, formatBytes, formatCores, loadGauges } from "./load-model"; import { WorkerFacts } from "./worker-facts"; import { platformName, @@ -330,6 +331,11 @@ function LoadFacts({ now, worker }: { readonly worker: WorkerView; readonly now: if (load === undefined) return

Not reported.

; 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; return (
@@ -338,10 +344,15 @@ function LoadFacts({ now, worker }: { readonly worker: WorkerView; readonly now: {cpu === undefined ? ( "Unavailable" ) : ( - <> - {formatBusyPercent(cpu.busyPercent)} busy, {formatCores(cpu.cores)} - - + + {formatBusyPercent(cpu.busyPercent)} busy, {formatCores(cpu.cores)} + + + } + gauge={gauges.cpu} + /> )}
@@ -351,10 +362,16 @@ function LoadFacts({ now, worker }: { readonly worker: WorkerView; readonly now: {memory === undefined ? ( "Unavailable" ) : ( - <> - {formatBytes(memory.availableBytes)} of {formatBytes(memory.totalBytes)} available - - + + {formatBytes(memory.availableBytes)} of {formatBytes(memory.totalBytes)} available + + + } + gauge={gauges.memory} + tone={memoryTone} + /> )} @@ -375,9 +392,14 @@ function LoadFacts({ now, worker }: { readonly worker: WorkerView; readonly now:
Disk
- {disk === undefined - ? "Unavailable" - : `${formatBytes(disk.freeBytes)} of ${formatBytes(disk.totalBytes)} free`} + {disk === undefined ? ( + "Unavailable" + ) : ( + + )}
@@ -387,3 +409,34 @@ function LoadFacts({ now, worker }: { readonly worker: WorkerView; readonly now:
); } + +function Gauged(props: { + readonly figure: ReactNode; + readonly gauge?: number | undefined; + readonly tone?: Tone; +}) { + const { figure, gauge, tone = "idle" } = props; + return ( + + {figure} + {gauge === undefined ? null : } + + ); +} + +// why: a gauge is never the only place its number is -- the figure above it is the text, so it +// carries no accessible name of its own (`aria-hidden`) and takes no focus. Its colour comes from +// the stylesheet by class, so both themes apply; only its level arrives inline, which the +// Content-Security-Policy allows (`style-src` governs style elements, not style attributes). +function LoadGauge(props: { readonly percent: number; readonly tone: Tone }) { + const { percent, tone } = props; + return ( +