feat: enrich earth bgp event visualization
This commit is contained in:
@@ -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: "低危异常" },
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user