mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-07-15 22:31:13 +02:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a676db2921 | |||
| e48e9464d7 | |||
| b72bc5d52b | |||
| 1220f0bcbd | |||
| 539410d5bb | |||
| 383b576d18 | |||
| 64a55a3ef3 | |||
| 9408201e57 | |||
| f75d6bf749 | |||
| 924d223866 | |||
| e9dcca1f19 | |||
| 00cc2abd23 | |||
| b76477167d | |||
| b41b249a6d | |||
| 71fcda2dd6 | |||
| c4453fbb31 | |||
| 79fa3f66a8 | |||
| 0ce64ac975 |
@@ -87,12 +87,13 @@ Samples of currently running instances:
|
|||||||
- https://map.wpamesh.net (Western Pennsylvania)
|
- https://map.wpamesh.net (Western Pennsylvania)
|
||||||
- https://meshview.chicagolandmesh.org (Chicago)
|
- https://meshview.chicagolandmesh.org (Chicago)
|
||||||
- https://meshview.mt.gt (Canadaverse)
|
- https://meshview.mt.gt (Canadaverse)
|
||||||
|
- https://canadaverse.org (Canadaverse)
|
||||||
- https://meshview.meshtastic.es (Spain)
|
- https://meshview.meshtastic.es (Spain)
|
||||||
- https://view.mtnme.sh (North Georgia / East Tennessee)
|
- https://view.mtnme.sh (North Georgia / East Tennessee)
|
||||||
- https://meshview.lsinfra.de (Hessen - Germany)
|
- https://meshview.lsinfra.de (Hessen - Germany)
|
||||||
- https://map.nswmesh.au (Sydney - Australia)
|
|
||||||
- https://meshview.pvmesh.org (Pioneer Valley, Massachusetts)
|
- https://meshview.pvmesh.org (Pioneer Valley, Massachusetts)
|
||||||
- https://meshview.louisianamesh.org (Louisiana)
|
- https://meshview.louisianamesh.org (Louisiana)
|
||||||
|
- https://www.swlamesh.com/map (Southwest Louisiana)
|
||||||
- https://meshview.meshcolombia.co/ (Colombia)
|
- https://meshview.meshcolombia.co/ (Colombia)
|
||||||
- https://meshview-salzburg.jmt.gr/ (Salzburg / Austria)
|
- https://meshview-salzburg.jmt.gr/ (Salzburg / Austria)
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -179,7 +179,11 @@
|
|||||||
"to": "To",
|
"to": "To",
|
||||||
"port": "Port",
|
"port": "Port",
|
||||||
"direct_to_mqtt": "Direct to MQTT",
|
"direct_to_mqtt": "Direct to MQTT",
|
||||||
"all_broadcast": "All"
|
"all_broadcast": "All",
|
||||||
|
"statistics": "Statistics",
|
||||||
|
"last_24h": "24h",
|
||||||
|
"packets_sent": "Packets sent",
|
||||||
|
"times_seen": "Times seen"
|
||||||
},
|
},
|
||||||
"packet": {
|
"packet": {
|
||||||
"loading": "Loading packet information...",
|
"loading": "Loading packet information...",
|
||||||
|
|||||||
@@ -164,7 +164,11 @@
|
|||||||
"to": "A",
|
"to": "A",
|
||||||
"port": "Puerto",
|
"port": "Puerto",
|
||||||
"direct_to_mqtt": "Directo a MQTT",
|
"direct_to_mqtt": "Directo a MQTT",
|
||||||
"all_broadcast": "Todos"
|
"all_broadcast": "Todos",
|
||||||
|
"statistics": "Estadísticas",
|
||||||
|
"last_24h": "24h",
|
||||||
|
"packets_sent": "Paquetes enviados",
|
||||||
|
"times_seen": "Veces visto"
|
||||||
},
|
},
|
||||||
|
|
||||||
"packet": {
|
"packet": {
|
||||||
|
|||||||
+313
-72
@@ -143,19 +143,29 @@
|
|||||||
|
|
||||||
<!-- Node Info -->
|
<!-- Node Info -->
|
||||||
<div id="node-info" class="node-info">
|
<div id="node-info" class="node-info">
|
||||||
<div><strong data-translate-lang="node_id">Node ID</strong><strong>:</strong><span id="info-node-id">—</span></div>
|
<div><strong data-translate-lang="node_id">Node ID</strong><strong>: </strong><span id="info-node-id">—</span></div>
|
||||||
<div><strong data-translate-lang="long_name">Long Name</strong><strong>:</strong> <span id="info-long-name">—</span></div>
|
<div><strong data-translate-lang="id">Hex ID</strong><strong>: </strong><span id="info-id">—</span></div>
|
||||||
<div><strong data-translate-lang="short_name">Short Name</strong><strong>:</strong> <span id="info-short-name">—</span></div>
|
<div><strong data-translate-lang="long_name">Long Name</strong><strong>: </strong> <span id="info-long-name">—</span></div>
|
||||||
|
<div><strong data-translate-lang="short_name">Short Name</strong><strong>: </strong> <span id="info-short-name">—</span></div>
|
||||||
|
|
||||||
<div><strong data-translate-lang="hw_model">Hardware Model</strong><strong>:</strong> <span id="info-hw-model">—</span></div>
|
<div><strong data-translate-lang="hw_model">Hardware Model</strong><strong>: </strong> <span id="info-hw-model">—</span></div>
|
||||||
<div><strong data-translate-lang="firmware">Firmware</strong><strong>:</strong> <span id="info-firmware">—</span></div>
|
<div><strong data-translate-lang="firmware">Firmware</strong><strong>: </strong> <span id="info-firmware">—</span></div>
|
||||||
<div><strong data-translate-lang="role">Role</strong><strong>:</strong> <span id="info-role">—</span></div>
|
<div><strong data-translate-lang="role">Role</strong><strong>: </strong> <span id="info-role">—</span></div>
|
||||||
|
|
||||||
|
<div><strong data-translate-lang="channel">Channel</strong><strong>: </strong> <span id="info-channel">—</span></div>
|
||||||
|
<div><strong data-translate-lang="latitude">Latitude</strong><strong>: </strong> <span id="info-lat">—</span></div>
|
||||||
|
<div><strong data-translate-lang="longitude">Longitude</strong><strong>: </strong> <span id="info-lon">—</span></div>
|
||||||
|
|
||||||
|
<div><strong data-translate-lang="last_update">Last Update</strong><strong>: </strong> <span id="info-last-update">—</span></div>
|
||||||
|
<div>
|
||||||
|
<strong data-translate-lang="statistics">Statistics</strong><strong>: </strong>
|
||||||
|
<span id="info-stats"
|
||||||
|
data-label-24h="24h"
|
||||||
|
data-label-sent="Packets sent"
|
||||||
|
data-label-seen="Times seen">—</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div><strong data-translate-lang="channel">Channel</strong><strong>:</strong> <span id="info-channel">—</span></div>
|
|
||||||
<div><strong data-translate-lang="latitude">Latitude</strong><strong>:</strong> <span id="info-lat">—</span></div>
|
|
||||||
<div><strong data-translate-lang="longitude">Longitude</strong><strong>:</strong> <span id="info-lon">—</span></div>
|
|
||||||
|
|
||||||
<div><strong data-translate-lang="last_update">Last Update</strong><strong>:</strong> <span id="info-last-update">—</span></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Map. -->
|
<!-- Map. -->
|
||||||
@@ -210,6 +220,41 @@
|
|||||||
<div id="chart_neighbors" style="height:380px;"></div>
|
<div id="chart_neighbors" style="height:380px;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Packet Histogram -->
|
||||||
|
<div id="packet_histogram_container" class="chart-container">
|
||||||
|
<div class="chart-header">
|
||||||
|
📊 <span data-translate-lang="packets_per_day">Packets per Day (Last 7 Days)</span>
|
||||||
|
<div class="chart-actions">
|
||||||
|
<button onclick="expandChart('packet_histogram')" data-translate-lang="expand">Expand</button>
|
||||||
|
<button onclick="exportCSV('packet_histogram')" data-translate-lang="export_csv">Export CSV</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="chart_packet_histogram" style="height:380px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Packet Filters -->
|
||||||
|
<div class="filter-container" style="margin-bottom:10px; display:flex; gap:12px; flex-wrap:wrap;">
|
||||||
|
<select id="packet_since">
|
||||||
|
<option value="">All time</option>
|
||||||
|
<option value="3600">Last hour</option>
|
||||||
|
<option value="21600">Last 6 hours</option>
|
||||||
|
<option value="86400">Last 24 hours</option>
|
||||||
|
<option value="172800">Last 2 days</option>
|
||||||
|
<option value="259200">Last 3 days</option>
|
||||||
|
<option value="432000">Last 5 days</option>
|
||||||
|
<option value="604800">Last 7 days</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="packet_port">
|
||||||
|
<option value="">All ports</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button onclick="reloadPackets()">Apply</button>
|
||||||
|
<button onclick="exportPacketsCSV()">Export CSV</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Packets -->
|
<!-- Packets -->
|
||||||
<table class="packet-table">
|
<table class="packet-table">
|
||||||
@@ -240,6 +285,33 @@
|
|||||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
|
const PORT_COLOR_MAP = {
|
||||||
|
0: "#6c757d",
|
||||||
|
1: "#007bff",
|
||||||
|
3: "#28a745",
|
||||||
|
4: "#ffc107",
|
||||||
|
5: "#dc3545",
|
||||||
|
6: "#20c997",
|
||||||
|
65: "#6610f2",
|
||||||
|
67: "#17a2b8",
|
||||||
|
70: "#ff9800",
|
||||||
|
71: "#ff66cc",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PORT_LABEL_MAP = {
|
||||||
|
0: "UNKNOWN",
|
||||||
|
1: "Text",
|
||||||
|
3: "Position",
|
||||||
|
4: "Node Info",
|
||||||
|
5: "Routing",
|
||||||
|
6: "Admin",
|
||||||
|
65: "Store & Forward",
|
||||||
|
67: "Telemetry",
|
||||||
|
70: "Traceroute",
|
||||||
|
71: "Neighbor"
|
||||||
|
};
|
||||||
|
|
||||||
/* ======================================================
|
/* ======================================================
|
||||||
NODE PAGE TRANSLATION (isolated from base)
|
NODE PAGE TRANSLATION (isolated from base)
|
||||||
====================================================== */
|
====================================================== */
|
||||||
@@ -344,6 +416,7 @@ let nodeMap = {}; // node_id -> label
|
|||||||
let nodePositions = {}; // node_id -> [lat, lon]
|
let nodePositions = {}; // node_id -> [lat, lon]
|
||||||
let nodeCache = {}; // node_id -> full node object
|
let nodeCache = {}; // node_id -> full node object
|
||||||
let currentNode = null;
|
let currentNode = null;
|
||||||
|
let currentPacketRows = [];
|
||||||
|
|
||||||
let map, markers = {};
|
let map, markers = {};
|
||||||
let chartData = {}, neighborData = { ids:[], names:[], snrs:[] };
|
let chartData = {}, neighborData = { ids:[], names:[], snrs:[] };
|
||||||
@@ -406,6 +479,7 @@ async function loadNodeInfo(){
|
|||||||
nodeMap[fromNodeId] || fromNodeId;
|
nodeMap[fromNodeId] || fromNodeId;
|
||||||
|
|
||||||
// Info card
|
// Info card
|
||||||
|
document.getElementById("info-id").textContent = node.id ?? "—";
|
||||||
document.getElementById("info-node-id").textContent = node.node_id ?? "—";
|
document.getElementById("info-node-id").textContent = node.node_id ?? "—";
|
||||||
document.getElementById("info-long-name").textContent = node.long_name ?? "—";
|
document.getElementById("info-long-name").textContent = node.long_name ?? "—";
|
||||||
document.getElementById("info-short-name").textContent = node.short_name ?? "—";
|
document.getElementById("info-short-name").textContent = node.short_name ?? "—";
|
||||||
@@ -424,6 +498,7 @@ async function loadNodeInfo(){
|
|||||||
lastSeen = formatLastSeen(node.last_seen_us);
|
lastSeen = formatLastSeen(node.last_seen_us);
|
||||||
}
|
}
|
||||||
document.getElementById("info-last-update").textContent = lastSeen;
|
document.getElementById("info-last-update").textContent = lastSeen;
|
||||||
|
loadNodeStats(node.node_id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load node info:", err);
|
console.error("Failed to load node info:", err);
|
||||||
document.getElementById("node-info").style.display = "none";
|
document.getElementById("node-info").style.display = "none";
|
||||||
@@ -457,47 +532,27 @@ function nodeLink(id, labelOverride = null) {
|
|||||||
</a>`;
|
</a>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initPacketPortFilter() {
|
||||||
|
const sel = document.getElementById("packet_port");
|
||||||
|
if (!sel) return;
|
||||||
|
|
||||||
|
Object.keys(PORT_LABEL_MAP)
|
||||||
|
.map(Number)
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.forEach(p => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = p;
|
||||||
|
opt.textContent = `${PORT_LABEL_MAP[p]} (${p})`;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ======================================================
|
/* ======================================================
|
||||||
PORT LABELS
|
PORT LABELS
|
||||||
====================================================== */
|
====================================================== */
|
||||||
|
|
||||||
function portLabel(p) {
|
function portLabel(p) {
|
||||||
const PORT_COLOR_MAP = {
|
|
||||||
0: "#6c757d",
|
|
||||||
1: "#007bff",
|
|
||||||
3: "#28a745",
|
|
||||||
4: "#ffc107",
|
|
||||||
5: "#dc3545",
|
|
||||||
6: "#20c997",
|
|
||||||
65: "#6610f2",
|
|
||||||
67: "#17a2b8",
|
|
||||||
68: "#fd7e14",
|
|
||||||
69: "#6f42c1",
|
|
||||||
70: "#ff4444",
|
|
||||||
71: "#ff66cc",
|
|
||||||
72: "#00cc99",
|
|
||||||
73: "#9999ff",
|
|
||||||
74: "#cc00cc",
|
|
||||||
75: "#ffbb33",
|
|
||||||
76: "#00bcd4",
|
|
||||||
77: "#8bc34a",
|
|
||||||
78: "#795548"
|
|
||||||
};
|
|
||||||
|
|
||||||
const PORT_LABEL_MAP = {
|
|
||||||
0: "UNKNOWN",
|
|
||||||
1: "Text",
|
|
||||||
3: "Position",
|
|
||||||
4: "Node Info",
|
|
||||||
5: "Routing",
|
|
||||||
6: "Admin",
|
|
||||||
65: "Store & Forward",
|
|
||||||
67: "Telemetry",
|
|
||||||
70: "Traceroute",
|
|
||||||
71: "Neighbor"
|
|
||||||
};
|
|
||||||
|
|
||||||
const color = PORT_COLOR_MAP[p] || "#6c757d";
|
const color = PORT_COLOR_MAP[p] || "#6c757d";
|
||||||
const label = PORT_LABEL_MAP[p] || `Port ${p}`;
|
const label = PORT_LABEL_MAP[p] || `Port ${p}`;
|
||||||
|
|
||||||
@@ -511,6 +566,7 @@ function portLabel(p) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ======================================================
|
/* ======================================================
|
||||||
MAP SETUP
|
MAP SETUP
|
||||||
====================================================== */
|
====================================================== */
|
||||||
@@ -679,62 +735,67 @@ async function loadTrack(){
|
|||||||
PACKETS TABLE + NEIGHBOR OVERLAY
|
PACKETS TABLE + NEIGHBOR OVERLAY
|
||||||
====================================================== */
|
====================================================== */
|
||||||
|
|
||||||
async function loadPackets(){
|
async function loadPackets(filters = {}) {
|
||||||
|
const list = document.getElementById("packet_list");
|
||||||
|
list.innerHTML = "";
|
||||||
|
|
||||||
const url = new URL("/api/packets", window.location.origin);
|
const url = new URL("/api/packets", window.location.origin);
|
||||||
url.searchParams.set("node_id", fromNodeId); // node_id includes to/from
|
url.searchParams.set("node_id", fromNodeId);
|
||||||
url.searchParams.set("limit", 200);
|
url.searchParams.set("limit", 1000);
|
||||||
|
|
||||||
|
if (filters.since) {
|
||||||
|
url.searchParams.set("since", filters.since);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.portnum) {
|
||||||
|
url.searchParams.set("portnum", filters.portnum);
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const list = document.getElementById("packet_list");
|
const packets = data.packets || [];
|
||||||
|
currentPacketRows = packets;
|
||||||
|
|
||||||
|
|
||||||
for (const pkt of (data.packets || []).reverse()) {
|
for (const pkt of (data.packets || []).reverse()) {
|
||||||
const safePayload = (pkt.payload || "").replace(/[<>]/g, m => m === "<" ? "<" : ">");
|
const safePayload = (pkt.payload || "")
|
||||||
const localTime = formatLocalTime(pkt.import_time_us);
|
.replace(/[<>]/g, m => (m === "<" ? "<" : ">"));
|
||||||
const fromCell = nodeLink(pkt.from_node_id,pkt.long_name);
|
|
||||||
const toCell = nodeLink(pkt.to_node_id, pkt.to_long_name);
|
|
||||||
|
|
||||||
// Neighbor packets (port 71) → draw neighbors on map
|
const localTime = formatLocalTime(pkt.import_time_us);
|
||||||
if (pkt.portnum === 71 && pkt.payload) {
|
const fromCell = nodeLink(pkt.from_node_id, pkt.long_name);
|
||||||
const nids = [];
|
const toCell = nodeLink(pkt.to_node_id, pkt.to_long_name);
|
||||||
const re = /neighbors\s*\{\s*node_id:\s*(\d+)/g;
|
|
||||||
let m;
|
|
||||||
while ((m = re.exec(pkt.payload)) !== null) {
|
|
||||||
nids.push(parseInt(m[1]));
|
|
||||||
}
|
|
||||||
if (nids.length && map) {
|
|
||||||
await drawNeighbors(pkt.from_node_id, nids);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let inlineLinks = "";
|
let inlineLinks = "";
|
||||||
|
|
||||||
// Position link (Google Maps)
|
|
||||||
if (pkt.portnum === 3 && pkt.payload) {
|
if (pkt.portnum === 3 && pkt.payload) {
|
||||||
const latMatch = pkt.payload.match(/latitude_i:\s*(-?\d+)/);
|
const latMatch = pkt.payload.match(/latitude_i:\s*(-?\d+)/);
|
||||||
const lonMatch = pkt.payload.match(/longitude_i:\s*(-?\d+)/);
|
const lonMatch = pkt.payload.match(/longitude_i:\s*(-?\d+)/);
|
||||||
|
|
||||||
if (latMatch && lonMatch) {
|
if (latMatch && lonMatch) {
|
||||||
const lat = parseFloat(latMatch[1]) / 1e7;
|
const lat = parseFloat(latMatch[1]) / 1e7;
|
||||||
const lon = parseFloat(lonMatch[1]) / 1e7;
|
const lon = parseFloat(lonMatch[1]) / 1e7;
|
||||||
inlineLinks += ` <a class="inline-link" href="https://www.google.com/maps?q=${lat},${lon}" target="_blank">📍</a>`;
|
inlineLinks +=
|
||||||
|
` <a class="inline-link" href="https://www.google.com/maps?q=${lat},${lon}" target="_blank">📍</a>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Traceroute link
|
|
||||||
if (pkt.portnum === 70) {
|
if (pkt.portnum === 70) {
|
||||||
let traceId = pkt.id;
|
let traceId = pkt.id;
|
||||||
const match = pkt.payload?.match(/ID:\s*(\d+)/i);
|
const match = pkt.payload?.match(/ID:\s*(\d+)/i);
|
||||||
if (match) traceId = match[1];
|
if (match) traceId = match[1];
|
||||||
inlineLinks += ` <a class="inline-link" href="/graph/traceroute/${traceId}" target="_blank">⮕</a>`;
|
inlineLinks +=
|
||||||
|
` <a class="inline-link" href="/graph/traceroute/${traceId}" target="_blank">⮕</a>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
list.insertAdjacentHTML("afterbegin", `
|
list.insertAdjacentHTML("afterbegin", `
|
||||||
<tr class="packet-row">
|
<tr class="packet-row">
|
||||||
<td>${localTime}</td>
|
<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="/packet/${pkt.id}" style="text-decoration:underline; color:inherit;">
|
||||||
|
${pkt.id}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
<td>${fromCell}</td>
|
<td>${fromCell}</td>
|
||||||
<td>${toCell}</td>
|
<td>${toCell}</td>
|
||||||
<td>${portLabel(pkt.portnum)}${inlineLinks}</td>
|
<td>${portLabel(pkt.portnum)}${inlineLinks}</td>
|
||||||
@@ -745,6 +806,7 @@ async function loadPackets(){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ======================================================
|
/* ======================================================
|
||||||
TELEMETRY CHARTS (portnum=67)
|
TELEMETRY CHARTS (portnum=67)
|
||||||
====================================================== */
|
====================================================== */
|
||||||
@@ -928,7 +990,6 @@ async function loadNeighborTimeSeries() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- FIX #1: enforce chronological order (oldest → newest) ---
|
|
||||||
packets.sort((a, b) => (a.import_time_us || 0) - (b.import_time_us || 0));
|
packets.sort((a, b) => (a.import_time_us || 0) - (b.import_time_us || 0));
|
||||||
|
|
||||||
// neighborHistory = { node_id: { name, snr:[...], times:[...] } }
|
// neighborHistory = { node_id: { name, snr:[...], times:[...] } }
|
||||||
@@ -1023,6 +1084,99 @@ async function loadNeighborTimeSeries() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function loadPacketHistogram() {
|
||||||
|
const DAYS = 7;
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const dayKeys = [];
|
||||||
|
const dayLabels = [];
|
||||||
|
|
||||||
|
for (let i = DAYS - 1; i >= 0; i--) {
|
||||||
|
const d = new Date(now);
|
||||||
|
d.setDate(d.getDate() - i);
|
||||||
|
dayKeys.push(d.toISOString().slice(0, 10));
|
||||||
|
dayLabels.push(
|
||||||
|
d.toLocaleDateString([], { month: "short", day: "numeric" })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL("/api/packets", window.location.origin);
|
||||||
|
url.searchParams.set("node_id", fromNodeId);
|
||||||
|
|
||||||
|
// last 7 days only (microseconds)
|
||||||
|
const sinceUs = Date.now() * 1000 - (7 * 24 * 60 * 60 * 1_000_000);
|
||||||
|
url.searchParams.set("since", sinceUs);
|
||||||
|
|
||||||
|
// modest safety limit (still applies after server-side filter)
|
||||||
|
url.searchParams.set("limit", 2000);
|
||||||
|
|
||||||
|
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) return;
|
||||||
|
|
||||||
|
const packets = (await res.json()).packets || [];
|
||||||
|
|
||||||
|
const counts = {}; // { port: { day: count } }
|
||||||
|
const ports = new Set();
|
||||||
|
|
||||||
|
for (const pkt of packets) {
|
||||||
|
if (!pkt.import_time_us) continue;
|
||||||
|
|
||||||
|
const day = new Date(pkt.import_time_us / 1000)
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
if (!dayKeys.includes(day)) continue;
|
||||||
|
|
||||||
|
const port = pkt.portnum ?? 0;
|
||||||
|
ports.add(port);
|
||||||
|
|
||||||
|
counts[port] ??= {};
|
||||||
|
counts[port][day] = (counts[port][day] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ports.size) {
|
||||||
|
document.getElementById("packet_histogram_container").style.display = "none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const series = Array.from(ports)
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.map(port => ({
|
||||||
|
name: PORT_LABEL_MAP[port] || `Port ${port}`,
|
||||||
|
type: "bar",
|
||||||
|
stack: "total",
|
||||||
|
barMaxWidth: 42,
|
||||||
|
itemStyle: {
|
||||||
|
color: PORT_COLOR_MAP[port] || "#888"
|
||||||
|
},
|
||||||
|
data: dayKeys.map(d => counts[port]?.[d] || 0)
|
||||||
|
}));
|
||||||
|
|
||||||
|
const chart = echarts.init(
|
||||||
|
document.getElementById("chart_packet_histogram")
|
||||||
|
);
|
||||||
|
|
||||||
|
chart.setOption({
|
||||||
|
animation: false,
|
||||||
|
tooltip: { trigger: "axis" },
|
||||||
|
legend: { textStyle: { color: "#ccc" } },
|
||||||
|
xAxis: {
|
||||||
|
type: "category",
|
||||||
|
data: dayLabels,
|
||||||
|
axisLabel: { color: "#ccc" }
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "value",
|
||||||
|
axisLabel: { color: "#ccc" }
|
||||||
|
},
|
||||||
|
series
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("resize", () => chart.resize());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* ======================================================
|
/* ======================================================
|
||||||
EXPAND / EXPORT BUTTONS
|
EXPAND / EXPORT BUTTONS
|
||||||
@@ -1110,8 +1264,10 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
if (!map) initMap(); // init map early so neighbors can draw
|
if (!map) initMap(); // init map early so neighbors can draw
|
||||||
await loadTrack();
|
await loadTrack();
|
||||||
await loadPackets();
|
await loadPackets();
|
||||||
|
initPacketPortFilter();
|
||||||
await loadTelemetryCharts();
|
await loadTelemetryCharts();
|
||||||
await loadNeighborTimeSeries();
|
await loadNeighborTimeSeries();
|
||||||
|
await loadPacketHistogram();
|
||||||
ensureMapVisible();
|
ensureMapVisible();
|
||||||
setTimeout(ensureMapVisible, 1000);
|
setTimeout(ensureMapVisible, 1000);
|
||||||
window.addEventListener("resize", ensureMapVisible);
|
window.addEventListener("resize", ensureMapVisible);
|
||||||
@@ -1119,5 +1275,90 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function loadNodeStats(nodeId) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/stats/count?from_node=${nodeId}&period_type=day&length=1`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
const packets = data?.total_packets ?? 0;
|
||||||
|
const seen = data?.total_seen ?? 0;
|
||||||
|
|
||||||
|
document.getElementById("info-stats").textContent =
|
||||||
|
`24h · Packets sent: ${packets.toLocaleString()} · Times seen: ${seen.toLocaleString()} `;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load node stats:", err);
|
||||||
|
document.getElementById("info-stats").textContent = "—";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reloadPackets() {
|
||||||
|
const sinceSel = document.getElementById("packet_since").value;
|
||||||
|
const portSel = document.getElementById("packet_port").value;
|
||||||
|
|
||||||
|
const filters = {};
|
||||||
|
|
||||||
|
if (sinceSel) {
|
||||||
|
const sinceUs = Date.now() * 1000 - (parseInt(sinceSel, 10) * 1_000_000);
|
||||||
|
filters.since = sinceUs;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (portSel) {
|
||||||
|
filters.portnum = portSel;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPackets(filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportPacketsCSV() {
|
||||||
|
if (!currentPacketRows.length) {
|
||||||
|
alert("No packets to export.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = [
|
||||||
|
["Time", "Packet ID", "From Node", "To Node", "Port", "Port Name", "Payload"]
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pkt of currentPacketRows) {
|
||||||
|
const time = pkt.import_time_us
|
||||||
|
? new Date(pkt.import_time_us / 1000).toISOString()
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const portName = PORT_LABEL_MAP[pkt.portnum] || `Port ${pkt.portnum}`;
|
||||||
|
|
||||||
|
// Escape quotes + line breaks for CSV safety
|
||||||
|
const payload = (pkt.payload || "")
|
||||||
|
.replace(/"/g, '""')
|
||||||
|
.replace(/\r?\n/g, " ");
|
||||||
|
|
||||||
|
rows.push([
|
||||||
|
time,
|
||||||
|
pkt.id,
|
||||||
|
pkt.from_node_id,
|
||||||
|
pkt.to_node_id,
|
||||||
|
pkt.portnum,
|
||||||
|
portName,
|
||||||
|
`"${payload}"`
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `packets_${fromNodeId}_${Date.now()}.csv`;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -2,23 +2,32 @@
|
|||||||
|
|
||||||
{% block css %}
|
{% block css %}
|
||||||
<style>
|
<style>
|
||||||
|
html, body {
|
||||||
|
overflow-x: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
width: 80%;
|
/* FIX: allow table to keep natural width so scrolling works */
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
margin: 1em auto;
|
margin: 1em auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure table centered visually */
|
/* Desktop scroll wrapper */
|
||||||
#node-list {
|
#node-list {
|
||||||
display: flex;
|
width: 100%;
|
||||||
justify-content: center;
|
overflow-x: auto; /* allows horizontal scroll */
|
||||||
|
overflow-y: hidden;
|
||||||
|
/* !!! removed display:flex because it prevents scrolling */
|
||||||
}
|
}
|
||||||
|
|
||||||
#node-list table {
|
#node-list table {
|
||||||
margin-left: auto;
|
width: max-content; /* table keeps its natural width */
|
||||||
margin-right: auto;
|
min-width: 100%; /* won't shrink smaller than viewport */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
th, td {
|
th, td {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border: 1px solid #333;
|
border: 1px solid #333;
|
||||||
@@ -134,16 +143,20 @@ select, .export-btn, .search-box, .clear-btn {
|
|||||||
/* --------------------------------------------- */
|
/* --------------------------------------------- */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|
||||||
/* Hide desktop table */
|
/* Hide desktop view */
|
||||||
#node-list table {
|
#node-list table {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Show mobile card list */
|
/* Show mobile cards */
|
||||||
#mobile-node-list {
|
#mobile-node-list {
|
||||||
display: block !important;
|
display: block !important;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
|
|
||||||
|
/* If you want horizontal swiping, uncomment:
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: nowrap; */
|
||||||
}
|
}
|
||||||
|
|
||||||
.node-card {
|
.node-card {
|
||||||
@@ -177,6 +190,7 @@ select, .export-btn, .search-box, .clear-btn {
|
|||||||
font-size: 1.4em;
|
font-size: 1.4em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
+120
-73
@@ -380,103 +380,150 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------
|
/* ---------------------------------------------
|
||||||
Load packets_seen
|
Load packets_seen
|
||||||
----------------------------------------------*/
|
----------------------------------------------*/
|
||||||
const seenRes = await fetch(`/api/packets_seen/${packetId}`);
|
const seenRes = await fetch(`/api/packets_seen/${packetId}`);
|
||||||
const seenData = await seenRes.json();
|
const seenData = await seenRes.json();
|
||||||
const seenList = seenData.seen ?? [];
|
const seenList = seenData.seen ?? [];
|
||||||
|
|
||||||
const seenSorted = seenList.slice().sort((a,b)=>{
|
/* ---------------------------------------------
|
||||||
return (b.hop_start ?? -999) - (a.hop_start ?? -999);
|
Sort by hop count (highest first)
|
||||||
});
|
----------------------------------------------*/
|
||||||
|
const seenSorted = seenList.slice().sort((a,b)=>{
|
||||||
|
const ha = (a.hop_start ?? 0) - (a.hop_limit ?? 0);
|
||||||
|
const hb = (b.hop_start ?? 0) - (b.hop_limit ?? 0);
|
||||||
|
return hb - ha;
|
||||||
|
});
|
||||||
|
|
||||||
if (seenSorted.length){
|
if (seenSorted.length){
|
||||||
seenContainer.classList.remove("d-none");
|
seenContainer.classList.remove("d-none");
|
||||||
seenCountSpan.textContent = `(${seenSorted.length})`;
|
seenCountSpan.textContent = `(${seenSorted.length})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------
|
/* ---------------------------------------------
|
||||||
Render gateway table + map markers
|
GROUP BY HOP COUNT
|
||||||
----------------------------------------------*/
|
----------------------------------------------*/
|
||||||
seenTableBody.innerHTML = seenSorted.map(s=>{
|
const hopGroups = {};
|
||||||
const node = nodeLookup[s.node_id];
|
|
||||||
const label = node?.long_name || s.node_id;
|
|
||||||
|
|
||||||
const timeStr = s.import_time_us
|
seenSorted.forEach(s => {
|
||||||
? new Date(s.import_time_us/1000).toLocaleTimeString()
|
const hopValue = Math.max(
|
||||||
: "—";
|
0,
|
||||||
|
(s.hop_start ?? 0) - (s.hop_limit ?? 0)
|
||||||
|
);
|
||||||
|
if (!hopGroups[hopValue]) hopGroups[hopValue] = [];
|
||||||
|
hopGroups[hopValue].push(s);
|
||||||
|
});
|
||||||
|
|
||||||
if (node?.last_lat && node.last_long){
|
/* ---------------------------------------------
|
||||||
const rlat = node.last_lat/1e7;
|
Render grouped gateway table + map markers
|
||||||
const rlon = node.last_long/1e7;
|
----------------------------------------------*/
|
||||||
allBounds.push([rlat, rlon]);
|
seenTableBody.innerHTML = Object.keys(hopGroups)
|
||||||
|
.sort((a,b) => Number(a) - Number(b)) // 0 hop first
|
||||||
|
.map(hopKey => {
|
||||||
|
|
||||||
const hopValue = (s.hop_start ?? 0) - (s.hop_limit ?? 0);
|
const hopLabel =
|
||||||
const color = hopColor(hopValue);
|
hopKey === "0"
|
||||||
|
? (packetTranslations.direct || "Direct (0 hops)")
|
||||||
|
: `${hopKey} ${packetTranslations.hops || "hops"}`;
|
||||||
|
|
||||||
const marker = L.marker([rlat,rlon],{
|
const rows = hopGroups[hopKey].map(s => {
|
||||||
icon: L.divIcon({
|
const node = nodeLookup[s.node_id];
|
||||||
html: `
|
const label = node?.long_name || s.node_id;
|
||||||
<div style="
|
|
||||||
background:${color};
|
|
||||||
width:24px; height:24px;
|
|
||||||
border-radius:50%;
|
|
||||||
display:flex;
|
|
||||||
align-items:center;
|
|
||||||
justify-content:center;
|
|
||||||
color:white;
|
|
||||||
font-size:11px;
|
|
||||||
font-weight:700;
|
|
||||||
border:2px solid rgba(0,0,0,0.35);
|
|
||||||
box-shadow:0 0 5px rgba(0,0,0,0.45);
|
|
||||||
">${hopValue}</div>`,
|
|
||||||
className: "",
|
|
||||||
iconSize:[24,24],
|
|
||||||
iconAnchor:[12,12]
|
|
||||||
})
|
|
||||||
}).addTo(map);
|
|
||||||
|
|
||||||
let distKm = null, distMi = null;
|
const timeStr = s.import_time_us
|
||||||
if (srcLat && srcLon){
|
? new Date(s.import_time_us/1000).toLocaleTimeString()
|
||||||
distKm = haversine(srcLat, srcLon, rlat, rlon);
|
: "—";
|
||||||
distMi = distKm * 0.621371;
|
|
||||||
}
|
|
||||||
|
|
||||||
marker.bindPopup(`
|
/* ---------------- MAP MARKERS (UNCHANGED) ---------------- */
|
||||||
|
if (node?.last_lat && node.last_long){
|
||||||
|
const rlat = node.last_lat/1e7;
|
||||||
|
const rlon = node.last_long/1e7;
|
||||||
|
allBounds.push([rlat, rlon]);
|
||||||
|
let distanceKm = null;
|
||||||
|
if (srcLat && srcLon) {
|
||||||
|
distanceKm = haversine(srcLat, srcLon, rlat, rlon);
|
||||||
|
}
|
||||||
|
const distanceMi = distanceKm !== null ? distanceKm * 0.621371 : null;
|
||||||
|
const color = hopColor(hopKey);
|
||||||
|
|
||||||
|
const marker = L.marker([rlat,rlon],{
|
||||||
|
icon: L.divIcon({
|
||||||
|
html: `
|
||||||
|
<div style="
|
||||||
|
background:${color};
|
||||||
|
width:24px; height:24px;
|
||||||
|
border-radius:50%;
|
||||||
|
display:flex;
|
||||||
|
align-items:center;
|
||||||
|
justify-content:center;
|
||||||
|
color:white;
|
||||||
|
font-size:11px;
|
||||||
|
font-weight:700;
|
||||||
|
border:2px solid rgba(0,0,0,0.35);
|
||||||
|
box-shadow:0 0 5px rgba(0,0,0,0.45);
|
||||||
|
">${hopKey}</div>`,
|
||||||
|
className: "",
|
||||||
|
iconSize:[24,24],
|
||||||
|
iconAnchor:[12,12]
|
||||||
|
})
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
marker.bindPopup(`
|
||||||
<div style="font-size:0.9em">
|
<div style="font-size:0.9em">
|
||||||
<b>${label}</b><br>
|
<b>${label}</b><br>
|
||||||
<span data-translate-lang="node_id_short">${packetTranslations.node_id_short || "Node ID"}</span>:
|
<span data-translate-lang="node_id_short">Node ID</span>:
|
||||||
<a href="/node/${s.node_id}">${s.node_id}</a><br>
|
<a href="/node/${s.node_id}">${s.node_id}</a><br>
|
||||||
HW: ${node?.hw_model ?? "—"}<br>
|
HW: ${node?.hw_model ?? "—"}<br>
|
||||||
<span data-translate-lang="channel">${packetTranslations.channel || "Channel"}</span>: ${s.channel ?? "—"}<br><br>
|
<span data-translate-lang="channel">Channel</span>: ${s.channel ?? "—"}<br>
|
||||||
|
${
|
||||||
|
distanceKm !== null
|
||||||
|
? `<span data-translate-lang="distance">Distance</span>:
|
||||||
|
${distanceKm.toFixed(1)} km / ${distanceMi.toFixed(1)} mi<br>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
|
||||||
<b data-translate-lang="signal">${packetTranslations.signal || "Signal"}</b><br>
|
<br>
|
||||||
|
|
||||||
|
<b data-translate-lang="signal">Signal</b><br>
|
||||||
RSSI: ${s.rx_rssi ?? "—"}<br>
|
RSSI: ${s.rx_rssi ?? "—"}<br>
|
||||||
SNR: ${s.rx_snr ?? "—"}<br><br>
|
SNR: ${s.rx_snr ?? "—"}<br><br>
|
||||||
|
|
||||||
<b data-translate-lang="hops">${packetTranslations.hops || "Hops"}</b>: ${hopValue}<br>
|
<b data-translate-lang="hops">Hops</b>: ${hopKey}
|
||||||
|
|
||||||
<b data-translate-lang="distance">${packetTranslations.distance || "Distance"}:</b><br>
|
|
||||||
${
|
|
||||||
distKm
|
|
||||||
? `${distKm.toFixed(2)} km (${distMi.toFixed(2)} mi)`
|
|
||||||
: "—"
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
`);
|
`);
|
||||||
}
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td><a href="/node/${s.node_id}">${label}</a></td>
|
||||||
|
<td>${s.rx_rssi ?? "—"}</td>
|
||||||
|
<td>${s.rx_snr ?? "—"}</td>
|
||||||
|
<td>${hopKey}</td>
|
||||||
|
<td>${s.channel ?? "—"}</td>
|
||||||
|
<td>${timeStr}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="/node/${s.node_id}">${label}</a></td>
|
<td colspan="6"
|
||||||
<td>${s.rx_rssi ?? "—"}</td>
|
style="
|
||||||
<td>${s.rx_snr ?? "—"}</td>
|
background:#1f2327;
|
||||||
<td>${s.hop_start ?? "—"} → ${s.hop_limit ?? "—"}</td>
|
font-weight:700;
|
||||||
<td>${s.channel ?? "—"}</td>
|
color:#9ecbff;
|
||||||
<td>${timeStr}</td>
|
border-top:1px solid #444;
|
||||||
</tr>`;
|
padding:8px 12px;
|
||||||
|
">
|
||||||
|
🔁 ${hopLabel} (${hopGroups[hopKey].length})
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
${rows}
|
||||||
|
`;
|
||||||
}).join("");
|
}).join("");
|
||||||
|
|
||||||
|
|
||||||
/* ---------------------------------------------
|
/* ---------------------------------------------
|
||||||
Fit map around all markers
|
Fit map around all markers
|
||||||
----------------------------------------------*/
|
----------------------------------------------*/
|
||||||
|
|||||||
+121
-189
@@ -39,7 +39,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
table th { background-color: #333; }
|
table th { background-color: #333; }
|
||||||
table tbody tr:nth-child(odd) { background-color: #272b2f; }
|
|
||||||
|
table tbody tr:nth-child(odd) { background-color: #272b2f; }
|
||||||
table tbody tr:nth-child(even) { background-color: #212529; }
|
table tbody tr:nth-child(even) { background-color: #212529; }
|
||||||
table tbody tr:hover { background-color: #555; cursor: pointer; }
|
table tbody tr:hover { background-color: #555; cursor: pointer; }
|
||||||
|
|
||||||
@@ -50,8 +51,15 @@
|
|||||||
.node-link:hover { text-decoration: underline; }
|
.node-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
.good-x { color: #81ff81; font-weight: bold; }
|
.good-x { color: #81ff81; font-weight: bold; }
|
||||||
.ok-x { color: #e8e86d; font-weight: bold; }
|
.ok-x { color: #e8e86d; font-weight: bold; }
|
||||||
.bad-x { color: #ff6464; font-weight: bold; }
|
.bad-x { color: #ff6464; font-weight: bold; }
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -63,47 +71,42 @@
|
|||||||
|
|
||||||
<div class="filter-bar">
|
<div class="filter-bar">
|
||||||
<div>
|
<div>
|
||||||
<label for="channelFilter" data-translate-lang="channel">Channel:</label>
|
<label data-translate-lang="channel">Channel:</label>
|
||||||
<select id="channelFilter" class="form-select form-select-sm" style="width:auto;"></select>
|
<select id="channelFilter"></select>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="nodeSearch" data-translate-lang="search">Search:</label>
|
|
||||||
<input id="nodeSearch" type="text" class="form-control form-control-sm"
|
|
||||||
placeholder="Search nodes..."
|
|
||||||
data-translate-lang="search_placeholder"
|
|
||||||
style="width:180px; display:inline-block;">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ⭐ ADDED NODE COUNT ⭐ -->
|
<div style="margin-bottom:10px;font-weight:bold;">
|
||||||
<div id="count-container" style="margin-bottom:10px; font-weight:bold;">
|
|
||||||
<span data-translate-lang="showing_nodes">Showing</span>
|
<span data-translate-lang="showing_nodes">Showing</span>
|
||||||
<span id="node-count">0</span>
|
<span id="node-count">0</span>
|
||||||
<span data-translate-lang="nodes_suffix">nodes</span>
|
<span data-translate-lang="nodes_suffix">nodes</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-responsive">
|
<table id="nodesTable">
|
||||||
<table id="nodesTable">
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
<tr>
|
<th data-translate-lang="long_name">Long Name</th>
|
||||||
<th data-translate-lang="long_name">Long Name</th>
|
<th data-translate-lang="short_name">Short Name</th>
|
||||||
<th data-translate-lang="short_name">Short Name</th>
|
<th data-translate-lang="channel">Channel</th>
|
||||||
<th data-translate-lang="channel">Channel</th>
|
<th data-translate-lang="packets_sent">Sent (24h)</th>
|
||||||
<th data-translate-lang="packets_sent">Sent (24h)</th>
|
<th data-translate-lang="times_seen">Seen (24h)</th>
|
||||||
<th data-translate-lang="times_seen">Seen (24h)</th>
|
<th data-translate-lang="avg_gateways">Avg Gateways</th>
|
||||||
<th data-translate-lang="avg_gateways">Avg Gateways</th>
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
<tbody></tbody>
|
||||||
<tbody></tbody>
|
</table>
|
||||||
</table>
|
|
||||||
|
<div class="pagination">
|
||||||
|
<button id="prevPage" class="btn btn-sm btn-secondary">Prev</button>
|
||||||
|
<span id="pageInfo"></span>
|
||||||
|
<button id="nextPage" class="btn btn-sm btn-secondary">Next</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
/* ======================================================
|
/* ======================================================
|
||||||
TOP PAGE TRANSLATION (isolated from base)
|
TRANSLATIONS
|
||||||
====================================================== */
|
====================================================== */
|
||||||
let topTranslations = {};
|
let topTranslations = {};
|
||||||
|
|
||||||
@@ -111,198 +114,127 @@ function applyTranslationsTop(dict, root=document) {
|
|||||||
root.querySelectorAll("[data-translate-lang]").forEach(el => {
|
root.querySelectorAll("[data-translate-lang]").forEach(el => {
|
||||||
const key = el.dataset.translateLang;
|
const key = el.dataset.translateLang;
|
||||||
if (!dict[key]) return;
|
if (!dict[key]) return;
|
||||||
|
el.textContent = dict[key];
|
||||||
// input placeholder support
|
|
||||||
if (el.tagName === "INPUT" && el.placeholder !== undefined) {
|
|
||||||
el.placeholder = dict[key];
|
|
||||||
} else {
|
|
||||||
el.textContent = dict[key];
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTranslationsTop() {
|
async function loadTranslationsTop() {
|
||||||
try {
|
const cfg = await window._siteConfigPromise;
|
||||||
const cfg = await window._siteConfigPromise;
|
const lang = cfg?.site?.language || "en";
|
||||||
const lang = cfg?.site?.language || "en";
|
const res = await fetch(`/api/lang?lang=${lang}§ion=top`);
|
||||||
|
topTranslations = await res.json();
|
||||||
const res = await fetch(`/api/lang?lang=${lang}§ion=top`);
|
applyTranslationsTop(topTranslations);
|
||||||
topTranslations = await res.json();
|
|
||||||
|
|
||||||
applyTranslationsTop(topTranslations);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("TOP translation load failed:", err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ======================================================
|
/* ======================================================
|
||||||
PAGE LOGIC
|
CONFIG
|
||||||
====================================================== */
|
====================================================== */
|
||||||
let allNodes = [];
|
const PAGE_SIZE = 20;
|
||||||
|
let currentPage = 0;
|
||||||
async function loadChannels() {
|
let totalRows = 0;
|
||||||
try {
|
|
||||||
const res = await fetch("/api/channels");
|
|
||||||
const data = await res.json();
|
|
||||||
const channels = data.channels || [];
|
|
||||||
|
|
||||||
const select = document.getElementById("channelFilter");
|
|
||||||
|
|
||||||
// LongFast first
|
|
||||||
if (channels.includes("LongFast")) {
|
|
||||||
const opt = document.createElement("option");
|
|
||||||
opt.value = "LongFast";
|
|
||||||
opt.textContent = "LongFast";
|
|
||||||
select.appendChild(opt);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const ch of channels) {
|
|
||||||
if (ch === "LongFast") continue;
|
|
||||||
const opt = document.createElement("option");
|
|
||||||
opt.value = ch;
|
|
||||||
opt.textContent = ch;
|
|
||||||
select.appendChild(opt);
|
|
||||||
}
|
|
||||||
|
|
||||||
select.addEventListener("change", renderTable);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error loading channels:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadNodes() {
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/nodes");
|
|
||||||
const data = await res.json();
|
|
||||||
allNodes = data.nodes || [];
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error loading nodes:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchNodeStats(nodeId) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/stats/count?from_node=${nodeId}&period_type=day&length=1`);
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
const sent = data.total_packets || 0;
|
|
||||||
const seen = data.total_seen || 0;
|
|
||||||
const avg = seen / Math.max(sent, 1);
|
|
||||||
|
|
||||||
return { sent, seen, avg };
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Stat error:", err);
|
|
||||||
return { sent: 0, seen: 0, avg: 0 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/* ======================================================
|
||||||
|
HELPERS
|
||||||
|
====================================================== */
|
||||||
function avgClass(v) {
|
function avgClass(v) {
|
||||||
if (v >= 10) return "good-x";
|
if (v >= 10) return "good-x";
|
||||||
if (v >= 2) return "ok-x";
|
if (v >= 2) return "ok-x";
|
||||||
return "bad-x";
|
return "bad-x";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ======================================================
|
||||||
|
LOAD CHANNELS
|
||||||
|
====================================================== */
|
||||||
|
async function loadChannels() {
|
||||||
|
const res = await fetch("/api/channels");
|
||||||
|
const data = await res.json();
|
||||||
|
const sel = document.getElementById("channelFilter");
|
||||||
|
|
||||||
|
sel.innerHTML = "";
|
||||||
|
for (const ch of data.channels || []) {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = ch;
|
||||||
|
opt.textContent = ch;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
}
|
||||||
|
|
||||||
|
sel.value = "MediumFast";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================================================
|
||||||
|
FETCH + RENDER
|
||||||
|
====================================================== */
|
||||||
async function renderTable() {
|
async function renderTable() {
|
||||||
const tbody = document.querySelector("#nodesTable tbody");
|
const tbody = document.querySelector("#nodesTable tbody");
|
||||||
tbody.innerHTML = "";
|
tbody.innerHTML = "";
|
||||||
|
|
||||||
const channel = document.getElementById("channelFilter").value;
|
const channel = document.getElementById("channelFilter").value;
|
||||||
const searchText = document.getElementById("nodeSearch").value.trim().toLowerCase();
|
const offset = currentPage * PAGE_SIZE;
|
||||||
|
|
||||||
// Filter by channel
|
const url = new URL("/api/stats/top", window.location.origin);
|
||||||
let filtered = allNodes.filter(n => n.channel === channel);
|
url.searchParams.set("limit", PAGE_SIZE);
|
||||||
|
url.searchParams.set("offset", offset);
|
||||||
|
if (channel) url.searchParams.set("channel", channel);
|
||||||
|
|
||||||
// Filter by search
|
const res = await fetch(url);
|
||||||
if (searchText !== "") {
|
const data = await res.json();
|
||||||
filtered = filtered.filter(n =>
|
|
||||||
(n.long_name && n.long_name.toLowerCase().includes(searchText)) ||
|
totalRows = data.total || 0;
|
||||||
(n.short_name && n.short_name.toLowerCase().includes(searchText)) ||
|
|
||||||
String(n.node_id).includes(searchText)
|
for (const n of data.nodes || []) {
|
||||||
);
|
const tr = document.createElement("tr");
|
||||||
|
tr.onclick = () => location.href = `/node/${n.node_id}`;
|
||||||
|
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>
|
||||||
|
<a class="node-link" href="/node/${n.node_id}"
|
||||||
|
onclick="event.stopPropagation()">
|
||||||
|
${n.long_name || n.node_id}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>${n.short_name || ""}</td>
|
||||||
|
<td>${n.channel || ""}</td>
|
||||||
|
<td>${n.sent}</td>
|
||||||
|
<td>${n.seen}</td>
|
||||||
|
<td><span class="${avgClass(n.avg)}">${n.avg.toFixed(1)}</span></td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Placeholder rows first
|
const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE));
|
||||||
const rowRefs = filtered.map(n => {
|
|
||||||
const tr = document.createElement("tr");
|
|
||||||
tr.addEventListener("click", () => window.location.href = `/node/${n.node_id}`);
|
|
||||||
|
|
||||||
const tdLong = document.createElement("td");
|
document.getElementById("node-count").textContent = totalRows;
|
||||||
const a = document.createElement("a");
|
document.getElementById("pageInfo").textContent =
|
||||||
a.href = `/node/${n.node_id}`;
|
`Page ${currentPage + 1} / ${totalPages}`;
|
||||||
a.textContent = n.long_name || n.node_id;
|
|
||||||
a.className = "node-link";
|
|
||||||
a.addEventListener("click", e => e.stopPropagation());
|
|
||||||
tdLong.appendChild(a);
|
|
||||||
|
|
||||||
const tdShort = document.createElement("td");
|
document.getElementById("prevPage").disabled = currentPage === 0;
|
||||||
tdShort.textContent = n.short_name || "";
|
document.getElementById("nextPage").disabled = currentPage >= totalPages - 1;
|
||||||
|
|
||||||
const tdChannel = document.createElement("td");
|
|
||||||
tdChannel.textContent = n.channel || "";
|
|
||||||
|
|
||||||
const tdSent = document.createElement("td");
|
|
||||||
tdSent.textContent = "...";
|
|
||||||
|
|
||||||
const tdSeen = document.createElement("td");
|
|
||||||
tdSeen.textContent = "...";
|
|
||||||
|
|
||||||
const tdAvg = document.createElement("td");
|
|
||||||
tdAvg.textContent = "...";
|
|
||||||
|
|
||||||
tr.appendChild(tdLong);
|
|
||||||
tr.appendChild(tdShort);
|
|
||||||
tr.appendChild(tdChannel);
|
|
||||||
tr.appendChild(tdSent);
|
|
||||||
tr.appendChild(tdSeen);
|
|
||||||
tr.appendChild(tdAvg);
|
|
||||||
|
|
||||||
tbody.appendChild(tr);
|
|
||||||
|
|
||||||
return { node: n, tr, tdSent, tdSeen, tdAvg };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch stats
|
|
||||||
const statsList = await Promise.all(
|
|
||||||
rowRefs.map(ref => fetchNodeStats(ref.node.node_id))
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update rows
|
|
||||||
let combined = rowRefs.map((ref, i) => {
|
|
||||||
const stats = statsList[i];
|
|
||||||
|
|
||||||
ref.tdSent.textContent = stats.sent;
|
|
||||||
ref.tdSeen.textContent = stats.seen;
|
|
||||||
ref.tdAvg.innerHTML =
|
|
||||||
`<span class="${avgClass(stats.avg)}">${stats.avg.toFixed(1)}</span>`;
|
|
||||||
|
|
||||||
return { tr: ref.tr, sent: stats.sent, seen: stats.seen };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove nodes with no activity
|
|
||||||
combined = combined.filter(r => !(r.sent === 0 && r.seen === 0));
|
|
||||||
|
|
||||||
// Sort by seen
|
|
||||||
combined.sort((a, b) => b.seen - a.seen);
|
|
||||||
|
|
||||||
tbody.innerHTML = "";
|
|
||||||
for (const r of combined) tbody.appendChild(r.tr);
|
|
||||||
|
|
||||||
// ⭐ UPDATE COUNT ⭐
|
|
||||||
document.getElementById("node-count").textContent = combined.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ======================================================
|
/* ======================================================
|
||||||
INITIALIZE PAGE
|
INIT
|
||||||
====================================================== */
|
====================================================== */
|
||||||
document.addEventListener("DOMContentLoaded", async () => {
|
document.addEventListener("DOMContentLoaded", async () => {
|
||||||
await loadTranslationsTop(); // ⭐ MUST run first
|
await loadTranslationsTop();
|
||||||
await loadNodes();
|
|
||||||
await loadChannels();
|
await loadChannels();
|
||||||
|
await renderTable();
|
||||||
|
|
||||||
document.getElementById("channelFilter").value = "LongFast";
|
channelFilter.onchange = () => {
|
||||||
document.getElementById("nodeSearch").addEventListener("input", renderTable);
|
currentPage = 0;
|
||||||
|
renderTable();
|
||||||
|
};
|
||||||
|
|
||||||
renderTable();
|
prevPage.onclick = () => {
|
||||||
|
if (currentPage > 0) {
|
||||||
|
currentPage--;
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
nextPage.onclick = () => {
|
||||||
|
currentPage++;
|
||||||
|
renderTable();
|
||||||
|
};
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
+187
-1
@@ -134,7 +134,7 @@ async def api_packets(request):
|
|||||||
|
|
||||||
# --- Parse limit ---
|
# --- Parse limit ---
|
||||||
try:
|
try:
|
||||||
limit = min(max(int(limit_str), 1), 100)
|
limit = min(max(int(limit_str), 1), 1000)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
limit = 50
|
limit = 50
|
||||||
|
|
||||||
@@ -724,3 +724,189 @@ async def api_packets_seen(request):
|
|||||||
{"error": "Internal server error"},
|
{"error": "Internal server error"},
|
||||||
status=500,
|
status=500,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@routes.get("/api/traceroute/{packet_id}")
|
||||||
|
async def api_traceroute(request):
|
||||||
|
packet_id = int(request.match_info['packet_id'])
|
||||||
|
|
||||||
|
traceroutes = list(await store.get_traceroute(packet_id))
|
||||||
|
packet = await store.get_packet(packet_id)
|
||||||
|
|
||||||
|
if not packet:
|
||||||
|
return web.json_response({"error": "Packet not found"}, status=404)
|
||||||
|
|
||||||
|
tr_groups = []
|
||||||
|
|
||||||
|
# --------------------------------------------
|
||||||
|
# Decode each traceroute entry
|
||||||
|
# --------------------------------------------
|
||||||
|
for idx, tr in enumerate(traceroutes):
|
||||||
|
route = decode_payload.decode_payload(PortNum.TRACEROUTE_APP, tr.route)
|
||||||
|
|
||||||
|
forward_list = list(route.route)
|
||||||
|
reverse_list = list(route.route_back)
|
||||||
|
|
||||||
|
tr_groups.append({
|
||||||
|
"index": idx,
|
||||||
|
"import_time": tr.import_time.isoformat() if tr.import_time else None,
|
||||||
|
"gateway_node_id": tr.gateway_node_id,
|
||||||
|
"done": tr.done,
|
||||||
|
"forward_hops": forward_list,
|
||||||
|
"reverse_hops": reverse_list,
|
||||||
|
})
|
||||||
|
|
||||||
|
# --------------------------------------------
|
||||||
|
# Compute UNIQUE paths + counts + winning path
|
||||||
|
# --------------------------------------------
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
forward_paths = []
|
||||||
|
reverse_paths = []
|
||||||
|
winning_paths = []
|
||||||
|
|
||||||
|
for tr in tr_groups:
|
||||||
|
f = tuple(tr["forward_hops"])
|
||||||
|
r = tuple(tr["reverse_hops"])
|
||||||
|
|
||||||
|
if tr["forward_hops"]:
|
||||||
|
forward_paths.append(f)
|
||||||
|
|
||||||
|
if tr["reverse_hops"]:
|
||||||
|
reverse_paths.append(r)
|
||||||
|
|
||||||
|
if tr["done"]:
|
||||||
|
winning_paths.append(f)
|
||||||
|
|
||||||
|
# Deduplicate
|
||||||
|
unique_forward_paths = sorted(set(forward_paths))
|
||||||
|
unique_reverse_paths = sorted(set(reverse_paths))
|
||||||
|
|
||||||
|
# Count occurrences
|
||||||
|
forward_counts = Counter(forward_paths)
|
||||||
|
|
||||||
|
# Convert for JSON output
|
||||||
|
unique_forward_paths_json = [
|
||||||
|
{"path": list(p), "count": forward_counts[p]} for p in unique_forward_paths
|
||||||
|
]
|
||||||
|
|
||||||
|
unique_reverse_paths_json = [list(p) for p in unique_reverse_paths]
|
||||||
|
|
||||||
|
winning_paths_json = [list(p) for p in set(winning_paths)]
|
||||||
|
|
||||||
|
# --------------------------------------------
|
||||||
|
# Final API output
|
||||||
|
# --------------------------------------------
|
||||||
|
return web.json_response({
|
||||||
|
"packet": {
|
||||||
|
"id": packet.id,
|
||||||
|
"from": packet.from_node_id,
|
||||||
|
"to": packet.to_node_id,
|
||||||
|
"channel": packet.channel,
|
||||||
|
},
|
||||||
|
"traceroute_packets": tr_groups,
|
||||||
|
"unique_forward_paths": unique_forward_paths_json,
|
||||||
|
"unique_reverse_paths": unique_reverse_paths_json,
|
||||||
|
"winning_paths": winning_paths_json,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@routes.get("/api/stats/top")
|
||||||
|
async def api_stats_top(request):
|
||||||
|
"""
|
||||||
|
Returns nodes sorted by SEEN (high → low) with pagination.
|
||||||
|
"""
|
||||||
|
|
||||||
|
period_type = request.query.get("period_type", "day")
|
||||||
|
length = int(request.query.get("length", 1))
|
||||||
|
channel = request.query.get("channel")
|
||||||
|
|
||||||
|
limit = min(int(request.query.get("limit", 20)), 100)
|
||||||
|
offset = int(request.query.get("offset", 0))
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"period_type": period_type,
|
||||||
|
"length": length,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
}
|
||||||
|
|
||||||
|
channel_filter = ""
|
||||||
|
if channel:
|
||||||
|
channel_filter = "AND n.channel = :channel"
|
||||||
|
params["channel"] = channel
|
||||||
|
|
||||||
|
sql = f"""
|
||||||
|
WITH sent AS (
|
||||||
|
SELECT
|
||||||
|
p.from_node_id AS node_id,
|
||||||
|
COUNT(*) AS sent
|
||||||
|
FROM packet p
|
||||||
|
WHERE p.import_time_us >= (
|
||||||
|
SELECT MAX(import_time_us) FROM packet
|
||||||
|
) - (
|
||||||
|
CASE
|
||||||
|
WHEN :period_type = 'hour' THEN :length * 3600 * 1000000
|
||||||
|
ELSE :length * 86400 * 1000000
|
||||||
|
END
|
||||||
|
)
|
||||||
|
GROUP BY p.from_node_id
|
||||||
|
),
|
||||||
|
seen AS (
|
||||||
|
SELECT
|
||||||
|
p.from_node_id AS node_id,
|
||||||
|
COUNT(*) AS seen
|
||||||
|
FROM packet_seen ps
|
||||||
|
JOIN packet p ON p.id = ps.packet_id
|
||||||
|
WHERE ps.import_time_us >= (
|
||||||
|
SELECT MAX(import_time_us) FROM packet_seen
|
||||||
|
) - (
|
||||||
|
CASE
|
||||||
|
WHEN :period_type = 'hour' THEN :length * 3600 * 1000000
|
||||||
|
ELSE :length * 86400 * 1000000
|
||||||
|
END
|
||||||
|
)
|
||||||
|
GROUP BY p.from_node_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
n.node_id,
|
||||||
|
n.long_name,
|
||||||
|
n.short_name,
|
||||||
|
n.channel,
|
||||||
|
COALESCE(s.sent, 0) AS sent,
|
||||||
|
COALESCE(se.seen, 0) AS seen
|
||||||
|
FROM node n
|
||||||
|
LEFT JOIN sent s ON s.node_id = n.node_id
|
||||||
|
LEFT JOIN seen se ON se.node_id = n.node_id
|
||||||
|
WHERE 1=1
|
||||||
|
{channel_filter}
|
||||||
|
ORDER BY seen DESC
|
||||||
|
LIMIT :limit OFFSET :offset
|
||||||
|
"""
|
||||||
|
|
||||||
|
count_sql = f"""
|
||||||
|
SELECT COUNT(*) FROM node n WHERE 1=1 {channel_filter}
|
||||||
|
"""
|
||||||
|
|
||||||
|
async with database.async_session() as session:
|
||||||
|
rows = (await session.execute(text(sql), params)).all()
|
||||||
|
total = (await session.execute(text(count_sql), params)).scalar() or 0
|
||||||
|
|
||||||
|
nodes = []
|
||||||
|
for r in rows:
|
||||||
|
avg = r.seen / max(r.sent, 1)
|
||||||
|
nodes.append({
|
||||||
|
"node_id": r.node_id,
|
||||||
|
"long_name": r.long_name,
|
||||||
|
"short_name": r.short_name,
|
||||||
|
"channel": r.channel,
|
||||||
|
"sent": r.sent,
|
||||||
|
"seen": r.seen,
|
||||||
|
"avg": round(avg, 2),
|
||||||
|
})
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
"total": total,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
"nodes": nodes,
|
||||||
|
})
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Basic Jinja Project
|
||||||
|
|
||||||
|
Small starter project that renders HTML with Jinja using Python's built-in HTTP server.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python3 collector.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://127.0.0.1:8000/`.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `collector.py` starts the server and renders templates.
|
||||||
|
- `templates/base.html` is the shared layout.
|
||||||
|
- `templates/index.html` is the page template.
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from http import HTTPStatus
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_HOST = "127.0.0.1"
|
||||||
|
DEFAULT_PORT = 8000
|
||||||
|
DEFAULT_DB_PATH = "readings.db"
|
||||||
|
DEFAULT_READINGS_LIMIT = 100
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
TEMPLATE_DIR = BASE_DIR / "templates"
|
||||||
|
|
||||||
|
templates = Environment(
|
||||||
|
loader=FileSystemLoader(TEMPLATE_DIR),
|
||||||
|
autoescape=select_autoescape(["html", "xml"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now_iso():
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def init_db(db_path):
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS readings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
device_id TEXT NOT NULL,
|
||||||
|
sensor_type TEXT NOT NULL,
|
||||||
|
temperature_c REAL NOT NULL,
|
||||||
|
humidity REAL NOT NULL,
|
||||||
|
received_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_readings_device_received_at
|
||||||
|
ON readings (device_id, received_at DESC)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def insert_reading(db_path, reading):
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO readings (
|
||||||
|
device_id,
|
||||||
|
sensor_type,
|
||||||
|
temperature_c,
|
||||||
|
humidity,
|
||||||
|
received_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
reading["device_id"],
|
||||||
|
reading["sensor_type"],
|
||||||
|
reading["temperature_c"],
|
||||||
|
reading["humidity"],
|
||||||
|
utc_now_iso(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.lastrowid
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_recent_readings(db_path, limit):
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
device_id,
|
||||||
|
sensor_type,
|
||||||
|
temperature_c,
|
||||||
|
humidity,
|
||||||
|
received_at
|
||||||
|
FROM readings
|
||||||
|
ORDER BY received_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
readings = [dict(row) for row in rows]
|
||||||
|
readings.reverse()
|
||||||
|
return readings
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_payload(payload):
|
||||||
|
required_fields = (
|
||||||
|
"device_id",
|
||||||
|
"sensor_type",
|
||||||
|
"temperature_c",
|
||||||
|
"humidity",
|
||||||
|
)
|
||||||
|
for field in required_fields:
|
||||||
|
if field not in payload:
|
||||||
|
raise ValueError("Missing field: {}".format(field))
|
||||||
|
|
||||||
|
validated = {
|
||||||
|
"device_id": str(payload["device_id"]).strip(),
|
||||||
|
"sensor_type": str(payload["sensor_type"]).strip(),
|
||||||
|
"temperature_c": float(payload["temperature_c"]),
|
||||||
|
"humidity": float(payload["humidity"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
if not validated["device_id"]:
|
||||||
|
raise ValueError("device_id must not be empty")
|
||||||
|
if not validated["sensor_type"]:
|
||||||
|
raise ValueError("sensor_type must not be empty")
|
||||||
|
|
||||||
|
return validated
|
||||||
|
|
||||||
|
|
||||||
|
def render_template(template_name, **context):
|
||||||
|
template = templates.get_template(template_name)
|
||||||
|
return template.render(**context)
|
||||||
|
|
||||||
|
|
||||||
|
def build_dashboard_context(db_path):
|
||||||
|
readings = fetch_recent_readings(db_path, DEFAULT_READINGS_LIMIT)
|
||||||
|
latest = readings[-1] if readings else None
|
||||||
|
return {
|
||||||
|
"title": "ESP32 Collector",
|
||||||
|
"readings": readings,
|
||||||
|
"reading_count": len(readings),
|
||||||
|
"latest": latest,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AppHandler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "ESP32Collector/1.0"
|
||||||
|
|
||||||
|
def send_json(self, payload, status=HTTPStatus.OK):
|
||||||
|
body = json.dumps(payload).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def send_html(self, html, status=HTTPStatus.OK):
|
||||||
|
body = html.encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
route = urlparse(self.path).path
|
||||||
|
|
||||||
|
if route == "/":
|
||||||
|
self.send_html(render_template("dashboard.html", **build_dashboard_context(self.server.db_path)))
|
||||||
|
return
|
||||||
|
|
||||||
|
if route == "/chart":
|
||||||
|
self.send_html(render_template("dashboard.html", **build_dashboard_context(self.server.db_path)))
|
||||||
|
return
|
||||||
|
|
||||||
|
if route == "/health":
|
||||||
|
self.send_json({"status": "ok"})
|
||||||
|
return
|
||||||
|
|
||||||
|
if route == "/readings":
|
||||||
|
readings = fetch_recent_readings(self.server.db_path, DEFAULT_READINGS_LIMIT)
|
||||||
|
self.send_json({"readings": readings})
|
||||||
|
return
|
||||||
|
|
||||||
|
self.send_json({"error": "not found"}, status=HTTPStatus.NOT_FOUND)
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
route = urlparse(self.path).path
|
||||||
|
if route != "/metrics":
|
||||||
|
self.send_json({"error": "not found"}, status=HTTPStatus.NOT_FOUND)
|
||||||
|
return
|
||||||
|
|
||||||
|
content_length = self.headers.get("Content-Length")
|
||||||
|
if not content_length:
|
||||||
|
self.send_json({"error": "missing content length"}, status=HTTPStatus.LENGTH_REQUIRED)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_body = self.rfile.read(int(content_length))
|
||||||
|
payload = json.loads(raw_body)
|
||||||
|
reading = validate_payload(payload)
|
||||||
|
row_id = insert_reading(self.server.db_path, reading)
|
||||||
|
except ValueError as exc:
|
||||||
|
self.send_json({"error": str(exc)}, status=HTTPStatus.BAD_REQUEST)
|
||||||
|
return
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
self.send_json({"error": "invalid json"}, status=HTTPStatus.BAD_REQUEST)
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
self.send_json(
|
||||||
|
{"error": "server error", "detail": str(exc)},
|
||||||
|
status=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
print(
|
||||||
|
"[{}] stored reading id={} device={} temp_c={} humidity={}".format(
|
||||||
|
utc_now_iso(),
|
||||||
|
row_id,
|
||||||
|
reading["device_id"],
|
||||||
|
reading["temperature_c"],
|
||||||
|
reading["humidity"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.send_json({"status": "stored", "id": row_id}, status=HTTPStatus.CREATED)
|
||||||
|
|
||||||
|
def log_message(self, format_string, *args):
|
||||||
|
print(
|
||||||
|
"[{}] {} {}".format(
|
||||||
|
utc_now_iso(),
|
||||||
|
self.address_string(),
|
||||||
|
format_string % args,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Collect ESP32 sensor data and render a Jinja dashboard."
|
||||||
|
)
|
||||||
|
parser.add_argument("--host", default=DEFAULT_HOST)
|
||||||
|
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
|
||||||
|
parser.add_argument("--db", default=DEFAULT_DB_PATH)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
db_path = Path(args.db).resolve()
|
||||||
|
init_db(db_path)
|
||||||
|
|
||||||
|
server = ThreadingHTTPServer((args.host, args.port), AppHandler)
|
||||||
|
server.db_path = str(db_path)
|
||||||
|
|
||||||
|
print("Metrics endpoint: http://{}:{}/metrics".format(args.host, args.port))
|
||||||
|
print("Dashboard: http://{}:{}/chart".format(args.host, args.port))
|
||||||
|
print("SQLite database:", db_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nShutting down")
|
||||||
|
finally:
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Jinja2>=3.1,<4
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{ title }}</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f8f5ef;
|
||||||
|
--panel: #fffdf8;
|
||||||
|
--text: #1f2937;
|
||||||
|
--accent: #0f766e;
|
||||||
|
--border: #d6d3d1;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Georgia, serif;
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(15, 118, 110, 0.10), transparent 25%),
|
||||||
|
linear-gradient(180deg, #fdfcf9 0%, var(--bg) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
width: min(760px, calc(100% - 32px));
|
||||||
|
margin: 48px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 32px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 20px 50px rgba(31, 41, 55, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: clamp(2rem, 5vw, 3rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat strong,
|
||||||
|
.stat span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat strong {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat span {
|
||||||
|
color: #57534e;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-wrap {
|
||||||
|
position: relative;
|
||||||
|
height: 360px;
|
||||||
|
margin: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 24px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: "Courier New", monospace;
|
||||||
|
background: rgba(15, 118, 110, 0.08);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<section class="card">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>ESP32 Collector</h1>
|
||||||
|
<p>Recent sensor uploads rendered with Jinja.</p>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat">
|
||||||
|
<strong>{{ reading_count }}</strong>
|
||||||
|
<span>stored readings</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<strong>{{ latest.device_id if latest else "n/a" }}</strong>
|
||||||
|
<span>latest device</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<strong>{{ "%.1f"|format(latest.temperature_c) if latest else "n/a" }}</strong>
|
||||||
|
<span>latest temp C</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<strong>{{ "%.1f"|format(latest.humidity) if latest else "n/a" }}</strong>
|
||||||
|
<span>latest humidity %</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if readings %}
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<canvas id="readingChart"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Device</th>
|
||||||
|
<th>Sensor</th>
|
||||||
|
<th>Temp C</th>
|
||||||
|
<th>Humidity %</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for reading in readings|reverse %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ reading.received_at }}</td>
|
||||||
|
<td>{{ reading.device_id }}</td>
|
||||||
|
<td>{{ reading.sensor_type }}</td>
|
||||||
|
<td>{{ "%.1f"|format(reading.temperature_c) }}</td>
|
||||||
|
<td>{{ "%.1f"|format(reading.humidity) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<script>
|
||||||
|
const readings = {{ readings | tojson }};
|
||||||
|
const labels = readings.map((reading) => new Date(reading.received_at).toLocaleString());
|
||||||
|
const temperatures = readings.map((reading) => reading.temperature_c);
|
||||||
|
const humidity = readings.map((reading) => reading.humidity);
|
||||||
|
|
||||||
|
new Chart(document.getElementById("readingChart"), {
|
||||||
|
type: "line",
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Temperature (C)",
|
||||||
|
data: temperatures,
|
||||||
|
borderColor: "#c2410c",
|
||||||
|
backgroundColor: "rgba(194, 65, 12, 0.14)",
|
||||||
|
borderWidth: 3,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.25,
|
||||||
|
pointRadius: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Humidity (%)",
|
||||||
|
data: humidity,
|
||||||
|
borderColor: "#0f766e",
|
||||||
|
backgroundColor: "rgba(15, 118, 110, 0.12)",
|
||||||
|
borderWidth: 3,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.25,
|
||||||
|
pointRadius: 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty">
|
||||||
|
No readings yet. Your ESP32 should post JSON to <code>/metrics</code>.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user