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:
@@ -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