diff --git a/meshview/store.py b/meshview/store.py index 8060469..ef3c7ae 100644 --- a/meshview/store.py +++ b/meshview/store.py @@ -24,14 +24,7 @@ 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 | list[str] | tuple[str, ...] | None = None, -): +async def get_packets(node_id=None, portnum=None, after=None, before=None, limit=None): async with database.async_session() as session: q = select(Packet) @@ -43,13 +36,6 @@ async def get_packets( q = q.where(Packet.import_time > after) if before: q = q.where(Packet.import_time < before) - if channel: - 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()) @@ -161,21 +147,17 @@ async def get_mqtt_neighbors(since): # We count the total amount of packages # This is to be used by /stats in web.py -async def get_total_packet_count(channel: str | None = None) -> int: +async def get_total_packet_count(): async with database.async_session() as session: q = select(func.count(Packet.id)) # Use SQLAlchemy's func to count packets - if channel: - q = q.where(func.lower(Packet.channel) == channel.lower()) result = await session.execute(q) return result.scalar() # Return the total count of packets # We count the total amount of seen packets -async def get_total_packet_seen_count(channel: str | None = None) -> int: +async def get_total_packet_seen_count(): async with database.async_session() as session: q = select(func.count(PacketSeen.node_id)) # Use SQLAlchemy's func to count nodes - if channel: - q = q.where(func.lower(PacketSeen.channel) == channel.lower()) result = await session.execute(q) return result.scalar() # Return the` total count of seen packets @@ -275,13 +257,7 @@ async def get_node_traffic(node_id: int): return [] -async def get_nodes( - role=None, - channel=None, - hw_model=None, - days_active=None, - active_within: timedelta | None = None, -): +async def get_nodes(role=None, channel=None, hw_model=None, days_active=None): """ Fetches nodes from the database based on optional filtering criteria. @@ -289,8 +265,6 @@ async def get_nodes( role (str, optional): The role of the node (converted to uppercase for consistency). channel (str, optional): The communication channel associated with the node. hw_model (str, optional): The hardware model of the node. - days_active (int, optional): Legacy support for filtering by a number of days. - active_within (timedelta, optional): Filter nodes seen within the provided window. Returns: list: A list of Node objects that match the given criteria. @@ -310,12 +284,8 @@ async def get_nodes( if hw_model is not None: query = query.where(Node.hw_model == hw_model) - window = active_within - if window is None and days_active is not None: - window = timedelta(days=days_active) - - if window is not None: - query = query.where(Node.last_update > datetime.now() - window) + if days_active is not None: + query = query.where(Node.last_update > datetime.now() - timedelta(days_active)) # Exclude nodes where last_update is an empty string query = query.where(Node.last_update != "") diff --git a/meshview/templates/chat.html b/meshview/templates/chat.html index f297b6a..3df51a9 100644 --- a/meshview/templates/chat.html +++ b/meshview/templates/chat.html @@ -17,61 +17,19 @@ /* Nested reply style */ .replying-to { font-size: 0.85em; color: #aaa; margin-top: 4px; padding-left: 20px; } .replying-to .reply-preview { color: #aaa; } - -.filter-bar { - display: flex; - justify-content: center; - align-items: center; - gap: 10px; - margin-bottom: 16px; -} - -.filter-label { - color: #ccc; - font-size: 14px; -} - -#chatChannelSelect { - padding: 4px 6px; - background: #444; - color: #fff; - border: none; - border-radius: 4px; -} - -.status-message { - color: #bbb; - margin-bottom: 12px; - display: none; -} {% endblock %} {% block body %}
-
- - -
-
diff --git a/meshview/templates/map.html b/meshview/templates/map.html index 449137d..49ce025 100644 --- a/meshview/templates/map.html +++ b/meshview/templates/map.html @@ -23,20 +23,10 @@ #filter-container { text-align: center; margin-top: 10px; - display: flex; - flex-wrap: wrap; - justify-content: center; - align-items: center; - gap: 8px; } .filter-checkbox { margin: 0 10px; } - #activity-range { - padding: 4px 8px; - border-radius: 4px; - border: 1px solid #ccc; - } #share-button { margin-left: 20px; padding: 5px 15px; @@ -82,12 +72,6 @@ {% block body %}
- - Show Routers Only
@@ -115,20 +99,8 @@ async function loadTranslations() { } // Initialize map AFTER translations are loaded -loadTranslations().then(async () => { +loadTranslations().then(() => { const t = window.mapTranslations || {}; - const activitySelect = document.getElementById("activity-range"); - const activityLabel = document.getElementById("activity-range-label"); - if (activityLabel) { - activityLabel.textContent = t.active_within || "Active in:"; - } - if (activitySelect) { - activitySelect.addEventListener("change", () => { - const url = new URL(window.location.href); - url.searchParams.set("active", activitySelect.value); - window.location.href = url.toString(); - }); - } // ---- Map Setup ---- var map = L.map('map'); @@ -163,8 +135,6 @@ loadTranslations().then(async () => { }{{ "," if not loop.last else "" }} {% endfor %} ]; - 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"}; @@ -192,37 +162,18 @@ loadTranslations().then(async () => { return color; } - function channelKey(channel) { - if (typeof channel === 'string' && channel.trim().length > 0) { - return channel; - } - 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); } // ---- Marker Plotting ---- var bounds = L.latLngBounds(); + var channels = new Set(); nodes.forEach(node => { if (!isInvalidCoord(node)) { - let category = channelKey(node.channel); - channelSet.add(category); + let category = node.channel; + channels.add(category); let color = hashToColor(category); let popupContent = `${node.long_name} (${node.short_name})
@@ -258,10 +209,6 @@ loadTranslations().then(async () => { if (customView) map.setView([customView.lat,customView.lng],customView.zoom); else map.fitBounds(areaBounds); - 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'; @@ -287,13 +234,16 @@ loadTranslations().then(async () => { }); 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) { - return JSON.parse(stored); + const filters = JSON.parse(stored); + console.log('Filters loaded from localStorage:', filters); + return filters; } } catch (error) { console.error('Error loading filters from localStorage:', error); @@ -325,8 +275,20 @@ loadTranslations().then(async () => { function resetFiltersToDefaults() { localStorage.removeItem(FILTER_STORAGE_KEY); + console.log('Filters reset to defaults'); + + // Reset routers only filter document.getElementById("filter-routers-only").checked = false; - renderChannelFilters(null); + + // Reset all channel filters to checked (default) + channels.forEach(channel => { + let filterId = `filter-${channel.replace(/\s+/g, '-').toLowerCase()}`; + let checkbox = document.getElementById(filterId); + if (checkbox) { + checkbox.checked = true; + } + }); + updateMarkers(); const button = document.getElementById('reset-filters-button'); @@ -340,35 +302,48 @@ loadTranslations().then(async () => { }, 2000); } - window.resetFiltersToDefaults = resetFiltersToDefaults; - // ---- Filters ---- const filterLabel = document.getElementById("filter-routers-label"); filterLabel.textContent = t.show_routers_only || "Show Routers Only"; - const routersOnlyCheckbox = document.getElementById("filter-routers-only"); + + let filterContainer = document.getElementById("filter-container"); + channels.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 savedFilters = loadFiltersFromLocalStorage(); if (savedFilters) { - routersOnlyCheckbox.checked = savedFilters.routersOnly || false; + // Apply routers only filter + document.getElementById("filter-routers-only").checked = savedFilters.routersOnly || false; + + // Apply channel filters + channels.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.addEventListener("change", updateMarkers); - renderChannelFilters(savedFilters); function updateMarkers() { let showRoutersOnly = document.getElementById("filter-routers-only").checked; nodes.forEach(node => { - let category = channelKey(node.channel); + let category=node.channel; let checkbox=document.getElementById(`filter-${category.replace(/\s+/g,'-').toLowerCase()}`); - let shouldShow=(!checkbox || checkbox.checked) && (!showRoutersOnly || node.isRouter); + let shouldShow=checkbox.checked && (!showRoutersOnly || node.isRouter); let marker=markerById[node.id]; if(marker) marker.setStyle({fillOpacity:shouldShow?1:0}); }); // Save filters to localStorage whenever they change saveFiltersToLocalStorage(); - - if (!document.hidden) { - restartPacketFetcher(true); - } } function getActiveChannels() { @@ -390,11 +365,7 @@ loadTranslations().then(async () => { const zoom = map.getZoom(); const lat = center.lat.toFixed(6); const lng = center.lng.toFixed(6); - const url = new URL(window.location.href); - url.searchParams.set('lat', lat); - url.searchParams.set('lng', lng); - url.searchParams.set('zoom', zoom); - const shareUrl = url.toString(); + const shareUrl = `${window.location.origin}/map?lat=${lat}&lng=${lng}&zoom=${zoom}`; navigator.clipboard.writeText(shareUrl).then(()=>{ const orig = shareBtn.textContent; shareBtn.textContent = '✓ Link Copied!'; @@ -507,78 +478,33 @@ loadTranslations().then(async () => { // ---- 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(){ - 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); - }); + 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)); } function fetchNewPackets(){ if(!lastImportTime) return; - 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); + 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); + } }); + if(latestSeen) lastImportTime=latestSeen; + }).catch(err=>console.error(err)); } let packetInterval=null; - 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 startPacketFetcher(){ if(mapInterval<=0) return; if(!packetInterval){ fetchLatestPacket(); packetInterval=setInterval(fetchNewPackets,mapInterval*1000); } } function stopPacketFetcher(){ if(packetInterval){ clearInterval(packetInterval); packetInterval=null; } } - async function restartPacketFetcher(resetImportTime=false){ - if(mapInterval<=0) return; - if(document.hidden) return; - await startPacketFetcher(resetImportTime); - } - document.addEventListener("visibilitychange",function(){ - if(document.hidden) stopPacketFetcher(); - else restartPacketFetcher(false); - }); - if(mapInterval>0) startPacketFetcher(true); + document.addEventListener("visibilitychange",function(){ if(document.hidden) stopPacketFetcher(); else startPacketFetcher(); }); + if(mapInterval>0) startPacketFetcher(); }); {% endblock %} \ No newline at end of file diff --git a/meshview/templates/nodegraph.html b/meshview/templates/nodegraph.html index f71cf58..3d25625 100644 --- a/meshview/templates/nodegraph.html +++ b/meshview/templates/nodegraph.html @@ -87,12 +87,6 @@
- - @@ -129,23 +123,6 @@
{% endblock %} diff --git a/meshview/templates/stats.html b/meshview/templates/stats.html index 2b63d8c..e045371 100644 --- a/meshview/templates/stats.html +++ b/meshview/templates/stats.html @@ -77,90 +77,14 @@ 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; - align-items: center; - gap: 10px; - margin-bottom: 20px; -} - -.filter-label { - color: #ccc; - font-size: 14px; -} - #channelSelect { + margin-bottom: 8px; padding: 4px 6px; background:#444; color:#fff; border:none; border-radius:4px; } - {% endblock %} {% block head %} @@ -171,25 +95,18 @@

Mesh Statistics - Summary (all available in Database)

-
- - -
-

Total Nodes

-
{{ "{:,}".format(total_nodes) }}
+
{{ "{:,}".format(total_nodes) }}

Total Packets

-
{{ "{:,}".format(total_packets) }}
+
{{ "{:,}".format(total_packets) }}

Total Packets Seen

-
{{ "{:,}".format(total_packets_seen) }}
+
{{ "{:,}".format(total_packets_seen) }}
@@ -205,6 +122,9 @@

Packet Types - Last 24 Hours

+
@@ -253,29 +173,9 @@

Channel Breakdown

- +
- -
-

Nodes Overview

-
- - - - - - - - - - - - -
Long NameShort NameRoleHardwareChannelLast Seen
- -
-
@@ -301,28 +201,12 @@ const PORTNUM_LABELS = { 71: "Neighbor Info" }; -const PORT_CONFIG = [ - { port: 1, color: '#ff5722', domId: 'chart_portnum_1', totalId: 'total_portnum_1' }, - { port: 3, color: '#2196f3', domId: 'chart_portnum_3', totalId: 'total_portnum_3' }, - { port: 4, color: '#9c27b0', domId: 'chart_portnum_4', totalId: 'total_portnum_4' }, - { port: 67, color: '#ffeb3b', domId: 'chart_portnum_67', totalId: 'total_portnum_67' }, - { port: 70, color: '#795548', domId: 'chart_portnum_70', totalId: 'total_portnum_70' }, - { port: 71, color: '#4caf50', domId: 'chart_portnum_71', totalId: 'total_portnum_71' }, -]; - -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){ try{ let url=`/api/stats?period_type=${period_type}&length=${length}`; if(portnum!==null) url+=`&portnum=${portnum}`; - if(channel) url+=`&channel=${encodeURIComponent(channel)}`; + if(channel) url+=`&channel=${channel}`; const res=await fetch(url); if(!res.ok) return []; const json=await res.json(); @@ -330,212 +214,28 @@ async function fetchStats(period_type,length,portnum=null,channel=null){ }catch{return [];} } -async function fetchNodes(channel=null){ - try{ - const base="/api/nodes"; - const url=channel?`${base}?channel=${encodeURIComponent(channel)}`:base; - const res=await fetch(url); - const json=await res.json(); - return json.nodes||[]; - }catch{return [];} -} +async function fetchNodes(){ try{ const res=await fetch("/api/nodes"); const json=await res.json(); return json.nodes||[];}catch{return [];} } +async function fetchChannels(){ try{ const res = await fetch("/api/channels"); const json = await res.json(); return json.channels || [];}catch{return [];} } -async function fetchChannels(){ - try{ - const res = await fetch("/api/channels"); - const json = await res.json(); - return json.channels || []; - }catch{return [];} -} - -async function fetchSummary(channel=null){ - try{ - const base="/api/stats/summary"; - const url=channel?`${base}?channel=${encodeURIComponent(channel)}`:base; - const res=await fetch(url); - if(!res.ok) return null; - return await res.json(); - }catch{return null;} -} - -function processCountField(nodes,field){ - const counts={}; - (nodes||[]).forEach(n=>{ - const key=n?.[field]||"Unknown"; - counts[key]=(counts[key]||0)+1; - }); - return Object.entries(counts).map(([name,value])=>({name,value})); -} - -function updateTotalCount(domId,data){ - const el=document.getElementById(domId); - if(!el) return; - const dataset=Array.isArray(data)?data:[]; - const total=dataset.reduce((acc,d)=>acc+(d?.count??d?.packet_count??0),0); - el.textContent=`Total: ${total.toLocaleString()}`; -} - -function prepareTopN(data=[],n=20){ - const sorted=[...(data||[])].sort((a,b)=>b.value-a.value); - const top=sorted.slice(0,n); - if(sorted.length>n){ - const otherValue=sorted.slice(n).reduce((sum,item)=>sum+item.value,0); - top.push({name:"Other", value:otherValue}); - } - 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(); -} +function processCountField(nodes,field){ const counts={}; nodes.forEach(n=>{ const key=n[field]||"Unknown"; counts[key]=(counts[key]||0)+1; }); return Object.entries(counts).map(([name,value])=>({name,value})); } +function updateTotalCount(domId,data){ const el=document.getElementById(domId); if(!el||!data.length) return; const total=data.reduce((acc,d)=>acc+(d.count??d.packet_count??0),0); el.textContent=`Total: ${total.toLocaleString()}`; } +function prepareTopN(data,n=20){ data.sort((a,b)=>b.value-a.value); let top=data.slice(0,n); if(data.length>n){ const otherValue=data.slice(n).reduce((sum,item)=>sum+item.value,0); top.push({name:"Other", value:otherValue}); } return top; } // --- Chart Rendering --- -function renderChart(domId,data,type,color){ - const el=document.getElementById(domId); - if(!el) return null; - const existing=echarts.getInstanceByDom(el); - if(existing) existing.dispose(); - const chart=echarts.init(el); - const source=Array.isArray(data)?data:[]; - const periods=source.map(d=>{ - const periodValue=d?.period; - return (periodValue||periodValue===0) ? String(periodValue) : ''; - }); - const counts=source.map(d=>d?.count??d?.packet_count??0); - chart.setOption({ - backgroundColor:'#272b2f', - tooltip:{trigger:'axis'}, - grid:{left:'6%', right:'6%', bottom:'18%'}, - xAxis:{type:'category', data:periods, axisLine:{lineStyle:{color:'#aaa'}}, axisLabel:{rotate:45,color:'#ccc'}}, - yAxis:{type:'value', axisLine:{lineStyle:{color:'#aaa'}}, axisLabel:{color:'#ccc'}}, - series:[{data:counts,type:type,smooth:type==='line',itemStyle:{color:color}, areaStyle:type==='line'?{}:undefined}] - }); - return chart; -} +function renderChart(domId,data,type,color){ const el=document.getElementById(domId); if(!el) return; const chart=echarts.init(el); const periods=data.map(d=>(d.period??d.period===0)?d.period.toString():''); const counts=data.map(d=>d.count??d.packet_count??0); chart.setOption({backgroundColor:'#272b2f', tooltip:{trigger:'axis'}, grid:{left:'6%', right:'6%', bottom:'18%'}, xAxis:{type:'category', data:periods, axisLine:{lineStyle:{color:'#aaa'}}, axisLabel:{rotate:45,color:'#ccc'}}, yAxis:{type:'value', axisLine:{lineStyle:{color:'#aaa'}}, axisLabel:{color:'#ccc'}}, series:[{data:counts,type:type,smooth:type==='line',itemStyle:{color:color}, areaStyle:type==='line'?{}:undefined}]}); return chart; } -function renderPieChart(elId,data,name){ - const el=document.getElementById(elId); - if(!el) return null; - const existing=echarts.getInstanceByDom(el); - if(existing) existing.dispose(); - const chart=echarts.init(el); - const top20=prepareTopN(data,20); - chart.setOption({ - backgroundColor:"#272b2f", - tooltip:{trigger:"item", formatter: params=>`${params.name}: ${Math.round(params.percent)}% (${params.value})`}, - series:[{ - name:name, - type:"pie", - radius:["30%","70%"], - center:["50%","50%"], - avoidLabelOverlap:true, - itemStyle:{borderRadius:6,borderColor:"#272b2f",borderWidth:2}, - label:{show:true,formatter:"{b}\n{d}%", color:"#ccc", fontSize:10}, - labelLine:{show:true,length:10,length2:6}, - data:top20 - }] - }); - return chart; -} +function renderPieChart(elId,data,name){ const el=document.getElementById(elId); if(!el) return; const chart=echarts.init(el); const top20=prepareTopN(data,20); chart.setOption({backgroundColor:"#272b2f", tooltip:{trigger:"item", formatter: params=>`${params.name}: ${Math.round(params.percent)}% (${params.value})`}, series:[{name:name, type:"pie", radius:["30%","70%"], center:["50%","50%"], avoidLabelOverlap:true, itemStyle:{borderRadius:6,borderColor:"#272b2f",borderWidth:2}, label:{show:true,formatter:"{b}\n{d}%", color:"#ccc", fontSize:10}, labelLine:{show:true,length:10,length2:6}, data:top20}]}); return chart; } // --- Packet Type Pie Chart --- async function fetchPacketTypeBreakdown(channel=null) { - const requests = PORT_CONFIG.map(async ({port}) => { - const data = await fetchStats('hour',24,port,channel); - const total = (data || []).reduce((sum,d)=>sum+(d?.count??d?.packet_count??0),0); - return {portnum: port, count: total}; + const portnums = [1,3,4,67,70,71]; + const requests = portnums.map(async pn => { + const data = await fetchStats('hour',24,pn,channel); + const total = (data || []).reduce((sum,d)=>sum+(d.count??d.packet_count??0),0); + return {portnum: pn, count: total}; }); const allData = await fetchStats('hour',24,null,channel); - const totalAll = (allData||[]).reduce((sum,d)=>sum+(d?.count??d?.packet_count??0),0); + const totalAll = allData.reduce((sum,d)=>sum+(d.count??d.packet_count??0),0); const results = await Promise.all(requests); const trackedTotal = results.reduce((sum,d)=>sum+d.count,0); const other = Math.max(totalAll - trackedTotal,0); @@ -543,137 +243,44 @@ async function fetchPacketTypeBreakdown(channel=null) { return results; } -function updateSummaryCards(summary){ - if(!summary) return; - const nodesEl=document.getElementById("summaryTotalNodes"); - const packetsEl=document.getElementById("summaryTotalPackets"); - const packetsSeenEl=document.getElementById("summaryTotalPacketsSeen"); - if(nodesEl) nodesEl.textContent=Number(summary.total_nodes||0).toLocaleString(); - if(packetsEl) packetsEl.textContent=Number(summary.total_packets||0).toLocaleString(); - if(packetsSeenEl) packetsSeenEl.textContent=Number(summary.total_packets_seen||0).toLocaleString(); -} - -function populateChannelSelect(channels){ - const select=document.getElementById("channelSelect"); - if(!select) return; - const unique=Array.from(new Set((channels||[]).filter(ch=>typeof ch==="string" && ch.trim().length>0))).sort((a,b)=>a.localeCompare(b)); - const prioritized=[]; - CHANNEL_PRESETS.forEach(preset=>{ - const idx=unique.indexOf(preset); - if(idx>=0){ - prioritized.push(unique[idx]); - unique.splice(idx,1); - } - }); - const ordered=[...new Set([...prioritized, ...unique])]; - select.innerHTML=""; - const allOption=document.createElement("option"); - allOption.value=""; - allOption.textContent="All Channels"; - allOption.setAttribute("data-translate-lang","all_channels"); - select.appendChild(allOption); - ordered.forEach(ch=>{ - const opt=document.createElement("option"); - opt.value=ch; - opt.textContent=ch; - select.appendChild(opt); - }); - let preferred=currentChannel; - if(!preferred){ - preferred=CHANNEL_PRESETS.find(preset=>ordered.includes(preset))||""; - } - if(preferred && !ordered.includes(preferred)){ - preferred=""; - } - select.value=preferred; - currentChannel=select.value; -} - -function setPortChart(port, chart){ - switch(port){ - case 1: chartPortnum1 = chart; break; - case 3: chartPortnum3 = chart; break; - case 4: chartPortnum4 = chart; break; - case 67: chartPortnum67 = chart; break; - case 70: chartPortnum70 = chart; break; - case 71: chartPortnum71 = chart; break; - default: break; - } -} - // --- Init --- let chartHourlyAll, chartPortnum1, chartPortnum3, chartPortnum4, chartPortnum67, chartPortnum70, chartPortnum71; let chartDailyAll, chartDailyPortnum1; let chartHwModel, chartRole, chartChannel; let chartPacketTypes; -let isRefreshing=false; - -async function refreshDashboard(){ - if(isRefreshing) return; - isRefreshing=true; - const channel=currentChannel||null; - try{ - const [summary, dailyAllData, dailyPort1Data, hourlyAllData, portDataSets, nodes, packetTypesData] = await Promise.all([ - fetchSummary(channel), - fetchStats('day',14,null,channel), - fetchStats('day',14,1,channel), - fetchStats('hour',24,null,channel), - Promise.all(PORT_CONFIG.map(cfg=>fetchStats('hour',24,cfg.port,channel))), - fetchNodes(channel), - fetchPacketTypeBreakdown(channel) - ]); - - updateSummaryCards(summary); - - updateTotalCount('total_daily_all',dailyAllData); - chartDailyAll=renderChart('chart_daily_all',dailyAllData,'line','#66bb6a'); - - updateTotalCount('total_daily_portnum_1',dailyPort1Data); - chartDailyPortnum1=renderChart('chart_daily_portnum_1',dailyPort1Data,'bar','#ff5722'); - - updateTotalCount('total_hourly_all',hourlyAllData); - chartHourlyAll=renderChart('chart_hourly_all',hourlyAllData,'bar','#03dac6'); - - PORT_CONFIG.forEach((cfg,idx)=>{ - const data=portDataSets?.[idx]||[]; - updateTotalCount(cfg.totalId,data); - const chart=renderChart(cfg.domId,data,'bar',cfg.color); - setPortChart(cfg.port,chart); - }); - - 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}`), - value: d.count - })); - chartPacketTypes=renderPieChart("chart_packet_types",formatted,"Packet Types (Last 24h)"); - } finally { - isRefreshing=false; - } -} async function init(){ const channels = await fetchChannels(); - populateChannelSelect(channels); - const select=document.getElementById("channelSelect"); - if(select && !select.dataset.listenerAttached){ - select.addEventListener("change",async e=>{ - currentChannel=e.target.value; - await refreshDashboard(); - }); - 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(); + const select = document.getElementById("channelSelect"); + channels.forEach(ch=>{ const opt = document.createElement("option"); opt.value = ch; opt.textContent = ch; select.appendChild(opt); }); + + const dailyAllData=await fetchStats('day',14); + updateTotalCount('total_daily_all',dailyAllData); + chartDailyAll=renderChart('chart_daily_all',dailyAllData,'line','#66bb6a'); + + const dailyPort1Data=await fetchStats('day',14,1); + updateTotalCount('total_daily_portnum_1',dailyPort1Data); + chartDailyPortnum1=renderChart('chart_daily_portnum_1',dailyPort1Data,'bar','#ff5722'); + + const hourlyAllData=await fetchStats('hour',24); + updateTotalCount('total_hourly_all',hourlyAllData); + chartHourlyAll=renderChart('chart_hourly_all',hourlyAllData,'bar','#03dac6'); + + const portnums=[1,3,4,67,70,71]; + const colors=['#ff5722','#2196f3','#9c27b0','#ffeb3b','#795548','#4caf50']; + const domIds=['chart_portnum_1','chart_portnum_3','chart_portnum_4','chart_portnum_67','chart_portnum_70','chart_portnum_71']; + const totalIds=['total_portnum_1','total_portnum_3','total_portnum_4','total_portnum_67','total_portnum_70','total_portnum_71']; + const allData=await Promise.all(portnums.map(pn=>fetchStats('hour',24,pn))); + for(let i=0;id.count>0).map(d=>({ name: d.portnum==="other" ? "Other" : (PORTNUM_LABELS[d.portnum]||`Port ${d.portnum}`), value: d.count })); + chartPacketTypes = renderPieChart("chart_packet_types",formatted,"Packet Types (Last 24h)"); } window.addEventListener('resize',()=>{ [chartHourlyAll,chartPortnum1,chartPortnum3,chartPortnum4,chartPortnum67,chartPortnum70,chartPortnum71, chartDailyAll,chartDailyPortnum1,chartHwModel,chartRole,chartChannel,chartPacketTypes].forEach(c=>c?.resize()); }); @@ -735,6 +342,14 @@ document.querySelectorAll(".export-btn").forEach(btn=>{ }); }); +document.getElementById("channelSelect").addEventListener("change", async (e)=>{ + const channel = e.target.value; + const packetTypesData = await fetchPacketTypeBreakdown(channel); + const formatted = packetTypesData.filter(d=>d.count>0).map(d=>({ name: d.portnum==="other" ? "Other" : (PORTNUM_LABELS[d.portnum]||`Port ${d.portnum}`), value: d.count })); + chartPacketTypes?.dispose(); + chartPacketTypes = renderPieChart("chart_packet_types",formatted,"Packet Types (Last 24h)"); +}); + init(); // --- Translation Loader --- diff --git a/meshview/web.py b/meshview/web.py index 728ab45..54f015b 100644 --- a/meshview/web.py +++ b/meshview/web.py @@ -33,17 +33,6 @@ SEQ_REGEX = re.compile(r"seq \d+") SOFTWARE_RELEASE = "2.0.7 ~ 09-17-25" CONFIG = config.CONFIG -ACTIVITY_FILTERS = [ - ("1h", "Last 1 hour", timedelta(hours=1)), - ("8h", "Last 8 hours", timedelta(hours=8)), - ("1d", "Last 1 day", timedelta(days=1)), - ("3d", "Last 3 days", timedelta(days=3)), - ("7d", "Last 7 days", timedelta(days=7)), - ("total", "All time", None), -] -ACTIVITY_OPTIONS = {value: window for value, _label, window in ACTIVITY_FILTERS} -DEFAULT_ACTIVITY_OPTION = "1d" - env = Environment(loader=PackageLoader("meshview"), autoescape=select_autoescape()) # Start Database @@ -197,17 +186,6 @@ def format_timestamp(timestamp): env.filters["node_id_to_hex"] = node_id_to_hex env.filters["format_timestamp"] = format_timestamp - -def resolve_activity_window( - raw_value: str | None, default_key: str = DEFAULT_ACTIVITY_OPTION -): - default_key = default_key if default_key in ACTIVITY_OPTIONS else DEFAULT_ACTIVITY_OPTION - if raw_value: - normalized = raw_value.strip().lower() - if normalized in ACTIVITY_OPTIONS: - return normalized, ACTIVITY_OPTIONS[normalized] - return default_key, ACTIVITY_OPTIONS[default_key] - routes = web.RouteTableDef() @@ -447,27 +425,15 @@ async def packet_details(request): @routes.get("/firehose") async def packet_details_firehose(request): - portnum_value = request.query.get("portnum") - channel = request.query.get("channel") - - portnum = None - if portnum_value: - try: - portnum = int(portnum_value) - except ValueError: - logger.warning("Invalid portnum '%s' provided to /firehose", portnum_value) - - packets = await store.get_packets(portnum=portnum, limit=10, channel=channel) - ui_packets = [Packet.from_model(p) for p in packets] - latest_time = ui_packets[0].import_time.isoformat() if ui_packets else None - + portnum = request.query.get("portnum") + if portnum: + portnum = int(portnum) + packets = await store.get_packets(portnum=portnum, limit=10) template = env.get_template("firehose.html") return web.Response( text=template.render( - packets=ui_packets, + packets=(Packet.from_model(p) for p in packets), portnum=portnum, - channel=channel, - last_time=latest_time, site_config=CONFIG, SOFTWARE_RELEASE=SOFTWARE_RELEASE, ), @@ -488,18 +454,8 @@ async def firehose_updates(request): logger.error(f"Failed to parse last_time '{last_time_str}': {e}") last_time = None - portnum_value = request.query.get("portnum") - channel = request.query.get("channel") - - portnum = None - if portnum_value: - try: - portnum = int(portnum_value) - except ValueError: - logger.warning("Invalid portnum '%s' provided to /firehose/updates", portnum_value) - # Query packets after last_time (microsecond precision) - packets = await store.get_packets(after=last_time, limit=10, portnum=portnum, channel=channel) + packets = await store.get_packets(after=last_time, limit=10) # Convert to UI model ui_packets = [Packet.from_model(p) for p in packets] @@ -1203,15 +1159,7 @@ async def net(request): @routes.get("/map") async def map(request): try: - activity_param = request.query.get("active") - if not activity_param: - legacy_days = request.query.get("days_active") - if legacy_days and legacy_days.isdigit(): - activity_param = "total" if legacy_days == "0" else f"{legacy_days}d" - - selected_activity, activity_window = resolve_activity_window(activity_param) - - nodes = await store.get_nodes(active_within=activity_window) + nodes = await store.get_nodes(days_active=3) # Filter out nodes with no latitude nodes = [node for node in nodes if node.last_lat is not None] @@ -1244,8 +1192,6 @@ async def map(request): text=template.render( nodes=nodes, custom_view=custom_view, - activity_filters=ACTIVITY_FILTERS, - selected_activity=selected_activity, site_config=CONFIG, SOFTWARE_RELEASE=SOFTWARE_RELEASE, ), @@ -1384,26 +1330,22 @@ async def chat(request): # Assuming the route URL structure is /nodegraph @routes.get("/nodegraph") async def nodegraph(request): - activity_param = request.query.get("active") - if not activity_param: - legacy_days = request.query.get("days_active") - if legacy_days and legacy_days.isdigit(): - activity_param = "total" if legacy_days == "0" else f"{legacy_days}d" - - selected_activity, activity_window = resolve_activity_window(activity_param) - - nodes = await store.get_nodes(active_within=activity_window) - - active_node_ids = {node.node_id for node in nodes} + nodes = await store.get_nodes(days_active=3) # Fetch nodes for the given channel + node_ids = set() edges_map = defaultdict( lambda: {"weight": 0, "type": None} ) # weight is based on the number of traceroutes and neighbor info packets + used_nodes = set() # This will track nodes involved in edges (including traceroutes) since = datetime.timedelta(hours=48) traceroutes = [] # Fetch traceroutes async for tr in store.get_traceroutes(since): + node_ids.add(tr.gateway_node_id) + node_ids.add(tr.packet.from_node_id) + node_ids.add(tr.packet.to_node_id) route = decode_payload.decode_payload(PortNum.TRACEROUTE_APP, tr.route) + node_ids.update(route.route) path = [tr.packet.from_node_id] path.extend(route.route) @@ -1419,12 +1361,18 @@ async def nodegraph(request): edge_pair = (path[i], path[i + 1]) edges_map[edge_pair]["weight"] += 1 edges_map[edge_pair]["type"] = "traceroute" + used_nodes.add(path[i]) # Add all nodes in the traceroute path + used_nodes.add(path[i + 1]) # Add all nodes in the traceroute path # Fetch NeighborInfo packets for packet in await store.get_packets(portnum=PortNum.NEIGHBORINFO_APP, after=since): try: _, neighbor_info = decode_payload.decode(packet) + node_ids.add(packet.from_node_id) + used_nodes.add(packet.from_node_id) for node in neighbor_info.neighbors: + node_ids.add(node.node_id) + used_nodes.add(node.node_id) edge_pair = (node.node_id, packet.from_node_id) edges_map[edge_pair]["weight"] += 1 @@ -1433,16 +1381,7 @@ async def nodegraph(request): logger.error(f"Error decoding NeighborInfo packet: {e}") # Convert edges_map to a list of dicts with colors - filtered_edge_items = [ - ((frm, to), info) - for (frm, to), info in edges_map.items() - if frm in active_node_ids and to in active_node_ids - ] - max_weight = ( - max(info["weight"] for _, info in filtered_edge_items) - if filtered_edge_items - else 1 - ) + max_weight = max(i['weight'] for i in edges_map.values()) if edges_map else 1 edges = [ { "from": frm, @@ -1450,22 +1389,17 @@ async def nodegraph(request): "type": info["type"], "weight": max([info['weight'] / float(max_weight) * 10, 1]), } - for (frm, to), info in filtered_edge_items + for (frm, to), info in edges_map.items() ] # Filter nodes to only include those involved in edges (including traceroutes) - connected_node_ids = { - node_id for edge in edges for node_id in (edge["from"], edge["to"]) - } - nodes_with_edges = [node for node in nodes if node.node_id in connected_node_ids] + nodes_with_edges = [node for node in nodes if node.node_id in used_nodes] template = env.get_template("nodegraph.html") return web.Response( text=template.render( nodes=nodes_with_edges, edges=edges, # Pass edges with color info - activity_filters=ACTIVITY_FILTERS, - selected_activity=selected_activity, site_config=CONFIG, SOFTWARE_RELEASE=SOFTWARE_RELEASE, ), @@ -1524,7 +1458,6 @@ async def api_chat(request): # Parse query params limit_str = request.query.get("limit", "20") since_str = request.query.get("since") - channel_filter = request.query.get("channel") # Clamp limit between 1 and 200 try: @@ -1544,9 +1477,7 @@ async def api_chat(request): packets = await store.get_packets( node_id=0xFFFFFFFF, portnum=PortNum.TEXT_MESSAGE_APP, - after=since, limit=limit, - channel=channel_filter, ) ui_packets = [Packet.from_model(p) for p in packets] @@ -1616,20 +1547,17 @@ async def api_nodes(request): role = request.query.get("role") channel = request.query.get("channel") hw_model = request.query.get("hw_model") - activity_param = request.query.get("active") - if not activity_param: - legacy_days = request.query.get("days_active") - if legacy_days and legacy_days.isdigit(): - activity_param = "total" if legacy_days == "0" else f"{legacy_days}d" + days_active = request.query.get("days_active") - _, activity_window = resolve_activity_window(activity_param, default_key="total") + if days_active: + try: + days_active = int(days_active) + except ValueError: + days_active = None # Fetch nodes from database using your get_nodes function nodes = await store.get_nodes( - role=role, - channel=channel, - hw_model=hw_model, - active_within=activity_window, + role=role, channel=channel, hw_model=hw_model, days_active=days_active ) # Prepare the JSON response @@ -1665,19 +1593,6 @@ 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: @@ -1687,14 +1602,7 @@ async def api_packets(request): logger.error(f"Failed to parse 'since' timestamp '{since_str}': {e}") # Fetch last N packets - 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 = await store.get_packets(limit=limit, after=since_time) packets = [Packet.from_model(p) for p in packets] # Build JSON response (no raw_payload) @@ -1770,22 +1678,6 @@ async def api_stats(request): return web.json_response(stats) -@routes.get("/api/stats/summary") -async def api_stats_summary(request): - channel = request.query.get("channel") or None - total_packets = await store.get_total_packet_count(channel=channel) - total_nodes = await store.get_total_node_count(channel=channel) - total_packets_seen = await store.get_total_packet_seen_count(channel=channel) - - return web.json_response( - { - "total_packets": total_packets, - "total_nodes": total_nodes, - "total_packets_seen": total_packets_seen, - } - ) - - @routes.get("/api/config") async def api_config(request): try: