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