Files
planet/backend/app/api/v1/visualization.py
2026-04-23 17:57:35 +08:00

1443 lines
50 KiB
Python

"""Visualization API - GeoJSON endpoints for 3D Earth display
Unified API for all visualization data sources.
Returns GeoJSON format compatible with Three.js, CesiumJS, and Unreal Cesium.
"""
from datetime import UTC, datetime
import logging
import math
import httpx
from fastapi import APIRouter, HTTPException, Depends, Query, Response
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from typing import List, Dict, Any, Optional
from app.core.collected_data_fields import get_record_field
from app.core.countries import get_country_centroid
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.bgp_collectors import build_bgp_collector_coverage
from app.services.cable_graph import build_graph_from_data, CableGraph, haversine_distance
from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
from app.services.persistent_logs import record_system_log
router = APIRouter()
logger = logging.getLogger(__name__)
TERRAIN_TILE_URL_TEMPLATE = (
"https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"
)
# ============== Converter Functions ==============
def convert_cable_to_geojson(records: List[CollectedData]) -> Dict[str, Any]:
"""Convert cable records to GeoJSON FeatureCollection"""
features = []
for record in records:
metadata = record.extra_data or {}
route_coords = metadata.get("route_coordinates", [])
if not route_coords:
continue
all_lines = []
# Handle both old format (flat array) and new format (array of arrays)
if route_coords and isinstance(route_coords[0], list):
# New format: array of arrays (MultiLineString structure)
if route_coords and isinstance(route_coords[0][0], list):
# Array of arrays of arrays - multiple lines
for line in route_coords:
line_coords = []
for point in line:
if len(point) >= 2:
try:
lon = float(point[0])
lat = float(point[1])
line_coords.append([lon, lat])
except (ValueError, TypeError):
continue
if len(line_coords) >= 2:
all_lines.append(line_coords)
else:
# Old format: flat array of points - treat as single line
line_coords = []
for point in route_coords:
if len(point) >= 2:
try:
lon = float(point[0])
lat = float(point[1])
line_coords.append([lon, lat])
except (ValueError, TypeError):
continue
if len(line_coords) >= 2:
all_lines.append(line_coords)
if not all_lines:
continue
# Use MultiLineString format to preserve cable segments
features.append(
{
"type": "Feature",
"geometry": {"type": "MultiLineString", "coordinates": all_lines},
"properties": {
"id": record.id,
"cable_id": record.name,
"source_id": record.source_id,
"Name": record.name,
"name": record.name,
"owner": metadata.get("owners"),
"owners": metadata.get("owners"),
"rfs": metadata.get("rfs"),
"RFS": metadata.get("rfs"),
"status": metadata.get("status", "active"),
"length": get_record_field(record, "value"),
"length_km": get_record_field(record, "value"),
"SHAPE__Length": get_record_field(record, "value"),
"url": metadata.get("url"),
"color": metadata.get("color"),
"year": metadata.get("year"),
},
}
)
return {"type": "FeatureCollection", "features": features}
def convert_landing_point_to_geojson(records: List[CollectedData], city_to_cable_ids_map: Dict[int, List[int]] = None, cable_id_to_name_map: Dict[int, str] = None) -> Dict[str, Any]:
features = []
for record in records:
try:
latitude = get_record_field(record, "latitude")
longitude = get_record_field(record, "longitude")
lat = float(latitude) if latitude else None
lon = float(longitude) if longitude else None
except (ValueError, TypeError):
continue
if lat is None or lon is None:
continue
metadata = record.extra_data or {}
city_id = metadata.get("city_id")
props = {
"id": record.id,
"source_id": record.source_id,
"name": record.name,
"country": get_record_field(record, "country"),
"city": get_record_field(record, "city"),
"is_tbd": metadata.get("is_tbd", False),
}
cable_names = []
if city_to_cable_ids_map and city_id in city_to_cable_ids_map:
for cable_id in city_to_cable_ids_map[city_id]:
if cable_id_to_name_map and cable_id in cable_id_to_name_map:
cable_names.append(cable_id_to_name_map[cable_id])
if cable_names:
props["cable_names"] = cable_names
features.append(
{
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [lon, lat]},
"properties": props,
}
)
return {"type": "FeatureCollection", "features": features}
def convert_satellite_to_geojson(records: List[CollectedData]) -> Dict[str, Any]:
"""Convert satellite TLE records to GeoJSON"""
features = []
for record in records:
metadata = record.extra_data or {}
norad_id = metadata.get("norad_cat_id")
if not norad_id:
continue
tle_line1 = metadata.get("tle_line1")
tle_line2 = metadata.get("tle_line2")
if not tle_line1 or not tle_line2:
tle_line1, tle_line2 = build_tle_lines_from_elements(
norad_cat_id=norad_id,
epoch=metadata.get("epoch"),
inclination=metadata.get("inclination"),
raan=metadata.get("raan"),
eccentricity=metadata.get("eccentricity"),
arg_of_perigee=metadata.get("arg_of_perigee"),
mean_anomaly=metadata.get("mean_anomaly"),
mean_motion=metadata.get("mean_motion"),
)
features.append(
{
"type": "Feature",
"id": norad_id,
"geometry": {"type": "Point", "coordinates": [0, 0, 0]},
"properties": {
"id": record.id,
"norad_cat_id": norad_id,
"name": record.name,
"international_designator": metadata.get("international_designator"),
"epoch": metadata.get("epoch"),
"inclination": metadata.get("inclination"),
"raan": metadata.get("raan"),
"eccentricity": metadata.get("eccentricity"),
"arg_of_perigee": metadata.get("arg_of_perigee"),
"mean_anomaly": metadata.get("mean_anomaly"),
"mean_motion": metadata.get("mean_motion"),
"bstar": metadata.get("bstar"),
"classification_type": metadata.get("classification_type"),
"tle_line1": tle_line1,
"tle_line2": tle_line2,
"data_type": "satellite_tle",
},
}
)
return {"type": "FeatureCollection", "features": features}
def _current_collected_data_stmt(source: str):
return (
select(CollectedData)
.where(CollectedData.source == source)
.where(CollectedData.is_current.is_(True))
.order_by(CollectedData.id.desc())
)
async def _load_current_collected_data(
db: AsyncSession,
source: str,
*,
exclude_unknown_name: bool = False,
limit: Optional[int] = None,
) -> List[CollectedData]:
stmt = _current_collected_data_stmt(source)
if exclude_unknown_name:
stmt = stmt.where(CollectedData.name != "Unknown")
if limit is not None:
stmt = stmt.limit(limit)
result = await db.execute(stmt)
return list(result.scalars().all())
async def _load_current_collected_data_by_sources(
db: AsyncSession,
sources: List[str],
) -> Dict[str, List[CollectedData]]:
if not sources:
return {}
stmt = (
select(CollectedData)
.where(CollectedData.source.in_(sources))
.where(CollectedData.is_current.is_(True))
.order_by(CollectedData.source.asc(), CollectedData.id.desc())
)
result = await db.execute(stmt)
grouped_records: Dict[str, List[CollectedData]] = {source: [] for source in sources}
for record in result.scalars().all():
grouped_records.setdefault(record.source, []).append(record)
return grouped_records
def _build_landing_point_cable_maps(
relation_records: List[CollectedData],
cable_records: List[CollectedData],
) -> tuple[Dict[int, List[int]], Dict[int, str]]:
city_to_cable_ids_map: Dict[int, List[int]] = {}
for relation_record in relation_records:
if not relation_record.extra_data:
continue
city_id = relation_record.extra_data.get("city_id")
cable_id = relation_record.extra_data.get("cable_id")
if city_id is None or cable_id is None:
continue
city_to_cable_ids_map.setdefault(city_id, [])
if cable_id not in city_to_cable_ids_map[city_id]:
city_to_cable_ids_map[city_id].append(cable_id)
cable_id_to_name_map: Dict[int, str] = {}
for cable_record in cable_records:
if not cable_record.extra_data:
continue
cable_id = cable_record.extra_data.get("cable_id")
cable_name = cable_record.name
if cable_id and cable_name:
cable_id_to_name_map[cable_id] = cable_name
return city_to_cable_ids_map, cable_id_to_name_map
def _filter_known_records(records: List[CollectedData]) -> List[CollectedData]:
return [record for record in records if record.name != "Unknown"]
def convert_supercomputer_to_geojson(records: List[CollectedData]) -> Dict[str, Any]:
"""Convert TOP500 supercomputer records to GeoJSON"""
features = []
for record in records:
try:
latitude = get_record_field(record, "latitude")
longitude = get_record_field(record, "longitude")
lat = float(latitude) if latitude and latitude != "0.0" else None
lon = (
float(longitude) if longitude and longitude != "0.0" else None
)
except (ValueError, TypeError):
lat, lon = None, None
metadata = record.extra_data or {}
features.append(
{
"type": "Feature",
"id": record.id,
"geometry": {"type": "Point", "coordinates": [lon or 0, lat or 0]},
"properties": {
"id": record.id,
"name": record.name,
"rank": metadata.get("rank"),
"r_max": get_record_field(record, "rmax"),
"r_peak": get_record_field(record, "rpeak"),
"cores": get_record_field(record, "cores"),
"power": get_record_field(record, "power"),
"country": get_record_field(record, "country"),
"city": get_record_field(record, "city"),
"data_type": "supercomputer",
},
}
)
return {"type": "FeatureCollection", "features": features}
def convert_gpu_cluster_to_geojson(records: List[CollectedData]) -> Dict[str, Any]:
"""Convert GPU cluster records to GeoJSON"""
features = []
for record in records:
try:
latitude = get_record_field(record, "latitude")
longitude = get_record_field(record, "longitude")
lat = float(latitude) if latitude else None
lon = float(longitude) if longitude else None
except (ValueError, TypeError):
lat, lon = None, None
metadata = record.extra_data or {}
features.append(
{
"type": "Feature",
"id": record.id,
"geometry": {"type": "Point", "coordinates": [lon or 0, lat or 0]},
"properties": {
"id": record.id,
"name": record.name,
"country": get_record_field(record, "country"),
"city": get_record_field(record, "city"),
"metadata": metadata,
"data_type": "gpu_cluster",
},
}
)
return {"type": "FeatureCollection", "features": features}
def _parse_float(value: Any) -> Optional[float]:
try:
if value in (None, ""):
return None
return float(value)
except (TypeError, ValueError):
return None
COMPUTE_CENTER_COORDINATE_HINTS = (
("el capitan", 37.6819, -121.7681),
("livermore", 37.6819, -121.7681),
("llnl", 37.6819, -121.7681),
("lawrence livermore", 37.6819, -121.7681),
("frontier", 35.9319, -84.3107),
("oak ridge", 35.9319, -84.3107),
("ornl", 35.9319, -84.3107),
("aurora", 41.7130, -87.9820),
("argonne", 41.7130, -87.9820),
("anl", 41.7130, -87.9820),
("fugaku", 34.6953, 135.1974),
("kobe", 34.6953, 135.1974),
("riken", 34.6953, 135.1974),
("summit", 35.9319, -84.3107),
("leonardo", 44.4949, 11.3426),
("bologna", 44.4949, 11.3426),
("alps", 46.0037, 8.9511),
("lugano", 46.0037, 8.9511),
("sunway taihulight", 31.4912, 120.3119),
("wuxi", 31.4912, 120.3119),
("tianhe-2", 23.1291, 113.2644),
("tianhe-2a", 23.1291, 113.2644),
("guangzhou", 23.1291, 113.2644),
("colossus", 35.1495, -90.0490),
("memphis", 35.1495, -90.0490),
("xai", 35.1495, -90.0490),
)
def _normalize_hint_text(*parts: Any) -> str:
return " ".join(
str(part).strip().lower()
for part in parts
if part not in (None, "")
)
def _resolve_compute_center_coordinates(
record: CollectedData,
metadata: Dict[str, Any],
) -> Dict[str, Any]:
latitude = _parse_float(get_record_field(record, "latitude"))
longitude = _parse_float(get_record_field(record, "longitude"))
if latitude not in (None, 0.0) and longitude not in (None, 0.0):
return {
"latitude": latitude,
"longitude": longitude,
"location_precision": "precise",
"geography_mode": "source_coordinates",
"is_estimated": False,
"estimated_reason": None,
}
hint_text = _normalize_hint_text(
record.name,
get_record_field(record, "city"),
get_record_field(record, "country"),
metadata.get("site"),
metadata.get("organization"),
metadata.get("operator"),
)
for needle, resolved_latitude, resolved_longitude in COMPUTE_CENTER_COORDINATE_HINTS:
if needle in hint_text:
return {
"latitude": resolved_latitude,
"longitude": resolved_longitude,
"location_precision": "estimated_site",
"geography_mode": "site_hint",
"is_estimated": True,
"estimated_reason": f"Matched known site hint: {needle}",
}
centroid = get_country_centroid(get_record_field(record, "country"))
if centroid:
return {
"latitude": centroid.get("latitude"),
"longitude": centroid.get("longitude"),
"location_precision": "estimated_country",
"geography_mode": "country_centroid",
"is_estimated": True,
"estimated_reason": "Estimated from country centroid",
}
return {
"latitude": latitude,
"longitude": longitude,
"location_precision": "unknown",
"geography_mode": "unknown",
"is_estimated": True,
"estimated_reason": "No resolvable location hints",
}
def _normalize_capacity_band(capacity_value: Optional[float], capacity_unit: str) -> str:
if capacity_value is None:
return "unknown"
unit = str(capacity_unit or "").strip().lower()
if unit in {"pflop/s", "pflops", "pflop"}:
normalized_tflops = capacity_value * 1000
elif unit in {"gflop/s", "gflops", "gflop"}:
normalized_tflops = capacity_value / 1000
else:
normalized_tflops = capacity_value
if normalized_tflops >= 1_000_000:
return "exascale"
if normalized_tflops >= 100_000:
return "ultra"
if normalized_tflops >= 10_000:
return "large"
if normalized_tflops > 0:
return "regional"
return "unknown"
def convert_compute_centers_to_geojson(records: List[CollectedData]) -> Dict[str, Any]:
"""Convert compute infrastructure records into a unified GeoJSON layer."""
features = []
for record in records:
metadata = record.extra_data or {}
coordinate_info = _resolve_compute_center_coordinates(record, metadata)
latitude = coordinate_info.get("latitude")
longitude = coordinate_info.get("longitude")
site_type = (
"supercomputer"
if record.source == "top500" or record.data_type == "supercomputer"
else "gpu_cluster"
)
if latitude in (None, 0.0) or longitude in (None, 0.0):
continue
if site_type == "supercomputer":
capacity_value = _parse_float(get_record_field(record, "rmax"))
capacity_unit = "GFlops"
else:
capacity_value = _parse_float(get_record_field(record, "value"))
capacity_unit = str(get_record_field(record, "unit") or "TFlop/s")
vendor = (
metadata.get("manufacturer")
or metadata.get("vendor")
or metadata.get("gpu_type")
)
operator = (
metadata.get("organization")
or metadata.get("operator")
or metadata.get("owner")
)
rank = metadata.get("rank")
if rank in (None, "") and site_type == "supercomputer":
rank = get_record_field(record, "rank")
updated_at = to_iso8601_utc(record.reference_date or record.collected_at)
features.append(
{
"type": "Feature",
"id": record.id,
"geometry": {
"type": "Point",
"coordinates": [longitude or 0, latitude or 0],
},
"properties": {
"id": record.id,
"source_id": record.source_id,
"name": record.name,
"site_type": site_type,
"country": get_record_field(record, "country"),
"city": get_record_field(record, "city"),
"latitude": latitude,
"longitude": longitude,
"operator": operator,
"vendor": vendor,
"capacity_value": capacity_value,
"capacity_unit": capacity_unit,
"capacity_band": _normalize_capacity_band(capacity_value, capacity_unit),
"rank": rank,
"gpu_count": metadata.get("gpu_count"),
"gpu_type": metadata.get("gpu_type"),
"cores": get_record_field(record, "cores"),
"power": get_record_field(record, "power"),
"source": record.source,
"updated_at": updated_at,
"status": "observed",
"location_precision": coordinate_info.get("location_precision"),
"geography_mode": coordinate_info.get("geography_mode"),
"is_estimated": coordinate_info.get("is_estimated", False),
"estimated_reason": coordinate_info.get("estimated_reason"),
"data_type": "compute_center",
"metadata": metadata,
},
}
)
return {"type": "FeatureCollection", "features": features}
def convert_bgp_anomalies_to_geojson(
records: List[BGPAnomaly],
geography_hints: Optional[Dict[str, Dict[str, Any]]] = None,
) -> Dict[str, Any]:
features = []
geography_hints = geography_hints or {}
for record in records:
evidence = record.evidence or {}
hint = geography_hints.get(str(record.entity_key or record.id), {})
collectors = evidence.get("collectors") or record.peer_scope or []
if not collectors:
nested = evidence.get("events") or []
collectors = [
str((item or {}).get("collector") or "").strip()
for item in nested
if (item or {}).get("collector")
]
collectors = [collector for collector in collectors if collector]
if not collectors:
collectors = []
as_path = []
if isinstance(evidence.get("as_path"), list):
as_path = evidence.get("as_path") or []
if not as_path:
nested = evidence.get("events") or []
for item in nested:
candidate_path = (item or {}).get("as_path")
if isinstance(candidate_path, list) and candidate_path:
as_path = candidate_path
break
impacted_regions = []
seen_regions = set()
for collector_name in collectors:
collector_location = RIPE_RIS_COLLECTOR_COORDS.get(str(collector_name))
if not collector_location:
continue
region_key = (
collector_location.get("country"),
collector_location.get("city"),
)
if region_key in seen_regions:
continue
seen_regions.add(region_key)
impacted_regions.append(
{
"collector": collector_name,
"country": collector_location.get("country"),
"city": collector_location.get("city"),
"latitude": collector_location.get("latitude"),
"longitude": collector_location.get("longitude"),
}
)
geography_regions = _normalize_geo_regions(hint.get("regions") or [])
geography_mode = hint.get("geography_mode") or "collector_centroid"
collector = collectors[0] if collectors else None
location = geography_regions[0] if geography_regions else None
if location is None and collector:
location = RIPE_RIS_COLLECTOR_COORDS.get(str(collector))
if location is None:
nested = evidence.get("events") or []
for item in nested:
collector_name = (item or {}).get("collector")
if collector_name and collector_name in RIPE_RIS_COLLECTOR_COORDS:
location = RIPE_RIS_COLLECTOR_COORDS[collector_name]
collector = collector_name
geography_mode = "collector_centroid"
break
if location is None:
continue
features.append(
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [location["longitude"], location["latitude"]],
},
"properties": {
"id": record.id,
"collector": collector,
"city": location.get("city"),
"country": location.get("country"),
"source": record.source,
"anomaly_type": record.anomaly_type,
"severity": record.severity,
"status": record.status,
"prefix": record.prefix,
"origin_asn": record.origin_asn,
"new_origin_asn": record.new_origin_asn,
"collectors": collectors,
"collector_count": len(collectors) or 1,
"as_path": as_path,
"impacted_regions": impacted_regions,
"geography_mode": geography_mode,
"confidence": record.confidence,
"summary": record.summary,
"created_at": to_iso8601_utc(record.created_at),
},
}
)
return {"type": "FeatureCollection", "features": features}
async def build_anomaly_geography_hints(
db: AsyncSession,
records: List[BGPAnomaly],
) -> Dict[str, Dict[str, Any]]:
hints: Dict[str, Dict[str, Any]] = {}
for record in records:
hint = _extract_evidence_geography_hint(record.evidence or {})
if hint:
hints[str(record.entity_key or record.id)] = hint
return hints
def convert_bgp_collectors_to_geojson(
coverage_by_collector: Dict[str, Dict[str, Any]] | None = None,
) -> Dict[str, Any]:
features = []
coverage_by_collector = coverage_by_collector or {}
for collector, location in sorted(RIPE_RIS_COLLECTOR_COORDS.items()):
coverage = coverage_by_collector.get(collector, {})
features.append(
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [location["longitude"], location["latitude"]],
},
"properties": {
"collector": collector,
"city": coverage.get("city") or location.get("city"),
"country": coverage.get("country") or location.get("country"),
"status": "online",
"observation_count": coverage.get("observation_count", 0),
"prefix_count": coverage.get("prefix_count", 0),
"origin_asn_count": coverage.get("origin_asn_count", 0),
"peer_asn_count": coverage.get("peer_asn_count", 0),
"recent_15m_observation_count": coverage.get("recent_15m_observation_count", 0),
"recent_24h_observation_count": coverage.get("recent_24h_observation_count", 0),
"recent_7d_observation_count": coverage.get("recent_7d_observation_count", 0),
"recent_15m_prefix_count": coverage.get("recent_15m_prefix_count", 0),
"recent_24h_prefix_count": coverage.get("recent_24h_prefix_count", 0),
"recent_7d_prefix_count": coverage.get("recent_7d_prefix_count", 0),
"top_event_types": coverage.get("top_event_types", []),
"latest_observed_at": coverage.get("latest_observed_at"),
"latest_event_type": coverage.get("latest_event_type"),
"baseline_scope": coverage.get(
"baseline_scope",
{
"countries": [location.get("country")] if location.get("country") else [],
"cities": [location.get("city")] if location.get("city") else [],
},
),
},
}
)
return {"type": "FeatureCollection", "features": features}
def _incident_estimated_center(valid_regions: List[Dict[str, Any]]) -> Dict[str, float]:
x = 0.0
y = 0.0
z = 0.0
for region in valid_regions:
lat_rad = math.radians(float(region["latitude"]))
lon_rad = math.radians(float(region["longitude"]))
x += math.cos(lat_rad) * math.cos(lon_rad)
y += math.cos(lat_rad) * math.sin(lon_rad)
z += math.sin(lat_rad)
total = float(len(valid_regions))
if total <= 0:
return {"latitude": 0.0, "longitude": 0.0}
x /= total
y /= total
z /= total
hyp = math.sqrt((x * x) + (y * y))
if hyp == 0:
return {"latitude": 0.0, "longitude": 0.0}
return {
"latitude": math.degrees(math.atan2(z, hyp)),
"longitude": math.degrees(math.atan2(y, x)),
}
def _incident_estimated_radius_km(center: Dict[str, float], valid_regions: List[Dict[str, Any]]) -> float:
center_coords = (float(center["longitude"]), float(center["latitude"]))
distances = [
haversine_distance(
center_coords,
(float(region["longitude"]), float(region["latitude"])),
)
for region in valid_regions
]
return round(max(distances) if distances else 0.0, 1)
def _normalize_geo_regions(regions: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
normalized: list[dict[str, Any]] = []
seen: set[tuple[Any, ...]] = set()
for region in regions:
if not isinstance(region, dict):
continue
latitude = region.get("latitude")
longitude = region.get("longitude")
if not isinstance(latitude, (int, float)) or not isinstance(longitude, (int, float)):
continue
item = {
"collector": region.get("collector"),
"country": region.get("country"),
"city": region.get("city"),
"latitude": float(latitude),
"longitude": float(longitude),
}
key = (
item["collector"],
item["country"],
item["city"],
item["latitude"],
item["longitude"],
)
if key in seen:
continue
seen.add(key)
normalized.append(item)
return normalized
def _extract_evidence_geography_hint(evidence: Dict[str, Any]) -> Dict[str, Any] | None:
prefix_geo_regions = []
prefix_regions = []
asn_regions = []
evidence_prefix_geography = evidence.get("prefix_geography") or {}
prefix_geo_regions.extend(
_normalize_geo_regions(evidence_prefix_geography.get("regions") or [])
)
prefix_scope = evidence.get("prefix_scope") or {}
prefix_regions.extend(_normalize_geo_regions(prefix_scope.get("regions") or []))
for profile_key in ("origin_asn_profile", "new_origin_asn_profile"):
profile = evidence.get(profile_key) or {}
latitude = profile.get("latitude")
longitude = profile.get("longitude")
if isinstance(latitude, (int, float)) and isinstance(longitude, (int, float)):
asn_regions.append(
{
"country": profile.get("country"),
"city": profile.get("city"),
"latitude": float(latitude),
"longitude": float(longitude),
}
)
prefix_geo_regions = _normalize_geo_regions(prefix_geo_regions)
prefix_regions = _normalize_geo_regions(prefix_regions)
asn_regions = _normalize_geo_regions(asn_regions)
if prefix_geo_regions:
return {"regions": prefix_geo_regions, "geography_mode": "prefix_geography"}
if prefix_regions:
return {"regions": prefix_regions, "geography_mode": "prefix_scope"}
if asn_regions:
return {"regions": asn_regions, "geography_mode": "asn_region"}
return None
async def build_incident_geography_hints(
db: AsyncSession,
records: List[BGPIncident],
) -> Dict[str, Dict[str, Any]]:
evidence_refs = sorted(
{
str(ref)
for record in records
for ref in (record.evidence_refs or [])
if ref
}
)
anomalies = []
if evidence_refs:
result = await db.execute(
select(BGPAnomaly).where(BGPAnomaly.entity_key.in_(evidence_refs))
)
anomalies = result.scalars().all()
anomaly_by_key = {
str(anomaly.entity_key): anomaly
for anomaly in anomalies
if anomaly.entity_key
}
hints: Dict[str, Dict[str, Any]] = {}
for record in records:
merged_hint: Dict[str, Any] | None = None
priority = {"prefix_geography": 3, "prefix_scope": 2, "asn_region": 1}
for ref in record.evidence_refs or []:
anomaly = anomaly_by_key.get(str(ref))
if anomaly is None:
continue
hint = _extract_evidence_geography_hint(anomaly.evidence or {})
if hint is None:
continue
if merged_hint is None:
merged_hint = {
"regions": list(hint["regions"]),
"geography_mode": hint["geography_mode"],
}
continue
if priority[hint["geography_mode"]] > priority[merged_hint["geography_mode"]]:
merged_hint = {
"regions": list(hint["regions"]),
"geography_mode": hint["geography_mode"],
}
elif priority[hint["geography_mode"]] == priority[merged_hint["geography_mode"]]:
merged_hint["regions"].extend(hint["regions"])
if merged_hint:
merged_hint["regions"] = _normalize_geo_regions(merged_hint["regions"])
hints[record.incident_key] = merged_hint
return hints
def convert_bgp_incidents_to_geojson(
records: List[BGPIncident],
geography_hints: Optional[Dict[str, Dict[str, Any]]] = None,
) -> Dict[str, Any]:
features = []
for record in records:
hint = (geography_hints or {}).get(record.incident_key, {})
regions = hint.get("regions") or (record.affected_regions or [])
if not regions:
continue
valid_regions = _normalize_geo_regions(regions)
if not valid_regions:
continue
estimated_center = _incident_estimated_center(valid_regions)
estimated_radius_km = _incident_estimated_radius_km(estimated_center, valid_regions)
features.append(
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [
estimated_center["longitude"],
estimated_center["latitude"],
],
},
"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,
"estimated_center": estimated_center,
"estimated_radius_km": estimated_radius_km,
"geography_mode": hint.get("geography_mode") or "collector_centroid",
"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 ==============
@router.get("/geo/cables")
async def get_cables_geojson(db: AsyncSession = Depends(get_db)):
"""获取海底电缆 GeoJSON 数据 (LineString)"""
try:
records = await _load_current_collected_data(db, "arcgis_cables")
if not records:
raise HTTPException(
status_code=404,
detail="No cable data found. Please run the arcgis_cables collector first.",
)
return convert_cable_to_geojson(records)
except HTTPException:
raise
except Exception as e:
logger.exception("Failed to build cables GeoJSON response")
await record_system_log(
source="backend",
service="api",
module=__name__,
event="visualization.cables.load_failed",
level="error",
message="Failed to build cables GeoJSON response",
category="visualization",
context={"error": str(e)},
)
raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}")
@router.get("/geo/landing-points")
async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
try:
records_by_source = await _load_current_collected_data_by_sources(
db,
[
"arcgis_landing_points",
"arcgis_cable_landing_relation",
"arcgis_cables",
],
)
records = records_by_source.get("arcgis_landing_points", [])
relation_records = records_by_source.get(
"arcgis_cable_landing_relation",
[],
)
cable_records = records_by_source.get("arcgis_cables", [])
city_to_cable_ids_map, cable_id_to_name_map = _build_landing_point_cable_maps(
relation_records,
cable_records,
)
if not records:
raise HTTPException(
status_code=404,
detail="No landing point data found. Please run the arcgis_landing_points collector first.",
)
return convert_landing_point_to_geojson(records, city_to_cable_ids_map, cable_id_to_name_map)
except HTTPException:
raise
except Exception as e:
logger.exception("Failed to build landing points GeoJSON response")
await record_system_log(
source="backend",
service="api",
module=__name__,
event="visualization.landing_points.load_failed",
level="error",
message="Failed to build landing points GeoJSON response",
category="visualization",
context={"error": str(e)},
)
raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}")
@router.get("/terrain/terrarium/{z}/{x}/{y}.png")
async def get_terrarium_tile(z: int, x: int, y: int):
"""Proxy Terrarium elevation tiles through the backend to avoid browser CORS issues."""
if z < 0 or x < 0 or y < 0:
raise HTTPException(status_code=400, detail="Invalid terrain tile coordinates")
url = TERRAIN_TILE_URL_TEMPLATE.format(z=z, x=x, y=y)
try:
async with httpx.AsyncClient(
timeout=20.0,
follow_redirects=True,
) as client:
upstream = await client.get(url)
upstream.raise_for_status()
except httpx.HTTPStatusError as exc:
raise HTTPException(
status_code=exc.response.status_code,
detail=f"Terrain tile upstream error: {exc.response.status_code}",
) from exc
except httpx.HTTPError as exc:
raise HTTPException(
status_code=502,
detail=f"Terrain tile fetch failed: {exc}",
) from exc
cache_control = upstream.headers.get("cache-control") or "public, max-age=86400"
etag = upstream.headers.get("etag")
last_modified = upstream.headers.get("last-modified")
headers = {
"Cache-Control": cache_control,
}
if etag:
headers["ETag"] = etag
if last_modified:
headers["Last-Modified"] = last_modified
return Response(
content=upstream.content,
media_type=upstream.headers.get("content-type", "image/png"),
headers=headers,
)
@router.get("/geo/all")
async def get_all_geojson(db: AsyncSession = Depends(get_db)):
records_by_source = await _load_current_collected_data_by_sources(
db,
[
"arcgis_cables",
"arcgis_landing_points",
"arcgis_cable_landing_relation",
],
)
cables_records = records_by_source.get("arcgis_cables", [])
points_records = records_by_source.get("arcgis_landing_points", [])
relation_records = records_by_source.get("arcgis_cable_landing_relation", [])
city_to_cable_ids_map, cable_id_to_name_map = _build_landing_point_cable_maps(
relation_records,
cables_records,
)
cables = (
convert_cable_to_geojson(cables_records)
if cables_records
else {"type": "FeatureCollection", "features": []}
)
points = (
convert_landing_point_to_geojson(points_records, city_to_cable_ids_map, cable_id_to_name_map)
if points_records
else {"type": "FeatureCollection", "features": []}
)
return {
"cables": cables,
"landing_points": points,
"stats": {
"cable_count": len(cables.get("features", [])) if cables else 0,
"landing_point_count": len(points.get("features", [])) if points else 0,
},
}
@router.get("/geo/satellites")
async def get_satellites_geojson(
limit: Optional[int] = Query(
None,
ge=1,
description="Maximum number of satellites to return. Omit for no limit.",
),
db: AsyncSession = Depends(get_db),
):
"""获取卫星 TLE GeoJSON 数据"""
records = await _load_current_collected_data(
db,
"celestrak_tle",
exclude_unknown_name=True,
limit=limit,
)
if not records:
return {"type": "FeatureCollection", "features": [], "count": 0}
geojson = convert_satellite_to_geojson(list(records))
return {
**geojson,
"count": len(geojson.get("features", [])),
}
@router.get("/geo/supercomputers")
async def get_supercomputers_geojson(
limit: int = 500,
db: AsyncSession = Depends(get_db),
):
"""获取 TOP500 超算中心 GeoJSON 数据"""
records = await _load_current_collected_data(
db,
"top500",
exclude_unknown_name=True,
limit=limit,
)
if not records:
return {"type": "FeatureCollection", "features": [], "count": 0}
geojson = convert_supercomputer_to_geojson(list(records))
return {
**geojson,
"count": len(geojson.get("features", [])),
}
@router.get("/geo/gpu-clusters")
async def get_gpu_clusters_geojson(
limit: int = 100,
db: AsyncSession = Depends(get_db),
):
"""获取 GPU 集群 GeoJSON 数据"""
records = await _load_current_collected_data(
db,
"epoch_ai_gpu",
exclude_unknown_name=True,
limit=limit,
)
if not records:
return {"type": "FeatureCollection", "features": [], "count": 0}
geojson = convert_gpu_cluster_to_geojson(list(records))
return {
**geojson,
"count": len(geojson.get("features", [])),
}
@router.get("/geo/compute-centers")
async def get_compute_centers_geojson(
limit: int = Query(200, ge=1, le=1000),
db: AsyncSession = Depends(get_db),
):
"""获取统一算力中心 GeoJSON 数据"""
records_by_source = await _load_current_collected_data_by_sources(
db,
["top500", "epoch_ai_gpu"],
)
records = _filter_known_records(
records_by_source.get("top500", []) + records_by_source.get("epoch_ai_gpu", []),
)
if limit is not None:
records = records[:limit]
if not records:
return {
"type": "FeatureCollection",
"features": [],
"count": 0,
"stats": {
"total": 0,
"supercomputers": 0,
"gpu_clusters": 0,
},
}
geojson = convert_compute_centers_to_geojson(records)
features = geojson.get("features", [])
return {
**geojson,
"count": len(features),
"stats": {
"total": len(features),
"supercomputers": sum(
1 for feature in features
if feature.get("properties", {}).get("site_type") == "supercomputer"
),
"gpu_clusters": sum(
1 for feature in features
if feature.get("properties", {}).get("site_type") == "gpu_cluster"
),
},
}
@router.get("/geo/bgp-anomalies")
async def get_bgp_anomalies_geojson(
severity: Optional[str] = Query(None),
status: Optional[str] = Query("active"),
limit: int = Query(200, ge=1, le=1000),
db: AsyncSession = Depends(get_db),
):
stmt = select(BGPAnomaly).order_by(BGPAnomaly.created_at.desc()).limit(limit)
if severity:
stmt = stmt.where(BGPAnomaly.severity == severity)
if status:
stmt = stmt.where(BGPAnomaly.status == status)
result = await db.execute(stmt)
records = list(result.scalars().all())
geography_hints = await build_anomaly_geography_hints(db, records)
geojson = convert_bgp_anomalies_to_geojson(records, geography_hints)
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())
geography_hints = await build_incident_geography_hints(db, records)
geojson = convert_bgp_incidents_to_geojson(records, geography_hints)
return {**geojson, "count": len(geojson.get("features", []))}
@router.get("/geo/bgp-collectors")
async def get_bgp_collectors_geojson(db: AsyncSession = Depends(get_db)):
coverage = await build_bgp_collector_coverage(
db,
source_filter=("ris_live_bgp", "bgpstream_bgp"),
)
coverage_by_collector = {
item["collector"]: item
for item in coverage
if item.get("collector")
}
geojson = convert_bgp_collectors_to_geojson(coverage_by_collector)
return {**geojson, "count": len(geojson.get("features", []))}
@router.get("/all")
async def get_all_visualization_data(db: AsyncSession = Depends(get_db)):
"""获取所有可视化数据的统一端点
Returns GeoJSON FeatureCollections for all data types:
- satellites: 卫星 TLE 数据
- cables: 海底电缆
- landing_points: 登陆点
- supercomputers: TOP500 超算
- gpu_clusters: GPU 集群
"""
records_by_source = await _load_current_collected_data_by_sources(
db,
[
"arcgis_cables",
"arcgis_landing_points",
"celestrak_tle",
"top500",
"epoch_ai_gpu",
],
)
cables_records = records_by_source.get("arcgis_cables", [])
points_records = records_by_source.get("arcgis_landing_points", [])
satellites_records = _filter_known_records(
records_by_source.get("celestrak_tle", []),
)
supercomputers_records = _filter_known_records(
records_by_source.get("top500", []),
)
gpu_records = _filter_known_records(
records_by_source.get("epoch_ai_gpu", []),
)
cables = (
convert_cable_to_geojson(cables_records)
if cables_records
else {"type": "FeatureCollection", "features": []}
)
landing_points = (
convert_landing_point_to_geojson(points_records)
if points_records
else {"type": "FeatureCollection", "features": []}
)
satellites = (
convert_satellite_to_geojson(satellites_records)
if satellites_records
else {"type": "FeatureCollection", "features": []}
)
supercomputers = (
convert_supercomputer_to_geojson(supercomputers_records)
if supercomputers_records
else {"type": "FeatureCollection", "features": []}
)
gpu_clusters = (
convert_gpu_cluster_to_geojson(gpu_records)
if gpu_records
else {"type": "FeatureCollection", "features": []}
)
return {
"generated_at": to_iso8601_utc(datetime.now(UTC)),
"version": "1.0",
"data": {
"satellites": satellites,
"cables": cables,
"landing_points": landing_points,
"supercomputers": supercomputers,
"gpu_clusters": gpu_clusters,
},
"stats": {
"total_features": (
len(satellites.get("features", []))
+ len(cables.get("features", []))
+ len(landing_points.get("features", []))
+ len(supercomputers.get("features", []))
+ len(gpu_clusters.get("features", []))
),
"satellites": len(satellites.get("features", [])),
"cables": len(cables.get("features", [])),
"landing_points": len(landing_points.get("features", [])),
"supercomputers": len(supercomputers.get("features", [])),
"gpu_clusters": len(gpu_clusters.get("features", [])),
},
}
# Cache for cable graph
_cable_graph: Optional[CableGraph] = None
async def get_cable_graph(db: AsyncSession) -> CableGraph:
"""Get or build cable graph (cached)"""
global _cable_graph
if _cable_graph is None:
cables_records = await _load_current_collected_data(db, "arcgis_cables")
points_records = await _load_current_collected_data(db, "arcgis_landing_points")
cables_data = convert_cable_to_geojson(cables_records)
points_data = convert_landing_point_to_geojson(points_records)
_cable_graph = build_graph_from_data(cables_data, points_data)
return _cable_graph
@router.post("/geo/path")
async def find_path(
start: List[float],
end: List[float],
db: AsyncSession = Depends(get_db),
):
"""Find shortest path between two coordinates via cable network"""
if not start or len(start) != 2:
raise HTTPException(status_code=400, detail="Start must be [lon, lat]")
if not end or len(end) != 2:
raise HTTPException(status_code=400, detail="End must be [lon, lat]")
graph = await get_cable_graph(db)
result = graph.find_shortest_path(start, end)
if not result:
raise HTTPException(
status_code=404,
detail="No path found between these points. They may be too far from any landing point.",
)
return result