Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| import { useState, useEffect, useRef, useCallback } from 'react'; | |
| import { Link, useNavigate } from 'react-router-dom'; | |
| import { useAuth } from '../components/AuthContext'; | |
| import { LabelList, ComposedChart, RadialBarChart, RadialBar, RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis, AreaChart, Area, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend, BarChart, Bar } from 'recharts'; | |
| import { Navigate } from 'react-router-dom'; | |
| import { OrganizationSelector } from '../components/OrganizationSelector'; | |
| const ACTIVE_SCAN_KEY = 'wss_active_scan'; // localStorage key for persistence | |
| const CustomBarTooltip = ({ active, payload, label }) => { | |
| if (active && payload && payload.length) { | |
| return ( | |
| <div className="bg-[#0f172a] border border-[#334155] rounded-lg px-3 py-1.5 shadow-xl text-left pointer-events-none"> | |
| <div className="text-[12px] font-bold text-sky-400 leading-tight">{label}</div> | |
| <div className="text-[12px] font-semibold text-slate-200 leading-tight mt-1"> | |
| Findings : <span className="font-extrabold text-white">{payload[0].value}</span> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| return null; | |
| }; | |
| const getCleanDomain = (url) => { | |
| if (!url) return ''; | |
| try { | |
| const u = new URL(url.startsWith('http') ? url : `https://${url}`); | |
| return u.hostname || url; | |
| } catch (_) { | |
| return url.replace(/^https?:\/\//, '').split('/')[0]; | |
| } | |
| }; | |
| export const Dashboard = () => { | |
| const { token, refreshAccessToken, user } = useAuth(); | |
| const navigate = useNavigate(); | |
| useEffect(() => { | |
| if (user?.role === 'executive_user') { | |
| navigate('/scans/history', { replace: true }); | |
| } | |
| }, [user?.role, navigate]); | |
| const [summary, setSummary] = useState(null); | |
| const [recentScans, setRecentScans] = useState([]); | |
| const [activeScan, setActiveScan] = useState(null); | |
| const [liveLogs, setLiveLogs] = useState([]); | |
| const [loading, setLoading] = useState(true); | |
| const [completedScanId, setCompletedScanId] = useState(null); | |
| const [lastUpdated, setLastUpdated] = useState(null); | |
| const [sortColumn, setSortColumn] = useState('Date'); | |
| const [sortDirection, setSortDirection] = useState('desc'); | |
| const logContainerRef = useRef(null); | |
| const logPollRef = useRef(null); | |
| const dashboardPollRef = useRef(null); | |
| const activeScanRef = useRef(null); | |
| const completedScanIdRef = useRef(null); | |
| const liveScanIdsRef = useRef(new Set()); | |
| const markScanAsCompleted = useCallback((id) => { | |
| completedScanIdRef.current = id; | |
| setCompletedScanId(id); | |
| }, []); | |
| // Always read the latest token from localStorage so the poller works even | |
| // after a token refresh (avoids stale closure issues) | |
| const getToken = useCallback(() => | |
| localStorage.getItem('wss_token') || token | |
| , [token]); | |
| // Auto-scroll log terminal when new logs arrive | |
| useEffect(() => { | |
| if (logContainerRef.current) { | |
| logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight; | |
| } | |
| }, [liveLogs]); | |
| // ── Persist active scan to localStorage ───────────────────── | |
| const persistActiveScan = useCallback((scan) => { | |
| if (scan) { | |
| localStorage.setItem(ACTIVE_SCAN_KEY, JSON.stringify({ id: scan.id, target_url: scan.target_url, scan_type: scan.scan_type })); | |
| } else { | |
| localStorage.removeItem(ACTIVE_SCAN_KEY); | |
| } | |
| }, []); | |
| // ── Log Polling ───────────────────────────────────────────── | |
| const stopLogPolling = useCallback(() => { | |
| if (logPollRef.current) { | |
| clearInterval(logPollRef.current); | |
| logPollRef.current = null; | |
| } | |
| }, []); | |
| const startLogPolling = useCallback((scan, isLive = true) => { | |
| stopLogPolling(); | |
| activeScanRef.current = scan; | |
| persistActiveScan(scan); | |
| if (isLive && scan?.id) { | |
| liveScanIdsRef.current.add(scan.id); | |
| } | |
| let consecutiveErrors = 0; | |
| const MAX_ERRORS = 20; // tolerate up to 20 failures (covers token refresh + brief outages) | |
| const pollLogs = async () => { | |
| const currentScan = activeScanRef.current || scan; | |
| if (!currentScan) { stopLogPolling(); return; } | |
| let activeToken = getToken(); | |
| try { | |
| let res = await fetch(`/api/scans/${currentScan.id}/logs`, { | |
| headers: { 'Authorization': `Bearer ${activeToken}` } | |
| }); | |
| // Token expired — try to refresh it silently | |
| if (res.status === 401) { | |
| const newToken = await refreshAccessToken(); | |
| if (newToken) { | |
| activeToken = newToken; | |
| res = await fetch(`/api/scans/${currentScan.id}/logs`, { | |
| headers: { 'Authorization': `Bearer ${newToken}` } | |
| }); | |
| } | |
| } | |
| if (!res.ok) { | |
| consecutiveErrors++; | |
| if (consecutiveErrors >= MAX_ERRORS) stopLogPolling(); | |
| return; | |
| } | |
| consecutiveErrors = 0; | |
| const data = await res.json(); | |
| if (data.logs) { | |
| setLiveLogs(data.logs); | |
| } | |
| if (data.status === 'completed' || data.status === 'failed' || data.status === 'terminated') { | |
| stopLogPolling(); | |
| setActiveScan(null); | |
| activeScanRef.current = null; | |
| persistActiveScan(null); // clear localStorage | |
| if (liveScanIdsRef.current.has(currentScan.id)) { | |
| markScanAsCompleted(currentScan.id); | |
| liveScanIdsRef.current.delete(currentScan.id); | |
| } | |
| fetchDashboard(); | |
| } | |
| } catch (err) { | |
| consecutiveErrors++; | |
| console.error('[Dashboard] Log poll error:', err); | |
| if (consecutiveErrors >= MAX_ERRORS) stopLogPolling(); | |
| } | |
| }; | |
| // Execute immediately on start for instant response without 1.5s delay | |
| pollLogs(); | |
| logPollRef.current = setInterval(pollLogs, 1500); | |
| }, [getToken, refreshAccessToken, stopLogPolling, persistActiveScan, markScanAsCompleted]); | |
| // ── Dashboard Data Fetch ───────────────────────────────────── | |
| const fetchDashboard = useCallback(async () => { | |
| try { | |
| const activeToken = getToken(); | |
| const [summaryRes, historyRes] = await Promise.all([ | |
| fetch('/api/vulnerabilities/summary', { headers: { 'Authorization': `Bearer ${activeToken}` } }), | |
| fetch('/api/scans/history?limit=100', { headers: { 'Authorization': `Bearer ${activeToken}` } }) | |
| ]); | |
| if (!summaryRes.ok || !historyRes.ok) return; | |
| const summaryData = await summaryRes.json(); | |
| const historyData = await historyRes.json(); | |
| setSummary(summaryData.summary); | |
| setRecentScans(historyData.scans || []); | |
| setLastUpdated(new Date()); | |
| // Filter all currently active or queued scans (excluding completed ones) | |
| const runningScans = (historyData.scans || []).filter( | |
| s => (s.status === 'scanning' || s.status === 'queued') && s.id !== completedScanIdRef.current | |
| ); | |
| // Sort by status ('scanning' first) and then earliest creation time (FIFO order) | |
| runningScans.sort((a, b) => { | |
| if (a.status === 'scanning' && b.status !== 'scanning') return -1; | |
| if (b.status === 'scanning' && a.status !== 'scanning') return 1; | |
| return new Date(a.started_at || a.created_at || 0) - new Date(b.started_at || b.created_at || 0); | |
| }); | |
| if (runningScans.length > 0) { | |
| runningScans.forEach(s => liveScanIdsRef.current.add(s.id)); | |
| // If we are ALREADY tracking an active scan that is still in progress, STICK WITH IT! | |
| const currentStillRunning = activeScanRef.current | |
| ? runningScans.find(s => s.id === activeScanRef.current.id) | |
| : null; | |
| if (currentStillRunning) { | |
| // Keep current active scan without resetting logs or switching | |
| setActiveScan(currentStillRunning); | |
| activeScanRef.current = currentStillRunning; | |
| } else { | |
| // No active scan tracked currently — pick the earliest scan in queue | |
| const nextScan = runningScans[0]; | |
| setActiveScan(nextScan); | |
| activeScanRef.current = nextScan; | |
| setLiveLogs([]); | |
| startLogPolling(nextScan, true); | |
| } | |
| } else { | |
| // No active scans remaining | |
| localStorage.removeItem(ACTIVE_SCAN_KEY); | |
| if (activeScanRef.current) { | |
| setActiveScan(null); | |
| activeScanRef.current = null; | |
| persistActiveScan(null); | |
| stopLogPolling(); | |
| } | |
| } | |
| } catch (err) { | |
| console.error('[Dashboard] Fetch error:', err); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }, [getToken, startLogPolling, stopLogPolling, persistActiveScan]); | |
| // ── Mount — recover active scan from localStorage ───────────────────────── | |
| useEffect(() => { | |
| // 1. Immediately try to restore a previously active scan from localStorage | |
| // so LIVE AUDIT appears instantly even after refresh or re-login. | |
| const stored = localStorage.getItem(ACTIVE_SCAN_KEY); | |
| if (stored && token) { | |
| try { | |
| const storedScan = JSON.parse(stored); | |
| if (storedScan && storedScan.id) { | |
| setActiveScan(storedScan); | |
| activeScanRef.current = storedScan; | |
| startLogPolling(storedScan, false); // isLive = false, prevent stale error banner | |
| } | |
| } catch (_) { | |
| localStorage.removeItem(ACTIVE_SCAN_KEY); | |
| } | |
| } | |
| // 2. Then do the normal full dashboard fetch | |
| fetchDashboard(); | |
| dashboardPollRef.current = setInterval(fetchDashboard, 5000); | |
| return () => { | |
| clearInterval(dashboardPollRef.current); | |
| stopLogPolling(); | |
| }; | |
| }, [fetchDashboard, stopLogPolling]); // intentionally shallow — only run on mount | |
| // ── Derived values ─────────────────────────────────────────── | |
| if (loading) { | |
| return ( | |
| <div className="flex items-center justify-center py-2xl font-label-md text-label-md text-on-surface-variant"> | |
| <span className="material-symbols-outlined animate-spin mr-sm">sync</span> | |
| Loading Security Console... | |
| </div> | |
| ); | |
| } | |
| const counts = summary?.vulnerabilities_count || { critical: 0, high: 0, medium: 0, low: 0, total: 0 }; | |
| // Real-time dynamic security score calculation | |
| const rawScore = summary?.average_security_score; | |
| const hasCompletedScans = (summary?.scans_count > 0 || recentScans.some(s => s.status === 'completed')) && rawScore !== null && rawScore !== undefined; | |
| const score = hasCompletedScans ? Math.round(rawScore) : null; | |
| const dashOffset = hasCompletedScans ? (283 - (283 * score) / 100) : 283; | |
| let scoreDisplay = hasCompletedScans ? score : 'N/A'; | |
| let scoreLabel = hasCompletedScans ? 'Excellent' : 'Not Tested Yet'; | |
| let scoreColorClass = hasCompletedScans ? 'text-primary bg-primary/10' : 'text-slate-400 bg-slate-500/10 border border-slate-500/20'; | |
| let scoreSubtext = hasCompletedScans | |
| ? (score >= 80 ? 'System Protected' : 'Remediation Required') | |
| : 'No Scans Performed Yet'; | |
| if (hasCompletedScans) { | |
| if (score < 50) { scoreLabel = 'Critical'; scoreColorClass = 'text-error bg-error/10'; } | |
| else if (score < 80) { scoreLabel = 'Warning'; scoreColorClass = 'text-tertiary bg-tertiary/10'; } | |
| } | |
| const getRatingGrade = (s) => { | |
| if (s === null || s === undefined) return '--'; | |
| if (s >= 90) return 'A'; if (s >= 80) return 'B'; | |
| if (s >= 70) return 'C'; if (s >= 50) return 'D'; return 'F'; | |
| }; | |
| const ratingColor = (g) => ({ | |
| A: 'text-green-600', B: 'text-green-500', C: 'text-yellow-600', | |
| D: 'text-orange-600', F: 'text-red-600' | |
| }[g] || 'text-slate-400'); | |
| // Log line coloring — match exactly what backend writes | |
| const getLogColor = (log) => { | |
| if (log.includes('[VULN]')) return 'text-red-400 font-semibold'; | |
| if (log.includes('[WARN]')) return 'text-yellow-400'; | |
| if (log.includes('[SUCCESS]')) return 'text-green-400 font-semibold'; | |
| if (log.includes('[INFO]')) return 'text-blue-300'; | |
| if (log.includes('[ERROR]')) return 'text-red-500 font-bold'; | |
| return 'text-slate-300'; | |
| }; | |
| // Chart: last 7 days line chart data | |
| const buildChart = () => { | |
| const days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; | |
| const today = new Date(); | |
| const buckets = Array.from({ length: 7 }, (_, i) => { | |
| const d = new Date(today); d.setDate(today.getDate() - (6 - i)); | |
| return { date: d, name: days[d.getDay()], Scans: 0, Threats: 0 }; | |
| }); | |
| recentScans.forEach(scan => { | |
| if (!scan.started_at) return; | |
| const sd = new Date(scan.started_at); | |
| const b = buckets.find(b => b.date.toDateString() === sd.toDateString()); | |
| if (b) { | |
| b.Scans++; | |
| const v = scan.vulnerabilities_count || {}; | |
| b.Threats += (v.critical||0) + (v.high||0) + (v.medium||0) + (v.low||0); | |
| } | |
| }); | |
| return buckets; | |
| }; | |
| const chartData = buildChart(); | |
| // Derived totals | |
| const totalCounts = counts.critical + counts.high + counts.medium + counts.low; | |
| const handleSort = (column) => { | |
| if (column === 'Severity' || column === 'Actions') return; | |
| if (sortColumn === column) { | |
| setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); | |
| } else { | |
| setSortColumn(column); | |
| setSortDirection('asc'); | |
| } | |
| }; | |
| const getSortedScans = () => { | |
| return [...recentScans].sort((a, b) => { | |
| let aVal, bVal; | |
| switch (sortColumn) { | |
| case 'Status': | |
| aVal = a.status || ''; bVal = b.status || ''; break; | |
| case 'Target URL': | |
| aVal = a.target_url || ''; bVal = b.target_url || ''; break; | |
| case 'Scan Profile': | |
| aVal = a.scan_type || ''; bVal = b.scan_type || ''; break; | |
| case 'Date': | |
| aVal = new Date(a.started_at || 0).getTime(); bVal = new Date(b.started_at || 0).getTime(); break; | |
| case 'Rating': | |
| aVal = a.security_score || 0; bVal = b.security_score || 0; break; | |
| default: | |
| return 0; | |
| } | |
| if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1; | |
| if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1; | |
| return 0; | |
| }); | |
| }; | |
| if (user?.role === 'executive_user') { | |
| return null; | |
| } | |
| return ( | |
| <div className="flex flex-col gap-gutter text-left"> | |
| {/* Page Header */} | |
| <div className="flex flex-col md:flex-row md:items-end justify-between gap-sm mb-sm"> | |
| <div> | |
| <h2 className="font-headline-lg-mobile md:font-headline-lg text-headline-lg-mobile md:text-headline-lg text-on-surface font-bold tracking-tight"> | |
| Security Dashboard | |
| </h2> | |
| <p className="font-body-sm text-body-sm text-on-surface-variant mt-xs"> | |
| Real-time infrastructure health and vulnerability monitoring. | |
| </p> | |
| </div> | |
| <div className="flex items-center gap-sm"> | |
| <OrganizationSelector /> | |
| <div className="flex items-center gap-xs text-on-surface-variant bg-surface-container-low px-sm py-xs rounded-md border border-outline-variant"> | |
| <span className="material-symbols-outlined" style={{ fontSize: '16px' }}>schedule</span> | |
| <span className="font-label-sm text-label-sm uppercase"> | |
| {lastUpdated ? `Updated ${lastUpdated.toLocaleTimeString()}` : 'Loading...'} | |
| </span> | |
| </div> | |
| </div> | |
| </div> | |
| {/* ── Live Scan Terminal ── */} | |
| {activeScan && ( | |
| (() => { | |
| const runningScansList = recentScans.filter( | |
| s => (s.status === 'scanning' || s.status === 'queued') && s.id !== completedScanIdRef.current | |
| ); | |
| return ( | |
| <div className="w-full bg-[#020617] border border-[#1e293b] rounded-xl overflow-hidden shadow-2xl"> | |
| <div className="bg-[#0f172a] px-md py-sm border-b border-[#1e293b] flex items-center justify-between gap-sm min-w-0"> | |
| {/* Left Section: Live Audit Title + Scrollable Scan Tabs */} | |
| <div className="flex items-center gap-sm min-w-0 flex-1 overflow-hidden"> | |
| <div className="flex items-center gap-sm shrink-0"> | |
| <span className="relative flex h-3 w-3"> | |
| <span className={`animate-ping absolute inline-flex h-full w-full rounded-full ${activeScan.status === 'scanning' ? 'bg-red-400' : 'bg-yellow-400'} opacity-75`}></span> | |
| <span className={`relative inline-flex rounded-full h-3 w-3 ${activeScan.status === 'scanning' ? 'bg-red-500' : 'bg-yellow-500'}`}></span> | |
| </span> | |
| <span className="font-label-md text-label-md text-white font-bold tracking-tight ml-1 whitespace-nowrap"> | |
| LIVE AUDIT — {activeScan.target_url} | |
| </span> | |
| </div> | |
| {/* Interactive Scan Tabs when multiple scans are active or queued (Scrollable horizontally) */} | |
| {runningScansList.length > 1 && ( | |
| <div className="flex items-center gap-1 bg-[#020617] p-1 rounded-lg border border-[#1e293b] overflow-x-auto scrollbar-none shrink min-w-0 max-w-full"> | |
| {runningScansList.map((s) => { | |
| const isSelected = activeScan.id === s.id; | |
| const domainName = getCleanDomain(s.target_url); | |
| return ( | |
| <button | |
| key={s.id} | |
| onClick={() => { | |
| if (activeScan.id !== s.id) { | |
| setActiveScan(s); | |
| activeScanRef.current = s; | |
| setLiveLogs([]); | |
| startLogPolling(s); | |
| } | |
| }} | |
| className={`px-2.5 py-1 rounded text-xs font-mono transition-all flex items-center gap-1.5 cursor-pointer shrink-0 whitespace-nowrap ${ | |
| isSelected | |
| ? 'bg-sky-500/20 text-sky-300 border border-sky-500/40 font-semibold shadow-sm shadow-sky-500/20' | |
| : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/80 border border-transparent' | |
| }`} | |
| title={`Click to inspect audit logs for ${s.target_url}`} | |
| > | |
| <span className={`h-2 w-2 rounded-full ${s.status === 'scanning' ? 'bg-emerald-400 animate-pulse' : 'bg-yellow-400'}`} /> | |
| <span className="max-w-[130px] truncate">{domainName}</span> | |
| <span className="text-[10px] uppercase opacity-75"> | |
| [{s.status === 'scanning' ? 'Active' : 'Queued'}] | |
| </span> | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| </div> | |
| {/* Right Section: QUICK SCAN, +N QUEUED NEXT, ● RUNNING (Pinned strictly to the right side) */} | |
| <div className="flex items-center gap-xs md:gap-sm shrink-0 ml-auto"> | |
| <span className="font-label-sm text-label-sm text-slate-400 bg-slate-800 px-sm py-[2px] rounded uppercase shrink-0 whitespace-nowrap"> | |
| {activeScan.scan_type} Scan | |
| </span> | |
| {/* Clickable +N Queued Next button to switch to the next queued scan */} | |
| {runningScansList.filter(s => s.id !== activeScan.id).length > 0 && ( | |
| <button | |
| onClick={() => { | |
| const remaining = runningScansList.filter(s => s.id !== activeScan.id); | |
| if (remaining.length > 0) { | |
| const nextTarget = remaining[0]; | |
| setActiveScan(nextTarget); | |
| activeScanRef.current = nextTarget; | |
| setLiveLogs([]); | |
| startLogPolling(nextTarget); | |
| } | |
| }} | |
| className="font-label-sm text-label-sm text-sky-400 bg-sky-950/40 border border-sky-800/60 hover:bg-sky-900/60 hover:border-sky-700 px-sm py-[2px] rounded uppercase cursor-pointer transition-colors flex items-center gap-1 group shrink-0 whitespace-nowrap" | |
| title="Click to view next queued scan in audit terminal" | |
| > | |
| <span>+{runningScansList.filter(s => s.id !== activeScan.id).length} Queued Next</span> | |
| <span className="material-symbols-outlined text-xs group-hover:translate-x-0.5 transition-transform">arrow_forward</span> | |
| </button> | |
| )} | |
| <span className={`font-label-sm text-label-sm ${activeScan.status === 'scanning' ? 'text-yellow-400 bg-yellow-400/10 border-yellow-400/20' : 'text-sky-400 bg-sky-400/10 border-sky-400/20'} border px-sm py-[2px] rounded uppercase animate-pulse shrink-0 whitespace-nowrap`}> | |
| ● {activeScan.status === 'scanning' ? 'Running' : 'Queued'} | |
| </span> | |
| </div> | |
| </div> | |
| <div | |
| ref={logContainerRef} | |
| className="p-md font-mono text-[12.5px] leading-relaxed h-56 overflow-y-auto flex flex-col gap-[2px] scroll-smooth custom-scrollbar" | |
| > | |
| {liveLogs.length === 0 ? ( | |
| <div className="text-slate-500 animate-pulse"> | |
| {activeScan.status === 'queued' ? '⏳ Waiting in execution queue (prior scan running)...' : '⏳ Spawning audit worker threads...'} | |
| </div> | |
| ) : ( | |
| liveLogs.map((log, i) => ( | |
| <div key={i} className={`${getLogColor(log)} leading-snug`}>{log}</div> | |
| )) | |
| )} | |
| </div> | |
| <div className="bg-[#0a0f1e] border-t border-[#1e293b] px-md py-xs flex items-center justify-between"> | |
| <span className="font-label-sm text-label-sm text-slate-500">{liveLogs.length} log entries</span> | |
| <span className="text-slate-400 text-xs animate-pulse"> | |
| ● {activeScan.status === 'scanning' ? 'Scanning in progress...' : 'Queued — awaiting execution...'} | |
| </span> | |
| </div> | |
| </div> | |
| ); | |
| })() | |
| )} | |
| {/* ── Scan Complete Banner ── */} | |
| {completedScanId && !activeScan && ( | |
| (() => { | |
| const scanData = recentScans.find(s => s.id === completedScanId); | |
| const isFailed = scanData?.status === 'failed'; | |
| const isTerminated = scanData?.status === 'terminated'; | |
| const handleDismiss = () => { | |
| setCompletedScanId(null); | |
| completedScanIdRef.current = null; | |
| localStorage.removeItem(ACTIVE_SCAN_KEY); | |
| }; | |
| if (isTerminated) { | |
| return ( | |
| <div className="w-full bg-red-50 border border-red-200 rounded-xl p-md flex items-center justify-between shadow-sm"> | |
| <div className="flex items-center gap-sm"> | |
| <span className="material-symbols-outlined text-red-600 text-[28px]" style={{ fontVariationSettings: "'FILL' 1" }}>error</span> | |
| <div> | |
| <p className="font-label-md text-label-md text-red-900 font-bold">Scanner Terminated</p> | |
| <p className="font-body-sm text-body-sm text-red-700">Sorry, due to some problemes we terminate your scanning and also show that scanner is terminated.</p> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-sm"> | |
| <Link | |
| to={`/scans/results?id=${completedScanId}`} | |
| className="bg-red-600 hover:bg-red-700 text-white font-label-md text-label-md px-lg py-sm rounded-lg flex items-center gap-sm transition-colors font-bold border-0" | |
| style={{ textDecoration: 'none' }} | |
| > | |
| <span className="material-symbols-outlined text-[18px]">open_in_new</span> | |
| View Details | |
| </Link> | |
| <button | |
| onClick={handleDismiss} | |
| className="p-sm text-red-600 hover:bg-red-100 rounded-lg transition-colors border-0 bg-transparent cursor-pointer flex items-center justify-center" | |
| title="Dismiss notification" | |
| > | |
| <span className="material-symbols-outlined text-[20px]">close</span> | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| if (isFailed) { | |
| return ( | |
| <div className="w-full bg-red-50 border border-red-200 rounded-xl p-md flex items-center justify-between shadow-sm"> | |
| <div className="flex items-center gap-sm"> | |
| <span className="material-symbols-outlined text-red-600 text-[28px]" style={{ fontVariationSettings: "'FILL' 1" }}>error</span> | |
| <div> | |
| <p className="font-label-md text-label-md text-red-900 font-bold">Scan Failed!</p> | |
| <p className="font-body-sm text-body-sm text-red-700">The scanner encountered a critical error. View logs for details.</p> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-sm"> | |
| <Link | |
| to={`/scans/results?id=${completedScanId}`} | |
| className="bg-red-600 hover:bg-red-700 text-white font-label-md text-label-md px-lg py-sm rounded-lg flex items-center gap-sm transition-colors font-bold border-0" | |
| style={{ textDecoration: 'none' }} | |
| > | |
| <span className="material-symbols-outlined text-[18px]">open_in_new</span> | |
| View Details | |
| </Link> | |
| <button | |
| onClick={handleDismiss} | |
| className="p-sm text-red-600 hover:bg-red-100 rounded-lg transition-colors border-0 bg-transparent cursor-pointer flex items-center justify-center" | |
| title="Dismiss notification" | |
| > | |
| <span className="material-symbols-outlined text-[20px]">close</span> | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="w-full bg-green-50 border border-green-200 rounded-xl p-md flex items-center justify-between shadow-sm"> | |
| <div className="flex items-center gap-sm"> | |
| <span className="material-symbols-outlined text-green-600 text-[28px]" style={{ fontVariationSettings: "'FILL' 1" }}>check_circle</span> | |
| <div> | |
| <p className="font-label-md text-label-md text-green-900 font-bold">Scan Complete!</p> | |
| <p className="font-body-sm text-body-sm text-green-700">Vulnerability analysis finished. View the full report below.</p> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-sm"> | |
| <Link | |
| to={`/scans/results?id=${completedScanId}`} | |
| className="bg-green-600 hover:bg-green-700 text-white font-label-md text-label-md px-lg py-sm rounded-lg flex items-center gap-sm transition-colors font-bold border-0" | |
| style={{ textDecoration: 'none' }} | |
| > | |
| <span className="material-symbols-outlined text-[18px]">open_in_new</span> | |
| View Full Report | |
| </Link> | |
| <button | |
| onClick={handleDismiss} | |
| className="p-sm text-green-600 hover:bg-green-100 rounded-lg transition-colors border-0 bg-transparent cursor-pointer flex items-center justify-center" | |
| title="Dismiss notification" | |
| > | |
| <span className="material-symbols-outlined text-[20px]">close</span> | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| })() | |
| )} | |
| {/* ── Bento Grid ── */} | |
| <div className="grid grid-cols-1 md:grid-cols-12 gap-gutter"> | |
| {/* Security Score Gauge */} | |
| <div className="md:col-span-6 bg-surface-container-lowest border border-outline-variant rounded-xl p-lg flex flex-col justify-between shadow-sm relative overflow-hidden"> | |
| <div className="absolute top-0 left-0 w-full h-full bg-gradient-to-br from-primary/5 to-transparent pointer-events-none"></div> | |
| <div> | |
| <h3 className="font-headline-md text-headline-md text-on-surface tracking-tight">Security Score</h3> | |
| <p className="font-body-sm text-body-sm text-on-surface-variant mt-xs">Overall system resilience</p> | |
| </div> | |
| {!hasCompletedScans ? ( | |
| <div className="flex-grow flex flex-col items-center justify-center py-md text-center"> | |
| <div className="relative w-44 h-44 flex items-center justify-center my-xs"> | |
| {/* Track Circle */} | |
| <svg className="w-full h-full absolute transform -rotate-90" viewBox="0 0 100 100"> | |
| <circle cx="50" cy="50" fill="none" r="42" stroke="currentColor" strokeWidth="6" className="text-slate-200 dark:text-slate-700/60" /> | |
| </svg> | |
| {/* Sleek Dashed Radar Ring */} | |
| <svg className="w-full h-full absolute transform -rotate-90" viewBox="0 0 100 100"> | |
| <circle | |
| cx="50" cy="50" fill="none" r="42" | |
| stroke="#94a3b8" | |
| strokeDasharray="6 4" | |
| strokeWidth="6" | |
| className="opacity-40" | |
| /> | |
| </svg> | |
| {/* Inner Content */} | |
| <div className="text-center flex flex-col items-center justify-center z-10 px-xs"> | |
| <div className="w-8 h-8 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center mb-1 text-slate-400 dark:text-slate-500 shadow-inner"> | |
| <span className="material-symbols-outlined text-[18px]">shield</span> | |
| </div> | |
| <span className="font-extrabold text-2xl tracking-tight text-slate-400 dark:text-slate-500 leading-none mb-1.5"> | |
| N/A | |
| </span> | |
| <span className="inline-block px-2.5 py-0.5 rounded-full text-[10px] font-bold tracking-wider uppercase bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20"> | |
| NOT TESTED YET | |
| </span> | |
| </div> | |
| </div> | |
| {/* Subtext and Action */} | |
| <div className="mt-1 flex flex-col items-center gap-2"> | |
| <p className="text-body-sm text-on-surface-variant font-medium"> | |
| No security audit performed on this workspace yet. | |
| </p> | |
| <button | |
| onClick={() => navigate('/scans/new')} | |
| className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-xs font-semibold bg-primary text-white hover:bg-primary/90 transition-all shadow-xs cursor-pointer" | |
| > | |
| <span className="material-symbols-outlined text-[15px]">radar</span> | |
| Run Initial Scan | |
| </button> | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="flex-grow flex flex-col items-center justify-center py-xl"> | |
| <div className="relative w-48 h-48 flex items-center justify-center"> | |
| <svg className="w-full h-full absolute transform -rotate-90" viewBox="0 0 100 100"> | |
| <circle cx="50" cy="50" fill="none" r="45" stroke="#e5eeff" strokeWidth="8"></circle> | |
| </svg> | |
| <svg className="w-full h-full absolute transform -rotate-90" viewBox="0 0 100 100"> | |
| <circle | |
| className="transition-all duration-1000 ease-out" | |
| cx="50" cy="50" fill="none" r="45" | |
| stroke={score < 50 ? '#ba1a1a' : score < 80 ? '#bc4800' : '#004ac6'} | |
| strokeDasharray="283" | |
| strokeDashoffset={dashOffset} | |
| strokeLinecap="round" | |
| strokeWidth="8" | |
| /> | |
| </svg> | |
| <div className="text-center flex flex-col items-center z-10"> | |
| <span className="font-display-lg text-display-lg text-primary tracking-tighter">{score}</span> | |
| <span className={`font-label-sm text-label-sm uppercase tracking-widest px-xs py-[2px] rounded-sm mt-xs ${scoreColorClass}`}> | |
| {scoreLabel} | |
| </span> | |
| <span className="font-label-sm text-label-sm text-on-surface-variant/70 mt-base"> | |
| {score >= 80 ? 'System Protected' : 'Remediation Required'} | |
| </span> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| <div className="flex items-center justify-between text-body-sm font-body-sm border-t border-outline-variant pt-sm mt-sm"> | |
| <span className="text-on-surface-variant">Live security posture</span> | |
| <span className={`font-medium flex items-center ${ | |
| !hasCompletedScans ? 'text-amber-600 dark:text-amber-400' : score >= 80 ? 'text-green-600' : score >= 50 ? 'text-orange-600' : 'text-error' | |
| }`}> | |
| <span className="material-symbols-outlined text-[16px] mr-[2px]"> | |
| {!hasCompletedScans ? 'hourglass_empty' : score >= 80 ? 'trending_up' : score >= 50 ? 'trending_flat' : 'trending_down'} | |
| </span> | |
| {!hasCompletedScans ? 'Pending Initial Scan' : score >= 80 ? 'Stable' : score >= 50 ? 'Needs Attention' : 'Critical Risk'} | |
| </span> | |
| </div> | |
| </div> | |
| {/* Stats + Chart */} | |
| <div className="md:col-span-6 flex flex-col gap-gutter"> | |
| {/* Vulnerability Category Breakdown Bar Chart with Visible Numbers */} | |
| <div className="w-full h-full bg-surface-container-lowest border border-outline-variant rounded-xl p-lg shadow-sm flex flex-col"> | |
| <div className="w-full flex justify-between items-center mb-md"> | |
| <h3 className="font-headline-md text-headline-md text-on-surface tracking-tight font-bold">Vulnerability Categories</h3> | |
| <span className="text-[12px] font-bold text-on-surface-variant bg-surface-container px-2 py-0.5 rounded border border-outline-variant"> | |
| Top Vectors | |
| </span> | |
| </div> | |
| <div className="flex-grow min-h-[260px] w-full"> | |
| <ResponsiveContainer width="100%" height="100%"> | |
| <BarChart | |
| data={[ | |
| { category: 'Critical', count: counts.critical || 0 }, | |
| { category: 'High', count: counts.high || 0 }, | |
| { category: 'Medium', count: counts.medium || 0 }, | |
| { category: 'Low', count: counts.low || 0 } | |
| ]} | |
| margin={{ top: 30, right: 15, left: -10, bottom: 25 }} | |
| > | |
| <defs> | |
| <linearGradient id="colorCritical" x1="0" y1="0" x2="0" y2="1"> | |
| <stop offset="0%" stopColor="#ef4444" stopOpacity={1}/> | |
| <stop offset="100%" stopColor="#991b1b" stopOpacity={0.95}/> | |
| </linearGradient> | |
| <linearGradient id="colorHigh" x1="0" y1="0" x2="0" y2="1"> | |
| <stop offset="0%" stopColor="#f97316" stopOpacity={1}/> | |
| <stop offset="100%" stopColor="#c2410c" stopOpacity={0.95}/> | |
| </linearGradient> | |
| <linearGradient id="colorMedium" x1="0" y1="0" x2="0" y2="1"> | |
| <stop offset="0%" stopColor="#eab308" stopOpacity={1}/> | |
| <stop offset="100%" stopColor="#854d0e" stopOpacity={0.95}/> | |
| </linearGradient> | |
| <linearGradient id="colorLow" x1="0" y1="0" x2="0" y2="1"> | |
| <stop offset="0%" stopColor="#3b82f6" stopOpacity={1}/> | |
| <stop offset="100%" stopColor="#1e40af" stopOpacity={0.95}/> | |
| </linearGradient> | |
| </defs> | |
| <CartesianGrid strokeDasharray="3 3" stroke="#334155" vertical={false} /> | |
| <XAxis | |
| dataKey="category" | |
| stroke="#475569" | |
| fontSize={12} | |
| tickLine={false} | |
| tick={{ fill: '#000000', fontSize: 12, fontWeight: 'bold' }} | |
| angle={0} | |
| textAnchor="middle" | |
| height={30} | |
| interval={0} | |
| /> | |
| <YAxis | |
| stroke="#475569" | |
| fontSize={11} | |
| tickLine={false} | |
| axisLine={false} | |
| allowDecimals={false} | |
| tick={{ fill: '#000000', fontSize: 11, fontWeight: 'bold' }} | |
| /> | |
| <RechartsTooltip content={<CustomBarTooltip />} /> | |
| <Bar dataKey="count" name="Findings" radius={[8, 8, 0, 0]} barSize={45}> | |
| <Cell key="cell-0" fill="url(#colorCritical)" /> | |
| <Cell key="cell-1" fill="url(#colorHigh)" /> | |
| <Cell key="cell-2" fill="url(#colorMedium)" /> | |
| <Cell key="cell-3" fill="url(#colorLow)" /> | |
| <LabelList | |
| dataKey="count" | |
| position="top" | |
| fill="#000000" | |
| fontSize={12} | |
| fontWeight="bold" | |
| offset={8} | |
| /> | |
| </Bar> | |
| </BarChart> | |
| </ResponsiveContainer> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Recent Scans Table */} | |
| <div className="bg-surface-container-lowest border border-outline-variant rounded-xl shadow-sm flex flex-col overflow-hidden"> | |
| <div className="p-lg border-b border-outline-variant flex justify-between items-center"> | |
| <h3 className="font-headline-md text-headline-md text-on-surface tracking-tight">Configured Target Assets</h3> | |
| <Link className="font-label-md text-label-md text-primary hover:underline" to="/scans/history" style={{ textDecoration: 'none' }}> | |
| View Full Audit Log | |
| </Link> | |
| </div> | |
| {recentScans.length === 0 ? ( | |
| <div className="text-center py-2xl text-on-surface-variant font-body-sm"> | |
| No target domains scanned yet. Launch your first website scan under the New Scan tab! | |
| </div> | |
| ) : ( | |
| <div className="overflow-x-auto"> | |
| <table className="w-full text-left border-collapse min-w-[800px]"> | |
| <thead> | |
| <tr className="bg-surface-container-low border-b border-outline-variant"> | |
| {['Status','Target URL','Scan Profile','Date','Rating','Severity','Actions'].map((h, i) => ( | |
| <th | |
| key={h} | |
| onClick={() => handleSort(h)} | |
| className={`py-sm px-lg font-label-sm text-label-sm text-on-surface-variant uppercase tracking-wider font-medium ${i === 6 ? 'text-right' : ''} ${(h !== 'Severity' && h !== 'Actions') ? 'cursor-pointer hover:bg-surface-container-high transition-colors select-none group' : ''}`} | |
| > | |
| <div className={`flex items-center gap-xs ${i === 6 ? 'justify-end' : ''}`}> | |
| {h} | |
| {(h !== 'Severity' && h !== 'Actions') && ( | |
| <span className={`material-symbols-outlined text-[16px] opacity-0 group-hover:opacity-50 transition-opacity ${sortColumn === h ? 'opacity-100 group-hover:opacity-100 text-primary' : ''}`}> | |
| {sortColumn === h && sortDirection === 'desc' ? 'arrow_downward' : 'arrow_upward'} | |
| </span> | |
| )} | |
| </div> | |
| </th> | |
| ))} | |
| </tr> | |
| </thead> | |
| <tbody className="font-body-sm text-body-sm text-on-surface"> | |
| {getSortedScans().slice(0, 8).map((scan) => { | |
| let dot = 'bg-primary', statusText = 'Clean', rowBg = ''; | |
| if (scan.status === 'completed') { | |
| if (scan.security_score < 60) { dot = 'bg-error animate-pulse'; statusText = 'Critical'; rowBg = 'bg-error/5'; } | |
| else if (scan.security_score < 80) { dot = 'bg-tertiary'; statusText = 'Warning'; } | |
| else { dot = 'bg-green-500'; statusText = 'Secure'; } | |
| } else if (scan.status === 'scanning' || scan.status === 'queued') { | |
| dot = 'bg-yellow-500 animate-pulse'; statusText = 'Scanning'; | |
| } else if (scan.status === 'terminated') { | |
| dot = 'bg-slate-400'; statusText = 'Terminated'; | |
| } else { | |
| dot = 'bg-slate-400'; statusText = 'Failed'; | |
| } | |
| const grade = getRatingGrade(scan.security_score); | |
| return ( | |
| <tr key={scan.id} className={`border-b border-outline-variant/50 hover:bg-surface-bright transition-colors ${rowBg}`}> | |
| <td className="py-md px-lg"> | |
| <div className="flex items-center gap-xs"> | |
| <div className={`w-2.5 h-2.5 rounded-full ${dot}`}></div> | |
| <span className="font-medium">{statusText}</span> | |
| </div> | |
| </td> | |
| <td className="py-md px-lg font-label-md text-on-surface-variant font-bold max-w-[200px] truncate">{scan.target_url}</td> | |
| <td className="py-md px-lg text-on-surface-variant">{scan.scan_type} Assessment</td> | |
| <td className="py-md px-lg text-on-surface-variant font-medium text-xs"> | |
| {scan.started_at ? new Date(scan.started_at).toLocaleString('en-US', { | |
| day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' | |
| }) : 'Pending'} | |
| </td> | |
| <td className="py-md px-lg"> | |
| <span className={`font-mono text-[20px] font-extrabold ${ratingColor(grade)}`}>{grade}</span> | |
| {scan.security_score !== null && <span className="text-on-surface-variant text-xs ml-1 opacity-70">({scan.security_score})</span>} | |
| </td> | |
| <td className="py-md px-lg"> | |
| <div className="flex gap-sm flex-wrap"> | |
| {(scan.vulnerabilities_count?.critical > 0) && <span className="bg-red-500/10 text-red-600 px-sm py-[2px] rounded text-xs font-bold border border-red-500/20">{scan.vulnerabilities_count.critical} Crit</span>} | |
| {(scan.vulnerabilities_count?.high > 0) && <span className="bg-orange-500/10 text-orange-600 px-sm py-[2px] rounded text-xs font-bold border border-orange-500/20">{scan.vulnerabilities_count.high} High</span>} | |
| {(scan.vulnerabilities_count?.total === 0) && <span className="bg-green-500/10 text-green-600 px-sm py-[2px] rounded text-xs font-bold border border-green-500/20">Clean</span>} | |
| {scan.status === 'scanning' && <span className="bg-yellow-500/10 text-yellow-600 px-sm py-[2px] rounded text-xs font-bold border border-yellow-500/20 animate-pulse">Scanning...</span>} | |
| </div> | |
| </td> | |
| <td className="py-md px-lg text-right"> | |
| <Link to={`/scans/results?id=${scan.id}`} className="text-primary hover:text-primary-container font-semibold inline-flex items-center gap-xs" style={{ textDecoration: 'none' }}> | |
| Details <span className="material-symbols-outlined text-[16px]">arrow_forward</span> | |
| </Link> | |
| </td> | |
| </tr> | |
| ); | |
| })} | |
| </tbody> | |
| </table> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| }; | |