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:
@@ -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
@@ -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.
|
||||
Reference in New Issue
Block a user