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:
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user