mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-07 09:23:07 +02:00
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:
@@ -13,7 +13,13 @@ from meshcore_hub.api.dependencies import (
|
||||
get_db_session,
|
||||
get_mqtt_client,
|
||||
)
|
||||
from meshcore_hub.common.models import Node, UserProfile, UserProfileNode
|
||||
from meshcore_hub.common.models import (
|
||||
Node,
|
||||
Route,
|
||||
RouteResult,
|
||||
UserProfile,
|
||||
UserProfileNode,
|
||||
)
|
||||
|
||||
|
||||
def _make_basic_auth(username: str, password: str) -> str:
|
||||
@@ -395,3 +401,43 @@ class TestMetricsCache:
|
||||
response1 = client_no_auth.get("/metrics")
|
||||
response2 = client_no_auth.get("/metrics")
|
||||
assert response1.text == response2.text
|
||||
|
||||
|
||||
class TestRouteMetrics:
|
||||
"""Tests for route health metrics."""
|
||||
|
||||
def test_route_metrics_emitted(self, client_no_auth, api_db_session):
|
||||
"""Enabled routes with results emit the five route gauges."""
|
||||
route = Route(name="TestRoute", enabled=True, packet_count_threshold=3)
|
||||
api_db_session.add(route)
|
||||
api_db_session.flush()
|
||||
api_db_session.add(
|
||||
RouteResult(
|
||||
route_id=route.id,
|
||||
state="healthy",
|
||||
quality="clear",
|
||||
matched_count=10,
|
||||
threshold=3,
|
||||
effective_degraded=6,
|
||||
)
|
||||
)
|
||||
api_db_session.commit()
|
||||
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
text = response.text
|
||||
assert "meshcore_route_healthy" in text
|
||||
assert "meshcore_route_quality" in text
|
||||
assert "meshcore_route_matched_packets" in text
|
||||
assert "meshcore_route_threshold" in text
|
||||
assert "meshcore_route_degraded_threshold" in text
|
||||
assert 'route="TestRoute"' in text
|
||||
|
||||
def test_disabled_routes_omitted(self, client_no_auth, api_db_session):
|
||||
"""Disabled routes are not emitted."""
|
||||
api_db_session.add(Route(name="Off", enabled=False))
|
||||
api_db_session.commit()
|
||||
|
||||
_clear_metrics_cache()
|
||||
response = client_no_auth.get("/metrics")
|
||||
assert 'route="Off"' not in response.text
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
"""Tests for route API endpoints."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from meshcore_hub.common.models import Node, Route, RouteNode
|
||||
|
||||
|
||||
def _make_node(session, public_key: str, name: str | None = None) -> Node:
|
||||
node = Node(public_key=public_key, name=name, first_seen=datetime.now(timezone.utc))
|
||||
session.add(node)
|
||||
session.flush()
|
||||
return node
|
||||
|
||||
|
||||
def _sample_nodes(session, count: int = 2) -> list[Node]:
|
||||
keys = [f"{chr(97 + i)}" * 64 for i in range(count)]
|
||||
return [_make_node(session, k, f"Node-{i}") for i, k in enumerate(keys)]
|
||||
|
||||
|
||||
class TestListRoutes:
|
||||
def test_empty(self, client_no_auth):
|
||||
resp = client_no_auth.get("/api/v1/routes")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_visibility_filter(self, client_no_auth, api_db_session):
|
||||
api_db_session.add(Route(name="Public", visibility="community"))
|
||||
api_db_session.add(Route(name="Secret", visibility="admin"))
|
||||
api_db_session.commit()
|
||||
|
||||
resp = client_no_auth.get("/api/v1/routes")
|
||||
assert resp.status_code == 200
|
||||
names = [r["name"] for r in resp.json()["items"]]
|
||||
assert "Public" in names
|
||||
assert "Secret" not in names
|
||||
|
||||
def test_admin_sees_all(self, client_no_auth, api_db_session):
|
||||
api_db_session.add(Route(name="Public", visibility="community"))
|
||||
api_db_session.add(Route(name="Secret", visibility="admin"))
|
||||
api_db_session.commit()
|
||||
|
||||
resp = client_no_auth.get("/api/v1/routes", headers={"X-User-Roles": "admin"})
|
||||
assert resp.status_code == 200
|
||||
names = [r["name"] for r in resp.json()["items"]]
|
||||
assert "Public" in names
|
||||
assert "Secret" in names
|
||||
|
||||
|
||||
class TestCreateRoute:
|
||||
def test_create_success(self, client_no_auth, api_db_session):
|
||||
nodes = _sample_nodes(api_db_session)
|
||||
api_db_session.commit()
|
||||
|
||||
resp = client_no_auth.post(
|
||||
"/api/v1/routes",
|
||||
json={
|
||||
"name": "Route1",
|
||||
"node_public_keys": [n.public_key for n in nodes],
|
||||
"match_width": 1,
|
||||
},
|
||||
headers={"X-User-Roles": "admin"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["name"] == "Route1"
|
||||
assert len(data["route_nodes"]) == 2
|
||||
assert data["route_nodes"][0]["expected_hash"] is not None
|
||||
|
||||
def test_duplicate_name_rejected(self, client_no_auth, api_db_session):
|
||||
nodes = _sample_nodes(api_db_session)
|
||||
api_db_session.add(Route(name="Dup"))
|
||||
api_db_session.commit()
|
||||
|
||||
resp = client_no_auth.post(
|
||||
"/api/v1/routes",
|
||||
json={"name": "Dup", "node_public_keys": [n.public_key for n in nodes]},
|
||||
headers={"X-User-Roles": "admin"},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
def test_min_two_nodes(self, client_no_auth, api_db_session):
|
||||
node = _make_node(api_db_session, "a" * 64)
|
||||
api_db_session.commit()
|
||||
|
||||
resp = client_no_auth.post(
|
||||
"/api/v1/routes",
|
||||
json={"name": "R", "node_public_keys": [node.public_key]},
|
||||
headers={"X-User-Roles": "admin"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_distinct_nodes(self, client_no_auth, api_db_session):
|
||||
node = _make_node(api_db_session, "a" * 64)
|
||||
api_db_session.commit()
|
||||
|
||||
resp = client_no_auth.post(
|
||||
"/api/v1/routes",
|
||||
json={"name": "R", "node_public_keys": [node.public_key, node.public_key]},
|
||||
headers={"X-User-Roles": "admin"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_degraded_threshold_validation(self, client_no_auth, api_db_session):
|
||||
nodes = _sample_nodes(api_db_session)
|
||||
api_db_session.commit()
|
||||
|
||||
resp = client_no_auth.post(
|
||||
"/api/v1/routes",
|
||||
json={
|
||||
"name": "R",
|
||||
"node_public_keys": [n.public_key for n in nodes],
|
||||
"packet_count_threshold": 5,
|
||||
"degraded_threshold": 3,
|
||||
},
|
||||
headers={"X-User-Roles": "admin"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_non_admin_rejected(self, client_with_auth, api_db_session):
|
||||
nodes = _sample_nodes(api_db_session)
|
||||
api_db_session.commit()
|
||||
|
||||
resp = client_with_auth.post(
|
||||
"/api/v1/routes",
|
||||
json={"name": "R", "node_public_keys": [n.public_key for n in nodes]},
|
||||
headers={"Authorization": "Bearer test-read-key"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
class TestGetRouteDetail:
|
||||
def test_detail_shape(self, client_no_auth, api_db_session):
|
||||
nodes = _sample_nodes(api_db_session, 3)
|
||||
route = Route(name="R1")
|
||||
api_db_session.add(route)
|
||||
api_db_session.flush()
|
||||
for pos, n in enumerate(nodes):
|
||||
api_db_session.add(
|
||||
RouteNode(
|
||||
route_id=route.id,
|
||||
node_id=n.id,
|
||||
position=pos,
|
||||
expected_hash=n.public_key[:2].upper(),
|
||||
)
|
||||
)
|
||||
api_db_session.commit()
|
||||
|
||||
resp = client_no_auth.get(f"/api/v1/routes/{route.id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "R1"
|
||||
assert len(data["route_nodes"]) == 3
|
||||
assert "contributing_observers" in data
|
||||
assert "recent_matches" in data
|
||||
|
||||
def test_not_found(self, client_no_auth):
|
||||
resp = client_no_auth.get("/api/v1/routes/nonexistent")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestUpdateRoute:
|
||||
def test_update_name(self, client_no_auth, api_db_session):
|
||||
nodes = _sample_nodes(api_db_session)
|
||||
route = Route(name="OldName")
|
||||
api_db_session.add(route)
|
||||
api_db_session.flush()
|
||||
for pos, n in enumerate(nodes):
|
||||
api_db_session.add(
|
||||
RouteNode(
|
||||
route_id=route.id,
|
||||
node_id=n.id,
|
||||
position=pos,
|
||||
expected_hash=n.public_key[:2].upper(),
|
||||
)
|
||||
)
|
||||
api_db_session.commit()
|
||||
|
||||
resp = client_no_auth.put(
|
||||
f"/api/v1/routes/{route.id}",
|
||||
json={"name": "NewName"},
|
||||
headers={"X-User-Roles": "admin"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "NewName"
|
||||
|
||||
def test_update_path_nodes(self, client_no_auth, api_db_session):
|
||||
nodes = _sample_nodes(api_db_session, 2)
|
||||
route = Route(name="R")
|
||||
api_db_session.add(route)
|
||||
api_db_session.flush()
|
||||
for pos, n in enumerate(nodes):
|
||||
api_db_session.add(
|
||||
RouteNode(
|
||||
route_id=route.id,
|
||||
node_id=n.id,
|
||||
position=pos,
|
||||
expected_hash=n.public_key[:2].upper(),
|
||||
)
|
||||
)
|
||||
api_db_session.commit()
|
||||
|
||||
new_node = _make_node(api_db_session, "z" * 64)
|
||||
api_db_session.commit()
|
||||
|
||||
resp = client_no_auth.put(
|
||||
f"/api/v1/routes/{route.id}",
|
||||
json={"node_public_keys": [nodes[0].public_key, new_node.public_key]},
|
||||
headers={"X-User-Roles": "admin"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
public_keys = [rn["public_key"] for rn in data["route_nodes"]]
|
||||
assert new_node.public_key in public_keys
|
||||
|
||||
|
||||
class TestDeleteRoute:
|
||||
def test_delete_success(self, client_no_auth, api_db_session):
|
||||
route = Route(name="Bye")
|
||||
api_db_session.add(route)
|
||||
api_db_session.commit()
|
||||
|
||||
resp = client_no_auth.delete(
|
||||
f"/api/v1/routes/{route.id}",
|
||||
headers={"X-User-Roles": "admin"},
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
def test_not_found(self, client_no_auth):
|
||||
resp = client_no_auth.delete(
|
||||
"/api/v1/routes/nonexistent",
|
||||
headers={"X-User-Roles": "admin"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestPreview:
|
||||
def test_preview_no_match(self, client_no_auth, api_db_session):
|
||||
nodes = _sample_nodes(api_db_session)
|
||||
api_db_session.commit()
|
||||
|
||||
resp = client_no_auth.post(
|
||||
"/api/v1/routes/preview",
|
||||
json={
|
||||
"node_public_keys": [n.public_key for n in nodes],
|
||||
"match_width": 1,
|
||||
"window_hours": 24,
|
||||
"packet_count_threshold": 3,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["truncated"] is False
|
||||
assert data["matched_count"] == 0
|
||||
|
||||
def test_preview_validation_min_nodes(self, client_no_auth, api_db_session):
|
||||
node = _make_node(api_db_session, "a" * 64)
|
||||
api_db_session.commit()
|
||||
|
||||
resp = client_no_auth.post(
|
||||
"/api/v1/routes/preview",
|
||||
json={"node_public_keys": [node.public_key]},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
Reference in New Issue
Block a user