mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-07-07 02:11:17 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a676db2921 |
@@ -82,9 +82,12 @@ async def process_envelope(topic, env):
|
||||
async with mqtt_database.async_session() as session:
|
||||
# --- Packet insert with ON CONFLICT DO NOTHING
|
||||
result = await session.execute(select(Packet).where(Packet.id == env.packet.id))
|
||||
# FIXME: Not Used
|
||||
# new_packet = False
|
||||
packet = result.scalar_one_or_none()
|
||||
if not packet:
|
||||
|
||||
# FIXME: Not Used
|
||||
# new_packet = True
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
now_us = int(now.timestamp() * 1_000_000)
|
||||
stmt = (
|
||||
@@ -235,3 +238,6 @@ async def process_envelope(topic, env):
|
||||
|
||||
await session.commit()
|
||||
|
||||
# if new_packet:
|
||||
# await packet.awaitable_attrs.to_node
|
||||
# await packet.awaitable_attrs.from_node
|
||||
|
||||
+66
-166
@@ -258,17 +258,15 @@
|
||||
|
||||
<!-- Packets -->
|
||||
<table class="packet-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-translate-lang="time">Time</th>
|
||||
<th data-translate-lang="packet_id">Packet ID</th>
|
||||
<th data-translate-lang="from">From</th>
|
||||
<th data-translate-lang="to">To</th>
|
||||
<th data-translate-lang="port">Port</th>
|
||||
<th data-translate-lang="size">Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-translate-lang="time">Time</th>
|
||||
<th data-translate-lang="packet_id">Packet ID</th>
|
||||
<th data-translate-lang="from">From</th>
|
||||
<th data-translate-lang="to">To</th>
|
||||
<th data-translate-lang="port">Port</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="packet_list"></tbody>
|
||||
</table>
|
||||
|
||||
@@ -610,16 +608,10 @@ function addMarker(id, lat, lon, color = "red", node = null) {
|
||||
m.bringToFront();
|
||||
}
|
||||
|
||||
async function drawNeighbors(src, nids) {
|
||||
async function drawNeighbors(src, nids){
|
||||
if (!map) return;
|
||||
|
||||
// Ensure source node position exists
|
||||
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];
|
||||
const srcPos = nodePositions[src];
|
||||
if (!srcPos) return;
|
||||
|
||||
for (const nid of nids) {
|
||||
const neighbor = await fetchNodeFromApi(nid);
|
||||
@@ -628,22 +620,13 @@ async function drawNeighbors(src, nids) {
|
||||
const lat = neighbor.last_lat / 1e7;
|
||||
const lon = neighbor.last_long / 1e7;
|
||||
|
||||
nodePositions[nid] = [lat, lon];
|
||||
|
||||
// Marker
|
||||
addMarker(nid, lat, lon, "blue", neighbor);
|
||||
|
||||
// Link line
|
||||
L.polyline(
|
||||
[[srcLat, srcLon], [lat, lon]],
|
||||
{ color: "gray", weight: 1 }
|
||||
).addTo(map);
|
||||
const dstPos = [lat, lon];
|
||||
L.polyline([srcPos, dstPos], { color:'gray', weight:1 }).addTo(map);
|
||||
}
|
||||
|
||||
ensureMapVisible();
|
||||
}
|
||||
|
||||
|
||||
function ensureMapVisible(){
|
||||
if (!map) return;
|
||||
requestAnimationFrame(() => {
|
||||
@@ -775,11 +758,8 @@ async function loadPackets(filters = {}) {
|
||||
const packets = data.packets || [];
|
||||
currentPacketRows = packets;
|
||||
|
||||
for (const pkt of packets.reverse()) {
|
||||
|
||||
// ================================
|
||||
// TABLE ROW
|
||||
// ================================
|
||||
for (const pkt of (data.packets || []).reverse()) {
|
||||
const safePayload = (pkt.payload || "")
|
||||
.replace(/[<>]/g, m => (m === "<" ? "<" : ">"));
|
||||
|
||||
@@ -808,30 +788,25 @@ async function loadPackets(filters = {}) {
|
||||
` <a class="inline-link" href="/graph/traceroute/${traceId}" target="_blank">⮕</a>`;
|
||||
}
|
||||
|
||||
const sizeBytes = packetSizeBytes(pkt);
|
||||
|
||||
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>${fromCell}</td>
|
||||
<td>${toCell}</td>
|
||||
<td>${portLabel(pkt.portnum)}${inlineLinks}</td>
|
||||
<td>${sizeBytes.toLocaleString()} B</td>
|
||||
</tr>
|
||||
<tr class="payload-row">
|
||||
<td colspan="6" class="payload-cell">${safePayload}</td>
|
||||
</tr>`);
|
||||
|
||||
<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>${fromCell}</td>
|
||||
<td>${toCell}</td>
|
||||
<td>${portLabel(pkt.portnum)}${inlineLinks}</td>
|
||||
</tr>
|
||||
<tr class="payload-row">
|
||||
<td colspan="5" class="payload-cell">${safePayload}</td>
|
||||
</tr>`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ======================================================
|
||||
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)
|
||||
====================================================== */
|
||||
|
||||
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 res = await fetch(url);
|
||||
|
||||
if (!res.ok) {
|
||||
container.style.display = "none";
|
||||
document.getElementById("neighbor_chart_container").style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const packets = data.packets || [];
|
||||
let packets = data.packets || [];
|
||||
|
||||
if (!packets.length) {
|
||||
container.style.display = "none";
|
||||
document.getElementById("neighbor_chart_container").style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort packets chronologically (microseconds)
|
||||
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) {
|
||||
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;
|
||||
let m;
|
||||
|
||||
while ((m = blockRe.exec(pkt.payload)) !== null) {
|
||||
const block = m[1];
|
||||
|
||||
@@ -1068,14 +1019,9 @@ async function loadNeighborTimeSeries() {
|
||||
const nid = parseInt(idMatch[1], 10);
|
||||
const snr = parseFloat(snrMatch[1]);
|
||||
|
||||
// Fetch neighbor metadata once
|
||||
const neighbor = await fetchNodeFromApi(nid);
|
||||
|
||||
if (!neighborHistory[nid]) {
|
||||
neighborHistory[nid] = {
|
||||
name: neighbor?.short_name ||
|
||||
neighbor?.long_name ||
|
||||
`Node ${nid}`,
|
||||
name: nodeMap[nid] || `Node ${nid}`,
|
||||
times: [],
|
||||
snr: []
|
||||
};
|
||||
@@ -1086,59 +1032,45 @@ async function loadNeighborTimeSeries() {
|
||||
}
|
||||
}
|
||||
|
||||
// Collect ALL timestamps across 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 chart = echarts.init(document.getElementById("chart_neighbors"));
|
||||
|
||||
const legend = [];
|
||||
const series = [];
|
||||
|
||||
for (const entry of Object.values(neighborHistory)) {
|
||||
for (const [nid, entry] of Object.entries(neighborHistory)) {
|
||||
legend.push(entry.name);
|
||||
|
||||
series.push({
|
||||
name: entry.name,
|
||||
type: "line",
|
||||
smooth: true,
|
||||
connectNulls: true,
|
||||
connectNulls: true, // --- FIX #2: connect dots even if missing ---
|
||||
showSymbol: false,
|
||||
data: xTimes.map(t => {
|
||||
const idx = entry.times.indexOf(t);
|
||||
return idx >= 0 ? entry.snr[idx] : null;
|
||||
})
|
||||
data: entry.snr,
|
||||
});
|
||||
}
|
||||
|
||||
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({
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
axisPointer: { type: "line" }
|
||||
},
|
||||
legend: {
|
||||
data: legend,
|
||||
textStyle: { color: "#ccc" }
|
||||
},
|
||||
tooltip: { trigger: "axis" },
|
||||
legend: { data: legend, textStyle: { color: "#ccc" } },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: xTimes,
|
||||
axisLabel: {
|
||||
color: "#ccc",
|
||||
formatter: value =>
|
||||
new Date(value / 1000).toLocaleString([], {
|
||||
year: "2-digit",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
})
|
||||
}
|
||||
data: sampleTimes,
|
||||
axisLabel: { color: "#ccc" }
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
@@ -1152,7 +1084,6 @@ async function loadNeighborTimeSeries() {
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function loadPacketHistogram() {
|
||||
const DAYS = 7;
|
||||
const now = new Date();
|
||||
@@ -1326,29 +1257,17 @@ document.addEventListener("click", e => {
|
||||
====================================================== */
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
await loadTranslationsNode();
|
||||
await loadTranslationsNode(); // translations first
|
||||
|
||||
requestAnimationFrame(async () => {
|
||||
await loadNodeInfo();
|
||||
|
||||
// ✅ 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 loadNodeInfo(); // single-node fetch
|
||||
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);
|
||||
@@ -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) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
|
||||
@@ -26,6 +26,8 @@ table {
|
||||
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;
|
||||
@@ -103,21 +105,6 @@ select, .export-btn, .search-box, .clear-btn {
|
||||
font-weight: bold;
|
||||
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-star {
|
||||
@@ -203,6 +190,7 @@ select, .export-btn, .search-box, .clear-btn {
|
||||
font-size: 1.4em;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -214,7 +202,7 @@ select, .export-btn, .search-box, .clear-btn {
|
||||
id="search-box"
|
||||
class="search-box"
|
||||
data-translate-lang="search_placeholder"
|
||||
placeholder="Search by name or ID or HEX ID..."
|
||||
placeholder="Search by name or ID..."
|
||||
/>
|
||||
|
||||
<select id="role-filter">
|
||||
@@ -250,7 +238,6 @@ select, .export-btn, .search-box, .clear-btn {
|
||||
<span data-translate-lang="showing_nodes">Showing</span>
|
||||
<span id="node-count">0</span>
|
||||
<span data-translate-lang="nodes_suffix">nodes</span>
|
||||
<span id="node-status" class="node-status" aria-live="polite"></span>
|
||||
</div>
|
||||
|
||||
<!-- Desktop table -->
|
||||
@@ -321,11 +308,6 @@ let allNodes = [];
|
||||
let sortColumn = "short_name";
|
||||
let sortAsc = true;
|
||||
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 keyMap = [
|
||||
@@ -333,51 +315,28 @@ const keyMap = [
|
||||
"last_lat","last_long","channel","last_seen_us"
|
||||
];
|
||||
|
||||
function debounce(fn, delay = 250) {
|
||||
let t;
|
||||
return (...args) => {
|
||||
clearTimeout(t);
|
||||
t = setTimeout(() => fn(...args), delay);
|
||||
};
|
||||
}
|
||||
|
||||
function nextFrame() {
|
||||
return new Promise(resolve => requestAnimationFrame(() => resolve()));
|
||||
}
|
||||
|
||||
function loadFavorites() {
|
||||
function getFavorites() {
|
||||
const favorites = localStorage.getItem('nodelist_favorites');
|
||||
if (!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();
|
||||
}
|
||||
return favorites ? JSON.parse(favorites) : [];
|
||||
}
|
||||
function saveFavorites() {
|
||||
localStorage.setItem('nodelist_favorites', JSON.stringify([...favoritesSet]));
|
||||
function saveFavorites(favs) {
|
||||
localStorage.setItem('nodelist_favorites', JSON.stringify(favs));
|
||||
}
|
||||
function toggleFavorite(nodeId) {
|
||||
if (favoritesSet.has(nodeId)) {
|
||||
favoritesSet.delete(nodeId);
|
||||
} else {
|
||||
favoritesSet.add(nodeId);
|
||||
}
|
||||
saveFavorites();
|
||||
let favs = getFavorites();
|
||||
const idx = favs.indexOf(nodeId);
|
||||
if (idx >= 0) favs.splice(idx, 1);
|
||||
else favs.push(nodeId);
|
||||
saveFavorites(favs);
|
||||
}
|
||||
function isFavorite(nodeId) {
|
||||
return favoritesSet.has(nodeId);
|
||||
return getFavorites().includes(nodeId);
|
||||
}
|
||||
|
||||
function timeAgoFromMs(msTimestamp) {
|
||||
if (!msTimestamp) return "N/A";
|
||||
const diff = Date.now() - msTimestamp;
|
||||
function timeAgo(usTimestamp) {
|
||||
if (!usTimestamp) return "N/A";
|
||||
const ms = usTimestamp / 1000;
|
||||
const diff = Date.now() - ms;
|
||||
|
||||
if (diff < 60000) return "just now";
|
||||
const mins = Math.floor(diff / 60000);
|
||||
@@ -394,7 +353,6 @@ function timeAgoFromMs(msTimestamp) {
|
||||
document.addEventListener("DOMContentLoaded", async function() {
|
||||
|
||||
await loadTranslationsNodelist();
|
||||
loadFavorites();
|
||||
|
||||
const tbody = document.getElementById("node-table-body");
|
||||
const mobileList = document.getElementById("mobile-node-list");
|
||||
@@ -405,82 +363,52 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
const firmwareFilter = document.getElementById("firmware-filter");
|
||||
const searchBox = document.getElementById("search-box");
|
||||
const countSpan = document.getElementById("node-count");
|
||||
const statusSpan = document.getElementById("node-status");
|
||||
const exportBtn = document.getElementById("export-btn");
|
||||
const clearBtn = document.getElementById("clear-btn");
|
||||
const favoritesBtn = document.getElementById("favorites-btn");
|
||||
|
||||
let lastIsMobile = (window.innerWidth <= 768);
|
||||
|
||||
try {
|
||||
setStatus("Loading nodes…");
|
||||
await nextFrame();
|
||||
const res = await fetch("/api/nodes?days_active=3");
|
||||
if (!res.ok) throw new Error("Failed to fetch nodes");
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
allNodes = data.nodes.map(n => {
|
||||
const 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()
|
||||
};
|
||||
});
|
||||
allNodes = data.nodes.map(n => ({
|
||||
...n,
|
||||
firmware: n.firmware || n.firmware_version || ""
|
||||
}));
|
||||
|
||||
populateFilters(allNodes);
|
||||
applyFilters(); // ensures initial sort + render uses same path
|
||||
renderTable(allNodes);
|
||||
updateSortIcons();
|
||||
setStatus("");
|
||||
} catch (err) {
|
||||
tbody.innerHTML = `<tr>
|
||||
<td colspan="10" style="text-align:center; color:red;">
|
||||
${nodelistTranslations.error_loading_nodes || "Error loading nodes"}
|
||||
</td></tr>`;
|
||||
setStatus("");
|
||||
return;
|
||||
}
|
||||
|
||||
roleFilter.addEventListener("change", applyFilters);
|
||||
channelFilter.addEventListener("change", applyFilters);
|
||||
hwFilter.addEventListener("change", applyFilters);
|
||||
firmwareFilter.addEventListener("change", applyFilters);
|
||||
|
||||
// Debounced only for search typing
|
||||
searchBox.addEventListener("input", debounce(applyFilters, 250));
|
||||
|
||||
searchBox.addEventListener("input", applyFilters);
|
||||
exportBtn.addEventListener("click", exportToCSV);
|
||||
clearBtn.addEventListener("click", clearFilters);
|
||||
favoritesBtn.addEventListener("click", toggleFavoritesFilter);
|
||||
|
||||
// Favorite star click handler (delegated)
|
||||
// Favorite star click handler
|
||||
document.addEventListener("click", e => {
|
||||
if (e.target.classList.contains('favorite-star')) {
|
||||
const nodeId = parseInt(e.target.dataset.nodeId, 10);
|
||||
const fav = isFavorite(nodeId);
|
||||
const nodeId = parseInt(e.target.dataset.nodeId);
|
||||
const isFav = isFavorite(nodeId);
|
||||
|
||||
if (fav) {
|
||||
if (isFav) {
|
||||
e.target.classList.remove("active");
|
||||
e.target.textContent = "☆";
|
||||
} else {
|
||||
e.target.classList.add("active");
|
||||
e.target.textContent = "★";
|
||||
}
|
||||
|
||||
toggleFavorite(nodeId);
|
||||
applyFilters();
|
||||
}
|
||||
@@ -488,26 +416,13 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
|
||||
headers.forEach((th, index) => {
|
||||
th.addEventListener("click", () => {
|
||||
const key = keyMap[index];
|
||||
// ignore clicks on the "favorite" (last header) which has no sort key
|
||||
if (!key) return;
|
||||
|
||||
let key = keyMap[index];
|
||||
sortAsc = (sortColumn === key) ? !sortAsc : true;
|
||||
sortColumn = key;
|
||||
|
||||
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) {
|
||||
const roles = new Set(), channels = new Set(), hws = new Set(), fws = new Set();
|
||||
|
||||
@@ -542,9 +457,7 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
async function applyFilters() {
|
||||
setStatus("Updating…");
|
||||
await nextFrame();
|
||||
function applyFilters() {
|
||||
const searchTerm = searchBox.value.trim().toLowerCase();
|
||||
|
||||
let filtered = allNodes.filter(n => {
|
||||
@@ -553,116 +466,102 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
const hwMatch = !hwFilter.value || n.hw_model === hwFilter.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);
|
||||
|
||||
return roleMatch && channelMatch && hwMatch && fwMatch && searchMatch && favMatch;
|
||||
});
|
||||
|
||||
// IMPORTANT: Always sort the filtered subset to preserve expected behavior
|
||||
filtered = sortNodes(filtered, sortColumn, sortAsc);
|
||||
|
||||
renderTable(filtered);
|
||||
updateSortIcons();
|
||||
setStatus("");
|
||||
}
|
||||
|
||||
function renderTable(nodes) {
|
||||
tbody.innerHTML = "";
|
||||
mobileList.innerHTML = "";
|
||||
|
||||
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 (shouldRenderTable) {
|
||||
tbody.innerHTML = `<tr>
|
||||
<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;">
|
||||
tbody.innerHTML = `<tr>
|
||||
<td colspan="10" style="text-align:center; color:white;">
|
||||
${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;
|
||||
return;
|
||||
}
|
||||
|
||||
nodes.forEach(node => {
|
||||
const fav = isFavorite(node.node_id);
|
||||
const star = fav ? "★" : "☆";
|
||||
const isFav = isFavorite(node.node_id);
|
||||
const star = isFav ? "★" : "☆";
|
||||
|
||||
if (shouldRenderTable) {
|
||||
// DESKTOP TABLE ROW
|
||||
const row = document.createElement("tr");
|
||||
row.innerHTML = `
|
||||
<td>${node.short_name || "N/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.firmware || "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_long ? (node.last_long / 1e7).toFixed(7) : "N/A"}</td>
|
||||
<td>${node.channel || "N/A"}</td>
|
||||
<td>${timeAgoFromMs(node.last_seen_ms)}</td>
|
||||
<td style="text-align:center;">
|
||||
<span class="favorite-star ${fav ? "active" : ""}" data-node-id="${node.node_id}">
|
||||
${star}
|
||||
</span>
|
||||
</td>
|
||||
`;
|
||||
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>
|
||||
// DESKTOP TABLE ROW
|
||||
const row = document.createElement("tr");
|
||||
row.innerHTML = `
|
||||
<td>${node.short_name || "N/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.firmware || "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_long ? (node.last_long / 1e7).toFixed(7) : "N/A"}</td>
|
||||
<td>${node.channel || "N/A"}</td>
|
||||
<td>${timeAgo(node.last_seen_us)}</td>
|
||||
<td style="text-align:center;">
|
||||
<span class="favorite-star ${isFav ? "active" : ""}" data-node-id="${node.node_id}">
|
||||
${star}
|
||||
</span>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
|
||||
<div class="node-card-field"><b>ID:</b> ${node.node_id}</div>
|
||||
<div class="node-card-field"><b>Name:</b> ${node.long_name || "N/A"}</div>
|
||||
<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>
|
||||
<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 || "N/A"}</div>
|
||||
<div class="node-card-field"><b>Last Seen:</b> ${timeAgoFromMs(node.last_seen_ms)}</div>
|
||||
// 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 ${isFav ? "active" : ""}" data-node-id="${node.node_id}">
|
||||
${star}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<a href="/node/${node.node_id}" style="color:#9fd4ff; text-decoration:underline; margin-top:5px; display:block;">
|
||||
View Node →
|
||||
</a>
|
||||
`;
|
||||
mobileFrag.appendChild(card);
|
||||
}
|
||||
<div class="node-card-field"><b>ID:</b> ${node.node_id}</div>
|
||||
<div class="node-card-field"><b>Name:</b> ${node.long_name || "N/A"}</div>
|
||||
<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>
|
||||
<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
|
||||
mobileList.style.display = isMobile ? "block" : "none";
|
||||
if (isMobile) {
|
||||
mobileList.style.display = "block";
|
||||
} else {
|
||||
mobileList.style.display = "none";
|
||||
}
|
||||
|
||||
countSpan.textContent = nodes.length;
|
||||
|
||||
if (shouldRenderTable) {
|
||||
tbody.appendChild(tableFrag);
|
||||
} else {
|
||||
mobileList.appendChild(mobileFrag);
|
||||
}
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
@@ -671,7 +570,6 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
hwFilter.value = "";
|
||||
firmwareFilter.value = "";
|
||||
searchBox.value = "";
|
||||
|
||||
sortColumn = "short_name";
|
||||
sortAsc = true;
|
||||
showOnlyFavorites = false;
|
||||
@@ -679,7 +577,7 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
favoritesBtn.textContent = "⭐ Show Favorites";
|
||||
favoritesBtn.classList.remove("active");
|
||||
|
||||
applyFilters();
|
||||
renderTable(allNodes);
|
||||
updateSortIcons();
|
||||
}
|
||||
|
||||
@@ -715,10 +613,6 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
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;
|
||||
return 0;
|
||||
@@ -733,41 +627,6 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Basic Jinja Project
|
||||
|
||||
Small starter project that renders HTML with Jinja using Python's built-in HTTP server.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
python3 collector.py
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:8000/`.
|
||||
|
||||
## Files
|
||||
|
||||
- `collector.py` starts the server and renders templates.
|
||||
- `templates/base.html` is the shared layout.
|
||||
- `templates/index.html` is the page template.
|
||||
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
|
||||
DEFAULT_HOST = "127.0.0.1"
|
||||
DEFAULT_PORT = 8000
|
||||
DEFAULT_DB_PATH = "readings.db"
|
||||
DEFAULT_READINGS_LIMIT = 100
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
TEMPLATE_DIR = BASE_DIR / "templates"
|
||||
|
||||
templates = Environment(
|
||||
loader=FileSystemLoader(TEMPLATE_DIR),
|
||||
autoescape=select_autoescape(["html", "xml"]),
|
||||
)
|
||||
|
||||
|
||||
def utc_now_iso():
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def init_db(db_path):
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS readings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_id TEXT NOT NULL,
|
||||
sensor_type TEXT NOT NULL,
|
||||
temperature_c REAL NOT NULL,
|
||||
humidity REAL NOT NULL,
|
||||
received_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_readings_device_received_at
|
||||
ON readings (device_id, received_at DESC)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def insert_reading(db_path, reading):
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT INTO readings (
|
||||
device_id,
|
||||
sensor_type,
|
||||
temperature_c,
|
||||
humidity,
|
||||
received_at
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
reading["device_id"],
|
||||
reading["sensor_type"],
|
||||
reading["temperature_c"],
|
||||
reading["humidity"],
|
||||
utc_now_iso(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.lastrowid
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def fetch_recent_readings(db_path, limit):
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
device_id,
|
||||
sensor_type,
|
||||
temperature_c,
|
||||
humidity,
|
||||
received_at
|
||||
FROM readings
|
||||
ORDER BY received_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
readings = [dict(row) for row in rows]
|
||||
readings.reverse()
|
||||
return readings
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def validate_payload(payload):
|
||||
required_fields = (
|
||||
"device_id",
|
||||
"sensor_type",
|
||||
"temperature_c",
|
||||
"humidity",
|
||||
)
|
||||
for field in required_fields:
|
||||
if field not in payload:
|
||||
raise ValueError("Missing field: {}".format(field))
|
||||
|
||||
validated = {
|
||||
"device_id": str(payload["device_id"]).strip(),
|
||||
"sensor_type": str(payload["sensor_type"]).strip(),
|
||||
"temperature_c": float(payload["temperature_c"]),
|
||||
"humidity": float(payload["humidity"]),
|
||||
}
|
||||
|
||||
if not validated["device_id"]:
|
||||
raise ValueError("device_id must not be empty")
|
||||
if not validated["sensor_type"]:
|
||||
raise ValueError("sensor_type must not be empty")
|
||||
|
||||
return validated
|
||||
|
||||
|
||||
def render_template(template_name, **context):
|
||||
template = templates.get_template(template_name)
|
||||
return template.render(**context)
|
||||
|
||||
|
||||
def build_dashboard_context(db_path):
|
||||
readings = fetch_recent_readings(db_path, DEFAULT_READINGS_LIMIT)
|
||||
latest = readings[-1] if readings else None
|
||||
return {
|
||||
"title": "ESP32 Collector",
|
||||
"readings": readings,
|
||||
"reading_count": len(readings),
|
||||
"latest": latest,
|
||||
}
|
||||
|
||||
|
||||
class AppHandler(BaseHTTPRequestHandler):
|
||||
server_version = "ESP32Collector/1.0"
|
||||
|
||||
def send_json(self, payload, status=HTTPStatus.OK):
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def send_html(self, html, status=HTTPStatus.OK):
|
||||
body = html.encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
route = urlparse(self.path).path
|
||||
|
||||
if route == "/":
|
||||
self.send_html(render_template("dashboard.html", **build_dashboard_context(self.server.db_path)))
|
||||
return
|
||||
|
||||
if route == "/chart":
|
||||
self.send_html(render_template("dashboard.html", **build_dashboard_context(self.server.db_path)))
|
||||
return
|
||||
|
||||
if route == "/health":
|
||||
self.send_json({"status": "ok"})
|
||||
return
|
||||
|
||||
if route == "/readings":
|
||||
readings = fetch_recent_readings(self.server.db_path, DEFAULT_READINGS_LIMIT)
|
||||
self.send_json({"readings": readings})
|
||||
return
|
||||
|
||||
self.send_json({"error": "not found"}, status=HTTPStatus.NOT_FOUND)
|
||||
|
||||
def do_POST(self):
|
||||
route = urlparse(self.path).path
|
||||
if route != "/metrics":
|
||||
self.send_json({"error": "not found"}, status=HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
|
||||
content_length = self.headers.get("Content-Length")
|
||||
if not content_length:
|
||||
self.send_json({"error": "missing content length"}, status=HTTPStatus.LENGTH_REQUIRED)
|
||||
return
|
||||
|
||||
try:
|
||||
raw_body = self.rfile.read(int(content_length))
|
||||
payload = json.loads(raw_body)
|
||||
reading = validate_payload(payload)
|
||||
row_id = insert_reading(self.server.db_path, reading)
|
||||
except ValueError as exc:
|
||||
self.send_json({"error": str(exc)}, status=HTTPStatus.BAD_REQUEST)
|
||||
return
|
||||
except json.JSONDecodeError:
|
||||
self.send_json({"error": "invalid json"}, status=HTTPStatus.BAD_REQUEST)
|
||||
return
|
||||
except Exception as exc:
|
||||
self.send_json(
|
||||
{"error": "server error", "detail": str(exc)},
|
||||
status=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
return
|
||||
|
||||
print(
|
||||
"[{}] stored reading id={} device={} temp_c={} humidity={}".format(
|
||||
utc_now_iso(),
|
||||
row_id,
|
||||
reading["device_id"],
|
||||
reading["temperature_c"],
|
||||
reading["humidity"],
|
||||
)
|
||||
)
|
||||
self.send_json({"status": "stored", "id": row_id}, status=HTTPStatus.CREATED)
|
||||
|
||||
def log_message(self, format_string, *args):
|
||||
print(
|
||||
"[{}] {} {}".format(
|
||||
utc_now_iso(),
|
||||
self.address_string(),
|
||||
format_string % args,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Collect ESP32 sensor data and render a Jinja dashboard."
|
||||
)
|
||||
parser.add_argument("--host", default=DEFAULT_HOST)
|
||||
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
|
||||
parser.add_argument("--db", default=DEFAULT_DB_PATH)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
db_path = Path(args.db).resolve()
|
||||
init_db(db_path)
|
||||
|
||||
server = ThreadingHTTPServer((args.host, args.port), AppHandler)
|
||||
server.db_path = str(db_path)
|
||||
|
||||
print("Metrics endpoint: http://{}:{}/metrics".format(args.host, args.port))
|
||||
print("Dashboard: http://{}:{}/chart".format(args.host, args.port))
|
||||
print("SQLite database:", db_path)
|
||||
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nShutting down")
|
||||
finally:
|
||||
server.server_close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
Jinja2>=3.1,<4
|
||||
@@ -0,0 +1,123 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ title }}</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f8f5ef;
|
||||
--panel: #fffdf8;
|
||||
--text: #1f2937;
|
||||
--accent: #0f766e;
|
||||
--border: #d6d3d1;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Georgia, serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(15, 118, 110, 0.10), transparent 25%),
|
||||
linear-gradient(180deg, #fdfcf9 0%, var(--bg) 100%);
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(760px, calc(100% - 32px));
|
||||
margin: 48px auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 32px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 50px rgba(31, 41, 55, 0.08);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 12px;
|
||||
font-size: clamp(2rem, 5vw, 3rem);
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.stat {
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.stat strong,
|
||||
.stat span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.stat strong {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.stat span {
|
||||
color: #57534e;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.chart-wrap {
|
||||
position: relative;
|
||||
height: 360px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin-top: 24px;
|
||||
padding: 18px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 24px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 10px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: "Courier New", monospace;
|
||||
background: rgba(15, 118, 110, 0.08);
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<section class="card">
|
||||
{% block content %}{% endblock %}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,99 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>ESP32 Collector</h1>
|
||||
<p>Recent sensor uploads rendered with Jinja.</p>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<strong>{{ reading_count }}</strong>
|
||||
<span>stored readings</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<strong>{{ latest.device_id if latest else "n/a" }}</strong>
|
||||
<span>latest device</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<strong>{{ "%.1f"|format(latest.temperature_c) if latest else "n/a" }}</strong>
|
||||
<span>latest temp C</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<strong>{{ "%.1f"|format(latest.humidity) if latest else "n/a" }}</strong>
|
||||
<span>latest humidity %</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if readings %}
|
||||
<div class="chart-wrap">
|
||||
<canvas id="readingChart"></canvas>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Device</th>
|
||||
<th>Sensor</th>
|
||||
<th>Temp C</th>
|
||||
<th>Humidity %</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for reading in readings|reverse %}
|
||||
<tr>
|
||||
<td>{{ reading.received_at }}</td>
|
||||
<td>{{ reading.device_id }}</td>
|
||||
<td>{{ reading.sensor_type }}</td>
|
||||
<td>{{ "%.1f"|format(reading.temperature_c) }}</td>
|
||||
<td>{{ "%.1f"|format(reading.humidity) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
const readings = {{ readings | tojson }};
|
||||
const labels = readings.map((reading) => new Date(reading.received_at).toLocaleString());
|
||||
const temperatures = readings.map((reading) => reading.temperature_c);
|
||||
const humidity = readings.map((reading) => reading.humidity);
|
||||
|
||||
new Chart(document.getElementById("readingChart"), {
|
||||
type: "line",
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: "Temperature (C)",
|
||||
data: temperatures,
|
||||
borderColor: "#c2410c",
|
||||
backgroundColor: "rgba(194, 65, 12, 0.14)",
|
||||
borderWidth: 3,
|
||||
fill: true,
|
||||
tension: 0.25,
|
||||
pointRadius: 2
|
||||
},
|
||||
{
|
||||
label: "Humidity (%)",
|
||||
data: humidity,
|
||||
borderColor: "#0f766e",
|
||||
backgroundColor: "rgba(15, 118, 110, 0.12)",
|
||||
borderWidth: 3,
|
||||
fill: true,
|
||||
tension: 0.25,
|
||||
pointRadius: 2
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% else %}
|
||||
<div class="empty">
|
||||
No readings yet. Your ESP32 should post JSON to <code>/metrics</code>.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user