worked on making map and base all API driven

This commit is contained in:
Pablo Revilla
2025-10-17 12:54:44 -07:00
parent 8b0c7a16e7
commit 3cca445cad
3 changed files with 349 additions and 564 deletions
+63 -29
View File
@@ -1,10 +1,7 @@
<!doctype html>
<html lang="{{ site_config.get('general', {}).get('language', 'en') }}" data-bs-theme="dark">
<html lang="en" data-bs-theme="dark">
<head>
<title>
Meshview - {{ site_config.get("site", {}).get("title", "") }}
{% if node and node.short_name %}-- {{ node.short_name }}{% endif %}
</title>
<title>Meshview</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -29,27 +26,13 @@
</style>
</head>
<body>
{% set site = site_config.get("site", {}) %}
<br>
<div style="text-align:center">
<strong>{{ site.get("title", "") }} {{ site.get("domain", "") }}</strong>
</div>
<div style="text-align:center">
{{ site.get("message", "") }}
</div>
<!-- Dynamic Header -->
<div style="text-align:center" id="site-header"></div>
<div style="text-align:center" id="site-message"></div>
<!-- Menu -->
<div style="text-align:center">
{% if site.get("nodes") == "True" %}<a href="/nodelist" id="nav-nodes" data-translate-lang="nodes">Nodes</a>{% endif %}
{% if site.get("conversations") == "True" %}&nbsp;-&nbsp;<a href="/chat" id="nav-conversations" data-translate-lang="conversations">Conversations</a>{% endif %}
{% if site.get("everything") == "True" %}&nbsp;-&nbsp;<a href="/firehose" id="nav-everything" data-translate-lang="everything">See Everything</a>{% endif %}
{% if site.get("graphs") == "True" %}&nbsp;-&nbsp;<a href="/nodegraph" id="nav-graph" data-translate-lang="graph">Mesh Graphs</a>{% endif %}
{% if site.get("net") == "True" %}&nbsp;-&nbsp;<a href="/net" id="nav-net" data-translate-lang="net">Weekly Net</a>{% endif %}
{% if site.get("map") == "True" %}&nbsp;-&nbsp;<a href="/map" id="nav-map" data-translate-lang="map">Live Map</a>{% endif %}
{% if site.get("stats") == "True" %}&nbsp;-&nbsp;<a href="/stats" id="nav-stats" data-translate-lang="stats">Stats</a>{% endif %}
{% if site.get("top") == "True" %}&nbsp;-&nbsp;<a href="/top" id="nav-top" data-translate-lang="top">Top Traffic Nodes</a>{% endif %}
</div>
<!-- Dynamic Menu -->
<div style="text-align:center" id="site-menu"></div>
<!-- Search Form -->
<form class="container p-2 sticky-top mx-auto" id="search_form" action="/node_search">
@@ -75,7 +58,7 @@
</datalist>
<select name="portnum" class="col-2 m-2" id="portnum_select">
<!-- Options will be populated dynamically -->
<!-- Options populated dynamically -->
</select>
<input type="submit" value="Go to Node" class="col-2 m-2" data-translate-lang="go to node" />
@@ -85,15 +68,15 @@
{% block body %}{% endblock %}
<br>
<div style="text-align:center" id="footer" data-translate-lang="footer">
</div><div style="text-align:center"><div><small>ver. {{ SOFTWARE_RELEASE | default("unknown") }}</small></div>
<div style="text-align:center" id="footer" data-translate-lang="footer"></div>
<div style="text-align:center"><div><small id="site-version">ver. unknown</small></div>
<br>
<!-- Language Loader -->
<script>
async function loadTranslations() {
try {
const langCode = "{{ site_config.get('site', {}).get('language', 'en') }}";
const langCode = "en";
const res = await fetch(`/api/lang?lang=${langCode}&section=base`);
const t = await res.json();
@@ -132,8 +115,59 @@
console.error("Failed to load language:", err);
}
}
document.addEventListener("DOMContentLoaded", loadTranslations);
</script>
<!-- Dynamic Site Config Loader -->
<script>
async function loadSiteConfig() {
try {
const res = await fetch("/api/config");
const cfg = await res.json();
const site = cfg.site || {};
// Title
document.title = "Meshview - " + (site.title || "");
// Header
const header = document.getElementById("site-header");
if (header) {
header.innerHTML = `<strong>${site.title || ""} ${site.domain ? "(" + site.domain + ")" : ""}</strong>`;
}
// Message
const msg = document.getElementById("site-message");
if (msg) {
msg.textContent = site.message || "";
}
// Menu
const menu = document.getElementById("site-menu");
if (menu) {
let html = "";
if (site.nodes === "true") html += `<a href="/nodelist">Nodes</a>`;
if (site.conversations === "true") html += `&nbsp;-&nbsp;<a href="/chat">Conversations</a>`;
if (site.everything === "true") html += `&nbsp;-&nbsp;<a href="/firehose">See Everything</a>`;
if (site.graphs === "true") html += `&nbsp;-&nbsp;<a href="/nodegraph">Mesh Graphs</a>`;
if (site.net === "true") html += `&nbsp;-&nbsp;<a href="/net">Weekly Net</a>`;
if (site.map === "true") html += `&nbsp;-&nbsp;<a href="/map">Live Map</a>`;
if (site.stats === "true") html += `&nbsp;-&nbsp;<a href="/stats">Stats</a>`;
if (site.top === "true") html += `&nbsp;-&nbsp;<a href="/top">Top Traffic Nodes</a>`;
menu.innerHTML = html;
}
// Version
const verEl = document.getElementById("site-version");
if (verEl) {
verEl.textContent = "ver. " + (site.version || "unknown");
}
} catch (err) {
console.error("Failed to load site config:", err);
}
}
document.addEventListener("DOMContentLoaded", loadSiteConfig);
</script>
</body>
</html>
+178 -472
View File
@@ -1,80 +1,33 @@
{% 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;
}
#share-button {
margin-left: 20px;
padding: 5px 15px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
#share-button:hover {
background-color: #45a049;
}
#share-button:active {
background-color: #3d8b40;
}
#reset-filters-button {
margin-left: 10px;
padding: 5px 15px;
background-color: #f44336;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
#reset-filters-button:hover {
background-color: #da190b;
}
#reset-filters-button:active {
background-color: #c41e0d;
}
.blinking-tooltip {
background: white;
color: black;
border: 1px solid black;
border-radius: 4px;
padding: 2px 5px;
.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; }
#share-button, #reset-filters-button {
padding:5px 15px;border:none;border-radius:4px;font-size:14px;cursor:pointer;color:white;
}
#share-button { margin-left:20px; background-color:#4CAF50; }
#share-button:hover { background-color:#45a049; }
#share-button:active { background-color:#3d8b40; }
#reset-filters-button { margin-left:10px; background-color:#f44336; }
#reset-filters-button:hover { background-color:#da190b; }
#reset-filters-button:active { background-color:#c41e0d; }
.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: calc(100vh - 270px)"></div>
<div id="map" style="width:100%;height:calc(100vh - 270px)"></div>
<div id="filter-container">
<input type="checkbox" class="filter-checkbox" id="filter-routers-only"> Show Routers Only
</div>
<div style="text-align: center; margin-top: 5px;">
<div style="text-align:center;margin-top:5px;">
<button id="share-button" onclick="shareCurrentView()">🔗 Share This View</button>
<button id="reset-filters-button" onclick="resetFiltersToDefaults()">↺ Reset Filters To Defaults</button>
</div>
@@ -87,438 +40,191 @@
crossorigin></script>
<script>
// ---- Map Setup ----
var map = L.map('map');
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);
// ---------------------- Map Initialization ----------------------
var map = L.map('map');
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom:19, attribution:'&copy; OpenStreetMap' }).addTo(map);
// Custom view from URL parameters
{% if custom_view %}
var customView = {
lat: {{ custom_view.lat }},
lng: {{ custom_view.lng }},
zoom: {{ custom_view.zoom }}
};
{% else %}
var customView = null;
{% endif %}
// ---------------------- Globals ----------------------
var nodes=[], markers={}, markerById={}, nodeMap = new Map();
var edgesData=[], edgeLayer = L.layerGroup().addTo(map), selectedNodeId = null;
var activeBlinks = new Map(), lastImportTime = null;
var mapInterval = 3;
const portMap = {1:"Text",67:"Telemetry",3:"Position",70:"Traceroute",4:"Node Info",71:"Neighbour Info",73:"Map Report"};
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;
const channelSet = new Set();
// ---- Node Data ----
var markers = {};
var markerById = {};
var nodes = [
{% for node in nodes %}
{
lat: {{ ((node.last_lat / 10**7) + (range(-9,9) | random) / 30000) | round(7) }},
long: {{ ((node.last_long / 10**7) + (range(-9,9) | random) / 30000) | 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 %}
];
// ---------------------- Helpers ----------------------
function timeAgo(date){ const diff=Date.now()-new Date(date), s=Math.floor(diff/1000), m=Math.floor(s/60), h=Math.floor(m/60), d=Math.floor(h/24); return d>0?d+"d":h>0?h+"h":m>0?m+"m":s+"s"; }
function hashToColor(str){ if(colorMap.has(str)) return colorMap.get(str); const c=palette[nextColorIndex++%palette.length]; colorMap.set(str,c); return c; }
function isInvalidCoord(n){ return !n||!n.lat||!n.long||n.lat===0||n.long===0||Number.isNaN(n.lat)||Number.isNaN(n.long); }
const portMap = {1: "Text", 67: "Telemetry", 3: "Position", 70: "Traceroute", 4: "Node Info", 71: "Neighbour Info", 73: "Map Report"};
function timeAgo(date) {
const now = Date.now();
const diff = now - new Date(date);
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const 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";
// ---------------------- Load Config ----------------------
fetch('/api/config').then(r=>r.json()).then(cfg=>{
const site = cfg.site || {};
mapInterval = parseInt(site.map_interval||3,10);
const topLeft = [parseFloat(site.map_top_left_lat), parseFloat(site.map_top_left_lon)];
const bottomRight = [parseFloat(site.map_bottom_right_lat), parseFloat(site.map_bottom_right_lon)];
if(topLeft && bottomRight){
map.fitBounds([topLeft,bottomRight]);
window.configBoundsApplied = true; // prevent nodes from overriding
setTimeout(()=>map.invalidateSize(),100);
}
}).catch(console.error);
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;
// ---------------------- Load Nodes + Edges ----------------------
fetch('/api/nodes?days_active=3').then(r=>r.json()).then(data=>{
if(!data.nodes) return;
nodes = data.nodes.map(n=>({
key: n.node_id!==null?n.node_id:n.id,
id: n.id,
node_id: n.node_id,
lat: n.last_lat?n.last_lat/1e7:null,
long: n.last_long?n.last_long/1e7:null,
long_name: n.long_name||"",
short_name: n.short_name||"",
channel: n.channel||"",
hw_model: n.hw_model||"",
role: n.role||"",
firmware: n.firmware||"",
last_update: n.last_update||"",
isRouter: n.role? n.role.toLowerCase().includes("router"):false
}));
nodes.forEach(n=>{ nodeMap.set(n.key,n); if(n.channel) channelSet.add(n.channel); });
renderNodesOnMap();
createChannelFilters();
return fetch('/api/edges');
}).then(r=>r?r.json():null).then(data=>{
if(data && data.edges) edgesData=data.edges;
if(mapInterval>0) startPacketFetcher();
}).catch(console.error);
function hashToColor(str) {
if (colorMap.has(str)) return colorMap.get(str);
const color = palette[nextColorIndex % palette.length];
colorMap.set(str, color);
nextColorIndex++;
return color;
}
const nodeMap = new Map();
nodes.forEach(n => nodeMap.set(n.id, n));
function isInvalidCoord(node) {
if (!node) return true;
let {lat, long} = node;
return !lat || !long || lat === 0 || long === 0 || Number.isNaN(lat) || Number.isNaN(long);
}
// ---- Marker Plotting ----
var bounds = L.latLngBounds();
var channels = new Set();
nodes.forEach(node => {
if (!isInvalidCoord(node)) {
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 };
let 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(e) {
e.originalEvent.stopPropagation();
marker.bindPopup(popupContent).openPopup();
setTimeout(() => marker.closePopup(), 3000);
onNodeClick(node);
});
if (!markers[category]) markers[category] = [];
markers[category].push({ marker, isRouter: node.isRouter });
bounds.extend(marker.getLatLng());
}
// ---------------------- Render Nodes ----------------------
function renderNodesOnMap(){
const bounds = L.latLngBounds();
nodes.forEach(node=>{
if(isInvalidCoord(node)) return;
const color = hashToColor(node.channel);
const opts = { radius: node.isRouter?9:7, color:"white", fillColor:color, fillOpacity:1, weight:0.7 };
const marker = L.circleMarker([node.lat,node.long],opts).addTo(map);
marker.nodeId = node.key;
marker.originalColor = color;
markerById[node.key] = marker;
const popup = `<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>
${node.last_update? `<b>Last seen:</b> ${timeAgo(node.last_update)}<br>`:""}
${node.firmware? `<b>Firmware:</b> ${node.firmware}<br>`:""}`;
marker.on('click',()=>{ onNodeClick(node); marker.bindPopup(popup).openPopup(); setTimeout(()=>marker.closePopup(),3000); });
bounds.extend(marker.getLatLng());
});
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"] }}]
];
// Apply custom view or default bounds
if (customView) {
map.setView([customView.lat, customView.lng], customView.zoom);
} else {
map.fitBounds(bayAreaBounds);
// Only fit to nodes if no config bounds were applied
if(!window.configBoundsApplied && bounds.isValid()){
map.fitBounds(bounds);
setTimeout(()=>map.invalidateSize(),100);
}
}
// ---- LocalStorage for Filter Preferences ----
const FILTER_STORAGE_KEY = 'meshview_map_filters';
function getDefaultFilters() {
return {
routersOnly: false,
channels: {}
};
}
function saveFiltersToLocalStorage() {
const filters = {
routersOnly: document.getElementById("filter-routers-only").checked,
channels: {}
};
channels.forEach(channel => {
let filterId = `filter-${channel.replace(/\s+/g, '-').toLowerCase()}`;
let checkbox = document.getElementById(filterId);
if (checkbox) {
filters.channels[channel] = checkbox.checked;
}
});
localStorage.setItem(FILTER_STORAGE_KEY, JSON.stringify(filters));
console.log('Filters saved to localStorage:', filters);
}
function loadFiltersFromLocalStorage() {
try {
const stored = localStorage.getItem(FILTER_STORAGE_KEY);
if (stored) {
const filters = JSON.parse(stored);
console.log('Filters loaded from localStorage:', filters);
return filters;
}
} catch (error) {
console.error('Error loading filters from localStorage:', error);
// ---------------------- Render Edges ----------------------
function onNodeClick(node){
selectedNodeId = node.key;
edgeLayer.clearLayers();
edgesData.forEach(edge=>{
if(edge.from!==node.key && edge.to!==node.key) return;
const f=nodeMap.get(edge.from), t=nodeMap.get(edge.to);
if(!f||!t||isInvalidCoord(f)||isInvalidCoord(t)) return;
const color=edge.type==="neighbor"?"gray":"orange";
const l=L.polyline([[f.lat,f.long],[t.lat,t.long]],{color,weight:3}).addTo(edgeLayer);
if(edge.type==="traceroute"){
L.polylineDecorator(l,{patterns:[{offset:'100%',repeat:0,symbol:L.Symbol.arrowHead({pixelSize:5,polygon:false,pathOptions:{stroke:true,color}})}]}).addTo(edgeLayer);
}
return null;
}
});
}
map.on('click',e=>{ if(!e.originalEvent.target.classList.contains('leaflet-interactive')){ edgeLayer.clearLayers(); selectedNodeId=null; } });
function resetFiltersToDefaults() {
localStorage.removeItem(FILTER_STORAGE_KEY);
console.log('Filters reset to defaults');
// ---------------------- Packet Blinking ----------------------
function blinkNode(marker,longName,portnum){
if(!map.hasLayer(marker)) return;
if(activeBlinks.has(marker)){ clearInterval(activeBlinks.get(marker)); marker.setStyle({fillColor:marker.originalColor}); if(marker.tooltip) map.removeLayer(marker.tooltip); }
let blinkCount=0;
const portName = portMap[portnum]||`Port ${portnum}`;
const tooltip = L.tooltip({permanent:true,direction:'top',offset:[0,-marker.options.radius-5],className:'blinking-tooltip'})
.setContent(`${longName} (${portName})`).setLatLng(marker.getLatLng()).addTo(map);
marker.tooltip = tooltip;
const interval = setInterval(()=>{
if(map.hasLayer(marker)){ marker.setStyle({fillColor: blinkCount%2===0?'yellow':marker.originalColor}); marker.bringToFront(); }
blinkCount++;
if(blinkCount>7){ clearInterval(interval); marker.setStyle({fillColor:marker.originalColor}); map.removeLayer(tooltip); activeBlinks.delete(marker); }
},500);
activeBlinks.set(marker,interval);
}
// Reset routers only filter
document.getElementById("filter-routers-only").checked = false;
// Reset all channel filters to checked (default)
channels.forEach(channel => {
let filterId = `filter-${channel.replace(/\s+/g, '-').toLowerCase()}`;
let checkbox = document.getElementById(filterId);
if (checkbox) {
checkbox.checked = true;
}
// ---------------------- Packet Fetching ----------------------
function fetchLatestPacket(){ fetch(`/api/packets?limit=1`).then(r=>r.json()).then(data=>{ lastImportTime=data.packets?.[0]?.import_time||new Date().toISOString(); }).catch(console.error); }
function fetchNewPackets(){
if(!lastImportTime) return;
fetch(`/api/packets?since=${encodeURIComponent(lastImportTime)}`).then(r=>r.json()).then(data=>{
if(!data.packets||data.packets.length===0) return;
let latest = lastImportTime;
data.packets.forEach(pkt=>{
if(pkt.import_time>latest) latest=pkt.import_time;
const marker = markerById[pkt.from_node_id];
const nodeData = nodeMap.get(pkt.from_node_id);
if(marker && nodeData) blinkNode(marker,nodeData.long_name,pkt.portnum);
});
lastImportTime=latest;
}).catch(console.error);
}
updateMarkers();
// ---------------------- Polling ----------------------
let packetInterval=null;
function startPacketFetcher(){ if(mapInterval<=0) return; if(!packetInterval){ fetchLatestPacket(); packetInterval=setInterval(fetchNewPackets,mapInterval*1000); } }
function stopPacketFetcher(){ if(packetInterval){ clearInterval(packetInterval); packetInterval=null; } }
document.addEventListener("visibilitychange",()=>{ document.hidden?stopPacketFetcher():startPacketFetcher(); });
// Show feedback to user
const button = document.getElementById('reset-filters-button');
const originalText = button.textContent;
button.textContent = '✓ Filters Reset!';
button.style.backgroundColor = '#2196F3';
// ---------------------- Channel Filters ----------------------
function createChannelFilters(){
const filterContainer = document.getElementById("filter-container");
channelSet.forEach(channel=>{
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.className = "filter-checkbox";
checkbox.id = `filter-channel-${channel}`;
checkbox.checked = true;
checkbox.addEventListener("change", updateNodeVisibility);
filterContainer.appendChild(checkbox);
setTimeout(() => {
button.textContent = originalText;
button.style.backgroundColor = '#f44336';
}, 2000);
}
// ---- 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}`;
const label = document.createElement("label");
label.htmlFor = checkbox.id;
label.innerText = channel;
// Set the label color to match the channel
label.style.color = hashToColor(channel);
filterContainer.appendChild(label);
});
// Load saved filters from localStorage
const savedFilters = loadFiltersFromLocalStorage();
if (savedFilters) {
// Apply routers only filter
document.getElementById("filter-routers-only").checked = savedFilters.routersOnly || false;
// Apply channel filters
channels.forEach(channel => {
let filterId = `filter-${channel.replace(/\s+/g, '-').toLowerCase()}`;
let checkbox = document.getElementById(filterId);
if (checkbox && savedFilters.channels.hasOwnProperty(channel)) {
checkbox.checked = savedFilters.channels[channel];
}
});
}
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 (marker) marker.setStyle({ fillOpacity: shouldShow ? 1 : 0 });
});
// Save filters to localStorage whenever they change
saveFiltersToLocalStorage();
}
document.querySelectorAll(".filter-checkbox").forEach(input => input.addEventListener("change", updateMarkers));
// Apply initial filters (from localStorage or defaults)
updateMarkers();
// ---- Edges ----
var edgeLayer = L.layerGroup().addTo(map);
var edgesData = null;
let selectedNodeId = null;
fetch('/api/edges').then(res => res.json()).then(data => { edgesData = data.edges; }).catch(err => console.error(err));
function onNodeClick(node) {
if (selectedNodeId != node.id) {
selectedNodeId = node.id;
edgeLayer.clearLayers();
if (!edgesData) return;
if (!map.hasLayer(edgeLayer)) edgeLayer.addTo(map);
edgesData.forEach(edge => {
if (edge.from !== node.id && edge.to !== node.id) return;
const fromNode = nodeMap.get(edge.from);
const toNode = nodeMap.get(edge.to);
if (!fromNode || !toNode) return;
if (isInvalidCoord(fromNode) || isInvalidCoord(toNode)) return;
const lineColor = edge.type === "neighbor" ? "gray" : "orange";
const weight = 3
const polyline = L.polyline(
[[fromNode.lat, fromNode.long], [toNode.lat, toNode.long]],
{ color: lineColor, weight, opacity: 1 }
).addTo(edgeLayer).bringToFront();
document.getElementById("filter-routers-only").addEventListener("change", updateNodeVisibility);
}
if (edge.type === "traceroute") {
L.polylineDecorator(polyline, {
patterns: [{ offset: '100%', repeat: 0, symbol: L.Symbol.arrowHead({ pixelSize: 5, polygon: false, pathOptions: { stroke: true, color: lineColor } }) }]
}).addTo(edgeLayer);
}
});
}
}
map.on('click', function(e) {
if (!e.originalEvent.target.classList.contains('leaflet-interactive')) {
edgeLayer.clearLayers();
selectedNodeId = null;
function updateNodeVisibility(){
const showRoutersOnly = document.getElementById("filter-routers-only").checked;
const activeChannels = Array.from(channelSet).filter(ch=>document.getElementById(`filter-channel-${ch}`).checked);
nodes.forEach(n=>{
const marker = markerById[n.key];
if(marker){
const visible = (!showRoutersOnly || n.isRouter) && activeChannels.includes(n.channel);
if(visible) map.addLayer(marker); else map.removeLayer(marker);
}
});
}
// ---- Blinking Nodes ----
var activeBlinks = new Map();
function blinkNode(marker, longName, portnum) {
if (!map.hasLayer(marker)) return;
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 (map.hasLayer(marker)) {
// Alternate color
marker.setStyle({ fillColor: blinkCount % 2 === 0 ? 'yellow' : marker.originalColor });
// Bring marker to top
marker.bringToFront();
}
blinkCount++;
if (blinkCount > 7) {
clearInterval(interval);
marker.setStyle({ fillColor: marker.originalColor });
map.removeLayer(tooltip);
activeBlinks.delete(marker);
}
}, 500);
activeBlinks.set(marker, interval);
}
// ---- Packet Fetching ----
let lastImportTime = null;
function fetchLatestPacket() {
fetch(`/api/packets?limit=1`)
.then(res => res.json())
.then(data => {
if (data.packets && data.packets.length > 0) {
lastImportTime = data.packets[0].import_time;
console.log("Initial lastImportTime:", lastImportTime);
} else {
lastImportTime = new Date().toISOString();
console.log("No packets, setting lastImportTime to now:", lastImportTime);
}
})
.catch(err => console.error("Error fetching latest packet:", err));
}
function fetchNewPackets() {
if (!lastImportTime) return;
fetch(`/api/packets?since=${lastImportTime}`)
.then(res => res.json())
.then(data => {
console.log("===== New Fetch =====");
if (!data.packets || data.packets.length === 0) {
console.log("No new packets");
return;
}
let latestSeen = lastImportTime;
data.packets.forEach(packet => {
console.log(`Packet ID: ${packet.id}, From Node: ${packet.from_node_id}, Port: ${packet.portnum}, Time: ${packet.import_time}`);
if (packet.import_time && (!latestSeen || packet.import_time > latestSeen)) latestSeen = packet.import_time;
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);
}
});
if (latestSeen) lastImportTime = latestSeen;
console.log("Updated lastImportTime:", lastImportTime);
console.log("===== End Fetch =====");
})
.catch(err => console.error("Fetch error:", err));
}
// ---- Polling Control ----
let packetInterval = null;
const mapInterval = {{ site_config["site"]["map_interval"] | default(3) }};
function startPacketFetcher() {
if (mapInterval <= 0) return;
if (!packetInterval) {
fetchLatestPacket();
packetInterval = setInterval(fetchNewPackets, mapInterval * 1000);
console.log("Packet fetcher started, interval:", mapInterval, "seconds");
}
}
function stopPacketFetcher() {
if (packetInterval) {
clearInterval(packetInterval);
packetInterval = null;
console.log("Packet fetcher stopped");
}
}
document.addEventListener("visibilitychange", function() {
if (document.hidden) stopPacketFetcher();
else startPacketFetcher();
});
// ---- Share Current View ----
function shareCurrentView() {
const center = map.getCenter();
const zoom = map.getZoom();
const lat = center.lat.toFixed(6);
const lng = center.lng.toFixed(6);
const shareUrl = `${window.location.origin}/map?lat=${lat}&lng=${lng}&zoom=${zoom}`;
// Copy to clipboard
navigator.clipboard.writeText(shareUrl).then(() => {
const button = document.getElementById('share-button');
const originalText = button.textContent;
button.textContent = '✓ Link Copied!';
button.style.backgroundColor = '#2196F3';
setTimeout(() => {
button.textContent = originalText;
button.style.backgroundColor = '#4CAF50';
}, 2000);
}).catch(err => {
// Fallback for older browsers
alert('Share this link:\n' + shareUrl);
});
}
// ---- Initialize ----
if (mapInterval > 0) startPacketFetcher();
// ---------------------- Share / Reset ----------------------
function shareCurrentView(){ alert("Sharing is not implemented yet."); }
function resetFiltersToDefaults(){
document.getElementById("filter-routers-only").checked = false;
channelSet.forEach(ch=>document.getElementById(`filter-channel-${ch}`).checked = true);
updateNodeVisibility();
}
</script>
{% endblock %}
+108 -63
View File
@@ -1159,16 +1159,6 @@ async def net(request):
@routes.get("/map")
async def map(request):
try:
nodes = await store.get_nodes(days_active=3)
# Filter out nodes with no latitude
nodes = [node for node in nodes if node.last_lat is not None]
# Optional datetime formatting
for node in nodes:
if hasattr(node, "last_update") and isinstance(node.last_update, datetime.datetime):
node.last_update = node.last_update.isoformat()
# Parse optional URL parameters for custom view
map_center_lat = request.query.get("lat")
map_center_lng = request.query.get("lng")
@@ -1190,19 +1180,18 @@ async def map(request):
return web.Response(
text=template.render(
nodes=nodes,
custom_view=custom_view,
site_config=CONFIG,
SOFTWARE_RELEASE=SOFTWARE_RELEASE,
custom_view=custom_view,
),
content_type="text/html",
)
except Exception as e:
return web.Response(
text="An error occurred while processing your request.",
status=500,
content_type="text/plain",
)
print(f"/map route error: {e}")
return web.Response(
text="An error occurred while processing your request.",
status=500,
content_type="text/plain",
)
@routes.get("/stats")
@@ -1406,31 +1395,6 @@ async def nodegraph(request):
content_type="text/html",
)
# Show basic details about the site on the site
@routes.get("/config")
async def get_config(request):
try:
site = CONFIG.get("site", {})
mqtt = CONFIG.get("mqtt", {})
return web.json_response(
{
"Server": site.get("domain", ""),
"Title": site.get("title", ""),
"Message": site.get("message", ""),
"MQTT Server": mqtt.get("server", ""),
"Topics": json.loads(mqtt.get("topics", "[]")),
"Release": SOFTWARE_RELEASE,
"Time": datetime.datetime.now().isoformat(),
},
dumps=lambda obj: json.dumps(obj, indent=2),
)
except (json.JSONDecodeError, TypeError):
return web.json_response({"error": "Invalid configuration format"}, status=500)
# API Section
#######################################################################
# How this works
@@ -1678,25 +1642,6 @@ async def api_stats(request):
return web.json_response(stats)
@routes.get("/api/config")
async def api_config(request):
try:
site = CONFIG.get("site", {})
safe_site = {
"map_interval": site.get("map_interval", 3),
"firehose_interval": site.get("firehose_interval", 3),
"map_top_left_lat": site.get("map_top_left_lat", 3),
"map_top_left_lon": site.get("map_top_left_lon", 3),
"map_bottom_right_lat": site.get("map_bottom_right_lat", 3),
"map_bottom_right_lon": site.get("map_bottom_right_lon", 3),
}
safe_config = {"site": safe_site}
return web.json_response(safe_config)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@routes.get("/api/edges")
async def api_edges(request):
since = datetime.datetime.now() - datetime.timedelta(hours=48)
@@ -1739,6 +1684,106 @@ async def api_edges(request):
return web.json_response({"edges": edges_list})
@routes.get("/api/config")
async def api_config(request):
try:
# ------------------ Helpers ------------------
def get(section, key, default=None):
"""Safe getter for both dict and ConfigParser."""
if isinstance(section, dict):
return section.get(key, default)
return section.get(key, fallback=default)
def get_bool(section, key, default=False):
val = get(section, key, default)
if isinstance(val, bool):
return "true" if val else "false"
if isinstance(val, str):
return "true" if val.lower() in ("1", "true", "yes", "on") else "false"
return "true" if bool(val) else "false"
def get_float(section, key, default=0.0):
try:
return float(get(section, key, default))
except Exception:
return float(default)
def get_int(section, key, default=0):
try:
return int(get(section, key, default))
except Exception:
return default
def get_str(section, key, default=""):
val = get(section, key, default)
return str(val) if val is not None else str(default)
# ------------------ SITE ------------------
site = CONFIG.get("site", {})
safe_site = {
"domain": get_str(site, "domain", ""),
"language": get_str(site, "language", "en"),
"title": get_str(site, "title", ""),
"message": get_str(site, "message", ""),
"starting": get_str(site, "starting", "/chat"),
"nodes": get_bool(site, "nodes", True),
"conversations": get_bool(site, "conversations", True),
"everything": get_bool(site, "everything", True),
"graphs": get_bool(site, "graphs", True),
"stats": get_bool(site, "stats", True),
"net": get_bool(site, "net", True),
"map": get_bool(site, "map", True),
"top": get_bool(site, "top", True),
"map_top_left_lat": get_float(site, "map_top_left_lat", 39.0),
"map_top_left_lon": get_float(site, "map_top_left_lon", -123.0),
"map_bottom_right_lat": get_float(site, "map_bottom_right_lat", 36.0),
"map_bottom_right_lon": get_float(site, "map_bottom_right_lon", -121.0),
"map_interval": get_int(site, "map_interval", 3),
"firehose_interval": get_int(site, "firehose_interval", 3),
"weekly_net_message": get_str(site, "weekly_net_message", "Weekly Mesh check-in message."),
"net_tag": get_str(site, "net_tag", "#BayMeshNet"),
"version": str(SOFTWARE_RELEASE),
}
# ------------------ MQTT ------------------
mqtt = CONFIG.get("mqtt", {})
topics_raw = get(mqtt, "topics", [])
import json
if isinstance(topics_raw, str):
try:
topics = json.loads(topics_raw)
except Exception:
topics = [topics_raw]
elif isinstance(topics_raw, list):
topics = topics_raw
else:
topics = []
safe_mqtt = {
"server": get_str(mqtt, "server", ""),
"topics": topics,
}
# ------------------ CLEANUP ------------------
cleanup = CONFIG.get("cleanup", {})
safe_cleanup = {
"enabled": get_bool(cleanup, "enabled", False),
"days_to_keep": get_str(cleanup, "days_to_keep", "14"),
"hour": get_str(cleanup, "hour", "2"),
"minute": get_str(cleanup, "minute", "0"),
"vacuum": get_bool(cleanup, "vacuum", False),
}
safe_config = {
"site": safe_site,
"mqtt": safe_mqtt,
"cleanup": safe_cleanup,
}
return web.json_response(safe_config)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@routes.get("/api/lang")
async def api_lang(request):