fase(12): session pages with live feed

This commit is contained in:
debian
2026-03-05 10:34:31 -05:00
parent 458302ca86
commit 3ff36f0b6a
10 changed files with 1086 additions and 22 deletions

View File

@@ -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` Spec: `.ralph/specs/phase-11-dashboard.md`
- [ ] 11.1: Instalar en frontend: `npm i tremor recharts` - [x] 11.1: Instalar en frontend: `npm i tremor recharts`
- [ ] 11.2: Crear `hooks/useFindings.ts` — TanStack Query hooks: useFindings, useFindingStats - [x] 11.2: Crear `hooks/useFindings.ts` — TanStack Query hooks: useFindings, useFindingStats
- [ ] 11.3: Crear `hooks/useSessions.ts` — TanStack Query hooks: useSessions, useSession - [x] 11.3: Crear `hooks/useSessions.ts` — TanStack Query hooks: useSessions, useSession
- [ ] 11.4: Crear `hooks/useSocket.ts` — socket.io-client connection con auto-reconnect - [x] 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 - [x] 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 - [x] 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 - [x] 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 - [x] 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 - [x] 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 - [x] 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 - [x] 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 - [x] 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.13: Verificar frontend build + commit: `fase(11): dashboard page with charts and realtime`
--- ---

View File

@@ -9,14 +9,8 @@ import { queryClient } from '@/lib/queryClient'
import { Login } from '@/pages/Login' import { Login } from '@/pages/Login'
import { Setup } from '@/pages/Setup' import { Setup } from '@/pages/Setup'
import { Dashboard } from '@/pages/Dashboard' import { Dashboard } from '@/pages/Dashboard'
import { SessionList } from '@/pages/sessions/SessionList'
// Lazy placeholders for future phases import { SessionDetail } from '@/pages/sessions/SessionDetail'
function SessionList() {
return <div className="text-muted-foreground p-4">Explorations Coming in Phase 12</div>
}
function SessionDetail() {
return <div className="text-muted-foreground p-4">Session Detail Coming in Phase 12</div>
}
function FindingsList() { function FindingsList() {
return <div className="text-muted-foreground p-4">Findings Coming in Phase 13</div> return <div className="text-muted-foreground p-4">Findings Coming in Phase 13</div>
} }

View File

@@ -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<string, string> = {
'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<HTMLDivElement>(null)
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [events.length])
return (
<div className="h-72 rounded-lg border bg-black/80 font-mono text-xs overflow-hidden">
<ScrollArea className="h-full p-3">
{events.length === 0 ? (
<p className="text-muted-foreground">Waiting for events...</p>
) : (
<div className="space-y-0.5">
{events.map(e => (
<div key={e.id} className="flex gap-2">
<span className="text-muted-foreground shrink-0">
{new Date(e.timestamp).toLocaleTimeString()}
</span>
<span className={`font-semibold shrink-0 ${EVENT_COLOR[e.event] ?? 'text-muted-foreground'}`}>
{e.event}
</span>
<span className="text-foreground/80 truncate">{e.text}</span>
</div>
))}
</div>
)}
<div ref={bottomRef} />
</ScrollArea>
</div>
)
}

View File

@@ -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<typeof schema>
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 (
<div className="space-y-1.5">
<Label>{label}</Label>
<div className="flex gap-2">
<Input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); add() } }}
placeholder={placeholder}
/>
<Button type="button" variant="outline" size="sm" onClick={add}>Add</Button>
</div>
{values.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{values.map(v => (
<Badge key={v} variant="secondary" className="gap-1">
{v}
<button type="button" onClick={() => onChange(values.filter(x => x !== v))}>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</div>
)
}
export function NewExplorationForm({ onCreated, onCancel }: NewExplorationFormProps) {
const [allowedDomains, setAllowedDomains] = useState<string[]>([])
const [excludedPaths, setExcludedPaths] = useState<string[]>([])
const [showAdvanced, setShowAdvanced] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const form = useForm<FormValues>({
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 (
<Card>
<CardHeader>
<CardTitle>New Exploration</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={(e) => { void form.handleSubmit(onSubmit)(e) }} className="space-y-4">
{/* URL */}
<div className="space-y-1.5">
<Label htmlFor="url">Target URL *</Label>
<Input id="url" {...form.register('url')} placeholder="https://app.example.com" />
{form.formState.errors.url && (
<p className="text-xs text-destructive">{form.formState.errors.url.message}</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
{/* Seed */}
<div className="space-y-1.5">
<Label htmlFor="seed">Seed</Label>
<Input
id="seed"
type="number"
{...form.register('seed', { valueAsNumber: true })}
/>
</div>
{/* Max States */}
<div className="space-y-1.5">
<Label htmlFor="maxStates">Max States</Label>
<Input
id="maxStates"
type="number"
{...form.register('maxStates', { valueAsNumber: true })}
/>
</div>
</div>
{/* Fuzzing */}
<div className="flex items-center gap-3">
<Switch
id="fuzzing"
checked={fuzzingEnabled}
onCheckedChange={v => form.setValue('fuzzingEnabled', v)}
/>
<Label htmlFor="fuzzing">Enable Fuzzing</Label>
{fuzzingEnabled && (
<Select
value={form.watch('fuzzingIntensity')}
onValueChange={v => form.setValue('fuzzingIntensity', v as 'low' | 'medium' | 'high')}
>
<SelectTrigger className="w-28 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
</SelectContent>
</Select>
)}
</div>
{/* Auth */}
<div className="space-y-1.5">
<Label>Authentication</Label>
<Select
value={authType}
onValueChange={v => form.setValue('authType', v as FormValues['authType'])}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="cookies">Cookie-based</SelectItem>
<SelectItem value="headers">Custom Headers</SelectItem>
<SelectItem value="login_flow">Login Flow</SelectItem>
</SelectContent>
</Select>
</div>
{/* Advanced */}
<button
type="button"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setShowAdvanced(v => !v)}
>
{showAdvanced ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
Advanced options
</button>
{showAdvanced && (
<div className="space-y-4 p-4 rounded-lg border bg-muted/20">
<div className="space-y-1.5">
<Label htmlFor="maxDepth">Max Depth</Label>
<Input
id="maxDepth"
type="number"
{...form.register('maxDepth', { valueAsNumber: true })}
/>
</div>
<ChipInput
label="Allowed Domains"
values={allowedDomains}
onChange={setAllowedDomains}
placeholder="example.com"
/>
<ChipInput
label="Excluded Paths"
values={excludedPaths}
onChange={setExcludedPaths}
placeholder="/admin/*"
/>
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={onCancel}>Cancel</Button>
<Button type="submit" disabled={loading}>
{loading ? 'Starting...' : 'Start Exploration'}
</Button>
</div>
</form>
</CardContent>
</Card>
)
}

View File

@@ -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 (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Session Configuration</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-2">
{rows.map(row => (
<div key={row.label} className="flex gap-3 text-sm">
<dt className="text-muted-foreground w-28 shrink-0">{row.label}</dt>
<dd className="font-mono break-all">{row.value}</dd>
</div>
))}
</dl>
</CardContent>
</Card>
)
}

View File

@@ -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 (
<div className="space-y-2">
{[1, 2, 3].map(i => <Skeleton key={i} className="h-10 w-full" />)}
</div>
)
}
if (findings.length === 0) {
return (
<div className="text-center py-12 text-muted-foreground">
No findings yet for this session.
</div>
)
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Severity</TableHead>
<TableHead>Type</TableHead>
<TableHead className="hidden md:table-cell">Description</TableHead>
<TableHead className="hidden sm:table-cell">Time</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{findings.map(f => (
<TableRow
key={f.id}
className="cursor-pointer"
onClick={() => navigate(`/findings/${f.id}`)}
>
<TableCell><SeverityBadge severity={f.severity} /></TableCell>
<TableCell className="font-mono text-xs">{f.type}</TableCell>
<TableCell className="hidden md:table-cell text-sm text-muted-foreground max-w-xs truncate">
{f.description}
</TableCell>
<TableCell className="hidden sm:table-cell text-xs text-muted-foreground">
{new Date(f.timestamp).toLocaleString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}

View File

@@ -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<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}

View File

@@ -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<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -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<string, string> = {
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<FeedEvent[]>([])
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<string, unknown>
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 (
<div className="space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-96" />
<Skeleton className="h-64 w-full" />
</div>
)
}
if (!session) {
return <div className="text-muted-foreground">Session not found.</div>
}
const isRunning = session.status === 'running'
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate('/sessions')}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold truncate">{session.url}</h1>
<Badge variant="outline" className={STATUS_COLOR[session.status]}>
{session.status}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{session.statesVisited} states · {session.anomaliesFound} findings
</p>
</div>
{isRunning && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" className="gap-2" disabled={stopping}>
<Square className="h-3.5 w-3.5" />
Stop
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Stop exploration?</AlertDialogTitle>
<AlertDialogDescription>
This will stop the current exploration. All discovered states and findings will be kept.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => void handleStop()}>Stop</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
{/* Progress bar */}
{isRunning && (
<div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span>States explored</span>
<span>{session.statesVisited}</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all animate-pulse"
style={{ width: `${Math.min(100, (session.statesVisited / 50) * 100)}%` }}
/>
</div>
</div>
)}
{/* Tabs */}
<Tabs defaultValue="live">
<TabsList>
<TabsTrigger value="live">Live Feed</TabsTrigger>
<TabsTrigger value="findings">
Findings
{session.anomaliesFound > 0 && (
<Badge variant="secondary" className="ml-1.5 text-xs">{session.anomaliesFound}</Badge>
)}
</TabsTrigger>
<TabsTrigger value="config">Config</TabsTrigger>
</TabsList>
<TabsContent value="live" className="mt-4">
<LiveFeed events={feedEvents} />
</TabsContent>
<TabsContent value="findings" className="mt-4">
<SessionFindings sessionId={session.sessionId} />
</TabsContent>
<TabsContent value="config" className="mt-4">
<SessionConfig session={session} />
</TabsContent>
</Tabs>
</div>
)
}

View File

@@ -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<SessionStatus, string> = {
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<Session>[] = [
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => (
<Badge variant="outline" className={STATUS_COLOR[row.original.status]}>
{row.original.status}
</Badge>
),
},
{
accessorKey: 'url',
header: ({ column }) => (
<button
className="flex items-center gap-1 hover:text-foreground"
onClick={() => column.toggleSorting()}
>
URL <ArrowUpDown className="h-3 w-3" />
</button>
),
cell: ({ row }) => (
<span className="font-mono text-xs max-w-xs block truncate">{row.original.url}</span>
),
},
{
accessorKey: 'anomaliesFound',
header: 'Findings',
cell: ({ row }) => (
<span className={row.original.anomaliesFound > 0 ? 'text-destructive font-medium' : ''}>
{row.original.anomaliesFound}
</span>
),
},
{
id: 'duration',
header: 'Duration',
cell: ({ row }) => formatDuration(row.original.startedAt, row.original.finishedAt),
},
{
accessorKey: 'startedAt',
header: ({ column }) => (
<button
className="flex items-center gap-1 hover:text-foreground"
onClick={() => column.toggleSorting()}
>
Started <ArrowUpDown className="h-3 w-3" />
</button>
),
cell: ({ row }) => (
<span className="text-xs text-muted-foreground">
{new Date(row.original.startedAt).toLocaleString()}
</span>
),
},
]
export function SessionList() {
const { data: sessions = [], isLoading } = useSessions()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [sorting, setSorting] = useState<SortingState>([{ 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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Explorations</h1>
<p className="text-sm text-muted-foreground">{sessions.length} sessions</p>
</div>
<Button onClick={() => setShowForm(true)} className="gap-2">
<Plus className="h-4 w-4" />
New Exploration
</Button>
</div>
<Input
placeholder="Filter by URL, status..."
value={filter}
onChange={e => setFilter(e.target.value)}
className="max-w-sm"
/>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map(i => <Skeleton key={i} className="h-12 w-full" />)}
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map(hg => (
<TableRow key={hg.id}>
{hg.headers.map(h => (
<TableHead key={h.id}>
{flexRender(h.column.columnDef.header, h.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length} className="text-center py-8 text-muted-foreground">
No sessions yet. Start your first exploration!
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map(row => (
<TableRow
key={row.id}
className="cursor-pointer"
onClick={() => navigate(`/sessions/${row.original.sessionId}`)}
>
{row.getVisibleCells().map(cell => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)}
<Dialog open={showForm} onOpenChange={setShowForm}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<NewExplorationForm
onCreated={handleCreated}
onCancel={() => setShowForm(false)}
/>
</DialogContent>
</Dialog>
</div>
)
}