Files
FabledSteward/tests/integration/test_alert_history_page.py
T
bvandeusenandClaude Opus 5.5 7275fad767
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 51s
CI / unit (push) Successful in 1m13s
CI / publish (push) Successful in 1m7s
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>
2026-10-07 00:10:53 -04:00

94 lines
4.7 KiB
Python

"""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, an HTMX request gets only the rows, and ?before= the next page."""
from __future__ import annotations
import asyncio
import os
import time
import uuid
from datetime import datetime, timedelta, timezone
import pytest
pytestmark = pytest.mark.integration
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
def test_a_viewer_reads_the_alert_history(app):
from steward.core.alerts import MUTED
from steward.models.alerts import AlertEvent, AlertOperator, AlertRule
from steward.models.users import User, UserRole
async def _go():
tag = uuid.uuid4().hex[:8]
uid = str(uuid.uuid4())
now = datetime.now(timezone.utc)
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(User(id=uid, username=f"hist-{tag}", email=f"hist-{tag}@test",
password_hash="x", role=UserRole.viewer, is_active=True))
await s.flush()
hot = AlertRule(name=f"hot {tag}", source_module="host_agent", resource_name=f"box-{tag}",
metric_name="cpu_pct", operator=AlertOperator(">"), threshold=90,
created_by=uid)
cold = AlertRule(name=f"cold {tag}", source_module="host_agent", resource_name=f"box-{tag}",
metric_name="cpu_pct", operator=AlertOperator("<"), threshold=1,
created_by=uid)
s.add_all([hot, cold])
await s.flush()
s.add_all([
AlertEvent(rule_id=hot.id, from_state="inactive", to_state="firing", metric_value=97.5,
transitioned_at=now - timedelta(minutes=5), email_sent=False,
email_error=f"mx-{tag}: timed out", webhook_sent=True),
AlertEvent(rule_id=hot.id, from_state="firing", to_state="resolved", metric_value=None,
transitioned_at=now - timedelta(minutes=2), note=f"{MUTED} patch-{tag}"),
AlertEvent(rule_id=cold.id, from_state="inactive", to_state="firing", metric_value=0.5,
transitioned_at=now - timedelta(minutes=1), note=f"cold-note-{tag}"),
# Outside a 1h range.
AlertEvent(rule_id=hot.id, from_state="pending", to_state="firing", metric_value=95,
transitioned_at=now - timedelta(hours=3), note=f"old-{tag}"),
])
hot_id = hot.id
client = app.test_client()
async with client.session_transaction() as sess:
sess.update(user_id=uid, user_role="viewer", username=f"hist-{tag}", login_at=time.time())
resp = await client.get("/alerts/history?range=1h")
assert resp.status_code == 200
page = await resp.get_data(as_text=True)
assert "Alert History" in page and 'href="/alerts/"' in page # crumbs back to Alerts
assert f"hot {tag}" in page and f"cold-note-{tag}" in page and f"old-{tag}" not in page
assert "cpu_pct = 97.5" in page and "rule: &gt; 90" in page
assert "email failed" in page and f"mx-{tag}: timed out" in page and "webhook sent" in page
assert f"muted · patch-{tag}" in page and "cpu_pct = no data" in page
resp = await client.get(f"/alerts/history?range=24h&rule={hot_id}", headers={"HX-Request": "true"})
rows = await resp.get_data(as_text=True)
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)
asyncio.run(_go())