Files
manage/frontend/src/pages/service-tabs/AlertsTab.tsx
T
Developer 84c1262bd6 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).
2026-06-26 19:52:06 +00:00

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>
);
}