From 9ab760a64c07bcb3d0bda7cb99f5af3e32192300 Mon Sep 17 00:00:00 2001 From: Simone Date: Sun, 26 Jul 2026 03:04:22 +0200 Subject: [PATCH] fix(web): backup age in Templates card was frozen by hx-preserve The backups
carried hx-preserve="true" to survive the card's 30s poll without collapsing if the operator had it open -- but hx-preserve freezes the whole node, so "newest X ago" inside it never updated either, same class of bug as the Started column before it got its own JS-side toggle state. Drop hx-preserve; track open/closed the same way (stable id, state re-applied after every swap) so the count/age text stays live. --- src/ci_orchestrator/web/static/app.js | 29 +++++++++++++++++ .../web/templates/partials/templates.html | 2 +- tests/python/test_web_stage_b.py | 32 +++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/ci_orchestrator/web/static/app.js b/src/ci_orchestrator/web/static/app.js index d7a6041..53cf6d1 100644 --- a/src/ci_orchestrator/web/static/app.js +++ b/src/ci_orchestrator/web/static/app.js @@ -239,6 +239,33 @@ } }); + // Templates card: each "N backup(s)" row is a
the operator may + // expand -- it must not collapse every 30s poll. Unlike the time-toggle + // buttons, its own content (backup count, "newest X ago") legitimately + // needs to keep updating, so this tracks only open/closed the same way + // (stable id, state re-applied after every swap) instead of freezing the + // whole node with hx-preserve, which used to freeze that text too. + var detailsState = {}; + + function wireDetails(root) { + var list = root.querySelectorAll + ? root.querySelectorAll("details.backups-details[id]") + : []; + Array.prototype.forEach.call(list, function (el) { + var saved = detailsState[el.id]; + if (saved !== undefined) { + el.open = saved; + } + if (el.dataset.wired === "true") { + return; + } + el.dataset.wired = "true"; + el.addEventListener("toggle", function () { + detailsState[el.id] = el.open; + }); + }); + } + document.body.addEventListener("htmx:afterSwap", function (evt) { var target = evt.detail && evt.detail.target; if (!target || !target.isConnected) { @@ -253,11 +280,13 @@ } if (target) { restoreTimeToggles(target); + wireDetails(target); } }); document.addEventListener("DOMContentLoaded", function () { observeStack(); processSlot(document.getElementById("action-slot")); + wireDetails(document); }); })(); diff --git a/src/ci_orchestrator/web/templates/partials/templates.html b/src/ci_orchestrator/web/templates/partials/templates.html index 72e186a..dd2859f 100644 --- a/src/ci_orchestrator/web/templates/partials/templates.html +++ b/src/ci_orchestrator/web/templates/partials/templates.html @@ -35,7 +35,7 @@ {% if bks %} -
+
{{ bks | length }} backup(s) ยท newest {{ bks[0].mtime | relative_time }}
    {% for b in bks %} diff --git a/tests/python/test_web_stage_b.py b/tests/python/test_web_stage_b.py index c137fa7..8ff9715 100644 --- a/tests/python/test_web_stage_b.py +++ b/tests/python/test_web_stage_b.py @@ -180,6 +180,38 @@ def test_templates_route_degrades_on_probe_failure( assert "unavailable" in resp.text.lower() +def test_templates_backups_details_is_not_frozen_by_hx_preserve( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """``hx-preserve`` freezes the *whole* node across the card's 30s poll -- + including the "newest X ago" text inside it, which must keep updating. + Only the
    open/closed state should persist (via app.js, the + same pattern already used for the Started column's click-to-toggle).""" + monkeypatch.setattr(app_module, "list_templates", lambda _config: [_TEMPLATE_ROW]) + monkeypatch.setattr( + app_module, + "backups_by_template", + lambda _config: { + "LinuxBuild2404": [ + FileEntry( + name="Template_LinuxBuild2404_20260101_000000.7z", + size_bytes=2048, + mtime=1_700_000_000.0, + ) + ] + }, + ) + app = create_app(_make_config(tmp_path)) + client = TestClient(app) + + resp = client.get("/templates") + + assert resp.status_code == 200 + assert 'id="backups-LinuxBuild2404"' in resp.text + assert "newest" in resp.text + assert "hx-preserve" not in resp.text + + def test_templates_route_shows_running_ram_snapshot_warning( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: