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
+432
View File
@@ -0,0 +1,432 @@
package main
import (
"database/sql"
"encoding/json"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/gorilla/mux"
)
// advertPayloadType mirrors MeshCore ADVERT (0x04). Local const so this file
// stays independent of decoder internals.
const advertPayloadType = 4
// pathRow is one observation fed to attributeDirections. path tokens are
// uppercase hex hop prefixes (as stored in observations.path_json).
type pathRow struct {
observerPK string // lowercase pubkey of the observer (may be "")
fromPubkey string // lowercase originator pubkey (may be "")
payloadType int
path []string
snr *float64
}
type obsAgg struct {
count int
snrSum float64
snrN int
}
type dirCounts struct {
we map[string]int
they map[string]int
obs map[string]*obsAgg
relay int
}
// attributeDirections walks each path and attributes directional evidence for
// the target node (identified by any token in ourTokens). resolve maps a hop
// token → a unique relay pubkey ("" when ambiguous/unknown → skipped). ourPK is
// the target's own pubkey (lowercase) so self-edges are ignored.
func attributeDirections(rows []pathRow, ourTokens map[string]bool, ourPK string, resolve func(string) string) dirCounts {
d := dirCounts{we: map[string]int{}, they: map[string]int{}, obs: map[string]*obsAgg{}}
for _, r := range rows {
n := len(r.path)
if n == 0 {
continue
}
hit := false
for i, tok := range r.path {
if !ourTokens[tok] {
continue
}
hit = true
// predecessor → we heard it
if i > 0 {
if pk := resolve(r.path[i-1]); pk != "" && pk != ourPK {
d.we[pk]++
}
} else if r.payloadType == advertPayloadType && r.fromPubkey != "" && r.fromPubkey != ourPK {
d.we[r.fromPubkey]++
}
// successor → it heard us; or if we're the last hop, the observer did
if i < n-1 {
if pk := resolve(r.path[i+1]); pk != "" && pk != ourPK {
d.they[pk]++
}
} else if r.observerPK != "" && r.observerPK != ourPK {
d.they[r.observerPK]++
a := d.obs[r.observerPK]
if a == nil {
a = &obsAgg{}
d.obs[r.observerPK] = a
}
a.count++
if r.snr != nil {
a.snrSum += *r.snr
a.snrN++
}
}
}
if hit {
d.relay++
}
}
return d
}
// reliableTokens returns the uppercase hex prefixes (1, 2, 3 byte) of pubkey
// that are UNIQUE among relay-capable nodes in pm. 1-byte prefixes almost always
// collide and are excluded; only unique prefixes can identify a node in a path.
func reliableTokens(pubkey string, pm *prefixMap) map[string]bool {
out := map[string]bool{}
lpk := strings.ToLower(pubkey)
for _, l := range []int{2, 4, 6} { // hex chars = 1,2,3 bytes
if len(lpk) < l {
continue
}
p := lpk[:l]
if pm != nil && len(pm.m[p]) == 1 {
out[strings.ToUpper(p)] = true
}
}
return out
}
// uniqueResolve returns the single relay pubkey (lowercase) for a hop token, or
// "" when the token resolves to zero or multiple candidates (conservative).
func uniqueResolve(pm *prefixMap, token string) string {
if pm == nil {
return ""
}
cands := pm.m[strings.ToLower(token)]
if len(cands) == 1 {
return strings.ToLower(cands[0].PublicKey)
}
return ""
}
type NodeReachInfo struct {
Pubkey string `json:"pubkey"`
Name string `json:"name"`
Role string `json:"role"`
Lat *float64 `json:"lat"`
Lon *float64 `json:"lon"`
FirstSeen string `json:"first_seen"`
}
type NodeReachWindow struct {
Days int `json:"days"`
Since string `json:"since"`
}
type NodeReachImportance struct {
NeighborDegree int `json:"neighbor_degree"`
DegreeRank int `json:"degree_rank"`
NodesWithEdges int `json:"nodes_with_edges"`
RelayObservations int `json:"relay_observations"`
BidirectionalLinks int `json:"bidirectional_links"`
DirectObservers int `json:"direct_observers"`
}
type NodeReachObserver struct {
Pubkey string `json:"pubkey"`
Name string `json:"name"`
Count int `json:"count"`
AvgSNR *float64 `json:"avg_snr"`
Lat *float64 `json:"lat"`
Lon *float64 `json:"lon"`
DistanceKm *float64 `json:"distance_km"`
}
type NodeReachLink struct {
Pubkey string `json:"pubkey"`
Name string `json:"name"`
Role string `json:"role"`
Lat *float64 `json:"lat"`
Lon *float64 `json:"lon"`
WeHear int `json:"we_hear"`
TheyHear int `json:"they_hear"`
Bottleneck int `json:"bottleneck"`
Bidir bool `json:"bidir"`
DistanceKm *float64 `json:"distance_km"`
}
type NodeReachResponse struct {
Node NodeReachInfo `json:"node"`
Window NodeReachWindow `json:"window"`
ReliableTokens []string `json:"reliable_tokens"`
Importance NodeReachImportance `json:"importance"`
DirectObservers []NodeReachObserver `json:"direct_observers"`
Links []NodeReachLink `json:"links"`
}
func fptr(v float64) *float64 { return &v }
// gpsPtrs returns (lat,lon) pointers, nil when the node has no GPS (0,0).
func gpsPtrs(info nodeInfo, ok bool) (*float64, *float64) {
if !ok || !info.HasGPS {
return nil, nil
}
return fptr(info.Lat), fptr(info.Lon)
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
// clampDays bounds the lookback window to [1,30]; default callers pass 7.
func clampDays(d int) int {
if d < 1 {
return 1
}
if d > 30 {
return 30
}
return d
}
// --- bounded TTL cache (perf is gated by the time window; this just avoids
// recompute under dashboard polling). Keyed "pubkey|days". ---
const (
reachCacheTTL = 5 * time.Minute
reachCacheMax = 256
)
type reachCacheEntry struct {
at time.Time
raw []byte
}
var (
reachCacheMu sync.Mutex
reachCache = map[string]reachCacheEntry{}
)
func reachCacheGet(key string) ([]byte, bool) {
reachCacheMu.Lock()
defer reachCacheMu.Unlock()
e, ok := reachCache[key]
if !ok || time.Since(e.at) > reachCacheTTL {
return nil, false
}
return e.raw, true
}
func reachCachePut(key string, raw []byte) {
reachCacheMu.Lock()
defer reachCacheMu.Unlock()
if len(reachCache) >= reachCacheMax {
reachCache = map[string]reachCacheEntry{} // crude bounded reset
}
reachCache[key] = reachCacheEntry{at: time.Now(), raw: raw}
}
func (s *Server) handleNodeReach(w http.ResponseWriter, r *http.Request) {
pubkey := strings.ToLower(mux.Vars(r)["pubkey"])
if s.cfg != nil && s.cfg.IsBlacklisted(pubkey) {
writeError(w, 404, "Not found")
return
}
days := 7
if v := r.URL.Query().Get("days"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
days = n
}
}
days = clampDays(days)
cacheKey := pubkey + "|" + strconv.Itoa(days)
if raw, ok := reachCacheGet(cacheKey); ok {
w.Header().Set("Content-Type", "application/json")
w.Write(raw)
return
}
resp, ok := s.computeNodeReach(pubkey, days)
if !ok {
writeError(w, 404, "Not found")
return
}
raw, _ := json.Marshal(resp)
reachCachePut(cacheKey, raw)
w.Header().Set("Content-Type", "application/json")
w.Write(raw)
}
// computeNodeReach does the read-only scan + assembly. ok=false → 404.
func (s *Server) computeNodeReach(pubkey string, days int) (NodeReachResponse, bool) {
if s.store == nil || s.db == nil || s.db.conn == nil {
return NodeReachResponse{}, false
}
nodeMap := s.buildNodeInfoMap()
self, found := nodeMap[pubkey]
if !found {
return NodeReachResponse{}, false
}
_, pm := s.store.getCachedNodesAndPM()
tokens := reliableTokens(pubkey, pm)
since := time.Now().UTC().Add(-time.Duration(days) * 24 * time.Hour)
sinceEpoch := since.Unix()
var d dirCounts
if len(tokens) > 0 {
rows := s.scanReachRows(tokens, sinceEpoch)
d = attributeDirections(rows, tokens, pubkey, func(tok string) string {
return uniqueResolve(pm, tok)
})
} else {
d = dirCounts{we: map[string]int{}, they: map[string]int{}, obs: map[string]*obsAgg{}}
}
// importance: neighbor_edges degree + rank (all-time)
var degree, rank, nodesWithEdges int
s.db.conn.QueryRow(`
WITH dd AS (SELECT node_a pk, count c FROM neighbor_edges
UNION ALL SELECT node_b, count FROM neighbor_edges),
aa AS (SELECT pk, COUNT(*) neigh FROM dd GROUP BY pk)
SELECT (SELECT COUNT(*) FROM aa),
COALESCE((SELECT neigh FROM aa WHERE pk=?),0),
(SELECT 1+COUNT(*) FROM aa WHERE neigh > COALESCE((SELECT neigh FROM aa WHERE pk=?),0))
`, pubkey, pubkey).Scan(&nodesWithEdges, &degree, &rank)
// node first_seen (nodeInfo only carries last_seen; the contract wants first_seen)
var firstSeen sql.NullString
s.db.conn.QueryRow(`SELECT first_seen FROM nodes WHERE LOWER(public_key)=?`, pubkey).Scan(&firstSeen)
// assemble links
links := []NodeReachLink{}
bidir := 0
seen := map[string]bool{}
for pk := range d.we {
seen[pk] = true
}
for pk := range d.they {
seen[pk] = true
}
for pk := range seen {
we, they := d.we[pk], d.they[pk]
info := nodeMap[pk]
lat, lon := gpsPtrs(info, true)
var dist *float64
if self.HasGPS && info.HasGPS {
dist = fptr(haversineKm(self.Lat, self.Lon, info.Lat, info.Lon))
}
b := we > 0 && they > 0
if b {
bidir++
}
links = append(links, NodeReachLink{
Pubkey: pk, Name: info.Name, Role: info.Role, Lat: lat, Lon: lon,
WeHear: we, TheyHear: they, Bottleneck: minInt(we, they), Bidir: b, DistanceKm: dist,
})
}
sort.Slice(links, func(i, j int) bool {
if links[i].Bidir != links[j].Bidir {
return links[i].Bidir
}
if links[i].Bottleneck != links[j].Bottleneck {
return links[i].Bottleneck > links[j].Bottleneck
}
return links[i].WeHear+links[i].TheyHear > links[j].WeHear+links[j].TheyHear
})
// direct observers
directObs := []NodeReachObserver{}
for pk, a := range d.obs {
info := nodeMap[pk]
lat, lon := gpsPtrs(info, true)
var avg, dist *float64
if a.snrN > 0 {
avg = fptr(a.snrSum / float64(a.snrN))
}
if self.HasGPS && info.HasGPS {
dist = fptr(haversineKm(self.Lat, self.Lon, info.Lat, info.Lon))
}
directObs = append(directObs, NodeReachObserver{
Pubkey: pk, Name: info.Name, Count: a.count, AvgSNR: avg, Lat: lat, Lon: lon, DistanceKm: dist,
})
}
sort.Slice(directObs, func(i, j int) bool { return directObs[i].Count > directObs[j].Count })
toks := make([]string, 0, len(tokens))
for t := range tokens {
toks = append(toks, t)
}
sort.Strings(toks)
selfLat, selfLon := gpsPtrs(self, true)
return NodeReachResponse{
Node: NodeReachInfo{Pubkey: pubkey, Name: self.Name, Role: self.Role,
Lat: selfLat, Lon: selfLon, FirstSeen: firstSeen.String},
Window: NodeReachWindow{Days: days, Since: since.Format(time.RFC3339)},
ReliableTokens: toks,
Importance: NodeReachImportance{
NeighborDegree: degree, DegreeRank: rank, NodesWithEdges: nodesWithEdges,
RelayObservations: d.relay, BidirectionalLinks: bidir, DirectObservers: len(directObs),
},
DirectObservers: directObs,
Links: links,
}, true
}
// scanReachRows reads windowed observations whose path contains any reliable
// token, with the originator + observer + snr needed for attribution.
func (s *Server) scanReachRows(tokens map[string]bool, sinceEpoch int64) []pathRow {
likes := make([]string, 0, len(tokens))
args := []interface{}{sinceEpoch}
for tok := range tokens {
likes = append(likes, "o.path_json LIKE ?")
args = append(args, "%\""+tok+"\"%")
}
q := `SELECT COALESCE(obs.id,''), COALESCE(t.from_pubkey,''), COALESCE(t.payload_type,0), o.path_json, o.snr
FROM observations o
JOIN transmissions t ON t.id = o.transmission_id
LEFT JOIN observers obs ON obs.rowid = o.observer_idx
WHERE o.timestamp >= ? AND (` + strings.Join(likes, " OR ") + `)`
rows, err := s.db.conn.Query(q, args...)
if err != nil {
return nil
}
defer rows.Close()
var out []pathRow
for rows.Next() {
var oid, fpk, pj string
var pt int
var snr sql.NullFloat64
if err := rows.Scan(&oid, &fpk, &pt, &pj, &snr); err != nil {
continue
}
var raw []string
if json.Unmarshal([]byte(pj), &raw) != nil || len(raw) == 0 {
continue
}
path := make([]string, len(raw))
for i, h := range raw {
path[i] = strings.ToUpper(h)
}
pr := pathRow{observerPK: strings.ToLower(oid), fromPubkey: strings.ToLower(fpk),
payloadType: pt, path: path}
if snr.Valid {
v := snr.Float64
pr.snr = &v
}
out = append(out, pr)
}
return out
}
+55
View File
@@ -0,0 +1,55 @@
package main
import (
"database/sql"
"fmt"
"testing"
_ "modernc.org/sqlite"
)
// benchReachDB builds an in-memory DB with nObs observations whose path
// contains the "01FA" token, for benchmarking scanReachRows.
func benchReachDB(b *testing.B, nObs int) *DB {
b.Helper()
conn, err := sql.Open("sqlite", ":memory:")
if err != nil {
b.Fatal(err)
}
schema := []string{
`CREATE TABLE transmissions (id INTEGER PRIMARY KEY, hash TEXT, first_seen TEXT, payload_type INTEGER, from_pubkey TEXT)`,
`CREATE TABLE observers (id TEXT PRIMARY KEY, name TEXT)`,
`CREATE TABLE observations (id INTEGER PRIMARY KEY, transmission_id INTEGER, observer_idx INTEGER, snr REAL, path_json TEXT, timestamp INTEGER)`,
`CREATE INDEX idx_obs_ts ON observations(timestamp)`,
}
for _, s := range schema {
if _, err := conn.Exec(s); err != nil {
b.Fatal(err)
}
}
tx, _ := conn.Begin()
tx.Exec(`INSERT INTO observers (id, name) VALUES ('OBS', 'o')`)
for i := 0; i < nObs; i++ {
tx.Exec(`INSERT INTO transmissions (id, hash, first_seen, payload_type, from_pubkey) VALUES (?,?,?,5,'')`,
i, fmt.Sprintf("h%d", i), "2026-06-07T00:00:00Z")
tx.Exec(`INSERT INTO observations (id, transmission_id, observer_idx, snr, path_json, timestamp) VALUES (?,?,1,-7.0,?,?)`,
i, i, `["AA","01FA","BB"]`, 1000)
}
if err := tx.Commit(); err != nil {
b.Fatal(err)
}
return &DB{conn: conn}
}
func BenchmarkNodeReachScan(b *testing.B) {
db := benchReachDB(b, 5000)
srv := &Server{db: db}
tokens := map[string]bool{"01FA": true}
b.ResetTimer()
for i := 0; i < b.N; i++ {
rows := srv.scanReachRows(tokens, 0)
if len(rows) == 0 {
b.Fatal("expected rows")
}
}
}
+76
View File
@@ -0,0 +1,76 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gorilla/mux"
)
func serveReach(srv *Server, path string) *httptest.ResponseRecorder {
router := mux.NewRouter()
router.HandleFunc("/api/nodes/{pubkey}/reach", srv.handleNodeReach).Methods("GET")
req := httptest.NewRequest("GET", path, nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
return rr
}
func TestClampDays(t *testing.T) {
cases := []struct{ in, want int }{{0, 1}, {-5, 1}, {1, 1}, {7, 7}, {30, 30}, {31, 30}, {999, 30}}
for _, c := range cases {
if got := clampDays(c.in); got != c.want {
t.Errorf("clampDays(%d)=%d want %d", c.in, got, c.want)
}
}
}
func TestNodeReach_UnknownNode(t *testing.T) {
srv := makeTestServer(makeTestGraph()) // no store/db wired → 404
rr := serveReach(srv, "/api/nodes/deadbeef/reach")
if rr.Code != http.StatusNotFound {
t.Fatalf("status=%d want 404", rr.Code)
}
}
func TestNodeReach_ShapeAndClamp(t *testing.T) {
db := setupTestDBv2(t)
const pk = "01fa326b475800a31105abcb9e4cac000b3e5d9e2b5ba0739981ce8d5f3a6754"
mustExecDB(t, db, `INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
VALUES ('`+pk+`', 'BE-Test', 'repeater', 50.9, 5.4, '2026-06-07T00:00:00Z', '2026-06-01T00:00:00Z', 3)`)
cfg := &Config{}
srv := &Server{store: newTestStoreWithDB(t, db, cfg), db: db, cfg: cfg, perfStats: NewPerfStats()}
rr := serveReach(srv, "/api/nodes/"+pk+"/reach?days=999")
if rr.Code != http.StatusOK {
t.Fatalf("status=%d want 200 (body=%s)", rr.Code, rr.Body.String())
}
var resp NodeReachResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("bad json: %v", err)
}
if resp.Window.Days != 30 {
t.Fatalf("days not clamped to 30: %d", resp.Window.Days)
}
if resp.Links == nil || resp.DirectObservers == nil || resp.ReliableTokens == nil {
t.Fatalf("array fields must be non-nil (never null)")
}
if !contains(resp.ReliableTokens, "01FA") {
t.Fatalf("expected 01FA reliable token, got %v", resp.ReliableTokens)
}
if resp.Node.FirstSeen != "2026-06-01T00:00:00Z" {
t.Fatalf("first_seen not sourced from nodes table: %q", resp.Node.FirstSeen)
}
}
func contains(s []string, v string) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}
+98
View File
@@ -0,0 +1,98 @@
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)
}
}
+1
View File
@@ -238,6 +238,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/nodes/{pubkey}/clock-skew", s.handleNodeClockSkew).Methods("GET")
r.HandleFunc("/api/observers/clock-skew", s.handleObserverClockSkew).Methods("GET")
r.HandleFunc("/api/nodes/{pubkey}/neighbors", s.handleNodeNeighbors).Methods("GET")
r.HandleFunc("/api/nodes/{pubkey}/reach", s.handleNodeReach).Methods("GET")
r.HandleFunc("/api/nodes/{pubkey}", s.handleNodeDetail).Methods("GET")
r.HandleFunc("/api/nodes", s.handleNodes).Methods("GET")
+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.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,272 @@
# Node Quality Report — Design Spec
**Date:** 2026-06-07
**Status:** Draft for review
**Author:** efite (+ Claude)
**Upstream goal:** Build in the fork, fully AGENTS.md-compliant, so it can later be
PR'd to `Kpa-clawbot/CoreScope` (upstream) without rework. Keep it decoupled from
the fork-private matomo/locode/security customizations.
---
## 1. Goal
Give any node a **quality report**: a map of the node and the neighbours it has a
**stable two-way (bidirectional) RF link** with, plus a table of those links rated
by quality, plus a few importance stats. Reachable at a **direct, shareable URL**
and **printable to an offline PDF** (browser Save-as-PDF).
### Success criteria
- A user opens `…/#/nodes/<pubkey>?section=quality`, sees a map with the node +
its bidirectional neighbours (links coloured by quality) and a table below.
- `Ctrl-P` / Save-as-PDF produces a clean one/two-page offline report.
- The directional/quality numbers match the validated `analysis-output/` toolkit.
- No write to the DB; bounded, cached query; tests + api-spec updated.
### Non-goals (YAGNI)
- No server-side PDF rendering engine (Go PDF / headless browser). Browser print
covers offline. A `/quality.pdf` endpoint is a possible *later* follow-up.
- No change to the global `neighbor_edges` builder or DB schema (that's the heavier
"approach C" — explicitly deferred).
- No new npm deps, no build step, no framework.
---
## 2. Approach
**Approach B — on-demand per-node endpoint + frontend report section.**
The existing neighbour feature (`/api/neighbors/graph`, `/api/nodes/:pubkey/neighbors`,
the Leaflet map, spider-fan viz) is built on `neighbor_edges`, which is
**canonical-merged (node_a ≤ node_b)** and therefore directionless — the
`GraphEdge.Bidirectional` field is currently hardcoded `true` (placeholder). This
feature computes **real direction** on demand from raw `observations.path_json`,
for a single node, bounded to a recent window. It does not touch the global builder,
so it is read-only and self-contained (maximally upstreamable).
Rejected alternatives:
- **A (standalone CLI/script):** the validated `analysis-output/` toolkit. Great for
ad-hoc use but no in-app URL, not what the user wants long-term.
- **C (directional `neighbor_edges`):** teach `cmd/ingestor/neighbor_builder.go` to
track direction + add columns. More valuable app-wide (fills the placeholder
everywhere) but touches the `dbschema` source-of-truth invariant (#1321), the
ingestor, and migrations — much larger review surface. Deferred as a future
follow-up that upstream can own.
---
## 3. Directionality method (the core algorithm)
A flood path is recorded origin → observer; each repeater appends its own hash
hop. So in a path `[A, B]`, **B received A directly** (B is one RF hop downstream
of A). For the target node N (matched by its reliable token):
| Position of N in path | Meaning | Counts toward |
|---|---|---|
| token **before** N (`[X, N]`) | we received X directly | `we_hear[X] += 1` |
| token **after** N (`[N, X]`) | X received us directly | `they_hear[X] += 1` |
| N is **last** hop | the **observer** heard us directly | `they_hear[observer] += 1` (+SNR) |
| N is **first** hop of an ADVERT | we were first relay of the originator's advert ⇒ we heard it | `we_hear[from_pubkey] += 1` |
- A **link is bidirectional/stable** when both `we_hear>0` and `they_hear>0`.
- **bottleneck = min(we_hear, they_hear)** — the weaker direction rates real
two-way reliability and drives link colour/width.
### Reliability rule
A node (target or neighbour) is only identified from a path hop when its pubkey
**prefix is unique** at that hop's byte length, across all known pubkeys
(nodes + inactive_nodes + observers). 1-byte prefixes almost always collide and
are auto-excluded; only unique 23 byte hops are trusted. The target's reliable
tokens are derived automatically; ambiguous neighbour hops are skipped (never
misattributed — conservative undercount).
This is the exact, validated logic of `analysis-output/analyze_node.py`.
---
## 4. Backend
### 4.1 New endpoint
`GET /api/nodes/:pubkey/quality?days=7`
- `days` — lookback window, default 7, clamped 130 (perf bound; "recent quality"
is also more meaningful than all-time).
- Read-only (`mode=ro`), per #1283. No writes.
### 4.2 Response (named structs in `cmd/server/node_quality.go`)
```go
type NodeQualityResponse struct {
Node QualityNode `json:"node"`
Window QualityWindow `json:"window"`
ReliableTokens []string `json:"reliable_tokens"`
Importance QualityImportance `json:"importance"`
DirectObservers []QualityObserver `json:"direct_observers"`
Links []QualityLink `json:"links"`
}
type QualityNode struct { Pubkey, Name, Role string; Lat, Lon *float64; FirstSeen string }
type QualityWindow struct { Days int `json:"days"`; Since string `json:"since"` }
type QualityImportance struct {
NeighborDegree, DegreeRank, NodesWithEdges int
RelayObservations, BidirectionalLinks, DirectObservers int
}
type QualityLink struct {
Pubkey, Name, Role string
Lat, Lon *float64
WeHear int `json:"we_hear"`
TheyHear int `json:"they_hear"`
Bottleneck int `json:"bottleneck"`
Bidir bool `json:"bidir"`
DistanceKm *float64 `json:"distance_km,omitempty"`
}
type QualityObserver struct { Pubkey, Name string; Count int; AvgSNR, Lat, Lon, DistanceKm *float64 }
```
No `map[string]interface{}` anywhere (#1383).
### 4.3 Computation & reuse
- Resolve `:pubkey`, derive reliable tokens (unique-prefix check).
- Build a **prefix→pubkey unique index** + a pubkey→info map. Reuse
`buildNodeInfoMap()` (already merges observers) for names/roles/GPS and
`haversineKm()` for distance — both already in `cmd/server/neighbor_api.go`.
- Query, bounded by the **timestamp index**:
`SELECT obs.id, t.from_pubkey, t.payload_type, o.path_json, o.snr
FROM observations o JOIN transmissions t ON t.id=o.transmission_id
LEFT JOIN observers obs ON obs.rowid=o.observer_idx
WHERE o.timestamp >= ? AND (o.path_json LIKE '%"TOK"%' OR …)`
- Run the §3 attribution as a **pure function**
`attributeDirections(rows, ourTokens, resolve) -> (we, they, observers, relayCount)`
so it is unit-testable in isolation (DI per AGENTS Testability).
- Importance fields:
- `neighbor_degree` + `degree_rank` + `nodes_with_edges` from the existing
`neighbor_edges` degree CTE (already validated). **These are all-time** (the
edge table is not windowed); the directional `links` are windowed. This mix is
intentional — degree/rank express standing connectivity, the links express
*current* two-way quality. Stated in the UI so it isn't misread.
- `relay_observations` = count of windowed observations in which any reliable
token of the node appears **anywhere** in the path (its relay throughput).
- `bidirectional_links` / `direct_observers` = derived counts from the scan.
### 4.4 Performance (AGENTS rule 0 — must justify)
- The leading-wildcard `LIKE` cannot use an index, so cost = rows in the
**timestamp window** only. Measured: 156k obs (≈1.5 d) scanned + filtered in
~0.6 s; a 7-day window is a few × that worst-case.
- **Bound:** `days` clamped ≤30. **Cache** the JSON per `(pubkey, days)` with a TTL
(≈300 s) via the existing server cache layer, keyed like other analytics.
- Ship a Go benchmark with realistic fixture sizes (before/after timings) per the
"no proof = no merge" rule.
### 4.5 Errors
- Unknown pubkey → `404 {"error":"Not found"}`.
- `days` out of range → clamp (no error).
- Node with no unique 13 byte prefix → `200` with `reliable_tokens: []` and empty
`links`/`direct_observers`. The UI detects `reliable_tokens.length === 0` and
shows an explanatory empty-state ("node niet betrouwbaar identificeerbaar in
paden — alleen 1-byte prefix, botst") rather than a blank report. No extra
response field needed.
---
## 5. Frontend
### 5.1 Route / deep-link
New section on the existing node detail page: `#/nodes/<pubkey>?section=quality`
(matches the existing `section=node-neighbors` pattern; satisfies AGENTS deep-link
rule). **This hash URL is the shareable "direct URL to the report."**
### 5.2 Layout
- **Importance header**: small stat cards (degree, rank, relay obs, #2-way links,
#direct observers).
- **Map**: a focused Leaflet map (reuse `map.js` tile config + helpers) showing the
node (highlighted) and only its **bidirectional** neighbours, links coloured by
bottleneck. Reuse existing marker/role styling.
- **Table**: the 2-way links sorted by bottleneck — columns: buur, wij horen,
zij horen ons, bottleneck, afstand. A toggle to also show one-way links
(default off) for diagnosing asymmetry.
### 5.3 Print / PDF
A print stylesheet (`@media print`) lays out header → map → table on A4 and hides
nav/chrome. A "Print / PDF" button calls `window.print()`. Offline PDF via the
browser's Save-as-PDF.
### 5.4 Conventions
- Link colours via **CSS variables** (`--link-strong/--link-medium/--link-weak`)
in `style.css`, mapped in the customizer (AGENTS rule 8 — note as later milestone,
OK to ship with sane defaults).
- Bulk fetch (one call to the new endpoint), filter/sort client-side — no per-item
calls.
- Code in its own file (e.g. `public/node-quality.js`) or a clearly-bounded section
module, not entangled with matomo/locode.
---
## 6. API contract
Add the `GET /api/nodes/:pubkey/quality` section to `docs/api-spec.md` **before**
building the UI (contract is authoritative; AGENTS rule 4).
---
## 7. Testing
- **Go unit** (`cmd/server/node_quality_test.go`): drive `attributeDirections`
with synthetic path arrays covering predecessor/successor/last-hop/first-hop-advert,
ambiguous-token skipping, and the unique-prefix derivation. Test-first.
- **Go endpoint test**: shape + 404 + clamp, against the test fixture DB.
- **Go benchmark**: scan timing on a realistic window (perf proof).
- **Playwright** (`test-node-quality-*.js`): section loads, map renders bidirectional
links, table populates, print layout sane, deep-link round-trips.
- `npm test` + local E2E green before any push.
---
## 8. Upstreamability checklist
- [ ] Self-contained files (`cmd/server/node_quality.go`, `public/node-quality.js`),
no matomo/locode coupling.
- [ ] Read-only (#1283), named structs (#1383), CSS vars, no npm deps.
- [ ] `docs/api-spec.md` updated.
- [ ] Tests + benchmark + browser validation.
- [ ] Deep-linked hash route.
- [ ] Later, when desired: branch off `upstream/master`, cherry-pick the feature
commits, open a PR per AGENTS.md (clean markdown body, perf proof).
---
## 9. Resolved decisions
1. **Map base** — extract a minimal reusable `renderLinkMap(container, node, links)`
helper (Leaflet tile config/markers reused from `map.js`); do not duplicate the
whole map module nor force the full `map.js` instance.
2. **One-way links** — shown in the table behind a toggle (default **off**), so the
default report is the bidirectional set but asymmetry stays diagnosable.
3. **Placement**~~section on the node detail page~~**superseded** (see §10):
a dedicated full-page view at `#/nodes/<pubkey>/quality` with a top "Quality"
button next to Analytics.
---
## 10. Revisions (post-review, 2026-06-07)
> **Feature renamed Quality → Reach.** "Quality" didn't capture the page (it's
> about a node's two-way RF *reach*). Endpoint `/api/nodes/:pubkey/quality`
> `/reach`; files `node_quality.go``node_reach.go`, `node-quality*.{js,css}`
> `node-reach*`; route `#/nodes/<pk>/quality``/reach`; Go types `Quality*`
> `NodeReach*` (the bare `Reach*` namespace was already taken by the topology
> per-observer-reach feature). Earlier mentions of "quality" in this doc reflect
> the original name.
After the first staging deploy, review feedback reshaped the frontend:
- **Standalone page, not a buried section.** Registered as page `node-quality`
(route `#/nodes/<pubkey>/quality`), mirroring `node-analytics.js`, with a
📈 Quality button next to Analytics on the node detail (full + side pane). The
inline far-down section was removed. (Reverses §9.3.)
- **English UI** to match the rest of the app.
- **Day selector** (24h / 7d / 14d / 30d) re-fetching the endpoint; default 7d.
- **Importance grouped + explained**: "Network position (all-time)" (Neighbours,
Rank) vs "Last N days" (Links, Two-way, Relay obs, Direct observers), each card
carrying a description — clarifies why all-time *Neighbours* (narrow,
geo-filtered neighbour_edges) can be **less** than windowed *Two-way* (full
path-adjacency, both directions).
- **Map fixes**: node shown as a default Leaflet pin (was an invisible white
dot); neighbour dots filled with the link colour; print resizes the map to a
fixed width + `invalidateSize()` so the whole map prints (was clipped to the
left half on wide screens).
- **One-way toggle made visible**: a live "showing X of Y (Z two-way)" count,
one-way rows muted with a direction hint.
- **Clickable neighbours**: each table row links to that node's detail page.
+5
View File
@@ -908,6 +908,11 @@ function navigate() {
basePage = 'node-analytics';
}
// Special route: nodes/PUBKEY/reach → node-reach page
if (basePage === 'nodes' && routeParam && routeParam.endsWith('/reach')) {
basePage = 'node-reach';
}
// Special route: packet/123 → standalone packet detail page
if (basePage === 'packet' && routeParam) {
basePage = 'packet-detail';
+3
View File
@@ -39,6 +39,7 @@
<link rel="stylesheet" href="route-view.css?v=__BUST__">
<link rel="stylesheet" href="home.css?v=__BUST__">
<link rel="stylesheet" href="live.css?v=__BUST__">
<link rel="stylesheet" href="node-reach.css?v=__BUST__">
<link rel="stylesheet" href="bottom-nav.css?v=__BUST__">
<link rel="stylesheet" href="nav-drawer.css?v=__BUST__">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
@@ -213,6 +214,8 @@
<script src="observer-detail.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-reach-map.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-reach.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="mobile-page-actions.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
</body>
+47
View File
@@ -0,0 +1,47 @@
/* window.NodeReachMap.render(containerId, node, links, colorFn) focused
Leaflet map of a node and its bidirectional links, coloured by bottleneck.
Returns the Leaflet map instance (with _nqBounds) so the caller can resize
for printing. */
(function () {
'use strict';
function cssColor(varExpr) {
var name = varExpr.replace('var(', '').replace(')', '').trim();
var v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return v || '#888';
}
function render(containerId, node, links, colorFn) {
var c = document.getElementById(containerId);
if (!c || typeof L === 'undefined') return null;
var map = L.map(containerId, { zoomControl: true, attributionControl: false })
.setView([node.lat, node.lon], 11);
if (typeof window._applyTilesToNodeMap === 'function') {
window._applyTilesToNodeMap(map);
} else {
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map);
}
var pts = [[node.lat, node.lon]];
links.forEach(function (l) {
if (l.lat == null || l.lon == null) return;
pts.push([l.lat, l.lon]);
var col = cssColor(colorFn(l.bottleneck));
L.polyline([[node.lat, node.lon], [l.lat, l.lon]], {
color: col,
weight: Math.max(1.5, Math.min(7, 1.2 + 1.6 * Math.log10(l.bottleneck + 1))),
opacity: 0.85
}).addTo(map).bindPopup(escapeHtml(l.name) + '<br>we ' + l.we_hear + ' / they ' + l.they_hear);
// Neighbour dot: filled with the link colour (was white/invisible before).
L.circleMarker([l.lat, l.lon], { radius: 5, color: '#ffffff', weight: 1, fillColor: col, fillOpacity: 1 })
.addTo(map).bindTooltip(escapeHtml(l.name));
});
// The node itself: a default Leaflet pin marker — always clearly visible.
L.marker([node.lat, node.lon]).addTo(map).bindPopup(escapeHtml(node.name));
try { map.fitBounds(pts, { padding: [30, 30] }); } catch (e) {}
map._nqBounds = pts;
setTimeout(function () { map.invalidateSize(); }, 120);
return map;
}
window.NodeReachMap = { render: render };
})();
+24
View File
@@ -0,0 +1,24 @@
/* Node reach page reuses .analytics-stats/.analytics-stat-card/.analytics-time-range
from the analytics page; these are the reach-specific bits. */
.nq-group-h { font-size:11px; font-weight:600; color:var(--text-muted, #57606a); text-transform:uppercase; letter-spacing:.3px; margin:14px 0 6px; }
.nq-actions { display:flex; align-items:center; gap:14px; margin:10px 0; font-size:12px; }
.nq-count { color:var(--text-muted, #57606a); }
.nq-empty { color:var(--text-muted, #57606a); font-size:13px; padding:18px 12px; border:1px dashed var(--border, #d0d7de); border-radius:6px; margin:6px 0 24px; }
.nq-note { margin:6px 0 0; padding:6px 10px; font-size:11px; line-height:1.4; color:var(--text-muted, #57606a); background:var(--section-bg, #f6f8fa); border:1px solid var(--border, #d0d7de); border-radius:6px; }
.nq-map { height:420px; border:1px solid var(--border, #d0d7de); border-radius:6px; margin-bottom:12px; }
.nq-table { border-collapse:collapse; width:100%; font-size:12px; margin-bottom:24px; }
.nq-table th, .nq-table td { border:1px solid var(--border, #d0d7de); padding:3px 7px; }
.nq-table th { background:var(--section-bg, #f6f8fa); font-size:10px; text-transform:uppercase; }
.nq-n { text-align:right; font-variant-numeric:tabular-nums; }
.nq-num { text-align:right; color:var(--text-muted, #8c959f); width:26px; }
.nq-link { color:var(--accent); text-decoration:none; font-weight:600; }
.nq-link:hover { text-decoration:underline; }
.nq-oneway { color:var(--text-muted, #8c959f); }
.nq-dir { font-size:10px; color:var(--text-muted, #8c959f); }
@media print {
.nq-noprint { display:none !important; }
.nq-map { width:680px !important; height:300px; }
.nq-table { font-size:10px; }
}
+194
View File
@@ -0,0 +1,194 @@
/* === CoreScope node-reach.js ===
Standalone per-node "Reach" page: importance stats + a map of the node's
bidirectional RF links + a link table. Registered as page 'node-reach'
(route #/nodes/<pubkey>/reach), mirroring node-analytics.js. */
'use strict';
(function () {
var qmap = null;
var current = null;
function colorVar(b) {
if (b >= 300) return 'var(--link-strong)';
if (b >= 100) return 'var(--link-medium)';
return 'var(--link-weak)';
}
function statCard(label, value, desc) {
return '<div class="analytics-stat-card"><div class="analytics-stat-label">' + label +
'</div><div class="analytics-stat-value">' + value +
'</div><div class="analytics-stat-desc">' + desc + '</div></div>';
}
function linkRow(i, l) {
var dist = l.distance_km != null ? Number(l.distance_km).toFixed(1) : '—';
var dir = l.bidir ? '' : (l.we_hear > 0 ? 'incoming' : 'outgoing');
var href = '#/nodes/' + encodeURIComponent(l.pubkey);
return '<tr' + (l.bidir ? '' : ' class="nq-oneway"') + '>' +
'<td class="nq-num">' + i + '</td>' +
'<td><a href="' + href + '" class="nq-link">' + escapeHtml(l.name || l.pubkey.slice(0, 8)) + '</a>' +
(dir ? ' <span class="nq-dir">' + dir + '</span>' : '') + '</td>' +
'<td class="nq-n">' + l.we_hear + '</td>' +
'<td class="nq-n">' + l.they_hear + '</td>' +
'<td class="nq-n" style="color:' + colorVar(l.bottleneck) + '"><b>' + l.bottleneck + '</b></td>' +
'<td class="nq-n">' + dist + '</td></tr>';
}
function dayBtn(d, cur, label) {
return '<button data-days="' + d + '"' + (d === cur ? ' class="active"' : '') + '>' + label + '</button>';
}
function headerHtml(n, nodeName, days) {
return '<div class="nq-head" style="max-width:1000px;margin:0 auto;padding:12px 16px">' +
'<a href="#/nodes/' + encodeURIComponent(n.pubkey) + '" style="color:var(--accent);text-decoration:none;font-size:12px">← Back to ' + nodeName + '</a>' +
'<h2 style="margin:4px 0 2px;font-size:18px">📡 ' + nodeName + ' — Reach</h2>' +
'<div style="color:var(--text-muted);font-size:11px">' + escapeHtml(n.role || 'Unknown role') + ' · two-way RF link reach</div>' +
'<div class="nq-note">️ Reliable by design: built only from unique <b>23 byte</b> path-hash (multibyte) matches. 1-byte hops collide between nodes and are excluded, so the links shown are trustworthy.</div>' +
'<div class="analytics-time-range nq-noprint" id="nqDays" style="margin-top:8px">' +
dayBtn(1, days, '24h') + dayBtn(7, days, '7d') + dayBtn(14, days, '14d') + dayBtn(30, days, '30d') +
'</div></div>';
}
function wireTimeRange(container, pubkey) {
var bar = container.querySelector('#nqDays');
if (!bar) return;
bar.addEventListener('click', function (e) {
var b = e.target.closest('button[data-days]');
if (b) load(container, pubkey, parseInt(b.getAttribute('data-days'), 10));
});
}
function printReport() {
// Leaflet only renders tiles for the on-screen size; on a wide screen the
// printed page is narrower and the right half is clipped. Resize the map to
// a print-safe width and invalidate before printing.
var mapEl = document.getElementById('nqMap');
if (qmap && mapEl) {
mapEl.style.width = '680px';
qmap.invalidateSize();
try { qmap.fitBounds(qmap._nqBounds, { padding: [20, 20] }); } catch (e) {}
setTimeout(function () {
window.print();
mapEl.style.width = '';
qmap.invalidateSize();
try { qmap.fitBounds(qmap._nqBounds, { padding: [30, 30] }); } catch (e) {}
}, 350);
} else {
window.print();
}
}
async function load(container, pubkey, days) {
current = { pubkey: pubkey, days: days };
if (qmap) { try { qmap.remove(); } catch (e) {} qmap = null; }
container.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-muted)">Loading reach…</div>';
var d;
try {
d = await api('/nodes/' + encodeURIComponent(pubkey) + '/reach?days=' + days, { ttl: 30000 });
} catch (e) {
container.innerHTML = '<div style="padding:40px;text-align:center;color:#ff6b6b">Failed to load reach: ' + escapeHtml(e.message) + '</div>';
return;
}
current.data = d;
var n = d.node;
var nodeName = escapeHtml(n.name || n.pubkey.slice(0, 12));
var imp = d.importance || {};
var twoWay = d.links.filter(function (l) { return l.bidir; });
if (!d.reliable_tokens || d.reliable_tokens.length === 0) {
// nodeName is already escaped above; build the fragment then assign
// (same build-then-assign pattern as statsHtml below).
var emptyHtml = headerHtml(n, nodeName, days) +
'<div style="max-width:1000px;margin:0 auto;padding:0 16px;color:var(--text-muted)">This node has no unique 13 byte prefix, so it cannot be reliably identified in paths — no link data available.</div>';
container.innerHTML = emptyHtml;
wireTimeRange(container, pubkey);
return;
}
var statsHtml = headerHtml(n, nodeName, days) +
'<div class="nq-body" style="max-width:1000px;margin:0 auto;padding:0 16px">' +
'<div class="nq-group-h">Network position (all-time)</div>' +
'<div class="analytics-stats">' +
statCard('Neighbours', imp.neighbor_degree, 'Distinct neighbours in the all-time neighbour graph (built only from advert first-hop + observer last-hop, geo-filtered).') +
statCard('Rank', '#' + imp.degree_rank + ' / ' + imp.nodes_with_edges, 'Rank by neighbour count among all nodes that have edges. #1 = the most-connected node in the network.') +
'</div>' +
'<div class="nq-group-h">Last ' + d.window.days + ' days</div>' +
'<div class="analytics-stats">' +
statCard('Links', d.links.length, 'Distinct direct neighbours seen in paths this window (any direction).') +
statCard('Two-way', imp.bidirectional_links, 'Neighbours heard in BOTH directions — stable links. Counts mid-path adjacency too, so this can exceed the all-time "Neighbours".') +
statCard('Relay obs', imp.relay_observations, 'Observations where this node appears anywhere in the path (its relay throughput).') +
statCard('Direct observers', imp.direct_observers, 'Stations that received this node directly, at 0 hops.') +
'</div>';
// Identifiable, but no path adjacency observed in this window → explain rather
// than showing an empty map + table.
if (!d.links.length) {
container.innerHTML = statsHtml +
'<div class="nq-empty">No observed RF links in the last ' + d.window.days +
' days — this node advertises but hasnt been seen relaying traffic (or no observers captured it). Try a longer window.</div>' +
'</div>';
wireTimeRange(container, pubkey);
return;
}
container.innerHTML = statsHtml +
'<div class="nq-actions nq-noprint">' +
'<label><input type="checkbox" id="nqIncoming"> incoming <span class="nq-dir">(we hear them)</span></label>' +
'<label><input type="checkbox" id="nqOutgoing"> outgoing <span class="nq-dir">(they hear us)</span></label>' +
'<span id="nqCount" class="nq-count"></span>' +
'<button id="nqPrintBtn" class="btn-primary" style="margin-left:auto">🖨️ Print / PDF</button>' +
'</div>' +
'<div id="nqMap" class="nq-map"></div>' +
'<table class="nq-table"><thead><tr><th>#</th><th>Neighbour</th><th>we hear</th>' +
'<th>they hear us</th><th>bottleneck</th><th>km</th></tr></thead><tbody id="nqRows"></tbody></table>' +
'</div>';
function renderMap(list) {
if (qmap) { try { qmap.remove(); } catch (e) {} qmap = null; }
if (window.NodeReachMap && n.lat != null) {
qmap = window.NodeReachMap.render('nqMap', n, list, colorVar);
}
}
// Two-way links are always shown; the two checkboxes add the asymmetric ones.
// incoming = we hear them only; outgoing = they hear us only.
function paint() {
var inc = document.getElementById('nqIncoming').checked;
var out = document.getElementById('nqOutgoing').checked;
var list = d.links.filter(function (l) {
if (l.bidir) return true;
var weOnly = l.we_hear > 0 && l.they_hear === 0;
return (inc && weOnly) || (out && !weOnly);
}).sort(function (a, b) {
return (b.bidir - a.bidir) || (b.bottleneck - a.bottleneck) ||
((b.we_hear + b.they_hear) - (a.we_hear + a.they_hear));
});
document.getElementById('nqRows').innerHTML = list.map(function (l, i) { return linkRow(i + 1, l); }).join('');
document.getElementById('nqCount').textContent =
'showing ' + list.length + ' of ' + d.links.length + ' (' + twoWay.length + ' two-way)';
// Keep the map in sync with the table.
renderMap(list);
}
paint();
document.getElementById('nqIncoming').addEventListener('change', paint);
document.getElementById('nqOutgoing').addEventListener('change', paint);
document.getElementById('nqPrintBtn').addEventListener('click', printReport);
wireTimeRange(container, pubkey);
}
function init(container, routeParam) {
if (!routeParam || !routeParam.endsWith('/reach')) {
container.innerHTML = '<div style="padding:40px;text-align:center">Invalid reach URL</div>';
return;
}
load(container, routeParam.slice(0, -'/reach'.length), 7);
}
function destroy() {
if (qmap) { try { qmap.remove(); } catch (e) {} qmap = null; }
current = null;
}
registerPage('node-reach', { init: init, destroy: destroy });
})();
+11 -6
View File
@@ -106,6 +106,9 @@
} catch (_e) {}
return layer;
}
// Exposed so the node-reach link-map (node-reach-map.js) reuses the user's
// configured tile provider instead of hardcoding OSM.
window._applyTilesToNodeMap = _applyTilesToNodeMap;
// ROLE_COLORS loaded from shared roles.js
@@ -562,10 +565,11 @@
<div style="margin:4px 0 6px">${renderNodeBadges(n, roleColor)}</div>
${renderHashInconsistencyWarning(n)}
<div class="node-detail-key mono" style="font-size:11px;word-break:break-all;margin-bottom:6px">${n.public_key}</div>
<div>
<button class="btn-primary" id="copyUrlBtn" style="font-size:12px;padding:4px 10px">📋 Copy URL</button>
<button class="btn-primary" id="copyShortUrlBtn" title="Short URL using an 8-char pubkey prefix — easier to send over the mesh (issue #772)" style="font-size:12px;padding:4px 10px;margin-left:6px">📡 Copy short URL</button>
<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="btn-primary" style="display:inline-block;margin-left:6px;text-decoration:none;font-size:12px;padding:4px 10px">📊 Analytics</a>
<div style="display:flex;flex-wrap:wrap;gap:6px">
<button class="btn-primary" id="copyUrlBtn" style="flex:0 0 auto;font-size:12px;padding:4px 10px">📋 Copy URL</button>
<button class="btn-primary" id="copyShortUrlBtn" title="Short URL using an 8-char pubkey prefix — easier to send over the mesh (issue #772)" style="flex:0 0 auto;font-size:12px;padding:4px 10px">📡 Copy short URL</button>
<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="btn-primary" style="flex:0 0 auto;text-decoration:none;font-size:12px;padding:4px 10px">📊 Analytics</a>
<a href="#/nodes/${encodeURIComponent(n.public_key)}/reach" class="btn-primary" style="flex:0 0 auto;text-decoration:none;font-size:12px;padding:4px 10px">📡 Reach</a>
</div>
</div>
@@ -1348,8 +1352,8 @@
if (link) {
e.preventDefault();
var href = link.getAttribute('href');
if (href.indexOf('/analytics') !== -1) {
// Analytics link — different page, force hashchange via replaceState + assign
if (href.indexOf('/analytics') !== -1 || href.indexOf('/reach') !== -1) {
// Analytics/Reach link — different page, force hashchange via replaceState + assign
history.replaceState(null, '', '#/');
location.hash = href.substring(1);
}
@@ -1545,6 +1549,7 @@
<div class="node-detail-role">${renderNodeBadges(n, roleColor)}
<button class="btn-primary node-detail-btn" data-pubkey="${encodeURIComponent(n.public_key)}" aria-label="View details for ${escapeHtml(n.name || n.public_key)}" style="font-size:11px;padding:2px 8px;margin-left:8px;cursor:pointer">🔍 Details</button>
<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="btn-primary" style="display:inline-block;margin-left:4px;text-decoration:none;font-size:11px;padding:2px 8px">📊 Analytics</a>
<a href="#/nodes/${encodeURIComponent(n.public_key)}/reach" class="btn-primary" style="display:inline-block;margin-left:4px;text-decoration:none;font-size:11px;padding:2px 8px">📡 Reach</a>
</div>
${renderStatusExplanation(n)}
+4
View File
@@ -62,6 +62,10 @@
* ============================================================ */
:root {
/* Node-quality link strength colours (bottleneck tiers) */
--link-strong: #1a7f37;
--link-medium: #bf8700;
--link-weak: #cf222e;
/* --- Fluid spacing scale ---------------------------------
* Targets at 1440px viewport: 4 / 8 / 16 / 24 / 32 / 48 px.
* Min/max clamps keep small viewports usable and prevent
+58
View File
@@ -0,0 +1,58 @@
// E2E for the per-node Reach page (#/nodes/<pubkey>/reach).
// Defaults to localhost:3000 — NEVER point at prod (AGENTS.md). CI sets BASE_URL.
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:3000';
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
// A repeater is most likely to have reach data (it relays).
const nodes = await (await page.request.get(BASE + '/api/nodes?role=repeater&limit=1')).json();
if (!nodes.nodes || !nodes.nodes.length) {
console.log('node-reach E2E SKIP (no repeater in dataset)');
await browser.close();
return;
}
const pk = nodes.nodes[0].public_key;
// 1. The endpoint returns the documented shape.
const reach = await (await page.request.get(BASE + '/api/nodes/' + pk + '/reach?days=7')).json();
for (const k of ['node', 'window', 'reliable_tokens', 'importance', 'links', 'direct_observers']) {
if (!(k in reach)) throw new Error('reach response missing key: ' + k);
}
if (!Array.isArray(reach.links)) throw new Error('reach.links must be an array');
// 2. The page renders.
await page.goto(BASE + '/#/nodes/' + pk + '/reach');
await page.waitForSelector('.nq-head', { timeout: 20000 });
if (!(await page.locator('h2', { hasText: 'Reach' }).count())) {
throw new Error('Reach header missing');
}
// 3. If this node is identifiable, exercise the table, toggles and links.
if (reach.reliable_tokens.length && (await page.locator('#nqRows').count())) {
await page.waitForSelector('#nqIncoming');
await page.waitForSelector('#nqOutgoing');
const twoWay = await page.locator('#nqRows tr').count();
await page.check('#nqIncoming');
const withIncoming = await page.locator('#nqRows tr').count();
if (withIncoming < twoWay) throw new Error('incoming toggle reduced rows');
await page.check('#nqOutgoing');
const withBoth = await page.locator('#nqRows tr').count();
if (withBoth < withIncoming) throw new Error('outgoing toggle reduced rows');
// Neighbour rows link to a node detail page.
if (await page.locator('#nqRows a.nq-link').count()) {
const href = await page.locator('#nqRows a.nq-link').first().getAttribute('href');
if (!href || !href.startsWith('#/nodes/')) throw new Error('neighbour link malformed: ' + href);
}
// Map renders (node has GPS for a repeater fixture).
await page.waitForSelector('#nqMap .leaflet-container', { timeout: 10000 }).catch(() => {});
}
console.log('node-reach E2E OK');
await browser.close();
})().catch((e) => { console.error(e); process.exit(1); });