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
+73
View File
@@ -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()