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