feat: route health monitoring with visual path builder

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

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

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

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

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

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