fix(web): backup age in Templates card was frozen by hx-preserve

The backups <details> 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.
This commit is contained in:
2026-07-26 03:04:22 +02:00
parent 3da992119b
commit 9ab760a64c
3 changed files with 62 additions and 1 deletions
+29
View File
@@ -239,6 +239,33 @@
} }
}); });
// Templates card: each "N backup(s)" row is a <details> 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) { document.body.addEventListener("htmx:afterSwap", function (evt) {
var target = evt.detail && evt.detail.target; var target = evt.detail && evt.detail.target;
if (!target || !target.isConnected) { if (!target || !target.isConnected) {
@@ -253,11 +280,13 @@
} }
if (target) { if (target) {
restoreTimeToggles(target); restoreTimeToggles(target);
wireDetails(target);
} }
}); });
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
observeStack(); observeStack();
processSlot(document.getElementById("action-slot")); processSlot(document.getElementById("action-slot"));
wireDetails(document);
}); });
})(); })();
@@ -35,7 +35,7 @@
</td> </td>
<td> <td>
{% if bks %} {% if bks %}
<details class="backups-details" id="backups-{{ tmpl.name }}" hx-preserve="true"> <details class="backups-details" id="backups-{{ tmpl.name }}">
<summary>{{ bks | length }} backup(s) <span class="text-dim">· newest {{ bks[0].mtime | relative_time }}</span></summary> <summary>{{ bks | length }} backup(s) <span class="text-dim">· newest {{ bks[0].mtime | relative_time }}</span></summary>
<ul class="backups-list"> <ul class="backups-list">
{% for b in bks %} {% for b in bks %}
+32
View File
@@ -180,6 +180,38 @@ def test_templates_route_degrades_on_probe_failure(
assert "unavailable" in resp.text.lower() 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 <details> 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( def test_templates_route_shows_running_ram_snapshot_warning(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None: