feat: improve bgp incident visibility

This commit is contained in:
linkong
2026-03-30 17:17:33 +08:00
parent 945786cee5
commit ac63bba2a2
12 changed files with 860 additions and 49 deletions

View File

@@ -86,6 +86,28 @@ async def list_bgp_events(
}
@router.get("/events/summary")
async def get_bgp_event_summary(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(BGPObservation).where(BGPObservation.source.in_(BGP_SOURCES)))
records = result.scalars().all()
collectors = sorted({record.collector for record in records if record.collector})
prefixes = sorted({record.prefix for record in records if record.prefix})
by_type: dict[str, int] = {}
for record in records:
by_type[record.event_type] = by_type.get(record.event_type, 0) + 1
return {
"total": len(records),
"collector_count": len(collectors),
"prefix_count": len(prefixes),
"by_type": by_type,
}
@router.get("/events/{event_id}")
async def get_bgp_event(
event_id: int,
@@ -211,6 +233,36 @@ async def list_bgp_incidents(
}
@router.get("/incidents/summary")
async def get_bgp_incident_summary(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
total_result = await db.execute(select(func.count(BGPIncident.id)))
type_result = await db.execute(
select(BGPIncident.incident_type, func.count(BGPIncident.id))
.group_by(BGPIncident.incident_type)
.order_by(func.count(BGPIncident.id).desc())
)
severity_result = await db.execute(
select(BGPIncident.severity, func.count(BGPIncident.id))
.group_by(BGPIncident.severity)
.order_by(func.count(BGPIncident.id).desc())
)
status_result = await db.execute(
select(BGPIncident.status, func.count(BGPIncident.id))
.group_by(BGPIncident.status)
.order_by(func.count(BGPIncident.id).desc())
)
return {
"total": total_result.scalar() or 0,
"by_type": {row[0]: row[1] for row in type_result.fetchall()},
"by_severity": {row[0]: row[1] for row in severity_result.fetchall()},
"by_status": {row[0]: row[1] for row in status_result.fetchall()},
}
@router.get("/incidents/{incident_id}")
async def get_bgp_incident(
incident_id: int,

View File

@@ -15,6 +15,7 @@ from app.core.satellite_tle import build_tle_lines_from_elements
from app.core.time import to_iso8601_utc
from app.db.session import get_db
from app.models.bgp_anomaly import BGPAnomaly
from app.models.bgp_incident import BGPIncident
from app.models.collected_data import CollectedData
from app.services.cable_graph import build_graph_from_data, CableGraph
from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
@@ -439,6 +440,58 @@ def convert_bgp_collectors_to_geojson() -> Dict[str, Any]:
return {"type": "FeatureCollection", "features": features}
def convert_bgp_incidents_to_geojson(records: List[BGPIncident]) -> Dict[str, Any]:
features = []
for record in records:
regions = record.affected_regions or []
if not regions:
continue
valid_regions = [
region
for region in regions
if isinstance(region, dict)
and isinstance(region.get("latitude"), (int, float))
and isinstance(region.get("longitude"), (int, float))
]
if not valid_regions:
continue
avg_lat = sum(float(region["latitude"]) for region in valid_regions) / len(valid_regions)
avg_lon = sum(float(region["longitude"]) for region in valid_regions) / len(valid_regions)
features.append(
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [avg_lon, avg_lat],
},
"properties": {
"id": record.id,
"incident_key": record.incident_key,
"incident_type": record.incident_type,
"title": record.title,
"summary": record.summary,
"severity": record.severity,
"status": record.status,
"confidence": record.confidence,
"affected_prefixes": record.affected_prefixes or [],
"affected_asns": record.affected_asns or [],
"affected_collectors": record.affected_collectors or [],
"affected_regions": valid_regions,
"related_cables": record.related_cables or [],
"related_ixps": record.related_ixps or [],
"created_at": to_iso8601_utc(record.created_at),
"started_at": to_iso8601_utc(record.started_at),
},
}
)
return {"type": "FeatureCollection", "features": features}
# ============== API Endpoints ==============
@@ -667,6 +720,25 @@ async def get_bgp_anomalies_geojson(
return {**geojson, "count": len(geojson.get("features", []))}
@router.get("/geo/bgp-incidents")
async def get_bgp_incidents_geojson(
severity: Optional[str] = Query(None),
status: Optional[str] = Query("active"),
limit: int = Query(100, ge=1, le=500),
db: AsyncSession = Depends(get_db),
):
stmt = select(BGPIncident).order_by(BGPIncident.created_at.desc()).limit(limit)
if severity:
stmt = stmt.where(BGPIncident.severity == severity)
if status:
stmt = stmt.where(BGPIncident.status == status)
result = await db.execute(stmt)
records = list(result.scalars().all())
geojson = convert_bgp_incidents_to_geojson(records)
return {**geojson, "count": len(geojson.get("features", []))}
@router.get("/geo/bgp-collectors")
async def get_bgp_collectors_geojson():
geojson = convert_bgp_collectors_to_geojson()

View File

@@ -563,3 +563,83 @@ async def test_bgp_incidents_api_returns_incident():
assert list_response.json()["total"] == 1
assert detail_response.status_code == 200
assert detail_response.json()["incident_type"] == "origin_change"
@pytest.mark.asyncio
async def test_bgp_incident_summary_api_returns_aggregates():
class _SummaryResult:
def __init__(self, scalar_value=None, rows=None):
self._scalar_value = scalar_value
self._rows = rows or []
def scalar(self):
return self._scalar_value
def fetchall(self):
return self._rows
class _SummarySession:
def __init__(self):
self.calls = 0
async def execute(self, _stmt):
self.calls += 1
if self.calls == 1:
return _SummaryResult(scalar_value=2)
if self.calls == 2:
return _SummaryResult(rows=[("origin_change", 2)])
if self.calls == 3:
return _SummaryResult(rows=[("critical", 1), ("high", 1)])
return _SummaryResult(rows=[("active", 2)])
db = _SummarySession()
client = await _bgp_test_client(db)
try:
response = await client.get("/api/v1/bgp/incidents/summary")
finally:
await client.aclose()
app.dependency_overrides.clear()
assert response.status_code == 200
payload = response.json()
assert payload["total"] == 2
assert payload["by_type"]["origin_change"] == 2
assert payload["by_severity"]["critical"] == 1
assert payload["by_status"]["active"] == 2
@pytest.mark.asyncio
async def test_bgp_event_summary_api_returns_aggregates():
observation_one = BGPObservation(
id=1,
source="ris_live_bgp",
collector="rrc00",
prefix="203.0.113.0/24",
event_type="announcement",
observed_at=datetime(2026, 3, 30, 10, 0, tzinfo=UTC),
)
observation_two = BGPObservation(
id=2,
source="ris_live_bgp",
collector="rrc01",
prefix="198.51.100.0/24",
event_type="withdrawal",
observed_at=datetime(2026, 3, 30, 10, 5, tzinfo=UTC),
)
db = _FakeAsyncSession([[observation_one, observation_two]])
client = await _bgp_test_client(db)
try:
response = await client.get("/api/v1/bgp/events/summary")
finally:
await client.aclose()
app.dependency_overrides.clear()
assert response.status_code == 200
payload = response.json()
assert payload["total"] == 2
assert payload["collector_count"] == 2
assert payload["prefix_count"] == 2
assert payload["by_type"]["announcement"] == 1
assert payload["by_type"]["withdrawal"] == 1

238
docs/bgp-context.md Normal file
View File

@@ -0,0 +1,238 @@
# BGP Context
## Current Goal
The BGP module is being evolved from an anomaly-only demo into a layered observability pipeline:
`raw observations -> enrichment -> detectors -> incidents -> console/Earth visualization`
The practical product goal is to turn low-level BGP control-plane changes into understandable network situation events with collector coverage, impact regions, and incident-centric visualization.
## Current Backend Architecture
### Data Layers
1. `BGPObservation`
- File: `backend/app/models/bgp_observation.py`
- Purpose: store normalized raw routing observations from live/history sources.
- Typical fields:
- `source`
- `collector`
- `peer_asn`
- `peer_ip`
- `prefix`
- `event_type`
- `as_path`
- `origin_asn`
- `next_hop`
- `communities`
- `observed_at`
- `raw_payload`
- `collector_geo`
- `ingest_batch_id`
2. `BGPAnomaly`
- File: `backend/app/models/bgp_anomaly.py`
- Purpose: hold atomic detector outputs.
- Current detector output types include:
- `origin_change`
- `more_specific_burst`
- `mass_withdrawal`
3. `BGPIncident`
- File: `backend/app/models/bgp_incident.py`
- Purpose: aggregate atomic anomalies into incident-level objects for humans and the UI.
### Pipeline
Main flow is currently anchored in:
- `backend/app/services/collectors/bgp_common.py`
- `backend/app/services/bgp_enrichment.py`
- `backend/app/services/bgp_detectors.py`
- `backend/app/services/bgp_incidents.py`
Operational flow:
1. collectors fetch raw BGP data
2. `normalize_bgp_event()` standardizes payloads
3. observations are persisted to `bgp_observations`
4. enrichment augments events with analysis context
5. detectors create `bgp_anomalies`
6. incident aggregation rolls anomalies up into `bgp_incidents`
### Current Ingest Sources
1. `RIPE RIS Live`
- Collector file: `backend/app/services/collectors/ris_live.py`
- Used for realtime observation flow.
2. `CAIDA BGPStream Backfill`
- Collector file: `backend/app/services/collectors/bgpstream.py`
- Used as history/backfill entry point.
## Current Enrichment Status
Implemented enrichment skeleton in:
- `backend/app/services/bgp_enrichment.py`
Current enrichments:
- prefix family / prefix length
- supernet / more-specific derivation
- deduplicated AS path
- path prepending hints
- collector region info
- prefix baseline hints
- new-origin detection
- ASN organization profile from PeeringDB where available
- prefix scope / impacted region hints
Current limitation:
- `RPKI` is still placeholder-only and returns `unknown`
- no real ROA validation source is integrated yet
## Current API Surface
Primary API file:
- `backend/app/api/v1/bgp.py`
Available endpoints:
- `/api/v1/bgp/events`
- `/api/v1/bgp/events/summary`
- `/api/v1/bgp/events/{id}`
- `/api/v1/bgp/anomalies`
- `/api/v1/bgp/anomalies/summary`
- `/api/v1/bgp/anomalies/{id}`
- `/api/v1/bgp/incidents`
- `/api/v1/bgp/incidents/summary`
- `/api/v1/bgp/incidents/{id}`
Visualization GeoJSON endpoints:
- `backend/app/api/v1/visualization.py`
- `/api/v1/visualization/geo/bgp-collectors`
- `/api/v1/visualization/geo/bgp-anomalies`
- `/api/v1/visualization/geo/bgp-incidents`
## Current Earth Behavior
Relevant files:
- `frontend/public/earth/js/bgp.js`
- `frontend/public/earth/js/main.js`
- `frontend/public/earth/js/info-card.js`
- `frontend/public/earth/js/constants.js`
- `frontend/public/earth/index.html`
Current design:
1. Collectors are always shown when BGP is enabled.
2. Incident markers are now the primary Earth BGP markers.
3. If there are no incidents, Earth falls back to anomaly markers.
4. If there are no anomalies either, collectors still provide presence.
5. The right-side stats now show:
- BGP events
- collector count
- BGP status summary
Current BGP status strategy:
- incidents present: show active incident count
- no incidents but anomalies present: show active anomaly count
- no incidents/anomalies but collectors present: show `当前无活跃事件`
- no BGP data at all: show `暂无观测数据`
Earth info-card strategy:
- `bgp` card is now incident-centric in wording
- `bgp_collector` card shows collector location and current event count
## Current Console Behavior
Relevant page:
- `frontend/src/pages/BGP/BGP.tsx`
Current BGP console page has three levels:
1. observation summary
- total events
- collector count
- prefix count
2. incident summary and incident table
3. anomaly detail table plus recent observation events
This means the BGP page still has useful signal even when there are zero anomalies.
## Known Product/Engineering Boundaries
1. The current system is still closer to an event board than a full BGP sensing platform.
2. RIS coverage still needs to expand beyond narrow subscription scope.
3. BGPStream history is still not full MRT-to-prefix decoded analytics.
4. Collector geography still depends heavily on static RIPE RIS mappings.
5. Incident-to-cable/IXP/region association is still weak and early-stage.
6. Earth currently visualizes logical observation/impact structure, not true physical traffic paths.
## Test Status
BGP-specific tests live in:
- `backend/tests/test_bgp.py`
Verified status at this point:
- `17 passed`
Covered areas include:
- normalization
- observation serialization
- enrichment
- detectors
- incident aggregation
- batch anomaly creation
- BGP events/incidents API
- summary endpoints
## Most Relevant Files
Backend:
- `backend/app/models/bgp_observation.py`
- `backend/app/models/bgp_anomaly.py`
- `backend/app/models/bgp_incident.py`
- `backend/app/services/collectors/bgp_common.py`
- `backend/app/services/bgp_enrichment.py`
- `backend/app/services/bgp_detectors.py`
- `backend/app/services/bgp_incidents.py`
- `backend/app/api/v1/bgp.py`
- `backend/app/api/v1/visualization.py`
Frontend:
- `frontend/src/pages/BGP/BGP.tsx`
- `frontend/public/earth/js/bgp.js`
- `frontend/public/earth/js/main.js`
- `frontend/public/earth/js/info-card.js`
- `frontend/public/earth/js/constants.js`
- `frontend/public/earth/index.html`
## Recommended Next Steps
1. Expand realtime collector coverage and include withdrawals more broadly.
2. Integrate real RPKI validation data.
3. Improve route leak and path instability detectors.
4. Strengthen incident aggregation semantics and titles.
5. Add weak correlation from incidents to:
- cable corridors
- landing points
- IXPs
- other traffic anomaly sources
6. Refine Earth hover/click handoff between collectors and incidents.

View File

@@ -204,9 +204,17 @@
<span class="stats-value" id="satellite-count">0 颗</span>
</div>
<div class="stats-item">
<span class="stats-label">BGP异常:</span>
<span class="stats-label">BGP事件:</span>
<span class="stats-value" id="bgp-anomaly-count">0 条</span>
</div>
<div class="stats-item">
<span class="stats-label">观测站:</span>
<span class="stats-value" id="bgp-collector-count">0 个</span>
</div>
<div class="stats-item">
<span class="stats-label">BGP态势:</span>
<span class="stats-value" id="bgp-status-summary">暂无观测数据</span>
</div>
<div class="stats-item">
<span class="stats-label">视角距离:</span>
<span class="stats-value" id="camera-distance">300 km</span>
@@ -220,7 +228,7 @@
<div id="loading">
<div id="loading-spinner"></div>
<div id="loading-title">正在初始化全球态势数据...</div>
<div id="loading-subtitle" style="font-size:0.9rem; margin-top:10px; color:#aaa;">同步卫星、海底光缆、登陆点与BGP异常数据</div>
<div id="loading-subtitle" style="font-size:0.9rem; margin-top:10px; color:#aaa;">同步卫星、海底光缆、登陆点与BGP态势数据</div>
</div>
<div id="status-message" class="status-message" style="display: none;"></div>
<div id="tooltip" class="tooltip"></div>

View File

@@ -7,10 +7,11 @@ const bgpGroup = new THREE.Group();
const bgpOverlayGroup = new THREE.Group();
const collectorMarkers = [];
const anomalyMarkers = [];
const anomalyCountByCollector = new Map();
const activeEventCountByCollector = new Map();
let showBGP = true;
let totalAnomalyCount = 0;
let totalIncidentCount = 0;
let textureCache = null;
let activeEventOverlay = null;
const relativeTimeFormatter = new Intl.RelativeTimeFormat("zh-CN", {
@@ -328,6 +329,78 @@ function buildAnomalyFeatureData(feature) {
};
}
function buildIncidentFeatureData(feature) {
const coordinates = feature?.geometry?.coordinates || [];
const [longitude, latitude] = coordinates;
if (
typeof latitude !== "number" ||
typeof longitude !== "number" ||
Number.isNaN(latitude) ||
Number.isNaN(longitude)
) {
return null;
}
const properties = feature?.properties || {};
const severity = normalizeSeverity(properties.severity);
const startedAt = properties.started_at || properties.created_at || null;
const affectedPrefixes = Array.isArray(properties.affected_prefixes)
? properties.affected_prefixes
: [];
const affectedAsns = Array.isArray(properties.affected_asns)
? properties.affected_asns
: [];
const affectedCollectors = Array.isArray(properties.affected_collectors)
? properties.affected_collectors
: [];
const affectedRegions = Array.isArray(properties.affected_regions)
? properties.affected_regions
: [];
const primaryRegion = affectedRegions[0] || {};
return {
latitude,
longitude,
rawSeverity: properties.severity || severity,
severity,
collector: affectedCollectors[0] || primaryRegion.collector || "-",
city: primaryRegion.city || "-",
country: primaryRegion.country || "-",
source: "bgp_incident",
anomaly_type: properties.incident_type || properties.title || "-",
incident_type: properties.incident_type || "-",
incident_key: properties.incident_key || "-",
status: properties.status || "-",
prefix: affectedPrefixes[0] || "-",
prefixes: affectedPrefixes,
origin_asn: affectedAsns[0] ?? "-",
new_origin_asn: affectedAsns[1] ?? "-",
affected_asns: affectedAsns,
as_path: [],
collectors: affectedCollectors,
collector_count: affectedCollectors.length || 1,
impacted_regions: affectedRegions,
confidence: properties.confidence ?? "-",
summary: properties.summary || properties.title || "-",
created_at: formatLocalDateTime(startedAt),
created_at_raw: startedAt,
route_change:
affectedAsns.length > 1
? affectedAsns.slice(0, 2).map((asn) => `AS${asn}`).join(" -> ")
: affectedPrefixes.length > 1
? `${affectedPrefixes.length}个前缀簇`
: properties.incident_type || "-",
observed_by: formatBGPObservedBy(affectedCollectors),
impacted_scope: formatBGPImpactedScope(affectedRegions),
location: formatBGPLocation(primaryRegion.city, primaryRegion.country),
id:
properties.id ||
properties.incident_key ||
`${properties.incident_type || "incident"}-${latitude}-${longitude}`,
};
}
function clearMarkerArray(markers) {
while (markers.length > 0) {
const marker = markers.pop();
@@ -454,9 +527,9 @@ function dedupeAnomalies(features) {
const data = buildAnomalyFeatureData(feature);
if (!data) return;
anomalyCountByCollector.set(
activeEventCountByCollector.set(
data.collector,
(anomalyCountByCollector.get(data.collector) || 0) + 1,
(activeEventCountByCollector.get(data.collector) || 0) + 1,
);
const dedupeKey = `${data.collector}|${data.latitude.toFixed(4)}|${data.longitude.toFixed(4)}`;
@@ -482,39 +555,85 @@ function dedupeAnomalies(features) {
.slice(0, BGP_CONFIG.maxRenderedMarkers);
}
function dedupeIncidents(features) {
const latestByKey = new Map();
features.forEach((feature) => {
const data = buildIncidentFeatureData(feature);
if (!data) return;
data.collectors.forEach((collector) => {
activeEventCountByCollector.set(
collector,
(activeEventCountByCollector.get(collector) || 0) + 1,
);
});
const dedupeKey = String(data.incident_key || data.id);
const previous = latestByKey.get(dedupeKey);
const currentTime = data.created_at_raw
? new Date(data.created_at_raw).getTime()
: 0;
const previousTime = previous?.created_at_raw
? new Date(previous.created_at_raw).getTime()
: 0;
if (!previous || currentTime >= previousTime) {
latestByKey.set(dedupeKey, data);
}
});
return Array.from(latestByKey.values())
.sort((a, b) => {
const timeA = a.created_at_raw ? new Date(a.created_at_raw).getTime() : 0;
const timeB = b.created_at_raw ? new Date(b.created_at_raw).getTime() : 0;
return timeB - timeA;
})
.slice(0, BGP_CONFIG.maxRenderedMarkers);
}
function applyCollectorCounts() {
collectorMarkers.forEach((marker) => {
marker.userData.anomaly_count =
anomalyCountByCollector.get(marker.userData.collector) || 0;
activeEventCountByCollector.get(marker.userData.collector) || 0;
});
}
export async function loadBGPAnomalies(scene, earth) {
clearBGPData(earth);
const [collectorsResponse, anomaliesResponse] = await Promise.all([
const [collectorsResponse, incidentsResponse, anomaliesResponse] = await Promise.all([
fetch(PATHS.bgpCollectorsApi),
fetch(`${PATHS.bgpIncidentsApi}?limit=${BGP_CONFIG.defaultFetchLimit}`),
fetch(`${PATHS.bgpApi}?limit=${BGP_CONFIG.defaultFetchLimit}`),
]);
if (!collectorsResponse.ok) {
throw new Error(`BGP collectors HTTP ${collectorsResponse.status}`);
}
if (!incidentsResponse.ok) {
throw new Error(`BGP incidents HTTP ${incidentsResponse.status}`);
}
if (!anomaliesResponse.ok) {
throw new Error(`BGP anomalies HTTP ${anomaliesResponse.status}`);
}
const collectorsPayload = await collectorsResponse.json();
const incidentsPayload = await incidentsResponse.json();
const anomaliesPayload = await anomaliesResponse.json();
const collectorFeatures = Array.isArray(collectorsPayload?.features)
? collectorsPayload.features
: [];
const incidentFeatures = Array.isArray(incidentsPayload?.features)
? incidentsPayload.features
: [];
const anomalyFeatures = Array.isArray(anomaliesPayload?.features)
? anomaliesPayload.features
: [];
totalAnomalyCount = anomaliesPayload?.count ?? anomalyFeatures.length;
anomalyCountByCollector.clear();
totalIncidentCount = incidentsPayload?.count ?? incidentFeatures.length;
activeEventCountByCollector.clear();
spreadCollectorPositions(
collectorFeatures
@@ -522,7 +641,12 @@ export async function loadBGPAnomalies(scene, earth) {
.filter(Boolean),
).forEach(createCollectorMarker);
dedupeAnomalies(anomalyFeatures).forEach(createAnomalyMarker);
const incidentMarkers = dedupeIncidents(incidentFeatures);
if (incidentMarkers.length > 0) {
incidentMarkers.forEach(createAnomalyMarker);
} else {
dedupeAnomalies(anomalyFeatures).forEach(createAnomalyMarker);
}
applyCollectorCounts();
if (!bgpGroup.parent) {
@@ -540,7 +664,8 @@ export async function loadBGPAnomalies(scene, earth) {
}
return {
totalCount: totalAnomalyCount,
totalCount: totalIncidentCount,
anomalyCount: totalAnomalyCount,
renderedCount: anomalyMarkers.length,
collectorCount: collectorMarkers.length,
};
@@ -644,8 +769,9 @@ export function clearBGPData(earth) {
clearMarkerArray(collectorMarkers);
clearMarkerArray(anomalyMarkers);
clearBGPEventOverlay();
anomalyCountByCollector.clear();
activeEventCountByCollector.clear();
totalAnomalyCount = 0;
totalIncidentCount = 0;
if (earth && bgpGroup.parent === earth) {
earth.remove(bgpGroup);
@@ -684,7 +810,27 @@ export function getBGPCollectorMarkers() {
}
export function getBGPCount() {
return totalAnomalyCount;
return totalIncidentCount;
}
export function getBGPCollectorCount() {
return collectorMarkers.length;
}
export function getBGPStatusSummary() {
if (totalIncidentCount > 0 && totalAnomalyCount > 0) {
return `${totalIncidentCount} 起活跃事件 / ${totalAnomalyCount} 条异常`;
}
if (totalIncidentCount > 0) {
return `${totalIncidentCount} 起活跃事件`;
}
if (totalAnomalyCount > 0) {
return `${totalAnomalyCount} 条活跃异常`;
}
if (collectorMarkers.length > 0) {
return "当前无活跃事件";
}
return "暂无观测数据";
}
export function showBGPEventOverlay(marker, earth) {
@@ -779,9 +925,9 @@ export function getBGPLegendItems() {
{ color: "#6db7ff", label: "观测站" },
{ color: "#8af5ff", label: "事件连线 / 枢纽" },
{ color: "#2dd4bf", label: "影响区域" },
{ color: "#ff4d4f", label: "严重异常" },
{ color: "#ff9f43", label: "高危异常" },
{ color: "#ffd166", label: "中危异常" },
{ color: "#4dabf7", label: "低危异常" },
{ color: "#ff4d4f", label: "严重事件" },
{ color: "#ff9f43", label: "高危事件" },
{ color: "#ffd166", label: "中危事件" },
{ color: "#4dabf7", label: "低危事件" },
];
}

View File

@@ -27,6 +27,7 @@ export const PATHS = {
cablesApi: '/api/v1/visualization/geo/cables',
landingPointsApi: '/api/v1/visualization/geo/landing-points',
bgpApi: '/api/v1/visualization/geo/bgp-anomalies',
bgpIncidentsApi: '/api/v1/visualization/geo/bgp-incidents',
bgpCollectorsApi: '/api/v1/visualization/geo/bgp-collectors',
geoJSON: './geo.json',
landingPointsStatic: './landing-point-geo.geojson',

View File

@@ -32,23 +32,23 @@ const CARD_CONFIG = {
},
bgp: {
icon: '📡',
title: 'BGP异常详情',
title: 'BGP事件详情',
className: 'bgp',
fields: [
{ key: 'anomaly_type', label: '异常类型' },
{ key: 'anomaly_type', label: '事件类型' },
{ key: 'severity', label: '严重度' },
{ key: 'status', label: '状态' },
{ key: 'route_change', label: '路由变更' },
{ key: 'route_change', label: '事件特征' },
{ key: 'prefix', label: '前缀' },
{ key: 'as_path_display', label: '传播路径' },
{ key: 'origin_asn', label: '原始 ASN' },
{ key: 'new_origin_asn', label: ' ASN' },
{ key: 'origin_asn', label: '涉及 ASN' },
{ key: 'new_origin_asn', label: '关联 ASN' },
{ key: 'confidence', label: '置信度' },
{ key: 'collector', label: '采集器' },
{ key: 'collector', label: '主观测站' },
{ key: 'observed_by', label: '观测范围' },
{ key: 'impacted_scope', label: '影响区域' },
{ key: 'location', label: '观测位置' },
{ key: 'created_at', label: '发生时间' },
{ key: 'created_at', label: '事件时间' },
{ key: 'summary', label: '摘要' }
]
},
@@ -59,7 +59,7 @@ const CARD_CONFIG = {
fields: [
{ key: 'collector', label: '采集器' },
{ key: 'location', label: '观测位置' },
{ key: 'anomaly_count', label: '当前异常数' },
{ key: 'anomaly_count', label: '当前事件数' },
{ key: 'status', label: '状态' }
]
},

View File

@@ -72,6 +72,8 @@ import {
getBGPCollectorMarkers,
getBGPLegendItems,
getBGPCount,
getBGPCollectorCount,
getBGPStatusSummary,
getShowBGP,
clearBGPSelection,
setBGPMarkerState,
@@ -382,24 +384,44 @@ function showBGPInfo(marker) {
},
];
showInfoCard("bgp", {
anomaly_type: formatBGPAnomalyTypeLabel(marker.userData.anomaly_type),
anomaly_type: formatBGPAnomalyTypeLabel(
marker.userData.incident_type || marker.userData.anomaly_type,
),
severity: formatBGPSeverityLabel(
marker.userData.rawSeverity || marker.userData.severity,
),
status: formatBGPStatusLabel(marker.userData.status),
route_change: formatBGPRouteChange(
marker.userData.origin_asn,
marker.userData.new_origin_asn,
),
prefix: marker.userData.prefix,
as_path_display: formatBGPASPath(marker.userData.as_path),
origin_asn: marker.userData.origin_asn,
new_origin_asn: marker.userData.new_origin_asn,
route_change:
marker.userData.route_change ||
formatBGPRouteChange(
marker.userData.origin_asn,
marker.userData.new_origin_asn,
),
prefix:
Array.isArray(marker.userData.prefixes) && marker.userData.prefixes.length > 1
? `${marker.userData.prefixes[0]}${marker.userData.prefixes.length}`
: marker.userData.prefix,
as_path_display:
Array.isArray(marker.userData.as_path) && marker.userData.as_path.length > 0
? formatBGPASPath(marker.userData.as_path)
: "-",
origin_asn:
Array.isArray(marker.userData.affected_asns) && marker.userData.affected_asns.length > 0
? marker.userData.affected_asns.slice(0, 3).map((asn) => `AS${asn}`).join(", ")
: marker.userData.origin_asn,
new_origin_asn:
Array.isArray(marker.userData.affected_asns) && marker.userData.affected_asns.length > 3
? `${marker.userData.affected_asns.length}个ASN`
: marker.userData.new_origin_asn,
confidence: formatBGPConfidence(marker.userData.confidence),
collector: marker.userData.collector,
observed_by: formatBGPObservedBy(marker.userData.collectors),
observed_by:
marker.userData.observed_by ||
formatBGPObservedBy(marker.userData.collectors),
impacted_scope: formatBGPImpactedScope(impactedRegions),
location: formatBGPLocation(marker.userData.city, marker.userData.country),
location:
marker.userData.location ||
formatBGPLocation(marker.userData.city, marker.userData.country),
created_at: formatBGPObservedTime(marker.userData.created_at_raw),
summary: marker.userData.summary,
});
@@ -603,6 +625,8 @@ function updateStatsSummary() {
landingPointCount:
document.getElementById("landing-point-count")?.textContent || 0,
bgpAnomalyCount: `${getBGPCount()}`,
bgpCollectorCount: `${getBGPCollectorCount()}`,
bgpStatusSummary: getBGPStatusSummary(),
terrainOn: getShowTerrain(),
textureQuality: "8K 卫星图",
});
@@ -717,8 +741,8 @@ async function loadData(showWhiteSphere = false) {
setLoadingMessage(
showWhiteSphere ? "正在刷新全球态势数据..." : "正在初始化全球态势数据...",
showWhiteSphere
? "重新同步卫星、海底光缆、登陆点与BGP异常数据"
: "同步卫星、海底光缆、登陆点与BGP异常数据",
? "重新同步卫星、海底光缆、登陆点与BGP态势数据"
: "同步卫星、海底光缆、登陆点与BGP态势数据",
);
setLoading(true);
clearLockedObject();
@@ -746,7 +770,20 @@ async function loadData(showWhiteSphere = false) {
}
const bgpCountEl = document.getElementById("bgp-anomaly-count");
if (bgpCountEl) {
bgpCountEl.textContent = `${bgpResult.totalCount} `;
bgpCountEl.textContent = `${bgpResult.totalCount} `;
}
const bgpCollectorEl = document.getElementById("bgp-collector-count");
if (bgpCollectorEl) {
bgpCollectorEl.textContent = `${bgpResult.collectorCount}`;
}
const bgpStatusEl = document.getElementById("bgp-status-summary");
if (bgpStatusEl) {
bgpStatusEl.textContent =
bgpResult.totalCount > 0
? `${bgpResult.totalCount} 起活跃事件`
: bgpResult.anomalyCount > 0
? `${bgpResult.anomalyCount} 条活跃异常`
: "当前无活跃事件";
}
return bgpResult;
})(),
@@ -765,7 +802,7 @@ async function loadData(showWhiteSphere = false) {
errors.push({ label: "卫星", reason: results[1].reason });
}
if (results[2].status === "rejected") {
errors.push({ label: "BGP异常", reason: results[2].reason });
errors.push({ label: "BGP态势", reason: results[2].reason });
}
if (errors.length > 0) {
@@ -1175,7 +1212,7 @@ function onClick(event) {
showBGPEventOverlay(clickedMarker, earth);
showBGPInfo(clickedMarker);
showStatusMessage(
`已选择BGP异常: ${clickedMarker.userData.collector}`,
`已选择BGP事件: ${clickedMarker.userData.collector}`,
"info",
);
return;

View File

@@ -86,6 +86,8 @@ export function updateEarthStats(stats) {
const cableCountEl = document.getElementById("cable-count");
const landingPointCountEl = document.getElementById("landing-point-count");
const bgpAnomalyCountEl = document.getElementById("bgp-anomaly-count");
const bgpCollectorCountEl = document.getElementById("bgp-collector-count");
const bgpStatusSummaryEl = document.getElementById("bgp-status-summary");
const terrainStatusEl = document.getElementById("terrain-status");
const textureQualityEl = document.getElementById("texture-quality");
@@ -94,6 +96,10 @@ export function updateEarthStats(stats) {
landingPointCountEl.textContent = stats.landingPointCount || 0;
if (bgpAnomalyCountEl)
bgpAnomalyCountEl.textContent = stats.bgpAnomalyCount || 0;
if (bgpCollectorCountEl)
bgpCollectorCountEl.textContent = stats.bgpCollectorCount || 0;
if (bgpStatusSummaryEl)
bgpStatusSummaryEl.textContent = stats.bgpStatusSummary || "-";
if (terrainStatusEl)
terrainStatusEl.textContent = stats.terrainOn ? "开启" : "关闭";
if (textureQualityEl)

View File

@@ -20,6 +20,32 @@ interface BGPAnomaly {
created_at: string | null
}
interface BGPEvent {
id: number
collector: string | null
event_type: string
prefix: string | null
origin_asn: number | null
peer_asn: number | null
observed_at: string | null
}
interface BGPIncident {
id: number
incident_type: string
title: string
summary: string
severity: string
status: string
confidence: number
affected_prefixes: string[]
affected_asns: number[]
affected_collectors: string[]
affected_regions: Array<{ country?: string; city?: string }>
created_at: string | null
started_at: string | null
}
interface Summary {
total: number
by_type: Record<string, number>
@@ -27,6 +53,13 @@ interface Summary {
by_status: Record<string, number>
}
interface EventSummary {
total: number
collector_count: number
prefix_count: number
by_type: Record<string, number>
}
function severityColor(severity: string) {
if (severity === 'critical') return 'red'
if (severity === 'high') return 'orange'
@@ -36,19 +69,31 @@ function severityColor(severity: string) {
function BGP() {
const [loading, setLoading] = useState(false)
const [incidents, setIncidents] = useState<BGPIncident[]>([])
const [anomalies, setAnomalies] = useState<BGPAnomaly[]>([])
const [summary, setSummary] = useState<Summary | null>(null)
const [events, setEvents] = useState<BGPEvent[]>([])
const [incidentSummary, setIncidentSummary] = useState<Summary | null>(null)
const [anomalySummary, setAnomalySummary] = useState<Summary | null>(null)
const [eventSummary, setEventSummary] = useState<EventSummary | null>(null)
useEffect(() => {
const load = async () => {
setLoading(true)
try {
const [anomaliesRes, summaryRes] = await Promise.all([
const [incidentsRes, incidentSummaryRes, anomaliesRes, anomalySummaryRes, eventsRes, eventSummaryRes] = await Promise.all([
axios.get('/api/v1/bgp/incidents', { params: { page_size: 50 } }),
axios.get('/api/v1/bgp/incidents/summary'),
axios.get('/api/v1/bgp/anomalies', { params: { page_size: 100 } }),
axios.get('/api/v1/bgp/anomalies/summary'),
axios.get('/api/v1/bgp/events', { params: { page_size: 20 } }),
axios.get('/api/v1/bgp/events/summary'),
])
setIncidents(incidentsRes.data.data || [])
setIncidentSummary(incidentSummaryRes.data)
setAnomalies(anomaliesRes.data.data || [])
setSummary(summaryRes.data)
setAnomalySummary(anomalySummaryRes.data)
setEvents(eventsRes.data.data || [])
setEventSummary(eventSummaryRes.data)
} finally {
setLoading(false)
}
@@ -62,7 +107,7 @@ function BGP() {
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<div>
<Title level={3} style={{ marginBottom: 4 }}>BGP观测</Title>
<Text type="secondary"></Text>
<Text type="secondary"></Text>
</div>
<Alert
@@ -74,22 +119,102 @@ function BGP() {
<Row gutter={16}>
<Col xs={24} md={8}>
<Card>
<Statistic title="异常总数" value={summary?.total || 0} />
<Statistic title="观测事件" value={eventSummary?.total || 0} />
</Card>
</Col>
<Col xs={24} md={8}>
<Card>
<Statistic title="Critical" value={summary?.by_severity?.critical || 0} />
<Statistic title="观测站" value={eventSummary?.collector_count || 0} />
</Card>
</Col>
<Col xs={24} md={8}>
<Card>
<Statistic title="Active" value={summary?.by_status?.active || 0} />
<Statistic title="观测前缀" value={eventSummary?.prefix_count || 0} />
</Card>
</Col>
</Row>
<Card title="异常列表">
<Row gutter={16}>
<Col xs={24} md={8}>
<Card>
<Statistic title="事件总数" value={incidentSummary?.total || 0} />
</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<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: 'confidence',
width: 120,
render: (value: number) => `${Math.round((value || 0) * 100)}%`,
},
{
title: '摘要',
dataIndex: 'summary',
},
]}
/>
</Card>
<Card title="异常明细">
<Table<BGPAnomaly>
rowKey="id"
loading={loading}
@@ -151,6 +276,52 @@ function BGP() {
]}
/>
</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>
</AppLayout>
)

2
uv.lock generated
View File

@@ -475,7 +475,7 @@ wheels = [
[[package]]
name = "planet"
version = "0.21.0"
version = "0.21.9"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },