diff --git a/frontend/src/pages/service-tabs/AlertsTab.tsx b/frontend/src/pages/service-tabs/AlertsTab.tsx new file mode 100644 index 0000000..cd40c9e --- /dev/null +++ b/frontend/src/pages/service-tabs/AlertsTab.tsx @@ -0,0 +1,188 @@ +/** + * Alertmanager Alerts tab (spec R2.4, R8.2). + * + * Lifts the Alertmanager alerts content from the old cross-service + * ObservabilityPage into an instance-scoped tab. Renders the active-alert + * summary (total + by severity) and the expandable alert list. + * + * The hooks (useAlertmanagerAlerts, useAlertmanagerStatus) are global / + * first-configured for now — they don't accept a service_id yet. Wiring + * `instance.id` into them is a documented follow-up once the hooks gain the + * parameter. The `instance` prop is accepted for future scoping. + */ +import { AlertTriangle, Bell, ChevronDown, Inbox } from "lucide-react"; +import { + useAlertmanagerAlerts, + useAlertmanagerStatus, +} from "../../hooks/useObservability"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import type { AlertmanagerAlert, ServiceInstance } from "../../types"; + +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"; + } +} + +function AlertItem({ alert }: { alert: AlertmanagerAlert }) { + return ( + + +
+
+
{alert.name}
+
+ + {alert.severity} + + +
+
+
+ {alert.summary || alert.description} +
+ {alert.active_since && ( +
+ Since {new Date(alert.active_since).toLocaleString()} +
+ )} +
+
+ +
+ {alert.description && ( +
+ Description:{" "} + {alert.description} +
+ )} +
+ {alert.job_name && ( +
+ Job: {alert.job_name} +
+ )} + {alert.category && ( +
+ Category: {alert.category} +
+ )} +
+ State: {alert.state} +
+
+ Since:{" "} + {alert.active_since + ? new Date(alert.active_since).toLocaleString() + : "unknown"} +
+
+ {alert.labels && Object.keys(alert.labels).length > 0 && ( +
+ {Object.entries(alert.labels).map(([key, value]) => ( + + {key}={value} + + ))} +
+ )} +
+
+
+ ); +} + +export function AlertsTab({ instance }: { instance: ServiceInstance }) { + // Global / first-configured hooks for now; instance.id scoping is a + // follow-up (see file docstring). + void instance; + + const { + data: alertsSummary, + isLoading: alertsLoading, + error: alertsError, + } = useAlertmanagerAlerts(); + const { data: status, isLoading: statusLoading } = useAlertmanagerStatus(); + + const statusDetail = status?.up + ? status.version + ? `version ${status.version}` + : "reachable" + : statusLoading + ? "checking…" + : "unreachable"; + + return ( +
+
+ + Alertmanager {statusDetail} +
+ + {alertsError && ( + + Failed to load alerts + {alertsError.message} + + )} + + + + + + Active Alerts ({alertsSummary?.total ?? 0}) + + + + {alertsLoading ? ( +
+ + + +
+ ) : !alertsSummary || alertsSummary.total === 0 ? ( +
+ +
No active alerts
+
+ Everything looks quiet. Firing alerts will appear here. +
+
+ ) : ( + <> + {alertsSummary.alerts.map((alert, idx) => ( + + ))} + {alertsSummary.total > alertsSummary.alerts.length && ( +
+ {alertsSummary.total - alertsSummary.alerts.length} more alert + {alertsSummary.total - alertsSummary.alerts.length === 1 + ? "" + : "s"}{" "} + in Alertmanager +
+ )} + + )} +
+
+
+ ); +} diff --git a/frontend/src/pages/service-tabs/LinksTab.tsx b/frontend/src/pages/service-tabs/LinksTab.tsx new file mode 100644 index 0000000..07ea98d --- /dev/null +++ b/frontend/src/pages/service-tabs/LinksTab.tsx @@ -0,0 +1,194 @@ +/** + * Grafana Links tab (spec R2.4, R8.2). + * + * Lifts the Grafana deep-link content from the old cross-service + * ObservabilityPage into an instance-scoped tab. Shows service health + the + * configured Grafana deep-links (node-exporter dashboard, Loki logs per + * machine). + * + * The hooks (useGrafanaStatus, useMonitoringMachines) are global / + * first-configured for now. Wiring `instance.id` into the status hook is a + * follow-up. The machine links use the configured Grafana base_url from the + * instance's config. + */ +import { useMemo, useState } from "react"; +import { Link } from "react-router-dom"; +import { Activity, ExternalLink, Gauge, ServerOff } from "lucide-react"; +import { + useGrafanaStatus, + useMonitoringMachines, +} from "../../hooks/useObservability"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { ServiceInstance } from "../../types"; + +function GrafanaLinkCard({ + title, + description, + href, +}: { + title: string; + description: string; + href: string; +}) { + return ( +
+
+
+
{title}
+
{description}
+
+ +
+
+ ); +} + +export function LinksTab({ instance }: { instance: ServiceInstance }) { + const { data: status, isLoading, error } = useGrafanaStatus(); + const { data: machines = [], isLoading: machinesLoading } = + useMonitoringMachines(); + const [selectedMachineId, setSelectedMachineId] = useState(""); + + const grafanaBaseUrl = + (instance.config?.base_url as string | undefined) ?? ""; + + const selectedMachine = useMemo( + () => + machines.find((m) => m.id === selectedMachineId) ?? machines[0] ?? null, + [machines, selectedMachineId], + ); + + const nodeExporterDashboardUrl = useMemo(() => { + if (!selectedMachine || !grafanaBaseUrl) return ""; + const inst = `${selectedMachine.host || "localhost"}:9100`; + return `${grafanaBaseUrl}/d/node-exporter-overview/node-exporter-overview?kiosk&var-instance=${encodeURIComponent(inst)}`; + }, [selectedMachine, grafanaBaseUrl]); + + const logsUrl = useMemo(() => { + if (!selectedMachine || !grafanaBaseUrl) return ""; + const container = + selectedMachine.mode === "local" ? "backend" : selectedMachine.name; + return `${grafanaBaseUrl}/explore?orgId=1&left=${encodeURIComponent( + JSON.stringify({ + datasource: "Loki", + queries: [{ refId: "A", expr: `{container="${container}"}` }], + range: { from: "now-1h", to: "now" }, + }), + )}`; + }, [selectedMachine, grafanaBaseUrl]); + + const statusDetail = status?.up + ? status.version + ? `version ${status.version}` + : "reachable" + : isLoading + ? "checking…" + : error + ? "unreachable" + : "not configured"; + + return ( +
+
+ + Grafana {statusDetail} +
+ + {error && ( + + Failed to reach Grafana + {error.message} + + )} + + + + + + Machine Dashboard + + {machines.length > 0 ? ( + + ) : null} + + + {isLoading ? ( + + ) : selectedMachine && grafanaBaseUrl ? ( + <> + + + + ) : !grafanaBaseUrl ? ( +
+ +
No Grafana base URL configured
+
+ Add a Grafana service instance to enable deep-links to + dashboards and logs. +
+ +
+ ) : ( +
+ +
No machine selected
+
+ Add monitoring machines in Settings to see Grafana drill-down + links. +
+ +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/pages/service-tabs/MetricsTab.tsx b/frontend/src/pages/service-tabs/MetricsTab.tsx new file mode 100644 index 0000000..3adec82 --- /dev/null +++ b/frontend/src/pages/service-tabs/MetricsTab.tsx @@ -0,0 +1,117 @@ +/** + * Prometheus Metrics tab (spec R2.4, R8.2). + * + * Lifts the Prometheus status + targets content from the old cross-service + * ObservabilityPage into an instance-scoped tab. Shows service health and + * the Node Exporter scrape-targets list. + * + * The hooks (usePrometheusStatus, usePrometheusTargets) are global / + * first-configured for now. Wiring `instance.id` is a follow-up. + */ +import { Radio } from "lucide-react"; +import { + usePrometheusStatus, + usePrometheusTargets, +} from "../../hooks/useObservability"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { PrometheusTarget, ServiceInstance } from "../../types"; + +function TargetsTable({ targets }: { targets: PrometheusTarget[] }) { + return ( +
+ {targets.map((target, idx) => ( +
+
{target.targets.join(", ")}
+ {target.labels && Object.keys(target.labels).length > 0 && ( +
+ {Object.entries(target.labels).map(([key, value]) => ( + + {key}: {value} + + ))} +
+ )} +
+ ))} +
+ ); +} + +export function MetricsTab({ instance }: { instance: ServiceInstance }) { + // Global / first-configured hooks for now; instance.id scoping is a + // follow-up (see file docstring). + void instance; + + const { + data: status, + isLoading: statusLoading, + error: statusError, + } = usePrometheusStatus(); + const { + data: targets, + isLoading: targetsLoading, + error: targetsError, + } = usePrometheusTargets(); + + const statusDetail = status?.up + ? status.version + ? `version ${status.version}` + : "reachable" + : statusLoading + ? "checking…" + : "unreachable"; + + return ( +
+
+ + Prometheus {statusDetail} +
+ + {statusError && ( + + Failed to reach Prometheus + {statusError.message} + + )} + + + + + + Node Exporter Targets ({targets?.length ?? 0}) + + + + {targetsLoading ? ( +
+ + +
+ ) : !targets || targets.length === 0 ? ( +
+ +
No Node Exporter targets
+
+ Enable Node Exporter on an SSH machine in Settings to populate + Prometheus scrape targets. +
+
+ ) : ( + + )} +
+
+ + {targetsError && ( + + Failed to load targets + {targetsError.message} + + )} +
+ ); +} diff --git a/frontend/src/pages/service-tabs/__tests__/AlertsTab.test.tsx b/frontend/src/pages/service-tabs/__tests__/AlertsTab.test.tsx new file mode 100644 index 0000000..ec95414 --- /dev/null +++ b/frontend/src/pages/service-tabs/__tests__/AlertsTab.test.tsx @@ -0,0 +1,70 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { AlertsTab } from "../AlertsTab"; +import type { ServiceInstance } from "../../../types"; + +const instance: ServiceInstance = { + id: "am-1", + service_type: "alertmanager", + name: "Main Alertmanager", + config: { base_url: "https://am.example.com", timeout_seconds: 5 }, + secrets_set: {}, + enabled: true, + created_at: 1_700_000_000, + updated_at: 1_700_000_000, +}; + +vi.mock("../../../hooks/useObservability", () => ({ + useAlertmanagerAlerts: () => ({ + data: { + total: 2, + by_severity: { critical: 1, warning: 1 }, + alerts: [ + { + name: "DiskFull", + severity: "critical", + category: "disk", + job_name: "node", + summary: "Disk is almost full", + description: "Disk usage above 90%", + active_since: "2026-06-26T10:00:00Z", + state: "firing", + labels: { instance: "node1" }, + }, + { + name: "HighCpu", + severity: "warning", + category: "cpu", + job_name: "node", + summary: "High CPU usage", + description: "", + active_since: "2026-06-26T09:00:00Z", + state: "firing", + labels: {}, + }, + ], + }, + isLoading: false, + error: null, + }), + useAlertmanagerStatus: () => ({ + data: { up: true, version: "0.27.0", uptime: "", name: "", peers: [] }, + isLoading: false, + error: null, + }), +})); + +describe("AlertsTab", () => { + it("renders the alert count and alert names", () => { + render(); + expect(screen.getByText(/Active Alerts \(2\)/)).toBeInTheDocument(); + expect(screen.getByText("DiskFull")).toBeInTheDocument(); + expect(screen.getByText("HighCpu")).toBeInTheDocument(); + }); + + it("renders severity badges", () => { + render(); + expect(screen.getByText("critical")).toBeInTheDocument(); + expect(screen.getByText("warning")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/service-tabs/__tests__/LinksTab.test.tsx b/frontend/src/pages/service-tabs/__tests__/LinksTab.test.tsx new file mode 100644 index 0000000..8f9d4b6 --- /dev/null +++ b/frontend/src/pages/service-tabs/__tests__/LinksTab.test.tsx @@ -0,0 +1,58 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { LinksTab } from "../LinksTab"; +import type { ServiceInstance } from "../../../types"; + +const instance: ServiceInstance = { + id: "graf-1", + service_type: "grafana", + name: "Main Grafana", + config: { base_url: "https://grafana.example.com", timeout_seconds: 5 }, + secrets_set: {}, + enabled: true, + created_at: 1_700_000_000, + updated_at: 1_700_000_000, +}; + +vi.mock("../../../hooks/useObservability", () => ({ + useGrafanaStatus: () => ({ + data: { + up: true, + version: "11.0.0", + service_id: "graf-1", + name: "Main Grafana", + }, + isLoading: false, + error: null, + }), + useMonitoringMachines: () => ({ + data: [ + { + id: "m1", + name: "storage", + mode: "ssh", + host: "10.0.0.5", + enabled: true, + services: [], + port: 22, + username: "admin", + }, + ], + isLoading: false, + }), +})); + +describe("LinksTab", () => { + it("renders the Grafana version and machine dashboard links", () => { + render(); + expect(screen.getByText(/version 11\.0\.0/)).toBeInTheDocument(); + expect(screen.getByText(/storage metrics/)).toBeInTheDocument(); + expect(screen.getByText(/storage logs/)).toBeInTheDocument(); + }); + + it("renders open-in-grafana link buttons", () => { + render(); + const links = screen.getAllByText("Open in Grafana"); + expect(links).toHaveLength(2); + }); +}); diff --git a/frontend/src/pages/service-tabs/__tests__/MetricsTab.test.tsx b/frontend/src/pages/service-tabs/__tests__/MetricsTab.test.tsx new file mode 100644 index 0000000..e2edce6 --- /dev/null +++ b/frontend/src/pages/service-tabs/__tests__/MetricsTab.test.tsx @@ -0,0 +1,51 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MetricsTab } from "../MetricsTab"; +import type { ServiceInstance } from "../../../types"; + +const instance: ServiceInstance = { + id: "prom-1", + service_type: "prometheus", + name: "Main Prometheus", + config: { base_url: "https://prom.example.com", timeout_seconds: 10 }, + secrets_set: {}, + enabled: true, + created_at: 1_700_000_000, + updated_at: 1_700_000_000, +}; + +vi.mock("../../../hooks/useObservability", () => ({ + usePrometheusStatus: () => ({ + data: { + up: true, + version: "2.52.0", + service_id: "prom-1", + name: "Main Prometheus", + }, + isLoading: false, + error: null, + }), + usePrometheusTargets: () => ({ + data: [ + { + targets: ["10.0.0.5:9100"], + labels: { instance: "storage", job: "node_exporter" }, + }, + ], + isLoading: false, + error: null, + }), +})); + +describe("MetricsTab", () => { + it("renders the Prometheus version and target list", () => { + render(); + expect(screen.getByText(/version 2\.52\.0/)).toBeInTheDocument(); + expect(screen.getByText("10.0.0.5:9100")).toBeInTheDocument(); + }); + + it("renders the target count in the heading", () => { + render(); + expect(screen.getByText(/Node Exporter Targets \(1\)/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/service-tabs/index.ts b/frontend/src/pages/service-tabs/index.ts index 8f809f8..d169055 100644 --- a/frontend/src/pages/service-tabs/index.ts +++ b/frontend/src/pages/service-tabs/index.ts @@ -6,7 +6,10 @@ */ import type { ComponentType } from "react"; import type { ServiceInstance } from "../../types"; -import { AlertsTab, LinksTab, MetricsTab, OverviewTab } from "./stubs"; +import { OverviewTab } from "./stubs"; +import { AlertsTab } from "./AlertsTab"; +import { LinksTab } from "./LinksTab"; +import { MetricsTab } from "./MetricsTab"; import { MediaTab } from "./MediaTab"; import { RequestsTab } from "./RequestsTab"; import { FilesTab } from "./FilesTab"; diff --git a/frontend/src/pages/service-tabs/stubs.tsx b/frontend/src/pages/service-tabs/stubs.tsx index 523ea26..64de68d 100644 --- a/frontend/src/pages/service-tabs/stubs.tsx +++ b/frontend/src/pages/service-tabs/stubs.tsx @@ -27,15 +27,3 @@ function Stub({ export function OverviewTab({ instance }: { instance: ServiceInstance }) { return ; } - -export function AlertsTab({ instance }: { instance: ServiceInstance }) { - return ; -} - -export function LinksTab({ instance }: { instance: ServiceInstance }) { - return ; -} - -export function MetricsTab({ instance }: { instance: ServiceInstance }) { - return ; -}