feat(ui): audit log, alert history and Ansible runs load as you scroll (#5211)
#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:
@@ -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)
|
||||
|
||||
@@ -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()]
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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]))
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %} · {% 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 %}
|
||||
@@ -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 %} · {% 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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
'&action=host."') in more
|
||||
assert "Load more" not in more
|
||||
assert "data-scroll-more" not in last and "End of the list." in last
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user