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.
This commit is contained in:
@@ -56,9 +56,7 @@ def _auth_headers(service: ServiceRecord) -> dict[str, str]:
|
|||||||
return {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
return {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
||||||
|
|
||||||
|
|
||||||
def _status_response(
|
def _status_response(service: ServiceRecord | None, *, version: str = "", error: str | None = None) -> dict[str, Any]:
|
||||||
service: ServiceRecord | None, *, version: str = "", error: str | None = None
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return {
|
return {
|
||||||
"up": error is None,
|
"up": error is None,
|
||||||
"version": version or "",
|
"version": version or "",
|
||||||
@@ -232,9 +230,7 @@ def get_prometheus_status(
|
|||||||
try:
|
try:
|
||||||
health = requests.get(f"{base}/-/healthy", headers=headers, timeout=timeout)
|
health = requests.get(f"{base}/-/healthy", headers=headers, timeout=timeout)
|
||||||
health.raise_for_status()
|
health.raise_for_status()
|
||||||
build_info = requests.get(
|
build_info = requests.get(f"{base}/api/v1/status/buildinfo", headers=headers, timeout=timeout)
|
||||||
f"{base}/api/v1/status/buildinfo", headers=headers, timeout=timeout
|
|
||||||
)
|
|
||||||
build_info.raise_for_status()
|
build_info.raise_for_status()
|
||||||
version = build_info.json().get("data", {}).get("version", "")
|
version = build_info.json().get("data", {}).get("version", "")
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -636,6 +636,7 @@ class TestResolveServiceRecord:
|
|||||||
def _store(self, rows):
|
def _store(self, rows):
|
||||||
store = MagicMock()
|
store = MagicMock()
|
||||||
store.get_service = lambda sid: next((r for r in rows if r["id"] == sid), None)
|
store.get_service = lambda sid: next((r for r in rows if r["id"] == sid), None)
|
||||||
|
|
||||||
def list_filtered(service_type=None):
|
def list_filtered(service_type=None):
|
||||||
return [r for r in rows if r["service_type"] == (service_type or r["service_type"])]
|
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"
|
assert data["error"] == "no_service_configured"
|
||||||
|
|
||||||
def test_grafana_status_when_unreachable(self, test_client):
|
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 (
|
with (
|
||||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||||
patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
|
patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
|
||||||
@@ -895,7 +898,9 @@ class TestGrafanaStatus:
|
|||||||
assert data["name"] == "Grafana"
|
assert data["name"] == "Grafana"
|
||||||
|
|
||||||
def test_grafana_status_returns_version(self, test_client):
|
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 = MagicMock()
|
||||||
resp.json.return_value = {"version": "11.3.1", "database": "ok"}
|
resp.json.return_value = {"version": "11.3.1", "database": "ok"}
|
||||||
resp.raise_for_status = MagicMock()
|
resp.raise_for_status = MagicMock()
|
||||||
@@ -920,7 +925,9 @@ class TestPrometheusStatus:
|
|||||||
assert data["error"] == "no_service_configured"
|
assert data["error"] == "no_service_configured"
|
||||||
|
|
||||||
def test_prometheus_status_when_unreachable(self, test_client):
|
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 (
|
with (
|
||||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||||
patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
|
patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
|
||||||
@@ -932,7 +939,9 @@ class TestPrometheusStatus:
|
|||||||
assert data["error"] == "prometheus_unreachable"
|
assert data["error"] == "prometheus_unreachable"
|
||||||
|
|
||||||
def test_prometheus_status_returns_version(self, test_client):
|
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 = MagicMock()
|
||||||
health.raise_for_status = MagicMock()
|
health.raise_for_status = MagicMock()
|
||||||
build_info = MagicMock()
|
build_info = MagicMock()
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import type {
|
|||||||
DashboardShortcutInput,
|
DashboardShortcutInput,
|
||||||
AlertmanagerAlertSummary,
|
AlertmanagerAlertSummary,
|
||||||
AlertmanagerStatus,
|
AlertmanagerStatus,
|
||||||
|
GrafanaStatus,
|
||||||
|
PrometheusStatus,
|
||||||
PrometheusTarget,
|
PrometheusTarget,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
@@ -387,5 +389,11 @@ export const fetchAlertmanagerAlerts = () =>
|
|||||||
export const fetchAlertmanagerStatus = () =>
|
export const fetchAlertmanagerStatus = () =>
|
||||||
get<AlertmanagerStatus>("/api/monitoring/alertmanager-status");
|
get<AlertmanagerStatus>("/api/monitoring/alertmanager-status");
|
||||||
|
|
||||||
|
export const fetchGrafanaStatus = () =>
|
||||||
|
get<GrafanaStatus>("/api/monitoring/grafana-status");
|
||||||
|
|
||||||
|
export const fetchPrometheusStatus = () =>
|
||||||
|
get<PrometheusStatus>("/api/monitoring/prometheus-status");
|
||||||
|
|
||||||
export const fetchPrometheusTargets = () =>
|
export const fetchPrometheusTargets = () =>
|
||||||
get<PrometheusTarget[]>("/api/monitoring/prometheus-targets");
|
get<PrometheusTarget[]>("/api/monitoring/prometheus-targets");
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
Gauge,
|
||||||
Inbox,
|
Inbox,
|
||||||
Radio,
|
Radio,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -17,9 +18,12 @@ import {
|
|||||||
import {
|
import {
|
||||||
useAlertmanagerAlerts,
|
useAlertmanagerAlerts,
|
||||||
useAlertmanagerStatus,
|
useAlertmanagerStatus,
|
||||||
|
useGrafanaStatus,
|
||||||
|
usePrometheusStatus,
|
||||||
usePrometheusTargets,
|
usePrometheusTargets,
|
||||||
useMonitoringMachines,
|
useMonitoringMachines,
|
||||||
} from "../hooks/useObservability";
|
} from "../hooks/useObservability";
|
||||||
|
import { useServiceInstances } from "../hooks/useServices";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
@@ -43,9 +47,6 @@ import type {
|
|||||||
PrometheusTarget,
|
PrometheusTarget,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
const GRAFANA_BASE_URL =
|
|
||||||
import.meta.env.VITE_GRAFANA_URL || "http://localhost:3000";
|
|
||||||
|
|
||||||
function severityVariant(
|
function severityVariant(
|
||||||
severity: string,
|
severity: string,
|
||||||
): "default" | "secondary" | "destructive" | "outline" {
|
): "default" | "secondary" | "destructive" | "outline" {
|
||||||
@@ -283,6 +284,18 @@ export function ObservabilityPage() {
|
|||||||
error: statusError,
|
error: statusError,
|
||||||
refetch: refetchStatus,
|
refetch: refetchStatus,
|
||||||
} = useAlertmanagerStatus();
|
} = useAlertmanagerStatus();
|
||||||
|
const {
|
||||||
|
data: grafanaStatus,
|
||||||
|
isLoading: grafanaLoading,
|
||||||
|
error: grafanaError,
|
||||||
|
refetch: refetchGrafana,
|
||||||
|
} = useGrafanaStatus();
|
||||||
|
const {
|
||||||
|
data: prometheusStatus,
|
||||||
|
isLoading: prometheusLoading,
|
||||||
|
error: prometheusError,
|
||||||
|
refetch: refetchPrometheus,
|
||||||
|
} = usePrometheusStatus();
|
||||||
const {
|
const {
|
||||||
data: prometheusTargets,
|
data: prometheusTargets,
|
||||||
isLoading: targetsLoading,
|
isLoading: targetsLoading,
|
||||||
@@ -295,8 +308,14 @@ export function ObservabilityPage() {
|
|||||||
error: machinesError,
|
error: machinesError,
|
||||||
refetch: refetchMachines,
|
refetch: refetchMachines,
|
||||||
} = useMonitoringMachines();
|
} = useMonitoringMachines();
|
||||||
|
const { data: grafanaServices = [] } = useServiceInstances("grafana");
|
||||||
const [selectedMachineId, setSelectedMachineId] = useState<string>("");
|
const [selectedMachineId, setSelectedMachineId] = useState<string>("");
|
||||||
|
|
||||||
|
const grafanaService =
|
||||||
|
grafanaServices.find((s) => s.enabled) ?? grafanaServices[0];
|
||||||
|
const GRAFANA_BASE_URL =
|
||||||
|
(grafanaService?.config?.base_url as string | undefined) ?? "";
|
||||||
|
|
||||||
const selectedMachine = useMemo<MonitoringMachine | null>(
|
const selectedMachine = useMemo<MonitoringMachine | null>(
|
||||||
() =>
|
() =>
|
||||||
machines.find((m) => m.id === selectedMachineId) ?? machines[0] ?? null,
|
machines.find((m) => m.id === selectedMachineId) ?? machines[0] ?? null,
|
||||||
@@ -304,13 +323,13 @@ export function ObservabilityPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const nodeExporterDashboardUrl = useMemo(() => {
|
const nodeExporterDashboardUrl = useMemo(() => {
|
||||||
if (!selectedMachine) return "";
|
if (!selectedMachine || !GRAFANA_BASE_URL) return "";
|
||||||
const instance = `${selectedMachine.host || "localhost"}:9100`;
|
const instance = `${selectedMachine.host || "localhost"}:9100`;
|
||||||
return `${GRAFANA_BASE_URL}/d/node-exporter-overview/node-exporter-overview?kiosk&var-instance=${encodeURIComponent(instance)}`;
|
return `${GRAFANA_BASE_URL}/d/node-exporter-overview/node-exporter-overview?kiosk&var-instance=${encodeURIComponent(instance)}`;
|
||||||
}, [selectedMachine]);
|
}, [selectedMachine, GRAFANA_BASE_URL]);
|
||||||
|
|
||||||
const logsUrl = useMemo(() => {
|
const logsUrl = useMemo(() => {
|
||||||
if (!selectedMachine) return "";
|
if (!selectedMachine || !GRAFANA_BASE_URL) return "";
|
||||||
const container =
|
const container =
|
||||||
selectedMachine.mode === "local" ? "backend" : selectedMachine.name;
|
selectedMachine.mode === "local" ? "backend" : selectedMachine.name;
|
||||||
return `${GRAFANA_BASE_URL}/explore?orgId=1&left=${encodeURIComponent(
|
return `${GRAFANA_BASE_URL}/explore?orgId=1&left=${encodeURIComponent(
|
||||||
@@ -320,7 +339,7 @@ export function ObservabilityPage() {
|
|||||||
range: { from: "now-1h", to: "now" },
|
range: { from: "now-1h", to: "now" },
|
||||||
}),
|
}),
|
||||||
)}`;
|
)}`;
|
||||||
}, [selectedMachine]);
|
}, [selectedMachine, GRAFANA_BASE_URL]);
|
||||||
|
|
||||||
const alertmanagerStatusDetail = alertmanagerStatus?.up
|
const alertmanagerStatusDetail = alertmanagerStatus?.up
|
||||||
? alertmanagerStatus.version
|
? alertmanagerStatus.version
|
||||||
@@ -402,6 +421,52 @@ export function ObservabilityPage() {
|
|||||||
icon={Server}
|
icon={Server}
|
||||||
isLoading={machinesLoading}
|
isLoading={machinesLoading}
|
||||||
/>
|
/>
|
||||||
|
<HealthCard
|
||||||
|
title="Grafana"
|
||||||
|
status={
|
||||||
|
grafanaError
|
||||||
|
? "error"
|
||||||
|
: grafanaStatus?.up
|
||||||
|
? "ok"
|
||||||
|
: grafanaLoading
|
||||||
|
? "unknown"
|
||||||
|
: "error"
|
||||||
|
}
|
||||||
|
detail={
|
||||||
|
grafanaStatus?.up
|
||||||
|
? grafanaStatus.version
|
||||||
|
? `version ${grafanaStatus.version}`
|
||||||
|
: "reachable"
|
||||||
|
: grafanaStatus?.error === "no_service_configured"
|
||||||
|
? "not configured"
|
||||||
|
: "unreachable"
|
||||||
|
}
|
||||||
|
icon={Gauge}
|
||||||
|
isLoading={grafanaLoading}
|
||||||
|
/>
|
||||||
|
<HealthCard
|
||||||
|
title="Prometheus"
|
||||||
|
status={
|
||||||
|
prometheusError
|
||||||
|
? "error"
|
||||||
|
: prometheusStatus?.up
|
||||||
|
? "ok"
|
||||||
|
: prometheusLoading
|
||||||
|
? "unknown"
|
||||||
|
: "error"
|
||||||
|
}
|
||||||
|
detail={
|
||||||
|
prometheusStatus?.up
|
||||||
|
? prometheusStatus.version
|
||||||
|
? `version ${prometheusStatus.version}`
|
||||||
|
: "reachable"
|
||||||
|
: prometheusStatus?.error === "no_service_configured"
|
||||||
|
? "not configured"
|
||||||
|
: "unreachable"
|
||||||
|
}
|
||||||
|
icon={Radio}
|
||||||
|
isLoading={prometheusLoading}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -433,6 +498,20 @@ export function ObservabilityPage() {
|
|||||||
refetch={refetchMachines}
|
refetch={refetchMachines}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{grafanaError && (
|
||||||
|
<QueryError
|
||||||
|
label="Grafana status"
|
||||||
|
error={grafanaError}
|
||||||
|
refetch={refetchGrafana}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{prometheusError && (
|
||||||
|
<QueryError
|
||||||
|
label="Prometheus status"
|
||||||
|
error={prometheusError}
|
||||||
|
refetch={refetchPrometheus}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{alertsSummary?.error && (
|
{alertsSummary?.error && (
|
||||||
@@ -542,7 +621,8 @@ export function ObservabilityPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{selectedMachine ? (
|
{selectedMachine ? (
|
||||||
|
GRAFANA_BASE_URL ? (
|
||||||
<>
|
<>
|
||||||
<GrafanaLinkCard
|
<GrafanaLinkCard
|
||||||
title={`${selectedMachine.name} metrics`}
|
title={`${selectedMachine.name} metrics`}
|
||||||
@@ -556,6 +636,18 @@ export function ObservabilityPage() {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon={Gauge}
|
||||||
|
title="No Grafana service configured"
|
||||||
|
description="Add a Grafana service instance to enable deep-links to dashboards and logs."
|
||||||
|
action={
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link to="/services">Open Services</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={ServerOff}
|
icon={ServerOff}
|
||||||
title="No machine selected"
|
title="No machine selected"
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import {
|
import {
|
||||||
fetchAlertmanagerAlerts,
|
fetchAlertmanagerAlerts,
|
||||||
fetchAlertmanagerStatus,
|
fetchAlertmanagerStatus,
|
||||||
|
fetchGrafanaStatus,
|
||||||
|
fetchPrometheusStatus,
|
||||||
fetchPrometheusTargets,
|
fetchPrometheusTargets,
|
||||||
fetchMonitoringMachines,
|
fetchMonitoringMachines,
|
||||||
} from "../api/client";
|
} from "../api/client";
|
||||||
@@ -26,6 +28,26 @@ export function useAlertmanagerStatus() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useGrafanaStatus() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["observability", "grafana-status"],
|
||||||
|
queryFn: fetchGrafanaStatus,
|
||||||
|
retry: 2,
|
||||||
|
staleTime: 10_000,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePrometheusStatus() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["observability", "prometheus-status"],
|
||||||
|
queryFn: fetchPrometheusStatus,
|
||||||
|
retry: 2,
|
||||||
|
staleTime: 10_000,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function usePrometheusTargets() {
|
export function usePrometheusTargets() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["observability", "prometheus-targets"],
|
queryKey: ["observability", "prometheus-targets"],
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import {
|
|||||||
import type { ServiceInstance, WidgetInstance } from "../types";
|
import type { ServiceInstance, WidgetInstance } from "../types";
|
||||||
|
|
||||||
describe("service registry", () => {
|
describe("service registry", () => {
|
||||||
it("registers the five backend service types", () => {
|
it("registers the backend service types", () => {
|
||||||
expect(Object.keys(SERVICE_REGISTRY).sort()).toEqual([
|
expect(Object.keys(SERVICE_REGISTRY).sort()).toEqual([
|
||||||
|
"alertmanager",
|
||||||
"grafana",
|
"grafana",
|
||||||
"jellyfin",
|
"jellyfin",
|
||||||
"nextcloud",
|
"nextcloud",
|
||||||
@@ -23,6 +24,9 @@ describe("service registry", () => {
|
|||||||
expect(SERVICE_REGISTRY.grafana.widgets.map((w) => w.kind)).toEqual([
|
expect(SERVICE_REGISTRY.grafana.widgets.map((w) => w.kind)).toEqual([
|
||||||
"link",
|
"link",
|
||||||
]);
|
]);
|
||||||
|
expect(SERVICE_REGISTRY.alertmanager.widgets.map((w) => w.kind)).toEqual([
|
||||||
|
"active_alerts",
|
||||||
|
]);
|
||||||
expect(SERVICE_REGISTRY.ssh_tasks.widgets.map((w) => w.kind)).toEqual([
|
expect(SERVICE_REGISTRY.ssh_tasks.widgets.map((w) => w.kind)).toEqual([
|
||||||
"task_output",
|
"task_output",
|
||||||
]);
|
]);
|
||||||
@@ -62,6 +66,35 @@ describe("service registry", () => {
|
|||||||
expect(resolved?.refreshIntervalMs).toBe(0);
|
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", () => {
|
it("resolves a built-in widget without a service", () => {
|
||||||
const widget: WidgetInstance = {
|
const widget: WidgetInstance = {
|
||||||
id: "w2",
|
id: "w2",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ComponentType } from "react";
|
import type { ComponentType } from "react";
|
||||||
|
import { AlertmanagerAlertsWidget } from "../widgets/AlertmanagerAlertsWidget";
|
||||||
import { BackupsWidget } from "../widgets/BackupsWidget";
|
import { BackupsWidget } from "../widgets/BackupsWidget";
|
||||||
import { GrafanaLinkWidget } from "../widgets/GrafanaLinkWidget";
|
import { GrafanaLinkWidget } from "../widgets/GrafanaLinkWidget";
|
||||||
import { JellyfinWidget } from "../widgets/JellyfinWidget";
|
import { JellyfinWidget } from "../widgets/JellyfinWidget";
|
||||||
@@ -42,6 +43,28 @@ export interface ServiceBinding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
|
export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
|
||||||
|
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: {
|
grafana: {
|
||||||
serviceType: "grafana",
|
serviceType: "grafana",
|
||||||
name: "Grafana",
|
name: "Grafana",
|
||||||
|
|||||||
@@ -386,6 +386,24 @@ export interface AlertmanagerStatus {
|
|||||||
uptime: string;
|
uptime: string;
|
||||||
name: string;
|
name: string;
|
||||||
peers: 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 {
|
export interface PrometheusTarget {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<SectionCard title={widget.title} description={description}>
|
||||||
|
{isLoading && !data ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
</div>
|
||||||
|
) : data?.error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{data.error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : summary ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-semibold">{summary.total}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Firing alert{summary.total === 1 ? "" : "s"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{Object.entries(summary.by_severity).map(([sev, count]) => (
|
||||||
|
<Badge key={sev} variant={severityVariant(sev)}>
|
||||||
|
{sev}: {count}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{alerts.length > 0 ? (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{alerts.slice(0, 5).map((a, idx) => (
|
||||||
|
<li
|
||||||
|
key={`${a.name}-${idx}`}
|
||||||
|
className="flex items-center justify-between gap-2 rounded-md border px-2 py-1 text-sm"
|
||||||
|
>
|
||||||
|
<span className="truncate">{a.name}</span>
|
||||||
|
<Badge variant={severityVariant(a.severity)}>
|
||||||
|
{a.severity}
|
||||||
|
</Badge>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Everything looks quiet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export { AlertmanagerAlertsWidget } from "./AlertmanagerAlertsWidget";
|
||||||
export { BackupsWidget } from "./BackupsWidget";
|
export { BackupsWidget } from "./BackupsWidget";
|
||||||
export { GrafanaLinkWidget } from "./GrafanaLinkWidget";
|
export { GrafanaLinkWidget } from "./GrafanaLinkWidget";
|
||||||
export { JellyfinWidget } from "./JellyfinWidget";
|
export { JellyfinWidget } from "./JellyfinWidget";
|
||||||
|
|||||||
Reference in New Issue
Block a user