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:
+1
-1
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user