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