diff --git a/cmd/server/db.go b/cmd/server/db.go index 34079c79..91f01542 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -3170,6 +3170,269 @@ 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 + var bucketExpr string + switch window { + case "1h": + since = time.Now().Add(-1 * time.Hour).UTC().Format(time.RFC3339) + // 5-minute buckets -- same bucketing as GetScopeStats' TimeSeries. + bucketExpr = `strftime('%Y-%m-%dT%H:', t.first_seen) || printf('%02d', (CAST(strftime('%M', t.first_seen) AS INTEGER) / 5) * 5) || ':00Z'` + case "7d": + since = time.Now().Add(-7 * 24 * time.Hour).UTC().Format(time.RFC3339) + // 6-hour buckets + bucketExpr = `strftime('%Y-%m-%dT', t.first_seen) || printf('%02d', (CAST(strftime('%H', t.first_seen) AS INTEGER) / 6) * 6) || ':00:00Z'` + default: + window = "24h" + since = time.Now().Add(-24 * time.Hour).UTC().Format(time.RFC3339) + // 1-hour buckets + bucketExpr = `strftime('%Y-%m-%dT%H:00:00Z', t.first_seen)` + } + + rows, err := db.conn.Query(` + SELECT t.id, t.route_type, t.payload_type, o.resolved_path, `+bucketExpr+` AS bucket + 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 + bucket string + } + byTx := make(map[int]*txInfo) + for rows.Next() { + var txID int + var routeType, payloadType sql.NullInt64 + var rpJSON, bucket string + if err := rows.Scan(&txID, &routeType, &payloadType, &rpJSON, &bucket); 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, bucket: bucket} + 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{} + // Same scoped/unscoped hop-index tally as scopedBuckets/unscopedBuckets + // above, but split by time bucket too -- feeds TimeSeries, answering + // "is containment getting better or worse over the window" instead of + // just a single window-wide snapshot. + scopedByTimeBucket := map[string]map[int]int{} + unscopedByTimeBucket := map[string]map[int]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]++ + if scopedByTimeBucket[info.bucket] == nil { + scopedByTimeBucket[info.bucket] = map[int]int{} + } + scopedByTimeBucket[info.bucket][idx]++ + } else { + unscopedBuckets[idx]++ + if unscopedByTimeBucket[info.bucket] == nil { + unscopedByTimeBucket[info.bucket] = map[int]int{} + } + unscopedByTimeBucket[info.bucket][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 + } + + // medianHopFromCounts mirrors the frontend's hopDepthBucketStats: the + // smallest hop value whose cumulative count reaches half the total -- + // a histogram-based median consistent with how ScopedHopDepth/ + // UnscopedHopDepth's medians are read client-side, rather than + // introducing a second, subtly different median definition here. + medianHopFromCounts := func(counts map[int]int) *int { + total := 0 + for _, c := range counts { + total += c + } + if total == 0 { + return nil + } + hops := make([]int, 0, len(counts)) + for h := range counts { + hops = append(hops, h) + } + sort.Ints(hops) + cum := 0 + for _, h := range hops { + cum += counts[h] + if cum*2 >= total { + v := h + return &v + } + } + return nil + } + + bucketSet := map[string]bool{} + for b := range scopedByTimeBucket { + bucketSet[b] = true + } + for b := range unscopedByTimeBucket { + bucketSet[b] = true + } + timeBuckets := make([]string, 0, len(bucketSet)) + for b := range bucketSet { + timeBuckets = append(timeBuckets, b) + } + sort.Strings(timeBuckets) + timeSeries := make([]HopDepthTimePoint, 0, len(timeBuckets)) + for _, b := range timeBuckets { + timeSeries = append(timeSeries, HopDepthTimePoint{ + T: b, + ScopedMedianHop: medianHopFromCounts(scopedByTimeBucket[b]), + UnscopedMedianHop: medianHopFromCounts(unscopedByTimeBucket[b]), + }) + } + + // 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, + TimeSeries: timeSeries, + }, 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..a0540c10 --- /dev/null +++ b/cmd/server/hop_depth_analytics_test.go @@ -0,0 +1,264 @@ +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 || len(resp.TimeSeries) != 0 { + t.Errorf("expected all-empty response on an empty DB, got %+v", resp) + } +} + +// TestGetHopDepthAnalytics_TimeSeries covers the per-time-bucket +// scoped/unscoped median hop trend: two transmissions two hours apart +// must land in two distinct 1-hour buckets (24h window), sorted +// chronologically, and a series with no samples in a given bucket must +// report nil (not 0 -- 0 is itself a valid median hop). +func TestGetHopDepthAnalytics_TimeSeries(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)`) + + repeaterA := "aa001111aaaabbbb" + conn.Exec(`INSERT INTO nodes (public_key, name, role) VALUES (?, 'RepeaterA', 'repeater')`, repeaterA) + + now := time.Now().UTC() + // Truncated-hour-aligned with a safe +10min offset so a slow test run + // can't accidentally straddle an hour boundary and merge the buckets. + oldBucket := now.Truncate(time.Hour).Add(-3*time.Hour + 10*time.Minute) + recentBucket := now.Truncate(time.Hour).Add(-1*time.Hour + 10*time.Minute) + + // oldBucket: one scoped, single-hop path -> ScopedMedianHop=0, no + // unscoped traffic in this bucket at all -> UnscopedMedianHop=nil. + conn.Exec(`INSERT INTO transmissions (id, hash, first_seen, route_type, payload_type) VALUES (1, 'h1', ?, 0, 5)`, oldBucket.Format(time.RFC3339)) + conn.Exec(`INSERT INTO observations (id, transmission_id, resolved_path) VALUES (1, 1, ?)`, `["`+repeaterA+`"]`) + + // recentBucket: one unscoped, single-hop path -> UnscopedMedianHop=0, + // no scoped traffic here -> ScopedMedianHop=nil. + conn.Exec(`INSERT INTO transmissions (id, hash, first_seen, route_type, payload_type) VALUES (2, 'h2', ?, 1, 5)`, recentBucket.Format(time.RFC3339)) + conn.Exec(`INSERT INTO observations (id, transmission_id, resolved_path) VALUES (2, 2, ?)`, `["`+repeaterA+`"]`) + + resp, err := db.GetHopDepthAnalytics("24h") + if err != nil { + t.Fatalf("GetHopDepthAnalytics: %v", err) + } + if len(resp.TimeSeries) != 2 { + t.Fatalf("TimeSeries = %+v, want exactly 2 buckets", resp.TimeSeries) + } + + old, recentPt := resp.TimeSeries[0], resp.TimeSeries[1] + if old.T >= recentPt.T { + t.Errorf("TimeSeries not chronologically sorted: %q should be before %q", old.T, recentPt.T) + } + if old.ScopedMedianHop == nil || *old.ScopedMedianHop != 0 { + t.Errorf("old bucket ScopedMedianHop = %v, want pointer to 0", old.ScopedMedianHop) + } + if old.UnscopedMedianHop != nil { + t.Errorf("old bucket UnscopedMedianHop = %v, want nil (no unscoped traffic that hour)", *old.UnscopedMedianHop) + } + if recentPt.UnscopedMedianHop == nil || *recentPt.UnscopedMedianHop != 0 { + t.Errorf("recent bucket UnscopedMedianHop = %v, want pointer to 0", recentPt.UnscopedMedianHop) + } + if recentPt.ScopedMedianHop != nil { + t.Errorf("recent bucket ScopedMedianHop = %v, want nil (no scoped traffic that hour)", *recentPt.ScopedMedianHop) + } +} + +// 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..b2650d36 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 three 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, (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), and (3) is that containment trending better or worse over the window (timeSeries). Plain DIRECT traffic never undergoes flood propagation and is excluded throughout. 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,45 @@ 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"}, + }, + }, + "HopDepthTimePoint": map[string]interface{}{ + "type": "object", + "description": "One time bucket's scoped/unscoped median hop depth (5min/1h/6h buckets for 1h/24h/7d windows, same bucketing as ScopeStatsResponse.timeSeries).", + "properties": map[string]interface{}{ + "t": str("Bucket start, RFC3339 UTC."), + "scopedMedianHop": map[string]interface{}{"type": "integer", "nullable": true, "description": "Median hop depth of scoped traffic in this bucket, or null if there was none (0 is a valid median, so absence isn't the same as zero)."}, + "unscopedMedianHop": map[string]interface{}{"type": "integer", "nullable": true, "description": "Median hop depth of unscoped traffic in this bucket, or null if there was none."}, + }, + }, + "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."}, + "timeSeries": map[string]interface{}{"type": "array", "items": schemaRef("HopDepthTimePoint"), "description": "Scoped/unscoped median hop depth over time within the window — is containment trending better or worse."}, + }, + }, } } 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..48f5948f 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -880,6 +880,69 @@ 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. +// 3. TimeSeries: the same scoped/unscoped median hop split by time +// bucket instead of collapsed to one window-wide number -- is +// containment trending better or worse, not just where it stands +// right now. +type HopDepthAnalyticsResponse struct { + Window string `json:"window"` + ScopedHopDepth []HopDepthBucket `json:"scopedHopDepth"` + UnscopedHopDepth []HopDepthBucket `json:"unscopedHopDepth"` + UnscopedByRepeater []RepeaterUnscopedHopDepth `json:"unscopedByRepeater"` + TimeSeries []HopDepthTimePoint `json:"timeSeries"` +} + +// HopDepthTimePoint is one time bucket's scoped/unscoped median hop depth +// -- same time-series shape as ScopeTimePoint, but tracking flood- +// propagation depth instead of raw scoped/unscoped transmission counts. +// Same bucketing as ScopeStatsResponse.TimeSeries (5min/1h/6h for +// 1h/24h/7d windows). Pointers, not plain ints: 0 is a valid median hop, +// so "no scoped (or unscoped) traffic in this bucket" has to be +// distinguishable from "median is 0" rather than silently defaulting to +// zero and implying containment that isn't really there. +type HopDepthTimePoint struct { + T string `json:"t"` + ScopedMedianHop *int `json:"scopedMedianHop,omitempty"` + UnscopedMedianHop *int `json:"unscopedMedianHop,omitempty"` +} + // ─── Analytics — RF ──────────────────────────────────────────────────────────── type PayloadTypeSignal struct { diff --git a/public/analytics.js b/public/analytics.js index 33b094b1..e6154c11 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -2719,6 +2719,12 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf window._analyticsStopWardrivingRefresh = _stopWardrivingRefresh; window._analyticsComputeNodesWithoutScope = computeNodesWithoutScope; window._analyticsComputeRepeatersNeverRelayingScope = computeRepeatersNeverRelayingScope; + window._analyticsHopDepthBucketStats = hopDepthBucketStats; + window._analyticsHopDepthPercentile = hopDepthPercentile; + window._analyticsRenderHopDepthSectionHtml = renderHopDepthSectionHtml; + window._analyticsHopDepthTimeSeriesChartHtml = hopDepthTimeSeriesChartHtml; + window._analyticsHopDepthLookupByPubkey = hopDepthLookupByPubkey; + window._analyticsBridgeRepeaterPubkeySet = bridgeRepeaterPubkeySet; } // ─── Neighbor Graph Tab ───────────────────────────────────────────────────── @@ -4491,16 +4497,18 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf var winKey = 'scopes_window'; var selectedWindow = (typeof sessionStorage !== 'undefined' && sessionStorage.getItem(winKey)) || '24h'; - // #1852: the tab grew to 12 stacked sections (windowed adoption stats, + // #1852: the tab grew to stacked sections (windowed adoption stats, // all-time region breakdowns, all-time node/repeater hygiene lists) — - // grouped into 3 sub-tabs so a visitor sees one coherent screen at a - // time instead of one long scroll. Purely presentational: all three - // groups' data still loads together (one #scope-stats fetch + the two - // hygiene sections' own fetchAllNodes calls), only DOM visibility is - // gated on the active sub-tab. The 1h/24h/7d window picker only - // affects the Overview group (the all-time Regions/Hygiene sections - // ignore it entirely), so it lives inside that panel, not above the - // sub-tab bar. + // grouped into sub-tabs so a visitor sees one coherent screen at a + // time instead of one long scroll. Purely presentational: every + // group's data still loads together (one #scope-stats fetch + the + // hop-depth fetch + the two hygiene sections' own fetchAllNodes + // calls), only DOM visibility is gated on the active sub-tab. The + // 1h/24h/7d window picker affects Overview and Hop Depth (both + // windowed) but not the all-time Regions/Hygiene groups, so each + // windowed panel gets its own copy of the picker buttons rather than + // one shared control above the sub-tab bar — every button still + // drives the same selectedWindow/load(), see the click listener below. var subtabKey = 'scopes_subtab'; var selectedSubtab = (typeof sessionStorage !== 'undefined' && sessionStorage.getItem(subtabKey)) || 'overview'; @@ -4534,6 +4542,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf '
' + [ { key: 'overview', label: 'Overview' }, + { key: 'hopdepth', label: 'Hop Depth' }, { key: 'regions', label: 'Regions' }, { key: 'hygiene', label: 'Hygiene' }, ].map(function(t) { @@ -4561,6 +4570,14 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf '
' + '' + '
' + + '
' + + '
' + + ['1h', '24h', '7d'].map(function(v) { + return ''; + }).join('') + + '
' + + '
' + + '
' + '
' + '
' + '
' + @@ -4582,7 +4599,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf selectedSubtab = btn.dataset.subtab; if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(subtabKey, selectedSubtab); subtabsEl.querySelectorAll('[data-subtab]').forEach(function(b) { b.classList.toggle('active', b.dataset.subtab === selectedSubtab); }); - ['overview', 'regions', 'hygiene'].forEach(function(key) { + ['overview', 'hopdepth', 'regions', 'hygiene'].forEach(function(key) { var panel = document.getElementById('scopes-panel-' + key); if (panel) panel.style.display = key === selectedSubtab ? '' : 'none'; }); @@ -4938,6 +4955,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 +5419,213 @@ 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 }; + } + + // Cumulative-count percentile from a HopDepthBucket[] (see + // GetHopDepthAnalytics) -- p is 0..1 (e.g. 0.95 for P95). Returns the + // smallest hop value whose cumulative count reaches that fraction of + // the total, or null when there's no data. Used to turn the raw + // scoped/unscoped histograms into a concrete "set flood.max.unscoped to + // roughly this" number, rather than making the operator eyeball the bar + // chart themselves. + function hopDepthPercentile(buckets, p) { + var total = 0; + (buckets || []).forEach(function(b) { total += b.count; }); + if (!total) return null; + var sorted = (buckets || []).slice().sort(function(a, b) { return a.hops - b.hops; }); + var target = total * p; + var cum = 0; + for (var i = 0; i < sorted.length; i++) { + cum += sorted[i].count; + if (cum >= target) return sorted[i].hops; + } + return sorted.length ? sorted[sorted.length - 1].hops : null; + } + + // Two-line SVG chart of scoped vs unscoped median hop depth over time + // (HopDepthAnalyticsResponse.timeSeries) -- same visual language as the + // Scopes Overview's scoped/unscoped count time-series, but each series + // can have gaps (a bucket with no traffic of that kind reports + // scopedMedianHop/unscopedMedianHop as null, not 0) so the polyline is + // built as separate contiguous segments rather than one line that would + // otherwise silently interpolate straight through missing data. + function hopDepthTimeSeriesChartHtml(timeSeries) { + if (!timeSeries || timeSeries.length < 2) { + return '

Insufficient data points to render a trend — wait for more observations in this window.

'; + } + var n = timeSeries.length; + var maxVal = 1; + timeSeries.forEach(function(p) { + if (typeof p.scopedMedianHop === 'number') maxVal = Math.max(maxVal, p.scopedMedianHop); + if (typeof p.unscopedMedianHop === 'number') maxVal = Math.max(maxVal, p.unscopedMedianHop); + }); + + var W = 800, H = 180, padL = 30, padT = 10, padR = 10; + var plotW = W - padL - padR, plotH = H - 24 - padT; + + function segments(key) { + var out = []; + var current = []; + timeSeries.forEach(function(p, i) { + var v = p[key]; + if (typeof v !== 'number') { + if (current.length > 1) out.push(current); + current = []; + return; + } + var x = padL + i * plotW / Math.max(n - 1, 1); + var y = padT + plotH - (v / maxVal) * plotH; + current.push(x.toFixed(1) + ',' + y.toFixed(1)); + }); + if (current.length > 1) out.push(current); + return out; + } + + function polylines(key, stroke, dash) { + return segments(key).map(function(seg) { + return ''; + }).join(''); + } + + var grid = ''; + for (var gi = 0; gi <= 4; gi++) { + var gy = padT + plotH * gi / 4; + var gv = Math.round(maxVal * (4 - gi) / 4); + grid += ''; + grid += '' + gv + ''; + } + + var legendX = padL + plotW - 120; + return '
' + + '' + + grid + + polylines('scopedMedianHop', 'var(--accent)') + + polylines('unscopedMedianHop', 'var(--text-muted)', '4') + + '' + + 'Scoped median' + + '' + + 'Unscoped median' + + '
'; + } + + // Renders the scoped-vs-unscoped hop-depth comparison (Scopes tab > Hop + // Depth sub-tab). Order (top to bottom): the median-hop-over-time trend + // chart first (most at-a-glance signal, "is this getting better or + // worse" — put at the top for visibility rather than buried below the + // static snapshot), then median-hop stat cards for both series, a + // P95-derived "suggested flood.max.unscoped" callout, and 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 — turn + // that into a concrete number an operator can plug into their MeshCore + // firmware config instead of eyeballing the chart, and show whether + // it's getting better or worse over time. + 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); + var unscopedP95 = hopDepthPercentile(unscoped, 0.95); + 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.' + + '

' + + '

Median Hop Depth Over Time

' + + '

Is containment trending better or worse within this window, rather than just where it stands right now. A gap in a line means no traffic of that kind in that bucket, not a median of zero.

' + + hopDepthTimeSeriesChartHtml(hopData.timeSeries) + + '
' + + [ + { 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' }, + { label: 'Suggested flood.max.unscoped', value: unscopedP95 === null ? '—' : unscopedP95.toLocaleString(), note: 'P95 of unscoped hop depth — caps runaway propagation, lets ~95% of legitimate unscoped traffic through' }, + ].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; + } + + // publicKey -> true for every BridgeRepeater (ScopeStatsResponse.bridgeRepeaters, + // see db.go) -- repeaters confirmed relaying for 2+ regions. Cross-referenced + // against the Foreign Traffic tab's unscoped-relay table so a high hop-depth + // reading at a bridge reads as expected cross-region traffic, not a leak: a + // bridge is SUPPOSED to see traffic that already traveled far from another + // region's scope, unlike a plain regional repeater seeing the same pattern. + function bridgeRepeaterPubkeySet(bridgeRepeaters) { + var byPK = {}; + (bridgeRepeaters || []).forEach(function(b) { byPK[b.publicKey] = true; }); + return byPK; + } + function computeNodesWithoutScope(allNodes, cap, opts) { opts = opts || {}; var noScopeNodes = allNodes.filter(function(n) { return !n.default_scope; }); @@ -5514,6 +5754,16 @@ 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 = {}; + // publicKey -> true for confirmed bridge repeaters (2+ regions), populated + // by load() from /api/scope-stats. See bridgeRepeaterPubkeySet's doc comment. + var bridgePubkeys = {}; function relaysHtml(relays, expanded) { if (relays.length === 0) { return '

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

'; @@ -5522,16 +5772,25 @@ 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]; + var bridgeBadge = bridgePubkeys[n.public_key] + ? ' Bridge' + : ''; return '' + - '' + esc(n.name || n.public_key) + '' + + '' + esc(n.name || n.public_key) + '' + bridgeBadge + '' + '' + 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 +5819,20 @@ 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; }); + // bridgeRepeaters is all-time regardless of window (see BridgeRepeater's + // doc comment) -- 24h here just reuses the same cached /scope-stats + // response the Scopes tab's default window already warms. + const bridgeStatsPromise = api('/scope-stats?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 +5857,11 @@ 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); + const bridgeStats = await bridgeStatsPromise; + bridgePubkeys = bridgeRepeaterPubkeySet(bridgeStats && bridgeStats.bridgeRepeaters); + 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..f3a2ebf7 --- /dev/null +++ b/test-analytics-hop-depth-ui.js @@ -0,0 +1,443 @@ +/** + * 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/scope-stats endpoints 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 || opts.scopeStatsResponse !== undefined) { + ctx.fetch = (url) => { + if (String(url).indexOf('/analytics/hop-depth') !== -1) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(opts.hopDepthResponse) }); + } + if (String(url).indexOf('/scope-stats') !== -1) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(opts.scopeStatsResponse) }); + } + 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: hopDepthPercentile ==='); + + await testAsync('no data returns null', async () => { + const ctx = makeAnalyticsSandbox([]); + assert.strictEqual(ctx.window._analyticsHopDepthPercentile([], 0.95), null); + assert.strictEqual(ctx.window._analyticsHopDepthPercentile(null, 0.95), null); + }); + + await testAsync('P95 of a 100-sample even spread lands at hops=94', async () => { + const ctx = makeAnalyticsSandbox([]); + // hops 0..99, count 1 each -> total 100, cumulative >= 95 first at hops=94. + const buckets = []; + for (let h = 0; h < 100; h++) buckets.push({ hops: h, count: 1 }); + assert.strictEqual(ctx.window._analyticsHopDepthPercentile(buckets, 0.95), 94); + }); + + await testAsync('P50 matches the median helper on the same data', async () => { + const ctx = makeAnalyticsSandbox([]); + const buckets = [{ hops: 0, count: 1 }, { hops: 1, count: 1 }, { hops: 2, count: 1 }, { hops: 3, count: 1 }, { hops: 4, count: 1 }]; + const median = ctx.window._analyticsHopDepthBucketStats(buckets).median; + const p50 = ctx.window._analyticsHopDepthPercentile(buckets, 0.5); + assert.strictEqual(p50, median); + }); + + await testAsync('a single heavy bucket dominates the percentile regardless of p', async () => { + const ctx = makeAnalyticsSandbox([]); + const buckets = [{ hops: 0, count: 1000 }, { hops: 20, count: 1 }]; + assert.strictEqual(ctx.window._analyticsHopDepthPercentile(buckets, 0.95), 0); + assert.strictEqual(ctx.window._analyticsHopDepthPercentile(buckets, 0.999), 0); + }); + + 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('Suggested flood.max.unscoped'), 'should show a suggested flood.max.unscoped 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'); + }); + + await testAsync('renderHopDepthSectionHtml includes the time-series trend when timeSeries is present', async () => { + const ctx = makeAnalyticsSandbox([]); + const html = ctx.window._analyticsRenderHopDepthSectionHtml({ + scopedHopDepth: [{ hops: 0, count: 10 }], + unscopedHopDepth: [{ hops: 0, count: 4 }], + timeSeries: [ + { t: '2026-07-23T06:00:00Z', scopedMedianHop: 0, unscopedMedianHop: 1 }, + { t: '2026-07-23T07:00:00Z', scopedMedianHop: 1, unscopedMedianHop: 2 }, + ], + }); + assert.ok(html.includes('Median Hop Depth Over Time'), 'should include the trend section heading'); + assert.ok(html.includes(' { + const ctx = makeAnalyticsSandbox([]); + const html = ctx.window._analyticsRenderHopDepthSectionHtml({ + scopedHopDepth: [{ hops: 0, count: 10 }], + unscopedHopDepth: [{ hops: 0, count: 4 }], + timeSeries: [ + { t: '2026-07-23T06:00:00Z', scopedMedianHop: 0, unscopedMedianHop: 1 }, + { t: '2026-07-23T07:00:00Z', scopedMedianHop: 1, unscopedMedianHop: 2 }, + ], + }); + const trendIdx = html.indexOf('Median Hop Depth Over Time'); + const statsIdx = html.indexOf('Scoped Median Hop'); + assert.ok(trendIdx > -1 && statsIdx > -1, 'both sections should be present'); + assert.ok(trendIdx < statsIdx, 'the time-series trend heading should appear before the stat cards'); + }); + + console.log('\n=== analytics.js: hopDepthTimeSeriesChartHtml ==='); + + await testAsync('fewer than 2 points renders the insufficient-data message, not a broken chart', async () => { + const ctx = makeAnalyticsSandbox([]); + assert.ok(ctx.window._analyticsHopDepthTimeSeriesChartHtml([]).includes('Insufficient data')); + assert.ok(ctx.window._analyticsHopDepthTimeSeriesChartHtml(null).includes('Insufficient data')); + assert.ok(ctx.window._analyticsHopDepthTimeSeriesChartHtml([{ t: '2026-07-23T06:00:00Z', scopedMedianHop: 0, unscopedMedianHop: 0 }]).includes('Insufficient data')); + }); + + await testAsync('2+ points render an svg with both series polylines', async () => { + const ctx = makeAnalyticsSandbox([]); + const html = ctx.window._analyticsHopDepthTimeSeriesChartHtml([ + { t: '2026-07-23T06:00:00Z', scopedMedianHop: 0, unscopedMedianHop: 2 }, + { t: '2026-07-23T07:00:00Z', scopedMedianHop: 1, unscopedMedianHop: 3 }, + { t: '2026-07-23T08:00:00Z', scopedMedianHop: 2, unscopedMedianHop: 4 }, + ]); + assert.ok(html.includes(' { + const ctx = makeAnalyticsSandbox([]); + // 3 points, middle one null for unscopedMedianHop -- the unscoped + // series must render as 0 polylines (each segment needs 2+ points to + // draw a line, and isolated single points on either side of the gap + // don't qualify), while scoped (fully populated) still renders 1. + const html = ctx.window._analyticsHopDepthTimeSeriesChartHtml([ + { t: '2026-07-23T06:00:00Z', scopedMedianHop: 0, unscopedMedianHop: 2 }, + { t: '2026-07-23T07:00:00Z', scopedMedianHop: 1, unscopedMedianHop: null }, + { t: '2026-07-23T08:00:00Z', scopedMedianHop: 2, unscopedMedianHop: 4 }, + ]); + const polylineCount = (html.match(/ { + const ctx = makeAnalyticsSandbox([]); + // 4 points: unscoped has data at [0,1], gap at [2], data at [3] -- + // should draw exactly one segment (points 0-1), not connect across + // the gap to point 3, and not draw anything for the isolated point 3. + const html = ctx.window._analyticsHopDepthTimeSeriesChartHtml([ + { t: 't0', scopedMedianHop: 0, unscopedMedianHop: 1 }, + { t: 't1', scopedMedianHop: 1, unscopedMedianHop: 2 }, + { t: 't2', scopedMedianHop: 2, unscopedMedianHop: null }, + { t: 't3', scopedMedianHop: 3, unscopedMedianHop: 5 }, + ]); + const polylineCount = (html.match(/ 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=== analytics.js: bridgeRepeaterPubkeySet ==='); + + await testAsync('builds a publicKey -> true set from BridgeRepeater entries', async () => { + const ctx = makeAnalyticsSandbox([]); + const set = ctx.window._analyticsBridgeRepeaterPubkeySet([ + { publicKey: 'pkBridge1', name: 'Bridge1', regions: ['eu', 'dk'], count: 12 }, + { publicKey: 'pkBridge2', name: 'Bridge2', regions: ['dk', 'se'], count: 3 }, + ]); + assert.strictEqual(set.pkBridge1, true); + assert.strictEqual(set.pkBridge2, true); + assert.strictEqual(set.pkNotABridge, undefined); + }); + + await testAsync('empty/null input returns an empty set, not a throw', async () => { + const ctx = makeAnalyticsSandbox([]); + [[], null, undefined].forEach((input) => { + const set = ctx.window._analyticsBridgeRepeaterPubkeySet(input); + assert.strictEqual(Object.keys(set).length, 0); + }); + }); + + console.log('\n=== analytics.js: Foreign Traffic tab bridge-repeater badge ==='); + + await testAsync('a confirmed bridge repeater gets a Bridge badge on its row', async () => { + const ctx = makeAnalyticsSandbox([ + { public_key: 'pkBridge', name: 'BridgeRepeater', role: 'repeater', unscoped_relay_count_24h: 20, relay_count_24h: 25 }, + { public_key: 'pkPlain', name: 'PlainRepeater', role: 'repeater', unscoped_relay_count_24h: 15, relay_count_24h: 20 }, + ], { + hopDepthResponse: { window: '24h', scopedHopDepth: [], unscopedHopDepth: [], unscopedByRepeater: [] }, + scopeStatsResponse: { + bridgeRepeaters: [ + { publicKey: 'pkBridge', name: 'BridgeRepeater', regions: ['eu', 'dk'], count: 7 }, + ], + }, + }); + const el = fakeEl(); + await ctx.window._analyticsRenderForeignTrafficTab(el); + const bridgeRowStart = el.innerHTML.indexOf('BridgeRepeater'); + const bridgeRowEnd = el.innerHTML.indexOf('', bridgeRowStart); + assert.ok(el.innerHTML.slice(bridgeRowStart, bridgeRowEnd).includes('Bridge'), 'the bridge repeater row should carry a Bridge badge'); + + const plainRowStart = el.innerHTML.indexOf('PlainRepeater'); + const plainRowEnd = el.innerHTML.indexOf('', plainRowStart); + const plainRowHtml = el.innerHTML.slice(plainRowStart, plainRowEnd); + assert.ok(!plainRowHtml.includes('badge'), 'a non-bridge repeater must not get the badge'); + }); + + await testAsync('no bridge data (fetch failure or empty list) renders with no badges, no throw', async () => { + const ctx = makeAnalyticsSandbox([ + { public_key: 'pkA', name: 'RepeaterA', role: 'repeater', unscoped_relay_count_24h: 5, relay_count_24h: 5 }, + ], { + hopDepthResponse: { window: '24h', scopedHopDepth: [], unscopedHopDepth: [], unscopedByRepeater: [] }, + scopeStatsResponse: {}, + }); + const el = fakeEl(); + await ctx.window._analyticsRenderForeignTrafficTab(el); + assert.ok(el.innerHTML.includes('RepeaterA')); + assert.ok(!el.innerHTML.includes('badge'), 'no bridge data should mean no badges anywhere'); + }); + + console.log('\n════════════════════════════════════════'); + console.log(` Hop-Depth Analytics UI: ${passed} passed, ${failed} failed`); + console.log('════════════════════════════════════════'); + if (failed > 0) process.exit(1); +})();