diff --git a/alembic/versions/20260712_2330_8f2a3c4d5e6f_add_route_health_monitoring.py b/alembic/versions/20260712_2330_8f2a3c4d5e6f_add_route_health_monitoring.py index 0a8aca0..48c6f41 100644 --- a/alembic/versions/20260712_2330_8f2a3c4d5e6f_add_route_health_monitoring.py +++ b/alembic/versions/20260712_2330_8f2a3c4d5e6f_add_route_health_monitoring.py @@ -6,10 +6,15 @@ 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``. +``packet_path_hops``. Routes are identified by endpoint labels +(``from_label`` / ``to_label``) with a composite unique index, and each +route carries a ``reversible`` flag so the matching engine can also accept +reverse-ordered paths. + +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``. """ @@ -94,7 +99,8 @@ def upgrade() -> None: server_default=sa.func.now(), nullable=False, ), - sa.Column("name", sa.String(255), nullable=False), + sa.Column("from_label", sa.String(255), nullable=False), + sa.Column("to_label", 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), @@ -103,8 +109,19 @@ def upgrade() -> None: sa.Column("degraded_threshold", sa.Integer, nullable=True), sa.Column("max_hop_span", sa.Integer, nullable=True), sa.Column("enabled", sa.Boolean, nullable=False), + sa.Column( + "reversible", + sa.Boolean, + nullable=False, + server_default=sa.text("true"), + ), + ) + op.create_index( + "ix_routes_from_to", + "routes", + ["from_label", "to_label"], + unique=True, ) - op.create_index("ix_routes_name", "routes", ["name"], unique=True) # --- route_nodes --- op.create_table( diff --git a/alembic/versions/20260713_0100_f1e2d3c4b5a6_add_reversible_to_routes.py b/alembic/versions/20260713_0100_f1e2d3c4b5a6_add_reversible_to_routes.py deleted file mode 100644 index 7058445..0000000 --- a/alembic/versions/20260713_0100_f1e2d3c4b5a6_add_reversible_to_routes.py +++ /dev/null @@ -1,37 +0,0 @@ -"""add reversible column to routes - -Revision ID: f1e2d3c4b5a6 -Revises: 8f2a3c4d5e6f -Create Date: 2026-07-13 01:00:00.000000+00:00 - -Adds a ``reversible`` boolean to the ``routes`` table (default true). -When true, the matching engine also checks the reverse-ordered path, -so A->B->C matches packets observed as C->B->A. - -""" - -from typing import Sequence, Union - -import sqlalchemy as sa -from alembic import op - -revision: str = "f1e2d3c4b5a6" -down_revision: Union[str, None] = "8f2a3c4d5e6f" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.add_column( - "routes", - sa.Column( - "reversible", - sa.Boolean(), - nullable=False, - server_default=sa.text("true"), - ), - ) - - -def downgrade() -> None: - op.drop_column("routes", "reversible") diff --git a/docs/plans/20260705-2306-mesh-link-monitoring/plan.md b/docs/plans/20260705-2306-mesh-link-monitoring/plan.md index e11ec73..2daad54 100644 --- a/docs/plans/20260705-2306-mesh-link-monitoring/plan.md +++ b/docs/plans/20260705-2306-mesh-link-monitoring/plan.md @@ -154,8 +154,10 @@ first bytes co-occur far apart on an unrelated long flood path. ### Functional Requirements - **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 + update, and delete Routes. Each Route has: a `from_label`/`to_label` + endpoint pair (composite unique), an optional description, a `reversible` + flag (default true — when true the matching engine also accepts + reverse-ordered paths), 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 @@ -253,30 +255,34 @@ first bytes co-occur far apart on an unrelated long flood path. - **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 route name; each entry holds the route's knobs plus an + file is a list under the top-level `routes:` key; each entry holds the + route's `from`/`to` endpoint labels, 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 + route plus its `route_nodes`/`route_observers` children idempotently by `(from_label, to_label)` — 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 - visibility: community # default; member/operator/admin also supported - 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 - - a1b2c3d4e5f6... - - 9a8b7c6d5e4f... - observers: # optional; omit/empty = all observers - - 010203040506... + routes: + - from: Ipswich + to: Norwich + description: A140 corridor route + visibility: community # default; member/operator/admin also supported + 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 + reversible: true # default: true (match both directions) + path: # ordered, ≥2, by public_key + - a1b2c3d4e5f6... + - 9a8b7c6d5e4f... + observers: # optional; omit/empty = all observers + - 010203040506... ``` ### Technical Requirements @@ -340,7 +346,7 @@ first bytes co-occur far apart on an unrelated long flood path. wired into `_run_seed_import` so the existing `meshcore-hub seed` command (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` + Upsert is by `(from_label, to_label)`; 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 @@ -399,8 +405,8 @@ admin work. Every layout choice below follows from that. to matched hashes + window (reuses built UI; no new packet browser). ### Create/edit modal (wider: `modal-box-lg`) -- `name`, `description`, `visibility` (select), `enabled` (checkbox) — as - channels. +- `from`, `to`, `description`, `visibility` (select), `enabled` (checkbox), + `reversible` (checkbox, default true) — as channels. - **`match_width`** — **segmented control** `[ 1 byte | 2 bytes | 3 bytes ]` with a dynamic hint ("Matches all traffic · ~256 buckets" / "2-byte+ only · ~65K" / "3-byte only · ~16M"). Chosen over a ` +
+
+ + +
+
+ + +
@@ -366,10 +389,12 @@ function renderRouteModal({ modalState, onSave, onCancel }) { } function renderDeleteModal({ route, onConfirm, onCancel }) { + const arrow = route.reversible !== false ? '\u2194' : '\u2192'; + const label = `${route.from_label} ${arrow} ${route.to_label}`; return html`