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