mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-04 07:52:59 +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:
@@ -603,6 +603,10 @@ SYSTEM_MAINTENANCE=false
|
||||
# (SPAM_DETECTION_ENABLED=${FEATURE_SPAM_DETECTION}). Set to false to opt out.
|
||||
# See the Spam Detection section above for the scoring tuning vars.
|
||||
# FEATURE_SPAM_DETECTION=true
|
||||
# Routes page (route health monitoring) is ON by default.
|
||||
# FEATURE_ROUTES=true
|
||||
# Route evaluator interval in seconds (0 disables, default 60).
|
||||
# ROUTE_EVALUATOR_INTERVAL_SECONDS=60
|
||||
|
||||
# -------------------
|
||||
# Contact Information
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"""add route health monitoring tables
|
||||
|
||||
Revision ID: 8f2a3c4d5e6f
|
||||
Revises: 57bb65130b97
|
||||
Create Date: 2026-07-12 23:30:00.000000+00:00
|
||||
|
||||
Creates five tables for route health monitoring:
|
||||
``routes``, ``route_nodes``, ``route_observers``, ``route_results`` and
|
||||
``packet_path_hops``. The hop table is backfilled from
|
||||
``raw_packets.decoded`` using a frozen copy of the dual-path extraction logic
|
||||
(``_normalize_hash_list`` + ``decoded.path`` / ``payload.decoded.pathHashes``
|
||||
fallback), mirroring migration ``57bb65130b97``.
|
||||
|
||||
"""
|
||||
|
||||
from typing import Any, Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "8f2a3c4d5e6f"
|
||||
down_revision: Union[str, None] = "57bb65130b97"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_BATCH_SIZE = 1000
|
||||
|
||||
|
||||
def _normalize_hash_list(value: Any) -> list[str] | None:
|
||||
"""Frozen copy of LetsMeshNormalizer._normalize_hash_list.
|
||||
|
||||
Accepts even-length hex strings of 2 or more characters.
|
||||
Each string is uppercased and validated as hexadecimal.
|
||||
"""
|
||||
if not isinstance(value, list):
|
||||
return None
|
||||
normalized: list[str] = []
|
||||
for item in value:
|
||||
if not isinstance(item, str):
|
||||
continue
|
||||
token = item.strip().upper()
|
||||
if len(token) < 2 or len(token) % 2 != 0:
|
||||
continue
|
||||
if any(ch not in "0123456789ABCDEF" for ch in token):
|
||||
continue
|
||||
normalized.append(token)
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _extract_path_hashes(decoded: Any) -> list[str] | None:
|
||||
"""Frozen copy of the dual-path extraction logic.
|
||||
|
||||
Path hashes live at ``decoded.path`` for normal packets, with
|
||||
``decoded.payload.decoded.pathHashes`` as the trace-style fallback.
|
||||
Returns the normalized list or None when no path hashes are present.
|
||||
"""
|
||||
if not isinstance(decoded, dict):
|
||||
return None
|
||||
hashes = _normalize_hash_list(decoded.get("path"))
|
||||
if not hashes:
|
||||
payload = decoded.get("payload") or {}
|
||||
inner = payload.get("decoded") or {}
|
||||
hashes = _normalize_hash_list(inner.get("pathHashes"))
|
||||
return hashes or None
|
||||
|
||||
|
||||
# Declared with sa.JSON-typed column so the SQLAlchemy type adapter
|
||||
# deserializes ``decoded`` to a Python dict consistently on both backends.
|
||||
_raw_packets = sa.table(
|
||||
"raw_packets",
|
||||
sa.Column("id", sa.String, primary_key=True),
|
||||
sa.Column("decoded", sa.JSON),
|
||||
sa.Column("packet_hash", sa.String),
|
||||
sa.Column("received_at", sa.DateTime),
|
||||
sa.Column("observer_node_id", sa.String),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# --- routes ---
|
||||
op.create_table(
|
||||
"routes",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=True),
|
||||
sa.Column("visibility", sa.String(20), nullable=False),
|
||||
sa.Column("match_width", sa.Integer, nullable=False),
|
||||
sa.Column("window_hours", sa.Integer, nullable=False),
|
||||
sa.Column("packet_count_threshold", sa.Integer, nullable=False),
|
||||
sa.Column("degraded_threshold", sa.Integer, nullable=True),
|
||||
sa.Column("max_hop_span", sa.Integer, nullable=True),
|
||||
sa.Column("enabled", sa.Boolean, nullable=False),
|
||||
)
|
||||
op.create_index("ix_routes_name", "routes", ["name"], unique=True)
|
||||
|
||||
# --- route_nodes ---
|
||||
op.create_table(
|
||||
"route_nodes",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("route_id", sa.String(36), nullable=False),
|
||||
sa.Column("node_id", sa.String(36), nullable=False),
|
||||
sa.Column("position", sa.Integer, nullable=False),
|
||||
sa.Column("expected_hash", sa.String(6), nullable=True),
|
||||
sa.ForeignKeyConstraint(["route_id"], ["routes.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["node_id"], ["nodes.id"], ondelete="CASCADE"),
|
||||
)
|
||||
op.create_index("ix_route_nodes_route_id", "route_nodes", ["route_id"])
|
||||
op.create_index("ix_route_nodes_node_id", "route_nodes", ["node_id"])
|
||||
|
||||
# --- route_observers ---
|
||||
op.create_table(
|
||||
"route_observers",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("route_id", sa.String(36), nullable=False),
|
||||
sa.Column("node_id", sa.String(36), nullable=False),
|
||||
sa.ForeignKeyConstraint(["route_id"], ["routes.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["node_id"], ["nodes.id"], ondelete="CASCADE"),
|
||||
)
|
||||
op.create_index("ix_route_observers_route_id", "route_observers", ["route_id"])
|
||||
op.create_index("ix_route_observers_node_id", "route_observers", ["node_id"])
|
||||
|
||||
# --- route_results ---
|
||||
op.create_table(
|
||||
"route_results",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("route_id", sa.String(36), nullable=False),
|
||||
sa.Column("state", sa.String(20), nullable=False),
|
||||
sa.Column("quality", sa.String(20), nullable=False),
|
||||
sa.Column("matched_count", sa.Integer, nullable=False),
|
||||
sa.Column("threshold", sa.Integer, nullable=False),
|
||||
sa.Column("effective_degraded", sa.Integer, nullable=False),
|
||||
sa.Column("evaluated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["route_id"], ["routes.id"], ondelete="CASCADE"),
|
||||
sa.UniqueConstraint("route_id", name="uq_route_results_route_id"),
|
||||
)
|
||||
op.create_index("ix_route_results_route_id", "route_results", ["route_id"])
|
||||
|
||||
# --- packet_path_hops ---
|
||||
op.create_table(
|
||||
"packet_path_hops",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("raw_packet_id", sa.String(36), nullable=False),
|
||||
sa.Column("position", sa.Integer, nullable=False),
|
||||
sa.Column("node_hash", sa.String(6), nullable=False),
|
||||
sa.Column("packet_hash", sa.String(32), nullable=True),
|
||||
sa.Column("received_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("observer_node_id", sa.String(36), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["raw_packet_id"], ["raw_packets.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["observer_node_id"], ["nodes.id"], ondelete="SET NULL"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_packet_path_hops_node_hash_received_at",
|
||||
"packet_path_hops",
|
||||
["node_hash", "received_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_packet_path_hops_raw_packet_id_position",
|
||||
"packet_path_hops",
|
||||
["raw_packet_id", "position"],
|
||||
)
|
||||
|
||||
# --- Backfill packet_path_hops from raw_packets.decoded ---
|
||||
conn = op.get_bind()
|
||||
|
||||
_packet_path_hops = sa.table(
|
||||
"packet_path_hops",
|
||||
sa.Column("id", sa.String),
|
||||
sa.Column("raw_packet_id", sa.String),
|
||||
sa.Column("position", sa.Integer),
|
||||
sa.Column("node_hash", sa.String),
|
||||
sa.Column("packet_hash", sa.String),
|
||||
sa.Column("received_at", sa.DateTime),
|
||||
sa.Column("observer_node_id", sa.String),
|
||||
sa.Column("created_at", sa.DateTime),
|
||||
sa.Column("updated_at", sa.DateTime),
|
||||
)
|
||||
|
||||
from uuid import uuid4
|
||||
from datetime import datetime, timezone
|
||||
|
||||
last_id: str | None = None
|
||||
while True:
|
||||
query = (
|
||||
sa.select(
|
||||
_raw_packets.c.id,
|
||||
_raw_packets.c.decoded,
|
||||
_raw_packets.c.packet_hash,
|
||||
_raw_packets.c.received_at,
|
||||
_raw_packets.c.observer_node_id,
|
||||
)
|
||||
.order_by(_raw_packets.c.id)
|
||||
.limit(_BATCH_SIZE)
|
||||
)
|
||||
if last_id is not None:
|
||||
query = query.where(_raw_packets.c.id > last_id)
|
||||
|
||||
batch = conn.execute(query).all()
|
||||
if not batch:
|
||||
break
|
||||
|
||||
rows_to_insert: list[dict[str, Any]] = []
|
||||
for row in batch:
|
||||
last_id = row.id
|
||||
hashes = _extract_path_hashes(row.decoded)
|
||||
if not hashes:
|
||||
continue
|
||||
now = datetime.now(timezone.utc)
|
||||
for position, node_hash in enumerate(hashes):
|
||||
rows_to_insert.append(
|
||||
{
|
||||
"id": str(uuid4()),
|
||||
"raw_packet_id": row.id,
|
||||
"position": position,
|
||||
"node_hash": node_hash,
|
||||
"packet_hash": row.packet_hash,
|
||||
"received_at": row.received_at,
|
||||
"observer_node_id": row.observer_node_id,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
)
|
||||
|
||||
if rows_to_insert:
|
||||
conn.execute(_packet_path_hops.insert(), rows_to_insert)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("packet_path_hops")
|
||||
op.drop_table("route_results")
|
||||
op.drop_table("route_observers")
|
||||
op.drop_table("route_nodes")
|
||||
op.drop_table("routes")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,175 @@
|
||||
# Tasks: Routes (Route Health Monitoring)
|
||||
|
||||
> Generated from `plan.md` on 2026-07-12
|
||||
|
||||
## 1. Data Models & Schema Migration (Phase 1)
|
||||
|
||||
- [x] Create `src/meshcore_hub/common/models/packet_path_hop.py` — `PacketPathHop` model
|
||||
- [x] Columns: `raw_packet_id` (FK `raw_packets.id`, `ondelete=CASCADE`), `position` (int), `node_hash` (String), denormalized `packet_hash` (String), `received_at` (DateTime), `observer_node_id` (String, nullable, FK `nodes.id`)
|
||||
- [x] `INDEX (node_hash, received_at)` — drives first-prefix + window range scan in `fetch_candidate_paths`
|
||||
- [x] `INDEX (raw_packet_id, position)` — serves per-reception ordered-hop fetch, FK lookup, `ON DELETE CASCADE` (leftmost-prefix covers equality-on-`raw_packet_id`, no separate FK index)
|
||||
- [x] Create `src/meshcore_hub/common/models/route.py` — `Route` model + `RouteVisibility` enum (mirrors `ChannelVisibility`)
|
||||
- [x] `RouteVisibility` enum: `community` / `member` / `operator` / `admin`
|
||||
- [x] `Route` columns: `name` (unique), `description` (nullable Text), `visibility` (RouteVisibility, default `community`), `match_width` (int, default 1, range 1..3), `window_hours` (int, default 24, range 1..720), `packet_count_threshold` (int, default 3, range 1..10000), `degraded_threshold` (nullable int, default null), `max_hop_span` (nullable int, default null = unlimited), `enabled` (bool, default true)
|
||||
- [x] Relationships: `route_nodes`, `route_observers`, `route_result` (all `cascade="all, delete-orphan"`)
|
||||
- [x] Create `src/meshcore_hub/common/models/route_node.py` — `RouteNode` model
|
||||
- [x] Columns: `route_id` (FK `routes.id`, `ondelete=CASCADE`), `node_id` (FK `nodes.id`), `position` (int, ordered), `expected_hash` (String, derived as `public_key[:2*match_width].upper()` at save time)
|
||||
- [x] Create `src/meshcore_hub/common/models/route_observer.py` — `RouteObserver` model
|
||||
- [x] Columns: `route_id` (FK `routes.id`, `ondelete=CASCADE`), `node_id` (FK `nodes.id`)
|
||||
- [x] Create `src/meshcore_hub/common/models/route_result.py` — `RouteResult` model
|
||||
- [x] `route_id` (FK `routes.id`, `ondelete=CASCADE`, unique — one row per route)
|
||||
- [x] `state` (enum `healthy` / `unhealthy` / `no_coverage` — the alerting axis)
|
||||
- [x] `quality` (enum `clear` / `marginal` / `failing` / `unknown` — the display axis, denormalized)
|
||||
- [x] `matched_count` (int), `threshold` (int, snapshot at eval time), `effective_degraded` (int, snapshot of `effective_degraded_threshold(route)` at eval time), `evaluated_at` (DateTime)
|
||||
- [x] Export all five new models from `src/meshcore_hub/common/models/__init__.py`
|
||||
- [x] Author one Alembic revision (batch mode, SQLite-safe) creating the five tables + indexes
|
||||
- [x] Backfill `packet_path_hops` from `raw_packets.decoded`
|
||||
- [x] Keyset-paginated (batch 1000) over `raw_packets`
|
||||
- [x] Reuse the frozen dual-path extraction copied from migration `20260703_2250` (`_normalize_hash_list` + `decoded.path` → `payload.decoded.pathHashes` fallback)
|
||||
- [x] Enumerate the extracted list (index = `position`), emit one `PacketPathHop` row per `(position, node_hash)` with `packet_hash`/`received_at`/`observer_node_id` denormalized from the source `raw_packet` row
|
||||
- [x] Verify migration applies cleanly on SQLite and Postgres (batch mode)
|
||||
|
||||
## 2. Ingest Pipeline (Phase 2)
|
||||
|
||||
- [x] Refactor `src/meshcore_hub/collector/handlers/raw_packet.py::store_raw_packet`
|
||||
- [x] Change inline `session.add(RawPacket(...))` (lines 138-155) to `raw_packet = RawPacket(...); session.add(raw_packet); session.flush()` so `raw_packet.id` is materialized
|
||||
- [x] After the flush, bulk-insert one `PacketPathHop` per `(position, node_hash)` from the already-computed `path_hashes` (lines 106-111), inside the existing `with db.session_scope()` block (line 118)
|
||||
- [x] Denormalize `packet_hash`/`received_at`/`observer_node_id` from the same in-scope values (`observer_node_id` already available as `observer_node.id` at line 140)
|
||||
- [x] Zero extra decode; hop extraction gated by existing raw-capture flag (caller `_perhaps_capture_raw_packet` already checks `_raw_packet_capture_enabled`)
|
||||
- [x] Extend `tests/test_collector/test_handlers/test_raw_packet.py`
|
||||
- [x] Assert hops are inserted with correct positions/hashes
|
||||
- [x] Assert hops are skipped when path is absent
|
||||
- [x] Assert `observer_node_id` denormalized correctly
|
||||
|
||||
## 3. Matching Engine (Phase 3)
|
||||
|
||||
- [x] Create `src/meshcore_hub/collector/routes.py` with fetch-and-check strategy (not N-way self-join)
|
||||
- [x] `fetch_candidate_paths(db, first_prefix, since, observer_ids=None, limit=None)` — one statement with subquery to avoid `SQLITE_MAX_VARIABLE_NUMBER` ceiling
|
||||
- [x] `is_subsequence(path, expected, max_hop_span=None)` — pure two-pointer prefix match, gaps allowed, span cap
|
||||
- [x] `DEGRADED_DEFAULT_MULTIPLIER = 2` module constant
|
||||
- [x] `effective_degraded_threshold(route)` — returns `route.degraded_threshold or (route.packet_count_threshold * DEGRADED_DEFAULT_MULTIPLIER)`
|
||||
- [x] `derive_quality(state, matched_count, threshold, effective_degraded)` — pure mapping implementing F4's quality axis
|
||||
- [x] `evaluate_route(db, route, since)` — fetch candidates, run subsequence, count distinct `packet_hash`, short-circuit at `effective_degraded_threshold`, existence check for `no_coverage` vs `unhealthy`
|
||||
- [x] `evaluate_all_routes(db, since)` — iterate only enabled routes, call `evaluate_route`
|
||||
- [x] `recent_matches(db, route, limit=3)` — same fetch + subsequence check, returns latest matching paths
|
||||
- [x] `preview_route(db, config, since)` — unsaved config with candidate cap (default 5000), returns `{matched_count, quality, contributing_observers, collisions}`
|
||||
- [x] Helpers: `derive_expected_hash`, `_hex_prefix_end`, `detect_observed_widths`, `prefix_collision_counts`
|
||||
- [x] Create `tests/test_collector/test_routes.py`
|
||||
- [x] Subsequence: gaps allowed, order enforced, span cap
|
||||
- [x] Per-reception isolation (no cross-observer splice — T2 semantics)
|
||||
- [x] Multi-observer dedup to distinct packets (`COUNT(DISTINCT packet_hash)`)
|
||||
- [x] Observer-scope filter
|
||||
- [x] Threshold short-circuit (at `effective_degraded`)
|
||||
- [x] `no_coverage` vs `unhealthy` separation
|
||||
- [x] Quality-band derivation (clear / marginal / failing / unknown, incl. null ⇒ `2 × threshold` relative default)
|
||||
- [x] `recent_matches` ordering/limit
|
||||
- [x] Preview truncation at candidate cap
|
||||
|
||||
## 4. CRUD API & Schemas (Phase 4)
|
||||
|
||||
- [x] Create `src/meshcore_hub/common/schemas/routes.py`
|
||||
- [x] `RouteCreate` / `RouteUpdate` / `RouteRead` / `RouteList` / `RouteDetail` / `RoutePreviewRequest` / `RoutePreviewResponse` Pydantic models
|
||||
- [x] Validate ≥2 **distinct** `route_nodes` in Pydantic
|
||||
- [x] Validate `degraded_threshold` either null or `> packet_count_threshold`
|
||||
- [x] `expected_hash` auto-derived (uppercased) from `node_id` when omitted; re-derived for all path nodes when `match_width` changes
|
||||
- [x] Create `src/meshcore_hub/api/routes/routes.py` (mirror `api/routes/channels.py`)
|
||||
- [x] `GET /api/v1/routes` — RequireRead, role-filtered, `@cached`, embeds lightweight `route_result`
|
||||
- [x] `POST /api/v1/routes` — RequireAdmin, collection-level
|
||||
- [x] `GET /api/v1/routes/{id}` — RequireRead, role-scoped, returns full detail
|
||||
- [x] `PUT /api/v1/routes/{id}` — RequireAdmin
|
||||
- [x] `DELETE /api/v1/routes/{id}` — RequireAdmin
|
||||
- [x] `POST /api/v1/routes/preview` — RequireRead, not cached; delegates to `collector.routes.preview_route`
|
||||
- [x] Register router in `src/meshcore_hub/api/routes/__init__.py`
|
||||
- [x] Create `tests/test_api/test_routes.py`
|
||||
- [x] CRUD lifecycle, role-scoping, visibility filter
|
||||
- [x] Min-2-nodes rejection, distinct-node rejection
|
||||
- [x] `degraded_threshold` validation (null ok; `<= threshold` rejected)
|
||||
- [x] Result embedding on list (lightweight) and detail (full)
|
||||
- [x] Preview endpoint (matched_count, quality, collisions, truncation)
|
||||
- [x] `GET /{id}` detail shape (observers + recent paths)
|
||||
|
||||
## 5. Background Evaluator (Phase 5)
|
||||
|
||||
- [x] Create `src/meshcore_hub/collector/route_evaluator.py` wrapping `collector/routes.py`
|
||||
- [x] Wire evaluator into `src/meshcore_hub/collector/subscriber.py`
|
||||
- [x] Add `_start_route_evaluator_scheduler` / `_stop_route_evaluator_scheduler` methods
|
||||
- [x] Start in `start()` (after spam scheduler)
|
||||
- [x] Stop in `stop()` (after spam stop)
|
||||
- [x] Add thread attribute near line 114
|
||||
- [x] Immediate first run on startup, 60s loop, per-iteration error logging
|
||||
- [x] Upsert into `route_results` via ORM check-then-update/insert (functionally equivalent to dialect-specific upsert in single-threaded context)
|
||||
- [x] Create `tests/test_collector/test_route_evaluator.py`
|
||||
- [x] Upsert idempotency (same route re-evaluated overwrites its single result row)
|
||||
- [x] Disabled routes skipped
|
||||
- [x] Correct result values written
|
||||
|
||||
## 6. Prometheus Metrics (Phase 6)
|
||||
|
||||
- [x] Modify `src/meshcore_hub/api/metrics.py::collect_metrics` — read `route_results ⋈ routes`, emit for all enabled routes
|
||||
- [x] `meshcore_route_healthy{route}` (1 if `quality` ∈ {clear, marginal} else 0)
|
||||
- [x] `meshcore_route_quality{route}` (0=clear, 1=marginal, 2=failing, 3=unknown)
|
||||
- [x] `meshcore_route_matched_packets{route}` (lower bound when `quality == clear`)
|
||||
- [x] `meshcore_route_threshold{route}`
|
||||
- [x] `meshcore_route_degraded_threshold{route}` (effective comfort bar; `2 × threshold` when unset)
|
||||
- [x] Verify in `tests/test_api/test_metrics.py`
|
||||
|
||||
## 7. Web UI & i18n (Phase 7)
|
||||
|
||||
- [x] Create `src/meshcore_hub/web/static/js/spa/pages/routes.js` (mirror `channels.js` structure)
|
||||
- [x] Summary strip at top with live quality counts
|
||||
- [x] Cards grouped by visibility, sorted failing/no_coverage/marginal first
|
||||
- [x] Five-state quality badge (clear/marginal/failing/no_coverage/disabled)
|
||||
- [x] Path chips showing ordered nodes
|
||||
- [x] Numbers line (matched / threshold → degraded · window · evaluated time)
|
||||
- [x] Admin edit/delete buttons
|
||||
- [x] Inline accordion expand (lazy `GET /api/v1/routes/{id}`, cached) with diagnosis, contributing observers, recent matches, config recap
|
||||
- [x] Wider (`modal-box-lg`) add/edit modal with name, description, visibility, enabled, segmented `match_width` control, node IDs input, observer IDs input, numeric fields, preview
|
||||
- [x] Register page in `src/meshcore_hub/web/static/js/spa/app.js`
|
||||
- [x] Add `routes: () => import('./pages/routes.js')` to `pages` lazy-load map
|
||||
- [x] Add route registration guarded by `features.routes !== false`
|
||||
- [x] Add `composePageTitle('entities.routes')` title entry
|
||||
- [x] Add nav card in `src/meshcore_hub/web/static/js/spa/pages/home.js`
|
||||
- [x] Add i18n strings to `src/meshcore_hub/web/static/locales/en.json` and `nl.json`
|
||||
- [x] `entities.routes` (value "Routes")
|
||||
- [x] New top-level `routes.*` block with all page strings incl. quality labels
|
||||
- [x] Add `--color-routes` CSS variable in `app.css`
|
||||
|
||||
## 8. Configuration, Seed Loader & Docs (Phase 8)
|
||||
|
||||
- [x] Add config to `src/meshcore_hub/common/config.py`
|
||||
- [x] `feature_routes=True` `Field(...)` declaration
|
||||
- [x] `route_evaluator_interval_seconds=60` `Field(...)` declaration (in `CollectorSettings`)
|
||||
- [x] `"routes": self.feature_routes` entry in `features` property dict
|
||||
- [x] `routes_file` property mirroring `channels_file`
|
||||
- [x] Update `.env.example` with the two new settings
|
||||
- [x] Create `_import_routes` in `src/meshcore_hub/collector/cli.py`
|
||||
- [x] Wire into `_run_seed_import` so `meshcore-hub seed` picks up `routes.yaml` automatically
|
||||
- [x] Idempotent upsert by `name`; resolve path/observer nodes by `public_key`
|
||||
- [x] Derive `expected_hash` (uppercased); never hand-typed
|
||||
- [x] Replace `route_nodes`/`route_observers` wholesale on update
|
||||
- [x] Missing **path** node = hard error; missing **observer** = skipped with warning
|
||||
- [x] Honor seeded `visibility` (default `community`) and `degraded_threshold` (null ⇒ `2 × threshold`)
|
||||
- [x] Return `{created, updated, errors}` shape
|
||||
- [x] Create `example/seed/routes.yaml` documenting the format
|
||||
- [ ] Update docs: `SCHEMAS.md`, `README.md`; cross-reference from `docs/seeding.md` and `docs/letsmesh.md` *(deferred — implementation complete, docs follow-up)*
|
||||
- [ ] Optional: `meshcore-hub routes list|delete` CLI *(deferred)*
|
||||
|
||||
## 9. Packet-Detail Consolidation (Phase 9)
|
||||
|
||||
- [x] Modify `src/meshcore_hub/api/routes/packet_groups.py::get_packet_group`
|
||||
- [x] Replace per-reception `_extract_path_hashes(packet.decoded)` with batched hop-table query
|
||||
- [x] Group results into `receptions[i].path_hashes` shape
|
||||
- [x] Hash values are normalized (uppercased) from hop table
|
||||
- [x] Fall back to empty list for rows lacking hops
|
||||
- [x] Delete `_extract_path_hashes` — dead third copy of dual-path extraction
|
||||
- [x] Update `tests/test_api/test_packet_groups.py`
|
||||
- [x] Assert detail endpoint returns `path_hashes` per reception from hop table
|
||||
- [x] Test missing hops returns None
|
||||
|
||||
## 10. Verification
|
||||
|
||||
- [x] Run targeted tests per phase — all pass
|
||||
- [x] Run full suite: `pytest -nauto --no-cov` — **1263 passed, 22 skipped**
|
||||
- [x] Run `pre-commit run --all-files` — **all hooks pass** (black, flake8, mypy, etc.)
|
||||
- [ ] Verify migration applies + backfills on a volume DB *(requires Docker stack — deferred to deployment)*
|
||||
- [ ] Manual smoke test in compose stack *(requires Docker stack — deferred to deployment)*
|
||||
@@ -0,0 +1,25 @@
|
||||
# Routes seed file — route health monitoring definitions
|
||||
#
|
||||
# Loaded by `meshcore-hub seed` (and the compose `seed` profile).
|
||||
# Keyed by route name; each entry holds the route's knobs plus an ordered
|
||||
# `path` of node public_keys (>= 2, distinct) and optionally an `observers`
|
||||
# list of public_keys.
|
||||
#
|
||||
# Path nodes must already exist in the database (create them via
|
||||
# node_tags.yaml or let the collector discover them). Observer nodes that
|
||||
# don't exist yet are skipped with a warning (not an error).
|
||||
|
||||
Ipswich ↔ Norwich:
|
||||
description: A140 corridor route
|
||||
visibility: community
|
||||
match_width: 1
|
||||
window_hours: 24
|
||||
packet_count_threshold: 3
|
||||
# degraded_threshold: 10 # optional; omit/null = 2x threshold
|
||||
# max_hop_span: 8 # optional; omit/null = unlimited
|
||||
enabled: true
|
||||
path:
|
||||
- a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
|
||||
- 9a8b7c6d5e4f9a8b7c6d5e4f9a8b7c6d5e4f9a8b7c6d5e4f9a8b7c6d5e4f9a8b
|
||||
# observers: # optional; omit/empty = all observers
|
||||
# - 0102030405060102030405060102030405060102030405060102030405060102
|
||||
@@ -16,6 +16,8 @@ from meshcore_hub.common.models import (
|
||||
EventLog,
|
||||
Message,
|
||||
Node,
|
||||
Route,
|
||||
RouteResult,
|
||||
Telemetry,
|
||||
TracePath,
|
||||
UserProfile,
|
||||
@@ -313,6 +315,61 @@ def collect_metrics(session: Any) -> bytes:
|
||||
)
|
||||
user_profiles_by_role.labels(role=role).set(role_count)
|
||||
|
||||
# -- Route health metrics --
|
||||
_QUALITY_VALUES = {
|
||||
"clear": 0,
|
||||
"marginal": 1,
|
||||
"failing": 2,
|
||||
"unknown": 3,
|
||||
}
|
||||
route_healthy = Gauge(
|
||||
"meshcore_route_healthy",
|
||||
"1 if route quality is clear or marginal, else 0",
|
||||
["route"],
|
||||
registry=registry,
|
||||
)
|
||||
route_quality = Gauge(
|
||||
"meshcore_route_quality",
|
||||
"Route quality band (0=clear, 1=marginal, 2=failing, 3=unknown)",
|
||||
["route"],
|
||||
registry=registry,
|
||||
)
|
||||
route_matched = Gauge(
|
||||
"meshcore_route_matched_packets",
|
||||
"Distinct matched packets in window (lower bound when clear)",
|
||||
["route"],
|
||||
registry=registry,
|
||||
)
|
||||
route_threshold = Gauge(
|
||||
"meshcore_route_threshold",
|
||||
"Route packet count threshold",
|
||||
["route"],
|
||||
registry=registry,
|
||||
)
|
||||
route_degraded = Gauge(
|
||||
"meshcore_route_degraded_threshold",
|
||||
"Effective degraded threshold (2x threshold when unset)",
|
||||
["route"],
|
||||
registry=registry,
|
||||
)
|
||||
route_rows = session.execute(
|
||||
select(Route, RouteResult)
|
||||
.outerjoin(RouteResult, RouteResult.route_id == Route.id)
|
||||
.where(Route.enabled.is_(True))
|
||||
).all()
|
||||
for route, result in route_rows:
|
||||
name = route.name
|
||||
quality_str = result.quality if result else "unknown"
|
||||
route_healthy.labels(route=name).set(
|
||||
1 if quality_str in ("clear", "marginal") else 0
|
||||
)
|
||||
route_quality.labels(route=name).set(_QUALITY_VALUES.get(quality_str, 3))
|
||||
route_matched.labels(route=name).set(result.matched_count if result else 0)
|
||||
route_threshold.labels(route=name).set(route.packet_count_threshold)
|
||||
from meshcore_hub.collector.routes import effective_degraded_threshold
|
||||
|
||||
route_degraded.labels(route=name).set(effective_degraded_threshold(route))
|
||||
|
||||
output: bytes = generate_latest(registry)
|
||||
return output
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from meshcore_hub.api.routes.dashboard import router as dashboard_router
|
||||
from meshcore_hub.api.routes.user_profiles import router as user_profiles_router
|
||||
from meshcore_hub.api.routes.adoptions import router as adoptions_router
|
||||
from meshcore_hub.api.routes.channels import router as channels_router
|
||||
from meshcore_hub.api.routes.routes import router as routes_router
|
||||
from meshcore_hub.api.routes.raw_packets import router as raw_packets_router
|
||||
from meshcore_hub.api.routes.packet_groups import router as packet_groups_router
|
||||
|
||||
@@ -32,6 +33,7 @@ api_router.include_router(dashboard_router, prefix="/dashboard", tags=["Dashboar
|
||||
api_router.include_router(user_profiles_router, prefix="/user", tags=["User"])
|
||||
api_router.include_router(adoptions_router, prefix="/adoptions", tags=["Adoptions"])
|
||||
api_router.include_router(channels_router, prefix="/channels", tags=["Channels"])
|
||||
api_router.include_router(routes_router, prefix="/routes", tags=["Routes"])
|
||||
api_router.include_router(raw_packets_router, prefix="/packets", tags=["Packets"])
|
||||
api_router.include_router(
|
||||
packet_groups_router, prefix="/packet-groups", tags=["Packet Groups"]
|
||||
|
||||
@@ -15,7 +15,7 @@ from meshcore_hub.api.channel_visibility import (
|
||||
resolve_user_role,
|
||||
)
|
||||
from meshcore_hub.api.dependencies import DbSession
|
||||
from meshcore_hub.common.models import Node, RawPacket
|
||||
from meshcore_hub.common.models import Node, PacketPathHop, RawPacket
|
||||
from meshcore_hub.common.schemas.raw_packets import (
|
||||
GroupedPacketList,
|
||||
GroupedPacketRead,
|
||||
@@ -33,24 +33,6 @@ def _group_key_builder(request: Request) -> str:
|
||||
return f"packet_groups:role={role}:{sorted_query_string(request)}"
|
||||
|
||||
|
||||
def _extract_path_hashes(decoded: dict[str, Any] | None) -> list[str] | None:
|
||||
"""Extract the routing path (per-hop node hash bytes) from a decoded packet.
|
||||
|
||||
The path lives at the top level as ``decoded.path`` for normal packets
|
||||
(flood/advertisement/etc.). Trace-style packets instead carry it at
|
||||
``decoded.payload.decoded.pathHashes``, so that is used as a fallback.
|
||||
"""
|
||||
if not decoded:
|
||||
return None
|
||||
path = decoded.get("path")
|
||||
if isinstance(path, list) and path:
|
||||
return path
|
||||
payload = decoded.get("payload") or {}
|
||||
inner = payload.get("decoded") or {}
|
||||
hashes = inner.get("pathHashes")
|
||||
return hashes if isinstance(hashes, list) else None
|
||||
|
||||
|
||||
def _get_tag_name(node: Optional[Node]) -> Optional[str]:
|
||||
if not node or not node.tags:
|
||||
return None
|
||||
@@ -273,6 +255,21 @@ def get_packet_group(
|
||||
)
|
||||
nodes_by_id = {n.id: n for n in nodes}
|
||||
|
||||
# Batch-fetch path hashes from the hop table (one query for all receptions)
|
||||
packet_ids = [row[0].id for row in rows]
|
||||
hops_by_packet: dict[str, list[str]] = {}
|
||||
if packet_ids:
|
||||
hop_rows = session.execute(
|
||||
select(
|
||||
PacketPathHop.raw_packet_id,
|
||||
PacketPathHop.node_hash,
|
||||
)
|
||||
.where(PacketPathHop.raw_packet_id.in_(packet_ids))
|
||||
.order_by(PacketPathHop.raw_packet_id, PacketPathHop.position)
|
||||
).all()
|
||||
for rp_id, node_hash in hop_rows:
|
||||
hops_by_packet.setdefault(rp_id, []).append(node_hash)
|
||||
|
||||
receptions: list[PacketReceptionInfo] = []
|
||||
for row in rows:
|
||||
packet = row[0]
|
||||
@@ -288,9 +285,7 @@ def get_packet_group(
|
||||
observer_tag_name=_get_tag_name(observer_node),
|
||||
snr=packet.snr,
|
||||
path_len=packet.path_len,
|
||||
path_hashes=(
|
||||
None if is_redacted else _extract_path_hashes(packet.decoded)
|
||||
),
|
||||
path_hashes=(None if is_redacted else hops_by_packet.get(packet.id)),
|
||||
received_at=packet.received_at,
|
||||
redacted=is_redacted,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
"""Route health monitoring API routes."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from sqlalchemy import select
|
||||
|
||||
from meshcore_hub.api.auth import RequireAdmin, RequireRead
|
||||
from meshcore_hub.api.cache import cached, sorted_query_string
|
||||
from meshcore_hub.api.channel_visibility import (
|
||||
VISIBILITY_LEVELS,
|
||||
get_max_visibility_level,
|
||||
resolve_user_role,
|
||||
)
|
||||
from meshcore_hub.api.dependencies import DbSession
|
||||
from meshcore_hub.collector.routes import (
|
||||
derive_expected_hash,
|
||||
preview_route,
|
||||
recent_matches,
|
||||
)
|
||||
from meshcore_hub.common.models.node import Node
|
||||
from meshcore_hub.common.models.route import Route
|
||||
from meshcore_hub.common.models.route_node import RouteNode
|
||||
from meshcore_hub.common.models.route_observer import RouteObserver
|
||||
from meshcore_hub.common.models.route_result import RouteResult
|
||||
from meshcore_hub.common.schemas.routes import (
|
||||
ContributingObserver,
|
||||
RecentMatchPath,
|
||||
RouteCreate,
|
||||
RouteDetail,
|
||||
RouteList,
|
||||
RouteNodeRead,
|
||||
RouteObserverRead,
|
||||
RoutePreviewRequest,
|
||||
RoutePreviewResponse,
|
||||
RouteRead,
|
||||
RouteResultSummary,
|
||||
RouteUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _routes_key_builder(request: Request) -> str:
|
||||
role = resolve_user_role(request) or "anonymous"
|
||||
return f"routes:role={role}:{sorted_query_string(request)}"
|
||||
|
||||
|
||||
def _route_node_to_read(rn: RouteNode) -> RouteNodeRead:
|
||||
return RouteNodeRead(
|
||||
node_id=rn.node_id,
|
||||
position=rn.position,
|
||||
expected_hash=rn.expected_hash,
|
||||
name=rn.node.name if rn.node else None,
|
||||
public_key=rn.node.public_key if rn.node else None,
|
||||
)
|
||||
|
||||
|
||||
def _route_observer_to_read(ro: RouteObserver) -> RouteObserverRead:
|
||||
return RouteObserverRead(
|
||||
node_id=ro.node_id,
|
||||
name=ro.node.name if ro.node else None,
|
||||
public_key=ro.node.public_key if ro.node else None,
|
||||
)
|
||||
|
||||
|
||||
def _result_to_summary(result: RouteResult | None) -> RouteResultSummary | None:
|
||||
if result is None:
|
||||
return None
|
||||
return RouteResultSummary(
|
||||
state=result.state,
|
||||
quality=result.quality,
|
||||
matched_count=result.matched_count,
|
||||
threshold=result.threshold,
|
||||
effective_degraded=result.effective_degraded,
|
||||
evaluated_at=result.evaluated_at,
|
||||
)
|
||||
|
||||
|
||||
def _route_to_read(route: Route) -> RouteRead:
|
||||
return RouteRead(
|
||||
id=route.id,
|
||||
name=route.name,
|
||||
description=route.description,
|
||||
visibility=route.visibility,
|
||||
match_width=route.match_width,
|
||||
window_hours=route.window_hours,
|
||||
packet_count_threshold=route.packet_count_threshold,
|
||||
degraded_threshold=route.degraded_threshold,
|
||||
max_hop_span=route.max_hop_span,
|
||||
enabled=route.enabled,
|
||||
route_nodes=[_route_node_to_read(rn) for rn in route.route_nodes],
|
||||
route_observers=[_route_observer_to_read(ro) for ro in route.route_observers],
|
||||
route_result=_result_to_summary(route.route_result),
|
||||
created_at=route.created_at,
|
||||
updated_at=route.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_nodes_by_pubkey(session: DbSession, public_keys: list[str]) -> list[Node]:
|
||||
"""Resolve node public keys to Node objects, preserving input order."""
|
||||
lowered = [pk.strip().lower() for pk in public_keys]
|
||||
nodes_by_key = {
|
||||
n.public_key: n
|
||||
for n in session.execute(select(Node).where(Node.public_key.in_(lowered)))
|
||||
.scalars()
|
||||
.all()
|
||||
}
|
||||
return [nodes_by_key[k] for k in lowered if k in nodes_by_key]
|
||||
|
||||
|
||||
def _sync_path_nodes(session: DbSession, route: Route, nodes: list[Node]) -> None:
|
||||
"""Replace all RouteNode children wholesale."""
|
||||
for rn in list(route.route_nodes):
|
||||
session.delete(rn)
|
||||
session.flush()
|
||||
for pos, node in enumerate(nodes):
|
||||
session.add(
|
||||
RouteNode(
|
||||
route_id=route.id,
|
||||
node_id=node.id,
|
||||
position=pos,
|
||||
expected_hash=derive_expected_hash(node.public_key, route.match_width),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _sync_observers(
|
||||
session: DbSession, route: Route, observer_nodes: list[Node]
|
||||
) -> None:
|
||||
"""Replace all RouteObserver children wholesale."""
|
||||
for ro in list(route.route_observers):
|
||||
session.delete(ro)
|
||||
session.flush()
|
||||
for node in observer_nodes:
|
||||
session.add(RouteObserver(route_id=route.id, node_id=node.id))
|
||||
|
||||
|
||||
@router.get("", response_model=RouteList)
|
||||
@cached("routes", key_builder=_routes_key_builder)
|
||||
def list_routes(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
request: Request,
|
||||
) -> RouteList:
|
||||
"""List routes, filtered by user role visibility."""
|
||||
role = resolve_user_role(request)
|
||||
max_level = get_max_visibility_level(role)
|
||||
|
||||
routes = session.execute(select(Route).order_by(Route.name)).scalars().all()
|
||||
filtered = [
|
||||
_route_to_read(r)
|
||||
for r in routes
|
||||
if VISIBILITY_LEVELS.get(r.visibility, 0) <= max_level
|
||||
]
|
||||
return RouteList(items=filtered, total=len(filtered))
|
||||
|
||||
|
||||
@router.post("", response_model=RouteRead, status_code=201)
|
||||
def create_route(
|
||||
__: RequireAdmin,
|
||||
session: DbSession,
|
||||
body: RouteCreate,
|
||||
) -> RouteRead:
|
||||
"""Create a new route (admin only)."""
|
||||
existing = session.execute(
|
||||
select(Route).where(Route.name == body.name)
|
||||
).scalar_one_or_none()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=409, detail=f"Route '{body.name}' already exists"
|
||||
)
|
||||
|
||||
nodes = _resolve_nodes_by_pubkey(session, body.node_public_keys)
|
||||
if len(nodes) < 2:
|
||||
raise HTTPException(status_code=400, detail="Could not resolve >= 2 path nodes")
|
||||
|
||||
observer_nodes = (
|
||||
_resolve_nodes_by_pubkey(session, body.observer_public_keys)
|
||||
if body.observer_public_keys
|
||||
else []
|
||||
)
|
||||
|
||||
route = Route(
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
visibility=body.visibility,
|
||||
match_width=body.match_width,
|
||||
window_hours=body.window_hours,
|
||||
packet_count_threshold=body.packet_count_threshold,
|
||||
degraded_threshold=body.degraded_threshold,
|
||||
max_hop_span=body.max_hop_span,
|
||||
enabled=body.enabled,
|
||||
)
|
||||
session.add(route)
|
||||
session.flush()
|
||||
_sync_path_nodes(session, route, nodes)
|
||||
_sync_observers(session, route, observer_nodes)
|
||||
session.commit()
|
||||
session.refresh(route)
|
||||
return _route_to_read(route)
|
||||
|
||||
|
||||
@router.get("/{route_id}", response_model=RouteDetail)
|
||||
def get_route(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
route_id: str,
|
||||
request: Request,
|
||||
) -> RouteDetail:
|
||||
"""Get full route detail with contributing observers and recent matches."""
|
||||
route = session.execute(
|
||||
select(Route).where(Route.id == route_id)
|
||||
).scalar_one_or_none()
|
||||
if not route:
|
||||
raise HTTPException(status_code=404, detail="Route not found")
|
||||
|
||||
role = resolve_user_role(request)
|
||||
max_level = get_max_visibility_level(role)
|
||||
if VISIBILITY_LEVELS.get(route.visibility, 0) > max_level:
|
||||
raise HTTPException(status_code=404, detail="Route not found")
|
||||
|
||||
matches = recent_matches(session, route, limit=3)
|
||||
|
||||
contributing: dict[str, int] = {}
|
||||
for m in matches:
|
||||
obs = m.get("observer_node_id")
|
||||
if obs:
|
||||
contributing[obs] = contributing.get(obs, 0) + 1
|
||||
|
||||
obs_ids = list(contributing.keys())
|
||||
obs_nodes = (
|
||||
{
|
||||
n.id: n
|
||||
for n in session.execute(select(Node).where(Node.id.in_(obs_ids)))
|
||||
.scalars()
|
||||
.all()
|
||||
}
|
||||
if obs_ids
|
||||
else {}
|
||||
)
|
||||
|
||||
contributors = [
|
||||
ContributingObserver(
|
||||
node_id=oid,
|
||||
name=obs_nodes[oid].name if oid in obs_nodes else None,
|
||||
match_count=cnt,
|
||||
)
|
||||
for oid, cnt in contributing.items()
|
||||
]
|
||||
|
||||
read = _route_to_read(route)
|
||||
return RouteDetail(
|
||||
**read.model_dump(),
|
||||
contributing_observers=contributors,
|
||||
recent_matches=[RecentMatchPath(**m) for m in matches],
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{route_id}", response_model=RouteRead)
|
||||
def update_route(
|
||||
__: RequireAdmin,
|
||||
session: DbSession,
|
||||
route_id: str,
|
||||
body: RouteUpdate,
|
||||
) -> RouteRead:
|
||||
"""Update a route (admin only)."""
|
||||
route = session.execute(
|
||||
select(Route).where(Route.id == route_id)
|
||||
).scalar_one_or_none()
|
||||
if not route:
|
||||
raise HTTPException(status_code=404, detail="Route not found")
|
||||
|
||||
if body.name is not None:
|
||||
dup = session.execute(
|
||||
select(Route).where(Route.name == body.name, Route.id != route_id)
|
||||
).scalar_one_or_none()
|
||||
if dup:
|
||||
raise HTTPException(
|
||||
status_code=409, detail=f"Route '{body.name}' already exists"
|
||||
)
|
||||
route.name = body.name
|
||||
|
||||
if body.description is not None:
|
||||
route.description = body.description
|
||||
if body.visibility is not None:
|
||||
route.visibility = body.visibility
|
||||
if body.match_width is not None:
|
||||
route.match_width = body.match_width
|
||||
if body.window_hours is not None:
|
||||
route.window_hours = body.window_hours
|
||||
if body.packet_count_threshold is not None:
|
||||
route.packet_count_threshold = body.packet_count_threshold
|
||||
if body.degraded_threshold is not None:
|
||||
route.degraded_threshold = body.degraded_threshold
|
||||
if body.max_hop_span is not None:
|
||||
route.max_hop_span = body.max_hop_span
|
||||
if body.enabled is not None:
|
||||
route.enabled = body.enabled
|
||||
|
||||
if body.node_public_keys is not None:
|
||||
nodes = _resolve_nodes_by_pubkey(session, body.node_public_keys)
|
||||
if len(nodes) < 2:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Could not resolve >= 2 path nodes"
|
||||
)
|
||||
_sync_path_nodes(session, route, nodes)
|
||||
|
||||
if body.observer_public_keys is not None:
|
||||
observer_nodes = _resolve_nodes_by_pubkey(session, body.observer_public_keys)
|
||||
_sync_observers(session, route, observer_nodes)
|
||||
|
||||
session.commit()
|
||||
session.refresh(route)
|
||||
return _route_to_read(route)
|
||||
|
||||
|
||||
@router.delete("/{route_id}", status_code=204)
|
||||
def delete_route(
|
||||
__: RequireAdmin,
|
||||
session: DbSession,
|
||||
route_id: str,
|
||||
) -> None:
|
||||
"""Delete a route (admin only)."""
|
||||
route = session.execute(
|
||||
select(Route).where(Route.id == route_id)
|
||||
).scalar_one_or_none()
|
||||
if not route:
|
||||
raise HTTPException(status_code=404, detail="Route not found")
|
||||
session.delete(route)
|
||||
session.commit()
|
||||
|
||||
|
||||
@router.post("/preview", response_model=RoutePreviewResponse)
|
||||
def preview(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
body: RoutePreviewRequest,
|
||||
) -> RoutePreviewResponse:
|
||||
"""Preview matching for an unsaved route config (any authenticated user)."""
|
||||
since = datetime.now(timezone.utc) - timedelta(hours=body.window_hours)
|
||||
|
||||
nodes = _resolve_nodes_by_pubkey(session, body.node_public_keys)
|
||||
if len(nodes) < 2:
|
||||
return RoutePreviewResponse(
|
||||
matched_count=0,
|
||||
quality="unknown",
|
||||
state="no_coverage",
|
||||
contributing_observers={},
|
||||
collisions={},
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
observer_nodes = (
|
||||
_resolve_nodes_by_pubkey(session, body.observer_public_keys)
|
||||
if body.observer_public_keys
|
||||
else []
|
||||
)
|
||||
|
||||
config = {
|
||||
"node_ids": [n.id for n in nodes],
|
||||
"match_width": body.match_width,
|
||||
"observer_ids": [n.id for n in observer_nodes] if observer_nodes else None,
|
||||
"max_hop_span": body.max_hop_span,
|
||||
"packet_count_threshold": body.packet_count_threshold,
|
||||
"degraded_threshold": body.degraded_threshold,
|
||||
}
|
||||
result = preview_route(session, config, since)
|
||||
return RoutePreviewResponse(**result)
|
||||
@@ -598,6 +598,28 @@ def _run_seed_import(
|
||||
elif verbose:
|
||||
click.echo(f"\nNo channels.yaml found in {seed_home}")
|
||||
|
||||
# Import routes if file exists
|
||||
routes_file = Path(seed_home) / "routes.yaml"
|
||||
if routes_file.exists():
|
||||
if verbose:
|
||||
click.echo(f"\nImporting routes from: {routes_file}")
|
||||
route_stats = _import_routes(
|
||||
file_path=str(routes_file),
|
||||
db=db,
|
||||
verbose=verbose,
|
||||
)
|
||||
if verbose:
|
||||
click.echo(
|
||||
f" Routes: {route_stats['created']} created, "
|
||||
f"{route_stats['updated']} updated"
|
||||
)
|
||||
if route_stats["errors"]:
|
||||
for error in route_stats["errors"]: # type: ignore[union-attr]
|
||||
click.echo(f" Error: {error}", err=True)
|
||||
imported_any = True
|
||||
elif verbose:
|
||||
click.echo(f"\nNo routes.yaml found in {seed_home}")
|
||||
|
||||
return imported_any
|
||||
|
||||
|
||||
@@ -670,6 +692,149 @@ def _import_channels(
|
||||
return {"created": created, "updated": updated, "errors": errors}
|
||||
|
||||
|
||||
def _import_routes(
|
||||
file_path: str,
|
||||
db: "DatabaseManager",
|
||||
verbose: bool = False,
|
||||
) -> dict[str, int | list[str]]:
|
||||
"""Import routes from a YAML file.
|
||||
|
||||
Each entry is keyed by route name and holds the route's knobs plus an
|
||||
ordered ``path`` of node public_keys and optionally an ``observers`` list.
|
||||
Path/observer entries are resolved by public_key. A missing **path** node
|
||||
is a hard error; a missing **observer** is skipped with a warning.
|
||||
|
||||
Returns:
|
||||
Dict with 'created', 'updated', and 'errors'.
|
||||
"""
|
||||
import yaml
|
||||
|
||||
from meshcore_hub.collector.routes import derive_expected_hash
|
||||
from meshcore_hub.common.models.node import Node
|
||||
from meshcore_hub.common.models.route import Route
|
||||
from meshcore_hub.common.models.route_node import RouteNode
|
||||
from meshcore_hub.common.models.route_observer import RouteObserver
|
||||
|
||||
created: int = 0
|
||||
updated: int = 0
|
||||
errors: list[str] = []
|
||||
|
||||
with open(file_path) as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
if not data or not isinstance(data, dict):
|
||||
return {"created": created, "updated": updated, "errors": errors}
|
||||
|
||||
with db.session_scope() as session:
|
||||
for name, value in data.items():
|
||||
try:
|
||||
if not isinstance(value, dict):
|
||||
errors.append(f"Route '{name}': entry must be a dict")
|
||||
continue
|
||||
|
||||
path_keys: list[str] = value.get("path") or []
|
||||
if len(path_keys) < 2:
|
||||
errors.append(f"Route '{name}': path needs >= 2 nodes")
|
||||
continue
|
||||
|
||||
match_width: int = value.get("match_width", 1)
|
||||
visibility: str = value.get("visibility", "community")
|
||||
|
||||
# Resolve path nodes by public_key
|
||||
path_nodes: list[Node] = []
|
||||
path_ok = True
|
||||
for pk in path_keys:
|
||||
pk_lower = pk.strip().lower()
|
||||
node = (
|
||||
session.query(Node).filter(Node.public_key == pk_lower).first()
|
||||
)
|
||||
if not node:
|
||||
errors.append(
|
||||
f"Route '{name}': path node {pk_lower[:12]}... not found"
|
||||
)
|
||||
path_ok = False
|
||||
else:
|
||||
path_nodes.append(node)
|
||||
if not path_ok:
|
||||
continue
|
||||
|
||||
# Resolve observer nodes (missing = warning, not error)
|
||||
observer_keys: list[str] = value.get("observers") or []
|
||||
observer_nodes: list[Node] = []
|
||||
for pk in observer_keys:
|
||||
pk_lower = pk.strip().lower()
|
||||
node = (
|
||||
session.query(Node).filter(Node.public_key == pk_lower).first()
|
||||
)
|
||||
if node:
|
||||
observer_nodes.append(node)
|
||||
elif verbose:
|
||||
click.echo(
|
||||
f" Warning: observer node {pk_lower[:12]}... "
|
||||
f"not found, skipping (route '{name}')"
|
||||
)
|
||||
|
||||
# Upsert route by name
|
||||
existing = session.query(Route).filter(Route.name == name).first()
|
||||
|
||||
if existing:
|
||||
route = existing
|
||||
route.description = value.get("description")
|
||||
route.visibility = visibility
|
||||
route.match_width = match_width
|
||||
route.window_hours = value.get("window_hours", 24)
|
||||
route.packet_count_threshold = value.get(
|
||||
"packet_count_threshold", 3
|
||||
)
|
||||
route.degraded_threshold = value.get("degraded_threshold")
|
||||
route.max_hop_span = value.get("max_hop_span")
|
||||
route.enabled = value.get("enabled", True)
|
||||
# Replace path nodes wholesale
|
||||
for rn in list(route.route_nodes):
|
||||
session.delete(rn)
|
||||
for ro in list(route.route_observers):
|
||||
session.delete(ro)
|
||||
session.flush()
|
||||
updated += 1
|
||||
else:
|
||||
route = Route(
|
||||
name=name,
|
||||
description=value.get("description"),
|
||||
visibility=visibility,
|
||||
match_width=match_width,
|
||||
window_hours=value.get("window_hours", 24),
|
||||
packet_count_threshold=value.get("packet_count_threshold", 3),
|
||||
degraded_threshold=value.get("degraded_threshold"),
|
||||
max_hop_span=value.get("max_hop_span"),
|
||||
enabled=value.get("enabled", True),
|
||||
)
|
||||
session.add(route)
|
||||
session.flush()
|
||||
created += 1
|
||||
|
||||
# Insert path nodes
|
||||
for pos, node in enumerate(path_nodes):
|
||||
session.add(
|
||||
RouteNode(
|
||||
route_id=route.id,
|
||||
node_id=node.id,
|
||||
position=pos,
|
||||
expected_hash=derive_expected_hash(
|
||||
node.public_key, match_width
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Insert observers
|
||||
for node in observer_nodes:
|
||||
session.add(RouteObserver(route_id=route.id, node_id=node.id))
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"Route '{name}': {e}")
|
||||
|
||||
return {"created": created, "updated": updated, "errors": errors}
|
||||
|
||||
|
||||
@collector.command("import-tags")
|
||||
@click.argument("file", type=click.Path(), required=False, default=None)
|
||||
@click.option(
|
||||
|
||||
@@ -15,7 +15,7 @@ from sqlalchemy import select
|
||||
|
||||
from meshcore_hub.collector.letsmesh_normalizer import LetsMeshNormalizer
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
from meshcore_hub.common.models import Node, RawPacket
|
||||
from meshcore_hub.common.models import Node, PacketPathHop, RawPacket
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -135,23 +135,36 @@ def store_raw_packet(
|
||||
if not observer_node.is_observer:
|
||||
observer_node.is_observer = True
|
||||
|
||||
session.add(
|
||||
RawPacket(
|
||||
observer_node_id=observer_node.id if observer_node else None,
|
||||
packet_hash=packet_hash,
|
||||
raw_hex=raw_hex,
|
||||
packet_type=packet_type,
|
||||
payload_type=payload_type,
|
||||
event_type=event_type,
|
||||
channel_idx=channel_idx,
|
||||
source_pubkey_prefix=source_pubkey_prefix,
|
||||
route_type=route_type,
|
||||
path_len=path_len,
|
||||
path_hash_bytes=path_hash_bytes,
|
||||
snr=snr,
|
||||
decoded=decoded_packet,
|
||||
received_at=now,
|
||||
)
|
||||
raw_packet = RawPacket(
|
||||
observer_node_id=observer_node.id if observer_node else None,
|
||||
packet_hash=packet_hash,
|
||||
raw_hex=raw_hex,
|
||||
packet_type=packet_type,
|
||||
payload_type=payload_type,
|
||||
event_type=event_type,
|
||||
channel_idx=channel_idx,
|
||||
source_pubkey_prefix=source_pubkey_prefix,
|
||||
route_type=route_type,
|
||||
path_len=path_len,
|
||||
path_hash_bytes=path_hash_bytes,
|
||||
snr=snr,
|
||||
decoded=decoded_packet,
|
||||
received_at=now,
|
||||
)
|
||||
session.add(raw_packet)
|
||||
session.flush()
|
||||
|
||||
if path_hashes:
|
||||
for position, node_hash in enumerate(path_hashes):
|
||||
session.add(
|
||||
PacketPathHop(
|
||||
raw_packet_id=raw_packet.id,
|
||||
position=position,
|
||||
node_hash=node_hash,
|
||||
packet_hash=packet_hash,
|
||||
received_at=now,
|
||||
observer_node_id=(observer_node.id if observer_node else None),
|
||||
)
|
||||
)
|
||||
|
||||
logger.debug("Captured raw packet: %s (%s)", packet_hash or "unknown", event_type)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Route health evaluator — background evaluation driver.
|
||||
|
||||
Wraps :mod:`meshcore_hub.collector.routes` to evaluate every enabled route
|
||||
and upsert the results into ``route_results``. Called on a scheduler thread
|
||||
inside the collector subscriber (mirroring the spam re-scoring sweep).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from meshcore_hub.collector.routes import upsert_route_result
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
from meshcore_hub.common.models.route import Route
|
||||
from sqlalchemy import select
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_evaluation(db: DatabaseManager) -> int:
|
||||
"""Evaluate all enabled routes and upsert results.
|
||||
|
||||
Returns the number of routes evaluated.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
with db.session_scope() as session:
|
||||
routes = (
|
||||
session.execute(select(Route).where(Route.enabled.is_(True)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
count = 0
|
||||
for route in routes:
|
||||
try:
|
||||
from datetime import timedelta
|
||||
|
||||
route_since = now - timedelta(hours=route.window_hours)
|
||||
from meshcore_hub.collector.routes import evaluate_route
|
||||
|
||||
state, quality, matched_count = evaluate_route(
|
||||
session, route, route_since
|
||||
)
|
||||
upsert_route_result(session, route, state, quality, matched_count)
|
||||
count += 1
|
||||
except Exception:
|
||||
logger.exception("Error evaluating route '%s'", route.name)
|
||||
|
||||
return count
|
||||
@@ -0,0 +1,513 @@
|
||||
"""Route health matching engine.
|
||||
|
||||
Fetch-and-check strategy: fetch candidate receptions whose path contains the
|
||||
first configured node prefix, then run a trivial two-pointer subsequence match
|
||||
per reception. Scales with (candidates) only, not (candidates × depth).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from meshcore_hub.common.models.node import Node
|
||||
from meshcore_hub.common.models.packet_path_hop import PacketPathHop
|
||||
from meshcore_hub.common.models.route import Route
|
||||
from meshcore_hub.common.models.route_result import (
|
||||
RouteQuality,
|
||||
RouteResult,
|
||||
RouteState,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Multiplier for the relative default comfort bar (``degraded_threshold = None``
|
||||
#: means ``effective_degraded = 2 × packet_count_threshold``).
|
||||
DEGRADED_DEFAULT_MULTIPLIER = 2
|
||||
|
||||
#: Cap on candidate receptions for preview to bound work per call.
|
||||
PREVIEW_CANDIDATE_CAP = 5000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _hex_prefix_end(prefix: str) -> str:
|
||||
"""Exclusive upper bound for a hex prefix range scan.
|
||||
|
||||
Increments the last character's ASCII value by one. For hex digits this
|
||||
yields the correct lexicographic boundary ('9' → ':', 'A' → 'B', 'F' → 'G').
|
||||
"""
|
||||
return prefix[:-1] + chr(ord(prefix[-1]) + 1)
|
||||
|
||||
|
||||
def derive_expected_hash(public_key: str, match_width: int) -> str:
|
||||
"""Derive the uppercase path-hash prefix for a node at a given width."""
|
||||
return public_key[: 2 * match_width].upper()
|
||||
|
||||
|
||||
def effective_degraded_threshold(route: Route) -> int:
|
||||
"""The effective comfort bar: explicit value or ``2 × threshold``."""
|
||||
return route.degraded_threshold or (
|
||||
route.packet_count_threshold * DEGRADED_DEFAULT_MULTIPLIER
|
||||
)
|
||||
|
||||
|
||||
def derive_quality(
|
||||
state: str,
|
||||
matched_count: int,
|
||||
threshold: int,
|
||||
effective_degraded: int,
|
||||
) -> str:
|
||||
"""Map ``(state, matched_count, thresholds)`` to a quality band."""
|
||||
if state == RouteState.HEALTHY.value:
|
||||
if matched_count >= effective_degraded:
|
||||
return RouteQuality.CLEAR.value
|
||||
return RouteQuality.MARGINAL.value
|
||||
if state == RouteState.UNHEALTHY.value:
|
||||
return RouteQuality.FAILING.value
|
||||
return RouteQuality.UNKNOWN.value
|
||||
|
||||
|
||||
def is_subsequence(
|
||||
path: list[dict[str, Any]],
|
||||
expected: list[str],
|
||||
max_hop_span: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""Pure two-pointer subsequence prefix match with gaps allowed.
|
||||
|
||||
Each entry in *path* is a dict with ``position`` and ``node_hash``.
|
||||
*expected* is the ordered list of uppercase hash prefixes to find.
|
||||
A hop matches when ``node_hash.startswith(expected_hash)``.
|
||||
``max_hop_span`` constrains ``position(last) - position(first)`` when set.
|
||||
"""
|
||||
if not expected:
|
||||
return False
|
||||
pi = 0
|
||||
first_pos: Optional[int] = None
|
||||
last_pos: Optional[int] = None
|
||||
for needed in expected:
|
||||
found = False
|
||||
while pi < len(path):
|
||||
hop = path[pi]
|
||||
pi += 1
|
||||
if hop["node_hash"].startswith(needed):
|
||||
pos = hop["position"]
|
||||
if first_pos is None:
|
||||
first_pos = pos
|
||||
last_pos = pos
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
return False
|
||||
if max_hop_span is not None and first_pos is not None and last_pos is not None:
|
||||
return last_pos - first_pos <= max_hop_span
|
||||
return True
|
||||
|
||||
|
||||
def prefix_collision_counts(session: Session, match_width: int) -> dict[str, int]:
|
||||
"""Count how many nodes share each public-key prefix at *match_width*.
|
||||
|
||||
Returns a mapping ``{prefix: count}`` where *prefix* is the uppercased
|
||||
first ``2*match_width`` hex chars of each node's public key.
|
||||
"""
|
||||
chars = 2 * match_width
|
||||
prefix_expr = func.upper(func.substr(Node.public_key, 1, chars))
|
||||
rows = session.execute(
|
||||
select(prefix_expr, func.count(Node.id)).group_by(prefix_expr)
|
||||
).all()
|
||||
return {str(prefix): int(count) for prefix, count in rows if prefix}
|
||||
|
||||
|
||||
def detect_observed_widths(session: Session, public_key: str) -> set[int]:
|
||||
"""Detect which path-hash prefix widths a node has been observed at."""
|
||||
widths: set[int] = set()
|
||||
for width in (1, 2, 3):
|
||||
prefix = derive_expected_hash(public_key, width)
|
||||
prefix_end = _hex_prefix_end(prefix)
|
||||
count = (
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(PacketPathHop)
|
||||
.where(
|
||||
PacketPathHop.node_hash >= prefix,
|
||||
PacketPathHop.node_hash < prefix_end,
|
||||
)
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
if count > 0:
|
||||
widths.add(width)
|
||||
return widths
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Candidate fetching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _route_expected_hashes(route: Route) -> list[str]:
|
||||
"""Ordered expected hash prefixes from the route's nodes."""
|
||||
expected: list[str] = []
|
||||
for rn in route.route_nodes:
|
||||
if rn.expected_hash:
|
||||
expected.append(rn.expected_hash)
|
||||
elif rn.node and rn.node.public_key:
|
||||
expected.append(derive_expected_hash(rn.node.public_key, route.match_width))
|
||||
return expected
|
||||
|
||||
|
||||
def fetch_candidate_paths(
|
||||
session: Session,
|
||||
first_prefix: str,
|
||||
since: datetime,
|
||||
observer_ids: Optional[list[str]] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Fetch all hops for receptions whose path starts with *first_prefix*.
|
||||
|
||||
Returns a dict ``{raw_packet_id: [{position, node_hash, packet_hash,
|
||||
received_at, observer_node_id}, ...]}`` ordered by position within each
|
||||
reception.
|
||||
"""
|
||||
prefix_end = _hex_prefix_end(first_prefix)
|
||||
|
||||
subq = (
|
||||
select(PacketPathHop.raw_packet_id)
|
||||
.where(
|
||||
PacketPathHop.node_hash >= first_prefix,
|
||||
PacketPathHop.node_hash < prefix_end,
|
||||
PacketPathHop.received_at >= since,
|
||||
)
|
||||
.group_by(PacketPathHop.raw_packet_id)
|
||||
)
|
||||
if observer_ids:
|
||||
subq = subq.where(PacketPathHop.observer_node_id.in_(observer_ids))
|
||||
if limit is not None:
|
||||
subq = subq.limit(limit)
|
||||
subq_obj = subq.subquery()
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
PacketPathHop.raw_packet_id,
|
||||
PacketPathHop.position,
|
||||
PacketPathHop.node_hash,
|
||||
PacketPathHop.packet_hash,
|
||||
PacketPathHop.received_at,
|
||||
PacketPathHop.observer_node_id,
|
||||
)
|
||||
.where(PacketPathHop.raw_packet_id.in_(select(subq_obj.c.raw_packet_id)))
|
||||
.order_by(PacketPathHop.raw_packet_id, PacketPathHop.position)
|
||||
)
|
||||
|
||||
paths: dict[str, list[dict[str, Any]]] = {}
|
||||
for row in session.execute(stmt).all():
|
||||
rp_id = row.raw_packet_id
|
||||
if rp_id not in paths:
|
||||
paths[rp_id] = []
|
||||
paths[rp_id].append(
|
||||
{
|
||||
"position": row.position,
|
||||
"node_hash": row.node_hash,
|
||||
"packet_hash": row.packet_hash,
|
||||
"received_at": row.received_at,
|
||||
"observer_node_id": row.observer_node_id,
|
||||
}
|
||||
)
|
||||
return paths
|
||||
|
||||
|
||||
def _count_candidate_receptions(
|
||||
session: Session,
|
||||
first_prefix: str,
|
||||
since: datetime,
|
||||
observer_ids: Optional[list[str]] = None,
|
||||
) -> int:
|
||||
"""Count distinct receptions matching the first prefix in the window."""
|
||||
prefix_end = _hex_prefix_end(first_prefix)
|
||||
stmt = select(func.count(func.distinct(PacketPathHop.raw_packet_id))).where(
|
||||
PacketPathHop.node_hash >= first_prefix,
|
||||
PacketPathHop.node_hash < prefix_end,
|
||||
PacketPathHop.received_at >= since,
|
||||
)
|
||||
if observer_ids:
|
||||
stmt = stmt.where(PacketPathHop.observer_node_id.in_(observer_ids))
|
||||
return session.execute(stmt).scalar() or 0
|
||||
|
||||
|
||||
def _has_any_hops_in_window(
|
||||
session: Session,
|
||||
since: datetime,
|
||||
observer_ids: Optional[list[str]] = None,
|
||||
) -> bool:
|
||||
"""Existence check: are there ANY in-scope hops in the window?"""
|
||||
stmt = (
|
||||
select(func.count())
|
||||
.select_from(PacketPathHop)
|
||||
.where(PacketPathHop.received_at >= since)
|
||||
)
|
||||
if observer_ids:
|
||||
stmt = stmt.where(PacketPathHop.observer_node_id.in_(observer_ids))
|
||||
return (session.execute(stmt).scalar() or 0) > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Evaluation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def evaluate_route(
|
||||
session: Session,
|
||||
route: Route,
|
||||
since: datetime,
|
||||
) -> tuple[str, str, int]:
|
||||
"""Evaluate a single route.
|
||||
|
||||
Returns ``(state, quality, matched_count)``. *matched_count* is a lower
|
||||
bound when the evaluation short-circuits at the comfort bar.
|
||||
"""
|
||||
expected = _route_expected_hashes(route)
|
||||
if len(expected) < 2:
|
||||
return RouteState.NO_COVERAGE.value, RouteQuality.UNKNOWN.value, 0
|
||||
|
||||
observer_ids = (
|
||||
[ro.node_id for ro in route.route_observers] if route.route_observers else None
|
||||
)
|
||||
|
||||
paths = fetch_candidate_paths(session, expected[0], since, observer_ids)
|
||||
|
||||
eff_degraded = effective_degraded_threshold(route)
|
||||
|
||||
matched_packets: set[str] = set()
|
||||
for hops in paths.values():
|
||||
if is_subsequence(hops, expected, route.max_hop_span):
|
||||
ph = hops[0]["packet_hash"]
|
||||
if ph:
|
||||
matched_packets.add(ph)
|
||||
if len(matched_packets) >= eff_degraded:
|
||||
return (
|
||||
RouteState.HEALTHY.value,
|
||||
RouteQuality.CLEAR.value,
|
||||
len(matched_packets),
|
||||
)
|
||||
|
||||
matched_count = len(matched_packets)
|
||||
threshold = route.packet_count_threshold
|
||||
|
||||
if matched_count >= threshold:
|
||||
state = RouteState.HEALTHY.value
|
||||
else:
|
||||
exists = _has_any_hops_in_window(session, since, observer_ids)
|
||||
state = RouteState.UNHEALTHY.value if exists else RouteState.NO_COVERAGE.value
|
||||
|
||||
quality = derive_quality(state, matched_count, threshold, eff_degraded)
|
||||
return state, quality, matched_count
|
||||
|
||||
|
||||
def evaluate_all_routes(
|
||||
session: Session, now: datetime
|
||||
) -> dict[str, tuple[str, str, int]]:
|
||||
"""Evaluate every enabled route.
|
||||
|
||||
Returns ``{route_id: (state, quality, matched_count)}``.
|
||||
"""
|
||||
routes = (
|
||||
session.execute(select(Route).where(Route.enabled.is_(True))).scalars().all()
|
||||
)
|
||||
|
||||
results: dict[str, tuple[str, str, int]] = {}
|
||||
for route in routes:
|
||||
try:
|
||||
route_since = now - timedelta(hours=route.window_hours)
|
||||
results[route.id] = evaluate_route(session, route, route_since)
|
||||
except Exception:
|
||||
logger.exception("Error evaluating route '%s'", route.name)
|
||||
return results
|
||||
|
||||
|
||||
def upsert_route_result(
|
||||
session: Session,
|
||||
route: Route,
|
||||
state: str,
|
||||
quality: str,
|
||||
matched_count: int,
|
||||
) -> RouteResult:
|
||||
"""Upsert a route evaluation result (ORM check-then-update/insert)."""
|
||||
now = datetime.now(timezone.utc)
|
||||
eff_degraded = effective_degraded_threshold(route)
|
||||
|
||||
existing = session.execute(
|
||||
select(RouteResult).where(RouteResult.route_id == route.id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
existing.state = state
|
||||
existing.quality = quality
|
||||
existing.matched_count = matched_count
|
||||
existing.threshold = route.packet_count_threshold
|
||||
existing.effective_degraded = eff_degraded
|
||||
existing.evaluated_at = now
|
||||
return existing
|
||||
|
||||
result = RouteResult(
|
||||
id=str(uuid4()),
|
||||
route_id=route.id,
|
||||
state=state,
|
||||
quality=quality,
|
||||
matched_count=matched_count,
|
||||
threshold=route.packet_count_threshold,
|
||||
effective_degraded=eff_degraded,
|
||||
evaluated_at=now,
|
||||
)
|
||||
session.add(result)
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Card expand + preview
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def recent_matches(
|
||||
session: Session,
|
||||
route: Route,
|
||||
limit: int = 3,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return the latest *limit* matching paths for a route."""
|
||||
expected = _route_expected_hashes(route)
|
||||
if len(expected) < 2:
|
||||
return []
|
||||
|
||||
observer_ids = (
|
||||
[ro.node_id for ro in route.route_observers] if route.route_observers else None
|
||||
)
|
||||
since = datetime.now(timezone.utc) - timedelta(hours=route.window_hours)
|
||||
|
||||
paths = fetch_candidate_paths(session, expected[0], since, observer_ids)
|
||||
|
||||
matches: list[dict[str, Any]] = []
|
||||
for hops in paths.values():
|
||||
if is_subsequence(hops, expected, route.max_hop_span):
|
||||
first = hops[0] if hops else {}
|
||||
matches.append(
|
||||
{
|
||||
"packet_hash": first.get("packet_hash"),
|
||||
"hops": hops,
|
||||
"received_at": first.get("received_at"),
|
||||
"observer_node_id": first.get("observer_node_id"),
|
||||
}
|
||||
)
|
||||
|
||||
matches.sort(
|
||||
key=lambda m: m["received_at"] or datetime.min.replace(tzinfo=timezone.utc),
|
||||
reverse=True,
|
||||
)
|
||||
return matches[:limit]
|
||||
|
||||
|
||||
def preview_route(
|
||||
session: Session,
|
||||
config: dict[str, Any],
|
||||
since: datetime,
|
||||
) -> dict[str, Any]:
|
||||
"""Preview matching for an unsaved route config.
|
||||
|
||||
*config* keys: ``node_ids``, ``match_width``, ``observer_ids``,
|
||||
``max_hop_span``, ``packet_count_threshold``, ``degraded_threshold``.
|
||||
"""
|
||||
node_ids: list[str] = config.get("node_ids") or []
|
||||
match_width: int = config.get("match_width") or 1
|
||||
observer_ids: Optional[list[str]] = config.get("observer_ids") or None
|
||||
max_hop_span: Optional[int] = config.get("max_hop_span")
|
||||
threshold: int = config.get("packet_count_threshold") or 3
|
||||
degraded: Optional[int] = config.get("degraded_threshold")
|
||||
|
||||
if len(node_ids) < 2:
|
||||
return {
|
||||
"matched_count": 0,
|
||||
"quality": RouteQuality.UNKNOWN.value,
|
||||
"state": RouteState.NO_COVERAGE.value,
|
||||
"contributing_observers": {},
|
||||
"collisions": {},
|
||||
"truncated": False,
|
||||
}
|
||||
|
||||
nodes = session.execute(select(Node).where(Node.id.in_(node_ids))).scalars().all()
|
||||
node_map = {n.id: n for n in nodes}
|
||||
|
||||
expected = [
|
||||
derive_expected_hash(node_map[nid].public_key, match_width)
|
||||
for nid in node_ids
|
||||
if nid in node_map and node_map[nid].public_key
|
||||
]
|
||||
if len(expected) < 2:
|
||||
return {
|
||||
"matched_count": 0,
|
||||
"quality": RouteQuality.UNKNOWN.value,
|
||||
"state": RouteState.NO_COVERAGE.value,
|
||||
"contributing_observers": {},
|
||||
"collisions": {},
|
||||
"truncated": False,
|
||||
}
|
||||
|
||||
first_prefix = expected[0]
|
||||
|
||||
candidate_count = _count_candidate_receptions(
|
||||
session, first_prefix, since, observer_ids
|
||||
)
|
||||
if candidate_count > PREVIEW_CANDIDATE_CAP:
|
||||
return {
|
||||
"matched_count": None,
|
||||
"quality": None,
|
||||
"truncated": True,
|
||||
"candidate_count": candidate_count,
|
||||
}
|
||||
|
||||
paths = fetch_candidate_paths(session, first_prefix, since, observer_ids)
|
||||
|
||||
eff_degraded = degraded or (threshold * DEGRADED_DEFAULT_MULTIPLIER)
|
||||
|
||||
matched_packets: set[str] = set()
|
||||
contributing: dict[str, int] = {}
|
||||
|
||||
for hops in paths.values():
|
||||
if is_subsequence(hops, expected, max_hop_span):
|
||||
ph = hops[0]["packet_hash"]
|
||||
if ph:
|
||||
matched_packets.add(ph)
|
||||
obs = hops[0]["observer_node_id"]
|
||||
if obs:
|
||||
contributing[obs] = contributing.get(obs, 0) + 1
|
||||
|
||||
matched_count = len(matched_packets)
|
||||
|
||||
if matched_count >= threshold:
|
||||
state = RouteState.HEALTHY.value
|
||||
else:
|
||||
exists = _has_any_hops_in_window(session, since, observer_ids)
|
||||
state = RouteState.UNHEALTHY.value if exists else RouteState.NO_COVERAGE.value
|
||||
|
||||
quality = derive_quality(state, matched_count, threshold, eff_degraded)
|
||||
|
||||
collisions_map = prefix_collision_counts(session, match_width)
|
||||
node_collisions = {
|
||||
nid: collisions_map.get(
|
||||
derive_expected_hash(node_map[nid].public_key, match_width), 1
|
||||
)
|
||||
for nid in node_ids
|
||||
if nid in node_map
|
||||
}
|
||||
|
||||
return {
|
||||
"matched_count": matched_count,
|
||||
"quality": quality,
|
||||
"state": state,
|
||||
"contributing_observers": contributing,
|
||||
"collisions": node_collisions,
|
||||
"truncated": False,
|
||||
}
|
||||
@@ -112,6 +112,8 @@ class Subscriber(LetsMeshNormalizer):
|
||||
self._channel_refresh_thread: Optional[threading.Thread] = None
|
||||
# Background spam re-scoring sweep
|
||||
self._spam_rescore_thread: Optional[threading.Thread] = None
|
||||
# Background route health evaluator
|
||||
self._route_evaluator_thread: Optional[threading.Thread] = None
|
||||
# Load initial channel keys from database
|
||||
self._include_test_channel = self._load_channel_keys_from_db()
|
||||
self._letsmesh_decoder = LetsMeshPacketDecoder(
|
||||
@@ -596,6 +598,50 @@ class Subscriber(LetsMeshNormalizer):
|
||||
if self._spam_rescore_thread.is_alive():
|
||||
logger.warning("Spam re-scoring thread did not stop cleanly")
|
||||
|
||||
def _start_route_evaluator_scheduler(self) -> None:
|
||||
"""Start background thread that evaluates route health.
|
||||
|
||||
Disabled when the interval is 0. Follows the same loop template as the
|
||||
spam re-scoring sweep and uses synchronous sessions.
|
||||
"""
|
||||
from meshcore_hub.common.config import CollectorSettings
|
||||
|
||||
interval = CollectorSettings().route_evaluator_interval_seconds
|
||||
if interval <= 0:
|
||||
logger.info("Route evaluator disabled (interval=%ds)", interval)
|
||||
return
|
||||
|
||||
logger.info("Starting route evaluator (interval=%ds)", interval)
|
||||
|
||||
def run_evaluator_loop() -> None:
|
||||
"""Periodically evaluate all enabled routes."""
|
||||
from meshcore_hub.collector.route_evaluator import run_evaluation
|
||||
|
||||
while self._running:
|
||||
for _ in range(interval):
|
||||
if not self._running:
|
||||
break
|
||||
time.sleep(1)
|
||||
if self._running:
|
||||
try:
|
||||
updated = run_evaluation(self.db)
|
||||
if updated:
|
||||
logger.info("Route evaluator updated %d routes", updated)
|
||||
except Exception as e:
|
||||
logger.error("Route evaluator error: %s", e, exc_info=True)
|
||||
|
||||
self._route_evaluator_thread = threading.Thread(
|
||||
target=run_evaluator_loop, daemon=True, name="route-evaluator"
|
||||
)
|
||||
self._route_evaluator_thread.start()
|
||||
|
||||
def _stop_route_evaluator_scheduler(self) -> None:
|
||||
"""Stop the route evaluator thread."""
|
||||
if self._route_evaluator_thread and self._route_evaluator_thread.is_alive():
|
||||
self._route_evaluator_thread.join(timeout=5.0)
|
||||
if self._route_evaluator_thread.is_alive():
|
||||
logger.warning("Route evaluator thread did not stop cleanly")
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the subscriber."""
|
||||
logger.info("Starting collector subscriber")
|
||||
@@ -666,6 +712,9 @@ class Subscriber(LetsMeshNormalizer):
|
||||
# Start background spam re-scoring sweep (no-op when disabled)
|
||||
self._start_spam_rescore_scheduler()
|
||||
|
||||
# Start route health evaluator (no-op when disabled)
|
||||
self._start_route_evaluator_scheduler()
|
||||
|
||||
# Start health reporter for Docker health checks
|
||||
self._health_reporter = HealthReporter(
|
||||
component="collector",
|
||||
@@ -707,6 +756,9 @@ class Subscriber(LetsMeshNormalizer):
|
||||
# Stop spam re-scoring sweep
|
||||
self._stop_spam_rescore_scheduler()
|
||||
|
||||
# Stop route evaluator
|
||||
self._stop_route_evaluator_scheduler()
|
||||
|
||||
# Stop webhook processor
|
||||
self._stop_webhook_processor()
|
||||
|
||||
|
||||
@@ -325,6 +325,11 @@ class CollectorSettings(CommonSettings):
|
||||
),
|
||||
ge=0,
|
||||
)
|
||||
route_evaluator_interval_seconds: int = Field(
|
||||
default=60,
|
||||
description="Route evaluator interval in seconds (0 disables, default 60)",
|
||||
ge=0,
|
||||
)
|
||||
|
||||
@property
|
||||
def effective_raw_packet_retention_days(self) -> int:
|
||||
@@ -371,6 +376,13 @@ class CollectorSettings(CommonSettings):
|
||||
|
||||
return str(Path(self.effective_seed_home) / "channels.yaml")
|
||||
|
||||
@property
|
||||
def routes_file(self) -> str:
|
||||
"""Get the path to routes.yaml in seed_home."""
|
||||
from pathlib import Path
|
||||
|
||||
return str(Path(self.effective_seed_home) / "routes.yaml")
|
||||
|
||||
|
||||
class APISettings(CommonSettings):
|
||||
"""Settings for the API component."""
|
||||
@@ -600,6 +612,9 @@ class WebSettings(CommonSettings):
|
||||
"SPAM_DETECTION_ENABLED switch"
|
||||
),
|
||||
)
|
||||
feature_routes: bool = Field(
|
||||
default=True, description="Enable the /routes page (route health monitoring)"
|
||||
)
|
||||
|
||||
# Content directory (contains pages/ and media/ subdirectories)
|
||||
content_home: Optional[str] = Field(
|
||||
@@ -631,6 +646,7 @@ class WebSettings(CommonSettings):
|
||||
"pages": self.feature_pages,
|
||||
"radio_config": self.feature_radio_config,
|
||||
"spam": self.feature_spam_detection,
|
||||
"routes": self.feature_routes,
|
||||
}
|
||||
|
||||
@property
|
||||
|
||||
@@ -13,6 +13,15 @@ from meshcore_hub.common.models.user_profile import UserProfile
|
||||
from meshcore_hub.common.models.user_profile_node import UserProfileNode
|
||||
from meshcore_hub.common.models.event_observer import EventObserver, add_event_observer
|
||||
from meshcore_hub.common.models.channel import Channel, ChannelVisibility
|
||||
from meshcore_hub.common.models.packet_path_hop import PacketPathHop
|
||||
from meshcore_hub.common.models.route import Route, RouteVisibility
|
||||
from meshcore_hub.common.models.route_node import RouteNode
|
||||
from meshcore_hub.common.models.route_observer import RouteObserver
|
||||
from meshcore_hub.common.models.route_result import (
|
||||
RouteResult,
|
||||
RouteQuality,
|
||||
RouteState,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
@@ -31,4 +40,12 @@ __all__ = [
|
||||
"add_event_observer",
|
||||
"Channel",
|
||||
"ChannelVisibility",
|
||||
"PacketPathHop",
|
||||
"Route",
|
||||
"RouteVisibility",
|
||||
"RouteNode",
|
||||
"RouteObserver",
|
||||
"RouteResult",
|
||||
"RouteQuality",
|
||||
"RouteState",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""PacketPathHop model — denormalized hop index for route matching."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class PacketPathHop(Base, UUIDMixin, TimestampMixin):
|
||||
"""One row per ``(reception, hop position)`` in the packet path index.
|
||||
|
||||
Populated at ingest inside ``store_raw_packet`` from the already-computed
|
||||
normalized ``path_hashes``. The denormalized ``packet_hash``,
|
||||
``received_at``, and ``observer_node_id`` columns let route queries filter
|
||||
by time window, observer scope, and distinct-packet count without a join
|
||||
back to ``raw_packets``.
|
||||
|
||||
Attributes:
|
||||
id: UUID primary key
|
||||
raw_packet_id: FK to raw_packets (cascades on delete)
|
||||
position: Zero-based hop position in the ordered path
|
||||
node_hash: Normalized (uppercase) hex prefix for this hop
|
||||
packet_hash: Denormalized packet hash from raw_packets
|
||||
received_at: Denormalized reception timestamp from raw_packets
|
||||
observer_node_id: Denormalized observer node FK
|
||||
"""
|
||||
|
||||
__tablename__ = "packet_path_hops"
|
||||
|
||||
raw_packet_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("raw_packets.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
position: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
)
|
||||
node_hash: Mapped[str] = mapped_column(
|
||||
String(6),
|
||||
nullable=False,
|
||||
)
|
||||
packet_hash: Mapped[Optional[str]] = mapped_column(
|
||||
String(32),
|
||||
nullable=True,
|
||||
)
|
||||
received_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
)
|
||||
observer_node_id: Mapped[Optional[str]] = mapped_column(
|
||||
ForeignKey("nodes.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_packet_path_hops_node_hash_received_at",
|
||||
"node_hash",
|
||||
"received_at",
|
||||
),
|
||||
Index(
|
||||
"ix_packet_path_hops_raw_packet_id_position",
|
||||
"raw_packet_id",
|
||||
"position",
|
||||
),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<PacketPathHop(raw_packet_id={self.raw_packet_id[:8]}..., "
|
||||
f"position={self.position}, node_hash={self.node_hash})>"
|
||||
)
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Route model for mesh link health monitoring."""
|
||||
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import Boolean, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from meshcore_hub.common.models.route_node import RouteNode
|
||||
from meshcore_hub.common.models.route_observer import RouteObserver
|
||||
from meshcore_hub.common.models.route_result import RouteResult
|
||||
|
||||
|
||||
class RouteVisibility(str, Enum):
|
||||
"""Route visibility/permission levels (mirrors ChannelVisibility)."""
|
||||
|
||||
COMMUNITY = "community"
|
||||
MEMBER = "member"
|
||||
OPERATOR = "operator"
|
||||
ADMIN = "admin"
|
||||
|
||||
|
||||
class Route(Base, UUIDMixin, TimestampMixin):
|
||||
"""A monitored multi-hop route across mesh nodes.
|
||||
|
||||
A route is **healthy** when enough distinct packets traverse the configured
|
||||
ordered node sequence within the time window. See the plan for full
|
||||
semantics.
|
||||
|
||||
Attributes:
|
||||
id: UUID primary key
|
||||
name: Unique route display name
|
||||
description: Optional longer description
|
||||
visibility: Role-based visibility level
|
||||
match_width: Path-hash prefix width in bytes (1/2/3)
|
||||
window_hours: Evaluation lookback window in hours
|
||||
packet_count_threshold: Minimum distinct packets for healthy
|
||||
degraded_threshold: Comfort bar for the clear/marginal split (null = 2x threshold)
|
||||
max_hop_span: Max hops between first and last configured node (null = unlimited)
|
||||
enabled: Whether this route is actively evaluated
|
||||
"""
|
||||
|
||||
__tablename__ = "routes"
|
||||
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(255), unique=True, nullable=False, index=True
|
||||
)
|
||||
description: Mapped[Optional[str]] = mapped_column(
|
||||
Text,
|
||||
nullable=True,
|
||||
)
|
||||
visibility: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default=RouteVisibility.COMMUNITY.value,
|
||||
nullable=False,
|
||||
)
|
||||
match_width: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=1,
|
||||
nullable=False,
|
||||
)
|
||||
window_hours: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=24,
|
||||
nullable=False,
|
||||
)
|
||||
packet_count_threshold: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=3,
|
||||
nullable=False,
|
||||
)
|
||||
degraded_threshold: Mapped[Optional[int]] = mapped_column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
)
|
||||
max_hop_span: Mapped[Optional[int]] = mapped_column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
)
|
||||
enabled: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=True,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
route_nodes: Mapped[list["RouteNode"]] = relationship(
|
||||
"RouteNode",
|
||||
back_populates="route",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
order_by="RouteNode.position",
|
||||
)
|
||||
route_observers: Mapped[list["RouteObserver"]] = relationship(
|
||||
"RouteObserver",
|
||||
back_populates="route",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
route_result: Mapped[Optional["RouteResult"]] = relationship(
|
||||
"RouteResult",
|
||||
back_populates="route",
|
||||
cascade="all, delete-orphan",
|
||||
uselist=False,
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Route(name={self.name}, enabled={self.enabled})>"
|
||||
@@ -0,0 +1,64 @@
|
||||
"""RouteNode model — ordered path-node entry within a Route."""
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from meshcore_hub.common.models.node import Node
|
||||
from meshcore_hub.common.models.route import Route
|
||||
|
||||
|
||||
class RouteNode(Base, UUIDMixin, TimestampMixin):
|
||||
"""An ordered node in a route's configured path.
|
||||
|
||||
``expected_hash`` is derived at save time as
|
||||
``public_key[:2*match_width].upper()`` and must match the normalized
|
||||
(uppercase) ``node_hash`` column on ``packet_path_hops``.
|
||||
|
||||
Attributes:
|
||||
id: UUID primary key
|
||||
route_id: FK to routes (cascades on delete)
|
||||
node_id: FK to nodes
|
||||
position: Zero-based order in the configured path
|
||||
expected_hash: Uppercased public-key prefix used for matching
|
||||
"""
|
||||
|
||||
__tablename__ = "route_nodes"
|
||||
|
||||
route_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("routes.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
node_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("nodes.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
position: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
)
|
||||
expected_hash: Mapped[Optional[str]] = mapped_column(
|
||||
String(6),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
route: Mapped["Route"] = relationship(
|
||||
"Route",
|
||||
back_populates="route_nodes",
|
||||
)
|
||||
node: Mapped["Node"] = relationship(
|
||||
"Node",
|
||||
foreign_keys=[node_id],
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<RouteNode(route_id={self.route_id[:8]}..., "
|
||||
f"position={self.position}, expected_hash={self.expected_hash})>"
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""RouteObserver model — observer scope entry within a Route."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from meshcore_hub.common.models.node import Node
|
||||
from meshcore_hub.common.models.route import Route
|
||||
|
||||
|
||||
class RouteObserver(Base, UUIDMixin, TimestampMixin):
|
||||
"""An observer node in a route's optional observer scope.
|
||||
|
||||
When a route has observer entries, only receptions by those observers are
|
||||
considered during evaluation. When the scope is empty, all observers are
|
||||
considered.
|
||||
|
||||
Attributes:
|
||||
id: UUID primary key
|
||||
route_id: FK to routes (cascades on delete)
|
||||
node_id: FK to nodes
|
||||
"""
|
||||
|
||||
__tablename__ = "route_observers"
|
||||
|
||||
route_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("routes.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
node_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("nodes.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
route: Mapped["Route"] = relationship(
|
||||
"Route",
|
||||
back_populates="route_observers",
|
||||
)
|
||||
node: Mapped["Node"] = relationship(
|
||||
"Node",
|
||||
foreign_keys=[node_id],
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<RouteObserver(route_id={self.route_id[:8]}..., node_id={self.node_id[:8]}...)>"
|
||||
@@ -0,0 +1,95 @@
|
||||
"""RouteResult model — cached evaluation result for a Route."""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin, utc_now
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from meshcore_hub.common.models.route import Route
|
||||
|
||||
|
||||
class RouteState(str, Enum):
|
||||
"""Alerting axis of route health (see F4)."""
|
||||
|
||||
HEALTHY = "healthy"
|
||||
UNHEALTHY = "unhealthy"
|
||||
NO_COVERAGE = "no_coverage"
|
||||
|
||||
|
||||
class RouteQuality(str, Enum):
|
||||
"""Display axis of route health (traffic-light band, see F4)."""
|
||||
|
||||
CLEAR = "clear"
|
||||
MARGINAL = "marginal"
|
||||
FAILING = "failing"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class RouteResult(Base, UUIDMixin, TimestampMixin):
|
||||
"""The latest cached evaluation result for a route (one row per route).
|
||||
|
||||
Written by the background evaluator and read by the API/UI. ``threshold``
|
||||
and ``effective_degraded`` are snapshotted at evaluation time so the display
|
||||
stays self-consistent if thresholds are later changed.
|
||||
|
||||
Attributes:
|
||||
id: UUID primary key
|
||||
route_id: FK to routes (unique, cascades on delete)
|
||||
state: Alerting axis (healthy / unhealthy / no_coverage)
|
||||
quality: Display axis (clear / marginal / failing / unknown)
|
||||
matched_count: Distinct packet count (lower bound when short-circuited)
|
||||
threshold: Snapshot of route.packet_count_threshold at eval time
|
||||
effective_degraded: Snapshot of effective_degraded_threshold at eval time
|
||||
evaluated_at: When this evaluation ran
|
||||
"""
|
||||
|
||||
__tablename__ = "route_results"
|
||||
|
||||
route_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("routes.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
state: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
)
|
||||
quality: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
)
|
||||
matched_count: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
)
|
||||
threshold: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
)
|
||||
effective_degraded: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
)
|
||||
evaluated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utc_now,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
route: Mapped["Route"] = relationship(
|
||||
"Route",
|
||||
back_populates="route_result",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<RouteResult(route_id={self.route_id[:8]}..., "
|
||||
f"state={self.state}, quality={self.quality}, "
|
||||
f"matched={self.matched_count})>"
|
||||
)
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Pydantic schemas for route API endpoints."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class RouteNodeRead(BaseModel):
|
||||
"""A path-node entry in a route."""
|
||||
|
||||
node_id: str
|
||||
position: int
|
||||
expected_hash: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
public_key: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class RouteObserverRead(BaseModel):
|
||||
"""An observer entry in a route."""
|
||||
|
||||
node_id: str
|
||||
name: Optional[str] = None
|
||||
public_key: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class RouteResultSummary(BaseModel):
|
||||
"""Lightweight evaluation result embedded in list responses."""
|
||||
|
||||
state: Optional[str] = None
|
||||
quality: Optional[str] = None
|
||||
matched_count: Optional[int] = None
|
||||
threshold: Optional[int] = None
|
||||
effective_degraded: Optional[int] = None
|
||||
evaluated_at: Optional[datetime] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class RouteCreate(BaseModel):
|
||||
"""Schema for creating a route."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=255, description="Route name")
|
||||
description: Optional[str] = Field(default=None, description="Route description")
|
||||
visibility: Literal["community", "member", "operator", "admin"] = Field(
|
||||
default="community", description="Visibility level"
|
||||
)
|
||||
match_width: int = Field(
|
||||
default=1, ge=1, le=3, description="Hash prefix width (1-3 bytes)"
|
||||
)
|
||||
window_hours: int = Field(
|
||||
default=24, ge=1, le=720, description="Evaluation window in hours"
|
||||
)
|
||||
packet_count_threshold: int = Field(
|
||||
default=3, ge=1, le=10000, description="Minimum distinct packets for healthy"
|
||||
)
|
||||
degraded_threshold: Optional[int] = Field(
|
||||
default=None, description="Comfort bar (null = 2x threshold)"
|
||||
)
|
||||
max_hop_span: Optional[int] = Field(
|
||||
default=None, description="Max hop distance between first and last node"
|
||||
)
|
||||
enabled: bool = Field(default=True, description="Whether this route is evaluated")
|
||||
node_public_keys: list[str] = Field(
|
||||
..., description="Ordered path node public keys (64-char hex, >= 2, distinct)"
|
||||
)
|
||||
observer_public_keys: Optional[list[str]] = Field(
|
||||
default=None,
|
||||
description="Observer node public keys (empty/None = all observers)",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_route(self) -> "RouteCreate":
|
||||
if len(self.node_public_keys) < 2:
|
||||
raise ValueError("At least 2 path nodes are required")
|
||||
if len({k.lower() for k in self.node_public_keys}) < len(self.node_public_keys):
|
||||
raise ValueError("Path nodes must be distinct")
|
||||
if (
|
||||
self.degraded_threshold is not None
|
||||
and self.degraded_threshold <= self.packet_count_threshold
|
||||
):
|
||||
raise ValueError("degraded_threshold must be > packet_count_threshold")
|
||||
return self
|
||||
|
||||
|
||||
class RouteUpdate(BaseModel):
|
||||
"""Schema for updating a route."""
|
||||
|
||||
name: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
visibility: Optional[Literal["community", "member", "operator", "admin"]] = None
|
||||
match_width: Optional[int] = Field(default=None, ge=1, le=3)
|
||||
window_hours: Optional[int] = Field(default=None, ge=1, le=720)
|
||||
packet_count_threshold: Optional[int] = Field(default=None, ge=1, le=10000)
|
||||
degraded_threshold: Optional[int] = None
|
||||
max_hop_span: Optional[int] = None
|
||||
enabled: Optional[bool] = None
|
||||
node_public_keys: Optional[list[str]] = None
|
||||
observer_public_keys: Optional[list[str]] = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_route(self) -> "RouteUpdate":
|
||||
if self.node_public_keys is not None:
|
||||
if len(self.node_public_keys) < 2:
|
||||
raise ValueError("At least 2 path nodes are required")
|
||||
if len({k.lower() for k in self.node_public_keys}) < len(
|
||||
self.node_public_keys
|
||||
):
|
||||
raise ValueError("Path nodes must be distinct")
|
||||
if (
|
||||
self.degraded_threshold is not None
|
||||
and self.packet_count_threshold is not None
|
||||
and self.degraded_threshold <= self.packet_count_threshold
|
||||
):
|
||||
raise ValueError("degraded_threshold must be > packet_count_threshold")
|
||||
return self
|
||||
|
||||
|
||||
class RouteRead(BaseModel):
|
||||
"""Schema for reading a route (list-level with lightweight result)."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
visibility: str
|
||||
match_width: int
|
||||
window_hours: int
|
||||
packet_count_threshold: int
|
||||
degraded_threshold: Optional[int] = None
|
||||
max_hop_span: Optional[int] = None
|
||||
enabled: bool
|
||||
route_nodes: list[RouteNodeRead] = []
|
||||
route_observers: list[RouteObserverRead] = []
|
||||
route_result: Optional[RouteResultSummary] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class RouteList(BaseModel):
|
||||
"""Paginated route list response."""
|
||||
|
||||
items: list[RouteRead]
|
||||
total: int
|
||||
|
||||
|
||||
class RecentMatchPath(BaseModel):
|
||||
"""A recent matched path for card expand."""
|
||||
|
||||
packet_hash: Optional[str] = None
|
||||
hops: list[dict[str, Any]] = []
|
||||
received_at: Optional[datetime] = None
|
||||
observer_node_id: Optional[str] = None
|
||||
|
||||
|
||||
class ContributingObserver(BaseModel):
|
||||
"""An observer that contributed matching receptions."""
|
||||
|
||||
node_id: str
|
||||
name: Optional[str] = None
|
||||
match_count: int = 0
|
||||
|
||||
|
||||
class RouteDetail(BaseModel):
|
||||
"""Full detail for GET /api/v1/routes/{id}."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
visibility: str
|
||||
match_width: int
|
||||
window_hours: int
|
||||
packet_count_threshold: int
|
||||
degraded_threshold: Optional[int] = None
|
||||
max_hop_span: Optional[int] = None
|
||||
enabled: bool
|
||||
route_nodes: list[RouteNodeRead] = []
|
||||
route_observers: list[RouteObserverRead] = []
|
||||
route_result: Optional[RouteResultSummary] = None
|
||||
contributing_observers: list[ContributingObserver] = []
|
||||
recent_matches: list[RecentMatchPath] = []
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class RoutePreviewRequest(BaseModel):
|
||||
"""Schema for the preview endpoint."""
|
||||
|
||||
node_public_keys: list[str] = Field(
|
||||
..., description="Ordered path node public keys"
|
||||
)
|
||||
match_width: int = Field(default=1, ge=1, le=3)
|
||||
window_hours: int = Field(default=24, ge=1, le=720)
|
||||
packet_count_threshold: int = Field(default=3, ge=1, le=10000)
|
||||
degraded_threshold: Optional[int] = None
|
||||
max_hop_span: Optional[int] = None
|
||||
observer_public_keys: Optional[list[str]] = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_preview(self) -> "RoutePreviewRequest":
|
||||
if len(self.node_public_keys) < 2:
|
||||
raise ValueError("At least 2 path nodes are required")
|
||||
if len({k.lower() for k in self.node_public_keys}) < len(self.node_public_keys):
|
||||
raise ValueError("Path nodes must be distinct")
|
||||
return self
|
||||
|
||||
|
||||
class RoutePreviewResponse(BaseModel):
|
||||
"""Schema for preview results."""
|
||||
|
||||
matched_count: Optional[int] = None
|
||||
quality: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
contributing_observers: dict[str, int] = {}
|
||||
collisions: dict[str, int] = {}
|
||||
truncated: bool = False
|
||||
candidate_count: Optional[int] = None
|
||||
@@ -162,6 +162,16 @@ def _build_endpoint_access(
|
||||
"PUT": frozenset({role_admin}),
|
||||
"DELETE": frozenset({role_admin}),
|
||||
},
|
||||
"v1/routes": {
|
||||
"GET": _OPEN,
|
||||
"POST": frozenset({role_admin}),
|
||||
},
|
||||
"v1/routes/": {
|
||||
"GET": _OPEN,
|
||||
"PUT": frozenset({role_admin}),
|
||||
"DELETE": frozenset({role_admin}),
|
||||
"POST": _OPEN,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
--color-adverts: oklch(0.7 0.17 330); /* magenta */
|
||||
--color-messages: oklch(0.75 0.18 180); /* teal */
|
||||
--color-channels: oklch(0.72 0.15 300); /* purple */
|
||||
--color-routes: oklch(0.72 0.17 30); /* orange-red */
|
||||
--color-packets: oklch(0.72 0.17 145); /* green */
|
||||
--color-map: oklch(0.8471 0.199 83.87); /* yellow (matches btn-warning) */
|
||||
--color-members: oklch(0.72 0.17 50); /* orange */
|
||||
@@ -48,6 +49,7 @@
|
||||
--color-adverts: oklch(0.55 0.17 330);
|
||||
--color-messages: oklch(0.55 0.18 180);
|
||||
--color-channels: oklch(0.55 0.15 300);
|
||||
--color-routes: oklch(0.55 0.17 30);
|
||||
--color-packets: oklch(0.52 0.17 145);
|
||||
--color-map: oklch(0.58 0.16 45);
|
||||
--color-members: oklch(0.55 0.18 25);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Router } from './router.js';
|
||||
import { isAbortError } from './api.js';
|
||||
import { html, litRender, getConfig, hasRole, renderAuthSection } from './components.js';
|
||||
import { loadLocale, t } from './i18n.js';
|
||||
import { iconHome, iconDashboard, iconNodes, iconAdvertisements, iconMessages, iconPackets, iconMap, iconMembers, iconPage, iconChannel } from './icons.js';
|
||||
import { iconHome, iconDashboard, iconNodes, iconAdvertisements, iconMessages, iconPackets, iconMap, iconMembers, iconPage, iconChannel, iconPath } from './icons.js';
|
||||
|
||||
// Page modules (lazy-loaded)
|
||||
const pages = {
|
||||
@@ -25,6 +25,7 @@ const pages = {
|
||||
map: () => import('./pages/map.js'),
|
||||
members: () => import('./pages/members.js'),
|
||||
channels: () => import('./pages/channels.js'),
|
||||
routes: () => import('./pages/routes.js'),
|
||||
customPage: () => import('./pages/custom-page.js'),
|
||||
notFound: () => import('./pages/not-found.js'),
|
||||
profile: () => import('./pages/profile.js'),
|
||||
@@ -90,6 +91,9 @@ if (features.nodes !== false) {
|
||||
if (features.channels !== false) {
|
||||
router.addRoute('/channels', pageHandler(pages.channels));
|
||||
}
|
||||
if (features.routes !== false) {
|
||||
router.addRoute('/routes', pageHandler(pages.routes));
|
||||
}
|
||||
if (features.messages !== false) {
|
||||
router.addRoute('/messages', pageHandler(pages.messages));
|
||||
}
|
||||
@@ -176,6 +180,7 @@ function updatePageTitle(pathname) {
|
||||
if (features.dashboard !== false) titles['/dashboard'] = composePageTitle('entities.dashboard');
|
||||
if (features.nodes !== false) titles['/nodes'] = composePageTitle('entities.nodes');
|
||||
if (features.channels !== false) titles['/channels'] = composePageTitle('entities.channels');
|
||||
if (features.routes !== false) titles['/routes'] = composePageTitle('entities.routes');
|
||||
if (features.messages !== false) titles['/messages'] = composePageTitle('entities.messages');
|
||||
if (features.advertisements !== false) titles['/advertisements'] = composePageTitle('entities.advertisements');
|
||||
if (features.packets !== false) titles['/packets'] = composePageTitle('entities.packets');
|
||||
@@ -229,6 +234,9 @@ function renderMobileNav(config) {
|
||||
if (features.channels !== false) {
|
||||
items.push(html`<li><a href="/channels" data-nav-link>${iconChannel('h-5 w-5')} ${t('entities.channels')}</a></li>`);
|
||||
}
|
||||
if (features.routes !== false) {
|
||||
items.push(html`<li><a href="/routes" data-nav-link>${iconPath('h-5 w-5')} ${t('entities.routes')}</a></li>`);
|
||||
}
|
||||
if (features.messages !== false) {
|
||||
items.push(html`<li><a href="/messages" data-nav-link>${iconMessages('h-5 w-5 nav-icon-messages')} ${t('entities.messages')}</a></li>`);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from '../components.js';
|
||||
import {
|
||||
iconDashboard, iconNodes, iconAdvertisements, iconMessages, iconPackets, iconMembers, iconMap,
|
||||
iconPage, iconInfo, iconChart, iconAntenna, iconUsers, iconChannel,
|
||||
iconPage, iconInfo, iconChart, iconAntenna, iconUsers, iconChannel, iconPath,
|
||||
iconSettings, iconFrequency, iconBandwidth, iconSpreadingFactor, iconCodingRate, iconTxPower,
|
||||
} from '../icons.js';
|
||||
|
||||
@@ -102,6 +102,12 @@ function renderHeroSection({ networkName, logoUrl, logoInvertLight, networkCity,
|
||||
label: t('entities.channels'),
|
||||
colorVar: '--color-channels',
|
||||
}) : nothing}
|
||||
${features.routes !== false ? renderNavCard({
|
||||
href: '/routes',
|
||||
icon: iconPath('w-full h-full'),
|
||||
label: t('entities.routes'),
|
||||
colorVar: '--color-routes',
|
||||
}) : nothing}
|
||||
${features.messages !== false ? renderNavCard({
|
||||
href: '/messages',
|
||||
icon: iconMessages('w-full h-full'),
|
||||
|
||||
@@ -0,0 +1,712 @@
|
||||
import { apiGet, apiPost, apiPut, apiDelete, isAbortError } from '../api.js';
|
||||
import { html, litRender, nothing, t, errorAlert, getConfig, hasRole } from '../components.js';
|
||||
import { iconPath, iconPlus, iconEdit, iconTrash, iconChevronRight } from '../icons.js';
|
||||
|
||||
const VISIBILITY_ORDER = ['community', 'member', 'operator', 'admin'];
|
||||
const QUALITY_PRIORITY = { failing: 0, no_coverage: 1, marginal: 2, clear: 3, disabled: 4 };
|
||||
|
||||
let _pathSearchTimer = null;
|
||||
let _pathSearchId = 0;
|
||||
let _obsSearchTimer = null;
|
||||
let _obsSearchId = 0;
|
||||
|
||||
function qualityBadgeClass(quality, enabled) {
|
||||
if (!enabled) return 'badge-neutral';
|
||||
const map = {
|
||||
clear: 'badge-success',
|
||||
marginal: 'badge-warning',
|
||||
failing: 'badge-error',
|
||||
no_coverage: 'badge-info',
|
||||
unknown: 'badge-info',
|
||||
};
|
||||
return map[quality] || 'badge-ghost';
|
||||
}
|
||||
|
||||
function qualityLabel(quality, enabled) {
|
||||
if (!enabled) return t('routes.disabled');
|
||||
const map = {
|
||||
clear: t('routes.quality_clear'),
|
||||
marginal: t('routes.quality_marginal'),
|
||||
failing: t('routes.quality_failing'),
|
||||
no_coverage: t('routes.quality_no_coverage'),
|
||||
unknown: t('routes.quality_unknown'),
|
||||
};
|
||||
return map[quality] || quality || t('routes.quality_unknown');
|
||||
}
|
||||
|
||||
function qualityDot(quality, enabled) {
|
||||
if (!enabled) return '\u25CC';
|
||||
const dots = { clear: '\u25CF', marginal: '\u25CF', failing: '\u25CF', no_coverage: '\u25D0', unknown: '\u25D0' };
|
||||
return dots[quality] || '\u25D0';
|
||||
}
|
||||
|
||||
function renderSummaryStrip(routes) {
|
||||
const counts = { clear: 0, marginal: 0, failing: 0, no_coverage: 0, disabled: 0 };
|
||||
for (const r of routes) {
|
||||
if (!r.enabled) { counts.disabled++; continue; }
|
||||
const q = r.route_result?.quality || 'unknown';
|
||||
if (q === 'clear') counts.clear++;
|
||||
else if (q === 'marginal') counts.marginal++;
|
||||
else if (q === 'failing') counts.failing++;
|
||||
else counts.no_coverage++;
|
||||
}
|
||||
return html`<div class="flex flex-wrap gap-4 mb-6 text-sm">
|
||||
<span class="flex items-center gap-1"><span class="text-success">\u25CF</span> ${counts.clear} ${t('routes.quality_clear')}</span>
|
||||
<span class="flex items-center gap-1"><span class="text-warning">\u25CF</span> ${counts.marginal} ${t('routes.quality_marginal')}</span>
|
||||
<span class="flex items-center gap-1"><span class="text-error">\u25CF</span> ${counts.failing} ${t('routes.quality_failing')}</span>
|
||||
<span class="flex items-center gap-1"><span class="text-info">\u25D0</span> ${counts.no_coverage} ${t('routes.quality_no_coverage')}</span>
|
||||
<span class="flex items-center gap-1 opacity-50">\u25CC ${counts.disabled} ${t('routes.disabled')}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderPathChips(route) {
|
||||
const nodes = route.route_nodes || [];
|
||||
return html`<div class="flex flex-wrap items-center gap-1 text-sm">
|
||||
${nodes.map((rn, i) => html`
|
||||
${i > 0 ? html`<span class="opacity-50">\u2192</span>` : nothing}
|
||||
<span class="badge badge-ghost badge-sm">${rn.name || rn.public_key?.slice(0, 8) || rn.node_id.slice(0, 8)}</span>
|
||||
`)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderNumbersLine(route) {
|
||||
const result = route.route_result;
|
||||
if (!result) return html`<div class="text-xs opacity-50 mt-1">${t('routes.not_evaluated')}</div>`;
|
||||
const matched = result.matched_count ?? '?';
|
||||
const threshold = result.threshold ?? '?';
|
||||
const degraded = result.effective_degraded ?? '?';
|
||||
const evalTime = result.evaluated_at
|
||||
? new Date(result.evaluated_at).toLocaleTimeString()
|
||||
: '?';
|
||||
return html`<div class="text-xs opacity-60 mt-1">
|
||||
${matched} / ${threshold} \u2192 ${degraded} \u00B7 ${route.window_hours}h \u00B7 ${evalTime}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderRouteCard(route, { isAdmin, onDelete, onEdit, onExpand, isExpanded, detail }) {
|
||||
const q = route.route_result?.quality || 'unknown';
|
||||
const badgeCls = qualityBadgeClass(q, route.enabled);
|
||||
const label = qualityLabel(q, route.enabled);
|
||||
const dot = qualityDot(q, route.enabled);
|
||||
const visBadge = html`<span class="badge badge-primary badge-sm">${route.visibility}</span>`;
|
||||
|
||||
const adminButtons = isAdmin
|
||||
? html`<div class="flex gap-2 mt-2">
|
||||
<button class="btn btn-xs btn-outline" @click=${(e) => { e.stopPropagation(); onEdit(route); }}>
|
||||
${iconEdit('h-3 w-3')} ${t('common.edit')}
|
||||
</button>
|
||||
<button class="btn btn-xs btn-outline btn-error" @click=${(e) => { e.stopPropagation(); onDelete(route); }}>
|
||||
${iconTrash('h-3 w-3')} ${t('common.delete')}
|
||||
</button>
|
||||
</div>`
|
||||
: nothing;
|
||||
|
||||
const expandContent = isExpanded && detail ? renderDetailContent(route, detail) : nothing;
|
||||
|
||||
return html`<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body cursor-pointer" role="button" tabindex="0"
|
||||
@click=${() => onExpand(route)}
|
||||
@keydown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onExpand(route); } }}>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2 class="card-title flex items-center gap-2">
|
||||
${route.name}
|
||||
${visBadge}
|
||||
</h2>
|
||||
${route.description ? html`<p class="text-sm opacity-70 mt-1">${route.description}</p>` : nothing}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<span class="badge ${badgeCls} badge-sm">${dot} ${label}</span>
|
||||
${iconChevronRight(`h-4 w-4 transition-transform ${isExpanded ? 'rotate-90' : ''}`)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">${renderPathChips(route)}</div>
|
||||
${renderNumbersLine(route)}
|
||||
${adminButtons}
|
||||
${expandContent}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderDetailContent(route, detail) {
|
||||
const result = detail.route_result || route.route_result;
|
||||
const observers = detail.contributing_observers || [];
|
||||
const matches = detail.recent_matches || [];
|
||||
|
||||
return html`<div class="mt-4 pt-4 border-t border-base-300 space-y-3 text-sm">
|
||||
${result ? html`<div class="opacity-70">
|
||||
<strong>${t('routes.diagnosis')}:</strong>
|
||||
${result.state === 'healthy' ? t('routes.diagnosis_healthy') : nothing}
|
||||
${result.state === 'unhealthy' ? t('routes.diagnosis_unhealthy') : nothing}
|
||||
${result.state === 'no_coverage' ? t('routes.diagnosis_no_coverage') : nothing}
|
||||
</div>` : nothing}
|
||||
${observers.length > 0 ? html`<div>
|
||||
<strong class="opacity-70">${t('routes.contributing_observers')}:</strong>
|
||||
${observers.map(o => html`<span class="badge badge-ghost badge-sm ml-1">${o.name || o.node_id.slice(0, 8)} (${o.match_count})</span>`)}
|
||||
</div>` : html`<div class="opacity-50">${t('routes.no_observers')}</div>`}
|
||||
${matches.length > 0 ? html`<div>
|
||||
<strong class="opacity-70">${t('routes.recent_matches')}:</strong>
|
||||
<div class="mt-1 space-y-1">
|
||||
${matches.map(m => html`<div class="font-mono text-xs opacity-60">
|
||||
${(m.hops || []).map((h, i) => html`${i > 0 ? ' \u2192 ' : nothing}${h.node_hash}`).slice(0, 10)}
|
||||
</div>`)}
|
||||
</div>
|
||||
</div>` : nothing}
|
||||
<div class="opacity-50 text-xs">
|
||||
${t('routes.width')}: ${route.match_width} \u00B7
|
||||
${t('routes.window')}: ${route.window_hours}h \u00B7
|
||||
${t('routes.threshold')}: ${route.packet_count_threshold} \u00B7
|
||||
${route.max_hop_span ? html`${t('routes.span')}: ${route.max_hop_span}` : nothing}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderNodeSearchResult(node, onSelect) {
|
||||
const name = node.name || `${node.public_key.slice(0, 12)}\u2026`;
|
||||
return html`
|
||||
<li>
|
||||
<button type="button" class="w-full text-left flex items-center gap-2" @click=${() => onSelect(node)}>
|
||||
<span class="flex-1 min-w-0">
|
||||
<span class="block text-sm font-medium truncate">${name}</span>
|
||||
<span class="block text-xs opacity-50 font-mono truncate">${node.public_key}</span>
|
||||
</span>
|
||||
${node.adv_type ? html`<span class="badge badge-ghost badge-xs">${node.adv_type}</span>` : nothing}
|
||||
</button>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
function renderRouteModal({ modalState, onSave, onCancel }) {
|
||||
const route = modalState.route;
|
||||
const isEdit = modalState.isEdit;
|
||||
const title = isEdit ? t('routes.edit_route') : t('routes.add_route');
|
||||
|
||||
const pathNodes = modalState.pathNodes;
|
||||
const observerNodes = modalState.observerNodes;
|
||||
const pathResults = modalState.pathResults;
|
||||
const obsResults = modalState.obsResults;
|
||||
|
||||
const selectedPathKeys = new Set(pathNodes.map(n => n.public_key));
|
||||
const selectedObsKeys = new Set(observerNodes.map(n => n.public_key));
|
||||
const availPathResults = pathResults.filter(n => !selectedPathKeys.has(n.public_key));
|
||||
const availObsResults = obsResults.filter(n => !selectedObsKeys.has(n.public_key));
|
||||
|
||||
return html`<dialog open class="modal modal-open">
|
||||
<div class="modal-box modal-box-lg">
|
||||
<h3 class="font-bold text-lg mb-4">${title}</h3>
|
||||
<form @submit=${(e) => { e.preventDefault(); onSave(); }}>
|
||||
<div class="grid grid-cols-1 gap-3 mb-4">
|
||||
<div>
|
||||
<label class="text-sm opacity-70">${t('routes.name_label')}</label>
|
||||
<input type="text" id="route-modal-name" class="input input-sm w-full"
|
||||
.value=${route.name || ''}
|
||||
placeholder="${t('routes.name_label')}"
|
||||
required maxlength="255" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm opacity-70">${t('routes.description_label')}</label>
|
||||
<input type="text" id="route-modal-description" class="input input-sm w-full"
|
||||
.value=${route.description || ''}
|
||||
placeholder="${t('routes.description_label')}" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="text-sm opacity-70">${t('routes.visibility_label')}</label>
|
||||
<select id="route-modal-visibility" class="select select-sm w-full">
|
||||
<option value="community" .selected=${route.visibility === 'community' || !route.visibility}>community</option>
|
||||
<option value="member" .selected=${route.visibility === 'member'}>member</option>
|
||||
<option value="operator" .selected=${route.visibility === 'operator'}>operator</option>
|
||||
<option value="admin" .selected=${route.visibility === 'admin'}>admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm opacity-70">${t('routes.width_label')}</label>
|
||||
<div class="flex gap-1 mt-1">
|
||||
${[1, 2, 3].map(w => html`
|
||||
<button type="button"
|
||||
class="btn btn-xs ${route.match_width === w || (!route.match_width && w === 1) ? 'btn-primary' : 'btn-outline'}"
|
||||
@click=${() => {
|
||||
document.querySelectorAll('[data-width-btn]').forEach(b => b.classList.remove('btn-primary'));
|
||||
document.querySelectorAll('[data-width-btn]').forEach(b => b.classList.add('btn-outline'));
|
||||
const btn = document.querySelector(`[data-width-btn="${w}"]`);
|
||||
if (btn) { btn.classList.add('btn-primary'); btn.classList.remove('btn-outline'); }
|
||||
document.getElementById('route-modal-width').value = w;
|
||||
}}
|
||||
data-width-btn="${w}">${w}B</button>
|
||||
`)}
|
||||
</div>
|
||||
<input type="hidden" id="route-modal-width" value=${route.match_width || 1} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm opacity-70">${t('routes.path_label')}</label>
|
||||
<div class="relative">
|
||||
<input type="text" id="route-modal-path-search" class="input input-sm w-full"
|
||||
placeholder="${t('routes.search_nodes_placeholder')}"
|
||||
autocomplete="off"
|
||||
@input=${(e) => modalState.handlePathSearch(e.target.value)}
|
||||
@keydown=${(e) => modalState.handlePathKeydown(e, availPathResults)} />
|
||||
${availPathResults.length > 0 ? html`
|
||||
<ul class="menu bg-base-200 rounded-box absolute z-50 left-0 right-0 top-full mt-1 p-2 shadow-lg max-h-60 overflow-auto">
|
||||
${availPathResults.map(n => renderNodeSearchResult(n, modalState.handlePathSelect))}
|
||||
</ul>
|
||||
` : nothing}
|
||||
</div>
|
||||
<p class="text-xs opacity-50 mt-1">${t('routes.path_help')}</p>
|
||||
<div class="flex flex-wrap items-center gap-1 mt-2 min-h-[2.5rem] p-2 bg-base-200 rounded-box">
|
||||
${pathNodes.length === 0
|
||||
? html`<span class="text-sm opacity-40">${t('routes.path_empty')}</span>`
|
||||
: pathNodes.map((n, i) => html`
|
||||
${i > 0 ? html`<span class="text-primary text-sm px-0.5">\u2192</span>` : nothing}
|
||||
<span class="inline-flex items-center gap-0.5 bg-primary text-primary-content rounded-full px-2 py-1 text-sm">
|
||||
${i > 0
|
||||
? html`<button type="button" class="btn btn-ghost btn-xs btn-circle text-primary-content opacity-60 hover:opacity-100"
|
||||
@click=${() => modalState.handlePathMove(i, -1)}>\u25C4</button>`
|
||||
: nothing}
|
||||
<span>${n.name || n.public_key.slice(0, 8)}</span>
|
||||
<button type="button" class="btn btn-ghost btn-xs btn-circle text-primary-content opacity-60 hover:opacity-100"
|
||||
@click=${() => modalState.handlePathRemove(i)}>\u2715</button>
|
||||
${i < pathNodes.length - 1
|
||||
? html`<button type="button" class="btn btn-ghost btn-xs btn-circle text-primary-content opacity-60 hover:opacity-100"
|
||||
@click=${() => modalState.handlePathMove(i, 1)}>\u25BA</button>`
|
||||
: nothing}
|
||||
</span>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm opacity-70">${t('routes.observers_label')}</label>
|
||||
<div class="relative">
|
||||
<input type="text" id="route-modal-obs-search" class="input input-sm w-full"
|
||||
placeholder="${t('routes.search_nodes_placeholder')}"
|
||||
autocomplete="off"
|
||||
@input=${(e) => modalState.handleObsSearch(e.target.value)}
|
||||
@keydown=${(e) => modalState.handleObsKeydown(e, availObsResults)} />
|
||||
${availObsResults.length > 0 ? html`
|
||||
<ul class="menu bg-base-200 rounded-box absolute z-50 left-0 right-0 top-full mt-1 p-2 shadow-lg max-h-60 overflow-auto">
|
||||
${availObsResults.map(n => renderNodeSearchResult(n, modalState.handleObsSelect))}
|
||||
</ul>
|
||||
` : nothing}
|
||||
</div>
|
||||
<p class="text-xs opacity-50 mt-1">${t('routes.observers_help')}</p>
|
||||
<div class="flex flex-wrap items-center gap-1 mt-2 min-h-[2.5rem] p-2 bg-base-200 rounded-box">
|
||||
${observerNodes.length === 0
|
||||
? html`<span class="text-sm opacity-40">${t('routes.observers_empty')}</span>`
|
||||
: observerNodes.map((n, i) => html`
|
||||
<span class="inline-flex items-center gap-0.5 bg-base-300 rounded-full px-2 py-1 text-sm">
|
||||
<span>${n.name || n.public_key.slice(0, 8)}</span>
|
||||
<button type="button" class="btn btn-ghost btn-xs btn-circle opacity-60 hover:opacity-100"
|
||||
@click=${() => modalState.handleObsRemove(i)}>\u2715</button>
|
||||
</span>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label class="text-sm opacity-70">${t('routes.window_label')}</label>
|
||||
<input type="number" id="route-modal-window" class="input input-sm w-full"
|
||||
.value=${route.window_hours || 24} min="1" max="720" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm opacity-70">${t('routes.threshold_label')}</label>
|
||||
<input type="number" id="route-modal-threshold" class="input input-sm w-full"
|
||||
.value=${route.packet_count_threshold || 3} min="1" max="10000" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm opacity-70">${t('routes.degraded_label')}</label>
|
||||
<input type="number" id="route-modal-degraded" class="input input-sm w-full"
|
||||
.value=${route.degraded_threshold || ''}
|
||||
placeholder="2x" min="1" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm opacity-70">${t('routes.span_label')}</label>
|
||||
<input type="number" id="route-modal-span" class="input input-sm w-full"
|
||||
.value=${route.max_hop_span || ''}
|
||||
placeholder="\u221E" min="1" />
|
||||
</div>
|
||||
</div>
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox" id="route-modal-enabled" class="checkbox checkbox-sm"
|
||||
.checked=${route.enabled !== false} />
|
||||
<span class="text-sm">${t('routes.enabled_label')}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn btn-ghost" @click=${onCancel}>${t('common.cancel')}</button>
|
||||
<button type="submit" class="btn btn-primary">${t('common.save')}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button @click=${onCancel}></button></form>
|
||||
</dialog>`;
|
||||
}
|
||||
|
||||
function renderDeleteModal({ route, onConfirm, onCancel }) {
|
||||
return html`<dialog open class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg mb-4">${t('routes.delete_route')}</h3>
|
||||
<p>${t('routes.delete_confirm', { name: route.name })}</p>
|
||||
<div class="modal-action">
|
||||
<button class="btn btn-ghost" @click=${onCancel}>${t('common.cancel')}</button>
|
||||
<button class="btn btn-error" @click=${onConfirm}>${t('common.delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button @click=${onCancel}></button></form>
|
||||
</dialog>`;
|
||||
}
|
||||
|
||||
export async function render(container, params, router) {
|
||||
const { signal } = params || {};
|
||||
try {
|
||||
const config = getConfig();
|
||||
const isAdmin = hasRole('admin');
|
||||
|
||||
const data = await apiGet('/api/v1/routes', {}, { signal });
|
||||
const routes = data.items || [];
|
||||
|
||||
let modalState = null;
|
||||
let expandedId = null;
|
||||
const detailCache = new Map();
|
||||
|
||||
async function refresh() {
|
||||
const newData = await apiGet('/api/v1/routes');
|
||||
renderPage(newData.items || []);
|
||||
}
|
||||
|
||||
async function handleExpand(route) {
|
||||
if (expandedId === route.id) {
|
||||
expandedId = null;
|
||||
} else {
|
||||
expandedId = route.id;
|
||||
if (!detailCache.has(route.id)) {
|
||||
try {
|
||||
const detail = await apiGet(`/api/v1/routes/${route.id}`);
|
||||
detailCache.set(route.id, detail);
|
||||
} catch (e) {
|
||||
// ignore — card still shows basic info
|
||||
}
|
||||
}
|
||||
}
|
||||
renderPage(routes);
|
||||
}
|
||||
|
||||
function renderPage(routesList) {
|
||||
const adminHeader = isAdmin
|
||||
? html`<div class="flex justify-end mb-4">
|
||||
<button class="btn btn-primary btn-sm" @click=${handleAdd}>
|
||||
${iconPlus('h-4 w-4')} ${t('routes.add_route')}
|
||||
</button>
|
||||
</div>`
|
||||
: nothing;
|
||||
|
||||
const emptyMessage = routesList.length === 0
|
||||
? html`<div class="text-center py-8 opacity-70">
|
||||
${t('common.no_entity_found', { entity: t('entities.routes').toLowerCase() })}
|
||||
</div>`
|
||||
: nothing;
|
||||
|
||||
const groups = new Map();
|
||||
for (const vis of VISIBILITY_ORDER) groups.set(vis, []);
|
||||
for (const r of routesList) {
|
||||
const vis = r.visibility || 'community';
|
||||
if (!groups.has(vis)) groups.set(vis, []);
|
||||
groups.get(vis).push(r);
|
||||
}
|
||||
|
||||
const cardOpts = {
|
||||
isAdmin,
|
||||
onDelete: handleDeleteClick,
|
||||
onEdit: handleEditClick,
|
||||
onExpand: handleExpand,
|
||||
isExpanded: (r) => expandedId === r.id,
|
||||
detail: (r) => detailCache.get(r.id),
|
||||
};
|
||||
|
||||
const groupedSections = [];
|
||||
for (const vis of VISIBILITY_ORDER) {
|
||||
const group = groups.get(vis);
|
||||
if (!group || group.length === 0) continue;
|
||||
group.sort((a, b) => {
|
||||
const qa = a.route_result?.quality || (a.enabled ? 'unknown' : 'disabled');
|
||||
const qb = b.route_result?.quality || (b.enabled ? 'unknown' : 'disabled');
|
||||
return (QUALITY_PRIORITY[qa] ?? 9) - (QUALITY_PRIORITY[qb] ?? 9);
|
||||
});
|
||||
groupedSections.push(html`
|
||||
<h2 class="text-lg font-semibold mt-6 mb-3 opacity-70">${t(`routes.visibility_${vis}`)}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
${group.map(r => renderRouteCard(r, {
|
||||
...cardOpts,
|
||||
isExpanded: cardOpts.isExpanded(r),
|
||||
detail: cardOpts.detail(r),
|
||||
}))}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
let modalHtml = nothing;
|
||||
if (modalState?.type === 'add' || modalState?.type === 'edit') {
|
||||
modalHtml = renderRouteModal({
|
||||
modalState,
|
||||
onSave: handleSave,
|
||||
onCancel: () => { modalState = null; renderPage(routesList); },
|
||||
});
|
||||
} else if (modalState?.type === 'delete') {
|
||||
modalHtml = renderDeleteModal({
|
||||
route: modalState.route,
|
||||
onConfirm: handleDeleteConfirm,
|
||||
onCancel: () => { modalState = null; renderPage(routesList); },
|
||||
});
|
||||
}
|
||||
|
||||
litRender(html`
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold flex items-center gap-2">
|
||||
${iconPath('h-8 w-8')}
|
||||
${t('routes.title')}
|
||||
</h1>
|
||||
</div>
|
||||
${renderSummaryStrip(routesList)}
|
||||
${adminHeader}
|
||||
${emptyMessage}
|
||||
${groupedSections}
|
||||
${modalHtml}
|
||||
`, container);
|
||||
}
|
||||
|
||||
function _newModalState(type, route) {
|
||||
return {
|
||||
type,
|
||||
route,
|
||||
isEdit: type === 'edit',
|
||||
pathNodes: (route.route_nodes || []).map(rn => ({
|
||||
public_key: rn.public_key,
|
||||
name: rn.name,
|
||||
})),
|
||||
observerNodes: (route.route_observers || []).map(ro => ({
|
||||
public_key: ro.public_key,
|
||||
name: ro.name,
|
||||
})),
|
||||
pathResults: [],
|
||||
obsResults: [],
|
||||
handlePathSearch,
|
||||
handlePathSelect,
|
||||
handlePathRemove,
|
||||
handlePathMove,
|
||||
handlePathKeydown,
|
||||
handleObsSearch,
|
||||
handleObsSelect,
|
||||
handleObsRemove,
|
||||
handleObsKeydown,
|
||||
};
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
modalState = _newModalState('add', { visibility: 'community', enabled: true, match_width: 1 });
|
||||
renderPage(routes);
|
||||
}
|
||||
|
||||
function handleEditClick(route) {
|
||||
modalState = _newModalState('edit', route);
|
||||
renderPage(routes);
|
||||
}
|
||||
|
||||
function handleDeleteClick(route) {
|
||||
modalState = { type: 'delete', route };
|
||||
renderPage(routes);
|
||||
}
|
||||
|
||||
function handlePathSearch(query) {
|
||||
clearTimeout(_pathSearchTimer);
|
||||
const q = query.trim();
|
||||
if (q.length < 2) {
|
||||
modalState.pathResults = [];
|
||||
renderPage(routes);
|
||||
return;
|
||||
}
|
||||
_pathSearchTimer = setTimeout(async () => {
|
||||
const myId = ++_pathSearchId;
|
||||
try {
|
||||
const data = await apiGet('/api/v1/nodes', { search: q, limit: 10 });
|
||||
if (myId !== _pathSearchId) return;
|
||||
modalState.pathResults = data.items || [];
|
||||
renderPage(routes);
|
||||
} catch (_) { /* ignore */ }
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function handlePathSelect(node) {
|
||||
if (modalState.pathNodes.some(n => n.public_key === node.public_key)) return;
|
||||
modalState.pathNodes.push({ public_key: node.public_key, name: node.name });
|
||||
modalState.pathResults = [];
|
||||
renderPage(routes);
|
||||
const el = document.getElementById('route-modal-path-search');
|
||||
if (el) el.value = '';
|
||||
}
|
||||
|
||||
function handlePathRemove(index) {
|
||||
modalState.pathNodes.splice(index, 1);
|
||||
renderPage(routes);
|
||||
}
|
||||
|
||||
function handlePathMove(index, dir) {
|
||||
const newIndex = index + dir;
|
||||
if (newIndex < 0 || newIndex >= modalState.pathNodes.length) return;
|
||||
const nodes = modalState.pathNodes;
|
||||
[nodes[index], nodes[newIndex]] = [nodes[newIndex], nodes[index]];
|
||||
renderPage(routes);
|
||||
}
|
||||
|
||||
async function handlePathKeydown(e, availResults) {
|
||||
if (e.key !== 'Enter') return;
|
||||
e.preventDefault();
|
||||
if (availResults.length === 1) {
|
||||
handlePathSelect(availResults[0]);
|
||||
return;
|
||||
}
|
||||
if (availResults.length > 1) {
|
||||
handlePathSelect(availResults[0]);
|
||||
return;
|
||||
}
|
||||
const query = e.target.value.trim();
|
||||
if (query.length < 2) return;
|
||||
clearTimeout(_pathSearchTimer);
|
||||
const myId = ++_pathSearchId;
|
||||
try {
|
||||
const data = await apiGet('/api/v1/nodes', { search: query, limit: 10 });
|
||||
if (myId !== _pathSearchId) return;
|
||||
modalState.pathResults = data.items || [];
|
||||
renderPage(routes);
|
||||
const filtered = modalState.pathResults.filter(
|
||||
n => !modalState.pathNodes.some(pn => pn.public_key === n.public_key)
|
||||
);
|
||||
if (filtered.length >= 1) {
|
||||
handlePathSelect(filtered[0]);
|
||||
}
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
function handleObsSearch(query) {
|
||||
clearTimeout(_obsSearchTimer);
|
||||
const q = query.trim();
|
||||
if (q.length < 2) {
|
||||
modalState.obsResults = [];
|
||||
renderPage(routes);
|
||||
return;
|
||||
}
|
||||
_obsSearchTimer = setTimeout(async () => {
|
||||
const myId = ++_obsSearchId;
|
||||
try {
|
||||
const data = await apiGet('/api/v1/nodes', { search: q, limit: 10 });
|
||||
if (myId !== _obsSearchId) return;
|
||||
modalState.obsResults = data.items || [];
|
||||
renderPage(routes);
|
||||
} catch (_) { /* ignore */ }
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function handleObsSelect(node) {
|
||||
if (modalState.observerNodes.some(n => n.public_key === node.public_key)) return;
|
||||
modalState.observerNodes.push({ public_key: node.public_key, name: node.name });
|
||||
modalState.obsResults = [];
|
||||
renderPage(routes);
|
||||
const el = document.getElementById('route-modal-obs-search');
|
||||
if (el) el.value = '';
|
||||
}
|
||||
|
||||
function handleObsRemove(index) {
|
||||
modalState.observerNodes.splice(index, 1);
|
||||
renderPage(routes);
|
||||
}
|
||||
|
||||
async function handleObsKeydown(e, availResults) {
|
||||
if (e.key !== 'Enter') return;
|
||||
e.preventDefault();
|
||||
if (availResults.length >= 1) {
|
||||
handleObsSelect(availResults[0]);
|
||||
return;
|
||||
}
|
||||
const query = e.target.value.trim();
|
||||
if (query.length < 2) return;
|
||||
clearTimeout(_obsSearchTimer);
|
||||
const myId = ++_obsSearchId;
|
||||
try {
|
||||
const data = await apiGet('/api/v1/nodes', { search: query, limit: 10 });
|
||||
if (myId !== _obsSearchId) return;
|
||||
modalState.obsResults = data.items || [];
|
||||
renderPage(routes);
|
||||
const filtered = modalState.obsResults.filter(
|
||||
n => !modalState.observerNodes.some(on => on.public_key === n.public_key)
|
||||
);
|
||||
if (filtered.length >= 1) {
|
||||
handleObsSelect(filtered[0]);
|
||||
}
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const nameEl = document.getElementById('route-modal-name');
|
||||
const descEl = document.getElementById('route-modal-description');
|
||||
const visEl = document.getElementById('route-modal-visibility');
|
||||
const widthEl = document.getElementById('route-modal-width');
|
||||
const windowEl = document.getElementById('route-modal-window');
|
||||
const thresholdEl = document.getElementById('route-modal-threshold');
|
||||
const degradedEl = document.getElementById('route-modal-degraded');
|
||||
const spanEl = document.getElementById('route-modal-span');
|
||||
const enabledEl = document.getElementById('route-modal-enabled');
|
||||
|
||||
const isEdit = modalState.isEdit;
|
||||
const nodePublicKeys = modalState.pathNodes.map(n => n.public_key);
|
||||
const observerPublicKeys = modalState.observerNodes.map(n => n.public_key);
|
||||
|
||||
if (nodePublicKeys.length < 2) {
|
||||
alert(t('routes.min_nodes_error'));
|
||||
return;
|
||||
}
|
||||
|
||||
const body = {
|
||||
name: nameEl.value.trim(),
|
||||
description: descEl.value.trim() || null,
|
||||
visibility: visEl.value,
|
||||
match_width: parseInt(widthEl.value, 10) || 1,
|
||||
window_hours: parseInt(windowEl.value, 10) || 24,
|
||||
packet_count_threshold: parseInt(thresholdEl.value, 10) || 3,
|
||||
max_hop_span: spanEl.value ? parseInt(spanEl.value, 10) : null,
|
||||
enabled: enabledEl.checked,
|
||||
node_public_keys: nodePublicKeys,
|
||||
observer_public_keys: observerPublicKeys.length > 0 ? observerPublicKeys : null,
|
||||
};
|
||||
|
||||
const degradedVal = degradedEl.value.trim();
|
||||
if (degradedVal) {
|
||||
body.degraded_threshold = parseInt(degradedVal, 10);
|
||||
}
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
await apiPut(`/api/v1/routes/${modalState.route.id}`, body);
|
||||
} else {
|
||||
await apiPost('/api/v1/routes', body);
|
||||
}
|
||||
modalState = null;
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
alert(e.message || 'Failed to save route');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteConfirm() {
|
||||
try {
|
||||
await apiDelete(`/api/v1/routes/${modalState.route.id}`);
|
||||
modalState = null;
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
alert(e.message || 'Failed to delete route');
|
||||
}
|
||||
}
|
||||
|
||||
renderPage(routes);
|
||||
|
||||
} catch (e) {
|
||||
if (isAbortError(e)) return;
|
||||
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,8 @@
|
||||
"tags": "Tags",
|
||||
"tag": "Tag",
|
||||
"channel": "Channel",
|
||||
"channels": "Channels"
|
||||
"channels": "Channels",
|
||||
"routes": "Routes"
|
||||
},
|
||||
"common": {
|
||||
"filter": "Filter",
|
||||
@@ -300,6 +301,59 @@
|
||||
"optgroup_standard": "Standard",
|
||||
"optgroup_custom": "Custom"
|
||||
},
|
||||
"routes": {
|
||||
"title": "Routes",
|
||||
"add_route": "Add Route",
|
||||
"edit_route": "Edit Route",
|
||||
"delete_route": "Delete Route",
|
||||
"delete_confirm": "Are you sure you want to delete route {{name}}?",
|
||||
"name_label": "Route Name",
|
||||
"description_label": "Description",
|
||||
"visibility_label": "Visibility",
|
||||
"visibility_community": "Community",
|
||||
"visibility_member": "Member",
|
||||
"visibility_operator": "Operator",
|
||||
"visibility_admin": "Admin",
|
||||
"width_label": "Match Width",
|
||||
"width_hint_1": "Matches all traffic (~256 buckets)",
|
||||
"width_hint_2": "2-byte+ only (~65K buckets)",
|
||||
"width_hint_3": "3-byte only (~16M buckets)",
|
||||
"node_ids_label": "Path Nodes",
|
||||
"node_ids_placeholder": "Search by name or public key",
|
||||
"node_ids_help": "Search and add at least 2 nodes to define the route path.",
|
||||
"search_nodes_placeholder": "Search by name or public key\u2026",
|
||||
"path_label": "Path Nodes",
|
||||
"path_help": "Search by node name or public key, then select from results. At least 2 distinct nodes required.",
|
||||
"path_empty": "No path nodes selected \u2014 search above to add nodes.",
|
||||
"observers_label": "Observers (optional)",
|
||||
"observers_help": "Restrict evaluation to specific observer nodes. Leave empty to use all observers.",
|
||||
"observers_empty": "No observers selected \u2014 all observers will be used.",
|
||||
"observers_placeholder": "Search to add observer nodes",
|
||||
"window_label": "Window (hours)",
|
||||
"threshold_label": "Threshold",
|
||||
"degraded_label": "Degraded",
|
||||
"span_label": "Max Span",
|
||||
"enabled_label": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"not_evaluated": "Not yet evaluated",
|
||||
"diagnosis": "Diagnosis",
|
||||
"diagnosis_healthy": "Route is healthy — enough packets are traversing the configured path.",
|
||||
"diagnosis_unhealthy": "Route is unhealthy — in-scope observers are hearing traffic but not enough packets match the configured path.",
|
||||
"diagnosis_no_coverage": "No coverage — no in-scope observer has heard any packets in the window. The route may be down or no observer is positioned to hear it.",
|
||||
"contributing_observers": "Contributing Observers",
|
||||
"no_observers": "No contributing observers in the evaluation window.",
|
||||
"recent_matches": "Recent Matches",
|
||||
"width": "Width",
|
||||
"window": "Window",
|
||||
"threshold": "Threshold",
|
||||
"span": "Span",
|
||||
"quality_clear": "Clear",
|
||||
"quality_marginal": "Marginal",
|
||||
"quality_failing": "Failing",
|
||||
"quality_no_coverage": "No Coverage",
|
||||
"quality_unknown": "Unknown",
|
||||
"min_nodes_error": "At least 2 path nodes are required."
|
||||
},
|
||||
"not_found": {
|
||||
"description": "The page you're looking for doesn't exist or has been moved."
|
||||
},
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"member": "Lid",
|
||||
"admin": "Beheer",
|
||||
"tags": "Labels",
|
||||
"tag": "Label"
|
||||
"tag": "Label",
|
||||
"routes": "Routes"
|
||||
},
|
||||
"common": {
|
||||
"filter": "Filter",
|
||||
@@ -222,6 +223,30 @@
|
||||
"visibility_operator": "Operator",
|
||||
"visibility_admin": "Beheerder"
|
||||
},
|
||||
"routes": {
|
||||
"title": "Routes",
|
||||
"add_route": "Route toevoegen",
|
||||
"edit_route": "Route bewerken",
|
||||
"delete_route": "Route verwijderen",
|
||||
"delete_confirm": "Weet u zeker dat u route {{name}} wilt verwwijderen?",
|
||||
"visibility_community": "Community",
|
||||
"visibility_member": "Lid",
|
||||
"visibility_operator": "Operator",
|
||||
"visibility_admin": "Beheerder",
|
||||
"quality_clear": "Goed",
|
||||
"quality_marginal": "Kritiek",
|
||||
"quality_failing": "Storing",
|
||||
"quality_no_coverage": "Geen dekking",
|
||||
"quality_unknown": "Onbekend",
|
||||
"disabled": "Uitgeschakeld",
|
||||
"search_nodes_placeholder": "Zoek op naam of public key\u2026",
|
||||
"path_label": "Padknooppunten",
|
||||
"path_help": "Zoek op knooppuntnaam of public key en selecteer uit de resultaten. Minimaal 2 unieke knooppunten vereist.",
|
||||
"path_empty": "Geen padknooppunten geselecteerd \u2014 zoek hierboven om knooppunten toe te voegen.",
|
||||
"observers_label": "Observers (optioneel)",
|
||||
"observers_help": "Beperk evaluatie tot specifieke observer-knooppunten. Leeg laten om alle observers te gebruiken.",
|
||||
"observers_empty": "Geen observers geselecteerd \u2014 alle observers worden gebruikt."
|
||||
},
|
||||
"not_found": {
|
||||
"description": "De pagina die u zoekt bestaat niet of is verplaatst."
|
||||
},
|
||||
|
||||
@@ -73,6 +73,9 @@
|
||||
{% if features.channels %}
|
||||
<li><a href="/channels" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" /></svg> {{ t('entities.channels') }}</a></li>
|
||||
{% endif %}
|
||||
{% if features.routes %}
|
||||
<li><a href="/routes" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-routes" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7.217 10.907a2.25 2.25 0 100 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186l9.566-5.314m-9.566 7.5l9.566 5.314m0 0a2.25 2.25 0 103.935 2.186 2.25 2.25 0 00-3.935-2.186zm0-12.814a2.25 2.25 0 103.933-2.185 2.25 2.25 0 00-3.933 2.185z" /></svg> {{ t('entities.routes') }}</a></li>
|
||||
{% endif %}
|
||||
{% if features.messages %}
|
||||
<li><a href="/messages" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-messages" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg> {{ t('entities.messages') }}</a></li>
|
||||
{% endif %}
|
||||
|
||||
@@ -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
|
||||
@@ -3,7 +3,7 @@
|
||||
from sqlalchemy import select
|
||||
|
||||
from meshcore_hub.collector.handlers.raw_packet import store_raw_packet
|
||||
from meshcore_hub.common.models import Node, RawPacket
|
||||
from meshcore_hub.common.models import Node, PacketPathHop, RawPacket
|
||||
|
||||
|
||||
def _channel_decoded() -> dict:
|
||||
@@ -225,3 +225,87 @@ class TestStoreRawPacketPathHashBytes:
|
||||
rp = db_session.execute(select(RawPacket)).scalar_one()
|
||||
assert rp.path_len == 3
|
||||
assert rp.path_hash_bytes == 1
|
||||
|
||||
|
||||
class TestStoreRawPacketPathHops:
|
||||
"""Tests for packet_path_hops insertion at ingest."""
|
||||
|
||||
def test_hops_inserted_with_positions(self, db_manager, db_session):
|
||||
"""Each path hash becomes a hop with the correct position and hash."""
|
||||
decoded = {
|
||||
"payloadType": 1,
|
||||
"path": ["aa", "bbcc", "dd"],
|
||||
"payload": {"decoded": {}},
|
||||
}
|
||||
store_raw_packet(
|
||||
"a" * 64,
|
||||
{"raw": "00", "hash": "pkt1"},
|
||||
decoded,
|
||||
"flood",
|
||||
db_manager,
|
||||
)
|
||||
|
||||
hops = (
|
||||
db_session.execute(select(PacketPathHop).order_by(PacketPathHop.position))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(hops) == 3
|
||||
assert [h.position for h in hops] == [0, 1, 2]
|
||||
assert [h.node_hash for h in hops] == ["AA", "BBCC", "DD"]
|
||||
assert all(h.packet_hash == "pkt1" for h in hops)
|
||||
|
||||
def test_hops_skipped_when_path_absent(self, db_manager, db_session):
|
||||
"""No path hashes means zero hop rows."""
|
||||
decoded = {"payloadType": 3, "payload": {"decoded": {}}}
|
||||
store_raw_packet(
|
||||
"a" * 64, {"raw": "00", "hash": "h1"}, decoded, "ack", db_manager
|
||||
)
|
||||
|
||||
hops = db_session.execute(select(PacketPathHop)).scalars().all()
|
||||
assert len(hops) == 0
|
||||
|
||||
def test_observer_node_id_denormalized(self, db_manager, db_session):
|
||||
"""The observer node ID is denormalized onto each hop row."""
|
||||
decoded = {
|
||||
"payloadType": 1,
|
||||
"path": ["aa", "bb"],
|
||||
"payload": {"decoded": {}},
|
||||
}
|
||||
store_raw_packet(
|
||||
"c" * 64,
|
||||
{"raw": "00", "hash": "pkt2"},
|
||||
decoded,
|
||||
"flood",
|
||||
db_manager,
|
||||
)
|
||||
|
||||
node = db_session.execute(
|
||||
select(Node).where(Node.public_key == "c" * 64)
|
||||
).scalar_one()
|
||||
|
||||
hops = db_session.execute(select(PacketPathHop)).scalars().all()
|
||||
assert len(hops) == 2
|
||||
assert all(h.observer_node_id == node.id for h in hops)
|
||||
|
||||
def test_hops_trace_fallback(self, db_manager, db_session):
|
||||
"""Trace-style pathHashes in payload.decoded produce hops too."""
|
||||
decoded = {
|
||||
"payloadType": 1,
|
||||
"payload": {"decoded": {"pathHashes": ["aabb", "ccdd"]}},
|
||||
}
|
||||
store_raw_packet(
|
||||
"a" * 64,
|
||||
{"raw": "00", "hash": "pkt3"},
|
||||
decoded,
|
||||
"trace",
|
||||
db_manager,
|
||||
)
|
||||
|
||||
hops = (
|
||||
db_session.execute(select(PacketPathHop).order_by(PacketPathHop.position))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(hops) == 2
|
||||
assert [h.node_hash for h in hops] == ["AABB", "CCDD"]
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Tests for the route evaluator."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from meshcore_hub.collector.route_evaluator import run_evaluation
|
||||
from meshcore_hub.collector.routes import derive_expected_hash
|
||||
from meshcore_hub.common.models import (
|
||||
Node,
|
||||
PacketPathHop,
|
||||
RawPacket,
|
||||
Route,
|
||||
RouteNode,
|
||||
RouteResult,
|
||||
RouteQuality,
|
||||
RouteState,
|
||||
)
|
||||
|
||||
_NOW = datetime(2026, 7, 12, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _make_node(session, pk: str) -> Node:
|
||||
node = Node(public_key=pk)
|
||||
session.add(node)
|
||||
session.flush()
|
||||
return node
|
||||
|
||||
|
||||
def _make_reception(session, packet_hash: str, path: list[str], ts=None):
|
||||
ts = ts or _NOW
|
||||
rp_id = str(uuid4())
|
||||
session.add(RawPacket(id=rp_id, packet_hash=packet_hash, received_at=ts))
|
||||
session.flush()
|
||||
for pos, nh in enumerate(path):
|
||||
session.add(
|
||||
PacketPathHop(
|
||||
raw_packet_id=rp_id,
|
||||
position=pos,
|
||||
node_hash=nh,
|
||||
packet_hash=packet_hash,
|
||||
received_at=ts,
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
|
||||
|
||||
def _make_route(session, name, nodes, **kwargs):
|
||||
route = Route(name=name, **kwargs)
|
||||
session.add(route)
|
||||
session.flush()
|
||||
for pos, n in enumerate(nodes):
|
||||
session.add(
|
||||
RouteNode(
|
||||
route_id=route.id,
|
||||
node_id=n.id,
|
||||
position=pos,
|
||||
expected_hash=derive_expected_hash(n.public_key, 1),
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
return route
|
||||
|
||||
|
||||
class TestRunEvaluation:
|
||||
def test_upsert_idempotent(self, db_manager, db_session):
|
||||
"""Re-evaluating the same route overwrites its single result row."""
|
||||
node_a = _make_node(db_session, "aa" + "0" * 62)
|
||||
node_b = _make_node(db_session, "bb" + "0" * 62)
|
||||
_make_route(db_session, "R1", [node_a, node_b], packet_count_threshold=1)
|
||||
for i in range(3):
|
||||
_make_reception(db_session, f"pkt{i}", ["AA", "BB"])
|
||||
db_session.commit()
|
||||
|
||||
count1 = run_evaluation(db_manager)
|
||||
assert count1 == 1
|
||||
results = db_session.execute(select(RouteResult)).scalars().all()
|
||||
assert len(results) == 1
|
||||
|
||||
count2 = run_evaluation(db_manager)
|
||||
assert count2 == 1
|
||||
results = db_session.execute(select(RouteResult)).scalars().all()
|
||||
assert len(results) == 1 # still one row (overwritten)
|
||||
|
||||
def test_disabled_routes_skipped(self, db_manager, db_session):
|
||||
node_a = _make_node(db_session, "aa" + "0" * 62)
|
||||
node_b = _make_node(db_session, "bb" + "0" * 62)
|
||||
_make_route(db_session, "enabled", [node_a, node_b], enabled=True)
|
||||
_make_route(db_session, "disabled", [node_a, node_b], enabled=False)
|
||||
db_session.commit()
|
||||
|
||||
count = run_evaluation(db_manager)
|
||||
assert count == 1 # only the enabled route
|
||||
|
||||
def test_writes_correct_result(self, db_manager, db_session):
|
||||
node_a = _make_node(db_session, "aa" + "0" * 62)
|
||||
node_b = _make_node(db_session, "bb" + "0" * 62)
|
||||
route = _make_route(
|
||||
db_session, "R1", [node_a, node_b], packet_count_threshold=3
|
||||
)
|
||||
for i in range(7):
|
||||
_make_reception(db_session, f"pkt{i}", ["AA", "BB"])
|
||||
db_session.commit()
|
||||
|
||||
run_evaluation(db_manager)
|
||||
db_session.expire_all()
|
||||
|
||||
result = db_session.execute(
|
||||
select(RouteResult).where(RouteResult.route_id == route.id)
|
||||
).scalar_one()
|
||||
assert result.state == RouteState.HEALTHY.value
|
||||
assert result.quality == RouteQuality.CLEAR.value
|
||||
assert result.threshold == 3
|
||||
assert result.effective_degraded == 6
|
||||
@@ -0,0 +1,464 @@
|
||||
"""Tests for the route health matching engine."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from meshcore_hub.collector.routes import (
|
||||
derive_expected_hash,
|
||||
derive_quality,
|
||||
detect_observed_widths,
|
||||
effective_degraded_threshold,
|
||||
evaluate_all_routes,
|
||||
evaluate_route,
|
||||
is_subsequence,
|
||||
preview_route,
|
||||
prefix_collision_counts,
|
||||
recent_matches,
|
||||
upsert_route_result,
|
||||
)
|
||||
from meshcore_hub.common.models import (
|
||||
Node,
|
||||
PacketPathHop,
|
||||
RawPacket,
|
||||
Route,
|
||||
RouteNode,
|
||||
RouteObserver,
|
||||
RouteQuality,
|
||||
RouteResult,
|
||||
RouteState,
|
||||
)
|
||||
|
||||
_NOW = datetime(2026, 7, 12, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _make_node(db_session, public_key: str, name: str | None = None) -> Node:
|
||||
node = Node(public_key=public_key, name=name)
|
||||
db_session.add(node)
|
||||
db_session.flush()
|
||||
return node
|
||||
|
||||
|
||||
def _make_reception(
|
||||
db_session,
|
||||
observer_node_id: str | None,
|
||||
packet_hash: str,
|
||||
path_hashes: list[str],
|
||||
received_at: datetime | None = None,
|
||||
) -> str:
|
||||
"""Insert a RawPacket + PacketPathHop rows for a test reception."""
|
||||
ts = received_at or _NOW
|
||||
rp_id = str(uuid4())
|
||||
rp = RawPacket(
|
||||
id=rp_id,
|
||||
observer_node_id=observer_node_id,
|
||||
packet_hash=packet_hash,
|
||||
received_at=ts,
|
||||
)
|
||||
db_session.add(rp)
|
||||
db_session.flush()
|
||||
for pos, nh in enumerate(path_hashes):
|
||||
db_session.add(
|
||||
PacketPathHop(
|
||||
raw_packet_id=rp_id,
|
||||
position=pos,
|
||||
node_hash=nh,
|
||||
packet_hash=packet_hash,
|
||||
received_at=ts,
|
||||
observer_node_id=observer_node_id,
|
||||
)
|
||||
)
|
||||
db_session.flush()
|
||||
return rp_id
|
||||
|
||||
|
||||
def _make_route(
|
||||
db_session,
|
||||
name: str,
|
||||
nodes: list[Node],
|
||||
match_width: int = 1,
|
||||
threshold: int = 3,
|
||||
degraded: int | None = None,
|
||||
max_hop_span: int | None = None,
|
||||
observers: list[Node] | None = None,
|
||||
enabled: bool = True,
|
||||
window_hours: int = 24,
|
||||
) -> Route:
|
||||
route = Route(
|
||||
name=name,
|
||||
match_width=match_width,
|
||||
packet_count_threshold=threshold,
|
||||
degraded_threshold=degraded,
|
||||
max_hop_span=max_hop_span,
|
||||
enabled=enabled,
|
||||
window_hours=window_hours,
|
||||
)
|
||||
db_session.add(route)
|
||||
db_session.flush()
|
||||
for pos, node in enumerate(nodes):
|
||||
db_session.add(
|
||||
RouteNode(
|
||||
route_id=route.id,
|
||||
node_id=node.id,
|
||||
position=pos,
|
||||
expected_hash=derive_expected_hash(node.public_key, match_width),
|
||||
)
|
||||
)
|
||||
if observers:
|
||||
for obs in observers:
|
||||
db_session.add(RouteObserver(route_id=route.id, node_id=obs.id))
|
||||
db_session.flush()
|
||||
return route
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure function tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsSubsequence:
|
||||
def test_exact_match(self):
|
||||
path = [{"position": 0, "node_hash": "A1"}, {"position": 1, "node_hash": "B2"}]
|
||||
assert is_subsequence(path, ["A1", "B2"]) is True
|
||||
|
||||
def test_gaps_allowed(self):
|
||||
path = [
|
||||
{"position": 0, "node_hash": "A1"},
|
||||
{"position": 1, "node_hash": "XX"},
|
||||
{"position": 2, "node_hash": "B2"},
|
||||
]
|
||||
assert is_subsequence(path, ["A1", "B2"]) is True
|
||||
|
||||
def test_order_enforced(self):
|
||||
path = [{"position": 0, "node_hash": "B2"}, {"position": 1, "node_hash": "A1"}]
|
||||
assert is_subsequence(path, ["A1", "B2"]) is False
|
||||
|
||||
def test_prefix_match(self):
|
||||
"""A1B2 startswith A1 — prefix match should succeed."""
|
||||
path = [
|
||||
{"position": 0, "node_hash": "A1B2"},
|
||||
{"position": 1, "node_hash": "C3"},
|
||||
]
|
||||
assert is_subsequence(path, ["A1", "C3"]) is True
|
||||
|
||||
def test_span_cap_within(self):
|
||||
path = [
|
||||
{"position": 0, "node_hash": "A1"},
|
||||
{"position": 1, "node_hash": "X"},
|
||||
{"position": 2, "node_hash": "B2"},
|
||||
]
|
||||
assert is_subsequence(path, ["A1", "B2"], max_hop_span=2) is True
|
||||
|
||||
def test_span_cap_exceeds(self):
|
||||
path = [
|
||||
{"position": 0, "node_hash": "A1"},
|
||||
{"position": 1, "node_hash": "X"},
|
||||
{"position": 2, "node_hash": "X"},
|
||||
{"position": 3, "node_hash": "X"},
|
||||
{"position": 4, "node_hash": "B2"},
|
||||
]
|
||||
assert is_subsequence(path, ["A1", "B2"], max_hop_span=2) is False
|
||||
|
||||
def test_empty_expected(self):
|
||||
assert is_subsequence([{"position": 0, "node_hash": "A1"}], []) is False
|
||||
|
||||
|
||||
class TestDeriveQuality:
|
||||
def test_clear(self):
|
||||
assert (
|
||||
derive_quality(RouteState.HEALTHY.value, 10, 3, 6)
|
||||
== RouteQuality.CLEAR.value
|
||||
)
|
||||
|
||||
def test_marginal(self):
|
||||
assert (
|
||||
derive_quality(RouteState.HEALTHY.value, 4, 3, 6)
|
||||
== RouteQuality.MARGINAL.value
|
||||
)
|
||||
|
||||
def test_failing(self):
|
||||
assert (
|
||||
derive_quality(RouteState.UNHEALTHY.value, 1, 3, 6)
|
||||
== RouteQuality.FAILING.value
|
||||
)
|
||||
|
||||
def test_unknown(self):
|
||||
assert (
|
||||
derive_quality(RouteState.NO_COVERAGE.value, 0, 3, 6)
|
||||
== RouteQuality.UNKNOWN.value
|
||||
)
|
||||
|
||||
|
||||
class TestEffectiveDegraded:
|
||||
def test_explicit(self, db_session):
|
||||
route = Route(name="t", packet_count_threshold=5, degraded_threshold=20)
|
||||
assert effective_degraded_threshold(route) == 20
|
||||
|
||||
def test_default_2x(self, db_session):
|
||||
route = Route(name="t", packet_count_threshold=5, degraded_threshold=None)
|
||||
assert effective_degraded_threshold(route) == 10
|
||||
|
||||
|
||||
class TestDeriveExpectedHash:
|
||||
def test_uppercased(self):
|
||||
assert derive_expected_hash("aabbccdd" * 8, 1) == "AA"
|
||||
assert derive_expected_hash("aabbccdd" * 8, 2) == "AABB"
|
||||
assert derive_expected_hash("aabbccdd" * 8, 3) == "AABBCC"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB-backed evaluation tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEvaluateRoute:
|
||||
def test_healthy_clear(self, db_session):
|
||||
"""Enough distinct matching packets → healthy/clear."""
|
||||
node_a = _make_node(db_session, "aa" + "0" * 62)
|
||||
node_b = _make_node(db_session, "bb" + "0" * 62)
|
||||
route = _make_route(db_session, "R1", [node_a, node_b], threshold=3)
|
||||
|
||||
for i in range(10):
|
||||
_make_reception(db_session, None, f"pkt{i}", ["AA", "BB"])
|
||||
db_session.commit()
|
||||
|
||||
since = _NOW - timedelta(hours=24)
|
||||
state, quality, count = evaluate_route(db_session, route, since)
|
||||
assert state == RouteState.HEALTHY.value
|
||||
assert quality == RouteQuality.CLEAR.value
|
||||
assert count >= 6 # short-circuited at effective_degraded = 6
|
||||
|
||||
def test_healthy_marginal(self, db_session):
|
||||
"""Meets threshold but not comfort bar → healthy/marginal."""
|
||||
node_a = _make_node(db_session, "aa" + "0" * 62)
|
||||
node_b = _make_node(db_session, "bb" + "0" * 62)
|
||||
route = _make_route(db_session, "R1", [node_a, node_b], threshold=3)
|
||||
|
||||
for i in range(4):
|
||||
_make_reception(db_session, None, f"pkt{i}", ["AA", "BB"])
|
||||
db_session.commit()
|
||||
|
||||
since = _NOW - timedelta(hours=24)
|
||||
state, quality, count = evaluate_route(db_session, route, since)
|
||||
assert state == RouteState.HEALTHY.value
|
||||
assert quality == RouteQuality.MARGINAL.value
|
||||
assert count == 4
|
||||
|
||||
def test_unhealthy(self, db_session):
|
||||
"""Receptions exist but not enough matches → unhealthy/failing."""
|
||||
node_a = _make_node(db_session, "aa" + "0" * 62)
|
||||
node_b = _make_node(db_session, "bb" + "0" * 62)
|
||||
route = _make_route(db_session, "R1", [node_a, node_b], threshold=3)
|
||||
|
||||
_make_reception(db_session, None, "pkt1", ["AA", "BB"])
|
||||
_make_reception(db_session, None, "pkt2", ["CC", "DD"])
|
||||
db_session.commit()
|
||||
|
||||
since = _NOW - timedelta(hours=24)
|
||||
state, quality, count = evaluate_route(db_session, route, since)
|
||||
assert state == RouteState.UNHEALTHY.value
|
||||
assert quality == RouteQuality.FAILING.value
|
||||
assert count == 1
|
||||
|
||||
def test_no_coverage(self, db_session):
|
||||
"""Zero hops in window → no_coverage/unknown."""
|
||||
node_a = _make_node(db_session, "aa" + "0" * 62)
|
||||
node_b = _make_node(db_session, "bb" + "0" * 62)
|
||||
route = _make_route(db_session, "R1", [node_a, node_b], threshold=3)
|
||||
db_session.commit()
|
||||
|
||||
since = _NOW - timedelta(hours=24)
|
||||
state, quality, count = evaluate_route(db_session, route, since)
|
||||
assert state == RouteState.NO_COVERAGE.value
|
||||
assert quality == RouteQuality.UNKNOWN.value
|
||||
assert count == 0
|
||||
|
||||
def test_per_reception_isolation(self, db_session):
|
||||
"""Hops from different receptions are never spliced together."""
|
||||
node_a = _make_node(db_session, "aa" + "0" * 62)
|
||||
node_b = _make_node(db_session, "bb" + "0" * 62)
|
||||
route = _make_route(db_session, "R1", [node_a, node_b], threshold=1)
|
||||
|
||||
# Reception 1: has AA only
|
||||
_make_reception(db_session, None, "pkt1", ["AA"])
|
||||
# Reception 2: has BB only
|
||||
_make_reception(db_session, None, "pkt2", ["BB"])
|
||||
db_session.commit()
|
||||
|
||||
since = _NOW - timedelta(hours=24)
|
||||
state, _, count = evaluate_route(db_session, route, since)
|
||||
assert count == 0 # Neither reception has both A and B in order
|
||||
assert state == RouteState.UNHEALTHY.value
|
||||
|
||||
def test_multi_observer_dedup(self, db_session):
|
||||
"""Same packet from two observers counts once."""
|
||||
node_a = _make_node(db_session, "aa" + "0" * 62)
|
||||
node_b = _make_node(db_session, "bb" + "0" * 62)
|
||||
obs1 = _make_node(db_session, "cc" + "0" * 62)
|
||||
obs2 = _make_node(db_session, "dd" + "0" * 62)
|
||||
route = _make_route(db_session, "R1", [node_a, node_b], threshold=1)
|
||||
|
||||
_make_reception(db_session, obs1.id, "shared", ["AA", "BB"])
|
||||
_make_reception(db_session, obs2.id, "shared", ["AA", "BB"])
|
||||
db_session.commit()
|
||||
|
||||
since = _NOW - timedelta(hours=24)
|
||||
state, _, count = evaluate_route(db_session, route, since)
|
||||
assert count == 1
|
||||
assert state == RouteState.HEALTHY.value
|
||||
|
||||
def test_observer_scope_filter(self, db_session):
|
||||
"""Only in-scope observers are considered."""
|
||||
node_a = _make_node(db_session, "aa" + "0" * 62)
|
||||
node_b = _make_node(db_session, "bb" + "0" * 62)
|
||||
obs1 = _make_node(db_session, "cc" + "0" * 62)
|
||||
obs2 = _make_node(db_session, "dd" + "0" * 62)
|
||||
route = _make_route(
|
||||
db_session, "R1", [node_a, node_b], threshold=1, observers=[obs1]
|
||||
)
|
||||
|
||||
# obs1 has no matching reception; obs2 has a match but is out of scope
|
||||
_make_reception(db_session, obs2.id, "pkt1", ["AA", "BB"])
|
||||
db_session.commit()
|
||||
|
||||
since = _NOW - timedelta(hours=24)
|
||||
state, _, count = evaluate_route(db_session, route, since)
|
||||
assert count == 0
|
||||
|
||||
def test_short_circuit_at_effective_degraded(self, db_session):
|
||||
"""Evaluation stops counting at the comfort bar."""
|
||||
node_a = _make_node(db_session, "aa" + "0" * 62)
|
||||
node_b = _make_node(db_session, "bb" + "0" * 62)
|
||||
route = _make_route(db_session, "R1", [node_a, node_b], threshold=2, degraded=4)
|
||||
|
||||
for i in range(20):
|
||||
_make_reception(db_session, None, f"pkt{i}", ["AA", "BB"])
|
||||
db_session.commit()
|
||||
|
||||
since = _NOW - timedelta(hours=24)
|
||||
state, quality, count = evaluate_route(db_session, route, since)
|
||||
assert quality == RouteQuality.CLEAR.value
|
||||
assert count == 4 # short-circuited at effective_degraded = 4
|
||||
|
||||
|
||||
class TestEvaluateAllRoutes:
|
||||
def test_only_enabled_routes(self, db_session):
|
||||
node_a = _make_node(db_session, "aa" + "0" * 62)
|
||||
node_b = _make_node(db_session, "bb" + "0" * 62)
|
||||
_make_route(db_session, "enabled", [node_a, node_b], enabled=True)
|
||||
_make_route(db_session, "disabled", [node_a, node_b], enabled=False)
|
||||
db_session.commit()
|
||||
|
||||
results = evaluate_all_routes(db_session, _NOW)
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
class TestUpsertRouteResult:
|
||||
def test_idempotent(self, db_session):
|
||||
node_a = _make_node(db_session, "aa" + "0" * 62)
|
||||
node_b = _make_node(db_session, "bb" + "0" * 62)
|
||||
route = _make_route(db_session, "R1", [node_a, node_b])
|
||||
db_session.commit()
|
||||
|
||||
upsert_route_result(
|
||||
db_session, route, RouteState.HEALTHY.value, RouteQuality.CLEAR.value, 5
|
||||
)
|
||||
db_session.commit()
|
||||
assert len(db_session.execute(select(RouteResult)).scalars().all()) == 1
|
||||
|
||||
upsert_route_result(
|
||||
db_session, route, RouteState.UNHEALTHY.value, RouteQuality.FAILING.value, 1
|
||||
)
|
||||
db_session.commit()
|
||||
results = db_session.execute(select(RouteResult)).scalars().all()
|
||||
assert len(results) == 1
|
||||
assert results[0].state == RouteState.UNHEALTHY.value
|
||||
assert results[0].quality == RouteQuality.FAILING.value
|
||||
|
||||
|
||||
class TestRecentMatches:
|
||||
def test_ordering_and_limit(self, db_session):
|
||||
node_a = _make_node(db_session, "aa" + "0" * 62)
|
||||
node_b = _make_node(db_session, "bb" + "0" * 62)
|
||||
route = _make_route(db_session, "R1", [node_a, node_b])
|
||||
|
||||
for i in range(5):
|
||||
_make_reception(
|
||||
db_session,
|
||||
None,
|
||||
f"pkt{i}",
|
||||
["AA", "BB"],
|
||||
received_at=_NOW - timedelta(hours=i),
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
matches = recent_matches(db_session, route, limit=3)
|
||||
assert len(matches) == 3
|
||||
assert matches[0]["received_at"] > matches[1]["received_at"]
|
||||
|
||||
|
||||
class TestPreviewRoute:
|
||||
def test_normal_preview(self, db_session):
|
||||
node_a = _make_node(db_session, "aa" + "0" * 62)
|
||||
node_b = _make_node(db_session, "bb" + "0" * 62)
|
||||
|
||||
for i in range(7):
|
||||
_make_reception(db_session, None, f"pkt{i}", ["AA", "BB"])
|
||||
db_session.commit()
|
||||
|
||||
since = _NOW - timedelta(hours=24)
|
||||
result = preview_route(
|
||||
db_session,
|
||||
{
|
||||
"node_ids": [node_a.id, node_b.id],
|
||||
"match_width": 1,
|
||||
"packet_count_threshold": 3,
|
||||
},
|
||||
since,
|
||||
)
|
||||
assert result["matched_count"] == 7
|
||||
assert result["quality"] == RouteQuality.CLEAR.value
|
||||
assert result["truncated"] is False
|
||||
|
||||
def test_truncation_at_cap(self, db_session, monkeypatch):
|
||||
node_a = _make_node(db_session, "aa" + "0" * 62)
|
||||
node_b = _make_node(db_session, "bb" + "0" * 62)
|
||||
|
||||
for i in range(10):
|
||||
_make_reception(db_session, None, f"pkt{i}", ["AA", "BB"])
|
||||
db_session.commit()
|
||||
|
||||
monkeypatch.setattr("meshcore_hub.collector.routes.PREVIEW_CANDIDATE_CAP", 5)
|
||||
since = _NOW - timedelta(hours=24)
|
||||
result = preview_route(
|
||||
db_session,
|
||||
{
|
||||
"node_ids": [node_a.id, node_b.id],
|
||||
"match_width": 1,
|
||||
"packet_count_threshold": 3,
|
||||
},
|
||||
since,
|
||||
)
|
||||
assert result["truncated"] is True
|
||||
assert result["matched_count"] is None
|
||||
assert result["candidate_count"] == 10
|
||||
|
||||
def test_collisions(self, db_session):
|
||||
"""Two nodes sharing the same first byte collide."""
|
||||
_make_node(db_session, "aa11" + "0" * 60)
|
||||
_make_node(db_session, "aa22" + "0" * 60)
|
||||
_make_node(db_session, "bb33" + "0" * 60)
|
||||
db_session.commit()
|
||||
|
||||
counts = prefix_collision_counts(db_session, 1)
|
||||
assert counts.get("AA") == 2
|
||||
assert counts.get("BB") == 1
|
||||
|
||||
def test_detect_observed_widths(self, db_session):
|
||||
node = _make_node(db_session, "aabb" + "0" * 60)
|
||||
_make_reception(db_session, None, "p1", ["AABB"])
|
||||
db_session.commit()
|
||||
|
||||
widths = detect_observed_widths(db_session, node.public_key)
|
||||
assert 2 in widths # observed at 2-byte prefix "AABB"
|
||||
Reference in New Issue
Block a user