231 lines
7.2 KiB
TypeScript
231 lines
7.2 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
|
|
import type { Alert, AlertTransition } from "@/api";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
|
|
type Props = {
|
|
alerts: Alert[];
|
|
transitions: AlertTransition[];
|
|
alertsHealth: { available: boolean; reason?: string } | null;
|
|
alertsStreamConnected: boolean;
|
|
onRefresh: () => Promise<void>;
|
|
};
|
|
|
|
export function AlertsPage({
|
|
alerts,
|
|
transitions,
|
|
alertsHealth,
|
|
alertsStreamConnected,
|
|
onRefresh,
|
|
}: Props) {
|
|
const [severity, setSeverity] = useState("all");
|
|
const [status, setStatus] = useState("all");
|
|
const [kind, setKind] = useState("all");
|
|
const [transitionQ, setTransitionQ] = useState("");
|
|
|
|
const severities = useMemo(
|
|
() => ["all", ...Array.from(new Set(alerts.map((a) => a.severity))).sort()],
|
|
[alerts],
|
|
);
|
|
const statuses = useMemo(
|
|
() => ["all", ...Array.from(new Set(alerts.map((a) => a.status))).sort()],
|
|
[alerts],
|
|
);
|
|
const kinds = useMemo(
|
|
() => ["all", ...Array.from(new Set(alerts.map((a) => a.kind))).sort()],
|
|
[alerts],
|
|
);
|
|
|
|
const filteredAlerts = useMemo(
|
|
() =>
|
|
alerts.filter(
|
|
(a) =>
|
|
(severity === "all" || a.severity === severity) &&
|
|
(status === "all" || a.status === status) &&
|
|
(kind === "all" || a.kind === kind),
|
|
),
|
|
[alerts, severity, status, kind],
|
|
);
|
|
|
|
const filteredTransitions = useMemo(() => {
|
|
const q = transitionQ.trim().toLowerCase();
|
|
if (!q) return transitions;
|
|
return transitions.filter(
|
|
(t) =>
|
|
t.kind.toLowerCase().includes(q) ||
|
|
t.to_status.toLowerCase().includes(q) ||
|
|
t.message.toLowerCase().includes(q) ||
|
|
(t.agent_id || "").toLowerCase().includes(q),
|
|
);
|
|
}, [transitions, transitionQ]);
|
|
|
|
return (
|
|
<section className="grid gap-3 lg:grid-cols-2">
|
|
<Card>
|
|
<CardHeader className="flex items-center justify-between gap-2">
|
|
<CardTitle>Active Alerts</CardTitle>
|
|
<div className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1">
|
|
<div
|
|
aria-live="polite"
|
|
className="text-right text-xs text-muted-foreground"
|
|
>
|
|
{!alertsStreamConnected && <div>Alert stream disconnected</div>}
|
|
{alertsHealth?.available === false && (
|
|
<div>
|
|
Alert results may be stale or unavailable
|
|
{alertsHealth.reason ? `: ${alertsHealth.reason}` : ""}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => void onRefresh()}
|
|
>
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<div className="grid gap-2 sm:grid-cols-3">
|
|
<label
|
|
htmlFor="alert-severity"
|
|
className="grid gap-1 text-sm text-muted-foreground"
|
|
>
|
|
Severity
|
|
<Select
|
|
value={severity}
|
|
onValueChange={(value) => {
|
|
if (value) setSeverity(value);
|
|
}}
|
|
>
|
|
<SelectTrigger id="alert-severity" className="w-full">
|
|
<SelectValue placeholder="Severity" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{severities.map((v) => (
|
|
<SelectItem key={v} value={v}>
|
|
{v}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</label>
|
|
|
|
<label
|
|
htmlFor="alert-status"
|
|
className="grid gap-1 text-sm text-muted-foreground"
|
|
>
|
|
Status
|
|
<Select
|
|
value={status}
|
|
onValueChange={(value) => {
|
|
if (value) setStatus(value);
|
|
}}
|
|
>
|
|
<SelectTrigger id="alert-status" className="w-full">
|
|
<SelectValue placeholder="Status" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{statuses.map((v) => (
|
|
<SelectItem key={v} value={v}>
|
|
{v}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</label>
|
|
|
|
<label
|
|
htmlFor="alert-kind"
|
|
className="grid gap-1 text-sm text-muted-foreground"
|
|
>
|
|
Kind
|
|
<Select
|
|
value={kind}
|
|
onValueChange={(value) => {
|
|
if (value) setKind(value);
|
|
}}
|
|
>
|
|
<SelectTrigger id="alert-kind" className="w-full">
|
|
<SelectValue placeholder="Kind" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{kinds.map((v) => (
|
|
<SelectItem key={v} value={v}>
|
|
{v}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</label>
|
|
</div>
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
Showing {filteredAlerts.length} of {alerts.length}
|
|
</p>
|
|
|
|
<div className="grid gap-2">
|
|
{filteredAlerts.map((alert) => (
|
|
<div
|
|
className="flex items-center justify-between gap-2 rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
|
key={alert.alert_id}
|
|
>
|
|
<span>
|
|
{alert.kind}
|
|
{alert.agent_id ? ` (${alert.agent_id})` : ""}
|
|
</span>
|
|
<span className="text-muted-foreground">{alert.severity}</span>
|
|
</div>
|
|
))}
|
|
{!filteredAlerts.length && (
|
|
<div className="px-1 text-sm text-muted-foreground">
|
|
No active alerts
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="flex items-center justify-between gap-2">
|
|
<CardTitle>Transition History</CardTitle>
|
|
<span className="text-sm text-muted-foreground">
|
|
{filteredTransitions.length} shown
|
|
</span>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2">
|
|
<label
|
|
htmlFor="alert-transition-search"
|
|
className="grid gap-1 text-sm text-muted-foreground"
|
|
>
|
|
Search
|
|
<Input
|
|
id="alert-transition-search"
|
|
value={transitionQ}
|
|
onChange={(e) => setTransitionQ(e.target.value)}
|
|
placeholder="kind, status, message, agent"
|
|
/>
|
|
</label>
|
|
|
|
<Textarea
|
|
className="min-h-57.5 font-mono text-xs"
|
|
readOnly
|
|
value={JSON.stringify(filteredTransitions, null, 2)}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
</section>
|
|
);
|
|
}
|