feat(dashboard): an Alerts widget beside the Overview; the Overview's overflow links to Alerts and Monitors (#5510)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m16s
CI / unit (push) Failing after 1m28s
CI / publish (push) Skipped

The Alerts widget (/alerts/widget) is shaped like every widget: a verdict
("2 firing, 1 silent." / "Nothing firing: all 12 rules are quiet."), then
what is firing or silent with its reading now, what the rule fires on and
since when, then what someone acknowledged and who; a line when alert
email failed in the last 7 days or is retrying (outbox.alert_trouble),
linking admins to Settings -> System; the quiet rules counted, a covering
rule once. It sits beside the Overview in the recommended layout (6x4
each).

The Overview's "+N more" line pointed at the removed /status/; alerts now
link to /alerts/ and monitors to /monitors/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 16:27:17 -04:00
co-authored by Claude Opus 5.5
parent defad2e98b
commit 57e1960dc1
10 changed files with 279 additions and 13 deletions
+13
View File
@@ -178,6 +178,19 @@ async def active_fragment():
rules_off=sum(1 for r in rules if not r.enabled))
@alerts_bp.get("/widget")
@require_role(UserRole.viewer)
async def widget():
"""The dashboard Alerts widget (#5510): a verdict, what is firing or
silent, then what someone has taken, a line when alert email is failing,
and the quiet rules counted."""
async with current_app.db_sessionmaker() as db:
active = await alert_svc.active_rows(db, datetime.now(timezone.utc))
view = alert_svc.widget_view(active, await alert_svc.list_rules(db))
email = await outbox.alert_trouble(db)
return await render_template("alerts/widget.html", **view, email=email)
def _delivery(label: str, sent: bool | None, error: str | None) -> dict | None:
"""A channel's outcome as recorded on the event; None when not tried."""
if sent is None:
+16 -1
View File
@@ -231,11 +231,13 @@ async def active_rows(db, now: datetime) -> list[dict]:
since = started.get(rule.id, state.last_transition_at)
else:
since = state.last_transition_at
value = await latest_value(db, rule)
rows.append({
"state": state, "rule": rule, "label": label, "since": since,
"breach_since": started.get(rule.id) if label != "no_data" else None,
"acked_by": names.get(state.acknowledged_by, state.acknowledged_by),
"says": alert_sentence(rule, label, await latest_value(db, rule), since, now),
"value": value,
"says": alert_sentence(rule, label, value, since, now),
"url": await resource_url(db, rule),
})
epoch = datetime.max.replace(tzinfo=timezone.utc)
@@ -243,6 +245,19 @@ async def active_rows(db, now: datetime) -> list[dict]:
return rows
def widget_view(active: list[dict], rules: list[AlertRule]) -> dict:
"""The dashboard Alerts widget (#5510) from active_rows and every rule:
the alerts in that order, each with what its rule fires on, and how many
enabled rules, as people made them, are quiet (a covering rule counts
once, and is quiet only while every host under it is).
{"rows", "quiet", "total"}."""
busy = {r["rule"].id for r in active}
busy |= {r["rule"].parent_id for r in active if r["rule"].parent_id}
made = [r for r in rules if r.enabled and not r.parent_id]
return {"rows": [{**r, "condition": rule_condition(r["rule"])} for r in active],
"quiet": sum(1 for r in made if r.id not in busy), "total": len(made)}
def watches_host(rule: AlertRule, host_name: str, monitor_keys: set) -> bool:
"""Whether a rule watches something on this host: its agent series
("<host>" or "<host>:<part>"), one of its containers ("<host>/<name>"),