feat: expand bgp observability surfaces
This commit is contained in:
@@ -11,6 +11,7 @@ from app.models.bgp_anomaly import BGPAnomaly
|
|||||||
from app.models.bgp_incident import BGPIncident
|
from app.models.bgp_incident import BGPIncident
|
||||||
from app.models.bgp_observation import BGPObservation
|
from app.models.bgp_observation import BGPObservation
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.services.bgp_collectors import build_bgp_collector_coverage
|
||||||
|
|
||||||
router = APIRouter()
|
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}")
|
@router.get("/events/{event_id}")
|
||||||
async def get_bgp_event(
|
async def get_bgp_event(
|
||||||
event_id: int,
|
event_id: int,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from app.db.session import get_db
|
|||||||
from app.models.bgp_anomaly import BGPAnomaly
|
from app.models.bgp_anomaly import BGPAnomaly
|
||||||
from app.models.bgp_incident import BGPIncident
|
from app.models.bgp_incident import BGPIncident
|
||||||
from app.models.collected_data import CollectedData
|
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.cable_graph import build_graph_from_data, CableGraph
|
||||||
from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
|
from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
|
||||||
|
|
||||||
@@ -417,10 +418,14 @@ def convert_bgp_anomalies_to_geojson(records: List[BGPAnomaly]) -> Dict[str, Any
|
|||||||
return {"type": "FeatureCollection", "features": features}
|
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 = []
|
features = []
|
||||||
|
coverage_by_collector = coverage_by_collector or {}
|
||||||
|
|
||||||
for collector, location in sorted(RIPE_RIS_COLLECTOR_COORDS.items()):
|
for collector, location in sorted(RIPE_RIS_COLLECTOR_COORDS.items()):
|
||||||
|
coverage = coverage_by_collector.get(collector, {})
|
||||||
features.append(
|
features.append(
|
||||||
{
|
{
|
||||||
"type": "Feature",
|
"type": "Feature",
|
||||||
@@ -430,9 +435,27 @@ def convert_bgp_collectors_to_geojson() -> Dict[str, Any]:
|
|||||||
},
|
},
|
||||||
"properties": {
|
"properties": {
|
||||||
"collector": collector,
|
"collector": collector,
|
||||||
"city": location.get("city"),
|
"city": coverage.get("city") or location.get("city"),
|
||||||
"country": location.get("country"),
|
"country": coverage.get("country") or location.get("country"),
|
||||||
"status": "online",
|
"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")
|
@router.get("/geo/bgp-collectors")
|
||||||
async def get_bgp_collectors_geojson():
|
async def get_bgp_collectors_geojson(db: AsyncSession = Depends(get_db)):
|
||||||
geojson = convert_bgp_collectors_to_geojson()
|
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", []))}
|
return {**geojson, "count": len(geojson.get("features", []))}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
162
backend/app/services/bgp_collectors.py
Normal file
162
backend/app/services/bgp_collectors.py
Normal file
@@ -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
|
||||||
@@ -3,12 +3,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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_anomaly import BGPAnomaly
|
||||||
from app.models.bgp_incident import BGPIncident
|
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:
|
def _severity_rank(value: str | None) -> int:
|
||||||
@@ -44,6 +48,150 @@ def _collector_regions_from_anomaly(anomaly: BGPAnomaly) -> list[dict]:
|
|||||||
return collected
|
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(
|
async def create_bgp_incidents_for_anomalies(
|
||||||
db: AsyncSession,
|
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"{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)."
|
f"affecting {len(prefixes) or 1} prefix scope(s) across {len(collectors)} collector(s)."
|
||||||
)
|
)
|
||||||
|
related_infrastructure = await infer_related_infrastructure(db, regions)
|
||||||
|
|
||||||
db.add(
|
db.add(
|
||||||
BGPIncident(
|
BGPIncident(
|
||||||
@@ -138,8 +287,8 @@ async def create_bgp_incidents_for_anomalies(
|
|||||||
affected_asns=asns,
|
affected_asns=asns,
|
||||||
affected_collectors=collectors,
|
affected_collectors=collectors,
|
||||||
affected_regions=regions,
|
affected_regions=regions,
|
||||||
related_cables=[],
|
related_cables=related_infrastructure["related_cables"],
|
||||||
related_ixps=[],
|
related_ixps=related_infrastructure["related_ixps"],
|
||||||
evidence_refs=evidence_refs,
|
evidence_refs=evidence_refs,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ from app.services.collectors.bgp_common import (
|
|||||||
save_bgp_observations_for_batch,
|
save_bgp_observations_for_batch,
|
||||||
)
|
)
|
||||||
from app.services.bgp_enrichment import enrich_bgp_events_for_batch, extract_bgp_network_fields
|
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.bgp_anomaly import BGPAnomaly
|
||||||
from app.models.collected_data import CollectedData
|
from app.models.collected_data import CollectedData
|
||||||
from app.models.bgp_incident import BGPIncident
|
from app.models.bgp_incident import BGPIncident
|
||||||
@@ -331,6 +335,10 @@ async def test_create_bgp_incidents_for_anomalies_aggregates_regions_and_collect
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.bgp_incidents.infer_related_infrastructure",
|
||||||
|
new=AsyncMock(return_value={"related_cables": [], "related_ixps": []}),
|
||||||
|
):
|
||||||
created = await create_bgp_incidents_for_anomalies(
|
created = await create_bgp_incidents_for_anomalies(
|
||||||
db,
|
db,
|
||||||
source="ris_live_bgp",
|
source="ris_live_bgp",
|
||||||
@@ -348,6 +356,91 @@ async def test_create_bgp_incidents_for_anomalies_aggregates_regions_and_collect
|
|||||||
assert incident.affected_regions[0]["city"] == "Amsterdam"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_save_bgp_observations_for_batch_adds_rows():
|
async def test_save_bgp_observations_for_batch_adds_rows():
|
||||||
db = _FakeAsyncSession([])
|
db = _FakeAsyncSession([])
|
||||||
@@ -643,3 +736,41 @@ async def test_bgp_event_summary_api_returns_aggregates():
|
|||||||
assert payload["prefix_count"] == 2
|
assert payload["prefix_count"] == 2
|
||||||
assert payload["by_type"]["announcement"] == 1
|
assert payload["by_type"]["announcement"] == 1
|
||||||
assert payload["by_type"]["withdrawal"] == 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
|
||||||
|
|||||||
@@ -300,3 +300,41 @@ Released: 2026-03-25
|
|||||||
- Fixed several collected-data field mapping issues.
|
- Fixed several collected-data field mapping issues.
|
||||||
- Fixed frontend table layout inconsistencies across multiple admin pages.
|
- Fixed frontend table layout inconsistencies across multiple admin pages.
|
||||||
- Fixed TOP500 parsing and related metadata alignment issues.
|
- 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.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "planet-frontend",
|
"name": "planet-frontend",
|
||||||
"version": "0.21.9",
|
"version": "0.22.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^5.2.6",
|
"@ant-design/icons": "^5.2.6",
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ let showBGP = true;
|
|||||||
let totalAnomalyCount = 0;
|
let totalAnomalyCount = 0;
|
||||||
let totalIncidentCount = 0;
|
let totalIncidentCount = 0;
|
||||||
let textureCache = null;
|
let textureCache = null;
|
||||||
|
let collectorTextureCache = null;
|
||||||
let activeEventOverlay = null;
|
let activeEventOverlay = null;
|
||||||
const relativeTimeFormatter = new Intl.RelativeTimeFormat("zh-CN", {
|
const relativeTimeFormatter = new Intl.RelativeTimeFormat("zh-CN", {
|
||||||
numeric: "auto",
|
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() {
|
function getMarkerTexture() {
|
||||||
if (textureCache) return textureCache;
|
if (textureCache) return textureCache;
|
||||||
@@ -46,6 +48,42 @@ function getMarkerTexture() {
|
|||||||
return textureCache;
|
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) {
|
function normalizeSeverity(severity) {
|
||||||
const value = String(severity || "").trim().toLowerCase();
|
const value = String(severity || "").trim().toLowerCase();
|
||||||
|
|
||||||
@@ -69,6 +107,50 @@ function getSeverityScale(severity) {
|
|||||||
return BGP_CONFIG.severityScales[normalizeSeverity(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) {
|
function formatLocalDateTime(value) {
|
||||||
if (!value) return "-";
|
if (!value) return "-";
|
||||||
|
|
||||||
@@ -222,6 +304,62 @@ export function formatBGPImpactedScope(regions) {
|
|||||||
return `${labels.slice(0, 3).join(" / ")} 等${labels.length}地`;
|
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) {
|
function buildCollectorFeatureData(feature) {
|
||||||
const coordinates = feature?.geometry?.coordinates || [];
|
const coordinates = feature?.geometry?.coordinates || [];
|
||||||
const [longitude, latitude] = coordinates;
|
const [longitude, latitude] = coordinates;
|
||||||
@@ -242,6 +380,19 @@ function buildCollectorFeatureData(feature) {
|
|||||||
city: properties.city || "-",
|
city: properties.city || "-",
|
||||||
country: properties.country || "-",
|
country: properties.country || "-",
|
||||||
status: properties.status || "online",
|
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,
|
collectors: affectedCollectors,
|
||||||
collector_count: affectedCollectors.length || 1,
|
collector_count: affectedCollectors.length || 1,
|
||||||
impacted_regions: affectedRegions,
|
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 ?? "-",
|
confidence: properties.confidence ?? "-",
|
||||||
summary: properties.summary || properties.title || "-",
|
summary: properties.summary || properties.title || "-",
|
||||||
created_at: formatLocalDateTime(startedAt),
|
created_at: formatLocalDateTime(startedAt),
|
||||||
@@ -404,6 +561,10 @@ function buildIncidentFeatureData(feature) {
|
|||||||
function clearMarkerArray(markers) {
|
function clearMarkerArray(markers) {
|
||||||
while (markers.length > 0) {
|
while (markers.length > 0) {
|
||||||
const marker = markers.pop();
|
const marker = markers.pop();
|
||||||
|
while (marker.children.length > 0) {
|
||||||
|
const child = marker.children.pop();
|
||||||
|
child.material?.dispose();
|
||||||
|
}
|
||||||
marker.material?.dispose();
|
marker.material?.dispose();
|
||||||
bgpGroup.remove(marker);
|
bgpGroup.remove(marker);
|
||||||
}
|
}
|
||||||
@@ -459,10 +620,15 @@ function createArcLine(start, end, color) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createCollectorMarker(markerData) {
|
function createCollectorMarker(markerData) {
|
||||||
|
const activity = getCollectorActivityProfile(markerData);
|
||||||
const sprite = new THREE.Sprite(
|
const sprite = new THREE.Sprite(
|
||||||
createSpriteMaterial({
|
new THREE.SpriteMaterial({
|
||||||
color: BGP_CONFIG.collectorColor,
|
map: getCollectorTexture(),
|
||||||
|
color: activity.color,
|
||||||
|
transparent: true,
|
||||||
opacity: BGP_CONFIG.opacity.collector,
|
opacity: BGP_CONFIG.opacity.collector,
|
||||||
|
depthWrite: false,
|
||||||
|
depthTest: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -473,15 +639,46 @@ function createCollectorMarker(markerData) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
sprite.position.copy(position);
|
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.renderOrder = 3;
|
||||||
sprite.visible = showBGP;
|
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 = {
|
sprite.userData = {
|
||||||
type: "bgp_collector",
|
type: "bgp_collector",
|
||||||
state: "normal",
|
state: "normal",
|
||||||
baseScale: BGP_CONFIG.collectorScale,
|
baseScale: BGP_CONFIG.collectorScale * activity.scaleBoost,
|
||||||
|
baseColor: activity.color,
|
||||||
pulseOffset: Math.random() * Math.PI * 2,
|
pulseOffset: Math.random() * Math.PI * 2,
|
||||||
anomaly_count: 0,
|
anomaly_count: 0,
|
||||||
|
activity,
|
||||||
|
heatHalo,
|
||||||
|
pulseHalo,
|
||||||
|
coverageHalo,
|
||||||
...markerData,
|
...markerData,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -512,6 +709,7 @@ function createAnomalyMarker(markerData) {
|
|||||||
type: "bgp",
|
type: "bgp",
|
||||||
state: "normal",
|
state: "normal",
|
||||||
baseScale,
|
baseScale,
|
||||||
|
baseColor: getSeverityColor(markerData.severity),
|
||||||
pulseOffset: Math.random() * Math.PI * 2,
|
pulseOffset: Math.random() * Math.PI * 2,
|
||||||
...markerData,
|
...markerData,
|
||||||
};
|
};
|
||||||
@@ -692,23 +890,61 @@ export function updateBGPVisualState(lockedObjectType, lockedObject) {
|
|||||||
|
|
||||||
let scale = marker.userData.baseScale;
|
let scale = marker.userData.baseScale;
|
||||||
let opacity = BGP_CONFIG.opacity.collector;
|
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) {
|
if (isLocked) {
|
||||||
scale *= 1.1 + 0.14 * pulse;
|
scale *= 1.1 + 0.14 * pulse;
|
||||||
opacity = BGP_CONFIG.opacity.collectorHover;
|
opacity = BGP_CONFIG.opacity.collectorHover;
|
||||||
|
haloOpacity = 0.07;
|
||||||
|
pulseOpacity = 0.04;
|
||||||
|
coverageOpacity = 0.035;
|
||||||
} else if (isHovered) {
|
} else if (isHovered) {
|
||||||
scale *= 1.08;
|
scale *= 1.08;
|
||||||
opacity = BGP_CONFIG.opacity.collectorHover;
|
opacity = BGP_CONFIG.opacity.collectorHover;
|
||||||
|
haloOpacity = 0.05;
|
||||||
|
pulseOpacity = 0.03;
|
||||||
|
coverageOpacity = 0.028;
|
||||||
} else if (hasLockedLayer) {
|
} else if (hasLockedLayer) {
|
||||||
scale *= BGP_CONFIG.dimmedScale;
|
scale *= BGP_CONFIG.dimmedScale;
|
||||||
opacity = BGP_CONFIG.opacity.dimmed;
|
opacity = 0.12;
|
||||||
|
haloOpacity = 0.0;
|
||||||
|
pulseOpacity = 0.0;
|
||||||
|
coverageOpacity = 0.0;
|
||||||
|
markerColor = 0x7d8ca3;
|
||||||
} else {
|
} else {
|
||||||
scale *= 1 + 0.05 * pulse;
|
scale *= 1 + 0.05 * pulse;
|
||||||
}
|
}
|
||||||
|
|
||||||
marker.scale.setScalar(scale);
|
marker.scale.setScalar(scale);
|
||||||
|
marker.material.color.setHex(markerColor);
|
||||||
marker.material.opacity = opacity;
|
marker.material.opacity = opacity;
|
||||||
marker.visible = showBGP;
|
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) => {
|
anomalyMarkers.forEach((marker) => {
|
||||||
@@ -724,6 +960,7 @@ export function updateBGPVisualState(lockedObjectType, lockedObject) {
|
|||||||
|
|
||||||
let scale = marker.userData.baseScale;
|
let scale = marker.userData.baseScale;
|
||||||
let opacity = BGP_CONFIG.opacity.normal;
|
let opacity = BGP_CONFIG.opacity.normal;
|
||||||
|
let markerColor = marker.userData.baseColor || getSeverityColor(marker.userData.severity);
|
||||||
|
|
||||||
if (isLocked || isLinkedCollectorLocked) {
|
if (isLocked || isLinkedCollectorLocked) {
|
||||||
scale *= 1 + BGP_CONFIG.lockedPulseAmplitude * pulse;
|
scale *= 1 + BGP_CONFIG.lockedPulseAmplitude * pulse;
|
||||||
@@ -735,13 +972,15 @@ export function updateBGPVisualState(lockedObjectType, lockedObject) {
|
|||||||
opacity = BGP_CONFIG.opacity.hover;
|
opacity = BGP_CONFIG.opacity.hover;
|
||||||
} else if (isOtherLocked) {
|
} else if (isOtherLocked) {
|
||||||
scale *= BGP_CONFIG.dimmedScale;
|
scale *= BGP_CONFIG.dimmedScale;
|
||||||
opacity = BGP_CONFIG.opacity.dimmed;
|
opacity = 0.1;
|
||||||
|
markerColor = 0x7d8ca3;
|
||||||
} else {
|
} else {
|
||||||
scale *= 1 + BGP_CONFIG.normalPulseAmplitude * pulse;
|
scale *= 1 + BGP_CONFIG.normalPulseAmplitude * pulse;
|
||||||
opacity = BGP_CONFIG.opacity.normal;
|
opacity = BGP_CONFIG.opacity.normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
marker.scale.setScalar(scale);
|
marker.scale.setScalar(scale);
|
||||||
|
marker.material.color.setHex(markerColor);
|
||||||
marker.material.opacity = opacity;
|
marker.material.opacity = opacity;
|
||||||
marker.visible = showBGP;
|
marker.visible = showBGP;
|
||||||
});
|
});
|
||||||
@@ -915,6 +1154,63 @@ export function showBGPEventOverlay(marker, earth) {
|
|||||||
bgpOverlayGroup.visible = showBGP;
|
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() {
|
export function clearBGPEventOverlay() {
|
||||||
activeEventOverlay = null;
|
activeEventOverlay = null;
|
||||||
clearGroup(bgpOverlayGroup);
|
clearGroup(bgpOverlayGroup);
|
||||||
@@ -922,7 +1218,9 @@ export function clearBGPEventOverlay() {
|
|||||||
|
|
||||||
export function getBGPLegendItems() {
|
export function getBGPLegendItems() {
|
||||||
return [
|
return [
|
||||||
{ color: "#6db7ff", label: "观测站" },
|
{ color: "#6db7ff", label: "静态观测站" },
|
||||||
|
{ color: "#fbbf24", label: "中活跃观测站" },
|
||||||
|
{ color: "#ff5f57", label: "高活跃观测站" },
|
||||||
{ color: "#8af5ff", label: "事件连线 / 枢纽" },
|
{ color: "#8af5ff", label: "事件连线 / 枢纽" },
|
||||||
{ color: "#2dd4bf", label: "影响区域" },
|
{ color: "#2dd4bf", label: "影响区域" },
|
||||||
{ color: "#ff4d4f", label: "严重事件" },
|
{ color: "#ff4d4f", label: "严重事件" },
|
||||||
|
|||||||
@@ -496,10 +496,17 @@ export function getAllLandingPoints() {
|
|||||||
export function applyLandingPointVisualState(lockedCableName, dimAll = false) {
|
export function applyLandingPointVisualState(lockedCableName, dimAll = false) {
|
||||||
const pulse = (Math.sin(Date.now() * 0.003) + 1) * 0.5;
|
const pulse = (Math.sin(Date.now() * 0.003) + 1) * 0.5;
|
||||||
const brightness = 0.3;
|
const brightness = 0.3;
|
||||||
|
const relatedNames = Array.isArray(lockedCableName)
|
||||||
|
? lockedCableName.filter(Boolean)
|
||||||
|
: lockedCableName
|
||||||
|
? [lockedCableName]
|
||||||
|
: [];
|
||||||
|
|
||||||
landingPoints.forEach((lp) => {
|
landingPoints.forEach((lp) => {
|
||||||
const isRelated =
|
const isRelated =
|
||||||
!dimAll && lp.userData.cableNames?.includes(lockedCableName);
|
!dimAll &&
|
||||||
|
Array.isArray(lp.userData.cableNames) &&
|
||||||
|
lp.userData.cableNames.some((name) => relatedNames.includes(name));
|
||||||
|
|
||||||
if (isRelated) {
|
if (isRelated) {
|
||||||
lp.material.color.setHex(0xffaa00);
|
lp.material.color.setHex(0xffaa00);
|
||||||
|
|||||||
@@ -110,9 +110,19 @@ export const BGP_CONFIG = {
|
|||||||
low: 0.94
|
low: 0.94
|
||||||
},
|
},
|
||||||
collectorColor: 0x6db7ff,
|
collectorColor: 0x6db7ff,
|
||||||
|
collectorHeatColors: {
|
||||||
|
idle: 0x6db7ff,
|
||||||
|
low: 0x60a5fa,
|
||||||
|
medium: 0xfbbf24,
|
||||||
|
high: 0xfb923c,
|
||||||
|
hot: 0xff5f57
|
||||||
|
},
|
||||||
eventHubColor: 0x8af5ff,
|
eventHubColor: 0x8af5ff,
|
||||||
linkColor: 0x54d2ff,
|
linkColor: 0x54d2ff,
|
||||||
regionColor: 0x2dd4bf
|
regionColor: 0x2dd4bf,
|
||||||
|
collectorHaloScale: 11.5,
|
||||||
|
collectorPulseHaloScale: 16.5,
|
||||||
|
collectorCoverageHaloScale: 22.5
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PREDICTED_ORBIT_CONFIG = {
|
export const PREDICTED_ORBIT_CONFIG = {
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ const CARD_CONFIG = {
|
|||||||
{ key: 'collector', label: '主观测站' },
|
{ key: 'collector', label: '主观测站' },
|
||||||
{ key: 'observed_by', label: '观测范围' },
|
{ key: 'observed_by', label: '观测范围' },
|
||||||
{ key: 'impacted_scope', label: '影响区域' },
|
{ key: 'impacted_scope', label: '影响区域' },
|
||||||
|
{ key: 'related_cables', label: '附近基础设施' },
|
||||||
|
{ key: 'related_satellites', label: '附近卫星' },
|
||||||
{ key: 'location', label: '观测位置' },
|
{ key: 'location', label: '观测位置' },
|
||||||
{ key: 'created_at', label: '事件时间' },
|
{ key: 'created_at', label: '事件时间' },
|
||||||
{ key: 'summary', label: '摘要' }
|
{ key: 'summary', label: '摘要' }
|
||||||
@@ -60,6 +62,17 @@ const CARD_CONFIG = {
|
|||||||
{ key: 'collector', label: '采集器' },
|
{ key: 'collector', label: '采集器' },
|
||||||
{ key: 'location', label: '观测位置' },
|
{ key: 'location', label: '观测位置' },
|
||||||
{ key: 'anomaly_count', 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: '状态' }
|
{ key: 'status', label: '状态' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -89,8 +102,36 @@ const CARD_CONFIG = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function initInfoCard() {
|
export function initInfoCard() {
|
||||||
|
const card = document.getElementById('info-card');
|
||||||
const content = document.getElementById('info-card-content');
|
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) => {
|
content.addEventListener('click', async (event) => {
|
||||||
const label = event.target.closest('.info-card-label');
|
const label = event.target.closest('.info-card-label');
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ import {
|
|||||||
getSatellitePositions,
|
getSatellitePositions,
|
||||||
showPredictedOrbit,
|
showPredictedOrbit,
|
||||||
hidePredictedOrbit,
|
hidePredictedOrbit,
|
||||||
|
highlightRelatedSatellites,
|
||||||
|
clearRelatedSatelliteHighlights,
|
||||||
|
getRelatedSatelliteIndicesForRegions,
|
||||||
|
updateRelatedSatelliteHighlights,
|
||||||
updateBreathingPhase,
|
updateBreathingPhase,
|
||||||
isSatelliteFrontFacing,
|
isSatelliteFrontFacing,
|
||||||
setSatelliteCamera,
|
setSatelliteCamera,
|
||||||
@@ -88,10 +92,15 @@ import {
|
|||||||
formatBGPLocation,
|
formatBGPLocation,
|
||||||
formatBGPObservedTime,
|
formatBGPObservedTime,
|
||||||
formatBGPObservedBy,
|
formatBGPObservedBy,
|
||||||
|
formatBGPRelatedCables,
|
||||||
formatBGPRouteChange,
|
formatBGPRouteChange,
|
||||||
|
formatBGPTopEventTypes,
|
||||||
|
formatBGPScope,
|
||||||
|
formatBGPCollectorCoverageHalo,
|
||||||
formatBGPSeverityLabel,
|
formatBGPSeverityLabel,
|
||||||
formatBGPStatusLabel,
|
formatBGPStatusLabel,
|
||||||
showBGPEventOverlay,
|
showBGPEventOverlay,
|
||||||
|
showBGPCollectorCoverageOverlay,
|
||||||
} from "./bgp.js";
|
} from "./bgp.js";
|
||||||
import {
|
import {
|
||||||
setupControls,
|
setupControls,
|
||||||
@@ -165,6 +174,18 @@ const DRAG_ROTATION_FACTOR = 0.005;
|
|||||||
const DRAG_SMOOTHING_FACTOR = 0.18;
|
const DRAG_SMOOTHING_FACTOR = 0.18;
|
||||||
const INERTIA_DAMPING = 0.92;
|
const INERTIA_DAMPING = 0.92;
|
||||||
const INERTIA_MIN_VELOCITY = 0.00008;
|
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) {
|
function bindListener(target, eventName, handler, options) {
|
||||||
if (!target) return;
|
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) {
|
function disposeMaterial(material) {
|
||||||
if (!material) return;
|
if (!material) return;
|
||||||
if (Array.isArray(material)) {
|
if (Array.isArray(material)) {
|
||||||
@@ -234,11 +261,18 @@ export function clearLockedObject() {
|
|||||||
clearAllCableStates();
|
clearAllCableStates();
|
||||||
clearCableSelection();
|
clearCableSelection();
|
||||||
clearBGPSelection();
|
clearBGPSelection();
|
||||||
|
clearRelatedSatelliteHighlights();
|
||||||
setSatelliteRingState(null, "none", null);
|
setSatelliteRingState(null, "none", null);
|
||||||
clearRuntimeSelection();
|
clearRuntimeSelection();
|
||||||
setLegendItems("satellites", getSatelliteLegendItems());
|
setLegendItems("satellites", getSatelliteLegendItems());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function clearLockedObjectAndInfo() {
|
||||||
|
clearLockedObject();
|
||||||
|
hideInfoCard();
|
||||||
|
hideTooltip();
|
||||||
|
}
|
||||||
|
|
||||||
function isSameCable(cable1, cable2) {
|
function isSameCable(cable1, cable2) {
|
||||||
if (!cable1 || !cable2) return false;
|
if (!cable1 || !cable2) return false;
|
||||||
const id1 = cable1.userData?.cableId;
|
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) {
|
function applyBGPHoverState(marker) {
|
||||||
resetTransientBGPStates();
|
resetTransientBGPStates();
|
||||||
if (!marker) {
|
if (!marker) {
|
||||||
@@ -419,6 +469,11 @@ function showBGPInfo(marker) {
|
|||||||
marker.userData.observed_by ||
|
marker.userData.observed_by ||
|
||||||
formatBGPObservedBy(marker.userData.collectors),
|
formatBGPObservedBy(marker.userData.collectors),
|
||||||
impacted_scope: formatBGPImpactedScope(impactedRegions),
|
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:
|
location:
|
||||||
marker.userData.location ||
|
marker.userData.location ||
|
||||||
formatBGPLocation(marker.userData.city, marker.userData.country),
|
formatBGPLocation(marker.userData.city, marker.userData.country),
|
||||||
@@ -433,10 +488,65 @@ function showBGPCollectorInfo(marker) {
|
|||||||
collector: marker.userData.collector,
|
collector: marker.userData.collector,
|
||||||
location: formatBGPLocation(marker.userData.city, marker.userData.country),
|
location: formatBGPLocation(marker.userData.city, marker.userData.country),
|
||||||
anomaly_count: marker.userData.anomaly_count ?? 0,
|
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"),
|
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() {
|
function applyCableVisualState() {
|
||||||
const allCables = getCableLines();
|
const allCables = getCableLines();
|
||||||
const pulse = (Math.sin(Date.now() * CABLE_CONFIG.pulseSpeed) + 1) * 0.5;
|
const pulse = (Math.sin(Date.now() * CABLE_CONFIG.pulseSpeed) + 1) * 0.5;
|
||||||
@@ -462,7 +572,8 @@ function applyCableVisualState() {
|
|||||||
if (
|
if (
|
||||||
(lockedObjectType === "cable" && lockedObject) ||
|
(lockedObjectType === "cable" && lockedObject) ||
|
||||||
(lockedObjectType === "satellite" && lockedSatellite) ||
|
(lockedObjectType === "satellite" && lockedSatellite) ||
|
||||||
(lockedObjectType === "bgp" && lockedObject)
|
(lockedObjectType === "bgp" && lockedObject) ||
|
||||||
|
(lockedObjectType === "bgp_collector" && lockedObject)
|
||||||
) {
|
) {
|
||||||
cable.material.opacity = CABLE_CONFIG.otherOpacity;
|
cable.material.opacity = CABLE_CONFIG.otherOpacity;
|
||||||
const origColor = cable.userData.originalColor;
|
const origColor = cable.userData.originalColor;
|
||||||
@@ -972,6 +1083,26 @@ function onMouseMove(event) {
|
|||||||
const earth = getEarth();
|
const earth = getEarth();
|
||||||
if (!earth) return;
|
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 (isDragging) {
|
||||||
if (Date.now() - dragStartTime > 500) {
|
if (Date.now() - dragStartTime > 500) {
|
||||||
isLongDrag = true;
|
isLongDrag = true;
|
||||||
@@ -1029,12 +1160,8 @@ function onMouseMove(event) {
|
|||||||
bgpCollectorIntersects,
|
bgpCollectorIntersects,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (hoveredBGP && !isSameBGPMarker(hoveredBGP, hoveredBGPMarker)) {
|
||||||
hoveredBGP &&
|
clearTransientHoverState();
|
||||||
!isSameBGPMarker(hoveredBGP, hoveredBGPMarker)
|
|
||||||
) {
|
|
||||||
resetTransientBGPStates();
|
|
||||||
hoveredBGP = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -1042,20 +1169,14 @@ function onMouseMove(event) {
|
|||||||
(!cableIntersects.length ||
|
(!cableIntersects.length ||
|
||||||
!isSameCable(cableIntersects[0]?.object, hoveredCable))
|
!isSameCable(cableIntersects[0]?.object, hoveredCable))
|
||||||
) {
|
) {
|
||||||
if (!isSameCable(hoveredCable, lockedObject)) {
|
clearTransientHoverState();
|
||||||
setCableState(hoveredCable.userData.cableId, CABLE_STATE.NORMAL);
|
|
||||||
}
|
|
||||||
hoveredCable = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
hoveredSatelliteIndex !== null &&
|
hoveredSatelliteIndex !== null &&
|
||||||
hoveredSatelliteIndex !== hoveredSatIndexFromIntersect
|
hoveredSatelliteIndex !== hoveredSatIndexFromIntersect
|
||||||
) {
|
) {
|
||||||
if (hoveredSatelliteIndex !== lockedSatelliteIndex) {
|
clearTransientHoverState();
|
||||||
setSatelliteRingState(hoveredSatelliteIndex, "none", null);
|
|
||||||
}
|
|
||||||
hoveredSatelliteIndex = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -1142,6 +1263,10 @@ function onMouseMove(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onMouseDown(event) {
|
function onMouseDown(event) {
|
||||||
|
if (isEventOnHud(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const earth = getEarth();
|
const earth = getEarth();
|
||||||
isDragging = true;
|
isDragging = true;
|
||||||
dragStartTime = Date.now();
|
dragStartTime = Date.now();
|
||||||
@@ -1170,6 +1295,7 @@ function onMouseLeave() {
|
|||||||
function onClick(event) {
|
function onClick(event) {
|
||||||
const earth = getEarth();
|
const earth = getEarth();
|
||||||
if (!earth) return;
|
if (!earth) return;
|
||||||
|
if (isEventOnHud(event)) return;
|
||||||
|
|
||||||
updatePointerFromEvent(event);
|
updatePointerFromEvent(event);
|
||||||
|
|
||||||
@@ -1210,6 +1336,14 @@ function onClick(event) {
|
|||||||
lastBGPClickPos = { x: event.clientX, y: event.clientY };
|
lastBGPClickPos = { x: event.clientX, y: event.clientY };
|
||||||
setAutoRotate(false);
|
setAutoRotate(false);
|
||||||
showBGPEventOverlay(clickedMarker, earth);
|
showBGPEventOverlay(clickedMarker, earth);
|
||||||
|
{
|
||||||
|
const relatedSatelliteIndices = getRelatedSatelliteIndicesForRegions(
|
||||||
|
getBGPRelatedRegions(clickedMarker),
|
||||||
|
{ limit: 6, maxAngleDeg: 20 },
|
||||||
|
);
|
||||||
|
clickedMarker.userData.related_satellite_count = relatedSatelliteIndices.length;
|
||||||
|
highlightRelatedSatellites(relatedSatelliteIndices, "#7dd3fc");
|
||||||
|
}
|
||||||
showBGPInfo(clickedMarker);
|
showBGPInfo(clickedMarker);
|
||||||
showStatusMessage(
|
showStatusMessage(
|
||||||
`已选择BGP事件: ${clickedMarker.userData.collector}`,
|
`已选择BGP事件: ${clickedMarker.userData.collector}`,
|
||||||
@@ -1231,6 +1365,15 @@ function onClick(event) {
|
|||||||
lastBGPClickType = "bgp_collector";
|
lastBGPClickType = "bgp_collector";
|
||||||
lastBGPClickPos = { x: event.clientX, y: event.clientY };
|
lastBGPClickPos = { x: event.clientX, y: event.clientY };
|
||||||
setAutoRotate(false);
|
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);
|
showBGPCollectorInfo(clickedMarker);
|
||||||
showStatusMessage(
|
showStatusMessage(
|
||||||
`已选择观测站: ${clickedMarker.userData.collector}`,
|
`已选择观测站: ${clickedMarker.userData.collector}`,
|
||||||
@@ -1374,6 +1517,21 @@ function animate() {
|
|||||||
) {
|
) {
|
||||||
applyLandingPointVisualState(null, true);
|
applyLandingPointVisualState(null, true);
|
||||||
} else if (lockedObjectType === "bgp" && lockedObject) {
|
} 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);
|
applyLandingPointVisualState(null, true);
|
||||||
} else {
|
} else {
|
||||||
resetLandingPointVisualState();
|
resetLandingPointVisualState();
|
||||||
@@ -1381,6 +1539,7 @@ function animate() {
|
|||||||
|
|
||||||
updateSatellitePositions(deltaTime);
|
updateSatellitePositions(deltaTime);
|
||||||
updateBreathingPhase(deltaTime);
|
updateBreathingPhase(deltaTime);
|
||||||
|
updateRelatedSatelliteHighlights();
|
||||||
|
|
||||||
const satPositions = getSatellitePositions();
|
const satPositions = getSatellitePositions();
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { twoline2satrec, propagate } from "satellite.js";
|
import { twoline2satrec, propagate } from "satellite.js";
|
||||||
import { CONFIG, SATELLITE_CONFIG } from "./constants.js";
|
import { CONFIG, SATELLITE_CONFIG } from "./constants.js";
|
||||||
|
import { latLonToVector3 } from "./utils.js";
|
||||||
|
|
||||||
let satellitePoints = null;
|
let satellitePoints = null;
|
||||||
let satelliteTrails = null;
|
let satelliteTrails = null;
|
||||||
@@ -15,6 +16,7 @@ let hoverRingSprite = null;
|
|||||||
let lockedRingSprite = null;
|
let lockedRingSprite = null;
|
||||||
let lockedDotSprite = null;
|
let lockedDotSprite = null;
|
||||||
let predictedOrbitLine = null;
|
let predictedOrbitLine = null;
|
||||||
|
let relatedSatelliteSprites = [];
|
||||||
let earthObjRef = null;
|
let earthObjRef = null;
|
||||||
let sceneRef = null;
|
let sceneRef = null;
|
||||||
let cameraRef = null;
|
let cameraRef = null;
|
||||||
@@ -759,6 +761,25 @@ function createRingSprite(position, isLocked = false) {
|
|||||||
return sprite;
|
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) {
|
export function showHoverRing(position, isLocked = false) {
|
||||||
if (!earthObjRef || !position) return null;
|
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) {
|
export function initSatelliteScene(scene, earth) {
|
||||||
sceneRef = scene;
|
sceneRef = scene;
|
||||||
earthObjRef = earth;
|
earthObjRef = earth;
|
||||||
@@ -1000,6 +1097,7 @@ export function clearSatelliteData() {
|
|||||||
hideHoverRings();
|
hideHoverRings();
|
||||||
hideLockedRing();
|
hideLockedRing();
|
||||||
hidePredictedOrbit();
|
hidePredictedOrbit();
|
||||||
|
clearRelatedSatelliteHighlights();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetSatelliteState() {
|
export function resetSatelliteState() {
|
||||||
|
|||||||
@@ -30,6 +30,26 @@ interface BGPEvent {
|
|||||||
observed_at: string | null
|
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 {
|
interface BGPIncident {
|
||||||
id: number
|
id: number
|
||||||
incident_type: string
|
incident_type: string
|
||||||
@@ -42,6 +62,13 @@ interface BGPIncident {
|
|||||||
affected_asns: number[]
|
affected_asns: number[]
|
||||||
affected_collectors: string[]
|
affected_collectors: string[]
|
||||||
affected_regions: Array<{ country?: string; city?: 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
|
created_at: string | null
|
||||||
started_at: string | null
|
started_at: string | null
|
||||||
}
|
}
|
||||||
@@ -60,6 +87,15 @@ interface EventSummary {
|
|||||||
by_type: Record<string, number>
|
by_type: Record<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function severityColor(severity: string) {
|
||||||
if (severity === 'critical') return 'red'
|
if (severity === 'critical') return 'red'
|
||||||
if (severity === 'high') return 'orange'
|
if (severity === 'high') return 'orange'
|
||||||
@@ -72,21 +108,25 @@ function BGP() {
|
|||||||
const [incidents, setIncidents] = useState<BGPIncident[]>([])
|
const [incidents, setIncidents] = useState<BGPIncident[]>([])
|
||||||
const [anomalies, setAnomalies] = useState<BGPAnomaly[]>([])
|
const [anomalies, setAnomalies] = useState<BGPAnomaly[]>([])
|
||||||
const [events, setEvents] = useState<BGPEvent[]>([])
|
const [events, setEvents] = useState<BGPEvent[]>([])
|
||||||
|
const [collectors, setCollectors] = useState<BGPCollectorCoverage[]>([])
|
||||||
const [incidentSummary, setIncidentSummary] = useState<Summary | null>(null)
|
const [incidentSummary, setIncidentSummary] = useState<Summary | null>(null)
|
||||||
const [anomalySummary, setAnomalySummary] = useState<Summary | null>(null)
|
const [anomalySummary, setAnomalySummary] = useState<Summary | null>(null)
|
||||||
const [eventSummary, setEventSummary] = useState<EventSummary | null>(null)
|
const [eventSummary, setEventSummary] = useState<EventSummary | null>(null)
|
||||||
|
const [collectorSummary, setCollectorSummary] = useState<CollectorSummary | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
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', { params: { page_size: 50 } }),
|
||||||
axios.get('/api/v1/bgp/incidents/summary'),
|
axios.get('/api/v1/bgp/incidents/summary'),
|
||||||
axios.get('/api/v1/bgp/anomalies', { params: { page_size: 100 } }),
|
axios.get('/api/v1/bgp/anomalies', { params: { page_size: 100 } }),
|
||||||
axios.get('/api/v1/bgp/anomalies/summary'),
|
axios.get('/api/v1/bgp/anomalies/summary'),
|
||||||
axios.get('/api/v1/bgp/events', { params: { page_size: 20 } }),
|
axios.get('/api/v1/bgp/events', { params: { page_size: 20 } }),
|
||||||
axios.get('/api/v1/bgp/events/summary'),
|
axios.get('/api/v1/bgp/events/summary'),
|
||||||
|
axios.get('/api/v1/bgp/collectors'),
|
||||||
|
axios.get('/api/v1/bgp/collectors/summary'),
|
||||||
])
|
])
|
||||||
setIncidents(incidentsRes.data.data || [])
|
setIncidents(incidentsRes.data.data || [])
|
||||||
setIncidentSummary(incidentSummaryRes.data)
|
setIncidentSummary(incidentSummaryRes.data)
|
||||||
@@ -94,6 +134,8 @@ function BGP() {
|
|||||||
setAnomalySummary(anomalySummaryRes.data)
|
setAnomalySummary(anomalySummaryRes.data)
|
||||||
setEvents(eventsRes.data.data || [])
|
setEvents(eventsRes.data.data || [])
|
||||||
setEventSummary(eventSummaryRes.data)
|
setEventSummary(eventSummaryRes.data)
|
||||||
|
setCollectors(collectorsRes.data.data || [])
|
||||||
|
setCollectorSummary(collectorSummaryRes.data)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -119,17 +161,17 @@ function BGP() {
|
|||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col xs={24} md={8}>
|
<Col xs={24} md={8}>
|
||||||
<Card>
|
<Card>
|
||||||
<Statistic title="观测事件" value={eventSummary?.total || 0} />
|
<Statistic title="近24h事件" value={collectorSummary?.recent_24h_events || 0} />
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} md={8}>
|
<Col xs={24} md={8}>
|
||||||
<Card>
|
<Card>
|
||||||
<Statistic title="观测站" value={eventSummary?.collector_count || 0} />
|
<Statistic title="活跃观测站" value={collectorSummary?.active_collectors || 0} />
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} md={8}>
|
<Col xs={24} md={8}>
|
||||||
<Card>
|
<Card>
|
||||||
<Statistic title="观测前缀" value={eventSummary?.prefix_count || 0} />
|
<Statistic title="观测前缀" value={collectorSummary?.observed_prefixes || eventSummary?.prefix_count || 0} />
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
@@ -152,6 +194,64 @@ function BGP() {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
<Card title="观测站覆盖">
|
||||||
|
<Table<BGPCollectorCoverage>
|
||||||
|
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 || '-'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card title="事件列表">
|
<Card title="事件列表">
|
||||||
<Table<BGPIncident>
|
<Table<BGPIncident>
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
@@ -200,6 +300,23 @@ function BGP() {
|
|||||||
.join(' / ')
|
.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: '置信度',
|
title: '置信度',
|
||||||
dataIndex: 'confidence',
|
dataIndex: 'confidence',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "planet"
|
name = "planet"
|
||||||
version = "0.21.9"
|
version = "0.22.0"
|
||||||
description = "智能星球计划 - 态势感知系统"
|
description = "智能星球计划 - 态势感知系统"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
Reference in New Issue
Block a user