mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-07-03 16:31:51 +02:00
Added Weekly Mesh reporting
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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: <a href="/nodelist">Nodes</a> - <a href="/chat">Conversations</a> - <a href="/firehose">See <strong>everything</strong> </a> - Mesh Graph <a href="/graph/longfast">LF</a> - <a href="/graph/mediumslow">MS </a> - <a href="/stats">Stats </a></div><br>
|
||||
<div style="text-align:center">Quick Links: <a href="/nodelist">Nodes</a> - <a href="/chat">Conversations</a> - <a href="/firehose">See <strong>everything</strong> </a>
|
||||
- Mesh Graph <a href="/graph/longfast">LF</a> - <a href="/graph/mediumslow">MS </a> - <a href="/stats">Stats </a>
|
||||
- <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
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user