feat(web): Stage E dashboard — wide cards, per-item actions, toast stack

A large, cohesive redesign of the web dashboard (all pieces depend on
each other, so this lands as one commit):

Layout / display:
- Host/Runners/VMware/Cache split into a Host-VMware-Runners-Cache info
  row; Host card keeps uptime/load/cores/mem-total now that CPU%/RAM
  are promoted to the status-header tiles (new gauge tiles, red/amber/
  green thresholds shared with the existing disk tile).
- VMware modules rendered as a table; Live VMs/Templates/Artifacts/
  Services/Maintenance span the full grid width (card--wide).
- Backups moved from a standalone Files-panel table into an expandable
  per-template row on the Templates panel (backups_by_template), with
  a per-backup Delete action and a per-row Backup button replacing the
  old host-card template dropdown.
- New Maintenance card: cleanup-orphans and cache-purge forms
  consolidated into one danger-zone card (red rule + row separators),
  each op's max-age control on its own row so it reads as the op's
  parameter, not decoration; cache purge gets an explicit "Empty all"
  button alongside the age-based "Purge".
- Artifacts gain a per-row Delete action.

Action/feedback layer:
- Every mutating route now sets HX-Trigger: ci:refresh on its result,
  so polled panels reload immediately instead of waiting for their
  next poll interval; _result_partial takes an explicit level
  (ok/info/error) independent of the ok bool, so e.g. an age-based
  purge that finds nothing to remove reads as neutral "info" instead
  of a misleading green "ok" or a red "error".
- Results render as toasts appended to a fixed #toast-stack (base.html)
  instead of swapping a single #action-slot -- concurrent actions (two
  backups running at once) now each keep their own toast instead of
  the later one clobbering the earlier. Per-state colour is a left
  stripe + tint (result-ok/info/error), fixing a cascade bug where the
  plain .toast background silently beat the state class.
- The confirm dialog is a real modal <dialog> (app.js: showModal(),
  Cancel/ESC clear the slot); confirming closes it immediately and
  shows a busy spinner toast in the stack (since a real backup can run
  for minutes) instead of leaving the dialog open with no feedback.
- host_backup now runs backup_template via asyncio.to_thread so a
  multi-GB 7z run no longer blocks the event loop (and every other
  dashboard poll) for its whole duration.
- Static assets are served with a mtime-based ?v= query param so a
  redeploy is picked up without a hard refresh.
This commit is contained in:
2026-07-24 22:04:48 +02:00
parent 2395e3e79f
commit d18321d4a0
25 changed files with 1695 additions and 351 deletions
+89 -3
View File
@@ -136,7 +136,7 @@ def _csrf_from(text: str) -> str:
return match.group(1)
def _get_csrf(client: TestClient, route: str = "/cache") -> str:
def _get_csrf(client: TestClient, route: str = "/") -> str:
resp = client.get(route)
assert resp.status_code == 200
return _csrf_from(resp.text)
@@ -207,9 +207,11 @@ def test_cache_purge_preview_returns_confirm_partial_and_runs_dry_run(
assert resp.status_code == 200
assert "Confirm" in resp.text
assert "would remove" in resp.text
# Age-based purge confirm spells out the max-age dependency (#4).
assert "older than 30 day(s)" in resp.text
assert len(spy.calls) == 1 # type: ignore[attr-defined]
assert spy.calls[0][1]["dry_run"] is True # type: ignore[attr-defined]
assert spy.calls[0][1]["force"] is False # type: ignore[attr-defined]
entry = _last_audit_entry(config.web.audit_log)
assert entry.result == "dry-run"
@@ -287,6 +289,89 @@ def test_cache_purge_execute_actually_removes_expired_entries(tmp_path: Path) ->
assert not entry_path.exists()
def test_cache_purge_execute_nothing_removed_is_info_not_silent(tmp_path: Path) -> None:
# A fresh entry within retention -> age-based purge removes nothing. The
# result must say *why* (neutral "info", not a green "ok" that reads as a
# lie) and still ask panels to refresh via HX-Trigger (#3/#4).
cache_root = tmp_path / "cache" / "cargo"
cache_root.mkdir(parents=True)
(cache_root / "v1-fresh.tar.zst").write_bytes(b"x" * 10) # mtime = now
config = _make_config(tmp_path)
client = TestClient(create_app(config))
csrf = _get_csrf(client)
resp = client.post("/cache/purge", data={"csrf": csrf, "stage": "execute"})
assert resp.status_code == 200
assert "Nothing removed" in resp.text
assert "max age" in resp.text
assert "result-info" in resp.text # neutral badge, not result-ok
assert resp.headers["HX-Trigger"] == "ci:refresh"
def test_cache_purge_max_age_override_from_form(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from ci_orchestrator.operations.cache import purge_cargo_cache as real_purge
spy = _spy(real_purge)
monkeypatch.setattr(routes_module, "purge_cargo_cache", spy)
config = _make_config(tmp_path)
client = TestClient(create_app(config))
csrf = _get_csrf(client)
# An explicit max age overrides the configured retention (30) for the
# age-based purge, and the confirm spells the chosen age out.
resp = client.post("/cache/purge", data={"csrf": csrf, "max_age_days": "7"})
assert resp.status_code == 200
assert "older than 7 day(s)" in resp.text
assert spy.calls[0][1]["retention_days"] == 7 # type: ignore[attr-defined]
assert spy.calls[0][1]["force"] is False # type: ignore[attr-defined]
def test_cache_empty_all_preview_forces_and_confirms(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from ci_orchestrator.operations.cache import purge_cargo_cache as real_purge
spy = _spy(real_purge)
monkeypatch.setattr(routes_module, "purge_cargo_cache", spy)
config = _make_config(tmp_path)
client = TestClient(create_app(config))
csrf = _get_csrf(client)
resp = client.post("/cache/purge", data={"csrf": csrf, "mode": "all"})
assert resp.status_code == 200
assert "Confirm" in resp.text
assert "Empty cargo cache" in resp.text
assert "entire cache" in resp.text
# The confirm form must carry mode=all through to the execute POST.
assert 'value="all"' in resp.text
assert spy.calls[0][1]["force"] is True # type: ignore[attr-defined]
assert spy.calls[0][1]["dry_run"] is True # type: ignore[attr-defined]
def test_cache_empty_all_execute_removes_regardless_of_age(tmp_path: Path) -> None:
cache_root = tmp_path / "cache" / "cargo"
cache_root.mkdir(parents=True)
fresh = cache_root / "v1-fresh.tar.zst"
fresh.write_bytes(b"x" * 10) # brand-new, well within the 30-day retention
config = _make_config(tmp_path)
client = TestClient(create_app(config))
csrf = _get_csrf(client)
resp = client.post("/cache/purge", data={"csrf": csrf, "mode": "all", "stage": "execute"})
assert resp.status_code == 200
assert not fresh.exists()
assert "Freed" in resp.text
assert resp.headers["HX-Trigger"] == "ci:refresh"
def test_vm_remove_preview_then_execute_full_cycle(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
@@ -602,8 +687,9 @@ def test_no_secret_leaks_into_any_action_response(
client.get("/auth/callback") # logs in
bodies: list[str] = []
bodies.append(client.get("/cache").text)
bodies.append(client.get("/").text)
csrf = _csrf_from(bodies[-1])
bodies.append(client.get("/cache").text)
bodies.append(client.post("/cache/purge", data={"csrf": csrf}).text)
bodies.append(client.post("/cache/purge", data={"csrf": csrf, "stage": "execute"}).text)
bodies.append(client.get("/audit").text)
+8 -7
View File
@@ -213,23 +213,24 @@ def test_cache_route_degrades_when_operations_raises(
# ── /host ─────────────────────────────────────────────────────────────────
def test_host_route_renders_disk_and_runner_status(
def test_runners_route_renders_runner_status(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Stage E: the /runners partial carries only the runner statuses -- the
# disk card was folded into the status-header Disk tile.
_quiet_host(monkeypatch)
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/host")
resp = client.get("/runners")
assert resp.status_code == 200
assert "Disk" in resp.text
assert "Runners" in resp.text
assert "act-runner.service" in resp.text
assert "act-runner-rust.service" in resp.text
def test_host_route_degrades_when_disk_status_raises(
def test_runners_route_degrades_when_disk_status_raises(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
def _boom(_path: Path) -> Any:
@@ -240,12 +241,12 @@ def test_host_route_degrades_when_disk_status_raises(
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/host")
resp = client.get("/runners")
assert resp.status_code == 200
def test_host_route_degrades_when_runner_status_raises(
def test_runners_route_degrades_when_runner_status_raises(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
def _boom(_names: list[str]) -> Any:
@@ -256,7 +257,7 @@ def test_host_route_degrades_when_runner_status_raises(
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/host")
resp = client.get("/runners")
assert resp.status_code == 200
+330
View File
@@ -0,0 +1,330 @@
"""Tests for the Stage-E artifact-delete route + ``operations.inventory``
helpers (``delete_artifact``, ``backups_by_template``).
SECURITY-CRITICAL: ``/artifacts/{id}/delete`` unlinks files under
``config.paths.artifacts`` for an authenticated web user. These tests assert
the same discipline the other mutating routes carry: CSRF, the two-step
confirm gate, path containment, and an audit entry per stage. No file outside
``tmp_path`` is ever touched.
"""
from __future__ import annotations
import re
from pathlib import Path
from fastapi.testclient import TestClient
from ci_orchestrator.config import (
BackendConfig,
CargoCacheConfig,
Config,
Paths,
WebConfig,
)
from ci_orchestrator.operations.audit import read_audit
from ci_orchestrator.operations.inventory import (
FileEntry,
backups_by_template,
delete_artifact,
delete_backup,
)
from ci_orchestrator.web.app import create_app
from ci_orchestrator.web.routes import encode_artifact_id
def _make_config(root: Path) -> Config:
return Config(
paths=Paths(
root=root,
templates=root / "templates",
build_vms=root / "build-vms",
artifacts=root / "artifacts",
keys=root / "keys",
),
backend=BackendConfig(),
cargo_cache=CargoCacheConfig(enabled=True, root=root / "cache" / "cargo", retention_days=30),
web=WebConfig(audit_log=root / "web-audit.jsonl"),
)
def _csrf_from(text: str) -> str:
match = re.search(r'name="csrf" value="([^"]+)"', text)
assert match is not None, f"no csrf field found in: {text[:300]!r}"
return match.group(1)
def _get_csrf(client: TestClient) -> str:
# The dashboard's Maintenance card always carries a CSRF field, even when
# there are no artifacts/VMs/templates to render an action form for.
resp = client.get("/")
assert resp.status_code == 200
return _csrf_from(resp.text)
def _make_artifact(config: Config, name: str = "build-42.tar.zst") -> Path:
config.paths.artifacts.mkdir(parents=True, exist_ok=True)
path = config.paths.artifacts / name
path.write_bytes(b"payload")
return path
# ── operations.inventory.delete_artifact ────────────────────────────────────
def test_delete_artifact_dry_run_keeps_the_file(tmp_path: Path) -> None:
config = _make_config(tmp_path)
path = _make_artifact(config)
result = delete_artifact(config, path.name, dry_run=True)
assert result.ok
assert path.exists()
def test_delete_artifact_execute_removes_the_file(tmp_path: Path) -> None:
config = _make_config(tmp_path)
path = _make_artifact(config)
result = delete_artifact(config, path.name, dry_run=False)
assert result.ok
assert not path.exists()
def test_delete_artifact_missing_file_is_error(tmp_path: Path) -> None:
config = _make_config(tmp_path)
config.paths.artifacts.mkdir(parents=True, exist_ok=True)
result = delete_artifact(config, "nope.zip", dry_run=False)
assert not result.ok
assert result.error is not None
def test_delete_artifact_traversal_is_refused(tmp_path: Path) -> None:
config = _make_config(tmp_path)
config.paths.artifacts.mkdir(parents=True, exist_ok=True)
secret = tmp_path / "secret.txt"
secret.write_text("do not delete", encoding="utf-8")
result = delete_artifact(config, "../secret.txt", dry_run=False)
assert not result.ok
assert secret.exists() # the traversal never reached outside artifacts/
# ── operations.inventory.backups_by_template ────────────────────────────────
def test_backups_by_template_groups_and_orders_newest_first(tmp_path: Path) -> None:
config = _make_config(tmp_path)
backups = config.paths.root / "backups"
backups.mkdir(parents=True)
import os
def _touch(name: str, mtime: float) -> None:
p = backups / name
p.write_bytes(b"x")
os.utime(p, (mtime, mtime))
# Real archive names carry a ``_%Y%m%d_%H%M%S`` timestamp (two ``_``
# chunks), exactly what operations/host.py::backup_template and the
# ``template backup`` CLI write -- the parser must strip *both* the date
# and the time to recover the template name (regression: it used to strip
# only the final chunk and grouped under "LinuxBuild2404_20260715").
_touch("Template_LinuxBuild2404_20260715_101500.7z", 1000.0)
_touch("Template_LinuxBuild2404_20260722_145001.7z", 2000.0) # newer
_touch("Template_WinBuild2025_20260720_030000.7z", 1500.0)
_touch("not-a-template-archive.7z", 1600.0) # ignored
grouped = backups_by_template(config)
assert set(grouped) == {"LinuxBuild2404", "WinBuild2025"}
linux = grouped["LinuxBuild2404"]
assert [e.name for e in linux] == [
"Template_LinuxBuild2404_20260722_145001.7z",
"Template_LinuxBuild2404_20260715_101500.7z",
]
assert all(isinstance(e, FileEntry) for e in linux)
# ── operations.inventory.delete_backup ──────────────────────────────────────
def _make_backup(config: Config, name: str = "Template_LinuxBuild2404_20260722_145001.7z") -> Path:
backups = config.paths.root / "backups"
backups.mkdir(parents=True, exist_ok=True)
path = backups / name
path.write_bytes(b"archive")
return path
def test_delete_backup_dry_run_reports_without_unlinking(tmp_path: Path) -> None:
config = _make_config(tmp_path)
path = _make_backup(config)
result = delete_backup(config, path.name, dry_run=True)
assert result.ok is True
assert result.target == path.resolve()
assert path.exists(), "dry_run must not delete"
def test_delete_backup_execute_unlinks(tmp_path: Path) -> None:
config = _make_config(tmp_path)
path = _make_backup(config)
result = delete_backup(config, path.name, dry_run=False)
assert result.ok is True
assert not path.exists()
def test_delete_backup_refuses_non_backup_name(tmp_path: Path) -> None:
config = _make_config(tmp_path)
backups = config.paths.root / "backups"
backups.mkdir(parents=True)
stray = backups / "keep-me.txt"
stray.write_bytes(b"x")
result = delete_backup(config, "keep-me.txt", dry_run=False)
assert result.ok is False
assert stray.exists(), "a non-archive file must never be unlinked"
def test_delete_backup_refuses_path_traversal(tmp_path: Path) -> None:
config = _make_config(tmp_path)
(config.paths.root / "backups").mkdir(parents=True)
secret = config.paths.root / "secret.7z"
secret.write_bytes(b"x")
# A traversal that also happens to satisfy the name regex must still be
# refused by containment (parent != backups dir) -- and the regex rejects
# the slash anyway, so this is doubly safe.
result = delete_backup(config, "../secret.7z", dry_run=False)
assert result.ok is False
assert secret.exists()
def test_delete_backup_missing_file_is_not_ok(tmp_path: Path) -> None:
config = _make_config(tmp_path)
(config.paths.root / "backups").mkdir(parents=True)
result = delete_backup(config, "Template_LinuxBuild2404_20260722_145001.7z", dry_run=False)
assert result.ok is False
assert result.error == "backup not found"
# ── route: /artifacts/{id}/delete ───────────────────────────────────────────
def test_delete_route_without_csrf_is_403(tmp_path: Path) -> None:
config = _make_config(tmp_path)
_make_artifact(config)
client = TestClient(create_app(config))
client.get("/files") # establish a session
resp = client.post(f"/artifacts/{encode_artifact_id('build-42.tar.zst')}/delete", data={})
assert resp.status_code == 403
def test_delete_route_invalid_id_is_400(tmp_path: Path) -> None:
config = _make_config(tmp_path)
client = TestClient(create_app(config))
csrf = _get_csrf(client)
# A path-bearing decoded name must be rejected before any op runs.
resp = client.post(f"/artifacts/{encode_artifact_id('a/b')}/delete", data={"csrf": csrf})
assert resp.status_code == 400
def test_delete_route_preview_confirms_and_keeps_file(tmp_path: Path) -> None:
config = _make_config(tmp_path)
path = _make_artifact(config)
client = TestClient(create_app(config))
csrf = _get_csrf(client)
resp = client.post(
f"/artifacts/{encode_artifact_id(path.name)}/delete", data={"csrf": csrf}
)
assert resp.status_code == 200
assert "Confirm" in resp.text
assert "would delete" in resp.text
assert path.exists()
assert read_audit(config.web.audit_log, last=10)[-1].result == "dry-run"
def test_delete_route_execute_deletes_and_audits_ok(tmp_path: Path) -> None:
config = _make_config(tmp_path)
path = _make_artifact(config)
client = TestClient(create_app(config))
csrf = _get_csrf(client)
resp = client.post(
f"/artifacts/{encode_artifact_id(path.name)}/delete",
data={"csrf": csrf, "stage": "execute"},
)
assert resp.status_code == 200
assert not path.exists()
entry = read_audit(config.web.audit_log, last=10)[-1]
assert entry.action == "artifact.delete"
assert entry.result == "ok"
# ── route: /backups/{id}/delete ─────────────────────────────────────────────
def test_backup_delete_without_csrf_is_403(tmp_path: Path) -> None:
config = _make_config(tmp_path)
_make_backup(config)
client = TestClient(create_app(config))
client.get("/") # establish a session
resp = client.post(
f"/backups/{encode_artifact_id('Template_LinuxBuild2404_20260722_145001.7z')}/delete",
data={},
)
assert resp.status_code == 403
def test_backup_delete_invalid_id_is_400(tmp_path: Path) -> None:
config = _make_config(tmp_path)
client = TestClient(create_app(config))
csrf = _get_csrf(client)
resp = client.post(f"/backups/{encode_artifact_id('a/b')}/delete", data={"csrf": csrf})
assert resp.status_code == 400
def test_backup_delete_preview_then_execute_full_cycle(tmp_path: Path) -> None:
config = _make_config(tmp_path)
path = _make_backup(config)
client = TestClient(create_app(config))
csrf = _get_csrf(client)
backup_id = encode_artifact_id(path.name)
preview = client.post(f"/backups/{backup_id}/delete", data={"csrf": csrf})
assert preview.status_code == 200
assert "Confirm" in preview.text
assert "would delete backup" in preview.text
assert path.exists()
assert read_audit(config.web.audit_log, last=10)[-1].result == "dry-run"
execute = client.post(f"/backups/{backup_id}/delete", data={"csrf": csrf, "stage": "execute"})
assert execute.status_code == 200
assert not path.exists()
# Every terminal result asks the dashboard to refresh (#3).
assert execute.headers["HX-Trigger"] == "ci:refresh"
entry = read_audit(config.web.audit_log, last=10)[-1]
assert entry.action == "backup.delete"
assert entry.result == "ok"
+43 -42
View File
@@ -1,4 +1,4 @@
"""Tests for the Stage B read-only web routes: ``/resources``, ``/vmware``,
"""Tests for the Stage B read-only web routes: ``/host``, ``/vmware``,
``/templates``, ``/files``, plus the B6 backup-form template dropdown.
In-process only, same conventions as ``test_web_app.py``: no real ``/proc``,
@@ -54,22 +54,24 @@ _TEMPLATE_ROW = TemplateInfo(
_FILE_ENTRY = FileEntry(name="Template_X_20260101_000000.7z", size_bytes=2048, mtime=1_700_000_000.0)
# ── /resources ───────────────────────────────────────────────────────────────
# ── /host (host resources card) ──────────────────────────────────────────────
def test_resources_route_renders_snapshot(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
def test_host_route_renders_snapshot(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(app_module, "host_resources", lambda: _RESOURCES)
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/resources")
resp = client.get("/host")
assert resp.status_code == 200
assert "Host resources" in resp.text
assert "42.5" in resp.text
# Stage E: CPU%/RAM-used moved to the status-header tiles; the Host card
# keeps uptime, load average, core count and total memory.
assert "Host" in resp.text
assert "16.0" in resp.text # total memory GB
def test_resources_route_degrades_on_probe_failure(
def test_host_route_degrades_on_probe_failure(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
def _boom() -> Any:
@@ -79,7 +81,7 @@ def test_resources_route_degrades_on_probe_failure(
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/resources")
resp = client.get("/host")
assert resp.status_code == 200
assert "unavailable" in resp.text.lower()
@@ -198,10 +200,27 @@ def test_templates_route_shows_running_ram_snapshot_warning(
# ── /files ────────────────────────────────────────────────────────────────────
def test_files_route_renders_backups_and_artifacts(
def test_files_route_renders_artifacts(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Stage E: /files is now the Artifacts panel only (backups moved under
# Templates). Each artifact row carries a Delete action.
artifact = FileEntry(name="build-99.tar.zst", size_bytes=2048, mtime=1_700_000_000.0)
monkeypatch.setattr(app_module, "list_artifacts", lambda _config: [artifact])
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/files")
assert resp.status_code == 200
assert "build-99.tar.zst" in resp.text
assert "Build outputs collected" in resp.text # the panel description
assert "Delete" in resp.text
def test_files_route_empty_shows_no_artifacts(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(app_module, "list_backups", lambda _config: [_FILE_ENTRY])
monkeypatch.setattr(app_module, "list_artifacts", lambda _config: [])
app = create_app(_make_config(tmp_path))
client = TestClient(app)
@@ -209,34 +228,15 @@ def test_files_route_renders_backups_and_artifacts(
resp = client.get("/files")
assert resp.status_code == 200
assert "Template_X_20260101_000000.7z" in resp.text
assert "No artifacts found" in resp.text
def test_files_route_degrades_on_backups_probe_failure(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
def _boom(_config: Config) -> Any:
raise RuntimeError("boom")
monkeypatch.setattr(app_module, "list_backups", _boom)
monkeypatch.setattr(app_module, "list_artifacts", lambda _config: [])
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/files")
assert resp.status_code == 200
assert "unavailable" in resp.text.lower()
def test_files_route_degrades_on_artifacts_probe_failure(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
def _boom(_config: Config) -> Any:
raise RuntimeError("boom")
monkeypatch.setattr(app_module, "list_backups", lambda _config: [])
monkeypatch.setattr(app_module, "list_artifacts", _boom)
app = create_app(_make_config(tmp_path))
client = TestClient(app)
@@ -257,7 +257,6 @@ def test_dashboard_renders_all_stage_b_cards(
monkeypatch.setattr(app_module, "host_resources", lambda: _RESOURCES)
monkeypatch.setattr(app_module, "vmware_module_health", lambda: _VMWARE_OK)
monkeypatch.setattr(app_module, "list_templates", lambda _config: [_TEMPLATE_ROW])
monkeypatch.setattr(app_module, "list_backups", lambda _config: [])
monkeypatch.setattr(app_module, "list_artifacts", lambda _config: [])
app = create_app(_make_config(tmp_path))
client = TestClient(app)
@@ -265,38 +264,40 @@ def test_dashboard_renders_all_stage_b_cards(
resp = client.get("/")
assert resp.status_code == 200
for needle in ("Host resources", "VMware modules", "Templates", "Template backups", "Artifacts"):
# Stage E card set: the "Host" card, VMware modules, Templates, Artifacts,
# plus the new bottom Maintenance card.
for needle in ("Host", "VMware modules", "Templates", "Artifacts", "Maintenance"):
assert needle in resp.text
# ── B6: backup dropdown ──────────────────────────────────────────────────────
# ── Stage E: per-template backup action (moved from the host backup dropdown) ─
def test_host_partial_backup_form_offers_discovered_template_names(
def test_templates_partial_offers_per_row_backup_action(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_quiet_systemctl(monkeypatch)
monkeypatch.setattr(app_module, "list_templates", lambda _config: [_TEMPLATE_ROW])
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/host")
resp = client.get("/templates")
assert resp.status_code == 200
assert '<select id="host-backup-template" name="template"' in resp.text
assert '<option value="LinuxBuild2404">LinuxBuild2404</option>' in resp.text
assert "7z archive of the template dir under" in resp.text
# Each row posts its own template name to /host/backup (no more dropdown).
assert 'action="/host/backup"' in resp.text
assert '<input type="hidden" name="template" value="LinuxBuild2404">' in resp.text
assert "Backup" in resp.text
def test_host_partial_backup_form_empty_dropdown_when_no_templates(
def test_templates_partial_no_backup_action_when_no_templates(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_quiet_systemctl(monkeypatch)
monkeypatch.setattr(app_module, "list_templates", lambda _config: [])
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/host")
resp = client.get("/templates")
assert resp.status_code == 200
assert "<option" not in resp.text
assert 'action="/host/backup"' not in resp.text
assert "No templates found" in resp.text
+7 -7
View File
@@ -55,7 +55,7 @@ def _csrf_from(text: str) -> str:
def test_hx_request_preview_gets_bare_partial(tmp_path: Path) -> None:
app = create_app(_make_config(tmp_path))
client = TestClient(app)
csrf = _csrf_from(client.get("/cache").text)
csrf = _csrf_from(client.get("/").text)
resp = client.post(
"/cache/purge", data={"csrf": csrf}, headers={"HX-Request": "true"}
@@ -70,7 +70,7 @@ def test_hx_request_preview_gets_bare_partial(tmp_path: Path) -> None:
def test_hx_request_execute_gets_bare_partial(tmp_path: Path) -> None:
app = create_app(_make_config(tmp_path))
client = TestClient(app)
csrf = _csrf_from(client.get("/cache").text)
csrf = _csrf_from(client.get("/").text)
resp = client.post(
"/cache/purge",
@@ -89,12 +89,12 @@ def test_hx_request_execute_gets_bare_partial(tmp_path: Path) -> None:
def test_no_hx_header_preview_gets_full_themed_page(tmp_path: Path) -> None:
app = create_app(_make_config(tmp_path))
client = TestClient(app)
csrf = _csrf_from(client.get("/cache").text)
csrf = _csrf_from(client.get("/").text)
resp = client.post("/cache/purge", data={"csrf": csrf})
assert resp.status_code == 200
assert '<link rel="stylesheet" href="/static/app.css">' in resp.text
assert '<link rel="stylesheet" href="/static/app.css?v=' in resp.text
assert 'id="action-slot"' in resp.text
assert "Confirm" in resp.text
@@ -102,7 +102,7 @@ def test_no_hx_header_preview_gets_full_themed_page(tmp_path: Path) -> None:
def test_no_hx_header_execute_gets_full_themed_page(tmp_path: Path) -> None:
app = create_app(_make_config(tmp_path))
client = TestClient(app)
csrf = _csrf_from(client.get("/cache").text)
csrf = _csrf_from(client.get("/").text)
resp = client.post("/cache/purge", data={"csrf": csrf, "stage": "execute"})
@@ -123,7 +123,7 @@ def test_lock_conflict_status_code_preserved_in_full_page(tmp_path: Path) -> Non
app = create_app(config)
app.dependency_overrides[get_backend] = lambda: None
client = TestClient(app)
csrf = _csrf_from(client.get("/cache").text)
csrf = _csrf_from(client.get("/").text)
resp = client.post("/vms/cleanup", data={"csrf": csrf, "stage": "execute"})
@@ -141,7 +141,7 @@ def test_lock_conflict_status_code_preserved_bare_for_htmx(tmp_path: Path) -> No
app = create_app(config)
app.dependency_overrides[get_backend] = lambda: None
client = TestClient(app)
csrf = _csrf_from(client.get("/cache").text)
csrf = _csrf_from(client.get("/").text)
resp = client.post(
"/vms/cleanup",