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