From cfb7b7297d2a2dff211154be9865e6216a3e9172 Mon Sep 17 00:00:00 2001 From: you Date: Fri, 24 Apr 2026 15:26:47 +0000 Subject: [PATCH] feat: add last_packet_at column to observers Add a new 'last_packet_at' column to the observers table that is only bumped when an actual packet observation lands (InsertTransmission path), while 'last_seen' continues to be bumped on both status updates and packets. This allows the UI to distinguish between an observer that is alive (sending status pings) vs one that is actively forwarding packets. Schema migration backfills last_packet_at = last_seen for observers with packet_count > 0. Server API now returns last_packet_at in the Observer JSON response. --- cmd/ingestor/db.go | 24 ++++++++++++++++++++---- cmd/server/db.go | 9 +++++---- 2 files changed, 25 insertions(+), 8 deletions(-) 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 }