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}")
|
@router.get("/events/{event_id}")
|
||||||
async def get_bgp_event(
|
async def get_bgp_event(
|
||||||
event_id: int,
|
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}")
|
@router.get("/incidents/{incident_id}")
|
||||||
async def get_bgp_incident(
|
async def get_bgp_incident(
|
||||||
incident_id: int,
|
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.core.time import to_iso8601_utc
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models.bgp_anomaly import BGPAnomaly
|
from app.models.bgp_anomaly import BGPAnomaly
|
||||||
|
from app.models.bgp_incident import BGPIncident
|
||||||
from app.models.collected_data import CollectedData
|
from app.models.collected_data import CollectedData
|
||||||
from app.services.cable_graph import build_graph_from_data, CableGraph
|
from app.services.cable_graph import build_graph_from_data, CableGraph
|
||||||
from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
|
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}
|
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 ==============
|
# ============== API Endpoints ==============
|
||||||
|
|
||||||
|
|
||||||
@@ -667,6 +720,25 @@ async def get_bgp_anomalies_geojson(
|
|||||||
return {**geojson, "count": len(geojson.get("features", []))}
|
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")
|
@router.get("/geo/bgp-collectors")
|
||||||
async def get_bgp_collectors_geojson():
|
async def get_bgp_collectors_geojson():
|
||||||
geojson = convert_bgp_collectors_to_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 list_response.json()["total"] == 1
|
||||||
assert detail_response.status_code == 200
|
assert detail_response.status_code == 200
|
||||||
assert detail_response.json()["incident_type"] == "origin_change"
|
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>
|
<span class="stats-value" id="satellite-count">0 颗</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stats-item">
|
<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>
|
<span class="stats-value" id="bgp-anomaly-count">0 条</span>
|
||||||
</div>
|
</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">
|
<div class="stats-item">
|
||||||
<span class="stats-label">视角距离:</span>
|
<span class="stats-label">视角距离:</span>
|
||||||
<span class="stats-value" id="camera-distance">300 km</span>
|
<span class="stats-value" id="camera-distance">300 km</span>
|
||||||
@@ -220,7 +228,7 @@
|
|||||||
<div id="loading">
|
<div id="loading">
|
||||||
<div id="loading-spinner"></div>
|
<div id="loading-spinner"></div>
|
||||||
<div id="loading-title">正在初始化全球态势数据...</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>
|
||||||
<div id="status-message" class="status-message" style="display: none;"></div>
|
<div id="status-message" class="status-message" style="display: none;"></div>
|
||||||
<div id="tooltip" class="tooltip"></div>
|
<div id="tooltip" class="tooltip"></div>
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ const bgpGroup = new THREE.Group();
|
|||||||
const bgpOverlayGroup = new THREE.Group();
|
const bgpOverlayGroup = new THREE.Group();
|
||||||
const collectorMarkers = [];
|
const collectorMarkers = [];
|
||||||
const anomalyMarkers = [];
|
const anomalyMarkers = [];
|
||||||
const anomalyCountByCollector = new Map();
|
const activeEventCountByCollector = new Map();
|
||||||
|
|
||||||
let showBGP = true;
|
let showBGP = true;
|
||||||
let totalAnomalyCount = 0;
|
let totalAnomalyCount = 0;
|
||||||
|
let totalIncidentCount = 0;
|
||||||
let textureCache = null;
|
let textureCache = null;
|
||||||
let activeEventOverlay = null;
|
let activeEventOverlay = null;
|
||||||
const relativeTimeFormatter = new Intl.RelativeTimeFormat("zh-CN", {
|
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) {
|
function clearMarkerArray(markers) {
|
||||||
while (markers.length > 0) {
|
while (markers.length > 0) {
|
||||||
const marker = markers.pop();
|
const marker = markers.pop();
|
||||||
@@ -454,9 +527,9 @@ function dedupeAnomalies(features) {
|
|||||||
const data = buildAnomalyFeatureData(feature);
|
const data = buildAnomalyFeatureData(feature);
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
anomalyCountByCollector.set(
|
activeEventCountByCollector.set(
|
||||||
data.collector,
|
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)}`;
|
const dedupeKey = `${data.collector}|${data.latitude.toFixed(4)}|${data.longitude.toFixed(4)}`;
|
||||||
@@ -482,39 +555,85 @@ function dedupeAnomalies(features) {
|
|||||||
.slice(0, BGP_CONFIG.maxRenderedMarkers);
|
.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() {
|
function applyCollectorCounts() {
|
||||||
collectorMarkers.forEach((marker) => {
|
collectorMarkers.forEach((marker) => {
|
||||||
marker.userData.anomaly_count =
|
marker.userData.anomaly_count =
|
||||||
anomalyCountByCollector.get(marker.userData.collector) || 0;
|
activeEventCountByCollector.get(marker.userData.collector) || 0;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadBGPAnomalies(scene, earth) {
|
export async function loadBGPAnomalies(scene, earth) {
|
||||||
clearBGPData(earth);
|
clearBGPData(earth);
|
||||||
|
|
||||||
const [collectorsResponse, anomaliesResponse] = await Promise.all([
|
const [collectorsResponse, incidentsResponse, anomaliesResponse] = await Promise.all([
|
||||||
fetch(PATHS.bgpCollectorsApi),
|
fetch(PATHS.bgpCollectorsApi),
|
||||||
|
fetch(`${PATHS.bgpIncidentsApi}?limit=${BGP_CONFIG.defaultFetchLimit}`),
|
||||||
fetch(`${PATHS.bgpApi}?limit=${BGP_CONFIG.defaultFetchLimit}`),
|
fetch(`${PATHS.bgpApi}?limit=${BGP_CONFIG.defaultFetchLimit}`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!collectorsResponse.ok) {
|
if (!collectorsResponse.ok) {
|
||||||
throw new Error(`BGP collectors HTTP ${collectorsResponse.status}`);
|
throw new Error(`BGP collectors HTTP ${collectorsResponse.status}`);
|
||||||
}
|
}
|
||||||
|
if (!incidentsResponse.ok) {
|
||||||
|
throw new Error(`BGP incidents HTTP ${incidentsResponse.status}`);
|
||||||
|
}
|
||||||
if (!anomaliesResponse.ok) {
|
if (!anomaliesResponse.ok) {
|
||||||
throw new Error(`BGP anomalies HTTP ${anomaliesResponse.status}`);
|
throw new Error(`BGP anomalies HTTP ${anomaliesResponse.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const collectorsPayload = await collectorsResponse.json();
|
const collectorsPayload = await collectorsResponse.json();
|
||||||
|
const incidentsPayload = await incidentsResponse.json();
|
||||||
const anomaliesPayload = await anomaliesResponse.json();
|
const anomaliesPayload = await anomaliesResponse.json();
|
||||||
const collectorFeatures = Array.isArray(collectorsPayload?.features)
|
const collectorFeatures = Array.isArray(collectorsPayload?.features)
|
||||||
? collectorsPayload.features
|
? collectorsPayload.features
|
||||||
: [];
|
: [];
|
||||||
|
const incidentFeatures = Array.isArray(incidentsPayload?.features)
|
||||||
|
? incidentsPayload.features
|
||||||
|
: [];
|
||||||
const anomalyFeatures = Array.isArray(anomaliesPayload?.features)
|
const anomalyFeatures = Array.isArray(anomaliesPayload?.features)
|
||||||
? anomaliesPayload.features
|
? anomaliesPayload.features
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
totalAnomalyCount = anomaliesPayload?.count ?? anomalyFeatures.length;
|
totalAnomalyCount = anomaliesPayload?.count ?? anomalyFeatures.length;
|
||||||
anomalyCountByCollector.clear();
|
totalIncidentCount = incidentsPayload?.count ?? incidentFeatures.length;
|
||||||
|
activeEventCountByCollector.clear();
|
||||||
|
|
||||||
spreadCollectorPositions(
|
spreadCollectorPositions(
|
||||||
collectorFeatures
|
collectorFeatures
|
||||||
@@ -522,7 +641,12 @@ export async function loadBGPAnomalies(scene, earth) {
|
|||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
).forEach(createCollectorMarker);
|
).forEach(createCollectorMarker);
|
||||||
|
|
||||||
dedupeAnomalies(anomalyFeatures).forEach(createAnomalyMarker);
|
const incidentMarkers = dedupeIncidents(incidentFeatures);
|
||||||
|
if (incidentMarkers.length > 0) {
|
||||||
|
incidentMarkers.forEach(createAnomalyMarker);
|
||||||
|
} else {
|
||||||
|
dedupeAnomalies(anomalyFeatures).forEach(createAnomalyMarker);
|
||||||
|
}
|
||||||
applyCollectorCounts();
|
applyCollectorCounts();
|
||||||
|
|
||||||
if (!bgpGroup.parent) {
|
if (!bgpGroup.parent) {
|
||||||
@@ -540,7 +664,8 @@ export async function loadBGPAnomalies(scene, earth) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalCount: totalAnomalyCount,
|
totalCount: totalIncidentCount,
|
||||||
|
anomalyCount: totalAnomalyCount,
|
||||||
renderedCount: anomalyMarkers.length,
|
renderedCount: anomalyMarkers.length,
|
||||||
collectorCount: collectorMarkers.length,
|
collectorCount: collectorMarkers.length,
|
||||||
};
|
};
|
||||||
@@ -644,8 +769,9 @@ export function clearBGPData(earth) {
|
|||||||
clearMarkerArray(collectorMarkers);
|
clearMarkerArray(collectorMarkers);
|
||||||
clearMarkerArray(anomalyMarkers);
|
clearMarkerArray(anomalyMarkers);
|
||||||
clearBGPEventOverlay();
|
clearBGPEventOverlay();
|
||||||
anomalyCountByCollector.clear();
|
activeEventCountByCollector.clear();
|
||||||
totalAnomalyCount = 0;
|
totalAnomalyCount = 0;
|
||||||
|
totalIncidentCount = 0;
|
||||||
|
|
||||||
if (earth && bgpGroup.parent === earth) {
|
if (earth && bgpGroup.parent === earth) {
|
||||||
earth.remove(bgpGroup);
|
earth.remove(bgpGroup);
|
||||||
@@ -684,7 +810,27 @@ export function getBGPCollectorMarkers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getBGPCount() {
|
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) {
|
export function showBGPEventOverlay(marker, earth) {
|
||||||
@@ -779,9 +925,9 @@ export function getBGPLegendItems() {
|
|||||||
{ color: "#6db7ff", label: "观测站" },
|
{ color: "#6db7ff", label: "观测站" },
|
||||||
{ color: "#8af5ff", label: "事件连线 / 枢纽" },
|
{ color: "#8af5ff", label: "事件连线 / 枢纽" },
|
||||||
{ color: "#2dd4bf", label: "影响区域" },
|
{ color: "#2dd4bf", label: "影响区域" },
|
||||||
{ color: "#ff4d4f", label: "严重异常" },
|
{ color: "#ff4d4f", label: "严重事件" },
|
||||||
{ color: "#ff9f43", label: "高危异常" },
|
{ color: "#ff9f43", label: "高危事件" },
|
||||||
{ color: "#ffd166", label: "中危异常" },
|
{ color: "#ffd166", label: "中危事件" },
|
||||||
{ color: "#4dabf7", label: "低危异常" },
|
{ color: "#4dabf7", label: "低危事件" },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const PATHS = {
|
|||||||
cablesApi: '/api/v1/visualization/geo/cables',
|
cablesApi: '/api/v1/visualization/geo/cables',
|
||||||
landingPointsApi: '/api/v1/visualization/geo/landing-points',
|
landingPointsApi: '/api/v1/visualization/geo/landing-points',
|
||||||
bgpApi: '/api/v1/visualization/geo/bgp-anomalies',
|
bgpApi: '/api/v1/visualization/geo/bgp-anomalies',
|
||||||
|
bgpIncidentsApi: '/api/v1/visualization/geo/bgp-incidents',
|
||||||
bgpCollectorsApi: '/api/v1/visualization/geo/bgp-collectors',
|
bgpCollectorsApi: '/api/v1/visualization/geo/bgp-collectors',
|
||||||
geoJSON: './geo.json',
|
geoJSON: './geo.json',
|
||||||
landingPointsStatic: './landing-point-geo.geojson',
|
landingPointsStatic: './landing-point-geo.geojson',
|
||||||
|
|||||||
@@ -32,23 +32,23 @@ const CARD_CONFIG = {
|
|||||||
},
|
},
|
||||||
bgp: {
|
bgp: {
|
||||||
icon: '📡',
|
icon: '📡',
|
||||||
title: 'BGP异常详情',
|
title: 'BGP事件详情',
|
||||||
className: 'bgp',
|
className: 'bgp',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'anomaly_type', label: '异常类型' },
|
{ key: 'anomaly_type', label: '事件类型' },
|
||||||
{ key: 'severity', label: '严重度' },
|
{ key: 'severity', label: '严重度' },
|
||||||
{ key: 'status', label: '状态' },
|
{ key: 'status', label: '状态' },
|
||||||
{ key: 'route_change', label: '路由变更' },
|
{ key: 'route_change', label: '事件特征' },
|
||||||
{ key: 'prefix', label: '前缀' },
|
{ key: 'prefix', label: '前缀' },
|
||||||
{ key: 'as_path_display', label: '传播路径' },
|
{ key: 'as_path_display', label: '传播路径' },
|
||||||
{ key: 'origin_asn', label: '原始 ASN' },
|
{ key: 'origin_asn', label: '涉及 ASN' },
|
||||||
{ key: 'new_origin_asn', label: '新 ASN' },
|
{ key: 'new_origin_asn', label: '关联 ASN' },
|
||||||
{ key: 'confidence', label: '置信度' },
|
{ key: 'confidence', label: '置信度' },
|
||||||
{ key: 'collector', label: '采集器' },
|
{ key: 'collector', label: '主观测站' },
|
||||||
{ key: 'observed_by', label: '观测范围' },
|
{ key: 'observed_by', label: '观测范围' },
|
||||||
{ key: 'impacted_scope', label: '影响区域' },
|
{ key: 'impacted_scope', label: '影响区域' },
|
||||||
{ key: 'location', label: '观测位置' },
|
{ key: 'location', label: '观测位置' },
|
||||||
{ key: 'created_at', label: '发生时间' },
|
{ key: 'created_at', label: '事件时间' },
|
||||||
{ key: 'summary', label: '摘要' }
|
{ key: 'summary', label: '摘要' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -59,7 +59,7 @@ const CARD_CONFIG = {
|
|||||||
fields: [
|
fields: [
|
||||||
{ key: 'collector', label: '采集器' },
|
{ key: 'collector', label: '采集器' },
|
||||||
{ key: 'location', label: '观测位置' },
|
{ key: 'location', label: '观测位置' },
|
||||||
{ key: 'anomaly_count', label: '当前异常数' },
|
{ key: 'anomaly_count', label: '当前事件数' },
|
||||||
{ key: 'status', label: '状态' }
|
{ key: 'status', label: '状态' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ import {
|
|||||||
getBGPCollectorMarkers,
|
getBGPCollectorMarkers,
|
||||||
getBGPLegendItems,
|
getBGPLegendItems,
|
||||||
getBGPCount,
|
getBGPCount,
|
||||||
|
getBGPCollectorCount,
|
||||||
|
getBGPStatusSummary,
|
||||||
getShowBGP,
|
getShowBGP,
|
||||||
clearBGPSelection,
|
clearBGPSelection,
|
||||||
setBGPMarkerState,
|
setBGPMarkerState,
|
||||||
@@ -382,24 +384,44 @@ function showBGPInfo(marker) {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
showInfoCard("bgp", {
|
showInfoCard("bgp", {
|
||||||
anomaly_type: formatBGPAnomalyTypeLabel(marker.userData.anomaly_type),
|
anomaly_type: formatBGPAnomalyTypeLabel(
|
||||||
|
marker.userData.incident_type || marker.userData.anomaly_type,
|
||||||
|
),
|
||||||
severity: formatBGPSeverityLabel(
|
severity: formatBGPSeverityLabel(
|
||||||
marker.userData.rawSeverity || marker.userData.severity,
|
marker.userData.rawSeverity || marker.userData.severity,
|
||||||
),
|
),
|
||||||
status: formatBGPStatusLabel(marker.userData.status),
|
status: formatBGPStatusLabel(marker.userData.status),
|
||||||
route_change: formatBGPRouteChange(
|
route_change:
|
||||||
marker.userData.origin_asn,
|
marker.userData.route_change ||
|
||||||
marker.userData.new_origin_asn,
|
formatBGPRouteChange(
|
||||||
),
|
marker.userData.origin_asn,
|
||||||
prefix: marker.userData.prefix,
|
marker.userData.new_origin_asn,
|
||||||
as_path_display: formatBGPASPath(marker.userData.as_path),
|
),
|
||||||
origin_asn: marker.userData.origin_asn,
|
prefix:
|
||||||
new_origin_asn: marker.userData.new_origin_asn,
|
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),
|
confidence: formatBGPConfidence(marker.userData.confidence),
|
||||||
collector: marker.userData.collector,
|
collector: marker.userData.collector,
|
||||||
observed_by: formatBGPObservedBy(marker.userData.collectors),
|
observed_by:
|
||||||
|
marker.userData.observed_by ||
|
||||||
|
formatBGPObservedBy(marker.userData.collectors),
|
||||||
impacted_scope: formatBGPImpactedScope(impactedRegions),
|
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),
|
created_at: formatBGPObservedTime(marker.userData.created_at_raw),
|
||||||
summary: marker.userData.summary,
|
summary: marker.userData.summary,
|
||||||
});
|
});
|
||||||
@@ -603,6 +625,8 @@ function updateStatsSummary() {
|
|||||||
landingPointCount:
|
landingPointCount:
|
||||||
document.getElementById("landing-point-count")?.textContent || 0,
|
document.getElementById("landing-point-count")?.textContent || 0,
|
||||||
bgpAnomalyCount: `${getBGPCount()} 条`,
|
bgpAnomalyCount: `${getBGPCount()} 条`,
|
||||||
|
bgpCollectorCount: `${getBGPCollectorCount()} 个`,
|
||||||
|
bgpStatusSummary: getBGPStatusSummary(),
|
||||||
terrainOn: getShowTerrain(),
|
terrainOn: getShowTerrain(),
|
||||||
textureQuality: "8K 卫星图",
|
textureQuality: "8K 卫星图",
|
||||||
});
|
});
|
||||||
@@ -717,8 +741,8 @@ async function loadData(showWhiteSphere = false) {
|
|||||||
setLoadingMessage(
|
setLoadingMessage(
|
||||||
showWhiteSphere ? "正在刷新全球态势数据..." : "正在初始化全球态势数据...",
|
showWhiteSphere ? "正在刷新全球态势数据..." : "正在初始化全球态势数据...",
|
||||||
showWhiteSphere
|
showWhiteSphere
|
||||||
? "重新同步卫星、海底光缆、登陆点与BGP异常数据"
|
? "重新同步卫星、海底光缆、登陆点与BGP态势数据"
|
||||||
: "同步卫星、海底光缆、登陆点与BGP异常数据",
|
: "同步卫星、海底光缆、登陆点与BGP态势数据",
|
||||||
);
|
);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
clearLockedObject();
|
clearLockedObject();
|
||||||
@@ -746,7 +770,20 @@ async function loadData(showWhiteSphere = false) {
|
|||||||
}
|
}
|
||||||
const bgpCountEl = document.getElementById("bgp-anomaly-count");
|
const bgpCountEl = document.getElementById("bgp-anomaly-count");
|
||||||
if (bgpCountEl) {
|
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;
|
return bgpResult;
|
||||||
})(),
|
})(),
|
||||||
@@ -765,7 +802,7 @@ async function loadData(showWhiteSphere = false) {
|
|||||||
errors.push({ label: "卫星", reason: results[1].reason });
|
errors.push({ label: "卫星", reason: results[1].reason });
|
||||||
}
|
}
|
||||||
if (results[2].status === "rejected") {
|
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) {
|
if (errors.length > 0) {
|
||||||
@@ -1175,7 +1212,7 @@ function onClick(event) {
|
|||||||
showBGPEventOverlay(clickedMarker, earth);
|
showBGPEventOverlay(clickedMarker, earth);
|
||||||
showBGPInfo(clickedMarker);
|
showBGPInfo(clickedMarker);
|
||||||
showStatusMessage(
|
showStatusMessage(
|
||||||
`已选择BGP异常: ${clickedMarker.userData.collector}`,
|
`已选择BGP事件: ${clickedMarker.userData.collector}`,
|
||||||
"info",
|
"info",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ export function updateEarthStats(stats) {
|
|||||||
const cableCountEl = document.getElementById("cable-count");
|
const cableCountEl = document.getElementById("cable-count");
|
||||||
const landingPointCountEl = document.getElementById("landing-point-count");
|
const landingPointCountEl = document.getElementById("landing-point-count");
|
||||||
const bgpAnomalyCountEl = document.getElementById("bgp-anomaly-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 terrainStatusEl = document.getElementById("terrain-status");
|
||||||
const textureQualityEl = document.getElementById("texture-quality");
|
const textureQualityEl = document.getElementById("texture-quality");
|
||||||
|
|
||||||
@@ -94,6 +96,10 @@ export function updateEarthStats(stats) {
|
|||||||
landingPointCountEl.textContent = stats.landingPointCount || 0;
|
landingPointCountEl.textContent = stats.landingPointCount || 0;
|
||||||
if (bgpAnomalyCountEl)
|
if (bgpAnomalyCountEl)
|
||||||
bgpAnomalyCountEl.textContent = stats.bgpAnomalyCount || 0;
|
bgpAnomalyCountEl.textContent = stats.bgpAnomalyCount || 0;
|
||||||
|
if (bgpCollectorCountEl)
|
||||||
|
bgpCollectorCountEl.textContent = stats.bgpCollectorCount || 0;
|
||||||
|
if (bgpStatusSummaryEl)
|
||||||
|
bgpStatusSummaryEl.textContent = stats.bgpStatusSummary || "-";
|
||||||
if (terrainStatusEl)
|
if (terrainStatusEl)
|
||||||
terrainStatusEl.textContent = stats.terrainOn ? "开启" : "关闭";
|
terrainStatusEl.textContent = stats.terrainOn ? "开启" : "关闭";
|
||||||
if (textureQualityEl)
|
if (textureQualityEl)
|
||||||
|
|||||||
@@ -20,6 +20,32 @@ interface BGPAnomaly {
|
|||||||
created_at: string | null
|
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 {
|
interface Summary {
|
||||||
total: number
|
total: number
|
||||||
by_type: Record<string, number>
|
by_type: Record<string, number>
|
||||||
@@ -27,6 +53,13 @@ interface Summary {
|
|||||||
by_status: Record<string, number>
|
by_status: Record<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EventSummary {
|
||||||
|
total: number
|
||||||
|
collector_count: number
|
||||||
|
prefix_count: number
|
||||||
|
by_type: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
function severityColor(severity: string) {
|
function severityColor(severity: string) {
|
||||||
if (severity === 'critical') return 'red'
|
if (severity === 'critical') return 'red'
|
||||||
if (severity === 'high') return 'orange'
|
if (severity === 'high') return 'orange'
|
||||||
@@ -36,19 +69,31 @@ function severityColor(severity: string) {
|
|||||||
|
|
||||||
function BGP() {
|
function BGP() {
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [incidents, setIncidents] = useState<BGPIncident[]>([])
|
||||||
const [anomalies, setAnomalies] = useState<BGPAnomaly[]>([])
|
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(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
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', { params: { page_size: 100 } }),
|
||||||
axios.get('/api/v1/bgp/anomalies/summary'),
|
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 || [])
|
setAnomalies(anomaliesRes.data.data || [])
|
||||||
setSummary(summaryRes.data)
|
setAnomalySummary(anomalySummaryRes.data)
|
||||||
|
setEvents(eventsRes.data.data || [])
|
||||||
|
setEventSummary(eventSummaryRes.data)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -62,7 +107,7 @@ function BGP() {
|
|||||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||||
<div>
|
<div>
|
||||||
<Title level={3} style={{ marginBottom: 4 }}>BGP观测</Title>
|
<Title level={3} style={{ marginBottom: 4 }}>BGP观测</Title>
|
||||||
<Text type="secondary">查看实时与回放阶段归一化出的路由异常。</Text>
|
<Text type="secondary">先看事件态势,再下钻到原子异常明细。</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Alert
|
<Alert
|
||||||
@@ -74,22 +119,102 @@ function BGP() {
|
|||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col xs={24} md={8}>
|
<Col xs={24} md={8}>
|
||||||
<Card>
|
<Card>
|
||||||
<Statistic title="异常总数" value={summary?.total || 0} />
|
<Statistic title="观测事件" value={eventSummary?.total || 0} />
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} md={8}>
|
<Col xs={24} md={8}>
|
||||||
<Card>
|
<Card>
|
||||||
<Statistic title="Critical" value={summary?.by_severity?.critical || 0} />
|
<Statistic title="观测站" value={eventSummary?.collector_count || 0} />
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} md={8}>
|
<Col xs={24} md={8}>
|
||||||
<Card>
|
<Card>
|
||||||
<Statistic title="Active" value={summary?.by_status?.active || 0} />
|
<Statistic title="观测前缀" value={eventSummary?.prefix_count || 0} />
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</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>
|
<Table<BGPAnomaly>
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@@ -151,6 +276,52 @@ function BGP() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card title="最近观测事件">
|
||||||
|
<Table<BGPEvent>
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
dataSource={events}
|
||||||
|
pagination={{ pageSize: 8 }}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
dataIndex: 'observed_at',
|
||||||
|
width: 180,
|
||||||
|
render: (value: string | null) => formatDateTimeZhCN(value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '观测站',
|
||||||
|
dataIndex: 'collector',
|
||||||
|
width: 140,
|
||||||
|
render: (value: string | null) => value || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '类型',
|
||||||
|
dataIndex: 'event_type',
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '前缀',
|
||||||
|
dataIndex: 'prefix',
|
||||||
|
width: 200,
|
||||||
|
render: (value: string | null) => value || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Origin ASN',
|
||||||
|
dataIndex: 'origin_asn',
|
||||||
|
width: 140,
|
||||||
|
render: (value: number | null) => (value ? `AS${value}` : '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Peer ASN',
|
||||||
|
dataIndex: 'peer_asn',
|
||||||
|
width: 140,
|
||||||
|
render: (value: number | null) => (value ? `AS${value}` : '-'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
</Space>
|
</Space>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user