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:
Claude 2026-05-19 08:13:24 +00:00
parent 939f83102b
commit 799f465121
No known key found for this signature in database

View 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