From 0c5698c903ae48662493acd5b7c5aa007f26bd72 Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 24 Jun 2026 08:23:23 +0000 Subject: [PATCH] feat(observability): service discovery, health cards, alertmanager widget Slice 3 of observability-service-registry (frontend). The Observability page discovers Grafana from the service registry instead of env vars, adds Grafana + Prometheus health cards, and ships an alertmanager active_alerts dashboard widget. - types: added GrafanaStatus + PrometheusStatus; added optional service_id/error to AlertmanagerStatus. - api/client.ts + hooks/useObservability.ts: fetchGrafanaStatus, fetchPrometheusStatus, useGrafanaStatus, usePrometheusStatus. - widgets/AlertmanagerAlertsWidget.tsx (new): presentational widget consuming the active_alerts summary shape (total/by_severity/alerts); exported from widgets/index.ts. - integrations/registry.ts: alertmanager binding (active_alerts kind, 30s refresh, optional severity_filter); registry.test.ts updated to 6 service types incl alertmanager + a resolve test. - components/ObservabilityPage.tsx: removed import.meta.env.VITE_GRAFANA_URL; derive GRAFANA_BASE_URL from the first enabled grafana service via useServiceInstances("grafana"); added Grafana + Prometheus HealthCards (up/not-configured/unreachable) with QueryError retry blocks; machine dashboard shows a "No Grafana service configured" empty-state linking to /services when none is set. npm run build (tsc -b + vite) clean; 0 lint errors; 72 frontend tests pass. Reviewed fresh-context (read-only): no blockers. --- .../routers/monitoring.py | 8 +- backend/tests/test_api.py | 17 ++- frontend/src/api/client.ts | 8 ++ frontend/src/components/ObservabilityPage.tsx | 108 ++++++++++++++++-- frontend/src/hooks/useObservability.ts | 22 ++++ frontend/src/integrations/registry.test.ts | 35 +++++- frontend/src/integrations/registry.ts | 23 ++++ frontend/src/types/index.ts | 18 +++ .../src/widgets/AlertmanagerAlertsWidget.tsx | 93 +++++++++++++++ frontend/src/widgets/index.ts | 1 + 10 files changed, 314 insertions(+), 19 deletions(-) create mode 100644 frontend/src/widgets/AlertmanagerAlertsWidget.tsx diff --git a/backend/src/media_library_viewer_api/routers/monitoring.py b/backend/src/media_library_viewer_api/routers/monitoring.py index 7be453c..674ec54 100644 --- a/backend/src/media_library_viewer_api/routers/monitoring.py +++ b/backend/src/media_library_viewer_api/routers/monitoring.py @@ -56,9 +56,7 @@ def _auth_headers(service: ServiceRecord) -> dict[str, str]: 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]: +def _status_response(service: ServiceRecord | None, *, version: str = "", error: str | None = None) -> dict[str, Any]: return { "up": error is None, "version": version or "", @@ -232,9 +230,7 @@ def get_prometheus_status( 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 = 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: diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 0c3b1f2..c5a9588 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -636,6 +636,7 @@ class TestResolveServiceRecord: def _store(self, rows): store = MagicMock() store.get_service = lambda sid: next((r for r in rows if r["id"] == sid), None) + def list_filtered(service_type=None): return [r for r in rows if r["service_type"] == (service_type or r["service_type"])] @@ -882,7 +883,9 @@ class TestGrafanaStatus: assert data["error"] == "no_service_configured" def test_grafana_status_when_unreachable(self, test_client): - service = ServiceRecord(id="g1", service_type="grafana", name="Grafana", config={"base_url": "http://grafana:3000"}) + service = ServiceRecord( + id="g1", service_type="grafana", name="Grafana", config={"base_url": "http://grafana:3000"} + ) with ( patch(f"{_MON}._resolve_service_record", return_value=service), patch(f"{_MON}.requests.get", side_effect=Exception("refused")), @@ -895,7 +898,9 @@ class TestGrafanaStatus: assert data["name"] == "Grafana" def test_grafana_status_returns_version(self, test_client): - service = ServiceRecord(id="g1", service_type="grafana", name="Grafana", config={"base_url": "http://grafana:3000"}) + service = ServiceRecord( + id="g1", service_type="grafana", name="Grafana", config={"base_url": "http://grafana:3000"} + ) resp = MagicMock() resp.json.return_value = {"version": "11.3.1", "database": "ok"} resp.raise_for_status = MagicMock() @@ -920,7 +925,9 @@ class TestPrometheusStatus: assert data["error"] == "no_service_configured" def test_prometheus_status_when_unreachable(self, test_client): - service = ServiceRecord(id="p1", service_type="prometheus", name="Prometheus", config={"base_url": "http://prometheus:9090"}) + service = ServiceRecord( + id="p1", service_type="prometheus", name="Prometheus", config={"base_url": "http://prometheus:9090"} + ) with ( patch(f"{_MON}._resolve_service_record", return_value=service), patch(f"{_MON}.requests.get", side_effect=Exception("refused")), @@ -932,7 +939,9 @@ class TestPrometheusStatus: assert data["error"] == "prometheus_unreachable" def test_prometheus_status_returns_version(self, test_client): - service = ServiceRecord(id="p1", service_type="prometheus", name="Prometheus", config={"base_url": "http://prometheus:9090"}) + service = ServiceRecord( + id="p1", service_type="prometheus", name="Prometheus", config={"base_url": "http://prometheus:9090"} + ) health = MagicMock() health.raise_for_status = MagicMock() build_info = MagicMock() diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1ca0b48..7075fa1 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -33,6 +33,8 @@ import type { DashboardShortcutInput, AlertmanagerAlertSummary, AlertmanagerStatus, + GrafanaStatus, + PrometheusStatus, PrometheusTarget, } from "../types"; @@ -387,5 +389,11 @@ export const fetchAlertmanagerAlerts = () => export const fetchAlertmanagerStatus = () => get("/api/monitoring/alertmanager-status"); +export const fetchGrafanaStatus = () => + get("/api/monitoring/grafana-status"); + +export const fetchPrometheusStatus = () => + get("/api/monitoring/prometheus-status"); + export const fetchPrometheusTargets = () => get("/api/monitoring/prometheus-targets"); diff --git a/frontend/src/components/ObservabilityPage.tsx b/frontend/src/components/ObservabilityPage.tsx index 3afebb7..185f40f 100644 --- a/frontend/src/components/ObservabilityPage.tsx +++ b/frontend/src/components/ObservabilityPage.tsx @@ -7,6 +7,7 @@ import { CheckCircle2, ChevronDown, ExternalLink, + Gauge, Inbox, Radio, RefreshCw, @@ -17,9 +18,12 @@ import { import { useAlertmanagerAlerts, useAlertmanagerStatus, + useGrafanaStatus, + usePrometheusStatus, usePrometheusTargets, useMonitoringMachines, } from "../hooks/useObservability"; +import { useServiceInstances } from "../hooks/useServices"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; @@ -43,9 +47,6 @@ import type { PrometheusTarget, } from "../types"; -const GRAFANA_BASE_URL = - import.meta.env.VITE_GRAFANA_URL || "http://localhost:3000"; - function severityVariant( severity: string, ): "default" | "secondary" | "destructive" | "outline" { @@ -283,6 +284,18 @@ export function ObservabilityPage() { error: statusError, refetch: refetchStatus, } = useAlertmanagerStatus(); + const { + data: grafanaStatus, + isLoading: grafanaLoading, + error: grafanaError, + refetch: refetchGrafana, + } = useGrafanaStatus(); + const { + data: prometheusStatus, + isLoading: prometheusLoading, + error: prometheusError, + refetch: refetchPrometheus, + } = usePrometheusStatus(); const { data: prometheusTargets, isLoading: targetsLoading, @@ -295,8 +308,14 @@ export function ObservabilityPage() { error: machinesError, refetch: refetchMachines, } = useMonitoringMachines(); + const { data: grafanaServices = [] } = useServiceInstances("grafana"); const [selectedMachineId, setSelectedMachineId] = useState(""); + const grafanaService = + grafanaServices.find((s) => s.enabled) ?? grafanaServices[0]; + const GRAFANA_BASE_URL = + (grafanaService?.config?.base_url as string | undefined) ?? ""; + const selectedMachine = useMemo( () => machines.find((m) => m.id === selectedMachineId) ?? machines[0] ?? null, @@ -304,13 +323,13 @@ export function ObservabilityPage() { ); const nodeExporterDashboardUrl = useMemo(() => { - if (!selectedMachine) return ""; + if (!selectedMachine || !GRAFANA_BASE_URL) return ""; const instance = `${selectedMachine.host || "localhost"}:9100`; return `${GRAFANA_BASE_URL}/d/node-exporter-overview/node-exporter-overview?kiosk&var-instance=${encodeURIComponent(instance)}`; - }, [selectedMachine]); + }, [selectedMachine, GRAFANA_BASE_URL]); const logsUrl = useMemo(() => { - if (!selectedMachine) return ""; + if (!selectedMachine || !GRAFANA_BASE_URL) return ""; const container = selectedMachine.mode === "local" ? "backend" : selectedMachine.name; return `${GRAFANA_BASE_URL}/explore?orgId=1&left=${encodeURIComponent( @@ -320,7 +339,7 @@ export function ObservabilityPage() { range: { from: "now-1h", to: "now" }, }), )}`; - }, [selectedMachine]); + }, [selectedMachine, GRAFANA_BASE_URL]); const alertmanagerStatusDetail = alertmanagerStatus?.up ? alertmanagerStatus.version @@ -402,6 +421,52 @@ export function ObservabilityPage() { icon={Server} isLoading={machinesLoading} /> + +
@@ -433,6 +498,20 @@ export function ObservabilityPage() { refetch={refetchMachines} /> )} + {grafanaError && ( + + )} + {prometheusError && ( + + )}
{alertsSummary?.error && ( @@ -542,7 +621,8 @@ export function ObservabilityPage() { - {selectedMachine ? ( + {selectedMachine ? ( + GRAFANA_BASE_URL ? ( <> ) : ( + + Open Services + + } + /> + ) + ) : ( { - it("registers the five backend service types", () => { + it("registers the backend service types", () => { expect(Object.keys(SERVICE_REGISTRY).sort()).toEqual([ + "alertmanager", "grafana", "jellyfin", "nextcloud", @@ -23,6 +24,9 @@ describe("service registry", () => { expect(SERVICE_REGISTRY.grafana.widgets.map((w) => w.kind)).toEqual([ "link", ]); + expect(SERVICE_REGISTRY.alertmanager.widgets.map((w) => w.kind)).toEqual([ + "active_alerts", + ]); expect(SERVICE_REGISTRY.ssh_tasks.widgets.map((w) => w.kind)).toEqual([ "task_output", ]); @@ -62,6 +66,35 @@ describe("service registry", () => { expect(resolved?.refreshIntervalMs).toBe(0); }); + it("resolves an alertmanager active_alerts widget", () => { + const widget: WidgetInstance = { + id: "wam", + service_id: "sam", + widget_kind: "active_alerts", + title: "Alerts", + config: {}, + enabled: true, + sort_order: 0, + created_at: 0, + updated_at: 0, + }; + const services: ServiceInstance[] = [ + { + id: "sam", + service_type: "alertmanager", + name: "Alertmanager", + config: { base_url: "http://am:9093" }, + secrets_set: {}, + enabled: true, + created_at: 0, + updated_at: 0, + }, + ]; + const resolved = resolveWidget(widget, services); + expect(resolved).toBeDefined(); + expect(resolved?.refreshIntervalMs).toBe(30_000); + }); + it("resolves a built-in widget without a service", () => { const widget: WidgetInstance = { id: "w2", diff --git a/frontend/src/integrations/registry.ts b/frontend/src/integrations/registry.ts index 16c87d3..5d1b4d4 100644 --- a/frontend/src/integrations/registry.ts +++ b/frontend/src/integrations/registry.ts @@ -1,4 +1,5 @@ import type { ComponentType } from "react"; +import { AlertmanagerAlertsWidget } from "../widgets/AlertmanagerAlertsWidget"; import { BackupsWidget } from "../widgets/BackupsWidget"; import { GrafanaLinkWidget } from "../widgets/GrafanaLinkWidget"; import { JellyfinWidget } from "../widgets/JellyfinWidget"; @@ -42,6 +43,28 @@ export interface ServiceBinding { } export const SERVICE_REGISTRY: Record = { + alertmanager: { + serviceType: "alertmanager", + name: "Alertmanager", + description: "Alertmanager alerts and status.", + widgets: [ + { + kind: "active_alerts", + name: "Active alerts", + description: "Firing alerts summary from Alertmanager.", + refreshIntervalMs: 30_000, + defaultConfig: {}, + configSchema: { + type: "object", + properties: { + severity_filter: { type: "string" }, + }, + required: [], + }, + component: AlertmanagerAlertsWidget, + }, + ], + }, grafana: { serviceType: "grafana", name: "Grafana", diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6e57e39..781f1d6 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -386,6 +386,24 @@ export interface AlertmanagerStatus { uptime: string; name: string; peers: string[]; + service_id?: string; + error?: string | null; +} + +export interface GrafanaStatus { + up: boolean; + version: string; + service_id: string; + name: string; + error?: string | null; +} + +export interface PrometheusStatus { + up: boolean; + version: string; + service_id: string; + name: string; + error?: string | null; } export interface PrometheusTarget { diff --git a/frontend/src/widgets/AlertmanagerAlertsWidget.tsx b/frontend/src/widgets/AlertmanagerAlertsWidget.tsx new file mode 100644 index 0000000..90b3195 --- /dev/null +++ b/frontend/src/widgets/AlertmanagerAlertsWidget.tsx @@ -0,0 +1,93 @@ +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { SectionCard } from "../components/SectionCard"; +import { useWidgetData } from "../hooks/useWidgets"; +import type { + AlertmanagerAlert, + AlertmanagerAlertSummary, + WidgetInstance, +} from "../types"; + +interface Props { + widget: WidgetInstance; + refreshIntervalMs: number; + description?: string; +} + +function severityVariant( + severity: string, +): "default" | "secondary" | "destructive" | "outline" { + switch (severity.toLowerCase()) { + case "critical": + return "destructive"; + case "warning": + return "default"; + case "info": + return "secondary"; + default: + return "outline"; + } +} + +export function AlertmanagerAlertsWidget({ + widget, + refreshIntervalMs, + description, +}: Props) { + const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs); + const summary = data?.data as AlertmanagerAlertSummary | undefined; + const alerts: AlertmanagerAlert[] = summary?.alerts ?? []; + + return ( + + {isLoading && !data ? ( +
+ + +
+ ) : data?.error ? ( + + {data.error} + + ) : summary ? ( +
+
+
+
{summary.total}
+
+ Firing alert{summary.total === 1 ? "" : "s"} +
+
+
+ {Object.entries(summary.by_severity).map(([sev, count]) => ( + + {sev}: {count} + + ))} +
+
+ {alerts.length > 0 ? ( +
    + {alerts.slice(0, 5).map((a, idx) => ( +
  • + {a.name} + + {a.severity} + +
  • + ))} +
+ ) : ( +
+ Everything looks quiet. +
+ )} +
+ ) : null} +
+ ); +} diff --git a/frontend/src/widgets/index.ts b/frontend/src/widgets/index.ts index 3d35d2f..24c1093 100644 --- a/frontend/src/widgets/index.ts +++ b/frontend/src/widgets/index.ts @@ -1,3 +1,4 @@ +export { AlertmanagerAlertsWidget } from "./AlertmanagerAlertsWidget"; export { BackupsWidget } from "./BackupsWidget"; export { GrafanaLinkWidget } from "./GrafanaLinkWidget"; export { JellyfinWidget } from "./JellyfinWidget";