Updates stats with pie chart and report for channel

This commit is contained in:
Pablo Revilla
2025-09-22 10:18:06 -07:00
parent 86b4fa6cbf
commit 03e198b80c
3 changed files with 158 additions and 72 deletions
+28
View File
@@ -340,3 +340,31 @@ async def get_packet_stats(
"from_node": from_node,
"data": data
}
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.
period_type: "hour" or "day"
length: number of hours or days to look back
"""
now = datetime.now()
if period_type == "hour":
start_time = now - timedelta(hours=length)
elif period_type == "day":
start_time = now - timedelta(days=length)
else:
raise ValueError("period_type must be 'hour' or 'day'")
async with database.async_session() as session:
q = (
select(Packet.channel)
.where(Packet.import_time >= start_time)
.distinct()
.order_by(Packet.channel)
)
result = await session.execute(q)
channels = [row[0] for row in result if row[0] is not None]
return channels
+119 -72
View File
@@ -76,6 +76,15 @@
color: #66bb6a;
font-weight: bold;
}
#channelSelect {
margin-bottom: 8px;
padding: 4px 6px;
background:#444;
color:#fff;
border:none;
border-radius:4px;
}
{% endblock %}
{% block head %}
@@ -84,26 +93,24 @@
{% block body %}
<div class="main-container">
<h2 class="main-header">Mesh Statistics - Summary (all available in Database) </h2>
<h2 class="main-header">Mesh Statistics - Summary (all available in Database)</h2>
<div class="summary-container" style="display:flex; justify-content:space-between; gap:10px; margin-bottom:20px;">
<div class="summary-card" style="flex:1;">
<p>Total Nodes</p>
<div class="summary-count">{{ "{:,}".format(total_nodes) }}</div>
<div class="summary-container" style="display:flex; justify-content:space-between; gap:10px; margin-bottom:20px;">
<div class="summary-card" style="flex:1;">
<p>Total Nodes</p>
<div class="summary-count">{{ "{:,}".format(total_nodes) }}</div>
</div>
<div class="summary-card" style="flex:1;">
<p>Total Packets</p>
<div class="summary-count">{{ "{:,}".format(total_packets) }}</div>
</div>
<div class="summary-card" style="flex:1;">
<p>Total Packets Seen</p>
<div class="summary-count">{{ "{:,}".format(total_packets_seen) }}</div>
</div>
</div>
<div class="summary-card" style="flex:1;">
<p>Total Packets</p>
<div class="summary-count">{{ "{:,}".format(total_packets) }}</div>
</div>
<div class="summary-card" style="flex:1;">
<p>Total Packets Seen</p>
<div class="summary-count">{{ "{:,}".format(total_packets_seen) }}</div>
</div>
</div>
{# Daily Charts #}
<!-- Daily Charts -->
<div class="card-section">
<p class="section-header">Packets per Day - All Ports (Last 14 Days)</p>
<div id="total_daily_all" class="total-count">Total: 0</div>
@@ -120,7 +127,18 @@
<div id="chart_daily_portnum_1" class="chart"></div>
</div>
{# Hourly Charts #}
<!-- Packet Types Pie Chart with Channel Selector (moved here) -->
<div class="card-section">
<p class="section-header">Packet Types - Last 24 Hours</p>
<select id="channelSelect">
<option value="">All Channels</option>
</select>
<button class="expand-btn" data-chart="chart_packet_types">Expand Chart</button>
<button class="export-btn" data-chart="chart_packet_types">Export CSV</button>
<div id="chart_packet_types" class="chart"></div>
</div>
<!-- Hourly Charts -->
<div class="card-section">
<p class="section-header">Packets per Hour - All Ports</p>
<div id="total_hourly_all" class="total-count">Total: 0</div>
@@ -137,47 +155,7 @@
<div id="chart_portnum_1" class="chart"></div>
</div>
<div class="card-section">
<p class="section-header">Packets per Hour - Position (Port 3)</p>
<div id="total_portnum_3" class="total-count">Total: 0</div>
<button class="expand-btn" data-chart="chart_portnum_3">Expand Chart</button>
<button class="export-btn" data-chart="chart_portnum_3">Export CSV</button>
<div id="chart_portnum_3" class="chart"></div>
</div>
<div class="card-section">
<p class="section-header">Packets per Hour - Node Info (Port 4)</p>
<div id="total_portnum_4" class="total-count">Total: 0</div>
<button class="expand-btn" data-chart="chart_portnum_4">Expand Chart</button>
<button class="export-btn" data-chart="chart_portnum_4">Export CSV</button>
<div id="chart_portnum_4" class="chart"></div>
</div>
<div class="card-section">
<p class="section-header">Packets per Hour - Telemetry (Port 67)</p>
<div id="total_portnum_67" class="total-count">Total: 0</div>
<button class="expand-btn" data-chart="chart_portnum_67">Expand Chart</button>
<button class="export-btn" data-chart="chart_portnum_67">Export CSV</button>
<div id="chart_portnum_67" class="chart"></div>
</div>
<div class="card-section">
<p class="section-header">Packets per Hour - Traceroute (Port 70)</p>
<div id="total_portnum_70" class="total-count">Total: 0</div>
<button class="expand-btn" data-chart="chart_portnum_70">Expand Chart</button>
<button class="export-btn" data-chart="chart_portnum_70">Export CSV</button>
<div id="chart_portnum_70" class="chart"></div>
</div>
<div class="card-section">
<p class="section-header">Packets per Hour - Neighbor Info (Port 71)</p>
<div id="total_portnum_71" class="total-count">Total: 0</div>
<button class="expand-btn" data-chart="chart_portnum_71">Expand Chart</button>
<button class="export-btn" data-chart="chart_portnum_71">Export CSV</button>
<div id="chart_portnum_71" class="chart"></div>
</div>
{# Node breakdown charts #}
<!-- Node breakdown charts -->
<div class="card-section">
<p class="section-header">Hardware Breakdown</p>
<button class="expand-btn" data-chart="chart_hw_model">Expand Chart</button>
@@ -200,7 +178,7 @@
</div>
</div>
{# Modal for expanded charts #}
<!-- Modal for expanded charts -->
<div id="chartModal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%;
background:rgba(0,0,0,0.7); z-index:1000; justify-content:center; align-items:center;">
<div style="position:relative; width:80%; max-width:1000px; height:80%;
@@ -214,11 +192,21 @@
</div>
<script>
const PORTNUM_LABELS = {
1: "Text Messages",
3: "Position",
4: "Node Info",
67: "Telemetry",
70: "Traceroute",
71: "Neighbor Info"
};
// --- Fetch & Processing ---
async function fetchStats(period_type,length,portnum=null){
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}`;
const res=await fetch(url);
if(!res.ok) return [];
const json=await res.json();
@@ -234,6 +222,14 @@ async function fetchNodes(){
}catch{return [];}
}
async function fetchChannels(){
try{
const res = await fetch("/api/channels");
const json = await res.json();
return json.channels || [];
}catch{return [];}
}
function processCountField(nodes,field){
const counts={};
nodes.forEach(n=>{
@@ -300,12 +296,43 @@ function renderPieChart(elId,data,name){
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 allData = await fetchStats('hour',24,null,channel);
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);
if(other>0) results.push({portnum:"other", count:other});
return results;
}
// --- Init ---
let chartHourlyAll, chartPortnum1, chartPortnum3, chartPortnum4, chartPortnum67, chartPortnum70, chartPortnum71;
let chartDailyAll, chartDailyPortnum1;
let chartHwModel, chartRole, chartChannel;
let chartPacketTypes;
async function init(){
// Populate channels
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);
});
// Daily
const dailyAllData=await fetchStats('day',14);
updateTotalCount('total_daily_all',dailyAllData);
chartDailyAll=renderChart('chart_daily_all',dailyAllData,'line','#66bb6a',false);
@@ -314,6 +341,7 @@ async function init(){
updateTotalCount('total_daily_portnum_1',dailyPort1Data);
chartDailyPortnum1=renderChart('chart_daily_portnum_1',dailyPort1Data,'bar','#ff5722',false);
// Hourly
const hourlyAllData=await fetchStats('hour',24);
updateTotalCount('total_hourly_all',hourlyAllData);
chartHourlyAll=renderChart('chart_hourly_all',hourlyAllData,'bar','#03dac6',true);
@@ -328,16 +356,25 @@ async function init(){
window['chartPortnum'+portnums[i]]=renderChart(domIds[i],allData[i],'bar',colors[i],true);
}
// Node Breakdown
const nodes=await fetchNodes();
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");
// Packet Type Pie Chart
const packetTypesData = await fetchPacketTypeBreakdown();
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)");
}
// --- Resize ---
window.addEventListener('resize',()=>{
[chartHourlyAll,chartPortnum1,chartPortnum3,chartPortnum4,chartPortnum67,chartPortnum70,chartPortnum71,
chartDailyAll,chartDailyPortnum1,chartHwModel,chartRole,chartChannel].forEach(c=>c?.resize());
chartDailyAll,chartDailyPortnum1,chartHwModel,chartRole,chartChannel,chartPacketTypes].forEach(c=>c?.resize());
});
// --- Modal ---
@@ -348,25 +385,23 @@ let modalChart=null;
document.querySelectorAll(".expand-btn").forEach(btn=>{
btn.addEventListener("click",()=>{
const chartId=btn.getAttribute("data-chart");
const chartInstance=echarts.getInstanceByDom(document.getElementById(chartId));
if(!chartInstance) return;
const chartData=chartInstance.getOption();
const sourceChart=echarts.getInstanceByDom(document.getElementById(chartId));
if(!sourceChart)return;
modal.style.display="flex";
if(modalChart) modalChart.dispose();
modalChart=echarts.init(modalChartEl);
modalChart.setOption(chartData);
modalChart.resize();
modalChart.setOption(sourceChart.getOption());
});
});
document.getElementById("closeModal").addEventListener("click",()=>{
modal.style.display="none";
if(modalChart) modalChart.dispose();
modalChart?.dispose();
modalChart=null;
});
// --- CSV Export ---
function downloadCSV(filename,rows){
const csvContent=rows.map(e=>e.join(",")).join("\n");
const csvContent=rows.map(r=>r.map(v=>`"${v}"`).join(",")).join("\n");
const blob=new Blob([csvContent],{type:"text/csv;charset=utf-8;"});
const link=document.createElement("a");
link.href=URL.createObjectURL(blob);
@@ -380,13 +415,13 @@ document.querySelectorAll(".export-btn").forEach(btn=>{
btn.addEventListener("click",()=>{
const chartId=btn.getAttribute("data-chart");
const chart=echarts.getInstanceByDom(document.getElementById(chartId));
if(!chart) return;
if(!chart)return;
const option=chart.getOption();
let rows=[];
if(option.series[0].type==="bar"||option.series[0].type==="line"){
rows.push(["Period","Count"]);
const xData=option.xAxis[0].data;
const yData=option.series[0].data;
rows.push(["Period","Count"]);
for(let i=0;i<xData.length;i++) rows.push([xData[i],yData[i]]);
}
if(option.series[0].type==="pie"){
@@ -401,6 +436,18 @@ document.querySelectorAll(".export-btn").forEach(btn=>{
});
});
// --- Channel filter for Packet Types ---
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();
</script>
{% endblock %}
+11
View File
@@ -1340,6 +1340,17 @@ async def get_config(request):
# The response includes "latest_import_time" for frontend to keep track of the newest message timestamp.
# The backend fetches extra packets (limit*5) to account for filtering messages like "seq N" and since filtering.
@routes.get("/api/channels")
async def api_channels(request: web.Request):
period_type = request.query.get("period_type", "hour")
length = int(request.query.get("length", 24))
try:
channels = await store.get_channels_in_period(period_type, length)
return web.json_response({"channels": channels})
except Exception as e:
return web.json_response({"channels": [], "error": str(e)})
@routes.get("/api/chat")
async def api_chat(request):