Loading weekly message...
@@ -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: —
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Time |
+ Packet ID |
+ From |
+ To |
+ Port |
+
+
+
+
+
+
+
+
+
+
+
+{% 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
+
+
+
+
+
+
+ | Node |
+ RSSI |
+ SNR |
+ Hop |
+ Channel |
+ Time |
+
+
+
+
+
+
+
+
+
+{% 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,
+ )