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; **Started is click-to-toggle.** It renders relative ("2h ago") by default;
clicking swaps to the absolute local timestamp, and clicking again swaps clicking swaps to the absolute local timestamp, and clicking again swaps
back. Both strings are pre-rendered server-side into the button's back. Both strings are pre-rendered server-side into the button's
`data-relative`/`data-absolute` attributes, so the swap (`app.js`, delegated `data-relative`/`data-absolute` attributes, so the swap (`app.js`) is
on `document.body`) is instant and needs no request — and keeps working instant and needs no request. The card's own 5s poll re-renders the whole
after the card's own 5s poll replaces the row, since the listener isn't on table from the server, always starting back at relative — so which format
the button itself. 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 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 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 }); }).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 // Executions "Started" cell: click swaps between the relative and
// absolute time already rendered into the button's own data attributes -- // absolute time already rendered into the button's own data attributes --
// no request. Delegated on body (not the button itself) so it keeps // 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. // 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) { document.body.addEventListener("click", function (evt) {
var btn = evt.target.closest(".time-toggle"); var btn = evt.target.closest(".time-toggle");
if (!btn) { if (!btn) {
return; return;
} }
var next = btn.dataset.showing === "relative" ? "absolute" : "relative"; var next = btn.dataset.showing === "relative" ? "absolute" : "relative";
var shown = next === "relative" ? "absolute" : "relative"; applyTimeToggle(btn, next);
btn.textContent = btn.dataset[next]; if (btn.id) {
btn.title = btn.dataset[shown]; timeToggleState[btn.id] = next;
btn.dataset.showing = 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 () { document.addEventListener("DOMContentLoaded", function () {
@@ -89,7 +89,7 @@
<td>{{ run.duration_sec | duration }}</td> <td>{{ run.duration_sec | duration }}</td>
<td> <td>
{% if run.started %} {% 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 }}" data-relative="{{ run.started | relative_time }}" data-absolute="{{ run.started | localtime }}"
title="{{ run.started | localtime }}">{{ run.started | relative_time }}</button> title="{{ run.started | localtime }}">{{ run.started | relative_time }}</button>
{% else %} {% else %}
@@ -139,7 +139,7 @@
<td><span class="text-dim">—</span></td> <td><span class="text-dim">—</span></td>
<td> <td>
{% if parsed.started %} {% 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 }}" data-relative="{{ parsed.started | relative_time }}" data-absolute="{{ parsed.started | localtime }}"
title="{{ parsed.started | localtime }}">{{ parsed.started | relative_time }}</button> title="{{ parsed.started | localtime }}">{{ parsed.started | relative_time }}</button>
{% else %} {% 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 """Both the relative and absolute renderings are pre-rendered into the
button's data attributes -- app.js swaps textContent/title on click, no 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 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( _stub_view(
monkeypatch, monkeypatch,
ExecutionsView( ExecutionsView(
runs=[_execution(started="2026-07-25T11:50:00Z")], runs=[_execution(run_id=412, attempt=1, started="2026-07-25T11:50:00Z")],
unmatched_vms=[], unmatched_vms=[],
source="admin", source="admin",
error=None, error=None,
@@ -362,7 +368,8 @@ def test_started_cell_is_a_click_to_toggle_button(
resp = client.get("/executions") 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-showing="relative"' in resp.text
assert 'data-relative="' in resp.text assert 'data-relative="' in resp.text
assert 'data-absolute="' in resp.text assert 'data-absolute="' in resp.text