mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-11 05:06:55 +00:00
352967ca37
1. Hoist getCachedNodesAndPM() before observation loop in IngestNewObservations 2. Include resolved_path in IngestNewObservations broadcast maps 3. Persist resolved paths and edges to SQLite in IngestNewObservations 4. Add error logging for stmt.Exec in backfillResolvedPaths 5. Add error logging for stmt.Exec in buildAndPersistEdges 6. Remove tautological TestNeighborPersistFileCompiles test
6045 lines
162 KiB
Go
6045 lines
162 KiB
Go
package main
|
||
|
||
import (
|
||
"database/sql"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"math"
|
||
"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
|
||
// 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
|
||
}
|
||
|
||
// 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
|
||
byObserver map[string][]*StoreObs // observer_id → observations
|
||
byNode map[string][]*StoreTx // pubkey → transmissions
|
||
nodeHashes map[string]map[string]bool // pubkey → Set<hash>
|
||
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 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
|
||
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
|
||
}
|
||
|
||
// 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),
|
||
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),
|
||
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
|
||
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 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()
|
||
|
||
// Precompute distance analytics (hop distances, path totals)
|
||
s.buildDistanceIndex()
|
||
|
||
s.loaded = true
|
||
elapsed := time.Since(t0)
|
||
estMB := (len(s.packets)*5120 + s.totalObs*500) / (1024 * 1024)
|
||
log.Printf("[store] Loaded %d transmissions (%d observations) in %v (~%dMB est)",
|
||
len(s.packets), s.totalObs, elapsed, estMB)
|
||
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
|
||
}
|
||
var decoded map[string]interface{}
|
||
if json.Unmarshal([]byte(tx.DecodedJSON), &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
|
||
}
|
||
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 != "" {
|
||
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),
|
||
},
|
||
})
|
||
}
|
||
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)
|
||
s.db.conn.QueryRow("SELECT COUNT(*) FROM nodes WHERE last_seen > ?", sevenDaysAgo).Scan(&st.TotalNodes)
|
||
s.db.conn.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&st.TotalNodesAllTime)
|
||
s.db.conn.QueryRow("SELECT COUNT(*) FROM observers").Scan(&st.TotalObservers)
|
||
|
||
oneHourAgo := time.Now().Add(-1 * time.Hour).Unix()
|
||
s.db.conn.QueryRow("SELECT COUNT(*) FROM observations WHERE timestamp > ?", oneHourAgo).Scan(&st.PacketsLastHour)
|
||
|
||
oneDayAgo := time.Now().Add(-24 * time.Hour).Unix()
|
||
s.db.conn.QueryRow("SELECT COUNT(*) FROM observations WHERE timestamp > ?", oneDayAgo).Scan(&st.PacketsLast24h)
|
||
|
||
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)
|
||
ptIdx := len(s.byPayloadType)
|
||
|
||
// Distinct advert pubkey count — precomputed incrementally (see trackAdvertPubkey).
|
||
advertByObsCount := len(s.advertPubkeys)
|
||
s.mu.RUnlock()
|
||
|
||
// Realistic estimate: ~5KB per packet + ~500 bytes per observation
|
||
estimatedMB := math.Round(float64(totalLoaded*5120+totalObs*500)/1048576*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,
|
||
"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(float64(totalLoaded*5120+totalObs*500)/1048576*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: 1024,
|
||
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
|
||
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 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 addTxToSubpathIndex(s.spIndex, tx) {
|
||
s.spTotalPaths++
|
||
}
|
||
}
|
||
|
||
// 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
|
||
// Collect data for persistence outside the lock
|
||
type persistObs struct {
|
||
obsID int
|
||
resolvedPath string
|
||
}
|
||
type persistEdge struct {
|
||
a, b, ts string
|
||
}
|
||
var obsUpdates []persistObs
|
||
var edgeUpdates []persistEdge
|
||
|
||
_, 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 {
|
||
// Collect resolved_path updates
|
||
if obs.ResolvedPath != nil {
|
||
rpJSON := marshalResolvedPath(obs.ResolvedPath)
|
||
if rpJSON != "" {
|
||
obsUpdates = append(obsUpdates, persistObs{obs.ID, rpJSON})
|
||
}
|
||
}
|
||
|
||
// Extract neighbor edges using shared helper (review item #3 — DRY)
|
||
for _, ec := range extractEdgesFromObs(obs, tx, pm) {
|
||
edgeUpdates = append(edgeUpdates, persistEdge{ec.A, ec.B, ec.Timestamp})
|
||
// Update in-memory graph
|
||
if graphRef != nil {
|
||
// parseTimestamp handles empty strings safely (returns zero time — review item #8)
|
||
graphRef.upsertEdge(ec.A, ec.B, "", obs.ObserverID, obs.SNR, parseTimestamp(ec.Timestamp))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Persist asynchronously
|
||
if len(obsUpdates) > 0 || len(edgeUpdates) > 0 {
|
||
go func() {
|
||
rw, err := openRW(dbPath)
|
||
if err != nil {
|
||
log.Printf("[store] persist rw open error: %v", err)
|
||
return
|
||
}
|
||
defer rw.Close()
|
||
|
||
if len(obsUpdates) > 0 {
|
||
sqlTx, err := rw.Begin()
|
||
if err == nil {
|
||
stmt, err := sqlTx.Prepare("UPDATE observations SET resolved_path = ? WHERE id = ?")
|
||
if err == nil {
|
||
var firstErr error
|
||
for _, u := range obsUpdates {
|
||
if _, err := stmt.Exec(u.resolvedPath, u.obsID); err != nil && firstErr == nil {
|
||
firstErr = err
|
||
}
|
||
}
|
||
stmt.Close()
|
||
if firstErr != nil {
|
||
log.Printf("[store] async persist resolved_path error (first): %v", firstErr)
|
||
}
|
||
} else {
|
||
log.Printf("[store] async persist resolved_path prepare error: %v", err)
|
||
}
|
||
sqlTx.Commit()
|
||
}
|
||
}
|
||
|
||
if len(edgeUpdates) > 0 {
|
||
sqlTx, err := rw.Begin()
|
||
if err == nil {
|
||
stmt, err := sqlTx.Prepare(`INSERT INTO neighbor_edges (node_a, node_b, count, last_seen)
|
||
VALUES (?, ?, 1, ?)
|
||
ON CONFLICT(node_a, node_b) DO UPDATE SET
|
||
count = count + 1, last_seen = MAX(last_seen, excluded.last_seen)`)
|
||
if err == nil {
|
||
var firstErr error
|
||
for _, e := range edgeUpdates {
|
||
if _, err := stmt.Exec(e.a, e.b, e.ts); err != nil && firstErr == nil {
|
||
firstErr = err
|
||
}
|
||
}
|
||
stmt.Close()
|
||
if firstErr != nil {
|
||
log.Printf("[store] async persist edge error (first): %v", firstErr)
|
||
}
|
||
} else {
|
||
log.Printf("[store] async persist edge prepare error: %v", err)
|
||
}
|
||
sqlTx.Commit()
|
||
}
|
||
}
|
||
}()
|
||
}
|
||
}
|
||
|
||
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))
|
||
|
||
// 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++
|
||
if obs.Timestamp > tx.LatestSeen {
|
||
tx.LatestSeen = obs.Timestamp
|
||
}
|
||
s.byObsID[r.obsID] = obs
|
||
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))
|
||
for txID, tx := range updatedTxs {
|
||
oldPaths[txID] = tx.PathJSON
|
||
}
|
||
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 removeTxFromSubpathIndex(s.spIndex, tx) {
|
||
s.spTotalPaths--
|
||
}
|
||
tx.parsedPath, tx.pathParsed = saved, savedFlag
|
||
}
|
||
// pickBestObservation already set pathParsed=false so
|
||
// addTxToSubpathIndex will re-parse the new path.
|
||
if addTxToSubpathIndex(s.spIndex, tx) {
|
||
s.spTotalPaths++
|
||
}
|
||
}
|
||
}
|
||
|
||
// Rebuild distance index if any paths changed (distances depend on path hops)
|
||
for txID, tx := range updatedTxs {
|
||
if tx.PathJSON != oldPaths[txID] {
|
||
s.buildDistanceIndex()
|
||
break
|
||
}
|
||
}
|
||
|
||
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.
|
||
hasPathChanges := false
|
||
for txID, tx := range updatedTxs {
|
||
if tx.PathJSON != oldPaths[txID] {
|
||
hasPathChanges = true
|
||
break
|
||
}
|
||
}
|
||
s.invalidateCachesFor(cacheInvalidation{
|
||
hasNewObservations: true,
|
||
hasNewPaths: hasPathChanges,
|
||
})
|
||
}
|
||
|
||
// Persist resolved paths and neighbor edges asynchronously (review fix #3).
|
||
// Same pattern as IngestNewFromDB — late observations need persistence too.
|
||
if len(updatedTxs) > 0 && s.db != nil {
|
||
dbPath := s.db.path
|
||
type persistObs struct {
|
||
obsID int
|
||
resolvedPath string
|
||
}
|
||
type persistEdge struct {
|
||
a, b, ts string
|
||
}
|
||
var obsUpdates []persistObs
|
||
var edgeUpdates []persistEdge
|
||
|
||
for _, tx := range updatedTxs {
|
||
for _, obs := range tx.Observations {
|
||
if obs.ResolvedPath != nil {
|
||
rpJSON := marshalResolvedPath(obs.ResolvedPath)
|
||
if rpJSON != "" {
|
||
obsUpdates = append(obsUpdates, persistObs{obs.ID, rpJSON})
|
||
}
|
||
}
|
||
for _, ec := range extractEdgesFromObs(obs, tx, pm) {
|
||
edgeUpdates = append(edgeUpdates, persistEdge{ec.A, ec.B, ec.Timestamp})
|
||
if graphRef != nil {
|
||
graphRef.upsertEdge(ec.A, ec.B, "", obs.ObserverID, obs.SNR, parseTimestamp(ec.Timestamp))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if len(obsUpdates) > 0 || len(edgeUpdates) > 0 {
|
||
go func() {
|
||
rw, err := openRW(dbPath)
|
||
if err != nil {
|
||
log.Printf("[store] obs-persist rw open error: %v", err)
|
||
return
|
||
}
|
||
defer rw.Close()
|
||
|
||
if len(obsUpdates) > 0 {
|
||
sqlTx, err := rw.Begin()
|
||
if err == nil {
|
||
stmt, err := sqlTx.Prepare("UPDATE observations SET resolved_path = ? WHERE id = ?")
|
||
if err == nil {
|
||
var firstErr error
|
||
for _, u := range obsUpdates {
|
||
if _, err := stmt.Exec(u.resolvedPath, u.obsID); err != nil && firstErr == nil {
|
||
firstErr = err
|
||
}
|
||
}
|
||
stmt.Close()
|
||
if firstErr != nil {
|
||
log.Printf("[store] obs-persist resolved_path error (first): %v", firstErr)
|
||
}
|
||
} else {
|
||
log.Printf("[store] obs-persist resolved_path prepare error: %v", err)
|
||
}
|
||
sqlTx.Commit()
|
||
}
|
||
}
|
||
|
||
if len(edgeUpdates) > 0 {
|
||
sqlTx, err := rw.Begin()
|
||
if err == nil {
|
||
stmt, err := sqlTx.Prepare(`INSERT INTO neighbor_edges (node_a, node_b, count, last_seen)
|
||
VALUES (?, ?, 1, ?)
|
||
ON CONFLICT(node_a, node_b) DO UPDATE SET
|
||
count = count + 1, last_seen = MAX(last_seen, excluded.last_seen)`)
|
||
if err == nil {
|
||
var firstErr error
|
||
for _, e := range edgeUpdates {
|
||
if _, err := stmt.Exec(e.a, e.b, e.ts); err != nil && firstErr == nil {
|
||
firstErr = err
|
||
}
|
||
}
|
||
stmt.Close()
|
||
if firstErr != nil {
|
||
log.Printf("[store] obs-persist edge error (first): %v", firstErr)
|
||
}
|
||
} else {
|
||
log.Printf("[store] obs-persist edge prepare error: %v", err)
|
||
}
|
||
sqlTx.Commit()
|
||
}
|
||
}
|
||
}()
|
||
}
|
||
}
|
||
|
||
return broadcastMaps
|
||
}
|
||
|
||
// MaxTransmissionID returns the highest transmission ID in the store.
|
||
func (s *PacketStore) MaxTransmissionID() int {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
maxID := 0
|
||
for id := range s.byTxID {
|
||
if id > maxID {
|
||
maxID = id
|
||
}
|
||
}
|
||
return maxID
|
||
}
|
||
|
||
// MaxObservationID returns the highest observation ID in the store.
|
||
func (s *PacketStore) MaxObservationID() int {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
maxID := 0
|
||
for id := range s.byObsID {
|
||
if id > maxID {
|
||
maxID = id
|
||
}
|
||
}
|
||
return maxID
|
||
}
|
||
|
||
// --- 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.
|
||
func (s *PacketStore) resolveRegionObservers(region string) map[string]bool {
|
||
ids, err := s.db.GetObserverIdsForRegion(region)
|
||
if err != nil || len(ids) == 0 {
|
||
return nil
|
||
}
|
||
m := make(map[string]bool, len(ids))
|
||
for _, id := range ids {
|
||
m[id] = true
|
||
}
|
||
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 {
|
||
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.Join(hops[start:start+l], ",")
|
||
idx[key]++
|
||
}
|
||
}
|
||
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 {
|
||
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.Join(hops[start:start+l], ",")
|
||
idx[key]--
|
||
if idx[key] <= 0 {
|
||
delete(idx, 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.spTotalPaths = 0
|
||
for _, tx := range s.packets {
|
||
if addTxToSubpathIndex(s.spIndex, tx) {
|
||
s.spTotalPaths++
|
||
}
|
||
}
|
||
log.Printf("[store] Built subpath index: %d unique raw subpaths from %d paths",
|
||
len(s.spIndex), s.spTotalPaths)
|
||
}
|
||
|
||
// 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 estimated memory usage of the packet store.
|
||
func (s *PacketStore) estimatedMemoryMB() float64 {
|
||
return float64(len(s.packets)*5120+s.totalObs*500) / 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 still over budget, trim more from head
|
||
if s.maxMemoryMB > 0 {
|
||
for cutoffIdx < len(s.packets) && s.estimatedMemoryMB() > float64(s.maxMemoryMB) {
|
||
// Estimate how many more to evict: rough binary approach
|
||
overMB := s.estimatedMemoryMB() - float64(s.maxMemoryMB)
|
||
// ~5KB per packet, so overMB * 1024*1024 / 5120 packets
|
||
extra := int(overMB * 1048576.0 / 5120.0)
|
||
if extra < 100 {
|
||
extra = 100
|
||
}
|
||
cutoffIdx += extra
|
||
if cutoffIdx > len(s.packets) {
|
||
cutoffIdx = len(s.packets)
|
||
}
|
||
// Recalculate estimated memory with fewer packets
|
||
// (we haven't actually removed yet, so simulate)
|
||
remainingPkts := len(s.packets) - cutoffIdx
|
||
remainingObs := s.totalObs
|
||
for _, tx := range s.packets[:cutoffIdx] {
|
||
remainingObs -= len(tx.Observations)
|
||
}
|
||
estMB := float64(remainingPkts*5120+remainingObs*500) / 1048576.0
|
||
if estMB <= float64(s.maxMemoryMB) {
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
removeTxFromSubpathIndex(s.spIndex, 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))
|
||
freedMB := float64(evictCount*5120+evictedObs*500) / 1048576.0
|
||
log.Printf("[store] Evicted %d packets older than %.0fh (freed ~%.1fMB estimated)",
|
||
evictCount, s.retentionHours, freedMB)
|
||
|
||
// 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
|
||
}
|
||
|
||
func buildPrefixMap(nodes []nodeInfo) *prefixMap {
|
||
pm := &prefixMap{m: make(map[string][]nodeInfo, len(nodes)*10)}
|
||
for _, n := range nodes {
|
||
pk := strings.ToLower(n.PublicKey)
|
||
for l := 2; l <= len(pk); l++ {
|
||
pfx := pk[:l]
|
||
pm.m[pfx] = append(pm.m[pfx], 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
|
||
}
|
||
|
||
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 {
|
||
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.
|
||
func (s *PacketStore) computeNodeHashSizeInfo() map[string]*hashSizeNodeInfo {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
info := make(map[string]*hashSizeNodeInfo)
|
||
|
||
adverts := s.byPayloadType[4]
|
||
for _, tx := range adverts {
|
||
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
|
||
}
|
||
|
||
hourBuckets := make([]int, 24)
|
||
var snrSum, rssiSum float64
|
||
var snrCount, rssiCount int
|
||
observers := map[string]int{}
|
||
parentPaths := map[string]int{}
|
||
var matchCount int
|
||
var firstSeen, lastSeen string
|
||
|
||
for _, tx := range s.packets {
|
||
hops := txGetParsedPath(tx)
|
||
if len(hops) < len(rawHops) {
|
||
continue
|
||
}
|
||
|
||
// Check if rawHops appears as contiguous subsequence
|
||
found := false
|
||
for i := 0; i <= len(hops)-len(rawHops); i++ {
|
||
match := true
|
||
for j := 0; j < len(rawHops); j++ {
|
||
if !strings.EqualFold(hops[i+j], rawHops[j]) {
|
||
match = false
|
||
break
|
||
}
|
||
}
|
||
if match {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
continue
|
||
}
|
||
|
||
matchCount++
|
||
ts := tx.FirstSeen
|
||
if ts != "" {
|
||
if firstSeen == "" || ts < firstSeen {
|
||
firstSeen = ts
|
||
}
|
||
if lastSeen == "" || ts > lastSeen {
|
||
lastSeen = ts
|
||
}
|
||
// Parse hour from timestamp for hourly distribution
|
||
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)
|
||
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,
|
||
}
|
||
}
|