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)
|
||||
|
||||
Reference in New Issue
Block a user