feat: expand bgp observability surfaces

This commit is contained in:
linkong
2026-03-31 14:07:28 +08:00
parent ac63bba2a2
commit 552e49bde0
16 changed files with 1319 additions and 47 deletions

View File

@@ -1 +1 @@
0.21.9 0.22.0

View File

@@ -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,

View File

@@ -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", []))}

View 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

View File

@@ -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,
) )
) )

View File

@@ -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

View File

@@ -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.

View File

@@ -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",

View File

@@ -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: "严重事件" },

View File

@@ -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);

View File

@@ -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 = {

View File

@@ -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');

View File

@@ -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 (

View File

@@ -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() {

View File

@@ -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',

View File

@@ -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 = [