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
+10 -7
View File
@@ -5,6 +5,7 @@ from steward.core.audit import log_audit
from sqlalchemy import select
from steward.auth.middleware import require_role
from steward.alerts import service as alert_svc
from steward.core.paging import PAGE_SIZE, next_page
from steward.core.time_range import parse_range
from steward.ansible import sources as src_module
from steward.models.alerts import AlertRule, AlertOperator, AlertState, MaintenanceWindow
@@ -117,9 +118,6 @@ async def active_fragment():
return await render_template("alerts/_active.html", active=active)
HISTORY_LIMIT = 200
def _history_row(event, rule) -> dict:
"""One transition as the history page shows it."""
from steward.core.alerts import MUTED, RELEASED
@@ -147,15 +145,20 @@ def _history_row(event, rule) -> dict:
@alerts_bp.get("/history")
@require_role(UserRole.viewer)
async def alert_history():
"""Every alert transition in a range, newest first; one rule's with ?rule=."""
"""Every alert transition in a range, newest first; one rule's with ?rule=.
A page at a time as the reader scrolls: ?before=<cursor> is the next
page's rows alone (core.paging)."""
since, range_key = parse_range(request.args.get("range") or "7d")
before = request.args.get("before")
async with current_app.db_sessionmaker() as db:
rules = await alert_svc.list_rules(db)
chosen = next((r for r in rules if r.id == request.args.get("rule")), None)
rows = await alert_svc.history(db, since, chosen.id if chosen else None, HISTORY_LIMIT)
ctx = {"events": [_history_row(e, r) for e, r in rows[:HISTORY_LIMIT]],
"truncated": len(rows) > HISTORY_LIMIT, "limit": HISTORY_LIMIT,
rows = await alert_svc.history(db, since, chosen.id if chosen else None, PAGE_SIZE, before)
rows, after = next_page(rows, lambda row: (row[0].transitioned_at, row[0].id))
ctx = {"events": [_history_row(e, r) for e, r in rows], "after": after,
"rules": rules, "chosen": chosen, "current_range": range_key}
if before:
return await render_template("alerts/_history_page.html", **ctx)
if request.headers.get("HX-Request"):
return await render_template("alerts/_history_rows.html", **ctx)
return await render_template("alerts/history.html", **ctx)
+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()]
+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:
+12 -13
View File
@@ -4,6 +4,7 @@ from quart import Blueprint, render_template, request, current_app
from sqlalchemy import select
from steward.audit.service import entity_labels
from steward.auth.middleware import require_role
from steward.core.paging import keyset, next_page
from steward.models.audit import AuditEvent
from steward.models.users import UserRole
@@ -13,16 +14,16 @@ audit_bp = Blueprint("audit", __name__, url_prefix="/audit")
@audit_bp.get("/")
@require_role(UserRole.admin)
async def list_events():
limit = min(int(request.args.get("limit", 200)), 500)
"""Who changed what, newest first, a page at a time as the reader scrolls;
?before=<cursor> is the next page's rows alone (core.paging)."""
action_filter = request.args.get("action", "").strip()
async with current_app.db_sessionmaker() as db:
stmt = select(AuditEvent).order_by(AuditEvent.timestamp.desc()).limit(limit)
stmt = select(AuditEvent)
if action_filter:
stmt = select(AuditEvent).where(
AuditEvent.action.like(f"{action_filter}%")
).order_by(AuditEvent.timestamp.desc()).limit(limit)
result = await db.execute(stmt)
events = result.scalars().all()
stmt = stmt.where(AuditEvent.action.like(f"{action_filter}%"))
stmt = keyset(stmt, AuditEvent.timestamp, AuditEvent.id, request.args.get("before"))
events, after = next_page((await db.execute(stmt)).scalars().all(),
lambda e: (e.timestamp, e.id))
# Parse detail_json for display
parsed = []
@@ -40,9 +41,7 @@ async def list_events():
e = item["event"]
item["entity"] = labels.get((e.entity_type, str(e.entity_id))) if e.entity_id else None
return await render_template(
"audit/list.html",
events=parsed,
limit=limit,
action_filter=action_filter,
)
ctx = {"events": parsed, "after": after, "action_filter": action_filter}
if request.args.get("before"):
return await render_template("audit/_rows.html", **ctx)
return await render_template("audit/list.html", **ctx)
+59
View File
@@ -0,0 +1,59 @@
"""Keyset paging for newest-first lists that load as you scroll (#5211, pref 172).
A page is the rows strictly older than a cursor, ordered (time, id) newest
first, so a row added while someone scrolls never shifts the pages after it,
and no page needs an OFFSET. The cursor is "<iso time>|<id>": opaque to the
browser, which only hands back the one the last page gave it.
stmt = keyset(select(AuditEvent), AuditEvent.timestamp, AuditEvent.id, request.args.get("before"))
rows = (await db.execute(stmt)).scalars().all()
rows, after = next_page(rows, lambda e: (e.timestamp, e.id))
Templates end each page with scroll_more(<url with ?before=after>) while
`after` is set, and scroll_end() once it is not (_macros.html).
"""
from __future__ import annotations
from datetime import datetime
from typing import Callable
from sqlalchemy import tuple_
# Rows per page: enough to fill a tall screen past the scroll margin, so the
# next page starts loading before the reader reaches the end of this one.
PAGE_SIZE = 100
def cursor(at: datetime, row_id) -> str:
return f"{at.isoformat()}|{row_id}"
def parse_cursor(raw: str | None) -> tuple[datetime, str] | None:
"""(time, id) from a cursor, or None for no cursor or one that does not parse
(a mangled cursor starts the list over rather than failing the page)."""
if not raw or "|" not in raw:
return None
at, row_id = raw.split("|", 1)
try:
return datetime.fromisoformat(at), row_id
except ValueError:
return None
def keyset(stmt, at_col, id_col, before: str | None, size: int = PAGE_SIZE):
"""`stmt` limited to one page older than `before`, newest first. Fetches
one row more than a page so next_page can tell whether another follows."""
parsed = parse_cursor(before)
if parsed is not None:
stmt = stmt.where(tuple_(at_col, id_col) < tuple_(*parsed))
return stmt.order_by(at_col.desc(), id_col.desc()).limit(size + 1)
def next_page(rows, key: Callable, size: int = PAGE_SIZE) -> tuple[list, str | None]:
"""(this page's rows, the cursor for the next page or None at the end).
`key(row)` is the row's (time, id)."""
rows = list(rows)
if len(rows) <= size:
return rows, None
rows = rows[:size]
return rows, cursor(*key(rows[-1]))
+59
View File
@@ -0,0 +1,59 @@
/* Lists that continue themselves (#5211, pref 172).
*
* A [data-scroll-more] sentinel (the scroll_more macro) fetches the next page
* when it comes within MARGIN of the viewport, so the rows arrive before the
* reader reaches the end, and is replaced by them. While a page is in flight
* the sentinel says "Loading more…"; a failed page says so with a Retry button
* and is not re-fetched by scrolling (a dead endpoint must not become a request
* loop). The last page ends with scroll_end's "End of the list." instead of a sentinel.
*/
(function () {
var MARGIN = "600px 0px";
function load(el) {
el.setAttribute("data-scroll-state", "loading");
htmx.trigger(el, "scroll-more");
}
var observer = "IntersectionObserver" in window ? new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
var el = entry.target;
if (!entry.isIntersecting || el.hasAttribute("data-scroll-state")) return;
observer.unobserve(el);
load(el);
});
}, { rootMargin: MARGIN }) : null;
function watch(root) {
if (!root || !root.querySelectorAll) return;
var found = Array.prototype.slice.call(root.querySelectorAll("[data-scroll-more]"));
if (root.matches && root.matches("[data-scroll-more]")) found.push(root);
found.forEach(function (el) {
if (el.hasAttribute("data-scroll-state")) return;
if (observer) observer.observe(el); else load(el);
});
}
function failed(evt) {
var el = evt.detail && evt.detail.elt;
if (!el || !el.hasAttribute || !el.hasAttribute("data-scroll-more")) return;
el.setAttribute("data-scroll-state", "failed");
var status = el.querySelector("[data-scroll-status]");
if (!status) return;
status.textContent = "Couldn't load more. ";
var retry = document.createElement("button");
retry.type = "button";
retry.className = "btn btn-sm btn-ghost";
retry.textContent = "Retry";
retry.addEventListener("click", function () {
status.textContent = "Loading more…";
load(el);
});
status.appendChild(retry);
}
document.addEventListener("htmx:load", function (evt) { watch(evt.detail.elt); });
document.addEventListener("htmx:responseError", failed);
document.addEventListener("htmx:sendError", failed);
document.addEventListener("htmx:timeout", failed);
})();
+17
View File
@@ -84,3 +84,20 @@
{%- if kwargs.get('confirm') %} data-confirm="{{ kwargs.confirm }}"{% endif %}>{{ icon }}</button>
{%- endif -%}
{%- endmacro %}
{# A list that continues itself (#5211, pref 172; static/scroll-more.js).
scroll_more ends a page while there are more rows: it fetches `url` (the
next page, built with core.paging's cursor) when it comes near the
viewport, and is replaced by that page's rows. scroll_end ends the last
page. Pass `cols` inside a table body, to render as a full-width row. #}
{% macro scroll_more(url, cols=none) -%}
{%- if cols %}<tr data-scroll-more hx-get="{{ url }}" hx-trigger="scroll-more" hx-swap="outerHTML"><td colspan="{{ cols }}">
{%- else %}<div data-scroll-more hx-get="{{ url }}" hx-trigger="scroll-more" hx-swap="outerHTML">{% endif -%}
<span class="field-hint" role="status" aria-live="polite" data-scroll-status>Loading more…</span>
{%- if cols %}</td></tr>{% else %}</div>{% endif %}
{%- endmacro %}
{% macro scroll_end(cols=none, text="End of the list.") -%}
{%- if cols %}<tr><td colspan="{{ cols }}"><span class="field-hint" role="status">{{ text }}</span></td></tr>
{%- else %}<p class="field-hint" role="status">{{ text }}</p>{% endif %}
{%- endmacro %}
@@ -0,0 +1,53 @@
{# alerts/_history_page.html — one page of alert transitions, then the sentinel
that loads the next page as the reader nears it (core.paging, pref 172). #}
{% from "_macros.html" import status_badge, scroll_more, scroll_end %}
{% for ev in events %}
<tr>
<td style="font-family:ui-monospace,monospace;font-size:0.82rem;white-space:nowrap;">
{{ ev.at.strftime("%Y-%m-%d %H:%M:%S") }}
</td>
<td>
<a href="/alerts/history?rule={{ ev.rule.id }}&range={{ current_range }}">{{ ev.rule.name }}</a>
<div style="font-size:0.78rem;color:var(--text-dim);">{{ ev.rule.source_module }}/{{ ev.rule.resource_name }}</div>
</td>
<td style="white-space:nowrap;">
{{ status_badge(ev.from_state) }}
<span style="color:var(--text-dim);">→</span>
{{ status_badge(ev.to_state) }}
{% for n in ev.notes %}
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;white-space:normal;">{{ n }}</div>
{% endfor %}
</td>
<td style="font-family:ui-monospace,monospace;font-size:0.85rem;white-space:nowrap;">
{{ ev.rule.metric_name }} = {{ ev.value }}
<div style="font-size:0.75rem;color:var(--text-dim);">rule: {{ ev.condition }}</div>
</td>
<td style="font-size:0.82rem;">
{% if ev.muted_by %}
<span class="badge badge-yellow" title="A maintenance window muted this notification">muted · {{ ev.muted_by }}</span>
{% if ev.released %}<div style="color:var(--text-muted);margin-top:0.25rem;">notified when the window ended</div>{% endif %}
{% endif %}
{% for label, sent, error in ev.deliveries %}
<div style="margin-top:0.15rem;">
{% if sent %}
<span class="badge badge-green">{{ label }} sent</span>
{% else %}
<span class="badge badge-red">{{ label }} failed</span>
{% if error %}<div style="color:var(--text-muted);font-size:0.78rem;margin-top:0.15rem;max-width:28rem;word-break:break-word;">{{ error }}</div>{% endif %}
{% endif %}
</div>
{% endfor %}
{% if not ev.muted_by and not ev.deliveries %}<span style="color:var(--text-dim);" title="No notification was sent for this change">—</span>{% endif %}
</td>
<td style="font-size:0.82rem;">
{% if ev.ansible_run_id %}
<a href="/ansible/runs/{{ ev.ansible_run_id }}">Ansible run</a>
{% else %}<span style="color:var(--text-dim);">—</span>{% endif %}
</td>
</tr>
{% endfor %}
{% if after %}
{{ scroll_more("/alerts/history?before=" ~ (after|urlencode) ~ "&range=" ~ (current_range|urlencode) ~ (("&rule=" ~ (chosen.id|urlencode)) if chosen else ""), cols=6) }}
{% else %}
{{ scroll_end(cols=6) }}
{% endif %}
+3 -51
View File
@@ -1,4 +1,5 @@
{# alerts/_history_rows.html — the history table; swapped in on a range or rule change #}
{# alerts/_history_rows.html — the history table; swapped in on a range or rule
change. Its rows come a page at a time (alerts/_history_page.html). #}
{% from "_macros.html" import status_badge, empty_state %}
{% if events %}
<div class="card-flush" style="overflow-x:auto;">
@@ -14,59 +15,10 @@
</tr>
</thead>
<tbody>
{% for ev in events %}
<tr>
<td style="font-family:ui-monospace,monospace;font-size:0.82rem;white-space:nowrap;">
{{ ev.at.strftime("%Y-%m-%d %H:%M:%S") }}
</td>
<td>
<a href="/alerts/history?rule={{ ev.rule.id }}&range={{ current_range }}">{{ ev.rule.name }}</a>
<div style="font-size:0.78rem;color:var(--text-dim);">{{ ev.rule.source_module }}/{{ ev.rule.resource_name }}</div>
</td>
<td style="white-space:nowrap;">
{{ status_badge(ev.from_state) }}
<span style="color:var(--text-dim);">→</span>
{{ status_badge(ev.to_state) }}
{% for n in ev.notes %}
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;white-space:normal;">{{ n }}</div>
{% endfor %}
</td>
<td style="font-family:ui-monospace,monospace;font-size:0.85rem;white-space:nowrap;">
{{ ev.rule.metric_name }} = {{ ev.value }}
<div style="font-size:0.75rem;color:var(--text-dim);">rule: {{ ev.condition }}</div>
</td>
<td style="font-size:0.82rem;">
{% if ev.muted_by %}
<span class="badge badge-yellow" title="A maintenance window muted this notification">muted · {{ ev.muted_by }}</span>
{% if ev.released %}<div style="color:var(--text-muted);margin-top:0.25rem;">notified when the window ended</div>{% endif %}
{% endif %}
{% for label, sent, error in ev.deliveries %}
<div style="margin-top:0.15rem;">
{% if sent %}
<span class="badge badge-green">{{ label }} sent</span>
{% else %}
<span class="badge badge-red">{{ label }} failed</span>
{% if error %}<div style="color:var(--text-muted);font-size:0.78rem;margin-top:0.15rem;max-width:28rem;word-break:break-word;">{{ error }}</div>{% endif %}
{% endif %}
</div>
{% endfor %}
{% if not ev.muted_by and not ev.deliveries %}<span style="color:var(--text-dim);" title="No notification was sent for this change">—</span>{% endif %}
</td>
<td style="font-size:0.82rem;">
{% if ev.ansible_run_id %}
<a href="/ansible/runs/{{ ev.ansible_run_id }}">Ansible run</a>
{% else %}<span style="color:var(--text-dim);">—</span>{% endif %}
</td>
</tr>
{% endfor %}
{% include "alerts/_history_page.html" %}
</tbody>
</table>
</div>
{% if truncated %}
<p style="color:var(--text-muted);font-size:0.82rem;margin-top:0.75rem;">
Showing the newest {{ limit }}. Pick a shorter range or one rule to see the rest.
</p>
{% endif %}
{% else %}
<div class="card">{{ empty_state("No alert changed state in the last " ~ current_range ~ ((" for " ~ chosen.name) if chosen else "") ~ ".") }}</div>
{% endif %}
+21
View File
@@ -0,0 +1,21 @@
{# ansible/_runs_page.html — one page of runs, then the sentinel that loads the
next page as the reader nears it (core.paging, pref 172). #}
{% from "_macros.html" import status_badge, scroll_more, scroll_end %}
{% for row in runs %}
{% set run = row.run %}
<tr>
<td>
<a href="/ansible/runs/{{ run.id }}" title="{{ run.inventory_scope }}">{{ row.title }}</a>
{% if row.check %}<span class="badge badge-dim" title="Ansible check mode: nothing was changed">dry run</span>{% endif %}
<div class="field-hint">{{ run.source_name }}</div>
</td>
<td>{{ status_badge(run.status) }}</td>
<td title="{{ run.started_at.strftime('%Y-%m-%d %H:%M:%S UTC') }}">{{ run.started_at|ago }}</td>
<td>{{ row.duration_s|duration }}{% if not run.finished_at and run.status.value in ('running', 'queued') %} so far{% endif %}</td>
</tr>
{% endfor %}
{% if after %}
{{ scroll_more("/ansible/?before=" ~ (after|urlencode), cols=4) }}
{% else %}
{{ scroll_end(cols=4) }}
{% endif %}
+1 -13
View File
@@ -21,19 +21,7 @@
</tr>
</thead>
<tbody>
{% for row in runs %}
{% set run = row.run %}
<tr>
<td>
<a href="/ansible/runs/{{ run.id }}" title="{{ run.inventory_scope }}">{{ row.title }}</a>
{% if row.check %}<span class="badge badge-dim" title="Ansible check mode: nothing was changed">dry run</span>{% endif %}
<div class="field-hint">{{ run.source_name }}</div>
</td>
<td>{{ status_badge(run.status) }}</td>
<td title="{{ run.started_at.strftime('%Y-%m-%d %H:%M:%S UTC') }}">{{ run.started_at|ago }}</td>
<td>{{ row.duration_s|duration }}{% if not run.finished_at and run.status.value in ('running', 'queued') %} so far{% endif %}</td>
</tr>
{% endfor %}
{% include "ansible/_runs_page.html" %}
</tbody>
</table>
</div>
+33
View File
@@ -0,0 +1,33 @@
{# audit/_rows.html — one page of the audit log's rows, then the sentinel that
loads the next page as the reader nears it (core.paging, pref 172). #}
{% from "_macros.html" import scroll_more, scroll_end %}
{% for item in events %}
{% set e = item.event %}
{% set d = item.detail %}
<tr>
<td style="white-space:nowrap;" title="{{ e.timestamp.strftime('%Y-%m-%d %H:%M:%S UTC') }}">{{ e.timestamp|ago }}</td>
<td style="font-size:0.85rem;">{{ e.username }}</td>
<td><span class="mono">{{ e.action }}</span></td>
<td>
{% if item.entity %}
<span class="field-hint">{{ e.entity_type|replace('_', ' ') }}</span>
{% if item.entity.url %}<a href="{{ item.entity.url }}">{{ item.entity.label }}</a>{% else %}{{ item.entity.label }}{% endif %}
{% if item.entity.gone %}<span class="field-hint">(deleted)</span>{% endif %}
{% elif e.entity_type %}<span class="field-hint">{{ e.entity_type|replace('_', ' ') }}</span>{% endif %}
</td>
<td style="font-size:0.8rem;color:var(--text-muted);max-width:340px;">
{% if d %}
{% for k, v in d.items() %}
<span style="color:var(--text-dim);">{{ k }}:</span>
<span class="mono">{{ v }}</span>
{% if not loop.last %} &nbsp;·&nbsp; {% endif %}
{% endfor %}
{% endif %}
</td>
</tr>
{% endfor %}
{% if after %}
{{ scroll_more("/audit/?before=" ~ (after|urlencode) ~ (("&action=" ~ (action_filter|urlencode)) if action_filter else ""), cols=5) }}
{% else %}
{{ scroll_end(cols=5) }}
{% endif %}
+1 -31
View File
@@ -27,40 +27,10 @@
</tr>
</thead>
<tbody>
{% for item in events %}
{% set e = item.event %}
{% set d = item.detail %}
<tr>
<td style="white-space:nowrap;" title="{{ e.timestamp.strftime('%Y-%m-%d %H:%M:%S UTC') }}">{{ e.timestamp|ago }}</td>
<td style="font-size:0.85rem;">{{ e.username }}</td>
<td><span class="mono">{{ e.action }}</span></td>
<td>
{% if item.entity %}
<span class="field-hint">{{ e.entity_type|replace('_', ' ') }}</span>
{% if item.entity.url %}<a href="{{ item.entity.url }}">{{ item.entity.label }}</a>{% else %}{{ item.entity.label }}{% endif %}
{% if item.entity.gone %}<span class="field-hint">(deleted)</span>{% endif %}
{% elif e.entity_type %}<span class="field-hint">{{ e.entity_type|replace('_', ' ') }}</span>{% endif %}
</td>
<td style="font-size:0.8rem;color:var(--text-muted);max-width:340px;">
{% if d %}
{% for k, v in d.items() %}
<span style="color:var(--text-dim);">{{ k }}:</span>
<span class="mono">{{ v }}</span>
{% if not loop.last %} &nbsp;·&nbsp; {% endif %}
{% endfor %}
{% endif %}
</td>
</tr>
{% endfor %}
{% include "audit/_rows.html" %}
</tbody>
</table>
</div>
<p style="font-size:0.78rem;color:var(--text-dim);margin-top:0.75rem;">
Showing last {{ limit }} events.
{% if events | length == limit %}
<a href="?limit={{ limit * 2 }}{% if action_filter %}&action={{ action_filter }}{% endif %}">Load more</a>
{% endif %}
</p>
{% else %}
<div class="card">{{ empty_state("No audit events recorded yet.") }}</div>
{% endif %}
+1
View File
@@ -27,6 +27,7 @@
<link rel="stylesheet" href="{{ url_for('static', filename='theme.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='steward.css') }}">
<script src="{{ url_for('static', filename='chart-colors.js') }}"></script>
<script src="{{ url_for('static', filename='scroll-more.js') }}" defer></script>
{% block head %}{% endblock %}
</head>
<body>
+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)