diff --git a/VERSION b/VERSION index ca222b7c..610e2872 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.23.0 +0.23.1 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 301e72bb..ea0479e2 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/frontend/package.json b/frontend/package.json index 75b87a17..658ce068 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.23.0", + "version": "0.23.1", "private": true, "dependencies": { "@ant-design/icons": "^5.2.6", diff --git a/frontend/src/index.css b/frontend/src/index.css index fff1a20e..d426010e 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; } diff --git a/frontend/src/pages/BGP/BGP.tsx b/frontend/src/pages/BGP/BGP.tsx index 8dfc7a45..530dd071 100644 --- a/frontend/src/pages/BGP/BGP.tsx +++ b/frontend/src/pages/BGP/BGP.tsx @@ -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(null) const [eventSummary, setEventSummary] = useState(null) const [collectorSummary, setCollectorSummary] = useState(null) + const [compactViewport, setCompactViewport] = useState(false) + const tableRegionRef = useRef(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 ( - -
- BGP观测 - 先看事件态势,再下钻到原子异常明细。 +
+
+
+ BGP观测 + 先看事件态势,再下钻到原子异常明细。 +
- +
+ + - - - - + + + {summaryItems.map((item) => ( + +
+
{item.label}
+ +
+ + ))} +
- - - - - - - - - - - -
- - - - + +
+ + 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: ( + + 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) => {value}, + }, + { + 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: ( + + 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) => {value}, + }, + { + 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: ( + + 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}` : '-'), + }, + ]} + /> + ), + }, + ]} + /> +
- - - - - - - - - - - -
- - - - 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 || '-' - }, - }, - ]} - /> - - - - - 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) => {value}, - }, - { - 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', - }, - ]} - /> - - - - - 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) => {value}, - }, - { - 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', - }, - ]} - /> - - - - - 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}` : '-'), - }, - ]} - /> - -
+ +
+
) } diff --git a/pyproject.toml b/pyproject.toml index 621946fb..ca9b9828 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.23.0" +version = "0.23.1" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [