feat: route health monitoring with visual path builder

Add complete route health monitoring feature that tracks whether packets
traverse expected multi-hop paths through the mesh network.

Models & migration:
- 5 new models: PacketPathHop, Route, RouteNode, RouteObserver, RouteResult
- Alembic migration with keyset-paginated backfill of existing packets

Collector:
- store_raw_packet refactored to persist path hops via bulk insert
- Matching engine (collector/routes.py) with subsequence matching, quality
  bands (clear/marginal/failing/no_coverage), and collision detection
- Background route evaluator (60s loop) wired into subscriber lifespan
- Route seed loader in CLI (resolves by public_key, matching YAML format)

API:
- 6 CRUD endpoints + preview endpoint under /api/v1/routes
- Schemas accept node_public_keys (64-char hex) instead of internal UUIDs
- 5 Prometheus gauges for route health metrics
- Packet groups endpoint reads from hop table

Web UI:
- Full SPA routes page with summary strip, grouped cards, expandable detail
- Routes nav entry in both desktop (spa.html) and mobile (app.js) navbars
- Home page nav card with feature gate
- API proxy access mapping for v1/routes endpoints
- Visual node-search path builder with autocomplete dropdown, ordered chips
  with reorder/remove controls, and paste-64-char-key support
- Observer picker with same search UX (unordered chips)
- i18n strings in en.json and nl.json

Config:
- feature_routes flag (default: true)
- route_evaluator_interval_seconds (default: 60)
- routes_file seed path
- example/seed/routes.yaml
This commit is contained in:
Louis King
2026-07-12 22:26:44 +01:00
parent d60e2e8b1d
commit 14fbc45387
36 changed files with 4787 additions and 412 deletions
+22 -70
View File
@@ -4,7 +4,7 @@ from datetime import datetime, timezone
import pytest
from meshcore_hub.common.models import Channel, Node, NodeTag, RawPacket
from meshcore_hub.common.models import Channel, Node, NodeTag, PacketPathHop, RawPacket
def _now() -> datetime:
@@ -584,23 +584,27 @@ class TestGetPacketGroup:
assert r["observer_name"] == "ObsName"
assert r["observer_tag_name"] == "TaggedObs"
def test_path_hashes_extracted(self, client_no_auth, api_db_session):
decoded = {
"payload": {
"decoded": {
"pathHashes": ["AA", "BB", "CC"],
}
}
}
api_db_session.add(
RawPacket(
raw_hex="AA",
packet_hash="H1",
decoded=decoded,
path_len=3,
received_at=_now(),
)
def test_path_hashes_from_hop_table(self, client_no_auth, api_db_session):
"""Path hashes are read from packet_path_hops, not decoded JSON."""
rp = RawPacket(
raw_hex="AA",
packet_hash="H1",
decoded={"payload": {"decoded": {"pathHashes": ["AA", "BB", "CC"]}}},
path_len=3,
received_at=_now(),
)
api_db_session.add(rp)
api_db_session.flush()
for pos, nh in enumerate(["AA", "BB", "CC"]):
api_db_session.add(
PacketPathHop(
raw_packet_id=rp.id,
position=pos,
node_hash=nh,
packet_hash="H1",
received_at=_now(),
)
)
api_db_session.commit()
data = client_no_auth.get("/api/v1/packet-groups/H1").json()
@@ -609,6 +613,7 @@ class TestGetPacketGroup:
assert r["path_len"] == 3
def test_path_hashes_missing_returns_none(self, client_no_auth, api_db_session):
"""A raw_packet with no hop rows returns path_hashes=None."""
api_db_session.add(
RawPacket(
raw_hex="AA",
@@ -754,56 +759,3 @@ class TestPacketGroupRedaction:
).json()
assert data["redacted"] is False
assert data["raw_hex"] == "SECRET"
class TestExtractPathHashes:
"""Unit tests for the _extract_path_hashes helper."""
def test_extracts_valid_hashes(self):
from meshcore_hub.api.routes.packet_groups import _extract_path_hashes
decoded = {"payload": {"decoded": {"pathHashes": ["AA", "BB"]}}}
assert _extract_path_hashes(decoded) == ["AA", "BB"]
def test_extracts_top_level_path(self):
from meshcore_hub.api.routes.packet_groups import _extract_path_hashes
# Normal (flood/advertisement) packets carry the routing path here.
decoded = {"path": ["16", "69", "23"], "pathLength": 3}
assert _extract_path_hashes(decoded) == ["16", "69", "23"]
def test_top_level_path_takes_precedence(self):
from meshcore_hub.api.routes.packet_groups import _extract_path_hashes
decoded = {
"path": ["16", "69"],
"payload": {"decoded": {"pathHashes": ["AA"]}},
}
assert _extract_path_hashes(decoded) == ["16", "69"]
def test_empty_top_level_path_falls_back(self):
from meshcore_hub.api.routes.packet_groups import _extract_path_hashes
decoded = {"path": [], "payload": {"decoded": {"pathHashes": ["AA"]}}}
assert _extract_path_hashes(decoded) == ["AA"]
def test_none_input(self):
from meshcore_hub.api.routes.packet_groups import _extract_path_hashes
assert _extract_path_hashes(None) is None
def test_missing_path_hashes(self):
from meshcore_hub.api.routes.packet_groups import _extract_path_hashes
assert _extract_path_hashes({"payload": {"decoded": {}}}) is None
def test_non_list_path_hashes(self):
from meshcore_hub.api.routes.packet_groups import _extract_path_hashes
decoded = {"payload": {"decoded": {"pathHashes": "not-a-list"}}}
assert _extract_path_hashes(decoded) is None
def test_empty_decoded(self):
from meshcore_hub.api.routes.packet_groups import _extract_path_hashes
assert _extract_path_hashes({}) is None