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() }}