Files
planet/frontend/src/pages/BGP/BGP.tsx
2026-04-07 23:51:44 +08:00

419 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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