Worked on /api/packet. Needed to modify

- Added new api endpoint /api/packets_seen
- Modified web.py and store.py to support changes to APIs.
- Started to work on new_node.html and new_packet.html for presentation of data.
This commit is contained in:
Pablo Revilla
2025-11-19 11:18:34 -08:00
parent 39c0dd589d
commit 5f5ae75d84
8 changed files with 667 additions and 269 deletions
+1 -30
View File
@@ -35,37 +35,8 @@
<div style="text-align:center" id="site-header"></div>
<div style="text-align:center" id="site-message"></div>
<div style="text-align:center" id="site-menu"></div>
<!-- Search Form -->
<form class="container p-2 sticky-top mx-auto" id="search_form" action="/node_search">
<div class="row">
<input
class="col m-2"
id="q"
type="text"
name="q"
data-translate="node id"
placeholder="Node id"
autocomplete="off"
list="node_options"
value="{{raw_node_id}}"
hx-trigger="input delay:100ms"
hx-get="/node_match"
hx-target="#node_options"
/>
<datalist id="node_options">
{% for option in node_options %}
<option value="{{option.id}}">{{option.id}} -- {{option.long_name}} ({{option.short_name}})</option>
{% endfor %}
</datalist>
<!-- portnum select will be translated dynamically -->
<select id="portnum_select" name="portnum" class="col-2 m-2"></select>
<input type="submit" value="Go to Node" class="col-2 m-2" data-translate="go to node" />
</div>
</form>
<br>
{% block body %}{% endblock %}
<br>
+2 -2
View File
@@ -118,8 +118,8 @@ document.addEventListener("DOMContentLoaded", async () => {
div.innerHTML = `
<span class="col-2 timestamp" title="${packet.import_time_us}">${formattedTimestamp}</span>
<span class="col-2 channel">
<a href="/new_packet/${packet.id}" title="${chatLang.view_packet_details || 'View details'}">✉️</a>
${escapeHtml(packet.channel || "")}
<a href="/new_packet/${packet.id}" title="${chatLang.view_packet_details || 'View details'}">🔎</a>
${escapeHtml(packet.channel || "")}
</span>
<span class="col-3 nodename">
<a href="/new_node/${packet.from_node_id}">
+2 -2
View File
@@ -217,11 +217,11 @@ async function fetchUpdates() {
const toNodeId = pkt.to_node_id;
let from = fromNodeId === 4294967295 ? `<span class="to-mqtt">All</span>` :
`<a href="/packet_list/${fromNodeId}" style="text-decoration:underline; color:inherit;">${nodeMap[fromNodeId] || fromNodeId}</a>`;
`<a href="/new_node/${fromNodeId}" style="text-decoration:underline; color:inherit;">${nodeMap[fromNodeId] || fromNodeId}</a>`;
let to = toNodeId === 1 ? `<span class="to-mqtt">direct to MQTT</span>` :
toNodeId === 4294967295 ? `<span class="to-mqtt">All</span>` :
`<a href="/packet_list/${toNodeId}" style="text-decoration:underline; color:inherit;">${nodeMap[toNodeId] || toNodeId}</a>`;
`<a href="/new_node/${toNodeId}" style="text-decoration:underline; color:inherit;">${nodeMap[toNodeId] || toNodeId}</a>`;
let links = "";
if (pkt.portnum === 3 && pkt.payload) {
+1 -1
View File
@@ -205,7 +205,7 @@ function renderNodesOnMap(){
marker.nodeId = node.key;
marker.originalColor = color;
markerById[node.key] = marker;
const popup = `<b><a href="/packet_list/${node.node_id}">${node.long_name}</a> (${node.short_name})</b><br>
const popup = `<b><a href="/new_node/${node.node_id}">${node.long_name}</a> (${node.short_name})</b><br>
<b>Channel:</b> ${node.channel}<br>
<b>Model:</b> ${node.hw_model}<br>
<b>Role:</b> ${node.role}<br>
+617 -204
View File
@@ -16,19 +16,26 @@
z-index: 1;
}
/* --- Node Info --- */
/* --- Node Info (3-column compact grid) --- */
.node-info {
background-color: #1f2226;
border: 1px solid #3a3f44;
color: #ddd;
font-size: 0.9rem;
max-width: 400px;
padding: 8px 12px;
margin-bottom: 10px;
font-size: 0.88rem;
padding: 12px 14px;
margin-bottom: 14px;
border-radius: 8px;
display: grid;
grid-template-columns: repeat(3, minmax(120px, 1fr));
grid-column-gap: 14px;
grid-row-gap: 6px;
}
.node-info div {
margin-bottom: 3px;
padding: 2px 0;
}
.node-info strong {
color: #9fd4ff;
font-weight: 600;
@@ -71,7 +78,7 @@
border-color: #888;
}
/* --- Table --- */
/* --- Packet Table --- */
.packet-table {
width: 100%;
border-collapse: collapse;
@@ -91,11 +98,9 @@
.packet-table tr:nth-of-type(even) { background-color: #212529; }
.port-tag {
display: inline-block;
padding: 2px 6px;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 500;
color: #fff;
}
.port-1 { background-color: #007bff; }
@@ -106,16 +111,16 @@
.port-67 { background-color: #17a2b8; }
.port-70 { background-color: #ff7043; }
.port-71 { background-color: #ff66cc; }
.port-0, .port-unknown { background-color: #6c757d; }
.port-0 { background-color: #6c757d; }
.to-mqtt { font-style: italic; color: #aaa; }
.payload-row { display: none; background-color: #1b1e22; }
.payload-cell { padding: 8px 12px; font-family: monospace; white-space: pre-wrap; color: #b0bec5; border-top: none; }
.packet-table tr.expanded + .payload-row { display: table-row; }
.toggle-btn { cursor: pointer; color: #aaa; margin-right: 6px; font-weight: bold; }
.toggle-btn { cursor: pointer; color: #aaa; margin-right: 6px; }
.toggle-btn:hover { color: #fff; }
/* --- Chart modal --- */
/* --- Chart Modal --- */
#chartModal {
display:none; position:fixed; top:0; left:0; width:100%; height:100%;
background:rgba(0,0,0,0.9); z-index:9999;
@@ -129,14 +134,23 @@
{% block body %}
<div class="container">
<h5 class="mb-3">📡 Node Feed: <span id="nodeLabel"></span></h5>
<!-- Node Info -->
<div id="node-info" class="node-info p-3 rounded">
<h5 class="mb-3">📡 Node Details: <span id="nodeLabel"></span></h5>
<!-- Node Info (3 column grid) -->
<div id="node-info" class="node-info">
<div><strong>Node ID:</strong> <span id="info-node-id"></span></div>
<div><strong>Channel:</strong> <span id="info-channel"></span></div>
<div><strong>HW Model:</strong> <span id="info-hw-model"></span></div>
<div><strong>Long Name:</strong> <span id="info-long-name"></span></div>
<div><strong>Short Name:</strong> <span id="info-short-name"></span></div>
<div><strong>Hardware Model:</strong> <span id="info-hw-model"></span></div>
<div><strong>Firmware:</strong> <span id="info-firmware"></span></div>
<div><strong>Role:</strong> <span id="info-role"></span></div>
<div><strong>Channel:</strong> <span id="info-channel"></span></div>
<div><strong>Latitude:</strong> <span id="info-lat"></span></div>
<div><strong>Longitude:</strong> <span id="info-lon"></span></div>
<div><strong>Last Update:</strong> <span id="info-last-update"></span></div>
</div>
@@ -144,8 +158,9 @@
<div id="map" style="min-height:400px;"></div>
<!-- Charts -->
<div class="chart-container">
<div class="chart-header">🔋 Battery & Voltage
<div id="battery_voltage_container" class="chart-container">
<div class="chart-header">
🔋 Battery & Voltage
<div class="chart-actions">
<button onclick="expandChart('battery_voltage')">Expand</button>
<button onclick="exportCSV('battery_voltage')">Export CSV</button>
@@ -154,8 +169,9 @@
<div id="chart_battery_voltage" style="height:260px;"></div>
</div>
<div class="chart-container">
<div class="chart-header">📶 Air & Channel Utilization
<div id="air_channel_container" class="chart-container">
<div class="chart-header">
📶 Air & Channel Utilization
<div class="chart-actions">
<button onclick="expandChart('air_channel')">Expand</button>
<button onclick="exportCSV('air_channel')">Export CSV</button>
@@ -165,7 +181,8 @@
</div>
<div id="env_chart_container" class="chart-container" style="display:none;">
<div class="chart-header">🌡️ Environment Metrics
<div class="chart-header">
🌡️ Environment Metrics
<div class="chart-actions">
<button onclick="expandChart('environment')">Expand</button>
<button onclick="exportCSV('environment')">Export CSV</button>
@@ -174,6 +191,18 @@
<div id="chart_environment" style="height:260px;"></div>
</div>
<!-- Neighbors chart -->
<div id="neighbor_chart_container" class="chart-container" style="display:none;">
<div class="chart-header">
📡 Neighbors (Signal-to-Noise)
<div class="chart-actions">
<button onclick="expandChart('neighbors')">Expand</button>
<button onclick="exportCSV('neighbors')">Export CSV</button>
</div>
</div>
<div id="chart_neighbors" style="height:260px;"></div>
</div>
<!-- Table -->
<table class="packet-table">
<thead>
@@ -187,6 +216,7 @@
</thead>
<tbody id="packet_list"></tbody>
</table>
</div>
<!-- Modal -->
@@ -201,250 +231,633 @@
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
<script>
let nodeMap={}, nodePositions={}, map, markers={}, chartData={};
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];}
let nodeMap = {}, nodePositions = {}, 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(){
try{
const res=await fetch("/api/nodes");
if(!res.ok){console.error("Failed /api/nodes",res.status);return;}
const data=await res.json();
allNodes=data.nodes||[];
console.log(`Loaded ${allNodes.length} nodes`);
for(const n of allNodes){
const name=n.long_name||n.short_name||n.id||n.node_id;
nodeMap[n.node_id]=name;
if(n.last_lat&&n.last_long)
nodePositions[n.node_id]=[n.last_lat/1e7,n.last_long/1e7];
try {
const res = await fetch("/api/nodes");
if (!res.ok) {
console.error("Failed /api/nodes", res.status);
return;
}
nodeMap[4294967295]="All";
document.getElementById("nodeLabel").textContent=nodeMap[fromNodeId]||fromNodeId;
}catch(err){console.error("Error loading nodes:",err);}
const data = await res.json();
allNodes = data.nodes || [];
for (const n of allNodes) {
const name = n.long_name || n.short_name || n.id || n.node_id;
nodeMap[n.node_id] = name;
if (n.last_lat && n.last_long)
nodePositions[n.node_id] = [n.last_lat / 1e7, n.last_long / 1e7];
}
nodeMap[4294967295] = "All";
document.getElementById("nodeLabel").textContent = nodeMap[fromNodeId] || fromNodeId;
} catch (err) {
console.error("Error loading nodes:", err);
}
}
// --- Load single node info from cached list ---
// --- Load Node Info ---
async function loadNodeInfo(){
try{
if(!allNodes.length) await loadNodes();
const node=allNodes.find(n=>String(n.node_id)===String(fromNodeId));
if(!node){console.warn("Node not found",fromNodeId);
document.getElementById("node-info").style.display="none";return;}
console.log("Loaded node info:",node);
try {
if (!allNodes.length) await loadNodes();
document.getElementById("info-node-id").textContent=node.id||node.node_id||"—";
document.getElementById("info-channel").textContent=node.channel||"—";
document.getElementById("info-hw-model").textContent=node.hw_model||"—";
document.getElementById("info-role").textContent=node.role||"—";
const last=node.last_update?new Date(node.last_update).toLocaleString([],{
month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit"}):"—";
document.getElementById("info-last-update").textContent=last;
document.getElementById("node-info").style.display="block";
}catch(err){
console.error("Failed to load node info:",err);
document.getElementById("node-info").style.display="none";
const node = allNodes.find(n => String(n.node_id) === String(fromNodeId));
if (!node) {
document.getElementById("node-info").style.display = "none";
return;
}
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 ?? "—";
document.getElementById("info-hw-model").textContent = node.hw_model ?? "—";
document.getElementById("info-firmware").textContent = node.firmware ?? "—";
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) : "—";
const last = node.last_update
? new Date(node.last_update).toLocaleString([], { month:"2-digit", day:"2-digit", hour:"2-digit", minute:"2-digit" })
: "—";
document.getElementById("info-last-update").textContent = last;
} catch (err) {
console.error("Failed to load node info:", err);
document.getElementById("node-info").style.display = "none";
}
}
// --- Helpers ---
function nodeLink(id){
if(id===4294967295) return `<span class="to-mqtt">All</span>`;
if(id===1) return `<span class="to-mqtt">Direct to MQTT</span>`;
return `<a href="/firehose/node/${id}" style="text-decoration:underline; color:inherit;">${nodeMap[id]||id}</a>`;
if (id === 4294967295) return `<span class="to-mqtt">All</span>`;
if (id === 1) return `<span class="to-mqtt">Direct to MQTT</span>`;
return `<a href="/new_node/${id}" style="text-decoration:underline; color:inherit;">${nodeMap[id] || id}</a>`;
}
function portLabel(p){
const names={0:"UNKNOWN APP",1:"Text",3:"Position",4:"Node Info",5:"Routing",6:"Admin",67:"Telemetry",70:"Traceroute",71:"Neighbor"};
const label=names[p]||"Unknown";
function portLabel(p, id){
const names = {
0:"UNKNOWN APP",
1:"Text",
3:"Position",
4:"Node Info",
5:"Routing",
6:"Admin",
67:"Telemetry",
70:"Traceroute",
71:"Neighbor"
};
const label = names[p] || "Unknown";
// Add arrow ONLY for traceroute packets
if (p === 70) {
return `
<span class="port-tag port-${p}">${label}</span>
<a href="/graph/traceroute/${id}"
style="color:#ccc; text-decoration:none; margin-left:6px;">⮕️</a>
`;
}
return `<span class="port-tag port-${p}">${label}</span>`;
}
function formatLocalTime(us){
return new Date(us/1000).toLocaleString([], {month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit"});
return new Date(us / 1000).toLocaleString([], {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
}
// --- Map ---
function initMap(){
map=L.map('map',{preferCanvas:true}).setView([37.7749,-122.4194],12);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{attribution:'&copy; OpenStreetMap'}).addTo(map);
console.log("✅ Map initialized");
map = L.map('map', { preferCanvas:true }).setView([37.7749, -122.4194], 12);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution:'&copy; OpenStreetMap'
}).addTo(map);
}
function addMarker(id,lat,lon,label,color="red"){
if(isNaN(lat)||isNaN(lon))return;
nodePositions[id]=[lat,lon];
const m=L.circleMarker([lat,lon],{radius:5,color,fillColor:color,fillOpacity:1}).addTo(map).bindPopup(label);
markers[id]=m;m.bringToFront();
function hideMap(){
const mapDiv = document.getElementById("map");
if (mapDiv) {
mapDiv.style.display = "none";
}
}
function drawNeighbors(src,nids){
const s=nodePositions[src]; if(!s)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);
function addMarker(id, lat, lon, label, color = "red"){
if (!map) return;
if (isNaN(lat) || isNaN(lon)) return;
nodePositions[id] = [lat, lon];
const m = L.circleMarker([lat, lon], {
radius: 5,
color,
fillColor: color,
fillOpacity: 1
}).addTo(map).bindPopup(label);
markers[id] = m;
m.bringToFront();
}
function drawNeighbors(src, nids){
if (!map) return;
const s = nodePositions[src];
if (!s) 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);
}
}
}
function ensureMapVisible(){
if(!map)return;
requestAnimationFrame(()=>{
if (!map) return;
requestAnimationFrame(() => {
map.invalidateSize();
const group=L.featureGroup(Object.values(markers));
if(group.getLayers().length>0) map.fitBounds(group.getBounds(),{padding:[20,20]});
else map.setView([37.7749,-122.4194],11);
const group = L.featureGroup(Object.values(markers));
if (group.getLayers().length > 0)
map.fitBounds(group.getBounds(), { padding:[20,20] });
else
map.setView([37.7749, -122.4194], 11);
});
}
// --- Packets ---
// --- Position Track ---
async function loadTrack(){
try {
const url = new URL("/api/packets", window.location.origin);
url.searchParams.set("portnum", 3);
url.searchParams.set("from_node_id", fromNodeId);
// Last 24 hours in microseconds
const nowUs = Date.now() * 1000;
const sinceUs = nowUs - (24 * 60 * 60 * 1000 * 1000);
url.searchParams.set("since", sinceUs);
url.searchParams.set("limit", 50); // large enough to capture full day
const res = await fetch(url);
if (!res.ok) {
hideMap();
return;
}
const data = await res.json();
const packets = data.packets || [];
const points = [];
for (const pkt of packets) {
if (!pkt.payload) continue;
const latMatch = pkt.payload.match(/latitude_i:\s*(-?\d+)/);
const lonMatch = pkt.payload.match(/longitude_i:\s*(-?\d+)/);
if (!latMatch || !lonMatch) continue;
const lat = parseInt(latMatch[1], 10) / 1e7;
const lon = parseInt(lonMatch[1], 10) / 1e7;
if (isNaN(lat) || isNaN(lon)) continue;
points.push({
lat,
lon,
time: pkt.import_time_us
});
}
if (!points.length) {
// No position packets -> hide map entirely
hideMap();
return;
}
// Sort chronologically (oldest -> newest)
points.sort((a, b) => a.time - b.time);
// Track node's last known position
const latest = points[points.length - 1];
nodePositions[fromNodeId] = [latest.lat, latest.lon];
// Ensure map exists
if (!map) {
initMap();
}
const latlngs = points.map(p => [p.lat, p.lon]);
const trackLine = L.polyline(latlngs, {
color: '#6e460b',
weight: 2
}).addTo(map);
// First + last markers only
const first = points[0];
const last = points[points.length - 1];
const startMarker = L.circleMarker([first.lat, first.lon], {
radius: 6,
color: 'green',
fillColor: 'green',
fillOpacity: 1
}).addTo(map).bindPopup("Start");
const endMarker = L.circleMarker([last.lat, last.lon], {
radius: 6,
color: 'red',
fillColor: 'red',
fillOpacity: 1
}).addTo(map).bindPopup("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) {
console.error("Failed to load track:", err);
hideMap();
}
}
// --- Packets (for table + neighbor map overlay) ---
async function loadPackets(){
const url=new URL("/api/packets",window.location.origin);
url.searchParams.set("from_node_id",fromNodeId);
url.searchParams.set("limit",200);
const res=await fetch(url); if(!res.ok)return;
const data=await res.json(); const list=document.getElementById("packet_list");
for(const pkt of (data.packets||[]).reverse()){
const safePayload=(pkt.payload||"").replace(/[<>]/g,m=>m=="<"?"&lt;":"&gt;");
const localTime=formatLocalTime(pkt.import_time_us);
const fromCell=nodeLink(pkt.from_node_id),toCell=nodeLink(pkt.to_node_id);
if(pkt.portnum===3&&pkt.payload){
const lat=pkt.payload.match(/latitude_i:\s*(-?\d+)/),lon=pkt.payload.match(/longitude_i:\s*(-?\d+)/);
if(lat&&lon){addMarker(pkt.from_node_id,parseInt(lat[1])/1e7,parseInt(lon[1])/1e7,nodeMap[pkt.from_node_id]||pkt.from_node_id,"red");}
const url = new URL("/api/packets", window.location.origin);
url.searchParams.set("from_node_id", fromNodeId);
url.searchParams.set("limit", 200);
const res = await fetch(url);
if (!res.ok) return;
const data = await res.json();
const list = document.getElementById("packet_list");
for (const pkt of (data.packets || []).reverse()) {
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);
// Neighbor packets still update map overlay if map exists
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);
}
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);
}
list.insertAdjacentHTML("afterbegin",`
list.insertAdjacentHTML("afterbegin", `
<tr class="packet-row">
<td>${localTime}</td>
<td><span class="toggle-btn">▶</span> <a href="/packet/${pkt.id}" style="text-decoration:underline; color:inherit;">${pkt.id}</a></td>
<td>${fromCell}</td><td>${toCell}</td><td>${portLabel(pkt.portnum)}</td>
</tr><tr class="payload-row"><td colspan="5" class="payload-cell">${safePayload}</td></tr>`);
<td>${localTime}</td>
<td><span class="toggle-btn">▶</span> <a href="/new_packet/${pkt.id}" style="text-decoration:underline; color:inherit;">${pkt.id}</a></td>
<td>${fromCell}</td>
<td>${toCell}</td>
<td>${portLabel(pkt.portnum, pkt.id)}</td>
</tr>
<tr class="payload-row">
<td colspan="5" class="payload-cell">${safePayload}</td>
</tr>`);
}
}
// --- Charts ---
// --- Telemetry Charts (battery / air / env) ---
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={times:[],battery:[],voltage:[],airUtil:[],chanUtil:[],temperature:[],humidity:[],pressure:[]};
for(const pkt of packets.reverse()){
const pl=pkt.payload||"",t=new Date(pkt.import_time_us/1000);
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));
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 = {
times: [],
battery: [], voltage: [],
airUtil: [], chanUtil: [],
temperature: [], humidity: [], pressure: []
};
for (const pkt of packets.reverse()) {
const pl = pkt.payload || "";
const t = new Date(pkt.import_time_us / 1000);
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));
}
const makeLine=(name,color,data,yAxisIndex=0)=>({
name,type:'line',smooth:true,connectNulls:true,yAxisIndex,
showSymbol:true,symbol:'circle',symbolSize:8,
lineStyle:{width:2,color,shadowColor:color.replace('1)','0.4)'),shadowBlur:8,shadowOffsetY:3},
itemStyle:{color,borderColor:'#000',borderWidth:1},
areaStyle:{color:new echarts.graphic.LinearGradient(0,0,0,1,[
{offset:0,color:color.replace('1)','0.65)')},
{offset:0.5,color:color.replace('1)','0.35)')},
{offset:1,color:'rgba(0,0,0,0)'}
])},
data:data.map(v=>isNaN(v)?null:v)
const hasBattery = chartData.battery.some(v => !isNaN(v));
const hasVoltage = chartData.voltage.some(v => !isNaN(v));
const hasAir = chartData.airUtil.some(v => !isNaN(v));
const hasChan = chartData.chanUtil.some(v => !isNaN(v));
const hasEnv =
chartData.temperature.some(v => !isNaN(v)) ||
chartData.humidity.some(v => !isNaN(v)) ||
chartData.pressure.some(v => !isNaN(v));
const batteryContainer = document.getElementById("battery_voltage_container");
const airContainer = document.getElementById("air_channel_container");
const envContainer = document.getElementById("env_chart_container");
const makeLine = (name, color, data, yAxisIndex = 0) => ({
name,
type: 'line',
smooth: true,
connectNulls: true,
yAxisIndex,
showSymbol: true,
symbol: 'circle',
symbolSize: 8,
lineStyle: {
width: 2,
color,
shadowColor: color.replace('1)', '0.4)'),
shadowBlur: 8,
shadowOffsetY: 3
},
itemStyle: {
color,
borderColor: '#000',
borderWidth: 1
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: color.replace('1)', '0.65)') },
{ offset: 0.5, color: color.replace('1)', '0.35)') },
{ offset: 1, color: 'rgba(0,0,0,0)' }
])
},
data: data.map(v => isNaN(v) ? null : v)
});
const chart1=echarts.init(document.getElementById('chart_battery_voltage'));
chart1.setOption({
tooltip:{trigger:'axis'},
legend:{data:['Battery Level','Voltage'],textStyle:{color:'#ccc'}},
xAxis:{type:'category',data:chartData.times,axisLabel:{color:'#ccc'}},
yAxis:[{type:'value',name:'Battery (%)',axisLabel:{color:'#ccc'}},{type:'value',name:'Voltage (V)',axisLabel:{color:'#ccc'}}],
series:[
makeLine('Battery Level','rgba(255,214,82,1)',chartData.battery),
makeLine('Voltage','rgba(79,155,255,1)',chartData.voltage,1)
]
});
let chart1 = null, chart2 = null, chart3 = null;
const chart2=echarts.init(document.getElementById('chart_air_channel'));
chart2.setOption({
tooltip:{trigger:'axis'},
legend:{data:['Air Util Tx','Channel Utilization'],textStyle:{color:'#ccc'}},
xAxis:{type:'category',data:chartData.times,axisLabel:{color:'#ccc'}},
yAxis:{type:'value',name:'%',axisLabel:{color:'#ccc'}},
series:[
makeLine('Air Util Tx','rgba(138,255,108,1)',chartData.airUtil),
makeLine('Channel Utilization','rgba(255,102,204,1)',chartData.chanUtil)
]
});
let chart3=null;
if(chartData.temperature.some(v=>!isNaN(v))){
document.getElementById("env_chart_container").style.display="block";
chart3=echarts.init(document.getElementById('chart_environment'));
chart3.setOption({
tooltip:{trigger:'axis'},
legend:{data:['Temperature (°C)','Humidity (%)','Pressure (hPa)'],textStyle:{color:'#ccc'}},
xAxis:{type:'category',data:chartData.times,axisLabel:{color:'#ccc'}},
yAxis:[{type:'value',name:'°C / %',axisLabel:{color:'#ccc'}},{type:'value',name:'hPa',axisLabel:{color:'#ccc'}}],
series:[
makeLine('Temperature (°C)','rgba(255,138,82,1)',chartData.temperature),
makeLine('Humidity (%)','rgba(138,255,108,1)',chartData.humidity),
makeLine('Pressure (hPa)','rgba(79,155,255,1)',chartData.pressure,1)
// Battery / Voltage chart
if (hasBattery || hasVoltage) {
batteryContainer.style.display = "block";
chart1 = echarts.init(document.getElementById('chart_battery_voltage'));
chart1.setOption({
tooltip: { trigger:'axis' },
legend: { data:['Battery Level','Voltage'], textStyle:{ color:'#ccc' } },
xAxis: { type:'category', data:chartData.times, axisLabel:{ color:'#ccc' } },
yAxis: [
{ type:'value', name:'Battery (%)', axisLabel:{ color:'#ccc' } },
{ type:'value', name:'Voltage (V)', axisLabel:{ color:'#ccc' } }
],
series: [
makeLine('Battery Level', 'rgba(255,214,82,1)', chartData.battery),
makeLine('Voltage', 'rgba(79,155,255,1)', chartData.voltage, 1)
]
});
} else {
batteryContainer.style.display = "none";
}
window.addEventListener("resize",()=>{[chart1,chart2,chart3].forEach(c=>{if(c)c.resize();});});
// Air / Channel chart
if (hasAir || hasChan) {
airContainer.style.display = "block";
chart2 = echarts.init(document.getElementById('chart_air_channel'));
chart2.setOption({
tooltip: { trigger:'axis' },
legend: { data:['Air Util Tx','Channel Utilization'], textStyle:{ color:'#ccc' } },
xAxis: { type:'category', data:chartData.times, axisLabel:{ color:'#ccc' } },
yAxis: { type:'value', name:'%', axisLabel:{ color:'#ccc' } },
series: [
makeLine('Air Util Tx', 'rgba(138,255,108,1)', chartData.airUtil),
makeLine('Channel Utilization', 'rgba(255,102,204,1)', chartData.chanUtil)
]
});
} else {
airContainer.style.display = "none";
}
// Environment chart
if (hasEnv) {
envContainer.style.display = "block";
chart3 = echarts.init(document.getElementById('chart_environment'));
chart3.setOption({
tooltip: { trigger:'axis' },
legend: { data:['Temperature (°C)','Humidity (%)','Pressure (hPa)'], textStyle:{ color:'#ccc' } },
xAxis: { type:'category', data:chartData.times, axisLabel:{ color:'#ccc' } },
yAxis: [
{ type:'value', name:'°C / %', axisLabel:{ color:'#ccc' } },
{ type:'value', name:'hPa', axisLabel:{ color:'#ccc' } }
],
series: [
makeLine('Temperature (°C)', 'rgba(255,138,82,1)', chartData.temperature),
makeLine('Humidity (%)', 'rgba(138,255,108,1)', chartData.humidity),
makeLine('Pressure (hPa)', 'rgba(79,155,255,1)', chartData.pressure, 1)
]
});
} else {
envContainer.style.display = "none";
}
// Resize charts that exist
window.addEventListener("resize", () => {
[chart1, chart2, chart3].forEach(c => { if (c) c.resize(); });
});
}
// --- Expand/Export ---
function expandChart(type){
const modal=document.getElementById('chartModal');
const modalChart=echarts.init(document.getElementById('modalChart'));
modal.style.display="flex";
const opt=echarts.getInstanceByDom(document.getElementById(`chart_${type}`)).getOption();
modalChart.setOption(opt); modalChart.resize();
// --- Neighbor chart (latest portnum=71) ---
async function loadNeighborChart(){
const url = `/api/packets?portnum=71&from_node_id=${fromNodeId}&limit=1`;
const res = await fetch(url);
if (!res.ok) return;
const data = await res.json();
const packets = data.packets || [];
if (!packets.length) {
document.getElementById("neighbor_chart_container").style.display = "none";
return;
}
const pkt = packets[0];
const payload = pkt.payload || "";
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+)/);
const snrMatch = block.match(/snr:\s*(-?\d+(?:\.\d+)?)/);
if (!idMatch || !snrMatch) continue;
const nid = parseInt(idMatch[1], 10);
const snr = parseFloat(snrMatch[1]);
ids.push(nid);
names.push(nodeMap[nid] || nid);
snrs.push(snr);
}
if (!ids.length) {
document.getElementById("neighbor_chart_container").style.display = "none";
return;
}
neighborData = { ids, names, snrs };
const container = document.getElementById("neighbor_chart_container");
container.style.display = "block";
const chartEl = document.getElementById("chart_neighbors");
const neighborChart = echarts.init(chartEl);
neighborChart.setOption({
tooltip: { trigger:'axis' },
legend: { data:['SNR (dB)'], textStyle:{ color:'#ccc' } },
xAxis: {
type:'category',
data:names,
axisLabel:{ color:'#ccc', rotate: names.length > 8 ? 45 : 0 }
},
yAxis: {
type:'value',
name:'SNR (dB)',
axisLabel:{ color:'#ccc' }
},
series:[{
name:'SNR (dB)',
type:'bar',
data:snrs,
itemStyle:{ color:'rgba(138,255,108,1)' }
}]
});
window.addEventListener("resize", () => {
neighborChart.resize();
});
}
function closeModal(){document.getElementById('chartModal').style.display="none";}
// --- Expand / Export ---
function expandChart(type){
const srcEl = document.getElementById(`chart_${type}`);
if (!srcEl) return;
const sourceChart = echarts.getInstanceByDom(srcEl);
if (!sourceChart) return;
const modal = document.getElementById('chartModal');
const modalChart = echarts.init(document.getElementById('modalChart'));
modal.style.display = "flex";
modalChart.setOption(sourceChart.getOption());
modalChart.resize();
}
function closeModal(){
document.getElementById('chartModal').style.display = "none";
}
function exportCSV(type){
const rows=[["Time"]];
if(type==="battery_voltage"){rows[0].push("Battery Level","Voltage");
for(let i=0;i<chartData.times.length;i++)rows.push([chartData.times[i],chartData.battery[i],chartData.voltage[i]]);}
else if(type==="air_channel"){rows[0].push("Air Util Tx","Channel Utilization");
for(let i=0;i<chartData.times.length;i++)rows.push([chartData.times[i],chartData.airUtil[i],chartData.chanUtil[i]]);}
else{rows[0].push("Temperature","Humidity","Pressure");
for(let i=0;i<chartData.times.length;i++)rows.push([chartData.times[i],chartData.temperature[i],chartData.humidity[i],chartData.pressure[i]]);}
const csv=rows.map(r=>r.join(",")).join("\n");
const blob=new Blob([csv],{type:"text/csv"});
const link=document.createElement("a");link.href=URL.createObjectURL(blob);
link.download=`${type}_${fromNodeId}.csv`;link.click();
const rows = [["Time"]];
if (type === "battery_voltage") {
rows[0].push("Battery Level", "Voltage");
for (let i = 0; i < chartData.times.length; i++)
rows.push([chartData.times[i], chartData.battery[i], chartData.voltage[i]]);
}
else if (type === "air_channel") {
rows[0].push("Air Util Tx", "Channel Utilization");
for (let i = 0; i < chartData.times.length; i++)
rows.push([chartData.times[i], chartData.airUtil[i], chartData.chanUtil[i]]);
}
else if (type === "environment") {
rows[0].push("Temperature", "Humidity", "Pressure");
for (let i = 0; i < chartData.times.length; i++)
rows.push([
chartData.times[i],
chartData.temperature[i],
chartData.humidity[i],
chartData.pressure[i]
]);
}
else if (type === "neighbors") {
rows[0] = ["Neighbor Node ID", "Neighbor Name", "SNR (dB)"];
for (let i = 0; i < neighborData.ids.length; i++) {
rows.push([
neighborData.ids[i],
neighborData.names[i],
neighborData.snrs[i]
]);
}
}
const csv = rows.map(r => r.join(",")).join("\n");
const blob = new Blob([csv], { type:"text/csv" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = `${type}_${fromNodeId}.csv`;
link.click();
}
// --- Expand payload rows ---
document.addEventListener("click",e=>{
const btn=e.target.closest(".toggle-btn");
if(!btn)return;
const row=btn.closest(".packet-row");
document.addEventListener("click", e => {
const btn = e.target.closest(".toggle-btn");
if (!btn) return;
const row = btn.closest(".packet-row");
row.classList.toggle("expanded");
btn.textContent=row.classList.contains("expanded")?"▼":"▶";
btn.textContent = row.classList.contains("expanded") ? "▼" : "▶";
});
// --- Init ---
document.addEventListener("DOMContentLoaded",async()=>{
requestAnimationFrame(async ()=>{
initMap();
document.addEventListener("DOMContentLoaded", async () => {
requestAnimationFrame(async () => {
await loadNodes();
await loadNodeInfo();
await loadPackets();
await loadTrack(); // builds track & decides whether to show map
await loadPackets(); // table + neighbor overlay (if map exists)
await loadTelemetryCharts();
await loadNeighborChart();
ensureMapVisible();
setTimeout(ensureMapVisible,1000);
window.addEventListener("resize",ensureMapVisible);
window.addEventListener("focus",ensureMapVisible);
setTimeout(ensureMapVisible, 1000);
window.addEventListener("resize", ensureMapVisible);
window.addEventListener("focus", ensureMapVisible);
});
});
</script>
+42 -28
View File
@@ -136,7 +136,7 @@
<table class="table table-dark table-sm seen-table">
<thead>
<tr>
<th>Node</th>
<th>Gateway</th>
<th>RSSI</th>
<th>SNR</th>
<th>Hop</th>
@@ -168,7 +168,22 @@ document.addEventListener("DOMContentLoaded", async () => {
const packetId = match[1];
// ------------------------------------------------
// FETCH PACKET FIRST
// PORT NAME LOOKUP (your exact mapping)
// ------------------------------------------------
const PORT_NAMES = {
0:"UNKNOWN APP",
1:"Text",
3:"Position",
4:"Node Info",
5:"Routing",
6:"Admin",
67:"Telemetry",
70:"Traceroute",
71:"Neighbor"
};
// ------------------------------------------------
// FETCH PACKET
// ------------------------------------------------
const packetRes = await fetch(`/api/packets?packet_id=${packetId}`);
const packetData = await packetRes.json();
@@ -180,7 +195,7 @@ document.addEventListener("DOMContentLoaded", async () => {
const p = packetData.packets[0];
// ------------------------------------------------
// FETCH NODES NOW (BEFORE RENDERING CARD)
// FETCH NODES
// ------------------------------------------------
const nodesRes = await fetch("/api/nodes");
const nodesData = await nodesRes.json();
@@ -188,18 +203,17 @@ document.addEventListener("DOMContentLoaded", async () => {
const nodeLookup = {};
(nodesData.nodes || []).forEach(n => nodeLookup[n.node_id] = n);
// Friendly From Node name
// Names
const fromNodeObj = nodeLookup[p.from_node_id];
const fromNodeLabel = fromNodeObj?.long_name || p.from_node_id;
// Friendly To Node name
const toNodeObj = nodeLookup[p.to_node_id];
const toNodeLabel = (p.to_node_id == 4294967295)
? "Broadcast"
: (toNodeObj?.long_name || p.to_node_id);
const toNodeLabel = p.to_node_id == 4294967295
? "All"
: (toNodeObj?.long_name || p.to_node_id);
// ------------------------------------------------
// Decode GPS + Telemetry
// Decode payload
// ------------------------------------------------
let lat = null, lon = null;
const parsed = {};
@@ -226,11 +240,11 @@ document.addEventListener("DOMContentLoaded", async () => {
: "—";
// ------------------------------------------------
// RENDER PACKET CARD (NOW SAFE)
// RENDER PACKET CARD
// ------------------------------------------------
packetCard.innerHTML = `
<div class="card-header">
<span>Packet ${p.id}</span>
<span>Packet ID: <i>${p.id}</i></span>
<small>${time}</small>
</div>
@@ -240,13 +254,24 @@ document.addEventListener("DOMContentLoaded", async () => {
<dd><a href="/new_node/${p.from_node_id}">${fromNodeLabel}</a></dd>
<dt>To Node</dt>
<dd><a href="/new_node/${p.to_node_id}">${toNodeLabel}</a></dd>
<dd>
${
p.to_node_id == 4294967295
? `<i>All</i>`
: `<a href="/new_node/${p.to_node_id}">${toNodeLabel}</a>`
}
</dd>
<dt>Channel</dt>
<dd>${p.channel ?? "—"}</dd>
<dt>Port</dt>
<dd>${p.portnum}</dd>
<dd>
<span style="font-style: italic;">
${PORT_NAMES[p.portnum] || "UNKNOWN APP"}
</span>
(${p.portnum})
</dd>
<dt>Raw Payload</dt>
<dd><pre>${escapeHtml(p.payload ?? "—")}</pre></dd>
@@ -274,7 +299,7 @@ document.addEventListener("DOMContentLoaded", async () => {
packetCard.classList.remove("d-none");
// ------------------------------------------------
// ALWAYS SHOW MAP
// MAP INITIALIZATION
// ------------------------------------------------
const map = L.map("map");
mapDiv.style.display = "block";
@@ -298,7 +323,7 @@ document.addEventListener("DOMContentLoaded", async () => {
}
// ------------------------------------------------
// COLOR SCALE FOR HOP MARKERS
// Color by hop
// ------------------------------------------------
function hopColor(hop){
const c=[
@@ -310,9 +335,6 @@ document.addEventListener("DOMContentLoaded", async () => {
return c[hop-1];
}
// ------------------------------------------------
// HAVERSINE
// ------------------------------------------------
function haversine(lat1,lon1,lat2,lon2){
const R=6371;
const dLat=(lat2-lat1)*Math.PI/180;
@@ -325,13 +347,12 @@ document.addEventListener("DOMContentLoaded", async () => {
}
// ------------------------------------------------
// FETCH SEEN LIST
// SEEN LIST
// ------------------------------------------------
const seenRes = await fetch(`/api/packets_seen/${packetId}`);
const seenData = await seenRes.json();
const seenList = seenData.seen ?? [];
// Sort by hop_start (DESC)
const seenSorted = seenList.slice().sort((a,b)=>{
const A=a.hop_start??-999;
const B=b.hop_start??-999;
@@ -343,9 +364,6 @@ document.addEventListener("DOMContentLoaded", async () => {
seenCountSpan.textContent=`(${seenSorted.length} gateways)`;
}
// ------------------------------------------------
// RENDER SEEN TABLE + MAP MARKERS
// ------------------------------------------------
seenTableBody.innerHTML = seenSorted.map(s=>{
const node=nodeLookup[s.node_id];
const label=node?(node.long_name||node.node_id):s.node_id;
@@ -354,7 +372,6 @@ document.addEventListener("DOMContentLoaded", async () => {
? new Date(s.import_time_us/1000).toLocaleTimeString()
: "—";
// --- map marker ---
if(node?.last_lat && node.last_long){
const rlat=node.last_lat/1e7;
const rlon=node.last_long/1e7;
@@ -398,7 +415,7 @@ document.addEventListener("DOMContentLoaded", async () => {
marker.bindPopup(`
<div style="font-size:0.9em">
<b>${node?.long_name || s.node_id}</b><br>
Node ID: ${s.node_id}<br>
Node ID: <a href="/new_node/${s.node_id}">${s.node_id}</a><br>
HW: ${node?.hw_model ?? "—"}<br>
Channel: ${s.channel ?? "—"}<br><br>
<b>Signal</b><br>
@@ -428,9 +445,6 @@ document.addEventListener("DOMContentLoaded", async () => {
</tr>`;
}).join("");
// ------------------------------------------------
// FIT MAP TO ALL MARKERS
// ------------------------------------------------
if(allBounds.length>0){
map.fitBounds(allBounds,{padding:[40,40]});
}
+1 -1
View File
@@ -350,7 +350,7 @@ document.addEventListener("DOMContentLoaded", async function() {
row.innerHTML = `
<td>${node.short_name || "N/A"}</td>
<td><a href="/packet_list/${node.node_id}">${node.long_name || "N/A"}</a></td>
<td><a href="/new_node/${node.node_id}">${node.long_name || "N/A"}</a></td>
<td>${node.hw_model || "N/A"}</td>
<td>${node.firmware || "N/A"}</td>
<td>${node.role || "N/A"}</td>
+1 -1
View File
@@ -187,7 +187,7 @@ function updateTable() {
for (const node of filteredNodes) {
const percent = mean > 0 ? ((node.total_times_seen / mean) * 100).toFixed(1) + "%" : "0%";
const row = `<tr>
<td><a href="/packet_list/${node.node_id}">${node.long_name}</a></td>
<td><a href="/new_node/${node.node_id}">${node.long_name}</a></td>
<td>${node.short_name}</td>
<td>${node.channel}</td>
<td><a href="/top?node_id=${node.node_id}">${node.total_packets_sent}</a></td>