@@ -0,0 +1,95 @@
import { useCallback , useEffect , useMemo , useState , type ComponentProps } from 'react' ;
import type { EChartsOption } from 'echarts' ;
import { AlertTriangle , Ban , BellRing , RefreshCw , Settings2 , ShieldCheck , ShieldOff } from 'lucide-react' ;
import { adminApi , type SecurityAlert , type SecurityBlock , type SecurityOverview , type SecurityProtectedNetwork , type SecurityRule } from '@/api/adminApi' ;
import { Breadcrumb , Button , Chart , Input , Modal as BaseModal , Select , Table , Tabs , Tag , Textarea , type TableColumn } from '@/components/ui' ;
import './AdminSecurityDetectionPage.css' ;
const statusLabels : Record < string , string > = { open : '待处理' , acknowledged : '已确认' , blocked : '已封禁' , block_failed : '封禁失败' , ignored : '已忽略' , requested : '执行中' , failed : '失败' , released : '已解封' } ;
const severityLabels : Record < string , string > = { low : '低' , medium : '中' , high : '高' , critical : '严重' } ;
const durationOptions = [ { value : '600' , label : '10分钟' } , { value : '3600' , label : '1小时' } , { value : '86400' , label : '24小时' } , { value : '604800' , label : '7天' } ] ;
const formatTime = ( value? : string | null ) = > value ? new Intl . DateTimeFormat ( 'zh-CN' , { month : '2-digit' , day : '2-digit' , hour : '2-digit' , minute : '2-digit' , hour12 : false } ) . format ( new Date ( value ) ) : '—' ;
function Modal ( props : Omit < ComponentProps < typeof BaseModal > , 'open' > ) { return < BaseModal { ...props } open / > ; }
export function AdminSecurityDetectionPage() {
const [ overview , setOverview ] = useState < SecurityOverview | null > ( null ) ;
const [ alerts , setAlerts ] = useState < SecurityAlert [ ] > ( [ ] ) ;
const [ rules , setRules ] = useState < SecurityRule [ ] > ( [ ] ) ;
const [ blocks , setBlocks ] = useState < SecurityBlock [ ] > ( [ ] ) ;
const [ networks , setNetworks ] = useState < SecurityProtectedNetwork [ ] > ( [ ] ) ;
const [ loading , setLoading ] = useState ( true ) ;
const [ error , setError ] = useState ( '' ) ;
const [ blockAlert , setBlockAlert ] = useState < SecurityAlert | null > ( null ) ;
const [ editRule , setEditRule ] = useState < SecurityRule | null > ( null ) ;
const [ showNetwork , setShowNetwork ] = useState ( false ) ;
const [ reasonAction , setReasonAction ] = useState < { title : string ; confirmLabel : string ; run : ( reason : string ) = > Promise < void > } | null > ( null ) ;
const load = useCallback ( async ( ) = > {
setLoading ( true ) ; setError ( '' ) ;
try {
const [ nextOverview , nextAlerts , nextRules , nextBlocks , nextNetworks ] = await Promise . all ( [
adminApi . getSecurityOverview ( '24h' ) , adminApi . listSecurityAlerts ( { pageSize : 100 } ) , adminApi . listSecurityRules ( ) , adminApi . listSecurityBlocks ( ) , adminApi . listProtectedNetworks ( ) ,
] ) ;
setOverview ( nextOverview ) ; setAlerts ( nextAlerts . items ) ; setRules ( nextRules ) ; setBlocks ( nextBlocks ) ; setNetworks ( nextNetworks ) ;
} catch ( reason ) { setError ( reason instanceof Error ? reason . message : '安全检测数据加载失败' ) ; }
finally { setLoading ( false ) ; }
} , [ ] ) ;
useEffect ( ( ) = > { void load ( ) ; } , [ load ] ) ;
const alertColumns : Array < TableColumn < SecurityAlert > > = [
{ key : 'level' , title : '级别' , width : '80px' , render : ( item ) = > < Tag tone = { item . severity === 'critical' ? 'danger' : item . severity === 'high' ? 'warning' : 'info' } > { severityLabels [ item . severity ] ? ? item . severity } < / Tag > } ,
{ key : 'rule' , title : '检测类型' , width : '180px' , render : ( item ) = > < div className = "security-cell" > < strong > { item . rule . name } < / strong > < span > { item . rule . code } < / span > < / div > } ,
{ key : 'ip' , title : '来源 IP' , width : '150px' , render : ( item ) = > < code > { item . sourceIp } < / code > } ,
{ key : 'count' , title : '窗口命中' , width : '100px' , render : ( item ) = > ` ${ item . eventCount } 次 ` } ,
{ key : 'time' , title : '最后发生' , width : '140px' , render : ( item ) = > formatTime ( item . lastOccurredAt ) } ,
{ key : 'status' , title : '状态' , width : '96px' , render : ( item ) = > < Tag tone = { item . status === 'blocked' ? 'success' : item . status === 'block_failed' ? 'danger' : 'neutral' } > { statusLabels [ item . status ] ? ? item . status } < / Tag > } ,
{ key : 'action' , title : '人工处置' , width : '190px' , render : ( item ) = > < div className = "security-actions" > < Button disabled = { ! [ 'open' , 'acknowledged' , 'block_failed' ] . includes ( item . status ) } onClick = { ( ) = > setBlockAlert ( item ) } size = "sm" variant = "danger" > 封 禁 < / Button > < Button disabled = { ! [ 'open' , 'acknowledged' , 'block_failed' ] . includes ( item . status ) } onClick = { ( ) = > void ignore ( item ) } size = "sm" variant = "ghost" > 忽 略 < / Button > < / div > } ,
] ;
function ignore ( item : SecurityAlert ) { setReasonAction ( { title : ` 忽略告警 · ${ item . sourceIp } ` , confirmLabel : '确认忽略' , run : async ( reason ) = > { await adminApi . ignoreSecurityAlert ( item . id , reason ) ; await load ( ) ; } } ) ; }
const ruleColumns : Array < TableColumn < SecurityRule > > = [
{ key : 'name' , title : '规则' , width : '230px' , render : ( item ) = > < div className = "security-cell" > < strong > { item . name } < / strong > < span > { item . code } < / span > < / div > } ,
{ key : 'source' , title : '数据源' , width : '110px' , render : ( item ) = > item . sourceType } ,
{ key : 'threshold' , title : '阈值 / 窗口' , width : '150px' , render : ( item ) = > ` ${ item . threshold } 次 / ${ item . windowSeconds } 秒 ` } ,
{ key : 'cooldown' , title : '冷却' , width : '100px' , render : ( item ) = > ` ${ item . cooldownSeconds } 秒 ` } ,
{ key : 'version' , title : '生效版本' , width : '120px' , render : ( item ) = > < Tag tone = { item . applyStatus === 'effective' ? 'success' : item . applyStatus === 'failed' ? 'danger' : 'warning' } > { item . effectiveVersion } / { item . configVersion } < / Tag > } ,
{ key : 'enabled' , title : '启用' , width : '90px' , render : ( item ) = > < Tag tone = { item . enabled ? 'success' : 'neutral' } > { item . enabled ? '启用' : '停用' } < / Tag > } ,
{ key : 'action' , title : '操作' , width : '90px' , render : ( item ) = > < Button onClick = { ( ) = > setEditRule ( item ) } size = "sm" variant = "ghost" > 配 置 < / Button > } ,
] ;
const blockColumns : Array < TableColumn < SecurityBlock > > = [
{ key : 'ip' , title : 'IP' , width : '160px' , render : ( item ) = > < code > { item . sourceIp } < / code > } , { key : 'executor' , title : '执行器' , width : '140px' , render : ( item ) = > item . executor } ,
{ key : 'status' , title : '状态' , width : '100px' , render : ( item ) = > < Tag tone = { item . status === 'blocked' ? 'success' : item . status === 'failed' ? 'danger' : 'neutral' } > { statusLabels [ item . status ] ? ? item . status } < / Tag > } ,
{ key : 'reason' , title : '原因' , width : '260px' , render : ( item ) = > item . reason } , { key : 'expiry' , title : '到期时间' , width : '150px' , render : ( item ) = > formatTime ( item . expiresAt ) } , { key : 'error' , title : '执行结果' , width : '220px' , render : ( item ) = > item . lastError ? ? '已由执行器回读确认' } ,
{ key : 'action' , title : '操作' , width : '90px' , render : ( item ) = > < Button disabled = { item . status !== 'blocked' } onClick = { ( ) = > void releaseBlock ( item ) } size = "sm" variant = "ghost" > 解 封 < / Button > } ,
] ;
function releaseBlock ( item : SecurityBlock ) { setReasonAction ( { title : ` 人工解封 · ${ item . sourceIp } ` , confirmLabel : '确认解封' , run : async ( reason ) = > { await adminApi . unblockSecurityBlock ( item . id , reason ) ; await load ( ) ; } } ) ; }
const networkColumns : Array < TableColumn < SecurityProtectedNetwork > > = [
{ key : 'network' , title : 'IP / 网段' , width : '180px' , render : ( item ) = > < code > { item . network } < / code > } , { key : 'name' , title : '名称' , width : '180px' , render : ( item ) = > item . name } , { key : 'reason' , title : '保护原因' , width : '320px' , render : ( item ) = > item . reason } , { key : 'status' , title : '状态' , width : '100px' , render : ( item ) = > < Tag tone = { item . enabled ? 'success' : 'neutral' } > { item . enabled ? '保护中' : '已停用' } < / Tag > } ,
] ;
const chartOption = useMemo < EChartsOption > ( ( ) = > ( { tooltip : { trigger : 'item' } , legend : { bottom : 0 , type : 'scroll' } , series : [ { type : 'pie' , radius : [ '52%' , '76%' ] , center : [ '50%' , '43%' ] , label : { show : false } , data : overview?.sourceDistribution ? ? [ ] } ] } ) , [ overview ] ) ;
const tabs = [
{ value : 'overview' , label : '总览' , content : < > < div className = "security-overview-grid" > < article className = "surface security-chart" > < header > < strong > 24 小 时 检 测 来 源 < / strong > < span > 真 实 事 件 聚 合 < / span > < / header > { overview ? . sourceDistribution . length ? < Chart height = { 265 } option = { chartOption } / > : < div className = "security-empty" > 暂 无 检 测 事 件 < / div > } < / article > < article className = "surface security-latest" > < header > < strong > 最 新 告 警 < / strong > < span > { overview ? . alerts . length ? ? 0 } 条 < / span > < / header > { overview ? . alerts . slice ( 0 , 6 ) . map ( ( item ) = > < div className = "security-latest-row" key = { item . id } > < span className = { ` severity-dot is- ${ item . severity } ` } / > < div > < strong > { item . rule . name } < / strong > < small > { item . sourceIp } · { item . eventCount } 次 < / small > < / div > < time > { formatTime ( item . lastOccurredAt ) } < / time > < / div > ) } < / article > < / div > < / > } ,
{ value : 'alerts' , label : '告警中心' , content : < section className = "surface security-table-card" > < Table columns = { alertColumns } data = { alerts } emptyText = "暂无安全告警" rowKey = "id" / > < / section > } ,
{ value : 'rules' , label : '规则配置' , content : < section className = "surface security-table-card" > < div className = "security-note" > < Settings2 size = { 17 } / > < span > 所 有 阈 值 来 自 数 据 库 ; 修 改 后 必 须 由 受 限 安 全 代 理 验 证 并 应 用 , 版 本 一 致 才 标 记 生 效 。 < / span > < / div > < Table columns = { ruleColumns } data = { rules } rowKey = "id" / > < / section > } ,
{ value : 'blocks' , label : '封禁记录' , content : < section className = "surface security-table-card" > < Table columns = { blockColumns } data = { blocks } emptyText = "暂无人工封禁记录" rowKey = "id" / > < / section > } ,
{ value : 'protected' , label : '保护名单' , content : < section className = "surface security-table-card" > < div className = "security-section-toolbar" > < span > 受 保 护 的 运 维 出 口 、 内 网 和 可 信 代 理 永 远 不 能 从 面 板 封 禁 。 < / span > < Button onClick = { ( ) = > setShowNetwork ( true ) } > 新 增 保 护 网 段 < / Button > < / div > < Table columns = { networkColumns } data = { networks } emptyText = "暂无保护网段" rowKey = "id" / > < / section > } ,
] ;
return < section className = "page-stack admin-security-page" > < div className = "page-heading security-heading" > < div > < Breadcrumb items = { [ '安全控制' , '安全检测与封禁' ] } / > < h1 > 安 全 检 测 与 封 禁 < / h1 > < p > Fail2ban 与 业 务 事 件 只 负 责 检 测 , 由 运 营 人 员 复 核 后 人 工 处 置 < / p > < / div > < Button disabled = { loading } icon = { < RefreshCw className = { loading ? 'is-spinning' : '' } size = { 16 } / > } onClick = { ( ) = > void load ( ) } variant = "ghost" > 刷 新 < / Button > < / div >
{ error ? < div className = "security-error" role = "alert" > < AlertTriangle size = { 18 } / > { error } < / div > : null }
< div className = "security-kpis" > < Kpi icon = { < BellRing / > } label = "24小时检测事件" value = { overview ? . totalEvents ? ? 0 } / > < Kpi icon = { < AlertTriangle / > } label = "待处置告警" value = { overview ? . activeAlerts ? ? 0 } danger = { Boolean ( overview ? . criticalAlerts ) } / > < Kpi icon = { < Ban / > } label = "生效封禁" value = { overview ? . activeBlocks ? ? 0 } / > < Kpi icon = { overview ? . health . agent === 'healthy' ? < ShieldCheck / > : < ShieldOff / > } label = "安全代理" value = { overview ? . health . agent === 'healthy' ? '在线' : '不可用' } danger = { overview ? . health . agent !== 'healthy' } / > < / div >
< Tabs items = { tabs } / >
{ blockAlert ? < BlockDialog alert = { blockAlert } onClose = { ( ) = > setBlockAlert ( null ) } onSaved = { async ( ) = > { setBlockAlert ( null ) ; await load ( ) ; } } / > : null }
{ editRule ? < RuleDialog rule = { editRule } onClose = { ( ) = > setEditRule ( null ) } onSaved = { async ( ) = > { setEditRule ( null ) ; await load ( ) ; } } / > : null }
{ showNetwork ? < NetworkDialog onClose = { ( ) = > setShowNetwork ( false ) } onSaved = { async ( ) = > { setShowNetwork ( false ) ; await load ( ) ; } } / > : null }
{ reasonAction ? < ActionReasonDialog action = { reasonAction } onClose = { ( ) = > setReasonAction ( null ) } / > : null }
< / section > ;
}
function Kpi ( { icon , label , value , danger = false } : { icon : React.ReactNode ; label : string ; value : React.ReactNode ; danger? : boolean } ) { return < article className = { ` surface security-kpi ${ danger ? 'is-danger' : '' } ` } > < div > { icon } < / div > < span > { label } < / span > < strong > { value } < / strong > < / article > ; }
function BlockDialog ( { alert , onClose , onSaved } : { alert : SecurityAlert ; onClose : ( ) = > void ; onSaved : ( ) = > void } ) { const [ duration , setDuration ] = useState ( String ( alert . rule . defaultBlockSeconds ) ) ; const [ reason , setReason ] = useState ( '' ) ; const [ saving , setSaving ] = useState ( false ) ; const [ error , setError ] = useState ( '' ) ; const executor = alert . rule . code . startsWith ( 'admin_' ) || alert . rule . code . startsWith ( 'client_' ) ? 'Nginx Real-IP deny' : 'nftables' ; async function save() { setSaving ( true ) ; setError ( '' ) ; try { await adminApi . blockSecurityAlert ( alert . id , { durationSeconds : Number ( duration ) , reason } ) ; await onSaved ( ) ; } catch ( reason ) { setError ( reason instanceof Error ? reason . message : '封禁失败' ) ; } finally { setSaving ( false ) ; } } return < Modal title = "确认人工封禁" onClose = { onClose } footer = { < > < Button onClick = { onClose } variant = "ghost" > 取 消 < / Button > < Button disabled = { saving || reason . trim ( ) . length < 5 } onClick = { ( ) = > void save ( ) } variant = "danger" > { saving ? '执行并回读中' : '确认封禁' } < / Button > < / > } > < div className = "security-dialog" > < div className = "security-target" > < span > 目 标 IP < / span > < strong > { alert . sourceIp } < / strong > < small > { alert . rule . name } · 窗 口 命 中 { alert . eventCount } 次 < / small > < / div > < Input disabled label = "服务端固定执行器" value = { executor } / > < Select label = "封禁时长" options = { durationOptions . filter ( ( item ) = > Number ( item . value ) <= alert . rule . maximumBlockSeconds ) } value = { duration } onChange = { ( event ) = > setDuration ( event . target . value ) } / > < Textarea label = "封禁原因" minLength = { 5 } onChange = { ( event ) = > setReason ( event . target . value ) } required value = { reason } / > { error ? < div className = "security-error" > { error } < / div > : null } < / div > < / Modal > ; }
function RuleDialog ( { rule , onClose , onSaved } : { rule : SecurityRule ; onClose : ( ) = > void ; onSaved : ( ) = > void } ) { const [ value , setValue ] = useState ( rule ) ; const [ saving , setSaving ] = useState ( false ) ; const [ error , setError ] = useState ( '' ) ; const number = ( key : keyof SecurityRule ) = > ( event : React.ChangeEvent < HTMLInputElement > ) = > setValue ( { . . . value , [ key ] : Number ( event . target . value ) } ) ; async function save() { setSaving ( true ) ; try { await adminApi . updateSecurityRule ( rule . id , value ) ; await onSaved ( ) ; } catch ( reason ) { setError ( reason instanceof Error ? reason . message : '规则保存失败' ) ; } finally { setSaving ( false ) ; } } return < Modal title = { ` 配置规则 · ${ rule . name } ` } onClose = { onClose } footer = { < > < Button onClick = { onClose } variant = "ghost" > 取 消 < / Button > < Button disabled = { saving } onClick = { ( ) = > void save ( ) } > { saving ? '验证并应用中' : '保存并应用' } < / Button > < / > } > < div className = "security-form-grid" > < Select label = "启用状态" options = { [ { value : 'true' , label : '启用' } , { value : 'false' , label : '停用' } ] } value = { String ( value . enabled ) } onChange = { ( event ) = > setValue ( { . . . value , enabled : event.target.value === 'true' } ) } / > < Select label = "告警级别" options = { [ 'low' , 'medium' , 'high' , 'critical' ] . map ( ( item ) = > ( { value : item , label : severityLabels [ item ] } ) ) } value = { value . severity } onChange = { ( event ) = > setValue ( { . . . value , severity : event.target.value } ) } / > < Input label = "触发次数" min = { 1 } onChange = { number ( 'threshold' ) } type = "number" value = { value . threshold } / > < Input label = "检测窗口(秒)" min = { 10 } onChange = { number ( 'windowSeconds' ) } type = "number" value = { value . windowSeconds } / > < Input label = "告警冷却(秒)" min = { 0 } onChange = { number ( 'cooldownSeconds' ) } type = "number" value = { value . cooldownSeconds } / > < Input label = "默认封禁(秒)" min = { 600 } onChange = { number ( 'defaultBlockSeconds' ) } type = "number" value = { value . defaultBlockSeconds } / > < Input label = "最大封禁(秒)" min = { 600 } onChange = { number ( 'maximumBlockSeconds' ) } type = "number" value = { value . maximumBlockSeconds } / > { error ? < div className = "security-error" > { error } < / div > : null } < / div > < / Modal > ; }
function NetworkDialog ( { onClose , onSaved } : { onClose : ( ) = > void ; onSaved : ( ) = > void } ) { const [ value , setValue ] = useState ( { network : '' , name : '' , reason : '' } ) ; const [ error , setError ] = useState ( '' ) ; async function save() { try { await adminApi . addProtectedNetwork ( value ) ; await onSaved ( ) ; } catch ( reason ) { setError ( reason instanceof Error ? reason . message : '新增失败' ) ; } } return < Modal title = "新增保护网段" onClose = { onClose } footer = { < > < Button onClick = { onClose } variant = "ghost" > 取 消 < / Button > < Button disabled = { ! value . network || ! value . name || ! value . reason } onClick = { ( ) = > void save ( ) } > 确 认 保 护 < / Button > < / > } > < div className = "security-dialog" > < Input label = "IP 或 CIDR" placeholder = "例如 203.0.113.10 或 10.0.0.0/8" value = { value . network } onChange = { ( event ) = > setValue ( { . . . value , network : event.target.value } ) } / > < Input label = "名称" value = { value . name } onChange = { ( event ) = > setValue ( { . . . value , name : event.target.value } ) } / > < Textarea label = "保护原因" value = { value . reason } onChange = { ( event ) = > setValue ( { . . . value , reason : event.target.value } ) } / > { error ? < div className = "security-error" > { error } < / div > : null } < / div > < / Modal > ; }
function ActionReasonDialog ( { action , onClose } : { action : { title : string ; confirmLabel : string ; run : ( reason : string ) = > Promise < void > } ; onClose : ( ) = > void } ) { const [ reason , setReason ] = useState ( '' ) ; const [ saving , setSaving ] = useState ( false ) ; const [ error , setError ] = useState ( '' ) ; async function save() { setSaving ( true ) ; setError ( '' ) ; try { await action . run ( reason ) ; onClose ( ) ; } catch ( cause ) { setError ( cause instanceof Error ? cause . message : '操作失败' ) ; } finally { setSaving ( false ) ; } } return < Modal title = { action . title } onClose = { onClose } footer = { < > < Button onClick = { onClose } variant = "ghost" > 取 消 < / Button > < Button disabled = { saving || reason . trim ( ) . length < 5 } onClick = { ( ) = > void save ( ) } > { saving ? '处理中' : action . confirmLabel } < / Button > < / > } > < div className = "security-dialog" > < Textarea autoFocus label = "操作原因" minLength = { 5 } onChange = { ( event ) = > setReason ( event . target . value ) } required value = { reason } / > { error ? < div className = "security-error" > { error } < / div > : null } < / div > < / Modal > ; }