mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-11-03 12:22:19 +01:00
* fix: correct traceroute path orientation when building graph edges
A completed traceroute is observed as a response packet, whose from/to are
reversed with respect to the path being traced: `to` is the node that started
the traceroute and `from` is the node that answered. Meshview assembled paths
as [from, *route, to], so both endpoint-adjacent edges were wrong for every
completed traceroute with at least one intermediate hop. Because the false
edges always attach to the endpoints, a busy initiator or gateway accumulated
false direct links to every remote target it ever traced.
Confirmed against TraceRouteModule::alterReceivedProtobuf, which renders a
response as printRoute(r, p.to, p.from, false), against the meshtastic python
CLI, which starts the forward path at p["to"] and the return path at p["from"],
and against real recorded traceroutes where a request's route[] is a prefix of
the matching response's route[].
Add meshview/traceroute.py and use it from /api/edges, /api/traceroute and
graph_traceroute, which had three separate copies of this logic that had all
drifted. /api/traceroute also gains initiator/target so the traceroute page
stops re-deriving the endpoints itself, which is where a fourth copy lived.
While in /api/edges, skip the NODENUM_BROADCAST placeholders that
insertUnknownHops() writes into route[] (they became a pseudo-node hub in the
graph) and guard against the (gateway, gateway) self edge that
graph_traceroute already guarded against.
* fix: do not invent links across unknown hops or unfinished returns
Review of becf4a8 found two remaining path errors.
Dropping NODENUM_BROADCAST from route[] during assembly joined the nodes
either side of it, so A -> B -> ? -> C -> D became A -> B -> C -> D and a
lone unknown hop collapsed into a false direct A -> D. Keep the placeholder
in the path and skip the edges that touch it instead. On a 2660 row sample
this removes 10 links that do not exist.
done=True only means a response was observed, not that it arrived. A gateway
usually reports one still travelling home with route_back holding just the
hops so far, so always appending the initiator claimed a final link that was
never seen. Append it only when the response reached the initiator, which the
spare snr_back entry from SNRonly=isToUs() marks. Note this is rare in
practice: 2 of 303 real responses, since the entry is added by the initiator
and gateways see packets in flight.
The deduplicated unique_forward_paths and unique_reverse_paths carry no
per-observation provenance, so the traceroute page can no longer tell which
of them reached the far endpoint. It now adds only the adjacent endpoint.
* fix: treat a response as proof the forward path reached the target
Winning path selection required return hops, so a response observed before
its first return hop contributed nothing and the page fell back to the
deduplicated reported paths, which cannot tell which of them reached the
target. A response with route=[B,C] and no route_back rendered A > B > C,
losing the target and the last link.
Select winning paths from response observations instead. A response only
exists because the request arrived, whatever its return trip went on to do.
Dropping the non-empty forward_hops guard at the same time means a zero hop
response contributes its real direct link, so a fully direct traceroute and a
completed direct return are no longer excluded from the graph.
Mark reported forward paths that some response reported, so the page can
close those on the target and they deduplicate against the winning path
instead of drawing a shorter copy underneath it.
526 lines
16 KiB
Python
526 lines
16 KiB
Python
"""Main web server routes and page rendering for Meshview."""
|
|
|
|
import asyncio
|
|
import datetime
|
|
import logging
|
|
import os
|
|
import pathlib
|
|
import re
|
|
import ssl
|
|
from dataclasses import dataclass
|
|
|
|
import pydot
|
|
from aiohttp import web
|
|
from google.protobuf import text_format
|
|
from google.protobuf.message import Message
|
|
from jinja2 import Environment, PackageLoader, Undefined, select_autoescape
|
|
from markupsafe import Markup
|
|
|
|
from meshtastic.protobuf.mesh_pb2 import Routing
|
|
from meshtastic.protobuf.portnums_pb2 import PortNum
|
|
from meshview import config, database, decode_payload, migrations, models, store, traceroute
|
|
from meshview.__version__ import (
|
|
__version_string__,
|
|
)
|
|
from meshview.deps import check_optional_deps
|
|
from meshview.web_api import api
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(filename)s:%(lineno)d [pid:%(process)d] %(levelname)s - %(message)s",
|
|
datefmt="%Y-%m-%d %H:%M:%S",
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
SEQ_REGEX = re.compile(r"seq \d+")
|
|
SOFTWARE_RELEASE = __version_string__ # Keep for backward compatibility
|
|
CONFIG = config.CONFIG
|
|
|
|
env = Environment(loader=PackageLoader("meshview"), autoescape=select_autoescape())
|
|
|
|
# Start Database
|
|
database.init_database(CONFIG["database"]["connection_string"])
|
|
check_optional_deps()
|
|
|
|
BASE_DIR = os.path.dirname(__file__)
|
|
LANG_DIR = os.path.join(BASE_DIR, "lang")
|
|
|
|
with open(os.path.join(os.path.dirname(__file__), '1x1.png'), 'rb') as png:
|
|
empty_png = png.read()
|
|
|
|
|
|
@dataclass
|
|
class Packet:
|
|
"""UI-friendly packet wrapper for templates and API payloads."""
|
|
|
|
id: int
|
|
packet_id: int | None
|
|
from_node_id: int
|
|
from_node: models.Node
|
|
to_node_id: int
|
|
to_node: models.Node
|
|
channel: str
|
|
portnum: int
|
|
data: str
|
|
raw_mesh_packet: object
|
|
raw_payload: object
|
|
payload: str
|
|
pretty_payload: Markup
|
|
import_time_us: int
|
|
|
|
@classmethod
|
|
def from_model(cls, packet):
|
|
"""Convert a Packet ORM model into a presentation-friendly Packet."""
|
|
mesh_packet, payload = decode_payload.decode(packet)
|
|
pretty_payload = None
|
|
|
|
if mesh_packet:
|
|
mesh_packet.decoded.payload = b""
|
|
text_mesh_packet = text_format.MessageToString(mesh_packet)
|
|
else:
|
|
text_mesh_packet = "Did node decode"
|
|
|
|
if payload is None:
|
|
text_payload = "Did not decode"
|
|
elif (
|
|
packet.portnum == PortNum.ROUTING_APP
|
|
and isinstance(payload, Routing)
|
|
and payload.WhichOneof("variant") == "error_reason"
|
|
):
|
|
# error_reason NONE is the implicit ACK, not a failure.
|
|
text_payload = (
|
|
"ACK"
|
|
if payload.error_reason == Routing.Error.NONE
|
|
else f"NAK: {Routing.Error.Name(payload.error_reason)}"
|
|
)
|
|
elif isinstance(payload, Message):
|
|
text_payload = text_format.MessageToString(payload)
|
|
elif packet.portnum == PortNum.TEXT_MESSAGE_APP and packet.to_node_id != 0xFFFFFFFF:
|
|
text_payload = "<redacted>"
|
|
elif isinstance(payload, bytes):
|
|
text_payload = payload.decode("utf-8", errors="replace") # decode bytes safely
|
|
else:
|
|
text_payload = str(payload)
|
|
|
|
if payload:
|
|
if (
|
|
packet.portnum == PortNum.POSITION_APP
|
|
and getattr(payload, "latitude_i", None)
|
|
and getattr(payload, "longitude_i", None)
|
|
):
|
|
pretty_payload = Markup(
|
|
f'<a href="https://www.google.com/maps/search/?api=1&query={payload.latitude_i * 1e-7},{payload.longitude_i * 1e-7}" target="_blank">map</a>'
|
|
)
|
|
|
|
packet_id = None
|
|
if mesh_packet and mesh_packet.id:
|
|
packet_id = mesh_packet.id
|
|
|
|
return cls(
|
|
id=packet.id,
|
|
packet_id=packet_id,
|
|
from_node=packet.from_node,
|
|
from_node_id=packet.from_node_id,
|
|
to_node=packet.to_node,
|
|
to_node_id=packet.to_node_id,
|
|
channel=packet.channel,
|
|
portnum=packet.portnum,
|
|
data=text_mesh_packet,
|
|
payload=text_payload, # now always a string
|
|
pretty_payload=pretty_payload,
|
|
import_time_us=packet.import_time_us, # <-- include microseconds
|
|
raw_mesh_packet=mesh_packet,
|
|
raw_payload=payload,
|
|
)
|
|
|
|
|
|
async def build_trace(node_id):
|
|
"""Build a recent GPS trace list for a node using position packets."""
|
|
trace = []
|
|
for raw_p in await store.get_packets_from(
|
|
node_id, PortNum.POSITION_APP, since=datetime.timedelta(hours=24)
|
|
):
|
|
p = Packet.from_model(raw_p)
|
|
if not p.raw_payload or not p.raw_payload.latitude_i or not p.raw_payload.longitude_i:
|
|
continue
|
|
trace.append((p.raw_payload.latitude_i * 1e-7, p.raw_payload.longitude_i * 1e-7))
|
|
|
|
if not trace:
|
|
for raw_p in await store.get_packets_from(node_id, PortNum.POSITION_APP):
|
|
p = Packet.from_model(raw_p)
|
|
if not p.raw_payload or not p.raw_payload.latitude_i or not p.raw_payload.longitude_i:
|
|
continue
|
|
trace.append((p.raw_payload.latitude_i * 1e-7, p.raw_payload.longitude_i * 1e-7))
|
|
break
|
|
|
|
return trace
|
|
|
|
|
|
async def build_neighbors(node_id):
|
|
"""Return neighbor node metadata for the given node ID."""
|
|
packets = await store.get_packets_from(node_id, PortNum.NEIGHBORINFO_APP, limit=1)
|
|
packet = packets.first()
|
|
|
|
if not packet:
|
|
return []
|
|
|
|
_, payload = decode_payload.decode(packet)
|
|
neighbors = {}
|
|
|
|
# Gather node information asynchronously
|
|
tasks = {n.node_id: store.get_node(n.node_id) for n in payload.neighbors}
|
|
results = await asyncio.gather(*tasks.values(), return_exceptions=True)
|
|
|
|
for neighbor, node in zip(payload.neighbors, results, strict=False):
|
|
if isinstance(node, Exception):
|
|
continue
|
|
if node and node.last_lat and node.last_long:
|
|
neighbors[neighbor.node_id] = {
|
|
'node_id': neighbor.node_id,
|
|
'snr': neighbor.snr, # Fix dictionary keying issue
|
|
'short_name': node.short_name,
|
|
'long_name': node.long_name,
|
|
'location': (node.last_lat * 1e-7, node.last_long * 1e-7),
|
|
}
|
|
|
|
return list(neighbors.values()) # Return a list of dictionaries
|
|
|
|
|
|
def node_id_to_hex(node_id):
|
|
"""Format a node_id in Meshtastic hex notation."""
|
|
if node_id is None or isinstance(node_id, Undefined):
|
|
return "Invalid node_id" # i... have no clue
|
|
if node_id == 4294967295:
|
|
return "^all"
|
|
else:
|
|
return f"!{hex(node_id)[2:].zfill(8)}"
|
|
|
|
|
|
def format_timestamp(timestamp):
|
|
"""Normalize timestamps to ISO 8601 strings."""
|
|
if isinstance(timestamp, int):
|
|
timestamp = datetime.datetime.fromtimestamp(timestamp, datetime.UTC)
|
|
return timestamp.isoformat(timespec="milliseconds")
|
|
|
|
|
|
env.filters["node_id_to_hex"] = node_id_to_hex
|
|
env.filters["format_timestamp"] = format_timestamp
|
|
|
|
# Initialize API module with dependencies
|
|
api.init_api_module(Packet, SEQ_REGEX, LANG_DIR)
|
|
|
|
# Create main routes table
|
|
routes = web.RouteTableDef()
|
|
|
|
|
|
@routes.get("/")
|
|
async def index(request):
|
|
"""
|
|
Redirect root URL '/' to the page specified in CONFIG['site']['starting'].
|
|
Defaults to '/map' if not set.
|
|
"""
|
|
# Get the starting page from config
|
|
starting_url = CONFIG["site"].get("starting", "/map") # default to /map if not set
|
|
raise web.HTTPFound(location=starting_url)
|
|
|
|
|
|
# redirect for backwards compatibility
|
|
@routes.get("/packet_list/{packet_id}")
|
|
async def redirect_packet_list(request):
|
|
packet_id = request.match_info["packet_id"]
|
|
raise web.HTTPFound(location=f"/node/{packet_id}")
|
|
|
|
|
|
# Generic static HTML route
|
|
@routes.get("/{page}")
|
|
async def serve_page(request):
|
|
"""Serve static HTML pages from meshview/static."""
|
|
page = request.match_info["page"]
|
|
|
|
# default to index.html if no extension
|
|
if not page.endswith(".html"):
|
|
page = f"{page}.html"
|
|
|
|
html_file = pathlib.Path(__file__).parent / "static" / page
|
|
if not html_file.exists():
|
|
raise web.HTTPNotFound(text=f"Page '{page}' not found")
|
|
|
|
content = html_file.read_text(encoding="utf-8")
|
|
return web.Response(text=content, content_type="text/html")
|
|
|
|
|
|
@routes.get("/docs/{doc}")
|
|
async def serve_doc(request):
|
|
"""Serve documentation files from docs/ (markdown)."""
|
|
doc = request.match_info["doc"]
|
|
docs_root = pathlib.Path(__file__).parent.parent / "docs"
|
|
doc_path = (docs_root / doc).resolve()
|
|
|
|
if not doc_path.is_file() or docs_root not in doc_path.parents:
|
|
raise web.HTTPNotFound(text="Document not found")
|
|
|
|
content = doc_path.read_text(encoding="utf-8")
|
|
return web.Response(text=content, content_type="text/markdown")
|
|
|
|
|
|
@routes.get("/net")
|
|
async def net(request):
|
|
return web.Response(
|
|
text=env.get_template("net.html").render(),
|
|
content_type="text/html",
|
|
)
|
|
|
|
|
|
@routes.get("/map")
|
|
async def map(request):
|
|
template = env.get_template("map.html")
|
|
return web.Response(text=template.render(), content_type="text/html")
|
|
|
|
|
|
@routes.get("/reliability")
|
|
async def reliability(request):
|
|
template = env.get_template("reliability.html")
|
|
return web.Response(text=template.render(), content_type="text/html")
|
|
|
|
|
|
@routes.get("/nodelist")
|
|
async def nodelist(request):
|
|
template = env.get_template("nodelist.html")
|
|
return web.Response(
|
|
text=template.render(),
|
|
content_type="text/html",
|
|
)
|
|
|
|
|
|
@routes.get("/firehose")
|
|
async def firehose(request):
|
|
return web.Response(
|
|
text=env.get_template("firehose.html").render(),
|
|
content_type="text/html",
|
|
)
|
|
|
|
|
|
@routes.get("/chat")
|
|
async def chat(request):
|
|
template = env.get_template("chat.html")
|
|
return web.Response(
|
|
text=template.render(),
|
|
content_type="text/html",
|
|
)
|
|
|
|
|
|
@routes.get("/packet/{packet_id}")
|
|
async def new_packet(request):
|
|
template = env.get_template("packet.html")
|
|
return web.Response(
|
|
text=template.render(),
|
|
content_type="text/html",
|
|
)
|
|
|
|
|
|
@routes.get("/node/{from_node_id}")
|
|
async def firehose_node(request):
|
|
template = env.get_template("node.html")
|
|
return web.Response(
|
|
text=template.render(),
|
|
content_type="text/html",
|
|
)
|
|
|
|
|
|
@routes.get("/nodegraph")
|
|
async def nodegraph(request):
|
|
template = env.get_template("nodegraph.html")
|
|
return web.Response(
|
|
text=template.render(),
|
|
content_type="text/html",
|
|
)
|
|
|
|
|
|
@routes.get("/top")
|
|
async def top(request):
|
|
template = env.get_template("top.html")
|
|
return web.Response(
|
|
text=template.render(),
|
|
content_type="text/html",
|
|
)
|
|
|
|
|
|
@routes.get("/stats")
|
|
async def stats(request):
|
|
template = env.get_template("stats.html")
|
|
return web.Response(
|
|
text=template.render(),
|
|
content_type="text/html",
|
|
)
|
|
|
|
|
|
@routes.get("/traceroute/{packet_id}")
|
|
async def traceroute_page(request):
|
|
template = env.get_template("traceroute.html")
|
|
return web.Response(
|
|
text=template.render(),
|
|
content_type="text/html",
|
|
)
|
|
|
|
|
|
# Keep !!
|
|
@routes.get("/graph/traceroute/{packet_id}")
|
|
async def graph_traceroute(request):
|
|
packet_id = int(request.match_info['packet_id'])
|
|
traceroutes = list(await store.get_traceroute(packet_id))
|
|
|
|
packet = await store.get_packet(packet_id)
|
|
if not packet:
|
|
return web.Response(
|
|
status=404,
|
|
)
|
|
|
|
node_ids = set()
|
|
for tr in traceroutes:
|
|
route = decode_payload.decode_payload(PortNum.TRACEROUTE_APP, tr.route)
|
|
if route is None:
|
|
continue
|
|
node_ids.add(tr.gateway_node_id)
|
|
for node_id in route.route:
|
|
node_ids.add(node_id)
|
|
node_ids.add(packet.from_node_id)
|
|
node_ids.add(packet.to_node_id)
|
|
|
|
nodes = {}
|
|
async with asyncio.TaskGroup() as tg:
|
|
for node_id in node_ids:
|
|
nodes[node_id] = tg.create_task(store.get_node(node_id))
|
|
|
|
graph = pydot.Dot('traceroute', graph_type="digraph")
|
|
|
|
paths = set()
|
|
node_color = {}
|
|
mqtt_nodes = set()
|
|
saw_reply = set()
|
|
dest = None
|
|
node_seen_time = {}
|
|
for tr in traceroutes:
|
|
if tr.done:
|
|
saw_reply.add(tr.gateway_node_id)
|
|
if tr.done and dest:
|
|
continue
|
|
route = decode_payload.decode_payload(PortNum.TRACEROUTE_APP, tr.route)
|
|
if route is None:
|
|
continue
|
|
path = traceroute.forward_path(
|
|
packet.from_node_id,
|
|
packet.to_node_id,
|
|
route.route,
|
|
tr.done,
|
|
tr.gateway_node_id,
|
|
)
|
|
if not path:
|
|
continue
|
|
if tr.done:
|
|
dest = path[-1]
|
|
|
|
if not tr.done and tr.gateway_node_id not in node_seen_time and tr.import_time_us:
|
|
node_seen_time[path[-1]] = tr.import_time_us
|
|
|
|
mqtt_nodes.add(tr.gateway_node_id)
|
|
node_color[path[-1]] = '#' + hex(hash(tuple(path)))[3:9]
|
|
paths.add(tuple(path))
|
|
|
|
used_nodes = set()
|
|
for path in paths:
|
|
used_nodes.update(path)
|
|
|
|
import_times = [tr.import_time_us for tr in traceroutes if tr.import_time_us]
|
|
if import_times:
|
|
first_time = min(import_times)
|
|
else:
|
|
first_time = 0
|
|
|
|
for node_id in used_nodes:
|
|
node = await nodes[node_id]
|
|
if not node:
|
|
node_name = node_id_to_hex(node_id)
|
|
else:
|
|
node_name = (
|
|
f'[{node.short_name}] {node.long_name}\n{node_id_to_hex(node_id)}\n{node.role}'
|
|
)
|
|
if node_id in node_seen_time:
|
|
ms = (node_seen_time[node_id] - first_time) / 1000
|
|
node_name += f'\n {ms:.2f}ms'
|
|
style = 'dashed'
|
|
if node_id == dest:
|
|
style = 'filled'
|
|
elif node_id in mqtt_nodes:
|
|
style = 'solid'
|
|
|
|
if node_id in saw_reply:
|
|
style += ', diagonals'
|
|
|
|
graph.add_node(
|
|
pydot.Node(
|
|
str(node_id),
|
|
label=node_name,
|
|
shape='box',
|
|
color=node_color.get(node_id, 'black'),
|
|
style=style,
|
|
href=f"/node/{node_id}",
|
|
)
|
|
)
|
|
|
|
for path in paths:
|
|
color = '#' + hex(hash(tuple(path)))[3:9]
|
|
for src, dest in zip(path, path[1:], strict=False):
|
|
graph.add_edge(pydot.Edge(src, dest, color=color))
|
|
|
|
return web.Response(
|
|
body=graph.create_svg(),
|
|
content_type="image/svg+xml",
|
|
)
|
|
|
|
|
|
async def run_server():
|
|
"""Start the aiohttp web server after migrations are complete."""
|
|
# Wait for database migrations to complete before starting web server
|
|
logger.info("Checking database schema status...")
|
|
database_url = CONFIG["database"]["connection_string"]
|
|
|
|
# Wait for migrations to complete (writer app responsibility)
|
|
migration_ready = await migrations.wait_for_migrations(
|
|
database.engine, database_url, max_retries=30, retry_delay=2
|
|
)
|
|
|
|
if not migration_ready:
|
|
logger.error("Database schema is not up to date. Cannot start web server.")
|
|
raise RuntimeError("Database schema version mismatch - migrations not complete")
|
|
|
|
logger.info("Database schema verified - starting web server")
|
|
|
|
app = web.Application()
|
|
app.router.add_static("/static/", pathlib.Path(__file__).parent / "static")
|
|
app.add_routes(api.routes) # Add API routes
|
|
app.add_routes(routes) # Add main web routes
|
|
|
|
# Check if access logging should be disabled
|
|
enable_access_log = CONFIG.get("logging", {}).get("access_log", "False").lower() == "true"
|
|
access_log_handler = None if not enable_access_log else logging.getLogger("aiohttp.access")
|
|
|
|
runner = web.AppRunner(app, access_log=access_log_handler)
|
|
await runner.setup()
|
|
if CONFIG["server"]["tls_cert"]:
|
|
ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
|
ssl_context.load_cert_chain(CONFIG["server"]["tls_cert"])
|
|
logger.info(f"TLS enabled with certificate: {CONFIG['server']['tls_cert']}")
|
|
else:
|
|
ssl_context = None
|
|
logger.info("TLS disabled")
|
|
if host := CONFIG["server"]["bind"]:
|
|
port = CONFIG["server"]["port"]
|
|
protocol = "https" if ssl_context else "http"
|
|
site = web.TCPSite(runner, host, port, ssl_context=ssl_context)
|
|
await site.start()
|
|
# Display localhost instead of wildcard addresses for usability
|
|
display_host = "localhost" if host in ("0.0.0.0", "*", "::") else host
|
|
logger.info(f"Web server started at {protocol}://{display_host}:{port}")
|
|
while True:
|
|
await asyncio.sleep(3600) # sleep forever
|