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:
@@ -117,6 +117,14 @@ class WebConfig:
|
||||
management panel is allowed to start/stop/restart. Left unset, the
|
||||
default applies unchanged. See ``_parse_managed_services`` for the
|
||||
permissive-parse contract (a malformed row is skipped, never raised).
|
||||
|
||||
``gitea_token_cred`` (Executions card) is the **keyring key name** of an
|
||||
optional Gitea API token used to list recent Actions runs. It is only a
|
||||
fallback: the dashboard normally reuses the access token from the
|
||||
operator's own OIDC login (held server-side, never in the session
|
||||
cookie). Left unset, the card degrades to listing live VMs whenever no
|
||||
logged-in token is available. ``executions_limit`` caps how many runs the
|
||||
card asks Gitea for.
|
||||
"""
|
||||
|
||||
enabled: bool = False
|
||||
@@ -127,6 +135,8 @@ class WebConfig:
|
||||
session_secret_cred: str = "WebSessionSecret"
|
||||
oidc: WebOidcConfig | None = None
|
||||
gitea_url: str | None = None
|
||||
gitea_token_cred: str | None = None
|
||||
executions_limit: int = 15
|
||||
managed_services: tuple[ManagedService, ...] = field(
|
||||
default_factory=lambda: MANAGED_SERVICES
|
||||
)
|
||||
@@ -279,6 +289,8 @@ def _parse_web_config(web_section: dict[str, Any]) -> WebConfig:
|
||||
allowed_users = web_section.get("allowed_users")
|
||||
audit_log = web_section.get("audit_log")
|
||||
gitea_url = web_section.get("gitea_url")
|
||||
gitea_token_cred = web_section.get("gitea_token_cred")
|
||||
executions_limit = web_section.get("executions_limit")
|
||||
|
||||
return WebConfig(
|
||||
enabled=bool(web_section.get("enabled", False)),
|
||||
@@ -290,9 +302,21 @@ def _parse_web_config(web_section: dict[str, Any]) -> WebConfig:
|
||||
managed_services=_parse_managed_services(web_section.get("managed_services")),
|
||||
oidc=_parse_web_oidc(web_section.get("oidc", {}) or {}),
|
||||
gitea_url=str(gitea_url) if gitea_url else None,
|
||||
gitea_token_cred=str(gitea_token_cred) if gitea_token_cred else None,
|
||||
# A non-numeric or nonsensical limit falls back to the default rather
|
||||
# than raising at startup over a cosmetic setting.
|
||||
executions_limit=_parse_executions_limit(executions_limit),
|
||||
)
|
||||
|
||||
|
||||
def _parse_executions_limit(raw: Any) -> int:
|
||||
"""Clamp ``[web] executions_limit`` to 1..100; anything odd -> the default."""
|
||||
default = WebConfig().executions_limit
|
||||
if isinstance(raw, bool) or not isinstance(raw, int):
|
||||
return default
|
||||
return max(1, min(raw, 100))
|
||||
|
||||
|
||||
def load_config(
|
||||
config_path: Path | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
@@ -25,10 +25,24 @@ appear in diagnostics.
|
||||
**Allowlist is fail-closed.** After a successful OIDC login the username is
|
||||
checked against ``config.web.allowed_users``; an *empty* allowlist denies
|
||||
everyone (misconfiguration must never mean "allow all").
|
||||
|
||||
**The Gitea access token NEVER goes into the session.** The login token is
|
||||
kept for reuse against Gitea's API (the Executions card), but
|
||||
``SessionMiddleware`` stores the session in a cookie that is *signed, not
|
||||
encrypted*, and this service is deployed over plain HTTP on the LAN
|
||||
(``https_only=False``) -- a bearer token in there would be readable in the
|
||||
browser and on the wire. It therefore lives only in the server-side map on
|
||||
``app.state.gitea_tokens``, keyed by username (see :func:`store_gitea_token`),
|
||||
and is dropped on logout. A process restart empties the map: the card then
|
||||
degrades to "re-authenticate" until the next login, which is the intended
|
||||
trade-off.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
@@ -41,10 +55,101 @@ from ci_orchestrator.credentials import KeyringCredentialStore
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from ci_orchestrator.config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Userinfo claims checked, in priority order, to derive the login username.
|
||||
_USERNAME_CLAIMS = ("preferred_username", "login", "username")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GiteaToken:
|
||||
"""A Gitea OAuth2 token held server-side for API reuse.
|
||||
|
||||
Never rendered, never logged, never put in the session -- see this
|
||||
module's docstring for why.
|
||||
"""
|
||||
|
||||
access_token: str
|
||||
refresh_token: str | None = None
|
||||
expires_at: float | None = None
|
||||
|
||||
@property
|
||||
def expired(self) -> bool:
|
||||
"""True once the access token is past its expiry (30s of slack)."""
|
||||
return self.expires_at is not None and time.time() >= self.expires_at - 30
|
||||
|
||||
|
||||
def _token_map(app: FastAPI) -> dict[str, GiteaToken]:
|
||||
"""The per-process username -> token map, created on first use."""
|
||||
existing = getattr(app.state, "gitea_tokens", None)
|
||||
if isinstance(existing, dict):
|
||||
return existing
|
||||
fresh: dict[str, GiteaToken] = {}
|
||||
app.state.gitea_tokens = fresh
|
||||
return fresh
|
||||
|
||||
|
||||
def store_gitea_token(app: FastAPI, username: str, token: dict[str, Any]) -> None:
|
||||
"""Keep the access/refresh token of a fresh login for later API calls.
|
||||
|
||||
A token dict without a usable ``access_token`` stores nothing (the login
|
||||
itself still succeeds -- the Executions card simply degrades).
|
||||
"""
|
||||
access = token.get("access_token")
|
||||
if not isinstance(access, str) or not access:
|
||||
return
|
||||
refresh = token.get("refresh_token")
|
||||
expires_at = token.get("expires_at")
|
||||
_token_map(app)[username] = GiteaToken(
|
||||
access_token=access,
|
||||
refresh_token=refresh if isinstance(refresh, str) and refresh else None,
|
||||
expires_at=float(expires_at) if isinstance(expires_at, int | float) else None,
|
||||
)
|
||||
|
||||
|
||||
def gitea_token_for(app: FastAPI, username: str | None) -> GiteaToken | None:
|
||||
"""The stored token for ``username``, or ``None`` if there is none."""
|
||||
if not username:
|
||||
return None
|
||||
return _token_map(app).get(username)
|
||||
|
||||
|
||||
def drop_gitea_token(app: FastAPI, username: str | None) -> None:
|
||||
"""Forget a stored token (logout, or a refusal that refresh cannot fix)."""
|
||||
if username:
|
||||
_token_map(app).pop(username, None)
|
||||
|
||||
|
||||
async def refresh_gitea_token(request: Request, username: str) -> GiteaToken | None:
|
||||
"""Exchange the stored refresh token for a new access token.
|
||||
|
||||
Returns the new token, or ``None`` when there is nothing to refresh with
|
||||
or Gitea refuses -- in which case the stored entry is dropped so the card
|
||||
asks for a fresh login instead of retrying a dead token every poll.
|
||||
"""
|
||||
app: FastAPI = request.app
|
||||
current = gitea_token_for(app, username)
|
||||
oauth: OAuth | None = getattr(app.state, "oauth", None)
|
||||
if current is None or current.refresh_token is None or oauth is None:
|
||||
return None
|
||||
try:
|
||||
token: dict[str, Any] = await oauth.gitea.fetch_access_token(
|
||||
grant_type="refresh_token", refresh_token=current.refresh_token
|
||||
)
|
||||
except Exception as exc: # Authlib raises a wide family here
|
||||
logger.warning("Gitea token refresh failed for %s: %s", username, exc)
|
||||
drop_gitea_token(app, username)
|
||||
return None
|
||||
# Gitea rotates the refresh token; keep the old one if it does not send one.
|
||||
if not token.get("refresh_token"):
|
||||
token["refresh_token"] = current.refresh_token
|
||||
store_gitea_token(app, username, token)
|
||||
refreshed = gitea_token_for(app, username)
|
||||
if refreshed is None:
|
||||
drop_gitea_token(app, username)
|
||||
return refreshed
|
||||
|
||||
|
||||
def _read_secret(cred_name: str) -> str:
|
||||
"""Read a secret from the OS keyring by key *name* -- NEVER from TOML.
|
||||
|
||||
@@ -162,11 +267,19 @@ async def auth_callback(request: Request) -> RedirectResponse:
|
||||
)
|
||||
|
||||
request.session["user"] = username
|
||||
# Reused for Gitea API reads (Executions card). Server-side only: the
|
||||
# session cookie is signed but not encrypted -- see the module docstring.
|
||||
store_gitea_token(request.app, username, token)
|
||||
return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
async def logout(request: Request) -> RedirectResponse:
|
||||
"""``GET /logout`` -- clear the signed session and send the user to ``/login``."""
|
||||
"""``GET /logout`` -- clear the signed session and send the user to ``/login``.
|
||||
|
||||
Also forgets the server-side Gitea token, so logging out really does end
|
||||
this process's ability to act as that user against Gitea.
|
||||
"""
|
||||
drop_gitea_token(request.app, request.session.get("user"))
|
||||
request.session.clear()
|
||||
return RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
@@ -196,10 +309,15 @@ def configure_auth(app: FastAPI, config: Config) -> None:
|
||||
|
||||
|
||||
__all__ = [
|
||||
"GiteaToken",
|
||||
"auth_callback",
|
||||
"configure_auth",
|
||||
"drop_gitea_token",
|
||||
"gitea_token_for",
|
||||
"login",
|
||||
"logout",
|
||||
"refresh_gitea_token",
|
||||
"register_oauth",
|
||||
"require_user",
|
||||
"store_gitea_token",
|
||||
]
|
||||
|
||||
@@ -31,6 +31,16 @@ _SECONDS_PER_MINUTE = 60
|
||||
_SECONDS_PER_HOUR = 3600
|
||||
_SECONDS_PER_DAY = 86400
|
||||
|
||||
#: Badge class per Executions-card run status (see :func:`run_badge`).
|
||||
_RUN_BADGES: dict[str, str] = {
|
||||
"running": "badge-info",
|
||||
"queued": "badge-muted",
|
||||
"success": "badge-success",
|
||||
"failure": "badge-danger",
|
||||
"cancelled": "badge-muted",
|
||||
"skipped": "badge-muted",
|
||||
}
|
||||
|
||||
|
||||
def humanbytes(n: int | float) -> str:
|
||||
"""Render a byte count as ``"11.8 MB"``-style text (base-1024, decimal display).
|
||||
@@ -158,6 +168,17 @@ def thousands(n: int | float) -> str:
|
||||
return f"{int(n):,}"
|
||||
|
||||
|
||||
def run_badge(status: str | None) -> str:
|
||||
"""Badge CSS class for an :class:`~ci_orchestrator.operations.executions.Execution` status.
|
||||
|
||||
Colour is never the only signal -- the template always prints the status
|
||||
word next to the badge (same rule as the status-header health tiles), so
|
||||
an unmapped status degrading to the neutral ``badge-muted`` still reads
|
||||
correctly.
|
||||
"""
|
||||
return _RUN_BADGES.get(status or "", "badge-muted")
|
||||
|
||||
|
||||
def clone_job(name: str | None) -> ParsedClone:
|
||||
"""Parse a VM/clone name into its ``job_id`` + ``started`` (Stage C1).
|
||||
|
||||
@@ -180,6 +201,7 @@ def register(templates: Jinja2Templates) -> None:
|
||||
templates.env.filters["shorten_path"] = shorten_path
|
||||
templates.env.filters["thousands"] = thousands
|
||||
templates.env.filters["clone_job"] = clone_job
|
||||
templates.env.filters["run_badge"] = run_badge
|
||||
|
||||
|
||||
__all__ = [
|
||||
@@ -189,6 +211,7 @@ __all__ = [
|
||||
"localtime",
|
||||
"register",
|
||||
"relative_time",
|
||||
"run_badge",
|
||||
"shorten_path",
|
||||
"thousands",
|
||||
]
|
||||
|
||||
@@ -376,6 +376,11 @@ tr.row-alert td { color: var(--danger); }
|
||||
.badge-success { background: rgba(63, 185, 80, 0.15); color: var(--success); border: 1px solid rgba(63, 185, 80, 0.4); }
|
||||
.badge-danger { background: rgba(248, 81, 73, 0.15); color: var(--danger); border: 1px solid rgba(248, 81, 73, 0.4); }
|
||||
.badge-warn { background: rgba(210, 153, 34, 0.15); color: var(--warn); border: 1px solid rgba(210, 153, 34, 0.4); }
|
||||
/* Executions card: "running" (in flight, nothing wrong) and the neutral
|
||||
* states (queued/cancelled/skipped/unknown) that must not read as either
|
||||
* success or failure. */
|
||||
.badge-info { background: rgba(65, 131, 196, 0.15); color: var(--accent); border: 1px solid rgba(65, 131, 196, 0.4); }
|
||||
.badge-muted { background: var(--panel-2); color: var(--text-dim); border: 1px solid var(--border); }
|
||||
|
||||
/* ── buttons ──────────────────────────────────────────────────────────── */
|
||||
|
||||
|
||||
@@ -7,20 +7,21 @@
|
||||
{% include "partials/status_header.html" %}
|
||||
{#
|
||||
Layout ordering (Stage E). Narrative top-to-bottom: infrastructure health ->
|
||||
what's running now (VMs) -> assets (templates + their backups) -> build
|
||||
outputs (artifacts) -> managed services -> the dedicated Maintenance/danger
|
||||
card last. The wide, many-column tables (vms / templates / files / services
|
||||
/ maintenance) span the full grid row via ``.card--wide``; the four compact
|
||||
info cards flow in one breathing-room row at the top, ordered Host ->
|
||||
VMware -> Runners -> Cargo cache, followed by the VMs table and then the
|
||||
Templates table.
|
||||
what the CI has been doing (Executions: recent runs, live ones included,
|
||||
with their VMs) -> assets (templates + their backups) -> build outputs
|
||||
(artifacts) -> managed services -> the dedicated Maintenance/danger card
|
||||
last. The wide, many-column tables (executions / templates / files /
|
||||
services / maintenance) span the full grid row via ``.card--wide``; the four
|
||||
compact info cards flow in one breathing-room row at the top, ordered Host
|
||||
-> VMware -> Runners -> Cargo cache, followed by the Executions table and
|
||||
then the Templates table.
|
||||
#}
|
||||
<div class="dashboard-grid">
|
||||
{% include "partials/host.html" %}{# Host (CPU/RAM/load) #}
|
||||
{% include "partials/vmware.html" %}{# VMware modules #}
|
||||
{% include "partials/runners.html" %}{# Runners #}
|
||||
{% include "partials/cache.html" %}{# Cargo cache #}
|
||||
{% include "partials/vms.html" %}
|
||||
{% include "partials/executions.html" %}
|
||||
{% include "partials/templates.html" %}
|
||||
{% include "partials/files.html" %}
|
||||
{% include "partials/services.html" %}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
{#
|
||||
Executions card. Replaces the old "Live VMs" table: one row per recent Gitea
|
||||
Actions run (the live ones included), with the build VM(s) that run spawned
|
||||
grouped into its VM cell, Remove action and all.
|
||||
|
||||
Degraded states (no token, expired token, Gitea unreachable) render a notice
|
||||
and still list every running VM below, so this card is never less useful
|
||||
than the VM table it replaces. Orphan VMs are NOT handled here: the sweep
|
||||
lives in the Maintenance card and the count in the status-header VMs tile;
|
||||
``executions.unmatched_vms`` only exists so a powered-on VM whose run has
|
||||
scrolled out of the window (or that no run ever claimed) keeps its row and
|
||||
its Remove button.
|
||||
#}
|
||||
<section class="card card--wide" id="executions-card" hx-get="/executions" hx-trigger="every 5s, ci:refresh from:body" hx-swap="outerHTML">
|
||||
<h2><svg class="icon" aria-hidden="true"><use href="#i-play"></use></svg>Executions</h2>
|
||||
{% if not backend_available %}
|
||||
<p class="notice notice-warn" role="alert">Backend unavailable (vmrun not found on this host).</p>
|
||||
{% endif %}
|
||||
{% if executions.error %}
|
||||
<p class="notice notice-warn" role="alert">
|
||||
{{ executions.error }}
|
||||
{% if executions.auth_failed %}<a href="/login">Log in again</a> to restore the run list.{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if executions.runs or executions.unmatched_vms %}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run</th>
|
||||
<th>Workflow</th>
|
||||
<th>Branch / commit</th>
|
||||
<th>Trigger</th>
|
||||
<th>Status</th>
|
||||
<th>Duration</th>
|
||||
<th>VM</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for run in executions.runs %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if run.html_url %}
|
||||
<a href="{{ run.html_url }}" target="_blank" rel="noopener noreferrer">#{{ run.number }}{% if run.attempt > 1 %}.{{ run.attempt }}{% endif %}<svg class="icon" aria-hidden="true"><use href="#i-external"></use></svg></a>
|
||||
{% else %}
|
||||
#{{ run.number }}{% if run.attempt > 1 %}.{{ run.attempt }}{% endif %}
|
||||
{% endif %}
|
||||
{% if run.repo %}<br><span class="text-dim">{{ run.repo }}</span>{% endif %}
|
||||
</td>
|
||||
<td>{{ run.workflow or "—" }}</td>
|
||||
<td>
|
||||
{{ run.branch or "—" }}
|
||||
{% if run.commit %}<br><code class="text-dim">{{ run.commit[:7] }}</code>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ run.event or "—" }}
|
||||
{% if run.actor %}<br><span class="text-dim">{{ run.actor }}</span>{% endif %}
|
||||
</td>
|
||||
<td><span class="badge {{ run.status | run_badge }}">{{ run.status }}</span></td>
|
||||
<td>
|
||||
{{ run.duration_sec | duration }}
|
||||
{% if run.started %}<br><span class="text-dim" title="{{ run.started | localtime }}">{{ run.started | relative_time }}</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% for vm in run.vms %}
|
||||
<div>
|
||||
{{ vm.name }}
|
||||
<br><span class="text-dim">{{ vm.ip or "no ip" }} · up {{ vm.uptime_sec | duration }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-dim">—</span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td>
|
||||
{% for vm in run.vms %}
|
||||
<form method="post" action="/vms/{{ vm.vmx | vmx_id }}/remove"
|
||||
hx-post="/vms/{{ vm.vmx | vmx_id }}/remove" hx-target="#action-slot" hx-swap="innerHTML show:top">
|
||||
<input type="hidden" name="csrf" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger btn-sm" aria-label="Remove VM {{ vm.name }}">Remove</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% for vm in executions.unmatched_vms %}
|
||||
<tr class="{{ 'row-orphan' if vm.is_orphan else '' }}">
|
||||
{% set parsed = vm.name | clone_job %}
|
||||
<td>
|
||||
<span class="text-dim">{{ parsed.job_id or "no run" }}</span>
|
||||
</td>
|
||||
<td><span class="text-dim">—</span></td>
|
||||
<td><span class="text-dim">—</span></td>
|
||||
<td><span class="text-dim">—</span></td>
|
||||
<td>
|
||||
{% if vm.is_orphan %}
|
||||
<span class="badge badge-warn">orphan</span>
|
||||
{% else %}
|
||||
<span class="badge badge-info">running</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if parsed.started %}
|
||||
<span class="text-dim" title="{{ parsed.started | localtime }}">{{ parsed.started | relative_time }}</span>
|
||||
{% else %}
|
||||
<span class="text-dim">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ vm.name }}
|
||||
<br><span class="text-dim">{{ vm.ip or "no ip" }} · up {{ vm.uptime_sec | duration }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="/vms/{{ vm.vmx | vmx_id }}/remove"
|
||||
hx-post="/vms/{{ vm.vmx | vmx_id }}/remove" hx-target="#action-slot" hx-swap="innerHTML show:top">
|
||||
<input type="hidden" name="csrf" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger btn-sm" aria-label="Remove VM {{ vm.name }}">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>No recent executions{% if not executions.error %} and no VMs running{% endif %}.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
@@ -76,4 +76,10 @@
|
||||
<line x1="12" y1="9" x2="12" y2="13"></line>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
||||
</symbol>
|
||||
|
||||
<!-- Executions card: a run/play glyph, stroked like every other icon here. -->
|
||||
<symbol id="i-play" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="9"></circle>
|
||||
<path d="M10 8.5l6 3.5-6 3.5z"></path>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
<section class="card card--wide" id="vms-card" hx-get="/vms" hx-trigger="every 5s, ci:refresh from:body" hx-swap="outerHTML">
|
||||
<h2><svg class="icon" aria-hidden="true"><use href="#i-server"></use></svg>Live VMs</h2>
|
||||
{% if not backend_available %}
|
||||
<p class="notice notice-warn" role="alert">Backend unavailable (vmrun not found on this host).</p>
|
||||
{% endif %}
|
||||
{% if vms %}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Job</th>
|
||||
<th>VMX</th>
|
||||
<th>IP</th>
|
||||
<th>Guest OS</th>
|
||||
<th>Uptime</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for vm in vms %}
|
||||
{% set parsed = vm.name | clone_job %}
|
||||
<tr class="{{ 'row-orphan' if vm.is_orphan else '' }}">
|
||||
<td>{{ vm.name }}</td>
|
||||
<td>
|
||||
{{ parsed.job_id or "—" }}
|
||||
{% if parsed.started %}
|
||||
<br><span class="text-dim" title="{{ parsed.started | localtime }}">{{ parsed.started | relative_time }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><code title="{{ vm.vmx }}">{{ vm.vmx | shorten_path(2) }}</code></td>
|
||||
<td>{{ vm.ip or "-" }}</td>
|
||||
<td>{{ vm.guest_os or "-" }}</td>
|
||||
<td>{{ vm.uptime_sec | duration }}</td>
|
||||
<td>
|
||||
{% if vm.is_orphan %}
|
||||
<span class="badge badge-warn">orphan</span>
|
||||
{% else %}
|
||||
<span class="badge badge-success">running</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="/vms/{{ vm.vmx | vmx_id }}/remove"
|
||||
hx-post="/vms/{{ vm.vmx | vmx_id }}/remove" hx-target="#action-slot" hx-swap="innerHTML show:top">
|
||||
<input type="hidden" name="csrf" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger btn-sm" aria-label="Remove VM {{ vm.name }}">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>No VMs currently running.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
Reference in New Issue
Block a user