From 57dce4f099fe88e0c0b79032819257189a6cc919 Mon Sep 17 00:00:00 2001 From: Anton Roslund Date: Tue, 6 Jan 2026 16:39:39 +0100 Subject: [PATCH 1/6] Capture edges from traceroutes --- package-lock.json | 3 +- .../20260106151912_add_edges/migration.sql | 23 +++++ prisma/schema.prisma | 24 +++++ src/mqtt.js | 92 +++++++++++++++++++ 4 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20260106151912_add_edges/migration.sql diff --git a/package-lock.json b/package-lock.json index 5ff0661..1fb2318 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,8 @@ "cors": "^2.8.5", "express": "^5.2.1", "mqtt": "^5.14.1", - "protobufjs": "^7.5.4" + "protobufjs": "^7.5.4", + "ws": "^8.18.3" }, "devDependencies": { "jest": "^30.1.3", diff --git a/prisma/migrations/20260106151912_add_edges/migration.sql b/prisma/migrations/20260106151912_add_edges/migration.sql new file mode 100644 index 0000000..113243f --- /dev/null +++ b/prisma/migrations/20260106151912_add_edges/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable +CREATE TABLE `edges` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `from_node_id` BIGINT NOT NULL, + `to_node_id` BIGINT NOT NULL, + `snr` INTEGER NOT NULL, + `from_latitude` INTEGER NULL, + `from_longitude` INTEGER NULL, + `to_latitude` INTEGER NULL, + `to_longitude` INTEGER NULL, + `packet_id` BIGINT NOT NULL, + `channel_id` VARCHAR(191) NULL, + `gateway_id` BIGINT NULL, + `source` VARCHAR(191) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `edges_from_node_id_idx`(`from_node_id`), + INDEX `edges_to_node_id_idx`(`to_node_id`), + INDEX `edges_created_at_idx`(`created_at`), + INDEX `edges_from_node_id_to_node_id_idx`(`from_node_id`, `to_node_id`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ef96a9c..2a908dc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -347,4 +347,28 @@ model ChannelUtilizationStats { @@index([channel_id]) @@index([recorded_at]) @@map("channel_utilization_stats") +} + +model Edge { + id BigInt @id @default(autoincrement()) + from_node_id BigInt + to_node_id BigInt + snr Int + from_latitude Int? + from_longitude Int? + to_latitude Int? + to_longitude Int? + packet_id BigInt + channel_id String? + gateway_id BigInt? + source String + + created_at DateTime @default(now()) + updated_at DateTime @default(now()) @updatedAt + + @@index(from_node_id) + @@index(to_node_id) + @@index(created_at) + @@index([from_node_id, to_node_id]) + @@map("edges") } \ No newline at end of file diff --git a/src/mqtt.js b/src/mqtt.js index f0f65bd..a446fd9 100644 --- a/src/mqtt.js +++ b/src/mqtt.js @@ -1336,6 +1336,98 @@ client.on("message", async (topic, message) => { console.error(e); } + // Extract edges from traceroute (only for response packets) + if(!envelope.packet.decoded.wantResponse) { + try { + const route = routeDiscovery.route || []; + const snrTowards = routeDiscovery.snrTowards || []; + const originNodeId = envelope.packet.to; + const destinationNodeId = envelope.packet.from; + const packetId = envelope.packet.id; + const channelId = envelope.channelId; + const gatewayId = envelope.gatewayId ? convertHexIdToNumericId(envelope.gatewayId) : null; + + // Determine number of edges: route.length + 1 + const numEdges = route.length + 1; + const edgesToCreate = []; + + // Extract edges from the route path + for(let i = 0; i < numEdges; i++) { + // Get SNR for this edge + if(i >= snrTowards.length) { + // Array length mismatch - skip this edge + continue; + } + + const snr = snrTowards[i]; + + // Skip if SNR is -128 (no SNR recorded) + if(snr === -128) { + continue; + } + + // Determine from_node and to_node + let fromNodeId, toNodeId; + + if(route.length === 0) { + // Empty route: direct connection (to -> from) + fromNodeId = originNodeId; + toNodeId = destinationNodeId; + } else if(i === 0) { + // First edge: origin -> route[0] + fromNodeId = originNodeId; + toNodeId = route[0]; + } else if(i === route.length) { + // Last edge: route[route.length-1] -> destination + fromNodeId = route[route.length - 1]; + toNodeId = destinationNodeId; + } else { + // Middle edge: route[i-1] -> route[i] + fromNodeId = route[i - 1]; + toNodeId = route[i]; + } + + // Fetch node positions from Node table + const [fromNode, toNode] = await Promise.all([ + prisma.node.findUnique({ + where: { node_id: fromNodeId }, + select: { latitude: true, longitude: true }, + }), + prisma.node.findUnique({ + where: { node_id: toNodeId }, + select: { latitude: true, longitude: true }, + }), + ]); + + // Create edge record (skip if nodes don't exist, but still create edge with null positions) + edgesToCreate.push({ + from_node_id: fromNodeId, + to_node_id: toNodeId, + snr: snr, + from_latitude: fromNode?.latitude ?? null, + from_longitude: fromNode?.longitude ?? null, + to_latitude: toNode?.latitude ?? null, + to_longitude: toNode?.longitude ?? null, + packet_id: packetId, + channel_id: channelId, + gateway_id: gatewayId, + source: "TRACEROUTE_APP", + }); + } + + // Bulk insert edges + if(edgesToCreate.length > 0) { + await prisma.edge.createMany({ + data: edgesToCreate, + skipDuplicates: true, // Skip if exact duplicate exists + }); + } + } catch (e) { + // Log error but don't crash - edge extraction is non-critical + console.error("Error extracting edges from traceroute:", e); + } + } + } else if(portnum === 73) { From 58d71c8c74a16d1a3281d3ed69e81e8a64cadf39 Mon Sep 17 00:00:00 2001 From: Anton Roslund Date: Wed, 7 Jan 2026 16:46:56 +0100 Subject: [PATCH 2/6] Extract edges from neighbour info --- src/mqtt.js | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src/mqtt.js b/src/mqtt.js index a446fd9..7c91cdf 100644 --- a/src/mqtt.js +++ b/src/mqtt.js @@ -1094,6 +1094,70 @@ client.on("message", async (topic, message) => { console.error(e); } + // Extract edges from neighbour info + try { + const fromNodeId = envelope.packet.from; + const neighbors = neighbourInfo.neighbors || []; + const packetId = envelope.packet.id; + const channelId = envelope.channelId; + const gatewayId = envelope.gatewayId ? convertHexIdToNumericId(envelope.gatewayId) : null; + const edgesToCreate = []; + + for(const neighbour of neighbors) { + // Skip if no node ID + if(!neighbour.nodeId) { + continue; + } + + // Skip if SNR is invalid (0 or null/undefined) + // Note: SNR can be negative, so we check for 0 specifically + if(neighbour.snr === 0 || neighbour.snr == null) { + continue; + } + + const toNodeId = neighbour.nodeId; + const snr = neighbour.snr; + + // Fetch node positions from Node table + const [fromNode, toNode] = await Promise.all([ + prisma.node.findUnique({ + where: { node_id: fromNodeId }, + select: { latitude: true, longitude: true }, + }), + prisma.node.findUnique({ + where: { node_id: toNodeId }, + select: { latitude: true, longitude: true }, + }), + ]); + + // Create edge record + edgesToCreate.push({ + from_node_id: fromNodeId, + to_node_id: toNodeId, + snr: snr, + from_latitude: fromNode?.latitude ?? null, + from_longitude: fromNode?.longitude ?? null, + to_latitude: toNode?.latitude ?? null, + to_longitude: toNode?.longitude ?? null, + packet_id: packetId, + channel_id: channelId, + gateway_id: gatewayId, + source: "NEIGHBORINFO_APP", + }); + } + + // Bulk insert edges + if(edgesToCreate.length > 0) { + await prisma.edge.createMany({ + data: edgesToCreate, + skipDuplicates: true, // Skip if exact duplicate exists + }); + } + } catch (e) { + // Log error but don't crash - edge extraction is non-critical + console.error("Error extracting edges from neighbour info:", e); + } + // don't store all neighbour infos, but we want to update the existing node above if(!collectNeighbourInfo){ return; From 13334473985945ce139e4f4090697939554dae53 Mon Sep 17 00:00:00 2001 From: Anton Roslund Date: Wed, 7 Jan 2026 16:47:20 +0100 Subject: [PATCH 3/6] Extract edges from route_back path --- src/mqtt.js | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/mqtt.js b/src/mqtt.js index 7c91cdf..f7bf9b5 100644 --- a/src/mqtt.js +++ b/src/mqtt.js @@ -1479,6 +1479,68 @@ client.on("message", async (topic, message) => { }); } + // Extract edges from route_back path + const routeBack = routeDiscovery.routeBack || []; + const snrBack = routeDiscovery.snrBack || []; + + if(routeBack.length > 0) { + // Number of edges in route_back equals route_back.length + for(let i = 0; i < routeBack.length; i++) { + // Get SNR for this edge + if(i >= snrBack.length) { + // Array length mismatch - skip this edge + continue; + } + + const snr = snrBack[i]; + + // Skip if SNR is -128 (no SNR recorded) + if(snr === -128) { + continue; + } + + // Determine from_node and to_node + let fromNodeId, toNodeId; + + if(i === 0) { + // First edge: from -> route_back[0] + fromNodeId = destinationNodeId; // 'from' in the packet + toNodeId = routeBack[0]; + } else { + // Subsequent edges: route_back[i-1] -> route_back[i] + fromNodeId = routeBack[i - 1]; + toNodeId = routeBack[i]; + } + + // Fetch node positions from Node table + const [fromNode, toNode] = await Promise.all([ + prisma.node.findUnique({ + where: { node_id: fromNodeId }, + select: { latitude: true, longitude: true }, + }), + prisma.node.findUnique({ + where: { node_id: toNodeId }, + select: { latitude: true, longitude: true }, + }), + ]); + + // Create edge record + edgesToCreate.push({ + from_node_id: fromNodeId, + to_node_id: toNodeId, + snr: snr, + from_latitude: fromNode?.latitude ?? null, + from_longitude: fromNode?.longitude ?? null, + to_latitude: toNode?.latitude ?? null, + to_longitude: toNode?.longitude ?? null, + packet_id: packetId, + channel_id: channelId, + gateway_id: gatewayId, + source: "TRACEROUTE_APP", + }); + } + } + // Bulk insert edges if(edgesToCreate.length > 0) { await prisma.edge.createMany({ From 556dde517bf47157359774f12be407030dd2c0ae Mon Sep 17 00:00:00 2001 From: Anton Roslund Date: Wed, 7 Jan 2026 20:32:18 +0100 Subject: [PATCH 4/6] Add connections endpoint and UI configuration for connections time period and colored lines --- src/index.js | 188 +++++++++++++++++++++++++++++++++++ src/public/index.html | 222 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 407 insertions(+), 3 deletions(-) diff --git a/src/index.js b/src/index.js index 8d1eabe..58269f0 100644 --- a/src/index.js +++ b/src/index.js @@ -155,6 +155,14 @@ app.get('/api', async (req, res) => { "time_to": "Only include traceroutes updated before this unix timestamp (milliseconds)" } }, + { + "path": "/api/v1/connections", + "description": "Aggregated edges between nodes from traceroutes", + "params": { + "time_from": "Only include edges created after this unix timestamp (milliseconds)", + "time_to": "Only include edges created before this unix timestamp (milliseconds)" + } + }, { "path": "/api/v1/nodes/:nodeId/position-history", "description": "Position history for a meshtastic node", @@ -698,6 +706,186 @@ app.get('/api/v1/traceroutes', async (req, res) => { } }); +// Aggregated edges endpoint +// GET /api/v1/connections?time_from=...&time_to=... +app.get('/api/v1/connections', 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; + + // Query edges from database + const edges = await prisma.edge.findMany({ + where: { + created_at: { + gte: timeFrom ? new Date(timeFrom) : undefined, + lte: timeTo ? new Date(timeTo) : undefined, + }, + // Only include edges where both nodes have positions + from_latitude: { not: null }, + from_longitude: { not: null }, + to_latitude: { not: null }, + to_longitude: { not: null }, + }, + orderBy: [ + { created_at: 'desc' }, + { packet_id: 'desc' }, + ], + }); + + // Collect all unique node IDs from edges + const nodeIds = new Set(); + for (const edge of edges) { + nodeIds.add(edge.from_node_id); + nodeIds.add(edge.to_node_id); + } + + // Fetch current positions for all nodes + const nodes = await prisma.node.findMany({ + where: { + node_id: { in: Array.from(nodeIds) }, + }, + select: { + node_id: true, + latitude: true, + longitude: true, + }, + }); + + // Create a map of current node positions + const nodePositions = new Map(); + for (const node of nodes) { + nodePositions.set(node.node_id, { + latitude: node.latitude, + longitude: node.longitude, + }); + } + + // Filter edges: only include edges where both nodes are still at the same location + const validEdges = edges.filter(edge => { + const fromCurrentPos = nodePositions.get(edge.from_node_id); + const toCurrentPos = nodePositions.get(edge.to_node_id); + + // Skip if either node doesn't exist or doesn't have a current position + if (!fromCurrentPos || !toCurrentPos || + fromCurrentPos.latitude === null || fromCurrentPos.longitude === null || + toCurrentPos.latitude === null || toCurrentPos.longitude === null) { + return false; + } + + // Check if stored positions match current positions + const fromMatches = fromCurrentPos.latitude === edge.from_latitude && + fromCurrentPos.longitude === edge.from_longitude; + const toMatches = toCurrentPos.latitude === edge.to_latitude && + toCurrentPos.longitude === edge.to_longitude; + + return fromMatches && toMatches; + }); + + // Normalize node pairs: always use min/max to treat A->B and B->A as same connection + const connectionsMap = new Map(); + + for (const edge of validEdges) { + const nodeA = edge.from_node_id < edge.to_node_id ? edge.from_node_id : edge.to_node_id; + const nodeB = edge.from_node_id < edge.to_node_id ? edge.to_node_id : edge.from_node_id; + const key = `${nodeA}-${nodeB}`; + + if (!connectionsMap.has(key)) { + connectionsMap.set(key, { + node_a: nodeA, + node_b: nodeB, + direction_ab: [], // A -> B edges + direction_ba: [], // B -> A edges + }); + } + + const connection = connectionsMap.get(key); + const isAB = edge.from_node_id === nodeA; + + // Add edge to appropriate direction + if (isAB) { + connection.direction_ab.push({ + snr: edge.snr, + snr_db: edge.snr / 4, // Convert to dB + created_at: edge.created_at, + packet_id: edge.packet_id, + source: edge.source, + }); + } else { + connection.direction_ba.push({ + snr: edge.snr, + snr_db: edge.snr / 4, + created_at: edge.created_at, + packet_id: edge.packet_id, + source: edge.source, + }); + } + } + + // Aggregate each connection + const connections = Array.from(connectionsMap.values()).map(conn => { + // Deduplicate edges by packet_id for each direction (keep first occurrence, which is most recent) + const dedupeByPacketId = (edges) => { + const seen = new Set(); + return edges.filter(edge => { + if (seen.has(edge.packet_id)) { + return false; + } + seen.add(edge.packet_id); + return true; + }); + }; + + const deduplicatedAB = dedupeByPacketId(conn.direction_ab); + const deduplicatedBA = dedupeByPacketId(conn.direction_ba); + + // Calculate average SNR for A->B (using deduplicated edges) + const avgSnrAB = deduplicatedAB.length > 0 + ? deduplicatedAB.reduce((sum, e) => sum + e.snr_db, 0) / deduplicatedAB.length + : null; + + // Calculate average SNR for B->A (using deduplicated edges) + const avgSnrBA = deduplicatedBA.length > 0 + ? deduplicatedBA.reduce((sum, e) => sum + e.snr_db, 0) / deduplicatedBA.length + : null; + + // Get last 5 edges for each direction (already sorted by created_at DESC, packet_id DESC, now deduplicated) + const last5AB = deduplicatedAB.slice(0, 5); + const last5BA = deduplicatedBA.slice(0, 5); + + // Determine worst average SNR + const worstAvgSnrDb = [avgSnrAB, avgSnrBA] + .filter(v => v !== null) + .reduce((min, val) => val < min ? val : min, Infinity); + + return { + node_a: conn.node_a, + node_b: conn.node_b, + direction_ab: { + avg_snr_db: avgSnrAB, + last_5_edges: last5AB, + total_count: deduplicatedAB.length, // Use deduplicated count + }, + direction_ba: { + avg_snr_db: avgSnrBA, + last_5_edges: last5BA, + total_count: deduplicatedBA.length, // Use deduplicated count + }, + worst_avg_snr_db: worstAvgSnrDb !== Infinity ? worstAvgSnrDb : null, + }; + }).filter(conn => conn.worst_avg_snr_db !== null); // Only return connections with at least one direction + + res.json({ + connections: connections, + }); + + } 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 6a71a50..dd0cab6 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -1440,6 +1440,36 @@
Map will animate flying to nodes.
+ +
+ +
Edges within this time period are shown in the Connections layer. Reload to update map.
+ +
+ + +
+
+
+ +
+ +
+
Colors the connection lines by the average SNR in the worst direction. Reload to update map.
+
+ @@ -1725,6 +1755,29 @@ return localStorage.setItem("config_zoom_level_go_to_node", value); } + function getConfigConnectionsTimePeriodInSeconds() { + const value = localStorage.getItem("config_connections_time_period_in_seconds"); + // default to 24 hours if unset + return value != null ? parseInt(value) : 86400; + } + + function setConfigConnectionsTimePeriodInSeconds(value) { + return localStorage.setItem("config_connections_time_period_in_seconds", value); + } + + function getConfigConnectionsColoredLines() { + const value = localStorage.getItem("config_connections_colored_lines"); + // disable colored lines by default + if(value === null){ + return false; + } + return value === "true"; + } + + function setConfigConnectionsColoredLines(value) { + return localStorage.setItem("config_connections_colored_lines", value); + } + function isMobile() { return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); } @@ -1748,6 +1801,8 @@ configAutoUpdatePositionInUrl: window.getConfigAutoUpdatePositionInUrl(), configEnableMapAnimations: window.getConfigEnableMapAnimations(), configTemperatureFormat: window.getConfigTemperatureFormat(), + configConnectionsTimePeriodInSeconds: window.getConfigConnectionsTimePeriodInSeconds(), + configConnectionsColoredLines: window.getConfigConnectionsColoredLines(), isShowingHardwareModels: false, hardwareModelStats: null, @@ -2673,6 +2728,12 @@ configTemperatureFormat() { window.setConfigTemperatureFormat(this.configTemperatureFormat); }, + configConnectionsTimePeriodInSeconds() { + window.setConfigConnectionsTimePeriodInSeconds(this.configConnectionsTimePeriodInSeconds); + }, + configConnectionsColoredLines() { + window.setConfigConnectionsColoredLines(this.configConnectionsColoredLines); + }, deviceMetricsTimeRange() { this.loadNodeDeviceMetrics(this.selectedNode.node_id); }, @@ -2771,6 +2832,7 @@ var waypointsLayerGroup = new L.LayerGroup(); var nodePositionHistoryLayerGroup = new L.LayerGroup(); var traceroutesLayerGroup = new L.LayerGroup(); + var connectionsLayerGroup = new L.LayerGroup(); // create icons var iconMqttConnected = L.divIcon({ @@ -2859,6 +2921,7 @@ "Legend": legendLayerGroup, "Neighbours": neighboursLayerGroup, "Backbone Connection": backboneNeighboursLayerGroup, + "Connections": connectionsLayerGroup, "Waypoints": waypointsLayerGroup, "Position History": nodePositionHistoryLayerGroup, "Traceroutes": traceroutesLayerGroup, @@ -2882,6 +2945,9 @@ if(enabledOverlayLayers.includes("Backbone Connection")){ backboneNeighboursLayerGroup.addTo(map); } + if(enabledOverlayLayers.includes("Connections")){ + connectionsLayerGroup.addTo(map); + } if(enabledOverlayLayers.includes("Waypoints")){ waypointsLayerGroup.addTo(map); } @@ -3053,6 +3119,10 @@ traceroutesLayerGroup.clearLayers(); } + function clearAllConnections() { + connectionsLayerGroup.clearLayers(); + } + function closeAllPopups() { map.eachLayer(function(layer) { if(layer.options.pane === "popupPane"){ @@ -3120,9 +3190,40 @@ } function getColourForSnr(snr) { - if(snr >= -5) return "#16a34a"; // good - if(snr > -15) return "#fff200"; // meh - if(snr <= -15) return "#dc2626"; // bad + if(snr >= -4) return "#16a34a"; // good + if(snr > -8) return "#fff200"; // medium-good + if(snr > -12) return "#ff9f1c"; // medium + return "#dc2626"; // bad + } + + function getSignalBarsIndicator(snrDb) { + if(snrDb == null) return ''; + + // Determine number of bars based on SNR + let bars = 0; + if(snrDb >= -4) bars = 4; // good + else if(snrDb > -8) bars = 3; // medium-good + else if(snrDb > -12) bars = 2; // medium + else bars = 1; // bad + + const color = getColourForSnr(snrDb); + + // Create 4 bars with increasing height + let indicator = ''; + + // Bar heights: 4px, 6px, 8px, 10px + const barHeights = [4, 6, 8, 10]; + const barWidth = 2; + + for (let i = 0; i < 4; i++) { + const height = barHeights[i]; + const isActive = i < bars; + const barColor = isActive ? color : '#d1d5db'; // gray for inactive bars + indicator += ``; + } + + indicator += ''; + return indicator; } function cleanUpNodeNeighbours() { @@ -3492,6 +3593,7 @@ clearAllNeighbours(); clearAllWaypoints(); clearAllTraceroutes(); + clearAllConnections(); clearNodeOutline(); cleanUpNodeNeighbours(); } @@ -3968,6 +4070,109 @@ } } + function onConnectionsUpdated(connections) { + // Clear existing connections + clearAllConnections(); + + for (const connection of connections) { + // Find both node markers + const nodeAMarker = findNodeMarkerById(connection.node_a); + const nodeBMarker = findNodeMarkerById(connection.node_b); + + // Skip if either node marker doesn't exist + if (!nodeAMarker || !nodeBMarker) { + continue; + } + + // Find node objects for names and terrain profile + const nodeA = findNodeById(connection.node_a); + const nodeB = findNodeById(connection.node_b); + + if (!nodeA || !nodeB) { + continue; + } + + // Calculate distance between nodes + const distanceInMeters = nodeAMarker.getLatLng().distanceTo(nodeBMarker.getLatLng()).toFixed(2); + let distance = `${distanceInMeters} meters`; + if (distanceInMeters >= 1000) { + const distanceInKilometers = (distanceInMeters / 1000).toFixed(2); + distance = `${distanceInKilometers} kilometers`; + } + + // Determine line color based on worst average SNR (if colored lines enabled) + const configConnectionsColoredLines = getConfigConnectionsColoredLines(); + const worstSnrDb = connection.worst_avg_snr_db; + const lineColor = configConnectionsColoredLines && worstSnrDb != null ? getColourForSnr(worstSnrDb) : '#2563eb'; + + // Create polyline (bidirectional, no arrows) + const line = L.polyline([ + nodeAMarker.getLatLng(), + nodeBMarker.getLatLng(), + ], { + color: lineColor, + opacity: 0.75, + weight: 3, + }).addTo(connectionsLayerGroup); + + // Generate tooltip + let tooltip = `Connection`; + tooltip += `
[${escapeString(nodeA.short_name)}] ${escapeString(nodeA.long_name)} <-> [${escapeString(nodeB.short_name)}] ${escapeString(nodeB.long_name)}`; + tooltip += `
Distance: ${distance}`; + tooltip += `
`; + + // Direction A -> B + if (connection.direction_ab.avg_snr_db != null) { + tooltip += `
${escapeString(nodeA.short_name)} -> ${escapeString(nodeB.short_name)}:`; + tooltip += `
SNR: ${connection.direction_ab.avg_snr_db.toFixed(1)}dB ${getSignalBarsIndicator(connection.direction_ab.avg_snr_db)} (Average of ${connection.direction_ab.total_count} edges)`; + if (connection.direction_ab.last_5_edges.length > 0) { + tooltip += `
Last 5 edges:`; + for (const edge of connection.direction_ab.last_5_edges) { + const timeAgo = moment(new Date(edge.created_at)).fromNow(); + console.log(edge.source); + const sourceIcon = edge.source === "TRACEROUTE_APP" ? "⇵" : (edge.source === "NEIGHBORINFO_APP" ? "✳" : "?"); + tooltip += `
   ${edge.snr_db.toFixed(1)}dB ${getSignalBarsIndicator(edge.snr_db)} (${timeAgo} by:${sourceIcon})`; + } + } else { + tooltip += `
No recent edges`; + } + } + + // Direction B -> A + if (connection.direction_ba.avg_snr_db != null) { + tooltip += `

${escapeString(nodeB.short_name)} -> ${escapeString(nodeA.short_name)}:`; + tooltip += `
SNR: ${connection.direction_ba.avg_snr_db.toFixed(1)}dB ${getSignalBarsIndicator(connection.direction_ba.avg_snr_db)} (Average of ${connection.direction_ba.total_count} edges)`; + if (connection.direction_ba.last_5_edges.length > 0) { + tooltip += `
Last 5 edges:`; + for (const edge of connection.direction_ba.last_5_edges) { + const timeAgo = moment(new Date(edge.created_at)).fromNow(); + const sourceIcon = edge.source === "TRACEROUTE_APP" ? "⇵" : (edge.source === "NEIGHBORINFO_APP" ? "✳" : "?"); + tooltip += `
   ${edge.snr_db.toFixed(1)}dB ${getSignalBarsIndicator(edge.snr_db)} (${timeAgo} by:${sourceIcon})`; + } + } else { + tooltip += `
No recent edges`; + } + } + + // Add terrain profile image + const terrainImageUrl = getTerrainProfileImage(nodeA, nodeB); + tooltip += `

Terrain images from HeyWhatsThat.com`; + tooltip += `
`; + + // Bind tooltip and popup + line.bindTooltip(tooltip, { + sticky: true, + opacity: 1, + interactive: true, + }) + .bindPopup(tooltip) + .on('click', function(event) { + // close tooltip on click to prevent tooltip and popup showing at same time + event.target.closeTooltip(); + }); + } + } + function onPositionHistoryUpdated(updatedPositionHistories) { let positionHistoryLinesCords = []; @@ -4111,6 +4316,17 @@ onTracerouteEdgesUpdated([]); }); + // fetch connections (edges) + const connectionsTimePeriodSec = getConfigConnectionsTimePeriodInSeconds(); + const connectionsTimeFrom = connectionsTimePeriodSec ? (Date.now() - connectionsTimePeriodSec * 1000) : undefined; + const connectionsParams = new URLSearchParams(); + if (connectionsTimeFrom) connectionsParams.set('time_from', connectionsTimeFrom); + await window.axios.get(`/api/v1/connections?${connectionsParams.toString()}`).then(async (response) => { + onConnectionsUpdated(response.data.connections ?? []); + }).catch(() => { + onConnectionsUpdated([]); + }); + } function getRegionFrequencyRange(regionName) { From 71d32d1cd099af8aa54a040f3fdc7de791272a0e Mon Sep 17 00:00:00 2001 From: Anton Roslund Date: Thu, 8 Jan 2026 18:32:55 +0100 Subject: [PATCH 5/6] Add optional parameter for filtering connections by node --- src/index.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/index.js b/src/index.js index 58269f0..48dbcc2 100644 --- a/src/index.js +++ b/src/index.js @@ -159,6 +159,7 @@ app.get('/api', async (req, res) => { "path": "/api/v1/connections", "description": "Aggregated edges between nodes from traceroutes", "params": { + "node_id": "Only include connections involving this node id", "time_from": "Only include edges created after this unix timestamp (milliseconds)", "time_to": "Only include edges created before this unix timestamp (milliseconds)" } @@ -707,9 +708,10 @@ app.get('/api/v1/traceroutes', async (req, res) => { }); // Aggregated edges endpoint -// GET /api/v1/connections?time_from=...&time_to=... +// GET /api/v1/connections?node_id=...&time_from=...&time_to=... app.get('/api/v1/connections', async (req, res) => { try { + const nodeId = req.query.node_id ? parseInt(req.query.node_id) : undefined; const timeFrom = req.query.time_from ? parseInt(req.query.time_from) : undefined; const timeTo = req.query.time_to ? parseInt(req.query.time_to) : undefined; @@ -717,14 +719,21 @@ app.get('/api/v1/connections', async (req, res) => { const edges = await prisma.edge.findMany({ where: { created_at: { - gte: timeFrom ? new Date(timeFrom) : undefined, - lte: timeTo ? new Date(timeTo) : undefined, + ...(timeFrom && { gte: new Date(timeFrom) }), + ...(timeTo && { lte: new Date(timeTo) }), }, // Only include edges where both nodes have positions from_latitude: { not: null }, from_longitude: { not: null }, to_latitude: { not: null }, to_longitude: { not: null }, + // If node_id is provided, filter edges where either from_node_id or to_node_id matches + ...(nodeId !== undefined && { + OR: [ + { from_node_id: nodeId }, + { to_node_id: nodeId }, + ], + }), }, orderBy: [ { created_at: 'desc' }, From f79ff5b7e455d03c8db47499e73d9b53f12e184e Mon Sep 17 00:00:00 2001 From: Anton Roslund Date: Thu, 8 Jan 2026 18:33:16 +0100 Subject: [PATCH 6/6] Refactor connections feature: update UI for connections time period and add configuration for colored connection lines. Replace traceroute-related functionality with connections logic, including fetching and displaying connections on the map. Enhance tooltips for connections with detailed SNR information and distance metrics. --- src/public/index.html | 834 ++++++++++-------------------------------- 1 file changed, 195 insertions(+), 639 deletions(-) diff --git a/src/public/index.html b/src/public/index.html index dd0cab6..e9443d4 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -1372,33 +1372,41 @@ - +
- -
Traceroute edges older than this time are hidden. Reload to update map.
- + - - - - - + +
- +
- -
Neighbours further than this are hidden. Reload to update map.
- +
+
+ +
+ +
+
Colors the connection lines by the average SNR in the worst direction. Reload to update map.
+
+ + +
+ +
Connections further than this are hidden. Reload to update map.
+
@@ -1439,37 +1447,6 @@
Map will animate flying to nodes.
- - -
- -
Edges within this time period are shown in the Connections layer. Reload to update map.
- -
- - -
-
-
- -
- -
-
Colors the connection lines by the average SNR in the worst direction. Reload to update map.
-
- @@ -1479,7 +1456,7 @@ - +