NeuroSploit v3.2 - Autonomous AI Penetration Testing Platform

116 modules | 100 vuln types | 18 API routes | 18 frontend pages

Major features:
- VulnEngine: 100 vuln types, 526+ payloads, 12 testers, anti-hallucination prompts
- Autonomous Agent: 3-stream auto pentest, multi-session (5 concurrent), pause/resume/stop
- CLI Agent: Claude Code / Gemini CLI / Codex CLI inside Kali containers
- Validation Pipeline: negative controls, proof of execution, confidence scoring, judge
- AI Reasoning: ReACT engine, token budget, endpoint classifier, CVE hunter, deep recon
- Multi-Agent: 5 specialists + orchestrator + researcher AI + vuln type agents
- RAG System: BM25/TF-IDF/ChromaDB vectorstore, few-shot, reasoning templates
- Smart Router: 20 providers (8 CLI OAuth + 12 API), tier failover, token refresh
- Kali Sandbox: container-per-scan, 56 tools, VPN support, on-demand install
- Full IA Testing: methodology-driven comprehensive pentest sessions
- Notifications: Discord, Telegram, WhatsApp/Twilio multi-channel alerts
- Frontend: React/TypeScript with 18 pages, real-time WebSocket updates
This commit is contained in:
CyberSecurityUP
2026-02-22 17:58:12 -03:00
commit e0935793c5
271 changed files with 132462 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NeuroSploit v3 - AI-Powered Penetration Testing</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Generated Executable
+3467
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "neurosploit-frontend",
"version": "3.0.0",
"description": "NeuroSploit v3 - AI-Powered Penetration Testing Platform",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.0",
"zustand": "^4.4.0",
"axios": "^1.6.0",
"socket.io-client": "^4.6.0",
"recharts": "^2.10.0",
"lucide-react": "^0.303.0",
"clsx": "^2.1.0",
"tailwind-merge": "^2.2.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.0",
"vite": "^5.0.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="45" fill="#1a1a2e" stroke="#e94560" stroke-width="3"/>
<path d="M30 50 L45 35 L45 45 L70 45 L70 55 L45 55 L45 65 Z" fill="#e94560"/>
<circle cx="50" cy="50" r="8" fill="none" stroke="#e94560" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 314 B

+49
View File
@@ -0,0 +1,49 @@
import { Routes, Route } from 'react-router-dom'
import Layout from './components/layout/Layout'
import HomePage from './pages/HomePage'
import NewScanPage from './pages/NewScanPage'
import ScanDetailsPage from './pages/ScanDetailsPage'
import AgentStatusPage from './pages/AgentStatusPage'
import TaskLibraryPage from './pages/TaskLibraryPage'
import RealtimeTaskPage from './pages/RealtimeTaskPage'
import ReportsPage from './pages/ReportsPage'
import ReportViewPage from './pages/ReportViewPage'
import SettingsPage from './pages/SettingsPage'
import SchedulerPage from './pages/SchedulerPage'
import AutoPentestPage from './pages/AutoPentestPage'
import VulnLabPage from './pages/VulnLabPage'
import TerminalAgentPage from './pages/TerminalAgentPage'
import SandboxDashboardPage from './pages/SandboxDashboardPage'
import KnowledgePage from './pages/KnowledgePage'
import MCPManagementPage from './pages/MCPManagementPage'
import ProvidersPage from './pages/ProvidersPage'
import FullIATestingPage from './pages/FullIATestingPage'
function App() {
return (
<Layout>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/auto" element={<AutoPentestPage />} />
<Route path="/full-ia" element={<FullIATestingPage />} />
<Route path="/vuln-lab" element={<VulnLabPage />} />
<Route path="/terminal" element={<TerminalAgentPage />} />
<Route path="/scan/new" element={<NewScanPage />} />
<Route path="/scan/:scanId" element={<ScanDetailsPage />} />
<Route path="/agent/:agentId" element={<AgentStatusPage />} />
<Route path="/tasks" element={<TaskLibraryPage />} />
<Route path="/realtime" element={<RealtimeTaskPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/mcp" element={<MCPManagementPage />} />
<Route path="/scheduler" element={<SchedulerPage />} />
<Route path="/sandboxes" element={<SandboxDashboardPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/reports/:reportId" element={<ReportViewPage />} />
<Route path="/providers" element={<ProvidersPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</Layout>
)
}
export default App
+326
View File
@@ -0,0 +1,326 @@
import { useState, useEffect, useRef } from 'react'
import { Shield, Loader2, CheckCircle2, XCircle, Clock, AlertTriangle } from 'lucide-react'
import { agentApi } from '../services/api'
import type { VulnAgentStatus, VulnAgentDashboard } from '../types'
// Category color mapping for vuln types
const VULN_CATEGORY_COLORS: Record<string, string> = {
// XSS variants
xss_reflected: 'border-yellow-500/60',
xss_stored: 'border-yellow-500/60',
xss_dom: 'border-yellow-500/60',
blind_xss: 'border-yellow-500/60',
mutation_xss: 'border-yellow-500/60',
// SQL Injection
sqli_error: 'border-red-500/60',
sqli_union: 'border-red-500/60',
sqli_blind: 'border-red-500/60',
sqli_time: 'border-red-500/60',
// SSRF
ssrf: 'border-purple-500/60',
ssrf_cloud: 'border-purple-500/60',
// Auth/Access
auth_bypass: 'border-blue-500/60',
idor: 'border-blue-500/60',
bola: 'border-blue-500/60',
bfla: 'border-blue-500/60',
privilege_escalation: 'border-blue-500/60',
// Command/Template
command_injection: 'border-red-600/60',
ssti: 'border-red-600/60',
// File access
lfi: 'border-orange-500/60',
rfi: 'border-orange-500/60',
path_traversal: 'border-orange-500/60',
xxe: 'border-orange-500/60',
}
function getCategoryColor(vulnType: string): string {
return VULN_CATEGORY_COLORS[vulnType] || 'border-dark-600'
}
// Shortened display names for grid cells
function getShortName(vulnType: string): string {
const names: Record<string, string> = {
sqli_error: 'SQLi Err',
sqli_union: 'SQLi Union',
sqli_blind: 'SQLi Blind',
sqli_time: 'SQLi Time',
xss_reflected: 'XSS Refl',
xss_stored: 'XSS Stored',
xss_dom: 'XSS DOM',
blind_xss: 'Blind XSS',
mutation_xss: 'Mut XSS',
command_injection: 'Cmd Inj',
expression_language_injection: 'EL Inj',
nosql_injection: 'NoSQLi',
ldap_injection: 'LDAP Inj',
xpath_injection: 'XPath Inj',
orm_injection: 'ORM Inj',
graphql_injection: 'GQL Inj',
path_traversal: 'Path Trav',
arbitrary_file_read: 'File Read',
ssrf_cloud: 'SSRF Cloud',
open_redirect: 'Open Redir',
crlf_injection: 'CRLF',
header_injection: 'Header Inj',
host_header_injection: 'Host Hdr',
http_smuggling: 'Smuggling',
parameter_pollution: 'Param Poll',
log_injection: 'Log Inj',
html_injection: 'HTML Inj',
csv_injection: 'CSV Inj',
email_injection: 'Email Inj',
prototype_pollution: 'Proto Poll',
soap_injection: 'SOAP Inj',
type_juggling: 'Type Jugg',
cache_poisoning: 'Cache Poi',
security_headers: 'Sec Hdrs',
http_methods: 'HTTP Meth',
ssl_issues: 'SSL/TLS',
cors_misconfig: 'CORS',
directory_listing: 'Dir List',
debug_mode: 'Debug',
exposed_admin_panel: 'Admin Exp',
exposed_api_docs: 'API Docs',
insecure_cookie_flags: 'Cookies',
sensitive_data_exposure: 'Data Exp',
information_disclosure: 'Info Disc',
api_key_exposure: 'API Keys',
version_disclosure: 'Version',
cleartext_transmission: 'Cleartext',
weak_encryption: 'Weak Enc',
weak_hashing: 'Weak Hash',
source_code_disclosure: 'Src Disc',
backup_file_exposure: 'Backup Exp',
graphql_introspection: 'GQL Intro',
auth_bypass: 'Auth Byp',
jwt_manipulation: 'JWT Manip',
session_fixation: 'Sess Fix',
weak_password: 'Weak Pass',
default_credentials: 'Default Creds',
brute_force: 'Brute Force',
two_factor_bypass: '2FA Byp',
oauth_misconfiguration: 'OAuth Misc',
privilege_escalation: 'Priv Esc',
mass_assignment: 'Mass Assign',
forced_browsing: 'Forced Brw',
race_condition: 'Race Cond',
business_logic: 'Biz Logic',
rate_limit_bypass: 'Rate Limit',
timing_attack: 'Timing',
insecure_deserialization: 'Deseiral',
file_upload: 'File Upload',
arbitrary_file_delete: 'File Del',
zip_slip: 'Zip Slip',
dom_clobbering: 'DOM Clob',
postmessage_vulnerability: 'PostMsg',
websocket_hijacking: 'WS Hijack',
css_injection: 'CSS Inj',
tabnabbing: 'Tabnab',
subdomain_takeover: 'Subdomain',
cloud_metadata_exposure: 'Cloud Meta',
s3_bucket_misconfiguration: 'S3 Bucket',
serverless_misconfiguration: 'Serverless',
container_escape: 'Container',
vulnerable_dependency: 'Vuln Dep',
outdated_component: 'Outdated',
insecure_cdn: 'CDN',
weak_random: 'Weak Rand',
graphql_dos: 'GQL DoS',
rest_api_versioning: 'API Ver',
api_rate_limiting: 'API Rate',
excessive_data_exposure: 'Data Overexp',
improper_error_handling: 'Error Hndl',
}
return names[vulnType] || vulnType.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()).substring(0, 12)
}
const STATUS_ICONS: Record<string, React.ReactNode> = {
idle: <Clock className="w-3 h-3 text-dark-500" />,
running: <Loader2 className="w-3 h-3 text-blue-400 animate-spin" />,
completed: <CheckCircle2 className="w-3 h-3 text-green-400" />,
failed: <XCircle className="w-3 h-3 text-red-400" />,
cancelled: <AlertTriangle className="w-3 h-3 text-yellow-500" />,
}
interface Props {
agentId: string
isRunning: boolean
}
export default function VulnAgentGrid({ agentId, isRunning }: Props) {
const [data, setData] = useState<VulnAgentDashboard | null>(null)
const [hoveredAgent, setHoveredAgent] = useState<string | null>(null)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => {
const fetchData = async () => {
try {
const result = await agentApi.getVulnAgents(agentId)
setData(result)
} catch {
// Agent may not exist yet
}
}
fetchData()
if (isRunning) {
pollRef.current = setInterval(fetchData, 2000)
}
return () => {
if (pollRef.current) clearInterval(pollRef.current)
}
}, [agentId, isRunning])
if (!data || !data.enabled) {
return (
<div className="bg-dark-800 border border-dark-700 rounded-2xl p-6 text-center">
<Shield className="w-8 h-8 text-dark-500 mx-auto mb-2" />
<p className="text-dark-400 text-sm">
Per-vulnerability agent orchestration is not enabled for this scan.
</p>
<p className="text-dark-500 text-xs mt-1">
Set ENABLE_VULN_AGENTS=true in .env to enable
</p>
</div>
)
}
const { agents, stats } = data
return (
<div className="space-y-4">
{/* Summary bar */}
<div className="bg-dark-800 border border-dark-700 rounded-xl p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Shield className="w-5 h-5 text-primary-400" />
<span className="text-white font-semibold">Vulnerability Agents</span>
<span className="text-dark-400 text-sm">({stats.total} types)</span>
</div>
<div className="flex items-center gap-4 text-xs">
{stats.completed > 0 && (
<span className="text-green-400">
<CheckCircle2 className="w-3 h-3 inline mr-1" />{stats.completed} done
</span>
)}
{stats.running > 0 && (
<span className="text-blue-400">
<Loader2 className="w-3 h-3 inline mr-1 animate-spin" />{stats.running} running
</span>
)}
{stats.failed > 0 && (
<span className="text-red-400">
<XCircle className="w-3 h-3 inline mr-1" />{stats.failed} failed
</span>
)}
{(stats.total - stats.completed - stats.running - stats.failed - (stats.cancelled || 0)) > 0 && (
<span className="text-dark-400">
<Clock className="w-3 h-3 inline mr-1" />
{stats.total - stats.completed - stats.running - stats.failed - (stats.cancelled || 0)} pending
</span>
)}
{stats.findings_total > 0 && (
<span className="text-red-400 font-bold">
{stats.findings_total} findings
</span>
)}
{stats.elapsed > 0 && (
<span className="text-dark-500">
{stats.elapsed < 60 ? `${Math.round(stats.elapsed)}s` : `${Math.round(stats.elapsed / 60)}m`}
</span>
)}
</div>
</div>
{/* Progress bar */}
{stats.total > 0 && (
<div className="mt-3 h-1.5 bg-dark-700 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-primary-500 to-green-500 rounded-full transition-all duration-500"
style={{ width: `${Math.round((stats.completed / stats.total) * 100)}%` }}
/>
</div>
)}
</div>
{/* Agent grid */}
<div className="bg-dark-800 border border-dark-700 rounded-xl p-4">
<div className="grid grid-cols-5 sm:grid-cols-8 md:grid-cols-10 lg:grid-cols-12 xl:grid-cols-14 gap-1.5">
{agents.map((agent: VulnAgentStatus) => (
<div
key={agent.vuln_type}
className={`relative border rounded-lg p-1.5 cursor-pointer transition-all hover:scale-105 ${
getCategoryColor(agent.vuln_type)
} ${
agent.status === 'running' ? 'bg-blue-500/10' :
agent.status === 'completed' ? 'bg-dark-900' :
agent.status === 'failed' ? 'bg-red-500/5' :
'bg-dark-900/50'
} ${
agent.findings_count > 0 ? 'ring-1 ring-red-500/50' : ''
}`}
onMouseEnter={() => setHoveredAgent(agent.vuln_type)}
onMouseLeave={() => setHoveredAgent(null)}
>
{/* Status icon */}
<div className="flex items-center justify-between mb-1">
{STATUS_ICONS[agent.status] || STATUS_ICONS.idle}
{agent.findings_count > 0 && (
<span className="bg-red-500 text-white text-[9px] font-bold rounded-full w-3.5 h-3.5 flex items-center justify-center">
{agent.findings_count}
</span>
)}
</div>
{/* Label */}
<div className="text-[9px] text-dark-300 leading-tight truncate">
{getShortName(agent.vuln_type)}
</div>
{/* Micro progress bar */}
{agent.status === 'running' && (
<div className="mt-1 h-0.5 bg-dark-700 rounded-full overflow-hidden">
<div
className="h-full bg-blue-400 rounded-full transition-all"
style={{ width: `${agent.progress}%` }}
/>
</div>
)}
{/* Tooltip */}
{hoveredAgent === agent.vuln_type && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-50 bg-dark-900 border border-dark-600 rounded-lg p-2 shadow-xl min-w-[180px] pointer-events-none">
<div className="text-xs text-white font-medium mb-1">
{agent.vuln_type.replace(/_/g, ' ')}
</div>
<div className="text-[10px] text-dark-400 space-y-0.5">
<div>Status: <span className={
agent.status === 'completed' ? 'text-green-400' :
agent.status === 'running' ? 'text-blue-400' :
agent.status === 'failed' ? 'text-red-400' :
'text-dark-300'
}>{agent.status}</span></div>
<div>Targets: {agent.targets_tested}/{agent.targets_total}</div>
{agent.findings_count > 0 && (
<div className="text-red-400 font-bold">{agent.findings_count} finding(s)</div>
)}
{agent.duration != null && agent.duration > 0 && (
<div>Duration: {agent.duration < 60 ? `${Math.round(agent.duration)}s` : `${(agent.duration / 60).toFixed(1)}m`}</div>
)}
{agent.error && (
<div className="text-red-400 truncate">Error: {agent.error}</div>
)}
</div>
</div>
)}
</div>
))}
</div>
</div>
</div>
)
}
+37
View File
@@ -0,0 +1,37 @@
import { clsx } from 'clsx'
interface BadgeProps {
variant?: 'critical' | 'high' | 'medium' | 'low' | 'info' | 'success' | 'warning' | 'default'
children: React.ReactNode
className?: string
}
const variants = {
critical: 'bg-red-500/20 text-red-400 border-red-500/30',
high: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
medium: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
low: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
info: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
success: 'bg-green-500/20 text-green-400 border-green-500/30',
warning: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
default: 'bg-dark-900/50 text-dark-300 border-dark-700',
}
export default function Badge({ variant = 'default', children, className }: BadgeProps) {
return (
<span
className={clsx(
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border',
variants[variant],
className
)}
>
{children}
</span>
)
}
export function SeverityBadge({ severity }: { severity: string }) {
const variant = severity.toLowerCase() as BadgeProps['variant']
return <Badge variant={variant}>{severity.toUpperCase()}</Badge>
}
+70
View File
@@ -0,0 +1,70 @@
import { ButtonHTMLAttributes, ReactNode } from 'react'
import { clsx } from 'clsx'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
size?: 'sm' | 'md' | 'lg'
isLoading?: boolean
children: ReactNode
}
export default function Button({
variant = 'primary',
size = 'md',
isLoading = false,
children,
className,
disabled,
...props
}: ButtonProps) {
const baseStyles = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-dark-700 disabled:opacity-50 disabled:cursor-not-allowed'
const variants = {
primary: 'bg-primary-500 text-white hover:bg-primary-600 focus:ring-primary-500',
secondary: 'bg-dark-900 text-white hover:bg-dark-800 focus:ring-dark-500',
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
ghost: 'text-dark-300 hover:text-white hover:bg-dark-900/50 focus:ring-dark-500',
}
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
}
return (
<button
className={clsx(baseStyles, variants[variant], sizes[size], className)}
disabled={disabled || isLoading}
{...props}
>
{isLoading ? (
<>
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Loading...
</>
) : (
children
)}
</button>
)
}
+27
View File
@@ -0,0 +1,27 @@
import { ReactNode } from 'react'
import { clsx } from 'clsx'
interface CardProps {
children: ReactNode
className?: string
title?: ReactNode
subtitle?: string
action?: ReactNode
}
export default function Card({ children, className, title, subtitle, action }: CardProps) {
return (
<div className={clsx('bg-dark-800 rounded-xl border border-dark-900/50', className)}>
{(title || action) && (
<div className="flex items-center justify-between p-4 border-b border-dark-900/50">
<div>
{title && <h3 className="text-lg font-semibold text-white">{title}</h3>}
{subtitle && <p className="text-sm text-dark-400 mt-1">{subtitle}</p>}
</div>
{action}
</div>
)}
<div className="p-4">{children}</div>
</div>
)
}
+41
View File
@@ -0,0 +1,41 @@
import { InputHTMLAttributes, forwardRef } from 'react'
import { clsx } from 'clsx'
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
helperText?: string
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, helperText, className, ...props }, ref) => {
return (
<div className="w-full">
{label && (
<label className="block text-sm font-medium text-dark-200 mb-1.5">
{label}
</label>
)}
<input
ref={ref}
className={clsx(
'w-full px-4 py-2.5 bg-dark-900 border rounded-lg text-white placeholder-dark-500',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent',
'transition-colors',
error ? 'border-red-500' : 'border-dark-700',
className
)}
{...props}
/>
{error && <p className="mt-1 text-sm text-red-400">{error}</p>}
{helperText && !error && (
<p className="mt-1 text-sm text-dark-400">{helperText}</p>
)}
</div>
)
}
)
Input.displayName = 'Input'
export default Input
+41
View File
@@ -0,0 +1,41 @@
import { TextareaHTMLAttributes, forwardRef } from 'react'
import { clsx } from 'clsx'
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string
error?: string
helperText?: string
}
const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ label, error, helperText, className, ...props }, ref) => {
return (
<div className="w-full">
{label && (
<label className="block text-sm font-medium text-dark-200 mb-1.5">
{label}
</label>
)}
<textarea
ref={ref}
className={clsx(
'w-full px-4 py-2.5 bg-dark-900 border rounded-lg text-white placeholder-dark-500',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent',
'transition-colors resize-none',
error ? 'border-red-500' : 'border-dark-700',
className
)}
{...props}
/>
{error && <p className="mt-1 text-sm text-red-400">{error}</p>}
{helperText && !error && (
<p className="mt-1 text-sm text-dark-400">{helperText}</p>
)}
</div>
)
}
)
Textarea.displayName = 'Textarea'
export default Textarea
+30
View File
@@ -0,0 +1,30 @@
import { useLocation } from 'react-router-dom'
const pageTitles: Record<string, string> = {
'/': 'Dashboard',
'/scan/new': 'New Security Scan',
'/reports': 'Reports',
'/settings': 'Settings',
'/full-ia': 'FULL AI TESTING',
}
export default function Header() {
const location = useLocation()
const title = pageTitles[location.pathname] || 'NeuroSploit'
return (
<header className="h-16 bg-dark-800 border-b border-dark-900/50 flex items-center justify-between px-6">
<h1 className="text-xl font-semibold text-white">{title}</h1>
<div className="flex items-center gap-4">
<span className="text-sm text-dark-400">
{new Date().toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</span>
</div>
</header>
)
}
+21
View File
@@ -0,0 +1,21 @@
import { ReactNode } from 'react'
import Sidebar from './Sidebar'
import Header from './Header'
interface LayoutProps {
children: ReactNode
}
export default function Layout({ children }: LayoutProps) {
return (
<div className="flex min-h-screen bg-dark-700 overflow-hidden">
<Sidebar />
<div className="flex-1 flex flex-col min-w-0">
<Header />
<main className="flex-1 p-6 overflow-auto">
{children}
</main>
</div>
</div>
)
}
+145
View File
@@ -0,0 +1,145 @@
import { Link, useLocation } from 'react-router-dom'
import {
Home,
Bot,
BookOpen,
FileText,
Settings,
Activity,
Shield,
Zap,
Clock,
Rocket,
FlaskConical,
Terminal,
Container,
Brain,
Cable,
Plug,
Crosshair,
ChevronLeft,
ChevronRight,
} from 'lucide-react'
import { useUIStore } from '../../store'
const navGroups = [
{
label: 'Operations',
items: [
{ path: '/', icon: Home, label: 'Dashboard' },
{ path: '/auto', icon: Rocket, label: 'Auto Pentest' },
{ path: '/scan/new', icon: Bot, label: 'AI Agent' },
{ path: '/realtime', icon: Zap, label: 'Real-time Task' },
{ path: '/full-ia', icon: Crosshair, label: 'FULL AI TESTING' },
],
},
{
label: 'Tools',
items: [
{ path: '/vuln-lab', icon: FlaskConical, label: 'Vuln Lab' },
{ path: '/terminal', icon: Terminal, label: 'Terminal Agent' },
{ path: '/sandboxes', icon: Container, label: 'Sandboxes' },
{ path: '/tasks', icon: BookOpen, label: 'Task Library' },
{ path: '/knowledge', icon: Brain, label: 'Knowledge' },
{ path: '/mcp', icon: Cable, label: 'MCP Servers' },
{ path: '/providers', icon: Plug, label: 'Providers' },
],
},
{
label: 'Configuration',
items: [
{ path: '/scheduler', icon: Clock, label: 'Scheduler' },
{ path: '/reports', icon: FileText, label: 'Reports' },
{ path: '/settings', icon: Settings, label: 'Settings' },
],
},
]
export default function Sidebar() {
const location = useLocation()
const { sidebarCollapsed, toggleSidebar } = useUIStore()
return (
<aside
className={`${
sidebarCollapsed ? 'w-16' : 'w-64'
} bg-dark-800 border-r border-dark-900/50 flex flex-col transition-all duration-300 ease-in-out flex-shrink-0`}
>
{/* Logo */}
<div className={`border-b border-dark-900/50 ${sidebarCollapsed ? 'p-3' : 'p-4'}`}>
<div className="flex items-center justify-between">
<Link to="/" className="flex items-center gap-3 min-w-0">
<div className="w-10 h-10 bg-primary-500 rounded-lg flex items-center justify-center flex-shrink-0">
<Shield className="w-6 h-6 text-white" />
</div>
{!sidebarCollapsed && (
<div className="min-w-0">
<h1 className="text-lg font-bold text-white truncate">NeuroSploit</h1>
<p className="text-xs text-dark-400">v3.0 AI Pentest</p>
</div>
)}
</Link>
<button
onClick={toggleSidebar}
className="text-dark-400 hover:text-white transition-colors p-1 rounded hover:bg-dark-700 flex-shrink-0"
title={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{sidebarCollapsed ? (
<ChevronRight className="w-4 h-4" />
) : (
<ChevronLeft className="w-4 h-4" />
)}
</button>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 p-2 overflow-y-auto overflow-x-hidden">
{navGroups.map((group) => (
<div key={group.label} className="mb-3">
{!sidebarCollapsed && (
<p className="px-3 mb-1 text-[10px] font-semibold uppercase text-dark-500 tracking-wider">
{group.label}
</p>
)}
{sidebarCollapsed && <div className="border-t border-dark-700/50 mx-2 mb-2 mt-1" />}
<ul className="space-y-0.5">
{group.items.map((item) => {
const isActive = location.pathname === item.path
const Icon = item.icon
return (
<li key={item.path}>
<Link
to={item.path}
title={sidebarCollapsed ? item.label : undefined}
className={`flex items-center ${
sidebarCollapsed ? 'justify-center px-2' : 'gap-3 px-3'
} py-2.5 rounded-lg transition-colors ${
isActive
? 'bg-primary-500/20 text-primary-500'
: 'text-dark-300 hover:bg-dark-900/50 hover:text-white'
}`}
>
<Icon className="w-5 h-5 flex-shrink-0" />
{!sidebarCollapsed && (
<span className="whitespace-nowrap text-sm">{item.label}</span>
)}
</Link>
</li>
)
})}
</ul>
</div>
))}
</nav>
{/* Status */}
<div className="p-3 border-t border-dark-900/50">
<div className={`flex items-center ${sidebarCollapsed ? 'justify-center' : 'gap-2'} text-sm`}>
<Activity className="w-4 h-4 text-green-500 flex-shrink-0" />
{!sidebarCollapsed && <span className="text-dark-400">System Online</span>}
</div>
</div>
</aside>
)
}
+13
View File
@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './styles/globals.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+662
View File
@@ -0,0 +1,662 @@
import { useEffect, useMemo, useState, useCallback, useRef } from 'react'
import { Link } from 'react-router-dom'
import {
Activity, Shield, AlertTriangle, Plus, ArrowRight, CheckCircle,
FileText, Cpu, Globe, Zap, Terminal, Bug, FlaskConical,
ChevronRight, X, RefreshCw, WifiOff, Play
} from 'lucide-react'
import {
PieChart, Pie, Cell, Tooltip as RechartsTooltip, ResponsiveContainer
} from 'recharts'
import Card from '../components/common/Card'
import Button from '../components/common/Button'
import { SeverityBadge } from '../components/common/Badge'
import { dashboardApi, agentApi } from '../services/api'
import { useDashboardStore } from '../store'
import type { ActivityFeedItem } from '../types'
/* ─── Constants ──────────────────────────────────────────────── */
const SEVERITY_CHART_COLORS: Record<string, string> = {
critical: '#ef4444',
high: '#f97316',
medium: '#eab308',
low: '#3b82f6',
info: '#6b7280',
}
const STATUS_CHART_COLORS: Record<string, string> = {
running: '#22c55e',
completed: '#6366f1',
stopped: '#eab308',
failed: '#ef4444',
pending: '#6b7280',
paused: '#f59e0b',
}
/* ─── Helpers ────────────────────────────────────────────────── */
function relativeTime(ts: string): string {
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000)
if (diff < 60) return `${diff}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return `${Math.floor(diff / 86400)}d ago`
}
/* ─── Toast System ───────────────────────────────────────────── */
interface Toast { id: number; message: string; severity: 'info' | 'success' | 'warning' | 'error' }
let _toastId = 0
function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: number) => void }) {
if (toasts.length === 0) return null
const border: Record<string, string> = {
info: 'border-blue-500', success: 'border-green-500',
warning: 'border-yellow-500', error: 'border-red-500',
}
return (
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
{toasts.map(t => (
<div
key={t.id}
className={`bg-dark-800 border-l-4 ${border[t.severity]} rounded-lg px-4 py-3 shadow-xl flex items-start gap-3`}
style={{ animation: 'fadeSlideIn 0.3s ease-out' }}
>
<span className="text-sm text-dark-200 flex-1">{t.message}</span>
<button onClick={() => onDismiss(t.id)} className="text-dark-500 hover:text-white">
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)
}
/* ─── Donut Chart ────────────────────────────────────────────── */
function DonutChart({ data }: { data: Array<{ name: string; value: number; color: string }> }) {
const filtered = data.filter(d => d.value > 0)
if (filtered.length === 0) return <p className="text-dark-500 text-center py-8 text-sm">No data yet</p>
const total = filtered.reduce((s, d) => s + d.value, 0)
return (
<div className="flex items-center gap-4">
<ResponsiveContainer width={140} height={140}>
<PieChart>
<Pie
data={filtered}
dataKey="value"
cx="50%"
cy="50%"
innerRadius={38}
outerRadius={62}
paddingAngle={2}
strokeWidth={0}
>
{filtered.map((d, i) => (
<Cell key={i} fill={d.color} />
))}
</Pie>
<RechartsTooltip
contentStyle={{ background: '#1a1a2e', border: '1px solid #2a2a3e', borderRadius: 8, fontSize: 12 }}
itemStyle={{ color: '#e2e8f0' }}
/>
</PieChart>
</ResponsiveContainer>
<div className="flex flex-col gap-1.5">
{filtered.map(d => (
<div key={d.name} className="flex items-center gap-2 text-sm">
<span className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: d.color }} />
<span className="text-dark-300 whitespace-nowrap">{d.name}</span>
<span className="text-white font-semibold ml-auto tabular-nums">{d.value}</span>
<span className="text-dark-500 text-xs w-10 text-right">{((d.value / total) * 100).toFixed(0)}%</span>
</div>
))}
</div>
</div>
)
}
/* ─── Active Agent Card ──────────────────────────────────────── */
interface ActiveAgent {
agent_id: string
target: string
status: string
progress: number
phase: string
scan_id: string | null
started_at: string
findings_count: number
mode: string
}
function ActiveAgentCard({ agent }: { agent: ActiveAgent }) {
return (
<Link
to={agent.scan_id ? `/scan/${agent.scan_id}` : '#'}
className="flex items-center gap-4 p-3 bg-dark-900/60 rounded-lg hover:bg-dark-900 transition-colors group"
>
{/* Pulse indicator */}
<div className="relative flex-shrink-0">
<div className={`w-3 h-3 rounded-full ${agent.status === 'running' ? 'bg-green-500' : 'bg-yellow-500'}`} />
{agent.status === 'running' && (
<div className="absolute inset-0 w-3 h-3 rounded-full bg-green-500 animate-ping opacity-40" />
)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-white truncate max-w-[180px] sm:max-w-[300px]">
{agent.target}
</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-dark-700 text-dark-300 uppercase hidden sm:inline">
{agent.mode.replace('_', ' ')}
</span>
</div>
<div className="flex items-center gap-2 mt-1">
<div className="flex-1 h-1.5 bg-dark-700 rounded-full overflow-hidden max-w-[200px]">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${agent.progress}%`,
background: 'linear-gradient(90deg, #22c55e, #16a34a)',
}}
/>
</div>
<span className="text-xs text-dark-400 tabular-nums w-8">{agent.progress}%</span>
<span className="text-xs text-dark-500 hidden sm:inline">{agent.phase}</span>
</div>
</div>
{/* Findings + arrow */}
<div className="flex items-center gap-2">
{agent.findings_count > 0 && (
<div className="flex items-center gap-1">
<Bug className="w-3 h-3 text-red-400" />
<span className="text-xs text-red-400 font-medium tabular-nums">{agent.findings_count}</span>
</div>
)}
<ChevronRight className="w-4 h-4 text-dark-600 group-hover:text-dark-400 transition-colors" />
</div>
</Link>
)
}
/*
Main Dashboard Component
*/
export default function HomePage() {
const {
stats, recentScans, recentVulnerabilities,
setStats, setRecentScans, setRecentVulnerabilities, setLoading,
} = useDashboardStore()
const [activityFeed, setActivityFeed] = useState<ActivityFeedItem[]>([])
const [activeAgents, setActiveAgents] = useState<ActiveAgent[]>([])
const [maxConcurrent, setMaxConcurrent] = useState(5)
const [toasts, setToasts] = useState<Toast[]>([])
const [connectionLost, setConnectionLost] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [activityFilter, setActivityFilter] = useState<string>('all')
const consecutiveErrorsRef = useRef(0)
const prevFindingsCountRef = useRef(-1)
const prevRunningCountRef = useRef(-1)
/* ── Toast helpers ──────────────────────────────────────────── */
const addToast = useCallback((message: string, severity: Toast['severity'] = 'info') => {
const id = ++_toastId
setToasts(prev => [...prev.slice(-4), { id, message, severity }])
setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), 5000)
}, [])
const dismissToast = useCallback((id: number) => {
setToasts(prev => prev.filter(t => t.id !== id))
}, [])
/* ── Data fetch ─────────────────────────────────────────────── */
const fetchData = useCallback(async () => {
try {
const [statsData, recentData, activityData, agentsData] = await Promise.all([
dashboardApi.getStats(),
dashboardApi.getRecent(5),
dashboardApi.getActivityFeed(20),
agentApi.listActive().catch(() => ({ agents: [] as ActiveAgent[], max_concurrent: 5, running_count: 0 })),
])
setStats(statsData)
setRecentScans(recentData.recent_scans)
setRecentVulnerabilities(recentData.recent_vulnerabilities)
setActivityFeed(activityData.activities)
setActiveAgents(agentsData.agents || [])
setMaxConcurrent(agentsData.max_concurrent || 5)
// Detect new findings
const totalFindings = statsData.vulnerabilities.total
if (prevFindingsCountRef.current >= 0 && totalFindings > prevFindingsCountRef.current) {
const diff = totalFindings - prevFindingsCountRef.current
addToast(`${diff} new finding${diff > 1 ? 's' : ''} discovered`, 'warning')
}
prevFindingsCountRef.current = totalFindings
// Detect scan completions
const runningCount = (agentsData.agents || []).filter((a: ActiveAgent) => a.status === 'running').length
if (prevRunningCountRef.current > 0 && runningCount < prevRunningCountRef.current) {
addToast('A scan has completed', 'success')
}
prevRunningCountRef.current = runningCount
// Connection restored
if (consecutiveErrorsRef.current >= 3) {
setConnectionLost(false)
addToast('Connection restored', 'success')
}
consecutiveErrorsRef.current = 0
} catch (error) {
console.error('Failed to fetch dashboard data:', error)
consecutiveErrorsRef.current++
if (consecutiveErrorsRef.current >= 3) setConnectionLost(true)
}
}, [setStats, setRecentScans, setRecentVulnerabilities, addToast])
useEffect(() => {
setLoading(true)
fetchData().finally(() => setLoading(false))
const interval = setInterval(fetchData, 10000)
return () => clearInterval(interval)
}, [fetchData, setLoading])
const handleRefresh = useCallback(async () => {
setRefreshing(true)
await fetchData()
setRefreshing(false)
}, [fetchData])
/* ── Derived data ───────────────────────────────────────────── */
const severityChartData = useMemo(() => [
{ name: 'Critical', value: stats?.vulnerabilities.critical || 0, color: SEVERITY_CHART_COLORS.critical },
{ name: 'High', value: stats?.vulnerabilities.high || 0, color: SEVERITY_CHART_COLORS.high },
{ name: 'Medium', value: stats?.vulnerabilities.medium || 0, color: SEVERITY_CHART_COLORS.medium },
{ name: 'Low', value: stats?.vulnerabilities.low || 0, color: SEVERITY_CHART_COLORS.low },
{ name: 'Info', value: stats?.vulnerabilities.info || 0, color: SEVERITY_CHART_COLORS.info },
], [stats])
const scanChartData = useMemo(() => [
{ name: 'Running', value: stats?.scans.running || 0, color: STATUS_CHART_COLORS.running },
{ name: 'Completed', value: stats?.scans.completed || 0, color: STATUS_CHART_COLORS.completed },
{ name: 'Stopped', value: stats?.scans.stopped || 0, color: STATUS_CHART_COLORS.stopped },
{ name: 'Failed', value: stats?.scans.failed || 0, color: STATUS_CHART_COLORS.failed },
{ name: 'Pending', value: stats?.scans.pending || 0, color: STATUS_CHART_COLORS.pending },
], [stats])
const filteredActivity = useMemo(() => {
if (activityFilter === 'all') return activityFeed
return activityFeed.filter(a => a.type === activityFilter)
}, [activityFeed, activityFilter])
const statCards = useMemo(() => [
{ label: 'Total Scans', value: stats?.scans.total || 0, icon: Activity, color: 'text-blue-400', bg: 'bg-blue-500/10', border: 'border-blue-500/20' },
{ label: 'Running', value: stats?.scans.running || 0, icon: Play, color: 'text-green-400', bg: 'bg-green-500/10', border: 'border-green-500/20' },
{ label: 'Completed', value: stats?.scans.completed || 0, icon: CheckCircle, color: 'text-indigo-400', bg: 'bg-indigo-500/10', border: 'border-indigo-500/20' },
{ label: 'Total Vulns', value: stats?.vulnerabilities.total || 0, icon: Bug, color: 'text-red-400', bg: 'bg-red-500/10', border: 'border-red-500/20' },
{ label: 'Critical', value: stats?.vulnerabilities.critical || 0, icon: AlertTriangle, color: 'text-red-500', bg: 'bg-red-600/10', border: 'border-red-600/20' },
{ label: 'High', value: stats?.vulnerabilities.high || 0, icon: Shield, color: 'text-orange-400', bg: 'bg-orange-500/10', border: 'border-orange-500/20' },
], [stats])
/* ── Render ─────────────────────────────────────────────────── */
return (
<div className="space-y-6">
{/* Animations */}
<style>{`
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes countUp {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
`}</style>
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
{/* Connection Lost Banner */}
{connectionLost && (
<div
className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg px-4 py-2.5 flex items-center gap-3"
style={{ animation: 'fadeSlideIn 0.3s ease-out' }}
>
<WifiOff className="w-4 h-4 text-yellow-400 flex-shrink-0" />
<span className="text-sm text-yellow-300">Connection issues detected. Retrying...</span>
</div>
)}
{/* ── Header ────────────────────────────────────────────── */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
<Zap className="w-6 h-6 text-primary-500" />
NeuroSploit Dashboard
</h2>
<p className="text-dark-400 mt-1">AI-Powered Penetration Testing Platform</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleRefresh}
className="p-2 rounded-lg bg-dark-800 border border-dark-700 hover:border-dark-600 text-dark-400 hover:text-white transition-all"
title="Refresh"
>
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
</button>
<Link to="/scan/new">
<Button size="lg">
<Plus className="w-5 h-5 mr-2" />
New Scan
</Button>
</Link>
</div>
</div>
{/* ── Quick Actions ─────────────────────────────────────── */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{([
{ label: 'Auto Pentest', icon: Zap, to: '/auto', color: 'text-green-400', bg: 'bg-green-500/10 hover:bg-green-500/20', border: 'border-green-500/20 hover:border-green-500/40', desc: '3-stream AI testing' },
{ label: 'Full IA Testing', icon: Shield, to: '/full-ia', color: 'text-red-400', bg: 'bg-red-500/10 hover:bg-red-500/20', border: 'border-red-500/20 hover:border-red-500/40', desc: '100 vuln types' },
{ label: 'Vuln Lab', icon: FlaskConical, to: '/vuln-lab', color: 'text-purple-400', bg: 'bg-purple-500/10 hover:bg-purple-500/20', border: 'border-purple-500/20 hover:border-purple-500/40', desc: 'Per-type challenges' },
{ label: 'Terminal', icon: Terminal, to: '/terminal', color: 'text-cyan-400', bg: 'bg-cyan-500/10 hover:bg-cyan-500/20', border: 'border-cyan-500/20 hover:border-cyan-500/40', desc: 'AI chat + commands' },
] as const).map(action => (
<Link
key={action.to}
to={action.to}
className={`p-4 rounded-xl border ${action.border} ${action.bg} transition-all group`}
>
<action.icon className={`w-6 h-6 ${action.color} mb-2`} />
<p className="font-semibold text-white text-sm group-hover:translate-x-0.5 transition-transform">
{action.label}
</p>
<p className="text-xs text-dark-400 mt-0.5">{action.desc}</p>
</Link>
))}
</div>
{/* ── Stats Grid ────────────────────────────────────────── */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
{statCards.map((stat, idx) => (
<div
key={stat.label}
className={`bg-dark-800 rounded-xl border ${stat.border} p-4 hover:scale-[1.02] transition-all cursor-default`}
style={{ animation: `fadeSlideIn 0.3s ease-out ${idx * 0.05}s both` }}
>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${stat.bg}`}>
<stat.icon className={`w-5 h-5 ${stat.color}`} />
</div>
<div>
<p
className="text-xl font-bold text-white tabular-nums"
style={{ animation: 'countUp 0.5s ease-out' }}
>
{stat.value}
</p>
<p className="text-[11px] text-dark-400 whitespace-nowrap">{stat.label}</p>
</div>
</div>
</div>
))}
</div>
{/* ── Live Agents ───────────────────────────────────────── */}
{activeAgents.length > 0 && (
<Card
title={
<span className="flex items-center gap-2">
<span className="relative flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500" />
</span>
Live Agents ({activeAgents.length}/{maxConcurrent})
</span>
}
action={
<Link to="/auto" className="text-sm text-primary-500 hover:text-primary-400 flex items-center gap-1">
Manage <ArrowRight className="w-3.5 h-3.5" />
</Link>
}
>
<div className="space-y-2">
{activeAgents.map(agent => (
<ActiveAgentCard key={agent.agent_id} agent={agent} />
))}
</div>
</Card>
)}
{/* ── Charts Row ────────────────────────────────────────── */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card title="Vulnerability Severity">
<DonutChart data={severityChartData} />
</Card>
<Card title="Scan Status">
<DonutChart data={scanChartData} />
</Card>
</div>
{/* ── Recent Scans + Findings ───────────────────────────── */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Recent Scans */}
<Card
title="Recent Scans"
action={
<Link to="/reports" className="text-sm text-primary-500 hover:text-primary-400 flex items-center gap-1">
View All <ArrowRight className="w-4 h-4" />
</Link>
}
>
<div className="space-y-2">
{recentScans.length === 0 ? (
<div className="text-center py-8">
<Globe className="w-10 h-10 text-dark-600 mx-auto mb-2" />
<p className="text-dark-400 text-sm">No scans yet</p>
<Link to="/scan/new" className="text-primary-500 text-sm hover:underline mt-1 inline-block">
Start your first scan
</Link>
</div>
) : (
recentScans.map(scan => (
<Link
key={scan.id}
to={`/scan/${scan.id}`}
className="flex items-center justify-between p-3 bg-dark-900/50 rounded-lg hover:bg-dark-900 transition-colors group"
>
<div className="flex-1 min-w-0">
<p className="font-medium text-white truncate group-hover:text-primary-400 transition-colors">
{scan.name || 'Unnamed Scan'}
</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-dark-500">{relativeTime(scan.created_at)}</span>
{scan.status === 'running' && (
<div className="flex items-center gap-1.5">
<div className="w-16 h-1 bg-dark-700 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 rounded-full transition-all"
style={{ width: `${scan.progress}%` }}
/>
</div>
<span className="text-[10px] text-green-400 tabular-nums">{scan.progress}%</span>
</div>
)}
</div>
</div>
<div className="flex items-center gap-2 ml-2">
<SeverityBadge severity={scan.status} />
{scan.total_vulnerabilities > 0 && (
<span className="text-xs text-dark-400 tabular-nums">
{scan.total_vulnerabilities} vulns
</span>
)}
</div>
</Link>
))
)}
</div>
</Card>
{/* Recent Findings */}
<Card
title="Recent Findings"
action={
<Link to="/reports" className="text-sm text-primary-500 hover:text-primary-400 flex items-center gap-1">
View All <ArrowRight className="w-4 h-4" />
</Link>
}
>
<div className="space-y-2">
{recentVulnerabilities.length === 0 ? (
<div className="text-center py-8">
<Shield className="w-10 h-10 text-dark-600 mx-auto mb-2" />
<p className="text-dark-400 text-sm">No vulnerabilities found yet</p>
</div>
) : (
recentVulnerabilities.slice(0, 5).map(vuln => (
<div
key={vuln.id}
className={`flex items-center justify-between p-3 bg-dark-900/50 rounded-lg transition-colors hover:bg-dark-900 ${
vuln.validation_status === 'ai_rejected' ? 'opacity-60 border-l-2 border-orange-500/40' :
vuln.validation_status === 'false_positive' ? 'opacity-40' : ''
}`}
>
<div className="flex-1 min-w-0">
<p className="font-medium text-white truncate">{vuln.title}</p>
<p className="text-xs text-dark-400 truncate mt-0.5">{vuln.affected_endpoint}</p>
</div>
<div className="flex items-center gap-1.5 ml-2">
{vuln.confidence_score != null && (
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-bold tabular-nums ${
vuln.confidence_score >= 90 ? 'bg-green-500/20 text-green-400' :
vuln.confidence_score >= 60 ? 'bg-yellow-500/20 text-yellow-400' :
'bg-red-500/20 text-red-400'
}`}>
{vuln.confidence_score}
</span>
)}
{vuln.validation_status === 'ai_rejected' && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-orange-500/20 text-orange-400">
Rejected
</span>
)}
{vuln.validation_status === 'validated' && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-green-500/20 text-green-400">
Validated
</span>
)}
{vuln.validation_status === 'false_positive' && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-dark-600 text-dark-400">
FP
</span>
)}
<SeverityBadge severity={vuln.severity} />
</div>
</div>
))
)}
</div>
</Card>
</div>
{/* ── Activity Feed ─────────────────────────────────────── */}
<Card
title="Activity Feed"
action={
<div className="flex items-center gap-1 flex-wrap">
{(['all', 'scan', 'vulnerability', 'agent_task', 'report'] as const).map(f => (
<button
key={f}
onClick={() => setActivityFilter(f)}
className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
activityFilter === f
? 'bg-primary-500/20 text-primary-400'
: 'text-dark-400 hover:text-dark-200 hover:bg-dark-700'
}`}
>
{f === 'all' ? 'All'
: f === 'agent_task' ? 'Tasks'
: f === 'vulnerability' ? 'Vulns'
: f.charAt(0).toUpperCase() + f.slice(1) + 's'}
</button>
))}
</div>
}
>
<div className="space-y-1.5 max-h-[400px] overflow-auto">
{filteredActivity.length === 0 ? (
<p className="text-dark-400 text-center py-8 text-sm">No recent activity</p>
) : (
filteredActivity.map((activity, idx) => (
<Link
key={`${activity.type}-${activity.timestamp}-${idx}`}
to={activity.link}
className="flex items-start gap-3 p-3 bg-dark-900/50 rounded-lg hover:bg-dark-900 transition-colors group"
style={{ animation: `fadeSlideIn 0.2s ease-out ${Math.min(idx * 0.03, 0.3)}s both` }}
>
{/* Icon */}
<div className={`mt-0.5 p-1.5 rounded-lg flex-shrink-0 ${
activity.type === 'scan' ? 'bg-blue-500/20 text-blue-400' :
activity.type === 'vulnerability' ? 'bg-red-500/20 text-red-400' :
activity.type === 'agent_task' ? 'bg-purple-500/20 text-purple-400' :
'bg-green-500/20 text-green-400'
}`}>
{activity.type === 'scan' ? <Shield className="w-3.5 h-3.5" /> :
activity.type === 'vulnerability' ? <AlertTriangle className="w-3.5 h-3.5" /> :
activity.type === 'agent_task' ? <Cpu className="w-3.5 h-3.5" /> :
<FileText className="w-3.5 h-3.5" />}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[10px] text-dark-500 uppercase font-medium">
{activity.type.replace('_', ' ')}
</span>
<span className="text-[10px] text-dark-600">{activity.action}</span>
</div>
<p className="font-medium text-white text-sm truncate group-hover:text-primary-400 transition-colors">
{activity.title}
</p>
{activity.description && (
<p className="text-xs text-dark-400 truncate">{activity.description}</p>
)}
</div>
{/* Meta */}
<div className="flex flex-col items-end gap-1 flex-shrink-0">
{activity.severity && <SeverityBadge severity={activity.severity} />}
{activity.status && !activity.severity && (
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
activity.status === 'completed' ? 'bg-green-500/20 text-green-400' :
activity.status === 'running' ? 'bg-blue-500/20 text-blue-400' :
activity.status === 'failed' ? 'bg-red-500/20 text-red-400' :
activity.status === 'stopped' ? 'bg-yellow-500/20 text-yellow-400' :
'bg-dark-700 text-dark-300'
}`}>
{activity.status}
</span>
)}
<span className="text-[10px] text-dark-500">{relativeTime(activity.timestamp)}</span>
</div>
</Link>
))
)}
</div>
</Card>
</div>
)
}
+790
View File
@@ -0,0 +1,790 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import { Upload, FileText, Brain, Search, Trash2, ChevronDown, ChevronUp, BookOpen, RefreshCw, AlertTriangle, CheckCircle2, X, Database } from 'lucide-react'
import Card from '../components/common/Card'
import Button from '../components/common/Button'
/* ---------- inline keyframes ---------- */
const styleTag = `
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes refreshSpin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`
const API_BASE = '/api/v1/knowledge'
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
const SUPPORTED_EXTENSIONS = ['.pdf', '.md', '.txt', '.html']
const SUPPORTED_MIME_TYPES = [
'application/pdf',
'text/markdown',
'text/plain',
'text/html',
]
interface KnowledgeDocument {
id: string
filename: string
title: string
source_type: string
uploaded_at: string
summary: string
vuln_types: string[]
entry_count: number
file_size_bytes: number
}
interface KnowledgeEntry {
id: string
vuln_type: string
category: string
content: string
source: string
}
interface KnowledgeDocumentDetail extends KnowledgeDocument {
knowledge_entries: KnowledgeEntry[]
}
interface KnowledgeStats {
total_documents: number
total_entries: number
vuln_types_covered: string[]
vuln_type_count: number
}
/* ---------- Toast notification system ---------- */
interface Toast {
id: number
message: string
severity: 'info' | 'success' | 'warning' | 'error'
}
let _toastId = 0
function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: number) => void }) {
if (toasts.length === 0) return null
const border: Record<string, string> = {
info: 'border-blue-500',
success: 'border-green-500',
warning: 'border-yellow-500',
error: 'border-red-500',
}
return (
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
{toasts.map(t => (
<div
key={t.id}
className={`bg-dark-800 border-l-4 ${border[t.severity]} rounded-lg px-4 py-3 shadow-xl flex items-start gap-3`}
style={{ animation: 'fadeSlideIn 0.3s ease-out' }}
>
<span className="text-sm text-dark-200 flex-1">{t.message}</span>
<button onClick={() => onDismiss(t.id)} className="text-dark-500 hover:text-white">
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)
}
// Assign a consistent color to a vuln type based on its name
const VULN_TYPE_COLORS = [
{ bg: 'bg-purple-500/20', text: 'text-purple-400' },
{ bg: 'bg-blue-500/20', text: 'text-blue-400' },
{ bg: 'bg-green-500/20', text: 'text-green-400' },
{ bg: 'bg-yellow-500/20', text: 'text-yellow-400' },
{ bg: 'bg-red-500/20', text: 'text-red-400' },
{ bg: 'bg-pink-500/20', text: 'text-pink-400' },
{ bg: 'bg-cyan-500/20', text: 'text-cyan-400' },
{ bg: 'bg-orange-500/20', text: 'text-orange-400' },
{ bg: 'bg-indigo-500/20', text: 'text-indigo-400' },
{ bg: 'bg-teal-500/20', text: 'text-teal-400' },
{ bg: 'bg-emerald-500/20', text: 'text-emerald-400' },
{ bg: 'bg-violet-500/20', text: 'text-violet-400' },
]
function getVulnTypeColor(vulnType: string) {
let hash = 0
for (let i = 0; i < vulnType.length; i++) {
hash = vulnType.charCodeAt(i) + ((hash << 5) - hash)
}
const index = Math.abs(hash) % VULN_TYPE_COLORS.length
return VULN_TYPE_COLORS[index]
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
const SOURCE_TYPE_STYLES: Record<string, { bg: string; text: string }> = {
pdf: { bg: 'bg-red-500/15', text: 'text-red-400' },
markdown: { bg: 'bg-blue-500/15', text: 'text-blue-400' },
text: { bg: 'bg-green-500/15', text: 'text-green-400' },
html: { bg: 'bg-orange-500/15', text: 'text-orange-400' },
}
function getSourceTypeBadge(sourceType: string) {
const style = SOURCE_TYPE_STYLES[sourceType] || { bg: 'bg-dark-600', text: 'text-dark-300' }
return style
}
const ENTRY_CATEGORY_ICONS: Record<string, string> = {
methodology: 'Strategy & approach',
payloads: 'Attack payloads',
insights: 'Key observations',
bypass: 'WAF/filter bypass techniques',
detection: 'Detection patterns',
remediation: 'Fix guidance',
reference: 'Reference material',
}
export default function KnowledgePage() {
const [stats, setStats] = useState<KnowledgeStats | null>(null)
const [documents, setDocuments] = useState<KnowledgeDocument[]>([])
const [loading, setLoading] = useState(true)
const [uploading, setUploading] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const [filterVulnType, setFilterVulnType] = useState<string>('')
const [expandedDocId, setExpandedDocId] = useState<string | null>(null)
const [expandedDocDetail, setExpandedDocDetail] = useState<KnowledgeDocumentDetail | null>(null)
const [loadingDetail, setLoadingDetail] = useState(false)
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
const [isDragOver, setIsDragOver] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [toasts, setToasts] = useState<Toast[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
/* ---------- Toast helpers ---------- */
const addToast = useCallback((message: string, severity: Toast['severity']) => {
const id = ++_toastId
setToasts(prev => [...prev, { id, message, severity }])
setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), 4000)
}, [])
const dismissToast = useCallback((id: number) => {
setToasts(prev => prev.filter(t => t.id !== id))
}, [])
/* ---------- Derived data ---------- */
const uniqueCategories = useMemo(() => {
const cats = new Set<string>()
documents.forEach(doc => {
doc.vuln_types.forEach(vt => cats.add(vt))
})
return cats.size
}, [documents])
const totalEntries = useMemo(() => {
return documents.reduce((sum, doc) => sum + doc.entry_count, 0)
}, [documents])
/* ---------- Data fetching ---------- */
const fetchStats = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/stats`)
if (res.ok) {
const data = await res.json()
setStats(data)
}
} catch (err) {
console.error('Failed to fetch knowledge stats:', err)
}
}, [])
const fetchDocuments = useCallback(async (vulnType?: string) => {
setLoading(true)
try {
const url = vulnType
? `${API_BASE}/search?vuln_type=${encodeURIComponent(vulnType)}`
: `${API_BASE}/documents`
const res = await fetch(url)
if (res.ok) {
const data = await res.json()
// search endpoint returns { results, count }, documents returns array
setDocuments(vulnType ? data.results : data)
}
} catch (err) {
console.error('Failed to fetch knowledge documents:', err)
} finally {
setLoading(false)
}
}, [])
const fetchAll = useCallback(() => {
fetchStats()
fetchDocuments(filterVulnType || undefined)
}, [fetchStats, fetchDocuments, filterVulnType])
const handleRefresh = useCallback(async () => {
setRefreshing(true)
await Promise.all([fetchStats(), fetchDocuments(filterVulnType || undefined)])
setRefreshing(false)
}, [fetchStats, fetchDocuments, filterVulnType])
useEffect(() => {
fetchStats()
fetchDocuments()
}, [fetchStats, fetchDocuments])
// Auto-dismiss messages after 5 seconds
useEffect(() => {
if (message) {
const timer = setTimeout(() => setMessage(null), 5000)
return () => clearTimeout(timer)
}
}, [message])
const handleFilterChange = useCallback((vulnType: string) => {
setFilterVulnType(vulnType)
setExpandedDocId(null)
setExpandedDocDetail(null)
fetchDocuments(vulnType || undefined)
}, [fetchDocuments])
const validateFile = useCallback((file: File): string | null => {
if (file.size > MAX_FILE_SIZE) {
return `File "${file.name}" exceeds 10MB limit (${formatFileSize(file.size)})`
}
const ext = '.' + file.name.split('.').pop()?.toLowerCase()
if (!SUPPORTED_EXTENSIONS.includes(ext) && !SUPPORTED_MIME_TYPES.includes(file.type)) {
return `Unsupported file type "${ext}". Supported: PDF, MD, TXT, HTML`
}
return null
}, [])
const uploadFile = useCallback(async (file: File) => {
const validationError = validateFile(file)
if (validationError) {
setMessage({ type: 'error', text: validationError })
addToast(validationError, 'error')
return
}
setUploading(true)
setMessage(null)
try {
const formData = new FormData()
formData.append('file', file)
const res = await fetch(`${API_BASE}/upload`, {
method: 'POST',
body: formData,
})
if (res.ok) {
const data = await res.json()
const successMsg = data.message || `"${file.name}" uploaded and processed successfully`
setMessage({ type: 'success', text: successMsg })
addToast(successMsg, 'success')
fetchAll()
} else {
const errData = await res.json().catch(() => null)
const errMsg = errData?.detail || `Failed to upload "${file.name}"`
setMessage({ type: 'error', text: errMsg })
addToast(errMsg, 'error')
}
} catch (err) {
const errMsg = `Upload failed: ${err instanceof Error ? err.message : 'Network error'}`
setMessage({ type: 'error', text: errMsg })
addToast(errMsg, 'error')
} finally {
setUploading(false)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
}, [validateFile, addToast, fetchAll])
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) uploadFile(file)
}, [uploadFile])
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragOver(true)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragOver(false)
}, [])
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragOver(false)
const file = e.dataTransfer.files?.[0]
if (file) uploadFile(file)
}, [uploadFile])
const handleDelete = useCallback(async (docId: string) => {
try {
const res = await fetch(`${API_BASE}/documents/${docId}`, { method: 'DELETE' })
if (res.ok) {
setMessage({ type: 'success', text: 'Document deleted successfully' })
addToast('Document deleted successfully', 'success')
setDeleteConfirm(null)
if (expandedDocId === docId) {
setExpandedDocId(null)
setExpandedDocDetail(null)
}
fetchAll()
} else {
setMessage({ type: 'error', text: 'Failed to delete document' })
addToast('Failed to delete document', 'error')
}
} catch (err) {
setMessage({ type: 'error', text: 'Failed to delete document' })
addToast('Failed to delete document', 'error')
}
}, [expandedDocId, addToast, fetchAll])
const toggleExpand = useCallback(async (docId: string) => {
if (expandedDocId === docId) {
setExpandedDocId(null)
setExpandedDocDetail(null)
return
}
setExpandedDocId(docId)
setExpandedDocDetail(null)
setLoadingDetail(true)
try {
const res = await fetch(`${API_BASE}/documents/${docId}`)
if (res.ok) {
const data = await res.json()
setExpandedDocDetail(data)
}
} catch (err) {
console.error('Failed to fetch document detail:', err)
} finally {
setLoadingDetail(false)
}
}, [expandedDocId])
return (
<div className="space-y-6 animate-fadeIn">
{/* Inline keyframes */}
<style>{styleTag}</style>
{/* Toast notifications */}
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
{/* Header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-white flex items-center gap-3">
<div className="p-2 bg-purple-500/20 rounded-lg">
<Brain className="w-6 h-6 text-purple-400" />
</div>
Knowledge Base
</h2>
<p className="text-dark-400 mt-1 ml-14">
Upload and manage vulnerability research, methodologies, and attack knowledge
</p>
</div>
<Button variant="secondary" onClick={handleRefresh} disabled={refreshing}>
<RefreshCw
className="w-4 h-4 mr-2"
style={refreshing ? { animation: 'refreshSpin 0.8s linear infinite' } : undefined}
/>
Refresh
</Button>
</div>
{/* Stats Row */}
<div
className="grid grid-cols-1 sm:grid-cols-3 gap-4"
style={{ animation: 'fadeSlideIn 0.3s ease-out' }}
>
<div className="bg-dark-800/50 border border-dark-700/50 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-500/15 rounded-lg">
<FileText className="w-5 h-5 text-blue-400" />
</div>
<div>
<p className="text-dark-400 text-sm">Documents</p>
<p className="text-2xl font-bold text-white">{stats?.total_documents ?? documents.length}</p>
</div>
</div>
</div>
<div className="bg-dark-800/50 border border-dark-700/50 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-500/15 rounded-lg">
<Database className="w-5 h-5 text-purple-400" />
</div>
<div>
<p className="text-dark-400 text-sm">Knowledge Entries</p>
<p className="text-2xl font-bold text-white">{stats?.total_entries ?? totalEntries}</p>
</div>
</div>
</div>
<div className="bg-dark-800/50 border border-dark-700/50 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-500/15 rounded-lg">
<BookOpen className="w-5 h-5 text-green-400" />
</div>
<div>
<p className="text-dark-400 text-sm">Categories</p>
<p className="text-2xl font-bold text-white">{stats?.vuln_type_count ?? uniqueCategories}</p>
</div>
</div>
{stats && stats.vuln_types_covered.length > 0 && (
<div className="flex flex-wrap gap-1 mt-3">
{stats.vuln_types_covered.slice(0, 8).map(vt => {
const color = getVulnTypeColor(vt)
return (
<span
key={vt}
className={`px-2 py-0.5 text-xs rounded-full ${color.bg} ${color.text}`}
>
{vt}
</span>
)
})}
{stats.vuln_types_covered.length > 8 && (
<span className="px-2 py-0.5 text-xs rounded-full bg-dark-600 text-dark-300">
+{stats.vuln_types_covered.length - 8} more
</span>
)}
</div>
)}
</div>
</div>
{/* Status Message */}
{message && (
<div
className={`flex items-center justify-between gap-3 p-4 rounded-lg border transition-all ${
message.type === 'success'
? 'bg-green-500/10 border-green-500/30 text-green-400'
: 'bg-red-500/10 border-red-500/30 text-red-400'
}`}
style={{ animation: 'fadeSlideIn 0.3s ease-out' }}
>
<div className="flex items-center gap-3">
{message.type === 'success'
? <CheckCircle2 className="w-5 h-5 flex-shrink-0" />
: <AlertTriangle className="w-5 h-5 flex-shrink-0" />
}
<span>{message.text}</span>
</div>
<button onClick={() => setMessage(null)} className="text-dark-400 hover:text-white transition-colors">
<X className="w-4 h-4" />
</button>
</div>
)}
{/* Upload Area */}
<div style={{ animation: 'fadeSlideIn 0.4s ease-out' }}>
<Card title="Upload Knowledge" subtitle="Add vulnerability research documents to the knowledge base">
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => !uploading && fileInputRef.current?.click()}
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer ${
isDragOver
? 'border-primary-500 bg-primary-500/5'
: 'border-dark-600 hover:border-primary-500'
} ${uploading ? 'opacity-60 cursor-wait' : ''}`}
>
<input
ref={fileInputRef}
type="file"
accept=".pdf,.md,.txt,.html"
onChange={handleFileSelect}
className="hidden"
disabled={uploading}
/>
{uploading ? (
<div className="flex flex-col items-center gap-3">
<div className="w-12 h-12 rounded-full border-2 border-primary-500 border-t-transparent animate-spin" />
<p className="text-white font-medium">Processing document...</p>
<p className="text-dark-400 text-sm">Extracting knowledge entries and indexing vulnerability types</p>
</div>
) : (
<div className="flex flex-col items-center gap-3">
<div className="w-14 h-14 bg-dark-700/50 rounded-full flex items-center justify-center">
<Upload className="w-7 h-7 text-dark-400" />
</div>
<div>
<p className="text-white font-medium">
{isDragOver ? 'Drop file here' : 'Drag and drop a file here, or click to browse'}
</p>
<p className="text-dark-400 text-sm mt-1">
Supported: PDF, Markdown, TXT, HTML -- Max 10MB
</p>
</div>
</div>
)}
</div>
</Card>
</div>
{/* Filter / Search */}
<div
className="flex flex-col sm:flex-row items-start sm:items-center gap-4"
style={{ animation: 'fadeSlideIn 0.5s ease-out' }}
>
<div className="flex items-center gap-2 text-dark-400">
<Search className="w-4 h-4" />
<span className="text-sm font-medium">Filter by vulnerability type</span>
</div>
<div className="relative flex-1 max-w-xs w-full">
<select
value={filterVulnType}
onChange={(e) => handleFilterChange(e.target.value)}
className="w-full bg-dark-900 border border-dark-700 rounded-lg px-4 py-2 text-white text-sm appearance-none focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors"
>
<option value="">All vulnerability types</option>
{(stats?.vuln_types_covered || []).map(vt => (
<option key={vt} value={vt}>{vt}</option>
))}
</select>
<ChevronDown className="w-4 h-4 text-dark-400 absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none" />
</div>
{filterVulnType && (
<button
onClick={() => handleFilterChange('')}
className="text-sm text-primary-400 hover:text-primary-300 transition-colors"
>
Clear filter
</button>
)}
</div>
{/* Document List */}
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white">
Documents
<span className="text-dark-500 text-sm font-normal ml-2">
{documents.length} document{documents.length !== 1 ? 's' : ''}
{filterVulnType && ` matching "${filterVulnType}"`}
</span>
</h3>
</div>
{loading ? (
<div className="flex items-center justify-center py-16">
<RefreshCw className="w-6 h-6 text-dark-400 animate-spin" />
</div>
) : documents.length === 0 ? (
<Card>
<div className="text-center py-16" style={{ animation: 'fadeSlideIn 0.4s ease-out' }}>
<div className="w-20 h-20 bg-dark-700/30 rounded-full flex items-center justify-center mx-auto mb-5">
<BookOpen className="w-10 h-10 text-dark-500" />
</div>
<p className="text-dark-300 font-semibold text-lg">
{filterVulnType ? 'No documents match this filter' : 'No knowledge documents yet'}
</p>
<p className="text-dark-500 text-sm mt-2 max-w-md mx-auto">
{filterVulnType
? 'Try a different vulnerability type or clear the filter'
: 'Upload research papers, writeups, or methodology docs to build your knowledge base'
}
</p>
{!filterVulnType && (
<Button
variant="primary"
className="mt-6"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="w-4 h-4 mr-2" />
Upload your first document
</Button>
)}
</div>
</Card>
) : (
<div className="space-y-3">
{documents.map((doc, idx) => (
<div
key={doc.id}
className="bg-dark-800 border border-dark-700/50 rounded-xl overflow-hidden hover:border-dark-600 transition-colors"
style={{ animation: `fadeSlideIn ${0.2 + idx * 0.06}s ease-out` }}
>
{/* Document Header */}
<div className="p-4 sm:p-5">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3 sm:gap-4 flex-1 min-w-0">
<div className="p-2.5 bg-dark-700/50 rounded-lg flex-shrink-0">
<FileText className="w-5 h-5 text-dark-300" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 flex-wrap">
<h4 className="font-semibold text-white text-base sm:text-lg truncate">
{doc.title || doc.filename}
</h4>
{(() => {
const badge = getSourceTypeBadge(doc.source_type)
return (
<span className={`px-2.5 py-0.5 text-xs rounded-full font-medium ${badge.bg} ${badge.text} border border-current/20`}>
{doc.source_type.toUpperCase()}
</span>
)
})()}
</div>
{doc.title && doc.title !== doc.filename && (
<p className="text-dark-500 text-sm mt-0.5 truncate">{doc.filename}</p>
)}
{/* Metadata row */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2 text-sm text-dark-400">
<span>{formatDate(doc.uploaded_at)}</span>
<span>{formatFileSize(doc.file_size_bytes)}</span>
<span>{doc.entry_count} {doc.entry_count === 1 ? 'entry' : 'entries'}</span>
</div>
{/* Summary preview */}
{doc.summary && (
<p className="text-dark-300 text-sm mt-2 line-clamp-2">{doc.summary}</p>
)}
{/* Vuln type badges */}
{doc.vuln_types.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-3">
{doc.vuln_types.map(vt => {
const color = getVulnTypeColor(vt)
return (
<span
key={vt}
className={`px-2 py-0.5 text-xs rounded-full ${color.bg} ${color.text}`}
>
{vt}
</span>
)
})}
</div>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1 flex-shrink-0 ml-4">
<button
onClick={() => toggleExpand(doc.id)}
className="p-2 rounded-lg text-dark-400 hover:text-white hover:bg-dark-700/50 transition-colors"
title={expandedDocId === doc.id ? 'Collapse' : 'Expand details'}
>
{expandedDocId === doc.id
? <ChevronUp className="w-5 h-5" />
: <ChevronDown className="w-5 h-5" />
}
</button>
{deleteConfirm === doc.id ? (
<div className="flex items-center gap-1">
<Button variant="danger" size="sm" onClick={() => handleDelete(doc.id)}>
Confirm
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeleteConfirm(null)}>
<span className="text-dark-400 text-xs">Cancel</span>
</Button>
</div>
) : (
<button
onClick={() => setDeleteConfirm(doc.id)}
className="p-2 rounded-lg text-dark-400 hover:text-red-400 hover:bg-red-500/10 transition-colors"
title="Delete document"
>
<Trash2 className="w-5 h-5" />
</button>
)}
</div>
</div>
</div>
{/* Expanded Detail */}
{expandedDocId === doc.id && (
<div
className="border-t border-dark-700/50 bg-dark-900/50"
style={{ animation: 'fadeSlideIn 0.3s ease-out' }}
>
{loadingDetail ? (
<div className="flex items-center justify-center py-12">
<RefreshCw className="w-5 h-5 text-dark-400 animate-spin" />
<span className="text-dark-400 text-sm ml-3">Loading knowledge entries...</span>
</div>
) : expandedDocDetail && expandedDocDetail.knowledge_entries.length > 0 ? (
<div className="p-5 space-y-3">
<div className="flex items-center gap-2 mb-4">
<BookOpen className="w-4 h-4 text-purple-400" />
<span className="text-sm font-medium text-white">
{expandedDocDetail.knowledge_entries.length} Knowledge {expandedDocDetail.knowledge_entries.length === 1 ? 'Entry' : 'Entries'}
</span>
</div>
{expandedDocDetail.knowledge_entries.map((entry, idx) => {
const vulnColor = getVulnTypeColor(entry.vuln_type)
const categoryLabel = ENTRY_CATEGORY_ICONS[entry.category] || entry.category
return (
<div
key={entry.id || idx}
className="bg-dark-800 border border-dark-700/50 rounded-lg p-4"
style={{ animation: `fadeSlideIn ${0.15 + idx * 0.05}s ease-out` }}
>
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className={`px-2 py-0.5 text-xs rounded-full ${vulnColor.bg} ${vulnColor.text}`}>
{entry.vuln_type}
</span>
<span className="px-2 py-0.5 text-xs rounded-full bg-dark-600 text-dark-300">
{entry.category}
</span>
{categoryLabel !== entry.category && (
<span className="text-xs text-dark-500">{categoryLabel}</span>
)}
</div>
<pre className="text-sm text-dark-200 whitespace-pre-wrap font-mono leading-relaxed bg-dark-900/50 rounded-lg p-3 max-h-64 overflow-y-auto">
{entry.content}
</pre>
{entry.source && (
<p className="text-xs text-dark-500 mt-2">
Source: {entry.source}
</p>
)}
</div>
)
})}
</div>
) : (
<div className="text-center py-12">
<p className="text-dark-400 text-sm">No knowledge entries found in this document</p>
</div>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
)
}
+981
View File
@@ -0,0 +1,981 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import {
Plus, Trash2, RefreshCw, Server, Wifi, Terminal, Pencil,
ChevronDown, ChevronRight, CheckCircle2, AlertTriangle, X,
Wrench, Plug, Loader2
} from 'lucide-react'
import Card from '../components/common/Card'
import Button from '../components/common/Button'
import Input from '../components/common/Input'
/* ------------------------------------------------------------------ */
/* Inline keyframes */
/* ------------------------------------------------------------------ */
const styleTag = `
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes refreshSpin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface MCPServer {
name: string
transport: 'stdio' | 'sse'
command?: string
args?: string[]
url?: string
env?: Record<string, string>
description?: string
enabled: boolean
is_builtin: boolean
tool_count: number
}
interface MCPTool {
name: string
description: string
input_schema: Record<string, unknown>
}
interface TestResult {
success: boolean
message: string
}
/* ------------------------------------------------------------------ */
/* Toast notification system */
/* ------------------------------------------------------------------ */
interface Toast {
id: number
message: string
severity: 'info' | 'success' | 'warning' | 'error'
}
let _toastId = 0
function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: number) => void }) {
if (toasts.length === 0) return null
const border: Record<string, string> = {
info: 'border-blue-500',
success: 'border-green-500',
warning: 'border-yellow-500',
error: 'border-red-500',
}
return (
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
{toasts.map(t => (
<div
key={t.id}
className={`bg-dark-800 border-l-4 ${border[t.severity]} rounded-lg px-4 py-3 shadow-xl flex items-start gap-3`}
style={{ animation: 'fadeSlideIn 0.3s ease-out' }}
>
<span className="text-sm text-dark-200 flex-1">{t.message}</span>
<button onClick={() => onDismiss(t.id)} className="text-dark-500 hover:text-white">
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)
}
/* ------------------------------------------------------------------ */
/* Delete Confirmation Modal */
/* ------------------------------------------------------------------ */
function DeleteModal({ name, onConfirm, onCancel }: {
name: string
onConfirm: () => void
onCancel: () => void
}) {
return (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" onClick={onCancel}>
<div
className="bg-dark-800 rounded-xl border border-dark-700 p-6 max-w-sm w-full"
onClick={e => e.stopPropagation()}
style={{ animation: 'fadeSlideIn 0.2s ease-out' }}
>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-red-500/10">
<Trash2 className="w-5 h-5 text-red-400" />
</div>
<h3 className="text-lg font-semibold text-white">Delete Server</h3>
</div>
<p className="text-sm text-dark-300 mb-6">
Are you sure you want to delete <span className="text-white font-medium">&quot;{name}&quot;</span>?
This cannot be undone.
</p>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onCancel}>Cancel</Button>
<Button variant="danger" onClick={onConfirm}>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</Button>
</div>
</div>
</div>
)
}
/* ------------------------------------------------------------------ */
/* Sub-components */
/* ------------------------------------------------------------------ */
function ToggleSwitch({ enabled, onToggle }: { enabled: boolean; onToggle: () => void }) {
return (
<button
onClick={onToggle}
className={`w-12 h-6 rounded-full transition-colors ${enabled ? 'bg-primary-500' : 'bg-dark-700'}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow-md transform transition-transform ${enabled ? 'translate-x-6' : 'translate-x-0.5'}`} />
</button>
)
}
function TransportBadge({ transport }: { transport: 'stdio' | 'sse' }) {
return (
<span className={`px-2 py-0.5 text-xs rounded-full font-medium ${
transport === 'stdio'
? 'bg-blue-500/20 text-blue-400'
: 'bg-purple-500/20 text-purple-400'
}`}>
{transport === 'stdio' ? (
<span className="inline-flex items-center gap-1"><Terminal className="w-3 h-3" />stdio</span>
) : (
<span className="inline-flex items-center gap-1"><Wifi className="w-3 h-3" />sse</span>
)}
</span>
)
}
/* ------------------------------------------------------------------ */
/* Tool param extractor */
/* ------------------------------------------------------------------ */
function getToolParams(schema: Record<string, unknown>): string[] {
if (!schema || typeof schema !== 'object') return []
const props = schema.properties
if (!props || typeof props !== 'object') return []
return Object.keys(props as Record<string, unknown>)
}
/* ------------------------------------------------------------------ */
/* Main Component */
/* ------------------------------------------------------------------ */
export default function MCPManagementPage() {
// Server list
const [servers, setServers] = useState<MCPServer[]>([])
const [loading, setLoading] = useState(true)
// Modal
const [showModal, setShowModal] = useState(false)
const [editingServer, setEditingServer] = useState<MCPServer | null>(null)
// Form state
const [formName, setFormName] = useState('')
const [formTransport, setFormTransport] = useState<'stdio' | 'sse'>('stdio')
const [formCommand, setFormCommand] = useState('')
const [formArgs, setFormArgs] = useState('')
const [formUrl, setFormUrl] = useState('')
const [formEnv, setFormEnv] = useState('')
const [formDescription, setFormDescription] = useState('')
const [isSaving, setIsSaving] = useState(false)
// Test results { serverName: TestResult }
const [testResults, setTestResults] = useState<Record<string, TestResult>>({})
const [testingServer, setTestingServer] = useState<string | null>(null)
// Expanded tool browser
const [expandedServer, setExpandedServer] = useState<string | null>(null)
const [serverTools, setServerTools] = useState<Record<string, MCPTool[]>>({})
const [loadingTools, setLoadingTools] = useState<string | null>(null)
// Toast system
const [toasts, setToasts] = useState<Toast[]>([])
// Delete confirmation
const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
// Refresh animation
const [refreshing, setRefreshing] = useState(false)
/* ---------------------------------------------------------------- */
/* Toast helpers */
/* ---------------------------------------------------------------- */
const addToast = useCallback((message: string, severity: Toast['severity']) => {
const id = ++_toastId
setToasts(prev => [...prev, { id, message, severity }])
setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), 5000)
}, [])
const dismissToast = useCallback((id: number) => {
setToasts(prev => prev.filter(t => t.id !== id))
}, [])
/* ---------------------------------------------------------------- */
/* Derived data */
/* ---------------------------------------------------------------- */
const enabledCount = useMemo(() => servers.filter(s => s.enabled).length, [servers])
const totalTools = useMemo(() => servers.reduce((sum, s) => sum + s.tool_count, 0), [servers])
/* ---------------------------------------------------------------- */
/* Data fetching */
/* ---------------------------------------------------------------- */
const fetchServers = useCallback(async () => {
setLoading(true)
try {
const res = await fetch('/api/v1/mcp/servers')
if (res.ok) {
const data = await res.json()
setServers(data.servers ?? [])
}
} catch (err) {
console.error('Failed to fetch MCP servers:', err)
} finally {
setLoading(false)
}
}, [])
const handleRefresh = useCallback(async () => {
setRefreshing(true)
try {
const res = await fetch('/api/v1/mcp/servers')
if (res.ok) {
const data = await res.json()
setServers(data.servers ?? [])
}
} catch (err) {
console.error('Failed to fetch MCP servers:', err)
} finally {
setLoading(false)
setRefreshing(false)
}
}, [])
useEffect(() => {
fetchServers()
}, [fetchServers])
/* ---------------------------------------------------------------- */
/* CRUD handlers */
/* ---------------------------------------------------------------- */
const parseEnvVars = useCallback((raw: string): Record<string, string> | undefined => {
const lines = raw.split('\n').map(l => l.trim()).filter(Boolean)
if (lines.length === 0) return undefined
const env: Record<string, string> = {}
for (const line of lines) {
const idx = line.indexOf('=')
if (idx > 0) {
env[line.slice(0, idx).trim()] = line.slice(idx + 1).trim()
}
}
return Object.keys(env).length > 0 ? env : undefined
}, [])
const handleSave = useCallback(async () => {
if (!formName.trim()) {
addToast('Server name is required', 'error')
return
}
if (formTransport === 'stdio' && !formCommand.trim()) {
addToast('Command is required for stdio transport', 'error')
return
}
if (formTransport === 'sse' && !formUrl.trim()) {
addToast('URL is required for SSE transport', 'error')
return
}
setIsSaving(true)
const body: Record<string, unknown> = {
name: formName.trim(),
transport: formTransport,
description: formDescription.trim() || undefined,
env: parseEnvVars(formEnv),
}
if (formTransport === 'stdio') {
body.command = formCommand.trim()
body.args = formArgs.trim() ? formArgs.trim().split(/\s+/) : undefined
} else {
body.url = formUrl.trim()
}
const isEdit = !!editingServer
const url = isEdit
? `/api/v1/mcp/servers/${encodeURIComponent(editingServer.name)}`
: '/api/v1/mcp/servers'
const method = isEdit ? 'PUT' : 'POST'
try {
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (res.ok) {
addToast(`Server "${formName}" ${isEdit ? 'updated' : 'created'} successfully`, 'success')
closeModal()
fetchServers()
} else {
const data = await res.json().catch(() => ({}))
const detail = (data as Record<string, string>).detail
addToast(detail || `Failed to ${isEdit ? 'update' : 'create'} server`, 'error')
}
} catch {
addToast(`Failed to ${isEdit ? 'update' : 'create'} server`, 'error')
} finally {
setIsSaving(false)
}
}, [formName, formTransport, formCommand, formArgs, formUrl, formEnv, formDescription, editingServer, parseEnvVars, addToast, fetchServers])
const handleDelete = useCallback(async (name: string) => {
try {
const res = await fetch(`/api/v1/mcp/servers/${encodeURIComponent(name)}`, { method: 'DELETE' })
if (res.ok) {
addToast(`Server "${name}" deleted`, 'success')
setDeleteTarget(null)
fetchServers()
} else {
const data = await res.json().catch(() => ({}))
const detail = (data as Record<string, string>).detail
addToast(detail || `Failed to delete "${name}"`, 'error')
}
} catch {
addToast(`Failed to delete "${name}"`, 'error')
}
}, [addToast, fetchServers])
const handleToggle = useCallback(async (name: string) => {
try {
const res = await fetch(`/api/v1/mcp/servers/${encodeURIComponent(name)}/toggle`, { method: 'POST' })
if (res.ok) {
fetchServers()
} else {
addToast(`Failed to toggle "${name}"`, 'error')
}
} catch {
addToast(`Failed to toggle "${name}"`, 'error')
}
}, [addToast, fetchServers])
const handleTest = useCallback(async (name: string) => {
setTestingServer(name)
setTestResults(prev => {
const next = { ...prev }
delete next[name]
return next
})
try {
const res = await fetch(`/api/v1/mcp/servers/${encodeURIComponent(name)}/test`, { method: 'POST' })
if (res.ok) {
const data: TestResult = await res.json()
setTestResults(prev => ({ ...prev, [name]: data }))
addToast(data.success ? `"${name}" connected successfully` : `"${name}" test failed: ${data.message}`, data.success ? 'success' : 'error')
} else {
setTestResults(prev => ({ ...prev, [name]: { success: false, message: 'Test request failed' } }))
addToast(`Test request for "${name}" failed`, 'error')
}
} catch {
setTestResults(prev => ({ ...prev, [name]: { success: false, message: 'Network error' } }))
addToast(`Network error testing "${name}"`, 'error')
} finally {
setTestingServer(null)
}
}, [addToast])
/* ---------------------------------------------------------------- */
/* Tool browser */
/* ---------------------------------------------------------------- */
const toggleToolBrowser = useCallback(async (name: string) => {
if (expandedServer === name) {
setExpandedServer(null)
return
}
setExpandedServer(name)
if (serverTools[name]) return // already loaded
setLoadingTools(name)
try {
const res = await fetch(`/api/v1/mcp/servers/${encodeURIComponent(name)}/tools`)
if (res.ok) {
const data = await res.json()
setServerTools(prev => ({ ...prev, [name]: data.tools ?? [] }))
}
} catch {
console.error('Failed to fetch tools for', name)
} finally {
setLoadingTools(null)
}
}, [expandedServer, serverTools])
/* ---------------------------------------------------------------- */
/* Modal helpers */
/* ---------------------------------------------------------------- */
const openAddModal = useCallback(() => {
setEditingServer(null)
setFormName('')
setFormTransport('stdio')
setFormCommand('')
setFormArgs('')
setFormUrl('')
setFormEnv('')
setFormDescription('')
setShowModal(true)
}, [])
const openEditModal = useCallback((server: MCPServer) => {
setEditingServer(server)
setFormName(server.name)
setFormTransport(server.transport)
setFormCommand(server.command ?? '')
setFormArgs(server.args?.join(' ') ?? '')
setFormUrl(server.url ?? '')
setFormEnv(
server.env
? Object.entries(server.env).map(([k, v]) => `${k}=${v}`).join('\n')
: ''
)
setFormDescription(server.description ?? '')
setShowModal(true)
}, [])
const closeModal = useCallback(() => {
setShowModal(false)
setEditingServer(null)
}, [])
/* ---------------------------------------------------------------- */
/* Render */
/* ---------------------------------------------------------------- */
return (
<>
{/* Inline keyframes */}
<style>{styleTag}</style>
{/* Toast notifications */}
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
<div className="space-y-6 animate-fadeIn">
{/* Header */}
<div
className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4"
style={{ animation: 'fadeSlideIn 0.3s ease-out' }}
>
<div>
<h2 className="text-2xl font-bold text-white flex items-center gap-3">
<div className="p-2 bg-brand-500/20 rounded-lg">
<Plug className="w-6 h-6 text-brand-400" />
</div>
MCP Servers
</h2>
<p className="text-dark-400 mt-1 ml-14">Manage Model Context Protocol server connections and tools</p>
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={handleRefresh} disabled={refreshing}>
<RefreshCw
className="w-4 h-4 mr-2"
style={refreshing ? { animation: 'refreshSpin 0.8s linear infinite' } : undefined}
/>
Refresh
</Button>
<Button onClick={openAddModal}>
<Plus className="w-4 h-4 mr-2" />
Add Server
</Button>
</div>
</div>
{/* Summary Stats */}
{servers.length > 0 && (
<div
className="grid grid-cols-1 sm:grid-cols-3 gap-4"
style={{ animation: 'fadeSlideIn 0.35s ease-out' }}
>
<div className="bg-dark-800/50 border border-dark-700/50 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-500/15 rounded-lg">
<Server className="w-5 h-5 text-blue-400" />
</div>
<div>
<p className="text-dark-400 text-sm">Total Servers</p>
<p className="text-2xl font-bold text-white">{servers.length}</p>
</div>
</div>
</div>
<div className="bg-dark-800/50 border border-dark-700/50 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-500/15 rounded-lg">
<CheckCircle2 className="w-5 h-5 text-green-400" />
</div>
<div>
<p className="text-dark-400 text-sm">Enabled</p>
<p className="text-2xl font-bold text-green-400">{enabledCount}</p>
</div>
</div>
</div>
<div className="bg-dark-800/50 border border-dark-700/50 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-brand-500/15 rounded-lg">
<Wrench className="w-5 h-5 text-brand-400" />
</div>
<div>
<p className="text-dark-400 text-sm">Total Tools</p>
<p className="text-2xl font-bold text-brand-400">{totalTools}</p>
</div>
</div>
</div>
</div>
)}
{/* Server List */}
<div style={{ animation: 'fadeSlideIn 0.4s ease-out' }}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white">
Configured Servers
<span className="text-dark-500 text-sm font-normal ml-2">
{servers.length} server{servers.length !== 1 ? 's' : ''}
</span>
</h3>
</div>
{loading ? (
<div className="flex items-center justify-center py-16">
<RefreshCw className="w-6 h-6 text-dark-400 animate-spin" />
</div>
) : servers.length === 0 ? (
<Card>
<div className="text-center py-16" style={{ animation: 'fadeSlideIn 0.4s ease-out' }}>
<div className="w-20 h-20 bg-dark-700/30 rounded-full flex items-center justify-center mx-auto mb-5">
<Server className="w-10 h-10 text-dark-500" />
</div>
<p className="text-dark-300 font-semibold text-lg">No MCP servers configured</p>
<p className="text-dark-500 text-sm mt-2 max-w-md mx-auto">
Add a server to connect external tools via Model Context Protocol
</p>
<Button className="mt-6" onClick={openAddModal}>
<Plus className="w-4 h-4 mr-2" />
Add First Server
</Button>
</div>
</Card>
) : (
<div className="space-y-3">
{servers.map((server, idx) => (
<div
key={server.name}
className="bg-dark-800 border border-dark-700/50 rounded-xl overflow-hidden hover:border-dark-600 transition-colors"
style={{ animation: `fadeSlideIn ${0.2 + idx * 0.06}s ease-out` }}
>
{/* Server Row */}
<div className="p-4 sm:p-5">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3 sm:gap-4 flex-1 min-w-0">
{/* Icon */}
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${
server.enabled ? 'bg-green-500/15' : 'bg-dark-700/50'
}`}>
<Server className={`w-5 h-5 ${server.enabled ? 'text-green-400' : 'text-dark-500'}`} />
</div>
<div className="flex-1 min-w-0">
{/* Name + badges */}
<div className="flex items-center gap-3 flex-wrap">
<p className="font-semibold text-white text-base sm:text-lg truncate">{server.name}</p>
<TransportBadge transport={server.transport} />
{server.is_builtin && (
<span className="px-2 py-0.5 text-xs rounded-full bg-yellow-500/20 text-yellow-400 font-medium">
builtin
</span>
)}
<span className={`px-2 py-0.5 text-xs rounded-full font-medium ${
server.enabled
? 'bg-green-500/15 text-green-400 border border-green-500/30'
: 'bg-dark-700 text-dark-400 border border-dark-600'
}`}>
{server.enabled ? 'enabled' : 'disabled'}
</span>
</div>
{/* Description */}
{server.description && (
<p className="text-sm text-dark-400 mt-1">{server.description}</p>
)}
{/* Meta row */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2 text-sm text-dark-400">
{server.transport === 'stdio' && server.command && (
<span className="flex items-center gap-1.5">
<Terminal className="w-3.5 h-3.5" />
<code className="text-dark-300 text-xs bg-dark-900/50 px-1.5 py-0.5 rounded">
{server.command}{server.args?.length ? ` ${server.args.join(' ')}` : ''}
</code>
</span>
)}
{server.transport === 'sse' && server.url && (
<span className="flex items-center gap-1.5">
<Wifi className="w-3.5 h-3.5" />
<code className="text-dark-300 text-xs bg-dark-900/50 px-1.5 py-0.5 rounded">
{server.url}
</code>
</span>
)}
<span className="flex items-center gap-1.5">
<Wrench className="w-3.5 h-3.5" />
{server.tool_count} tool{server.tool_count !== 1 ? 's' : ''}
</span>
</div>
{/* Env vars indicator */}
{server.env && Object.keys(server.env).length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{Object.keys(server.env).map(key => (
<span key={key} className="px-1.5 py-0.5 text-[10px] bg-dark-700 text-dark-400 rounded font-mono">
{key}
</span>
))}
</div>
)}
{/* Test result badge */}
{testResults[server.name] && (
<div className={`inline-flex items-center gap-1.5 mt-2 px-3 py-1 rounded-lg text-xs font-medium ${
testResults[server.name].success
? 'bg-green-500/10 text-green-400 border border-green-500/30'
: 'bg-red-500/10 text-red-400 border border-red-500/30'
}`}
style={{ animation: 'fadeSlideIn 0.3s ease-out' }}
>
{testResults[server.name].success
? <CheckCircle2 className="w-3.5 h-3.5" />
: <AlertTriangle className="w-3.5 h-3.5" />
}
{testResults[server.name].message}
</div>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0 ml-4">
{/* Toggle */}
<ToggleSwitch enabled={server.enabled} onToggle={() => handleToggle(server.name)} />
{/* Tool browser toggle */}
<Button
variant="ghost"
size="sm"
onClick={() => toggleToolBrowser(server.name)}
title="Browse tools"
>
{expandedServer === server.name
? <ChevronDown className="w-4 h-4 text-brand-400" />
: <ChevronRight className="w-4 h-4 text-dark-400" />
}
</Button>
{/* Test */}
<Button
variant="ghost"
size="sm"
onClick={() => handleTest(server.name)}
disabled={testingServer === server.name}
title="Test connection"
>
{testingServer === server.name
? <Loader2 className="w-4 h-4 text-dark-400 animate-spin" />
: <Plug className="w-4 h-4 text-blue-400" />
}
</Button>
{/* Edit */}
<Button
variant="ghost"
size="sm"
onClick={() => openEditModal(server)}
title="Edit server"
>
<Pencil className="w-4 h-4 text-dark-300" />
</Button>
{/* Delete */}
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteTarget(server.name)}
disabled={server.is_builtin}
title={server.is_builtin ? 'Cannot delete builtin server' : 'Delete server'}
>
<Trash2 className={`w-4 h-4 ${server.is_builtin ? 'text-dark-600' : 'text-red-400'}`} />
</Button>
</div>
</div>
</div>
{/* Expandable Tool Browser */}
{expandedServer === server.name && (
<div
className="border-t border-dark-700/50 bg-dark-900/30 p-4"
style={{ animation: 'fadeSlideIn 0.3s ease-out' }}
>
<h4 className="text-sm font-medium text-dark-200 mb-3 flex items-center gap-2">
<Wrench className="w-4 h-4 text-brand-400" />
Available Tools
{serverTools[server.name] && (
<span className="text-dark-500 font-normal">({serverTools[server.name].length})</span>
)}
</h4>
{loadingTools === server.name ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 text-dark-400 animate-spin" />
<span className="text-dark-400 text-sm ml-3">Loading tools...</span>
</div>
) : serverTools[server.name]?.length ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{serverTools[server.name].map((tool, tIdx) => (
<div
key={tool.name}
className="p-3 bg-dark-800 border border-dark-700/50 rounded-lg hover:border-dark-600 transition-colors"
style={{ animation: `fadeSlideIn ${0.15 + tIdx * 0.04}s ease-out` }}
>
<div className="flex items-start gap-2">
<Wrench className="w-3.5 h-3.5 text-brand-400 flex-shrink-0 mt-0.5" />
<div className="min-w-0">
<p className="text-sm font-medium text-white truncate">{tool.name}</p>
{tool.description && (
<p className="text-xs text-dark-400 mt-0.5 line-clamp-2">{tool.description}</p>
)}
{tool.input_schema && Object.keys(tool.input_schema).length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{getToolParams(tool.input_schema).slice(0, 4).map(param => (
<span key={param} className="px-1.5 py-0.5 text-[10px] bg-dark-700 text-dark-300 rounded">
{param}
</span>
))}
{getToolParams(tool.input_schema).length > 4 && (
<span className="px-1.5 py-0.5 text-[10px] bg-dark-700 text-dark-400 rounded">
+{getToolParams(tool.input_schema).length - 4}
</span>
)}
</div>
)}
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8">
<div className="w-12 h-12 bg-dark-700/30 rounded-full flex items-center justify-center mx-auto mb-3">
<Wrench className="w-6 h-6 text-dark-500" />
</div>
<p className="text-sm text-dark-500">No tools available or server not connected</p>
</div>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
{/* Add / Edit Modal */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={closeModal}
/>
{/* Modal Panel */}
<div
className="relative w-full max-w-lg bg-dark-800 border border-dark-700 rounded-xl shadow-2xl mx-4 max-h-[90vh] overflow-y-auto"
style={{ animation: 'fadeSlideIn 0.2s ease-out' }}
>
{/* Modal Header */}
<div className="flex items-center justify-between p-5 border-b border-dark-700">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-brand-500/15">
{editingServer
? <Pencil className="w-5 h-5 text-brand-400" />
: <Plus className="w-5 h-5 text-brand-400" />
}
</div>
<div>
<h3 className="text-lg font-semibold text-white">
{editingServer ? 'Edit Server' : 'Add MCP Server'}
</h3>
<p className="text-dark-400 text-sm mt-0.5">
{editingServer
? `Editing "${editingServer.name}" configuration`
: 'Configure a new Model Context Protocol server connection'
}
</p>
</div>
</div>
<button
onClick={closeModal}
className="p-1.5 rounded-lg text-dark-400 hover:text-white hover:bg-dark-700 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Modal Body */}
<div className="p-5 space-y-5">
{/* Name */}
<Input
label="Server Name"
placeholder="my-mcp-server"
value={formName}
onChange={(e) => setFormName(e.target.value)}
disabled={!!editingServer}
helperText={editingServer ? 'Name cannot be changed after creation' : 'Unique identifier for this server'}
/>
{/* Transport */}
<div>
<label className="block text-sm font-medium text-dark-200 mb-2">Transport</label>
<div className="flex gap-3">
<button
onClick={() => setFormTransport('stdio')}
className={`flex-1 flex items-center gap-3 p-3 rounded-lg border-2 transition-all ${
formTransport === 'stdio'
? 'border-brand-500 bg-brand-500/10'
: 'border-dark-600 bg-dark-900/50 hover:border-dark-500'
}`}
>
<Terminal className={`w-5 h-5 ${formTransport === 'stdio' ? 'text-brand-400' : 'text-dark-400'}`} />
<div className="text-left">
<p className={`text-sm font-medium ${formTransport === 'stdio' ? 'text-white' : 'text-dark-300'}`}>stdio</p>
<p className="text-xs text-dark-500">Local process</p>
</div>
</button>
<button
onClick={() => setFormTransport('sse')}
className={`flex-1 flex items-center gap-3 p-3 rounded-lg border-2 transition-all ${
formTransport === 'sse'
? 'border-brand-500 bg-brand-500/10'
: 'border-dark-600 bg-dark-900/50 hover:border-dark-500'
}`}
>
<Wifi className={`w-5 h-5 ${formTransport === 'sse' ? 'text-brand-400' : 'text-dark-400'}`} />
<div className="text-left">
<p className={`text-sm font-medium ${formTransport === 'sse' ? 'text-white' : 'text-dark-300'}`}>sse</p>
<p className="text-xs text-dark-500">Remote HTTP</p>
</div>
</button>
</div>
</div>
{/* Transport-specific fields */}
{formTransport === 'stdio' ? (
<div className="space-y-4">
<Input
label="Command"
placeholder="npx"
value={formCommand}
onChange={(e) => setFormCommand(e.target.value)}
helperText="The executable to run (e.g. npx, python, node)"
/>
<Input
label="Arguments"
placeholder="-y @modelcontextprotocol/server-filesystem /tmp"
value={formArgs}
onChange={(e) => setFormArgs(e.target.value)}
helperText="Space-separated command arguments"
/>
</div>
) : (
<Input
label="Server URL"
placeholder="http://localhost:3001/sse"
value={formUrl}
onChange={(e) => setFormUrl(e.target.value)}
helperText="Full URL to the SSE endpoint"
/>
)}
{/* Environment Variables */}
<div>
<label className="block text-sm font-medium text-dark-200 mb-1.5">
Environment Variables
</label>
<textarea
className="w-full px-4 py-2.5 bg-dark-900 border border-dark-700 rounded-lg text-white placeholder-dark-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors font-mono text-sm resize-y min-h-[80px]"
placeholder={'API_KEY=your-key-here\nANOTHER_VAR=value'}
value={formEnv}
onChange={(e) => setFormEnv(e.target.value)}
rows={3}
/>
<p className="mt-1 text-sm text-dark-400">One KEY=VALUE per line. Passed to the server process.</p>
</div>
{/* Description */}
<Input
label="Description"
placeholder="What this server provides..."
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
helperText="Optional description for this server"
/>
</div>
{/* Modal Footer */}
<div className="flex items-center justify-end gap-3 p-5 border-t border-dark-700">
<Button variant="secondary" onClick={closeModal}>
Cancel
</Button>
<Button onClick={handleSave} isLoading={isSaving}>
{editingServer ? (
<>
<Pencil className="w-4 h-4 mr-2" />
Update Server
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
Add Server
</>
)}
</Button>
</div>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{deleteTarget && (
<DeleteModal
name={deleteTarget}
onConfirm={() => handleDelete(deleteTarget)}
onCancel={() => setDeleteTarget(null)}
/>
)}
</div>
</>
)
}
+716
View File
@@ -0,0 +1,716 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Upload, Link as LinkIcon, FileText, Play, AlertTriangle,
Bot, Search, Target, Brain, BookOpen, ChevronDown, Key, Settings, X
} from 'lucide-react'
import Card from '../components/common/Card'
import Button from '../components/common/Button'
import Input from '../components/common/Input'
import Textarea from '../components/common/Textarea'
import { agentApi, targetsApi } from '../services/api'
import type { AgentTask, AgentMode, AgentRequest } from '../types'
type TargetInputMode = 'single' | 'multiple' | 'file'
type AuthTypeOption = 'none' | 'cookie' | 'bearer' | 'basic' | 'header'
interface OperationModeInfo {
id: AgentMode
name: string
icon: React.ReactNode
description: string
warning?: string
color: string
}
interface Toast {
id: number
message: string
severity: 'info' | 'success' | 'warning' | 'error'
}
let _toastId = 0
function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: number) => void }) {
if (toasts.length === 0) return null
const border: Record<string, string> = {
info: 'border-blue-500',
success: 'border-green-500',
warning: 'border-yellow-500',
error: 'border-red-500'
}
return (
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
{toasts.map(t => (
<div
key={t.id}
className={`bg-dark-800 border-l-4 ${border[t.severity]} rounded-lg px-4 py-3 shadow-xl flex items-start gap-3`}
style={{ animation: 'fadeSlideIn 0.3s ease-out' }}
>
<span className="text-sm text-dark-200 flex-1">{t.message}</span>
<button onClick={() => onDismiss(t.id)} className="text-dark-500 hover:text-white">
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)
}
const OPERATION_MODES: OperationModeInfo[] = [
{
id: 'full_auto',
name: 'Full Auto',
icon: <Bot className="w-5 h-5" />,
description: 'Complete workflow: Recon -> Analyze -> Test -> Report',
color: 'primary'
},
{
id: 'recon_only',
name: 'Recon Only',
icon: <Search className="w-5 h-5" />,
description: 'Reconnaissance and enumeration only, no vulnerability testing',
color: 'blue'
},
{
id: 'prompt_only',
name: 'AI Prompt Mode',
icon: <Brain className="w-5 h-5" />,
description: 'AI decides everything based on your prompt - full autonomy',
warning: 'HIGH TOKEN USAGE - The AI will use more API calls to decide what to do',
color: 'purple'
},
{
id: 'analyze_only',
name: 'Analyze Only',
icon: <Target className="w-5 h-5" />,
description: 'Analyze provided data without active testing',
color: 'green'
}
]
const TASK_CATEGORIES = [
{ id: 'all', name: 'All Tasks' },
{ id: 'full_auto', name: 'Full Auto' },
{ id: 'recon', name: 'Reconnaissance' },
{ id: 'vulnerability', name: 'Vulnerability' },
{ id: 'custom', name: 'Custom' },
{ id: 'reporting', name: 'Reporting' }
]
const AUTH_TYPE_OPTIONS: { id: AuthTypeOption; label: string }[] = [
{ id: 'none', label: 'None' },
{ id: 'cookie', label: 'Cookie' },
{ id: 'bearer', label: 'Bearer Token' },
{ id: 'basic', label: 'Basic Auth' },
{ id: 'header', label: 'Custom Header' }
]
export default function NewScanPage() {
const navigate = useNavigate()
const fileInputRef = useRef<HTMLInputElement>(null)
// Target state
const [targetMode, setTargetMode] = useState<TargetInputMode>('single')
const [singleUrl, setSingleUrl] = useState('')
const [multipleUrls, setMultipleUrls] = useState('')
const [uploadedUrls, setUploadedUrls] = useState<string[]>([])
const [urlError, setUrlError] = useState('')
// Operation mode
const [operationMode, setOperationMode] = useState<AgentMode>('full_auto')
// Task library
const [tasks, setTasks] = useState<AgentTask[]>([])
const [selectedTask, setSelectedTask] = useState<AgentTask | null>(null)
const [taskCategory, setTaskCategory] = useState('all')
const [showTaskLibrary, setShowTaskLibrary] = useState(false)
const [loadingTasks, setLoadingTasks] = useState(false)
// Custom prompt
const [useCustomPrompt, setUseCustomPrompt] = useState(false)
const [customPrompt, setCustomPrompt] = useState('')
// Auth options
const [showAuthOptions, setShowAuthOptions] = useState(false)
const [authType, setAuthType] = useState<AuthTypeOption>('none')
const [authValue, setAuthValue] = useState('')
// Advanced options
const [maxDepth, setMaxDepth] = useState(5)
// UI state
const [isLoading, setIsLoading] = useState(false)
// Toast state
const [toasts, setToasts] = useState<Toast[]>([])
const addToast = useCallback((message: string, severity: Toast['severity'] = 'info') => {
const id = ++_toastId
setToasts(prev => [...prev, { id, message, severity }])
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id))
}, 4000)
}, [])
const dismissToast = useCallback((id: number) => {
setToasts(prev => prev.filter(t => t.id !== id))
}, [])
// Load tasks on mount
useEffect(() => {
loadTasks()
}, [])
const loadTasks = async (category?: string) => {
setLoadingTasks(true)
try {
const taskList = await agentApi.tasks.list(category === 'all' ? undefined : category)
setTasks(taskList)
} catch (error) {
console.error('Failed to load tasks:', error)
} finally {
setLoadingTasks(false)
}
}
const handleCategoryChange = useCallback((category: string) => {
setTaskCategory(category)
loadTasks(category)
}, [])
const handleFileUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
try {
const result = await targetsApi.upload(file)
const validUrls = result.filter((r: { valid: boolean; normalized_url: string }) => r.valid).map((r: { valid: boolean; normalized_url: string }) => r.normalized_url)
setUploadedUrls(validUrls)
setUrlError('')
} catch (error) {
setUrlError('Failed to parse file')
}
}, [])
const getTargetUrl = useCallback((): string => {
switch (targetMode) {
case 'single':
return singleUrl.trim()
case 'multiple':
return multipleUrls.split(/[,\n]/)[0]?.trim() || ''
case 'file':
return uploadedUrls[0] || ''
default:
return ''
}
}, [targetMode, singleUrl, multipleUrls, uploadedUrls])
const handleStartAgent = useCallback(async () => {
const target = getTargetUrl()
if (!target) {
setUrlError('Please enter a target URL')
addToast('Please enter a target URL before deploying', 'warning')
return
}
setIsLoading(true)
try {
// Validate URL
const validation = await targetsApi.validateBulk([target])
if (!validation[0]?.valid) {
setUrlError('Invalid URL format')
addToast('Invalid URL format - please check and try again', 'error')
setIsLoading(false)
return
}
// Build request
const request: AgentRequest = {
target: validation[0].normalized_url,
mode: operationMode,
max_depth: maxDepth
}
// Add task or custom prompt
if (selectedTask && !useCustomPrompt) {
request.task_id = selectedTask.id
} else if (useCustomPrompt && customPrompt.trim()) {
request.prompt = customPrompt
}
// Add auth if specified
if (authType !== 'none' && authValue.trim()) {
request.auth_type = authType as AgentRequest['auth_type']
request.auth_value = authValue
}
// Start agent
const response = await agentApi.run(request)
addToast('Agent deployed successfully - redirecting...', 'success')
// Navigate to agent status page
setTimeout(() => {
navigate(`/agent/${response.agent_id}`)
}, 300)
} catch (error) {
console.error('Failed to start agent:', error)
setUrlError('Failed to start agent. Please try again.')
addToast('Failed to start agent - please try again', 'error')
} finally {
setIsLoading(false)
}
}, [getTargetUrl, operationMode, maxDepth, selectedTask, useCustomPrompt, customPrompt, authType, authValue, addToast, navigate])
const handleSelectTask = useCallback((task: AgentTask) => {
setSelectedTask(task)
addToast(`Task selected: ${task.name}`, 'info')
}, [addToast])
const handleClearTask = useCallback(() => {
setSelectedTask(null)
}, [])
const handleSetAuthType = useCallback((id: AuthTypeOption) => {
setAuthType(id)
}, [])
const handleToggleTaskLibrary = useCallback(() => {
setShowTaskLibrary(prev => !prev)
}, [])
const handleToggleAuthOptions = useCallback(() => {
setShowAuthOptions(prev => !prev)
}, [])
const handleToggleCustomPrompt = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setUseCustomPrompt(e.target.checked)
}, [])
const handleSingleUrlChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setSingleUrl(e.target.value)
setUrlError('')
}, [])
const handleMultipleUrlsChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setMultipleUrls(e.target.value)
setUrlError('')
}, [])
const handleCustomPromptChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setCustomPrompt(e.target.value)
}, [])
const handleMaxDepthChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setMaxDepth(parseInt(e.target.value))
}, [])
const handleAuthValueChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setAuthValue(e.target.value)
}, [])
const handleNavigateHome = useCallback(() => {
navigate('/')
}, [navigate])
// Memoized filtered task list
const filteredTasks = useMemo(() => {
if (showTaskLibrary) return tasks
return tasks.slice(0, 4)
}, [tasks, showTaskLibrary])
const currentModeInfo = OPERATION_MODES.find(m => m.id === operationMode)!
return (
<div
className="max-w-5xl mx-auto space-y-6"
style={{ animation: 'fadeSlideIn 0.4s ease-out' }}
>
<style>{`
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
`}</style>
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
{/* Header */}
<div
className="flex items-center justify-between"
style={{ animation: 'fadeSlideIn 0.3s ease-out 0s both' }}
>
<div>
<h1 className="text-3xl font-bold text-white flex items-center gap-3">
<Bot className="w-8 h-8 text-primary-500" />
AI Security Agent
</h1>
<p className="text-dark-400 mt-1">Autonomous penetration testing powered by AI</p>
</div>
</div>
{/* Operation Mode Selector */}
<div style={{ animation: 'fadeSlideIn 0.3s ease-out 0.05s both' }}>
<Card title="Operation Mode" subtitle="Select how the AI agent should operate">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{OPERATION_MODES.map((mode, idx) => (
<div
key={mode.id}
onClick={() => setOperationMode(mode.id)}
className={`p-4 rounded-xl border-2 cursor-pointer transition-all ${
operationMode === mode.id
? `border-${mode.color}-500 bg-${mode.color}-500/10`
: 'border-dark-700 hover:border-dark-500 bg-dark-900/50'
}`}
style={{ animation: `fadeSlideIn 0.3s ease-out ${idx * 0.05}s both` }}
>
<div className={`flex items-center gap-2 mb-2 ${
operationMode === mode.id ? `text-${mode.color}-400` : 'text-dark-300'
}`}>
{mode.icon}
<span className="font-semibold">{mode.name}</span>
</div>
<p className="text-sm text-dark-400">{mode.description}</p>
{mode.warning && operationMode === mode.id && (
<div className="mt-2 flex items-start gap-2 text-yellow-400 text-xs">
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
<span>{mode.warning}</span>
</div>
)}
</div>
))}
</div>
</Card>
</div>
{/* Target Input */}
<div style={{ animation: 'fadeSlideIn 0.3s ease-out 0.1s both' }}>
<Card title="Target" subtitle="Enter the URL to test">
<div className="space-y-4">
{/* Mode Selector */}
<div className="flex gap-2 flex-wrap">
<Button
variant={targetMode === 'single' ? 'primary' : 'secondary'}
onClick={() => setTargetMode('single')}
>
<LinkIcon className="w-4 h-4 mr-2" />
Single URL
</Button>
<Button
variant={targetMode === 'multiple' ? 'primary' : 'secondary'}
onClick={() => setTargetMode('multiple')}
>
<FileText className="w-4 h-4 mr-2" />
Multiple URLs
</Button>
<Button
variant={targetMode === 'file' ? 'primary' : 'secondary'}
onClick={() => setTargetMode('file')}
>
<Upload className="w-4 h-4 mr-2" />
Upload File
</Button>
</div>
{/* Input Fields */}
{targetMode === 'single' && (
<Input
placeholder="https://example.com"
value={singleUrl}
onChange={handleSingleUrlChange}
error={urlError}
/>
)}
{targetMode === 'multiple' && (
<div>
<Textarea
placeholder="Enter URLs separated by commas or new lines:&#10;https://example1.com&#10;https://example2.com"
rows={5}
value={multipleUrls}
onChange={handleMultipleUrlsChange}
/>
<p className="text-xs text-dark-500 mt-1">Note: Agent will test the first URL. Multiple URL support coming soon.</p>
{urlError && <p className="mt-1 text-sm text-red-400">{urlError}</p>}
</div>
)}
{targetMode === 'file' && (
<div>
<input
type="file"
ref={fileInputRef}
onChange={handleFileUpload}
accept=".txt,.csv,.lst"
className="hidden"
/>
<div
onClick={() => fileInputRef.current?.click()}
className="border-2 border-dashed border-dark-700 rounded-lg p-8 text-center cursor-pointer hover:border-primary-500 transition-colors"
>
<Upload className="w-10 h-10 mx-auto text-dark-400 mb-3" />
<p className="text-dark-300">Click to upload a file with URLs</p>
<p className="text-sm text-dark-500 mt-1">Supports .txt, .csv, .lst files</p>
</div>
{uploadedUrls.length > 0 && (
<p className="mt-2 text-sm text-green-400">
{uploadedUrls.length} valid URLs loaded - using first URL
</p>
)}
{urlError && <p className="mt-2 text-sm text-red-400">{urlError}</p>}
</div>
)}
</div>
</Card>
</div>
{/* Task Library */}
<div style={{ animation: 'fadeSlideIn 0.3s ease-out 0.15s both' }}>
<Card
title={
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<BookOpen className="w-5 h-5 text-primary-500" />
<span>Task Library</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleToggleTaskLibrary}
>
<ChevronDown className={`w-4 h-4 transition-transform ${showTaskLibrary ? 'rotate-180' : ''}`} />
</Button>
</div>
}
subtitle="Select a preset task or create a custom prompt"
>
{/* Custom Prompt Toggle */}
<div className="flex items-center justify-between mb-4 pb-4 border-b border-dark-700">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="customPrompt"
checked={useCustomPrompt}
onChange={handleToggleCustomPrompt}
className="w-4 h-4 rounded border-dark-600 bg-dark-800 text-primary-500 focus:ring-primary-500"
/>
<label htmlFor="customPrompt" className="text-white">Use custom prompt instead of task</label>
</div>
</div>
{useCustomPrompt ? (
<Textarea
placeholder="Enter your custom prompt for the AI agent...&#10;&#10;Example: Test for SQL injection on all form inputs, check for authentication bypass on the login endpoint, and look for IDOR vulnerabilities in user profile APIs."
rows={6}
value={customPrompt}
onChange={handleCustomPromptChange}
/>
) : (
<>
{showTaskLibrary && (
<>
{/* Category Filter */}
<div className="flex gap-2 mb-4 flex-wrap">
{TASK_CATEGORIES.map((cat) => (
<Button
key={cat.id}
variant={taskCategory === cat.id ? 'primary' : 'secondary'}
size="sm"
onClick={() => handleCategoryChange(cat.id)}
>
{cat.name}
</Button>
))}
</div>
</>
)}
{/* Tasks Grid */}
<div className={`grid grid-cols-1 sm:grid-cols-2 gap-3 ${showTaskLibrary ? 'max-h-80 overflow-auto' : ''}`}>
{loadingTasks ? (
<p className="text-dark-400 col-span-2 text-center py-4">Loading tasks...</p>
) : (
filteredTasks.map((task, idx) => (
<div
key={task.id}
onClick={() => handleSelectTask(task)}
className={`p-4 rounded-lg border cursor-pointer transition-all ${
selectedTask?.id === task.id
? 'border-primary-500 bg-primary-500/10'
: 'border-dark-700 hover:border-dark-500 bg-dark-900/50'
}`}
style={{ animation: `fadeSlideIn 0.3s ease-out ${idx * 0.05}s both` }}
>
<div className="flex items-center justify-between mb-1">
<span className="font-medium text-white">{task.name}</span>
{task.is_preset && (
<span className="text-xs bg-primary-500/20 text-primary-400 px-2 py-0.5 rounded">Preset</span>
)}
</div>
<p className="text-sm text-dark-400 line-clamp-2">{task.description}</p>
<div className="flex items-center gap-2 mt-2">
<span className="text-xs text-dark-500">{task.category}</span>
{task.estimated_tokens > 0 && (
<span className="text-xs text-dark-500">~{task.estimated_tokens} tokens</span>
)}
</div>
{task.tags?.length > 0 && (
<div className="flex gap-1 mt-2 flex-wrap">
{task.tags.slice(0, 3).map((tag) => (
<span key={tag} className="text-xs bg-dark-700 text-dark-300 px-2 py-0.5 rounded">
{tag}
</span>
))}
</div>
)}
</div>
))
)}
</div>
{!showTaskLibrary && tasks.length > 4 && (
<Button
variant="ghost"
className="w-full mt-3"
onClick={() => setShowTaskLibrary(true)}
>
Show all {tasks.length} tasks
</Button>
)}
</>
)}
{/* Selected Task Preview */}
{selectedTask && !useCustomPrompt && (
<div className="mt-4 p-4 bg-dark-800 rounded-lg border border-dark-700">
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-white">Selected: {selectedTask.name}</span>
<Button variant="ghost" size="sm" onClick={handleClearTask}>
Clear
</Button>
</div>
<p className="text-sm text-dark-400 whitespace-pre-wrap line-clamp-4">
{selectedTask.prompt}
</p>
</div>
)}
</Card>
</div>
{/* Authentication Options */}
<div style={{ animation: 'fadeSlideIn 0.3s ease-out 0.2s both' }}>
<Card
title={
<div className="flex items-center gap-2">
<Key className="w-5 h-5 text-primary-500" />
<span>Authentication</span>
<span className="text-xs text-dark-500">(Optional)</span>
</div>
}
>
<div className="space-y-4">
<div className="flex gap-2 flex-wrap">
{AUTH_TYPE_OPTIONS.map((type) => (
<Button
key={type.id}
variant={authType === type.id ? 'primary' : 'secondary'}
size="sm"
onClick={() => handleSetAuthType(type.id)}
>
{type.label}
</Button>
))}
</div>
{authType !== 'none' && (
<Input
placeholder={
authType === 'cookie' ? 'session=abc123; token=xyz789' :
authType === 'bearer' ? 'eyJhbGciOiJIUzI1NiIs...' :
authType === 'basic' ? 'username:password' :
'X-API-Key: your-api-key'
}
value={authValue}
onChange={handleAuthValueChange}
label={
authType === 'cookie' ? 'Cookie String' :
authType === 'bearer' ? 'Bearer Token' :
authType === 'basic' ? 'Username:Password' :
'Header:Value'
}
/>
)}
</div>
</Card>
</div>
{/* Advanced Options */}
<div style={{ animation: 'fadeSlideIn 0.3s ease-out 0.25s both' }}>
<Card
title={
<div className="flex items-center gap-2 cursor-pointer" onClick={handleToggleAuthOptions}>
<Settings className="w-5 h-5 text-primary-500" />
<span>Advanced Options</span>
<ChevronDown className={`w-4 h-4 transition-transform ${showAuthOptions ? 'rotate-180' : ''}`} />
</div>
}
>
{showAuthOptions && (
<div className="space-y-4">
<div>
<label className="text-sm text-dark-300 mb-1 block">Max Crawl Depth</label>
<div className="flex items-center gap-4">
<input
type="range"
min="1"
max="10"
value={maxDepth}
onChange={handleMaxDepthChange}
className="flex-1"
/>
<span className="text-white font-medium w-8">{maxDepth}</span>
</div>
</div>
</div>
)}
{!showAuthOptions && (
<p className="text-dark-500 text-sm">Click to expand advanced options</p>
)}
</Card>
</div>
{/* Warning for Prompt Only Mode */}
{operationMode === 'prompt_only' && (
<div
className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-4 flex items-start gap-3"
style={{ animation: 'fadeSlideIn 0.3s ease-out' }}
>
<AlertTriangle className="w-6 h-6 text-yellow-500 flex-shrink-0" />
<div>
<p className="font-medium text-yellow-400">High Token Usage Warning</p>
<p className="text-sm text-yellow-300/80 mt-1">
In AI Prompt Mode, the agent has full autonomy to decide what tools to use and what tests to run.
This results in significantly higher API token consumption. Consider using Full Auto mode for most use cases.
</p>
</div>
</div>
)}
{/* Start Button */}
<div
className="flex justify-end gap-3 sticky bottom-4 bg-dark-950/90 backdrop-blur p-4 -mx-4 rounded-lg"
style={{ animation: 'fadeSlideIn 0.3s ease-out 0.3s both' }}
>
<Button variant="secondary" onClick={handleNavigateHome}>
Cancel
</Button>
<Button onClick={handleStartAgent} isLoading={isLoading} size="lg">
<Play className="w-5 h-5 mr-2" />
Deploy Agent ({currentModeInfo.name})
</Button>
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+110
View File
@@ -0,0 +1,110 @@
import { useEffect, useState, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft, Download, ExternalLink, FileText, RefreshCw, Maximize2 } from 'lucide-react'
import Button from '../components/common/Button'
import { reportsApi } from '../services/api'
export default function ReportViewPage() {
const { reportId } = useParams<{ reportId: string }>()
const navigate = useNavigate()
const [isLoading, setIsLoading] = useState(true)
const [isFullscreen, setIsFullscreen] = useState(false)
const [iframeKey, setIframeKey] = useState(0)
useEffect(() => {
if (!reportId) {
navigate('/reports')
return
}
setIsLoading(false)
}, [reportId, navigate])
const handleRefresh = useCallback(() => {
setIframeKey(k => k + 1)
}, [])
const toggleFullscreen = useCallback(() => {
setIsFullscreen(f => !f)
}, [])
if (isLoading || !reportId) {
return (
<div className="flex flex-col items-center justify-center h-64 gap-3">
<div className="animate-spin w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full" />
<p className="text-dark-400 text-sm">Loading report...</p>
</div>
)
}
return (
<>
<style>{`
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
`}</style>
<div
className={`space-y-4 ${isFullscreen ? 'fixed inset-0 z-50 bg-dark-950 p-4' : ''}`}
style={{ animation: 'fadeSlideIn 0.3s ease-out' }}
>
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
<Button variant="ghost" onClick={() => navigate('/reports')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Reports
</Button>
<div className="hidden sm:flex items-center gap-2 text-dark-400">
<FileText className="w-4 h-4" />
<span className="text-sm font-mono truncate max-w-[200px]">{reportId}</span>
</div>
</div>
<div className="flex gap-2 flex-wrap">
<Button variant="ghost" size="sm" onClick={handleRefresh} title="Refresh report">
<RefreshCw className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={toggleFullscreen} title="Toggle fullscreen">
<Maximize2 className="w-4 h-4" />
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => window.open(reportsApi.getDownloadUrl(reportId, 'html'), '_blank')}
>
<Download className="w-4 h-4 mr-1.5" />
<span className="hidden sm:inline">HTML</span>
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => window.open(reportsApi.getDownloadUrl(reportId, 'json'), '_blank')}
>
<Download className="w-4 h-4 mr-1.5" />
<span className="hidden sm:inline">JSON</span>
</Button>
<Button
size="sm"
onClick={() => window.open(reportsApi.getViewUrl(reportId), '_blank')}
>
<ExternalLink className="w-4 h-4 mr-1.5" />
<span className="hidden sm:inline">New Tab</span>
</Button>
</div>
</div>
{/* Report iframe */}
<div className="bg-dark-800 rounded-xl overflow-hidden border border-dark-900/50">
<iframe
key={iframeKey}
src={reportsApi.getViewUrl(reportId)}
className={`w-full ${isFullscreen ? 'h-[calc(100vh-80px)]' : 'h-[calc(100vh-200px)]'}`}
title="Report"
/>
</div>
</div>
</>
)
}
+694
View File
@@ -0,0 +1,694 @@
import { useEffect, useMemo, useState, useCallback, useRef } from 'react'
import { Link } from 'react-router-dom'
import {
FileText, Download, Eye, Trash2, Calendar, Sparkles, Search,
RefreshCw, X, WifiOff, Filter, ArrowUpDown, Plus, AlertTriangle,
ExternalLink, Archive, Package
} from 'lucide-react'
import { PieChart, Pie, Cell, Tooltip as RechartsTooltip, ResponsiveContainer } from 'recharts'
import Card from '../components/common/Card'
import Button from '../components/common/Button'
import { reportsApi, scansApi } from '../services/api'
import type { Report, Scan } from '../types'
/* ─── Constants ──────────────────────────────────────────────── */
const FORMAT_STYLE: Record<string, { bg: string; text: string; chart: string }> = {
html: { bg: 'bg-blue-500/10', text: 'text-blue-400', chart: '#3b82f6' },
json: { bg: 'bg-green-500/10', text: 'text-green-400', chart: '#22c55e' },
pdf: { bg: 'bg-red-500/10', text: 'text-red-400', chart: '#ef4444' },
}
/* ─── Helpers ────────────────────────────────────────────────── */
function relativeTime(ts: string): string {
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000)
if (diff < 60) return `${diff}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return `${Math.floor(diff / 86400)}d ago`
}
/* ─── Toast System ───────────────────────────────────────────── */
interface Toast { id: number; message: string; severity: 'info' | 'success' | 'warning' | 'error' }
let _toastId = 0
function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: number) => void }) {
if (toasts.length === 0) return null
const border: Record<string, string> = {
info: 'border-blue-500', success: 'border-green-500',
warning: 'border-yellow-500', error: 'border-red-500',
}
return (
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
{toasts.map(t => (
<div
key={t.id}
className={`bg-dark-800 border-l-4 ${border[t.severity]} rounded-lg px-4 py-3 shadow-xl flex items-start gap-3`}
style={{ animation: 'fadeSlideIn 0.3s ease-out' }}
>
<span className="text-sm text-dark-200 flex-1">{t.message}</span>
<button onClick={() => onDismiss(t.id)} className="text-dark-500 hover:text-white">
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)
}
/* ─── Format Mini Chart ──────────────────────────────────────── */
function FormatChart({ reports }: { reports: Report[] }) {
const data = useMemo(() => {
const counts: Record<string, number> = {}
reports.forEach(r => { counts[r.format] = (counts[r.format] || 0) + 1 })
return Object.entries(counts).map(([name, value]) => ({
name: name.toUpperCase(),
value,
color: FORMAT_STYLE[name]?.chart || '#6b7280',
}))
}, [reports])
if (data.length === 0) return null
return (
<ResponsiveContainer width={80} height={80}>
<PieChart>
<Pie data={data} dataKey="value" cx="50%" cy="50%" innerRadius={20} outerRadius={35} paddingAngle={2} strokeWidth={0}>
{data.map((d, i) => <Cell key={i} fill={d.color} />)}
</Pie>
<RechartsTooltip
contentStyle={{ background: '#1a1a2e', border: '1px solid #2a2a3e', borderRadius: 8, fontSize: 11 }}
itemStyle={{ color: '#e2e8f0' }}
/>
</PieChart>
</ResponsiveContainer>
)
}
/* ─── Delete Confirmation Modal ──────────────────────────────── */
function DeleteModal({ title, onConfirm, onCancel }: {
title: string
onConfirm: () => void
onCancel: () => void
}) {
return (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" onClick={onCancel}>
<div
className="bg-dark-800 rounded-xl border border-dark-700 p-6 max-w-sm w-full"
onClick={e => e.stopPropagation()}
style={{ animation: 'fadeSlideIn 0.2s ease-out' }}
>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-red-500/10">
<Trash2 className="w-5 h-5 text-red-400" />
</div>
<h3 className="text-lg font-semibold text-white">Delete Report</h3>
</div>
<p className="text-sm text-dark-300 mb-6">
Are you sure you want to delete <span className="text-white font-medium">&quot;{title}&quot;</span>?
This cannot be undone.
</p>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onCancel}>Cancel</Button>
<Button variant="danger" onClick={onConfirm}>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</Button>
</div>
</div>
</div>
)
}
/*
Main Component
*/
export default function ReportsPage() {
const [reports, setReports] = useState<Report[]>([])
const [scans, setScans] = useState<Map<string, Scan>>(new Map())
const [isLoading, setIsLoading] = useState(true)
const [toasts, setToasts] = useState<Toast[]>([])
const [connectionLost, setConnectionLost] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [regeneratingId, setRegeneratingId] = useState<string | null>(null)
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [formatFilter, setFormatFilter] = useState<string>('all')
const [sortBy, setSortBy] = useState<'date' | 'name' | 'vulns'>('date')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
const consecutiveErrorsRef = useRef(0)
/* ── Toast helpers ──────────────────────────────────────────── */
const addToast = useCallback((message: string, severity: Toast['severity'] = 'info') => {
const id = ++_toastId
setToasts(prev => [...prev.slice(-4), { id, message, severity }])
setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), 5000)
}, [])
const dismissToast = useCallback((id: number) => {
setToasts(prev => prev.filter(t => t.id !== id))
}, [])
/* ── Data fetch ─────────────────────────────────────────────── */
const fetchData = useCallback(async () => {
try {
const [reportsData, scansData] = await Promise.all([
reportsApi.list(),
scansApi.list(1, 100),
])
setReports(reportsData.reports)
const scansMap = new Map<string, Scan>()
scansData.scans.forEach((scan: Scan) => scansMap.set(scan.id, scan))
setScans(scansMap)
if (consecutiveErrorsRef.current >= 3) {
setConnectionLost(false)
addToast('Connection restored', 'success')
}
consecutiveErrorsRef.current = 0
} catch (error) {
console.error('Failed to fetch reports:', error)
consecutiveErrorsRef.current++
if (consecutiveErrorsRef.current >= 3) setConnectionLost(true)
}
}, [addToast])
useEffect(() => {
setIsLoading(true)
fetchData().finally(() => setIsLoading(false))
const interval = setInterval(fetchData, 30000)
return () => clearInterval(interval)
}, [fetchData])
const handleRefresh = useCallback(async () => {
setRefreshing(true)
await fetchData()
setRefreshing(false)
addToast('Reports refreshed', 'info')
}, [fetchData, addToast])
/* ── Actions ────────────────────────────────────────────────── */
const handleDelete = useCallback(async () => {
if (!deleteTarget) return
try {
await reportsApi.delete(deleteTarget.id)
setReports(prev => prev.filter(r => r.id !== deleteTarget.id))
addToast(`Report deleted`, 'success')
} catch (error) {
console.error('Failed to delete report:', error)
addToast('Failed to delete report', 'error')
} finally {
setDeleteTarget(null)
}
}, [deleteTarget, addToast])
const handleDownload = useCallback((reportId: string, format: string) => {
window.open(reportsApi.getDownloadUrl(reportId, format), '_blank')
addToast(`Downloading ${format.toUpperCase()} report`, 'info')
}, [addToast])
const handleDownloadZip = useCallback((reportId: string) => {
window.open(reportsApi.getDownloadZipUrl(reportId), '_blank')
addToast('Downloading ZIP package', 'info')
}, [addToast])
const handleAiRegenerate = useCallback(async (scanId: string, reportTitle: string) => {
setRegeneratingId(scanId)
try {
const report = await reportsApi.generateAiReport({
scan_id: scanId,
title: `AI Report - ${reportTitle}`,
})
window.open(reportsApi.getViewUrl(report.id), '_blank')
const reportsData = await reportsApi.list()
setReports(reportsData.reports)
addToast('AI report generated successfully', 'success')
} catch (error) {
console.error('Failed to generate AI report:', error)
addToast('Failed to generate AI report', 'error')
} finally {
setRegeneratingId(null)
}
}, [addToast])
/* ── Derived data ───────────────────────────────────────────── */
const filteredReports = useMemo(() => {
let result = [...reports]
if (formatFilter !== 'all') {
result = result.filter(r => r.format === formatFilter)
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase()
result = result.filter(r => {
const scan = scans.get(r.scan_id)
const title = (r.title || scan?.name || '').toLowerCase()
return title.includes(q) || r.format.includes(q)
})
}
result.sort((a, b) => {
let cmp = 0
if (sortBy === 'date') {
cmp = new Date(a.generated_at).getTime() - new Date(b.generated_at).getTime()
} else if (sortBy === 'name') {
const na = (a.title || scans.get(a.scan_id)?.name || '').toLowerCase()
const nb = (b.title || scans.get(b.scan_id)?.name || '').toLowerCase()
cmp = na.localeCompare(nb)
} else if (sortBy === 'vulns') {
cmp = (scans.get(a.scan_id)?.total_vulnerabilities || 0) - (scans.get(b.scan_id)?.total_vulnerabilities || 0)
}
return sortDir === 'desc' ? -cmp : cmp
})
return result
}, [reports, formatFilter, searchQuery, sortBy, sortDir, scans])
const statsData = useMemo(() => {
const totalVulns = reports.reduce((sum, r) => sum + (scans.get(r.scan_id)?.total_vulnerabilities || 0), 0)
const formats: Record<string, number> = {}
reports.forEach(r => { formats[r.format] = (formats[r.format] || 0) + 1 })
const autoCount = reports.filter(r => r.auto_generated).length
return { totalVulns, formats, autoCount }
}, [reports, scans])
/* ── Render ─────────────────────────────────────────────────── */
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full" />
</div>
)
}
return (
<div className="space-y-6">
<style>{`
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
`}</style>
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
{deleteTarget && (
<DeleteModal
title={deleteTarget.title}
onConfirm={handleDelete}
onCancel={() => setDeleteTarget(null)}
/>
)}
{/* Connection Lost Banner */}
{connectionLost && (
<div
className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg px-4 py-2.5 flex items-center gap-3"
style={{ animation: 'fadeSlideIn 0.3s ease-out' }}
>
<WifiOff className="w-4 h-4 text-yellow-400 flex-shrink-0" />
<span className="text-sm text-yellow-300">Connection issues detected. Retrying...</span>
</div>
)}
{/* ── Header ────────────────────────────────────────────── */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
<FileText className="w-6 h-6 text-primary-500" />
Reports
</h2>
<p className="text-dark-400 mt-1">View and download security assessment reports</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleRefresh}
className="p-2 rounded-lg bg-dark-800 border border-dark-700 hover:border-dark-600 text-dark-400 hover:text-white transition-all"
title="Refresh"
>
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
</button>
<Link to="/scan/new">
<Button>
<Plus className="w-4 h-4 mr-2" />
New Scan
</Button>
</Link>
</div>
</div>
{/* ── Stats Row ─────────────────────────────────────────── */}
{reports.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{/* Total Reports */}
<div
className="bg-dark-800 rounded-xl border border-primary-500/20 p-4"
style={{ animation: 'fadeSlideIn 0.3s ease-out' }}
>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary-500/10">
<FileText className="w-5 h-5 text-primary-500" />
</div>
<div>
<p className="text-xl font-bold text-white tabular-nums">{reports.length}</p>
<p className="text-[11px] text-dark-400">Total Reports</p>
</div>
</div>
</div>
{/* Total Vulns */}
<div
className="bg-dark-800 rounded-xl border border-red-500/20 p-4"
style={{ animation: 'fadeSlideIn 0.3s ease-out 0.05s both' }}
>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-red-500/10">
<AlertTriangle className="w-5 h-5 text-red-400" />
</div>
<div>
<p className="text-xl font-bold text-white tabular-nums">{statsData.totalVulns}</p>
<p className="text-[11px] text-dark-400">Total Vulns</p>
</div>
</div>
</div>
{/* AI Generated */}
<div
className="bg-dark-800 rounded-xl border border-yellow-500/20 p-4"
style={{ animation: 'fadeSlideIn 0.3s ease-out 0.1s both' }}
>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-yellow-500/10">
<Sparkles className="w-5 h-5 text-yellow-400" />
</div>
<div>
<p className="text-xl font-bold text-white tabular-nums">{statsData.autoCount}</p>
<p className="text-[11px] text-dark-400">AI Generated</p>
</div>
</div>
</div>
{/* Format Distribution */}
<div
className="bg-dark-800 rounded-xl border border-dark-700 p-4 flex items-center justify-between"
style={{ animation: 'fadeSlideIn 0.3s ease-out 0.15s both' }}
>
<div className="flex flex-col gap-1">
{Object.entries(statsData.formats).map(([fmt, count]) => {
const fs = FORMAT_STYLE[fmt] || { bg: 'bg-dark-700', text: 'text-dark-300' }
return (
<div key={fmt} className="flex items-center gap-2">
<span className={`text-[10px] uppercase font-bold px-1.5 py-0.5 rounded ${fs.bg} ${fs.text}`}>
{fmt}
</span>
<span className="text-sm text-white font-semibold tabular-nums">{count}</span>
</div>
)
})}
</div>
<FormatChart reports={reports} />
</div>
</div>
)}
{/* ── Search + Filters ──────────────────────────────────── */}
{reports.length > 0 && (
<div className="flex flex-wrap items-center gap-3">
{/* Search */}
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-dark-500" />
<input
type="text"
placeholder="Search reports..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-dark-800 border border-dark-700 rounded-lg text-sm text-white placeholder-dark-500 focus:outline-none focus:border-primary-500/50 transition-colors"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 hover:text-white"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Format Filter */}
<div className="flex items-center gap-1">
<Filter className="w-3.5 h-3.5 text-dark-500 mr-1" />
{(['all', 'html', 'json', 'pdf'] as const).map(f => (
<button
key={f}
onClick={() => setFormatFilter(f)}
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium transition-colors ${
formatFilter === f
? 'bg-primary-500/20 text-primary-400'
: 'text-dark-400 hover:text-dark-200 hover:bg-dark-700'
}`}
>
{f === 'all' ? 'All' : f.toUpperCase()}
</button>
))}
</div>
{/* Sort */}
<div className="flex items-center gap-1 ml-auto">
<ArrowUpDown className="w-3.5 h-3.5 text-dark-500 mr-1" />
{(['date', 'name', 'vulns'] as const).map(s => (
<button
key={s}
onClick={() => {
if (sortBy === s) setSortDir(d => d === 'asc' ? 'desc' : 'asc')
else { setSortBy(s); setSortDir('desc') }
}}
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium transition-colors ${
sortBy === s
? 'bg-primary-500/20 text-primary-400'
: 'text-dark-400 hover:text-dark-200 hover:bg-dark-700'
}`}
>
{s === 'date' ? 'Date' : s === 'name' ? 'Name' : 'Vulns'}
{sortBy === s && <span className="ml-1">{sortDir === 'desc' ? '\u2193' : '\u2191'}</span>}
</button>
))}
</div>
</div>
)}
{/* ── Report List ───────────────────────────────────────── */}
{reports.length === 0 ? (
<Card>
<div className="text-center py-12">
<FileText className="w-16 h-16 mx-auto text-dark-500 mb-4" />
<h3 className="text-lg font-medium text-white mb-2">No Reports Yet</h3>
<p className="text-dark-400 mb-4">Reports are generated after completing a security scan.</p>
<Link to="/scan/new">
<Button>Start a New Scan</Button>
</Link>
</div>
</Card>
) : filteredReports.length === 0 ? (
<Card>
<div className="text-center py-8">
<Search className="w-10 h-10 mx-auto text-dark-500 mb-3" />
<p className="text-dark-400 text-sm">No reports match your filters</p>
<button
onClick={() => { setSearchQuery(''); setFormatFilter('all') }}
className="text-primary-500 text-sm hover:underline mt-1"
>
Clear filters
</button>
</div>
</Card>
) : (
<div className="grid gap-3">
<p className="text-xs text-dark-500">
{filteredReports.length} report{filteredReports.length !== 1 ? 's' : ''}
{filteredReports.length !== reports.length && ` of ${reports.length}`}
</p>
{filteredReports.map((report, idx) => {
const scan = scans.get(report.scan_id)
const title = report.title || scan?.name || 'Security Report'
const fs = FORMAT_STYLE[report.format] || { bg: 'bg-dark-700', text: 'text-dark-300' }
return (
<div
key={report.id}
className="bg-dark-800 rounded-xl border border-dark-900/50 hover:border-dark-700 transition-all group"
style={{ animation: `fadeSlideIn 0.3s ease-out ${Math.min(idx * 0.04, 0.4)}s both` }}
>
<div className="p-4">
{/* Top row */}
<div className="flex items-start gap-4">
<div className={`p-2.5 rounded-lg ${fs.bg} flex-shrink-0`}>
<FileText className={`w-5 h-5 ${fs.text}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-medium text-white truncate max-w-[300px] sm:max-w-none">
{title}
</h3>
<span className={`text-[10px] uppercase font-bold px-1.5 py-0.5 rounded ${fs.bg} ${fs.text}`}>
{report.format}
</span>
{report.auto_generated && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-yellow-500/10 text-yellow-400 flex items-center gap-0.5">
<Sparkles className="w-2.5 h-2.5" /> Auto
</span>
)}
{report.is_partial && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-orange-500/10 text-orange-400">
Partial
</span>
)}
</div>
<div className="flex items-center gap-3 mt-1.5 flex-wrap">
<span className="text-xs text-dark-500 flex items-center gap-1">
<Calendar className="w-3 h-3" />
{relativeTime(report.generated_at)}
</span>
{scan && scan.total_vulnerabilities > 0 && (
<div className="flex items-center gap-1">
{scan.critical_count > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-red-500/20 text-red-400 font-bold tabular-nums">
{scan.critical_count}C
</span>
)}
{scan.high_count > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-orange-500/20 text-orange-400 font-bold tabular-nums">
{scan.high_count}H
</span>
)}
{scan.medium_count > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-yellow-500/20 text-yellow-400 font-bold tabular-nums">
{scan.medium_count}M
</span>
)}
{scan.low_count > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-blue-500/20 text-blue-400 font-bold tabular-nums">
{scan.low_count}L
</span>
)}
</div>
)}
{scan && (
<Link
to={`/scan/${scan.id}`}
className="text-xs text-primary-500 hover:text-primary-400 flex items-center gap-0.5"
onClick={e => e.stopPropagation()}
>
View Scan <ExternalLink className="w-2.5 h-2.5" />
</Link>
)}
</div>
</div>
{/* Actions — Desktop */}
<div className="hidden sm:flex items-center gap-1.5 flex-shrink-0">
<button
onClick={() => window.open(reportsApi.getViewUrl(report.id), '_blank')}
className="p-2 rounded-lg text-dark-400 hover:text-white hover:bg-dark-700 transition-colors"
title="View in browser"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={() => handleDownload(report.id, 'html')}
className="p-2 rounded-lg text-blue-400 hover:text-blue-300 hover:bg-blue-500/10 transition-colors"
title="Download HTML"
>
<Download className="w-4 h-4" />
</button>
<button
onClick={() => handleDownload(report.id, 'json')}
className="p-2 rounded-lg text-green-400 hover:text-green-300 hover:bg-green-500/10 transition-colors"
title="Download JSON"
>
<Package className="w-4 h-4" />
</button>
<button
onClick={() => handleDownloadZip(report.id)}
className="p-2 rounded-lg text-purple-400 hover:text-purple-300 hover:bg-purple-500/10 transition-colors"
title="Download ZIP"
>
<Archive className="w-4 h-4" />
</button>
<button
onClick={() => handleAiRegenerate(report.scan_id, title)}
disabled={regeneratingId === report.scan_id}
className="p-2 rounded-lg text-yellow-400 hover:text-yellow-300 hover:bg-yellow-500/10 transition-colors disabled:opacity-40"
title="Generate AI Report"
>
<Sparkles className={`w-4 h-4 ${regeneratingId === report.scan_id ? 'animate-spin' : ''}`} />
</button>
<button
onClick={() => setDeleteTarget({ id: report.id, title })}
className="p-2 rounded-lg text-dark-500 hover:text-red-400 hover:bg-red-500/10 transition-colors"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{/* Actions — Mobile */}
<div className="flex sm:hidden items-center gap-1.5 mt-3 pt-3 border-t border-dark-900/50 flex-wrap">
<button
onClick={() => window.open(reportsApi.getViewUrl(report.id), '_blank')}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg bg-dark-700 text-dark-300 hover:text-white text-xs font-medium transition-colors"
>
<Eye className="w-3.5 h-3.5" /> View
</button>
<button
onClick={() => handleDownload(report.id, 'html')}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg bg-dark-700 text-blue-400 hover:bg-blue-500/10 text-xs font-medium transition-colors"
>
<Download className="w-3.5 h-3.5" /> HTML
</button>
<button
onClick={() => handleDownloadZip(report.id)}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg bg-dark-700 text-purple-400 hover:bg-purple-500/10 text-xs font-medium transition-colors"
>
<Archive className="w-3.5 h-3.5" /> ZIP
</button>
<button
onClick={() => handleAiRegenerate(report.scan_id, title)}
disabled={regeneratingId === report.scan_id}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg bg-dark-700 text-yellow-400 hover:bg-yellow-500/10 text-xs font-medium transition-colors disabled:opacity-40"
>
<Sparkles className={`w-3.5 h-3.5 ${regeneratingId === report.scan_id ? 'animate-spin' : ''}`} /> AI
</button>
<button
onClick={() => setDeleteTarget({ id: report.id, title })}
className="px-3 py-2 rounded-lg bg-dark-700 text-dark-500 hover:text-red-400 hover:bg-red-500/10 text-xs transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
)
}
+617
View File
@@ -0,0 +1,617 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { Link } from 'react-router-dom'
import {
Box, RefreshCw, Trash2, Heart, Clock, Cpu,
HardDrive, Timer, CheckCircle2,
XCircle, Wrench, Container, X, WifiOff
} from 'lucide-react'
import { PieChart, Pie, Cell, Tooltip as RechartsTooltip, ResponsiveContainer } from 'recharts'
import Card from '../components/common/Card'
import Button from '../components/common/Button'
import { sandboxApi } from '../services/api'
import type { SandboxPoolStatus, SandboxContainer } from '../types'
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function formatUptime(seconds: number): string {
if (seconds < 60) return `${Math.floor(seconds)}s`
if (seconds < 3600) {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m}m ${s}s`
}
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
return `${h}h ${m}m`
}
function relativeTime(isoDate: string | null): string {
if (!isoDate) return 'Unknown'
const now = Date.now()
const then = new Date(isoDate).getTime()
const diffMs = now - then
const diffS = Math.floor(diffMs / 1000)
if (diffS < 5) return 'just now'
if (diffS < 60) return `${diffS}s ago`
const diffM = Math.floor(diffS / 60)
if (diffM < 60) return `${diffM}m ago`
const diffH = Math.floor(diffM / 60)
if (diffH < 24) return `${diffH}h ${diffM % 60}m ago`
const diffD = Math.floor(diffH / 24)
return `${diffD}d ${diffH % 24}h ago`
}
/* ------------------------------------------------------------------ */
/* Toast System */
/* ------------------------------------------------------------------ */
interface Toast {
id: number
message: string
severity: 'info' | 'success' | 'warning' | 'error'
}
let _toastId = 0
function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: number) => void }) {
if (toasts.length === 0) return null
const border: Record<string, string> = {
info: 'border-blue-500',
success: 'border-green-500',
warning: 'border-yellow-500',
error: 'border-red-500',
}
return (
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
{toasts.map(t => (
<div
key={t.id}
className={`bg-dark-800 border-l-4 ${border[t.severity]} rounded-lg px-4 py-3 shadow-xl flex items-start gap-3`}
style={{ animation: 'fadeSlideIn 0.3s ease-out' }}
>
<span className="text-sm text-dark-200 flex-1">{t.message}</span>
<button onClick={() => onDismiss(t.id)} className="text-dark-500 hover:text-white">
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)
}
/* ------------------------------------------------------------------ */
/* Donut Chart Colors */
/* ------------------------------------------------------------------ */
const DONUT_COLORS = ['#3b82f6', '#1e293b']
/* ------------------------------------------------------------------ */
/* Page Component */
/* ------------------------------------------------------------------ */
export default function SandboxDashboardPage() {
const [data, setData] = useState<SandboxPoolStatus | null>(null)
const [loading, setLoading] = useState(true)
const [toasts, setToasts] = useState<Toast[]>([])
const [destroyConfirm, setDestroyConfirm] = useState<string | null>(null)
const [healthResults, setHealthResults] = useState<Record<string, { status: string; tools: string[] } | null>>({})
const [healthLoading, setHealthLoading] = useState<Record<string, boolean>>({})
const [actionLoading, setActionLoading] = useState(false)
const [refreshSpinning, setRefreshSpinning] = useState(false)
const [pollFailures, setPollFailures] = useState(0)
const dataRef = useRef(data)
dataRef.current = data
/* Toast helpers */
const addToast = useCallback((message: string, severity: Toast['severity'] = 'info') => {
const id = ++_toastId
setToasts(prev => [...prev, { id, message, severity }])
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id))
}, 4000)
}, [])
const dismissToast = useCallback((id: number) => {
setToasts(prev => prev.filter(t => t.id !== id))
}, [])
const fetchData = useCallback(async (showSpinner = false) => {
if (showSpinner) setLoading(true)
try {
const result = await sandboxApi.list()
setData(result)
setPollFailures(0)
} catch (error) {
console.error('Failed to fetch sandbox data:', error)
setPollFailures(prev => prev + 1)
if (!dataRef.current) {
setData({
pool: { active: 0, max_concurrent: 0, image: 'N/A', container_ttl_minutes: 0, docker_available: false },
containers: [],
error: 'Failed to connect to backend',
})
}
} finally {
setLoading(false)
}
}, [])
/* Initial fetch + 15-second polling */
useEffect(() => {
fetchData(true)
const interval = setInterval(() => fetchData(false), 15000)
return () => clearInterval(interval)
}, [fetchData])
const handleRefreshClick = useCallback(() => {
setRefreshSpinning(true)
fetchData(false)
setTimeout(() => setRefreshSpinning(false), 800)
}, [fetchData])
const handleDestroy = async (scanId: string) => {
if (destroyConfirm !== scanId) {
setDestroyConfirm(scanId)
setTimeout(() => setDestroyConfirm(null), 5000)
return
}
setDestroyConfirm(null)
setActionLoading(true)
try {
await sandboxApi.destroy(scanId)
addToast(`Container for scan ${scanId.slice(0, 8)}... destroyed`, 'success')
fetchData(false)
} catch (error: any) {
addToast(error?.response?.data?.detail || 'Failed to destroy container', 'error')
} finally {
setActionLoading(false)
}
}
const handleHealthCheck = async (scanId: string) => {
setHealthLoading(prev => ({ ...prev, [scanId]: true }))
try {
const result = await sandboxApi.healthCheck(scanId)
setHealthResults(prev => ({ ...prev, [scanId]: result }))
setTimeout(() => {
setHealthResults(prev => ({ ...prev, [scanId]: null }))
}, 8000)
} catch {
setHealthResults(prev => ({ ...prev, [scanId]: { status: 'error', tools: [] } }))
} finally {
setHealthLoading(prev => ({ ...prev, [scanId]: false }))
}
}
const handleCleanup = async (type: 'expired' | 'orphans') => {
setActionLoading(true)
try {
if (type === 'expired') {
await sandboxApi.cleanup()
} else {
await sandboxApi.cleanupOrphans()
}
addToast(`${type === 'expired' ? 'Expired' : 'Orphan'} containers cleaned up`, 'success')
fetchData(false)
} catch (error: any) {
addToast(error?.response?.data?.detail || 'Cleanup failed', 'error')
} finally {
setActionLoading(false)
}
}
const pool = data?.pool
const containers = data?.containers || []
const utilizationPct = pool ? (pool.max_concurrent > 0 ? (pool.active / pool.max_concurrent) * 100 : 0) : 0
const donutData = useMemo(() => {
if (!pool || pool.max_concurrent === 0) return []
return [
{ name: 'Active', value: pool.active },
{ name: 'Available', value: Math.max(0, pool.max_concurrent - pool.active) },
]
}, [pool])
const connectionLost = pollFailures >= 3
if (loading && !data) {
return (
<div className="animate-pulse space-y-6">
<div className="h-8 bg-dark-800 rounded w-64" />
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[1, 2, 3, 4].map(i => (
<div key={i} className="h-24 bg-dark-800 rounded-lg" />
))}
</div>
<div className="space-y-4">
{[1, 2].map(i => (
<div key={i} className="h-40 bg-dark-800 rounded-lg" />
))}
</div>
</div>
)
}
return (
<div className="space-y-6 animate-fadeIn">
{/* Inline keyframes */}
<style>{`
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes spinOnce {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
{/* Toast Notifications */}
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
{/* Connection Lost Banner */}
{connectionLost && (
<div
className="flex items-center gap-3 px-4 py-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30 text-yellow-400 text-sm"
style={{ animation: 'fadeSlideIn 0.3s ease-out' }}
>
<WifiOff className="w-4 h-4 flex-shrink-0" />
<span>Connection lost -- data may be stale. Retrying automatically...</span>
</div>
)}
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<div className="w-10 h-10 bg-blue-500/20 rounded-lg flex items-center justify-center">
<Container className="w-6 h-6 text-blue-400" />
</div>
<Box className="w-5 h-5 text-dark-400 -ml-1" />
Sandbox Containers
</h1>
<p className="text-dark-400 mt-1">Real-time monitoring of per-scan Kali Linux containers</p>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="ghost"
size="sm"
onClick={() => handleCleanup('expired')}
isLoading={actionLoading}
>
<Timer className="w-4 h-4 mr-1" />
Cleanup Expired
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleCleanup('orphans')}
isLoading={actionLoading}
>
<Trash2 className="w-4 h-4 mr-1" />
Cleanup Orphans
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleRefreshClick}
>
<RefreshCw
className="w-4 h-4 mr-1"
style={refreshSpinning ? { animation: 'spinOnce 0.6s ease-in-out' } : undefined}
/>
Refresh
</Button>
</div>
</div>
{/* Pool Stats Cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{/* Active Containers */}
<div style={{ animation: 'fadeSlideIn 0.3s ease-out 0.05s both' }}>
<Card>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
utilizationPct >= 100 ? 'bg-red-500/20' :
utilizationPct >= 80 ? 'bg-yellow-500/20' :
'bg-green-500/20'
}`}>
<Box className={`w-5 h-5 ${
utilizationPct >= 100 ? 'text-red-400' :
utilizationPct >= 80 ? 'text-yellow-400' :
'text-green-400'
}`} />
</div>
<div>
<p className="text-2xl font-bold text-white">
{pool?.active || 0}<span className="text-dark-400 text-lg">/{pool?.max_concurrent || 0}</span>
</p>
<p className="text-xs text-dark-400">Active Containers</p>
</div>
</div>
</Card>
</div>
{/* Docker Status */}
<div style={{ animation: 'fadeSlideIn 0.3s ease-out 0.1s both' }}>
<Card>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
pool?.docker_available ? 'bg-green-500/20' : 'bg-red-500/20'
}`}>
<HardDrive className={`w-5 h-5 ${
pool?.docker_available ? 'text-green-400' : 'text-red-400'
}`} />
</div>
<div>
<p className="text-lg font-bold text-white">
{pool?.docker_available ? 'Online' : 'Offline'}
</p>
<p className="text-xs text-dark-400">Docker Engine</p>
</div>
</div>
</Card>
</div>
{/* Container Image */}
<div style={{ animation: 'fadeSlideIn 0.3s ease-out 0.15s both' }}>
<Card>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-500/20 rounded-lg flex items-center justify-center">
<Cpu className="w-5 h-5 text-purple-400" />
</div>
<div>
<p className="text-sm font-bold text-white truncate max-w-[140px]" title={pool?.image}>
{pool?.image?.split(':')[0]?.split('/').pop() || 'N/A'}
</p>
<p className="text-xs text-dark-400">
{pool?.image?.includes(':') ? pool.image.split(':')[1] : 'latest'}
</p>
</div>
</div>
</Card>
</div>
{/* TTL */}
<div style={{ animation: 'fadeSlideIn 0.3s ease-out 0.2s both' }}>
<Card>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-orange-500/20 rounded-lg flex items-center justify-center">
<Clock className="w-5 h-5 text-orange-400" />
</div>
<div>
<p className="text-2xl font-bold text-white">
{pool?.container_ttl_minutes || 0}<span className="text-dark-400 text-lg"> min</span>
</p>
<p className="text-xs text-dark-400">Container TTL</p>
</div>
</div>
</Card>
</div>
</div>
{/* Capacity Bar + Donut Chart */}
{pool && pool.max_concurrent > 0 && (
<div className="bg-dark-800 rounded-lg p-4 border border-dark-700">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-6">
{/* Bar section */}
<div className="flex-1 w-full">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-dark-300">Pool Capacity</span>
<span className={`text-sm font-medium ${
utilizationPct >= 100 ? 'text-red-400' :
utilizationPct >= 80 ? 'text-yellow-400' :
'text-green-400'
}`}>
{Math.round(utilizationPct)}%
</span>
</div>
<div className="w-full bg-dark-900 rounded-full h-2.5">
<div
className={`h-2.5 rounded-full transition-all duration-500 ${
utilizationPct >= 100 ? 'bg-red-500' :
utilizationPct >= 80 ? 'bg-yellow-500' :
'bg-green-500'
}`}
style={{ width: `${Math.min(utilizationPct, 100)}%` }}
/>
</div>
</div>
{/* Donut chart */}
{donutData.length > 0 && (
<div className="w-24 h-24 flex-shrink-0">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={donutData}
cx="50%"
cy="50%"
innerRadius={25}
outerRadius={38}
paddingAngle={2}
dataKey="value"
stroke="none"
>
{donutData.map((_entry, index) => (
<Cell key={`cell-${index}`} fill={DONUT_COLORS[index % DONUT_COLORS.length]} />
))}
</Pie>
<RechartsTooltip
contentStyle={{ background: '#1e293b', border: '1px solid #334155', borderRadius: '8px', fontSize: '12px' }}
itemStyle={{ color: '#e2e8f0' }}
/>
</PieChart>
</ResponsiveContainer>
</div>
)}
</div>
</div>
)}
{/* Container List */}
{containers.length === 0 ? (
<div className="bg-dark-800 rounded-lg border border-dark-700 p-12 text-center">
<Box className="w-16 h-16 text-dark-600 mx-auto mb-4" />
<h3 className="text-lg font-medium text-dark-300 mb-2">No Sandbox Containers Running</h3>
<p className="text-dark-400 text-sm max-w-md mx-auto">
Containers are automatically created when scans start and destroyed when they complete.
Start a scan to see containers here.
</p>
</div>
) : (
<div className="space-y-3">
<h2 className="text-lg font-semibold text-white">
Running Containers ({containers.length})
</h2>
{containers.map((container: SandboxContainer, idx: number) => {
const health = healthResults[container.scan_id]
const isHealthLoading = healthLoading[container.scan_id]
const isConfirming = destroyConfirm === container.scan_id
return (
<div
key={container.scan_id}
className="bg-dark-800 rounded-lg border border-dark-700 p-5 hover:border-dark-600 transition-colors"
style={{ animation: `fadeSlideIn 0.3s ease-out ${0.05 * (idx + 1)}s both` }}
>
{/* Container Header */}
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3 mb-4">
<div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${
container.available ? 'bg-green-500 animate-pulse' : 'bg-red-500'
}`} />
<div>
<h3 className="text-white font-medium font-mono text-sm">
{container.container_name}
</h3>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-dark-400">Scan:</span>
<Link
to={`/scan/${container.scan_id}`}
className="text-xs text-primary-400 hover:text-primary-300 font-mono"
>
{container.scan_id.slice(0, 12)}...
</Link>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{/* Status badge */}
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium ${
container.available
? 'bg-green-500/10 text-green-400 border border-green-500/30'
: 'bg-red-500/10 text-red-400 border border-red-500/30'
}`}>
{container.available ? (
<><CheckCircle2 className="w-3 h-3" /> Running</>
) : (
<><XCircle className="w-3 h-3" /> Stopped</>
)}
</span>
</div>
</div>
{/* Container Info Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 mb-4">
{/* Uptime */}
<div>
<p className="text-xs text-dark-400 mb-1">Uptime</p>
<p className="text-sm text-white font-medium">
{formatUptime(container.uptime_seconds)}
</p>
</div>
{/* Created */}
<div>
<p className="text-xs text-dark-400 mb-1">Created</p>
<p className="text-sm text-dark-300" title={container.created_at || undefined}>
{relativeTime(container.created_at)}
</p>
</div>
{/* Tools count */}
<div>
<p className="text-xs text-dark-400 mb-1">Installed Tools</p>
<p className="text-sm text-white font-medium">
{container.installed_tools.length}
</p>
</div>
</div>
{/* Installed Tools */}
{container.installed_tools.length > 0 && (
<div className="mb-4">
<p className="text-xs text-dark-400 mb-2">Tools</p>
<div className="flex flex-wrap gap-1.5">
{container.installed_tools.map(tool => (
<span
key={tool}
className="inline-flex items-center gap-1 px-2 py-0.5 bg-dark-900 border border-dark-600 rounded text-xs text-dark-300"
>
<Wrench className="w-3 h-3 text-dark-500" />
{tool}
</span>
))}
</div>
</div>
)}
{/* Health Check Result */}
{health && (
<div className={`mb-4 px-3 py-2 rounded-lg text-xs ${
health.status === 'healthy'
? 'bg-green-500/10 border border-green-500/20 text-green-400'
: health.status === 'degraded'
? 'bg-yellow-500/10 border border-yellow-500/20 text-yellow-400'
: 'bg-red-500/10 border border-red-500/20 text-red-400'
}`} style={{ animation: 'fadeSlideIn 0.3s ease-out' }}>
<span className="font-medium">Health: {health.status}</span>
{health.tools.length > 0 && (
<span className="ml-2">
-- Verified: {health.tools.join(', ')}
</span>
)}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-3 border-t border-dark-700 flex-wrap">
<Button
variant="ghost"
size="sm"
onClick={() => handleHealthCheck(container.scan_id)}
isLoading={isHealthLoading}
>
<Heart className="w-4 h-4 mr-1" />
Health Check
</Button>
<Button
variant={isConfirming ? 'danger' : 'ghost'}
size="sm"
onClick={() => handleDestroy(container.scan_id)}
isLoading={actionLoading}
>
<Trash2 className="w-4 h-4 mr-1" />
{isConfirming ? 'Confirm Destroy' : 'Destroy'}
</Button>
</div>
</div>
)
})}
</div>
)}
{/* Auto-refresh indicator */}
<div className="text-center text-xs text-dark-500">
Auto-refreshing every 15 seconds
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
+954
View File
@@ -0,0 +1,954 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { Plus, Trash2, Play, Pause, Clock, RefreshCw, Target, Calendar, ChevronDown, Shield, Zap, Search, Settings2, X } from 'lucide-react'
import Card from '../components/common/Card'
import Button from '../components/common/Button'
import Input from '../components/common/Input'
import { schedulerApi } from '../services/api'
import type { ScheduleJob, AgentRole } from '../types'
/* ---------- inline keyframes ---------- */
const styleTag = `
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes refreshSpin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`
/* ---------- Toast notification system ---------- */
interface Toast {
id: number
message: string
type: 'success' | 'error' | 'info'
}
let _toastId = 0
function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: number) => void }) {
if (toasts.length === 0) return null
const borderColor: Record<Toast['type'], string> = {
info: 'border-blue-500',
success: 'border-green-500',
error: 'border-red-500',
}
return (
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
{toasts.map(t => (
<div
key={t.id}
className={`bg-dark-800 border-l-4 ${borderColor[t.type]} rounded-lg px-4 py-3 shadow-xl flex items-start gap-3`}
style={{ animation: 'fadeSlideIn 0.3s ease-out' }}
>
<span className="text-sm text-dark-200 flex-1">{t.message}</span>
<button onClick={() => onDismiss(t.id)} className="text-dark-500 hover:text-white">
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)
}
/* ---------- Delete Confirmation Modal ---------- */
function DeleteModal({ jobId, onConfirm, onCancel }: {
jobId: string
onConfirm: () => void
onCancel: () => void
}) {
return (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" onClick={onCancel}>
<div
className="bg-dark-800 rounded-xl border border-dark-700 p-6 max-w-sm w-full"
onClick={e => e.stopPropagation()}
style={{ animation: 'fadeSlideIn 0.2s ease-out' }}
>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-red-500/10">
<Trash2 className="w-5 h-5 text-red-400" />
</div>
<h3 className="text-lg font-semibold text-white">Delete Schedule</h3>
</div>
<p className="text-sm text-dark-300 mb-6">
Are you sure you want to delete <span className="text-white font-medium">&quot;{jobId}&quot;</span>?
This action cannot be undone.
</p>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onCancel}>Cancel</Button>
<Button variant="danger" onClick={onConfirm}>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</Button>
</div>
</div>
</div>
)
}
/* ---------- Relative time helper ---------- */
function relativeTime(ts: string | null): string {
if (!ts) return 'N/A'
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000)
if (diff < 0) {
// future time
const abs = Math.abs(diff)
if (abs < 60) return `in ${abs}s`
if (abs < 3600) return `in ${Math.floor(abs / 60)}m`
if (abs < 86400) return `in ${Math.floor(abs / 3600)}h`
return `in ${Math.floor(abs / 86400)}d`
}
if (diff < 60) return `${diff}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return `${Math.floor(diff / 86400)}d ago`
}
/* ---------- Constants ---------- */
// Cron presets for quick selection
const CRON_PRESETS = [
{ label: 'Every Hour', value: '0 * * * *', desc: 'Runs at the start of every hour' },
{ label: 'Every 6 Hours', value: '0 */6 * * *', desc: 'Runs every 6 hours' },
{ label: 'Daily at 2 AM', value: '0 2 * * *', desc: 'Runs once a day at 2:00 AM' },
{ label: 'Daily at Midnight', value: '0 0 * * *', desc: 'Runs once a day at midnight' },
{ label: 'Weekdays at 9 AM', value: '0 9 * * 1-5', desc: 'Monday to Friday at 9:00 AM' },
{ label: 'Weekly (Monday)', value: '0 0 * * 1', desc: 'Every Monday at midnight' },
{ label: 'Weekly (Friday)', value: '0 18 * * 5', desc: 'Every Friday at 6:00 PM' },
{ label: 'Monthly (1st)', value: '0 0 1 * *', desc: 'First day of each month' },
{ label: 'Custom', value: 'custom', desc: 'Enter a custom cron expression' },
]
const SCAN_TYPES = [
{ id: 'quick', label: 'Quick', icon: Zap, desc: 'Fast surface scan' },
{ id: 'full', label: 'Full', icon: Search, desc: 'Comprehensive analysis' },
{ id: 'custom', label: 'Custom', icon: Settings2, desc: 'Custom configuration' },
]
const DAYS_OF_WEEK = [
{ id: 0, short: 'Sun', full: 'Sunday' },
{ id: 1, short: 'Mon', full: 'Monday' },
{ id: 2, short: 'Tue', full: 'Tuesday' },
{ id: 3, short: 'Wed', full: 'Wednesday' },
{ id: 4, short: 'Thu', full: 'Thursday' },
{ id: 5, short: 'Fri', full: 'Friday' },
{ id: 6, short: 'Sat', full: 'Saturday' },
]
const INTERVAL_OPTIONS = [
{ label: '15 min', value: '15' },
{ label: '30 min', value: '30' },
{ label: '1 hour', value: '60' },
{ label: '2 hours', value: '120' },
{ label: '4 hours', value: '240' },
{ label: '6 hours', value: '360' },
{ label: '12 hours', value: '720' },
{ label: '24 hours', value: '1440' },
]
const SCHEDULE_MODE_TABS = [
{ id: 'preset' as const, label: 'Presets' },
{ id: 'days' as const, label: 'Days & Time' },
{ id: 'interval' as const, label: 'Interval' },
]
/* ===================================================================
Main Component
=================================================================== */
export default function SchedulerPage() {
const [jobs, setJobs] = useState<ScheduleJob[]>([])
const [agentRoles, setAgentRoles] = useState<AgentRole[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [toasts, setToasts] = useState<Toast[]>([])
const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
// Form state
const [jobId, setJobId] = useState('')
const [target, setTarget] = useState('')
const [scanType, setScanType] = useState('quick')
const [scheduleMode, setScheduleMode] = useState<'interval' | 'preset' | 'days'>('preset')
const [cronPreset, setCronPreset] = useState('0 2 * * *')
const [customCron, setCustomCron] = useState('')
const [intervalMinutes, setIntervalMinutes] = useState('60')
const [selectedDays, setSelectedDays] = useState<number[]>([1, 2, 3, 4, 5])
const [executionHour, setExecutionHour] = useState('02')
const [executionMinute, setExecutionMinute] = useState('00')
const [agentRole, setAgentRole] = useState('')
const [showRoleDropdown, setShowRoleDropdown] = useState(false)
const [isCreating, setIsCreating] = useState(false)
/* ---------- Toast helpers ---------- */
const addToast = useCallback((message: string, type: Toast['type']) => {
const id = ++_toastId
setToasts(prev => [...prev.slice(-4), { id, message, type }])
setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), 5000)
}, [])
const dismissToast = useCallback((id: number) => {
setToasts(prev => prev.filter(t => t.id !== id))
}, [])
/* ---------- Derived data ---------- */
const selectedRole = useMemo(
() => agentRoles.find(r => r.id === agentRole),
[agentRoles, agentRole]
)
const activeJobCount = useMemo(
() => jobs.filter(j => j.status === 'active').length,
[jobs]
)
const totalRunCount = useMemo(
() => jobs.reduce((sum, j) => sum + j.run_count, 0),
[jobs]
)
const intervalDisplayText = useMemo(() => {
const mins = parseInt(intervalMinutes)
if (isNaN(mins) || mins <= 0) return `${intervalMinutes} minutes`
if (mins >= 60) {
const hours = Math.floor(mins / 60)
const remaining = mins % 60
return remaining > 0 ? `${hours}h ${remaining}m` : `${hours} hour(s)`
}
return `${mins} minutes`
}, [intervalMinutes])
const scheduleSummaryText = useMemo(() => {
if (scheduleMode === 'interval') {
return `Runs every ${intervalDisplayText}`
}
if (scheduleMode === 'days' && selectedDays.length > 0) {
const dayNames = [...selectedDays].sort((a, b) => a - b).map(d => DAYS_OF_WEEK[d].short).join(', ')
return `Runs on ${dayNames} at ${executionHour}:${executionMinute}`
}
if (scheduleMode === 'preset' && cronPreset !== 'custom') {
return CRON_PRESETS.find(p => p.value === cronPreset)?.desc || ''
}
return ''
}, [scheduleMode, intervalDisplayText, selectedDays, executionHour, executionMinute, cronPreset])
/* ---------- Data fetching ---------- */
const fetchData = useCallback(async () => {
setLoading(true)
try {
const [jobsData, rolesData] = await Promise.all([
schedulerApi.list(),
schedulerApi.getAgentRoles(),
])
setJobs(jobsData)
setAgentRoles(rolesData)
} catch (error) {
console.error('Failed to fetch scheduler data:', error)
} finally {
setLoading(false)
}
}, [])
const handleRefresh = useCallback(async () => {
setRefreshing(true)
try {
const [jobsData, rolesData] = await Promise.all([
schedulerApi.list(),
schedulerApi.getAgentRoles(),
])
setJobs(jobsData)
setAgentRoles(rolesData)
addToast('Schedules refreshed', 'info')
} catch (error) {
console.error('Failed to fetch scheduler data:', error)
addToast('Failed to refresh schedules', 'error')
} finally {
setRefreshing(false)
}
}, [addToast])
useEffect(() => {
fetchData()
}, [fetchData])
/* ---------- Form logic ---------- */
const buildCronExpression = useCallback((): string | undefined => {
if (scheduleMode === 'interval') return undefined
if (scheduleMode === 'preset') {
return cronPreset === 'custom' ? customCron : cronPreset
}
// days mode: build cron from selected days + time
if (selectedDays.length === 0) return undefined
const daysStr = [...selectedDays].sort((a, b) => a - b).join(',')
return `${executionMinute} ${executionHour} * * ${daysStr}`
}, [scheduleMode, cronPreset, customCron, selectedDays, executionMinute, executionHour])
const getIntervalMinutes = useCallback((): number | undefined => {
if (scheduleMode !== 'interval') return undefined
return parseInt(intervalMinutes) || 60
}, [scheduleMode, intervalMinutes])
const resetForm = useCallback(() => {
setJobId('')
setTarget('')
setScanType('quick')
setScheduleMode('preset')
setCronPreset('0 2 * * *')
setCustomCron('')
setIntervalMinutes('60')
setSelectedDays([1, 2, 3, 4, 5])
setExecutionHour('02')
setExecutionMinute('00')
setAgentRole('')
setShowRoleDropdown(false)
}, [])
const handleCreate = useCallback(async () => {
if (!jobId.trim()) {
addToast('Job ID is required', 'error')
return
}
if (!target.trim()) {
addToast('Target URL is required', 'error')
return
}
const cron = buildCronExpression()
const interval = getIntervalMinutes()
if (!cron && !interval) {
addToast('Please configure a schedule (select days or set interval)', 'error')
return
}
setIsCreating(true)
try {
await schedulerApi.create({
job_id: jobId.trim(),
target: target.trim(),
scan_type: scanType,
cron_expression: cron,
interval_minutes: interval,
agent_role: agentRole || undefined,
})
addToast(`Schedule "${jobId}" created successfully`, 'success')
setShowForm(false)
resetForm()
fetchData()
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
const detail = err?.response?.data?.detail || 'Failed to create schedule'
addToast(detail, 'error')
} finally {
setIsCreating(false)
}
}, [jobId, target, scanType, agentRole, buildCronExpression, getIntervalMinutes, addToast, resetForm, fetchData])
const handleDelete = useCallback(async () => {
if (!deleteTarget) return
try {
await schedulerApi.delete(deleteTarget)
addToast(`Schedule "${deleteTarget}" deleted`, 'success')
setDeleteTarget(null)
fetchData()
} catch (error) {
addToast(`Failed to delete "${deleteTarget}"`, 'error')
setDeleteTarget(null)
}
}, [deleteTarget, addToast, fetchData])
const handlePause = useCallback(async (id: string) => {
try {
await schedulerApi.pause(id)
addToast(`Schedule "${id}" paused`, 'info')
fetchData()
} catch (error) {
addToast(`Failed to pause "${id}"`, 'error')
}
}, [addToast, fetchData])
const handleResume = useCallback(async (id: string) => {
try {
await schedulerApi.resume(id)
addToast(`Schedule "${id}" resumed`, 'success')
fetchData()
} catch (error) {
addToast(`Failed to resume "${id}"`, 'error')
}
}, [addToast, fetchData])
const toggleDay = useCallback((dayId: number) => {
setSelectedDays(prev =>
prev.includes(dayId) ? prev.filter(d => d !== dayId) : [...prev, dayId]
)
}, [])
const handleToggleForm = useCallback(() => {
if (showForm) {
resetForm()
}
setShowForm(prev => !prev)
}, [showForm, resetForm])
const handleCancelForm = useCallback(() => {
setShowForm(false)
resetForm()
}, [resetForm])
/* ---------- Render ---------- */
return (
<>
<style>{styleTag}</style>
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
{deleteTarget && (
<DeleteModal
jobId={deleteTarget}
onConfirm={handleDelete}
onCancel={() => setDeleteTarget(null)}
/>
)}
<div className="max-w-5xl mx-auto space-y-6" style={{ animation: 'fadeSlideIn 0.3s ease-out' }}>
{/* Header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-white flex items-center gap-3">
<div className="p-2 bg-brand-500/20 rounded-lg">
<Calendar className="w-6 h-6 text-brand-400" />
</div>
Scan Scheduler
</h2>
<p className="text-dark-400 mt-1 ml-14">Schedule automated recurring scans with agent specialization</p>
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={handleRefresh} disabled={refreshing}>
<RefreshCw
className="w-4 h-4 mr-2"
style={refreshing ? { animation: 'refreshSpin 0.8s linear infinite' } : undefined}
/>
Refresh
</Button>
<Button onClick={handleToggleForm}>
<Plus className="w-4 h-4 mr-2" />
New Schedule
</Button>
</div>
</div>
{/* Create Form */}
{showForm && (
<div
className="bg-dark-800 border border-dark-700 rounded-xl overflow-hidden"
style={{ animation: 'fadeSlideIn 0.3s ease-out' }}
>
<div className="p-5 border-b border-dark-700">
<h3 className="text-lg font-semibold text-white">Create New Schedule</h3>
<p className="text-dark-400 text-sm mt-1">Configure a recurring scan with specialized agent roles</p>
</div>
<div className="p-5 space-y-6">
{/* Row 1: Job ID + Target */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label="Job ID"
placeholder="daily-scan-prod"
value={jobId}
onChange={(e) => setJobId(e.target.value)}
helperText="Unique identifier for this schedule"
/>
<Input
label="Target URL"
placeholder="https://example.com"
value={target}
onChange={(e) => setTarget(e.target.value)}
helperText="URL to scan on each execution"
/>
</div>
{/* Row 2: Scan Type */}
<div>
<label className="block text-sm font-medium text-dark-200 mb-3">Scan Type</label>
<div className="grid grid-cols-3 gap-3">
{SCAN_TYPES.map(({ id, label, icon: Icon, desc }) => (
<button
key={id}
onClick={() => setScanType(id)}
className={`p-4 rounded-lg border-2 text-left transition-all ${
scanType === id
? 'border-brand-500 bg-brand-500/10'
: 'border-dark-600 bg-dark-900/50 hover:border-dark-500'
}`}
>
<div className="flex items-center gap-2 mb-1">
<Icon className={`w-4 h-4 ${scanType === id ? 'text-brand-400' : 'text-dark-400'}`} />
<span className={`font-medium ${scanType === id ? 'text-white' : 'text-dark-300'}`}>{label}</span>
</div>
<p className="text-xs text-dark-500">{desc}</p>
</button>
))}
</div>
</div>
{/* Row 3: Agent Role Dropdown */}
<div>
<label className="block text-sm font-medium text-dark-200 mb-3">
<Shield className="w-4 h-4 inline mr-1 -mt-0.5" />
Agent Role
</label>
<div className="relative">
<button
onClick={() => setShowRoleDropdown(!showRoleDropdown)}
className="w-full flex items-center justify-between p-3 rounded-lg border border-dark-600 bg-dark-900/50 hover:border-dark-500 transition-colors text-left"
>
<div>
{selectedRole ? (
<>
<span className="text-white font-medium">{selectedRole.name}</span>
<span className="text-dark-500 text-sm ml-2">- {selectedRole.description}</span>
</>
) : (
<span className="text-dark-500">Select an agent role (optional)</span>
)}
</div>
<ChevronDown className={`w-4 h-4 text-dark-400 transition-transform ${showRoleDropdown ? 'rotate-180' : ''}`} />
</button>
{showRoleDropdown && (
<div
className="absolute z-20 w-full mt-1 bg-dark-800 border border-dark-600 rounded-lg shadow-xl max-h-72 overflow-y-auto"
style={{ animation: 'fadeSlideIn 0.2s ease-out' }}
>
{/* None option */}
<button
onClick={() => { setAgentRole(''); setShowRoleDropdown(false) }}
className={`w-full flex items-start gap-3 p-3 text-left hover:bg-dark-700/50 transition-colors border-b border-dark-700/50 ${
!agentRole ? 'bg-dark-700/30' : ''
}`}
>
<div className="w-8 h-8 rounded-lg bg-dark-600 flex items-center justify-center flex-shrink-0 mt-0.5">
<Target className="w-4 h-4 text-dark-400" />
</div>
<div>
<p className="text-dark-300 font-medium">Default Agent</p>
<p className="text-dark-500 text-xs">No specialization - uses general pentest agent</p>
</div>
</button>
{agentRoles.map((role) => (
<button
key={role.id}
onClick={() => { setAgentRole(role.id); setShowRoleDropdown(false) }}
className={`w-full flex items-start gap-3 p-3 text-left hover:bg-dark-700/50 transition-colors ${
agentRole === role.id ? 'bg-brand-500/10 border-l-2 border-brand-500' : ''
}`}
>
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5 ${
agentRole === role.id ? 'bg-brand-500/20' : 'bg-dark-600'
}`}>
<Shield className={`w-4 h-4 ${agentRole === role.id ? 'text-brand-400' : 'text-dark-400'}`} />
</div>
<div className="min-w-0">
<p className={`font-medium ${agentRole === role.id ? 'text-brand-400' : 'text-white'}`}>
{role.name}
</p>
<p className="text-dark-500 text-xs mt-0.5">{role.description}</p>
{role.tools.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{role.tools.slice(0, 4).map(tool => (
<span key={tool} className="px-1.5 py-0.5 text-[10px] bg-dark-600 text-dark-300 rounded">
{tool}
</span>
))}
{role.tools.length > 4 && (
<span className="px-1.5 py-0.5 text-[10px] bg-dark-600 text-dark-400 rounded">
+{role.tools.length - 4}
</span>
)}
</div>
)}
</div>
</button>
))}
</div>
)}
</div>
</div>
{/* Row 4: Schedule Configuration */}
<div>
<label className="block text-sm font-medium text-dark-200 mb-3">
<Clock className="w-4 h-4 inline mr-1 -mt-0.5" />
Schedule
</label>
{/* Schedule mode tabs */}
<div className="flex gap-1 p-1 bg-dark-900/50 rounded-lg mb-4">
{SCHEDULE_MODE_TABS.map(tab => (
<button
key={tab.id}
onClick={() => setScheduleMode(tab.id)}
className={`flex-1 py-2 px-3 rounded-md text-sm font-medium transition-all ${
scheduleMode === tab.id
? 'bg-brand-500 text-white shadow-sm'
: 'text-dark-400 hover:text-dark-200'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Preset mode */}
{scheduleMode === 'preset' && (
<div className="space-y-3" style={{ animation: 'fadeSlideIn 0.2s ease-out' }}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{CRON_PRESETS.map(preset => (
<button
key={preset.value}
onClick={() => setCronPreset(preset.value)}
className={`p-3 rounded-lg border text-left transition-all ${
cronPreset === preset.value
? 'border-brand-500 bg-brand-500/10'
: 'border-dark-600 bg-dark-900/30 hover:border-dark-500'
}`}
>
<p className={`text-sm font-medium ${cronPreset === preset.value ? 'text-brand-400' : 'text-dark-200'}`}>
{preset.label}
</p>
<p className="text-xs text-dark-500 mt-0.5">{preset.desc}</p>
</button>
))}
</div>
{cronPreset === 'custom' && (
<Input
label="Custom Cron Expression"
placeholder="*/30 * * * *"
value={customCron}
onChange={(e) => setCustomCron(e.target.value)}
helperText="Format: minute hour day-of-month month day-of-week"
/>
)}
</div>
)}
{/* Days & Time mode */}
{scheduleMode === 'days' && (
<div className="space-y-4" style={{ animation: 'fadeSlideIn 0.2s ease-out' }}>
<div>
<p className="text-sm text-dark-400 mb-2">Select days of the week</p>
<div className="flex gap-2 flex-wrap">
{DAYS_OF_WEEK.map(day => (
<button
key={day.id}
onClick={() => toggleDay(day.id)}
className={`flex-1 min-w-[3rem] py-3 rounded-lg border-2 text-center text-sm font-medium transition-all ${
selectedDays.includes(day.id)
? 'border-brand-500 bg-brand-500/15 text-brand-400'
: 'border-dark-600 bg-dark-900/30 text-dark-400 hover:border-dark-500'
}`}
title={day.full}
>
{day.short}
</button>
))}
</div>
<div className="flex gap-2 mt-2">
<button
onClick={() => setSelectedDays([1, 2, 3, 4, 5])}
className="text-xs text-brand-400 hover:text-brand-300 transition-colors"
>
Weekdays
</button>
<span className="text-dark-600">|</span>
<button
onClick={() => setSelectedDays([0, 6])}
className="text-xs text-brand-400 hover:text-brand-300 transition-colors"
>
Weekends
</button>
<span className="text-dark-600">|</span>
<button
onClick={() => setSelectedDays([0, 1, 2, 3, 4, 5, 6])}
className="text-xs text-brand-400 hover:text-brand-300 transition-colors"
>
Every Day
</button>
</div>
</div>
<div>
<p className="text-sm text-dark-400 mb-2">Execution Time</p>
<div className="flex items-center gap-2">
<select
value={executionHour}
onChange={(e) => setExecutionHour(e.target.value)}
className="bg-dark-900 border border-dark-600 rounded-lg px-3 py-2.5 text-white text-sm focus:border-brand-500 focus:outline-none"
>
{Array.from({ length: 24 }, (_, i) => (
<option key={i} value={String(i).padStart(2, '0')}>
{String(i).padStart(2, '0')}
</option>
))}
</select>
<span className="text-dark-400 text-lg font-bold">:</span>
<select
value={executionMinute}
onChange={(e) => setExecutionMinute(e.target.value)}
className="bg-dark-900 border border-dark-600 rounded-lg px-3 py-2.5 text-white text-sm focus:border-brand-500 focus:outline-none"
>
{['00', '15', '30', '45'].map(m => (
<option key={m} value={m}>{m}</option>
))}
</select>
<span className="text-dark-500 text-sm ml-2">UTC</span>
</div>
</div>
{selectedDays.length > 0 && (
<div className="p-3 bg-dark-900/50 rounded-lg border border-dark-700/50">
<p className="text-xs text-dark-400">
Cron: <code className="text-brand-400 bg-dark-700 px-1.5 py-0.5 rounded">
{`${executionMinute} ${executionHour} * * ${[...selectedDays].sort((a, b) => a - b).join(',')}`}
</code>
</p>
</div>
)}
</div>
)}
{/* Interval mode */}
{scheduleMode === 'interval' && (
<div className="space-y-3" style={{ animation: 'fadeSlideIn 0.2s ease-out' }}>
<div className="grid grid-cols-4 gap-2">
{INTERVAL_OPTIONS.map(opt => (
<button
key={opt.value}
onClick={() => setIntervalMinutes(opt.value)}
className={`py-2.5 px-3 rounded-lg border text-sm font-medium transition-all ${
intervalMinutes === opt.value
? 'border-brand-500 bg-brand-500/10 text-brand-400'
: 'border-dark-600 bg-dark-900/30 text-dark-400 hover:border-dark-500'
}`}
>
{opt.label}
</button>
))}
</div>
<Input
label="Custom interval (minutes)"
type="number"
min="1"
value={intervalMinutes}
onChange={(e) => setIntervalMinutes(e.target.value)}
helperText={`Scan runs every ${intervalDisplayText}`}
/>
</div>
)}
</div>
{/* Actions */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between pt-2 border-t border-dark-700 gap-3">
<p className="text-xs text-dark-500">{scheduleSummaryText}</p>
<div className="flex gap-3">
<Button variant="secondary" onClick={handleCancelForm}>
Cancel
</Button>
<Button onClick={handleCreate} isLoading={isCreating}>
<Plus className="w-4 h-4 mr-2" />
Create Schedule
</Button>
</div>
</div>
</div>
</div>
)}
{/* Summary Stats */}
{jobs.length > 0 && (
<div
className="grid grid-cols-1 sm:grid-cols-3 gap-4"
style={{ animation: 'fadeSlideIn 0.35s ease-out' }}
>
<div className="bg-dark-800/50 border border-dark-700/50 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-500/15 rounded-lg">
<Calendar className="w-5 h-5 text-blue-400" />
</div>
<div>
<p className="text-dark-400 text-sm">Total Schedules</p>
<p className="text-2xl font-bold text-white tabular-nums">{jobs.length}</p>
</div>
</div>
</div>
<div className="bg-dark-800/50 border border-dark-700/50 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-500/15 rounded-lg">
<Play className="w-5 h-5 text-green-400" />
</div>
<div>
<p className="text-dark-400 text-sm">Active</p>
<p className="text-2xl font-bold text-green-400 tabular-nums">{activeJobCount}</p>
</div>
</div>
</div>
<div className="bg-dark-800/50 border border-dark-700/50 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-brand-500/15 rounded-lg">
<RefreshCw className="w-5 h-5 text-brand-400" />
</div>
<div>
<p className="text-dark-400 text-sm">Total Runs</p>
<p className="text-2xl font-bold text-brand-400 tabular-nums">{totalRunCount}</p>
</div>
</div>
</div>
</div>
)}
{/* Jobs List */}
<div style={{ animation: 'fadeSlideIn 0.4s ease-out' }}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white">
Scheduled Jobs
<span className="text-dark-500 text-sm font-normal ml-2">
{jobs.length} job{jobs.length !== 1 ? 's' : ''}
</span>
</h3>
</div>
{loading ? (
<div className="flex items-center justify-center py-16">
<RefreshCw className="w-6 h-6 text-dark-400 animate-spin" />
</div>
) : jobs.length === 0 ? (
<Card>
<div className="text-center py-16" style={{ animation: 'fadeSlideIn 0.4s ease-out' }}>
<div className="w-20 h-20 bg-dark-700/30 rounded-full flex items-center justify-center mx-auto mb-5">
<Calendar className="w-10 h-10 text-dark-500" />
</div>
<p className="text-dark-300 font-semibold text-lg">No scheduled jobs yet</p>
<p className="text-dark-500 text-sm mt-2 max-w-md mx-auto">
Create a schedule to run automated recurring scans against your targets
</p>
<Button className="mt-6" onClick={() => setShowForm(true)}>
<Plus className="w-4 h-4 mr-2" />
Create First Schedule
</Button>
</div>
</Card>
) : (
<div className="space-y-3">
{jobs.map((job, idx) => (
<div
key={job.id}
className="bg-dark-800 border border-dark-700/50 rounded-xl p-5 hover:border-dark-600 transition-colors"
style={{ animation: `fadeSlideIn ${0.2 + idx * 0.06}s ease-out` }}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4 flex-1 min-w-0">
{/* Status indicator */}
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${
job.status === 'active' ? 'bg-green-500/15' : 'bg-yellow-500/15'
}`}>
{job.status === 'active'
? <Play className="w-5 h-5 text-green-400" />
: <Pause className="w-5 h-5 text-yellow-400" />
}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3 flex-wrap">
<p className="font-semibold text-white text-lg truncate">{job.id}</p>
<span className={`px-2.5 py-0.5 text-xs rounded-full font-medium ${
job.status === 'active'
? 'bg-green-500/15 text-green-400 border border-green-500/30'
: 'bg-yellow-500/15 text-yellow-400 border border-yellow-500/30'
}`}>
{job.status}
</span>
<span className="px-2 py-0.5 text-xs rounded bg-dark-700 text-dark-300">
{job.scan_type}
</span>
{job.agent_role && (
<span className="px-2 py-0.5 text-xs rounded bg-brand-500/15 text-brand-400 border border-brand-500/30">
{job.agent_role.replace(/_/g, ' ')}
</span>
)}
</div>
<div className="flex items-center gap-4 mt-2 text-sm text-dark-400 flex-wrap">
<span className="flex items-center gap-1.5">
<Target className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate max-w-[220px]">{job.target}</span>
</span>
<span className="flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
{job.schedule}
</span>
{job.run_count > 0 && (
<span className="flex items-center gap-1.5">
<RefreshCw className="w-3.5 h-3.5 flex-shrink-0" />
{job.run_count} run{job.run_count !== 1 ? 's' : ''}
</span>
)}
</div>
{(job.next_run || job.last_run) && (
<div className="flex items-center gap-4 mt-1.5 text-xs text-dark-500 flex-wrap">
{job.next_run && (
<span title={new Date(job.next_run).toLocaleString()}>
Next: {relativeTime(job.next_run)}
</span>
)}
{job.last_run && (
<span title={new Date(job.last_run).toLocaleString()}>
Last: {relativeTime(job.last_run)}
</span>
)}
</div>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1 flex-shrink-0">
{job.status === 'active' ? (
<button
onClick={() => handlePause(job.id)}
title="Pause schedule"
className="p-2 rounded-lg text-yellow-400 hover:bg-yellow-500/10 transition-colors"
>
<Pause className="w-4 h-4" />
</button>
) : (
<button
onClick={() => handleResume(job.id)}
title="Resume schedule"
className="p-2 rounded-lg text-green-400 hover:bg-green-500/10 transition-colors"
>
<Play className="w-4 h-4" />
</button>
)}
<button
onClick={() => setDeleteTarget(job.id)}
title="Delete schedule"
className="p-2 rounded-lg text-dark-500 hover:text-red-400 hover:bg-red-500/10 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</>
)
}
+1086
View File
File diff suppressed because it is too large Load Diff
+698
View File
@@ -0,0 +1,698 @@
import { useEffect, useState, useCallback, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import {
BookOpen, Plus, Trash2, Play, Search, Tag, Zap, X, Save, RefreshCw,
Layers, Star, PenTool, Inbox, AlertTriangle
} from 'lucide-react'
import Card from '../components/common/Card'
import Button from '../components/common/Button'
import Input from '../components/common/Input'
import Textarea from '../components/common/Textarea'
import { agentApi } from '../services/api'
import type { AgentTask } from '../types'
/* ─── Constants ────────────────────────────────────────────────── */
const CATEGORIES = [
{ id: 'all', name: 'All Tasks', color: 'dark' },
{ id: 'full_auto', name: 'Full Auto', color: 'primary' },
{ id: 'recon', name: 'Reconnaissance', color: 'blue' },
{ id: 'vulnerability', name: 'Vulnerability', color: 'orange' },
{ id: 'custom', name: 'Custom', color: 'purple' },
{ id: 'reporting', name: 'Reporting', color: 'green' }
]
/* ─── Toast System ─────────────────────────────────────────────── */
interface Toast { id: number; message: string; severity: 'info' | 'success' | 'warning' | 'error' }
let _toastId = 0
function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: number) => void }) {
if (toasts.length === 0) return null
const border: Record<string, string> = {
info: 'border-blue-500', success: 'border-green-500',
warning: 'border-yellow-500', error: 'border-red-500',
}
return (
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
{toasts.map(t => (
<div
key={t.id}
className={`bg-dark-800 border-l-4 ${border[t.severity]} rounded-lg px-4 py-3 shadow-xl flex items-start gap-3`}
style={{ animation: 'fadeSlideIn 0.3s ease-out' }}
>
<span className="text-sm text-dark-200 flex-1">{t.message}</span>
<button onClick={() => onDismiss(t.id)} className="text-dark-500 hover:text-white">
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)
}
/* ─── Main Component ───────────────────────────────────────────── */
export default function TaskLibraryPage() {
const navigate = useNavigate()
const [tasks, setTasks] = useState<AgentTask[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [selectedCategory, setSelectedCategory] = useState('all')
const [searchQuery, setSearchQuery] = useState('')
const [selectedTask, setSelectedTask] = useState<AgentTask | null>(null)
const [toasts, setToasts] = useState<Toast[]>([])
// Create task modal
const [showCreateModal, setShowCreateModal] = useState(false)
const [newTask, setNewTask] = useState({
name: '',
description: '',
category: 'custom',
prompt: '',
system_prompt: '',
tags: ''
})
const [creating, setCreating] = useState(false)
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
/* ── Toast helpers ──────────────────────────────────────────── */
const addToast = useCallback((message: string, severity: Toast['severity'] = 'info') => {
const id = ++_toastId
setToasts(prev => [...prev.slice(-4), { id, message, severity }])
setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), 5000)
}, [])
const dismissToast = useCallback((id: number) => {
setToasts(prev => prev.filter(t => t.id !== id))
}, [])
/* ── Data fetch ─────────────────────────────────────────────── */
const loadTasks = useCallback(async () => {
try {
const taskList = await agentApi.tasks.list()
setTasks(taskList)
} catch (error) {
console.error('Failed to load tasks:', error)
}
}, [])
useEffect(() => {
setLoading(true)
loadTasks().finally(() => setLoading(false))
}, [loadTasks])
const handleRefresh = useCallback(async () => {
setRefreshing(true)
await loadTasks()
setRefreshing(false)
}, [loadTasks])
/* ── Derived data ───────────────────────────────────────────── */
const filteredTasks = useMemo(() => {
let filtered = [...tasks]
// Category filter
if (selectedCategory !== 'all') {
filtered = filtered.filter(t => t.category === selectedCategory)
}
// Search filter
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase()
filtered = filtered.filter(t =>
t.name.toLowerCase().includes(query) ||
t.description.toLowerCase().includes(query) ||
t.tags?.some(tag => tag.toLowerCase().includes(query))
)
}
return filtered
}, [tasks, selectedCategory, searchQuery])
const stats = useMemo(() => {
const presetCount = tasks.filter(t => t.is_preset).length
const customCount = tasks.filter(t => !t.is_preset).length
const categoryCounts: Record<string, number> = {}
CATEGORIES.filter(c => c.id !== 'all').forEach(cat => {
categoryCounts[cat.id] = tasks.filter(t => t.category === cat.id).length
})
return { total: tasks.length, presetCount, customCount, categoryCounts }
}, [tasks])
/* ── Handlers ───────────────────────────────────────────────── */
const handleCreateTask = useCallback(async () => {
if (!newTask.name.trim() || !newTask.prompt.trim()) return
setCreating(true)
try {
await agentApi.tasks.create({
name: newTask.name,
description: newTask.description,
category: newTask.category,
prompt: newTask.prompt,
system_prompt: newTask.system_prompt || undefined,
tags: newTask.tags.split(',').map(t => t.trim()).filter(t => t)
})
// Reload tasks
await loadTasks()
setShowCreateModal(false)
setNewTask({
name: '',
description: '',
category: 'custom',
prompt: '',
system_prompt: '',
tags: ''
})
addToast('Task created successfully', 'success')
} catch (error) {
console.error('Failed to create task:', error)
addToast('Failed to create task', 'error')
} finally {
setCreating(false)
}
}, [newTask, loadTasks, addToast])
const handleDeleteTask = useCallback(async (taskId: string) => {
try {
await agentApi.tasks.delete(taskId)
await loadTasks()
setDeleteConfirm(null)
if (selectedTask?.id === taskId) {
setSelectedTask(null)
}
addToast('Task deleted', 'success')
} catch (error) {
console.error('Failed to delete task:', error)
addToast('Failed to delete task', 'error')
}
}, [loadTasks, selectedTask, addToast])
const handleRunTask = useCallback((task: AgentTask) => {
// Navigate to new scan page with task pre-selected
navigate('/scan/new', { state: { selectedTaskId: task.id } })
}, [navigate])
const handleSelectTask = useCallback((task: AgentTask) => {
setSelectedTask(task)
}, [])
const handleClearSearch = useCallback(() => {
setSearchQuery('')
}, [])
/* ── Loading state ──────────────────────────────────────────── */
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full" />
</div>
)
}
/* ── Render ─────────────────────────────────────────────────── */
return (
<div className="space-y-6">
<style>{`
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
`}</style>
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
{/* Header */}
<div
className="flex items-center justify-between"
style={{ animation: 'fadeSlideIn 0.3s ease-out' }}
>
<div>
<h1 className="text-3xl font-bold text-white flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary-500/10">
<BookOpen className="w-7 h-7 text-primary-500" />
</div>
Task Library
</h1>
<p className="text-dark-400 mt-1">Manage and create reusable security testing tasks</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleRefresh}
className="p-2 rounded-lg bg-dark-800 border border-dark-700 text-dark-400 hover:text-white hover:border-dark-500 transition-all"
title="Refresh"
>
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
</button>
<Button onClick={() => setShowCreateModal(true)}>
<Plus className="w-4 h-4 mr-2" />
Create Task
</Button>
</div>
</div>
{/* Stats Row */}
{tasks.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{/* Total Tasks */}
<div
className="bg-dark-800 rounded-xl border border-primary-500/20 p-4"
style={{ animation: 'fadeSlideIn 0.3s ease-out' }}
>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary-500/10">
<Layers className="w-5 h-5 text-primary-500" />
</div>
<div>
<p className="text-xl font-bold text-white tabular-nums">{stats.total}</p>
<p className="text-[11px] text-dark-400">Total Tasks</p>
</div>
</div>
</div>
{/* Presets */}
<div
className="bg-dark-800 rounded-xl border border-yellow-500/20 p-4"
style={{ animation: 'fadeSlideIn 0.3s ease-out 0.05s both' }}
>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-yellow-500/10">
<Star className="w-5 h-5 text-yellow-400" />
</div>
<div>
<p className="text-xl font-bold text-white tabular-nums">{stats.presetCount}</p>
<p className="text-[11px] text-dark-400">Presets</p>
</div>
</div>
</div>
{/* Custom */}
<div
className="bg-dark-800 rounded-xl border border-purple-500/20 p-4"
style={{ animation: 'fadeSlideIn 0.3s ease-out 0.1s both' }}
>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-purple-500/10">
<PenTool className="w-5 h-5 text-purple-400" />
</div>
<div>
<p className="text-xl font-bold text-white tabular-nums">{stats.customCount}</p>
<p className="text-[11px] text-dark-400">Custom</p>
</div>
</div>
</div>
{/* Category Breakdown */}
<div
className="bg-dark-800 rounded-xl border border-dark-700 p-4"
style={{ animation: 'fadeSlideIn 0.3s ease-out 0.15s both' }}
>
<div className="flex flex-col gap-1">
{CATEGORIES.filter(c => c.id !== 'all' && stats.categoryCounts[c.id] > 0).map(cat => {
const colorMap: Record<string, string> = {
primary: 'text-primary-400 bg-primary-500/10',
blue: 'text-blue-400 bg-blue-500/10',
orange: 'text-orange-400 bg-orange-500/10',
purple: 'text-purple-400 bg-purple-500/10',
green: 'text-green-400 bg-green-500/10',
}
const cls = colorMap[cat.color] || 'text-dark-300 bg-dark-700'
return (
<div key={cat.id} className="flex items-center gap-2">
<span className={`text-[10px] uppercase font-bold px-1.5 py-0.5 rounded ${cls}`}>
{cat.id}
</span>
<span className="text-sm text-white font-semibold tabular-nums">
{stats.categoryCounts[cat.id]}
</span>
</div>
)
})}
</div>
</div>
</div>
)}
{/* Filters */}
<Card>
<div className="flex flex-wrap gap-4">
{/* Search */}
<div className="flex-1 min-w-[200px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-dark-400" />
<input
type="text"
placeholder="Search tasks..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-9 py-2 bg-dark-900 border border-dark-700 rounded-lg text-white placeholder-dark-500 focus:border-primary-500 focus:outline-none"
/>
{searchQuery && (
<button
onClick={handleClearSearch}
className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 hover:text-white transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* Category Filter */}
<div className="flex gap-2 flex-wrap">
{CATEGORIES.map((cat) => (
<Button
key={cat.id}
variant={selectedCategory === cat.id ? 'primary' : 'secondary'}
size="sm"
onClick={() => setSelectedCategory(cat.id)}
>
{cat.name}
</Button>
))}
</div>
</div>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Task List */}
<div className="lg:col-span-2 space-y-3">
{filteredTasks.length === 0 ? (
<Card>
<div className="text-center py-12">
{searchQuery || selectedCategory !== 'all' ? (
<>
<Search className="w-14 h-14 mx-auto text-dark-500 mb-4" />
<h3 className="text-lg font-medium text-white mb-2">No Tasks Match</h3>
<p className="text-dark-400 text-sm mb-4">
No tasks found matching your current filters.
</p>
<button
onClick={() => { setSearchQuery(''); setSelectedCategory('all') }}
className="text-primary-500 text-sm hover:underline"
>
Clear filters
</button>
</>
) : (
<>
<Inbox className="w-16 h-16 mx-auto text-dark-500 mb-4" />
<h3 className="text-lg font-medium text-white mb-2">No Tasks Yet</h3>
<p className="text-dark-400 text-sm mb-4">
Create your first reusable security testing task.
</p>
<Button onClick={() => setShowCreateModal(true)}>
<Plus className="w-4 h-4 mr-2" />
Create Task
</Button>
</>
)}
</div>
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2 gap-3">
{filteredTasks.map((task, idx) => (
<div
key={task.id}
onClick={() => handleSelectTask(task)}
className={`bg-dark-800 rounded-lg border p-4 cursor-pointer transition-all ${
selectedTask?.id === task.id
? 'border-primary-500 bg-primary-500/5'
: 'border-dark-700 hover:border-dark-500'
}`}
style={{ animation: `fadeSlideIn 0.3s ease-out ${Math.min(idx * 0.04, 0.4)}s both` }}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-white">{task.name}</span>
{task.is_preset && (
<span className="text-xs bg-primary-500/20 text-primary-400 px-2 py-0.5 rounded">
Preset
</span>
)}
</div>
<p className="text-sm text-dark-400 line-clamp-2">{task.description}</p>
<div className="flex items-center gap-3 mt-3">
<span className={`text-xs px-2 py-0.5 rounded ${
task.category === 'full_auto' ? 'bg-primary-500/20 text-primary-400' :
task.category === 'recon' ? 'bg-blue-500/20 text-blue-400' :
task.category === 'vulnerability' ? 'bg-orange-500/20 text-orange-400' :
task.category === 'reporting' ? 'bg-green-500/20 text-green-400' :
'bg-purple-500/20 text-purple-400'
}`}>
{task.category}
</span>
{task.estimated_tokens > 0 && (
<span className="text-xs text-dark-500 flex items-center gap-1">
<Zap className="w-3 h-3" />
~{task.estimated_tokens} tokens
</span>
)}
</div>
{task.tags?.length > 0 && (
<div className="flex gap-1 mt-2 flex-wrap">
{task.tags.slice(0, 5).map((tag) => (
<span key={tag} className="text-xs bg-dark-700 text-dark-300 px-2 py-0.5 rounded flex items-center gap-1">
<Tag className="w-3 h-3" />
{tag}
</span>
))}
{task.tags.length > 5 && (
<span className="text-xs text-dark-500">+{task.tags.length - 5} more</span>
)}
</div>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation()
handleRunTask(task)
}}
>
<Play className="w-4 h-4" />
</Button>
{!task.is_preset && (
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation()
setDeleteConfirm(task.id)
}}
>
<Trash2 className="w-4 h-4 text-red-400" />
</Button>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Task Details */}
<div>
<Card title="Task Details">
{selectedTask ? (
<div className="space-y-4" style={{ animation: 'fadeSlideIn 0.3s ease-out' }}>
<div>
<p className="text-sm text-dark-400">Name</p>
<p className="text-white font-medium">{selectedTask.name}</p>
</div>
<div>
<p className="text-sm text-dark-400">Description</p>
<p className="text-dark-300">{selectedTask.description}</p>
</div>
<div>
<p className="text-sm text-dark-400">Category</p>
<p className="text-white">{selectedTask.category}</p>
</div>
<div>
<p className="text-sm text-dark-400">Prompt</p>
<pre className="text-xs bg-dark-900 p-3 rounded-lg overflow-auto max-h-60 text-dark-300 whitespace-pre-wrap">
{selectedTask.prompt}
</pre>
</div>
{selectedTask.system_prompt && (
<div>
<p className="text-sm text-dark-400">System Prompt</p>
<pre className="text-xs bg-dark-900 p-3 rounded-lg overflow-auto max-h-40 text-dark-300 whitespace-pre-wrap">
{selectedTask.system_prompt}
</pre>
</div>
)}
{selectedTask.tools_required?.length > 0 && (
<div>
<p className="text-sm text-dark-400">Required Tools</p>
<div className="flex gap-1 flex-wrap mt-1">
{selectedTask.tools_required.map((tool) => (
<span key={tool} className="text-xs bg-dark-700 text-dark-300 px-2 py-1 rounded">
{tool}
</span>
))}
</div>
</div>
)}
<div className="pt-4 border-t border-dark-700">
<Button
className="w-full"
onClick={() => handleRunTask(selectedTask)}
>
<Play className="w-4 h-4 mr-2" />
Run This Task
</Button>
</div>
</div>
) : (
<div className="text-center py-8">
<BookOpen className="w-10 h-10 mx-auto text-dark-500 mb-3" />
<p className="text-dark-400 text-sm">
Select a task to view details
</p>
</div>
)}
</Card>
</div>
</div>
{/* Create Task Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4" onClick={() => setShowCreateModal(false)}>
<div
className="bg-dark-800 rounded-xl border border-dark-700 w-full max-w-2xl max-h-[90vh] overflow-auto"
onClick={e => e.stopPropagation()}
style={{ animation: 'fadeSlideIn 0.2s ease-out' }}
>
<div className="flex items-center justify-between p-4 border-b border-dark-700">
<h3 className="text-xl font-bold text-white">Create New Task</h3>
<Button variant="ghost" size="sm" onClick={() => setShowCreateModal(false)}>
<X className="w-5 h-5" />
</Button>
</div>
<div className="p-4 space-y-4">
<Input
label="Task Name"
placeholder="My Custom Task"
value={newTask.name}
onChange={(e) => setNewTask({ ...newTask, name: e.target.value })}
/>
<Input
label="Description"
placeholder="Brief description of what this task does"
value={newTask.description}
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
/>
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">Category</label>
<select
value={newTask.category}
onChange={(e) => setNewTask({ ...newTask, category: e.target.value })}
className="w-full px-4 py-2 bg-dark-900 border border-dark-700 rounded-lg text-white focus:border-primary-500 focus:outline-none"
>
<option value="custom">Custom</option>
<option value="recon">Reconnaissance</option>
<option value="vulnerability">Vulnerability</option>
<option value="full_auto">Full Auto</option>
<option value="reporting">Reporting</option>
</select>
</div>
<Textarea
label="Prompt"
placeholder="Enter the prompt for the AI agent..."
rows={8}
value={newTask.prompt}
onChange={(e) => setNewTask({ ...newTask, prompt: e.target.value })}
/>
<Textarea
label="System Prompt (Optional)"
placeholder="Enter a system prompt to guide the AI's behavior..."
rows={4}
value={newTask.system_prompt}
onChange={(e) => setNewTask({ ...newTask, system_prompt: e.target.value })}
/>
<Input
label="Tags (comma separated)"
placeholder="pentest, api, auth, custom"
value={newTask.tags}
onChange={(e) => setNewTask({ ...newTask, tags: e.target.value })}
/>
</div>
<div className="flex justify-end gap-3 p-4 border-t border-dark-700">
<Button variant="secondary" onClick={() => setShowCreateModal(false)}>
Cancel
</Button>
<Button
onClick={handleCreateTask}
isLoading={creating}
disabled={!newTask.name.trim() || !newTask.prompt.trim()}
>
<Save className="w-4 h-4 mr-2" />
Create Task
</Button>
</div>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{deleteConfirm && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4" onClick={() => setDeleteConfirm(null)}>
<div
className="bg-dark-800 rounded-xl border border-dark-700 p-6 max-w-md"
onClick={e => e.stopPropagation()}
style={{ animation: 'fadeSlideIn 0.2s ease-out' }}
>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-red-500/10">
<AlertTriangle className="w-5 h-5 text-red-400" />
</div>
<h3 className="text-lg font-semibold text-white">Delete Task?</h3>
</div>
<p className="text-dark-400 mb-6">
Are you sure you want to delete this task? This action cannot be undone.
</p>
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setDeleteConfirm(null)}>
Cancel
</Button>
<Button variant="danger" onClick={() => handleDeleteTask(deleteConfirm)}>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</Button>
</div>
</div>
</div>
)}
</div>
)
}
File diff suppressed because it is too large Load Diff
+1319
View File
File diff suppressed because it is too large Load Diff
+891
View File
@@ -0,0 +1,891 @@
import axios from 'axios'
import type {
Scan, Vulnerability, Prompt, PromptPreset, Report, DashboardStats,
AgentTask, AgentRequest, AgentResponse, AgentStatus, AgentLog, AgentMode,
ScanAgentTask, ActivityFeedItem, ScheduleJob, ScheduleJobRequest, AgentRole,
VulnLabChallenge, VulnLabRunRequest, VulnLabRunResponse, VulnLabRealtimeStatus,
VulnTypeCategory, VulnLabStats, SandboxPoolStatus
} from '../types'
const api = axios.create({
baseURL: '/api/v1',
headers: {
'Content-Type': 'application/json',
},
})
// Scans API
export const scansApi = {
list: async (page = 1, perPage = 10, status?: string) => {
const params = new URLSearchParams({ page: String(page), per_page: String(perPage) })
if (status) params.append('status', status)
const response = await api.get(`/scans?${params}`)
return response.data
},
get: async (scanId: string): Promise<Scan> => {
const response = await api.get(`/scans/${scanId}`)
return response.data
},
create: async (data: {
name?: string
targets: string[]
scan_type?: string
recon_enabled?: boolean
custom_prompt?: string
prompt_id?: string
}): Promise<Scan> => {
const response = await api.post('/scans', data)
return response.data
},
start: async (scanId: string) => {
const response = await api.post(`/scans/${scanId}/start`)
return response.data
},
stop: async (scanId: string) => {
const response = await api.post(`/scans/${scanId}/stop`)
return response.data
},
pause: async (scanId: string) => {
const response = await api.post(`/scans/${scanId}/pause`)
return response.data
},
resume: async (scanId: string) => {
const response = await api.post(`/scans/${scanId}/resume`)
return response.data
},
delete: async (scanId: string) => {
const response = await api.delete(`/scans/${scanId}`)
return response.data
},
skipToPhase: async (scanId: string, phase: string) => {
const response = await api.post(`/scans/${scanId}/skip-to/${phase}`)
return response.data
},
getEndpoints: async (scanId: string, page = 1, perPage = 50) => {
const response = await api.get(`/scans/${scanId}/endpoints?page=${page}&per_page=${perPage}`)
return response.data
},
getVulnerabilities: async (scanId: string, severity?: string, page = 1, perPage = 50) => {
const params = new URLSearchParams({ page: String(page), per_page: String(perPage) })
if (severity) params.append('severity', severity)
const response = await api.get(`/scans/${scanId}/vulnerabilities?${params}`)
return response.data
},
}
// Targets API
export const targetsApi = {
validate: async (url: string) => {
const response = await api.post('/targets/validate', { url })
return response.data
},
validateBulk: async (urls: string[]) => {
const response = await api.post('/targets/validate/bulk', { urls })
return response.data
},
upload: async (file: File) => {
const formData = new FormData()
formData.append('file', file)
const response = await api.post('/targets/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data
},
}
// Prompts API
export const promptsApi = {
getPresets: async (): Promise<PromptPreset[]> => {
const response = await api.get('/prompts/presets')
return response.data
},
getPreset: async (presetId: string) => {
const response = await api.get(`/prompts/presets/${presetId}`)
return response.data
},
parse: async (content: string) => {
const response = await api.post('/prompts/parse', { content })
return response.data
},
list: async (category?: string): Promise<Prompt[]> => {
const params = category ? `?category=${category}` : ''
const response = await api.get(`/prompts${params}`)
return response.data
},
create: async (data: { name: string; description?: string; content: string; category?: string }): Promise<Prompt> => {
const response = await api.post('/prompts', data)
return response.data
},
upload: async (file: File) => {
const formData = new FormData()
formData.append('file', file)
const response = await api.post('/prompts/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data
},
}
// CLI Agent API
export const cliAgentApi = {
getProviders: async (): Promise<{ enabled: boolean; providers: Array<{ id: string; name: string; connected: boolean; account_label?: string; source?: string }>; connected_count: number }> => {
const response = await api.get('/cli-agent/providers')
return response.data
},
getMethodologies: async (): Promise<{ methodologies: Array<{ name: string; path: string; size: number; size_human: string; is_default: boolean }>; total: number }> => {
const response = await api.get('/cli-agent/methodologies')
return response.data
},
}
// Reports API
export const reportsApi = {
list: async (options?: { scanId?: string; autoGenerated?: boolean }): Promise<{ reports: Report[]; total: number }> => {
const params = new URLSearchParams()
if (options?.scanId) params.append('scan_id', options.scanId)
if (options?.autoGenerated !== undefined) params.append('auto_generated', String(options.autoGenerated))
const queryString = params.toString()
const response = await api.get(`/reports${queryString ? `?${queryString}` : ''}`)
return response.data
},
get: async (reportId: string): Promise<Report> => {
const response = await api.get(`/reports/${reportId}`)
return response.data
},
generate: async (data: {
scan_id: string
format?: string
title?: string
include_executive_summary?: boolean
include_poc?: boolean
include_remediation?: boolean
}): Promise<Report> => {
const response = await api.post('/reports', data)
return response.data
},
generateAiReport: async (data: {
scan_id: string
title?: string
preferred_provider?: string
preferred_model?: string
}): Promise<Report> => {
const response = await api.post('/reports/ai-generate', data)
return response.data
},
getViewUrl: (reportId: string) => `/api/v1/reports/${reportId}/view`,
getDownloadUrl: (reportId: string, format: string) => `/api/v1/reports/${reportId}/download/${format}`,
getDownloadZipUrl: (reportId: string) => `/api/v1/reports/${reportId}/download-zip`,
delete: async (reportId: string) => {
const response = await api.delete(`/reports/${reportId}`)
return response.data
},
}
// Dashboard API
export const dashboardApi = {
getStats: async (): Promise<DashboardStats> => {
const response = await api.get('/dashboard/stats')
return response.data
},
getRecent: async (limit = 10) => {
const response = await api.get(`/dashboard/recent?limit=${limit}`)
return response.data
},
getFindings: async (limit = 20, severity?: string) => {
const params = new URLSearchParams({ limit: String(limit) })
if (severity) params.append('severity', severity)
const response = await api.get(`/dashboard/findings?${params}`)
return response.data
},
getVulnerabilityTypes: async () => {
const response = await api.get('/dashboard/vulnerability-types')
return response.data
},
getAgentTasks: async (limit = 20) => {
const response = await api.get(`/dashboard/agent-tasks?limit=${limit}`)
return response.data
},
getActivityFeed: async (limit = 30): Promise<{ activities: ActivityFeedItem[]; total: number }> => {
const response = await api.get(`/dashboard/activity-feed?limit=${limit}`)
return response.data
},
}
// Vulnerabilities API
export const vulnerabilitiesApi = {
getTypes: async () => {
const response = await api.get('/vulnerabilities/types')
return response.data
},
get: async (vulnId: string): Promise<Vulnerability> => {
const response = await api.get(`/vulnerabilities/${vulnId}`)
return response.data
},
validate: async (vulnId: string, validationStatus: string, notes?: string) => {
const response = await api.patch(`/scans/vulnerabilities/${vulnId}/validate`, {
validation_status: validationStatus,
notes,
})
return response.data
},
submitFeedback: async (vulnId: string, isTruePositive: boolean, explanation: string) => {
const response = await api.post(`/scans/vulnerabilities/${vulnId}/feedback`, {
is_true_positive: isTruePositive,
explanation,
})
return response.data
},
getLearningStats: async () => {
const response = await api.get('/scans/vulnerabilities/learning/stats')
return response.data
},
}
// Scan Agent Tasks API (for tracking scan-specific tasks)
export const agentTasksApi = {
list: async (scanId: string, status?: string, taskType?: string): Promise<{ tasks: ScanAgentTask[]; total: number; scan_id: string }> => {
const params = new URLSearchParams()
params.append('scan_id', scanId)
if (status) params.append('status', status)
if (taskType) params.append('task_type', taskType)
const response = await api.get(`/agent-tasks?${params}`)
return response.data
},
get: async (taskId: string): Promise<ScanAgentTask> => {
const response = await api.get(`/agent-tasks/${taskId}`)
return response.data
},
getSummary: async (scanId: string): Promise<{
total: number
pending: number
running: number
completed: number
failed: number
by_type: Record<string, number>
by_tool: Record<string, number>
}> => {
const response = await api.get(`/agent-tasks/summary?scan_id=${scanId}`)
return response.data
},
getTimeline: async (scanId: string): Promise<{ scan_id: string; timeline: ScanAgentTask[]; total: number }> => {
const response = await api.get(`/agent-tasks/scan/${scanId}/timeline`)
return response.data
},
}
// Agent API (Autonomous AI Agent like PentAGI)
export const agentApi = {
// Run the autonomous agent
run: async (request: AgentRequest): Promise<AgentResponse> => {
const response = await api.post('/agent/run', request)
return response.data
},
// Get agent status and results
getStatus: async (agentId: string): Promise<AgentStatus> => {
const response = await api.get(`/agent/status/${agentId}`)
return response.data
},
// Get agent status by scan_id (reverse lookup)
getByScan: async (scanId: string): Promise<AgentStatus | null> => {
try {
const response = await api.get(`/agent/by-scan/${scanId}`)
return response.data
} catch {
return null
}
},
// Get agent logs
getLogs: async (agentId: string, limit = 100): Promise<{ agent_id: string; total_logs: number; logs: AgentLog[] }> => {
const response = await api.get(`/agent/logs/${agentId}?limit=${limit}`)
return response.data
},
// Get findings with details
getFindings: async (agentId: string) => {
const response = await api.get(`/agent/findings/${agentId}`)
return response.data
},
// Delete agent results
delete: async (agentId: string) => {
const response = await api.delete(`/agent/${agentId}`)
return response.data
},
// Stop a running agent
stop: async (agentId: string) => {
const response = await api.post(`/agent/stop/${agentId}`)
return response.data
},
// Pause a running agent
pause: async (agentId: string) => {
const response = await api.post(`/agent/pause/${agentId}`)
return response.data
},
// Resume a paused agent
resume: async (agentId: string) => {
const response = await api.post(`/agent/resume/${agentId}`)
return response.data
},
// Skip to a specific phase
skipToPhase: async (agentId: string, phase: string) => {
const response = await api.post(`/agent/skip-to/${agentId}/${phase}`)
return response.data
},
// Send custom prompt to agent
sendPrompt: async (agentId: string, prompt: string) => {
const response = await api.post(`/agent/prompt/${agentId}`, { prompt })
return response.data
},
// One-click auto pentest
autoPentest: async (target: string, options?: { subdomain_discovery?: boolean; targets?: string[]; auth_type?: string; auth_value?: string; prompt?: string; enable_kali_sandbox?: boolean; custom_prompt_ids?: string[]; preferred_provider?: string; preferred_model?: string; mode?: string; enable_cli_agent?: boolean; cli_agent_provider?: string; methodology_file?: string }): Promise<AgentResponse> => {
const response = await api.post('/agent/run', {
target,
mode: options?.mode || 'auto_pentest',
subdomain_discovery: options?.subdomain_discovery || false,
targets: options?.targets,
auth_type: options?.auth_type,
auth_value: options?.auth_value,
prompt: options?.prompt,
enable_kali_sandbox: options?.enable_kali_sandbox || false,
custom_prompt_ids: options?.custom_prompt_ids,
preferred_provider: options?.preferred_provider || undefined,
preferred_model: options?.preferred_model || undefined,
enable_cli_agent: options?.enable_cli_agent || false,
cli_agent_provider: options?.cli_agent_provider || undefined,
methodology_file: options?.methodology_file || undefined,
})
return response.data
},
// List all active/recent agent sessions
listActive: async (): Promise<{
agents: Array<{
agent_id: string
target: string
status: string
progress: number
phase: string
scan_id: string | null
started_at: string
findings_count: number
mode: string
}>
max_concurrent: number
running_count: number
}> => {
const response = await api.get('/agent/active')
return response.data
},
// Quick synchronous run (for small targets)
quickRun: async (target: string, mode: AgentMode = 'full_auto') => {
const response = await api.post(`/agent/quick?target=${encodeURIComponent(target)}&mode=${mode}`)
return response.data
},
// Get per-vulnerability-type agent statuses (orchestration dashboard)
getVulnAgents: async (agentId: string) => {
const response = await api.get(`/agent/vuln-agents/${agentId}`)
return response.data
},
// Task Library
tasks: {
list: async (category?: string): Promise<AgentTask[]> => {
const params = category ? `?category=${category}` : ''
const response = await api.get(`/agent/tasks${params}`)
return response.data
},
get: async (taskId: string): Promise<AgentTask> => {
const response = await api.get(`/agent/tasks/${taskId}`)
return response.data
},
create: async (task: {
name: string
description: string
category?: string
prompt: string
system_prompt?: string
tags?: string[]
}): Promise<{ message: string; task_id: string }> => {
const response = await api.post('/agent/tasks', task)
return response.data
},
delete: async (taskId: string) => {
const response = await api.delete(`/agent/tasks/${taskId}`)
return response.data
},
},
// Real-time Task API
realtime: {
createSession: async (target: string, name?: string) => {
const response = await api.post('/agent/realtime/session', { target, name })
return response.data
},
sendMessage: async (sessionId: string, message: string) => {
const response = await api.post(`/agent/realtime/${sessionId}/message`, { message })
return response.data
},
getSession: async (sessionId: string) => {
const response = await api.get(`/agent/realtime/${sessionId}`)
return response.data
},
getReport: async (sessionId: string) => {
const response = await api.get(`/agent/realtime/${sessionId}/report`)
return response.data
},
deleteSession: async (sessionId: string) => {
const response = await api.delete(`/agent/realtime/${sessionId}`)
return response.data
},
listSessions: async () => {
const response = await api.get('/agent/realtime/sessions/list')
return response.data
},
getLlmStatus: async () => {
const response = await api.get('/agent/realtime/llm-status')
return response.data
},
getReportHtml: async (sessionId: string) => {
const response = await api.get(`/agent/realtime/${sessionId}/report?format=html`, {
responseType: 'text'
})
return response.data
},
getToolsList: async () => {
const response = await api.get('/agent/realtime/tools/list')
return response.data
},
getToolsStatus: async () => {
const response = await api.get('/agent/realtime/tools/status')
return response.data
},
executeTool: async (sessionId: string, tool: string, options?: Record<string, any>, timeout?: number) => {
const response = await api.post(`/agent/realtime/${sessionId}/execute-tool`, {
tool,
options,
timeout: timeout || 300
})
return response.data
},
},
// History
getHistory: async (page = 1, perPage = 20, targetFilter = '') => {
const params = new URLSearchParams({ page: String(page), per_page: String(perPage) })
if (targetFilter) params.append('target_filter', targetFilter)
const response = await api.get(`/agent/history?${params}`)
return response.data
},
// Triple-check
tripleCheck: async (scanId: string, preferredProvider?: string, preferredModel?: string) => {
const response = await api.post(`/agent/triple-check/${scanId}`, {
preferred_provider: preferredProvider || undefined,
preferred_model: preferredModel || undefined,
})
return response.data
},
}
// Providers API
export const providersApi = {
list: async () => {
const response = await api.get('/providers')
return response.data
},
getStatus: async () => {
const response = await api.get('/providers/status')
return response.data
},
detectAll: async () => {
const response = await api.post('/providers/detect-all')
return response.data
},
detect: async (providerId: string) => {
const response = await api.post(`/providers/${providerId}/detect`)
return response.data
},
connect: async (providerId: string, credential: string, label?: string, modelOverride?: string) => {
const response = await api.post(`/providers/${providerId}/connect`, {
credential,
label: label || 'Manual API Key',
model_override: modelOverride || undefined,
})
return response.data
},
removeAccount: async (providerId: string, accountId: string) => {
const response = await api.delete(`/providers/${providerId}/accounts/${accountId}`)
return response.data
},
testConnection: async (providerId: string, accountId: string) => {
const response = await api.post(`/providers/test/${providerId}/${accountId}`)
return response.data
},
toggle: async (providerId: string, enabled: boolean) => {
const response = await api.post(`/providers/${providerId}/toggle`, { enabled })
return response.data
},
getAvailableModels: async () => {
const response = await api.get('/providers/available-models')
return response.data
},
getEnv: async () => {
const response = await api.get('/providers/env')
return response.data
},
updateEnv: async (key: string, value: string) => {
const response = await api.post('/providers/env', { key, value })
return response.data
},
}
// Vulnerability Lab API
export const vulnLabApi = {
getTypes: async (): Promise<{ categories: Record<string, VulnTypeCategory>; total_types: number }> => {
const response = await api.get('/vuln-lab/types')
return response.data
},
run: async (request: VulnLabRunRequest): Promise<VulnLabRunResponse> => {
const response = await api.post('/vuln-lab/run', request)
return response.data
},
listChallenges: async (filters?: {
vuln_type?: string
vuln_category?: string
status?: string
result?: string
limit?: number
}): Promise<{ challenges: VulnLabChallenge[]; total: number }> => {
const params = new URLSearchParams()
if (filters?.vuln_type) params.append('vuln_type', filters.vuln_type)
if (filters?.vuln_category) params.append('vuln_category', filters.vuln_category)
if (filters?.status) params.append('status', filters.status)
if (filters?.result) params.append('result', filters.result)
if (filters?.limit) params.append('limit', String(filters.limit))
const qs = params.toString()
const response = await api.get(`/vuln-lab/challenges${qs ? `?${qs}` : ''}`)
return response.data
},
getChallenge: async (challengeId: string): Promise<VulnLabRealtimeStatus | VulnLabChallenge> => {
const response = await api.get(`/vuln-lab/challenges/${challengeId}`)
return response.data
},
getStats: async (): Promise<VulnLabStats> => {
const response = await api.get('/vuln-lab/stats')
return response.data
},
stopChallenge: async (challengeId: string) => {
const response = await api.post(`/vuln-lab/challenges/${challengeId}/stop`)
return response.data
},
deleteChallenge: async (challengeId: string) => {
const response = await api.delete(`/vuln-lab/challenges/${challengeId}`)
return response.data
},
getLogs: async (challengeId: string, limit = 100) => {
const response = await api.get(`/vuln-lab/logs/${challengeId}?limit=${limit}`)
return response.data
},
}
// Scheduler API
export const schedulerApi = {
list: async (): Promise<ScheduleJob[]> => {
const response = await api.get('/scheduler/')
return response.data
},
create: async (data: ScheduleJobRequest): Promise<ScheduleJob> => {
const response = await api.post('/scheduler/', data)
return response.data
},
delete: async (jobId: string) => {
const response = await api.delete(`/scheduler/${jobId}`)
return response.data
},
pause: async (jobId: string) => {
const response = await api.post(`/scheduler/${jobId}/pause`)
return response.data
},
resume: async (jobId: string) => {
const response = await api.post(`/scheduler/${jobId}/resume`)
return response.data
},
getAgentRoles: async (): Promise<AgentRole[]> => {
const response = await api.get('/scheduler/agent-roles')
return response.data
},
}
// Terminal Agent API
export const terminalApi = {
createSession: async (target: string, name?: string, template_id?: string) => {
const response = await api.post('/terminal/session', { target, name, template_id })
return response.data
},
listSessions: async () => {
const response = await api.get('/terminal/sessions')
return response.data
},
getSession: async (sessionId: string) => {
const response = await api.get(`/terminal/sessions/${sessionId}`)
return response.data
},
deleteSession: async (sessionId: string) => {
const response = await api.delete(`/terminal/sessions/${sessionId}`)
return response.data
},
sendMessage: async (sessionId: string, message: string) => {
const response = await api.post(`/terminal/sessions/${sessionId}/message`, { message })
return response.data
},
executeCommand: async (sessionId: string, command: string, execution_method: string) => {
const response = await api.post(`/terminal/sessions/${sessionId}/execute`, { command, execution_method })
return response.data
},
addExploitationStep: async (sessionId: string, step: { description: string; command: string; result: string; step_type: string }) => {
const response = await api.post(`/terminal/sessions/${sessionId}/exploitation-path`, step)
return response.data
},
getExploitationPath: async (sessionId: string) => {
const response = await api.get(`/terminal/sessions/${sessionId}/exploitation-path`)
return response.data
},
getVpnStatus: async (sessionId: string) => {
const response = await api.get(`/terminal/sessions/${sessionId}/vpn-status`)
return response.data
},
listTemplates: async () => {
const response = await api.get('/terminal/templates')
return response.data
},
// VPN management
uploadVpnConfig: async (
sessionId: string,
file: File,
username?: string,
password?: string,
) => {
const formData = new FormData()
formData.append('ovpn_file', file)
if (username) formData.append('username', username)
if (password) formData.append('password', password)
const response = await api.post(
`/terminal/sessions/${sessionId}/vpn/upload`,
formData,
{ headers: { 'Content-Type': 'multipart/form-data' } },
)
return response.data
},
connectVpn: async (sessionId: string) => {
const response = await api.post(`/terminal/sessions/${sessionId}/vpn/connect`)
return response.data
},
disconnectVpn: async (sessionId: string) => {
const response = await api.post(`/terminal/sessions/${sessionId}/vpn/disconnect`)
return response.data
},
}
// Sandbox API
export const sandboxApi = {
list: async (): Promise<SandboxPoolStatus> => {
const response = await api.get('/sandbox/')
return response.data
},
healthCheck: async (scanId: string) => {
const response = await api.get(`/sandbox/${scanId}`)
return response.data
},
destroy: async (scanId: string) => {
const response = await api.delete(`/sandbox/${scanId}`)
return response.data
},
cleanup: async () => {
const response = await api.post('/sandbox/cleanup')
return response.data
},
cleanupOrphans: async () => {
const response = await api.post('/sandbox/cleanup-orphans')
return response.data
},
}
// Knowledge API
export const knowledgeApi = {
upload: async (file: File) => {
const formData = new FormData()
formData.append('file', file)
const response = await api.post('/knowledge/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data
},
listDocuments: async () => {
const response = await api.get('/knowledge/documents')
return response.data
},
getDocument: async (docId: string) => {
const response = await api.get(`/knowledge/documents/${docId}`)
return response.data
},
deleteDocument: async (docId: string) => {
const response = await api.delete(`/knowledge/documents/${docId}`)
return response.data
},
search: async (vulnType: string) => {
const response = await api.get(`/knowledge/search?vuln_type=${encodeURIComponent(vulnType)}`)
return response.data
},
getStats: async () => {
const response = await api.get('/knowledge/stats')
return response.data
},
}
// MCP Servers API
export const mcpApi = {
listServers: async () => {
const response = await api.get('/mcp/servers')
return response.data
},
getServer: async (name: string) => {
const response = await api.get(`/mcp/servers/${encodeURIComponent(name)}`)
return response.data
},
createServer: async (data: { name: string; transport: string; command?: string; args?: string[]; url?: string; env?: Record<string, string>; description?: string }) => {
const response = await api.post('/mcp/servers', data)
return response.data
},
updateServer: async (name: string, data: Record<string, unknown>) => {
const response = await api.put(`/mcp/servers/${encodeURIComponent(name)}`, data)
return response.data
},
deleteServer: async (name: string) => {
const response = await api.delete(`/mcp/servers/${encodeURIComponent(name)}`)
return response.data
},
toggleServer: async (name: string) => {
const response = await api.post(`/mcp/servers/${encodeURIComponent(name)}/toggle`)
return response.data
},
testServer: async (name: string) => {
const response = await api.post(`/mcp/servers/${encodeURIComponent(name)}/test`)
return response.data
},
listTools: async (name: string) => {
const response = await api.get(`/mcp/servers/${encodeURIComponent(name)}/tools`)
return response.data
},
}
export default api
+116
View File
@@ -0,0 +1,116 @@
import type { WSMessage } from '../types'
type MessageHandler = (message: WSMessage) => void
class WebSocketService {
private ws: WebSocket | null = null
private handlers: Map<string, Set<MessageHandler>> = new Map()
private reconnectAttempts = 0
private maxReconnectAttempts = 5
private reconnectDelay = 1000
private scanId: string | null = null
connect(scanId: string): void {
if (this.ws?.readyState === WebSocket.OPEN && this.scanId === scanId) {
return
}
this.disconnect()
this.scanId = scanId
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${protocol}//${window.location.host}/ws/scan/${scanId}`
try {
this.ws = new WebSocket(wsUrl)
this.ws.onopen = () => {
console.log('WebSocket connected')
this.reconnectAttempts = 0
}
this.ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data) as WSMessage
this.notifyHandlers(message.type, message)
this.notifyHandlers('*', message) // Wildcard handlers
} catch (e) {
console.error('Failed to parse WebSocket message:', e)
}
}
this.ws.onclose = () => {
console.log('WebSocket disconnected')
this.attemptReconnect()
}
this.ws.onerror = (error) => {
console.error('WebSocket error:', error)
}
} catch (e) {
console.error('Failed to create WebSocket:', e)
}
}
disconnect(): void {
if (this.ws) {
this.ws.close()
this.ws = null
}
this.scanId = null
}
private attemptReconnect(): void {
if (this.reconnectAttempts >= this.maxReconnectAttempts || !this.scanId) {
return
}
this.reconnectAttempts++
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1)
setTimeout(() => {
if (this.scanId) {
console.log(`Attempting reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`)
this.connect(this.scanId)
}
}, delay)
}
subscribe(eventType: string, handler: MessageHandler): () => void {
if (!this.handlers.has(eventType)) {
this.handlers.set(eventType, new Set())
}
this.handlers.get(eventType)!.add(handler)
// Return unsubscribe function
return () => {
this.handlers.get(eventType)?.delete(handler)
}
}
private notifyHandlers(eventType: string, message: WSMessage): void {
const handlers = this.handlers.get(eventType)
if (handlers) {
handlers.forEach((handler) => {
try {
handler(message)
} catch (e) {
console.error('Handler error:', e)
}
})
}
}
send(data: unknown): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(typeof data === 'string' ? data : JSON.stringify(data))
}
}
ping(): void {
this.send('ping')
}
}
export const wsService = new WebSocketService()
export default wsService
+230
View File
@@ -0,0 +1,230 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { Scan, Vulnerability, Endpoint, DashboardStats, ScanAgentTask } from '../types'
interface LogEntry {
level: string
message: string
time: string
}
interface ScanDataCache {
endpoints: Endpoint[]
vulnerabilities: Vulnerability[]
logs: LogEntry[]
agentTasks: ScanAgentTask[]
}
interface ScanState {
currentScan: Scan | null
scans: Scan[]
endpoints: Endpoint[]
vulnerabilities: Vulnerability[]
logs: LogEntry[]
agentTasks: ScanAgentTask[]
scanDataCache: Record<string, ScanDataCache>
isLoading: boolean
error: string | null
setCurrentScan: (scan: Scan | null) => void
setScans: (scans: Scan[]) => void
updateScan: (scanId: string, updates: Partial<Scan>) => void
addEndpoint: (endpoint: Endpoint) => void
addVulnerability: (vulnerability: Vulnerability) => void
setEndpoints: (endpoints: Endpoint[]) => void
setVulnerabilities: (vulnerabilities: Vulnerability[]) => void
addLog: (level: string, message: string) => void
setLogs: (logs: LogEntry[]) => void
addAgentTask: (task: ScanAgentTask) => void
updateAgentTask: (taskId: string, updates: Partial<ScanAgentTask>) => void
setAgentTasks: (tasks: ScanAgentTask[]) => void
setLoading: (loading: boolean) => void
setError: (error: string | null) => void
loadScanData: (scanId: string) => void
saveScanData: (scanId: string) => void
reset: () => void
resetCurrentScan: () => void
getVulnCounts: () => { critical: number; high: number; medium: number; low: number; info: number }
}
export const useScanStore = create<ScanState>()(
persist(
(set, get) => ({
currentScan: null,
scans: [],
endpoints: [],
vulnerabilities: [],
logs: [],
agentTasks: [],
scanDataCache: {},
isLoading: false,
error: null,
setCurrentScan: (scan) => set({ currentScan: scan }),
setScans: (scans) => set({ scans }),
updateScan: (scanId, updates) =>
set((state) => ({
currentScan:
state.currentScan?.id === scanId
? { ...state.currentScan, ...updates }
: state.currentScan,
scans: state.scans.map((s) => (s.id === scanId ? { ...s, ...updates } : s)),
})),
addEndpoint: (endpoint) =>
set((state) => {
const exists = state.endpoints.some(e => e.id === endpoint.id || (e.url === endpoint.url && e.method === endpoint.method))
if (exists) return state
return { endpoints: [...state.endpoints, endpoint] }
}),
addVulnerability: (vulnerability) =>
set((state) => {
const exists = state.vulnerabilities.some(v => v.id === vulnerability.id)
if (exists) return state
return { vulnerabilities: [...state.vulnerabilities, vulnerability] }
}),
setEndpoints: (endpoints) => set({ endpoints }),
setVulnerabilities: (vulnerabilities) => set({ vulnerabilities }),
addLog: (level, message) =>
set((state) => ({
logs: [...state.logs, { level, message, time: new Date().toISOString() }].slice(-200)
})),
setLogs: (logs) => set({ logs }),
// Agent Tasks
addAgentTask: (task) =>
set((state) => {
const exists = state.agentTasks.some(t => t.id === task.id)
if (exists) {
// Update existing task
return {
agentTasks: state.agentTasks.map(t => t.id === task.id ? task : t)
}
}
return { agentTasks: [...state.agentTasks, task] }
}),
updateAgentTask: (taskId, updates) =>
set((state) => ({
agentTasks: state.agentTasks.map(t =>
t.id === taskId ? { ...t, ...updates } : t
)
})),
setAgentTasks: (agentTasks) => set({ agentTasks }),
setLoading: (isLoading) => set({ isLoading }),
setError: (error) => set({ error }),
loadScanData: (scanId) => {
const state = get()
const cached = state.scanDataCache[scanId]
if (cached) {
set({
endpoints: cached.endpoints,
vulnerabilities: cached.vulnerabilities,
logs: cached.logs,
agentTasks: cached.agentTasks || []
})
}
},
saveScanData: (scanId) => {
const state = get()
set({
scanDataCache: {
...state.scanDataCache,
[scanId]: {
endpoints: state.endpoints,
vulnerabilities: state.vulnerabilities,
logs: state.logs,
agentTasks: state.agentTasks
}
}
})
},
reset: () =>
set({
currentScan: null,
scans: [],
endpoints: [],
vulnerabilities: [],
logs: [],
agentTasks: [],
scanDataCache: {},
isLoading: false,
error: null,
}),
resetCurrentScan: () =>
set({
endpoints: [],
vulnerabilities: [],
logs: [],
agentTasks: [],
}),
getVulnCounts: () => {
const vulns = get().vulnerabilities
return {
critical: vulns.filter(v => v.severity === 'critical').length,
high: vulns.filter(v => v.severity === 'high').length,
medium: vulns.filter(v => v.severity === 'medium').length,
low: vulns.filter(v => v.severity === 'low').length,
info: vulns.filter(v => v.severity === 'info').length,
}
}
}),
{
name: 'neurosploit-scan-store',
partialize: (state) => ({
scanDataCache: state.scanDataCache,
scans: state.scans
})
}
)
)
interface DashboardState {
stats: DashboardStats | null
recentScans: Scan[]
recentVulnerabilities: Vulnerability[]
isLoading: boolean
setStats: (stats: DashboardStats) => void
setRecentScans: (scans: Scan[]) => void
setRecentVulnerabilities: (vulns: Vulnerability[]) => void
setLoading: (loading: boolean) => void
}
export const useDashboardStore = create<DashboardState>((set) => ({
stats: null,
recentScans: [],
recentVulnerabilities: [],
isLoading: false,
setStats: (stats) => set({ stats }),
setRecentScans: (recentScans) => set({ recentScans }),
setRecentVulnerabilities: (recentVulnerabilities) => set({ recentVulnerabilities }),
setLoading: (isLoading) => set({ isLoading }),
}))
// ── UI Preferences Store (persisted to localStorage) ──
interface UIState {
sidebarCollapsed: boolean
toggleSidebar: () => void
setSidebarCollapsed: (collapsed: boolean) => void
}
export const useUIStore = create<UIState>()(
persist(
(set) => ({
sidebarCollapsed: false,
toggleSidebar: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
}),
{
name: 'neurosploit-ui-store',
}
)
)
+63
View File
@@ -0,0 +1,63 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-card: #0f3460;
--text-primary: #eee;
--text-secondary: #aaa;
--accent: #e94560;
--border: #333;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
}
* {
box-sizing: border-box;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--bg-card);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--accent);
}
/* Animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.animate-fadeIn {
animation: fadeIn 0.3s ease-out;
}
.animate-pulse {
animation: pulse 2s infinite;
}
+577
View File
@@ -0,0 +1,577 @@
// Scan types
export interface Scan {
id: string
name: string | null
status: 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'stopped'
scan_type: 'quick' | 'full' | 'custom'
recon_enabled: boolean
progress: number
current_phase: string | null
config: Record<string, unknown>
custom_prompt: string | null
prompt_id: string | null
created_at: string
started_at: string | null
completed_at: string | null
duration: number | null // Duration in seconds
error_message: string | null
total_endpoints: number
total_vulnerabilities: number
critical_count: number
high_count: number
medium_count: number
low_count: number
info_count: number
targets: Target[]
}
export interface Target {
id: string
scan_id: string
url: string
hostname: string | null
port: number | null
protocol: string | null
path: string | null
status: string
created_at: string
}
// Vulnerability types
export interface Vulnerability {
id: string
scan_id: string
title: string
vulnerability_type: string
severity: 'critical' | 'high' | 'medium' | 'low' | 'info'
cvss_score: number | null
cvss_vector: string | null
cwe_id: string | null
description: string | null
affected_endpoint: string | null
poc_request: string | null
poc_response: string | null
poc_payload: string | null
poc_parameter: string | null
poc_evidence: string | null
impact: string | null
remediation: string | null
references: string[]
ai_analysis: string | null
poc_code?: string | null
validation_status?: 'ai_confirmed' | 'ai_rejected' | 'validated' | 'false_positive' | 'pending_review'
ai_rejection_reason?: string | null
confidence_score?: number // 0-100 numeric (from agent findings)
confidence_breakdown?: Record<string, number>
proof_of_execution?: string
negative_controls?: string
created_at: string
}
// Endpoint types
export interface Endpoint {
id: string
scan_id: string
url: string
method: string
path: string | null
parameters: unknown[]
response_status: number | null
content_type: string | null
technologies: string[]
discovered_at: string
}
// Prompt types
export interface Prompt {
id: string
name: string
description: string | null
content: string
is_preset: boolean
category: string | null
parsed_vulnerabilities: unknown[]
created_at: string
updated_at: string
}
export interface PromptPreset {
id: string
name: string
description: string
category: string
vulnerability_count: number
}
// Report types
export interface Report {
id: string
scan_id: string
title: string | null
format: 'html' | 'pdf' | 'json'
file_path: string | null
executive_summary: string | null
auto_generated: boolean
is_partial: boolean
generated_at: string
}
// Dashboard types
export interface DashboardStats {
scans: {
total: number
running: number
completed: number
stopped: number
failed: number
pending: number
recent: number
}
vulnerabilities: {
total: number
critical: number
high: number
medium: number
low: number
info: number
recent: number
}
endpoints: {
total: number
}
}
// WebSocket message types
export interface WSMessage {
type: string
scan_id: string
[key: string]: unknown
}
// Scan Agent Task (different from AgentTask which is for the task library)
export interface ScanAgentTask {
id: string
scan_id: string
task_type: 'recon' | 'analysis' | 'testing' | 'reporting'
task_name: string
description: string | null
tool_name: string | null
tool_category: string | null
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
started_at: string | null
completed_at: string | null
duration_ms: number | null
items_processed: number
items_found: number
result_summary: string | null
error_message: string | null
created_at: string
}
// Agent types
export type AgentMode = 'full_auto' | 'recon_only' | 'prompt_only' | 'analyze_only' | 'auto_pentest'
export interface AgentTask {
id: string
name: string
description: string
category: string
prompt: string
system_prompt?: string
tools_required: string[]
tags: string[]
is_preset: boolean
estimated_tokens: number
created_at?: string
updated_at?: string
}
export interface AgentRequest {
target: string
mode: AgentMode
task_id?: string
prompt?: string
auth_type?: 'cookie' | 'bearer' | 'basic' | 'header'
auth_value?: string
custom_headers?: Record<string, string>
max_depth?: number
subdomain_discovery?: boolean
targets?: string[]
enable_kali_sandbox?: boolean
enable_cli_agent?: boolean
cli_agent_provider?: string
}
export interface CLIAgentProvider {
id: string
name: string
connected: boolean
account_label?: string
source?: string
}
export interface MethodologyFile {
name: string
path: string
size: number
size_human: string
is_default: boolean
}
export interface AgentResponse {
agent_id: string
status: string
mode: string
message: string
}
export interface AgentStatus {
agent_id: string
scan_id?: string // Link to database scan
status: 'running' | 'paused' | 'completed' | 'error' | 'stopped'
mode: string
target: string
task?: string
progress: number
phase: string
started_at?: string
completed_at?: string
logs_count: number
findings_count: number
findings: AgentFinding[]
rejected_findings_count?: number
rejected_findings?: AgentFinding[]
report?: AgentReport
error?: string
tool_executions?: ToolExecution[]
container_status?: ContainerStatus
}
export interface ToolExecution {
task_id: string
tool: string
command: string
exit_code: number | null
duration: number | null
findings_count: number
container_id?: string | null
container_name?: string | null
image_digest?: string | null
stdout_preview?: string
stderr_preview?: string
start_time?: string | null
end_time?: string | null
reason?: string
}
export interface ContainerStatus {
online: boolean
container_id?: string | null
container_name?: string | null
}
export interface AgentFinding {
id: string
title: string
severity: 'critical' | 'high' | 'medium' | 'low' | 'info'
vulnerability_type: string
cvss_score: number
cvss_vector: string
cwe_id: string
description: string
affected_endpoint: string
parameter?: string
payload?: string
evidence?: string
request?: string
response?: string
impact: string
poc_code: string
remediation: string
references: string[]
ai_verified: boolean
confidence?: string
confidence_score?: number // 0-100 numeric
confidence_breakdown?: Record<string, number> // Scoring breakdown
proof_of_execution?: string // What proof was found
negative_controls?: string // Control test results
ai_status?: 'confirmed' | 'rejected' | 'pending'
rejection_reason?: string
}
export interface AgentReport {
summary: {
target: string
mode: string
duration: string
total_findings: number
severity_breakdown: Record<string, number>
}
findings: AgentFinding[]
recommendations: string[]
executive_summary?: string
}
export interface AgentLog {
level: string
message: string
time: string
source?: 'script' | 'llm' // Identifies if log is from script or LLM
}
// Real-time Task types
export interface RealtimeMessageMetadata {
error?: boolean
api_error?: boolean
tests_executed?: boolean
new_findings?: number
provider?: string
tool_execution?: boolean
tool?: string
}
export interface RealtimeMessage {
role: 'user' | 'assistant' | 'system' | 'tool'
content: string
timestamp: string
metadata?: RealtimeMessageMetadata
}
export interface RealtimeSession {
session_id: string
name: string
target: string
status: 'active' | 'completed' | 'error'
created_at: string
messages: RealtimeMessage[]
findings: RealtimeFinding[]
recon_data: {
endpoints: Array<{ url: string; status: number; path: string }>
parameters: Record<string, string[]>
technologies: string[]
headers: Record<string, string>
}
}
export interface RealtimeFinding {
title: string
severity: 'critical' | 'high' | 'medium' | 'low' | 'info'
vulnerability_type: string
description: string
affected_endpoint: string
remediation: string
evidence?: string
references?: string[]
cvss_score?: number
cvss_vector?: string
cwe_id?: string
owasp?: string
impact?: string
}
export interface RealtimeSessionSummary {
session_id: string
name: string
target: string
status: string
created_at: string
findings_count: number
messages_count: number
}
// Agent Role type (from config.json)
export interface AgentRole {
id: string
name: string
description: string
tools: string[]
}
// Scheduler types
export interface ScheduleJob {
id: string
target: string
scan_type: string
schedule: string
status: 'active' | 'paused'
next_run: string | null
last_run: string | null
run_count: number
agent_role: string | null
llm_profile: string | null
}
export interface ScheduleJobRequest {
job_id: string
target: string
scan_type: string
cron_expression?: string
interval_minutes?: number
agent_role?: string
llm_profile?: string
}
// Vulnerability Lab types
export interface VulnLabLogEntry {
level: string
message: string
time: string
source: string
}
export interface VulnLabChallenge {
id: string
target_url: string
challenge_name: string | null
vuln_type: string
vuln_category: string | null
auth_type: string | null
status: 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'stopped'
result: 'detected' | 'not_detected' | 'error' | null
agent_id: string | null
scan_id: string | null
findings_count: number
critical_count: number
high_count: number
medium_count: number
low_count: number
info_count: number
findings_detail: Array<{
title: string
vulnerability_type: string
severity: string
affected_endpoint: string
evidence: string
payload?: string
}>
started_at: string | null
completed_at: string | null
duration: number | null
notes: string | null
logs?: VulnLabLogEntry[]
logs_count?: number
endpoints_count?: number
created_at: string | null
}
export interface VulnLabRunRequest {
target_url: string
vuln_type: string
challenge_name?: string
auth_type?: string
auth_value?: string
custom_headers?: Record<string, string>
notes?: string
}
export interface VulnLabRunResponse {
challenge_id: string
agent_id: string
status: string
message: string
}
export interface VulnLabRealtimeStatus {
challenge_id: string
status: string
progress: number
phase: string
findings_count: number
findings: any[]
logs_count: number
logs?: VulnLabLogEntry[]
error: string | null
result: string | null
scan_id: string | null
agent_id: string | null
vuln_type?: string
target?: string
source: string
}
export interface VulnTypeCategory {
label: string
types: Array<{
key: string
title: string
severity: string
cwe_id: string
description: string
}>
count: number
}
export interface VulnLabStats {
total: number
running: number
status_counts: Record<string, number>
result_counts: Record<string, number>
detection_rate: number
by_type: Record<string, { detected: number; not_detected: number; error: number; total: number }>
by_category: Record<string, { detected: number; not_detected: number; error: number; total: number }>
}
// Sandbox Container types
export interface SandboxContainer {
scan_id: string
container_name: string
available: boolean
installed_tools: string[]
created_at: string | null
uptime_seconds: number
}
export interface SandboxPoolStatus {
pool: {
active: number
max_concurrent: number
image: string
container_ttl_minutes: number
docker_available: boolean
}
containers: SandboxContainer[]
error?: string
}
// Activity Feed types
export interface ActivityFeedItem {
type: 'scan' | 'vulnerability' | 'agent_task' | 'report'
action: string
title: string
description: string
status: string | null
severity: 'critical' | 'high' | 'medium' | 'low' | 'info' | null
timestamp: string
scan_id: string
link: string
}
// Vuln Agent Orchestration types
export interface VulnAgentStatus {
name: string
vuln_type: string
status: 'idle' | 'running' | 'completed' | 'failed' | 'cancelled'
progress: number
targets_tested: number
targets_total: number
findings_count: number
tokens_used: number
duration?: number
error?: string
}
export interface VulnOrchestratorStats {
total: number
completed: number
failed: number
cancelled: number
running: number
findings_total: number
elapsed: number
}
export interface VulnAgentDashboard {
enabled: boolean
agents: VulnAgentStatus[]
stats: VulnOrchestratorStats
}
+39
View File
@@ -0,0 +1,39 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#e94560',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
},
dark: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#1a1a2e',
800: '#16213e',
900: '#0f3460',
950: '#0a0a15',
},
},
},
},
plugins: [],
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:8000',
ws: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: true,
},
})