package main import ( "database/sql" "encoding/json" "fmt" "log" "math" "os" "regexp" "sort" "strconv" "strings" "sync" "time" "github.com/meshcore-analyzer/dbschema" "github.com/meshcore-analyzer/geofilter" _ "modernc.org/sqlite" ) // routeTypeTransport covers TRANSPORT_FLOOD (0) and TRANSPORT_DIRECT (3) — // the only route types that carry transport_code_1 (transport-level scope). // Per firmware/docs/packet_format.md § Route Types: // // 0 = TRANSPORT_FLOOD, 1 = FLOOD, 2 = DIRECT, 3 = TRANSPORT_DIRECT. // // Routes 1 (FLOOD) and 2 (DIRECT) never carry a scope by protocol — they are // inherently unscoped and are counted separately in GetScopeStats (#1838). const routeTypeTransportSQL = "route_type IN (0, 3)" // routeTypeNonTransportSQL matches FLOOD (1) and DIRECT (2) — non-transport // routes that carry no transport_code_1 and are therefore inherently unscoped // per MeshCore protocol (#1838). const routeTypeNonTransportSQL = "route_type IN (1, 2)" // DB wraps a read-only connection to the MeshCore SQLite database. type DB struct { conn *sql.DB path string // filesystem path to the database file isV3 bool // v3 schema: observer_idx in observations (vs observer_id in v2) hasResolvedPath bool // observations table has resolved_path column hasObsRawHex bool // observations table has raw_hex column (#881) hasScopeName bool // transmissions.scope_name column exists (#899) hasDefaultScope bool // nodes.default_scope column exists (#899) hasMultibyteSupCols bool // nodes/inactive_nodes have multibyte_sup/multibyte_evidence (#903) hasLastSeen bool // transmissions.last_seen column exists (#1690) // Channel list cache (60s TTL) — avoids repeated GROUP BY scans (#762) channelsCacheMu sync.Mutex channelsCacheKey string channelsCacheRes []map[string]interface{} channelsCacheExp time.Time } // OpenDB opens a read-only SQLite connection with WAL mode. func OpenDB(path string) (*DB, error) { dsn := fmt.Sprintf("file:%s?mode=ro&_journal_mode=WAL&_busy_timeout=5000", path) conn, err := sql.Open("sqlite", dsn) if err != nil { return nil, err } conn.SetMaxOpenConns(4) conn.SetMaxIdleConns(2) if err := conn.Ping(); err != nil { conn.Close() return nil, fmt.Errorf("ping failed: %w", err) } d := &DB{conn: conn, path: path} d.detectSchema() return d, nil } func (db *DB) Close() error { // Checkpoint WAL before closing to release lock cleanly for new processes if _, err := db.conn.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil { log.Printf("[db] WAL checkpoint error: %v", err) } else { log.Println("[db] WAL checkpoint complete") } return db.conn.Close() } // detectSchema checks if the observations table uses v3 schema (observer_idx). func (db *DB) detectSchema() { rows, err := db.conn.Query("PRAGMA table_info(observations)") if err != nil { return } defer rows.Close() for rows.Next() { var cid int var colName string var colType sql.NullString var notNull, pk int var dflt sql.NullString if rows.Scan(&cid, &colName, &colType, ¬Null, &dflt, &pk) == nil { if colName == "observer_idx" { db.isV3 = true } if colName == "resolved_path" { db.hasResolvedPath = true } if colName == "raw_hex" { db.hasObsRawHex = true } } } txRows, err := db.conn.Query("PRAGMA table_info(transmissions)") if err != nil { return } defer txRows.Close() for txRows.Next() { var cid int var colName string var colType sql.NullString var notNull, pk int var dflt sql.NullString if txRows.Scan(&cid, &colName, &colType, ¬Null, &dflt, &pk) == nil { if colName == "scope_name" { db.hasScopeName = true } if colName == "last_seen" { db.hasLastSeen = true } } } nodeRows, err := db.conn.Query("PRAGMA table_info(nodes)") if err != nil { return } defer nodeRows.Close() for nodeRows.Next() { var cid int var colName string var colType sql.NullString var notNull, pk int var dflt sql.NullString if nodeRows.Scan(&cid, &colName, &colType, ¬Null, &dflt, &pk) == nil { switch colName { case "default_scope": db.hasDefaultScope = true case "multibyte_sup": db.hasMultibyteSupCols = true } } } } // nodeSelectCols returns the SELECT column list for nodes queries. // When hasDefaultScope is true, default_scope is appended as the last column. func (db *DB) nodeSelectCols() string { cols := "public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c, foreign_advert" if db.hasDefaultScope { cols += ", default_scope" } return cols } // transmissionBaseSQL returns the SELECT columns and JOIN clause for transmission-centric queries. func (db *DB) transmissionBaseSQL() (selectCols, observerJoin string) { if db.isV3 { selectCols = `t.id, t.raw_hex, t.hash, t.first_seen, t.route_type, t.payload_type, t.decoded_json, COALESCE((SELECT COUNT(*) FROM observations WHERE transmission_id = t.id), 0) AS observation_count, obs.id AS observer_id, obs.name AS observer_name, COALESCE(obs.iata, '') AS observer_iata, o.snr, o.rssi, o.path_json, o.direction` observerJoin = `LEFT JOIN observations o ON o.id = ( SELECT id FROM observations WHERE transmission_id = t.id ORDER BY length(COALESCE(path_json,'')) DESC LIMIT 1 ) LEFT JOIN observers obs ON obs.rowid = o.observer_idx` } else { selectCols = `t.id, t.raw_hex, t.hash, t.first_seen, t.route_type, t.payload_type, t.decoded_json, COALESCE((SELECT COUNT(*) FROM observations WHERE transmission_id = t.id), 0) AS observation_count, o.observer_id, o.observer_name, COALESCE(obs2.iata, '') AS observer_iata, o.snr, o.rssi, o.path_json, o.direction` observerJoin = `LEFT JOIN observations o ON o.id = ( SELECT id FROM observations WHERE transmission_id = t.id ORDER BY length(COALESCE(path_json,'')) DESC LIMIT 1 ) LEFT JOIN observers obs2 ON obs2.id = o.observer_id` } if db.hasScopeName { selectCols += `, t.scope_name` } return } // scanTransmissionRow scans a row from the transmission-centric query. // Returns a map matching the Node.js packet-store transmission shape. func (db *DB) scanTransmissionRow(rows *sql.Rows) map[string]interface{} { var id, observationCount int var rawHex, hash, firstSeen, decodedJSON, observerID, observerName, observerIATA, pathJSON, direction sql.NullString var routeType, payloadType sql.NullInt64 var snr, rssi sql.NullFloat64 var scopeName sql.NullString scanArgs := []interface{}{&id, &rawHex, &hash, &firstSeen, &routeType, &payloadType, &decodedJSON, &observationCount, &observerID, &observerName, &observerIATA, &snr, &rssi, &pathJSON, &direction} if db.hasScopeName { scanArgs = append(scanArgs, &scopeName) } if err := rows.Scan(scanArgs...); err != nil { return nil } m := map[string]interface{}{ "id": id, "raw_hex": nullStr(rawHex), "hash": nullStr(hash), "first_seen": nullStr(firstSeen), "timestamp": nullStr(firstSeen), "route_type": nullInt(routeType), "payload_type": nullInt(payloadType), "decoded_json": nullStr(decodedJSON), "observation_count": observationCount, "observer_id": nullStr(observerID), "observer_name": nullStr(observerName), "observer_iata": nullStr(observerIATA), "snr": nullFloat(snr), "rssi": nullFloat(rssi), "path_json": nullStr(pathJSON), "direction": nullStr(direction), } if db.hasScopeName { m["scope_name"] = nullStr(scopeName) } return m } // Node represents a row from the nodes table. type Node struct { PublicKey string `json:"public_key"` Name *string `json:"name"` Role *string `json:"role"` Lat *float64 `json:"lat"` Lon *float64 `json:"lon"` LastSeen *string `json:"last_seen"` FirstSeen *string `json:"first_seen"` AdvertCount int `json:"advert_count"` BatteryMv *int `json:"battery_mv"` TemperatureC *float64 `json:"temperature_c"` } // Observer represents a row from the observers table. type Observer struct { ID string `json:"id"` Name *string `json:"name"` IATA *string `json:"iata"` LastSeen *string `json:"last_seen"` FirstSeen *string `json:"first_seen"` PacketCount int `json:"packet_count"` Model *string `json:"model"` Firmware *string `json:"firmware"` ClientVersion *string `json:"client_version"` Radio *string `json:"radio"` BatteryMv *int `json:"battery_mv"` UptimeSecs *int64 `json:"uptime_secs"` NoiseFloor *float64 `json:"noise_floor"` LastPacketAt *string `json:"last_packet_at"` // Issue #1478: per-observer naive-clock skew tracking. // Written by the ingestor in cmd/ingestor/db.go RecordNaiveSkew whenever // resolveRxTime clamps a naive envelope timestamp >15 min off UTC. The // server reads these as-is; the handler derives the bool `clock_naive` // from clock_last_naive_at being within the last 24h. ClockSkewSeconds *int64 `json:"clock_skew_seconds"` ClockSkewCount24h int `json:"clock_skew_count_24h"` ClockLastNaiveAt *string `json:"clock_last_naive_at"` // Issue #1290: firmware 1.16 `repeat: on|off` flag persisted by the // ingestor. true = relay-capable, false = listener-only, nil = // unknown (legacy observer that never sent the field — drives the // tri-state UI badge so legacy rows don't masquerade as confirmed // repeaters). The ingestor sets can_relay_seen=1 only when it has // an explicit value; the read layer returns nil when seen=0. CanRelay *bool `json:"can_relay,omitempty"` } // Transmission represents a row from the transmissions table. type Transmission struct { ID int `json:"id"` RawHex *string `json:"raw_hex"` Hash string `json:"hash"` FirstSeen string `json:"first_seen"` RouteType *int `json:"route_type"` PayloadType *int `json:"payload_type"` PayloadVersion *int `json:"payload_version"` DecodedJSON *string `json:"decoded_json"` CreatedAt *string `json:"created_at"` } // Observation (observation-level data). type Observation struct { ID int `json:"id"` RawHex *string `json:"raw_hex"` Timestamp *string `json:"timestamp"` ObserverID *string `json:"observer_id"` ObserverName *string `json:"observer_name"` Direction *string `json:"direction"` SNR *float64 `json:"snr"` RSSI *float64 `json:"rssi"` Score *int `json:"score"` Hash *string `json:"hash"` RouteType *int `json:"route_type"` PayloadType *int `json:"payload_type"` PayloadVer *int `json:"payload_version"` PathJSON *string `json:"path_json"` DecodedJSON *string `json:"decoded_json"` CreatedAt *string `json:"created_at"` } // Stats holds system statistics. type Stats struct { TotalPackets int `json:"totalPackets"` TotalTransmissions int `json:"totalTransmissions"` TotalObservations int `json:"totalObservations"` TotalNodes int `json:"totalNodes"` TotalNodesAllTime int `json:"totalNodesAllTime"` TotalObservers int `json:"totalObservers"` PacketsLastHour int `json:"packetsLastHour"` PacketsLast24h int `json:"packetsLast24h"` } // GetStats returns aggregate counts (matches Node.js db.getStats shape). func (db *DB) GetStats() (*Stats, error) { s := &Stats{} err := db.conn.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&s.TotalTransmissions) if err != nil { return nil, err } s.TotalPackets = s.TotalTransmissions db.conn.QueryRow("SELECT COUNT(*) FROM observations").Scan(&s.TotalObservations) // Node.js uses 7-day active nodes for totalNodes sevenDaysAgo := time.Now().Add(-7 * 24 * time.Hour).Format(time.RFC3339) db.conn.QueryRow("SELECT COUNT(*) FROM nodes WHERE last_seen > ?", sevenDaysAgo).Scan(&s.TotalNodes) db.conn.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&s.TotalNodesAllTime) db.conn.QueryRow("SELECT COUNT(*) FROM observers WHERE inactive IS NULL OR inactive = 0").Scan(&s.TotalObservers) oneHourAgo := time.Now().Add(-1 * time.Hour).Unix() db.conn.QueryRow("SELECT COUNT(*) FROM observations WHERE timestamp > ?", oneHourAgo).Scan(&s.PacketsLastHour) oneDayAgo := time.Now().Add(-24 * time.Hour).Unix() db.conn.QueryRow("SELECT COUNT(*) FROM observations WHERE timestamp > ?", oneDayAgo).Scan(&s.PacketsLast24h) return s, nil } // GetDBSizeStats returns SQLite file sizes and row counts (matching Node.js /api/perf sqlite shape). func (db *DB) GetDBSizeStats() map[string]interface{} { result := map[string]interface{}{} // DB file size var dbSizeMB float64 if db.path != "" && db.path != ":memory:" { if info, err := os.Stat(db.path); err == nil { dbSizeMB = math.Round(float64(info.Size())/1048576*10) / 10 } } result["dbSizeMB"] = dbSizeMB // WAL file size var walSizeMB float64 if db.path != "" && db.path != ":memory:" { if info, err := os.Stat(db.path + "-wal"); err == nil { walSizeMB = math.Round(float64(info.Size())/1048576*10) / 10 } } result["walSizeMB"] = walSizeMB // Freelist size via PRAGMA (matches Node.js: page_size * freelist_count) var pageSize, freelistCount int64 db.conn.QueryRow("PRAGMA page_size").Scan(&pageSize) db.conn.QueryRow("PRAGMA freelist_count").Scan(&freelistCount) freelistMB := math.Round(float64(pageSize*freelistCount)/1048576*10) / 10 result["freelistMB"] = freelistMB // WAL checkpoint info (matches Node.js: PRAGMA wal_checkpoint(PASSIVE)) var walBusy, walLog, walCheckpointed int err := db.conn.QueryRow("PRAGMA wal_checkpoint(PASSIVE)").Scan(&walBusy, &walLog, &walCheckpointed) if err == nil { result["walPages"] = map[string]interface{}{ "total": walLog, "checkpointed": walCheckpointed, "busy": walBusy, } } else { result["walPages"] = map[string]interface{}{ "total": 0, "checkpointed": 0, "busy": 0, } } // Row counts per table rows := map[string]int{} for _, table := range []string{"transmissions", "observations", "nodes", "observers"} { var count int db.conn.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) rows[table] = count } result["rows"] = rows return result } // GetDBSizeStatsTyped returns SQLite file sizes and row counts as a typed struct. func (db *DB) GetDBSizeStatsTyped() SqliteStats { result := SqliteStats{} if db.path != "" && db.path != ":memory:" { if info, err := os.Stat(db.path); err == nil { result.DbSizeMB = math.Round(float64(info.Size())/1048576*10) / 10 } } if db.path != "" && db.path != ":memory:" { if info, err := os.Stat(db.path + "-wal"); err == nil { result.WalSizeMB = math.Round(float64(info.Size())/1048576*10) / 10 } } var pageSize, freelistCount int64 db.conn.QueryRow("PRAGMA page_size").Scan(&pageSize) db.conn.QueryRow("PRAGMA freelist_count").Scan(&freelistCount) result.FreelistMB = math.Round(float64(pageSize*freelistCount)/1048576*10) / 10 var walBusy, walLog, walCheckpointed int err := db.conn.QueryRow("PRAGMA wal_checkpoint(PASSIVE)").Scan(&walBusy, &walLog, &walCheckpointed) if err == nil { result.WalPages = &WalPages{ Total: walLog, Checkpointed: walCheckpointed, Busy: walBusy, } } else { result.WalPages = &WalPages{} } rows := &SqliteRowCounts{} for _, table := range []string{"transmissions", "observations", "nodes", "observers"} { var count int db.conn.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) switch table { case "transmissions": rows.Transmissions = count case "observations": rows.Observations = count case "nodes": rows.Nodes = count case "observers": rows.Observers = count } } result.Rows = rows return result } // GetRoleCounts returns count per role (7-day active, matching Node.js /api/stats). func (db *DB) GetRoleCounts() map[string]int { sevenDaysAgo := time.Now().Add(-7 * 24 * time.Hour).Format(time.RFC3339) counts := map[string]int{} for _, role := range []string{"repeater", "room", "companion", "sensor"} { var c int db.conn.QueryRow("SELECT COUNT(*) FROM nodes WHERE role = ? AND last_seen > ?", role, sevenDaysAgo).Scan(&c) counts[role+"s"] = c } return counts } // GetAllRoleCounts returns count per role (all nodes, no time filter — matching Node.js /api/nodes). func (db *DB) GetAllRoleCounts() map[string]int { counts := map[string]int{} for _, role := range []string{"repeater", "room", "companion", "sensor"} { var c int db.conn.QueryRow("SELECT COUNT(*) FROM nodes WHERE role = ?", role).Scan(&c) counts[role+"s"] = c } return counts } // PacketQuery holds filter params for packet listing. type PacketQuery struct { Limit int Offset int Type *int Route *int Observer string Hash string Since string Until string Region string Area string // area key; filters by transmitting node's GPS position Node string Channel string // channel_hash filter (#812). Plain names like "#test"/"public" or "enc_" for encrypted Order string // ASC or DESC ExpandObservations bool // when true, include observation sub-maps in txToMap output } // PacketResult wraps paginated packet list. type PacketResult struct { Packets []map[string]interface{} `json:"packets"` Total int `json:"total"` Limit int `json:"limit"` Offset int `json:"offset"` } // QueryPackets returns paginated, filtered packets as transmissions (matching Node.js shape). func (db *DB) QueryPackets(q PacketQuery) (*PacketResult, error) { if q.Limit <= 0 { q.Limit = 50 } if q.Order == "" { q.Order = "DESC" } where, args := db.buildTransmissionWhere(q) w := "" if len(where) > 0 { w = "WHERE " + strings.Join(where, " AND ") } // Count transmissions (not observations) var total int if len(where) == 0 { db.conn.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&total) } else { countSQL := fmt.Sprintf("SELECT COUNT(*) FROM transmissions t %s", w) db.conn.QueryRow(countSQL, args...).Scan(&total) } // #1345: order by ingest id, NOT first_seen. PR #1233 made first_seen=rxTime, // so buffered-then-uploaded observer packets with hours-old rxTime were // sorting to the top/middle and hiding fresh ingest. Ordering by id keeps // "latest activity" semantically equal to "what we ingested last" — which // is what the packets page is showing. The `since=` filter still uses // first_seen / observation timestamp, preserving "received-by-radio since X." selectCols, observerJoin := db.transmissionBaseSQL() querySQL := fmt.Sprintf("SELECT %s FROM transmissions t %s %s ORDER BY t.id %s LIMIT ? OFFSET ?", selectCols, observerJoin, w, q.Order) qArgs := make([]interface{}, len(args)) copy(qArgs, args) qArgs = append(qArgs, q.Limit, q.Offset) rows, err := db.conn.Query(querySQL, qArgs...) if err != nil { return nil, err } defer rows.Close() packets := make([]map[string]interface{}, 0) for rows.Next() { p := db.scanTransmissionRow(rows) if p != nil { packets = append(packets, p) } } return &PacketResult{Packets: packets, Total: total}, nil } // QueryGroupedPackets groups by hash (transmissions) — queries transmissions table directly for performance. func (db *DB) QueryGroupedPackets(q PacketQuery) (*PacketResult, error) { if q.Limit <= 0 { q.Limit = 50 } where, args := db.buildTransmissionWhere(q) w := "" if len(where) > 0 { w = "WHERE " + strings.Join(where, " AND ") } // Count total transmissions (fast — queries transmissions directly, not a VIEW) var total int if len(where) == 0 { db.conn.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&total) } else { db.conn.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM transmissions t %s", w), args...).Scan(&total) } // Build grouped query using transmissions table with correlated subqueries. // #1189 R2: distinct_iatas is a NEW column — comma-separated DISTINCT IATA // codes across all observers of the transmission, with empty/NULL IATAs // excluded. Frontend needs this on the DEFAULT COLLAPSED VIEW (where // p._children is empty), so we compute it server-side. groupedScopeCol := "" if db.hasScopeName { groupedScopeCol = ", t.scope_name" } var querySQL string if db.isV3 { querySQL = fmt.Sprintf(`SELECT t.hash, t.first_seen, t.raw_hex, t.decoded_json, t.payload_type, t.route_type, COALESCE((SELECT COUNT(*) FROM observations oi WHERE oi.transmission_id = t.id), 0) AS count, COALESCE((SELECT COUNT(DISTINCT oi.observer_idx) FROM observations oi WHERE oi.transmission_id = t.id), 0) AS observer_count, COALESCE((SELECT MAX(strftime('%%Y-%%m-%%dT%%H:%%M:%%fZ', oi.timestamp, 'unixepoch')) FROM observations oi WHERE oi.transmission_id = t.id), t.first_seen) AS latest, obs.id AS observer_id, obs.name AS observer_name, COALESCE(obs.iata, '') AS observer_iata, o.snr, o.rssi, o.path_json, COALESCE((SELECT GROUP_CONCAT(DISTINCT obi.iata) FROM observations oi JOIN observers obi ON obi.rowid = oi.observer_idx WHERE oi.transmission_id = t.id AND obi.iata IS NOT NULL AND obi.iata != ''), '') AS distinct_iatas`+groupedScopeCol+` FROM transmissions t LEFT JOIN observations o ON o.id = ( SELECT id FROM observations WHERE transmission_id = t.id ORDER BY length(COALESCE(path_json,'')) DESC LIMIT 1 ) LEFT JOIN observers obs ON obs.rowid = o.observer_idx %s ORDER BY latest DESC LIMIT ? OFFSET ?`, w) } else { querySQL = fmt.Sprintf(`SELECT t.hash, t.first_seen, t.raw_hex, t.decoded_json, t.payload_type, t.route_type, COALESCE((SELECT COUNT(*) FROM observations oi WHERE oi.transmission_id = t.id), 0) AS count, COALESCE((SELECT COUNT(DISTINCT oi.observer_id) FROM observations oi WHERE oi.transmission_id = t.id), 0) AS observer_count, COALESCE((SELECT MAX(oi.timestamp) FROM observations oi WHERE oi.transmission_id = t.id), t.first_seen) AS latest, o.observer_id, o.observer_name, COALESCE(obs2.iata, '') AS observer_iata, o.snr, o.rssi, o.path_json, COALESCE((SELECT GROUP_CONCAT(DISTINCT obi.iata) FROM observations oi JOIN observers obi ON obi.id = oi.observer_id WHERE oi.transmission_id = t.id AND obi.iata IS NOT NULL AND obi.iata != ''), '') AS distinct_iatas`+groupedScopeCol+` FROM transmissions t LEFT JOIN observations o ON o.id = ( SELECT id FROM observations WHERE transmission_id = t.id ORDER BY length(COALESCE(path_json,'')) DESC LIMIT 1 ) LEFT JOIN observers obs2 ON obs2.id = o.observer_id %s ORDER BY latest DESC LIMIT ? OFFSET ?`, w) } qArgs := make([]interface{}, len(args)) copy(qArgs, args) qArgs = append(qArgs, q.Limit, q.Offset) rows, err := db.conn.Query(querySQL, qArgs...) if err != nil { return nil, err } defer rows.Close() packets := make([]map[string]interface{}, 0) for rows.Next() { var hash, firstSeen, rawHex, decodedJSON, latest, observerID, observerName, observerIATA, pathJSON, distinctIatasCSV sql.NullString var payloadType, routeType sql.NullInt64 var count, observerCount int var snr, rssi sql.NullFloat64 var scopeName sql.NullString scanArgs := []interface{}{&hash, &firstSeen, &rawHex, &decodedJSON, &payloadType, &routeType, &count, &observerCount, &latest, &observerID, &observerName, &observerIATA, &snr, &rssi, &pathJSON, &distinctIatasCSV} if db.hasScopeName { scanArgs = append(scanArgs, &scopeName) } if err := rows.Scan(scanArgs...); err != nil { continue } packets = append(packets, map[string]interface{}{ "hash": nullStr(hash), "first_seen": nullStr(firstSeen), "count": count, "observer_count": observerCount, "observation_count": count, "latest": nullStr(latest), "observer_id": nullStr(observerID), "observer_name": nullStr(observerName), "observer_iata": nullStr(observerIATA), "distinct_iatas": parseDistinctIatasCSV(nullStr(distinctIatasCSV)), "path_json": nullStr(pathJSON), "payload_type": nullInt(payloadType), "route_type": nullInt(routeType), "raw_hex": nullStr(rawHex), "decoded_json": nullStr(decodedJSON), "snr": nullFloat(snr), "rssi": nullFloat(rssi), "scope_name": nullStr(scopeName), }) } return &PacketResult{Packets: packets, Total: total}, nil } // parseDistinctIatasCSV turns SQLite GROUP_CONCAT output ("SJC,SFO,OAK") into // a sorted, deduped []string. Returns an empty (non-nil) slice when the input // is empty/nil so JSON serialization stays consistent (`[]` not `null`). func parseDistinctIatasCSV(v interface{}) []string { s, ok := v.(string) if !ok || s == "" { return []string{} } parts := strings.Split(s, ",") seen := make(map[string]bool, len(parts)) out := make([]string, 0, len(parts)) for _, p := range parts { code := strings.TrimSpace(p) if code == "" || seen[code] { continue } seen[code] = true out = append(out, code) } sort.Strings(out) return out } func (db *DB) buildPacketWhere(q PacketQuery) ([]string, []interface{}) { var where []string var args []interface{} if q.Type != nil { where = append(where, "payload_type = ?") args = append(args, *q.Type) } if q.Route != nil { where = append(where, "route_type = ?") args = append(args, *q.Route) } if q.Observer != "" { where = append(where, "observer_id = ?") args = append(args, q.Observer) } if q.Hash != "" { where = append(where, "hash = ?") args = append(args, strings.ToLower(q.Hash)) } if q.Since != "" { where = append(where, "timestamp > ?") args = append(args, q.Since) } if q.Until != "" { where = append(where, "timestamp < ?") args = append(args, q.Until) } if q.Region != "" { where = append(where, "observer_id IN (SELECT id FROM observers WHERE iata = ?)") args = append(args, q.Region) } if q.Node != "" { pk := db.resolveNodePubkey(q.Node) // #1143: exact-match on the dedicated from_pubkey column instead of // LIKE-on-JSON substring (adversarial spoof + same-name false positives). where = append(where, "from_pubkey = ?") args = append(args, pk) } return where, args } // buildTransmissionWhere builds WHERE clauses for transmission-centric queries. // Uses t. prefix for transmission columns and EXISTS subqueries for observation filters. func (db *DB) buildTransmissionWhere(q PacketQuery) ([]string, []interface{}) { var where []string var args []interface{} if q.Type != nil { where = append(where, "t.payload_type = ?") args = append(args, *q.Type) } if q.Route != nil { where = append(where, "t.route_type = ?") args = append(args, *q.Route) } if q.Hash != "" { where = append(where, "t.hash = ?") args = append(args, strings.ToLower(q.Hash)) } if q.Since != "" { // RFC3339 since/until use an observations.timestamp subquery so that // re-observed packets (whose t.first_seen is older than the window // but which have observations inside the window) are still included. // Non-RFC3339 falls back to t.first_seen string compare. if ts, err := time.Parse(time.RFC3339Nano, q.Since); err == nil { where = append(where, "t.id IN (SELECT DISTINCT transmission_id FROM observations WHERE timestamp >= ?)") args = append(args, ts.Unix()) } else { where = append(where, "t.first_seen > ?") args = append(args, q.Since) } } if q.Until != "" { if ts, err := time.Parse(time.RFC3339Nano, q.Until); err == nil { where = append(where, "t.id IN (SELECT DISTINCT transmission_id FROM observations WHERE timestamp <= ?)") args = append(args, ts.Unix()) } else { where = append(where, "t.first_seen < ?") args = append(args, q.Until) } } if q.Node != "" { pk := db.resolveNodePubkey(q.Node) // #1143: exact-match on dedicated from_pubkey column. where = append(where, "t.from_pubkey = ?") args = append(args, pk) } if q.Channel != "" { // channel_hash column is indexed for payload_type = 5; filter is exact match. where = append(where, "t.channel_hash = ?") args = append(args, q.Channel) } if q.Observer != "" { ids := strings.Split(q.Observer, ",") placeholders := strings.Repeat("?,", len(ids)) placeholders = placeholders[:len(placeholders)-1] if db.isV3 { where = append(where, "EXISTS (SELECT 1 FROM observations oi JOIN observers obi ON obi.rowid = oi.observer_idx WHERE oi.transmission_id = t.id AND obi.id IN ("+placeholders+"))") } else { where = append(where, "EXISTS (SELECT 1 FROM observations oi WHERE oi.transmission_id = t.id AND oi.observer_id IN ("+placeholders+"))") } for _, id := range ids { args = append(args, strings.TrimSpace(id)) } } if q.Region != "" { if db.isV3 { where = append(where, "EXISTS (SELECT 1 FROM observations oi JOIN observers obi ON obi.rowid = oi.observer_idx WHERE oi.transmission_id = t.id AND obi.iata = ?)") } else { where = append(where, "EXISTS (SELECT 1 FROM observations oi JOIN observers obi ON obi.id = oi.observer_id WHERE oi.transmission_id = t.id AND obi.iata = ?)") } args = append(args, q.Region) } return where, args } func (db *DB) resolveNodePubkey(nodeIDOrName string) string { var pk string err := db.conn.QueryRow("SELECT public_key FROM nodes WHERE public_key = ? OR name = ? LIMIT 1", nodeIDOrName, nodeIDOrName).Scan(&pk) if err != nil { return nodeIDOrName } return pk } // GetTransmissionByID fetches from transmissions table with observer data. func (db *DB) GetTransmissionByID(id int) (map[string]interface{}, error) { selectCols, observerJoin := db.transmissionBaseSQL() querySQL := fmt.Sprintf("SELECT %s FROM transmissions t %s WHERE t.id = ?", selectCols, observerJoin) rows, err := db.conn.Query(querySQL, id) if err != nil { return nil, err } defer rows.Close() if rows.Next() { return db.scanTransmissionRow(rows), nil } return nil, nil } // GetPacketByHash fetches a transmission by content hash with observer data. func (db *DB) GetPacketByHash(hash string) (map[string]interface{}, error) { selectCols, observerJoin := db.transmissionBaseSQL() querySQL := fmt.Sprintf("SELECT %s FROM transmissions t %s WHERE t.hash = ?", selectCols, observerJoin) rows, err := db.conn.Query(querySQL, strings.ToLower(hash)) if err != nil { return nil, err } defer rows.Close() if rows.Next() { return db.scanTransmissionRow(rows), nil } return nil, nil } // GetObservationsForHash returns all observations for the transmission with // the given content hash. Used as a fallback by the packet-detail handler // when the in-memory PacketStore has pruned the entry but the DB still has it. func (db *DB) GetObservationsForHash(hash string) []map[string]interface{} { var txID int err := db.conn.QueryRow("SELECT id FROM transmissions WHERE hash = ?", strings.ToLower(hash)).Scan(&txID) if err != nil { return nil } obsByTx := db.getObservationsForTransmissions([]int{txID}) return obsByTx[txID] } // GetNodes returns filtered, paginated node list. func (db *DB) GetNodes(limit, offset int, role, search, before, lastHeard, sortBy, region string) ([]map[string]interface{}, int, map[string]int, error) { var where []string var args []interface{} if role != "" { where = append(where, "role = ?") args = append(args, role) } if search != "" { where = append(where, "name LIKE ?") args = append(args, "%"+search+"%") } if before != "" { where = append(where, "first_seen <= ?") args = append(args, before) } if lastHeard != "" { durations := map[string]int64{ "1h": 3600000, "6h": 21600000, "24h": 86400000, "7d": 604800000, "30d": 2592000000, } if ms, ok := durations[lastHeard]; ok { since := time.Now().Add(-time.Duration(ms) * time.Millisecond).Format(time.RFC3339) where = append(where, "last_seen > ?") args = append(args, since) } } if region != "" { codes := normalizeRegionCodes(region) if len(codes) > 0 { placeholders := make([]string, len(codes)) regionArgs := make([]interface{}, len(codes)) for i, c := range codes { placeholders[i] = "?" regionArgs[i] = c } joinCond := "obs.rowid = o.observer_idx" if !db.isV3 { joinCond = "obs.id = o.observer_id" } subq := fmt.Sprintf(`public_key IN ( SELECT DISTINCT JSON_EXTRACT(t.decoded_json, '$.pubKey') FROM transmissions t JOIN observations o ON o.transmission_id = t.id JOIN observers obs ON %s WHERE t.payload_type = 4 AND UPPER(TRIM(obs.iata)) IN (%s) )`, joinCond, strings.Join(placeholders, ",")) where = append(where, subq) args = append(args, regionArgs...) } } w := "" if len(where) > 0 { w = "WHERE " + strings.Join(where, " AND ") } sortMap := map[string]string{ "name": "name ASC", "lastSeen": "last_seen DESC", "packetCount": "advert_count DESC", } order := "last_seen DESC" if s, ok := sortMap[sortBy]; ok { order = s } if limit <= 0 { limit = 50 } var total int db.conn.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM nodes %s", w), args...).Scan(&total) querySQL := fmt.Sprintf("SELECT %s FROM nodes %s ORDER BY %s LIMIT ? OFFSET ?", db.nodeSelectCols(), w, order) qArgs := append(args, limit, offset) rows, err := db.conn.Query(querySQL, qArgs...) if err != nil { return nil, 0, nil, err } defer rows.Close() nodes := make([]map[string]interface{}, 0) for rows.Next() { n := db.scanNodeRow(rows) if n != nil { nodes = append(nodes, n) } } counts := db.GetAllRoleCounts() return nodes, total, counts, nil } // SearchNodes searches nodes by name or pubkey prefix. func (db *DB) SearchNodes(query string, limit int) ([]map[string]interface{}, error) { if limit <= 0 { limit = 10 } rows, err := db.conn.Query(fmt.Sprintf("SELECT %s FROM nodes WHERE name LIKE ? OR public_key LIKE ? ORDER BY last_seen DESC LIMIT ?", db.nodeSelectCols()), "%"+query+"%", query+"%", limit) if err != nil { return nil, err } defer rows.Close() nodes := make([]map[string]interface{}, 0) for rows.Next() { n := db.scanNodeRow(rows) if n != nil { nodes = append(nodes, n) } } return nodes, nil } // GetNodeByPrefix resolves a hex prefix (>=8 chars) to a unique node. // Returns (node, ambiguous, error). When multiple nodes share the prefix, // returns (nil, true, nil). Used by the short-URL feature (issue #772). // // Trade-off vs an opaque ID lookup table: prefixes are stable across // restarts, self-describing (no allocator needed), and resolve to the // authoritative pubkey on the server. Cost: ambiguity grows with the // node directory; we mitigate with a hard 8-hex-char (32-bit) minimum // and surface 409 Conflict when collisions occur. func (db *DB) GetNodeByPrefix(prefix string) (map[string]interface{}, bool, error) { if len(prefix) < 8 { return nil, false, nil } // Validate hex (avoid SQL LIKE wildcards leaking through). for _, c := range prefix { isHex := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') if !isHex { return nil, false, nil } } rows, err := db.conn.Query( fmt.Sprintf("SELECT %s FROM nodes WHERE public_key LIKE ? LIMIT 2", db.nodeSelectCols()), prefix+"%", ) if err != nil { return nil, false, err } defer rows.Close() var first map[string]interface{} count := 0 for rows.Next() { n := db.scanNodeRow(rows) if n == nil { continue } count++ if count == 1 { first = n } else { return nil, true, nil } } if count == 0 { return nil, false, nil } return first, false, nil } // GetNodeByPubkey returns a single node. func (db *DB) GetNodeByPubkey(pubkey string) (map[string]interface{}, error) { rows, err := db.conn.Query(fmt.Sprintf("SELECT %s FROM nodes WHERE public_key = ?", db.nodeSelectCols()), pubkey) if err != nil { return nil, err } defer rows.Close() if rows.Next() { return db.scanNodeRow(rows), nil } return nil, nil } // GetRecentTransmissionsForNode returns recent transmissions originated by a // node, identified by exact pubkey match on the indexed from_pubkey column // (#1143). The legacy `name` substring fallback was removed: it produced // same-name false positives and an adversarial spoof path where any node // could attribute its transmissions to a victim by naming itself with the // victim's pubkey. Pubkey is unique by design — that's the whole point. func (db *DB) GetRecentTransmissionsForNode(pubkey string, limit int) ([]map[string]interface{}, error) { if limit <= 0 { limit = 20 } selectCols, observerJoin := db.transmissionBaseSQL() // #1345: order by ingest id, not first_seen (=rxTime). Buffered observer // uploads with old rxTime would otherwise displace fresh activity from // the "recent transmissions for node" list. querySQL := fmt.Sprintf("SELECT %s FROM transmissions t %s WHERE t.from_pubkey = ? ORDER BY t.id DESC LIMIT ?", selectCols, observerJoin) args := []interface{}{pubkey, limit} rows, err := db.conn.Query(querySQL, args...) if err != nil { return nil, err } defer rows.Close() packets := make([]map[string]interface{}, 0) var txIDs []int for rows.Next() { p := db.scanTransmissionRow(rows) if p != nil { // Placeholder for observations — filled below p["observations"] = []map[string]interface{}{} if id, ok := p["id"].(int); ok { txIDs = append(txIDs, id) } packets = append(packets, p) } } // Fetch observations for all transmissions if len(txIDs) > 0 { obsMap := db.getObservationsForTransmissions(txIDs) for _, p := range packets { if id, ok := p["id"].(int); ok { if obs, found := obsMap[id]; found { p["observations"] = obs } } } } return packets, nil } // getObservationsForTransmissions fetches all observations for a set of transmission IDs, // returning a map of txID → []observation maps (matching Node.js recentAdverts shape). func (db *DB) getObservationsForTransmissions(txIDs []int) map[int][]map[string]interface{} { result := make(map[int][]map[string]interface{}) if len(txIDs) == 0 { return result } // Build IN clause placeholders := make([]string, len(txIDs)) args := make([]interface{}, len(txIDs)) for i, id := range txIDs { placeholders[i] = "?" args[i] = id } var querySQL string if db.isV3 { querySQL = fmt.Sprintf(`SELECT o.transmission_id, o.id, obs.id AS observer_id, obs.name AS observer_name, COALESCE(obs.iata, '') AS observer_iata, o.direction, o.snr, o.rssi, o.path_json, strftime('%%Y-%%m-%%dT%%H:%%M:%%fZ', o.timestamp, 'unixepoch') AS obs_timestamp FROM observations o LEFT JOIN observers obs ON obs.rowid = o.observer_idx WHERE o.transmission_id IN (%s) ORDER BY o.timestamp DESC`, strings.Join(placeholders, ",")) } else { querySQL = fmt.Sprintf(`SELECT o.transmission_id, o.id, o.observer_id, o.observer_name, COALESCE(obs.iata, '') AS observer_iata, o.direction, o.snr, o.rssi, o.path_json, o.timestamp AS obs_timestamp FROM observations o LEFT JOIN observers obs ON obs.id = o.observer_id WHERE o.transmission_id IN (%s) ORDER BY o.timestamp DESC`, strings.Join(placeholders, ",")) } rows, err := db.conn.Query(querySQL, args...) if err != nil { return result } defer rows.Close() for rows.Next() { var txID, obsID int var observerID, observerName, observerIATA, direction, pathJSON, obsTimestamp sql.NullString var snr, rssi sql.NullFloat64 if err := rows.Scan(&txID, &obsID, &observerID, &observerName, &observerIATA, &direction, &snr, &rssi, &pathJSON, &obsTimestamp); err != nil { continue } ts := nullStr(obsTimestamp) if s, ok := ts.(string); ok { ts = normalizeTimestamp(s) } obs := map[string]interface{}{ "id": obsID, "transmission_id": txID, "observer_id": nullStr(observerID), "observer_name": nullStr(observerName), "observer_iata": nullStr(observerIATA), "snr": nullFloat(snr), "rssi": nullFloat(rssi), "path_json": nullStr(pathJSON), "timestamp": ts, } result[txID] = append(result[txID], obs) } return result } // GetObservers returns active observers (not soft-deleted) sorted by last_seen DESC. func (db *DB) GetObservers() ([]Observer, error) { // Issue #1290: can_relay is read via COALESCE(can_relay, 1). The // column is added by internal/dbschema; older test fixtures and // pre-migration DBs may lack it, so we probe and fall back. // PR #1624 MAJOR-2: can_relay_seen is the tri-state sentinel — 1 // means the ingestor explicitly wrote a value, 0 means "unknown" // and the server returns CanRelay=nil so the UI shows no badge. canRelayClause := "COALESCE(can_relay, 1)" canRelaySeenClause := "0" if hasCol, _ := dbschema.TableHasColumn(db.conn, "observers", "can_relay"); !hasCol { canRelayClause = "1" } if hasCol, _ := dbschema.TableHasColumn(db.conn, "observers", "can_relay_seen"); hasCol { canRelaySeenClause = "COALESCE(can_relay_seen, 0)" } rows, err := db.conn.Query(`SELECT id, name, iata, last_seen, first_seen, packet_count, model, firmware, client_version, radio, battery_mv, uptime_secs, noise_floor, last_packet_at, clock_skew_seconds, clock_skew_count_24h, clock_last_naive_at, ` + canRelayClause + `, ` + canRelaySeenClause + ` FROM observers WHERE inactive IS NULL OR inactive = 0 ORDER BY last_seen DESC`) if err != nil { return nil, err } defer rows.Close() var observers []Observer for rows.Next() { var o Observer var batteryMv, uptimeSecs, clockSkewSec sql.NullInt64 var clockSkewCount sql.NullInt64 var noiseFloor sql.NullFloat64 var canRelay, canRelaySeen int if err := rows.Scan(&o.ID, &o.Name, &o.IATA, &o.LastSeen, &o.FirstSeen, &o.PacketCount, &o.Model, &o.Firmware, &o.ClientVersion, &o.Radio, &batteryMv, &uptimeSecs, &noiseFloor, &o.LastPacketAt, &clockSkewSec, &clockSkewCount, &o.ClockLastNaiveAt, &canRelay, &canRelaySeen); err != nil { continue } if canRelaySeen != 0 { b := canRelay != 0 o.CanRelay = &b } if batteryMv.Valid { v := int(batteryMv.Int64) o.BatteryMv = &v } if uptimeSecs.Valid { o.UptimeSecs = &uptimeSecs.Int64 } if noiseFloor.Valid { o.NoiseFloor = &noiseFloor.Float64 } if clockSkewSec.Valid { v := clockSkewSec.Int64 o.ClockSkewSeconds = &v } if clockSkewCount.Valid { o.ClockSkewCount24h = int(clockSkewCount.Int64) } observers = append(observers, o) } return observers, nil } // GetNonRelayObserverPubkeys returns the lowercase observer.id pubkeys // for observers that have advertised `repeat:off` (#1290). The server's // path-hop disambiguator consumes this to exclude listener-only nodes // from the candidate set. Inactive observers are excluded for // consistency with GetObservers; reactivation flips can_relay only on // the next status message. func (db *DB) GetNonRelayObserverPubkeys() ([]string, error) { // Graceful no-op when can_relay column is absent (legacy DB / older // test fixture). Avoids noisy schema-degradation log spam. if hasCol, _ := dbschema.TableHasColumn(db.conn, "observers", "can_relay"); !hasCol { return nil, nil } rows, err := db.conn.Query(`SELECT LOWER(id) FROM observers WHERE COALESCE(can_relay, 1) = 0 AND (inactive IS NULL OR inactive = 0)`) if err != nil { return nil, err } defer rows.Close() var out []string for rows.Next() { var pk string if err := rows.Scan(&pk); err == nil && pk != "" { out = append(out, pk) } } return out, rows.Err() } // GetCanRelaySeenObserverPubkeys returns the lowercase observer.id // pubkeys for which the ingestor has explicitly written a repeat-field // value (can_relay_seen=1). PR #1624 MAJOR-2: the badge surface uses // this to render tri-state — observers NOT in this set are "unknown" // and the UI shows no badge. func (db *DB) GetCanRelaySeenObserverPubkeys() ([]string, error) { if hasCol, _ := dbschema.TableHasColumn(db.conn, "observers", "can_relay_seen"); !hasCol { return nil, nil } rows, err := db.conn.Query(`SELECT LOWER(id) FROM observers WHERE COALESCE(can_relay_seen, 0) = 1 AND (inactive IS NULL OR inactive = 0)`) if err != nil { return nil, err } defer rows.Close() var out []string for rows.Next() { var pk string if err := rows.Scan(&pk); err == nil && pk != "" { out = append(out, pk) } } return out, rows.Err() } // GetObserverByID returns a single observer. func (db *DB) GetObserverByID(id string) (*Observer, error) { var o Observer var batteryMv, uptimeSecs, clockSkewSec sql.NullInt64 var clockSkewCount sql.NullInt64 var noiseFloor sql.NullFloat64 var canRelay, canRelaySeen int canRelayClause := "COALESCE(can_relay, 1)" canRelaySeenClause := "0" if hasCol, _ := dbschema.TableHasColumn(db.conn, "observers", "can_relay"); !hasCol { canRelayClause = "1" } if hasCol, _ := dbschema.TableHasColumn(db.conn, "observers", "can_relay_seen"); hasCol { canRelaySeenClause = "COALESCE(can_relay_seen, 0)" } err := db.conn.QueryRow(`SELECT id, name, iata, last_seen, first_seen, packet_count, model, firmware, client_version, radio, battery_mv, uptime_secs, noise_floor, last_packet_at, clock_skew_seconds, clock_skew_count_24h, clock_last_naive_at, `+canRelayClause+`, `+canRelaySeenClause+` FROM observers WHERE id = ?`, id). Scan(&o.ID, &o.Name, &o.IATA, &o.LastSeen, &o.FirstSeen, &o.PacketCount, &o.Model, &o.Firmware, &o.ClientVersion, &o.Radio, &batteryMv, &uptimeSecs, &noiseFloor, &o.LastPacketAt, &clockSkewSec, &clockSkewCount, &o.ClockLastNaiveAt, &canRelay, &canRelaySeen) if err != nil { return nil, err } if canRelaySeen != 0 { b := canRelay != 0 o.CanRelay = &b } if batteryMv.Valid { v := int(batteryMv.Int64) o.BatteryMv = &v } if uptimeSecs.Valid { o.UptimeSecs = &uptimeSecs.Int64 } if noiseFloor.Valid { o.NoiseFloor = &noiseFloor.Float64 } if clockSkewSec.Valid { v := clockSkewSec.Int64 o.ClockSkewSeconds = &v } if clockSkewCount.Valid { o.ClockSkewCount24h = int(clockSkewCount.Int64) } return &o, nil } // GetObserverIdsForRegion returns observer IDs for given IATA codes. func (db *DB) GetObserverIdsForRegion(regionParam string) ([]string, error) { codes := normalizeRegionCodes(regionParam) if len(codes) == 0 { return nil, nil } placeholders := make([]string, len(codes)) args := make([]interface{}, len(codes)) for i, c := range codes { placeholders[i] = "?" args[i] = c } rows, err := db.conn.Query(fmt.Sprintf("SELECT id FROM observers WHERE UPPER(TRIM(iata)) IN (%s)", strings.Join(placeholders, ",")), args...) if err != nil { return nil, err } defer rows.Close() var ids []string for rows.Next() { var id string if err := rows.Scan(&id); err != nil { return nil, err } ids = append(ids, id) } if err := rows.Err(); err != nil { return nil, err } return ids, nil } // normalizeRegionCodes parses a region query parameter into a list of upper-case // IATA codes. Returns nil to signal "no filter" (match all regions). // // Sentinel handling (issue #770): the frontend region filter dropdown labels its // catch-all option "All". When that option is selected the UI may send // ?region=All; older code interpreted that literally and tried to match an // IATA code "ALL", which never exists, returning an empty result set. Treat // "All" / "ALL" / "all" (case-insensitive, optionally surrounded by whitespace // or mixed with empty CSV slots) as equivalent to an empty value. // // Real IATA codes (e.g. "SJC", "PDX") still pass through unchanged. func normalizeRegionCodes(regionParam string) []string { if regionParam == "" { return nil } tokens := strings.Split(regionParam, ",") codes := make([]string, 0, len(tokens)) for _, token := range tokens { code := strings.TrimSpace(strings.ToUpper(token)) if code == "" || code == "ALL" { continue } codes = append(codes, code) } if len(codes) == 0 { return nil } return codes } // GetDistinctIATAs returns all distinct IATA codes from observers. func (db *DB) GetDistinctIATAs() ([]string, error) { rows, err := db.conn.Query("SELECT DISTINCT iata FROM observers WHERE iata IS NOT NULL") if err != nil { return nil, err } defer rows.Close() var codes []string for rows.Next() { var code string rows.Scan(&code) codes = append(codes, code) } return codes, nil } // GetNetworkStatus returns overall network health status. func (db *DB) GetNetworkStatus(healthThresholds HealthThresholds) (map[string]interface{}, error) { rows, err := db.conn.Query("SELECT public_key, name, role, last_seen FROM nodes") if err != nil { return nil, err } defer rows.Close() now := time.Now().UnixMilli() active, degraded, silent, total := 0, 0, 0, 0 roleCounts := map[string]int{} for rows.Next() { var pk string var name, role, lastSeen sql.NullString rows.Scan(&pk, &name, &role, &lastSeen) total++ r := "unknown" if role.Valid { r = role.String } roleCounts[r]++ age := int64(math.MaxInt64) if lastSeen.Valid { if t, err := time.Parse(time.RFC3339, lastSeen.String); err == nil { age = now - t.UnixMilli() } else if t, err := time.Parse("2006-01-02 15:04:05", lastSeen.String); err == nil { age = now - t.UnixMilli() } } degradedMs, silentMs := healthThresholds.GetHealthMs(r) if age < int64(degradedMs) { active++ } else if age < int64(silentMs) { degraded++ } else { silent++ } } return map[string]interface{}{ "total": total, "active": active, "degraded": degraded, "silent": silent, "roleCounts": roleCounts, }, nil } // GetTraces returns observations for a hash using direct table queries. func (db *DB) GetTraces(hash string) ([]map[string]interface{}, error) { var querySQL string if db.isV3 { querySQL = `SELECT obs.id AS observer_id, obs.name AS observer_name, strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch') AS timestamp, o.snr, o.rssi, o.path_json FROM observations o JOIN transmissions t ON t.id = o.transmission_id LEFT JOIN observers obs ON obs.rowid = o.observer_idx WHERE t.hash = ? ORDER BY o.timestamp ASC` } else { querySQL = `SELECT o.observer_id, o.observer_name, strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch') AS timestamp, o.snr, o.rssi, o.path_json FROM observations o JOIN transmissions t ON t.id = o.transmission_id WHERE t.hash = ? ORDER BY o.timestamp ASC` } rows, err := db.conn.Query(querySQL, strings.ToLower(hash)) if err != nil { return nil, err } defer rows.Close() var traces []map[string]interface{} for rows.Next() { var obsID, obsName, ts, pathJSON sql.NullString var snr, rssi sql.NullFloat64 rows.Scan(&obsID, &obsName, &ts, &snr, &rssi, &pathJSON) traces = append(traces, map[string]interface{}{ "observer": nullStr(obsID), "observer_name": nullStr(obsName), "time": nullStr(ts), "snr": nullFloat(snr), "rssi": nullFloat(rssi), "path_json": nullStr(pathJSON), }) } if traces == nil { traces = make([]map[string]interface{}, 0) } return traces, nil } // PacketPathPoint is one hop's position along a packet's resolved relay // path, for map visualization (public/packet-path-map.js). Lat/Lon are // nil when that node has never advertised a GPS position -- the caller // draws a gap rather than guessing. type PacketPathPoint struct { PublicKey string `json:"publicKey"` Name string `json:"name"` Role string `json:"role,omitempty"` Lat *float64 `json:"lat"` Lon *float64 `json:"lon"` // Approx is true when Lat/Lon are a weighted centroid of this node's // positioned neighbor_edges neighbors rather than its own position // (used as a last-resort stand-in when the node itself has no known // fix). ApproxNeighborCount/ApproxSpreadKm are only meaningful when // Approx is true. Approx bool `json:"approx,omitempty"` // ApproxNeighborCount is how many positioned neighbors fed the // centroid -- a rough confidence signal, higher is more confident. ApproxNeighborCount int `json:"approxNeighborCount,omitempty"` // ApproxSpreadKm is the widest distance (km) between any two of // those neighbors -- 0 (and omitted) with a single contributor; // larger means the neighbors disagree more about where "nearby" is. ApproxSpreadKm *float64 `json:"approxSpreadKm,omitempty"` } // PacketPathObserver is the station that produced a given branch's // observation of a packet (see GetPacketPath), positioned from its own // self-advertised GPS (the same source /api/observers uses) when known, // falling back to its configured IATA code, and finally its strongest // neighbor_edges neighbor's position (Approx=true), otherwise -- not a // stored per-observer lat/lon column. type PacketPathObserver struct { // PublicKey is the observer's mesh pubkey (observers.id for v3, // observer_id for legacy), when it has one -- lets callers link out // to the node detail page. Empty for an observer whose id never // resembled a pubkey. PublicKey string `json:"publicKey,omitempty"` Name string `json:"name"` IATA string `json:"iata,omitempty"` Role string `json:"role,omitempty"` Lat *float64 `json:"lat"` Lon *float64 `json:"lon"` // Approx is true when Lat/Lon are a weighted centroid of this // station's positioned neighbors instead of its own position -- see // PacketPathPoint.Approx (and ApproxNeighborCount/ApproxSpreadKm). Approx bool `json:"approx,omitempty"` ApproxNeighborCount int `json:"approxNeighborCount,omitempty"` ApproxSpreadKm *float64 `json:"approxSpreadKm,omitempty"` } // PacketPathBranch is one station's route to a packet: how far it // traveled to reach them (hop count taken straight from that // observation's path_json, independent of how much of it resolved) and, // where resolvable, each hop's name/role/lat/lon in path order. type PacketPathBranch struct { Hops int `json:"hops"` Points []PacketPathPoint `json:"points"` Observer *PacketPathObserver `json:"observer,omitempty"` SNR *float64 `json:"snr,omitempty"` // SecondsAfterFirst is how long after the earliest-arriving // observation (see PacketPathResponse.First) this branch's own // deepest observation arrived, in seconds. Zero for First itself. // Omitted when either timestamp is unknown. SecondsAfterFirst *float64 `json:"secondsAfterFirst,omitempty"` // DistanceFromFirstKm is the great-circle distance between this // branch's own Observer and First's Observer. Zero for First itself. // Omitted when either station's position is unknown (including when // one or both are Approx -- an estimate compounding another estimate // isn't worth surfacing). DistanceFromFirstKm *float64 `json:"distanceFromFirstKm,omitempty"` } // PacketPathResponse is every branch a packet is known to have reached -- // one per distinct observer, kept at that observer's own deepest // observation -- used to draw the full flood spread on a map (the // ping-bot reply's "View path" link), not just the single farthest route. // First is the single EARLIEST-arriving observation across every station // (regardless of observer or hop depth) -- the same "first observation // wins" pick already used for a ping message's own meta line -- included // separately since it approximates where the message entered the visible // mesh, a landmark the deepest-first Branches ordering doesn't surface. type PacketPathResponse struct { Hash string `json:"hash"` Branches []PacketPathBranch `json:"branches"` First *PacketPathBranch `json:"first,omitempty"` } // GetPacketPath resolves every distinct station that observed a packet to // its own branch: hop count and (where resolvable) relay names/positions // in path order, plus that station's own position. A station can hear a // packet more than once as flood copies arrive via different routes; only // its deepest observation (by raw hop count, same "farthest leg" // reasoning as the ping-bot reply -- see pingBotReply's doc comment) is // kept, so each station contributes exactly one branch. Branches are // returned deepest-first. The single earliest-arriving observation // (usually 0 hops, close to the sender) is additionally surfaced via // First, independent of which station it came from or how deep it was. func (db *DB) GetPacketPath(hash string) (*PacketPathResponse, error) { if !db.hasResolvedPath { return nil, fmt.Errorf("resolved_path not available on this server") } var querySQL string if db.isV3 { querySQL = `SELECT obs.rowid, obs.id, obs.name, obs.iata, o.path_json, o.resolved_path, o.snr, o.timestamp FROM observations o JOIN transmissions t ON t.id = o.transmission_id LEFT JOIN observers obs ON obs.rowid = o.observer_idx WHERE t.hash = ?` } else { querySQL = `SELECT o.observer_id, o.observer_id, o.observer_name, NULL, o.path_json, o.resolved_path, o.snr, o.timestamp FROM observations o JOIN transmissions t ON t.id = o.transmission_id WHERE t.hash = ?` } rows, err := db.conn.Query(querySQL, strings.ToLower(hash)) if err != nil { return nil, fmt.Errorf("packet path query: %w", err) } defer rows.Close() type obsBranch struct { hops int resolvedPath []*string observerName string observerPubkey string observerIATA sql.NullString snr sql.NullFloat64 ts int64 // unix epoch seconds of the observation that produced this branch's hops/resolvedPath; 0 if unknown } best := make(map[string]*obsBranch) var first *obsBranch var firstTS int64 for rows.Next() { var obsKey, obsPubkey, obsName, obsIATA, pathJSON, resolvedPathJSON sql.NullString var snr sql.NullFloat64 var ts sql.NullInt64 if err := rows.Scan(&obsKey, &obsPubkey, &obsName, &obsIATA, &pathJSON, &resolvedPathJSON, &snr, &ts); err != nil { continue } if !pathJSON.Valid { continue } var h []string if json.Unmarshal([]byte(pathJSON.String), &h) != nil { continue } hops := len(h) var resolvedPath []*string if resolvedPathJSON.Valid { resolvedPath = unmarshalResolvedPath(resolvedPathJSON.String) } key := obsKey.String if key == "" { key = obsName.String } if key == "" { continue // no way to attribute this observation to a station } var tsVal int64 if ts.Valid { tsVal = ts.Int64 } branch := &obsBranch{ hops: hops, resolvedPath: resolvedPath, observerName: obsName.String, observerPubkey: strings.ToLower(strings.TrimSpace(obsPubkey.String)), observerIATA: obsIATA, snr: snr, ts: tsVal, } if existing, ok := best[key]; !ok || hops > existing.hops { best[key] = branch } if ts.Valid && (first == nil || ts.Int64 < firstTS) { first, firstTS = branch, ts.Int64 } } if err := rows.Err(); err != nil { return nil, fmt.Errorf("packet path iteration: %w", err) } resp := &PacketPathResponse{Hash: hash, Branches: []PacketPathBranch{}} if len(best) == 0 { return resp, nil } pubkeySet := map[string]bool{} if first != nil { for _, pk := range first.resolvedPath { if pk != nil && *pk != "" { pubkeySet[*pk] = true } } if first.observerPubkey != "" { pubkeySet[first.observerPubkey] = true } } for _, b := range best { for _, pk := range b.resolvedPath { if pk != nil && *pk != "" { pubkeySet[*pk] = true } } // An observer is itself a mesh node -- if it has ever self-advertised // a GPS position, that's a more precise fix than its (often manually // typed, sometimes wrong or missing) configured IATA code. Folded // into the same batched lookup below rather than a second query. if b.observerPubkey != "" { pubkeySet[b.observerPubkey] = true } } pubkeys := make([]string, 0, len(pubkeySet)) for pk := range pubkeySet { pubkeys = append(pubkeys, pk) } type nodeInfo struct { name string role string lat *float64 lon *float64 } nodeByPK := make(map[string]nodeInfo, len(pubkeys)) if len(pubkeys) > 0 { placeholders := make([]byte, 0, len(pubkeys)*2) args := make([]interface{}, len(pubkeys)) for i, pk := range pubkeys { if i > 0 { placeholders = append(placeholders, ',') } placeholders = append(placeholders, '?') args[i] = pk } nodeRows, err := db.conn.Query( "SELECT public_key, name, role, lat, lon FROM nodes WHERE public_key IN ("+string(placeholders)+")", args...) if err == nil { for nodeRows.Next() { var pk string var name, role sql.NullString var lat, lon sql.NullFloat64 if nodeRows.Scan(&pk, &name, &role, &lat, &lon) == nil { ni := nodeInfo{name: name.String, role: role.String} // (0,0) is the ocean off Ghana, not a real fix -- some // nodes have it stored literally instead of NULL when // they've never actually reported a GPS position. // Excluded the same way GetNodesForScopeAdoption and // geofilter.PassesFilter already do; the node's own // name/role are kept, just not its bogus position. if lat.Valid && lon.Valid && !(lat.Float64 == 0 && lon.Float64 == 0) { v1, v2 := lat.Float64, lon.Float64 ni.lat, ni.lon = &v1, &v2 } nodeByPK[pk] = ni } } nodeRows.Close() } } // Fallback for observers whose own `observers.id` isn't its mesh // pubkey at all -- e.g. an MQTT-bridge-type observer (observed: // openHop-Repeater firmware) that publishes its status keyed by // device name rather than pubkey, so the lookup above never matches // even though the same physical device has a real, positioned nodes // row under its actual pubkey. Matched by exact display name; only // queried for observers the pubkey lookup left unpositioned, and only // applied if the pubkey lookup didn't already find something. nameFallbackNeeded := map[string]bool{} needsNameFallback := func(b *obsBranch) { if b == nil || b.observerName == "" { return } if ni, ok := nodeByPK[b.observerPubkey]; ok && ni.lat != nil && ni.lon != nil { return } nameFallbackNeeded[b.observerName] = true } for _, b := range best { needsNameFallback(b) } needsNameFallback(first) nodeByName := make(map[string]nodeInfo, len(nameFallbackNeeded)) if len(nameFallbackNeeded) > 0 { names := make([]string, 0, len(nameFallbackNeeded)) for n := range nameFallbackNeeded { names = append(names, n) } placeholders := make([]byte, 0, len(names)*2) args := make([]interface{}, len(names)) for i, n := range names { if i > 0 { placeholders = append(placeholders, ',') } placeholders = append(placeholders, '?') args[i] = n } nameRows, err := db.conn.Query( "SELECT name, role, lat, lon FROM nodes WHERE name IN ("+string(placeholders)+") AND lat IS NOT NULL AND lon IS NOT NULL AND lat != 0 AND lon != 0", args...) if err == nil { ambiguous := map[string]bool{} for nameRows.Next() { var name, role sql.NullString var lat, lon sql.NullFloat64 if nameRows.Scan(&name, &role, &lat, &lon) == nil { if _, exists := nodeByName[name.String]; exists { ambiguous[name.String] = true // >1 positioned node shares this name -- don't guess which one continue } v1, v2 := lat.Float64, lon.Float64 nodeByName[name.String] = nodeInfo{role: role.String, lat: &v1, lon: &v2} } } nameRows.Close() for n := range ambiguous { delete(nodeByName, n) } } } buildBranch := func(b *obsBranch) PacketPathBranch { branch := PacketPathBranch{Hops: b.hops, Points: []PacketPathPoint{}} for _, pk := range b.resolvedPath { if pk == nil || *pk == "" { continue } ni := nodeByPK[*pk] name := ni.name if name == "" { name = *pk } point := PacketPathPoint{PublicKey: *pk, Name: name, Role: ni.role, Lat: ni.lat, Lon: ni.lon} if point.Lat == nil { // Last resort: this node has never itself reported a // position -- borrow a weighted centroid of its // positioned neighbors instead, clearly flagged as // approximate rather than a real fix. if _, nLat, nLon, nCount, nSpread, ok := db.nearestPositionedNeighbor(*pk); ok { lat, lon := nLat, nLon point.Lat, point.Lon, point.Approx, point.ApproxNeighborCount = &lat, &lon, true, nCount if nCount > 1 { s := nSpread point.ApproxSpreadKm = &s } } } branch.Points = append(branch.Points, point) } if b.observerName != "" { obs := &PacketPathObserver{Name: b.observerName, PublicKey: b.observerPubkey} if b.observerIATA.Valid { obs.IATA = strings.ToUpper(strings.TrimSpace(b.observerIATA.String)) } // Prefer the observer's own self-advertised GPS (same source as // /api/observers and the Wardriving tab), falling back to a // name match against `nodes` (some bridge-type observers -- // seen on openHop-Repeater firmware -- publish their MQTT // status keyed by device name rather than mesh pubkey, so the // pubkey lookup above never finds their real, positioned node // row), and only then the configured IATA code -- which only // covers a fixed list of real airports plus a handful of // hand-added local codes, so a custom/regional code an // operator typed in (or a typo) falls through it even when // the node itself knows exactly where it is. if ni, ok := nodeByPK[b.observerPubkey]; ok && ni.role != "" { obs.Role = ni.role } else if ni, ok := nodeByName[b.observerName]; ok && ni.role != "" { obs.Role = ni.role } if ni, ok := nodeByPK[b.observerPubkey]; ok && ni.lat != nil && ni.lon != nil { obs.Lat, obs.Lon = ni.lat, ni.lon } else if ni, ok := nodeByName[b.observerName]; ok { obs.Lat, obs.Lon = ni.lat, ni.lon } else if obs.IATA != "" { if coord, ok := iataCoords[obs.IATA]; ok { lat, lon := coord.Lat, coord.Lon obs.Lat, obs.Lon = &lat, &lon } } if obs.Lat == nil && b.observerPubkey != "" { // Last resort, same as the hop-point fallback above: no // position of its own anywhere, so borrow a weighted // centroid of its positioned neighbors, flagged as approximate. if _, nLat, nLon, nCount, nSpread, ok := db.nearestPositionedNeighbor(b.observerPubkey); ok { lat, lon := nLat, nLon obs.Lat, obs.Lon, obs.Approx, obs.ApproxNeighborCount = &lat, &lon, true, nCount if nCount > 1 { s := nSpread obs.ApproxSpreadKm = &s } } } branch.Observer = obs } if b.snr.Valid { v := b.snr.Float64 branch.SNR = &v } if b.ts > 0 && first != nil && first.ts > 0 { d := float64(b.ts - first.ts) branch.SecondsAfterFirst = &d } return branch } // Build First first so its Observer position is known before computing // every other branch's DistanceFromFirstKm against it. var firstLat, firstLon *float64 if first != nil { fb := buildBranch(first) if fb.Observer != nil && !fb.Observer.Approx { firstLat, firstLon = fb.Observer.Lat, fb.Observer.Lon } if firstLat != nil { zero := 0.0 fb.DistanceFromFirstKm = &zero } resp.First = &fb } for _, b := range best { branch := buildBranch(b) if firstLat != nil && branch.Observer != nil && !branch.Observer.Approx && branch.Observer.Lat != nil { d := haversineKm(*branch.Observer.Lat, *branch.Observer.Lon, *firstLat, *firstLon) branch.DistanceFromFirstKm = &d } resp.Branches = append(resp.Branches, branch) } sort.Slice(resp.Branches, func(i, j int) bool { return resp.Branches[i].Hops > resp.Branches[j].Hops }) return resp, nil } // nearestPositionedNeighbor estimates pubkey's position from its // neighbor_edges neighbors that themselves have a real, known position, // for use as an approximate stand-in when pubkey has none of its own -- // e.g. a node that's never advertised a GPS fix, but is almost // certainly physically near wherever its neighbors are. Weighted by // each edge's observation count (a neighbor seen relaying to/from // pubkey many times pulls the estimate harder than one seen once), so // with several positioned neighbors this settles somewhere among them // rather than collapsing onto a single neighbor's exact coordinates -- // each neighbor's OWN position is a real, precise fix; only pubkey's // position relative to them is unknown, so more of them narrows it // down. With exactly one positioned neighbor this is identical to // using that neighbor's position outright. // // contributorCount is how many positioned neighbors fed the estimate, // and spreadKm is the widest distance between any two of them (0 when // there's only one) -- together a rough confidence signal callers can // use to size an "uncertainty" marker: more contributors that broadly // agree (small spread) means a tighter estimate than a single neighbor // or several that disagree (large spread). // // Returns ok=false when pubkey has no neighbor with a position at all. func (db *DB) nearestPositionedNeighbor(pubkey string) (name string, lat, lon float64, contributorCount int, spreadKm float64, ok bool) { pk := strings.ToLower(strings.TrimSpace(pubkey)) if pk == "" { return "", 0, 0, 0, 0, false } rows, err := db.conn.Query(` SELECT CASE WHEN node_a = ? THEN node_b ELSE node_a END AS neighbor, count FROM neighbor_edges WHERE node_a = ? OR node_b = ? ORDER BY count DESC LIMIT 20`, pk, pk, pk) if err != nil { return "", 0, 0, 0, 0, false } type candidate struct { pubkey string weight float64 } var candidates []candidate for rows.Next() { var neighborPK string var count float64 if rows.Scan(&neighborPK, &count) == nil { candidates = append(candidates, candidate{pubkey: neighborPK, weight: count}) } } rows.Close() if len(candidates) == 0 { return "", 0, 0, 0, 0, false } placeholders := make([]byte, 0, len(candidates)*2) args := make([]interface{}, len(candidates)) for i, c := range candidates { if i > 0 { placeholders = append(placeholders, ',') } placeholders = append(placeholders, '?') args[i] = c.pubkey } type posInfo struct { name string lat, lon float64 } posByPK := make(map[string]posInfo, len(candidates)) nodeRows, err := db.conn.Query( "SELECT public_key, name, lat, lon FROM nodes WHERE public_key IN ("+string(placeholders)+") AND lat IS NOT NULL AND lon IS NOT NULL AND lat != 0 AND lon != 0", args...) if err == nil { for nodeRows.Next() { var candPK string var candName sql.NullString var candLat, candLon float64 if nodeRows.Scan(&candPK, &candName, &candLat, &candLon) == nil { posByPK[candPK] = posInfo{name: candName.String, lat: candLat, lon: candLon} } } nodeRows.Close() } // candidates is count-DESC ordered, so the first contributor found // is the strongest -- used only for the returned name, which is // otherwise cosmetic (current callers discard it). var sumLat, sumLon, sumWeight float64 var strongestName string var contributors []posInfo for _, c := range candidates { p, found := posByPK[c.pubkey] if !found { continue } w := c.weight if w <= 0 { w = 1 } sumLat += p.lat * w sumLon += p.lon * w sumWeight += w if strongestName == "" { strongestName = p.name } contributors = append(contributors, p) } if sumWeight == 0 { return "", 0, 0, 0, 0, false } var spread float64 for i := 0; i < len(contributors); i++ { for j := i + 1; j < len(contributors); j++ { d := haversineKm(contributors[i].lat, contributors[i].lon, contributors[j].lat, contributors[j].lon) if d > spread { spread = d } } } return strongestName, sumLat / sumWeight, sumLon / sumWeight, len(contributors), spread, true } // GetChannels returns channel list from GRP_TXT packets. // Queries transmissions directly (not a VIEW) to avoid observation-level // duplicates that could cause stale lastMessage when an older message has // a later re-observation timestamp. func (db *DB) GetChannels(region ...string) ([]map[string]interface{}, error) { regionParam := "" if len(region) > 0 { regionParam = region[0] } // Check cache (60s TTL) db.channelsCacheMu.Lock() if db.channelsCacheRes != nil && db.channelsCacheKey == regionParam && time.Now().Before(db.channelsCacheExp) { res := db.channelsCacheRes db.channelsCacheMu.Unlock() return res, nil } db.channelsCacheMu.Unlock() regionCodes := normalizeRegionCodes(regionParam) var querySQL string args := make([]interface{}, 0, len(regionCodes)) if len(regionCodes) > 0 { placeholders := make([]string, len(regionCodes)) for i, code := range regionCodes { placeholders[i] = "?" args = append(args, code) } regionPlaceholder := strings.Join(placeholders, ",") if db.isV3 { querySQL = fmt.Sprintf(`SELECT t.channel_hash, COUNT(*) AS msg_count, MAX(t.first_seen) AS last_activity, (SELECT t2.decoded_json FROM transmissions t2 WHERE t2.channel_hash = t.channel_hash AND t2.payload_type = 5 ORDER BY t2.first_seen DESC LIMIT 1) AS sample_json FROM transmissions t JOIN observations o ON o.transmission_id = t.id LEFT JOIN observers obs ON obs.rowid = o.observer_idx WHERE t.payload_type = 5 AND t.channel_hash IS NOT NULL AND t.channel_hash NOT LIKE 'enc_%%' AND obs.rowid IS NOT NULL AND UPPER(TRIM(obs.iata)) IN (%s) GROUP BY t.channel_hash ORDER BY last_activity DESC`, regionPlaceholder) } else { querySQL = fmt.Sprintf(`SELECT t.channel_hash, COUNT(*) AS msg_count, MAX(t.first_seen) AS last_activity, (SELECT t2.decoded_json FROM transmissions t2 WHERE t2.channel_hash = t.channel_hash AND t2.payload_type = 5 ORDER BY t2.first_seen DESC LIMIT 1) AS sample_json FROM transmissions t JOIN observations o ON o.transmission_id = t.id WHERE t.payload_type = 5 AND t.channel_hash IS NOT NULL AND t.channel_hash NOT LIKE 'enc_%%' AND EXISTS ( SELECT 1 FROM observers obs WHERE obs.id = o.observer_id AND UPPER(TRIM(obs.iata)) IN (%s) ) GROUP BY t.channel_hash ORDER BY last_activity DESC`, regionPlaceholder) } } else { querySQL = `SELECT channel_hash, COUNT(*) AS msg_count, MAX(first_seen) AS last_activity, (SELECT t2.decoded_json FROM transmissions t2 WHERE t2.channel_hash = t.channel_hash AND t2.payload_type = 5 ORDER BY t2.first_seen DESC LIMIT 1) AS sample_json FROM transmissions t WHERE payload_type = 5 AND channel_hash IS NOT NULL AND channel_hash NOT LIKE 'enc_%%' GROUP BY channel_hash ORDER BY last_activity DESC` } rows, err := db.conn.Query(querySQL, args...) if err != nil { return nil, err } defer rows.Close() channels := make([]map[string]interface{}, 0) for rows.Next() { var chHash, lastActivity, sampleJSON sql.NullString var msgCount int if err := rows.Scan(&chHash, &msgCount, &lastActivity, &sampleJSON); err != nil { continue } channelName := nullStr(chHash) if channelName == "" { continue } var lastMessage, lastSender interface{} if sampleJSON.Valid { var decoded map[string]interface{} if json.Unmarshal([]byte(sampleJSON.String), &decoded) == nil { if text, ok := decoded["text"].(string); ok && text != "" { idx := strings.Index(text, ": ") if idx > 0 { lastMessage = text[idx+2:] } else { lastMessage = text } if sender, ok := decoded["sender"].(string); ok { lastSender = sender } } } } channels = append(channels, map[string]interface{}{ "hash": channelName, "name": channelName, "lastMessage": lastMessage, "lastSender": lastSender, "messageCount": msgCount, "lastActivity": nullStr(lastActivity), }) } // Store in cache (60s TTL) db.channelsCacheMu.Lock() db.channelsCacheRes = channels db.channelsCacheKey = regionParam db.channelsCacheExp = time.Now().Add(60 * time.Second) db.channelsCacheMu.Unlock() return channels, nil } // GetEncryptedChannels returns channels where all messages are undecryptable (no key). // Uses channel_hash column (prefixed with 'enc_') for fast grouped queries. func (db *DB) GetEncryptedChannels(region ...string) ([]map[string]interface{}, error) { regionParam := "" if len(region) > 0 { regionParam = region[0] } regionCodes := normalizeRegionCodes(regionParam) var querySQL string args := make([]interface{}, 0, len(regionCodes)) if len(regionCodes) > 0 { placeholders := make([]string, len(regionCodes)) for i, code := range regionCodes { placeholders[i] = "?" args = append(args, code) } regionPlaceholder := strings.Join(placeholders, ",") if db.isV3 { querySQL = fmt.Sprintf(`SELECT t.channel_hash, COUNT(*) AS msg_count, MAX(t.first_seen) AS last_activity FROM transmissions t JOIN observations o ON o.transmission_id = t.id LEFT JOIN observers obs ON obs.rowid = o.observer_idx WHERE t.payload_type = 5 AND t.channel_hash LIKE 'enc_%%' AND obs.rowid IS NOT NULL AND UPPER(TRIM(obs.iata)) IN (%s) GROUP BY t.channel_hash ORDER BY last_activity DESC`, regionPlaceholder) } else { querySQL = fmt.Sprintf(`SELECT t.channel_hash, COUNT(*) AS msg_count, MAX(t.first_seen) AS last_activity FROM transmissions t JOIN observations o ON o.transmission_id = t.id WHERE t.payload_type = 5 AND t.channel_hash LIKE 'enc_%%' AND EXISTS ( SELECT 1 FROM observers obs WHERE obs.id = o.observer_id AND UPPER(TRIM(obs.iata)) IN (%s) ) GROUP BY t.channel_hash ORDER BY last_activity DESC`, regionPlaceholder) } } else { querySQL = `SELECT channel_hash, COUNT(*) AS msg_count, MAX(first_seen) AS last_activity FROM transmissions WHERE payload_type = 5 AND channel_hash LIKE 'enc_%%' GROUP BY channel_hash ORDER BY last_activity DESC` } rows, err := db.conn.Query(querySQL, args...) if err != nil { return nil, err } defer rows.Close() channels := make([]map[string]interface{}, 0) for rows.Next() { var chHash, lastActivity sql.NullString var msgCount int if err := rows.Scan(&chHash, &msgCount, &lastActivity); err != nil { continue } fullHash := nullStrVal(chHash) // e.g. "enc_3A" hexPart := strings.TrimPrefix(fullHash, "enc_") channels = append(channels, map[string]interface{}{ "hash": fullHash, "name": "Encrypted (0x" + hexPart + ")", "lastMessage": nil, "lastSender": nil, "messageCount": msgCount, "lastActivity": nullStr(lastActivity), "encrypted": true, }) } return channels, nil } // GetChannelMessages returns messages for a specific channel. // Uses transmission-level ordering (first_seen) to ensure correct message // sequence even when observations arrive out of order. // // Pagination is applied at the SQL level on the transmissions table (not on // observations). The transmission.hash UNIQUE constraint means each // transmission is one logical message; multiple observations of the same // transmission collapse into one row with `repeats` = observation count. // This avoids loading every observation row for a channel into Go memory // before paginating (issue #1225: 5703 tx × ~50 obs ≈ 275K rows → ~30s // for limit=50). // channelMentionPrefixRe strips a leading "@target " reply-address the // same way the frontend does (public/channels.js replyMatch) before // matching the ping trigger, so "@CoreScopeBot ping" triggers the same as // a bare "ping". var channelMentionPrefixRe = regexp.MustCompile(`^@[A-Za-z0-9_-]{1,32}\s+`) // pingTriggerWords are the exact (case-insensitive) message bodies that // trigger a pong reply. Mirrored by pingTriggerWords in // public/channels.js -- keep both lists in sync by hand. var pingTriggerWords = map[string]bool{ "ping": true, "/ping": true, } // isPingTrigger reports whether displayText, after stripping a leading // "@target " mention the same way the frontend does (public/channels.js // replyMatch), exactly matches one of pingTriggerWords. func isPingTrigger(displayText string) bool { trigger := strings.TrimSpace(displayText) trigger = channelMentionPrefixRe.ReplaceAllString(trigger, "") return pingTriggerWords[strings.ToLower(strings.TrimSpace(trigger))] } // pingBotReply synthesizes a "pong" reply for a channel message whose // text matched isPingTrigger — CoreScope-side only, never transmitted // back onto the mesh (CoreScope has no publish path to a MeshCore // broker/radio). Purely a read-time annotation over data this message's // own row already carries (hop count + relay path, SNR, hearing // observer, region scope), not a persisted message. // // repeaterNames is the resolved relay path in hop order (element i is // hop i's node name, falling back to its pubkey/hash-prefix when a name // couldn't be resolved); nil/empty when hops == 0 or resolution wasn't // available -- the hop count itself is unaffected either way. // // farthestKm is the same "how wide did this spread geographically" signal // View Path's map shows (farthest any hearing station was from whoever // heard it first), computed from stations' own GPS fixes only -- nil when // fewer than two distinct stations have a position on file. func pingBotReply(hops int, snr sql.NullFloat64, observer string, repeaterNames []string, farthestKm *float64) map[string]interface{} { parts := make([]string, 0, 4) if hops > 0 { s := "s" if hops == 1 { s = "" } hopDesc := fmt.Sprintf("%d hop%s", hops, s) if len(repeaterNames) > 0 { hopDesc += " (via " + strings.Join(repeaterNames, " → ") + ")" } parts = append(parts, hopDesc) } else { parts = append(parts, "0 hops (direct)") } if snr.Valid { parts = append(parts, fmt.Sprintf("SNR %.1fdB", snr.Float64)) } if farthestKm != nil { parts = append(parts, fmt.Sprintf("spread up to %.1fkm", *farthestKm)) } if observer != "" { parts = append(parts, "heard by "+observer) } // Deliberately no scope/area here -- both are already shown on the // triggering message's own meta line right above this reply, so // repeating them would just be redundant. return map[string]interface{}{ "sender": "CoreScopeBot", "text": "🏓 pong! " + strings.Join(parts, " · "), "hops": hops, "snr": nullFloat(snr), } } func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region ...string) ([]map[string]interface{}, int, error) { if limit <= 0 { limit = 100 } if offset < 0 { offset = 0 } regionParam := "" if len(region) > 0 { regionParam = region[0] } regionCodes := normalizeRegionCodes(regionParam) regionArgs := make([]interface{}, 0, len(regionCodes)) regionPlaceholders := "" if len(regionCodes) > 0 { placeholders := make([]string, len(regionCodes)) for i, code := range regionCodes { placeholders[i] = "?" regionArgs = append(regionArgs, code) } regionPlaceholders = strings.Join(placeholders, ",") } // regionFilter: a transmission is included only if at least one of its // observations has an observer in one of the requested regions. regionFilter := "" if len(regionCodes) > 0 { if db.isV3 { regionFilter = fmt.Sprintf(` AND EXISTS ( SELECT 1 FROM observations o JOIN observers obs ON obs.rowid = o.observer_idx WHERE o.transmission_id = t.id AND UPPER(TRIM(obs.iata)) IN (%s))`, regionPlaceholders) } else { regionFilter = fmt.Sprintf(` AND EXISTS ( SELECT 1 FROM observations o JOIN observers obs ON obs.id = o.observer_id WHERE o.transmission_id = t.id AND UPPER(TRIM(obs.iata)) IN (%s))`, regionPlaceholders) } } // 1) Total count (after region filter, before pagination). countSQL := `SELECT COUNT(*) FROM transmissions t WHERE t.channel_hash = ? AND t.payload_type = 5` + regionFilter countArgs := []interface{}{channelHash} countArgs = append(countArgs, regionArgs...) var total int if err := db.conn.QueryRow(countSQL, countArgs...).Scan(&total); err != nil { return nil, 0, err } // 2) Page of transmission IDs — newest LIMIT msgs minus OFFSET. // Issue #1366 follow-up (fix #2): select page by latest observation // timestamp (LatestSeen) DESC, NOT by t.first_seen DESC — otherwise // a heartbeat tx whose FirstSeen is 24h old but whose latest // observation is fresh gets pushed off page 1. // // PR #1368 perf fix: use a correlated subquery for MAX(timestamp) per // transmission. With the composite index idx_observations_tx_ts // (transmission_id, timestamp) sqlite resolves MAX as an index-only // rightmost-leaf lookup — total O(N_tx · log N_obs). The previously- // used grouped derived table (`GROUP BY transmission_id` over the // whole observations table) scanned all observation rows (O(N_obs)) // and blew the 1.5s perf budget on 1500 tx × 50 obs under -race. // LEFT JOIN + GROUP BY t.id was even slower because GROUP BY forced // a temp B-tree on the full transmissions×observations join. // // The returned page is in newest-LatestSeen-FIRST (DESC) order. // The Go side re-orders the emitted rows ASC below (fix #3) so the // contract matches the in-memory path's tail-of-msgOrder convention. pageSQL := `SELECT t.id, COALESCE((SELECT MAX(timestamp) FROM observations WHERE transmission_id = t.id), 0) AS latest_obs_epoch FROM transmissions t WHERE t.channel_hash = ? AND t.payload_type = 5 ORDER BY latest_obs_epoch DESC, t.id DESC LIMIT ? OFFSET ?` if len(regionCodes) > 0 { pageSQL = `SELECT t.id, COALESCE((SELECT MAX(timestamp) FROM observations WHERE transmission_id = t.id), 0) AS latest_obs_epoch FROM transmissions t WHERE t.channel_hash = ? AND t.payload_type = 5` + regionFilter + ` ORDER BY latest_obs_epoch DESC, t.id DESC LIMIT ? OFFSET ?` } pageArgs := []interface{}{channelHash} pageArgs = append(pageArgs, regionArgs...) pageArgs = append(pageArgs, limit, offset) idRows, err := db.conn.Query(pageSQL, pageArgs...) if err != nil { return nil, 0, err } pageIDs := make([]int, 0, limit) for idRows.Next() { var id int var le sql.NullInt64 if err := idRows.Scan(&id, &le); err == nil { pageIDs = append(pageIDs, id) } } idRows.Close() if len(pageIDs) == 0 { return []map[string]interface{}{}, total, nil } // 3) Fetch observations for just this page of transmissions. We keep // the original "first observation wins" semantic for hops/snr/observer // by ordering observations by id ASC and breaking after first per tx. idPlaceholders := make([]string, len(pageIDs)) obsArgs := make([]interface{}, len(pageIDs)) for i, id := range pageIDs { idPlaceholders[i] = "?" obsArgs[i] = id } scopeCol := "" if db.hasScopeName { scopeCol = ", t.scope_name" } // resolvedPathCol feeds the ping-bot reply's "via RepeaterA → RepeaterB" // hop names (see the bulk-resolve pass below) -- optional like // scopeCol since not every DB/test fixture has this column. resolvedPathCol := "" if db.hasResolvedPath { resolvedPathCol = ", o.resolved_path" } var obsSQL string if db.isV3 { obsSQL = `SELECT o.id, t.id, t.hash, t.decoded_json, t.first_seen, obs.id, obs.name, o.snr, o.path_json, o.timestamp, t.route_type` + scopeCol + resolvedPathCol + ` FROM observations o JOIN transmissions t ON t.id = o.transmission_id LEFT JOIN observers obs ON obs.rowid = o.observer_idx WHERE t.id IN (` + strings.Join(idPlaceholders, ",") + `) ORDER BY o.id ASC` } else { obsSQL = `SELECT o.id, t.id, t.hash, t.decoded_json, t.first_seen, o.observer_id, o.observer_name, o.snr, o.path_json, o.timestamp, t.route_type` + scopeCol + resolvedPathCol + ` FROM observations o JOIN transmissions t ON t.id = o.transmission_id WHERE t.id IN (` + strings.Join(idPlaceholders, ",") + `) ORDER BY o.id ASC` } rows, err := db.conn.Query(obsSQL, obsArgs...) if err != nil { return nil, 0, err } defer rows.Close() type msg struct { Data map[string]interface{} Repeats int LatestEpoch int64 // max observation timestamp (unix seconds) — issue #1366 } msgMap := make(map[int]*msg, len(pageIDs)) // pendingPing collects a ping-triggering message's REACH across every // observation of it, not just the first: hops/snr/resolvedPath track // the DEEPEST (max-hop) observation seen so far -- how far the packet // had propagated before the farthest-along observer heard it -- and // observers is every distinct observer that heard it at all (breadth). // A single arbitrary "first observation wins" data point understates // both: two observers can hear the same flood at very different hop // depths depending on which relay leg reached them. type pendingPing struct { hops int snr sql.NullFloat64 resolvedPath []*string observers map[string]bool // observerPubkeys/firstPubkey/firstTS feed the "spread up to Nkm" // reply text -- the same farthest-from-first-hearer distance View // Path shows on its map, computed here as a cheap position-only // pass (no neighbor-centroid approximation) rather than reusing // GetPacketPath's heavier per-branch query for every ping. observerPubkeys map[string]bool firstPubkey string firstTS int64 } pendingPings := make(map[int]*pendingPing) for rows.Next() { var pktID, txID int var pktHash, dj, fs, obsID, obsName, pathJSON, resolvedPathJSON sql.NullString var snr sql.NullFloat64 var obsTs sql.NullInt64 var routeType sql.NullInt64 var scopeName sql.NullString scanArgs := []interface{}{&pktID, &txID, &pktHash, &dj, &fs, &obsID, &obsName, &snr, &pathJSON, &obsTs, &routeType} if db.hasScopeName { scanArgs = append(scanArgs, &scopeName) } if db.hasResolvedPath { scanArgs = append(scanArgs, &resolvedPathJSON) } if err := rows.Scan(scanArgs...); err != nil { return nil, 0, err } if !dj.Valid { continue } // Hop count, relay path, and hearing station for THIS observation // row -- computed for every row (not just the first) so a ping's // reach can be tracked across every station that heard it. var hops int var entryPrefix string if pathJSON.Valid { var h []string if json.Unmarshal([]byte(pathJSON.String), &h) == nil { hops = len(h) if len(h) > 0 { entryPrefix = h[0] } } } var resolvedPath []*string if resolvedPathJSON.Valid { resolvedPath = unmarshalResolvedPath(resolvedPathJSON.String) } observerName := "" if obsName.Valid { observerName = obsName.String } else if obsID.Valid { observerName = obsID.String } if existing, ok := msgMap[txID]; ok { existing.Repeats++ if obsTs.Valid && obsTs.Int64 > existing.LatestEpoch { existing.LatestEpoch = obsTs.Int64 } if agg, ok := pendingPings[txID]; ok { if observerName != "" { agg.observers[observerName] = true } if obsID.Valid && obsID.String != "" { pk := strings.ToLower(strings.TrimSpace(obsID.String)) agg.observerPubkeys[pk] = true if obsTs.Valid && (agg.firstPubkey == "" || obsTs.Int64 < agg.firstTS) { agg.firstPubkey = pk agg.firstTS = obsTs.Int64 } } if hops > agg.hops { agg.hops, agg.snr, agg.resolvedPath = hops, snr, resolvedPath } } continue } var decoded map[string]interface{} if json.Unmarshal([]byte(dj.String), &decoded) != nil { continue } text, _ := decoded["text"].(string) sender, _ := decoded["sender"].(string) if sender == "" && text != "" { if idx := strings.Index(text, ": "); idx > 0 && idx < 50 { sender = text[:idx] } } displaySender := sender displayText := text if text != "" { if idx := strings.Index(text, ": "); idx > 0 && idx < 50 { displaySender = text[:idx] displayText = text[idx+2:] } } senderTs := decoded["sender_timestamp"] m := &msg{ Data: map[string]interface{}{ "sender": displaySender, "text": displayText, "timestamp": nullStr(fs), "first_seen": nullStr(fs), "sender_timestamp": senderTs, "packetId": pktID, "packetHash": nullStr(pktHash), "repeats": 1, "observers": []string{}, "hops": hops, "snr": nullFloat(snr), "scope": nullStr(scopeName), "routeType": nullInt(routeType), "entryPrefix": entryPrefix, }, Repeats: 1, } if obsTs.Valid { m.LatestEpoch = obsTs.Int64 } if observerName != "" { m.Data["observers"] = []string{observerName} } if isPingTrigger(displayText) { agg := &pendingPing{ hops: hops, snr: snr, resolvedPath: resolvedPath, observers: map[string]bool{}, observerPubkeys: map[string]bool{}, } if observerName != "" { agg.observers[observerName] = true } if obsID.Valid && obsID.String != "" { pk := strings.ToLower(strings.TrimSpace(obsID.String)) agg.observerPubkeys[pk] = true if obsTs.Valid { agg.firstPubkey = pk agg.firstTS = obsTs.Int64 } } pendingPings[txID] = agg } msgMap[txID] = m } // Bulk-resolve every pubkey referenced by any ping's DEEPEST relay path // in ONE query, then build each pending reply's "via RepeaterA → // RepeaterB" text plus its observer-breadth label. Names default to // the raw pubkey/prefix when unresolved rather than being dropped, so // the hop count and reply still make sense. if len(pendingPings) > 0 { pubkeySet := map[string]bool{} for _, p := range pendingPings { for _, pk := range p.resolvedPath { if pk != nil && *pk != "" { pubkeySet[*pk] = true } } } pubkeys := make([]string, 0, len(pubkeySet)) for pk := range pubkeySet { pubkeys = append(pubkeys, pk) } names, _ := db.namesAndRolesForPubkeys(pubkeys) // Bulk-resolve observer positions too, for the "spread up to Nkm" // part of the reply -- only for pings that could possibly show one // (a first-hearer plus at least one other distinct station), and // deliberately WITHOUT GetPacketPath's neighbor-centroid fallback // for unpositioned stations: that's a per-node query each, too // expensive to run for every ping on a page of channel messages. // A station missing its own GPS fix just doesn't contribute here. posPubkeySet := map[string]bool{} for _, p := range pendingPings { if p.firstPubkey == "" || len(p.observerPubkeys) < 2 { continue } for pk := range p.observerPubkeys { posPubkeySet[pk] = true } } posByPK := map[string][2]float64{} if len(posPubkeySet) > 0 { posPubkeys := make([]string, 0, len(posPubkeySet)) for pk := range posPubkeySet { posPubkeys = append(posPubkeys, pk) } placeholders := make([]byte, 0, len(posPubkeys)*2) args := make([]interface{}, len(posPubkeys)) for i, pk := range posPubkeys { if i > 0 { placeholders = append(placeholders, ',') } placeholders = append(placeholders, '?') args[i] = pk } posRows, err := db.conn.Query( "SELECT public_key, lat, lon FROM nodes WHERE public_key IN ("+string(placeholders)+")", args...) if err == nil { for posRows.Next() { var pk string var lat, lon sql.NullFloat64 // (0,0) is the ocean off Ghana, not a real fix -- same // exclusion GetPacketPath applies. if posRows.Scan(&pk, &lat, &lon) == nil && lat.Valid && lon.Valid && !(lat.Float64 == 0 && lon.Float64 == 0) { posByPK[pk] = [2]float64{lat.Float64, lon.Float64} } } posRows.Close() } } for txID, p := range pendingPings { var repeaterNames []string for _, pk := range p.resolvedPath { if pk == nil || *pk == "" { continue } if name := names[*pk]; name != "" { repeaterNames = append(repeaterNames, name) } else { repeaterNames = append(repeaterNames, *pk) } } // Breadth: name the single observer when there's only one (as // specific as before), otherwise report the count -- "heard by // 4 observers" says more about actual reach than an arbitrarily // picked single name once more than one observer heard it. observerLabel := "" switch len(p.observers) { case 0: // leave empty case 1: for name := range p.observers { observerLabel = name } default: observerLabel = fmt.Sprintf("%d observers", len(p.observers)) } var farthestKm *float64 if firstPos, ok := posByPK[p.firstPubkey]; ok { var maxKm float64 sawOther := false for pk := range p.observerPubkeys { if pk == p.firstPubkey { continue } if pos, ok2 := posByPK[pk]; ok2 { d := haversineKm(firstPos[0], firstPos[1], pos[0], pos[1]) if !sawOther || d > maxKm { maxKm, sawOther = d, true } } } if sawOther { farthestKm = &maxKm } } if m, ok := msgMap[txID]; ok { m.Data["botReply"] = pingBotReply(p.hops, p.snr, observerLabel, repeaterNames, farthestKm) } } } // Issue #1366 follow-up: emit batch sorted by LatestSeen ascending // (newest LAST) — matches the in-memory path's tail-of-msgOrder // convention and the frontend's scrollToBottom() behavior. pageIDs // order is not LatestSeen-ordered for in-page rows after fix #2. type emitted struct { latestEpoch int64 txID int data map[string]interface{} } rowsOut := make([]emitted, 0, len(pageIDs)) for _, id := range pageIDs { m, ok := msgMap[id] if !ok { // Transmission had no observations (shouldn't happen via normal // ingest) or decoded_json was NULL/invalid — skip silently to // preserve prior behavior. continue } m.Data["repeats"] = m.Repeats // Issue #1366: emit LatestSeen (max obs timestamp) as the rendered // `timestamp` field. `first_seen` stays alongside for debug. if m.LatestEpoch > 0 { m.Data["timestamp"] = time.Unix(m.LatestEpoch, 0).UTC().Format(time.RFC3339) } rowsOut = append(rowsOut, emitted{latestEpoch: m.LatestEpoch, txID: id, data: m.Data}) } sort.SliceStable(rowsOut, func(i, j int) bool { if rowsOut[i].latestEpoch != rowsOut[j].latestEpoch { return rowsOut[i].latestEpoch < rowsOut[j].latestEpoch } return rowsOut[i].txID < rowsOut[j].txID }) messages := make([]map[string]interface{}, 0, len(rowsOut)) for _, e := range rowsOut { messages = append(messages, e.data) } return messages, total, nil } // GetNewTransmissionsSince returns new transmissions after a given ID for WebSocket polling. func (db *DB) GetNewTransmissionsSince(lastID int, limit int) ([]map[string]interface{}, error) { if limit <= 0 { limit = 100 } rows, err := db.conn.Query(`SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type, t.payload_type, t.payload_version, t.decoded_json FROM transmissions t WHERE t.id > ? ORDER BY t.id ASC LIMIT ?`, lastID, limit) if err != nil { return nil, err } defer rows.Close() var result []map[string]interface{} for rows.Next() { var id int var rawHex, hash, firstSeen, decodedJSON sql.NullString var routeType, payloadType, payloadVersion sql.NullInt64 rows.Scan(&id, &rawHex, &hash, &firstSeen, &routeType, &payloadType, &payloadVersion, &decodedJSON) result = append(result, map[string]interface{}{ "id": id, "raw_hex": nullStr(rawHex), "hash": nullStr(hash), "first_seen": nullStr(firstSeen), "route_type": nullInt(routeType), "payload_type": nullInt(payloadType), "payload_version": nullInt(payloadVersion), "decoded_json": nullStr(decodedJSON), }) } return result, nil } // GetMaxTransmissionID returns the current max ID for polling. func (db *DB) GetMaxTransmissionID() int { var maxID int db.conn.QueryRow("SELECT COALESCE(MAX(id), 0) FROM transmissions").Scan(&maxID) return maxID } // GetMaxObservationID returns the current max observation ID for polling. func (db *DB) GetMaxObservationID() int { var maxID int db.conn.QueryRow("SELECT COALESCE(MAX(id), 0) FROM observations").Scan(&maxID) return maxID } // GetObserverPacketCounts returns packetsLastHour for all observers (batch query). func (db *DB) GetObserverPacketCounts(sinceEpoch int64) map[string]int { counts := make(map[string]int) var rows *sql.Rows var err error if db.isV3 { rows, err = db.conn.Query(`SELECT obs.id, COUNT(*) as cnt FROM observations o JOIN observers obs ON obs.rowid = o.observer_idx WHERE o.timestamp > ? GROUP BY obs.id`, sinceEpoch) } else { rows, err = db.conn.Query(`SELECT o.observer_id, COUNT(*) as cnt FROM observations o WHERE o.observer_id IS NOT NULL AND o.timestamp > ? GROUP BY o.observer_id`, sinceEpoch) } if err != nil { return counts } defer rows.Close() for rows.Next() { var id string var cnt int rows.Scan(&id, &cnt) counts[id] = cnt } return counts } // GetNodeLocations returns a map of lowercase public_key → {lat, lon, role} for node geo lookups. func (db *DB) GetNodeLocations() map[string]map[string]interface{} { result := make(map[string]map[string]interface{}) rows, err := db.conn.Query("SELECT public_key, lat, lon, role FROM nodes") if err != nil { return result } defer rows.Close() for rows.Next() { var pk string var role sql.NullString var lat, lon sql.NullFloat64 rows.Scan(&pk, &lat, &lon, &role) result[strings.ToLower(pk)] = map[string]interface{}{ "lat": nullFloat(lat), "lon": nullFloat(lon), "role": nullStr(role), } } return result } // GetNodeLocationsByKeys returns location data only for the given public keys. // This avoids fetching ALL nodes when only a few keys need to be matched. func (db *DB) GetNodeLocationsByKeys(keys []string) map[string]map[string]interface{} { result := make(map[string]map[string]interface{}) if len(keys) == 0 { return result } placeholders := make([]string, len(keys)) args := make([]interface{}, len(keys)) for i, k := range keys { placeholders[i] = "?" args[i] = strings.ToLower(k) } // #1481 P0-3: drop LOWER(public_key) — that wrap is non-sargable and // forces a full scan. Nodes are stored lowercase already; we lowercase // args in Go above so a plain IN matches the index on public_key. query := "SELECT public_key, lat, lon, role FROM nodes WHERE public_key IN (" + strings.Join(placeholders, ",") + ")" rows, err := db.conn.Query(query, args...) if err != nil { return result } defer rows.Close() for rows.Next() { var pk string var role sql.NullString var lat, lon sql.NullFloat64 rows.Scan(&pk, &lat, &lon, &role) result[strings.ToLower(pk)] = map[string]interface{}{ "lat": nullFloat(lat), "lon": nullFloat(lon), "role": nullStr(role), } } return result } // GetRepeaterNamesByKeys batch-resolves pubkey -> display name, restricted // to role IN ('repeater','room'). The candidate key set (e.g. from // PacketStore.byPathHop) mixes full pubkeys with short hex-prefix bucket // keys used internally for ambiguous-hop resolution (#1751 follow-up) — // those never match a real nodes.public_key row, so the role-filtered IN // query doubles as the "is this actually a distinct node" existence check. // A matched pubkey with an empty/unset name falls back to itself so a // real repeater is never silently dropped just because it has no name yet. // // Queried in chunks of repeaterNamesByKeysBatchSize — SQLite's default // SQLITE_MAX_VARIABLE_NUMBER is 999 on older builds, and a large mesh's // byPathHop candidate set can exceed that in one IN (...) clause. const repeaterNamesByKeysBatchSize = 500 func (db *DB) GetRepeaterNamesByKeys(keys []string) map[string]string { result := make(map[string]string) if len(keys) == 0 { return result } for start := 0; start < len(keys); start += repeaterNamesByKeysBatchSize { end := start + repeaterNamesByKeysBatchSize if end > len(keys) { end = len(keys) } chunk := keys[start:end] placeholders := make([]string, len(chunk)) args := make([]interface{}, len(chunk)) for i, k := range chunk { placeholders[i] = "?" args[i] = strings.ToLower(k) } query := "SELECT public_key, name FROM nodes WHERE role IN ('repeater','room') AND public_key IN (" + strings.Join(placeholders, ",") + ")" rows, err := db.conn.Query(query, args...) if err != nil { continue } for rows.Next() { var pk string var name sql.NullString if rows.Scan(&pk, &name) != nil { continue } pk = strings.ToLower(pk) if name.Valid && name.String != "" { result[pk] = name.String } else { result[pk] = pk } } rows.Close() } return result } // GetNodesByDefaultScope groups nodes by their own configured region // (nodes.default_scope, #899) — the region a node's ADVERTs actually carry, // as opposed to GetRepeaterNamesByKeys/TransportedScopes which is about // repeaters *relaying* traffic scoped by other senders. Distinguishes // "runs this region" from "has carried this region's traffic". func (db *DB) GetNodesByDefaultScope() (map[string][]RepeaterRef, error) { result := make(map[string][]RepeaterRef) if !db.hasDefaultScope { return result, nil } rows, err := db.conn.Query(`SELECT public_key, name, default_scope FROM nodes WHERE default_scope IS NOT NULL AND default_scope != ''`) if err != nil { return nil, fmt.Errorf("nodes by default_scope query: %w", err) } defer rows.Close() for rows.Next() { var pk, scope string var name sql.NullString if rows.Scan(&pk, &name, &scope) != nil { continue } displayName := pk if name.Valid && name.String != "" { displayName = name.String } result[scope] = append(result[scope], RepeaterRef{Name: displayName, PublicKey: strings.ToLower(pk)}) } return result, rows.Err() } // nodeAreaScopeInput is one node's position + name + default_scope + // pubkey — the raw input to computeScopeAdoptionByArea. DefaultScope is "" // when unset or when this DB predates #899 (no default_scope column at // all). PublicKey is lowercase, for looking a node up in a // RepeaterRelayInfo map. type nodeAreaScopeInput struct { PublicKey string Name string Lat, Lon float64 DefaultScope string } // GetNodesForScopeAdoption returns every node with a real GPS fix (0,0 // excluded, same convention as geofilter.PassesFilter) and its // default_scope, for computeScopeAdoptionByArea to bucket by configured // area. Unlike GetNodesByDefaultScope, this includes nodes with NO scope // too — the whole point is measuring adoption, not just listing who has one. func (db *DB) GetNodesForScopeAdoption() ([]nodeAreaScopeInput, error) { query := "SELECT public_key, name, lat, lon" if db.hasDefaultScope { query += ", default_scope" } query += " FROM nodes WHERE lat IS NOT NULL AND lon IS NOT NULL AND lat != 0 AND lon != 0" rows, err := db.conn.Query(query) if err != nil { return nil, fmt.Errorf("nodes for scope adoption query: %w", err) } defer rows.Close() var out []nodeAreaScopeInput for rows.Next() { var pk string var name sql.NullString var lat, lon float64 var scope sql.NullString var scanErr error if db.hasDefaultScope { scanErr = rows.Scan(&pk, &name, &lat, &lon, &scope) } else { scanErr = rows.Scan(&pk, &name, &lat, &lon) } if scanErr != nil { continue } displayName := pk if name.Valid && name.String != "" { displayName = name.String } out = append(out, nodeAreaScopeInput{PublicKey: strings.ToLower(pk), Name: displayName, Lat: lat, Lon: lon, DefaultScope: scope.String}) } return out, rows.Err() } // computeScopeAdoptionByArea buckets nodes by their most specific // configured area and tallies, per area: how many nodes sit there at all, // how many "use scope" in ANY sense, and (when the area itself has a // RegionScopes link) how many specifically use THAT region — i.e. does // this geographic community actually engage with the scope the area is // nominally tied to, or something else entirely (or nothing at all). A // node outside every configured area is excluded. // // Unlike the per-node area *badges* (AreaForPoint/AreaKeyForPoint, which // pick a single most-specific area), this uses AreaKeysForPoint so a node // counts toward EVERY containing area — a node in "Aarhus by" also counts // toward "Jylland" and "Danmark (alle)". Without this, a broad roll-up // area like "Danmark (alle)" would only ever show the handful of nodes not // claimed by any smaller, more specific area (dborup flagged this: DK // showed almost nothing because nearly every real node already belonged // to a narrower sub-area), instead of the whole country's actual adoption. // // "Uses scope" counts two distinct signals, same runs-this-region vs // carried-this-region's-traffic distinction as OriginatingNodesByRegion vs // RepeatersByRegion above: (1) the node's own default_scope, and (2) any // region it has ever RELAYED (relayInfo/TransportedScopes) — a repeater // can carry dk-horsens traffic and thereby support the Horsens area // without ever configuring dk-horsens as its own default_scope. relayInfo // may be nil (in-memory store unavailable), in which case matching falls // back to default_scope only. // // For areas with a RegionScopes link, also returns the actual node lists // (Matching/NotMatching) — dborup wanted to see which specific nodes in // e.g. Østjylland relay dk-oj (correctly "support" the area) and which // don't, not just an aggregate count. func computeScopeAdoptionByArea(nodes []nodeAreaScopeInput, areas map[string]AreaEntry, relayInfo map[string]RepeaterRelayInfo) []AreaScopeAdoption { counts := make(map[string]*AreaScopeAdoption) for _, n := range nodes { keys := AreaKeysForPoint(n.Lat, n.Lon, areas) if len(keys) == 0 { continue } ownScope := strings.ToLower(strings.TrimPrefix(n.DefaultScope, "#")) relayedRegions := make(map[string]bool) if info, ok := relayInfo[n.PublicKey]; ok { for _, r := range info.TransportedScopes { relayedRegions[strings.ToLower(strings.TrimPrefix(r, "#"))] = true } } hasAnyScope := ownScope != "" || len(relayedRegions) > 0 for _, key := range keys { c, exists := counts[key] if !exists { a := areas[key] c = &AreaScopeAdoption{AreaKey: key, Label: a.Label, RegionScopes: a.RegionScopes} counts[key] = c } c.TotalNodes++ if hasAnyScope { c.NodesWithAnyScope++ } // Matching/NotMatching per-node lists only make sense when the // area actually has region(s) to compare against — an area // with no RegionScopes link has nothing to be "not matching". if len(c.RegionScopes) > 0 { var matchedScopes []string for _, rs := range c.RegionScopes { normalizedRegion := strings.ToLower(rs) if ownScope == normalizedRegion || relayedRegions[normalizedRegion] { matchedScopes = append(matchedScopes, rs) } } if len(matchedScopes) > 0 { c.NodesMatchingArea++ c.Matching = append(c.Matching, AreaScopeMatch{Name: n.Name, PublicKey: n.PublicKey, MatchedScopes: matchedScopes}) } else { c.NotMatching = append(c.NotMatching, RepeaterRef{Name: n.Name, PublicKey: n.PublicKey}) } } } } result := make([]AreaScopeAdoption, 0, len(counts)) for _, c := range counts { sort.Slice(c.Matching, func(i, j int) bool { return c.Matching[i].Name < c.Matching[j].Name }) sort.Slice(c.NotMatching, func(i, j int) bool { return c.NotMatching[i].Name < c.NotMatching[j].Name }) result = append(result, *c) } sort.Slice(result, func(i, j int) bool { if result[i].TotalNodes != result[j].TotalNodes { return result[i].TotalNodes > result[j].TotalNodes } return result[i].Label < result[j].Label }) return result } // QueryMultiNodePackets returns transmissions referencing any of the given pubkeys. func (db *DB) QueryMultiNodePackets(pubkeys []string, limit, offset int, order, since, until string) (*PacketResult, error) { if len(pubkeys) == 0 { return &PacketResult{Packets: []map[string]interface{}{}, Total: 0}, nil } if limit <= 0 { limit = 50 } if order == "" { order = "DESC" } // Build IN(?, ?, ...) on the dedicated from_pubkey column (#1143): // exact match, indexed lookup, no JSON substring scan. var args []interface{} placeholders := make([]string, 0, len(pubkeys)) for _, pk := range pubkeys { resolved := db.resolveNodePubkey(pk) args = append(args, resolved) placeholders = append(placeholders, "?") } pkWhere := "t.from_pubkey IN (" + strings.Join(placeholders, ",") + ")" var timeFilters []string if since != "" { timeFilters = append(timeFilters, "t.first_seen >= ?") args = append(args, since) } if until != "" { timeFilters = append(timeFilters, "t.first_seen <= ?") args = append(args, until) } w := "WHERE " + pkWhere if len(timeFilters) > 0 { w += " AND " + strings.Join(timeFilters, " AND ") } var total int db.conn.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM transmissions t %s", w), args...).Scan(&total) selectCols, observerJoin := db.transmissionBaseSQL() // #1345: order by ingest id (see QueryPackets comment above). querySQL := fmt.Sprintf("SELECT %s FROM transmissions t %s %s ORDER BY t.id %s LIMIT ? OFFSET ?", selectCols, observerJoin, w, order) qArgs := make([]interface{}, len(args)) copy(qArgs, args) qArgs = append(qArgs, limit, offset) rows, err := db.conn.Query(querySQL, qArgs...) if err != nil { return nil, err } defer rows.Close() packets := make([]map[string]interface{}, 0) for rows.Next() { p := db.scanTransmissionRow(rows) if p != nil { packets = append(packets, p) } } return &PacketResult{Packets: packets, Total: total}, nil } // --- Helpers --- func scanPacketRow(rows *sql.Rows) map[string]interface{} { var id int var rawHex, ts, obsID, obsName, direction, hash, pathJSON, decodedJSON, createdAt sql.NullString var snr, rssi sql.NullFloat64 var score, routeType, payloadType, payloadVersion sql.NullInt64 if err := rows.Scan(&id, &rawHex, &ts, &obsID, &obsName, &direction, &snr, &rssi, &score, &hash, &routeType, &payloadType, &payloadVersion, &pathJSON, &decodedJSON, &createdAt); err != nil { return nil } return map[string]interface{}{ "id": id, "raw_hex": nullStr(rawHex), "timestamp": nullStr(ts), "observer_id": nullStr(obsID), "observer_name": nullStr(obsName), "direction": nullStr(direction), "snr": nullFloat(snr), "rssi": nullFloat(rssi), "score": nullInt(score), "hash": nullStr(hash), "route_type": nullInt(routeType), "payload_type": nullInt(payloadType), "payload_version": nullInt(payloadVersion), "path_json": nullStr(pathJSON), "decoded_json": nullStr(decodedJSON), "created_at": nullStr(createdAt), } } // scanNodeRow scans a node row. When hasDefaultScope is true the SELECT must // include default_scope as the last column. func (db *DB) scanNodeRow(rows *sql.Rows) map[string]interface{} { var pk string var name, role, lastSeen, firstSeen sql.NullString var lat, lon sql.NullFloat64 var advertCount int var batteryMv sql.NullInt64 var temperatureC sql.NullFloat64 var foreign sql.NullInt64 var defaultScope sql.NullString scanArgs := []interface{}{&pk, &name, &role, &lat, &lon, &lastSeen, &firstSeen, &advertCount, &batteryMv, &temperatureC, &foreign} if db.hasDefaultScope { scanArgs = append(scanArgs, &defaultScope) } if err := rows.Scan(scanArgs...); err != nil { return nil } m := map[string]interface{}{ "public_key": pk, "name": nullStr(name), "role": nullStr(role), "lat": nullFloat(lat), "lon": nullFloat(lon), "last_seen": nullStr(lastSeen), "first_seen": nullStr(firstSeen), "advert_count": advertCount, "last_heard": nullStr(lastSeen), "hash_size": nil, "hash_size_inconsistent": false, "foreign": foreign.Valid && foreign.Int64 != 0, } if batteryMv.Valid { m["battery_mv"] = int(batteryMv.Int64) } else { m["battery_mv"] = nil } if temperatureC.Valid { m["temperature_c"] = temperatureC.Float64 } else { m["temperature_c"] = nil } if db.hasDefaultScope { m["default_scope"] = nullStr(defaultScope) } return m } func nullStr(ns sql.NullString) interface{} { if ns.Valid { return ns.String } return nil } func nullStrVal(ns sql.NullString) string { if ns.Valid { return ns.String } return "" } func nilIfEmpty(s string) interface{} { if s == "" { return nil } return s } func nullFloat(nf sql.NullFloat64) interface{} { if nf.Valid { return nf.Float64 } return nil } func nullInt(ni sql.NullInt64) interface{} { if ni.Valid { return int(ni.Int64) } return nil } // PruneOldPackets, PruneOldMetrics, and RemoveStaleObservers were // removed in #1283 — they are write operations and now live on the // ingestor's *Store (cmd/ingestor/maintenance.go and cmd/ingestor/db.go). // The server is the read path; it must not hold the SQLite write lock. // MetricsSample represents a single row from observer_metrics with computed deltas. type MetricsSample struct { Timestamp string `json:"timestamp"` NoiseFloor *float64 `json:"noise_floor"` TxAirSecs *int `json:"tx_air_secs,omitempty"` RxAirSecs *int `json:"rx_air_secs,omitempty"` RecvErrors *int `json:"recv_errors,omitempty"` BatteryMv *int `json:"battery_mv"` PacketsSent *int `json:"packets_sent,omitempty"` PacketsRecv *int `json:"packets_recv,omitempty"` TxAirtimePct *float64 `json:"tx_airtime_pct"` RxAirtimePct *float64 `json:"rx_airtime_pct"` RecvErrorRate *float64 `json:"recv_error_rate"` IsReboot bool `json:"is_reboot_sample,omitempty"` } // rawMetricsSample is the raw DB row before delta computation. type rawMetricsSample struct { Timestamp string NoiseFloor *float64 TxAirSecs *int RxAirSecs *int RecvErrors *int BatteryMv *int PacketsSent *int PacketsRecv *int } // GetObserverMetrics returns time-series metrics with server-side delta computation. // resolution: "5m" (raw), "1h", "1d" // sampleIntervalSec: expected interval between samples (default 300) func (db *DB) GetObserverMetrics(observerID, since, until, resolution string, sampleIntervalSec int) ([]MetricsSample, []string, error) { if sampleIntervalSec <= 0 { sampleIntervalSec = 300 } // Build query based on resolution var query string args := []interface{}{observerID} // Determine the effective bucket size for gap threshold scaling. // For raw data (5m), use sampleIntervalSec. For aggregated resolutions, // use the bucket duration so consecutive buckets aren't treated as gaps. bucketSizeSec := sampleIntervalSec switch resolution { case "1h": bucketSizeSec = 3600 // Use LAST value per bucket (latest timestamp) instead of MAX to preserve // reboot semantics: if a device reboots mid-bucket, the last sample is the // post-reboot baseline, not the pre-reboot high-water mark. query = `SELECT ts, noise_floor, tx_air_secs, rx_air_secs, recv_errors, battery_mv, packets_sent, packets_recv FROM ( SELECT strftime('%Y-%m-%dT%H:00:00Z', timestamp) as ts, noise_floor, tx_air_secs, rx_air_secs, recv_errors, battery_mv, packets_sent, packets_recv, ROW_NUMBER() OVER (PARTITION BY observer_id, strftime('%Y-%m-%dT%H:00:00Z', timestamp) ORDER BY timestamp DESC) as rn FROM observer_metrics WHERE observer_id = ?` case "1d": bucketSizeSec = 86400 query = `SELECT ts, noise_floor, tx_air_secs, rx_air_secs, recv_errors, battery_mv, packets_sent, packets_recv FROM ( SELECT strftime('%Y-%m-%dT00:00:00Z', timestamp) as ts, noise_floor, tx_air_secs, rx_air_secs, recv_errors, battery_mv, packets_sent, packets_recv, ROW_NUMBER() OVER (PARTITION BY observer_id, strftime('%Y-%m-%dT00:00:00Z', timestamp) ORDER BY timestamp DESC) as rn FROM observer_metrics WHERE observer_id = ?` default: // "5m" or raw query = `SELECT timestamp, noise_floor, tx_air_secs, rx_air_secs, recv_errors, battery_mv, packets_sent, packets_recv FROM observer_metrics WHERE observer_id = ?` } if since != "" { query += " AND timestamp >= ?" args = append(args, since) } if until != "" { query += " AND timestamp <= ?" args = append(args, until) } switch resolution { case "1h", "1d": query += ") WHERE rn = 1 ORDER BY ts ASC" default: query += " ORDER BY timestamp ASC" } rows, err := db.conn.Query(query, args...) if err != nil { return nil, nil, err } defer rows.Close() var raw []rawMetricsSample for rows.Next() { var s rawMetricsSample if err := rows.Scan(&s.Timestamp, &s.NoiseFloor, &s.TxAirSecs, &s.RxAirSecs, &s.RecvErrors, &s.BatteryMv, &s.PacketsSent, &s.PacketsRecv); err != nil { return nil, nil, err } raw = append(raw, s) } if err := rows.Err(); err != nil { return nil, nil, err } // Compute deltas between consecutive samples. // bucketSizeSec determines gap threshold: for raw data it's sampleIntervalSec, // for aggregated resolutions it's the bucket duration (3600 for 1h, 86400 for 1d). return computeDeltas(raw, bucketSizeSec) } // computeDeltas computes per-interval rates from cumulative counters. // Handles reboots (counter reset) and gaps (missing samples). // bucketSizeSec is the expected interval between consecutive points // (sampleInterval for raw data, bucket duration for aggregated resolutions). func computeDeltas(raw []rawMetricsSample, bucketSizeSec int) ([]MetricsSample, []string, error) { if len(raw) == 0 { return nil, nil, nil } gapThreshold := float64(bucketSizeSec) * 2.0 result := make([]MetricsSample, 0, len(raw)) var reboots []string for i, cur := range raw { s := MetricsSample{ Timestamp: cur.Timestamp, NoiseFloor: cur.NoiseFloor, BatteryMv: cur.BatteryMv, } if i == 0 { // First sample: no delta possible result = append(result, s) continue } prev := raw[i-1] // Check for gap curT, err1 := time.Parse(time.RFC3339, cur.Timestamp) prevT, err2 := time.Parse(time.RFC3339, prev.Timestamp) if err1 != nil || err2 != nil { result = append(result, s) continue } intervalSecs := curT.Sub(prevT).Seconds() if intervalSecs > gapThreshold { // Gap detected: insert null deltas (don't interpolate) result = append(result, s) continue } if intervalSecs <= 0 { result = append(result, s) continue } // Detect reboot: any cumulative counter decreased isReboot := false if cur.TxAirSecs != nil && prev.TxAirSecs != nil && *cur.TxAirSecs < *prev.TxAirSecs { isReboot = true } if cur.RxAirSecs != nil && prev.RxAirSecs != nil && *cur.RxAirSecs < *prev.RxAirSecs { isReboot = true } if cur.RecvErrors != nil && prev.RecvErrors != nil && *cur.RecvErrors < *prev.RecvErrors { isReboot = true } if cur.PacketsSent != nil && prev.PacketsSent != nil && *cur.PacketsSent < *prev.PacketsSent { isReboot = true } if cur.PacketsRecv != nil && prev.PacketsRecv != nil && *cur.PacketsRecv < *prev.PacketsRecv { isReboot = true } if isReboot { s.IsReboot = true reboots = append(reboots, cur.Timestamp) // Skip delta computation for reboot samples — use as new baseline result = append(result, s) continue } // Compute TX airtime percentage if cur.TxAirSecs != nil && prev.TxAirSecs != nil { delta := float64(*cur.TxAirSecs - *prev.TxAirSecs) pct := (delta / intervalSecs) * 100.0 if pct < 0 { pct = 0 } if pct > 100 { pct = 100 } result_pct := math.Round(pct*100) / 100 s.TxAirtimePct = &result_pct } // Compute RX airtime percentage if cur.RxAirSecs != nil && prev.RxAirSecs != nil { delta := float64(*cur.RxAirSecs - *prev.RxAirSecs) pct := (delta / intervalSecs) * 100.0 if pct < 0 { pct = 0 } if pct > 100 { pct = 100 } result_pct := math.Round(pct*100) / 100 s.RxAirtimePct = &result_pct } // Compute recv error rate if cur.RecvErrors != nil && prev.RecvErrors != nil && cur.PacketsRecv != nil && prev.PacketsRecv != nil { deltaErrors := float64(*cur.RecvErrors - *prev.RecvErrors) deltaRecv := float64(*cur.PacketsRecv - *prev.PacketsRecv) total := deltaRecv + deltaErrors if total > 0 { rate := (deltaErrors / total) * 100.0 rate = math.Round(rate*100) / 100 s.RecvErrorRate = &rate } } result = append(result, s) } return result, reboots, nil } // MetricsSummaryRow holds summary data for one observer. type MetricsSummaryRow struct { ObserverID string `json:"observer_id"` ObserverName *string `json:"observer_name"` IATA string `json:"iata,omitempty"` CurrentNF *float64 `json:"current_noise_floor"` AvgNF *float64 `json:"avg_noise_floor_24h"` MaxNF *float64 `json:"max_noise_floor_24h"` CurrentBattMv *int `json:"battery_mv"` SampleCount int `json:"sample_count"` Sparkline []*float64 `json:"sparkline"` } // GetMetricsSummary returns a fleet summary of observer metrics within a time window. // Uses a CTE with ROW_NUMBER to get latest values in a single pass (no correlated subqueries). // Also returns sparkline data (noise_floor time series) per observer. func (db *DB) GetMetricsSummary(since string) ([]MetricsSummaryRow, error) { query := ` WITH ranked AS ( SELECT observer_id, noise_floor, battery_mv, ROW_NUMBER() OVER (PARTITION BY observer_id ORDER BY timestamp DESC) as rn FROM observer_metrics WHERE timestamp >= ? ) SELECT m.observer_id, o.name, COALESCE(o.iata, '') as iata, r.noise_floor as current_nf, AVG(m.noise_floor) as avg_nf, MAX(m.noise_floor) as max_nf, r.battery_mv as current_batt, COUNT(*) as sample_count FROM observer_metrics m LEFT JOIN observers o ON o.id = m.observer_id LEFT JOIN ranked r ON r.observer_id = m.observer_id AND r.rn = 1 WHERE m.timestamp >= ? GROUP BY m.observer_id ORDER BY max_nf DESC ` rows, err := db.conn.Query(query, since, since) if err != nil { return nil, err } defer rows.Close() var result []MetricsSummaryRow for rows.Next() { var s MetricsSummaryRow if err := rows.Scan(&s.ObserverID, &s.ObserverName, &s.IATA, &s.CurrentNF, &s.AvgNF, &s.MaxNF, &s.CurrentBattMv, &s.SampleCount); err != nil { return nil, err } result = append(result, s) } if err := rows.Err(); err != nil { return nil, err } // Fetch sparkline data (noise_floor series) for all observers in one query if len(result) > 0 { sparkQuery := `SELECT observer_id, noise_floor FROM observer_metrics WHERE timestamp >= ? ORDER BY observer_id, timestamp ASC` sparkRows, err := db.conn.Query(sparkQuery, since) if err != nil { return nil, err } defer sparkRows.Close() sparkMap := make(map[string][]*float64) for sparkRows.Next() { var oid string var nf *float64 if err := sparkRows.Scan(&oid, &nf); err != nil { return nil, err } sparkMap[oid] = append(sparkMap[oid], nf) } if err := sparkRows.Err(); err != nil { return nil, err } for i := range result { if s, ok := sparkMap[result[i].ObserverID]; ok { result[i].Sparkline = s } } } return result, nil } // (PruneOldMetrics / RemoveStaleObservers removed in #1283 — see note // above the MetricsSample type. Ingestor owns these writes now.) // TouchNodeLastSeen updates last_seen for a node identified by full public key. // Only updates if the new timestamp is newer than the existing value (or NULL). // Returns nil even if no rows are affected (node doesn't exist). func (db *DB) TouchNodeLastSeen(pubkey string, timestamp string) error { _, err := db.conn.Exec( "UPDATE nodes SET last_seen = ? WHERE public_key = ? AND (last_seen IS NULL OR last_seen < ?)", timestamp, pubkey, timestamp, ) return err } // GetDroppedPackets returns recently dropped packets, newest first. func (db *DB) GetDroppedPackets(limit int, observerID, nodePubkey string) ([]map[string]interface{}, error) { if limit <= 0 || limit > 500 { limit = 100 } query := `SELECT id, hash, raw_hex, reason, observer_id, observer_name, node_pubkey, node_name, dropped_at FROM dropped_packets` var conditions []string var args []interface{} if observerID != "" { conditions = append(conditions, "observer_id = ?") args = append(args, observerID) } if nodePubkey != "" { conditions = append(conditions, "node_pubkey = ?") args = append(args, nodePubkey) } if len(conditions) > 0 { query += " WHERE " + strings.Join(conditions, " AND ") } query += " ORDER BY dropped_at DESC LIMIT ?" args = append(args, limit) rows, err := db.conn.Query(query, args...) if err != nil { return nil, err } defer rows.Close() var results []map[string]interface{} for rows.Next() { var id int var hash, rawHex, reason, obsID, obsName, pubkey, name, droppedAt sql.NullString if err := rows.Scan(&id, &hash, &rawHex, &reason, &obsID, &obsName, &pubkey, &name, &droppedAt); err != nil { continue } row := map[string]interface{}{ "id": id, "hash": nullStr(hash), "reason": nullStr(reason), "observer_id": nullStr(obsID), "observer_name": nullStr(obsName), "node_pubkey": nullStr(pubkey), "node_name": nullStr(name), "dropped_at": nullStr(droppedAt), } // Only include raw_hex if explicitly requested (it's large) if rawHex.Valid { row["raw_hex"] = rawHex.String } results = append(results, row) } if results == nil { results = []map[string]interface{}{} } return results, nil } // GetNodePubkeysInArea returns public keys of nodes whose GPS coordinates // fall inside the given area polygon or bounding box. func (db *DB) GetNodePubkeysInArea(entry AreaEntry) ([]string, error) { rows, err := db.conn.Query("SELECT public_key, lat, lon FROM nodes WHERE lat IS NOT NULL AND lon IS NOT NULL") if err != nil { return nil, err } defer rows.Close() gf := &geofilter.Config{ Polygon: entry.Polygon, LatMin: entry.LatMin, LatMax: entry.LatMax, LonMin: entry.LonMin, LonMax: entry.LonMax, } var result []string for rows.Next() { var pk string var lat, lon sql.NullFloat64 if err := rows.Scan(&pk, &lat, &lon); err != nil { continue } if !lat.Valid || !lon.Valid { continue } // Skip (0,0) — PassesFilter allows it but these nodes have no real GPS. if lat.Float64 == 0 && lon.Float64 == 0 { continue } if geofilter.PassesFilter(lat.Float64, lon.Float64, gf) { result = append(result, pk) } } return result, rows.Err() } // GetSignatureDropCount returns the total number of dropped packets. func (db *DB) GetSignatureDropCount() int64 { var count int64 // Table may not exist yet if ingestor hasn't run the migration err := db.conn.QueryRow("SELECT COUNT(*) FROM dropped_packets").Scan(&count) if err != nil { return 0 } return count } func (db *DB) GetScopeStats(window string) (*ScopeStatsResponse, error) { if !db.hasScopeName { return nil, fmt.Errorf("scope_name column not present — run ingestor to apply migrations") } var since string var bucketExpr string switch window { case "1h": since = time.Now().Add(-1 * time.Hour).UTC().Format(time.RFC3339) // 5-minute buckets bucketExpr = `strftime('%Y-%m-%dT%H:', first_seen) || printf('%02d', (CAST(strftime('%M', first_seen) AS INTEGER) / 5) * 5) || ':00Z'` case "7d": since = time.Now().Add(-7 * 24 * time.Hour).UTC().Format(time.RFC3339) // 6-hour buckets bucketExpr = `strftime('%Y-%m-%dT', first_seen) || printf('%02d', (CAST(strftime('%H', first_seen) AS INTEGER) / 6) * 6) || ':00:00Z'` default: // "24h" window = "24h" since = time.Now().Add(-24 * time.Hour).UTC().Format(time.RFC3339) // 1-hour buckets bucketExpr = `strftime('%Y-%m-%dT%H:00:00Z', first_seen)` } resp := &ScopeStatsResponse{Window: window} // Summary counts row := db.conn.QueryRow(` SELECT COUNT(*) AS transport_total, COUNT(scope_name) AS scoped, COALESCE(SUM(CASE WHEN scope_name IS NULL THEN 1 ELSE 0 END), 0) AS unscoped, COALESCE(SUM(CASE WHEN scope_name = '' THEN 1 ELSE 0 END), 0) AS unknown_scope FROM transmissions WHERE `+routeTypeTransportSQL+` AND first_seen >= ? `, since) if err := row.Scan( &resp.Summary.TransportTotal, &resp.Summary.Scoped, &resp.Summary.Unscoped, &resp.Summary.UnknownScope, ); err != nil { return nil, fmt.Errorf("scope summary query: %w", err) } // #1838: non-transport routes (FLOOD=1, DIRECT=2) never carry // transport_code_1 per MeshCore protocol, so they are inherently unscoped. // Fold their count into Summary.Unscoped so the analytics denominator // reflects total-observed-transmissions rather than only transport-eligible. var nonTransportUnscoped int if err := db.conn.QueryRow(` SELECT COUNT(*) FROM transmissions WHERE `+routeTypeNonTransportSQL+` AND first_seen >= ? `, since).Scan(&nonTransportUnscoped); err != nil { return nil, fmt.Errorf("scope non-transport count query: %w", err) } resp.Summary.Unscoped += nonTransportUnscoped // Per-region counts (named regions only) rows, err := db.conn.Query(` SELECT scope_name, COUNT(*) AS cnt FROM transmissions WHERE `+routeTypeTransportSQL+` AND scope_name IS NOT NULL AND scope_name != '' AND first_seen >= ? GROUP BY scope_name ORDER BY cnt DESC `, since) if err != nil { return nil, fmt.Errorf("scope byRegion query: %w", err) } defer rows.Close() for rows.Next() { var rc ScopeRegionCount if rows.Scan(&rc.Name, &rc.Count) == nil { resp.ByRegion = append(resp.ByRegion, rc) } } if err := rows.Err(); err != nil { return nil, fmt.Errorf("scope byRegion iteration: %w", err) } if resp.ByRegion == nil { resp.ByRegion = []ScopeRegionCount{} } // Time series tsQuery := fmt.Sprintf(` SELECT %s AS bucket, COUNT(scope_name) AS scoped, SUM(CASE WHEN scope_name IS NULL THEN 1 ELSE 0 END) AS unscoped FROM transmissions WHERE `+routeTypeTransportSQL+` AND first_seen >= ? GROUP BY bucket ORDER BY bucket `, bucketExpr) tsRows, err := db.conn.Query(tsQuery, since) if err != nil { return nil, fmt.Errorf("scope timeseries query: %w", err) } defer tsRows.Close() for tsRows.Next() { var pt ScopeTimePoint if tsRows.Scan(&pt.T, &pt.Scoped, &pt.Unscoped) == nil { resp.TimeSeries = append(resp.TimeSeries, pt) } } if err := tsRows.Err(); err != nil { return nil, fmt.Errorf("scope timeseries iteration: %w", err) } if resp.TimeSeries == nil { resp.TimeSeries = []ScopeTimePoint{} } // Hour-of-day activity per region: same named-region set as ByRegion, // but bucketed by hour-of-day (0-23, UTC) instead of chronological // time — answers "when during a typical day is this region active" // rather than "how did volume change over the window". Aggregated // across every day in the window, so this reads best on 7d (a single // 1h/24h window won't show a meaningful daily shape). hourRows, err := db.conn.Query(` SELECT scope_name, CAST(strftime('%H', first_seen) AS INTEGER) AS hour, COUNT(*) AS cnt FROM transmissions WHERE `+routeTypeTransportSQL+` AND scope_name IS NOT NULL AND scope_name != '' AND first_seen >= ? GROUP BY scope_name, hour `, since) if err != nil { return nil, fmt.Errorf("scope hourly activity query: %w", err) } defer hourRows.Close() hourly := make(map[string]*ScopeHourlyActivity) var hourlyOrder []string for hourRows.Next() { var region string var hour, cnt int if hourRows.Scan(®ion, &hour, &cnt) != nil { continue } if hour < 0 || hour > 23 { continue } ha, ok := hourly[region] if !ok { ha = &ScopeHourlyActivity{Region: region} hourly[region] = ha hourlyOrder = append(hourlyOrder, region) } ha.Hours[hour] = cnt } if err := hourRows.Err(); err != nil { return nil, fmt.Errorf("scope hourly activity iteration: %w", err) } resp.HourlyActivityByRegion = make([]ScopeHourlyActivity, 0, len(hourlyOrder)) for _, region := range hourlyOrder { resp.HourlyActivityByRegion = append(resp.HourlyActivityByRegion, *hourly[region]) } sort.Slice(resp.HourlyActivityByRegion, func(i, j int) bool { return resp.HourlyActivityByRegion[i].Region < resp.HourlyActivityByRegion[j].Region }) return resp, nil } // GetHopDepthAnalytics answers, network-wide over the given window, two // questions that share the same expensive "walk every resolved relay path" // pass — see HopDepthAnalyticsResponse's doc comment for the full // rationale. "Scoped" here is derived purely from route_type // (TRANSPORT_FLOOD=0/TRANSPORT_DIRECT=3 vs FLOOD=1/DIRECT=2, the same // convention used throughout this codebase — e.g. computeHopAnalyticsTransport), // not the scope_name column, so this works even on schemas predating #899. // // A transmission can have resolved_path stored on more than one // observation; this keeps whichever has the MOST entries per transmission // (a proxy for "most complete"), matching fetchResolvedPathForTxBest's // "longest wins" selection without needing per-tx round trips. func (db *DB) GetHopDepthAnalytics(window string) (*HopDepthAnalyticsResponse, error) { var since string var bucketExpr string switch window { case "1h": since = time.Now().Add(-1 * time.Hour).UTC().Format(time.RFC3339) // 5-minute buckets -- same bucketing as GetScopeStats' TimeSeries. bucketExpr = `strftime('%Y-%m-%dT%H:', t.first_seen) || printf('%02d', (CAST(strftime('%M', t.first_seen) AS INTEGER) / 5) * 5) || ':00Z'` case "7d": since = time.Now().Add(-7 * 24 * time.Hour).UTC().Format(time.RFC3339) // 6-hour buckets bucketExpr = `strftime('%Y-%m-%dT', t.first_seen) || printf('%02d', (CAST(strftime('%H', t.first_seen) AS INTEGER) / 6) * 6) || ':00:00Z'` default: window = "24h" since = time.Now().Add(-24 * time.Hour).UTC().Format(time.RFC3339) // 1-hour buckets bucketExpr = `strftime('%Y-%m-%dT%H:00:00Z', t.first_seen)` } rows, err := db.conn.Query(` SELECT t.id, t.route_type, t.payload_type, o.resolved_path, `+bucketExpr+` AS bucket FROM transmissions t JOIN observations o ON o.transmission_id = t.id WHERE t.first_seen > ? AND o.resolved_path IS NOT NULL AND o.resolved_path != ''`, since) if err != nil { return nil, fmt.Errorf("hop depth analytics query: %w", err) } defer rows.Close() type txInfo struct { routeType sql.NullInt64 payloadType sql.NullInt64 bestPath []*string bucket string } byTx := make(map[int]*txInfo) for rows.Next() { var txID int var routeType, payloadType sql.NullInt64 var rpJSON, bucket string if err := rows.Scan(&txID, &routeType, &payloadType, &rpJSON, &bucket); err != nil { continue } rp := unmarshalResolvedPath(rpJSON) if len(rp) == 0 { continue } cur, ok := byTx[txID] if !ok { byTx[txID] = &txInfo{routeType: routeType, payloadType: payloadType, bestPath: rp, bucket: bucket} continue } if len(rp) > len(cur.bestPath) { cur.bestPath = rp } } if err := rows.Err(); err != nil { return nil, fmt.Errorf("hop depth analytics iteration: %w", err) } scopedBuckets := map[int]int{} unscopedBuckets := map[int]int{} repeaterHops := map[string][]int{} // Same scoped/unscoped hop-index tally as scopedBuckets/unscopedBuckets // above, but split by time bucket too -- feeds TimeSeries, answering // "is containment getting better or worse over the window" instead of // just a single window-wide snapshot. scopedByTimeBucket := map[string]map[int]int{} unscopedByTimeBucket := map[string]map[int]int{} for _, info := range byTx { if !info.routeType.Valid { continue } rt := int(info.routeType.Int64) isFlood := rt == routeTypeFlood || rt == RouteTransportFlood if !isFlood { continue } scoped := rt == RouteTransportFlood isAdvert := info.payloadType.Valid && int(info.payloadType.Int64) == payloadTypeAdvert isUnscopedFlood := rt == routeTypeFlood && !isAdvert for idx, pk := range info.bestPath { if scoped { scopedBuckets[idx]++ if scopedByTimeBucket[info.bucket] == nil { scopedByTimeBucket[info.bucket] = map[int]int{} } scopedByTimeBucket[info.bucket][idx]++ } else { unscopedBuckets[idx]++ if unscopedByTimeBucket[info.bucket] == nil { unscopedByTimeBucket[info.bucket] = map[int]int{} } unscopedByTimeBucket[info.bucket][idx]++ } if isUnscopedFlood && pk != nil && *pk != "" { repeaterHops[*pk] = append(repeaterHops[*pk], idx) } } } toSortedBuckets := func(m map[int]int) []HopDepthBucket { out := make([]HopDepthBucket, 0, len(m)) for hops, count := range m { out = append(out, HopDepthBucket{Hops: hops, Count: count}) } sort.Slice(out, func(i, j int) bool { return out[i].Hops < out[j].Hops }) return out } // medianHopFromCounts mirrors the frontend's hopDepthBucketStats: the // smallest hop value whose cumulative count reaches half the total -- // a histogram-based median consistent with how ScopedHopDepth/ // UnscopedHopDepth's medians are read client-side, rather than // introducing a second, subtly different median definition here. medianHopFromCounts := func(counts map[int]int) *int { total := 0 for _, c := range counts { total += c } if total == 0 { return nil } hops := make([]int, 0, len(counts)) for h := range counts { hops = append(hops, h) } sort.Ints(hops) cum := 0 for _, h := range hops { cum += counts[h] if cum*2 >= total { v := h return &v } } return nil } bucketSet := map[string]bool{} for b := range scopedByTimeBucket { bucketSet[b] = true } for b := range unscopedByTimeBucket { bucketSet[b] = true } timeBuckets := make([]string, 0, len(bucketSet)) for b := range bucketSet { timeBuckets = append(timeBuckets, b) } sort.Strings(timeBuckets) timeSeries := make([]HopDepthTimePoint, 0, len(timeBuckets)) for _, b := range timeBuckets { timeSeries = append(timeSeries, HopDepthTimePoint{ T: b, ScopedMedianHop: medianHopFromCounts(scopedByTimeBucket[b]), UnscopedMedianHop: medianHopFromCounts(unscopedByTimeBucket[b]), }) } // Only repeater/room nodes are meaningful here (matches the Foreign // Traffic tab's existing "Repeaters Relaying Unscoped Traffic" role // filter) -- look up name/role for the pubkeys that actually relayed // unscoped flood traffic in this window, rather than every known node. pubkeys := make([]string, 0, len(repeaterHops)) for pk := range repeaterHops { pubkeys = append(pubkeys, pk) } names, roles := db.namesAndRolesForPubkeys(pubkeys) unscopedByRepeater := make([]RepeaterUnscopedHopDepth, 0, len(repeaterHops)) for pk, hops := range repeaterHops { if roles[pk] != "repeater" && roles[pk] != "room" { continue } sort.Ints(hops) n := len(hops) median := float64(hops[n/2]) if n%2 == 0 { median = float64(hops[n/2-1]+hops[n/2]) / 2 } name := names[pk] if name == "" { name = pk } unscopedByRepeater = append(unscopedByRepeater, RepeaterUnscopedHopDepth{ PublicKey: pk, Name: name, Count: n, MinHops: hops[0], MedianHops: median, MaxHops: hops[n-1], }) } sort.Slice(unscopedByRepeater, func(i, j int) bool { return unscopedByRepeater[i].Count > unscopedByRepeater[j].Count }) return &HopDepthAnalyticsResponse{ Window: window, ScopedHopDepth: toSortedBuckets(scopedBuckets), UnscopedHopDepth: toSortedBuckets(unscopedBuckets), UnscopedByRepeater: unscopedByRepeater, TimeSeries: timeSeries, }, nil } // namesAndRolesForPubkeys bulk-looks-up name/role for a set of pubkeys, // chunked to stay under SQLite's parameter limit. Missing pubkeys are // simply absent from the returned maps. func (db *DB) namesAndRolesForPubkeys(pubkeys []string) (names, roles map[string]string) { names = make(map[string]string, len(pubkeys)) roles = make(map[string]string, len(pubkeys)) if len(pubkeys) == 0 { return names, roles } const chunkSize = 499 for start := 0; start < len(pubkeys); start += chunkSize { end := start + chunkSize if end > len(pubkeys) { end = len(pubkeys) } chunk := pubkeys[start:end] placeholders := make([]byte, 0, len(chunk)*2) args := make([]interface{}, len(chunk)) for i, pk := range chunk { if i > 0 { placeholders = append(placeholders, ',') } placeholders = append(placeholders, '?') args[i] = pk } query := "SELECT public_key, name, role FROM nodes WHERE public_key IN (" + string(placeholders) + ")" rows, err := db.conn.Query(query, args...) if err != nil { continue } for rows.Next() { var pk string var name, role sql.NullString if err := rows.Scan(&pk, &name, &role); err != nil { continue } names[pk] = name.String roles[pk] = role.String } rows.Close() } return names, roles } // GetChannelMessageScopeStats narrows the scoped/unscoped/unknown question // to channel chat specifically (payload_type=5), for the given window. // Unlike GetScopeStats' TransportTotal (route_type 0/3 only), TotalMessages // here covers ALL route types — most channel chat is plain FLOOD, so // restricting to transport routes would answer a different question than // "how many of our channel messages are scoped". func (db *DB) GetChannelMessageScopeStats(window string) (*ChannelScopeStats, error) { if !db.hasScopeName { return nil, fmt.Errorf("scope_name column not present — run ingestor to apply migrations") } var since string switch window { case "1h": since = time.Now().Add(-1 * time.Hour).UTC().Format(time.RFC3339) case "7d": since = time.Now().Add(-7 * 24 * time.Hour).UTC().Format(time.RFC3339) default: since = time.Now().Add(-24 * time.Hour).UTC().Format(time.RFC3339) } stats := &ChannelScopeStats{} row := db.conn.QueryRow(` SELECT COUNT(*) AS transport_total, COUNT(scope_name) AS scoped, COALESCE(SUM(CASE WHEN scope_name IS NULL THEN 1 ELSE 0 END), 0) AS unscoped, COALESCE(SUM(CASE WHEN scope_name = '' THEN 1 ELSE 0 END), 0) AS unknown_scope FROM transmissions WHERE payload_type = 5 AND `+routeTypeTransportSQL+` AND first_seen >= ? `, since) var transportTotal int if err := row.Scan(&transportTotal, &stats.Scoped, &stats.Unscoped, &stats.UnknownScope); err != nil { return nil, fmt.Errorf("channel scope summary query: %w", err) } // Non-transport channel messages (plain FLOOD/DIRECT) never carry a // scope per MeshCore protocol — fold into Unscoped, mirroring #1838. var nonTransportCount int if err := db.conn.QueryRow(` SELECT COUNT(*) FROM transmissions WHERE payload_type = 5 AND `+routeTypeNonTransportSQL+` AND first_seen >= ? `, since).Scan(&nonTransportCount); err != nil { return nil, fmt.Errorf("channel scope non-transport count query: %w", err) } stats.Unscoped += nonTransportCount stats.TotalMessages = transportTotal + nonTransportCount return stats, nil } // GetChannelScopeAdoption breaks the channel-messages-only scoped/unscoped // question (see GetChannelMessageScopeStats) down PER CHANNEL — which // specific channels (#test, #wardriving, ...) actually use region scoping // vs which never do. Ordered by message volume; uncapped. // // Cardinality isn't a hard bound like the 1-byte hash space alone would // suggest: encrypted channels ARE bounded to 256 'enc_%02x' buckets, but // plain-text CHAN channels use the free-form channel name string // (ingestor/db.go's `json_extract(decoded_json, '$.channel')`), so in // principle a mesh with many ad-hoc named channels could grow this // unbounded. In practice payload_type=5 rows within a single window stay // small enough that this hasn't needed a cap. func (db *DB) GetChannelScopeAdoption(window string) ([]ChannelScopeAdoption, error) { if !db.hasScopeName { return nil, fmt.Errorf("scope_name column not present — run ingestor to apply migrations") } var since string switch window { case "1h": since = time.Now().Add(-1 * time.Hour).UTC().Format(time.RFC3339) case "7d": since = time.Now().Add(-7 * 24 * time.Hour).UTC().Format(time.RFC3339) default: since = time.Now().Add(-24 * time.Hour).UTC().Format(time.RFC3339) } rows, err := db.conn.Query(` SELECT COALESCE(NULLIF(channel_hash, ''), '(unknown channel)') AS channel, COUNT(*) AS total, COALESCE(SUM(CASE WHEN `+routeTypeTransportSQL+` AND scope_name IS NOT NULL AND scope_name != '' THEN 1 ELSE 0 END), 0) AS scoped, COALESCE(SUM(CASE WHEN `+routeTypeTransportSQL+` AND scope_name = '' THEN 1 ELSE 0 END), 0) AS unknown_scope FROM transmissions WHERE payload_type = 5 AND first_seen >= ? GROUP BY channel ORDER BY total DESC `, since) if err != nil { return nil, fmt.Errorf("channel scope adoption query: %w", err) } defer rows.Close() result := make([]ChannelScopeAdoption, 0) for rows.Next() { var ca ChannelScopeAdoption if err := rows.Scan(&ca.Channel, &ca.TotalMessages, &ca.Scoped, &ca.UnknownScope); err != nil { continue } ca.Unscoped = ca.TotalMessages - ca.Scoped - ca.UnknownScope result = append(result, ca) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("channel scope adoption iteration: %w", err) } regionsByChannel, err := db.getChannelScopeRegions(since) if err != nil { // Non-fatal: the adoption counts above are still useful without // the per-channel region breakdown. log.Printf("WARN getChannelScopeRegions: %v", err) } else { for i := range result { result[i].Regions = regionsByChannel[result[i].Channel] } } return result, nil } // getChannelScopeRegions answers "which regions" for GetChannelScopeAdoption's // "how many scoped messages" — for each channel, which distinct scope_name // values have actually been seen on its scoped messages, most-used first. func (db *DB) getChannelScopeRegions(since string) (map[string][]string, error) { rows, err := db.conn.Query(` SELECT COALESCE(NULLIF(channel_hash, ''), '(unknown channel)') AS channel, scope_name, COUNT(*) AS cnt FROM transmissions WHERE payload_type = 5 AND first_seen >= ? AND `+routeTypeTransportSQL+` AND scope_name IS NOT NULL AND scope_name != '' GROUP BY channel, scope_name ORDER BY channel, cnt DESC `, since) if err != nil { return nil, fmt.Errorf("channel scope regions query: %w", err) } defer rows.Close() result := make(map[string][]string) for rows.Next() { var channel, region string var cnt int if err := rows.Scan(&channel, ®ion, &cnt); err != nil { continue } result[channel] = append(result[channel], region) } return result, rows.Err() } // GetWardrivingStats aggregates activity on the given channel (normally // "#wardriving") over the requested window: message volume over time, who's // actively sending, which repeater first relayed each message (raw hash // prefixes — the caller resolves names via /api/resolve-hops), which // observer stations actually heard the traffic, signal quality (SNR/RSSI) // over the same time buckets as the activity series, each sender's messages // grouped into distinct sessions/runs (see buildWardrivingSessions), and any // senders who explicitly shared their own position (see // detectWardrivingGPSShares). See WardrivingObserverCoverage // doc for why observer coverage — not sender GPS — is the reliable half of // a "where did this reach" picture: MeshMapper's #wardriving messages carry // an anonymous per-session token by default, not the sender's live // coordinates (those go to MeshMapper's own server via a separate API call // we have no visibility into). func (db *DB) GetWardrivingStats(window, channel string) (*WardrivingStatsResponse, error) { var since string var bucketExpr string switch window { case "1h": since = time.Now().Add(-1 * time.Hour).UTC().Format(time.RFC3339) bucketExpr = `strftime('%Y-%m-%dT%H:', first_seen) || printf('%02d', (CAST(strftime('%M', first_seen) AS INTEGER) / 5) * 5) || ':00Z'` case "7d": since = time.Now().Add(-7 * 24 * time.Hour).UTC().Format(time.RFC3339) bucketExpr = `strftime('%Y-%m-%dT', first_seen) || printf('%02d', (CAST(strftime('%H', first_seen) AS INTEGER) / 6) * 6) || ':00:00Z'` default: window = "24h" since = time.Now().Add(-24 * time.Hour).UTC().Format(time.RFC3339) bucketExpr = `strftime('%Y-%m-%dT%H:00:00Z', first_seen)` } resp := &WardrivingStatsResponse{Window: window, Channel: channel} if err := db.conn.QueryRow( `SELECT COUNT(*) FROM transmissions WHERE channel_hash = ? AND payload_type = 5 AND first_seen >= ?`, channel, since, ).Scan(&resp.TotalMessages); err != nil { return nil, fmt.Errorf("wardriving total query: %w", err) } tsQuery := fmt.Sprintf(` SELECT %s AS bucket, COUNT(*) AS cnt FROM transmissions WHERE channel_hash = ? AND payload_type = 5 AND first_seen >= ? GROUP BY bucket ORDER BY bucket `, bucketExpr) tsRows, err := db.conn.Query(tsQuery, channel, since) if err != nil { return nil, fmt.Errorf("wardriving timeseries query: %w", err) } resp.TimeSeries = make([]WardrivingTimePoint, 0) for tsRows.Next() { var pt WardrivingTimePoint if tsRows.Scan(&pt.T, &pt.Count) == nil { resp.TimeSeries = append(resp.TimeSeries, pt) } } tsRows.Close() if err := tsRows.Err(); err != nil { return nil, fmt.Errorf("wardriving timeseries iteration: %w", err) } senderRows, err := db.conn.Query(` SELECT json_extract(decoded_json, '$.sender') AS sender, COUNT(*) AS cnt FROM transmissions WHERE channel_hash = ? AND payload_type = 5 AND first_seen >= ? AND json_extract(decoded_json, '$.sender') IS NOT NULL AND json_extract(decoded_json, '$.sender') != '' GROUP BY sender ORDER BY cnt DESC `, channel, since) if err != nil { return nil, fmt.Errorf("wardriving senders query: %w", err) } resp.TopSenders = make([]WardrivingSenderCount, 0) for senderRows.Next() { var sc WardrivingSenderCount if senderRows.Scan(&sc.Sender, &sc.Count) == nil { resp.TopSenders = append(resp.TopSenders, sc) } } senderRows.Close() if err := senderRows.Err(); err != nil { return nil, fmt.Errorf("wardriving senders iteration: %w", err) } entryRows, err := db.conn.Query(` SELECT json_extract(o.path_json, '$[0]') AS prefix, COUNT(*) AS observation_count, COUNT(DISTINCT o.transmission_id) AS message_count FROM observations o JOIN transmissions t ON t.id = o.transmission_id WHERE t.channel_hash = ? AND t.payload_type = 5 AND t.first_seen >= ? AND o.path_json IS NOT NULL AND json_array_length(o.path_json) > 0 GROUP BY prefix ORDER BY observation_count DESC `, channel, since) if err != nil { return nil, fmt.Errorf("wardriving entry points query: %w", err) } resp.EntryPoints = make([]WardrivingEntryPrefix, 0) for entryRows.Next() { var ep WardrivingEntryPrefix if entryRows.Scan(&ep.Prefix, &ep.ObservationCount, &ep.MessageCount) == nil { resp.EntryPoints = append(resp.EntryPoints, ep) } } entryRows.Close() if err := entryRows.Err(); err != nil { return nil, fmt.Errorf("wardriving entry points iteration: %w", err) } var obsQuery string if db.isV3 { obsQuery = ` SELECT obs.rowid AS observer_id, obs.name, COALESCE(obs.iata, ''), COUNT(*) AS observation_count, COUNT(DISTINCT o.transmission_id) AS message_count FROM observations o JOIN transmissions t ON t.id = o.transmission_id JOIN observers obs ON obs.rowid = o.observer_idx WHERE t.channel_hash = ? AND t.payload_type = 5 AND t.first_seen >= ? GROUP BY observer_id ORDER BY observation_count DESC` } else { obsQuery = ` SELECT obs.id AS observer_id, obs.name, COALESCE(obs.iata, ''), COUNT(*) AS observation_count, COUNT(DISTINCT o.transmission_id) AS message_count FROM observations o JOIN transmissions t ON t.id = o.transmission_id JOIN observers obs ON obs.id = o.observer_id WHERE t.channel_hash = ? AND t.payload_type = 5 AND t.first_seen >= ? GROUP BY observer_id ORDER BY observation_count DESC` } obsRows, err := db.conn.Query(obsQuery, channel, since) if err != nil { return nil, fmt.Errorf("wardriving observers query: %w", err) } resp.Observers = make([]WardrivingObserverCoverage, 0) for obsRows.Next() { var oc WardrivingObserverCoverage var name sql.NullString if err := obsRows.Scan(&oc.ObserverID, &name, &oc.IATA, &oc.ObservationCount, &oc.MessageCount); err != nil { continue } oc.ObserverName = name.String if oc.ObserverName == "" { oc.ObserverName = oc.ObserverID } if coord, ok := iataCoords[strings.ToUpper(strings.TrimSpace(oc.IATA))]; ok { lat, lon := coord.Lat, coord.Lon oc.Lat, oc.Lon = &lat, &lon } resp.Observers = append(resp.Observers, oc) } obsRows.Close() if err := obsRows.Err(); err != nil { return nil, fmt.Errorf("wardriving observers iteration: %w", err) } // Signal quality over time — same bucketing as the activity time series, // but averaged across every observation (any observer) in that bucket. sigBucketExpr := strings.ReplaceAll(bucketExpr, "first_seen", "t.first_seen") sigQuery := fmt.Sprintf(` SELECT %s AS bucket, AVG(o.snr) AS avg_snr, AVG(o.rssi) AS avg_rssi, COUNT(*) AS cnt FROM observations o JOIN transmissions t ON t.id = o.transmission_id WHERE t.channel_hash = ? AND t.payload_type = 5 AND t.first_seen >= ? GROUP BY bucket ORDER BY bucket `, sigBucketExpr) sigRows, err := db.conn.Query(sigQuery, channel, since) if err != nil { return nil, fmt.Errorf("wardriving signal timeseries query: %w", err) } resp.SignalTimeSeries = make([]WardrivingSignalPoint, 0) for sigRows.Next() { var sp WardrivingSignalPoint if sigRows.Scan(&sp.T, &sp.AvgSNR, &sp.AvgRSSI, &sp.ObservationCount) == nil { resp.SignalTimeSeries = append(resp.SignalTimeSeries, sp) } } sigRows.Close() if err := sigRows.Err(); err != nil { return nil, fmt.Errorf("wardriving signal timeseries iteration: %w", err) } var avgSNR, avgRSSI sql.NullFloat64 if err := db.conn.QueryRow(` SELECT AVG(o.snr), AVG(o.rssi) FROM observations o JOIN transmissions t ON t.id = o.transmission_id WHERE t.channel_hash = ? AND t.payload_type = 5 AND t.first_seen >= ? `, channel, since).Scan(&avgSNR, &avgRSSI); err != nil { return nil, fmt.Errorf("wardriving avg signal query: %w", err) } if avgSNR.Valid { v := avgSNR.Float64 resp.AvgSNR = &v } if avgRSSI.Valid { v := avgRSSI.Float64 resp.AvgRSSI = &v } sessions, err := db.buildWardrivingSessions(channel, since) if err != nil { return nil, err } resp.Sessions = sessions gpsShares, err := db.detectWardrivingGPSShares(channel, since) if err != nil { return nil, err } resp.GPSShares = gpsShares return resp, nil } // wardrivingSessionGapMinutes is the max gap between two consecutive // messages from the same sender before buildWardrivingSessions treats them // as separate wardriving runs rather than one continuous session. const wardrivingSessionGapMinutes = 15.0 // buildWardrivingSessions groups each sender's messages (ordered by time) // into runs, splitting on any gap over wardrivingSessionGapMinutes. For // each session it also computes how many distinct entry-point repeaters // and observers were involved, by unioning the per-transmission // observation data across every message in that session. func (db *DB) buildWardrivingSessions(channel, since string) ([]WardrivingSession, error) { msgRows, err := db.conn.Query(` SELECT id, json_extract(decoded_json, '$.sender') AS sender, first_seen FROM transmissions WHERE channel_hash = ? AND payload_type = 5 AND first_seen >= ? AND json_extract(decoded_json, '$.sender') IS NOT NULL AND json_extract(decoded_json, '$.sender') != '' ORDER BY sender, first_seen ASC `, channel, since) if err != nil { return nil, fmt.Errorf("wardriving sessions message query: %w", err) } type txInfo struct { id int64 sender string ts time.Time } var txs []txInfo for msgRows.Next() { var id int64 var sender, tsStr string if err := msgRows.Scan(&id, &sender, &tsStr); err != nil { continue } ts, err := time.Parse(time.RFC3339, tsStr) if err != nil { continue } txs = append(txs, txInfo{id: id, sender: sender, ts: ts}) } msgRows.Close() if err := msgRows.Err(); err != nil { return nil, fmt.Errorf("wardriving sessions message iteration: %w", err) } // Per-transmission entry-point prefixes and observer IDs, so each // session can report how many distinct ones it touched. var perTxQuery string if db.isV3 { perTxQuery = ` SELECT o.transmission_id, json_extract(o.path_json, '$[0]'), obs.rowid FROM observations o JOIN transmissions t ON t.id = o.transmission_id JOIN observers obs ON obs.rowid = o.observer_idx WHERE t.channel_hash = ? AND t.payload_type = 5 AND t.first_seen >= ?` } else { perTxQuery = ` SELECT o.transmission_id, json_extract(o.path_json, '$[0]'), obs.id FROM observations o JOIN transmissions t ON t.id = o.transmission_id JOIN observers obs ON obs.id = o.observer_id WHERE t.channel_hash = ? AND t.payload_type = 5 AND t.first_seen >= ?` } perTxRows, err := db.conn.Query(perTxQuery, channel, since) if err != nil { return nil, fmt.Errorf("wardriving sessions per-tx query: %w", err) } txPrefixes := make(map[int64]map[string]bool) txObservers := make(map[int64]map[string]bool) for perTxRows.Next() { var txID int64 var prefix sql.NullString var observerID string if err := perTxRows.Scan(&txID, &prefix, &observerID); err != nil { continue } if prefix.Valid && prefix.String != "" { if txPrefixes[txID] == nil { txPrefixes[txID] = make(map[string]bool) } txPrefixes[txID][prefix.String] = true } if txObservers[txID] == nil { txObservers[txID] = make(map[string]bool) } txObservers[txID][observerID] = true } perTxRows.Close() if err := perTxRows.Err(); err != nil { return nil, fmt.Errorf("wardriving sessions per-tx iteration: %w", err) } sessions := make([]WardrivingSession, 0) var cur *WardrivingSession var curPrefixes, curObservers map[string]bool var lastTS time.Time flush := func() { if cur == nil { return } cur.EntryPointCount = len(curPrefixes) cur.ObserverCount = len(curObservers) for p := range curPrefixes { cur.EntryPointPrefixes = append(cur.EntryPointPrefixes, p) } sort.Strings(cur.EntryPointPrefixes) start, errS := time.Parse(time.RFC3339, cur.StartTime) end, errE := time.Parse(time.RFC3339, cur.EndTime) if errS == nil && errE == nil { cur.DurationMinutes = end.Sub(start).Minutes() } sessions = append(sessions, *cur) } for _, tx := range txs { newSession := cur == nil || cur.Sender != tx.sender || tx.ts.Sub(lastTS).Minutes() > wardrivingSessionGapMinutes if newSession { flush() cur = &WardrivingSession{Sender: tx.sender, StartTime: tx.ts.UTC().Format(time.RFC3339)} curPrefixes = make(map[string]bool) curObservers = make(map[string]bool) } cur.EndTime = tx.ts.UTC().Format(time.RFC3339) cur.MessageCount++ cur.TransmissionIDs = append(cur.TransmissionIDs, tx.id) for p := range txPrefixes[tx.id] { curPrefixes[p] = true } for o := range txObservers[tx.id] { curObservers[o] = true } lastTS = tx.ts } flush() sort.Slice(sessions, func(i, j int) bool { return sessions[i].StartTime > sessions[j].StartTime }) return sessions, nil } // wardrivingGPSSharePattern matches the plaintext coordinate suffix some // wardriving clients append after the standard token — e.g. // "MM:c3e_zJ1rUA:55.59743,13.00128" — confirmed empirically against live // traffic. This is an explicit choice by that sender's client to share // their position in-band; CoreScope reads the plaintext numbers, it does // not decode or infer them from the token itself. var wardrivingGPSSharePattern = regexp.MustCompile(`^[A-Za-z0-9_-]+:(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)$`) // detectWardrivingGPSShares scans every #wardriving message — stored as // ": MM:" in decoded_json.text, per decodeGrpTxt's // ": " convention — for that coordinate-suffix shape, and // returns one entry per sender with the most recent position they shared. // Messages without a coordinate suffix (the standard anonymous token, or // anything else) are ignored entirely. func (db *DB) detectWardrivingGPSShares(channel, since string) ([]WardrivingGPSShare, error) { rows, err := db.conn.Query(` SELECT json_extract(decoded_json, '$.sender'), json_extract(decoded_json, '$.text'), first_seen FROM transmissions WHERE channel_hash = ? AND payload_type = 5 AND first_seen >= ? ORDER BY first_seen ASC `, channel, since) if err != nil { return nil, fmt.Errorf("wardriving gps-share query: %w", err) } defer rows.Close() type shareAgg struct { lat, lon float64 count int lastSeen string } agg := make(map[string]*shareAgg) for rows.Next() { var sender, text sql.NullString var ts string if err := rows.Scan(&sender, &text, &ts); err != nil { continue } if !sender.Valid || !text.Valid || sender.String == "" { continue } // decodeGrpTxt (cmd/ingestor/decoder.go) builds Text as // ": ", so the wardriving payload prefix is // ": MM:" — not a bare "MM:" at the start of text. prefix := sender.String + ": MM:" if !strings.HasPrefix(text.String, prefix) { continue } rest := strings.TrimPrefix(text.String, prefix) m := wardrivingGPSSharePattern.FindStringSubmatch(rest) if m == nil { continue } lat, errLat := strconv.ParseFloat(m[1], 64) lon, errLon := strconv.ParseFloat(m[2], 64) if errLat != nil || errLon != nil || lat < -90 || lat > 90 || lon < -180 || lon > 180 { continue } a := agg[sender.String] if a == nil { a = &shareAgg{} agg[sender.String] = a } a.count++ // Rows are ordered first_seen ASC, so the last write below wins // as the most recent shared position. a.lat, a.lon, a.lastSeen = lat, lon, ts } if err := rows.Err(); err != nil { return nil, fmt.Errorf("wardriving gps-share iteration: %w", err) } shares := make([]WardrivingGPSShare, 0, len(agg)) for senderName, a := range agg { shares = append(shares, WardrivingGPSShare{ Sender: senderName, Lat: a.lat, Lon: a.lon, MessageCount: a.count, LastSeen: a.lastSeen, }) } sort.Slice(shares, func(i, j int) bool { return shares[i].LastSeen > shares[j].LastSeen }) return shares, nil } // wardrivingSenderMessagesLimit caps how many individual messages // GetWardrivingSenderMessages returns per request — this is a drill-down // view (one sender, optionally one session), not a bulk export. const wardrivingSenderMessagesLimit = 200 // GetWardrivingSenderMessages returns one sender's individual #wardriving // messages in [since, until], most-recent-first: each message's entry-point // path (path[0] first, same convention as WardrivingEntryPrefix), the // observers that heard it with their own SNR/RSSI, and Lat/Lon when that // specific message carried an explicit shared position (see // detectWardrivingGPSShares). This is the per-message detail behind the // aggregate Sessions/Entry Points/Coverage views — used when a user drills // into one sender or one session. func (db *DB) GetWardrivingSenderMessages(sender, channel, since, until string) (*WardrivingSenderMessagesResponse, error) { resp := &WardrivingSenderMessagesResponse{ Sender: sender, Channel: channel, Since: since, Until: until, Messages: make([]WardrivingMessage, 0), } txRows, err := db.conn.Query(` SELECT id, first_seen, json_extract(decoded_json, '$.text') FROM transmissions WHERE channel_hash = ? AND payload_type = 5 AND first_seen >= ? AND first_seen <= ? AND json_extract(decoded_json, '$.sender') = ? ORDER BY first_seen DESC LIMIT ? `, channel, since, until, sender, wardrivingSenderMessagesLimit) if err != nil { return nil, fmt.Errorf("wardriving sender messages query: %w", err) } type txRow struct { id int64 ts string text sql.NullString } var txs []txRow for txRows.Next() { var t txRow if err := txRows.Scan(&t.id, &t.ts, &t.text); err != nil { continue } txs = append(txs, t) } txRows.Close() if err := txRows.Err(); err != nil { return nil, fmt.Errorf("wardriving sender messages iteration: %w", err) } if len(txs) == 0 { return resp, nil } placeholders := make([]string, len(txs)) args := make([]interface{}, len(txs)) txIndex := make(map[int64]int, len(txs)) for i, t := range txs { placeholders[i] = "?" args[i] = t.id txIndex[t.id] = i } var obsQuery string if db.isV3 { obsQuery = fmt.Sprintf(` SELECT o.transmission_id, obs.name, o.snr, o.rssi, o.path_json FROM observations o JOIN observers obs ON obs.rowid = o.observer_idx WHERE o.transmission_id IN (%s)`, strings.Join(placeholders, ",")) } else { obsQuery = fmt.Sprintf(` SELECT o.transmission_id, obs.name, o.snr, o.rssi, o.path_json FROM observations o JOIN observers obs ON obs.id = o.observer_id WHERE o.transmission_id IN (%s)`, strings.Join(placeholders, ",")) } obsRows, err := db.conn.Query(obsQuery, args...) if err != nil { return nil, fmt.Errorf("wardriving sender messages observations query: %w", err) } type msgAgg struct { observations []WardrivingMessageObservation pathPrefixes []string } aggs := make([]*msgAgg, len(txs)) for i := range aggs { aggs[i] = &msgAgg{observations: make([]WardrivingMessageObservation, 0), pathPrefixes: make([]string, 0)} } for obsRows.Next() { var txID int64 var observerName sql.NullString var snr, rssi sql.NullFloat64 var pathJSON sql.NullString if err := obsRows.Scan(&txID, &observerName, &snr, &rssi, &pathJSON); err != nil { continue } idx, ok := txIndex[txID] if !ok { continue } agg := aggs[idx] agg.observations = append(agg.observations, WardrivingMessageObservation{ ObserverName: observerName.String, SNR: snr.Float64, RSSI: rssi.Float64, }) if pathJSON.Valid && pathJSON.String != "" { var path []string if json.Unmarshal([]byte(pathJSON.String), &path) == nil && len(path) > len(agg.pathPrefixes) { agg.pathPrefixes = path } } } obsRows.Close() if err := obsRows.Err(); err != nil { return nil, fmt.Errorf("wardriving sender messages observations iteration: %w", err) } for i, t := range txs { msg := WardrivingMessage{ TransmissionID: t.id, Timestamp: t.ts, PathPrefixes: aggs[i].pathPrefixes, Observations: aggs[i].observations, } if t.text.Valid { prefix := sender + ": MM:" if strings.HasPrefix(t.text.String, prefix) { rest := strings.TrimPrefix(t.text.String, prefix) if m := wardrivingGPSSharePattern.FindStringSubmatch(rest); m != nil { lat, errLat := strconv.ParseFloat(m[1], 64) lon, errLon := strconv.ParseFloat(m[2], 64) if errLat == nil && errLon == nil && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180 { msg.Lat = &lat msg.Lon = &lon } } } } resp.Messages = append(resp.Messages, msg) } return resp, nil } // GetMatchedRegionNames returns the set of scope_name values that have ever // matched at least one transmission still in retention (NULL and empty-string // "unknown" rows are excluded). Used to diff against the operator's // configured hashRegions list and surface which configured regions have // never actually matched anything — region-utilization analytics. func (db *DB) GetMatchedRegionNames() (map[string]bool, error) { matched := make(map[string]bool) if !db.hasScopeName { return matched, nil } rows, err := db.conn.Query(`SELECT DISTINCT scope_name FROM transmissions WHERE scope_name IS NOT NULL AND scope_name != ''`) if err != nil { return nil, fmt.Errorf("matched region names query: %w", err) } defer rows.Close() for rows.Next() { var name string if rows.Scan(&name) == nil { matched[name] = true } } return matched, rows.Err() } // NodeForGeoPrune holds the minimal fields needed for geo-filter pruning. type NodeForGeoPrune struct { PubKey string Name string Lat *float64 Lon *float64 } // GetNodesForGeoPrune returns all nodes with their coordinates for geo-filter evaluation. // Read-only — safe on the server's mode=ro handle. func (db *DB) GetNodesForGeoPrune() ([]NodeForGeoPrune, error) { rows, err := db.conn.Query("SELECT public_key, name, lat, lon FROM nodes ORDER BY name") if err != nil { return nil, err } defer rows.Close() var nodes []NodeForGeoPrune for rows.Next() { var pk string var name sql.NullString var lat, lon sql.NullFloat64 if err := rows.Scan(&pk, &name, &lat, &lon); err != nil { continue } n := NodeForGeoPrune{PubKey: pk, Name: name.String} if lat.Valid { v := lat.Float64 n.Lat = &v } if lon.Valid { v := lon.Float64 n.Lon = &v } nodes = append(nodes, n) } return nodes, rows.Err() } // DeleteNodesByPubkeys was removed in PR #738 follow-up: server is read-only // (opened with mode=ro after #1283/#1289), so DELETE statements would fail at // runtime. Geo-prune now flows server → marker file → ingestor; see // internal/prunequeue and cmd/ingestor/prune_geofilter.go.