feat: improve bgp incident visibility

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

View File

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

View File

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

View File

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