fix(web): Started toggle survives the Executions card's 5s poll

The card's own htmx poll re-renders the whole table from the server every
5s, always starting fresh at "relative" -- so a toggled-to-absolute cell
snapped back on the next poll. Each button now has a stable id
(started-{run_id}-{attempt}, or started-vm-{vmx} on an unmatched-VM row);
app.js remembers which format was showing per id and re-applies it to the
fresh button after every swap, rather than freezing the node itself (which
would also freeze its relative-time text, badge, duration, everything else
in that row).
This commit is contained in:
2026-07-26 00:25:33 +02:00
parent 0d9c5ab353
commit bfa90190ea
4 changed files with 60 additions and 20 deletions
+8 -4
View File
@@ -362,10 +362,14 @@ card's own `.table-wrap` rather than the page, same as the other wide tables
**Started is click-to-toggle.** It renders relative ("2h ago") by default;
clicking swaps to the absolute local timestamp, and clicking again swaps
back. Both strings are pre-rendered server-side into the button's
`data-relative`/`data-absolute` attributes, so the swap (`app.js`, delegated
on `document.body`) is instant and needs no request — and keeps working
after the card's own 5s poll replaces the row, since the listener isn't on
the button itself.
`data-relative`/`data-absolute` attributes, so the swap (`app.js`) is
instant and needs no request. The card's own 5s poll re-renders the whole
table from the server, always starting back at relative — so which format
was showing is remembered client-side, keyed by the button's stable `id`
(`started-{run_id}-{attempt}`, or `started-vm-{vmx}` for an unmatched VM
row), and re-applied to the fresh button after every poll rather than
frozen onto the old node (which would also freeze its relative-time text,
badge, duration — everything else in that row).
Runs come from Gitea's Actions API: `/api/v1/admin/actions/runs` (every repo
on the instance, admin token) with `/api/v1/user/actions/runs` as the
+40 -11
View File
@@ -197,27 +197,56 @@
}).observe(stack, { childList: true });
}
document.body.addEventListener("htmx:afterSwap", function (evt) {
var target = evt.detail && evt.detail.target;
if (target && target.id === "action-slot") {
processSlot(target);
}
});
// Executions "Started" cell: click swaps between the relative and
// absolute time already rendered into the button's own data attributes --
// no request. Delegated on body (not the button itself) so it keeps
// working after the Executions card's own 5s htmx poll replaces the row.
//
// The poll re-renders the whole card from the server every 5s, always
// starting back at "relative" -- so the choice is remembered here, keyed
// by the button's own id (stable across polls: run id/attempt or vmx),
// and re-applied to the fresh button restoreTimeToggles() finds after each
// swap, rather than freezing the node itself (which would also freeze its
// relative-time text, badge, duration -- everything else in the row).
var timeToggleState = {};
function applyTimeToggle(btn, showing) {
var shown = showing === "relative" ? "absolute" : "relative";
btn.textContent = btn.dataset[showing];
btn.title = btn.dataset[shown];
btn.dataset.showing = showing;
}
function restoreTimeToggles(root) {
var buttons = root.querySelectorAll ? root.querySelectorAll(".time-toggle[id]") : [];
Array.prototype.forEach.call(buttons, function (btn) {
var saved = timeToggleState[btn.id];
if (saved && saved !== btn.dataset.showing) {
applyTimeToggle(btn, saved);
}
});
}
document.body.addEventListener("click", function (evt) {
var btn = evt.target.closest(".time-toggle");
if (!btn) {
return;
}
var next = btn.dataset.showing === "relative" ? "absolute" : "relative";
var shown = next === "relative" ? "absolute" : "relative";
btn.textContent = btn.dataset[next];
btn.title = btn.dataset[shown];
btn.dataset.showing = next;
applyTimeToggle(btn, next);
if (btn.id) {
timeToggleState[btn.id] = next;
}
});
document.body.addEventListener("htmx:afterSwap", function (evt) {
var target = evt.detail && evt.detail.target;
if (target && target.id === "action-slot") {
processSlot(target);
}
if (target) {
restoreTimeToggles(target);
}
});
document.addEventListener("DOMContentLoaded", function () {
@@ -89,7 +89,7 @@
<td>{{ run.duration_sec | duration }}</td>
<td>
{% if run.started %}
<button type="button" class="time-toggle text-dim" data-showing="relative"
<button type="button" id="started-{{ run.run_id }}-{{ run.attempt }}" class="time-toggle text-dim" data-showing="relative"
data-relative="{{ run.started | relative_time }}" data-absolute="{{ run.started | localtime }}"
title="{{ run.started | localtime }}">{{ run.started | relative_time }}</button>
{% else %}
@@ -139,7 +139,7 @@
<td><span class="text-dim">—</span></td>
<td>
{% if parsed.started %}
<button type="button" class="time-toggle text-dim" data-showing="relative"
<button type="button" id="started-vm-{{ vm.vmx | vmx_id }}" class="time-toggle text-dim" data-showing="relative"
data-relative="{{ parsed.started | relative_time }}" data-absolute="{{ parsed.started | localtime }}"
title="{{ parsed.started | localtime }}">{{ parsed.started | relative_time }}</button>
{% else %}
+10 -3
View File
@@ -346,11 +346,17 @@ def test_started_cell_is_a_click_to_toggle_button(
"""Both the relative and absolute renderings are pre-rendered into the
button's data attributes -- app.js swaps textContent/title on click, no
round trip. Server-side, this test can only pin the markup the toggle
depends on, not the click itself."""
depends on, not the click itself.
The id must be stable across the card's own 5s poll (run_id/attempt, not
anything generated fresh per render) -- app.js keys its client-side
"which format is showing" memory off it to survive the next poll's
server-rendered reset back to relative.
"""
_stub_view(
monkeypatch,
ExecutionsView(
runs=[_execution(started="2026-07-25T11:50:00Z")],
runs=[_execution(run_id=412, attempt=1, started="2026-07-25T11:50:00Z")],
unmatched_vms=[],
source="admin",
error=None,
@@ -362,7 +368,8 @@ def test_started_cell_is_a_click_to_toggle_button(
resp = client.get("/executions")
assert '<button type="button" class="time-toggle text-dim"' in resp.text
assert 'id="started-412-1"' in resp.text
assert 'class="time-toggle text-dim"' in resp.text
assert 'data-showing="relative"' in resp.text
assert 'data-relative="' in resp.text
assert 'data-absolute="' in resp.text