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:
@@ -0,0 +1,379 @@
|
||||
"""Pure Gitea-Actions "recent executions" operations shared by the CLI and web UI.
|
||||
|
||||
Feeds the dashboard's Executions card: the most recent workflow runs (the
|
||||
live ones included) with the build VM(s) each one spawned attached to its row.
|
||||
Zero ``click``, zero ``print`` — every call returns an :class:`ExecutionsView`.
|
||||
|
||||
Data source is the Gitea Actions API (1.24+):
|
||||
|
||||
* ``GET /api/v1/admin/actions/runs`` — every run across every repo in one
|
||||
call; needs an admin-scoped token.
|
||||
* ``GET /api/v1/user/actions/runs`` — the fallback when the token is not
|
||||
admin: the runs of the repos that user can see.
|
||||
|
||||
Per-run job/step detail (``/runs/{run}/jobs``) is deliberately not fetched:
|
||||
one list call per refresh, no fan-out.
|
||||
|
||||
**Never raises.** Missing token, unreachable host, HTTP error, malformed
|
||||
payload — each degrades to a view carrying ``error`` plus the live VMs, so a
|
||||
Gitea outage can never take the dashboard down with it. Results (errors
|
||||
included) are cached for :data:`_TTL_SEC`, so the card's 5s htmx poll costs at
|
||||
most one API call per TTL window; VM correlation is redone on every call, so
|
||||
the VM columns stay live even on a cache hit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ci_orchestrator.config import gitea_base_url
|
||||
from ci_orchestrator.operations.vms import parse_clone_name
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from ci_orchestrator.config import Config
|
||||
from ci_orchestrator.operations.vms import LiveVm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Short: the dashboard polls every 5s and a slow Gitea must not hold a
|
||||
# request open long enough to pile polls up on top of each other.
|
||||
_TIMEOUT_SEC = 6.0
|
||||
|
||||
# One API call per window at most, however many pollers ask.
|
||||
_TTL_SEC = 10.0
|
||||
|
||||
_RUNNING = "running"
|
||||
_QUEUED = "queued"
|
||||
_UNKNOWN = "unknown"
|
||||
|
||||
# Gitea's ``status`` field, for runs that have not reached a conclusion yet.
|
||||
_STATUS_MAP = {
|
||||
"in_progress": _RUNNING,
|
||||
"running": _RUNNING,
|
||||
"waiting": _QUEUED,
|
||||
"queued": _QUEUED,
|
||||
"blocked": _QUEUED,
|
||||
}
|
||||
# Its ``conclusion`` field once they have.
|
||||
_CONCLUSION_MAP = {
|
||||
"success": "success",
|
||||
"failure": "failure",
|
||||
"cancelled": "cancelled",
|
||||
"skipped": "skipped",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Execution:
|
||||
"""One workflow run, with the live VM(s) it spawned (if any)."""
|
||||
|
||||
run_id: int
|
||||
number: int
|
||||
attempt: int
|
||||
workflow: str
|
||||
repo: str
|
||||
branch: str
|
||||
commit: str
|
||||
event: str
|
||||
actor: str
|
||||
status: str
|
||||
started: datetime | None
|
||||
completed: datetime | None
|
||||
duration_sec: int | None
|
||||
html_url: str
|
||||
vms: list[LiveVm] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionsView:
|
||||
"""Everything the Executions card renders, degraded states included.
|
||||
|
||||
``unmatched_vms`` holds the running VMs that belong to no listed run — a
|
||||
run older than the window, or a genuine orphan. They are rendered as
|
||||
trailing rows so the dashboard never loses sight of a powered-on VM (nor
|
||||
of its Remove action) just because Gitea is unreachable.
|
||||
|
||||
``auth_failed`` distinguishes "the token was refused" (``401``) from any
|
||||
other failure, so the caller can try refreshing it once before giving up.
|
||||
"""
|
||||
|
||||
runs: list[Execution]
|
||||
unmatched_vms: list[LiveVm]
|
||||
source: str
|
||||
error: str | None
|
||||
auth_failed: bool = False
|
||||
|
||||
|
||||
def _monotonic() -> float:
|
||||
"""Indirection so tests can drive the TTL cache without sleeping."""
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
# key -> (fetched_at, runs, source, error, auth_failed)
|
||||
_CacheEntry = tuple[float, list[dict[str, Any]], str, str | None, bool]
|
||||
_CACHE: dict[tuple[str, str, int], _CacheEntry] = {}
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
"""Drop the TTL cache (used by tests and after a token change)."""
|
||||
_CACHE.clear()
|
||||
|
||||
|
||||
def _get(payload: dict[str, Any], key: str, default: str = "") -> str:
|
||||
value = payload.get(key)
|
||||
return value if isinstance(value, str) else default
|
||||
|
||||
|
||||
def _int(payload: dict[str, Any], key: str, default: int = 0) -> int:
|
||||
value = payload.get(key)
|
||||
return value if isinstance(value, int) and not isinstance(value, bool) else default
|
||||
|
||||
|
||||
def _login(payload: dict[str, Any], *keys: str) -> str:
|
||||
"""First ``{key: {"login": ...}}`` that actually carries a login."""
|
||||
for key in keys:
|
||||
holder = payload.get(key)
|
||||
if isinstance(holder, dict):
|
||||
login = holder.get("login")
|
||||
if isinstance(login, str) and login:
|
||||
return login
|
||||
return ""
|
||||
|
||||
|
||||
def _timestamp(payload: dict[str, Any], key: str) -> datetime | None:
|
||||
"""Parse an API timestamp; Gitea's zero value (``0001-01-01``) is ``None``."""
|
||||
raw = payload.get(key)
|
||||
if not isinstance(raw, str) or not raw:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.year <= 1:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=UTC)
|
||||
return parsed
|
||||
|
||||
|
||||
def _map_status(status: str, conclusion: str) -> str:
|
||||
"""Collapse Gitea's ``status`` + ``conclusion`` pair into one card state."""
|
||||
mapped = _STATUS_MAP.get(status)
|
||||
if mapped is not None:
|
||||
return mapped
|
||||
return _CONCLUSION_MAP.get(conclusion, _UNKNOWN)
|
||||
|
||||
|
||||
def _duration(
|
||||
status: str, started: datetime | None, completed: datetime | None, now: datetime
|
||||
) -> int | None:
|
||||
if started is None:
|
||||
return None
|
||||
end = completed if completed is not None else (now if status == _RUNNING else None)
|
||||
if end is None:
|
||||
return None
|
||||
return max(int((end - started).total_seconds()), 0)
|
||||
|
||||
|
||||
def _to_execution(payload: dict[str, Any], now: datetime) -> Execution:
|
||||
"""Map one ``ActionWorkflowRun``. Every field degrades on its own."""
|
||||
status = _map_status(_get(payload, "status"), _get(payload, "conclusion"))
|
||||
started = _timestamp(payload, "started_at")
|
||||
completed = _timestamp(payload, "completed_at")
|
||||
repository = payload.get("repository")
|
||||
repo = (
|
||||
_get(repository, "full_name")
|
||||
if isinstance(repository, dict)
|
||||
else ""
|
||||
)
|
||||
return Execution(
|
||||
run_id=_int(payload, "id"),
|
||||
number=_int(payload, "run_number"),
|
||||
attempt=_int(payload, "run_attempt", 1) or 1,
|
||||
workflow=_get(payload, "path").rsplit("/", 1)[-1],
|
||||
repo=repo,
|
||||
branch=_get(payload, "head_branch"),
|
||||
commit=_get(payload, "head_sha"),
|
||||
event=_get(payload, "event"),
|
||||
actor=_login(payload, "trigger_actor", "actor"),
|
||||
status=status,
|
||||
started=started,
|
||||
completed=completed,
|
||||
duration_sec=_duration(status, started, completed, now),
|
||||
html_url=_get(payload, "html_url"),
|
||||
)
|
||||
|
||||
|
||||
def _fetch(url: str, token: str) -> list[dict[str, Any]]:
|
||||
"""GET one runs endpoint. Raises on transport/HTTP/payload trouble."""
|
||||
# URL is built from the configured Gitea base URL, never user input.
|
||||
request = urllib.request.Request(
|
||||
url, headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=_TIMEOUT_SEC) as response:
|
||||
body = json.loads(response.read().decode("utf-8"))
|
||||
runs = body.get("workflow_runs") if isinstance(body, dict) else None
|
||||
if not isinstance(runs, list):
|
||||
raise ValueError("unexpected payload: no 'workflow_runs' array")
|
||||
return [item for item in runs if isinstance(item, dict)]
|
||||
|
||||
|
||||
def _fetch_runs(
|
||||
base_url: str, token: str, limit: int
|
||||
) -> tuple[list[dict[str, Any]], str, str | None, bool]:
|
||||
"""Admin endpoint first, user endpoint as the non-admin fallback.
|
||||
|
||||
Returns ``(runs, source, error, auth_failed)``; never raises.
|
||||
"""
|
||||
query = urllib.parse.urlencode({"limit": limit})
|
||||
admin_url = f"{base_url}/api/v1/admin/actions/runs?{query}"
|
||||
user_url = f"{base_url}/api/v1/user/actions/runs?{query}"
|
||||
|
||||
last_error: str | None = None
|
||||
auth_failed = False
|
||||
for source, url in (("admin", admin_url), ("user", user_url)):
|
||||
try:
|
||||
return _fetch(url, token), source, None, False
|
||||
except urllib.error.HTTPError as exc:
|
||||
last_error = f"Gitea API returned HTTP {exc.code}."
|
||||
if exc.code == 401:
|
||||
auth_failed = True
|
||||
break
|
||||
if exc.code in (403, 404) and source == "admin":
|
||||
# Token is not admin-scoped: try the per-user listing.
|
||||
continue
|
||||
break
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||
last_error = f"Gitea unreachable: {exc}."
|
||||
break
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
last_error = f"Unreadable Gitea response: {exc}"
|
||||
break
|
||||
|
||||
logger.warning("executions fetch failed: %s", last_error)
|
||||
return [], "none", last_error, auth_failed
|
||||
|
||||
|
||||
def _cached_runs(
|
||||
base_url: str, token: str, limit: int
|
||||
) -> tuple[list[dict[str, Any]], str, str | None, bool]:
|
||||
""":func:`_fetch_runs` behind the TTL cache (errors are cached too)."""
|
||||
key = (base_url, token, limit)
|
||||
now = _monotonic()
|
||||
entry = _CACHE.get(key)
|
||||
if entry is not None and now - entry[0] < _TTL_SEC:
|
||||
_fetched_at, runs, source, error, auth_failed = entry
|
||||
return runs, source, error, auth_failed
|
||||
|
||||
runs, source, error, auth_failed = _fetch_runs(base_url, token, limit)
|
||||
_CACHE[key] = (now, runs, source, error, auth_failed)
|
||||
return runs, source, error, auth_failed
|
||||
|
||||
|
||||
def _attach_vms(
|
||||
runs: list[Execution], vms: list[LiveVm]
|
||||
) -> tuple[list[Execution], list[LiveVm]]:
|
||||
"""Group live VMs under the run that spawned them.
|
||||
|
||||
The job id passed to ``job`` is ``{run_id}-{run_attempt}[-suffix]`` (see
|
||||
``.gitea/actions/local-ci-build/action.yml``) and the clone is named
|
||||
``Clone_<job_id>_<timestamp>_<suffix>``, so the run's
|
||||
``<run_id>-<attempt>`` is a prefix of the parsed job id. A matrix run
|
||||
therefore collects several VMs; anything left over is returned separately.
|
||||
"""
|
||||
by_prefix: dict[str, list[LiveVm]] = {}
|
||||
unmatched: list[LiveVm] = []
|
||||
prefixes = {f"{run.run_id}-{run.attempt}" for run in runs}
|
||||
for vm in vms:
|
||||
job_id = parse_clone_name(vm.name).job_id
|
||||
match = None
|
||||
if job_id is not None:
|
||||
match = next(
|
||||
(
|
||||
prefix
|
||||
for prefix in prefixes
|
||||
if job_id == prefix or job_id.startswith(f"{prefix}-")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if match is None:
|
||||
unmatched.append(vm)
|
||||
else:
|
||||
by_prefix.setdefault(match, []).append(vm)
|
||||
|
||||
attached = [
|
||||
replace(run, vms=by_prefix.get(f"{run.run_id}-{run.attempt}", []))
|
||||
for run in runs
|
||||
]
|
||||
return attached, unmatched
|
||||
|
||||
|
||||
def list_executions(
|
||||
config: Config,
|
||||
vms: list[LiveVm],
|
||||
*,
|
||||
token: str | None,
|
||||
limit: int | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> ExecutionsView:
|
||||
"""Recent Gitea Actions runs, with ``vms`` grouped under their own run.
|
||||
|
||||
``token`` is a Gitea API bearer token — the web UI passes the access token
|
||||
from the operator's OIDC login (kept server-side), falling back to an
|
||||
optional keyring PAT. ``None`` (or no configured Gitea URL) is not an
|
||||
error state to shout about: the view simply degrades to listing the live
|
||||
VMs, exactly what the card it replaces showed.
|
||||
"""
|
||||
effective_limit = config.web.executions_limit if limit is None else limit
|
||||
moment = now if now is not None else datetime.now(UTC)
|
||||
|
||||
base_url = gitea_base_url(config.web)
|
||||
if base_url is None:
|
||||
return ExecutionsView(
|
||||
runs=[],
|
||||
unmatched_vms=list(vms),
|
||||
source="none",
|
||||
error="Gitea URL not configured ([web] gitea_url or [web.oidc] issuer).",
|
||||
)
|
||||
if not token:
|
||||
return ExecutionsView(
|
||||
runs=[],
|
||||
unmatched_vms=list(vms),
|
||||
source="none",
|
||||
error="Gitea token not configured.",
|
||||
)
|
||||
|
||||
raw_runs, source, error, auth_failed = _cached_runs(
|
||||
base_url.rstrip("/"), token, effective_limit
|
||||
)
|
||||
if error is not None:
|
||||
return ExecutionsView(
|
||||
runs=[],
|
||||
unmatched_vms=list(vms),
|
||||
source=source,
|
||||
error=error,
|
||||
auth_failed=auth_failed,
|
||||
)
|
||||
|
||||
runs, unmatched = _attach_vms(
|
||||
[_to_execution(item, moment) for item in raw_runs], vms
|
||||
)
|
||||
return ExecutionsView(
|
||||
runs=runs, unmatched_vms=unmatched, source=source, error=None
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Execution",
|
||||
"ExecutionsView",
|
||||
"clear_cache",
|
||||
"list_executions",
|
||||
]
|
||||
Reference in New Issue
Block a user