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
+11 -5
View File
@@ -12,6 +12,7 @@ from steward.ansible import executor, guards, sources as src_module
from steward.ansible.inventory_gen import run_title, scope_labels
from steward.auth.middleware import require_role
from steward.core.audit import audit_request as _audit
from steward.core.paging import keyset, next_page
from steward.models.ansible import AnsibleRun, AnsibleRunStatus
from steward.models.ansible_schedule import AnsibleSchedule
from steward.models.users import User, UserRole
@@ -36,14 +37,19 @@ def _get_sources() -> list[dict]:
@ansible_bp.get("/")
@require_role(UserRole.viewer)
async def index():
"""Every run, newest first, a page at a time as the reader scrolls;
?before=<cursor> is the next page's rows alone (core.paging)."""
before = request.args.get("before")
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(AnsibleRun).order_by(AnsibleRun.started_at.desc()).limit(50)
)
runs = result.scalars().all()
stmt = keyset(select(AnsibleRun), AnsibleRun.started_at, AnsibleRun.id, before)
runs, after = next_page((await db.execute(stmt)).scalars().all(),
lambda r: (r.started_at, r.id))
labels = await scope_labels(db, [r.inventory_scope for r in runs])
now = datetime.now(timezone.utc)
return await render_template("ansible/index.html", runs=[run_view(r, labels, now) for r in runs])
ctx = {"runs": [run_view(r, labels, now) for r in runs], "after": after}
if before:
return await render_template("ansible/_runs_page.html", **ctx)
return await render_template("ansible/index.html", **ctx)
def run_duration(run: AnsibleRun, now: datetime) -> float | None: