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 from __future__ import annotations
import re
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -179,6 +180,47 @@ def run_badge(status: str | None) -> str:
return _RUN_BADGES.get(status or "", "badge-muted") 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: def clone_job(name: str | None) -> ParsedClone:
"""Parse a VM/clone name into its ``job_id`` + ``started`` (Stage C1). """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["thousands"] = thousands
templates.env.filters["clone_job"] = clone_job templates.env.filters["clone_job"] = clone_job
templates.env.filters["run_badge"] = run_badge templates.env.filters["run_badge"] = run_badge
templates.env.filters["run_badge_icon"] = run_badge_icon
templates.env.filters["cpu_short"] = cpu_short
__all__ = [ __all__ = [
"clone_job", "clone_job",
"cpu_short",
"duration", "duration",
"humanbytes", "humanbytes",
"localtime", "localtime",
"register", "register",
"relative_time", "relative_time",
"run_badge", "run_badge",
"run_badge_icon",
"shorten_path", "shorten_path",
"thousands", "thousands",
] ]
@@ -3,10 +3,17 @@
Actions run (the live ones included), with the build VM(s) that run spawned Actions run (the live ones included), with the build VM(s) that run spawned
grouped into its VM cell, Remove action and all. 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 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 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 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 ``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 scrolled out of the window (or that no run ever claimed) keeps its row and
its Remove button. its Remove button.
@@ -47,12 +54,17 @@
<thead> <thead>
<tr> <tr>
<th>Run</th> <th>Run</th>
<th>Repo</th>
<th>Workflow</th> <th>Workflow</th>
<th>Branch / commit</th> <th>Branch</th>
<th>Commit</th>
<th>Trigger</th> <th>Trigger</th>
<th>Actor</th>
<th>Status</th> <th>Status</th>
<th>Duration</th> <th>Duration</th>
<th>Started</th>
<th>VM</th> <th>VM</th>
<th>IP / Uptime</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@@ -65,32 +77,35 @@
{% else %} {% else %}
#{{ run.number }}{% if run.attempt > 1 %}.{{ run.attempt }}{% endif %} #{{ run.number }}{% if run.attempt > 1 %}.{{ run.attempt }}{% endif %}
{% endif %} {% endif %}
{% if run.repo %}<br><span class="text-dim">{{ run.repo }}</span>{% endif %}
</td> </td>
<td>{{ run.repo or "—" }}</td>
<td>{{ run.workflow 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> <td>
{{ run.branch or "—" }} {% if run.started %}
{% if run.commit %}<br><code class="text-dim">{{ run.commit[:7] }}</code>{% endif %} <span class="text-dim" title="{{ run.started | localtime }}">{{ run.started | relative_time }}</span>
</td> {% else %}
<td> <span class="text-dim">—</span>
{{ run.event or "—" }} {% endif %}
{% 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 %}
</td> </td>
<td> <td>
{% for vm in run.vms %} {% for vm in run.vms %}
<div> <div>{{ vm.name }}</div>
{{ vm.name }}
<br><span class="text-dim">{{ vm.ip or "no ip" }} · up {{ vm.uptime_sec | duration }}</span>
</div>
{% else %} {% else %}
<span class="text-dim">—</span> <span class="text-dim">—</span>
{% endfor %} {% endfor %}
</td> </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> <td>
{% for vm in run.vms %} {% for vm in run.vms %}
<form method="post" action="/vms/{{ vm.vmx | vmx_id }}/remove" <form method="post" action="/vms/{{ vm.vmx | vmx_id }}/remove"
@@ -105,9 +120,10 @@
{% for vm in executions.unmatched_vms %} {% for vm in executions.unmatched_vms %}
<tr class="{{ 'row-orphan' if vm.is_orphan else '' }}"> <tr class="{{ 'row-orphan' if vm.is_orphan else '' }}">
{% set parsed = vm.name | clone_job %} {% set parsed = vm.name | clone_job %}
<td> <td><span class="text-dim">{{ parsed.job_id or "no run" }}</span></td>
<span class="text-dim">{{ parsed.job_id or "no run" }}</span> <td><span class="text-dim">—</span></td>
</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>
<td><span class="text-dim">—</span></td> <td><span class="text-dim">—</span></td>
@@ -115,9 +131,10 @@
{% if vm.is_orphan %} {% if vm.is_orphan %}
<span class="badge badge-warn">orphan</span> <span class="badge badge-warn">orphan</span>
{% else %} {% 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 %} {% endif %}
</td> </td>
<td><span class="text-dim">—</span></td>
<td> <td>
{% if parsed.started %} {% if parsed.started %}
<span class="text-dim" title="{{ parsed.started | localtime }}">{{ parsed.started | relative_time }}</span> <span class="text-dim" title="{{ parsed.started | localtime }}">{{ parsed.started | relative_time }}</span>
@@ -125,10 +142,8 @@
<span class="text-dim">—</span> <span class="text-dim">—</span>
{% endif %} {% endif %}
</td> </td>
<td> <td>{{ vm.name }}</td>
{{ vm.name }} <td class="text-dim">{{ vm.ip or "no ip" }} · up {{ vm.uptime_sec | duration }}</td>
<br><span class="text-dim">{{ vm.ip or "no ip" }} · up {{ vm.uptime_sec | duration }}</span>
</td>
<td> <td>
<form method="post" action="/vms/{{ vm.vmx | vmx_id }}/remove" <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"> 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> <line x1="12" y1="17" x2="12.01" y2="17"></line>
</symbol> </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. --> <!-- Executions card: a run/play glyph, stroked like every other icon here. -->
<symbol id="i-play" viewBox="0 0 24 24"> <symbol id="i-play" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9"></circle> <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 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( def test_empty_state_when_no_runs_and_no_vms(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
@@ -305,6 +401,9 @@ def test_unmatched_vm_keeps_its_row_and_remove_button(
assert vm.name in resp.text assert vm.name in resp.text
assert "370-1" in resp.text # job id parsed out of the clone name assert "370-1" in resp.text # job id parsed out of the clone name
assert "Remove" in resp.text 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( 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.operations.vms import ParsedClone
from ci_orchestrator.web.filters import ( from ci_orchestrator.web.filters import (
clone_job, clone_job,
cpu_short,
duration, duration,
humanbytes, humanbytes,
localtime, localtime,
@@ -23,6 +24,25 @@ from ci_orchestrator.web.filters import (
thousands, 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 ─────────────────────────────────────────────────────────── # ── humanbytes ───────────────────────────────────────────────────────────