# Dashboard card consolidation — design (2026-07-25) Collapse the dashboard's redundant first two rows: six status-header tiles plus four compact cards that restated them. **The cards win; the tile row is deleted** and each card becomes the single owner of its subject. ## Problem The rows said the same thing twice: | Datum | Tile | Card | Also | |---|---|---|---| | Runner up/down | `Runners 2/2` | `partials/runners.html` unit table | Services card, *with* start/stop/restart | | Cache size + entries | `Cache` | `partials/cache.html` | Maintenance card (purge) | | CPU cores | sub-line `/ N cores` | `partials/host.html` "CPU cores" | — | | RAM total | sub-line `/ N GB` | `partials/host.html` "Memory" | — | Runner state was in **three** places at once. Only three readouts were unique to the tiles — CPU%, RAM used, and the disk usage whose own card Stage E had already folded away — and only they needed a new home. ## Decision One card per subject, no header: | Card | Route | Poll | Owns | |---|---|---|---| | Host | `/host` | 5s | hostname, kernel, full CPU model, cores, **CPU% / RAM / disk + gauges**, uptime, load avg | | VMware modules | `/vmware` | 15s | `vmmon`, `vmnet`, `vmware-host.service` | | Runners | `/runners` | 5s | each `act-runner*` unit + sub-state | | Cargo cache | `/cache` | 5s | root, size, entries, oldest, retention | Retired: `partials/status_header.html` and its `HealthSummary` / `_health_summary`. The VM count and orphan count went with them — running VMs are rows in the Executions card and the orphan sweep is a Maintenance action, so neither needed a numeric readout of its own. Rejected alternative: keep the tiles as the state layer and merge the four cards into one "Platform" detail card. It splits every subject across two places (is the runner state a tile or a card?), and forces the header to start polling to avoid going stale. One owner per subject is simpler to keep honest. ## What survives from the tiles `_resource_levels(resources, disk) -> ResourceLevels` keeps the threshold logic and the `tile-*` class names, now driving the Host card's three gauges: - CPU / RAM: `> 75%` amber, `> 90%` red. - Disk: `> 70%` amber, `> 85%` red. - Any unreadable source: `unknown` (neutral), never a false green. **Health is never color-only** — the rule the tiles were built around. Every gauge is followed by `.resource-level`: the level word plus a check/alert icon, both keyed off the same class the gauge fill uses. ## New data `HostResources` gains `cpu_model` / `kernel` / `hostname`, read through the existing `_read_proc` seam but via `_read_proc_optional` — a missing `/proc/cpuinfo` must cost the CPU *name*, never the CPU *percentage* that shares the live-metric `try`. Each degrades to `""`, never `None`, so templates print them unguarded. `cpu_model` is `""` on architectures that don't emit `model name` (arm64), which is a normal outcome. ## Stage G — height parity across the four cards Folding the tiles in left the Host card at eleven `
` rows against its neighbours' two-to-five, and `.dashboard-grid` uses `align-items: stretch`: VMware, Runners and Cargo cache were stretched to Host's height and padded with ~200px of empty card. Same data, packed to five rows: | Was | Now | |---|---| | `Hostname`, `Kernel` (2 rows) | `System` — `ci-host · 7.0.0-28-generic` | | `CPU` (full model), `Cores` (2 rows) | `CPU` — `Intel Core i9-13900K · 16 cores` | | `Uptime` (1 row) | `.head-meta` at the right end of the card header | | `Load avg` (1 row) | dim sub-line under the CPU gauge | | level strip (word + check icon under each gauge) | the word moved into the gauge's `aria-label` | Nothing is dropped and no new grammar is introduced — the card is still a `
`, so it still reads like Cargo cache next to it. Three details the shape depends on: - **`.head-meta` must reset `text-transform`.** `.card h2` uppercases; an unreset header meta renders `26H 03M`. `.head-select` (the Executions row-count selector) already had to do this — same fix, same header slot. - **Uptime is spelled out** (`uptime 26h 03m`). Outside the `
` it has no `
`, and a bare duration in a header names nothing. - **Dropping the level strip does not make health color-only**, which is why it could go. The rule the tiles were built around was written when a tile showed a coloured bar and little else; the card states the number in text above every bar, so `42%` versus `92%` is already readable without seeing a colour. The word is kept for screen readers via `aria-label`, and the disk row prints `levels.disk_label` as its value when the probe fails and there is no gauge to attach it to. - **The CPU model is trimmed** through `cpu_short` — the raw ~40-char name wraps to three lines in a quarter-width track, which is the height being reclaimed. Full name on the row's `title`. This is the filter's first template caller: it was written for a status tile that was retired before it ever shipped. ## Stage H — one grammar for all four cards Host and Cargo cache were always a `
` at the page's plain 15px body text. VMware and Runners were a bordered `` at `0.9rem` (13.5px) with uppercase `0.75rem` (11.25px) column headers — two font scales and two visual grammars in the same row of four cards. Converted VMware and Runners to the same `
`: component/unit name as `
`, the status badge plus its sub-state sharing one `
` (the same "value + dim sub-text" shape Host's Disk row and Runners' own unit rows already used). No table, no header row, no per-row color tint (`tr.row-alert` dropped along with the `
` it targeted — the badge already carries the color, so the row no longer double-encodes it, matching how Host and Cargo cache never tinted a whole row either). ## Tests `tests/python/test_web_cards.py`: the `_resource_levels` threshold matrix, each card's contents (including the three Stage F additions and the three readouts inherited from the tiles), the "every gauge has a text level" rule, and the absence of any status header. Stage G pins the packed shape: the paired `System` / `CPU` rows (and the absence of the four `
`s they replaced), `count("
") == 5`, uptime in `.head-meta` and not in a `
`, the load average with no `
` of its own, the trimmed-model-plus-`title` pair, and the level word present in each gauge's `aria-label` while `resource-level` is absent from the markup — `id="status-header"` and `stat-tile` must not appear in the dashboard, and `/status-header` / `/platform` must 404.