From 2d30e49247de42ed023febcfd9b331780fa442db Mon Sep 17 00:00:00 2001 From: dborup Date: Thu, 23 Jul 2026 08:48:11 +0200 Subject: [PATCH] feat: network-wide hop-depth analytics (Scopes + Foreign Traffic tabs) Extends the #1812 per-node relay hop-count work with a network-wide view: GET /api/analytics/hop-depth answers (1) whether scoped traffic actually travels fewer hops than unscoped before hitting a repeater's flood.max cap (Scopes tab Overview: new "Flood Containment" comparison), and (2) which repeaters relay unscoped traffic that already traveled far vs merely locally (Foreign Traffic tab: min/median/max hop columns joined onto the existing unscoped-relay table by public key). Co-Authored-By: Claude Sonnet 5 --- cmd/server/db.go | 190 ++++++++++++++++++ cmd/server/hop_depth_analytics_test.go | 201 +++++++++++++++++++ cmd/server/openapi.go | 34 ++++ cmd/server/routes.go | 53 +++++ cmd/server/types.go | 44 ++++ public/analytics.js | 144 ++++++++++++- test-all.sh | 1 + test-analytics-hop-depth-ui.js | 266 +++++++++++++++++++++++++ 8 files changed, 932 insertions(+), 1 deletion(-) create mode 100644 cmd/server/hop_depth_analytics_test.go create mode 100644 test-analytics-hop-depth-ui.js diff --git a/cmd/server/db.go b/cmd/server/db.go index 34079c79..4214c031 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -3170,6 +3170,196 @@ func (db *DB) GetScopeStats(window string) (*ScopeStatsResponse, error) { return resp, nil } +// GetHopDepthAnalytics answers, network-wide over the given window, two +// questions that share the same expensive "walk every resolved relay path" +// pass — see HopDepthAnalyticsResponse's doc comment for the full +// rationale. "Scoped" here is derived purely from route_type +// (TRANSPORT_FLOOD=0/TRANSPORT_DIRECT=3 vs FLOOD=1/DIRECT=2, the same +// convention used throughout this codebase — e.g. computeHopAnalyticsTransport), +// not the scope_name column, so this works even on schemas predating #899. +// +// A transmission can have resolved_path stored on more than one +// observation; this keeps whichever has the MOST entries per transmission +// (a proxy for "most complete"), matching fetchResolvedPathForTxBest's +// "longest wins" selection without needing per-tx round trips. +func (db *DB) GetHopDepthAnalytics(window string) (*HopDepthAnalyticsResponse, error) { + var since string + switch window { + case "1h": + since = time.Now().Add(-1 * time.Hour).UTC().Format(time.RFC3339) + case "7d": + since = time.Now().Add(-7 * 24 * time.Hour).UTC().Format(time.RFC3339) + default: + window = "24h" + since = time.Now().Add(-24 * time.Hour).UTC().Format(time.RFC3339) + } + + rows, err := db.conn.Query(` + SELECT t.id, t.route_type, t.payload_type, o.resolved_path + FROM transmissions t + JOIN observations o ON o.transmission_id = t.id + WHERE t.first_seen > ? AND o.resolved_path IS NOT NULL AND o.resolved_path != ''`, since) + if err != nil { + return nil, fmt.Errorf("hop depth analytics query: %w", err) + } + defer rows.Close() + + type txInfo struct { + routeType sql.NullInt64 + payloadType sql.NullInt64 + bestPath []*string + } + byTx := make(map[int]*txInfo) + for rows.Next() { + var txID int + var routeType, payloadType sql.NullInt64 + var rpJSON string + if err := rows.Scan(&txID, &routeType, &payloadType, &rpJSON); err != nil { + continue + } + rp := unmarshalResolvedPath(rpJSON) + if len(rp) == 0 { + continue + } + cur, ok := byTx[txID] + if !ok { + byTx[txID] = &txInfo{routeType: routeType, payloadType: payloadType, bestPath: rp} + continue + } + if len(rp) > len(cur.bestPath) { + cur.bestPath = rp + } + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("hop depth analytics iteration: %w", err) + } + + scopedBuckets := map[int]int{} + unscopedBuckets := map[int]int{} + repeaterHops := map[string][]int{} + + for _, info := range byTx { + if !info.routeType.Valid { + continue + } + rt := int(info.routeType.Int64) + isFlood := rt == routeTypeFlood || rt == RouteTransportFlood + if !isFlood { + continue + } + scoped := rt == RouteTransportFlood + isAdvert := info.payloadType.Valid && int(info.payloadType.Int64) == payloadTypeAdvert + isUnscopedFlood := rt == routeTypeFlood && !isAdvert + + for idx, pk := range info.bestPath { + if scoped { + scopedBuckets[idx]++ + } else { + unscopedBuckets[idx]++ + } + if isUnscopedFlood && pk != nil && *pk != "" { + repeaterHops[*pk] = append(repeaterHops[*pk], idx) + } + } + } + + toSortedBuckets := func(m map[int]int) []HopDepthBucket { + out := make([]HopDepthBucket, 0, len(m)) + for hops, count := range m { + out = append(out, HopDepthBucket{Hops: hops, Count: count}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Hops < out[j].Hops }) + return out + } + + // Only repeater/room nodes are meaningful here (matches the Foreign + // Traffic tab's existing "Repeaters Relaying Unscoped Traffic" role + // filter) -- look up name/role for the pubkeys that actually relayed + // unscoped flood traffic in this window, rather than every known node. + pubkeys := make([]string, 0, len(repeaterHops)) + for pk := range repeaterHops { + pubkeys = append(pubkeys, pk) + } + names, roles := db.namesAndRolesForPubkeys(pubkeys) + + unscopedByRepeater := make([]RepeaterUnscopedHopDepth, 0, len(repeaterHops)) + for pk, hops := range repeaterHops { + if roles[pk] != "repeater" && roles[pk] != "room" { + continue + } + sort.Ints(hops) + n := len(hops) + median := float64(hops[n/2]) + if n%2 == 0 { + median = float64(hops[n/2-1]+hops[n/2]) / 2 + } + name := names[pk] + if name == "" { + name = pk + } + unscopedByRepeater = append(unscopedByRepeater, RepeaterUnscopedHopDepth{ + PublicKey: pk, + Name: name, + Count: n, + MinHops: hops[0], + MedianHops: median, + MaxHops: hops[n-1], + }) + } + sort.Slice(unscopedByRepeater, func(i, j int) bool { return unscopedByRepeater[i].Count > unscopedByRepeater[j].Count }) + + return &HopDepthAnalyticsResponse{ + Window: window, + ScopedHopDepth: toSortedBuckets(scopedBuckets), + UnscopedHopDepth: toSortedBuckets(unscopedBuckets), + UnscopedByRepeater: unscopedByRepeater, + }, nil +} + +// namesAndRolesForPubkeys bulk-looks-up name/role for a set of pubkeys, +// chunked to stay under SQLite's parameter limit. Missing pubkeys are +// simply absent from the returned maps. +func (db *DB) namesAndRolesForPubkeys(pubkeys []string) (names, roles map[string]string) { + names = make(map[string]string, len(pubkeys)) + roles = make(map[string]string, len(pubkeys)) + if len(pubkeys) == 0 { + return names, roles + } + const chunkSize = 499 + for start := 0; start < len(pubkeys); start += chunkSize { + end := start + chunkSize + if end > len(pubkeys) { + end = len(pubkeys) + } + chunk := pubkeys[start:end] + placeholders := make([]byte, 0, len(chunk)*2) + args := make([]interface{}, len(chunk)) + for i, pk := range chunk { + if i > 0 { + placeholders = append(placeholders, ',') + } + placeholders = append(placeholders, '?') + args[i] = pk + } + query := "SELECT public_key, name, role FROM nodes WHERE public_key IN (" + string(placeholders) + ")" + rows, err := db.conn.Query(query, args...) + if err != nil { + continue + } + for rows.Next() { + var pk string + var name, role sql.NullString + if err := rows.Scan(&pk, &name, &role); err != nil { + continue + } + names[pk] = name.String + roles[pk] = role.String + } + rows.Close() + } + return names, roles +} + // GetChannelMessageScopeStats narrows the scoped/unscoped/unknown question // to channel chat specifically (payload_type=5), for the given window. // Unlike GetScopeStats' TransportTotal (route_type 0/3 only), TotalMessages diff --git a/cmd/server/hop_depth_analytics_test.go b/cmd/server/hop_depth_analytics_test.go new file mode 100644 index 00000000..6512961e --- /dev/null +++ b/cmd/server/hop_depth_analytics_test.go @@ -0,0 +1,201 @@ +package main + +import ( + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gorilla/mux" +) + +// TestGetHopDepthAnalytics covers the two questions HopDepthAnalyticsResponse +// answers: (1) does scoped traffic actually travel fewer hops than unscoped +// traffic network-wide, and (2) which repeaters relay unscoped flood traffic +// that has already traveled far (high hops) vs merely locally (low hops). +func TestGetHopDepthAnalytics(t *testing.T) { + conn, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("sql.Open: %v", err) + } + defer conn.Close() + conn.SetMaxOpenConns(1) + db := &DB{conn: conn} + + if _, err := conn.Exec(`CREATE TABLE nodes (public_key TEXT PRIMARY KEY, name TEXT, role TEXT)`); err != nil { + t.Fatal(err) + } + if _, err := conn.Exec(`CREATE TABLE transmissions ( + id INTEGER PRIMARY KEY, hash TEXT, first_seen TEXT, route_type INTEGER, payload_type INTEGER + )`); err != nil { + t.Fatal(err) + } + if _, err := conn.Exec(`CREATE TABLE observations ( + id INTEGER PRIMARY KEY, transmission_id INTEGER, resolved_path TEXT + )`); err != nil { + t.Fatal(err) + } + + recent := time.Now().Add(-1 * time.Hour).UTC().Format(time.RFC3339) + + repeaterA := "aa001111aaaabbbb" + repeaterB := "bb001111bbbbcccc" + origin := "cc001111ccccdddd" + + conn.Exec(`INSERT INTO nodes (public_key, name, role) VALUES (?, 'RepeaterA', 'repeater')`, repeaterA) + conn.Exec(`INSERT INTO nodes (public_key, name, role) VALUES (?, 'RepeaterB', 'repeater')`, repeaterB) + conn.Exec(`INSERT INTO nodes (public_key, name, role) VALUES (?, 'CompanionC', 'companion')`, origin) + + // tx1: TRANSPORT_FLOOD (scoped), path len 1 -> scoped bucket gets one + // hop=0 entry. Not unscoped, so doesn't feed unscopedByRepeater. + conn.Exec(`INSERT INTO transmissions (id, hash, first_seen, route_type, payload_type) VALUES (1, 'h1', ?, 0, 5)`, recent) + conn.Exec(`INSERT INTO observations (id, transmission_id, resolved_path) VALUES (1, 1, ?)`, `["`+repeaterA+`"]`) + + // tx2: plain FLOOD (unscoped), not advert, path len 2 -> unscoped + // bucket gets hop=0 (repeaterA) and hop=1 (repeaterB). Both feed + // unscopedByRepeater: repeaterA at hop 0, repeaterB at hop 1. + conn.Exec(`INSERT INTO transmissions (id, hash, first_seen, route_type, payload_type) VALUES (2, 'h2', ?, 1, 5)`, recent) + conn.Exec(`INSERT INTO observations (id, transmission_id, resolved_path) VALUES (2, 2, ?)`, `["`+repeaterA+`","`+repeaterB+`"]`) + + // tx3: another unscoped FLOOD, path len 3 -> repeaterB sees hop=2 this + // time, giving it hops [1, 2] (median matters for the assertion below). + conn.Exec(`INSERT INTO transmissions (id, hash, first_seen, route_type, payload_type) VALUES (3, 'h3', ?, 1, 5)`, recent) + conn.Exec(`INSERT INTO observations (id, transmission_id, resolved_path) VALUES (3, 3, ?)`, `["`+repeaterA+`","`+origin+`","`+repeaterB+`"]`) + + // tx4: unscoped FLOOD ADVERT -- must be EXCLUDED from unscopedByRepeater + // (adverts have their own separate flood.max.advert cap) but still + // counts toward the network-wide unscoped hop-depth bucket. + conn.Exec(`INSERT INTO transmissions (id, hash, first_seen, route_type, payload_type) VALUES (4, 'h4', ?, 1, 4)`, recent) + conn.Exec(`INSERT INTO observations (id, transmission_id, resolved_path) VALUES (4, 4, ?)`, `["`+repeaterA+`"]`) + + // tx5: DIRECT -- excluded from both scoped and unscoped flood buckets + // entirely (not a flood.max-relevant transport). + conn.Exec(`INSERT INTO transmissions (id, hash, first_seen, route_type, payload_type) VALUES (5, 'h5', ?, 2, 5)`, recent) + conn.Exec(`INSERT INTO observations (id, transmission_id, resolved_path) VALUES (5, 5, ?)`, `["`+repeaterA+`"]`) + + // tx6: companion (not repeater/room) relaying unscoped flood -- must + // NOT appear in unscopedByRepeater despite matching the transport filter. + conn.Exec(`INSERT INTO transmissions (id, hash, first_seen, route_type, payload_type) VALUES (6, 'h6', ?, 1, 5)`, recent) + conn.Exec(`INSERT INTO observations (id, transmission_id, resolved_path) VALUES (6, 6, ?)`, `["`+origin+`"]`) + + resp, err := db.GetHopDepthAnalytics("24h") + if err != nil { + t.Fatalf("GetHopDepthAnalytics: %v", err) + } + + if resp.Window != "24h" { + t.Errorf("Window = %q, want 24h", resp.Window) + } + + // Scoped bucket: only tx1 contributes -- one hop=0 entry. + scopedByHop := map[int]int{} + for _, b := range resp.ScopedHopDepth { + scopedByHop[b.Hops] = b.Count + } + if scopedByHop[0] != 1 || len(resp.ScopedHopDepth) != 1 { + t.Errorf("ScopedHopDepth = %+v, want just {hops:0 count:1}", resp.ScopedHopDepth) + } + + // Unscoped bucket: tx2 (hop0,hop1) + tx3 (hop0,hop1,hop2) + tx4 (hop0) + // + tx6 (hop0) -> hop0: 4 (tx2,tx3,tx4,tx6), hop1: 2 (tx2,tx3), hop2: 1 (tx3). + unscopedByHop := map[int]int{} + for _, b := range resp.UnscopedHopDepth { + unscopedByHop[b.Hops] = b.Count + } + if unscopedByHop[0] != 4 { + t.Errorf("UnscopedHopDepth[hop=0] = %d, want 4", unscopedByHop[0]) + } + if unscopedByHop[1] != 2 { + t.Errorf("UnscopedHopDepth[hop=1] = %d, want 2", unscopedByHop[1]) + } + if unscopedByHop[2] != 1 { + t.Errorf("UnscopedHopDepth[hop=2] = %d, want 1", unscopedByHop[2]) + } + + // unscopedByRepeater: only repeaterA and repeaterB should appear + // (origin is a companion, tx4's advert excluded, tx1/tx5 not unscoped-flood). + byPK := map[string]RepeaterUnscopedHopDepth{} + for _, r := range resp.UnscopedByRepeater { + byPK[r.PublicKey] = r + } + if len(byPK) != 2 { + t.Fatalf("UnscopedByRepeater = %+v, want exactly repeaterA and repeaterB", resp.UnscopedByRepeater) + } + // repeaterA: hop=0 from tx2, hop=0 from tx3 -> [0,0], median 0. + a := byPK[repeaterA] + if a.Count != 2 || a.MinHops != 0 || a.MaxHops != 0 || a.MedianHops != 0 { + t.Errorf("repeaterA = %+v, want count=2 min=0 max=0 median=0", a) + } + // repeaterB: hop=1 from tx2, hop=2 from tx3 -> [1,2], median 1.5. + b := byPK[repeaterB] + if b.Count != 2 || b.MinHops != 1 || b.MaxHops != 2 || b.MedianHops != 1.5 { + t.Errorf("repeaterB = %+v, want count=2 min=1 max=2 median=1.5", b) + } + if _, ok := byPK[origin]; ok { + t.Error("origin (companion role) must not appear in UnscopedByRepeater") + } +} + +func TestGetHopDepthAnalytics_EmptyWindow(t *testing.T) { + conn, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("sql.Open: %v", err) + } + defer conn.Close() + conn.SetMaxOpenConns(1) + db := &DB{conn: conn} + conn.Exec(`CREATE TABLE nodes (public_key TEXT PRIMARY KEY, name TEXT, role TEXT)`) + conn.Exec(`CREATE TABLE transmissions (id INTEGER PRIMARY KEY, hash TEXT, first_seen TEXT, route_type INTEGER, payload_type INTEGER)`) + conn.Exec(`CREATE TABLE observations (id INTEGER PRIMARY KEY, transmission_id INTEGER, resolved_path TEXT)`) + + resp, err := db.GetHopDepthAnalytics("1h") + if err != nil { + t.Fatalf("GetHopDepthAnalytics: %v", err) + } + if len(resp.ScopedHopDepth) != 0 || len(resp.UnscopedHopDepth) != 0 || len(resp.UnscopedByRepeater) != 0 { + t.Errorf("expected all-empty response on an empty DB, got %+v", resp) + } +} + +// TestHandleHopDepthAnalytics_InvalidWindow mirrors handleScopeStats' +// window validation. +func TestHandleHopDepthAnalytics_InvalidWindow(t *testing.T) { + db := setupTestDB(t) + cfg := &Config{Port: 3000} + hub := NewHub() + srv := NewServer(db, cfg, hub) + router := mux.NewRouter() + srv.RegisterRoutes(router) + + req := httptest.NewRequest("GET", "/api/analytics/hop-depth?window=30d", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestHandleHopDepthAnalytics_DefaultWindow(t *testing.T) { + db := setupTestDB(t) + cfg := &Config{Port: 3000} + hub := NewHub() + srv := NewServer(db, cfg, hub) + router := mux.NewRouter() + srv.RegisterRoutes(router) + + req := httptest.NewRequest("GET", "/api/analytics/hop-depth", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var resp HopDepthAnalyticsResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.Window != "24h" { + t.Errorf("Window = %q, want default 24h", resp.Window) + } +} diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index b86dca7b..5ea9cda1 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -113,6 +113,11 @@ func routeDescriptions() map[string]routeMeta { {Name: "window", Description: "Time window: 1h, 24h (default), or 7d", Type: "string"}, {Name: "channel", Description: "Channel name to analyze (default #wardriving)", Type: "string"}, }}, + "GET /api/analytics/hop-depth": {Summary: "Network-wide hop-depth analytics", Description: "Answers two flood-containment questions in one pass over resolved relay paths, using the same 0-based per-node path-index hop count as /api/nodes/{pubkey}/hop_analytics (issue #1812), not the unrelated observer-distance hopDistribution field: (1) does scoped (TRANSPORT_FLOOD/TRANSPORT_DIRECT) traffic actually travel fewer hops network-wide than unscoped (plain FLOOD, non-advert) traffic, and (2) which repeater/room nodes are relaying unscoped flood traffic that already traveled far (high hops, a stronger containment-problem signal) vs merely locally (low hops). Plain DIRECT traffic never undergoes flood propagation and is excluded from both buckets. Cached 30s per window.", Tag: "analytics", + QueryParams: []paramMeta{ + {Name: "window", Description: "Time window: 1h, 24h (default), or 7d", Type: "string"}, + }, + Response: schemaRef("HopDepthAnalyticsResponse")}, "GET /api/analytics/wardriving/sender-messages": {Summary: "Wardriving sender message drill-down", Description: "Individual #wardriving messages from one sender (drill-down behind Top Senders/Sessions): each message's entry-point path (path[0] first, resolve names via /api/resolve-hops), per-observer SNR/RSSI, and lat/lon when that message carried an explicit shared position. Pass since+until (RFC3339) to scope to one session's exact range; otherwise window covers the sender's whole activity in that period. Capped at 200 messages, most-recent-first. Not cached.", Tag: "analytics", QueryParams: []paramMeta{ {Name: "sender", Description: "Sender display name to look up (required, exact match)", Type: "string"}, @@ -287,6 +292,35 @@ func componentSchemas() map[string]interface{} { "packets": map[string]interface{}{"type": "array", "items": schemaRef("HopAnalyticsPacket")}, }, }, + "HopDepthBucket": map[string]interface{}{ + "type": "object", + "description": "How many relay-hop instances (network-wide) saw a given hop count.", + "properties": map[string]interface{}{ + "hops": map[string]interface{}{"type": "integer", "description": "0-based hop index."}, + "count": map[string]interface{}{"type": "integer"}, + }, + }, + "RepeaterUnscopedHopDepth": map[string]interface{}{ + "type": "object", + "description": "One repeater/room's hop-count profile across the unscoped (plain FLOOD, non-advert) traffic it has relayed.", + "properties": map[string]interface{}{ + "publicKey": str("Node's public key."), + "name": str("Node's display name, or its public key if unnamed."), + "count": map[string]interface{}{"type": "integer", "description": "Number of unscoped relay-hop instances at this node."}, + "minHops": map[string]interface{}{"type": "integer"}, + "medianHops": map[string]interface{}{"type": "number"}, + "maxHops": map[string]interface{}{"type": "integer"}, + }, + }, + "HopDepthAnalyticsResponse": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "window": str("Time window this response covers: 1h, 24h, or 7d."), + "scopedHopDepth": map[string]interface{}{"type": "array", "items": schemaRef("HopDepthBucket"), "description": "Hop-depth histogram for scoped (TRANSPORT_FLOOD/TRANSPORT_DIRECT) traffic."}, + "unscopedHopDepth": map[string]interface{}{"type": "array", "items": schemaRef("HopDepthBucket"), "description": "Hop-depth histogram for unscoped (plain FLOOD) traffic."}, + "unscopedByRepeater": map[string]interface{}{"type": "array", "items": schemaRef("RepeaterUnscopedHopDepth"), "description": "Per-repeater/room breakdown of unscoped hop depth, sorted by count descending."}, + }, + }, } } diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 1aa3ad21..1879d914 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -72,6 +72,14 @@ type Server struct { wardrivingStatsCache map[string]*WardrivingStatsResponse wardrivingStatsCachedAt map[string]time.Time + // Cached /api/analytics/hop-depth response — per-window, recomputed at + // most once every 30s (same reasoning as scopeStatsCache: this walks + // every resolved relay path in the window, expensive enough to be + // worth a short TTL cache rather than recomputing on every page view). + hopDepthMu sync.Mutex + hopDepthCache map[string]*HopDepthAnalyticsResponse + hopDepthCachedAt map[string]time.Time + // Router reference for OpenAPI spec generation router *mux.Router @@ -254,6 +262,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) { r.HandleFunc("/api/health", s.handleHealth).Methods("GET") r.HandleFunc("/api/stats", s.handleStats).Methods("GET") r.HandleFunc("/api/scope-stats", s.handleScopeStats).Methods("GET") + r.HandleFunc("/api/analytics/hop-depth", s.handleHopDepthAnalytics).Methods("GET") r.HandleFunc("/api/analytics/wardriving", s.handleWardrivingStats).Methods("GET") r.HandleFunc("/api/analytics/wardriving/sender-messages", s.handleWardrivingSenderMessages).Methods("GET") r.HandleFunc("/api/perf", s.handlePerf).Methods("GET") @@ -3910,6 +3919,50 @@ func (s *Server) handleScopeStats(w http.ResponseWriter, r *http.Request) { writeJSON(w, resp) } +// handleHopDepthAnalytics serves GetHopDepthAnalytics (see its doc comment) +// for the Scopes tab's scoped-vs-unscoped containment comparison and the +// Foreign Traffic tab's per-repeater unscoped hop-depth enrichment. Same +// per-window 30s-cache shape as handleScopeStats. +func (s *Server) handleHopDepthAnalytics(w http.ResponseWriter, r *http.Request) { + const hopDepthTTL = 30 * time.Second + + window := r.URL.Query().Get("window") + if window == "" { + window = "24h" + } + if window != "1h" && window != "24h" && window != "7d" { + writeError(w, 400, "window must be 1h, 24h, or 7d") + return + } + + s.hopDepthMu.Lock() + if s.hopDepthCache != nil { + if cached, ok := s.hopDepthCache[window]; ok && time.Since(s.hopDepthCachedAt[window]) < hopDepthTTL { + s.hopDepthMu.Unlock() + writeJSON(w, cached) + return + } + } + s.hopDepthMu.Unlock() + + resp, err := s.db.GetHopDepthAnalytics(window) + if err != nil { + writeError(w, 500, err.Error()) + return + } + + s.hopDepthMu.Lock() + if s.hopDepthCache == nil { + s.hopDepthCache = make(map[string]*HopDepthAnalyticsResponse) + s.hopDepthCachedAt = make(map[string]time.Time) + } + s.hopDepthCache[window] = resp + s.hopDepthCachedAt[window] = time.Now() + s.hopDepthMu.Unlock() + + writeJSON(w, resp) +} + // handleWardrivingStats serves activity/entry-point/coverage analytics for // the #wardriving channel (see GetWardrivingStats doc). Same per-window // 30s-cache shape as handleScopeStats. diff --git a/cmd/server/types.go b/cmd/server/types.go index 1ff0a6ce..f79cb220 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -880,6 +880,50 @@ type NodeHopAnalyticsResponse struct { Packets []HopAnalyticsPacket `json:"packets"` } +// HopDepthBucket is a (hop count -> how many relay-hop instances saw that +// count) tally, network-wide. +type HopDepthBucket struct { + Hops int `json:"hops"` + Count int `json:"count"` +} + +// RepeaterUnscopedHopDepth is one repeater/room's hop-count profile across +// the unscoped (FLOOD, non-advert) traffic it has relayed — the flip side +// of unscoped_relay_count_24h's raw volume: whether that volume is mostly +// FRESH/local unscoped traffic (low hops) or traffic that already +// propagated far, unscoped, before reaching this repeater (high hops) -- +// the latter is the stronger signal of an actual containment problem. +type RepeaterUnscopedHopDepth struct { + PublicKey string `json:"publicKey"` + Name string `json:"name"` + Count int `json:"count"` + MinHops int `json:"minHops"` + MedianHops float64 `json:"medianHops"` + MaxHops int `json:"maxHops"` +} + +// HopDepthAnalyticsResponse answers two related "is flood containment +// actually working" questions in one pass over resolved relay paths +// (both need the same expensive walk, so they're computed together): +// +// 1. ScopedHopDepth/UnscopedHopDepth: network-wide, does SCOPED +// (TRANSPORT_FLOOD/TRANSPORT_DIRECT) traffic actually travel fewer +// hops than UNSCOPED (FLOOD/DIRECT) traffic? hashRegions exists +// specifically to contain flood propagation to a relevant area — if +// scoped hop depth isn't meaningfully lower, that's evidence region +// boundaries are too loose or flood_max isn't tuned differently per +// scope, not just an adoption-percentage number. +// 2. UnscopedByRepeater: per-repeater hop-depth profile of the unscoped +// traffic it relays, enriching the Foreign Traffic tab's "Repeaters +// Relaying Unscoped Traffic" (which today only ranks by volume) with +// whether that volume is nearby noise or far-propagated pollution. +type HopDepthAnalyticsResponse struct { + Window string `json:"window"` + ScopedHopDepth []HopDepthBucket `json:"scopedHopDepth"` + UnscopedHopDepth []HopDepthBucket `json:"unscopedHopDepth"` + UnscopedByRepeater []RepeaterUnscopedHopDepth `json:"unscopedByRepeater"` +} + // ─── Analytics — RF ──────────────────────────────────────────────────────────── type PayloadTypeSignal struct { diff --git a/public/analytics.js b/public/analytics.js index 33b094b1..2bfeecbc 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -2719,6 +2719,9 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf window._analyticsStopWardrivingRefresh = _stopWardrivingRefresh; window._analyticsComputeNodesWithoutScope = computeNodesWithoutScope; window._analyticsComputeRepeatersNeverRelayingScope = computeRepeatersNeverRelayingScope; + window._analyticsHopDepthBucketStats = hopDepthBucketStats; + window._analyticsRenderHopDepthSectionHtml = renderHopDepthSectionHtml; + window._analyticsHopDepthLookupByPubkey = hopDepthLookupByPubkey; } // ─── Neighbor Graph Tab ───────────────────────────────────────────────────── @@ -4548,6 +4551,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf '' + '
' + '
' + + '
' + '
' + '
' + '
' + @@ -4938,6 +4942,22 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf ''; } + // Flood-containment check: is scoping actually working, i.e. does + // scoped traffic travel fewer relay hops than unscoped traffic before + // a repeater's flood.max cap kicks in? Same 0-based per-node hop + // index as /api/nodes/{pubkey}/hop_analytics (issue #1812) — NOT the + // unrelated observer-distance hopDistribution field. + var hopDepthEl = document.getElementById('scopes-hop-depth'); + var hopDepthData = null; + try { + hopDepthData = await api('/analytics/hop-depth?window=' + encodeURIComponent(w), { ttl: 30000 }); + } catch (e) { + hopDepthData = null; + } + if (hopDepthEl) { + hopDepthEl.innerHTML = renderHopDepthSectionHtml(hopDepthData); + } + // Channel-messages-only breakdown: same scoped/unscoped/unknown // question as the cards above, but restricted to payload_type=5 // (channel chat) — most channel traffic is plain FLOOD, so this can @@ -5386,6 +5406,102 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf ''; } + // Weighted median + total from a HopDepthBucket[] (see GetHopDepthAnalytics). + function hopDepthBucketStats(buckets) { + var total = 0; + (buckets || []).forEach(function(b) { total += b.count; }); + if (!total) return { total: 0, median: null }; + var sorted = (buckets || []).slice().sort(function(a, b) { return a.hops - b.hops; }); + var cum = 0, median = null; + sorted.forEach(function(b) { + cum += b.count; + if (median === null && cum >= total / 2) median = b.hops; + }); + return { total: total, median: median }; + } + + // Renders the scoped-vs-unscoped hop-depth comparison (Scopes tab + // Overview): two stat cards (median hop, sample size) plus a grouped bar + // chart across hop values 0..max, normalized to the taller of the two + // series at each hop. The whole point is to answer "is region scoping + // actually containing flood propagation" — scoped traffic clustering at + // a lower median hop than unscoped is the expected/healthy shape. + function renderHopDepthSectionHtml(hopData) { + if (!hopData || (!hopData.scopedHopDepth && !hopData.unscopedHopDepth)) { + return ''; + } + var scoped = hopData.scopedHopDepth || []; + var unscoped = hopData.unscopedHopDepth || []; + var scopedStats = hopDepthBucketStats(scoped); + var unscopedStats = hopDepthBucketStats(unscoped); + if (!scopedStats.total && !unscopedStats.total) { + return '

Flood Containment: Scoped vs Unscoped Hop Depth

' + + '

No relay-hop data in this window.

'; + } + + var maxHops = 0; + scoped.concat(unscoped).forEach(function(b) { if (b.hops > maxHops) maxHops = b.hops; }); + var scopedByHop = {}, unscopedByHop = {}; + scoped.forEach(function(b) { scopedByHop[b.hops] = b.count; }); + unscoped.forEach(function(b) { unscopedByHop[b.hops] = b.count; }); + var maxCount = 1; + for (var h = 0; h <= maxHops; h++) { + maxCount = Math.max(maxCount, scopedByHop[h] || 0, unscopedByHop[h] || 0); + } + + var rows = ''; + for (var hi = 0; hi <= maxHops; hi++) { + var sc = scopedByHop[hi] || 0, un = unscopedByHop[hi] || 0; + if (!sc && !un) continue; + var scW = (sc / maxCount * 100).toFixed(1); + var unW = (un / maxCount * 100).toFixed(1); + rows += '
' + + '
' + hi + ' hop' + (hi === 1 ? '' : 's') + '
' + + '
' + + '
' + + '
' + + '' + sc.toLocaleString() + '' + + '
' + + '
' + + '
' + + '' + un.toLocaleString() + '' + + '
' + + '
' + + '
'; + } + + return '

Flood Containment: Scoped vs Unscoped Hop Depth

' + + '

' + + 'How many relay hops packets travel before reaching a repeater, split by whether they carried a region scope (TRANSPORT_FLOOD/TRANSPORT_DIRECT) or not (plain FLOOD). If scoping is containing traffic as intended, Scoped should cluster at fewer hops than Unscoped — a similar or higher scoped median means scope boundaries aren’t actually limiting propagation.' + + '

' + + '
' + + [ + { label: 'Scoped Median Hop', value: scopedStats.median === null ? '—' : scopedStats.median.toLocaleString(), note: scopedStats.total.toLocaleString() + ' samples' }, + { label: 'Unscoped Median Hop', value: unscopedStats.median === null ? '—' : unscopedStats.median.toLocaleString(), note: unscopedStats.total.toLocaleString() + ' samples' }, + ].map(function(c) { + return '
' + c.value + '
' + + '
' + c.label + '
' + + '
' + c.note + '
' + + '
'; + }).join('') + + '
' + + '
' + + 'Scoped' + + 'Unscoped' + + '
' + + rows; + } + + // publicKey -> RepeaterUnscopedHopDepth lookup from + // HopDepthAnalyticsResponse.unscopedByRepeater (see GetHopDepthAnalytics), + // used to enrich the Foreign Traffic tab's unscoped-relay table with how + // far that traffic had already traveled before reaching each repeater. + function hopDepthLookupByPubkey(unscopedByRepeater) { + var byPK = {}; + (unscopedByRepeater || []).forEach(function(r) { byPK[r.publicKey] = r; }); + return byPK; + } + function computeNodesWithoutScope(allNodes, cap, opts) { opts = opts || {}; var noScopeNodes = allNodes.filter(function(n) { return !n.default_scope; }); @@ -5514,6 +5630,13 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf function isForeignNode(n) { return !nodePassesGeoFilter(n.lat, n.lon, window.MC_GEO_FILTER); } + // publicKey -> RepeaterUnscopedHopDepth, populated by load() from + // /api/analytics/hop-depth. Enriches the volume-only unscoped_relay_count_24h + // metric with WHERE in the flood's propagation this repeater sits: low + // hops means mostly fresh/local unscoped traffic, high hops means + // traffic that already traveled far, unscoped, before reaching it — the + // stronger signal of an actual containment problem. + var hopDepthByPubkey = {}; function relaysHtml(relays, expanded) { if (relays.length === 0) { return '

No repeater has relayed an unscoped flood packet in the last 24 hours.

'; @@ -5522,16 +5645,22 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf var rows = shown.map(function(n) { var total = n.relay_count_24h || 0; var unscoped = n.unscoped_relay_count_24h || 0; + var hd = hopDepthByPubkey[n.public_key]; return '' + '' + esc(n.name || n.public_key) + '' + '' + esc(n.role) + '' + '' + unscoped.toLocaleString() + '' + '' + total.toLocaleString() + '' + '' + pct(unscoped, total) + '' + + '' + (hd ? hd.minHops : '—') + '' + + '' + (hd ? (hd.medianHops % 1 === 0 ? hd.medianHops : hd.medianHops.toFixed(1)) : '—') + '' + + '' + (hd ? hd.maxHops : '—') + '' + ''; }).join(''); return '' + - '' + + '' + + '' + + '' + '' + rows + '' + '
RepeaterRoleUnscoped Relays (24h)Total Relays (24h)% Unscoped
RepeaterRoleUnscoped Relays (24h)Total Relays (24h)% UnscopedMin HopsMedian HopsMax Hops
' + topNToggleHtml(relays.length, expanded, 'repeaters'); } @@ -5560,6 +5689,16 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf async function load() { try { + // Fired early but only awaited below, right before it's needed for + // relaysHtml -- deliberately NOT awaited here. An extra await + // ahead of the isForeignNode/geo_filter computation just below + // shifts how many microtask ticks elapse before it runs, which in + // the test sandbox lets roles.js's own async window.MC_GEO_FILTER + // config-fetch race ahead and clobber the fixture's geo filter + // before isForeignNode reads it (see + // test-analytics-foreign-traffic-tab.js's makeAnalyticsSandbox). + const hopDepthPromise = api('/analytics/hop-depth?window=24h', { ttl: 30000 }).catch(function() { return null; }); + const nodesResp = await fetchAllNodes('', { ttl: CLIENT_TTL.nodeList }); const allNodes = nodesResp.nodes || nodesResp; const relays = allNodes.filter(function(n) { @@ -5584,6 +5723,9 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf .sort(function(a, b) { return b.t - a.t; }) .forEach(function(entry, i) { foreignNodes[i] = entry.n; }); + const hopData = await hopDepthPromise; + hopDepthByPubkey = hopDepthLookupByPubkey(hopData && hopData.unscopedByRepeater); + el.innerHTML = '

Foreign Traffic

' + '

' + diff --git a/test-all.sh b/test-all.sh index b50ff270..bb19215c 100755 --- a/test-all.sh +++ b/test-all.sh @@ -71,6 +71,7 @@ node test-issue-1473-prefix-generator.js node test-issue-1770-mobile-row-clamp.js node test-issue-1849-trace-hashbytes.js node test-node-analytics-hop-chart.js +node test-analytics-hop-depth-ui.js echo "" echo "═══════════════════════════════════════" diff --git a/test-analytics-hop-depth-ui.js b/test-analytics-hop-depth-ui.js new file mode 100644 index 00000000..4df11e03 --- /dev/null +++ b/test-analytics-hop-depth-ui.js @@ -0,0 +1,266 @@ +/** + * Tests for the two "lad os lave dem begge" extensions built on top of + * /api/analytics/hop-depth (GetHopDepthAnalytics, cmd/server/db.go): + * + * 1. Scopes tab Overview: renderHopDepthSectionHtml / hopDepthBucketStats + * — the scoped-vs-unscoped hop-depth comparison (does region scoping + * actually contain flood propagation to fewer hops than unscoped + * traffic). + * 2. Foreign Traffic tab: hopDepthLookupByPubkey — the per-repeater + * min/median/max hop-depth enrichment joined onto the existing + * "Repeaters Relaying Unscoped Traffic" table by public key. + * + * Pure-function unit tests plus one DOM-rendering integration test for + * the Foreign Traffic table join, following the same vm-sandbox pattern + * as test-analytics-foreign-traffic-tab.js / test-node-analytics-hop-chart.js. + */ +'use strict'; + +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +let passed = 0, failed = 0; +async function testAsync(name, fn) { + try { + await fn(); + passed++; + console.log(` ✅ ${name}`); + } catch (e) { + failed++; + console.log(` ❌ ${name}: ${e.message}`); + } +} + +function makeSandbox() { + const ctx = { + window: { addEventListener: () => {}, dispatchEvent: () => {} }, + document: { + readyState: 'complete', + createElement: () => ({ id: '', textContent: '', innerHTML: '' }), + head: { appendChild: () => {} }, + getElementById: () => null, + addEventListener: () => {}, + querySelectorAll: () => [], + querySelector: () => null, + }, + console, Date, Infinity, Math, Array, Object, String, Number, JSON, RegExp, + Error, TypeError, parseInt, parseFloat, isNaN, isFinite, + encodeURIComponent, decodeURIComponent, + setTimeout: () => {}, clearTimeout: () => {}, + fetch: () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }), + performance: { now: () => Date.now() }, + localStorage: (() => { const s = {}; return { getItem: k => s[k] || null, setItem: (k, v) => { s[k] = String(v); }, removeItem: k => { delete s[k]; } }; })(), + location: { hash: '' }, + getHashParams: function() { return new URLSearchParams((ctx.location.hash.split('?')[1] || '')); }, + CustomEvent: class CustomEvent {}, + Map, Promise, URLSearchParams, + addEventListener: () => {}, + dispatchEvent: () => {}, + requestAnimationFrame: (cb) => setTimeout(cb, 0), + setInterval: () => 1, + clearInterval: () => {}, + }; + vm.createContext(ctx); + return ctx; +} + +function loadInCtx(ctx, file) { + if (!ctx.__payloadLabelsLoaded && file !== 'public/payload-labels.js') { + ctx.__payloadLabelsLoaded = true; + vm.runInContext(fs.readFileSync('public/payload-labels.js', 'utf8'), ctx); + } + vm.runInContext(fs.readFileSync(file, 'utf8'), ctx); + for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; +} + +const GEO_BOX = { latMin: 53, latMax: 59, lonMin: 6, lonMax: 15 }; + +function makeAnalyticsSandbox(nodesFixture, opts) { + opts = opts || {}; + const ctx = makeSandbox(); + ctx.getComputedStyle = () => ({ getPropertyValue: () => '' }); + ctx.registerPage = () => {}; + ctx.timeAgo = (iso) => iso ? 'x ago' : '—'; + ctx.RegionFilter = { init: () => {}, onChange: () => {}, regionQueryString: () => '' }; + ctx.onWS = () => {}; + ctx.offWS = () => {}; + ctx.connectWS = () => {}; + ctx.invalidateApiCache = () => {}; + ctx.makeColumnsResizable = () => {}; + ctx.initTabBar = () => {}; + ctx.IATA_COORDS_GEO = {}; + // fetch is what app.js's real api() ultimately calls -- routes by URL so + // the hop-depth endpoint can be stubbed independently of every other + // in-flight api() call (e.g. roles.js's own config fetch on load). + if (opts.hopDepthResponse !== undefined) { + ctx.fetch = (url) => { + if (String(url).indexOf('/analytics/hop-depth') !== -1) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(opts.hopDepthResponse) }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + }; + } + loadInCtx(ctx, 'public/roles.js'); + loadInCtx(ctx, 'public/app.js'); + ctx.fetchAllNodes = async () => ({ nodes: nodesFixture || [] }); + ctx.window.MC_GEO_FILTER = GEO_BOX; + try { loadInCtx(ctx, 'public/analytics.js'); } catch (e) { + for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; + } + return ctx; +} + +function fakeEl() { + return { innerHTML: '' }; +} + +(async () => { + console.log('\n=== analytics.js: hopDepthBucketStats ==='); + + await testAsync('empty/missing buckets return total 0 and null median', async () => { + const ctx = makeAnalyticsSandbox([]); + [[], null, undefined].forEach((input) => { + const stats = ctx.window._analyticsHopDepthBucketStats(input); + assert.strictEqual(stats.total, 0); + assert.strictEqual(stats.median, null); + }); + }); + + await testAsync('single bucket -> median is that bucket\'s hop value', async () => { + const ctx = makeAnalyticsSandbox([]); + const stats = ctx.window._analyticsHopDepthBucketStats([{ hops: 3, count: 7 }]); + assert.strictEqual(stats.total, 7); + assert.strictEqual(stats.median, 3); + }); + + await testAsync('odd total -> median is the middle bucket by cumulative count', async () => { + const ctx = makeAnalyticsSandbox([]); + // hops 0..4 counts 1 -> total 5, cumulative >= 2.5 first at hops=2. + const stats = ctx.window._analyticsHopDepthBucketStats([ + { hops: 0, count: 1 }, { hops: 1, count: 1 }, { hops: 2, count: 1 }, + { hops: 3, count: 1 }, { hops: 4, count: 1 }, + ]); + assert.strictEqual(stats.total, 5); + assert.strictEqual(stats.median, 2); + }); + + await testAsync('median is unaffected by input bucket order (sorts internally)', async () => { + const ctx = makeAnalyticsSandbox([]); + const stats = ctx.window._analyticsHopDepthBucketStats([ + { hops: 4, count: 1 }, { hops: 0, count: 1 }, { hops: 2, count: 1 }, + { hops: 1, count: 1 }, { hops: 3, count: 1 }, + ]); + assert.strictEqual(stats.median, 2); + }); + + console.log('\n=== analytics.js: renderHopDepthSectionHtml ==='); + + await testAsync('null/empty hopData renders nothing', async () => { + const ctx = makeAnalyticsSandbox([]); + assert.strictEqual(ctx.window._analyticsRenderHopDepthSectionHtml(null), ''); + assert.strictEqual(ctx.window._analyticsRenderHopDepthSectionHtml({}), ''); + }); + + await testAsync('present-but-empty buckets renders the no-data message', async () => { + const ctx = makeAnalyticsSandbox([]); + const html = ctx.window._analyticsRenderHopDepthSectionHtml({ scopedHopDepth: [], unscopedHopDepth: [] }); + assert.ok(html.includes('No relay-hop data in this window')); + }); + + await testAsync('renders scoped/unscoped median stat cards and per-hop bar rows', async () => { + const ctx = makeAnalyticsSandbox([]); + const html = ctx.window._analyticsRenderHopDepthSectionHtml({ + scopedHopDepth: [{ hops: 0, count: 10 }], + unscopedHopDepth: [{ hops: 0, count: 4 }, { hops: 1, count: 4 }, { hops: 2, count: 2 }], + }); + assert.ok(html.includes('Scoped Median Hop'), 'should show a scoped median stat card'); + assert.ok(html.includes('Unscoped Median Hop'), 'should show an unscoped median stat card'); + assert.ok(html.includes('10 samples'), 'scoped sample size should be 10'); + assert.ok(html.includes('0 hops'), 'should have a hop=0 row'); + assert.ok(html.includes('2 hops'), 'should have a hop=2 row'); + }); + + console.log('\n=== analytics.js: hopDepthLookupByPubkey ==='); + + await testAsync('builds a publicKey -> entry map', async () => { + const ctx = makeAnalyticsSandbox([]); + const entries = [ + { publicKey: 'pkA', name: 'A', count: 5, minHops: 1, medianHops: 2, maxHops: 4 }, + { publicKey: 'pkB', name: 'B', count: 2, minHops: 0, medianHops: 0.5, maxHops: 1 }, + ]; + const map = ctx.window._analyticsHopDepthLookupByPubkey(entries); + assert.strictEqual(map.pkA.count, 5); + assert.strictEqual(map.pkB.maxHops, 1); + }); + + await testAsync('empty/null input returns an empty map, not a throw', async () => { + const ctx = makeAnalyticsSandbox([]); + [[], null, undefined].forEach((input) => { + const map = ctx.window._analyticsHopDepthLookupByPubkey(input); + assert.strictEqual(Object.keys(map).length, 0); + }); + }); + + console.log('\n=== analytics.js: Foreign Traffic tab hop-depth enrichment ==='); + + await testAsync('relay table shows min/median/max hop columns joined by public key', async () => { + const ctx = makeAnalyticsSandbox([ + { public_key: 'pkA', name: 'RepeaterA', role: 'repeater', unscoped_relay_count_24h: 10, relay_count_24h: 20 }, + ], { + hopDepthResponse: { + window: '24h', + scopedHopDepth: [], + unscopedHopDepth: [], + unscopedByRepeater: [ + { publicKey: 'pkA', name: 'RepeaterA', count: 10, minHops: 1, medianHops: 2.5, maxHops: 6 }, + ], + }, + }); + const el = fakeEl(); + await ctx.window._analyticsRenderForeignTrafficTab(el); + assert.ok(el.innerHTML.includes('Min Hops'), 'table header should include Min Hops'); + assert.ok(el.innerHTML.includes('Median Hops'), 'table header should include Median Hops'); + assert.ok(el.innerHTML.includes('Max Hops'), 'table header should include Max Hops'); + // Row cells: min=1, median=2.5, max=6 for pkA. + const rowStart = el.innerHTML.indexOf('RepeaterA'); + const rowSection = el.innerHTML.slice(rowStart, rowStart + 500); + assert.ok(rowSection.includes('>1<'), 'min hops cell should show 1'); + assert.ok(rowSection.includes('>2.5<'), 'median hops cell should show 2.5'); + assert.ok(rowSection.includes('>6<'), 'max hops cell should show 6'); + }); + + await testAsync('a repeater with no hop-depth entry shows placeholders instead of throwing', async () => { + const ctx = makeAnalyticsSandbox([ + { public_key: 'pkNoHops', name: 'QuietRepeater', role: 'repeater', unscoped_relay_count_24h: 3, relay_count_24h: 10 }, + ], { + hopDepthResponse: { window: '24h', scopedHopDepth: [], unscopedHopDepth: [], unscopedByRepeater: [] }, + }); + const el = fakeEl(); + await ctx.window._analyticsRenderForeignTrafficTab(el); + assert.ok(el.innerHTML.includes('QuietRepeater')); + const rowStart = el.innerHTML.indexOf('QuietRepeater'); + const rowSection = el.innerHTML.slice(rowStart, rowStart + 500); + assert.ok(rowSection.includes('>—<'), 'missing hop-depth data should render as an em dash placeholder'); + }); + + await testAsync('a failing hop-depth fetch degrades to placeholders, not a broken tab', async () => { + const ctx = makeAnalyticsSandbox([ + { public_key: 'pkA', name: 'RepeaterA', role: 'repeater', unscoped_relay_count_24h: 5, relay_count_24h: 5 }, + ]); + // No hopDepthResponse opt given -> default sandbox fetch mock returns + // { ok:true, json: () => ({}) } for every path, including hop-depth, + // so unscopedByRepeater is simply undefined/absent -- exercises the + // "no data at all" branch through the same code path a real network + // error's catch(() => null) would also produce. + const el = fakeEl(); + await ctx.window._analyticsRenderForeignTrafficTab(el); + assert.ok(el.innerHTML.includes('RepeaterA'), 'tab should still render the repeater row'); + assert.ok(el.innerHTML.includes('Min Hops'), 'table structure should still include the hop columns'); + }); + + console.log('\n════════════════════════════════════════'); + console.log(` Hop-Depth Analytics UI: ${passed} passed, ${failed} failed`); + console.log('════════════════════════════════════════'); + if (failed > 0) process.exit(1); +})();