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` |
|
| `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. |
|
| `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) |
|
| `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.
|
**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
|
# Web UI — Management Dashboard
|
||||||
|
|
||||||
Server-rendered dashboard (`ci web serve`) for the local CI/CD system: recent
|
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
|
status, plus confirm-gated destructive actions (VM remove/cleanup, cache
|
||||||
purge, template backup) with an audit trail. In-process only — it imports
|
purge, template backup) with an audit trail. In-process only — it imports
|
||||||
`operations/` and `backends.load_backend` directly and never shells out to
|
`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`.
|
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
|
**The allowlist is fail-closed**: an empty list denies everyone, it
|
||||||
never means "allow all".
|
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
|
`[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
|
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
|
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
|
address while `[web.oidc]` is absent — an unauthenticated dashboard that
|
||||||
can destroy VMs and purge caches must never be reachable from the LAN.
|
can destroy VMs and purge caches must never be reachable from the LAN.
|
||||||
Configure `[web.oidc]` first, or bind `127.0.0.1`.
|
Configure `[web.oidc]` first, or bind `127.0.0.1`.
|
||||||
- Every mutating route (VM remove/cleanup, cache purge, template backup) is
|
- Every mutating route (VM remove/cleanup, cache purge, template backup,
|
||||||
behind a synchronizer-token CSRF check and a two-step confirm gate (a
|
template delete, backup delete) is behind a synchronizer-token CSRF check
|
||||||
preview/dry-run, then an explicit "Confirm" that actually executes) —
|
and a two-step confirm gate (a preview/dry-run, then an explicit "Confirm"
|
||||||
there is no one-click destroy.
|
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
|
- Every mutating action appends exactly one entry to the audit log
|
||||||
(`[web].audit_log`, default `/var/lib/ci/logs/web-audit.jsonl`), viewable
|
(`[web].audit_log`, default `/var/lib/ci/logs/web-audit.jsonl`), viewable
|
||||||
at `/audit` in the dashboard.
|
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
|
The dashboard's top wide table is **Executions**: the most recent workflow
|
||||||
runs, the live ones included, each with its status, workflow, branch/commit,
|
runs, the live ones included, with the build VM(s) that run spawned (with
|
||||||
trigger, duration, and the build VM(s) that run spawned (with their `Remove`
|
their `Remove` button). It replaced the old read-only "Live VMs" table — a VM
|
||||||
button). It replaced the old read-only "Live VMs" table — a VM alone could
|
alone could never answer "did the last build pass?".
|
||||||
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
|
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
|
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
|
Gitea at most one call per 10s window; the VM columns are re-read on every
|
||||||
refresh.
|
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,
|
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
|
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 /
|
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
|
**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
|
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
|
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 **Maintenance** card ("Cleanup orphaned VMs"). That is the place to act on
|
||||||
the status-header VMs tile. Those two are the places to look at orphans; the
|
orphans; the Executions card only avoids hiding them.
|
||||||
Executions card only avoids hiding them.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Troubleshooting
|
## 10. Troubleshooting
|
||||||
|
|
||||||
**Login loop (bounces back to `/login` after authenticating with Gitea)**
|
**Login loop (bounces back to `/login` after authenticating with Gitea)**
|
||||||
: The registered Redirect URI in Gitea does not exactly match
|
: 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