efficiency improvement node.html now it only queries the needed node info rather than all the nodes.

This commit is contained in:
Pablo Revilla
2025-12-06 11:26:36 -08:00
parent 989da239fb
commit cd036b8004
4 changed files with 282 additions and 167 deletions

View File

@@ -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",

View File

@@ -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:

View File

@@ -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 == "<" ? "&lt;" : "&gt;");
const safePayload = (pkt.payload || "").replace(/[<>]/g, m => m === "<" ? "&lt;" : "&gt;");
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 %}

View File

@@ -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(