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",
+262 -13
View File
@@ -35,6 +35,7 @@ shells out to the CLI (see ``CLAUDE.md``).
from __future__ import annotations
import asyncio
import base64
import binascii
from datetime import UTC, datetime, timedelta
@@ -48,13 +49,19 @@ from starlette import status
from ci_orchestrator.operations.audit import AuditEntry, append_audit, read_audit
from ci_orchestrator.operations.cache import purge_cargo_cache
from ci_orchestrator.operations.host import backup_template
from ci_orchestrator.operations.inventory import list_templates
from ci_orchestrator.operations.inventory import delete_artifact, delete_backup, list_templates
from ci_orchestrator.operations.services import ALLOWED_ACTIONS, control_service, find_service
from ci_orchestrator.operations.vms import RemovalResult, cleanup_orphans, remove_vm
from ci_orchestrator.web.app import get_backend
from ci_orchestrator.web.auth import require_user
from ci_orchestrator.web.filters import humanbytes
from ci_orchestrator.web.security import issue_csrf, require_csrf
#: htmx client event emitted (via the ``HX-Trigger`` response header) on every
#: terminal action result, so the dashboard's polled panels reload immediately
#: instead of waiting up to 30s for their next poll (#3 / immediate refresh).
_REFRESH_EVENT = "ci:refresh"
if TYPE_CHECKING: # pragma: no cover
from fastapi.templating import Jinja2Templates
from starlette.datastructures import FormData
@@ -91,6 +98,30 @@ def _decode_vmx_id(vmx_id: str) -> Path | None:
return None
def encode_artifact_id(name: str) -> str:
"""Urlsafe-base64 an artifact filename for a ``/artifacts/{id}/delete`` path segment."""
return base64.urlsafe_b64encode(name.encode("utf-8")).decode("ascii").rstrip("=")
def _decode_artifact_id(artifact_id: str) -> str | None:
"""Inverse of :func:`encode_artifact_id`; ``None`` on malformed input or a path-bearing name.
A decoded name containing a path separator or a ``.``/``..`` component is
rejected here (returns ``None`` -> 400) so only a bare filename ever
reaches :func:`~ci_orchestrator.operations.inventory.delete_artifact` --
a defence-in-depth layer on top of that function's own resolve-and-
contain check.
"""
padded = artifact_id + "=" * (-len(artifact_id) % 4)
try:
name = base64.urlsafe_b64decode(padded).decode("utf-8")
except (binascii.Error, ValueError, UnicodeDecodeError):
return None
if not name or "/" in name or "\\" in name or name in (".", ".."):
return None
return name
def _is_contained(path: Path, base: Path) -> bool:
"""True iff ``base`` is an ancestor of (resolved) ``path`` -- never equal.
@@ -183,7 +214,12 @@ def _render_action(
plain form POST still lands on a styled page instead of unstyled HTML.
"""
templates: Jinja2Templates = request.app.state.templates
if _is_htmx_request(request):
is_htmx = _is_htmx_request(request)
# confirm.html renders as a real modal ``<dialog>``: on the htmx path JS
# calls ``showModal()`` after the swap, but the no-JS full-page fallback
# has no JS to open it, so it must render ``<dialog open>`` to stay visible.
context = {**context, "is_htmx": is_htmx}
if is_htmx:
return templates.TemplateResponse(
request, partial_template, context, status_code=status_code
)
@@ -223,9 +259,31 @@ def _confirm_partial(
return _render_action(request, "partials/confirm.html", context)
def _result_partial(request: Request, *, action_label: str, ok: bool, detail: str) -> HTMLResponse:
context = {"action_label": action_label, "ok": ok, "detail": detail}
return _render_action(request, "partials/result.html", context)
def _result_partial(
request: Request,
*,
action_label: str,
ok: bool,
detail: str,
level: str | None = None,
) -> HTMLResponse:
"""Render a terminal action result and ask polled panels to refresh.
``level`` overrides the visual state independently of ``ok``: it is one of
``"ok"`` (green), ``"info"`` (neutral -- e.g. a purge that found nothing to
remove), or ``"error"`` (red). When ``None`` it derives from ``ok``. Every
result -- success or failure -- carries the ``HX-Trigger: ci:refresh``
header so the dashboard reflects the real post-action state at once (#3).
"""
context = {
"action_label": action_label,
"ok": ok,
"detail": detail,
"level": level or ("ok" if ok else "error"),
}
response = _render_action(request, "partials/result.html", context)
response.headers["HX-Trigger"] = _REFRESH_EVENT
return response
def _lock_conflict_partial(request: Request, *, action_label: str, target: str) -> HTMLResponse:
@@ -379,34 +437,204 @@ async def cache_purge(
user: str = Depends(require_user),
_csrf: None = Depends(require_csrf),
) -> HTMLResponse:
"""Purge cargo cache entries past retention, confirm-gated."""
"""Purge cargo cache, confirm-gated. Two modes selected by the ``mode`` field.
Default (``mode`` absent/empty) is the age-based purge: only entries older
than ``retention_days`` are removed. ``mode=all`` empties the whole cache
regardless of age (``force=True``) -- the web UI's explicit "Empty all"
button. Every message spells out the max-age dependency so the age-based
purge finding nothing to remove no longer reads as a silent no-op.
"""
config: Config = request.app.state.config
action_label = "Purge cargo cache"
retention = config.cargo_cache.retention_days
target = str(config.cargo_cache.root)
form = await request.form()
force = _form_str(form, "mode") == "all"
execute = _form_str(form, _STAGE_FIELD) == _STAGE_EXECUTE
action_label = "Empty cargo cache" if force else "Purge cargo cache"
# Age-based purge exposes its max-age (retention window, in days) as an
# editable field so it is clear the purge is age-gated; the field defaults
# to the configured retention. A negative/garbage value falls back to it.
# Ignored entirely under force ("Empty all").
effective_days = _form_int(form, "max_age_days", retention)
if effective_days < 0:
effective_days = retention
try:
result = purge_cargo_cache(config, dry_run=not execute)
result = purge_cargo_cache(
config,
retention_days=None if force else effective_days,
force=force,
dry_run=not execute,
)
except Exception as exc: # never 500 on an operational failure
detail = f"error: {exc}"
_audit(config, user=user, action="cache.purge", target=target, result="error", detail=detail)
return _result_partial(request, action_label=action_label, ok=False, detail=detail)
freed = humanbytes(result.freed_bytes)
if execute:
detail = f"freed {result.freed_bytes} bytes across {result.removed_entries} entrie(s)"
if result.removed_entries == 0:
# Not an error -- the age-based purge simply found nothing past the
# window. Say so (and how to force) with a neutral, not-"ok" badge.
detail = (
f"Nothing removed — no cache entries are older than the "
f"{effective_days}-day max age. Use “Empty all” to clear "
f"the cache regardless of age."
)
_audit(config, user=user, action="cache.purge", target=target, result="ok", detail=detail)
return _result_partial(
request, action_label=action_label, ok=True, detail=detail, level="info"
)
detail = f"Freed {freed} across {result.removed_entries} entrie(s)."
_audit(config, user=user, action="cache.purge", target=target, result="ok", detail=detail)
return _result_partial(request, action_label=action_label, ok=True, detail=detail)
detail = f"would remove {result.removed_entries} entrie(s), freeing {result.freed_bytes} bytes"
_audit(config, user=user, action="cache.purge", target=target, result="dry-run", detail=detail)
if force:
summary = (
f"Empties the entire cache: removes all {result.removed_entries} "
f"entrie(s), freeing {freed}, ignoring the max-age window entirely."
)
else:
summary = (
f"Removes {result.removed_entries} entrie(s) older than {effective_days} day(s) "
f"(max age), freeing {freed}. Newer entries are kept — use “Empty all” "
f"to clear everything."
)
_audit(config, user=user, action="cache.purge", target=target, result="dry-run", detail=summary)
return _confirm_partial(
request,
action_label=action_label,
target=target,
summary=summary,
action_url="/cache/purge",
extra_fields={"mode": "all"} if force else {"max_age_days": str(effective_days)},
)
# ─────────────────────────────────────────────────────── POST /artifacts/{id}/delete
async def artifacts_delete(
artifact_id: str,
request: Request,
user: str = Depends(require_user),
_csrf: None = Depends(require_csrf),
) -> HTMLResponse:
"""Permanently delete one collected artifact file, behind the two-step confirm gate.
Mirrors :func:`vms_remove`'s shape: a malformed/path-bearing id is a pure
validation refusal (400, unaudited); a ``POST`` without ``stage=execute``
dry-runs + renders the confirm partial; ``stage=execute`` unlinks the
file. Containment (the file must resolve to a direct child of
``config.paths.artifacts``) is enforced twice -- in
:func:`_decode_artifact_id` and again in ``delete_artifact`` itself.
"""
config: Config = request.app.state.config
action_label = "Delete artifact"
name = _decode_artifact_id(artifact_id)
if name is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid artifact id"
)
target = name
form = await request.form()
execute = _form_str(form, _STAGE_FIELD) == _STAGE_EXECUTE
if execute:
result = delete_artifact(config, name, dry_run=False)
detail = f"deleted {name}" if result.ok else (result.error or "unknown error")
_audit(
config,
user=user,
action="artifact.delete",
target=target,
result="ok" if result.ok else "error",
detail=detail,
)
return _result_partial(request, action_label=action_label, ok=result.ok, detail=detail)
result = delete_artifact(config, name, dry_run=True)
if not result.ok:
# e.g. the file vanished between listing and this click -- a
# validation failure a real run could not fix, so report, don't confirm.
detail = result.error or "unknown error"
_audit(
config, user=user, action="artifact.delete", target=target, result="error", detail=detail
)
return _result_partial(request, action_label=action_label, ok=False, detail=detail)
detail = f"would delete artifact '{name}'"
_audit(config, user=user, action="artifact.delete", target=target, result="dry-run", detail=detail)
return _confirm_partial(
request,
action_label=action_label,
target=target,
summary=detail,
action_url="/cache/purge",
action_url=f"/artifacts/{artifact_id}/delete",
extra_fields={},
)
# ─────────────────────────────────────────────────────── POST /backups/{id}/delete
async def backups_delete(
backup_id: str,
request: Request,
user: str = Depends(require_user),
_csrf: None = Depends(require_csrf),
) -> HTMLResponse:
"""Permanently delete one template backup archive, behind the confirm gate.
Same shape as :func:`artifacts_delete`: a malformed/path-bearing id is a
pure validation refusal (400, unaudited); ``stage=execute`` unlinks the
``.7z``; anything else dry-runs + renders the confirm modal. Containment +
the ``Template_..._....7z`` name guard are enforced in ``delete_backup``.
"""
config: Config = request.app.state.config
action_label = "Delete backup"
name = _decode_artifact_id(backup_id)
if name is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid backup id")
target = name
form = await request.form()
execute = _form_str(form, _STAGE_FIELD) == _STAGE_EXECUTE
if execute:
result = delete_backup(config, name, dry_run=False)
detail = f"deleted {name}" if result.ok else (result.error or "unknown error")
_audit(
config,
user=user,
action="backup.delete",
target=target,
result="ok" if result.ok else "error",
detail=detail,
)
return _result_partial(request, action_label=action_label, ok=result.ok, detail=detail)
result = delete_backup(config, name, dry_run=True)
if not result.ok:
detail = result.error or "unknown error"
_audit(
config, user=user, action="backup.delete", target=target, result="error", detail=detail
)
return _result_partial(request, action_label=action_label, ok=False, detail=detail)
detail = f"would delete backup '{name}'"
_audit(config, user=user, action="backup.delete", target=target, result="dry-run", detail=detail)
return _confirm_partial(
request,
action_label=action_label,
target=target,
summary=detail,
action_url=f"/backups/{backup_id}/delete",
extra_fields={},
)
@@ -442,7 +670,13 @@ async def host_backup(
execute = _form_str(form, _STAGE_FIELD) == _STAGE_EXECUTE
try:
result = backup_template(config, template, dry_run=not execute)
# A real backup shells out to 7z for minutes on a multi-GB template.
# Run it in a worker thread so the compression never blocks the async
# event loop -- otherwise every other request (all the dashboard polls)
# would freeze for the whole duration and the confirm dialog would sit
# there looking hung. The instant dry-run path is offloaded too; the
# overhead is negligible.
result = await asyncio.to_thread(backup_template, config, template, dry_run=not execute)
except Exception as exc: # never 500 on an operational failure
detail = f"error: {exc}"
_audit(config, user=user, action="host.backup", target=template, result="error", detail=detail)
@@ -596,6 +830,18 @@ def register_action_routes(app: FastAPI) -> None:
)
app.add_api_route("/vms/cleanup", vms_cleanup, methods=["POST"], response_class=HTMLResponse)
app.add_api_route("/cache/purge", cache_purge, methods=["POST"], response_class=HTMLResponse)
app.add_api_route(
"/artifacts/{artifact_id}/delete",
artifacts_delete,
methods=["POST"],
response_class=HTMLResponse,
)
app.add_api_route(
"/backups/{backup_id}/delete",
backups_delete,
methods=["POST"],
response_class=HTMLResponse,
)
app.add_api_route("/host/backup", host_backup, methods=["POST"], response_class=HTMLResponse)
app.add_api_route(
"/services/{service_id}/{action}",
@@ -607,8 +853,11 @@ def register_action_routes(app: FastAPI) -> None:
__all__ = [
"artifacts_delete",
"audit_view",
"backups_delete",
"cache_purge",
"encode_artifact_id",
"encode_vmx_id",
"host_backup",
"register_action_routes",
+252 -16
View File
@@ -22,7 +22,14 @@
--danger: #f85149;
--warn: #d29922;
--link: #2f81f7;
--radius: 6px;
/* Single rounding token, matched to Gitea's `--border-radius` (~4px) so
* cards, buttons, inputs, code, notices and badges all round identically.
* The only intentional exception is the gauge/progress track (a pill). */
--radius: 4px;
/* Shared horizontal page padding — the navbar and the content column use
* the same value so their left/right edges line up at every width, while
* both span the full viewport (no centered max-width cap). */
--page-pad: clamp(1rem, 3vw, 2rem);
--font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, monospace;
}
@@ -51,11 +58,18 @@ code, .mono {
font-size: 0.9em;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 4px;
border-radius: var(--radius);
padding: 0.05em 0.4em;
color: var(--text);
}
/* Long filesystem paths in a status list (cache root, disk path) may wrap
* anywhere -- their <dd> is a flexible grid track. Inline code inside a TABLE
* cell deliberately does NOT wrap char-by-char: the table stays intact and its
* own .table-wrap scrolls if it must, rather than a path shattering vertically
* into one glyph per line. */
dd code, dd .mono { overflow-wrap: anywhere; }
/* ── navbar ───────────────────────────────────────────────────────────── */
.navbar {
@@ -64,9 +78,11 @@ code, .mono {
}
.navbar-inner {
max-width: 1200px;
margin: 0 auto;
padding: 0.85rem 1.5rem;
/* Full-bleed header: no centered max-width cap. Shares --page-pad with
* .content so the brand hugs the left edge and the links the right edge,
* aligned to the dashboard grid below at every viewport width. */
width: 100%;
padding: 0.85rem var(--page-pad);
display: flex;
align-items: center;
justify-content: space-between;
@@ -96,7 +112,7 @@ code, .mono {
/* ── layout ───────────────────────────────────────────────────────────── */
.content { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
.content { width: 100%; margin: 0; padding: 1.5rem var(--page-pad); }
/* Feedback region for htmx-swapped confirm/result/lock-conflict partials
* (A4). Empty at rest -- occupies no visible space until an action fills
@@ -108,7 +124,9 @@ code, .mono {
.status-header {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
/* Six tiles (VMs, Runners, CPU, RAM, Disk, Cache) -- a smaller min track
* lets all six sit on one row on a wide screen, wrapping gracefully below. */
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin-bottom: 1.25rem;
}
@@ -174,10 +192,26 @@ code, .mono {
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
/* FIXED four-column track, NOT auto-fit. A `.card--wide` spans `1 / -1`,
* i.e. occupies every column on its row -- with `auto-fit` that means no
* track is ever globally empty, so auto-fit can't collapse the compact
* row's spare tracks and the four info cards stayed narrow with dead
* columns on the right. A fixed count makes the four compact cards fill
* their row exactly, and the wide tables still span all four via
* `grid-column: 1 / -1`. minmax(0,1fr) lets a card shrink below its
* content width so a wide table scrolls inside its own .table-wrap
* instead of forcing the page to scroll. Column count steps down with
* width (below). */
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1.25rem;
align-items: start;
/* stretch: every card in a row shares the row's height, so the grid reads
* as tidy aligned rows instead of ragged different-height cards. Cards are
* flex-columns (below), so light cards simply gain bottom padding. */
align-items: stretch;
}
@media (max-width: 1200px) { .dashboard-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 900px) { .dashboard-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 620px) { .dashboard-grid { grid-template-columns: 1fr; } }
/* A polling wrapper (htmx hx-get/hx-trigger target) that must not itself
* become a grid box — its children (the real .card sections) lay out
@@ -191,8 +225,79 @@ code, .mono {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
/* Flex column so a trailing action form can pin to the card's bottom edge
* (margin-top:auto) and short cards stretch cleanly to the row height. */
display: flex;
flex-direction: column;
/* min-width:0 lets a card shrink below its content's intrinsic width in the
* grid track; without it an unbreakable long path/table forces the whole
* page to scroll horizontally. Wide tables scroll inside their own
* .table-wrap instead. */
min-width: 0;
}
/* Every direct card child may shrink; the table-wrap owns any overflow. */
.card > * { min-width: 0; }
/* Wide, many-column tables (Live VMs, Templates, Services) span the full grid
* row instead of being squeezed into one ~320px track -- a squeezed 8-column
* table is what forced paths to wrap and the card to scroll horizontally. */
.card--wide { grid-column: 1 / -1; }
/* Danger-zone card (Maintenance): a red left rule marks it as the home of the
* sweeping destructive operations. */
.card--danger { border-left: 4px solid var(--danger); }
/* Destructive operations stacked as separated rows (row separators like the
* tables above): each op is a full-width row, description on the left and its
* controls on the right, divided by a hairline. */
.maintenance-grid { display: flex; flex-direction: column; }
.maintenance-op {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem 1.5rem;
padding: 0.9rem 0;
border-bottom: 1px solid var(--border);
}
.maintenance-op:last-child { border-bottom: none; }
.maintenance-op:first-child { padding-top: 0.4rem; }
/* Left column: title + hint. Grows to fill, but never below a readable width
* so the controls stay on the right until the row genuinely must wrap. */
.maintenance-op-info { flex: 1 1 320px; min-width: 0; }
.maintenance-op h3 { font-size: 0.95rem; margin: 0 0 0.15rem; }
.maintenance-op .field-hint { margin: 0; }
.maintenance-op form { flex: none; }
/* The max-age control sits on its own row above the action buttons, so it
* reads as a general max-age parameter of the operation (not part of a
* button). */
.op-field { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
.op-field label { font-size: 0.85rem; color: var(--text-dim); white-space: nowrap; }
.op-field input { width: 6rem; }
/* A row of buttons (e.g. Purge / Empty all) that wraps on narrow cards. */
.btn-row { display: flex; flex-wrap: wrap; gap: 0.5rem; }
/* Per-template expandable backup list (Templates panel). */
.backups-details > summary {
cursor: pointer;
color: var(--link);
font-size: 0.85rem;
white-space: nowrap;
}
.backups-details[open] > summary { margin-bottom: 0.35rem; }
.backups-list { list-style: none; margin: 0; padding: 0; }
.backups-list li {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: baseline;
padding: 0.2rem 0;
font-size: 0.82rem;
}
/* Per-backup delete button pinned to the right end of its row. */
.backup-del { margin-left: auto; align-self: center; }
.card h2 {
display: flex;
align-items: center;
@@ -212,7 +317,10 @@ code, .mono {
/* ── definition lists (status cards) ─────────────────────────────────── */
dl { display: grid; grid-template-columns: auto 1fr; gap: 0.35rem 0.9rem; }
/* Key/value info cards (cache, host) rendered as bordered rows so they read
* as the same tabular style as the Runners / VMware-modules tables. */
dl { display: grid; grid-template-columns: auto 1fr; gap: 0 0.9rem; }
dt, dd { padding: 0.45rem 0; border-bottom: 1px solid var(--border); }
dt { color: var(--text-dim); font-weight: 500; }
dd { margin: 0; }
@@ -246,15 +354,21 @@ tr.row-alert td { color: var(--danger); }
/* ── status badges ────────────────────────────────────────────────────── */
.badge {
display: inline-block;
padding: 0.15rem 0.6rem;
border-radius: 999px;
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.1rem 0.5rem;
/* Gitea-style rounded-rectangle label, not a full pill -- keeps the corner
* radius coherent with cards/buttons/inputs. */
border-radius: var(--radius);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.02em;
line-height: 1.5;
white-space: nowrap;
}
/* The check/alert icon many badges carry should track the badge text size. */
.badge .icon { width: 0.95em; height: 0.95em; }
.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); }
@@ -281,15 +395,29 @@ tr.row-alert td { color: var(--danger); }
.btn:active { transform: translateY(1px); }
.btn-primary { background: var(--accent); color: #ffffff; border-color: var(--accent); }
.btn-primary:hover { background: var(--accent-hover); }
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn-danger { background: transparent; color: var(--danger); border-color: var(--danger); }
.btn-danger:hover { background: var(--danger); color: #2b0705; }
/* Destructive actions: a NEUTRAL button at rest (same panel surface + border
* as .btn) that only reads as red through its text/icon -- no red border or
* fill glaring on the page. Danger becomes fully visible on hover (red border
* + soft red wash). The real safeguard is the two-step confirm gate, not the
* colour. */
.btn-danger { background: var(--panel-2); color: var(--danger); border-color: var(--border); }
.btn-danger:hover { background: rgba(248, 81, 73, 0.14); color: var(--danger); border-color: var(--danger); }
/* Compact variant for buttons that live inside a table row (VM remove,
* per-service start/stop/restart) so a row of them stays low-profile. */
.btn-sm { padding: 0.25rem 0.6rem; font-size: 0.8rem; }
/* ── forms ────────────────────────────────────────────────────────────── */
form { margin-top: 1rem; display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem; }
/* A one-button action form living inside a table cell (VM remove, per-row
* template Backup, artifact Delete) must not inherit the block form's
* top-margin, which would shove the button below its row's baseline. */
td form { margin-top: 0; }
label { font-size: 0.85rem; color: var(--text-dim); display: inline-flex; align-items: center; gap: 0.4rem; }
input[type="text"], input[type="number"], select {
@@ -335,6 +463,114 @@ input[type="text"], input[type="number"], select {
}
.result-ok { border-color: rgba(63, 185, 80, 0.4); background: rgba(63, 185, 80, 0.08); }
.result-error { border-color: rgba(248, 81, 73, 0.4); background: rgba(248, 81, 73, 0.08); }
/* Neutral state: an action that legitimately did nothing (e.g. an age-based
* purge with nothing past the window) -- informative, not success, not error. */
.result-info { border-color: rgba(65, 131, 196, 0.45); background: rgba(65, 131, 196, 0.1); }
/* ── confirm modal (<dialog>) ─────────────────────────────────────────── */
.modal {
border: none;
background: transparent;
padding: 0;
max-width: min(92vw, 460px);
color: var(--text);
}
.modal::backdrop { background: rgba(0, 0, 0, 0.55); }
.modal-card {
background: var(--panel);
border: 1px solid var(--border);
border-left: 3px solid var(--warn);
border-radius: var(--radius);
padding: 1.1rem 1.25rem;
}
.modal-card h3 { margin: 0 0 0.5rem; }
.modal-card p { margin: 0.35rem 0; font-size: 0.9rem; }
.modal-actions {
display: flex;
gap: 0.6rem;
justify-content: flex-end;
margin-top: 1rem;
}
/* ── toast stack (transient action results) ───────────────────────────── */
/* Fixed top-right column that toasts are appended to. pointer-events:none so
* the empty column never eats clicks on the page beneath; each .toast re-opts
* into pointer-events so its close button still works. */
.toast-stack {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 0.5rem;
width: min(92vw, 420px);
pointer-events: none;
}
.toast {
pointer-events: auto;
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.8rem 0.9rem 0.8rem 1rem;
border-radius: var(--radius);
border: 1px solid var(--border);
/* A 4px left stripe carries the state colour (overridden per-state below);
* this default handles the busy/unknown case. */
border-left: 4px solid var(--border);
background: var(--panel);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
animation: toast-in 160ms ease-out;
}
/* Per-state colour: a coloured left stripe plus a faint tint. Uses the
* two-class ``.toast.result-*`` selector so it beats the plain ``.toast``
* background above (equal specificity would otherwise let ``.toast`` win by
* source order, which is the bug that made the state colour invisible). */
.toast.result-ok { border-left-color: var(--success); background: rgba(63, 185, 80, 0.10); }
.toast.result-info { border-left-color: var(--accent); background: rgba(65, 131, 196, 0.12); }
.toast.result-error { border-left-color: var(--danger); background: rgba(248, 81, 73, 0.10); }
.toast.toast-busy { border-left-color: var(--accent); }
@keyframes toast-in { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: none; } }
.toast--closing { opacity: 0; transform: translateY(-6px); transition: opacity 180ms ease, transform 180ms ease; }
.toast-body { min-width: 0; }
.toast-title { display: block; font-size: 0.9rem; }
.toast-detail { margin: 0.2rem 0 0; font-size: 0.85rem; color: var(--text-dim); }
.toast-close {
order: 2;
margin-left: auto;
flex: none;
background: none;
border: none;
cursor: pointer;
color: var(--text-dim);
font-size: 1.25rem;
line-height: 1;
padding: 0 0.15rem;
}
.toast-close:hover { color: var(--text); }
/* Busy variant: a spinner instead of a close button, shown while a confirmed
* action (e.g. a large backup) is still running. */
.toast-busy { align-items: center; }
.spinner {
flex: none;
width: 1.1rem;
height: 1.1rem;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.toast { animation: none; }
.toast--closing { transition: none; }
/* Keep a (slower) spin: the spinner is the only "still working" signal. */
.spinner { animation-duration: 1.4s; }
}
/* ── icon sprite (A6) ─────────────────────────────────────────────────── */
+198
View File
@@ -0,0 +1,198 @@
/*
* Dashboard action behaviour: turns the htmx-swapped confirm/result partials
* into a modal <dialog> (confirm) and a stack of auto-dismissing toasts
* (results + busy spinners). Pure vanilla JS, no dependencies beyond htmx's
* own swap/request events.
*
* - confirm.html -> <dialog class="modal"> in #action-slot : opened with
* showModal() so it is a real focus-trapping, backdrop-dimming modal;
* Cancel / ESC clear the slot. (No-JS path renders <dialog open> instead.)
* - result.html -> .toast appended to #toast-stack : the confirm form swaps
* its terminal result there with hx-swap="beforeend", so concurrent actions
* (e.g. two template backups at once) each keep their own toast instead of
* the later one replacing the earlier. A MutationObserver wires each new
* toast; a close button dismisses it and everything but an error also
* auto-dismisses after AUTODISMISS_MS.
* - busy toast -> .toast.toast-busy appended to #toast-stack while a
* confirmed (possibly slow, multi-GB) action is in flight; removed when its
* request settles.
*/
(function () {
"use strict";
var AUTODISMISS_MS = 6000;
var FADE_MS = 200;
function toastStack() {
return document.getElementById("toast-stack");
}
function clearSlot() {
var slot = document.getElementById("action-slot");
if (slot) {
slot.innerHTML = "";
}
}
function dismissToast(toast) {
if (!toast || toast.dataset.closing === "true") {
return;
}
toast.dataset.closing = "true";
toast.classList.add("toast--closing");
window.setTimeout(function () {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, FADE_MS);
}
function wireToast(toast) {
if (toast.dataset.wired === "true") {
return;
}
toast.dataset.wired = "true";
var closeBtn = toast.querySelector("[data-toast-close]");
if (closeBtn) {
closeBtn.addEventListener("click", function () {
dismissToast(toast);
});
}
// Errors stay put until dismissed (data-autodismiss="false"); every other
// result fades on its own after a few seconds.
if (toast.dataset.autodismiss !== "false") {
window.setTimeout(function () {
dismissToast(toast);
}, AUTODISMISS_MS);
}
}
function showBusyToast(dialog) {
// A non-dismissable spinner toast shown while a (possibly slow) confirmed
// action is in flight -- e.g. a multi-GB template backup. Appended to the
// stack so it coexists with any other running action's toast, and returned
// so the caller can remove it once the request settles.
var stack = toastStack();
if (!stack) {
return null;
}
var label = dialog.getAttribute("data-action-label") || "Working";
var toast = document.createElement("div");
toast.className = "toast toast-busy";
toast.setAttribute("role", "status");
toast.setAttribute("aria-live", "polite");
toast.innerHTML =
'<span class="spinner" aria-hidden="true"></span>' +
'<div class="toast-body"><strong class="toast-title"></strong>' +
'<p class="toast-detail">Working… this can take a while for large templates.</p></div>';
// textContent, so a template/label can never inject markup.
toast.querySelector(".toast-title").textContent = label + " — in progress";
stack.appendChild(toast);
return toast;
}
function wireModal(dialog) {
// Upgrade an inert/closed dialog into a true modal where supported. On the
// no-JS full-page path it already renders `open`; closing then re-opening
// via showModal() gives the backdrop + focus trap without a double-open.
if (typeof dialog.showModal === "function") {
if (dialog.open) {
dialog.close();
}
dialog.showModal();
}
var cancel = dialog.querySelector("[data-modal-cancel]");
if (cancel) {
cancel.addEventListener("click", function () {
if (dialog.open) {
dialog.close();
}
clearSlot();
});
}
// ESC / backdrop dismiss fires `cancel` (not `close`, and never on a
// programmatic close()); clear the slot once the native close settles.
dialog.addEventListener("cancel", function () {
window.setTimeout(clearSlot, 0);
});
// On Confirm: close the modal at once and show a busy spinner toast in the
// stack until the request settles. The terminal result is appended to the
// stack by htmx (hx-swap="beforeend"); on afterRequest we drop our busy
// toast and clear the now-defunct dialog from #action-slot. Uses
// htmx:beforeSend so the XHR is already prepared -- closing the dialog here
// cannot cancel it. busyEl is per-request, captured in this closure.
var form = dialog.querySelector("form");
if (form) {
var busyEl = null;
form.addEventListener("htmx:beforeSend", function () {
if (dialog.open) {
dialog.close();
}
busyEl = showBusyToast(dialog);
});
form.addEventListener("htmx:afterRequest", function () {
if (busyEl) {
dismissToast(busyEl);
busyEl = null;
}
// The dialog stays in #action-slot after the result went to the stack;
// remove it now that the action is done. isConnected guards a slot
// already cleared by an ESC/Cancel race.
if (dialog.isConnected) {
clearSlot();
}
});
}
}
function processSlot(root) {
// #action-slot only ever holds a confirm <dialog> now; the no-JS full-page
// result path also drops a bare .toast here, so wire that too for that case.
if (!root || !root.querySelector) {
return;
}
var dialog = root.querySelector("dialog.modal");
if (dialog) {
wireModal(dialog);
}
var toast = root.querySelector(".toast");
if (toast) {
wireToast(toast);
}
}
function observeStack() {
// Result toasts arrive via htmx beforeend swaps (not into #action-slot), so
// wire them as they are inserted. Busy toasts are created + managed by
// showBusyToast/wireModal, so skip them here.
var stack = toastStack();
if (!stack || typeof MutationObserver !== "function") {
return;
}
new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
Array.prototype.forEach.call(mutation.addedNodes, function (node) {
if (
node.nodeType === 1 &&
node.classList.contains("toast") &&
!node.classList.contains("toast-busy")
) {
wireToast(node);
}
});
});
}).observe(stack, { childList: true });
}
document.body.addEventListener("htmx:afterSwap", function (evt) {
var target = evt.detail && evt.detail.target;
if (target && target.id === "action-slot") {
processSlot(target);
}
});
document.addEventListener("DOMContentLoaded", function () {
observeStack();
processSlot(document.getElementById("action-slot"));
});
})();
+10 -1
View File
@@ -4,8 +4,9 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}ci-orchestrator{% endblock %}</title>
<link rel="stylesheet" href="/static/app.css">
<link rel="stylesheet" href="/static/app.css?v={{ asset_version() }}">
<script src="/static/htmx.min.js" defer></script>
<script src="/static/app.js?v={{ asset_version() }}" defer></script>
</head>
<body>
{% include "partials/icons.html" %}
@@ -32,5 +33,13 @@
<div id="action-slot"></div>
{% endblock %}
</main>
{#
Toast stack: a fixed, top-right column that action results and busy
spinners are *appended* to (never replaced), so concurrent operations --
e.g. two template backups running at once -- each get their own toast
instead of the later one clobbering the earlier. The confirm modal still
lives in #action-slot above; only its terminal result lands here.
#}
<div id="toast-stack" class="toast-stack" role="region" aria-label="Notifications" aria-live="polite"></div>
</body>
</html>
@@ -5,14 +5,25 @@
{% block content %}
{{ super() }}
{% include "partials/status_header.html" %}
{#
Layout ordering (Stage E). Narrative top-to-bottom: what's running now
(VMs) -> infrastructure health -> 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 between the VMs
table and the Templates table, ordered Host -> VMware -> Runners -> Cargo
cache.
#}
<div class="dashboard-grid">
{% include "partials/vms.html" %}
{% include "partials/cache.html" %}
{% include "partials/host.html" %}
{% include "partials/resources.html" %}
{% include "partials/vmware.html" %}
{% 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/templates.html" %}
{% include "partials/files.html" %}
{% include "partials/services.html" %}
{% include "partials/maintenance.html" %}
</div>
{% endblock %}
@@ -1,20 +1,21 @@
<section class="card" id="cache-card" hx-get="/cache" hx-trigger="every 5s" hx-swap="outerHTML">
<section class="card" id="cache-card" hx-get="/cache" hx-trigger="every 5s, ci:refresh from:body" hx-swap="outerHTML">
<h2><svg class="icon" aria-hidden="true"><use href="#i-file"></use></svg>Cargo cache</h2>
{% if cache %}
{# Read-only info card -- the destructive "Purge" action lives in the
Maintenance card at the bottom of the dashboard (Stage E). #}
<dl>
<dt>Root</dt><dd><code>{{ cache.root }}</code></dd>
<dt>Total size</dt><dd>{{ cache.total_bytes | humanbytes }}</dd>
<dt>Entries</dt><dd>{{ cache.entry_count | thousands }}</dd>
<dt>Oldest entry age</dt>
<dt>Oldest entry</dt>
<dd>{{ cache.oldest_age_days ~ " day(s)" if cache.oldest_age_days is not none else "-" }}</dd>
<dt>Retention</dt><dd>{{ cache.retention_days }} day(s)</dd>
</dl>
<p class="field-hint">
“Purge (max age)” in Maintenance removes only entries older than this
retention window; “Empty all” clears the cache regardless of age.
</p>
{% else %}
<p class="notice notice-warn" role="alert">Cargo cache status unavailable.</p>
{% endif %}
<form method="post" action="/cache/purge" id="cache-purge-form" aria-label="Purge cargo cache"
hx-post="/cache/purge" hx-target="#action-slot" hx-swap="innerHTML">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger" aria-label="Purge cargo cache">Purge cache</button>
</form>
</section>
@@ -1,16 +1,35 @@
<div class="confirm-box notice notice-warn" id="action-confirm" role="alert">
<h3>Confirm: {{ action_label }}</h3>
<p>Target: <code>{{ target }}</code></p>
<p>{{ summary }}</p>
{% if warning %}
<p class="notice notice-warn" role="alert">{{ warning }}</p>
{% endif %}
<form method="post" action="{{ action_url }}" hx-post="{{ action_url }}" hx-target="#action-slot" hx-swap="innerHTML">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<input type="hidden" name="stage" value="execute">
{% for key, value in extra_fields.items() %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endfor %}
<button type="submit" class="btn btn-danger" aria-label="Confirm: {{ action_label }}">Confirm</button>
</form>
</div>
{#
Confirm gate rendered as a real modal <dialog> (not a toast): a blocking,
focus-trapping prompt the user must act on. On the htmx path it renders
closed and app.js calls dialog.showModal() after the swap; the no-JS
full-page fallback has no JS to open it, so it renders ``open`` (visible
inline) when ``is_htmx`` is false. Submitting posts stage=execute and
*appends* the result toast to #toast-stack (so it coexists with other
in-flight results); app.js closes this dialog on beforeSend and clears
#action-slot on afterRequest, so the modal never lingers.
#}
<dialog class="modal confirm-modal" id="action-confirm" aria-labelledby="confirm-title"
data-action-label="{{ action_label }}" {% if not is_htmx %}open{% endif %}>
<div class="modal-card">
<h3 id="confirm-title">Confirm: {{ action_label }}</h3>
<p>Target: <code>{{ target }}</code></p>
<p>{{ summary }}</p>
{% if warning %}
<p class="notice notice-warn" role="alert">{{ warning }}</p>
{% endif %}
<form method="post" action="{{ action_url }}" hx-post="{{ action_url }}"
hx-target="#toast-stack" hx-swap="beforeend">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<input type="hidden" name="stage" value="execute">
{% for key, value in extra_fields.items() %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endfor %}
<div class="modal-actions">
<button type="button" class="btn" data-modal-cancel
aria-label="Cancel: {{ action_label }}">Cancel</button>
<button type="submit" class="btn btn-primary"
aria-label="Confirm: {{ action_label }}">Confirm</button>
</div>
</form>
</div>
</dialog>
@@ -1,42 +1,29 @@
<div class="contents" id="files-partial" hx-get="/files" hx-trigger="every 30s" hx-swap="outerHTML">
<section class="card" id="backups-card">
<h2><svg class="icon" aria-hidden="true"><use href="#i-file"></use></svg>Template backups</h2>
{% if backups is none %}
<p class="notice notice-warn" role="alert">Backup listing unavailable.</p>
{% elif backups %}
<div class="table-wrap">
<table>
<thead><tr><th>Name</th><th>Size</th><th>Created</th></tr></thead>
<tbody>
{% for entry in backups %}
<tr>
<td><code>{{ entry.name }}</code></td>
<td>{{ entry.size_bytes | humanbytes }}</td>
<td>{{ entry.mtime | relative_time }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p>No backups found.</p>
{% endif %}
</section>
<section class="card" id="artifacts-card">
<div class="contents" id="files-partial" hx-get="/files" hx-trigger="every 30s, ci:refresh from:body" hx-swap="outerHTML">
<section class="card card--wide" id="artifacts-card">
<h2><svg class="icon" aria-hidden="true"><use href="#i-file"></use></svg>Artifacts</h2>
<p class="field-hint">Build outputs collected from finished jobs (binaries, bundles, logs). Delete to reclaim space.</p>
{% if artifacts is none %}
<p class="notice notice-warn" role="alert">Artifact listing unavailable.</p>
{% elif artifacts %}
<div class="table-wrap">
<table>
<thead><tr><th>Name</th><th>Size</th><th>Created</th></tr></thead>
<thead><tr><th>Name</th><th>Size</th><th>Created</th><th>Actions</th></tr></thead>
<tbody>
{% for entry in artifacts %}
<tr>
<td><code>{{ entry.name }}</code></td>
<td><code title="{{ entry.name }}">{{ entry.name }}</code></td>
<td>{{ entry.size_bytes | humanbytes }}</td>
<td>{{ entry.mtime | relative_time }}</td>
<td title="{{ entry.mtime | localtime }}">{{ entry.mtime | relative_time }}</td>
<td>
<form method="post" action="/artifacts/{{ entry.name | artifact_id }}/delete"
aria-label="Delete artifact {{ entry.name }}"
hx-post="/artifacts/{{ entry.name | artifact_id }}/delete" 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="Delete artifact {{ entry.name }}">
<svg class="icon" aria-hidden="true"><use href="#i-trash"></use></svg>Delete
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
@@ -1,65 +1,16 @@
<div class="contents" id="host-partial" hx-get="/host" hx-trigger="every 5s" hx-swap="outerHTML">
<section class="card" id="disk-card">
<h2><svg class="icon" aria-hidden="true"><use href="#i-disk"></use></svg>Disk</h2>
{% if disk %}
<dl>
<dt>Path</dt><dd><code>{{ disk.path }}</code></dd>
<dt>Total</dt><dd>{{ "%.1f"|format(disk.total_gb) }} GB</dd>
<dt>Free</dt><dd>{{ "%.1f"|format(disk.free_gb) }} GB</dd>
<dt>Used</dt><dd>{{ "%.1f"|format(disk.used_pct) }}%</dd>
</dl>
{% else %}
<p class="notice notice-warn" role="alert">Disk status unavailable.</p>
{% endif %}
</section>
<section class="card" id="runner-card">
<h2><svg class="icon" aria-hidden="true"><use href="#i-gear"></use></svg>Runners</h2>
{% if runners %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Service</th>
<th>Status</th>
<th>Sub-state</th>
</tr>
</thead>
<tbody>
{% for runner in runners %}
<tr class="{{ 'row-alert' if not runner.active else '' }}">
<td>{{ runner.name }}</td>
<td>
{% if runner.active %}
<span class="badge badge-success">active</span>
{% else %}
<span class="badge badge-danger">down</span>
{% endif %}
</td>
<td>{{ runner.sub_state }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="notice notice-warn" role="alert">Runner status unavailable.</p>
{% endif %}
</section>
<section class="card" id="backup-card">
<h2><svg class="icon" aria-hidden="true"><use href="#i-box"></use></svg>Template backup</h2>
<p class="field-hint">7z archive of the template dir under &lt;root&gt;/backups</p>
<form method="post" action="/host/backup" id="host-backup-form" aria-label="Backup template"
hx-post="/host/backup" hx-target="#action-slot" hx-swap="innerHTML">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<label for="host-backup-template">Template</label>
<select id="host-backup-template" name="template" required hx-preserve="true">
{% for name in template_names %}
<option value="{{ name }}">{{ name }}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-danger" aria-label="Backup template">Backup template</button>
</form>
</section>
</div>
<section class="card" id="host-card" hx-get="/host" hx-trigger="every 5s, ci:refresh from:body" hx-swap="outerHTML">
<h2><svg class="icon" aria-hidden="true"><use href="#i-cpu"></use></svg>Host</h2>
{% if resources %}
{# CPU% and RAM live in the status-header tiles now -- this card keeps only
the details that aren't up there: uptime, load average, core count. #}
<dl>
<dt>Uptime</dt><dd>{{ resources.uptime_sec | duration }}</dd>
<dt>Load avg</dt>
<dd>{{ "%.2f"|format(resources.load1) }} / {{ "%.2f"|format(resources.load5) }} / {{ "%.2f"|format(resources.load15) }}</dd>
<dt>CPU cores</dt><dd>{{ resources.ncpu }}</dd>
<dt>Memory</dt><dd>{{ "%.1f"|format(resources.mem_total_gb) }} GB total</dd>
</dl>
{% else %}
<p class="notice notice-warn" role="alert">Host resource status unavailable.</p>
{% endif %}
</section>
@@ -27,6 +27,24 @@
<rect x="19.5" y="10" width="3" height="4"></rect>
</symbol>
<symbol id="i-memory" viewBox="0 0 24 24">
<rect x="2" y="7" width="20" height="10" rx="1.5"></rect>
<line x1="6" y1="17" x2="6" y2="20"></line>
<line x1="12" y1="17" x2="12" y2="20"></line>
<line x1="18" y1="17" x2="18" y2="20"></line>
<line x1="7" y1="11" x2="9" y2="11"></line>
<line x1="11" y1="11" x2="13" y2="11"></line>
<line x1="15" y1="11" x2="17" y2="11"></line>
</symbol>
<symbol id="i-trash" viewBox="0 0 24 24">
<path d="M3 6h18"></path>
<path d="M8 6V4a1 1 0 011-1h6a1 1 0 011 1v2"></path>
<path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</symbol>
<symbol id="i-box" viewBox="0 0 24 24">
<path d="M21 8l-9-5-9 5 9 5 9-5z"></path>
<path d="M3 8v8l9 5 9-5V8"></path>
@@ -0,0 +1,65 @@
{#
Maintenance card (Stage E). The global destructive sweeps, gathered into one
card at the bottom of the dashboard. Marked as a danger zone by the red left
rule (.card--danger); the destructive ops are stacked as separated rows --
description on the left, controls on the right, divided by a hairline like
the tables above. Per-row destructive actions (VM remove, artifact delete)
stay contextual in their own tables. Both sweeps go through the same modal
confirm gate as before.
#}
<section class="card card--wide card--danger" id="maintenance-card">
<h2><svg class="icon" aria-hidden="true"><use href="#i-alert"></use></svg>Maintenance</h2>
<p class="field-hint">Destructive operations — each asks for confirmation before it runs.</p>
<div class="maintenance-grid">
<div class="maintenance-op">
<div class="maintenance-op-info">
<h3>Cleanup orphaned VMs</h3>
<p class="field-hint">Delete orphaned clone directories older than the given age.</p>
</div>
<form method="post" action="/vms/cleanup" id="vms-cleanup-form" aria-label="Cleanup orphaned VMs"
hx-post="/vms/cleanup" hx-target="#action-slot" hx-swap="innerHTML">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<div class="op-field">
<label for="vms-cleanup-max-age">Max age (h)</label>
<input type="number" id="vms-cleanup-max-age" name="max_age_hours" value="4" min="0" hx-preserve="true">
</div>
<div class="btn-row">
<button type="submit" class="btn btn-danger" aria-label="Cleanup orphaned VMs older than the given age">
<svg class="icon" aria-hidden="true"><use href="#i-trash"></use></svg>Cleanup orphans
</button>
</div>
</form>
</div>
<div class="maintenance-op">
<div class="maintenance-op-info">
<h3>Purge cargo cache</h3>
<p class="field-hint">
<strong>Purge (max age)</strong> removes only entries older than the
retention window. <strong>Empty all</strong> clears the whole cache now,
regardless of age.
</p>
</div>
<form method="post" action="/cache/purge" id="cache-purge-form" aria-label="Purge cargo cache"
hx-post="/cache/purge" hx-target="#action-slot" hx-swap="innerHTML">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<div class="op-field">
<label for="cache-max-age">Max age (days)</label>
<input type="number" id="cache-max-age" name="max_age_days"
value="{{ cache.retention_days if cache else 30 }}" min="0" hx-preserve="true">
</div>
<div class="btn-row">
<button type="submit" class="btn btn-danger" aria-label="Purge cargo cache entries older than the max age">
<svg class="icon" aria-hidden="true"><use href="#i-trash"></use></svg>Purge (max age)
</button>
<button type="submit" name="mode" value="all" class="btn btn-danger"
aria-label="Empty the entire cargo cache regardless of age">
<svg class="icon" aria-hidden="true"><use href="#i-trash"></use></svg>Empty all
</button>
</div>
</form>
</div>
</div>
</section>
@@ -1,21 +0,0 @@
<section class="card" id="resources-card" hx-get="/resources" hx-trigger="every 5s" hx-swap="outerHTML">
<h2><svg class="icon" aria-hidden="true"><use href="#i-cpu"></use></svg>Host resources</h2>
{% if resources %}
<dl>
<dt>CPU</dt>
<dd>
{{ "%.1f"|format(resources.cpu_pct) }}% across {{ resources.ncpu }} core(s)
<div class="gauge" role="img" aria-label="CPU {{ '%.1f'|format(resources.cpu_pct) }} percent used">
<div class="gauge-fill" style="width: {{ resources.cpu_pct }}%"></div>
</div>
</dd>
<dt>Memory</dt>
<dd>{{ "%.1f"|format(resources.mem_used_gb) }} / {{ "%.1f"|format(resources.mem_total_gb) }} GB</dd>
<dt>Uptime</dt><dd>{{ resources.uptime_sec | duration }}</dd>
<dt>Load avg</dt>
<dd>{{ "%.2f"|format(resources.load1) }} / {{ "%.2f"|format(resources.load5) }} / {{ "%.2f"|format(resources.load15) }}</dd>
</dl>
{% else %}
<p class="notice notice-warn" role="alert">Host resource status unavailable.</p>
{% endif %}
</section>
@@ -1,11 +1,19 @@
<div class="action-result {{ 'result-ok' if ok else 'result-error' }}" id="action-result" role="status" aria-live="polite">
<h3>
{{ action_label }} --
{% if ok %}
<span class="badge badge-success">ok</span>
{% else %}
<span class="badge badge-danger">error</span>
{% endif %}
</h3>
<p>{{ detail }}</p>
{#
Terminal action result rendered as a transient toast, *appended* to the
fixed #toast-stack (see base.html + app.css) so it stacks alongside any
other in-flight results instead of replacing them. ``level`` is
ok | info | error and drives the left-stripe + tint via ``result-<level>``.
A MutationObserver in app.js wires the close button and auto-dismisses
everything except errors (``data-autodismiss``), which stay until dismissed
so a failure is never missed. No ``id`` -- several may coexist in the stack.
The response also carries ``HX-Trigger: ci:refresh`` so the dashboard panels
(incl. Templates, where a fresh backup appears) reload at once.
#}
<div class="toast result-{{ level }}" role="status" aria-live="polite"
data-autodismiss="{{ 'false' if level == 'error' else 'true' }}">
<button type="button" class="toast-close" data-toast-close aria-label="Dismiss notification">&times;</button>
<div class="toast-body">
<strong class="toast-title">{{ action_label }}</strong>
<p class="toast-detail">{{ detail }}</p>
</div>
</div>
@@ -0,0 +1,35 @@
<div class="contents" id="runners-partial" hx-get="/runners" hx-trigger="every 5s, ci:refresh from:body" hx-swap="outerHTML">
<section class="card" id="runner-card">
<h2><svg class="icon" aria-hidden="true"><use href="#i-gear"></use></svg>Runners</h2>
{% if runners %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Service</th>
<th>Status</th>
<th>Sub-state</th>
</tr>
</thead>
<tbody>
{% for runner in runners %}
<tr class="{{ 'row-alert' if not runner.active else '' }}">
<td>{{ runner.name }}</td>
<td>
{% if runner.active %}
<span class="badge badge-success"><svg class="icon" aria-hidden="true"><use href="#i-check"></use></svg>active</span>
{% else %}
<span class="badge badge-danger"><svg class="icon" aria-hidden="true"><use href="#i-alert"></use></svg>down</span>
{% endif %}
</td>
<td>{{ runner.sub_state }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="notice notice-warn" role="alert">Runner status unavailable.</p>
{% endif %}
</section>
</div>
@@ -1,4 +1,4 @@
<section class="card" id="services-card" hx-get="/services" hx-trigger="every 15s" hx-swap="outerHTML">
<section class="card card--wide" id="services-card" hx-get="/services" hx-trigger="every 15s, ci:refresh from:body" hx-swap="outerHTML">
<h2><svg class="icon" aria-hidden="true"><use href="#i-gear"></use></svg>Services</h2>
{% if services %}
<div class="table-wrap">
@@ -28,22 +28,22 @@
<td class="service-actions">
{% if not state.active %}
<form method="post" action="/services/{{ state.service.id }}/start"
hx-post="/services/{{ state.service.id }}/start" hx-target="#action-slot" hx-swap="innerHTML">
hx-post="/services/{{ state.service.id }}/start" hx-target="#action-slot" hx-swap="innerHTML show:top">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<button type="submit" class="btn btn-primary" aria-label="Start {{ state.service.label }}">Start</button>
<button type="submit" class="btn btn-primary btn-sm" aria-label="Start {{ state.service.label }}">Start</button>
</form>
{% endif %}
{% if state.active and state.service.allow_stop %}
<form method="post" action="/services/{{ state.service.id }}/stop"
hx-post="/services/{{ state.service.id }}/stop" hx-target="#action-slot" hx-swap="innerHTML">
hx-post="/services/{{ state.service.id }}/stop" hx-target="#action-slot" hx-swap="innerHTML show:top">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger" aria-label="Stop {{ state.service.label }}">Stop</button>
<button type="submit" class="btn btn-danger btn-sm" aria-label="Stop {{ state.service.label }}">Stop</button>
</form>
{% endif %}
<form method="post" action="/services/{{ state.service.id }}/restart"
hx-post="/services/{{ state.service.id }}/restart" hx-target="#action-slot" hx-swap="innerHTML">
hx-post="/services/{{ state.service.id }}/restart" hx-target="#action-slot" hx-swap="innerHTML show:top">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<button type="submit" class="btn" aria-label="Restart {{ state.service.label }}">Restart</button>
<button type="submit" class="btn btn-sm" aria-label="Restart {{ state.service.label }}">Restart</button>
</form>
</td>
</tr>
@@ -1,9 +1,10 @@
{#
Status header (A3): a row of stat tiles fed only from the dashboard
context already fetched by app.py's ``dashboard()`` route (via
Status header (A3, enriched Stage E): a row of stat tiles fed only from the
dashboard context already fetched by app.py's ``dashboard()`` route (via
``_health_summary`` -> ``health``) -- no fetch of its own. Health is never
color-only: each tile pairs its color class with a text label and a
check/alert icon.
check/alert icon. Stage E adds the CPU and RAM tiles (so the near-empty
Disk card could be retired) and gives Disk/Cache a value sub-line.
#}
<section class="status-header" id="status-header" aria-label="System health summary">
@@ -34,15 +35,54 @@
</div>
</div>
<div class="stat-tile {{ health.cpu_class }}">
<svg class="icon" aria-hidden="true"><use href="#i-cpu"></use></svg>
<div class="stat-tile-body">
<span class="stat-tile-label">CPU</span>
<span class="stat-tile-value">
{{ "%.0f"|format(health.cpu_pct) ~ "%" if health.cpu_pct is not none else "-" }}
{% if health.ncpu %}<span class="stat-tile-sub">/ {{ health.ncpu }} cores</span>{% endif %}
</span>
<div class="gauge" role="img"
aria-label="CPU {{ '%.0f'|format(health.cpu_pct) ~ ' percent used' if health.cpu_pct is not none else 'usage unknown' }}">
<div class="gauge-fill" style="width: {{ health.cpu_pct if health.cpu_pct is not none else 0 }}%"></div>
</div>
<span class="stat-tile-status">
<svg class="icon" aria-hidden="true"><use href="#{{ 'i-check' if health.cpu_class == 'tile-ok' else 'i-alert' }}"></use></svg>
{{ health.cpu_label }}
</span>
</div>
</div>
<div class="stat-tile {{ health.mem_class }}">
<svg class="icon" aria-hidden="true"><use href="#i-memory"></use></svg>
<div class="stat-tile-body">
<span class="stat-tile-label">RAM</span>
<span class="stat-tile-value">
{{ "%.1f"|format(health.mem_used_gb) if health.mem_used_gb is not none else "-" }}
{% if health.mem_total_gb is not none %}<span class="stat-tile-sub">/ {{ "%.0f"|format(health.mem_total_gb) }} GB</span>{% endif %}
</span>
<div class="gauge" role="img"
aria-label="Memory {{ '%.0f'|format(health.mem_pct) ~ ' percent used' if health.mem_pct is not none else 'usage unknown' }}">
<div class="gauge-fill" style="width: {{ health.mem_pct if health.mem_pct is not none else 0 }}%"></div>
</div>
<span class="stat-tile-status">
<svg class="icon" aria-hidden="true"><use href="#{{ 'i-check' if health.mem_class == 'tile-ok' else 'i-alert' }}"></use></svg>
{{ health.mem_label }}
</span>
</div>
</div>
<div class="stat-tile {{ health.disk_class }}">
<svg class="icon" aria-hidden="true"><use href="#i-disk"></use></svg>
<div class="stat-tile-body">
<span class="stat-tile-label">Disk used</span>
<span class="stat-tile-label">Disk</span>
<span class="stat-tile-value">
{{ "%.1f"|format(health.disk_used_pct) ~ "%" if health.disk_used_pct is not none else "-" }}
{{ "%.0f"|format(health.disk_used_pct) ~ "%" if health.disk_used_pct is not none else "-" }}
{% if health.disk_free_gb is not none %}<span class="stat-tile-sub">{{ "%.0f"|format(health.disk_free_gb) }} GB free</span>{% endif %}
</span>
<div class="gauge" role="img"
aria-label="Disk {{ '%.1f'|format(health.disk_used_pct) ~ ' percent used' if health.disk_used_pct is not none else 'usage unknown' }}">
aria-label="Disk {{ '%.0f'|format(health.disk_used_pct) ~ ' percent used' if health.disk_used_pct is not none else 'usage unknown' }}">
<div class="gauge-fill" style="width: {{ health.disk_used_pct if health.disk_used_pct is not none else 0 }}%"></div>
</div>
<span class="stat-tile-status">
@@ -55,9 +95,10 @@
<div class="stat-tile {{ health.cache_class }}">
<svg class="icon" aria-hidden="true"><use href="#i-file"></use></svg>
<div class="stat-tile-body">
<span class="stat-tile-label">Cache size</span>
<span class="stat-tile-label">Cache</span>
<span class="stat-tile-value">
{{ health.cache_bytes | humanbytes if health.cache_bytes is not none else "-" }}
{% if health.cache_entries is not none %}<span class="stat-tile-sub">{{ health.cache_entries | thousands }} entries</span>{% endif %}
</span>
<span class="stat-tile-status">
<svg class="icon" aria-hidden="true"><use href="#{{ 'i-check' if health.cache_class == 'tile-ok' else 'i-alert' }}"></use></svg>
@@ -1,4 +1,4 @@
<section class="card" id="templates-card" hx-get="/templates" hx-trigger="every 30s" hx-swap="outerHTML">
<section class="card card--wide" id="templates-card" hx-get="/templates" hx-trigger="every 30s, ci:refresh from:body" hx-swap="outerHTML">
<h2><svg class="icon" aria-hidden="true"><use href="#i-box"></use></svg>Templates</h2>
{% if templates is none %}
<p class="notice notice-warn" role="alert">Template inventory unavailable.</p>
@@ -12,11 +12,13 @@
<th>Transport</th>
<th>Size</th>
<th>Snapshot</th>
<th>Last backup</th>
<th>Backups</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for tmpl in templates %}
{% set bks = template_backups.get(tmpl.name, []) if template_backups else [] %}
<tr class="{{ 'row-alert' if tmpl.has_vmem else '' }}">
<td>{{ tmpl.name }}</td>
<td>{{ tmpl.guest_os or "-" }}</td>
@@ -32,12 +34,40 @@
{% endif %}
</td>
<td>
{% if tmpl.last_backup %}
{{ tmpl.last_backup.name }} <span class="text-dim">({{ tmpl.last_backup.mtime | relative_time }})</span>
{% if bks %}
<details class="backups-details" id="backups-{{ tmpl.name }}" hx-preserve="true">
<summary>{{ bks | length }} backup(s) <span class="text-dim">· newest {{ bks[0].mtime | relative_time }}</span></summary>
<ul class="backups-list">
{% for b in bks %}
<li>
<code title="{{ b.name }}">{{ b.name }}</code>
<span class="text-dim">{{ b.size_bytes | humanbytes }} · {{ b.mtime | relative_time }}</span>
<form method="post" action="/backups/{{ b.name | artifact_id }}/delete" class="backup-del"
aria-label="Delete backup {{ b.name }}"
hx-post="/backups/{{ b.name | artifact_id }}/delete" hx-target="#action-slot" hx-swap="innerHTML">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger btn-sm" aria-label="Delete backup {{ b.name }}">
<svg class="icon" aria-hidden="true"><use href="#i-trash"></use></svg>Delete
</button>
</form>
</li>
{% endfor %}
</ul>
</details>
{% else %}
&mdash;
<span class="text-dim">no backups</span>
{% endif %}
</td>
<td>
<form method="post" action="/host/backup" aria-label="Backup template {{ tmpl.name }}"
hx-post="/host/backup" hx-target="#action-slot" hx-swap="innerHTML show:top">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<input type="hidden" name="template" value="{{ tmpl.name }}">
<button type="submit" class="btn btn-sm" aria-label="Backup template {{ tmpl.name }}">
<svg class="icon" aria-hidden="true"><use href="#i-box"></use></svg>Backup
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
@@ -1,4 +1,4 @@
<section class="card" id="vms-card" hx-get="/vms" hx-trigger="every 5s" hx-swap="outerHTML">
<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>
@@ -29,7 +29,7 @@
<br><span class="text-dim" title="{{ parsed.started | localtime }}">{{ parsed.started | relative_time }}</span>
{% endif %}
</td>
<td><code>{{ vm.vmx }}</code></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>
@@ -42,9 +42,9 @@
</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">
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" aria-label="Remove VM {{ vm.name }}">Remove</button>
<button type="submit" class="btn btn-danger btn-sm" aria-label="Remove VM {{ vm.name }}">Remove</button>
</form>
</td>
</tr>
@@ -55,11 +55,4 @@
{% else %}
<p>No VMs currently running.</p>
{% endif %}
<form method="post" action="/vms/cleanup" id="vms-cleanup-form" aria-label="Cleanup orphaned VMs"
hx-post="/vms/cleanup" hx-target="#action-slot" hx-swap="innerHTML">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<label for="vms-cleanup-max-age">Max age (h)</label>
<input type="number" id="vms-cleanup-max-age" name="max_age_hours" value="4" min="0" hx-preserve="true">
<button type="submit" class="btn btn-danger" aria-label="Cleanup orphaned VMs older than the given age">Cleanup orphans</button>
</form>
</section>
@@ -1,33 +1,52 @@
<section class="card" id="vmware-card" hx-get="/vmware" hx-trigger="every 15s" hx-swap="outerHTML">
<section class="card" id="vmware-card" hx-get="/vmware" hx-trigger="every 15s, ci:refresh from:body" hx-swap="outerHTML">
<h2><svg class="icon" aria-hidden="true"><use href="#i-box"></use></svg>VMware modules</h2>
{% if vmware %}
<dl>
<dt>vmmon</dt>
<dd>
{% if vmware.vmmon_loaded %}
<span class="badge badge-success"><svg class="icon" aria-hidden="true"><use href="#i-check"></use></svg>loaded</span>
{% else %}
<span class="badge badge-danger"><svg class="icon" aria-hidden="true"><use href="#i-alert"></use></svg>not loaded</span>
{% endif %}
</dd>
<dt>vmnet</dt>
<dd>
{% if vmware.vmnet_loaded %}
<span class="badge badge-success"><svg class="icon" aria-hidden="true"><use href="#i-check"></use></svg>loaded</span>
{% else %}
<span class="badge badge-danger"><svg class="icon" aria-hidden="true"><use href="#i-alert"></use></svg>not loaded</span>
{% endif %}
</dd>
<dt>vmware-host.service</dt>
<dd>
{% if vmware.host_service_active %}
<span class="badge badge-success"><svg class="icon" aria-hidden="true"><use href="#i-check"></use></svg>active</span>
{% else %}
<span class="badge badge-danger"><svg class="icon" aria-hidden="true"><use href="#i-alert"></use></svg>down</span>
{% endif %}
<span class="text-dim">({{ vmware.host_service_sub_state }})</span>
</dd>
</dl>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Component</th>
<th>Status</th>
<th>Sub-state</th>
</tr>
</thead>
<tbody>
<tr class="{{ '' if vmware.vmmon_loaded else 'row-alert' }}">
<td><code>vmmon</code></td>
<td>
{% if vmware.vmmon_loaded %}
<span class="badge badge-success"><svg class="icon" aria-hidden="true"><use href="#i-check"></use></svg>loaded</span>
{% else %}
<span class="badge badge-danger"><svg class="icon" aria-hidden="true"><use href="#i-alert"></use></svg>not loaded</span>
{% endif %}
</td>
<td class="text-dim">kernel module</td>
</tr>
<tr class="{{ '' if vmware.vmnet_loaded else 'row-alert' }}">
<td><code>vmnet</code></td>
<td>
{% if vmware.vmnet_loaded %}
<span class="badge badge-success"><svg class="icon" aria-hidden="true"><use href="#i-check"></use></svg>loaded</span>
{% else %}
<span class="badge badge-danger"><svg class="icon" aria-hidden="true"><use href="#i-alert"></use></svg>not loaded</span>
{% endif %}
</td>
<td class="text-dim">kernel module</td>
</tr>
<tr class="{{ '' if vmware.host_service_active else 'row-alert' }}">
<td><code>vmware-host.service</code></td>
<td>
{% if vmware.host_service_active %}
<span class="badge badge-success"><svg class="icon" aria-hidden="true"><use href="#i-check"></use></svg>active</span>
{% else %}
<span class="badge badge-danger"><svg class="icon" aria-hidden="true"><use href="#i-alert"></use></svg>down</span>
{% endif %}
</td>
<td>{{ vmware.host_service_sub_state }}</td>
</tr>
</tbody>
</table>
</div>
{% else %}
<p class="notice notice-warn" role="alert">VMware module status unavailable.</p>
{% endif %}
+89 -3
View File
@@ -136,7 +136,7 @@ def _csrf_from(text: str) -> str:
return match.group(1)
def _get_csrf(client: TestClient, route: str = "/cache") -> str:
def _get_csrf(client: TestClient, route: str = "/") -> str:
resp = client.get(route)
assert resp.status_code == 200
return _csrf_from(resp.text)
@@ -207,9 +207,11 @@ def test_cache_purge_preview_returns_confirm_partial_and_runs_dry_run(
assert resp.status_code == 200
assert "Confirm" in resp.text
assert "would remove" in resp.text
# Age-based purge confirm spells out the max-age dependency (#4).
assert "older than 30 day(s)" in resp.text
assert len(spy.calls) == 1 # type: ignore[attr-defined]
assert spy.calls[0][1]["dry_run"] is True # type: ignore[attr-defined]
assert spy.calls[0][1]["force"] is False # type: ignore[attr-defined]
entry = _last_audit_entry(config.web.audit_log)
assert entry.result == "dry-run"
@@ -287,6 +289,89 @@ def test_cache_purge_execute_actually_removes_expired_entries(tmp_path: Path) ->
assert not entry_path.exists()
def test_cache_purge_execute_nothing_removed_is_info_not_silent(tmp_path: Path) -> None:
# A fresh entry within retention -> age-based purge removes nothing. The
# result must say *why* (neutral "info", not a green "ok" that reads as a
# lie) and still ask panels to refresh via HX-Trigger (#3/#4).
cache_root = tmp_path / "cache" / "cargo"
cache_root.mkdir(parents=True)
(cache_root / "v1-fresh.tar.zst").write_bytes(b"x" * 10) # mtime = now
config = _make_config(tmp_path)
client = TestClient(create_app(config))
csrf = _get_csrf(client)
resp = client.post("/cache/purge", data={"csrf": csrf, "stage": "execute"})
assert resp.status_code == 200
assert "Nothing removed" in resp.text
assert "max age" in resp.text
assert "result-info" in resp.text # neutral badge, not result-ok
assert resp.headers["HX-Trigger"] == "ci:refresh"
def test_cache_purge_max_age_override_from_form(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from ci_orchestrator.operations.cache import purge_cargo_cache as real_purge
spy = _spy(real_purge)
monkeypatch.setattr(routes_module, "purge_cargo_cache", spy)
config = _make_config(tmp_path)
client = TestClient(create_app(config))
csrf = _get_csrf(client)
# An explicit max age overrides the configured retention (30) for the
# age-based purge, and the confirm spells the chosen age out.
resp = client.post("/cache/purge", data={"csrf": csrf, "max_age_days": "7"})
assert resp.status_code == 200
assert "older than 7 day(s)" in resp.text
assert spy.calls[0][1]["retention_days"] == 7 # type: ignore[attr-defined]
assert spy.calls[0][1]["force"] is False # type: ignore[attr-defined]
def test_cache_empty_all_preview_forces_and_confirms(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from ci_orchestrator.operations.cache import purge_cargo_cache as real_purge
spy = _spy(real_purge)
monkeypatch.setattr(routes_module, "purge_cargo_cache", spy)
config = _make_config(tmp_path)
client = TestClient(create_app(config))
csrf = _get_csrf(client)
resp = client.post("/cache/purge", data={"csrf": csrf, "mode": "all"})
assert resp.status_code == 200
assert "Confirm" in resp.text
assert "Empty cargo cache" in resp.text
assert "entire cache" in resp.text
# The confirm form must carry mode=all through to the execute POST.
assert 'value="all"' in resp.text
assert spy.calls[0][1]["force"] is True # type: ignore[attr-defined]
assert spy.calls[0][1]["dry_run"] is True # type: ignore[attr-defined]
def test_cache_empty_all_execute_removes_regardless_of_age(tmp_path: Path) -> None:
cache_root = tmp_path / "cache" / "cargo"
cache_root.mkdir(parents=True)
fresh = cache_root / "v1-fresh.tar.zst"
fresh.write_bytes(b"x" * 10) # brand-new, well within the 30-day retention
config = _make_config(tmp_path)
client = TestClient(create_app(config))
csrf = _get_csrf(client)
resp = client.post("/cache/purge", data={"csrf": csrf, "mode": "all", "stage": "execute"})
assert resp.status_code == 200
assert not fresh.exists()
assert "Freed" in resp.text
assert resp.headers["HX-Trigger"] == "ci:refresh"
def test_vm_remove_preview_then_execute_full_cycle(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
@@ -602,8 +687,9 @@ def test_no_secret_leaks_into_any_action_response(
client.get("/auth/callback") # logs in
bodies: list[str] = []
bodies.append(client.get("/cache").text)
bodies.append(client.get("/").text)
csrf = _csrf_from(bodies[-1])
bodies.append(client.get("/cache").text)
bodies.append(client.post("/cache/purge", data={"csrf": csrf}).text)
bodies.append(client.post("/cache/purge", data={"csrf": csrf, "stage": "execute"}).text)
bodies.append(client.get("/audit").text)
+8 -7
View File
@@ -213,23 +213,24 @@ def test_cache_route_degrades_when_operations_raises(
# ── /host ─────────────────────────────────────────────────────────────────
def test_host_route_renders_disk_and_runner_status(
def test_runners_route_renders_runner_status(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Stage E: the /runners partial carries only the runner statuses -- the
# disk card was folded into the status-header Disk tile.
_quiet_host(monkeypatch)
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/host")
resp = client.get("/runners")
assert resp.status_code == 200
assert "Disk" in resp.text
assert "Runners" in resp.text
assert "act-runner.service" in resp.text
assert "act-runner-rust.service" in resp.text
def test_host_route_degrades_when_disk_status_raises(
def test_runners_route_degrades_when_disk_status_raises(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
def _boom(_path: Path) -> Any:
@@ -240,12 +241,12 @@ def test_host_route_degrades_when_disk_status_raises(
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/host")
resp = client.get("/runners")
assert resp.status_code == 200
def test_host_route_degrades_when_runner_status_raises(
def test_runners_route_degrades_when_runner_status_raises(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
def _boom(_names: list[str]) -> Any:
@@ -256,7 +257,7 @@ def test_host_route_degrades_when_runner_status_raises(
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/host")
resp = client.get("/runners")
assert resp.status_code == 200
+330
View File
@@ -0,0 +1,330 @@
"""Tests for the Stage-E artifact-delete route + ``operations.inventory``
helpers (``delete_artifact``, ``backups_by_template``).
SECURITY-CRITICAL: ``/artifacts/{id}/delete`` unlinks files under
``config.paths.artifacts`` for an authenticated web user. These tests assert
the same discipline the other mutating routes carry: CSRF, the two-step
confirm gate, path containment, and an audit entry per stage. No file outside
``tmp_path`` is ever touched.
"""
from __future__ import annotations
import re
from pathlib import Path
from fastapi.testclient import TestClient
from ci_orchestrator.config import (
BackendConfig,
CargoCacheConfig,
Config,
Paths,
WebConfig,
)
from ci_orchestrator.operations.audit import read_audit
from ci_orchestrator.operations.inventory import (
FileEntry,
backups_by_template,
delete_artifact,
delete_backup,
)
from ci_orchestrator.web.app import create_app
from ci_orchestrator.web.routes import encode_artifact_id
def _make_config(root: Path) -> Config:
return Config(
paths=Paths(
root=root,
templates=root / "templates",
build_vms=root / "build-vms",
artifacts=root / "artifacts",
keys=root / "keys",
),
backend=BackendConfig(),
cargo_cache=CargoCacheConfig(enabled=True, root=root / "cache" / "cargo", retention_days=30),
web=WebConfig(audit_log=root / "web-audit.jsonl"),
)
def _csrf_from(text: str) -> str:
match = re.search(r'name="csrf" value="([^"]+)"', text)
assert match is not None, f"no csrf field found in: {text[:300]!r}"
return match.group(1)
def _get_csrf(client: TestClient) -> str:
# The dashboard's Maintenance card always carries a CSRF field, even when
# there are no artifacts/VMs/templates to render an action form for.
resp = client.get("/")
assert resp.status_code == 200
return _csrf_from(resp.text)
def _make_artifact(config: Config, name: str = "build-42.tar.zst") -> Path:
config.paths.artifacts.mkdir(parents=True, exist_ok=True)
path = config.paths.artifacts / name
path.write_bytes(b"payload")
return path
# ── operations.inventory.delete_artifact ────────────────────────────────────
def test_delete_artifact_dry_run_keeps_the_file(tmp_path: Path) -> None:
config = _make_config(tmp_path)
path = _make_artifact(config)
result = delete_artifact(config, path.name, dry_run=True)
assert result.ok
assert path.exists()
def test_delete_artifact_execute_removes_the_file(tmp_path: Path) -> None:
config = _make_config(tmp_path)
path = _make_artifact(config)
result = delete_artifact(config, path.name, dry_run=False)
assert result.ok
assert not path.exists()
def test_delete_artifact_missing_file_is_error(tmp_path: Path) -> None:
config = _make_config(tmp_path)
config.paths.artifacts.mkdir(parents=True, exist_ok=True)
result = delete_artifact(config, "nope.zip", dry_run=False)
assert not result.ok
assert result.error is not None
def test_delete_artifact_traversal_is_refused(tmp_path: Path) -> None:
config = _make_config(tmp_path)
config.paths.artifacts.mkdir(parents=True, exist_ok=True)
secret = tmp_path / "secret.txt"
secret.write_text("do not delete", encoding="utf-8")
result = delete_artifact(config, "../secret.txt", dry_run=False)
assert not result.ok
assert secret.exists() # the traversal never reached outside artifacts/
# ── operations.inventory.backups_by_template ────────────────────────────────
def test_backups_by_template_groups_and_orders_newest_first(tmp_path: Path) -> None:
config = _make_config(tmp_path)
backups = config.paths.root / "backups"
backups.mkdir(parents=True)
import os
def _touch(name: str, mtime: float) -> None:
p = backups / name
p.write_bytes(b"x")
os.utime(p, (mtime, mtime))
# Real archive names carry a ``_%Y%m%d_%H%M%S`` timestamp (two ``_``
# chunks), exactly what operations/host.py::backup_template and the
# ``template backup`` CLI write -- the parser must strip *both* the date
# and the time to recover the template name (regression: it used to strip
# only the final chunk and grouped under "LinuxBuild2404_20260715").
_touch("Template_LinuxBuild2404_20260715_101500.7z", 1000.0)
_touch("Template_LinuxBuild2404_20260722_145001.7z", 2000.0) # newer
_touch("Template_WinBuild2025_20260720_030000.7z", 1500.0)
_touch("not-a-template-archive.7z", 1600.0) # ignored
grouped = backups_by_template(config)
assert set(grouped) == {"LinuxBuild2404", "WinBuild2025"}
linux = grouped["LinuxBuild2404"]
assert [e.name for e in linux] == [
"Template_LinuxBuild2404_20260722_145001.7z",
"Template_LinuxBuild2404_20260715_101500.7z",
]
assert all(isinstance(e, FileEntry) for e in linux)
# ── operations.inventory.delete_backup ──────────────────────────────────────
def _make_backup(config: Config, name: str = "Template_LinuxBuild2404_20260722_145001.7z") -> Path:
backups = config.paths.root / "backups"
backups.mkdir(parents=True, exist_ok=True)
path = backups / name
path.write_bytes(b"archive")
return path
def test_delete_backup_dry_run_reports_without_unlinking(tmp_path: Path) -> None:
config = _make_config(tmp_path)
path = _make_backup(config)
result = delete_backup(config, path.name, dry_run=True)
assert result.ok is True
assert result.target == path.resolve()
assert path.exists(), "dry_run must not delete"
def test_delete_backup_execute_unlinks(tmp_path: Path) -> None:
config = _make_config(tmp_path)
path = _make_backup(config)
result = delete_backup(config, path.name, dry_run=False)
assert result.ok is True
assert not path.exists()
def test_delete_backup_refuses_non_backup_name(tmp_path: Path) -> None:
config = _make_config(tmp_path)
backups = config.paths.root / "backups"
backups.mkdir(parents=True)
stray = backups / "keep-me.txt"
stray.write_bytes(b"x")
result = delete_backup(config, "keep-me.txt", dry_run=False)
assert result.ok is False
assert stray.exists(), "a non-archive file must never be unlinked"
def test_delete_backup_refuses_path_traversal(tmp_path: Path) -> None:
config = _make_config(tmp_path)
(config.paths.root / "backups").mkdir(parents=True)
secret = config.paths.root / "secret.7z"
secret.write_bytes(b"x")
# A traversal that also happens to satisfy the name regex must still be
# refused by containment (parent != backups dir) -- and the regex rejects
# the slash anyway, so this is doubly safe.
result = delete_backup(config, "../secret.7z", dry_run=False)
assert result.ok is False
assert secret.exists()
def test_delete_backup_missing_file_is_not_ok(tmp_path: Path) -> None:
config = _make_config(tmp_path)
(config.paths.root / "backups").mkdir(parents=True)
result = delete_backup(config, "Template_LinuxBuild2404_20260722_145001.7z", dry_run=False)
assert result.ok is False
assert result.error == "backup not found"
# ── route: /artifacts/{id}/delete ───────────────────────────────────────────
def test_delete_route_without_csrf_is_403(tmp_path: Path) -> None:
config = _make_config(tmp_path)
_make_artifact(config)
client = TestClient(create_app(config))
client.get("/files") # establish a session
resp = client.post(f"/artifacts/{encode_artifact_id('build-42.tar.zst')}/delete", data={})
assert resp.status_code == 403
def test_delete_route_invalid_id_is_400(tmp_path: Path) -> None:
config = _make_config(tmp_path)
client = TestClient(create_app(config))
csrf = _get_csrf(client)
# A path-bearing decoded name must be rejected before any op runs.
resp = client.post(f"/artifacts/{encode_artifact_id('a/b')}/delete", data={"csrf": csrf})
assert resp.status_code == 400
def test_delete_route_preview_confirms_and_keeps_file(tmp_path: Path) -> None:
config = _make_config(tmp_path)
path = _make_artifact(config)
client = TestClient(create_app(config))
csrf = _get_csrf(client)
resp = client.post(
f"/artifacts/{encode_artifact_id(path.name)}/delete", data={"csrf": csrf}
)
assert resp.status_code == 200
assert "Confirm" in resp.text
assert "would delete" in resp.text
assert path.exists()
assert read_audit(config.web.audit_log, last=10)[-1].result == "dry-run"
def test_delete_route_execute_deletes_and_audits_ok(tmp_path: Path) -> None:
config = _make_config(tmp_path)
path = _make_artifact(config)
client = TestClient(create_app(config))
csrf = _get_csrf(client)
resp = client.post(
f"/artifacts/{encode_artifact_id(path.name)}/delete",
data={"csrf": csrf, "stage": "execute"},
)
assert resp.status_code == 200
assert not path.exists()
entry = read_audit(config.web.audit_log, last=10)[-1]
assert entry.action == "artifact.delete"
assert entry.result == "ok"
# ── route: /backups/{id}/delete ─────────────────────────────────────────────
def test_backup_delete_without_csrf_is_403(tmp_path: Path) -> None:
config = _make_config(tmp_path)
_make_backup(config)
client = TestClient(create_app(config))
client.get("/") # establish a session
resp = client.post(
f"/backups/{encode_artifact_id('Template_LinuxBuild2404_20260722_145001.7z')}/delete",
data={},
)
assert resp.status_code == 403
def test_backup_delete_invalid_id_is_400(tmp_path: Path) -> None:
config = _make_config(tmp_path)
client = TestClient(create_app(config))
csrf = _get_csrf(client)
resp = client.post(f"/backups/{encode_artifact_id('a/b')}/delete", data={"csrf": csrf})
assert resp.status_code == 400
def test_backup_delete_preview_then_execute_full_cycle(tmp_path: Path) -> None:
config = _make_config(tmp_path)
path = _make_backup(config)
client = TestClient(create_app(config))
csrf = _get_csrf(client)
backup_id = encode_artifact_id(path.name)
preview = client.post(f"/backups/{backup_id}/delete", data={"csrf": csrf})
assert preview.status_code == 200
assert "Confirm" in preview.text
assert "would delete backup" in preview.text
assert path.exists()
assert read_audit(config.web.audit_log, last=10)[-1].result == "dry-run"
execute = client.post(f"/backups/{backup_id}/delete", data={"csrf": csrf, "stage": "execute"})
assert execute.status_code == 200
assert not path.exists()
# Every terminal result asks the dashboard to refresh (#3).
assert execute.headers["HX-Trigger"] == "ci:refresh"
entry = read_audit(config.web.audit_log, last=10)[-1]
assert entry.action == "backup.delete"
assert entry.result == "ok"
+43 -42
View File
@@ -1,4 +1,4 @@
"""Tests for the Stage B read-only web routes: ``/resources``, ``/vmware``,
"""Tests for the Stage B read-only web routes: ``/host``, ``/vmware``,
``/templates``, ``/files``, plus the B6 backup-form template dropdown.
In-process only, same conventions as ``test_web_app.py``: no real ``/proc``,
@@ -54,22 +54,24 @@ _TEMPLATE_ROW = TemplateInfo(
_FILE_ENTRY = FileEntry(name="Template_X_20260101_000000.7z", size_bytes=2048, mtime=1_700_000_000.0)
# ── /resources ───────────────────────────────────────────────────────────────
# ── /host (host resources card) ──────────────────────────────────────────────
def test_resources_route_renders_snapshot(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
def test_host_route_renders_snapshot(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(app_module, "host_resources", lambda: _RESOURCES)
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/resources")
resp = client.get("/host")
assert resp.status_code == 200
assert "Host resources" in resp.text
assert "42.5" in resp.text
# Stage E: CPU%/RAM-used moved to the status-header tiles; the Host card
# keeps uptime, load average, core count and total memory.
assert "Host" in resp.text
assert "16.0" in resp.text # total memory GB
def test_resources_route_degrades_on_probe_failure(
def test_host_route_degrades_on_probe_failure(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
def _boom() -> Any:
@@ -79,7 +81,7 @@ def test_resources_route_degrades_on_probe_failure(
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/resources")
resp = client.get("/host")
assert resp.status_code == 200
assert "unavailable" in resp.text.lower()
@@ -198,10 +200,27 @@ def test_templates_route_shows_running_ram_snapshot_warning(
# ── /files ────────────────────────────────────────────────────────────────────
def test_files_route_renders_backups_and_artifacts(
def test_files_route_renders_artifacts(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Stage E: /files is now the Artifacts panel only (backups moved under
# Templates). Each artifact row carries a Delete action.
artifact = FileEntry(name="build-99.tar.zst", size_bytes=2048, mtime=1_700_000_000.0)
monkeypatch.setattr(app_module, "list_artifacts", lambda _config: [artifact])
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/files")
assert resp.status_code == 200
assert "build-99.tar.zst" in resp.text
assert "Build outputs collected" in resp.text # the panel description
assert "Delete" in resp.text
def test_files_route_empty_shows_no_artifacts(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(app_module, "list_backups", lambda _config: [_FILE_ENTRY])
monkeypatch.setattr(app_module, "list_artifacts", lambda _config: [])
app = create_app(_make_config(tmp_path))
client = TestClient(app)
@@ -209,34 +228,15 @@ def test_files_route_renders_backups_and_artifacts(
resp = client.get("/files")
assert resp.status_code == 200
assert "Template_X_20260101_000000.7z" in resp.text
assert "No artifacts found" in resp.text
def test_files_route_degrades_on_backups_probe_failure(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
def _boom(_config: Config) -> Any:
raise RuntimeError("boom")
monkeypatch.setattr(app_module, "list_backups", _boom)
monkeypatch.setattr(app_module, "list_artifacts", lambda _config: [])
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/files")
assert resp.status_code == 200
assert "unavailable" in resp.text.lower()
def test_files_route_degrades_on_artifacts_probe_failure(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
def _boom(_config: Config) -> Any:
raise RuntimeError("boom")
monkeypatch.setattr(app_module, "list_backups", lambda _config: [])
monkeypatch.setattr(app_module, "list_artifacts", _boom)
app = create_app(_make_config(tmp_path))
client = TestClient(app)
@@ -257,7 +257,6 @@ def test_dashboard_renders_all_stage_b_cards(
monkeypatch.setattr(app_module, "host_resources", lambda: _RESOURCES)
monkeypatch.setattr(app_module, "vmware_module_health", lambda: _VMWARE_OK)
monkeypatch.setattr(app_module, "list_templates", lambda _config: [_TEMPLATE_ROW])
monkeypatch.setattr(app_module, "list_backups", lambda _config: [])
monkeypatch.setattr(app_module, "list_artifacts", lambda _config: [])
app = create_app(_make_config(tmp_path))
client = TestClient(app)
@@ -265,38 +264,40 @@ def test_dashboard_renders_all_stage_b_cards(
resp = client.get("/")
assert resp.status_code == 200
for needle in ("Host resources", "VMware modules", "Templates", "Template backups", "Artifacts"):
# Stage E card set: the "Host" card, VMware modules, Templates, Artifacts,
# plus the new bottom Maintenance card.
for needle in ("Host", "VMware modules", "Templates", "Artifacts", "Maintenance"):
assert needle in resp.text
# ── B6: backup dropdown ──────────────────────────────────────────────────────
# ── Stage E: per-template backup action (moved from the host backup dropdown) ─
def test_host_partial_backup_form_offers_discovered_template_names(
def test_templates_partial_offers_per_row_backup_action(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_quiet_systemctl(monkeypatch)
monkeypatch.setattr(app_module, "list_templates", lambda _config: [_TEMPLATE_ROW])
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/host")
resp = client.get("/templates")
assert resp.status_code == 200
assert '<select id="host-backup-template" name="template"' in resp.text
assert '<option value="LinuxBuild2404">LinuxBuild2404</option>' in resp.text
assert "7z archive of the template dir under" in resp.text
# Each row posts its own template name to /host/backup (no more dropdown).
assert 'action="/host/backup"' in resp.text
assert '<input type="hidden" name="template" value="LinuxBuild2404">' in resp.text
assert "Backup" in resp.text
def test_host_partial_backup_form_empty_dropdown_when_no_templates(
def test_templates_partial_no_backup_action_when_no_templates(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_quiet_systemctl(monkeypatch)
monkeypatch.setattr(app_module, "list_templates", lambda _config: [])
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/host")
resp = client.get("/templates")
assert resp.status_code == 200
assert "<option" not in resp.text
assert 'action="/host/backup"' not in resp.text
assert "No templates found" in resp.text
+7 -7
View File
@@ -55,7 +55,7 @@ def _csrf_from(text: str) -> str:
def test_hx_request_preview_gets_bare_partial(tmp_path: Path) -> None:
app = create_app(_make_config(tmp_path))
client = TestClient(app)
csrf = _csrf_from(client.get("/cache").text)
csrf = _csrf_from(client.get("/").text)
resp = client.post(
"/cache/purge", data={"csrf": csrf}, headers={"HX-Request": "true"}
@@ -70,7 +70,7 @@ def test_hx_request_preview_gets_bare_partial(tmp_path: Path) -> None:
def test_hx_request_execute_gets_bare_partial(tmp_path: Path) -> None:
app = create_app(_make_config(tmp_path))
client = TestClient(app)
csrf = _csrf_from(client.get("/cache").text)
csrf = _csrf_from(client.get("/").text)
resp = client.post(
"/cache/purge",
@@ -89,12 +89,12 @@ def test_hx_request_execute_gets_bare_partial(tmp_path: Path) -> None:
def test_no_hx_header_preview_gets_full_themed_page(tmp_path: Path) -> None:
app = create_app(_make_config(tmp_path))
client = TestClient(app)
csrf = _csrf_from(client.get("/cache").text)
csrf = _csrf_from(client.get("/").text)
resp = client.post("/cache/purge", data={"csrf": csrf})
assert resp.status_code == 200
assert '<link rel="stylesheet" href="/static/app.css">' in resp.text
assert '<link rel="stylesheet" href="/static/app.css?v=' in resp.text
assert 'id="action-slot"' in resp.text
assert "Confirm" in resp.text
@@ -102,7 +102,7 @@ def test_no_hx_header_preview_gets_full_themed_page(tmp_path: Path) -> None:
def test_no_hx_header_execute_gets_full_themed_page(tmp_path: Path) -> None:
app = create_app(_make_config(tmp_path))
client = TestClient(app)
csrf = _csrf_from(client.get("/cache").text)
csrf = _csrf_from(client.get("/").text)
resp = client.post("/cache/purge", data={"csrf": csrf, "stage": "execute"})
@@ -123,7 +123,7 @@ def test_lock_conflict_status_code_preserved_in_full_page(tmp_path: Path) -> Non
app = create_app(config)
app.dependency_overrides[get_backend] = lambda: None
client = TestClient(app)
csrf = _csrf_from(client.get("/cache").text)
csrf = _csrf_from(client.get("/").text)
resp = client.post("/vms/cleanup", data={"csrf": csrf, "stage": "execute"})
@@ -141,7 +141,7 @@ def test_lock_conflict_status_code_preserved_bare_for_htmx(tmp_path: Path) -> No
app = create_app(config)
app.dependency_overrides[get_backend] = lambda: None
client = TestClient(app)
csrf = _csrf_from(client.get("/cache").text)
csrf = _csrf_from(client.get("/").text)
resp = client.post(
"/vms/cleanup",