from datetime import datetime from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from pydantic import BaseModel from app.core.security import get_current_user from app.db.session import get_db from app.models.bgp_anomaly import BGPAnomaly from app.models.bgp_incident import BGPIncident from app.models.bgp_observation import BGPObservation from app.models.user import User from app.services.bgp_collector_locations import ( build_bgp_collector_location_query, collect_bgp_collector_location_candidates, get_bgp_collector_location_dict, ) from app.services.bgp_collectors import build_bgp_collector_coverage from app.services.ai_client import get_ai_provider_client from app.api.v1.settings import get_web_search_client from app.services.location.llm_fallback import ( collect_llm_location_fallback_candidate, collect_location_search_evidence, ) router = APIRouter() BGP_SOURCES = ("ris_live_bgp", "bgpstream_bgp") def _parse_dt(value: Optional[str]) -> Optional[datetime]: if not value: return None return datetime.fromisoformat(value.replace("Z", "+00:00")) def _event_filters( *, prefix: Optional[str], origin_asn: Optional[int], peer_asn: Optional[int], collector: Optional[str], event_type: Optional[str], source: Optional[str], time_from: Optional[datetime], time_to: Optional[datetime], ): filters = [BGPObservation.source.in_(BGP_SOURCES)] if source: filters.append(BGPObservation.source == source) if prefix: filters.append(BGPObservation.prefix == prefix) if origin_asn is not None: filters.append(BGPObservation.origin_asn == origin_asn) if peer_asn is not None: filters.append(BGPObservation.peer_asn == peer_asn) if collector: filters.append(BGPObservation.collector == collector) if event_type: filters.append(BGPObservation.event_type == event_type) if time_from: filters.append(BGPObservation.observed_at >= time_from) if time_to: filters.append(BGPObservation.observed_at <= time_to) return filters def _anomaly_filters( *, severity: Optional[str], anomaly_type: Optional[str], status: Optional[str], prefix: Optional[str], origin_asn: Optional[int], time_from: Optional[datetime], time_to: Optional[datetime], ): filters = [] if severity: filters.append(BGPAnomaly.severity == severity) if anomaly_type: filters.append(BGPAnomaly.anomaly_type == anomaly_type) if status: filters.append(BGPAnomaly.status == status) if prefix: filters.append(BGPAnomaly.prefix == prefix) if origin_asn is not None: filters.append(BGPAnomaly.origin_asn == origin_asn) if time_from: filters.append(BGPAnomaly.created_at >= time_from) if time_to: filters.append(BGPAnomaly.created_at <= time_to) return filters def _incident_filters( *, severity: Optional[str], incident_type: Optional[str], status: Optional[str], ): filters = [] if severity: filters.append(BGPIncident.severity == severity) if incident_type: filters.append(BGPIncident.incident_type == incident_type) if status: filters.append(BGPIncident.status == status) return filters async def _build_event_summary_payload(db: AsyncSession) -> dict: base_filters = [BGPObservation.source.in_(BGP_SOURCES)] total_result = await db.execute( select(func.count(BGPObservation.id)).where(*base_filters) ) collectors_result = await db.execute( select(func.count(func.distinct(BGPObservation.collector))).where( *base_filters, BGPObservation.collector.isnot(None) ) ) prefixes_result = await db.execute( select(func.count(func.distinct(BGPObservation.prefix))).where( *base_filters, BGPObservation.prefix.isnot(None) ) ) type_result = await db.execute( select(BGPObservation.event_type, func.count(BGPObservation.id)) .where(*base_filters) .group_by(BGPObservation.event_type) ) return { "total": total_result.scalar() or 0, "collector_count": collectors_result.scalar() or 0, "prefix_count": prefixes_result.scalar() or 0, "by_type": {row[0]: row[1] for row in type_result.fetchall()}, } async def _build_anomaly_summary_payload(db: AsyncSession) -> dict: total_result = await db.execute(select(func.count(BGPAnomaly.id))) type_result = await db.execute( select(BGPAnomaly.anomaly_type, func.count(BGPAnomaly.id)) .group_by(BGPAnomaly.anomaly_type) .order_by(func.count(BGPAnomaly.id).desc()) ) severity_result = await db.execute( select(BGPAnomaly.severity, func.count(BGPAnomaly.id)) .group_by(BGPAnomaly.severity) .order_by(func.count(BGPAnomaly.id).desc()) ) status_result = await db.execute( select(BGPAnomaly.status, func.count(BGPAnomaly.id)) .group_by(BGPAnomaly.status) .order_by(func.count(BGPAnomaly.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()}, } async def _build_incident_summary_payload(db: AsyncSession) -> dict: 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("/events") async def list_bgp_events( prefix: Optional[str] = Query(None), origin_asn: Optional[int] = Query(None), peer_asn: Optional[int] = Query(None), collector: Optional[str] = Query(None), event_type: Optional[str] = Query(None), source: Optional[str] = Query(None), time_from: Optional[str] = Query(None), time_to: Optional[str] = Query(None), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): dt_from = _parse_dt(time_from) dt_to = _parse_dt(time_to) filters = _event_filters( prefix=prefix, origin_asn=origin_asn, peer_asn=peer_asn, collector=collector, event_type=event_type, source=source, time_from=dt_from, time_to=dt_to, ) offset = (page - 1) * page_size count_result = await db.execute( select(func.count(BGPObservation.id)).where(*filters) ) data_result = await db.execute( select(BGPObservation) .where(*filters) .order_by(BGPObservation.observed_at.desc(), BGPObservation.id.desc()) .offset(offset) .limit(page_size) ) records = data_result.scalars().all() return { "total": count_result.scalar() or 0, "page": page, "page_size": page_size, "data": [record.to_dict() for record in records], } @router.get("/events/summary") async def get_bgp_event_summary( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): return await _build_event_summary_payload(db) @router.get("/collectors") async def list_bgp_collectors( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): data = await build_bgp_collector_coverage(db, source_filter=BGP_SOURCES) return { "total": len(data), "data": data, } @router.get("/collectors/summary") async def get_bgp_collector_summary( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): collectors = await build_bgp_collector_coverage(db, source_filter=BGP_SOURCES) active_collectors = [item for item in collectors if item["observation_count"] > 0] return { "total": len(collectors), "active_collectors": len(active_collectors), "observed_prefixes": sum(item["prefix_count"] for item in active_collectors), "observed_origins": sum(item["origin_asn_count"] for item in active_collectors), "recent_24h_events": sum(item["recent_24h_observation_count"] for item in active_collectors), "recent_7d_events": sum(item["recent_7d_observation_count"] for item in active_collectors), } class CollectBGPCollectorLocationRequest(BaseModel): city: Optional[str] = None country: Optional[str] = None site: Optional[str] = None operator: Optional[str] = None @router.post("/collectors/{collector_id}/collect-location") async def collect_bgp_collector_location( collector_id: str, payload: CollectBGPCollectorLocationRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Run the shared location pipeline for a BGP route collector. Mirrors ``POST /api/v1/visualization/compute-centers/{source_id}/collect-location``. Returns ranked candidates from source coordinates and Nominatim queries built around the collector's stored context (IXP / city / country). Stored collector locations provide context only; they are not emitted as candidates. """ if not collector_id or not collector_id.strip(): raise HTTPException(status_code=400, detail="collector_id is required") legacy = get_bgp_collector_location_dict(collector_id) or {} site = payload.site or legacy.get("matched_location_name") city = payload.city or legacy.get("city") country = payload.country or legacy.get("country") operator = payload.operator or "RIPE NCC" candidates, attempted_queries = collect_bgp_collector_location_candidates( collector=collector_id, site=site, city=city, country=country, operator=operator, ) llm_failure_reason = None if not candidates: query = build_bgp_collector_location_query( collector=collector_id, site=site, city=city, country=country, operator=operator, ) llm_result = None try: web_search_client = await get_web_search_client(db) search_result = await collect_location_search_evidence( web_search_client=web_search_client, query=query, entity_type="bgp_collector", ) attempted_queries = [*attempted_queries, *search_result.attempted_queries] if not search_result.evidence: llm_failure_reason = search_result.failure_reason raise RuntimeError(search_result.failure_reason or "no WebSearch evidence") provider_client = await get_ai_provider_client(db) llm_result = await collect_llm_location_fallback_candidate( provider_client=provider_client, query=query, entity_type="bgp_collector", db=db, attempted_queries=attempted_queries, search_evidence=search_result.evidence, ) except Exception as exc: if llm_failure_reason is None: llm_failure_reason = f"LLM location factcheck unavailable: {exc}" attempted_queries = [ *attempted_queries, f"llm_factcheck:bgp_collector:{collector_id or 'unknown'}", ] if llm_result is not None: attempted_queries = [*attempted_queries, *llm_result.attempted_queries] candidates = llm_result.candidates llm_failure_reason = llm_result.failure_reason context = { "collector": collector_id, "site": site, "city": city, "country": country, "operator": operator, } if not candidates: return { "collector_id": collector_id, "name": collector_id, "success": False, "failure_reason": ( "No source coordinates or online geocoding result reached" " city-level precision for this collector." ), "candidates": [], "attempted_queries": list(attempted_queries), "llm_failure_reason": llm_failure_reason, "context": context, } return { "collector_id": collector_id, "name": collector_id, "success": True, "candidates": [candidate.to_dict() for candidate in candidates], "best_candidate": candidates[0].to_dict(), "attempted_queries": list(attempted_queries), "context": context, } @router.get("/overview/summary") async def get_bgp_overview_summary( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): event_summary = await _build_event_summary_payload(db) anomaly_summary = await _build_anomaly_summary_payload(db) incident_summary = await _build_incident_summary_payload(db) collectors = await build_bgp_collector_coverage(db, source_filter=BGP_SOURCES) active_collectors = [item for item in collectors if item["observation_count"] > 0] return { "incidentSummary": incident_summary, "anomalySummary": anomaly_summary, "eventSummary": event_summary, "collectorSummary": { "total": len(collectors), "active_collectors": len(active_collectors), "observed_prefixes": sum(item["prefix_count"] for item in active_collectors), "observed_origins": sum(item["origin_asn_count"] for item in active_collectors), "recent_24h_events": sum(item["recent_24h_observation_count"] for item in active_collectors), "recent_7d_events": sum(item["recent_7d_observation_count"] for item in active_collectors), }, } @router.get("/events/{event_id}") async def get_bgp_event( event_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): record = await db.get(BGPObservation, event_id) if not record or record.source not in BGP_SOURCES: raise HTTPException(status_code=404, detail="BGP event not found") return record.to_dict() @router.get("/anomalies") async def list_bgp_anomalies( severity: Optional[str] = Query(None), anomaly_type: Optional[str] = Query(None), status: Optional[str] = Query(None), prefix: Optional[str] = Query(None), origin_asn: Optional[int] = Query(None), time_from: Optional[str] = Query(None), time_to: Optional[str] = Query(None), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): dt_from = _parse_dt(time_from) dt_to = _parse_dt(time_to) filters = _anomaly_filters( severity=severity, anomaly_type=anomaly_type, status=status, prefix=prefix, origin_asn=origin_asn, time_from=dt_from, time_to=dt_to, ) offset = (page - 1) * page_size total_result = await db.execute( select(func.count(BGPAnomaly.id)).where(*filters) ) data_result = await db.execute( select(BGPAnomaly) .where(*filters) .order_by(BGPAnomaly.created_at.desc(), BGPAnomaly.id.desc()) .offset(offset) .limit(page_size) ) records = data_result.scalars().all() return { "total": total_result.scalar() or 0, "page": page, "page_size": page_size, "data": [record.to_dict() for record in records], } @router.get("/anomalies/summary") async def get_bgp_anomaly_summary( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): return await _build_anomaly_summary_payload(db) @router.get("/anomalies/{anomaly_id}") async def get_bgp_anomaly( anomaly_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): record = await db.get(BGPAnomaly, anomaly_id) if not record: raise HTTPException(status_code=404, detail="BGP anomaly not found") return record.to_dict() @router.get("/incidents") async def list_bgp_incidents( severity: Optional[str] = Query(None), incident_type: Optional[str] = Query(None), status: Optional[str] = Query(None), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): filters = _incident_filters( severity=severity, incident_type=incident_type, status=status, ) offset = (page - 1) * page_size total_result = await db.execute( select(func.count(BGPIncident.id)).where(*filters) ) data_result = await db.execute( select(BGPIncident) .where(*filters) .order_by(BGPIncident.created_at.desc(), BGPIncident.id.desc()) .offset(offset) .limit(page_size) ) records = data_result.scalars().all() return { "total": total_result.scalar() or 0, "page": page, "page_size": page_size, "data": [record.to_dict() for record in records], } @router.get("/incidents/summary") async def get_bgp_incident_summary( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): return await _build_incident_summary_payload(db) @router.get("/incidents/{incident_id}") async def get_bgp_incident( incident_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): record = await db.get(BGPIncident, incident_id) if not record: raise HTTPException(status_code=404, detail="BGP incident not found") return record.to_dict()