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
+78 -11
View File
@@ -44,6 +44,7 @@ from ci_orchestrator.backends import load_backend
from ci_orchestrator.backends.errors import BackendError
from ci_orchestrator.config import gitea_base_url
from ci_orchestrator.operations.cache import CacheStatus, cargo_cache_status
from ci_orchestrator.operations.executions import ExecutionsView, list_executions
from ci_orchestrator.operations.host import (
DiskStatus,
HostResources,
@@ -64,7 +65,13 @@ from ci_orchestrator.operations.inventory import (
from ci_orchestrator.operations.services import ServiceState, service_states
from ci_orchestrator.operations.vms import LiveVm, list_live_vms
from ci_orchestrator.web import filters
from ci_orchestrator.web.auth import _read_secret, configure_auth, require_user
from ci_orchestrator.web.auth import (
_read_secret,
configure_auth,
gitea_token_for,
refresh_gitea_token,
require_user,
)
from ci_orchestrator.web.security import issue_csrf
if TYPE_CHECKING: # pragma: no cover
@@ -137,6 +144,56 @@ def _fetch_cache_status(config: Config) -> CacheStatus | None:
return None
def _pat_fallback(config: Config) -> str | None:
"""The optional keyring PAT for Gitea, or ``None`` when not configured.
Covers the cases the login token cannot: a process restart (which empties
the server-side token map while sessions stay valid) and a non-admin
operator. A missing/unreadable keyring entry is not an error -- the card
degrades to listing VMs.
"""
cred = config.web.gitea_token_cred
if not cred:
return None
try:
return _read_secret(cred)
except Exception: # degrade, never 500 over an optional secret
return None
async def _fetch_executions(
request: Request, config: Config, vms: list[LiveVm]
) -> ExecutionsView:
"""Recent Gitea runs with ``vms`` attached. Never raises.
Token precedence: the access token from this operator's own OIDC login
(held server-side, never in the cookie -- see ``web/auth.py``), then the
optional keyring PAT. A ``401`` triggers exactly one refresh attempt
before the card falls back to its degraded state.
"""
username = request.session.get("user")
stored = gitea_token_for(request.app, username)
if stored is not None and stored.expired and username:
stored = await refresh_gitea_token(request, str(username)) or stored
token = stored.access_token if stored is not None else _pat_fallback(config)
try:
view = list_executions(config, vms, token=token)
except Exception: # the operations layer already degrades; belt and braces
return ExecutionsView(
runs=[], unmatched_vms=vms, source="none", error="Executions unavailable."
)
if view.auth_failed and stored is not None and username:
refreshed = await refresh_gitea_token(request, str(username))
if refreshed is not None:
try:
return list_executions(config, vms, token=refreshed.access_token)
except Exception:
return view
return view
def _fetch_disk_status(config: Config) -> DiskStatus | None:
"""Disk usage for ``config.paths.root``, or ``None`` if unreadable."""
try:
@@ -368,10 +425,10 @@ def healthz() -> dict[str, str]:
return {"status": "ok"}
def dashboard(
async def dashboard(
request: Request, backend: VmBackend | None = Depends(get_backend)
) -> HTMLResponse:
"""Full dashboard: VM live, cache, disk, runner, and Stage-B info cards."""
"""Full dashboard: executions, cache, disk, runner, and Stage-B info cards."""
config: Config = request.app.state.config
templates: Jinja2Templates = request.app.state.templates
vms = _fetch_live_vms(backend, config)
@@ -383,6 +440,7 @@ def dashboard(
template_rows = _fetch_templates(config)
context = {
"vms": vms,
"executions": await _fetch_executions(request, config, vms),
"backend_available": backend_available,
"cache": cache,
"disk": disk,
@@ -406,18 +464,26 @@ def dashboard(
return templates.TemplateResponse(request, "dashboard.html", context)
def vms_partial(
async def executions_partial(
request: Request, backend: VmBackend | None = Depends(get_backend)
) -> HTMLResponse:
"""``GET /vms`` -- polled every 5s by the dashboard via htmx."""
"""``GET /executions`` -- polled every 5s by the dashboard via htmx.
Replaces the old ``GET /vms`` card: run-centric, with the live VMs grouped
under the run that spawned them. The runs come from a 10s-TTL cache
(``operations.executions``) while the VM data is read fresh on every poll,
so this cadence costs Gitea at most one API call per TTL window.
"""
config: Config = request.app.state.config
templates: Jinja2Templates = request.app.state.templates
vms = _fetch_live_vms(backend, config)
context = {
"vms": _fetch_live_vms(backend, config),
"vms": vms,
"executions": await _fetch_executions(request, config, vms),
"backend_available": backend is not None,
"csrf_token": issue_csrf(request),
}
return templates.TemplateResponse(request, "partials/vms.html", context)
return templates.TemplateResponse(request, "partials/executions.html", context)
def cache_partial(request: Request) -> HTMLResponse:
@@ -493,7 +559,7 @@ def services_partial(request: Request) -> HTMLResponse:
Needs a fresh ``csrf_token`` on every render (unlike the read-only
``host``/``vmware``/``runners`` cards) -- the panel's start/stop/restart
forms are mutating, like the per-row forms in
``vms_partial``/``templates_partial``/``files_partial``.
``executions_partial``/``templates_partial``/``files_partial``.
"""
config: Config = request.app.state.config
templates: Jinja2Templates = request.app.state.templates
@@ -558,9 +624,9 @@ def create_app(config: Config) -> FastAPI:
app.get("/healthz")(healthz)
app.get("/", response_class=HTMLResponse, dependencies=[Depends(require_user)])(dashboard)
app.get("/vms", response_class=HTMLResponse, dependencies=[Depends(require_user)])(
vms_partial
)
app.get(
"/executions", response_class=HTMLResponse, dependencies=[Depends(require_user)]
)(executions_partial)
app.get("/cache", response_class=HTMLResponse, dependencies=[Depends(require_user)])(
cache_partial
)
@@ -617,6 +683,7 @@ __all__ = [
"HealthSummary",
"create_app",
"dashboard",
"executions_partial",
"files_partial",
"get_backend",
"healthz",