diff --git a/docs/WEB-UI.md b/docs/WEB-UI.md index 3686676..30ad2fe 100644 --- a/docs/WEB-UI.md +++ b/docs/WEB-UI.md @@ -359,6 +359,14 @@ table is wide as a result — 13 columns — and scrolls horizontally inside the card's own `.table-wrap` rather than the page, same as the other wide tables (Templates, Services). +**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. + 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 non-admin fallback. Results are cached for 10s, so the card's 5s refresh costs diff --git a/src/ci_orchestrator/web/static/app.css b/src/ci_orchestrator/web/static/app.css index 869097f..8cc6214 100644 --- a/src/ci_orchestrator/web/static/app.css +++ b/src/ci_orchestrator/web/static/app.css @@ -501,6 +501,22 @@ input[type="text"], input[type="number"], select { /* Muted inline text (e.g. a relative-time suffix next to a file name). */ .text-dim { color: var(--text-dim); } +/* Executions "Started" cell: reset to read as plain inline text (no button + * chrome), with a dotted underline as the click-to-toggle affordance -- + * not colour alone, same rule the rest of the app follows. app.js swaps + * between its data-relative/data-absolute values on click. */ +.time-toggle { + background: none; + border: none; + padding: 0; + margin: 0; + font: inherit; + cursor: pointer; + text-decoration: underline dotted; + text-underline-offset: 2px; +} +.time-toggle:hover { color: var(--text); } + /* ── notices / confirm / result panels ───────────────────────────────── */ .notice { diff --git a/src/ci_orchestrator/web/static/app.js b/src/ci_orchestrator/web/static/app.js index 1fdc33f..92bc825 100644 --- a/src/ci_orchestrator/web/static/app.js +++ b/src/ci_orchestrator/web/static/app.js @@ -204,6 +204,22 @@ } }); + // 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. + 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; + }); + document.addEventListener("DOMContentLoaded", function () { observeStack(); processSlot(document.getElementById("action-slot")); diff --git a/src/ci_orchestrator/web/templates/partials/executions.html b/src/ci_orchestrator/web/templates/partials/executions.html index 66f2727..08e83ff 100644 --- a/src/ci_orchestrator/web/templates/partials/executions.html +++ b/src/ci_orchestrator/web/templates/partials/executions.html @@ -89,7 +89,9 @@ {{ run.duration_sec | duration }} {% if run.started %} - {{ run.started | relative_time }} + {% else %} — {% endif %} @@ -137,7 +139,9 @@ — {% if parsed.started %} - {{ parsed.started | relative_time }} + {% else %} — {% endif %} diff --git a/tests/python/test_web_executions.py b/tests/python/test_web_executions.py index f0934ec..3bd1a69 100644 --- a/tests/python/test_web_executions.py +++ b/tests/python/test_web_executions.py @@ -340,6 +340,34 @@ def test_status_badge_has_no_icon_for_non_terminal_states( assert "#i-x" not in resp.text +def test_started_cell_is_a_click_to_toggle_button( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """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.""" + _stub_view( + monkeypatch, + ExecutionsView( + runs=[_execution(started="2026-07-25T11:50:00Z")], + unmatched_vms=[], + source="admin", + error=None, + ), + ) + app = create_app(_make_config(tmp_path)) + app.dependency_overrides[get_backend] = lambda: _FakeBackend([]) + client = TestClient(app) + + resp = client.get("/executions") + + assert '