mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-07-21 00:52:25 +02:00
619 lines
18 KiB
HTML
619 lines
18 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block css %}
|
|
<style>
|
|
table {
|
|
width: 80%;
|
|
border-collapse: collapse;
|
|
margin: 1em auto;
|
|
}
|
|
|
|
/* Ensure table centered visually */
|
|
#node-list {
|
|
display: flex;
|
|
justify-content: center;
|
|
}
|
|
|
|
#node-list table {
|
|
margin-left: auto;
|
|
margin-right: auto;
|
|
}
|
|
|
|
th, td {
|
|
padding: 10px;
|
|
border: 1px solid #333;
|
|
text-align: left;
|
|
}
|
|
|
|
th {
|
|
background-color: #1f1f1f;
|
|
color: white;
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 2;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.sort-icon {
|
|
font-size: 0.7em;
|
|
margin-left: 4px;
|
|
}
|
|
|
|
tr:nth-child(even) {
|
|
background-color: #181818;
|
|
}
|
|
|
|
tr:nth-child(odd) {
|
|
background-color: #222;
|
|
}
|
|
|
|
tr:hover {
|
|
background-color: #2a2a2a;
|
|
}
|
|
|
|
.filter-container {
|
|
width: 80%;
|
|
margin: 1em auto;
|
|
display: flex;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
select, .export-btn, .search-box, .clear-btn {
|
|
padding: 6px;
|
|
border-radius: 4px;
|
|
border: 1px solid #333;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.search-box {
|
|
flex: 1;
|
|
}
|
|
|
|
.export-btn {
|
|
background-color: #28a745;
|
|
color: white;
|
|
border: none;
|
|
}
|
|
|
|
.export-btn:hover {
|
|
background-color: #218838;
|
|
}
|
|
|
|
.clear-btn {
|
|
background-color: #dc3545;
|
|
color: white;
|
|
border: none;
|
|
}
|
|
|
|
.clear-btn:hover {
|
|
background-color: #c82333;
|
|
}
|
|
|
|
.count-container {
|
|
width: 80%;
|
|
margin: 0 auto 10px;
|
|
font-weight: bold;
|
|
color: white;
|
|
}
|
|
|
|
/* Favorite stars */
|
|
.favorite-star {
|
|
cursor: pointer;
|
|
font-size: 1.2em;
|
|
user-select: none;
|
|
transition: color 0.2s;
|
|
}
|
|
|
|
.favorite-star:hover {
|
|
transform: scale(1.2);
|
|
}
|
|
|
|
.favorite-star.active {
|
|
color: #ffd700;
|
|
}
|
|
|
|
/* Favorite filter button */
|
|
.favorites-btn {
|
|
background-color: #ffd700;
|
|
color: #000;
|
|
border: none;
|
|
}
|
|
|
|
.favorites-btn:hover {
|
|
background-color: #ffed4e;
|
|
}
|
|
|
|
.favorites-btn.active {
|
|
background-color: #ff6b6b;
|
|
color: white;
|
|
}
|
|
|
|
/* --------------------------------------------- */
|
|
/* MOBILE CARD VIEW */
|
|
/* --------------------------------------------- */
|
|
@media (max-width: 768px) {
|
|
|
|
/* Hide desktop table */
|
|
#node-list table {
|
|
display: none;
|
|
}
|
|
|
|
/* Show mobile card list */
|
|
#mobile-node-list {
|
|
display: block !important;
|
|
width: 100%;
|
|
padding: 0 10px;
|
|
}
|
|
|
|
.node-card {
|
|
background: #1e1e1e;
|
|
border: 1px solid #333;
|
|
border-radius: 8px;
|
|
padding: 12px 15px;
|
|
margin-bottom: 12px;
|
|
color: white;
|
|
}
|
|
|
|
.node-card-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
font-size: 1.2em;
|
|
font-weight: bold;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.node-card-field {
|
|
margin: 4px 0;
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
.node-card-field b {
|
|
color: #9fd4ff;
|
|
}
|
|
|
|
.favorite-star {
|
|
font-size: 1.4em;
|
|
}
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block body %}
|
|
<div class="filter-container">
|
|
|
|
<input
|
|
type="text"
|
|
id="search-box"
|
|
class="search-box"
|
|
data-translate-lang="search_placeholder"
|
|
placeholder="Search by name or ID..."
|
|
/>
|
|
|
|
<select id="role-filter">
|
|
<option value="" data-translate-lang="all_roles">All Roles</option>
|
|
</select>
|
|
|
|
<select id="channel-filter">
|
|
<option value="" data-translate-lang="all_channels">All Channels</option>
|
|
</select>
|
|
|
|
<select id="hw-filter">
|
|
<option value="" data-translate-lang="all_hw">All HW Models</option>
|
|
</select>
|
|
|
|
<select id="firmware-filter">
|
|
<option value="" data-translate-lang="all_firmware">All Firmware</option>
|
|
</select>
|
|
|
|
<button class="favorites-btn" id="favorites-btn" data-translate-lang="show_favorites">
|
|
⭐ Show Favorites
|
|
</button>
|
|
|
|
<button class="export-btn" id="export-btn" data-translate-lang="export_csv">
|
|
Export CSV
|
|
</button>
|
|
|
|
<button class="clear-btn" id="clear-btn" data-translate-lang="clear_filters">
|
|
Clear Filters
|
|
</button>
|
|
</div>
|
|
|
|
<div class="count-container">
|
|
<span data-translate-lang="showing_nodes">Showing</span>
|
|
<span id="node-count">0</span>
|
|
<span data-translate-lang="nodes_suffix">nodes</span>
|
|
</div>
|
|
|
|
<!-- Desktop table -->
|
|
<div id="node-list">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th data-translate-lang="short_name">Short <span class="sort-icon">▲</span></th>
|
|
<th data-translate-lang="long_name">Long Name <span class="sort-icon"></span></th>
|
|
<th data-translate-lang="hw_model">HW Model <span class="sort-icon"></span></th>
|
|
<th data-translate-lang="firmware">Firmware <span class="sort-icon"></span></th>
|
|
<th data-translate-lang="role">Role <span class="sort-icon"></span></th>
|
|
<th data-translate-lang="last_lat">Last Latitude <span class="sort-icon"></span></th>
|
|
<th data-translate-lang="last_long">Last Longitude <span class="sort-icon"></span></th>
|
|
<th data-translate-lang="channel">Channel <span class="sort-icon"></span></th>
|
|
<th data-translate-lang="last_seen">Last Seen <span class="sort-icon"></span></th>
|
|
<th data-translate-lang="favorite"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="node-table-body">
|
|
<tr>
|
|
<td colspan="10" style="text-align:center; color:white;" data-translate-lang="loading_nodes">
|
|
Loading nodes...
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Mobile Card View -->
|
|
<div id="mobile-node-list" style="display:none;"></div>
|
|
|
|
<script>
|
|
// =====================================================
|
|
// TRANSLATIONS
|
|
// =====================================================
|
|
let nodelistTranslations = {};
|
|
|
|
function applyTranslationsNodelist() {
|
|
document.querySelectorAll("[data-translate-lang]").forEach(el => {
|
|
const key = el.dataset.translateLang;
|
|
if (nodelistTranslations[key]) {
|
|
if (el.tagName === "INPUT" && el.placeholder) {
|
|
el.placeholder = nodelistTranslations[key];
|
|
} else {
|
|
el.textContent = nodelistTranslations[key];
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async function loadTranslationsNodelist() {
|
|
try {
|
|
const cfg = await window._siteConfigPromise;
|
|
const lang = cfg?.site?.language || "en";
|
|
const res = await fetch(`/api/lang?lang=${lang}§ion=nodelist`);
|
|
nodelistTranslations = await res.json();
|
|
applyTranslationsNodelist();
|
|
} catch (err) {
|
|
console.error("Failed to load nodelist translations:", err);
|
|
}
|
|
}
|
|
|
|
// =====================================================
|
|
// GLOBALS
|
|
// =====================================================
|
|
let allNodes = [];
|
|
let sortColumn = "short_name";
|
|
let sortAsc = true;
|
|
let showOnlyFavorites = false;
|
|
|
|
const headers = document.querySelectorAll("thead th");
|
|
const keyMap = [
|
|
"short_name","long_name","hw_model","firmware","role",
|
|
"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 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);
|
|
if (mins < 60) return `${mins} min ago`;
|
|
const hrs = Math.floor(mins / 60);
|
|
if (hrs < 24) return `${hrs} hr ago`;
|
|
const days = Math.floor(hrs / 24);
|
|
return `${days} days ago`;
|
|
}
|
|
|
|
// =====================================================
|
|
// DOM LOADED
|
|
// =====================================================
|
|
document.addEventListener("DOMContentLoaded", async function() {
|
|
|
|
await loadTranslationsNodelist();
|
|
|
|
const tbody = document.getElementById("node-table-body");
|
|
const mobileList = document.getElementById("mobile-node-list");
|
|
|
|
const roleFilter = document.getElementById("role-filter");
|
|
const channelFilter = document.getElementById("channel-filter");
|
|
const hwFilter = document.getElementById("hw-filter");
|
|
const firmwareFilter = document.getElementById("firmware-filter");
|
|
const searchBox = document.getElementById("search-box");
|
|
const countSpan = document.getElementById("node-count");
|
|
const exportBtn = document.getElementById("export-btn");
|
|
const clearBtn = document.getElementById("clear-btn");
|
|
const favoritesBtn = document.getElementById("favorites-btn");
|
|
|
|
try {
|
|
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 || ""
|
|
}));
|
|
|
|
populateFilters(allNodes);
|
|
renderTable(allNodes);
|
|
updateSortIcons();
|
|
} catch (err) {
|
|
tbody.innerHTML = `<tr>
|
|
<td colspan="10" style="text-align:center; color:red;">
|
|
${nodelistTranslations.error_loading_nodes || "Error loading nodes"}
|
|
</td></tr>`;
|
|
}
|
|
|
|
roleFilter.addEventListener("change", applyFilters);
|
|
channelFilter.addEventListener("change", applyFilters);
|
|
hwFilter.addEventListener("change", applyFilters);
|
|
firmwareFilter.addEventListener("change", applyFilters);
|
|
searchBox.addEventListener("input", applyFilters);
|
|
exportBtn.addEventListener("click", exportToCSV);
|
|
clearBtn.addEventListener("click", clearFilters);
|
|
favoritesBtn.addEventListener("click", toggleFavoritesFilter);
|
|
|
|
// Favorite star click handler
|
|
document.addEventListener("click", e => {
|
|
if (e.target.classList.contains('favorite-star')) {
|
|
const nodeId = parseInt(e.target.dataset.nodeId);
|
|
const isFav = isFavorite(nodeId);
|
|
|
|
if (isFav) {
|
|
e.target.classList.remove("active");
|
|
e.target.textContent = "☆";
|
|
} else {
|
|
e.target.classList.add("active");
|
|
e.target.textContent = "★";
|
|
}
|
|
toggleFavorite(nodeId);
|
|
applyFilters();
|
|
}
|
|
});
|
|
|
|
headers.forEach((th, index) => {
|
|
th.addEventListener("click", () => {
|
|
let key = keyMap[index];
|
|
sortAsc = (sortColumn === key) ? !sortAsc : true;
|
|
sortColumn = key;
|
|
applyFilters();
|
|
});
|
|
});
|
|
|
|
function populateFilters(nodes) {
|
|
const roles = new Set(), channels = new Set(), hws = new Set(), fws = new Set();
|
|
|
|
nodes.forEach(n => {
|
|
if (n.role) roles.add(n.role);
|
|
if (n.channel) channels.add(n.channel);
|
|
if (n.hw_model) hws.add(n.hw_model);
|
|
if (n.firmware) fws.add(n.firmware);
|
|
});
|
|
|
|
fillSelect(roleFilter, roles);
|
|
fillSelect(channelFilter, channels);
|
|
fillSelect(hwFilter, hws);
|
|
fillSelect(firmwareFilter, fws);
|
|
}
|
|
|
|
function fillSelect(select, values) {
|
|
[...values].sort().forEach(v => {
|
|
const opt = document.createElement("option");
|
|
opt.value = v;
|
|
opt.textContent = v;
|
|
select.appendChild(opt);
|
|
});
|
|
}
|
|
|
|
function toggleFavoritesFilter() {
|
|
showOnlyFavorites = !showOnlyFavorites;
|
|
favoritesBtn.textContent = showOnlyFavorites
|
|
? "Show All"
|
|
: "⭐ Show Favorites";
|
|
favoritesBtn.classList.toggle("active", showOnlyFavorites);
|
|
applyFilters();
|
|
}
|
|
|
|
function applyFilters() {
|
|
const searchTerm = searchBox.value.trim().toLowerCase();
|
|
|
|
let filtered = allNodes.filter(n => {
|
|
const roleMatch = !roleFilter.value || n.role === roleFilter.value;
|
|
const channelMatch = !channelFilter.value || n.channel === channelFilter.value;
|
|
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 favMatch = !showOnlyFavorites || isFavorite(n.node_id);
|
|
|
|
return roleMatch && channelMatch && hwMatch && fwMatch && searchMatch && favMatch;
|
|
});
|
|
|
|
filtered = sortNodes(filtered, sortColumn, sortAsc);
|
|
renderTable(filtered);
|
|
updateSortIcons();
|
|
}
|
|
|
|
function renderTable(nodes) {
|
|
tbody.innerHTML = "";
|
|
mobileList.innerHTML = "";
|
|
|
|
const isMobile = window.innerWidth <= 768;
|
|
|
|
if (!nodes.length) {
|
|
tbody.innerHTML = `<tr>
|
|
<td colspan="10" style="text-align:center; color:white;">
|
|
${nodelistTranslations.no_nodes_found || "No nodes found"}
|
|
</td>
|
|
</tr>`;
|
|
|
|
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 ? "★" : "☆";
|
|
|
|
// 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);
|
|
|
|
// 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}</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
|
|
if (isMobile) {
|
|
mobileList.style.display = "block";
|
|
} else {
|
|
mobileList.style.display = "none";
|
|
}
|
|
|
|
countSpan.textContent = nodes.length;
|
|
}
|
|
|
|
function clearFilters() {
|
|
roleFilter.value = "";
|
|
channelFilter.value = "";
|
|
hwFilter.value = "";
|
|
firmwareFilter.value = "";
|
|
searchBox.value = "";
|
|
sortColumn = "short_name";
|
|
sortAsc = true;
|
|
showOnlyFavorites = false;
|
|
|
|
favoritesBtn.textContent = "⭐ Show Favorites";
|
|
favoritesBtn.classList.remove("active");
|
|
|
|
renderTable(allNodes);
|
|
updateSortIcons();
|
|
}
|
|
|
|
function exportToCSV() {
|
|
const rows = [];
|
|
const headerList = Array.from(headers).map(h =>
|
|
`"${h.innerText.replace(/▲|▼/g,'')}"`
|
|
);
|
|
rows.push(headerList.join(","));
|
|
|
|
const trs = tbody.querySelectorAll("tr");
|
|
trs.forEach(tr => {
|
|
const cells = Array.from(tr.children).map(td =>
|
|
`"${td.innerText.replace(/"/g,'""')}"`
|
|
);
|
|
rows.push(cells.join(","));
|
|
});
|
|
|
|
const csv = "data:text/csv;charset=utf-8,\uFEFF" + rows.join("\n");
|
|
const a = document.createElement("a");
|
|
a.href = encodeURI(csv);
|
|
a.download = "nodelist.csv";
|
|
a.click();
|
|
}
|
|
|
|
function sortNodes(nodes, key, asc) {
|
|
return [...nodes].sort((a, b) => {
|
|
let A = a[key];
|
|
let B = b[key];
|
|
|
|
if (key === "last_seen_us") {
|
|
A = A || 0;
|
|
B = B || 0;
|
|
}
|
|
|
|
if (A < B) return asc ? -1 : 1;
|
|
if (A > B) return asc ? 1 : -1;
|
|
return 0;
|
|
});
|
|
}
|
|
|
|
function updateSortIcons() {
|
|
headers.forEach((th, i) => {
|
|
const span = th.querySelector(".sort-icon");
|
|
if (!span) return;
|
|
span.textContent =
|
|
keyMap[i] === sortColumn ? (sortAsc ? "▲" : "▼") : "";
|
|
});
|
|
}
|
|
});
|
|
</script>
|
|
{% endblock %}
|