| import { useMemo } from "react" |
| import { Inbox } from "lucide-react" |
| import { useDashboard } from "@/lib/dashboard" |
| import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" |
| import { |
| Table, |
| TableBody, |
| TableCell, |
| TableHead, |
| TableHeader, |
| TableRow, |
| } from "@/components/ui/table" |
| import { Badge } from "@/components/ui/badge" |
|
|
| |
| function hueFor(label: string): number { |
| let h = 0 |
| for (let i = 0; i < label.length; i++) h = (h * 31 + label.charCodeAt(i)) % 360 |
| return h |
| } |
|
|
| function StatusBadge({ status }: { status: string }) { |
| const variant = status.includes("fail") || status.includes("missing") |
| ? "danger" |
| : status.includes("posted") |
| ? "info" |
| : status.includes("block") |
| ? "warning" |
| : "live" |
| return <Badge variant={variant}>{status}</Badge> |
| } |
|
|
| export function ActivityPanel() { |
| const { result } = useDashboard() |
| const detections = result?.detections ?? [] |
| const events = result?.events ?? [] |
|
|
| const maxT = useMemo( |
| () => |
| Math.max( |
| 0.001, |
| ...detections.map((d) => d.timestamp_sec), |
| ...events.map((e) => e.timestamp_sec), |
| ), |
| [detections, events], |
| ) |
|
|
| const labels = useMemo( |
| () => Array.from(new Set(detections.map((d) => d.label))), |
| [detections], |
| ) |
|
|
| if (!result) { |
| return ( |
| <div className="flex flex-col items-center gap-2 py-10 text-center text-muted-foreground"> |
| <Inbox className="size-7 opacity-40" /> |
| <p className="text-sm">Run detection to populate the activity timeline.</p> |
| </div> |
| ) |
| } |
|
|
| return ( |
| <div className="space-y-5"> |
| {/* timeline track */} |
| <div className="space-y-2"> |
| <div className="flex items-center justify-between font-mono text-[0.625rem] uppercase tracking-wider text-muted-foreground"> |
| <span>0.0s</span> |
| <span>detection timeline</span> |
| <span>{maxT.toFixed(1)}s</span> |
| </div> |
| <div className="relative h-16 overflow-hidden rounded-lg border border-border bg-black/30"> |
| {/* gridlines */} |
| {[0.25, 0.5, 0.75].map((f) => ( |
| <div |
| key={f} |
| className="absolute inset-y-0 w-px bg-white/5" |
| style={{ left: `${f * 100}%` }} |
| /> |
| ))} |
| {/* detection ticks */} |
| {detections.map((d, i) => ( |
| <span |
| key={i} |
| className="absolute top-1/2 size-1.5 -translate-y-1/2 rounded-full" |
| style={{ |
| left: `${(d.timestamp_sec / maxT) * 100}%`, |
| backgroundColor: `oklch(0.75 0.13 ${hueFor(d.label)})`, |
| opacity: 0.25 + d.confidence * 0.75, |
| }} |
| /> |
| ))} |
| {/* event markers */} |
| {events.map((e, i) => ( |
| <div |
| key={i} |
| className="absolute inset-y-0 w-0.5 bg-primary" |
| style={{ left: `${(e.timestamp_sec / maxT) * 100}%` }} |
| > |
| <span className="absolute -top-0 left-1/2 size-2 -translate-x-1/2 rounded-full bg-primary signal-dot" /> |
| </div> |
| ))} |
| </div> |
| {/* legend */} |
| <div className="flex flex-wrap gap-x-4 gap-y-1"> |
| {labels.map((l) => ( |
| <div key={l} className="flex items-center gap-1.5 text-[0.6875rem] text-muted-foreground"> |
| <span |
| className="size-2 rounded-full" |
| style={{ backgroundColor: `oklch(0.75 0.13 ${hueFor(l)})` }} |
| /> |
| {l} |
| </div> |
| ))} |
| </div> |
| </div> |
| |
| {/* tables */} |
| <Tabs defaultValue="actions"> |
| <TabsList> |
| <TabsTrigger value="actions"> |
| Actions |
| <span className="ml-1 font-mono text-[0.625rem] text-muted-foreground"> |
| {events.length} |
| </span> |
| </TabsTrigger> |
| <TabsTrigger value="detections"> |
| Detections |
| <span className="ml-1 font-mono text-[0.625rem] text-muted-foreground"> |
| {detections.length} |
| </span> |
| </TabsTrigger> |
| </TabsList> |
| |
| <TabsContent value="actions"> |
| {events.length ? ( |
| <Table> |
| <TableHeader> |
| <TableRow> |
| <TableHead>Time</TableHead> |
| <TableHead>Rule</TableHead> |
| <TableHead>Action</TableHead> |
| <TableHead>Status</TableHead> |
| <TableHead className="text-right">Frame</TableHead> |
| </TableRow> |
| </TableHeader> |
| <TableBody> |
| {events.map((e, i) => ( |
| <TableRow key={i}> |
| <TableCell className="font-mono text-xs tabular-nums text-muted-foreground"> |
| {e.timestamp_sec.toFixed(1)}s |
| </TableCell> |
| <TableCell className="font-mono text-xs">{e.rule}</TableCell> |
| <TableCell className="text-xs">{e.action}</TableCell> |
| <TableCell> |
| <StatusBadge status={e.status} /> |
| </TableCell> |
| <TableCell className="text-right font-mono text-xs tabular-nums text-muted-foreground"> |
| {e.frame_index} |
| </TableCell> |
| </TableRow> |
| ))} |
| </TableBody> |
| </Table> |
| ) : ( |
| <p className="py-6 text-center text-sm text-muted-foreground"> |
| No actions fired this run. |
| </p> |
| )} |
| </TabsContent> |
| |
| <TabsContent value="detections"> |
| <div className="max-h-72 overflow-y-auto"> |
| <Table> |
| <TableHeader> |
| <TableRow> |
| <TableHead className="text-right">Frame</TableHead> |
| <TableHead>Label</TableHead> |
| <TableHead className="text-right">Conf.</TableHead> |
| <TableHead className="text-right">Time</TableHead> |
| </TableRow> |
| </TableHeader> |
| <TableBody> |
| {detections.slice(0, 200).map((d, i) => ( |
| <TableRow key={i}> |
| <TableCell className="text-right font-mono text-xs tabular-nums text-muted-foreground"> |
| {d.frame_index} |
| </TableCell> |
| <TableCell> |
| <span className="flex items-center gap-1.5 text-xs"> |
| <span |
| className="size-2 rounded-full" |
| style={{ backgroundColor: `oklch(0.75 0.13 ${hueFor(d.label)})` }} |
| /> |
| {d.label} |
| </span> |
| </TableCell> |
| <TableCell className="text-right font-mono text-xs tabular-nums"> |
| {d.confidence.toFixed(2)} |
| </TableCell> |
| <TableCell className="text-right font-mono text-xs tabular-nums text-muted-foreground"> |
| {d.timestamp_sec.toFixed(1)}s |
| </TableCell> |
| </TableRow> |
| ))} |
| </TableBody> |
| </Table> |
| </div> |
| </TabsContent> |
| </Tabs> |
| </div> |
| ) |
| } |
|
|