diff --git a/.env.example b/.env.example index e6d5061..fc3310d 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/alembic/versions/20260712_2330_8f2a3c4d5e6f_add_route_health_monitoring.py b/alembic/versions/20260712_2330_8f2a3c4d5e6f_add_route_health_monitoring.py new file mode 100644 index 0000000..0a8aca0 --- /dev/null +++ b/alembic/versions/20260712_2330_8f2a3c4d5e6f_add_route_health_monitoring.py @@ -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") diff --git a/docs/plans/20260705-2306-mesh-link-monitoring/plan.md b/docs/plans/20260705-2306-mesh-link-monitoring/plan.md index 97ad089..e11ec73 100644 --- a/docs/plans/20260705-2306-mesh-link-monitoring/plan.md +++ b/docs/plans/20260705-2306-mesh-link-monitoring/plan.md @@ -1,4 +1,4 @@ -# Mesh Link Monitoring (Route Health) +# Routes (Route Health Monitoring) ## How it works (overview) @@ -12,11 +12,11 @@ to **monitor whether a route you care about is actually working**. **The idea, with an example.** Suppose there are known repeaters near Ipswich and Norwich, and you want to know when traffic stops getting between them. -You'd create a **Link** named "Ipswich ↔ Norwich", pick those two repeater +You'd create a **Route** named "Ipswich ↔ Norwich", pick those two repeater nodes in order, set a window of "last 24 hours" and a threshold of "3 packets". The hub then continuously asks: *in the last 24 hours, did at least 3 distinct packets travel along a path that passed through the Ipswich -repeater and then the Norwich repeater?* If yes, the link is **healthy**; if +repeater and then the Norwich repeater?* If yes, the route is **healthy**; if not, it's **unhealthy** and something along that route may be down. The two nodes don't need to be directly adjacent — a packet counts if it went @@ -24,14 +24,17 @@ Ipswich → …some other repeaters… → Norwich, **in that order**. You can a a midpoint node (say, a Cambridge repeater) to make the route more specific and reduce accidental matches. -**Health, in one line:** a link is healthy when **≥ N distinct packets**, each +**Health, in one line:** a route is healthy when **≥ N distinct packets**, each seen within the time window, each travelled a path that contains your -configured nodes in the right order (gaps allowed). +configured nodes in the right order (gaps allowed). It also reports a +**quality band** — `clear` when comfortably above the threshold, `marginal` +when barely meeting it — so a fading route goes yellow before it goes red +(see F4). **Where the numbers come from.** Each packet's path is a list of short node -identifiers (the first byte or two of each node's public key). Links match on +identifiers (the first byte or two of each node's public key). Routes match on those identifiers. Most traffic on our network today uses 1-byte identifiers, -so links default to matching on that one byte — which catches every packet +so routes default to matching on that one byte — which catches every packet regardless of how detailed its path is, at the cost of occasional collisions (two different nodes sharing a first byte). Several levers keep that manageable: prefer nodes with a rare first byte, add a midpoint node, require @@ -40,42 +43,45 @@ path. **How it runs day-to-day:** -- A small background task in the collector re-evaluates every link once a +- A small background task in the collector re-evaluates every route once a minute and stores the result. -- The web UI shows a list of links with green/red health badges and lets admins - create and edit them. -- Prometheus exposes `meshcore_link_healthy` (0 or 1) and - `meshcore_link_matched_packets` per link, so external alerts can be wired up - (e.g. "page me if Ipswich ↔ Norwich has been unhealthy for 10 minutes"). -- Each link has a visibility level (community / member / operator / admin), so - sensitive links are only shown to the right roles — exactly like channel keys - today. -- Links can be configured in the web UI by admins, **or** loaded from a YAML +- The web UI shows a list of routes with colour-coded health badges and lets + admins create and edit them. +- Prometheus exposes `meshcore_route_healthy` (0 or 1) and + `meshcore_route_matched_packets` per route, so external alerts can be wired + up (e.g. "page me if Ipswich ↔ Norwich has been unhealthy for 10 minutes"). +- Each route has a visibility level (community / member / operator / admin), + so sensitive routes are only shown to the right roles — exactly like channel + keys today. +- Routes can be configured in the web UI by admins, **or** loaded from a YAML file by site operators without logging in (via the existing seed system) — handy for provisioning a fresh instance before any users exist. -**Things to know before configuring one.** Links rely on the hub capturing raw -packets (`FEATURE_PACKETS` on), and on at least one observer hearing enough of -a packet's path to recognise your configured nodes. A link that reads -unhealthy might mean the route is down **or** that no observer is well-placed -to see it — the UI shows which observers contributed so you can tell the two -apart. +**Things to know before configuring one.** Routes rely on the hub capturing +raw packets (`FEATURE_PACKETS` on), and on at least one observer hearing +enough of a packet's path to recognise your configured nodes. A route that +reads unhealthy might mean the route is down **or** that no observer is +well-placed to see it — the UI shows which observers contributed so you can +tell the two apart. ## Summary -A new **Link** entity lets operators define an ordered sequence of mesh nodes -(a route, e.g. an Ipswich repeater → a Norwich repeater) and have the hub -continuously test whether packets are traversing that route. Each link carries -a time window (e.g. 24h) and a packet-count threshold (e.g. 3); when enough -distinct packets whose path contains the configured nodes *in order, with gaps -allowed* are observed within the window, the link is **healthy**. +A new **Route** entity lets operators define an ordered sequence of mesh nodes +(e.g. an Ipswich repeater → a Norwich repeater) and have the hub continuously +test whether packets are traversing that route. Each route carries a time +window (e.g. 24h) and a packet-count threshold (e.g. 3); when enough distinct +packets whose path contains the configured nodes *in order, with gaps allowed* +are observed within the window, the route is **healthy**. A **comfort bar** +(`degraded_threshold`, defaulting to twice the floor) subdivides healthy into +`clear` vs `marginal` so a route trending toward failure is visible before it +breaks. Health is computed by a background evaluator inside the collector (mirroring the existing spam re-scoring sweep) and cached in a results table. The API/UI -read those cached results; `/metrics` exposes `meshcore_link_healthy` and -`meshcore_link_matched_packets` gauges for external Prometheus alerting. The -feature is instance-wide and role-scoped per link (community/member/operator/ -admin), exactly like channels. +read those cached results; `/metrics` exposes `meshcore_route_healthy` and +`meshcore_route_matched_packets` gauges (plus a `meshcore_route_quality` band +gauge) for external Prometheus alerting. The feature is instance-wide and +role-scoped per route (community/member/operator/admin), exactly like channels. ## Background & Motivation @@ -109,19 +115,21 @@ byte is always present regardless of packet width, a 1-byte prefix match catches a node at any width — the trade-off is collision (256 buckets), which the plan mitigates with four independent, composable levers: (1) preferring unique-prefix nodes, (2) configuring 3-node paths for combinatorial -specificity, (3) the count threshold, and (4) an optional per-link **hop-span +specificity, (3) the count threshold, and (4) an optional per-route **hop-span cap** (`max_hop_span`) that rejects matches where the configured nodes' first bytes co-occur far apart on an unrelated long flood path. ## Goals -- Let operators configure ordered multi-node routes ("Links") and have the hub - report whether each is healthy over a configurable window + packet-count - threshold. +- Let operators configure ordered multi-node routes and have the hub report + whether each is healthy over a configurable window + packet-count threshold. +- Report a **quality band** (clear / marginal / failing / unknown), not just a + binary alive/dead, so a degrading route is visible before it crosses the + red floor. - Make matching **performant** regardless of window size via a denormalized hop index populated at ingest, not by scanning/parsing JSON on demand. -- Expose link health to **Prometheus** for external monitoring/alerting, and to - a dedicated admin UI for configuration and human check-in. +- Expose route health to **Prometheus** for external monitoring/alerting, and + to a dedicated admin UI for configuration and human check-in. - Reuse existing patterns (channel-style role-scoped CRUD, spam-style collector sweep, raw-packet dual-path extraction) so the feature is consistent with the codebase. @@ -130,39 +138,43 @@ first bytes co-occur far apart on an unrelated long flood path. ## Non-Goals -- Historical link-health time series (only the latest result is cached in - `link_results`; retention of trend points is future work). +- Historical route-health time series (only the latest result is cached in + `route_results`; retention of trend points is future work). - In-app alert rule authoring — operators write Prometheus alert expressions against the emitted gauges. - Auto-selecting the match width from the observed wire distribution (the width - is an explicit per-link knob; auto-selection is future work). -- Trace-route-specific analysis — Links keys off all packet types via + is an explicit per-route knob; auto-selection is future work). +- Trace-route-specific analysis — Routes key off all packet types via `raw_packets`/the hop table, not the `trace_paths` table. -- Decoupling hop extraction from raw packet capture (Links requires +- Decoupling hop extraction from raw packet capture (Routes require `FEATURE_PACKETS=1`; extraction piggybacks on `store_raw_packet`). ## Requirements ### Functional Requirements -- **F1 — Link configuration.** An operator with the `admin` role can create, - update, and delete Links. Each Link has: a unique name, optional description, - a `visibility` (community/member/operator/admin, default `community`), a `match_width` (1/2/3, - default 1), `window_hours` (default 24, range 1..720), - `packet_count_threshold` (default 3, range 1..10000), `max_hop_span` - (nullable int, default `null` = unlimited), an `enabled` flag (default true), an ordered +- **F1 — Route configuration.** An operator with the `admin` role can create, + update, and delete Routes. Each Route has: a unique name, optional + description, a `visibility` (community/member/operator/admin, default + `community`), a `match_width` (1/2/3, default 1), `window_hours` (default 24, + range 1..720), `packet_count_threshold` (default 3, range 1..10000), + `degraded_threshold` (nullable int, default `null` ⇒ effective comfort bar + of `2 × packet_count_threshold`; when set explicitly it must be `> + packet_count_threshold` — the comfort bar at/above which a healthy route + reads `clear` instead of `marginal`; see F4), `max_hop_span` (nullable int, + default `null` = unlimited), an `enabled` flag (default true), an ordered list of **≥2** path node specs, and an optional observer scope. - **F2 — Path node specs.** Each path entry selects a known Node (from - `nodes`); the system derives `expected_hash = public_key[:2*match_width]` at + `nodes`); the system derives `expected_hash = public_key[:2*match_width].upper()` at save time. Entries are ordered; entries must be **distinct** (the same node - twice in one link is invalid). The subsequence match preserves that order + twice in one route is invalid). The subsequence match preserves that order with gaps allowed (intermediary nodes may sit between configured entries). - `match_width` is **per-link**: an operator who knows the traffic in a given - area is uniformly 2- or 3-byte can widen that link's width to drop from 256 + `match_width` is **per-route**: an operator who knows the traffic in a given + area is uniformly 2- or 3-byte can widen that route's width to drop from 256 to 65 536 / 16 777 216 buckets (far fewer collisions), at the cost of becoming blind to narrower-width traffic — the UI's live "matches in 24h" preview confirms coverage before save. -- **F2b — Optional hop-span cap (collision lever).** A Link may set +- **F2b — Optional hop-span cap (collision lever).** A Route may set `max_hop_span` (nullable, default `null` = unlimited): the maximum number of hops allowed between the first and last configured node in a matched subsequence (`position(last) − position(first) ≤ max_hop_span`). This is the @@ -171,59 +183,85 @@ first bytes co-occur far apart on an unrelated long flood path. the false negatives a total-`path_len` cap would introduce on long packets that contain a short genuine sub-path. It needs no new hop-table column (positions are already stored). -- **F3 — Observer scope.** Each Link selects **all observers** (default) or a +- **F3 — Observer scope.** Each Route selects **all observers** (default) or a specific set of observer nodes. When scoped, only receptions by those observers are considered. -- **F4 — Health semantics.** A Link is **healthy** when the number of +- **F4 — Health semantics.** A Route is **healthy** when the number of **distinct packets** (`packet_hash`) whose path, in at least one observer's reception (within the observer scope, if set), contains the configured ordered subsequence within the window and within `max_hop_span` (if set), is greater than or equal to `packet_count_threshold`. Each evaluation writes a - `link_result` row whose `state` is one of: - - **`healthy`** — `matched_count >= packet_count_threshold`. - - **`unhealthy`** — in-scope observers received packets in the window but - `matched_count < threshold` (route may be down). - - **`no_coverage`** — `matched_count == 0` **and** no in-scope observer - received any packet with a non-empty path in the window (cannot - distinguish route-down from no-listener; the operator action is to - add/widen observers, not assume the route failed). When the scope is "all - observers", `no_coverage` is only reachable when the whole mesh is silent. + `route_result` row carrying two axes: + - **`state`** (the alerting axis) — one of: + - **`healthy`** — `matched_count >= packet_count_threshold`. + - **`unhealthy`** — in-scope observers received packets in the window but + `matched_count < threshold` (route may be down). + - **`no_coverage`** — `matched_count == 0` **and** no in-scope observer + received any packet with a non-empty path in the window (cannot + distinguish route-down from no-listener; the operator action is to + add/widen observers, not assume the route failed). When the scope is + "all observers", `no_coverage` is only reachable when the whole mesh is + silent. + - **`quality`** (the display axis — a traffic-light band derived from + `state` + `matched_count` + the two thresholds) — one of: + - **`clear`** — `state == healthy` **and** `matched_count >= + effective_degraded`: comfortably healthy. + - **`marginal`** — `state == healthy` **and** `matched_count < + effective_degraded`: meets the floor but not the comfort bar — the + route is trending toward failure. + - **`failing`** — `state == unhealthy`. + - **`unknown`** — `state == no_coverage`. + where `effective_degraded = route.degraded_threshold or (2 × + route.packet_count_threshold)` — the relative default means every route + has a band out of the box; an operator only sets `degraded_threshold` + explicitly to widen or tighten the band. - The evaluator separates the latter two with one extra existence check (any - in-scope `packet_path_hops` row in the window). Disabled links are excluded - from evaluation entirely — they produce no `link_result`, are omitted from - Prometheus output, and render a gray **disabled** badge (that badge comes - from `link.enabled`, not a result state). -- **F5 — Visibility scoping.** The link list is filtered by the requesting + The evaluator separates `unhealthy` from `no_coverage` with one extra + existence check (any in-scope `packet_path_hops` row in the window), then + derives `quality`. Disabled routes are excluded from evaluation entirely — + they produce no `route_result`, are omitted from Prometheus output, and + render a gray **disabled** badge (that badge comes from `route.enabled`, + not a result state). +- **F5 — Visibility scoping.** The route list is filtered by the requesting user's role exactly like channels, using `VISIBILITY_LEVELS` from `api/channel_visibility.py`. Reads are role-scoped; writes are admin-only. -- **F6 — UI.** A dedicated `/links` page (see **UI Design** below) behaves as +- **F6 — UI.** A dedicated `/routes` page (see **UI Design** below) behaves as a status board: a health summary strip, cards grouped by visibility with - unhealthy/no_coverage sorted first, a four-state badge (healthy / - unhealthy / no_coverage / disabled), and an inline accordion expand - revealing the diagnosis, contributing observers, the latest matched path, a - config recap, and a deep-link to the packets view. Admin CRUD uses a wider - modal containing a node path-builder and observer picker with prefix- - collision badges, a live "matches in 24h" preview, and a segmented - `match_width` control. Mirrors `channels.js` structure throughout. -- **F7 — Prometheus.** `/metrics` emits `meshcore_link_healthy{link}` (1 if - `state == healthy` else 0), `meshcore_link_state{link}` (0=healthy, - 1=unhealthy, 2=no_coverage — so the amber case is independently alertable: - `state==1` = route may be down, `state==2` = add observers), - `meshcore_link_matched_packets{link}`, and `meshcore_link_threshold{link}` - for **all** enabled links (no visibility filtering on the monitoring feed). -- **F8 — Seeding (no-auth provisioning).** Site operators can load Links from - a YAML file (`$SEED_HOME/links.yaml`) without authenticating, via the + failing/no_coverage/marginal sorted first, a five-state quality badge + (`clear` / `marginal` / `failing` / `no_coverage` / `disabled` — colour map + in UI Design → The route card), and an inline accordion expand revealing + the diagnosis, contributing observers, the latest matched path, a config + recap, and a deep-link to the packets view. Admin CRUD uses a wider modal + containing a node path-builder and observer picker with prefix-collision + badges, a live "matches in 24h" preview, and a segmented `match_width` + control. Mirrors `channels.js` structure throughout. +- **F7 — Prometheus.** `/metrics` emits `meshcore_route_healthy{route}` (1 if + `quality` ∈ {clear, marginal} else 0), `meshcore_route_quality{route}` + (0=clear, 1=marginal, 2=failing, 3=unknown — supersedes the originally- + planned `meshcore_route_state`; gives the `marginal` band its own alertable + value; alert recipes — "not clear" = `quality >= 1`, "page on failure + only" = `quality == 2`, "indeterminate" = `quality == 3` — note `unknown` + =3 carries the highest ordinal but is indeterminate, not more severe than + `failing`), `meshcore_route_matched_packets{route}` (a **lower bound** when + `quality == clear`: the evaluator short-circuits at `effective_degraded`, + so the gauge reports "≥ N" rather than an exact count for comfortably- + healthy routes; exact for `marginal` / `failing` / `unknown`), + `meshcore_route_threshold{route}`, and `meshcore_route_degraded_threshold + {route}` (the effective comfort bar; `2 × threshold` when the route hasn't + set one) for **all** enabled routes (no visibility filtering on the + monitoring feed). +- **F8 — Seeding (no-auth provisioning).** Site operators can load Routes + from a YAML file (`$SEED_HOME/routes.yaml`) without authenticating, via the existing `meshcore-hub seed` command (and the compose `seed` profile). The - file is keyed by link name; each entry holds the link's knobs plus an ordered - `path` of **≥2** node public_keys and, optionally, an `observers` list of - public_keys. The importer resolves each public_key to its node, derives - `expected_hash = public_key[:2*match_width]` itself, and upserts the link - plus its `link_nodes`/`link_observers` children idempotently by name — - mirroring how `channels.yaml` is seeded. `visibility` defaults to - `community` (public); an explicit higher level may be set, since the operator - has filesystem access and links carry no secret (unlike channel keys). - Example shape: + file is keyed by route name; each entry holds the route's knobs plus an + ordered `path` of **≥2** node public_keys and, optionally, an `observers` + list of public_keys. The importer resolves each public_key to its node, + derives `expected_hash = public_key[:2*match_width].upper()` itself, and upserts the + route plus its `route_nodes`/`route_observers` children idempotently by name + — mirroring how `channels.yaml` is seeded. `visibility` defaults to + `community` (public); an explicit higher level may be set, since the + operator has filesystem access and routes carry no secret (unlike channel + keys). Example shape: ```yaml Ipswich ↔ Norwich: description: A140 corridor route @@ -231,6 +269,7 @@ first bytes co-occur far apart on an unrelated long flood path. match_width: 1 # default: 1 (1/2/3) window_hours: 24 packet_count_threshold: 3 + degraded_threshold: 10 # optional; omit/null = 2× threshold (default) max_hop_span: 8 # optional; omit/null = unlimited enabled: true # default: true path: # ordered, ≥2, by public_key @@ -244,20 +283,32 @@ first bytes co-occur far apart on an unrelated long flood path. - **T1 — Denormalized hop index.** A new `packet_path_hops` table stores one row per `(reception, hop position)` with `node_hash`, denormalized - `packet_hash` and `received_at`, populated at ingest inside - `store_raw_packet` (reusing the already-computed normalized `path_hashes`). + `packet_hash`, `received_at`, and `observer_node_id`, populated at ingest + inside `store_raw_packet` (reusing the already-computed normalized + `path_hashes`). The `observer_node_id` denormalization lets observer-scoped + routes (F3) filter directly on the hop table without a join back to + `raw_packets` — consistent with the `packet_hash`/`received_at` + denormalization rationale. - **T2 — Per-reception matching.** The subsequence self-join keys on `raw_packet_id` (one observer's reception), **not** `packet_hash`, so hop positions are never compared across observers' divergent path arrays. Distinct logical packets are deduped via `COUNT(DISTINCT packet_hash)`. -- **T3 — Prefix matching.** Hops match by `node_hash LIKE expected_hash || '%'` - (range-sargable), defaulting to the 1-byte prefix so a node is matched - regardless of the originating packet's width. +- **T3 — Prefix matching.** Hops match by a **range scan** (`node_hash >= + expected_hash AND node_hash < _hex_prefix_end(expected_hash)`), not `LIKE`, + because Postgres with locale collations cannot use a btree index for `LIKE + 'prefix%'` (SQLite auto-optimizes it, but the range form is sargable on + both backends unconditionally). `expected_hash` is **uppercased** at + derivation (`public_key[:2*match_width].upper()`) to match the normalized + (uppercase) `node_hash` column — the `Node` model lowercases `public_key` + (`node.py:45-46`) while `_normalize_hash_list` uppercases path hashes + (`letsmesh_normalizer.py:847`), so without `.upper()` no route would ever + match. Defaulting to the 1-byte prefix catches a node regardless of the + originating packet's width. - **T4 — Background evaluator.** A collector daemon thread, line-for-line modeled on the spam re-scoring sweep (`subscriber.py:545-597`), runs at a configurable interval (default 60s, `0` disables), performs an immediate - first run on startup, and upserts one row per link into `link_results` using - a dialect-specific `on_conflict_do_update` (postgresql + first run on startup, and upserts one row per route into `route_results` + using a dialect-specific `on_conflict_do_update` (postgresql `pg_insert(...).on_conflict_do_update(...)` / sqlite `sqlite_insert(...).on_conflict_do_update(...)`), modeled on the existing dialect branch in `common/models/event_observer.py:143-158`. That branch @@ -270,33 +321,34 @@ first bytes co-occur far apart on an unrelated long flood path. recent) — and `INDEX (raw_packet_id, position)` — serves the per-reception ordered-hop fetch, the FK lookup, and the `ON DELETE CASCADE` (leftmost- prefix covers equality-on-`raw_packet_id`, so no separate FK index). The - denormalized `packet_hash`/`received_at` columns back the distinct count - and the window without a join back to `raw_packets`. + denormalized `packet_hash`/`received_at`/`observer_node_id` columns back + the distinct count, the window, and the observer scope without a join back + to `raw_packets`. - **T6 — Backend-agnostic.** All DDL via Alembic **batch mode** (SQLite-safe); queries use SQLAlchemy Core/ORM with a Python-computed `window_since` datetime (never `NOW() - INTERVAL`). - **T7 — Retention.** `packet_path_hops.raw_packet_id` uses `ON DELETE CASCADE`, so the existing cleanup in `cleanup.py` removes hop rows for free when aged `raw_packets` are deleted; no cleanup change required. - `link_results` and `link_nodes`/`link_observers` cascade-delete with their - parent `links` row (standard FK cascade). -- **T8 — Feature gating.** New `feature_links=True` UI flag and - `link_evaluator_interval_seconds=60` collector knob in `common/config.py`, - surfaced in `.env.example`. Hop extraction only runs when raw packet capture - is enabled (`FEATURE_PACKETS=1`). -- **T9 — Seed loader.** A new `_import_links` helper in `collector/cli.py`, + `route_results` and `route_nodes`/`route_observers` cascade-delete with + their parent `routes` row (standard FK cascade). +- **T8 — Feature gating.** New `feature_routes=True` UI flag and + `route_evaluator_interval_seconds=60` collector knob in `common/config.py`, + surfaced in `.env.example`. Hop extraction only runs when raw packet + capture is enabled (`FEATURE_PACKETS=1`). +- **T9 — Seed loader.** A new `_import_routes` helper in `collector/cli.py`, wired into `_run_seed_import` so the existing `meshcore-hub seed` command - (and the compose `seed` profile) loads `links.yaml` automatically, plus a - `links_file` property on the settings resolving to `$SEED_HOME/links.yaml`. - Upsert is by `name`; on update the `link_nodes` and `link_observers` + (and the compose `seed` profile) loads `routes.yaml` automatically, plus a + `routes_file` property on the settings resolving to `$SEED_HOME/routes.yaml`. + Upsert is by `name`; on update the `route_nodes` and `route_observers` children are replaced wholesale. Path and observer entries are resolved by `public_key` — a missing **path** node is a hard error (the route can't be tested against a node the hub has never seen); a missing **observer** is - skipped with a warning. `expected_hash` is computed by the importer, never - hand-typed. Returns the `{created, updated, errors}` shape already used by + skipped with a warning. `expected_hash` is computed by the importer (uppercased to match the + normalized `node_hash` column), never hand-typed. Returns the `{created, updated, errors}` shape already used by the channel and tag seeders. `visibility` defaults to `community`; an - explicit value is honored, since the operator has filesystem access and links - carry no secret (unlike channel keys). + explicit value is honored, since the operator has filesystem access and + routes carry no secret (unlike channel keys). ## UI Design @@ -304,36 +356,45 @@ Decisions captured during plan review. The build lives in Phase 7; this section is the single source of truth for the design. ### Mental model: status board, not catalog -Channels is a catalog (CRUD list of static keys). Links is a **status board** +Channels is a catalog (CRUD list of static keys). Routes is a **status board** — the page's primary job is glancing at health; configuration is secondary admin work. Every layout choice below follows from that. -### List page (`/links`) -- **Summary strip** at the top: `● N healthy · ● M unhealthy · ◐ K no - coverage · ◌ D disabled` (live counts from the embedded results). +### List page (`/routes`) +- **Summary strip** at the top: `● N clear · ● M marginal · ● U failing · ◐ K + no coverage · ◌ D disabled` (live counts from the embedded `quality` + values). - **Cards grouped by visibility** (`VISIBILITY_ORDER`, like channels), but - within each group **sorted unhealthy / no_coverage first** so broken routes - surface immediately. -- Header + admin "Add link" button + empty state mirror `channels.js`. + within each group **sorted failing / no_coverage / marginal first** so + broken or at-risk routes surface immediately. +- Header + admin "Add route" button + empty state mirror `channels.js`. -### The link card -- **Health badge** — four states, color-coded: `healthy` (green ●), - `unhealthy` (red ●), `no_coverage` (amber ◐), `disabled` (gray ◌, from - `link.enabled`). +### The route card +- **Quality badge** — five states using daisyUI semantic classes: `clear` + (green ● `badge-success`), `marginal` (amber ● `badge-warning`), `failing` + (red ● `badge-error`, was `unhealthy`), `no_coverage` (blue ◐ `badge-info` + — indeterminate, **not** a warning), and `disabled` (gray ◌ `badge-neutral`, + from `route.enabled`). Recolouring `no_coverage` from amber to blue removes + the original plan's clash between "watch this" (marginal) and "can't tell" + (no_coverage). - **Path chips** — the configured nodes as `[Ipswich RP] → … → [Norwich RP]`, conveying "ordered, gaps allowed". -- **Numbers line** — `matched / threshold · window · evaluated Xm ago`. +- **Numbers line** — `matched / threshold [→ degraded] · window · quality · + evaluated Xm ago`; the `[→ degraded]` target is the result's snapshot + `effective_degraded` (`2 × threshold` when the route hasn't set one). - Admin edit/delete buttons (channels pattern). - **Click → inline accordion expand** (not a modal, not a separate page). -### Card expand contents (lazy `GET /api/v1/links/{id}`) -1. **Diagnosis line** — turns the amber/red split into a sentence. +### Card expand contents (lazy `GET /api/v1/routes/{id}`) +1. **Diagnosis line** — turns the blue/amber/red split (`no_coverage` / + `marginal` / `failing`) into a sentence. 2. **Contributing observers with counts** — an empty list *is* the `no_coverage` signal. 3. **Latest match (~3) with the observed path** — configured nodes marked ✓, intermediates shown, so the operator can verify a real match vs a - collision. Served by a new `recent_matches(link, limit)` engine helper. -4. **Config recap** (read-only) — width, span, window, observer scope. + collision. Served by a new `recent_matches(route, limit)` engine helper. +4. **Config recap** (read-only) — width, span, window, observer scope, + thresholds. 5. **"View packets" deep-link** — to the existing packet-groups page filtered to matched hashes + window (reuses built UI; no new packet browser). @@ -351,39 +412,51 @@ admin work. Every layout choice below follows from that. amber). Warn on mixed-width intent (a node never observed at the chosen width) and suggest adding a 3rd node when collisions appear. - **Observers** multi-picker (same component; empty = all observers). -- `window_hours`, `packet_count_threshold`, `max_hop_span` (empty = - unlimited) numeric fields. -- **Live "matches in 24h" preview** — debounced `POST /api/v1/links/preview` - as the path / width / observers change; shows `~N matches in 24h` plus the - per-node collision counts used by the chips. +- `window_hours`, `packet_count_threshold`, `degraded_threshold` (empty = 2× + threshold default; placeholder hints "leave blank for 2× threshold"), + `max_hop_span` (empty = unlimited) numeric fields. +- **Live "matches in 24h" preview** — debounced `POST /api/v1/routes/preview` + as the path / width / observers / thresholds change; shows `~N matches in + 24h → quality: clear/marginal/failing` plus the per-node collision counts + used by the chips. ### Data split (list vs detail vs preview) -- `GET /api/v1/links` embeds the **lightweight** result per card: `state`, - `matched_count`, `threshold`, `evaluated_at`. Keeps the list payload small. -- `GET /api/v1/links/{id}` returns the **full detail**: the lightweight result - plus contributing observers (with counts) and the latest ~3 matched paths. - Fetched lazily on first expand and cached in page state. -- `POST /api/v1/links/preview` (unsaved config → `{matched_count, +- `GET /api/v1/routes` embeds the **lightweight** result per card: `state`, + `quality`, `matched_count`, `threshold`, `effective_degraded`, + `evaluated_at`. Keeps the list + payload small. +- `GET /api/v1/routes/{id}` returns the **full detail**: the lightweight + result plus contributing observers (with counts) and the latest ~3 matched + paths. Fetched lazily on first expand and cached in page state. +- `POST /api/v1/routes/preview` (unsaved config → `{matched_count, quality, contributing_observers, collisions}`) powers the live preview + chip badges. ### i18n -- Feature strings under a new top-level **`mesh_links.*`** block (the existing - `links.*` block holds footer labels and must not be reused); nav label under - `entities.links`. See Phase 7. +- Feature strings under a new top-level **`routes.*`** block; nav label under + `entities.routes` (value "Routes"). The `routes.*` token is collision-free. + See Phase 7. ## Implementation Plan ### Phase 1: Data model + migration + backfill - Add models in `src/meshcore_hub/common/models/`: `packet_path_hop.py`, - `link.py` (with `LinkVisibility` mirroring the channel enum, plus config - columns `match_width` and nullable `max_hop_span`), `link_node.py`, - `link_observer.py`, `link_result.py`. Export all from `models/__init__.py`. - `link_result` carries: `link_id` (FK `links.id`, `ondelete=CASCADE`, unique - — one row per link), `state` (enum `healthy` / `unhealthy` / - `no_coverage`), `matched_count` (int), `threshold` (int, snapshot at eval - time for stable reporting), `evaluated_at` (datetime). Per-observer - breakdown and recent matched paths are **not** stored — they are computed - on demand by `GET /api/v1/links/{id}` (see UI Design → Data split). + `route.py` (with `RouteVisibility` mirroring the channel enum, plus config + columns `match_width`, nullable `max_hop_span`, and nullable + `degraded_threshold`), `route_node.py`, `route_observer.py`, + `route_result.py`. Export all from `models/__init__.py`. `route_result` + carries: `route_id` (FK `routes.id`, `ondelete=CASCADE`, unique — one row + per route), `state` (enum `healthy` / `unhealthy` / `no_coverage` — the + alerting axis), `quality` (enum `clear` / `marginal` / `failing` / + `unknown` — the display axis, derived from `state` + `matched_count` + the + route's thresholds at eval time and denormalized here so the list endpoint + need not recompute), `matched_count` (int), `threshold` (int, snapshot at + eval time for stable reporting), `effective_degraded` (int, snapshot of + `effective_degraded_threshold(route)` at eval time — the comfort bar used + for this result, so the `[→ degraded]` display and the `quality` band stay + self-consistent if the operator later changes thresholds), `evaluated_at` + (datetime). Per-observer breakdown and recent matched paths are **not** + stored — they are computed + on demand by `GET /api/v1/routes/{id}` (see UI Design → Data split). - One Alembic revision (batch mode) creating the five tables + indexes. - Backfill `packet_path_hops` from `raw_packets.decoded`, keyset-paginated (batch 1000), reusing the **frozen dual-path extraction** copied from @@ -391,8 +464,8 @@ admin work. Every layout choice below follows from that. `payload.decoded.pathHashes` fallback), which yields a list of `node_hash` strings ordered origin-to-observer. The backfill enumerates this list (index = `position`) and emits one `PacketPathHop` row per - `(position, node_hash)` with `packet_hash`/`received_at` denormalized from - the source `raw_packet` row. + `(position, node_hash)` with `packet_hash`/`received_at`/`observer_node_id` + denormalized from the source `raw_packet` row. ### Phase 2: Ingest hook + tests - In `collector/handlers/raw_packet.py::store_raw_packet`, the normalized @@ -404,16 +477,17 @@ admin work. Every layout choice below follows from that. `raw_packet = RawPacket(...); session.add(raw_packet); session.flush()` so `raw_packet.id` is materialized, then bulk-insert one `PacketPathHop` per `(position, node_hash)` from `path_hashes`, denormalizing - `packet_hash`/`received_at` from the same values. Zero extra decode; gated + `packet_hash`/`received_at`/`observer_node_id` from the same values + (`observer_node_id` is already in scope as `observer_node.id`). Zero extra decode; gated by the existing raw-capture flag (the caller, - `Subscriber._maybe_capture_raw_packet`, already checks + `Subscriber._perhaps_capture_raw_packet`, already checks `self._raw_packet_capture_enabled`). - Extend `tests/test_collector/test_handlers/test_raw_packet.py` to assert hops are inserted with correct positions/hashes and skipped when the path is absent. ### Phase 3: Matching engine (pure, DB-tested) -- New `collector/links.py` using a **fetch-and-check** strategy (not an N-way +- New `collector/routes.py` using a **fetch-and-check** strategy (not an N-way self-join). Rationale: the default 1-byte match width produces broad first- prefix candidate sets where a self-join's cost scales with (candidates × depth); fetch-and-check scales with (candidates) only, on per-reception @@ -421,143 +495,191 @@ admin work. Every layout choice below follows from that. - `fetch_candidate_paths(db, first_prefix, since, observer_ids=None, limit=None)`: one statement — `SELECT raw_packet_id, position, node_hash, packet_hash FROM packet_path_hops WHERE raw_packet_id IN (SELECT - raw_packet_id FROM packet_path_hops WHERE node_hash LIKE :p0 || '%' AND - received_at >= :since [...observer filter]) ORDER BY raw_packet_id, - position` — returns grouped ordered hop arrays. A subquery (not a client - `IN`-list) avoids the `SQLITE_MAX_VARIABLE_NUMBER` ceiling. + raw_packet_id FROM packet_path_hops WHERE node_hash >= :prefix AND + node_hash < :prefix_end AND received_at >= :since [AND observer_node_id + IN (:obs)]) ORDER BY raw_packet_id, position` — returns grouped ordered + hop arrays. The prefix range (`>= :prefix AND < :prefix_end`) is + sargable on both backends; the observer filter is a direct column + condition (no join) thanks to T1's denormalization. A subquery (not a + client `IN`-list) avoids the `SQLITE_MAX_VARIABLE_NUMBER` ceiling. - `is_subsequence(path, expected, max_hop_span=None)`: pure two-pointer prefix match (`node_hash.startswith(expected_hash)`), gaps allowed, `position(last) − position(first) <= max_hop_span` when set. ~8 lines, unit-trivial. - - `evaluate_link(db, link, since)`: fetch candidates for the link's first - node prefix, run `is_subsequence` per reception, count **distinct** + - `DEGRADED_DEFAULT_MULTIPLIER = 2` (module constant) — the relative + default used when a route leaves `degraded_threshold` null. + - `effective_degraded_threshold(route)`: returns `route.degraded_threshold + or (route.packet_count_threshold * DEGRADED_DEFAULT_MULTIPLIER)`. + Centralises the relative default for the evaluator, preview, metrics, and + UI. + - `derive_quality(state, matched_count, threshold, effective_degraded)`: + pure mapping implementing F4's `quality` axis (clear / marginal / failing + / unknown). Unit-trivial. + - `evaluate_route(db, route, since)`: fetch candidates for the route's + first node prefix, run `is_subsequence` per reception, count **distinct** `packet_hash`, and **short-circuit as soon as the count reaches - `packet_count_threshold`** (the evaluator only needs the threshold - crossing, not an exact count → `healthy` early-exit). Below threshold, run - **one existence check** (any in-scope `packet_path_hops` row in the - window) to choose `unhealthy` vs `no_coverage` per F4. Returns `(state, - matched_count)` (`matched_count` is `>= threshold` when short-circuited). - - `evaluate_all_links`: iterates enabled links, calls `evaluate_link`. - - `recent_matches(db, link, limit=3)`: same fetch + subsequence check, + `effective_degraded_threshold(route)`** (the comfort bar — always ≥ the + floor, so clearing it classifies `clear` vs `marginal` in one pass; a + `healthy` early-exit). Below `packet_count_threshold`, run **one existence + check** (any in-scope `packet_path_hops` row in the window) to choose + `unhealthy` vs `no_coverage` per F4. Finally derive `quality` via + `derive_quality(..., effective_degraded)`. Returns `(state, quality, + matched_count)` (`matched_count` is `>= effective_degraded` when + short-circuited). + - `evaluate_all_routes`: iterates enabled routes, calls `evaluate_route`. + - `recent_matches(db, route, limit=3)`: same fetch + subsequence check, returns the latest `limit` matching paths (positions/hashes) for the card expand's ✓-marked path view. - - `preview_link(db, config, since)`: accepts an **unsaved** config (path - nodes by `node_id`, width, observers, span) and returns `{matched_count, - contributing_observers, collisions}`. Applies the **candidate cap**: - if `fetch_candidate_paths` exceeds the cap (default 5000), stops and - returns `{matched_count: null, truncated: true, candidate_count}` so no - preview call does unbounded work (see Phase 4). - - Helpers: `derive_expected_hash`, `detect_observed_width`, - `prefix_collision_counts` (`GROUP BY lower(public_key[:2])`). -- `tests/test_collector/test_links.py`: subsequence (gaps allowed, order + - `preview_route(db, config, since)`: accepts an **unsaved** config (path + nodes by `node_id`, width, observers, span, `packet_count_threshold`, + nullable `degraded_threshold`) and returns `{matched_count, quality, + contributing_observers, collisions}`. Resolves `effective_degraded` from + the config (null ⇒ `2 × threshold`) before deriving `quality`. Applies the + **candidate cap**: if `fetch_candidate_paths` exceeds the cap (default + 5000), stops and returns `{matched_count: null, quality: null, truncated: + true, candidate_count}` so no preview call does unbounded work (see Phase + 4). + - Helpers: `derive_expected_hash` (uppercases the public-key prefix to + match the normalized `node_hash` column), `_hex_prefix_end(prefix)` + (exclusive upper bound for the range scan — increments the last hex + char, ~2 lines), `detect_observed_width`, `prefix_collision_counts` + (`GROUP BY upper(public_key[:2*match_width])`). +- `tests/test_collector/test_routes.py`: subsequence (gaps allowed, order enforced, span cap), **per-reception isolation** (no cross-observer splice), multi-observer dedup to distinct packets, observer-scope filter, - **threshold short-circuit**, **`no_coverage` vs `unhealthy` separation**, - **`recent_matches` ordering/limit**, and **preview truncation**. + **threshold short-circuit** (at the floor when no band, at + `effective_degraded` otherwise), **`no_coverage` vs `unhealthy` + separation**, **quality-band derivation** (`clear` / `marginal` / `failing` + / `unknown` incl. the null ⇒ `2 × threshold` relative default), ** + `recent_matches` ordering/limit**, and **preview truncation**. ### Phase 4: CRUD API + schemas -- New `api/routes/links.py` + `common/schemas/links.py` mirroring +- New `api/routes/routes.py` + `common/schemas/routes.py` mirroring `api/routes/channels.py` (which uses `@cached` from `api/cache.py`, `RequireRead`/`RequireAdmin` from `api/auth.py`, and `DbSession` from - `api/dependencies.py`): `GET /api/v1/links` (RequireRead, role-filtered, - `@cached`, embeds current `link_result`); `POST /api/v1/links` - (collection-level, like channels) and `GET/PUT/DELETE /api/v1/links/{id}` - (RequireAdmin writes; ≥2 **distinct** `link_nodes` validated in Pydantic; - `expected_hash` auto-derived from `node_id` when omitted, and re-derived for - all path nodes when `match_width` changes; observer set managed inline). - Note: channels has no single-resource `GET`; links adds one to serve the - embedded result. Register router in `api/app.py`. -- `POST /api/v1/links/preview` (RequireRead — any authenticated user may - preview; it computes no saved state): accepts an unsaved link config (path - `node_id`s, `match_width`, observers, `max_hop_span`, `window_hours`) and - returns `{matched_count, contributing_observers, collisions}` by delegating - to `collector.links.preview_link`. Not cached (inputs are arbitrary). - `preview_link` applies a **candidate cap** (default 5000): on overflow it - returns `{matched_count: null, truncated: true, candidate_count}` and the - UI shows "~many — narrow your path to preview", bounding every call to one - scan. The client debounces (~400ms) and cancels in-flight via - `AbortController` (the `signal` pattern) so typing never stacks calls. -- `GET /api/v1/links/{id}` (RequireRead, role-scoped) returns the **full + `api/dependencies.py`): `GET /api/v1/routes` (RequireRead, role-filtered, + `@cached`, embeds current `route_result`); `POST /api/v1/routes` + (collection-level, like channels) and `GET/PUT/DELETE /api/v1/routes/{id}` + (RequireAdmin writes; ≥2 **distinct** `route_nodes` validated in Pydantic, + and `degraded_threshold` either null or `> packet_count_threshold` (null ⇒ + `2 × threshold` default); `expected_hash` auto-derived (uppercased) from + `node_id` when omitted, and re-derived for all path nodes when `match_width` + changes; observer set managed inline). Note: channels has no single-resource + `GET`; routes adds one to serve the embedded result. Register router in + `api/routes/__init__.py` (import `router as routes_router` + + `api_router.include_router(routes_router, prefix="/routes", tags=["Routes"])`); + `api/app.py:183` mounts the aggregate `api_router`. +- `POST /api/v1/routes/preview` (RequireRead — any authenticated user may + preview; it computes no saved state): accepts an unsaved route config (path + `node_id`s, `match_width`, observers, `max_hop_span`, `window_hours`, + `packet_count_threshold`, `degraded_threshold`) and returns + `{matched_count, quality, contributing_observers, collisions}` by + delegating to `collector.routes.preview_route`. Not cached (inputs are + arbitrary). `preview_route` applies a **candidate cap** (default 5000): on + overflow it returns `{matched_count: null, quality: null, truncated: true, + candidate_count}` and the UI shows "~many — narrow your path to preview", + bounding every call to one scan. The client debounces (~400ms) and cancels + in-flight via `AbortController` (the `signal` pattern) so typing never + stacks calls. +- `GET /api/v1/routes/{id}` (RequireRead, role-scoped) returns the **full detail** per UI Design → Data split: the lightweight result plus contributing observers (with counts) and the latest ~3 matched paths (via - `recent_matches`). The list `GET /api/v1/links` embeds only the lightweight - result (`state`, `matched_count`, `threshold`, `evaluated_at`). -- `tests/test_api/test_links.py`: CRUD, role-scoping, visibility filter, - min-2-nodes rejection, result embedding, **the preview endpoint**, and the - **`GET /{id}` detail shape** (observers + recent paths). + `recent_matches`). The list `GET /api/v1/routes` embeds only the + lightweight result (`state`, `quality`, `matched_count`, `threshold`, + `effective_degraded`, `evaluated_at`). +- `tests/test_api/test_routes.py`: CRUD, role-scoping, visibility filter, + min-2-nodes rejection, `degraded_threshold` validation (null or `>` + threshold), result embedding, **the preview endpoint**, and the **`GET + /{id}` detail shape** (observers + recent paths). ### Phase 5: Evaluator thread -- New `collector/link_evaluator.py` wrapping `collector/links.py`. - `evaluate_all_links` iterates only enabled links. In - `collector/subscriber.py`, add `_start_link_evaluator_scheduler` / - `_stop_link_evaluator_scheduler` (copy of the spam sweep), started in +- New `collector/route_evaluator.py` wrapping `collector/routes.py`. + `evaluate_all_routes` iterates only enabled routes. In + `collector/subscriber.py`, add `_start_route_evaluator_scheduler` / + `_stop_route_evaluator_scheduler` (copy of the spam sweep), started in `start()` (after the spam scheduler at ~line 669) and stopped in `stop()` (after the spam stop at ~line 710); thread attr near line 114. Immediate first run on startup, 60s loop, dialect upsert, per-iteration error logging. -- `tests/test_collector/test_link_evaluator.py`: upsert idempotency, +- `tests/test_collector/test_route_evaluator.py`: upsert idempotency, immediate-first-run, disabled when interval is 0. ### Phase 6: Prometheus -- In `api/metrics.py::collect_metrics`, read `link_results ⋈ links` and emit - `meshcore_link_healthy`, `meshcore_link_state`, `meshcore_link_matched_packets`, - and `meshcore_link_threshold`, labelled by link name. Verify in - `tests/test_api/test_metrics.py`. +- In `api/metrics.py::collect_metrics`, read `route_results ⋈ routes` and + emit `meshcore_route_healthy` (1 if `quality` ∈ {clear, marginal} else 0), + `meshcore_route_quality` (0=clear, 1=marginal, 2=failing, 3=unknown — + supersedes the originally-planned `meshcore_route_state`, giving the + `marginal` band its own alertable value), `meshcore_route_matched_packets` + (a lower bound when `quality == clear` — the evaluator short-circuits at + `effective_degraded`; exact otherwise), `meshcore_route_threshold`, and + `meshcore_route_degraded_threshold` (the effective comfort bar; `2 × + threshold` when the route hasn't set one), labelled by route name. Verify + in `tests/test_api/test_metrics.py`. ### Phase 7: Web UI + i18n - Build per the **UI Design** section above. New - `src/meshcore_hub/web/static/js/spa/pages/links.js` (mirror `channels.js`): - summary strip + visibility-grouped cards sorted unhealthy/no_coverage first; - four-state health badge; **inline accordion expand** (toggle `expandedId` in - page state, lazy `GET /api/v1/links/{id}` on first expand, cached) showing - diagnosis / contributing observers / latest matched path (✓ markers via + `src/meshcore_hub/web/static/js/spa/pages/routes.js` (mirror `channels.js`): + summary strip + visibility-grouped cards sorted + failing/no_coverage/marginal first; **five-state quality badge** (see UI + Design → The route card for the daisyUI colour map); **inline accordion + expand** (toggle `expandedId` in page state, lazy + `GET /api/v1/routes/{id}` on first expand, cached) showing diagnosis / + contributing observers / latest matched path (✓ markers via `recent_matches`) / config recap / "View packets" deep-link; wider (`modal-box-lg`) add/edit modal with the shared node path-builder + - observer picker, segmented `match_width` control, and a debounced - `POST /api/v1/links/preview` driving the live "matches in 24h" readout and - collision badges. + observer picker, segmented `match_width` control, a `degraded_threshold` + numeric field (empty ⇒ `2 × threshold` default), and a debounced + `POST /api/v1/routes/preview` driving the live "matches in 24h → quality" + readout and collision badges. - Register route in `src/meshcore_hub/web/static/js/spa/app.js`: add - `links: () => import('./pages/links.js')` to the `pages` lazy-load map - (~line 27); add a `if (features.links !== false) { router.addRoute('/links', - pageHandler(pages.links)); }` block (~line 92, next to the channels guard); - add a `composePageTitle('entities.links')` title entry (~line 178). Add a + `routes: () => import('./pages/routes.js')` to the `pages` lazy-load map + (~line 27); add a `if (features.routes !== false) { router.addRoute('/routes', + pageHandler(pages.routes)); }` block (~line 92, next to the channels guard); + add a `composePageTitle('entities.routes')` title entry (~line 178). Add a nav card in `src/meshcore_hub/web/static/js/spa/pages/home.js` (~line 99) among the existing `renderNavCard` blocks in `renderHeroSection`. -- Add `entities.links` plus a new **`mesh_links.*`** top-level block to - `src/meshcore_hub/web/static/locales/en.json` and `nl.json`. The existing - top-level `links` block (`en.json:121-127`) holds footer labels - (website/github/discord/youtube/profile) and must **not** be reused — hence - `mesh_links.*` for the feature namespace. +- Add `entities.routes` (value "Routes") plus a new **`routes.*`** top-level + block (incl. the four quality-label strings: `quality_clear`, + `quality_marginal`, `quality_failing`, `quality_unknown`) to + `src/meshcore_hub/web/static/locales/en.json` and `nl.json`. ### Phase 8: Config + seed loader + docs -- Add `feature_links=True` and `link_evaluator_interval_seconds=60` to +- Add `feature_routes=True` and `route_evaluator_interval_seconds=60` to `common/config.py` (as `feature_*` `Field(...)` declarations in the - ~569-602 block), and a `"links": self.feature_links` entry in the `features` - property's returned dict (dict body at ~lines 622-634); add a `links_file` - property mirroring the existing `channels_file` property at + ~569-602 block), and a `"routes": self.feature_routes` entry in the + `features` property's returned dict (dict body at ~lines 622-634); add a + `routes_file` property mirroring the existing `channels_file` property at `config.py:367-372` (resolves to `Path(self.effective_seed_home) / - "links.yaml"`); update `.env.example`. -- New `_import_links` in `collector/cli.py`, wired into `_run_seed_import` so - `meshcore-hub seed` and the compose `seed` profile pick up `links.yaml` + "routes.yaml"`); update `.env.example`. +- New `_import_routes` in `collector/cli.py`, wired into `_run_seed_import` + so `meshcore-hub seed` and the compose `seed` profile pick up `routes.yaml` automatically. Idempotent upsert by `name`; resolves path/observer nodes by - `public_key`; derives `expected_hash`; replaces `link_nodes`/`link_observers` - on update; honors seeded `visibility`; returns `{created, updated, errors}`. -- Add `example/seed/links.yaml` documenting the format (mirrors the example in - F8), alongside the existing `example/seed/channels.yaml`. + `public_key`; derives `expected_hash` (uppercased to match the + normalized `node_hash` column); replaces `route_nodes`/ + `route_observers` on update; honors seeded `visibility` and + `degraded_threshold` (null ⇒ `2 × threshold` default); returns + `{created, updated, errors}`. +- Add `example/seed/routes.yaml` documenting the format (mirrors the example + in F8), alongside the existing `example/seed/channels.yaml`. - Document in `SCHEMAS.md`, `README.md`, and cross-reference from - `docs/seeding.md` and `docs/letsmesh.md`. Optional `meshcore-hub links + `docs/seeding.md` and `docs/letsmesh.md`. Optional `meshcore-hub routes list|delete` CLI (create/edit stays in the UI or seed). ### Phase 9: Consolidate packet-detail path read onto the hop table - Opportunistic consolidation riding on the populated `packet_path_hops` table. In `api/routes/packet_groups.py::get_packet_group`, replace the - per-reception `_extract_path_hashes(packet.decoded)` call (~line 292) with a - batched `SELECT raw_packet_id, position, node_hash FROM packet_path_hops + per-reception `_extract_path_hashes(packet.decoded)` call (~line 292) with + a batched `SELECT raw_packet_id, position, node_hash FROM packet_path_hops WHERE raw_packet_id IN (:ids) ORDER BY raw_packet_id, position` (one query for the whole reception set), grouped into the same - `receptions[i].path_hashes` shape. **Zero client-visible payload change** — - `PacketReceptionInfo.path_hashes` stays `Optional[list[str]]`; the only - renderer (`packet-group-detail.js`) is untouched. + `receptions[i].path_hashes` shape. **Near-zero payload change** — + `PacketReceptionInfo.path_hashes` stays `Optional[list[str]]` and the only + renderer (`packet-group-detail.js`) is untouched, but hash values shift + from **raw to normalized (uppercased)** because the hop table stores + `_normalize_hash_list` output (`.upper()`) while `_extract_path_hashes` + returns raw `decoded.path` (possibly lowercase). Cosmetically minor (hex + case) but technically a payload change; the parity test should assert + `upper()` equality, not byte-identity. - Delete `_extract_path_hashes` (lines 36-51) — it becomes a dead third copy of the dual-path extraction (the live normalizer in `letsmesh_normalizer.py` and the frozen copy in migration `20260703_2250` remain the canonical @@ -568,12 +690,12 @@ admin work. Every layout choice below follows from that. has its hops populated). For a row that somehow lacks hops, fall back to an empty list (the renderer already handles a missing/empty path). - `tests/test_api/test_packet_groups.py`: assert the detail endpoint returns - identical `path_hashes` per reception after the swap (golden-path parity), - including multi-observer divergence and packets with no path. + `path_hashes` per reception after the swap, uppercased to match the + normalized hop-table values (golden-path parity up to case), including multi-observer divergence and packets with no path. ## Enabled Future Capabilities -The `packet_path_hops` index is built for Link matching, but it unlocks +The `packet_path_hops` index is built for Route matching, but it unlocks packet-exploration features that are **impossible today** (each would require a full-table JSON scan). This plan does **not** build them; they are noted here as follow-on work, each likely its own plan: @@ -599,16 +721,14 @@ reading from the hop table). The filtering/stats features above are deferred. node selection, 3-node paths, and the count threshold, but the acceptable false-healthy rate for the operator's alerting needs confirming once live data is available. -- **Cap on configured nodes per link (join depth).** Subsequence join depth - equals the number of configured nodes per link (distinct from `max_hop_span`, - which caps the gap between endpoints). The plan proposes capping `link_nodes` - at ~8; confirm this is comfortable for the realistic longest route. +- **Cap on configured nodes per route (UX, not perf).** With the fetch-and- + check strategy there is no N-way self-join — configured-node depth only + affects the trivial `is_subsequence` two-pointer pass, so even 10+ nodes is + cheap. The proposed cap of ~8 is therefore a **UX** limit (path-builder + chip clutter), enforced as a soft Pydantic/UI cap, not a performance guard. - **Observer coverage guidance.** Whether the UI should proactively recommend - adding observers when a link reads unhealthy with zero contributing + adding observers when a route reads unhealthy with zero contributing observers (route-dead vs no-coverage disambiguation). -- **Naming confirmation.** "Link" was chosen over "Route"/"Mesh Link"; table - `links`. Confirm before migration is authored, since renaming later is - costly. ## References @@ -619,9 +739,9 @@ reading from the hop table). The filtering/stats features above are deferred. sweep pattern, path semantics (origin-side shared, receiver-side divergent), and the `(prefix, received_at)` indexed-count technique this plan adapts. - `docs/plans/20260612-2014-raw-packets-feature/plan.md` — `raw_packets` table - and `FEATURE_PACKETS` gating that Links piggybacks on. + and `FEATURE_PACKETS` gating that Routes piggybacks on. - `docs/plans/20260519-2051-channel-model-db-decrypt/plan.md` — `Channel` - model + `ChannelVisibility` role-scoping pattern copied for `links`. + model + `ChannelVisibility` role-scoping pattern copied for `routes`. - Key sources: `collector/handlers/raw_packet.py`, `collector/subscriber.py` (spam sweep at lines 545-597), `api/routes/channels.py`, `api/metrics.py`, `meshcoredecoder` (`decoder/packet_decoder.py:155-160, @@ -633,29 +753,31 @@ reading from the hop table). The filtering/stats features above are deferred. ## Review -**Status**: Approved with Changes +**Status**: Approved -**Reviewed**: 2026-07-06 +**Reviewed**: 2026-07-12 (second pass; first pass 2026-07-06) ### Resolutions **Conflicts** — None. Cross-checked against all 45 plans under `docs/plans/`, all 7 source files the plan modifies, and `git log --oneline -20` on `main`. -No existing `packet_path_hops` table, `Link` model, or `/api/v1/links`| -`/api/v1/routes` endpoint exists. The plan builds on (not duplicates) -spam-detection (`20260622-2243`), path-hash-bytes-filter (`20260703-2338`), +No existing `packet_path_hops` table, `Route` model, or `/api/v1/routes` +endpoint exists. The plan builds on (not duplicates) spam-detection +(`20260622-2243`), path-hash-bytes-filter (`20260703-2338`), raw-packets-feature (`20260612-2014`), and channel-model-db-decrypt (`20260519-2051`); their cited commits (`c029eae`, `0300609`, `f845830`) all landed on `main`. **First-pass resolutions (content):** - **F1 — Defaults**: `window_hours` defaults to 24 (range 1..720), - `packet_count_threshold` to 3 (range 1..10000); `enabled` defaults `true`. + `packet_count_threshold` to 3 (range 1..10000); `degraded_threshold` + defaults null ⇒ effective `2 × packet_count_threshold`; `enabled` defaults + `true`. - **F2 — Duplicate path nodes**: entries must be distinct; validated in Pydantic. -- **F4 — Disabled links**: excluded from evaluation, produce no `link_result`, - omitted from Prometheus. -- **T7 — Cascade completeness**: `link_results`, `link_nodes`, - `link_observers` all cascade-delete with their parent `links` row. +- **F4 — Disabled routes**: excluded from evaluation, produce no + `route_result`, omitted from Prometheus. +- **T7 — Cascade completeness**: `route_results`, `route_nodes`, + `route_observers` all cascade-delete with their parent `routes` row. - **Phase 1 — Backfill enumeration**: frozen extraction yields an ordered list; backfill enumerates index = `position`. @@ -677,36 +799,172 @@ landed on `main`. `src/meshcore_hub/web/`. All `web/...` citations corrected. - **F5 — `VISIBILITY_LEVELS` location.** It lives in `api/channel_visibility.py:13`, not `models/channel.py`; import path added. -- **Phase 4 — Route shape clarified.** Channels has no single-resource `GET` - and its POST is collection-level (`""`); links' POST mirrors that, and links - adds a `GET /{id}` to serve the embedded result. `@cached`/`RequireRead`/ - `RequireAdmin`/`DbSession` import paths confirmed. +- **Phase 4 — Endpoint shape clarified.** Channels has no single-resource + `GET` and its POST is collection-level (`""`); routes' POST mirrors that, + and routes adds a `GET /{id}` to serve the embedded result. `@cached`/ + `RequireRead`/`RequireAdmin`/`DbSession` import paths confirmed. - **Phase 8 — Config locations tightened.** Feature-flag `Field(...)` decls live at ~569-602; the `features` property's dict body is at ~622-634 (not - ~611); the `links_file` property mirrors `channels_file` at + ~611); the `routes_file` property mirrors `channels_file` at `config.py:367-372`. - **Phase 5 — Line numbers confirmed.** Spam scheduler calls occupy 667/708 exactly; ~669/~710 is the correct after-insertion point. Thread attr at 114 confirmed. **Decisions:** -- **i18n namespace** — `mesh_links.*` chosen for the feature's page strings. - The existing top-level `links` block (`en.json:121-127`) holds footer labels - (website/github/discord/youtube/profile) and must not be reused; `mesh_links` - avoids the collision without renaming existing strings. `entities.links` - (the nav label) is unaffected. +- **i18n namespace** — `routes.*` for the feature's page strings; nav label + under `entities.routes` (value "Routes"). The `routes.*` token is + collision-free (unlike the original `links.*` footer block that first + motivated a `mesh_` prefix; with the feature renamed to Routes that prefix + is no longer needed). + +### Naming amendment (2026-07-12) + +Renamed the feature from **"Link" / "Mesh Link"** to **"Routes"** to avoid the +web-link ambiguity (the original plan's primary open question). The display +label is **"Routes"** (not "Mesh Routes") — the `Mesh` qualifier was dropped +because the feature lives in the nav alongside Nodes/Channels/Messages, where +the mesh context is already implicit. Naming map (single source of truth): + +| Concern | Value | +|---|---| +| User-facing label (nav card, page title) | **Routes** | +| Entity / model class | `Route` | +| Tables | `routes`, `route_nodes`, `route_observers`, `route_results` | +| Visibility enum | `RouteVisibility` | +| API | `/api/v1/routes`, `/api/v1/routes/{id}`, `/api/v1/routes/preview` | +| Web page / module | `/routes`, `spa/pages/routes.js` | +| Feature flag / config | `feature_routes`, `route_evaluator_interval_seconds`, `routes_file` | +| Seed file / importer | `routes.yaml`, `_import_routes` | +| Prometheus metrics | `meshcore_route_healthy`, `meshcore_route_quality`, `meshcore_route_matched_packets`, `meshcore_route_threshold`, `meshcore_route_degraded_threshold` (label `{route}`) | +| i18n | `entities.routes` = "Routes"; feature strings under `routes.*` | +| Engine functions | `evaluate_route`, `evaluate_all_routes`, `preview_route`, `effective_degraded_threshold`, `derive_quality`, `recent_matches(db, route, …)` | +| Unchanged (not route-feature-specific) | `packet_path_hops` table, `raw_packets`, `trace_paths`, `max_hop_span`, `node_hash`, `path_hash_bytes` | + +**Considered — `route_type` semantic overlap (accepted).** The word "Route" +is already user-visible via the per-packet **`route_type`** delivery +classification (`flood` / `direct`, shown as a "Route Type" column and filter +on the Packets/Advertisements pages, e.g. `advertisements.js:299`, +`packets.col_route_type`), and the `trace_paths` traceroute feature. The +overlap was accepted because (a) the scopes differ — a nav-level monitored- +route feature vs a per-packet delivery attribute; (b) i18n namespaces already +separate them (`routes.*` vs `packets.col_route_type` / +`advertisements.route_type_*`); and (c) context disambiguates — "Routes" is a +nav-level monitoring feature, while "Route Type" is a per-packet column/ +filter on the Packets and Advertisements pages. A literal `routes` token is +collision-free across models, API routes, SPA pages, feature flags, i18n +blocks, metrics, and config (audited 2026-07-12 against `main`). + +### Quality band amendment (2026-07-12) + +Added a **route quality band** on top of the existing 3-state `state` +(alerting) axis, so a route shows amber (`marginal`) before it crosses the +red floor — the "is it alive or dead" answer is unchanged; the band adds +"how comfortably alive". + +- **`Route.degraded_threshold`** (nullable int, default null) — the comfort + bar. `matched_count` in `[packet_count_threshold, effective_degraded)` ⇒ + `marginal`; `≥ effective_degraded` ⇒ `clear`. **Null ⇒ relative default + `effective_degraded = 2 × packet_count_threshold`** (module constant + `DEGRADED_DEFAULT_MULTIPLIER = 2` in `collector/routes.py`), so every route + gets a marginal/clear split out of the box; an operator only sets + `degraded_threshold` explicitly to widen or tighten the band. Validated `> + packet_count_threshold` in Pydantic when explicitly set (F1, Phase 4). +- **`RouteResult.quality`** (enum `clear` / `marginal` / `failing` / + `unknown`) — the display axis, derived at eval time from `state` + + `matched_count` + the effective comfort bar, denormalized into + `route_results` so the list endpoint doesn't recompute. `state` (healthy / + unhealthy / no_coverage) is kept as the stable alerting contract (F4). +- **Evaluator** (Phase 3) — new helpers `effective_degraded_threshold(route)` + and `derive_quality(state, matched_count, threshold, effective_degraded)`; + the short-circuit bar is `effective_degraded_threshold(route)`; below the + floor the existing existence check splits `failing` vs `unknown`. +- **Badge** (Phase 7 / UI Design) — five daisyUI colours: `clear` + `badge-success`, `marginal` `badge-warning`, `failing` `badge-error`, + `no_coverage` `badge-info`, `disabled` `badge-neutral`. **Recoloured + `no_coverage` from amber to blue** — it is indeterminate, not a warning, + and this removes the original plan's clash with the `marginal`/warning + band. +- **Metrics** (Phase 6) — replaced the planned `meshcore_route_state` ordinal + with the richer `meshcore_route_quality` (0/1/2/3; alert recipes — "not + clear" = `>= 1`, "page on failure" = `== 2`, "indeterminate" = `== 3`; + `unknown`=3 carries the highest ordinal but is indeterminate, not more + severe than `failing`); `meshcore_route_matched_packets` is a lower bound + when `quality == clear` (short-circuit). `meshcore_route_degraded_threshold` + emits the effective comfort bar (`2 × threshold` when unset). + `meshcore_route_healthy` stays as the simple boolean (1 when `quality` ∈ + {clear, marginal}). + +Normative detail lives in F4 / F7 / UI Design → The route card. + +### Second-pass review (2026-07-12) + +Re-verified all cited line numbers and functions against `main` (commit +`d60e2e8`). All citations are accurate. No new conflicts with the 45 sibling +plans or `git log --oneline -20`. One critical bug found and fixed; two +design decisions resolved. + +**Critical fix — case mismatch (would have broken all matching):** +- `Node.public_key` is stored **lowercase** (`node.py:45-46`: + `self.public_key = self.public_key.lower()`), while `_normalize_hash_list` + **uppercases** path hashes (`letsmesh_normalizer.py:847`, + migration `20260703_2250:52`: `token = item.strip().upper()`). The plan + derived `expected_hash = public_key[:2*match_width]` without `.upper()`, + so `LIKE 'a1%'` would never match `'A1B2'` (case-sensitive on both + backends) — **every route would permanently read unhealthy**. + **Fix**: `.upper()` added to every `expected_hash` derivation (F2, F8, + T3, T9, Phase 3 `derive_expected_hash`, Phase 4 CRUD, Phase 8 seed). + +**Design decision 1 — Postgres LIKE sargability (resolved: range query):** +- `node_hash LIKE 'prefix%'` defeats the btree index on Postgres with locale + collations (the planner won't recognize LIKE-prefix as sargable, causing a + seq scan). SQLite auto-optimizes it, but the range form is sargable on + both backends unconditionally. + **Resolution**: replaced `LIKE` with a range scan (`node_hash >= :prefix + AND node_hash < :prefix_end`) throughout (T3, Phase 3 + `fetch_candidate_paths`). Added `_hex_prefix_end(prefix)` helper (~2 lines: + increment last hex char) to Phase 3 helpers. + +**Design decision 2 — observer denormalization (resolved: add column):** +- `packet_path_hops` denormalized `packet_hash` and `received_at` but not + `observer_node_id`, forcing observer-scoped routes (F3) to join + `raw_packets`. This broke T5's stated "no join back to `raw_packets`" goal. + **Resolution**: added `observer_node_id` to the hop table (T1, T5, Phase 1 + backfill, Phase 2 ingest, Phase 3 `fetch_candidate_paths`). Storage cost + ~36 bytes/row; ingest cost negligible (value already in scope at line 140). + +**Factual corrections:** +- **Router registration** — plan said "Register router in `api/app.py`" but + routers are registered in `api/routes/__init__.py` (import + + `include_router`); `app.py:183` only mounts the aggregate `api_router`. + Corrected in Phase 4. +- **Phase 9 payload change** — plan claimed "zero client-visible payload + change" but the hop table stores normalized (uppercased) hashes while + `_extract_path_hashes` returns raw (possibly lowercase) values. Corrected + to "near-zero" with a note to assert `upper()` equality in the parity test. +- **`prefix_collision_counts`** — was hardcoded to 1-byte (`[:2]`, + lowercase); parameterized to `[:2*match_width]` and uppercased for + consistency with the `node_hash` column. ### Remaining Action Items -- **Naming confirmation** — "Link" / `links` must be confirmed before Phase 1 - (the migration authors the table; renaming later is costly). -- **Cap on configured nodes** — confirm ~8 is comfortable for the realistic - longest route (drives max self-join depth). +- ~~**Naming confirmation**~~ — **RESOLVED 2026-07-12**: feature is **Routes**; + table `routes`, namespace `routes` (see Naming amendment above). No longer + blocks Phase 1. +- ~~**Quality band default**~~ — **RESOLVED 2026-07-12**: `degraded_threshold` + ships a **relative default** — null ⇒ `2 × packet_count_threshold` (module + constant `DEGRADED_DEFAULT_MULTIPLIER = 2`), so every route has a marginal/ + clear split out of the box. +- **Cap on configured nodes** — confirm ~8 is comfortable; this is a **UX** + limit (path-builder chip clutter), not a performance guard — fetch-and- + check eliminated the N-way self-join, so depth only affects the trivial + `is_subsequence` pass. - **Collision tolerance** — validate the acceptable false-healthy rate with live data once the hop table is populated. - **Observer coverage guidance** — UI decision during Phase 7 (recommend - adding observers when a link reads unhealthy with zero contributing + adding observers when a route reads unhealthy with zero contributing observers). -These four items are the plan's existing Open Questions; none block starting -Phase 1 except naming. +Only the last three items remain; none block starting Phase 1. The +second-pass review (2026-07-12) added no new action items — all findings +were resolved directly in the plan. diff --git a/docs/plans/20260705-2306-mesh-link-monitoring/tasks.md b/docs/plans/20260705-2306-mesh-link-monitoring/tasks.md new file mode 100644 index 0000000..0034bf8 --- /dev/null +++ b/docs/plans/20260705-2306-mesh-link-monitoring/tasks.md @@ -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)* diff --git a/example/seed/routes.yaml b/example/seed/routes.yaml new file mode 100644 index 0000000..7ddb8c8 --- /dev/null +++ b/example/seed/routes.yaml @@ -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 diff --git a/src/meshcore_hub/api/metrics.py b/src/meshcore_hub/api/metrics.py index 9899e54..fd83fb0 100644 --- a/src/meshcore_hub/api/metrics.py +++ b/src/meshcore_hub/api/metrics.py @@ -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 diff --git a/src/meshcore_hub/api/routes/__init__.py b/src/meshcore_hub/api/routes/__init__.py index 8261fc5..84a9d4d 100644 --- a/src/meshcore_hub/api/routes/__init__.py +++ b/src/meshcore_hub/api/routes/__init__.py @@ -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"] diff --git a/src/meshcore_hub/api/routes/packet_groups.py b/src/meshcore_hub/api/routes/packet_groups.py index 18fefdb..7b2b29a 100644 --- a/src/meshcore_hub/api/routes/packet_groups.py +++ b/src/meshcore_hub/api/routes/packet_groups.py @@ -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, ) diff --git a/src/meshcore_hub/api/routes/routes.py b/src/meshcore_hub/api/routes/routes.py new file mode 100644 index 0000000..3d27114 --- /dev/null +++ b/src/meshcore_hub/api/routes/routes.py @@ -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) diff --git a/src/meshcore_hub/collector/cli.py b/src/meshcore_hub/collector/cli.py index e2baae9..b730d57 100644 --- a/src/meshcore_hub/collector/cli.py +++ b/src/meshcore_hub/collector/cli.py @@ -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( diff --git a/src/meshcore_hub/collector/handlers/raw_packet.py b/src/meshcore_hub/collector/handlers/raw_packet.py index a763e42..e8586ab 100644 --- a/src/meshcore_hub/collector/handlers/raw_packet.py +++ b/src/meshcore_hub/collector/handlers/raw_packet.py @@ -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) diff --git a/src/meshcore_hub/collector/route_evaluator.py b/src/meshcore_hub/collector/route_evaluator.py new file mode 100644 index 0000000..73a4884 --- /dev/null +++ b/src/meshcore_hub/collector/route_evaluator.py @@ -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 diff --git a/src/meshcore_hub/collector/routes.py b/src/meshcore_hub/collector/routes.py new file mode 100644 index 0000000..52d5864 --- /dev/null +++ b/src/meshcore_hub/collector/routes.py @@ -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, + } diff --git a/src/meshcore_hub/collector/subscriber.py b/src/meshcore_hub/collector/subscriber.py index ce6c678..fefd6af 100644 --- a/src/meshcore_hub/collector/subscriber.py +++ b/src/meshcore_hub/collector/subscriber.py @@ -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() diff --git a/src/meshcore_hub/common/config.py b/src/meshcore_hub/common/config.py index 0b3d55e..b086ecb 100644 --- a/src/meshcore_hub/common/config.py +++ b/src/meshcore_hub/common/config.py @@ -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 diff --git a/src/meshcore_hub/common/models/__init__.py b/src/meshcore_hub/common/models/__init__.py index 73650b9..8f61021 100644 --- a/src/meshcore_hub/common/models/__init__.py +++ b/src/meshcore_hub/common/models/__init__.py @@ -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", ] diff --git a/src/meshcore_hub/common/models/packet_path_hop.py b/src/meshcore_hub/common/models/packet_path_hop.py new file mode 100644 index 0000000..ac68e27 --- /dev/null +++ b/src/meshcore_hub/common/models/packet_path_hop.py @@ -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"" + ) diff --git a/src/meshcore_hub/common/models/route.py b/src/meshcore_hub/common/models/route.py new file mode 100644 index 0000000..ae9c481 --- /dev/null +++ b/src/meshcore_hub/common/models/route.py @@ -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"" diff --git a/src/meshcore_hub/common/models/route_node.py b/src/meshcore_hub/common/models/route_node.py new file mode 100644 index 0000000..8a8d0f7 --- /dev/null +++ b/src/meshcore_hub/common/models/route_node.py @@ -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"" + ) diff --git a/src/meshcore_hub/common/models/route_observer.py b/src/meshcore_hub/common/models/route_observer.py new file mode 100644 index 0000000..1cf08d1 --- /dev/null +++ b/src/meshcore_hub/common/models/route_observer.py @@ -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"" diff --git a/src/meshcore_hub/common/models/route_result.py b/src/meshcore_hub/common/models/route_result.py new file mode 100644 index 0000000..819fec6 --- /dev/null +++ b/src/meshcore_hub/common/models/route_result.py @@ -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"" + ) diff --git a/src/meshcore_hub/common/schemas/routes.py b/src/meshcore_hub/common/schemas/routes.py new file mode 100644 index 0000000..816e646 --- /dev/null +++ b/src/meshcore_hub/common/schemas/routes.py @@ -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 diff --git a/src/meshcore_hub/web/app.py b/src/meshcore_hub/web/app.py index 5bc70fd..ae9aa01 100644 --- a/src/meshcore_hub/web/app.py +++ b/src/meshcore_hub/web/app.py @@ -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, + }, } diff --git a/src/meshcore_hub/web/static/css/app.css b/src/meshcore_hub/web/static/css/app.css index 0ab0c51..7cef40b 100644 --- a/src/meshcore_hub/web/static/css/app.css +++ b/src/meshcore_hub/web/static/css/app.css @@ -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); diff --git a/src/meshcore_hub/web/static/js/spa/app.js b/src/meshcore_hub/web/static/js/spa/app.js index 15713d2..3c2b9a3 100644 --- a/src/meshcore_hub/web/static/js/spa/app.js +++ b/src/meshcore_hub/web/static/js/spa/app.js @@ -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`
  • ${iconChannel('h-5 w-5')} ${t('entities.channels')}
  • `); } + if (features.routes !== false) { + items.push(html`
  • ${iconPath('h-5 w-5')} ${t('entities.routes')}
  • `); + } if (features.messages !== false) { items.push(html`
  • ${iconMessages('h-5 w-5 nav-icon-messages')} ${t('entities.messages')}
  • `); } diff --git a/src/meshcore_hub/web/static/js/spa/pages/home.js b/src/meshcore_hub/web/static/js/spa/pages/home.js index d35cced..d5ef873 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/home.js +++ b/src/meshcore_hub/web/static/js/spa/pages/home.js @@ -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'), diff --git a/src/meshcore_hub/web/static/js/spa/pages/routes.js b/src/meshcore_hub/web/static/js/spa/pages/routes.js new file mode 100644 index 0000000..7a8088d --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa/pages/routes.js @@ -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`
    + \u25CF ${counts.clear} ${t('routes.quality_clear')} + \u25CF ${counts.marginal} ${t('routes.quality_marginal')} + \u25CF ${counts.failing} ${t('routes.quality_failing')} + \u25D0 ${counts.no_coverage} ${t('routes.quality_no_coverage')} + \u25CC ${counts.disabled} ${t('routes.disabled')} +
    `; +} + +function renderPathChips(route) { + const nodes = route.route_nodes || []; + return html`
    + ${nodes.map((rn, i) => html` + ${i > 0 ? html`\u2192` : nothing} + ${rn.name || rn.public_key?.slice(0, 8) || rn.node_id.slice(0, 8)} + `)} +
    `; +} + +function renderNumbersLine(route) { + const result = route.route_result; + if (!result) return html`
    ${t('routes.not_evaluated')}
    `; + 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`
    + ${matched} / ${threshold} \u2192 ${degraded} \u00B7 ${route.window_hours}h \u00B7 ${evalTime} +
    `; +} + +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`${route.visibility}`; + + const adminButtons = isAdmin + ? html`
    + + +
    ` + : nothing; + + const expandContent = isExpanded && detail ? renderDetailContent(route, detail) : nothing; + + return html`
    +
    onExpand(route)} + @keydown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onExpand(route); } }}> +
    +
    +

    + ${route.name} + ${visBadge} +

    + ${route.description ? html`

    ${route.description}

    ` : nothing} +
    +
    + ${dot} ${label} + ${iconChevronRight(`h-4 w-4 transition-transform ${isExpanded ? 'rotate-90' : ''}`)} +
    +
    +
    ${renderPathChips(route)}
    + ${renderNumbersLine(route)} + ${adminButtons} + ${expandContent} +
    +
    `; +} + +function renderDetailContent(route, detail) { + const result = detail.route_result || route.route_result; + const observers = detail.contributing_observers || []; + const matches = detail.recent_matches || []; + + return html`
    + ${result ? html`
    + ${t('routes.diagnosis')}: + ${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} +
    ` : nothing} + ${observers.length > 0 ? html`
    + ${t('routes.contributing_observers')}: + ${observers.map(o => html`${o.name || o.node_id.slice(0, 8)} (${o.match_count})`)} +
    ` : html`
    ${t('routes.no_observers')}
    `} + ${matches.length > 0 ? html`
    + ${t('routes.recent_matches')}: +
    + ${matches.map(m => html`
    + ${(m.hops || []).map((h, i) => html`${i > 0 ? ' \u2192 ' : nothing}${h.node_hash}`).slice(0, 10)} +
    `)} +
    +
    ` : nothing} +
    + ${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} +
    +
    `; +} + +function renderNodeSearchResult(node, onSelect) { + const name = node.name || `${node.public_key.slice(0, 12)}\u2026`; + return html` +
  • + +
  • `; +} + +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` + + + `; +} + +function renderDeleteModal({ route, onConfirm, onCancel }) { + return html` + + + `; +} + +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`
    + +
    ` + : nothing; + + const emptyMessage = routesList.length === 0 + ? html`
    + ${t('common.no_entity_found', { entity: t('entities.routes').toLowerCase() })} +
    ` + : 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` +

    ${t(`routes.visibility_${vis}`)}

    +
    + ${group.map(r => renderRouteCard(r, { + ...cardOpts, + isExpanded: cardOpts.isExpanded(r), + detail: cardOpts.detail(r), + }))} +
    + `); + } + + 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` +
    +

    + ${iconPath('h-8 w-8')} + ${t('routes.title')} +

    +
    + ${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); + } +} diff --git a/src/meshcore_hub/web/static/locales/en.json b/src/meshcore_hub/web/static/locales/en.json index c362c90..7891744 100644 --- a/src/meshcore_hub/web/static/locales/en.json +++ b/src/meshcore_hub/web/static/locales/en.json @@ -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." }, diff --git a/src/meshcore_hub/web/static/locales/nl.json b/src/meshcore_hub/web/static/locales/nl.json index 92acece..9a687a9 100644 --- a/src/meshcore_hub/web/static/locales/nl.json +++ b/src/meshcore_hub/web/static/locales/nl.json @@ -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." }, diff --git a/src/meshcore_hub/web/templates/spa.html b/src/meshcore_hub/web/templates/spa.html index 30480d9..8af3ba6 100644 --- a/src/meshcore_hub/web/templates/spa.html +++ b/src/meshcore_hub/web/templates/spa.html @@ -73,6 +73,9 @@ {% if features.channels %}
  • {{ t('entities.channels') }}
  • {% endif %} + {% if features.routes %} +
  • {{ t('entities.routes') }}
  • + {% endif %} {% if features.messages %}
  • {{ t('entities.messages') }}
  • {% endif %} diff --git a/tests/test_api/test_metrics.py b/tests/test_api/test_metrics.py index a076ee9..859a4e6 100644 --- a/tests/test_api/test_metrics.py +++ b/tests/test_api/test_metrics.py @@ -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 diff --git a/tests/test_api/test_packet_groups.py b/tests/test_api/test_packet_groups.py index 199d630..b2535c3 100644 --- a/tests/test_api/test_packet_groups.py +++ b/tests/test_api/test_packet_groups.py @@ -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 diff --git a/tests/test_api/test_routes.py b/tests/test_api/test_routes.py new file mode 100644 index 0000000..d7b3a1a --- /dev/null +++ b/tests/test_api/test_routes.py @@ -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 diff --git a/tests/test_collector/test_handlers/test_raw_packet.py b/tests/test_collector/test_handlers/test_raw_packet.py index d77b1e7..16a6dce 100644 --- a/tests/test_collector/test_handlers/test_raw_packet.py +++ b/tests/test_collector/test_handlers/test_raw_packet.py @@ -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"] diff --git a/tests/test_collector/test_route_evaluator.py b/tests/test_collector/test_route_evaluator.py new file mode 100644 index 0000000..621e764 --- /dev/null +++ b/tests/test_collector/test_route_evaluator.py @@ -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 diff --git a/tests/test_collector/test_routes.py b/tests/test_collector/test_routes.py new file mode 100644 index 0000000..82f8dda --- /dev/null +++ b/tests/test_collector/test_routes.py @@ -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"