fase(12): session pages with live feed
This commit is contained in:
@@ -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`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
55
frontend/src/components/sessions/LiveFeed.tsx
Normal file
55
frontend/src/components/sessions/LiveFeed.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
260
frontend/src/components/sessions/NewExplorationForm.tsx
Normal file
260
frontend/src/components/sessions/NewExplorationForm.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
41
frontend/src/components/sessions/SessionConfig.tsx
Normal file
41
frontend/src/components/sessions/SessionConfig.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
68
frontend/src/components/sessions/SessionFindings.tsx
Normal file
68
frontend/src/components/sessions/SessionFindings.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
194
frontend/src/components/ui/alert-dialog.tsx
Normal file
194
frontend/src/components/ui/alert-dialog.tsx
Normal 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,
|
||||||
|
}
|
||||||
56
frontend/src/components/ui/scroll-area.tsx
Normal file
56
frontend/src/components/ui/scroll-area.tsx
Normal 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 }
|
||||||
193
frontend/src/pages/sessions/SessionDetail.tsx
Normal file
193
frontend/src/pages/sessions/SessionDetail.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
203
frontend/src/pages/sessions/SessionList.tsx
Normal file
203
frontend/src/pages/sessions/SessionList.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user