docs(spec): Executions card design

Replaces the Live VMs card with a run-centric Executions card fed by the
Gitea Actions API, reusing the OIDC login token (kept server-side, never in
the unencrypted session cookie) with an optional keyring PAT fallback.
This commit is contained in:
2026-07-25 14:38:16 +02:00
parent 197719990d
commit 0635236391
@@ -0,0 +1,231 @@
# Executions card — design (2026-07-25)
Replace the dashboard's "Live VMs" card with an **Executions** card that lists
the most recent CI runs — including the ones running right now — each with its
status, and with the live build VM(s) attached to the run that spawned them.
## Problem
The dashboard shows only what is powered on *this instant*
(`partials/vms.html`): one row per running clone, job id scraped out of the
clone name. Anything that already finished is invisible, so the dashboard
cannot answer "did the last build pass?" — the user has to switch to Gitea.
No local execution history exists to draw on:
- `report job` reads `F:\CI\Logs\<jobId>\invoke-ci.jsonl`, a path written by
the retired PowerShell `Invoke-CIJob.ps1`. Nothing writes it on this host;
`/var/lib/ci/logs/` holds only `benchmark.jsonl` and `web-audit.jsonl`.
- `commands/job.py` logs to stdout only.
- `/var/lib/ci/artifacts/<run_id>-<attempt>/` exists per job but the recent
ones are empty — not even `manifest.json` — so it is not a usable ledger.
## Data source: Gitea Actions API
Gitea on this host is **1.26.2**, which exposes:
- `GET /api/v1/admin/actions/runs?limit=N` — every run across every repo in
one call (admin token required).
- `GET /api/v1/user/actions/runs?limit=N` — fallback for a non-admin token:
runs of the repos that user can see.
`ActionWorkflowRun` carries everything the card needs: `id`, `run_number`,
`run_attempt`, `status`, `conclusion`, `started_at`, `completed_at`,
`head_branch`, `head_sha`, `event`, `path`, `display_title`, `repository`,
`html_url`.
Per-run job/step detail (`/runs/{run}/jobs`) is deliberately **not** fetched:
one list call per refresh, no fan-out.
### Authentication: reuse the OIDC login token
`auth.py` already exchanges the OIDC code for a Gitea access token
(`authorize_access_token`, `auth.py:153`) and then discards it, keeping only
`session["user"]`. That token is valid for `/api/v1` as a bearer token, so no
new PAT is required. Two changes make it usable:
1. `[web.oidc] scopes` gains the API read scopes (`read:repository`, plus
`read:admin` for the cross-repo admin endpoint). Existing sessions keep
working; the new scopes take effect after one re-login (consent screen).
2. The callback keeps `access_token`, `refresh_token` and `expires_at`.
**Where the token is kept — security requirement.** `SessionMiddleware`
(`app.py:604`) stores the session in a *signed but unencrypted* cookie, and
this deployment serves plain HTTP on the LAN (`https_only=False`). A bearer
token placed in the session would therefore be readable in the browser cookie
and on the wire. So the token is **never** put in the session: it lives in a
server-side map on `app.state`, keyed by username (the allowlist is a handful
of users), and `logout` removes its entry. The cookie keeps carrying the
username alone, exactly as today.
Consequences, accepted:
- A service restart empties the map while the session cookie stays valid, so
the card degrades to "re-authenticate" until the next login.
- An optional PAT in the keyring (`[web] gitea_token_cred`, unset by default)
is used when no session token is available. This covers restarts and
non-admin users, and keeps a working path if the OAuth app cannot be given
API scopes.
- On `401`, one refresh attempt via the stored refresh token; if that fails,
the card degrades.
### Endpoint selection
Try `/admin/actions/runs`; on `403`/`404` fall back to `/user/actions/runs`.
The chosen source is reported in the view (`source: "admin" | "user"`) so the
template can say which scope the list covers.
## Module: `operations/executions.py`
Pure logic layer, same rules as its siblings: dataclasses only, no `click`, no
`print`, callable from both the CLI and the web UI.
```python
@dataclass(frozen=True)
class Execution:
run_id: int
number: int
attempt: int
workflow: str # basename of ``path``
repo: str # "owner/name"
branch: str
commit: str # head_sha, full; template shortens
event: str
actor: str
status: str # queued | running | success | failure
# | cancelled | skipped | unknown
started: datetime | None
completed: datetime | None
duration_sec: int | None
html_url: str
vms: list[LiveVm]
@dataclass(frozen=True)
class ExecutionsView:
runs: list[Execution]
unmatched_vms: list[LiveVm]
source: str # "admin" | "user" | "none"
error: str | None
```
`list_executions(config, vms, *, token, limit) -> ExecutionsView`
- HTTP via `urllib.request` (stdlib — same approach as
`commands/monitor.py:303`; no new dependency).
- Base URL from `config.gitea_base_url(config.web)`.
- Status mapping: `in_progress` → `running`; `waiting`/`queued`/`blocked` →
`queued`; otherwise `conclusion` (`success`, `failure`, `cancelled`,
`skipped`), anything unrecognised → `unknown`.
- `duration_sec`: `completed - started`; for a running run, `now - started`.
- **Never raises.** Network error, HTTP error, malformed JSON, missing token —
all degrade to `ExecutionsView(runs=[], unmatched_vms=vms, source="none",
error="…")`. The dashboard must never break because Gitea is unreachable.
- **10 s TTL cache** in-process, keyed by `(limit, token identity)`: htmx polls
every 5 s, Gitea sees at most one call per 10 s. VM data is read fresh on
every poll, so live rows stay live.
### VM ↔ run correlation
The job id is `{run_id}-{run_attempt}[-suffix]`
(`.gitea/actions/local-ci-build/action.yml:293`) and a clone is named
`Clone_<jobId>_<YYYYmmdd>_<HHMMSS>_<suffix>`
(`operations/vms.py:134`). So `parse_clone_name(vm.name).job_id` is matched
against `f"{run_id}-{run_attempt}"` as a prefix; a matrix run therefore
attaches several VMs to one row.
VMs that match no listed run (run older than the window, or a genuine orphan)
go to `unmatched_vms` and are rendered as trailing rows flagged `no run`, each
keeping its Remove button — otherwise the only per-VM action on the dashboard
would be lost. Orphan handling elsewhere is unchanged and not duplicated here:
the sweep stays in the Maintenance card
(`partials/maintenance.html:17`) and the count stays in the status-header VMs
tile (`partials/status_header.html:17`).
## Card
`partials/executions.html`, a `.card--wide` table titled **Executions**:
| Run | Workflow | Branch / commit | Trigger | Status | Duration | VM | Actions |
|---|---|---|---|---|---|---|---|
| `#412` ↗ <br><sub>owner/repo</sub> | `build-ns7zip.yml` | `main` `a1b2c3d` | `push` · Simone | ● running | 02:41 | `Clone_412-1_…` <br><sub>192.168.79.201 · up 2m</sub> | Remove |
- `#<number>`, plus `.<attempt>` only when `attempt > 1`; links to `html_url`
with the existing `#i-external` icon. Repo in `text-dim` underneath.
- Commit shortened to 7 chars; duration via the existing `duration` filter.
- One row per run; several VMs stack inside the VM cell, each with its own
Remove form (`/vms/{vmx_id}/remove`, unchanged).
- Card icon: a new `#i-play` in the `partials/icons.html` sprite.
**Badges.** Only `.badge-success`, `.badge-warn`, `.badge-danger` exist, so
`.badge-info` (accent) and `.badge-muted` (dim) are added to `app.css`.
Mapping: `queued` → muted, `running` → info, `success` → success, `failure` →
danger, `cancelled`/`skipped` → muted, `unknown` → muted. Never colour-only:
every badge pairs its colour with the status word, matching the health tiles.
Degraded states render as a `notice-warn` above the table, and the VM rows are
still listed, so the card is never less useful than today's:
- no token at all → "Gitea token not configured — showing running VMs only";
- expired/refused token → "Gitea session expired — re-login" with a link;
- transport error → the error text, truncated.
## Web wiring
- `web/app.py`: `_fetch_executions(config, vms, token)` helper in the style of
`_fetch_cache_status`, wrapped so it cannot raise; `executions` context key
in `dashboard()`; `executions_partial()` serving `GET /executions`,
registered with `require_user` alongside the other partial routes
(`app.py:561`). The token is read from the server-side map, falling back to
`_read_secret(config.web.gitea_token_cred)` when configured.
- `web/auth.py`: the callback stores the token triple in the map; `logout`
pops it. The session payload is unchanged (username only).
- `partials/executions.html` replaces the `partials/vms.html` include in
`dashboard.html`. `partials/vms.html` and the `GET /vms` route are deleted;
`POST /vms/{id}/remove` and `POST /vms/cleanup` stay exactly as they are.
- `web/filters.py`: `run_badge_class(status)`, registered like the rest.
- Poll: `hx-get="/executions" hx-trigger="every 5s, ci:refresh from:body"`,
the same cadence as the card it replaces.
- `config.py`: `WebConfig.gitea_token_cred: str | None = None`,
`WebConfig.executions_limit: int = 15`, both read from the `[web]` section.
## Testing
New `tests/python/test_operations_executions.py`, with `urlopen`
monkeypatched (no network in tests):
- admin payload parsed into `Execution` rows, field by field;
- `403` on the admin endpoint falls back to `/user/actions/runs`
(`source == "user"`);
- network error, HTTP 500, malformed JSON, absent token → degraded view with
`error` set and `unmatched_vms` populated, no exception;
- status/conclusion mapping table, including an unknown value;
- duration for finished vs running runs;
- VM correlation: single, matrix (several VMs on one run), unmatched;
- TTL cache: two calls within the window issue one HTTP request, and the
window expiring issues a second.
Extended web tests: the partial renders run rows and their VM sub-rows; the
degraded notice appears without a token while VM rows still render;
`/executions` requires login; `GET /vms` is gone (404); `logout` drops the
token from the map.
Gate: `ruff`, `mypy --strict`, coverage ≥ 90 % (currently 94 %).
## Documentation
- `docs/WEB-UI.md`: new section for the Executions card — required OAuth
scopes and the one-off re-login, the optional `gitea_token_cred` fallback
and why the token is kept server-side, plus the degraded states. Also
correct §4's health check, which uses `127.0.0.1` where nothing listens
(the service binds `192.168.10.11:8800`), and record that orphan VMs are
handled by the Maintenance sweep and the VMs tile, not inside the card.
- `config.example.toml`: `gitea_token_cred`, `executions_limit`, and the
scopes note in `[web.oidc]`.
- `dashboard.html` layout comment, the `web/` row in `CLAUDE.md`, and a
`TODO.md` entry.
## Out of scope
A twin CLI command (`ci report runs`), rerun/cancel actions from the UI, and
in-page job logs. The operations module stays CLI-callable, so the first of
these is cheap to add later.