mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-03-04 23:27:46 +01:00
357 lines
11 KiB
HTML
357 lines
11 KiB
HTML
{% extends "base.html" %}
|
|
{% block css %}
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
|
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
|
crossorigin=""/>
|
|
<style>
|
|
.legend {
|
|
background: white;
|
|
padding: 8px;
|
|
line-height: 1.5;
|
|
border-radius: 5px;
|
|
box-shadow: 0 0 10px rgba(0,0,0,0.3);
|
|
font-size: 14px;
|
|
color: black;
|
|
}
|
|
.legend i {
|
|
width: 12px;
|
|
height: 12px;
|
|
display: inline-block;
|
|
margin-right: 6px;
|
|
border-radius: 50%;
|
|
}
|
|
#filter-container {
|
|
text-align: center;
|
|
margin-top: 10px;
|
|
}
|
|
.filter-checkbox {
|
|
margin: 0 10px;
|
|
}
|
|
.blinking-tooltip {
|
|
background: white;
|
|
color: black;
|
|
border: 1px solid black;
|
|
border-radius: 4px;
|
|
padding: 2px 5px;
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block body %}
|
|
<div id="map" style="width: 100%; height: 600px;"></div>
|
|
|
|
<div id="filter-container">
|
|
<input type="checkbox" class="filter-checkbox" id="filter-routers-only"> Show Routers Only
|
|
</div>
|
|
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
|
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
|
crossorigin=""></script>
|
|
|
|
<script>
|
|
var map = L.map('map');
|
|
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
maxZoom: 19,
|
|
attribution: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
|
}).addTo(map);
|
|
|
|
var markers = {};
|
|
var markerById = {};
|
|
var nodes = [
|
|
{% for node in nodes %}
|
|
{
|
|
lat: {{ (node.last_lat / 10**7)|round(7) }},
|
|
long: {{ (node.last_long / 10**7)|round(7) if node.last_long is not none else "null" }},
|
|
long_name: {{ (node.long_name or "") | tojson }},
|
|
short_name: {{ (node.short_name or "") | tojson }},
|
|
channel: {{ (node.channel or "") | tojson }},
|
|
hw_model: {{ (node.hw_model or "") | tojson }},
|
|
role: {{ (node.role or "") | tojson }},
|
|
last_update: {{ node.last_update | default("", true) | tojson }},
|
|
firmware: {{ (node.firmware or "") | tojson }},
|
|
id: {{ (node.node_id or "") | tojson }},
|
|
isRouter: {{ 'true' if 'router' in (node.role or '').lower() else 'false' }}
|
|
}{{ "," if not loop.last else "" }}
|
|
{% endfor %}
|
|
];
|
|
|
|
// Helper maps
|
|
const portMap = {
|
|
1: "Text",
|
|
67: "Telemetry",
|
|
3: "Position",
|
|
70: "Traceroute",
|
|
4: "Node Info",
|
|
71: "Neighbour Info",
|
|
73: "Map Report"
|
|
};
|
|
|
|
function timeAgo(date) {
|
|
var now = Date.now();
|
|
var diff = now - new Date(date);
|
|
var seconds = Math.floor(diff / 1000);
|
|
var minutes = Math.floor(seconds / 60);
|
|
var hours = Math.floor(minutes / 60);
|
|
var days = Math.floor(hours / 24);
|
|
if (days > 0) return days + "d";
|
|
if (hours > 0) return hours + "h";
|
|
if (minutes > 0) return minutes + "m";
|
|
return seconds + "s";
|
|
}
|
|
|
|
// Color palette
|
|
const palette = [
|
|
"#e6194b","#4363d8","#f58231","#911eb4","#46f0f0","#f032e6","#bcf60c","#fabebe",
|
|
"#008080","#e6beff","#9a6324","#fffac8","#800000","#aaffc3","#808000","#ffd8b1","#000075","#808080"
|
|
];
|
|
const colorMap = new Map();
|
|
let nextColorIndex = 0;
|
|
function hashToColor(str) {
|
|
if (colorMap.has(str)) return colorMap.get(str);
|
|
const color = palette[nextColorIndex % palette.length];
|
|
colorMap.set(str, color);
|
|
nextColorIndex++;
|
|
return color;
|
|
}
|
|
|
|
// Create node map for fast lookup
|
|
const nodeMap = new Map();
|
|
nodes.forEach(n => nodeMap.set(n.id, n));
|
|
|
|
var bounds = L.latLngBounds();
|
|
var channels = new Set();
|
|
|
|
// --- Plot nodes ---
|
|
nodes.forEach(function(node) {
|
|
if (node.lat !== null && node.long !== null) {
|
|
let category = node.channel;
|
|
channels.add(category);
|
|
let color = hashToColor(category);
|
|
|
|
let markerOptions = {
|
|
radius: node.isRouter ? 9 : 7,
|
|
color: "white",
|
|
fillColor: color,
|
|
fillOpacity: 1,
|
|
weight: 0.7,
|
|
};
|
|
|
|
var popupContent = `
|
|
<b><a href="/packet_list/${node.id}">${node.long_name}</a> (${node.short_name})</b><br>
|
|
<b>Channel:</b> ${node.channel}<br>
|
|
<b>Model:</b> ${node.hw_model}<br>
|
|
<b>Role:</b> ${node.role}<br>
|
|
`;
|
|
if (node.last_update) popupContent += `<b>Last seen:</b> ${timeAgo(node.last_update)}<br>`;
|
|
if (node.firmware) popupContent += `<b>Firmware:</b> ${node.firmware}<br>`;
|
|
|
|
var marker = L.circleMarker([node.lat, node.long], markerOptions).addTo(map);
|
|
marker.nodeId = node.id;
|
|
marker.originalColor = color;
|
|
markerById[node.id] = marker;
|
|
|
|
marker.on('click', function() {
|
|
marker.bindPopup(popupContent).openPopup();
|
|
setTimeout(() => marker.closePopup(), 3000);
|
|
});
|
|
|
|
if (!markers[category]) markers[category] = [];
|
|
markers[category].push({ marker, isRouter: node.isRouter });
|
|
|
|
bounds.extend(marker.getLatLng());
|
|
}
|
|
});
|
|
|
|
// Fit map bounds
|
|
var bayAreaBounds = [
|
|
[{{ site_config["site"]["map_top_left_lat"] }}, {{ site_config["site"]["map_top_left_lon"] }}],
|
|
[{{ site_config["site"]["map_bottom_right_lat"] }}, {{ site_config["site"]["map_bottom_right_lon"] }}]
|
|
];
|
|
map.fitBounds(bayAreaBounds);
|
|
|
|
// --- Filters ---
|
|
let filterContainer = document.getElementById("filter-container");
|
|
channels.forEach(channel => {
|
|
let filterId = `filter-${channel.replace(/\s+/g, '-').toLowerCase()}`;
|
|
let color = hashToColor(channel);
|
|
let label = document.createElement('label');
|
|
label.style.color = color;
|
|
label.innerHTML = `<input type="checkbox" class="filter-checkbox" id="${filterId}" checked> ${channel}`;
|
|
filterContainer.appendChild(label);
|
|
});
|
|
|
|
function isMarkerVisible(marker) {
|
|
return map.hasLayer(marker);
|
|
}
|
|
|
|
function updateMarkers() {
|
|
let showRoutersOnly = document.getElementById("filter-routers-only").checked;
|
|
nodes.forEach(node => {
|
|
let category = node.channel;
|
|
let checkbox = document.getElementById(`filter-${category.replace(/\s+/g,'-').toLowerCase()}`);
|
|
let shouldShow = checkbox.checked && (!showRoutersOnly || node.isRouter);
|
|
let marker = markerById[node.id];
|
|
if (shouldShow) map.addLayer(marker);
|
|
else map.removeLayer(marker);
|
|
|
|
// Remove blinking tooltip if hidden
|
|
if (!shouldShow && marker.tooltip) {
|
|
map.removeLayer(marker.tooltip);
|
|
marker.tooltip = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
document.querySelectorAll(".filter-checkbox").forEach(input => {
|
|
input.addEventListener("change", updateMarkers);
|
|
});
|
|
|
|
// --- Edges ---
|
|
var edgeLayer = L.layerGroup().addTo(map);
|
|
var edgesData = null;
|
|
|
|
function loadEdges(callback) {
|
|
if (edgesData) callback(edgesData);
|
|
else {
|
|
fetch('/api/edges')
|
|
.then(res => res.json())
|
|
.then(data => { edgesData = data.edges; callback(edgesData); })
|
|
.catch(err => console.error("Error loading edges:", err));
|
|
}
|
|
}
|
|
|
|
function onNodeClick(node) {
|
|
loadEdges(edges => {
|
|
edgeLayer.clearLayers();
|
|
edges.forEach(edge => {
|
|
if (edge.from !== node.id && edge.to !== node.id) return;
|
|
let fromNode = nodeMap.get(edge.from);
|
|
let toNode = nodeMap.get(edge.to);
|
|
if (!fromNode || !toNode) return;
|
|
if (!fromNode.lat || !fromNode.long || !toNode.lat || !toNode.long) return;
|
|
|
|
let lineColor = edge.type === "neighbor" ? "red" : "blue";
|
|
let dash = edge.type === "traceroute" ? "5,5" : null;
|
|
|
|
L.polyline(
|
|
[[fromNode.lat, fromNode.long], [toNode.lat, toNode.long]],
|
|
{ color: lineColor, weight: 2, opacity: 1, dashArray: dash }
|
|
).addTo(edgeLayer);
|
|
});
|
|
});
|
|
}
|
|
|
|
nodes.forEach(node => {
|
|
let marker = markerById[node.id];
|
|
if (marker) marker.on('click', () => onNodeClick(node));
|
|
});
|
|
|
|
// --- Blinking nodes ---
|
|
var lastFetchTime = null;
|
|
const activeBlinks = new Map();
|
|
|
|
function fetchLatestPacket() {
|
|
fetch(`/api/packets?limit=1`)
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
if (data.packets && data.packets.length > 0) lastFetchTime = data.packets[0].import_time;
|
|
else lastFetchTime = new Date().toISOString();
|
|
})
|
|
.catch(err => console.error("Error fetching latest packet:", err));
|
|
}
|
|
|
|
function blinkNode(marker, longName, portnum) {
|
|
if (!isMarkerVisible(marker)) return; // skip if hidden
|
|
|
|
// Clear previous blink if any
|
|
if (activeBlinks.has(marker)) {
|
|
clearInterval(activeBlinks.get(marker));
|
|
marker.setStyle({ fillColor: marker.originalColor });
|
|
if (marker.tooltip) map.removeLayer(marker.tooltip);
|
|
}
|
|
|
|
let blinkCount = 0;
|
|
let portName = portMap[portnum] || `Port ${portnum}`;
|
|
|
|
let tooltip = L.tooltip({
|
|
permanent: true,
|
|
direction: 'top',
|
|
offset: [0, -marker.options.radius - 5],
|
|
className: 'blinking-tooltip'
|
|
}).setContent(`${longName} (${portName})`)
|
|
.setLatLng(marker.getLatLng());
|
|
tooltip.addTo(map);
|
|
marker.tooltip = tooltip;
|
|
|
|
let interval = setInterval(() => {
|
|
if (isMarkerVisible(marker)) {
|
|
marker.setStyle({ fillColor: blinkCount % 2 === 0 ? 'yellow' : marker.originalColor });
|
|
marker.bringToFront(); // <-- keeps blinking node on top
|
|
}
|
|
blinkCount++;
|
|
if (blinkCount > 7) {
|
|
clearInterval(interval);
|
|
marker.setStyle({ fillColor: marker.originalColor });
|
|
map.removeLayer(tooltip);
|
|
activeBlinks.delete(marker);
|
|
}
|
|
}, 500);
|
|
|
|
activeBlinks.set(marker, interval);
|
|
}
|
|
|
|
function fetchNewPackets() {
|
|
if (!lastFetchTime) return;
|
|
fetch(`/api/packets?since=${lastFetchTime}`)
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
if (!data.packets || data.packets.length === 0) return;
|
|
|
|
data.packets.forEach(packet => {
|
|
let marker = markerById[packet.from_node_id];
|
|
if (marker) {
|
|
let nodeData = nodeMap.get(packet.from_node_id);
|
|
if (nodeData) blinkNode(marker, nodeData.long_name, packet.portnum);
|
|
}
|
|
});
|
|
|
|
let latestPacket = data.packets[data.packets.length - 1];
|
|
if (latestPacket && latestPacket.import_time) lastFetchTime = latestPacket.import_time;
|
|
})
|
|
.catch(err => console.error("Error fetching packets:", err));
|
|
}
|
|
|
|
// --- Visibility-based polling control ---
|
|
let packetInterval = null;
|
|
|
|
function startPacketFetcher() {
|
|
if (!packetInterval) {
|
|
fetchLatestPacket();
|
|
packetInterval = setInterval(fetchNewPackets, 1000);
|
|
console.log("Packet fetching started");
|
|
}
|
|
}
|
|
|
|
function stopPacketFetcher() {
|
|
if (packetInterval) {
|
|
clearInterval(packetInterval);
|
|
packetInterval = null;
|
|
console.log("Packet fetching stopped");
|
|
}
|
|
}
|
|
|
|
document.addEventListener("visibilitychange", function() {
|
|
if (document.hidden) {
|
|
stopPacketFetcher();
|
|
} else {
|
|
startPacketFetcher();
|
|
}
|
|
});
|
|
|
|
// Init
|
|
fetchLatestPacket();
|
|
startPacketFetcher();
|
|
|
|
</script>
|
|
{% endblock %}
|