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