feat(web): Stage E dashboard — wide cards, per-item actions, toast stack

A large, cohesive redesign of the web dashboard (all pieces depend on
each other, so this lands as one commit):

Layout / display:
- Host/Runners/VMware/Cache split into a Host-VMware-Runners-Cache info
  row; Host card keeps uptime/load/cores/mem-total now that CPU%/RAM
  are promoted to the status-header tiles (new gauge tiles, red/amber/
  green thresholds shared with the existing disk tile).
- VMware modules rendered as a table; Live VMs/Templates/Artifacts/
  Services/Maintenance span the full grid width (card--wide).
- Backups moved from a standalone Files-panel table into an expandable
  per-template row on the Templates panel (backups_by_template), with
  a per-backup Delete action and a per-row Backup button replacing the
  old host-card template dropdown.
- New Maintenance card: cleanup-orphans and cache-purge forms
  consolidated into one danger-zone card (red rule + row separators),
  each op's max-age control on its own row so it reads as the op's
  parameter, not decoration; cache purge gets an explicit "Empty all"
  button alongside the age-based "Purge".
- Artifacts gain a per-row Delete action.

Action/feedback layer:
- Every mutating route now sets HX-Trigger: ci:refresh on its result,
  so polled panels reload immediately instead of waiting for their
  next poll interval; _result_partial takes an explicit level
  (ok/info/error) independent of the ok bool, so e.g. an age-based
  purge that finds nothing to remove reads as neutral "info" instead
  of a misleading green "ok" or a red "error".
- Results render as toasts appended to a fixed #toast-stack (base.html)
  instead of swapping a single #action-slot -- concurrent actions (two
  backups running at once) now each keep their own toast instead of
  the later one clobbering the earlier. Per-state colour is a left
  stripe + tint (result-ok/info/error), fixing a cascade bug where the
  plain .toast background silently beat the state class.
- The confirm dialog is a real modal <dialog> (app.js: showModal(),
  Cancel/ESC clear the slot); confirming closes it immediately and
  shows a busy spinner toast in the stack (since a real backup can run
  for minutes) instead of leaving the dialog open with no feedback.
- host_backup now runs backup_template via asyncio.to_thread so a
  multi-GB 7z run no longer blocks the event loop (and every other
  dashboard poll) for its whole duration.
- Static assets are served with a mtime-based ?v= query param so a
  redeploy is picked up without a hard refresh.
This commit is contained in:
2026-07-24 22:04:48 +02:00
parent 2395e3e79f
commit d18321d4a0
25 changed files with 1695 additions and 351 deletions
+129 -52
View File
@@ -57,8 +57,8 @@ from ci_orchestrator.operations.host import (
from ci_orchestrator.operations.inventory import (
FileEntry,
TemplateInfo,
backups_by_template,
list_artifacts,
list_backups,
list_templates,
)
from ci_orchestrator.operations.services import ServiceState, service_states
@@ -82,6 +82,13 @@ RUNNER_SERVICE_NAMES: list[str] = ["act-runner.service", "act-runner-rust.servic
_DISK_PCT_CRITICAL = 85.0
_DISK_PCT_ELEVATED = 70.0
#: CPU / memory usage thresholds for the two Stage-E resource tiles, same
#: red/amber/green shape as the disk thresholds above.
_CPU_PCT_CRITICAL = 90.0
_CPU_PCT_ELEVATED = 75.0
_MEM_PCT_CRITICAL = 90.0
_MEM_PCT_ELEVATED = 75.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`).
@@ -181,20 +188,6 @@ def _fetch_templates(config: Config) -> list[TemplateInfo] | None:
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:
@@ -240,15 +233,38 @@ class HealthSummary:
runner_class: str
runner_label: str
cpu_pct: float | None
ncpu: int
cpu_class: str
cpu_label: str
mem_used_gb: float | None
mem_total_gb: float | None
mem_pct: float | None
mem_class: str
mem_label: str
disk_used_pct: float | None
disk_free_gb: float | None
disk_total_gb: float | None
disk_class: str
disk_label: str
cache_bytes: int | None
cache_entries: int | None
cache_class: str
cache_label: str
def _threshold_level(pct: float, *, elevated: float, critical: float) -> tuple[str, str]:
"""Map a usage percentage to a ``(tile-class, label)`` on the shared red/amber/green scale."""
if pct > critical:
return _TILE_BAD, "critical"
if pct > elevated:
return _TILE_WARN, "elevated"
return _TILE_OK, "ok"
def _health_summary(
vms: list[LiveVm],
*,
@@ -256,6 +272,7 @@ def _health_summary(
cache: CacheStatus | None,
disk: DiskStatus | None,
runners: list[RunnerStatus],
resources: HostResources | None = None,
) -> HealthSummary:
"""Derive the A3 status-header tiles from already-fetched dashboard context.
@@ -281,17 +298,34 @@ def _health_summary(
else:
runner_class, runner_label = _TILE_OK, "ok"
if resources is None:
cpu_pct, ncpu = None, 0
cpu_class, cpu_label = _TILE_UNKNOWN, "unknown"
mem_used_gb = mem_total_gb = mem_pct = None
mem_class, mem_label = _TILE_UNKNOWN, "unknown"
else:
cpu_pct, ncpu = resources.cpu_pct, resources.ncpu
cpu_class, cpu_label = _threshold_level(
cpu_pct, elevated=_CPU_PCT_ELEVATED, critical=_CPU_PCT_CRITICAL
)
mem_used_gb, mem_total_gb = resources.mem_used_gb, resources.mem_total_gb
mem_pct = (mem_used_gb / mem_total_gb * 100.0) if mem_total_gb > 0 else 0.0
mem_class, mem_label = _threshold_level(
mem_pct, elevated=_MEM_PCT_ELEVATED, critical=_MEM_PCT_CRITICAL
)
disk_used_pct = disk.used_pct if disk is not None else None
disk_free_gb = disk.free_gb if disk is not None else None
disk_total_gb = disk.total_gb 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"
disk_class, disk_label = _threshold_level(
disk_used_pct, elevated=_DISK_PCT_ELEVATED, critical=_DISK_PCT_CRITICAL
)
cache_bytes = cache.total_bytes if cache is not None else None
cache_entries = cache.entry_count if cache is not None else None
cache_class, cache_label = (
(_TILE_OK, "ok") if cache is not None else (_TILE_UNKNOWN, "unknown")
)
@@ -305,10 +339,22 @@ def _health_summary(
runners_total=runners_total,
runner_class=runner_class,
runner_label=runner_label,
cpu_pct=cpu_pct,
ncpu=ncpu,
cpu_class=cpu_class,
cpu_label=cpu_label,
mem_used_gb=mem_used_gb,
mem_total_gb=mem_total_gb,
mem_pct=mem_pct,
mem_class=mem_class,
mem_label=mem_label,
disk_used_pct=disk_used_pct,
disk_free_gb=disk_free_gb,
disk_total_gb=disk_total_gb,
disk_class=disk_class,
disk_label=disk_label,
cache_bytes=cache_bytes,
cache_entries=cache_entries,
cache_class=cache_class,
cache_label=cache_label,
)
@@ -333,22 +379,18 @@ def dashboard(
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.
resources = _fetch_resources()
template_rows = _fetch_templates(config)
template_names = [t.name for t in template_rows] if template_rows is not None else []
context = {
"vms": vms,
"backend_available": backend_available,
"cache": cache,
"disk": disk,
"runners": runners,
"resources": _fetch_resources(),
"resources": resources,
"vmware": _fetch_vmware_health(),
"templates": template_rows,
"template_names": template_names,
"backups": _fetch_backups(config),
"template_backups": backups_by_template(config),
"artifacts": _fetch_artifacts(config),
"services": _fetch_service_states(config),
"health": _health_summary(
@@ -357,6 +399,7 @@ def dashboard(
cache=cache,
disk=disk,
runners=runners,
resources=resources,
),
"csrf_token": issue_csrf(request),
}
@@ -385,24 +428,23 @@ def cache_partial(request: Request) -> HTMLResponse:
return templates.TemplateResponse(request, "partials/cache.html", context)
def host_partial(request: Request) -> HTMLResponse:
"""``GET /host`` -- disk + runner status + template-backup dropdown partial."""
config: Config = request.app.state.config
def runners_partial(request: Request) -> HTMLResponse:
"""``GET /runners`` -- runner status partial (Stage E: disk/backup moved out).
The disk card was folded into the status-header Disk tile and the
standalone template-backup card into a per-row Templates action, so this
read-only poll now carries only the runner statuses.
"""
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)
context = {"runners": _fetch_runner_status()}
return templates.TemplateResponse(request, "partials/runners.html", context)
def resources_partial(request: Request) -> HTMLResponse:
"""``GET /resources`` (B5) -- polled every 5s by the dashboard via htmx."""
def host_partial(request: Request) -> HTMLResponse:
"""``GET /host`` (B5) -- host resources (CPU/RAM/load) partial, polled every 5s."""
templates: Jinja2Templates = request.app.state.templates
context = {"resources": _fetch_resources()}
return templates.TemplateResponse(request, "partials/resources.html", context)
return templates.TemplateResponse(request, "partials/host.html", context)
def vmware_partial(request: Request) -> HTMLResponse:
@@ -413,20 +455,34 @@ def vmware_partial(request: Request) -> HTMLResponse:
def templates_partial(request: Request) -> HTMLResponse:
"""``GET /templates`` (B5) -- polled every 30s by the dashboard via htmx."""
"""``GET /templates`` (B5) -- polled every 30s by the dashboard via htmx.
Stage E: now carries the per-template backups map (for each row's
expandable backup list) and a fresh ``csrf_token`` (the per-row Backup
button is a mutating form, so this poll is no longer purely read-only).
"""
config: Config = request.app.state.config
templates: Jinja2Templates = request.app.state.templates
context = {"templates": _fetch_templates(config)}
context = {
"templates": _fetch_templates(config),
"template_backups": backups_by_template(config),
"csrf_token": issue_csrf(request),
}
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."""
"""``GET /files`` (B5) -- polled every 30s by the dashboard via htmx.
Stage E: the backups listing moved under the Templates panel (per-row
expandable), so this poll carries only the artifacts list plus a fresh
``csrf_token`` for the per-row Delete button (now a mutating form).
"""
config: Config = request.app.state.config
templates: Jinja2Templates = request.app.state.templates
context = {
"backups": _fetch_backups(config),
"artifacts": _fetch_artifacts(config),
"csrf_token": issue_csrf(request),
}
return templates.TemplateResponse(request, "partials/files.html", context)
@@ -434,10 +490,10 @@ def files_partial(request: Request) -> HTMLResponse:
def services_partial(request: Request) -> HTMLResponse:
"""``GET /services`` (Stage D) -- polled every 15s by the dashboard via htmx.
Needs a fresh ``csrf_token`` on every render (unlike ``resources``/
``vmware``/``templates``/``files``, which are pure read-only cards) --
the panel's start/stop/restart forms are mutating, same as
``vms_partial``/``host_partial``.
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``.
"""
config: Config = request.app.state.config
templates: Jinja2Templates = request.app.state.templates
@@ -466,18 +522,38 @@ def create_app(config: Config) -> FastAPI:
"""
# Deferred import: avoids a module-level import cycle (web.routes imports
# get_backend from this module).
from ci_orchestrator.web.routes import encode_vmx_id, register_action_routes
from ci_orchestrator.web.routes import (
encode_artifact_id,
encode_vmx_id,
register_action_routes,
)
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
app.state.templates.env.filters["artifact_id"] = encode_artifact_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)
# Cache-busting for the stylesheet: StaticFiles sends only ETag/
# Last-Modified (no Cache-Control), so a plain browser reload can serve a
# stale app.css after a redeploy. Stamping the <link> with the file's
# mtime -- re-read per render -- means every deploy changes the URL, so a
# normal navigation refetches the CSS with no hard-refresh needed.
static_css = _WEB_DIR / "static" / "app.css"
def _asset_version() -> str:
try:
return str(int(static_css.stat().st_mtime))
except OSError:
return "0"
app.state.templates.env.globals["asset_version"] = _asset_version
app.mount("/static", StaticFiles(directory=str(_WEB_DIR / "static")), name="static")
app.get("/healthz")(healthz)
@@ -491,8 +567,8 @@ 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("/runners", response_class=HTMLResponse, dependencies=[Depends(require_user)])(
runners_partial
)
app.get("/vmware", response_class=HTMLResponse, dependencies=[Depends(require_user)])(
vmware_partial
@@ -544,7 +620,8 @@ __all__ = [
"files_partial",
"get_backend",
"healthz",
"resources_partial",
"host_partial",
"runners_partial",
"services_partial",
"templates_partial",
"vmware_partial",