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
+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