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 <noreply@anthropic.com>
This commit is contained in:
dborup
2026-07-23 08:48:11 +02:00
co-authored by Claude Sonnet 5
parent 902d0cac2f
commit 2d30e49247
8 changed files with 932 additions and 1 deletions
+190
View File
@@ -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
+201
View File
@@ -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)
}
}
+34
View File
@@ -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."},
},
},
}
}
+53
View File
@@ -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.
+44
View File
@@ -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 {
+143 -1
View File
@@ -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
'</div>' +
'<div id="scopes-cards" class="stats-grid" style="margin-bottom:16px"></div>' +
'<div id="scopes-highlights" style="margin-bottom:16px"></div>' +
'<div id="scopes-hop-depth" style="margin-bottom:16px"></div>' +
'<div id="scopes-hourly" style="margin-bottom:16px"></div>' +
'<div id="scopes-channel-messages" style="margin-bottom:16px"></div>' +
'<div id="scopes-channel-adoption" style="margin-bottom:16px"></div>' +
@@ -4938,6 +4942,22 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf
'</div>';
}
// 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
'</div>';
}
// 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 '<h4 style="margin:0 0 4px">Flood Containment: Scoped vs Unscoped Hop Depth</h4>' +
'<p class="text-muted" style="font-size:0.85em">No relay-hop data in this window.</p>';
}
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 += '<div style="display:flex;align-items:center;gap:6px;margin-bottom:3px">' +
'<div style="width:44px;font-size:11px;text-align:right" class="text-muted">' + hi + ' hop' + (hi === 1 ? '' : 's') + '</div>' +
'<div style="flex:1;display:flex;flex-direction:column;gap:1px">' +
'<div style="display:flex;align-items:center;gap:4px">' +
'<div style="height:7px;width:' + scW + '%;background:var(--accent);min-width:' + (sc ? '2px' : '0') + '"></div>' +
'<span style="font-size:10px" class="text-muted">' + sc.toLocaleString() + '</span>' +
'</div>' +
'<div style="display:flex;align-items:center;gap:4px">' +
'<div style="height:7px;width:' + unW + '%;background:var(--text-muted);min-width:' + (un ? '2px' : '0') + '"></div>' +
'<span style="font-size:10px" class="text-muted">' + un.toLocaleString() + '</span>' +
'</div>' +
'</div>' +
'</div>';
}
return '<h4 style="margin:0 0 4px">Flood Containment: Scoped vs Unscoped Hop Depth</h4>' +
'<p class="text-muted" style="margin:0 0 8px;font-size:0.85em">' +
'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 &mdash; a similar or higher scoped median means scope boundaries aren&rsquo;t actually limiting propagation.' +
'</p>' +
'<div class="stats-grid" style="margin-bottom:10px">' +
[
{ 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 '<div class="stat-card"><div class="stat-value">' + c.value + '</div>' +
'<div class="stat-label">' + c.label + '</div>' +
'<div class="stat-note text-muted" style="font-size:11px">' + c.note + '</div>' +
'</div>';
}).join('') +
'</div>' +
'<div style="display:flex;gap:10px;margin-bottom:4px;font-size:11px" class="text-muted">' +
'<span><span style="display:inline-block;width:8px;height:8px;background:var(--accent);margin-right:3px"></span>Scoped</span>' +
'<span><span style="display:inline-block;width:8px;height:8px;background:var(--text-muted);margin-right:3px"></span>Unscoped</span>' +
'</div>' +
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 '<p class="text-muted" style="font-size:0.85em">No repeater has relayed an unscoped flood packet in the last 24 hours.</p>';
@@ -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 '<tr>' +
'<td><a href="#/nodes/' + encodeURIComponent(n.public_key) + '">' + esc(n.name || n.public_key) + '</a></td>' +
'<td>' + esc(n.role) + '</td>' +
'<td>' + unscoped.toLocaleString() + '</td>' +
'<td>' + total.toLocaleString() + '</td>' +
'<td>' + pct(unscoped, total) + '</td>' +
'<td>' + (hd ? hd.minHops : '—') + '</td>' +
'<td>' + (hd ? (hd.medianHops % 1 === 0 ? hd.medianHops : hd.medianHops.toFixed(1)) : '—') + '</td>' +
'<td>' + (hd ? hd.maxHops : '—') + '</td>' +
'</tr>';
}).join('');
return '<table class="data-table analytics-table">' +
'<thead><tr><th>Repeater</th><th>Role</th><th>Unscoped Relays (24h)</th><th>Total Relays (24h)</th><th>% Unscoped</th></tr></thead>' +
'<thead><tr><th>Repeater</th><th>Role</th><th>Unscoped Relays (24h)</th><th>Total Relays (24h)</th><th>% Unscoped</th>' +
'<th title="0-based hop index within the packet\'s resolved relay path at this repeater — how far the packet had already traveled before this hop.">Min Hops</th>' +
'<th>Median Hops</th><th>Max Hops</th></tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>' + 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 =
'<h3 style="margin:0 0 4px">Foreign Traffic</h3>' +
'<p class="text-muted" style="margin:0 0 16px;font-size:0.85em">' +
+1
View File
@@ -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 "═══════════════════════════════════════"
+266
View File
@@ -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);
})();