refactor: replace route name with from/to endpoint labels

Route identity is now a (from_label, to_label) composite unique pair
instead of a single name column.  The three route migrations (create
tables, add reversible, replace name) are collapsed into one clean
migration since the feature hasn't shipped yet — 8f2a3c4d5e6f creates
all five tables with from_label/to_label + reversible from the start,
no intermediate steps.

Engine: recent_matches now returns the sliced subpath between the
matched endpoints, not the full path.  Diagnosis text moved to a hover
tooltip on the quality badge.  Cards sorted by from_label.  Seed YAML
switched to a list format with from/to keys; upsert by (from_label,
to_label).  Plan/tasks docs updated to present the final schema.
This commit is contained in:
Louis King
2026-07-13 13:35:58 +01:00
parent 6a4329b9d2
commit 1ee01a65fe
20 changed files with 511 additions and 243 deletions
@@ -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(
@@ -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")
@@ -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 `<select>` because toggling it
@@ -613,7 +619,8 @@ admin work. Every layout choice below follows from that.
(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
threshold` when the route hasn't set one), labelled by `{route}` (the
`from_label → to_label` composite label). Verify
in `tests/test_api/test_metrics.py`.
### Phase 7: Web UI + i18n
@@ -653,7 +660,7 @@ admin work. Every layout choice below follows from that.
"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
automatically. Idempotent upsert by `(from_label, to_label)`; resolves path/observer nodes by
`public_key`; derives `expected_hash` (uppercased to match the
normalized `node_hash` column); replaces `route_nodes`/
`route_observers` on update; honors seeded `visibility` and
@@ -10,7 +10,7 @@
- [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] `Route` columns: `from_label`/`to_label` (composite unique pair), `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), `reversible` (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)
@@ -123,7 +123,7 @@
- [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] Wider (`modal-box-lg`) add/edit modal with from, to, description, visibility, enabled, reversible, 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`
@@ -144,7 +144,7 @@
- [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] Idempotent upsert by `(from_label, to_label)`; 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
+37 -1
View File
@@ -14,13 +14,15 @@ This imports data from the following files (if they exist):
- `{SEED_HOME}/node_tags.yaml` - Node tag definitions
- `{SEED_HOME}/channels.yaml` - Channel decryption keys
- `{SEED_HOME}/routes.yaml` - Route health monitoring definitions
## Directory Structure
```
seed/ # SEED_HOME (seed data files)
├── node_tags.yaml # Node tags for import
── channels.yaml # Channel keys for import
── channels.yaml # Channel keys for import
└── routes.yaml # Route health definitions for import
data/ # DATA_HOME (runtime data)
└── collector/
@@ -106,3 +108,37 @@ meshcore-hub collector channel disable --name MyChannel
# Remove a channel
meshcore-hub collector channel remove --name MyChannel
```
## Routes
Routes define multi-hop mesh paths to monitor for health. Each route is keyed by its `from`/`to` endpoint labels and is upserted (created or updated) by that label pair.
### Routes YAML Format
A list under the top-level `routes:` key. Each entry requires `from`, `to`, and an ordered `path` of at least 2 distinct node public keys:
```yaml
routes:
- from: Ipswich
to: 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
reversible: true # match both directions (A->B and B->A)
path:
- a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
- 9a8b7c6d5e4f9a8b7c6d5e4f9a8b7c6d5e4f9a8b7c6d5e4f9a8b7c6d5e4f9a8b
# observers: # optional; omit/empty = all observers
# - 0102030405060102030405060102030405060102030405060102030405060102
```
Rules:
- Path nodes must already exist in the database (create them via `node_tags.yaml` or let the collector discover them). A missing path node is a hard error.
- Observer nodes that don't exist yet are skipped with a warning (not an error).
- The `(from, to)` label pair must be unique across all routes.
+25 -21
View File
@@ -1,26 +1,30 @@
# 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.
# A list under the top-level `routes:` key. Each entry has `from`/`to`
# endpoint labels, the route's knobs, and an ordered `path` of node
# public_keys (>= 2, distinct) plus an optional `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).
# Entries are upserted by the (from, to) label pair. 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
reversible: true # match both directions (A->B and B->A)
path:
- a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
- 9a8b7c6d5e4f9a8b7c6d5e4f9a8b7c6d5e4f9a8b7c6d5e4f9a8b7c6d5e4f9a8b
# observers: # optional; omit/empty = all observers
# - 0102030405060102030405060102030405060102030405060102030405060102
routes:
- from: Ipswich
to: 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
reversible: true # match both directions (A->B and B->A)
path:
- a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
- 9a8b7c6d5e4f9a8b7c6d5e4f9a8b7c6d5e4f9a8b7c6d5e4f9a8b7c6d5e4f9a8b
# observers: # optional; omit/empty = all observers
# - 0102030405060102030405060102030405060102030405060102030405060102
+1 -1
View File
@@ -358,7 +358,7 @@ def collect_metrics(session: Any) -> bytes:
.where(Route.enabled.is_(True))
).all()
for route, result in route_rows:
name = route.name
name = f"{route.from_label} -> {route.to_label}"
quality_str = result.quality if result else "unknown"
route_healthy.labels(route=name).set(
1 if quality_str in ("clear", "marginal") else 0
+23 -9
View File
@@ -80,7 +80,8 @@ def _result_to_summary(result: RouteResult | None) -> RouteResultSummary | None:
def _route_to_read(route: Route) -> RouteRead:
return RouteRead(
id=route.id,
name=route.name,
from_label=route.from_label,
to_label=route.to_label,
description=route.description,
visibility=route.visibility,
match_width=route.match_width,
@@ -148,7 +149,7 @@ def list_routes(
role = resolve_user_role(request)
max_level = get_max_visibility_level(role)
routes = session.execute(select(Route).order_by(Route.name)).scalars().all()
routes = session.execute(select(Route).order_by(Route.from_label)).scalars().all()
filtered = [
_route_to_read(r)
for r in routes
@@ -165,11 +166,15 @@ def create_route(
) -> RouteRead:
"""Create a new route (admin only)."""
existing = session.execute(
select(Route).where(Route.name == body.name)
select(Route).where(
Route.from_label == body.from_label,
Route.to_label == body.to_label,
)
).scalar_one_or_none()
if existing:
raise HTTPException(
status_code=409, detail=f"Route '{body.name}' already exists"
status_code=409,
detail=f"Route '{body.from_label}' -> '{body.to_label}' already exists",
)
nodes = _resolve_nodes_by_pubkey(session, body.node_public_keys)
@@ -183,7 +188,8 @@ def create_route(
)
route = Route(
name=body.name,
from_label=body.from_label,
to_label=body.to_label,
description=body.description,
visibility=body.visibility,
match_width=body.match_width,
@@ -273,15 +279,23 @@ def update_route(
if not route:
raise HTTPException(status_code=404, detail="Route not found")
if body.name is not None:
if body.from_label is not None or body.to_label is not None:
new_from = body.from_label if body.from_label is not None else route.from_label
new_to = body.to_label if body.to_label is not None else route.to_label
dup = session.execute(
select(Route).where(Route.name == body.name, Route.id != route_id)
select(Route).where(
Route.from_label == new_from,
Route.to_label == new_to,
Route.id != route_id,
)
).scalar_one_or_none()
if dup:
raise HTTPException(
status_code=409, detail=f"Route '{body.name}' already exists"
status_code=409,
detail=f"Route '{new_from}' -> '{new_to}' already exists",
)
route.name = body.name
route.from_label = new_from
route.to_label = new_to
if body.description is not None:
route.description = body.description
+36 -13
View File
@@ -699,10 +699,11 @@ def _import_routes(
) -> 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.
Each entry is a dict with ``from``/``to`` endpoint labels plus the route's
knobs, 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. Entries are upserted by ``(from_label, to_label)``.
Returns:
Dict with 'created', 'updated', and 'errors'.
@@ -725,16 +726,28 @@ def _import_routes(
if not data or not isinstance(data, dict):
return {"created": created, "updated": updated, "errors": errors}
entries = data.get("routes")
if not isinstance(entries, list):
errors.append("routes seed file must have a list under the 'routes:' key")
return {"created": created, "updated": updated, "errors": errors}
with db.session_scope() as session:
for name, value in data.items():
for idx, value in enumerate(entries):
try:
if not isinstance(value, dict):
errors.append(f"Route '{name}': entry must be a dict")
errors.append(f"Route #{idx}: entry must be a dict")
continue
from_label = (value.get("from") or "").strip()
to_label = (value.get("to") or "").strip()
if not from_label or not to_label:
errors.append(f"Route #{idx}: 'from' and 'to' are required")
continue
label = f"{from_label} -> {to_label}"
path_keys: list[str] = value.get("path") or []
if len(path_keys) < 2:
errors.append(f"Route '{name}': path needs >= 2 nodes")
errors.append(f"Route '{label}': path needs >= 2 nodes")
continue
match_width: int = value.get("match_width", 1)
@@ -750,7 +763,7 @@ def _import_routes(
)
if not node:
errors.append(
f"Route '{name}': path node {pk_lower[:12]}... not found"
f"Route '{label}': path node {pk_lower[:12]}... not found"
)
path_ok = False
else:
@@ -771,14 +784,23 @@ def _import_routes(
elif verbose:
click.echo(
f" Warning: observer node {pk_lower[:12]}... "
f"not found, skipping (route '{name}')"
f"not found, skipping (route '{label}')"
)
# Upsert route by name
existing = session.query(Route).filter(Route.name == name).first()
# Upsert route by (from_label, to_label)
existing = (
session.query(Route)
.filter(
Route.from_label == from_label,
Route.to_label == to_label,
)
.first()
)
if existing:
route = existing
route.from_label = from_label
route.to_label = to_label
route.description = value.get("description")
route.visibility = visibility
route.match_width = match_width
@@ -799,7 +821,8 @@ def _import_routes(
updated += 1
else:
route = Route(
name=name,
from_label=from_label,
to_label=to_label,
description=value.get("description"),
visibility=visibility,
match_width=match_width,
@@ -832,7 +855,7 @@ def _import_routes(
session.add(RouteObserver(route_id=route.id, node_id=node.id))
except Exception as e:
errors.append(f"Route '{name}': {e}")
errors.append(f"Route '{label}': {e}")
return {"created": created, "updated": updated, "errors": errors}
@@ -43,6 +43,10 @@ def run_evaluation(db: DatabaseManager) -> int:
upsert_route_result(session, route, state, quality, matched_count)
count += 1
except Exception:
logger.exception("Error evaluating route '%s'", route.name)
logger.exception(
"Error evaluating route '%s -> %s'",
route.from_label,
route.to_label,
)
return count
+90 -39
View File
@@ -74,6 +74,53 @@ def derive_quality(
return RouteQuality.UNKNOWN.value
def _subsequence_indices(
path: list[dict[str, Any]],
expected: list[str],
max_hop_span: Optional[int] = None,
) -> Optional[tuple[int, int]]:
"""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.
Returns the ``(first_i, last_i)`` indices into *path* of the matched
endpoints, or ``None`` when no match is found.
"""
if not expected:
return None
pi = 0
first_i: Optional[int] = None
last_i: Optional[int] = None
first_pos: Optional[int] = None
last_pos: Optional[int] = None
for needed in expected:
found = False
while pi < len(path):
i = pi
hop = path[pi]
pi += 1
if hop["node_hash"].startswith(needed):
pos = hop["position"]
if first_i is None:
first_i = i
first_pos = pos
last_i = i
last_pos = pos
found = True
break
if not found:
return None
if max_hop_span is not None and first_pos is not None and last_pos is not None:
if last_pos - first_pos > max_hop_span:
return None
if first_i is not None and last_i is not None:
return (first_i, last_i)
return None
def is_subsequence(
path: list[dict[str, Any]],
expected: list[str],
@@ -86,28 +133,30 @@ def is_subsequence(
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
return _subsequence_indices(path, expected, max_hop_span) is not None
def _matched_subpath(
hops: list[dict[str, Any]],
expected: list[str],
max_hop_span: Optional[int] = None,
reversible: bool = True,
) -> Optional[list[dict[str, Any]]]:
"""Return the slice of *hops* between the first and last matched node.
Forward match is tried first; if *reversible* and the expected sequence
has > 1 node, the reverse-ordered match is also tried. Returns ``None``
when no match is found. The returned slice is in packet-traversal order
(never reversed), so a reverse-direction packet shows as To -> ... -> From.
"""
idx = _subsequence_indices(hops, expected, max_hop_span)
if idx is not None:
return hops[idx[0] : idx[1] + 1]
if reversible and len(expected) > 1:
idx = _subsequence_indices(hops, list(reversed(expected)), max_hop_span)
if idx is not None:
return hops[idx[0] : idx[1] + 1]
return None
def _match_hops(
@@ -117,11 +166,7 @@ def _match_hops(
reversible: bool = True,
) -> bool:
"""Check whether *hops* match *expected* forward (and optionally reverse)."""
if is_subsequence(hops, expected, max_hop_span):
return True
if reversible and len(expected) > 1:
return is_subsequence(hops, list(reversed(expected)), max_hop_span)
return False
return _matched_subpath(hops, expected, max_hop_span, reversible) is not None
def _fetch_candidate_paths_maybe_bidirectional(
@@ -361,7 +406,9 @@ def evaluate_all_routes(
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)
logger.exception(
"Error evaluating route '%s -> %s'", route.from_label, route.to_label
)
return results
@@ -412,6 +459,7 @@ def recent_matches(
session: Session,
route: Route,
limit: int = 3,
now: Optional[datetime] = None,
) -> list[dict[str, Any]]:
"""Return the latest *limit* matching paths for a route."""
expected = _route_expected_hashes(route)
@@ -421,7 +469,8 @@ def recent_matches(
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)
current = now or datetime.now(timezone.utc)
since = current - timedelta(hours=route.window_hours)
reversible = getattr(route, "reversible", True)
paths = _fetch_candidate_paths_maybe_bidirectional(
@@ -430,16 +479,18 @@ def recent_matches(
matches: list[dict[str, Any]] = []
for hops in paths.values():
if _match_hops(hops, expected, route.max_hop_span, reversible):
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"),
}
)
subpath = _matched_subpath(hops, expected, route.max_hop_span, reversible)
if not subpath:
continue
first = subpath[0] if subpath else {}
matches.append(
{
"packet_hash": first.get("packet_hash"),
"hops": subpath,
"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),
+9 -6
View File
@@ -3,7 +3,7 @@
from enum import Enum
from typing import TYPE_CHECKING, Optional
from sqlalchemy import Boolean, Integer, String, Text
from sqlalchemy import Boolean, Index, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin
@@ -32,7 +32,8 @@ class Route(Base, UUIDMixin, TimestampMixin):
Attributes:
id: UUID primary key
name: Unique route display name
from_label: Human-readable label for the route's start endpoint
to_label: Human-readable label for the route's end endpoint
description: Optional longer description
visibility: Role-based visibility level
match_width: Path-hash prefix width in bytes (1/2/3)
@@ -44,10 +45,12 @@ class Route(Base, UUIDMixin, TimestampMixin):
"""
__tablename__ = "routes"
name: Mapped[str] = mapped_column(
String(255), unique=True, nullable=False, index=True
__table_args__ = (
Index("ix_routes_from_to", "from_label", "to_label", unique=True),
)
from_label: Mapped[str] = mapped_column(String(255), nullable=False)
to_label: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(
Text,
nullable=True,
@@ -114,4 +117,4 @@ class Route(Base, UUIDMixin, TimestampMixin):
)
def __repr__(self) -> str:
return f"<Route(name={self.name}, enabled={self.enabled})>"
return f"<Route(from={self.from_label}, to={self.to_label}, enabled={self.enabled})>"
+18 -4
View File
@@ -44,7 +44,18 @@ class RouteResultSummary(BaseModel):
class RouteCreate(BaseModel):
"""Schema for creating a route."""
name: str = Field(..., min_length=1, max_length=255, description="Route name")
from_label: str = Field(
...,
min_length=1,
max_length=255,
description="Label for the route's start endpoint",
)
to_label: str = Field(
...,
min_length=1,
max_length=255,
description="Label for the route's end endpoint",
)
description: Optional[str] = Field(default=None, description="Route description")
visibility: Literal["community", "member", "operator", "admin"] = Field(
default="community", description="Visibility level"
@@ -93,7 +104,8 @@ class RouteCreate(BaseModel):
class RouteUpdate(BaseModel):
"""Schema for updating a route."""
name: Optional[str] = Field(default=None, min_length=1, max_length=255)
from_label: Optional[str] = Field(default=None, min_length=1, max_length=255)
to_label: 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)
@@ -128,7 +140,8 @@ class RouteRead(BaseModel):
"""Schema for reading a route (list-level with lightweight result)."""
id: str
name: str
from_label: str
to_label: str
description: Optional[str] = None
visibility: str
match_width: int
@@ -175,7 +188,8 @@ class RouteDetail(BaseModel):
"""Full detail for GET /api/v1/routes/{id}."""
id: str
name: str
from_label: str
to_label: str
description: Optional[str] = None
visibility: str
match_width: int
@@ -3,7 +3,6 @@ import { html, litRender, nothing, t, errorAlert, getConfig, hasRole } from '../
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;
@@ -40,6 +39,15 @@ function qualityDot(quality, enabled) {
return dots[quality] || '\u25D0';
}
function diagnosisText(route) {
const result = route.route_result;
if (!result || !route.enabled) return '';
if (result.state === 'healthy') return t('routes.diagnosis_healthy');
if (result.state === 'unhealthy') return t('routes.diagnosis_unhealthy');
if (result.state === 'no_coverage') return t('routes.diagnosis_no_coverage');
return '';
}
function renderSummaryStrip(routes) {
const counts = { clear: 0, marginal: 0, failing: 0, no_coverage: 0, disabled: 0 };
for (const r of routes) {
@@ -85,11 +93,16 @@ function renderNumbersLine(route) {
</div>`;
}
function renderRouteCard(route, { isAdmin, onDelete, onEdit, onExpand, isExpanded, detail }) {
function renderRouteCard(route, { isAdmin, onDelete, onEdit, onExpand, isExpanded, detail, navigate, packetsEnabled }) {
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 tip = diagnosisText(route);
const arrow = route.reversible !== false ? '\u2194' : '\u2192';
const badge = tip
? html`<span class="badge ${badgeCls} badge-sm tooltip tooltip-left" data-tip=${tip}>${dot} ${label}</span>`
: html`<span class="badge ${badgeCls} badge-sm">${dot} ${label}</span>`;
const adminButtons = isAdmin
? html`<div class="flex gap-2 mt-2">
@@ -102,7 +115,7 @@ function renderRouteCard(route, { isAdmin, onDelete, onEdit, onExpand, isExpande
</div>`
: nothing;
const expandContent = isExpanded && detail ? renderDetailContent(route, detail) : nothing;
const expandContent = isExpanded && detail ? renderDetailContent(route, detail, { navigate, packetsEnabled }) : nothing;
return html`<div class="card bg-base-100 shadow-xl">
<div class="card-body cursor-pointer" role="button" tabindex="0"
@@ -110,13 +123,15 @@ function renderRouteCard(route, { isAdmin, onDelete, onEdit, onExpand, isExpande
@keydown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onExpand(route); } }}>
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<h2 class="card-title flex items-center gap-2">
${route.name}
<h2 class="card-title flex items-center gap-2 flex-wrap">
<span>${route.from_label}</span>
<span class="opacity-50">${arrow}</span>
<span>${route.to_label}</span>
</h2>
${route.description ? html`<p class="text-sm opacity-70 mt-1">${route.description}</p>` : nothing}
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<span class="badge ${badgeCls} badge-sm">${dot} ${label}</span>
${badge}
${iconChevronRight(`h-4 w-4 transition-transform ${isExpanded ? 'rotate-90' : ''}`)}
</div>
</div>
@@ -131,18 +146,14 @@ function renderRouteCard(route, { isAdmin, onDelete, onEdit, onExpand, isExpande
</div>`;
}
function renderDetailContent(route, detail) {
function renderDetailContent(route, detail, { navigate, packetsEnabled }) {
const result = detail.route_result || route.route_result;
const observers = detail.contributing_observers || [];
const matches = detail.recent_matches || [];
const packetDetailUrl = (packetHash) =>
(packetsEnabled && packetHash) ? `/packets/hash/${packetHash}` : null;
return html`<div class="mt-4 pt-4 border-t border-base-300 space-y-3 text-sm">
${result ? html`<div class="opacity-70">
<strong>${t('routes.diagnosis')}:</strong>
${result.state === 'healthy' ? t('routes.diagnosis_healthy') : nothing}
${result.state === 'unhealthy' ? t('routes.diagnosis_unhealthy') : nothing}
${result.state === 'no_coverage' ? t('routes.diagnosis_no_coverage') : nothing}
</div>` : nothing}
${observers.length > 0 ? html`<div>
<strong class="opacity-70">${t('routes.contributing_observers')}:</strong>
${observers.map(o => html`<span class="badge badge-ghost badge-sm ml-1">${o.name || o.node_id.slice(0, 8)} (${o.match_count})</span>`)}
@@ -156,8 +167,10 @@ function renderDetailContent(route, detail) {
(route.route_nodes || []).map(rn =>
[rn.expected_hash?.toLowerCase(), rn])
);
return html`<div class="flex flex-wrap items-center gap-0.5 text-xs pb-1 border-b border-base-300 last:border-0">
${(m.hops || []).slice(0, 10).map((h, i) => {
const detailUrl = packetDetailUrl(m.packet_hash);
return html`<div class="flex flex-wrap items-center gap-0.5 text-xs pb-1 border-b border-base-300 last:border-0 ${detailUrl ? 'hover:bg-base-200 cursor-pointer -mx-1 px-1 rounded transition-colors' : ''}"
@click=${detailUrl ? (e) => { e.stopPropagation(); navigate(detailUrl); } : undefined}>
${(m.hops || []).map((h, i) => {
const rn = pathLookup.get((h.node_hash || '').toLowerCase().slice(0, prefixLen));
return html`
${i > 0 ? html`<span class="opacity-30 mx-0.5">\u2192</span>` : nothing}
@@ -166,6 +179,7 @@ function renderDetailContent(route, detail) {
: html`<span class="badge badge-ghost badge-sm opacity-50">${(h.node_hash || '').toLowerCase()}</span>`}
`;
})}
${m.received_at ? html`<span class="ml-auto opacity-40 whitespace-nowrap">${new Date(m.received_at).toLocaleString()}</span>` : nothing}
</div>`;
})}
</div>
@@ -213,12 +227,21 @@ function renderRouteModal({ modalState, onSave, onCancel }) {
<h3 class="font-bold text-lg mb-4">${title}</h3>
<form @submit=${(e) => { e.preventDefault(); onSave(); }}>
<div class="grid grid-cols-1 gap-3 mb-4">
<div>
<label class="text-sm opacity-70">${t('routes.name_label')}</label>
<input type="text" id="route-modal-name" class="input input-sm w-full"
.value=${route.name || ''}
placeholder="${t('routes.name_label')}"
required maxlength="255" />
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label class="text-sm opacity-70">${t('routes.from_label')}</label>
<input type="text" id="route-modal-from" class="input input-sm w-full"
.value=${route.from_label || ''}
placeholder="${t('routes.from_label')}"
required maxlength="255" />
</div>
<div>
<label class="text-sm opacity-70">${t('routes.to_label')}</label>
<input type="text" id="route-modal-to" class="input input-sm w-full"
.value=${route.to_label || ''}
placeholder="${t('routes.to_label')}"
required maxlength="255" />
</div>
</div>
<div>
<label class="text-sm opacity-70">${t('routes.description_label')}</label>
@@ -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`<dialog open class="modal modal-open">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">${t('routes.delete_route')}</h3>
<p>${t('routes.delete_confirm', { name: route.name })}</p>
<p>${t('routes.delete_confirm', { label })}</p>
<div class="modal-action">
<button class="btn btn-ghost" @click=${onCancel}>${t('common.cancel')}</button>
<button class="btn btn-error" @click=${onConfirm}>${t('common.delete')}</button>
@@ -384,6 +409,8 @@ export async function render(container, params, router) {
try {
const config = getConfig();
const isAdmin = hasRole('admin');
const navigate = (url) => router.navigate(url);
const packetsEnabled = config.features?.packets !== false;
const data = await apiGet('/api/v1/routes', {}, { signal });
const routes = data.items || [];
@@ -444,17 +471,17 @@ export async function render(container, params, router) {
onExpand: handleExpand,
isExpanded: (r) => expandedId === r.id,
detail: (r) => detailCache.get(r.id),
navigate,
packetsEnabled,
};
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);
});
group.sort((a, b) =>
(a.from_label || '').localeCompare(b.from_label || '')
);
groupedSections.push(html`
<h2 class="text-lg font-semibold mt-6 mb-3 opacity-70">${t(`routes.visibility_${vis}`)}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@@ -668,7 +695,8 @@ export async function render(container, params, router) {
}
async function handleSave() {
const nameEl = document.getElementById('route-modal-name');
const fromEl = document.getElementById('route-modal-from');
const toEl = document.getElementById('route-modal-to');
const descEl = document.getElementById('route-modal-description');
const visEl = document.getElementById('route-modal-visibility');
const widthEl = document.getElementById('route-modal-width');
@@ -689,7 +717,8 @@ export async function render(container, params, router) {
}
const body = {
name: nameEl.value.trim(),
from_label: fromEl.value.trim(),
to_label: toEl.value.trim(),
description: descEl.value.trim() || null,
visibility: visEl.value,
match_width: parseInt(widthEl.value, 10) || 1,
+3 -3
View File
@@ -306,8 +306,9 @@
"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",
"delete_confirm": "Are you sure you want to delete route {{label}}?",
"from_label": "From",
"to_label": "To",
"description_label": "Description",
"visibility_label": "Visibility",
"visibility_community": "Community",
@@ -338,7 +339,6 @@
"nodes_count": "nodes",
"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.",
+39 -10
View File
@@ -228,26 +228,55 @@
"add_route": "Route toevoegen",
"edit_route": "Route bewerken",
"delete_route": "Route verwijderen",
"delete_confirm": "Weet u zeker dat u route {{name}} wilt verwwijderen?",
"delete_confirm": "Weet u zeker dat u route {{label}} wilt verwijderen?",
"from_label": "Van",
"to_label": "Naar",
"description_label": "Omschrijving",
"visibility_label": "Zichtbaarheid",
"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",
"reversible_label": "Omkeerbaar (beide richtingen)",
"nodes_count": "knooppunten",
"width_label": "Overeenkomstbreedte",
"width_hint_1": "Al het verkeer (~256 buckets)",
"width_hint_2": "Alleen 2-byte+ (~65K buckets)",
"width_hint_3": "Alleen 3-byte (~16M buckets)",
"node_ids_label": "Padknooppunten",
"node_ids_placeholder": "Zoek op naam of public key",
"node_ids_help": "Zoek en voeg minimaal 2 knooppunten toe om het pad te definiëren.",
"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."
"observers_empty": "Geen observers geselecteerd \u2014 alle observers worden gebruikt.",
"observers_placeholder": "Zoek om observer-knooppunten toe te voegen",
"window_label": "Venster (uren)",
"threshold_label": "Drempel",
"degraded_label": "Achteruitgang",
"span_label": "Max span",
"enabled_label": "Ingeschakeld",
"reversible_label": "Omkeerbaar (beide richtingen)",
"nodes_count": "knooppunten",
"disabled": "Uitgeschakeld",
"not_evaluated": "Nog niet geëvalueerd",
"diagnosis_healthy": "Route is gezond \u2014 voldoende packets volgen het geconfigureerde pad.",
"diagnosis_unhealthy": "Route is ongezond \u2014 observers horen verkeer, maar onvoldoende packets volgen het geconfigureerde pad.",
"diagnosis_no_coverage": "Geen dekking \u2014 geen in-scope observer heeft packets gehoord in het venster. De route ligt mogelijk plat of geen observer is bereikbaar.",
"contributing_observers": "Bijdragende observers",
"no_observers": "Geen bijdragende observers in het evaluatievenster.",
"recent_matches": "Recente overeenkomsten",
"width": "Breedte",
"window": "Venster",
"threshold": "Drempel",
"span": "Span",
"quality_clear": "Goed",
"quality_marginal": "Kritiek",
"quality_failing": "Storing",
"quality_no_coverage": "Geen dekking",
"quality_unknown": "Onbekend",
"min_nodes_error": "Minimaal 2 padknooppunten zijn vereist."
},
"not_found": {
"description": "De pagina die u zoekt bestaat niet of is verplaatst."
+6 -4
View File
@@ -408,7 +408,9 @@ class TestRouteMetrics:
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)
route = Route(
from_label="Test", to_label="Route", enabled=True, packet_count_threshold=3
)
api_db_session.add(route)
api_db_session.flush()
api_db_session.add(
@@ -431,13 +433,13 @@ class TestRouteMetrics:
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
assert 'route="Test -> Route"' 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.add(Route(from_label="Off", to_label="Off", enabled=False))
api_db_session.commit()
_clear_metrics_cache()
response = client_no_auth.get("/metrics")
assert 'route="Off"' not in response.text
assert 'route="Off -> Off"' not in response.text
+59 -28
View File
@@ -26,26 +26,34 @@ class TestListRoutes:
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.add(
Route(from_label="Public", to_label="Endpoint", visibility="community")
)
api_db_session.add(
Route(from_label="Secret", to_label="Endpoint", 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
labels = [r["from_label"] for r in resp.json()["items"]]
assert "Public" in labels
assert "Secret" not in labels
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.add(
Route(from_label="Public", to_label="Endpoint", visibility="community")
)
api_db_session.add(
Route(from_label="Secret", to_label="Endpoint", 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
labels = [r["from_label"] for r in resp.json()["items"]]
assert "Public" in labels
assert "Secret" in labels
class TestCreateRoute:
@@ -56,7 +64,8 @@ class TestCreateRoute:
resp = client_no_auth.post(
"/api/v1/routes",
json={
"name": "Route1",
"from_label": "Alpha",
"to_label": "Beta",
"node_public_keys": [n.public_key for n in nodes],
"match_width": 1,
},
@@ -64,7 +73,8 @@ class TestCreateRoute:
)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "Route1"
assert data["from_label"] == "Alpha"
assert data["to_label"] == "Beta"
assert len(data["route_nodes"]) == 2
assert data["route_nodes"][0]["expected_hash"] is not None
assert data["reversible"] is True
@@ -76,7 +86,8 @@ class TestCreateRoute:
resp = client_no_auth.post(
"/api/v1/routes",
json={
"name": "OneWay",
"from_label": "Alpha",
"to_label": "Beta",
"node_public_keys": [n.public_key for n in nodes],
"reversible": False,
},
@@ -85,14 +96,18 @@ class TestCreateRoute:
assert resp.status_code == 201
assert resp.json()["reversible"] is False
def test_duplicate_name_rejected(self, client_no_auth, api_db_session):
def test_duplicate_from_to_rejected(self, client_no_auth, api_db_session):
nodes = _sample_nodes(api_db_session)
api_db_session.add(Route(name="Dup"))
api_db_session.add(Route(from_label="Dup", to_label="End"))
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]},
json={
"from_label": "Dup",
"to_label": "End",
"node_public_keys": [n.public_key for n in nodes],
},
headers={"X-User-Roles": "admin"},
)
assert resp.status_code == 409
@@ -103,7 +118,11 @@ class TestCreateRoute:
resp = client_no_auth.post(
"/api/v1/routes",
json={"name": "R", "node_public_keys": [node.public_key]},
json={
"from_label": "A",
"to_label": "B",
"node_public_keys": [node.public_key],
},
headers={"X-User-Roles": "admin"},
)
assert resp.status_code == 422
@@ -114,7 +133,11 @@ class TestCreateRoute:
resp = client_no_auth.post(
"/api/v1/routes",
json={"name": "R", "node_public_keys": [node.public_key, node.public_key]},
json={
"from_label": "A",
"to_label": "B",
"node_public_keys": [node.public_key, node.public_key],
},
headers={"X-User-Roles": "admin"},
)
assert resp.status_code == 422
@@ -126,7 +149,8 @@ class TestCreateRoute:
resp = client_no_auth.post(
"/api/v1/routes",
json={
"name": "R",
"from_label": "A",
"to_label": "B",
"node_public_keys": [n.public_key for n in nodes],
"packet_count_threshold": 5,
"degraded_threshold": 3,
@@ -141,7 +165,11 @@ class TestCreateRoute:
resp = client_with_auth.post(
"/api/v1/routes",
json={"name": "R", "node_public_keys": [n.public_key for n in nodes]},
json={
"from_label": "A",
"to_label": "B",
"node_public_keys": [n.public_key for n in nodes],
},
headers={"Authorization": "Bearer test-read-key"},
)
assert resp.status_code == 403
@@ -150,7 +178,7 @@ class TestCreateRoute:
class TestGetRouteDetail:
def test_detail_shape(self, client_no_auth, api_db_session):
nodes = _sample_nodes(api_db_session, 3)
route = Route(name="R1")
route = Route(from_label="Alpha", to_label="Beta")
api_db_session.add(route)
api_db_session.flush()
for pos, n in enumerate(nodes):
@@ -167,7 +195,8 @@ class TestGetRouteDetail:
resp = client_no_auth.get(f"/api/v1/routes/{route.id}")
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "R1"
assert data["from_label"] == "Alpha"
assert data["to_label"] == "Beta"
assert len(data["route_nodes"]) == 3
assert "contributing_observers" in data
assert "recent_matches" in data
@@ -178,9 +207,9 @@ class TestGetRouteDetail:
class TestUpdateRoute:
def test_update_name(self, client_no_auth, api_db_session):
def test_update_from_to(self, client_no_auth, api_db_session):
nodes = _sample_nodes(api_db_session)
route = Route(name="OldName")
route = Route(from_label="OldFrom", to_label="OldTo")
api_db_session.add(route)
api_db_session.flush()
for pos, n in enumerate(nodes):
@@ -196,15 +225,17 @@ class TestUpdateRoute:
resp = client_no_auth.put(
f"/api/v1/routes/{route.id}",
json={"name": "NewName"},
json={"from_label": "NewFrom", "to_label": "NewTo"},
headers={"X-User-Roles": "admin"},
)
assert resp.status_code == 200
assert resp.json()["name"] == "NewName"
data = resp.json()
assert data["from_label"] == "NewFrom"
assert data["to_label"] == "NewTo"
def test_update_path_nodes(self, client_no_auth, api_db_session):
nodes = _sample_nodes(api_db_session, 2)
route = Route(name="R")
route = Route(from_label="A", to_label="B")
api_db_session.add(route)
api_db_session.flush()
for pos, n in enumerate(nodes):
@@ -234,7 +265,7 @@ class TestUpdateRoute:
class TestDeleteRoute:
def test_delete_success(self, client_no_auth, api_db_session):
route = Route(name="Bye")
route = Route(from_label="Bye", to_label="Gone")
api_db_session.add(route)
api_db_session.commit()
+1 -1
View File
@@ -47,7 +47,7 @@ def _make_reception(session, packet_hash: str, path: list[str], ts=None):
def _make_route(session, name, nodes, **kwargs):
route = Route(name=name, **kwargs)
route = Route(from_label=name, to_label=name, **kwargs)
session.add(route)
session.flush()
for pos, n in enumerate(nodes):
+45 -4
View File
@@ -87,7 +87,8 @@ def _make_route(
reversible: bool = True,
) -> Route:
route = Route(
name=name,
from_label=name,
to_label=name,
match_width=match_width,
packet_count_threshold=threshold,
degraded_threshold=degraded,
@@ -194,11 +195,21 @@ class TestDeriveQuality:
class TestEffectiveDegraded:
def test_explicit(self, db_session):
route = Route(name="t", packet_count_threshold=5, degraded_threshold=20)
route = Route(
from_label="t",
to_label="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)
route = Route(
from_label="t",
to_label="t",
packet_count_threshold=5,
degraded_threshold=None,
)
assert effective_degraded_threshold(route) == 10
@@ -445,10 +456,40 @@ class TestRecentMatches:
)
db_session.commit()
matches = recent_matches(db_session, route, limit=3)
matches = recent_matches(db_session, route, limit=3, now=_NOW)
assert len(matches) == 3
assert matches[0]["received_at"] > matches[1]["received_at"]
def test_returns_sliced_subpath(self, db_session):
"""Recent matches return only the hops between From and To, not the
full packet path."""
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 path has noise before AA and after BB; only AA..BB should be kept.
_make_reception(db_session, None, "pkt0", ["XX", "AA", "YY", "BB", "ZZ"])
db_session.commit()
matches = recent_matches(db_session, route, limit=3, now=_NOW)
assert len(matches) == 1
hops = matches[0]["hops"]
assert [h["node_hash"] for h in hops] == ["AA", "YY", "BB"]
def test_returns_sliced_subpath_reverse(self, db_session):
"""A reverse-direction packet is sliced in traversal order (To..From)."""
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], reversible=True)
_make_reception(db_session, None, "pkt0", ["XX", "BB", "YY", "AA", "ZZ"])
db_session.commit()
matches = recent_matches(db_session, route, limit=3, now=_NOW)
assert len(matches) == 1
hops = matches[0]["hops"]
assert [h["node_hash"] for h in hops] == ["BB", "YY", "AA"]
class TestPreviewRoute:
def test_normal_preview(self, db_session):