From 552e49bde0e8135f7622245ebe7e9a661afc6246 Mon Sep 17 00:00:00 2001 From: linkong Date: Tue, 31 Mar 2026 14:07:28 +0800 Subject: [PATCH] feat: expand bgp observability surfaces --- VERSION | 2 +- backend/app/api/v1/bgp.py | 30 +++ backend/app/api/v1/visualization.py | 42 +++- backend/app/services/bgp_collectors.py | 162 +++++++++++++ backend/app/services/bgp_incidents.py | 153 +++++++++++- backend/tests/test_bgp.py | 147 +++++++++++- docs/CHANGELOG.md | 38 +++ frontend/package.json | 2 +- frontend/public/earth/js/bgp.js | 312 ++++++++++++++++++++++++- frontend/public/earth/js/cables.js | 9 +- frontend/public/earth/js/constants.js | 12 +- frontend/public/earth/js/info-card.js | 43 +++- frontend/public/earth/js/main.js | 189 +++++++++++++-- frontend/public/earth/js/satellites.js | 98 ++++++++ frontend/src/pages/BGP/BGP.tsx | 125 +++++++++- pyproject.toml | 2 +- 16 files changed, 1319 insertions(+), 47 deletions(-) create mode 100644 backend/app/services/bgp_collectors.py diff --git a/VERSION b/VERSION index 59f23022..21574090 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.21.9 +0.22.0 diff --git a/backend/app/api/v1/bgp.py b/backend/app/api/v1/bgp.py index 60b44ea6..26067b1e 100644 --- a/backend/app/api/v1/bgp.py +++ b/backend/app/api/v1/bgp.py @@ -11,6 +11,7 @@ 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_collectors import build_bgp_collector_coverage router = APIRouter() @@ -108,6 +109,35 @@ async def get_bgp_event_summary( } +@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), + } + + @router.get("/events/{event_id}") async def get_bgp_event( event_id: int, diff --git a/backend/app/api/v1/visualization.py b/backend/app/api/v1/visualization.py index 3e64b9a5..e0370657 100644 --- a/backend/app/api/v1/visualization.py +++ b/backend/app/api/v1/visualization.py @@ -17,6 +17,7 @@ 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 from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS @@ -417,10 +418,14 @@ def convert_bgp_anomalies_to_geojson(records: List[BGPAnomaly]) -> Dict[str, Any return {"type": "FeatureCollection", "features": features} -def convert_bgp_collectors_to_geojson() -> Dict[str, Any]: +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", @@ -430,9 +435,27 @@ def convert_bgp_collectors_to_geojson() -> Dict[str, Any]: }, "properties": { "collector": collector, - "city": location.get("city"), - "country": location.get("country"), + "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_24h_observation_count": coverage.get("recent_24h_observation_count", 0), + "recent_7d_observation_count": coverage.get("recent_7d_observation_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 [], + }, + ), }, } ) @@ -740,8 +763,17 @@ async def get_bgp_incidents_geojson( @router.get("/geo/bgp-collectors") -async def get_bgp_collectors_geojson(): - geojson = convert_bgp_collectors_to_geojson() +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", []))} diff --git a/backend/app/services/bgp_collectors.py b/backend/app/services/bgp_collectors.py new file mode 100644 index 00000000..488730f2 --- /dev/null +++ b/backend/app/services/bgp_collectors.py @@ -0,0 +1,162 @@ +"""Collector baseline and coverage helpers for BGP observations.""" + +from __future__ import annotations + +from collections import defaultdict +from datetime import UTC, datetime, timedelta +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.time import to_iso8601_utc +from app.models.bgp_observation import BGPObservation +from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS + + +async def build_bgp_collector_coverage( + db: AsyncSession, + *, + source_filter: tuple[str, ...] | None = None, +) -> list[dict[str, Any]]: + now = datetime.now(UTC) + recent_24h_threshold = now - timedelta(hours=24) + recent_7d_threshold = now - timedelta(days=7) + + stmt = select(BGPObservation).order_by(BGPObservation.observed_at.desc(), BGPObservation.id.desc()) + if source_filter: + stmt = stmt.where(BGPObservation.source.in_(source_filter)) + + result = await db.execute(stmt) + records = list(result.scalars().all()) + + by_collector: dict[str, dict[str, Any]] = {} + for record in records: + collector = str(record.collector or "").strip() + if not collector: + continue + + coverage = by_collector.get(collector) + if coverage is None: + location = record.collector_geo or RIPE_RIS_COLLECTOR_COORDS.get(collector, {}) + coverage = { + "collector": collector, + "city": location.get("city"), + "country": location.get("country"), + "latitude": location.get("latitude"), + "longitude": location.get("longitude"), + "observation_count": 0, + "prefixes": set(), + "origin_asns": set(), + "peer_asns": set(), + "event_types": defaultdict(int), + "countries": set(), + "cities": set(), + "recent_24h_observation_count": 0, + "recent_7d_observation_count": 0, + "recent_24h_prefixes": set(), + "recent_7d_prefixes": set(), + "latest_observed_at": None, + "latest_event_type": None, + } + by_collector[collector] = coverage + + coverage["observation_count"] += 1 + if record.prefix: + coverage["prefixes"].add(record.prefix) + if record.origin_asn is not None: + coverage["origin_asns"].add(record.origin_asn) + if record.peer_asn is not None: + coverage["peer_asns"].add(record.peer_asn) + if record.event_type: + coverage["event_types"][record.event_type] += 1 + + observed_at = record.observed_at + if observed_at is not None: + aware_observed_at = ( + observed_at.astimezone(UTC) + if observed_at.tzinfo + else observed_at.replace(tzinfo=UTC) + ) + if aware_observed_at >= recent_24h_threshold: + coverage["recent_24h_observation_count"] += 1 + if record.prefix: + coverage["recent_24h_prefixes"].add(record.prefix) + if aware_observed_at >= recent_7d_threshold: + coverage["recent_7d_observation_count"] += 1 + if record.prefix: + coverage["recent_7d_prefixes"].add(record.prefix) + + geo = record.collector_geo or {} + if geo.get("country"): + coverage["countries"].add(geo["country"]) + if geo.get("city"): + coverage["cities"].add(geo["city"]) + + current_latest = coverage["latest_observed_at"] + if current_latest is None or ( + record.observed_at is not None and record.observed_at > current_latest + ): + coverage["latest_observed_at"] = record.observed_at + coverage["latest_event_type"] = record.event_type + + for collector, location in RIPE_RIS_COLLECTOR_COORDS.items(): + if collector in by_collector: + continue + by_collector[collector] = { + "collector": collector, + "city": location.get("city"), + "country": location.get("country"), + "latitude": location.get("latitude"), + "longitude": location.get("longitude"), + "observation_count": 0, + "prefixes": set(), + "origin_asns": set(), + "peer_asns": set(), + "event_types": defaultdict(int), + "countries": {location.get("country")} if location.get("country") else set(), + "cities": {location.get("city")} if location.get("city") else set(), + "recent_24h_observation_count": 0, + "recent_7d_observation_count": 0, + "recent_24h_prefixes": set(), + "recent_7d_prefixes": set(), + "latest_observed_at": None, + "latest_event_type": None, + } + + results: list[dict[str, Any]] = [] + for collector in sorted(by_collector.keys()): + item = by_collector[collector] + top_event_types = sorted( + item["event_types"].items(), + key=lambda pair: (-pair[1], pair[0]), + ) + results.append( + { + "collector": item["collector"], + "city": item["city"], + "country": item["country"], + "latitude": item["latitude"], + "longitude": item["longitude"], + "observation_count": item["observation_count"], + "prefix_count": len(item["prefixes"]), + "origin_asn_count": len(item["origin_asns"]), + "peer_asn_count": len(item["peer_asns"]), + "recent_24h_observation_count": item["recent_24h_observation_count"], + "recent_7d_observation_count": item["recent_7d_observation_count"], + "recent_24h_prefix_count": len(item["recent_24h_prefixes"]), + "recent_7d_prefix_count": len(item["recent_7d_prefixes"]), + "top_event_types": [ + {"event_type": event_type, "count": count} + for event_type, count in top_event_types[:3] + ], + "latest_observed_at": to_iso8601_utc(item["latest_observed_at"]), + "latest_event_type": item["latest_event_type"], + "baseline_scope": { + "countries": sorted(country for country in item["countries"] if country), + "cities": sorted(city for city in item["cities"] if city), + }, + } + ) + + return results diff --git a/backend/app/services/bgp_incidents.py b/backend/app/services/bgp_incidents.py index f1a12df9..03149b4e 100644 --- a/backend/app/services/bgp_incidents.py +++ b/backend/app/services/bgp_incidents.py @@ -3,12 +3,16 @@ from __future__ import annotations from datetime import UTC, datetime +from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.core.collected_data_fields import get_record_field 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 haversine_distance def _severity_rank(value: str | None) -> int: @@ -44,6 +48,150 @@ def _collector_regions_from_anomaly(anomaly: BGPAnomaly) -> list[dict]: return collected +def _dedupe_collected_records(records: list[CollectedData]) -> list[CollectedData]: + latest_by_key: dict[str, CollectedData] = {} + for record in records: + dedupe_key = str(record.source_id or record.entity_key or record.name or record.id) + existing = latest_by_key.get(dedupe_key) + if existing is None or (record.id or 0) > (existing.id or 0): + latest_by_key[dedupe_key] = record + return list(latest_by_key.values()) + + +async def infer_related_infrastructure( + db: AsyncSession, + affected_regions: list[dict], + *, + max_matches: int = 6, + max_distance_km: float = 450.0, +) -> dict[str, list[dict[str, Any]]]: + valid_regions = [ + region + for region in affected_regions + if isinstance(region, dict) + and isinstance(region.get("latitude"), (int, float)) + and isinstance(region.get("longitude"), (int, float)) + ] + if not valid_regions: + return {"related_cables": [], "related_ixps": []} + + landing_result = await db.execute( + select(CollectedData).where(CollectedData.source == "arcgis_landing_points") + ) + relation_result = await db.execute( + select(CollectedData).where(CollectedData.source == "arcgis_cable_landing_relation") + ) + cable_result = await db.execute( + select(CollectedData).where(CollectedData.source == "arcgis_cables") + ) + + landing_records = _dedupe_collected_records(list(landing_result.scalars().all())) + relation_records = _dedupe_collected_records(list(relation_result.scalars().all())) + cable_records = _dedupe_collected_records(list(cable_result.scalars().all())) + + city_to_cable_ids: dict[int, list[int]] = {} + for relation in relation_records: + metadata = relation.extra_data or {} + city_id = metadata.get("city_id") + cable_id = metadata.get("cable_id") + if city_id is None or cable_id is None: + continue + city_key = int(city_id) + cable_key = int(cable_id) + city_to_cable_ids.setdefault(city_key, []) + if cable_key not in city_to_cable_ids[city_key]: + city_to_cable_ids[city_key].append(cable_key) + + cable_id_to_name: dict[int, str] = {} + for cable in cable_records: + metadata = cable.extra_data or {} + cable_id = metadata.get("cable_id") + if cable_id is None or not cable.name: + continue + cable_id_to_name[int(cable_id)] = cable.name + + matches: list[dict[str, Any]] = [] + seen_match_keys: set[tuple[Any, ...]] = set() + + for region in valid_regions: + region_coords = (float(region["longitude"]), float(region["latitude"])) + + for landing in landing_records: + try: + latitude = get_record_field(landing, "latitude") + longitude = get_record_field(landing, "longitude") + landing_lat = float(latitude) if latitude is not None else None + landing_lon = float(longitude) if longitude is not None else None + except (TypeError, ValueError): + landing_lat = None + landing_lon = None + + if landing_lat is None or landing_lon is None: + continue + + distance_km = haversine_distance(region_coords, (landing_lon, landing_lat)) + if distance_km > max_distance_km: + continue + + landing_meta = landing.extra_data or {} + city_id = landing_meta.get("city_id") + cable_names = [] + if city_id is not None: + for cable_id in city_to_cable_ids.get(int(city_id), []): + cable_name = cable_id_to_name.get(int(cable_id)) + if cable_name and cable_name not in cable_names: + cable_names.append(cable_name) + + match = { + "landing_point": landing.name or "Unknown", + "city": get_record_field(landing, "city"), + "country": get_record_field(landing, "country"), + "distance_km": round(distance_km, 1), + "collector": region.get("collector"), + "cable_names": cable_names, + } + match_key = ( + match["landing_point"], + match["city"], + match["country"], + ) + if match_key in seen_match_keys: + continue + seen_match_keys.add(match_key) + matches.append(match) + + matches.sort( + key=lambda item: ( + item.get("distance_km", 999999), + str(item.get("landing_point") or ""), + ) + ) + matches = matches[:max_matches] + + related_ixps = [] + seen_ixp_keys: set[tuple[str, str]] = set() + for item in matches: + city = str(item.get("city") or "").strip() + country = str(item.get("country") or "").strip() + if not city and not country: + continue + key = (city, country) + if key in seen_ixp_keys: + continue + seen_ixp_keys.add(key) + related_ixps.append( + { + "name": ", ".join(part for part in [city, country] if part), + "type": "regional_exchange_hint", + } + ) + + return { + "related_cables": matches, + "related_ixps": related_ixps, + } + + async def create_bgp_incidents_for_anomalies( db: AsyncSession, *, @@ -120,6 +268,7 @@ async def create_bgp_incidents_for_anomalies( f"{len(items)} anomaly signal(s) grouped into one {primary.anomaly_type} incident, " f"affecting {len(prefixes) or 1} prefix scope(s) across {len(collectors)} collector(s)." ) + related_infrastructure = await infer_related_infrastructure(db, regions) db.add( BGPIncident( @@ -138,8 +287,8 @@ async def create_bgp_incidents_for_anomalies( affected_asns=asns, affected_collectors=collectors, affected_regions=regions, - related_cables=[], - related_ixps=[], + related_cables=related_infrastructure["related_cables"], + related_ixps=related_infrastructure["related_ixps"], evidence_refs=evidence_refs, ) ) diff --git a/backend/tests/test_bgp.py b/backend/tests/test_bgp.py index ff98af55..57a483a8 100644 --- a/backend/tests/test_bgp.py +++ b/backend/tests/test_bgp.py @@ -16,7 +16,11 @@ from app.services.collectors.bgp_common import ( save_bgp_observations_for_batch, ) from app.services.bgp_enrichment import enrich_bgp_events_for_batch, extract_bgp_network_fields -from app.services.bgp_incidents import create_bgp_incidents_for_anomalies +from app.services.bgp_incidents import ( + create_bgp_incidents_for_anomalies, + infer_related_infrastructure, +) +from app.services.bgp_collectors import build_bgp_collector_coverage from app.models.bgp_anomaly import BGPAnomaly from app.models.collected_data import CollectedData from app.models.bgp_incident import BGPIncident @@ -331,13 +335,17 @@ async def test_create_bgp_incidents_for_anomalies_aggregates_regions_and_collect }, ) - created = await create_bgp_incidents_for_anomalies( - db, - source="ris_live_bgp", - snapshot_id=1, - task_id=2, - anomalies=[anomaly], - ) + with patch( + "app.services.bgp_incidents.infer_related_infrastructure", + new=AsyncMock(return_value={"related_cables": [], "related_ixps": []}), + ): + created = await create_bgp_incidents_for_anomalies( + db, + source="ris_live_bgp", + snapshot_id=1, + task_id=2, + anomalies=[anomaly], + ) assert created == 1 assert db.commits == 1 @@ -348,6 +356,91 @@ async def test_create_bgp_incidents_for_anomalies_aggregates_regions_and_collect assert incident.affected_regions[0]["city"] == "Amsterdam" +@pytest.mark.asyncio +async def test_infer_related_infrastructure_links_nearby_cables(): + landing = CollectedData( + source="arcgis_landing_points", + name="Amsterdam Landing", + data_type="landing_point", + extra_data={ + "city_id": 10, + "country": "Netherlands", + "city": "Amsterdam", + "latitude": 52.3676, + "longitude": 4.9041, + }, + ) + relation = CollectedData( + source="arcgis_cable_landing_relation", + name="rel-1", + data_type="landing_relation", + extra_data={"city_id": 10, "cable_id": 20}, + ) + cable = CollectedData( + source="arcgis_cables", + name="AEConnect-1", + data_type="cable", + extra_data={"cable_id": 20}, + ) + db = _FakeAsyncSession([[landing], [relation], [cable]]) + + result = await infer_related_infrastructure( + db, + [ + { + "collector": "rrc00", + "country": "Netherlands", + "city": "Amsterdam", + "latitude": 52.36, + "longitude": 4.90, + } + ], + ) + + assert len(result["related_cables"]) == 1 + assert result["related_cables"][0]["landing_point"] == "Amsterdam Landing" + assert result["related_cables"][0]["cable_names"] == ["AEConnect-1"] + assert result["related_ixps"][0]["name"] == "Amsterdam, Netherlands" + + +@pytest.mark.asyncio +async def test_build_bgp_collector_coverage_summarizes_observations(): + obs_one = BGPObservation( + source="ris_live_bgp", + collector="rrc00", + prefix="203.0.113.0/24", + origin_asn=64496, + peer_asn=3333, + event_type="announcement", + observed_at=datetime(2026, 3, 30, 10, 0, tzinfo=UTC), + collector_geo={"city": "Amsterdam", "country": "Netherlands"}, + ) + obs_two = BGPObservation( + source="ris_live_bgp", + collector="rrc00", + prefix="198.51.100.0/24", + origin_asn=64497, + peer_asn=3334, + event_type="withdrawal", + observed_at=datetime(2026, 3, 30, 10, 5, tzinfo=UTC), + collector_geo={"city": "Amsterdam", "country": "Netherlands"}, + ) + db = _FakeAsyncSession([[obs_one, obs_two]]) + + coverage = await build_bgp_collector_coverage(db, source_filter=BGP_SOURCES) + + first = next(item for item in coverage if item["collector"] == "rrc00") + assert first["observation_count"] == 2 + assert first["recent_24h_observation_count"] == 2 + assert first["recent_7d_observation_count"] == 2 + assert first["prefix_count"] == 2 + assert first["recent_24h_prefix_count"] == 2 + assert first["origin_asn_count"] == 2 + assert first["latest_event_type"] == "withdrawal" + assert first["baseline_scope"]["countries"] == ["Netherlands"] + assert first["baseline_scope"]["cities"] == ["Amsterdam"] + + @pytest.mark.asyncio async def test_save_bgp_observations_for_batch_adds_rows(): db = _FakeAsyncSession([]) @@ -643,3 +736,41 @@ async def test_bgp_event_summary_api_returns_aggregates(): assert payload["prefix_count"] == 2 assert payload["by_type"]["announcement"] == 1 assert payload["by_type"]["withdrawal"] == 1 + + +@pytest.mark.asyncio +async def test_bgp_collectors_api_returns_coverage(): + observation = BGPObservation( + id=1, + source="ris_live_bgp", + collector="rrc00", + peer_asn=3333, + prefix="203.0.113.0/24", + event_type="announcement", + origin_asn=64496, + observed_at=datetime(2026, 3, 30, 10, 0, tzinfo=UTC), + collector_geo={"city": "Amsterdam", "country": "Netherlands"}, + ) + db = _FakeAsyncSession([[observation], [observation]]) + client = await _bgp_test_client(db) + + try: + list_response = await client.get("/api/v1/bgp/collectors") + summary_response = await client.get("/api/v1/bgp/collectors/summary") + finally: + await client.aclose() + app.dependency_overrides.clear() + + assert list_response.status_code == 200 + list_payload = list_response.json() + assert list_payload["total"] >= 1 + target = next(item for item in list_payload["data"] if item["collector"] == "rrc00") + assert target["observation_count"] == 1 + assert target["prefix_count"] == 1 + + assert summary_response.status_code == 200 + summary_payload = summary_response.json() + assert summary_payload["active_collectors"] >= 1 + assert summary_payload["observed_prefixes"] >= 1 + assert summary_payload["recent_24h_events"] >= 1 + assert summary_payload["recent_7d_events"] >= 1 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e30426da..e717f771 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -300,3 +300,41 @@ Released: 2026-03-25 - Fixed several collected-data field mapping issues. - Fixed frontend table layout inconsistencies across multiple admin pages. - Fixed TOP500 parsing and related metadata alignment issues. +## 0.22.0 + +Released: 2026-03-31 + +### Highlights + +- Expanded the BGP stack from raw events into collector coverage, incident weak-correlation, and Earth-side observability overlays. +- Upgraded the BGP console and Earth runtime so collector baselines and nearby infrastructure context remain visible even outside active anomaly spikes. + +### Added + +- Added BGP collector coverage APIs and dynamic GeoJSON enrichment for collector baseline metrics. +- Added weak correlation from BGP incidents to nearby landing points, cable names, and regional exchange hints. +- Added BGP collector coverage views in the admin console, including recent activity and baseline scope summaries. + +### Improved + +- Improved Earth BGP rendering with clearer collector markers, activity-driven halos, selected coverage overlays, and nearby satellite highlighting. +- Improved incident presentation by surfacing related infrastructure directly in BGP incident listings and Earth detail cards. +- Improved BGP backend test coverage around collector coverage and infrastructure inference. + +## 0.21.9 + +Released: 2026-03-31 + +### Highlights + +- Expanded the BGP pipeline with observations, enrichment, detectors, and incidents. +- Stabilized backend tests and improved visualization deduplication across repeated collections. + +### Improved + +- Improved visualization APIs so repeated satellite and infrastructure collections no longer inflate rendered entity counts. +- Improved backend coverage for BGP observations, incidents, and API summaries. + +### Fixed + +- Fixed several backend tests to match the current UTC serialization and collector behavior. diff --git a/frontend/package.json b/frontend/package.json index 1a1ab5eb..e367f6e3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.21.9", + "version": "0.22.0", "private": true, "dependencies": { "@ant-design/icons": "^5.2.6", diff --git a/frontend/public/earth/js/bgp.js b/frontend/public/earth/js/bgp.js index 90ffc3cc..be6d52e3 100644 --- a/frontend/public/earth/js/bgp.js +++ b/frontend/public/earth/js/bgp.js @@ -13,10 +13,12 @@ let showBGP = true; let totalAnomalyCount = 0; let totalIncidentCount = 0; let textureCache = null; +let collectorTextureCache = null; let activeEventOverlay = null; const relativeTimeFormatter = new Intl.RelativeTimeFormat("zh-CN", { numeric: "auto", }); +const MATERIAL_ACCESS_POINT_PATH = "M4.93 4.93A9.97 9.97 0 0 0 2 12c0 2.76 1.12 5.26 2.93 7.07l1.41-1.41A7.94 7.94 0 0 1 4 12c0-2.21.89-4.22 2.34-5.66zm14.14 0l-1.41 1.41A7.96 7.96 0 0 1 20 12c0 2.22-.89 4.22-2.34 5.66l1.41 1.41A9.97 9.97 0 0 0 22 12c0-2.76-1.12-5.26-2.93-7.07M7.76 7.76A5.98 5.98 0 0 0 6 12c0 1.65.67 3.15 1.76 4.24l1.41-1.41A4 4 0 0 1 8 12c0-1.11.45-2.11 1.17-2.83zm8.48 0l-1.41 1.41A4 4 0 0 1 16 12c0 1.11-.45 2.11-1.17 2.83l1.41 1.41A5.98 5.98 0 0 0 18 12c0-1.65-.67-3.15-1.76-4.24M12 10a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2"; function getMarkerTexture() { if (textureCache) return textureCache; @@ -46,6 +48,42 @@ function getMarkerTexture() { return textureCache; } +function getCollectorTexture() { + if (collectorTextureCache) return collectorTextureCache; + + const canvas = document.createElement("canvas"); + canvas.width = 128; + canvas.height = 128; + const context = canvas.getContext("2d"); + if (!context) { + collectorTextureCache = new THREE.Texture(canvas); + return collectorTextureCache; + } + + context.clearRect(0, 0, 128, 128); + + const glow = context.createRadialGradient(64, 64, 8, 64, 64, 34); + glow.addColorStop(0, "rgba(255,255,255,0.36)"); + glow.addColorStop(0.55, "rgba(255,255,255,0.12)"); + glow.addColorStop(1, "rgba(255,255,255,0)"); + context.fillStyle = glow; + context.beginPath(); + context.arc(64, 64, 34, 0, Math.PI * 2); + context.fill(); + + context.save(); + context.translate(16, 16); + context.scale(4, 4); + context.fillStyle = "rgba(255,255,255,0.98)"; + context.shadowColor = "rgba(255,255,255,0.3)"; + context.shadowBlur = 3; + context.fill(new Path2D(MATERIAL_ACCESS_POINT_PATH)); + context.restore(); + + collectorTextureCache = new THREE.CanvasTexture(canvas); + return collectorTextureCache; +} + function normalizeSeverity(severity) { const value = String(severity || "").trim().toLowerCase(); @@ -69,6 +107,50 @@ function getSeverityScale(severity) { return BGP_CONFIG.severityScales[normalizeSeverity(severity)]; } +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); +} + +function getCollectorActivityProfile(markerData) { + const recent24h = Number(markerData?.recent_24h_observation_count || 0); + const recent7d = Number(markerData?.recent_7d_observation_count || 0); + const prefixes = Number(markerData?.prefix_count || 0); + const origins = Number(markerData?.origin_asn_count || 0); + + const activityScore = + recent24h * 1.7 + + recent7d * 0.3 + + prefixes * 0.16 + + origins * 0.12; + + let tier = "idle"; + if (activityScore >= 50 || recent24h >= 24) tier = "hot"; + else if (activityScore >= 20 || recent24h >= 10) tier = "high"; + else if (activityScore >= 8 || recent24h >= 4) tier = "medium"; + else if (activityScore > 0) tier = "low"; + + const scaleBoost = clamp(1 + Math.log2(activityScore + 1) * 0.12, 1, 1.55); + const haloScale = + BGP_CONFIG.collectorHaloScale + + clamp(Math.log2(recent24h + prefixes + 1) * 2.2, 0, 12); + const pulseHaloScale = + BGP_CONFIG.collectorPulseHaloScale + + clamp(Math.log2(recent24h + recent7d + 1) * 2.8, 0, 14); + const coverageHaloScale = + BGP_CONFIG.collectorCoverageHaloScale + + clamp(Math.log2(prefixes + origins + 1) * 3.4, 0, 18); + + return { + tier, + color: BGP_CONFIG.collectorHeatColors[tier] || BGP_CONFIG.collectorColor, + scaleBoost, + haloScale, + pulseHaloScale, + coverageHaloScale, + activityScore, + }; +} + function formatLocalDateTime(value) { if (!value) return "-"; @@ -222,6 +304,62 @@ export function formatBGPImpactedScope(regions) { return `${labels.slice(0, 3).join(" / ")} 等${labels.length}地`; } +export function formatBGPRelatedCables(items) { + if (!Array.isArray(items) || items.length === 0) return "-"; + + const labels = items + .slice(0, 3) + .map((item) => { + const landing = item?.landing_point || ""; + const cables = Array.isArray(item?.cable_names) ? item.cable_names : []; + const cableText = cables.length > 0 ? cables.slice(0, 2).join(", ") : "附近登陆点"; + const distance = item?.distance_km !== undefined ? ` ${item.distance_km}km` : ""; + return `${landing || cableText} (${cableText}${distance})`; + }) + .filter(Boolean); + + if (labels.length === 0) return "-"; + if (items.length <= 3) return labels.join(" / "); + return `${labels.join(" / ")} 等${items.length}处`; +} + +export function formatBGPScope(scope) { + const countries = Array.isArray(scope?.countries) ? scope.countries : []; + const cities = Array.isArray(scope?.cities) ? scope.cities : []; + const cityText = cities.slice(0, 3).join(" / "); + const countryText = countries.slice(0, 3).join(" / "); + + if (cityText && countryText) { + return `${cityText} | ${countryText}`; + } + return cityText || countryText || "-"; +} + +export function formatBGPTopEventTypes(items) { + if (!Array.isArray(items) || items.length === 0) return "-"; + return items + .slice(0, 3) + .map((item) => `${item?.event_type || "-"} x${item?.count || 0}`) + .join(" / "); +} + +export function formatBGPCollectorCoverageHalo(markerData) { + const prefixes = Number( + markerData?.recent_24h_prefix_count || + markerData?.recent_7d_prefix_count || + markerData?.prefix_count || + 0, + ); + const observations = Number( + markerData?.recent_24h_observation_count || + markerData?.recent_7d_observation_count || + markerData?.observation_count || + 0, + ); + if (prefixes <= 0 && observations <= 0) return "静态观测站"; + return `近24h ${observations}条事件 / ${prefixes}个前缀`; +} + function buildCollectorFeatureData(feature) { const coordinates = feature?.geometry?.coordinates || []; const [longitude, latitude] = coordinates; @@ -242,6 +380,19 @@ function buildCollectorFeatureData(feature) { city: properties.city || "-", country: properties.country || "-", status: properties.status || "online", + observation_count: properties.observation_count || 0, + recent_24h_observation_count: properties.recent_24h_observation_count || 0, + recent_7d_observation_count: properties.recent_7d_observation_count || 0, + prefix_count: properties.prefix_count || 0, + recent_24h_prefix_count: properties.recent_24h_prefix_count || 0, + recent_7d_prefix_count: properties.recent_7d_prefix_count || 0, + origin_asn_count: properties.origin_asn_count || 0, + latest_observed_at: properties.latest_observed_at || null, + latest_event_type: properties.latest_event_type || null, + top_event_types: Array.isArray(properties.top_event_types) + ? properties.top_event_types + : [], + baseline_scope: properties.baseline_scope || { countries: [], cities: [] }, }; } @@ -381,6 +532,12 @@ function buildIncidentFeatureData(feature) { collectors: affectedCollectors, collector_count: affectedCollectors.length || 1, impacted_regions: affectedRegions, + related_cables: Array.isArray(properties.related_cables) + ? properties.related_cables + : [], + related_ixps: Array.isArray(properties.related_ixps) + ? properties.related_ixps + : [], confidence: properties.confidence ?? "-", summary: properties.summary || properties.title || "-", created_at: formatLocalDateTime(startedAt), @@ -404,6 +561,10 @@ function buildIncidentFeatureData(feature) { function clearMarkerArray(markers) { while (markers.length > 0) { const marker = markers.pop(); + while (marker.children.length > 0) { + const child = marker.children.pop(); + child.material?.dispose(); + } marker.material?.dispose(); bgpGroup.remove(marker); } @@ -459,10 +620,15 @@ function createArcLine(start, end, color) { } function createCollectorMarker(markerData) { + const activity = getCollectorActivityProfile(markerData); const sprite = new THREE.Sprite( - createSpriteMaterial({ - color: BGP_CONFIG.collectorColor, + new THREE.SpriteMaterial({ + map: getCollectorTexture(), + color: activity.color, + transparent: true, opacity: BGP_CONFIG.opacity.collector, + depthWrite: false, + depthTest: true, }), ); @@ -473,15 +639,46 @@ function createCollectorMarker(markerData) { ); sprite.position.copy(position); - sprite.scale.setScalar(BGP_CONFIG.collectorScale); + sprite.scale.set(BGP_CONFIG.collectorScale * 0.88 * activity.scaleBoost, BGP_CONFIG.collectorScale * 1.08 * activity.scaleBoost, 1); sprite.renderOrder = 3; sprite.visible = showBGP; + + const heatHalo = createOverlaySprite({ + color: activity.color, + opacity: 0.018, + scale: activity.haloScale * 0.78, + }); + heatHalo.renderOrder = 1; + sprite.add(heatHalo); + + const pulseHalo = createOverlaySprite({ + color: activity.color, + opacity: 0.008, + scale: activity.pulseHaloScale * 0.72, + }); + pulseHalo.renderOrder = 0; + sprite.add(pulseHalo); + + const coverageHalo = createOverlaySprite({ + color: BGP_CONFIG.regionColor, + opacity: 0.01, + scale: activity.coverageHaloScale * 0.7, + }); + coverageHalo.renderOrder = 0; + coverageHalo.scale.set(activity.coverageHaloScale * 0.82, activity.coverageHaloScale * 0.56, 1); + sprite.add(coverageHalo); + sprite.userData = { type: "bgp_collector", state: "normal", - baseScale: BGP_CONFIG.collectorScale, + baseScale: BGP_CONFIG.collectorScale * activity.scaleBoost, + baseColor: activity.color, pulseOffset: Math.random() * Math.PI * 2, anomaly_count: 0, + activity, + heatHalo, + pulseHalo, + coverageHalo, ...markerData, }; @@ -512,6 +709,7 @@ function createAnomalyMarker(markerData) { type: "bgp", state: "normal", baseScale, + baseColor: getSeverityColor(markerData.severity), pulseOffset: Math.random() * Math.PI * 2, ...markerData, }; @@ -692,23 +890,61 @@ export function updateBGPVisualState(lockedObjectType, lockedObject) { let scale = marker.userData.baseScale; let opacity = BGP_CONFIG.opacity.collector; + let haloOpacity = 0.018; + let pulseOpacity = 0.008; + let coverageOpacity = 0.01; + let markerColor = marker.userData.baseColor || BGP_CONFIG.collectorColor; if (isLocked) { scale *= 1.1 + 0.14 * pulse; opacity = BGP_CONFIG.opacity.collectorHover; + haloOpacity = 0.07; + pulseOpacity = 0.04; + coverageOpacity = 0.035; } else if (isHovered) { scale *= 1.08; opacity = BGP_CONFIG.opacity.collectorHover; + haloOpacity = 0.05; + pulseOpacity = 0.03; + coverageOpacity = 0.028; } else if (hasLockedLayer) { scale *= BGP_CONFIG.dimmedScale; - opacity = BGP_CONFIG.opacity.dimmed; + opacity = 0.12; + haloOpacity = 0.0; + pulseOpacity = 0.0; + coverageOpacity = 0.0; + markerColor = 0x7d8ca3; } else { scale *= 1 + 0.05 * pulse; } marker.scale.setScalar(scale); + marker.material.color.setHex(markerColor); marker.material.opacity = opacity; marker.visible = showBGP; + + if (marker.userData.heatHalo) { + marker.userData.heatHalo.material.opacity = haloOpacity; + marker.userData.heatHalo.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor); + marker.userData.heatHalo.scale.setScalar( + marker.userData.activity?.haloScale * 0.78 * (1 + pulse * 0.03), + ); + } + if (marker.userData.pulseHalo) { + marker.userData.pulseHalo.material.opacity = pulseOpacity; + marker.userData.pulseHalo.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor); + marker.userData.pulseHalo.scale.setScalar( + marker.userData.activity?.pulseHaloScale * 0.72 * (1 + pulse * 0.05), + ); + } + if (marker.userData.coverageHalo) { + marker.userData.coverageHalo.material.opacity = coverageOpacity; + marker.userData.coverageHalo.scale.set( + marker.userData.activity?.coverageHaloScale * 0.82 * (1 + pulse * 0.012), + marker.userData.activity?.coverageHaloScale * 0.56 * (1 + pulse * 0.012), + 1, + ); + } }); anomalyMarkers.forEach((marker) => { @@ -724,6 +960,7 @@ export function updateBGPVisualState(lockedObjectType, lockedObject) { let scale = marker.userData.baseScale; let opacity = BGP_CONFIG.opacity.normal; + let markerColor = marker.userData.baseColor || getSeverityColor(marker.userData.severity); if (isLocked || isLinkedCollectorLocked) { scale *= 1 + BGP_CONFIG.lockedPulseAmplitude * pulse; @@ -735,13 +972,15 @@ export function updateBGPVisualState(lockedObjectType, lockedObject) { opacity = BGP_CONFIG.opacity.hover; } else if (isOtherLocked) { scale *= BGP_CONFIG.dimmedScale; - opacity = BGP_CONFIG.opacity.dimmed; + opacity = 0.1; + markerColor = 0x7d8ca3; } else { scale *= 1 + BGP_CONFIG.normalPulseAmplitude * pulse; opacity = BGP_CONFIG.opacity.normal; } marker.scale.setScalar(scale); + marker.material.color.setHex(markerColor); marker.material.opacity = opacity; marker.visible = showBGP; }); @@ -915,6 +1154,63 @@ export function showBGPEventOverlay(marker, earth) { bgpOverlayGroup.visible = showBGP; } +export function showBGPCollectorCoverageOverlay(marker, earth) { + if (!marker?.userData || marker.userData.type !== "bgp_collector" || !earth) return; + + clearBGPEventOverlay(); + + const prefixCount = Number( + marker.userData.recent_24h_prefix_count || + marker.userData.recent_7d_prefix_count || + marker.userData.prefix_count || + 0, + ); + const observationCount = Number( + marker.userData.recent_24h_observation_count || + marker.userData.recent_7d_observation_count || + marker.userData.observation_count || + 0, + ); + const scaleBoost = Math.min(10, Math.log2(prefixCount + observationCount + 1) * 1.8); + const haloScale = BGP_CONFIG.regionScale * 0.7 + scaleBoost; + const pulseHaloScale = haloScale * 1.32; + + const halo = createOverlaySprite({ + color: BGP_CONFIG.regionColor, + opacity: 0.11, + scale: haloScale * 0.78, + }); + halo.position.copy( + latLonToVector3( + marker.userData.displayLatitude ?? marker.userData.latitude, + marker.userData.displayLongitude ?? marker.userData.longitude, + CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset - 0.15, + ), + ); + halo.renderOrder = 2; + bgpOverlayGroup.add(halo); + + const pulseHalo = createOverlaySprite({ + color: BGP_CONFIG.collectorColor, + opacity: 0.065, + scale: pulseHaloScale * 0.82, + }); + pulseHalo.position.copy(halo.position); + pulseHalo.renderOrder = 1; + bgpOverlayGroup.add(pulseHalo); + const innerRing = createOverlaySprite({ + color: BGP_CONFIG.collectorColor, + opacity: 0.12, + scale: Math.max(haloScale * 0.34, 5.5), + }); + innerRing.position.copy(halo.position); + innerRing.renderOrder = 3; + bgpOverlayGroup.add(innerRing); + + activeEventOverlay = [halo, pulseHalo, innerRing]; + bgpOverlayGroup.visible = showBGP; +} + export function clearBGPEventOverlay() { activeEventOverlay = null; clearGroup(bgpOverlayGroup); @@ -922,7 +1218,9 @@ export function clearBGPEventOverlay() { export function getBGPLegendItems() { return [ - { color: "#6db7ff", label: "观测站" }, + { color: "#6db7ff", label: "静态观测站" }, + { color: "#fbbf24", label: "中活跃观测站" }, + { color: "#ff5f57", label: "高活跃观测站" }, { color: "#8af5ff", label: "事件连线 / 枢纽" }, { color: "#2dd4bf", label: "影响区域" }, { color: "#ff4d4f", label: "严重事件" }, diff --git a/frontend/public/earth/js/cables.js b/frontend/public/earth/js/cables.js index 8276eb4c..03048f2f 100644 --- a/frontend/public/earth/js/cables.js +++ b/frontend/public/earth/js/cables.js @@ -496,10 +496,17 @@ export function getAllLandingPoints() { export function applyLandingPointVisualState(lockedCableName, dimAll = false) { const pulse = (Math.sin(Date.now() * 0.003) + 1) * 0.5; const brightness = 0.3; + const relatedNames = Array.isArray(lockedCableName) + ? lockedCableName.filter(Boolean) + : lockedCableName + ? [lockedCableName] + : []; landingPoints.forEach((lp) => { const isRelated = - !dimAll && lp.userData.cableNames?.includes(lockedCableName); + !dimAll && + Array.isArray(lp.userData.cableNames) && + lp.userData.cableNames.some((name) => relatedNames.includes(name)); if (isRelated) { lp.material.color.setHex(0xffaa00); diff --git a/frontend/public/earth/js/constants.js b/frontend/public/earth/js/constants.js index 558b4ae8..685017b7 100644 --- a/frontend/public/earth/js/constants.js +++ b/frontend/public/earth/js/constants.js @@ -110,9 +110,19 @@ export const BGP_CONFIG = { low: 0.94 }, collectorColor: 0x6db7ff, + collectorHeatColors: { + idle: 0x6db7ff, + low: 0x60a5fa, + medium: 0xfbbf24, + high: 0xfb923c, + hot: 0xff5f57 + }, eventHubColor: 0x8af5ff, linkColor: 0x54d2ff, - regionColor: 0x2dd4bf + regionColor: 0x2dd4bf, + collectorHaloScale: 11.5, + collectorPulseHaloScale: 16.5, + collectorCoverageHaloScale: 22.5 }; export const PREDICTED_ORBIT_CONFIG = { diff --git a/frontend/public/earth/js/info-card.js b/frontend/public/earth/js/info-card.js index 856a360d..43d89247 100644 --- a/frontend/public/earth/js/info-card.js +++ b/frontend/public/earth/js/info-card.js @@ -47,6 +47,8 @@ const CARD_CONFIG = { { key: 'collector', label: '主观测站' }, { key: 'observed_by', label: '观测范围' }, { key: 'impacted_scope', label: '影响区域' }, + { key: 'related_cables', label: '附近基础设施' }, + { key: 'related_satellites', label: '附近卫星' }, { key: 'location', label: '观测位置' }, { key: 'created_at', label: '事件时间' }, { key: 'summary', label: '摘要' } @@ -60,6 +62,17 @@ const CARD_CONFIG = { { key: 'collector', label: '采集器' }, { key: 'location', label: '观测位置' }, { key: 'anomaly_count', label: '当前事件数' }, + { key: 'observation_count', label: '观测事件数' }, + { key: 'recent_24h_observation_count', label: '近24h事件数' }, + { key: 'recent_7d_observation_count', label: '近7d事件数' }, + { key: 'prefix_count', label: '观测前缀数' }, + { key: 'origin_asn_count', label: '观测 ASN 数' }, + { key: 'top_event_types', label: '主要事件类型' }, + { key: 'coverage_halo', label: '日常活跃度' }, + { key: 'related_satellites', label: '附近卫星' }, + { key: 'latest_event_type', label: '最近事件类型' }, + { key: 'latest_observed_at', label: '最近活跃时间' }, + { key: 'baseline_scope', label: '日常覆盖范围' }, { key: 'status', label: '状态' } ] }, @@ -89,8 +102,36 @@ const CARD_CONFIG = { }; export function initInfoCard() { + const card = document.getElementById('info-card'); const content = document.getElementById('info-card-content'); - if (!content || content.dataset.copyBound === 'true') return; + if (!card || !content) return; + + if (card.dataset.interactionBound !== 'true') { + const stopEvent = (event) => { + event.stopPropagation(); + }; + + [ + 'mousemove', + 'mousedown', + 'mouseup', + 'click', + 'dblclick', + 'wheel', + 'pointerdown', + 'pointerup', + 'pointermove', + 'touchstart', + 'touchmove', + 'touchend', + ].forEach((eventName) => { + card.addEventListener(eventName, stopEvent, { passive: false }); + }); + + card.dataset.interactionBound = 'true'; + } + + if (content.dataset.copyBound === 'true') return; content.addEventListener('click', async (event) => { const label = event.target.closest('.info-card-label'); diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js index 21766302..3309c64e 100644 --- a/frontend/public/earth/js/main.js +++ b/frontend/public/earth/js/main.js @@ -59,6 +59,10 @@ import { getSatellitePositions, showPredictedOrbit, hidePredictedOrbit, + highlightRelatedSatellites, + clearRelatedSatelliteHighlights, + getRelatedSatelliteIndicesForRegions, + updateRelatedSatelliteHighlights, updateBreathingPhase, isSatelliteFrontFacing, setSatelliteCamera, @@ -88,10 +92,15 @@ import { formatBGPLocation, formatBGPObservedTime, formatBGPObservedBy, + formatBGPRelatedCables, formatBGPRouteChange, + formatBGPTopEventTypes, + formatBGPScope, + formatBGPCollectorCoverageHalo, formatBGPSeverityLabel, formatBGPStatusLabel, showBGPEventOverlay, + showBGPCollectorCoverageOverlay, } from "./bgp.js"; import { setupControls, @@ -165,6 +174,18 @@ const DRAG_ROTATION_FACTOR = 0.005; const DRAG_SMOOTHING_FACTOR = 0.18; const INERTIA_DAMPING = 0.92; const INERTIA_MIN_VELOCITY = 0.00008; +const HUD_INTERACTIVE_SELECTORS = [ + "#info-panel", + "#info-panel *", + "#right-toolbar-group", + "#right-toolbar-group *", + "#coordinates-display", + "#coordinates-display *", + "#legend", + "#legend *", + "#earth-stats", + "#earth-stats *", +]; function bindListener(target, eventName, handler, options) { if (!target) return; @@ -174,6 +195,12 @@ function bindListener(target, eventName, handler, options) { ); } +function isEventOnHud(event) { + const target = event?.target; + if (!(target instanceof Element)) return false; + return HUD_INTERACTIVE_SELECTORS.some((selector) => target.closest(selector)); +} + function disposeMaterial(material) { if (!material) return; if (Array.isArray(material)) { @@ -234,11 +261,18 @@ export function clearLockedObject() { clearAllCableStates(); clearCableSelection(); clearBGPSelection(); + clearRelatedSatelliteHighlights(); setSatelliteRingState(null, "none", null); clearRuntimeSelection(); setLegendItems("satellites", getSatelliteLegendItems()); } +export function clearLockedObjectAndInfo() { + clearLockedObject(); + hideInfoCard(); + hideTooltip(); +} + function isSameCable(cable1, cable2) { if (!cable1 || !cable2) return false; const id1 = cable1.userData?.cableId; @@ -281,6 +315,22 @@ function resetTransientBGPStates() { }); } +function clearTransientHoverState() { + resetTransientBGPStates(); + hoveredBGP = null; + + if (hoveredCable && !isSameCable(hoveredCable, lockedObject)) { + setCableState(hoveredCable.userData.cableId, CABLE_STATE.NORMAL); + } + hoveredCable = null; + + if (hoveredSatelliteIndex !== null && hoveredSatelliteIndex !== lockedSatelliteIndex) { + setSatelliteRingState(hoveredSatelliteIndex, "none", null); + } + hoveredSatellite = null; + hoveredSatelliteIndex = null; +} + function applyBGPHoverState(marker) { resetTransientBGPStates(); if (!marker) { @@ -419,6 +469,11 @@ function showBGPInfo(marker) { marker.userData.observed_by || formatBGPObservedBy(marker.userData.collectors), impacted_scope: formatBGPImpactedScope(impactedRegions), + related_cables: formatBGPRelatedCables(marker.userData.related_cables), + related_satellites: + marker.userData.related_satellite_count > 0 + ? `${marker.userData.related_satellite_count}颗附近卫星` + : "-", location: marker.userData.location || formatBGPLocation(marker.userData.city, marker.userData.country), @@ -433,10 +488,65 @@ function showBGPCollectorInfo(marker) { collector: marker.userData.collector, location: formatBGPLocation(marker.userData.city, marker.userData.country), anomaly_count: marker.userData.anomaly_count ?? 0, + observation_count: marker.userData.observation_count ?? 0, + recent_24h_observation_count: marker.userData.recent_24h_observation_count ?? 0, + recent_7d_observation_count: marker.userData.recent_7d_observation_count ?? 0, + prefix_count: marker.userData.prefix_count ?? 0, + origin_asn_count: marker.userData.origin_asn_count ?? 0, + top_event_types: formatBGPTopEventTypes(marker.userData.top_event_types), + coverage_halo: formatBGPCollectorCoverageHalo(marker.userData), + related_satellites: + marker.userData.related_satellite_count > 0 + ? `${marker.userData.related_satellite_count}颗附近卫星` + : "-", + latest_event_type: marker.userData.latest_event_type || "-", + latest_observed_at: formatBGPObservedTime(marker.userData.latest_observed_at), + baseline_scope: formatBGPScope(marker.userData.baseline_scope), status: formatBGPCollectorStatus(marker.userData.status || "online"), }); } +function getBGPRelatedCableNames(marker) { + const items = Array.isArray(marker?.userData?.related_cables) + ? marker.userData.related_cables + : []; + + const names = []; + items.forEach((item) => { + const cableNames = Array.isArray(item?.cable_names) ? item.cable_names : []; + cableNames.forEach((name) => { + if (name && !names.includes(name)) { + names.push(name); + } + }); + }); + return names; +} + +function getBGPRelatedRegions(marker) { + if (Array.isArray(marker?.userData?.impacted_regions) && marker.userData.impacted_regions.length > 0) { + return marker.userData.impacted_regions; + } + + if ( + marker?.userData?.type === "bgp_collector" && + typeof marker.userData.latitude === "number" && + typeof marker.userData.longitude === "number" + ) { + return [ + { + collector: marker.userData.collector, + city: marker.userData.city, + country: marker.userData.country, + latitude: marker.userData.latitude, + longitude: marker.userData.longitude, + }, + ]; + } + + return []; +} + function applyCableVisualState() { const allCables = getCableLines(); const pulse = (Math.sin(Date.now() * CABLE_CONFIG.pulseSpeed) + 1) * 0.5; @@ -462,7 +572,8 @@ function applyCableVisualState() { if ( (lockedObjectType === "cable" && lockedObject) || (lockedObjectType === "satellite" && lockedSatellite) || - (lockedObjectType === "bgp" && lockedObject) + (lockedObjectType === "bgp" && lockedObject) || + (lockedObjectType === "bgp_collector" && lockedObject) ) { cable.material.opacity = CABLE_CONFIG.otherOpacity; const origColor = cable.userData.originalColor; @@ -972,6 +1083,26 @@ function onMouseMove(event) { const earth = getEarth(); if (!earth) return; + if (isEventOnHud(event)) { + clearTransientHoverState(); + + if (lockedObjectType === "bgp" && lockedObject) { + applyBGPHoverState(lockedObject); + showBGPInfo(lockedObject); + } else if (lockedObjectType === "bgp_collector" && lockedObject) { + applyBGPHoverState(lockedObject); + showBGPCollectorInfo(lockedObject); + } else if (lockedObjectType === "cable" && lockedObject) { + showCableInfo(lockedObject); + } else if (lockedObjectType === "satellite" && lockedSatellite) { + showSatelliteInfo(lockedSatellite.properties); + } else { + hideInfoCard(); + } + hideTooltip(); + return; + } + if (isDragging) { if (Date.now() - dragStartTime > 500) { isLongDrag = true; @@ -1029,12 +1160,8 @@ function onMouseMove(event) { bgpCollectorIntersects, ); - if ( - hoveredBGP && - !isSameBGPMarker(hoveredBGP, hoveredBGPMarker) - ) { - resetTransientBGPStates(); - hoveredBGP = null; + if (hoveredBGP && !isSameBGPMarker(hoveredBGP, hoveredBGPMarker)) { + clearTransientHoverState(); } if ( @@ -1042,20 +1169,14 @@ function onMouseMove(event) { (!cableIntersects.length || !isSameCable(cableIntersects[0]?.object, hoveredCable)) ) { - if (!isSameCable(hoveredCable, lockedObject)) { - setCableState(hoveredCable.userData.cableId, CABLE_STATE.NORMAL); - } - hoveredCable = null; + clearTransientHoverState(); } if ( hoveredSatelliteIndex !== null && hoveredSatelliteIndex !== hoveredSatIndexFromIntersect ) { - if (hoveredSatelliteIndex !== lockedSatelliteIndex) { - setSatelliteRingState(hoveredSatelliteIndex, "none", null); - } - hoveredSatelliteIndex = null; + clearTransientHoverState(); } if ( @@ -1142,6 +1263,10 @@ function onMouseMove(event) { } function onMouseDown(event) { + if (isEventOnHud(event)) { + return; + } + const earth = getEarth(); isDragging = true; dragStartTime = Date.now(); @@ -1170,6 +1295,7 @@ function onMouseLeave() { function onClick(event) { const earth = getEarth(); if (!earth) return; + if (isEventOnHud(event)) return; updatePointerFromEvent(event); @@ -1210,6 +1336,14 @@ function onClick(event) { lastBGPClickPos = { x: event.clientX, y: event.clientY }; setAutoRotate(false); showBGPEventOverlay(clickedMarker, earth); + { + const relatedSatelliteIndices = getRelatedSatelliteIndicesForRegions( + getBGPRelatedRegions(clickedMarker), + { limit: 6, maxAngleDeg: 20 }, + ); + clickedMarker.userData.related_satellite_count = relatedSatelliteIndices.length; + highlightRelatedSatellites(relatedSatelliteIndices, "#7dd3fc"); + } showBGPInfo(clickedMarker); showStatusMessage( `已选择BGP事件: ${clickedMarker.userData.collector}`, @@ -1231,6 +1365,15 @@ function onClick(event) { lastBGPClickType = "bgp_collector"; lastBGPClickPos = { x: event.clientX, y: event.clientY }; setAutoRotate(false); + showBGPCollectorCoverageOverlay(clickedMarker, earth); + { + const relatedSatelliteIndices = getRelatedSatelliteIndicesForRegions( + getBGPRelatedRegions(clickedMarker), + { limit: 4, maxAngleDeg: 18 }, + ); + clickedMarker.userData.related_satellite_count = relatedSatelliteIndices.length; + highlightRelatedSatellites(relatedSatelliteIndices, "#93c5fd"); + } showBGPCollectorInfo(clickedMarker); showStatusMessage( `已选择观测站: ${clickedMarker.userData.collector}`, @@ -1374,6 +1517,21 @@ function animate() { ) { applyLandingPointVisualState(null, true); } else if (lockedObjectType === "bgp" && lockedObject) { + const relatedCableNames = getBGPRelatedCableNames(lockedObject); + clearAllCableStates(); + relatedCableNames.forEach((name) => { + getCableLines().forEach((cable) => { + if (cable.userData?.name === name) { + setCableState(cable.userData.cableId, CABLE_STATE.HOVERED); + } + }); + }); + applyLandingPointVisualState( + relatedCableNames.length > 0 ? relatedCableNames : null, + relatedCableNames.length === 0, + ); + } else if (lockedObjectType === "bgp_collector" && lockedObject) { + clearAllCableStates(); applyLandingPointVisualState(null, true); } else { resetLandingPointVisualState(); @@ -1381,6 +1539,7 @@ function animate() { updateSatellitePositions(deltaTime); updateBreathingPhase(deltaTime); + updateRelatedSatelliteHighlights(); const satPositions = getSatellitePositions(); if ( diff --git a/frontend/public/earth/js/satellites.js b/frontend/public/earth/js/satellites.js index 3435565b..c237210e 100644 --- a/frontend/public/earth/js/satellites.js +++ b/frontend/public/earth/js/satellites.js @@ -3,6 +3,7 @@ import * as THREE from "three"; import { twoline2satrec, propagate } from "satellite.js"; import { CONFIG, SATELLITE_CONFIG } from "./constants.js"; +import { latLonToVector3 } from "./utils.js"; let satellitePoints = null; let satelliteTrails = null; @@ -15,6 +16,7 @@ let hoverRingSprite = null; let lockedRingSprite = null; let lockedDotSprite = null; let predictedOrbitLine = null; +let relatedSatelliteSprites = []; let earthObjRef = null; let sceneRef = null; let cameraRef = null; @@ -759,6 +761,25 @@ function createRingSprite(position, isLocked = false) { return sprite; } +function createRelatedSatelliteSprite(position, color = "#7dd3fc") { + if (!earthObjRef) return null; + + const ringTexture = createRingTexture(7, 11, color); + const spriteMaterial = new THREE.SpriteMaterial({ + map: ringTexture, + transparent: true, + opacity: 0.55, + depthTest: false, + sizeAttenuation: false, + }); + + const sprite = new THREE.Sprite(spriteMaterial); + sprite.position.copy(position); + sprite.scale.set(SATELLITE_CONFIG.ringSize * 0.8, SATELLITE_CONFIG.ringSize * 0.8, 1); + earthObjRef.add(sprite); + return sprite; +} + export function showHoverRing(position, isLocked = false) { if (!earthObjRef || !position) return null; @@ -866,6 +887,82 @@ export function setSatelliteRingState(index, state, position) { } } +export function clearRelatedSatelliteHighlights() { + relatedSatelliteSprites.forEach((item) => { + if (item.sprite) { + disposeObject3D(item.sprite); + } + }); + relatedSatelliteSprites = []; +} + +export function highlightRelatedSatellites(indices, color = "#7dd3fc") { + clearRelatedSatelliteHighlights(); + if (!Array.isArray(indices) || indices.length === 0) return; + + indices.forEach((index) => { + const pos = satellitePositions?.[index]?.current; + if (!pos) return; + const sprite = createRelatedSatelliteSprite(pos, color); + if (!sprite) return; + relatedSatelliteSprites.push({ index, sprite, color }); + }); +} + +export function updateRelatedSatelliteHighlights() { + if (relatedSatelliteSprites.length === 0) return; + relatedSatelliteSprites = relatedSatelliteSprites.filter((item) => { + const pos = satellitePositions?.[item.index]?.current; + if (!pos || !item.sprite) return false; + item.sprite.position.copy(pos); + return true; + }); +} + +export function getRelatedSatelliteIndicesForRegions( + regions, + { limit = 6, maxAngleDeg = 22 } = {}, +) { + if (!Array.isArray(regions) || regions.length === 0 || satellitePositions.length === 0) { + return []; + } + + const regionVectors = regions + .filter( + (region) => + typeof region?.latitude === "number" && + typeof region?.longitude === "number", + ) + .map((region) => + latLonToVector3(region.latitude, region.longitude, CONFIG.earthRadius + 1) + .clone() + .normalize(), + ); + + if (regionVectors.length === 0) return []; + + const threshold = Math.cos((maxAngleDeg * Math.PI) / 180); + const ranked = []; + + satellitePositions.forEach((item, index) => { + const current = item?.current; + if (!current || current.lengthSq() === 0) return; + const satVector = current.clone().normalize(); + let bestDot = -1; + regionVectors.forEach((regionVector) => { + bestDot = Math.max(bestDot, satVector.dot(regionVector)); + }); + if (bestDot >= threshold) { + ranked.push({ index, score: bestDot }); + } + }); + + return ranked + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map((item) => item.index); +} + export function initSatelliteScene(scene, earth) { sceneRef = scene; earthObjRef = earth; @@ -1000,6 +1097,7 @@ export function clearSatelliteData() { hideHoverRings(); hideLockedRing(); hidePredictedOrbit(); + clearRelatedSatelliteHighlights(); } export function resetSatelliteState() { diff --git a/frontend/src/pages/BGP/BGP.tsx b/frontend/src/pages/BGP/BGP.tsx index 7eb30d28..02458b06 100644 --- a/frontend/src/pages/BGP/BGP.tsx +++ b/frontend/src/pages/BGP/BGP.tsx @@ -30,6 +30,26 @@ interface BGPEvent { observed_at: string | null } +interface BGPCollectorCoverage { + collector: string + city?: string | null + country?: string | null + observation_count: number + recent_24h_observation_count: number + recent_7d_observation_count: number + prefix_count: number + recent_24h_prefix_count: number + recent_7d_prefix_count: number + origin_asn_count: number + peer_asn_count: number + latest_observed_at: string | null + latest_event_type: string | null + baseline_scope: { + countries: string[] + cities: string[] + } +} + interface BGPIncident { id: number incident_type: string @@ -42,6 +62,13 @@ interface BGPIncident { affected_asns: number[] affected_collectors: string[] affected_regions: Array<{ country?: string; city?: string }> + related_cables: Array<{ + landing_point?: string + city?: string + country?: string + distance_km?: number + cable_names?: string[] + }> created_at: string | null started_at: string | null } @@ -60,6 +87,15 @@ interface EventSummary { by_type: Record } +interface CollectorSummary { + total: number + active_collectors: number + observed_prefixes: number + observed_origins: number + recent_24h_events: number + recent_7d_events: number +} + function severityColor(severity: string) { if (severity === 'critical') return 'red' if (severity === 'high') return 'orange' @@ -72,21 +108,25 @@ function BGP() { const [incidents, setIncidents] = useState([]) const [anomalies, setAnomalies] = useState([]) const [events, setEvents] = useState([]) + const [collectors, setCollectors] = useState([]) const [incidentSummary, setIncidentSummary] = useState(null) const [anomalySummary, setAnomalySummary] = useState(null) const [eventSummary, setEventSummary] = useState(null) + const [collectorSummary, setCollectorSummary] = useState(null) useEffect(() => { const load = async () => { setLoading(true) try { - const [incidentsRes, incidentSummaryRes, anomaliesRes, anomalySummaryRes, eventsRes, eventSummaryRes] = await Promise.all([ + const [incidentsRes, incidentSummaryRes, anomaliesRes, anomalySummaryRes, eventsRes, eventSummaryRes, collectorsRes, collectorSummaryRes] = await Promise.all([ axios.get('/api/v1/bgp/incidents', { params: { page_size: 50 } }), axios.get('/api/v1/bgp/incidents/summary'), axios.get('/api/v1/bgp/anomalies', { params: { page_size: 100 } }), axios.get('/api/v1/bgp/anomalies/summary'), axios.get('/api/v1/bgp/events', { params: { page_size: 20 } }), axios.get('/api/v1/bgp/events/summary'), + axios.get('/api/v1/bgp/collectors'), + axios.get('/api/v1/bgp/collectors/summary'), ]) setIncidents(incidentsRes.data.data || []) setIncidentSummary(incidentSummaryRes.data) @@ -94,6 +134,8 @@ function BGP() { setAnomalySummary(anomalySummaryRes.data) setEvents(eventsRes.data.data || []) setEventSummary(eventSummaryRes.data) + setCollectors(collectorsRes.data.data || []) + setCollectorSummary(collectorSummaryRes.data) } finally { setLoading(false) } @@ -119,17 +161,17 @@ function BGP() { - + - + - + @@ -152,6 +194,64 @@ function BGP() { + + + rowKey="collector" + loading={loading} + dataSource={collectors} + pagination={{ pageSize: 8 }} + columns={[ + { + title: '观测站', + dataIndex: 'collector', + width: 120, + }, + { + title: '位置', + width: 180, + render: (_, record) => [record.city, record.country].filter(Boolean).join(', ') || '-', + }, + { + title: '近24h事件数', + dataIndex: 'recent_24h_observation_count', + width: 120, + }, + { + title: '近7d事件数', + dataIndex: 'recent_7d_observation_count', + width: 120, + }, + { + title: '前缀数', + dataIndex: 'prefix_count', + width: 120, + }, + { + title: 'Origin ASN 数', + dataIndex: 'origin_asn_count', + width: 140, + }, + { + title: '最近事件', + width: 220, + render: (_, record) => { + const time = formatDateTimeZhCN(record.latest_observed_at) + return record.latest_event_type ? `${record.latest_event_type} @ ${time}` : time + }, + }, + { + title: '日常覆盖范围', + dataIndex: 'baseline_scope', + render: (value: BGPCollectorCoverage['baseline_scope']) => { + const cities = value?.cities?.slice(0, 3).join(' / ') || '' + const countries = value?.countries?.slice(0, 3).join(' / ') || '' + return cities && countries ? `${cities} | ${countries}` : cities || countries || '-' + }, + }, + ]} + /> + + rowKey="id" @@ -200,6 +300,23 @@ function BGP() { .join(' / ') }, }, + { + title: '附近基础设施', + dataIndex: 'related_cables', + width: 260, + render: (value: BGPIncident['related_cables']) => { + if (!value || value.length === 0) return '-' + return value + .slice(0, 2) + .map((item) => { + const landing = item.landing_point || [item.city, item.country].filter(Boolean).join(', ') + const cable = item.cable_names && item.cable_names.length > 0 ? item.cable_names[0] : '附近登陆点' + const distance = item.distance_km !== undefined ? ` ${item.distance_km}km` : '' + return `${landing} (${cable}${distance})` + }) + .join(' / ') + }, + }, { title: '置信度', dataIndex: 'confidence', diff --git a/pyproject.toml b/pyproject.toml index 360e280a..32404991 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.21.9" +version = "0.22.0" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [