mirror of
https://github.com/ParrotXray/Mantis.git
synced 2026-08-24 18:50:28 +09:00
research: refactored detection page draft aligned to UnifiedAlert API
Rewrites detection.tsx to match the current backend payload: - AlertLog -> UnifiedAlert (src_ip/dst_ip/src_port/dst_port, severity, source) - Removes complex JSON parsing fallbacks (API sends clean JSON) - Timestamp: Unix u64 -> formatted locale string - Protocol: u8 -> TCP/UDP/ICMP label - Severity: priority 1-4 -> High | Critical enum - Source: new Ml | Rule | Fusion field drives filter + badge display - Filter: dual axis (source x severity) replaces single priority filter - Stats: adds ML / Rule / Fusion counts, removes Medium/Low buckets - ML panel: confidence progress bar + ae_score for Ml/Fusion alerts - Rule panel: rule_sid for Rule/Fusion alerts - isPaused: uses ref in subscription to avoid unnecessary resubscription - WebSocket: getAiAlertStream -> getDetectionAlertStream (needs WebSocketProvider update) https://claude.ai/code/session_01Wen51749mAnwBEfh7XmvUw
This commit is contained in:
parent
939f83102b
commit
799f465121
762
.research/frontend-refactor/detection.tsx
Normal file
762
.research/frontend-refactor/detection.tsx
Normal file
@ -0,0 +1,762 @@
|
||||
import React, { useContext, useEffect, useState, useCallback, useMemo, useRef } from 'react'
|
||||
import Head from 'next/head'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import {
|
||||
faShieldAlt,
|
||||
faExclamationTriangle,
|
||||
faFilter,
|
||||
faCheck,
|
||||
faGlobe,
|
||||
faTimes,
|
||||
faArrowRight,
|
||||
faEye,
|
||||
faList,
|
||||
faPause,
|
||||
faPlay,
|
||||
faBrain,
|
||||
faScroll,
|
||||
faLayerGroup,
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { WebsocketContext } from '../providers/WebSocketProvider'
|
||||
import { putData } from '../utils/connectionUtils'
|
||||
import { urls } from '../config'
|
||||
import Layout from '../components/Layout'
|
||||
import LoadingSpinner from '../components/LoadingSpinner'
|
||||
|
||||
// --- Types (mirrors backend UnifiedAlert) ---
|
||||
|
||||
type AlertSource = 'Ml' | 'Rule' | 'Fusion'
|
||||
type AlertSeverity = 'High' | 'Critical'
|
||||
|
||||
interface UnifiedAlert {
|
||||
timestamp: number
|
||||
flow_key: string
|
||||
src_ip: string
|
||||
dst_ip: string
|
||||
src_port: number
|
||||
dst_port: number
|
||||
protocol: number
|
||||
source: AlertSource
|
||||
severity: AlertSeverity
|
||||
is_attack: boolean
|
||||
attack_type: string | null
|
||||
confidence: number
|
||||
ae_score: number
|
||||
rule_sid: number | null
|
||||
rule_msg: string | null
|
||||
}
|
||||
|
||||
// --- Display config ---
|
||||
|
||||
const SOURCE_CONFIG = {
|
||||
Ml: {
|
||||
label: 'AI',
|
||||
color: '#7c3aed',
|
||||
bgColor: 'rgba(124,58,237,0.10)',
|
||||
icon: faBrain,
|
||||
},
|
||||
Rule: {
|
||||
label: '規則',
|
||||
color: '#2563eb',
|
||||
bgColor: 'rgba(37,99,235,0.10)',
|
||||
icon: faScroll,
|
||||
},
|
||||
Fusion: {
|
||||
label: '融合',
|
||||
color: '#dc2626',
|
||||
bgColor: 'rgba(220,38,38,0.10)',
|
||||
icon: faLayerGroup,
|
||||
},
|
||||
} as const
|
||||
|
||||
const SEVERITY_CONFIG = {
|
||||
Critical: {
|
||||
label: '嚴重',
|
||||
color: '#dc2626',
|
||||
bgColor: 'rgba(220,38,38,0.10)',
|
||||
icon: faExclamationTriangle,
|
||||
},
|
||||
High: {
|
||||
label: '高',
|
||||
color: '#d97706',
|
||||
bgColor: 'rgba(217,119,6,0.10)',
|
||||
icon: faExclamationTriangle,
|
||||
},
|
||||
} as const
|
||||
|
||||
const PROTOCOL_MAP: Record<number, string> = {
|
||||
1: 'ICMP',
|
||||
6: 'TCP',
|
||||
17: 'UDP',
|
||||
58: 'ICMPv6',
|
||||
132: 'SCTP',
|
||||
}
|
||||
|
||||
const formatProtocol = (proto: number): string =>
|
||||
PROTOCOL_MAP[proto] ?? `Proto(${proto})`
|
||||
|
||||
const formatTimestamp = (unix: number): string =>
|
||||
new Date(unix * 1000).toLocaleString('zh-TW', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
|
||||
// --- Notification ---
|
||||
|
||||
interface NotificationProps {
|
||||
message: string
|
||||
type: 'success' | 'error' | 'warning' | 'info'
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const Notification: React.FC<NotificationProps> = ({ message, type, onClose }) => {
|
||||
useEffect(() => {
|
||||
const t = setTimeout(onClose, 4000)
|
||||
return () => clearTimeout(t)
|
||||
}, [onClose])
|
||||
|
||||
const styles = {
|
||||
success: 'bg-green-100 border-green-400 text-green-700',
|
||||
error: 'bg-red-100 border-red-400 text-red-700',
|
||||
warning: 'bg-yellow-100 border-yellow-400 text-yellow-700',
|
||||
info: 'bg-blue-100 border-blue-400 text-blue-700',
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -50, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -50, scale: 0.95 }}
|
||||
className={`fixed top-4 right-4 z-50 px-4 py-3 border rounded-lg shadow-lg max-w-md ${styles[type]}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{message}</span>
|
||||
<button onClick={onClose} className="ml-3 text-xl font-bold hover:opacity-70 transition-opacity">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- FilterButton ---
|
||||
|
||||
interface FilterButtonProps {
|
||||
isActive: boolean
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
activeColor?: string
|
||||
}
|
||||
|
||||
const FilterButton: React.FC<FilterButtonProps> = ({
|
||||
isActive,
|
||||
onClick,
|
||||
children,
|
||||
activeColor = '#374151',
|
||||
}) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all border ${
|
||||
isActive
|
||||
? 'text-white shadow border-transparent'
|
||||
: 'bg-white text-gray-600 border-gray-200 hover:bg-gray-50'
|
||||
}`}
|
||||
style={isActive ? { backgroundColor: activeColor, borderColor: activeColor } : undefined}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
|
||||
// --- AlertItem ---
|
||||
|
||||
interface AlertItemProps {
|
||||
alert: UnifiedAlert
|
||||
index: number
|
||||
onClick: (index: number) => void
|
||||
}
|
||||
|
||||
const AlertItem: React.FC<AlertItemProps> = ({ alert, index, onClick }) => {
|
||||
const srcCfg = SOURCE_CONFIG[alert.source]
|
||||
const sevCfg = SEVERITY_CONFIG[alert.severity]
|
||||
const displayMessage = alert.attack_type ?? alert.rule_msg ?? `SID ${alert.rule_sid ?? '—'}`
|
||||
|
||||
const handleClick = useCallback(() => onClick(index), [index, onClick])
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: Math.min(index * 0.03, 0.3) }}
|
||||
className="bg-white border border-gray-200 rounded-lg p-4 cursor-pointer hover:shadow-md transition-shadow duration-200"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<div
|
||||
className="flex-shrink-0 w-9 h-9 rounded-full flex items-center justify-center mt-0.5"
|
||||
style={{ backgroundColor: sevCfg.bgColor }}
|
||||
>
|
||||
<FontAwesomeIcon icon={sevCfg.icon} style={{ color: sevCfg.color }} className="text-sm" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs text-gray-500">{formatTimestamp(alert.timestamp)}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded-full font-medium"
|
||||
style={{ backgroundColor: srcCfg.bgColor, color: srcCfg.color }}
|
||||
>
|
||||
<FontAwesomeIcon icon={srcCfg.icon} className="mr-1" />
|
||||
{srcCfg.label}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded-full font-medium"
|
||||
style={{ backgroundColor: sevCfg.bgColor, color: sevCfg.color }}
|
||||
>
|
||||
{sevCfg.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-900 mb-1.5 line-clamp-1 font-medium">
|
||||
{displayMessage}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-xs text-gray-500 space-x-1">
|
||||
<span className="font-mono">{alert.src_ip}:{alert.src_port}</span>
|
||||
<FontAwesomeIcon icon={faArrowRight} className="text-gray-400" />
|
||||
<span className="font-mono">{alert.dst_ip}:{alert.dst_port}</span>
|
||||
<span className="ml-2 px-1.5 py-0.5 bg-gray-100 text-gray-600 rounded">
|
||||
{formatProtocol(alert.protocol)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- AlertDetails ---
|
||||
|
||||
interface AlertDetailsProps {
|
||||
alert: UnifiedAlert
|
||||
onClose: () => void
|
||||
showNotification: (message: string, type?: 'success' | 'error' | 'warning' | 'info') => void
|
||||
}
|
||||
|
||||
const AlertDetails: React.FC<AlertDetailsProps> = ({ alert, onClose, showNotification }) => {
|
||||
const srcCfg = SOURCE_CONFIG[alert.source]
|
||||
const sevCfg = SEVERITY_CONFIG[alert.severity]
|
||||
const isIPv6Src = alert.src_ip.includes(':') && !alert.src_ip.includes('.')
|
||||
const isIPv6Dst = alert.dst_ip.includes(':') && !alert.dst_ip.includes('.')
|
||||
|
||||
const handleBlockIP = useCallback(
|
||||
(ip: string, port: number, isSource: boolean, blockAll = false) => {
|
||||
const ipv6 = isSource ? isIPv6Src : isIPv6Dst
|
||||
const formatted = ipv6 ? `[${ip}]` : ip
|
||||
const url = ipv6
|
||||
? urls.access_control.ipv6.black_list
|
||||
: urls.access_control.ipv4.black_list
|
||||
const portStr = blockAll ? '0' : String(port)
|
||||
putData(
|
||||
url,
|
||||
`${formatted}:${portStr}`,
|
||||
() => showNotification(`已成功添加到黑名單: ${ip}:${blockAll ? '*' : port}`, 'success'),
|
||||
(err: any) => showNotification(`添加到黑名單失敗: ${err.message}`, 'error')
|
||||
)
|
||||
},
|
||||
[isIPv6Src, isIPv6Dst, showNotification]
|
||||
)
|
||||
|
||||
const handleAllowIP = useCallback(
|
||||
(ip: string, port: number, isSource: boolean, allowAll = false) => {
|
||||
const ipv6 = isSource ? isIPv6Src : isIPv6Dst
|
||||
const formatted = ipv6 ? `[${ip}]` : ip
|
||||
const url = ipv6
|
||||
? urls.access_control.ipv6.white_list
|
||||
: urls.access_control.ipv4.white_list
|
||||
const portStr = allowAll ? '0' : String(port)
|
||||
putData(
|
||||
url,
|
||||
`${formatted}:${portStr}`,
|
||||
() => showNotification(`已成功添加到白名單: ${ip}:${allowAll ? '*' : port}`, 'success'),
|
||||
(err: any) => showNotification(`添加到白名單失敗: ${err.message}`, 'error')
|
||||
)
|
||||
},
|
||||
[isIPv6Src, isIPv6Dst, showNotification]
|
||||
)
|
||||
|
||||
const IpActions: React.FC<{ ip: string; port: number; isSource: boolean; label: string; bg: string }> = ({
|
||||
ip, port, isSource, label, bg,
|
||||
}) => (
|
||||
<div className={`${bg} rounded-lg p-4`}>
|
||||
<h5 className="text-xs font-medium text-gray-700 mb-2">{label}: {ip}</h5>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
className="px-3 py-2 bg-red-600 text-white text-xs rounded-lg hover:bg-red-700 transition-colors"
|
||||
onClick={() => handleBlockIP(ip, port, isSource)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faFilter} className="mr-1" />
|
||||
封鎖 :{port}
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-2 bg-red-700 text-white text-xs rounded-lg hover:bg-red-800 transition-colors"
|
||||
onClick={() => handleBlockIP(ip, port, isSource, true)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faFilter} className="mr-1" />
|
||||
封鎖所有
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors"
|
||||
onClick={() => handleAllowIP(ip, port, isSource)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCheck} className="mr-1" />
|
||||
允許 :{port}
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-2 bg-green-700 text-white text-xs rounded-lg hover:bg-green-800 transition-colors"
|
||||
onClick={() => handleAllowIP(ip, port, isSource, true)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCheck} className="mr-1" />
|
||||
允許所有
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
className="bg-white rounded-lg shadow-md p-6 max-h-[calc(100vh-2rem)] overflow-y-auto"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-xl font-semibold text-gray-900 flex items-center">
|
||||
<FontAwesomeIcon icon={faEye} className="mr-2 text-blue-600" />
|
||||
警報詳情
|
||||
</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<FontAwesomeIcon icon={faTimes} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Basic metadata */}
|
||||
<div className="space-y-3 mb-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-gray-600">時間:</span>
|
||||
<span className="text-sm text-gray-900">{formatTimestamp(alert.timestamp)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-gray-600">來源:</span>
|
||||
<span
|
||||
className="inline-flex items-center px-2 py-1 text-xs font-medium rounded-full"
|
||||
style={{ backgroundColor: srcCfg.bgColor, color: srcCfg.color }}
|
||||
>
|
||||
<FontAwesomeIcon icon={srcCfg.icon} className="mr-1" />
|
||||
{srcCfg.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-gray-600">嚴重程度:</span>
|
||||
<span
|
||||
className="inline-flex items-center px-2 py-1 text-xs font-medium rounded-full"
|
||||
style={{ backgroundColor: sevCfg.bgColor, color: sevCfg.color }}
|
||||
>
|
||||
<FontAwesomeIcon icon={sevCfg.icon} className="mr-1" />
|
||||
{sevCfg.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-gray-600">協議:</span>
|
||||
<span className="text-sm font-mono text-gray-900">{formatProtocol(alert.protocol)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Attack / rule message */}
|
||||
{(alert.attack_type || alert.rule_msg) && (
|
||||
<div className="mb-6">
|
||||
<span className="text-sm font-medium text-gray-600">
|
||||
{alert.source === 'Ml' ? '攻擊類型' : '規則訊息'}:
|
||||
</span>
|
||||
<div className="mt-1 p-3 bg-gray-50 rounded-lg">
|
||||
<p className="text-sm text-gray-900">{alert.attack_type ?? alert.rule_msg}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ML inference metrics */}
|
||||
{(alert.source === 'Ml' || alert.source === 'Fusion') && (
|
||||
<div className="mb-6 space-y-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700">AI 推論指標</h4>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600">信心度:</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-24 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="h-2 rounded-full transition-all"
|
||||
style={{
|
||||
width: `${(alert.confidence * 100).toFixed(0)}%`,
|
||||
backgroundColor: alert.confidence > 0.8 ? '#dc2626' : '#d97706',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-mono text-gray-900">
|
||||
{(alert.confidence * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600">AE Score:</span>
|
||||
<span className="text-sm font-mono text-gray-900">{alert.ae_score.toFixed(6)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rule SID */}
|
||||
{(alert.source === 'Rule' || alert.source === 'Fusion') && alert.rule_sid !== null && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-2">規則資訊</h4>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600">SID:</span>
|
||||
<span className="text-sm font-mono text-gray-900">{alert.rule_sid}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-3">連線詳情</h4>
|
||||
<div className="flex items-center justify-between bg-gray-50 rounded-lg p-4">
|
||||
<div className="text-center">
|
||||
<div className="text-xs font-medium text-gray-500">來源</div>
|
||||
<div className="text-sm font-semibold text-blue-600">{alert.src_ip}</div>
|
||||
<div className="text-xs text-gray-500">:{alert.src_port}</div>
|
||||
</div>
|
||||
<FontAwesomeIcon icon={faArrowRight} className="text-gray-400" />
|
||||
<div className="text-center">
|
||||
<div className="text-xs font-medium text-gray-500">目標</div>
|
||||
<div className="text-sm font-semibold text-red-600">{alert.dst_ip}</div>
|
||||
<div className="text-xs text-gray-500">:{alert.dst_port}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* IP management */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-semibold text-gray-700">IP 管理</h4>
|
||||
<IpActions ip={alert.src_ip} port={alert.src_port} isSource={true} label="來源 IP" bg="bg-blue-50" />
|
||||
<IpActions ip={alert.dst_ip} port={alert.dst_port} isSource={false} label="目標 IP" bg="bg-red-50" />
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Main page ---
|
||||
|
||||
const Detection: React.FC = () => {
|
||||
// NOTE: Replace getAiAlertStream with the WebsocketContext key that connects
|
||||
// to /detection/websocket/alert (unified alert stream).
|
||||
const { getDetectionAlertStream } = useContext(WebsocketContext)
|
||||
|
||||
const [alerts, setAlerts] = useState<UnifiedAlert[]>([])
|
||||
const [selectedIdx, setSelectedIdx] = useState<number | null>(null)
|
||||
const [sourceFilter, setSourceFilter] = useState<AlertSource | 'all'>('all')
|
||||
const [severityFilter, setSeverityFilter] = useState<AlertSeverity | 'all'>('all')
|
||||
const [notification, setNotification] = useState<{
|
||||
message: string
|
||||
type: 'success' | 'error' | 'warning' | 'info'
|
||||
} | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isPaused, setIsPaused] = useState(false)
|
||||
|
||||
// Use a ref so the subscription callback reads the latest isPaused
|
||||
// without causing a resubscribe on every pause toggle.
|
||||
const isPausedRef = useRef(false)
|
||||
isPausedRef.current = isPaused
|
||||
|
||||
const showNotification = useCallback(
|
||||
(message: string, type: 'success' | 'error' | 'warning' | 'info' = 'success') => {
|
||||
setNotification({ message, type })
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!getDetectionAlertStream) {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => setIsLoading(false), 3000)
|
||||
|
||||
const sub = getDetectionAlertStream().subscribe({
|
||||
next: (data: UnifiedAlert) => {
|
||||
clearTimeout(timeout)
|
||||
setIsLoading(false)
|
||||
if (!isPausedRef.current) {
|
||||
setAlerts(prev => [data, ...prev.slice(0, 199)])
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
clearTimeout(timeout)
|
||||
setIsLoading(false)
|
||||
},
|
||||
})
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
sub.unsubscribe()
|
||||
}
|
||||
}, [getDetectionAlertStream])
|
||||
|
||||
const filteredAlerts = useMemo(
|
||||
() =>
|
||||
alerts.filter(a => {
|
||||
if (sourceFilter !== 'all' && a.source !== sourceFilter) return false
|
||||
if (severityFilter !== 'all' && a.severity !== severityFilter) return false
|
||||
return true
|
||||
}),
|
||||
[alerts, sourceFilter, severityFilter]
|
||||
)
|
||||
|
||||
const stats = useMemo(
|
||||
() => ({
|
||||
total: alerts.length,
|
||||
critical: alerts.filter(a => a.severity === 'Critical').length,
|
||||
high: alerts.filter(a => a.severity === 'High').length,
|
||||
ml: alerts.filter(a => a.source === 'Ml').length,
|
||||
rule: alerts.filter(a => a.source === 'Rule').length,
|
||||
fusion: alerts.filter(a => a.source === 'Fusion').length,
|
||||
uniqueIPs: new Set([...alerts.map(a => a.src_ip), ...alerts.map(a => a.dst_ip)]).size,
|
||||
}),
|
||||
[alerts]
|
||||
)
|
||||
|
||||
const handleAlertClick = useCallback((idx: number) => setSelectedIdx(idx), [])
|
||||
const handleCloseDetails = useCallback(() => setSelectedIdx(null), [])
|
||||
const togglePause = useCallback(() => setIsPaused(p => !p), [])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<Head><title>威脅檢測 - NetGuardia</title></Head>
|
||||
<Layout>
|
||||
<div className="flex items-center justify-center w-full h-full min-h-[calc(100vh-4rem)]">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
</Layout>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>威脅檢測 - NetGuardia</title>
|
||||
<meta name="description" content="NetGuardia 統一威脅檢測系統" />
|
||||
</Head>
|
||||
|
||||
<Layout>
|
||||
<AnimatePresence>
|
||||
{notification && (
|
||||
<Notification
|
||||
message={notification.message}
|
||||
type={notification.type}
|
||||
onClose={() => setNotification(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Page title */}
|
||||
<motion.div initial={{ opacity: 0, y: -20 }} animate={{ opacity: 1, y: 0 }} className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-1 flex items-center">
|
||||
<FontAwesomeIcon icon={faShieldAlt} className="mr-3 text-blue-600" />
|
||||
威脅檢測
|
||||
</h1>
|
||||
<p className="text-gray-500 text-sm">AI 與規則引擎聯合偵測,實時監控網路威脅</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4 mb-6">
|
||||
{(
|
||||
[
|
||||
{ label: '總警報', value: stats.total, color: '#2563eb', icon: faList },
|
||||
{ label: '嚴重', value: stats.critical, color: '#dc2626', icon: faExclamationTriangle },
|
||||
{ label: '高', value: stats.high, color: '#d97706', icon: faExclamationTriangle },
|
||||
{ label: 'AI', value: stats.ml, color: '#7c3aed', icon: faBrain },
|
||||
{ label: '規則', value: stats.rule, color: '#2563eb', icon: faScroll },
|
||||
{ label: '融合', value: stats.fusion, color: '#dc2626', icon: faLayerGroup },
|
||||
{ label: '涉及 IP', value: stats.uniqueIPs, color: '#059669', icon: faGlobe },
|
||||
] as const
|
||||
).map(({ label, value, color, icon }, i) => (
|
||||
<motion.div
|
||||
key={label}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
className="bg-white rounded-lg shadow-sm p-4 flex items-center space-x-3"
|
||||
>
|
||||
<div className="rounded-lg p-2" style={{ backgroundColor: `${color}1a` }}>
|
||||
<FontAwesomeIcon icon={icon} style={{ color }} className="text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">{label}</p>
|
||||
<p className="text-xl font-bold text-gray-900">{value}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-lg shadow-sm p-4 mb-6"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Pause toggle */}
|
||||
<button
|
||||
onClick={togglePause}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors flex items-center gap-1.5 ${
|
||||
isPaused
|
||||
? 'bg-green-100 text-green-700 hover:bg-green-200'
|
||||
: 'bg-orange-100 text-orange-700 hover:bg-orange-200'
|
||||
}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={isPaused ? faPlay : faPause} />
|
||||
{isPaused ? '恢復' : '暫停'}
|
||||
</button>
|
||||
|
||||
{/* Live indicator */}
|
||||
<div className="flex items-center space-x-1.5">
|
||||
<div
|
||||
className={`w-2.5 h-2.5 rounded-full ${
|
||||
isPaused ? 'bg-orange-400' : 'bg-green-500 animate-pulse'
|
||||
}`}
|
||||
/>
|
||||
<span className={`text-xs font-medium ${isPaused ? 'text-orange-600' : 'text-green-600'}`}>
|
||||
{isPaused ? '已暫停' : '監控中'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-5 w-px bg-gray-200" />
|
||||
|
||||
{/* Source filter */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-gray-500">來源:</span>
|
||||
{(['all', 'Ml', 'Rule', 'Fusion'] as const).map(s => (
|
||||
<FilterButton
|
||||
key={s}
|
||||
isActive={sourceFilter === s}
|
||||
onClick={() => setSourceFilter(s)}
|
||||
activeColor={
|
||||
s === 'all' ? '#374151'
|
||||
: s === 'Ml' ? '#7c3aed'
|
||||
: s === 'Rule' ? '#2563eb'
|
||||
: '#dc2626'
|
||||
}
|
||||
>
|
||||
{s === 'all' ? '全部' : SOURCE_CONFIG[s].label}
|
||||
</FilterButton>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="h-5 w-px bg-gray-200" />
|
||||
|
||||
{/* Severity filter */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-gray-500">嚴重度:</span>
|
||||
{(['all', 'Critical', 'High'] as const).map(s => (
|
||||
<FilterButton
|
||||
key={s}
|
||||
isActive={severityFilter === s}
|
||||
onClick={() => setSeverityFilter(s)}
|
||||
activeColor={
|
||||
s === 'all' ? '#374151' : s === 'Critical' ? '#dc2626' : '#d97706'
|
||||
}
|
||||
>
|
||||
{s === 'all' ? '全部' : SEVERITY_CONFIG[s].label}
|
||||
</FilterButton>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto text-xs text-gray-400">
|
||||
{filteredAlerts.length} / {alerts.length} 筆
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className={selectedIdx !== null ? 'lg:col-span-7' : 'lg:col-span-12'}
|
||||
>
|
||||
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-gray-100 flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-gray-900 flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faShieldAlt} className="text-blue-600" />
|
||||
安全警報
|
||||
</h2>
|
||||
<span className="text-xs text-gray-400">{filteredAlerts.length} 條</span>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[620px] overflow-y-auto">
|
||||
{filteredAlerts.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<FontAwesomeIcon icon={faShieldAlt} className="text-5xl text-gray-200 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-600 mb-1">
|
||||
{alerts.length === 0 ? '尚無警報' : '無符合條件的警報'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
{alerts.length === 0
|
||||
? '系統偵測到威脅時將在此顯示'
|
||||
: '請調整過濾條件'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 space-y-3">
|
||||
{filteredAlerts.map((alert, i) => (
|
||||
<AlertItem
|
||||
key={`${alert.flow_key}-${alert.timestamp}-${i}`}
|
||||
alert={alert}
|
||||
index={i}
|
||||
onClick={handleAlertClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{selectedIdx !== null && filteredAlerts[selectedIdx] && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="lg:col-span-5"
|
||||
>
|
||||
<AlertDetails
|
||||
alert={filteredAlerts[selectedIdx]}
|
||||
onClose={handleCloseDetails}
|
||||
showNotification={showNotification}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Detection
|
||||
Loading…
x
Reference in New Issue
Block a user