mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-10 10:52:48 +02:00
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:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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})>"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user