diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..30196ef1 --- /dev/null +++ b/TODO.md @@ -0,0 +1,4 @@ +# TODO + +- [ ] 把 BGP 观测站和异常点的 `hover/click` 手感再磨细一点 +- [ ] 开始做 BGP 异常和海缆/区域的关联展示 diff --git a/backend/app/api/v1/visualization.py b/backend/app/api/v1/visualization.py index f6a57686..2a1a748a 100644 --- a/backend/app/api/v1/visualization.py +++ b/backend/app/api/v1/visualization.py @@ -282,6 +282,18 @@ def convert_bgp_anomalies_to_geojson(records: List[BGPAnomaly]) -> Dict[str, Any for record in records: evidence = record.evidence or {} collectors = evidence.get("collectors") or record.peer_scope or [] + if not collectors: + nested = evidence.get("events") or [] + collectors = [ + str((item or {}).get("collector") or "").strip() + for item in nested + if (item or {}).get("collector") + ] + + collectors = [collector for collector in collectors if collector] + if not collectors: + collectors = [] + collector = collectors[0] if collectors else None location = None if collector: @@ -299,6 +311,40 @@ def convert_bgp_anomalies_to_geojson(records: List[BGPAnomaly]) -> Dict[str, Any if location is None: continue + as_path = [] + if isinstance(evidence.get("as_path"), list): + as_path = evidence.get("as_path") or [] + if not as_path: + nested = evidence.get("events") or [] + for item in nested: + candidate_path = (item or {}).get("as_path") + if isinstance(candidate_path, list) and candidate_path: + as_path = candidate_path + break + + impacted_regions = [] + seen_regions = set() + for collector_name in collectors: + collector_location = RIPE_RIS_COLLECTOR_COORDS.get(str(collector_name)) + if not collector_location: + continue + region_key = ( + collector_location.get("country"), + collector_location.get("city"), + ) + if region_key in seen_regions: + continue + seen_regions.add(region_key) + impacted_regions.append( + { + "collector": collector_name, + "country": collector_location.get("country"), + "city": collector_location.get("city"), + "latitude": collector_location.get("latitude"), + "longitude": collector_location.get("longitude"), + } + ) + features.append( { "type": "Feature", @@ -318,6 +364,10 @@ def convert_bgp_anomalies_to_geojson(records: List[BGPAnomaly]) -> Dict[str, Any "prefix": record.prefix, "origin_asn": record.origin_asn, "new_origin_asn": record.new_origin_asn, + "collectors": collectors, + "collector_count": len(collectors) or 1, + "as_path": as_path, + "impacted_regions": impacted_regions, "confidence": record.confidence, "summary": record.summary, "created_at": to_iso8601_utc(record.created_at), diff --git a/frontend/public/earth/js/bgp.js b/frontend/public/earth/js/bgp.js index b20a8cf7..6f7920f5 100644 --- a/frontend/public/earth/js/bgp.js +++ b/frontend/public/earth/js/bgp.js @@ -4,6 +4,7 @@ import { BGP_CONFIG, CONFIG, PATHS } from "./constants.js"; import { latLonToVector3 } from "./utils.js"; const bgpGroup = new THREE.Group(); +const bgpOverlayGroup = new THREE.Group(); const collectorMarkers = []; const anomalyMarkers = []; const anomalyCountByCollector = new Map(); @@ -11,6 +12,10 @@ const anomalyCountByCollector = new Map(); let showBGP = true; let totalAnomalyCount = 0; let textureCache = null; +let activeEventOverlay = null; +const relativeTimeFormatter = new Intl.RelativeTimeFormat("zh-CN", { + numeric: "auto", +}); function getMarkerTexture() { if (textureCache) return textureCache; @@ -72,6 +77,150 @@ function formatLocalDateTime(value) { return `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, "0")}/${String(date.getDate()).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}:${String(date.getSeconds()).padStart(2, "0")}`; } +function toDate(value) { + if (!value) return null; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return null; + return date; +} + +function formatRelativeTime(value) { + const date = toDate(value); + if (!date) return null; + + const diffMs = date.getTime() - Date.now(); + const absMs = Math.abs(diffMs); + + if (absMs < 60 * 1000) { + return relativeTimeFormatter.format(Math.round(diffMs / 1000), "second"); + } + if (absMs < 60 * 60 * 1000) { + return relativeTimeFormatter.format(Math.round(diffMs / (60 * 1000)), "minute"); + } + if (absMs < 24 * 60 * 60 * 1000) { + return relativeTimeFormatter.format(Math.round(diffMs / (60 * 60 * 1000)), "hour"); + } + return relativeTimeFormatter.format( + Math.round(diffMs / (24 * 60 * 60 * 1000)), + "day", + ); +} + +export function formatBGPSeverityLabel(severity) { + const normalized = normalizeSeverity(severity); + switch (normalized) { + case "critical": + return "严重"; + case "high": + return "高"; + case "medium": + return "中"; + case "low": + return "低"; + default: + return "中"; + } +} + +export function formatBGPAnomalyTypeLabel(type) { + const value = String(type || "").trim().toLowerCase(); + if (!value) return "-"; + + if (value.includes("hijack")) return "前缀劫持"; + if (value.includes("leak")) return "路由泄露"; + if (value.includes("withdraw")) return "大规模撤销"; + if (value.includes("subprefix") || value.includes("more_specific")) { + return "更具体前缀异常"; + } + if (value.includes("path")) return "路径突变"; + if (value.includes("flap")) return "路由抖动"; + + return String(type); +} + +export function formatBGPStatusLabel(status) { + const value = String(status || "").trim().toLowerCase(); + if (!value) return "-"; + if (value === "active") return "活跃"; + if (value === "resolved") return "已恢复"; + if (value === "suppressed") return "已抑制"; + return String(status); +} + +export function formatBGPCollectorStatus(status) { + const value = String(status || "").trim().toLowerCase(); + if (!value) return "在线"; + if (value === "online") return "在线"; + if (value === "offline") return "离线"; + return String(status); +} + +export function formatBGPConfidence(value) { + if (value === null || value === undefined || value === "") return "-"; + const number = Number(value); + if (!Number.isFinite(number)) return String(value); + if (number >= 0 && number <= 1) { + return `${Math.round(number * 100)}%`; + } + return `${Math.round(number)}%`; +} + +export function formatBGPLocation(city, country) { + const cityText = city || ""; + const countryText = country || ""; + if (cityText && countryText) return `${cityText}, ${countryText}`; + return cityText || countryText || "-"; +} + +export function formatBGPRouteChange(originAsn, newOriginAsn) { + const from = originAsn ?? "-"; + const to = newOriginAsn ?? "-"; + + if ((from === "-" || from === "" || from === null) && (to === "-" || to === "" || to === null)) { + return "-"; + } + if (to === "-" || to === "" || to === null) { + return `AS${from}`; + } + return `AS${from} -> AS${to}`; +} + +export function formatBGPObservedTime(value) { + const absolute = formatLocalDateTime(value); + const relative = formatRelativeTime(value); + if (!relative || absolute === "-") return absolute; + return `${relative} (${absolute})`; +} + +export function formatBGPASPath(asPath) { + if (!Array.isArray(asPath) || asPath.length === 0) return "-"; + return asPath.map((asn) => `AS${asn}`).join(" -> "); +} + +export function formatBGPObservedBy(collectors) { + if (!Array.isArray(collectors) || collectors.length === 0) return "-"; + const preview = collectors.slice(0, 3).join(", "); + if (collectors.length <= 3) { + return `${collectors.length}个观测站 (${preview})`; + } + return `${collectors.length}个观测站 (${preview} 等)`; +} + +export function formatBGPImpactedScope(regions) { + if (!Array.isArray(regions) || regions.length === 0) return "-"; + const labels = regions + .map((region) => { + const city = region?.city || ""; + const country = region?.country || ""; + return city && country ? `${city}, ${country}` : city || country || ""; + }) + .filter(Boolean); + + if (labels.length === 0) return "-"; + if (labels.length <= 3) return labels.join(" / "); + return `${labels.slice(0, 3).join(" / ")} 等${labels.length}地`; +} + function buildCollectorFeatureData(feature) { const coordinates = feature?.geometry?.coordinates || []; const [longitude, latitude] = coordinates; @@ -163,6 +312,12 @@ function buildAnomalyFeatureData(feature) { prefix: properties.prefix || "-", origin_asn: properties.origin_asn ?? "-", new_origin_asn: properties.new_origin_asn ?? "-", + as_path: Array.isArray(properties.as_path) ? properties.as_path : [], + collectors: Array.isArray(properties.collectors) ? properties.collectors : [], + collector_count: properties.collector_count ?? 1, + impacted_regions: Array.isArray(properties.impacted_regions) + ? properties.impacted_regions + : [], confidence: properties.confidence ?? "-", summary: properties.summary || "-", created_at: formatLocalDateTime(createdAt), @@ -181,6 +336,15 @@ function clearMarkerArray(markers) { } } +function clearGroup(group) { + while (group.children.length > 0) { + const child = group.children[group.children.length - 1]; + group.remove(child); + if (child.geometry) child.geometry.dispose(); + if (child.material) child.material.dispose(); + } +} + function createSpriteMaterial({ color, opacity }) { return new THREE.SpriteMaterial({ map: getMarkerTexture(), @@ -193,6 +357,34 @@ function createSpriteMaterial({ color, opacity }) { }); } +function createOverlaySprite({ color, opacity, scale }) { + const sprite = new THREE.Sprite(createSpriteMaterial({ color, opacity })); + sprite.scale.setScalar(scale); + return sprite; +} + +function createArcLine(start, end, color) { + const midpoint = start + .clone() + .add(end) + .multiplyScalar(0.5) + .normalize() + .multiplyScalar(CONFIG.earthRadius + BGP_CONFIG.eventHubAltitudeOffset * 0.8); + + const curve = new THREE.QuadraticBezierCurve3(start, midpoint, end); + const points = curve.getPoints(32); + const geometry = new THREE.BufferGeometry().setFromPoints(points); + const material = new THREE.LineBasicMaterial({ + color, + transparent: true, + opacity: 0.82, + depthWrite: false, + blending: THREE.AdditiveBlending, + }); + + return new THREE.Line(geometry, material); +} + function createCollectorMarker(markerData) { const sprite = new THREE.Sprite( createSpriteMaterial({ @@ -336,8 +528,12 @@ export async function loadBGPAnomalies(scene, earth) { if (!bgpGroup.parent) { earth.add(bgpGroup); } + if (!bgpOverlayGroup.parent) { + earth.add(bgpOverlayGroup); + } bgpGroup.visible = showBGP; + bgpOverlayGroup.visible = showBGP; if (scene && !scene.children.includes(earth)) { scene.add(earth); @@ -441,22 +637,28 @@ export function clearBGPSelection() { anomalyMarkers.forEach((marker) => { marker.userData.state = "normal"; }); + clearBGPEventOverlay(); } export function clearBGPData(earth) { clearMarkerArray(collectorMarkers); clearMarkerArray(anomalyMarkers); + clearBGPEventOverlay(); anomalyCountByCollector.clear(); totalAnomalyCount = 0; if (earth && bgpGroup.parent === earth) { earth.remove(bgpGroup); } + if (earth && bgpOverlayGroup.parent === earth) { + earth.remove(bgpOverlayGroup); + } } export function toggleBGP(show) { showBGP = Boolean(show); bgpGroup.visible = showBGP; + bgpOverlayGroup.visible = showBGP; collectorMarkers.forEach((marker) => { marker.visible = showBGP; }); @@ -485,12 +687,101 @@ export function getBGPCount() { return totalAnomalyCount; } +export function showBGPEventOverlay(marker, earth) { + if (!marker?.userData || marker.userData.type !== "bgp" || !earth) return; + + clearBGPEventOverlay(); + + const impactedRegions = + Array.isArray(marker.userData.impacted_regions) && + marker.userData.impacted_regions.length > 0 + ? marker.userData.impacted_regions + : [ + { + collector: marker.userData.collector, + city: marker.userData.city, + country: marker.userData.country, + latitude: marker.userData.latitude, + longitude: marker.userData.longitude, + }, + ]; + + const validRegions = impactedRegions.filter( + (region) => + typeof region?.latitude === "number" && + typeof region?.longitude === "number", + ); + if (validRegions.length === 0) return; + + const averageLatitude = + validRegions.reduce((sum, region) => sum + region.latitude, 0) / + validRegions.length; + const averageLongitude = + validRegions.reduce((sum, region) => sum + region.longitude, 0) / + validRegions.length; + + const hubPosition = latLonToVector3( + averageLatitude, + averageLongitude, + CONFIG.earthRadius + BGP_CONFIG.eventHubAltitudeOffset, + ); + const hub = createOverlaySprite({ + color: BGP_CONFIG.eventHubColor, + opacity: 0.95, + scale: BGP_CONFIG.eventHubScale, + }); + hub.position.copy(hubPosition); + hub.renderOrder = 6; + bgpOverlayGroup.add(hub); + + const overlayItems = [hub]; + + validRegions.forEach((region) => { + const regionPosition = latLonToVector3( + region.latitude, + region.longitude, + CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset + 0.3, + ); + + const link = createArcLine(regionPosition, hubPosition, BGP_CONFIG.linkColor); + link.renderOrder = 4; + bgpOverlayGroup.add(link); + overlayItems.push(link); + + const halo = createOverlaySprite({ + color: BGP_CONFIG.regionColor, + opacity: 0.24, + scale: BGP_CONFIG.regionScale, + }); + halo.position.copy( + latLonToVector3( + region.latitude, + region.longitude, + CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset - 0.1, + ), + ); + halo.renderOrder = 2; + bgpOverlayGroup.add(halo); + overlayItems.push(halo); + }); + + activeEventOverlay = overlayItems; + bgpOverlayGroup.visible = showBGP; +} + +export function clearBGPEventOverlay() { + activeEventOverlay = null; + clearGroup(bgpOverlayGroup); +} + export function getBGPLegendItems() { return [ { color: "#6db7ff", label: "观测站" }, - { color: "#ff4d4f", label: "Critical 异常" }, - { color: "#ff9f43", label: "High / Major 异常" }, - { color: "#ffd166", label: "Medium 异常" }, - { color: "#4dabf7", label: "Low / Info 异常" }, + { color: "#8af5ff", label: "事件连线 / 枢纽" }, + { color: "#2dd4bf", label: "影响区域" }, + { color: "#ff4d4f", label: "严重异常" }, + { color: "#ff9f43", label: "高危异常" }, + { color: "#ffd166", label: "中危异常" }, + { color: "#4dabf7", label: "低危异常" }, ]; } diff --git a/frontend/public/earth/js/constants.js b/frontend/public/earth/js/constants.js index 6522c545..b3f75afe 100644 --- a/frontend/public/earth/js/constants.js +++ b/frontend/public/earth/js/constants.js @@ -82,6 +82,9 @@ export const BGP_CONFIG = { dimmedScale: 0.92, pulseSpeed: 0.0045, collectorPulseSpeed: 0.0024, + eventHubAltitudeOffset: 7.2, + eventHubScale: 4.8, + regionScale: 11.5, normalPulseAmplitude: 0.08, lockedPulseAmplitude: 0.28, opacity: { @@ -105,7 +108,10 @@ export const BGP_CONFIG = { medium: 1.0, low: 0.94 }, - collectorColor: 0x6db7ff + collectorColor: 0x6db7ff, + eventHubColor: 0x8af5ff, + linkColor: 0x54d2ff, + regionColor: 0x2dd4bf }; export const PREDICTED_ORBIT_CONFIG = { diff --git a/frontend/public/earth/js/info-card.js b/frontend/public/earth/js/info-card.js index 8026306c..6f8281c2 100644 --- a/frontend/public/earth/js/info-card.js +++ b/frontend/public/earth/js/info-card.js @@ -38,14 +38,17 @@ const CARD_CONFIG = { { key: 'anomaly_type', label: '异常类型' }, { key: 'severity', label: '严重度' }, { key: 'status', label: '状态' }, + { key: 'route_change', label: '路由变更' }, { key: 'prefix', label: '前缀' }, + { key: 'as_path_display', label: '传播路径' }, { key: 'origin_asn', label: '原始 ASN' }, { key: 'new_origin_asn', label: '新 ASN' }, { key: 'confidence', label: '置信度' }, { key: 'collector', label: '采集器' }, - { key: 'country', label: '国家' }, - { key: 'city', label: '城市' }, - { key: 'created_at', label: '创建时间' }, + { key: 'observed_by', label: '观测范围' }, + { key: 'impacted_scope', label: '影响区域' }, + { key: 'location', label: '观测位置' }, + { key: 'created_at', label: '发生时间' }, { key: 'summary', label: '摘要' } ] }, @@ -55,8 +58,7 @@ const CARD_CONFIG = { className: 'bgp', fields: [ { key: 'collector', label: '采集器' }, - { key: 'country', label: '国家' }, - { key: 'city', label: '城市' }, + { key: 'location', label: '观测位置' }, { key: 'anomaly_count', label: '当前异常数' }, { key: 'status', label: '状态' } ] diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js index f509c468..e797295e 100644 --- a/frontend/public/earth/js/main.js +++ b/frontend/public/earth/js/main.js @@ -78,6 +78,18 @@ import { updateBGPVisualState, clearBGPData, toggleBGP, + formatBGPAnomalyTypeLabel, + formatBGPASPath, + formatBGPCollectorStatus, + formatBGPConfidence, + formatBGPImpactedScope, + formatBGPLocation, + formatBGPObservedTime, + formatBGPObservedBy, + formatBGPRouteChange, + formatBGPSeverityLabel, + formatBGPStatusLabel, + showBGPEventOverlay, } from "./bgp.js"; import { setupControls, @@ -140,6 +152,7 @@ const scratchCameraToEarth = new THREE.Vector3(); const scratchCableCenter = new THREE.Vector3(); const scratchCableDirection = new THREE.Vector3(); const scratchBGPDirection = new THREE.Vector3(); +const scratchBGPWorldPosition = new THREE.Vector3(); const cleanupFns = []; const DRAG_ROTATION_FACTOR = 0.005; @@ -262,18 +275,36 @@ function showSatelliteInfo(props) { function showBGPInfo(marker) { setLegendMode("bgp"); + const impactedRegions = + Array.isArray(marker.userData.impacted_regions) && + marker.userData.impacted_regions.length > 0 + ? marker.userData.impacted_regions + : [ + { + city: marker.userData.city, + country: marker.userData.country, + }, + ]; showInfoCard("bgp", { - anomaly_type: marker.userData.anomaly_type, - severity: marker.userData.rawSeverity || marker.userData.severity, - status: marker.userData.status, + anomaly_type: formatBGPAnomalyTypeLabel(marker.userData.anomaly_type), + severity: formatBGPSeverityLabel( + marker.userData.rawSeverity || marker.userData.severity, + ), + status: formatBGPStatusLabel(marker.userData.status), + route_change: formatBGPRouteChange( + marker.userData.origin_asn, + marker.userData.new_origin_asn, + ), prefix: marker.userData.prefix, + as_path_display: formatBGPASPath(marker.userData.as_path), origin_asn: marker.userData.origin_asn, new_origin_asn: marker.userData.new_origin_asn, - confidence: marker.userData.confidence, + confidence: formatBGPConfidence(marker.userData.confidence), collector: marker.userData.collector, - country: marker.userData.country, - city: marker.userData.city, - created_at: marker.userData.created_at, + observed_by: formatBGPObservedBy(marker.userData.collectors), + impacted_scope: formatBGPImpactedScope(impactedRegions), + location: formatBGPLocation(marker.userData.city, marker.userData.country), + created_at: formatBGPObservedTime(marker.userData.created_at_raw), summary: marker.userData.summary, }); } @@ -282,10 +313,9 @@ function showBGPCollectorInfo(marker) { setLegendMode("bgp"); showInfoCard("bgp_collector", { collector: marker.userData.collector, - country: marker.userData.country, - city: marker.userData.city, + location: formatBGPLocation(marker.userData.city, marker.userData.country), anomaly_count: marker.userData.anomaly_count ?? 0, - status: marker.userData.status || "online", + status: formatBGPCollectorStatus(marker.userData.status || "online"), }); } @@ -793,7 +823,11 @@ function getFrontFacingBGPMarkers(markers) { scratchCameraToEarth.subVectors(camera.position, earth.position).normalize(); return markers.filter((marker) => { - scratchBGPDirection.copy(marker.position).normalize(); + scratchBGPWorldPosition.copy(marker.position); + marker.parent?.localToWorld(scratchBGPWorldPosition); + scratchBGPDirection + .subVectors(scratchBGPWorldPosition, earth.position) + .normalize(); return scratchCameraToEarth.dot(scratchBGPDirection) > 0; }); } @@ -1032,6 +1066,7 @@ function onClick(event) { lockedObject = clickedMarker; lockedObjectType = "bgp"; setAutoRotate(false); + showBGPEventOverlay(clickedMarker, earth); showBGPInfo(clickedMarker); showStatusMessage( `已选择BGP异常: ${clickedMarker.userData.collector}`,