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:
@@ -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",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user