feat(alerts): alert history page — transitions, deliveries, mutes and actions (#5066)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 50s
CI / unit (push) Successful in 1m7s
CI / publish (push) Successful in 1m4s

#5066 "Alert history page". /alerts/history lists every alert transition
in a range (1h–30d, the shared range picker), newest first, all rules or
one (?rule=). Each row: from → to, the value against the rule's condition
("no data" for a silent one), email and webhook sent or failed with the
error, "muted · <window>" (and "notified when the window ended") taken out
of the event note, other notes, and a link to the Ansible run an action
started. Range and rule changes swap only the rows (HTMX). Viewer role.

The Alerts page links each rule's history and the whole history. The query
moves to alert_svc.history, which MCP get_alert_history now uses too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 14:06:04 -04:00
co-authored by Claude Opus 5.5
parent c9f5c049db
commit dc22a35e9d
8 changed files with 302 additions and 12 deletions
+46
View File
@@ -0,0 +1,46 @@
"""How the alert history page reads one AlertEvent (#5066): the maintenance
mute and its release come out of the note as their own fields, a silent
transition reads "no data", and only attempted deliveries are listed."""
from datetime import datetime, timezone
from types import SimpleNamespace
import pytest
from steward.alerts.routes import _history_row
from steward.core.alerts import MUTED, RELEASED
from steward.models.alerts import AlertOperator
RULE = SimpleNamespace(operator=AlertOperator(">"), threshold=90.0)
def _event(**kw):
base = dict(transitioned_at=datetime(2026, 10, 6, tzinfo=timezone.utc), from_state="inactive",
to_state="firing", metric_value=97.5, note=None, email_sent=None, email_error=None,
webhook_sent=None, webhook_error=None, ansible_run_id=None)
return SimpleNamespace(**{**base, **kw})
@pytest.mark.parametrize("note, notes, muted_by, released", [
(None, [], None, False),
("rule enabled", ["rule enabled"], None, False),
(f"{MUTED} patch night", [], "patch night", False),
(f"no sample for 18m; {MUTED} patch night; {RELEASED}", ["no sample for 18m"], "patch night", True),
(MUTED, [], "a maintenance window", False),
])
def test_notes_split_into_mute_release_and_the_rest(note, notes, muted_by, released):
row = _history_row(_event(note=note), RULE)
assert (row["notes"], row["muted_by"], row["released"]) == (notes, muted_by, released)
def test_value_and_condition():
assert _history_row(_event(), RULE)["value"] == "97.5"
assert _history_row(_event(metric_value=None), RULE)["value"] == "no data"
assert _history_row(_event(), RULE)["condition"] == "> 90"
def test_only_attempted_deliveries_are_listed():
row = _history_row(_event(email_sent=False, email_error="timed out", webhook_sent=None), RULE)
assert row["deliveries"] == [("email", False, "timed out")]
row = _history_row(_event(email_sent=True, webhook_sent=True), RULE)
assert [(label, sent) for label, sent, _ in row["deliveries"]] == [("email", True), ("webhook", True)]
assert _history_row(_event(), RULE)["deliveries"] == []
@@ -0,0 +1,83 @@
"""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."""
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 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())