mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-02 08:51:40 +00:00
Closes #1751.
This commit is contained in:
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user