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:
Developer
2026-06-26 19:52:06 +00:00
parent 8f4e8428f0
commit 84c1262bd6
8 changed files with 682 additions and 13 deletions
@@ -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();
});
});
+4 -1
View File
@@ -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";
-12
View File
@@ -27,15 +27,3 @@ function Stub({
export function OverviewTab({ instance }: { instance: ServiceInstance }) {
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} />;
}