feat(alerts): a sentence per alert, a link to what it watches, a count in the sidebar (#5124)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 45s
CI / unit (push) Failing after 1m7s
CI / publish (push) Skipped

The Alerts page's active list now says each alert in one line around the
resource it watches, e.g. "cpu_pct on web-1 is 94% (> 90%), firing for 12m",
with web-1 linking to its host, monitor or container page (or the plugin's
page for Traefik, UniFi and SNMP). Values take their unit from the metric
name through formatting.metric_value. A silent series reads "No cpu_pct from
web-1 for 20m"; an up/down metric says the word.

Actions follow the routes' roles: acknowledge, edit, toggle and New rule for
operators and admins, Delete for admins only, nothing for viewers.

The sidebar shows how many alerts need attention beside Alerts, from an
async context processor that skips htmx fragments, logged-out pages and the
database-down page, and renders without the count if the query fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 18:12:50 -04:00
co-authored by Claude Opus 5.5
parent 6caf737917
commit 4fbe93bdf7
8 changed files with 304 additions and 20 deletions
+1 -1
View File
@@ -96,7 +96,7 @@ def _parse_ansible_action(form) -> tuple[dict | None, str | None]:
@require_role(UserRole.viewer)
async def list_alerts():
async with current_app.db_sessionmaker() as db:
active = await alert_svc.active_alerts(db)
active = await alert_svc.active_rows(db, datetime.now(timezone.utc))
rules = await alert_svc.list_rules(db)
return await render_template("alerts/list.html", active=active, rules=rules, operators=_OPERATORS)
+95
View File
@@ -85,6 +85,101 @@ def alert_label(state: AlertState) -> str:
return state.state.value
async def latest_value(db, rule: AlertRule) -> float | None:
"""The newest sample of the series a rule watches, or None before the first."""
return (await db.execute(
select(PluginMetric.value)
.where(PluginMetric.source_module == rule.source_module,
PluginMetric.resource_name == rule.resource_name,
PluginMetric.metric_name == rule.metric_name)
.order_by(PluginMetric.recorded_at.desc()).limit(1)
)).scalar_one_or_none()
# Sources whose resources have no page of their own: the plugin's page lists them.
PLUGIN_PAGES = {"traefik": "/plugins/traefik/", "unifi": "/plugins/unifi/",
"snmp": "/plugins/snmp/"}
async def resource_url(db, rule: AlertRule) -> str | None:
"""Where the thing a rule watches is shown, or None when nothing shows it.
host_agent resources are the host's name, or "<host>:<mount|nic|core>";
docker's are "<host>/<container>"; a monitor's is its name under its type.
"""
from steward.models.hosts import Host
source, resource = rule.source_module, rule.resource_name
if source == "host_agent":
name = resource.split(":", 1)[0]
host_id = (await db.execute(
select(Host.id).where(Host.name == name).limit(1))).scalar_one_or_none()
return f"/hosts/{host_id}" if host_id else None
if source in MONITOR_TYPE_VALUES:
monitor_id = (await db.execute(
select(Monitor.id).where(Monitor.type == source, Monitor.name == resource).limit(1)
)).scalar_one_or_none()
return f"/monitors/{monitor_id}" if monitor_id else None
if source == "docker" and "/" in resource:
host_name, container = resource.split("/", 1)
host_id = (await db.execute(
select(Host.id).where(Host.name == host_name).limit(1))).scalar_one_or_none()
return f"/plugins/docker/container/{host_id}/{container}" if host_id else None
return PLUGIN_PAGES.get(source)
# Metrics whose value is a word: the sentence says the word, not the condition.
_BINARY = ("is_up", "is_healthy")
def alert_sentence(rule: AlertRule, label: str, value: float | None,
since: datetime | None, now: datetime) -> dict:
"""An alert as one sentence around its resource: {"lead", "resource",
"tail"}, read lead + resource + tail, e.g. "cpu_pct on " + "web-1" +
" is 94% (> 90%), firing for 12m". label is alert_label(state); since is
when that label began (no_data_since for no_data)."""
from steward.core.formatting import human_duration, metric_value
metric = rule.metric_name
held = human_duration((now - since).total_seconds()) if since else None
if label == "no_data":
return {"lead": f"No {metric} from ", "resource": rule.resource_name,
"tail": f" for {held}" if held else ""}
for_how_long = f", {label} for {held}" if held else f", {label}"
if value is None:
reading = " has no reading yet"
elif metric in _BINARY:
return {"lead": "", "resource": rule.resource_name,
"tail": f" is {metric_value(metric, value)}{for_how_long}"}
else:
reading = f" is {metric_value(metric, value)}"
condition = f" ({rule.operator.value} {metric_value(metric, rule.threshold)})"
return {"lead": f"{metric} on ", "resource": rule.resource_name,
"tail": f"{reading}{condition}{for_how_long}"}
async def active_rows(db, now: datetime) -> list[dict]:
"""The alerts page's active list: each alert's state, rule, sentence and
the link to what it watches."""
rows = []
for state, rule in await active_alerts(db):
label = alert_label(state)
since = state.no_data_since if label == "no_data" else state.last_transition_at
rows.append({
"state": state, "rule": rule, "label": label,
"says": alert_sentence(rule, label, await latest_value(db, rule), since, now),
"url": await resource_url(db, rule),
})
return rows
async def active_count(db) -> int:
"""How many alerts need attention: the sidebar's number."""
from sqlalchemy import func
return (await db.execute(
select(func.count()).select_from(AlertState)
.where(or_(AlertState.state.in_(ACTIVE_STATES), AlertState.no_data_since.is_not(None)))
)).scalar_one()
async def list_rules(db) -> list[AlertRule]:
return list((await db.execute(select(AlertRule).order_by(AlertRule.name))).scalars())
+19 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from quart import Quart, render_template, request
from quart import Quart, g, render_template, request
from .config import load_bootstrap
from .database import (
init_db, ensure_database_reachable, DatabaseUnavailable,
@@ -269,6 +269,23 @@ def create_app(
"undecryptable_secrets": get_undecryptable_secrets(),
}
@app.context_processor
async def _inject_alert_count():
"""The sidebar's count of alerts needing attention. Full pages only:
an htmx fragment has no sidebar, and a logged-out page has no nav."""
from quart import session
if (request.headers.get("HX-Request") or not session.get("user_id")
or g.get("database_down")):
return {}
try:
from .alerts.service import active_count
async with app.db_sessionmaker() as db:
return {"active_alert_count": await active_count(db)}
except Exception:
# A count is decoration; the page renders without it.
logging.getLogger(__name__).exception("Active alert count failed")
return {}
# ── 11. Share-token middleware ─────────────────────────────────────────────
@app.before_request
async def _validate_share_token():
@@ -312,6 +329,7 @@ def create_app(
except DatabaseUnavailable as exc:
logger.error("Database unreachable while serving %s: %s",
request.path, exc)
g.database_down = True
return await render_template(
"errors/database_unavailable.html"), 503
return None
+27
View File
@@ -149,3 +149,30 @@ def worst_reading(readings, thresholds: dict):
if _SEVERITY.get(grade, 0) > rank:
worst, rank = (label, value, grade), _SEVERITY[grade]
return worst
def metric_value(name: str, value: Any) -> str:
"""A metric reading in its unit, from the name's suffix: _pct → 94%,
_ms → 120 ms, _bytes → 1.2 GiB, _bps → its rate, _secs / seconds_ → a
span, _c → 71°C, _days → 12 days; is_up / is_healthy read as yes or no.
None (no reading) is "—"."""
if value is None:
return "—"
n = name or ""
if n in ("is_up", "is_healthy"):
return {"is_up": ("up", "down"), "is_healthy": ("healthy", "unhealthy")}[n][0 if value >= 0.5 else 1]
if n.endswith("_pct") or "_pct_" in n:
return f"{value:.1f}%" if abs(value) < 10 else f"{value:.0f}%"
if n.endswith("_ms"):
return f"{value:.0f} ms"
if n.endswith("_bytes"):
return human_bytes(value)
if n.endswith(("_bps", "bytes_rate")):
return human_rate(value)
if n.endswith("_secs") or n.startswith("seconds_"):
return human_duration(value)
if n.endswith("_c"):
return f"{value:.0f}°C"
if n.endswith("_days"):
return f"{value:.0f} days"
return f"{value:g}"
+6
View File
@@ -86,6 +86,12 @@ code { font-family: var(--fs-font-mono); font-size: 0.875em; background: var(--b
color: var(--gold); border-left-color: var(--gold);
background: color-mix(in srgb, var(--gold) 8%, transparent);
}
/* How many alerts need attention, beside the Alerts link. */
.nav-count {
margin-left: auto; padding: 0 0.45em; border-radius: var(--fs-radius-sm);
background: var(--red-dim); color: var(--red); font-size: 0.75rem; font-weight: 600;
font-variant-numeric: tabular-nums;
}
.side-user { border-top: 1px solid var(--border); padding: 0.7rem 1.25rem; }
.side-user form { margin: 0; }
.side-user button {
+21 -17
View File
@@ -2,10 +2,11 @@
{% block title %}Alerts — Steward{% endblock %}
{% from "_macros.html" import page_header, status_badge, empty_state %}
{% block content %}
{% call page_header("Alerts") %}
{% set can_edit = session.user_role in ('operator', 'admin') %}
{% call page_header("Alerts", "What is breaching now, in a sentence each, then every rule.") %}
<a class="btn btn-ghost" href="/alerts/history">History</a>
<a class="btn btn-ghost" href="/alerts/maintenance">Maintenance</a>
<a class="btn" href="/alerts/rules/new">New rule</a>
{% if can_edit %}<a class="btn" href="/alerts/rules/new">New rule</a>{% endif %}
{% endcall %}
{% if active %}
@@ -15,30 +16,29 @@
<thead>
<tr>
<th>State</th>
<th>What is wrong</th>
<th>Rule</th>
<th>Resource</th>
<th>Metric</th>
<th></th>
</tr>
</thead>
<tbody>
{% for state, rule in active %}
{% for row in active %}
<tr>
<td>
{{ status_badge(state.state) }}
{% if state.no_data_since %}
{{ status_badge('no_data', title="No sample since " ~ (state.last_sample_at.strftime('%Y-%m-%d %H:%M UTC') if state.last_sample_at else 'the rule started watching')) }}
<div style="font-size:0.72rem;color:var(--text-dim);margin-top:0.2rem;">
since {{ state.no_data_since|ago }}
</div>
{{ status_badge(row.label, title=("No sample since " ~ (row.state.last_sample_at.strftime('%Y-%m-%d %H:%M UTC') if row.state.last_sample_at else 'the rule started watching')) if row.label == 'no_data' else none) }}
{% if row.state.no_data_since and row.label != 'no_data' %}
{{ status_badge('no_data', title="Silent since " ~ row.state.no_data_since.strftime('%Y-%m-%d %H:%M UTC')) }}
{% endif %}
</td>
<td>{{ rule.name }}</td>
<td style="color:var(--text-muted);">{{ rule.resource_name }}</td>
<td style="color:var(--text-muted);">{{ rule.metric_name }} {{ rule.operator.value }} {{ rule.threshold }}</td>
<td>
{{- row.says.lead -}}
{%- if row.url %}<a href="{{ row.url }}">{{ row.says.resource }}</a>{% else %}<strong>{{ row.says.resource }}</strong>{% endif -%}
{{- row.says.tail -}}
</td>
<td style="color:var(--text-muted);"><a href="/alerts/history?rule={{ row.rule.id }}">{{ row.rule.name }}</a></td>
<td class="td-actions">
{% if state.state.value == 'firing' %}
<form method="post" action="/alerts/{{ rule.id }}/acknowledge" style="display:inline;">{{ csrf_field() }}
{% if can_edit and row.state.state.value == 'firing' %}
<form method="post" action="/alerts/{{ row.rule.id }}/acknowledge" style="display:inline;">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-warn">Acknowledge</button>
</form>
{% endif %}
@@ -82,16 +82,20 @@
<td style="color:var(--text-muted);text-align:center;">{{ rule.consecutive_failures_required }} / {{ rule.consecutive_ok_required }}</td>
<td class="td-actions">
<a href="/alerts/history?rule={{ rule.id }}" class="btn btn-sm btn-ghost">History</a>
{% if can_edit %}
<a href="/alerts/rules/{{ rule.id }}/edit" class="btn btn-sm btn-ghost">Edit</a>
<form method="post" action="/alerts/rules/{{ rule.id }}/toggle" style="display:inline;">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-ghost">
{% if rule.enabled %}Disable{% else %}Enable{% endif %}
</button>
</form>
{% endif %}
{% if session.user_role == 'admin' %}
<form method="post" action="/alerts/rules/{{ rule.id }}/delete" style="display:inline;">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-danger"
data-confirm="Delete rule {{ rule.name }}?">Delete</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
@@ -99,6 +103,6 @@
</table>
</div>
{% else %}
<div class="card">{{ empty_state("No alert rules yet.", "New rule", "/alerts/rules/new") }}</div>
<div class="card">{{ empty_state("No alert rules yet.", "New rule" if can_edit else none, "/alerts/rules/new" if can_edit else none) }}</div>
{% endif %}
{% endblock %}
+1 -1
View File
@@ -62,7 +62,7 @@
<div class="nav-group">
<div class="nav-group-label">Monitoring</div>
<a href="/monitors/" {% if _p.startswith('/monitors') %}class="active" aria-current="page"{% endif %}>Monitors</a>
<a href="/alerts/" {% if _p.startswith('/alerts') %}class="active" aria-current="page"{% endif %}>Alerts</a>
<a href="/alerts/" {% if _p.startswith('/alerts') %}class="active" aria-current="page"{% endif %}>Alerts{% if active_alert_count %} <span class="nav-count" title="{{ active_alert_count }} alert{{ 's' if active_alert_count != 1 }} need{{ 's' if active_alert_count == 1 }} attention">{{ active_alert_count }}</span>{% endif %}</a>
</div>
<div class="nav-group">
<div class="nav-group-label">Automation</div>
+134
View File
@@ -0,0 +1,134 @@
"""The Alerts page says each alert as a sentence around a link to what it
watches, offers only the actions the viewer's role can take, and the sidebar
counts what needs attention (#5124)."""
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
import pytest
from quart import render_template, session
from steward.alerts import service as alert_svc
from steward.alerts.service import alert_sentence
from steward.models.alerts import AlertOperator, AlertStateEnum
from tests.test_style_tokens import hex_colours
def _rule(metric="cpu_pct", op=AlertOperator.gt, threshold=90.0, resource="web-1", rid="r1"):
return SimpleNamespace(id=rid, name=f"{resource} {metric}", source_module="host_agent",
resource_name=resource, metric_name=metric, operator=op,
threshold=threshold, enabled=True,
consecutive_failures_required=1, consecutive_ok_required=1)
def _said(says):
return says["lead"] + says["resource"] + says["tail"]
@pytest.mark.parametrize("op, threshold, value, reads", [
(AlertOperator.gt, 90.0, 94.0, "cpu_pct on web-1 is 94% (> 90%), firing for 12m"),
(AlertOperator.gte, 90.0, 90.0, "cpu_pct on web-1 is 90% (>= 90%), firing for 12m"),
(AlertOperator.lt, 5.0, 2.5, "cpu_pct on web-1 is 2.5% (< 5.0%), firing for 12m"),
(AlertOperator.lte, 5.0, 5.0, "cpu_pct on web-1 is 5.0% (<= 5.0%), firing for 12m"),
(AlertOperator.eq, 100.0, 100.0, "cpu_pct on web-1 is 100% (== 100%), firing for 12m"),
(AlertOperator.ne, 0.0, 3.0, "cpu_pct on web-1 is 3.0% (!= 0.0%), firing for 12m"),
])
def test_a_firing_alert_reads_as_one_sentence(op, threshold, value, reads):
now = datetime.now(timezone.utc)
says = alert_sentence(_rule(op=op, threshold=threshold), "firing", value,
now - timedelta(minutes=12), now)
assert says["resource"] == "web-1"
assert _said(says) == reads
def test_units_follow_the_metric_name():
now = datetime.now(timezone.utc)
says = alert_sentence(_rule("response_ms", threshold=200.0, resource="api"), "pending", 431.0,
now - timedelta(seconds=45), now)
assert _said(says) == "response_ms on api is 431 ms (> 200 ms), pending for 45s"
def test_an_up_or_down_metric_says_the_word():
now = datetime.now(timezone.utc)
says = alert_sentence(_rule("is_up", AlertOperator.lt, 1.0, "nas"), "acknowledged", 0.0,
now - timedelta(hours=2, minutes=5), now)
assert _said(says) == "nas is down, acknowledged for 2h 5m"
def test_no_reading_yet_is_said_not_shown_as_a_number():
now = datetime.now(timezone.utc)
says = alert_sentence(_rule(), "firing", None, now - timedelta(minutes=3), now)
assert _said(says) == "cpu_pct on web-1 has no reading yet (> 90%), firing for 3m"
def test_a_silent_series_says_how_long_it_has_been_quiet():
now = datetime.now(timezone.utc)
says = alert_sentence(_rule(), "no_data", 50.0, now - timedelta(minutes=20), now)
assert _said(says) == "No cpu_pct from web-1 for 20m"
def _row(rule, label, url, state=AlertStateEnum.firing):
now = datetime.now(timezone.utc)
st = SimpleNamespace(state=state, no_data_since=None, last_sample_at=None,
last_transition_at=now - timedelta(minutes=12))
return {"state": st, "rule": rule, "label": label, "url": url,
"says": alert_sentence(rule, label, 94.0, st.last_transition_at, now)}
async def test_an_operator_sees_the_sentence_linked_and_can_acknowledge(app):
rule = _rule()
async with app.test_request_context("/alerts/"):
session["user_role"] = "operator"
page = await render_template("alerts/list.html", active=[_row(rule, "firing", "/hosts/h1")],
rules=[rule], operators=[])
assert 'cpu_pct on <a href="/hosts/h1">web-1</a> is 94% (&gt; 90%), firing for 12m' in page
assert '<span class="badge badge-crit">Firing</span>' in page
assert 'action="/alerts/r1/acknowledge"' in page
assert 'href="/alerts/rules/new"' in page and 'href="/alerts/rules/r1/edit"' in page
assert 'action="/alerts/rules/r1/delete"' not in page # deleting a rule is the admin's
main = page[page.index("<main"):] # the sidebar brand mark carries its own colours
assert "onclick" not in main and not hex_colours(main), hex_colours(main)
async def test_a_viewer_reads_but_gets_no_actions(app):
rule = _rule()
async with app.test_request_context("/alerts/"):
session["user_role"] = "viewer"
page = await render_template("alerts/list.html", active=[_row(rule, "firing", None)],
rules=[rule], operators=[])
assert "cpu_pct on <strong>web-1</strong>" in page # nothing to link to: no bare href
for action in ("/acknowledge", "/alerts/rules/new", "/edit", "/toggle", "/delete"):
assert action not in page, action
assert 'href="/alerts/history?rule=r1"' in page
async def test_an_admin_can_delete_a_rule(app):
rule = _rule()
async with app.test_request_context("/alerts/"):
session["user_role"] = "admin"
page = await render_template("alerts/list.html", active=[], rules=[rule], operators=[])
assert 'action="/alerts/rules/r1/delete"' in page
assert 'data-confirm="Delete rule web-1 cpu_pct?"' in page
async def _active_count(_db):
return 3
async def test_the_sidebar_counts_alerts_needing_attention(app, monkeypatch):
monkeypatch.setattr(alert_svc, "active_count", _active_count)
async with app.test_request_context("/alerts/"):
session["user_id"], session["user_role"] = "u1", "viewer"
page = await render_template("alerts/list.html", active=[], rules=[], operators=[])
assert '<span class="nav-count" title="3 alerts need attention">3</span>' in page
async def test_no_count_for_a_fragment_or_a_logged_out_page(app, monkeypatch):
monkeypatch.setattr(alert_svc, "active_count", _active_count)
async with app.test_request_context("/alerts/", headers={"HX-Request": "true"}):
session["user_id"], session["user_role"] = "u1", "viewer"
fragment = await render_template("alerts/list.html", active=[], rules=[], operators=[])
async with app.test_request_context("/alerts/"):
logged_out = await render_template("alerts/list.html", active=[], rules=[], operators=[])
assert "nav-count" not in fragment and "nav-count" not in logged_out