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
@@ -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,