diff --git a/meshview/store.py b/meshview/store.py index a5d9ffc..7b0a9b9 100644 --- a/meshview/store.py +++ b/meshview/store.py @@ -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) diff --git a/meshview/templates/chat.html b/meshview/templates/chat.html index bdcc156..def7c92 100644 --- a/meshview/templates/chat.html +++ b/meshview/templates/chat.html @@ -107,7 +107,7 @@ document.addEventListener("DOMContentLoaded", async () => { replyHtml = `
${replyPrefix} - ${packet.reply_id} + ${packet.reply_id}
`; } } @@ -118,11 +118,11 @@ document.addEventListener("DOMContentLoaded", async () => { div.innerHTML = ` ${formattedTimestamp} - ✉️ + ✉️ ${escapeHtml(packet.channel || "")} - + ${escapeHtml((packet.long_name || "").trim() || `Node ${packet.from_node_id}`)} diff --git a/meshview/templates/firehose.html b/meshview/templates/firehose.html index 7374c80..efe87e0 100644 --- a/meshview/templates/firehose.html +++ b/meshview/templates/firehose.html @@ -248,7 +248,7 @@ async function fetchUpdates() { const html = ` ${localTime} - ${pkt.id} + ${pkt.id} ${from} ${to} ${portLabel(pkt.portnum, pkt.payload)} diff --git a/meshview/templates/net.html b/meshview/templates/net.html index 554771c..fb2c451 100644 --- a/meshview/templates/net.html +++ b/meshview/templates/net.html @@ -35,9 +35,7 @@ {% block body %}
-
Loading weekly message...
-
Total messages: 0
@@ -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."; + } }); - {% endblock %} diff --git a/meshview/templates/new_node.html b/meshview/templates/new_node.html new file mode 100644 index 0000000..e950034 --- /dev/null +++ b/meshview/templates/new_node.html @@ -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 %} +
+
📡 Node Feed:
+ + +
+
Node ID:
+
Channel:
+
HW Model:
+
Role:
+
Last Update:
+
+ + +
+ + +
+
🔋 Battery & Voltage +
+ + +
+
+
+
+ +
+
📶 Air & Channel Utilization +
+ + +
+
+
+
+ + + + + + + + + + + + + + + +
TimePacket IDFromToPort
+
+ + +
+
+
+ +
+
+
+
+ + + +{% endblock %} diff --git a/meshview/templates/new_packet.html b/meshview/templates/new_packet.html new file mode 100644 index 0000000..b1a56fb --- /dev/null +++ b/meshview/templates/new_packet.html @@ -0,0 +1,446 @@ +{% extends "base.html" %} + +{% block title %}Packet Details{%endblock%} + +{% block css %} +{{ super() }} + +{% endblock %} + +{% block body %} +
+ +
Loading packet information...
+
+ +
+ +
+
+ 📡 Seen By +
+ +
+ + + + + + + + + + + + +
NodeRSSISNRHopChannelTime
+
+
+
+ + +{% endblock %} diff --git a/meshview/web.py b/meshview/web.py index 7563f3b..b0bceb4 100644 --- a/meshview/web.py +++ b/meshview/web.py @@ -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") diff --git a/meshview/web_api/api.py b/meshview/web_api/api.py index 4962505..889c2d2 100644 --- a/meshview/web_api/api.py +++ b/meshview/web_api/api.py @@ -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, + )