From d18321d4a08174121d63f67a1e45dda56e387c30 Mon Sep 17 00:00:00 2001 From: Simone Date: Fri, 24 Jul 2026 22:04:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20Stage=20E=20dashboard=20=E2=80=94?= =?UTF-8?q?=20wide=20cards,=20per-item=20actions,=20toast=20stack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (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. --- src/ci_orchestrator/web/app.py | 181 +++++++--- src/ci_orchestrator/web/routes.py | 275 ++++++++++++++- src/ci_orchestrator/web/static/app.css | 268 +++++++++++++- src/ci_orchestrator/web/static/app.js | 198 +++++++++++ src/ci_orchestrator/web/templates/base.html | 11 +- .../web/templates/dashboard.html | 19 +- .../web/templates/partials/cache.html | 15 +- .../web/templates/partials/confirm.html | 51 ++- .../web/templates/partials/files.html | 45 +-- .../web/templates/partials/host.html | 81 +---- .../web/templates/partials/icons.html | 18 + .../web/templates/partials/maintenance.html | 65 ++++ .../web/templates/partials/resources.html | 21 -- .../web/templates/partials/result.html | 28 +- .../web/templates/partials/runners.html | 35 ++ .../web/templates/partials/services.html | 14 +- .../web/templates/partials/status_header.html | 55 ++- .../web/templates/partials/templates.html | 40 ++- .../web/templates/partials/vms.html | 15 +- .../web/templates/partials/vmware.html | 75 ++-- tests/python/test_web_actions.py | 92 ++++- tests/python/test_web_app.py | 15 +- tests/python/test_web_artifacts.py | 330 ++++++++++++++++++ tests/python/test_web_stage_b.py | 85 ++--- tests/python/test_web_theming.py | 14 +- 25 files changed, 1695 insertions(+), 351 deletions(-) create mode 100644 src/ci_orchestrator/web/static/app.js create mode 100644 src/ci_orchestrator/web/templates/partials/maintenance.html delete mode 100644 src/ci_orchestrator/web/templates/partials/resources.html create mode 100644 src/ci_orchestrator/web/templates/partials/runners.html create mode 100644 tests/python/test_web_artifacts.py diff --git a/src/ci_orchestrator/web/app.py b/src/ci_orchestrator/web/app.py index 87c0f3c..010a280 100644 --- a/src/ci_orchestrator/web/app.py +++ b/src/ci_orchestrator/web/app.py @@ -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 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", diff --git a/src/ci_orchestrator/web/routes.py b/src/ci_orchestrator/web/routes.py index 336612d..c5ac83a 100644 --- a/src/ci_orchestrator/web/routes.py +++ b/src/ci_orchestrator/web/routes.py @@ -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 ````: 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 ```` 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", diff --git a/src/ci_orchestrator/web/static/app.css b/src/ci_orchestrator/web/static/app.css index 4c21814..edee0d1 100644 --- a/src/ci_orchestrator/web/static/app.css +++ b/src/ci_orchestrator/web/static/app.css @@ -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
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 () ─────────────────────────────────────────── */ + +.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) ─────────────────────────────────────────────────── */ diff --git a/src/ci_orchestrator/web/static/app.js b/src/ci_orchestrator/web/static/app.js new file mode 100644 index 0000000..33879a4 --- /dev/null +++ b/src/ci_orchestrator/web/static/app.js @@ -0,0 +1,198 @@ +/* + * Dashboard action behaviour: turns the htmx-swapped confirm/result partials + * into a modal (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 -> 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 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 = + '' + + '
' + + '

Working… this can take a while for large templates.

'; + // 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 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")); + }); +})(); diff --git a/src/ci_orchestrator/web/templates/base.html b/src/ci_orchestrator/web/templates/base.html index fe1ae9e..78304ef 100644 --- a/src/ci_orchestrator/web/templates/base.html +++ b/src/ci_orchestrator/web/templates/base.html @@ -4,8 +4,9 @@ {% block title %}ci-orchestrator{% endblock %} - + + {% include "partials/icons.html" %} @@ -32,5 +33,13 @@
{% endblock %} + {# + 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. + #} +
diff --git a/src/ci_orchestrator/web/templates/dashboard.html b/src/ci_orchestrator/web/templates/dashboard.html index bf26729..945259a 100644 --- a/src/ci_orchestrator/web/templates/dashboard.html +++ b/src/ci_orchestrator/web/templates/dashboard.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. +#}
{% 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" %}
{% endblock %} diff --git a/src/ci_orchestrator/web/templates/partials/cache.html b/src/ci_orchestrator/web/templates/partials/cache.html index 3e5254e..bd253ba 100644 --- a/src/ci_orchestrator/web/templates/partials/cache.html +++ b/src/ci_orchestrator/web/templates/partials/cache.html @@ -1,20 +1,21 @@ -
+

Cargo cache

{% if cache %} + {# Read-only info card -- the destructive "Purge" action lives in the + Maintenance card at the bottom of the dashboard (Stage E). #}
Root
{{ cache.root }}
Total size
{{ cache.total_bytes | humanbytes }}
Entries
{{ cache.entry_count | thousands }}
-
Oldest entry age
+
Oldest entry
{{ cache.oldest_age_days ~ " day(s)" if cache.oldest_age_days is not none else "-" }}
Retention
{{ cache.retention_days }} day(s)
+

+ “Purge (max age)” in Maintenance removes only entries older than this + retention window; “Empty all” clears the cache regardless of age. +

{% else %} {% endif %} -
- - -
diff --git a/src/ci_orchestrator/web/templates/partials/confirm.html b/src/ci_orchestrator/web/templates/partials/confirm.html index 65ef124..b7778ad 100644 --- a/src/ci_orchestrator/web/templates/partials/confirm.html +++ b/src/ci_orchestrator/web/templates/partials/confirm.html @@ -1,16 +1,35 @@ - +{# + Confirm gate rendered as a real modal (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. +#} + + + diff --git a/src/ci_orchestrator/web/templates/partials/files.html b/src/ci_orchestrator/web/templates/partials/files.html index 88272a5..4a66052 100644 --- a/src/ci_orchestrator/web/templates/partials/files.html +++ b/src/ci_orchestrator/web/templates/partials/files.html @@ -1,42 +1,29 @@ -
-
-

Template backups

- {% if backups is none %} - - {% elif backups %} -
- - - - {% for entry in backups %} - - - - - - {% endfor %} - -
NameSizeCreated
{{ entry.name }}{{ entry.size_bytes | humanbytes }}{{ entry.mtime | relative_time }}
-
- {% else %} -

No backups found.

- {% endif %} -
- -
+
+

Artifacts

+

Build outputs collected from finished jobs (binaries, bundles, logs). Delete to reclaim space.

{% if artifacts is none %} {% elif artifacts %}
- + {% for entry in artifacts %} - + - + + {% endfor %} diff --git a/src/ci_orchestrator/web/templates/partials/host.html b/src/ci_orchestrator/web/templates/partials/host.html index ba8a2a8..e13143a 100644 --- a/src/ci_orchestrator/web/templates/partials/host.html +++ b/src/ci_orchestrator/web/templates/partials/host.html @@ -1,65 +1,16 @@ -
-
-

Disk

- {% if disk %} -
-
Path
{{ disk.path }}
-
Total
{{ "%.1f"|format(disk.total_gb) }} GB
-
Free
{{ "%.1f"|format(disk.free_gb) }} GB
-
Used
{{ "%.1f"|format(disk.used_pct) }}%
-
- {% else %} - - {% endif %} -
- -
-

Runners

- {% if runners %} -
-
NameSizeCreated
NameSizeCreatedActions
{{ entry.name }}{{ entry.name }} {{ entry.size_bytes | humanbytes }}{{ entry.mtime | relative_time }}{{ entry.mtime | relative_time }} +
+ + +
+
- - - - - - - - - {% for runner in runners %} - - - - - - {% endfor %} - -
ServiceStatusSub-state
{{ runner.name }} - {% if runner.active %} - active - {% else %} - down - {% endif %} - {{ runner.sub_state }}
-
- {% else %} - - {% endif %} -
- -
-

Template backup

-

7z archive of the template dir under <root>/backups

-
- - - - -
-
-
+
+

Host

+ {% 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. #} +
+
Uptime
{{ resources.uptime_sec | duration }}
+
Load avg
+
{{ "%.2f"|format(resources.load1) }} / {{ "%.2f"|format(resources.load5) }} / {{ "%.2f"|format(resources.load15) }}
+
CPU cores
{{ resources.ncpu }}
+
Memory
{{ "%.1f"|format(resources.mem_total_gb) }} GB total
+
+ {% else %} + + {% endif %} +
diff --git a/src/ci_orchestrator/web/templates/partials/icons.html b/src/ci_orchestrator/web/templates/partials/icons.html index 74741f2..3f0dd0a 100644 --- a/src/ci_orchestrator/web/templates/partials/icons.html +++ b/src/ci_orchestrator/web/templates/partials/icons.html @@ -27,6 +27,24 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/ci_orchestrator/web/templates/partials/maintenance.html b/src/ci_orchestrator/web/templates/partials/maintenance.html new file mode 100644 index 0000000..3aa3b16 --- /dev/null +++ b/src/ci_orchestrator/web/templates/partials/maintenance.html @@ -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. +#} +
+

Maintenance

+

Destructive operations — each asks for confirmation before it runs.

+
+ +
+
+

Cleanup orphaned VMs

+

Delete orphaned clone directories older than the given age.

+
+
+ +
+ + +
+
+ +
+
+
+ +
+
+

Purge cargo cache

+

+ Purge (max age) removes only entries older than the + retention window. Empty all clears the whole cache now, + regardless of age. +

+
+
+ +
+ + +
+
+ + +
+
+
+ +
+
diff --git a/src/ci_orchestrator/web/templates/partials/resources.html b/src/ci_orchestrator/web/templates/partials/resources.html deleted file mode 100644 index 96148b5..0000000 --- a/src/ci_orchestrator/web/templates/partials/resources.html +++ /dev/null @@ -1,21 +0,0 @@ -
-

Host resources

- {% if resources %} -
-
CPU
-
- {{ "%.1f"|format(resources.cpu_pct) }}% across {{ resources.ncpu }} core(s) - -
-
Memory
-
{{ "%.1f"|format(resources.mem_used_gb) }} / {{ "%.1f"|format(resources.mem_total_gb) }} GB
-
Uptime
{{ resources.uptime_sec | duration }}
-
Load avg
-
{{ "%.2f"|format(resources.load1) }} / {{ "%.2f"|format(resources.load5) }} / {{ "%.2f"|format(resources.load15) }}
-
- {% else %} - - {% endif %} -
diff --git a/src/ci_orchestrator/web/templates/partials/result.html b/src/ci_orchestrator/web/templates/partials/result.html index 47b9e34..8986faa 100644 --- a/src/ci_orchestrator/web/templates/partials/result.html +++ b/src/ci_orchestrator/web/templates/partials/result.html @@ -1,11 +1,19 @@ -
-

- {{ action_label }} -- - {% if ok %} - ok - {% else %} - error - {% endif %} -

-

{{ detail }}

+{# + 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-``. + 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. +#} +
+ +
+ {{ action_label }} +

{{ detail }}

+
diff --git a/src/ci_orchestrator/web/templates/partials/runners.html b/src/ci_orchestrator/web/templates/partials/runners.html new file mode 100644 index 0000000..c56645e --- /dev/null +++ b/src/ci_orchestrator/web/templates/partials/runners.html @@ -0,0 +1,35 @@ +
+
+

Runners

+ {% if runners %} +
+ + + + + + + + + + {% for runner in runners %} + + + + + + {% endfor %} + +
ServiceStatusSub-state
{{ runner.name }} + {% if runner.active %} + active + {% else %} + down + {% endif %} + {{ runner.sub_state }}
+
+ {% else %} + + {% endif %} +
+
diff --git a/src/ci_orchestrator/web/templates/partials/services.html b/src/ci_orchestrator/web/templates/partials/services.html index 34f5ae7..ea9285a 100644 --- a/src/ci_orchestrator/web/templates/partials/services.html +++ b/src/ci_orchestrator/web/templates/partials/services.html @@ -1,4 +1,4 @@ -
+

Services

{% if services %}
@@ -28,22 +28,22 @@ {% if not state.active %}
+ hx-post="/services/{{ state.service.id }}/start" hx-target="#action-slot" hx-swap="innerHTML show:top"> - +
{% endif %} {% if state.active and state.service.allow_stop %}
+ hx-post="/services/{{ state.service.id }}/stop" hx-target="#action-slot" hx-swap="innerHTML show:top"> - +
{% endif %}
+ hx-post="/services/{{ state.service.id }}/restart" hx-target="#action-slot" hx-swap="innerHTML show:top"> - +
diff --git a/src/ci_orchestrator/web/templates/partials/status_header.html b/src/ci_orchestrator/web/templates/partials/status_header.html index c237b6e..3f7319d 100644 --- a/src/ci_orchestrator/web/templates/partials/status_header.html +++ b/src/ci_orchestrator/web/templates/partials/status_header.html @@ -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. #}
@@ -34,15 +35,54 @@
+
+ +
+ CPU + + {{ "%.0f"|format(health.cpu_pct) ~ "%" if health.cpu_pct is not none else "-" }} + {% if health.ncpu %}/ {{ health.ncpu }} cores{% endif %} + + + + + {{ health.cpu_label }} + +
+
+ +
+ +
+ RAM + + {{ "%.1f"|format(health.mem_used_gb) if health.mem_used_gb is not none else "-" }} + {% if health.mem_total_gb is not none %}/ {{ "%.0f"|format(health.mem_total_gb) }} GB{% endif %} + + + + + {{ health.mem_label }} + +
+
+
- Disk used + Disk - {{ "%.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 %}{{ "%.0f"|format(health.disk_free_gb) }} GB free{% endif %} @@ -55,9 +95,10 @@
- Cache size + Cache {{ health.cache_bytes | humanbytes if health.cache_bytes is not none else "-" }} + {% if health.cache_entries is not none %}{{ health.cache_entries | thousands }} entries{% endif %} diff --git a/src/ci_orchestrator/web/templates/partials/templates.html b/src/ci_orchestrator/web/templates/partials/templates.html index a02a1a2..4e470ec 100644 --- a/src/ci_orchestrator/web/templates/partials/templates.html +++ b/src/ci_orchestrator/web/templates/partials/templates.html @@ -1,4 +1,4 @@ -
+

Templates

{% if templates is none %} @@ -12,11 +12,13 @@ Transport Size Snapshot - Last backup + Backups + Actions {% for tmpl in templates %} + {% set bks = template_backups.get(tmpl.name, []) if template_backups else [] %} {{ tmpl.name }} {{ tmpl.guest_os or "-" }} @@ -32,12 +34,40 @@ {% endif %} - {% if tmpl.last_backup %} - {{ tmpl.last_backup.name }} ({{ tmpl.last_backup.mtime | relative_time }}) + {% if bks %} +
+ {{ bks | length }} backup(s) · newest {{ bks[0].mtime | relative_time }} +
    + {% for b in bks %} +
  • + {{ b.name }} + {{ b.size_bytes | humanbytes }} · {{ b.mtime | relative_time }} +
    + + +
    +
  • + {% endfor %} +
+
{% else %} - — + no backups {% endif %} + +
+ + + +
+ {% endfor %} diff --git a/src/ci_orchestrator/web/templates/partials/vms.html b/src/ci_orchestrator/web/templates/partials/vms.html index 8fdd27e..fe7debe 100644 --- a/src/ci_orchestrator/web/templates/partials/vms.html +++ b/src/ci_orchestrator/web/templates/partials/vms.html @@ -1,4 +1,4 @@ -
+

Live VMs

{% if not backend_available %} @@ -29,7 +29,7 @@
{{ parsed.started | relative_time }} {% endif %} - {{ vm.vmx }} + {{ vm.vmx | shorten_path(2) }} {{ vm.ip or "-" }} {{ vm.guest_os or "-" }} {{ vm.uptime_sec | duration }} @@ -42,9 +42,9 @@
+ hx-post="/vms/{{ vm.vmx | vmx_id }}/remove" hx-target="#action-slot" hx-swap="innerHTML show:top"> - +
@@ -55,11 +55,4 @@ {% else %}

No VMs currently running.

{% endif %} -
- - - - -
diff --git a/src/ci_orchestrator/web/templates/partials/vmware.html b/src/ci_orchestrator/web/templates/partials/vmware.html index 2b97b0f..7457ab1 100644 --- a/src/ci_orchestrator/web/templates/partials/vmware.html +++ b/src/ci_orchestrator/web/templates/partials/vmware.html @@ -1,33 +1,52 @@ -
+

VMware modules

{% if vmware %} -
-
vmmon
-
- {% if vmware.vmmon_loaded %} - loaded - {% else %} - not loaded - {% endif %} -
-
vmnet
-
- {% if vmware.vmnet_loaded %} - loaded - {% else %} - not loaded - {% endif %} -
-
vmware-host.service
-
- {% if vmware.host_service_active %} - active - {% else %} - down - {% endif %} - ({{ vmware.host_service_sub_state }}) -
-
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
ComponentStatusSub-state
vmmon + {% if vmware.vmmon_loaded %} + loaded + {% else %} + not loaded + {% endif %} + kernel module
vmnet + {% if vmware.vmnet_loaded %} + loaded + {% else %} + not loaded + {% endif %} + kernel module
vmware-host.service + {% if vmware.host_service_active %} + active + {% else %} + down + {% endif %} + {{ vmware.host_service_sub_state }}
+
{% else %} {% endif %} diff --git a/tests/python/test_web_actions.py b/tests/python/test_web_actions.py index 222f462..6a47f29 100644 --- a/tests/python/test_web_actions.py +++ b/tests/python/test_web_actions.py @@ -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) diff --git a/tests/python/test_web_app.py b/tests/python/test_web_app.py index 6fa3c45..66595be 100644 --- a/tests/python/test_web_app.py +++ b/tests/python/test_web_app.py @@ -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 diff --git a/tests/python/test_web_artifacts.py b/tests/python/test_web_artifacts.py new file mode 100644 index 0000000..0af5a0b --- /dev/null +++ b/tests/python/test_web_artifacts.py @@ -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" diff --git a/tests/python/test_web_stage_b.py b/tests/python/test_web_stage_b.py index bc0a44a..659488a 100644 --- a/tests/python/test_web_stage_b.py +++ b/tests/python/test_web_stage_b.py @@ -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 '' 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 " 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 '' in resp.text + assert ' 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",