diff --git a/meshview/templates/nodelist.html b/meshview/templates/nodelist.html
index c68c1ba..70c8e9a 100644
--- a/meshview/templates/nodelist.html
+++ b/meshview/templates/nodelist.html
@@ -103,6 +103,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 {
@@ -235,6 +250,7 @@ select, .export-btn, .search-box, .clear-btn {
Showing
0
nodes
+
@@ -305,6 +321,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 = [
@@ -320,22 +341,38 @@ function debounce(fn, delay = 250) {
};
}
-function getFavorites() {
- const favorites = localStorage.getItem('nodelist_favorites');
- return favorites ? JSON.parse(favorites) : [];
+function nextFrame() {
+ return new Promise(resolve => requestAnimationFrame(() => resolve()));
}
-function saveFavorites(favs) {
- localStorage.setItem('nodelist_favorites', JSON.stringify(favs));
+
+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) {
- let favs = getFavorites();
- const idx = favs.indexOf(nodeId);
- if (idx >= 0) favs.splice(idx, 1);
- else favs.push(nodeId);
- saveFavorites(favs);
+ if (favoritesSet.has(nodeId)) {
+ favoritesSet.delete(nodeId);
+ } else {
+ favoritesSet.add(nodeId);
+ }
+ saveFavorites();
}
function isFavorite(nodeId) {
- return getFavorites().includes(nodeId);
+ return favoritesSet.has(nodeId);
}
function timeAgoFromMs(msTimestamp) {
@@ -357,6 +394,7 @@ 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");
@@ -367,6 +405,7 @@ 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");
@@ -374,6 +413,8 @@ document.addEventListener("DOMContentLoaded", async function() {
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");
@@ -404,11 +445,13 @@ document.addEventListener("DOMContentLoaded", async function() {
populateFilters(allNodes);
applyFilters(); // ensures initial sort + render uses same path
updateSortIcons();
+ setStatus("");
} catch (err) {
tbody.innerHTML = `
|
${nodelistTranslations.error_loading_nodes || "Error loading nodes"}
|
`;
+ setStatus("");
return;
}
@@ -499,7 +542,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 => {
@@ -519,27 +564,34 @@ document.addEventListener("DOMContentLoaded", async function() {
renderTable(filtered);
updateSortIcons();
+ setStatus("");
}
function renderTable(nodes) {
- tbody.innerHTML = "";
- mobileList.innerHTML = "";
-
- const tableFrag = document.createDocumentFragment();
- const mobileFrag = document.createDocumentFragment();
-
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 = `
- ${nodelistTranslations.no_nodes_found || "No nodes found"}
-
`;
+
`;
+ }
countSpan.textContent = 0;
return;
@@ -549,54 +601,56 @@ document.addEventListener("DOMContentLoaded", async function() {
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"} |
- ${timeAgoFromMs(node.last_seen_ms)} |
-
-
- ${star}
-
- |
- `;
- tableFrag.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 || "N/A"}
- Last Seen: ${timeAgoFromMs(node.last_seen_ms)}
-
-
- View Node →
-
- `;
- mobileFrag.appendChild(card);
+
+ View Node →
+
+ `;
+ mobileFrag.appendChild(card);
+ }
});
// Toggle correct view
@@ -604,8 +658,11 @@ document.addEventListener("DOMContentLoaded", async function() {
countSpan.textContent = nodes.length;
- tbody.appendChild(tableFrag);
- mobileList.appendChild(mobileFrag);
+ if (shouldRenderTable) {
+ tbody.appendChild(tableFrag);
+ } else {
+ mobileList.appendChild(mobileFrag);
+ }
}
function clearFilters() {
@@ -676,6 +733,41 @@ 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;
+ }
});
{% endblock %}