fix: ship persistent bgp ai briefs and optimize bgp queries
This commit is contained in:
167
frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx
Normal file
167
frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx
Normal 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>
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user