419 lines
18 KiB
TypeScript
419 lines
18 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
||
import { Alert, Card, Col, Row, Space, Statistic, Table, Tabs, Tag, Typography } from 'antd'
|
||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||
import {
|
||
getSituationalAwarenessGateway,
|
||
type BGPAnomaly,
|
||
type BGPCollectorCoverage,
|
||
type BGPEvent,
|
||
type BGPIncident,
|
||
type CollectorSummary,
|
||
type EventSummary,
|
||
type Summary,
|
||
} from '../../services/situational-awareness'
|
||
|
||
const { Title, Text } = Typography
|
||
const situationalAwarenessGateway = getSituationalAwarenessGateway()
|
||
|
||
function severityColor(severity: string) {
|
||
if (severity === 'critical') return 'red'
|
||
if (severity === 'high') return 'orange'
|
||
if (severity === 'medium') return 'gold'
|
||
return 'blue'
|
||
}
|
||
|
||
function BGP() {
|
||
const [loading, setLoading] = useState(false)
|
||
const [incidents, setIncidents] = useState<BGPIncident[]>([])
|
||
const [anomalies, setAnomalies] = useState<BGPAnomaly[]>([])
|
||
const [events, setEvents] = useState<BGPEvent[]>([])
|
||
const [collectors, setCollectors] = useState<BGPCollectorCoverage[]>([])
|
||
const [incidentSummary, setIncidentSummary] = useState<Summary | null>(null)
|
||
const [eventSummary, setEventSummary] = useState<EventSummary | null>(null)
|
||
const [collectorSummary, setCollectorSummary] = useState<CollectorSummary | null>(null)
|
||
const [compactViewport, setCompactViewport] = useState(false)
|
||
const tableRegionRef = useRef<HTMLDivElement | null>(null)
|
||
const [tableHeight, setTableHeight] = useState(360)
|
||
|
||
useEffect(() => {
|
||
const load = async () => {
|
||
setLoading(true)
|
||
try {
|
||
const snapshot = await situationalAwarenessGateway.getBGPOverview({
|
||
incidentPageSize: 50,
|
||
anomalyPageSize: 100,
|
||
eventPageSize: 20,
|
||
})
|
||
setIncidents(snapshot.incidents)
|
||
setIncidentSummary(snapshot.incidentSummary)
|
||
setAnomalies(snapshot.anomalies)
|
||
setEvents(snapshot.events)
|
||
setEventSummary(snapshot.eventSummary)
|
||
setCollectors(snapshot.collectors)
|
||
setCollectorSummary(snapshot.collectorSummary)
|
||
} catch (error) {
|
||
console.error('Failed to load BGP overview:', error)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
load()
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
const updateViewportMode = () => {
|
||
if (typeof window === 'undefined') return
|
||
const compact = window.devicePixelRatio >= 1.5 || window.innerHeight <= 860 || window.innerWidth <= 1440
|
||
setCompactViewport(compact)
|
||
}
|
||
|
||
updateViewportMode()
|
||
window.addEventListener('resize', updateViewportMode)
|
||
return () => window.removeEventListener('resize', updateViewportMode)
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
const updateTableHeight = () => {
|
||
const regionHeight = tableRegionRef.current?.offsetHeight || 0
|
||
setTableHeight(Math.max(compactViewport ? 180 : 240, regionHeight - (compactViewport ? 40 : 52)))
|
||
}
|
||
|
||
updateTableHeight()
|
||
|
||
if (typeof ResizeObserver === 'undefined') {
|
||
return undefined
|
||
}
|
||
|
||
const observer = new ResizeObserver(updateTableHeight)
|
||
if (tableRegionRef.current) observer.observe(tableRegionRef.current)
|
||
|
||
return () => observer.disconnect()
|
||
}, [compactViewport, collectors.length, incidents.length, anomalies.length, events.length])
|
||
|
||
const summaryItems = [
|
||
{ label: '近24h事件', value: collectorSummary?.recent_24h_events || 0 },
|
||
{ label: '活跃观测站', value: collectorSummary?.active_collectors || 0 },
|
||
{ label: '观测前缀', value: collectorSummary?.observed_prefixes || eventSummary?.prefix_count || 0 },
|
||
{ label: '事件总数', value: incidentSummary?.total || 0 },
|
||
{ label: '活跃事件', value: incidentSummary?.by_status?.active || 0 },
|
||
{ label: '严重事件', value: incidentSummary?.by_severity?.critical || 0 },
|
||
]
|
||
|
||
return (
|
||
<AppLayout>
|
||
<div className={`page-shell bgp-page${compactViewport ? ' bgp-page--compact' : ''}`}>
|
||
<div className="page-shell__header bgp-page__header">
|
||
<div>
|
||
<Title level={3} style={{ marginBottom: 4 }}>BGP观测</Title>
|
||
<Text type="secondary">先看事件态势,再下钻到原子异常明细。</Text>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="page-shell__body bgp-page__body">
|
||
<Space direction="vertical" size={compactViewport ? 12 : 16} style={{ width: '100%' }}>
|
||
<Alert
|
||
className="bgp-page__alert"
|
||
type="info"
|
||
showIcon
|
||
message="该视图展示的是控制平面异常,不代表真实业务流量路径。"
|
||
/>
|
||
|
||
<Card className="bgp-page__summary-card">
|
||
<Row gutter={[compactViewport ? 8 : 12, compactViewport ? 8 : 12]} className="bgp-page__summary-grid">
|
||
{summaryItems.map((item) => (
|
||
<Col key={item.label} xs={8} sm={8} md={4}>
|
||
<div className="bgp-page__summary-item">
|
||
<div className="bgp-page__summary-label">{item.label}</div>
|
||
<Statistic className="bgp-page__summary-stat" value={item.value} />
|
||
</div>
|
||
</Col>
|
||
))}
|
||
</Row>
|
||
</Card>
|
||
|
||
<Card className="bgp-page__table-card">
|
||
<div ref={tableRegionRef} className="table-scroll-region bgp-page__table-region">
|
||
<Tabs
|
||
className="bgp-page__tabs"
|
||
items={[
|
||
{
|
||
key: 'collectors',
|
||
label: '观测站覆盖',
|
||
children: (
|
||
<Table<BGPCollectorCoverage>
|
||
rowKey="collector"
|
||
loading={loading}
|
||
dataSource={collectors}
|
||
pagination={{ pageSize: compactViewport ? 6 : 8 }}
|
||
scroll={{ x: 'max-content', y: tableHeight }}
|
||
tableLayout="fixed"
|
||
columns={[
|
||
{
|
||
title: '观测站',
|
||
dataIndex: 'collector',
|
||
width: 120,
|
||
},
|
||
{
|
||
title: '位置',
|
||
width: 180,
|
||
render: (_, record) => [record.city, record.country].filter(Boolean).join(', ') || '-',
|
||
},
|
||
{
|
||
title: '近24h事件数',
|
||
dataIndex: 'recent_24h_observation_count',
|
||
width: 120,
|
||
},
|
||
{
|
||
title: '近7d事件数',
|
||
dataIndex: 'recent_7d_observation_count',
|
||
width: 120,
|
||
},
|
||
{
|
||
title: '前缀数',
|
||
dataIndex: 'prefix_count',
|
||
width: 120,
|
||
},
|
||
{
|
||
title: 'Origin ASN 数',
|
||
dataIndex: 'origin_asn_count',
|
||
width: 140,
|
||
},
|
||
{
|
||
title: '最近事件',
|
||
width: 220,
|
||
render: (_, record) => {
|
||
const time = formatDateTimeZhCN(record.latest_observed_at)
|
||
return record.latest_event_type ? `${record.latest_event_type} @ ${time}` : time
|
||
},
|
||
},
|
||
{
|
||
title: '日常覆盖范围',
|
||
dataIndex: 'baseline_scope',
|
||
render: (value: BGPCollectorCoverage['baseline_scope']) => {
|
||
const cities = value?.cities?.slice(0, 3).join(' / ') || ''
|
||
const countries = value?.countries?.slice(0, 3).join(' / ') || ''
|
||
return cities && countries ? `${cities} | ${countries}` : cities || countries || '-'
|
||
},
|
||
},
|
||
]}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
key: 'incidents',
|
||
label: '事件列表',
|
||
children: (
|
||
<Table<BGPIncident>
|
||
rowKey="id"
|
||
loading={loading}
|
||
dataSource={incidents}
|
||
pagination={{ pageSize: compactViewport ? 6 : 8 }}
|
||
scroll={{ x: 'max-content', y: tableHeight }}
|
||
tableLayout="fixed"
|
||
columns={[
|
||
{
|
||
title: '开始时间',
|
||
dataIndex: 'started_at',
|
||
width: 180,
|
||
render: (value: string | null) => formatDateTimeZhCN(value),
|
||
},
|
||
{
|
||
title: '类型',
|
||
dataIndex: 'incident_type',
|
||
width: 180,
|
||
},
|
||
{
|
||
title: '严重度',
|
||
dataIndex: 'severity',
|
||
width: 120,
|
||
render: (value: string) => <Tag color={severityColor(value)}>{value}</Tag>,
|
||
},
|
||
{
|
||
title: '影响前缀',
|
||
dataIndex: 'affected_prefixes',
|
||
width: 200,
|
||
render: (value: string[]) => (value && value.length > 0 ? value.join(', ') : '-'),
|
||
},
|
||
{
|
||
title: '观测站',
|
||
dataIndex: 'affected_collectors',
|
||
width: 180,
|
||
render: (value: string[]) => (value && value.length > 0 ? `${value.length}个 (${value.slice(0, 3).join(', ')})` : '-'),
|
||
},
|
||
{
|
||
title: '区域',
|
||
dataIndex: 'affected_regions',
|
||
width: 220,
|
||
render: (value: Array<{ country?: string; city?: string }>) => {
|
||
if (!value || value.length === 0) return '-'
|
||
return value
|
||
.slice(0, 3)
|
||
.map((item) => [item.city, item.country].filter(Boolean).join(', '))
|
||
.join(' / ')
|
||
},
|
||
},
|
||
{
|
||
title: '附近基础设施',
|
||
dataIndex: 'related_cables',
|
||
width: 260,
|
||
render: (value: BGPIncident['related_cables']) => {
|
||
if (!value || value.length === 0) return '-'
|
||
return value
|
||
.slice(0, 2)
|
||
.map((item) => {
|
||
const landing = item.landing_point || [item.city, item.country].filter(Boolean).join(', ')
|
||
const cable = item.cable_names && item.cable_names.length > 0 ? item.cable_names[0] : '附近登陆点'
|
||
const distance = item.distance_km !== undefined ? ` ${item.distance_km}km` : ''
|
||
return `${landing} (${cable}${distance})`
|
||
})
|
||
.join(' / ')
|
||
},
|
||
},
|
||
{
|
||
title: '置信度',
|
||
dataIndex: 'confidence',
|
||
width: 120,
|
||
render: (value: number) => `${Math.round((value || 0) * 100)}%`,
|
||
},
|
||
{
|
||
title: '摘要',
|
||
dataIndex: 'summary',
|
||
},
|
||
]}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
key: 'anomalies',
|
||
label: '异常明细',
|
||
children: (
|
||
<Table<BGPAnomaly>
|
||
rowKey="id"
|
||
loading={loading}
|
||
dataSource={anomalies}
|
||
pagination={{ pageSize: compactViewport ? 8 : 10 }}
|
||
scroll={{ x: 'max-content', y: tableHeight }}
|
||
tableLayout="fixed"
|
||
columns={[
|
||
{
|
||
title: '时间',
|
||
dataIndex: 'created_at',
|
||
width: 180,
|
||
render: (value: string | null) => formatDateTimeZhCN(value),
|
||
},
|
||
{
|
||
title: '类型',
|
||
dataIndex: 'anomaly_type',
|
||
width: 180,
|
||
},
|
||
{
|
||
title: '严重度',
|
||
dataIndex: 'severity',
|
||
width: 120,
|
||
render: (value: string) => <Tag color={severityColor(value)}>{value}</Tag>,
|
||
},
|
||
{
|
||
title: '前缀',
|
||
dataIndex: 'prefix',
|
||
width: 180,
|
||
render: (value: string | null) => value || '-',
|
||
},
|
||
{
|
||
title: 'ASN',
|
||
key: 'asn',
|
||
width: 160,
|
||
render: (_, record) => {
|
||
if (record.origin_asn && record.new_origin_asn) {
|
||
return `AS${record.origin_asn} -> AS${record.new_origin_asn}`
|
||
}
|
||
if (record.origin_asn) {
|
||
return `AS${record.origin_asn}`
|
||
}
|
||
return '-'
|
||
},
|
||
},
|
||
{
|
||
title: '来源',
|
||
dataIndex: 'source',
|
||
width: 140,
|
||
},
|
||
{
|
||
title: '置信度',
|
||
dataIndex: 'confidence',
|
||
width: 120,
|
||
render: (value: number) => `${Math.round((value || 0) * 100)}%`,
|
||
},
|
||
{
|
||
title: '摘要',
|
||
dataIndex: 'summary',
|
||
},
|
||
]}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
key: 'events',
|
||
label: '最近观测事件',
|
||
children: (
|
||
<Table<BGPEvent>
|
||
rowKey="id"
|
||
loading={loading}
|
||
dataSource={events}
|
||
pagination={{ pageSize: compactViewport ? 6 : 8 }}
|
||
scroll={{ x: 'max-content', y: tableHeight }}
|
||
tableLayout="fixed"
|
||
columns={[
|
||
{
|
||
title: '时间',
|
||
dataIndex: 'observed_at',
|
||
width: 180,
|
||
render: (value: string | null) => formatDateTimeZhCN(value),
|
||
},
|
||
{
|
||
title: '观测站',
|
||
dataIndex: 'collector',
|
||
width: 140,
|
||
render: (value: string | null) => value || '-',
|
||
},
|
||
{
|
||
title: '类型',
|
||
dataIndex: 'event_type',
|
||
width: 120,
|
||
},
|
||
{
|
||
title: '前缀',
|
||
dataIndex: 'prefix',
|
||
width: 200,
|
||
render: (value: string | null) => value || '-',
|
||
},
|
||
{
|
||
title: 'Origin ASN',
|
||
dataIndex: 'origin_asn',
|
||
width: 140,
|
||
render: (value: number | null) => (value ? `AS${value}` : '-'),
|
||
},
|
||
{
|
||
title: 'Peer ASN',
|
||
dataIndex: 'peer_asn',
|
||
width: 140,
|
||
render: (value: number | null) => (value ? `AS${value}` : '-'),
|
||
},
|
||
]}
|
||
/>
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
</div>
|
||
</Card>
|
||
</Space>
|
||
</div>
|
||
</div>
|
||
</AppLayout>
|
||
)
|
||
}
|
||
|
||
export default BGP
|