From 47f85f6c4cb22f85b96026b38bef16bfee2d441e Mon Sep 17 00:00:00 2001 From: efiten Date: Mon, 8 Jun 2026 13:11:06 +0200 Subject: [PATCH] feat(nodes): per-node Reach page + GET /api/nodes/{pubkey}/reach (directional link quality) (#1625) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- cmd/server/node_reach.go | 432 ++++++ cmd/server/node_reach_bench_test.go | 55 + cmd/server/node_reach_endpoint_test.go | 76 + cmd/server/node_reach_test.go | 98 ++ cmd/server/routes.go | 1 + docs/api-spec.md | 58 + docs/plans/2026-06-07-node-quality-report.md | 1229 +++++++++++++++++ .../2026-06-07-node-quality-report-design.md | 272 ++++ public/app.js | 5 + public/index.html | 3 + public/node-reach-map.js | 47 + public/node-reach.css | 24 + public/node-reach.js | 194 +++ public/nodes.js | 17 +- public/style.css | 4 + test-node-reach-e2e.js | 58 + 16 files changed, 2567 insertions(+), 6 deletions(-) create mode 100644 cmd/server/node_reach.go create mode 100644 cmd/server/node_reach_bench_test.go create mode 100644 cmd/server/node_reach_endpoint_test.go create mode 100644 cmd/server/node_reach_test.go create mode 100644 docs/plans/2026-06-07-node-quality-report.md create mode 100644 docs/specs/2026-06-07-node-quality-report-design.md create mode 100644 public/node-reach-map.js create mode 100644 public/node-reach.css create mode 100644 public/node-reach.js create mode 100644 test-node-reach-e2e.js diff --git a/cmd/server/node_reach.go b/cmd/server/node_reach.go new file mode 100644 index 00000000..b4faac67 --- /dev/null +++ b/cmd/server/node_reach.go @@ -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, °ree, &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 +} diff --git a/cmd/server/node_reach_bench_test.go b/cmd/server/node_reach_bench_test.go new file mode 100644 index 00000000..afb80c09 --- /dev/null +++ b/cmd/server/node_reach_bench_test.go @@ -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") + } + } +} diff --git a/cmd/server/node_reach_endpoint_test.go b/cmd/server/node_reach_endpoint_test.go new file mode 100644 index 00000000..74ff2e94 --- /dev/null +++ b/cmd/server/node_reach_endpoint_test.go @@ -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 +} diff --git a/cmd/server/node_reach_test.go b/cmd/server/node_reach_test.go new file mode 100644 index 00000000..5b157404 --- /dev/null +++ b/cmd/server/node_reach_test.go @@ -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) + } +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 135b663e..08ab429b 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -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") diff --git a/docs/api-spec.md b/docs/api-spec.md index 353c3606..e39d9b91 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -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 2–3 byte** path prefixes +(1-byte prefixes collide and are excluded). + +### Query Parameters + +| Param | Type | Default | Description | +|--------|--------|---------|--------------------------------------| +| `days` | number | `7` | Lookback window, clamped 1–30 | + +### 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 1–3 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. diff --git a/docs/plans/2026-06-07-node-quality-report.md b/docs/plans/2026-06-07-node-quality-report.md new file mode 100644 index 00000000..2c65662c --- /dev/null +++ b/docs/plans/2026-06-07-node-quality-report.md @@ -0,0 +1,1229 @@ +# Node Quality Report Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a per-node "quality report" β€” a map of the node's bidirectional (two-way) RF links + a quality table + importance stats β€” reachable at `#/nodes/?section=quality` and printable to offline PDF. + +**Architecture:** Read-only Go endpoint `GET /api/nodes/:pubkey/quality?days=N` computes link directionality on demand from raw `observations.path_json` (a path travels originβ†’observer, so `[A,B]` β‡’ B heard A). It reuses the existing `prefixMap` (relay-node prefix index) and `buildNodeInfoMap`/`haversineKm`. A new vanilla-JS section in the node detail page renders importance cards, a Leaflet link-map, and a table, with a print stylesheet for PDF. No change to `neighbor_edges`/schema (approach B), so it is self-contained and upstream-PR-ready. + +**Tech Stack:** Go (`cmd/server`, gorilla/mux, stdlib SQLite read-only), vanilla JS + Leaflet 1.9 (`public/`), no build step. + +**Why not reuse `resolved_path`?** That column exists but is **unpopulated on prod** (verified: 0 of 162 341 windowed rows have it). So directionality must be computed from raw `path_json` with a conservative **unique 2–3 byte prefix** rule (1-byte prefixes collide and are excluded). + +--- + +## File Structure + +| File | Responsibility | Create/Modify | +|------|----------------|---------------| +| `cmd/server/node_quality.go` | Endpoint handler, response structs, pure `attributeDirections`, reliable-token derivation, bounded TTL cache | Create | +| `cmd/server/node_quality_test.go` | Unit tests for the pure function + token derivation | Create | +| `cmd/server/node_quality_endpoint_test.go` | Handler shape/404/clamp tests | Create | +| `cmd/server/node_quality_bench_test.go` | Scan benchmark (perf proof) | Create | +| `cmd/server/routes.go` | Register the route | Modify (~line 240) | +| `docs/api-spec.md` | Document the endpoint (contract first) | Modify | +| `public/node-quality.js` | Fetch + render section (cards, map, table, one-way toggle, print) | Create | +| `public/node-quality.css` | Section + `@media print` styles, link-colour CSS vars | Create | +| `public/style.css` | Add `--link-strong/-medium/-weak` vars to `:root` | Modify | +| `public/index.html` | `` add: +```html + + +``` + +- [ ] **Step 3: Commit** + +```bash +git add public/node-quality.js public/index.html +git commit -m "feat(frontend): node quality section renderer" +``` + +--- + +## Task 9: Link map helper (`renderLinkMap`) + +**Files:** +- Create: `public/node-quality-map.js` + +- [ ] **Step 1: Implement the map helper** (reuses Leaflet + the node-map tile helper) + +```javascript +/* window.NodeQualityMap.render(containerId, node, links, colorFn) β€” focused + Leaflet map of a node and its bidirectional links, coloured by bottleneck. */ +(function () { + 'use strict'; + var map = null; + + function render(containerId, node, links, colorFn) { + var c = document.getElementById(containerId); + if (!c || typeof L === 'undefined') return; + if (map) { map.remove(); map = null; } + map = L.map(containerId, { zoomControl: true, attributionControl: false }) + .setView([node.lat, node.lon], 11); + // Reuse the node-detail tile applier if present; else plain OSM. + 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]); + L.polyline([[node.lat, node.lon], [l.lat, l.lon]], { + color: getComputedStyle(document.documentElement) + .getPropertyValue(colorFn(l.bottleneck).replace('var(', '').replace(')', '')).trim() || '#888', + weight: Math.max(1.5, Math.min(7, 1.2 + 1.6 * Math.log10(l.bottleneck + 1))), + opacity: 0.8 + }).addTo(map).bindPopup(escapeHtml(l.name) + '
wij ' + l.we_hear + ' / zij ' + l.they_hear); + L.circleMarker([l.lat, l.lon], { radius: 5, color: '#fff', weight: 1, fillOpacity: 1 }) + .addTo(map).bindTooltip(escapeHtml(l.name)); + }); + L.circleMarker([node.lat, node.lon], { radius: 8, color: '#fff', weight: 2, fillColor: '#0969da', fillOpacity: 1 }) + .addTo(map).bindPopup(escapeHtml(node.name)); + try { map.fitBounds(pts, { padding: [30, 30] }); } catch (e) {} + setTimeout(function () { map.invalidateSize(); }, 120); + } + + window.NodeQualityMap = { render: render }; +})(); +``` +> If `_applyTilesToNodeMap` is not exposed on `window` in `nodes.js`, expose it +> (`window._applyTilesToNodeMap = _applyTilesToNodeMap;`) in the same commit so +> the helper reuses the user's configured tile provider rather than hardcoding OSM. + +- [ ] **Step 2: Commit** + +```bash +git add public/node-quality-map.js public/nodes.js +git commit -m "feat(frontend): reusable bidirectional link map" +``` + +--- + +## Task 10: Styles + CSS vars + print + +**Files:** +- Create: `public/node-quality.css` +- Modify: `public/style.css` (`:root` link colour vars) + +- [ ] **Step 1: Add CSS variables to `public/style.css`** + +Inside the existing `:root { … }` block, add: +```css + --link-strong: #1a7f37; + --link-medium: #bf8700; + --link-weak: #cf222e; +``` + +- [ ] **Step 2: Create `public/node-quality.css`** + +```css +.nq-stats { display:flex; gap:8px; flex-wrap:wrap; margin:8px 0 12px; } +.nq-stat { flex:1; min-width:90px; border:1px solid var(--border, #d0d7de); border-radius:6px; padding:6px 8px; background:var(--section-bg, #f6f8fa); } +.nq-stat-v { font-size:16px; font-weight:700; } +.nq-stat-k { font-size:9px; text-transform:uppercase; letter-spacing:.3px; color:var(--text-muted, #57606a); } +.nq-actions { display:flex; align-items:center; gap:14px; margin:6px 0; font-size:12px; } +.nq-map { height:380px; border:1px solid var(--border, #d0d7de); border-radius:6px; margin-bottom:12px; } +.nq-table { border-collapse:collapse; width:100%; font-size:12px; } +.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; } + +@media print { + body * { visibility:hidden; } + #node-quality, #node-quality * { visibility:visible; } + #node-quality { position:absolute; left:0; top:0; width:100%; } + .nq-actions { display:none; } + .nq-map { height:300px; } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add public/node-quality.css public/style.css +git commit -m "feat(frontend): node quality styles + link colour vars + print" +``` + +--- + +## Task 11: Wire the section into the node detail page + +**Files:** +- Modify: `public/nodes.js` (~line 707 β€” after the clock-skew card; and the post-render hook ~line 709) + +- [ ] **Step 1: Add the card placeholder** + +In the node detail template string, immediately after the +`
` +line, add: +```javascript +
+

Link quality (2-weg)

+
Loading quality…
+
`; +``` +(Keep the closing backtick/semicolon that currently ends the template β€” move it +to the end of this added block.) + +- [ ] **Step 2: Invoke the renderer after the map block** + +After the `// Map` block (around line 718, after the `if (hasLoc) { … }`), add: +```javascript + // Quality section + if (window.NodeQuality) { + try { window.NodeQuality.render(n.public_key); } catch (e) {} + } +``` +The existing deep-link scroll (line 741+) already handles `?section=node-quality` +because the card `id` is `node-quality`. + +- [ ] **Step 3: Manual build/serve check** + +Run a local server (`cd cmd/server && go run . --config ../../config.example.json` +or the repo's documented dev command) and open +`http://localhost:3000/#/nodes/?section=quality`. +Expected: stat cards, a map with coloured links, a table; the page scrolls to the +section; `Ctrl-P` preview shows only the report. + +- [ ] **Step 4: Commit** + +```bash +git add public/nodes.js +git commit -m "feat(frontend): mount node quality section on node detail page" +``` + +--- + +## Task 12: Playwright E2E + +**Files:** +- Create: `test-node-quality-e2e.js` + +- [ ] **Step 1: Write the test** (mirror an existing `test-issue-*-e2e.js` harness β€” default `BASE_URL=http://localhost:3000`, never prod) + +```javascript +const { chromium } = require('playwright'); +const BASE = process.env.BASE_URL || 'http://localhost:3000'; + +(async () => { + const browser = await chromium.launch(); + const page = await browser.newPage(); + // pick any repeater pubkey from the API + const nodes = await (await page.request.get(BASE + '/api/nodes?role=repeater&limit=1')).json(); + const pk = nodes.nodes[0].public_key; + + await page.goto(BASE + '/#/nodes/' + pk + '?section=quality'); + await page.waitForSelector('#nodeQualityContent .nq-stats, #nodeQualityContent .text-muted', { timeout: 15000 }); + + const hasStats = await page.locator('#nodeQualityContent .nq-stats').count(); + if (hasStats) { + await page.waitForSelector('#nqMap', { timeout: 10000 }); + const rows = await page.locator('#nqRows tr').count(); + if (rows < 0) throw new Error('table did not render'); + // one-way toggle changes row count or leaves it β‰₯ bidirectional count + const before = await page.locator('#nqRows tr').count(); + await page.check('#nqShowOneWay'); + const after = await page.locator('#nqRows tr').count(); + if (after < before) throw new Error('one-way toggle reduced rows unexpectedly'); + } + console.log('node-quality E2E OK'); + await browser.close(); +})().catch((e) => { console.error(e); process.exit(1); }); +``` + +- [ ] **Step 2: Run against a local server** + +Run: `node test-node-quality-e2e.js` +Expected: prints `node-quality E2E OK`. + +- [ ] **Step 3: Run the full suite** + +Run: `npm test` (backend) then `cd cmd/server && go test ./...` +Expected: all green (test count up). + +- [ ] **Step 4: Commit** + +```bash +git add test-node-quality-e2e.js +git commit -m "test(e2e): node quality report section" +``` + +--- + +## Self-review (completed) + +- **Spec coverage:** Β§3 algorithm β†’ Task 2; reliability rule β†’ Task 3; Β§4 endpoint/structs/perf/errors β†’ Tasks 4–7; Β§5 frontend (map/table/print/toggle/deep-link) β†’ Tasks 8–12; Β§6 api-spec β†’ Task 1; Β§7 testing β†’ Tasks 2,3,6,7,12; Β§8 upstreamability honoured (own files, read-only, named structs, CSS vars, deep-link). Resolved Β§9 decisions reflected (renderLinkMap helper, one-way toggle default-off, `?section=quality`). +- **Placeholders:** none β€” every code step has complete code. Two "if helper has a different name, grep for it" notes are deliberate guardrails for fixture/tile helpers whose exact names live in test/`nodes.js` internals; they instruct reuse, not invention. +- **Type consistency:** `pathRow`, `dirCounts`, `obsAgg`, `attributeDirections`, `reliableTokens`, `uniqueResolve`, the `Quality*` structs, `scanQualityRows`, `computeNodeQuality`, `handleNodeQuality` names are used identically across tasks. Frontend `window.NodeQuality.render` / `window.NodeQualityMap.render` and the `colorVar`/`#nqRows`/`#nqMap`/`#nodeQualityContent` ids match across Tasks 8–12. diff --git a/docs/specs/2026-06-07-node-quality-report-design.md b/docs/specs/2026-06-07-node-quality-report-design.md new file mode 100644 index 00000000..1bcb8227 --- /dev/null +++ b/docs/specs/2026-06-07-node-quality-report-design.md @@ -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/?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 2–3 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 1–30 (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 1–3 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/?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//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//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//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. diff --git a/public/app.js b/public/app.js index 09c5fc22..f41f1cf8 100644 --- a/public/app.js +++ b/public/app.js @@ -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'; diff --git a/public/index.html b/public/index.html index 1a8b5454..dd566f03 100644 --- a/public/index.html +++ b/public/index.html @@ -39,6 +39,7 @@ + + + diff --git a/public/node-reach-map.js b/public/node-reach-map.js new file mode 100644 index 00000000..ed218b00 --- /dev/null +++ b/public/node-reach-map.js @@ -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) + '
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 }; +})(); diff --git a/public/node-reach.css b/public/node-reach.css new file mode 100644 index 00000000..adc8b52b --- /dev/null +++ b/public/node-reach.css @@ -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; } +} diff --git a/public/node-reach.js b/public/node-reach.js new file mode 100644 index 00000000..13a01df4 --- /dev/null +++ b/public/node-reach.js @@ -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//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 '
' + label + + '
' + value + + '
' + desc + '
'; + } + + 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 '' + + '' + i + '' + + '' + escapeHtml(l.name || l.pubkey.slice(0, 8)) + '' + + (dir ? ' ' + dir + '' : '') + '' + + '' + l.we_hear + '' + + '' + l.they_hear + '' + + '' + l.bottleneck + '' + + '' + dist + ''; + } + + function dayBtn(d, cur, label) { + return ''; + } + + function headerHtml(n, nodeName, days) { + return '
' + + '← Back to ' + nodeName + '' + + '

πŸ“‘ ' + nodeName + ' β€” Reach

' + + '
' + escapeHtml(n.role || 'Unknown role') + ' Β· two-way RF link reach
' + + '
ℹ️ Reliable by design: built only from unique 2–3 byte path-hash (multibyte) matches. 1-byte hops collide between nodes and are excluded, so the links shown are trustworthy.
' + + '
' + + dayBtn(1, days, '24h') + dayBtn(7, days, '7d') + dayBtn(14, days, '14d') + dayBtn(30, days, '30d') + + '
'; + } + + 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 = '
Loading reach…
'; + + var d; + try { + d = await api('/nodes/' + encodeURIComponent(pubkey) + '/reach?days=' + days, { ttl: 30000 }); + } catch (e) { + container.innerHTML = '
Failed to load reach: ' + escapeHtml(e.message) + '
'; + 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) + + '
This node has no unique 1–3 byte prefix, so it cannot be reliably identified in paths β€” no link data available.
'; + container.innerHTML = emptyHtml; + wireTimeRange(container, pubkey); + return; + } + + var statsHtml = headerHtml(n, nodeName, days) + + '
' + + '
Network position (all-time)
' + + '
' + + 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.') + + '
' + + '
Last ' + d.window.days + ' days
' + + '
' + + 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.') + + '
'; + + // Identifiable, but no path adjacency observed in this window β†’ explain rather + // than showing an empty map + table. + if (!d.links.length) { + container.innerHTML = statsHtml + + '
No observed RF links in the last ' + d.window.days + + ' days β€” this node advertises but hasn’t been seen relaying traffic (or no observers captured it). Try a longer window.
' + + '
'; + wireTimeRange(container, pubkey); + return; + } + + container.innerHTML = statsHtml + + '
' + + '' + + '' + + '' + + '' + + '
' + + '
' + + '' + + '
#Neighbourwe hearthey hear usbottleneckkm
' + + ''; + + 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 = '
Invalid reach URL
'; + 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 }); +})(); diff --git a/public/nodes.js b/public/nodes.js index ce17d8fa..01e6106c 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -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 @@
${renderNodeBadges(n, roleColor)}
${renderHashInconsistencyWarning(n)}
${n.public_key}
-
- - - πŸ“Š Analytics +
+ + + πŸ“Š Analytics + πŸ“‘ Reach
@@ -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 @@
${renderNodeBadges(n, roleColor)} πŸ“Š Analytics + πŸ“‘ Reach
${renderStatusExplanation(n)} diff --git a/public/style.css b/public/style.css index 3cb88dc7..ff2b789c 100644 --- a/public/style.css +++ b/public/style.css @@ -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 diff --git a/test-node-reach-e2e.js b/test-node-reach-e2e.js new file mode 100644 index 00000000..f83f2c7c --- /dev/null +++ b/test-node-reach-e2e.js @@ -0,0 +1,58 @@ +// E2E for the per-node Reach page (#/nodes//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); });