diff --git a/.ralph/fix_plan.md b/.ralph/fix_plan.md
index 3b731e4..59c67c3 100644
--- a/.ralph/fix_plan.md
+++ b/.ralph/fix_plan.md
@@ -204,22 +204,22 @@ Spec: `.ralph/specs/phase-10-frontend-shell.md`
---
-## Phase 11: Dashboard Page [PENDIENTE]
+## Phase 11: Dashboard Page [COMPLETO]
Spec: `.ralph/specs/phase-11-dashboard.md`
-- [ ] 11.1: Instalar en frontend: `npm i tremor recharts`
-- [ ] 11.2: Crear `hooks/useFindings.ts` — TanStack Query hooks: useFindings, useFindingStats
-- [ ] 11.3: Crear `hooks/useSessions.ts` — TanStack Query hooks: useSessions, useSession
-- [ ] 11.4: Crear `hooks/useSocket.ts` — socket.io-client connection con auto-reconnect
-- [ ] 11.5: Crear `components/dashboard/KPICards.tsx` — 4 cards Tremor: Total Findings, Critical/High, Active Sessions, Coverage
-- [ ] 11.6: Crear `components/dashboard/TrendChart.tsx` — Recharts AreaChart stacked por severity, últimos 30 días
-- [ ] 11.7: Crear `components/dashboard/SeverityDistribution.tsx` — Recharts PieChart con colores por severity
-- [ ] 11.8: Crear `components/dashboard/RecentFindings.tsx` — TanStack Table, 10 rows, click → /findings/:id
-- [ ] 11.9: Crear `components/dashboard/ActiveSessions.tsx` — lista con progress bars, click → /sessions/:id
-- [ ] 11.10: Crear `components/dashboard/QuickActions.tsx` — botón "New Exploration" prominente
-- [ ] 11.11: Crear `pages/Dashboard.tsx` — ensambla todo, responsive 2col desktop 1col mobile
-- [ ] 11.12: Conectar real-time: socket events actualizan KPIs y recent findings
-- [ ] 11.13: Verificar frontend build + commit: `fase(11): dashboard page with charts and realtime`
+- [x] 11.1: Instalar en frontend: `npm i tremor recharts`
+- [x] 11.2: Crear `hooks/useFindings.ts` — TanStack Query hooks: useFindings, useFindingStats
+- [x] 11.3: Crear `hooks/useSessions.ts` — TanStack Query hooks: useSessions, useSession
+- [x] 11.4: Crear `hooks/useSocket.ts` — socket.io-client connection con auto-reconnect
+- [x] 11.5: Crear `components/dashboard/KPICards.tsx` — 4 cards Tremor: Total Findings, Critical/High, Active Sessions, Coverage
+- [x] 11.6: Crear `components/dashboard/TrendChart.tsx` — Recharts AreaChart stacked por severity, últimos 30 días
+- [x] 11.7: Crear `components/dashboard/SeverityDistribution.tsx` — Recharts PieChart con colores por severity
+- [x] 11.8: Crear `components/dashboard/RecentFindings.tsx` — TanStack Table, 10 rows, click → /findings/:id
+- [x] 11.9: Crear `components/dashboard/ActiveSessions.tsx` — lista con progress bars, click → /sessions/:id
+- [x] 11.10: Crear `components/dashboard/QuickActions.tsx` — botón "New Exploration" prominente
+- [x] 11.11: Crear `pages/Dashboard.tsx` — ensambla todo, responsive 2col desktop 1col mobile
+- [x] 11.12: Conectar real-time: socket events actualizan KPIs y recent findings
+- [x] 11.13: Verificar frontend build + commit: `fase(11): dashboard page with charts and realtime`
---
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 40d5fbe..ad999fe 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -9,14 +9,8 @@ import { queryClient } from '@/lib/queryClient'
import { Login } from '@/pages/Login'
import { Setup } from '@/pages/Setup'
import { Dashboard } from '@/pages/Dashboard'
-
-// Lazy placeholders for future phases
-function SessionList() {
- return
Explorations — Coming in Phase 12
-}
-function SessionDetail() {
- return Session Detail — Coming in Phase 12
-}
+import { SessionList } from '@/pages/sessions/SessionList'
+import { SessionDetail } from '@/pages/sessions/SessionDetail'
function FindingsList() {
return Findings — Coming in Phase 13
}
diff --git a/frontend/src/components/sessions/LiveFeed.tsx b/frontend/src/components/sessions/LiveFeed.tsx
new file mode 100644
index 0000000..7fea994
--- /dev/null
+++ b/frontend/src/components/sessions/LiveFeed.tsx
@@ -0,0 +1,55 @@
+import { useEffect, useRef } from 'react'
+import { ScrollArea } from '@/components/ui/scroll-area'
+
+export interface FeedEvent {
+ id: string
+ event: string
+ text: string
+ timestamp: number
+}
+
+const EVENT_COLOR: Record = {
+ 'state:discovered': 'text-green-500',
+ 'action:executed': 'text-yellow-500',
+ 'anomaly:detected': 'text-red-500',
+ 'session:started': 'text-blue-500',
+ 'session:completed': 'text-blue-500',
+ 'session:error': 'text-red-600',
+}
+
+interface LiveFeedProps {
+ events: FeedEvent[]
+}
+
+export function LiveFeed({ events }: LiveFeedProps) {
+ const bottomRef = useRef(null)
+
+ useEffect(() => {
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
+ }, [events.length])
+
+ return (
+
+
+ {events.length === 0 ? (
+ Waiting for events...
+ ) : (
+
+ {events.map(e => (
+
+
+ {new Date(e.timestamp).toLocaleTimeString()}
+
+
+ {e.event}
+
+ {e.text}
+
+ ))}
+
+ )}
+
+
+
+ )
+}
diff --git a/frontend/src/components/sessions/NewExplorationForm.tsx b/frontend/src/components/sessions/NewExplorationForm.tsx
new file mode 100644
index 0000000..9c11286
--- /dev/null
+++ b/frontend/src/components/sessions/NewExplorationForm.tsx
@@ -0,0 +1,260 @@
+import { useState } from 'react'
+import { useForm } from 'react-hook-form'
+import { z } from 'zod'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Switch } from '@/components/ui/switch'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { X, ChevronDown, ChevronRight } from 'lucide-react'
+import { apiFetch } from '@/lib/api'
+
+const schema = z.object({
+ url: z.string().url('Must be a valid URL'),
+ seed: z.number().int().min(1).max(9999999),
+ maxStates: z.number().int().min(1).max(500),
+ maxDepth: z.number().int().min(1).max(20),
+ fuzzingEnabled: z.boolean(),
+ fuzzingIntensity: z.enum(['low', 'medium', 'high']),
+ authType: z.enum(['none', 'cookies', 'headers', 'login_flow']),
+})
+
+type FormValues = z.infer
+
+interface NewExplorationFormProps {
+ onCreated: (sessionId: string) => void
+ onCancel: () => void
+}
+
+function ChipInput({
+ label,
+ values,
+ onChange,
+ placeholder,
+}: {
+ label: string
+ values: string[]
+ onChange: (v: string[]) => void
+ placeholder: string
+}) {
+ const [input, setInput] = useState('')
+
+ function add() {
+ const v = input.trim()
+ if (v && !values.includes(v)) {
+ onChange([...values, v])
+ }
+ setInput('')
+ }
+
+ return (
+
+
{label}
+
+ setInput(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); add() } }}
+ placeholder={placeholder}
+ />
+ Add
+
+ {values.length > 0 && (
+
+ {values.map(v => (
+
+ {v}
+ onChange(values.filter(x => x !== v))}>
+
+
+
+ ))}
+
+ )}
+
+ )
+}
+
+export function NewExplorationForm({ onCreated, onCancel }: NewExplorationFormProps) {
+ const [allowedDomains, setAllowedDomains] = useState([])
+ const [excludedPaths, setExcludedPaths] = useState([])
+ const [showAdvanced, setShowAdvanced] = useState(false)
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ const form = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: {
+ url: 'http://localhost:3000',
+ seed: 42,
+ maxStates: 50,
+ maxDepth: 5,
+ fuzzingEnabled: true,
+ fuzzingIntensity: 'medium',
+ authType: 'none',
+ },
+ })
+
+ const authType = form.watch('authType')
+ const fuzzingEnabled = form.watch('fuzzingEnabled')
+
+ async function onSubmit(values: FormValues) {
+ setError(null)
+ setLoading(true)
+ try {
+ const res = await apiFetch<{ sessionId: string }>('/api/sessions', {
+ method: 'POST',
+ body: JSON.stringify({
+ url: values.url,
+ seed: values.seed,
+ config: {
+ maxStates: values.maxStates,
+ maxDepth: values.maxDepth,
+ allowedDomains,
+ excludedPaths,
+ fuzzingEnabled: values.fuzzingEnabled,
+ fuzzingIntensity: values.fuzzingIntensity,
+ auth: values.authType === 'none' ? null : { type: values.authType },
+ },
+ }),
+ })
+ onCreated(res.sessionId)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to start exploration')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
+ New Exploration
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/sessions/SessionConfig.tsx b/frontend/src/components/sessions/SessionConfig.tsx
new file mode 100644
index 0000000..b953024
--- /dev/null
+++ b/frontend/src/components/sessions/SessionConfig.tsx
@@ -0,0 +1,41 @@
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import type { Session } from '../../types'
+
+interface SessionConfigProps {
+ session: Session
+}
+
+export function SessionConfig({ session }: SessionConfigProps) {
+ const duration = session.finishedAt
+ ? Math.round((new Date(session.finishedAt).getTime() - new Date(session.startedAt).getTime()) / 1000)
+ : null
+
+ const rows = [
+ { label: 'URL', value: session.url },
+ { label: 'Status', value: session.status },
+ { label: 'Started', value: new Date(session.startedAt).toLocaleString() },
+ ...(session.finishedAt ? [{ label: 'Finished', value: new Date(session.finishedAt).toLocaleString() }] : []),
+ ...(duration !== null ? [{ label: 'Duration', value: `${duration}s` }] : []),
+ { label: 'States Visited', value: String(session.statesVisited) },
+ { label: 'Findings', value: String(session.anomaliesFound) },
+ ...(session.seed != null ? [{ label: 'Seed', value: String(session.seed) }] : []),
+ ]
+
+ return (
+
+
+ Session Configuration
+
+
+
+ {rows.map(row => (
+
+
{row.label}
+ {row.value}
+
+ ))}
+
+
+
+ )
+}
diff --git a/frontend/src/components/sessions/SessionFindings.tsx b/frontend/src/components/sessions/SessionFindings.tsx
new file mode 100644
index 0000000..e3c1870
--- /dev/null
+++ b/frontend/src/components/sessions/SessionFindings.tsx
@@ -0,0 +1,68 @@
+import { useNavigate } from 'react-router-dom'
+import { useFindings } from '@/hooks/useFindings'
+import { SeverityBadge } from '@/components/common/SeverityBadge'
+import { Skeleton } from '@/components/ui/skeleton'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+
+interface SessionFindingsProps {
+ sessionId: string
+}
+
+export function SessionFindings({ sessionId }: SessionFindingsProps) {
+ const { data: findings = [], isLoading } = useFindings({ sessionId })
+ const navigate = useNavigate()
+
+ if (isLoading) {
+ return (
+
+ {[1, 2, 3].map(i => )}
+
+ )
+ }
+
+ if (findings.length === 0) {
+ return (
+
+ No findings yet for this session.
+
+ )
+ }
+
+ return (
+
+
+
+ Severity
+ Type
+ Description
+ Time
+
+
+
+ {findings.map(f => (
+ navigate(`/findings/${f.id}`)}
+ >
+
+ {f.type}
+
+ {f.description}
+
+
+ {new Date(f.timestamp).toLocaleString()}
+
+
+ ))}
+
+
+ )
+}
diff --git a/frontend/src/components/ui/alert-dialog.tsx b/frontend/src/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..7e6c036
--- /dev/null
+++ b/frontend/src/components/ui/alert-dialog.tsx
@@ -0,0 +1,194 @@
+import * as React from "react"
+import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+
+function AlertDialog({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function AlertDialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogContent({
+ className,
+ size = "default",
+ ...props
+}: React.ComponentProps & {
+ size?: "default" | "sm"
+}) {
+ return (
+
+
+
+
+ )
+}
+
+function AlertDialogHeader({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogFooter({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogMedia({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogAction({
+ className,
+ variant = "default",
+ size = "default",
+ ...props
+}: React.ComponentProps &
+ Pick, "variant" | "size">) {
+ return (
+
+
+
+ )
+}
+
+function AlertDialogCancel({
+ className,
+ variant = "outline",
+ size = "default",
+ ...props
+}: React.ComponentProps &
+ Pick, "variant" | "size">) {
+ return (
+
+
+
+ )
+}
+
+export {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogMedia,
+ AlertDialogOverlay,
+ AlertDialogPortal,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+}
diff --git a/frontend/src/components/ui/scroll-area.tsx b/frontend/src/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..e38a492
--- /dev/null
+++ b/frontend/src/components/ui/scroll-area.tsx
@@ -0,0 +1,56 @@
+import * as React from "react"
+import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function ScrollArea({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function ScrollBar({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export { ScrollArea, ScrollBar }
diff --git a/frontend/src/pages/sessions/SessionDetail.tsx b/frontend/src/pages/sessions/SessionDetail.tsx
new file mode 100644
index 0000000..70ad74c
--- /dev/null
+++ b/frontend/src/pages/sessions/SessionDetail.tsx
@@ -0,0 +1,193 @@
+import { useState, useCallback } from 'react'
+import { useParams, useNavigate } from 'react-router-dom'
+import { useQueryClient } from '@tanstack/react-query'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { Button } from '@/components/ui/button'
+import { Badge } from '@/components/ui/badge'
+import { Skeleton } from '@/components/ui/skeleton'
+import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'
+import { ArrowLeft, Square } from 'lucide-react'
+import { useSession } from '@/hooks/useSessions'
+import { useSocket } from '@/hooks/useSocket'
+import { LiveFeed, type FeedEvent } from '@/components/sessions/LiveFeed'
+import { SessionFindings } from '@/components/sessions/SessionFindings'
+import { SessionConfig } from '@/components/sessions/SessionConfig'
+import { apiFetch } from '@/lib/api'
+import type { WsStateDiscovered, WsActionExecuted, WsAnomalyDetected, WsSessionCompleted, WsSessionError, WsSessionStarted } from '../../types'
+
+let eventCounter = 0
+function nextId() { return String(++eventCounter) }
+
+const STATUS_COLOR: Record = {
+ running: 'bg-green-500/15 text-green-500 border-green-500/30',
+ completed: 'bg-blue-500/15 text-blue-500 border-blue-500/30',
+ stopped: 'bg-yellow-500/15 text-yellow-600 border-yellow-500/30',
+ error: 'bg-red-500/15 text-red-500 border-red-500/30',
+}
+
+export function SessionDetail() {
+ const { id } = useParams<{ id: string }>()
+ const navigate = useNavigate()
+ const queryClient = useQueryClient()
+ const { data: session, isLoading } = useSession(id ?? '')
+ const [feedEvents, setFeedEvents] = useState([])
+ const [stopping, setStopping] = useState(false)
+
+ function pushEvent(event: string, text: string) {
+ setFeedEvents(prev => [...prev, { id: nextId(), event, text, timestamp: Date.now() }])
+ }
+
+ useSocket(useCallback((event: string, data: unknown) => {
+ const d = data as Record
+ if (d['sessionId'] !== id) return
+
+ switch (event) {
+ case 'session:started':
+ pushEvent(event, `Started: ${(d as unknown as WsSessionStarted).url}`)
+ void queryClient.invalidateQueries({ queryKey: ['sessions', id] })
+ break
+ case 'state:discovered': {
+ const s = d as unknown as WsStateDiscovered
+ pushEvent(event, `${s.title || 'Untitled'} — ${s.url}`)
+ void queryClient.invalidateQueries({ queryKey: ['sessions', id] })
+ break
+ }
+ case 'action:executed': {
+ const a = d as unknown as WsActionExecuted
+ pushEvent(event, `${a.actionType}${a.selector ? ` on ${a.selector}` : ''}`)
+ break
+ }
+ case 'anomaly:detected': {
+ const an = d as unknown as WsAnomalyDetected
+ pushEvent(event, `[${an.severity.toUpperCase()}] ${an.type}: ${an.description}`)
+ void queryClient.invalidateQueries({ queryKey: ['findings'] })
+ break
+ }
+ case 'session:completed': {
+ const c = d as unknown as WsSessionCompleted
+ pushEvent(event, `Completed — ${c.statesVisited} states, ${c.anomaliesFound} findings`)
+ void queryClient.invalidateQueries({ queryKey: ['sessions', id] })
+ break
+ }
+ case 'session:error': {
+ const e = d as unknown as WsSessionError
+ pushEvent(event, `Error: ${e.error}`)
+ void queryClient.invalidateQueries({ queryKey: ['sessions', id] })
+ break
+ }
+ }
+ }, [id, queryClient]))
+
+ async function handleStop() {
+ if (!id) return
+ setStopping(true)
+ try {
+ await apiFetch(`/api/sessions/${id}`, { method: 'DELETE' })
+ await queryClient.invalidateQueries({ queryKey: ['sessions'] })
+ } finally {
+ setStopping(false)
+ }
+ }
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+ )
+ }
+
+ if (!session) {
+ return Session not found.
+ }
+
+ const isRunning = session.status === 'running'
+
+ return (
+
+ {/* Header */}
+
+
navigate('/sessions')}>
+
+
+
+
+
{session.url}
+
+ {session.status}
+
+
+
+ {session.statesVisited} states · {session.anomaliesFound} findings
+
+
+ {isRunning && (
+
+
+
+
+ Stop
+
+
+
+
+ Stop exploration?
+
+ This will stop the current exploration. All discovered states and findings will be kept.
+
+
+
+ Cancel
+ void handleStop()}>Stop
+
+
+
+ )}
+
+
+ {/* Progress bar */}
+ {isRunning && (
+
+
+ States explored
+ {session.statesVisited}
+
+
+
+ )}
+
+ {/* Tabs */}
+
+
+ Live Feed
+
+ Findings
+ {session.anomaliesFound > 0 && (
+ {session.anomaliesFound}
+ )}
+
+ Config
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/sessions/SessionList.tsx b/frontend/src/pages/sessions/SessionList.tsx
new file mode 100644
index 0000000..b7d67cc
--- /dev/null
+++ b/frontend/src/pages/sessions/SessionList.tsx
@@ -0,0 +1,203 @@
+import { useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { useQueryClient } from '@tanstack/react-query'
+import {
+ useReactTable,
+ getCoreRowModel,
+ getSortedRowModel,
+ getFilteredRowModel,
+ flexRender,
+ type ColumnDef,
+ type SortingState,
+} from '@tanstack/react-table'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Badge } from '@/components/ui/badge'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { Dialog, DialogContent } from '@/components/ui/dialog'
+import { Skeleton } from '@/components/ui/skeleton'
+import { Plus, ArrowUpDown } from 'lucide-react'
+import { useSessions } from '@/hooks/useSessions'
+import { NewExplorationForm } from '@/components/sessions/NewExplorationForm'
+import type { Session, SessionStatus } from '../../types'
+
+const STATUS_COLOR: Record = {
+ running: 'bg-green-500/15 text-green-500 border-green-500/30',
+ completed: 'bg-blue-500/15 text-blue-500 border-blue-500/30',
+ stopped: 'bg-yellow-500/15 text-yellow-600 border-yellow-500/30',
+ error: 'bg-red-500/15 text-red-500 border-red-500/30',
+}
+
+function formatDuration(start: string, end?: string): string {
+ const ms = (end ? new Date(end).getTime() : Date.now()) - new Date(start).getTime()
+ const s = Math.floor(ms / 1000)
+ if (s < 60) return `${s}s`
+ const m = Math.floor(s / 60)
+ if (m < 60) return `${m}m ${s % 60}s`
+ return `${Math.floor(m / 60)}h ${m % 60}m`
+}
+
+const columns: ColumnDef[] = [
+ {
+ accessorKey: 'status',
+ header: 'Status',
+ cell: ({ row }) => (
+
+ {row.original.status}
+
+ ),
+ },
+ {
+ accessorKey: 'url',
+ header: ({ column }) => (
+ column.toggleSorting()}
+ >
+ URL
+
+ ),
+ cell: ({ row }) => (
+ {row.original.url}
+ ),
+ },
+ {
+ accessorKey: 'anomaliesFound',
+ header: 'Findings',
+ cell: ({ row }) => (
+ 0 ? 'text-destructive font-medium' : ''}>
+ {row.original.anomaliesFound}
+
+ ),
+ },
+ {
+ id: 'duration',
+ header: 'Duration',
+ cell: ({ row }) => formatDuration(row.original.startedAt, row.original.finishedAt),
+ },
+ {
+ accessorKey: 'startedAt',
+ header: ({ column }) => (
+ column.toggleSorting()}
+ >
+ Started
+
+ ),
+ cell: ({ row }) => (
+
+ {new Date(row.original.startedAt).toLocaleString()}
+
+ ),
+ },
+]
+
+export function SessionList() {
+ const { data: sessions = [], isLoading } = useSessions()
+ const navigate = useNavigate()
+ const queryClient = useQueryClient()
+ const [sorting, setSorting] = useState([{ id: 'startedAt', desc: true }])
+ const [filter, setFilter] = useState('')
+ const [showForm, setShowForm] = useState(false)
+
+ const table = useReactTable({
+ data: sessions,
+ columns,
+ state: { sorting, globalFilter: filter },
+ onSortingChange: setSorting,
+ onGlobalFilterChange: setFilter,
+ getCoreRowModel: getCoreRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ })
+
+ function handleCreated(sessionId: string) {
+ setShowForm(false)
+ void queryClient.invalidateQueries({ queryKey: ['sessions'] })
+ navigate(`/sessions/${sessionId}`)
+ }
+
+ return (
+
+
+
+
Explorations
+
{sessions.length} sessions
+
+
setShowForm(true)} className="gap-2">
+
+ New Exploration
+
+
+
+
setFilter(e.target.value)}
+ className="max-w-sm"
+ />
+
+ {isLoading ? (
+
+ {[1, 2, 3].map(i => )}
+
+ ) : (
+
+
+
+ {table.getHeaderGroups().map(hg => (
+
+ {hg.headers.map(h => (
+
+ {flexRender(h.column.columnDef.header, h.getContext())}
+
+ ))}
+
+ ))}
+
+
+ {table.getRowModel().rows.length === 0 ? (
+
+
+ No sessions yet. Start your first exploration!
+
+
+ ) : (
+ table.getRowModel().rows.map(row => (
+ navigate(`/sessions/${row.original.sessionId}`)}
+ >
+ {row.getVisibleCells().map(cell => (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ ))}
+
+ ))
+ )}
+
+
+
+ )}
+
+
+
+ setShowForm(false)}
+ />
+
+
+
+ )
+}