feat(observability): add alertmanager service type and widget
Slice 1 of observability-service-registry. Alertmanager becomes a
first-class service-registry type, mirroring grafana/prometheus.
- integrations/alertmanager.py (new): AlertmanagerConfig
(base_url, timeout_seconds), AlertmanagerAlertsWidgetConfig (optional
severity_filter), shared summarize_alerts() helper, and DEFINITION
(service_type "alertmanager", secret api_key, widget "active_alerts").
- integrations/registry.py: register ALERTMANAGER (7 types now).
- widgets/sources.py: AlertmanagerWidgetSource fetches
{base_url}/api/v1/alerts, sends optional Bearer token from the api_key
secret, applies optional severity_filter, and summarizes via the shared
helper; registered in SERVICE_ADAPTERS.
- routers/monitoring.py: _summary_from_alerts delegates to the shared
summarize_alerts (behavior unchanged).
- tests: registry now 7 types; /api/services/types lists alertmanager;
4 new adapter tests (summarize, severity filter, bearer token, missing
service).
Backend-only slice; the frontend active_alerts widget binding lands in a
later slice. ruff clean; 228 backend tests pass.
Reviewed fresh-context (read-only): no blockers.
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
"""Alertmanager service definition."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
WidgetConfigBase,
|
||||
widget_kind,
|
||||
)
|
||||
|
||||
|
||||
class AlertmanagerConfig(ServiceConfigBase):
|
||||
"""Non-secret Alertmanager connection config."""
|
||||
|
||||
base_url: str
|
||||
timeout_seconds: int = 5
|
||||
|
||||
|
||||
class AlertmanagerAlertsWidgetConfig(WidgetConfigBase):
|
||||
"""Active-alerts summary for an Alertmanager instance."""
|
||||
|
||||
severity_filter: str | None = None
|
||||
|
||||
|
||||
def summarize_alerts(
|
||||
alerts: list[dict[str, Any]],
|
||||
*,
|
||||
severity_filter: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a UI-friendly summary from an Alertmanager ``/api/v1/alerts`` list.
|
||||
|
||||
Reshapes the raw alert objects into a stable summary (``total``,
|
||||
``by_severity``, top-50 ``alerts``). When ``severity_filter`` is given, only
|
||||
alerts whose ``labels.severity`` matches are counted.
|
||||
"""
|
||||
by_severity: dict[str, int] = {}
|
||||
open_alerts: list[dict[str, Any]] = []
|
||||
for alert in alerts:
|
||||
labels = alert.get("labels") or {}
|
||||
annotations = alert.get("annotations") or {}
|
||||
severity = labels.get("severity", "unknown")
|
||||
if severity_filter and severity != severity_filter:
|
||||
continue
|
||||
by_severity[severity] = by_severity.get(severity, 0) + 1
|
||||
open_alerts.append(
|
||||
{
|
||||
"name": labels.get("alertname", "unknown"),
|
||||
"severity": severity,
|
||||
"category": labels.get("category", ""),
|
||||
"job_name": labels.get("job_name", labels.get("job", "")),
|
||||
"summary": annotations.get("summary", ""),
|
||||
"description": annotations.get("description", ""),
|
||||
"active_since": alert.get("startsAt"),
|
||||
"state": alert.get("status", "firing"),
|
||||
"labels": labels,
|
||||
}
|
||||
)
|
||||
open_alerts.sort(key=lambda a: (a["severity"] not in {"critical", "warning"}, a["severity"], a["name"]))
|
||||
return {
|
||||
"total": len(open_alerts),
|
||||
"by_severity": by_severity,
|
||||
"alerts": open_alerts[:50],
|
||||
}
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="alertmanager",
|
||||
name="Alertmanager",
|
||||
description="Alertmanager alerts and status.",
|
||||
config_model=AlertmanagerConfig,
|
||||
secret_fields=[
|
||||
SecretField(key="api_key", label="API key", helper="Optional bearer token"),
|
||||
],
|
||||
widget_kinds=[
|
||||
widget_kind(
|
||||
kind="active_alerts",
|
||||
name="Active alerts",
|
||||
description="Firing alerts summary from Alertmanager.",
|
||||
model_cls=AlertmanagerAlertsWidgetConfig,
|
||||
default_config={},
|
||||
refresh_interval_ms=30_000,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -6,6 +6,7 @@ There is no runtime plugin loading.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.alertmanager import DEFINITION as ALERTMANAGER
|
||||
from media_library_viewer_api.integrations.base import ServiceDefinition, WidgetKind
|
||||
from media_library_viewer_api.integrations.grafana import DEFINITION as GRAFANA
|
||||
from media_library_viewer_api.integrations.jellyfin import DEFINITION as JELLYFIN
|
||||
@@ -17,6 +18,7 @@ from media_library_viewer_api.integrations.ssh_tasks import DEFINITION as SSH_TA
|
||||
SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
|
||||
GRAFANA.service_type: GRAFANA,
|
||||
PROMETHEUS.service_type: PROMETHEUS,
|
||||
ALERTMANAGER.service_type: ALERTMANAGER,
|
||||
JELLYFIN.service_type: JELLYFIN,
|
||||
JELLYSEERR.service_type: JELLYSEERR,
|
||||
NEXTCLOUD.service_type: NEXTCLOUD,
|
||||
|
||||
@@ -33,32 +33,9 @@ def _webhook_client() -> Any:
|
||||
|
||||
def _summary_from_alerts(alerts: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Build a UI-friendly summary from Alertmanager /api/v1/alerts payload."""
|
||||
by_severity: dict[str, int] = {}
|
||||
open_alerts: list[dict[str, Any]] = []
|
||||
for alert in alerts:
|
||||
labels = alert.get("labels") or {}
|
||||
annotations = alert.get("annotations") or {}
|
||||
severity = labels.get("severity", "unknown")
|
||||
by_severity[severity] = by_severity.get(severity, 0) + 1
|
||||
open_alerts.append(
|
||||
{
|
||||
"name": labels.get("alertname", "unknown"),
|
||||
"severity": severity,
|
||||
"category": labels.get("category", ""),
|
||||
"job_name": labels.get("job_name", labels.get("job", "")),
|
||||
"summary": annotations.get("summary", ""),
|
||||
"description": annotations.get("description", ""),
|
||||
"active_since": alert.get("startsAt"),
|
||||
"state": alert.get("status", "firing"),
|
||||
"labels": labels,
|
||||
}
|
||||
)
|
||||
open_alerts.sort(key=lambda a: (a["severity"] not in {"critical", "warning"}, a["severity"], a["name"]))
|
||||
return {
|
||||
"total": len(open_alerts),
|
||||
"by_severity": by_severity,
|
||||
"alerts": open_alerts[:50],
|
||||
}
|
||||
from media_library_viewer_api.integrations.alertmanager import summarize_alerts
|
||||
|
||||
return summarize_alerts(alerts)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/monitoring", tags=["monitoring"])
|
||||
|
||||
@@ -22,6 +22,7 @@ from media_library_viewer_api.domain.dashboard import (
|
||||
_map_sessions_to_activity_rows,
|
||||
build_backup_dashboard_summary,
|
||||
)
|
||||
from media_library_viewer_api.integrations.alertmanager import summarize_alerts
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore, get_settings_store
|
||||
from media_library_viewer_api.services.task_runner import run_saved_task
|
||||
|
||||
@@ -151,6 +152,43 @@ class PrometheusWidgetSource:
|
||||
return {"error": f"Prometheus query failed: {exc}"}
|
||||
|
||||
|
||||
class AlertmanagerWidgetSource:
|
||||
"""Fetch firing alerts from an Alertmanager service and summarize them."""
|
||||
|
||||
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
if service is None:
|
||||
return {"error": "Alertmanager widget is missing its service"}
|
||||
base_url = str(service.config.get("base_url") or "").rstrip("/")
|
||||
timeout = int(service.config.get("timeout_seconds") or 5)
|
||||
severity_filter = config.get("severity_filter") or None
|
||||
headers: dict[str, str] = {}
|
||||
api_key = str(service.secrets.get("api_key") or "")
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
response = await asyncio.wait_for(
|
||||
asyncio.to_thread(
|
||||
requests.get,
|
||||
f"{base_url}/api/v1/alerts",
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
),
|
||||
timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
alerts = payload.get("data", []) if isinstance(payload, dict) else []
|
||||
return summarize_alerts(alerts, severity_filter=severity_filter)
|
||||
except asyncio.TimeoutError:
|
||||
return {"error": "Widget data fetch timed out"}
|
||||
except requests.RequestException as exc:
|
||||
logger.exception("alertmanager adapter failed")
|
||||
return {"error": f"Alertmanager query failed: {exc}"}
|
||||
except Exception as exc:
|
||||
logger.exception("alertmanager adapter failed")
|
||||
return {"error": f"Alertmanager query failed: {exc}"}
|
||||
|
||||
|
||||
class JellyfinWidgetSource:
|
||||
"""Fetch Jellyfin sessions and map them to activity rows."""
|
||||
|
||||
@@ -234,6 +272,7 @@ def _record_timeout(service: ServiceRecord | None, config: dict[str, Any], timeo
|
||||
SERVICE_ADAPTERS: dict[str, WidgetSource] = {
|
||||
"grafana": GrafanaWidgetSource(),
|
||||
"prometheus": PrometheusWidgetSource(),
|
||||
"alertmanager": AlertmanagerWidgetSource(),
|
||||
"jellyfin": JellyfinWidgetSource(),
|
||||
"ssh_tasks": SshTaskWidgetSource(),
|
||||
}
|
||||
|
||||
@@ -56,10 +56,11 @@ def client(tmp_path):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_registry_contains_five_service_types():
|
||||
def test_registry_contains_seven_service_types():
|
||||
assert set(SERVICE_DEFINITIONS) == {
|
||||
"grafana",
|
||||
"prometheus",
|
||||
"alertmanager",
|
||||
"jellyfin",
|
||||
"jellyseerr",
|
||||
"nextcloud",
|
||||
@@ -70,6 +71,7 @@ def test_registry_contains_five_service_types():
|
||||
def test_definitions_declare_widget_kinds():
|
||||
assert {wk.kind for wk in get_service_definition("grafana").widget_kinds} == {"link"}
|
||||
assert {wk.kind for wk in get_service_definition("prometheus").widget_kinds} == {"metric"}
|
||||
assert {wk.kind for wk in get_service_definition("alertmanager").widget_kinds} == {"active_alerts"}
|
||||
assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity"}
|
||||
assert get_service_definition("nextcloud").widget_kinds == []
|
||||
assert {wk.kind for wk in get_service_definition("ssh_tasks").widget_kinds} == {"task_output"}
|
||||
@@ -135,6 +137,7 @@ def test_list_service_types(client):
|
||||
assert response.status_code == 200
|
||||
types = {item["service_type"] for item in response.json()}
|
||||
assert types == {
|
||||
"alertmanager",
|
||||
"grafana",
|
||||
"jellyfin",
|
||||
"jellyseerr",
|
||||
|
||||
@@ -13,6 +13,7 @@ from media_library_viewer_api.dependencies import get_settings_store
|
||||
from media_library_viewer_api.main import app
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.widgets.sources import (
|
||||
AlertmanagerWidgetSource,
|
||||
BackupsWidgetSource,
|
||||
GrafanaWidgetSource,
|
||||
ServiceRecord,
|
||||
@@ -324,6 +325,78 @@ async def test_grafana_adapter_missing_service():
|
||||
assert "error" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alertmanager_adapter_summarizes_alerts():
|
||||
adapter = AlertmanagerWidgetSource()
|
||||
service = ServiceRecord(id="s", service_type="alertmanager", name="am", config={"base_url": "http://am:9093"})
|
||||
payload = SimpleNamespace(
|
||||
raise_for_status=lambda: None,
|
||||
json=lambda: {
|
||||
"status": "success",
|
||||
"data": [
|
||||
{
|
||||
"labels": {"alertname": "DiskFull", "severity": "critical"},
|
||||
"annotations": {"summary": "disk full"},
|
||||
"startsAt": "2026-06-23T00:00:00Z",
|
||||
"status": "firing",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
|
||||
result = await adapter.fetch(service, "active_alerts", {})
|
||||
assert result["total"] == 1
|
||||
assert result["by_severity"]["critical"] == 1
|
||||
assert result["alerts"][0]["name"] == "DiskFull"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alertmanager_adapter_applies_severity_filter():
|
||||
adapter = AlertmanagerWidgetSource()
|
||||
service = ServiceRecord(id="s", service_type="alertmanager", name="am", config={"base_url": "http://am:9093"})
|
||||
payload = SimpleNamespace(
|
||||
raise_for_status=lambda: None,
|
||||
json=lambda: {
|
||||
"status": "success",
|
||||
"data": [
|
||||
{"labels": {"alertname": "A", "severity": "critical"}, "annotations": {}, "status": "firing"},
|
||||
{"labels": {"alertname": "B", "severity": "warning"}, "annotations": {}, "status": "firing"},
|
||||
],
|
||||
},
|
||||
)
|
||||
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
|
||||
result = await adapter.fetch(service, "active_alerts", {"severity_filter": "critical"})
|
||||
assert result["total"] == 1
|
||||
assert result["alerts"][0]["name"] == "A"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alertmanager_adapter_sends_bearer_token():
|
||||
adapter = AlertmanagerWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s",
|
||||
service_type="alertmanager",
|
||||
name="am",
|
||||
config={"base_url": "http://am:9093"},
|
||||
secrets={"api_key": "tok"},
|
||||
)
|
||||
payload = SimpleNamespace(
|
||||
raise_for_status=lambda: None, json=lambda: {"status": "success", "data": []}
|
||||
)
|
||||
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload) as mock_get:
|
||||
result = await adapter.fetch(service, "active_alerts", {})
|
||||
assert result["total"] == 0
|
||||
_, kwargs = mock_get.call_args
|
||||
assert kwargs["headers"]["Authorization"] == "Bearer tok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alertmanager_adapter_missing_service():
|
||||
adapter = AlertmanagerWidgetSource()
|
||||
result = await adapter.fetch(None, "active_alerts", {})
|
||||
assert "error" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_static_adapter():
|
||||
adapter = StaticWidgetSource()
|
||||
|
||||
Reference in New Issue
Block a user