feat(web): dashboard redesign — Gitea palette, health panels, VM/audit enrichment (stages A–C)

Stage A: swap CSS tokens to the real Gitea dark palette (accent blue #4183c4); add Jinja formatting filters (humanbytes/duration/relative_time/localtime/shorten_path/thousands), a health status-header, and an inline SVG icon sprite; fix two UI bugs — unthemed action pages (htmx swap into #action-slot with a full-page HX-Request fallback) and polling wiping form input (hx-preserve).

Stage B: four read-only panels that degrade to notices (never 500) — host resources (/proc, two-sample CPU%), VMware module health, template inventory (.vmem warn + last backup), and a path-contained file browser; backup card becomes a validated template dropdown; expensive dir-size walk is TTL-cached. Adds operations/inventory.py plus host_resources()/vmware_module_health() in operations/host.py.

Stage C: parse_clone_name() adds a Job column (job_id + started) to the VM table; nav gains a Gitea link-out derived from the OIDC issuer origin (no fabricated run URLs); audit page gets action icons, relative time, and a client-side filter.

Gates: 989 tests pass, coverage 94.12%, ruff + mypy --strict clean. Stage D (service management) intentionally deferred.
This commit is contained in:
2026-07-24 11:46:50 +02:00
parent 907e79818e
commit 9d7bbee35d
33 changed files with 4099 additions and 65 deletions
+274 -8
View File
@@ -30,6 +30,7 @@ never 500s. Tests override it via ``app.dependency_overrides[get_backend]``.
from __future__ import annotations
import secrets
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
@@ -41,9 +42,27 @@ from starlette.middleware.sessions import SessionMiddleware
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.host import DiskStatus, RunnerStatus, disk_status, runner_status
from ci_orchestrator.operations.host import (
DiskStatus,
HostResources,
RunnerStatus,
VmwareModuleHealth,
disk_status,
host_resources,
runner_status,
vmware_module_health,
)
from ci_orchestrator.operations.inventory import (
FileEntry,
TemplateInfo,
list_artifacts,
list_backups,
list_templates,
)
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.security import issue_csrf
@@ -57,6 +76,19 @@ _WEB_DIR = Path(__file__).resolve().parent
#: CLAUDE.md's "Phase C"). A module constant per the W4 plan section.
RUNNER_SERVICE_NAMES: list[str] = ["act-runner.service", "act-runner-rust.service"]
#: Disk-usage health thresholds for the status header (A3): above the
#: "critical" percentage -> red; above "elevated" -> amber; else green.
_DISK_PCT_CRITICAL = 85.0
_DISK_PCT_ELEVATED = 70.0
#: CSS class used by ``partials/status_header.html`` for each health level.
#: Health must never be color-only -- the template always pairs this class
#: with a short text label (see :class:`HealthSummary`).
_TILE_OK = "tile-ok"
_TILE_WARN = "tile-warn"
_TILE_BAD = "tile-bad"
_TILE_UNKNOWN = "tile-unknown"
# ─────────────────────────────────────────────────────────────── dependency
@@ -113,6 +145,160 @@ def _fetch_runner_status() -> list[RunnerStatus]:
return []
def _fetch_resources() -> HostResources | None:
"""Host CPU/memory/uptime/load snapshot (B1), or ``None`` if unreadable.
``host_resources`` itself already degrades to an all-zero snapshot on
``OSError`` (never raises) -- this ``except Exception`` is a defensive
second layer so a route can never 500 even on a genuinely unexpected
failure.
"""
try:
return host_resources()
except Exception: # degrade, never 500
return None
def _fetch_vmware_health() -> VmwareModuleHealth | None:
"""``vmmon``/``vmnet``/``vmware-host.service`` health (B2), or ``None`` if unreadable."""
try:
return vmware_module_health()
except Exception: # degrade, never 500
return None
def _fetch_templates(config: Config) -> list[TemplateInfo] | None:
"""Template inventory rows (B3), or ``None`` if the scan itself failed.
Distinct from an empty list (``[]``, a templates dir with nothing in
it) -- ``None`` triggers the "unavailable" notice in ``templates.html``,
``[]`` triggers its plain "no templates found" state.
"""
try:
return list_templates(config)
except Exception: # degrade, never 500
return None
def _fetch_template_names(config: Config) -> list[str]:
"""Template names only, for the B6 backup-form dropdown. Never ``None``."""
rows = _fetch_templates(config)
return [t.name for t in rows] if rows is not None else []
def _fetch_backups(config: Config) -> list[FileEntry] | None:
"""Backup archive listing (B4), or ``None`` if the scan itself failed."""
try:
return list_backups(config)
except Exception: # degrade, never 500
return None
def _fetch_artifacts(config: Config) -> list[FileEntry] | None:
"""Artifact listing (B4), or ``None`` if the scan itself failed."""
try:
return list_artifacts(config)
except Exception: # degrade, never 500
return None
# ────────────────────────────────────────────────────────── status header
@dataclass(frozen=True)
class HealthSummary:
"""Render-ready values for the status-header tiles (A3).
Every field is derived purely from data the dashboard route already
fetched -- :func:`_health_summary` makes no new call of its own. Each
tile carries both a CSS class (``*_class``) and a short text
``*_label`` so health never reads from color alone.
"""
vm_count: int
orphan_count: int
vm_class: str
vm_label: str
runners_active: int
runners_total: int
runner_class: str
runner_label: str
disk_used_pct: float | None
disk_class: str
disk_label: str
cache_bytes: int | None
cache_class: str
cache_label: str
def _health_summary(
vms: list[LiveVm],
*,
backend_available: bool,
cache: CacheStatus | None,
disk: DiskStatus | None,
runners: list[RunnerStatus],
) -> HealthSummary:
"""Derive the A3 status-header tiles from already-fetched dashboard context.
Thresholds (see the A3 brief): disk ``used_pct`` > 85 -> red, > 70 ->
amber, else green; any runner not active -> red; an orphaned VM present
-> amber. A missing data source (backend down, disk/cache unreadable)
renders as a neutral "unknown" tile rather than false-green.
"""
orphan_count = sum(1 for vm in vms if vm.is_orphan)
if not backend_available:
vm_class, vm_label = _TILE_UNKNOWN, "unknown"
elif orphan_count > 0:
vm_class, vm_label = _TILE_WARN, "orphaned"
else:
vm_class, vm_label = _TILE_OK, "ok"
runners_total = len(runners)
runners_active = sum(1 for r in runners if r.active)
if runners_total == 0:
runner_class, runner_label = _TILE_UNKNOWN, "unknown"
elif runners_active < runners_total:
runner_class, runner_label = _TILE_BAD, "down"
else:
runner_class, runner_label = _TILE_OK, "ok"
disk_used_pct = disk.used_pct if disk is not None else None
if disk_used_pct is None:
disk_class, disk_label = _TILE_UNKNOWN, "unknown"
elif disk_used_pct > _DISK_PCT_CRITICAL:
disk_class, disk_label = _TILE_BAD, "critical"
elif disk_used_pct > _DISK_PCT_ELEVATED:
disk_class, disk_label = _TILE_WARN, "elevated"
else:
disk_class, disk_label = _TILE_OK, "ok"
cache_bytes = cache.total_bytes if cache is not None else None
cache_class, cache_label = (
(_TILE_OK, "ok") if cache is not None else (_TILE_UNKNOWN, "unknown")
)
return HealthSummary(
vm_count=len(vms),
orphan_count=orphan_count,
vm_class=vm_class,
vm_label=vm_label,
runners_active=runners_active,
runners_total=runners_total,
runner_class=runner_class,
runner_label=runner_label,
disk_used_pct=disk_used_pct,
disk_class=disk_class,
disk_label=disk_label,
cache_bytes=cache_bytes,
cache_class=cache_class,
cache_label=cache_label,
)
# ────────────────────────────────────────────────────────────────── routes
@@ -124,15 +310,38 @@ def healthz() -> dict[str, str]:
def dashboard(
request: Request, backend: VmBackend | None = Depends(get_backend)
) -> HTMLResponse:
"""Full dashboard: VM live, cache, disk, runner cards."""
"""Full dashboard: VM live, 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)
backend_available = backend is not None
cache = _fetch_cache_status(config)
disk = _fetch_disk_status(config)
runners = _fetch_runner_status()
# One template scan feeds both the full Templates-panel rows and the
# backup-form dropdown's names -- no need for _fetch_template_names to
# call list_templates a second time within the same request.
template_rows = _fetch_templates(config)
template_names = [t.name for t in template_rows] if template_rows is not None else []
context = {
"vms": _fetch_live_vms(backend, config),
"backend_available": backend is not None,
"cache": _fetch_cache_status(config),
"disk": _fetch_disk_status(config),
"runners": _fetch_runner_status(),
"vms": vms,
"backend_available": backend_available,
"cache": cache,
"disk": disk,
"runners": runners,
"resources": _fetch_resources(),
"vmware": _fetch_vmware_health(),
"templates": template_rows,
"template_names": template_names,
"backups": _fetch_backups(config),
"artifacts": _fetch_artifacts(config),
"health": _health_summary(
vms,
backend_available=backend_available,
cache=cache,
disk=disk,
runners=runners,
),
"csrf_token": issue_csrf(request),
}
return templates.TemplateResponse(request, "dashboard.html", context)
@@ -161,17 +370,51 @@ def cache_partial(request: Request) -> HTMLResponse:
def host_partial(request: Request) -> HTMLResponse:
"""``GET /host`` -- disk + runner status partial."""
"""``GET /host`` -- disk + runner status + template-backup dropdown partial."""
config: Config = request.app.state.config
templates: Jinja2Templates = request.app.state.templates
context = {
"disk": _fetch_disk_status(config),
"runners": _fetch_runner_status(),
"template_names": _fetch_template_names(config),
"csrf_token": issue_csrf(request),
}
return templates.TemplateResponse(request, "partials/host.html", context)
def resources_partial(request: Request) -> HTMLResponse:
"""``GET /resources`` (B5) -- polled every 5s by the dashboard via htmx."""
templates: Jinja2Templates = request.app.state.templates
context = {"resources": _fetch_resources()}
return templates.TemplateResponse(request, "partials/resources.html", context)
def vmware_partial(request: Request) -> HTMLResponse:
"""``GET /vmware`` (B5) -- polled every 15s by the dashboard via htmx."""
templates: Jinja2Templates = request.app.state.templates
context = {"vmware": _fetch_vmware_health()}
return templates.TemplateResponse(request, "partials/vmware.html", context)
def templates_partial(request: Request) -> HTMLResponse:
"""``GET /templates`` (B5) -- polled every 30s by the dashboard via htmx."""
config: Config = request.app.state.config
templates: Jinja2Templates = request.app.state.templates
context = {"templates": _fetch_templates(config)}
return templates.TemplateResponse(request, "partials/templates.html", context)
def files_partial(request: Request) -> HTMLResponse:
"""``GET /files`` (B5) -- polled every 30s by the dashboard via htmx."""
config: Config = request.app.state.config
templates: Jinja2Templates = request.app.state.templates
context = {
"backups": _fetch_backups(config),
"artifacts": _fetch_artifacts(config),
}
return templates.TemplateResponse(request, "partials/files.html", context)
# ─────────────────────────────────────────────────────────────────── factory
@@ -195,7 +438,13 @@ def create_app(config: Config) -> FastAPI:
app = FastAPI(title="ci-orchestrator web")
app.state.config = config
app.state.templates = Jinja2Templates(directory=str(_WEB_DIR / "templates"))
filters.register(app.state.templates)
app.state.templates.env.filters["vmx_id"] = encode_vmx_id
# Stage C2: a Jinja *global* (not per-route context) so every template
# that extends base.html -- dashboard, audit, the action-page wrapper --
# picks up the nav's "Gitea ↗" link without each route builder having to
# remember to thread it through.
app.state.templates.env.globals["gitea_url"] = gitea_base_url(config.web)
app.mount("/static", StaticFiles(directory=str(_WEB_DIR / "static")), name="static")
app.get("/healthz")(healthz)
@@ -209,6 +458,18 @@ def create_app(config: Config) -> FastAPI:
app.get("/host", response_class=HTMLResponse, dependencies=[Depends(require_user)])(
host_partial
)
app.get("/resources", response_class=HTMLResponse, dependencies=[Depends(require_user)])(
resources_partial
)
app.get("/vmware", response_class=HTMLResponse, dependencies=[Depends(require_user)])(
vmware_partial
)
app.get("/templates", response_class=HTMLResponse, dependencies=[Depends(require_user)])(
templates_partial
)
app.get("/files", response_class=HTMLResponse, dependencies=[Depends(require_user)])(
files_partial
)
register_action_routes(app)
# SessionMiddleware is ALWAYS installed (W6) -- CSRF needs
@@ -241,8 +502,13 @@ def create_app(config: Config) -> FastAPI:
__all__ = [
"RUNNER_SERVICE_NAMES",
"HealthSummary",
"create_app",
"dashboard",
"files_partial",
"get_backend",
"healthz",
"resources_partial",
"templates_partial",
"vmware_partial",
]