mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-04-25 15:22:25 +00:00
## Summary Replace N+1 per-hop DB queries in `handleResolveHops` with O(1) lookups against the in-memory prefix map that already exists in the packet store. ## Problem Each hop in the `resolve-hops` API triggered a separate `SELECT ... LIKE ?` query against the nodes table. With 10 hops, that's 10 DB round-trips — unnecessary when `getCachedNodesAndPM()` already maintains an in-memory prefix map that can resolve hops instantly. ## Changes - **routes.go**: Replace the per-hop DB query loop with `pm.m[hopLower]` lookups from the prefix map. Convert `nodeInfo` → `HopCandidate` inline. Remove unused `rows`/`sql.Scan` code. - **store.go**: Add `InvalidateNodeCache()` method to force prefix map rebuild (needed by tests that insert nodes after store initialization). - **routes_test.go**: Give `TestResolveHopsAmbiguous` a proper store so hops resolve via the prefix map. - **resolve_context_test.go**: Call `InvalidateNodeCache()` after inserting test nodes. Fix confidence assertion — with GPS candidates and no affinity context, `resolveWithContext` correctly returns `gps_preference` (previously masked because the prefix map didn't have the test nodes). ## Complexity O(1) per hop lookup via hash map vs O(n) DB scan per hop. No hot-path impact — this endpoint is called on-demand, not in a render loop. Fixes #369 --------- Co-authored-by: you <you@example.com>
6191 lines
167 KiB
Go
6191 lines
167 KiB
Go
package main
|
||
|
||
import (
|
||
"database/sql"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"math"
|
||
"runtime"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"time"
|
||
"unicode/utf8"
|
||
)
|
||
|
||
// payloadTypeNames maps payload_type int → human-readable name (firmware-standard).
|
||
var payloadTypeNames = map[int]string{
|
||
0: "REQ", 1: "RESPONSE", 2: "TXT_MSG", 3: "ACK", 4: "ADVERT",
|
||
5: "GRP_TXT", 7: "ANON_REQ", 8: "PATH", 9: "TRACE", 11: "CONTROL",
|
||
}
|
||
|
||
// StoreTx is an in-memory transmission with embedded observations.
|
||
type StoreTx struct {
|
||
ID int
|
||
RawHex string
|
||
Hash string
|
||
FirstSeen string
|
||
RouteType *int
|
||
PayloadType *int
|
||
DecodedJSON string
|
||
Observations []*StoreObs
|
||
ObservationCount int
|
||
// Display fields from longest-path observation
|
||
ObserverID string
|
||
ObserverName string
|
||
SNR *float64
|
||
RSSI *float64
|
||
PathJSON string
|
||
Direction string
|
||
ResolvedPath []*string // resolved path from best observation
|
||
LatestSeen string // max observation timestamp (or FirstSeen if no observations)
|
||
// Cached parsed fields (set once, read many)
|
||
parsedPath []string // cached parsePathJSON result
|
||
pathParsed bool // whether parsedPath has been set
|
||
decodedOnce sync.Once // guards parsedDecoded
|
||
parsedDecoded map[string]interface{} // cached json.Unmarshal of DecodedJSON
|
||
// Dedup map: "observerID|pathJSON" → true for O(1) duplicate checks
|
||
obsKeys map[string]bool
|
||
}
|
||
|
||
// StoreObs is a lean in-memory observation (no duplication of transmission fields).
|
||
type StoreObs struct {
|
||
ID int
|
||
TransmissionID int
|
||
ObserverID string
|
||
ObserverName string
|
||
Direction string
|
||
SNR *float64
|
||
RSSI *float64
|
||
Score *int
|
||
PathJSON string
|
||
ResolvedPath []*string // resolved full pubkeys, parallel to path_json; nil elements = unresolved
|
||
Timestamp string
|
||
}
|
||
|
||
// ParsedDecoded returns the parsed DecodedJSON map, caching the result.
|
||
// Thread-safe via sync.Once — the first call parses, subsequent calls return cached.
|
||
func (tx *StoreTx) ParsedDecoded() map[string]interface{} {
|
||
tx.decodedOnce.Do(func() {
|
||
if tx.DecodedJSON != "" {
|
||
json.Unmarshal([]byte(tx.DecodedJSON), &tx.parsedDecoded)
|
||
}
|
||
})
|
||
return tx.parsedDecoded
|
||
}
|
||
|
||
// PacketStore holds all transmissions in memory with indexes for fast queries.
|
||
type PacketStore struct {
|
||
mu sync.RWMutex
|
||
db *DB
|
||
packets []*StoreTx // sorted by first_seen ASC (oldest first; newest at tail)
|
||
byHash map[string]*StoreTx // hash → *StoreTx
|
||
byTxID map[int]*StoreTx // transmission_id → *StoreTx
|
||
byObsID map[int]*StoreObs // observation_id → *StoreObs
|
||
maxTxID int // highest transmission_id in store
|
||
maxObsID int // highest observation_id in store
|
||
byObserver map[string][]*StoreObs // observer_id → observations
|
||
byNode map[string][]*StoreTx // pubkey → transmissions
|
||
nodeHashes map[string]map[string]bool // pubkey → Set<hash>
|
||
byPathHop map[string][]*StoreTx // lowercase hop/pubkey → transmissions with that hop in path
|
||
byPayloadType map[int][]*StoreTx // payload_type → transmissions
|
||
loaded bool
|
||
totalObs int
|
||
insertCount int64
|
||
queryCount int64
|
||
// Response caches (separate mutex to avoid contention with store RWMutex)
|
||
cacheMu sync.Mutex
|
||
rfCache map[string]*cachedResult // region → cached RF result
|
||
topoCache map[string]*cachedResult // region → cached topology result
|
||
hashCache map[string]*cachedResult // region → cached hash-sizes result
|
||
collisionCache map[string]*cachedResult // cached hash-collisions result keyed by region ("" = global)
|
||
chanCache map[string]*cachedResult // region → cached channels result
|
||
distCache map[string]*cachedResult // region → cached distance result
|
||
subpathCache map[string]*cachedResult // params → cached subpaths result
|
||
rfCacheTTL time.Duration
|
||
collisionCacheTTL time.Duration
|
||
cacheHits int64
|
||
cacheMisses int64
|
||
// Rate-limited invalidation (fixes #533: caches cleared faster than hit)
|
||
lastInvalidated time.Time
|
||
pendingInv *cacheInvalidation // accumulated dirty flags during cooldown
|
||
invCooldown time.Duration // minimum time between invalidations
|
||
// Short-lived cache for QueryGroupedPackets (avoids repeated full sort)
|
||
groupedCacheMu sync.Mutex
|
||
groupedCacheKey string
|
||
groupedCacheExp time.Time
|
||
groupedCacheRes *PacketResult
|
||
// Short-lived cache for GetChannels (avoids repeated full scan + JSON unmarshal)
|
||
channelsCacheMu sync.Mutex
|
||
channelsCacheKey string
|
||
channelsCacheExp time.Time
|
||
channelsCacheRes []map[string]interface{}
|
||
// Cached region → observer ID mapping (30s TTL, avoids repeated DB queries)
|
||
regionObsMu sync.Mutex
|
||
regionObsCache map[string]map[string]bool
|
||
regionObsCacheTime time.Time
|
||
// Cached node list + prefix map (rebuilt on demand, shared across analytics)
|
||
nodeCache []nodeInfo
|
||
nodePM *prefixMap
|
||
nodeCacheTime time.Time
|
||
// Precomputed subpath index: raw comma-joined hops → occurrence count.
|
||
// Built during Load(), incrementally updated on ingest. Avoids full
|
||
// packet iteration at query time (O(unique_subpaths) vs O(total_packets)).
|
||
spIndex map[string]int // "hop1,hop2" → count
|
||
spTxIndex map[string][]*StoreTx // "hop1,hop2" → transmissions containing this subpath
|
||
spTotalPaths int // transmissions with paths >= 2 hops
|
||
// Precomputed distance analytics: hop distances and path totals
|
||
// computed during Load() and incrementally updated on ingest.
|
||
distHops []distHopRecord
|
||
distPaths []distPathRecord
|
||
|
||
// Cached GetNodeHashSizeInfo result — recomputed at most once every 15s
|
||
hashSizeInfoMu sync.Mutex
|
||
hashSizeInfoCache map[string]*hashSizeNodeInfo
|
||
hashSizeInfoAt time.Time
|
||
|
||
// Precomputed distinct advert pubkey count (refcounted for eviction correctness).
|
||
// Updated incrementally during Load/Ingest/Evict — avoids JSON parsing in GetPerfStoreStats.
|
||
advertPubkeys map[string]int // pubkey → number of advert packets referencing it
|
||
|
||
// Persisted neighbor graph for hop resolution at ingest time.
|
||
graph *NeighborGraph
|
||
|
||
// Eviction config and stats
|
||
retentionHours float64 // 0 = unlimited
|
||
maxMemoryMB int // 0 = unlimited
|
||
evicted int64 // total packets evicted
|
||
memoryEstimator func() float64 // injectable for tests; nil = use runtime.ReadMemStats
|
||
}
|
||
|
||
// Precomputed distance records for fast analytics aggregation.
|
||
type distHopRecord struct {
|
||
FromName string
|
||
FromPk string
|
||
ToName string
|
||
ToPk string
|
||
Dist float64
|
||
Type string // "R↔R", "C↔R", "C↔C"
|
||
SNR *float64
|
||
Hash string
|
||
Timestamp string
|
||
HourBucket string
|
||
tx *StoreTx
|
||
}
|
||
|
||
type distPathRecord struct {
|
||
Hash string
|
||
TotalDist float64
|
||
HopCount int
|
||
Timestamp string
|
||
Hops []distHopDetail
|
||
tx *StoreTx
|
||
}
|
||
|
||
type distHopDetail struct {
|
||
FromName string
|
||
FromPk string
|
||
ToName string
|
||
ToPk string
|
||
Dist float64
|
||
}
|
||
|
||
type cachedResult struct {
|
||
data map[string]interface{}
|
||
expiresAt time.Time
|
||
}
|
||
|
||
// NewPacketStore creates a new empty packet store backed by db.
|
||
func NewPacketStore(db *DB, cfg *PacketStoreConfig) *PacketStore {
|
||
ps := &PacketStore{
|
||
db: db,
|
||
packets: make([]*StoreTx, 0, 65536),
|
||
byHash: make(map[string]*StoreTx, 65536),
|
||
byTxID: make(map[int]*StoreTx, 65536),
|
||
byObsID: make(map[int]*StoreObs, 65536),
|
||
byObserver: make(map[string][]*StoreObs),
|
||
byNode: make(map[string][]*StoreTx),
|
||
byPathHop: make(map[string][]*StoreTx),
|
||
nodeHashes: make(map[string]map[string]bool),
|
||
byPayloadType: make(map[int][]*StoreTx),
|
||
rfCache: make(map[string]*cachedResult),
|
||
topoCache: make(map[string]*cachedResult),
|
||
hashCache: make(map[string]*cachedResult),
|
||
|
||
collisionCache: make(map[string]*cachedResult),
|
||
chanCache: make(map[string]*cachedResult),
|
||
distCache: make(map[string]*cachedResult),
|
||
subpathCache: make(map[string]*cachedResult),
|
||
rfCacheTTL: 15 * time.Second,
|
||
collisionCacheTTL: 60 * time.Second,
|
||
invCooldown: 10 * time.Second,
|
||
spIndex: make(map[string]int, 4096),
|
||
spTxIndex: make(map[string][]*StoreTx, 4096),
|
||
advertPubkeys: make(map[string]int),
|
||
}
|
||
if cfg != nil {
|
||
ps.retentionHours = cfg.RetentionHours
|
||
ps.maxMemoryMB = cfg.MaxMemoryMB
|
||
}
|
||
return ps
|
||
}
|
||
|
||
// Load reads all transmissions + observations from SQLite into memory.
|
||
func (s *PacketStore) Load() error {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
|
||
t0 := time.Now()
|
||
|
||
var loadSQL string
|
||
rpCol := ""
|
||
if s.db.hasResolvedPath {
|
||
rpCol = ",\n\t\t\t\to.resolved_path"
|
||
}
|
||
if s.db.isV3 {
|
||
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, o.direction,
|
||
o.snr, o.rssi, o.score, o.path_json, strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch')` + rpCol + `
|
||
FROM transmissions t
|
||
LEFT JOIN observations o ON o.transmission_id = t.id
|
||
LEFT JOIN observers obs ON obs.rowid = o.observer_idx
|
||
ORDER BY t.first_seen ASC, o.timestamp DESC`
|
||
} else {
|
||
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, o.direction,
|
||
o.snr, o.rssi, o.score, o.path_json, o.timestamp` + rpCol + `
|
||
FROM transmissions t
|
||
LEFT JOIN observations o ON o.transmission_id = t.id
|
||
ORDER BY t.first_seen ASC, o.timestamp DESC`
|
||
}
|
||
|
||
rows, err := s.db.conn.Query(loadSQL)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
|
||
for rows.Next() {
|
||
var txID int
|
||
var rawHex, hash, firstSeen, decodedJSON sql.NullString
|
||
var routeType, payloadType, payloadVersion sql.NullInt64
|
||
var obsID sql.NullInt64
|
||
var observerID, observerName, direction, pathJSON, obsTimestamp sql.NullString
|
||
var snr, rssi sql.NullFloat64
|
||
var score sql.NullInt64
|
||
var resolvedPathStr sql.NullString
|
||
|
||
scanArgs := []interface{}{&txID, &rawHex, &hash, &firstSeen, &routeType, &payloadType,
|
||
&payloadVersion, &decodedJSON,
|
||
&obsID, &observerID, &observerName, &direction,
|
||
&snr, &rssi, &score, &pathJSON, &obsTimestamp}
|
||
if s.db.hasResolvedPath {
|
||
scanArgs = append(scanArgs, &resolvedPathStr)
|
||
}
|
||
if err := rows.Scan(scanArgs...); err != nil {
|
||
log.Printf("[store] scan error: %v", err)
|
||
continue
|
||
}
|
||
|
||
hashStr := nullStrVal(hash)
|
||
tx := s.byHash[hashStr]
|
||
if tx == nil {
|
||
tx = &StoreTx{
|
||
ID: txID,
|
||
RawHex: nullStrVal(rawHex),
|
||
Hash: hashStr,
|
||
FirstSeen: nullStrVal(firstSeen),
|
||
LatestSeen: nullStrVal(firstSeen),
|
||
RouteType: nullIntPtr(routeType),
|
||
PayloadType: nullIntPtr(payloadType),
|
||
DecodedJSON: nullStrVal(decodedJSON),
|
||
obsKeys: make(map[string]bool),
|
||
}
|
||
s.byHash[hashStr] = tx
|
||
s.packets = append(s.packets, tx)
|
||
s.byTxID[txID] = tx
|
||
if txID > s.maxTxID {
|
||
s.maxTxID = txID
|
||
}
|
||
s.indexByNode(tx)
|
||
if tx.PayloadType != nil {
|
||
pt := *tx.PayloadType
|
||
s.byPayloadType[pt] = append(s.byPayloadType[pt], tx)
|
||
}
|
||
s.trackAdvertPubkey(tx)
|
||
}
|
||
|
||
if obsID.Valid {
|
||
oid := int(obsID.Int64)
|
||
obsIDStr := nullStrVal(observerID)
|
||
obsPJ := nullStrVal(pathJSON)
|
||
|
||
// Dedup: skip if same observer + same path already loaded (O(1) map lookup)
|
||
dk := obsIDStr + "|" + obsPJ
|
||
if tx.obsKeys[dk] {
|
||
continue
|
||
}
|
||
|
||
obs := &StoreObs{
|
||
ID: oid,
|
||
TransmissionID: txID,
|
||
ObserverID: obsIDStr,
|
||
ObserverName: nullStrVal(observerName),
|
||
Direction: nullStrVal(direction),
|
||
SNR: nullFloatPtr(snr),
|
||
RSSI: nullFloatPtr(rssi),
|
||
Score: nullIntPtr(score),
|
||
PathJSON: obsPJ,
|
||
ResolvedPath: unmarshalResolvedPath(nullStrVal(resolvedPathStr)),
|
||
Timestamp: normalizeTimestamp(nullStrVal(obsTimestamp)),
|
||
}
|
||
|
||
tx.Observations = append(tx.Observations, obs)
|
||
tx.obsKeys[dk] = true
|
||
tx.ObservationCount++
|
||
if obs.Timestamp > tx.LatestSeen {
|
||
tx.LatestSeen = obs.Timestamp
|
||
}
|
||
|
||
s.byObsID[oid] = obs
|
||
if oid > s.maxObsID {
|
||
s.maxObsID = oid
|
||
}
|
||
|
||
if obsIDStr != "" {
|
||
s.byObserver[obsIDStr] = append(s.byObserver[obsIDStr], obs)
|
||
}
|
||
|
||
s.totalObs++
|
||
}
|
||
}
|
||
|
||
// Post-load: pick best observation (longest path) for each transmission
|
||
for _, tx := range s.packets {
|
||
pickBestObservation(tx)
|
||
}
|
||
|
||
// Build precomputed subpath index for O(1) analytics queries
|
||
s.buildSubpathIndex()
|
||
|
||
// Build path-hop index for O(1) node path lookups
|
||
s.buildPathHopIndex()
|
||
|
||
// Precompute distance analytics (hop distances, path totals)
|
||
s.buildDistanceIndex()
|
||
|
||
s.loaded = true
|
||
elapsed := time.Since(t0)
|
||
log.Printf("[store] Loaded %d transmissions (%d observations) in %v (heap ~%.0fMB)",
|
||
len(s.packets), s.totalObs, elapsed, s.estimatedMemoryMB())
|
||
return nil
|
||
}
|
||
|
||
// pickBestObservation selects the observation with the longest path
|
||
// and sets it as the transmission's display observation.
|
||
func pickBestObservation(tx *StoreTx) {
|
||
if len(tx.Observations) == 0 {
|
||
return
|
||
}
|
||
best := tx.Observations[0]
|
||
bestLen := pathLen(best.PathJSON)
|
||
for _, obs := range tx.Observations[1:] {
|
||
l := pathLen(obs.PathJSON)
|
||
if l > bestLen {
|
||
best = obs
|
||
bestLen = l
|
||
}
|
||
}
|
||
tx.ObserverID = best.ObserverID
|
||
tx.ObserverName = best.ObserverName
|
||
tx.SNR = best.SNR
|
||
tx.RSSI = best.RSSI
|
||
tx.PathJSON = best.PathJSON
|
||
tx.Direction = best.Direction
|
||
tx.ResolvedPath = best.ResolvedPath
|
||
tx.pathParsed = false // invalidate cached parsed path
|
||
}
|
||
|
||
func pathLen(pathJSON string) int {
|
||
if pathJSON == "" {
|
||
return 0
|
||
}
|
||
var hops []interface{}
|
||
if json.Unmarshal([]byte(pathJSON), &hops) != nil {
|
||
return 0
|
||
}
|
||
return len(hops)
|
||
}
|
||
|
||
// indexByNode extracts pubkeys from decoded_json and indexes the transmission.
|
||
func (s *PacketStore) indexByNode(tx *StoreTx) {
|
||
if tx.DecodedJSON == "" {
|
||
return
|
||
}
|
||
// All three target fields ("pubKey", "destPubKey", "srcPubKey") share the
|
||
// common suffix "ubKey" — skip JSON parse for packets that have none of them.
|
||
if !strings.Contains(tx.DecodedJSON, "ubKey") {
|
||
return
|
||
}
|
||
decoded := tx.ParsedDecoded()
|
||
if decoded == nil {
|
||
return
|
||
}
|
||
for _, field := range []string{"pubKey", "destPubKey", "srcPubKey"} {
|
||
if v, ok := decoded[field].(string); ok && v != "" {
|
||
if s.nodeHashes[v] == nil {
|
||
s.nodeHashes[v] = make(map[string]bool)
|
||
}
|
||
if s.nodeHashes[v][tx.Hash] {
|
||
continue
|
||
}
|
||
s.nodeHashes[v][tx.Hash] = true
|
||
s.byNode[v] = append(s.byNode[v], tx)
|
||
}
|
||
}
|
||
}
|
||
|
||
// trackAdvertPubkey increments the advertPubkeys refcount for ADVERT packets.
|
||
// Must be called under s.mu write lock.
|
||
func (s *PacketStore) trackAdvertPubkey(tx *StoreTx) {
|
||
if tx.PayloadType == nil || *tx.PayloadType != 4 || tx.DecodedJSON == "" {
|
||
return
|
||
}
|
||
d := tx.ParsedDecoded()
|
||
if d == nil {
|
||
return
|
||
}
|
||
pk := ""
|
||
if v, ok := d["pubKey"].(string); ok {
|
||
pk = v
|
||
} else if v, ok := d["public_key"].(string); ok {
|
||
pk = v
|
||
}
|
||
if pk != "" {
|
||
s.advertPubkeys[pk]++
|
||
}
|
||
}
|
||
|
||
// untrackAdvertPubkey decrements the advertPubkeys refcount for ADVERT packets.
|
||
// Must be called under s.mu write lock.
|
||
func (s *PacketStore) untrackAdvertPubkey(tx *StoreTx) {
|
||
if tx.PayloadType == nil || *tx.PayloadType != 4 || tx.DecodedJSON == "" {
|
||
return
|
||
}
|
||
var d map[string]interface{}
|
||
if json.Unmarshal([]byte(tx.DecodedJSON), &d) != nil {
|
||
return
|
||
}
|
||
pk := ""
|
||
if v, ok := d["pubKey"].(string); ok {
|
||
pk = v
|
||
} else if v, ok := d["public_key"].(string); ok {
|
||
pk = v
|
||
}
|
||
if pk != "" {
|
||
if s.advertPubkeys[pk] <= 1 {
|
||
delete(s.advertPubkeys, pk)
|
||
} else {
|
||
s.advertPubkeys[pk]--
|
||
}
|
||
}
|
||
}
|
||
|
||
// QueryPackets returns filtered, paginated packets from memory.
|
||
func (s *PacketStore) QueryPackets(q PacketQuery) *PacketResult {
|
||
atomic.AddInt64(&s.queryCount, 1)
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
if q.Limit <= 0 {
|
||
q.Limit = 50
|
||
}
|
||
if q.Order == "" {
|
||
q.Order = "DESC"
|
||
}
|
||
|
||
results := s.filterPackets(q)
|
||
total := len(results)
|
||
|
||
// results is oldest-first (ASC). For DESC (default) read backwards from the tail;
|
||
// for ASC read forwards. Both are O(page_size) — no sort copy needed.
|
||
start := q.Offset
|
||
if start >= total {
|
||
return &PacketResult{Packets: []map[string]interface{}{}, Total: total}
|
||
}
|
||
pageSize := q.Limit
|
||
if start+pageSize > total {
|
||
pageSize = total - start
|
||
}
|
||
|
||
packets := make([]map[string]interface{}, 0, pageSize)
|
||
if q.Order == "ASC" {
|
||
for _, tx := range results[start : start+pageSize] {
|
||
packets = append(packets, txToMap(tx))
|
||
}
|
||
} else {
|
||
// DESC: newest items are at the tail; page 0 = last pageSize items reversed
|
||
endIdx := total - start
|
||
startIdx := endIdx - pageSize
|
||
if startIdx < 0 {
|
||
startIdx = 0
|
||
}
|
||
for i := endIdx - 1; i >= startIdx; i-- {
|
||
packets = append(packets, txToMap(results[i]))
|
||
}
|
||
}
|
||
return &PacketResult{Packets: packets, Total: total}
|
||
}
|
||
|
||
// QueryGroupedPackets returns transmissions grouped by hash (already 1:1).
|
||
func (s *PacketStore) QueryGroupedPackets(q PacketQuery) *PacketResult {
|
||
atomic.AddInt64(&s.queryCount, 1)
|
||
|
||
if q.Limit <= 0 {
|
||
q.Limit = 50
|
||
}
|
||
|
||
// Cache key covers all filter dimensions. Empty key = no filters.
|
||
cacheKey := q.Since + "|" + q.Until + "|" + q.Region + "|" + q.Node + "|" + q.Hash + "|" + q.Observer
|
||
if q.Type != nil {
|
||
cacheKey += fmt.Sprintf("|t%d", *q.Type)
|
||
}
|
||
if q.Route != nil {
|
||
cacheKey += fmt.Sprintf("|r%d", *q.Route)
|
||
}
|
||
|
||
// Return cached sorted list if still fresh (3s TTL)
|
||
s.groupedCacheMu.Lock()
|
||
if s.groupedCacheRes != nil && s.groupedCacheKey == cacheKey && time.Now().Before(s.groupedCacheExp) {
|
||
cached := s.groupedCacheRes
|
||
s.groupedCacheMu.Unlock()
|
||
return pagePacketResult(cached, q.Offset, q.Limit)
|
||
}
|
||
s.groupedCacheMu.Unlock()
|
||
|
||
// Build entries under read lock (observer scan needs lock), sort outside it.
|
||
type groupEntry struct {
|
||
latest map[string]interface{}
|
||
ts string
|
||
}
|
||
var entries []groupEntry
|
||
|
||
s.mu.RLock()
|
||
results := s.filterPackets(q)
|
||
entries = make([]groupEntry, 0, len(results))
|
||
for _, tx := range results {
|
||
observerCount := 0
|
||
seen := make(map[string]bool)
|
||
for _, obs := range tx.Observations {
|
||
if obs.ObserverID != "" && !seen[obs.ObserverID] {
|
||
seen[obs.ObserverID] = true
|
||
observerCount++
|
||
}
|
||
}
|
||
entries = append(entries, groupEntry{
|
||
ts: tx.LatestSeen,
|
||
latest: map[string]interface{}{
|
||
"hash": strOrNil(tx.Hash),
|
||
"first_seen": strOrNil(tx.FirstSeen),
|
||
"count": tx.ObservationCount,
|
||
"observer_count": observerCount,
|
||
"observation_count": tx.ObservationCount,
|
||
"latest": strOrNil(tx.LatestSeen),
|
||
"observer_id": strOrNil(tx.ObserverID),
|
||
"observer_name": strOrNil(tx.ObserverName),
|
||
"path_json": strOrNil(tx.PathJSON),
|
||
"payload_type": intPtrOrNil(tx.PayloadType),
|
||
"route_type": intPtrOrNil(tx.RouteType),
|
||
"raw_hex": strOrNil(tx.RawHex),
|
||
"decoded_json": strOrNil(tx.DecodedJSON),
|
||
"snr": floatPtrOrNil(tx.SNR),
|
||
"rssi": floatPtrOrNil(tx.RSSI),
|
||
},
|
||
})
|
||
if tx.ResolvedPath != nil {
|
||
entries[len(entries)-1].latest["resolved_path"] = tx.ResolvedPath
|
||
}
|
||
}
|
||
s.mu.RUnlock()
|
||
|
||
// Sort outside the lock — only touches our local slice.
|
||
sort.Slice(entries, func(i, j int) bool {
|
||
return entries[i].ts > entries[j].ts
|
||
})
|
||
|
||
packets := make([]map[string]interface{}, len(entries))
|
||
for i, e := range entries {
|
||
packets[i] = e.latest
|
||
}
|
||
|
||
full := &PacketResult{Packets: packets, Total: len(packets)}
|
||
|
||
s.groupedCacheMu.Lock()
|
||
s.groupedCacheRes = full
|
||
s.groupedCacheKey = cacheKey
|
||
s.groupedCacheExp = time.Now().Add(3 * time.Second)
|
||
s.groupedCacheMu.Unlock()
|
||
|
||
return pagePacketResult(full, q.Offset, q.Limit)
|
||
}
|
||
|
||
// pagePacketResult returns a window of a PacketResult without re-allocating the slice.
|
||
func pagePacketResult(r *PacketResult, offset, limit int) *PacketResult {
|
||
total := r.Total
|
||
if offset >= total {
|
||
return &PacketResult{Packets: []map[string]interface{}{}, Total: total}
|
||
}
|
||
end := offset + limit
|
||
if end > total {
|
||
end = total
|
||
}
|
||
return &PacketResult{Packets: r.Packets[offset:end], Total: total}
|
||
}
|
||
|
||
// GetStoreStats returns aggregate counts (packet data from memory, node/observer from DB).
|
||
func (s *PacketStore) GetStoreStats() (*Stats, error) {
|
||
s.mu.RLock()
|
||
txCount := len(s.packets)
|
||
obsCount := s.totalObs
|
||
s.mu.RUnlock()
|
||
|
||
st := &Stats{
|
||
TotalTransmissions: txCount,
|
||
TotalPackets: txCount,
|
||
TotalObservations: obsCount,
|
||
}
|
||
|
||
sevenDaysAgo := time.Now().Add(-7 * 24 * time.Hour).Format(time.RFC3339)
|
||
oneHourAgo := time.Now().Add(-1 * time.Hour).Unix()
|
||
oneDayAgo := time.Now().Add(-24 * time.Hour).Unix()
|
||
|
||
// Run node/observer counts and observation counts concurrently (2 queries instead of 5).
|
||
var wg sync.WaitGroup
|
||
var nodeErr, obsErr error
|
||
|
||
wg.Add(2)
|
||
go func() {
|
||
defer wg.Done()
|
||
nodeErr = s.db.conn.QueryRow(
|
||
`SELECT
|
||
(SELECT COUNT(*) FROM nodes WHERE last_seen > ?) AS active_nodes,
|
||
(SELECT COUNT(*) FROM nodes) AS all_nodes,
|
||
(SELECT COUNT(*) FROM observers) AS observers`,
|
||
sevenDaysAgo,
|
||
).Scan(&st.TotalNodes, &st.TotalNodesAllTime, &st.TotalObservers)
|
||
}()
|
||
go func() {
|
||
defer wg.Done()
|
||
obsErr = s.db.conn.QueryRow(
|
||
`SELECT
|
||
COALESCE(SUM(CASE WHEN timestamp > ? THEN 1 ELSE 0 END), 0),
|
||
COALESCE(SUM(CASE WHEN timestamp > ? THEN 1 ELSE 0 END), 0)
|
||
FROM observations WHERE timestamp > ?`,
|
||
oneHourAgo, oneDayAgo, oneDayAgo,
|
||
).Scan(&st.PacketsLastHour, &st.PacketsLast24h)
|
||
}()
|
||
wg.Wait()
|
||
|
||
if nodeErr != nil {
|
||
return st, nodeErr
|
||
}
|
||
if obsErr != nil {
|
||
return st, obsErr
|
||
}
|
||
|
||
return st, nil
|
||
}
|
||
|
||
// GetPerfStoreStats returns packet store statistics for /api/perf.
|
||
func (s *PacketStore) GetPerfStoreStats() map[string]interface{} {
|
||
s.mu.RLock()
|
||
totalLoaded := len(s.packets)
|
||
totalObs := s.totalObs
|
||
hashIdx := len(s.byHash)
|
||
txIdx := len(s.byTxID)
|
||
obsIdx := len(s.byObsID)
|
||
observerIdx := len(s.byObserver)
|
||
nodeIdx := len(s.byNode)
|
||
pathHopIdx := len(s.byPathHop)
|
||
ptIdx := len(s.byPayloadType)
|
||
|
||
// Distinct advert pubkey count — precomputed incrementally (see trackAdvertPubkey).
|
||
advertByObsCount := len(s.advertPubkeys)
|
||
s.mu.RUnlock()
|
||
|
||
estimatedMB := math.Round(s.estimatedMemoryMB()*10) / 10
|
||
|
||
evicted := atomic.LoadInt64(&s.evicted)
|
||
|
||
return map[string]interface{}{
|
||
"totalLoaded": totalLoaded,
|
||
"totalObservations": totalObs,
|
||
"evicted": evicted,
|
||
"inserts": atomic.LoadInt64(&s.insertCount),
|
||
"queries": atomic.LoadInt64(&s.queryCount),
|
||
"inMemory": totalLoaded,
|
||
"sqliteOnly": false,
|
||
"retentionHours": s.retentionHours,
|
||
"maxMemoryMB": s.maxMemoryMB,
|
||
"estimatedMB": estimatedMB,
|
||
"indexes": map[string]interface{}{
|
||
"byHash": hashIdx,
|
||
"byTxID": txIdx,
|
||
"byObsID": obsIdx,
|
||
"byObserver": observerIdx,
|
||
"byNode": nodeIdx,
|
||
"byPathHop": pathHopIdx,
|
||
"byPayloadType": ptIdx,
|
||
"advertByObserver": advertByObsCount,
|
||
},
|
||
}
|
||
}
|
||
|
||
// GetCacheStats returns RF cache hit/miss statistics.
|
||
func (s *PacketStore) GetCacheStats() map[string]interface{} {
|
||
s.cacheMu.Lock()
|
||
size := len(s.rfCache) + len(s.topoCache) + len(s.hashCache) + len(s.chanCache) + len(s.distCache) + len(s.subpathCache)
|
||
hits := s.cacheHits
|
||
misses := s.cacheMisses
|
||
s.cacheMu.Unlock()
|
||
|
||
var hitRate float64
|
||
if hits+misses > 0 {
|
||
hitRate = math.Round(float64(hits)/float64(hits+misses)*1000) / 10
|
||
}
|
||
|
||
return map[string]interface{}{
|
||
"size": size,
|
||
"hits": hits,
|
||
"misses": misses,
|
||
"staleHits": 0,
|
||
"recomputes": misses,
|
||
"hitRate": hitRate,
|
||
}
|
||
}
|
||
|
||
// GetCacheStatsTyped returns cache stats as a typed struct.
|
||
func (s *PacketStore) GetCacheStatsTyped() CacheStats {
|
||
s.cacheMu.Lock()
|
||
size := len(s.rfCache) + len(s.topoCache) + len(s.hashCache) + len(s.chanCache) + len(s.distCache) + len(s.subpathCache)
|
||
hits := s.cacheHits
|
||
misses := s.cacheMisses
|
||
s.cacheMu.Unlock()
|
||
|
||
var hitRate float64
|
||
if hits+misses > 0 {
|
||
hitRate = math.Round(float64(hits)/float64(hits+misses)*1000) / 10
|
||
}
|
||
|
||
return CacheStats{
|
||
Entries: size,
|
||
Hits: hits,
|
||
Misses: misses,
|
||
StaleHits: 0,
|
||
Recomputes: misses,
|
||
HitRate: hitRate,
|
||
}
|
||
}
|
||
|
||
// cacheInvalidation flags indicate what kind of data changed during ingestion.
|
||
// Used by invalidateCachesFor to selectively clear only affected caches.
|
||
type cacheInvalidation struct {
|
||
hasNewObservations bool // new SNR/RSSI data → rfCache
|
||
hasNewPaths bool // new/changed path data → topoCache, distCache, subpathCache
|
||
hasNewTransmissions bool // new transmissions → hashCache
|
||
hasChannelData bool // new GRP_TXT (payload_type 5) → chanCache
|
||
eviction bool // data removed → all caches
|
||
}
|
||
|
||
// invalidateCachesFor selectively clears only the analytics caches affected
|
||
// by the kind of data that changed. To prevent continuous ingestion from
|
||
// defeating caching entirely (issue #533), invalidation is rate-limited:
|
||
// if called within invCooldown of the last invalidation, the flags are
|
||
// accumulated in pendingInv and applied on the next call after cooldown.
|
||
func (s *PacketStore) invalidateCachesFor(inv cacheInvalidation) {
|
||
s.cacheMu.Lock()
|
||
defer s.cacheMu.Unlock()
|
||
|
||
// Eviction bypasses rate-limiting — data was removed, caches must clear.
|
||
if inv.eviction {
|
||
s.rfCache = make(map[string]*cachedResult)
|
||
s.topoCache = make(map[string]*cachedResult)
|
||
s.hashCache = make(map[string]*cachedResult)
|
||
s.collisionCache = make(map[string]*cachedResult)
|
||
s.chanCache = make(map[string]*cachedResult)
|
||
s.distCache = make(map[string]*cachedResult)
|
||
s.subpathCache = make(map[string]*cachedResult)
|
||
s.channelsCacheMu.Lock()
|
||
s.channelsCacheRes = nil
|
||
s.channelsCacheMu.Unlock()
|
||
s.lastInvalidated = time.Now()
|
||
s.pendingInv = nil
|
||
return
|
||
}
|
||
|
||
now := time.Now()
|
||
if now.Sub(s.lastInvalidated) < s.invCooldown {
|
||
// Within cooldown — accumulate dirty flags
|
||
if s.pendingInv == nil {
|
||
s.pendingInv = &cacheInvalidation{}
|
||
}
|
||
s.pendingInv.hasNewObservations = s.pendingInv.hasNewObservations || inv.hasNewObservations
|
||
s.pendingInv.hasNewPaths = s.pendingInv.hasNewPaths || inv.hasNewPaths
|
||
s.pendingInv.hasNewTransmissions = s.pendingInv.hasNewTransmissions || inv.hasNewTransmissions
|
||
s.pendingInv.hasChannelData = s.pendingInv.hasChannelData || inv.hasChannelData
|
||
return
|
||
}
|
||
|
||
// Cooldown expired — merge any pending flags and apply
|
||
if s.pendingInv != nil {
|
||
inv.hasNewObservations = inv.hasNewObservations || s.pendingInv.hasNewObservations
|
||
inv.hasNewPaths = inv.hasNewPaths || s.pendingInv.hasNewPaths
|
||
inv.hasNewTransmissions = inv.hasNewTransmissions || s.pendingInv.hasNewTransmissions
|
||
inv.hasChannelData = inv.hasChannelData || s.pendingInv.hasChannelData
|
||
s.pendingInv = nil
|
||
}
|
||
|
||
s.applyCacheInvalidation(inv)
|
||
s.lastInvalidated = now
|
||
}
|
||
|
||
// applyCacheInvalidation performs the actual cache clearing. Must be called
|
||
// with cacheMu held.
|
||
func (s *PacketStore) applyCacheInvalidation(inv cacheInvalidation) {
|
||
if inv.hasNewObservations {
|
||
s.rfCache = make(map[string]*cachedResult)
|
||
}
|
||
if inv.hasNewPaths {
|
||
s.topoCache = make(map[string]*cachedResult)
|
||
s.distCache = make(map[string]*cachedResult)
|
||
s.subpathCache = make(map[string]*cachedResult)
|
||
}
|
||
if inv.hasNewTransmissions {
|
||
s.hashCache = make(map[string]*cachedResult)
|
||
s.collisionCache = make(map[string]*cachedResult)
|
||
}
|
||
if inv.hasChannelData {
|
||
s.chanCache = make(map[string]*cachedResult)
|
||
s.channelsCacheMu.Lock()
|
||
s.channelsCacheRes = nil
|
||
s.channelsCacheMu.Unlock()
|
||
}
|
||
}
|
||
|
||
// GetPerfStoreStatsTyped returns packet store stats as a typed struct.
|
||
func (s *PacketStore) GetPerfStoreStatsTyped() PerfPacketStoreStats {
|
||
s.mu.RLock()
|
||
totalLoaded := len(s.packets)
|
||
totalObs := s.totalObs
|
||
hashIdx := len(s.byHash)
|
||
observerIdx := len(s.byObserver)
|
||
nodeIdx := len(s.byNode)
|
||
|
||
advertByObsCount := len(s.advertPubkeys)
|
||
s.mu.RUnlock()
|
||
|
||
estimatedMB := math.Round(s.estimatedMemoryMB()*10) / 10
|
||
|
||
return PerfPacketStoreStats{
|
||
TotalLoaded: totalLoaded,
|
||
TotalObservations: totalObs,
|
||
Evicted: int(atomic.LoadInt64(&s.evicted)),
|
||
Inserts: atomic.LoadInt64(&s.insertCount),
|
||
Queries: atomic.LoadInt64(&s.queryCount),
|
||
InMemory: totalLoaded,
|
||
SqliteOnly: false,
|
||
MaxPackets: 2386092,
|
||
EstimatedMB: estimatedMB,
|
||
MaxMB: s.maxMemoryMB,
|
||
Indexes: PacketStoreIndexes{
|
||
ByHash: hashIdx,
|
||
ByObserver: observerIdx,
|
||
ByNode: nodeIdx,
|
||
AdvertByObserver: advertByObsCount,
|
||
},
|
||
}
|
||
}
|
||
|
||
// GetTransmissionByID returns a transmission by its DB ID, formatted as a map.
|
||
func (s *PacketStore) GetTransmissionByID(id int) map[string]interface{} {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
tx := s.byTxID[id]
|
||
if tx == nil {
|
||
return nil
|
||
}
|
||
return txToMap(tx)
|
||
}
|
||
|
||
// GetPacketByHash returns a transmission by content hash.
|
||
func (s *PacketStore) GetPacketByHash(hash string) map[string]interface{} {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
tx := s.byHash[strings.ToLower(hash)]
|
||
if tx == nil {
|
||
return nil
|
||
}
|
||
return txToMap(tx)
|
||
}
|
||
|
||
// GetPacketByID returns an observation (enriched with transmission fields) by observation ID.
|
||
func (s *PacketStore) GetPacketByID(id int) map[string]interface{} {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
obs := s.byObsID[id]
|
||
if obs == nil {
|
||
return nil
|
||
}
|
||
return s.enrichObs(obs)
|
||
}
|
||
|
||
// GetObservationsForHash returns all observations for a hash, enriched with transmission fields.
|
||
func (s *PacketStore) GetObservationsForHash(hash string) []map[string]interface{} {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
tx := s.byHash[strings.ToLower(hash)]
|
||
if tx == nil {
|
||
return []map[string]interface{}{}
|
||
}
|
||
|
||
result := make([]map[string]interface{}, 0, len(tx.Observations))
|
||
for _, obs := range tx.Observations {
|
||
result = append(result, s.enrichObs(obs))
|
||
}
|
||
return result
|
||
}
|
||
|
||
// GetTimestamps returns transmission first_seen timestamps after since, in ASC order.
|
||
func (s *PacketStore) GetTimestamps(since string) []string {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
// packets sorted oldest-first — scan from tail until we reach items older than since
|
||
var result []string
|
||
for i := len(s.packets) - 1; i >= 0; i-- {
|
||
tx := s.packets[i]
|
||
if tx.FirstSeen <= since {
|
||
break
|
||
}
|
||
result = append(result, tx.FirstSeen)
|
||
}
|
||
// result is currently newest-first; reverse to return ASC order
|
||
for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
|
||
result[i], result[j] = result[j], result[i]
|
||
}
|
||
return result
|
||
}
|
||
|
||
// QueryMultiNodePackets filters packets matching any of the given pubkeys.
|
||
func (s *PacketStore) QueryMultiNodePackets(pubkeys []string, limit, offset int, order, since, until string) *PacketResult {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
if len(pubkeys) == 0 {
|
||
return &PacketResult{Packets: []map[string]interface{}{}, Total: 0}
|
||
}
|
||
if limit <= 0 {
|
||
limit = 50
|
||
}
|
||
|
||
resolved := make([]string, len(pubkeys))
|
||
for i, pk := range pubkeys {
|
||
resolved[i] = s.db.resolveNodePubkey(pk)
|
||
}
|
||
|
||
var filtered []*StoreTx
|
||
for _, tx := range s.packets {
|
||
if tx.DecodedJSON == "" {
|
||
continue
|
||
}
|
||
match := false
|
||
for _, pk := range resolved {
|
||
if strings.Contains(tx.DecodedJSON, pk) {
|
||
match = true
|
||
break
|
||
}
|
||
}
|
||
if !match {
|
||
continue
|
||
}
|
||
if since != "" && tx.FirstSeen < since {
|
||
continue
|
||
}
|
||
if until != "" && tx.FirstSeen > until {
|
||
continue
|
||
}
|
||
filtered = append(filtered, tx)
|
||
}
|
||
|
||
total := len(filtered)
|
||
|
||
// filtered is oldest-first (built by iterating s.packets forward).
|
||
// Apply same DESC/ASC pagination logic as QueryPackets.
|
||
if offset >= total {
|
||
return &PacketResult{Packets: []map[string]interface{}{}, Total: total}
|
||
}
|
||
pageSize := limit
|
||
if offset+pageSize > total {
|
||
pageSize = total - offset
|
||
}
|
||
|
||
packets := make([]map[string]interface{}, 0, pageSize)
|
||
if order == "ASC" {
|
||
for _, tx := range filtered[offset : offset+pageSize] {
|
||
packets = append(packets, txToMap(tx))
|
||
}
|
||
} else {
|
||
endIdx := total - offset
|
||
startIdx := endIdx - pageSize
|
||
if startIdx < 0 {
|
||
startIdx = 0
|
||
}
|
||
for i := endIdx - 1; i >= startIdx; i-- {
|
||
packets = append(packets, txToMap(filtered[i]))
|
||
}
|
||
}
|
||
return &PacketResult{Packets: packets, Total: total}
|
||
}
|
||
|
||
// IngestNewFromDB loads new transmissions from SQLite into memory and returns
|
||
// broadcast-ready maps plus the new max transmission ID.
|
||
func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interface{}, int) {
|
||
if limit <= 0 {
|
||
limit = 100
|
||
}
|
||
|
||
// NOTE: The SQL query intentionally does NOT select resolved_path from the DB.
|
||
// New ingests always resolve fresh using the current prefix map and neighbor graph.
|
||
// On restart, Load() handles reading persisted resolved_path values. (review item #7)
|
||
var querySQL string
|
||
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, o.direction,
|
||
o.snr, o.rssi, o.score, o.path_json, strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch')
|
||
FROM transmissions t
|
||
LEFT JOIN observations o ON o.transmission_id = t.id
|
||
LEFT JOIN observers obs ON obs.rowid = o.observer_idx
|
||
WHERE t.id > ?
|
||
ORDER BY t.id ASC, o.timestamp DESC`
|
||
} else {
|
||
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, o.direction,
|
||
o.snr, o.rssi, o.score, o.path_json, o.timestamp
|
||
FROM transmissions t
|
||
LEFT JOIN observations o ON o.transmission_id = t.id
|
||
WHERE t.id > ?
|
||
ORDER BY t.id ASC, o.timestamp DESC`
|
||
}
|
||
|
||
rows, err := s.db.conn.Query(querySQL, sinceID)
|
||
if err != nil {
|
||
log.Printf("[store] ingest query error: %v", err)
|
||
return nil, sinceID
|
||
}
|
||
defer rows.Close()
|
||
|
||
// Scan into temp structures
|
||
type tempRow struct {
|
||
txID int
|
||
rawHex, hash, firstSeen, decodedJSON string
|
||
routeType, payloadType *int
|
||
obsID *int
|
||
observerID, observerName, direction, pathJSON, obsTS string
|
||
snr, rssi *float64
|
||
score *int
|
||
}
|
||
|
||
var tempRows []tempRow
|
||
txCount := 0
|
||
lastTxID := sinceID
|
||
|
||
for rows.Next() {
|
||
var txID int
|
||
var rawHex, hash, firstSeen, decodedJSON sql.NullString
|
||
var routeType, payloadType, payloadVersion sql.NullInt64
|
||
var obsIDVal sql.NullInt64
|
||
var observerID, observerName, direction, pathJSON, obsTimestamp sql.NullString
|
||
var snrVal, rssiVal sql.NullFloat64
|
||
var scoreVal sql.NullInt64
|
||
|
||
if err := rows.Scan(&txID, &rawHex, &hash, &firstSeen, &routeType, &payloadType,
|
||
&payloadVersion, &decodedJSON,
|
||
&obsIDVal, &observerID, &observerName, &direction,
|
||
&snrVal, &rssiVal, &scoreVal, &pathJSON, &obsTimestamp); err != nil {
|
||
continue
|
||
}
|
||
|
||
if txID != lastTxID {
|
||
txCount++
|
||
if txCount > limit {
|
||
break
|
||
}
|
||
lastTxID = txID
|
||
}
|
||
|
||
tr := tempRow{
|
||
txID: txID,
|
||
rawHex: nullStrVal(rawHex),
|
||
hash: nullStrVal(hash),
|
||
firstSeen: nullStrVal(firstSeen),
|
||
decodedJSON: nullStrVal(decodedJSON),
|
||
routeType: nullIntPtr(routeType),
|
||
payloadType: nullIntPtr(payloadType),
|
||
observerID: nullStrVal(observerID),
|
||
observerName: nullStrVal(observerName),
|
||
direction: nullStrVal(direction),
|
||
pathJSON: nullStrVal(pathJSON),
|
||
obsTS: nullStrVal(obsTimestamp),
|
||
snr: nullFloatPtr(snrVal),
|
||
rssi: nullFloatPtr(rssiVal),
|
||
score: nullIntPtr(scoreVal),
|
||
}
|
||
if obsIDVal.Valid {
|
||
oid := int(obsIDVal.Int64)
|
||
tr.obsID = &oid
|
||
}
|
||
tempRows = append(tempRows, tr)
|
||
}
|
||
|
||
if len(tempRows) == 0 {
|
||
return nil, sinceID
|
||
}
|
||
|
||
// Now lock and merge into store
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
|
||
newMaxID := sinceID
|
||
broadcastTxs := make(map[int]*StoreTx) // track new transmissions for broadcast
|
||
var broadcastOrder []int
|
||
|
||
// Hoist getCachedNodesAndPM() once before the observation loop to avoid
|
||
// per-observation function calls (review item #1).
|
||
_, cachedPM := s.getCachedNodesAndPM()
|
||
|
||
for _, r := range tempRows {
|
||
if r.txID > newMaxID {
|
||
newMaxID = r.txID
|
||
}
|
||
|
||
tx := s.byHash[r.hash]
|
||
if tx == nil {
|
||
tx = &StoreTx{
|
||
ID: r.txID,
|
||
RawHex: r.rawHex,
|
||
Hash: r.hash,
|
||
FirstSeen: r.firstSeen,
|
||
LatestSeen: r.firstSeen,
|
||
RouteType: r.routeType,
|
||
PayloadType: r.payloadType,
|
||
DecodedJSON: r.decodedJSON,
|
||
obsKeys: make(map[string]bool),
|
||
}
|
||
s.byHash[r.hash] = tx
|
||
s.packets = append(s.packets, tx) // oldest-first; new items go to tail
|
||
s.byTxID[r.txID] = tx
|
||
if r.txID > s.maxTxID {
|
||
s.maxTxID = r.txID
|
||
}
|
||
s.indexByNode(tx)
|
||
if tx.PayloadType != nil {
|
||
pt := *tx.PayloadType
|
||
// Append to maintain oldest-first order (matches Load ordering)
|
||
// so GetChannelMessages reverse iteration stays correct
|
||
s.byPayloadType[pt] = append(s.byPayloadType[pt], tx)
|
||
}
|
||
s.trackAdvertPubkey(tx)
|
||
|
||
if _, exists := broadcastTxs[r.txID]; !exists {
|
||
broadcastTxs[r.txID] = tx
|
||
broadcastOrder = append(broadcastOrder, r.txID)
|
||
}
|
||
}
|
||
|
||
if r.obsID != nil {
|
||
oid := *r.obsID
|
||
// Dedup (O(1) map lookup)
|
||
dk := r.observerID + "|" + r.pathJSON
|
||
if tx.obsKeys == nil {
|
||
tx.obsKeys = make(map[string]bool)
|
||
}
|
||
if tx.obsKeys[dk] {
|
||
continue
|
||
}
|
||
|
||
obs := &StoreObs{
|
||
ID: oid,
|
||
TransmissionID: r.txID,
|
||
ObserverID: r.observerID,
|
||
ObserverName: r.observerName,
|
||
Direction: r.direction,
|
||
SNR: r.snr,
|
||
RSSI: r.rssi,
|
||
Score: r.score,
|
||
PathJSON: r.pathJSON,
|
||
Timestamp: normalizeTimestamp(r.obsTS),
|
||
}
|
||
|
||
// Resolve path at ingest time using neighbor graph
|
||
// (cachedPM is hoisted before the observation loop to avoid per-obs function calls)
|
||
if r.pathJSON != "" && r.pathJSON != "[]" && cachedPM != nil {
|
||
obs.ResolvedPath = resolvePathForObs(r.pathJSON, r.observerID, tx, cachedPM, s.graph)
|
||
}
|
||
|
||
tx.Observations = append(tx.Observations, obs)
|
||
tx.obsKeys[dk] = true
|
||
tx.ObservationCount++
|
||
if obs.Timestamp > tx.LatestSeen {
|
||
tx.LatestSeen = obs.Timestamp
|
||
}
|
||
s.byObsID[oid] = obs
|
||
if oid > s.maxObsID {
|
||
s.maxObsID = oid
|
||
}
|
||
if r.observerID != "" {
|
||
s.byObserver[r.observerID] = append(s.byObserver[r.observerID], obs)
|
||
}
|
||
s.totalObs++
|
||
}
|
||
}
|
||
|
||
// Pick best observation for new transmissions
|
||
for _, tx := range broadcastTxs {
|
||
pickBestObservation(tx)
|
||
}
|
||
|
||
// Incrementally update precomputed subpath index with new transmissions
|
||
for _, tx := range broadcastTxs {
|
||
if addTxToSubpathIndexFull(s.spIndex, s.spTxIndex, tx) {
|
||
s.spTotalPaths++
|
||
}
|
||
addTxToPathHopIndex(s.byPathHop, tx)
|
||
}
|
||
|
||
// Incrementally update precomputed distance index with new transmissions
|
||
if len(broadcastTxs) > 0 {
|
||
allNodes, pm := s.getCachedNodesAndPM()
|
||
nodeByPk := make(map[string]*nodeInfo, len(allNodes))
|
||
repeaterSet := make(map[string]bool)
|
||
for i := range allNodes {
|
||
n := &allNodes[i]
|
||
nodeByPk[n.PublicKey] = n
|
||
if strings.Contains(strings.ToLower(n.Role), "repeater") {
|
||
repeaterSet[n.PublicKey] = true
|
||
}
|
||
}
|
||
hopCache := make(map[string]*nodeInfo)
|
||
resolveHop := func(hop string) *nodeInfo {
|
||
if cached, ok := hopCache[hop]; ok {
|
||
return cached
|
||
}
|
||
r, _, _ := pm.resolveWithContext(hop, nil, s.graph)
|
||
hopCache[hop] = r
|
||
return r
|
||
}
|
||
for _, tx := range broadcastTxs {
|
||
txHops, txPath := computeDistancesForTx(tx, nodeByPk, repeaterSet, resolveHop)
|
||
if len(txHops) > 0 {
|
||
s.distHops = append(s.distHops, txHops...)
|
||
}
|
||
if txPath != nil {
|
||
s.distPaths = append(s.distPaths, *txPath)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Build broadcast maps (same shape as Node.js WS broadcast), one per observation.
|
||
result := make([]map[string]interface{}, 0, len(broadcastOrder))
|
||
for _, txID := range broadcastOrder {
|
||
tx := broadcastTxs[txID]
|
||
// Build decoded object with header.payloadTypeName for live.js
|
||
decoded := map[string]interface{}{
|
||
"header": map[string]interface{}{
|
||
"payloadTypeName": resolvePayloadTypeName(tx.PayloadType),
|
||
},
|
||
}
|
||
if tx.DecodedJSON != "" {
|
||
var payload map[string]interface{}
|
||
if json.Unmarshal([]byte(tx.DecodedJSON), &payload) == nil {
|
||
decoded["payload"] = payload
|
||
}
|
||
}
|
||
for _, obs := range tx.Observations {
|
||
// Build the nested packet object (packets.js checks m.data.packet)
|
||
pkt := map[string]interface{}{
|
||
"id": tx.ID,
|
||
"raw_hex": strOrNil(tx.RawHex),
|
||
"hash": strOrNil(tx.Hash),
|
||
"first_seen": strOrNil(tx.FirstSeen),
|
||
"timestamp": strOrNil(tx.FirstSeen),
|
||
"route_type": intPtrOrNil(tx.RouteType),
|
||
"payload_type": intPtrOrNil(tx.PayloadType),
|
||
"decoded_json": strOrNil(tx.DecodedJSON),
|
||
"observer_id": strOrNil(obs.ObserverID),
|
||
"observer_name": strOrNil(obs.ObserverName),
|
||
"snr": floatPtrOrNil(obs.SNR),
|
||
"rssi": floatPtrOrNil(obs.RSSI),
|
||
"path_json": strOrNil(obs.PathJSON),
|
||
"direction": strOrNil(obs.Direction),
|
||
"observation_count": tx.ObservationCount,
|
||
}
|
||
if obs.ResolvedPath != nil {
|
||
pkt["resolved_path"] = obs.ResolvedPath
|
||
}
|
||
// Broadcast map: top-level fields for live.js + nested packet for packets.js
|
||
broadcastMap := make(map[string]interface{}, len(pkt)+2)
|
||
for k, v := range pkt {
|
||
broadcastMap[k] = v
|
||
}
|
||
broadcastMap["decoded"] = decoded
|
||
broadcastMap["packet"] = pkt
|
||
result = append(result, broadcastMap)
|
||
}
|
||
}
|
||
|
||
// Targeted cache invalidation: only clear caches affected by the ingested
|
||
// data instead of wiping everything on every cycle (fixes #375).
|
||
if len(result) > 0 {
|
||
inv := cacheInvalidation{
|
||
hasNewTransmissions: len(broadcastTxs) > 0,
|
||
}
|
||
for _, tx := range broadcastTxs {
|
||
if len(tx.Observations) > 0 {
|
||
inv.hasNewObservations = true
|
||
}
|
||
if tx.PayloadType != nil && *tx.PayloadType == 5 {
|
||
inv.hasChannelData = true
|
||
}
|
||
if tx.PathJSON != "" {
|
||
inv.hasNewPaths = true
|
||
}
|
||
if inv.hasNewObservations && inv.hasChannelData && inv.hasNewPaths {
|
||
break // all flags set, no need to continue
|
||
}
|
||
}
|
||
s.invalidateCachesFor(inv)
|
||
}
|
||
|
||
// Persist resolved paths and neighbor edges asynchronously (don't block ingest).
|
||
if len(broadcastTxs) > 0 && s.db != nil {
|
||
dbPath := s.db.path
|
||
var obsUpdates []persistObsUpdate
|
||
var edgeUpdates []persistEdgeUpdate
|
||
|
||
_, pm := s.getCachedNodesAndPM()
|
||
// Read graph ref under lock (it's set during startup and not replaced after,
|
||
// but reading under lock is safer — review item #5).
|
||
graphRef := s.graph
|
||
for _, tx := range broadcastTxs {
|
||
for _, obs := range tx.Observations {
|
||
if obs.ResolvedPath != nil {
|
||
rpJSON := marshalResolvedPath(obs.ResolvedPath)
|
||
if rpJSON != "" {
|
||
obsUpdates = append(obsUpdates, persistObsUpdate{obs.ID, rpJSON})
|
||
}
|
||
}
|
||
for _, ec := range extractEdgesFromObs(obs, tx, pm) {
|
||
edgeUpdates = append(edgeUpdates, persistEdgeUpdate{ec.A, ec.B, ec.Timestamp})
|
||
if graphRef != nil {
|
||
graphRef.upsertEdge(ec.A, ec.B, "", obs.ObserverID, obs.SNR, parseTimestamp(ec.Timestamp))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
asyncPersistResolvedPathsAndEdges(dbPath, obsUpdates, edgeUpdates, "persist")
|
||
}
|
||
|
||
return result, newMaxID
|
||
}
|
||
|
||
// IngestNewObservations loads new observations for transmissions already in the
|
||
// store. This catches observations that arrive after IngestNewFromDB has already
|
||
// advanced past the transmission's ID (fixes #174).
|
||
func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]interface{} {
|
||
if limit <= 0 {
|
||
limit = 500
|
||
}
|
||
|
||
var querySQL string
|
||
if s.db.isV3 {
|
||
querySQL = `SELECT o.id, o.transmission_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')
|
||
FROM observations o
|
||
LEFT JOIN observers obs ON obs.rowid = o.observer_idx
|
||
WHERE o.id > ?
|
||
ORDER BY o.id ASC
|
||
LIMIT ?`
|
||
} else {
|
||
querySQL = `SELECT o.id, o.transmission_id, o.observer_id, o.observer_name, o.direction,
|
||
o.snr, o.rssi, o.score, o.path_json, o.timestamp
|
||
FROM observations o
|
||
WHERE o.id > ?
|
||
ORDER BY o.id ASC
|
||
LIMIT ?`
|
||
}
|
||
|
||
rows, err := s.db.conn.Query(querySQL, sinceObsID, limit)
|
||
if err != nil {
|
||
log.Printf("[store] ingest observations query error: %v", err)
|
||
return nil
|
||
}
|
||
defer rows.Close()
|
||
|
||
type obsRow struct {
|
||
obsID int
|
||
txID int
|
||
observerID string
|
||
observerName string
|
||
direction string
|
||
snr, rssi *float64
|
||
score *int
|
||
pathJSON string
|
||
timestamp string
|
||
}
|
||
|
||
var obsRows []obsRow
|
||
for rows.Next() {
|
||
var oid, txID int
|
||
var observerID, observerName, direction, pathJSON, ts sql.NullString
|
||
var snr, rssi sql.NullFloat64
|
||
var score sql.NullInt64
|
||
|
||
if err := rows.Scan(&oid, &txID, &observerID, &observerName, &direction,
|
||
&snr, &rssi, &score, &pathJSON, &ts); err != nil {
|
||
continue
|
||
}
|
||
|
||
obsRows = append(obsRows, obsRow{
|
||
obsID: oid,
|
||
txID: txID,
|
||
observerID: nullStrVal(observerID),
|
||
observerName: nullStrVal(observerName),
|
||
direction: nullStrVal(direction),
|
||
snr: nullFloatPtr(snr),
|
||
rssi: nullFloatPtr(rssi),
|
||
score: nullIntPtr(score),
|
||
pathJSON: nullStrVal(pathJSON),
|
||
timestamp: nullStrVal(ts),
|
||
})
|
||
}
|
||
|
||
if len(obsRows) == 0 {
|
||
return nil
|
||
}
|
||
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
|
||
updatedTxs := make(map[int]*StoreTx)
|
||
broadcastMaps := make([]map[string]interface{}, 0, len(obsRows))
|
||
// Track newly created observations for persistence — only these should be
|
||
// persisted, not all observations of each updated tx (fixes edge count inflation).
|
||
var newObs []*StoreObs
|
||
|
||
// Hoist getCachedNodesAndPM() before the loop — same pattern as IngestNewFromDB (review fix #1).
|
||
_, pm := s.getCachedNodesAndPM()
|
||
graphRef := s.graph
|
||
|
||
for _, r := range obsRows {
|
||
// Already ingested (e.g. by IngestNewFromDB in same cycle)
|
||
if _, exists := s.byObsID[r.obsID]; exists {
|
||
continue
|
||
}
|
||
|
||
tx := s.byTxID[r.txID]
|
||
if tx == nil {
|
||
continue // transmission not yet in store
|
||
}
|
||
|
||
// Dedup by observer + path (O(1) map lookup)
|
||
dk := r.observerID + "|" + r.pathJSON
|
||
if tx.obsKeys == nil {
|
||
tx.obsKeys = make(map[string]bool)
|
||
}
|
||
if tx.obsKeys[dk] {
|
||
continue
|
||
}
|
||
|
||
obs := &StoreObs{
|
||
ID: r.obsID,
|
||
TransmissionID: r.txID,
|
||
ObserverID: r.observerID,
|
||
ObserverName: r.observerName,
|
||
Direction: r.direction,
|
||
SNR: r.snr,
|
||
RSSI: r.rssi,
|
||
Score: r.score,
|
||
PathJSON: r.pathJSON,
|
||
Timestamp: normalizeTimestamp(r.timestamp),
|
||
}
|
||
|
||
// Resolve path at ingest time for late-arriving observations (review item #2).
|
||
if r.pathJSON != "" && r.pathJSON != "[]" {
|
||
if pm != nil {
|
||
obs.ResolvedPath = resolvePathForObs(r.pathJSON, r.observerID, tx, pm, s.graph)
|
||
}
|
||
}
|
||
|
||
tx.Observations = append(tx.Observations, obs)
|
||
tx.obsKeys[dk] = true
|
||
tx.ObservationCount++
|
||
newObs = append(newObs, obs)
|
||
if obs.Timestamp > tx.LatestSeen {
|
||
tx.LatestSeen = obs.Timestamp
|
||
}
|
||
s.byObsID[r.obsID] = obs
|
||
if r.obsID > s.maxObsID {
|
||
s.maxObsID = r.obsID
|
||
}
|
||
if r.observerID != "" {
|
||
s.byObserver[r.observerID] = append(s.byObserver[r.observerID], obs)
|
||
}
|
||
s.totalObs++
|
||
updatedTxs[r.txID] = tx
|
||
|
||
decoded := map[string]interface{}{
|
||
"header": map[string]interface{}{
|
||
"payloadTypeName": resolvePayloadTypeName(tx.PayloadType),
|
||
},
|
||
}
|
||
if tx.DecodedJSON != "" {
|
||
var payload map[string]interface{}
|
||
if json.Unmarshal([]byte(tx.DecodedJSON), &payload) == nil {
|
||
decoded["payload"] = payload
|
||
}
|
||
}
|
||
|
||
pkt := map[string]interface{}{
|
||
"id": tx.ID,
|
||
"raw_hex": strOrNil(tx.RawHex),
|
||
"hash": strOrNil(tx.Hash),
|
||
"first_seen": strOrNil(tx.FirstSeen),
|
||
"timestamp": strOrNil(tx.FirstSeen),
|
||
"route_type": intPtrOrNil(tx.RouteType),
|
||
"payload_type": intPtrOrNil(tx.PayloadType),
|
||
"decoded_json": strOrNil(tx.DecodedJSON),
|
||
"observer_id": strOrNil(obs.ObserverID),
|
||
"observer_name": strOrNil(obs.ObserverName),
|
||
"snr": floatPtrOrNil(obs.SNR),
|
||
"rssi": floatPtrOrNil(obs.RSSI),
|
||
"path_json": strOrNil(obs.PathJSON),
|
||
"direction": strOrNil(obs.Direction),
|
||
"observation_count": tx.ObservationCount,
|
||
}
|
||
if obs.ResolvedPath != nil {
|
||
pkt["resolved_path"] = obs.ResolvedPath
|
||
}
|
||
broadcastMap := make(map[string]interface{}, len(pkt)+2)
|
||
for k, v := range pkt {
|
||
broadcastMap[k] = v
|
||
}
|
||
broadcastMap["decoded"] = decoded
|
||
broadcastMap["packet"] = pkt
|
||
broadcastMaps = append(broadcastMaps, broadcastMap)
|
||
}
|
||
|
||
// Re-pick best observation for updated transmissions and update subpath index
|
||
// if the path changed.
|
||
oldPaths := make(map[int]string, len(updatedTxs))
|
||
oldResolvedPaths := make(map[int][]*string, len(updatedTxs))
|
||
for txID, tx := range updatedTxs {
|
||
oldPaths[txID] = tx.PathJSON
|
||
oldResolvedPaths[txID] = tx.ResolvedPath
|
||
}
|
||
for _, tx := range updatedTxs {
|
||
pickBestObservation(tx)
|
||
}
|
||
for txID, tx := range updatedTxs {
|
||
if tx.PathJSON != oldPaths[txID] {
|
||
// Path changed — remove old subpaths, add new ones.
|
||
oldHops := parsePathJSON(oldPaths[txID])
|
||
if len(oldHops) >= 2 {
|
||
// Temporarily set parsedPath to old hops for removal.
|
||
saved, savedFlag := tx.parsedPath, tx.pathParsed
|
||
tx.parsedPath, tx.pathParsed = oldHops, true
|
||
if removeTxFromSubpathIndexFull(s.spIndex, s.spTxIndex, tx) {
|
||
s.spTotalPaths--
|
||
}
|
||
tx.parsedPath, tx.pathParsed = saved, savedFlag
|
||
}
|
||
// Remove old path-hop index entries using old hops + old resolved path.
|
||
if len(oldHops) > 0 {
|
||
saved, savedFlag := tx.parsedPath, tx.pathParsed
|
||
savedRP := tx.ResolvedPath
|
||
tx.parsedPath, tx.pathParsed = oldHops, true
|
||
tx.ResolvedPath = oldResolvedPaths[txID]
|
||
removeTxFromPathHopIndex(s.byPathHop, tx)
|
||
tx.parsedPath, tx.pathParsed = saved, savedFlag
|
||
tx.ResolvedPath = savedRP
|
||
}
|
||
// pickBestObservation already set pathParsed=false so
|
||
// addTxToSubpathIndex will re-parse the new path.
|
||
if addTxToSubpathIndexFull(s.spIndex, s.spTxIndex, tx) {
|
||
s.spTotalPaths++
|
||
}
|
||
addTxToPathHopIndex(s.byPathHop, tx)
|
||
}
|
||
}
|
||
|
||
// Check if any paths changed (used for distance update and cache invalidation).
|
||
hasPathChanges := false
|
||
var changedTxs []*StoreTx
|
||
for txID, tx := range updatedTxs {
|
||
if tx.PathJSON != oldPaths[txID] {
|
||
hasPathChanges = true
|
||
changedTxs = append(changedTxs, tx)
|
||
}
|
||
}
|
||
if len(changedTxs) > 0 {
|
||
s.updateDistanceIndexForTxs(changedTxs)
|
||
}
|
||
|
||
if len(updatedTxs) > 0 {
|
||
// Targeted cache invalidation: new observations always affect RF
|
||
// analytics; topology/distance/subpath caches only if paths changed.
|
||
// Channel and hash caches are unaffected by observation-only ingestion.
|
||
s.invalidateCachesFor(cacheInvalidation{
|
||
hasNewObservations: true,
|
||
hasNewPaths: hasPathChanges,
|
||
})
|
||
}
|
||
|
||
// Persist resolved paths and neighbor edges asynchronously (review fix #3).
|
||
// Only process NEW observations — not all observations of each updated tx —
|
||
// to avoid edge count inflation and unnecessary UPDATEs for pre-existing data.
|
||
if len(newObs) > 0 && s.db != nil {
|
||
dbPath := s.db.path
|
||
var obsUpdates []persistObsUpdate
|
||
var edgeUpdates []persistEdgeUpdate
|
||
|
||
for _, obs := range newObs {
|
||
tx := s.byTxID[obs.TransmissionID]
|
||
if tx == nil {
|
||
continue
|
||
}
|
||
if obs.ResolvedPath != nil {
|
||
rpJSON := marshalResolvedPath(obs.ResolvedPath)
|
||
if rpJSON != "" {
|
||
obsUpdates = append(obsUpdates, persistObsUpdate{obs.ID, rpJSON})
|
||
}
|
||
}
|
||
for _, ec := range extractEdgesFromObs(obs, tx, pm) {
|
||
edgeUpdates = append(edgeUpdates, persistEdgeUpdate{ec.A, ec.B, ec.Timestamp})
|
||
if graphRef != nil {
|
||
graphRef.upsertEdge(ec.A, ec.B, "", obs.ObserverID, obs.SNR, parseTimestamp(ec.Timestamp))
|
||
}
|
||
}
|
||
}
|
||
|
||
asyncPersistResolvedPathsAndEdges(dbPath, obsUpdates, edgeUpdates, "obs-persist")
|
||
}
|
||
|
||
return broadcastMaps
|
||
}
|
||
|
||
// MaxTransmissionID returns the highest transmission ID in the store.
|
||
func (s *PacketStore) MaxTransmissionID() int {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
return s.maxTxID
|
||
}
|
||
|
||
// MaxObservationID returns the highest observation ID in the store.
|
||
func (s *PacketStore) MaxObservationID() int {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
return s.maxObsID
|
||
}
|
||
|
||
// --- Internal filter/query helpers ---
|
||
|
||
// filterPackets applies PacketQuery filters to the in-memory packet list.
|
||
func (s *PacketStore) filterPackets(q PacketQuery) []*StoreTx {
|
||
// Fast path: single-key index lookups
|
||
if q.Hash != "" && q.Type == nil && q.Route == nil && q.Observer == "" &&
|
||
q.Region == "" && q.Node == "" && q.Since == "" && q.Until == "" {
|
||
h := strings.ToLower(q.Hash)
|
||
tx := s.byHash[h]
|
||
if tx == nil {
|
||
return nil
|
||
}
|
||
return []*StoreTx{tx}
|
||
}
|
||
if q.Observer != "" && q.Type == nil && q.Route == nil &&
|
||
q.Region == "" && q.Node == "" && q.Hash == "" && q.Since == "" && q.Until == "" {
|
||
return s.transmissionsForObserver(q.Observer, nil)
|
||
}
|
||
|
||
results := s.packets
|
||
|
||
if q.Type != nil {
|
||
t := *q.Type
|
||
results = filterTxSlice(results, func(tx *StoreTx) bool {
|
||
return tx.PayloadType != nil && *tx.PayloadType == t
|
||
})
|
||
}
|
||
if q.Route != nil {
|
||
r := *q.Route
|
||
results = filterTxSlice(results, func(tx *StoreTx) bool {
|
||
return tx.RouteType != nil && *tx.RouteType == r
|
||
})
|
||
}
|
||
if q.Observer != "" {
|
||
results = s.transmissionsForObserver(q.Observer, results)
|
||
}
|
||
if q.Hash != "" {
|
||
h := strings.ToLower(q.Hash)
|
||
results = filterTxSlice(results, func(tx *StoreTx) bool {
|
||
return tx.Hash == h
|
||
})
|
||
}
|
||
if q.Since != "" {
|
||
results = filterTxSlice(results, func(tx *StoreTx) bool {
|
||
return tx.FirstSeen > q.Since
|
||
})
|
||
}
|
||
if q.Until != "" {
|
||
results = filterTxSlice(results, func(tx *StoreTx) bool {
|
||
return tx.FirstSeen < q.Until
|
||
})
|
||
}
|
||
if q.Region != "" {
|
||
regionObservers := s.resolveRegionObservers(q.Region)
|
||
if len(regionObservers) > 0 {
|
||
results = filterTxSlice(results, func(tx *StoreTx) bool {
|
||
for _, obs := range tx.Observations {
|
||
if regionObservers[obs.ObserverID] {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
})
|
||
} else {
|
||
results = nil
|
||
}
|
||
}
|
||
if q.Node != "" {
|
||
pk := s.db.resolveNodePubkey(q.Node)
|
||
// Use node index if available
|
||
if indexed, ok := s.byNode[pk]; ok && results == nil {
|
||
results = indexed
|
||
} else {
|
||
results = filterTxSlice(results, func(tx *StoreTx) bool {
|
||
if tx.DecodedJSON == "" {
|
||
return false
|
||
}
|
||
return strings.Contains(tx.DecodedJSON, pk) || strings.Contains(tx.DecodedJSON, q.Node)
|
||
})
|
||
}
|
||
}
|
||
|
||
return results
|
||
}
|
||
|
||
// transmissionsForObserver returns unique transmissions for an observer.
|
||
func (s *PacketStore) transmissionsForObserver(observerIDs string, from []*StoreTx) []*StoreTx {
|
||
ids := strings.Split(observerIDs, ",")
|
||
idSet := make(map[string]bool, len(ids))
|
||
for i, id := range ids {
|
||
ids[i] = strings.TrimSpace(id)
|
||
idSet[ids[i]] = true
|
||
}
|
||
if from != nil {
|
||
return filterTxSlice(from, func(tx *StoreTx) bool {
|
||
for _, obs := range tx.Observations {
|
||
if idSet[obs.ObserverID] {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
})
|
||
}
|
||
// Use byObserver index: union transmissions for all IDs
|
||
seen := make(map[int]bool)
|
||
var result []*StoreTx
|
||
for _, id := range ids {
|
||
for _, obs := range s.byObserver[id] {
|
||
if seen[obs.TransmissionID] {
|
||
continue
|
||
}
|
||
seen[obs.TransmissionID] = true
|
||
tx := s.byTxID[obs.TransmissionID]
|
||
if tx != nil {
|
||
result = append(result, tx)
|
||
}
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
// resolveRegionObservers returns a set of observer IDs for a given IATA region.
|
||
// Results are cached for 30 seconds to avoid repeated DB queries.
|
||
// Uses its own mutex (regionObsMu) so callers holding s.mu won't deadlock.
|
||
func (s *PacketStore) resolveRegionObservers(region string) map[string]bool {
|
||
s.regionObsMu.Lock()
|
||
defer s.regionObsMu.Unlock()
|
||
|
||
if s.regionObsCache != nil && time.Since(s.regionObsCacheTime) < 30*time.Second {
|
||
if m, ok := s.regionObsCache[region]; ok {
|
||
return m
|
||
}
|
||
return s.fetchAndCacheRegionObs(region)
|
||
}
|
||
// Cache expired — rebuild.
|
||
s.regionObsCache = make(map[string]map[string]bool)
|
||
s.regionObsCacheTime = time.Now()
|
||
|
||
// Fetch for the requested region and cache it.
|
||
return s.fetchAndCacheRegionObs(region)
|
||
}
|
||
|
||
// fetchAndCacheRegionObs fetches observer IDs for a region from the DB and stores in cache.
|
||
// Caller must hold regionObsMu.
|
||
func (s *PacketStore) fetchAndCacheRegionObs(region string) map[string]bool {
|
||
if m, ok := s.regionObsCache[region]; ok {
|
||
return m
|
||
}
|
||
ids, err := s.db.GetObserverIdsForRegion(region)
|
||
if err != nil || len(ids) == 0 {
|
||
s.regionObsCache[region] = nil
|
||
return nil
|
||
}
|
||
m := make(map[string]bool, len(ids))
|
||
for _, id := range ids {
|
||
m[id] = true
|
||
}
|
||
s.regionObsCache[region] = m
|
||
return m
|
||
}
|
||
|
||
// enrichObs returns a map with observation fields + transmission fields.
|
||
func (s *PacketStore) enrichObs(obs *StoreObs) map[string]interface{} {
|
||
tx := s.byTxID[obs.TransmissionID]
|
||
|
||
m := map[string]interface{}{
|
||
"id": obs.ID,
|
||
"timestamp": strOrNil(obs.Timestamp),
|
||
"observer_id": strOrNil(obs.ObserverID),
|
||
"observer_name": strOrNil(obs.ObserverName),
|
||
"direction": strOrNil(obs.Direction),
|
||
"snr": floatPtrOrNil(obs.SNR),
|
||
"rssi": floatPtrOrNil(obs.RSSI),
|
||
"score": intPtrOrNil(obs.Score),
|
||
"path_json": strOrNil(obs.PathJSON),
|
||
}
|
||
if obs.ResolvedPath != nil {
|
||
m["resolved_path"] = obs.ResolvedPath
|
||
}
|
||
|
||
if tx != nil {
|
||
m["hash"] = strOrNil(tx.Hash)
|
||
m["raw_hex"] = strOrNil(tx.RawHex)
|
||
m["payload_type"] = intPtrOrNil(tx.PayloadType)
|
||
m["route_type"] = intPtrOrNil(tx.RouteType)
|
||
m["decoded_json"] = strOrNil(tx.DecodedJSON)
|
||
}
|
||
|
||
return m
|
||
}
|
||
|
||
// --- Conversion helpers ---
|
||
|
||
// txToMap converts a StoreTx to the map shape matching scanTransmissionRow output.
|
||
func txToMap(tx *StoreTx) map[string]interface{} {
|
||
m := map[string]interface{}{
|
||
"id": tx.ID,
|
||
"raw_hex": strOrNil(tx.RawHex),
|
||
"hash": strOrNil(tx.Hash),
|
||
"first_seen": strOrNil(tx.FirstSeen),
|
||
"timestamp": strOrNil(tx.FirstSeen),
|
||
"route_type": intPtrOrNil(tx.RouteType),
|
||
"payload_type": intPtrOrNil(tx.PayloadType),
|
||
"decoded_json": strOrNil(tx.DecodedJSON),
|
||
"observation_count": tx.ObservationCount,
|
||
"observer_id": strOrNil(tx.ObserverID),
|
||
"observer_name": strOrNil(tx.ObserverName),
|
||
"snr": floatPtrOrNil(tx.SNR),
|
||
"rssi": floatPtrOrNil(tx.RSSI),
|
||
"path_json": strOrNil(tx.PathJSON),
|
||
"direction": strOrNil(tx.Direction),
|
||
}
|
||
if tx.ResolvedPath != nil {
|
||
m["resolved_path"] = tx.ResolvedPath
|
||
}
|
||
// Include parsed path array to match Node.js output shape
|
||
if hops := txGetParsedPath(tx); len(hops) > 0 {
|
||
m["_parsedPath"] = hops
|
||
} else {
|
||
m["_parsedPath"] = nil
|
||
}
|
||
// Include observations for expand=observations support (stripped by handler when not requested)
|
||
obs := make([]map[string]interface{}, 0, len(tx.Observations))
|
||
for _, o := range tx.Observations {
|
||
om := map[string]interface{}{
|
||
"id": o.ID,
|
||
"observer_id": strOrNil(o.ObserverID),
|
||
"observer_name": strOrNil(o.ObserverName),
|
||
"snr": floatPtrOrNil(o.SNR),
|
||
"rssi": floatPtrOrNil(o.RSSI),
|
||
"path_json": strOrNil(o.PathJSON),
|
||
"timestamp": strOrNil(o.Timestamp),
|
||
"direction": strOrNil(o.Direction),
|
||
}
|
||
if o.ResolvedPath != nil {
|
||
om["resolved_path"] = o.ResolvedPath
|
||
}
|
||
obs = append(obs, om)
|
||
}
|
||
m["observations"] = obs
|
||
return m
|
||
}
|
||
|
||
func strOrNil(s string) interface{} {
|
||
if s == "" {
|
||
return nil
|
||
}
|
||
return s
|
||
}
|
||
|
||
// normalizeTimestamp converts SQLite datetime format ("YYYY-MM-DD HH:MM:SS")
|
||
// to ISO 8601 ("YYYY-MM-DDTHH:MM:SSZ"). Already-ISO strings pass through.
|
||
func normalizeTimestamp(s string) string {
|
||
if s == "" {
|
||
return s
|
||
}
|
||
if t, err := time.Parse("2006-01-02 15:04:05", s); err == nil {
|
||
return t.UTC().Format("2006-01-02T15:04:05.000Z")
|
||
}
|
||
return s
|
||
}
|
||
|
||
func intPtrOrNil(p *int) interface{} {
|
||
if p == nil {
|
||
return nil
|
||
}
|
||
return *p
|
||
}
|
||
|
||
func floatPtrOrNil(p *float64) interface{} {
|
||
if p == nil {
|
||
return nil
|
||
}
|
||
return *p
|
||
}
|
||
|
||
func nullIntPtr(ni sql.NullInt64) *int {
|
||
if ni.Valid {
|
||
v := int(ni.Int64)
|
||
return &v
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func nullFloatPtr(nf sql.NullFloat64) *float64 {
|
||
if nf.Valid {
|
||
return &nf.Float64
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// resolvePayloadTypeName returns the firmware-standard name for a payload_type.
|
||
func resolvePayloadTypeName(pt *int) string {
|
||
if pt == nil {
|
||
return "UNKNOWN"
|
||
}
|
||
if name, ok := payloadTypeNames[*pt]; ok {
|
||
return name
|
||
}
|
||
return fmt.Sprintf("UNK(%d)", *pt)
|
||
}
|
||
|
||
// txGetParsedPath returns cached parsed path hops, parsing on first call.
|
||
func txGetParsedPath(tx *StoreTx) []string {
|
||
if tx.pathParsed {
|
||
return tx.parsedPath
|
||
}
|
||
tx.parsedPath = parsePathJSON(tx.PathJSON)
|
||
tx.pathParsed = true
|
||
return tx.parsedPath
|
||
}
|
||
|
||
// addTxToSubpathIndex extracts all raw subpaths (lengths 2–8) from tx and
|
||
// increments their counts in the index. Returns true if the tx contributed
|
||
// (path had ≥ 2 hops).
|
||
func addTxToSubpathIndex(idx map[string]int, tx *StoreTx) bool {
|
||
return addTxToSubpathIndexFull(idx, nil, tx)
|
||
}
|
||
|
||
// addTxToSubpathIndexFull is like addTxToSubpathIndex but also appends
|
||
// tx to txIdx for each subpath key (if txIdx is non-nil).
|
||
func addTxToSubpathIndexFull(idx map[string]int, txIdx map[string][]*StoreTx, tx *StoreTx) bool {
|
||
hops := txGetParsedPath(tx)
|
||
if len(hops) < 2 {
|
||
return false
|
||
}
|
||
maxL := min(8, len(hops))
|
||
for l := 2; l <= maxL; l++ {
|
||
for start := 0; start <= len(hops)-l; start++ {
|
||
key := strings.ToLower(strings.Join(hops[start:start+l], ","))
|
||
idx[key]++
|
||
if txIdx != nil {
|
||
txIdx[key] = append(txIdx[key], tx)
|
||
}
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// removeTxFromSubpathIndex is the inverse of addTxToSubpathIndex — it
|
||
// decrements counts for all raw subpaths of tx. Returns true if the tx
|
||
// had a path.
|
||
func removeTxFromSubpathIndex(idx map[string]int, tx *StoreTx) bool {
|
||
return removeTxFromSubpathIndexFull(idx, nil, tx)
|
||
}
|
||
|
||
// removeTxFromSubpathIndexFull is like removeTxFromSubpathIndex but also
|
||
// removes tx from txIdx for each subpath key (if txIdx is non-nil).
|
||
func removeTxFromSubpathIndexFull(idx map[string]int, txIdx map[string][]*StoreTx, tx *StoreTx) bool {
|
||
hops := txGetParsedPath(tx)
|
||
if len(hops) < 2 {
|
||
return false
|
||
}
|
||
maxL := min(8, len(hops))
|
||
for l := 2; l <= maxL; l++ {
|
||
for start := 0; start <= len(hops)-l; start++ {
|
||
key := strings.ToLower(strings.Join(hops[start:start+l], ","))
|
||
idx[key]--
|
||
if idx[key] <= 0 {
|
||
delete(idx, key)
|
||
}
|
||
if txIdx != nil {
|
||
txs := txIdx[key]
|
||
for i, t := range txs {
|
||
if t == tx {
|
||
txIdx[key] = append(txs[:i], txs[i+1:]...)
|
||
break
|
||
}
|
||
}
|
||
if len(txIdx[key]) == 0 {
|
||
delete(txIdx, key)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// buildSubpathIndex scans all packets and populates spIndex + spTotalPaths.
|
||
// Must be called with s.mu held.
|
||
func (s *PacketStore) buildSubpathIndex() {
|
||
s.spIndex = make(map[string]int, 4096)
|
||
s.spTxIndex = make(map[string][]*StoreTx, 4096)
|
||
s.spTotalPaths = 0
|
||
for _, tx := range s.packets {
|
||
if addTxToSubpathIndexFull(s.spIndex, s.spTxIndex, tx) {
|
||
s.spTotalPaths++
|
||
}
|
||
}
|
||
log.Printf("[store] Built subpath index: %d unique raw subpaths from %d paths",
|
||
len(s.spIndex), s.spTotalPaths)
|
||
}
|
||
|
||
// buildPathHopIndex scans all packets and populates byPathHop.
|
||
// Must be called with s.mu held.
|
||
func (s *PacketStore) buildPathHopIndex() {
|
||
s.byPathHop = make(map[string][]*StoreTx, 4096)
|
||
for _, tx := range s.packets {
|
||
addTxToPathHopIndex(s.byPathHop, tx)
|
||
}
|
||
log.Printf("[store] Built path-hop index: %d unique keys", len(s.byPathHop))
|
||
}
|
||
|
||
// addTxToPathHopIndex indexes a transmission under each unique hop key
|
||
// (raw lowercase hop + resolved full pubkey from ResolvedPath).
|
||
func addTxToPathHopIndex(idx map[string][]*StoreTx, tx *StoreTx) {
|
||
hops := txGetParsedPath(tx)
|
||
if len(hops) == 0 {
|
||
return
|
||
}
|
||
seen := make(map[string]bool, len(hops)*2)
|
||
for i, hop := range hops {
|
||
key := strings.ToLower(hop)
|
||
if !seen[key] {
|
||
seen[key] = true
|
||
idx[key] = append(idx[key], tx)
|
||
}
|
||
// Also index by resolved pubkey if available
|
||
if tx.ResolvedPath != nil && i < len(tx.ResolvedPath) && tx.ResolvedPath[i] != nil {
|
||
pk := *tx.ResolvedPath[i]
|
||
if !seen[pk] {
|
||
seen[pk] = true
|
||
idx[pk] = append(idx[pk], tx)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// removeTxFromPathHopIndex removes a transmission from all its path-hop index entries.
|
||
func removeTxFromPathHopIndex(idx map[string][]*StoreTx, tx *StoreTx) {
|
||
hops := txGetParsedPath(tx)
|
||
if len(hops) == 0 {
|
||
return
|
||
}
|
||
seen := make(map[string]bool, len(hops)*2)
|
||
for i, hop := range hops {
|
||
key := strings.ToLower(hop)
|
||
if !seen[key] {
|
||
seen[key] = true
|
||
removeTxFromSlice(idx, key, tx)
|
||
}
|
||
if tx.ResolvedPath != nil && i < len(tx.ResolvedPath) && tx.ResolvedPath[i] != nil {
|
||
pk := *tx.ResolvedPath[i]
|
||
if !seen[pk] {
|
||
seen[pk] = true
|
||
removeTxFromSlice(idx, pk, tx)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// removeTxFromSlice removes tx from idx[key] by ID, deleting the key if empty.
|
||
func removeTxFromSlice(idx map[string][]*StoreTx, key string, tx *StoreTx) {
|
||
list := idx[key]
|
||
for i, t := range list {
|
||
if t.ID == tx.ID {
|
||
idx[key] = append(list[:i], list[i+1:]...)
|
||
break
|
||
}
|
||
}
|
||
if len(idx[key]) == 0 {
|
||
delete(idx, key)
|
||
}
|
||
}
|
||
|
||
// updateDistanceIndexForTxs removes old distance records for the given
|
||
// transmissions and recomputes them. Builds lookup maps once, amortising the
|
||
// cost across all changed txs in a single ingest cycle. Must be called with
|
||
// s.mu held.
|
||
func (s *PacketStore) updateDistanceIndexForTxs(txs []*StoreTx) {
|
||
// Remove old records for all changed txs first.
|
||
removeSet := make(map[*StoreTx]bool, len(txs))
|
||
for _, tx := range txs {
|
||
removeSet[tx] = true
|
||
}
|
||
n := 0
|
||
for _, r := range s.distHops {
|
||
if !removeSet[r.tx] {
|
||
s.distHops[n] = r
|
||
n++
|
||
}
|
||
}
|
||
s.distHops = s.distHops[:n]
|
||
n = 0
|
||
for _, r := range s.distPaths {
|
||
if !removeSet[r.tx] {
|
||
s.distPaths[n] = r
|
||
n++
|
||
}
|
||
}
|
||
s.distPaths = s.distPaths[:n]
|
||
|
||
// Build lookup maps once.
|
||
allNodes, pm := s.getCachedNodesAndPM()
|
||
nodeByPk := make(map[string]*nodeInfo, len(allNodes))
|
||
repeaterSet := make(map[string]bool)
|
||
for i := range allNodes {
|
||
nd := &allNodes[i]
|
||
nodeByPk[nd.PublicKey] = nd
|
||
if strings.Contains(strings.ToLower(nd.Role), "repeater") {
|
||
repeaterSet[nd.PublicKey] = true
|
||
}
|
||
}
|
||
hopCache := make(map[string]*nodeInfo)
|
||
resolveHop := func(hop string) *nodeInfo {
|
||
if cached, ok := hopCache[hop]; ok {
|
||
return cached
|
||
}
|
||
r, _, _ := pm.resolveWithContext(hop, nil, s.graph)
|
||
hopCache[hop] = r
|
||
return r
|
||
}
|
||
|
||
// Recompute distance records for each changed tx.
|
||
for _, tx := range txs {
|
||
txHops, txPath := computeDistancesForTx(tx, nodeByPk, repeaterSet, resolveHop)
|
||
if len(txHops) > 0 {
|
||
s.distHops = append(s.distHops, txHops...)
|
||
}
|
||
if txPath != nil {
|
||
s.distPaths = append(s.distPaths, *txPath)
|
||
}
|
||
}
|
||
}
|
||
|
||
// buildDistanceIndex precomputes haversine distances for all packets.
|
||
// Must be called with s.mu held (Lock).
|
||
func (s *PacketStore) buildDistanceIndex() {
|
||
allNodes, pm := s.getCachedNodesAndPM()
|
||
nodeByPk := make(map[string]*nodeInfo, len(allNodes))
|
||
repeaterSet := make(map[string]bool)
|
||
for i := range allNodes {
|
||
n := &allNodes[i]
|
||
nodeByPk[n.PublicKey] = n
|
||
if strings.Contains(strings.ToLower(n.Role), "repeater") {
|
||
repeaterSet[n.PublicKey] = true
|
||
}
|
||
}
|
||
|
||
hopCache := make(map[string]*nodeInfo)
|
||
resolveHop := func(hop string) *nodeInfo {
|
||
if cached, ok := hopCache[hop]; ok {
|
||
return cached
|
||
}
|
||
r, _, _ := pm.resolveWithContext(hop, nil, s.graph)
|
||
hopCache[hop] = r
|
||
return r
|
||
}
|
||
|
||
hops := make([]distHopRecord, 0, len(s.packets))
|
||
paths := make([]distPathRecord, 0, len(s.packets)/2)
|
||
|
||
for _, tx := range s.packets {
|
||
txHops, txPath := computeDistancesForTx(tx, nodeByPk, repeaterSet, resolveHop)
|
||
if len(txHops) > 0 {
|
||
hops = append(hops, txHops...)
|
||
}
|
||
if txPath != nil {
|
||
paths = append(paths, *txPath)
|
||
}
|
||
}
|
||
|
||
s.distHops = hops
|
||
s.distPaths = paths
|
||
log.Printf("[store] Built distance index: %d hop records, %d path records",
|
||
len(s.distHops), len(s.distPaths))
|
||
}
|
||
|
||
// estimatedMemoryMB returns current Go heap allocation in MB.
|
||
// Uses runtime.ReadMemStats so it accounts for all data structures
|
||
// (distHops, distPaths, spIndex, map overhead) not just packets/observations.
|
||
// In tests, memoryEstimator can be set to inject a deterministic value.
|
||
func (s *PacketStore) estimatedMemoryMB() float64 {
|
||
if s.memoryEstimator != nil {
|
||
return s.memoryEstimator()
|
||
}
|
||
var ms runtime.MemStats
|
||
runtime.ReadMemStats(&ms)
|
||
return float64(ms.HeapAlloc) / 1048576.0
|
||
}
|
||
|
||
// EvictStale removes packets older than the retention window and/or exceeding
|
||
// the memory cap. Must be called with s.mu held (Lock). Returns the number of
|
||
// packets evicted.
|
||
func (s *PacketStore) EvictStale() int {
|
||
if s.retentionHours <= 0 && s.maxMemoryMB <= 0 {
|
||
return 0
|
||
}
|
||
|
||
cutoffIdx := 0
|
||
|
||
// Time-based eviction: find how many packets from the head are too old
|
||
if s.retentionHours > 0 {
|
||
cutoff := time.Now().UTC().Add(-time.Duration(s.retentionHours*3600) * time.Second).Format(time.RFC3339)
|
||
for cutoffIdx < len(s.packets) && s.packets[cutoffIdx].FirstSeen < cutoff {
|
||
cutoffIdx++
|
||
}
|
||
}
|
||
|
||
// Memory-based eviction: if heap exceeds budget, trim proportionally from head.
|
||
// All major data structures (distHops, distPaths, spIndex) scale with packet count,
|
||
// so evicting a fraction of packets frees roughly the same fraction of total heap.
|
||
// A 10% buffer avoids immediately re-triggering on the next ingest cycle.
|
||
if s.maxMemoryMB > 0 {
|
||
currentMB := s.estimatedMemoryMB()
|
||
if currentMB > float64(s.maxMemoryMB) && len(s.packets) > 0 {
|
||
fractionToKeep := (float64(s.maxMemoryMB) / currentMB) * 0.9
|
||
keepCount := int(float64(len(s.packets)) * fractionToKeep)
|
||
if keepCount < 0 {
|
||
keepCount = 0
|
||
}
|
||
newCutoff := len(s.packets) - keepCount
|
||
if newCutoff > cutoffIdx {
|
||
cutoffIdx = newCutoff
|
||
}
|
||
if cutoffIdx > len(s.packets) {
|
||
cutoffIdx = len(s.packets)
|
||
}
|
||
}
|
||
}
|
||
|
||
if cutoffIdx == 0 {
|
||
return 0
|
||
}
|
||
if cutoffIdx > len(s.packets) {
|
||
cutoffIdx = len(s.packets)
|
||
}
|
||
|
||
evicting := s.packets[:cutoffIdx]
|
||
evictedObs := 0
|
||
|
||
// Remove from all indexes
|
||
for _, tx := range evicting {
|
||
delete(s.byHash, tx.Hash)
|
||
delete(s.byTxID, tx.ID)
|
||
|
||
// Remove observations from indexes
|
||
for _, obs := range tx.Observations {
|
||
delete(s.byObsID, obs.ID)
|
||
// Remove from byObserver
|
||
if obs.ObserverID != "" {
|
||
obsList := s.byObserver[obs.ObserverID]
|
||
for i, o := range obsList {
|
||
if o.ID == obs.ID {
|
||
s.byObserver[obs.ObserverID] = append(obsList[:i], obsList[i+1:]...)
|
||
break
|
||
}
|
||
}
|
||
if len(s.byObserver[obs.ObserverID]) == 0 {
|
||
delete(s.byObserver, obs.ObserverID)
|
||
}
|
||
}
|
||
evictedObs++
|
||
}
|
||
|
||
// Remove from byPayloadType
|
||
s.untrackAdvertPubkey(tx)
|
||
if tx.PayloadType != nil {
|
||
pt := *tx.PayloadType
|
||
ptList := s.byPayloadType[pt]
|
||
for i, t := range ptList {
|
||
if t.ID == tx.ID {
|
||
s.byPayloadType[pt] = append(ptList[:i], ptList[i+1:]...)
|
||
break
|
||
}
|
||
}
|
||
if len(s.byPayloadType[pt]) == 0 {
|
||
delete(s.byPayloadType, pt)
|
||
}
|
||
}
|
||
|
||
// Remove from byNode and nodeHashes
|
||
if tx.DecodedJSON != "" {
|
||
var decoded map[string]interface{}
|
||
if json.Unmarshal([]byte(tx.DecodedJSON), &decoded) == nil {
|
||
for _, field := range []string{"pubKey", "destPubKey", "srcPubKey"} {
|
||
if v, ok := decoded[field].(string); ok && v != "" {
|
||
if hashes, ok := s.nodeHashes[v]; ok {
|
||
delete(hashes, tx.Hash)
|
||
if len(hashes) == 0 {
|
||
delete(s.nodeHashes, v)
|
||
}
|
||
}
|
||
// Remove tx from byNode
|
||
nodeList := s.byNode[v]
|
||
for i, t := range nodeList {
|
||
if t.ID == tx.ID {
|
||
s.byNode[v] = append(nodeList[:i], nodeList[i+1:]...)
|
||
break
|
||
}
|
||
}
|
||
if len(s.byNode[v]) == 0 {
|
||
delete(s.byNode, v)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Remove from subpath index
|
||
removeTxFromSubpathIndexFull(s.spIndex, s.spTxIndex, tx)
|
||
// Remove from path-hop index
|
||
removeTxFromPathHopIndex(s.byPathHop, tx)
|
||
}
|
||
|
||
// Remove from distance indexes — filter out records referencing evicted txs
|
||
evictedTxSet := make(map[*StoreTx]bool, cutoffIdx)
|
||
for _, tx := range evicting {
|
||
evictedTxSet[tx] = true
|
||
}
|
||
newDistHops := s.distHops[:0]
|
||
for i := range s.distHops {
|
||
if !evictedTxSet[s.distHops[i].tx] {
|
||
newDistHops = append(newDistHops, s.distHops[i])
|
||
}
|
||
}
|
||
s.distHops = newDistHops
|
||
|
||
newDistPaths := s.distPaths[:0]
|
||
for i := range s.distPaths {
|
||
if !evictedTxSet[s.distPaths[i].tx] {
|
||
newDistPaths = append(newDistPaths, s.distPaths[i])
|
||
}
|
||
}
|
||
s.distPaths = newDistPaths
|
||
|
||
// Trim packets slice
|
||
n := copy(s.packets, s.packets[cutoffIdx:])
|
||
s.packets = s.packets[:n]
|
||
s.totalObs -= evictedObs
|
||
|
||
evictCount := cutoffIdx
|
||
atomic.AddInt64(&s.evicted, int64(evictCount))
|
||
log.Printf("[store] Evicted %d packets (%d obs)", evictCount, evictedObs)
|
||
|
||
// Eviction removes data — all caches may be affected
|
||
s.invalidateCachesFor(cacheInvalidation{eviction: true})
|
||
|
||
// Invalidate hash size cache
|
||
s.hashSizeInfoMu.Lock()
|
||
s.hashSizeInfoCache = nil
|
||
s.hashSizeInfoMu.Unlock()
|
||
|
||
return evictCount
|
||
}
|
||
|
||
// RunEviction acquires the write lock and runs eviction. Safe to call from
|
||
// a goroutine. Returns evicted count.
|
||
func (s *PacketStore) RunEviction() int {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
return s.EvictStale()
|
||
}
|
||
|
||
// StartEvictionTicker starts a background goroutine that runs eviction every
|
||
// minute. Returns a stop function.
|
||
func (s *PacketStore) StartEvictionTicker() func() {
|
||
if s.retentionHours <= 0 && s.maxMemoryMB <= 0 {
|
||
return func() {} // no-op
|
||
}
|
||
ticker := time.NewTicker(1 * time.Minute)
|
||
done := make(chan struct{})
|
||
go func() {
|
||
for {
|
||
select {
|
||
case <-ticker.C:
|
||
s.RunEviction()
|
||
case <-done:
|
||
ticker.Stop()
|
||
return
|
||
}
|
||
}
|
||
}()
|
||
return func() { close(done) }
|
||
}
|
||
|
||
// computeDistancesForTx computes distance records for a single transmission.
|
||
func computeDistancesForTx(tx *StoreTx, nodeByPk map[string]*nodeInfo, repeaterSet map[string]bool, resolveHop func(string) *nodeInfo) ([]distHopRecord, *distPathRecord) {
|
||
pathHops := txGetParsedPath(tx)
|
||
if len(pathHops) == 0 {
|
||
return nil, nil
|
||
}
|
||
|
||
resolved := make([]*nodeInfo, len(pathHops))
|
||
for i, h := range pathHops {
|
||
resolved[i] = resolveHop(h)
|
||
}
|
||
|
||
var senderNode *nodeInfo
|
||
if tx.DecodedJSON != "" {
|
||
var dec map[string]interface{}
|
||
if json.Unmarshal([]byte(tx.DecodedJSON), &dec) == nil {
|
||
if pk, ok := dec["pubKey"].(string); ok && pk != "" {
|
||
senderNode = nodeByPk[pk]
|
||
}
|
||
}
|
||
}
|
||
|
||
chain := make([]*nodeInfo, 0, len(pathHops)+1)
|
||
if senderNode != nil && senderNode.HasGPS {
|
||
chain = append(chain, senderNode)
|
||
}
|
||
for _, r := range resolved {
|
||
if r != nil && r.HasGPS {
|
||
chain = append(chain, r)
|
||
}
|
||
}
|
||
if len(chain) < 2 {
|
||
return nil, nil
|
||
}
|
||
|
||
hourBucket := ""
|
||
if tx.FirstSeen != "" && len(tx.FirstSeen) >= 13 {
|
||
hourBucket = tx.FirstSeen[:13]
|
||
}
|
||
|
||
var hopRecords []distHopRecord
|
||
var hopDetails []distHopDetail
|
||
pathDist := 0.0
|
||
|
||
for i := 0; i < len(chain)-1; i++ {
|
||
a, b := chain[i], chain[i+1]
|
||
dist := haversineKm(a.Lat, a.Lon, b.Lat, b.Lon)
|
||
if dist > 300 {
|
||
continue
|
||
}
|
||
|
||
aRep := repeaterSet[a.PublicKey]
|
||
bRep := repeaterSet[b.PublicKey]
|
||
var hopType string
|
||
if aRep && bRep {
|
||
hopType = "R↔R"
|
||
} else if !aRep && !bRep {
|
||
hopType = "C↔C"
|
||
} else {
|
||
hopType = "C↔R"
|
||
}
|
||
|
||
roundedDist := math.Round(dist*100) / 100
|
||
hopRecords = append(hopRecords, distHopRecord{
|
||
FromName: a.Name, FromPk: a.PublicKey,
|
||
ToName: b.Name, ToPk: b.PublicKey,
|
||
Dist: roundedDist, Type: hopType,
|
||
SNR: tx.SNR, Hash: tx.Hash, Timestamp: tx.FirstSeen,
|
||
HourBucket: hourBucket, tx: tx,
|
||
})
|
||
hopDetails = append(hopDetails, distHopDetail{
|
||
FromName: a.Name, FromPk: a.PublicKey,
|
||
ToName: b.Name, ToPk: b.PublicKey,
|
||
Dist: roundedDist,
|
||
})
|
||
pathDist += dist
|
||
}
|
||
|
||
if len(hopRecords) == 0 {
|
||
return nil, nil
|
||
}
|
||
|
||
pathRec := &distPathRecord{
|
||
Hash: tx.Hash, TotalDist: math.Round(pathDist*100) / 100,
|
||
HopCount: len(hopDetails), Timestamp: tx.FirstSeen,
|
||
Hops: hopDetails, tx: tx,
|
||
}
|
||
return hopRecords, pathRec
|
||
}
|
||
|
||
func filterTxSlice(s []*StoreTx, fn func(*StoreTx) bool) []*StoreTx {
|
||
var result []*StoreTx
|
||
for _, tx := range s {
|
||
if fn(tx) {
|
||
result = append(result, tx)
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
// countNonPrintable counts characters that are non-printable (< 0x20 except \n, \t)
|
||
// or invalid UTF-8 replacement characters. Mirrors the heuristic from #197.
|
||
func countNonPrintable(s string) int {
|
||
count := 0
|
||
for _, r := range s {
|
||
if r < 0x20 && r != '\n' && r != '\t' {
|
||
count++
|
||
} else if r == utf8.RuneError {
|
||
count++
|
||
}
|
||
}
|
||
return count
|
||
}
|
||
|
||
// hasGarbageChars returns true if the string contains garbage (non-printable) data.
|
||
func hasGarbageChars(s string) bool {
|
||
return s != "" && (!utf8.ValidString(s) || countNonPrintable(s) > 2)
|
||
}
|
||
|
||
// GetChannels returns channel list from in-memory packets (payload_type 5, decoded type CHAN).
|
||
func (s *PacketStore) GetChannels(region string) []map[string]interface{} {
|
||
cacheKey := region
|
||
|
||
s.channelsCacheMu.Lock()
|
||
if s.channelsCacheRes != nil && s.channelsCacheKey == cacheKey && time.Now().Before(s.channelsCacheExp) {
|
||
res := s.channelsCacheRes
|
||
s.channelsCacheMu.Unlock()
|
||
return res
|
||
}
|
||
s.channelsCacheMu.Unlock()
|
||
|
||
type txSnapshot struct {
|
||
firstSeen string
|
||
decodedJSON string
|
||
hasRegion bool
|
||
}
|
||
|
||
// Copy only the fields needed — release the lock before JSON unmarshal.
|
||
s.mu.RLock()
|
||
var regionObs map[string]bool
|
||
if region != "" {
|
||
regionObs = s.resolveRegionObservers(region)
|
||
}
|
||
grpTxts := s.byPayloadType[5]
|
||
snapshots := make([]txSnapshot, 0, len(grpTxts))
|
||
for _, tx := range grpTxts {
|
||
inRegion := true
|
||
if regionObs != nil {
|
||
inRegion = false
|
||
for _, obs := range tx.Observations {
|
||
if regionObs[obs.ObserverID] {
|
||
inRegion = true
|
||
break
|
||
}
|
||
}
|
||
}
|
||
snapshots = append(snapshots, txSnapshot{
|
||
firstSeen: tx.FirstSeen,
|
||
decodedJSON: tx.DecodedJSON,
|
||
hasRegion: inRegion,
|
||
})
|
||
}
|
||
s.mu.RUnlock()
|
||
|
||
// JSON unmarshal outside the lock.
|
||
type chanInfo struct {
|
||
Hash string
|
||
Name string
|
||
LastMessage interface{}
|
||
LastSender interface{}
|
||
MessageCount int
|
||
LastActivity string
|
||
}
|
||
type decodedGrp struct {
|
||
Type string `json:"type"`
|
||
Channel string `json:"channel"`
|
||
Text string `json:"text"`
|
||
Sender string `json:"sender"`
|
||
}
|
||
channelMap := map[string]*chanInfo{}
|
||
for _, snap := range snapshots {
|
||
if !snap.hasRegion {
|
||
continue
|
||
}
|
||
var decoded decodedGrp
|
||
if json.Unmarshal([]byte(snap.decodedJSON), &decoded) != nil {
|
||
continue
|
||
}
|
||
if decoded.Type != "CHAN" {
|
||
continue
|
||
}
|
||
if hasGarbageChars(decoded.Channel) || hasGarbageChars(decoded.Text) {
|
||
continue
|
||
}
|
||
channelName := decoded.Channel
|
||
if channelName == "" {
|
||
channelName = "unknown"
|
||
}
|
||
ch := channelMap[channelName]
|
||
if ch == nil {
|
||
ch = &chanInfo{Hash: channelName, Name: channelName, LastActivity: snap.firstSeen}
|
||
channelMap[channelName] = ch
|
||
}
|
||
ch.MessageCount++
|
||
if snap.firstSeen >= ch.LastActivity {
|
||
ch.LastActivity = snap.firstSeen
|
||
if decoded.Text != "" {
|
||
idx := strings.Index(decoded.Text, ": ")
|
||
if idx > 0 {
|
||
ch.LastMessage = decoded.Text[idx+2:]
|
||
} else {
|
||
ch.LastMessage = decoded.Text
|
||
}
|
||
if decoded.Sender != "" {
|
||
ch.LastSender = decoded.Sender
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
channels := make([]map[string]interface{}, 0, len(channelMap))
|
||
for _, ch := range channelMap {
|
||
channels = append(channels, map[string]interface{}{
|
||
"hash": ch.Hash, "name": ch.Name,
|
||
"lastMessage": ch.LastMessage, "lastSender": ch.LastSender,
|
||
"messageCount": ch.MessageCount, "lastActivity": ch.LastActivity,
|
||
})
|
||
}
|
||
|
||
s.channelsCacheMu.Lock()
|
||
s.channelsCacheRes = channels
|
||
s.channelsCacheKey = cacheKey
|
||
s.channelsCacheExp = time.Now().Add(15 * time.Second)
|
||
s.channelsCacheMu.Unlock()
|
||
|
||
return channels
|
||
}
|
||
|
||
// GetChannelMessages returns deduplicated messages for a channel from in-memory packets.
|
||
func (s *PacketStore) GetChannelMessages(channelHash string, limit, offset int, region ...string) ([]map[string]interface{}, int) {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
if limit <= 0 {
|
||
limit = 100
|
||
}
|
||
|
||
type msgEntry struct {
|
||
Data map[string]interface{}
|
||
Repeats int
|
||
Observers []string
|
||
}
|
||
msgMap := map[string]*msgEntry{}
|
||
var msgOrder []string
|
||
regionParam := ""
|
||
if len(region) > 0 {
|
||
regionParam = region[0]
|
||
}
|
||
regionObs := s.resolveRegionObservers(regionParam)
|
||
|
||
// Iterate type-5 packets oldest-first (byPayloadType is ASC = oldest first)
|
||
type decodedMsg struct {
|
||
Type string `json:"type"`
|
||
Channel string `json:"channel"`
|
||
Text string `json:"text"`
|
||
Sender string `json:"sender"`
|
||
SenderTimestamp interface{} `json:"sender_timestamp"`
|
||
PathLen int `json:"path_len"`
|
||
}
|
||
|
||
grpTxts := s.byPayloadType[5]
|
||
for _, tx := range grpTxts {
|
||
if regionObs != nil {
|
||
match := false
|
||
for _, obs := range tx.Observations {
|
||
if regionObs[obs.ObserverID] {
|
||
match = true
|
||
break
|
||
}
|
||
}
|
||
if !match {
|
||
continue
|
||
}
|
||
}
|
||
|
||
if tx.DecodedJSON == "" {
|
||
continue
|
||
}
|
||
|
||
var decoded decodedMsg
|
||
if json.Unmarshal([]byte(tx.DecodedJSON), &decoded) != nil {
|
||
continue
|
||
}
|
||
if decoded.Type != "CHAN" {
|
||
continue
|
||
}
|
||
ch := decoded.Channel
|
||
if ch == "" {
|
||
ch = "unknown"
|
||
}
|
||
if ch != channelHash {
|
||
continue
|
||
}
|
||
|
||
text := decoded.Text
|
||
sender := decoded.Sender
|
||
if sender == "" && text != "" {
|
||
idx := strings.Index(text, ": ")
|
||
if idx > 0 && idx < 50 {
|
||
sender = text[:idx]
|
||
}
|
||
}
|
||
|
||
dedupeKey := sender + ":" + tx.Hash
|
||
|
||
if existing, ok := msgMap[dedupeKey]; ok {
|
||
existing.Repeats++
|
||
existing.Data["repeats"] = existing.Repeats
|
||
// Add observer if new
|
||
obsName := tx.ObserverName
|
||
if obsName == "" {
|
||
obsName = tx.ObserverID
|
||
}
|
||
if obsName != "" {
|
||
found := false
|
||
for _, o := range existing.Observers {
|
||
if o == obsName {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
existing.Observers = append(existing.Observers, obsName)
|
||
existing.Data["observers"] = existing.Observers
|
||
}
|
||
}
|
||
} else {
|
||
displaySender := sender
|
||
displayText := text
|
||
if text != "" {
|
||
idx := strings.Index(text, ": ")
|
||
if idx > 0 && idx < 50 {
|
||
displaySender = text[:idx]
|
||
displayText = text[idx+2:]
|
||
}
|
||
}
|
||
|
||
hops := pathLen(tx.PathJSON)
|
||
|
||
var snrVal interface{}
|
||
if tx.SNR != nil {
|
||
snrVal = *tx.SNR
|
||
}
|
||
|
||
senderTs := decoded.SenderTimestamp
|
||
|
||
observers := []string{}
|
||
obsName := tx.ObserverName
|
||
if obsName == "" {
|
||
obsName = tx.ObserverID
|
||
}
|
||
if obsName != "" {
|
||
observers = []string{obsName}
|
||
}
|
||
|
||
entry := &msgEntry{
|
||
Data: map[string]interface{}{
|
||
"sender": displaySender,
|
||
"text": displayText,
|
||
"timestamp": strOrNil(tx.FirstSeen),
|
||
"sender_timestamp": senderTs,
|
||
"packetId": tx.ID,
|
||
"packetHash": strOrNil(tx.Hash),
|
||
"repeats": 1,
|
||
"observers": observers,
|
||
"hops": hops,
|
||
"snr": snrVal,
|
||
},
|
||
Repeats: 1,
|
||
Observers: observers,
|
||
}
|
||
msgMap[dedupeKey] = entry
|
||
msgOrder = append(msgOrder, dedupeKey)
|
||
}
|
||
}
|
||
|
||
total := len(msgOrder)
|
||
// Return latest messages (tail)
|
||
start := total - limit - offset
|
||
if start < 0 {
|
||
start = 0
|
||
}
|
||
end := total - offset
|
||
if end < 0 {
|
||
end = 0
|
||
}
|
||
if end > total {
|
||
end = total
|
||
}
|
||
|
||
messages := make([]map[string]interface{}, 0, end-start)
|
||
for i := start; i < end; i++ {
|
||
messages = append(messages, msgMap[msgOrder[i]].Data)
|
||
}
|
||
return messages, total
|
||
}
|
||
|
||
// GetAnalyticsChannels returns full channel analytics computed from in-memory packets.
|
||
func (s *PacketStore) GetAnalyticsChannels(region string) map[string]interface{} {
|
||
s.cacheMu.Lock()
|
||
if cached, ok := s.chanCache[region]; ok && time.Now().Before(cached.expiresAt) {
|
||
s.cacheHits++
|
||
s.cacheMu.Unlock()
|
||
return cached.data
|
||
}
|
||
s.cacheMisses++
|
||
s.cacheMu.Unlock()
|
||
|
||
result := s.computeAnalyticsChannels(region)
|
||
|
||
s.cacheMu.Lock()
|
||
s.chanCache[region] = &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)}
|
||
s.cacheMu.Unlock()
|
||
|
||
return result
|
||
}
|
||
|
||
func (s *PacketStore) computeAnalyticsChannels(region string) map[string]interface{} {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
var regionObs map[string]bool
|
||
if region != "" {
|
||
regionObs = s.resolveRegionObservers(region)
|
||
}
|
||
|
||
type decodedGrp struct {
|
||
Type string `json:"type"`
|
||
Channel string `json:"channel"`
|
||
ChannelHash interface{} `json:"channelHash"`
|
||
ChannelHash2 string `json:"channel_hash"`
|
||
Text string `json:"text"`
|
||
Sender string `json:"sender"`
|
||
}
|
||
|
||
// Convert channelHash (number or string in JSON) to string
|
||
chHashStr := func(v interface{}) string {
|
||
if v == nil {
|
||
return ""
|
||
}
|
||
switch val := v.(type) {
|
||
case string:
|
||
return val
|
||
case float64:
|
||
return strconv.FormatFloat(val, 'f', -1, 64)
|
||
default:
|
||
return fmt.Sprintf("%v", val)
|
||
}
|
||
}
|
||
|
||
type chanInfo struct {
|
||
Hash string
|
||
Name string
|
||
Messages int
|
||
Senders map[string]bool
|
||
LastActivity string
|
||
Encrypted bool
|
||
}
|
||
|
||
channelMap := map[string]*chanInfo{}
|
||
senderCounts := map[string]int{}
|
||
msgLengths := make([]int, 0)
|
||
timeline := map[string]int{} // hour|channelName → count
|
||
|
||
grpTxts := s.byPayloadType[5]
|
||
for _, tx := range grpTxts {
|
||
if regionObs != nil {
|
||
match := false
|
||
for _, obs := range tx.Observations {
|
||
if regionObs[obs.ObserverID] {
|
||
match = true
|
||
break
|
||
}
|
||
}
|
||
if !match {
|
||
continue
|
||
}
|
||
}
|
||
|
||
var decoded decodedGrp
|
||
if json.Unmarshal([]byte(tx.DecodedJSON), &decoded) != nil {
|
||
continue
|
||
}
|
||
|
||
hash := chHashStr(decoded.ChannelHash)
|
||
if hash == "" {
|
||
hash = decoded.ChannelHash2
|
||
}
|
||
if hash == "" {
|
||
hash = "?"
|
||
}
|
||
name := decoded.Channel
|
||
if name == "" {
|
||
name = "ch" + hash
|
||
}
|
||
encrypted := decoded.Text == "" && decoded.Sender == ""
|
||
// Use hash as key for grouping (matches Node.js String(hash))
|
||
chKey := hash
|
||
if decoded.Type == "CHAN" && decoded.Channel != "" {
|
||
chKey = hash + "_" + decoded.Channel
|
||
}
|
||
|
||
ch := channelMap[chKey]
|
||
if ch == nil {
|
||
ch = &chanInfo{Hash: hash, Name: name, Senders: map[string]bool{}, LastActivity: tx.FirstSeen, Encrypted: encrypted}
|
||
channelMap[chKey] = ch
|
||
}
|
||
ch.Messages++
|
||
ch.LastActivity = tx.FirstSeen
|
||
if !encrypted {
|
||
ch.Encrypted = false
|
||
}
|
||
|
||
if decoded.Sender != "" {
|
||
ch.Senders[decoded.Sender] = true
|
||
senderCounts[decoded.Sender]++
|
||
}
|
||
if decoded.Text != "" {
|
||
msgLengths = append(msgLengths, len(decoded.Text))
|
||
}
|
||
|
||
// Timeline
|
||
if len(tx.FirstSeen) >= 13 {
|
||
hr := tx.FirstSeen[:13]
|
||
key := hr + "|" + name
|
||
timeline[key]++
|
||
}
|
||
}
|
||
|
||
channelList := make([]map[string]interface{}, 0, len(channelMap))
|
||
decryptable := 0
|
||
for _, c := range channelMap {
|
||
if !c.Encrypted {
|
||
decryptable++
|
||
}
|
||
channelList = append(channelList, map[string]interface{}{
|
||
"hash": c.Hash, "name": c.Name,
|
||
"messages": c.Messages, "senders": len(c.Senders),
|
||
"lastActivity": c.LastActivity, "encrypted": c.Encrypted,
|
||
})
|
||
}
|
||
sort.Slice(channelList, func(i, j int) bool {
|
||
return channelList[i]["messages"].(int) > channelList[j]["messages"].(int)
|
||
})
|
||
|
||
// Top senders
|
||
type senderEntry struct {
|
||
name string
|
||
count int
|
||
}
|
||
senderList := make([]senderEntry, 0, len(senderCounts))
|
||
for n, c := range senderCounts {
|
||
senderList = append(senderList, senderEntry{n, c})
|
||
}
|
||
sort.Slice(senderList, func(i, j int) bool { return senderList[i].count > senderList[j].count })
|
||
topSenders := make([]map[string]interface{}, 0)
|
||
for i, e := range senderList {
|
||
if i >= 15 {
|
||
break
|
||
}
|
||
topSenders = append(topSenders, map[string]interface{}{"name": e.name, "count": e.count})
|
||
}
|
||
|
||
// Channel timeline
|
||
type tlEntry struct {
|
||
hour, channel string
|
||
count int
|
||
}
|
||
var tlList []tlEntry
|
||
for key, count := range timeline {
|
||
parts := strings.SplitN(key, "|", 2)
|
||
if len(parts) == 2 {
|
||
tlList = append(tlList, tlEntry{parts[0], parts[1], count})
|
||
}
|
||
}
|
||
sort.Slice(tlList, func(i, j int) bool { return tlList[i].hour < tlList[j].hour })
|
||
channelTimeline := make([]map[string]interface{}, 0, len(tlList))
|
||
for _, e := range tlList {
|
||
channelTimeline = append(channelTimeline, map[string]interface{}{
|
||
"hour": e.hour, "channel": e.channel, "count": e.count,
|
||
})
|
||
}
|
||
|
||
return map[string]interface{}{
|
||
"activeChannels": len(channelList),
|
||
"decryptable": decryptable,
|
||
"channels": channelList,
|
||
"topSenders": topSenders,
|
||
"channelTimeline": channelTimeline,
|
||
"msgLengths": msgLengths,
|
||
}
|
||
}
|
||
|
||
// GetAnalyticsRF returns full RF analytics computed from in-memory observations.
|
||
func (s *PacketStore) GetAnalyticsRF(region string) map[string]interface{} {
|
||
s.cacheMu.Lock()
|
||
if cached, ok := s.rfCache[region]; ok && time.Now().Before(cached.expiresAt) {
|
||
s.cacheHits++
|
||
s.cacheMu.Unlock()
|
||
return cached.data
|
||
}
|
||
s.cacheMisses++
|
||
s.cacheMu.Unlock()
|
||
|
||
result := s.computeAnalyticsRF(region)
|
||
|
||
s.cacheMu.Lock()
|
||
s.rfCache[region] = &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)}
|
||
s.cacheMu.Unlock()
|
||
|
||
return result
|
||
}
|
||
|
||
func (s *PacketStore) computeAnalyticsRF(region string) map[string]interface{} {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
ptNames := payloadTypeNames
|
||
|
||
var regionObs map[string]bool
|
||
if region != "" {
|
||
regionObs = s.resolveRegionObservers(region)
|
||
}
|
||
|
||
// Collect all observations matching the region
|
||
estCap := s.totalObs
|
||
if estCap > 2000000 {
|
||
estCap = 2000000
|
||
}
|
||
snrVals := make([]float64, 0, estCap/2)
|
||
rssiVals := make([]float64, 0, estCap/2)
|
||
packetSizes := make([]int, 0, len(s.packets))
|
||
seenSizeHashes := make(map[string]bool, len(s.packets))
|
||
seenTypeHashes := make(map[string]bool, len(s.packets))
|
||
typeBuckets := map[int]int{}
|
||
hourBuckets := map[string]int{}
|
||
seenHourHash := make(map[string]bool, len(s.packets)) // dedup packets-per-hour by hash+hour
|
||
snrByType := map[string]*struct{ vals []float64 }{}
|
||
sigTime := map[string]*struct {
|
||
snrs []float64
|
||
count int
|
||
}{}
|
||
scatterAll := make([]struct{ snr, rssi float64 }, 0, estCap/4)
|
||
totalObs := 0
|
||
regionalHashes := make(map[string]bool, len(s.packets))
|
||
var minTimestamp, maxTimestamp string
|
||
|
||
if regionObs != nil {
|
||
// Regional: iterate observations from matching observers
|
||
for obsID := range regionObs {
|
||
obsList := s.byObserver[obsID]
|
||
for _, obs := range obsList {
|
||
totalObs++
|
||
tx := s.byTxID[obs.TransmissionID]
|
||
hash := ""
|
||
if tx != nil {
|
||
hash = tx.Hash
|
||
}
|
||
if hash != "" {
|
||
regionalHashes[hash] = true
|
||
}
|
||
|
||
ts := obs.Timestamp
|
||
if ts != "" {
|
||
if minTimestamp == "" || ts < minTimestamp {
|
||
minTimestamp = ts
|
||
}
|
||
if ts > maxTimestamp {
|
||
maxTimestamp = ts
|
||
}
|
||
}
|
||
|
||
// SNR/RSSI
|
||
if obs.SNR != nil {
|
||
snrVals = append(snrVals, *obs.SNR)
|
||
typeName := "UNK"
|
||
if tx != nil && tx.PayloadType != nil {
|
||
if n, ok := ptNames[*tx.PayloadType]; ok {
|
||
typeName = n
|
||
} else {
|
||
typeName = fmt.Sprintf("UNK(%d)", *tx.PayloadType)
|
||
}
|
||
}
|
||
if snrByType[typeName] == nil {
|
||
snrByType[typeName] = &struct{ vals []float64 }{}
|
||
}
|
||
snrByType[typeName].vals = append(snrByType[typeName].vals, *obs.SNR)
|
||
|
||
if obs.RSSI != nil {
|
||
scatterAll = append(scatterAll, struct{ snr, rssi float64 }{*obs.SNR, *obs.RSSI})
|
||
}
|
||
|
||
// Signal over time
|
||
if len(ts) >= 13 {
|
||
hr := ts[:13]
|
||
if sigTime[hr] == nil {
|
||
sigTime[hr] = &struct {
|
||
snrs []float64
|
||
count int
|
||
}{}
|
||
}
|
||
sigTime[hr].snrs = append(sigTime[hr].snrs, *obs.SNR)
|
||
sigTime[hr].count++
|
||
}
|
||
}
|
||
if obs.RSSI != nil {
|
||
rssiVals = append(rssiVals, *obs.RSSI)
|
||
}
|
||
|
||
// Packets per hour (unique by hash per hour)
|
||
if len(ts) >= 13 {
|
||
hr := ts[:13]
|
||
hk := hash + "|" + hr
|
||
if hash == "" || !seenHourHash[hk] {
|
||
if hash != "" {
|
||
seenHourHash[hk] = true
|
||
}
|
||
hourBuckets[hr]++
|
||
}
|
||
}
|
||
|
||
// Packet sizes (unique by hash)
|
||
if hash != "" && !seenSizeHashes[hash] && tx != nil && tx.RawHex != "" {
|
||
seenSizeHashes[hash] = true
|
||
packetSizes = append(packetSizes, len(tx.RawHex)/2)
|
||
}
|
||
|
||
// Payload type distribution (unique by hash)
|
||
if hash != "" && !seenTypeHashes[hash] && tx != nil && tx.PayloadType != nil {
|
||
seenTypeHashes[hash] = true
|
||
typeBuckets[*tx.PayloadType]++
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// No region: iterate all transmissions and their observations
|
||
for _, tx := range s.packets {
|
||
hash := tx.Hash
|
||
if hash != "" {
|
||
regionalHashes[hash] = true
|
||
if !seenSizeHashes[hash] && tx.RawHex != "" {
|
||
seenSizeHashes[hash] = true
|
||
packetSizes = append(packetSizes, len(tx.RawHex)/2)
|
||
}
|
||
if !seenTypeHashes[hash] && tx.PayloadType != nil {
|
||
seenTypeHashes[hash] = true
|
||
typeBuckets[*tx.PayloadType]++
|
||
}
|
||
}
|
||
|
||
// Pre-resolve type name once per transmission
|
||
typeName := "UNK"
|
||
if tx.PayloadType != nil {
|
||
if n, ok := ptNames[*tx.PayloadType]; ok {
|
||
typeName = n
|
||
} else {
|
||
typeName = fmt.Sprintf("UNK(%d)", *tx.PayloadType)
|
||
}
|
||
}
|
||
|
||
if len(tx.Observations) > 0 {
|
||
for _, obs := range tx.Observations {
|
||
totalObs++
|
||
ts := obs.Timestamp
|
||
if ts != "" {
|
||
if minTimestamp == "" || ts < minTimestamp {
|
||
minTimestamp = ts
|
||
}
|
||
if ts > maxTimestamp {
|
||
maxTimestamp = ts
|
||
}
|
||
}
|
||
|
||
if obs.SNR != nil {
|
||
snr := *obs.SNR
|
||
snrVals = append(snrVals, snr)
|
||
entry := snrByType[typeName]
|
||
if entry == nil {
|
||
entry = &struct{ vals []float64 }{}
|
||
snrByType[typeName] = entry
|
||
}
|
||
entry.vals = append(entry.vals, snr)
|
||
|
||
if obs.RSSI != nil {
|
||
scatterAll = append(scatterAll, struct{ snr, rssi float64 }{snr, *obs.RSSI})
|
||
}
|
||
|
||
if len(ts) >= 13 {
|
||
hr := ts[:13]
|
||
st := sigTime[hr]
|
||
if st == nil {
|
||
st = &struct {
|
||
snrs []float64
|
||
count int
|
||
}{}
|
||
sigTime[hr] = st
|
||
}
|
||
st.snrs = append(st.snrs, snr)
|
||
st.count++
|
||
}
|
||
}
|
||
if obs.RSSI != nil {
|
||
rssiVals = append(rssiVals, *obs.RSSI)
|
||
}
|
||
|
||
if len(ts) >= 13 {
|
||
hr := ts[:13]
|
||
hk := hash + "|" + hr
|
||
if hash == "" || !seenHourHash[hk] {
|
||
if hash != "" {
|
||
seenHourHash[hk] = true
|
||
}
|
||
hourBuckets[hr]++
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// Legacy: transmission without observations
|
||
totalObs++
|
||
if tx.SNR != nil {
|
||
snrVals = append(snrVals, *tx.SNR)
|
||
}
|
||
if tx.RSSI != nil {
|
||
rssiVals = append(rssiVals, *tx.RSSI)
|
||
}
|
||
ts := tx.FirstSeen
|
||
if ts != "" {
|
||
if minTimestamp == "" || ts < minTimestamp {
|
||
minTimestamp = ts
|
||
}
|
||
if ts > maxTimestamp {
|
||
maxTimestamp = ts
|
||
}
|
||
}
|
||
if len(ts) >= 13 {
|
||
hourBuckets[ts[:13]]++
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Stats helpers
|
||
sortedF64 := func(arr []float64) []float64 {
|
||
c := make([]float64, len(arr))
|
||
copy(c, arr)
|
||
sort.Float64s(c)
|
||
return c
|
||
}
|
||
medianF64 := func(arr []float64) float64 {
|
||
s := sortedF64(arr)
|
||
if len(s) == 0 {
|
||
return 0
|
||
}
|
||
return s[len(s)/2]
|
||
}
|
||
stddevF64 := func(arr []float64, avg float64) float64 {
|
||
if len(arr) == 0 {
|
||
return 0
|
||
}
|
||
sum := 0.0
|
||
for _, v := range arr {
|
||
d := v - avg
|
||
sum += d * d
|
||
}
|
||
return math.Sqrt(sum / float64(len(arr)))
|
||
}
|
||
minF64 := func(arr []float64) float64 {
|
||
if len(arr) == 0 {
|
||
return 0
|
||
}
|
||
m := arr[0]
|
||
for _, v := range arr[1:] {
|
||
if v < m {
|
||
m = v
|
||
}
|
||
}
|
||
return m
|
||
}
|
||
maxF64 := func(arr []float64) float64 {
|
||
if len(arr) == 0 {
|
||
return 0
|
||
}
|
||
m := arr[0]
|
||
for _, v := range arr[1:] {
|
||
if v > m {
|
||
m = v
|
||
}
|
||
}
|
||
return m
|
||
}
|
||
minInt := func(arr []int) int {
|
||
if len(arr) == 0 {
|
||
return 0
|
||
}
|
||
m := arr[0]
|
||
for _, v := range arr[1:] {
|
||
if v < m {
|
||
m = v
|
||
}
|
||
}
|
||
return m
|
||
}
|
||
maxInt := func(arr []int) int {
|
||
if len(arr) == 0 {
|
||
return 0
|
||
}
|
||
m := arr[0]
|
||
for _, v := range arr[1:] {
|
||
if v > m {
|
||
m = v
|
||
}
|
||
}
|
||
return m
|
||
}
|
||
|
||
snrAvg := 0.0
|
||
if len(snrVals) > 0 {
|
||
sum := 0.0
|
||
for _, v := range snrVals {
|
||
sum += v
|
||
}
|
||
snrAvg = sum / float64(len(snrVals))
|
||
}
|
||
rssiAvg := 0.0
|
||
if len(rssiVals) > 0 {
|
||
sum := 0.0
|
||
for _, v := range rssiVals {
|
||
sum += v
|
||
}
|
||
rssiAvg = sum / float64(len(rssiVals))
|
||
}
|
||
|
||
// Packets per hour
|
||
type hourCount struct {
|
||
Hour string `json:"hour"`
|
||
Count int `json:"count"`
|
||
}
|
||
hourKeys := make([]string, 0, len(hourBuckets))
|
||
for k := range hourBuckets {
|
||
hourKeys = append(hourKeys, k)
|
||
}
|
||
sort.Strings(hourKeys)
|
||
packetsPerHour := make([]hourCount, len(hourKeys))
|
||
for i, k := range hourKeys {
|
||
packetsPerHour[i] = hourCount{Hour: k, Count: hourBuckets[k]}
|
||
}
|
||
|
||
// Payload types
|
||
type ptEntry struct {
|
||
Type int `json:"type"`
|
||
Name string `json:"name"`
|
||
Count int `json:"count"`
|
||
}
|
||
payloadTypes := make([]ptEntry, 0, len(typeBuckets))
|
||
for t, c := range typeBuckets {
|
||
name := ptNames[t]
|
||
if name == "" {
|
||
name = fmt.Sprintf("UNK(%d)", t)
|
||
}
|
||
payloadTypes = append(payloadTypes, ptEntry{Type: t, Name: name, Count: c})
|
||
}
|
||
sort.Slice(payloadTypes, func(i, j int) bool { return payloadTypes[i].Count > payloadTypes[j].Count })
|
||
|
||
// SNR by type
|
||
type snrTypeEntry struct {
|
||
Name string `json:"name"`
|
||
Count int `json:"count"`
|
||
Avg float64 `json:"avg"`
|
||
Min float64 `json:"min"`
|
||
Max float64 `json:"max"`
|
||
}
|
||
snrByTypeArr := make([]snrTypeEntry, 0, len(snrByType))
|
||
for name, d := range snrByType {
|
||
sum := 0.0
|
||
for _, v := range d.vals {
|
||
sum += v
|
||
}
|
||
snrByTypeArr = append(snrByTypeArr, snrTypeEntry{
|
||
Name: name, Count: len(d.vals),
|
||
Avg: sum / float64(len(d.vals)),
|
||
Min: minF64(d.vals), Max: maxF64(d.vals),
|
||
})
|
||
}
|
||
sort.Slice(snrByTypeArr, func(i, j int) bool { return snrByTypeArr[i].Count > snrByTypeArr[j].Count })
|
||
|
||
// Signal over time
|
||
type sigTimeEntry struct {
|
||
Hour string `json:"hour"`
|
||
Count int `json:"count"`
|
||
AvgSnr float64 `json:"avgSnr"`
|
||
}
|
||
sigKeys := make([]string, 0, len(sigTime))
|
||
for k := range sigTime {
|
||
sigKeys = append(sigKeys, k)
|
||
}
|
||
sort.Strings(sigKeys)
|
||
signalOverTime := make([]sigTimeEntry, len(sigKeys))
|
||
for i, k := range sigKeys {
|
||
d := sigTime[k]
|
||
sum := 0.0
|
||
for _, v := range d.snrs {
|
||
sum += v
|
||
}
|
||
signalOverTime[i] = sigTimeEntry{Hour: k, Count: d.count, AvgSnr: sum / float64(d.count)}
|
||
}
|
||
|
||
// Scatter (downsample to 500)
|
||
type scatterPoint struct {
|
||
SNR float64 `json:"snr"`
|
||
RSSI float64 `json:"rssi"`
|
||
}
|
||
scatterStep := 1
|
||
if len(scatterAll) > 500 {
|
||
scatterStep = len(scatterAll) / 500
|
||
}
|
||
scatterData := make([]scatterPoint, 0, 500)
|
||
for i, p := range scatterAll {
|
||
if i%scatterStep == 0 {
|
||
scatterData = append(scatterData, scatterPoint{SNR: p.snr, RSSI: p.rssi})
|
||
}
|
||
}
|
||
|
||
// Histograms
|
||
buildHistogramF64 := func(values []float64, bins int) map[string]interface{} {
|
||
if len(values) == 0 {
|
||
return map[string]interface{}{"bins": []interface{}{}, "min": 0, "max": 0}
|
||
}
|
||
mn, mx := minF64(values), maxF64(values)
|
||
rng := mx - mn
|
||
if rng == 0 {
|
||
rng = 1
|
||
}
|
||
binWidth := rng / float64(bins)
|
||
counts := make([]int, bins)
|
||
for _, v := range values {
|
||
idx := int((v - mn) / binWidth)
|
||
if idx >= bins {
|
||
idx = bins - 1
|
||
}
|
||
counts[idx]++
|
||
}
|
||
binArr := make([]map[string]interface{}, bins)
|
||
for i, c := range counts {
|
||
binArr[i] = map[string]interface{}{"x": mn + float64(i)*binWidth, "w": binWidth, "count": c}
|
||
}
|
||
return map[string]interface{}{"bins": binArr, "min": mn, "max": mx}
|
||
}
|
||
buildHistogramInt := func(values []int, bins int) map[string]interface{} {
|
||
if len(values) == 0 {
|
||
return map[string]interface{}{"bins": []interface{}{}, "min": 0, "max": 0}
|
||
}
|
||
mn, mx := float64(minInt(values)), float64(maxInt(values))
|
||
rng := mx - mn
|
||
if rng == 0 {
|
||
rng = 1
|
||
}
|
||
binWidth := rng / float64(bins)
|
||
counts := make([]int, bins)
|
||
for _, v := range values {
|
||
idx := int((float64(v) - mn) / binWidth)
|
||
if idx >= bins {
|
||
idx = bins - 1
|
||
}
|
||
counts[idx]++
|
||
}
|
||
binArr := make([]map[string]interface{}, bins)
|
||
for i, c := range counts {
|
||
binArr[i] = map[string]interface{}{"x": mn + float64(i)*binWidth, "w": binWidth, "count": c}
|
||
}
|
||
return map[string]interface{}{"bins": binArr, "min": mn, "max": mx}
|
||
}
|
||
|
||
snrHistogram := buildHistogramF64(snrVals, 20)
|
||
rssiHistogram := buildHistogramF64(rssiVals, 20)
|
||
sizeHistogram := buildHistogramInt(packetSizes, 25)
|
||
|
||
// Time span from min/max timestamps tracked during first pass
|
||
timeSpanHours := 0.0
|
||
if minTimestamp != "" && maxTimestamp != "" && minTimestamp != maxTimestamp {
|
||
// Parse only 2 timestamps instead of 1.2M
|
||
parseTS := func(ts string) (time.Time, bool) {
|
||
t, err := time.Parse("2006-01-02 15:04:05", ts)
|
||
if err != nil {
|
||
t, err = time.Parse(time.RFC3339, ts)
|
||
}
|
||
if err != nil {
|
||
return time.Time{}, false
|
||
}
|
||
return t, true
|
||
}
|
||
if tMin, ok := parseTS(minTimestamp); ok {
|
||
if tMax, ok := parseTS(maxTimestamp); ok {
|
||
timeSpanHours = float64(tMax.UnixMilli()-tMin.UnixMilli()) / 3600000.0
|
||
}
|
||
}
|
||
}
|
||
|
||
// Avg packet size
|
||
avgPktSize := 0
|
||
if len(packetSizes) > 0 {
|
||
sum := 0
|
||
for _, v := range packetSizes {
|
||
sum += v
|
||
}
|
||
avgPktSize = sum / len(packetSizes)
|
||
}
|
||
|
||
snrStats := map[string]interface{}{"min": 0.0, "max": 0.0, "avg": 0.0, "median": 0.0, "stddev": 0.0}
|
||
if len(snrVals) > 0 {
|
||
snrStats = map[string]interface{}{
|
||
"min": minF64(snrVals), "max": maxF64(snrVals),
|
||
"avg": snrAvg, "median": medianF64(snrVals),
|
||
"stddev": stddevF64(snrVals, snrAvg),
|
||
}
|
||
}
|
||
rssiStats := map[string]interface{}{"min": 0.0, "max": 0.0, "avg": 0.0, "median": 0.0, "stddev": 0.0}
|
||
if len(rssiVals) > 0 {
|
||
rssiStats = map[string]interface{}{
|
||
"min": minF64(rssiVals), "max": maxF64(rssiVals),
|
||
"avg": rssiAvg, "median": medianF64(rssiVals),
|
||
"stddev": stddevF64(rssiVals, rssiAvg),
|
||
}
|
||
}
|
||
|
||
return map[string]interface{}{
|
||
"totalPackets": len(snrVals),
|
||
"totalAllPackets": totalObs,
|
||
"totalTransmissions": len(regionalHashes),
|
||
"snr": snrStats,
|
||
"rssi": rssiStats,
|
||
"snrValues": snrHistogram,
|
||
"rssiValues": rssiHistogram,
|
||
"packetSizes": sizeHistogram,
|
||
"minPacketSize": minInt(packetSizes),
|
||
"maxPacketSize": maxInt(packetSizes),
|
||
"avgPacketSize": avgPktSize,
|
||
"packetsPerHour": packetsPerHour,
|
||
"payloadTypes": payloadTypes,
|
||
"snrByType": snrByTypeArr,
|
||
"signalOverTime": signalOverTime,
|
||
"scatterData": scatterData,
|
||
"timeSpanHours": timeSpanHours,
|
||
}
|
||
}
|
||
|
||
// --- Topology Analytics ---
|
||
|
||
type nodeInfo struct {
|
||
PublicKey string
|
||
Name string
|
||
Role string
|
||
Lat float64
|
||
Lon float64
|
||
HasGPS bool
|
||
}
|
||
|
||
func (s *PacketStore) getAllNodes() []nodeInfo {
|
||
rows, err := s.db.conn.Query("SELECT public_key, name, role, lat, lon FROM nodes")
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
defer rows.Close()
|
||
var nodes []nodeInfo
|
||
for rows.Next() {
|
||
var pk string
|
||
var name, role sql.NullString
|
||
var lat, lon sql.NullFloat64
|
||
rows.Scan(&pk, &name, &role, &lat, &lon)
|
||
n := nodeInfo{PublicKey: pk, Name: nullStrVal(name), Role: nullStrVal(role)}
|
||
if lat.Valid && lon.Valid {
|
||
n.Lat = lat.Float64
|
||
n.Lon = lon.Float64
|
||
n.HasGPS = !(n.Lat == 0 && n.Lon == 0)
|
||
}
|
||
nodes = append(nodes, n)
|
||
}
|
||
return nodes
|
||
}
|
||
|
||
type prefixMap struct {
|
||
m map[string][]nodeInfo
|
||
}
|
||
|
||
// maxPrefixLen caps prefix map entries. MeshCore path hops use 2–6 char
|
||
// prefixes; 8 gives comfortable headroom while cutting map size from ~31×N
|
||
// entries to ~7×N (+ 1 full-key entry per node for exact-match lookups).
|
||
const maxPrefixLen = 8
|
||
|
||
func buildPrefixMap(nodes []nodeInfo) *prefixMap {
|
||
pm := &prefixMap{m: make(map[string][]nodeInfo, len(nodes)*(maxPrefixLen+1))}
|
||
for _, n := range nodes {
|
||
pk := strings.ToLower(n.PublicKey)
|
||
maxLen := maxPrefixLen
|
||
if maxLen > len(pk) {
|
||
maxLen = len(pk)
|
||
}
|
||
for l := 2; l <= maxLen; l++ {
|
||
pfx := pk[:l]
|
||
pm.m[pfx] = append(pm.m[pfx], n)
|
||
}
|
||
// Always add full pubkey so exact-match lookups work.
|
||
if len(pk) > maxPrefixLen {
|
||
pm.m[pk] = append(pm.m[pk], n)
|
||
}
|
||
}
|
||
return pm
|
||
}
|
||
|
||
// getCachedNodesAndPM returns cached node list and prefix map, rebuilding if stale.
|
||
// Must be called with s.mu held (RLock or Lock).
|
||
func (s *PacketStore) getCachedNodesAndPM() ([]nodeInfo, *prefixMap) {
|
||
s.cacheMu.Lock()
|
||
if s.nodeCache != nil && time.Since(s.nodeCacheTime) < 30*time.Second {
|
||
nodes, pm := s.nodeCache, s.nodePM
|
||
s.cacheMu.Unlock()
|
||
return nodes, pm
|
||
}
|
||
s.cacheMu.Unlock()
|
||
|
||
nodes := s.getAllNodes()
|
||
pm := buildPrefixMap(nodes)
|
||
|
||
s.cacheMu.Lock()
|
||
s.nodeCache = nodes
|
||
s.nodePM = pm
|
||
s.nodeCacheTime = time.Now()
|
||
s.cacheMu.Unlock()
|
||
|
||
return nodes, pm
|
||
}
|
||
|
||
// InvalidateNodeCache forces the next getCachedNodesAndPM call to rebuild.
|
||
func (s *PacketStore) InvalidateNodeCache() {
|
||
s.cacheMu.Lock()
|
||
s.nodeCache = nil
|
||
s.nodePM = nil
|
||
s.nodeCacheTime = time.Time{}
|
||
s.cacheMu.Unlock()
|
||
}
|
||
|
||
func (pm *prefixMap) resolve(hop string) *nodeInfo {
|
||
h := strings.ToLower(hop)
|
||
candidates := pm.m[h]
|
||
if len(candidates) == 0 {
|
||
return nil
|
||
}
|
||
if len(candidates) == 1 {
|
||
return &candidates[0]
|
||
}
|
||
// Multiple candidates: prefer one with GPS
|
||
for i := range candidates {
|
||
if candidates[i].HasGPS {
|
||
return &candidates[i]
|
||
}
|
||
}
|
||
return &candidates[0]
|
||
}
|
||
|
||
// resolveWithContext resolves a hop prefix using the neighbor affinity graph
|
||
// for disambiguation when multiple candidates match. It applies a 4-tier
|
||
// priority: (1) affinity graph score, (2) geographic proximity to context
|
||
// nodes, (3) GPS preference, (4) first match fallback.
|
||
//
|
||
// contextPubkeys are pubkeys of nodes that provide context for disambiguation
|
||
// (e.g., the originator, observer, or adjacent hops in the path).
|
||
// graph may be nil, in which case it falls back to the existing resolve().
|
||
func (pm *prefixMap) resolveWithContext(hop string, contextPubkeys []string, graph *NeighborGraph) (*nodeInfo, string, float64) {
|
||
h := strings.ToLower(hop)
|
||
candidates := pm.m[h]
|
||
if len(candidates) == 0 {
|
||
return nil, "no_match", 0
|
||
}
|
||
if len(candidates) == 1 {
|
||
return &candidates[0], "unique_prefix", 1.0
|
||
}
|
||
|
||
// Priority 1: Affinity graph score
|
||
//
|
||
// NOTE: We use raw Score() (count × time-decay) here rather than Jaccard
|
||
// similarity. Jaccard is used at the graph builder level (disambiguate() in
|
||
// neighbor_graph.go) to resolve ambiguous edges by comparing neighbor-set
|
||
// overlap. Here, edges are already resolved — we just need to pick the
|
||
// highest-affinity candidate among them. Raw score is appropriate because
|
||
// it reflects both observation frequency and recency, which are the right
|
||
// signals for "which candidate is this hop most likely referring to."
|
||
if graph != nil && len(contextPubkeys) > 0 {
|
||
type scored struct {
|
||
idx int
|
||
score float64
|
||
count int // observation count of the best-scoring edge
|
||
}
|
||
now := time.Now()
|
||
var scores []scored
|
||
for i, cand := range candidates {
|
||
candPK := strings.ToLower(cand.PublicKey)
|
||
bestScore := 0.0
|
||
bestCount := 0
|
||
for _, ctxPK := range contextPubkeys {
|
||
edges := graph.Neighbors(strings.ToLower(ctxPK))
|
||
for _, e := range edges {
|
||
if e.Ambiguous {
|
||
continue
|
||
}
|
||
otherPK := e.NodeA
|
||
if strings.EqualFold(otherPK, ctxPK) {
|
||
otherPK = e.NodeB
|
||
}
|
||
if strings.EqualFold(otherPK, candPK) {
|
||
s := e.Score(now)
|
||
if s > bestScore {
|
||
bestScore = s
|
||
bestCount = e.Count
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if bestScore > 0 {
|
||
scores = append(scores, scored{i, bestScore, bestCount})
|
||
}
|
||
}
|
||
|
||
if len(scores) >= 1 {
|
||
// Sort descending
|
||
for i := 0; i < len(scores)-1; i++ {
|
||
for j := i + 1; j < len(scores); j++ {
|
||
if scores[j].score > scores[i].score {
|
||
scores[i], scores[j] = scores[j], scores[i]
|
||
}
|
||
}
|
||
}
|
||
best := scores[0]
|
||
// Require both score ratio ≥ 3× AND minimum observations (mirrors
|
||
// disambiguate() in neighbor_graph.go which checks affinityMinObservations).
|
||
if best.count >= affinityMinObservations &&
|
||
(len(scores) == 1 || best.score >= affinityConfidenceRatio*scores[1].score) {
|
||
return &candidates[best.idx], "neighbor_affinity", best.score
|
||
}
|
||
// Scores too close — fall through to lower-priority strategies
|
||
}
|
||
}
|
||
|
||
// Priority 2: Geographic proximity (if context pubkeys have GPS and candidates have GPS)
|
||
if len(contextPubkeys) > 0 {
|
||
// Find GPS positions of context nodes from the prefix map or candidates
|
||
// We need nodeInfo for context pubkeys — look them up
|
||
var contextLat, contextLon float64
|
||
var contextGPSCount int
|
||
for _, ctxPK := range contextPubkeys {
|
||
ctxLower := strings.ToLower(ctxPK)
|
||
if infos, ok := pm.m[ctxLower]; ok && len(infos) == 1 && infos[0].HasGPS {
|
||
contextLat += infos[0].Lat
|
||
contextLon += infos[0].Lon
|
||
contextGPSCount++
|
||
}
|
||
}
|
||
if contextGPSCount > 0 {
|
||
contextLat /= float64(contextGPSCount)
|
||
contextLon /= float64(contextGPSCount)
|
||
|
||
bestIdx := -1
|
||
bestDist := math.MaxFloat64
|
||
for i, cand := range candidates {
|
||
if !cand.HasGPS {
|
||
continue
|
||
}
|
||
d := geoDistApprox(contextLat, contextLon, cand.Lat, cand.Lon)
|
||
if d < bestDist {
|
||
bestDist = d
|
||
bestIdx = i
|
||
}
|
||
}
|
||
if bestIdx >= 0 {
|
||
return &candidates[bestIdx], "geo_proximity", 0
|
||
}
|
||
}
|
||
}
|
||
|
||
// Priority 3: GPS preference
|
||
for i := range candidates {
|
||
if candidates[i].HasGPS {
|
||
return &candidates[i], "gps_preference", 0
|
||
}
|
||
}
|
||
|
||
// Priority 4: First match fallback
|
||
return &candidates[0], "first_match", 0
|
||
}
|
||
|
||
// geoDistApprox returns an approximate distance between two lat/lon points
|
||
// (equirectangular approximation, sufficient for relative comparison).
|
||
func geoDistApprox(lat1, lon1, lat2, lon2 float64) float64 {
|
||
dLat := (lat2 - lat1) * math.Pi / 180
|
||
dLon := (lon2 - lon1) * math.Pi / 180 * math.Cos((lat1+lat2)/2*math.Pi/180)
|
||
return math.Sqrt(dLat*dLat + dLon*dLon)
|
||
}
|
||
|
||
func parsePathJSON(pathJSON string) []string {
|
||
if pathJSON == "" || pathJSON == "[]" {
|
||
return nil
|
||
}
|
||
var hops []string
|
||
if json.Unmarshal([]byte(pathJSON), &hops) != nil {
|
||
return nil
|
||
}
|
||
return hops
|
||
}
|
||
|
||
func (s *PacketStore) GetAnalyticsTopology(region string) map[string]interface{} {
|
||
s.cacheMu.Lock()
|
||
if cached, ok := s.topoCache[region]; ok && time.Now().Before(cached.expiresAt) {
|
||
s.cacheHits++
|
||
s.cacheMu.Unlock()
|
||
return cached.data
|
||
}
|
||
s.cacheMisses++
|
||
s.cacheMu.Unlock()
|
||
|
||
result := s.computeAnalyticsTopology(region)
|
||
|
||
s.cacheMu.Lock()
|
||
s.topoCache[region] = &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)}
|
||
s.cacheMu.Unlock()
|
||
|
||
return result
|
||
}
|
||
|
||
func (s *PacketStore) computeAnalyticsTopology(region string) map[string]interface{} {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
var regionObs map[string]bool
|
||
if region != "" {
|
||
regionObs = s.resolveRegionObservers(region)
|
||
}
|
||
|
||
allNodes, pm := s.getCachedNodesAndPM()
|
||
_ = allNodes // only pm is needed for topology
|
||
hopCache := make(map[string]*nodeInfo)
|
||
|
||
resolveHop := func(hop string) *nodeInfo {
|
||
if cached, ok := hopCache[hop]; ok {
|
||
return cached
|
||
}
|
||
r, _, _ := pm.resolveWithContext(hop, nil, s.graph)
|
||
hopCache[hop] = r
|
||
return r
|
||
}
|
||
|
||
hopCounts := map[int]int{}
|
||
var allHopsList []int
|
||
hopSnr := map[int][]float64{}
|
||
hopFreq := map[string]int{}
|
||
pairFreq := map[string]int{}
|
||
observerMap := map[string]string{} // observer_id → observer_name
|
||
perObserver := map[string]map[string]*struct{ minDist, maxDist, count int }{}
|
||
|
||
for _, tx := range s.packets {
|
||
hops := txGetParsedPath(tx)
|
||
if len(hops) == 0 {
|
||
continue
|
||
}
|
||
if regionObs != nil {
|
||
match := false
|
||
for _, obs := range tx.Observations {
|
||
if regionObs[obs.ObserverID] {
|
||
match = true
|
||
break
|
||
}
|
||
}
|
||
if !match {
|
||
continue
|
||
}
|
||
}
|
||
|
||
n := len(hops)
|
||
hopCounts[n]++
|
||
allHopsList = append(allHopsList, n)
|
||
if tx.SNR != nil {
|
||
hopSnr[n] = append(hopSnr[n], *tx.SNR)
|
||
}
|
||
for _, h := range hops {
|
||
hopFreq[h]++
|
||
}
|
||
for i := 0; i < len(hops)-1; i++ {
|
||
a, b := hops[i], hops[i+1]
|
||
if a > b {
|
||
a, b = b, a
|
||
}
|
||
pairFreq[a+"|"+b]++
|
||
}
|
||
|
||
obsID := tx.ObserverID
|
||
if obsID != "" {
|
||
observerMap[obsID] = tx.ObserverName
|
||
}
|
||
if _, ok := perObserver[obsID]; !ok {
|
||
perObserver[obsID] = map[string]*struct{ minDist, maxDist, count int }{}
|
||
}
|
||
for i, h := range hops {
|
||
dist := n - i
|
||
entry := perObserver[obsID][h]
|
||
if entry == nil {
|
||
entry = &struct{ minDist, maxDist, count int }{dist, dist, 0}
|
||
perObserver[obsID][h] = entry
|
||
}
|
||
if dist < entry.minDist {
|
||
entry.minDist = dist
|
||
}
|
||
if dist > entry.maxDist {
|
||
entry.maxDist = dist
|
||
}
|
||
entry.count++
|
||
}
|
||
}
|
||
|
||
// Hop distribution
|
||
hopDist := make([]map[string]interface{}, 0)
|
||
for h, c := range hopCounts {
|
||
if h <= 25 {
|
||
hopDist = append(hopDist, map[string]interface{}{"hops": h, "count": c})
|
||
}
|
||
}
|
||
sort.Slice(hopDist, func(i, j int) bool {
|
||
return hopDist[i]["hops"].(int) < hopDist[j]["hops"].(int)
|
||
})
|
||
|
||
avgHops := 0.0
|
||
if len(allHopsList) > 0 {
|
||
sum := 0
|
||
for _, v := range allHopsList {
|
||
sum += v
|
||
}
|
||
avgHops = float64(sum) / float64(len(allHopsList))
|
||
}
|
||
medianHops := 0
|
||
if len(allHopsList) > 0 {
|
||
sorted := make([]int, len(allHopsList))
|
||
copy(sorted, allHopsList)
|
||
sort.Ints(sorted)
|
||
medianHops = sorted[len(sorted)/2]
|
||
}
|
||
maxHops := 0
|
||
for _, v := range allHopsList {
|
||
if v > maxHops {
|
||
maxHops = v
|
||
}
|
||
}
|
||
|
||
// Top repeaters
|
||
type freqEntry struct {
|
||
hop string
|
||
count int
|
||
}
|
||
freqList := make([]freqEntry, 0, len(hopFreq))
|
||
for h, c := range hopFreq {
|
||
freqList = append(freqList, freqEntry{h, c})
|
||
}
|
||
sort.Slice(freqList, func(i, j int) bool { return freqList[i].count > freqList[j].count })
|
||
topRepeaters := make([]map[string]interface{}, 0)
|
||
for i, e := range freqList {
|
||
if i >= 20 {
|
||
break
|
||
}
|
||
r := resolveHop(e.hop)
|
||
entry := map[string]interface{}{"hop": e.hop, "count": e.count, "name": nil, "pubkey": nil}
|
||
if r != nil {
|
||
entry["name"] = r.Name
|
||
entry["pubkey"] = r.PublicKey
|
||
}
|
||
topRepeaters = append(topRepeaters, entry)
|
||
}
|
||
|
||
// Top pairs
|
||
pairList := make([]freqEntry, 0, len(pairFreq))
|
||
for p, c := range pairFreq {
|
||
pairList = append(pairList, freqEntry{p, c})
|
||
}
|
||
sort.Slice(pairList, func(i, j int) bool { return pairList[i].count > pairList[j].count })
|
||
topPairs := make([]map[string]interface{}, 0)
|
||
for i, e := range pairList {
|
||
if i >= 15 {
|
||
break
|
||
}
|
||
parts := strings.SplitN(e.hop, "|", 2)
|
||
rA := resolveHop(parts[0])
|
||
rB := resolveHop(parts[1])
|
||
entry := map[string]interface{}{
|
||
"hopA": parts[0], "hopB": parts[1], "count": e.count,
|
||
"nameA": nil, "nameB": nil, "pubkeyA": nil, "pubkeyB": nil,
|
||
}
|
||
if rA != nil {
|
||
entry["nameA"] = rA.Name
|
||
entry["pubkeyA"] = rA.PublicKey
|
||
}
|
||
if rB != nil {
|
||
entry["nameB"] = rB.Name
|
||
entry["pubkeyB"] = rB.PublicKey
|
||
}
|
||
topPairs = append(topPairs, entry)
|
||
}
|
||
|
||
// Hops vs SNR
|
||
hopsVsSnr := make([]map[string]interface{}, 0)
|
||
for h, snrs := range hopSnr {
|
||
if h > 20 {
|
||
continue
|
||
}
|
||
sum := 0.0
|
||
for _, v := range snrs {
|
||
sum += v
|
||
}
|
||
hopsVsSnr = append(hopsVsSnr, map[string]interface{}{
|
||
"hops": h, "count": len(snrs), "avgSnr": sum / float64(len(snrs)),
|
||
})
|
||
}
|
||
sort.Slice(hopsVsSnr, func(i, j int) bool {
|
||
return hopsVsSnr[i]["hops"].(int) < hopsVsSnr[j]["hops"].(int)
|
||
})
|
||
|
||
// Observers list
|
||
observers := make([]map[string]interface{}, 0)
|
||
for id, name := range observerMap {
|
||
n := name
|
||
if n == "" {
|
||
n = id
|
||
}
|
||
observers = append(observers, map[string]interface{}{"id": id, "name": n})
|
||
}
|
||
|
||
// Per-observer reachability
|
||
perObserverReach := map[string]interface{}{}
|
||
for obsID, nodes := range perObserver {
|
||
obsName := observerMap[obsID]
|
||
if obsName == "" {
|
||
obsName = obsID
|
||
}
|
||
byDist := map[int][]map[string]interface{}{}
|
||
for hop, data := range nodes {
|
||
d := data.minDist
|
||
if d > 15 {
|
||
continue
|
||
}
|
||
r := resolveHop(hop)
|
||
entry := map[string]interface{}{
|
||
"hop": hop, "name": nil, "pubkey": nil,
|
||
"count": data.count, "distRange": nil,
|
||
}
|
||
if r != nil {
|
||
entry["name"] = r.Name
|
||
entry["pubkey"] = r.PublicKey
|
||
}
|
||
if data.minDist != data.maxDist {
|
||
entry["distRange"] = fmt.Sprintf("%d-%d", data.minDist, data.maxDist)
|
||
}
|
||
byDist[d] = append(byDist[d], entry)
|
||
}
|
||
rings := make([]map[string]interface{}, 0)
|
||
for dist, nodeList := range byDist {
|
||
sort.Slice(nodeList, func(i, j int) bool {
|
||
return nodeList[i]["count"].(int) > nodeList[j]["count"].(int)
|
||
})
|
||
rings = append(rings, map[string]interface{}{"hops": dist, "nodes": nodeList})
|
||
}
|
||
sort.Slice(rings, func(i, j int) bool {
|
||
return rings[i]["hops"].(int) < rings[j]["hops"].(int)
|
||
})
|
||
perObserverReach[obsID] = map[string]interface{}{
|
||
"observer_name": obsName,
|
||
"rings": rings,
|
||
}
|
||
}
|
||
|
||
// Cross-observer: build from perObserver
|
||
crossObserver := map[string][]map[string]interface{}{}
|
||
bestPath := map[string]map[string]interface{}{}
|
||
for obsID, nodes := range perObserver {
|
||
obsName := observerMap[obsID]
|
||
if obsName == "" {
|
||
obsName = obsID
|
||
}
|
||
for hop, data := range nodes {
|
||
crossObserver[hop] = append(crossObserver[hop], map[string]interface{}{
|
||
"observer_id": obsID, "observer_name": obsName,
|
||
"minDist": data.minDist, "count": data.count,
|
||
})
|
||
if bp, ok := bestPath[hop]; !ok || data.minDist < bp["minDist"].(int) {
|
||
bestPath[hop] = map[string]interface{}{
|
||
"minDist": data.minDist, "observer_id": obsID, "observer_name": obsName,
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Multi-observer nodes
|
||
multiObsNodes := make([]map[string]interface{}, 0)
|
||
for hop, obs := range crossObserver {
|
||
if len(obs) <= 1 {
|
||
continue
|
||
}
|
||
sort.Slice(obs, func(i, j int) bool {
|
||
return obs[i]["minDist"].(int) < obs[j]["minDist"].(int)
|
||
})
|
||
r := resolveHop(hop)
|
||
entry := map[string]interface{}{
|
||
"hop": hop, "name": nil, "pubkey": nil, "observers": obs,
|
||
}
|
||
if r != nil {
|
||
entry["name"] = r.Name
|
||
entry["pubkey"] = r.PublicKey
|
||
}
|
||
multiObsNodes = append(multiObsNodes, entry)
|
||
}
|
||
sort.Slice(multiObsNodes, func(i, j int) bool {
|
||
return len(multiObsNodes[i]["observers"].([]map[string]interface{})) >
|
||
len(multiObsNodes[j]["observers"].([]map[string]interface{}))
|
||
})
|
||
if len(multiObsNodes) > 50 {
|
||
multiObsNodes = multiObsNodes[:50]
|
||
}
|
||
|
||
// Best path list
|
||
bestPathList := make([]map[string]interface{}, 0, len(bestPath))
|
||
for hop, data := range bestPath {
|
||
r := resolveHop(hop)
|
||
entry := map[string]interface{}{
|
||
"hop": hop, "name": nil, "pubkey": nil,
|
||
"minDist": data["minDist"], "observer_id": data["observer_id"],
|
||
"observer_name": data["observer_name"],
|
||
}
|
||
if r != nil {
|
||
entry["name"] = r.Name
|
||
entry["pubkey"] = r.PublicKey
|
||
}
|
||
bestPathList = append(bestPathList, entry)
|
||
}
|
||
sort.Slice(bestPathList, func(i, j int) bool {
|
||
return bestPathList[i]["minDist"].(int) < bestPathList[j]["minDist"].(int)
|
||
})
|
||
if len(bestPathList) > 50 {
|
||
bestPathList = bestPathList[:50]
|
||
}
|
||
|
||
// Use DB 7-day active node count (matches /api/stats totalNodes)
|
||
uniqueNodes := 0
|
||
if s.db != nil {
|
||
if stats, err := s.db.GetStats(); err == nil {
|
||
uniqueNodes = stats.TotalNodes
|
||
}
|
||
}
|
||
|
||
return map[string]interface{}{
|
||
"uniqueNodes": uniqueNodes,
|
||
"avgHops": avgHops,
|
||
"medianHops": medianHops,
|
||
"maxHops": maxHops,
|
||
"hopDistribution": hopDist,
|
||
"topRepeaters": topRepeaters,
|
||
"topPairs": topPairs,
|
||
"hopsVsSnr": hopsVsSnr,
|
||
"observers": observers,
|
||
"perObserverReach": perObserverReach,
|
||
"multiObsNodes": multiObsNodes,
|
||
"bestPathList": bestPathList,
|
||
}
|
||
}
|
||
|
||
// --- Distance Analytics ---
|
||
|
||
func haversineKm(lat1, lon1, lat2, lon2 float64) float64 {
|
||
const R = 6371.0
|
||
dLat := (lat2 - lat1) * math.Pi / 180
|
||
dLon := (lon2 - lon1) * math.Pi / 180
|
||
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
|
||
math.Cos(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)*
|
||
math.Sin(dLon/2)*math.Sin(dLon/2)
|
||
return R * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
||
}
|
||
|
||
func (s *PacketStore) GetAnalyticsDistance(region string) map[string]interface{} {
|
||
s.cacheMu.Lock()
|
||
if cached, ok := s.distCache[region]; ok && time.Now().Before(cached.expiresAt) {
|
||
s.cacheHits++
|
||
s.cacheMu.Unlock()
|
||
return cached.data
|
||
}
|
||
s.cacheMisses++
|
||
s.cacheMu.Unlock()
|
||
|
||
result := s.computeAnalyticsDistance(region)
|
||
|
||
s.cacheMu.Lock()
|
||
s.distCache[region] = &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)}
|
||
s.cacheMu.Unlock()
|
||
|
||
return result
|
||
}
|
||
|
||
func (s *PacketStore) computeAnalyticsDistance(region string) map[string]interface{} {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
var regionObs map[string]bool
|
||
if region != "" {
|
||
regionObs = s.resolveRegionObservers(region)
|
||
}
|
||
|
||
// Build region match set using precomputed tx pointers
|
||
var matchSet map[*StoreTx]bool
|
||
if regionObs != nil {
|
||
matchSet = make(map[*StoreTx]bool)
|
||
seen := make(map[*StoreTx]bool)
|
||
for i := range s.distHops {
|
||
tx := s.distHops[i].tx
|
||
if seen[tx] {
|
||
continue
|
||
}
|
||
seen[tx] = true
|
||
for _, obs := range tx.Observations {
|
||
if regionObs[obs.ObserverID] {
|
||
matchSet[tx] = true
|
||
break
|
||
}
|
||
}
|
||
}
|
||
for i := range s.distPaths {
|
||
tx := s.distPaths[i].tx
|
||
if seen[tx] {
|
||
continue
|
||
}
|
||
seen[tx] = true
|
||
for _, obs := range tx.Observations {
|
||
if regionObs[obs.ObserverID] {
|
||
matchSet[tx] = true
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Filter precomputed hop records (copy to avoid mutating precomputed data during sort)
|
||
filteredHops := make([]distHopRecord, 0, len(s.distHops))
|
||
for i := range s.distHops {
|
||
if matchSet == nil || matchSet[s.distHops[i].tx] {
|
||
filteredHops = append(filteredHops, s.distHops[i])
|
||
}
|
||
}
|
||
|
||
// Filter precomputed path records
|
||
filteredPaths := make([]distPathRecord, 0, len(s.distPaths))
|
||
for i := range s.distPaths {
|
||
if matchSet == nil || matchSet[s.distPaths[i].tx] {
|
||
filteredPaths = append(filteredPaths, s.distPaths[i])
|
||
}
|
||
}
|
||
|
||
// Build category stats and time series from precomputed data
|
||
catDists := map[string][]float64{"R↔R": {}, "C↔R": {}, "C↔C": {}}
|
||
distByHour := map[string][]float64{}
|
||
for i := range filteredHops {
|
||
h := &filteredHops[i]
|
||
catDists[h.Type] = append(catDists[h.Type], h.Dist)
|
||
if h.HourBucket != "" {
|
||
distByHour[h.HourBucket] = append(distByHour[h.HourBucket], h.Dist)
|
||
}
|
||
}
|
||
|
||
// Sort and pick top hops
|
||
sort.Slice(filteredHops, func(i, j int) bool { return filteredHops[i].Dist > filteredHops[j].Dist })
|
||
topHops := make([]map[string]interface{}, 0)
|
||
for i := range filteredHops {
|
||
if i >= 50 {
|
||
break
|
||
}
|
||
h := &filteredHops[i]
|
||
topHops = append(topHops, map[string]interface{}{
|
||
"fromName": h.FromName, "fromPk": h.FromPk,
|
||
"toName": h.ToName, "toPk": h.ToPk,
|
||
"dist": h.Dist, "type": h.Type,
|
||
"snr": floatPtrOrNil(h.SNR), "hash": h.Hash, "timestamp": h.Timestamp,
|
||
})
|
||
}
|
||
|
||
// Sort and pick top paths
|
||
sort.Slice(filteredPaths, func(i, j int) bool { return filteredPaths[i].TotalDist > filteredPaths[j].TotalDist })
|
||
topPaths := make([]map[string]interface{}, 0)
|
||
for i := range filteredPaths {
|
||
if i >= 20 {
|
||
break
|
||
}
|
||
p := &filteredPaths[i]
|
||
hops := make([]map[string]interface{}, len(p.Hops))
|
||
for j, hd := range p.Hops {
|
||
hops[j] = map[string]interface{}{
|
||
"fromName": hd.FromName, "fromPk": hd.FromPk,
|
||
"toName": hd.ToName, "toPk": hd.ToPk,
|
||
"dist": hd.Dist,
|
||
}
|
||
}
|
||
topPaths = append(topPaths, map[string]interface{}{
|
||
"hash": p.Hash, "totalDist": p.TotalDist,
|
||
"hopCount": p.HopCount, "timestamp": p.Timestamp, "hops": hops,
|
||
})
|
||
}
|
||
|
||
// Category stats
|
||
medianF := func(arr []float64) float64 {
|
||
if len(arr) == 0 {
|
||
return 0
|
||
}
|
||
c := make([]float64, len(arr))
|
||
copy(c, arr)
|
||
sort.Float64s(c)
|
||
return c[len(c)/2]
|
||
}
|
||
minF := func(arr []float64) float64 {
|
||
if len(arr) == 0 {
|
||
return 0
|
||
}
|
||
m := arr[0]
|
||
for _, v := range arr[1:] {
|
||
if v < m {
|
||
m = v
|
||
}
|
||
}
|
||
return m
|
||
}
|
||
maxF := func(arr []float64) float64 {
|
||
if len(arr) == 0 {
|
||
return 0
|
||
}
|
||
m := arr[0]
|
||
for _, v := range arr[1:] {
|
||
if v > m {
|
||
m = v
|
||
}
|
||
}
|
||
return m
|
||
}
|
||
|
||
catStats := map[string]interface{}{}
|
||
for cat, dists := range catDists {
|
||
if len(dists) == 0 {
|
||
catStats[cat] = map[string]interface{}{"count": 0, "avg": 0, "median": 0, "min": 0, "max": 0}
|
||
continue
|
||
}
|
||
sum := 0.0
|
||
for _, v := range dists {
|
||
sum += v
|
||
}
|
||
avg := sum / float64(len(dists))
|
||
catStats[cat] = map[string]interface{}{
|
||
"count": len(dists),
|
||
"avg": math.Round(avg*100) / 100,
|
||
"median": math.Round(medianF(dists)*100) / 100,
|
||
"min": math.Round(minF(dists)*100) / 100,
|
||
"max": math.Round(maxF(dists)*100) / 100,
|
||
}
|
||
}
|
||
|
||
// Distance histogram
|
||
var distHistogram interface{} = []interface{}{}
|
||
allDists := make([]float64, len(filteredHops))
|
||
for i := range filteredHops {
|
||
allDists[i] = filteredHops[i].Dist
|
||
}
|
||
if len(allDists) > 0 {
|
||
hMin, hMax := minF(allDists), maxF(allDists)
|
||
binCount := 25
|
||
binW := (hMax - hMin) / float64(binCount)
|
||
if binW == 0 {
|
||
binW = 1
|
||
}
|
||
bins := make([]int, binCount)
|
||
for _, d := range allDists {
|
||
idx := int(math.Floor((d - hMin) / binW))
|
||
if idx >= binCount {
|
||
idx = binCount - 1
|
||
}
|
||
if idx < 0 {
|
||
idx = 0
|
||
}
|
||
bins[idx]++
|
||
}
|
||
binArr := make([]map[string]interface{}, binCount)
|
||
for i, c := range bins {
|
||
binArr[i] = map[string]interface{}{
|
||
"x": math.Round((hMin+float64(i)*binW)*10) / 10,
|
||
"w": math.Round(binW*10) / 10,
|
||
"count": c,
|
||
}
|
||
}
|
||
distHistogram = map[string]interface{}{"bins": binArr, "min": hMin, "max": hMax}
|
||
}
|
||
|
||
// Distance over time
|
||
timeKeys := make([]string, 0, len(distByHour))
|
||
for k := range distByHour {
|
||
timeKeys = append(timeKeys, k)
|
||
}
|
||
sort.Strings(timeKeys)
|
||
distOverTime := make([]map[string]interface{}, 0, len(timeKeys))
|
||
for _, hour := range timeKeys {
|
||
dists := distByHour[hour]
|
||
sum := 0.0
|
||
for _, v := range dists {
|
||
sum += v
|
||
}
|
||
distOverTime = append(distOverTime, map[string]interface{}{
|
||
"hour": hour,
|
||
"avg": math.Round(sum/float64(len(dists))*100) / 100,
|
||
"count": len(dists),
|
||
})
|
||
}
|
||
|
||
// Summary
|
||
summary := map[string]interface{}{
|
||
"totalHops": len(filteredHops),
|
||
"totalPaths": len(filteredPaths),
|
||
"avgDist": 0.0,
|
||
"maxDist": 0.0,
|
||
}
|
||
if len(allDists) > 0 {
|
||
sum := 0.0
|
||
for _, v := range allDists {
|
||
sum += v
|
||
}
|
||
summary["avgDist"] = math.Round(sum/float64(len(allDists))*100) / 100
|
||
summary["maxDist"] = math.Round(maxF(allDists)*100) / 100
|
||
}
|
||
|
||
return map[string]interface{}{
|
||
"summary": summary,
|
||
"topHops": topHops,
|
||
"topPaths": topPaths,
|
||
"catStats": catStats,
|
||
"distHistogram": distHistogram,
|
||
"distOverTime": distOverTime,
|
||
}
|
||
}
|
||
|
||
// --- Hash Sizes Analytics ---
|
||
|
||
func (s *PacketStore) GetAnalyticsHashSizes(region string) map[string]interface{} {
|
||
s.cacheMu.Lock()
|
||
if cached, ok := s.hashCache[region]; ok && time.Now().Before(cached.expiresAt) {
|
||
s.cacheHits++
|
||
s.cacheMu.Unlock()
|
||
return cached.data
|
||
}
|
||
s.cacheMisses++
|
||
s.cacheMu.Unlock()
|
||
|
||
result := s.computeAnalyticsHashSizes(region)
|
||
|
||
s.cacheMu.Lock()
|
||
s.hashCache[region] = &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)}
|
||
s.cacheMu.Unlock()
|
||
|
||
return result
|
||
}
|
||
|
||
func (s *PacketStore) computeAnalyticsHashSizes(region string) map[string]interface{} {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
var regionObs map[string]bool
|
||
if region != "" {
|
||
regionObs = s.resolveRegionObservers(region)
|
||
}
|
||
|
||
_, pm := s.getCachedNodesAndPM()
|
||
|
||
distribution := map[string]int{"1": 0, "2": 0, "3": 0}
|
||
byHour := map[string]map[string]int{}
|
||
byNode := map[string]map[string]interface{}{}
|
||
uniqueHops := map[string]map[string]interface{}{}
|
||
total := 0
|
||
|
||
for _, tx := range s.packets {
|
||
if tx.RawHex == "" {
|
||
continue
|
||
}
|
||
if regionObs != nil {
|
||
match := false
|
||
for _, obs := range tx.Observations {
|
||
if regionObs[obs.ObserverID] {
|
||
match = true
|
||
break
|
||
}
|
||
}
|
||
if !match {
|
||
continue
|
||
}
|
||
}
|
||
|
||
// Parse header and path byte
|
||
if len(tx.RawHex) < 4 {
|
||
continue
|
||
}
|
||
header, err := strconv.ParseUint(tx.RawHex[:2], 16, 8)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
routeType := header & 0x03
|
||
pathByteIdx := 1
|
||
if routeType == 0 || routeType == 3 {
|
||
pathByteIdx = 5
|
||
}
|
||
hexStart := pathByteIdx * 2
|
||
hexEnd := hexStart + 2
|
||
if hexEnd > len(tx.RawHex) {
|
||
continue
|
||
}
|
||
actualPathByte, err := strconv.ParseUint(tx.RawHex[hexStart:hexEnd], 16, 8)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
|
||
hashSize := int((actualPathByte>>6)&0x3) + 1
|
||
if hashSize > 3 {
|
||
continue
|
||
}
|
||
|
||
// Track originator from advert packets (including zero-hop adverts,
|
||
// keyed by pubKey so same-name nodes don't merge).
|
||
if tx.PayloadType != nil && *tx.PayloadType == 4 && tx.DecodedJSON != "" {
|
||
var d map[string]interface{}
|
||
if json.Unmarshal([]byte(tx.DecodedJSON), &d) == nil {
|
||
pk := ""
|
||
if v, ok := d["pubKey"].(string); ok {
|
||
pk = v
|
||
} else if v, ok := d["public_key"].(string); ok {
|
||
pk = v
|
||
}
|
||
if pk != "" {
|
||
name := ""
|
||
if n, ok := d["name"].(string); ok {
|
||
name = n
|
||
}
|
||
if name == "" {
|
||
if len(pk) >= 8 {
|
||
name = pk[:8]
|
||
} else {
|
||
name = pk
|
||
}
|
||
}
|
||
if byNode[pk] == nil {
|
||
byNode[pk] = map[string]interface{}{
|
||
"hashSize": hashSize, "packets": 0,
|
||
"lastSeen": tx.FirstSeen, "name": name,
|
||
}
|
||
}
|
||
byNode[pk]["packets"] = byNode[pk]["packets"].(int) + 1
|
||
byNode[pk]["hashSize"] = hashSize
|
||
byNode[pk]["lastSeen"] = tx.FirstSeen
|
||
}
|
||
}
|
||
}
|
||
|
||
// Distribution/hourly/uniqueHops only for packets with relay hops
|
||
hops := txGetParsedPath(tx)
|
||
if len(hops) == 0 {
|
||
continue
|
||
}
|
||
total++
|
||
|
||
sizeKey := strconv.Itoa(hashSize)
|
||
distribution[sizeKey]++
|
||
|
||
// Hourly buckets
|
||
if len(tx.FirstSeen) >= 13 {
|
||
hour := tx.FirstSeen[:13]
|
||
if byHour[hour] == nil {
|
||
byHour[hour] = map[string]int{"1": 0, "2": 0, "3": 0}
|
||
}
|
||
byHour[hour][sizeKey]++
|
||
}
|
||
|
||
// Track unique hops with their sizes
|
||
for _, hop := range hops {
|
||
if uniqueHops[hop] == nil {
|
||
hopLower := strings.ToLower(hop)
|
||
candidates := pm.m[hopLower]
|
||
var matchName, matchPk interface{}
|
||
if len(candidates) > 0 {
|
||
matchName = candidates[0].Name
|
||
matchPk = candidates[0].PublicKey
|
||
}
|
||
uniqueHops[hop] = map[string]interface{}{
|
||
"size": (len(hop) + 1) / 2, "count": 0,
|
||
"name": matchName, "pubkey": matchPk,
|
||
}
|
||
}
|
||
uniqueHops[hop]["count"] = uniqueHops[hop]["count"].(int) + 1
|
||
}
|
||
}
|
||
|
||
// Sort hourly data
|
||
hourKeys := make([]string, 0, len(byHour))
|
||
for k := range byHour {
|
||
hourKeys = append(hourKeys, k)
|
||
}
|
||
sort.Strings(hourKeys)
|
||
hourly := make([]map[string]interface{}, 0, len(hourKeys))
|
||
for _, hour := range hourKeys {
|
||
sizes := byHour[hour]
|
||
hourly = append(hourly, map[string]interface{}{
|
||
"hour": hour, "1": sizes["1"], "2": sizes["2"], "3": sizes["3"],
|
||
})
|
||
}
|
||
|
||
// Top hops by frequency
|
||
type hopEntry struct {
|
||
hex string
|
||
data map[string]interface{}
|
||
}
|
||
hopList := make([]hopEntry, 0, len(uniqueHops))
|
||
for hex, data := range uniqueHops {
|
||
hopList = append(hopList, hopEntry{hex, data})
|
||
}
|
||
sort.Slice(hopList, func(i, j int) bool {
|
||
return hopList[i].data["count"].(int) > hopList[j].data["count"].(int)
|
||
})
|
||
topHops := make([]map[string]interface{}, 0)
|
||
for i, e := range hopList {
|
||
if i >= 50 {
|
||
break
|
||
}
|
||
topHops = append(topHops, map[string]interface{}{
|
||
"hex": e.hex, "size": e.data["size"], "count": e.data["count"],
|
||
"name": e.data["name"], "pubkey": e.data["pubkey"],
|
||
})
|
||
}
|
||
|
||
// Multi-byte nodes
|
||
multiByteNodes := make([]map[string]interface{}, 0)
|
||
for pk, data := range byNode {
|
||
if data["hashSize"].(int) > 1 {
|
||
multiByteNodes = append(multiByteNodes, map[string]interface{}{
|
||
"name": data["name"], "hashSize": data["hashSize"],
|
||
"packets": data["packets"], "lastSeen": data["lastSeen"],
|
||
"pubkey": pk,
|
||
})
|
||
}
|
||
}
|
||
sort.Slice(multiByteNodes, func(i, j int) bool {
|
||
return multiByteNodes[i]["packets"].(int) > multiByteNodes[j]["packets"].(int)
|
||
})
|
||
|
||
// Distribution by repeaters: count unique nodes per hash size
|
||
distributionByRepeaters := map[string]int{"1": 0, "2": 0, "3": 0}
|
||
for _, data := range byNode {
|
||
hs := data["hashSize"].(int)
|
||
key := strconv.Itoa(hs)
|
||
distributionByRepeaters[key]++
|
||
}
|
||
|
||
return map[string]interface{}{
|
||
"total": total,
|
||
"distribution": distribution,
|
||
"distributionByRepeaters": distributionByRepeaters,
|
||
"hourly": hourly,
|
||
"topHops": topHops,
|
||
"multiByteNodes": multiByteNodes,
|
||
}
|
||
}
|
||
|
||
// hashSizeNodeInfo holds per-node hash size tracking data.
|
||
type hashSizeNodeInfo struct {
|
||
HashSize int
|
||
AllSizes map[int]bool
|
||
Seq []int
|
||
Inconsistent bool
|
||
}
|
||
|
||
// --- Hash Collision Analytics ---
|
||
|
||
// GetAnalyticsHashCollisions returns pre-computed hash collision analysis.
|
||
// This moves the O(n²) distance computation from the frontend to the server.
|
||
func (s *PacketStore) GetAnalyticsHashCollisions(region string) map[string]interface{} {
|
||
s.cacheMu.Lock()
|
||
if cached, ok := s.collisionCache[region]; ok && time.Now().Before(cached.expiresAt) {
|
||
s.cacheHits++
|
||
s.cacheMu.Unlock()
|
||
return cached.data
|
||
}
|
||
s.cacheMisses++
|
||
s.cacheMu.Unlock()
|
||
|
||
result := s.computeHashCollisions(region)
|
||
|
||
s.cacheMu.Lock()
|
||
s.collisionCache[region] = &cachedResult{data: result, expiresAt: time.Now().Add(s.collisionCacheTTL)}
|
||
s.cacheMu.Unlock()
|
||
|
||
return result
|
||
}
|
||
|
||
// collisionNode is a lightweight node representation for collision analysis.
|
||
type collisionNode struct {
|
||
PublicKey string `json:"public_key"`
|
||
Name string `json:"name"`
|
||
Role string `json:"role"`
|
||
Lat float64 `json:"lat"`
|
||
Lon float64 `json:"lon"`
|
||
HashSize int `json:"hash_size"`
|
||
HashSizeInconsistent bool `json:"hash_size_inconsistent"`
|
||
HashSizesSeen []int `json:"hash_sizes_seen,omitempty"`
|
||
}
|
||
|
||
// collisionEntry represents a prefix collision with pre-computed distances.
|
||
type collisionEntry struct {
|
||
Prefix string `json:"prefix"`
|
||
ByteSize int `json:"byte_size"`
|
||
Appearances int `json:"appearances"`
|
||
Nodes []collisionNode `json:"nodes"`
|
||
MaxDistKm float64 `json:"max_dist_km"`
|
||
Classification string `json:"classification"`
|
||
WithCoords int `json:"with_coords"`
|
||
}
|
||
|
||
// prefixCellInfo holds per-prefix-cell data for the matrix view.
|
||
type prefixCellInfo struct {
|
||
Nodes []collisionNode `json:"nodes"`
|
||
}
|
||
|
||
// twoByteCellInfo holds per-first-byte-group data for 2-byte matrix.
|
||
type twoByteCellInfo struct {
|
||
GroupNodes []collisionNode `json:"group_nodes"`
|
||
TwoByteMap map[string][]collisionNode `json:"two_byte_map"`
|
||
MaxCollision int `json:"max_collision"`
|
||
CollisionCount int `json:"collision_count"`
|
||
}
|
||
|
||
func (s *PacketStore) computeHashCollisions(region string) map[string]interface{} {
|
||
// Get all nodes from DB
|
||
nodes := s.getAllNodes()
|
||
hashInfo := s.GetNodeHashSizeInfo()
|
||
|
||
// If region is specified, filter to only nodes seen by regional observers
|
||
if region != "" {
|
||
regionObs := s.resolveRegionObservers(region)
|
||
if regionObs != nil {
|
||
s.mu.RLock()
|
||
regionNodePKs := make(map[string]bool)
|
||
for _, tx := range s.packets {
|
||
match := false
|
||
for _, obs := range tx.Observations {
|
||
if regionObs[obs.ObserverID] {
|
||
match = true
|
||
break
|
||
}
|
||
}
|
||
if !match {
|
||
continue
|
||
}
|
||
// Collect node public keys from advert packets
|
||
if tx.DecodedJSON != "" {
|
||
var d map[string]interface{}
|
||
if json.Unmarshal([]byte(tx.DecodedJSON), &d) == nil {
|
||
if pk, ok := d["pubKey"].(string); ok && pk != "" {
|
||
regionNodePKs[pk] = true
|
||
}
|
||
if pk, ok := d["public_key"].(string); ok && pk != "" {
|
||
regionNodePKs[pk] = true
|
||
}
|
||
}
|
||
}
|
||
// Include observers themselves as nodes in the region
|
||
for _, obs := range tx.Observations {
|
||
if obs.ObserverID != "" {
|
||
regionNodePKs[obs.ObserverID] = true
|
||
}
|
||
}
|
||
}
|
||
s.mu.RUnlock()
|
||
|
||
// Filter nodes to only those seen in the region
|
||
filtered := make([]nodeInfo, 0, len(regionNodePKs))
|
||
for _, n := range nodes {
|
||
if regionNodePKs[n.PublicKey] {
|
||
filtered = append(filtered, n)
|
||
}
|
||
}
|
||
nodes = filtered
|
||
}
|
||
}
|
||
|
||
// Build collision nodes with hash info
|
||
var allCNodes []collisionNode
|
||
for _, n := range nodes {
|
||
cn := collisionNode{
|
||
PublicKey: n.PublicKey,
|
||
Name: n.Name,
|
||
Role: n.Role,
|
||
Lat: n.Lat,
|
||
Lon: n.Lon,
|
||
}
|
||
if info, ok := hashInfo[n.PublicKey]; ok && info != nil {
|
||
cn.HashSize = info.HashSize
|
||
cn.HashSizeInconsistent = info.Inconsistent
|
||
if len(info.AllSizes) > 1 {
|
||
sizes := make([]int, 0, len(info.AllSizes))
|
||
for sz := range info.AllSizes {
|
||
sizes = append(sizes, sz)
|
||
}
|
||
sort.Ints(sizes)
|
||
cn.HashSizesSeen = sizes
|
||
}
|
||
}
|
||
allCNodes = append(allCNodes, cn)
|
||
}
|
||
|
||
// Inconsistent nodes
|
||
var inconsistentNodes []collisionNode
|
||
for _, cn := range allCNodes {
|
||
if cn.HashSizeInconsistent && (cn.Role == "repeater" || cn.Role == "room_server") {
|
||
inconsistentNodes = append(inconsistentNodes, cn)
|
||
}
|
||
}
|
||
if inconsistentNodes == nil {
|
||
inconsistentNodes = make([]collisionNode, 0)
|
||
}
|
||
|
||
// Compute collisions for each byte size (1, 2, 3)
|
||
collisionsBySize := make(map[string]interface{})
|
||
for _, bytes := range []int{1, 2, 3} {
|
||
// Filter nodes relevant to this byte size.
|
||
// - Exclude hash_size==0 nodes: no adverts seen, so actual hash
|
||
// size is unknown. Including them in every bucket inflates
|
||
// collision counts.
|
||
// - Exclude companions: they are mobile/temporary and don't form
|
||
// the mesh backbone, so collisions with them aren't meaningful.
|
||
// (Fixes #441)
|
||
var nodesForByte []collisionNode
|
||
for _, cn := range allCNodes {
|
||
if cn.HashSize == bytes && cn.Role == "repeater" {
|
||
nodesForByte = append(nodesForByte, cn)
|
||
}
|
||
}
|
||
|
||
// Build prefix map
|
||
prefixMap := make(map[string][]collisionNode)
|
||
for _, cn := range nodesForByte {
|
||
if len(cn.PublicKey) < bytes*2 {
|
||
continue
|
||
}
|
||
prefix := strings.ToUpper(cn.PublicKey[:bytes*2])
|
||
prefixMap[prefix] = append(prefixMap[prefix], cn)
|
||
}
|
||
|
||
// Compute collisions with pairwise distances
|
||
var collisions []collisionEntry
|
||
for prefix, pnodes := range prefixMap {
|
||
if len(pnodes) <= 1 {
|
||
continue
|
||
}
|
||
// Pairwise distance
|
||
var withCoords []collisionNode
|
||
for _, cn := range pnodes {
|
||
if cn.Lat != 0 || cn.Lon != 0 {
|
||
withCoords = append(withCoords, cn)
|
||
}
|
||
}
|
||
var maxDistKm float64
|
||
classification := "unknown"
|
||
if len(withCoords) >= 2 {
|
||
for i := 0; i < len(withCoords); i++ {
|
||
for j := i + 1; j < len(withCoords); j++ {
|
||
d := haversineKm(withCoords[i].Lat, withCoords[i].Lon, withCoords[j].Lat, withCoords[j].Lon)
|
||
if d > maxDistKm {
|
||
maxDistKm = d
|
||
}
|
||
}
|
||
}
|
||
if maxDistKm < 50 {
|
||
classification = "local"
|
||
} else if maxDistKm < 200 {
|
||
classification = "regional"
|
||
} else {
|
||
classification = "distant"
|
||
}
|
||
} else {
|
||
classification = "incomplete"
|
||
}
|
||
collisions = append(collisions, collisionEntry{
|
||
Prefix: prefix,
|
||
ByteSize: bytes,
|
||
Appearances: len(pnodes),
|
||
Nodes: pnodes,
|
||
MaxDistKm: maxDistKm,
|
||
Classification: classification,
|
||
WithCoords: len(withCoords),
|
||
})
|
||
}
|
||
if collisions == nil {
|
||
collisions = make([]collisionEntry, 0)
|
||
}
|
||
|
||
// Sort: local first, then regional, distant, incomplete
|
||
classOrder := map[string]int{"local": 0, "regional": 1, "distant": 2, "incomplete": 3, "unknown": 4}
|
||
sort.Slice(collisions, func(i, j int) bool {
|
||
oi, oj := classOrder[collisions[i].Classification], classOrder[collisions[j].Classification]
|
||
if oi != oj {
|
||
return oi < oj
|
||
}
|
||
return collisions[i].Appearances > collisions[j].Appearances
|
||
})
|
||
|
||
// Stats
|
||
nodeCount := len(nodesForByte)
|
||
usingThisSize := 0
|
||
for _, cn := range allCNodes {
|
||
if cn.HashSize == bytes {
|
||
usingThisSize++
|
||
}
|
||
}
|
||
uniquePrefixes := len(prefixMap)
|
||
collisionCount := len(collisions)
|
||
var spaceSize int
|
||
switch bytes {
|
||
case 1:
|
||
spaceSize = 256
|
||
case 2:
|
||
spaceSize = 65536
|
||
case 3:
|
||
spaceSize = 16777216
|
||
}
|
||
pctUsed := 0.0
|
||
if spaceSize > 0 {
|
||
pctUsed = float64(uniquePrefixes) / float64(spaceSize) * 100
|
||
}
|
||
|
||
// For 1-byte and 2-byte, include the full prefix cell data for matrix rendering
|
||
var oneByteCells map[string][]collisionNode
|
||
var twoByteCells map[string]*twoByteCellInfo
|
||
if bytes == 1 {
|
||
oneByteCells = make(map[string][]collisionNode)
|
||
for i := 0; i < 256; i++ {
|
||
hex := strings.ToUpper(fmt.Sprintf("%02x", i))
|
||
oneByteCells[hex] = prefixMap[hex]
|
||
if oneByteCells[hex] == nil {
|
||
oneByteCells[hex] = make([]collisionNode, 0)
|
||
}
|
||
}
|
||
} else if bytes == 2 {
|
||
twoByteCells = make(map[string]*twoByteCellInfo)
|
||
for i := 0; i < 256; i++ {
|
||
hex := strings.ToUpper(fmt.Sprintf("%02x", i))
|
||
cell := &twoByteCellInfo{
|
||
GroupNodes: make([]collisionNode, 0),
|
||
TwoByteMap: make(map[string][]collisionNode),
|
||
}
|
||
twoByteCells[hex] = cell
|
||
}
|
||
for _, cn := range nodesForByte {
|
||
if len(cn.PublicKey) < 4 {
|
||
continue
|
||
}
|
||
firstHex := strings.ToUpper(cn.PublicKey[:2])
|
||
twoHex := strings.ToUpper(cn.PublicKey[:4])
|
||
cell := twoByteCells[firstHex]
|
||
if cell == nil {
|
||
continue
|
||
}
|
||
cell.GroupNodes = append(cell.GroupNodes, cn)
|
||
cell.TwoByteMap[twoHex] = append(cell.TwoByteMap[twoHex], cn)
|
||
}
|
||
for _, cell := range twoByteCells {
|
||
for _, ns := range cell.TwoByteMap {
|
||
if len(ns) > 1 {
|
||
cell.CollisionCount++
|
||
if len(ns) > cell.MaxCollision {
|
||
cell.MaxCollision = len(ns)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
sizeData := map[string]interface{}{
|
||
"stats": map[string]interface{}{
|
||
"total_nodes": len(allCNodes),
|
||
"nodes_for_byte": nodeCount,
|
||
"using_this_size": usingThisSize,
|
||
"unique_prefixes": uniquePrefixes,
|
||
"collision_count": collisionCount,
|
||
"space_size": spaceSize,
|
||
"pct_used": pctUsed,
|
||
},
|
||
"collisions": collisions,
|
||
}
|
||
if oneByteCells != nil {
|
||
sizeData["one_byte_cells"] = oneByteCells
|
||
}
|
||
if twoByteCells != nil {
|
||
sizeData["two_byte_cells"] = twoByteCells
|
||
}
|
||
collisionsBySize[strconv.Itoa(bytes)] = sizeData
|
||
}
|
||
|
||
return map[string]interface{}{
|
||
"inconsistent_nodes": inconsistentNodes,
|
||
"by_size": collisionsBySize,
|
||
}
|
||
}
|
||
|
||
// GetNodeHashSizeInfo returns cached per-node hash size data, recomputing at most every 15s.
|
||
func (s *PacketStore) GetNodeHashSizeInfo() map[string]*hashSizeNodeInfo {
|
||
const ttl = 15 * time.Second
|
||
s.hashSizeInfoMu.Lock()
|
||
if s.hashSizeInfoCache != nil && time.Since(s.hashSizeInfoAt) < ttl {
|
||
cached := s.hashSizeInfoCache
|
||
s.hashSizeInfoMu.Unlock()
|
||
return cached
|
||
}
|
||
s.hashSizeInfoMu.Unlock()
|
||
result := s.computeNodeHashSizeInfo()
|
||
s.hashSizeInfoMu.Lock()
|
||
s.hashSizeInfoCache = result
|
||
s.hashSizeInfoAt = time.Now()
|
||
s.hashSizeInfoMu.Unlock()
|
||
return result
|
||
}
|
||
|
||
// computeNodeHashSizeInfo scans advert packets to compute per-node hash size data.
|
||
// Only adverts from the last 7 days are considered so that legitimate config
|
||
// changes during testing don't create permanent false positives.
|
||
func (s *PacketStore) computeNodeHashSizeInfo() map[string]*hashSizeNodeInfo {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
info := make(map[string]*hashSizeNodeInfo)
|
||
|
||
cutoff := time.Now().UTC().Add(-7 * 24 * time.Hour).Format("2006-01-02T15:04:05.000Z")
|
||
|
||
adverts := s.byPayloadType[4]
|
||
for _, tx := range adverts {
|
||
// Skip adverts older than 7 days to avoid false positives from
|
||
// historical config changes during testing.
|
||
if tx.FirstSeen != "" && tx.FirstSeen < cutoff {
|
||
continue
|
||
}
|
||
if tx.RawHex == "" || tx.DecodedJSON == "" {
|
||
continue
|
||
}
|
||
if len(tx.RawHex) < 4 {
|
||
continue
|
||
}
|
||
header, err := strconv.ParseUint(tx.RawHex[:2], 16, 8)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
routeType := int(header & 0x03)
|
||
pathByte, err := strconv.ParseUint(tx.RawHex[2:4], 16, 8)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
// DIRECT zero-hop adverts use path byte 0x00 locally and can misreport
|
||
// multibyte repeater hash mode as 1-byte.
|
||
if routeType == RouteDirect && (pathByte&0x3F) == 0 {
|
||
continue
|
||
}
|
||
hs := int((pathByte>>6)&0x3) + 1
|
||
|
||
var d map[string]interface{}
|
||
if json.Unmarshal([]byte(tx.DecodedJSON), &d) != nil {
|
||
continue
|
||
}
|
||
pk := ""
|
||
if v, ok := d["pubKey"].(string); ok {
|
||
pk = v
|
||
} else if v, ok := d["public_key"].(string); ok {
|
||
pk = v
|
||
}
|
||
if pk == "" {
|
||
continue
|
||
}
|
||
|
||
ni := info[pk]
|
||
if ni == nil {
|
||
ni = &hashSizeNodeInfo{AllSizes: make(map[int]bool)}
|
||
info[pk] = ni
|
||
}
|
||
ni.AllSizes[hs] = true
|
||
ni.Seq = append(ni.Seq, hs)
|
||
}
|
||
|
||
// Post-process: use latest advert hash size and compute flip-flop flag.
|
||
// The most recent advert reflects the node's current hash size
|
||
// configuration. The upstream firmware bug causing stale path bytes in
|
||
// flood adverts was fixed (meshcore-dev/MeshCore#2154).
|
||
for _, ni := range info {
|
||
// Use the most recent advert's hash size (last in chronological order).
|
||
ni.HashSize = ni.Seq[len(ni.Seq)-1]
|
||
|
||
// Flip-flop (inconsistent) flag: need >= 3 observations,
|
||
// >= 2 unique sizes, and >= 2 transitions in the sequence.
|
||
if len(ni.Seq) < 3 || len(ni.AllSizes) < 2 {
|
||
continue
|
||
}
|
||
transitions := 0
|
||
for i := 1; i < len(ni.Seq); i++ {
|
||
if ni.Seq[i] != ni.Seq[i-1] {
|
||
transitions++
|
||
}
|
||
}
|
||
ni.Inconsistent = transitions >= 2
|
||
}
|
||
|
||
return info
|
||
}
|
||
|
||
// EnrichNodeWithHashSize populates hash_size, hash_size_inconsistent, and
|
||
// hash_sizes_seen on a node map using precomputed hash size info.
|
||
func EnrichNodeWithHashSize(node map[string]interface{}, info *hashSizeNodeInfo) {
|
||
if info == nil {
|
||
return
|
||
}
|
||
node["hash_size"] = info.HashSize
|
||
node["hash_size_inconsistent"] = info.Inconsistent
|
||
if len(info.AllSizes) > 1 {
|
||
sizes := make([]int, 0, len(info.AllSizes))
|
||
for s := range info.AllSizes {
|
||
sizes = append(sizes, s)
|
||
}
|
||
sort.Ints(sizes)
|
||
node["hash_sizes_seen"] = sizes
|
||
}
|
||
}
|
||
|
||
// --- Bulk Health (in-memory) ---
|
||
|
||
func (s *PacketStore) GetBulkHealth(limit int, region string) []map[string]interface{} {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
// Region filtering
|
||
var regionNodeKeys map[string]bool
|
||
if region != "" {
|
||
regionObs := s.resolveRegionObservers(region)
|
||
if regionObs != nil {
|
||
regionalHashes := make(map[string]bool)
|
||
for obsID := range regionObs {
|
||
obsList := s.byObserver[obsID]
|
||
for _, o := range obsList {
|
||
tx := s.byTxID[o.TransmissionID]
|
||
if tx != nil {
|
||
regionalHashes[tx.Hash] = true
|
||
}
|
||
}
|
||
}
|
||
regionNodeKeys = make(map[string]bool)
|
||
for pk, hashes := range s.nodeHashes {
|
||
for h := range hashes {
|
||
if regionalHashes[h] {
|
||
regionNodeKeys[pk] = true
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Get nodes from DB
|
||
queryLimit := limit
|
||
if regionNodeKeys != nil {
|
||
queryLimit = 500
|
||
}
|
||
rows, err := s.db.conn.Query("SELECT public_key, name, role, lat, lon FROM nodes ORDER BY last_seen DESC LIMIT ?", queryLimit)
|
||
if err != nil {
|
||
return []map[string]interface{}{}
|
||
}
|
||
defer rows.Close()
|
||
|
||
type dbNode struct {
|
||
pk, name, role string
|
||
lat, lon interface{}
|
||
}
|
||
var nodes []dbNode
|
||
for rows.Next() {
|
||
var pk string
|
||
var name, role sql.NullString
|
||
var lat, lon sql.NullFloat64
|
||
rows.Scan(&pk, &name, &role, &lat, &lon)
|
||
if regionNodeKeys != nil && !regionNodeKeys[pk] {
|
||
continue
|
||
}
|
||
nodes = append(nodes, dbNode{
|
||
pk: pk, name: nullStrVal(name), role: nullStrVal(role),
|
||
lat: nullFloat(lat), lon: nullFloat(lon),
|
||
})
|
||
if regionNodeKeys == nil && len(nodes) >= limit {
|
||
break
|
||
}
|
||
}
|
||
if regionNodeKeys != nil && len(nodes) > limit {
|
||
nodes = nodes[:limit]
|
||
}
|
||
|
||
todayStart := time.Now().UTC().Truncate(24 * time.Hour).Format(time.RFC3339)
|
||
results := make([]map[string]interface{}, 0, len(nodes))
|
||
|
||
for _, n := range nodes {
|
||
packets := s.byNode[n.pk]
|
||
var packetsToday int
|
||
var snrSum float64
|
||
var snrCount int
|
||
var lastHeard string
|
||
observerStats := map[string]*struct {
|
||
name string
|
||
snrSum, rssiSum float64
|
||
snrCount, rssiCount, count int
|
||
}{}
|
||
totalObservations := 0
|
||
|
||
for _, pkt := range packets {
|
||
totalObservations += pkt.ObservationCount
|
||
if totalObservations == 0 {
|
||
totalObservations = 1
|
||
}
|
||
if pkt.FirstSeen > todayStart {
|
||
packetsToday++
|
||
}
|
||
if pkt.SNR != nil {
|
||
snrSum += *pkt.SNR
|
||
snrCount++
|
||
}
|
||
if lastHeard == "" || pkt.FirstSeen > lastHeard {
|
||
lastHeard = pkt.FirstSeen
|
||
}
|
||
obsID := pkt.ObserverID
|
||
if obsID != "" {
|
||
obs := observerStats[obsID]
|
||
if obs == nil {
|
||
obs = &struct {
|
||
name string
|
||
snrSum, rssiSum float64
|
||
snrCount, rssiCount, count int
|
||
}{name: pkt.ObserverName}
|
||
observerStats[obsID] = obs
|
||
}
|
||
obs.count++
|
||
if pkt.SNR != nil {
|
||
obs.snrSum += *pkt.SNR
|
||
obs.snrCount++
|
||
}
|
||
if pkt.RSSI != nil {
|
||
obs.rssiSum += *pkt.RSSI
|
||
obs.rssiCount++
|
||
}
|
||
}
|
||
}
|
||
|
||
observerRows := make([]map[string]interface{}, 0)
|
||
for id, o := range observerStats {
|
||
var avgSnr, avgRssi interface{}
|
||
if o.snrCount > 0 {
|
||
avgSnr = o.snrSum / float64(o.snrCount)
|
||
}
|
||
if o.rssiCount > 0 {
|
||
avgRssi = o.rssiSum / float64(o.rssiCount)
|
||
}
|
||
observerRows = append(observerRows, map[string]interface{}{
|
||
"observer_id": id, "observer_name": o.name,
|
||
"avgSnr": avgSnr, "avgRssi": avgRssi, "packetCount": o.count,
|
||
})
|
||
}
|
||
sort.Slice(observerRows, func(i, j int) bool {
|
||
return observerRows[i]["packetCount"].(int) > observerRows[j]["packetCount"].(int)
|
||
})
|
||
|
||
var avgSnr interface{}
|
||
if snrCount > 0 {
|
||
avgSnr = snrSum / float64(snrCount)
|
||
}
|
||
var lhVal interface{}
|
||
if lastHeard != "" {
|
||
lhVal = lastHeard
|
||
}
|
||
|
||
results = append(results, map[string]interface{}{
|
||
"public_key": n.pk,
|
||
"name": nilIfEmpty(n.name),
|
||
"role": nilIfEmpty(n.role),
|
||
"lat": n.lat,
|
||
"lon": n.lon,
|
||
"stats": map[string]interface{}{
|
||
"totalTransmissions": len(packets),
|
||
"totalObservations": totalObservations,
|
||
"totalPackets": len(packets),
|
||
"packetsToday": packetsToday,
|
||
"avgSnr": avgSnr,
|
||
"lastHeard": lhVal,
|
||
},
|
||
"observers": observerRows,
|
||
})
|
||
}
|
||
|
||
return results
|
||
}
|
||
|
||
// --- Subpaths Analytics ---
|
||
|
||
// GetNodeHealth returns health info for a single node using in-memory data.
|
||
func (s *PacketStore) GetNodeHealth(pubkey string) (map[string]interface{}, error) {
|
||
// Fetch node info from DB (fast single-row lookup)
|
||
node, err := s.db.GetNodeByPubkey(pubkey)
|
||
if err != nil || node == nil {
|
||
return nil, err
|
||
}
|
||
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
packets := s.byNode[pubkey]
|
||
todayStart := time.Now().UTC().Truncate(24 * time.Hour).Format(time.RFC3339)
|
||
|
||
var packetsToday int
|
||
var snrSum float64
|
||
var snrCount int
|
||
var totalHops, hopCount int
|
||
var lastHeard string
|
||
totalObservations := 0
|
||
|
||
observerStats := map[string]*struct {
|
||
name string
|
||
snrSum, rssiSum float64
|
||
snrCount, rssiCount, count int
|
||
}{}
|
||
|
||
for _, pkt := range packets {
|
||
totalObservations += pkt.ObservationCount
|
||
if pkt.FirstSeen > todayStart {
|
||
packetsToday++
|
||
}
|
||
if pkt.SNR != nil {
|
||
snrSum += *pkt.SNR
|
||
snrCount++
|
||
}
|
||
if lastHeard == "" || pkt.FirstSeen > lastHeard {
|
||
lastHeard = pkt.FirstSeen
|
||
}
|
||
// Hop counting
|
||
hops := txGetParsedPath(pkt)
|
||
if len(hops) > 0 {
|
||
totalHops += len(hops)
|
||
hopCount++
|
||
}
|
||
// Observer stats
|
||
obsID := pkt.ObserverID
|
||
if obsID != "" {
|
||
obs := observerStats[obsID]
|
||
if obs == nil {
|
||
obs = &struct {
|
||
name string
|
||
snrSum, rssiSum float64
|
||
snrCount, rssiCount, count int
|
||
}{name: pkt.ObserverName}
|
||
observerStats[obsID] = obs
|
||
}
|
||
obs.count++
|
||
if pkt.SNR != nil {
|
||
obs.snrSum += *pkt.SNR
|
||
obs.snrCount++
|
||
}
|
||
if pkt.RSSI != nil {
|
||
obs.rssiSum += *pkt.RSSI
|
||
obs.rssiCount++
|
||
}
|
||
}
|
||
}
|
||
|
||
observerRows := make([]map[string]interface{}, 0)
|
||
for id, o := range observerStats {
|
||
var avgSnr, avgRssi interface{}
|
||
if o.snrCount > 0 {
|
||
avgSnr = o.snrSum / float64(o.snrCount)
|
||
}
|
||
if o.rssiCount > 0 {
|
||
avgRssi = o.rssiSum / float64(o.rssiCount)
|
||
}
|
||
observerRows = append(observerRows, map[string]interface{}{
|
||
"observer_id": id, "observer_name": o.name,
|
||
"avgSnr": avgSnr, "avgRssi": avgRssi, "packetCount": o.count,
|
||
})
|
||
}
|
||
sort.Slice(observerRows, func(i, j int) bool {
|
||
return observerRows[i]["packetCount"].(int) > observerRows[j]["packetCount"].(int)
|
||
})
|
||
|
||
var avgSnr interface{}
|
||
if snrCount > 0 {
|
||
avgSnr = snrSum / float64(snrCount)
|
||
}
|
||
avgHops := 0
|
||
if hopCount > 0 {
|
||
avgHops = int(math.Round(float64(totalHops) / float64(hopCount)))
|
||
}
|
||
var lhVal interface{}
|
||
if lastHeard != "" {
|
||
lhVal = lastHeard
|
||
}
|
||
|
||
// Recent packets (up to 20, newest first — read from tail of oldest-first slice)
|
||
recentLimit := 20
|
||
if len(packets) < recentLimit {
|
||
recentLimit = len(packets)
|
||
}
|
||
recentPackets := make([]map[string]interface{}, 0, recentLimit)
|
||
for i := len(packets) - 1; i >= len(packets)-recentLimit; i-- {
|
||
p := txToMap(packets[i])
|
||
delete(p, "observations")
|
||
recentPackets = append(recentPackets, p)
|
||
}
|
||
|
||
return map[string]interface{}{
|
||
"node": node,
|
||
"observers": observerRows,
|
||
"stats": map[string]interface{}{
|
||
"totalTransmissions": len(packets),
|
||
"totalObservations": totalObservations,
|
||
"totalPackets": len(packets),
|
||
"packetsToday": packetsToday,
|
||
"avgSnr": avgSnr,
|
||
"avgHops": avgHops,
|
||
"lastHeard": lhVal,
|
||
},
|
||
"recentPackets": recentPackets,
|
||
}, nil
|
||
}
|
||
|
||
// GetNodeAnalytics computes analytics for a single node using in-memory byNode index.
|
||
func (s *PacketStore) GetNodeAnalytics(pubkey string, days int) (*NodeAnalyticsResponse, error) {
|
||
node, err := s.db.GetNodeByPubkey(pubkey)
|
||
if err != nil || node == nil {
|
||
return nil, err
|
||
}
|
||
|
||
name := ""
|
||
if n, ok := node["name"]; ok && n != nil {
|
||
name = fmt.Sprintf("%v", n)
|
||
}
|
||
|
||
fromTime := time.Now().Add(-time.Duration(days) * 24 * time.Hour)
|
||
fromISO := fromTime.Format(time.RFC3339)
|
||
toISO := time.Now().Format(time.RFC3339)
|
||
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
// Collect packets from byNode index + text search (matches Node.js findPacketsForNode)
|
||
indexed := s.byNode[pubkey]
|
||
hashSet := make(map[string]bool, len(indexed))
|
||
for _, tx := range indexed {
|
||
hashSet[tx.Hash] = true
|
||
}
|
||
var allPkts []*StoreTx
|
||
if name != "" {
|
||
for _, tx := range s.packets {
|
||
if hashSet[tx.Hash] {
|
||
allPkts = append(allPkts, tx)
|
||
} else if tx.DecodedJSON != "" && (strings.Contains(tx.DecodedJSON, name) || strings.Contains(tx.DecodedJSON, pubkey)) {
|
||
allPkts = append(allPkts, tx)
|
||
}
|
||
}
|
||
} else {
|
||
allPkts = indexed
|
||
}
|
||
|
||
// Filter by time range
|
||
var packets []*StoreTx
|
||
for _, p := range allPkts {
|
||
if p.FirstSeen > fromISO {
|
||
packets = append(packets, p)
|
||
}
|
||
}
|
||
|
||
// Activity timeline (hourly buckets)
|
||
timelineBuckets := map[string]int{}
|
||
for _, p := range packets {
|
||
if len(p.FirstSeen) >= 13 {
|
||
bucket := p.FirstSeen[:13] + ":00:00Z"
|
||
timelineBuckets[bucket]++
|
||
}
|
||
}
|
||
bucketKeys := make([]string, 0, len(timelineBuckets))
|
||
for k := range timelineBuckets {
|
||
bucketKeys = append(bucketKeys, k)
|
||
}
|
||
sort.Strings(bucketKeys)
|
||
activityTimeline := make([]TimeBucket, 0, len(bucketKeys))
|
||
for _, k := range bucketKeys {
|
||
b := k
|
||
activityTimeline = append(activityTimeline, TimeBucket{Bucket: &b, Count: timelineBuckets[k]})
|
||
}
|
||
|
||
// SNR trend
|
||
snrTrend := make([]SnrTrendEntry, 0)
|
||
for _, p := range packets {
|
||
if p.SNR != nil {
|
||
snrTrend = append(snrTrend, SnrTrendEntry{
|
||
Timestamp: p.FirstSeen,
|
||
SNR: floatPtrOrNil(p.SNR),
|
||
RSSI: floatPtrOrNil(p.RSSI),
|
||
ObserverID: strOrNil(p.ObserverID),
|
||
ObserverName: strOrNil(p.ObserverName),
|
||
})
|
||
}
|
||
}
|
||
|
||
// Packet type breakdown
|
||
typeBuckets := map[int]int{}
|
||
for _, p := range packets {
|
||
if p.PayloadType != nil {
|
||
typeBuckets[*p.PayloadType]++
|
||
}
|
||
}
|
||
packetTypeBreakdown := make([]PayloadTypeCount, 0, len(typeBuckets))
|
||
for pt, cnt := range typeBuckets {
|
||
packetTypeBreakdown = append(packetTypeBreakdown, PayloadTypeCount{PayloadType: pt, Count: cnt})
|
||
}
|
||
|
||
// Observer coverage
|
||
type obsAccum struct {
|
||
name string
|
||
snrSum, rssiSum float64
|
||
snrCount, rssiCount, count int
|
||
first, last string
|
||
}
|
||
obsMap := map[string]*obsAccum{}
|
||
for _, p := range packets {
|
||
if p.ObserverID == "" {
|
||
continue
|
||
}
|
||
o := obsMap[p.ObserverID]
|
||
if o == nil {
|
||
o = &obsAccum{name: p.ObserverName, first: p.FirstSeen, last: p.FirstSeen}
|
||
obsMap[p.ObserverID] = o
|
||
}
|
||
o.count++
|
||
if p.SNR != nil {
|
||
o.snrSum += *p.SNR
|
||
o.snrCount++
|
||
}
|
||
if p.RSSI != nil {
|
||
o.rssiSum += *p.RSSI
|
||
o.rssiCount++
|
||
}
|
||
if p.FirstSeen < o.first {
|
||
o.first = p.FirstSeen
|
||
}
|
||
if p.FirstSeen > o.last {
|
||
o.last = p.FirstSeen
|
||
}
|
||
}
|
||
observerCoverage := make([]NodeObserverStatsResp, 0, len(obsMap))
|
||
for id, o := range obsMap {
|
||
var avgSnr, avgRssi interface{}
|
||
if o.snrCount > 0 {
|
||
avgSnr = o.snrSum / float64(o.snrCount)
|
||
}
|
||
if o.rssiCount > 0 {
|
||
avgRssi = o.rssiSum / float64(o.rssiCount)
|
||
}
|
||
observerCoverage = append(observerCoverage, NodeObserverStatsResp{
|
||
ObserverID: id,
|
||
ObserverName: o.name,
|
||
PacketCount: o.count,
|
||
AvgSnr: avgSnr,
|
||
AvgRssi: avgRssi,
|
||
FirstSeen: o.first,
|
||
LastSeen: o.last,
|
||
})
|
||
}
|
||
sort.Slice(observerCoverage, func(i, j int) bool {
|
||
return observerCoverage[i].PacketCount > observerCoverage[j].PacketCount
|
||
})
|
||
|
||
// Hop distribution
|
||
hopCounts := map[string]int{}
|
||
totalWithPath := 0
|
||
relayedCount := 0
|
||
for _, p := range packets {
|
||
hops := txGetParsedPath(p)
|
||
if len(hops) > 0 {
|
||
key := fmt.Sprintf("%d", len(hops))
|
||
if len(hops) >= 4 {
|
||
key = "4+"
|
||
}
|
||
hopCounts[key]++
|
||
totalWithPath++
|
||
if len(hops) > 1 {
|
||
relayedCount++
|
||
}
|
||
} else {
|
||
hopCounts["0"]++
|
||
}
|
||
}
|
||
hopDistribution := make([]HopDistEntry, 0)
|
||
for _, h := range []string{"0", "1", "2", "3", "4+"} {
|
||
if c, ok := hopCounts[h]; ok {
|
||
hopDistribution = append(hopDistribution, HopDistEntry{Hops: h, Count: c})
|
||
}
|
||
}
|
||
|
||
// Peer interactions
|
||
type peerAccum struct {
|
||
key, name string
|
||
count int
|
||
lastContact string
|
||
}
|
||
peerMap := map[string]*peerAccum{}
|
||
for _, p := range packets {
|
||
if p.DecodedJSON == "" {
|
||
continue
|
||
}
|
||
var decoded map[string]interface{}
|
||
if json.Unmarshal([]byte(p.DecodedJSON), &decoded) != nil {
|
||
continue
|
||
}
|
||
type candidate struct{ key, name string }
|
||
var candidates []candidate
|
||
if sk, ok := decoded["sender_key"].(string); ok && sk != "" && sk != pubkey {
|
||
sn, _ := decoded["sender_name"].(string)
|
||
if sn == "" {
|
||
sn, _ = decoded["sender_short_name"].(string)
|
||
}
|
||
candidates = append(candidates, candidate{sk, sn})
|
||
}
|
||
if rk, ok := decoded["recipient_key"].(string); ok && rk != "" && rk != pubkey {
|
||
rn, _ := decoded["recipient_name"].(string)
|
||
if rn == "" {
|
||
rn, _ = decoded["recipient_short_name"].(string)
|
||
}
|
||
candidates = append(candidates, candidate{rk, rn})
|
||
}
|
||
if pk, ok := decoded["pubkey"].(string); ok && pk != "" && pk != pubkey {
|
||
nm, _ := decoded["name"].(string)
|
||
candidates = append(candidates, candidate{pk, nm})
|
||
}
|
||
for _, c := range candidates {
|
||
if c.key == "" {
|
||
continue
|
||
}
|
||
pm := peerMap[c.key]
|
||
if pm == nil {
|
||
pn := c.name
|
||
if pn == "" && len(c.key) >= 12 {
|
||
pn = c.key[:12]
|
||
}
|
||
pm = &peerAccum{key: c.key, name: pn, lastContact: p.FirstSeen}
|
||
peerMap[c.key] = pm
|
||
}
|
||
pm.count++
|
||
if p.FirstSeen > pm.lastContact {
|
||
pm.lastContact = p.FirstSeen
|
||
}
|
||
}
|
||
}
|
||
peerSlice := make([]PeerInteraction, 0, len(peerMap))
|
||
for _, pm := range peerMap {
|
||
peerSlice = append(peerSlice, PeerInteraction{
|
||
PeerKey: pm.key, PeerName: pm.name,
|
||
MessageCount: pm.count, LastContact: pm.lastContact,
|
||
})
|
||
}
|
||
sort.Slice(peerSlice, func(i, j int) bool {
|
||
return peerSlice[i].MessageCount > peerSlice[j].MessageCount
|
||
})
|
||
if len(peerSlice) > 20 {
|
||
peerSlice = peerSlice[:20]
|
||
}
|
||
|
||
// Uptime heatmap
|
||
heatBuckets := map[string]*HeatmapCell{}
|
||
for _, p := range packets {
|
||
t, err := time.Parse(time.RFC3339, p.FirstSeen)
|
||
if err != nil {
|
||
t, err = time.Parse("2006-01-02 15:04:05", p.FirstSeen)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
}
|
||
dow := int(t.UTC().Weekday())
|
||
hr := t.UTC().Hour()
|
||
k := fmt.Sprintf("%d:%d", dow, hr)
|
||
if heatBuckets[k] == nil {
|
||
heatBuckets[k] = &HeatmapCell{DayOfWeek: dow, Hour: hr}
|
||
}
|
||
heatBuckets[k].Count++
|
||
}
|
||
uptimeHeatmap := make([]HeatmapCell, 0, len(heatBuckets))
|
||
for _, cell := range heatBuckets {
|
||
uptimeHeatmap = append(uptimeHeatmap, *cell)
|
||
}
|
||
|
||
// Computed stats
|
||
totalPackets := len(packets)
|
||
distinctHours := len(activityTimeline)
|
||
totalHours := float64(days) * 24
|
||
availabilityPct := 0.0
|
||
if totalHours > 0 {
|
||
availabilityPct = round(float64(distinctHours)*100.0/totalHours, 1)
|
||
if availabilityPct > 100 {
|
||
availabilityPct = 100
|
||
}
|
||
}
|
||
|
||
var avgPacketsPerDay float64
|
||
if days > 0 {
|
||
avgPacketsPerDay = round(float64(totalPackets)/float64(days), 1)
|
||
}
|
||
|
||
// Longest silence
|
||
var longestSilenceMs int
|
||
var longestSilenceStart interface{}
|
||
if len(activityTimeline) >= 2 {
|
||
for i := 1; i < len(activityTimeline); i++ {
|
||
var t1Str, t2Str string
|
||
if activityTimeline[i-1].Bucket != nil {
|
||
t1Str = *activityTimeline[i-1].Bucket
|
||
}
|
||
if activityTimeline[i].Bucket != nil {
|
||
t2Str = *activityTimeline[i].Bucket
|
||
}
|
||
t1, e1 := time.Parse(time.RFC3339, t1Str)
|
||
t2, e2 := time.Parse(time.RFC3339, t2Str)
|
||
if e1 == nil && e2 == nil {
|
||
gap := int(t2.Sub(t1).Milliseconds())
|
||
if gap > longestSilenceMs {
|
||
longestSilenceMs = gap
|
||
longestSilenceStart = t1Str
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Signal grade & SNR stats
|
||
var snrMean, snrStdDev float64
|
||
if len(snrTrend) > 0 {
|
||
var sum float64
|
||
for _, e := range snrTrend {
|
||
if v, ok := e.SNR.(float64); ok {
|
||
sum += v
|
||
}
|
||
}
|
||
snrMean = sum / float64(len(snrTrend))
|
||
if len(snrTrend) > 1 {
|
||
var sqSum float64
|
||
for _, e := range snrTrend {
|
||
if v, ok := e.SNR.(float64); ok {
|
||
sqSum += (v - snrMean) * (v - snrMean)
|
||
}
|
||
}
|
||
snrStdDev = math.Sqrt(sqSum / float64(len(snrTrend)))
|
||
}
|
||
}
|
||
|
||
signalGrade := "D"
|
||
if snrMean > 15 && snrStdDev < 2 {
|
||
signalGrade = "A"
|
||
} else if snrMean > 15 {
|
||
signalGrade = "A-"
|
||
} else if snrMean > 12 && snrStdDev < 3 {
|
||
signalGrade = "B+"
|
||
} else if snrMean > 8 {
|
||
signalGrade = "B"
|
||
} else if snrMean > 3 {
|
||
signalGrade = "C"
|
||
}
|
||
|
||
var relayPct float64
|
||
if totalWithPath > 0 {
|
||
relayPct = round(float64(relayedCount)*100.0/float64(totalWithPath), 1)
|
||
}
|
||
|
||
return &NodeAnalyticsResponse{
|
||
Node: node,
|
||
TimeRange: TimeRangeResp{From: fromISO, To: toISO, Days: days},
|
||
ActivityTimeline: activityTimeline,
|
||
SnrTrend: snrTrend,
|
||
PacketTypeBreakdown: packetTypeBreakdown,
|
||
ObserverCoverage: observerCoverage,
|
||
HopDistribution: hopDistribution,
|
||
PeerInteractions: peerSlice,
|
||
UptimeHeatmap: uptimeHeatmap,
|
||
ComputedStats: ComputedNodeStats{
|
||
AvailabilityPct: availabilityPct,
|
||
LongestSilenceMs: longestSilenceMs,
|
||
LongestSilenceStart: longestSilenceStart,
|
||
SignalGrade: signalGrade,
|
||
SnrMean: round(snrMean, 1),
|
||
SnrStdDev: round(snrStdDev, 1),
|
||
RelayPct: relayPct,
|
||
TotalPackets: totalPackets,
|
||
UniqueObservers: len(observerCoverage),
|
||
UniquePeers: len(peerSlice),
|
||
AvgPacketsPerDay: avgPacketsPerDay,
|
||
},
|
||
}, nil
|
||
}
|
||
|
||
func (s *PacketStore) GetAnalyticsSubpaths(region string, minLen, maxLen, limit int) map[string]interface{} {
|
||
cacheKey := fmt.Sprintf("%s|%d|%d|%d", region, minLen, maxLen, limit)
|
||
|
||
s.cacheMu.Lock()
|
||
if cached, ok := s.subpathCache[cacheKey]; ok && time.Now().Before(cached.expiresAt) {
|
||
s.cacheHits++
|
||
s.cacheMu.Unlock()
|
||
return cached.data
|
||
}
|
||
s.cacheMisses++
|
||
s.cacheMu.Unlock()
|
||
|
||
result := s.computeAnalyticsSubpaths(region, minLen, maxLen, limit)
|
||
|
||
s.cacheMu.Lock()
|
||
s.subpathCache[cacheKey] = &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)}
|
||
s.cacheMu.Unlock()
|
||
|
||
return result
|
||
}
|
||
|
||
// subpathAccum holds a running count for a single named subpath.
|
||
type subpathAccum struct {
|
||
count int
|
||
raw string // first raw-hop key seen (used for rawHops in the API response)
|
||
}
|
||
|
||
func (s *PacketStore) computeAnalyticsSubpaths(region string, minLen, maxLen, limit int) map[string]interface{} {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
_, pm := s.getCachedNodesAndPM()
|
||
hopCache := make(map[string]*nodeInfo)
|
||
resolveHop := func(hop string) string {
|
||
if cached, ok := hopCache[hop]; ok {
|
||
if cached != nil {
|
||
return cached.Name
|
||
}
|
||
return hop
|
||
}
|
||
r, _, _ := pm.resolveWithContext(hop, nil, s.graph)
|
||
hopCache[hop] = r
|
||
if r != nil {
|
||
return r.Name
|
||
}
|
||
return hop
|
||
}
|
||
|
||
// For region queries fall back to packet iteration (region filtering
|
||
// requires per-transmission observer checks).
|
||
if region != "" {
|
||
return s.computeSubpathsSlow(region, minLen, maxLen, limit, resolveHop)
|
||
}
|
||
|
||
// Fast path: read from precomputed raw-hop subpath index.
|
||
// Resolve raw hop prefixes to names and merge counts.
|
||
namedCounts := make(map[string]*subpathAccum, len(s.spIndex))
|
||
for rawKey, count := range s.spIndex {
|
||
hops := strings.Split(rawKey, ",")
|
||
hopLen := len(hops)
|
||
if hopLen < minLen || hopLen > maxLen {
|
||
continue
|
||
}
|
||
named := make([]string, hopLen)
|
||
for i, h := range hops {
|
||
named[i] = resolveHop(h)
|
||
}
|
||
namedKey := strings.Join(named, " → ")
|
||
entry := namedCounts[namedKey]
|
||
if entry == nil {
|
||
entry = &subpathAccum{raw: rawKey}
|
||
namedCounts[namedKey] = entry
|
||
}
|
||
entry.count += count
|
||
}
|
||
|
||
return s.rankSubpaths(namedCounts, s.spTotalPaths, limit)
|
||
}
|
||
|
||
// computeSubpathsSlow is the original O(N) packet-iteration path, used only
|
||
// for region-filtered queries where we must check per-transmission observers.
|
||
func (s *PacketStore) computeSubpathsSlow(region string, minLen, maxLen, limit int, resolveHop func(string) string) map[string]interface{} {
|
||
regionObs := s.resolveRegionObservers(region)
|
||
|
||
subpathCounts := make(map[string]*subpathAccum)
|
||
totalPaths := 0
|
||
|
||
for _, tx := range s.packets {
|
||
hops := txGetParsedPath(tx)
|
||
if len(hops) < 2 {
|
||
continue
|
||
}
|
||
if regionObs != nil {
|
||
match := false
|
||
for _, obs := range tx.Observations {
|
||
if regionObs[obs.ObserverID] {
|
||
match = true
|
||
break
|
||
}
|
||
}
|
||
if !match {
|
||
continue
|
||
}
|
||
}
|
||
totalPaths++
|
||
|
||
named := make([]string, len(hops))
|
||
for i, h := range hops {
|
||
named[i] = resolveHop(h)
|
||
}
|
||
|
||
for l := minLen; l <= maxLen && l <= len(named); l++ {
|
||
for start := 0; start <= len(named)-l; start++ {
|
||
sub := strings.Join(named[start:start+l], " → ")
|
||
raw := strings.Join(hops[start:start+l], ",")
|
||
entry := subpathCounts[sub]
|
||
if entry == nil {
|
||
entry = &subpathAccum{raw: raw}
|
||
subpathCounts[sub] = entry
|
||
}
|
||
entry.count++
|
||
}
|
||
}
|
||
}
|
||
|
||
return s.rankSubpaths(subpathCounts, totalPaths, limit)
|
||
}
|
||
|
||
// rankSubpaths sorts accumulated subpath counts by frequency, truncates to
|
||
// limit, and builds the API response map.
|
||
func (s *PacketStore) rankSubpaths(counts map[string]*subpathAccum, totalPaths, limit int) map[string]interface{} {
|
||
type subpathEntry struct {
|
||
path string
|
||
count int
|
||
raw string
|
||
}
|
||
ranked := make([]subpathEntry, 0, len(counts))
|
||
for path, data := range counts {
|
||
ranked = append(ranked, subpathEntry{path, data.count, data.raw})
|
||
}
|
||
sort.Slice(ranked, func(i, j int) bool { return ranked[i].count > ranked[j].count })
|
||
if len(ranked) > limit {
|
||
ranked = ranked[:limit]
|
||
}
|
||
|
||
subpaths := make([]map[string]interface{}, 0, len(ranked))
|
||
for _, e := range ranked {
|
||
pct := 0.0
|
||
if totalPaths > 0 {
|
||
pct = math.Round(float64(e.count)/float64(totalPaths)*1000) / 10
|
||
}
|
||
subpaths = append(subpaths, map[string]interface{}{
|
||
"path": e.path,
|
||
"rawHops": strings.Split(e.raw, ","),
|
||
"count": e.count,
|
||
"hops": len(strings.Split(e.path, " → ")),
|
||
"pct": pct,
|
||
})
|
||
}
|
||
|
||
return map[string]interface{}{
|
||
"subpaths": subpaths,
|
||
"totalPaths": totalPaths,
|
||
}
|
||
}
|
||
|
||
// --- Subpath Detail ---
|
||
|
||
func (s *PacketStore) GetSubpathDetail(rawHops []string) map[string]interface{} {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
_, pm := s.getCachedNodesAndPM()
|
||
|
||
// Resolve the requested hops
|
||
nodes := make([]map[string]interface{}, len(rawHops))
|
||
for i, hop := range rawHops {
|
||
r, _, _ := pm.resolveWithContext(hop, nil, s.graph)
|
||
entry := map[string]interface{}{"hop": hop, "name": hop, "lat": nil, "lon": nil, "pubkey": nil}
|
||
if r != nil {
|
||
entry["name"] = r.Name
|
||
entry["pubkey"] = r.PublicKey
|
||
if r.HasGPS {
|
||
entry["lat"] = r.Lat
|
||
entry["lon"] = r.Lon
|
||
}
|
||
}
|
||
nodes[i] = entry
|
||
}
|
||
|
||
// Build the subpath key the same way the index does (lowercase, comma-joined)
|
||
spKey := strings.ToLower(strings.Join(rawHops, ","))
|
||
|
||
// Direct lookup instead of scanning all packets
|
||
matchedTxs := s.spTxIndex[spKey]
|
||
|
||
hourBuckets := make([]int, 24)
|
||
var snrSum, rssiSum float64
|
||
var snrCount, rssiCount int
|
||
observers := map[string]int{}
|
||
parentPaths := map[string]int{}
|
||
matchCount := len(matchedTxs)
|
||
var firstSeen, lastSeen string
|
||
|
||
for _, tx := range matchedTxs {
|
||
ts := tx.FirstSeen
|
||
if ts != "" {
|
||
if firstSeen == "" || ts < firstSeen {
|
||
firstSeen = ts
|
||
}
|
||
if lastSeen == "" || ts > lastSeen {
|
||
lastSeen = ts
|
||
}
|
||
t, err := time.Parse(time.RFC3339, ts)
|
||
if err != nil {
|
||
t, err = time.Parse("2006-01-02 15:04:05", ts)
|
||
}
|
||
if err == nil {
|
||
hourBuckets[t.Hour()]++
|
||
}
|
||
}
|
||
if tx.SNR != nil {
|
||
snrSum += *tx.SNR
|
||
snrCount++
|
||
}
|
||
if tx.RSSI != nil {
|
||
rssiSum += *tx.RSSI
|
||
rssiCount++
|
||
}
|
||
if tx.ObserverName != "" {
|
||
observers[tx.ObserverName]++
|
||
}
|
||
|
||
// Full parent path (resolved)
|
||
hops := txGetParsedPath(tx)
|
||
resolved := make([]string, len(hops))
|
||
for i, h := range hops {
|
||
r, _, _ := pm.resolveWithContext(h, nil, s.graph)
|
||
if r != nil {
|
||
resolved[i] = r.Name
|
||
} else {
|
||
resolved[i] = h
|
||
}
|
||
}
|
||
fullPath := strings.Join(resolved, " → ")
|
||
parentPaths[fullPath]++
|
||
}
|
||
|
||
var avgSnr, avgRssi interface{}
|
||
if snrCount > 0 {
|
||
avgSnr = snrSum / float64(snrCount)
|
||
}
|
||
if rssiCount > 0 {
|
||
avgRssi = rssiSum / float64(rssiCount)
|
||
}
|
||
|
||
topParents := make([]map[string]interface{}, 0)
|
||
for path, count := range parentPaths {
|
||
topParents = append(topParents, map[string]interface{}{"path": path, "count": count})
|
||
}
|
||
sort.Slice(topParents, func(i, j int) bool {
|
||
return topParents[i]["count"].(int) > topParents[j]["count"].(int)
|
||
})
|
||
if len(topParents) > 15 {
|
||
topParents = topParents[:15]
|
||
}
|
||
|
||
topObs := make([]map[string]interface{}, 0)
|
||
for name, count := range observers {
|
||
topObs = append(topObs, map[string]interface{}{"name": name, "count": count})
|
||
}
|
||
sort.Slice(topObs, func(i, j int) bool {
|
||
return topObs[i]["count"].(int) > topObs[j]["count"].(int)
|
||
})
|
||
if len(topObs) > 10 {
|
||
topObs = topObs[:10]
|
||
}
|
||
|
||
return map[string]interface{}{
|
||
"hops": rawHops,
|
||
"nodes": nodes,
|
||
"totalMatches": matchCount,
|
||
"firstSeen": firstSeen,
|
||
"lastSeen": lastSeen,
|
||
"signal": map[string]interface{}{"avgSnr": avgSnr, "avgRssi": avgRssi, "samples": snrCount},
|
||
"hourDistribution": hourBuckets,
|
||
"parentPaths": topParents,
|
||
"observers": topObs,
|
||
}
|
||
}
|