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>