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.
11 KiB
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 jobreadsF:\CI\Logs\<jobId>\invoke-ci.jsonl, a path written by the retired PowerShellInvoke-CIJob.ps1. Nothing writes it on this host;/var/lib/ci/logs/holds onlybenchmark.jsonlandweb-audit.jsonl.commands/job.pylogs to stdout only./var/lib/ci/artifacts/<run_id>-<attempt>/exists per job but the recent ones are empty — not evenmanifest.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:
[web.oidc] scopesgains the API read scopes (read:repository, plusread:adminfor the cross-repo admin endpoint). Existing sessions keep working; the new scopes take effect after one re-login (consent screen).- The callback keeps
access_token,refresh_tokenandexpires_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.
@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 ascommands/monitor.py:303; no new dependency). - Base URL from
config.gitea_base_url(config.web). - Status mapping:
in_progress→running;waiting/queued/blocked→queued; otherwiseconclusion(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 ↗ owner/repo |
build-ns7zip.yml |
main a1b2c3d |
push · Simone |
● running | 02:41 | Clone_412-1_… 192.168.79.201 · up 2m |
Remove |
#<number>, plus.<attempt>only whenattempt > 1; links tohtml_urlwith the existing#i-externalicon. Repo intext-dimunderneath.- Commit shortened to 7 chars; duration via the existing
durationfilter. - 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-playin thepartials/icons.htmlsprite.
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;executionscontext key indashboard();executions_partial()servingGET /executions, registered withrequire_useralongside 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;logoutpops it. The session payload is unchanged (username only).partials/executions.htmlreplaces thepartials/vms.htmlinclude indashboard.html.partials/vms.htmland theGET /vmsroute are deleted;POST /vms/{id}/removeandPOST /vms/cleanupstay 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
Executionrows, field by field; 403on the admin endpoint falls back to/user/actions/runs(source == "user");- network error, HTTP 500, malformed JSON, absent token → degraded view with
errorset andunmatched_vmspopulated, 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 optionalgitea_token_credfallback and why the token is kept server-side, plus the degraded states. Also correct §4's health check, which uses127.0.0.1where nothing listens (the service binds192.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.htmllayout comment, theweb/row inCLAUDE.md, and aTODO.mdentry.
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.