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
-
+
-
+
-
+
+
+
+
+
+
{% 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)