fase(15): reporting module with pdf generation
This commit is contained in:
@@ -11,21 +11,21 @@ import { Setup } from '@/pages/Setup'
|
||||
import { Dashboard } from '@/pages/Dashboard'
|
||||
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>
|
||||
}
|
||||
function FindingDetail() {
|
||||
return <div className="text-muted-foreground p-4">Finding Detail — Coming in Phase 13</div>
|
||||
}
|
||||
function Reports() {
|
||||
return <div className="text-muted-foreground p-4">Reports — Coming in Phase 15</div>
|
||||
}
|
||||
import { FindingsList } from '@/pages/findings/FindingsList'
|
||||
import { FindingDetail } from '@/pages/findings/FindingDetail'
|
||||
import { SettingsLayout } from '@/pages/settings/SettingsLayout'
|
||||
import { ProfileSection } from '@/pages/settings/ProfileSection'
|
||||
import { OrganizationSection } from '@/pages/settings/OrganizationSection'
|
||||
import { ApiKeysSection } from '@/pages/settings/ApiKeysSection'
|
||||
import { ExplorationDefaultsSection } from '@/pages/settings/ExplorationDefaultsSection'
|
||||
import { NotificationsSection } from '@/pages/settings/NotificationsSection'
|
||||
import { AppearanceSection } from '@/pages/settings/AppearanceSection'
|
||||
import { LicenseSection } from '@/pages/settings/LicenseSection'
|
||||
import { Reports } from '@/pages/Reports'
|
||||
|
||||
function VisualReview() {
|
||||
return <div className="text-muted-foreground p-4">Visual Review — Coming in Phase 20</div>
|
||||
}
|
||||
function Settings() {
|
||||
return <div className="text-muted-foreground p-4">Settings — Coming in Phase 14</div>
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -50,7 +50,16 @@ export default function App() {
|
||||
<Route path="/findings/:id" element={<FindingDetail />} />
|
||||
<Route path="/reports" element={<Reports />} />
|
||||
<Route path="/visual-review" element={<VisualReview />} />
|
||||
<Route path="/settings/*" element={<Settings />} />
|
||||
<Route path="/settings" element={<SettingsLayout />}>
|
||||
<Route index element={<Navigate to="profile" replace />} />
|
||||
<Route path="profile" element={<ProfileSection />} />
|
||||
<Route path="organization" element={<OrganizationSection />} />
|
||||
<Route path="api-keys" element={<ApiKeysSection />} />
|
||||
<Route path="defaults" element={<ExplorationDefaultsSection />} />
|
||||
<Route path="notifications" element={<NotificationsSection />} />
|
||||
<Route path="appearance" element={<AppearanceSection />} />
|
||||
<Route path="license" element={<LicenseSection />} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
122
frontend/src/components/findings/AIAnalysisPanel.tsx
Normal file
122
frontend/src/components/findings/AIAnalysisPanel.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useState } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import type { AIEnrichment } from '../../types'
|
||||
|
||||
interface AIAnalysisPanelProps {
|
||||
findingId: string
|
||||
enrichment?: AIEnrichment | null
|
||||
}
|
||||
|
||||
const CONFIDENCE_COLOR: Record<string, string> = {
|
||||
high: 'bg-green-500/15 text-green-600 border-green-500/30',
|
||||
medium: 'bg-yellow-500/15 text-yellow-600 border-yellow-500/30',
|
||||
low: 'bg-red-500/15 text-red-500 border-red-500/30',
|
||||
}
|
||||
|
||||
export function AIAnalysisPanel({ findingId, enrichment }: AIAnalysisPanelProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleAnalyze() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
await apiFetch(`/api/findings/${findingId}/enrich`, { method: 'POST' })
|
||||
await queryClient.invalidateQueries({ queryKey: ['findings', findingId] })
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Analysis failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
AI Analysis
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (!enrichment) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
AI Analysis
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error && <p className="text-sm text-destructive mb-3">{error}</p>}
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Get AI-powered root cause analysis, user impact assessment, and suggested fixes.
|
||||
</p>
|
||||
<Button size="sm" onClick={() => void handleAnalyze()} className="gap-2">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Analyze with AI
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
AI Analysis
|
||||
</CardTitle>
|
||||
<Badge variant="outline" className={`text-xs ${CONFIDENCE_COLOR[enrichment.confidence]}`}>
|
||||
{enrichment.confidence} confidence
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{enrichment.provider} / {enrichment.model} ·{' '}
|
||||
{new Date(enrichment.generatedAt).toLocaleString()}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-1">Root Cause</h4>
|
||||
<p className="text-sm">{enrichment.rootCause}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-1">User Impact</h4>
|
||||
<p className="text-sm">{enrichment.userImpact}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-1">Suggested Fix</h4>
|
||||
<p className="text-sm">{enrichment.suggestedFix}</p>
|
||||
</div>
|
||||
{enrichment.debugPrompt && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-1">Debug Prompt</h4>
|
||||
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto whitespace-pre-wrap">
|
||||
{enrichment.debugPrompt}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
78
frontend/src/components/findings/EvidencePanel.tsx
Normal file
78
frontend/src/components/findings/EvidencePanel.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import type { AnomalyEvidence } from '../../types'
|
||||
|
||||
interface EvidencePanelProps {
|
||||
evidence: AnomalyEvidence
|
||||
}
|
||||
|
||||
export function EvidencePanel({ evidence }: EvidencePanelProps) {
|
||||
return (
|
||||
<Tabs defaultValue="console">
|
||||
<TabsList>
|
||||
<TabsTrigger value="console">Console</TabsTrigger>
|
||||
<TabsTrigger value="network">Network</TabsTrigger>
|
||||
{evidence.domSnapshotPath && <TabsTrigger value="dom">DOM</TabsTrigger>}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="console" className="mt-3">
|
||||
{evidence.rawErrors && evidence.rawErrors.length > 0 ? (
|
||||
<ScrollArea className="h-48 rounded border bg-black/80 p-3 font-mono text-xs">
|
||||
{evidence.rawErrors.map((err, i) => (
|
||||
<div key={i} className="text-red-400 mb-1">{err}</div>
|
||||
))}
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No console errors captured.</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="network" className="mt-3">
|
||||
{evidence.httpLog && evidence.httpLog.length > 0 ? (
|
||||
<div className="rounded border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Method</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>URL</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{evidence.httpLog.map((req, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="font-mono text-xs">{req.method}</TableCell>
|
||||
<TableCell className={`font-mono text-xs ${req.status >= 400 ? 'text-destructive' : ''}`}>
|
||||
{req.status}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs max-w-xs truncate">{req.url}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{req.durationMs}ms</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No network requests captured.</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{evidence.domSnapshotPath && (
|
||||
<TabsContent value="dom" className="mt-3">
|
||||
<div className="text-sm text-muted-foreground rounded border p-3">
|
||||
<p>DOM snapshot: <code className="font-mono text-xs">{evidence.domSnapshotPath}</code></p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
62
frontend/src/components/findings/ReproductionSteps.tsx
Normal file
62
frontend/src/components/findings/ReproductionSteps.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import type { Action } from '../../types'
|
||||
|
||||
interface ReproductionStepsProps {
|
||||
steps: Action[]
|
||||
}
|
||||
|
||||
const ACTION_ICONS: Record<string, string> = {
|
||||
click: '🖱️',
|
||||
fill: '⌨️',
|
||||
navigate: '🔗',
|
||||
scroll: '📜',
|
||||
hover: '🎯',
|
||||
select: '📋',
|
||||
press: '⌨️',
|
||||
}
|
||||
|
||||
export function ReproductionSteps({ steps }: ReproductionStepsProps) {
|
||||
if (steps.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">Reproduction Steps</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">No action trace available.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">Reproduction Steps</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ol className="space-y-2">
|
||||
{steps.map((step, i) => (
|
||||
<li key={step.id} className="flex gap-3 text-sm">
|
||||
<span className="text-muted-foreground font-mono w-6 shrink-0 text-right">{i + 1}.</span>
|
||||
<span className="shrink-0">{ACTION_ICONS[step.type] ?? '▶️'}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="font-medium">{step.type}</span>
|
||||
{step.selector && (
|
||||
<code className="ml-2 text-xs bg-muted px-1.5 py-0.5 rounded font-mono truncate block mt-0.5">
|
||||
{step.selector}
|
||||
</code>
|
||||
)}
|
||||
{step.value && (
|
||||
<span className="text-muted-foreground ml-2 text-xs">
|
||||
value: "{step.value}"
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ interface User {
|
||||
email: string
|
||||
name: string
|
||||
role: string
|
||||
orgId?: string
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import type { AnomalySummary, Stats } from '../types'
|
||||
import type { AnomalySummary, Finding, Stats } from '../types'
|
||||
|
||||
export function useFindings(params?: { sessionId?: string; severity?: string }) {
|
||||
const qs = new URLSearchParams()
|
||||
@@ -14,6 +14,14 @@ export function useFindings(params?: { sessionId?: string; severity?: string })
|
||||
})
|
||||
}
|
||||
|
||||
export function useFinding(id: string) {
|
||||
return useQuery<Finding>({
|
||||
queryKey: ['findings', id],
|
||||
queryFn: () => apiFetch<Finding>(`/api/findings/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export function useFindingStats() {
|
||||
return useQuery<Stats>({
|
||||
queryKey: ['findings', 'stats'],
|
||||
|
||||
257
frontend/src/pages/Reports.tsx
Normal file
257
frontend/src/pages/Reports.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface Report {
|
||||
id: string
|
||||
title: string
|
||||
format: 'html' | 'json' | 'pdf'
|
||||
status: 'pending' | 'generating' | 'ready' | 'failed'
|
||||
totalFindings: number
|
||||
createdAt: string
|
||||
completedAt: string | null
|
||||
}
|
||||
|
||||
interface GenerateForm {
|
||||
title: string
|
||||
format: 'html' | 'json' | 'pdf'
|
||||
sessionId: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
severity: string
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
pending: 'secondary',
|
||||
generating: 'secondary',
|
||||
ready: 'default',
|
||||
failed: 'destructive',
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleString()
|
||||
}
|
||||
|
||||
export function Reports() {
|
||||
const qc = useQueryClient()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [form, setForm] = useState<GenerateForm>({
|
||||
title: '',
|
||||
format: 'html',
|
||||
sessionId: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
severity: '',
|
||||
})
|
||||
|
||||
const { data: reports = [], isLoading } = useQuery<Report[]>({
|
||||
queryKey: ['reports'],
|
||||
queryFn: () => apiFetch<Report[]>('/api/reports'),
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
|
||||
const generate = useMutation({
|
||||
mutationFn: (body: object) =>
|
||||
apiFetch<{ reportId: string }>('/api/reports', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Report queued — it will be ready shortly')
|
||||
setOpen(false)
|
||||
void qc.invalidateQueries({ queryKey: ['reports'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
function handleSubmit() {
|
||||
if (!form.title) { toast.error('Title is required'); return }
|
||||
const filters: Record<string, string> = {}
|
||||
if (form.sessionId) filters['sessionId'] = form.sessionId
|
||||
if (form.startDate) filters['startDate'] = form.startDate
|
||||
if (form.endDate) filters['endDate'] = form.endDate
|
||||
if (form.severity) filters['severity'] = form.severity
|
||||
generate.mutate({ title: form.title, format: form.format, filters })
|
||||
}
|
||||
|
||||
function handleDownload(report: Report) {
|
||||
window.open(`/api/reports/${report.id}/download`, '_blank')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Reports</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Generate and download bug reports in multiple formats.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setOpen(true)}>Generate Report</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-muted-foreground text-sm">Loading...</p>
|
||||
) : reports.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center border rounded-lg bg-muted/20">
|
||||
<p className="text-muted-foreground">No reports yet.</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">Click "Generate Report" to create one.</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Format</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Findings</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Completed</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{reports.map(r => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-medium">{r.title}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{r.format.toUpperCase()}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={STATUS_COLORS[r.status] ?? 'outline'}>
|
||||
{r.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{r.status === 'ready' ? r.totalFindings : '—'}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{formatDate(r.createdAt)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{r.completedAt ? formatDate(r.completedAt) : '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{r.status === 'ready' && (
|
||||
<Button size="sm" variant="outline" onClick={() => handleDownload(r)}>
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Generate Report</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-1">
|
||||
<Label>Title</Label>
|
||||
<Input
|
||||
placeholder="e.g. Weekly Security Report"
|
||||
value={form.title}
|
||||
onChange={e => setForm(f => ({ ...f, title: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Format</Label>
|
||||
<Select
|
||||
value={form.format}
|
||||
onValueChange={v => setForm(f => ({ ...f, format: v as GenerateForm['format'] }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="html">HTML</SelectItem>
|
||||
<SelectItem value="json">JSON</SelectItem>
|
||||
<SelectItem value="pdf">PDF</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Session ID (optional)</Label>
|
||||
<Input
|
||||
placeholder="Filter by session"
|
||||
value={form.sessionId}
|
||||
onChange={e => setForm(f => ({ ...f, sessionId: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Start Date</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={form.startDate}
|
||||
onChange={e => setForm(f => ({ ...f, startDate: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>End Date</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={form.endDate}
|
||||
onChange={e => setForm(f => ({ ...f, endDate: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Min Severity (optional)</Label>
|
||||
<Select
|
||||
value={form.severity || '_all'}
|
||||
onValueChange={v => setForm(f => ({ ...f, severity: v === '_all' ? '' : v }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All severities" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_all">All severities</SelectItem>
|
||||
<SelectItem value="low">Low+</SelectItem>
|
||||
<SelectItem value="medium">Medium+</SelectItem>
|
||||
<SelectItem value="high">High+</SelectItem>
|
||||
<SelectItem value="critical">Critical only</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} disabled={generate.isPending}>
|
||||
{generate.isPending ? 'Queuing…' : 'Generate'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
176
frontend/src/pages/findings/FindingDetail.tsx
Normal file
176
frontend/src/pages/findings/FindingDetail.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
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 { ArrowLeft, Download, FileText, FileJson, Code2, CheckCircle2, Search, XCircle } from 'lucide-react'
|
||||
import { SeverityBadge } from '@/components/common/SeverityBadge'
|
||||
import { ReproductionSteps } from '@/components/findings/ReproductionSteps'
|
||||
import { EvidencePanel } from '@/components/findings/EvidencePanel'
|
||||
import { AIAnalysisPanel } from '@/components/findings/AIAnalysisPanel'
|
||||
import { useFinding } from '@/hooks/useFindings'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import type { FindingStatus } from '../../types'
|
||||
|
||||
const STATUS_COLOR: Record<FindingStatus, string> = {
|
||||
open: 'bg-red-500/15 text-red-500 border-red-500/30',
|
||||
investigating: 'bg-yellow-500/15 text-yellow-600 border-yellow-500/30',
|
||||
resolved: 'bg-green-500/15 text-green-600 border-green-500/30',
|
||||
closed: 'bg-slate-500/15 text-slate-500 border-slate-500/30',
|
||||
}
|
||||
|
||||
const STATUS_TRANSITIONS: Record<FindingStatus, Array<{ action: 'investigate' | 'resolve' | 'close'; label: string; icon: React.ElementType }>> = {
|
||||
open: [
|
||||
{ action: 'investigate', label: 'Investigate', icon: Search },
|
||||
{ action: 'resolve', label: 'Resolve', icon: CheckCircle2 },
|
||||
],
|
||||
investigating: [
|
||||
{ action: 'resolve', label: 'Resolve', icon: CheckCircle2 },
|
||||
{ action: 'close', label: 'Close', icon: XCircle },
|
||||
],
|
||||
resolved: [
|
||||
{ action: 'close', label: 'Close', icon: XCircle },
|
||||
],
|
||||
closed: [],
|
||||
}
|
||||
|
||||
function downloadHref(findingId: string, format: 'markdown' | 'json' | 'playwright') {
|
||||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||||
return `${API_URL}/api/findings/${findingId}/export/${format}`
|
||||
}
|
||||
|
||||
export function FindingDetail() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: finding, isLoading } = useFinding(id ?? '')
|
||||
|
||||
async function handleStatusChange(action: 'investigate' | 'resolve' | 'close') {
|
||||
if (!id) return
|
||||
try {
|
||||
await apiFetch(`/api/findings/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ action }),
|
||||
})
|
||||
await queryClient.invalidateQueries({ queryKey: ['findings', id] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['findings'] })
|
||||
} catch (e) {
|
||||
console.error('Failed to update status:', e)
|
||||
}
|
||||
}
|
||||
|
||||
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 (!finding) {
|
||||
return <div className="text-muted-foreground">Finding not found.</div>
|
||||
}
|
||||
|
||||
const transitions = STATUS_TRANSITIONS[finding.status] ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate('/findings')} className="mt-0.5 shrink-0">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<SeverityBadge severity={finding.severity} />
|
||||
<Badge variant="outline" className={STATUS_COLOR[finding.status]}>
|
||||
{finding.status}
|
||||
</Badge>
|
||||
<span className="font-mono text-xs text-muted-foreground">{finding.type}</span>
|
||||
</div>
|
||||
<h1 className="text-lg font-bold mt-1 leading-snug">{finding.description}</h1>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{finding.browser && <span>{finding.browser} · </span>}
|
||||
Found {new Date(finding.createdAt).toLocaleString()}
|
||||
{finding.resolvedAt && ` · Resolved ${new Date(finding.resolvedAt).toLocaleString()}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action bar */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* Status workflow */}
|
||||
{transitions.map(t => (
|
||||
<Button
|
||||
key={t.action}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => void handleStatusChange(t.action)}
|
||||
>
|
||||
<t.icon className="h-3.5 w-3.5" />
|
||||
{t.label}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Export buttons */}
|
||||
<a href={downloadHref(finding.id, 'playwright')} target="_blank" rel="noreferrer">
|
||||
<Button variant="outline" size="sm" className="gap-1.5">
|
||||
<Code2 className="h-3.5 w-3.5" />
|
||||
Playwright
|
||||
</Button>
|
||||
</a>
|
||||
<a href={downloadHref(finding.id, 'markdown')} target="_blank" rel="noreferrer">
|
||||
<Button variant="outline" size="sm" className="gap-1.5">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
Markdown
|
||||
</Button>
|
||||
</a>
|
||||
<a href={downloadHref(finding.id, 'json')} target="_blank" rel="noreferrer">
|
||||
<Button variant="outline" size="sm" className="gap-1.5">
|
||||
<FileJson className="h-3.5 w-3.5" />
|
||||
JSON
|
||||
</Button>
|
||||
</a>
|
||||
<Button variant="ghost" size="sm" className="gap-1.5" disabled>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Main split layout */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Left: Evidence + Reproduction */}
|
||||
<div className="space-y-4">
|
||||
<Tabs defaultValue="evidence">
|
||||
<TabsList>
|
||||
<TabsTrigger value="evidence">Evidence</TabsTrigger>
|
||||
<TabsTrigger value="steps">
|
||||
Steps
|
||||
{finding.actionTrace.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 text-xs">{finding.actionTrace.length}</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="evidence" className="mt-3">
|
||||
<EvidencePanel evidence={finding.evidence} />
|
||||
</TabsContent>
|
||||
<TabsContent value="steps" className="mt-3">
|
||||
<ReproductionSteps steps={finding.actionTrace} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Right: AI Analysis */}
|
||||
<div>
|
||||
<AIAnalysisPanel findingId={finding.id} enrichment={finding.aiEnrichment} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
184
frontend/src/pages/findings/FindingsList.tsx
Normal file
184
frontend/src/pages/findings/FindingsList.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ArrowUpDown, X } from 'lucide-react'
|
||||
import { SeverityBadge } from '@/components/common/SeverityBadge'
|
||||
import { useFindings } from '@/hooks/useFindings'
|
||||
import type { AnomalySummary, Severity } from '../../types'
|
||||
|
||||
const SEVERITIES: Severity[] = ['critical', 'high', 'medium', 'low']
|
||||
|
||||
const columns: ColumnDef<AnomalySummary>[] = [
|
||||
{
|
||||
accessorKey: 'severity',
|
||||
header: 'Severity',
|
||||
cell: ({ row }) => <SeverityBadge severity={row.original.severity} />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: ({ column }) => (
|
||||
<button className="flex items-center gap-1 hover:text-foreground" onClick={() => column.toggleSorting()}>
|
||||
Type <ArrowUpDown className="h-3 w-3" />
|
||||
</button>
|
||||
),
|
||||
cell: ({ row }) => <span className="font-mono text-xs">{row.original.type}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'description',
|
||||
header: 'Description',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground max-w-sm block truncate">
|
||||
{row.original.description}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'timestamp',
|
||||
header: ({ column }) => (
|
||||
<button className="flex items-center gap-1 hover:text-foreground" onClick={() => column.toggleSorting()}>
|
||||
Time <ArrowUpDown className="h-3 w-3" />
|
||||
</button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{new Date(row.original.timestamp).toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export function FindingsList() {
|
||||
const navigate = useNavigate()
|
||||
const [severity, setSeverity] = useState<string>('all')
|
||||
const [search, setSearch] = useState('')
|
||||
const [sorting, setSorting] = useState<SortingState>([{ id: 'timestamp', desc: true }])
|
||||
|
||||
const { data: allFindings = [], isLoading } = useFindings(
|
||||
severity !== 'all' ? { severity } : undefined
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: allFindings,
|
||||
columns,
|
||||
state: { sorting, globalFilter: search },
|
||||
onSortingChange: setSorting,
|
||||
onGlobalFilterChange: setSearch,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Findings</h1>
|
||||
<p className="text-sm text-muted-foreground">{table.getRowModel().rows.length} findings</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<Input
|
||||
placeholder="Search findings..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Select value={severity} onValueChange={setSeverity}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue placeholder="Severity" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All severities</SelectItem>
|
||||
{SEVERITIES.map(s => (
|
||||
<SelectItem key={s} value={s}>{s}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(severity !== 'all' || search) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => { setSeverity('all'); setSearch('') }}
|
||||
className="gap-1"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3, 4, 5].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 findings yet. Start an exploration!
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
table.getRowModel().rows.map(row => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => navigate(`/findings/${row.original.id}`)}
|
||||
>
|
||||
{row.getVisibleCells().map(cell => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
190
frontend/src/pages/settings/ApiKeysSection.tsx
Normal file
190
frontend/src/pages/settings/ApiKeysSection.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Key, Trash2, Copy, CheckCircle2 } from 'lucide-react'
|
||||
|
||||
interface ApiKey {
|
||||
id: string
|
||||
name: string
|
||||
keyPrefix: string
|
||||
permissions: string[]
|
||||
expiresAt: string | null
|
||||
lastUsedAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface CreatedKey {
|
||||
id: string
|
||||
name: string
|
||||
keyPrefix: string
|
||||
token: string
|
||||
}
|
||||
|
||||
export function ApiKeysSection() {
|
||||
const queryClient = useQueryClient()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [created, setCreated] = useState<CreatedKey | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const { data: keys = [], isLoading } = useQuery<ApiKey[]>({
|
||||
queryKey: ['api-keys'],
|
||||
queryFn: () => apiFetch<ApiKey[]>('/api/auth/api-keys'),
|
||||
})
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!name) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await apiFetch<CreatedKey>('/api/auth/api-keys', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, permissions: ['*'] }),
|
||||
})
|
||||
setCreated(result)
|
||||
setName('')
|
||||
await queryClient.invalidateQueries({ queryKey: ['api-keys'] })
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Create failed')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(id: string) {
|
||||
try {
|
||||
await apiFetch(`/api/auth/api-keys/${id}`, { method: 'DELETE' })
|
||||
await queryClient.invalidateQueries({ queryKey: ['api-keys'] })
|
||||
} catch (err) {
|
||||
console.error('Revoke failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function copyToken() {
|
||||
if (created?.token) {
|
||||
void navigator.clipboard.writeText(created.token)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDialogChange(v: boolean) {
|
||||
setOpen(v)
|
||||
if (!v) {
|
||||
setCreated(null)
|
||||
setError(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">API Keys</h2>
|
||||
<p className="text-sm text-muted-foreground">Manage API keys for programmatic access.</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={handleDialogChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" className="gap-1.5">
|
||||
<Key className="h-3.5 w-3.5" />
|
||||
New Key
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create API Key</DialogTitle>
|
||||
</DialogHeader>
|
||||
{created ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Copy your API key now — it won't be shown again.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 p-2 rounded border bg-muted font-mono text-xs break-all">
|
||||
<span className="flex-1">{created.token}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={copyToken}
|
||||
>
|
||||
{copied
|
||||
? <CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
|
||||
: <Copy className="h-3.5 w-3.5" />
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setOpen(false)}>Done</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleCreate} className="space-y-3">
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="key-name">Name</Label>
|
||||
<Input
|
||||
id="key-name"
|
||||
placeholder="e.g. CI Pipeline"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" size="sm" disabled={submitting}>
|
||||
{submitting ? 'Creating...' : 'Create Key'}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
) : keys.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">No API keys yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{keys.map(k => (
|
||||
<div key={k.id} className="flex items-center gap-3 py-2 border-b last:border-0">
|
||||
<Key className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{k.name}</p>
|
||||
<p className="text-xs text-muted-foreground font-mono">{k.keyPrefix}***</p>
|
||||
</div>
|
||||
{k.lastUsedAt && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Last used {new Date(k.lastUsedAt).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive shrink-0"
|
||||
onClick={() => void handleRevoke(k.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
frontend/src/pages/settings/AppearanceSection.tsx
Normal file
32
frontend/src/pages/settings/AppearanceSection.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useTheme } from '@/components/layout/ThemeProvider'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
|
||||
export function AppearanceSection() {
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-xl">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Appearance</h2>
|
||||
<p className="text-sm text-muted-foreground">Customize how ABE looks.</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Dark Mode</Label>
|
||||
<p className="text-xs text-muted-foreground">Toggle between dark and light theme.</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={theme === 'dark'}
|
||||
onCheckedChange={toggleTheme}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
104
frontend/src/pages/settings/ExplorationDefaultsSection.tsx
Normal file
104
frontend/src/pages/settings/ExplorationDefaultsSection.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import type { ServerConfig } from '@/types'
|
||||
|
||||
export function ExplorationDefaultsSection() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: config, isLoading } = useQuery<ServerConfig>({
|
||||
queryKey: ['config'],
|
||||
queryFn: () => apiFetch<ServerConfig>('/api/config'),
|
||||
})
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [maxStates, setMaxStates] = useState<number | undefined>()
|
||||
const [maxDepth, setMaxDepth] = useState<number | undefined>()
|
||||
const [actionDelayMs, setActionDelayMs] = useState<number | undefined>()
|
||||
|
||||
const effectiveMaxStates = maxStates ?? config?.defaultMaxStates ?? 50
|
||||
const effectiveMaxDepth = maxDepth ?? config?.defaultMaxDepth ?? 5
|
||||
const effectiveDelay = actionDelayMs ?? config?.defaultActionDelayMs ?? 500
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
await apiFetch('/api/config', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
defaultMaxStates: effectiveMaxStates,
|
||||
defaultMaxDepth: effectiveMaxDepth,
|
||||
defaultActionDelayMs: effectiveDelay,
|
||||
}),
|
||||
})
|
||||
await queryClient.invalidateQueries({ queryKey: ['config'] })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-xl">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Exploration Defaults</h2>
|
||||
<p className="text-sm text-muted-foreground">Default values for new explorations.</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="max-states">Max States</Label>
|
||||
<Input
|
||||
id="max-states"
|
||||
type="number"
|
||||
min={1}
|
||||
max={1000}
|
||||
value={effectiveMaxStates}
|
||||
onChange={e => setMaxStates(Number(e.target.value))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Maximum number of states to explore per session.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="max-depth">Max Depth</Label>
|
||||
<Input
|
||||
id="max-depth"
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={effectiveMaxDepth}
|
||||
onChange={e => setMaxDepth(Number(e.target.value))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Maximum BFS depth of exploration.</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="action-delay">Action Delay (ms)</Label>
|
||||
<Input
|
||||
id="action-delay"
|
||||
type="number"
|
||||
min={0}
|
||||
max={5000}
|
||||
value={effectiveDelay}
|
||||
onChange={e => setActionDelayMs(Number(e.target.value))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Delay between actions in milliseconds.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="submit" size="sm" disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save Defaults'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
frontend/src/pages/settings/LicenseSection.tsx
Normal file
32
frontend/src/pages/settings/LicenseSection.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Shield } from 'lucide-react'
|
||||
|
||||
export function LicenseSection() {
|
||||
return (
|
||||
<div className="space-y-6 max-w-xl">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">License</h2>
|
||||
<p className="text-sm text-muted-foreground">Manage your ABE license.</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
<CardTitle className="text-base">Current Plan</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Plan</span>
|
||||
<Badge>Free / OSS</Badge>
|
||||
</div>
|
||||
<CardDescription>
|
||||
License activation will be available in Phase 17 (RSA-signed keys with feature entitlements).
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
98
frontend/src/pages/settings/NotificationsSection.tsx
Normal file
98
frontend/src/pages/settings/NotificationsSection.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { ServerConfig } from '@/types'
|
||||
|
||||
export function NotificationsSection() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: config, isLoading } = useQuery<ServerConfig>({
|
||||
queryKey: ['config'],
|
||||
queryFn: () => apiFetch<ServerConfig>('/api/config'),
|
||||
})
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [webhookUrl, setWebhookUrl] = useState<string | undefined>()
|
||||
const [minSeverity, setMinSeverity] = useState<string | undefined>()
|
||||
|
||||
const effectiveWebhook = webhookUrl ?? config?.slackWebhookUrl ?? ''
|
||||
const effectiveMinSev = minSeverity ?? config?.notifyMinSeverity ?? 'high'
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
await apiFetch('/api/config', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
slackWebhookUrl: effectiveWebhook || null,
|
||||
notifyMinSeverity: effectiveMinSev,
|
||||
}),
|
||||
})
|
||||
await queryClient.invalidateQueries({ queryKey: ['config'] })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-xl">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Notifications</h2>
|
||||
<p className="text-sm text-muted-foreground">Configure Slack alerts for findings.</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="slack-webhook">Slack Webhook URL</Label>
|
||||
<Input
|
||||
id="slack-webhook"
|
||||
type="url"
|
||||
placeholder="https://hooks.slack.com/services/..."
|
||||
value={effectiveWebhook}
|
||||
onChange={e => setWebhookUrl(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty to disable Slack notifications.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="min-severity">Minimum Severity</Label>
|
||||
<Select value={effectiveMinSev} onValueChange={setMinSeverity}>
|
||||
<SelectTrigger id="min-severity">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
<SelectItem value="critical">Critical</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only send alerts for findings at or above this severity.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="submit" size="sm" disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save Settings'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
140
frontend/src/pages/settings/OrganizationSection.tsx
Normal file
140
frontend/src/pages/settings/OrganizationSection.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { UserPlus } from 'lucide-react'
|
||||
|
||||
interface OrgMember {
|
||||
userId: string
|
||||
email: string
|
||||
name: string
|
||||
role: string
|
||||
}
|
||||
|
||||
export function OrganizationSection() {
|
||||
const { user } = useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
const orgId = user?.orgId
|
||||
|
||||
const { data: members = [], isLoading } = useQuery<OrgMember[]>({
|
||||
queryKey: ['org', orgId, 'members'],
|
||||
queryFn: () => apiFetch<OrgMember[]>(`/api/auth/organizations/${orgId}/members`),
|
||||
enabled: !!orgId,
|
||||
})
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
const [role, setRole] = useState('member')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
async function handleInvite(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!orgId || !email) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await apiFetch(`/api/auth/organizations/${orgId}/members`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, role }),
|
||||
})
|
||||
setEmail('')
|
||||
await queryClient.invalidateQueries({ queryKey: ['org', orgId, 'members'] })
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Invite failed')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-xl">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Organization</h2>
|
||||
<p className="text-sm text-muted-foreground">Manage members and roles.</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Members</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
) : members.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No members yet.</p>
|
||||
) : (
|
||||
members.map(m => (
|
||||
<div key={m.userId} className="flex items-center gap-3 py-1">
|
||||
<Avatar className="h-7 w-7">
|
||||
<AvatarFallback className="text-xs">
|
||||
{m.name?.charAt(0).toUpperCase() ?? '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{m.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{m.email}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">{m.role}</Badge>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Invite Member
|
||||
</CardTitle>
|
||||
<CardDescription>Add a new member to your organization.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleInvite} className="space-y-3">
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="invite-email">Email</Label>
|
||||
<Input
|
||||
id="invite-email"
|
||||
type="email"
|
||||
placeholder="colleague@example.com"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="invite-role">Role</Label>
|
||||
<Select value={role} onValueChange={setRole}>
|
||||
<SelectTrigger id="invite-role">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="member">Member</SelectItem>
|
||||
<SelectItem value="viewer">Viewer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button type="submit" size="sm" disabled={submitting}>
|
||||
{submitting ? 'Inviting...' : 'Send Invite'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
60
frontend/src/pages/settings/ProfileSection.tsx
Normal file
60
frontend/src/pages/settings/ProfileSection.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
|
||||
export function ProfileSection() {
|
||||
const { user, isLoading } = useAuth()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-xl">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Profile</h2>
|
||||
<p className="text-sm text-muted-foreground">Your account information.</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="h-12 w-12">
|
||||
<AvatarFallback className="text-base">
|
||||
{user?.name?.charAt(0).toUpperCase() ?? '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<CardTitle className="text-base">{user?.name}</CardTitle>
|
||||
<CardDescription>{user?.email}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Role</span>
|
||||
<Badge variant="secondary">{user?.role}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">User ID</span>
|
||||
<code className="text-xs font-mono text-muted-foreground">{user?.id}</code>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Change Password</CardTitle>
|
||||
<CardDescription>Password management coming in a future release.</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
46
frontend/src/pages/settings/SettingsLayout.tsx
Normal file
46
frontend/src/pages/settings/SettingsLayout.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NavLink, Outlet } from 'react-router-dom'
|
||||
import { User, Building, Key, Sliders, Bell, Palette, Shield } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Profile', href: '/settings/profile', icon: User },
|
||||
{ label: 'Organization', href: '/settings/organization', icon: Building },
|
||||
{ label: 'API Keys', href: '/settings/api-keys', icon: Key },
|
||||
{ label: 'Exploration Defaults', href: '/settings/defaults', icon: Sliders },
|
||||
{ label: 'Notifications', href: '/settings/notifications', icon: Bell },
|
||||
{ label: 'Appearance', href: '/settings/appearance', icon: Palette },
|
||||
{ label: 'License', href: '/settings/license', icon: Shield },
|
||||
]
|
||||
|
||||
export function SettingsLayout() {
|
||||
return (
|
||||
<div className="flex gap-8">
|
||||
<nav className="w-48 shrink-0 space-y-1">
|
||||
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide px-3 mb-3">
|
||||
Settings
|
||||
</h2>
|
||||
{navItems.map(item => (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
to={item.href}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-colors',
|
||||
isActive
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -88,6 +88,40 @@ export interface AnomalySummary {
|
||||
browser?: BrowserType;
|
||||
}
|
||||
|
||||
export type FindingStatus = 'open' | 'investigating' | 'resolved' | 'closed';
|
||||
|
||||
export interface Finding {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
type: string;
|
||||
severity: Severity;
|
||||
description: string;
|
||||
status: FindingStatus;
|
||||
browser?: BrowserType;
|
||||
browserVersion?: string;
|
||||
actionTraceLength: number;
|
||||
actionTrace: Action[];
|
||||
evidence: AnomalyEvidence;
|
||||
aiEnrichment?: AIEnrichment | null;
|
||||
createdAt: string;
|
||||
resolvedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface FindingSummary {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
type: string;
|
||||
severity: Severity;
|
||||
description: string;
|
||||
status: FindingStatus;
|
||||
browser?: BrowserType;
|
||||
actionTraceLength: number;
|
||||
evidence: AnomalyEvidence;
|
||||
aiEnrichment?: AIEnrichment | null;
|
||||
createdAt: string;
|
||||
resolvedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
sessionId: string;
|
||||
url: string;
|
||||
|
||||
Reference in New Issue
Block a user