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
+25 -1
View File
@@ -35,7 +35,7 @@ from datetime import datetime, timedelta, timezone
from email.message import EmailMessage
from typing import TYPE_CHECKING, Callable
from sqlalchemy import insert, or_, select, text, update
from sqlalchemy import and_, insert, or_, select, text, update
from steward.models.outbox import EmailOutbox, email_outbox_events
@@ -434,3 +434,27 @@ async def outgoing(db, now: datetime | None = None, limit: int = 20) -> dict:
shown = sum(counts.get(s, 0) for s in open_states)
return {"rows": rows, "more": max(shown - len(rows), 0), "sent": counts.get("sent", 0),
"problems": problems}
async def alert_trouble(db, now: datetime | None = None) -> dict:
"""The dashboard Alerts widget's delivery line (#5510): alert emails of
the last SHOWN_DAYS that failed, and those still retrying (tried, not yet
accepted), and the line that says so ("" when neither).
{"failed": n, "retrying": n, "said": str}."""
from sqlalchemy import func
now = now or datetime.now(timezone.utc)
rows = dict((await db.execute(
select(EmailOutbox.state, func.count())
.where(EmailOutbox.kind == "alert", EmailOutbox.created_at >= now - timedelta(days=SHOWN_DAYS),
or_(EmailOutbox.state == "failed",
and_(EmailOutbox.state.in_(("pending", "sending")), EmailOutbox.attempts > 0)))
.group_by(EmailOutbox.state))).all())
failed = rows.get("failed", 0)
retrying = rows.get("pending", 0) + rows.get("sending", 0)
said = []
if failed:
said.append(f"{failed} alert email{'s' if failed != 1 else ''} failed in the last {SHOWN_DAYS} days")
if retrying:
said.append(f"{retrying} still retrying" if failed
else f"{retrying} alert email{'s' if retrying != 1 else ''} still retrying")
return {"failed": failed, "retrying": retrying, "said": "; ".join(said)}