From dff6ed035a2a88c56e5bad25da3cb91801a4e9f7 Mon Sep 17 00:00:00 2001 From: Anton Roslund Date: Sun, 10 Aug 2025 15:17:04 +0200 Subject: [PATCH] Ability to show traceroutes on map. --- src/index.js | 110 +++++++++++++++++++++++ src/public/index.html | 205 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 315 insertions(+) diff --git a/src/index.js b/src/index.js index 973f680..91faa31 100644 --- a/src/index.js +++ b/src/index.js @@ -146,6 +146,14 @@ app.get('/api', async (req, res) => { "path": "/api/v1/nodes/:nodeId/traceroutes", "description": "Trace routes for a meshtastic node", }, + { + "path": "/api/v1/traceroutes", + "description": "Recent traceroute edges across all nodes", + "params": { + "time_from": "Only include traceroutes updated after this unix timestamp (milliseconds)", + "time_to": "Only include traceroutes updated before this unix timestamp (milliseconds)" + } + }, { "path": "/api/v1/nodes/:nodeId/position-history", "description": "Position history for a meshtastic node", @@ -566,6 +574,108 @@ app.get('/api/v1/nodes/:nodeId/traceroutes', async (req, res) => { } }); +// Aggregated recent traceroute edges (global), filtered by updated_at +// Returns deduplicated edges with the latest SNR and timestamp. +// GET /api/v1/nodes/traceroutes?time_from=...&time_to=... +app.get('/api/v1/traceroutes', async (req, res) => { + try { + + const timeFrom = req.query.time_from ? parseInt(req.query.time_from) : undefined; + const timeTo = req.query.time_to ? parseInt(req.query.time_to) : undefined; + + // Pull recent traceroutes within the time window. We only want replies (want_response=false) + // and those that were actually gated to MQTT (gateway_id not null) + const traces = await prisma.traceRoute.findMany({ + where: { + want_response: false, + gateway_id: { not: null }, + updated_at: { + gte: timeFrom ? new Date(timeFrom) : undefined, + lte: timeTo ? new Date(timeTo) : undefined, + }, + }, + orderBy: { id: 'desc' }, + take: 5000, // cap to keep response bounded; UI can page/adjust time window if needed + }); + + // Normalize JSON fields that may be strings (depending on driver) + const normalized = traces.map((t) => { + const trace = { ...t }; + if (typeof trace.route === 'string') { + try { trace.route = JSON.parse(trace.route); } catch(_) {} + } + if (typeof trace.route_back === 'string') { + try { trace.route_back = JSON.parse(trace.route_back); } catch(_) {} + } + if (typeof trace.snr_towards === 'string') { + try { trace.snr_towards = JSON.parse(trace.snr_towards); } catch(_) {} + } + if (typeof trace.snr_back === 'string') { + try { trace.snr_back = JSON.parse(trace.snr_back); } catch(_) {} + } + return trace; + }); + + // Build edges from the forward (towards) path using snr_towards + // The forward path is: to (initiator) → route[0] → route[1] → ... → from (responder?) + // snr_towards holds SNR per hop along that path. + // We only care about neighbor-like edges between consecutive hops with their SNR and updated_at. + const edgeKey = (a, b) => `${String(a)}->${String(b)}`; // directional; map layer can choose how to render + const edges = new Map(); + + for (const tr of normalized) { + const path = []; + if (tr.to != null) path.push(Number(tr.to)); + if (Array.isArray(tr.route)) { + for (const hop of tr.route) { + if (hop != null) path.push(Number(hop)); + } + } + if (tr.from != null) path.push(Number(tr.from)); + + const snrs = Array.isArray(tr.snr_towards) ? tr.snr_towards : []; + + for (let i = 0; i < path.length - 1; i++) { + const fromNode = path[i]; + const toNode = path[i + 1]; + // snr_towards aligns to hops; guard if missing + const snr = typeof snrs[i] === 'number' ? snrs[i] : null; + + const key = edgeKey(fromNode, toNode); + const existing = edges.get(key); + if (!existing) { + edges.set(key, { + from: fromNode, + to: toNode, + snr: snr, + updated_at: tr.updated_at, + channel_id: tr.channel_id ?? null, + gateway_id: tr.gateway_id ?? null, + }); + } else { + // Deduplicate by keeping the most recent updated_at + if (new Date(tr.updated_at) > new Date(existing.updated_at)) { + existing.snr = snr; + existing.updated_at = tr.updated_at; + existing.channel_id = tr.channel_id ?? existing.channel_id; + existing.gateway_id = tr.gateway_id ?? existing.gateway_id; + } + } + } + } + + res.json({ + traceroute_edges: Array.from(edges.values()), + }); + + } catch (err) { + console.error(err); + res.status(500).json({ + message: "Something went wrong, try again later.", + }); + } +}); + app.get('/api/v1/nodes/:nodeId/position-history', async (req, res) => { try { diff --git a/src/public/index.html b/src/public/index.html index 2ca1d6c..8692d02 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -1315,6 +1315,28 @@ + +
+ +
Traceroute edges older than this time are hidden. Reload to update map.
+ +
+
@@ -1613,6 +1635,20 @@ } } + function getConfigTraceroutesMaxAgeInSeconds() { + const value = localStorage.getItem("config_traceroutes_max_age_in_seconds"); + // default to 3 days if unset, to limit payloads + return value != null ? parseInt(value) : 259200; + } + + function setConfigTraceroutesMaxAgeInSeconds(value) { + if(value != null){ + return localStorage.setItem("config_traceroutes_max_age_in_seconds", value); + } else { + return localStorage.removeItem("config_traceroutes_max_age_in_seconds"); + } + } + function getConfigNeighboursMaxDistanceInMeters() { const value = localStorage.getItem("config_neighbours_max_distance_in_meters"); return value != null ? parseInt(value) : null; @@ -1649,6 +1685,7 @@ configNodesDisconnectedAgeInSeconds: window.getConfigNodesDisconnectedAgeInSeconds(), configNodesOfflineAgeInSeconds: window.getConfigNodesOfflineAgeInSeconds(), configWaypointsMaxAgeInSeconds: window.getConfigWaypointsMaxAgeInSeconds(), + configTraceroutesMaxAgeInSeconds: window.getConfigTraceroutesMaxAgeInSeconds(), configNeighboursMaxDistanceInMeters: window.getConfigNeighboursMaxDistanceInMeters(), configZoomLevelGoToNode: window.getConfigZoomLevelGoToNode(), configAutoUpdatePositionInUrl: window.getConfigAutoUpdatePositionInUrl(), @@ -1685,6 +1722,7 @@ selectedNodePositionHistoryPolyLines: [], selectedTraceRoute: null, + tracerouteEdges: [], selectedNodeToShowNeighbours: null, selectedNodeToShowNeighboursType: null, @@ -2560,6 +2598,9 @@ configWaypointsMaxAgeInSeconds() { window.setConfigWaypointsMaxAgeInSeconds(this.configWaypointsMaxAgeInSeconds); }, + configTraceroutesMaxAgeInSeconds() { + window.setConfigTraceroutesMaxAgeInSeconds(this.configTraceroutesMaxAgeInSeconds); + }, configNeighboursMaxDistanceInMeters() { window.setConfigNeighboursMaxDistanceInMeters(this.configNeighboursMaxDistanceInMeters); }, @@ -2595,6 +2636,7 @@ var nodeMarkers = {}; var selectedNodeOutlineCircle = null; var waypoints = []; + var tracerouteEdgesCache = []; // set map bounds to be a little more than full size to prevent panning off screen var bounds = [ @@ -2669,6 +2711,7 @@ var nodesBackboneLayerGroup = new L.LayerGroup(); var waypointsLayerGroup = new L.LayerGroup(); var nodePositionHistoryLayerGroup = new L.LayerGroup(); + var traceroutesLayerGroup = new L.LayerGroup(); // create icons var iconMqttConnected = L.divIcon({ @@ -2736,6 +2779,7 @@ "Backbone Connection": backboneNeighboursLayerGroup, "Waypoints": waypointsLayerGroup, "Position History": nodePositionHistoryLayerGroup, + "Traceroutes": traceroutesLayerGroup, }, }, { // make the "Nodes" group exclusive (use radio inputs instead of checkbox) @@ -2762,6 +2806,9 @@ if(enabledOverlayLayers.includes("Position History")){ nodePositionHistoryLayerGroup.addTo(map); } + if(enabledOverlayLayers.includes("Traceroutes")){ + traceroutesLayerGroup.addTo(map); + } // update config when map overlay is added map.on('overlayadd', function(event) { @@ -2913,6 +2960,10 @@ waypointsLayerGroup.clearLayers(); } + function clearAllTraceroutes() { + traceroutesLayerGroup.clearLayers(); + } + function closeAllPopups() { map.eachLayer(function(layer) { if(layer.options.pane === "popupPane"){ @@ -3048,6 +3099,47 @@ // show overlay for node neighbours window._onShowNodeNeighboursWeHeardClick(node); + // Overlay ALL traceroute edges that terminate at this node (edge.to == node.node_id) + for (const edge of tracerouteEdgesCache) { + if (String(edge.to) !== String(node.node_id)) continue; + + const fromMarker = findNodeMarkerById(edge.from); + if (!fromMarker) continue; + + const snrDb = (typeof edge.snr === 'number') ? (edge.snr === -128 ? null : (Number(edge.snr) / 4)) : null; + const trColour = snrDb != null ? getColourForSnr(snrDb) : '#6b7280'; + + const trTooltip = (() => { + const fromNode = findNodeById(edge.from); + const toNode = findNodeById(node.node_id); + const distanceInMeters = fromMarker.getLatLng().distanceTo(nodeMarker.getLatLng()).toFixed(2); + let distance = `${distanceInMeters} meters`; + if (distanceInMeters >= 1000) { + const km = (distanceInMeters / 1000).toFixed(2); + distance = `${km} kilometers`; + } + const terrainImageUrl = getTerrainProfileImage(fromNode, toNode); + return `Traceroute hop` + + `
from [${escapeString(fromNode.short_name)}] ${escapeString(fromNode.long_name)}` + + ` to [${escapeString(toNode.short_name)}] ${escapeString(toNode.long_name)}` + + `
SNR: ${snrDb != null ? snrDb + 'dB' : '?'}` + + `
Distance: ${distance}` + + `

Terrain images from HeyWhatsThat.com` + + `
`; + })(); + + L.polyline([ + fromMarker.getLatLng(), + nodeMarker.getLatLng(), + ], { + color: trColour, + opacity: 0.9, + }).arrowheads({ size: '10px', fill: true, offsets: { start: '25px', end: '25px' } }) + .addTo(nodeNeighboursLayerGroup) + .bindTooltip(trTooltip, { sticky: true, opacity: 1, interactive: true }) + .bindPopup(trTooltip); + } + // ensure we have neighbours to show const neighbours = node.neighbours ?? []; if(neighbours.length === 0){ @@ -3171,6 +3263,47 @@ } + // Overlay ALL traceroute edges that originate from this node (edge.from == node.node_id) + for (const edge of tracerouteEdgesCache) { + if (String(edge.from) !== String(node.node_id)) continue; + + const toMarker = findNodeMarkerById(edge.to); + if (!toMarker) continue; + + const snrDb = (typeof edge.snr === 'number') ? (edge.snr === -128 ? null : (Number(edge.snr) / 4)) : null; + const trColour = snrDb != null ? getColourForSnr(snrDb) : '#6b7280'; + + const trTooltip2 = (() => { + const fromNode = findNodeById(node.node_id); + const toNode = findNodeById(edge.to); + const distanceInMeters = nodeMarker.getLatLng().distanceTo(toMarker.getLatLng()).toFixed(2); + let distance = `${distanceInMeters} meters`; + if (distanceInMeters >= 1000) { + const km = (distanceInMeters / 1000).toFixed(2); + distance = `${km} kilometers`; + } + const terrainImageUrl = getTerrainProfileImage(fromNode, toNode); + return `Traceroute hop` + + `
from [${escapeString(fromNode.short_name)}] ${escapeString(fromNode.long_name)}` + + ` to [${escapeString(toNode.short_name)}] ${escapeString(toNode.long_name)}` + + `
SNR: ${snrDb != null ? snrDb + 'dB' : '?'}` + + `
Distance: ${distance}` + + `

Terrain images from HeyWhatsThat.com` + + `
`; + })(); + + L.polyline([ + nodeMarker.getLatLng(), + toMarker.getLatLng(), + ], { + color: trColour, + opacity: 0.9, + }).arrowheads({ size: '10px', fill: true, offsets: { start: '25px', end: '25px' } }) + .addTo(nodeNeighboursLayerGroup) + .bindTooltip(trTooltip2, { sticky: true, opacity: 1, interactive: true }) + .bindPopup(trTooltip2); + } + // ensure we have neighbours to show if(neighbourNodeInfos.length === 0){ return; @@ -3257,6 +3390,7 @@ clearAllNodes(); clearAllNeighbours(); clearAllWaypoints(); + clearAllTraceroutes(); clearNodeOutline(); cleanUpNodeNeighbours(); } @@ -3624,6 +3758,66 @@ } + function onTracerouteEdgesUpdated(edges) { + + traceroutesLayerGroup.clearLayers(); + + tracerouteEdgesCache = edges; + + for (const edge of edges) { + // Convert SNR for traceroutes: snr/4 dB; -128 means unknown + const snrDb = (typeof edge.snr === 'number') + ? (edge.snr === -128 ? null : (Number(edge.snr) / 4)) + : null; + const fromNode = findNodeById(edge.from); + const toNode = findNodeById(edge.to); + if (!fromNode || !toNode) continue; + + const fromMarker = findNodeMarkerById(edge.from); + const toMarker = findNodeMarkerById(edge.to); + if (!fromMarker || !toMarker) continue; + + const distanceInMeters = fromMarker.getLatLng().distanceTo(toMarker.getLatLng()).toFixed(2); + + let distance = `${distanceInMeters} meters`; + if (distanceInMeters >= 1000) { + const km = (distanceInMeters / 1000).toFixed(2); + distance = `${km} kilometers`; + } + + const colour = '#f97316'; + + const terrainImageUrl = getTerrainProfileImage(fromNode, toNode); + + const tooltip = `Traceroute hop` + + `
from [${escapeString(fromNode.short_name)}] ${escapeString(fromNode.long_name)}` + + ` to [${escapeString(toNode.short_name)}] ${escapeString(toNode.long_name)}` + + `
SNR: ${snrDb != null ? snrDb + 'dB' : '?'}` + + `
Distance: ${distance}` + + (edge.updated_at ? `
Updated: ${moment(new Date(edge.updated_at)).fromNow()}` : '') + + (edge.channel_id ? `
Channel: ${edge.channel_id}` : '') + + `

Terrain images from HeyWhatsThat.com` + + `
`; + + 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(); + }); + } + } + function onPositionHistoryUpdated(updatedPositionHistories) { let positionHistoryLinesCords = []; @@ -3756,6 +3950,17 @@ onWaypointsUpdated(response.data.waypoints); }); + // fetch traceroute edges + const traceroutesMaxAgeSec = getConfigTraceroutesMaxAgeInSeconds(); + const timeFrom = traceroutesMaxAgeSec ? (Date.now() - traceroutesMaxAgeSec * 1000) : undefined; + const params = new URLSearchParams(); + if (timeFrom) params.set('time_from', timeFrom); + await window.axios.get(`/api/v1/traceroutes?${params.toString()}`).then(async (response) => { + onTracerouteEdgesUpdated(response.data.traceroute_edges ?? []); + }).catch(() => { + onTracerouteEdgesUpdated([]); + }); + } function getRegionFrequencyRange(regionName) {