Files
meshview/meshview/templates/stats.html
2025-09-19 08:50:10 -07:00

407 lines
15 KiB
HTML

{% extends "base.html" %}
{% block css %}
#packet_details {
height: 95vh;
overflow: auto;
}
.main-container, .container {
max-width: 900px;
margin: 0 auto;
text-align: center;
}
.card-section {
background-color: #272b2f;
border: 1px solid #474b4e;
padding: 15px 20px;
margin-bottom: 20px;
border-radius: 10px;
transition: background-color 0.2s ease;
}
.card-section:hover {
background-color: #2f3338;
}
.section-header {
font-size: 16px;
margin: 0 0 6px 0;
font-weight: 500;
color: #fff;
}
.main-header {
font-size: 22px;
margin-bottom: 20px;
font-weight: 600;
}
.chart {
height: 400px;
margin-top: 10px;
}
.total-count {
color: #ccc;
font-size: 14px;
margin-bottom: 8px;
}
.expand-btn, .export-btn {
margin-bottom: 8px;
padding: 4px 8px;
cursor: pointer;
background-color: #444;
color: #fff;
border: none;
border-radius: 4px;
font-size: 12px;
}
.expand-btn:hover { background-color: #666; }
.export-btn:hover { background-color: #777; }
/* Summary cards at top */
.summary-card {
background-color: #1f2124;
border: 1px solid #474b4e;
padding: 10px 15px;
margin-bottom: 15px;
border-radius: 8px;
}
.summary-count {
font-size: 18px;
color: #66bb6a;
font-weight: bold;
}
{% endblock %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
{% endblock %}
{% block body %}
<div class="main-container">
<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>
<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 #}
<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>
<button class="expand-btn" data-chart="chart_daily_all">Expand Chart</button>
<button class="export-btn" data-chart="chart_daily_all">Export CSV</button>
<div id="chart_daily_all" class="chart"></div>
</div>
<div class="card-section">
<p class="section-header">Packets per Day - Text Messages (Port 1, Last 14 Days)</p>
<div id="total_daily_portnum_1" class="total-count">Total: 0</div>
<button class="expand-btn" data-chart="chart_daily_portnum_1">Expand Chart</button>
<button class="export-btn" data-chart="chart_daily_portnum_1">Export CSV</button>
<div id="chart_daily_portnum_1" 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>
<button class="expand-btn" data-chart="chart_hourly_all">Expand Chart</button>
<button class="export-btn" data-chart="chart_hourly_all">Export CSV</button>
<div id="chart_hourly_all" class="chart"></div>
</div>
<div class="card-section">
<p class="section-header">Packets per Hour - Text Messages (Port 1)</p>
<div id="total_portnum_1" class="total-count">Total: 0</div>
<button class="expand-btn" data-chart="chart_portnum_1">Expand Chart</button>
<button class="export-btn" data-chart="chart_portnum_1">Export CSV</button>
<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 #}
<div class="card-section">
<p class="section-header">Hardware Breakdown</p>
<button class="expand-btn" data-chart="chart_hw_model">Expand Chart</button>
<button class="export-btn" data-chart="chart_hw_model">Export CSV</button>
<div id="chart_hw_model" class="chart"></div>
</div>
<div class="card-section">
<p class="section-header">Role Breakdown</p>
<button class="expand-btn" data-chart="chart_role">Expand Chart</button>
<button class="export-btn" data-chart="chart_role">Export CSV</button>
<div id="chart_role" class="chart"></div>
</div>
<div class="card-section">
<p class="section-header">Channel Breakdown</p>
<button class="expand-btn" data-chart="chart_channel">Expand Chart</button>
<button class="export-btn" data-chart="chart_channel">Export CSV</button>
<div id="chart_channel" class="chart"></div>
</div>
</div>
{# 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%;
background:#272b2f; border-radius:10px; padding:10px; display:flex; flex-direction:column;">
<button id="closeModal" style="position:absolute; top:10px; right:10px; z-index:1010;
background:#ff4c4c; border:none; color:white; font-size:24px; width:36px; height:36px;
border-radius:50%; cursor:pointer;">&times;</button>
<div id="modalChart" style="flex:1; width:100%; height:100%;"></div>
</div>
</div>
<script>
// --- Fetch & Processing ---
async function fetchStats(period_type,length,portnum=null){
try{
let url=`/api/stats?period_type=${period_type}&length=${length}`;
if(portnum!==null) url+=`&portnum=${portnum}`;
const res=await fetch(url);
if(!res.ok) return [];
const json=await res.json();
return json.data||[];
}catch{return [];}
}
async function fetchNodes(){
try{
const res=await fetch("/api/nodes");
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;
}
// --- Chart Rendering ---
function renderChart(domId,data,type,color,isHourly){
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);
const option={
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}]
};
chart.setOption(option);
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);
const option={
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
}]
};
chart.setOption(option);
return chart;
}
// --- Init ---
let chartHourlyAll, chartPortnum1, chartPortnum3, chartPortnum4, chartPortnum67, chartPortnum70, chartPortnum71;
let chartDailyAll, chartDailyPortnum1;
let chartHwModel, chartRole, chartChannel;
async function init(){
const dailyAllData=await fetchStats('day',14);
updateTotalCount('total_daily_all',dailyAllData);
chartDailyAll=renderChart('chart_daily_all',dailyAllData,'line','#66bb6a',false);
const dailyPort1Data=await fetchStats('day',14,1);
updateTotalCount('total_daily_portnum_1',dailyPort1Data);
chartDailyPortnum1=renderChart('chart_daily_portnum_1',dailyPort1Data,'bar','#ff5722',false);
const hourlyAllData=await fetchStats('hour',24);
updateTotalCount('total_hourly_all',hourlyAllData);
chartHourlyAll=renderChart('chart_hourly_all',hourlyAllData,'bar','#03dac6',true);
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;i<portnums.length;i++){
updateTotalCount(totalIds[i],allData[i]);
window['chartPortnum'+portnums[i]]=renderChart(domIds[i],allData[i],'bar',colors[i],true);
}
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");
}
// --- Resize ---
window.addEventListener('resize',()=>{
[chartHourlyAll,chartPortnum1,chartPortnum3,chartPortnum4,chartPortnum67,chartPortnum70,chartPortnum71,
chartDailyAll,chartDailyPortnum1,chartHwModel,chartRole,chartChannel].forEach(c=>c?.resize());
});
// --- Modal ---
const modal=document.getElementById("chartModal");
const modalChartEl=document.getElementById("modalChart");
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();
modal.style.display="flex";
if(modalChart) modalChart.dispose();
modalChart=echarts.init(modalChartEl);
modalChart.setOption(chartData);
modalChart.resize();
});
});
document.getElementById("closeModal").addEventListener("click",()=>{
modal.style.display="none";
if(modalChart) modalChart.dispose();
});
// --- CSV Export ---
function downloadCSV(filename,rows){
const csvContent=rows.map(e=>e.join(",")).join("\n");
const blob=new Blob([csvContent],{type:"text/csv;charset=utf-8;"});
const link=document.createElement("a");
link.href=URL.createObjectURL(blob);
link.setAttribute("download",filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
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;
const option=chart.getOption();
let rows=[];
if(option.series[0].type==="bar"||option.series[0].type==="line"){
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"){
rows.push(["Name","Value","Percentage"]);
const total=option.series[0].data.reduce((sum,d)=>sum+d.value,0);
option.series[0].data.forEach(d=>{
const percent=Math.round((d.value/total)*100);
rows.push([d.name,d.value,percent+"%"]);
});
}
downloadCSV(chartId+".csv",rows);
});
});
init();
</script>
{% endblock %}