Add WebSocket support for real-time traceroute visualizations.

This commit is contained in:
Anton Roslund 2026-01-02 22:20:24 +01:00
parent 8fd496c59d
commit 328fb3e842
5 changed files with 543 additions and 23 deletions

View file

@ -90,6 +90,18 @@
border: 1px solid white;
}
.icon-traceroute-start {
background-color: #16a34a; /* green */
border-radius: 25px;
border: 1px solid white;
}
.icon-traceroute-end {
background-color: #dc2626; /* red */
border-radius: 25px;
border: 1px solid white;
}
.waypoint-label {
font-size: 26px;
background-color: transparent;
@ -1619,7 +1631,7 @@
} catch(e) {}
// overlays enabled by default
return ["Legend", "Position History"];
return ["Legend", "Position History", "Traceroutes"];
}
@ -2791,6 +2803,16 @@
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
});
var iconTracerouteStart = L.divIcon({
className: 'icon-traceroute-start',
iconSize: [16, 16],
});
var iconTracerouteEnd = L.divIcon({
className: 'icon-traceroute-end',
iconSize: [16, 16],
});
// create legend
var legendLayerGroup = new L.LayerGroup();
var legend = L.control({position: 'bottomleft'});
@ -2801,7 +2823,8 @@
div.innerHTML = `<div style="margin-bottom:6px;"><strong>Legend</strong></div>`
+ `<div style="display:flex"><div class="icon-mediumfast" style="width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;"></div> MediumFast</div>`
+ `<div style="display:flex"><div class="icon-longfast" style="width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;"></div> LongFast</div>`
+ `<div style="display:flex"><div class="icon-offline" style="width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;"></div> Offline Too Long</div>`;
+ `<div style="display:flex"><div class="icon-offline" style="width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;"></div> Offline Too Long</div>`
+ `<div style="display:flex"><svg width="16" height="3" style="margin-right:4px;margin-top:auto;margin-bottom:auto;"><line x1="0" y1="1.5" x2="16" y2="1.5" stroke="#f97316" stroke-width="4"/></svg> Traceroute</div>`;
return div;
};
@ -3865,8 +3888,6 @@
function onTracerouteEdgesUpdated(edges) {
traceroutesLayerGroup.clearLayers();
tracerouteEdgesCache = edges;
for (const edge of edges) {
@ -3910,23 +3931,6 @@
+ `<br/><br/>Terrain images from <a href="http://www.heywhatsthat.com" target="_blank">HeyWhatsThat.com</a>`
+ `<br/><a href="${terrainImageUrl}" target="_blank"><img src="${terrainImageUrl}" width="100%"></a>`;
const line = L.polyline([
fromMarker.getLatLng(),
toMarker.getLatLng(),
], {
color: colour,
opacity: 0.9,
}).addTo(traceroutesLayerGroup);
line.bindTooltip(tooltip, {
sticky: true,
opacity: 1,
interactive: true,
}).bindPopup(tooltip)
.on('click', function(event) {
event.target.closeTooltip();
});
// additional line for backbone neighbours
const backboneNeighbourLine = L.polyline([
fromMarker.getLatLng(),
@ -3954,7 +3958,6 @@
});
if(fromNode.is_backbone && toNode.is_backbone) {
console.log("Adding to backbone neighbours layer group");
backboneNeighbourLine.addTo(backboneNeighboursLayerGroup);
}
}
@ -4328,6 +4331,192 @@
// reload and go to provided node id
reload(queryNodeId, queryZoom);
// WebSocket connection for real-time messages
var ws = null;
var tracerouteCooldown = {}; // Track last traceroute time per from node (for 20s cooldown)
var activeTracerouteKeys = new Set(); // Track active traceroute visualizations to prevent duplicates
var tracerouteLines = {}; // Track lines for each traceroute route key for cleanup
function connectWebSocket() {
// Determine WebSocket URL - use same hostname as current page, port 8081
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsHost = window.location.hostname;
const wsPort = '8081';
const wsUrl = `${wsProtocol}//${wsHost}:${wsPort}`;
console.log('Connecting to WebSocket:', wsUrl);
ws = new WebSocket(wsUrl);
ws.onopen = function() {
console.log('WebSocket connected');
};
ws.onmessage = function(event) {
try {
const message = JSON.parse(event.data);
handleWebSocketMessage(message);
} catch (e) {
console.error('Error parsing WebSocket message:', e);
}
};
ws.onerror = function(error) {
console.error('WebSocket error:', error);
};
ws.onclose = function() {
console.log('WebSocket disconnected, reconnecting in 5 seconds...');
setTimeout(connectWebSocket, 5000);
};
}
function handleWebSocketMessage(message) {
if (message.type === 'traceroute') {
handleTraceroute(message.data);
}
}
function handleTraceroute(data) {
// Only visualize traceroutes where want_response is false (the reply coming back)
if (data.want_response) {
return;
}
// When want_response is false, from and to are swapped from the original request
// The path goes from 'to' (original sender) through route to 'from' (original destination)
const originalSender = data.to; // This was the original sender
const originalDestination = data.from; // This was the original destination
const route = data.route || [];
const snrTowards = data.snr_towards || [];
// Deduplicate: ignore traceroutes from the same original sender for 20 seconds
const now = Date.now();
if (tracerouteCooldown[originalSender] && (now - tracerouteCooldown[originalSender]) < 20000) {
return; // Still in cooldown period
}
// Create unique key for this traceroute path to prevent duplicate visualizations
// Use original sender (to), original destination (from), and route to create unique key
// (ignoring gateway_id since multiple gateways can receive same route)
const routeKey = `${originalSender}-${originalDestination}-${route.join('-')}`;
if (activeTracerouteKeys.has(routeKey)) {
return; // Already visualizing this route
}
// Mark as active and set cooldown
activeTracerouteKeys.add(routeKey);
tracerouteCooldown[originalSender] = now;
// Build the complete path: to (original sender) -> route[0] -> route[1] -> ... -> from (original destination)
const path = [originalSender]; // Start from original sender
if (route.length > 0) {
path.push(...route);
}
path.push(originalDestination); // End at original destination
// Visualize the traceroute with animated hops
visualizeTraceroute(path, snrTowards, routeKey);
}
function visualizeTraceroute(path, snrTowards, routeKey) {
// Verify all nodes in path exist on map
const pathMarkers = [];
for (const nodeId of path) {
const marker = findNodeMarkerById(nodeId);
if (!marker) {
// Node not on map, skip this traceroute
activeTracerouteKeys.delete(routeKey);
return;
}
pathMarkers.push(marker);
}
// Store lines and overlays for this route key for cleanup
const routeElements = {
lines: [],
startOverlay: null,
endOverlay: null,
};
tracerouteLines[routeKey] = routeElements;
// Color starting node (first in path) green and destination node (last in path) red
const startMarker = pathMarkers[0];
const endMarker = pathMarkers[pathMarkers.length - 1];
const startOverlay = L.marker(startMarker.getLatLng(), {
icon: iconTracerouteStart,
zIndexOffset: 10000, // Ensure it's on top
}).addTo(traceroutesLayerGroup);
const endOverlay = L.marker(endMarker.getLatLng(), {
icon: iconTracerouteEnd,
zIndexOffset: 10000, // Ensure it's on top
}).addTo(traceroutesLayerGroup);
// Store overlays for cleanup
routeElements.startOverlay = startOverlay;
routeElements.endOverlay = endOverlay;
// Animate each hop sequentially
let hopIndex = 0;
const animateNextHop = () => {
if (hopIndex >= pathMarkers.length - 1) {
// All hops animated, cleanup after delay
setTimeout(() => {
if (tracerouteLines[routeKey]) {
const routeElements = tracerouteLines[routeKey];
// Remove all lines
if (routeElements.lines) {
routeElements.lines.forEach(line => {
line.remove();
});
}
// Remove node overlays
if (routeElements.startOverlay) {
routeElements.startOverlay.remove();
}
if (routeElements.endOverlay) {
routeElements.endOverlay.remove();
}
delete tracerouteLines[routeKey];
}
activeTracerouteKeys.delete(routeKey);
}, 2000);
return;
}
const fromMarker = pathMarkers[hopIndex];
const toMarker = pathMarkers[hopIndex + 1];
const snr = hopIndex < snrTowards.length ? snrTowards[hopIndex] : null;
// Use orange color for all traceroute lines
const lineColor = '#f97316'; // orange
// Create animated polyline for this hop with orange dotted style
const line = L.polyline([fromMarker.getLatLng(), toMarker.getLatLng()], {
color: lineColor,
weight: 4,
opacity: 0, // Start invisible
// dashArray: '10, 5', // Dotted line style
zIndexOffset: 10000,
}).addTo(traceroutesLayerGroup);
// Fade in animation
line.setStyle({ opacity: 1.0 });
tracerouteLines[routeKey].lines.push(line);
// Animate next hop after 600ms delay
hopIndex++;
setTimeout(animateNextHop, 600);
};
// Start animation
animateNextHop();
}
// Connect WebSocket when page loads
connectWebSocket();
</script>
<!-- Google tag (gtag.js) -->