diff --git a/.ralph/fix_plan.md b/.ralph/fix_plan.md index 3b731e4..59c67c3 100644 --- a/.ralph/fix_plan.md +++ b/.ralph/fix_plan.md @@ -204,22 +204,22 @@ Spec: `.ralph/specs/phase-10-frontend-shell.md` --- -## Phase 11: Dashboard Page [PENDIENTE] +## Phase 11: Dashboard Page [COMPLETO] Spec: `.ralph/specs/phase-11-dashboard.md` -- [ ] 11.1: Instalar en frontend: `npm i tremor recharts` -- [ ] 11.2: Crear `hooks/useFindings.ts` — TanStack Query hooks: useFindings, useFindingStats -- [ ] 11.3: Crear `hooks/useSessions.ts` — TanStack Query hooks: useSessions, useSession -- [ ] 11.4: Crear `hooks/useSocket.ts` — socket.io-client connection con auto-reconnect -- [ ] 11.5: Crear `components/dashboard/KPICards.tsx` — 4 cards Tremor: Total Findings, Critical/High, Active Sessions, Coverage -- [ ] 11.6: Crear `components/dashboard/TrendChart.tsx` — Recharts AreaChart stacked por severity, últimos 30 días -- [ ] 11.7: Crear `components/dashboard/SeverityDistribution.tsx` — Recharts PieChart con colores por severity -- [ ] 11.8: Crear `components/dashboard/RecentFindings.tsx` — TanStack Table, 10 rows, click → /findings/:id -- [ ] 11.9: Crear `components/dashboard/ActiveSessions.tsx` — lista con progress bars, click → /sessions/:id -- [ ] 11.10: Crear `components/dashboard/QuickActions.tsx` — botón "New Exploration" prominente -- [ ] 11.11: Crear `pages/Dashboard.tsx` — ensambla todo, responsive 2col desktop 1col mobile -- [ ] 11.12: Conectar real-time: socket events actualizan KPIs y recent findings -- [ ] 11.13: Verificar frontend build + commit: `fase(11): dashboard page with charts and realtime` +- [x] 11.1: Instalar en frontend: `npm i tremor recharts` +- [x] 11.2: Crear `hooks/useFindings.ts` — TanStack Query hooks: useFindings, useFindingStats +- [x] 11.3: Crear `hooks/useSessions.ts` — TanStack Query hooks: useSessions, useSession +- [x] 11.4: Crear `hooks/useSocket.ts` — socket.io-client connection con auto-reconnect +- [x] 11.5: Crear `components/dashboard/KPICards.tsx` — 4 cards Tremor: Total Findings, Critical/High, Active Sessions, Coverage +- [x] 11.6: Crear `components/dashboard/TrendChart.tsx` — Recharts AreaChart stacked por severity, últimos 30 días +- [x] 11.7: Crear `components/dashboard/SeverityDistribution.tsx` — Recharts PieChart con colores por severity +- [x] 11.8: Crear `components/dashboard/RecentFindings.tsx` — TanStack Table, 10 rows, click → /findings/:id +- [x] 11.9: Crear `components/dashboard/ActiveSessions.tsx` — lista con progress bars, click → /sessions/:id +- [x] 11.10: Crear `components/dashboard/QuickActions.tsx` — botón "New Exploration" prominente +- [x] 11.11: Crear `pages/Dashboard.tsx` — ensambla todo, responsive 2col desktop 1col mobile +- [x] 11.12: Conectar real-time: socket events actualizan KPIs y recent findings +- [x] 11.13: Verificar frontend build + commit: `fase(11): dashboard page with charts and realtime` --- diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 40d5fbe..ad999fe 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,14 +9,8 @@ import { queryClient } from '@/lib/queryClient' import { Login } from '@/pages/Login' import { Setup } from '@/pages/Setup' import { Dashboard } from '@/pages/Dashboard' - -// Lazy placeholders for future phases -function SessionList() { - return
Explorations — Coming in Phase 12
-} -function SessionDetail() { - return
Session Detail — Coming in Phase 12
-} +import { SessionList } from '@/pages/sessions/SessionList' +import { SessionDetail } from '@/pages/sessions/SessionDetail' function FindingsList() { return
Findings — Coming in Phase 13
} diff --git a/frontend/src/components/sessions/LiveFeed.tsx b/frontend/src/components/sessions/LiveFeed.tsx new file mode 100644 index 0000000..7fea994 --- /dev/null +++ b/frontend/src/components/sessions/LiveFeed.tsx @@ -0,0 +1,55 @@ +import { useEffect, useRef } from 'react' +import { ScrollArea } from '@/components/ui/scroll-area' + +export interface FeedEvent { + id: string + event: string + text: string + timestamp: number +} + +const EVENT_COLOR: Record = { + 'state:discovered': 'text-green-500', + 'action:executed': 'text-yellow-500', + 'anomaly:detected': 'text-red-500', + 'session:started': 'text-blue-500', + 'session:completed': 'text-blue-500', + 'session:error': 'text-red-600', +} + +interface LiveFeedProps { + events: FeedEvent[] +} + +export function LiveFeed({ events }: LiveFeedProps) { + const bottomRef = useRef(null) + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [events.length]) + + return ( +
+ + {events.length === 0 ? ( +

Waiting for events...

+ ) : ( +
+ {events.map(e => ( +
+ + {new Date(e.timestamp).toLocaleTimeString()} + + + {e.event} + + {e.text} +
+ ))} +
+ )} +
+ +
+ ) +} diff --git a/frontend/src/components/sessions/NewExplorationForm.tsx b/frontend/src/components/sessions/NewExplorationForm.tsx new file mode 100644 index 0000000..9c11286 --- /dev/null +++ b/frontend/src/components/sessions/NewExplorationForm.tsx @@ -0,0 +1,260 @@ +import { useState } from 'react' +import { useForm } from 'react-hook-form' +import { z } from 'zod' +import { zodResolver } from '@hookform/resolvers/zod' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { X, ChevronDown, ChevronRight } from 'lucide-react' +import { apiFetch } from '@/lib/api' + +const schema = z.object({ + url: z.string().url('Must be a valid URL'), + seed: z.number().int().min(1).max(9999999), + maxStates: z.number().int().min(1).max(500), + maxDepth: z.number().int().min(1).max(20), + fuzzingEnabled: z.boolean(), + fuzzingIntensity: z.enum(['low', 'medium', 'high']), + authType: z.enum(['none', 'cookies', 'headers', 'login_flow']), +}) + +type FormValues = z.infer + +interface NewExplorationFormProps { + onCreated: (sessionId: string) => void + onCancel: () => void +} + +function ChipInput({ + label, + values, + onChange, + placeholder, +}: { + label: string + values: string[] + onChange: (v: string[]) => void + placeholder: string +}) { + const [input, setInput] = useState('') + + function add() { + const v = input.trim() + if (v && !values.includes(v)) { + onChange([...values, v]) + } + setInput('') + } + + return ( +
+ +
+ setInput(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); add() } }} + placeholder={placeholder} + /> + +
+ {values.length > 0 && ( +
+ {values.map(v => ( + + {v} + + + ))} +
+ )} +
+ ) +} + +export function NewExplorationForm({ onCreated, onCancel }: NewExplorationFormProps) { + const [allowedDomains, setAllowedDomains] = useState([]) + const [excludedPaths, setExcludedPaths] = useState([]) + const [showAdvanced, setShowAdvanced] = useState(false) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + url: 'http://localhost:3000', + seed: 42, + maxStates: 50, + maxDepth: 5, + fuzzingEnabled: true, + fuzzingIntensity: 'medium', + authType: 'none', + }, + }) + + const authType = form.watch('authType') + const fuzzingEnabled = form.watch('fuzzingEnabled') + + async function onSubmit(values: FormValues) { + setError(null) + setLoading(true) + try { + const res = await apiFetch<{ sessionId: string }>('/api/sessions', { + method: 'POST', + body: JSON.stringify({ + url: values.url, + seed: values.seed, + config: { + maxStates: values.maxStates, + maxDepth: values.maxDepth, + allowedDomains, + excludedPaths, + fuzzingEnabled: values.fuzzingEnabled, + fuzzingIntensity: values.fuzzingIntensity, + auth: values.authType === 'none' ? null : { type: values.authType }, + }, + }), + }) + onCreated(res.sessionId) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to start exploration') + } finally { + setLoading(false) + } + } + + return ( + + + New Exploration + + +
{ void form.handleSubmit(onSubmit)(e) }} className="space-y-4"> + {/* URL */} +
+ + + {form.formState.errors.url && ( +

{form.formState.errors.url.message}

+ )} +
+ +
+ {/* Seed */} +
+ + +
+ {/* Max States */} +
+ + +
+
+ + {/* Fuzzing */} +
+ form.setValue('fuzzingEnabled', v)} + /> + + {fuzzingEnabled && ( + + )} +
+ + {/* Auth */} +
+ + +
+ + {/* Advanced */} + + + {showAdvanced && ( +
+
+ + +
+ + +
+ )} + + {error &&

{error}

} + +
+ + +
+
+
+
+ ) +} diff --git a/frontend/src/components/sessions/SessionConfig.tsx b/frontend/src/components/sessions/SessionConfig.tsx new file mode 100644 index 0000000..b953024 --- /dev/null +++ b/frontend/src/components/sessions/SessionConfig.tsx @@ -0,0 +1,41 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import type { Session } from '../../types' + +interface SessionConfigProps { + session: Session +} + +export function SessionConfig({ session }: SessionConfigProps) { + const duration = session.finishedAt + ? Math.round((new Date(session.finishedAt).getTime() - new Date(session.startedAt).getTime()) / 1000) + : null + + const rows = [ + { label: 'URL', value: session.url }, + { label: 'Status', value: session.status }, + { label: 'Started', value: new Date(session.startedAt).toLocaleString() }, + ...(session.finishedAt ? [{ label: 'Finished', value: new Date(session.finishedAt).toLocaleString() }] : []), + ...(duration !== null ? [{ label: 'Duration', value: `${duration}s` }] : []), + { label: 'States Visited', value: String(session.statesVisited) }, + { label: 'Findings', value: String(session.anomaliesFound) }, + ...(session.seed != null ? [{ label: 'Seed', value: String(session.seed) }] : []), + ] + + return ( + + + Session Configuration + + +
+ {rows.map(row => ( +
+
{row.label}
+
{row.value}
+
+ ))} +
+
+
+ ) +} diff --git a/frontend/src/components/sessions/SessionFindings.tsx b/frontend/src/components/sessions/SessionFindings.tsx new file mode 100644 index 0000000..e3c1870 --- /dev/null +++ b/frontend/src/components/sessions/SessionFindings.tsx @@ -0,0 +1,68 @@ +import { useNavigate } from 'react-router-dom' +import { useFindings } from '@/hooks/useFindings' +import { SeverityBadge } from '@/components/common/SeverityBadge' +import { Skeleton } from '@/components/ui/skeleton' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' + +interface SessionFindingsProps { + sessionId: string +} + +export function SessionFindings({ sessionId }: SessionFindingsProps) { + const { data: findings = [], isLoading } = useFindings({ sessionId }) + const navigate = useNavigate() + + if (isLoading) { + return ( +
+ {[1, 2, 3].map(i => )} +
+ ) + } + + if (findings.length === 0) { + return ( +
+ No findings yet for this session. +
+ ) + } + + return ( + + + + Severity + Type + Description + Time + + + + {findings.map(f => ( + navigate(`/findings/${f.id}`)} + > + + {f.type} + + {f.description} + + + {new Date(f.timestamp).toLocaleString()} + + + ))} + +
+ ) +} diff --git a/frontend/src/components/ui/alert-dialog.tsx b/frontend/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..7e6c036 --- /dev/null +++ b/frontend/src/components/ui/alert-dialog.tsx @@ -0,0 +1,194 @@ +import * as React from "react" +import { AlertDialog as AlertDialogPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" +}) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogAction({ + className, + variant = "default", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + variant = "outline", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +} diff --git a/frontend/src/components/ui/scroll-area.tsx b/frontend/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..e38a492 --- /dev/null +++ b/frontend/src/components/ui/scroll-area.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import { ScrollArea as ScrollAreaPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function ScrollArea({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + + ) +} + +function ScrollBar({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { ScrollArea, ScrollBar } diff --git a/frontend/src/pages/sessions/SessionDetail.tsx b/frontend/src/pages/sessions/SessionDetail.tsx new file mode 100644 index 0000000..70ad74c --- /dev/null +++ b/frontend/src/pages/sessions/SessionDetail.tsx @@ -0,0 +1,193 @@ +import { useState, useCallback } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { useQueryClient } from '@tanstack/react-query' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Skeleton } from '@/components/ui/skeleton' +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog' +import { ArrowLeft, Square } from 'lucide-react' +import { useSession } from '@/hooks/useSessions' +import { useSocket } from '@/hooks/useSocket' +import { LiveFeed, type FeedEvent } from '@/components/sessions/LiveFeed' +import { SessionFindings } from '@/components/sessions/SessionFindings' +import { SessionConfig } from '@/components/sessions/SessionConfig' +import { apiFetch } from '@/lib/api' +import type { WsStateDiscovered, WsActionExecuted, WsAnomalyDetected, WsSessionCompleted, WsSessionError, WsSessionStarted } from '../../types' + +let eventCounter = 0 +function nextId() { return String(++eventCounter) } + +const STATUS_COLOR: Record = { + running: 'bg-green-500/15 text-green-500 border-green-500/30', + completed: 'bg-blue-500/15 text-blue-500 border-blue-500/30', + stopped: 'bg-yellow-500/15 text-yellow-600 border-yellow-500/30', + error: 'bg-red-500/15 text-red-500 border-red-500/30', +} + +export function SessionDetail() { + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + const queryClient = useQueryClient() + const { data: session, isLoading } = useSession(id ?? '') + const [feedEvents, setFeedEvents] = useState([]) + const [stopping, setStopping] = useState(false) + + function pushEvent(event: string, text: string) { + setFeedEvents(prev => [...prev, { id: nextId(), event, text, timestamp: Date.now() }]) + } + + useSocket(useCallback((event: string, data: unknown) => { + const d = data as Record + if (d['sessionId'] !== id) return + + switch (event) { + case 'session:started': + pushEvent(event, `Started: ${(d as unknown as WsSessionStarted).url}`) + void queryClient.invalidateQueries({ queryKey: ['sessions', id] }) + break + case 'state:discovered': { + const s = d as unknown as WsStateDiscovered + pushEvent(event, `${s.title || 'Untitled'} — ${s.url}`) + void queryClient.invalidateQueries({ queryKey: ['sessions', id] }) + break + } + case 'action:executed': { + const a = d as unknown as WsActionExecuted + pushEvent(event, `${a.actionType}${a.selector ? ` on ${a.selector}` : ''}`) + break + } + case 'anomaly:detected': { + const an = d as unknown as WsAnomalyDetected + pushEvent(event, `[${an.severity.toUpperCase()}] ${an.type}: ${an.description}`) + void queryClient.invalidateQueries({ queryKey: ['findings'] }) + break + } + case 'session:completed': { + const c = d as unknown as WsSessionCompleted + pushEvent(event, `Completed — ${c.statesVisited} states, ${c.anomaliesFound} findings`) + void queryClient.invalidateQueries({ queryKey: ['sessions', id] }) + break + } + case 'session:error': { + const e = d as unknown as WsSessionError + pushEvent(event, `Error: ${e.error}`) + void queryClient.invalidateQueries({ queryKey: ['sessions', id] }) + break + } + } + }, [id, queryClient])) + + async function handleStop() { + if (!id) return + setStopping(true) + try { + await apiFetch(`/api/sessions/${id}`, { method: 'DELETE' }) + await queryClient.invalidateQueries({ queryKey: ['sessions'] }) + } finally { + setStopping(false) + } + } + + if (isLoading) { + return ( +
+ + + +
+ ) + } + + if (!session) { + return
Session not found.
+ } + + const isRunning = session.status === 'running' + + return ( +
+ {/* Header */} +
+ +
+
+

{session.url}

+ + {session.status} + +
+

+ {session.statesVisited} states · {session.anomaliesFound} findings +

+
+ {isRunning && ( + + + + + + + Stop exploration? + + This will stop the current exploration. All discovered states and findings will be kept. + + + + Cancel + void handleStop()}>Stop + + + + )} +
+ + {/* Progress bar */} + {isRunning && ( +
+
+ States explored + {session.statesVisited} +
+
+
+
+
+ )} + + {/* Tabs */} + + + Live Feed + + Findings + {session.anomaliesFound > 0 && ( + {session.anomaliesFound} + )} + + Config + + + + + + + + + + + + + + +
+ ) +} diff --git a/frontend/src/pages/sessions/SessionList.tsx b/frontend/src/pages/sessions/SessionList.tsx new file mode 100644 index 0000000..b7d67cc --- /dev/null +++ b/frontend/src/pages/sessions/SessionList.tsx @@ -0,0 +1,203 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useQueryClient } from '@tanstack/react-query' +import { + useReactTable, + getCoreRowModel, + getSortedRowModel, + getFilteredRowModel, + flexRender, + type ColumnDef, + type SortingState, +} from '@tanstack/react-table' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Badge } from '@/components/ui/badge' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { Dialog, DialogContent } from '@/components/ui/dialog' +import { Skeleton } from '@/components/ui/skeleton' +import { Plus, ArrowUpDown } from 'lucide-react' +import { useSessions } from '@/hooks/useSessions' +import { NewExplorationForm } from '@/components/sessions/NewExplorationForm' +import type { Session, SessionStatus } from '../../types' + +const STATUS_COLOR: Record = { + running: 'bg-green-500/15 text-green-500 border-green-500/30', + completed: 'bg-blue-500/15 text-blue-500 border-blue-500/30', + stopped: 'bg-yellow-500/15 text-yellow-600 border-yellow-500/30', + error: 'bg-red-500/15 text-red-500 border-red-500/30', +} + +function formatDuration(start: string, end?: string): string { + const ms = (end ? new Date(end).getTime() : Date.now()) - new Date(start).getTime() + const s = Math.floor(ms / 1000) + if (s < 60) return `${s}s` + const m = Math.floor(s / 60) + if (m < 60) return `${m}m ${s % 60}s` + return `${Math.floor(m / 60)}h ${m % 60}m` +} + +const columns: ColumnDef[] = [ + { + accessorKey: 'status', + header: 'Status', + cell: ({ row }) => ( + + {row.original.status} + + ), + }, + { + accessorKey: 'url', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.url} + ), + }, + { + accessorKey: 'anomaliesFound', + header: 'Findings', + cell: ({ row }) => ( + 0 ? 'text-destructive font-medium' : ''}> + {row.original.anomaliesFound} + + ), + }, + { + id: 'duration', + header: 'Duration', + cell: ({ row }) => formatDuration(row.original.startedAt, row.original.finishedAt), + }, + { + accessorKey: 'startedAt', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {new Date(row.original.startedAt).toLocaleString()} + + ), + }, +] + +export function SessionList() { + const { data: sessions = [], isLoading } = useSessions() + const navigate = useNavigate() + const queryClient = useQueryClient() + const [sorting, setSorting] = useState([{ id: 'startedAt', desc: true }]) + const [filter, setFilter] = useState('') + const [showForm, setShowForm] = useState(false) + + const table = useReactTable({ + data: sessions, + columns, + state: { sorting, globalFilter: filter }, + onSortingChange: setSorting, + onGlobalFilterChange: setFilter, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + }) + + function handleCreated(sessionId: string) { + setShowForm(false) + void queryClient.invalidateQueries({ queryKey: ['sessions'] }) + navigate(`/sessions/${sessionId}`) + } + + return ( +
+
+
+

Explorations

+

{sessions.length} sessions

+
+ +
+ + setFilter(e.target.value)} + className="max-w-sm" + /> + + {isLoading ? ( +
+ {[1, 2, 3].map(i => )} +
+ ) : ( +
+ + + {table.getHeaderGroups().map(hg => ( + + {hg.headers.map(h => ( + + {flexRender(h.column.columnDef.header, h.getContext())} + + ))} + + ))} + + + {table.getRowModel().rows.length === 0 ? ( + + + No sessions yet. Start your first exploration! + + + ) : ( + table.getRowModel().rows.map(row => ( + navigate(`/sessions/${row.original.sessionId}`)} + > + {row.getVisibleCells().map(cell => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + )} + +
+
+ )} + + + + setShowForm(false)} + /> + + +
+ ) +}