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-13 15:28:45 -08:00
parent 7411c7e8ee
commit 39c0dd589d
8 changed files with 1086 additions and 83 deletions

View File

@@ -24,8 +24,14 @@ async def get_fuzzy_nodes(query):
return result.scalars()
async def get_packets(node_id=None, portnum=None, after=None, before=None, limit=None):
async def get_packets(node_id=None, portnum=None, after=None, before=None, limit=None, packet_id=None):
async with database.async_session() as session:
# --- Fast path: fetch by packet_id (uses primary key lookup) ---
if packet_id is not None:
packet = await session.get(Packet, packet_id)
return [packet] if packet else []
# --- Normal query path ---
q = select(Packet)
if node_id:
@@ -47,6 +53,7 @@ async def get_packets(node_id=None, portnum=None, after=None, before=None, limit
return packets
async def get_packets_from(node_id=None, portnum=None, since=None, limit=500):
async with database.async_session() as session:
q = select(Packet)

View File

@@ -107,7 +107,7 @@ document.addEventListener("DOMContentLoaded", async () => {
replyHtml = `
<div class="replying-to">
${replyPrefix}
<a href="/packet/${packet.reply_id}">${packet.reply_id}</a>
<a href="/new_packet/${packet.reply_id}">${packet.reply_id}</a>
</div>`;
}
}
@@ -118,11 +118,11 @@ 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="/packet/${packet.id}" title="${chatLang.view_packet_details || 'View details'}">✉️</a>
<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="/packet_list/${packet.from_node_id}">
<a href="/new_node/${packet.from_node_id}">
${escapeHtml((packet.long_name || "").trim() || `Node ${packet.from_node_id}`)}
</a>
</span>

View File

@@ -248,7 +248,7 @@ async function fetchUpdates() {
const html = `
<tr class="packet-row" data-id="${pkt.id}">
<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><span class="toggle-btn">▶</span> <a href="/new_packet/${pkt.id}" style="text-decoration:underline; color:inherit;">${pkt.id}</a></td>
<td>${from}</td>
<td>${to}</td>
<td>${portLabel(pkt.portnum, pkt.payload)}</td>

View File

@@ -35,9 +35,7 @@
{% block body %}
<div class="container">
<!-- Weekly notice -->
<div id="weekly-message">Loading weekly message...</div>
<!-- Total messages -->
<div id="total-count">Total messages: 0</div>
<div id="chat-container">
@@ -50,24 +48,16 @@ document.addEventListener("DOMContentLoaded", async () => {
const chatContainer = document.querySelector("#chat-log");
const totalCountEl = document.querySelector("#total-count");
const weeklyMessageEl = document.querySelector("#weekly-message");
if (!chatContainer || !totalCountEl || !weeklyMessageEl) return console.error("Required elements not found");
if (!chatContainer || !totalCountEl || !weeklyMessageEl) {
console.error("Required elements not found");
return;
}
const renderedPacketIds = new Set();
const packetMap = new Map();
let chatTranslations = {};
let netTag = "";
// Fetch site config to get net_tag and weekly message
try {
const resp = await fetch("/api/config");
const config = await resp.json();
netTag = encodeURIComponent(config?.site?.net_tag || "");
weeklyMessageEl.textContent = config?.site?.weekly_net_message || "Weekly message not set.";
} catch(err) {
console.error("Failed to load site config:", err);
weeklyMessageEl.textContent = "Failed to load weekly message.";
}
function updateTotalCount() {
totalCountEl.textContent = `Total messages: ${renderedPacketIds.size}`;
}
@@ -78,7 +68,7 @@ document.addEventListener("DOMContentLoaded", async () => {
return div.innerHTML;
}
function applyTranslations(translations, root=document) {
function applyTranslations(translations, root = document) {
root.querySelectorAll("[data-translate-lang]").forEach(el => {
const key = el.dataset.translateLang;
if (translations[key]) el.textContent = translations[key];
@@ -95,8 +85,8 @@ document.addEventListener("DOMContentLoaded", async () => {
packetMap.set(packet.id, packet);
const date = new Date(packet.import_time_us / 1000);
const formattedTime = date.toLocaleTimeString([], { hour:"numeric", minute:"2-digit", second:"2-digit", hour12:true });
const formattedDate = `${(date.getMonth()+1).toString().padStart(2,"0")}/${date.getDate().toString().padStart(2,"0")}/${date.getFullYear()}`;
const formattedTime = date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit", second: "2-digit", hour12: true });
const formattedDate = `${(date.getMonth() + 1).toString().padStart(2, "0")}/${date.getDate().toString().padStart(2, "0")}/${date.getFullYear()}`;
const formattedTimestamp = `${formattedTime} - ${formattedDate}`;
let replyHtml = "";
@@ -140,33 +130,55 @@ document.addEventListener("DOMContentLoaded", async () => {
}
function renderPacketsEnsureDescending(packets) {
if (!Array.isArray(packets) || packets.length===0) return;
const sortedDesc = packets.slice().sort((a,b)=>b.import_time_us - a.import_time_us);
for (let i=sortedDesc.length-1; i>=0; i--) renderPacket(sortedDesc[i]);
if (!Array.isArray(packets) || packets.length === 0) return;
const sortedDesc = packets.slice().sort((a, b) => b.import_time_us - a.import_time_us);
for (let i = sortedDesc.length - 1; i >= 0; i--) renderPacket(sortedDesc[i]);
}
async function fetchInitial() {
async function fetchInitialPackets(tag) {
if (!tag) {
console.warn("No net_tag defined, skipping packet fetch.");
return;
}
try {
if (!netTag) return;
const resp = await fetch(`/api/packets?portnum=1&limit=100&contains=${netTag}`);
console.log("Fetching packets for netTag:", tag);
const sixDaysAgoMs = Date.now() - (6 * 24 * 60 * 60 * 1000);
const sinceUs = Math.floor(sixDaysAgoMs * 1000);
const resp = await fetch(`/api/packets?portnum=1&contains=${encodeURIComponent(tag)}&since=${sinceUs}`);
const data = await resp.json();
console.log("Packets received:", data?.packets?.length);
if (data?.packets?.length) renderPacketsEnsureDescending(data.packets);
} catch(err) { console.error("Initial fetch error:", err); }
} catch (err) {
console.error("Initial fetch error:", err);
}
}
async function loadTranslations() {
async function loadTranslations(cfg) {
try {
const cfg = await window._siteConfigPromise;
const langCode = cfg?.site?.language || "en";
const res = await fetch(`/api/lang?lang=${langCode}&section=chat`);
chatTranslations = await res.json();
applyTranslations(chatTranslations, document);
} catch(err){ console.error("Chat translation load failed:", err); }
} catch (err) {
console.error("Chat translation load failed:", err);
}
}
await loadTranslations();
await fetchInitial();
// --- MAIN LOGIC ---
try {
const cfg = await window._siteConfigPromise; // ✅ Already fetched by base.html
const site = cfg?.site || {};
// Populate from config
netTag = site.net_tag || "";
weeklyMessageEl.textContent = site.weekly_net_message || "Weekly message not set.";
await loadTranslations(cfg);
await fetchInitialPackets(netTag);
} catch (err) {
console.error("Initialization failed:", err);
weeklyMessageEl.textContent = "Failed to load site config.";
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,451 @@
{% extends "base.html" %}
{% block css %}
{{ super() }}
/* --- Map --- */
#map {
width: 100%;
height: 400px;
margin-bottom: 20px;
border-radius: 8px;
display: block;
}
.leaflet-container {
background: #1a1a1a;
z-index: 1;
}
/* --- Node Info --- */
.node-info {
background-color: #1f2226;
border: 1px solid #3a3f44;
color: #ddd;
font-size: 0.9rem;
max-width: 400px;
padding: 8px 12px;
margin-bottom: 10px;
}
.node-info div {
margin-bottom: 3px;
}
.node-info strong {
color: #9fd4ff;
font-weight: 600;
}
/* --- Charts --- */
.chart-container {
width: 100%;
height: 320px;
margin-bottom: 25px;
border: 1px solid #3a3f44;
border-radius: 8px;
overflow: hidden;
background-color: #16191d;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
background: #1f2226;
padding: 6px 12px;
font-weight: bold;
border-bottom: 1px solid #333;
font-size: 1rem;
letter-spacing: 0.5px;
}
.chart-actions button {
background: rgba(255,255,255,0.05);
border: 1px solid #555;
border-radius: 4px;
color: #ccc;
font-size: 0.8rem;
padding: 2px 6px;
cursor: pointer;
transition: background 0.2s;
}
.chart-actions button:hover {
color: #fff;
background: rgba(255,255,255,0.15);
border-color: #888;
}
/* --- Table --- */
.packet-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
color: #e4e9ee;
}
.packet-table th, .packet-table td {
border: 1px solid #3a3f44;
padding: 6px 10px;
text-align: left;
}
.packet-table th {
background-color: #1f2226;
font-weight: bold;
}
.packet-table tr:nth-of-type(odd) { background-color: #272b2f; }
.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; }
.port-3 { background-color: #28a745; }
.port-4 { background-color: #ffc107; color:#000; }
.port-5 { background-color: #dc3545; }
.port-6 { background-color: #20c997; }
.port-67 { background-color: #17a2b8; }
.port-70 { background-color: #ff7043; }
.port-71 { background-color: #ff66cc; }
.port-0, .port-unknown { 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:hover { color: #fff; }
/* --- 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;
align-items:center; justify-content:center;
}
#chartModal > div {
background:#1b1e22; border-radius:8px;
width:90%; height:85%; padding:10px;
}
{% endblock %}
{% 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">
<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>Role:</strong> <span id="info-role"></span></div>
<div><strong>Last Update:</strong> <span id="info-last-update"></span></div>
</div>
<!-- Map -->
<div id="map" style="min-height:400px;"></div>
<!-- Charts -->
<div 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>
</div>
</div>
<div id="chart_battery_voltage" style="height:260px;"></div>
</div>
<div 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>
</div>
</div>
<div id="chart_air_channel" style="height:260px;"></div>
</div>
<div id="env_chart_container" class="chart-container" style="display:none;">
<div class="chart-header">🌡️ Environment Metrics
<div class="chart-actions">
<button onclick="expandChart('environment')">Expand</button>
<button onclick="exportCSV('environment')">Export CSV</button>
</div>
</div>
<div id="chart_environment" style="height:260px;"></div>
</div>
<!-- Table -->
<table class="packet-table">
<thead>
<tr>
<th>Time</th>
<th>Packet ID</th>
<th>From</th>
<th>To</th>
<th>Port</th>
</tr>
</thead>
<tbody id="packet_list"></tbody>
</table>
</div>
<!-- Modal -->
<div id="chartModal">
<div>
<div style="text-align:right;">
<button onclick="closeModal()" style="background:none;border:none;color:#ccc;"></button>
</div>
<div id="modalChart" style="width:100%; height:90%;"></div>
</div>
</div>
<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];}
// --- 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];
}
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 ---
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);
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";
}
}
// --- 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>`;
}
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";
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"});
}
// --- 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");
}
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 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 ensureMapVisible(){
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);
});
}
// --- Packets ---
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");}
}
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",`
<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>`);
}
}
// --- Charts ---
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 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)
]
});
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)
]
});
}
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();
}
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();
}
// --- Expand payload rows ---
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")?"▼":"▶";
});
// --- Init ---
document.addEventListener("DOMContentLoaded",async()=>{
requestAnimationFrame(async ()=>{
initMap();
await loadNodes();
await loadNodeInfo();
await loadPackets();
await loadTelemetryCharts();
ensureMapVisible();
setTimeout(ensureMapVisible,1000);
window.addEventListener("resize",ensureMapVisible);
window.addEventListener("focus",ensureMapVisible);
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,446 @@
{% extends "base.html" %}
{% block title %}Packet Details{%endblock%}
{% block css %}
{{ super() }}
<style>
/* --- Packet page container --- */
.packet-container {
max-width: 900px;
margin: 0 auto;
padding: 20px 15px;
font-family: "JetBrains Mono", monospace;
}
/* --- More margin inside card body --- */
.packet-card .card-body {
padding: 26px 30px;
}
/* --- Packet Details Card --- */
.packet-card {
background-color: #1e1f22;
border: 1px solid #3a3a3a;
border-radius: 12px;
color: #ddd;
margin-top: 35px;
box-shadow: 0 0 20px rgba(0,0,0,0.35);
overflow: hidden;
}
.packet-card .card-header {
background: linear-gradient(90deg, #2c2f35, #25262a);
border-bottom: 1px solid #3f3f3f;
font-weight: 600;
font-size: 1.1em;
padding: 14px 18px;
color: #e2e6ea;
display: flex;
justify-content: space-between;
align-items: center;
}
.packet-card dt {
color: #9aa0a6;
margin-top: 14px;
}
.packet-card dd {
margin-bottom: 12px;
}
.packet-card dd pre {
background: #25272a;
padding: 12px;
border-radius: 8px;
border: 1px solid #3a3a3a;
}
/* --- Map --- */
#map {
width: 100%;
height: 640px;
border-radius: 10px;
margin-top: 20px;
border: 1px solid #333;
display: none;
}
#loading {
text-align: center;
padding: 40px;
font-size: 1.1em;
color: #888;
}
/* --- Seen Table --- */
.seen-table {
border-collapse: separate;
border-spacing: 0 6px;
font-size: 0.92em;
}
.seen-table thead th {
background-color: #2a2b2f;
color: #e2e2e2;
padding: 10px 12px;
border: none !important;
text-transform: uppercase;
font-size: 0.75em;
letter-spacing: 0.5px;
}
.seen-table tbody td {
background: #323338;
color: #f0f0f0;
border-top: 1px solid #4a4c4f !important;
border-bottom: 1px solid #4a4c4f !important;
padding: 10px 12px !important;
}
.seen-table tbody tr:hover td {
background-color: #3a3c41 !important;
}
/* Rounded corners */
.seen-table tbody tr td:first-child {
border-left: 1px solid #4a4c4f;
border-top-left-radius: 8px;
border-bottom-left-radius: 8px;
}
.seen-table tbody tr td:last-child {
border-right: 1px solid #4a4c4f;
border-top-right-radius: 8px;
border-bottom-right-radius: 8px;
}
</style>
{% endblock %}
{% block body %}
<div class="container mt-4 mb-5 packet-container">
<div id="loading">Loading packet information...</div>
<div id="packet-card" class="packet-card d-none"></div>
<div id="map"></div>
<div id="seen-container" class="mt-4 d-none">
<h5 style="color:#ccc; margin:15px 0 10px 0;">
📡 Seen By <span id="seen-count" style="color:#4da6ff;"></span>
</h5>
<div class="table-responsive">
<table class="table table-dark table-sm seen-table">
<thead>
<tr>
<th>Node</th>
<th>RSSI</th>
<th>SNR</th>
<th>Hop</th>
<th>Channel</th>
<th>Time</th>
</tr>
</thead>
<tbody id="seen-table-body"></tbody>
</table>
</div>
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", async () => {
const packetCard = document.getElementById("packet-card");
const loading = document.getElementById("loading");
const mapDiv = document.getElementById("map");
const seenContainer = document.getElementById("seen-container");
const seenTableBody = document.getElementById("seen-table-body");
const seenCountSpan = document.getElementById("seen-count");
const match = window.location.pathname.match(/\/new_packet\/(\d+)/);
if (!match) {
loading.textContent = "Invalid packet URL";
return;
}
const packetId = match[1];
// ------------------------------------------------
// FETCH PACKET FIRST
// ------------------------------------------------
const packetRes = await fetch(`/api/packets?packet_id=${packetId}`);
const packetData = await packetRes.json();
if (!packetData.packets.length) {
loading.textContent = "Packet not found.";
return;
}
const p = packetData.packets[0];
// ------------------------------------------------
// FETCH NODES NOW (BEFORE RENDERING CARD)
// ------------------------------------------------
const nodesRes = await fetch("/api/nodes");
const nodesData = await nodesRes.json();
const nodeLookup = {};
(nodesData.nodes || []).forEach(n => nodeLookup[n.node_id] = n);
// Friendly From Node name
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);
// ------------------------------------------------
// Decode GPS + Telemetry
// ------------------------------------------------
let lat = null, lon = null;
const parsed = {};
if (p.payload?.includes(":")) {
p.payload.split("\n").forEach(line => {
const [k, v] = line.split(":").map(x=>x.trim());
if (k && v !== undefined) {
parsed[k] = v;
if (k === "latitude_i") lat = Number(v) / 1e7;
if (k === "longitude_i") lon = Number(v) / 1e7;
}
});
}
const telemetryExtras = [];
if (parsed.PDOP) telemetryExtras.push(`PDOP: ${parsed.PDOP}`);
if (parsed.sats_in_view) telemetryExtras.push(`Sats: ${parsed.sats_in_view}`);
if (parsed.ground_speed) telemetryExtras.push(`Speed: ${parsed.ground_speed}`);
if (parsed.altitude) telemetryExtras.push(`Altitude: ${parsed.altitude}`);
const time = p.import_time_us
? new Date(p.import_time_us / 1000).toLocaleString()
: "—";
// ------------------------------------------------
// RENDER PACKET CARD (NOW SAFE)
// ------------------------------------------------
packetCard.innerHTML = `
<div class="card-header">
<span>Packet ${p.id}</span>
<small>${time}</small>
</div>
<div class="card-body">
<dl>
<dt>From Node</dt>
<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>
<dt>Channel</dt>
<dd>${p.channel ?? "—"}</dd>
<dt>Port</dt>
<dd>${p.portnum}</dd>
<dt>Raw Payload</dt>
<dd><pre>${escapeHtml(p.payload ?? "—")}</pre></dd>
${
telemetryExtras.length
? `<dt>Decoded Telemetry</dt><dd><pre>${telemetryExtras.join("\n")}</pre></dd>`
: ""
}
${
lat && lon
? `<dt>Location</dt>
<dd>${lat.toFixed(6)}, ${lon.toFixed(6)}
<br><a href="https://maps.google.com/?q=${lat},${lon}"
target="_blank">Open in Google Maps</a>
</dd>`
: ""
}
</dl>
</div>
`;
loading.classList.add("d-none");
packetCard.classList.remove("d-none");
// ------------------------------------------------
// ALWAYS SHOW MAP
// ------------------------------------------------
const map = L.map("map");
mapDiv.style.display = "block";
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19
}).addTo(map);
const allBounds = [];
if (lat && lon) {
allBounds.push([lat, lon]);
L.marker([lat, lon], {
icon: L.icon({
iconUrl: "https://maps.gstatic.com/mapfiles/ms2/micons/red-dot.png",
iconSize: [32, 32]
})
}).addTo(map);
} else {
map.setView([0, 0], 2);
}
// ------------------------------------------------
// COLOR SCALE FOR HOP MARKERS
// ------------------------------------------------
function hopColor(hop){
const c=[
"#ff3b30","#ff6b22","#ff9f0c",
"#ffd60a","#87d957","#57d9c4","#3db2ff"
];
if(!hop||hop<1)return"#aaa";
if(hop>7)hop=7;
return c[hop-1];
}
// ------------------------------------------------
// HAVERSINE
// ------------------------------------------------
function haversine(lat1,lon1,lat2,lon2){
const R=6371;
const dLat=(lat2-lat1)*Math.PI/180;
const dLon=(lon2-lon1)*Math.PI/180;
const a=Math.sin(dLat/2)**2+
Math.cos(lat1*Math.PI/180)*
Math.cos(lat2*Math.PI/180)*
Math.sin(dLon/2)**2;
return R*(2*Math.atan2(Math.sqrt(a),Math.sqrt(1-a)));
}
// ------------------------------------------------
// FETCH 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;
return B-A;
});
if (seenSorted.length){
seenContainer.classList.remove("d-none");
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;
const timeStr = s.import_time_us
? 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;
allBounds.push([rlat,rlon]);
const hop=s.hop_limit??"";
const color=hopColor(Number(hop));
const iconHtml=`
<div style="
background:${color};
width:30px;
height:30px;
border-radius:50%;
display:flex;
align-items:center;
justify-content:center;
color:white;
font-size:13px;
font-weight:700;
border:2px solid rgba(0,0,0,0.35);
box-shadow:0 0 6px rgba(0,0,0,0.45);
">${hop}</div>`;
const marker=L.marker([rlat,rlon],{
icon:L.divIcon({
html:iconHtml,
className:"",
iconSize:[30,30],
iconAnchor:[15,15]
})
}).addTo(map);
let distKm=null,distMi=null;
if(lat&&lon){
distKm=haversine(lat,lon,rlat,rlon);
distMi=distKm*0.621371;
}
marker.bindPopup(`
<div style="font-size:0.9em">
<b>${node?.long_name || s.node_id}</b><br>
Node ID: ${s.node_id}<br>
HW: ${node?.hw_model ?? "—"}<br>
Channel: ${s.channel ?? "—"}<br><br>
<b>Signal</b><br>
RSSI: ${s.rx_rssi ?? "—"}<br>
SNR: ${s.rx_snr ?? "—"}<br><br>
<b>Hop</b><br>
Start: ${s.hop_start ?? "?"}<br>
Limit: <b>${s.hop_limit ?? "?"}</b><br><br>
<b>Distance</b><br>
${
distKm
? `${distKm.toFixed(2)} km (${distMi.toFixed(2)} mi)`
: "—"
}
</div>
`);
}
return `
<tr>
<td><a href="/new_node/${s.node_id}">${label}</a></td>
<td>${s.rx_rssi ?? "—"}</td>
<td>${s.rx_snr ?? "—"}</td>
<td>${s.hop_start ?? "—"}${s.hop_limit ?? "—"}</td>
<td>${s.channel ?? "—"}</td>
<td>${timeStr}</td>
</tr>`;
}).join("");
// ------------------------------------------------
// FIT MAP TO ALL MARKERS
// ------------------------------------------------
if(allBounds.length>0){
map.fitBounds(allBounds,{padding:[40,40]});
}
function escapeHtml(unsafe) {
return (unsafe??"").replace(/[&<"'>]/g,m=>({
"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#039;"
})[m]);
}
});
</script>
{% endblock %}

View File

@@ -245,6 +245,23 @@ async def chat(request):
content_type="text/html",
)
@routes.get("/new_packet/{packet_id}")
async def new_packet(request):
template = env.get_template("new_packet.html")
return web.Response(
text=template.render(),
content_type="text/html",
)
@routes.get("/new_node/{from_node_id}")
async def firehose_node(request):
template = env.get_template("new_node.html")
return web.Response(
text=template.render(),
content_type="text/html",
)
def generate_response(request, body, raw_node_id="", node=None):
if "HX-Request" in request.headers:
return web.Response(text=body, content_type="text/html")

View File

@@ -94,18 +94,46 @@ async def api_nodes(request):
async def api_packets(request):
try:
# --- Parse query parameters ---
packet_id_str = request.query.get("packet_id")
limit_str = request.query.get("limit", "50")
since_str = request.query.get("since")
portnum = request.query.get("portnum")
contains = request.query.get("contains") # <-- new query parameter
portnum_str = request.query.get("portnum")
contains = request.query.get("contains")
from_node_id_str = request.query.get("from_node_id")
# Clamp limit between 1 and 100
# --- If a packet_id is provided, fetch just that one ---
if packet_id_str:
try:
packet_id = int(packet_id_str)
except ValueError:
return web.json_response({"error": "Invalid packet_id format"}, status=400)
packet = await store.get_packet(packet_id)
if not packet:
return web.json_response({"packets": []}) # consistent shape
p = Packet.from_model(packet)
data = {
"id": p.id,
"from_node_id": p.from_node_id,
"to_node_id": p.to_node_id,
"portnum": int(p.portnum) if p.portnum is not None else None,
"payload": (p.payload or "").strip(),
"import_time_us": p.import_time_us,
"channel": getattr(p.from_node, "channel", ""),
"long_name": getattr(p.from_node, "long_name", ""),
}
return web.json_response({"packets": [data]}) # unified key
# --- Otherwise: multi-packet listing mode ---
# Limit validation
try:
limit = min(max(int(limit_str), 1), 100)
except ValueError:
limit = 50
# Parse "since" timestamp in microseconds
# Parse 'since' timestamp
since = None
if since_str:
try:
@@ -113,9 +141,25 @@ async def api_packets(request):
except ValueError:
logger.warning(f"Invalid 'since' value (expected microseconds): {since_str}")
# --- Fetch packets from store ---
# Parse from_node_id (decimal or hex)
node_id = None
if from_node_id_str:
try:
node_id = int(from_node_id_str, 0)
except ValueError:
logger.warning(f"Invalid from_node_id: {from_node_id_str}")
# Parse portnum safely
portnum = None
if portnum_str:
try:
portnum = int(portnum_str)
except ValueError:
logger.warning(f"Invalid portnum: {portnum_str}")
# --- Fetch packets ---
packets = await store.get_packets(
node_id=0xFFFFFFFF if portnum else None,
node_id=node_id,
portnum=portnum,
after=since,
limit=limit,
@@ -123,59 +167,44 @@ async def api_packets(request):
ui_packets = [Packet.from_model(p) for p in packets]
# --- Chat-like filtering (if TEXT_MESSAGE_APP) ---
if str(portnum) == str(PortNum.TEXT_MESSAGE_APP):
# Filter out empty or "seq N" payloads
# --- Text message filtering ---
if portnum == PortNum.TEXT_MESSAGE_APP:
ui_packets = [
p for p in ui_packets
if p.payload and not SEQ_REGEX.fullmatch(p.payload)
]
# Apply "contains" filter if provided
if contains:
ui_packets = [
p for p in ui_packets if contains.lower() in p.payload.lower()
]
# Sort newest first and limit
ui_packets.sort(key=lambda p: p.import_time_us, reverse=True)
ui_packets = ui_packets[:limit]
# --- Sort descending by import_time_us ---
ui_packets.sort(key=lambda p: p.import_time_us, reverse=True)
ui_packets = ui_packets[:limit]
packets_data = []
for p in ui_packets:
reply_id = getattr(
getattr(getattr(p, "raw_mesh_packet", None), "decoded", None),
"reply_id",
None,
)
# --- Prepare output ---
packets_data = []
for p in ui_packets:
packet_dict = {
"id": p.id,
"import_time_us": p.import_time_us,
"channel": getattr(p.from_node, "channel", ""),
"from_node_id": p.from_node_id,
"to_node_id": p.to_node_id,
"portnum": int(p.portnum),
"long_name": getattr(p.from_node, "long_name", ""),
"payload": (p.payload or "").strip(),
}
packet_dict = {
"id": p.id,
"import_time_us": p.import_time_us,
"channel": getattr(p.from_node, "channel", ""),
"from_node_id": p.from_node_id,
"long_name": getattr(p.from_node, "long_name", ""),
"payload": (p.payload or "").strip(),
}
reply_id = getattr(
getattr(getattr(p, "raw_mesh_packet", None), "decoded", None),
"reply_id",
None,
)
if reply_id:
packet_dict["reply_id"] = reply_id
if reply_id:
packet_dict["reply_id"] = reply_id
packets_data.append(packet_dict)
# --- General packet listing ---
else:
packets_data = [
{
"id": p.id,
"from_node_id": p.from_node_id,
"to_node_id": p.to_node_id,
"portnum": int(p.portnum) if p.portnum is not None else None,
"payload": (p.payload or "").strip(),
"import_time_us": p.import_time_us,
}
for p in ui_packets
]
packets_data.append(packet_dict)
return web.json_response({"packets": packets_data})
@@ -184,7 +213,6 @@ async def api_packets(request):
return web.json_response({"error": "Failed to fetch packets"}, status=500)
@routes.get("/api/stats")
async def api_stats(request):
"""
@@ -468,3 +496,45 @@ async def version_endpoint(request):
except Exception as e:
logger.error(f"Error in /version: {e}")
return web.json_response({"error": "Failed to fetch version info"}, status=500)
@routes.get("/api/packets_seen/{packet_id}")
async def api_packets_seen(request):
try:
# --- Validate packet_id ---
try:
packet_id = int(request.match_info["packet_id"])
except (KeyError, ValueError):
return web.json_response(
{"error": "Invalid or missing packet_id"},
status=400,
)
# --- Fetch list using your helper ---
rows = await store.get_packets_seen(packet_id)
items = []
for row in rows: # <-- FIX: normal for-loop
items.append({
"packet_id": row.packet_id,
"node_id": row.node_id,
"rx_time": row.rx_time,
"hop_limit": row.hop_limit,
"hop_start": row.hop_start,
"channel": row.channel,
"rx_snr": row.rx_snr,
"rx_rssi": row.rx_rssi,
"topic": row.topic,
"import_time": (
row.import_time.isoformat() if row.import_time else None
),
"import_time_us": row.import_time_us,
})
return web.json_response({"seen": items})
except Exception:
logger.exception("Error in /api/packets_seen")
return web.json_response(
{"error": "Internal server error"},
status=500,
)