10 Commits

Author SHA1 Message Date
Roslund
c765d63804 Update tooltip to display images from both HeyWhatsThat.com and TjenaVadÄrDetDär.se. 2026-03-13 21:33:25 +01:00
Roslund
0ac1f796da Fix health check parametersin docker-compose.yml 2026-03-13 21:27:19 +01:00
Anton Roslund
3cccf80d07 Enhance traceroute retrieval by deduplicating results based on packet_id, ensuring only the latest entry for each packet_id is returned. 2026-02-28 11:04:42 +01:00
Anton Roslund
8fd6730e0d Remove arrowheads from backbone connection 2026-01-11 11:18:01 +01:00
Anton Roslund
1748079708 Add bidirectional connection filtering and minimum SNR configuration to connections UI 2026-01-10 13:43:11 +01:00
Anton Roslund
4a4b5fb7f3 Fix variable assignment in message handler to correctly identify fromNodeId and toNodeId for neighbor information extraction. 2026-01-08 20:58:46 +01:00
Anton Roslund
dc9a45a62a Update position history date format to ISO 8601 2026-01-08 20:50:05 +01:00
Anton Roslund
f3154cb97b Update UI label and description for Connections Max Age configuration to clarify functionality related to edges from traceroutes and neighbor info. 2026-01-08 19:03:29 +01:00
Anton Roslund
57c10383e2 Remove configuration for nodes disconnected age from UI and related functions, streamlining the node status display and tooltip information. 2026-01-08 19:02:35 +01:00
Anton Roslund
f690bb65a7 Merge pull request #68 from Roslund/Collect-edges
Ny Funktionalitet för kopplingar och signalstyrka
2026-01-08 18:44:56 +01:00
4 changed files with 199 additions and 90 deletions

View File

@@ -58,7 +58,7 @@ services:
interval: 15s
timeout: 5s
retries: 6
start_interval: 5s
start_period: 5s
volumes:
database_data:

View File

@@ -547,10 +547,22 @@ app.get('/api/v1/nodes/:nodeId/traceroutes', async (req, res) => {
return;
}
// get latest traceroutes
// get latest traceroutes, deduplicated by packet_id
// We want replies where want_response is false and it will be "to" the
// requester.
const traceroutes = await prisma.$queryRaw`SELECT * FROM traceroutes WHERE want_response = false and \`to\` = ${node.node_id} and gateway_id is not null order by id desc limit ${count}`;
// Deduplicate by packet_id, keeping the latest traceroute (highest id) for each packet_id
const traceroutes = await prisma.$queryRaw`
SELECT t1.*
FROM traceroutes t1
INNER JOIN (
SELECT packet_id, MAX(id) as max_id
FROM traceroutes
WHERE want_response = false and \`to\` = ${node.node_id} and gateway_id is not null
GROUP BY packet_id
) t2 ON t1.packet_id = t2.packet_id AND t1.id = t2.max_id
ORDER BY t1.id DESC
LIMIT ${count}
`;
res.json({
traceroutes: traceroutes.map((trace) => {

View File

@@ -1096,7 +1096,7 @@ client.on("message", async (topic, message) => {
// Extract edges from neighbour info
try {
const fromNodeId = envelope.packet.from;
const toNodeId = envelope.packet.from;
const neighbors = neighbourInfo.neighbors || [];
const packetId = envelope.packet.id;
const channelId = envelope.channelId;
@@ -1115,7 +1115,7 @@ client.on("message", async (topic, message) => {
continue;
}
const toNodeId = neighbour.nodeId;
const fromNodeId = neighbour.nodeId;
const snr = neighbour.snr;
// Fetch node positions from Node table

View File

@@ -1303,29 +1303,6 @@
</select>
</div>
<!-- configNodesDisconnectedAgeInSeconds -->
<div class="p-2">
<label class="block text-sm font-medium text-gray-900">Nodes Disconnected Age</label>
<div class="text-xs text-gray-600 mb-2">Nodes that have not uplinked to MQTT in this time will show as blue icons. Reload to update map.</div>
<select v-model="configNodesDisconnectedAgeInSeconds" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
<option value="900">15 minutes</option>
<option value="1800">30 minutes</option>
<option value="2700">45 minutes</option>
<option value="3600">1 hour</option>
<option value="7200">2 hours</option>
<option value="10800">3 hours</option>
<option value="21600">6 hours</option>
<option value="43200">12 hours</option>
<option value="86400">24 hours</option>
<option value="172800">2 days</option>
<option value="259200">3 days</option>
<option value="345600">4 days</option>
<option value="432000">5 days</option>
<option value="518400">6 days</option>
<option value="604800">7 days</option>
</select>
</div>
<!-- configNodesOfflineAgeInSeconds -->
<div class="p-2">
<label class="block text-sm font-medium text-gray-900">Nodes Offline Age</label>
@@ -1374,8 +1351,8 @@
<!-- configConnectionsTimePeriodInSeconds -->
<div class="p-2">
<label class="block text-sm font-medium text-gray-900">Connections Time Period</label>
<div class="text-xs text-gray-600 mb-2">Edges within this time period are shown in the Connections layer. Reload to update map.</div>
<label class="block text-sm font-medium text-gray-900">Connections Max Age</label>
<div class="text-xs text-gray-600 mb-2">Edges from traceroutes and neighbour info within this time period are shown in the Connections layer. Reload to update map.</div>
<select v-model="configConnectionsTimePeriodInSeconds" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
<option value="300">5 minutes</option>
<option value="900">15 minutes</option>
@@ -1402,6 +1379,31 @@
<div class="text-xs text-gray-600">Colors the connection lines by the average SNR in the worst direction. Reload to update map.</div>
</div>
<!-- configConnectionsBidirectionalOnly -->
<div class="p-2">
<div class="flex items-start">
<div class="flex items-center h-5">
<input type="checkbox" v-model="configConnectionsBidirectionalOnly" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300" required>
</div>
<label class="ml-2 text-sm font-medium text-gray-900">Bidirectional Connections Only</label>
</div>
<div class="text-xs text-gray-600">Only show connections where data flows in both directions. Reload to update map.</div>
</div>
<!-- configConnectionsMinSnrDb -->
<div class="p-2">
<label class="block text-sm font-medium text-gray-900">Connections Minimum SNR (dB)</label>
<div class="text-xs text-gray-600 mb-2">Only show connections where at least one direction has SNR above this threshold. Leave empty to show all connections. Reload to update map.</div>
<input type="number" v-model="configConnectionsMinSnrDb" placeholder="e.g. -10" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
<div class="mt-2 flex items-start">
<div class="flex items-center h-5">
<input type="checkbox" v-model="configConnectionsBidirectionalMinSnr" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300" required>
</div>
<label class="ml-2 text-sm font-medium text-gray-900">Bidirectional Minimum SNR</label>
</div>
<div class="text-xs text-gray-600 ml-6">If checked, all existing directions must meet the minimum SNR threshold (both directions if bidirectional, single direction if unidirectional).</div>
</div>
<!-- configConnectionsMaxDistanceInMeters -->
<div class="p-2">
<label class="block text-sm font-medium text-gray-900">Connections Max Distance (meters)</label>
@@ -1657,20 +1659,6 @@
}
}
function getConfigNodesDisconnectedAgeInSeconds() {
// default to showing nodes as recently uplinked if heard in the last 30 minutes
const value = localStorage.getItem("config_nodes_disconnected_age_in_seconds");
return value != null ? parseInt(value) : 1800;
}
function setConfigNodesDisconnectedAgeInSeconds(value) {
if(value != null){
return localStorage.setItem("config_nodes_disconnected_age_in_seconds", value);
} else {
return localStorage.removeItem("config_nodes_disconnected_age_in_seconds");
}
}
function getConfigNodesOfflineAgeInSeconds() {
const value = localStorage.getItem("config_nodes_offline_age_in_seconds");
return value != null ? parseInt(value) : null;
@@ -1719,8 +1707,8 @@
function getConfigConnectionsTimePeriodInSeconds() {
const value = localStorage.getItem("config_connections_time_period_in_seconds");
// default to 24 hours if unset
return value != null ? parseInt(value) : 86400;
// default to 7 days if unset
return value != null ? parseInt(value) : 604800;
}
function setConfigConnectionsTimePeriodInSeconds(value) {
@@ -1740,6 +1728,51 @@
return localStorage.setItem("config_connections_colored_lines", value);
}
function getConfigConnectionsBidirectionalOnly() {
const value = localStorage.getItem("config_connections_bidirectional_only");
// disable bidirectional filter by default
if(value === null){
return false;
}
return value === "true";
}
function setConfigConnectionsBidirectionalOnly(value) {
return localStorage.setItem("config_connections_bidirectional_only", value);
}
function getConfigConnectionsMinSnrDb() {
const value = localStorage.getItem("config_connections_min_snr_db");
// default to null (unset)
if(value === null || value === ""){
return null;
}
const parsed = parseFloat(value);
return isNaN(parsed) ? null : parsed;
}
function setConfigConnectionsMinSnrDb(value) {
if(value === null || value === "" || value === undefined){
return localStorage.removeItem("config_connections_min_snr_db");
}
// Convert to string for localStorage (handles both number and string inputs)
const stringValue = typeof value === "number" ? value.toString() : String(value);
return localStorage.setItem("config_connections_min_snr_db", stringValue);
}
function getConfigConnectionsBidirectionalMinSnr() {
const value = localStorage.getItem("config_connections_bidirectional_min_snr");
// disable bidirectional minimum SNR by default
if(value === null){
return false;
}
return value === "true";
}
function setConfigConnectionsBidirectionalMinSnr(value) {
return localStorage.setItem("config_connections_bidirectional_min_snr", value);
}
function isMobile() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}
@@ -1754,7 +1787,6 @@
isShowingAnnouncement: this.shouldShowAnnouncement(),
configNodesMaxAgeInSeconds: window.getConfigNodesMaxAgeInSeconds(),
configNodesDisconnectedAgeInSeconds: window.getConfigNodesDisconnectedAgeInSeconds(),
configNodesOfflineAgeInSeconds: window.getConfigNodesOfflineAgeInSeconds(),
configWaypointsMaxAgeInSeconds: window.getConfigWaypointsMaxAgeInSeconds(),
configConnectionsMaxDistanceInMeters: window.getConfigConnectionsMaxDistanceInMeters(),
@@ -1764,6 +1796,9 @@
configTemperatureFormat: window.getConfigTemperatureFormat(),
configConnectionsTimePeriodInSeconds: window.getConfigConnectionsTimePeriodInSeconds(),
configConnectionsColoredLines: window.getConfigConnectionsColoredLines(),
configConnectionsBidirectionalOnly: window.getConfigConnectionsBidirectionalOnly(),
configConnectionsMinSnrDb: window.getConfigConnectionsMinSnrDb(),
configConnectionsBidirectionalMinSnr: window.getConfigConnectionsBidirectionalMinSnr(),
isShowingHardwareModels: false,
hardwareModelStats: null,
@@ -2654,9 +2689,6 @@
configNodesMaxAgeInSeconds() {
window.setConfigNodesMaxAgeInSeconds(this.configNodesMaxAgeInSeconds);
},
configNodesDisconnectedAgeInSeconds() {
window.setConfigNodesDisconnectedAgeInSeconds(this.configNodesDisconnectedAgeInSeconds);
},
configNodesOfflineAgeInSeconds() {
window.setConfigNodesOfflineAgeInSeconds(this.configNodesOfflineAgeInSeconds);
},
@@ -2684,6 +2716,15 @@
configConnectionsColoredLines() {
window.setConfigConnectionsColoredLines(this.configConnectionsColoredLines);
},
configConnectionsBidirectionalOnly() {
window.setConfigConnectionsBidirectionalOnly(this.configConnectionsBidirectionalOnly);
},
configConnectionsMinSnrDb() {
window.setConfigConnectionsMinSnrDb(this.configConnectionsMinSnrDb);
},
configConnectionsBidirectionalMinSnr() {
window.setConfigConnectionsBidirectionalMinSnr(this.configConnectionsBidirectionalMinSnr);
},
deviceMetricsTimeRange() {
this.loadNodeDeviceMetrics(this.selectedNode.node_id);
},
@@ -2867,7 +2908,7 @@
},
"Overlays": {
"Legend": legendLayerGroup,
"Backbone Connection": backboneConnectionsLayerGroup,
"Backbone Connections": backboneConnectionsLayerGroup,
"Connections": connectionsLayerGroup,
"Waypoints": waypointsLayerGroup,
"Position History": nodePositionHistoryLayerGroup,
@@ -3183,7 +3224,7 @@
}
function getTerrainProfileImage(node1, node2) {
function getTerrainProfileImage(url,node1, node2) {
// line colour between nodes
const lineColour = "0000FF"; // blue
@@ -3201,7 +3242,7 @@
const node2ElevationMSL = node2.altitude ?? "";
// generate terrain profile image url
return "https://heywhatsthat.com/bin/profile-0904.cgi?" + new URLSearchParams({
return url + new URLSearchParams({
src: "meshtastic.liamcottle.net",
axes: 1, // include grid lines and a scale
metric: 1, // show metric units
@@ -3255,6 +3296,48 @@
if (!otherNode || !otherNodeMarker) continue;
// Apply bidirectional filter
const configConnectionsBidirectionalOnly = getConfigConnectionsBidirectionalOnly();
if(configConnectionsBidirectionalOnly){
const hasDirectionAB = connection.direction_ab && connection.direction_ab.avg_snr_db != null;
const hasDirectionBA = connection.direction_ba && connection.direction_ba.avg_snr_db != null;
if(!hasDirectionAB || !hasDirectionBA){
continue;
}
}
// Apply minimum SNR filter
const configConnectionsMinSnrDb = getConfigConnectionsMinSnrDb();
if(configConnectionsMinSnrDb != null){
const snrAB = connection.direction_ab && connection.direction_ab.avg_snr_db != null ? connection.direction_ab.avg_snr_db : null;
const snrBA = connection.direction_ba && connection.direction_ba.avg_snr_db != null ? connection.direction_ba.avg_snr_db : null;
const configConnectionsBidirectionalMinSnr = getConfigConnectionsBidirectionalMinSnr();
let hasSnrAboveThreshold;
if(configConnectionsBidirectionalMinSnr){
// Bidirectional mode: ALL existing directions must meet threshold
const directionsToCheck = [];
if(snrAB != null) directionsToCheck.push(snrAB);
if(snrBA != null) directionsToCheck.push(snrBA);
if(directionsToCheck.length === 0){
// No SNR data in either direction, skip
hasSnrAboveThreshold = false;
} else {
// All existing directions must be above threshold
hasSnrAboveThreshold = directionsToCheck.every(snr => snr > configConnectionsMinSnrDb);
}
} else {
// Default mode: EITHER direction has SNR above threshold
hasSnrAboveThreshold = (snrAB != null && snrAB > configConnectionsMinSnrDb) || (snrBA != null && snrBA > configConnectionsMinSnrDb);
}
if(!hasSnrAboveThreshold){
continue;
}
}
// Calculate distance
const distanceInMeters = nodeMarker.getLatLng().distanceTo(otherNodeMarker.getLatLng()).toFixed(2);
const configConnectionsMaxDistanceInMeters = getConfigConnectionsMaxDistanceInMeters();
@@ -3340,13 +3423,6 @@
return string.replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// determine if node was recently heard uplinking packets to mqtt
function hasNodeUplinkedToMqttRecently(node) {
const now = moment();
const configNodesDisconnectedAgeInSeconds = getConfigNodesDisconnectedAgeInSeconds();
const millisecondsSinceNodeLastUplinkedToMqtt = now.diff(moment(node.mqtt_connection_state_updated_at));
return millisecondsSinceNodeLastUplinkedToMqtt < configNodesDisconnectedAgeInSeconds * 1000;
}
function onNodesUpdated(updatedNodes) {
@@ -3416,12 +3492,6 @@
zIndexOffset = -1000;
}
// determine if node was recently heard uplinking packets to mqtt
//const nodeHasUplinkedToMqttRecently = hasNodeUplinkedToMqttRecently(node);
//if(nodeHasUplinkedToMqttRecently){
// icon = iconMqttConnected;
//}
// To not have overlapping nodes.
var latJitter = 0;
var lonJitter = 0;
@@ -3636,9 +3706,13 @@
}
// Add terrain profile image
const terrainImageUrl = getTerrainProfileImage(nodeA, nodeB);
tooltip += `<br/><br/>Terrain images from <a href="http://www.heywhatsthat.com" target="_blank">HeyWhatsThat.com</a>`;
const terrainImageUrl = getTerrainProfileImage("https://heywhatsthat.com/bin/profile-0904.cgi?", nodeA, nodeB);
tooltip += `<br/><br/>Terrain image from <a href="http://www.heywhatsthat.com" target="_blank">HeyWhatsThat.com</a>`;
tooltip += `<br/><a href="${terrainImageUrl}" target="_blank"><img src="${terrainImageUrl}" width="100%"></a>`;
const terrainImageUrl2 = getTerrainProfileImage("https://tjenavadärdetdär.se/?", nodeA, nodeB);
tooltip += `<br/>Terrain image from <a href="https://tjenavadärdetdär.se" target="_blank">TjenaVadÄrDetDär.se</a>`;
tooltip += `<br/><a href="${terrainImageUrl2}" target="_blank"><img src="${terrainImageUrl2}" width="100%"></a>`;
return tooltip;
}
@@ -3665,6 +3739,48 @@
continue;
}
// Apply bidirectional filter
const configConnectionsBidirectionalOnly = getConfigConnectionsBidirectionalOnly();
if(configConnectionsBidirectionalOnly){
const hasDirectionAB = connection.direction_ab && connection.direction_ab.avg_snr_db != null;
const hasDirectionBA = connection.direction_ba && connection.direction_ba.avg_snr_db != null;
if(!hasDirectionAB || !hasDirectionBA){
continue;
}
}
// Apply minimum SNR filter
const configConnectionsMinSnrDb = getConfigConnectionsMinSnrDb();
if(configConnectionsMinSnrDb != null){
const snrAB = connection.direction_ab && connection.direction_ab.avg_snr_db != null ? connection.direction_ab.avg_snr_db : null;
const snrBA = connection.direction_ba && connection.direction_ba.avg_snr_db != null ? connection.direction_ba.avg_snr_db : null;
const configConnectionsBidirectionalMinSnr = getConfigConnectionsBidirectionalMinSnr();
let hasSnrAboveThreshold;
if(configConnectionsBidirectionalMinSnr){
// Bidirectional mode: ALL existing directions must meet threshold
const directionsToCheck = [];
if(snrAB != null) directionsToCheck.push(snrAB);
if(snrBA != null) directionsToCheck.push(snrBA);
if(directionsToCheck.length === 0){
// No SNR data in either direction, skip
hasSnrAboveThreshold = false;
} else {
// All existing directions must be above threshold
hasSnrAboveThreshold = directionsToCheck.every(snr => snr > configConnectionsMinSnrDb);
}
} else {
// Default mode: EITHER direction has SNR above threshold
hasSnrAboveThreshold = (snrAB != null && snrAB > configConnectionsMinSnrDb) || (snrBA != null && snrBA > configConnectionsMinSnrDb);
}
if(!hasSnrAboveThreshold){
continue;
}
}
// Calculate distance between nodes
const distanceInMeters = nodeAMarker.getLatLng().distanceTo(nodeBMarker.getLatLng()).toFixed(2);
@@ -3710,7 +3826,7 @@
event.target.closeTooltip();
});
// If both nodes are backbone nodes, also add to backbone layer group with arrows
// If both nodes are backbone nodes, also add to backbone layer group
if (nodeA.is_backbone && nodeB.is_backbone) {
const backboneLine = L.polyline([
nodeAMarker.getLatLng(),
@@ -3719,13 +3835,6 @@
color: lineColor,
opacity: 0.75,
weight: 3,
}).arrowheads({
size: '10px',
fill: true,
offsets: {
start: '25px',
end: '25px',
},
}).addTo(backboneConnectionsLayerGroup);
backboneLine.bindTooltip(tooltip, {
@@ -3784,7 +3893,7 @@
}
tooltip += `<br/>[${escapeString(node.short_name)}] ${escapeString(node.long_name)}`;
tooltip += `<br/>${positionHistory.latitude}, ${positionHistory.longitude}`;
tooltip += `<br/>Heard on: ${moment(new Date(positionHistory.created_at)).format("DD/MM/YYYY hh:mm A")}`;
tooltip += `<br/>Heard on: ${moment(new Date(positionHistory.created_at)).format("YYYY-MM-DD HH:mm")}`;
// add gateway info if available
if(positionHistory.gateway_id){
@@ -3967,24 +4076,11 @@
function getTooltipContentForNode(node) {
// determine if node was recently heard uplinking packets to mqtt
const nodeHasUplinkedToMqttRecently = hasNodeUplinkedToMqttRecently(node);
var mqttStatus = `<span class="text-blue-700">Disconnected</span>`;
if(node.mqtt_connection_state_updated_at){
var mqttStatusUpdatedAt = moment(new Date(node.mqtt_connection_state_updated_at)).fromNow();
if(nodeHasUplinkedToMqttRecently){
mqttStatus = `<span><span class="text-green-700">Connected</span> (${mqttStatusUpdatedAt})</span>`;
} else {
mqttStatus = `<span><span class="text-blue-700">Disconnected</span> (${mqttStatusUpdatedAt})</span>`;
}
}
var loraFrequencyRange = getRegionFrequencyRange(node.region_name);
var tooltip = `<img class="mb-4 w-40 mx-auto" src="/images/devices/${node.hardware_model_name}.png" onerror="this.classList.add('hidden')"/>` +
`<b>${escapeString(node.long_name)}</b>` +
`<br/>Short Name: ${escapeString(node.short_name)}` +
`<br/>MQTT: ${mqttStatus}` +
(node.num_online_local_nodes != null ? `<br/>Local Nodes Online: ${node.num_online_local_nodes}` : '') +
(node.position_precision != null && node.position_precision !== 32 ? `<br/>Position Precision: ${formatPositionPrecision(node.position_precision)}` : '') +
`<br/><br/>Role: ${node.role_name}` +
@@ -4021,6 +4117,7 @@
tooltip += `<br/><br/>ID: ${node.node_id}`;
tooltip += `<br/>Hex ID: ${node.node_id_hex}`;
tooltip += `<br/>Updated: ${moment(new Date(node.updated_at)).fromNow()}`;
tooltip += (node.mqtt_connection_state_updated_at ? `<br/>MQTT Updated: ${moment(new Date(node.mqtt_connection_state_updated_at)).fromNow()}` : '');
tooltip += (node.neighbours_updated_at ? `<br/>Neighbours Updated: ${moment(new Date(node.neighbours_updated_at)).fromNow()}` : '');
tooltip += (node.position_updated_at ? `<br/>Position Updated: ${moment(new Date(node.position_updated_at)).fromNow()}` : '');