84c1262bd6
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).
189 lines
5.9 KiB
TypeScript
189 lines
5.9 KiB
TypeScript
/**
|
|
* 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>
|
|
);
|
|
}
|