mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-05-10 10:37:31 +02:00
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:
Executable
+13
@@ -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>
|
||||
+3467
File diff suppressed because it is too large
Load Diff
Executable
+34
@@ -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"
|
||||
}
|
||||
}
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
Executable
+5
@@ -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 |
Executable
+49
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Executable
+37
@@ -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>
|
||||
}
|
||||
Executable
+70
@@ -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>
|
||||
)
|
||||
}
|
||||
Executable
+27
@@ -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>
|
||||
)
|
||||
}
|
||||
Executable
+41
@@ -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
|
||||
Executable
+41
@@ -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
|
||||
Executable
+30
@@ -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>
|
||||
)
|
||||
}
|
||||
Executable
+21
@@ -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>
|
||||
)
|
||||
}
|
||||
Executable
+145
@@ -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>
|
||||
)
|
||||
}
|
||||
Executable
+13
@@ -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>,
|
||||
)
|
||||
Executable
+1545
File diff suppressed because it is too large
Load Diff
Executable
+1999
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Executable
+662
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">"{name}"</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Executable
+716
@@ -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: https://example1.com 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... 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
Executable
+1107
File diff suppressed because it is too large
Load Diff
Executable
+110
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Executable
+694
@@ -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">"{title}"</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>
|
||||
)
|
||||
}
|
||||
Executable
+617
@@ -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>
|
||||
)
|
||||
}
|
||||
Executable
+1667
File diff suppressed because it is too large
Load Diff
Executable
+954
@@ -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">"{jobId}"</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Executable
+1086
File diff suppressed because it is too large
Load Diff
Executable
+698
@@ -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>
|
||||
)
|
||||
}
|
||||
Executable
+1376
File diff suppressed because it is too large
Load Diff
Executable
+1319
File diff suppressed because it is too large
Load Diff
Executable
+891
@@ -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
|
||||
Executable
+116
@@ -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
|
||||
Executable
+230
@@ -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',
|
||||
}
|
||||
)
|
||||
)
|
||||
Executable
+63
@@ -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;
|
||||
}
|
||||
Executable
+577
@@ -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
|
||||
}
|
||||
Executable
+39
@@ -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: [],
|
||||
}
|
||||
Executable
+25
@@ -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" }]
|
||||
}
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
Executable
+23
@@ -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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user