More changes... almost ready for release.

Ranamed 2 pages for easy or reading.
This commit is contained in:
Pablo Revilla
2025-11-24 16:11:11 -08:00
parent 3c8fa0185e
commit 99cb5654e4
8 changed files with 177 additions and 128 deletions
+56 -26
View File
@@ -1,9 +1,9 @@
from datetime import datetime, timedelta
from sqlalchemy import func, nullslast, select, text
from sqlalchemy import and_, func, or_, select, text
from sqlalchemy.orm import lazyload
from meshview import database
from meshview import database, models
from meshview.models import Node, Packet, PacketSeen, Traceroute
@@ -24,38 +24,68 @@ async def get_fuzzy_nodes(query):
return result.scalars()
async def get_packets(
node_id=None, portnum=None, after=None, before=None, limit=None, packet_id=None
from_node_id=None,
to_node_id=None,
node_id=None, # legacy: match either from/to
portnum=None,
after=None,
limit=50,
):
"""
SQLAlchemy 2.0 async ORM version.
Supports strict from/to/node filtering, portnum, since, limit.
"""
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)
# Start select
stmt = select(models.Packet)
if node_id:
q = q.where((Packet.from_node_id == node_id) | (Packet.to_node_id == node_id))
if portnum:
q = q.where(Packet.portnum == portnum)
if after:
q = q.where(Packet.import_time_us > after)
if before:
q = q.where(Packet.import_time_us < before)
conditions = []
# Order by import_time_us when available, fallback to import_time for old data
# This handles databases where import_time_us may be NULL (old backups)
# First sort by import_time_us (NULLs last), then by import_time for those rows
q = q.order_by(nullslast(Packet.import_time_us.desc()), Packet.import_time.desc())
# Strict FROM filter
if from_node_id is not None:
conditions.append(models.Packet.from_node_id == from_node_id)
if limit is not None:
q = q.limit(limit)
# Strict TO filter
if to_node_id is not None:
conditions.append(models.Packet.to_node_id == to_node_id)
# Legacy "node_id = match either direction"
if node_id is not None:
conditions.append(
or_(
models.Packet.from_node_id == node_id,
models.Packet.to_node_id == node_id
)
)
# Port filter
if portnum is not None:
conditions.append(models.Packet.portnum == portnum)
# Time filter
if after is not None:
conditions.append(models.Packet.import_time_us > after)
# Apply WHERE clause if needed
if conditions:
stmt = stmt.where(and_(*conditions))
# Sort newest → oldest
stmt = stmt.order_by(models.Packet.import_time_us.desc())
# Limit
stmt = stmt.limit(limit)
# Execute
result = await session.execute(stmt)
# Convert ORM rows to models
return result.scalars().all()
result = await session.execute(q)
packets = list(result.scalars())
return packets
async def get_packets_from(node_id=None, portnum=None, since=None, limit=500):
-4
View File
@@ -6,11 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Scripts -->
<script src="https://unpkg.com/htmx.org@1.9.11" crossorigin="anonymous"></script>
<script src="https://unpkg.com/htmx.org@1.9.11/dist/ext/sse.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" crossorigin="anonymous"></script>
<!-- Styles -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
+2 -2
View File
@@ -131,7 +131,7 @@ document.addEventListener("DOMContentLoaded", async () => {
replyHtml = `
<div class="replying-to">
${replyPrefix}
<a href="/new_packet/${packet.reply_id}">${packet.reply_id}</a>
<a href="/packet/${packet.reply_id}">${packet.reply_id}</a>
</div>`;
}
}
@@ -142,7 +142,7 @@ 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>
<a href="/packet/${packet.id}" title="${chatLang.view_packet_details || 'View details'}">🔎</a>
${escapeHtml(packet.channel || "")}
</span>
<span class="col-3 nodename">
+1 -1
View File
@@ -265,7 +265,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="/new_packet/${pkt.id}" style="text-decoration:underline; color:inherit;">${pkt.id}</a></td>
<td><span class="toggle-btn">▶</span> <a href="/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, inlineLinks)}</td>
+1 -1
View File
@@ -572,7 +572,7 @@ async function loadPackets(){
list.insertAdjacentHTML("afterbegin", `
<tr class="packet-row">
<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><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)}${inlineLinks}</td>
@@ -48,10 +48,10 @@
display: none;
}
/* --- Source Marker: 2px bigger than gateway (26px vs 24px) --- */
/* --- SOURCE MARKER (slightly bigger) --- */
.source-marker {
width: 26px;
height: 26px;
width: 24px;
height: 24px;
background: rgba(255,0,0,0.55);
border: 3px solid #ff0000;
border-radius: 50%;
@@ -135,13 +135,17 @@ document.addEventListener("DOMContentLoaded", async () => {
const seenTableBody = document.getElementById("seen-table-body");
const seenCountSpan = document.getElementById("seen-count");
const match = window.location.pathname.match(/\/new_packet\/(\d+)/);
/* ---------------------------------------------
Identify packet ID
----------------------------------------------*/
const match = window.location.pathname.match(/\/packet\/(\d+)/);
if (!match) {
loading.textContent = "Invalid packet URL";
return;
}
const packetId = match[1];
/* PORT NAME MAP */
const PORT_NAMES = {
0:"UNKNOWN APP",
1:"Text",
@@ -154,9 +158,9 @@ document.addEventListener("DOMContentLoaded", async () => {
71:"Neighbor"
};
/* -------------------------
/* ---------------------------------------------
Fetch packet
-------------------------- */
----------------------------------------------*/
const packetRes = await fetch(`/api/packets?packet_id=${packetId}`);
const packetData = await packetRes.json();
if (!packetData.packets.length) {
@@ -165,24 +169,24 @@ document.addEventListener("DOMContentLoaded", async () => {
}
const p = packetData.packets[0];
/* -------------------------
Fetch nodes
-------------------------- */
/* ---------------------------------------------
Fetch all nodes
----------------------------------------------*/
const nodesRes = await fetch("/api/nodes");
const nodesData = await nodesRes.json();
const nodeLookup = {};
(nodesData.nodes || []).forEach(n => nodeLookup[n.node_id] = n);
const fromNodeObj = nodeLookup[p.from_node_id];
const fromNodeLabel = fromNodeObj?.long_name || p.from_node_id;
const toNodeObj = nodeLookup[p.to_node_id];
const toNodeObj = nodeLookup[p.to_node_id];
const fromNodeLabel = fromNodeObj?.long_name || p.from_node_id;
const toNodeLabel =
p.to_node_id == 4294967295 ? "All" : (toNodeObj?.long_name || p.to_node_id);
/* -------------------------
Parse payload
-------------------------- */
/* ---------------------------------------------
Parse payload for lat/lon if this *packet* is a position packet
----------------------------------------------*/
let lat = null, lon = null;
const parsed = {};
@@ -197,19 +201,19 @@ document.addEventListener("DOMContentLoaded", async () => {
});
}
/* ---------------------------------------------
Render packet header & details
----------------------------------------------*/
const time = p.import_time_us
? new Date(p.import_time_us / 1000).toLocaleString()
: "—";
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
-------------------------- */
packetCard.innerHTML = `
<div class="card-header">
<span>Packet ID: <i>${p.id}</i></span>
@@ -256,9 +260,9 @@ document.addEventListener("DOMContentLoaded", async () => {
loading.classList.add("d-none");
packetCard.classList.remove("d-none");
/* -------------------------
Map init
-------------------------- */
/* ---------------------------------------------
Map initialization
----------------------------------------------*/
const map = L.map("map");
mapDiv.style.display = "block";
@@ -268,11 +272,22 @@ document.addEventListener("DOMContentLoaded", async () => {
const allBounds = [];
/* --------------------------------------------------------------------
SOURCE MARKER (26px vs 24px)
--------------------------------------------------------------------- */
if (lat && lon) {
allBounds.push([lat, lon]);
/* ---------------------------------------------
ALWAYS SHOW SOURCE POSITION
Priority:
1) position from packet payload
2) fallback: last_lat/last_long from /api/nodes
----------------------------------------------*/
let srcLat = lat;
let srcLon = lon;
if ((!srcLat || !srcLon) && fromNodeObj?.last_lat && fromNodeObj?.last_long) {
srcLat = fromNodeObj.last_lat / 1e7;
srcLon = fromNodeObj.last_long / 1e7;
}
if (srcLat && srcLon) {
allBounds.push([srcLat, srcLon]);
const sourceIcon = L.divIcon({
html: `<div class="source-marker"></div>`,
@@ -281,7 +296,7 @@ document.addEventListener("DOMContentLoaded", async () => {
iconAnchor: [13, 13]
});
const sourceMarker = L.marker([lat, lon], {
const sourceMarker = L.marker([srcLat, srcLon], {
icon: sourceIcon,
zIndexOffset: 9999
}).addTo(map);
@@ -289,34 +304,32 @@ document.addEventListener("DOMContentLoaded", async () => {
sourceMarker.bindPopup(`
<div style="font-size:0.9em">
<b>Packet Source</b><br>
Lat: ${lat.toFixed(6)}<br>
Lon: ${lon.toFixed(6)}<br>
Lat: ${srcLat.toFixed(6)}<br>
Lon: ${srcLon.toFixed(6)}<br>
From Node: ${fromNodeLabel}<br>
Channel: ${p.channel ?? "—"}<br>
Port: ${PORT_NAMES[p.portnum] || "UNKNOWN"} (${p.portnum})
</div>
`);
} else {
map.setView([0, 0], 2);
map.setView([0,0], 2);
}
/* -------------------------
Helpers
-------------------------- */
/* 0 = warmest → 7 = coldest */
/* ---------------------------------------------
Color for hop indicator markers (warm → cold)
----------------------------------------------*/
function hopColor(hopValue){
const colors = [
"#ff3b30", // 0 red
"#ff3b30",
"#ff6b22",
"#ff9f0c",
"#ffd60a",
"#87d957",
"#57d9c4",
"#3db2ff",
"#1e63ff" // 7 deep cold blue
"#1e63ff"
];
let h = Number(hopValue);
if (isNaN(h)) return "#aaa";
if (h < 0) h = 0;
@@ -324,6 +337,7 @@ document.addEventListener("DOMContentLoaded", async () => {
return colors[h];
}
/* Distance helper */
function haversine(lat1,lon1,lat2,lon2){
const R=6371;
const dLat=(lat2-lat1)*Math.PI/180;
@@ -335,13 +349,14 @@ document.addEventListener("DOMContentLoaded", async () => {
return R*(2*Math.atan2(Math.sqrt(a),Math.sqrt(1-a)));
}
/* -------------------------
Fetch seen list
-------------------------- */
/* ---------------------------------------------
Fetch packets_seen
----------------------------------------------*/
const seenRes = await fetch(`/api/packets_seen/${packetId}`);
const seenData = await seenRes.json();
const seenList = seenData.seen ?? [];
/* sort by hop_start descending (warm → cold) */
const seenSorted = seenList.slice().sort((a,b)=>{
const A=a.hop_start??-999;
const B=b.hop_start??-999;
@@ -353,9 +368,9 @@ document.addEventListener("DOMContentLoaded", async () => {
seenCountSpan.textContent=`(${seenSorted.length} gateways)`;
}
/* -------------------------
Gateway markers + table
-------------------------- */
/* ---------------------------------------------
Gateway markers and seen table
----------------------------------------------*/
seenTableBody.innerHTML = seenSorted.map(s=>{
const node=nodeLookup[s.node_id];
const label=node?(node.long_name||node.node_id):s.node_id;
@@ -401,8 +416,8 @@ document.addEventListener("DOMContentLoaded", async () => {
}).addTo(map);
let distKm=null,distMi=null;
if(lat&&lon){
distKm=haversine(lat,lon,rlat,rlon);
if(srcLat&&srcLon){
distKm=haversine(srcLat,srcLon,rlat,rlon);
distMi=distKm*0.621371;
}
@@ -437,10 +452,16 @@ document.addEventListener("DOMContentLoaded", async () => {
</tr>`;
}).join("");
/* ---------------------------------------------
Fit map to all markers
----------------------------------------------*/
if(allBounds.length>0){
map.fitBounds(allBounds,{padding:[40,40]});
}
/* ---------------------------------------------
Escape HTML
----------------------------------------------*/
function escapeHtml(unsafe) {
return (unsafe??"").replace(/[&<"'>]/g,m=>({
"&":"&amp;",
+2 -8
View File
@@ -200,12 +200,6 @@ async def redirect_packet_list(request):
raise web.HTTPFound(location=f"/node/{packet_id}")
# redirect for backwards compatibility
@routes.get("/packet/{packet_id}")
async def redirect_packet(request):
packet_id = request.match_info["packet_id"]
raise web.HTTPFound(location=f"/packet/{packet_id}")
@routes.get("/net")
async def net(request):
return web.Response(
@@ -246,9 +240,9 @@ async def chat(request):
)
@routes.get("/new_packet/{packet_id}")
@routes.get("/packet/{packet_id}")
async def new_packet(request):
template = env.get_template("new_packet.html")
template = env.get_template("packet.html")
return web.Response(
text=template.render(),
content_type="text/html",
+45 -37
View File
@@ -100,9 +100,13 @@ async def api_packets(request):
since_str = request.query.get("since")
portnum_str = request.query.get("portnum")
contains = request.query.get("contains")
from_node_id_str = request.query.get("from_node_id")
# --- If a packet_id is provided, fetch just that one ---
# NEW — explicit filters
from_node_id_str = request.query.get("from_node_id")
to_node_id_str = request.query.get("to_node_id")
node_id_str = request.query.get("node_id") # legacy: match either from/to
# --- If a packet_id is provided, return only that packet ---
if packet_id_str:
try:
packet_id = int(packet_id_str)
@@ -111,7 +115,7 @@ async def api_packets(request):
packet = await store.get_packet(packet_id)
if not packet:
return web.json_response({"packets": []}) # consistent shape
return web.json_response({"packets": []})
p = Packet.from_model(packet)
data = {
@@ -121,24 +125,19 @@ async def api_packets(request):
"portnum": int(p.portnum) if p.portnum is not None else None,
"payload": (p.payload or "").strip(),
"import_time_us": p.import_time_us,
# NOTE: Temporary stopgap - include import_time as fallback until old data
# with import_time_us=0 is migrated/cleaned up. Can be removed once all
# legacy records have been updated.
"import_time": p.import_time.isoformat() if p.import_time else None,
"channel": getattr(p.from_node, "channel", ""),
"long_name": getattr(p.from_node, "long_name", ""),
}
return web.json_response({"packets": [data]}) # unified key
return web.json_response({"packets": [data]})
# --- Otherwise: multi-packet listing mode ---
# Limit validation
# --- Parse limit ---
try:
limit = min(max(int(limit_str), 1), 100)
except ValueError:
limit = 50
# Parse 'since' timestamp
# --- Parse since timestamp ---
since = None
if since_str:
try:
@@ -146,15 +145,7 @@ async def api_packets(request):
except ValueError:
logger.warning(f"Invalid 'since' value (expected microseconds): {since_str}")
# 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
# --- Parse portnum ---
portnum = None
if portnum_str:
try:
@@ -162,9 +153,34 @@ async def api_packets(request):
except ValueError:
logger.warning(f"Invalid portnum: {portnum_str}")
# --- Fetch packets ---
# --- Parse node filters ---
from_node_id = None
to_node_id = None
node_id = None # legacy: match either from/to
if from_node_id_str:
try:
from_node_id = int(from_node_id_str, 0)
except ValueError:
logger.warning(f"Invalid from_node_id: {from_node_id_str}")
if to_node_id_str:
try:
to_node_id = int(to_node_id_str, 0)
except ValueError:
logger.warning(f"Invalid to_node_id: {to_node_id_str}")
if node_id_str:
try:
node_id = int(node_id_str, 0)
except ValueError:
logger.warning(f"Invalid node_id: {node_id_str}")
# --- Fetch packets using explicit filters ---
packets = await store.get_packets(
node_id=node_id,
from_node_id=from_node_id,
to_node_id=to_node_id,
node_id=node_id, # old behavior: match from OR to
portnum=portnum,
after=since,
limit=limit,
@@ -179,21 +195,18 @@ async def api_packets(request):
ui_packets = [p for p in ui_packets if contains.lower() in p.payload.lower()]
# --- Sort descending by import_time_us ---
# Handle None values by treating them as smallest (will be sorted last)
ui_packets.sort(
key=lambda p: (p.import_time_us is not None, p.import_time_us or 0), reverse=True
key=lambda p: (p.import_time_us is not None, p.import_time_us or 0),
reverse=True
)
ui_packets = ui_packets[:limit]
# --- Prepare output ---
# --- Build JSON output ---
packets_data = []
for p in ui_packets:
packet_dict = {
"id": p.id,
"import_time_us": p.import_time_us,
# NOTE: Temporary stopgap - include import_time as fallback until old data
# with import_time_us=0 is migrated/cleaned up. Can be removed once all
# legacy records have been updated.
"import_time": p.import_time.isoformat() if p.import_time else None,
"channel": getattr(p.from_node, "channel", ""),
"from_node_id": p.from_node_id,
@@ -213,25 +226,19 @@ async def api_packets(request):
packets_data.append(packet_dict)
# Calculate latest_import_time for incremental updates
# NOTE: Temporary stopgap - fallback to import_time until old data with
# import_time_us=0 is migrated/cleaned up. Can be simplified once all
# legacy records have been updated.
# Use the highest import_time_us, with fallback to import_time
# --- Latest import_time for incremental fetch ---
latest_import_time = None
if packets_data:
for p in packets_data:
if p.get("import_time_us") and p["import_time_us"] > 0:
if latest_import_time is None or p["import_time_us"] > latest_import_time:
latest_import_time = p["import_time_us"]
latest_import_time = max(latest_import_time or 0, p["import_time_us"])
elif p.get("import_time") and latest_import_time is None:
# Fallback: convert ISO string to microseconds if import_time_us is missing
try:
dt = datetime.datetime.fromisoformat(
p["import_time"].replace("Z", "+00:00")
)
latest_import_time = int(dt.timestamp() * 1_000_000)
except (ValueError, AttributeError):
except Exception:
pass
response = {"packets": packets_data}
@@ -245,6 +252,7 @@ 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):
"""