@@ -1,7 +1,7 @@
import { IconAlarm , IconBell , IconChevronRight , IconClose , IconFilter , IconPlus , IconRefresh , IconSearch } from '@douyinfe/semi-icons' ;
import { IconAlarm , IconBell , IconChevronRight , IconClose , IconFilter , IconPlus , IconRefresh , IconSearch , IconSetting } from '@douyinfe/semi-icons' ;
import { Button , Card , CardGroup , Collapse , Descriptions , Empty , Input , Radio , Select , SideSheet , Spin , Table , Tag , TextArea , Timeline , Typography } from '@douyinfe/semi-ui' ;
import { useMutation , useQuery , useQueryClient } from '@tanstack/react-query' ;
import { FormEvent , useEffect , useMemo , useState } from 'react' ;
import { FormEvent , memo , useCallback , useEffect , useMemo , useState } from 'react' ;
import { Link , useSearchParams } from 'react-router-dom' ;
import { api } from '../../api/client' ;
import type { AlertEvent , AlertQuery , AlertRule , AlertRuleInput , AlertStatus , MetricDefinition , Page } from '../../api/types' ;
@@ -63,11 +63,18 @@ function StatusTag({ status }: Pick<AlertEvent, 'status'>) {
return < Tag className = { ` v2-alert-status is- ${ status } ` } color = { color } type = "light" size = "small" > { statusLabels [ status ] } < / Tag > ;
}
function AlertEventTabl e ( { rows , selectedID , onSelect } : { rows : AlertEvent [ ] ; selectedID : string ; onSelect : ( id : string ) = > void } ) {
const column s = useMemo ( ( ) = > [
function AlertTim e ( { value , className = '' } : { value? : string ; className? : string } ) {
const classe s = [ 'v2-alert-time' , className ] . filter ( Boolean ) . join ( ' ' ) ;
if ( ! value ) return < span className = { classes } > — < / span > ;
return < time className = { classes } dateTime = { value } title = { ` ${ value } · 上海时间 ` } > { formatAlertTime ( value ) } < / time > ;
}
const AlertEventTable = memo ( function AlertEventTable ( { rows , selectedID , compact , onSelect } : { rows : AlertEvent [ ] ; selectedID : string ; compact : boolean ; onSelect : ( id : string ) = > void } ) {
const columns = useMemo ( ( ) = > {
const allColumns = [
{
title : '' , dataIndex : 'selection' , width : 42 ,
render : ( _ : unknown , event : AlertEvent ) = > < Radio name = "alert-event" checked = { selectedID === event . id } onChange = { ( ) = > onSelect ( event . id ) } aria-label = { ` 选择 ${ event . ruleName } ` } / >
render : ( _ : unknown , event : AlertEvent ) = > < Radio name = "alert-event" checked = { selectedID === event . id } onChange = { ( ) = > onSelect ( event . id ) } aria-label = { ` 选择 ${ event . plate || event . vin } ${ event . ruleName} ` } / >
} ,
{ title : '严重程度' , dataIndex : 'severity' , width : 90 , render : ( _ : AlertEvent [ 'severity' ] , event : AlertEvent ) = > < SeverityTag severity = { event . severity } / > } ,
{
@@ -76,17 +83,19 @@ function AlertEventTable({ rows, selectedID, onSelect }: { rows: AlertEvent[]; s
} ,
{ title : '规则' , dataIndex : 'ruleName' , width : 170 , render : ( value : string ) = > < span className = "v2-alert-event-rule" title = { value } > { value } < / span > } ,
{ title : '协议' , dataIndex : 'protocol' , width : 92 , render : ( value : string ) = > value || '—' } ,
{ title : '触发时间' , dataIndex : 'triggeredAt' , width : 132 , render : ( value : string ) = > format AlertTime( value ) } ,
{ title : '触发时间' , dataIndex : 'triggeredAt' , width : 132 , render : ( value : string ) = > < AlertTime value = { value } / > } ,
{ title : '状态' , dataIndex : 'status' , width : 90 , render : ( _ : AlertEvent [ 'status' ] , event : AlertEvent ) = > < StatusTag status = { event . status } / > } ,
{
title : '触发 / 阈值' , dataIndex : 'evidence' , width : 164 ,
render : ( _ : unknown , event : AlertEvent ) = > < div className = "v2-alert-event-evidence" > < strong > { alertValue ( event ) } < / strong > < small > { thresholdText ( event ) } < / small > < / div >
} ,
{ title : '位置' , dataIndex : 'location' , width : 180 , render : ( value : string ) = > < span className = "v2-alert-event-location" title = { value } > { value || '—' } < / span > } ,
], [ onSelect , selectedID ] ) ;
] ;
return compact ? allColumns . filter ( ( column ) = > column . dataIndex !== 'evidence' && column . dataIndex !== 'location' ) : allColumns ;
} , [ compact , onSelect , selectedID ] ) ;
return < Table
className = " v2-alert-event-table"
className = { ` v2-alert-event-table${ compact ? ' is-compact' : '' } ` }
columns = { columns }
dataSource = { rows }
rowKey = "id"
@@ -100,7 +109,7 @@ function AlertEventTable({ rows, selectedID, onSelect }: { rows: AlertEvent[]; s
onOpen : ( ) = > onSelect ( event . id )
} ) : ( { } ) }
/ > ;
}
} ) ;
function EventInspector ( { event , note , acting , actionError , editable , onNote , onAction , onClose , sheet = false } : { event? : AlertEvent ; note : string ; acting : boolean ; actionError? : string ; editable : boolean ; onNote : ( value : string ) = > void ; onAction : ( action : 'acknowledge' | 'close' | 'ignore' ) = > void ; onClose : ( ) = > void ; sheet? : boolean } ) {
if ( ! event ) return < Card className = "v2-alert-inspector" bodyStyle = { { padding : 0 } } > < Empty className = "v2-alert-inspector-empty" image = { < IconAlarm / > } title = "选择告警事件" description = "查看触发证据、状态时间线和处置动作。" / > < / Card > ;
@@ -112,13 +121,13 @@ function EventInspector({ event, note, acting, actionError, editable, onNote, on
< Tag color = "blue" type = "light" size = "small" > { event . protocol || '未知协议' } < / Tag >
< / header >
< div className = "v2-alert-focus-facts" >
< span > < small > 触 发 时 间 < / small > < strong > { format AlertTime( event . triggeredAt ) } < / strong > < / span >
< span > < small > 触 发 时 间 < / small > < strong > < AlertTime value = { event . triggeredAt } / > < / strong > < / span >
< span > < small > 当 前 状 态 < / small > < strong > { statusLabels [ event . status ] } < / strong > < / span >
< / div >
< div className = "v2-alert-evidence" > < Card className = "v2-alert-evidence-value is-trigger" > < small > 触 发 值 < / small > < strong > { alertValue ( event ) } < / strong > < / Card > < Tag color = "grey" type = "light" size = "small" > VS < / Tag > < Card className = "v2-alert-evidence-value" > < small > 阈 值 条 件 < / small > < strong > { thresholdText ( event ) } < / strong > < / Card > < / div >
< / Card >
< Card className = "v2-alert-detail-card v2-alert-progress-card" title = { < strong > 处 理 进 度 < / strong > } headerLine >
{ event . actions ? . length ? < Timeline className = "v2-alert-timeline" aria-label = "告警处理进度" > { event . actions . map ( ( item , index ) = > < Timeline.Item key = { item . id } type = { index === event . actions ! . length - 1 ? 'ongoing' : 'default' } time = { < span > { item . actor } · { format AlertTime( item . createdAt ) } < / span > } > < strong > { actionLabels [ item . action ] ? ? item . action } < / strong > { item . note ? < p > { item . note } < / p > : null } < / Timeline.Item > ) } < / Timeline > : < Empty className = "v2-alert-timeline-empty" title = "暂无处理记录" description = "事件被确认、关闭或忽略后,将在这里形成审计履历。" / > }
{ event . actions ? . length ? < Timeline className = "v2-alert-timeline" aria-label = "告警处理进度" > { event . actions . map ( ( item , index ) = > < Timeline.Item key = { item . id } type = { index === event . actions ! . length - 1 ? 'ongoing' : 'default' } time = { < span > { item . actor } · < AlertTime value = { item . createdAt } / > < / span > } > < strong > { actionLabels [ item . action ] ? ? item . action } < / strong > { item . note ? < p > { item . note } < / p > : null } < / Timeline.Item > ) } < / Timeline > : < Empty className = "v2-alert-timeline-empty" title = "暂无处理记录" description = "事件被确认、关闭或忽略后,将在这里形成审计履历。" / > }
< / Card >
< Card className = "v2-alert-detail-card v2-alert-disposition-card" title = { < strong > 处 置 与 备 注 < / strong > } headerLine >
{ editable ? < > < TextArea maxCount = { 200 } autosize = { { minRows : 2 , maxRows : 5 } } placeholder = "请输入处置说明(选填)" value = { note } onChange = { onNote } / > { actionError ? < p className = "v2-alert-action-error" > { actionError } < / p > : null } < div className = "v2-alert-actions" > < Button theme = "solid" className = "is-primary" disabled = { acting || ! canAct ( event . status , 'acknowledge' ) } onClick = { ( ) = > onAction ( 'acknowledge' ) } > 确 认 告 警 < / Button > < Button theme = "light" disabled = { acting || ! canAct ( event . status , 'close' ) } onClick = { ( ) = > onAction ( 'close' ) } > 关 闭 < / Button > < Button theme = "borderless" disabled = { acting || ! canAct ( event . status , 'ignore' ) } onClick = { ( ) = > onAction ( 'ignore' ) } > 忽 略 < / Button > < / div > < / > : < p className = "v2-role-notice" > 当 前 为 只 读 角 色 , 可 查 看 完 整 证 据 与 处 置 记 录 。 < / p > }
@@ -132,8 +141,8 @@ function EventInspector({ event, note, acting, actionError, editable, onNote, on
{ key : '事件 ID' , value : < code > { event . id } < / code > } ,
{ key : '来源事件 ID' , value : event.sourceEventId || '—' } ,
{ key : '规则 / 版本' , value : ` ${ event . ruleId } / v ${ event . ruleVersion } ` } ,
{ key : '事件 / 接收' , value : ` ${ formatAlertTime ( event . eventAt ) } / ${ formatAlertTime ( event . receivedAt ) } ` } ,
{ key : '恢复时间' , value : format AlertTime ( event . recoveredAt ) } ,
{ key : '事件 / 接收' , value : < > < AlertTime value = { event . eventAt } / > / < AlertTime value = { event . receivedAt } / > < / > } ,
{ key : '恢复时间' , value : < AlertTime value = { event . recoveredAt } / > } ,
{ key : '处理人' , value : event.handler || '—' }
] } / >
< / Collapse.Panel >
@@ -167,6 +176,7 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e
const detail = useQuery ( { queryKey : [ 'alert-event-v2' , selectedID ] , queryFn : ( { signal } ) = > api . alertEventV2 ( selectedID , signal ) , enabled : Boolean ( selectedID ) , staleTime : 3_000 , gcTime : QUERY_MEMORY.summaryGcTime } ) ;
const action = useMutation ( { mutationFn : ( { name , event } : { name : 'acknowledge' | 'close' | 'ignore' ; event : AlertEvent } ) = > api . actOnAlertV2 ( event . id , { version : event.version , action : name , note } ) , onSuccess : async ( event ) = > { setNote ( '' ) ; queryClient . setQueryData ( [ 'alert-event-v2' , event . id ] , event ) ; await Promise . all ( [ queryClient . invalidateQueries ( { queryKey : [ 'alert-events-v2' ] } ) , queryClient . invalidateQueries ( { queryKey : [ 'alert-summary-v2' ] } ) , queryClient . invalidateQueries ( { queryKey : [ 'alert-notifications-v2' ] } ) ] ) ; } } ) ;
const closeDetail = ( ) = > { setSelection ( undefined ) ; setNote ( '' ) ; } ;
const selectEvent = useCallback ( ( id : string ) = > setSelection ( { scope : eventScope , id } ) , [ eventScope ] ) ;
const selectedEvent = detail . data ? ? rows . find ( ( row ) = > row . id === selectedID ) ;
useSideSheetA11y ( advancedOpen , '.v2-alert-filter-sidesheet' , 'v2-alert-advanced-filters' , '告警高级筛选' , '关闭告警高级筛选' ) ;
useSideSheetA11y ( mobileLayout && Boolean ( selectedID ) , '.v2-alert-detail-sidesheet' , 'v2-alert-detail' , '告警事件详情' , '关闭告警详情' ) ;
@@ -234,7 +244,7 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e
< Card className = "v2-alert-table-card" bodyStyle = { { padding : 0 } } >
< WorkspacePanelHeader
title = "告警事件"
description = " 点击事件查看触发证据、处理进度与处置动作"
description = { selectedID && ! mobileLayout ? '已展开处置详情 · 点击其他事件快速切换' : ' 点击事件查看触发证据、处理进度与处置动作' }
meta = { ` 共 ${ ( events . data ? . total ? ? 0 ) . toLocaleString ( 'zh-CN' ) } 条 ` }
actions = { < Button theme = "borderless" icon = { < IconRefresh / > } loading = { events . isFetching } onClick = { ( ) = > Promise . all ( [ events . refetch ( ) , summary . refetch ( ) , . . . ( selectedID ? [ detail . refetch ( ) ] : [ ] ) ] ) } > 刷 新 < / Button > }
/ >
@@ -244,8 +254,8 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e
aria-label = { mobileLayout ? '告警事件列表,可上下滚动' : undefined }
>
{ mobileLayout
? < div className = "v2-alert-mobile-list" > { rows . map ( ( event ) = > < Card key = { event . id } className = { ` v2-alert-mobile-card ${ selectedID === event . id ? ' is-selected' : '' } ` } bodyStyle = { { padding : 0 } } > < Button theme = "borderless" type = "tertiary" className = "v2-alert-mobile-action" aria-pressed = { selectedID === event . id } aria-expanded = { selectedID === event . id } aria-label = { ` 查看 ${ event . plate || event . vin } ${ event . ruleName } 告警详情 ` } onClick = { ( ) = > setSe lection ( { scope : eventScope , id : event.id } ) } > < span className = "v2-alert-mobile-card-content" > < header > < strong > { event . plate || '未绑定车牌' } < / strong > < span > < SeverityTag severity = { event . severity } / > < StatusTag status = { event . status } / > < / span > < / header > < p > { event . ruleName } < / p > < dl > < div > < dt > 触 发 时 间 < / dt > < dd > { format AlertTime( event . triggeredAt ) } < / dd > < / div > < div > < dt > 数 据 来 源 < / dt > < dd > { event . protocol || '—' } < / dd > < / div > < div > < dt > 触 发 / 阈 值 < / dt > < dd > { alertValue ( event ) } / { thresholdText ( event ) } < / dd > < / div > < / dl > < footer > 查 看 处 置 详 情 < IconChevronRight / > < / footer > < / span > < / Button > < / Card > ) } < / div >
: < AlertEventTable rows = { rows } selectedID = { selectedID } onSele ct= { ( id ) = > setSelection ( { scope : eventScope , id } ) } / > }
? < div className = "v2-alert-mobile-list" > { rows . map ( ( event ) = > < Card key = { event . id } className = { ` v2-alert-mobile-card ${ selectedID === event . id ? ' is-selected' : '' } ` } bodyStyle = { { padding : 0 } } > < Button theme = "borderless" type = "tertiary" className = "v2-alert-mobile-action" aria-pressed = { selectedID === event . id } aria-expanded = { selectedID === event . id } aria-label = { ` 查看 ${ event . plate || event . vin } ${ event . ruleName } 告警详情 ` } onClick = { ( ) = > selectEvent ( event . id ) } > < span className = "v2-alert-mobile-card-content" > < header > < strong > { event . plate || '未绑定车牌' } < / strong > < span > < SeverityTag severity = { event . severity } / > < StatusTag status = { event . status } / > < / span > < / header > < p > { event . ruleName } < / p > < dl > < div > < dt > 触 发 时 间 < / dt > < dd > < AlertTime value = { event . triggeredAt } / > < / dd > < / div > < div > < dt > 数 据 来 源 < / dt > < dd > { event . protocol || '—' } < / dd > < / div > < div > < dt > 触 发 / 阈 值 < / dt > < dd > { alertValue ( event ) } / { thresholdText ( event ) } < / dd > < / div > < / dl > < footer > 查 看 处 置 详 情 < IconChevronRight / > < / footer > < / span > < / Button > < / Card > ) } < / div >
: < AlertEventTable rows = { rows } selectedID = { selectedID } compa ct= { Boolean ( selectedID ) } onSelect = { selectEvent } / > }
{ events . isFetching ? < PanelLoading className = "v2-alert-loading" title = "正在更新事件…" description = "告警列表返回后会自动更新。" compact = { Boolean ( rows . length ) } / > : null }
{ ! events . isFetching && ! rows . length ? < PanelEmpty className = "v2-alert-empty" title = "当前筛选条件没有告警事件" description = "调整车辆、严重程度、状态或时间范围后重试。" / > : null }
< / div >
@@ -318,7 +328,7 @@ function RulesWorkspace({ rules, metrics }: { rules: AlertRule[]; metrics: Metri
description = { rules . length ? ` ${ rules . length } 条可审计规则 ` : '尚未创建规则' }
actions = { < Button theme = "light" type = "primary" icon = { < IconPlus / > } onClick = { ( ) = > { setSelectedID ( '__new__' ) ; setDraft ( emptyRule ( ) ) ; } } > 新 建 规 则 < / Button > }
/ >
< div className = "v2-alert-rule-items" >
< div className = "v2-alert-rule-items" role = "region" tabIndex = { 0 } aria-label = "告警规则列表,可左右滚动选择" >
{ rules . map ( ( rule ) = > < Button
className = { ` v2-alert-rule-item ${ selectedID === rule . id ? ' is-selected' : '' } ` }
key = { rule . id }
@@ -409,7 +419,7 @@ function NotificationsWorkspace({ editable }: { editable: boolean }) {
return < Card className = "v2-alert-notifications" bodyStyle = { { padding : 0 } } > < WorkspacePanelHeader title = "站内通知" description = "仅站内通道具备真实送达与已读状态" meta = { ` 共 ${ total . toLocaleString ( 'zh-CN' ) } 条 ` } actions = { editable ? < Button theme = "light" disabled = { read . isPending || ! items . some ( ( item ) = > ! item . read ) } onClick = { ( ) = > read . mutate ( items . filter ( ( item ) = > ! item . read ) . map ( ( item ) = > item . id ) ) } > { read . isPending ? '正在更新' : '本页全部已读' } < / Button > : < span className = "v2-role-badge" > 只 读 < / span > } / > { notifications . isError ? < InlineError message = { notifications . error ? . message ? ? '站内通知读取失败' } onRetry = { ( ) = > notifications . refetch ( ) } / > : null } { read . isError ? < InlineError message = { read . error instanceof Error ? read . error . message : '通知状态更新失败' } / > : null }
< div className = "v2-alert-notification-list" > { notifications . isPending ? < div className = "v2-alert-notification-loading" > < Spin size = "middle" tip = "正在读取站内通知" / > < / div > : items . length ? < CardGroup className = "v2-alert-notification-cards" type = "grid" spacing = { 0 } > { items . map ( ( item ) = > < Card className = { ` v2-alert-notification-card ${ item . read ? ' is-read' : ' is-unread' } ` } key = { item . id } title = { < span className = "v2-alert-notification-title" > < SeverityTag severity = { item . severity } / > < span title = { item . title } > { item . title } < / span > < / span > } headerExtraContent = { < Tag color = { item . read ? 'grey' : 'orange' } type = "light" size = "small" > { item . read ? '已读' : '未读' } < / Tag > } headerLine bodyStyle = { { padding : 0 } } >
< p className = "v2-alert-notification-content" title = { item . content } > { item . content } < / p >
< footer > < Typography.Text type = "tertiary" > { format AlertTime( item . createdAt ) } < / Typography.Text > { editable && ! item . read ? < Button theme = "borderless" disabled = { read . isPending } onClick = { ( ) = > read . mutate ( [ item . id ] ) } > { read . isPending ? '更新中' : '标为已读' } < / Button > : null } < / footer >
< footer > < Typography.Text type = "tertiary" > < AlertTime value = { item . createdAt } / > < / Typography.Text > { editable && ! item . read ? < Button theme = "borderless" disabled = { read . isPending } onClick = { ( ) = > read . mutate ( [ item . id ] ) } > { read . isPending ? '更新中' : '标为已读' } < / Button > : null } < / footer >
< / Card > ) } < / CardGroup > : ! notifications . isError ? < Empty className = "v2-alert-notification-empty" image = { < IconBell / > } title = "暂无站内通知" description = "告警触发或状态变更后,通知会在这里形成可追溯记录。" / > : null } < / div >
< footer className = "v2-alert-notification-pagination" > < TablePagination page = { page } totalPages = { totalPages } info = { ` 共 ${ total . toLocaleString ( 'zh-CN' ) } 条 · 本页 ${ items . length . toLocaleString ( 'zh-CN' ) } 条 ` } disabled = { notifications . isFetching } onPageChange = { ( next ) = > setOffset ( ( next - 1 ) * limit ) } pageSize = { limit } pageSizeLabel = "每页通知数" onPageSizeChange = { ( next ) = > { setLimit ( next ) ; setOffset ( 0 ) ; } } pageSizeOptions = { [ { value : 20 , label : '20 条/页' } , { value : 50 , label : '50 条/页' } ] } / > < / footer >
< Card className = "v2-alert-channel-card" title = { < strong > 外 部 通 知 通 道 < / strong > } headerLine bodyStyle = { { padding : 0 } } > < Descriptions className = "v2-alert-channel-descriptions" align = "left" size = "small" data = { [
@@ -432,6 +442,6 @@ export default function AlertsPage() {
const unread = notices . data ? . total ? ? 0 ;
const setTab = ( next : Tab ) = > { setTabState ( next ) ; const copy = new URLSearchParams ( params ) ; copy . set ( 'tab' , next ) ; setParams ( copy , { replace : true } ) ; } ;
const setFilters = ( next : Filters ) = > { setFilterState ( next ) ; const copy = new URLSearchParams ( ) ; if ( tab !== 'events' ) copy . set ( 'tab' , tab ) ; Object . entries ( next ) . forEach ( ( [ key , value ] ) = > { if ( value ) copy . set ( key , value ) ; } ) ; setParams ( copy , { replace : true } ) ; } ;
const tabs = [ { key : 'events' as const , label : '告警事件' , icon : < IconAlarm / > } , . . . ( admin ? [ { key : 'rules' as const , label : '规则配置' } ] : [ ] ) , { key : 'notifications' as const , label : '站内通知' , icon : < IconBell / > , count : unread || undefined } ] ;
const tabs = [ { key : 'events' as const , label : '告警事件' , icon : < IconAlarm / > } , . . . ( admin ? [ { key : 'rules' as const , label : '规则配置' , icon : < IconSetting / > } ] : [ ] ) , { key : 'notifications' as const , label : '站内通知' , icon : < IconBell / > , count : unread || undefined } ] ;
return < div className = { ` v2-alert-page is- ${ activeTab } ` } > < div className = "v2-alert-navigation" > < SegmentedTabs className = "v2-alert-tabs" variant = "filled" ariaLabel = "告警中心分类" value = { activeTab } items = { tabs } onChange = { setTab } / > < div className = "v2-alert-navigation-meta" > < Typography.Text type = "tertiary" > { operator ? '可处置事件' : '只读查看' } < / Typography.Text > < Tag color = { unread ? 'orange' : 'green' } type = "light" size = "small" > { unread ? ` ${ unread . toLocaleString ( 'zh-CN' ) } 条未读 ` : '通知已读' } < / Tag > < / div > < / div > { activeTab !== 'notifications' && rules . isError ? < InlineError message = { rules . error . message } onRetry = { ( ) = > rules . refetch ( ) } / > : null } { activeTab === 'rules' && metrics . isError ? < InlineError message = { metrics . error . message } onRetry = { ( ) = > metrics . refetch ( ) } / > : null } { activeTab === 'events' ? < EventWorkspace filters = { filters } draft = { eventDraft } setDraft = { setEventDraft } setFilters = { setFilters } rules = { rules . data ? ? [ ] } unread = { unread } editable = { operator } onTab = { setTab } / > : activeTab === 'rules' ? < RulesWorkspace rules = { rules . data ? ? [ ] } metrics = { metrics . data ? . metrics ? ? [ ] } / > : < NotificationsWorkspace editable = { operator } / > } < / div > ;
}