26 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
Pablo Revilla
e48e9464d7 Modify packet.html to add distance 2026-01-03 21:48:19 -08:00
Pablo Revilla
b72bc5d52b Modify packet.html to add distance 2026-01-03 21:44:26 -08:00
Pablo Revilla
1220f0bcbd Modify node.html to add statistics 2026-01-03 21:28:33 -08:00
Pablo Revilla
539410d5bb Modify node.html to add statistics 2026-01-03 21:26:39 -08:00
Pablo Revilla
383b576d18 Modify node.html to add statistics 2026-01-03 21:12:24 -08:00
Pablo Revilla
64a55a3ef3 Modify node.html to add statistics 2026-01-03 20:51:17 -08:00
Pablo Revilla
9408201e57 Modify node.html to add statistics 2026-01-03 19:27:00 -08:00
Pablo Revilla
f75d6bf749 Modify node.html to add statistics 2026-01-03 19:00:39 -08:00
Pablo Revilla
924d223866 Modify node.html to add statistics 2026-01-03 18:13:57 -08:00
Pablo Revilla
e9dcca1f19 Modify node.html to add statistics 2025-12-31 11:58:45 -08:00
Pablo Revilla
00cc2abd23 Modify node.html to add statistics 2025-12-31 11:56:18 -08:00
Pablo Revilla
b76477167d Modify top.html to add paging 2025-12-31 11:13:52 -08:00
Pablo Revilla
b41b249a6d Modify top.html to add paging 2025-12-31 10:38:13 -08:00
Pablo Revilla
71fcda2dd6 Modify top.html to add paging 2025-12-30 09:27:51 -08:00
Pablo Revilla
c4453fbb31 Modify packet.html to sort by hop count. 2025-12-24 10:54:09 -08:00
Pablo Revilla
79fa3f66a8 Fix chart on node.html. 2025-12-24 10:06:17 -08:00
Pablo Revilla
0ce64ac975 Fix chart on node.html. 2025-12-10 09:56:30 -08:00
9 changed files with 1180 additions and 516 deletions

View File

@@ -87,12 +87,13 @@ Samples of currently running instances:
- https://map.wpamesh.net (Western Pennsylvania)
- https://meshview.chicagolandmesh.org (Chicago)
- https://meshview.mt.gt (Canadaverse)
- https://canadaverse.org (Canadaverse)
- https://meshview.meshtastic.es (Spain)
- https://view.mtnme.sh (North Georgia / East Tennessee)
- https://meshview.lsinfra.de (Hessen - Germany)
- https://map.nswmesh.au (Sydney - Australia)
- https://meshview.pvmesh.org (Pioneer Valley, Massachusetts)
- https://meshview.louisianamesh.org (Louisiana)
- https://www.swlamesh.com/map (Southwest Louisiana)
- https://meshview.meshcolombia.co/ (Colombia)
- https://meshview-salzburg.jmt.gr/ (Salzburg / Austria)
---

View File

@@ -179,7 +179,11 @@
"to": "To",
"port": "Port",
"direct_to_mqtt": "Direct to MQTT",
"all_broadcast": "All"
"all_broadcast": "All",
"statistics": "Statistics",
"last_24h": "24h",
"packets_sent": "Packets sent",
"times_seen": "Times seen"
},
"packet": {
"loading": "Loading packet information...",

View File

@@ -164,7 +164,11 @@
"to": "A",
"port": "Puerto",
"direct_to_mqtt": "Directo a MQTT",
"all_broadcast": "Todos"
"all_broadcast": "Todos",
"statistics": "Estadísticas",
"last_24h": "24h",
"packets_sent": "Paquetes enviados",
"times_seen": "Veces visto"
},
"packet": {

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

View File

@@ -143,19 +143,29 @@
<!-- Node Info -->
<div id="node-info" class="node-info">
<div><strong data-translate-lang="node_id">Node ID</strong><strong>:</strong><span id="info-node-id"></span></div>
<div><strong data-translate-lang="long_name">Long Name</strong><strong>:</strong> <span id="info-long-name"></span></div>
<div><strong data-translate-lang="short_name">Short Name</strong><strong>:</strong> <span id="info-short-name"></span></div>
<div><strong data-translate-lang="node_id">Node ID</strong><strong>: </strong><span id="info-node-id"></span></div>
<div><strong data-translate-lang="id">Hex ID</strong><strong>: </strong><span id="info-id"></span></div>
<div><strong data-translate-lang="long_name">Long Name</strong><strong>: </strong> <span id="info-long-name"></span></div>
<div><strong data-translate-lang="short_name">Short Name</strong><strong>: </strong> <span id="info-short-name"></span></div>
<div><strong data-translate-lang="hw_model">Hardware Model</strong><strong>:</strong> <span id="info-hw-model"></span></div>
<div><strong data-translate-lang="firmware">Firmware</strong><strong>:</strong> <span id="info-firmware"></span></div>
<div><strong data-translate-lang="role">Role</strong><strong>:</strong> <span id="info-role"></span></div>
<div><strong data-translate-lang="hw_model">Hardware Model</strong><strong>: </strong> <span id="info-hw-model"></span></div>
<div><strong data-translate-lang="firmware">Firmware</strong><strong>: </strong> <span id="info-firmware"></span></div>
<div><strong data-translate-lang="role">Role</strong><strong>: </strong> <span id="info-role"></span></div>
<div><strong data-translate-lang="channel">Channel</strong><strong>: </strong> <span id="info-channel"></span></div>
<div><strong data-translate-lang="latitude">Latitude</strong><strong>: </strong> <span id="info-lat"></span></div>
<div><strong data-translate-lang="longitude">Longitude</strong><strong>: </strong> <span id="info-lon"></span></div>
<div><strong data-translate-lang="last_update">Last Update</strong><strong>: </strong> <span id="info-last-update"></span></div>
<div>
<strong data-translate-lang="statistics">Statistics</strong><strong>: </strong>
<span id="info-stats"
data-label-24h="24h"
data-label-sent="Packets sent"
data-label-seen="Times seen"></span>
</div>
<div><strong data-translate-lang="channel">Channel</strong><strong>:</strong> <span id="info-channel"></span></div>
<div><strong data-translate-lang="latitude">Latitude</strong><strong>:</strong> <span id="info-lat"></span></div>
<div><strong data-translate-lang="longitude">Longitude</strong><strong>:</strong> <span id="info-lon"></span></div>
<div><strong data-translate-lang="last_update">Last Update</strong><strong>:</strong> <span id="info-last-update"></span></div>
</div>
<!-- Map. -->
@@ -210,18 +220,55 @@
<div id="chart_neighbors" style="height:380px;"></div>
</div>
<!-- Packet Histogram -->
<div id="packet_histogram_container" class="chart-container">
<div class="chart-header">
📊 <span data-translate-lang="packets_per_day">Packets per Day (Last 7 Days)</span>
<div class="chart-actions">
<button onclick="expandChart('packet_histogram')" data-translate-lang="expand">Expand</button>
<button onclick="exportCSV('packet_histogram')" data-translate-lang="export_csv">Export CSV</button>
</div>
</div>
<div id="chart_packet_histogram" style="height:380px;"></div>
</div>
<!-- Packet Filters -->
<div class="filter-container" style="margin-bottom:10px; display:flex; gap:12px; flex-wrap:wrap;">
<select id="packet_since">
<option value="">All time</option>
<option value="3600">Last hour</option>
<option value="21600">Last 6 hours</option>
<option value="86400">Last 24 hours</option>
<option value="172800">Last 2 days</option>
<option value="259200">Last 3 days</option>
<option value="432000">Last 5 days</option>
<option value="604800">Last 7 days</option>
</select>
<select id="packet_port">
<option value="">All ports</option>
</select>
<button onclick="reloadPackets()">Apply</button>
<button onclick="exportPacketsCSV()">Export CSV</button>
</div>
<!-- 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>
@@ -240,6 +287,33 @@
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
<script>
const PORT_COLOR_MAP = {
0: "#6c757d",
1: "#007bff",
3: "#28a745",
4: "#ffc107",
5: "#dc3545",
6: "#20c997",
65: "#6610f2",
67: "#17a2b8",
70: "#ff9800",
71: "#ff66cc",
};
const PORT_LABEL_MAP = {
0: "UNKNOWN",
1: "Text",
3: "Position",
4: "Node Info",
5: "Routing",
6: "Admin",
65: "Store & Forward",
67: "Telemetry",
70: "Traceroute",
71: "Neighbor"
};
/* ======================================================
NODE PAGE TRANSLATION (isolated from base)
====================================================== */
@@ -344,6 +418,7 @@ let nodeMap = {}; // node_id -> label
let nodePositions = {}; // node_id -> [lat, lon]
let nodeCache = {}; // node_id -> full node object
let currentNode = null;
let currentPacketRows = [];
let map, markers = {};
let chartData = {}, neighborData = { ids:[], names:[], snrs:[] };
@@ -406,6 +481,7 @@ async function loadNodeInfo(){
nodeMap[fromNodeId] || fromNodeId;
// Info card
document.getElementById("info-id").textContent = node.id ?? "—";
document.getElementById("info-node-id").textContent = node.node_id ?? "—";
document.getElementById("info-long-name").textContent = node.long_name ?? "—";
document.getElementById("info-short-name").textContent = node.short_name ?? "—";
@@ -424,6 +500,7 @@ async function loadNodeInfo(){
lastSeen = formatLastSeen(node.last_seen_us);
}
document.getElementById("info-last-update").textContent = lastSeen;
loadNodeStats(node.node_id);
} catch (err) {
console.error("Failed to load node info:", err);
document.getElementById("node-info").style.display = "none";
@@ -457,47 +534,27 @@ function nodeLink(id, labelOverride = null) {
</a>`;
}
function initPacketPortFilter() {
const sel = document.getElementById("packet_port");
if (!sel) return;
Object.keys(PORT_LABEL_MAP)
.map(Number)
.sort((a, b) => a - b)
.forEach(p => {
const opt = document.createElement("option");
opt.value = p;
opt.textContent = `${PORT_LABEL_MAP[p]} (${p})`;
sel.appendChild(opt);
});
}
/* ======================================================
PORT LABELS
====================================================== */
function portLabel(p) {
const PORT_COLOR_MAP = {
0: "#6c757d",
1: "#007bff",
3: "#28a745",
4: "#ffc107",
5: "#dc3545",
6: "#20c997",
65: "#6610f2",
67: "#17a2b8",
68: "#fd7e14",
69: "#6f42c1",
70: "#ff4444",
71: "#ff66cc",
72: "#00cc99",
73: "#9999ff",
74: "#cc00cc",
75: "#ffbb33",
76: "#00bcd4",
77: "#8bc34a",
78: "#795548"
};
const PORT_LABEL_MAP = {
0: "UNKNOWN",
1: "Text",
3: "Position",
4: "Node Info",
5: "Routing",
6: "Admin",
65: "Store & Forward",
67: "Telemetry",
70: "Traceroute",
71: "Neighbor"
};
const color = PORT_COLOR_MAP[p] || "#6c757d";
const label = PORT_LABEL_MAP[p] || `Port ${p}`;
@@ -511,6 +568,7 @@ function portLabel(p) {
`;
}
/* ======================================================
MAP SETUP
====================================================== */
@@ -552,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);
@@ -564,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(() => {
@@ -679,72 +752,86 @@ async function loadTrack(){
PACKETS TABLE + NEIGHBOR OVERLAY
====================================================== */
async function loadPackets(){
async function loadPackets(filters = {}) {
const list = document.getElementById("packet_list");
list.innerHTML = "";
const url = new URL("/api/packets", window.location.origin);
url.searchParams.set("node_id", fromNodeId); // node_id includes to/from
url.searchParams.set("limit", 200);
url.searchParams.set("node_id", fromNodeId);
url.searchParams.set("limit", 1000);
if (filters.since) {
url.searchParams.set("since", filters.since);
}
if (filters.portnum) {
url.searchParams.set("portnum", filters.portnum);
}
const res = await fetch(url);
if (!res.ok) return;
const data = await res.json();
const list = document.getElementById("packet_list");
const packets = data.packets || [];
currentPacketRows = packets;
for (const pkt of (data.packets || []).reverse()) {
const safePayload = (pkt.payload || "").replace(/[<>]/g, m => m === "<" ? "&lt;" : "&gt;");
const localTime = formatLocalTime(pkt.import_time_us);
const fromCell = nodeLink(pkt.from_node_id,pkt.long_name);
const toCell = nodeLink(pkt.to_node_id, pkt.to_long_name);
for (const pkt of packets.reverse()) {
// Neighbor packets (port 71) → draw neighbors on map
if (pkt.portnum === 71 && pkt.payload) {
const nids = [];
const re = /neighbors\s*\{\s*node_id:\s*(\d+)/g;
let m;
while ((m = re.exec(pkt.payload)) !== null) {
nids.push(parseInt(m[1]));
}
if (nids.length && map) {
await drawNeighbors(pkt.from_node_id, nids);
}
}
// ================================
// TABLE ROW
// ================================
const safePayload = (pkt.payload || "")
.replace(/[<>]/g, m => (m === "<" ? "&lt;" : "&gt;"));
const localTime = formatLocalTime(pkt.import_time_us);
const fromCell = nodeLink(pkt.from_node_id, pkt.long_name);
const toCell = nodeLink(pkt.to_node_id, pkt.to_long_name);
let inlineLinks = "";
// Position link (Google Maps)
if (pkt.portnum === 3 && pkt.payload) {
const latMatch = pkt.payload.match(/latitude_i:\s*(-?\d+)/);
const lonMatch = pkt.payload.match(/longitude_i:\s*(-?\d+)/);
if (latMatch && lonMatch) {
const lat = parseFloat(latMatch[1]) / 1e7;
const lon = parseFloat(lonMatch[1]) / 1e7;
inlineLinks += ` <a class="inline-link" href="https://www.google.com/maps?q=${lat},${lon}" target="_blank">📍</a>`;
inlineLinks +=
` <a class="inline-link" href="https://www.google.com/maps?q=${lat},${lon}" target="_blank">📍</a>`;
}
}
// Traceroute link
if (pkt.portnum === 70) {
let traceId = pkt.id;
const match = pkt.payload?.match(/ID:\s*(\d+)/i);
if (match) traceId = match[1];
inlineLinks += ` <a class="inline-link" href="/graph/traceroute/${traceId}" target="_blank">⮕</a>`;
inlineLinks +=
` <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)
====================================================== */
@@ -907,46 +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;
}
// --- FIX #1: enforce chronological order (oldest → newest) ---
// 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];
@@ -958,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: []
};
@@ -971,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",
@@ -1024,6 +1153,100 @@ async function loadNeighborTimeSeries() {
async function loadPacketHistogram() {
const DAYS = 7;
const now = new Date();
const dayKeys = [];
const dayLabels = [];
for (let i = DAYS - 1; i >= 0; i--) {
const d = new Date(now);
d.setDate(d.getDate() - i);
dayKeys.push(d.toISOString().slice(0, 10));
dayLabels.push(
d.toLocaleDateString([], { month: "short", day: "numeric" })
);
}
const url = new URL("/api/packets", window.location.origin);
url.searchParams.set("node_id", fromNodeId);
// last 7 days only (microseconds)
const sinceUs = Date.now() * 1000 - (7 * 24 * 60 * 60 * 1_000_000);
url.searchParams.set("since", sinceUs);
// modest safety limit (still applies after server-side filter)
url.searchParams.set("limit", 2000);
const res = await fetch(url);
if (!res.ok) return;
const packets = (await res.json()).packets || [];
const counts = {}; // { port: { day: count } }
const ports = new Set();
for (const pkt of packets) {
if (!pkt.import_time_us) continue;
const day = new Date(pkt.import_time_us / 1000)
.toISOString()
.slice(0, 10);
if (!dayKeys.includes(day)) continue;
const port = pkt.portnum ?? 0;
ports.add(port);
counts[port] ??= {};
counts[port][day] = (counts[port][day] || 0) + 1;
}
if (!ports.size) {
document.getElementById("packet_histogram_container").style.display = "none";
return;
}
const series = Array.from(ports)
.sort((a, b) => a - b)
.map(port => ({
name: PORT_LABEL_MAP[port] || `Port ${port}`,
type: "bar",
stack: "total",
barMaxWidth: 42,
itemStyle: {
color: PORT_COLOR_MAP[port] || "#888"
},
data: dayKeys.map(d => counts[port]?.[d] || 0)
}));
const chart = echarts.init(
document.getElementById("chart_packet_histogram")
);
chart.setOption({
animation: false,
tooltip: { trigger: "axis" },
legend: { textStyle: { color: "#ccc" } },
xAxis: {
type: "category",
data: dayLabels,
axisLabel: { color: "#ccc" }
},
yAxis: {
type: "value",
axisLabel: { color: "#ccc" }
},
series
});
window.addEventListener("resize", () => chart.resize());
}
/* ======================================================
EXPAND / EXPORT BUTTONS
====================================================== */
@@ -1103,15 +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);
@@ -1119,5 +1356,109 @@ 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(
`/api/stats/count?from_node=${nodeId}&period_type=day&length=1`
);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const data = await res.json();
const packets = data?.total_packets ?? 0;
const seen = data?.total_seen ?? 0;
document.getElementById("info-stats").textContent =
`24h · Packets sent: ${packets.toLocaleString()} · Times seen: ${seen.toLocaleString()} `;
} catch (err) {
console.error("Failed to load node stats:", err);
document.getElementById("info-stats").textContent = "—";
}
}
function reloadPackets() {
const sinceSel = document.getElementById("packet_since").value;
const portSel = document.getElementById("packet_port").value;
const filters = {};
if (sinceSel) {
const sinceUs = Date.now() * 1000 - (parseInt(sinceSel, 10) * 1_000_000);
filters.since = sinceUs;
}
if (portSel) {
filters.portnum = portSel;
}
loadPackets(filters);
}
function exportPacketsCSV() {
if (!currentPacketRows.length) {
alert("No packets to export.");
return;
}
const rows = [
["Time", "Packet ID", "From Node", "To Node", "Port", "Port Name", "Payload"]
];
for (const pkt of currentPacketRows) {
const time = pkt.import_time_us
? new Date(pkt.import_time_us / 1000).toISOString()
: "";
const portName = PORT_LABEL_MAP[pkt.portnum] || `Port ${pkt.portnum}`;
// Escape quotes + line breaks for CSV safety
const payload = (pkt.payload || "")
.replace(/"/g, '""')
.replace(/\r?\n/g, " ");
rows.push([
time,
pkt.id,
pkt.from_node_id,
pkt.to_node_id,
pkt.portnum,
portName,
`"${payload}"`
]);
}
const csv = rows.map(r => r.join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = `packets_${fromNodeId}_${Date.now()}.csv`;
link.click();
}
</script>
{% endblock %}

View File

@@ -2,23 +2,30 @@
{% block css %}
<style>
html, body {
overflow-x: auto !important;
}
table {
width: 80%;
/* FIX: allow table to keep natural width so scrolling works */
width: max-content;
min-width: 100%;
border-collapse: collapse;
margin: 1em auto;
}
/* Ensure table centered visually */
/* Desktop scroll wrapper */
#node-list {
display: flex;
justify-content: center;
width: 100%;
overflow-x: auto; /* allows horizontal scroll */
overflow-y: hidden;
/* !!! removed display:flex because it prevents scrolling */
}
#node-list table {
margin-left: auto;
margin-right: auto;
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;
@@ -96,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 {
@@ -134,16 +156,20 @@ select, .export-btn, .search-box, .clear-btn {
/* --------------------------------------------- */
@media (max-width: 768px) {
/* Hide desktop table */
/* Hide desktop view */
#node-list table {
display: none;
}
/* Show mobile card list */
/* Show mobile cards */
#mobile-node-list {
display: block !important;
width: 100%;
padding: 0 10px;
/* If you want horizontal swiping, uncomment:
overflow-x: auto;
white-space: nowrap; */
}
.node-card {
@@ -188,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">
@@ -224,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 -->
@@ -294,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 = [
@@ -301,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);
@@ -339,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");
@@ -349,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();
}
@@ -402,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();
@@ -443,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 => {
@@ -452,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() {
@@ -556,6 +671,7 @@ document.addEventListener("DOMContentLoaded", async function() {
hwFilter.value = "";
firmwareFilter.value = "";
searchBox.value = "";
sortColumn = "short_name";
sortAsc = true;
showOnlyFavorites = false;
@@ -563,7 +679,7 @@ document.addEventListener("DOMContentLoaded", async function() {
favoritesBtn.textContent = "⭐ Show Favorites";
favoritesBtn.classList.remove("active");
renderTable(allNodes);
applyFilters();
updateSortIcons();
}
@@ -599,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;
@@ -613,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 %}

View File

@@ -380,103 +380,150 @@ document.addEventListener("DOMContentLoaded", async () => {
}
/* ---------------------------------------------
Load packets_seen
----------------------------------------------*/
const seenRes = await fetch(`/api/packets_seen/${packetId}`);
const seenData = await seenRes.json();
const seenList = seenData.seen ?? [];
Load packets_seen
----------------------------------------------*/
const seenRes = await fetch(`/api/packets_seen/${packetId}`);
const seenData = await seenRes.json();
const seenList = seenData.seen ?? [];
const seenSorted = seenList.slice().sort((a,b)=>{
return (b.hop_start ?? -999) - (a.hop_start ?? -999);
});
/* ---------------------------------------------
Sort by hop count (highest first)
----------------------------------------------*/
const seenSorted = seenList.slice().sort((a,b)=>{
const ha = (a.hop_start ?? 0) - (a.hop_limit ?? 0);
const hb = (b.hop_start ?? 0) - (b.hop_limit ?? 0);
return hb - ha;
});
if (seenSorted.length){
seenContainer.classList.remove("d-none");
seenCountSpan.textContent = `(${seenSorted.length})`;
}
if (seenSorted.length){
seenContainer.classList.remove("d-none");
seenCountSpan.textContent = `(${seenSorted.length})`;
}
/* ---------------------------------------------
Render gateway table + map markers
----------------------------------------------*/
seenTableBody.innerHTML = seenSorted.map(s=>{
const node = nodeLookup[s.node_id];
const label = node?.long_name || s.node_id;
/* ---------------------------------------------
GROUP BY HOP COUNT
----------------------------------------------*/
const hopGroups = {};
const timeStr = s.import_time_us
? new Date(s.import_time_us/1000).toLocaleTimeString()
: "—";
seenSorted.forEach(s => {
const hopValue = Math.max(
0,
(s.hop_start ?? 0) - (s.hop_limit ?? 0)
);
if (!hopGroups[hopValue]) hopGroups[hopValue] = [];
hopGroups[hopValue].push(s);
});
if (node?.last_lat && node.last_long){
const rlat = node.last_lat/1e7;
const rlon = node.last_long/1e7;
allBounds.push([rlat, rlon]);
/* ---------------------------------------------
Render grouped gateway table + map markers
----------------------------------------------*/
seenTableBody.innerHTML = Object.keys(hopGroups)
.sort((a,b) => Number(a) - Number(b)) // 0 hop first
.map(hopKey => {
const hopValue = (s.hop_start ?? 0) - (s.hop_limit ?? 0);
const color = hopColor(hopValue);
const hopLabel =
hopKey === "0"
? (packetTranslations.direct || "Direct (0 hops)")
: `${hopKey} ${packetTranslations.hops || "hops"}`;
const marker = L.marker([rlat,rlon],{
icon: L.divIcon({
html: `
<div style="
background:${color};
width:24px; height:24px;
border-radius:50%;
display:flex;
align-items:center;
justify-content:center;
color:white;
font-size:11px;
font-weight:700;
border:2px solid rgba(0,0,0,0.35);
box-shadow:0 0 5px rgba(0,0,0,0.45);
">${hopValue}</div>`,
className: "",
iconSize:[24,24],
iconAnchor:[12,12]
})
}).addTo(map);
const rows = hopGroups[hopKey].map(s => {
const node = nodeLookup[s.node_id];
const label = node?.long_name || s.node_id;
let distKm = null, distMi = null;
if (srcLat && srcLon){
distKm = haversine(srcLat, srcLon, rlat, rlon);
distMi = distKm * 0.621371;
}
const timeStr = s.import_time_us
? new Date(s.import_time_us/1000).toLocaleTimeString()
: "—";
marker.bindPopup(`
/* ---------------- MAP MARKERS (UNCHANGED) ---------------- */
if (node?.last_lat && node.last_long){
const rlat = node.last_lat/1e7;
const rlon = node.last_long/1e7;
allBounds.push([rlat, rlon]);
let distanceKm = null;
if (srcLat && srcLon) {
distanceKm = haversine(srcLat, srcLon, rlat, rlon);
}
const distanceMi = distanceKm !== null ? distanceKm * 0.621371 : null;
const color = hopColor(hopKey);
const marker = L.marker([rlat,rlon],{
icon: L.divIcon({
html: `
<div style="
background:${color};
width:24px; height:24px;
border-radius:50%;
display:flex;
align-items:center;
justify-content:center;
color:white;
font-size:11px;
font-weight:700;
border:2px solid rgba(0,0,0,0.35);
box-shadow:0 0 5px rgba(0,0,0,0.45);
">${hopKey}</div>`,
className: "",
iconSize:[24,24],
iconAnchor:[12,12]
})
}).addTo(map);
marker.bindPopup(`
<div style="font-size:0.9em">
<b>${label}</b><br>
<span data-translate-lang="node_id_short">${packetTranslations.node_id_short || "Node ID"}</span>:
<span data-translate-lang="node_id_short">Node ID</span>:
<a href="/node/${s.node_id}">${s.node_id}</a><br>
HW: ${node?.hw_model ?? "—"}<br>
<span data-translate-lang="channel">${packetTranslations.channel || "Channel"}</span>: ${s.channel ?? "—"}<br><br>
<span data-translate-lang="channel">Channel</span>: ${s.channel ?? "—"}<br>
${
distanceKm !== null
? `<span data-translate-lang="distance">Distance</span>:
${distanceKm.toFixed(1)} km / ${distanceMi.toFixed(1)} mi<br>`
: ""
}
<b data-translate-lang="signal">${packetTranslations.signal || "Signal"}</b><br>
<br>
<b data-translate-lang="signal">Signal</b><br>
RSSI: ${s.rx_rssi ?? "—"}<br>
SNR: ${s.rx_snr ?? "—"}<br><br>
<b data-translate-lang="hops">${packetTranslations.hops || "Hops"}</b>: ${hopValue}<br>
<b data-translate-lang="distance">${packetTranslations.distance || "Distance"}:</b><br>
${
distKm
? `${distKm.toFixed(2)} km (${distMi.toFixed(2)} mi)`
: "—"
}
<b data-translate-lang="hops">Hops</b>: ${hopKey}
</div>
`);
}
}
return `
<tr>
<td><a href="/node/${s.node_id}">${label}</a></td>
<td>${s.rx_rssi ?? "—"}</td>
<td>${s.rx_snr ?? "—"}</td>
<td>${hopKey}</td>
<td>${s.channel ?? "—"}</td>
<td>${timeStr}</td>
</tr>
`;
}).join("");
return `
<tr>
<td><a href="/node/${s.node_id}">${label}</a></td>
<td>${s.rx_rssi ?? "—"}</td>
<td>${s.rx_snr ?? "—"}</td>
<td>${s.hop_start ?? "—"}${s.hop_limit ?? "—"}</td>
<td>${s.channel ?? "—"}</td>
<td>${timeStr}</td>
</tr>`;
<td colspan="6"
style="
background:#1f2327;
font-weight:700;
color:#9ecbff;
border-top:1px solid #444;
padding:8px 12px;
">
🔁 ${hopLabel} (${hopGroups[hopKey].length})
</td>
</tr>
${rows}
`;
}).join("");
/* ---------------------------------------------
Fit map around all markers
----------------------------------------------*/

View File

@@ -39,7 +39,8 @@
}
table th { background-color: #333; }
table tbody tr:nth-child(odd) { background-color: #272b2f; }
table tbody tr:nth-child(odd) { background-color: #272b2f; }
table tbody tr:nth-child(even) { background-color: #212529; }
table tbody tr:hover { background-color: #555; cursor: pointer; }
@@ -50,8 +51,15 @@
.node-link:hover { text-decoration: underline; }
.good-x { color: #81ff81; font-weight: bold; }
.ok-x { color: #e8e86d; font-weight: bold; }
.bad-x { color: #ff6464; font-weight: bold; }
.ok-x { color: #e8e86d; font-weight: bold; }
.bad-x { color: #ff6464; font-weight: bold; }
.pagination {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 15px;
}
</style>
{% endblock %}
@@ -63,47 +71,42 @@
<div class="filter-bar">
<div>
<label for="channelFilter" data-translate-lang="channel">Channel:</label>
<select id="channelFilter" class="form-select form-select-sm" style="width:auto;"></select>
</div>
<div>
<label for="nodeSearch" data-translate-lang="search">Search:</label>
<input id="nodeSearch" type="text" class="form-control form-control-sm"
placeholder="Search nodes..."
data-translate-lang="search_placeholder"
style="width:180px; display:inline-block;">
<label data-translate-lang="channel">Channel:</label>
<select id="channelFilter"></select>
</div>
</div>
<!-- ⭐ ADDED NODE COUNT ⭐ -->
<div id="count-container" style="margin-bottom:10px; font-weight:bold;">
<div style="margin-bottom:10px;font-weight:bold;">
<span data-translate-lang="showing_nodes">Showing</span>
<span id="node-count">0</span>
<span data-translate-lang="nodes_suffix">nodes</span>
</div>
<div class="table-responsive">
<table id="nodesTable">
<thead>
<tr>
<th data-translate-lang="long_name">Long Name</th>
<th data-translate-lang="short_name">Short Name</th>
<th data-translate-lang="channel">Channel</th>
<th data-translate-lang="packets_sent">Sent (24h)</th>
<th data-translate-lang="times_seen">Seen (24h)</th>
<th data-translate-lang="avg_gateways">Avg Gateways</th>
</tr>
</thead>
<tbody></tbody>
</table>
<table id="nodesTable">
<thead>
<tr>
<th data-translate-lang="long_name">Long Name</th>
<th data-translate-lang="short_name">Short Name</th>
<th data-translate-lang="channel">Channel</th>
<th data-translate-lang="packets_sent">Sent (24h)</th>
<th data-translate-lang="times_seen">Seen (24h)</th>
<th data-translate-lang="avg_gateways">Avg Gateways</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div class="pagination">
<button id="prevPage" class="btn btn-sm btn-secondary">Prev</button>
<span id="pageInfo"></span>
<button id="nextPage" class="btn btn-sm btn-secondary">Next</button>
</div>
</div>
<script>
/* ======================================================
TOP PAGE TRANSLATION (isolated from base)
TRANSLATIONS
====================================================== */
let topTranslations = {};
@@ -111,198 +114,127 @@ function applyTranslationsTop(dict, root=document) {
root.querySelectorAll("[data-translate-lang]").forEach(el => {
const key = el.dataset.translateLang;
if (!dict[key]) return;
// input placeholder support
if (el.tagName === "INPUT" && el.placeholder !== undefined) {
el.placeholder = dict[key];
} else {
el.textContent = dict[key];
}
el.textContent = dict[key];
});
}
async function loadTranslationsTop() {
try {
const cfg = await window._siteConfigPromise;
const lang = cfg?.site?.language || "en";
const res = await fetch(`/api/lang?lang=${lang}&section=top`);
topTranslations = await res.json();
applyTranslationsTop(topTranslations);
} catch (err) {
console.error("TOP translation load failed:", err);
}
const cfg = await window._siteConfigPromise;
const lang = cfg?.site?.language || "en";
const res = await fetch(`/api/lang?lang=${lang}&section=top`);
topTranslations = await res.json();
applyTranslationsTop(topTranslations);
}
/* ======================================================
PAGE LOGIC
CONFIG
====================================================== */
let allNodes = [];
async function loadChannels() {
try {
const res = await fetch("/api/channels");
const data = await res.json();
const channels = data.channels || [];
const select = document.getElementById("channelFilter");
// LongFast first
if (channels.includes("LongFast")) {
const opt = document.createElement("option");
opt.value = "LongFast";
opt.textContent = "LongFast";
select.appendChild(opt);
}
for (const ch of channels) {
if (ch === "LongFast") continue;
const opt = document.createElement("option");
opt.value = ch;
opt.textContent = ch;
select.appendChild(opt);
}
select.addEventListener("change", renderTable);
} catch (err) {
console.error("Error loading channels:", err);
}
}
async function loadNodes() {
try {
const res = await fetch("/api/nodes");
const data = await res.json();
allNodes = data.nodes || [];
} catch (err) {
console.error("Error loading nodes:", err);
}
}
async function fetchNodeStats(nodeId) {
try {
const res = await fetch(`/api/stats/count?from_node=${nodeId}&period_type=day&length=1`);
const data = await res.json();
const sent = data.total_packets || 0;
const seen = data.total_seen || 0;
const avg = seen / Math.max(sent, 1);
return { sent, seen, avg };
} catch (err) {
console.error("Stat error:", err);
return { sent: 0, seen: 0, avg: 0 };
}
}
const PAGE_SIZE = 20;
let currentPage = 0;
let totalRows = 0;
/* ======================================================
HELPERS
====================================================== */
function avgClass(v) {
if (v >= 10) return "good-x";
if (v >= 2) return "ok-x";
if (v >= 2) return "ok-x";
return "bad-x";
}
/* ======================================================
LOAD CHANNELS
====================================================== */
async function loadChannels() {
const res = await fetch("/api/channels");
const data = await res.json();
const sel = document.getElementById("channelFilter");
sel.innerHTML = "";
for (const ch of data.channels || []) {
const opt = document.createElement("option");
opt.value = ch;
opt.textContent = ch;
sel.appendChild(opt);
}
sel.value = "MediumFast";
}
/* ======================================================
FETCH + RENDER
====================================================== */
async function renderTable() {
const tbody = document.querySelector("#nodesTable tbody");
tbody.innerHTML = "";
const channel = document.getElementById("channelFilter").value;
const searchText = document.getElementById("nodeSearch").value.trim().toLowerCase();
const offset = currentPage * PAGE_SIZE;
// Filter by channel
let filtered = allNodes.filter(n => n.channel === channel);
const url = new URL("/api/stats/top", window.location.origin);
url.searchParams.set("limit", PAGE_SIZE);
url.searchParams.set("offset", offset);
if (channel) url.searchParams.set("channel", channel);
// Filter by search
if (searchText !== "") {
filtered = filtered.filter(n =>
(n.long_name && n.long_name.toLowerCase().includes(searchText)) ||
(n.short_name && n.short_name.toLowerCase().includes(searchText)) ||
String(n.node_id).includes(searchText)
);
const res = await fetch(url);
const data = await res.json();
totalRows = data.total || 0;
for (const n of data.nodes || []) {
const tr = document.createElement("tr");
tr.onclick = () => location.href = `/node/${n.node_id}`;
tr.innerHTML = `
<td>
<a class="node-link" href="/node/${n.node_id}"
onclick="event.stopPropagation()">
${n.long_name || n.node_id}
</a>
</td>
<td>${n.short_name || ""}</td>
<td>${n.channel || ""}</td>
<td>${n.sent}</td>
<td>${n.seen}</td>
<td><span class="${avgClass(n.avg)}">${n.avg.toFixed(1)}</span></td>
`;
tbody.appendChild(tr);
}
// Placeholder rows first
const rowRefs = filtered.map(n => {
const tr = document.createElement("tr");
tr.addEventListener("click", () => window.location.href = `/node/${n.node_id}`);
const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE));
const tdLong = document.createElement("td");
const a = document.createElement("a");
a.href = `/node/${n.node_id}`;
a.textContent = n.long_name || n.node_id;
a.className = "node-link";
a.addEventListener("click", e => e.stopPropagation());
tdLong.appendChild(a);
document.getElementById("node-count").textContent = totalRows;
document.getElementById("pageInfo").textContent =
`Page ${currentPage + 1} / ${totalPages}`;
const tdShort = document.createElement("td");
tdShort.textContent = n.short_name || "";
const tdChannel = document.createElement("td");
tdChannel.textContent = n.channel || "";
const tdSent = document.createElement("td");
tdSent.textContent = "...";
const tdSeen = document.createElement("td");
tdSeen.textContent = "...";
const tdAvg = document.createElement("td");
tdAvg.textContent = "...";
tr.appendChild(tdLong);
tr.appendChild(tdShort);
tr.appendChild(tdChannel);
tr.appendChild(tdSent);
tr.appendChild(tdSeen);
tr.appendChild(tdAvg);
tbody.appendChild(tr);
return { node: n, tr, tdSent, tdSeen, tdAvg };
});
// Fetch stats
const statsList = await Promise.all(
rowRefs.map(ref => fetchNodeStats(ref.node.node_id))
);
// Update rows
let combined = rowRefs.map((ref, i) => {
const stats = statsList[i];
ref.tdSent.textContent = stats.sent;
ref.tdSeen.textContent = stats.seen;
ref.tdAvg.innerHTML =
`<span class="${avgClass(stats.avg)}">${stats.avg.toFixed(1)}</span>`;
return { tr: ref.tr, sent: stats.sent, seen: stats.seen };
});
// Remove nodes with no activity
combined = combined.filter(r => !(r.sent === 0 && r.seen === 0));
// Sort by seen
combined.sort((a, b) => b.seen - a.seen);
tbody.innerHTML = "";
for (const r of combined) tbody.appendChild(r.tr);
// ⭐ UPDATE COUNT ⭐
document.getElementById("node-count").textContent = combined.length;
document.getElementById("prevPage").disabled = currentPage === 0;
document.getElementById("nextPage").disabled = currentPage >= totalPages - 1;
}
/* ======================================================
INITIALIZE PAGE
INIT
====================================================== */
document.addEventListener("DOMContentLoaded", async () => {
await loadTranslationsTop(); // ⭐ MUST run first
await loadNodes();
await loadTranslationsTop();
await loadChannels();
await renderTable();
document.getElementById("channelFilter").value = "LongFast";
document.getElementById("nodeSearch").addEventListener("input", renderTable);
channelFilter.onchange = () => {
currentPage = 0;
renderTable();
};
renderTable();
prevPage.onclick = () => {
if (currentPage > 0) {
currentPage--;
renderTable();
}
};
nextPage.onclick = () => {
currentPage++;
renderTable();
};
});
</script>

View File

@@ -134,7 +134,7 @@ async def api_packets(request):
# --- Parse limit ---
try:
limit = min(max(int(limit_str), 1), 100)
limit = min(max(int(limit_str), 1), 1000)
except ValueError:
limit = 50
@@ -724,3 +724,189 @@ async def api_packets_seen(request):
{"error": "Internal server error"},
status=500,
)
@routes.get("/api/traceroute/{packet_id}")
async def api_traceroute(request):
packet_id = int(request.match_info['packet_id'])
traceroutes = list(await store.get_traceroute(packet_id))
packet = await store.get_packet(packet_id)
if not packet:
return web.json_response({"error": "Packet not found"}, status=404)
tr_groups = []
# --------------------------------------------
# Decode each traceroute entry
# --------------------------------------------
for idx, tr in enumerate(traceroutes):
route = decode_payload.decode_payload(PortNum.TRACEROUTE_APP, tr.route)
forward_list = list(route.route)
reverse_list = list(route.route_back)
tr_groups.append({
"index": idx,
"import_time": tr.import_time.isoformat() if tr.import_time else None,
"gateway_node_id": tr.gateway_node_id,
"done": tr.done,
"forward_hops": forward_list,
"reverse_hops": reverse_list,
})
# --------------------------------------------
# Compute UNIQUE paths + counts + winning path
# --------------------------------------------
from collections import Counter
forward_paths = []
reverse_paths = []
winning_paths = []
for tr in tr_groups:
f = tuple(tr["forward_hops"])
r = tuple(tr["reverse_hops"])
if tr["forward_hops"]:
forward_paths.append(f)
if tr["reverse_hops"]:
reverse_paths.append(r)
if tr["done"]:
winning_paths.append(f)
# Deduplicate
unique_forward_paths = sorted(set(forward_paths))
unique_reverse_paths = sorted(set(reverse_paths))
# Count occurrences
forward_counts = Counter(forward_paths)
# Convert for JSON output
unique_forward_paths_json = [
{"path": list(p), "count": forward_counts[p]} for p in unique_forward_paths
]
unique_reverse_paths_json = [list(p) for p in unique_reverse_paths]
winning_paths_json = [list(p) for p in set(winning_paths)]
# --------------------------------------------
# Final API output
# --------------------------------------------
return web.json_response({
"packet": {
"id": packet.id,
"from": packet.from_node_id,
"to": packet.to_node_id,
"channel": packet.channel,
},
"traceroute_packets": tr_groups,
"unique_forward_paths": unique_forward_paths_json,
"unique_reverse_paths": unique_reverse_paths_json,
"winning_paths": winning_paths_json,
})
@routes.get("/api/stats/top")
async def api_stats_top(request):
"""
Returns nodes sorted by SEEN (high → low) with pagination.
"""
period_type = request.query.get("period_type", "day")
length = int(request.query.get("length", 1))
channel = request.query.get("channel")
limit = min(int(request.query.get("limit", 20)), 100)
offset = int(request.query.get("offset", 0))
params = {
"period_type": period_type,
"length": length,
"limit": limit,
"offset": offset,
}
channel_filter = ""
if channel:
channel_filter = "AND n.channel = :channel"
params["channel"] = channel
sql = f"""
WITH sent AS (
SELECT
p.from_node_id AS node_id,
COUNT(*) AS sent
FROM packet p
WHERE p.import_time_us >= (
SELECT MAX(import_time_us) FROM packet
) - (
CASE
WHEN :period_type = 'hour' THEN :length * 3600 * 1000000
ELSE :length * 86400 * 1000000
END
)
GROUP BY p.from_node_id
),
seen AS (
SELECT
p.from_node_id AS node_id,
COUNT(*) AS seen
FROM packet_seen ps
JOIN packet p ON p.id = ps.packet_id
WHERE ps.import_time_us >= (
SELECT MAX(import_time_us) FROM packet_seen
) - (
CASE
WHEN :period_type = 'hour' THEN :length * 3600 * 1000000
ELSE :length * 86400 * 1000000
END
)
GROUP BY p.from_node_id
)
SELECT
n.node_id,
n.long_name,
n.short_name,
n.channel,
COALESCE(s.sent, 0) AS sent,
COALESCE(se.seen, 0) AS seen
FROM node n
LEFT JOIN sent s ON s.node_id = n.node_id
LEFT JOIN seen se ON se.node_id = n.node_id
WHERE 1=1
{channel_filter}
ORDER BY seen DESC
LIMIT :limit OFFSET :offset
"""
count_sql = f"""
SELECT COUNT(*) FROM node n WHERE 1=1 {channel_filter}
"""
async with database.async_session() as session:
rows = (await session.execute(text(sql), params)).all()
total = (await session.execute(text(count_sql), params)).scalar() or 0
nodes = []
for r in rows:
avg = r.seen / max(r.sent, 1)
nodes.append({
"node_id": r.node_id,
"long_name": r.long_name,
"short_name": r.short_name,
"channel": r.channel,
"sent": r.sent,
"seen": r.seen,
"avg": round(avg, 2),
})
return web.json_response({
"total": total,
"limit": limit,
"offset": offset,
"nodes": nodes,
})