Bump version to 0.23.1
This commit is contained in:
@@ -7,6 +7,23 @@ This project follows the repository versioning rule:
|
||||
- `feature` -> `+0.1.0`
|
||||
- `bugfix` -> `+0.0.1`
|
||||
|
||||
## 0.23.1
|
||||
|
||||
Released: 2026-04-07
|
||||
|
||||
### Highlights
|
||||
|
||||
- Fixed the BGP overview page so high-DPI and lower-height screens can keep the observation workspace visible within a single viewport.
|
||||
|
||||
### Improved
|
||||
|
||||
- Improved [BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx) by switching the page to a height-aware shell, compact summary layout, and tabbed data workspace so observation tables retain the majority of the screen.
|
||||
- Improved [index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) by adding responsive BGP-specific compact spacing, denser table paddings, and an internal scroll region for collector coverage and event tables.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the BGP observation page on small or high-scale displays where stacked stats and full-height blocks consumed too much vertical space, leaving only a couple of visible table rows.
|
||||
|
||||
## 0.23.0
|
||||
|
||||
Released: 2026-04-07
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.23.0",
|
||||
"version": "0.23.1",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
|
||||
@@ -483,6 +483,175 @@ body {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.bgp-page {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.bgp-page__header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.bgp-page__body {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bgp-page__body > .ant-space {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.bgp-page__body > .ant-space > .ant-space-item:last-child {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.bgp-page__alert .ant-alert {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.bgp-page__alert .ant-alert-message {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.bgp-page__summary-card .ant-card-body {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.bgp-page__summary-item {
|
||||
min-height: 64px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
background: #f7f8fa;
|
||||
border: 1px solid #eef1f5;
|
||||
}
|
||||
|
||||
.bgp-page__summary-label {
|
||||
margin-bottom: 4px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.bgp-page__summary-stat.ant-statistic {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.bgp-page__summary-stat .ant-statistic-content {
|
||||
font-size: 24px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.bgp-page__summary-stat .ant-statistic-content-value {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.bgp-page__table-card,
|
||||
.bgp-page__table-card .ant-card-body {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bgp-page__table-card .ant-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.bgp-page__table-region {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bgp-page__tabs,
|
||||
.bgp-page__tabs .ant-tabs-content-holder,
|
||||
.bgp-page__tabs .ant-tabs-content,
|
||||
.bgp-page__tabs .ant-tabs-tabpane {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bgp-page__tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bgp-page__tabs .ant-tabs-nav {
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.bgp-page__tabs .ant-tabs-content-holder {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.bgp-page__tabs .ant-tabs-tabpane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bgp-page__tabs .ant-table-wrapper {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.bgp-page--compact .page-shell__body {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bgp-page--compact .bgp-page__alert .ant-alert {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.bgp-page--compact .bgp-page__alert .ant-alert-message {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.bgp-page--compact .bgp-page__summary-card .ant-card-body {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.bgp-page--compact .bgp-page__summary-item {
|
||||
min-height: 56px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.bgp-page--compact .bgp-page__summary-label {
|
||||
margin-bottom: 2px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.bgp-page--compact .bgp-page__summary-stat .ant-statistic-content {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.bgp-page--compact .bgp-page__table-card .ant-card-body {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.bgp-page--compact .bgp-page__tabs .ant-tabs-nav {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.bgp-page--compact .bgp-page__tabs .ant-tabs-tab {
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.bgp-page--compact .bgp-page__tabs .ant-table-thead > tr > th {
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.bgp-page--compact .table-scroll-region .ant-table-thead > tr > th,
|
||||
.bgp-page--compact .table-scroll-region .ant-table-tbody > tr > td {
|
||||
padding: 6px 8px !important;
|
||||
}
|
||||
|
||||
.data-list-controls-shell {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.82) transparent;
|
||||
@@ -1056,6 +1225,22 @@ body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.bgp-page__body {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bgp-page__summary-card .ant-card-body {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.bgp-page__summary-item {
|
||||
min-height: 54px;
|
||||
}
|
||||
|
||||
.bgp-page__table-card .ant-card-body {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.data-list-workspace {
|
||||
gap: 10px;
|
||||
}
|
||||
@@ -1112,6 +1297,29 @@ body {
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.bgp-page__header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bgp-page__stats-row {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.bgp-page__summary-label {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.bgp-page__summary-stat .ant-statistic-content {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.bgp-page__tabs .ant-tabs-tab {
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.data-list-summary-treemap {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Alert, Card, Col, Row, Space, Statistic, Table, Tag, Typography } from 'antd'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Alert, Card, Col, Row, Space, Statistic, Table, Tabs, Tag, Typography } from 'antd'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||
import {
|
||||
@@ -32,6 +32,9 @@ function BGP() {
|
||||
const [incidentSummary, setIncidentSummary] = useState<Summary | null>(null)
|
||||
const [eventSummary, setEventSummary] = useState<EventSummary | null>(null)
|
||||
const [collectorSummary, setCollectorSummary] = useState<CollectorSummary | null>(null)
|
||||
const [compactViewport, setCompactViewport] = useState(false)
|
||||
const tableRegionRef = useRef<HTMLDivElement | null>(null)
|
||||
const [tableHeight, setTableHeight] = useState(360)
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
@@ -59,302 +62,355 @@ function BGP() {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const updateViewportMode = () => {
|
||||
if (typeof window === 'undefined') return
|
||||
const compact = window.devicePixelRatio >= 1.5 || window.innerHeight <= 860 || window.innerWidth <= 1440
|
||||
setCompactViewport(compact)
|
||||
}
|
||||
|
||||
updateViewportMode()
|
||||
window.addEventListener('resize', updateViewportMode)
|
||||
return () => window.removeEventListener('resize', updateViewportMode)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const updateTableHeight = () => {
|
||||
const regionHeight = tableRegionRef.current?.offsetHeight || 0
|
||||
setTableHeight(Math.max(compactViewport ? 180 : 240, regionHeight - (compactViewport ? 40 : 52)))
|
||||
}
|
||||
|
||||
updateTableHeight()
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(updateTableHeight)
|
||||
if (tableRegionRef.current) observer.observe(tableRegionRef.current)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [compactViewport, collectors.length, incidents.length, anomalies.length, events.length])
|
||||
|
||||
const summaryItems = [
|
||||
{ label: '近24h事件', value: collectorSummary?.recent_24h_events || 0 },
|
||||
{ label: '活跃观测站', value: collectorSummary?.active_collectors || 0 },
|
||||
{ label: '观测前缀', value: collectorSummary?.observed_prefixes || eventSummary?.prefix_count || 0 },
|
||||
{ label: '事件总数', value: incidentSummary?.total || 0 },
|
||||
{ label: '活跃事件', value: incidentSummary?.by_status?.active || 0 },
|
||||
{ label: '严重事件', value: incidentSummary?.by_severity?.critical || 0 },
|
||||
]
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Title level={3} style={{ marginBottom: 4 }}>BGP观测</Title>
|
||||
<Text type="secondary">先看事件态势,再下钻到原子异常明细。</Text>
|
||||
<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>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="该视图展示的是控制平面异常,不代表真实业务流量路径。"
|
||||
/>
|
||||
<div className="page-shell__body bgp-page__body">
|
||||
<Space direction="vertical" size={compactViewport ? 12 : 16} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
className="bgp-page__alert"
|
||||
type="info"
|
||||
showIcon
|
||||
message="该视图展示的是控制平面异常,不代表真实业务流量路径。"
|
||||
/>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={8}>
|
||||
<Card>
|
||||
<Statistic title="近24h事件" value={collectorSummary?.recent_24h_events || 0} />
|
||||
<Card className="bgp-page__summary-card">
|
||||
<Row gutter={[compactViewport ? 8 : 12, compactViewport ? 8 : 12]} className="bgp-page__summary-grid">
|
||||
{summaryItems.map((item) => (
|
||||
<Col key={item.label} xs={8} sm={8} md={4}>
|
||||
<div className="bgp-page__summary-item">
|
||||
<div className="bgp-page__summary-label">{item.label}</div>
|
||||
<Statistic className="bgp-page__summary-stat" value={item.value} />
|
||||
</div>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Card>
|
||||
<Statistic title="活跃观测站" value={collectorSummary?.active_collectors || 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Card>
|
||||
<Statistic title="观测前缀" value={collectorSummary?.observed_prefixes || eventSummary?.prefix_count || 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={8}>
|
||||
<Card>
|
||||
<Statistic title="事件总数" value={incidentSummary?.total || 0} />
|
||||
<Card className="bgp-page__table-card">
|
||||
<div ref={tableRegionRef} className="table-scroll-region bgp-page__table-region">
|
||||
<Tabs
|
||||
className="bgp-page__tabs"
|
||||
items={[
|
||||
{
|
||||
key: 'collectors',
|
||||
label: '观测站覆盖',
|
||||
children: (
|
||||
<Table<BGPCollectorCoverage>
|
||||
rowKey="collector"
|
||||
loading={loading}
|
||||
dataSource={collectors}
|
||||
pagination={{ pageSize: compactViewport ? 6 : 8 }}
|
||||
scroll={{ x: 'max-content', y: tableHeight }}
|
||||
tableLayout="fixed"
|
||||
columns={[
|
||||
{
|
||||
title: '观测站',
|
||||
dataIndex: 'collector',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '位置',
|
||||
width: 180,
|
||||
render: (_, record) => [record.city, record.country].filter(Boolean).join(', ') || '-',
|
||||
},
|
||||
{
|
||||
title: '近24h事件数',
|
||||
dataIndex: 'recent_24h_observation_count',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '近7d事件数',
|
||||
dataIndex: 'recent_7d_observation_count',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '前缀数',
|
||||
dataIndex: 'prefix_count',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: 'Origin ASN 数',
|
||||
dataIndex: 'origin_asn_count',
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: '最近事件',
|
||||
width: 220,
|
||||
render: (_, record) => {
|
||||
const time = formatDateTimeZhCN(record.latest_observed_at)
|
||||
return record.latest_event_type ? `${record.latest_event_type} @ ${time}` : time
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '日常覆盖范围',
|
||||
dataIndex: 'baseline_scope',
|
||||
render: (value: BGPCollectorCoverage['baseline_scope']) => {
|
||||
const cities = value?.cities?.slice(0, 3).join(' / ') || ''
|
||||
const countries = value?.countries?.slice(0, 3).join(' / ') || ''
|
||||
return cities && countries ? `${cities} | ${countries}` : cities || countries || '-'
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'incidents',
|
||||
label: '事件列表',
|
||||
children: (
|
||||
<Table<BGPIncident>
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={incidents}
|
||||
pagination={{ pageSize: compactViewport ? 6 : 8 }}
|
||||
scroll={{ x: 'max-content', y: tableHeight }}
|
||||
tableLayout="fixed"
|
||||
columns={[
|
||||
{
|
||||
title: '开始时间',
|
||||
dataIndex: 'started_at',
|
||||
width: 180,
|
||||
render: (value: string | null) => formatDateTimeZhCN(value),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'incident_type',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '严重度',
|
||||
dataIndex: 'severity',
|
||||
width: 120,
|
||||
render: (value: string) => <Tag color={severityColor(value)}>{value}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '影响前缀',
|
||||
dataIndex: 'affected_prefixes',
|
||||
width: 200,
|
||||
render: (value: string[]) => (value && value.length > 0 ? value.join(', ') : '-'),
|
||||
},
|
||||
{
|
||||
title: '观测站',
|
||||
dataIndex: 'affected_collectors',
|
||||
width: 180,
|
||||
render: (value: string[]) => (value && value.length > 0 ? `${value.length}个 (${value.slice(0, 3).join(', ')})` : '-'),
|
||||
},
|
||||
{
|
||||
title: '区域',
|
||||
dataIndex: 'affected_regions',
|
||||
width: 220,
|
||||
render: (value: Array<{ country?: string; city?: string }>) => {
|
||||
if (!value || value.length === 0) return '-'
|
||||
return value
|
||||
.slice(0, 3)
|
||||
.map((item) => [item.city, item.country].filter(Boolean).join(', '))
|
||||
.join(' / ')
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '附近基础设施',
|
||||
dataIndex: 'related_cables',
|
||||
width: 260,
|
||||
render: (value: BGPIncident['related_cables']) => {
|
||||
if (!value || value.length === 0) return '-'
|
||||
return value
|
||||
.slice(0, 2)
|
||||
.map((item) => {
|
||||
const landing = item.landing_point || [item.city, item.country].filter(Boolean).join(', ')
|
||||
const cable = item.cable_names && item.cable_names.length > 0 ? item.cable_names[0] : '附近登陆点'
|
||||
const distance = item.distance_km !== undefined ? ` ${item.distance_km}km` : ''
|
||||
return `${landing} (${cable}${distance})`
|
||||
})
|
||||
.join(' / ')
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '置信度',
|
||||
dataIndex: 'confidence',
|
||||
width: 120,
|
||||
render: (value: number) => `${Math.round((value || 0) * 100)}%`,
|
||||
},
|
||||
{
|
||||
title: '摘要',
|
||||
dataIndex: 'summary',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'anomalies',
|
||||
label: '异常明细',
|
||||
children: (
|
||||
<Table<BGPAnomaly>
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={anomalies}
|
||||
pagination={{ pageSize: compactViewport ? 8 : 10 }}
|
||||
scroll={{ x: 'max-content', y: tableHeight }}
|
||||
tableLayout="fixed"
|
||||
columns={[
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 180,
|
||||
render: (value: string | null) => formatDateTimeZhCN(value),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'anomaly_type',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '严重度',
|
||||
dataIndex: 'severity',
|
||||
width: 120,
|
||||
render: (value: string) => <Tag color={severityColor(value)}>{value}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '前缀',
|
||||
dataIndex: 'prefix',
|
||||
width: 180,
|
||||
render: (value: string | null) => value || '-',
|
||||
},
|
||||
{
|
||||
title: 'ASN',
|
||||
key: 'asn',
|
||||
width: 160,
|
||||
render: (_, record) => {
|
||||
if (record.origin_asn && record.new_origin_asn) {
|
||||
return `AS${record.origin_asn} -> AS${record.new_origin_asn}`
|
||||
}
|
||||
if (record.origin_asn) {
|
||||
return `AS${record.origin_asn}`
|
||||
}
|
||||
return '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '来源',
|
||||
dataIndex: 'source',
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: '置信度',
|
||||
dataIndex: 'confidence',
|
||||
width: 120,
|
||||
render: (value: number) => `${Math.round((value || 0) * 100)}%`,
|
||||
},
|
||||
{
|
||||
title: '摘要',
|
||||
dataIndex: 'summary',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'events',
|
||||
label: '最近观测事件',
|
||||
children: (
|
||||
<Table<BGPEvent>
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={events}
|
||||
pagination={{ pageSize: compactViewport ? 6 : 8 }}
|
||||
scroll={{ x: 'max-content', y: tableHeight }}
|
||||
tableLayout="fixed"
|
||||
columns={[
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'observed_at',
|
||||
width: 180,
|
||||
render: (value: string | null) => formatDateTimeZhCN(value),
|
||||
},
|
||||
{
|
||||
title: '观测站',
|
||||
dataIndex: 'collector',
|
||||
width: 140,
|
||||
render: (value: string | null) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'event_type',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '前缀',
|
||||
dataIndex: 'prefix',
|
||||
width: 200,
|
||||
render: (value: string | null) => value || '-',
|
||||
},
|
||||
{
|
||||
title: 'Origin ASN',
|
||||
dataIndex: 'origin_asn',
|
||||
width: 140,
|
||||
render: (value: number | null) => (value ? `AS${value}` : '-'),
|
||||
},
|
||||
{
|
||||
title: 'Peer ASN',
|
||||
dataIndex: 'peer_asn',
|
||||
width: 140,
|
||||
render: (value: number | null) => (value ? `AS${value}` : '-'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Card>
|
||||
<Statistic title="活跃事件" value={incidentSummary?.by_status?.active || 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Card>
|
||||
<Statistic title="严重事件" value={incidentSummary?.by_severity?.critical || 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card title="观测站覆盖">
|
||||
<Table<BGPCollectorCoverage>
|
||||
rowKey="collector"
|
||||
loading={loading}
|
||||
dataSource={collectors}
|
||||
pagination={{ pageSize: 8 }}
|
||||
columns={[
|
||||
{
|
||||
title: '观测站',
|
||||
dataIndex: 'collector',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '位置',
|
||||
width: 180,
|
||||
render: (_, record) => [record.city, record.country].filter(Boolean).join(', ') || '-',
|
||||
},
|
||||
{
|
||||
title: '近24h事件数',
|
||||
dataIndex: 'recent_24h_observation_count',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '近7d事件数',
|
||||
dataIndex: 'recent_7d_observation_count',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '前缀数',
|
||||
dataIndex: 'prefix_count',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: 'Origin ASN 数',
|
||||
dataIndex: 'origin_asn_count',
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: '最近事件',
|
||||
width: 220,
|
||||
render: (_, record) => {
|
||||
const time = formatDateTimeZhCN(record.latest_observed_at)
|
||||
return record.latest_event_type ? `${record.latest_event_type} @ ${time}` : time
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '日常覆盖范围',
|
||||
dataIndex: 'baseline_scope',
|
||||
render: (value: BGPCollectorCoverage['baseline_scope']) => {
|
||||
const cities = value?.cities?.slice(0, 3).join(' / ') || ''
|
||||
const countries = value?.countries?.slice(0, 3).join(' / ') || ''
|
||||
return cities && countries ? `${cities} | ${countries}` : cities || countries || '-'
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="事件列表">
|
||||
<Table<BGPIncident>
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={incidents}
|
||||
pagination={{ pageSize: 8 }}
|
||||
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',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="异常明细">
|
||||
<Table<BGPAnomaly>
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={anomalies}
|
||||
pagination={{ pageSize: 10 }}
|
||||
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',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="最近观测事件">
|
||||
<Table<BGPEvent>
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={events}
|
||||
pagination={{ pageSize: 8 }}
|
||||
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}` : '-'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.23.0"
|
||||
version = "0.23.1"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
Reference in New Issue
Block a user