diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 28d900a..1898ebd 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -68,6 +68,7 @@ import ErrorBoundary from "@/components/common/ErrorBoundary"; import CopilotWidget from "@/components/copilot/CopilotWidget"; import { NAV_GROUPS } from "@/lib/nav"; import { CommandPalette } from "@/components/common/CommandPalette"; +import { NotificationCenter } from "@/components/common/NotificationCenter"; import "./globals.css"; const inter = Inter({ subsets: ["latin"], variable: "--font-inter" }); @@ -331,6 +332,9 @@ export default function RootLayout({ + + + > diff --git a/frontend/src/components/common/NotificationCenter.tsx b/frontend/src/components/common/NotificationCenter.tsx new file mode 100644 index 0000000..62a22a0 --- /dev/null +++ b/frontend/src/components/common/NotificationCenter.tsx @@ -0,0 +1,227 @@ +"use client"; + +import * as React from "react"; +import { Bell, X } from "lucide-react"; + +import { useWebSocket } from "@/hooks/useWebSocket"; +import { SEVERITY_CLASSES } from "@/lib/tokens"; +import { cn } from "@/lib/utils"; +import type { WSMessage } from "@/lib/types"; + +interface LiveNotification { + id: string; + type: string; + title: string; + message: string; + severity: string; + receivedAt: number; + read: boolean; +} + +const MAX_ITEMS = 50; + +function severityClass(sev: string): string { + return SEVERITY_CLASSES[sev?.toLowerCase()] ?? SEVERITY_CLASSES.info; +} + +// Explicit (literal) dot colors so Tailwind's scanner keeps them. +const SEVERITY_DOT: Record = { + critical: "bg-red-500", + high: "bg-orange-500", + medium: "bg-yellow-500", + low: "bg-blue-400", + info: "bg-gray-400", +}; + +function severityDot(sev: string): string { + return SEVERITY_DOT[sev?.toLowerCase()] ?? SEVERITY_DOT.info; +} + +/** Best-effort title/body extraction across the varied payloads broadcast on + * the `notifications` channel (pending_action, autonomous_response, auto_response, + * mass_notification, agent dispatch …). */ +function normalize(data: Record, seq: number): LiveNotification { + const str = (v: unknown) => (typeof v === "string" ? v : undefined); + const type = str(data.type) ?? "notification"; + const title = + str(data.title) ?? + str(data.subject) ?? + str(data.summary) ?? + type.replace(/[_-]+/g, " "); + const message = + str(data.message) ?? str(data.summary) ?? str(data.threat_type) ?? ""; + const severity = str(data.severity) ?? "info"; + return { + id: `${seq}-${type}`, + type, + title, + message, + severity, + receivedAt: Date.now(), + read: false, + }; +} + +function timeAgo(ms: number): string { + const diff = Math.floor((Date.now() - ms) / 1000); + if (diff < 60) return `${diff}s ago`; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +} + +/** + * Live operator notification inbox. Subscribes to the `notifications` WS + * channel and surfaces incoming messages under a bell with an unread badge. + * Live-only (no server-side history endpoint exists for inbound notifications). + */ +export function NotificationCenter() { + const [items, setItems] = React.useState([]); + const [open, setOpen] = React.useState(false); + const seqRef = React.useRef(0); + const panelRef = React.useRef(null); + + const handleMessage = React.useCallback((msg: WSMessage) => { + if (msg.channel !== "notifications" && msg.channel !== "notification") return; + const next = normalize(msg.data ?? {}, seqRef.current++); + setItems((prev) => [next, ...prev].slice(0, MAX_ITEMS)); + }, []); + + const { connected } = useWebSocket({ + channels: ["notifications"], + onMessage: handleMessage, + }); + + const unread = items.filter((i) => !i.read).length; + + // Mark everything read when the panel is opened. + React.useEffect(() => { + if (open && unread > 0) { + setItems((prev) => prev.map((i) => ({ ...i, read: true }))); + } + }, [open]); // eslint-disable-line react-hooks/exhaustive-deps + + // Close on outside click / Escape. + React.useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { + if (panelRef.current && !panelRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + window.addEventListener("mousedown", onDown); + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("mousedown", onDown); + window.removeEventListener("keydown", onKey); + }; + }, [open]); + + return ( + + setOpen((o) => !o)} + title="Notifications" + className={cn( + "relative flex h-9 w-9 items-center justify-center rounded-lg border border-gray-800 bg-gray-950/80 text-gray-400 backdrop-blur transition-colors hover:text-gray-200", + open && "text-gray-200" + )} + > + + {unread > 0 && ( + + {unread > 99 ? "99+" : unread} + + )} + + + + {open && ( + + + + Notifications + + + {items.length > 0 && ( + setItems([])} + className="text-[11px] text-gray-500 hover:text-gray-300" + > + Clear + + )} + setOpen(false)} + className="text-gray-500 hover:text-gray-300" + > + + + + + + + {items.length === 0 ? ( + + + No notifications yet + + {connected ? "Listening for live events…" : "Reconnecting…"} + + + ) : ( + + {items.map((n) => ( + + + + + + + {n.title} + + + {timeAgo(n.receivedAt)} + + + {n.message && ( + + {n.message} + + )} + + {n.severity} + + + + + ))} + + )} + + + )} + + ); +} + +export default NotificationCenter;
No notifications yet
+ {connected ? "Listening for live events…" : "Reconnecting…"} +
+ {n.message} +