diff --git a/cmd/ingestor/db.go b/cmd/ingestor/db.go index 93d3dc53..d3a3a29a 100644 --- a/cmd/ingestor/db.go +++ b/cmd/ingestor/db.go @@ -116,7 +116,8 @@ func applySchema(db *sql.DB) error { battery_mv INTEGER, uptime_secs INTEGER, noise_floor REAL, - inactive INTEGER DEFAULT 0 + inactive INTEGER DEFAULT 0, + last_packet_at TEXT DEFAULT NULL ); CREATE INDEX IF NOT EXISTS idx_nodes_last_seen ON nodes(last_seen); @@ -421,6 +422,21 @@ func applySchema(db *sql.DB) error { log.Println("[migration] observations.raw_hex column added") } + // Migration: add last_packet_at column to observers (#last-packet-at) + row = db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'observers_last_packet_at_v1'") + if row.Scan(&migDone) != nil { + log.Println("[migration] Adding last_packet_at column to observers...") + db.Exec(`ALTER TABLE observers ADD COLUMN last_packet_at TEXT DEFAULT NULL`) + // Backfill: set last_packet_at = last_seen only for observers that have received packets + res, err := db.Exec(`UPDATE observers SET last_packet_at = last_seen WHERE packet_count > 0`) + if err == nil { + n, _ := res.RowsAffected() + log.Printf("[migration] Backfilled last_packet_at for %d observers with packets", n) + } + db.Exec(`INSERT INTO _migrations (name) VALUES ('observers_last_packet_at_v1')`) + log.Println("[migration] observers.last_packet_at column added") + } + return nil } @@ -504,7 +520,7 @@ func (s *Store) prepareStatements() error { return err } - s.stmtUpdateObserverLastSeen, err = s.db.Prepare("UPDATE observers SET last_seen = ? WHERE rowid = ?") + s.stmtUpdateObserverLastSeen, err = s.db.Prepare("UPDATE observers SET last_seen = ?, last_packet_at = ? WHERE rowid = ?") if err != nil { return err } @@ -583,9 +599,9 @@ func (s *Store) InsertTransmission(data *PacketData) (bool, error) { err := s.stmtGetObserverRowid.QueryRow(data.ObserverID).Scan(&rowid) if err == nil { observerIdx = &rowid - // Update observer last_seen on every packet to prevent + // Update observer last_seen and last_packet_at on every packet to prevent // low-traffic observers from appearing offline (#463) - _, _ = s.stmtUpdateObserverLastSeen.Exec(now, rowid) + _, _ = s.stmtUpdateObserverLastSeen.Exec(now, now, rowid) } } diff --git a/cmd/server/db.go b/cmd/server/db.go index 46e9590c..d970741c 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -170,6 +170,7 @@ type Observer struct { BatteryMv *int `json:"battery_mv"` UptimeSecs *int64 `json:"uptime_secs"` NoiseFloor *float64 `json:"noise_floor"` + LastPacketAt *string `json:"last_packet_at"` } // Transmission represents a row from the transmissions table. @@ -972,7 +973,7 @@ func (db *DB) getObservationsForTransmissions(txIDs []int) map[int][]map[string] // GetObservers returns active observers (not soft-deleted) sorted by last_seen DESC. func (db *DB) GetObservers() ([]Observer, error) { - 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 FROM observers WHERE inactive IS NULL OR inactive = 0 ORDER BY last_seen DESC") + 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 FROM observers WHERE inactive IS NULL OR inactive = 0 ORDER BY last_seen DESC") if err != nil { return nil, err } @@ -983,7 +984,7 @@ func (db *DB) GetObservers() ([]Observer, error) { var o Observer var batteryMv, uptimeSecs sql.NullInt64 var noiseFloor sql.NullFloat64 - 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); err != nil { + 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); err != nil { continue } if batteryMv.Valid { @@ -1006,8 +1007,8 @@ func (db *DB) GetObserverByID(id string) (*Observer, error) { var o Observer var batteryMv, uptimeSecs sql.NullInt64 var noiseFloor sql.NullFloat64 - 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 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) + 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 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) if err != nil { return nil, err }