feat: improve bgp incident visibility
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
238
docs/bgp-context.md
Normal 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.
|
||||
@@ -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>
|
||||
|
||||
@@ -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: "低危事件" },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '状态' }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user