18 Commits

Author SHA1 Message Date
pablorevilla-meshtastic a676db2921 Initial Jinja ESP32 collector 2026-03-18 14:28:04 -07:00
Pablo Revilla e48e9464d7 Modify packet.html to add distance 2026-01-03 21:48:19 -08:00
Pablo Revilla b72bc5d52b Modify packet.html to add distance 2026-01-03 21:44:26 -08:00
Pablo Revilla 1220f0bcbd Modify node.html to add statistics 2026-01-03 21:28:33 -08:00
Pablo Revilla 539410d5bb Modify node.html to add statistics 2026-01-03 21:26:39 -08:00
Pablo Revilla 383b576d18 Modify node.html to add statistics 2026-01-03 21:12:24 -08:00
Pablo Revilla 64a55a3ef3 Modify node.html to add statistics 2026-01-03 20:51:17 -08:00
Pablo Revilla 9408201e57 Modify node.html to add statistics 2026-01-03 19:27:00 -08:00
Pablo Revilla f75d6bf749 Modify node.html to add statistics 2026-01-03 19:00:39 -08:00
Pablo Revilla 924d223866 Modify node.html to add statistics 2026-01-03 18:13:57 -08:00
Pablo Revilla e9dcca1f19 Modify node.html to add statistics 2025-12-31 11:58:45 -08:00
Pablo Revilla 00cc2abd23 Modify node.html to add statistics 2025-12-31 11:56:18 -08:00
Pablo Revilla b76477167d Modify top.html to add paging 2025-12-31 11:13:52 -08:00
Pablo Revilla b41b249a6d Modify top.html to add paging 2025-12-31 10:38:13 -08:00
Pablo Revilla 71fcda2dd6 Modify top.html to add paging 2025-12-30 09:27:51 -08:00
Pablo Revilla c4453fbb31 Modify packet.html to sort by hop count. 2025-12-24 10:54:09 -08:00
Pablo Revilla 79fa3f66a8 Fix chart on node.html. 2025-12-24 10:06:17 -08:00
Pablo Revilla 0ce64ac975 Fix chart on node.html. 2025-12-10 09:56:30 -08:00
13 changed files with 1292 additions and 346 deletions
+2 -1
View File
@@ -87,12 +87,13 @@ Samples of currently running instances:
- https://map.wpamesh.net (Western Pennsylvania)
- https://meshview.chicagolandmesh.org (Chicago)
- https://meshview.mt.gt (Canadaverse)
- https://canadaverse.org (Canadaverse)
- https://meshview.meshtastic.es (Spain)
- https://view.mtnme.sh (North Georgia / East Tennessee)
- https://meshview.lsinfra.de (Hessen - Germany)
- https://map.nswmesh.au (Sydney - Australia)
- https://meshview.pvmesh.org (Pioneer Valley, Massachusetts)
- https://meshview.louisianamesh.org (Louisiana)
- https://www.swlamesh.com/map (Southwest Louisiana)
- https://meshview.meshcolombia.co/ (Colombia)
- https://meshview-salzburg.jmt.gr/ (Salzburg / Austria)
---
+5 -1
View File
@@ -179,7 +179,11 @@
"to": "To",
"port": "Port",
"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": {
"loading": "Loading packet information...",
+5 -1
View File
@@ -164,7 +164,11 @@
"to": "A",
"port": "Puerto",
"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": {
+313 -72
View File
@@ -143,19 +143,29 @@
<!-- 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="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="node_id">Node ID</strong><strong>: </strong><span id="info-node-id"></span></div>
<div><strong data-translate-lang="id">Hex ID</strong><strong>: </strong><span id="info-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="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="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="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="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>
<!-- Map. -->
@@ -210,6 +220,41 @@
<div id="chart_neighbors" style="height:380px;"></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 -->
<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>
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)
====================================================== */
@@ -344,6 +416,7 @@ let nodeMap = {}; // node_id -> label
let nodePositions = {}; // node_id -> [lat, lon]
let nodeCache = {}; // node_id -> full node object
let currentNode = null;
let currentPacketRows = [];
let map, markers = {};
let chartData = {}, neighborData = { ids:[], names:[], snrs:[] };
@@ -406,6 +479,7 @@ async function loadNodeInfo(){
nodeMap[fromNodeId] || fromNodeId;
// Info card
document.getElementById("info-id").textContent = node.id ?? "—";
document.getElementById("info-node-id").textContent = node.node_id ?? "—";
document.getElementById("info-long-name").textContent = node.long_name ?? "—";
document.getElementById("info-short-name").textContent = node.short_name ?? "—";
@@ -424,6 +498,7 @@ async function loadNodeInfo(){
lastSeen = formatLastSeen(node.last_seen_us);
}
document.getElementById("info-last-update").textContent = lastSeen;
loadNodeStats(node.node_id);
} catch (err) {
console.error("Failed to load node info:", err);
document.getElementById("node-info").style.display = "none";
@@ -457,47 +532,27 @@ function nodeLink(id, labelOverride = null) {
</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
====================================================== */
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 label = PORT_LABEL_MAP[p] || `Port ${p}`;
@@ -511,6 +566,7 @@ function portLabel(p) {
`;
}
/* ======================================================
MAP SETUP
====================================================== */
@@ -679,62 +735,67 @@ async function loadTrack(){
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);
url.searchParams.set("node_id", fromNodeId); // node_id includes to/from
url.searchParams.set("limit", 200);
url.searchParams.set("node_id", fromNodeId);
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);
if (!res.ok) return;
const data = await res.json();
const list = document.getElementById("packet_list");
const packets = data.packets || [];
currentPacketRows = packets;
for (const pkt of (data.packets || []).reverse()) {
const safePayload = (pkt.payload || "").replace(/[<>]/g, m => m === "<" ? "&lt;" : "&gt;");
const localTime = formatLocalTime(pkt.import_time_us);
const fromCell = nodeLink(pkt.from_node_id,pkt.long_name);
const toCell = nodeLink(pkt.to_node_id, pkt.to_long_name);
const safePayload = (pkt.payload || "")
.replace(/[<>]/g, m => (m === "<" ? "&lt;" : "&gt;"));
// Neighbor packets (port 71) → draw neighbors on map
if (pkt.portnum === 71 && pkt.payload) {
const nids = [];
const re = /neighbors\s*\{\s*node_id:\s*(\d+)/g;
let m;
while ((m = re.exec(pkt.payload)) !== null) {
nids.push(parseInt(m[1]));
}
if (nids.length && map) {
await drawNeighbors(pkt.from_node_id, nids);
}
}
const localTime = formatLocalTime(pkt.import_time_us);
const fromCell = nodeLink(pkt.from_node_id, pkt.long_name);
const toCell = nodeLink(pkt.to_node_id, pkt.to_long_name);
let inlineLinks = "";
// Position link (Google Maps)
if (pkt.portnum === 3 && pkt.payload) {
const latMatch = pkt.payload.match(/latitude_i:\s*(-?\d+)/);
const lonMatch = pkt.payload.match(/longitude_i:\s*(-?\d+)/);
if (latMatch && lonMatch) {
const lat = parseFloat(latMatch[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) {
let traceId = pkt.id;
const match = pkt.payload?.match(/ID:\s*(\d+)/i);
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", `
<tr class="packet-row">
<td>${localTime}</td>
<td><span class="toggle-btn">▶</span> <a href="/packet/${pkt.id}" style="text-decoration:underline; color:inherit;">${pkt.id}</a></td>
<td><span class="toggle-btn">▶</span>
<a href="/packet/${pkt.id}" style="text-decoration:underline; color:inherit;">
${pkt.id}
</a>
</td>
<td>${fromCell}</td>
<td>${toCell}</td>
<td>${portLabel(pkt.portnum)}${inlineLinks}</td>
@@ -745,6 +806,7 @@ async function loadPackets(){
}
}
/* ======================================================
TELEMETRY CHARTS (portnum=67)
====================================================== */
@@ -928,7 +990,6 @@ async function loadNeighborTimeSeries() {
return;
}
// --- FIX #1: enforce chronological order (oldest → newest) ---
packets.sort((a, b) => (a.import_time_us || 0) - (b.import_time_us || 0));
// 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
@@ -1110,8 +1264,10 @@ document.addEventListener("DOMContentLoaded", async () => {
if (!map) initMap(); // init map early so neighbors can draw
await loadTrack();
await loadPackets();
initPacketPortFilter();
await loadTelemetryCharts();
await loadNeighborTimeSeries();
await loadPacketHistogram();
ensureMapVisible();
setTimeout(ensureMapVisible, 1000);
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>
{% endblock %}
+22 -8
View File
@@ -2,23 +2,32 @@
{% block css %}
<style>
html, body {
overflow-x: auto !important;
}
table {
width: 80%;
/* FIX: allow table to keep natural width so scrolling works */
width: max-content;
min-width: 100%;
border-collapse: collapse;
margin: 1em auto;
}
/* Ensure table centered visually */
/* Desktop scroll wrapper */
#node-list {
display: flex;
justify-content: center;
width: 100%;
overflow-x: auto; /* allows horizontal scroll */
overflow-y: hidden;
/* !!! removed display:flex because it prevents scrolling */
}
#node-list table {
margin-left: auto;
margin-right: auto;
width: max-content; /* table keeps its natural width */
min-width: 100%; /* won't shrink smaller than viewport */
}
th, td {
padding: 10px;
border: 1px solid #333;
@@ -134,16 +143,20 @@ select, .export-btn, .search-box, .clear-btn {
/* --------------------------------------------- */
@media (max-width: 768px) {
/* Hide desktop table */
/* Hide desktop view */
#node-list table {
display: none;
}
/* Show mobile card list */
/* Show mobile cards */
#mobile-node-list {
display: block !important;
width: 100%;
padding: 0 10px;
/* If you want horizontal swiping, uncomment:
overflow-x: auto;
white-space: nowrap; */
}
.node-card {
@@ -177,6 +190,7 @@ select, .export-btn, .search-box, .clear-btn {
font-size: 1.4em;
}
}
</style>
{% endblock %}
+120 -73
View File
@@ -380,103 +380,150 @@ document.addEventListener("DOMContentLoaded", async () => {
}
/* ---------------------------------------------
Load packets_seen
----------------------------------------------*/
const seenRes = await fetch(`/api/packets_seen/${packetId}`);
const seenData = await seenRes.json();
const seenList = seenData.seen ?? [];
Load packets_seen
----------------------------------------------*/
const seenRes = await fetch(`/api/packets_seen/${packetId}`);
const seenData = await seenRes.json();
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){
seenContainer.classList.remove("d-none");
seenCountSpan.textContent = `(${seenSorted.length})`;
}
if (seenSorted.length){
seenContainer.classList.remove("d-none");
seenCountSpan.textContent = `(${seenSorted.length})`;
}
/* ---------------------------------------------
Render gateway table + map markers
----------------------------------------------*/
seenTableBody.innerHTML = seenSorted.map(s=>{
const node = nodeLookup[s.node_id];
const label = node?.long_name || s.node_id;
/* ---------------------------------------------
GROUP BY HOP COUNT
----------------------------------------------*/
const hopGroups = {};
const timeStr = s.import_time_us
? new Date(s.import_time_us/1000).toLocaleTimeString()
: "—";
seenSorted.forEach(s => {
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;
const rlon = node.last_long/1e7;
allBounds.push([rlat, rlon]);
/* ---------------------------------------------
Render grouped gateway table + map markers
----------------------------------------------*/
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 color = hopColor(hopValue);
const hopLabel =
hopKey === "0"
? (packetTranslations.direct || "Direct (0 hops)")
: `${hopKey} ${packetTranslations.hops || "hops"}`;
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);
">${hopValue}</div>`,
className: "",
iconSize:[24,24],
iconAnchor:[12,12]
})
}).addTo(map);
const rows = hopGroups[hopKey].map(s => {
const node = nodeLookup[s.node_id];
const label = node?.long_name || s.node_id;
let distKm = null, distMi = null;
if (srcLat && srcLon){
distKm = haversine(srcLat, srcLon, rlat, rlon);
distMi = distKm * 0.621371;
}
const timeStr = s.import_time_us
? new Date(s.import_time_us/1000).toLocaleTimeString()
: "—";
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">
<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>
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>
SNR: ${s.rx_snr ?? "—"}<br><br>
<b data-translate-lang="hops">${packetTranslations.hops || "Hops"}</b>: ${hopValue}<br>
<b data-translate-lang="distance">${packetTranslations.distance || "Distance"}:</b><br>
${
distKm
? `${distKm.toFixed(2)} km (${distMi.toFixed(2)} mi)`
: "—"
}
<b data-translate-lang="hops">Hops</b>: ${hopKey}
</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 `
<tr>
<td><a href="/node/${s.node_id}">${label}</a></td>
<td>${s.rx_rssi ?? "—"}</td>
<td>${s.rx_snr ?? "—"}</td>
<td>${s.hop_start ?? "—"}${s.hop_limit ?? "—"}</td>
<td>${s.channel ?? "—"}</td>
<td>${timeStr}</td>
</tr>`;
<td colspan="6"
style="
background:#1f2327;
font-weight:700;
color:#9ecbff;
border-top:1px solid #444;
padding:8px 12px;
">
🔁 ${hopLabel} (${hopGroups[hopKey].length})
</td>
</tr>
${rows}
`;
}).join("");
/* ---------------------------------------------
Fit map around all markers
----------------------------------------------*/
+121 -189
View File
@@ -39,7 +39,8 @@
}
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:hover { background-color: #555; cursor: pointer; }
@@ -50,8 +51,15 @@
.node-link:hover { text-decoration: underline; }
.good-x { color: #81ff81; font-weight: bold; }
.ok-x { color: #e8e86d; font-weight: bold; }
.bad-x { color: #ff6464; font-weight: bold; }
.ok-x { color: #e8e86d; font-weight: bold; }
.bad-x { color: #ff6464; font-weight: bold; }
.pagination {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 15px;
}
</style>
{% endblock %}
@@ -63,47 +71,42 @@
<div class="filter-bar">
<div>
<label for="channelFilter" data-translate-lang="channel">Channel:</label>
<select id="channelFilter" class="form-select form-select-sm" style="width:auto;"></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;">
<label data-translate-lang="channel">Channel:</label>
<select id="channelFilter"></select>
</div>
</div>
<!-- ⭐ ADDED NODE COUNT ⭐ -->
<div id="count-container" style="margin-bottom:10px; font-weight:bold;">
<div style="margin-bottom:10px;font-weight:bold;">
<span data-translate-lang="showing_nodes">Showing</span>
<span id="node-count">0</span>
<span data-translate-lang="nodes_suffix">nodes</span>
</div>
<div class="table-responsive">
<table id="nodesTable">
<thead>
<tr>
<th data-translate-lang="long_name">Long Name</th>
<th data-translate-lang="short_name">Short Name</th>
<th data-translate-lang="channel">Channel</th>
<th data-translate-lang="packets_sent">Sent (24h)</th>
<th data-translate-lang="times_seen">Seen (24h)</th>
<th data-translate-lang="avg_gateways">Avg Gateways</th>
</tr>
</thead>
<tbody></tbody>
</table>
<table id="nodesTable">
<thead>
<tr>
<th data-translate-lang="long_name">Long Name</th>
<th data-translate-lang="short_name">Short Name</th>
<th data-translate-lang="channel">Channel</th>
<th data-translate-lang="packets_sent">Sent (24h)</th>
<th data-translate-lang="times_seen">Seen (24h)</th>
<th data-translate-lang="avg_gateways">Avg Gateways</th>
</tr>
</thead>
<tbody></tbody>
</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>
<script>
/* ======================================================
TOP PAGE TRANSLATION (isolated from base)
TRANSLATIONS
====================================================== */
let topTranslations = {};
@@ -111,198 +114,127 @@ function applyTranslationsTop(dict, root=document) {
root.querySelectorAll("[data-translate-lang]").forEach(el => {
const key = el.dataset.translateLang;
if (!dict[key]) return;
// input placeholder support
if (el.tagName === "INPUT" && el.placeholder !== undefined) {
el.placeholder = dict[key];
} else {
el.textContent = dict[key];
}
el.textContent = dict[key];
});
}
async function loadTranslationsTop() {
try {
const cfg = await window._siteConfigPromise;
const lang = cfg?.site?.language || "en";
const res = await fetch(`/api/lang?lang=${lang}&section=top`);
topTranslations = await res.json();
applyTranslationsTop(topTranslations);
} catch (err) {
console.error("TOP translation load failed:", err);
}
const cfg = await window._siteConfigPromise;
const lang = cfg?.site?.language || "en";
const res = await fetch(`/api/lang?lang=${lang}&section=top`);
topTranslations = await res.json();
applyTranslationsTop(topTranslations);
}
/* ======================================================
PAGE LOGIC
CONFIG
====================================================== */
let allNodes = [];
async function loadChannels() {
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 };
}
}
const PAGE_SIZE = 20;
let currentPage = 0;
let totalRows = 0;
/* ======================================================
HELPERS
====================================================== */
function avgClass(v) {
if (v >= 10) return "good-x";
if (v >= 2) return "ok-x";
if (v >= 2) return "ok-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() {
const tbody = document.querySelector("#nodesTable tbody");
tbody.innerHTML = "";
const channel = document.getElementById("channelFilter").value;
const searchText = document.getElementById("nodeSearch").value.trim().toLowerCase();
const offset = currentPage * PAGE_SIZE;
// Filter by channel
let filtered = allNodes.filter(n => n.channel === channel);
const url = new URL("/api/stats/top", window.location.origin);
url.searchParams.set("limit", PAGE_SIZE);
url.searchParams.set("offset", offset);
if (channel) url.searchParams.set("channel", channel);
// Filter by search
if (searchText !== "") {
filtered = filtered.filter(n =>
(n.long_name && n.long_name.toLowerCase().includes(searchText)) ||
(n.short_name && n.short_name.toLowerCase().includes(searchText)) ||
String(n.node_id).includes(searchText)
);
const res = await fetch(url);
const data = await res.json();
totalRows = data.total || 0;
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 rowRefs = filtered.map(n => {
const tr = document.createElement("tr");
tr.addEventListener("click", () => window.location.href = `/node/${n.node_id}`);
const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE));
const tdLong = document.createElement("td");
const a = document.createElement("a");
a.href = `/node/${n.node_id}`;
a.textContent = n.long_name || n.node_id;
a.className = "node-link";
a.addEventListener("click", e => e.stopPropagation());
tdLong.appendChild(a);
document.getElementById("node-count").textContent = totalRows;
document.getElementById("pageInfo").textContent =
`Page ${currentPage + 1} / ${totalPages}`;
const tdShort = document.createElement("td");
tdShort.textContent = n.short_name || "";
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;
document.getElementById("prevPage").disabled = currentPage === 0;
document.getElementById("nextPage").disabled = currentPage >= totalPages - 1;
}
/* ======================================================
INITIALIZE PAGE
INIT
====================================================== */
document.addEventListener("DOMContentLoaded", async () => {
await loadTranslationsTop(); // ⭐ MUST run first
await loadNodes();
await loadTranslationsTop();
await loadChannels();
await renderTable();
document.getElementById("channelFilter").value = "LongFast";
document.getElementById("nodeSearch").addEventListener("input", renderTable);
channelFilter.onchange = () => {
currentPage = 0;
renderTable();
};
renderTable();
prevPage.onclick = () => {
if (currentPage > 0) {
currentPage--;
renderTable();
}
};
nextPage.onclick = () => {
currentPage++;
renderTable();
};
});
</script>
+187 -1
View File
@@ -134,7 +134,7 @@ async def api_packets(request):
# --- Parse limit ---
try:
limit = min(max(int(limit_str), 1), 100)
limit = min(max(int(limit_str), 1), 1000)
except ValueError:
limit = 50
@@ -724,3 +724,189 @@ async def api_packets_seen(request):
{"error": "Internal server error"},
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,
})
+20
View File
@@ -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.
+274
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
Jinja2>=3.1,<4
+123
View File
@@ -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>
+99
View File
@@ -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 %}