Frontend: Observability split -- Alerts + Links + Metrics tabs (Slice 9)
Split the aggregate ObservabilityPage into three instance-scoped tabs on their respective service pages, replacing the AlertsTab/LinksTab/ MetricsTab stubs. AlertsTab (alertmanager): Alertmanager status line + active-alerts summary (count + by severity) + expandable alert list (AlertItem with severity badge, summary, description, labels, active-since). Empty state when no alerts. LinksTab (grafana): Grafana status line + machine-selector dropdown + GrafanaLinkCards (Node Exporter metrics dashboard, Loki log explorer) generated from instance.config.base_url. Empty states when no base_url or no machine selected. MetricsTab (prometheus): Prometheus status line + Node Exporter targets table with labels badges. Empty state when no targets. All three tabs use the existing observability hooks which are global / first-configured (no service_id param yet). Per-instance scoping by instance.id is a documented follow-up once the hooks gain the parameter (same pattern as JobsTab slice 7). LinksTab does read instance.config.base_url for the specific Grafana deep-link URL. stubs.tsx loses AlertsTab/LinksTab/MetricsTab stubs (only OverviewTab stub remains); index.ts wires the real components. Old ObservabilityPage.tsx stays in the repo (route removed slice 4; file deleted slice 11). Tests: 2 per tab (renders content + empty/error states with mocked hooks). 106 tests pass (+6); lint/build green. Refs openspec/changes/services-as-hub-ia/ (spec R2.4/R8, tasks slice 9).
This commit is contained in:
@@ -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 (
|
||||||
|
<Collapsible>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<div className="group cursor-pointer rounded-lg border p-3 transition-colors hover:bg-muted/50">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="font-medium text-sm">{alert.name}</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Badge variant={severityVariant(alert.severity)}>
|
||||||
|
{alert.severity}
|
||||||
|
</Badge>
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{alert.summary || alert.description}
|
||||||
|
</div>
|
||||||
|
{alert.active_since && (
|
||||||
|
<div className="mt-1 text-[10px] text-muted-foreground">
|
||||||
|
Since {new Date(alert.active_since).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="overflow-hidden">
|
||||||
|
<div className="space-y-2 rounded-b-lg border-x border-b p-3 text-sm">
|
||||||
|
{alert.description && (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Description:</span>{" "}
|
||||||
|
{alert.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
{alert.job_name && (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Job:</span> {alert.job_name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{alert.category && (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Category:</span> {alert.category}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">State:</span> {alert.state}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Since:</span>{" "}
|
||||||
|
{alert.active_since
|
||||||
|
? new Date(alert.active_since).toLocaleString()
|
||||||
|
: "unknown"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{alert.labels && Object.keys(alert.labels).length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 pt-1">
|
||||||
|
{Object.entries(alert.labels).map(([key, value]) => (
|
||||||
|
<Badge key={key} variant="secondary" className="text-[10px]">
|
||||||
|
{key}={value}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Bell className="h-4 w-4" />
|
||||||
|
Alertmanager {statusDetail}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{alertsError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>Failed to load alerts</AlertTitle>
|
||||||
|
<AlertDescription>{alertsError.message}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
Active Alerts ({alertsSummary?.total ?? 0})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{alertsLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-16 w-full" />
|
||||||
|
<Skeleton className="h-16 w-full" />
|
||||||
|
<Skeleton className="h-16 w-full" />
|
||||||
|
</div>
|
||||||
|
) : !alertsSummary || alertsSummary.total === 0 ? (
|
||||||
|
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
|
||||||
|
<Inbox className="h-8 w-8 text-muted-foreground" />
|
||||||
|
<div className="font-medium">No active alerts</div>
|
||||||
|
<div className="max-w-md text-sm text-muted-foreground">
|
||||||
|
Everything looks quiet. Firing alerts will appear here.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{alertsSummary.alerts.map((alert, idx) => (
|
||||||
|
<AlertItem key={`${alert.name}-${idx}`} alert={alert} />
|
||||||
|
))}
|
||||||
|
{alertsSummary.total > alertsSummary.alerts.length && (
|
||||||
|
<div className="text-center text-xs text-muted-foreground">
|
||||||
|
{alertsSummary.total - alertsSummary.alerts.length} more alert
|
||||||
|
{alertsSummary.total - alertsSummary.alerts.length === 1
|
||||||
|
? ""
|
||||||
|
: "s"}{" "}
|
||||||
|
in Alertmanager
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="rounded-md border p-4">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{title}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{description}</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
Open in Grafana
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Gauge className="h-4 w-4" />
|
||||||
|
Grafana {statusDetail}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>Failed to reach Grafana</AlertTitle>
|
||||||
|
<AlertDescription>{error.message}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Activity className="h-4 w-4" />
|
||||||
|
Machine Dashboard
|
||||||
|
</CardTitle>
|
||||||
|
{machines.length > 0 ? (
|
||||||
|
<Select
|
||||||
|
value={selectedMachine?.id ?? ""}
|
||||||
|
onValueChange={setSelectedMachineId}
|
||||||
|
disabled={machinesLoading}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full sm:w-[240px]">
|
||||||
|
<SelectValue placeholder="Select machine" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{machines.map((machine) => (
|
||||||
|
<SelectItem key={machine.id} value={machine.id}>
|
||||||
|
{machine.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : null}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
) : selectedMachine && grafanaBaseUrl ? (
|
||||||
|
<>
|
||||||
|
<GrafanaLinkCard
|
||||||
|
title={`${selectedMachine.name} metrics`}
|
||||||
|
description="Open the Node Exporter overview dashboard for this machine in Grafana."
|
||||||
|
href={nodeExporterDashboardUrl}
|
||||||
|
/>
|
||||||
|
<GrafanaLinkCard
|
||||||
|
title={`${selectedMachine.name} logs`}
|
||||||
|
description="Explore Loki logs for this machine in Grafana."
|
||||||
|
href={logsUrl}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : !grafanaBaseUrl ? (
|
||||||
|
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
|
||||||
|
<Gauge className="h-8 w-8 text-muted-foreground" />
|
||||||
|
<div className="font-medium">No Grafana base URL configured</div>
|
||||||
|
<div className="max-w-md text-sm text-muted-foreground">
|
||||||
|
Add a Grafana service instance to enable deep-links to
|
||||||
|
dashboards and logs.
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link to="/services">Open Services</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
|
||||||
|
<ServerOff className="h-8 w-8 text-muted-foreground" />
|
||||||
|
<div className="font-medium">No machine selected</div>
|
||||||
|
<div className="max-w-md text-sm text-muted-foreground">
|
||||||
|
Add monitoring machines in Settings to see Grafana drill-down
|
||||||
|
links.
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link to="/settings">Open Settings</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{targets.map((target, idx) => (
|
||||||
|
<div key={idx} className="rounded-lg border p-3">
|
||||||
|
<div className="font-mono text-sm">{target.targets.join(", ")}</div>
|
||||||
|
{target.labels && Object.keys(target.labels).length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{Object.entries(target.labels).map(([key, value]) => (
|
||||||
|
<Badge key={key} variant="outline" className="text-[10px]">
|
||||||
|
{key}: {value}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Radio className="h-4 w-4" />
|
||||||
|
Prometheus {statusDetail}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{statusError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>Failed to reach Prometheus</AlertTitle>
|
||||||
|
<AlertDescription>{statusError.message}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Radio className="h-4 w-4" />
|
||||||
|
Node Exporter Targets ({targets?.length ?? 0})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{targetsLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-16 w-full" />
|
||||||
|
<Skeleton className="h-16 w-full" />
|
||||||
|
</div>
|
||||||
|
) : !targets || targets.length === 0 ? (
|
||||||
|
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
|
||||||
|
<Radio className="h-8 w-8 text-muted-foreground" />
|
||||||
|
<div className="font-medium">No Node Exporter targets</div>
|
||||||
|
<div className="max-w-md text-sm text-muted-foreground">
|
||||||
|
Enable Node Exporter on an SSH machine in Settings to populate
|
||||||
|
Prometheus scrape targets.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TargetsTable targets={targets} />
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{targetsError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>Failed to load targets</AlertTitle>
|
||||||
|
<AlertDescription>{targetsError.message}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(<AlertsTab instance={instance} />);
|
||||||
|
expect(screen.getByText(/Active Alerts \(2\)/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("DiskFull")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("HighCpu")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders severity badges", () => {
|
||||||
|
render(<AlertsTab instance={instance} />);
|
||||||
|
expect(screen.getByText("critical")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("warning")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(<LinksTab instance={instance} />);
|
||||||
|
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(<LinksTab instance={instance} />);
|
||||||
|
const links = screen.getAllByText("Open in Grafana");
|
||||||
|
expect(links).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(<MetricsTab instance={instance} />);
|
||||||
|
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(<MetricsTab instance={instance} />);
|
||||||
|
expect(screen.getByText(/Node Exporter Targets \(1\)/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,10 @@
|
|||||||
*/
|
*/
|
||||||
import type { ComponentType } from "react";
|
import type { ComponentType } from "react";
|
||||||
import type { ServiceInstance } from "../../types";
|
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 { MediaTab } from "./MediaTab";
|
||||||
import { RequestsTab } from "./RequestsTab";
|
import { RequestsTab } from "./RequestsTab";
|
||||||
import { FilesTab } from "./FilesTab";
|
import { FilesTab } from "./FilesTab";
|
||||||
|
|||||||
@@ -27,15 +27,3 @@ function Stub({
|
|||||||
export function OverviewTab({ instance }: { instance: ServiceInstance }) {
|
export function OverviewTab({ instance }: { instance: ServiceInstance }) {
|
||||||
return <Stub label="Service overview" instance={instance} />;
|
return <Stub label="Service overview" instance={instance} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AlertsTab({ instance }: { instance: ServiceInstance }) {
|
|
||||||
return <Stub label="Alerts" instance={instance} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LinksTab({ instance }: { instance: ServiceInstance }) {
|
|
||||||
return <Stub label="Links" instance={instance} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MetricsTab({ instance }: { instance: ServiceInstance }) {
|
|
||||||
return <Stub label="Metrics" instance={instance} />;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user