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
+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())