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`
|
||||
|
||||
- [ ] 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`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 <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>
|
||||
}
|
||||
import { SessionList } from '@/pages/sessions/SessionList'
|
||||
import { SessionDetail } from '@/pages/sessions/SessionDetail'
|
||||
function FindingsList() {
|
||||
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