fix: ship persistent bgp ai briefs and optimize bgp queries

This commit is contained in:
rayd1o
2026-04-10 00:33:01 +08:00
parent ed898aef9c
commit 83839b8b11
21 changed files with 1996 additions and 557 deletions

View File

@@ -0,0 +1,167 @@
import type { ReactNode } from 'react'
interface MarkdownRendererProps {
markdown: string
className?: string
}
function renderInlineMarkdown(text: string): ReactNode[] {
const result: ReactNode[] = []
const pattern = /(\[[^\]]+\]\([^)]+\)|`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*)/g
let lastIndex = 0
let key = 0
for (const match of text.matchAll(pattern)) {
const matchedText = match[0]
const start = match.index ?? 0
if (start > lastIndex) {
result.push(text.slice(lastIndex, start))
}
if (matchedText.startsWith('[')) {
const linkMatch = matchedText.match(/^\[([^\]]+)\]\(([^)]+)\)$/)
if (linkMatch) {
result.push(
<a key={`inline-${key}`} href={linkMatch[2]} target="_blank" rel="noreferrer">
{linkMatch[1]}
</a>,
)
key += 1
lastIndex = start + matchedText.length
continue
}
}
if (matchedText.startsWith('`')) {
result.push(<code key={`inline-${key}`}>{matchedText.slice(1, -1)}</code>)
key += 1
lastIndex = start + matchedText.length
continue
}
if (matchedText.startsWith('**')) {
result.push(<strong key={`inline-${key}`}>{matchedText.slice(2, -2)}</strong>)
key += 1
lastIndex = start + matchedText.length
continue
}
if (matchedText.startsWith('*')) {
result.push(<em key={`inline-${key}`}>{matchedText.slice(1, -1)}</em>)
key += 1
lastIndex = start + matchedText.length
continue
}
}
if (lastIndex < text.length) {
result.push(text.slice(lastIndex))
}
return result
}
export default function MarkdownRenderer({ markdown, className }: MarkdownRendererProps) {
const lines = markdown.replace(/\r\n/g, '\n').split('\n')
const nodes: ReactNode[] = []
let index = 0
while (index < lines.length) {
const line = lines[index]
const trimmed = line.trim()
if (!trimmed) {
index += 1
continue
}
if (trimmed.startsWith('```')) {
const codeLines: string[] = []
index += 1
while (index < lines.length && !lines[index].trim().startsWith('```')) {
codeLines.push(lines[index])
index += 1
}
if (index < lines.length) {
index += 1
}
nodes.push(
<pre key={`block-${index}`}>
<code>{codeLines.join('\n')}</code>
</pre>,
)
continue
}
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/)
if (headingMatch) {
const level = headingMatch[1].length
const content = renderInlineMarkdown(headingMatch[2])
if (level === 1) nodes.push(<h1 key={`block-${index}`}>{content}</h1>)
else if (level === 2) nodes.push(<h2 key={`block-${index}`}>{content}</h2>)
else if (level === 3) nodes.push(<h3 key={`block-${index}`}>{content}</h3>)
else if (level === 4) nodes.push(<h4 key={`block-${index}`}>{content}</h4>)
else nodes.push(<p key={`block-${index}`} className="markdown-renderer__heading-fallback">{content}</p>)
index += 1
continue
}
if (trimmed.startsWith('> ')) {
const quoteLines: string[] = []
while (index < lines.length && lines[index].trim().startsWith('> ')) {
quoteLines.push(lines[index].trim().slice(2))
index += 1
}
nodes.push(<blockquote key={`block-${index}`}>{quoteLines.join(' ')}</blockquote>)
continue
}
const unorderedMatch = trimmed.match(/^[-*]\s+(.+)$/)
if (unorderedMatch) {
const items: string[] = []
while (index < lines.length) {
const itemMatch = lines[index].trim().match(/^[-*]\s+(.+)$/)
if (!itemMatch) break
items.push(itemMatch[1])
index += 1
}
nodes.push(
<ul key={`block-${index}`}>
{items.map((item, itemIndex) => (
<li key={`item-${itemIndex}`}>{renderInlineMarkdown(item)}</li>
))}
</ul>,
)
continue
}
const orderedMatch = trimmed.match(/^\d+\.\s+(.+)$/)
if (orderedMatch) {
const items: string[] = []
while (index < lines.length) {
const itemMatch = lines[index].trim().match(/^\d+\.\s+(.+)$/)
if (!itemMatch) break
items.push(itemMatch[1])
index += 1
}
nodes.push(
<ol key={`block-${index}`}>
{items.map((item, itemIndex) => (
<li key={`item-${itemIndex}`}>{renderInlineMarkdown(item)}</li>
))}
</ol>,
)
continue
}
const paragraphLines: string[] = []
while (index < lines.length && lines[index].trim()) {
paragraphLines.push(lines[index].trim())
index += 1
}
nodes.push(<p key={`block-${index}`}>{renderInlineMarkdown(paragraphLines.join(' '))}</p>)
}
return <div className={className ? `markdown-renderer ${className}` : 'markdown-renderer'}>{nodes}</div>
}

View File

@@ -1006,6 +1006,155 @@ body {
font-size: 13px;
}
.bgp-page__brief-card {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 100%;
}
.bgp-page__brief-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.bgp-page__brief-actions {
display: flex;
align-items: center;
gap: 12px;
}
.bgp-page__brief-select {
min-width: 260px;
}
.bgp-page__brief-subtitle {
margin-top: 4px;
color: rgba(0, 0, 0, 0.45);
font-size: 13px;
line-height: 1.5;
}
.bgp-page__brief-loading,
.bgp-page__brief-empty {
min-height: 72px;
display: flex;
align-items: center;
gap: 12px;
}
.bgp-page__brief-meta .ant-descriptions-view {
background: #f7f8fa;
border-radius: 12px;
padding: 8px 12px;
}
.bgp-page__brief-content {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: 14px 16px;
border-radius: 14px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(246, 248, 251, 0.98));
border: 1px solid rgba(5, 5, 5, 0.08);
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
}
.markdown-renderer {
color: #262626;
font-size: 13px;
line-height: 1.7;
}
.markdown-renderer > :first-child {
margin-top: 0;
}
.markdown-renderer > :last-child {
margin-bottom: 0;
}
.markdown-renderer h1,
.markdown-renderer h2,
.markdown-renderer h3,
.markdown-renderer h4 {
margin: 1.2em 0 0.5em;
color: #111827;
font-weight: 600;
line-height: 1.4;
}
.markdown-renderer h1 {
font-size: 24px;
}
.markdown-renderer h2 {
font-size: 19px;
}
.markdown-renderer h3 {
font-size: 16px;
}
.markdown-renderer p,
.markdown-renderer ul,
.markdown-renderer ol,
.markdown-renderer blockquote,
.markdown-renderer pre {
margin: 0 0 0.9em;
}
.markdown-renderer ul,
.markdown-renderer ol {
padding-left: 1.4em;
}
.markdown-renderer li + li {
margin-top: 0.25em;
}
.markdown-renderer blockquote {
padding: 10px 14px;
border-left: 3px solid #91caff;
border-radius: 0 10px 10px 0;
background: rgba(230, 244, 255, 0.8);
color: #1f2937;
}
.markdown-renderer pre {
overflow: auto;
padding: 12px 14px;
border-radius: 10px;
background: #0f172a;
color: #e2e8f0;
}
.markdown-renderer code {
padding: 0.08em 0.32em;
border-radius: 6px;
background: rgba(15, 23, 42, 0.08);
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.92em;
}
.markdown-renderer pre code {
padding: 0;
background: transparent;
color: inherit;
}
.markdown-renderer a {
color: #1677ff;
text-decoration: none;
}
.markdown-renderer a:hover {
text-decoration: underline;
}
.bgp-page__summary-card .ant-card-body {
padding: 10px 12px;
}
@@ -1122,6 +1271,10 @@ body {
overflow: hidden;
}
.bgp-page__tabs .ant-tabs-tabpane-hidden {
display: none;
}
.bgp-page__tabs .ant-table-wrapper {
flex: 1 1 auto;
min-width: 0;
@@ -1175,6 +1328,16 @@ body {
padding: 10px 12px;
}
.bgp-page--compact .bgp-page__brief-head {
flex-direction: column;
align-items: stretch;
}
.bgp-page--compact .bgp-page__brief-actions,
.bgp-page--compact .bgp-page__brief-select {
width: 100%;
}
.bgp-page--compact .bgp-page__summary-item {
min-height: 64px;
padding: 8px 10px;

View File

@@ -1,19 +1,50 @@
import { useEffect, useRef, useState } from 'react'
import { Alert, Card, Col, Row, Space, Statistic, Table, Tabs, Tag, Typography } from 'antd'
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 {
getSituationalAwarenessGateway,
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) {
@@ -23,50 +54,402 @@ function severityColor(severity: string) {
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 [loading, setLoading] = useState(false)
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 tableRegionRef = useRef<HTMLDivElement | null>(null)
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 () => {
setLoading(true)
setSummaryLoading(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)
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 {
setLoading(false)
setSummaryLoading(false)
}
}
load()
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
const compact = window.innerHeight <= 860 || window.innerWidth <= 1440
setCompactViewport(compact)
setCompactViewport(window.innerHeight <= 860 || window.innerWidth <= 1440)
}
updateViewportMode()
@@ -77,7 +460,9 @@ function BGP() {
useEffect(() => {
const updateTableHeight = () => {
const regionHeight = tableRegionRef.current?.offsetHeight || 0
setTableHeight(Math.max(compactViewport ? 180 : 240, regionHeight - (compactViewport ? 40 : 52)))
const minimumHeight = compactViewport ? 180 : 240
const verticalOffset = compactViewport ? 40 : 52
setTableHeight(Math.max(minimumHeight, regionHeight - verticalOffset))
}
updateTableHeight()
@@ -87,7 +472,9 @@ function BGP() {
}
const observer = new ResizeObserver(updateTableHeight)
if (tableRegionRef.current) observer.observe(tableRegionRef.current)
if (tableRegionRef.current) {
observer.observe(tableRegionRef.current)
}
return () => observer.disconnect()
}, [compactViewport, collectors.length, incidents.length, anomalies.length, events.length])
@@ -101,8 +488,165 @@ function BGP() {
{ 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>
@@ -152,277 +696,9 @@ function BGP() {
<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={false}
scroll={{ x: 1240, 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',
width: 280,
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={false}
scroll={{ x: 1660, 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',
width: 320,
},
]}
/>
),
},
{
key: 'anomalies',
label: '异常明细',
children: (
<Table<BGPAnomaly>
rowKey="id"
loading={loading}
dataSource={anomalies}
pagination={false}
scroll={{ x: 1380, 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',
width: 320,
},
]}
/>
),
},
{
key: 'events',
label: '最近观测事件',
children: (
<Table<BGPEvent>
rowKey="id"
loading={loading}
dataSource={events}
pagination={false}
scroll={{ x: 980, 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}` : '-'),
},
]}
/>
),
},
]}
activeKey={activeTab}
onChange={setActiveTab}
items={tabItems}
/>
</div>
</Card>

View File

@@ -2,11 +2,14 @@ import axios from 'axios'
import type { SituationalAwarenessGateway } from './port'
import type {
BGPAnomaly,
BGPBriefRecord,
BGPBriefRecordSummary,
BGPCollectorCoverage,
BGPEvent,
BGPIncident,
BGPOverviewOptions,
BGPOverviewSnapshot,
BGPSummarySnapshot,
CollectorSummary,
EventSummary,
ListResponse,
@@ -16,6 +19,91 @@ import type {
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1'
export class HttpSituationalAwarenessGateway implements SituationalAwarenessGateway {
private bgpSummaryPromise: Promise<BGPSummarySnapshot> | null = null
private collectorsPromise: Promise<BGPCollectorCoverage[]> | null = null
private incidentsPromises = new Map<number, Promise<BGPIncident[]>>()
private anomaliesPromises = new Map<number, Promise<BGPAnomaly[]>>()
private eventsPromises = new Map<number, Promise<BGPEvent[]>>()
private briefListPromise: Promise<BGPBriefRecordSummary[]> | null = null
private latestBriefPromise: Promise<BGPBriefRecord | null> | null = null
async getBGPSummary(): Promise<BGPSummarySnapshot> {
if (!this.bgpSummaryPromise) {
this.bgpSummaryPromise = axios
.get<BGPSummarySnapshot>(`${API_BASE_URL}/bgp/overview/summary`)
.then((response) => response.data)
.finally(() => {
this.bgpSummaryPromise = null
})
}
return this.bgpSummaryPromise
}
async getBGPCollectors(): Promise<BGPCollectorCoverage[]> {
if (!this.collectorsPromise) {
this.collectorsPromise = axios
.get<ListResponse<BGPCollectorCoverage>>(`${API_BASE_URL}/bgp/collectors`)
.then((response) => response.data.data || [])
.finally(() => {
this.collectorsPromise = null
})
}
return this.collectorsPromise
}
async getBGPIncidents(pageSize = 50): Promise<BGPIncident[]> {
const existing = this.incidentsPromises.get(pageSize)
if (existing) {
return existing
}
const request = axios
.get<ListResponse<BGPIncident>>(`${API_BASE_URL}/bgp/incidents`, { params: { page_size: pageSize } })
.then((response) => response.data.data || [])
.finally(() => {
this.incidentsPromises.delete(pageSize)
})
this.incidentsPromises.set(pageSize, request)
return request
}
async getBGPAnomalies(pageSize = 100): Promise<BGPAnomaly[]> {
const existing = this.anomaliesPromises.get(pageSize)
if (existing) {
return existing
}
const request = axios
.get<ListResponse<BGPAnomaly>>(`${API_BASE_URL}/bgp/anomalies`, { params: { page_size: pageSize } })
.then((response) => response.data.data || [])
.finally(() => {
this.anomaliesPromises.delete(pageSize)
})
this.anomaliesPromises.set(pageSize, request)
return request
}
async getBGPEvents(pageSize = 20): Promise<BGPEvent[]> {
const existing = this.eventsPromises.get(pageSize)
if (existing) {
return existing
}
const request = axios
.get<ListResponse<BGPEvent>>(`${API_BASE_URL}/bgp/events`, { params: { page_size: pageSize } })
.then((response) => response.data.data || [])
.finally(() => {
this.eventsPromises.delete(pageSize)
})
this.eventsPromises.set(pageSize, request)
return request
}
async getBGPOverview(options: BGPOverviewOptions = {}): Promise<BGPOverviewSnapshot> {
const {
incidentPageSize = 50,
@@ -43,4 +131,46 @@ export class HttpSituationalAwarenessGateway implements SituationalAwarenessGate
collectorSummary: collectorSummaryRes.data,
}
}
async generateBGPBrief(): Promise<BGPBriefRecord> {
const response = await axios.post<BGPBriefRecord>(`${API_BASE_URL}/ai/bgp/brief`, {})
return response.data
}
async listBGPBriefs(): Promise<BGPBriefRecordSummary[]> {
if (!this.briefListPromise) {
this.briefListPromise = axios
.get<BGPBriefRecordSummary[]>(`${API_BASE_URL}/ai/bgp/briefs`)
.then((response) => response.data || [])
.finally(() => {
this.briefListPromise = null
})
}
return this.briefListPromise
}
async getBGPBrief(briefId: string): Promise<BGPBriefRecord> {
const response = await axios.get<BGPBriefRecord>(`${API_BASE_URL}/ai/bgp/briefs/${briefId}`)
return response.data
}
async getLatestBGPBrief(): Promise<BGPBriefRecord | null> {
if (!this.latestBriefPromise) {
this.latestBriefPromise = axios
.get<BGPBriefRecord | null>(`${API_BASE_URL}/ai/bgp/briefs/latest`)
.then((response) => response.data)
.catch((error) => {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null
}
throw error
})
.finally(() => {
this.latestBriefPromise = null
})
}
return this.latestBriefPromise
}
}

View File

@@ -1,5 +1,11 @@
import type { SituationalAwarenessGateway } from './port'
import type { BGPOverviewOptions, BGPOverviewSnapshot } from './types'
import type {
BGPBriefRecord,
BGPBriefRecordSummary,
BGPOverviewOptions,
BGPOverviewSnapshot,
BGPSummarySnapshot,
} from './types'
const EMPTY_SNAPSHOT: BGPOverviewSnapshot = {
incidents: [],
@@ -29,7 +35,69 @@ const EMPTY_SNAPSHOT: BGPOverviewSnapshot = {
}
export class MockSituationalAwarenessGateway implements SituationalAwarenessGateway {
private readonly brief: BGPBriefRecord = {
id: 'mock-bgp-brief-001',
title: 'BGP AI 简报',
provider: 'mock',
model: 'mock-brief',
request_id: 'mock-bgp-brief',
generated_at: '2026-04-09T12:00:00+08:00',
content_markdown: [
'# BGP 态势简报',
'',
'## 当前判断',
'',
'- 当前 BGP 态势以高严重度事件为主。',
'- 建议优先核查活跃 incidents 涉及的受影响前缀与重点观测站。',
'',
'## 值班建议',
'',
'1. 先确认高严重度 incident 是否持续活跃。',
'2. 对照重点 collector 的近 24h 波动,避免将控制平面噪声误判为真实业务中断。',
].join('\n'),
}
async getBGPOverview(_options: BGPOverviewOptions = {}): Promise<BGPOverviewSnapshot> {
return EMPTY_SNAPSHOT
}
async getBGPSummary(): Promise<BGPSummarySnapshot> {
return {
incidentSummary: EMPTY_SNAPSHOT.incidentSummary,
eventSummary: EMPTY_SNAPSHOT.eventSummary,
collectorSummary: EMPTY_SNAPSHOT.collectorSummary,
}
}
async getBGPCollectors() {
return EMPTY_SNAPSHOT.collectors
}
async getBGPIncidents() {
return EMPTY_SNAPSHOT.incidents
}
async getBGPAnomalies() {
return EMPTY_SNAPSHOT.anomalies
}
async getBGPEvents() {
return EMPTY_SNAPSHOT.events
}
async generateBGPBrief(): Promise<BGPBriefRecord> {
return this.brief
}
async listBGPBriefs(): Promise<BGPBriefRecordSummary[]> {
return [this.brief]
}
async getBGPBrief(_briefId: string): Promise<BGPBriefRecord> {
return this.brief
}
async getLatestBGPBrief(): Promise<BGPBriefRecord | null> {
return this.brief
}
}

View File

@@ -1,5 +1,24 @@
import type { BGPOverviewOptions, BGPOverviewSnapshot } from './types'
import type {
BGPAnomaly,
BGPBriefRecord,
BGPBriefRecordSummary,
BGPCollectorCoverage,
BGPEvent,
BGPIncident,
BGPOverviewOptions,
BGPOverviewSnapshot,
BGPSummarySnapshot,
} from './types'
export interface SituationalAwarenessGateway {
getBGPOverview(options?: BGPOverviewOptions): Promise<BGPOverviewSnapshot>
getBGPSummary(): Promise<BGPSummarySnapshot>
getBGPCollectors(): Promise<BGPCollectorCoverage[]>
getBGPIncidents(pageSize?: number): Promise<BGPIncident[]>
getBGPAnomalies(pageSize?: number): Promise<BGPAnomaly[]>
getBGPEvents(pageSize?: number): Promise<BGPEvent[]>
generateBGPBrief(): Promise<BGPBriefRecord>
listBGPBriefs(): Promise<BGPBriefRecordSummary[]>
getBGPBrief(briefId: string): Promise<BGPBriefRecord>
getLatestBGPBrief(): Promise<BGPBriefRecord | null>
}

View File

@@ -105,8 +105,43 @@ export interface BGPOverviewSnapshot {
collectorSummary: CollectorSummary | null
}
export interface BGPSummarySnapshot {
incidentSummary: Summary | null
eventSummary: EventSummary | null
collectorSummary: CollectorSummary | null
}
export interface BGPOverviewOptions {
incidentPageSize?: number
anomalyPageSize?: number
eventPageSize?: number
}
export interface AIContentBlock {
type: string
text?: string | null
thinking?: string | null
}
export interface AnalysisResponse {
provider: string
model: string
content: string
content_blocks: AIContentBlock[]
text_blocks: string[]
thinking_blocks: string[]
raw_response: Record<string, unknown>
}
export interface BGPBriefRecordSummary {
id: string
title: string
provider: string
model: string
request_id?: string | null
generated_at: string
}
export interface BGPBriefRecord extends BGPBriefRecordSummary {
content_markdown: string
}