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 <dl> grammar.
This commit is contained in:
2026-07-25 19:35:44 +02:00
parent 3ffcf4314b
commit 612e37844f
3 changed files with 217 additions and 16 deletions
+1 -1
View File
@@ -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.
+88 -15
View File
@@ -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 `<dl>` grammar at the page's plain body
size — one `<dt>`/`<dd>` pair per fact. VMware and Runners used to be a
bordered `<table>` 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 `<dt>{{
unit }}</dt><dd>{{ badge }} {{ sub_state }}</dd>` instead of a table row —
so no reading was dropped, and no card in this row uses `<table>` 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 `<dt>` to label it), and the load average
is the CPU gauge's sub-line. Five `<dt>` 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 `<br>`; 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
@@ -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 `<dl>` 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
`<dl>`, 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 `<dl>` it has no
`<dt>`, 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 `<dl>` at the page's plain 15px body text.
VMware and Runners were a bordered `<table>` 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 `<dl>`: component/unit name as `<dt>`, the status badge plus its
sub-state sharing one `<dd>` (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 `<tr>` 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 `<dt>`s they replaced),
`count("<dt>") == 5`, uptime in `.head-meta` and not in a `<dt>`, the load
average with no `<dt>` 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.