From b35acde8214027f193f4d293b3de86db31e2b2f4 Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 14 Oct 2025 21:34:52 -0700 Subject: [PATCH] Add channel-aware activity filters and API-driven dashboards --- meshview/store.py | 29 +++-- meshview/templates/map.html | 194 ++++++++++++++++++------------ meshview/templates/nodegraph.html | 65 ++++++---- meshview/templates/stats.html | 188 ++++++++++++++++++++++++++++- meshview/web.py | 42 +++---- 5 files changed, 380 insertions(+), 138 deletions(-) diff --git a/meshview/store.py b/meshview/store.py index d39daf5..8060469 100644 --- a/meshview/store.py +++ b/meshview/store.py @@ -24,7 +24,14 @@ async def get_fuzzy_nodes(query): return result.scalars() -async def get_packets(node_id=None, portnum=None, after=None, before=None, limit=None, channel: str | None = None): +async def get_packets( + node_id=None, + portnum=None, + after=None, + before=None, + limit=None, + channel: str | list[str] | tuple[str, ...] | None = None, +): async with database.async_session() as session: q = select(Packet) @@ -37,7 +44,12 @@ async def get_packets(node_id=None, portnum=None, after=None, before=None, limit if before: q = q.where(Packet.import_time < before) if channel: - q = q.where(func.lower(Packet.channel) == channel.lower()) + if isinstance(channel, (list, tuple, set)): + lowered = [c.lower() for c in channel if isinstance(c, str) and c] + if lowered: + q = q.where(func.lower(Packet.channel).in_(lowered)) + elif isinstance(channel, str): + q = q.where(func.lower(Packet.channel) == channel.lower()) q = q.order_by(Packet.import_time.desc()) @@ -372,19 +384,6 @@ async def get_packet_stats( } -async def get_all_channels(): - async with database.async_session() as session: - stmt = ( - select(Node.channel) - .where(Node.channel.is_not(None)) - .where(Node.channel != "") - .distinct() - .order_by(Node.channel.asc()) - ) - result = await session.execute(stmt) - return [row[0] for row in result] - - async def get_channels_in_period(period_type: str = "hour", length: int = 24): """ Returns a list of distinct channels used in packets over a given period. diff --git a/meshview/templates/map.html b/meshview/templates/map.html index 4d6ee3c..6c455e0 100644 --- a/meshview/templates/map.html +++ b/meshview/templates/map.html @@ -115,7 +115,7 @@ async function loadTranslations() { } // Initialize map AFTER translations are loaded -loadTranslations().then(() => { +loadTranslations().then(async () => { const t = window.mapTranslations || {}; const activitySelect = document.getElementById("activity-range"); const activityLabel = document.getElementById("activity-range-label"); @@ -163,8 +163,8 @@ loadTranslations().then(() => { }{{ "," if not loop.last else "" }} {% endfor %} ]; - const providedChannels = {{ all_channels | default([], true) | tojson }}; - const channelSet = new Set(providedChannels.filter(ch => ch)); + const channelSet = new Set(); + let channelList = []; const portMap = {1: "Text", 67: "Telemetry", 3: "Position", 70: "Traceroute", 4: "Node Info", 71: "Neighbour Info", 73: "Map Report"}; @@ -199,6 +199,19 @@ loadTranslations().then(() => { return 'Unknown'; } + async function fetchAdditionalChannels() { + try { + const res = await fetch('/api/channels?period_type=day&length=30'); + if (!res.ok) return []; + const data = await res.json(); + if (!data || !Array.isArray(data.channels)) return []; + return data.channels.filter(ch => typeof ch === 'string' && ch.trim().length > 0); + } catch (err) { + console.error('Channel list fetch failed:', err); + return []; + } + } + const nodeMap = new Map(); nodes.forEach(n => nodeMap.set(n.id, n)); function isInvalidCoord(node) { return !node || !node.lat || !node.long || node.lat===0 || node.long===0 || Number.isNaN(node.lat) || Number.isNaN(node.long); } @@ -245,7 +258,9 @@ loadTranslations().then(() => { if (customView) map.setView([customView.lat,customView.lng],customView.zoom); else map.fitBounds(areaBounds); - const channelList = Array.from(channelSet).sort(); + const extraChannels = await fetchAdditionalChannels(); + extraChannels.forEach(raw => channelSet.add(channelKey(raw))); + channelList = Array.from(channelSet).sort(); // ---- LocalStorage for Filter Preferences ---- const FILTER_STORAGE_KEY = 'meshview_map_filters'; @@ -272,16 +287,13 @@ loadTranslations().then(() => { }); localStorage.setItem(FILTER_STORAGE_KEY, JSON.stringify(filters)); - console.log('Filters saved to localStorage:', filters); } function loadFiltersFromLocalStorage() { try { const stored = localStorage.getItem(FILTER_STORAGE_KEY); if (stored) { - const filters = JSON.parse(stored); - console.log('Filters loaded from localStorage:', filters); - return filters; + return JSON.parse(stored); } } catch (error) { console.error('Error loading filters from localStorage:', error); @@ -289,25 +301,34 @@ loadTranslations().then(() => { return null; } + function renderChannelFilters(savedFilters) { + const filterContainer = document.getElementById("filter-container"); + filterContainer.querySelectorAll('label[data-channel-filter="true"]').forEach(el => el.remove()); + channelList.forEach(channel => { + let filterId = `filter-${channel.replace(/\s+/g,'-').toLowerCase()}`; + let color = hashToColor(channel); + let label = document.createElement('label'); + label.style.color = color; + label.setAttribute('data-channel-filter', 'true'); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = 'filter-checkbox'; + checkbox.id = filterId; + const shouldCheck = savedFilters ? savedFilters.channels?.[channel] !== false : true; + checkbox.checked = shouldCheck; + checkbox.addEventListener("change", updateMarkers); + label.appendChild(checkbox); + label.append(` ${channel}`); + filterContainer.appendChild(label); + }); + } + function resetFiltersToDefaults() { localStorage.removeItem(FILTER_STORAGE_KEY); - console.log('Filters reset to defaults'); - - // Reset routers only filter document.getElementById("filter-routers-only").checked = false; - - // Reset all channel filters to checked (default) - channelList.forEach(channel => { - let filterId = `filter-${channel.replace(/\s+/g, '-').toLowerCase()}`; - let checkbox = document.getElementById(filterId); - if (checkbox) { - checkbox.checked = true; - } - }); - + renderChannelFilters(null); updateMarkers(); - // Show feedback to user const button = document.getElementById('reset-filters-button'); const originalText = button.textContent; button.textContent = '✓ Filters Reset!'; @@ -319,35 +340,18 @@ loadTranslations().then(() => { }, 2000); } + window.resetFiltersToDefaults = resetFiltersToDefaults; + // ---- Filters ---- const filterLabel = document.getElementById("filter-routers-label"); filterLabel.textContent = t.show_routers_only || "Show Routers Only"; - - let filterContainer = document.getElementById("filter-container"); - channelList.forEach(channel => { - let filterId = `filter-${channel.replace(/\s+/g,'-').toLowerCase()}`; - let color = hashToColor(channel); - let label = document.createElement('label'); - label.style.color=color; - label.innerHTML=` ${channel}`; - filterContainer.appendChild(label); - }); - - // Load saved filters from localStorage + const routersOnlyCheckbox = document.getElementById("filter-routers-only"); const savedFilters = loadFiltersFromLocalStorage(); if (savedFilters) { - // Apply routers only filter - document.getElementById("filter-routers-only").checked = savedFilters.routersOnly || false; - - // Apply channel filters - channelList.forEach(channel => { - let filterId = `filter-${channel.replace(/\s+/g, '-').toLowerCase()}`; - let checkbox = document.getElementById(filterId); - if (checkbox && savedFilters.channels.hasOwnProperty(channel)) { - checkbox.checked = savedFilters.channels[channel]; - } - }); + routersOnlyCheckbox.checked = savedFilters.routersOnly || false; } + routersOnlyCheckbox.addEventListener("change", updateMarkers); + renderChannelFilters(savedFilters); function updateMarkers() { let showRoutersOnly = document.getElementById("filter-routers-only").checked; @@ -363,11 +367,17 @@ loadTranslations().then(() => { saveFiltersToLocalStorage(); if (!document.hidden) { - restartPacketFetcher(); + restartPacketFetcher(true); } } - document.querySelectorAll(".filter-checkbox").forEach(input=>input.addEventListener("change",updateMarkers)); + function getActiveChannels() { + return channelList.filter(channel => { + if (channel === 'Unknown') return false; + let checkbox = document.getElementById(`filter-${channel.replace(/\s+/g,'-').toLowerCase()}`); + return checkbox ? checkbox.checked : true; + }); + } // Apply initial filters (from localStorage or defaults) updateMarkers(); @@ -497,50 +507,78 @@ loadTranslations().then(() => { // ---- Packet fetching ---- let lastImportTime=null; const mapInterval={{ site_config["site"]["map_interval"]|default(3) }}; + function buildPacketsUrl(base){ + const active = getActiveChannels(); + const url = new URL(base, window.location.origin); + url.searchParams.delete('channel'); + if (active.length) { + active.forEach(ch => url.searchParams.append('channel', ch)); + } + if (url.origin === window.location.origin) { + return url.pathname + (url.search || '') + (url.hash || ''); + } + return url.toString(); + } function fetchLatestPacket(){ - fetch(`/api/packets?limit=1`).then(r=>r.json()).then(data=>{ - if(data.packets && data.packets.length>0) lastImportTime=data.packets[0].import_time; - else lastImportTime=new Date().toISOString(); - }).catch(err=>console.error(err)); + return fetch(buildPacketsUrl(`/api/packets?limit=1`)) + .then(r=>r.json()) + .then(data=>{ + if(data.packets && data.packets.length>0){ + lastImportTime=data.packets[0].import_time; + } else { + lastImportTime=new Date().toISOString(); + } + }) + .catch(err=>{ + console.error('fetchLatestPacket failed:', err); + }); } function fetchNewPackets(){ if(!lastImportTime) return; - fetch(`/api/packets?since=${lastImportTime}`).then(r=>r.json()).then(data=>{ - if(!data.packets||data.packets.length===0) return; - let latestSeen=lastImportTime; - data.packets.forEach(packet=>{ - if(packet.import_time && (!latestSeen || packet.import_time>latestSeen)) latestSeen=packet.import_time; - let marker=markerById[packet.from_node_id]; - if(marker){ - let nodeData=nodeMap.get(packet.from_node_id); - if(nodeData) blinkNode(marker,nodeData.long_name,packet.portnum); - } + const baseUrl = `/api/packets?since=${encodeURIComponent(lastImportTime)}`; + return fetch(buildPacketsUrl(baseUrl)) + .then(r=>r.json()) + .then(data=>{ + if(!data.packets||data.packets.length===0) return; + let latestSeen=lastImportTime; + data.packets.forEach(packet=>{ + if(packet.import_time && (!latestSeen || packet.import_time>latestSeen)) latestSeen=packet.import_time; + let marker=markerById[packet.from_node_id]; + if(marker){ + let nodeData=nodeMap.get(packet.from_node_id); + if(nodeData) blinkNode(marker,nodeData.long_name,packet.portnum); + } + }); + if(latestSeen) lastImportTime=latestSeen; + }) + .catch(err=>{ + console.error('fetchNewPackets failed:', err); }); - if(latestSeen) lastImportTime=latestSeen; - }).catch(err=>console.error(err)); } let packetInterval=null; - function startPacketFetcher(resetImportTime=true){ - if(mapInterval<=0) return; - if(!packetInterval){ - if(resetImportTime || !lastImportTime){ - fetchLatestPacket(); - } - packetInterval=setInterval(fetchNewPackets,mapInterval*1000); - if(!resetImportTime && lastImportTime){ - fetchNewPackets(); - } + async function startPacketFetcher(resetImportTime=true){ + if (mapInterval <= 0) return; + stopPacketFetcher(); + if (resetImportTime) { + lastImportTime = null; } + if (!lastImportTime) { + await fetchLatestPacket(); + } + await fetchNewPackets(); + packetInterval = setInterval(()=>{ fetchNewPackets(); }, mapInterval*1000); } function stopPacketFetcher(){ if(packetInterval){ clearInterval(packetInterval); packetInterval=null; } } - function restartPacketFetcher(){ + async function restartPacketFetcher(resetImportTime=false){ if(mapInterval<=0) return; - stopPacketFetcher(); if(document.hidden) return; - startPacketFetcher(false); + await startPacketFetcher(resetImportTime); } - document.addEventListener("visibilitychange",function(){ if(document.hidden) stopPacketFetcher(); else startPacketFetcher(); }); - if(mapInterval>0) startPacketFetcher(); + document.addEventListener("visibilitychange",function(){ + if(document.hidden) stopPacketFetcher(); + else restartPacketFetcher(false); + }); + if(mapInterval>0) startPacketFetcher(true); }); {% endblock %} diff --git a/meshview/templates/nodegraph.html b/meshview/templates/nodegraph.html index e833a27..f71cf58 100644 --- a/meshview/templates/nodegraph.html +++ b/meshview/templates/nodegraph.html @@ -130,10 +130,8 @@ {% endblock %} diff --git a/meshview/templates/stats.html b/meshview/templates/stats.html index 38bd3fe..2b63d8c 100644 --- a/meshview/templates/stats.html +++ b/meshview/templates/stats.html @@ -77,6 +77,69 @@ font-weight: bold; } +.table-wrapper { + max-height: 400px; + overflow: auto; + border: 1px solid #3a3d42; + border-radius: 6px; + margin-top: 10px; +} + +.stats-table { + width: 100%; + border-collapse: collapse; + color: #ddd; +} + +.stats-table th, +.stats-table td { + padding: 8px 10px; + text-align: left; + border-bottom: 1px solid #3a3d42; + font-size: 13px; + white-space: nowrap; +} + +.stats-table th { + cursor: pointer; + position: relative; + user-select: none; + color: #f0f0f0; +} + +.stats-table th.sorted-asc::after, +.stats-table th.sorted-desc::after { + content: ""; + position: absolute; + right: 8px; + top: 50%; + width: 0; + height: 0; + border-left: 4px solid transparent; + border-right: 4px solid transparent; +} + +.stats-table th.sorted-asc::after { + border-bottom: 6px solid #66bb6a; + transform: translateY(-75%); +} + +.stats-table th.sorted-desc::after { + border-top: 6px solid #66bb6a; + transform: translateY(-25%); +} + +.stats-table tbody tr:hover { + background-color: #2f3338; +} + +.empty-table { + padding: 16px; + text-align: center; + color: #aaa; + font-size: 14px; +} + .filter-bar { display: flex; justify-content: flex-end; @@ -190,9 +253,29 @@

Channel Breakdown

- +
+ +
+

Nodes Overview

+
+ + + + + + + + + + + + +
Long NameShort NameRoleHardwareChannelLast Seen
+ +
+
@@ -230,6 +313,9 @@ const PORT_CONFIG = [ const CHANNEL_PRESETS = ["LongFast", "MediumSlow"]; let currentChannel = ""; +let nodeTableData = []; +let nodeTableSortKey = "last_update"; +let nodeTableSortDirection = "desc"; // --- Fetch & Processing --- async function fetchStats(period_type,length,portnum=null,channel=null){ @@ -299,6 +385,99 @@ function prepareTopN(data=[],n=20){ return top; } +function formatDateString(value){ + if(!value) return "—"; + const date = new Date(value); + if(Number.isNaN(date.getTime())) return value; + return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`; +} + +function normalizeString(value){ + return (value ?? "").toString().toLowerCase(); +} + +function applyNodeTableSort(render=true){ + const dir = nodeTableSortDirection === "asc" ? 1 : -1; + nodeTableData.sort((a,b)=>{ + let lhs=a[nodeTableSortKey]; + let rhs=b[nodeTableSortKey]; + if(nodeTableSortKey==="last_update"){ + lhs = lhs ?? -Infinity; + rhs = rhs ?? -Infinity; + return (lhs - rhs) * dir; + } + const left = normalizeString(lhs); + const right = normalizeString(rhs); + if(left===right) return 0; + return left > right ? dir : -dir; + }); + if(render) renderNodeTableRows(); +} + +function renderNodeTableRows(){ + const tbody=document.querySelector("#nodesTable tbody"); + const emptyMessage=document.getElementById("nodesTableEmpty"); + if(!tbody) return; + tbody.innerHTML=""; + if(!nodeTableData.length){ + if(emptyMessage) emptyMessage.style.display="block"; + return; + } + if(emptyMessage) emptyMessage.style.display="none"; + nodeTableData.forEach(node=>{ + const tr=document.createElement("tr"); + tr.innerHTML=` + ${node.long_name} + ${node.short_name} + ${node.role} + ${node.hw_model} + ${node.channel || "—"} + ${node.last_update_display} + `; + tbody.appendChild(tr); + }); + updateSortIndicators(); +} + +function setNodeTableData(rawNodes){ + nodeTableData = (rawNodes||[]).map(n=>{ + const lastUpdateRaw = n?.last_update ?? null; + const lastUpdateDate = lastUpdateRaw ? new Date(lastUpdateRaw) : null; + return { + long_name: n?.long_name || "—", + short_name: n?.short_name || "—", + role: n?.role || "Unknown", + hw_model: n?.hw_model || "Unknown", + channel: n?.channel || "", + last_update: lastUpdateDate ? lastUpdateDate.getTime() : null, + last_update_display: formatDateString(lastUpdateRaw), + }; + }); + applyNodeTableSort(false); + renderNodeTableRows(); +} + +function updateSortIndicators(){ + document.querySelectorAll("#nodesTable thead th[data-sort-key]").forEach(th=>{ + th.classList.remove("sorted-asc","sorted-desc"); + if(th.dataset.sortKey === nodeTableSortKey){ + th.classList.add(nodeTableSortDirection === "asc" ? "sorted-asc" : "sorted-desc"); + } + }); +} + +function handleNodeTableSort(event){ + const key=event.currentTarget?.dataset?.sortKey; + if(!key) return; + if(nodeTableSortKey===key){ + nodeTableSortDirection = nodeTableSortDirection === "asc" ? "desc" : "asc"; + }else{ + nodeTableSortKey = key; + nodeTableSortDirection = key === "last_update" ? "desc" : "asc"; + } + applyNodeTableSort(); +} + // --- Chart Rendering --- function renderChart(domId,data,type,color){ const el=document.getElementById(domId); @@ -465,6 +644,7 @@ async function refreshDashboard(){ chartHwModel=renderPieChart("chart_hw_model",processCountField(nodes,"hw_model"),"Hardware"); chartRole=renderPieChart("chart_role",processCountField(nodes,"role"),"Role"); chartChannel=renderPieChart("chart_channel",processCountField(nodes,"channel"),"Channel"); + setNodeTableData(nodes); const formatted=(packetTypesData||[]).filter(d=>d.count>0).map(d=>({ name: d.portnum==="other" ? "Other" : (PORTNUM_LABELS[d.portnum]||`Port ${d.portnum}`), @@ -487,6 +667,12 @@ async function init(){ }); select.dataset.listenerAttached="true"; } + document.querySelectorAll("#nodesTable thead th[data-sort-key]").forEach(th=>{ + if(!th.dataset.listenerAttached){ + th.addEventListener("click",handleNodeTableSort); + th.dataset.listenerAttached="true"; + } + }); await refreshDashboard(); } diff --git a/meshview/web.py b/meshview/web.py index 0cef68d..728ab45 100644 --- a/meshview/web.py +++ b/meshview/web.py @@ -1212,7 +1212,6 @@ async def map(request): selected_activity, activity_window = resolve_activity_window(activity_param) nodes = await store.get_nodes(active_within=activity_window) - all_channels = await store.get_all_channels() # Filter out nodes with no latitude nodes = [node for node in nodes if node.last_lat is not None] @@ -1247,8 +1246,6 @@ async def map(request): custom_view=custom_view, activity_filters=ACTIVITY_FILTERS, selected_activity=selected_activity, - default_activity=DEFAULT_ACTIVITY_OPTION, - all_channels=all_channels, site_config=CONFIG, SOFTWARE_RELEASE=SOFTWARE_RELEASE, ), @@ -1396,20 +1393,6 @@ async def nodegraph(request): selected_activity, activity_window = resolve_activity_window(activity_param) nodes = await store.get_nodes(active_within=activity_window) - all_channels = await store.get_all_channels() - channel_param = request.query.get("channel") - node_channel_candidates = sorted({node.channel for node in nodes if node.channel}) - - if channel_param and channel_param in node_channel_candidates: - selected_channel = channel_param - elif channel_param and channel_param in all_channels: - selected_channel = channel_param - elif node_channel_candidates: - selected_channel = node_channel_candidates[0] - elif all_channels: - selected_channel = all_channels[0] - else: - selected_channel = None active_node_ids = {node.node_id for node in nodes} edges_map = defaultdict( @@ -1483,9 +1466,6 @@ async def nodegraph(request): edges=edges, # Pass edges with color info activity_filters=ACTIVITY_FILTERS, selected_activity=selected_activity, - default_activity=DEFAULT_ACTIVITY_OPTION, - all_channels=all_channels, - selected_channel=selected_channel, site_config=CONFIG, SOFTWARE_RELEASE=SOFTWARE_RELEASE, ), @@ -1685,6 +1665,19 @@ async def api_packets(request): limit = int(request.query.get("limit", 50)) since_str = request.query.get("since") since_time = None + channel_values = [] + + # Support repeated ?channel=foo&channel=bar and comma-separated values + if "channel" in request.query: + raw_channels = request.query.getall("channel", []) + if not raw_channels: + raw_value = request.query.get("channel") + if raw_value: + raw_channels = [raw_value] + for raw in raw_channels: + if raw: + parts = [part.strip() for part in raw.split(",") if part.strip()] + channel_values.extend(parts) # Parse 'since' timestamp if provided if since_str: @@ -1694,7 +1687,14 @@ async def api_packets(request): logger.error(f"Failed to parse 'since' timestamp '{since_str}': {e}") # Fetch last N packets - packets = await store.get_packets(limit=limit, after=since_time) + if not channel_values: + channel_filter = None + elif len(channel_values) == 1: + channel_filter = channel_values[0] + else: + channel_filter = channel_values + + packets = await store.get_packets(limit=limit, after=since_time, channel=channel_filter) packets = [Packet.from_model(p) for p in packets] # Build JSON response (no raw_payload)