Both renderings are pre-rendered server-side into a <button>'s
data-relative/data-absolute attributes; app.js swaps textContent (and the
hover title, to the opposite value) on click, delegated on document.body
so it survives the card's own 5s htmx poll without needing to re-wire
after every swap. Click again to swap back.
No cell stacks two facts on separate lines with <br> any more (Run/Repo,
Branch/Commit, Trigger/Actor, Duration/Started, VM/IP-Uptime each get their
own column, 8 -> 13 total). The card is already .card--wide with its own
.table-wrap, so the extra width scrolls horizontally rather than the page.
Status pills carry a check/x/play icon for the three outcomes that are a
real verdict or an in-progress state (success/failure/running); queued,
cancelled, skipped stay plain text. Every pill in the column -- including
the VM-only orphan/running pills on unmatched-VM rows -- is forced to the
same width with left-aligned content instead of a ragged column.
It was sitting in a strip below the heading with a "Show" label and a "newest
first" hint — three lines of chrome for one control. The select now lives at the
right end of the card's own header bar (`margin-left:auto` inside the flex h2),
compact and case-normal so it reads as a control and does not grow the bar.
Label and hint are gone; the header text names the card, the values speak for
themselves, and screen readers keep the `aria-label`.
Adds a "Show" selector (10 / 25 / 50 / 100) to the card header and drops the
default row count from 15 to 10. The chosen value rides on the card's own
polling URL (`/executions?limit=N`), so it survives the 5s htmx refresh with no
client-side state; a config default outside those four is rendered as an extra
option instead of disappearing. The query is parsed permissively — a stale or
hand-edited polling URL clamps or falls back to the config default rather than
422-ing a card that would otherwise render.
Ordering is now explicit rather than inherited from whatever Gitea returns:
newest first, a not-yet-started (queued) run above everything, ties broken on
Gitea's run id so matrix runs sharing a timestamp keep a stable order.
The login token took precedence unconditionally, so a configured keyring PAT
could never help while an operator was logged in — and on this instance the
OIDC token (scopes `openid profile`) is exactly what Gitea refuses with a 403
for insufficient API scope. `ExecutionsView` now carries `refused` (401 or a
final 403) and the web layer retries once with the PAT when the token it used
was not already the PAT. A refused PAT is never sent twice, and a 401 still
gets its single refresh attempt first.
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