mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-27 04:47:57 +00:00
feat: network-wide hop-depth analytics (Scopes + Foreign Traffic tabs)
Extends the #1812 per-node relay hop-count work with a network-wide view: GET /api/analytics/hop-depth answers (1) whether scoped traffic actually travels fewer hops than unscoped before hitting a repeater's flood.max cap (Scopes tab Overview: new "Flood Containment" comparison), and (2) which repeaters relay unscoped traffic that already traveled far vs merely locally (Foreign Traffic tab: min/median/max hop columns joined onto the existing unscoped-relay table by public key). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
902d0cac2f
commit
2d30e49247
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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."},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -880,6 +880,50 @@ type NodeHopAnalyticsResponse struct {
|
||||
Packets []HopAnalyticsPacket `json:"packets"`
|
||||
}
|
||||
|
||||
// HopDepthBucket is a (hop count -> how many relay-hop instances saw that
|
||||
// count) tally, network-wide.
|
||||
type HopDepthBucket struct {
|
||||
Hops int `json:"hops"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// RepeaterUnscopedHopDepth is one repeater/room's hop-count profile across
|
||||
// the unscoped (FLOOD, non-advert) traffic it has relayed — the flip side
|
||||
// of unscoped_relay_count_24h's raw volume: whether that volume is mostly
|
||||
// FRESH/local unscoped traffic (low hops) or traffic that already
|
||||
// propagated far, unscoped, before reaching this repeater (high hops) --
|
||||
// the latter is the stronger signal of an actual containment problem.
|
||||
type RepeaterUnscopedHopDepth struct {
|
||||
PublicKey string `json:"publicKey"`
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
MinHops int `json:"minHops"`
|
||||
MedianHops float64 `json:"medianHops"`
|
||||
MaxHops int `json:"maxHops"`
|
||||
}
|
||||
|
||||
// HopDepthAnalyticsResponse answers two related "is flood containment
|
||||
// actually working" questions in one pass over resolved relay paths
|
||||
// (both need the same expensive walk, so they're computed together):
|
||||
//
|
||||
// 1. ScopedHopDepth/UnscopedHopDepth: network-wide, does SCOPED
|
||||
// (TRANSPORT_FLOOD/TRANSPORT_DIRECT) traffic actually travel fewer
|
||||
// hops than UNSCOPED (FLOOD/DIRECT) traffic? hashRegions exists
|
||||
// specifically to contain flood propagation to a relevant area — if
|
||||
// scoped hop depth isn't meaningfully lower, that's evidence region
|
||||
// boundaries are too loose or flood_max isn't tuned differently per
|
||||
// scope, not just an adoption-percentage number.
|
||||
// 2. UnscopedByRepeater: per-repeater hop-depth profile of the unscoped
|
||||
// traffic it relays, enriching the Foreign Traffic tab's "Repeaters
|
||||
// Relaying Unscoped Traffic" (which today only ranks by volume) with
|
||||
// whether that volume is nearby noise or far-propagated pollution.
|
||||
type HopDepthAnalyticsResponse struct {
|
||||
Window string `json:"window"`
|
||||
ScopedHopDepth []HopDepthBucket `json:"scopedHopDepth"`
|
||||
UnscopedHopDepth []HopDepthBucket `json:"unscopedHopDepth"`
|
||||
UnscopedByRepeater []RepeaterUnscopedHopDepth `json:"unscopedByRepeater"`
|
||||
}
|
||||
|
||||
// ─── Analytics — RF ────────────────────────────────────────────────────────────
|
||||
|
||||
type PayloadTypeSignal struct {
|
||||
|
||||
+143
-1
@@ -2719,6 +2719,9 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf
|
||||
window._analyticsStopWardrivingRefresh = _stopWardrivingRefresh;
|
||||
window._analyticsComputeNodesWithoutScope = computeNodesWithoutScope;
|
||||
window._analyticsComputeRepeatersNeverRelayingScope = computeRepeatersNeverRelayingScope;
|
||||
window._analyticsHopDepthBucketStats = hopDepthBucketStats;
|
||||
window._analyticsRenderHopDepthSectionHtml = renderHopDepthSectionHtml;
|
||||
window._analyticsHopDepthLookupByPubkey = hopDepthLookupByPubkey;
|
||||
}
|
||||
|
||||
// ─── Neighbor Graph Tab ─────────────────────────────────────────────────────
|
||||
@@ -4548,6 +4551,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf
|
||||
'</div>' +
|
||||
'<div id="scopes-cards" class="stats-grid" style="margin-bottom:16px"></div>' +
|
||||
'<div id="scopes-highlights" style="margin-bottom:16px"></div>' +
|
||||
'<div id="scopes-hop-depth" style="margin-bottom:16px"></div>' +
|
||||
'<div id="scopes-hourly" style="margin-bottom:16px"></div>' +
|
||||
'<div id="scopes-channel-messages" style="margin-bottom:16px"></div>' +
|
||||
'<div id="scopes-channel-adoption" style="margin-bottom:16px"></div>' +
|
||||
@@ -4938,6 +4942,22 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// Flood-containment check: is scoping actually working, i.e. does
|
||||
// scoped traffic travel fewer relay hops than unscoped traffic before
|
||||
// a repeater's flood.max cap kicks in? Same 0-based per-node hop
|
||||
// index as /api/nodes/{pubkey}/hop_analytics (issue #1812) — NOT the
|
||||
// unrelated observer-distance hopDistribution field.
|
||||
var hopDepthEl = document.getElementById('scopes-hop-depth');
|
||||
var hopDepthData = null;
|
||||
try {
|
||||
hopDepthData = await api('/analytics/hop-depth?window=' + encodeURIComponent(w), { ttl: 30000 });
|
||||
} catch (e) {
|
||||
hopDepthData = null;
|
||||
}
|
||||
if (hopDepthEl) {
|
||||
hopDepthEl.innerHTML = renderHopDepthSectionHtml(hopDepthData);
|
||||
}
|
||||
|
||||
// Channel-messages-only breakdown: same scoped/unscoped/unknown
|
||||
// question as the cards above, but restricted to payload_type=5
|
||||
// (channel chat) — most channel traffic is plain FLOOD, so this can
|
||||
@@ -5386,6 +5406,102 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// Weighted median + total from a HopDepthBucket[] (see GetHopDepthAnalytics).
|
||||
function hopDepthBucketStats(buckets) {
|
||||
var total = 0;
|
||||
(buckets || []).forEach(function(b) { total += b.count; });
|
||||
if (!total) return { total: 0, median: null };
|
||||
var sorted = (buckets || []).slice().sort(function(a, b) { return a.hops - b.hops; });
|
||||
var cum = 0, median = null;
|
||||
sorted.forEach(function(b) {
|
||||
cum += b.count;
|
||||
if (median === null && cum >= total / 2) median = b.hops;
|
||||
});
|
||||
return { total: total, median: median };
|
||||
}
|
||||
|
||||
// Renders the scoped-vs-unscoped hop-depth comparison (Scopes tab
|
||||
// Overview): two stat cards (median hop, sample size) plus a grouped bar
|
||||
// chart across hop values 0..max, normalized to the taller of the two
|
||||
// series at each hop. The whole point is to answer "is region scoping
|
||||
// actually containing flood propagation" — scoped traffic clustering at
|
||||
// a lower median hop than unscoped is the expected/healthy shape.
|
||||
function renderHopDepthSectionHtml(hopData) {
|
||||
if (!hopData || (!hopData.scopedHopDepth && !hopData.unscopedHopDepth)) {
|
||||
return '';
|
||||
}
|
||||
var scoped = hopData.scopedHopDepth || [];
|
||||
var unscoped = hopData.unscopedHopDepth || [];
|
||||
var scopedStats = hopDepthBucketStats(scoped);
|
||||
var unscopedStats = hopDepthBucketStats(unscoped);
|
||||
if (!scopedStats.total && !unscopedStats.total) {
|
||||
return '<h4 style="margin:0 0 4px">Flood Containment: Scoped vs Unscoped Hop Depth</h4>' +
|
||||
'<p class="text-muted" style="font-size:0.85em">No relay-hop data in this window.</p>';
|
||||
}
|
||||
|
||||
var maxHops = 0;
|
||||
scoped.concat(unscoped).forEach(function(b) { if (b.hops > maxHops) maxHops = b.hops; });
|
||||
var scopedByHop = {}, unscopedByHop = {};
|
||||
scoped.forEach(function(b) { scopedByHop[b.hops] = b.count; });
|
||||
unscoped.forEach(function(b) { unscopedByHop[b.hops] = b.count; });
|
||||
var maxCount = 1;
|
||||
for (var h = 0; h <= maxHops; h++) {
|
||||
maxCount = Math.max(maxCount, scopedByHop[h] || 0, unscopedByHop[h] || 0);
|
||||
}
|
||||
|
||||
var rows = '';
|
||||
for (var hi = 0; hi <= maxHops; hi++) {
|
||||
var sc = scopedByHop[hi] || 0, un = unscopedByHop[hi] || 0;
|
||||
if (!sc && !un) continue;
|
||||
var scW = (sc / maxCount * 100).toFixed(1);
|
||||
var unW = (un / maxCount * 100).toFixed(1);
|
||||
rows += '<div style="display:flex;align-items:center;gap:6px;margin-bottom:3px">' +
|
||||
'<div style="width:44px;font-size:11px;text-align:right" class="text-muted">' + hi + ' hop' + (hi === 1 ? '' : 's') + '</div>' +
|
||||
'<div style="flex:1;display:flex;flex-direction:column;gap:1px">' +
|
||||
'<div style="display:flex;align-items:center;gap:4px">' +
|
||||
'<div style="height:7px;width:' + scW + '%;background:var(--accent);min-width:' + (sc ? '2px' : '0') + '"></div>' +
|
||||
'<span style="font-size:10px" class="text-muted">' + sc.toLocaleString() + '</span>' +
|
||||
'</div>' +
|
||||
'<div style="display:flex;align-items:center;gap:4px">' +
|
||||
'<div style="height:7px;width:' + unW + '%;background:var(--text-muted);min-width:' + (un ? '2px' : '0') + '"></div>' +
|
||||
'<span style="font-size:10px" class="text-muted">' + un.toLocaleString() + '</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
return '<h4 style="margin:0 0 4px">Flood Containment: Scoped vs Unscoped Hop Depth</h4>' +
|
||||
'<p class="text-muted" style="margin:0 0 8px;font-size:0.85em">' +
|
||||
'How many relay hops packets travel before reaching a repeater, split by whether they carried a region scope (TRANSPORT_FLOOD/TRANSPORT_DIRECT) or not (plain FLOOD). If scoping is containing traffic as intended, Scoped should cluster at fewer hops than Unscoped — a similar or higher scoped median means scope boundaries aren’t actually limiting propagation.' +
|
||||
'</p>' +
|
||||
'<div class="stats-grid" style="margin-bottom:10px">' +
|
||||
[
|
||||
{ label: 'Scoped Median Hop', value: scopedStats.median === null ? '—' : scopedStats.median.toLocaleString(), note: scopedStats.total.toLocaleString() + ' samples' },
|
||||
{ label: 'Unscoped Median Hop', value: unscopedStats.median === null ? '—' : unscopedStats.median.toLocaleString(), note: unscopedStats.total.toLocaleString() + ' samples' },
|
||||
].map(function(c) {
|
||||
return '<div class="stat-card"><div class="stat-value">' + c.value + '</div>' +
|
||||
'<div class="stat-label">' + c.label + '</div>' +
|
||||
'<div class="stat-note text-muted" style="font-size:11px">' + c.note + '</div>' +
|
||||
'</div>';
|
||||
}).join('') +
|
||||
'</div>' +
|
||||
'<div style="display:flex;gap:10px;margin-bottom:4px;font-size:11px" class="text-muted">' +
|
||||
'<span><span style="display:inline-block;width:8px;height:8px;background:var(--accent);margin-right:3px"></span>Scoped</span>' +
|
||||
'<span><span style="display:inline-block;width:8px;height:8px;background:var(--text-muted);margin-right:3px"></span>Unscoped</span>' +
|
||||
'</div>' +
|
||||
rows;
|
||||
}
|
||||
|
||||
// publicKey -> RepeaterUnscopedHopDepth lookup from
|
||||
// HopDepthAnalyticsResponse.unscopedByRepeater (see GetHopDepthAnalytics),
|
||||
// used to enrich the Foreign Traffic tab's unscoped-relay table with how
|
||||
// far that traffic had already traveled before reaching each repeater.
|
||||
function hopDepthLookupByPubkey(unscopedByRepeater) {
|
||||
var byPK = {};
|
||||
(unscopedByRepeater || []).forEach(function(r) { byPK[r.publicKey] = r; });
|
||||
return byPK;
|
||||
}
|
||||
|
||||
function computeNodesWithoutScope(allNodes, cap, opts) {
|
||||
opts = opts || {};
|
||||
var noScopeNodes = allNodes.filter(function(n) { return !n.default_scope; });
|
||||
@@ -5514,6 +5630,13 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf
|
||||
function isForeignNode(n) {
|
||||
return !nodePassesGeoFilter(n.lat, n.lon, window.MC_GEO_FILTER);
|
||||
}
|
||||
// publicKey -> RepeaterUnscopedHopDepth, populated by load() from
|
||||
// /api/analytics/hop-depth. Enriches the volume-only unscoped_relay_count_24h
|
||||
// metric with WHERE in the flood's propagation this repeater sits: low
|
||||
// hops means mostly fresh/local unscoped traffic, high hops means
|
||||
// traffic that already traveled far, unscoped, before reaching it — the
|
||||
// stronger signal of an actual containment problem.
|
||||
var hopDepthByPubkey = {};
|
||||
function relaysHtml(relays, expanded) {
|
||||
if (relays.length === 0) {
|
||||
return '<p class="text-muted" style="font-size:0.85em">No repeater has relayed an unscoped flood packet in the last 24 hours.</p>';
|
||||
@@ -5522,16 +5645,22 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf
|
||||
var rows = shown.map(function(n) {
|
||||
var total = n.relay_count_24h || 0;
|
||||
var unscoped = n.unscoped_relay_count_24h || 0;
|
||||
var hd = hopDepthByPubkey[n.public_key];
|
||||
return '<tr>' +
|
||||
'<td><a href="#/nodes/' + encodeURIComponent(n.public_key) + '">' + esc(n.name || n.public_key) + '</a></td>' +
|
||||
'<td>' + esc(n.role) + '</td>' +
|
||||
'<td>' + unscoped.toLocaleString() + '</td>' +
|
||||
'<td>' + total.toLocaleString() + '</td>' +
|
||||
'<td>' + pct(unscoped, total) + '</td>' +
|
||||
'<td>' + (hd ? hd.minHops : '—') + '</td>' +
|
||||
'<td>' + (hd ? (hd.medianHops % 1 === 0 ? hd.medianHops : hd.medianHops.toFixed(1)) : '—') + '</td>' +
|
||||
'<td>' + (hd ? hd.maxHops : '—') + '</td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
return '<table class="data-table analytics-table">' +
|
||||
'<thead><tr><th>Repeater</th><th>Role</th><th>Unscoped Relays (24h)</th><th>Total Relays (24h)</th><th>% Unscoped</th></tr></thead>' +
|
||||
'<thead><tr><th>Repeater</th><th>Role</th><th>Unscoped Relays (24h)</th><th>Total Relays (24h)</th><th>% Unscoped</th>' +
|
||||
'<th title="0-based hop index within the packet\'s resolved relay path at this repeater — how far the packet had already traveled before this hop.">Min Hops</th>' +
|
||||
'<th>Median Hops</th><th>Max Hops</th></tr></thead>' +
|
||||
'<tbody>' + rows + '</tbody>' +
|
||||
'</table>' + topNToggleHtml(relays.length, expanded, 'repeaters');
|
||||
}
|
||||
@@ -5560,6 +5689,16 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
// Fired early but only awaited below, right before it's needed for
|
||||
// relaysHtml -- deliberately NOT awaited here. An extra await
|
||||
// ahead of the isForeignNode/geo_filter computation just below
|
||||
// shifts how many microtask ticks elapse before it runs, which in
|
||||
// the test sandbox lets roles.js's own async window.MC_GEO_FILTER
|
||||
// config-fetch race ahead and clobber the fixture's geo filter
|
||||
// before isForeignNode reads it (see
|
||||
// test-analytics-foreign-traffic-tab.js's makeAnalyticsSandbox).
|
||||
const hopDepthPromise = api('/analytics/hop-depth?window=24h', { ttl: 30000 }).catch(function() { return null; });
|
||||
|
||||
const nodesResp = await fetchAllNodes('', { ttl: CLIENT_TTL.nodeList });
|
||||
const allNodes = nodesResp.nodes || nodesResp;
|
||||
const relays = allNodes.filter(function(n) {
|
||||
@@ -5584,6 +5723,9 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf
|
||||
.sort(function(a, b) { return b.t - a.t; })
|
||||
.forEach(function(entry, i) { foreignNodes[i] = entry.n; });
|
||||
|
||||
const hopData = await hopDepthPromise;
|
||||
hopDepthByPubkey = hopDepthLookupByPubkey(hopData && hopData.unscopedByRepeater);
|
||||
|
||||
el.innerHTML =
|
||||
'<h3 style="margin:0 0 4px">Foreign Traffic</h3>' +
|
||||
'<p class="text-muted" style="margin:0 0 16px;font-size:0.85em">' +
|
||||
|
||||
@@ -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 "═══════════════════════════════════════"
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
Reference in New Issue
Block a user