feat(web): Executions card replaces Live VMs

The dashboard could only show what was powered on this instant, so it could
not answer "did the last build pass?" — and no local execution history exists
to answer it from (`report job` reads a PowerShell-era JSONL path that nothing
writes on this host, and the recent artifact dirs are empty).

Recent runs now come from Gitea's Actions API (`operations/executions.py`):
`/api/v1/admin/actions/runs` for every repo on the instance, falling back to
`/api/v1/user/actions/runs` when the token is not admin-scoped. One list call
per refresh, cached 10s, so the card's 5s htmx poll costs Gitea at most one
call per window. Live VMs are grouped under the run that spawned them — the
job id is `{run_id}-{run_attempt}[-suffix]` and the clone name carries it — so
a matrix run collects several VMs on one row. Every failure mode (no token,
401, unreachable Gitea, malformed payload) degrades to a notice plus the plain
VM rows, each keeping its Remove button, so the card is never less useful than
the table it replaces. Orphans stay where they were: the sweep in Maintenance,
the count in the status-header tile.

Auth reuses the access token from the operator's own OIDC login, which
`auth.py` previously discarded. SECURITY: that token is kept server-side only
(`app.state.gitea_tokens`, dropped on logout) and never written into the
session — `SessionMiddleware` signs the session cookie but does not encrypt
it, and this service is deployed over plain HTTP on the LAN, so a bearer token
in there would be readable in the browser and on the wire. An expired token is
refreshed once; `[web] gitea_token_cred` names an optional keyring PAT for the
cases a login token cannot cover (service restart, non-admin operator).

`GET /vms` and `partials/vms.html` are gone; `POST /vms/{id}/remove` and
`POST /vms/cleanup` are untouched.

Design: docs/superpowers/specs/2026-07-25-executions-card-design.md
This commit is contained in:
2026-07-25 14:55:27 +02:00
parent 0635236391
commit af7dee60e5
20 changed files with 2136 additions and 109 deletions
+65 -9
View File
@@ -1,10 +1,11 @@
# Web UI — Management Dashboard
Server-rendered dashboard (`ci web serve`) for the local CI/CD system: live
VMs, 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 the CLI.
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
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
the CLI.
See `plans/web-ui-management-plan.md` (steps W0–W7) for the design history
and `config.example.toml`'s `[web]` / `[web.oidc]` section for every
@@ -105,6 +106,11 @@ 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
`[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
again); existing sessions keep working with the old scopes until then.
---
@@ -123,8 +129,10 @@ systemctl status ci-web.service
journalctl -u ci-web.service -f
journalctl -u ci-web.service -n 100 --no-pager
# Health check (unauthenticated, always 200)
curl -s http://127.0.0.1:8800/healthz
# Health check (unauthenticated, always 200). Use the address the service is
# bound to -- uvicorn listens on `[web].bind_host` only, so on this host
# (192.168.10.11) a loopback request gets connection-refused, not a 200.
curl -s http://192.168.10.11:8800/healthz
```
Once code changes: `sudo bash setup-web-linux.sh --host <same-as-before>`
@@ -239,7 +247,7 @@ allow_stop}` tables. A malformed row is skipped, never crashes startup.
- **`vmware-host` stop/restart interrupts running jobs.** The confirm step
shows the current count of active VMs ("N active VM(s) — this interrupts
running jobs") when stopping or restarting `vmware-host.service`, computed
from the same VM backend the Live VMs panel uses — best-effort, degrades
from the same VM backend the Executions card uses — best-effort, degrades
to no warning if the backend is unavailable, never blocks the action.
- **Every action is audited exactly once per stage** (`service.start` /
`service.stop` / `service.restart`, `target` = the unit name), same as
@@ -272,7 +280,55 @@ on the live host with `sudo visudo -c` before relying on the panel.
---
## 8. Troubleshooting
## 8. 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 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
non-admin fallback. `[web].executions_limit` (default 15) caps how many.
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.
**Where the API token comes from.** Normally nowhere extra: the dashboard
reuses the access token from your own OIDC login, so what the card shows is
exactly what your Gitea account can see. That token is kept **server-side
only** (an in-process map keyed by username) and dropped on logout — it is
never written into the session cookie, which is signed but *not* encrypted and
travels over plain HTTP on the LAN. An expired token is refreshed once
automatically; a refusal that refresh cannot fix shows a "log in again" link
in the card.
Optional fallback: store a Gitea PAT in the keyring and name it in
`[web].gitea_token_cred`. It is used only when no logged-in token is
available — after a service restart (which empties the in-process map while
session cookies stay valid), or for an operator whose own token lacks the
scope. Set it exactly like the other secrets:
```bash
sudo -u ci-runner /opt/ci/venv/bin/python -m ci_orchestrator creds set \
--target GiteaApiToken --user gitea-api
```
**Degraded states** never take the card down: no token, an expired token, or
an unreachable Gitea each render a warning notice *and still list every
running VM* below it, so the dashboard is never less useful than the VM table
it replaced.
**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.
---
## 9. Troubleshooting
**Login loop (bounces back to `/login` after authenticating with Gitea)**
: The registered Redirect URI in Gitea does not exactly match