From cfb7b7297d2a2dff211154be9865e6216a3e9172 Mon Sep 17 00:00:00 2001 From: you Date: Fri, 24 Apr 2026 15:26:47 +0000 Subject: [PATCH 1/7] 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 } From f56f1368be08b2b6a1721dbaf40e04075499aac6 Mon Sep 17 00:00:00 2001 From: you Date: Fri, 24 Apr 2026 15:26:53 +0000 Subject: [PATCH 2/7] feat(ui): show separate Last Status and Last Packet columns for observers - observers.js: rename 'Last Seen' column to 'Last Status', add 'Last Packet' column with a warning badge when no packets observed or packets lag behind status by >10min - observer-detail.js: add 'Last Status Update' and 'Last Packet Observation' stat cards with relative + absolute timestamps --- public/observer-detail.js | 8 ++++++++ public/observers.js | 14 +++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/public/observer-detail.js b/public/observer-detail.js index fb2fc4a1..640d74e3 100644 --- a/public/observer-detail.js +++ b/public/observer-detail.js @@ -150,6 +150,14 @@
First Seen
${obs.first_seen ? new Date(obs.first_seen).toLocaleDateString() : 'โ€”'}
+
+
Last Status Update
+
${obs.last_seen ? timeAgo(obs.last_seen) + '
' + new Date(obs.last_seen).toLocaleString() + '' : 'โ€”'}
+
+
+
Last Packet Observation
+
${obs.last_packet_at ? timeAgo(obs.last_packet_at) + '
' + new Date(obs.last_packet_at).toLocaleString() + '' : 'never'}
+
ID: ${obs.id} diff --git a/public/observers.js b/public/observers.js index 3619eaf7..3a46ecef 100644 --- a/public/observers.js +++ b/public/observers.js @@ -75,6 +75,17 @@ return { cls: 'health-red', label: 'Offline' }; } + function packetBadge(o) { + if (!o.last_packet_at) return '๐Ÿ“กโš  never'; + const pktAgo = Date.now() - new Date(o.last_packet_at).getTime(); + const statusAgo = o.last_seen ? Date.now() - new Date(o.last_seen).getTime() : Infinity; + const gap = pktAgo - statusAgo; + if (gap > 600000) { + return `๐Ÿ“กโš  ${timeAgo(o.last_packet_at)}`; + } + return timeAgo(o.last_packet_at); + } + function uptimeStr(firstSeen) { if (!firstSeen) return 'โ€”'; const ms = Date.now() - new Date(firstSeen).getTime(); @@ -123,7 +134,7 @@
- + ${filtered.map(o => { @@ -134,6 +145,7 @@ + From 7ac36178d6ae9f55d8eb01c37f806e43ee194d8d Mon Sep 17 00:00:00 2001 From: you Date: Fri, 24 Apr 2026 15:26:59 +0000 Subject: [PATCH 3/7] test: add last_packet_at tests for ingestor and server - Ingestor: verify last_packet_at is NULL after UpsertObserver (status path), set after InsertTransmission, and unchanged by subsequent UpsertObserver calls - Server: verify last_packet_at reads back through GetObservers and GetObserverByID --- cmd/ingestor/db_test.go | 53 +++++++++++++++++++++++++++++++++++++++++ cmd/server/db_test.go | 52 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/cmd/ingestor/db_test.go b/cmd/ingestor/db_test.go index d51903f9..12c2da6a 100644 --- a/cmd/ingestor/db_test.go +++ b/cmd/ingestor/db_test.go @@ -569,6 +569,59 @@ func TestInsertTransmissionUpdatesObserverLastSeen(t *testing.T) { } } +func TestLastPacketAtUpdatedOnPacketOnly(t *testing.T) { + s, err := OpenStore(tempDBPath(t)) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + // Insert observer via status path โ€” last_packet_at should be NULL + if err := s.UpsertObserver("obs1", "Observer1", "SJC", nil); err != nil { + t.Fatal(err) + } + + var lastPacketAt sql.NullString + s.db.QueryRow("SELECT last_packet_at FROM observers WHERE id = ?", "obs1").Scan(&lastPacketAt) + if lastPacketAt.Valid { + t.Fatalf("expected last_packet_at to be NULL after UpsertObserver, got %s", lastPacketAt.String) + } + + // Insert a packet from this observer โ€” last_packet_at should be set + data := &PacketData{ + RawHex: "0A00D69F", + Timestamp: "2026-04-24T12:00:00Z", + ObserverID: "obs1", + Hash: "lastpackettest123456", + RouteType: 2, + PayloadType: 2, + PathJSON: "[]", + DecodedJSON: `{"type":"TXT_MSG"}`, + } + if _, err := s.InsertTransmission(data); err != nil { + t.Fatal(err) + } + + s.db.QueryRow("SELECT last_packet_at FROM observers WHERE id = ?", "obs1").Scan(&lastPacketAt) + if !lastPacketAt.Valid { + t.Fatal("expected last_packet_at to be non-NULL after InsertTransmission") + } + if lastPacketAt.String != "2026-04-24T12:00:00Z" { + t.Errorf("expected last_packet_at=2026-04-24T12:00:00Z, got %s", lastPacketAt.String) + } + + // UpsertObserver again (status path) โ€” last_packet_at should NOT change + if err := s.UpsertObserver("obs1", "Observer1", "SJC", nil); err != nil { + t.Fatal(err) + } + + var lastPacketAtAfterStatus sql.NullString + s.db.QueryRow("SELECT last_packet_at FROM observers WHERE id = ?", "obs1").Scan(&lastPacketAtAfterStatus) + if !lastPacketAtAfterStatus.Valid || lastPacketAtAfterStatus.String != lastPacketAt.String { + t.Errorf("UpsertObserver should not change last_packet_at; expected %s, got %v", lastPacketAt.String, lastPacketAtAfterStatus) + } +} + func TestEndToEndIngest(t *testing.T) { s, err := OpenStore(tempDBPath(t)) if err != nil { diff --git a/cmd/server/db_test.go b/cmd/server/db_test.go index e648c212..118aa3c4 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -49,7 +49,8 @@ func setupTestDB(t *testing.T) *DB { battery_mv INTEGER, uptime_secs INTEGER, noise_floor REAL, - inactive INTEGER DEFAULT 0 + inactive INTEGER DEFAULT 0, + last_packet_at TEXT DEFAULT NULL ); CREATE TABLE transmissions ( @@ -356,6 +357,10 @@ func TestGetObservers(t *testing.T) { if observers[0].ID != "obs1" { t.Errorf("expected obs1 first (most recent), got %s", observers[0].ID) } + // last_packet_at should be nil since seedTestData doesn't set it + if observers[0].LastPacketAt != nil { + t.Errorf("expected nil LastPacketAt for obs1 from seed, got %v", *observers[0].LastPacketAt) + } } // Regression: GetObservers must exclude soft-deleted (inactive=1) rows. @@ -395,6 +400,48 @@ func TestGetObserverByID(t *testing.T) { if obs.ID != "obs1" { t.Errorf("expected obs1, got %s", obs.ID) } + // Verify last_packet_at is nil by default + if obs.LastPacketAt != nil { + t.Errorf("expected nil LastPacketAt, got %v", *obs.LastPacketAt) + } +} + +func TestGetObserverLastPacketAt(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + seedTestData(t, db) + + // Set last_packet_at for obs1 + ts := "2026-04-24T12:00:00Z" + db.conn.Exec(`UPDATE observers SET last_packet_at = ? WHERE id = ?`, ts, "obs1") + + // Verify via GetObservers + observers, err := db.GetObservers() + if err != nil { + t.Fatal(err) + } + var obs1 *Observer + for i := range observers { + if observers[i].ID == "obs1" { + obs1 = &observers[i] + break + } + } + if obs1 == nil { + t.Fatal("obs1 not found") + } + if obs1.LastPacketAt == nil || *obs1.LastPacketAt != ts { + t.Errorf("expected LastPacketAt=%s via GetObservers, got %v", ts, obs1.LastPacketAt) + } + + // Verify via GetObserverByID + obs, err := db.GetObserverByID("obs1") + if err != nil { + t.Fatal(err) + } + if obs.LastPacketAt == nil || *obs.LastPacketAt != ts { + t.Errorf("expected LastPacketAt=%s via GetObserverByID, got %v", ts, obs.LastPacketAt) + } } func TestGetObserverByIDNotFound(t *testing.T) { @@ -1135,7 +1182,8 @@ func setupTestDBV2(t *testing.T) *DB { iata TEXT, last_seen TEXT, first_seen TEXT, - packet_count INTEGER DEFAULT 0 + packet_count INTEGER DEFAULT 0, + last_packet_at TEXT DEFAULT NULL ); CREATE TABLE transmissions ( From 60893b64186d3cc03c58ad5d2da318e8e01ab07c Mon Sep 17 00:00:00 2001 From: you Date: Fri, 24 Apr 2026 15:31:23 +0000 Subject: [PATCH 4/7] fix: bump obs-table min-width to 720px for new Last Packet column The addition of the Last Packet column brings the table to 8 columns. The previous min-width of 640px was tight for 7 columns; 720px prevents cramped rendering and ensures the horizontal scroll trigger is appropriate on narrow viewports. --- public/style.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/style.css b/public/style.css index 3f373c6a..0cdac3a0 100644 --- a/public/style.css +++ b/public/style.css @@ -1558,7 +1558,7 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); } /* #20 โ€” Observers table horizontal scroll on mobile */ .obs-table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; } -.obs-table-scroll .obs-table { min-width: 640px; } +.obs-table-scroll .obs-table { min-width: 720px; } /* #206 โ€” Analytics/Compare tables scroll wrappers on mobile */ .analytics-table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; } From 33198b8012c6c522c319a1b556d773b9ed44774b Mon Sep 17 00:00:00 2001 From: you Date: Fri, 24 Apr 2026 15:38:05 +0000 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20address=20PR=20#905=20review=20?= =?UTF-8?q?=E2=80=94=20migration=20error=20handling,=20backfill=20heuristi?= =?UTF-8?q?c,=20test=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Migration ALTER error no longer swallowed: check error from ALTER TABLE and return if it fails (unless column already exists). Migration is not marked complete on failure. 2. Backfill heuristic fixed: use observations table JOIN instead of packet_count > 0, since UpsertObserver sets packet_count = 1 on INSERT even for status-only observers. 3. Test clarifying comment: document that InsertTransmission uses data.Timestamp (not time.Now()) as source-of-truth for last_packet_at, so the hardcoded assertion is correct. --- cmd/ingestor/db.go | 13 ++++++++++--- cmd/ingestor/db_test.go | 2 ++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/cmd/ingestor/db.go b/cmd/ingestor/db.go index d3a3a29a..6fc0f9a1 100644 --- a/cmd/ingestor/db.go +++ b/cmd/ingestor/db.go @@ -426,9 +426,16 @@ func applySchema(db *sql.DB) error { 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`) + _, alterErr := db.Exec(`ALTER TABLE observers ADD COLUMN last_packet_at TEXT DEFAULT NULL`) + if alterErr != nil && !strings.Contains(alterErr.Error(), "duplicate column") { + return fmt.Errorf("observers last_packet_at ALTER: %w", alterErr) + } + // Backfill: set last_packet_at = last_seen only for observers that actually have + // observation rows (packet_count alone is unreliable โ€” UpsertObserver sets it to 1 + // on INSERT even for status-only observers). + res, err := db.Exec(`UPDATE observers SET last_packet_at = last_seen + WHERE last_packet_at IS NULL + AND rowid IN (SELECT DISTINCT observer_idx FROM observations WHERE observer_idx IS NOT NULL)`) if err == nil { n, _ := res.RowsAffected() log.Printf("[migration] Backfilled last_packet_at for %d observers with packets", n) diff --git a/cmd/ingestor/db_test.go b/cmd/ingestor/db_test.go index 12c2da6a..957b7bdb 100644 --- a/cmd/ingestor/db_test.go +++ b/cmd/ingestor/db_test.go @@ -606,6 +606,8 @@ func TestLastPacketAtUpdatedOnPacketOnly(t *testing.T) { if !lastPacketAt.Valid { t.Fatal("expected last_packet_at to be non-NULL after InsertTransmission") } + // InsertTransmission uses `now = data.Timestamp || time.Now()`, so last_packet_at + // should match the packet's Timestamp when provided (same source-of-truth as last_seen). if lastPacketAt.String != "2026-04-24T12:00:00Z" { t.Errorf("expected last_packet_at=2026-04-24T12:00:00Z, got %s", lastPacketAt.String) } From 9ba45e6fb4e6f758d69c92293df545ccfefb8247 Mon Sep 17 00:00:00 2001 From: you Date: Sat, 2 May 2026 17:04:48 +0000 Subject: [PATCH 6/7] fix: add last_packet_at to ObserverResp (types.go extracted after PR #905) --- cmd/server/routes.go | 2 ++ cmd/server/types.go | 1 + 2 files changed, 3 insertions(+) diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 5bd0562f..ed4fc846 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -1955,6 +1955,7 @@ func (s *Server) handleObservers(w http.ResponseWriter, r *http.Request) { ClientVersion: o.ClientVersion, Radio: o.Radio, BatteryMv: o.BatteryMv, UptimeSecs: o.UptimeSecs, NoiseFloor: o.NoiseFloor, + LastPacketAt: o.LastPacketAt, PacketsLastHour: plh, Lat: lat, Lon: lon, NodeRole: nodeRole, }) @@ -1996,6 +1997,7 @@ func (s *Server) handleObserverDetail(w http.ResponseWriter, r *http.Request) { ClientVersion: obs.ClientVersion, Radio: obs.Radio, BatteryMv: obs.BatteryMv, UptimeSecs: obs.UptimeSecs, NoiseFloor: obs.NoiseFloor, + LastPacketAt: obs.LastPacketAt, PacketsLastHour: plh, }) } diff --git a/cmd/server/types.go b/cmd/server/types.go index 50505763..efe2d310 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -859,6 +859,7 @@ type ObserverResp struct { BatteryMv interface{} `json:"battery_mv"` UptimeSecs interface{} `json:"uptime_secs"` NoiseFloor interface{} `json:"noise_floor"` + LastPacketAt interface{} `json:"last_packet_at"` PacketsLastHour int `json:"packetsLastHour"` Lat interface{} `json:"lat"` Lon interface{} `json:"lon"` From 37a2b71fc7b88caa15eae92eb059531a18f9e447 Mon Sep 17 00:00:00 2001 From: you Date: Sat, 2 May 2026 17:48:15 +0000 Subject: [PATCH 7/7] fix: add ensureLastPacketAtColumn server-side migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the ensureObserverInactiveColumn pattern (PR #961) for the last_packet_at column added by the ingestor migration. Without this, the server SELECTs last_packet_at but never adds it โ€” causing 500 errors on /api/observers when running against DBs the ingestor has not yet touched (e.g. the e2e fixture). Adds TestEnsureLastPacketAtColumn for correctness + idempotency. --- cmd/server/main.go | 6 +++ cmd/server/neighbor_persist.go | 38 +++++++++++++++++++ cmd/server/neighbor_persist_test.go | 59 +++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+) diff --git a/cmd/server/main.go b/cmd/server/main.go index 17b9c0fe..942d2a75 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -180,6 +180,12 @@ func main() { log.Printf("[store] warning: could not add observers.inactive column: %v", err) } + // Ensure observers.last_packet_at column exists (PR #905 reads it; ingestor migration + // adds it but server may run against DBs ingestor never touched, e.g. e2e fixture). + if err := ensureLastPacketAtColumn(dbPath); err != nil { + log.Printf("[store] warning: could not add observers.last_packet_at column: %v", err) + } + // Soft-delete observers that are in the blacklist (mark inactive=1) so // historical data from a prior unblocked window is hidden too. if len(cfg.ObserverBlacklist) > 0 { diff --git a/cmd/server/neighbor_persist.go b/cmd/server/neighbor_persist.go index 58637e86..675772a6 100644 --- a/cmd/server/neighbor_persist.go +++ b/cmd/server/neighbor_persist.go @@ -320,6 +320,44 @@ func ensureObserverInactiveColumn(dbPath string) error { return nil } +// ensureLastPacketAtColumn adds the last_packet_at column to observers if missing. +// The column was originally added by ingestor migration (observers_last_packet_at_v1) +// to track the most recent packet observation time separately from status updates. +// When the server starts against a DB that was never touched by the ingestor (e.g. +// the e2e fixture), the column is missing and read queries that reference it +// (GetObservers, GetObserverByID) fail with "no such column: last_packet_at". +func ensureLastPacketAtColumn(dbPath string) error { + rw, err := openRW(dbPath) + if err != nil { + return err + } + defer rw.Close() + + rows, err := rw.Query("PRAGMA table_info(observers)") + if err != nil { + return err + } + 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 && colName == "last_packet_at" { + return nil // already exists + } + } + + _, err = rw.Exec("ALTER TABLE observers ADD COLUMN last_packet_at TEXT") + if err != nil { + return fmt.Errorf("add last_packet_at column: %w", err) + } + log.Println("[store] Added last_packet_at column to observers") + return nil +} + // softDeleteBlacklistedObservers marks observers matching the blacklist as // inactive=1 so they are hidden from API responses. Runs once at startup. func softDeleteBlacklistedObservers(dbPath string, blacklist []string) { diff --git a/cmd/server/neighbor_persist_test.go b/cmd/server/neighbor_persist_test.go index 33d29efc..40594e4e 100644 --- a/cmd/server/neighbor_persist_test.go +++ b/cmd/server/neighbor_persist_test.go @@ -538,3 +538,62 @@ func TestOpenRW_BusyTimeout(t *testing.T) { t.Errorf("expected busy_timeout=5000, got %d", timeout) } } + +func TestEnsureLastPacketAtColumn(t *testing.T) { + // Create a temp DB with observers table missing last_packet_at + dir := t.TempDir() + dbPath := dir + "/test.db" + db, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatal(err) + } + _, err = db.Exec(`CREATE TABLE observers ( + id TEXT PRIMARY KEY, + name TEXT, + last_seen TEXT, + lat REAL, + lon REAL, + inactive INTEGER DEFAULT 0 + )`) + if err != nil { + t.Fatal(err) + } + db.Close() + + // First call: should add the column + if err := ensureLastPacketAtColumn(dbPath); err != nil { + t.Fatalf("first call failed: %v", err) + } + + // Verify column exists + db2, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatal(err) + } + defer db2.Close() + + var found bool + rows, err := db2.Query("PRAGMA table_info(observers)") + if err != nil { + t.Fatal(err) + } + 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 && colName == "last_packet_at" { + found = true + } + } + if !found { + t.Fatal("last_packet_at column not found after migration") + } + + // Idempotency: second call should succeed without error + if err := ensureLastPacketAtColumn(dbPath); err != nil { + t.Fatalf("idempotent call failed: %v", err) + } +}
Observer status and statistics
StatusNameRegionLast SeenStatusNameRegionLast StatusLast Packet PacketsPackets/HourUptime
${o.name || o.id} ${o.iata ? `${o.iata}` : 'โ€”'} ${timeAgo(o.last_seen)}${packetBadge(o)} ${(o.packet_count || 0).toLocaleString()} ${sparkBar(o.packetsLastHour || 0, maxPktsHr)} ${uptimeStr(o.first_seen)}