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
+4 -3
View File
@@ -123,8 +123,9 @@ class WebConfig:
fallback: the dashboard normally reuses the access token from the
operator's own OIDC login (held server-side, never in the session
cookie). Left unset, the card degrades to listing live VMs whenever no
logged-in token is available. ``executions_limit`` caps how many runs the
card asks Gitea for.
logged-in token is available. ``executions_limit`` is the *default* number
of runs the card lists (newest first); the card's own selector overrides it
per request without touching the config.
"""
enabled: bool = False
@@ -136,7 +137,7 @@ class WebConfig:
oidc: WebOidcConfig | None = None
gitea_url: str | None = None
gitea_token_cred: str | None = None
executions_limit: int = 15
executions_limit: int = 10
managed_services: tuple[ManagedService, ...] = field(
default_factory=lambda: MANAGED_SERVICES
)
+17 -1
View File
@@ -350,6 +350,17 @@ def _cached_runs(
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(
runs: list[Execution], vms: list[LiveVm]
) -> tuple[list[Execution], list[LiveVm]]:
@@ -437,7 +448,12 @@ def list_executions(
)
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(
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.
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
#: "critical" percentage -> red; above "elevated" -> amber; else green.
_DISK_PCT_CRITICAL = 85.0
@@ -161,8 +167,26 @@ def _pat_fallback(config: Config) -> str | 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(
request: Request, config: Config, vms: list[LiveVm]
request: Request, config: Config, vms: list[LiveVm], limit: int | None = None
) -> ExecutionsView:
"""Recent Gitea runs with ``vms`` attached. Never raises.
@@ -181,27 +205,27 @@ async def _fetch_executions(
pat = _pat_fallback(config)
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:
refreshed = await refresh_gitea_token(request, str(username))
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:
return retried
view = retried
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
def _list_executions(
config: Config, vms: list[LiveVm], token: str | None
config: Config, vms: list[LiveVm], token: str | None, limit: int | None = None
) -> ExecutionsView:
"""``list_executions`` with a belt-and-braces guard. Never raises."""
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
return ExecutionsView(
runs=[], unmatched_vms=vms, source="none", error="Executions unavailable."
@@ -455,6 +479,8 @@ async def dashboard(
context = {
"vms": vms,
"executions": await _fetch_executions(request, config, vms),
"limit": config.web.executions_limit,
"limit_choices": EXECUTIONS_LIMIT_CHOICES,
"backend_available": backend_available,
"cache": cache,
"disk": disk,
@@ -479,21 +505,31 @@ async def dashboard(
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:
"""``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
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,
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
templates: Jinja2Templates = request.app.state.templates
vms = _fetch_live_vms(backend, config)
effective_limit = _limit_param(limit, config)
context = {
"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,
"csrf_token": issue_csrf(request),
}
@@ -693,6 +729,7 @@ def create_app(config: Config) -> FastAPI:
__all__ = [
"EXECUTIONS_LIMIT_CHOICES",
"RUNNER_SERVICE_NAMES",
"HealthSummary",
"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. */
.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). */
.backups-details > summary {
cursor: pointer;
@@ -10,9 +10,26 @@
``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.
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>
<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 %}
<p class="notice notice-warn" role="alert">Backend unavailable (vmrun not found on this host).</p>
{% endif %}