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
+7 -6
View File
@@ -241,17 +241,18 @@ async def list_rules(db) -> list[AlertRule]:
async def history(db, since: datetime, rule_id: str | None = None,
limit: int = 200) -> list[tuple[AlertEvent, AlertRule]]:
limit: int = 200, before: str | None = None) -> list[tuple[AlertEvent, AlertRule]]:
"""Alert transitions since `since`, newest first, with their rule; one
rule's when `rule_id` is given. Returns up to limit+1 rows so a caller can
tell the list was cut."""
rule's when `rule_id` is given; only those older than the `before` cursor
when given (core.paging). Returns up to limit+1 rows so a caller can tell
the list was cut."""
from steward.core.paging import keyset
stmt = (select(AlertEvent, AlertRule)
.join(AlertRule, AlertEvent.rule_id == AlertRule.id)
.where(AlertEvent.transitioned_at >= since)
.order_by(AlertEvent.transitioned_at.desc())
.limit(limit + 1))
.where(AlertEvent.transitioned_at >= since))
if rule_id:
stmt = stmt.where(AlertEvent.rule_id == rule_id)
stmt = keyset(stmt, AlertEvent.transitioned_at, AlertEvent.id, before, limit)
return [tuple(row) for row in (await db.execute(stmt)).all()]