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>"),
+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)}
+15 -3
View File
@@ -70,6 +70,16 @@ WIDGET_REGISTRY: dict[str, dict] = {
"poll": True,
"params": [],
},
"alerts": {
"key": "alerts",
"label": "Alerts",
"description": "What is firing or silent, its reading and since when, then what someone has acknowledged; says when alert email is failing",
"hx_url": "/alerts/widget",
"detail_url": "/alerts/",
"plugin": None,
"poll": True,
"params": [],
},
"hosts_overview": {
"key": "hosts_overview",
"label": "Hosts — Overview",
@@ -305,13 +315,15 @@ RETIRED = {
"uptime_summary": "monitors", # Monitors shows 24h/7d/30d uptime per monitor (#5250)
}
# The recommended dashboard (#5213): what needs attention first, then the
# The recommended dashboard (#5213): what needs attention first (the
# Overview, and beside it the Alerts, #5510), then the
# fleet, then each integration. (key, width, height, config) on the 12-column
# grid, one cell 70px tall; a widget whose plugin is off is skipped and the
# rest close up. Seeded into the first dashboard and offered by
# "Reset to the recommended layout" in edit mode.
RECOMMENDED: list[tuple[str, int, int, dict]] = [
("status_overview", 12, 3, {}),
("status_overview", 6, 4, {}),
("alerts", 6, 4, {}),
("hosts_overview", 6, 7, {}),
("monitors", 6, 7, {"show_down_only": "no", "limit": 10}),
("docker_containers", 6, 6, {}),
@@ -342,7 +354,7 @@ def recommended_layout(plugins_cfg: dict) -> list[dict]:
# Group each widget for the picker (Core monitors / Monitoring capabilities /
# Integrations), mirroring the Settings → Plugins taxonomy. Capability plugins
# are host/monitoring facets; traefik/unifi are discrete vendor integrations.
_CORE_WIDGETS = {"monitors", "status_overview", "hosts_overview"}
_CORE_WIDGETS = {"monitors", "status_overview", "alerts", "hosts_overview"}
_INTEGRATION_PLUGINS = {"traefik", "unifi"}
GROUP_LABELS = {
"core": "Core monitors",
+58
View File
@@ -0,0 +1,58 @@
{# alerts/widget.html — the dashboard Alerts widget (#5510), in the widget
shape (snippet #5259): a verdict; what is firing or silent, its reading
now against what the rule fires on, and since when; then what someone has
acknowledged, by whom and when; a line when alert email is failing or
retrying; the quiet rules counted. Rows come from alert_svc.active_rows,
so they read in the Alerts page's order. #}
{% from "_macros.html" import status_dot %}
{% set firing = rows|selectattr("label", "equalto", "firing")|list|length %}
{% if not total and not rows %}
<div class="w-verdict" style="color:var(--text-muted);">
No alert rules yet.{% if session.user_role in ('operator', 'admin') %} <a href="/alerts/rules/new">Add a rule →</a>{% endif %}
</div>
{% else %}
<div class="w-verdict {{ 'tone-crit' if firing else ('tone-warn' if rows else 'tone-ok') }}">
{%- if firing %}{{ firing }} firing{% else %}Nothing firing{% endif %}
{%- for key, word in (("no_data", "silent"), ("acknowledged", "acknowledged"), ("pending", "pending")) %}
{%- set n = rows|selectattr("label", "equalto", key)|list|length %}
{%- if n %}, {{ n }} {{ word }}{% endif %}
{%- endfor %}
{%- if not rows %}: {{ "the rule is" if total == 1 else "all " ~ total ~ " rules are" }} quiet{% endif %}.
</div>
{% if rows %}
<table class="w-table">
<colgroup><col><col style="width:5rem"><col style="width:5.5rem"></colgroup>
<thead><tr><th>Alert</th><th>Now</th><th>Since</th></tr></thead>
<tbody>
{% for r in rows %}
{% set rule = r.rule %}
<tr>
<td class="w-name" title="{{ rule.name }} · {{ rule.resource_name }}">
{{- status_dot(r.label) -}}
{% if session.user_id %}<a href="/alerts/history?rule={{ rule.id }}">{{ rule.name }}</a>{% else %}{{ rule.name }}{% endif %}
{%- if r.label == "acknowledged" %}
<span class="w-sub">{{ rule.resource_name }} · acknowledged{% if r.acked_by %} by {{ r.acked_by }}{% endif %}{% if r.state.acknowledged_at %} {{ r.state.acknowledged_at|ago }}{% endif %}</span>
{%- elif r.label == "no_data" %}
<span class="w-sub tone-warn">{{ rule.resource_name }} · no reading</span>
{%- else %}
<span class="w-sub">{{ rule.resource_name }} · fires on {{ r.condition }}</span>
{%- endif %}
</td>
<td class="{{ 'tone-crit' if r.label == 'firing' else ('tone-warn' if r.label == 'pending') }}">
{%- if r.label == "no_data" %}—{% else %}{{ metric_value(rule.metric_name, r.value) }}{% endif -%}
</td>
<td>{% if r.since %}{{ r.since|ago }}{% else %}—{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if email.said %}
<div class="{{ 'tone-crit' if email.failed else 'tone-warn' }}" style="font-size:0.75rem;margin-top:0.4rem;">
{{- email.said }}{% if session.user_role == "admin" %} — <a href="/settings/system/">Outgoing email</a>{% endif %}
</div>
{% endif %}
{% if rows and quiet %}
<div class="w-more">+{{ quiet }} rule{{ "s" if quiet != 1 }} quiet{% if session.user_id %} — <a href="/alerts/">Alerts</a>{% endif %}</div>
{% endif %}
{% endif %}
+6 -4
View File
@@ -54,11 +54,13 @@
{% if e.since %}<span style="color:var(--text-dim);font-size:0.75rem;white-space:nowrap;">{% if e.since_at_least %}over {% endif %}{{ e.since|ago }}</span>{% endif %}
</div>
{% endfor %}
{% if alerts|length > 6 or problems|length > 8 %}
{# What didn't fit, each linking to its own list (#5510). #}
{% set more_alerts, more_monitors = alerts|length - 6, problems|length - 8 %}
{% if more_alerts > 0 or more_monitors > 0 %}
<div style="color:var(--text-dim);font-size:0.75rem;padding-top:0.2rem;">
{% if alerts|length > 6 %}+{{ alerts|length - 6 }} more alerts{% endif %}
{% if problems|length > 8 %}{% if alerts|length > 6 %} · {% endif %}+{{ problems|length - 8 }} more monitors{% endif %}
{% if session.user_id %} — <a href="/status/">Status</a>{% endif %}
{%- if more_alerts > 0 %}{% if session.user_id %}<a href="/alerts/">+{{ more_alerts }} more alerts</a>{% else %}+{{ more_alerts }} more alerts{% endif %}{% endif %}
{%- if more_alerts > 0 and more_monitors > 0 %} · {% endif %}
{%- if more_monitors > 0 %}{% if session.user_id %}<a href="/monitors/">+{{ more_monitors }} more monitors</a>{% else %}+{{ more_monitors }} more monitors{% endif %}{% endif -%}
</div>
{% endif %}
</div>