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