feat(ui): audit log, alert history and Ansible runs load as you scroll (#5211)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 51s
CI / unit (push) Successful in 1m13s
CI / publish (push) Successful in 1m7s

#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>
This commit is contained in:
2026-10-07 00:10:53 -04:00
co-authored by Claude Opus 5.5
parent 8e890c6914
commit 7275fad767
18 changed files with 350 additions and 129 deletions
+3 -1
View File
@@ -50,7 +50,7 @@ async def test_the_runs_list_names_each_run_with_one_badge(app):
)]
async with app.test_request_context("/ansible/"):
session["user_role"] = "viewer"
page = await render_template("ansible/index.html", runs=runs)
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
@@ -58,6 +58,8 @@ async def test_the_runs_list_names_each_run_with_one_badge(app):
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
+14 -1
View File
@@ -39,7 +39,20 @@ async def test_the_page_names_and_links_the_entity(app):
"entity": {"label": "api check", "url": None, "gone": True}},
]
async with app.test_request_context("/audit/"):
page = await render_template("audit/list.html", events=events, limit=200, action_filter="")
page = await render_template("audit/list.html", events=events, after=None, action_filter="")
assert '<a href="/hosts/h1">web-1</a>' in page
assert "api check" in page and "(deleted)" in page
assert "h1</td>" not in page # no bare id where a name is known
async def test_the_log_continues_itself_instead_of_a_load_more_link(app):
events = [{"event": _ev("host", "h1", "host.renamed"), "detail": {}, "entity": None}]
async with app.test_request_context("/audit/"):
more = await render_template("audit/list.html", events=events,
after="2026-10-07T03:00:00+00:00|0190abc", action_filter="host.")
last = await render_template("audit/_rows.html", events=events, after=None, action_filter="")
assert ('data-scroll-more hx-get="/audit/?before=2026-10-07T03%3A00%3A00%2B00%3A00%7C0190abc'
'&amp;action=host."') in more
assert "Load more" not in more
assert "data-scroll-more" not in last and "End of the list." in last
+34
View File
@@ -0,0 +1,34 @@
"""Keyset paging for lists that load as you scroll (#5211): a cursor round-trips,
a mangled one starts over, and a page says whether another follows."""
from datetime import datetime, timezone
from types import SimpleNamespace as NS
from sqlalchemy import Column, DateTime, MetaData, String, Table, select
from sqlalchemy.dialects import postgresql
from steward.core.paging import cursor, keyset, next_page, parse_cursor
AT = datetime(2026, 10, 7, 3, 0, 5, 123456, tzinfo=timezone.utc)
def test_a_cursor_round_trips_and_a_mangled_one_starts_over():
assert parse_cursor(cursor(AT, "0190ab|cd")) == (AT, "0190ab|cd") # an id may hold the separator
assert parse_cursor(None) is None and parse_cursor("") is None
assert parse_cursor("not a cursor") is None and parse_cursor("yesterday|x") is None
def test_a_page_says_whether_another_follows():
rows = [NS(at=AT, id=str(i)) for i in range(5)]
page, after = next_page(rows, lambda r: (r.at, r.id), size=3)
assert [r.id for r in page] == ["0", "1", "2"] and after == cursor(AT, "2")
page, after = next_page(rows[:3], lambda r: (r.at, r.id), size=3)
assert len(page) == 3 and after is None # exactly a page: the end
def test_the_query_reads_one_row_past_the_page_older_than_the_cursor():
t = Table("t", MetaData(), Column("at", DateTime(timezone=True)), Column("id", String))
first = str(keyset(select(t), t.c.at, t.c.id, None, size=10).compile(dialect=postgresql.dialect()))
assert "WHERE" not in first and "ORDER BY t.at DESC, t.id DESC" in first and "LIMIT" in first
later = str(keyset(select(t), t.c.at, t.c.id, cursor(AT, "x"), size=10)
.compile(dialect=postgresql.dialect()))
assert "WHERE (t.at, t.id) < (" in later