feat(web): row-count selector on the Executions card

Adds a "Show" selector (10 / 25 / 50 / 100) to the card header and drops the
default row count from 15 to 10. The chosen value rides on the card's own
polling URL (`/executions?limit=N`), so it survives the 5s htmx refresh with no
client-side state; a config default outside those four is rendered as an extra
option instead of disappearing. The query is parsed permissively — a stale or
hand-edited polling URL clamps or falls back to the config default rather than
422-ing a card that would otherwise render.

Ordering is now explicit rather than inherited from whatever Gitea returns:
newest first, a not-yet-started (queued) run above everything, ties broken on
Gitea's run id so matrix runs sharing a timestamp keep a stable order.
This commit is contained in:
2026-07-25 15:36:21 +02:00
parent 30fd3120a6
commit 7d1f4e2784
9 changed files with 280 additions and 22 deletions
+1 -1
View File
@@ -103,7 +103,7 @@ retention_days = 30
# only -- so neither key below is required. Set them to cover the cases that # only -- so neither key below is required. Set them to cover the cases that
# token cannot: a service restart (which forgets the logged-in tokens) or a # token cannot: a service restart (which forgets the logged-in tokens) or a
# non-admin operator. # non-admin operator.
# executions_limit = 15 # how many runs to list (1..100) # executions_limit = 10 # default rows in the card's selector (1..100)
# gitea_token_cred = "GiteaApiToken" # keyring KEY NAME of a fallback PAT # gitea_token_cred = "GiteaApiToken" # keyring KEY NAME of a fallback PAT
# #
# Gitea OIDC client registration: Gitea -> Settings -> Applications -> OAuth2, # Gitea OIDC client registration: Gitea -> Settings -> Applications -> OAuth2,
+11 -3
View File
@@ -290,9 +290,17 @@ never answer "did the last build pass?".
Runs come from Gitea's Actions API: `/api/v1/admin/actions/runs` (every repo 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 on the instance, admin token) with `/api/v1/user/actions/runs` as the
non-admin fallback. `[web].executions_limit` (default 15) caps how many. non-admin fallback. Results are cached for 10s, so the card's 5s refresh costs
Results are cached for 10s, so the card's 5s refresh costs Gitea at most one Gitea at most one call per 10s window; the VM columns are re-read on every
call per 10s window; the VM columns are re-read on every refresh. refresh.
Rows are ordered **newest first** (a queued run, which has no start time yet,
sorts above everything; ties break on Gitea's run id). The **Show** selector in
the card's header picks how many to list — 10 / 25 / 50 / 100, default from
`[web].executions_limit` (10). The choice rides on the card's own polling URL
(`/executions?limit=N`), so it survives every 5s refresh with no client-side
state; a value set in the config outside those four is offered as an extra
option rather than dropped.
**Where the API token comes from.** Normally nowhere extra: the dashboard **Where the API token comes from.** Normally nowhere extra: the dashboard
reuses the access token from your own OIDC login, so what the card shows is reuses the access token from your own OIDC login, so what the card shows is
+4 -3
View File
@@ -123,8 +123,9 @@ class WebConfig:
fallback: the dashboard normally reuses the access token from the fallback: the dashboard normally reuses the access token from the
operator's own OIDC login (held server-side, never in the session operator's own OIDC login (held server-side, never in the session
cookie). Left unset, the card degrades to listing live VMs whenever no cookie). Left unset, the card degrades to listing live VMs whenever no
logged-in token is available. ``executions_limit`` caps how many runs the logged-in token is available. ``executions_limit`` is the *default* number
card asks Gitea for. of runs the card lists (newest first); the card's own selector overrides it
per request without touching the config.
""" """
enabled: bool = False enabled: bool = False
@@ -136,7 +137,7 @@ class WebConfig:
oidc: WebOidcConfig | None = None oidc: WebOidcConfig | None = None
gitea_url: str | None = None gitea_url: str | None = None
gitea_token_cred: str | None = None gitea_token_cred: str | None = None
executions_limit: int = 15 executions_limit: int = 10
managed_services: tuple[ManagedService, ...] = field( managed_services: tuple[ManagedService, ...] = field(
default_factory=lambda: MANAGED_SERVICES default_factory=lambda: MANAGED_SERVICES
) )
+17 -1
View File
@@ -350,6 +350,17 @@ def _cached_runs(
return runs, source, error, auth_failed, refused return runs, source, error, auth_failed, refused
def _recency_key(run: Execution) -> tuple[datetime, int]:
"""Sort key for "newest first" (used with ``sorted(..., reverse=True)``).
A run that has not started yet (queued) is the newest thing there is, so it
sorts above every started run. ``run_id`` breaks ties — Gitea hands ids out
in creation order — which keeps the order stable when several runs share a
timestamp, as matrix jobs routinely do.
"""
return (run.started or datetime.max.replace(tzinfo=UTC), run.run_id)
def _attach_vms( def _attach_vms(
runs: list[Execution], vms: list[LiveVm] runs: list[Execution], vms: list[LiveVm]
) -> tuple[list[Execution], list[LiveVm]]: ) -> tuple[list[Execution], list[LiveVm]]:
@@ -437,7 +448,12 @@ def list_executions(
) )
runs, unmatched = _attach_vms( runs, unmatched = _attach_vms(
[_to_execution(item, moment) for item in raw_runs], vms sorted(
(_to_execution(item, moment) for item in raw_runs),
key=_recency_key,
reverse=True,
),
vms,
) )
return ExecutionsView( return ExecutionsView(
runs=runs, unmatched_vms=unmatched, source=source, error=None runs=runs, unmatched_vms=unmatched, source=source, error=None
+46 -9
View File
@@ -84,6 +84,12 @@ _WEB_DIR = Path(__file__).resolve().parent
#: CLAUDE.md's "Phase C"). A module constant per the W4 plan section. #: CLAUDE.md's "Phase C"). A module constant per the W4 plan section.
RUNNER_SERVICE_NAMES: list[str] = ["act-runner.service", "act-runner-rust.service"] RUNNER_SERVICE_NAMES: list[str] = ["act-runner.service", "act-runner-rust.service"]
#: Row counts offered by the Executions card's selector. The *default* is not
#: here but in ``[web] executions_limit`` (10), so an operator can set a default
#: outside this list without it disappearing from the dropdown -- the template
#: renders the effective value as an extra option when it is not one of these.
EXECUTIONS_LIMIT_CHOICES: tuple[int, ...] = (10, 25, 50, 100)
#: Disk-usage health thresholds for the status header (A3): above the #: Disk-usage health thresholds for the status header (A3): above the
#: "critical" percentage -> red; above "elevated" -> amber; else green. #: "critical" percentage -> red; above "elevated" -> amber; else green.
_DISK_PCT_CRITICAL = 85.0 _DISK_PCT_CRITICAL = 85.0
@@ -161,8 +167,26 @@ def _pat_fallback(config: Config) -> str | None:
return None return None
def _limit_param(raw: str | None, config: Config) -> int:
"""Row count for the Executions card: query param, else the config default.
Permissive by design -- the value arrives on a polling URL that outlives
deploys and can be hand-edited, so anything unparseable or out of range
falls back to ``[web] executions_limit`` instead of failing the request.
"""
if raw is None:
return config.web.executions_limit
try:
parsed = int(raw)
except ValueError:
return config.web.executions_limit
if parsed in EXECUTIONS_LIMIT_CHOICES:
return parsed
return max(1, min(parsed, 100))
async def _fetch_executions( async def _fetch_executions(
request: Request, config: Config, vms: list[LiveVm] request: Request, config: Config, vms: list[LiveVm], limit: int | None = None
) -> ExecutionsView: ) -> ExecutionsView:
"""Recent Gitea runs with ``vms`` attached. Never raises. """Recent Gitea runs with ``vms`` attached. Never raises.
@@ -181,27 +205,27 @@ async def _fetch_executions(
pat = _pat_fallback(config) pat = _pat_fallback(config)
token = stored.access_token if stored is not None else pat token = stored.access_token if stored is not None else pat
view = _list_executions(config, vms, token) view = _list_executions(config, vms, token, limit)
if view.auth_failed and stored is not None and username: if view.auth_failed and stored is not None and username:
refreshed = await refresh_gitea_token(request, str(username)) refreshed = await refresh_gitea_token(request, str(username))
if refreshed is not None: if refreshed is not None:
retried = _list_executions(config, vms, refreshed.access_token) retried = _list_executions(config, vms, refreshed.access_token, limit)
if not retried.refused: if not retried.refused:
return retried return retried
view = retried view = retried
if view.refused and pat is not None and token != pat: if view.refused and pat is not None and token != pat:
return _list_executions(config, vms, pat) return _list_executions(config, vms, pat, limit)
return view return view
def _list_executions( def _list_executions(
config: Config, vms: list[LiveVm], token: str | None config: Config, vms: list[LiveVm], token: str | None, limit: int | None = None
) -> ExecutionsView: ) -> ExecutionsView:
"""``list_executions`` with a belt-and-braces guard. Never raises.""" """``list_executions`` with a belt-and-braces guard. Never raises."""
try: try:
return list_executions(config, vms, token=token) return list_executions(config, vms, token=token, limit=limit)
except Exception: # the operations layer already degrades; belt and braces except Exception: # the operations layer already degrades; belt and braces
return ExecutionsView( return ExecutionsView(
runs=[], unmatched_vms=vms, source="none", error="Executions unavailable." runs=[], unmatched_vms=vms, source="none", error="Executions unavailable."
@@ -455,6 +479,8 @@ async def dashboard(
context = { context = {
"vms": vms, "vms": vms,
"executions": await _fetch_executions(request, config, vms), "executions": await _fetch_executions(request, config, vms),
"limit": config.web.executions_limit,
"limit_choices": EXECUTIONS_LIMIT_CHOICES,
"backend_available": backend_available, "backend_available": backend_available,
"cache": cache, "cache": cache,
"disk": disk, "disk": disk,
@@ -479,21 +505,31 @@ async def dashboard(
async def executions_partial( async def executions_partial(
request: Request, backend: VmBackend | None = Depends(get_backend) request: Request,
limit: str | None = None,
backend: VmBackend | None = Depends(get_backend),
) -> HTMLResponse: ) -> HTMLResponse:
"""``GET /executions`` -- polled every 5s by the dashboard via htmx. """``GET /executions[?limit=N]`` -- polled every 5s by the dashboard via htmx.
Replaces the old ``GET /vms`` card: run-centric, with the live VMs grouped Replaces the old ``GET /vms`` card: run-centric, with the live VMs grouped
under the run that spawned them. The runs come from a 10s-TTL cache under the run that spawned them. The runs come from a 10s-TTL cache
(``operations.executions``) while the VM data is read fresh on every poll, (``operations.executions``) while the VM data is read fresh on every poll,
so this cadence costs Gitea at most one API call per TTL window. so this cadence costs Gitea at most one API call per TTL window.
``limit`` comes from the card's own row-count selector, and the re-rendered
card carries it on its polling URL so the choice survives every refresh.
It is parsed permissively (see :func:`_limit_param`): a stale or hand-edited
URL must never 422 a card that otherwise renders fine.
""" """
config: Config = request.app.state.config config: Config = request.app.state.config
templates: Jinja2Templates = request.app.state.templates templates: Jinja2Templates = request.app.state.templates
vms = _fetch_live_vms(backend, config) vms = _fetch_live_vms(backend, config)
effective_limit = _limit_param(limit, config)
context = { context = {
"vms": vms, "vms": vms,
"executions": await _fetch_executions(request, config, vms), "executions": await _fetch_executions(request, config, vms, effective_limit),
"limit": effective_limit,
"limit_choices": EXECUTIONS_LIMIT_CHOICES,
"backend_available": backend is not None, "backend_available": backend is not None,
"csrf_token": issue_csrf(request), "csrf_token": issue_csrf(request),
} }
@@ -693,6 +729,7 @@ def create_app(config: Config) -> FastAPI:
__all__ = [ __all__ = [
"EXECUTIONS_LIMIT_CHOICES",
"RUNNER_SERVICE_NAMES", "RUNNER_SERVICE_NAMES",
"HealthSummary", "HealthSummary",
"create_app", "create_app",
+12
View File
@@ -281,6 +281,18 @@ dd code, dd .mono { overflow-wrap: anywhere; }
/* A row of buttons (e.g. Purge / Empty all) that wraps on narrow cards. */ /* A row of buttons (e.g. Purge / Empty all) that wraps on narrow cards. */
.btn-row { display: flex; flex-wrap: wrap; gap: 0.5rem; } .btn-row { display: flex; flex-wrap: wrap; gap: 0.5rem; }
/* Small control strip under a card's heading (Executions row count): right
* aligned so it reads as a view option, not as part of the data below. */
.card-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
margin: -0.35rem 0 0.7rem;
}
.card-toolbar select { padding: 0.2rem 0.4rem; }
/* Per-template expandable backup list (Templates panel). */ /* Per-template expandable backup list (Templates panel). */
.backups-details > summary { .backups-details > summary {
cursor: pointer; cursor: pointer;
@@ -10,9 +10,26 @@
``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.
The row-count selector re-fetches the card with ``?limit=N``, and the card it
renders carries that same limit on its own polling URL -- that is what makes
the choice survive the 5s refresh without any client-side state.
#} #}
<section class="card card--wide" id="executions-card" hx-get="/executions" hx-trigger="every 5s, ci:refresh from:body" hx-swap="outerHTML"> <section class="card card--wide" id="executions-card" hx-get="/executions?limit={{ limit }}" hx-trigger="every 5s, ci:refresh from:body" hx-swap="outerHTML">
<h2><svg class="icon" aria-hidden="true"><use href="#i-play"></use></svg>Executions</h2> <h2><svg class="icon" aria-hidden="true"><use href="#i-play"></use></svg>Executions</h2>
<div class="card-toolbar">
<label for="executions-limit">Show</label>
<select id="executions-limit" name="limit" aria-label="Number of executions to show"
hx-get="/executions" hx-target="#executions-card" hx-swap="outerHTML" hx-trigger="change">
{% for choice in limit_choices %}
<option value="{{ choice }}" {{ 'selected' if choice == limit else '' }}>{{ choice }}</option>
{% endfor %}
{% if limit not in limit_choices %}
<option value="{{ limit }}" selected>{{ limit }}</option>
{% endif %}
</select>
<span class="field-hint">newest first</span>
</div>
{% if not backend_available %} {% if not backend_available %}
<p class="notice notice-warn" role="alert">Backend unavailable (vmrun not found on this host).</p> <p class="notice notice-warn" role="alert">Backend unavailable (vmrun not found on this host).</p>
{% endif %} {% endif %}
+55 -4
View File
@@ -36,7 +36,7 @@ def _make_config(
tmp_path: Path, tmp_path: Path,
*, *,
gitea_url: str | None = "https://gitea.example", gitea_url: str | None = "https://gitea.example",
limit: int = 15, limit: int = 10,
) -> Config: ) -> Config:
return Config( return Config(
paths=Paths( paths=Paths(
@@ -190,7 +190,7 @@ def test_admin_payload_parsed_into_executions(
# Bearer auth against the admin endpoint, with the configured limit. # Bearer auth against the admin endpoint, with the configured limit.
assert calls == [ assert calls == [
( (
"https://gitea.example/api/v1/admin/actions/runs?limit=15", "https://gitea.example/api/v1/admin/actions/runs?limit=10",
"Bearer tok", "Bearer tok",
) )
] ]
@@ -398,6 +398,57 @@ def test_non_list_runs_payload_is_an_error(
assert view.source == "none" assert view.source == "none"
# ── ordering ────────────────────────────────────────────────────────────────
def test_runs_are_sorted_newest_first(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Gitea's order is not contracted -- the card sorts by recency itself."""
payload = [
_run(run_id=1, number=1, started="2026-07-25T09:00:00Z"),
_run(run_id=3, number=3, started="2026-07-25T11:00:00Z"),
_run(run_id=2, number=2, started="2026-07-25T10:00:00Z"),
]
_install_urlopen(monkeypatch, _admin_ok(payload))
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
assert [r.number for r in view.runs] == [3, 2, 1]
def test_queued_run_sorts_above_started_runs(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A run with no start time was just created: it is the newest of all."""
payload = [
_run(run_id=1, number=1, started="2026-07-25T11:00:00Z"),
_run(run_id=2, number=2, status="waiting", conclusion="", started=None, completed=None),
]
_install_urlopen(monkeypatch, _admin_ok(payload))
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
assert [r.status for r in view.runs] == ["queued", "success"]
def test_same_timestamp_falls_back_to_run_id(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Matrix runs share a timestamp; ids keep the order stable, not arbitrary."""
stamp = "2026-07-25T11:00:00Z"
payload = [
_run(run_id=10, number=10, started=stamp),
_run(run_id=12, number=12, started=stamp),
_run(run_id=11, number=11, started=stamp),
]
_install_urlopen(monkeypatch, _admin_ok(payload))
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
assert [r.number for r in view.runs] == [12, 11, 10]
# ── endpoint fallback ─────────────────────────────────────────────────────── # ── endpoint fallback ───────────────────────────────────────────────────────
@@ -418,8 +469,8 @@ def test_admin_denied_falls_back_to_user_endpoint(
assert view.error is None assert view.error is None
assert len(view.runs) == 1 assert len(view.runs) == 1
assert [url for url, _auth in calls] == [ assert [url for url, _auth in calls] == [
"https://gitea.example/api/v1/admin/actions/runs?limit=15", "https://gitea.example/api/v1/admin/actions/runs?limit=10",
"https://gitea.example/api/v1/user/actions/runs?limit=15", "https://gitea.example/api/v1/user/actions/runs?limit=10",
] ]
+116
View File
@@ -352,6 +352,122 @@ def test_end_to_end_against_a_stubbed_gitea(
assert "no run" not in resp.text assert "no run" not in resp.text
# ── row-count selector ──────────────────────────────────────────────────────
def _stub_limits(monkeypatch: pytest.MonkeyPatch) -> list[int | None]:
"""Replace the operations call; return the log of limits it was given."""
limits: list[int | None] = []
def fake_list_executions(
_config: Config,
_vms: list[LiveVm],
*,
token: str | None,
limit: int | None = None,
**_kw: Any,
) -> ExecutionsView:
limits.append(limit)
return ExecutionsView(runs=[], unmatched_vms=[], source="admin", error=None)
monkeypatch.setattr(app_module, "list_executions", fake_list_executions)
return limits
def test_default_limit_is_ten(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
limits = _stub_limits(monkeypatch)
_install_fake_keyring(monkeypatch)
app = create_app(_make_config(tmp_path, token_cred=_PAT_CRED_NAME))
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
resp = client.get("/executions")
assert limits == [10]
assert '<option value="10" selected>10</option>' in resp.text
assert "newest first" in resp.text
def test_selector_offers_the_choices_and_marks_the_current_one(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
limits = _stub_limits(monkeypatch)
_install_fake_keyring(monkeypatch)
app = create_app(_make_config(tmp_path, token_cred=_PAT_CRED_NAME))
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
resp = client.get("/executions?limit=50")
assert limits == [50]
for choice in (10, 25, 50, 100):
assert f'value="{choice}"' in resp.text
assert '<option value="50" selected>50</option>' in resp.text
# The re-rendered card polls with the chosen limit, so it survives refreshes.
assert 'hx-get="/executions?limit=50"' in resp.text
def test_config_default_outside_the_choices_still_appears(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_stub_limits(monkeypatch)
_install_fake_keyring(monkeypatch)
config = _make_config(tmp_path, token_cred=_PAT_CRED_NAME)
object.__setattr__(config.web, "executions_limit", 7)
app = create_app(config)
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
resp = client.get("/executions")
assert '<option value="7" selected>7</option>' in resp.text
@pytest.mark.parametrize(
("query", "expected"),
[
("?limit=25", 25),
("?limit=7", 7), # not offered, but honoured
("?limit=0", 1), # clamped up
("?limit=9999", 100), # clamped down
("?limit=-5", 1),
("?limit=abc", 10), # unparseable -> config default
("?limit=", 10),
("", 10),
],
)
def test_limit_query_is_parsed_permissively(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, query: str, expected: int
) -> None:
"""A stale or hand-edited polling URL must never fail the card."""
limits = _stub_limits(monkeypatch)
_install_fake_keyring(monkeypatch)
app = create_app(_make_config(tmp_path, token_cred=_PAT_CRED_NAME))
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
resp = client.get(f"/executions{query}")
assert resp.status_code == 200
assert limits == [expected]
def test_dashboard_renders_the_selector_at_the_config_default(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_stub_limits(monkeypatch)
_install_fake_keyring(monkeypatch)
_quiet_host(monkeypatch)
app = create_app(_make_config(tmp_path, token_cred=_PAT_CRED_NAME))
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
resp = client.get("/")
assert resp.status_code == 200
assert 'hx-get="/executions?limit=10"' in resp.text
# ── 2. degraded states ────────────────────────────────────────────────────── # ── 2. degraded states ──────────────────────────────────────────────────────