diff --git a/docs/plans/20260705-2306-mesh-link-monitoring/plan.md b/docs/plans/20260705-2306-mesh-link-monitoring/plan.md index 2006e5f..97ad089 100644 --- a/docs/plans/20260705-2306-mesh-link-monitoring/plan.md +++ b/docs/plans/20260705-2306-mesh-link-monitoring/plan.md @@ -148,12 +148,14 @@ first bytes co-occur far apart on an unrelated long flood path. - **F1 — Link configuration.** An operator with the `admin` role can create, update, and delete Links. Each Link has: a unique name, optional description, a `visibility` (community/member/operator/admin, default `community`), a `match_width` (1/2/3, - default 1), `window_hours`, `packet_count_threshold`, `max_hop_span` - (nullable int, default `null` = unlimited), an `enabled` flag, an ordered + default 1), `window_hours` (default 24, range 1..720), + `packet_count_threshold` (default 3, range 1..10000), `max_hop_span` + (nullable int, default `null` = unlimited), an `enabled` flag (default true), an ordered list of **≥2** path node specs, and an optional observer scope. - **F2 — Path node specs.** Each path entry selects a known Node (from `nodes`); the system derives `expected_hash = public_key[:2*match_width]` at - save time. Entries are ordered; the subsequence match preserves that order + save time. Entries are ordered; entries must be **distinct** (the same node + twice in one link is invalid). The subsequence match preserves that order with gaps allowed (intermediary nodes may sit between configured entries). `match_width` is **per-link**: an operator who knows the traffic in a given area is uniformly 2- or 3-byte can widen that link's width to drop from 256 @@ -176,15 +178,38 @@ first bytes co-occur far apart on an unrelated long flood path. **distinct packets** (`packet_hash`) whose path, in at least one observer's reception (within the observer scope, if set), contains the configured ordered subsequence within the window and within `max_hop_span` (if set), is - greater than or equal to `packet_count_threshold`. + greater than or equal to `packet_count_threshold`. Each evaluation writes a + `link_result` row whose `state` is one of: + - **`healthy`** — `matched_count >= packet_count_threshold`. + - **`unhealthy`** — in-scope observers received packets in the window but + `matched_count < threshold` (route may be down). + - **`no_coverage`** — `matched_count == 0` **and** no in-scope observer + received any packet with a non-empty path in the window (cannot + distinguish route-down from no-listener; the operator action is to + add/widen observers, not assume the route failed). When the scope is "all + observers", `no_coverage` is only reachable when the whole mesh is silent. + + The evaluator separates the latter two with one extra existence check (any + in-scope `packet_path_hops` row in the window). Disabled links are excluded + from evaluation entirely — they produce no `link_result`, are omitted from + Prometheus output, and render a gray **disabled** badge (that badge comes + from `link.enabled`, not a result state). - **F5 — Visibility scoping.** The link list is filtered by the requesting - user's role exactly like channels (`VISIBILITY_LEVELS`). Reads are role- - scoped; writes are admin-only. -- **F6 — UI.** A dedicated `/links` page lists links with health badges, - grouped by visibility, with admin CRUD modals (mirror of `channels.js`). The - node picker shows prefix-collision badges and a live "matches in 24h" - preview; it warns on mixed-width intent and recommends 3-node paths. -- **F7 — Prometheus.** `/metrics` emits `meshcore_link_healthy{link}`, + user's role exactly like channels, using `VISIBILITY_LEVELS` from + `api/channel_visibility.py`. Reads are role-scoped; writes are admin-only. +- **F6 — UI.** A dedicated `/links` page (see **UI Design** below) behaves as + a status board: a health summary strip, cards grouped by visibility with + unhealthy/no_coverage sorted first, a four-state badge (healthy / + unhealthy / no_coverage / disabled), and an inline accordion expand + revealing the diagnosis, contributing observers, the latest matched path, a + config recap, and a deep-link to the packets view. Admin CRUD uses a wider + modal containing a node path-builder and observer picker with prefix- + collision badges, a live "matches in 24h" preview, and a segmented + `match_width` control. Mirrors `channels.js` structure throughout. +- **F7 — Prometheus.** `/metrics` emits `meshcore_link_healthy{link}` (1 if + `state == healthy` else 0), `meshcore_link_state{link}` (0=healthy, + 1=unhealthy, 2=no_coverage — so the amber case is independently alertable: + `state==1` = route may be down, `state==2` = add observers), `meshcore_link_matched_packets{link}`, and `meshcore_link_threshold{link}` for **all** enabled links (no visibility filtering on the monitoring feed). - **F8 — Seeding (no-auth provisioning).** Site operators can load Links from @@ -232,18 +257,29 @@ first bytes co-occur far apart on an unrelated long flood path. modeled on the spam re-scoring sweep (`subscriber.py:545-597`), runs at a configurable interval (default 60s, `0` disables), performs an immediate first run on startup, and upserts one row per link into `link_results` using - the existing dialect-specific `on_conflict_do_update` pattern - (`event_observer.py:143-158`). -- **T5 — Indexing.** `packet_path_hops` carries `INDEX (node_hash, - raw_packet_id, position)` (drives the chain), `INDEX (raw_packet_id)` (FK + - per-reception lookup + cascade), and the denormalized `packet_hash` for the - dedup count. + a dialect-specific `on_conflict_do_update` (postgresql + `pg_insert(...).on_conflict_do_update(...)` / sqlite + `sqlite_insert(...).on_conflict_do_update(...)`), modeled on the existing + dialect branch in `common/models/event_observer.py:143-158`. That branch + currently uses `on_conflict_do_nothing`; no `do_update` variant exists in + the codebase yet, so the evaluator authors it (standard SQLAlchemy idiom). +- **T5 — Indexing.** Every hop query is time-windowed, so `received_at` + belongs in the leading index, not as a post-filter. `packet_path_hops` + carries `INDEX (node_hash, received_at)` — drives the first-prefix + window + range scan in `fetch_candidate_paths` (a two-range seek: prefix then + recent) — and `INDEX (raw_packet_id, position)` — serves the per-reception + ordered-hop fetch, the FK lookup, and the `ON DELETE CASCADE` (leftmost- + prefix covers equality-on-`raw_packet_id`, so no separate FK index). The + denormalized `packet_hash`/`received_at` columns back the distinct count + and the window without a join back to `raw_packets`. - **T6 — Backend-agnostic.** All DDL via Alembic **batch mode** (SQLite-safe); queries use SQLAlchemy Core/ORM with a Python-computed `window_since` datetime (never `NOW() - INTERVAL`). - **T7 — Retention.** `packet_path_hops.raw_packet_id` uses `ON DELETE CASCADE`, so the existing cleanup in `cleanup.py` removes hop rows for free when aged `raw_packets` are deleted; no cleanup change required. + `link_results` and `link_nodes`/`link_observers` cascade-delete with their + parent `links` row (standard FK cascade). - **T8 — Feature gating.** New `feature_links=True` UI flag and `link_evaluator_interval_seconds=60` collector knob in `common/config.py`, surfaced in `.env.example`. Hop extraction only runs when raw packet capture @@ -262,6 +298,79 @@ first bytes co-occur far apart on an unrelated long flood path. explicit value is honored, since the operator has filesystem access and links carry no secret (unlike channel keys). +## UI Design + +Decisions captured during plan review. The build lives in Phase 7; this +section is the single source of truth for the design. + +### Mental model: status board, not catalog +Channels is a catalog (CRUD list of static keys). Links is a **status board** +— the page's primary job is glancing at health; configuration is secondary +admin work. Every layout choice below follows from that. + +### List page (`/links`) +- **Summary strip** at the top: `● N healthy · ● M unhealthy · ◐ K no + coverage · ◌ D disabled` (live counts from the embedded results). +- **Cards grouped by visibility** (`VISIBILITY_ORDER`, like channels), but + within each group **sorted unhealthy / no_coverage first** so broken routes + surface immediately. +- Header + admin "Add link" button + empty state mirror `channels.js`. + +### The link card +- **Health badge** — four states, color-coded: `healthy` (green ●), + `unhealthy` (red ●), `no_coverage` (amber ◐), `disabled` (gray ◌, from + `link.enabled`). +- **Path chips** — the configured nodes as `[Ipswich RP] → … → [Norwich RP]`, + conveying "ordered, gaps allowed". +- **Numbers line** — `matched / threshold · window · evaluated Xm ago`. +- Admin edit/delete buttons (channels pattern). +- **Click → inline accordion expand** (not a modal, not a separate page). + +### Card expand contents (lazy `GET /api/v1/links/{id}`) +1. **Diagnosis line** — turns the amber/red split into a sentence. +2. **Contributing observers with counts** — an empty list *is* the + `no_coverage` signal. +3. **Latest match (~3) with the observed path** — configured nodes marked ✓, + intermediates shown, so the operator can verify a real match vs a + collision. Served by a new `recent_matches(link, limit)` engine helper. +4. **Config recap** (read-only) — width, span, window, observer scope. +5. **"View packets" deep-link** — to the existing packet-groups page filtered + 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. +- **`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 `