add API code for /api/packets

This commit is contained in:
Pablo Revilla
2025-08-11 13:57:30 -07:00
parent 635cb7bb41
commit d8ceb19163
3 changed files with 253 additions and 36 deletions
+59 -7
View File
@@ -1,10 +1,9 @@
import datetime
from sqlalchemy import select, func
from sqlalchemy.orm import lazyload
from meshview import database
from meshview.models import Packet, PacketSeen, Node, Traceroute
from sqlalchemy import text
from datetime import datetime, timedelta
async def get_node(node_id):
async with database.async_session() as session:
@@ -60,7 +59,7 @@ async def get_packets_from(node_id=None, portnum=None, since=None, limit=500):
if portnum:
q = q.where(Packet.portnum == portnum)
if since:
q = q.where(Packet.import_time > (datetime.datetime.now() - since))
q = q.where(Packet.import_time > (datetime.now() - since))
result = await session.execute(q.limit(limit).order_by(Packet.import_time.desc()))
return result.scalars()
@@ -115,7 +114,7 @@ async def get_traceroutes(since):
result = await session.execute(
select(Traceroute)
.join(Packet)
.where(Traceroute.import_time > (datetime.datetime.now() - since))
.where(Traceroute.import_time > (datetime.now() - since))
.order_by(Traceroute.import_time)
)
return result.scalars()
@@ -128,7 +127,7 @@ async def get_mqtt_neighbors(since):
.where(
(PacketSeen.hop_limit == PacketSeen.hop_start)
& (PacketSeen.hop_start != 0)
& (PacketSeen.import_time > (datetime.datetime.now() - since))
& (PacketSeen.import_time > (datetime.now() - since))
)
.options(
lazyload(Packet.from_node),
@@ -159,7 +158,7 @@ async def get_total_node_count(channel: str = None) -> int:
try:
async with database.async_session() as session:
q = select(func.count(Node.id)).where(
Node.last_update > datetime.datetime.now() - datetime.timedelta(days=1)
Node.last_update > datetime.now() - timedelta(days=1)
)
if channel:
@@ -271,7 +270,7 @@ async def get_nodes(role=None, channel=None, hw_model=None, days_active=None):
query = query.where(Node.hw_model == hw_model)
if days_active is not None:
query = query.where(Node.last_update > datetime.datetime.now() - datetime.timedelta(days_active))
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 != "")
@@ -288,3 +287,56 @@ async def get_nodes(role=None, channel=None, hw_model=None, days_active=None):
print("error reading DB") # Consider using logging instead of print
return [] # Return an empty list in case of failure
async def get_packet_stats(
period_type: str = "day",
length: int = 14,
channel: str | None = None,
portnum: int | None = None,
to_node: int | None = None,
from_node: int | None = None
):
now = datetime.now()
if period_type == "hour":
start_time = now - timedelta(hours=length)
time_format = '%Y-%m-%d %H:00'
elif period_type == "day":
start_time = now - timedelta(days=length)
time_format = '%Y-%m-%d'
else:
raise ValueError("period_type must be 'hour' or 'day'")
async with database.async_session() as session:
q = (
select(
func.strftime(time_format, Packet.import_time).label('period'),
func.count().label('count')
)
.where(Packet.import_time >= start_time)
)
# Filters
if channel:
q = q.where(func.lower(Packet.channel) == channel.lower())
if portnum is not None:
q = q.where(Packet.portnum == portnum)
if to_node is not None:
q = q.where(Packet.to_node_id == to_node)
if from_node is not None:
q = q.where(Packet.from_node_id == from_node)
q = q.group_by('period').order_by('period')
result = await session.execute(q)
data = [{"period": row.period, "count": row.count} for row in result]
return {
"period_type": period_type,
"length": length,
"channel": channel,
"portnum": portnum,
"to_node": to_node,
"from_node": from_node,
"data": data
}
+124 -28
View File
@@ -7,7 +7,7 @@
}
.main-container, .container {
max-width: 600px;
max-width: 900px;
margin: 0 auto;
text-align: center;
}
@@ -16,7 +16,7 @@
background-color: #272b2f;
border: 1px solid #474b4e;
padding: 15px 20px;
margin-bottom: 10px;
margin-bottom: 20px;
border-radius: 10px;
transition: background-color 0.2s ease;
}
@@ -29,17 +29,7 @@
font-size: 16px;
margin: 0;
font-weight: 500;
}
.section-value {
font-weight: 700;
color: #03dac6;
}
.percentage {
font-size: 12px;
color: #ffeb3b;
font-weight: 400;
color: #fff;
}
.main-header {
@@ -47,34 +37,140 @@
margin-bottom: 20px;
font-weight: 600;
}
.chart {
height: 400px;
margin-top: 15px;
}
{% 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</h2>
<!-- Section for Total Nodes -->
<!-- Hourly Chart -->
<div class="card-section">
<p class="section-header">
Total Active Nodes (24 hours): <br>
<span class="section-value">{{ "{:,}".format(total_nodes) }}</span>
</p>
<p class="section-header">Packets per Hour (Last 24 Hours)</p>
<div id="chart_hourly" class="chart"></div>
</div>
<!-- Section for Total Packets -->
<!-- PortNum 1 Hourly Chart -->
<div class="card-section">
<p class="section-header">
Total Packets (14 days):
<span class="section-value">{{ "{:,}".format(total_packets) }}</span>
</p>
<p class="section-header">Packets per Hour for PortNum 1 (Last 24 Hours)</p>
<div id="chart_portnum_1" class="chart"></div>
</div>
<!-- Section for Total MQTT Reports -->
<!-- Daily Chart -->
<div class="card-section">
<p class="section-header">
Total MQTT Reports (14 days):
<span class="section-value">{{ "{:,}".format(total_packets_seen) }}</span>
</p>
<p class="section-header">Packets per Day (Last 14 Days)</p>
<div id="chart_daily" class="chart"></div>
</div>
<!-- PortNum 1 Daily Chart -->
<div class="card-section">
<p class="section-header">Packets per Day for PortNum 1 (Last 14 Days)</p>
<div id="chart_daily_portnum_1" class="chart"></div>
</div>
</div>
<script>
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) {
console.error(`Failed to fetch ${period_type} stats:`, res.status, res.statusText);
return [];
}
const json = await res.json();
return json.data || [];
} catch (err) {
console.error('Error fetching stats:', err);
return [];
}
}
let chartHourly = null;
let chartPortnum1 = null;
let chartDaily = null;
let chartDailyPortnum1 = null;
function renderChart(domId, data, type, color, isHourly) {
const el = document.getElementById(domId);
if (!el) return;
const chart = echarts.init(el);
if (domId === 'chart_hourly') chartHourly = chart;
else if (domId === 'chart_portnum_1') chartPortnum1 = chart;
else if (domId === 'chart_daily') chartDaily = chart;
else if (domId === 'chart_daily_portnum_1') chartDailyPortnum1 = chart;
const periods = data.map(d => {
const p = (d && (d.period || d.period === 0)) ? d.period.toString() : '';
if (isHourly) {
if (p.includes(' ')) return p.split(' ')[1];
return p.slice(-5) || p;
}
return p;
});
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);
}
async function init() {
const hourlyData = await fetchStats('hour', 24);
renderChart('chart_hourly', hourlyData, 'bar', '#03dac6', true);
const portnum1Data = await fetchStats('hour', 24, 1);
renderChart('chart_portnum_1', portnum1Data, 'bar', '#ff5722', true);
const dailyData = await fetchStats('day', 14);
renderChart('chart_daily', dailyData, 'line', '#ffeb3b', false);
const dailyPortnum1Data = await fetchStats('day', 14, 1);
renderChart('chart_daily_portnum_1', dailyPortnum1Data, 'bar', '#ff7043', false);
}
window.addEventListener('resize', () => {
if (chartHourly) chartHourly.resize();
if (chartPortnum1) chartPortnum1.resize();
if (chartDaily) chartDaily.resize();
if (chartDailyPortnum1) chartDailyPortnum1.resize();
});
init();
</script>
{% endblock %}
+70 -1
View File
@@ -384,7 +384,7 @@ async def packet_details(request):
portnum = request.query.get("portnum")
if portnum:
portnum = int(portnum)
packets = await store.get_packets(portnum=portnum, limit=20)
packets = await store.get_packets(portnum=portnum, limit=10)
template = env.get_template("firehose.html")
return web.Response(
text=template.render(
@@ -1395,6 +1395,18 @@ async def get_config(request):
except (json.JSONDecodeError, TypeError):
return web.json_response({"error": "Invalid configuration format"}, status=500)
@routes.get("/stats2")
async def packet_details(request):
template = env.get_template("stats2.html")
return web.Response(
text=template.render(
site_config = CONFIG,
SOFTWARE_RELEASE=SOFTWARE_RELEASE,
),
content_type="text/html",
)
# API Section
#######################################################################
# How this works
@@ -1569,6 +1581,63 @@ async def api_packets(request):
)
@routes.get("/api/stats")
async def api_stats(request):
"""
Return packet statistics for a given period type, length,
and optional filters for channel, portnum, to_node, from_node.
"""
allowed_periods = {"hour", "day"}
# period_type validation
period_type = request.query.get("period_type", "hour").lower()
if period_type not in allowed_periods:
return web.json_response(
{"error": f"Invalid period_type. Must be one of {allowed_periods}"},
status=400
)
# length validation
try:
length = int(request.query.get("length", 24))
except ValueError:
return web.json_response(
{"error": "length must be an integer"},
status=400
)
# Optional filters
channel = request.query.get("channel")
def parse_int_param(name):
value = request.query.get(name)
if value is not None:
try:
return int(value)
except ValueError:
raise web.HTTPBadRequest(
text=json.dumps({"error": f"{name} must be an integer"}),
content_type="application/json"
)
return None
portnum = parse_int_param("portnum")
to_node = parse_int_param("to_node")
from_node = parse_int_param("from_node")
# Fetch stats
stats = await store.get_packet_stats(
period_type=period_type,
length=length,
channel=channel,
portnum=portnum,
to_node=to_node,
from_node=from_node
)
return web.json_response(stats)
async def run_server():
app = web.Application()
app.add_routes(routes)