Files
meshview/meshview/templates/nodelist.html
T

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}&section=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 %}