diff --git a/meshview/templates/nodelist.html b/meshview/templates/nodelist.html
index dec82ca..593b0b0 100644
--- a/meshview/templates/nodelist.html
+++ b/meshview/templates/nodelist.html
@@ -27,7 +27,6 @@ table {
min-width: 100%; /* won't shrink smaller than viewport */
}
-
th, td {
padding: 10px;
border: 1px solid #333;
@@ -105,6 +104,21 @@ 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 {
@@ -190,7 +204,6 @@ select, .export-btn, .search-box, .clear-btn {
font-size: 1.4em;
}
}
-
{% endblock %}
@@ -238,6 +251,7 @@ select, .export-btn, .search-box, .clear-btn {
Showing
0
nodes
+
@@ -308,6 +322,11 @@ 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 = [
@@ -315,28 +334,51 @@ const keyMap = [
"last_lat","last_long","channel","last_seen_us"
];
-function getFavorites() {
- const favorites = localStorage.getItem('nodelist_favorites');
- return favorites ? JSON.parse(favorites) : [];
-}
-function saveFavorites(favs) {
- localStorage.setItem('nodelist_favorites', JSON.stringify(favs));
-}
-function toggleFavorite(nodeId) {
- 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 getFavorites().includes(nodeId);
+function debounce(fn, delay = 250) {
+ let t;
+ return (...args) => {
+ clearTimeout(t);
+ t = setTimeout(() => fn(...args), delay);
+ };
}
-function timeAgo(usTimestamp) {
- if (!usTimestamp) return "N/A";
- const ms = usTimestamp / 1000;
- const diff = Date.now() - ms;
+function nextFrame() {
+ return new Promise(resolve => requestAnimationFrame(() => resolve()));
+}
+
+function loadFavorites() {
+ 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();
+ }
+}
+function saveFavorites() {
+ localStorage.setItem('nodelist_favorites', JSON.stringify([...favoritesSet]));
+}
+function toggleFavorite(nodeId) {
+ if (favoritesSet.has(nodeId)) {
+ favoritesSet.delete(nodeId);
+ } else {
+ favoritesSet.add(nodeId);
+ }
+ saveFavorites();
+}
+function isFavorite(nodeId) {
+ return favoritesSet.has(nodeId);
+}
+
+function timeAgoFromMs(msTimestamp) {
+ if (!msTimestamp) return "N/A";
+ const diff = Date.now() - msTimestamp;
if (diff < 60000) return "just now";
const mins = Math.floor(diff / 60000);
@@ -353,6 +395,7 @@ function timeAgo(usTimestamp) {
document.addEventListener("DOMContentLoaded", async function() {
await loadTranslationsNodelist();
+ loadFavorites();
const tbody = document.getElementById("node-table-body");
const mobileList = document.getElementById("mobile-node-list");
@@ -363,52 +406,82 @@ 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 => ({
- ...n,
- firmware: n.firmware || n.firmware_version || ""
- }));
+
+ 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()
+ };
+ });
populateFilters(allNodes);
- renderTable(allNodes);
+ applyFilters(); // ensures initial sort + render uses same path
updateSortIcons();
+ setStatus("");
} catch (err) {
tbody.innerHTML = `
|
${nodelistTranslations.error_loading_nodes || "Error loading nodes"}
|
`;
+ setStatus("");
+ return;
}
roleFilter.addEventListener("change", applyFilters);
channelFilter.addEventListener("change", applyFilters);
hwFilter.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);
clearBtn.addEventListener("click", clearFilters);
favoritesBtn.addEventListener("click", toggleFavoritesFilter);
- // Favorite star click handler
+ // Favorite star click handler (delegated)
document.addEventListener("click", e => {
if (e.target.classList.contains('favorite-star')) {
- const nodeId = parseInt(e.target.dataset.nodeId);
- const isFav = isFavorite(nodeId);
+ const nodeId = parseInt(e.target.dataset.nodeId, 10);
+ const fav = isFavorite(nodeId);
- if (isFav) {
+ if (fav) {
e.target.classList.remove("active");
e.target.textContent = "☆";
} else {
e.target.classList.add("active");
e.target.textContent = "★";
}
+
toggleFavorite(nodeId);
applyFilters();
}
@@ -416,13 +489,26 @@ document.addEventListener("DOMContentLoaded", async function() {
headers.forEach((th, index) => {
th.addEventListener("click", () => {
- let key = keyMap[index];
+ const key = keyMap[index];
+ // ignore clicks on the "favorite" (last header) which has no sort key
+ if (!key) return;
+
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();
@@ -457,7 +543,9 @@ document.addEventListener("DOMContentLoaded", async function() {
applyFilters();
}
- function applyFilters() {
+ async function applyFilters() {
+ setStatus("Updating…");
+ await nextFrame();
const searchTerm = searchBox.value.trim().toLowerCase();
let filtered = allNodes.filter(n => {
@@ -466,108 +554,116 @@ document.addEventListener("DOMContentLoaded", async function() {
const hwMatch = !hwFilter.value || n.hw_model === hwFilter.value;
const fwMatch = !firmwareFilter.value || n.firmware === firmwareFilter.value;
- const searchBlob = [
- n.node_id, // numeric ID
- n.id, // hex ID (if present)
- n.long_name,
- n.short_name
- ]
- .filter(Boolean)
- .join(" ")
- .toLowerCase();
-
- const searchMatch = !searchTerm || searchBlob.includes(searchTerm);
-
+ const searchMatch = !searchTerm || n._search.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) {
- tbody.innerHTML = `
- |
+ if (shouldRenderTable) {
+ tbody.innerHTML = ` |
+ |
+ ${nodelistTranslations.no_nodes_found || "No nodes found"}
+ |
+
`;
+ } else {
+ mobileList.innerHTML = `
${nodelistTranslations.no_nodes_found || "No nodes found"}
-
- `;
+
`;
+ }
- mobileList.innerHTML = `No nodes found
`;
countSpan.textContent = 0;
return;
}
nodes.forEach(node => {
- const isFav = isFavorite(node.node_id);
- const star = isFav ? "★" : "☆";
+ const fav = isFavorite(node.node_id);
+ const star = fav ? "★" : "☆";
- // DESKTOP TABLE ROW
- const row = document.createElement("tr");
- row.innerHTML = `
- ${node.short_name || "N/A"} |
- ${node.long_name || "N/A"} |
- ${node.hw_model || "N/A"} |
- ${node.firmware || "N/A"} |
- ${node.role || "N/A"} |
- ${node.last_lat ? (node.last_lat / 1e7).toFixed(7) : "N/A"} |
- ${node.last_long ? (node.last_long / 1e7).toFixed(7) : "N/A"} |
- ${node.channel || "N/A"} |
- ${timeAgo(node.last_seen_us)} |
-
-
- ${star}
-
- |
- `;
- tbody.appendChild(row);
+ if (shouldRenderTable) {
+ // DESKTOP TABLE ROW
+ const row = document.createElement("tr");
+ row.innerHTML = `
+ ${node.short_name || "N/A"} |
+ ${node.long_name || "N/A"} |
+ ${node.hw_model || "N/A"} |
+ ${node.firmware || "N/A"} |
+ ${node.role || "N/A"} |
+ ${node.last_lat ? (node.last_lat / 1e7).toFixed(7) : "N/A"} |
+ ${node.last_long ? (node.last_long / 1e7).toFixed(7) : "N/A"} |
+ ${node.channel || "N/A"} |
+ ${timeAgoFromMs(node.last_seen_ms)} |
+
+
+ ${star}
+
+ |
+ `;
+ tableFrag.appendChild(row);
+ } else {
+ // MOBILE CARD VIEW
+ const card = document.createElement("div");
+ card.className = "node-card";
+ card.innerHTML = `
+
- // MOBILE CARD VIEW
- const card = document.createElement("div");
- card.className = "node-card";
- card.innerHTML = `
-
+ ID: ${node.node_id}
+ Name: ${node.long_name || "N/A"}
+ HW: ${node.hw_model || "N/A"}
+ Firmware: ${node.firmware || "N/A"}
+ Role: ${node.role || "N/A"}
+ Location:
+ ${node.last_lat ? (node.last_lat / 1e7).toFixed(5) : "N/A"},
+ ${node.last_long ? (node.last_long / 1e7).toFixed(5) : "N/A"}
+
+ Channel: ${node.channel || "N/A"}
+ Last Seen: ${timeAgoFromMs(node.last_seen_ms)}
- ID: ${node.node_id}
- Name: ${node.long_name || "N/A"}
- HW: ${node.hw_model || "N/A"}
- Firmware: ${node.firmware || "N/A"}
- Role: ${node.role || "N/A"}
- Location:
- ${node.last_lat ? (node.last_lat / 1e7).toFixed(5) : "N/A"},
- ${node.last_long ? (node.last_long / 1e7).toFixed(5) : "N/A"}
-
- Channel: ${node.channel}
- Last Seen: ${timeAgo(node.last_seen_us)}
-
-
- View Node →
-
- `;
- mobileList.appendChild(card);
+
+ View Node →
+
+ `;
+ mobileFrag.appendChild(card);
+ }
});
// Toggle correct view
- if (isMobile) {
- mobileList.style.display = "block";
- } else {
- mobileList.style.display = "none";
- }
+ mobileList.style.display = isMobile ? "block" : "none";
countSpan.textContent = nodes.length;
+
+ if (shouldRenderTable) {
+ tbody.appendChild(tableFrag);
+ } else {
+ mobileList.appendChild(mobileFrag);
+ }
}
function clearFilters() {
@@ -576,6 +672,7 @@ document.addEventListener("DOMContentLoaded", async function() {
hwFilter.value = "";
firmwareFilter.value = "";
searchBox.value = "";
+
sortColumn = "short_name";
sortAsc = true;
showOnlyFavorites = false;
@@ -583,7 +680,7 @@ document.addEventListener("DOMContentLoaded", async function() {
favoritesBtn.textContent = "⭐ Show Favorites";
favoritesBtn.classList.remove("active");
- renderTable(allNodes);
+ applyFilters();
updateSortIcons();
}
@@ -619,6 +716,10 @@ 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;
@@ -633,6 +734,38 @@ 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();
+ 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;
+ statusSpan.textContent = "";
+ statusSpan.classList.remove("active");
+ isBusy = false;
+ }, remaining);
+ return;
+ }
+
+ statusSpan.textContent = "";
+ statusSpan.classList.remove("active");
+ isBusy = false;
+ }
});
{% endblock %}