691d78ff06
Extract the duplicated _resolve_service_record helper (identical in routers/monitoring.py and routers/authentik_users.py) into a shared services/service_resolution.py module. Both routers now import resolve_service_record from the shared module. The authentik router previously hardcoded service_type='authentik' in its local copy; the shared helper takes service_type as a param (same as monitoring's did). Tests updated: test_api.py patches now target the correct module paths (resolve_service_record on the monitoring module where it's imported, build_service_record on the service_resolution module). 283 backend tests pass; ruff clean.
238 lines
8.5 KiB
Python
238 lines
8.5 KiB
Python
"""Monitoring router — observability service status.
|
|
|
|
Observability components (Alertmanager, Grafana, Prometheus) are resolved from
|
|
the service registry, not environment variables. The endpoints pick the first
|
|
enabled instance of a type when no ``service_id`` is given, and return graceful
|
|
"not configured" / "unreachable" payloads so the UI always renders a health card.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
import requests
|
|
from fastapi import APIRouter, Body, Depends
|
|
|
|
from media_library_viewer_api.dependencies import get_settings_store
|
|
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
|
from media_library_viewer_api.services.targets import build_node_exporter_targets
|
|
from media_library_viewer_api.widgets.sources import ServiceRecord
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _base_url(service: ServiceRecord) -> str:
|
|
return str(service.config.get("base_url") or "").rstrip("/")
|
|
|
|
|
|
def _timeout(service: ServiceRecord, default: int) -> int:
|
|
return int(service.config.get("timeout_seconds") or default)
|
|
|
|
|
|
def _auth_headers(service: ServiceRecord) -> dict[str, str]:
|
|
api_key = str(service.secrets.get("api_key") or "")
|
|
return {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
|
|
|
|
|
def _status_response(service: ServiceRecord | None, *, version: str = "", error: str | None = None) -> dict[str, Any]:
|
|
return {
|
|
"up": error is None,
|
|
"version": version or "",
|
|
"service_id": service.id if service else "",
|
|
"name": service.name if service else "",
|
|
"error": error,
|
|
}
|
|
|
|
|
|
def _summary_from_alerts(alerts: list[dict[str, Any]]) -> dict[str, Any]:
|
|
"""Build a UI-friendly summary from Alertmanager /api/v1/alerts payload."""
|
|
from media_library_viewer_api.integrations.alertmanager import summarize_alerts
|
|
|
|
return summarize_alerts(alerts)
|
|
|
|
|
|
router = APIRouter(prefix="/api/monitoring", tags=["monitoring"])
|
|
|
|
|
|
@router.get("/machines")
|
|
def get_machines(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
|
|
"""Return enabled monitoring machines for the UI."""
|
|
return [m for m in store.list_machines() if m.get("enabled")]
|
|
|
|
|
|
@router.get("/prometheus-targets")
|
|
def get_prometheus_targets(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
|
|
"""Return Prometheus scrape targets for remote Node Exporters.
|
|
|
|
External Prometheus instances consume this list via ``http_sd_configs``.
|
|
"""
|
|
targets = build_node_exporter_targets(store)
|
|
logger.info("Prometheus targets requested count=%s", len(targets))
|
|
return targets
|
|
|
|
|
|
@router.get("/alerts")
|
|
def get_alertmanager_alerts(
|
|
service_id: str | None = None,
|
|
store: SettingsStore = Depends(get_settings_store),
|
|
) -> dict[str, Any]:
|
|
"""Return a summary of active Alertmanager alerts for the UI.
|
|
|
|
Resolves an ``alertmanager`` service instance from the registry. When none
|
|
is configured the endpoint returns an empty summary with an
|
|
``alertmanager_not_configured`` error so the UI can render a health card.
|
|
"""
|
|
service = resolve_service_record(store, "alertmanager", service_id)
|
|
if service is None:
|
|
return {"total": 0, "by_severity": {}, "alerts": [], "error": "alertmanager_not_configured"}
|
|
try:
|
|
response = requests.get(
|
|
f"{_base_url(service)}/api/v1/alerts",
|
|
headers=_auth_headers(service),
|
|
timeout=_timeout(service, 5),
|
|
)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
except Exception:
|
|
logger.exception("Failed to fetch Alertmanager alerts")
|
|
return {
|
|
"total": 0,
|
|
"by_severity": {},
|
|
"alerts": [],
|
|
"error": "alertmanager_unreachable",
|
|
"service_id": service.id,
|
|
"name": service.name,
|
|
}
|
|
|
|
if data.get("status") != "success":
|
|
return {
|
|
"total": 0,
|
|
"by_severity": {},
|
|
"alerts": [],
|
|
"error": data.get("error", "unknown"),
|
|
"service_id": service.id,
|
|
"name": service.name,
|
|
}
|
|
|
|
summary = _summary_from_alerts(data.get("data", []))
|
|
summary["service_id"] = service.id
|
|
summary["name"] = service.name
|
|
logger.info("Alertmanager alerts requested total=%s", summary["total"])
|
|
return summary
|
|
|
|
|
|
@router.get("/alertmanager-status")
|
|
def get_alertmanager_status(
|
|
service_id: str | None = None,
|
|
store: SettingsStore = Depends(get_settings_store),
|
|
) -> dict[str, Any]:
|
|
"""Return Alertmanager cluster/status for the UI health card."""
|
|
service = resolve_service_record(store, "alertmanager", service_id)
|
|
if service is None:
|
|
return {
|
|
"up": False,
|
|
"version": "",
|
|
"uptime": "",
|
|
"name": "",
|
|
"peers": [],
|
|
"error": "alertmanager_not_configured",
|
|
}
|
|
try:
|
|
response = requests.get(
|
|
f"{_base_url(service)}/api/v2/status",
|
|
headers=_auth_headers(service),
|
|
timeout=_timeout(service, 5),
|
|
)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
except Exception:
|
|
logger.exception("Failed to fetch Alertmanager status")
|
|
return {
|
|
"up": False,
|
|
"version": "",
|
|
"uptime": "",
|
|
"name": service.name,
|
|
"peers": [],
|
|
"service_id": service.id,
|
|
"error": "alertmanager_unreachable",
|
|
}
|
|
|
|
cluster = data.get("cluster") or {}
|
|
status = data.get("clusterStatus") or {}
|
|
return {
|
|
"up": True,
|
|
"version": data.get("versionInfo", {}).get("version", ""),
|
|
"uptime": status.get("createdAt", ""),
|
|
"name": service.name,
|
|
"peers": [p.get("name", "") for p in cluster.get("peers", [])],
|
|
"service_id": service.id,
|
|
"error": None,
|
|
}
|
|
|
|
|
|
@router.get("/grafana-status")
|
|
def get_grafana_status(
|
|
service_id: str | None = None,
|
|
store: SettingsStore = Depends(get_settings_store),
|
|
) -> dict[str, Any]:
|
|
"""Probe a Grafana service instance's ``/api/health`` endpoint."""
|
|
service = resolve_service_record(store, "grafana", service_id)
|
|
if service is None:
|
|
return _status_response(None, error="no_service_configured")
|
|
try:
|
|
response = requests.get(
|
|
f"{_base_url(service)}/api/health",
|
|
headers=_auth_headers(service),
|
|
timeout=_timeout(service, 5),
|
|
)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
except Exception:
|
|
logger.exception("Failed to fetch Grafana status")
|
|
return _status_response(service, error="grafana_unreachable")
|
|
return _status_response(service, version=data.get("version", ""))
|
|
|
|
|
|
@router.get("/prometheus-status")
|
|
def get_prometheus_status(
|
|
service_id: str | None = None,
|
|
store: SettingsStore = Depends(get_settings_store),
|
|
) -> dict[str, Any]:
|
|
"""Probe a Prometheus service instance's health and build info."""
|
|
service = resolve_service_record(store, "prometheus", service_id)
|
|
if service is None:
|
|
return _status_response(None, error="no_service_configured")
|
|
base = _base_url(service)
|
|
timeout = _timeout(service, 10)
|
|
headers = _auth_headers(service)
|
|
try:
|
|
health = requests.get(f"{base}/-/healthy", headers=headers, timeout=timeout)
|
|
health.raise_for_status()
|
|
build_info = requests.get(f"{base}/api/v1/status/buildinfo", headers=headers, timeout=timeout)
|
|
build_info.raise_for_status()
|
|
version = build_info.json().get("data", {}).get("version", "")
|
|
except Exception:
|
|
logger.exception("Failed to fetch Prometheus status")
|
|
return _status_response(service, error="prometheus_unreachable")
|
|
return _status_response(service, version=version)
|
|
|
|
|
|
@router.post("/alertmanager-webhook")
|
|
def receive_alertmanager_webhook(payload: dict[str, Any] = Body(...)) -> dict[str, str]:
|
|
"""Receive alerts from Alertmanager and log them for audit/debug.
|
|
|
|
This endpoint is the receiver referenced by the optional ``webhook_configs``
|
|
block in Alertmanager. It is log-only: received payloads are recorded but not
|
|
forwarded anywhere. (The previous outbound relay to ``ALERTMANAGER_WEBHOOK_URL``
|
|
was removed when observability became service-registry configured.)
|
|
"""
|
|
alerts = payload.get("alerts", [])
|
|
logger.info(
|
|
"Received Alertmanager webhook with %s alert(s), status=%s",
|
|
len(alerts),
|
|
payload.get("status", "unknown"),
|
|
)
|
|
return {"status": "received"}
|