mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-03-04 23:27:46 +01:00
efficiency improvement node.html now it only queries the needed node info rather than all the nodes.
This commit is contained in:
@@ -64,10 +64,9 @@
|
||||
},
|
||||
|
||||
"net": {
|
||||
"number_of_checkins": "Number of Check-ins:",
|
||||
"view_packet_details": "View packet details",
|
||||
"view_all_packets_from_node": "View all packets from this node",
|
||||
"no_packets_found": "No packets found."
|
||||
"net_title": "Weekly Net:",
|
||||
"total_messages": "Number of messages:",
|
||||
"view_packet_details": "More details"
|
||||
},
|
||||
|
||||
"map": {
|
||||
@@ -157,17 +156,17 @@
|
||||
"graph": "Graph"
|
||||
},
|
||||
"node": {
|
||||
"specifications": "Specifications:",
|
||||
"node_id": "Node ID:",
|
||||
"long_name": "Long Name:",
|
||||
"short_name": "Short Name:",
|
||||
"hw_model": "Hardware Model:",
|
||||
"firmware": "Firmware:",
|
||||
"role": "Role:",
|
||||
"channel": "Channel:",
|
||||
"latitude": "Latitude:",
|
||||
"longitude": "Longitude:",
|
||||
"last_update": "Last Update:",
|
||||
"specifications": "Specifications",
|
||||
"node_id": "Node ID",
|
||||
"long_name": "Long Name",
|
||||
"short_name": "Short Name",
|
||||
"hw_model": "Hardware Model",
|
||||
"firmware": "Firmware",
|
||||
"role": "Role",
|
||||
"channel": "Channel",
|
||||
"latitude": "Latitude",
|
||||
"longitude": "Longitude",
|
||||
"last_update": "Last Update",
|
||||
"battery_voltage": "Battery & Voltage",
|
||||
"air_channel": "Air & Channel Utilization",
|
||||
"environment": "Environment Metrics",
|
||||
|
||||
@@ -260,11 +260,12 @@ async def get_node_traffic(node_id: int):
|
||||
return []
|
||||
|
||||
|
||||
async def get_nodes(role=None, channel=None, hw_model=None, days_active=None):
|
||||
async def get_nodes(node_id=None, role=None, channel=None, hw_model=None, days_active=None):
|
||||
"""
|
||||
Fetches nodes from the database based on optional filtering criteria.
|
||||
|
||||
Parameters:
|
||||
node_id
|
||||
role (str, optional): The role of the node (converted to uppercase for consistency).
|
||||
channel (str, optional): The communication channel associated with the node.
|
||||
hw_model (str, optional): The hardware model of the node.
|
||||
@@ -280,6 +281,8 @@ async def get_nodes(role=None, channel=None, hw_model=None, days_active=None):
|
||||
query = select(Node)
|
||||
|
||||
# Apply filters based on provided parameters
|
||||
if node_id is not None:
|
||||
query = query.where(Node.node_id == node_id)
|
||||
if role is not None:
|
||||
query = query.where(Node.role == role.upper()) # Ensure role is uppercase
|
||||
if channel is not None:
|
||||
|
||||
@@ -137,25 +137,25 @@
|
||||
<div class="container">
|
||||
|
||||
<h5 class="mb-3">
|
||||
📡 <span data-translate-lang="specifications">Specifications:</span>
|
||||
📡 <span data-translate-lang="specifications">Specifications:</span><strong>:</strong>
|
||||
<span id="nodeLabel"></span>
|
||||
</h5>
|
||||
|
||||
<!-- Node Info -->
|
||||
<div id="node-info" class="node-info">
|
||||
<div><strong data-translate-lang="node_id">Node ID:</strong> <span id="info-node-id">—</span></div>
|
||||
<div><strong data-translate-lang="long_name">Long Name:</strong> <span id="info-long-name">—</span></div>
|
||||
<div><strong data-translate-lang="short_name">Short Name:</strong> <span id="info-short-name">—</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="short_name">Short Name</strong><strong>:</strong> <span id="info-short-name">—</span></div>
|
||||
|
||||
<div><strong data-translate-lang="hw_model">Hardware Model:</strong> <span id="info-hw-model">—</span></div>
|
||||
<div><strong data-translate-lang="firmware">Firmware:</strong> <span id="info-firmware">—</span></div>
|
||||
<div><strong data-translate-lang="role">Role:</strong> <span id="info-role">—</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="firmware">Firmware</strong><strong>:</strong> <span id="info-firmware">—</span></div>
|
||||
<div><strong data-translate-lang="role">Role</strong><strong>:</strong> <span id="info-role">—</span></div>
|
||||
|
||||
<div><strong data-translate-lang="channel">Channel:</strong> <span id="info-channel">—</span></div>
|
||||
<div><strong data-translate-lang="latitude">Latitude:</strong> <span id="info-lat">—</span></div>
|
||||
<div><strong data-translate-lang="longitude">Longitude:</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="latitude">Latitude</strong><strong>:</strong> <span id="info-lat">—</span></div>
|
||||
<div><strong data-translate-lang="longitude">Longitude</strong><strong>:</strong> <span id="info-lon">—</span></div>
|
||||
|
||||
<div><strong data-translate-lang="last_update">Last Update:</strong> <span id="info-last-update">—</span></div>
|
||||
<div><strong data-translate-lang="last_update">Last Update</strong><strong>:</strong> <span id="info-last-update">—</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Map -->
|
||||
@@ -253,6 +253,9 @@ async function loadTranslationsNode() {
|
||||
nodeTranslations = await res.json();
|
||||
|
||||
applyTranslationsNode(nodeTranslations);
|
||||
|
||||
// Broadcast label can be set here since translations are now loaded
|
||||
nodeMap[4294967295] = nodeTranslations.all_broadcast || "All";
|
||||
} catch (err) {
|
||||
console.error("Node translation load failed", err);
|
||||
}
|
||||
@@ -273,57 +276,134 @@ function applyTranslationsNode(dict, root=document) {
|
||||
}
|
||||
|
||||
/* ======================================================
|
||||
NODE LOGIC
|
||||
POPUP + TIME HELPERS
|
||||
====================================================== */
|
||||
|
||||
let nodeMap = {}, nodePositions = {}, map, markers = {};
|
||||
function makeNodePopup(node) {
|
||||
return `
|
||||
<div style="font-size:0.9em">
|
||||
<a href="/node/${node.node_id}" style="color:inherit; text-decoration:underline;">
|
||||
<b>${node.long_name || node.short_name || node.node_id}</b>
|
||||
</a>
|
||||
${node.short_name ? ` (${node.short_name})` : ""}<br>
|
||||
|
||||
<b><span data-translate-lang="node_id">
|
||||
${nodeTranslations.node_id || "Node ID"}:
|
||||
</span></b> ${node.node_id}<br>
|
||||
|
||||
<b><span data-translate-lang="hw_model">
|
||||
${nodeTranslations.hw_model || "HW Model"}:
|
||||
</span></b> ${node.hw_model ?? "—"}<br>
|
||||
|
||||
<b><span data-translate-lang="channel">
|
||||
${nodeTranslations.channel || "Channel"}:
|
||||
</span></b> ${node.channel ?? "—"}<br>
|
||||
|
||||
<b><span data-translate-lang="role">
|
||||
${nodeTranslations.role || "Role"}:
|
||||
</span></b> ${node.role ?? "—"}<br>
|
||||
|
||||
<b><span data-translate-lang="firmware">
|
||||
${nodeTranslations.firmware || "Firmware"}:
|
||||
</span></b> ${node.firmware ?? "—"}<br>
|
||||
|
||||
<b><span data-translate-lang="last_update">
|
||||
${nodeTranslations.last_update || "Last Update"}:
|
||||
</span></b> ${formatLastSeen(node.last_seen_us)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function formatLastSeen(us) {
|
||||
if (!us) return "—";
|
||||
const d = new Date(us / 1000);
|
||||
return d.toLocaleString([], {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
}
|
||||
|
||||
function formatLocalTime(us){
|
||||
return new Date(us / 1000).toLocaleString([], {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
}
|
||||
|
||||
/* ======================================================
|
||||
GLOBALS
|
||||
====================================================== */
|
||||
|
||||
let nodeMap = {}; // node_id -> label
|
||||
let nodePositions = {}; // node_id -> [lat, lon]
|
||||
let nodeCache = {}; // node_id -> full node object
|
||||
let currentNode = null;
|
||||
|
||||
let map, markers = {};
|
||||
let chartData = {}, neighborData = { ids:[], names:[], snrs:[] };
|
||||
let allNodes = [];
|
||||
|
||||
let fromNodeId = new URLSearchParams(window.location.search).get("from_node_id");
|
||||
if (!fromNodeId) {
|
||||
const parts = window.location.pathname.split("/");
|
||||
fromNodeId = parts[parts.length - 1];
|
||||
}
|
||||
|
||||
// --- Load nodes ---
|
||||
async function loadNodes(){
|
||||
/* ======================================================
|
||||
API HELPERS (USE /api/nodes?node_id=...)
|
||||
====================================================== */
|
||||
|
||||
async function fetchNodeFromApi(nodeId) {
|
||||
if (nodeCache[nodeId]) return nodeCache[nodeId];
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/nodes");
|
||||
const res = await fetch(`/api/nodes?node_id=${encodeURIComponent(nodeId)}`);
|
||||
if (!res.ok) {
|
||||
console.error("Failed /api/nodes", res.status);
|
||||
return;
|
||||
console.error("Failed /api/nodes?node_id=", nodeId, res.status);
|
||||
return null;
|
||||
}
|
||||
const data = await res.json();
|
||||
allNodes = data.nodes || [];
|
||||
const node = (data.nodes || [])[0];
|
||||
if (!node) return null;
|
||||
|
||||
for (const n of allNodes) {
|
||||
const name = n.long_name || n.short_name || n.id || n.node_id;
|
||||
nodeMap[n.node_id] = name;
|
||||
nodeCache[nodeId] = node;
|
||||
|
||||
if (n.last_lat && n.last_long)
|
||||
nodePositions[n.node_id] = [n.last_lat / 1e7, n.last_long / 1e7];
|
||||
const label = node.long_name || node.short_name || node.id || node.node_id;
|
||||
nodeMap[node.node_id] = label;
|
||||
|
||||
if (node.last_lat && node.last_long) {
|
||||
nodePositions[node.node_id] = [node.last_lat / 1e7, node.last_long / 1e7];
|
||||
}
|
||||
|
||||
nodeMap[4294967295] = nodeTranslations.all_broadcast || "All";
|
||||
|
||||
document.getElementById("nodeLabel").textContent = nodeMap[fromNodeId] || fromNodeId;
|
||||
|
||||
return node;
|
||||
} catch (err) {
|
||||
console.error("Error loading nodes:", err);
|
||||
console.error("Error fetching node", nodeId, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Load Node Info ---
|
||||
/* ======================================================
|
||||
LOAD NODE INFO (SINGLE NODE)
|
||||
====================================================== */
|
||||
|
||||
async function loadNodeInfo(){
|
||||
try {
|
||||
if (!allNodes.length) await loadNodes();
|
||||
const node = await fetchNodeFromApi(fromNodeId);
|
||||
currentNode = node;
|
||||
|
||||
const node = allNodes.find(n => String(n.node_id) === String(fromNodeId));
|
||||
if (!node) {
|
||||
document.getElementById("node-info").style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
// Label in title
|
||||
document.getElementById("nodeLabel").textContent =
|
||||
nodeMap[fromNodeId] || fromNodeId;
|
||||
|
||||
// Info card
|
||||
document.getElementById("info-node-id").textContent = node.node_id ?? "—";
|
||||
document.getElementById("info-long-name").textContent = node.long_name ?? "—";
|
||||
document.getElementById("info-short-name").textContent = node.short_name ?? "—";
|
||||
@@ -332,51 +412,54 @@ async function loadNodeInfo(){
|
||||
document.getElementById("info-role").textContent = node.role ?? "—";
|
||||
document.getElementById("info-channel").textContent = node.channel ?? "—";
|
||||
|
||||
document.getElementById("info-lat").textContent = node.last_lat ? (node.last_lat / 1e7).toFixed(6) : "—";
|
||||
document.getElementById("info-lon").textContent = node.last_long ? (node.last_long / 1e7).toFixed(6) : "—";
|
||||
document.getElementById("info-lat").textContent =
|
||||
node.last_lat ? (node.last_lat / 1e7).toFixed(6) : "—";
|
||||
document.getElementById("info-lon").textContent =
|
||||
node.last_long ? (node.last_long / 1e7).toFixed(6) : "—";
|
||||
|
||||
let lastSeen = "—";
|
||||
if (node.last_seen_us) {
|
||||
lastSeen = new Date(node.last_seen_us / 1000).toLocaleString([], {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
lastSeen = formatLastSeen(node.last_seen_us);
|
||||
}
|
||||
|
||||
document.getElementById("info-last-update").textContent = lastSeen;
|
||||
|
||||
|
||||
} catch (err) {
|
||||
console.error("Failed to load node info:", err);
|
||||
document.getElementById("node-info").style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
function nodeLink(id) {
|
||||
/* ======================================================
|
||||
NODE LINK RENDERING
|
||||
====================================================== */
|
||||
|
||||
// Broadcast to everyone (ALL)
|
||||
function nodeLink(id, labelOverride = null) {
|
||||
// Broadcast
|
||||
if (id === 4294967295) {
|
||||
return `<span class="to-mqtt" data-translate-lang="all_broadcast">
|
||||
${nodeTranslations.all_broadcast || "All"}
|
||||
</span>`;
|
||||
}
|
||||
|
||||
// Direct to MQTT broker
|
||||
// Direct to MQTT
|
||||
if (id === 1) {
|
||||
return `<span class="to-mqtt" data-translate-lang="direct_to_mqtt">
|
||||
${nodeTranslations.direct_to_mqtt || "Direct to MQTT"}
|
||||
</span>`;
|
||||
}
|
||||
|
||||
// Normal node → clickable link
|
||||
return `<a href="/node/${id}"
|
||||
style="text-decoration:underline; color:inherit;">
|
||||
${nodeMap[id] || id}
|
||||
// Normal node
|
||||
const label = labelOverride || nodeMap[id] || id;
|
||||
|
||||
return `<a href="/node/${id}" style="text-decoration:underline; color:inherit;">
|
||||
${label}
|
||||
</a>`;
|
||||
}
|
||||
|
||||
|
||||
/* ======================================================
|
||||
PORT LABELS
|
||||
====================================================== */
|
||||
|
||||
function portLabel(p) {
|
||||
const PORT_COLOR_MAP = {
|
||||
0: "#6c757d",
|
||||
@@ -426,16 +509,10 @@ function portLabel(p) {
|
||||
`;
|
||||
}
|
||||
|
||||
function formatLocalTime(us){
|
||||
return new Date(us / 1000).toLocaleString([], {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
}
|
||||
/* ======================================================
|
||||
MAP SETUP
|
||||
====================================================== */
|
||||
|
||||
// --- Map ---
|
||||
function initMap(){
|
||||
map = L.map('map', { preferCanvas:true }).setView([37.7749, -122.4194], 8);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
@@ -450,30 +527,45 @@ function hideMap(){
|
||||
}
|
||||
}
|
||||
|
||||
function addMarker(id, lat, lon, label, color = "red"){
|
||||
function addMarker(id, lat, lon, color = "red", node = null) {
|
||||
if (!map) return;
|
||||
if (isNaN(lat) || isNaN(lon)) return;
|
||||
|
||||
nodePositions[id] = [lat, lon];
|
||||
|
||||
if (!node) {
|
||||
node = nodeCache[id] || null;
|
||||
}
|
||||
|
||||
const popupHtml = node ? makeNodePopup(node) : `<b>${id}</b>`;
|
||||
|
||||
const m = L.circleMarker([lat, lon], {
|
||||
radius: 5,
|
||||
radius: 6,
|
||||
color,
|
||||
fillColor: color,
|
||||
fillOpacity: 1
|
||||
}).addTo(map).bindPopup(label);
|
||||
}).addTo(map).bindPopup(popupHtml);
|
||||
|
||||
markers[id] = m;
|
||||
m.bringToFront();
|
||||
}
|
||||
|
||||
function drawNeighbors(src, nids){
|
||||
async function drawNeighbors(src, nids){
|
||||
if (!map) return;
|
||||
const s = nodePositions[src];
|
||||
if (!s) return;
|
||||
const srcPos = nodePositions[src];
|
||||
if (!srcPos) return;
|
||||
|
||||
for (const nid of nids) {
|
||||
const pos = nodePositions[nid];
|
||||
if (pos) {
|
||||
addMarker(nid, pos[0], pos[1], nodeMap[nid] || nid, "blue");
|
||||
L.polyline([s, pos], { color:'gray', weight:1 }).addTo(map);
|
||||
}
|
||||
const neighbor = await fetchNodeFromApi(nid);
|
||||
if (!neighbor || !neighbor.last_lat || !neighbor.last_long) continue;
|
||||
|
||||
const lat = neighbor.last_lat / 1e7;
|
||||
const lon = neighbor.last_long / 1e7;
|
||||
|
||||
addMarker(nid, lat, lon, "blue", neighbor);
|
||||
|
||||
const dstPos = [lat, lon];
|
||||
L.polyline([srcPos, dstPos], { color:'gray', weight:1 }).addTo(map);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,15 +574,19 @@ function ensureMapVisible(){
|
||||
requestAnimationFrame(() => {
|
||||
map.invalidateSize();
|
||||
const group = L.featureGroup(Object.values(markers));
|
||||
if (group.getLayers().length > 0)
|
||||
map.fitBounds(group.getBounds(), {
|
||||
padding: [20, 20],
|
||||
maxZoom: 11 //
|
||||
});
|
||||
if (group.getLayers().length > 0) {
|
||||
map.fitBounds(group.getBounds(), {
|
||||
padding: [20, 20],
|
||||
maxZoom: 11
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Position Track (last 50 position packets) ---
|
||||
/* ======================================================
|
||||
POSITION TRACK (portnum=3)
|
||||
====================================================== */
|
||||
|
||||
async function loadTrack(){
|
||||
try {
|
||||
const url = new URL("/api/packets", window.location.origin);
|
||||
@@ -526,7 +622,6 @@ async function loadTrack(){
|
||||
}
|
||||
|
||||
if (!points.length) {
|
||||
// No position packets -> hide map entirely
|
||||
hideMap();
|
||||
return;
|
||||
}
|
||||
@@ -538,7 +633,6 @@ async function loadTrack(){
|
||||
const latest = points[points.length - 1];
|
||||
nodePositions[fromNodeId] = [latest.lat, latest.lon];
|
||||
|
||||
// Ensure map exists
|
||||
if (!map) {
|
||||
initMap();
|
||||
}
|
||||
@@ -549,57 +643,28 @@ async function loadTrack(){
|
||||
weight: 2
|
||||
}).addTo(map);
|
||||
|
||||
// First + last markers only
|
||||
const first = points[0];
|
||||
const last = points[points.length - 1];
|
||||
|
||||
const node = currentNode || nodeCache[fromNodeId] || null;
|
||||
|
||||
const startMarker = L.circleMarker([first.lat, first.lon], {
|
||||
radius: 6,
|
||||
color: 'green',
|
||||
fillColor: 'green',
|
||||
fillOpacity: 1
|
||||
}).addTo(map).bindPopup("Start");
|
||||
}).addTo(map).bindPopup(node ? makeNodePopup(node) : "Start");
|
||||
|
||||
const endMarker = L.circleMarker([last.lat, last.lon], {
|
||||
radius: 6,
|
||||
color: 'red',
|
||||
fillColor: 'red',
|
||||
fillOpacity: 1
|
||||
}).addTo(map).bindPopup("Latest");
|
||||
}).addTo(map).bindPopup(node ? makeNodePopup(node) : "Latest");
|
||||
|
||||
markers["__track_start"] = startMarker;
|
||||
markers["__track_end"] = endMarker;
|
||||
|
||||
// Hover tooltip on the track: nearest point to cursor
|
||||
trackLine.on('mousemove', function(e){
|
||||
const { lat, lng } = e.latlng;
|
||||
let bestIdx = 0;
|
||||
let bestDist = Infinity;
|
||||
for (let i = 0; i < latlngs.length; i++) {
|
||||
const dLat = latlngs[i][0] - lat;
|
||||
const dLng = latlngs[i][1] - lng;
|
||||
const dist = dLat * dLat + dLng * dLng;
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
const p = points[bestIdx];
|
||||
const dt = new Date(p.time / 1000);
|
||||
const ts = dt.toLocaleString([], {
|
||||
month:"2-digit",
|
||||
day:"2-digit",
|
||||
hour:"2-digit",
|
||||
minute:"2-digit"
|
||||
});
|
||||
const tooltipHtml =
|
||||
`${ts}<br>` +
|
||||
`Lat: ${p.lat.toFixed(6)}<br>` +
|
||||
`Lon: ${p.lon.toFixed(6)}`;
|
||||
|
||||
trackLine.bindTooltip(tooltipHtml, { sticky:true }).openTooltip(e.latlng);
|
||||
});
|
||||
|
||||
// Fit map to full track
|
||||
map.fitBounds(trackLine.getBounds(), { padding:[20,20] });
|
||||
|
||||
} catch (err) {
|
||||
@@ -608,7 +673,10 @@ async function loadTrack(){
|
||||
}
|
||||
}
|
||||
|
||||
// --- Packets (for table + neighbor map overlay) ---
|
||||
/* ======================================================
|
||||
PACKETS TABLE + NEIGHBOR OVERLAY
|
||||
====================================================== */
|
||||
|
||||
async function loadPackets(){
|
||||
const url = new URL("/api/packets", window.location.origin);
|
||||
url.searchParams.set("from_node_id", fromNodeId);
|
||||
@@ -621,40 +689,45 @@ async function loadPackets(){
|
||||
const list = document.getElementById("packet_list");
|
||||
|
||||
for (const pkt of (data.packets || []).reverse()) {
|
||||
const safePayload = (pkt.payload || "").replace(/[<>]/g, m => m == "<" ? "<" : ">");
|
||||
const safePayload = (pkt.payload || "").replace(/[<>]/g, m => m === "<" ? "<" : ">");
|
||||
const localTime = formatLocalTime(pkt.import_time_us);
|
||||
const fromCell = nodeLink(pkt.from_node_id);
|
||||
const toCell = nodeLink(pkt.to_node_id);
|
||||
const toCell = nodeLink(pkt.to_node_id, pkt.to_long_name);
|
||||
|
||||
// Neighbor packets still update map overlay if map exists
|
||||
// Neighbor packets (port 71) → draw neighbors on map
|
||||
if (pkt.portnum === 71 && pkt.payload) {
|
||||
const nids = [];
|
||||
const re = /neighbors\s*\{\s*node_id:\s*(\d+)/g;
|
||||
let m;
|
||||
while ((m = re.exec(pkt.payload)) !== null) nids.push(parseInt(m[1]));
|
||||
drawNeighbors(pkt.from_node_id, nids);
|
||||
while ((m = re.exec(pkt.payload)) !== null) {
|
||||
nids.push(parseInt(m[1]));
|
||||
}
|
||||
if (nids.length && map) {
|
||||
await drawNeighbors(pkt.from_node_id, nids);
|
||||
}
|
||||
}
|
||||
|
||||
let inlineLinks = "";
|
||||
|
||||
if (pkt.portnum === 3 && pkt.payload) {
|
||||
const latMatch = pkt.payload.match(/latitude_i:\s*(-?\d+)/);
|
||||
const lonMatch = pkt.payload.match(/longitude_i:\s*(-?\d+)/);
|
||||
// Position link (Google Maps)
|
||||
if (pkt.portnum === 3 && pkt.payload) {
|
||||
const latMatch = pkt.payload.match(/latitude_i:\s*(-?\d+)/);
|
||||
const lonMatch = pkt.payload.match(/longitude_i:\s*(-?\d+)/);
|
||||
|
||||
if (latMatch && lonMatch) {
|
||||
const lat = parseFloat(latMatch[1]) / 1e7;
|
||||
const lon = parseFloat(lonMatch[1]) / 1e7;
|
||||
inlineLinks += ` <a class="inline-link" href="https://www.google.com/maps?q=${lat},${lon}" target="_blank">📍</a>`;
|
||||
if (latMatch && lonMatch) {
|
||||
const lat = parseFloat(latMatch[1]) / 1e7;
|
||||
const lon = parseFloat(lonMatch[1]) / 1e7;
|
||||
inlineLinks += ` <a class="inline-link" href="https://www.google.com/maps?q=${lat},${lon}" target="_blank">📍</a>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pkt.portnum === 70) {
|
||||
let traceId = pkt.id;
|
||||
const match = pkt.payload?.match(/ID:\s*(\d+)/i);
|
||||
if (match) traceId = match[1];
|
||||
inlineLinks += ` <a class="inline-link" href="/graph/traceroute/${traceId}" target="_blank">⮕</a>`;
|
||||
}
|
||||
|
||||
// Traceroute link
|
||||
if (pkt.portnum === 70) {
|
||||
let traceId = pkt.id;
|
||||
const match = pkt.payload?.match(/ID:\s*(\d+)/i);
|
||||
if (match) traceId = match[1];
|
||||
inlineLinks += ` <a class="inline-link" href="/graph/traceroute/${traceId}" target="_blank">⮕</a>`;
|
||||
}
|
||||
|
||||
list.insertAdjacentHTML("afterbegin", `
|
||||
<tr class="packet-row">
|
||||
@@ -670,11 +743,15 @@ async function loadPackets(){
|
||||
}
|
||||
}
|
||||
|
||||
// --- Telemetry Charts (battery / air / env) ---
|
||||
/* ======================================================
|
||||
TELEMETRY CHARTS (portnum=67)
|
||||
====================================================== */
|
||||
|
||||
async function loadTelemetryCharts(){
|
||||
const url = `/api/packets?portnum=67&from_node_id=${fromNodeId}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return;
|
||||
|
||||
const data = await res.json();
|
||||
const packets = data.packets || [];
|
||||
chartData = {
|
||||
@@ -690,13 +767,29 @@ async function loadTelemetryCharts(){
|
||||
chartData.times.push(
|
||||
t.toLocaleString([], { month:"2-digit", day:"2-digit", hour:"2-digit", minute:"2-digit" })
|
||||
);
|
||||
chartData.battery.push(parseFloat(pl.match(/battery_level:\s*([\d.]+)/)?.[1] || NaN));
|
||||
chartData.voltage.push(parseFloat(pl.match(/voltage:\s*([\d.]+)/)?.[1] || NaN));
|
||||
chartData.airUtil.push(parseFloat(pl.match(/air_util_tx:\s*([\d.]+)/)?.[1] || NaN));
|
||||
chartData.chanUtil.push(parseFloat(pl.match(/channel_utilization:\s*([\d.]+)/)?.[1] || NaN));
|
||||
chartData.temperature.push(parseFloat(pl.match(/temperature:\s*([\d.]+)/)?.[1] || NaN));
|
||||
chartData.humidity.push(parseFloat(pl.match(/relative_humidity:\s*([\d.]+)/)?.[1] || NaN));
|
||||
chartData.pressure.push(parseFloat(pl.match(/barometric_pressure:\s*([\d.]+)/)?.[1] || NaN));
|
||||
|
||||
// Matches your payload exactly (inside device_metrics {})
|
||||
chartData.battery.push(
|
||||
parseFloat(pl.match(/battery_level:\s*([\d.]+)/)?.[1] || NaN)
|
||||
);
|
||||
chartData.voltage.push(
|
||||
parseFloat(pl.match(/voltage:\s*([\d.]+)/)?.[1] || NaN)
|
||||
);
|
||||
chartData.airUtil.push(
|
||||
parseFloat(pl.match(/air_util_tx:\s*([\d.]+)/)?.[1] || NaN)
|
||||
);
|
||||
chartData.chanUtil.push(
|
||||
parseFloat(pl.match(/channel_utilization:\s*([\d.]+)/)?.[1] || NaN)
|
||||
);
|
||||
chartData.temperature.push(
|
||||
parseFloat(pl.match(/temperature:\s*([\d.]+)/)?.[1] || NaN)
|
||||
);
|
||||
chartData.humidity.push(
|
||||
parseFloat(pl.match(/relative_humidity:\s*([\d.]+)/)?.[1] || NaN)
|
||||
);
|
||||
chartData.pressure.push(
|
||||
parseFloat(pl.match(/barometric_pressure:\s*([\d.]+)/)?.[1] || NaN)
|
||||
);
|
||||
}
|
||||
|
||||
const hasBattery = chartData.battery.some(v => !isNaN(v));
|
||||
@@ -812,7 +905,10 @@ async function loadTelemetryCharts(){
|
||||
});
|
||||
}
|
||||
|
||||
// --- Neighbor chart (latest portnum=71) ---
|
||||
/* ======================================================
|
||||
NEIGHBOR CHART (portnum=71, latest)
|
||||
====================================================== */
|
||||
|
||||
async function loadNeighborChart(){
|
||||
const url = `/api/packets?portnum=71&from_node_id=${fromNodeId}&limit=1`;
|
||||
const res = await fetch(url);
|
||||
@@ -831,6 +927,7 @@ async function loadNeighborChart(){
|
||||
const re = /neighbors\s*\{\s*([^}]+)\}/g;
|
||||
let m;
|
||||
const ids = [], names = [], snrs = [];
|
||||
|
||||
while ((m = re.exec(payload)) !== null) {
|
||||
const block = m[1];
|
||||
const idMatch = block.match(/node_id:\s*(\d+)/);
|
||||
@@ -840,6 +937,11 @@ async function loadNeighborChart(){
|
||||
const nid = parseInt(idMatch[1], 10);
|
||||
const snr = parseFloat(snrMatch[1]);
|
||||
|
||||
// Try to enrich with node name from API (lazy)
|
||||
if (!nodeMap[nid]) {
|
||||
await fetchNodeFromApi(nid);
|
||||
}
|
||||
|
||||
ids.push(nid);
|
||||
names.push(nodeMap[nid] || nid);
|
||||
snrs.push(snr);
|
||||
@@ -884,7 +986,10 @@ async function loadNeighborChart(){
|
||||
});
|
||||
}
|
||||
|
||||
// --- Expand / Export ---
|
||||
/* ======================================================
|
||||
EXPAND / EXPORT BUTTONS
|
||||
====================================================== */
|
||||
|
||||
function expandChart(type){
|
||||
const srcEl = document.getElementById(`chart_${type}`);
|
||||
if (!srcEl) return;
|
||||
@@ -943,7 +1048,10 @@ function exportCSV(type){
|
||||
link.click();
|
||||
}
|
||||
|
||||
// --- Expand payload rows ---
|
||||
/* ======================================================
|
||||
EXPAND PAYLOAD ROWS
|
||||
====================================================== */
|
||||
|
||||
document.addEventListener("click", e => {
|
||||
const btn = e.target.closest(".toggle-btn");
|
||||
if (!btn) return;
|
||||
@@ -952,13 +1060,16 @@ document.addEventListener("click", e => {
|
||||
btn.textContent = row.classList.contains("expanded") ? "▼" : "▶";
|
||||
});
|
||||
|
||||
// --- Init ---
|
||||
/* ======================================================
|
||||
INIT
|
||||
====================================================== */
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
await loadTranslationsNode(); // <-- REQUIRED
|
||||
await loadTranslationsNode(); // translations first
|
||||
|
||||
requestAnimationFrame(async () => {
|
||||
await loadNodes();
|
||||
await loadNodeInfo();
|
||||
await loadNodeInfo(); // single-node fetch
|
||||
if (!map) initMap(); // init map early so neighbors can draw
|
||||
await loadTrack();
|
||||
await loadPackets();
|
||||
await loadTelemetryCharts();
|
||||
@@ -971,4 +1082,4 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
});
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -48,6 +48,7 @@ async def api_channels(request: web.Request):
|
||||
async def api_nodes(request):
|
||||
try:
|
||||
# Optional query parameters
|
||||
node_id = request.query.get("node_id")
|
||||
role = request.query.get("role")
|
||||
channel = request.query.get("channel")
|
||||
hw_model = request.query.get("hw_model")
|
||||
@@ -61,7 +62,7 @@ async def api_nodes(request):
|
||||
|
||||
# Fetch nodes from database
|
||||
nodes = await store.get_nodes(
|
||||
role=role, channel=channel, hw_model=hw_model, days_active=days_active
|
||||
node_id=node_id, role=role, channel=channel, hw_model=hw_model, days_active=days_active
|
||||
)
|
||||
|
||||
# Prepare the JSON response
|
||||
@@ -214,6 +215,7 @@ async def api_packets(request):
|
||||
"portnum": int(p.portnum),
|
||||
"long_name": getattr(p.from_node, "long_name", ""),
|
||||
"payload": (p.payload or "").strip(),
|
||||
"to_long_name": getattr(p.to_node, "long_name", ""),
|
||||
}
|
||||
|
||||
reply_id = getattr(
|
||||
|
||||
Reference in New Issue
Block a user