Files
NeuroSploit/frontend/src/pages/ScanDetailsPage.tsx
2026-01-23 15:49:46 -03:00

821 lines
32 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import {
Globe, FileText, StopCircle, RefreshCw, ChevronDown, ChevronRight,
ExternalLink, Copy, Shield, AlertTriangle, Cpu, CheckCircle, XCircle, Clock
} from 'lucide-react'
import Card from '../components/common/Card'
import Button from '../components/common/Button'
import { SeverityBadge } from '../components/common/Badge'
import { scansApi, reportsApi, agentTasksApi } from '../services/api'
import { wsService } from '../services/websocket'
import { useScanStore } from '../store'
import type { Endpoint, Vulnerability, WSMessage, ScanAgentTask, Report } from '../types'
export default function ScanDetailsPage() {
const { scanId } = useParams<{ scanId: string }>()
const navigate = useNavigate()
const {
currentScan, endpoints, vulnerabilities, logs, agentTasks,
setCurrentScan, setEndpoints, setVulnerabilities,
addEndpoint, addVulnerability, addLog, updateScan,
addAgentTask, updateAgentTask, setAgentTasks,
loadScanData, saveScanData, getVulnCounts
} = useScanStore()
const [isGeneratingReport, setIsGeneratingReport] = useState(false)
const [expandedVulns, setExpandedVulns] = useState<Set<string>>(new Set())
const [activeTab, setActiveTab] = useState<'vulns' | 'endpoints' | 'tasks'>('vulns')
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [autoGeneratedReport, setAutoGeneratedReport] = useState<Report | null>(null)
// Calculate vulnerability counts from actual data
const vulnCounts = useMemo(() => getVulnCounts(), [vulnerabilities])
useEffect(() => {
if (!scanId) return
// Try to load cached data first
loadScanData(scanId)
// Fetch initial data from API
const fetchData = async () => {
setIsLoading(true)
setError(null)
try {
const scan = await scansApi.get(scanId)
setCurrentScan(scan)
const [endpointsData, vulnsData, tasksData, reportsData] = await Promise.all([
scansApi.getEndpoints(scanId),
scansApi.getVulnerabilities(scanId),
agentTasksApi.list(scanId).catch(() => ({ tasks: [] })),
reportsApi.list({ scanId, autoGenerated: true }).catch(() => ({ reports: [] }))
])
// Only set if we have data from API
if (endpointsData.endpoints?.length > 0) {
setEndpoints(endpointsData.endpoints)
}
if (vulnsData.vulnerabilities?.length > 0) {
setVulnerabilities(vulnsData.vulnerabilities)
}
if (tasksData.tasks?.length > 0) {
setAgentTasks(tasksData.tasks)
}
// Set auto-generated report if exists
if (reportsData.reports?.length > 0) {
setAutoGeneratedReport(reportsData.reports[0])
}
} catch (err: any) {
console.error('Failed to fetch scan:', err)
setError(err?.response?.data?.detail || 'Failed to load scan')
} finally {
setIsLoading(false)
}
}
fetchData()
// Poll for updates while scan is running
const pollInterval = setInterval(async () => {
if (currentScan?.status === 'running' || !currentScan) {
try {
const scan = await scansApi.get(scanId)
setCurrentScan(scan)
const [endpointsData, vulnsData, tasksData] = await Promise.all([
scansApi.getEndpoints(scanId),
scansApi.getVulnerabilities(scanId),
agentTasksApi.list(scanId).catch(() => ({ tasks: [] }))
])
if (endpointsData.endpoints?.length > 0) {
setEndpoints(endpointsData.endpoints)
}
if (vulnsData.vulnerabilities?.length > 0) {
setVulnerabilities(vulnsData.vulnerabilities)
}
if (tasksData.tasks?.length > 0) {
setAgentTasks(tasksData.tasks)
}
} catch (err) {
console.error('Poll error:', err)
}
}
}, 3000)
// Connect WebSocket for running scans
wsService.connect(scanId)
// Subscribe to events
const unsubscribe = wsService.subscribe('*', (message: WSMessage) => {
switch (message.type) {
case 'progress_update':
updateScan(scanId, {
progress: message.progress as number,
current_phase: message.message as string
})
break
case 'phase_change':
updateScan(scanId, { current_phase: message.phase as string })
addLog('info', `Phase: ${message.phase}`)
break
case 'endpoint_found':
addEndpoint(message.endpoint as Endpoint)
break
case 'vuln_found':
addVulnerability(message.vulnerability as Vulnerability)
addLog('warning', `Found: ${(message.vulnerability as Vulnerability).title}`)
break
case 'stats_update':
// Real-time stats update from backend
if (message.stats) {
const stats = message.stats as {
total_vulnerabilities?: number
critical?: number
high?: number
medium?: number
low?: number
info?: number
total_endpoints?: number
}
updateScan(scanId, {
total_vulnerabilities: stats.total_vulnerabilities,
critical_count: stats.critical,
high_count: stats.high,
medium_count: stats.medium,
low_count: stats.low,
info_count: stats.info,
total_endpoints: stats.total_endpoints
})
}
break
case 'log_message':
addLog(message.level as string, message.message as string)
break
case 'scan_completed':
updateScan(scanId, { status: 'completed', progress: 100 })
addLog('info', 'Scan completed')
// Save data when scan completes
saveScanData(scanId)
break
case 'scan_stopped':
// Handle scan stopped by user
if (message.summary) {
const summary = message.summary as {
total_vulnerabilities?: number
critical?: number
high?: number
medium?: number
low?: number
info?: number
total_endpoints?: number
duration?: number
progress?: number
}
updateScan(scanId, {
status: 'stopped',
progress: summary.progress || currentScan?.progress,
total_vulnerabilities: summary.total_vulnerabilities,
critical_count: summary.critical,
high_count: summary.high,
medium_count: summary.medium,
low_count: summary.low,
info_count: summary.info,
total_endpoints: summary.total_endpoints,
duration: summary.duration
})
} else {
updateScan(scanId, { status: 'stopped' })
}
addLog('warning', 'Scan stopped by user')
saveScanData(scanId)
break
case 'scan_failed':
updateScan(scanId, { status: 'failed' })
addLog('error', `Scan failed: ${message.error || 'Unknown error'}`)
saveScanData(scanId)
break
case 'agent_task':
case 'agent_task_started':
// Handle new or updated agent task
if (message.task) {
addAgentTask(message.task as ScanAgentTask)
}
break
case 'agent_task_completed':
// Handle completed agent task
if (message.task) {
const task = message.task as ScanAgentTask
updateAgentTask(task.id, task)
}
break
case 'report_generated':
// Handle auto-generated report
if (message.report) {
const report = message.report as Report
setAutoGeneratedReport(report)
addLog('info', `Report generated: ${report.title}`)
}
break
case 'error':
addLog('error', message.error as string)
break
}
})
return () => {
// Save data before unmounting
saveScanData(scanId)
unsubscribe()
wsService.disconnect()
clearInterval(pollInterval)
}
}, [scanId])
const handleStopScan = async () => {
if (!scanId) return
try {
await scansApi.stop(scanId)
updateScan(scanId, { status: 'stopped' })
saveScanData(scanId)
} catch (error) {
console.error('Failed to stop scan:', error)
}
}
const handleGenerateReport = async () => {
if (!scanId) return
setIsGeneratingReport(true)
try {
const report = await reportsApi.generate({
scan_id: scanId,
format: 'html',
include_poc: true,
include_remediation: true
})
window.open(reportsApi.getViewUrl(report.id), '_blank')
} catch (error) {
console.error('Failed to generate report:', error)
} finally {
setIsGeneratingReport(false)
}
}
const toggleVuln = (id: string) => {
const newExpanded = new Set(expandedVulns)
if (newExpanded.has(id)) {
newExpanded.delete(id)
} else {
newExpanded.add(id)
}
setExpandedVulns(newExpanded)
}
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text)
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<RefreshCw className="w-8 h-8 animate-spin text-primary-500" />
</div>
)
}
if (error) {
return (
<div className="flex flex-col items-center justify-center h-64">
<AlertTriangle className="w-12 h-12 text-red-500 mb-4" />
<p className="text-xl text-white mb-2">Failed to load scan</p>
<p className="text-dark-400 mb-4">{error}</p>
<Button onClick={() => navigate('/')}>Go to Dashboard</Button>
</div>
)
}
if (!currentScan) {
return (
<div className="flex flex-col items-center justify-center h-64">
<AlertTriangle className="w-12 h-12 text-yellow-500 mb-4" />
<p className="text-xl text-white mb-2">Scan not found</p>
<p className="text-dark-400 mb-4">The scan may still be initializing or does not exist.</p>
<div className="flex gap-2">
<Button onClick={() => window.location.reload()}>Refresh</Button>
<Button variant="secondary" onClick={() => navigate('/')}>Go to Dashboard</Button>
</div>
</div>
)
}
return (
<div className="space-y-6 animate-fadeIn">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
<Shield className="w-6 h-6 text-primary-500" />
{currentScan.name || 'Unnamed Scan'}
</h2>
<div className="flex items-center gap-3 mt-2">
<SeverityBadge severity={currentScan.status} />
<span className="text-dark-400">
Started {new Date(currentScan.created_at).toLocaleString()}
</span>
</div>
</div>
<div className="flex gap-2">
{currentScan.status === 'running' && (
<Button variant="danger" onClick={handleStopScan}>
<StopCircle className="w-4 h-4 mr-2" />
Stop Scan
</Button>
)}
{autoGeneratedReport && (
<Button
variant="secondary"
onClick={() => window.open(reportsApi.getViewUrl(autoGeneratedReport.id), '_blank')}
>
<FileText className="w-4 h-4 mr-2" />
View Report
</Button>
)}
{(currentScan.status === 'completed' || currentScan.status === 'stopped') && (
<Button onClick={handleGenerateReport} isLoading={isGeneratingReport}>
<FileText className="w-4 h-4 mr-2" />
{autoGeneratedReport ? 'New Report' : 'Generate Report'}
</Button>
)}
</div>
</div>
{/* Progress */}
{currentScan.status === 'running' && (
<Card>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-dark-300">{currentScan.current_phase || 'Initializing...'}</span>
<span className="text-white font-medium">{currentScan.progress}%</span>
</div>
<div className="h-2 bg-dark-900 rounded-full overflow-hidden">
<div
className="h-full bg-primary-500 rounded-full transition-all duration-300"
style={{ width: `${currentScan.progress}%` }}
/>
</div>
</div>
</Card>
)}
{/* Auto-generated Report Notification */}
{autoGeneratedReport && (
<div className="bg-green-500/10 border border-green-500/30 rounded-lg p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="bg-green-500/20 rounded-full p-2">
<FileText className="w-5 h-5 text-green-400" />
</div>
<div>
<p className="text-white font-medium">
{autoGeneratedReport.is_partial ? 'Partial Report Generated' : 'Report Generated'}
</p>
<p className="text-sm text-dark-400">
{autoGeneratedReport.title || 'Scan report is ready to view'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={() => window.open(reportsApi.getViewUrl(autoGeneratedReport.id), '_blank')}
>
<ExternalLink className="w-4 h-4 mr-2" />
View Report
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setAutoGeneratedReport(null)}
>
Dismiss
</Button>
</div>
</div>
)}
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-6 gap-4">
<Card>
<div className="text-center">
<p className="text-2xl font-bold text-white">{endpoints.length}</p>
<p className="text-sm text-dark-400">Endpoints</p>
</div>
</Card>
<Card>
<div className="text-center">
<p className="text-2xl font-bold text-white">{vulnerabilities.length}</p>
<p className="text-sm text-dark-400">Total Vulns</p>
</div>
</Card>
<Card>
<div className="text-center">
<p className="text-2xl font-bold text-red-500">{vulnCounts.critical}</p>
<p className="text-sm text-dark-400">Critical</p>
</div>
</Card>
<Card>
<div className="text-center">
<p className="text-2xl font-bold text-orange-500">{vulnCounts.high}</p>
<p className="text-sm text-dark-400">High</p>
</div>
</Card>
<Card>
<div className="text-center">
<p className="text-2xl font-bold text-yellow-500">{vulnCounts.medium}</p>
<p className="text-sm text-dark-400">Medium</p>
</div>
</Card>
<Card>
<div className="text-center">
<p className="text-2xl font-bold text-blue-500">{vulnCounts.low}</p>
<p className="text-sm text-dark-400">Low</p>
</div>
</Card>
</div>
{/* Tabs */}
<div className="flex gap-2 border-b border-dark-700 pb-2">
<Button
variant={activeTab === 'vulns' ? 'primary' : 'ghost'}
onClick={() => setActiveTab('vulns')}
>
<AlertTriangle className="w-4 h-4 mr-2" />
Vulnerabilities ({vulnerabilities.length})
</Button>
<Button
variant={activeTab === 'endpoints' ? 'primary' : 'ghost'}
onClick={() => setActiveTab('endpoints')}
>
<Globe className="w-4 h-4 mr-2" />
Endpoints ({endpoints.length})
</Button>
<Button
variant={activeTab === 'tasks' ? 'primary' : 'ghost'}
onClick={() => setActiveTab('tasks')}
>
<Cpu className="w-4 h-4 mr-2" />
Agent Tasks ({agentTasks.length})
</Button>
</div>
{/* Vulnerabilities Tab */}
{activeTab === 'vulns' && (
<div className="space-y-3">
{vulnerabilities.length === 0 ? (
<Card>
<p className="text-dark-400 text-center py-8">
{currentScan.status === 'running' ? 'Scanning for vulnerabilities...' : 'No vulnerabilities found'}
</p>
</Card>
) : (
vulnerabilities.map((vuln, idx) => (
<div
key={vuln.id || `vuln-${idx}`}
className="bg-dark-800 rounded-lg border border-dark-700 overflow-hidden"
>
{/* Vulnerability Header */}
<div
className="p-4 cursor-pointer hover:bg-dark-750 transition-colors"
onClick={() => toggleVuln(vuln.id || `vuln-${idx}`)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2 flex-1">
{expandedVulns.has(vuln.id || `vuln-${idx}`) ? (
<ChevronDown className="w-4 h-4 mt-1 text-dark-400" />
) : (
<ChevronRight className="w-4 h-4 mt-1 text-dark-400" />
)}
<div className="flex-1 min-w-0">
<p className="font-medium text-white">{vuln.title}</p>
<p className="text-sm text-dark-400 truncate mt-1">{vuln.affected_endpoint}</p>
</div>
</div>
<div className="flex items-center gap-2">
{vuln.cvss_score && (
<span className={`text-sm font-bold px-2 py-0.5 rounded ${
vuln.cvss_score >= 9 ? 'bg-red-500/20 text-red-400' :
vuln.cvss_score >= 7 ? 'bg-orange-500/20 text-orange-400' :
vuln.cvss_score >= 4 ? 'bg-yellow-500/20 text-yellow-400' :
'bg-blue-500/20 text-blue-400'
}`}>
CVSS {vuln.cvss_score.toFixed(1)}
</span>
)}
<SeverityBadge severity={vuln.severity} />
</div>
</div>
</div>
{/* Vulnerability Details */}
{expandedVulns.has(vuln.id || `vuln-${idx}`) && (
<div className="p-4 pt-0 space-y-4 border-t border-dark-700">
{/* Meta Info */}
<div className="flex flex-wrap items-center gap-4 text-sm">
{vuln.vulnerability_type && (
<span className="text-dark-400">
Type: <span className="text-white">{vuln.vulnerability_type}</span>
</span>
)}
{vuln.cwe_id && (
<a
href={`https://cwe.mitre.org/data/definitions/${vuln.cwe_id.replace('CWE-', '')}.html`}
target="_blank"
rel="noopener noreferrer"
className="text-primary-400 hover:underline flex items-center gap-1"
>
{vuln.cwe_id}
<ExternalLink className="w-3 h-3" />
</a>
)}
{vuln.cvss_vector && (
<span className="text-xs bg-dark-700 px-2 py-1 rounded font-mono text-dark-300">
{vuln.cvss_vector}
</span>
)}
</div>
{/* Description */}
{vuln.description && (
<div>
<p className="text-sm font-medium text-dark-300 mb-1">Description</p>
<p className="text-sm text-dark-400">{vuln.description}</p>
</div>
)}
{/* Impact */}
{vuln.impact && (
<div>
<p className="text-sm font-medium text-dark-300 mb-1">Impact</p>
<p className="text-sm text-dark-400">{vuln.impact}</p>
</div>
)}
{/* Proof of Concept */}
{(vuln.poc_request || vuln.poc_payload) && (
<div>
<div className="flex items-center justify-between mb-1">
<p className="text-sm font-medium text-dark-300">Proof of Concept</p>
<Button
variant="ghost"
size="sm"
onClick={() => copyToClipboard(vuln.poc_request || vuln.poc_payload || '')}
>
<Copy className="w-3 h-3 mr-1" />
Copy
</Button>
</div>
{vuln.poc_payload && (
<div className="mb-2">
<p className="text-xs text-dark-500 mb-1">Payload:</p>
<pre className="text-xs bg-dark-900 p-3 rounded overflow-x-auto text-yellow-400 font-mono">
{vuln.poc_payload}
</pre>
</div>
)}
{vuln.poc_request && (
<div>
<p className="text-xs text-dark-500 mb-1">Request:</p>
<pre className="text-xs bg-dark-900 p-3 rounded overflow-x-auto text-dark-300 font-mono">
{vuln.poc_request}
</pre>
</div>
)}
{vuln.poc_response && (
<div className="mt-2">
<p className="text-xs text-dark-500 mb-1">Response:</p>
<pre className="text-xs bg-dark-900 p-3 rounded overflow-x-auto text-dark-300 font-mono max-h-40">
{vuln.poc_response}
</pre>
</div>
)}
</div>
)}
{/* Remediation */}
{vuln.remediation && (
<div>
<p className="text-sm font-medium text-green-400 mb-1">Remediation</p>
<p className="text-sm text-dark-400">{vuln.remediation}</p>
</div>
)}
{/* AI Analysis */}
{vuln.ai_analysis && (
<div>
<p className="text-sm font-medium text-purple-400 mb-1">AI Analysis</p>
<p className="text-sm text-dark-400 whitespace-pre-wrap">{vuln.ai_analysis}</p>
</div>
)}
{/* References */}
{vuln.references?.length > 0 && (
<div>
<p className="text-sm font-medium text-dark-300 mb-1">References</p>
<div className="flex flex-wrap gap-2">
{vuln.references.map((ref, i) => (
<a
key={i}
href={ref}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary-400 hover:underline flex items-center gap-1"
>
{(() => {
try {
return new URL(ref).hostname
} catch {
return ref
}
})()}
<ExternalLink className="w-3 h-3" />
</a>
))}
</div>
</div>
)}
</div>
)}
</div>
))
)}
</div>
)}
{/* Endpoints Tab */}
{activeTab === 'endpoints' && (
<Card title="Discovered Endpoints" subtitle={`${endpoints.length} endpoints found`}>
<div className="space-y-2 max-h-[500px] overflow-auto">
{endpoints.length === 0 ? (
<p className="text-dark-400 text-center py-8">No endpoints discovered yet</p>
) : (
endpoints.map((endpoint, idx) => (
<div
key={endpoint.id || `endpoint-${idx}`}
className="flex items-center gap-3 p-3 bg-dark-900/50 rounded-lg hover:bg-dark-900 transition-colors"
>
<Globe className="w-4 h-4 text-dark-400 flex-shrink-0" />
<span className={`text-xs px-2 py-0.5 rounded font-medium ${
endpoint.method === 'GET' ? 'bg-green-500/20 text-green-400' :
endpoint.method === 'POST' ? 'bg-blue-500/20 text-blue-400' :
endpoint.method === 'PUT' ? 'bg-yellow-500/20 text-yellow-400' :
endpoint.method === 'DELETE' ? 'bg-red-500/20 text-red-400' :
'bg-dark-700 text-dark-300'
}`}>
{endpoint.method}
</span>
<span className="text-sm text-dark-200 truncate flex-1 font-mono">
{endpoint.path || endpoint.url}
</span>
{endpoint.parameters?.length > 0 && (
<span className="text-xs text-dark-500">
{endpoint.parameters.length} params
</span>
)}
{endpoint.content_type && (
<span className="text-xs text-dark-500">{endpoint.content_type}</span>
)}
{endpoint.response_status && (
<span className={`text-xs font-medium ${
endpoint.response_status < 300 ? 'text-green-400' :
endpoint.response_status < 400 ? 'text-yellow-400' :
'text-red-400'
}`}>
{endpoint.response_status}
</span>
)}
</div>
))
)}
</div>
</Card>
)}
{/* Agent Tasks Tab */}
{activeTab === 'tasks' && (
<Card title="Agent Tasks" subtitle={`${agentTasks.length} tasks executed`}>
<div className="space-y-3 max-h-[500px] overflow-auto">
{agentTasks.length === 0 ? (
<p className="text-dark-400 text-center py-8">
{currentScan.status === 'running' ? 'Agent tasks will appear here...' : 'No agent tasks recorded'}
</p>
) : (
agentTasks.map((task, idx) => (
<div
key={task.id || `task-${idx}`}
className="p-4 bg-dark-900/50 rounded-lg border border-dark-700"
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 flex-1">
{/* Status Icon */}
<div className={`mt-0.5 ${
task.status === 'completed' ? 'text-green-400' :
task.status === 'running' ? 'text-blue-400' :
task.status === 'failed' ? 'text-red-400' :
'text-dark-400'
}`}>
{task.status === 'completed' ? <CheckCircle className="w-5 h-5" /> :
task.status === 'running' ? <RefreshCw className="w-5 h-5 animate-spin" /> :
task.status === 'failed' ? <XCircle className="w-5 h-5" /> :
<Clock className="w-5 h-5" />}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-white">{task.task_name}</p>
{task.description && (
<p className="text-sm text-dark-400 mt-1">{task.description}</p>
)}
<div className="flex flex-wrap items-center gap-3 mt-2 text-xs">
{task.tool_name && (
<span className="bg-dark-700 px-2 py-1 rounded text-dark-300">
{task.tool_name}
</span>
)}
<span className={`px-2 py-1 rounded ${
task.task_type === 'recon' ? 'bg-blue-500/20 text-blue-400' :
task.task_type === 'analysis' ? 'bg-purple-500/20 text-purple-400' :
task.task_type === 'testing' ? 'bg-orange-500/20 text-orange-400' :
'bg-green-500/20 text-green-400'
}`}>
{task.task_type}
</span>
{task.duration_ms !== null && (
<span className="text-dark-500">
{task.duration_ms < 1000
? `${task.duration_ms}ms`
: `${(task.duration_ms / 1000).toFixed(1)}s`}
</span>
)}
</div>
</div>
</div>
<div className="text-right">
<span className={`text-xs px-2 py-1 rounded font-medium ${
task.status === 'completed' ? 'bg-green-500/20 text-green-400' :
task.status === 'running' ? 'bg-blue-500/20 text-blue-400' :
task.status === 'failed' ? 'bg-red-500/20 text-red-400' :
'bg-dark-700 text-dark-300'
}`}>
{task.status}
</span>
{(task.items_processed > 0 || task.items_found > 0) && (
<p className="text-xs text-dark-500 mt-2">
{task.items_processed > 0 && `${task.items_processed} processed`}
{task.items_processed > 0 && task.items_found > 0 && ' / '}
{task.items_found > 0 && `${task.items_found} found`}
</p>
)}
</div>
</div>
{task.result_summary && (
<p className="text-xs text-dark-400 mt-3 border-t border-dark-700 pt-3">
{task.result_summary}
</p>
)}
{task.error_message && (
<p className="text-xs text-red-400 mt-3 border-t border-dark-700 pt-3">
Error: {task.error_message}
</p>
)}
</div>
))
)}
</div>
</Card>
)}
{/* Activity Log */}
<Card title="Activity Log">
<div className="space-y-1 max-h-60 overflow-auto font-mono text-xs">
{logs.length === 0 ? (
<p className="text-dark-400 text-center py-4">Waiting for activity...</p>
) : (
logs.map((log, i) => (
<div key={i} className="flex gap-2">
<span className="text-dark-500">{new Date(log.time).toLocaleTimeString()}</span>
<span className={`${
log.level === 'error' ? 'text-red-400' :
log.level === 'warning' ? 'text-yellow-400' :
log.level === 'success' ? 'text-green-400' :
'text-dark-300'
}`}>
{log.message}
</span>
</div>
))
)}
</div>
</Card>
</div>
)
}