mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-08-07 01:12:54 +02:00
add API code for /api/packets
This commit is contained in:
+59
-7
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user