From 612e37844ff2a80197cb8c57ffa7d9f466428eaa Mon Sep 17 00:00:00 2001 From: Simone Date: Sat, 25 Jul 2026 19:35:44 +0200 Subject: [PATCH] docs(web): document the card/executions/template-delete changes CLAUDE.md's web/app.py summary, WEB-UI.md's dashboard-layout and Executions sections, and a new design-history spec covering the tile removal, the compacted Host card, the dropped level-word strip, and the four cards' shared
grammar. --- CLAUDE.md | 2 +- docs/WEB-UI.md | 103 ++++++++++++-- .../specs/2026-07-25-card-consolidation.md | 128 ++++++++++++++++++ 3 files changed, 217 insertions(+), 16 deletions(-) create mode 100644 docs/superpowers/specs/2026-07-25-card-consolidation.md diff --git a/CLAUDE.md b/CLAUDE.md index 5706799..9741d23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,7 +94,7 @@ The host **never executes build tools directly** — it only orchestrates VM lif | `commands/report.py` | `report job` | | `operations/vms.py`, `cache.py`, `host.py`, `audit.py`, `executions.py` | Pure logic layer shared by the CLI and the web UI — dataclasses only, zero `click`/`print`. `commands/*` and `web/routes.py` both call these; never duplicate logic in either caller. | | `commands/web.py` | `web serve` — `ci web serve --host --port` launches the dashboard (`uvicorn`/`fastapi` imported lazily so the core CLI stays dep-light without the `[web]` extra) | -| `web/app.py`, `auth.py`, `routes.py`, `security.py` | FastAPI dashboard: recent-executions/cache/disk/runner view, Gitea OIDC login (Authlib, fail-closed allowlist), CSRF-protected confirm-gated mutating actions (VM remove/cleanup, cache purge, template backup), JSONL audit trail. In-process only — imports `operations/` + `backends.load_backend` directly, never shells the CLI. See `docs/WEB-UI.md`. | +| `web/app.py`, `auth.py`, `routes.py`, `security.py` | FastAPI dashboard: four info cards (Host with CPU/RAM/disk gauges, VMware modules, Runners, Cargo cache — no status-header tile row) + recent-executions view, Gitea OIDC login (Authlib, fail-closed allowlist), CSRF-protected confirm-gated mutating actions (VM remove/cleanup, cache purge, template backup), JSONL audit trail. In-process only — imports `operations/` + `backends.load_backend` directly, never shells the CLI. See `docs/WEB-UI.md`. | **Key design rule**: Application code (especially `commands/job.py`) must import `backends.load_backend` only — never `WorkstationVmrunBackend` directly. This keeps the Phase D ESXi extension path open. diff --git a/docs/WEB-UI.md b/docs/WEB-UI.md index a6a3431..570f730 100644 --- a/docs/WEB-UI.md +++ b/docs/WEB-UI.md @@ -1,7 +1,7 @@ # Web UI — Management Dashboard Server-rendered dashboard (`ci web serve`) for the local CI/CD system: recent -CI executions with their live VMs (§8), cargo cache, disk, and act_runner +CI executions with their live VMs (§9), cargo cache, disk, and act_runner status, plus confirm-gated destructive actions (VM remove/cleanup, cache purge, template backup) with an audit trail. In-process only — it imports `operations/` and `backends.load_backend` directly and never shells out to @@ -106,7 +106,7 @@ application **before** enabling `[web.oidc]`: 5. Add the Gitea username(s) allowed to log in to `[web].allowed_users`. **The allowlist is fail-closed**: an empty list denies everyone, it never means "allow all". -6. For the Executions card (§8), add an API read scope to +6. For the Executions card (§9), add an API read scope to `[web.oidc].scopes` — `read:repository`, plus `read:admin` if the account is a Gitea admin and you want runs from every repo on the instance. New scopes take effect after one fresh login (Gitea shows the consent screen @@ -152,10 +152,15 @@ reinstalls the `[web]` extra and restarts the service in one step (steps address while `[web.oidc]` is absent — an unauthenticated dashboard that can destroy VMs and purge caches must never be reachable from the LAN. Configure `[web.oidc]` first, or bind `127.0.0.1`. -- Every mutating route (VM remove/cleanup, cache purge, template backup) is - behind a synchronizer-token CSRF check and a two-step confirm gate (a - preview/dry-run, then an explicit "Confirm" that actually executes) — - there is no one-click destroy. +- Every mutating route (VM remove/cleanup, cache purge, template backup, + template delete, backup delete) is behind a synchronizer-token CSRF check + and a two-step confirm gate (a preview/dry-run, then an explicit "Confirm" + that actually executes) — there is no one-click destroy. +- **Template delete has no backup-required safety rail.** It removes the + entire template directory — the golden image VM clones are made from, not + a single backup archive — behind the same confirm gate as everything else, + but nothing stops you from deleting a template with zero backups. Use the + adjacent Backup button first if you want a way back. - Every mutating action appends exactly one entry to the audit log (`[web].audit_log`, default `/var/lib/ci/logs/web-audit.jsonl`), viewable at `/audit` in the dashboard. @@ -280,13 +285,73 @@ on the live host with `sudo visudo -c` before relying on the panel. --- -## 8. Executions card (recent Gitea Actions runs) +## 8. Dashboard layout — one card per subject, no tile row + +The dashboard opens with four compact info cards, each the **only** place its +subject is reported: + +| Card | Route | Refresh | Contents | +|---|---|---|---| +| **Host** | `GET /host` | 5s | `System` (hostname · kernel), `CPU` (model · cores), CPU / RAM / disk with gauges; uptime in the header bar, load average under the CPU gauge | +| **VMware modules** | `GET /vmware` | 15s | `vmmon`, `vmnet`, `vmware-host.service` | +| **Runners** | `GET /runners` | 5s | each `act-runner*` unit with its sub-state | +| **Cargo cache** | `GET /cache` | 5s | root, total size, entries, oldest entry, retention | + +Then the wide tables: Executions (§9), Templates, Artifacts, Services, +Maintenance. + +All four info cards render the same `
` grammar at the page's plain body +size — one `
`/`
` pair per fact. VMware and Runners used to be a +bordered `` at a smaller font (`0.9rem` body text, uppercase `0.75rem` +column headers), which read as a visibly different style sitting next to +Host and Cargo cache's plain label/value lists. They carry the same two data +points as before per row (a status badge, a sub-state) — just as `
{{ +unit }}
{{ badge }} {{ sub_state }}
` instead of a table row — +so no reading was dropped, and no card in this row uses `
` any more. + +There is **no status-header tile row**. It used to sit above these cards +showing VMs / Runners / CPU / RAM / Disk / Cache, which meant runner state was +displayed in three places at once (tile, Runners card, Services card) and the +core count, RAM total, cache size and entry count in two. The readouts that +only the tiles had — CPU%, RAM used, disk used/free — moved into the Host card, +where the rest of the host data already was. The VM count went away with the +tiles; running VMs are listed in the Executions card, and the orphan sweep is +in Maintenance. + +The Host card carries the most data of the four, so it is packed to match +their height: the near-static facts pair up (`System`, `CPU`), uptime rides the +header bar (spelled out — it has no `
` to label it), and the load average +is the CPU gauge's sub-line. Five `
` rows total. The CPU model is trimmed +by the `cpu_short` filter (`Intel(R) Core(TM) i9-13900K CPU @ 3.00GHz` → +`Intel Core i9-13900K`); the untrimmed name is the `title` of that row. + +**Health does not depend on color.** Each gauge states its value as text +directly above the bar (`42%`, `20.0 / 62 GB`, `40% · 60 GB free`), and the +level word — `ok` / `elevated` / `critical` / `unknown` — is part of the +gauge's `aria-label`, so a screen reader announces it. There is no visible +level strip: the words repeated what the numbers already said on every row. +Thresholds behind the fill colour and the announced word: CPU and RAM `> 75%` +amber, `> 90%` red; disk `> 70%` amber, `> 85%` red; a source that could not +be read is a neutral `unknown`, never a false green — and when the disk probe +fails outright, `unknown` is printed as the row's value, since there is no +gauge left to carry it. + +--- + +## 9. Executions card (recent Gitea Actions runs) The dashboard's top wide table is **Executions**: the most recent workflow -runs, the live ones included, each with its status, workflow, branch/commit, -trigger, duration, and the build VM(s) that run spawned (with their `Remove` -button). It replaced the old read-only "Live VMs" table — a VM alone could -never answer "did the last build pass?". +runs, the live ones included, with the build VM(s) that run spawned (with +their `Remove` button). It replaced the old read-only "Live VMs" table — a VM +alone could never answer "did the last build pass?". + +Every fact is its own column — Run, Repo, Workflow, Branch, Commit, Trigger, +Actor, Status, Duration, Started, VM, IP / Uptime, Actions. No cell stacks two +facts on separate lines with `
`; a run's repo, the commit hash, who +triggered it, and when it started each get their own column instead. The +table is wide as a result — 13 columns — and scrolls horizontally inside the +card's own `.table-wrap` rather than the page, same as the other wide tables +(Templates, Services). Runs come from Gitea's Actions API: `/api/v1/admin/actions/runs` (every repo on the instance, admin token) with `/api/v1/user/actions/runs` as the @@ -294,6 +359,15 @@ non-admin fallback. Results are cached for 10s, so the card's 5s refresh costs Gitea at most one call per 10s window; the VM columns are re-read on every refresh. +The Status pill carries a check/✕ icon for the two outcomes that are a real +verdict (`success`/`failure`, via `run_badge_icon`); `queued`/`running`/ +`cancelled`/`skipped` stay plain text since they are not a pass/fail on the +run. Every pill in the column — outcome pills and the VM-only `orphan`/ +`running` pills on unmatched-VM rows — is forced to the same width +(`#executions-card td:nth-child(8)` — Status is the 8th column, see above — +sized to fit `cancelled`, the longest word) with left-aligned content, so the +column reads as a clean list instead of ragged pill widths. + Rows are ordered **newest first** (a queued run, which has no start time yet, sorts above everything; ties break on Gitea's run id). The row-count dropdown at the right end of the card's header bar picks how many to list — 10 / 25 / 50 / @@ -340,13 +414,12 @@ passes. Do not remove that header. **Orphaned VMs are not handled in this card.** A powered-on VM whose run is no longer in the window (or that no run ever claimed) keeps a row of its own flagged `no run`, with its `Remove` button — but the orphan *sweep* lives in -the **Maintenance** card ("Cleanup orphaned VMs") and the orphan *count* in -the status-header VMs tile. Those two are the places to look at orphans; the -Executions card only avoids hiding them. +the **Maintenance** card ("Cleanup orphaned VMs"). That is the place to act on +orphans; the Executions card only avoids hiding them. --- -## 9. Troubleshooting +## 10. Troubleshooting **Login loop (bounces back to `/login` after authenticating with Gitea)** : The registered Redirect URI in Gitea does not exactly match diff --git a/docs/superpowers/specs/2026-07-25-card-consolidation.md b/docs/superpowers/specs/2026-07-25-card-consolidation.md new file mode 100644 index 0000000..1492045 --- /dev/null +++ b/docs/superpowers/specs/2026-07-25-card-consolidation.md @@ -0,0 +1,128 @@ +# 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.