From 257bf7ffac0f8bdc53903925f8fc8be63977d315 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sat, 11 Oct 2025 16:28:34 -0700 Subject: [PATCH 1/3] Add channel filters to stats, chat, and firehose views --- meshview/store.py | 12 +- meshview/templates/chat.html | 144 +++++++++++++- meshview/templates/firehose.html | 206 ++++++++++++++++---- meshview/templates/stats.html | 317 +++++++++++++++++++++++++------ meshview/web.py | 53 +++++- 5 files changed, 617 insertions(+), 115 deletions(-) diff --git a/meshview/store.py b/meshview/store.py index ef3c7ae..6bfa225 100644 --- a/meshview/store.py +++ b/meshview/store.py @@ -24,7 +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): +async def get_packets(node_id=None, portnum=None, after=None, before=None, limit=None, channel: str | None = None): async with database.async_session() as session: q = select(Packet) @@ -36,6 +36,8 @@ async def get_packets(node_id=None, portnum=None, after=None, before=None, limit q = q.where(Packet.import_time > after) if before: q = q.where(Packet.import_time < before) + if channel: + q = q.where(func.lower(Packet.channel) == channel.lower()) q = q.order_by(Packet.import_time.desc()) @@ -147,17 +149,21 @@ 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(): +async def get_total_packet_count(channel: str | None = None) -> int: 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(): +async def get_total_packet_seen_count(channel: str | None = None) -> int: 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 diff --git a/meshview/templates/chat.html b/meshview/templates/chat.html index 3df51a9..f297b6a 100644 --- a/meshview/templates/chat.html +++ b/meshview/templates/chat.html @@ -17,19 +17,61 @@ /* 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/stats.html b/meshview/templates/stats.html index e045371..38bd3fe 100644 --- a/meshview/templates/stats.html +++ b/meshview/templates/stats.html @@ -77,14 +77,27 @@ font-weight: bold; } +.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 %} @@ -95,18 +108,25 @@

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) }}
@@ -122,9 +142,6 @@

Packet Types - Last 24 Hours

-
@@ -201,12 +218,25 @@ 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 = ""; + // --- 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=${channel}`; + if(channel) url+=`&channel=${encodeURIComponent(channel)}`; const res=await fetch(url); if(!res.ok) return []; const json=await res.json(); @@ -214,28 +244,119 @@ async function fetchStats(period_type,length,portnum=null,channel=null){ }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 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 [];} +} -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; } +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; +} // --- Chart Rendering --- -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 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 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; } +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; +} // --- Packet Type Pie Chart --- async function fetchPacketTypeBreakdown(channel=null) { - 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 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 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); @@ -243,44 +364,130 @@ 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"); + + 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(); - 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)"); + 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"; + } + await refreshDashboard(); } window.addEventListener('resize',()=>{ [chartHourlyAll,chartPortnum1,chartPortnum3,chartPortnum4,chartPortnum67,chartPortnum70,chartPortnum71, chartDailyAll,chartDailyPortnum1,chartHwModel,chartRole,chartChannel,chartPacketTypes].forEach(c=>c?.resize()); }); @@ -342,14 +549,6 @@ 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 54f015b..d56db07 100644 --- a/meshview/web.py +++ b/meshview/web.py @@ -425,15 +425,27 @@ async def packet_details(request): @routes.get("/firehose") async def packet_details_firehose(request): - portnum = request.query.get("portnum") - if portnum: - portnum = int(portnum) - packets = await store.get_packets(portnum=portnum, limit=10) + 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 + template = env.get_template("firehose.html") return web.Response( text=template.render( - packets=(Packet.from_model(p) for p in packets), + packets=ui_packets, portnum=portnum, + channel=channel, + last_time=latest_time, site_config=CONFIG, SOFTWARE_RELEASE=SOFTWARE_RELEASE, ), @@ -454,8 +466,18 @@ 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) + packets = await store.get_packets(after=last_time, limit=10, portnum=portnum, channel=channel) # Convert to UI model ui_packets = [Packet.from_model(p) for p in packets] @@ -1458,6 +1480,7 @@ 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: @@ -1477,7 +1500,9 @@ 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] @@ -1678,6 +1703,22 @@ 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: From b7752bc3154b05778805468997ab546a9515e1be Mon Sep 17 00:00:00 2001 From: Nathan Date: Sat, 11 Oct 2025 21:21:10 -0700 Subject: [PATCH 2/3] Map: activity time filters --- meshview/store.py | 31 +++++++- meshview/templates/map.html | 85 +++++++++++++++++++--- meshview/templates/nodegraph.html | 77 +++++++++++++++++--- meshview/web.py | 115 +++++++++++++++++++++++------- 4 files changed, 261 insertions(+), 47 deletions(-) diff --git a/meshview/store.py b/meshview/store.py index 6bfa225..d39daf5 100644 --- a/meshview/store.py +++ b/meshview/store.py @@ -263,7 +263,13 @@ async def get_node_traffic(node_id: int): return [] -async def get_nodes(role=None, channel=None, hw_model=None, days_active=None): +async def get_nodes( + role=None, + channel=None, + hw_model=None, + days_active=None, + active_within: timedelta | None = None, +): """ Fetches nodes from the database based on optional filtering criteria. @@ -271,6 +277,8 @@ async def get_nodes(role=None, channel=None, hw_model=None, days_active=None): 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. @@ -290,8 +298,12 @@ async def get_nodes(role=None, channel=None, hw_model=None, days_active=None): if hw_model is not None: query = query.where(Node.hw_model == hw_model) - if days_active is not None: - query = query.where(Node.last_update > datetime.now() - timedelta(days_active)) + 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) # Exclude nodes where last_update is an empty string query = query.where(Node.last_update != "") @@ -360,6 +372,19 @@ 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 e093963..4d6ee3c 100644 --- a/meshview/templates/map.html +++ b/meshview/templates/map.html @@ -23,10 +23,20 @@ #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; @@ -72,6 +82,12 @@ {% block body %}
+ + Show Routers Only
@@ -101,6 +117,18 @@ async function loadTranslations() { // Initialize map AFTER translations are loaded 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'); @@ -135,6 +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 portMap = {1: "Text", 67: "Telemetry", 3: "Position", 70: "Traceroute", 4: "Node Info", 71: "Neighbour Info", 73: "Map Report"}; @@ -162,18 +192,24 @@ loadTranslations().then(() => { return color; } + function channelKey(channel) { + if (typeof channel === 'string' && channel.trim().length > 0) { + return channel; + } + return 'Unknown'; + } + 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 = node.channel; - channels.add(category); + let category = channelKey(node.channel); + channelSet.add(category); let color = hashToColor(category); let popupContent = `${node.long_name} (${node.short_name})
@@ -209,6 +245,8 @@ loadTranslations().then(() => { if (customView) map.setView([customView.lat,customView.lng],customView.zoom); else map.fitBounds(areaBounds); + const channelList = Array.from(channelSet).sort(); + // ---- LocalStorage for Filter Preferences ---- const FILTER_STORAGE_KEY = 'meshview_map_filters'; @@ -225,7 +263,7 @@ loadTranslations().then(() => { channels: {} }; - channels.forEach(channel => { + channelList.forEach(channel => { let filterId = `filter-${channel.replace(/\s+/g, '-').toLowerCase()}`; let checkbox = document.getElementById(filterId); if (checkbox) { @@ -259,7 +297,7 @@ loadTranslations().then(() => { document.getElementById("filter-routers-only").checked = false; // Reset all channel filters to checked (default) - channels.forEach(channel => { + channelList.forEach(channel => { let filterId = `filter-${channel.replace(/\s+/g, '-').toLowerCase()}`; let checkbox = document.getElementById(filterId); if (checkbox) { @@ -286,7 +324,7 @@ loadTranslations().then(() => { filterLabel.textContent = t.show_routers_only || "Show Routers Only"; let filterContainer = document.getElementById("filter-container"); - channels.forEach(channel => { + channelList.forEach(channel => { let filterId = `filter-${channel.replace(/\s+/g,'-').toLowerCase()}`; let color = hashToColor(channel); let label = document.createElement('label'); @@ -302,7 +340,7 @@ loadTranslations().then(() => { document.getElementById("filter-routers-only").checked = savedFilters.routersOnly || false; // Apply channel filters - channels.forEach(channel => { + channelList.forEach(channel => { let filterId = `filter-${channel.replace(/\s+/g, '-').toLowerCase()}`; let checkbox = document.getElementById(filterId); if (checkbox && savedFilters.channels.hasOwnProperty(channel)) { @@ -314,15 +352,19 @@ loadTranslations().then(() => { function updateMarkers() { let showRoutersOnly = document.getElementById("filter-routers-only").checked; nodes.forEach(node => { - let category=node.channel; + let category = channelKey(node.channel); let checkbox=document.getElementById(`filter-${category.replace(/\s+/g,'-').toLowerCase()}`); - let shouldShow=checkbox.checked && (!showRoutersOnly || node.isRouter); + let shouldShow=(!checkbox || 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(); + } } document.querySelectorAll(".filter-checkbox").forEach(input=>input.addEventListener("change",updateMarkers)); @@ -338,7 +380,11 @@ loadTranslations().then(() => { const zoom = map.getZoom(); const lat = center.lat.toFixed(6); const lng = center.lng.toFixed(6); - const shareUrl = `${window.location.origin}/map?lat=${lat}&lng=${lng}&zoom=${zoom}`; + 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(); navigator.clipboard.writeText(shareUrl).then(()=>{ const orig = shareBtn.textContent; shareBtn.textContent = '✓ Link Copied!'; @@ -474,8 +520,25 @@ loadTranslations().then(() => { }).catch(err=>console.error(err)); } let packetInterval=null; - function startPacketFetcher(){ if(mapInterval<=0) return; if(!packetInterval){ fetchLatestPacket(); packetInterval=setInterval(fetchNewPackets,mapInterval*1000); } } + function startPacketFetcher(resetImportTime=true){ + if(mapInterval<=0) return; + if(!packetInterval){ + if(resetImportTime || !lastImportTime){ + fetchLatestPacket(); + } + packetInterval=setInterval(fetchNewPackets,mapInterval*1000); + if(!resetImportTime && lastImportTime){ + fetchNewPackets(); + } + } + } function stopPacketFetcher(){ if(packetInterval){ clearInterval(packetInterval); packetInterval=null; } } + function restartPacketFetcher(){ + if(mapInterval<=0) return; + stopPacketFetcher(); + if(document.hidden) return; + startPacketFetcher(false); + } document.addEventListener("visibilitychange",function(){ if(document.hidden) stopPacketFetcher(); else startPacketFetcher(); }); if(mapInterval>0) startPacketFetcher(); }); diff --git a/meshview/templates/nodegraph.html b/meshview/templates/nodegraph.html index 3d25625..e833a27 100644 --- a/meshview/templates/nodegraph.html +++ b/meshview/templates/nodegraph.html @@ -87,6 +87,12 @@
+ + @@ -123,6 +129,25 @@
{% 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)