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
+11
View File
@@ -232,6 +232,17 @@ host. Each channel's result shows under the button. Nothing fires, the rule's
history doesn't change, and the test is recorded in the audit log as
`alert_rule.tested`.
### On the dashboard
The **Alerts** widget (`/alerts/widget`) sits beside the Overview in the
recommended layout. It opens with a verdict ("2 firing, 1 silent." or
"Nothing firing: all 12 rules are quiet."). Below that it lists what is
firing or silent: each rule, what it watches, its reading now against what
it fires on, and since when. Acknowledged alerts follow, with who
acknowledged them and when. A line appears when alert email has failed in
the last 7 days or is still retrying, linking admins to Settings → System.
The quiet rules are counted at the end.
---
## Data Models
+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>
+104
View File
@@ -0,0 +1,104 @@
"""The dashboard Alerts widget (#5510): a verdict, what is firing or silent
with its reading and since when, then what someone has acknowledged, a line
when alert email is failing, and the quiet rules counted."""
import re
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from quart import render_template, session
from steward.alerts.service import widget_view
from steward.core.widgets import WIDGET_REGISTRY
from steward.models.alerts import AlertOperator
from tests.test_style_tokens import hex_colours
NOW = datetime.now(timezone.utc)
QUIET_EMAIL = {"failed": 0, "retrying": 0, "said": ""}
def _rule(rid, name, resource="web-1", metric="cpu_pct", op=">", threshold=90.0,
parent_id=None, enabled=True):
return SimpleNamespace(id=rid, name=name, resource_name=resource, metric_name=metric,
operator=AlertOperator(op), threshold=threshold,
parent_id=parent_id, enabled=enabled)
def _row(rule, label, value=None, since=None, acked_by=None, acked_at=None):
return {"rule": rule, "label": label, "value": value, "since": since, "acked_by": acked_by,
"state": SimpleNamespace(acknowledged_at=acked_at)}
async def _render(app, view, email=QUIET_EMAIL, role="admin"):
async with app.test_request_context("/alerts/widget"):
if role:
session["user_id"] = "u1"
session["user_role"] = role
return await render_template("alerts/widget.html", **view, email=email)
def test_the_widget_is_registered_and_points_at_alerts():
w = WIDGET_REGISTRY["alerts"]
assert w["hx_url"] == "/alerts/widget" and w["detail_url"] == "/alerts/" and w["plugin"] is None
def test_a_covering_rule_counts_once_and_is_quiet_only_when_all_its_hosts_are():
cover = _rule("c", "disk on every host", resource="*")
kid_a = _rule("c-a", "disk on every host", resource="nas", parent_id="c")
kid_b = _rule("c-b", "disk on every host", resource="web-1", parent_id="c")
lone = _rule("l", "web up", metric="is_up", op="<", threshold=0.5)
off = _rule("o", "old rule", enabled=False)
view = widget_view([_row(kid_a, "firing", 91.0, NOW)], [cover, kid_a, kid_b, lone, off])
assert view["total"] == 2 and view["quiet"] == 1 # "web up"; the covering rule is busy
assert view["rows"][0]["condition"] == "CPU > 90%"
assert widget_view([], [cover, kid_a, kid_b, lone, off])["quiet"] == 2
async def test_firing_and_silent_come_first_then_who_acknowledged(app):
hot = _rule("r1", "web-1 CPU high")
gone = _rule("r2", "nas agent", resource="nas")
taken = _rule("r3", "db disk", resource="db-1:/var", metric="disk_used_pct", op=">=", threshold=85.0)
rows = [_row(hot, "firing", 94.0, NOW - timedelta(minutes=12)),
_row(gone, "no_data", None, NOW - timedelta(hours=2)),
_row(taken, "acknowledged", 88.0, NOW - timedelta(hours=3), acked_by="bryan",
acked_at=NOW - timedelta(minutes=40))]
page = await _render(app, widget_view(rows, [hot, gone, taken, _rule("r4", "quiet one")]))
assert re.search(r'w-verdict tone-crit">\s*1 firing, 1 silent, 1 acknowledged\.', page)
assert page.index("web-1 CPU high") < page.index("nas agent") < page.index("db disk")
assert 'href="/alerts/history?rule=r1"' in page
assert "web-1 · fires on CPU &gt; 90%" in page and "94%" in page and "12m ago" in page
assert "nas · no reading" in page
assert "db-1:/var · acknowledged by bryan" in page and "40m ago" in page
assert "+1 rule quiet" in page and 'href="/alerts/"' in page
assert len(re.findall(r"<th[ >]", page)) == 3
assert not hex_colours(page), hex_colours(page)
async def test_all_quiet_is_the_verdict_alone(app):
rules = [_rule("r1", "a"), _rule("r2", "b")]
page = await _render(app, widget_view([], rules))
assert re.search(r'w-verdict tone-ok">\s*Nothing firing: all 2 rules are quiet\.', page)
assert "<table" not in page and "rule quiet" not in page and "rules quiet" not in page
page = await _render(app, widget_view([], rules[:1]))
assert "Nothing firing: the rule is quiet." in page
async def test_no_rules_offers_to_add_one(app):
page = await _render(app, widget_view([], []), role="operator")
assert "No alert rules yet." in page and 'href="/alerts/rules/new"' in page
page = await _render(app, widget_view([], []), role="viewer")
assert "No alert rules yet." in page and "/alerts/rules/new" not in page
async def test_failing_alert_email_gets_a_line(app):
email = {"failed": 2, "retrying": 1, "said": "2 alert emails failed in the last 7 days; 1 still retrying"}
page = await _render(app, widget_view([], [_rule("r1", "a")]), email=email)
assert "2 alert emails failed in the last 7 days; 1 still retrying" in page
assert 'href="/settings/system/"' in page and "tone-crit" in page
page = await _render(app, widget_view([], [_rule("r1", "a")]), email=email, role="viewer")
assert "1 still retrying" in page and "/settings/system/" not in page
async def test_the_share_view_shows_the_alerts_without_links(app):
hot = _rule("r1", "web-1 CPU high")
page = await _render(app, widget_view([_row(hot, "firing", 94.0, NOW)], [hot]), role=None)
assert "web-1 CPU high" in page and "<a " not in page
+27 -4
View File
@@ -28,8 +28,11 @@ def test_retired_widgets_point_at_the_one_that_replaced_them():
def test_the_recommended_layout_fills_rows_without_overlap():
items = recommended_layout(ALL_ON)
assert [i["key"] for i in items] == [key for key, *_ in RECOMMENDED]
assert items[0] == {"key": "status_overview", "x": 0, "y": 0, "w": 12, "h": 3, "config": {}}
assert (items[1]["x"], items[1]["y"], items[2]["x"], items[2]["y"]) == (0, 3, 6, 3)
# The Overview and the Alerts side by side first (#5510), then the fleet.
assert items[0] == {"key": "status_overview", "x": 0, "y": 0, "w": 6, "h": 4, "config": {}}
assert items[1] == {"key": "alerts", "x": 6, "y": 0, "w": 6, "h": 4, "config": {}}
assert [(i["key"], i["x"], i["y"]) for i in items[2:4]] == [
("hosts_overview", 0, 4), ("monitors", 6, 4)]
for i, a in enumerate(items):
assert a["x"] + a["w"] <= 12
assert not any(_overlap(a, b) for b in items[i + 1:]), a
@@ -37,8 +40,8 @@ def test_the_recommended_layout_fills_rows_without_overlap():
def test_the_recommended_layout_skips_what_is_not_enabled():
items = recommended_layout({})
assert [i["key"] for i in items] == ["status_overview", "hosts_overview", "monitors"]
assert [(i["x"], i["y"]) for i in items] == [(0, 0), (0, 3), (6, 3)]
assert [i["key"] for i in items] == ["status_overview", "alerts", "hosts_overview", "monitors"]
assert [(i["x"], i["y"]) for i in items] == [(0, 0), (6, 0), (0, 4), (6, 4)]
def test_overview_alerts_put_the_breaching_first():
@@ -77,6 +80,26 @@ async def test_the_overview_labels_its_counts_and_lists_what_is_wrong(app):
assert not hex_colours(page), hex_colours(page)
async def test_the_overviews_overflow_links_to_alerts_and_to_monitors(app):
"""#5510: what doesn't fit goes to its own list; /status/ is gone."""
now = datetime.now(timezone.utc)
alerts = [{"rule": SimpleNamespace(id=f"r{i}", name=f"rule {i}"), "label": "firing", "since": now}
for i in range(7)]
entries = [StatusEntry(kind="tcp", key=str(i), name=f"m{i}", status="down", detail_url=f"/monitors/{i}")
for i in range(10)]
async with app.test_request_context("/status/widget"):
session["user_id"] = "u1"
page = await render_template("status/widget.html", entries=entries, up=0, down=10, pending=0,
total_hosts=1, alerts=alerts, widget_id="1")
assert '<a href="/alerts/">+1 more alerts</a> · <a href="/monitors/">+2 more monitors</a>' in page
assert "/status/" not in page
async with app.test_request_context("/status/widget"):
page = await render_template("status/widget.html", entries=entries[:3], up=0, down=3, pending=0,
total_hosts=1, alerts=alerts, widget_id="1")
assert "+1 more alerts" in page and "more monitors" not in page
assert 'href="/alerts/"' not in page # the share view has no links
async def test_a_quiet_overview_says_so(app):
async with app.test_request_context("/status/widget"):
page = await render_template("status/widget.html", entries=[], up=0, down=0, pending=0,
@@ -96,4 +96,8 @@ def test_an_alerts_email_reads_gathering_then_failed_in_history_and_on_system(ap
assert "failed in the last 7 days" in verdict
assert extra in system and "550 mailbox gone" in system
# The dashboard's Alerts widget names the rule and the failed email (#5510).
widget = " ".join((await (await client.get("/alerts/widget")).get_data(as_text=True)).split())
assert "failed in the last 7 days" in widget and 'href="/settings/system/"' in widget
asyncio.run(_go())