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:
Developer
2026-06-23 22:25:50 +00:00
parent c13e274ca4
commit 7d49df3e7d
6 changed files with 209 additions and 27 deletions
@@ -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(),
}