From 7f2b03029a90232ccdd393c10af033ec3721806c Mon Sep 17 00:00:00 2001 From: Simone Date: Sat, 25 Jul 2026 19:33:27 +0200 Subject: [PATCH] feat(web): Executions table gets real columns and status icons No cell stacks two facts on separate lines with
any more (Run/Repo, Branch/Commit, Trigger/Actor, Duration/Started, VM/IP-Uptime each get their own column, 8 -> 13 total). The card is already .card--wide with its own .table-wrap, so the extra width scrolls horizontally rather than the page. Status pills carry a check/x/play icon for the three outcomes that are a real verdict or an in-progress state (success/failure/running); queued, cancelled, skipped stay plain text. Every pill in the column -- including the VM-only orphan/running pills on unmatched-VM rows -- is forced to the same width with left-aligned content instead of a ragged column. --- src/ci_orchestrator/web/filters.py | 46 +++++++++ .../web/templates/partials/executions.html | 67 ++++++++----- .../web/templates/partials/icons.html | 8 ++ tests/python/test_web_executions.py | 99 +++++++++++++++++++ tests/python/test_web_filters.py | 20 ++++ 5 files changed, 214 insertions(+), 26 deletions(-) diff --git a/src/ci_orchestrator/web/filters.py b/src/ci_orchestrator/web/filters.py index ee48f18..1718edf 100644 --- a/src/ci_orchestrator/web/filters.py +++ b/src/ci_orchestrator/web/filters.py @@ -9,6 +9,7 @@ degraded ``operations/`` fetch, an unset timestamp, ...). from __future__ import annotations +import re from datetime import UTC, datetime from pathlib import Path from typing import TYPE_CHECKING @@ -179,6 +180,47 @@ def run_badge(status: str | None) -> str: return _RUN_BADGES.get(status or "", "badge-muted") +#: Icon id for a run status: a check/x for the two pass/fail verdicts, a play +#: glyph for "still going". ``queued``/``cancelled``/``skipped`` are neither +#: a verdict nor in progress, so they stay plain text -- an icon there would +#: just be noise repeating the badge colour. +_RUN_BADGE_ICONS: dict[str, str] = { + "success": "i-check", + "failure": "i-x", + "running": "i-play", +} + + +def run_badge_icon(status: str | None) -> str: + """Icon id for :func:`run_badge`'s pill, or ``""`` for non-terminal states.""" + return _RUN_BADGE_ICONS.get(status or "", "") + + +#: Marketing noise in ``/proc/cpuinfo``'s ``model name`` that costs tile width +#: and says nothing an operator needs: trademark marks, the clock-speed tail +#: (the CPU tile already shows live load, and the base clock is not the +#: current one), and the core-count tail (the tile prints the real ``ncpu``). +_CPU_NOISE = re.compile( + r"\((?:R|TM|tm|r)\)" + r"|\s+(?:CPU|Processor)?\s*@\s*[\d.]+\s*[GM]Hz" + r"|\s+\d+-Core\s+Processor" + r"|\s+CPU$" +) + + +def cpu_short(model: str | None) -> str: + """Trim a ``/proc/cpuinfo`` model name to what fits a status tile. + + ``"Intel(R) Core(TM) i9-13900K CPU @ 3.00GHz"`` -> ``"Intel Core i9-13900K"``. + An empty/absent model stays ``""`` (not ``"-"``) so the template can drop + the line entirely instead of rendering a dash under the CPU label. The full + untrimmed name still shows in the Platform card. + """ + if not model: + return "" + return " ".join(_CPU_NOISE.sub(" ", model).split()) + + def clone_job(name: str | None) -> ParsedClone: """Parse a VM/clone name into its ``job_id`` + ``started`` (Stage C1). @@ -202,16 +244,20 @@ def register(templates: Jinja2Templates) -> None: templates.env.filters["thousands"] = thousands templates.env.filters["clone_job"] = clone_job templates.env.filters["run_badge"] = run_badge + templates.env.filters["run_badge_icon"] = run_badge_icon + templates.env.filters["cpu_short"] = cpu_short __all__ = [ "clone_job", + "cpu_short", "duration", "humanbytes", "localtime", "register", "relative_time", "run_badge", + "run_badge_icon", "shorten_path", "thousands", ] diff --git a/src/ci_orchestrator/web/templates/partials/executions.html b/src/ci_orchestrator/web/templates/partials/executions.html index 9043688..66f2727 100644 --- a/src/ci_orchestrator/web/templates/partials/executions.html +++ b/src/ci_orchestrator/web/templates/partials/executions.html @@ -3,10 +3,17 @@ Actions run (the live ones included), with the build VM(s) that run spawned grouped into its VM cell, Remove action and all. + Every fact gets its own column -- no cell stacks two data points with +
(Run/Repo, Branch/Commit, Trigger/Actor, Duration/Started, VM/IP are + each a pair that used to share one cell on two lines). A wide table is the + accepted cost here: the card is already `.card--wide` and its own + `.table-wrap` scrolls horizontally rather than the page. + Degraded states (no token, expired token, Gitea unreachable) render a notice and still list every running VM below, so this card is never less useful than the VM table it replaces. Orphan VMs are NOT handled here: the sweep - lives in the Maintenance card and the count in the status-header VMs tile; + lives in the Maintenance card (Stage F retired the status-header VMs tile + that carried the count); ``executions.unmatched_vms`` only exists so a powered-on VM whose run has scrolled out of the window (or that no run ever claimed) keeps its row and its Remove button. @@ -47,12 +54,17 @@ Run + Repo Workflow - Branch / commit + Branch + Commit Trigger + Actor Status Duration + Started VM + IP / Uptime Actions @@ -65,32 +77,35 @@ {% else %} #{{ run.number }}{% if run.attempt > 1 %}.{{ run.attempt }}{% endif %} {% endif %} - {% if run.repo %}
{{ run.repo }}{% endif %} + {{ run.repo or "—" }} {{ run.workflow or "—" }} + {{ run.branch or "—" }} + {% if run.commit %}{{ run.commit[:7] }}{% else %}—{% endif %} + {{ run.event or "—" }} + {{ run.actor or "—" }} + {% set run_icon = run.status | run_badge_icon %} + {% if run_icon %}{% endif %}{{ run.status }} + {{ run.duration_sec | duration }} - {{ run.branch or "—" }} - {% if run.commit %}
{{ run.commit[:7] }}{% endif %} - - - {{ run.event or "—" }} - {% if run.actor %}
{{ run.actor }}{% endif %} - - {{ run.status }} - - {{ run.duration_sec | duration }} - {% if run.started %}
{{ run.started | relative_time }}{% endif %} + {% if run.started %} + {{ run.started | relative_time }} + {% else %} + — + {% endif %} {% for vm in run.vms %} -
- {{ vm.name }} -
{{ vm.ip or "no ip" }} · up {{ vm.uptime_sec | duration }} -
+
{{ vm.name }}
{% else %} — {% endfor %} + + {% for vm in run.vms %} +
{{ vm.ip or "no ip" }} · up {{ vm.uptime_sec | duration }}
+ {% endfor %} + {% for vm in run.vms %}
{% set parsed = vm.name | clone_job %} - - {{ parsed.job_id or "no run" }} - + {{ parsed.job_id or "no run" }} + — + — + — — — — @@ -115,9 +131,10 @@ {% if vm.is_orphan %} orphan {% else %} - running + running {% endif %} + — {% if parsed.started %} {{ parsed.started | relative_time }} @@ -125,10 +142,8 @@ — {% endif %} - - {{ vm.name }} -
{{ vm.ip or "no ip" }} · up {{ vm.uptime_sec | duration }} - + {{ vm.name }} + {{ vm.ip or "no ip" }} · up {{ vm.uptime_sec | duration }} diff --git a/src/ci_orchestrator/web/templates/partials/icons.html b/src/ci_orchestrator/web/templates/partials/icons.html index 7654eb3..6c1873d 100644 --- a/src/ci_orchestrator/web/templates/partials/icons.html +++ b/src/ci_orchestrator/web/templates/partials/icons.html @@ -77,6 +77,14 @@ + + + + + + diff --git a/tests/python/test_web_executions.py b/tests/python/test_web_executions.py index 85b4dc2..f0934ec 100644 --- a/tests/python/test_web_executions.py +++ b/tests/python/test_web_executions.py @@ -270,6 +270,102 @@ def test_status_badges( assert f">{status}<" in resp.text +@pytest.mark.parametrize( + ("status", "icon"), + [ + ("success", "i-check"), + ("failure", "i-x"), + ], +) +def test_status_badge_carries_a_check_or_x_icon( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, status: str, icon: str +) -> None: + """A pass/fail outcome should read without needing the badge colour.""" + _stub_view( + monkeypatch, + ExecutionsView( + runs=[_execution(status=status)], 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 f'href="#{icon}"' in resp.text + + +def test_status_badge_carries_a_play_icon_for_running( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """In progress is its own state, not a verdict -- but still not bare text.""" + _stub_view( + monkeypatch, + ExecutionsView( + runs=[_execution(status="running")], 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") + + # The card's own header icon is also #i-play -- count, don't just look + # for presence, or this assertion is trivially true either way. + assert resp.text.count('href="#i-play"') == 2 + + +@pytest.mark.parametrize("status", ["queued", "cancelled", "unknown"]) +def test_status_badge_has_no_icon_for_non_terminal_states( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, status: str +) -> None: + """queued/cancelled/unknown are neither a verdict nor in progress -- plain text.""" + _stub_view( + monkeypatch, + ExecutionsView( + runs=[_execution(status=status)], 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 ' None: + """Run/Repo, Branch/Commit, Trigger/Actor, Duration/Started and VM/IP each + get their own column now -- no cell relies on a
to show two facts.""" + vm = _vm("Clone_412-1_20260725_115000_linux") + _stub_view( + monkeypatch, + ExecutionsView( + runs=[_execution(vms=[vm])], 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 resp.status_code == 200 + assert "
" not in resp.text + for header in ("Repo", "Trigger", "Actor", "Started", "IP / Uptime"): + assert f"{header}" in resp.text + assert "Simone" in resp.text # actor, now its own column + assert vm.ip in resp.text + + def test_empty_state_when_no_runs_and_no_vms( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -305,6 +401,9 @@ def test_unmatched_vm_keeps_its_row_and_remove_button( assert vm.name in resp.text assert "370-1" in resp.text # job id parsed out of the clone name assert "Remove" in resp.text + # Same word, same column as a run's "running" status pill -- same icon. + # The card's own header icon is also #i-play, hence count == 2 not 1. + assert resp.text.count('href="#i-play"') == 2 def test_end_to_end_against_a_stubbed_gitea( diff --git a/tests/python/test_web_filters.py b/tests/python/test_web_filters.py index 91910be..0d42444 100644 --- a/tests/python/test_web_filters.py +++ b/tests/python/test_web_filters.py @@ -15,6 +15,7 @@ from pathlib import Path from ci_orchestrator.operations.vms import ParsedClone from ci_orchestrator.web.filters import ( clone_job, + cpu_short, duration, humanbytes, localtime, @@ -23,6 +24,25 @@ from ci_orchestrator.web.filters import ( thousands, ) +# ── cpu_short ──────────────────────────────────────────────────────────── + + +def test_cpu_short_strips_intel_trademark_noise_and_clock_tail() -> None: + assert cpu_short("Intel(R) Core(TM) i9-13900K CPU @ 3.00GHz") == "Intel Core i9-13900K" + + +def test_cpu_short_strips_amd_core_count_tail() -> None: + assert cpu_short("AMD Ryzen 9 5950X 16-Core Processor") == "AMD Ryzen 9 5950X" + + +def test_cpu_short_passes_through_an_already_short_name() -> None: + assert cpu_short("Apple M2 Pro") == "Apple M2 Pro" + + +def test_cpu_short_of_empty_stays_empty_so_the_template_can_hide_the_line() -> None: + assert cpu_short("") == "" + assert cpu_short(None) == "" + # ── humanbytes ───────────────────────────────────────────────────────────