mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-18 15:56:14 +00:00
## 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>
99 lines
3.6 KiB
Go
99 lines
3.6 KiB
Go
package main
|
|
|
|
import "testing"
|
|
|
|
// resolver that only resolves the exact tokens it's told are unique.
|
|
func testResolver(unique map[string]string) func(string) string {
|
|
return func(tok string) string {
|
|
if pk, ok := unique[tok]; ok {
|
|
return pk
|
|
}
|
|
return "" // ambiguous / unknown → skip
|
|
}
|
|
}
|
|
|
|
func TestAttributeDirections_PredecessorAndSuccessor(t *testing.T) {
|
|
// path A(aa) -> N(01fa) -> B(bb): we hear A, B hears us.
|
|
unique := map[string]string{"AA": "aa00", "BB": "bb00"}
|
|
rows := []pathRow{{
|
|
observerPK: "obs1", payloadType: 5,
|
|
path: []string{"AA", "01FA", "BB"},
|
|
}}
|
|
d := attributeDirections(rows, map[string]bool{"01FA": true}, "01fa326b", testResolver(unique))
|
|
if d.we["aa00"] != 1 {
|
|
t.Fatalf("we_hear[aa00]=%d want 1", d.we["aa00"])
|
|
}
|
|
if d.they["bb00"] != 1 {
|
|
t.Fatalf("they_hear[bb00]=%d want 1", d.they["bb00"])
|
|
}
|
|
if d.relay != 1 {
|
|
t.Fatalf("relay=%d want 1", d.relay)
|
|
}
|
|
}
|
|
|
|
func TestAttributeDirections_LastHopObserverAndAdvertFirstHop(t *testing.T) {
|
|
snr := 4.0
|
|
rows := []pathRow{
|
|
// N is last hop → observer heard us directly (+snr).
|
|
{observerPK: "obsx", payloadType: 5, path: []string{"AA", "01FA"}, snr: &snr},
|
|
// N is first hop of an ADVERT (type 4) → we heard the originator.
|
|
{observerPK: "obsy", payloadType: 4, fromPubkey: "origin1", path: []string{"01FA", "CC"}},
|
|
}
|
|
d := attributeDirections(rows, map[string]bool{"01FA": true}, "01fa326b",
|
|
testResolver(map[string]string{"CC": "cc00"}))
|
|
if d.obs["obsx"] == nil || d.obs["obsx"].count != 1 {
|
|
t.Fatalf("observer obsx not counted")
|
|
}
|
|
if d.obs["obsx"].snrN != 1 || d.obs["obsx"].snrSum != 4.0 {
|
|
t.Fatalf("observer snr not aggregated")
|
|
}
|
|
if d.they["obsx"] != 1 {
|
|
t.Fatalf("they_hear[obsx]=%d want 1", d.they["obsx"])
|
|
}
|
|
if d.we["origin1"] != 1 {
|
|
t.Fatalf("we_hear[origin1]=%d want 1 (advert first-hop)", d.we["origin1"])
|
|
}
|
|
if d.they["cc00"] != 1 {
|
|
t.Fatalf("they_hear[cc00]=%d want 1 (successor)", d.they["cc00"])
|
|
}
|
|
}
|
|
|
|
func TestAttributeDirections_AmbiguousSkippedAndSelfIgnored(t *testing.T) {
|
|
// No observer, so the last-hop observer branch can't fire — this isolates
|
|
// the resolve logic. ZZ is unresolved (ambiguous → skipped); the trailing
|
|
// 01FA resolves to self (ourPK) and must be ignored as a successor.
|
|
rows := []pathRow{{observerPK: "", payloadType: 5, path: []string{"ZZ", "01FA", "01FA"}}}
|
|
d := attributeDirections(rows, map[string]bool{"01FA": true}, "01fa326b",
|
|
testResolver(map[string]string{"01FA": "01fa326b"}))
|
|
if len(d.we) != 0 || len(d.they) != 0 {
|
|
t.Fatalf("ambiguous/self should yield no edges, got we=%v they=%v", d.we, d.they)
|
|
}
|
|
}
|
|
|
|
func TestAttributeDirections_LastHopWithObserverCountsObserver(t *testing.T) {
|
|
// Guards the case the previous test deliberately excludes: when our token is
|
|
// the last hop AND an observer is present, that observer heard us directly.
|
|
rows := []pathRow{{observerPK: "obs1", payloadType: 5, path: []string{"ZZ", "01FA"}}}
|
|
d := attributeDirections(rows, map[string]bool{"01FA": true}, "01fa326b",
|
|
testResolver(map[string]string{}))
|
|
if d.they["obs1"] != 1 || d.obs["obs1"] == nil || d.obs["obs1"].count != 1 {
|
|
t.Fatalf("last-hop observer should be counted, got they=%v", d.they)
|
|
}
|
|
}
|
|
|
|
func TestReliableTokens(t *testing.T) {
|
|
// pm where "01fa" is unique but "01" is shared (collision).
|
|
nodes := []nodeInfo{
|
|
{PublicKey: "01fa326b0000", Role: "repeater"},
|
|
{PublicKey: "0188aaaa0000", Role: "repeater"},
|
|
}
|
|
pm := buildPrefixMap(nodes)
|
|
toks := reliableTokens("01fa326b0000", pm)
|
|
if !toks["01FA"] {
|
|
t.Fatalf("expected 01FA reliable, got %v", toks)
|
|
}
|
|
if toks["01"] {
|
|
t.Fatalf("1-byte 01 must be excluded (collision), got %v", toks)
|
|
}
|
|
}
|