mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-03-04 23:27:46 +01:00
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:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}§ion=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 %}
|
||||
|
||||
451
meshview/templates/new_node.html
Normal file
451
meshview/templates/new_node.html
Normal 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:'© 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=="<"?"<":">");
|
||||
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 %}
|
||||
446
meshview/templates/new_packet.html
Normal file
446
meshview/templates/new_packet.html
Normal 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=>({
|
||||
"&":"&","<":"<",">":">","\"":""","'":"'"
|
||||
})[m]);
|
||||
}
|
||||
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user