diff --git a/meshview/lang/en.json b/meshview/lang/en.json index 2936f62..b063000 100644 --- a/meshview/lang/en.json +++ b/meshview/lang/en.json @@ -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", diff --git a/meshview/store.py b/meshview/store.py index db85028..5392b45 100644 --- a/meshview/store.py +++ b/meshview/store.py @@ -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: diff --git a/meshview/templates/node.html b/meshview/templates/node.html index 97809f3..7ed1434 100644 --- a/meshview/templates/node.html +++ b/meshview/templates/node.html @@ -137,25 +137,25 @@
- 📡 Specifications: + 📡 Specifications::
-
Node ID:
-
Long Name:
-
Short Name:
+
Node ID:
+
Long Name:
+
Short Name:
-
Hardware Model:
-
Firmware:
-
Role:
+
Hardware Model:
+
Firmware:
+
Role:
-
Channel:
-
Latitude:
-
Longitude:
+
Channel:
+
Latitude:
+
Longitude:
-
Last Update:
+
Last Update:
@@ -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 ` +
+ + ${node.long_name || node.short_name || node.node_id} + + ${node.short_name ? ` (${node.short_name})` : ""}
+ + + ${nodeTranslations.node_id || "Node ID"}: + ${node.node_id}
+ + + ${nodeTranslations.hw_model || "HW Model"}: + ${node.hw_model ?? "—"}
+ + + ${nodeTranslations.channel || "Channel"}: + ${node.channel ?? "—"}
+ + + ${nodeTranslations.role || "Role"}: + ${node.role ?? "—"}
+ + + ${nodeTranslations.firmware || "Firmware"}: + ${node.firmware ?? "—"}
+ + + ${nodeTranslations.last_update || "Last Update"}: + ${formatLastSeen(node.last_seen_us)} +
+ `; +} + +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 ` ${nodeTranslations.all_broadcast || "All"} `; } - // Direct to MQTT broker + // Direct to MQTT if (id === 1) { return ` ${nodeTranslations.direct_to_mqtt || "Direct to MQTT"} `; } - // Normal node → clickable link - return ` - ${nodeMap[id] || id} + // Normal node + const label = labelOverride || nodeMap[id] || id; + + return ` + ${label} `; } + +/* ====================================================== + 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) : `${id}`; + 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}
` + - `Lat: ${p.lat.toFixed(6)}
` + - `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 += ` 📍`; + if (latMatch && lonMatch) { + const lat = parseFloat(latMatch[1]) / 1e7; + const lon = parseFloat(lonMatch[1]) / 1e7; + inlineLinks += ` 📍`; + } } - } - - if (pkt.portnum === 70) { - let traceId = pkt.id; - const match = pkt.payload?.match(/ID:\s*(\d+)/i); - if (match) traceId = match[1]; - inlineLinks += ` `; - } + // 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 += ` `; + } list.insertAdjacentHTML("afterbegin", ` @@ -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 () => { }); -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/meshview/web_api/api.py b/meshview/web_api/api.py index 923c518..f57bc59 100644 --- a/meshview/web_api/api.py +++ b/meshview/web_api/api.py @@ -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(