Add node status indicator and improve favorites handling in nodelist

This commit is contained in:
pablorevilla-meshtastic
2026-01-08 17:38:12 -08:00
parent df26df07f1
commit 571559114d
+241 -108
View File
@@ -27,7 +27,6 @@ table {
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;
@@ -105,6 +104,21 @@ 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 {
@@ -190,7 +204,6 @@ select, .export-btn, .search-box, .clear-btn {
font-size: 1.4em; font-size: 1.4em;
} }
} }
</style> </style>
{% endblock %} {% endblock %}
@@ -238,6 +251,7 @@ 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 -->
@@ -308,6 +322,11 @@ 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 = [
@@ -315,28 +334,51 @@ const keyMap = [
"last_lat","last_long","channel","last_seen_us" "last_lat","last_long","channel","last_seen_us"
]; ];
function getFavorites() { function debounce(fn, delay = 250) {
const favorites = localStorage.getItem('nodelist_favorites'); let t;
return favorites ? JSON.parse(favorites) : []; return (...args) => {
} clearTimeout(t);
function saveFavorites(favs) { t = setTimeout(() => fn(...args), delay);
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 timeAgo(usTimestamp) { function nextFrame() {
if (!usTimestamp) return "N/A"; return new Promise(resolve => requestAnimationFrame(() => resolve()));
const ms = usTimestamp / 1000; }
const diff = Date.now() - ms;
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"; if (diff < 60000) return "just now";
const mins = Math.floor(diff / 60000); const mins = Math.floor(diff / 60000);
@@ -353,6 +395,7 @@ function timeAgo(usTimestamp) {
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");
@@ -363,52 +406,82 @@ 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 => ({
...n, allNodes = data.nodes.map(n => {
firmware: n.firmware || n.firmware_version || "" 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); populateFilters(allNodes);
renderTable(allNodes); applyFilters(); // ensures initial sort + render uses same path
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 // Favorite star click handler (delegated)
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); const nodeId = parseInt(e.target.dataset.nodeId, 10);
const isFav = isFavorite(nodeId); const fav = isFavorite(nodeId);
if (isFav) { if (fav) {
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();
} }
@@ -416,13 +489,26 @@ document.addEventListener("DOMContentLoaded", async function() {
headers.forEach((th, index) => { headers.forEach((th, index) => {
th.addEventListener("click", () => { 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; 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();
@@ -457,7 +543,9 @@ document.addEventListener("DOMContentLoaded", async function() {
applyFilters(); applyFilters();
} }
function applyFilters() { async 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 => {
@@ -466,108 +554,116 @@ 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 searchBlob = [ const searchMatch = !searchTerm || n._search.includes(searchTerm);
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 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) {
tbody.innerHTML = `<tr> if (shouldRenderTable) {
<td colspan="10" 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"}
</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"}
</td> </div>`;
</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 isFav = isFavorite(node.node_id); const fav = isFavorite(node.node_id);
const star = isFav ? "★" : "☆"; const star = fav ? "★" : "☆";
// DESKTOP TABLE ROW if (shouldRenderTable) {
const row = document.createElement("tr"); // DESKTOP TABLE ROW
row.innerHTML = ` const row = document.createElement("tr");
<td>${node.short_name || "N/A"}</td> row.innerHTML = `
<td><a href="/node/${node.node_id}">${node.long_name || "N/A"}</a></td> <td>${node.short_name || "N/A"}</td>
<td>${node.hw_model || "N/A"}</td> <td><a href="/node/${node.node_id}">${node.long_name || "N/A"}</a></td>
<td>${node.firmware || "N/A"}</td> <td>${node.hw_model || "N/A"}</td>
<td>${node.role || "N/A"}</td> <td>${node.firmware || "N/A"}</td>
<td>${node.last_lat ? (node.last_lat / 1e7).toFixed(7) : "N/A"}</td> <td>${node.role || "N/A"}</td>
<td>${node.last_long ? (node.last_long / 1e7).toFixed(7) : "N/A"}</td> <td>${node.last_lat ? (node.last_lat / 1e7).toFixed(7) : "N/A"}</td>
<td>${node.channel || "N/A"}</td> <td>${node.last_long ? (node.last_long / 1e7).toFixed(7) : "N/A"}</td>
<td>${timeAgo(node.last_seen_us)}</td> <td>${node.channel || "N/A"}</td>
<td style="text-align:center;"> <td>${timeAgoFromMs(node.last_seen_ms)}</td>
<span class="favorite-star ${isFav ? "active" : ""}" data-node-id="${node.node_id}"> <td style="text-align:center;">
${star} <span class="favorite-star ${fav ? "active" : ""}" data-node-id="${node.node_id}">
</span> ${star}
</td> </span>
`; </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>
// MOBILE CARD VIEW <div class="node-card-field"><b>ID:</b> ${node.node_id}</div>
const card = document.createElement("div"); <div class="node-card-field"><b>Name:</b> ${node.long_name || "N/A"}</div>
card.className = "node-card"; <div class="node-card-field"><b>HW:</b> ${node.hw_model || "N/A"}</div>
card.innerHTML = ` <div class="node-card-field"><b>Firmware:</b> ${node.firmware || "N/A"}</div>
<div class="node-card-header"> <div class="node-card-field"><b>Role:</b> ${node.role || "N/A"}</div>
<span>${node.short_name || node.long_name || node.node_id}</span> <div class="node-card-field"><b>Location:</b>
<span class="favorite-star ${isFav ? "active" : ""}" data-node-id="${node.node_id}"> ${node.last_lat ? (node.last_lat / 1e7).toFixed(5) : "N/A"},
${star} ${node.last_long ? (node.last_long / 1e7).toFixed(5) : "N/A"}
</span> </div>
</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>
<div class="node-card-field"><b>ID:</b> ${node.node_id}</div> <a href="/node/${node.node_id}" style="color:#9fd4ff; text-decoration:underline; margin-top:5px; display:block;">
<div class="node-card-field"><b>Name:</b> ${node.long_name || "N/A"}</div> View Node →
<div class="node-card-field"><b>HW:</b> ${node.hw_model || "N/A"}</div> </a>
<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> mobileFrag.appendChild(card);
<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
if (isMobile) { mobileList.style.display = isMobile ? "block" : "none";
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() {
@@ -576,6 +672,7 @@ 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;
@@ -583,7 +680,7 @@ document.addEventListener("DOMContentLoaded", async function() {
favoritesBtn.textContent = "⭐ Show Favorites"; favoritesBtn.textContent = "⭐ Show Favorites";
favoritesBtn.classList.remove("active"); favoritesBtn.classList.remove("active");
renderTable(allNodes); applyFilters();
updateSortIcons(); updateSortIcons();
} }
@@ -619,6 +716,10 @@ 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;
@@ -633,6 +734,38 @@ 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();
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;
}
}); });
</script> </script>
{% endblock %} {% endblock %}