feat(#1751): show transported region scopes in repeater sidebar (#1752)

Closes #1751.
This commit is contained in:
Michael J. Arcan
2026-06-27 23:59:50 +02:00
committed by GitHub
parent c03f2ebbcc
commit fc26fb6b3a
7 changed files with 285 additions and 9 deletions
+13 -2
View File
@@ -245,13 +245,19 @@ func (s *PacketStore) LoadChunked(chunkSize int) error {
if s.db.hasObsRawHex {
obsRawHexCol = ", o.raw_hex"
}
// #1751: scope_name is on the transmission row, so appending it as the
// last selected column is safe regardless of the observation fan-out.
scopeNameCol := ""
if s.db.hasScopeName {
scopeNameCol = ", t.scope_name"
}
var chunkSQL string
if s.db.isV3 {
chunkSQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type,
t.payload_type, t.payload_version, t.decoded_json,
o.id, obs.id, obs.name, COALESCE(obs.iata, ''), o.direction,
o.snr, o.rssi, o.score, o.path_json, strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch')` + obsRawHexCol + rpCol + `
o.snr, o.rssi, o.score, o.path_json, strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch')` + obsRawHexCol + rpCol + scopeNameCol + `
FROM (SELECT * FROM transmissions t2 ` + whereClause + ` ORDER BY t2.id ASC LIMIT ` + fmt.Sprintf("%d", chunkSize) + `) AS t
LEFT JOIN observations o ON o.transmission_id = t.id
LEFT JOIN observers obs ON obs.rowid = o.observer_idx
@@ -260,7 +266,7 @@ func (s *PacketStore) LoadChunked(chunkSize int) error {
chunkSQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type,
t.payload_type, t.payload_version, t.decoded_json,
o.id, o.observer_id, o.observer_name, COALESCE(obs.iata, ''), o.direction,
o.snr, o.rssi, o.score, o.path_json, o.timestamp` + obsRawHexCol + rpCol + `
o.snr, o.rssi, o.score, o.path_json, o.timestamp` + obsRawHexCol + rpCol + scopeNameCol + `
FROM (SELECT * FROM transmissions t2 ` + whereClause + ` ORDER BY t2.id ASC LIMIT ` + fmt.Sprintf("%d", chunkSize) + `) AS t
LEFT JOIN observations o ON o.transmission_id = t.id
LEFT JOIN observers obs ON obs.id = o.observer_id
@@ -373,6 +379,7 @@ func (s *PacketStore) scanAndMergeChunk(rows *sql.Rows, relayPM *prefixMap, cold
var score sql.NullInt64
var obsRawHex sql.NullString
var resolvedPathStr sql.NullString
var scopeName sql.NullString
scanArgs := []interface{}{&txID, &rawHex, &hash, &firstSeen, &routeType, &payloadType,
&payloadVersion, &decodedJSON,
@@ -384,6 +391,9 @@ func (s *PacketStore) scanAndMergeChunk(rows *sql.Rows, relayPM *prefixMap, cold
if s.db.hasResolvedPath {
scanArgs = append(scanArgs, &resolvedPathStr)
}
if s.db.hasScopeName {
scanArgs = append(scanArgs, &scopeName)
}
if err := rows.Scan(scanArgs...); err != nil {
log.Printf("[store] LoadChunked scan error: %v", err)
continue
@@ -406,6 +416,7 @@ func (s *PacketStore) scanAndMergeChunk(rows *sql.Rows, relayPM *prefixMap, cold
RouteType: nullIntPtr(routeType),
PayloadType: nullIntPtr(payloadType),
DecodedJSON: nullStrVal(decodedJSON),
ScopeName: nullStrVal(scopeName),
obsKeys: make(map[string]bool),
observerSet: make(map[string]bool),
}
+18
View File
@@ -111,6 +111,12 @@ func (s *PacketStore) computeRepeaterRelayInfoMap(windowHours float64) map[strin
out := make(map[string]RepeaterRelayInfo, len(snap))
for key, list := range snap {
info := RepeaterRelayInfo{WindowHours: windowHours}
// #1751: accumulate the set of region scope names carried by this
// hop key across every non-advert path-hop tx (NOT time-windowed).
// Captured by the visit closure below — lazily allocated on the first
// scope hit so hosts without scope_name pay nothing per key; converted
// to a sorted, capped slice before this key's info is stored.
var scopeSet map[string]struct{}
// When key looks like a full pubkey (>= 2 hex chars), also fold
// in the matching 1-byte raw-prefix bucket to mirror
// GetRepeaterRelayInfo's behavior. We dedup by tx ID.
@@ -141,6 +147,17 @@ func (s *PacketStore) computeRepeaterRelayInfoMap(windowHours float64) map[strin
if p.pt == payloadTypeAdvert {
continue
}
// #1751: scope accumulation is intentionally NOT gated on
// p.ok (timestamp parseability) — a packet with an
// unparseable first_seen still proves the repeater
// transported that scope. RelayCount/LastRelayed below
// remain timestamp-gated.
if tx.ScopeName != "" {
if scopeSet == nil {
scopeSet = map[string]struct{}{}
}
scopeSet[tx.ScopeName] = struct{}{}
}
if !p.ok {
continue
}
@@ -167,6 +184,7 @@ func (s *PacketStore) computeRepeaterRelayInfoMap(windowHours float64) map[strin
visit(snap[prefix])
}
}
info.TransportedScopes = sortedCappedScopes(scopeSet)
out[key] = info
}
return out
+52 -1
View File
@@ -1,6 +1,7 @@
package main
import (
"sort"
"strings"
"time"
)
@@ -26,6 +27,40 @@ type RepeaterRelayInfo struct {
// RelayCount24h is the count of distinct non-advert packets where this
// pubkey appeared as a relay hop in the last 24 hours.
RelayCount24h int `json:"relayCount24h"`
// TransportedScopes is the deduplicated, sorted set of region scope
// names (transmissions.scope_name) across ALL non-advert packets in
// which this pubkey appears as a path hop. Unlike RelayCount1h/24h this
// is NOT time-windowed — it answers "which region scopes has this
// repeater carried traffic for, ever (within the in-memory window)".
// Empty/absent on schemas without scope_name (#1751).
TransportedScopes []string `json:"transportedScopes,omitempty"`
}
// maxTransportedScopes bounds the per-node TransportedScopes list so a
// misbehaving sender flooding distinct scope_name values through a single
// repeater cannot inflate the node JSON unboundedly (#1751 review follow-up).
// Real region-scope counts are small; this is a defensive ceiling. When the
// set exceeds the cap the lexicographically-first names are kept, so the
// result stays deterministic.
const maxTransportedScopes = 32
// sortedCappedScopes converts a scope set into a sorted, length-capped slice,
// or nil when the set is empty/nil — so routes.go omits the JSON field via
// `omitempty`. Shared by the bulk (computeRepeaterRelayInfoMap) and per-node
// (computeRelayInfoFromEntries) paths to keep them in exact parity.
func sortedCappedScopes(set map[string]struct{}) []string {
if len(set) == 0 {
return nil
}
scopes := make([]string, 0, len(set))
for s := range set {
scopes = append(scopes, s)
}
sort.Strings(scopes)
if len(scopes) > maxTransportedScopes {
scopes = scopes[:maxTransportedScopes]
}
return scopes
}
// payloadTypeAdvert is the MeshCore payload type for ADVERT packets.
@@ -62,6 +97,9 @@ func parseRelayTS(ts string) (time.Time, bool) {
type relayEntry struct {
ts string
pt int
// scope is the tx's region scope name (transmissions.scope_name).
// Empty when absent / on older schemas. Used for TransportedScopes (#1751).
scope string
}
// collectRelayEntriesLocked returns deduplicated relayEntry snapshots for
@@ -105,7 +143,7 @@ func (s *PacketStore) collectRelayEntriesLocked(key string) []relayEntry {
if tx.PayloadType != nil {
pt = *tx.PayloadType
}
entries = append(entries, relayEntry{ts: tx.FirstSeen, pt: pt})
entries = append(entries, relayEntry{ts: tx.FirstSeen, pt: pt, scope: tx.ScopeName})
}
}
collect(txList)
@@ -124,11 +162,21 @@ func computeRelayInfoFromEntries(entries []relayEntry, windowHours float64) Repe
var latest time.Time
var latestRaw string
var scopeSet map[string]struct{}
for _, e := range entries {
// Self-originated adverts are not relay activity.
if e.pt == payloadTypeAdvert {
continue
}
// #1751: accumulate transported scopes BEFORE the timestamp gate —
// a non-advert path-hop tx proves scope transport even if its
// first_seen is unparseable. Mirrors the bulk path.
if e.scope != "" {
if scopeSet == nil {
scopeSet = map[string]struct{}{}
}
scopeSet[e.scope] = struct{}{}
}
t, ok := parseRelayTS(e.ts)
if !ok {
continue
@@ -144,6 +192,9 @@ func computeRelayInfoFromEntries(entries []relayEntry, windowHours float64) Repe
}
}
}
// #1751: emit transported scopes regardless of whether any timestamp
// parsed, and before the latestRaw early-return below.
info.TransportedScopes = sortedCappedScopes(scopeSet)
if latestRaw == "" {
return info
}
+11
View File
@@ -1361,6 +1361,12 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
node["relay_active"] = info.RelayActive
node["relay_count_1h"] = info.RelayCount1h
node["relay_count_24h"] = info.RelayCount24h
// #1751: region scopes this repeater has transported.
// Set only when non-empty so the field is absent for
// nodes without scopes / on older schemas.
if len(info.TransportedScopes) > 0 {
node["transported_scopes"] = info.TransportedScopes
}
// usefulness_score retained for API compat; new
// consumers should read traffic_share_score
// (issue #1456). When the #672 composite ships
@@ -1547,6 +1553,11 @@ func (s *Server) handleNodeDetail(w http.ResponseWriter, r *http.Request) {
node["relay_window_hours"] = info.WindowHours
node["relay_count_1h"] = info.RelayCount1h
node["relay_count_24h"] = info.RelayCount24h
// #1751: region scopes this repeater has transported. Set only
// when non-empty (absent for no-scope nodes / older schemas).
if len(info.TransportedScopes) > 0 {
node["transported_scopes"] = info.TransportedScopes
}
// usefulness_score retained for API compat; new
// consumers should read traffic_share_score (#1456).
us := s.store.GetRepeaterUsefulnessScore(pubkey)
+43 -6
View File
@@ -37,6 +37,10 @@ type StoreTx struct {
RouteType *int
PayloadType *int
DecodedJSON string
// ScopeName is the transmission's region scope name (transmissions.scope_name,
// #899). Empty on schemas without the column (db.hasScopeName=false). Used to
// surface the set of region scopes a repeater has transported (#1751).
ScopeName string
Observations []*StoreObs
ObservationCount int
// Display fields from longest-path observation
@@ -704,6 +708,12 @@ func (s *PacketStore) Load() error {
if s.db.hasObsRawHex {
obsRawHexCol = ", o.raw_hex"
}
// #1751: scope_name is on the transmission row; append as the last
// selected column so the observation fan-out doesn't affect its position.
scopeNameCol := ""
if s.db.hasScopeName {
scopeNameCol = ", t.scope_name"
}
// Build WHERE conditions: retention cutoff (mirrors Evict logic) + optional memory-cap limit.
// When hotStartupHours > 0, use it as the initial cutoff (smaller window = fast startup).
@@ -748,7 +758,7 @@ func (s *PacketStore) Load() error {
loadSQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type,
t.payload_type, t.payload_version, t.decoded_json,
o.id, obs.id, obs.name, COALESCE(obs.iata, ''), o.direction,
o.snr, o.rssi, o.score, o.path_json, strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch')` + obsRawHexCol + rpCol + `
o.snr, o.rssi, o.score, o.path_json, strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch')` + obsRawHexCol + rpCol + scopeNameCol + `
FROM transmissions t
LEFT JOIN observations o ON o.transmission_id = t.id
LEFT JOIN observers obs ON obs.rowid = o.observer_idx` + filterClause + `
@@ -757,7 +767,7 @@ func (s *PacketStore) Load() error {
loadSQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type,
t.payload_type, t.payload_version, t.decoded_json,
o.id, o.observer_id, o.observer_name, COALESCE(obs.iata, ''), o.direction,
o.snr, o.rssi, o.score, o.path_json, o.timestamp` + obsRawHexCol + rpCol + `
o.snr, o.rssi, o.score, o.path_json, o.timestamp` + obsRawHexCol + rpCol + scopeNameCol + `
FROM transmissions t
LEFT JOIN observations o ON o.transmission_id = t.id
LEFT JOIN observers obs ON obs.id = o.observer_id` + filterClause + `
@@ -795,6 +805,7 @@ func (s *PacketStore) Load() error {
var score sql.NullInt64
var obsRawHex sql.NullString
var resolvedPathStr sql.NullString
var scopeName sql.NullString
scanArgs := []interface{}{&txID, &rawHex, &hash, &firstSeen, &routeType, &payloadType,
&payloadVersion, &decodedJSON,
@@ -806,6 +817,9 @@ func (s *PacketStore) Load() error {
if s.db.hasResolvedPath {
scanArgs = append(scanArgs, &resolvedPathStr)
}
if s.db.hasScopeName {
scanArgs = append(scanArgs, &scopeName)
}
if err := rows.Scan(scanArgs...); err != nil {
log.Printf("[store] scan error: %v", err)
continue
@@ -823,6 +837,7 @@ func (s *PacketStore) Load() error {
RouteType: nullIntPtr(routeType),
PayloadType: nullIntPtr(payloadType),
DecodedJSON: nullStrVal(decodedJSON),
ScopeName: nullStrVal(scopeName),
obsKeys: make(map[string]bool),
observerSet: make(map[string]bool),
}
@@ -1026,6 +1041,11 @@ func (s *PacketStore) loadChunk(from, to time.Time) error {
if s.db.hasObsRawHex {
obsRawHexCol = ", o.raw_hex"
}
// #1751: scope_name is on the transmission row; append as the last column.
scopeNameCol := ""
if s.db.hasScopeName {
scopeNameCol = ", t.scope_name"
}
// #1690: window on the denormalized last_seen (effective recency)
// rather than first_seen. See chunked_load.go for the full rationale.
@@ -1045,7 +1065,7 @@ func (s *PacketStore) loadChunk(from, to time.Time) error {
chunkSQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type,
t.payload_type, t.payload_version, t.decoded_json,
o.id, obs.id, obs.name, o.direction,
o.snr, o.rssi, o.score, o.path_json, strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch')` + obsRawHexCol + rpCol + `
o.snr, o.rssi, o.score, o.path_json, strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch')` + obsRawHexCol + rpCol + scopeNameCol + `
FROM transmissions t
LEFT JOIN observations o ON o.transmission_id = t.id
LEFT JOIN observers obs ON obs.rowid = o.observer_idx` + filterClause + `
@@ -1054,7 +1074,7 @@ func (s *PacketStore) loadChunk(from, to time.Time) error {
chunkSQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type,
t.payload_type, t.payload_version, t.decoded_json,
o.id, o.observer_id, o.observer_name, o.direction,
o.snr, o.rssi, o.score, o.path_json, o.timestamp` + obsRawHexCol + rpCol + `
o.snr, o.rssi, o.score, o.path_json, o.timestamp` + obsRawHexCol + rpCol + scopeNameCol + `
FROM transmissions t
LEFT JOIN observations o ON o.transmission_id = t.id` + filterClause + `
ORDER BY t.first_seen ASC, o.timestamp DESC`
@@ -1114,6 +1134,7 @@ func (s *PacketStore) loadChunk(from, to time.Time) error {
var score sql.NullInt64
var obsRawHex sql.NullString
var resolvedPathStr sql.NullString
var scopeName sql.NullString
scanArgs := []interface{}{&txID, &rawHex, &hash, &firstSeen, &routeType, &payloadType,
&payloadVersion, &decodedJSON,
@@ -1125,6 +1146,9 @@ func (s *PacketStore) loadChunk(from, to time.Time) error {
if s.db.hasResolvedPath {
scanArgs = append(scanArgs, &resolvedPathStr)
}
if s.db.hasScopeName {
scanArgs = append(scanArgs, &scopeName)
}
if err := rows.Scan(scanArgs...); err != nil {
log.Printf("[store] loadChunk scan error: %v", err)
continue
@@ -1142,6 +1166,7 @@ func (s *PacketStore) loadChunk(from, to time.Time) error {
RouteType: nullIntPtr(routeType),
PayloadType: nullIntPtr(payloadType),
DecodedJSON: nullStrVal(decodedJSON),
ScopeName: nullStrVal(scopeName),
obsKeys: make(map[string]bool),
observerSet: make(map[string]bool),
}
@@ -2427,11 +2452,16 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
if s.db.hasObsRawHex {
obsRHCol = ", o.raw_hex"
}
// #1751: scope_name is on the transmission row; append as the last column.
scopeNameCol := ""
if s.db.hasScopeName {
scopeNameCol = ", t.scope_name"
}
if s.db.isV3 {
querySQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type,
t.payload_type, t.payload_version, t.decoded_json,
o.id, obs.id, obs.name, COALESCE(obs.iata, ''), o.direction,
o.snr, o.rssi, o.score, o.path_json, strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch')` + obsRHCol + `
o.snr, o.rssi, o.score, o.path_json, strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch')` + obsRHCol + scopeNameCol + `
FROM transmissions t
LEFT JOIN observations o ON o.transmission_id = t.id
LEFT JOIN observers obs ON obs.rowid = o.observer_idx
@@ -2441,7 +2471,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
querySQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type,
t.payload_type, t.payload_version, t.decoded_json,
o.id, o.observer_id, o.observer_name, COALESCE(obs.iata, ''), o.direction,
o.snr, o.rssi, o.score, o.path_json, o.timestamp` + obsRHCol + `
o.snr, o.rssi, o.score, o.path_json, o.timestamp` + obsRHCol + scopeNameCol + `
FROM transmissions t
LEFT JOIN observations o ON o.transmission_id = t.id
LEFT JOIN observers obs ON obs.id = o.observer_id
@@ -2464,6 +2494,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
obsID *int
observerID, observerName, observerIATA, direction, pathJSON, obsTS string
obsRawHex string
scopeName string
snr, rssi *float64
score *int
}
@@ -2481,6 +2512,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
var snrVal, rssiVal sql.NullFloat64
var scoreVal sql.NullInt64
var obsRawHex sql.NullString
var scopeName sql.NullString
scanArgs2 := []interface{}{&txID, &rawHex, &hash, &firstSeen, &routeType, &payloadType,
&payloadVersion, &decodedJSON,
@@ -2489,6 +2521,9 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
if s.db.hasObsRawHex {
scanArgs2 = append(scanArgs2, &obsRawHex)
}
if s.db.hasScopeName {
scanArgs2 = append(scanArgs2, &scopeName)
}
if err := rows.Scan(scanArgs2...); err != nil {
continue
}
@@ -2516,6 +2551,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
pathJSON: nullStrVal(pathJSON),
obsTS: nullStrVal(obsTimestamp),
obsRawHex: nullStrVal(obsRawHex),
scopeName: nullStrVal(scopeName),
snr: nullFloatPtr(snrVal),
rssi: nullFloatPtr(rssiVal),
score: nullIntPtr(scoreVal),
@@ -2570,6 +2606,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
RouteType: r.routeType,
PayloadType: r.payloadType,
DecodedJSON: r.decodedJSON,
ScopeName: r.scopeName,
obsKeys: make(map[string]bool),
observerSet: make(map[string]bool),
}
+147
View File
@@ -0,0 +1,147 @@
package main
import (
"fmt"
"reflect"
"strconv"
"sync"
"testing"
"time"
)
// Issue #1751: repeater/room nodes should expose the deduplicated, sorted
// set of region scope names (transmissions.scope_name) across every
// non-advert packet in which they appear as a path hop. Advert packets must
// be excluded (a self-advert is not transported traffic).
//
// These tests exercise BOTH computation paths that feed RepeaterRelayInfo:
// - computeRepeaterRelayInfoMap (bulk, repeater_enrich_bulk.go)
// - GetRepeaterRelayInfo (per-node, repeater_liveness.go)
// so the field stays in parity for /api/nodes (bulk) and the single-node
// detail endpoint (per-node).
const scope1751Key = "aabbccdd11223344"
// scopeTx builds a path-hop StoreTx with the given payload type, scope name,
// and an in-window FirstSeen.
func scopeTx(id int, payloadType int, scope string) *StoreTx {
pt := payloadType
return &StoreTx{
ID: id,
Hash: "scope-tx-" + scope + "-" + strconv.Itoa(id),
PayloadType: &pt,
ScopeName: scope,
FirstSeen: time.Now().UTC().Add(-10 * time.Minute).Format(time.RFC3339Nano),
}
}
func TestTransportedScopes_BulkDedupSortAndAdvertExcluded(t *testing.T) {
// Three non-advert packets across two distinct scopes (one repeated to
// prove dedup) PLUS one advert carrying a scope that must NOT appear.
txMsgWest := scopeTx(1, 2, "region-west") // TXT_MSG
txGrpEast := scopeTx(2, 5, "region-east") // GRP_TXT
txMsgWest2 := scopeTx(3, 1, "region-west") // REQ, duplicate scope
advertSecret := scopeTx(4, payloadTypeAdvert, "advert-only-scope")
store := &PacketStore{
byPathHop: map[string][]*StoreTx{
scope1751Key: {txMsgWest, txGrpEast, txMsgWest2, advertSecret},
},
mu: sync.RWMutex{},
}
out := store.computeRepeaterRelayInfoMap(24)
got := out[scope1751Key].TransportedScopes
want := []string{"region-east", "region-west"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("bulk TransportedScopes = %v, want %v (deduped, sorted, advert-excluded)", got, want)
}
}
func TestTransportedScopes_PerNodeMatchesBulk(t *testing.T) {
txMsgWest := scopeTx(1, 2, "region-west")
txGrpEast := scopeTx(2, 5, "region-east")
advertSecret := scopeTx(3, payloadTypeAdvert, "advert-only-scope")
store := &PacketStore{
byPathHop: map[string][]*StoreTx{
scope1751Key: {txMsgWest, txGrpEast, advertSecret},
},
mu: sync.RWMutex{},
}
info := store.GetRepeaterRelayInfo(scope1751Key, 24)
got := info.TransportedScopes
want := []string{"region-east", "region-west"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("per-node TransportedScopes = %v, want %v (deduped, sorted, advert-excluded)", got, want)
}
}
// TestTransportedScopes_EmptyWhenNoScope guards the "field absent" contract:
// a repeater whose path-hop packets carry no scope_name (older schema /
// hasScopeName=false → ScopeName always "") must yield a nil/empty slice so
// routes.go omits the JSON field entirely.
func TestTransportedScopes_EmptyWhenNoScope(t *testing.T) {
noScope := scopeTx(1, 2, "") // non-advert but ScopeName==""
store := &PacketStore{
byPathHop: map[string][]*StoreTx{scope1751Key: {noScope}},
mu: sync.RWMutex{},
}
if got := store.computeRepeaterRelayInfoMap(24)[scope1751Key].TransportedScopes; len(got) != 0 {
t.Fatalf("bulk: expected no scopes when ScopeName empty, got %v", got)
}
if got := store.GetRepeaterRelayInfo(scope1751Key, 24).TransportedScopes; len(got) != 0 {
t.Fatalf("per-node: expected no scopes when ScopeName empty, got %v", got)
}
}
// TestTransportedScopes_CrossBucketFold covers the bulk path's prefix fold:
// for a full-pubkey key it also folds in the matching 1-byte raw-prefix bucket
// (deduping by tx.ID). A scope seen only in the prefix bucket must surface on
// the full key, and a tx present in BOTH buckets must not be double-processed.
func TestTransportedScopes_CrossBucketFold(t *testing.T) {
full := scopeTx(1, 2, "region-direct") // only in the full-key bucket
prefixOnly := scopeTx(2, 2, "region-via-prefix") // only in the 1-byte bucket
shared := scopeTx(3, 2, "region-shared") // in BOTH buckets (dedup by ID)
store := &PacketStore{
byPathHop: map[string][]*StoreTx{
scope1751Key: {full, shared},
scope1751Key[:2]: {prefixOnly, shared},
},
mu: sync.RWMutex{},
}
got := store.computeRepeaterRelayInfoMap(24)[scope1751Key].TransportedScopes
want := []string{"region-direct", "region-shared", "region-via-prefix"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("cross-bucket fold TransportedScopes = %v, want %v", got, want)
}
}
// TestTransportedScopes_Capped pins the soft cap (#1751 review follow-up):
// more distinct scopes than maxTransportedScopes are bounded to the cap,
// keeping the lexicographically-first names so the output stays deterministic.
func TestTransportedScopes_Capped(t *testing.T) {
var txs []*StoreTx
for i := 0; i < maxTransportedScopes+10; i++ {
txs = append(txs, scopeTx(i+1, 2, fmt.Sprintf("scope-%03d", i)))
}
store := &PacketStore{
byPathHop: map[string][]*StoreTx{scope1751Key: txs},
mu: sync.RWMutex{},
}
got := store.computeRepeaterRelayInfoMap(24)[scope1751Key].TransportedScopes
if len(got) != maxTransportedScopes {
t.Fatalf("expected cap at %d scopes, got %d", maxTransportedScopes, len(got))
}
if got[0] != "scope-000" || got[len(got)-1] != fmt.Sprintf("scope-%03d", maxTransportedScopes-1) {
t.Fatalf("cap should keep lexicographically-first names: first=%q last=%q", got[0], got[len(got)-1])
}
}
+1
View File
@@ -672,6 +672,7 @@
const btooltip = "Normalized betweenness centrality (0..1). How often this node sits on the shortest path between other pairs of nodes in the affinity graph. 1.0 = the most structurally critical node on the mesh. High Bridge + low Traffic share = a quiet but irreplaceable chokepoint.";
return `<tr id="row-bridge-score" data-bridge-score="${b.toFixed(4)}"><td title="${btooltip}">Bridge score <span style="color:var(--text-muted);cursor:help" aria-label="help">ⓘ</span></td><td><span style="display:inline-block;vertical-align:middle;width:80px;height:8px;background:var(--bg-secondary,#333);border-radius:4px;overflow:hidden;margin-right:6px"><span style="display:block;width:${bbarWidth}%;height:100%;background:${bcolor}"></span></span><span style="color:${bcolor};font-weight:600">${bpct}%</span> <span style="color:var(--text-muted);font-size:11px;margin-left:4px">${blabel}</span></td></tr>`;
})() : ''}
${(n.role === 'repeater' || n.role === 'room') && Array.isArray(n.transported_scopes) && n.transported_scopes.length ? `<tr id="row-transported-scopes"><td title="Distinct region scopes (transmissions.scope_name) of all non-advert packets in which this repeater appears as a path hop. Shows which regions' traffic this repeater has carried (#1751).">Transported scopes</td><td><span style="display:inline-flex;flex-wrap:wrap;gap:3px;vertical-align:middle">${n.transported_scopes.map(sc => '<span class="badge">' + escapeHtml(String(sc)) + '</span>').join('')}</span></td></tr>` : ''}
<tr><td>First Seen</td><td>${renderNodeTimestampHtml(n.first_seen)}</td></tr>
<tr><td>Total Packets</td><td>${stats.totalTransmissions || stats.totalPackets || n.advert_count || 0}${stats.totalObservations && stats.totalObservations !== (stats.totalTransmissions || stats.totalPackets) ? ' <span class="text-muted" style="font-size:0.85em">(seen ' + stats.totalObservations + '×)</span>' : ''}</td></tr>
<tr><td>Packets Today</td><td>${stats.packetsToday || 0}</td></tr>