From bfa90190eab9fb81051a07f493b638b487a5a3b3 Mon Sep 17 00:00:00 2001 From: Simone Date: Sun, 26 Jul 2026 00:25:33 +0200 Subject: [PATCH] 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). --- docs/WEB-UI.md | 12 +++-- src/ci_orchestrator/web/static/app.js | 51 +++++++++++++++---- .../web/templates/partials/executions.html | 4 +- tests/python/test_web_executions.py | 13 +++-- 4 files changed, 60 insertions(+), 20 deletions(-) diff --git a/docs/WEB-UI.md b/docs/WEB-UI.md index 30ad2fe..dc324fc 100644 --- a/docs/WEB-UI.md +++ b/docs/WEB-UI.md @@ -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 diff --git a/src/ci_orchestrator/web/static/app.js b/src/ci_orchestrator/web/static/app.js index 92bc825..7212718 100644 --- a/src/ci_orchestrator/web/static/app.js +++ b/src/ci_orchestrator/web/static/app.js @@ -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 () { diff --git a/src/ci_orchestrator/web/templates/partials/executions.html b/src/ci_orchestrator/web/templates/partials/executions.html index 08e83ff..740319f 100644 --- a/src/ci_orchestrator/web/templates/partials/executions.html +++ b/src/ci_orchestrator/web/templates/partials/executions.html @@ -89,7 +89,7 @@ {{ run.duration_sec | duration }} {% if run.started %} - {% else %} @@ -139,7 +139,7 @@ — {% if parsed.started %} - {% else %} diff --git a/tests/python/test_web_executions.py b/tests/python/test_web_executions.py index 3bd1a69..73ce516 100644 --- a/tests/python/test_web_executions.py +++ b/tests/python/test_web_executions.py @@ -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 '