1 Commits

Author SHA1 Message Date
pablorevilla-meshtastic a676db2921 Initial Jinja ESP32 collector 2026-03-18 14:28:04 -07:00
8 changed files with 688 additions and 406 deletions
+7 -1
View File
@@ -82,9 +82,12 @@ async def process_envelope(topic, env):
async with mqtt_database.async_session() as session: async with mqtt_database.async_session() as session:
# --- Packet insert with ON CONFLICT DO NOTHING # --- Packet insert with ON CONFLICT DO NOTHING
result = await session.execute(select(Packet).where(Packet.id == env.packet.id)) result = await session.execute(select(Packet).where(Packet.id == env.packet.id))
# FIXME: Not Used
# new_packet = False
packet = result.scalar_one_or_none() packet = result.scalar_one_or_none()
if not packet: if not packet:
# FIXME: Not Used
# new_packet = True
now = datetime.datetime.now(datetime.UTC) now = datetime.datetime.now(datetime.UTC)
now_us = int(now.timestamp() * 1_000_000) now_us = int(now.timestamp() * 1_000_000)
stmt = ( stmt = (
@@ -235,3 +238,6 @@ async def process_envelope(topic, env):
await session.commit() await session.commit()
# if new_packet:
# await packet.awaitable_attrs.to_node
# await packet.awaitable_attrs.from_node
+66 -166
View File
@@ -258,17 +258,15 @@
<!-- Packets --> <!-- Packets -->
<table class="packet-table"> <table class="packet-table">
<thead> <thead>
<tr> <tr>
<th data-translate-lang="time">Time</th> <th data-translate-lang="time">Time</th>
<th data-translate-lang="packet_id">Packet ID</th> <th data-translate-lang="packet_id">Packet ID</th>
<th data-translate-lang="from">From</th> <th data-translate-lang="from">From</th>
<th data-translate-lang="to">To</th> <th data-translate-lang="to">To</th>
<th data-translate-lang="port">Port</th> <th data-translate-lang="port">Port</th>
<th data-translate-lang="size">Size</th> </tr>
</tr> </thead>
</thead>
<tbody id="packet_list"></tbody> <tbody id="packet_list"></tbody>
</table> </table>
@@ -610,16 +608,10 @@ function addMarker(id, lat, lon, color = "red", node = null) {
m.bringToFront(); m.bringToFront();
} }
async function drawNeighbors(src, nids) { async function drawNeighbors(src, nids){
if (!map) return; if (!map) return;
const srcPos = nodePositions[src];
// Ensure source node position exists if (!srcPos) return;
const srcNode = await fetchNodeFromApi(src);
if (!srcNode || !srcNode.last_lat || !srcNode.last_long) return;
const srcLat = srcNode.last_lat / 1e7;
const srcLon = srcNode.last_long / 1e7;
nodePositions[src] = [srcLat, srcLon];
for (const nid of nids) { for (const nid of nids) {
const neighbor = await fetchNodeFromApi(nid); const neighbor = await fetchNodeFromApi(nid);
@@ -628,22 +620,13 @@ async function drawNeighbors(src, nids) {
const lat = neighbor.last_lat / 1e7; const lat = neighbor.last_lat / 1e7;
const lon = neighbor.last_long / 1e7; const lon = neighbor.last_long / 1e7;
nodePositions[nid] = [lat, lon];
// Marker
addMarker(nid, lat, lon, "blue", neighbor); addMarker(nid, lat, lon, "blue", neighbor);
// Link line const dstPos = [lat, lon];
L.polyline( L.polyline([srcPos, dstPos], { color:'gray', weight:1 }).addTo(map);
[[srcLat, srcLon], [lat, lon]],
{ color: "gray", weight: 1 }
).addTo(map);
} }
ensureMapVisible();
} }
function ensureMapVisible(){ function ensureMapVisible(){
if (!map) return; if (!map) return;
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -775,11 +758,8 @@ async function loadPackets(filters = {}) {
const packets = data.packets || []; const packets = data.packets || [];
currentPacketRows = packets; currentPacketRows = packets;
for (const pkt of packets.reverse()) {
// ================================ for (const pkt of (data.packets || []).reverse()) {
// TABLE ROW
// ================================
const safePayload = (pkt.payload || "") const safePayload = (pkt.payload || "")
.replace(/[<>]/g, m => (m === "<" ? "&lt;" : "&gt;")); .replace(/[<>]/g, m => (m === "<" ? "&lt;" : "&gt;"));
@@ -808,30 +788,25 @@ async function loadPackets(filters = {}) {
` <a class="inline-link" href="/graph/traceroute/${traceId}" target="_blank">⮕</a>`; ` <a class="inline-link" href="/graph/traceroute/${traceId}" target="_blank">⮕</a>`;
} }
const sizeBytes = packetSizeBytes(pkt);
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> <td><span class="toggle-btn">▶</span>
<a href="/packet/${pkt.id}" style="text-decoration:underline; color:inherit;"> <a href="/packet/${pkt.id}" style="text-decoration:underline; color:inherit;">
${pkt.id} ${pkt.id}
</a> </a>
</td> </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>
<td>${sizeBytes.toLocaleString()} B</td> </tr>
</tr> <tr class="payload-row">
<tr class="payload-row"> <td colspan="5" class="payload-cell">${safePayload}</td>
<td colspan="6" class="payload-cell">${safePayload}</td> </tr>`);
</tr>`);
} }
} }
/* ====================================================== /* ======================================================
TELEMETRY CHARTS (portnum=67) TELEMETRY CHARTS (portnum=67)
====================================================== */ ====================================================== */
@@ -994,69 +969,45 @@ async function loadTelemetryCharts(){
}); });
} }
async function loadLatestNeighborIds() {
const url = new URL("/api/packets", window.location.origin);
url.searchParams.set("from_node_id", fromNodeId);
url.searchParams.set("portnum", 71);
url.searchParams.set("limit", 1); // ✅ ONLY the latest packet
const res = await fetch(url);
if (!res.ok) return [];
const data = await res.json();
const pkt = data.packets?.[0];
if (!pkt || !pkt.payload) return [];
const ids = [];
const re = /neighbors\s*\{([^}]+)\}/g;
let m;
while ((m = re.exec(pkt.payload)) !== null) {
const id = m[1].match(/node_id:\s*(\d+)/);
if (id) ids.push(parseInt(id[1], 10));
}
return ids;
}
/* ====================================================== /* ======================================================
NEIGHBOR CHART (portnum=71) NEIGHBOR CHART (portnum=71)
====================================================== */ ====================================================== */
async function loadNeighborTimeSeries() { async function loadNeighborTimeSeries() {
const container = document.getElementById("neighbor_chart_container");
const chartEl = document.getElementById("chart_neighbors");
const url = `/api/packets?portnum=71&from_node_id=${fromNodeId}&limit=500`; const url = `/api/packets?portnum=71&from_node_id=${fromNodeId}&limit=500`;
const res = await fetch(url); const res = await fetch(url);
if (!res.ok) { if (!res.ok) {
container.style.display = "none"; document.getElementById("neighbor_chart_container").style.display = "none";
return; return;
} }
const data = await res.json(); const data = await res.json();
const packets = data.packets || []; let packets = data.packets || [];
if (!packets.length) { if (!packets.length) {
container.style.display = "none"; document.getElementById("neighbor_chart_container").style.display = "none";
return; return;
} }
// Sort packets chronologically (microseconds)
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));
const neighborHistory = {}; // node_id -> { name, times[], snr[] } // neighborHistory = { node_id: { name, snr:[...], times:[...] } }
const neighborHistory = {};
for (const pkt of packets) { for (const pkt of packets) {
if (!pkt.import_time_us || !pkt.payload) continue; if (!pkt.import_time_us || !pkt.payload) continue;
const ts = pkt.import_time_us; // KEEP NUMERIC TIMESTAMP const ts = new Date(pkt.import_time_us / 1000).toLocaleString([], {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
// Extract neighbor blocks
const blockRe = /neighbors\s*\{([^}]+)\}/g; const blockRe = /neighbors\s*\{([^}]+)\}/g;
let m; let m;
while ((m = blockRe.exec(pkt.payload)) !== null) { while ((m = blockRe.exec(pkt.payload)) !== null) {
const block = m[1]; const block = m[1];
@@ -1068,14 +1019,9 @@ async function loadNeighborTimeSeries() {
const nid = parseInt(idMatch[1], 10); const nid = parseInt(idMatch[1], 10);
const snr = parseFloat(snrMatch[1]); const snr = parseFloat(snrMatch[1]);
// Fetch neighbor metadata once
const neighbor = await fetchNodeFromApi(nid);
if (!neighborHistory[nid]) { if (!neighborHistory[nid]) {
neighborHistory[nid] = { neighborHistory[nid] = {
name: neighbor?.short_name || name: nodeMap[nid] || `Node ${nid}`,
neighbor?.long_name ||
`Node ${nid}`,
times: [], times: [],
snr: [] snr: []
}; };
@@ -1086,59 +1032,45 @@ async function loadNeighborTimeSeries() {
} }
} }
// Collect ALL timestamps across neighbors const chart = echarts.init(document.getElementById("chart_neighbors"));
const allTimes = new Set();
Object.values(neighborHistory).forEach(entry => {
entry.times.forEach(t => allTimes.add(t));
});
// Sort timestamps numerically
const xTimes = Array.from(allTimes).sort((a, b) => a - b);
const legend = []; const legend = [];
const series = []; const series = [];
for (const entry of Object.values(neighborHistory)) { for (const [nid, entry] of Object.entries(neighborHistory)) {
legend.push(entry.name); legend.push(entry.name);
series.push({ series.push({
name: entry.name, name: entry.name,
type: "line", type: "line",
smooth: true, smooth: true,
connectNulls: true, connectNulls: true, // --- FIX #2: connect dots even if missing ---
showSymbol: false, showSymbol: false,
data: xTimes.map(t => { data: entry.snr,
const idx = entry.times.indexOf(t);
return idx >= 0 ? entry.snr[idx] : null;
})
}); });
} }
const chart = echarts.init(chartEl); // Collect all timestamps from all neighbors
const allTimesSet = new Set();
for (const entry of Object.values(neighborHistory)) {
for (const t of entry.times) {
allTimesSet.add(t);
}
}
// Convert to array and sort chronologically
const sampleTimes = Array.from(allTimesSet).sort((a, b) => {
return new Date(a) - new Date(b);
});
chart.setOption({ chart.setOption({
tooltip: { tooltip: { trigger: "axis" },
trigger: "axis", legend: { data: legend, textStyle: { color: "#ccc" } },
axisPointer: { type: "line" }
},
legend: {
data: legend,
textStyle: { color: "#ccc" }
},
xAxis: { xAxis: {
type: "category", type: "category",
data: xTimes, data: sampleTimes,
axisLabel: { axisLabel: { color: "#ccc" }
color: "#ccc",
formatter: value =>
new Date(value / 1000).toLocaleString([], {
year: "2-digit",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
})
}
}, },
yAxis: { yAxis: {
type: "value", type: "value",
@@ -1152,7 +1084,6 @@ async function loadNeighborTimeSeries() {
} }
async function loadPacketHistogram() { async function loadPacketHistogram() {
const DAYS = 7; const DAYS = 7;
const now = new Date(); const now = new Date();
@@ -1326,29 +1257,17 @@ document.addEventListener("click", e => {
====================================================== */ ====================================================== */
document.addEventListener("DOMContentLoaded", async () => { document.addEventListener("DOMContentLoaded", async () => {
await loadTranslationsNode(); await loadTranslationsNode(); // translations first
requestAnimationFrame(async () => { requestAnimationFrame(async () => {
await loadNodeInfo(); await loadNodeInfo(); // single-node fetch
if (!map) initMap(); // init map early so neighbors can draw
// ✅ MAP MUST EXIST FIRST
if (!map) initMap();
// ✅ DRAW LATEST NEIGHBORS ONCE
const neighborIds = await loadLatestNeighborIds();
if (neighborIds.length) {
await drawNeighbors(fromNodeId, neighborIds);
}
// ⚠️ Track may add to map, but must not hide it
await loadTrack(); await loadTrack();
await loadPackets(); await loadPackets();
initPacketPortFilter(); initPacketPortFilter();
await loadTelemetryCharts(); await loadTelemetryCharts();
await loadNeighborTimeSeries(); await loadNeighborTimeSeries();
await loadPacketHistogram(); await loadPacketHistogram();
ensureMapVisible(); ensureMapVisible();
setTimeout(ensureMapVisible, 1000); setTimeout(ensureMapVisible, 1000);
window.addEventListener("resize", ensureMapVisible); window.addEventListener("resize", ensureMapVisible);
@@ -1356,25 +1275,6 @@ document.addEventListener("DOMContentLoaded", async () => {
}); });
}); });
function packetSizeBytes(pkt) {
if (!pkt) return 0;
// Prefer raw payload length
if (pkt.payload) {
return new TextEncoder().encode(pkt.payload).length;
}
// Fallbacks (if you later add protobuf/base64)
if (pkt.raw_payload) {
return atob(pkt.raw_payload).length;
}
return 0;
}
async function loadNodeStats(nodeId) { async function loadNodeStats(nodeId) {
try { try {
const res = await fetch( const res = await fetch(
+98 -239
View File
@@ -26,6 +26,8 @@ table {
width: max-content; /* table keeps its natural width */ width: max-content; /* table keeps its natural width */
min-width: 100%; /* won't shrink smaller than viewport */ 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;
@@ -103,21 +105,6 @@ select, .export-btn, .search-box, .clear-btn {
font-weight: bold; font-weight: bold;
color: white; color: white;
} }
.node-status {
margin-left: 10px;
padding: 2px 8px;
border-radius: 12px;
border: 1px solid #2a6a8a;
background: #0d2a3a;
color: #9fd4ff;
font-size: 0.9em;
display: inline-block;
opacity: 0;
transition: opacity 0.15s ease-in-out;
}
.node-status.active {
opacity: 1;
}
/* Favorite stars */ /* Favorite stars */
.favorite-star { .favorite-star {
@@ -203,6 +190,7 @@ select, .export-btn, .search-box, .clear-btn {
font-size: 1.4em; font-size: 1.4em;
} }
} }
</style> </style>
{% endblock %} {% endblock %}
@@ -214,7 +202,7 @@ select, .export-btn, .search-box, .clear-btn {
id="search-box" id="search-box"
class="search-box" class="search-box"
data-translate-lang="search_placeholder" data-translate-lang="search_placeholder"
placeholder="Search by name or ID or HEX ID..." placeholder="Search by name or ID..."
/> />
<select id="role-filter"> <select id="role-filter">
@@ -250,7 +238,6 @@ select, .export-btn, .search-box, .clear-btn {
<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>
<span id="node-status" class="node-status" aria-live="polite"></span>
</div> </div>
<!-- Desktop table --> <!-- Desktop table -->
@@ -321,11 +308,6 @@ let allNodes = [];
let sortColumn = "short_name"; let sortColumn = "short_name";
let sortAsc = true; let sortAsc = true;
let showOnlyFavorites = false; let showOnlyFavorites = false;
let favoritesSet = new Set();
let isBusy = false;
let statusHideTimer = null;
let statusShownAt = 0;
const minStatusMs = 300;
const headers = document.querySelectorAll("thead th"); const headers = document.querySelectorAll("thead th");
const keyMap = [ const keyMap = [
@@ -333,51 +315,28 @@ const keyMap = [
"last_lat","last_long","channel","last_seen_us" "last_lat","last_long","channel","last_seen_us"
]; ];
function debounce(fn, delay = 250) { function getFavorites() {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), delay);
};
}
function nextFrame() {
return new Promise(resolve => requestAnimationFrame(() => resolve()));
}
function loadFavorites() {
const favorites = localStorage.getItem('nodelist_favorites'); const favorites = localStorage.getItem('nodelist_favorites');
if (!favorites) { return favorites ? JSON.parse(favorites) : [];
favoritesSet = new Set();
return;
}
try {
const parsed = JSON.parse(favorites);
favoritesSet = new Set(Array.isArray(parsed) ? parsed : []);
} catch (err) {
console.warn("Failed to parse favorites, resetting.", err);
favoritesSet = new Set();
}
} }
function saveFavorites() { function saveFavorites(favs) {
localStorage.setItem('nodelist_favorites', JSON.stringify([...favoritesSet])); localStorage.setItem('nodelist_favorites', JSON.stringify(favs));
} }
function toggleFavorite(nodeId) { function toggleFavorite(nodeId) {
if (favoritesSet.has(nodeId)) { let favs = getFavorites();
favoritesSet.delete(nodeId); const idx = favs.indexOf(nodeId);
} else { if (idx >= 0) favs.splice(idx, 1);
favoritesSet.add(nodeId); else favs.push(nodeId);
} saveFavorites(favs);
saveFavorites();
} }
function isFavorite(nodeId) { function isFavorite(nodeId) {
return favoritesSet.has(nodeId); return getFavorites().includes(nodeId);
} }
function timeAgoFromMs(msTimestamp) { function timeAgo(usTimestamp) {
if (!msTimestamp) return "N/A"; if (!usTimestamp) return "N/A";
const diff = Date.now() - msTimestamp; const ms = usTimestamp / 1000;
const diff = Date.now() - ms;
if (diff < 60000) return "just now"; if (diff < 60000) return "just now";
const mins = Math.floor(diff / 60000); const mins = Math.floor(diff / 60000);
@@ -394,7 +353,6 @@ function timeAgoFromMs(msTimestamp) {
document.addEventListener("DOMContentLoaded", async function() { document.addEventListener("DOMContentLoaded", async function() {
await loadTranslationsNodelist(); await loadTranslationsNodelist();
loadFavorites();
const tbody = document.getElementById("node-table-body"); const tbody = document.getElementById("node-table-body");
const mobileList = document.getElementById("mobile-node-list"); const mobileList = document.getElementById("mobile-node-list");
@@ -405,82 +363,52 @@ document.addEventListener("DOMContentLoaded", async function() {
const firmwareFilter = document.getElementById("firmware-filter"); const firmwareFilter = document.getElementById("firmware-filter");
const searchBox = document.getElementById("search-box"); const searchBox = document.getElementById("search-box");
const countSpan = document.getElementById("node-count"); const countSpan = document.getElementById("node-count");
const statusSpan = document.getElementById("node-status");
const exportBtn = document.getElementById("export-btn"); const exportBtn = document.getElementById("export-btn");
const clearBtn = document.getElementById("clear-btn"); const clearBtn = document.getElementById("clear-btn");
const favoritesBtn = document.getElementById("favorites-btn"); const favoritesBtn = document.getElementById("favorites-btn");
let lastIsMobile = (window.innerWidth <= 768);
try { try {
setStatus("Loading nodes…");
await nextFrame();
const res = await fetch("/api/nodes?days_active=3"); const res = await fetch("/api/nodes?days_active=3");
if (!res.ok) throw new Error("Failed to fetch nodes"); if (!res.ok) throw new Error("Failed to fetch nodes");
const data = await res.json(); const data = await res.json();
allNodes = data.nodes.map(n => ({
allNodes = data.nodes.map(n => { ...n,
const firmware = n.firmware || n.firmware_version || ""; firmware: n.firmware || n.firmware_version || ""
const last_seen_us = n.last_seen_us || 0; }));
const last_seen_ms = last_seen_us ? (last_seen_us / 1000) : 0;
return {
...n,
firmware,
last_seen_us,
last_seen_ms,
_search: [
n.node_id,
n.id,
n.long_name,
n.short_name
]
.filter(Boolean)
.join(" ")
.toLowerCase()
};
});
populateFilters(allNodes); populateFilters(allNodes);
applyFilters(); // ensures initial sort + render uses same path renderTable(allNodes);
updateSortIcons(); updateSortIcons();
setStatus("");
} catch (err) { } catch (err) {
tbody.innerHTML = `<tr> tbody.innerHTML = `<tr>
<td colspan="10" style="text-align:center; color:red;"> <td colspan="10" style="text-align:center; color:red;">
${nodelistTranslations.error_loading_nodes || "Error loading nodes"} ${nodelistTranslations.error_loading_nodes || "Error loading nodes"}
</td></tr>`; </td></tr>`;
setStatus("");
return;
} }
roleFilter.addEventListener("change", applyFilters); roleFilter.addEventListener("change", applyFilters);
channelFilter.addEventListener("change", applyFilters); channelFilter.addEventListener("change", applyFilters);
hwFilter.addEventListener("change", applyFilters); hwFilter.addEventListener("change", applyFilters);
firmwareFilter.addEventListener("change", applyFilters); firmwareFilter.addEventListener("change", applyFilters);
searchBox.addEventListener("input", applyFilters);
// Debounced only for search typing
searchBox.addEventListener("input", debounce(applyFilters, 250));
exportBtn.addEventListener("click", exportToCSV); exportBtn.addEventListener("click", exportToCSV);
clearBtn.addEventListener("click", clearFilters); clearBtn.addEventListener("click", clearFilters);
favoritesBtn.addEventListener("click", toggleFavoritesFilter); favoritesBtn.addEventListener("click", toggleFavoritesFilter);
// Favorite star click handler (delegated) // Favorite star click handler
document.addEventListener("click", e => { document.addEventListener("click", e => {
if (e.target.classList.contains('favorite-star')) { if (e.target.classList.contains('favorite-star')) {
const nodeId = parseInt(e.target.dataset.nodeId, 10); const nodeId = parseInt(e.target.dataset.nodeId);
const fav = isFavorite(nodeId); const isFav = isFavorite(nodeId);
if (fav) { if (isFav) {
e.target.classList.remove("active"); e.target.classList.remove("active");
e.target.textContent = "☆"; e.target.textContent = "☆";
} else { } else {
e.target.classList.add("active"); e.target.classList.add("active");
e.target.textContent = "★"; e.target.textContent = "★";
} }
toggleFavorite(nodeId); toggleFavorite(nodeId);
applyFilters(); applyFilters();
} }
@@ -488,26 +416,13 @@ document.addEventListener("DOMContentLoaded", async function() {
headers.forEach((th, index) => { headers.forEach((th, index) => {
th.addEventListener("click", () => { th.addEventListener("click", () => {
const key = keyMap[index]; let key = keyMap[index];
// ignore clicks on the "favorite" (last header) which has no sort key
if (!key) return;
sortAsc = (sortColumn === key) ? !sortAsc : true; sortAsc = (sortColumn === key) ? !sortAsc : true;
sortColumn = key; sortColumn = key;
applyFilters(); applyFilters();
}); });
}); });
// Re-render on breakpoint change so mobile/desktop view switches instantly
window.addEventListener("resize", debounce(() => {
const isMobile = (window.innerWidth <= 768);
if (isMobile !== lastIsMobile) {
lastIsMobile = isMobile;
applyFilters();
}
}, 150));
function populateFilters(nodes) { function populateFilters(nodes) {
const roles = new Set(), channels = new Set(), hws = new Set(), fws = new Set(); const roles = new Set(), channels = new Set(), hws = new Set(), fws = new Set();
@@ -542,9 +457,7 @@ document.addEventListener("DOMContentLoaded", async function() {
applyFilters(); applyFilters();
} }
async function applyFilters() { function applyFilters() {
setStatus("Updating…");
await nextFrame();
const searchTerm = searchBox.value.trim().toLowerCase(); const searchTerm = searchBox.value.trim().toLowerCase();
let filtered = allNodes.filter(n => { let filtered = allNodes.filter(n => {
@@ -553,116 +466,102 @@ document.addEventListener("DOMContentLoaded", async function() {
const hwMatch = !hwFilter.value || n.hw_model === hwFilter.value; const hwMatch = !hwFilter.value || n.hw_model === hwFilter.value;
const fwMatch = !firmwareFilter.value || n.firmware === firmwareFilter.value; const fwMatch = !firmwareFilter.value || n.firmware === firmwareFilter.value;
const searchMatch = !searchTerm || n._search.includes(searchTerm); const searchMatch =
!searchTerm ||
(n.long_name && n.long_name.toLowerCase().includes(searchTerm)) ||
(n.short_name && n.short_name.toLowerCase().includes(searchTerm)) ||
n.node_id.toString().includes(searchTerm);
const favMatch = !showOnlyFavorites || isFavorite(n.node_id); const favMatch = !showOnlyFavorites || isFavorite(n.node_id);
return roleMatch && channelMatch && hwMatch && fwMatch && searchMatch && favMatch; return roleMatch && channelMatch && hwMatch && fwMatch && searchMatch && favMatch;
}); });
// IMPORTANT: Always sort the filtered subset to preserve expected behavior
filtered = sortNodes(filtered, sortColumn, sortAsc); filtered = sortNodes(filtered, sortColumn, sortAsc);
renderTable(filtered); renderTable(filtered);
updateSortIcons(); updateSortIcons();
setStatus("");
} }
function renderTable(nodes) { function renderTable(nodes) {
tbody.innerHTML = "";
mobileList.innerHTML = "";
const isMobile = window.innerWidth <= 768; const isMobile = window.innerWidth <= 768;
const shouldRenderTable = !isMobile;
if (shouldRenderTable) {
tbody.innerHTML = "";
} else {
mobileList.innerHTML = "";
}
const tableFrag = shouldRenderTable ? document.createDocumentFragment() : null;
const mobileFrag = shouldRenderTable ? null : document.createDocumentFragment();
if (!nodes.length) { if (!nodes.length) {
if (shouldRenderTable) { tbody.innerHTML = `<tr>
tbody.innerHTML = `<tr> <td colspan="10" style="text-align:center; color:white;">
<td colspan="10" style="text-align:center; color:white;">
${nodelistTranslations.no_nodes_found || "No nodes found"}
</td>
</tr>`;
} else {
mobileList.innerHTML = `<div style="text-align:center; color:white;">
${nodelistTranslations.no_nodes_found || "No nodes found"} ${nodelistTranslations.no_nodes_found || "No nodes found"}
</div>`; </td>
} </tr>`;
mobileList.innerHTML = `<div style="text-align:center; color:white;">No nodes found</div>`;
countSpan.textContent = 0; countSpan.textContent = 0;
return; return;
} }
nodes.forEach(node => { nodes.forEach(node => {
const fav = isFavorite(node.node_id); const isFav = isFavorite(node.node_id);
const star = fav ? "★" : "☆"; const star = isFav ? "★" : "☆";
if (shouldRenderTable) { // DESKTOP TABLE ROW
// DESKTOP TABLE ROW const row = document.createElement("tr");
const row = document.createElement("tr"); row.innerHTML = `
row.innerHTML = ` <td>${node.short_name || "N/A"}</td>
<td>${node.short_name || "N/A"}</td> <td><a href="/node/${node.node_id}">${node.long_name || "N/A"}</a></td>
<td><a href="/node/${node.node_id}">${node.long_name || "N/A"}</a></td> <td>${node.hw_model || "N/A"}</td>
<td>${node.hw_model || "N/A"}</td> <td>${node.firmware || "N/A"}</td>
<td>${node.firmware || "N/A"}</td> <td>${node.role || "N/A"}</td>
<td>${node.role || "N/A"}</td> <td>${node.last_lat ? (node.last_lat / 1e7).toFixed(7) : "N/A"}</td>
<td>${node.last_lat ? (node.last_lat / 1e7).toFixed(7) : "N/A"}</td> <td>${node.last_long ? (node.last_long / 1e7).toFixed(7) : "N/A"}</td>
<td>${node.last_long ? (node.last_long / 1e7).toFixed(7) : "N/A"}</td> <td>${node.channel || "N/A"}</td>
<td>${node.channel || "N/A"}</td> <td>${timeAgo(node.last_seen_us)}</td>
<td>${timeAgoFromMs(node.last_seen_ms)}</td> <td style="text-align:center;">
<td style="text-align:center;"> <span class="favorite-star ${isFav ? "active" : ""}" data-node-id="${node.node_id}">
<span class="favorite-star ${fav ? "active" : ""}" data-node-id="${node.node_id}"> ${star}
${star} </span>
</span> </td>
</td> `;
`; tbody.appendChild(row);
tableFrag.appendChild(row);
} else {
// MOBILE CARD VIEW
const card = document.createElement("div");
card.className = "node-card";
card.innerHTML = `
<div class="node-card-header">
<span>${node.short_name || node.long_name || node.node_id}</span>
<span class="favorite-star ${fav ? "active" : ""}" data-node-id="${node.node_id}">
${star}
</span>
</div>
<div class="node-card-field"><b>ID:</b> ${node.node_id}</div> // MOBILE CARD VIEW
<div class="node-card-field"><b>Name:</b> ${node.long_name || "N/A"}</div> const card = document.createElement("div");
<div class="node-card-field"><b>HW:</b> ${node.hw_model || "N/A"}</div> card.className = "node-card";
<div class="node-card-field"><b>Firmware:</b> ${node.firmware || "N/A"}</div> card.innerHTML = `
<div class="node-card-field"><b>Role:</b> ${node.role || "N/A"}</div> <div class="node-card-header">
<div class="node-card-field"><b>Location:</b> <span>${node.short_name || node.long_name || node.node_id}</span>
${node.last_lat ? (node.last_lat / 1e7).toFixed(5) : "N/A"}, <span class="favorite-star ${isFav ? "active" : ""}" data-node-id="${node.node_id}">
${node.last_long ? (node.last_long / 1e7).toFixed(5) : "N/A"} ${star}
</div> </span>
<div class="node-card-field"><b>Channel:</b> ${node.channel || "N/A"}</div> </div>
<div class="node-card-field"><b>Last Seen:</b> ${timeAgoFromMs(node.last_seen_ms)}</div>
<a href="/node/${node.node_id}" style="color:#9fd4ff; text-decoration:underline; margin-top:5px; display:block;"> <div class="node-card-field"><b>ID:</b> ${node.node_id}</div>
View Node → <div class="node-card-field"><b>Name:</b> ${node.long_name || "N/A"}</div>
</a> <div class="node-card-field"><b>HW:</b> ${node.hw_model || "N/A"}</div>
`; <div class="node-card-field"><b>Firmware:</b> ${node.firmware || "N/A"}</div>
mobileFrag.appendChild(card); <div class="node-card-field"><b>Role:</b> ${node.role || "N/A"}</div>
} <div class="node-card-field"><b>Location:</b>
${node.last_lat ? (node.last_lat / 1e7).toFixed(5) : "N/A"},
${node.last_long ? (node.last_long / 1e7).toFixed(5) : "N/A"}
</div>
<div class="node-card-field"><b>Channel:</b> ${node.channel}</div>
<div class="node-card-field"><b>Last Seen:</b> ${timeAgo(node.last_seen_us)}</div>
<a href="/node/${node.node_id}" style="color:#9fd4ff; text-decoration:underline; margin-top:5px; display:block;">
View Node →
</a>
`;
mobileList.appendChild(card);
}); });
// Toggle correct view // Toggle correct view
mobileList.style.display = isMobile ? "block" : "none"; if (isMobile) {
mobileList.style.display = "block";
} else {
mobileList.style.display = "none";
}
countSpan.textContent = nodes.length; countSpan.textContent = nodes.length;
if (shouldRenderTable) {
tbody.appendChild(tableFrag);
} else {
mobileList.appendChild(mobileFrag);
}
} }
function clearFilters() { function clearFilters() {
@@ -671,7 +570,6 @@ document.addEventListener("DOMContentLoaded", async function() {
hwFilter.value = ""; hwFilter.value = "";
firmwareFilter.value = ""; firmwareFilter.value = "";
searchBox.value = ""; searchBox.value = "";
sortColumn = "short_name"; sortColumn = "short_name";
sortAsc = true; sortAsc = true;
showOnlyFavorites = false; showOnlyFavorites = false;
@@ -679,7 +577,7 @@ document.addEventListener("DOMContentLoaded", async function() {
favoritesBtn.textContent = "⭐ Show Favorites"; favoritesBtn.textContent = "⭐ Show Favorites";
favoritesBtn.classList.remove("active"); favoritesBtn.classList.remove("active");
applyFilters(); renderTable(allNodes);
updateSortIcons(); updateSortIcons();
} }
@@ -715,10 +613,6 @@ document.addEventListener("DOMContentLoaded", async function() {
B = B || 0; B = B || 0;
} }
// Normalize strings for stable sorting
if (typeof A === "string") A = A.toLowerCase();
if (typeof B === "string") B = B.toLowerCase();
if (A < B) return asc ? -1 : 1; if (A < B) return asc ? -1 : 1;
if (A > B) return asc ? 1 : -1; if (A > B) return asc ? 1 : -1;
return 0; return 0;
@@ -733,41 +627,6 @@ document.addEventListener("DOMContentLoaded", async function() {
keyMap[i] === sortColumn ? (sortAsc ? "▲" : "▼") : ""; keyMap[i] === sortColumn ? (sortAsc ? "▲" : "▼") : "";
}); });
} }
function setStatus(message) {
if (!statusSpan) return;
if (statusHideTimer) {
clearTimeout(statusHideTimer);
statusHideTimer = null;
}
if (message) {
statusShownAt = Date.now();
console.log("[nodelist] status:", message);
statusSpan.textContent = message;
statusSpan.classList.add("active");
isBusy = true;
return;
}
const elapsed = Date.now() - statusShownAt;
const remaining = Math.max(0, minStatusMs - elapsed);
if (remaining > 0) {
statusHideTimer = setTimeout(() => {
statusHideTimer = null;
console.log("[nodelist] status: cleared");
statusSpan.textContent = "";
statusSpan.classList.remove("active");
isBusy = false;
}, remaining);
return;
}
console.log("[nodelist] status: cleared");
statusSpan.textContent = "";
statusSpan.classList.remove("active");
isBusy = false;
}
}); });
</script> </script>
{% endblock %} {% endblock %}
+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 %}