9 Commits

Author SHA1 Message Date
pablorevilla-meshtastic 9912f6b181 testing commit message functionality 2026-01-08 18:39:01 -08:00
pablorevilla-meshtastic cb4cc281c6 fix speed of node list rendering 2026-01-08 17:38:56 -08:00
pablorevilla-meshtastic 571559114d Add node status indicator and improve favorites handling in nodelist 2026-01-08 17:38:12 -08:00
pablorevilla-meshtastic df26df07f1 Changes to node.html. fix some of the data 2026-01-08 14:59:45 -08:00
pablorevilla-meshtastic ffc7340bc9 Changes to nodelist.html. fix some of the data 2026-01-07 17:19:32 -08:00
pablorevilla-meshtastic 1d58aaba83 Changes to nodelist.html. fix some of the data 2026-01-07 13:35:58 -08:00
pablorevilla-meshtastic b2bb9345fe Changes to nodelist.html. fix some of the data 2026-01-07 13:29:56 -08:00
pablorevilla-meshtastic 9686622b56 Changes to node.html. fix some of the data 2026-01-07 10:01:02 -08:00
pablorevilla-meshtastic f7644a9573 Changes to node.html. fix some of the data 2026-01-07 09:48:26 -08:00
3 changed files with 412 additions and 177 deletions
+1 -7
View File
@@ -82,12 +82,9 @@ 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 = (
@@ -238,6 +235,3 @@ async def process_envelope(topic, env):
await session.commit()
# if new_packet:
# await packet.awaitable_attrs.to_node
# await packet.awaitable_attrs.from_node
+166 -66
View File
@@ -258,15 +258,17 @@
<!-- 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>
</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>
<th data-translate-lang="size">Size</th>
</tr>
</thead>
<tbody id="packet_list"></tbody>
</table>
@@ -608,10 +610,16 @@ function addMarker(id, lat, lon, color = "red", node = null) {
m.bringToFront();
}
async function drawNeighbors(src, nids){
async function drawNeighbors(src, nids) {
if (!map) return;
const srcPos = nodePositions[src];
if (!srcPos) 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];
for (const nid of nids) {
const neighbor = await fetchNodeFromApi(nid);
@@ -620,13 +628,22 @@ 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);
const dstPos = [lat, lon];
L.polyline([srcPos, dstPos], { color:'gray', weight:1 }).addTo(map);
// Link line
L.polyline(
[[srcLat, srcLon], [lat, lon]],
{ color: "gray", weight: 1 }
).addTo(map);
}
ensureMapVisible();
}
function ensureMapVisible(){
if (!map) return;
requestAnimationFrame(() => {
@@ -758,8 +775,11 @@ async function loadPackets(filters = {}) {
const packets = data.packets || [];
currentPacketRows = packets;
for (const pkt of packets.reverse()) {
for (const pkt of (data.packets || []).reverse()) {
// ================================
// TABLE ROW
// ================================
const safePayload = (pkt.payload || "")
.replace(/[<>]/g, m => (m === "<" ? "&lt;" : "&gt;"));
@@ -788,25 +808,30 @@ 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>
</tr>
<tr class="payload-row">
<td colspan="5" 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>
<td>${sizeBytes.toLocaleString()} B</td>
</tr>
<tr class="payload-row">
<td colspan="6" class="payload-cell">${safePayload}</td>
</tr>`);
}
}
/* ======================================================
TELEMETRY CHARTS (portnum=67)
====================================================== */
@@ -969,45 +994,69 @@ 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) {
document.getElementById("neighbor_chart_container").style.display = "none";
container.style.display = "none";
return;
}
const data = await res.json();
let packets = data.packets || [];
const packets = data.packets || [];
if (!packets.length) {
document.getElementById("neighbor_chart_container").style.display = "none";
container.style.display = "none";
return;
}
// Sort packets chronologically (microseconds)
packets.sort((a, b) => (a.import_time_us || 0) - (b.import_time_us || 0));
// neighborHistory = { node_id: { name, snr:[...], times:[...] } }
const neighborHistory = {};
const neighborHistory = {}; // node_id -> { name, times[], snr[] }
for (const pkt of packets) {
if (!pkt.import_time_us || !pkt.payload) continue;
const ts = new Date(pkt.import_time_us / 1000).toLocaleString([], {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
const ts = pkt.import_time_us; // KEEP NUMERIC TIMESTAMP
// Extract neighbor blocks
const blockRe = /neighbors\s*\{([^}]+)\}/g;
let m;
while ((m = blockRe.exec(pkt.payload)) !== null) {
const block = m[1];
@@ -1019,9 +1068,14 @@ 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: nodeMap[nid] || `Node ${nid}`,
name: neighbor?.short_name ||
neighbor?.long_name ||
`Node ${nid}`,
times: [],
snr: []
};
@@ -1032,45 +1086,59 @@ async function loadNeighborTimeSeries() {
}
}
const chart = echarts.init(document.getElementById("chart_neighbors"));
// 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 legend = [];
const series = [];
for (const [nid, entry] of Object.entries(neighborHistory)) {
for (const entry of Object.values(neighborHistory)) {
legend.push(entry.name);
series.push({
name: entry.name,
type: "line",
smooth: true,
connectNulls: true, // --- FIX #2: connect dots even if missing ---
connectNulls: true,
showSymbol: false,
data: entry.snr,
data: xTimes.map(t => {
const idx = entry.times.indexOf(t);
return idx >= 0 ? entry.snr[idx] : null;
})
});
}
// 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);
});
const chart = echarts.init(chartEl);
chart.setOption({
tooltip: { trigger: "axis" },
legend: { data: legend, textStyle: { color: "#ccc" } },
tooltip: {
trigger: "axis",
axisPointer: { type: "line" }
},
legend: {
data: legend,
textStyle: { color: "#ccc" }
},
xAxis: {
type: "category",
data: sampleTimes,
axisLabel: { color: "#ccc" }
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"
})
}
},
yAxis: {
type: "value",
@@ -1084,6 +1152,7 @@ async function loadNeighborTimeSeries() {
}
async function loadPacketHistogram() {
const DAYS = 7;
const now = new Date();
@@ -1257,17 +1326,29 @@ document.addEventListener("click", e => {
====================================================== */
document.addEventListener("DOMContentLoaded", async () => {
await loadTranslationsNode(); // translations first
await loadTranslationsNode();
requestAnimationFrame(async () => {
await loadNodeInfo(); // single-node fetch
if (!map) initMap(); // init map early so neighbors can draw
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 loadTrack();
await loadPackets();
initPacketPortFilter();
await loadTelemetryCharts();
await loadNeighborTimeSeries();
await loadPacketHistogram();
ensureMapVisible();
setTimeout(ensureMapVisible, 1000);
window.addEventListener("resize", ensureMapVisible);
@@ -1275,6 +1356,25 @@ 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(
+245 -104
View File
@@ -26,8 +26,6 @@ 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;
@@ -105,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 {
@@ -190,7 +203,6 @@ select, .export-btn, .search-box, .clear-btn {
font-size: 1.4em;
}
}
</style>
{% endblock %}
@@ -202,7 +214,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..."
placeholder="Search by name or ID or HEX ID..."
/>
<select id="role-filter">
@@ -238,6 +250,7 @@ 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 -->
@@ -308,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 = [
@@ -315,28 +333,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 +394,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 +405,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 = `<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);
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 +488,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 +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 => {
@@ -466,102 +553,116 @@ 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.long_name && n.long_name.toLowerCase().includes(searchTerm)) ||
(n.short_name && n.short_name.toLowerCase().includes(searchTerm)) ||
n.node_id.toString().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 = `<tr>
<td colspan="10" style="text-align:center; color:white;">
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;">
${nodelistTranslations.no_nodes_found || "No nodes found"}
</td>
</tr>`;
</div>`;
}
mobileList.innerHTML = `<div style="text-align:center; color:white;">No nodes found</div>`;
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 = `
<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);
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>
// 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>
<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>
<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);
<a href="/node/${node.node_id}" style="color:#9fd4ff; text-decoration:underline; margin-top:5px; display:block;">
View Node →
</a>
`;
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() {
@@ -570,6 +671,7 @@ document.addEventListener("DOMContentLoaded", async function() {
hwFilter.value = "";
firmwareFilter.value = "";
searchBox.value = "";
sortColumn = "short_name";
sortAsc = true;
showOnlyFavorites = false;
@@ -577,7 +679,7 @@ document.addEventListener("DOMContentLoaded", async function() {
favoritesBtn.textContent = "⭐ Show Favorites";
favoritesBtn.classList.remove("active");
renderTable(allNodes);
applyFilters();
updateSortIcons();
}
@@ -613,6 +715,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;
@@ -627,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;
}
});
</script>
{% endblock %}