Files
planet/frontend/src/pages/BGP/BGP.tsx

713 lines
21 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 { ReloadOutlined } from '@ant-design/icons'
import {
Alert,
Button,
Card,
Col,
Descriptions,
Row,
Select,
Space,
Spin,
Statistic,
Table,
Tabs,
Tag,
Typography,
message,
type TableColumnsType,
type TabsProps,
} from 'antd'
import AppLayout from '../../components/AppLayout/AppLayout'
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
import { formatDateTimeZhCN } from '../../utils/datetime'
import {
type BGPAnomaly,
type BGPBriefRecord,
type BGPBriefRecordSummary,
type BGPCollectorCoverage,
type BGPEvent,
type BGPIncident,
type CollectorSummary,
type EventSummary,
type Summary,
getSituationalAwarenessGateway,
} from '../../services/situational-awareness'
const { Title, Text } = Typography
const OVERVIEW_OPTIONS = {
incidentPageSize: 50,
anomalyPageSize: 100,
eventPageSize: 20,
}
const DEFAULT_BGP_TAB = 'collectors'
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 formatBriefOptions(records: BGPBriefRecordSummary[]) {
return records.map((item) => ({
value: item.id,
label: `${formatDateTimeZhCN(item.generated_at)} · ${item.model}`,
}))
}
function sortBriefRecords<T extends BGPBriefRecordSummary>(records: T[]) {
return [...records].sort((left, right) => right.generated_at.localeCompare(left.generated_at))
}
function renderCollectorLocation(_: unknown, record: BGPCollectorCoverage) {
return [record.city, record.country].filter(Boolean).join(', ') || '-'
}
function renderCollectorLatestEvent(_: unknown, record: BGPCollectorCoverage) {
const time = formatDateTimeZhCN(record.latest_observed_at)
return record.latest_event_type ? `${record.latest_event_type} @ ${time}` : time
}
function renderCollectorScope(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 || '-'
}
function renderIncidentCollectors(value: string[]) {
return value && value.length > 0 ? `${value.length}个 (${value.slice(0, 3).join(', ')})` : '-'
}
function renderIncidentRegions(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(' / ')
}
function renderIncidentCables(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(' / ')
}
function renderPercentage(value: number) {
return `${Math.round((value || 0) * 100)}%`
}
function renderAnomalyAsn(_: unknown, record: BGPAnomaly) {
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 '-'
}
function renderOptionalAsn(value: number | null) {
return value ? `AS${value}` : '-'
}
const collectorColumns: TableColumnsType<BGPCollectorCoverage> = [
{
title: '观测站',
dataIndex: 'collector',
width: 120,
},
{
title: '位置',
width: 180,
render: renderCollectorLocation,
},
{
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: renderCollectorLatestEvent,
},
{
title: '日常覆盖范围',
dataIndex: 'baseline_scope',
width: 280,
render: renderCollectorScope,
},
]
const incidentColumns: TableColumnsType<BGPIncident> = [
{
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: renderIncidentCollectors,
},
{
title: '区域',
dataIndex: 'affected_regions',
width: 220,
render: renderIncidentRegions,
},
{
title: '附近基础设施',
dataIndex: 'related_cables',
width: 260,
render: renderIncidentCables,
},
{
title: '置信度',
dataIndex: 'confidence',
width: 120,
render: renderPercentage,
},
{
title: '摘要',
dataIndex: 'summary',
width: 320,
},
]
const anomalyColumns: TableColumnsType<BGPAnomaly> = [
{
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: renderAnomalyAsn,
},
{
title: '来源',
dataIndex: 'source',
width: 140,
},
{
title: '置信度',
dataIndex: 'confidence',
width: 120,
render: renderPercentage,
},
{
title: '摘要',
dataIndex: 'summary',
width: 320,
},
]
const eventColumns: TableColumnsType<BGPEvent> = [
{
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: renderOptionalAsn,
},
{
title: 'Peer ASN',
dataIndex: 'peer_asn',
width: 140,
render: renderOptionalAsn,
},
]
async function loadInitialBrief(savedBriefs: BGPBriefRecordSummary[]) {
const latestBrief = await situationalAwarenessGateway.getLatestBGPBrief()
if (latestBrief) {
return latestBrief
}
if (savedBriefs[0]) {
return situationalAwarenessGateway.getBGPBrief(savedBriefs[0].id)
}
return null
}
function BGP() {
const [messageApi, contextHolder] = message.useMessage()
const [summaryLoading, setSummaryLoading] = useState(false)
const [activeTab, setActiveTab] = useState(DEFAULT_BGP_TAB)
const [incidents, setIncidents] = useState<BGPIncident[]>([])
const [anomalies, setAnomalies] = useState<BGPAnomaly[]>([])
const [events, setEvents] = useState<BGPEvent[]>([])
const [collectors, setCollectors] = useState<BGPCollectorCoverage[]>([])
const [collectorsLoading, setCollectorsLoading] = useState(false)
const [incidentsLoading, setIncidentsLoading] = useState(false)
const [anomaliesLoading, setAnomaliesLoading] = useState(false)
const [eventsLoading, setEventsLoading] = useState(false)
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 [tableHeight, setTableHeight] = useState(360)
const [briefLoading, setBriefLoading] = useState(false)
const [briefDetailLoading, setBriefDetailLoading] = useState(false)
const [brief, setBrief] = useState<BGPBriefRecord | null>(null)
const [briefOptions, setBriefOptions] = useState<BGPBriefRecordSummary[]>([])
const [selectedBriefId, setSelectedBriefId] = useState<string | null>(null)
const [briefListLoaded, setBriefListLoaded] = useState(false)
const tableRegionRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const load = async () => {
setSummaryLoading(true)
try {
const summary = await situationalAwarenessGateway.getBGPSummary()
setIncidentSummary(summary.incidentSummary)
setEventSummary(summary.eventSummary)
setCollectorSummary(summary.collectorSummary)
} catch (error) {
console.error('Failed to load BGP overview:', error)
} finally {
setSummaryLoading(false)
}
}
void load()
}, [])
useEffect(() => {
const loadTabData = async () => {
if (activeTab === 'collectors' && collectors.length === 0 && !collectorsLoading) {
setCollectorsLoading(true)
try {
setCollectors(await situationalAwarenessGateway.getBGPCollectors())
} catch (error) {
console.error('Failed to load BGP collectors:', error)
} finally {
setCollectorsLoading(false)
}
return
}
if (activeTab === 'incidents' && incidents.length === 0 && !incidentsLoading) {
setIncidentsLoading(true)
try {
setIncidents(await situationalAwarenessGateway.getBGPIncidents(OVERVIEW_OPTIONS.incidentPageSize))
} catch (error) {
console.error('Failed to load BGP incidents:', error)
} finally {
setIncidentsLoading(false)
}
return
}
if (activeTab === 'anomalies' && anomalies.length === 0 && !anomaliesLoading) {
setAnomaliesLoading(true)
try {
setAnomalies(await situationalAwarenessGateway.getBGPAnomalies(OVERVIEW_OPTIONS.anomalyPageSize))
} catch (error) {
console.error('Failed to load BGP anomalies:', error)
} finally {
setAnomaliesLoading(false)
}
return
}
if (activeTab === 'events' && events.length === 0 && !eventsLoading) {
setEventsLoading(true)
try {
setEvents(await situationalAwarenessGateway.getBGPEvents(OVERVIEW_OPTIONS.eventPageSize))
} catch (error) {
console.error('Failed to load BGP events:', error)
} finally {
setEventsLoading(false)
}
return
}
if (activeTab === 'brief' && !briefListLoaded && !briefDetailLoading) {
setBriefDetailLoading(true)
try {
const savedBriefs = sortBriefRecords(await situationalAwarenessGateway.listBGPBriefs())
const initialBrief = await loadInitialBrief(savedBriefs)
setBriefOptions(savedBriefs)
setBrief(initialBrief)
setSelectedBriefId(initialBrief?.id || savedBriefs[0]?.id || null)
setBriefListLoaded(true)
} catch (error) {
console.error('Failed to load BGP briefs:', error)
} finally {
setBriefDetailLoading(false)
}
}
}
void loadTabData()
}, [
activeTab,
anomalies.length,
anomaliesLoading,
briefDetailLoading,
briefListLoaded,
collectors.length,
collectorsLoading,
events.length,
eventsLoading,
incidents.length,
incidentsLoading,
])
useEffect(() => {
const updateViewportMode = () => {
if (typeof window === 'undefined') return
setCompactViewport(window.innerHeight <= 860 || window.innerWidth <= 1440)
}
updateViewportMode()
window.addEventListener('resize', updateViewportMode)
return () => window.removeEventListener('resize', updateViewportMode)
}, [])
useEffect(() => {
const updateTableHeight = () => {
const regionHeight = tableRegionRef.current?.offsetHeight || 0
const minimumHeight = compactViewport ? 180 : 240
const verticalOffset = compactViewport ? 40 : 52
setTableHeight(Math.max(minimumHeight, regionHeight - verticalOffset))
}
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 },
]
const handleGenerateBrief = async () => {
setBriefLoading(true)
try {
const record = await situationalAwarenessGateway.generateBGPBrief()
setBrief(record)
setSelectedBriefId(record.id)
setBriefOptions((current) => sortBriefRecords([record, ...current.filter((item) => item.id !== record.id)]))
setBriefListLoaded(true)
messageApi.success('BGP AI 简报已生成')
} catch (error) {
console.error('Failed to generate BGP brief:', error)
messageApi.error('BGP AI 简报生成失败,请检查 AI Provider 或稍后再试')
} finally {
setBriefLoading(false)
}
}
const handleBriefSelectionChange = async (briefId: string) => {
setSelectedBriefId(briefId)
setBriefDetailLoading(true)
try {
setBrief(await situationalAwarenessGateway.getBGPBrief(briefId))
} catch (error) {
console.error('Failed to load saved BGP brief:', error)
messageApi.error('BGP AI 简报加载失败,请稍后再试')
} finally {
setBriefDetailLoading(false)
}
}
const briefTabContent = (
<div className="bgp-page__brief-card">
<div className="bgp-page__brief-head">
<div>
<Text strong>BGP AI </Text>
<div className="bgp-page__brief-subtitle">
Markdown
</div>
</div>
<div className="bgp-page__brief-actions">
<Select
className="bgp-page__brief-select"
placeholder="选择历史简报"
value={selectedBriefId || undefined}
options={formatBriefOptions(briefOptions)}
onChange={(value) => void handleBriefSelectionChange(value)}
disabled={briefLoading || briefDetailLoading || briefOptions.length === 0}
/>
<Button
type="primary"
icon={<ReloadOutlined />}
loading={briefLoading}
onClick={() => void handleGenerateBrief()}
>
AI
</Button>
</div>
</div>
{briefLoading || briefDetailLoading ? (
<div className="bgp-page__brief-loading">
<Spin />
<Text type="secondary">
{briefLoading ? '正在整理 BGP 事实并生成简报...' : '正在加载已保存的 BGP 简报...'}
</Text>
</div>
) : brief ? (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Descriptions size="small" column={compactViewport ? 1 : 3} className="bgp-page__brief-meta">
<Descriptions.Item label="Provider">{brief.provider || '-'}</Descriptions.Item>
<Descriptions.Item label="模型">{brief.model || '-'}</Descriptions.Item>
<Descriptions.Item label="请求ID">{brief.request_id || '-'}</Descriptions.Item>
<Descriptions.Item label="生成时间" span={compactViewport ? 1 : 3}>
{formatDateTimeZhCN(brief.generated_at)}
</Descriptions.Item>
</Descriptions>
<div className="bgp-page__brief-content">
<MarkdownRenderer markdown={brief.content_markdown} />
</div>
</Space>
) : (
<div className="bgp-page__brief-empty">
<Text type="secondary"> BGP Markdown </Text>
</div>
)}
</div>
)
const tabItems: TabsProps['items'] = [
{
key: 'collectors',
label: '观测站覆盖',
children: (
<Table<BGPCollectorCoverage>
rowKey="collector"
loading={summaryLoading || collectorsLoading}
dataSource={collectors}
pagination={false}
scroll={{ x: 1240, y: tableHeight }}
tableLayout="fixed"
columns={collectorColumns}
/>
),
},
{
key: 'incidents',
label: '事件列表',
children: (
<Table<BGPIncident>
rowKey="id"
loading={summaryLoading || incidentsLoading}
dataSource={incidents}
pagination={false}
scroll={{ x: 1660, y: tableHeight }}
tableLayout="fixed"
columns={incidentColumns}
/>
),
},
{
key: 'anomalies',
label: '异常明细',
children: (
<Table<BGPAnomaly>
rowKey="id"
loading={summaryLoading || anomaliesLoading}
dataSource={anomalies}
pagination={false}
scroll={{ x: 1380, y: tableHeight }}
tableLayout="fixed"
columns={anomalyColumns}
/>
),
},
{
key: 'events',
label: '最近观测事件',
children: (
<Table<BGPEvent>
rowKey="id"
loading={summaryLoading || eventsLoading}
dataSource={events}
pagination={false}
scroll={{ x: 980, y: tableHeight }}
tableLayout="fixed"
columns={eventColumns}
/>
),
},
{
key: 'brief',
label: 'AI 简报',
children: briefTabContent,
},
]
return (
<AppLayout>
{contextHolder}
<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
className="bgp-page__stack"
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${compactViewport ? ' bgp-page__summary-grid--compact' : ''}`}
wrap={!compactViewport}
>
{summaryItems.map((item) => (
<Col
key={item.label}
xs={24}
sm={12}
md={8}
flex={compactViewport ? '180px' : undefined}
>
<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"
activeKey={activeTab}
onChange={setActiveTab}
items={tabItems}
/>
</div>
</Card>
</Space>
</div>
</div>
</AppLayout>
)
}
export default BGP