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
+11 -1
View File
@@ -1,7 +1,7 @@
"""Integration: /alerts/history (#5066) against real Postgres. A viewer can
read it; it shows the transition, value against the condition, delivery
outcome with its error, and a maintenance mute; ?rule= narrows it to one
rule, and an HTMX request gets only the rows."""
rule, an HTMX request gets only the rows, and ?before= the next page."""
from __future__ import annotations
import asyncio
@@ -77,6 +77,16 @@ def test_a_viewer_reads_the_alert_history(app):
assert "<html" not in rows and "<table" in rows
assert f"old-{tag}" in rows and f"cold-note-{tag}" not in rows
# The next page as the reader scrolls: only older rows, without the table
# around them, and the list says where it ends (#5211).
from steward.core.paging import cursor
resp = await client.get("/alerts/history", query_string={
"range": "24h", "rule": hot_id, "before": cursor(now - timedelta(minutes=3), "")})
older = await resp.get_data(as_text=True)
assert resp.status_code == 200 and "<table" not in older
assert "cpu_pct = 97.5" in older and f"old-{tag}" in older and f"patch-{tag}" not in older
assert "End of the list." in older and "data-scroll-more" not in older
# The rules list links each rule to its history.
assert f'/alerts/history?rule={hot_id}' in await (await client.get("/alerts/")).get_data(as_text=True)