#5211 "Lists load as you scroll: audit, alert history, Ansible runs" (milestone 477), per preference 172. - core/paging.py: keyset paging over (time, id), newest first, with an opaque "<iso>|<id>" cursor; a page reads one row past itself to know whether another follows. No OFFSET, so rows added while someone scrolls never shift the pages after them. - _macros.html scroll_more / scroll_end and static/scroll-more.js, written once: an IntersectionObserver with a 600px margin fetches the next page before the reader reaches the end; "Loading more…" while in flight, "End of the list." at the end, and a failed page says so with a Retry button instead of re-firing on every scroll. - Audit log: no "Load more" link and no 500-row cap. - Alert history: no 200-row stop with "pick a shorter range"; alert_svc.history takes the cursor, so the MCP tool and the page share one query. - Ansible runs: no silent cap of 50. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
93 lines
4.5 KiB
Python
93 lines
4.5 KiB
Python
"""Ansible runs are named by what ran where, say how long ago and for how
|
|
long, and wear the one status badge every page uses (#5125)."""
|
|
from datetime import datetime, timedelta, timezone
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from quart import render_template, session
|
|
|
|
from steward.ansible.inventory_gen import run_title
|
|
from steward.ansible.routes import run_duration, run_view
|
|
from steward.models.ansible import AnsibleRunStatus
|
|
from tests.test_style_tokens import hex_colours
|
|
|
|
|
|
@pytest.mark.parametrize("label, title", [
|
|
("target web-1", "update.yml on web-1"),
|
|
("group Docker Swarm", "update.yml on group Docker Swarm"),
|
|
("all inventory targets", "update.yml on all inventory targets"),
|
|
("homelab/hosts.ini", "update.yml on homelab/hosts.ini"),
|
|
("", "update.yml"),
|
|
])
|
|
def test_a_run_is_named_by_what_ran_where(label, title):
|
|
assert run_title("update.yml", label) == title
|
|
|
|
|
|
def _run(rid, status, started_min_ago, took_s=None, scope="steward:target:t1", check=False):
|
|
now = datetime.now(timezone.utc)
|
|
started = now - timedelta(minutes=started_min_ago)
|
|
return SimpleNamespace(
|
|
id=rid, playbook_path="update.yml", source_name="builtin", inventory_scope=scope,
|
|
status=status, started_at=started,
|
|
finished_at=started + timedelta(seconds=took_s) if took_s is not None else None,
|
|
params={"check": True} if check else None, results=None, output="")
|
|
|
|
|
|
def test_a_running_run_counts_its_duration_so_far():
|
|
now = datetime.now(timezone.utc)
|
|
run = _run("r", AnsibleRunStatus.running, 3)
|
|
assert run_duration(run, now) == pytest.approx(180, abs=2)
|
|
assert run_duration(_run("f", AnsibleRunStatus.success, 10, took_s=95), now) == 95
|
|
|
|
|
|
async def test_the_runs_list_names_each_run_with_one_badge(app):
|
|
now = datetime.now(timezone.utc)
|
|
labels = {"steward:target:t1": "target web-1", "steward:all": "all inventory targets"}
|
|
runs = [run_view(r, labels, now) for r in (
|
|
_run("aaaa1111", AnsibleRunStatus.running, 2),
|
|
_run("bbbb2222", AnsibleRunStatus.failed, 60, took_s=125, scope="steward:all", check=True),
|
|
_run("cccc3333", AnsibleRunStatus.success, 120, took_s=40),
|
|
)]
|
|
async with app.test_request_context("/ansible/"):
|
|
session["user_role"] = "viewer"
|
|
page = await render_template("ansible/index.html", runs=runs, after="2026-10-07T01:00:00+00:00|x")
|
|
|
|
main = page[page.index("<main"):]
|
|
assert '<a href="/ansible/runs/aaaa1111" title="steward:target:t1">update.yml on web-1</a>' in main
|
|
assert ">update.yml on all inventory targets</a>" in main
|
|
assert '<span class="badge badge-info">Running</span>' in main
|
|
assert '<span class="badge badge-crit">Failed</span>' in main
|
|
assert '<span class="badge badge-ok">Succeeded</span>' in main
|
|
# The list continues itself as the reader scrolls (#5211): no cap of 50.
|
|
assert 'data-scroll-more hx-get="/ansible/?before=2026-10-07T01%3A00%3A00%2B00%3A00%7Cx"' in main
|
|
assert "2m 5s" in main and "40s" in main and " so far" in main
|
|
assert "dry run" in main and "ago" in main
|
|
assert "RUNNING" not in main and "FAILED" not in main # the old shouted status text is gone
|
|
assert not hex_colours(main), hex_colours(main)
|
|
|
|
|
|
async def test_run_detail_titles_the_run_and_keeps_the_trail(app):
|
|
now = datetime.now(timezone.utc)
|
|
run = _run("dddd4444", AnsibleRunStatus.interrupted, 30, took_s=600)
|
|
async with app.test_request_context("/ansible/runs/dddd4444"):
|
|
session["user_role"] = "operator"
|
|
page = await render_template("ansible/run_detail.html", run=run, triggered_label="system",
|
|
row=run_view(run, {"steward:target:t1": "target web-1"}, now))
|
|
|
|
assert "<title>update.yml on web-1 — Steward</title>" in page
|
|
assert 'href="/ansible/"' in page # breadcrumb back to the runs
|
|
assert '<span class="badge badge-warn">Interrupted</span>' in page
|
|
assert "<dd>10m</dd>" in page
|
|
assert "/cancel" not in page # an interrupted run has nothing to cancel
|
|
main = page[page.index("<main"):]
|
|
assert not hex_colours(main), hex_colours(main)
|
|
|
|
|
|
async def test_run_started_is_on_the_palette(app):
|
|
run = _run("eeee5555", AnsibleRunStatus.running, 0)
|
|
async with app.test_request_context("/ansible/runs"):
|
|
page = await render_template("ansible/run_started.html", run=run, title="update.yml on web-1")
|
|
assert '<span class="badge badge-info">Running</span>' in page
|
|
assert "update.yml on web-1" in page and 'href="/ansible/runs/eeee5555"' in page
|
|
assert not hex_colours(page), hex_colours(page)
|