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

Flood Containment: Scoped vs Unscoped Hop Depth

' + + '

No relay-hop data in this window.

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

Flood Containment: Scoped vs Unscoped Hop Depth

' + + '

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

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

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

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

Foreign Traffic

' + '

' + diff --git a/test-all.sh b/test-all.sh index b50ff270..bb19215c 100755 --- a/test-all.sh +++ b/test-all.sh @@ -71,6 +71,7 @@ node test-issue-1473-prefix-generator.js node test-issue-1770-mobile-row-clamp.js node test-issue-1849-trace-hashbytes.js node test-node-analytics-hop-chart.js +node test-analytics-hop-depth-ui.js echo "" echo "═══════════════════════════════════════" diff --git a/test-analytics-hop-depth-ui.js b/test-analytics-hop-depth-ui.js new file mode 100644 index 00000000..4df11e03 --- /dev/null +++ b/test-analytics-hop-depth-ui.js @@ -0,0 +1,266 @@ +/** + * Tests for the two "lad os lave dem begge" extensions built on top of + * /api/analytics/hop-depth (GetHopDepthAnalytics, cmd/server/db.go): + * + * 1. Scopes tab Overview: renderHopDepthSectionHtml / hopDepthBucketStats + * — the scoped-vs-unscoped hop-depth comparison (does region scoping + * actually contain flood propagation to fewer hops than unscoped + * traffic). + * 2. Foreign Traffic tab: hopDepthLookupByPubkey — the per-repeater + * min/median/max hop-depth enrichment joined onto the existing + * "Repeaters Relaying Unscoped Traffic" table by public key. + * + * Pure-function unit tests plus one DOM-rendering integration test for + * the Foreign Traffic table join, following the same vm-sandbox pattern + * as test-analytics-foreign-traffic-tab.js / test-node-analytics-hop-chart.js. + */ +'use strict'; + +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +let passed = 0, failed = 0; +async function testAsync(name, fn) { + try { + await fn(); + passed++; + console.log(` ✅ ${name}`); + } catch (e) { + failed++; + console.log(` ❌ ${name}: ${e.message}`); + } +} + +function makeSandbox() { + const ctx = { + window: { addEventListener: () => {}, dispatchEvent: () => {} }, + document: { + readyState: 'complete', + createElement: () => ({ id: '', textContent: '', innerHTML: '' }), + head: { appendChild: () => {} }, + getElementById: () => null, + addEventListener: () => {}, + querySelectorAll: () => [], + querySelector: () => null, + }, + console, Date, Infinity, Math, Array, Object, String, Number, JSON, RegExp, + Error, TypeError, parseInt, parseFloat, isNaN, isFinite, + encodeURIComponent, decodeURIComponent, + setTimeout: () => {}, clearTimeout: () => {}, + fetch: () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }), + performance: { now: () => Date.now() }, + localStorage: (() => { const s = {}; return { getItem: k => s[k] || null, setItem: (k, v) => { s[k] = String(v); }, removeItem: k => { delete s[k]; } }; })(), + location: { hash: '' }, + getHashParams: function() { return new URLSearchParams((ctx.location.hash.split('?')[1] || '')); }, + CustomEvent: class CustomEvent {}, + Map, Promise, URLSearchParams, + addEventListener: () => {}, + dispatchEvent: () => {}, + requestAnimationFrame: (cb) => setTimeout(cb, 0), + setInterval: () => 1, + clearInterval: () => {}, + }; + vm.createContext(ctx); + return ctx; +} + +function loadInCtx(ctx, file) { + if (!ctx.__payloadLabelsLoaded && file !== 'public/payload-labels.js') { + ctx.__payloadLabelsLoaded = true; + vm.runInContext(fs.readFileSync('public/payload-labels.js', 'utf8'), ctx); + } + vm.runInContext(fs.readFileSync(file, 'utf8'), ctx); + for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; +} + +const GEO_BOX = { latMin: 53, latMax: 59, lonMin: 6, lonMax: 15 }; + +function makeAnalyticsSandbox(nodesFixture, opts) { + opts = opts || {}; + const ctx = makeSandbox(); + ctx.getComputedStyle = () => ({ getPropertyValue: () => '' }); + ctx.registerPage = () => {}; + ctx.timeAgo = (iso) => iso ? 'x ago' : '—'; + ctx.RegionFilter = { init: () => {}, onChange: () => {}, regionQueryString: () => '' }; + ctx.onWS = () => {}; + ctx.offWS = () => {}; + ctx.connectWS = () => {}; + ctx.invalidateApiCache = () => {}; + ctx.makeColumnsResizable = () => {}; + ctx.initTabBar = () => {}; + ctx.IATA_COORDS_GEO = {}; + // fetch is what app.js's real api() ultimately calls -- routes by URL so + // the hop-depth endpoint can be stubbed independently of every other + // in-flight api() call (e.g. roles.js's own config fetch on load). + if (opts.hopDepthResponse !== undefined) { + ctx.fetch = (url) => { + if (String(url).indexOf('/analytics/hop-depth') !== -1) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(opts.hopDepthResponse) }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + }; + } + loadInCtx(ctx, 'public/roles.js'); + loadInCtx(ctx, 'public/app.js'); + ctx.fetchAllNodes = async () => ({ nodes: nodesFixture || [] }); + ctx.window.MC_GEO_FILTER = GEO_BOX; + try { loadInCtx(ctx, 'public/analytics.js'); } catch (e) { + for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; + } + return ctx; +} + +function fakeEl() { + return { innerHTML: '' }; +} + +(async () => { + console.log('\n=== analytics.js: hopDepthBucketStats ==='); + + await testAsync('empty/missing buckets return total 0 and null median', async () => { + const ctx = makeAnalyticsSandbox([]); + [[], null, undefined].forEach((input) => { + const stats = ctx.window._analyticsHopDepthBucketStats(input); + assert.strictEqual(stats.total, 0); + assert.strictEqual(stats.median, null); + }); + }); + + await testAsync('single bucket -> median is that bucket\'s hop value', async () => { + const ctx = makeAnalyticsSandbox([]); + const stats = ctx.window._analyticsHopDepthBucketStats([{ hops: 3, count: 7 }]); + assert.strictEqual(stats.total, 7); + assert.strictEqual(stats.median, 3); + }); + + await testAsync('odd total -> median is the middle bucket by cumulative count', async () => { + const ctx = makeAnalyticsSandbox([]); + // hops 0..4 counts 1 -> total 5, cumulative >= 2.5 first at hops=2. + const stats = ctx.window._analyticsHopDepthBucketStats([ + { hops: 0, count: 1 }, { hops: 1, count: 1 }, { hops: 2, count: 1 }, + { hops: 3, count: 1 }, { hops: 4, count: 1 }, + ]); + assert.strictEqual(stats.total, 5); + assert.strictEqual(stats.median, 2); + }); + + await testAsync('median is unaffected by input bucket order (sorts internally)', async () => { + const ctx = makeAnalyticsSandbox([]); + const stats = ctx.window._analyticsHopDepthBucketStats([ + { hops: 4, count: 1 }, { hops: 0, count: 1 }, { hops: 2, count: 1 }, + { hops: 1, count: 1 }, { hops: 3, count: 1 }, + ]); + assert.strictEqual(stats.median, 2); + }); + + console.log('\n=== analytics.js: renderHopDepthSectionHtml ==='); + + await testAsync('null/empty hopData renders nothing', async () => { + const ctx = makeAnalyticsSandbox([]); + assert.strictEqual(ctx.window._analyticsRenderHopDepthSectionHtml(null), ''); + assert.strictEqual(ctx.window._analyticsRenderHopDepthSectionHtml({}), ''); + }); + + await testAsync('present-but-empty buckets renders the no-data message', async () => { + const ctx = makeAnalyticsSandbox([]); + const html = ctx.window._analyticsRenderHopDepthSectionHtml({ scopedHopDepth: [], unscopedHopDepth: [] }); + assert.ok(html.includes('No relay-hop data in this window')); + }); + + await testAsync('renders scoped/unscoped median stat cards and per-hop bar rows', async () => { + const ctx = makeAnalyticsSandbox([]); + const html = ctx.window._analyticsRenderHopDepthSectionHtml({ + scopedHopDepth: [{ hops: 0, count: 10 }], + unscopedHopDepth: [{ hops: 0, count: 4 }, { hops: 1, count: 4 }, { hops: 2, count: 2 }], + }); + assert.ok(html.includes('Scoped Median Hop'), 'should show a scoped median stat card'); + assert.ok(html.includes('Unscoped Median Hop'), 'should show an unscoped median stat card'); + assert.ok(html.includes('10 samples'), 'scoped sample size should be 10'); + assert.ok(html.includes('0 hops'), 'should have a hop=0 row'); + assert.ok(html.includes('2 hops'), 'should have a hop=2 row'); + }); + + console.log('\n=== analytics.js: hopDepthLookupByPubkey ==='); + + await testAsync('builds a publicKey -> entry map', async () => { + const ctx = makeAnalyticsSandbox([]); + const entries = [ + { publicKey: 'pkA', name: 'A', count: 5, minHops: 1, medianHops: 2, maxHops: 4 }, + { publicKey: 'pkB', name: 'B', count: 2, minHops: 0, medianHops: 0.5, maxHops: 1 }, + ]; + const map = ctx.window._analyticsHopDepthLookupByPubkey(entries); + assert.strictEqual(map.pkA.count, 5); + assert.strictEqual(map.pkB.maxHops, 1); + }); + + await testAsync('empty/null input returns an empty map, not a throw', async () => { + const ctx = makeAnalyticsSandbox([]); + [[], null, undefined].forEach((input) => { + const map = ctx.window._analyticsHopDepthLookupByPubkey(input); + assert.strictEqual(Object.keys(map).length, 0); + }); + }); + + console.log('\n=== analytics.js: Foreign Traffic tab hop-depth enrichment ==='); + + await testAsync('relay table shows min/median/max hop columns joined by public key', async () => { + const ctx = makeAnalyticsSandbox([ + { public_key: 'pkA', name: 'RepeaterA', role: 'repeater', unscoped_relay_count_24h: 10, relay_count_24h: 20 }, + ], { + hopDepthResponse: { + window: '24h', + scopedHopDepth: [], + unscopedHopDepth: [], + unscopedByRepeater: [ + { publicKey: 'pkA', name: 'RepeaterA', count: 10, minHops: 1, medianHops: 2.5, maxHops: 6 }, + ], + }, + }); + const el = fakeEl(); + await ctx.window._analyticsRenderForeignTrafficTab(el); + assert.ok(el.innerHTML.includes('Min Hops'), 'table header should include Min Hops'); + assert.ok(el.innerHTML.includes('Median Hops'), 'table header should include Median Hops'); + assert.ok(el.innerHTML.includes('Max Hops'), 'table header should include Max Hops'); + // Row cells: min=1, median=2.5, max=6 for pkA. + const rowStart = el.innerHTML.indexOf('RepeaterA'); + const rowSection = el.innerHTML.slice(rowStart, rowStart + 500); + assert.ok(rowSection.includes('>1<'), 'min hops cell should show 1'); + assert.ok(rowSection.includes('>2.5<'), 'median hops cell should show 2.5'); + assert.ok(rowSection.includes('>6<'), 'max hops cell should show 6'); + }); + + await testAsync('a repeater with no hop-depth entry shows placeholders instead of throwing', async () => { + const ctx = makeAnalyticsSandbox([ + { public_key: 'pkNoHops', name: 'QuietRepeater', role: 'repeater', unscoped_relay_count_24h: 3, relay_count_24h: 10 }, + ], { + hopDepthResponse: { window: '24h', scopedHopDepth: [], unscopedHopDepth: [], unscopedByRepeater: [] }, + }); + const el = fakeEl(); + await ctx.window._analyticsRenderForeignTrafficTab(el); + assert.ok(el.innerHTML.includes('QuietRepeater')); + const rowStart = el.innerHTML.indexOf('QuietRepeater'); + const rowSection = el.innerHTML.slice(rowStart, rowStart + 500); + assert.ok(rowSection.includes('>—<'), 'missing hop-depth data should render as an em dash placeholder'); + }); + + await testAsync('a failing hop-depth fetch degrades to placeholders, not a broken tab', async () => { + const ctx = makeAnalyticsSandbox([ + { public_key: 'pkA', name: 'RepeaterA', role: 'repeater', unscoped_relay_count_24h: 5, relay_count_24h: 5 }, + ]); + // No hopDepthResponse opt given -> default sandbox fetch mock returns + // { ok:true, json: () => ({}) } for every path, including hop-depth, + // so unscopedByRepeater is simply undefined/absent -- exercises the + // "no data at all" branch through the same code path a real network + // error's catch(() => null) would also produce. + const el = fakeEl(); + await ctx.window._analyticsRenderForeignTrafficTab(el); + assert.ok(el.innerHTML.includes('RepeaterA'), 'tab should still render the repeater row'); + assert.ok(el.innerHTML.includes('Min Hops'), 'table structure should still include the hop columns'); + }); + + console.log('\n════════════════════════════════════════'); + console.log(` Hop-Depth Analytics UI: ${passed} passed, ${failed} failed`); + console.log('════════════════════════════════════════'); + if (failed > 0) process.exit(1); +})(); From 6a215002c1e05ba683d9189107f5b939a0e8be4f Mon Sep 17 00:00:00 2001 From: dborup Date: Thu, 23 Jul 2026 09:10:27 +0200 Subject: [PATCH 2/6] refactor: move Flood Containment hop-depth section to its own Scopes sub-tab Was crowding the Overview panel; gets its own "Hop Depth" sub-tab next to Overview/Regions/Hygiene, with its own 1h/24h/7d window picker wired to the same selectedWindow/load() as the rest of the tab. Co-Authored-By: Claude Sonnet 5 --- public/analytics.js | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/public/analytics.js b/public/analytics.js index 2bfeecbc..357e6951 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -4494,16 +4494,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'; @@ -4537,6 +4539,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) { @@ -4551,7 +4554,6 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf '
' + '
' + '
' + - '
' + '
' + '
' + '
' + @@ -4565,6 +4567,14 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf '
' + '' + '' + + '
' + + '
' + + ['1h', '24h', '7d'].map(function(v) { + return ''; + }).join('') + + '
' + + '
' + + '
' + '
' + '
' + '
' + @@ -4586,7 +4596,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'; }); From 6eb094ba0070595430e3310f675ad7d8e982fdb4 Mon Sep 17 00:00:00 2001 From: dborup Date: Thu, 23 Jul 2026 09:24:52 +0200 Subject: [PATCH 3/6] feat: suggest a flood.max.unscoped value from unscoped hop-depth P95 Adds a third stat card to the Scopes > Hop Depth section: the P95 of the unscoped hop-depth histogram, framed as a concrete flood.max.unscoped starting point instead of leaving the operator to eyeball the bar chart. Co-Authored-By: Claude Sonnet 5 --- public/analytics.js | 39 ++++++++++++++++++++++++++++------ test-analytics-hop-depth-ui.js | 32 ++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/public/analytics.js b/public/analytics.js index 357e6951..8a0cd607 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -2720,6 +2720,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf window._analyticsComputeNodesWithoutScope = computeNodesWithoutScope; window._analyticsComputeRepeatersNeverRelayingScope = computeRepeatersNeverRelayingScope; window._analyticsHopDepthBucketStats = hopDepthBucketStats; + window._analyticsHopDepthPercentile = hopDepthPercentile; window._analyticsRenderHopDepthSectionHtml = renderHopDepthSectionHtml; window._analyticsHopDepthLookupByPubkey = hopDepthLookupByPubkey; } @@ -5430,12 +5431,36 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf 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. + // 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; + } + + // Renders the scoped-vs-unscoped hop-depth comparison (Scopes tab > Hop + // Depth sub-tab): median-hop stat cards for both series, a P95-derived + // "suggested flood.max.unscoped" callout, 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 — and to turn + // that into a concrete number an operator can plug into their MeshCore + // firmware config instead of eyeballing the chart. function renderHopDepthSectionHtml(hopData) { if (!hopData || (!hopData.scopedHopDepth && !hopData.unscopedHopDepth)) { return ''; @@ -5444,6 +5469,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf 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.

'; @@ -5488,6 +5514,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf [ { 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 + '
' + diff --git a/test-analytics-hop-depth-ui.js b/test-analytics-hop-depth-ui.js index 4df11e03..c753d0c1 100644 --- a/test-analytics-hop-depth-ui.js +++ b/test-analytics-hop-depth-ui.js @@ -154,6 +154,37 @@ function fakeEl() { 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 () => { @@ -176,6 +207,7 @@ function fakeEl() { }); 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'); From 6c9de125425672b0188c4b89ec4dd41ee39e1fef Mon Sep 17 00:00:00 2001 From: dborup Date: Thu, 23 Jul 2026 09:39:28 +0200 Subject: [PATCH 4/6] feat: flag bridge repeaters on the Foreign Traffic unscoped-relay table A bridge repeater (confirmed relaying for 2+ regions, ScopeStatsResponse. bridgeRepeaters) is supposed to see traffic that already traveled far from another region's scope -- a high hop-depth reading there is expected, not a containment leak. Adds a small "Bridge" badge next to those repeaters' names so the min/median/max hop columns added earlier can be read in that context, and a plain regional repeater with the same pattern stands out as the one actually worth investigating. Co-Authored-By: Claude Sonnet 5 --- public/analytics.js | 27 ++++++++++++- test-analytics-hop-depth-ui.js | 70 ++++++++++++++++++++++++++++++++-- 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/public/analytics.js b/public/analytics.js index 8a0cd607..f97db3ed 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -2723,6 +2723,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf window._analyticsHopDepthPercentile = hopDepthPercentile; window._analyticsRenderHopDepthSectionHtml = renderHopDepthSectionHtml; window._analyticsHopDepthLookupByPubkey = hopDepthLookupByPubkey; + window._analyticsBridgeRepeaterPubkeySet = bridgeRepeaterPubkeySet; } // ─── Neighbor Graph Tab ───────────────────────────────────────────────────── @@ -5539,6 +5540,18 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf 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; }); @@ -5674,6 +5687,9 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf // 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.

'; @@ -5683,8 +5699,11 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf 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() + '' + @@ -5735,6 +5754,10 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf // 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; @@ -5762,6 +5785,8 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf 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-analytics-hop-depth-ui.js b/test-analytics-hop-depth-ui.js index c753d0c1..9d1230e5 100644 --- a/test-analytics-hop-depth-ui.js +++ b/test-analytics-hop-depth-ui.js @@ -91,13 +91,17 @@ function makeAnalyticsSandbox(nodesFixture, opts) { 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) { + // 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({}) }); }; } @@ -291,6 +295,66 @@ function fakeEl() { 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('════════════════════════════════════════'); From e23e632d54b103321892edc697b964e59ff2a6d9 Mon Sep 17 00:00:00 2001 From: dborup Date: Thu, 23 Jul 2026 09:50:51 +0200 Subject: [PATCH 5/6] feat: scoped-vs-unscoped median hop time-series trend on Hop Depth tab GetHopDepthAnalytics now buckets its scoped/unscoped hop-index tallies by time (same 5min/1h/6h bucketing as GetScopeStats' TimeSeries) and computes a per-bucket median for each series, exposed as a new TimeSeries field. Median is a *int, nil when a bucket had no traffic of that kind -- 0 is a valid median hop, so absence has to stay distinguishable from zero. Frontend renders it as a two-line SVG trend chart on the Scopes > Hop Depth sub-tab (same visual language as Overview's scoped/unscoped time-series), answering "is containment getting better or worse over the window" rather than just a single window-wide snapshot. Each series' polyline is built from contiguous non-null segments so a gap renders as a visible break instead of silently interpolating through missing data. Co-Authored-By: Claude Sonnet 5 --- cmd/server/db.go | 81 ++++++++++++++++++++++-- cmd/server/hop_depth_analytics_test.go | 65 ++++++++++++++++++- cmd/server/openapi.go | 12 +++- cmd/server/types.go | 19 ++++++ public/analytics.js | 87 +++++++++++++++++++++++--- test-analytics-hop-depth-ui.js | 65 +++++++++++++++++++ 6 files changed, 315 insertions(+), 14 deletions(-) diff --git a/cmd/server/db.go b/cmd/server/db.go index 4214c031..91f01542 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -3184,18 +3184,25 @@ func (db *DB) GetScopeStats(window string) (*ScopeStatsResponse, error) { // "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 + 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) @@ -3208,13 +3215,14 @@ func (db *DB) GetHopDepthAnalytics(window string) (*HopDepthAnalyticsResponse, e 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 string - if err := rows.Scan(&txID, &routeType, &payloadType, &rpJSON); err != nil { + var rpJSON, bucket string + if err := rows.Scan(&txID, &routeType, &payloadType, &rpJSON, &bucket); err != nil { continue } rp := unmarshalResolvedPath(rpJSON) @@ -3223,7 +3231,7 @@ func (db *DB) GetHopDepthAnalytics(window string) (*HopDepthAnalyticsResponse, e } cur, ok := byTx[txID] if !ok { - byTx[txID] = &txInfo{routeType: routeType, payloadType: payloadType, bestPath: rp} + byTx[txID] = &txInfo{routeType: routeType, payloadType: payloadType, bestPath: rp, bucket: bucket} continue } if len(rp) > len(cur.bestPath) { @@ -3237,6 +3245,12 @@ func (db *DB) GetHopDepthAnalytics(window string) (*HopDepthAnalyticsResponse, e 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 { @@ -3254,8 +3268,16 @@ func (db *DB) GetHopDepthAnalytics(window string) (*HopDepthAnalyticsResponse, e 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) @@ -3272,6 +3294,56 @@ func (db *DB) GetHopDepthAnalytics(window string) (*HopDepthAnalyticsResponse, e 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 @@ -3313,6 +3385,7 @@ func (db *DB) GetHopDepthAnalytics(window string) (*HopDepthAnalyticsResponse, e ScopedHopDepth: toSortedBuckets(scopedBuckets), UnscopedHopDepth: toSortedBuckets(unscopedBuckets), UnscopedByRepeater: unscopedByRepeater, + TimeSeries: timeSeries, }, nil } diff --git a/cmd/server/hop_depth_analytics_test.go b/cmd/server/hop_depth_analytics_test.go index 6512961e..a0540c10 100644 --- a/cmd/server/hop_depth_analytics_test.go +++ b/cmd/server/hop_depth_analytics_test.go @@ -154,11 +154,74 @@ func TestGetHopDepthAnalytics_EmptyWindow(t *testing.T) { if err != nil { t.Fatalf("GetHopDepthAnalytics: %v", err) } - if len(resp.ScopedHopDepth) != 0 || len(resp.UnscopedHopDepth) != 0 || len(resp.UnscopedByRepeater) != 0 { + 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) { diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 5ea9cda1..b2650d36 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -113,7 +113,7 @@ 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", + "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"}, }, @@ -312,6 +312,15 @@ func componentSchemas() map[string]interface{} { "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{}{ @@ -319,6 +328,7 @@ func componentSchemas() map[string]interface{} { "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/types.go b/cmd/server/types.go index f79cb220..48f5948f 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -917,11 +917,30 @@ type RepeaterUnscopedHopDepth struct { // 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 ──────────────────────────────────────────────────────────── diff --git a/public/analytics.js b/public/analytics.js index f97db3ed..3563d2a4 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -2722,6 +2722,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf window._analyticsHopDepthBucketStats = hopDepthBucketStats; window._analyticsHopDepthPercentile = hopDepthPercentile; window._analyticsRenderHopDepthSectionHtml = renderHopDepthSectionHtml; + window._analyticsHopDepthTimeSeriesChartHtml = hopDepthTimeSeriesChartHtml; window._analyticsHopDepthLookupByPubkey = hopDepthLookupByPubkey; window._analyticsBridgeRepeaterPubkeySet = bridgeRepeaterPubkeySet; } @@ -5453,15 +5454,82 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf 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): median-hop stat cards for both series, a P95-derived - // "suggested flood.max.unscoped" callout, 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 — and to turn - // that into a concrete number an operator can plug into their MeshCore - // firmware config instead of eyeballing the chart. + // "suggested flood.max.unscoped" callout, a grouped bar chart across + // hop values 0..max normalized to the taller of the two series at each + // hop, and a median-hop-over-time trend chart. 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 ''; @@ -5527,7 +5595,10 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf 'Scoped' + 'Unscoped' + '
' + - rows; + rows + + '

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); } // publicKey -> RepeaterUnscopedHopDepth lookup from diff --git a/test-analytics-hop-depth-ui.js b/test-analytics-hop-depth-ui.js index 9d1230e5..c577b5f1 100644 --- a/test-analytics-hop-depth-ui.js +++ b/test-analytics-hop-depth-ui.js @@ -217,6 +217,71 @@ function fakeEl() { 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([]); + 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 () => { From 1acaabe10a8427471c165c72897a9c12ec40d148 Mon Sep 17 00:00:00 2001 From: dborup Date: Thu, 23 Jul 2026 10:05:21 +0200 Subject: [PATCH 6/6] refactor: move median-hop time-series trend to the top of the Hop Depth tab Was rendered last, below the stat cards and bar chart -- moved above them since it's the most at-a-glance signal (is containment trending better or worse) and shouldn't require scrolling past a static snapshot to see. Co-Authored-By: Claude Sonnet 5 --- public/analytics.js | 31 +++++++++++++++++-------------- test-analytics-hop-depth-ui.js | 16 ++++++++++++++++ 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/public/analytics.js b/public/analytics.js index 3563d2a4..e6154c11 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -5521,15 +5521,18 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf } // Renders the scoped-vs-unscoped hop-depth comparison (Scopes tab > Hop - // Depth sub-tab): median-hop stat cards for both series, a P95-derived - // "suggested flood.max.unscoped" callout, a grouped bar chart across - // hop values 0..max normalized to the taller of the two series at each - // hop, and a median-hop-over-time trend chart. 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. + // 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 ''; @@ -5579,7 +5582,10 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf '

' + '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' }, @@ -5595,10 +5601,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf 'Scoped' + 'Unscoped' + '
' + - rows + - '

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); + rows; } // publicKey -> RepeaterUnscopedHopDepth lookup from diff --git a/test-analytics-hop-depth-ui.js b/test-analytics-hop-depth-ui.js index c577b5f1..f3a2ebf7 100644 --- a/test-analytics-hop-depth-ui.js +++ b/test-analytics-hop-depth-ui.js @@ -231,6 +231,22 @@ function fakeEl() { 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 () => {