feat(nodes): per-node Reach page + GET /api/nodes/{pubkey}/reach (directional link quality) (#1625)

## What

Adds a per-node **Reach** view that answers "how well does this specific
node hear, and get heard by, its neighbours?" — both as a standalone
page (`#/nodes/{pubkey}/reach`) and as a section on the node detail
page.

New endpoint: **`GET /api/nodes/{pubkey}/reach`**.

## What it measures

For the target node it derives, from raw `path_json` adjacency (a path
travels origin→observer, so in `[A,B]` B received A directly):

- **Directional link counts** per neighbour: `we_hear` (how often we
received them) vs `they_hear` (how often they received us).
- **Bidirectional / bottleneck**: a link is two-way stable when both
directions > 0; the weaker direction is the bottleneck and rates real
two-way reliability.
- **Importance**: neighbour degree + rank, relay-observation volume,
bidirectional-link count, direct-observer count.
- **Direct observers**: who received the node at 0 hops, with SNR.

Reliability rule: a neighbour is only attributed when its pubkey
**prefix is unique** at the path's byte length (collisions are skipped,
never misattributed).

## UI

- Standalone Reach page + node-detail section.
- Reusable bidirectional link map (OSM) with links coloured by
bottleneck.
- Incoming/outgoing toggles to isolate each direction.

## Naming note (deliberate, no collision)

This is distinct from the existing **per-observer reachability** in
topology analytics (`ReachNode` / `ObserverReach` / `perObserverReach`).
This PR adds its own `NodeReach*` response structs in a new
`node_reach.go` and a new `/api/nodes/{pubkey}/reach` route — there are
no symbol or route collisions (verified: `go build ./...` clean). Happy
to rename to disambiguate further (e.g. "Link Quality") if you'd prefer
to reserve "Reach" for the per-observer feature.

## Testing

- `cmd/server`: endpoint shape/404/limit-clamp + unit tests for token
derivation and directional attribution, plus a scan benchmark — all
pass.
- Frontend: helper tests + Reach-page E2E (`test-node-reach-e2e.js`),
standalone route + incoming/outgoing toggles.
- `go build ./...` and `eslint public/*.js` (no-undef) clean.

## Docs

Design spec, implementation plan, and the `GET
/api/nodes/{pubkey}/reach` API contract are included under `docs/`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
efiten
2026-06-08 13:11:06 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent efd6464204
commit 47f85f6c4c
16 changed files with 2567 additions and 6 deletions
+58
View File
@@ -23,6 +23,7 @@
- [GET /api/nodes/:pubkey/health](#get-apinodespubkeyhealth)
- [GET /api/nodes/:pubkey/paths](#get-apinodespubkeypaths)
- [GET /api/nodes/:pubkey/analytics](#get-apinodespubkeyanalytics)
- [GET /api/nodes/:pubkey/reach](#get-apinodespubkeyreach)
- [GET /api/packets](#get-apipackets)
- [GET /api/packets/timestamps](#get-apipacketstimestamps)
- [GET /api/packets/:id](#get-apipacketsid)
@@ -672,6 +673,63 @@ Per-node analytics over a time range.
---
## GET /api/nodes/:pubkey/reach
Per-node RF reach report (two-way link quality). Computes **directional** link counts from raw
path adjacency (a flood path is recorded origin→observer, so in `[A,B]` B received
A directly). A link is **bidirectional** when both directions have observations;
the **bottleneck** (weaker direction) rates two-way stability. Read-only; bounded
to a recent window. Identifies nodes only by **unique 23 byte** path prefixes
(1-byte prefixes collide and are excluded).
### Query Parameters
| Param | Type | Default | Description |
|--------|--------|---------|--------------------------------------|
| `days` | number | `7` | Lookback window, clamped 130 |
### Response `200`
```jsonc
{
"node": { "pubkey": string, "name": string, "role": string,
"lat": number | null, "lon": number | null, "first_seen": string (ISO) },
"window": { "days": number, "since": string (ISO) },
"reliable_tokens": [string], // uppercase hex prefixes unique to this node ([] if unidentifiable)
"importance": {
"neighbor_degree": number, // all-time, from neighbor_edges
"degree_rank": number, // 1-based rank among nodes with edges
"nodes_with_edges": number,
"relay_observations": number, // windowed obs with this node anywhere in path
"bidirectional_links":number,
"direct_observers": number
},
"direct_observers": [
{ "pubkey": string, "name": string, "count": number,
"avg_snr": number | null, "lat": number | null, "lon": number | null,
"distance_km": number | null }
],
"links": [
{ "pubkey": string, "name": string, "role": string,
"lat": number | null, "lon": number | null,
"we_hear": number, "they_hear": number,
"bottleneck": number, "bidir": boolean,
"distance_km": number | null }
]
}
```
`reliable_tokens: []` means the node has no unique 13 byte prefix and cannot be
reliably identified in paths; `links`/`direct_observers` will be empty.
### Response `404`
```json
{ "error": "Not found" }
```
---
## GET /api/packets
Paginated packet (transmission) list with filtering.