diff --git a/config.example.toml b/config.example.toml index 883f1fb..4fe5bea 100644 --- a/config.example.toml +++ b/config.example.toml @@ -103,7 +103,7 @@ retention_days = 30 # 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 # 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 OIDC client registration: Gitea -> Settings -> Applications -> OAuth2, diff --git a/docs/WEB-UI.md b/docs/WEB-UI.md index 3e38225..6856a8b 100644 --- a/docs/WEB-UI.md +++ b/docs/WEB-UI.md @@ -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 on the instance, admin token) with `/api/v1/user/actions/runs` as the -non-admin fallback. `[web].executions_limit` (default 15) caps how many. -Results are cached for 10s, so the card's 5s refresh costs Gitea at most one -call per 10s window; the VM columns are re-read on every refresh. +non-admin fallback. Results are cached for 10s, so the card's 5s refresh costs +Gitea at most one call per 10s window; the VM columns are re-read on every +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 reuses the access token from your own OIDC login, so what the card shows is diff --git a/src/ci_orchestrator/config.py b/src/ci_orchestrator/config.py index 3a68fb8..a6f2905 100644 --- a/src/ci_orchestrator/config.py +++ b/src/ci_orchestrator/config.py @@ -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 ) diff --git a/src/ci_orchestrator/operations/executions.py b/src/ci_orchestrator/operations/executions.py index 3250c6a..0b3b5b6 100644 --- a/src/ci_orchestrator/operations/executions.py +++ b/src/ci_orchestrator/operations/executions.py @@ -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 diff --git a/src/ci_orchestrator/web/app.py b/src/ci_orchestrator/web/app.py index e0a842d..ebc9c93 100644 --- a/src/ci_orchestrator/web/app.py +++ b/src/ci_orchestrator/web/app.py @@ -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", diff --git a/src/ci_orchestrator/web/static/app.css b/src/ci_orchestrator/web/static/app.css index 3487daa..93b29a0 100644 --- a/src/ci_orchestrator/web/static/app.css +++ b/src/ci_orchestrator/web/static/app.css @@ -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; diff --git a/src/ci_orchestrator/web/templates/partials/executions.html b/src/ci_orchestrator/web/templates/partials/executions.html index a97ff44..e6a9027 100644 --- a/src/ci_orchestrator/web/templates/partials/executions.html +++ b/src/ci_orchestrator/web/templates/partials/executions.html @@ -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. #} -
+

Executions

+
+ + + newest first +
{% if not backend_available %} {% endif %} diff --git a/tests/python/test_operations_executions.py b/tests/python/test_operations_executions.py index 971833e..0aec7c6 100644 --- a/tests/python/test_operations_executions.py +++ b/tests/python/test_operations_executions.py @@ -36,7 +36,7 @@ def _make_config( tmp_path: Path, *, gitea_url: str | None = "https://gitea.example", - limit: int = 15, + limit: int = 10, ) -> Config: return Config( paths=Paths( @@ -190,7 +190,7 @@ def test_admin_payload_parsed_into_executions( # Bearer auth against the admin endpoint, with the configured limit. assert calls == [ ( - "https://gitea.example/api/v1/admin/actions/runs?limit=15", + "https://gitea.example/api/v1/admin/actions/runs?limit=10", "Bearer tok", ) ] @@ -398,6 +398,57 @@ def test_non_list_runs_payload_is_an_error( 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 ─────────────────────────────────────────────────────── @@ -418,8 +469,8 @@ def test_admin_denied_falls_back_to_user_endpoint( assert view.error is None assert len(view.runs) == 1 assert [url for url, _auth in calls] == [ - "https://gitea.example/api/v1/admin/actions/runs?limit=15", - "https://gitea.example/api/v1/user/actions/runs?limit=15", + "https://gitea.example/api/v1/admin/actions/runs?limit=10", + "https://gitea.example/api/v1/user/actions/runs?limit=10", ] diff --git a/tests/python/test_web_executions.py b/tests/python/test_web_executions.py index 42a19ae..1d5db77 100644 --- a/tests/python/test_web_executions.py +++ b/tests/python/test_web_executions.py @@ -352,6 +352,122 @@ def test_end_to_end_against_a_stubbed_gitea( 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 '' 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 '' 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 '' 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 ──────────────────────────────────────────────────────