From dc22a35e9d34abb6f4b398a731bed4ab408d658a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 6 Oct 2026 14:06:04 -0400 Subject: [PATCH] =?UTF-8?q?feat(alerts):=20alert=20history=20page=20?= =?UTF-8?q?=E2=80=94=20transitions,=20deliveries,=20mutes=20and=20actions?= =?UTF-8?q?=20(#5066)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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 · " (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 --- steward/alerts/routes.py | 45 +++++++++++ steward/alerts/service.py | 17 +++- steward/mcp/tools/alerts.py | 14 +--- steward/templates/alerts/_history_rows.html | 75 ++++++++++++++++++ steward/templates/alerts/history.html | 32 ++++++++ steward/templates/alerts/list.html | 2 + tests/core/test_alert_history_row.py | 46 +++++++++++ tests/integration/test_alert_history_page.py | 83 ++++++++++++++++++++ 8 files changed, 302 insertions(+), 12 deletions(-) create mode 100644 steward/templates/alerts/_history_rows.html create mode 100644 steward/templates/alerts/history.html create mode 100644 tests/core/test_alert_history_row.py create mode 100644 tests/integration/test_alert_history_page.py diff --git a/steward/alerts/routes.py b/steward/alerts/routes.py index 50053b1..9474601 100644 --- a/steward/alerts/routes.py +++ b/steward/alerts/routes.py @@ -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.time_range import parse_range from steward.ansible import sources as src_module from steward.models.alerts import AlertRule, AlertOperator, MaintenanceWindow from steward.models.users import UserRole @@ -101,6 +102,50 @@ async def list_alerts(): return await render_template("alerts/list.html", active=active, rules=rules, operators=_OPERATORS) +HISTORY_LIMIT = 200 + + +def _history_row(event, rule) -> dict: + """One transition as the history page shows it.""" + from steward.core.alerts import MUTED, RELEASED + notes, muted_by, released = [], None, False + for part in (event.note or "").split("; "): + if part.startswith(MUTED): + muted_by = part[len(MUTED):].strip() or "a maintenance window" + elif part == RELEASED: + released = True + elif part: + notes.append(part) + value = "no data" if event.metric_value is None else f"{event.metric_value:g}" + return { + "at": event.transitioned_at, "rule": rule, + "from_state": event.from_state, "to_state": event.to_state, + "value": value, "condition": f"{rule.operator.value} {rule.threshold:g}", + "notes": notes, "muted_by": muted_by, "released": released, + "deliveries": [(label, sent, error) for label, sent, error in ( + ("email", event.email_sent, event.email_error), + ("webhook", event.webhook_sent, event.webhook_error)) if sent is not None], + "ansible_run_id": event.ansible_run_id, + } + + +@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=.""" + since, range_key = parse_range(request.args.get("range") or "7d") + 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, + "rules": rules, "chosen": chosen, "current_range": range_key} + if request.headers.get("HX-Request"): + return await render_template("alerts/_history_rows.html", **ctx) + return await render_template("alerts/history.html", **ctx) + + @alerts_bp.get("/api/rule_fields") @require_role(UserRole.viewer) async def rule_fields_api(): diff --git a/steward/alerts/service.py b/steward/alerts/service.py index c7464cc..b391756 100644 --- a/steward/alerts/service.py +++ b/steward/alerts/service.py @@ -12,7 +12,7 @@ from datetime import datetime, timezone from sqlalchemy import or_, select from steward.models.alerts import ( - AlertOperator, AlertRule, AlertState, AlertStateEnum, MaintenanceWindow, + AlertEvent, AlertOperator, AlertRule, AlertState, AlertStateEnum, MaintenanceWindow, ) from steward.models.metrics import PluginMetric from steward.models.monitors import MONITOR_TYPE_VALUES, Monitor @@ -89,6 +89,21 @@ async def list_rules(db) -> list[AlertRule]: return list((await db.execute(select(AlertRule).order_by(AlertRule.name))).scalars()) +async def history(db, since: datetime, rule_id: str | None = None, + limit: int = 200) -> 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.""" + 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)) + if rule_id: + stmt = stmt.where(AlertEvent.rule_id == rule_id) + return [tuple(row) for row in (await db.execute(stmt)).all()] + + async def list_maintenance_windows(db) -> list[MaintenanceWindow]: """Every window, latest start first.""" return list((await db.execute( diff --git a/steward/mcp/tools/alerts.py b/steward/mcp/tools/alerts.py index 97cb967..0eb78cb 100644 --- a/steward/mcp/tools/alerts.py +++ b/steward/mcp/tools/alerts.py @@ -7,7 +7,7 @@ from sqlalchemy import select from steward.alerts import service as alert_svc from steward.mcp._context import current_caller -from steward.models.alerts import AlertEvent, AlertOperator, AlertRule, AlertState, MaintenanceWindow +from steward.models.alerts import AlertOperator, AlertRule, AlertState, MaintenanceWindow from steward.models.users import User, UserRole from ._audit import audit_mcp @@ -95,16 +95,8 @@ async def get_alert_history(rule: str = "", range: str = "7d", limit: int = 50) limit = clamp_limit(limit, 50, 500) async with db_session() as db: rules = await alert_svc.list_rules(db) - 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)) - chosen = None - if rule: - chosen = pick(rule, rules, kind="alert rule", list_tool="list_alert_rules") - stmt = stmt.where(AlertEvent.rule_id == chosen.id) - rows = (await db.execute(stmt)).all() + chosen = pick(rule, rules, kind="alert rule", list_tool="list_alert_rules") if rule else None + rows = await alert_svc.history(db, since, chosen.id if chosen else None, limit) events = [ { "rule_id": r.id, diff --git a/steward/templates/alerts/_history_rows.html b/steward/templates/alerts/_history_rows.html new file mode 100644 index 0000000..1d868b7 --- /dev/null +++ b/steward/templates/alerts/_history_rows.html @@ -0,0 +1,75 @@ +{# alerts/_history_rows.html — the history table; swapped in on a range or rule change #} +{% set state_badge = {"firing": "badge-red", "pending": "badge-yellow", "acknowledged": "badge-gold", + "resolved": "badge-green", "inactive": "badge-dim"} %} +{% if events %} +
+ + + + + + + + + + + + + {% for ev in events %} + + + + + + + + + {% endfor %} + +
When (UTC)RuleChangeValueNotifiedAction
+ {{ ev.at.strftime("%Y-%m-%d %H:%M:%S") }} + + {{ ev.rule.name }} +
{{ ev.rule.source_module }}/{{ ev.rule.resource_name }}
+
+ {{ ev.from_state }} + → + {{ ev.to_state }} + {% for n in ev.notes %} +
{{ n }}
+ {% endfor %} +
+ {{ ev.rule.metric_name }} = {{ ev.value }} +
rule: {{ ev.condition }}
+
+ {% if ev.muted_by %} + muted · {{ ev.muted_by }} + {% if ev.released %}
notified when the window ended
{% endif %} + {% endif %} + {% for label, sent, error in ev.deliveries %} +
+ {% if sent %} + {{ label }} sent + {% else %} + {{ label }} failed + {% if error %}
{{ error }}
{% endif %} + {% endif %} +
+ {% endfor %} + {% if not ev.muted_by and not ev.deliveries %}—{% endif %} +
+ {% if ev.ansible_run_id %} + Ansible run + {% else %}—{% endif %} +
+
+{% if truncated %} +

+ Showing the newest {{ limit }}. Pick a shorter range or one rule to see the rest. +

+{% endif %} +{% else %} +

+ No alert changed state in the last {{ current_range }}{% if chosen %} for {{ chosen.name }}{% endif %}. +

+{% endif %} diff --git a/steward/templates/alerts/history.html b/steward/templates/alerts/history.html new file mode 100644 index 0000000..e736f88 --- /dev/null +++ b/steward/templates/alerts/history.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% from "_macros.html" import crumbs %} +{% block title %}Alert History — Steward{% endblock %} +{% block breadcrumb %}{{ crumbs([("Alerts", "/alerts/"), ("History", "")]) }}{% endblock %} +{% block content %} +
+

Alert History

+
+ + {% include "_time_range.html" %} +
+
+ +

+ Every change of state, newest first: what the value was against the rule's condition, whether + the email and webhook went out, and the Ansible run an action started. +

+ +
+ {% include "alerts/_history_rows.html" %} +
+{% endblock %} diff --git a/steward/templates/alerts/list.html b/steward/templates/alerts/list.html index a69a64a..afa53fd 100644 --- a/steward/templates/alerts/list.html +++ b/steward/templates/alerts/list.html @@ -57,6 +57,7 @@

Rules

+ History Maintenance New Rule
@@ -89,6 +90,7 @@ {{ rule.consecutive_failures_required }} / {{ rule.consecutive_ok_required }} + History Edit
{{ csrf_field() }}