History (#5316): - Transitions fold into episodes (steward/alerts/episodes.py): each stretch out of quiet, the worst state it reached, how long it lasted, its worst reading against the rule ('3m 21s (fires above 3m)'), who was told across its changes, and its Ansible runs. The changes themselves fold under it. - An episode cut by paging says so ('cleared later (above)', 'earlier'). - For one rule: a verdict ('Fired 4 times in the last 7d, 1h 2m in all') and no Rule column. Notified and Action show only when the range has any. Tests: - The confirm guard looks for a confirm( call, not the word in the comment that says it is gone (lesson #4365). - The Hosts viewer test looks for the add-host dialog by id, since every page now carries the confirm dialog. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
100 lines
5.3 KiB
Python
100 lines
5.3 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
|
|
# One episode per stretch out of quiet (#5316): its worst reading against
|
|
# the rule, who was told across its changes, the changes folded under it.
|
|
assert '98% <span class="tone-dim">(fires above 90%)</span>' 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 "No CPU reading" in page and "2 changes of state" in page
|
|
assert "<th>Rule</th>" in page and "<th>Notified</th>" in page and "<th>Action</th>" not 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
|
|
assert "<th>Rule</th>" not in rows # one rule: no column for it
|
|
assert "Fired 1 time</strong> in the last 24h" in rows # its verdict
|
|
|
|
# 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 "98%" in older and f"old-{tag}" in older and f"patch-{tag}" not in older
|
|
assert "cleared later (above)" in older # its end is on the newer page
|
|
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())
|