Skip to content

The console draws each worker's load as gauges on its detail page #569

Closed

Description

@V3RON

Part of #527.

Scope

The worker's detail page in the console shows its latest CPU, memory and disk readings as gauges (bars) under the figures #534 prints, in the manner of macOS Activity Monitor's summary panels, instead of text alone.

In short

Today the detail page prints the load as text — 34% busy, 10 cores, 9.8 GiB of 32.0 GiB available, 112.4 GiB of 494.4 GiB free (F1). After this task each of those three figures also has a bar under it showing how full it is, coloured with the console's status colours (the memory pressure level colours the memory gauge on macOS). Only the latest reading is drawn: there is no history and no graph over time (a non-goal of #527, ADR 0028). If this goes wrong, a gauge shows a stale or empty bar for a figure that is unavailable.

Requested by the maintainer in the delivery session of #527 (option 1 of "gauges now, history later").

flowchart LR
  W["worker reads its load<br/>every 15 s"] --> V["the worker view's<br/>load block"]
  V --> P["detail page's Load panel"]
  P --> T["figures as text"]
  P --> G["gauges under the figures *"]
Loading

Technical spec

Facts

# Fact Source
F1 The Load panel prints CPU, Memory, Swap, Pressure (only where the view has it), Disk and Read; the CPU, Memory and Disk cells are text alone origin/main:ui/src/views/worker-detail.tsx:325-389 (LoadFacts)
F2 The worker's card prints the same load as one line of text and is drawn by WorkerFacts, a different component from the detail panel origin/main:ui/src/views/load-model.ts:33 (loadSummary), used at ui/src/views/worker-facts.tsx:92
F3 Nothing the console reads gives a threshold for CPU or disk. The load block's figures are busyPercent, cores, totalBytes, availableBytes, freeBytes, swapUsedBytes, pressure and limited (plus readAt, which the panel prints as its own row); the one disk threshold in the product (diskPressure.freeBytesThreshold) is daemon config and reaches no view origin/main:src/contract/schemas.ts:511-523 (the three load shapes), :883 (diskPressure); no hit for diskPressure under ui/src
F4 Memory pressure is the one level the worker sends. Only the macOS probe sets it (darwin reads kern.memorystatus_vm_pressure_level); the Linux readers return no pressure at all, limited or not. The console already maps it to a tone: normal ok, warn warn, critical error, an unknown word idle origin/main:src/ports/host-load.ts:95,113,117, :187-207 (machineMemory), :328-344 (#limitedMemory); origin/main:ui/src/status.tsx:32,51
F5 Only busyPercent is bounded (0–100) on the wire. A memory or disk pair is two independent non-negative numbers — nothing requires availableBytes ≤ totalBytes or freeBytes ≤ totalBytes, fitHostLoad drops a part only when it breaks its own shape, and nothing in the console validates a view at run time — the only schema references under ui/src are import type of its types, which erase origin/main:src/contract/schemas.ts:510-523 (loadNumber, loadCpuSchema, loadMemorySchema, loadDiskSchema), :549-563 (fitHostLoad); origin/main:ui/src/views/workers-model.ts:9, leases-model.ts:10, waiting-model.ts:8 are the only schema imports under ui/src and all three are import type; no zod value import, and no safeParse or schema .parse( call, exists under ui/src (the .parse( hits there are JSON.parse and Date.parse)
F6 Two of the three readers bound available themselves: macOS takes availableBytes = totalBytes × level / 100 with level refused above 100, and the cgroup reader takes max(0, limit − used) against that same limit as its total. The Linux machine reader returns MemAvailable as the kernel reports it, bounded by nothing in this code origin/main:src/ports/host-load.ts:93,97, :339-342, and :191,202-207 (machineMemory)
F7 A gauge's level moves once every 15 seconds, not every second: the worker samples on a 15 s tick, and the console refetches every 1 s origin/main:src/host-load/sampler.ts:32 (SAMPLE_INTERVAL_MS), origin/main:ui/src/live/connection.ts:31 (POLL_INTERVAL_MS)
F8 A CPU figure needs the sampler's second reading, so on a freshly started worker the CPU row says Unavailable for at least one interval after Memory and Disk already have numbers origin/main:src/host-load/sampler.ts:153-155 (the CPU part is kept only when busyPercent answers) and :178-180 (busyPercent answers nothing without a previous sample)
F9 The console's Content-Security-Policy says style-src 'self'. It refuses an injected <style> element and applies a style attribute: in the running console a bar carrying style="--load-bar: 46%" painted a fill 147 px of its 320 px track (expected 147) origin/main:src/http/server.ts:25; the check ran against a live daemon (Chromium, 2026-10-14): CSP: … style-src 'self' … and {"percent":"46","barWidth":320,"fillWidth":147,"expected":147}
F10 Recharts draws nothing where the 12 Load tests already look. renderToStaticMarkup of a RadialBarChart with one bar returned 123 characters, …<div class="recharts-wrapper" …></div>, and no <svg> — so a library gauge's shape and fill could only be asserted in the browser lane, while a plain CSS bar's markup can be asserted where the Load tests already are the check ran in this repo on 2026-10-14 (pnpm exec vitest run --project unit, one throwaway spec asserting on that string)
F11 Moving the bar by its width costs 0.13 ms of layout per change and no long task. Over 600 level changes on three bars, one per animation frame — 15× busier than F7's real rate — transition: width counted 614 layouts, 0.132 ms layout and 0.827 ms of task time per frame; transition: transform on scaleX() counted 7 layouts, 0.02 ms layout and 0.900 ms of task time. Chromium, from Chrome's own Performance.getMetrics counters; Firefox and WebKit were not installed on the machine, so the two were not compared there a throwaway Playwright harness + Chrome CDP Performance.getMetrics (not committed) printed: width+anim {"layouts":614,"layoutMsPerFrame":0.132,"styleMsPerFrame":0.24,"taskMsPerFrame":0.827} and transform+anim {"layouts":7,"layoutMsPerFrame":0.02,"styleMsPerFrame":0.373,"taskMsPerFrame":0.9}
F12 --duration is 120ms, and prefers-reduced-motion: reduce already sets it to 0ms for the whole document, so a transition written against --duration stops by itself. Verified in the built console: the fill's computed transition is width 0.12s, its width eased 66 48 34 25 20 17 16 16 px over eight frames after a level change, and under emulated reduce the duration reads 0s origin/main:ui/src/styles.css:31, :1224-1227; the checks ran on 2026-10-14 against the built console in Chromium (throwaway harness, not committed)
F13 A fact row is 8rem of label beside minmax(0, 1fr) of value; --field is the console's recessed-track colour and --status-idle the neutral tone, each with a dark value origin/main:ui/src/styles.css:617-621 (.facts > div), :36,58 (--field), :51,73 (--status-idle)
F14 DESIGN.md bans motion today — "Nothing slides, fades or bounces, charts included" — and requires square corners and that a status always show its word with colour only as a second signal origin/main:docs/internal/DESIGN.md:208-212, :101, :85
F15 The console lane already refuses a horizontal page scroll at 360px on every view including a worker's page, and runs an axe WCAG AA pass over every page in both themes origin/main:e2e/console/views.spec.ts:76, origin/main:e2e/console/long-lists.spec.ts:815-830
F16 The lane reaches a fact's value with fact(scope, label). Its colour rule is a separate test — "every status shows a word with its colour, and none uses the accent colour" — whose expectStatusColours reads each .status element's background against the four resolved status tokens, and which visits a worker's detail page origin/main:e2e/console/workers.spec.ts:119-121 (fact), :163-205 (readStatuses, expectStatusColours), :545,573 (the test, and its detail-page pass)
F17 Playwright documents page.emulateMedia({ reducedMotion: "reduce" }) for all three engines the lane runs, and it was verified working in Chromium only on the machine this spec was written (the lane's Firefox and WebKit binaries are not installed, and fetching them is a multi-GB download) Playwright 1.63 Page.emulateMedia reducedMotion argument (node_modules/.pnpm/playwright-core@1.63.0); a throwaway Playwright harness (not committed) printed chromium: default 0.12s -> reduce 0s and "Executable doesn't exist" for the other two
F18 The figure above a gauge rounds half up to a whole percent: formatBusyPercent is roundHalfUp(percent, 0), so 33.5 prints 34% and 33.4 prints 33%. A gauge's percent is whole for the same reason, and rounds identically origin/main:ui/src/views/load-model.ts:8-11 (roundHalfUp), :24-26 (formatBusyPercent), asserted at origin/main:ui/src/views/load-model.test.ts:14-15

The gauge

One shape, drawn in CSS: an 8px track as wide as the fact row's value column (F13), capped at 20rem — the column is minmax(0, 1fr), so uncapped it would stretch to the panel's whole width and turn one line of text into a band — with a fill whose width is that column's percent. It sits in a line of its own under the figure it belongs to, left-aligned with it, so the figure keeps its own line and neither the label column nor the text reflows.

flowchart LR
  L["the view's load block"] --> LM["views/load-model.ts *<br/>loadGauges"]
  LM --> WD["views/worker-detail.ts *<br/>LoadFacts"]
  WD --> ST["status.tsx<br/>pressureStatus"]
  WD --> CSS["styles.css *<br/>the bar"]
Loading
  • ui/src/views/load-model.ts gains loadGauges(load) (F2). It answers the fullness of CPU, memory and disk as whole 0–100 percents, and nothing for a figure the reading has no denominator for. Whole percents, rounded as the figure above it rounds (F18). Clamped to 0–100, because F5 leaves an odd memory or disk pair possible and the console revalidates no view: a reading that lands outside the bar is drawn at its end rather than past it, and a figure whose total is zero gets no gauge at all rather than Infinity%. It stays a pure function in the module the panel already reads, so the arithmetic has a test without a browser.
  • LoadFacts draws the gauge under each of its CPU, Memory and Disk figures (F1), and leaves Swap, Pressure and Read exactly as they are. The card's row is a different component and does not change (F2).
  • Colour. The bar's own classes are load-bar and load-bar-ok/-warn/-error/-idle. It must not reuse .status or .status-*: a .status is a word with a colour and the lane checks that every .status on a worker's page shows a non-empty word in its tone's colour (F16), and a bar has no word (F14). Memory takes the tone its pressure already prints (F4); CPU and disk stay --status-idle. The console takes thresholds from the worker and invents none, and F3 is the search that says there are none to take — a red CPU bar would mean something no line of this product says. Where memory has no pressure, which is every Linux worker (F4), its gauge is idle too, and the colour says nothing the words do not.
  • Level inline, colour by class. The percent arrives as a --load-bar custom property on the bar; every colour comes from styles.css by tone class (F9, F13). That is the shape ADR 0011 §4 asks of anything drawn, and the reason styles.css cannot hold the level: it does not know the reading.
  • Motion. transition: width var(--duration) on the fill. F11 is the measurement that says width is cheap enough that the compositor-only alternative is not worth its scaleX() trick, and F7 is why it barely moves at all: once every 15 seconds. --duration is 120ms and prefers-reduced-motion: reduce zeroes it document-wide, so reduced motion needs nothing new (F12). F14 forbids this today, so this change amends DESIGN.md's Motion section to say what it now allows, in the same PR.
  • A gauge is never the only place its number is. It carries aria-hidden, takes no focus and has no accessible name: the figure above it is the text, in the same cell. That keeps the rule F14 states — a status shows a word — true without saying 69% twice to a screen reader, and keeps axe off the bar, which is a shape and not text.

Contract and event changes

None. The gauge reads the load block #533 already puts in the worker view and sends nothing back (F5). No event is new or changed.

Other code on the same state

  • WorkerFacts' Load row (F2) reads the same load block and prints one line of text. It keeps doing that: this task's Scope is the detail page. If both were to draw gauges, loadGauges would be the one place either takes a percent from, which it is.
  • attention-model.ts reads worker views for the RAM budget item and reads no load, and this task adds no attention item for load (F3: there is no threshold to warn at). Unchanged.
  • workers-model.ts workersStats and busiestWorkers read the same views. Unchanged.
  • pressureStatus (F4) is read by both the Pressure row's <Status> and the memory gauge. Both take the tone from it, so a pressure of warn colours the dot and the bar the same way in one read of the same field; neither one wins over the other because they cannot disagree.
  • The 12 Load tests in ui/src/views/worker-detail.test.tsx (402-559) assert on the panel's text through loadPanel(). A gauge is a sibling of that text inside the same cell, so every one of them stays green and none needs narrowing.

Rules in play

  • docs/internal/agent-rules/coding.md — the body is the spec; a gap is an Assumption: line.
  • docs/internal/agent-rules/testing.md — the unit seam cannot see a painted width (F10), so the tests split by what each can actually prove.
  • docs/internal/agent-rules/documentation.md — docs/CONSOLE.md and docs/internal/DESIGN.md are both changed by this and both are written for their own reader.
  • docs/internal/DESIGN.md — Status colours, Shape and Motion (F14). Motion is amended by this task, not worked around.

Tests

The seams are loadGauges directly, WorkerDetail through renderToStaticMarkup with a now prop (where the 12 Load tests already sit), and the browser lane for anything painted. Model them on ui/src/views/worker-detail.test.tsx and e2e/console/workers.spec.ts ("on a single host, a card shows its Load row and the detail shows the Load panel with an age that ticks").

  • loadGauges answers each figure's fullness as a whole percent — 34 busy, 9.8 of 32.0 GiB available, 112.4 of 494.4 GiB free answers 34, 69, 77
  • a gauge's percent rounds the same way the busy share above it does, so a CPU reading of 34.4 draws a 34 under the words 34% busy
  • loadGauges clamps both ends. Full: 137% busy answers 100; negative free bytes answer 100; negative available answers 100. Empty: −4% busy answers 0; free equal to total answers 0; available above total answers 0 — used there is negative, so the bar is empty, never full
  • loadGauges answers nothing for a figure with no denominator: a zero memory or disk total, or a part the reading does not carry
  • the Load panel draws one gauge in each of its CPU, Memory and Disk rows and none in Swap, Pressure or Read
  • the Load panel draws no gauge in a row that says Unavailable, and prints a figure whose total is zero with its number and no gauge
  • the Load panel colours the memory gauge with its pressure — normal, warn and critical ok, warn and error — and idle where the view has no pressure
  • the Load panel keeps the CPU and disk gauges idle at any level, including a full disk
  • a gauge carries no accessible name, so the panel says each figure's number once
  • the Load panel draws no gauge at all where it says Not reported. or No reading while disconnected., since those two answer instead of drawing figures
  • the worker's card prints its Load row as one line of text with no gauge, as today

In the browser lane:

  • a worker's detail draws a bar under each of its CPU, memory and disk figures that the reading carries, and each fill's width is that figure's percent of its track, within a pixel — measured against simlock worker list --json beside it, retrying until both the reading and the fill have settled: the reading turns over every 15 s (F7) and the fill eases for 120 ms (F12), so a measurement taken in either window compares a bar to the wrong figure
  • a gauge's track is capped at 20rem rather than filling its value column, and the bar sits under its figure, left-aligned with it, not beside it — measured on a page wide enough that the two differ
  • each fill's colour is its own tone class's status token, resolved in the page, and never the accent, in both themes — and all four tones are painted by the real component in both themes: the test answers the page's own read of /v1/workers with memory.pressure set to normal, warn and critical in turn and once removed, every other field left as the daemon sent it, so no tone rule survives being deleted, swapped, dark-only overridden, or painted with the accent
  • a fill's computed transition is its width over the document's --duration, in every browser the lane runs, and zero under emulated prefers-reduced-motion: reduce — the second half guarded to Chromium, the only engine F17 verified, the way the lane's keyboard tests already branch on browserName

A live Linux worker still never reports a pressure (F4), so the lane's pressure levels arrive through that route rather than through the kernel — the page's own read of the route it reads, one field changed. Which pressure reaches which tone class remains the unit test's claim as well; the lane now proves the whole chain in both themes without a macOS-only machine.

Done when

  • Every test above is green.
  • pnpm test:console --project=chromium is green: the diff touches ui/.
  • On this machine (macOS, so the worker reports pressure), a worker's page shows a bar under CPU, memory and disk; the memory bar is the same colour as its normal pressure square; the other two are neutral. warn and critical are covered in the lane by the route above — the level is the kernel's own and no command arranges them on a healthy machine.
  • The fill's transition is its width over --duration, and 0s under reduced motion: the lane test named above. A 120 ms ease is not something to settle by eye.
  • docs/CONSOLE.md and docs/internal/DESIGN.md say what the panel and the design guide now do, with no claim left false about the old behaviour.

Out of scope

  • A gauge on the worker's card, or anywhere but the detail page's Load panel.
  • A gauge for swap. The wire sends swapUsedBytes and no swap total (F5), so there is nothing to divide by, and an invented one would be a wire change.
  • A CPU or disk threshold, or any colour those two could earn from a level (F3). That is a wire change of its own.
  • A graph over time, or any history kept by the worker or gateway.
  • Any change to the wire shape or the protocol.

Depends on

Written by an agent.

Metadata

Metadata

Assignees

No one assigned

    Labels

    task:draftScope written; technical spec or its spec check missing.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions