Added Weekly Mesh reporting

This commit is contained in:
Pablo Revilla
2025-02-13 11:18:11 -08:00
parent 0556706a30
commit 85c708a5a6
6 changed files with 203 additions and 74 deletions
-2
View File
@@ -34,7 +34,6 @@ def create_event(node_id):
def remove_event(node_event):
print("removing event")
waiting_node_ids_events[node_event.node_id].remove(node_event)
def notify_packet(node_id, packet):
@@ -57,7 +56,6 @@ def subscribe(node_id):
event = create_event(node_id)
try:
yield event
print("adding event...")
except Exception as e:
print(f"Error during subscription for node_id={node_id}: {e}")
raise
+3 -1
View File
@@ -34,7 +34,9 @@
</head>
<body hx-indicator="#spinner">
<br><div style="text-align:center"><strong>Bay Area Mesh - http://bayme.sh</strong></div>
<div style="text-align:center">Quick Links:&nbsp;&nbsp;<a href="/nodelist">Nodes</a>&nbsp;-&nbsp;<a href="/chat">Conversations</a>&nbsp;-&nbsp;<a href="/firehose">See <strong>everything</strong> </a>&nbsp;-&nbsp;Mesh Graph <a href="/graph/longfast">LF</a>&nbsp;-&nbsp;<a href="/graph/mediumslow">MS </a>&nbsp;-&nbsp;<a href="/stats">Stats </a></div><br>
<div style="text-align:center">Quick Links:&nbsp;&nbsp;<a href="/nodelist">Nodes</a>&nbsp;-&nbsp;<a href="/chat">Conversations</a>&nbsp;-&nbsp;<a href="/firehose">See <strong>everything</strong> </a>
&nbsp;-&nbsp;Mesh Graph <a href="/graph/longfast">LF</a>&nbsp;-&nbsp;<a href="/graph/mediumslow">MS </a>&nbsp;-&nbsp;<a href="/stats">Stats </a>
&nbsp;-&nbsp;<a href="/net">Weekly Net</a></div><br>
<div id="spinner" class="spinner-border secondary-primary htmx-indicator position-absolute top-50 start-50" role="status">
<span class="visually-hidden">Loading...</span>
</div>
+17 -20
View File
@@ -1,27 +1,24 @@
{% extends "base.html" %}
{% block css %}
#packet_details{
height: 95vh;
overflow: scroll;
}
.timestamp {
min-width:10em;
}
.chat-packet:nth-of-type(odd){
background-color:#1f1f1f;
}
.chat-packet:nth-of-type(even){
background-color:#181818;
}
{% endblock %}
{% block body %}
<div
id="net"
class="container text-center"
hx-ext="sse"
sse-connect="/events?portnum=1"
>
<div class="row">
<div class="col-6" id="packet_list" sse-swap="net_packet" hx-swap="afterbegin">
{% for packet in net_packets %}
{% include 'net_packet.html' %}
{% endfor %}
</div>
<div class="col-6 sticky-top" id="packet_details">
</div>
<div class="container"hx-ext="sse" sse-connect="/net_events" sse-swap="net_packet" hx-swap="afterbegin">
{% for packet in packets %}
{% include 'net_packet.html' %}
{% else %}
No packets found.
{% endfor %}
</div>
</div>
{% endblock body %}
{% endblock %}
+3 -7
View File
@@ -1,12 +1,8 @@
<div class="card mt-2">
<div class="card-header">
<span class="fw-bold">{{packet.from_node.long_name}}</span>
<a href="/packet_details/{{packet.id}}" hx-trigger="click" hx-target="#packet_details" hx-get="/packet_details/{{packet.id}}" hx-swap="innerHTML scroll:top">🔎</a>
</div>
<div class="card-text text-start">
<dl>
<div>{{packet.import_time.strftime('%-I:%M:%S %p - %d-%m-%Y')}}</div>
<div>{{packet.payload}}</div>
</dl>
</div>
<div class="card-body">
<div>{{packet.import_time.strftime('%-I:%M:%S %p - %d-%m-%Y')}} - {{packet.payload}}</div>
</div>
</div>
+22 -13
View File
@@ -31,7 +31,6 @@ tr:nth-child(odd) {
display: flex;
gap: 10px;
margin-bottom: 10px;
align-items: center;
}
.search, .filter-role, .filter-channel, .filter-hw_model, .export-btn {
@@ -55,8 +54,8 @@ tr:nth-child(odd) {
background: #218838;
}
.node-count {
font-size: 16px;
.count-container {
margin-bottom: 10px;
font-weight: bold;
color: white;
}
@@ -67,7 +66,7 @@ tr:nth-child(odd) {
<div class="search-container">
<input class="search" placeholder="Search nodes..." />
<!-- Filter by Role Dropdown -->
<!-- Filter by Role -->
<select class="filter-role" onchange="applyFilters()">
<option value="">Filter by Role</option>
{% for node in nodes|groupby('role') %}
@@ -75,7 +74,7 @@ tr:nth-child(odd) {
{% endfor %}
</select>
<!-- Filter by Channel Dropdown -->
<!-- Filter by Channel -->
<select class="filter-channel" onchange="applyFilters()">
<option value="">Filter by Channel</option>
{% for node in nodes|groupby('channel') %}
@@ -83,7 +82,7 @@ tr:nth-child(odd) {
{% endfor %}
</select>
<!-- Filter by HW Model Dropdown -->
<!-- Filter by HW Model -->
<select class="filter-hw_model" onchange="applyFilters()">
<option value="">Filter by HW Model</option>
{% for node in nodes|groupby('hw_model') %}
@@ -92,11 +91,11 @@ tr:nth-child(odd) {
</select>
<button class="export-btn" onclick="exportToCSV()">Export to CSV</button>
<!-- Display the count of filtered nodes -->
<span class="node-count">Total Nodes: <span id="node-count-value">0</span></span>
</div>
<!-- Count Display -->
<div class="count-container">Showing <span id="node-count-value">0</span> nodes</div>
{% if nodes %}
<table id="node-table">
<thead>
@@ -109,7 +108,7 @@ tr:nth-child(odd) {
<th class="sort" data-sort="last_lat">Last Latitude</th>
<th class="sort" data-sort="last_long">Last Longitude</th>
<th class="sort" data-sort="channel">Channel</th>
<th class="sort" data-sort="last_update">Last Update</th>
<th class="sort" data-sort="last_update" data-order="desc">Last Update</th>
</tr>
</thead>
<tbody class="list">
@@ -123,7 +122,9 @@ tr:nth-child(odd) {
<td class="last_lat">{{ "{:.7f}".format(node.last_lat / 10**7) if node.last_lat else "N/A" }}</td>
<td class="last_long">{{ "{:.7f}".format(node.last_long / 10**7) if node.last_long else "N/A" }}</td>
<td class="channel">{{ node.channel if node.channel else "N/A" }}</td>
<td class="last_update">{{ node.last_update.strftime('%-I:%M:%S %p - %m-%d-%Y') if node.last_update else "N/A" }}</td>
<td class="last_update" data-timestamp="{{ node.last_update.timestamp() if node.last_update else 0 }}">
{{ node.last_update.strftime('%-I:%M:%S %p - %m-%d-%Y') if node.last_update else "N/A" }}
</td>
</tr>
{% endfor %}
</tbody>
@@ -139,10 +140,18 @@ tr:nth-child(odd) {
document.addEventListener("DOMContentLoaded", function () {
var options = {
valueNames: ["long_name", "short_name", "hw_model", "firmware", "role", "last_lat", "last_long", "channel", "last_update"]
valueNames: [
"long_name", "short_name", "hw_model", "firmware", "role",
"last_lat", "last_long", "channel", { name: "last_update", attr: "data-timestamp" }
]
};
nodeList = new List("node-list", options);
updateCount(); // Set initial count
updateCount(); // Update count on load
nodeList.on("updated", function () {
updateCount(); // Update count when search or sort changes
});
});
function applyFilters() {
+158 -31
View File
@@ -304,73 +304,124 @@ async def _packet_list(request, raw_packets, packet_event):
content_type="text/html",
)
@routes.get("/chat_events")
async def chat_events(request):
"""
Server-Sent Events (SSE) endpoint for real-time chat packet updates.
This endpoint listens for new chat packets related to `PortNum.TEXT_MESSAGE_APP`
and streams them to connected clients. Messages matching the pattern `"seq \d+$"`
are filtered out before sending.
Args:
request (aiohttp.web.Request): The incoming HTTP request.
Returns:
aiohttp.web.StreamResponse: SSE response streaming chat events.
"""
chat_packet = env.get_template("chat_packet.html")
# Precompile regex for filtering out unwanted messages (case insensitive)
seq_pattern = re.compile(r"seq \d+$", re.IGNORECASE)
# Subscribe to notifications for packets from all nodes (0xFFFFFFFF = broadcast)
with notify.subscribe(node_id=0xFFFFFFFF) as event:
async with sse_response(request) as resp:
while resp.is_connected():
while resp.is_connected(): # Keep the connection open while the client is connected
try:
async with asyncio.timeout(10):
await event.wait()
except TimeoutError:
# Wait for an event with a timeout of 10 seconds
await asyncio.wait_for(event.wait(), timeout=10)
except asyncio.TimeoutError:
# Timeout reached, continue looping to keep connection alive
continue
if event.is_set():
# Extract relevant packets, ensuring event.packets is not None
packets = [
p
for p in event.packets
if PortNum.TEXT_MESSAGE_APP == p.portnum
p for p in (event.packets or [])
if p.portnum == PortNum.TEXT_MESSAGE_APP
]
event.clear()
event.clear() # Reset event flag
try:
for packet in packets:
ui_packet = Packet.from_model(packet)
if not re.match(r"seq \d+$", ui_packet.payload):
# Filter out packets that match "seq <number>"
if not seq_pattern.match(ui_packet.payload):
await resp.send(
chat_packet.render(
packet=ui_packet,
),
event="chat_packet",
chat_packet.render(packet=ui_packet),
event="chat_packet", # SSE event type
)
except ConnectionResetError:
return
# Log when a client disconnects unexpectedly
logging.warning("Client disconnected from SSE stream.")
return # Exit the loop and close the connection
@routes.get("/events")
async def events(request):
"""
Server-Sent Events (SSE) endpoint for real-time packet updates.
This endpoint listens for new network packets and streams them to connected clients.
Clients can optionally filter packets based on `node_id` and `portnum` query parameters.
Query Parameters:
- node_id (int, optional): Filter packets for a specific node (default: all nodes).
- portnum (int, optional): Filter packets for a specific port number (default: all ports).
Args:
request (aiohttp.web.Request): The incoming HTTP request.
Returns:
aiohttp.web.StreamResponse: SSE response streaming network events.
"""
# Extract and convert query parameters (if provided)
node_id = request.query.get("node_id")
if node_id:
node_id = int(node_id)
node_id = int(node_id) # Convert node_id to an integer
portnum = request.query.get("portnum")
if portnum:
portnum = int(portnum)
portnum = int(portnum) # Convert portnum to an integer
# Load Jinja2 templates for rendering packets
packet_template = env.get_template("packet.html")
net_packet_template = env.get_template("net_packet.html")
# Subscribe to packet notifications for the given node_id (or all nodes if None)
with notify.subscribe(node_id) as event:
async with sse_response(request) as resp:
while resp.is_connected():
while resp.is_connected(): # Keep connection open while client is connected
try:
async with asyncio.timeout(10):
await event.wait()
except TimeoutError:
continue
# Wait for an event with a timeout of 10 seconds
await asyncio.wait_for(event.wait(), timeout=10)
except asyncio.TimeoutError:
continue # No new packets, continue waiting
if event.is_set():
# Extract relevant packets based on `portnum` filter (if provided)
packets = [
p
for p in event.packets
p for p in (event.packets or [])
if portnum is None or portnum == p.portnum
]
# Extract uplinked packets (if port filter applies)
uplinked = [
u
for u in event.uplinked
u for u in (event.uplinked or [])
if portnum is None or portnum == u.portnum
]
event.clear()
event.clear() # Reset event flag
try:
# Process and send incoming packets
for packet in packets:
ui_packet = Packet.from_model(packet)
# Send standard packet event
await resp.send(
packet_template.render(
is_hx_request="HX-Request" in request.headers,
@@ -379,12 +430,16 @@ async def events(request):
),
event="packet",
)
# If the packet belongs to `PortNum.TEXT_MESSAGE_APP` and contains "#baymeshnet",
# send it as a network event
if ui_packet.portnum == PortNum.TEXT_MESSAGE_APP and '#baymeshnet' in ui_packet.payload.lower():
await resp.send(
net_packet_template.render(packet=ui_packet),
event="net_packet",
)
# Process and send uplinked packets separately
for packet in uplinked:
await resp.send(
packet_template.render(
@@ -394,8 +449,10 @@ async def events(request):
),
event="uplinked",
)
except ConnectionResetError:
return
logging.warning("Client disconnected from SSE stream.")
return # Gracefully exit on disconnection
@dataclass
class UplinkedNode:
@@ -1015,10 +1072,7 @@ async def graph_network(request):
used_nodes = new_used_nodes
edges = new_edges
#graph = pydot.Dot('network', graph_type="digraph", layout="sfdp", overlap="prism", quadtree="2", repulsiveforce="1.5", k="1", overlap_scaling="1.5", concentrate=True)
#graph = pydot.Dot('network', graph_type="digraph", layout="sfdp", overlap="prism1000", overlap_scaling="-4", sep="1000", pack="true")
#graph = pydot.Dot('network', graph_type="digraph", layout="neato", overlap="false", model='subset', esep="+5")
# Create the graph
graph = pydot.Dot('network', graph_type="digraph", layout="sfdp", overlap="prism", esep="+10", nodesep="0.5",
ranksep="1")
@@ -1482,6 +1536,7 @@ async def nodelist(request):
#print(hw_model)
nodes= await store.get_nodes(role,channel, hw_model)
template = env.get_template("nodelist.html")
return web.Response(
text=template.render(nodes=nodes),
content_type="text/html",
@@ -1495,7 +1550,79 @@ async def nodelist(request):
)
@routes.get("/net")
async def net(request):
try:
# Fetch packets for the given node ID and port number
packets = await store.get_packets(
node_id=0xFFFFFFFF, portnum=PortNum.TEXT_MESSAGE_APP, limit=200
)
# Convert packets to UI packets
ui_packets = [Packet.from_model(p) for p in packets]
# Precompile regex for performance
seq_pattern = re.compile(r"seq \d+$")
# Filter packets: exclude "seq \d+$" but include those containing "pablo-test"
filtered_packets = [
p for p in ui_packets
if not seq_pattern.match(p.payload) and "baymeshnet" in p.payload.lower()
]
# Render template
template = env.get_template("net.html")
return web.Response(
text=template.render(packets=filtered_packets),
content_type="text/html",
)
except web.HTTPException as e:
raise # Let aiohttp handle HTTP exceptions properly
except Exception as e:
logging.exception("Error processing chat request")
return web.Response(
text="An internal server error occurred.",
status=500,
content_type="text/plain",
)
@routes.get("/net_events")
async def net_events(request):
chat_packet = env.get_template("net_packet.html")
# Precompile regex for performance (case insensitive)
seq_pattern = re.compile(r"seq \d+$")
with notify.subscribe(node_id=0xFFFFFFFF) as event:
async with sse_response(request) as resp:
while resp.is_connected():
try:
await asyncio.wait_for(event.wait(), timeout=10)
except asyncio.TimeoutError:
continue # Timeout occurred, loop again
if event.is_set():
# Ensure event.packets is valid before accessing it
packets = [
p for p in (event.packets or [])
if p.portnum == PortNum.TEXT_MESSAGE_APP
]
event.clear()
try:
for packet in packets:
ui_packet = Packet.from_model(packet)
if not seq_pattern.match(ui_packet.payload) and "baymeshnet" in ui_packet.payload.lower():
await resp.send(
chat_packet.render(packet=ui_packet),
event="net_packet",
)
except ConnectionResetError:
print("Client disconnected from SSE stream.")
return # Gracefully exit on disconnection
async def run_server(bind, port, tls_cert):