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