From d8ceb1916306668064c9a60b2d646c9a0345385a Mon Sep 17 00:00:00 2001 From: Pablo Revilla Date: Mon, 11 Aug 2025 13:57:30 -0700 Subject: [PATCH] add API code for /api/packets --- meshview/store.py | 66 +++++++++++++-- meshview/templates/stats.html | 152 +++++++++++++++++++++++++++------- meshview/web.py | 71 +++++++++++++++- 3 files changed, 253 insertions(+), 36 deletions(-) diff --git a/meshview/store.py b/meshview/store.py index 3b0964a..7a7ff34 100644 --- a/meshview/store.py +++ b/meshview/store.py @@ -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 + } diff --git a/meshview/templates/stats.html b/meshview/templates/stats.html index a826a0f..5cfd00d 100644 --- a/meshview/templates/stats.html +++ b/meshview/templates/stats.html @@ -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 %} + {% endblock %} {% block body %}

Mesh Statistics

- +
-

- Total Active Nodes (24 hours):
- {{ "{:,}".format(total_nodes) }} -

+

Packets per Hour (Last 24 Hours)

+
- +
-

- Total Packets (14 days): - {{ "{:,}".format(total_packets) }} -

+

Packets per Hour for PortNum 1 (Last 24 Hours)

+
- +
-

- Total MQTT Reports (14 days): - {{ "{:,}".format(total_packets_seen) }} -

+

Packets per Day (Last 14 Days)

+
+
+ + +
+

Packets per Day for PortNum 1 (Last 14 Days)

+
+ + {% endblock %} diff --git a/meshview/web.py b/meshview/web.py index 143f6c9..a02534a 100644 --- a/meshview/web.py +++ b/meshview/web.py @@ -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)