feat(dashboard): an Alerts widget beside the Overview; the Overview's overflow links to Alerts and Monitors (#5510)
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:
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user