diff --git a/backend/src/media_library_viewer_api/integrations/alertmanager.py b/backend/src/media_library_viewer_api/integrations/alertmanager.py new file mode 100644 index 0000000..d4d86ba --- /dev/null +++ b/backend/src/media_library_viewer_api/integrations/alertmanager.py @@ -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, + ), + ], +) diff --git a/backend/src/media_library_viewer_api/integrations/registry.py b/backend/src/media_library_viewer_api/integrations/registry.py index 6aaf946..a018c51 100644 --- a/backend/src/media_library_viewer_api/integrations/registry.py +++ b/backend/src/media_library_viewer_api/integrations/registry.py @@ -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, diff --git a/backend/src/media_library_viewer_api/routers/monitoring.py b/backend/src/media_library_viewer_api/routers/monitoring.py index 1d70143..b9ebada 100644 --- a/backend/src/media_library_viewer_api/routers/monitoring.py +++ b/backend/src/media_library_viewer_api/routers/monitoring.py @@ -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"]) diff --git a/backend/src/media_library_viewer_api/widgets/sources.py b/backend/src/media_library_viewer_api/widgets/sources.py index d67967f..10a7ccc 100644 --- a/backend/src/media_library_viewer_api/widgets/sources.py +++ b/backend/src/media_library_viewer_api/widgets/sources.py @@ -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(), } diff --git a/backend/tests/test_services.py b/backend/tests/test_services.py index 6abbcd6..a7676ec 100644 --- a/backend/tests/test_services.py +++ b/backend/tests/test_services.py @@ -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", diff --git a/backend/tests/test_widgets.py b/backend/tests/test_widgets.py index 68736fa..99119d2 100644 --- a/backend/tests/test_widgets.py +++ b/backend/tests/test_widgets.py @@ -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()