feat(web): Executions table gets real columns and status icons

No cell stacks two facts on separate lines with <br> 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.
This commit is contained in:
2026-07-25 19:33:27 +02:00
parent c598d0658e
commit 7f2b03029a
5 changed files with 214 additions and 26 deletions
+46
View File
@@ -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",
]
@@ -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
<br> (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 @@
<thead>
<tr>
<th>Run</th>
<th>Repo</th>
<th>Workflow</th>
<th>Branch / commit</th>
<th>Branch</th>
<th>Commit</th>
<th>Trigger</th>
<th>Actor</th>
<th>Status</th>
<th>Duration</th>
<th>Started</th>
<th>VM</th>
<th>IP / Uptime</th>
<th>Actions</th>
</tr>
</thead>
@@ -65,32 +77,35 @@
{% else %}
#{{ run.number }}{% if run.attempt > 1 %}.{{ run.attempt }}{% endif %}
{% endif %}
{% if run.repo %}<br><span class="text-dim">{{ run.repo }}</span>{% endif %}
</td>
<td>{{ run.repo or "—" }}</td>
<td>{{ run.workflow or "—" }}</td>
<td>{{ run.branch or "—" }}</td>
<td>{% if run.commit %}<code class="text-dim">{{ run.commit[:7] }}</code>{% else %}—{% endif %}</td>
<td>{{ run.event or "—" }}</td>
<td>{{ run.actor or "—" }}</td>
{% set run_icon = run.status | run_badge_icon %}
<td><span class="badge {{ run.status | run_badge }}">{% if run_icon %}<svg class="icon" aria-hidden="true"><use href="#{{ run_icon }}"></use></svg>{% endif %}{{ run.status }}</span></td>
<td>{{ run.duration_sec | duration }}</td>
<td>
{{ run.branch or "—" }}
{% if run.commit %}<br><code class="text-dim">{{ run.commit[:7] }}</code>{% endif %}
</td>
<td>
{{ run.event or "—" }}
{% if run.actor %}<br><span class="text-dim">{{ run.actor }}</span>{% endif %}
</td>
<td><span class="badge {{ run.status | run_badge }}">{{ run.status }}</span></td>
<td>
{{ run.duration_sec | duration }}
{% if run.started %}<br><span class="text-dim" title="{{ run.started | localtime }}">{{ run.started | relative_time }}</span>{% endif %}
{% if run.started %}
<span class="text-dim" title="{{ run.started | localtime }}">{{ run.started | relative_time }}</span>
{% else %}
<span class="text-dim">—</span>
{% endif %}
</td>
<td>
{% for vm in run.vms %}
<div>
{{ vm.name }}
<br><span class="text-dim">{{ vm.ip or "no ip" }} · up {{ vm.uptime_sec | duration }}</span>
</div>
<div>{{ vm.name }}</div>
{% else %}
<span class="text-dim">—</span>
{% endfor %}
</td>
<td>
{% for vm in run.vms %}
<div class="text-dim">{{ vm.ip or "no ip" }} · up {{ vm.uptime_sec | duration }}</div>
{% endfor %}
</td>
<td>
{% for vm in run.vms %}
<form method="post" action="/vms/{{ vm.vmx | vmx_id }}/remove"
@@ -105,9 +120,10 @@
{% for vm in executions.unmatched_vms %}
<tr class="{{ 'row-orphan' if vm.is_orphan else '' }}">
{% set parsed = vm.name | clone_job %}
<td>
<span class="text-dim">{{ parsed.job_id or "no run" }}</span>
</td>
<td><span class="text-dim">{{ parsed.job_id or "no run" }}</span></td>
<td><span class="text-dim">—</span></td>
<td><span class="text-dim">—</span></td>
<td><span class="text-dim">—</span></td>
<td><span class="text-dim">—</span></td>
<td><span class="text-dim">—</span></td>
<td><span class="text-dim">—</span></td>
@@ -115,9 +131,10 @@
{% if vm.is_orphan %}
<span class="badge badge-warn">orphan</span>
{% else %}
<span class="badge badge-info">running</span>
<span class="badge badge-info"><svg class="icon" aria-hidden="true"><use href="#i-play"></use></svg>running</span>
{% endif %}
</td>
<td><span class="text-dim">—</span></td>
<td>
{% if parsed.started %}
<span class="text-dim" title="{{ parsed.started | localtime }}">{{ parsed.started | relative_time }}</span>
@@ -125,10 +142,8 @@
<span class="text-dim">—</span>
{% endif %}
</td>
<td>
{{ vm.name }}
<br><span class="text-dim">{{ vm.ip or "no ip" }} · up {{ vm.uptime_sec | duration }}</span>
</td>
<td>{{ vm.name }}</td>
<td class="text-dim">{{ vm.ip or "no ip" }} · up {{ vm.uptime_sec | duration }}</td>
<td>
<form method="post" action="/vms/{{ vm.vmx | vmx_id }}/remove"
hx-post="/vms/{{ vm.vmx | vmx_id }}/remove" hx-target="#action-slot" hx-swap="innerHTML show:top">
@@ -77,6 +77,14 @@
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</symbol>
<!-- Executions card: pass/fail run-status pills -- i-check pairs with
i-alert everywhere else in this sprite, but a failed *run* is a
boolean outcome, not a resource warning, so it gets its own cross. -->
<symbol id="i-x" viewBox="0 0 24 24">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</symbol>
<!-- Executions card: a run/play glyph, stroked like every other icon here. -->
<symbol id="i-play" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9"></circle>
+99
View File
@@ -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 '<td><span class="badge badge-' in resp.text
assert "#i-check" not in resp.text
assert "#i-x" not in resp.text
assert "#i-x" not in resp.text
def test_no_cell_stacks_two_facts_on_separate_lines(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Run/Repo, Branch/Commit, Trigger/Actor, Duration/Started and VM/IP each
get their own column now -- no cell relies on a <br> 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 "<br>" not in resp.text
for header in ("Repo", "Trigger", "Actor", "Started", "IP / Uptime"):
assert f"<th>{header}</th>" 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(
+20
View File
@@ -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 ───────────────────────────────────────────────────────────