2 Commits

Author SHA1 Message Date
pablorevilla-meshtastic
f87831c3a5 small map change 2026-02-18 16:17:18 -08:00
pablorevilla-meshtastic
9c40ce2d59 add world meshview tag 2026-02-18 15:32:16 -08:00
7 changed files with 132 additions and 119 deletions

View File

@@ -8,7 +8,6 @@ The project serves as a real-time monitoring and diagnostic tool for the Meshtas
- **IMPORTANT:** the predicted coverage feature requires the extra `pyitm` dependency. If it is not installed, the coverage API will return 503. - **IMPORTANT:** the predicted coverage feature requires the extra `pyitm` dependency. If it is not installed, the coverage API will return 503.
- Ubuntu install (inside the venv): `./env/bin/pip install pyitm` - Ubuntu install (inside the venv): `./env/bin/pip install pyitm`
- Coverage: predicted coverage overlay (LongleyRice area mode) with perimeter rendering and documentation. - Coverage: predicted coverage overlay (LongleyRice area mode) with perimeter rendering and documentation.
- UI: added QR code display for quick node/app access.
- Gateways: persistent gateway tracking (`is_mqtt_gateway`) and UI indicators in nodes, map popups, and stats. - Gateways: persistent gateway tracking (`is_mqtt_gateway`) and UI indicators in nodes, map popups, and stats.
- Map UX: deterministic jitter for overlapping nodes; edges follow jittered positions. - Map UX: deterministic jitter for overlapping nodes; edges follow jittered positions.
- Tooling: Meshtastic protobuf updater script with `--check` and `UPSTREAM_REV.txt` tracking. - Tooling: Meshtastic protobuf updater script with `--check` and `UPSTREAM_REV.txt` tracking.

View File

@@ -3,8 +3,8 @@
import subprocess import subprocess
from pathlib import Path from pathlib import Path
__version__ = "3.0.5" __version__ = "3.0.6"
__release_date__ = "2026-2-6" __release_date__ = "2026-3-6"
def get_git_revision(): def get_git_revision():

View File

@@ -176,6 +176,7 @@ async function initializePage() {
items.push(`<a href="${urls[i]}">${dict[key] || key}</a>`); items.push(`<a href="${urls[i]}">${dict[key] || key}</a>`);
} }
} }
items.push('<a href="https://meshview.world" target="_blank" rel="noopener noreferrer">MeshviewWorld</a>');
menu.innerHTML = items.join("&nbsp;-&nbsp;"); menu.innerHTML = items.join("&nbsp;-&nbsp;");
} }

View File

@@ -23,7 +23,25 @@
#reset-filters-button:hover { background-color:#da190b; } #reset-filters-button:hover { background-color:#da190b; }
#reset-filters-button:active { background-color:#c41e0d; } #reset-filters-button:active { background-color:#c41e0d; }
.blinking-tooltip { background:white;color:black;border:1px solid black;border-radius:4px;padding:2px 5px; } .blinking-tooltip {
background: white;
color: black;
border: 1px solid #111;
border-radius: 6px;
padding: 6px 10px;
font-size: 14px;
font-weight: 400;
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
}
.blinking-tooltip.text-packet {
animation: textPulse 1.1s ease-in-out 6;
border-color: #ff8c00;
}
@keyframes textPulse {
0% { box-shadow: 0 2px 8px rgba(0,0,0,0.25); }
50% { box-shadow: 0 4px 14px rgba(255,140,0,0.45); }
100% { box-shadow: 0 2px 8px rgba(0,0,0,0.25); }
}
#map-wrapper { #map-wrapper {
position: relative; position: relative;
@@ -210,44 +228,6 @@ function hashToColor(str){
return c; return c;
} }
function labelFor(key, fallback){
return mapTranslations[key] || fallback;
}
function buildNodePopup(node){
const labels = {
channel: labelFor("channel_label", "Channel"),
model: labelFor("model_label", "Model"),
role: labelFor("role_label", "Role"),
mqtt: labelFor("mqtt_gateway", "MQTT Gateway"),
lastSeen: labelFor("last_seen", "Last Seen"),
firmware: labelFor("firmware", "Firmware"),
yes: mapTranslations.yes || "Yes",
no: mapTranslations.no || "No"
};
return `
<b><a href="/node/${node.node_id}">${node.long_name}</a> (${node.short_name})</b><br>
<b>${labels.channel}</b> ${node.channel}<br>
<b>${labels.model}</b> ${node.hw_model}<br>
<b>${labels.role}</b> ${node.role}<br>
<b>${labels.mqtt}</b> ${node.is_mqtt_gateway ? labels.yes : labels.no}<br>
${
node.last_seen_us
? `<b>${labels.lastSeen}</b> ${timeAgoFromUs(node.last_seen_us)}<br>`
: ""
}
${
node.firmware
? `<b>${labels.firmware}</b> ${node.firmware}<br>`
: ""
}
`;
}
function hashToUnit(str){ function hashToUnit(str){
let h = 2166136261; let h = 2166136261;
for(let i=0;i<str.length;i++){ for(let i=0;i<str.length;i++){
@@ -312,7 +292,7 @@ function fetchNewPackets(){
const marker = markerById[pkt.from_node_id]; const marker = markerById[pkt.from_node_id];
const nodeData = nodeMap.get(pkt.from_node_id); const nodeData = nodeMap.get(pkt.from_node_id);
if(marker && nodeData) { if(marker && nodeData) {
blinkNode(marker,nodeData.long_name,pkt.portnum); blinkNode(marker, nodeData.long_name, pkt.portnum, pkt.payload);
} else { } else {
addUnmappedPacket(pkt, nodeData); addUnmappedPacket(pkt, nodeData);
} }
@@ -451,11 +431,32 @@ function renderNodesOnMap(){
marker.originalColor = color; marker.originalColor = color;
markerById[node.key] = marker; markerById[node.key] = marker;
marker.bindPopup(buildNodePopup(node)); const popup = `
<a href="/node/${node.node_id}">${node.long_name}</a> (${node.short_name})<br>
<span data-translate-lang="channel_label"></span> ${node.channel}<br>
<span data-translate-lang="model_label"></span> ${node.hw_model}<br>
<span data-translate-lang="role_label"></span> ${node.role}<br>
<span data-translate-lang="mqtt_gateway"></span> ${
node.is_mqtt_gateway ? (mapTranslations.yes || "Yes") : (mapTranslations.no || "No")
}<br>
${
node.last_seen_us
? `<span data-translate-lang="last_seen"></span> ${timeAgoFromUs(node.last_seen_us)}<br>`
: ""
}
${
node.firmware
? `<span data-translate-lang="firmware"></span> ${node.firmware}<br>`
: ""
}
`;
marker.on('click', () => { marker.on('click', () => {
onNodeClick(node); onNodeClick(node);
marker.setPopupContent(buildNodePopup(node)); marker.bindPopup(popup).openPopup();
marker.openPopup();
}); });
}); });
@@ -585,7 +586,20 @@ map.on('click', e=>{
BLINKING BLINKING
====================================================== */ ====================================================== */
function blinkNode(marker,longName,portnum){ function escapeHtml(value){
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function isTextPort(portnum){
return portnum === 1 || portnum === 7;
}
function blinkNode(marker,longName,portnum,payload){
if(!map.hasLayer(marker)) return; if(!map.hasLayer(marker)) return;
if(activeBlinks.has(marker)){ if(activeBlinks.has(marker)){
@@ -595,13 +609,24 @@ function blinkNode(marker,longName,portnum){
} }
let blinkCount = 0; let blinkCount = 0;
const blinkStart = Date.now();
const blinkDurationMs = 7000;
const safeName = escapeHtml(longName);
const portLabel = portMap[portnum] || `Port ${portnum ?? "?"}`;
const payloadText = (payload || "").trim();
const showPayload = isTextPort(portnum) && payloadText && payloadText !== "Did not decode";
const shortPayload = showPayload && payloadText.length > 80
? `${payloadText.slice(0, 77)}...`
: payloadText;
const payloadLine = showPayload ? `<br><span>${escapeHtml(shortPayload)}</span>` : "";
const tooltip = L.tooltip({ const tooltip = L.tooltip({
permanent:true, permanent:true,
direction:'top', direction:'top',
offset:[0,-marker.options.radius-5], offset:[0,-marker.options.radius-5],
className:'blinking-tooltip' className: isTextPort(portnum) ? 'blinking-tooltip text-packet' : 'blinking-tooltip'
}) })
.setContent(`${longName} (${portMap[portnum] || "Port "+portnum})`) .setContent(`${safeName} (${escapeHtml(portLabel)})${payloadLine}`)
.setLatLng(marker.getLatLng()) .setLatLng(marker.getLatLng())
.addTo(map); .addTo(map);
@@ -616,7 +641,7 @@ function blinkNode(marker,longName,portnum){
} }
blinkCount++; blinkCount++;
if(blinkCount>7){ if(Date.now() - blinkStart > blinkDurationMs){
clearInterval(interval); clearInterval(interval);
marker.setStyle({ fillColor: marker.originalColor }); marker.setStyle({ fillColor: marker.originalColor });
map.removeLayer(tooltip); map.removeLayer(tooltip);

View File

@@ -18,21 +18,23 @@
/* --- Node Info --- */ /* --- Node Info --- */
.node-info { .node-info {
background-color: #1f2226;
border: 1px solid #3a3f44;
color: #ddd; color: #ddd;
font-size: 0.88rem; font-size: 0.88rem;
padding: 12px 14px;
margin-bottom: 14px; margin-bottom: 14px;
border-radius: 8px;
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(120px, 1fr)); grid-template-columns: repeat(3, minmax(120px, 1fr));
grid-column-gap: 14px; grid-column-gap: 12px;
grid-row-gap: 6px; grid-row-gap: 12px;
} }
.node-info div { padding: 2px 0; } .node-info-col {
background-color: #1f2226;
border: 1px solid #3a3f44;
border-radius: 8px;
padding: 12px 14px;
}
.node-info-col div { padding: 2px 0; }
.node-info strong { .node-info strong {
color: #9fd4ff; color: #9fd4ff;
font-weight: 600; font-weight: 600;
@@ -357,31 +359,32 @@
<!-- Node Info --> <!-- Node Info -->
<div id="node-info" class="node-info"> <div id="node-info" class="node-info">
<div><strong data-translate-lang="node_id">Node ID</strong><strong>: </strong><span id="info-node-id"></span></div> <div class="node-info-col">
<div><strong data-translate-lang="id">Hex ID</strong><strong>: </strong><span id="info-id"></span></div> <div><strong data-translate-lang="node_id">Node ID</strong><strong>: </strong><span id="info-node-id"></span></div>
<div><strong data-translate-lang="long_name">Long Name</strong><strong>: </strong> <span id="info-long-name"></span></div> <div><strong data-translate-lang="id">Hex ID</strong><strong>: </strong><span id="info-id"></span></div>
<div><strong data-translate-lang="short_name">Short Name</strong><strong>: </strong> <span id="info-short-name"></span></div> <div><strong data-translate-lang="short_name">Short Name</strong><strong>: </strong> <span id="info-short-name"></span></div>
<div><strong data-translate-lang="long_name">Long Name</strong><strong>: </strong> <span id="info-long-name"></span></div>
<div><strong data-translate-lang="hw_model">Hardware Model</strong><strong>: </strong> <span id="info-hw-model"></span></div> <div><strong data-translate-lang="channel">Channel</strong><strong>: </strong> <span id="info-channel"></span></div>
<div><strong data-translate-lang="firmware">Firmware</strong><strong>: </strong> <span id="info-firmware"></span></div> </div>
<div><strong data-translate-lang="role">Role</strong><strong>: </strong> <span id="info-role"></span></div> <div class="node-info-col">
<div><strong data-translate-lang="latitude">Latitude</strong><strong>: </strong> <span id="info-lat"></span></div>
<div><strong data-translate-lang="mqtt_gateway">MQTT Gateway</strong><strong>: </strong> <span id="info-mqtt-gateway"></span></div> <div><strong data-translate-lang="longitude">Longitude</strong><strong>: </strong> <span id="info-lon"></span></div>
<div><strong data-translate-lang="channel">Channel</strong><strong>: </strong> <span id="info-channel"></span></div> <div><strong data-translate-lang="role">Role</strong><strong>: </strong> <span id="info-role"></span></div>
<div><strong data-translate-lang="latitude">Latitude</strong><strong>: </strong> <span id="info-lat"></span></div> <div><strong data-translate-lang="hw_model">Hardware Model</strong><strong>: </strong> <span id="info-hw-model"></span></div>
<div><strong data-translate-lang="longitude">Longitude</strong><strong>: </strong> <span id="info-lon"></span></div> <div><strong data-translate-lang="mqtt_gateway">MQTT Gateway</strong><strong>: </strong> <span id="info-mqtt-gateway"></span></div>
</div>
<div><strong data-translate-lang="first_update">First Update</strong><strong>: </strong> <span id="info-first-update"></span></div> <div class="node-info-col">
<div><strong data-translate-lang="last_update">Last Update</strong><strong>: </strong> <span id="info-last-update"></span></div> <div><strong data-translate-lang="first_update">First Update</strong><strong>: </strong> <span id="info-first-update"></span></div>
<div> <div><strong data-translate-lang="last_update">Last Update</strong><strong>: </strong> <span id="info-last-update"></span></div>
<strong data-translate-lang="statistics">Statistics</strong><strong>: </strong> <div><strong data-translate-lang="firmware">Firmware</strong><strong>: </strong> <span id="info-firmware"></span></div>
<span id="info-stats" <div>
data-label-24h="24h" <strong data-translate-lang="statistics">Statistics</strong><strong>: </strong>
data-label-sent="Packets sent" <span id="info-stats"
data-label-seen="Times seen"></span> data-label-24h="24h"
data-label-sent="Packets sent"
data-label-seen="Times seen"></span>
</div>
</div> </div>
</div> </div>
<!-- Map. --> <!-- Map. -->
@@ -895,21 +898,13 @@ function addMarker(id, lat, lon, color = "red", node = null) {
async function drawNeighbors(src, nids) { async function drawNeighbors(src, nids) {
if (!map) return; if (!map) return;
// Prefer the currently displayed source position (e.g. latest track point), // Ensure source node position exists
// then fall back to the node API location. const srcNode = await fetchNodeFromApi(src);
let srcLat; if (!srcNode || !srcNode.last_lat || !srcNode.last_long) return;
let srcLon;
let srcNode = currentNode || nodeCache[src] || null;
if (nodePositions[src]) { const srcLat = srcNode.last_lat / 1e7;
[srcLat, srcLon] = nodePositions[src]; const srcLon = srcNode.last_long / 1e7;
} else { nodePositions[src] = [srcLat, srcLon];
srcNode = srcNode || await fetchNodeFromApi(src);
if (!srcNode || !srcNode.last_lat || !srcNode.last_long) return;
srcLat = srcNode.last_lat / 1e7;
srcLon = srcNode.last_long / 1e7;
nodePositions[src] = [srcLat, srcLon];
}
for (const nid of nids) { for (const nid of nids) {
const neighbor = await fetchNodeFromApi(nid); const neighbor = await fetchNodeFromApi(nid);
@@ -1447,13 +1442,20 @@ async function loadPacketHistogram() {
const DAYS = 7; const DAYS = 7;
const now = new Date(); const now = new Date();
const dayKeyFromDate = (d) => {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
};
const dayKeys = []; const dayKeys = [];
const dayLabels = []; const dayLabels = [];
for (let i = DAYS - 1; i >= 0; i--) { for (let i = DAYS - 1; i >= 0; i--) {
const d = new Date(now); const d = new Date(now);
d.setDate(d.getDate() - i); d.setDate(d.getDate() - i);
dayKeys.push(d.toISOString().slice(0, 10)); dayKeys.push(dayKeyFromDate(d));
dayLabels.push( dayLabels.push(
d.toLocaleDateString([], { month: "short", day: "numeric" }) d.toLocaleDateString([], { month: "short", day: "numeric" })
); );
@@ -1481,9 +1483,7 @@ async function loadPacketHistogram() {
for (const pkt of packets) { for (const pkt of packets) {
if (!pkt.import_time_us) continue; if (!pkt.import_time_us) continue;
const day = new Date(pkt.import_time_us / 1000) const day = dayKeyFromDate(new Date(pkt.import_time_us / 1000));
.toISOString()
.slice(0, 10);
if (!dayKeys.includes(day)) continue; if (!dayKeys.includes(day)) continue;
@@ -1627,16 +1627,15 @@ document.addEventListener("DOMContentLoaded", async () => {
// ✅ MAP MUST EXIST FIRST // ✅ MAP MUST EXIST FIRST
if (!map) initMap(); if (!map) initMap();
// Load the track first so neighbor links anchor to the same
// visible current-node position shown on this page.
await loadTrack();
// ✅ DRAW LATEST NEIGHBORS ONCE // ✅ DRAW LATEST NEIGHBORS ONCE
const neighborIds = await loadLatestNeighborIds(); const neighborIds = await loadLatestNeighborIds();
if (neighborIds.length) { if (neighborIds.length) {
await drawNeighbors(fromNodeId, neighborIds); await drawNeighbors(fromNodeId, neighborIds);
} }
// ⚠️ Track may add to map, but must not hide it
await loadTrack();
await loadPackets(); await loadPackets();
initPacketPortFilter(); initPacketPortFilter();
await loadTelemetryCharts(); await loadTelemetryCharts();

View File

@@ -266,7 +266,7 @@ select, .export-btn, .search-box, .clear-btn {
<th data-translate-lang="last_lat">Last Latitude <span class="sort-icon"></span></th> <th data-translate-lang="last_lat">Last Latitude <span class="sort-icon"></span></th>
<th data-translate-lang="last_long">Last Longitude <span class="sort-icon"></span></th> <th data-translate-lang="last_long">Last Longitude <span class="sort-icon"></span></th>
<th data-translate-lang="channel">Channel <span class="sort-icon"></span></th> <th data-translate-lang="channel">Channel <span class="sort-icon"></span></th>
<th data-translate-lang="mqtt_gateway">MQTT <span class="sort-icon"></span></th> <th data-translate-lang="mqtt_gateway">MQTT</th>
<th data-translate-lang="last_seen">Last Seen <span class="sort-icon"></span></th> <th data-translate-lang="last_seen">Last Seen <span class="sort-icon"></span></th>
<th data-translate-lang="favorite"></th> <th data-translate-lang="favorite"></th>
</tr> </tr>
@@ -331,7 +331,7 @@ const minStatusMs = 300;
const headers = document.querySelectorAll("thead th"); const headers = document.querySelectorAll("thead th");
const keyMap = [ const keyMap = [
"short_name","long_name","hw_model","firmware","role", "short_name","long_name","hw_model","firmware","role",
"last_lat","last_long","channel","is_mqtt_gateway","last_seen_us" "last_lat","last_long","channel","last_seen_us"
]; ];
function debounce(fn, delay = 250) { function debounce(fn, delay = 250) {
@@ -717,11 +717,6 @@ document.addEventListener("DOMContentLoaded", async function() {
B = B || 0; B = B || 0;
} }
if (key === "is_mqtt_gateway") {
A = A ? 1 : 0;
B = B ? 1 : 0;
}
// Normalize strings for stable sorting // Normalize strings for stable sorting
if (typeof A === "string") A = A.toLowerCase(); if (typeof A === "string") A = A.toLowerCase();
if (typeof B === "string") B = B.toLowerCase(); if (typeof B === "string") B = B.toLowerCase();

View File

@@ -408,11 +408,6 @@ seenSorted.forEach(s => {
hopGroups[hopValue].push(s); hopGroups[hopValue].push(s);
}); });
function formatHopDisplay(hopKey, hopStart){
const startVal = hopStart ?? "—";
return `${hopKey}/${startVal}`;
}
/* --------------------------------------------- /* ---------------------------------------------
Render grouped gateway table + map markers Render grouped gateway table + map markers
----------------------------------------------*/ ----------------------------------------------*/
@@ -429,7 +424,6 @@ seenTableBody.innerHTML = Object.keys(hopGroups)
const node = nodeLookup[s.node_id]; const node = nodeLookup[s.node_id];
const label = node?.long_name || s.node_id; const label = node?.long_name || s.node_id;
const hopDisplay = formatHopDisplay(hopKey, s.hop_start);
const timeStr = s.import_time_us const timeStr = s.import_time_us
? new Date(s.import_time_us/1000).toLocaleTimeString() ? new Date(s.import_time_us/1000).toLocaleTimeString()
: "—"; : "—";
@@ -488,7 +482,7 @@ seenTableBody.innerHTML = Object.keys(hopGroups)
RSSI: ${s.rx_rssi ?? "—"}<br> RSSI: ${s.rx_rssi ?? "—"}<br>
SNR: ${s.rx_snr ?? "—"}<br><br> SNR: ${s.rx_snr ?? "—"}<br><br>
<b data-translate-lang="hops">Hops</b>: ${hopDisplay} <b data-translate-lang="hops">Hops</b>: ${hopKey}
</div> </div>
`); `);
@@ -499,7 +493,7 @@ seenTableBody.innerHTML = Object.keys(hopGroups)
<td><a href="/node/${s.node_id}">${label}</a></td> <td><a href="/node/${s.node_id}">${label}</a></td>
<td>${s.rx_rssi ?? "—"}</td> <td>${s.rx_rssi ?? "—"}</td>
<td>${s.rx_snr ?? "—"}</td> <td>${s.rx_snr ?? "—"}</td>
<td>${hopDisplay}</td> <td>${hopKey}</td>
<td>${s.channel ?? "—"}</td> <td>${s.channel ?? "—"}</td>
<td>${timeStr}</td> <td>${timeStr}</td>
</tr> </tr>