feat(alerts): Steward's own confirm dialog, a verdict-first rules list, and a rule page in three sections (#5314, #5315, #5317)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 55s
CI / unit (push) Failing after 1m25s
CI / publish (push) Skipped

Confirm dialog (#5314):
- Every data-confirm form now asks in one native <dialog> (base.html + static/confirm.js)
  instead of window.confirm.
- The question becomes the heading and what follows it the detail; the act's
  button is named after the form's own button; Cancel has the focus and Esc
  backs out. Optional data-confirm-title/-detail/-action/-dismiss attributes.
- All 27 existing uses work as they are. Cancelling a run says 'Keep running'.

Alerts list (#5315):
- A verdict line (what is firing, how many rules, where notifications go,
  or 'notifies nobody yet' with a link) replaces the empty Active card.
- Each rule's name opens it; rows show 'last fired' and fold fire/clear into
  the condition; History and Disable stay; Delete leaves the list.
- .docker-verdict becomes the shared .page-verdict.

Rule page (#5317):
- Named for the rule (title and crumb), with when it last fired and how many
  changes of state it holds.
- Three sections: what it watches, when it fires (checks side by side,
  seconds echoed as a duration), what happens (where notifications go, no-data,
  the playbook, whose fields stay folded until ticked).
- Delete at the foot, behind the dialog, saying its history goes with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 22:58:38 -04:00
co-authored by Claude Opus 5.5
parent 7cb6cb1fb5
commit 0c66c1e7d3
14 changed files with 401 additions and 131 deletions
+23 -7
View File
@@ -2,13 +2,13 @@ from __future__ import annotations
from datetime import datetime, timezone
from quart import Blueprint, render_template, request, redirect, url_for, current_app, session
from steward.core.audit import log_audit
from sqlalchemy import select
from sqlalchemy import func, select
from steward.auth.middleware import require_role
from steward.alerts import service as alert_svc
from steward.core.paging import PAGE_SIZE, next_page
from steward.core.time_range import parse_range
from steward.ansible import sources as src_module
from steward.models.alerts import AlertRule, AlertOperator, AlertState, MaintenanceWindow
from steward.models.alerts import AlertEvent, AlertRule, AlertOperator, AlertState, MaintenanceWindow
from steward.models.users import UserRole
alerts_bp = Blueprint("alerts", __name__, url_prefix="/alerts")
@@ -102,29 +102,41 @@ def _parse_ansible_action(form) -> tuple[dict | None, str | None]:
return action, None
def _notify() -> list[str]:
"""Where alerts' notifications go now, from the settings the app runs with."""
return alert_svc.notify_targets(current_app.config.get("SMTP", {}), current_app.config.get("WEBHOOK", {}))
@alerts_bp.get("/")
@require_role(UserRole.viewer)
async def list_alerts():
"""The verdict and what is active, then every rule with what it is doing
now and when it last fired (#5315)."""
async with current_app.db_sessionmaker() as db:
active = await alert_svc.active_rows(db, datetime.now(timezone.utc))
rules = await alert_svc.list_rules(db)
states = {s.rule_id: s for s in (await db.execute(select(AlertState))).scalars()}
fired = await alert_svc.last_fired(db)
# Every rule with what it is doing now; disabled rules last.
rules.sort(key=lambda r: (not r.enabled, r.name.lower()))
rule_state = {r.id: alert_svc.alert_label(states[r.id]) for r in rules if r.id in states}
return await render_template(
"alerts/list.html", active=active, rules=rules, rule_state=rule_state,
operators=_OPERATORS, poll_interval=current_app.config.get("MONITORS_POLL_INTERVAL", 60))
"alerts/list.html", active=active, rules=rules, rule_state=rule_state, last_fired=fired,
notify=_notify(), operators=_OPERATORS,
poll_interval=current_app.config.get("MONITORS_POLL_INTERVAL", 60))
@alerts_bp.get("/active")
@require_role(UserRole.viewer)
async def active_fragment():
"""The Active section alone, which the alerts page polls."""
"""The verdict and Active section alone, which the alerts page polls."""
async with current_app.db_sessionmaker() as db:
active = await alert_svc.active_rows(db, datetime.now(timezone.utc))
return await render_template("alerts/_active.html", active=active)
rules = await alert_svc.list_rules(db)
return await render_template(
"alerts/_active.html", active=active, notify=_notify(), rules_total=len(rules),
rules_off=sum(1 for r in rules if not r.enabled))
def _history_row(event, rule) -> dict:
@@ -215,6 +227,7 @@ async def new_rule():
metrics=alert_svc.metric_catalog().get(first_source, []),
catalog=alert_svc.metric_catalog(),
ansible_sources=_ansible_source_names(),
notify=_notify(),
)
@@ -227,9 +240,12 @@ async def edit_rule(rule_id: str):
if rule is None:
return "Not found", 404
resources = await alert_svc.resources_for(db, rule.source_module)
history_count = (await db.execute(
select(func.count()).select_from(AlertEvent).where(AlertEvent.rule_id == rule.id))).scalar_one()
fired = (await alert_svc.last_fired(db, rule.id)).get(rule.id)
return await render_template(
"alerts/rules_form.html",
rule=rule,
rule=rule, history_count=history_count, fired=fired, notify=_notify(),
operators=_OPERATORS,
source_modules=alert_svc.source_modules(),
initial_source=rule.source_module,