diff --git a/cmd/ingestor/db.go b/cmd/ingestor/db.go
index f8ab1213..b32c98ef 100644
--- a/cmd/ingestor/db.go
+++ b/cmd/ingestor/db.go
@@ -1735,11 +1735,14 @@ func (s *Store) TouchObserverNeighborsReport(observerID, reportedAt string) erro
}
// ObserverNeighborEntry is one entry from an observer's /neighbors report,
-// carrying enough to populate the observer_neighbors table.
+// carrying enough to populate the observer_neighbors and
+// observer_neighbor_metrics tables.
type ObserverNeighborEntry struct {
- Pubkey string // lowercase, already validated non-empty by the caller
- Scopes string // normalized "#"-prefixed form; empty for timeout entries
- Status string // "responded" | "timeout"
+ Pubkey string // lowercase, already validated non-empty by the caller
+ Scopes string // normalized "#"-prefixed form; empty for timeout entries
+ Status string // "responded" | "timeout"
+ SNR *float64 // dBm signal-to-noise for this direct neighbor; present regardless of status
+ HeardSecsAgo *int // seconds since the observer last heard this neighbor directly
}
// ReplaceObserverNeighbors stores the observer's CURRENT direct (zero-hop)
@@ -1792,6 +1795,60 @@ func (s *Store) ReplaceObserverNeighbors(observerID string, neighbors []Observer
return tx.Commit()
}
+// RecordObserverNeighborMetrics appends one SNR/heard_secs_ago history row
+// per neighbor entry that carries an SNR reading (#1865 follow-up: dborup
+// noticed the raw report also carries snr/heard_secs_ago per neighbor,
+// previously dropped entirely). Unlike ReplaceObserverNeighbors' current-
+// only snapshot, this is pure time-series -- every report is valid
+// historical data at its own timestamp regardless of arrival order, so
+// there is deliberately NO ordering guard here. The
+// (observer_id, neighbor_pubkey, timestamp) primary key makes a retried/
+// duplicate MQTT delivery a no-op via INSERT OR IGNORE.
+func (s *Store) RecordObserverNeighborMetrics(observerID string, neighbors []ObserverNeighborEntry, reportedAt string) error {
+ if observerID == "" {
+ return nil
+ }
+ reportedAt = normalizeReportTS(reportedAt)
+ if reportedAt == "" {
+ return nil
+ }
+ stmt, err := s.db.Prepare(`INSERT OR IGNORE INTO observer_neighbor_metrics (observer_id, neighbor_pubkey, timestamp, snr, heard_secs_ago) VALUES (?, ?, ?, ?, ?)`)
+ if err != nil {
+ return err
+ }
+ defer stmt.Close()
+ for _, n := range neighbors {
+ if n.Pubkey == "" || n.SNR == nil {
+ continue
+ }
+ var heardSecsAgo interface{}
+ if n.HeardSecsAgo != nil {
+ heardSecsAgo = *n.HeardSecsAgo
+ }
+ if _, err := stmt.Exec(observerID, n.Pubkey, reportedAt, *n.SNR, heardSecsAgo); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// PruneOldNeighborMetrics deletes observer_neighbor_metrics rows older than
+// retentionDays, mirroring PruneOldMetrics' retention model for
+// observer_metrics (same MetricsRetentionDays config knob, no separate
+// setting for this table).
+func (s *Store) PruneOldNeighborMetrics(retentionDays int) (int64, error) {
+ cutoff := time.Now().UTC().AddDate(0, 0, -retentionDays).Format(time.RFC3339)
+ result, err := s.instrumentedExec("prune_neighbor_metrics", `DELETE FROM observer_neighbor_metrics WHERE timestamp < ?`, cutoff)
+ if err != nil {
+ return 0, fmt.Errorf("prune neighbor metrics: %w", err)
+ }
+ n, _ := result.RowsAffected()
+ if n > 0 {
+ log.Printf("[neighbor-metrics] Pruned %d rows older than %d days", n, retentionDays)
+ }
+ return n, nil
+}
+
// normalizeConfiguredScopeList applies the same "#"-prefix normalization
// default_scope already gets (regions.Normalize, via matchScope) to a
// comma-separated /neighbors-report scope list, so both fields display
diff --git a/cmd/ingestor/main.go b/cmd/ingestor/main.go
index 7c0e08df..50326dfc 100644
--- a/cmd/ingestor/main.go
+++ b/cmd/ingestor/main.go
@@ -267,6 +267,7 @@ func main() {
// Metrics retention: prune old metrics on startup
metricsDays := cfg.MetricsRetentionDays()
store.PruneOldMetrics(metricsDays)
+ store.PruneOldNeighborMetrics(metricsDays)
store.PruneDroppedPackets(metricsDays)
// Packet (transmissions) retention: previously lived in cmd/server,
@@ -334,6 +335,7 @@ func main() {
go func() {
for range metricsRetentionTicker.C {
store.PruneOldMetrics(metricsDays)
+ store.PruneOldNeighborMetrics(metricsDays)
store.PruneDroppedPackets(metricsDays)
store.RunIncrementalVacuum(vacuumPages)
}
@@ -1577,18 +1579,33 @@ func handleNeighborsReport(store *Store, tag string, observerID string, msg map[
}
status, _ := n["status"].(string)
scopes, _ := n["scopes"].(string)
+ // snr/heard_secs_ago are present regardless of scope-query status --
+ // they come from the firmware's own RF neighbor table, not the OTA
+ // scope query (#1865 follow-up, spotted by dborup in a live payload).
+ var snr *float64
+ if v, ok := n["snr"].(float64); ok {
+ snr = &v
+ }
+ var heardSecsAgo *int
+ if v, ok := n["heard_secs_ago"].(float64); ok {
+ hs := int(v)
+ heardSecsAgo = &hs
+ }
if status == "responded" {
if err := store.UpdateNodeConfiguredScope(pubkey, scopes, reportedAt); err != nil {
log.Printf("MQTT [%s] neighbors scope error for %.8s: %v", tag, pubkey, err)
}
- entries = append(entries, ObserverNeighborEntry{Pubkey: pubkey, Scopes: normalizeConfiguredScopeList(scopes), Status: status})
+ entries = append(entries, ObserverNeighborEntry{Pubkey: pubkey, Scopes: normalizeConfiguredScopeList(scopes), Status: status, SNR: snr, HeardSecsAgo: heardSecsAgo})
} else {
- entries = append(entries, ObserverNeighborEntry{Pubkey: pubkey, Status: status})
+ entries = append(entries, ObserverNeighborEntry{Pubkey: pubkey, Status: status, SNR: snr, HeardSecsAgo: heardSecsAgo})
}
}
if err := store.ReplaceObserverNeighbors(observerID, entries, reportedAt); err != nil {
log.Printf("MQTT [%s] neighbors replace error for observer %.8s: %v", tag, observerID, err)
}
+ if err := store.RecordObserverNeighborMetrics(observerID, entries, reportedAt); err != nil {
+ log.Printf("MQTT [%s] neighbor metrics record error for observer %.8s: %v", tag, observerID, err)
+ }
}
// shouldUpdateDefaultScope returns true when the packet carries a transport
diff --git a/cmd/ingestor/observer_neighbor_metrics_test.go b/cmd/ingestor/observer_neighbor_metrics_test.go
new file mode 100644
index 00000000..595a6cd0
--- /dev/null
+++ b/cmd/ingestor/observer_neighbor_metrics_test.go
@@ -0,0 +1,157 @@
+package main
+
+import "testing"
+
+// #1865 follow-up: dborup spotted that the raw /neighbors payload also
+// carries snr/heard_secs_ago per neighbor, previously dropped entirely.
+// observer_neighbor_metrics is an APPEND-ONLY history (unlike
+// observer_neighbors' current-only snapshot), inspired by the existing
+// RF Health tab's observer_metrics pattern.
+
+func floatPtr(f float64) *float64 { return &f }
+func intPtr(i int) *int { return &i }
+
+func countObserverNeighborMetrics(t *testing.T, store *Store, observerID, pubkey string) int {
+ t.Helper()
+ var n int
+ if err := store.db.QueryRow(`SELECT COUNT(*) FROM observer_neighbor_metrics WHERE observer_id = ? AND neighbor_pubkey = ?`,
+ observerID, pubkey).Scan(&n); err != nil {
+ t.Fatalf("count observer_neighbor_metrics: %v", err)
+ }
+ return n
+}
+
+func TestRecordObserverNeighborMetrics_Basic(t *testing.T) {
+ store := openNeighborsStore(t)
+ seedObserverForNeighbors(t, store, "obs-metrics-1")
+
+ entries := []ObserverNeighborEntry{
+ {Pubkey: "aaaa000000000000000000000000000000000000000000000000000000000001", Status: "responded", SNR: floatPtr(10.5), HeardSecsAgo: intPtr(75)},
+ {Pubkey: "bbbb000000000000000000000000000000000000000000000000000000000002", Status: "timeout", SNR: floatPtr(-8.75), HeardSecsAgo: intPtr(77)},
+ }
+ if err := store.RecordObserverNeighborMetrics("obs-metrics-1", entries, "2026-07-26T12:00:00Z"); err != nil {
+ t.Fatal(err)
+ }
+
+ var snr float64
+ var heardSecsAgo int
+ if err := store.db.QueryRow(`SELECT snr, heard_secs_ago FROM observer_neighbor_metrics WHERE observer_id = ? AND neighbor_pubkey = ? AND timestamp = ?`,
+ "obs-metrics-1", "aaaa000000000000000000000000000000000000000000000000000000000001", "2026-07-26T12:00:00Z").Scan(&snr, &heardSecsAgo); err != nil {
+ t.Fatalf("select: %v", err)
+ }
+ if snr != 10.5 || heardSecsAgo != 75 {
+ t.Errorf("snr=%v heardSecsAgo=%v, want 10.5/75", snr, heardSecsAgo)
+ }
+ // Timeout entries still carry snr -- must be recorded too, not just
+ // scope-query "responded" entries.
+ if n := countObserverNeighborMetrics(t, store, "obs-metrics-1", "bbbb000000000000000000000000000000000000000000000000000000000002"); n != 1 {
+ t.Errorf("expected 1 row for the timeout neighbor's SNR reading, got %d", n)
+ }
+}
+
+func TestRecordObserverNeighborMetrics_AccumulatesAcrossReports(t *testing.T) {
+ store := openNeighborsStore(t)
+ seedObserverForNeighbors(t, store, "obs-metrics-2")
+ pk := "cccc000000000000000000000000000000000000000000000000000000000003"
+
+ if err := store.RecordObserverNeighborMetrics("obs-metrics-2", []ObserverNeighborEntry{
+ {Pubkey: pk, Status: "responded", SNR: floatPtr(5)},
+ }, "2026-07-26T12:00:00Z"); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.RecordObserverNeighborMetrics("obs-metrics-2", []ObserverNeighborEntry{
+ {Pubkey: pk, Status: "responded", SNR: floatPtr(6)},
+ }, "2026-07-26T13:00:00Z"); err != nil {
+ t.Fatal(err)
+ }
+ // Unlike ReplaceObserverNeighbors, this is a time-series -- both rows
+ // must survive, not just the latest.
+ if n := countObserverNeighborMetrics(t, store, "obs-metrics-2", pk); n != 2 {
+ t.Fatalf("expected 2 accumulated history rows, got %d", n)
+ }
+}
+
+func TestRecordObserverNeighborMetrics_OutOfOrderStillRecorded(t *testing.T) {
+ store := openNeighborsStore(t)
+ seedObserverForNeighbors(t, store, "obs-metrics-3")
+ pk := "dddd000000000000000000000000000000000000000000000000000000000004"
+
+ // Newer report first, then an older out-of-order one -- unlike
+ // ReplaceObserverNeighbors' snapshot guard, BOTH are valid history at
+ // their own timestamp and must both be recorded.
+ if err := store.RecordObserverNeighborMetrics("obs-metrics-3", []ObserverNeighborEntry{
+ {Pubkey: pk, Status: "responded", SNR: floatPtr(9)},
+ }, "2026-07-26T14:00:00Z"); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.RecordObserverNeighborMetrics("obs-metrics-3", []ObserverNeighborEntry{
+ {Pubkey: pk, Status: "responded", SNR: floatPtr(3)},
+ }, "2026-07-26T10:00:00Z"); err != nil {
+ t.Fatal(err)
+ }
+ if n := countObserverNeighborMetrics(t, store, "obs-metrics-3", pk); n != 2 {
+ t.Errorf("expected both the newer and the out-of-order older reading recorded, got %d rows", n)
+ }
+}
+
+func TestRecordObserverNeighborMetrics_SkipsEntriesWithoutSNR(t *testing.T) {
+ store := openNeighborsStore(t)
+ seedObserverForNeighbors(t, store, "obs-metrics-4")
+ pk := "eeee000000000000000000000000000000000000000000000000000000000005"
+
+ if err := store.RecordObserverNeighborMetrics("obs-metrics-4", []ObserverNeighborEntry{
+ {Pubkey: pk, Status: "timeout", SNR: nil},
+ }, "2026-07-26T12:00:00Z"); err != nil {
+ t.Fatal(err)
+ }
+ if n := countObserverNeighborMetrics(t, store, "obs-metrics-4", pk); n != 0 {
+ t.Errorf("expected no row when SNR is nil, got %d", n)
+ }
+}
+
+func TestPruneOldNeighborMetrics(t *testing.T) {
+ store := openNeighborsStore(t)
+ seedObserverForNeighbors(t, store, "obs-metrics-5")
+ pk := "ffff000000000000000000000000000000000000000000000000000000000006"
+
+ old := "2020-01-01T00:00:00Z"
+ recent := "2026-07-26T12:00:00Z"
+ if err := store.RecordObserverNeighborMetrics("obs-metrics-5", []ObserverNeighborEntry{{Pubkey: pk, SNR: floatPtr(1)}}, old); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.RecordObserverNeighborMetrics("obs-metrics-5", []ObserverNeighborEntry{{Pubkey: pk, SNR: floatPtr(2)}}, recent); err != nil {
+ t.Fatal(err)
+ }
+ if n, err := store.PruneOldNeighborMetrics(30); err != nil {
+ t.Fatal(err)
+ } else if n != 1 {
+ t.Fatalf("expected 1 row pruned, got %d", n)
+ }
+ if n := countObserverNeighborMetrics(t, store, "obs-metrics-5", pk); n != 1 {
+ t.Errorf("expected 1 row remaining after prune, got %d", n)
+ }
+}
+
+func TestHandleNeighborsReport_RecordsSnrHistory(t *testing.T) {
+ store := openNeighborsStore(t)
+ seedObserverForNeighbors(t, store, "obs-metrics-6")
+
+ report := map[string]interface{}{
+ "timestamp": "2026-07-26T16:45:06.000000+00:00",
+ "neighbors": []interface{}{
+ map[string]interface{}{"pubkey": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", "snr": 10.0, "heard_secs_ago": 75.0, "scopes": "", "status": "timeout"},
+ map[string]interface{}{"pubkey": "2102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", "snr": 14.0, "heard_secs_ago": 83.0, "scopes": "dk", "status": "responded"},
+ },
+ }
+ handleNeighborsReport(store, "test", "obs-metrics-6", report)
+
+ var snr float64
+ var heardSecsAgo int
+ if err := store.db.QueryRow(`SELECT snr, heard_secs_ago FROM observer_neighbor_metrics WHERE observer_id = ? AND neighbor_pubkey = ?`,
+ "obs-metrics-6", "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20").Scan(&snr, &heardSecsAgo); err != nil {
+ t.Fatalf("select: %v", err)
+ }
+ if snr != 10.0 || heardSecsAgo != 75 {
+ t.Errorf("snr=%v heardSecsAgo=%v, want 10.0/75 (timeout entry must still record SNR)", snr, heardSecsAgo)
+ }
+}
diff --git a/cmd/server/db.go b/cmd/server/db.go
index 48500dd5..dc20862c 100644
--- a/cmd/server/db.go
+++ b/cmd/server/db.go
@@ -1399,14 +1399,55 @@ type ObserverNeighbor struct {
Role *string `json:"role"`
Scopes *string `json:"scopes"`
Status string `json:"status"`
+ // SeenViaPackets is true when this firmware-confirmed neighbor also has
+ // an edge in the packet-path-inferred neighbor_edges graph. false is a
+ // diagnostic signal, NOT necessarily a fault: it means we've never
+ // resolved a packet path connecting these two stations despite RF
+ // adjacency, which can point at a coverage gap, packet loss, or simply
+ // that the neighbor hasn't transmitted since neighbor_edges last built
+ // (#1865 follow-up, requested by dborup to help "make the disambiguator
+ // smarter" -- this surfaces the mismatch; it does not yet feed the
+ // disambiguator's own scoring, which would be a separate, larger change).
+ SeenViaPackets bool `json:"seenViaPackets"`
+}
+
+// packetGraphNeighbors returns the set of lowercase pubkeys that
+// neighbor_edges records as adjacent to pubkey, in either edge direction
+// (canonEdge in cmd/ingestor/neighbor_builder.go stores node_a<=node_b, so
+// callers must check both columns rather than assuming a side).
+func (db *DB) packetGraphNeighbors(pubkey string) (map[string]bool, error) {
+ rows, err := db.conn.Query(`SELECT node_a, node_b FROM neighbor_edges WHERE node_a = ? OR node_b = ?`, pubkey, pubkey)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ set := make(map[string]bool)
+ for rows.Next() {
+ var a, b string
+ if err := rows.Scan(&a, &b); err != nil {
+ return nil, err
+ }
+ if a == pubkey {
+ set[b] = true
+ } else {
+ set[a] = true
+ }
+ }
+ return set, rows.Err()
}
// GetObserverNeighbors returns the observer's current direct-neighbor
// snapshot (empty slice if none/never reported -- not an error) alongside
// the shared report timestamp all rows carry (from observer_neighbors.
// reported_at, which the ingestor sets identically for every row in a
-// single replace).
+// single replace). Each entry is cross-referenced against the
+// packet-derived neighbor_edges graph via SeenViaPackets.
func (db *DB) GetObserverNeighbors(observerID string) ([]ObserverNeighbor, string, error) {
+ packetNeighbors, err := db.packetGraphNeighbors(strings.ToLower(observerID))
+ if err != nil {
+ return nil, "", err
+ }
+
rows, err := db.conn.Query(`
SELECT on2.neighbor_pubkey, on2.scopes, on2.status, on2.reported_at, n.name, n.role
FROM observer_neighbors on2
@@ -1426,6 +1467,7 @@ func (db *DB) GetObserverNeighbors(observerID string) ([]ObserverNeighbor, strin
if err := rows.Scan(&n.Pubkey, &scopes, &n.Status, &reportedAtCol, &name, &role); err != nil {
return nil, "", err
}
+ n.SeenViaPackets = packetNeighbors[n.Pubkey]
if scopes.Valid && scopes.String != "" {
s := scopes.String
n.Scopes = &s
@@ -1446,6 +1488,61 @@ func (db *DB) GetObserverNeighbors(observerID string) ([]ObserverNeighbor, strin
return result, reportedAt, rows.Err()
}
+// NeighborMetricPoint is one time-series sample of an observer<->neighbor
+// direct-RF link (#1865 follow-up: the /neighbors report's snr and
+// heard_secs_ago fields, previously dropped). Mirrors MetricsSample's
+// shape but deliberately simpler -- report volume per neighbor pair is
+// inherently low (one row per /neighbors report, which arrive hours
+// apart), so unlike GetObserverMetrics there's no resolution/downsampling.
+type NeighborMetricPoint struct {
+ Timestamp string `json:"timestamp"`
+ SNR *float64 `json:"snr"`
+ HeardSecsAgo *int `json:"heardSecsAgo"`
+}
+
+// GetObserverNeighborMetrics returns raw SNR/heard_secs_ago history for one
+// observer<->neighbor pair, oldest first, optionally bounded by since/until
+// (RFC3339; either may be "" to leave that bound open).
+func (db *DB) GetObserverNeighborMetrics(observerID, neighborPubkey, since, until string) ([]NeighborMetricPoint, error) {
+ query := `SELECT timestamp, snr, heard_secs_ago FROM observer_neighbor_metrics WHERE observer_id = ? AND neighbor_pubkey = ?`
+ args := []interface{}{observerID, neighborPubkey}
+ if since != "" {
+ query += ` AND timestamp >= ?`
+ args = append(args, since)
+ }
+ if until != "" {
+ query += ` AND timestamp <= ?`
+ args = append(args, until)
+ }
+ query += ` ORDER BY timestamp ASC`
+
+ rows, err := db.conn.Query(query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ result := []NeighborMetricPoint{}
+ for rows.Next() {
+ var p NeighborMetricPoint
+ var snr sql.NullFloat64
+ var heardSecsAgo sql.NullInt64
+ if err := rows.Scan(&p.Timestamp, &snr, &heardSecsAgo); err != nil {
+ return nil, err
+ }
+ if snr.Valid {
+ v := snr.Float64
+ p.SNR = &v
+ }
+ if heardSecsAgo.Valid {
+ v := int(heardSecsAgo.Int64)
+ p.HeardSecsAgo = &v
+ }
+ result = append(result, p)
+ }
+ return result, rows.Err()
+}
+
// GetObserverIdsForRegion returns observer IDs for given IATA codes.
func (db *DB) GetObserverIdsForRegion(regionParam string) ([]string, error) {
codes := normalizeRegionCodes(regionParam)
diff --git a/cmd/server/db_test.go b/cmd/server/db_test.go
index 3a3c244d..93c6bcd7 100644
--- a/cmd/server/db_test.go
+++ b/cmd/server/db_test.go
@@ -112,6 +112,23 @@ func setupTestDB(t *testing.T) *DB {
PRIMARY KEY (observer_id, neighbor_pubkey)
);
+ CREATE TABLE neighbor_edges (
+ node_a TEXT NOT NULL,
+ node_b TEXT NOT NULL,
+ count INTEGER DEFAULT 1,
+ last_seen TEXT,
+ PRIMARY KEY (node_a, node_b)
+ );
+
+ CREATE TABLE observer_neighbor_metrics (
+ observer_id TEXT NOT NULL,
+ neighbor_pubkey TEXT NOT NULL,
+ timestamp TEXT NOT NULL,
+ snr REAL,
+ heard_secs_ago INTEGER,
+ PRIMARY KEY (observer_id, neighbor_pubkey, timestamp)
+ );
+
-- Auto-populate from_pubkey for ADVERT rows so existing test fixtures
-- (which only set decoded_json) still attribute correctly under #1143's
-- exact-match column. Production migration handles legacy data; the
diff --git a/cmd/server/observer_direct_neighbors_endpoint_test.go b/cmd/server/observer_direct_neighbors_endpoint_test.go
index 28f88330..fb7a2aa5 100644
--- a/cmd/server/observer_direct_neighbors_endpoint_test.go
+++ b/cmd/server/observer_direct_neighbors_endpoint_test.go
@@ -128,3 +128,94 @@ func TestHandleObserverNeighbors_UnresolvedPubkeyHasNilNameAndRole(t *testing.T)
t.Errorf("Status = %q, want 'timeout'", n.Status)
}
}
+
+// More ambitious use, requested by dborup after the panel shipped:
+// cross-reference the firmware-confirmed direct neighbor against the
+// packet-derived neighbor_edges graph and flag mismatches.
+func TestHandleObserverNeighbors_SeenViaPacketsCrossReference(t *testing.T) {
+ srv, router := setupTestServer(t)
+
+ observerPubkey := "1111111111111111111111111111111111111111111111111111111111111111"
+ confirmedAndSeen := "2222222222222222222222222222222222222222222222222222222222222222"
+ confirmedButNeverSeen := "3333333333333333333333333333333333333333333333333333333333333333"
+
+ if _, err := srv.db.conn.Exec(`INSERT INTO observer_neighbors (observer_id, neighbor_pubkey, scopes, status, reported_at) VALUES
+ (?, ?, '', 'responded', '2026-07-26T12:00:00Z'),
+ (?, ?, '', 'responded', '2026-07-26T12:00:00Z')`,
+ observerPubkey, confirmedAndSeen, observerPubkey, confirmedButNeverSeen); err != nil {
+ t.Fatalf("seed observer_neighbors: %v", err)
+ }
+ // Packet-path evidence exists for observerPubkey<->confirmedAndSeen but
+ // NOT for observerPubkey<->confirmedButNeverSeen.
+ if _, err := srv.db.conn.Exec(`INSERT INTO neighbor_edges (node_a, node_b, count, last_seen) VALUES (?, ?, 5, '2026-07-26T11:00:00Z')`,
+ observerPubkey, confirmedAndSeen); err != nil {
+ t.Fatalf("seed neighbor_edges: %v", err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/observers/"+observerPubkey+"/neighbors", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != 200 {
+ t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
+ }
+
+ var body struct {
+ Neighbors []struct {
+ Pubkey string `json:"pubkey"`
+ SeenViaPackets bool `json:"seenViaPackets"`
+ } `json:"neighbors"`
+ }
+ if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
+ t.Fatalf("decode: %v body=%s", err, w.Body.String())
+ }
+ got := map[string]bool{}
+ for _, n := range body.Neighbors {
+ got[n.Pubkey] = n.SeenViaPackets
+ }
+ if !got[confirmedAndSeen] {
+ t.Errorf("seenViaPackets for %s = false, want true (edge exists in neighbor_edges)", confirmedAndSeen)
+ }
+ if got[confirmedButNeverSeen] {
+ t.Errorf("seenViaPackets for %s = true, want false (no edge in neighbor_edges)", confirmedButNeverSeen)
+ }
+}
+
+// The edge might be stored with the observer as node_b rather than node_a
+// (canonEdge orders node_a<=node_b) -- must still be detected.
+func TestHandleObserverNeighbors_SeenViaPacketsChecksBothEdgeColumns(t *testing.T) {
+ srv, router := setupTestServer(t)
+
+ observerPubkey := "9999999999999999999999999999999999999999999999999999999999999999"
+ neighborPubkey := "1000000000000000000000000000000000000000000000000000000000000000"
+
+ if _, err := srv.db.conn.Exec(`INSERT INTO observer_neighbors (observer_id, neighbor_pubkey, scopes, status, reported_at) VALUES (?, ?, '', 'responded', '2026-07-26T12:00:00Z')`,
+ observerPubkey, neighborPubkey); err != nil {
+ t.Fatalf("seed observer_neighbors: %v", err)
+ }
+ // neighborPubkey < observerPubkey lexicographically, so canonEdge would
+ // store it as node_a=neighborPubkey, node_b=observerPubkey.
+ if _, err := srv.db.conn.Exec(`INSERT INTO neighbor_edges (node_a, node_b, count, last_seen) VALUES (?, ?, 1, '2026-07-26T11:00:00Z')`,
+ neighborPubkey, observerPubkey); err != nil {
+ t.Fatalf("seed neighbor_edges: %v", err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/observers/"+observerPubkey+"/neighbors", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != 200 {
+ t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
+ }
+
+ var body struct {
+ Neighbors []struct {
+ Pubkey string `json:"pubkey"`
+ SeenViaPackets bool `json:"seenViaPackets"`
+ } `json:"neighbors"`
+ }
+ if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
+ t.Fatalf("decode: %v body=%s", err, w.Body.String())
+ }
+ if len(body.Neighbors) != 1 || !body.Neighbors[0].SeenViaPackets {
+ t.Fatalf("expected 1 neighbor with seenViaPackets=true (edge stored as node_a), got %+v", body.Neighbors)
+ }
+}
diff --git a/cmd/server/observer_neighbor_metrics_endpoint_test.go b/cmd/server/observer_neighbor_metrics_endpoint_test.go
new file mode 100644
index 00000000..9c8fcaab
--- /dev/null
+++ b/cmd/server/observer_neighbor_metrics_endpoint_test.go
@@ -0,0 +1,114 @@
+package main
+
+// #1865 follow-up: GET /api/observers/{id}/neighbors/{pubkey}/metrics
+// serves SNR/heard_secs_ago history for one observer<->neighbor link.
+
+import (
+ "encoding/json"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestHandleObserverNeighborMetrics_ReturnsOrderedHistory(t *testing.T) {
+ srv, router := setupTestServer(t)
+
+ pubkey := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ rows := []struct {
+ ts string
+ snr float64
+ hsa int
+ }{
+ {"2026-07-26T14:00:00Z", 6.0, 60},
+ {"2026-07-26T12:00:00Z", 5.0, 75},
+ }
+ for _, r := range rows {
+ if _, err := srv.db.conn.Exec(`INSERT INTO observer_neighbor_metrics (observer_id, neighbor_pubkey, timestamp, snr, heard_secs_ago) VALUES (?, ?, ?, ?, ?)`,
+ "obs1", pubkey, r.ts, r.snr, r.hsa); err != nil {
+ t.Fatalf("seed observer_neighbor_metrics: %v", err)
+ }
+ }
+
+ req := httptest.NewRequest("GET", "/api/observers/obs1/neighbors/"+pubkey+"/metrics?since=2026-07-01T00:00:00Z", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != 200 {
+ t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
+ }
+
+ var body struct {
+ Metrics []struct {
+ Timestamp string `json:"timestamp"`
+ SNR *float64 `json:"snr"`
+ HeardSecsAgo *int `json:"heardSecsAgo"`
+ } `json:"metrics"`
+ }
+ if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
+ t.Fatalf("decode: %v body=%s", err, w.Body.String())
+ }
+ if len(body.Metrics) != 2 {
+ t.Fatalf("expected 2 metric points, got %d", len(body.Metrics))
+ }
+ // Oldest first.
+ if body.Metrics[0].Timestamp != "2026-07-26T12:00:00Z" || body.Metrics[1].Timestamp != "2026-07-26T14:00:00Z" {
+ t.Errorf("expected chronological order, got %+v", body.Metrics)
+ }
+ if body.Metrics[0].SNR == nil || *body.Metrics[0].SNR != 5.0 {
+ t.Errorf("first SNR = %v, want 5.0", body.Metrics[0].SNR)
+ }
+ if body.Metrics[0].HeardSecsAgo == nil || *body.Metrics[0].HeardSecsAgo != 75 {
+ t.Errorf("first HeardSecsAgo = %v, want 75", body.Metrics[0].HeardSecsAgo)
+ }
+}
+
+func TestHandleObserverNeighborMetrics_NoDataReturnsEmptyNotError(t *testing.T) {
+ srv, router := setupTestServer(t)
+ _ = srv
+
+ req := httptest.NewRequest("GET", "/api/observers/obs1/neighbors/deadbeef/metrics", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != 200 {
+ t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
+ }
+
+ var body struct {
+ Metrics []interface{} `json:"metrics"`
+ }
+ if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
+ t.Fatalf("decode: %v body=%s", err, w.Body.String())
+ }
+ if body.Metrics == nil {
+ t.Error("metrics must be an empty array, not null")
+ }
+}
+
+func TestHandleObserverNeighborMetrics_SinceFiltersOlderRows(t *testing.T) {
+ srv, router := setupTestServer(t)
+
+ pubkey := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+ if _, err := srv.db.conn.Exec(`INSERT INTO observer_neighbor_metrics (observer_id, neighbor_pubkey, timestamp, snr) VALUES (?, ?, ?, ?)`,
+ "obs1", pubkey, "2020-01-01T00:00:00Z", 1.0); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+ if _, err := srv.db.conn.Exec(`INSERT INTO observer_neighbor_metrics (observer_id, neighbor_pubkey, timestamp, snr) VALUES (?, ?, ?, ?)`,
+ "obs1", pubkey, "2026-07-26T00:00:00Z", 2.0); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/observers/obs1/neighbors/"+pubkey+"/metrics?since=2026-01-01T00:00:00Z", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != 200 {
+ t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
+ }
+
+ var body struct {
+ Metrics []struct{ Timestamp string } `json:"metrics"`
+ }
+ if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if len(body.Metrics) != 1 || body.Metrics[0].Timestamp != "2026-07-26T00:00:00Z" {
+ t.Errorf("expected only the recent row, got %+v", body.Metrics)
+ }
+}
diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go
index aaeae925..b1a37940 100644
--- a/cmd/server/openapi.go
+++ b/cmd/server/openapi.go
@@ -132,12 +132,13 @@ func routeDescriptions() map[string]routeMeta {
"GET /api/channels/{hash}/messages": {Summary: "Get channel messages", Description: "Returns messages for a specific channel.", Tag: "channels"},
// Observers
- "GET /api/observers": {Summary: "List observers", Description: "Returns all known packet observers/gateways.", Tag: "observers"},
- "GET /api/observers/{id}": {Summary: "Get observer detail", Tag: "observers"},
- "GET /api/observers/{id}/metrics": {Summary: "Get observer metrics", Description: "Packet rates, uptime, and performance metrics.", Tag: "observers"},
- "GET /api/observers/{id}/analytics": {Summary: "Get observer analytics", Tag: "observers"},
- "GET /api/observers/{id}/neighbors": {Summary: "Get an observer's direct (zero-hop) neighbors", Description: "Ground truth from the observer's own /neighbors firmware report (#1865) -- distinct from the packet-path-inferred neighbor graph. Empty `neighbors` (never null) and an empty `reportedAt` mean the observer has never sent a /neighbors report: opt-in firmware, unavailable on non-PSRAM hardware -- absence is normal, not a fault. Each entry's `scopes` is null unless the neighbor's OTA scope query responded (status=\"responded\"); `name`/`role` are null when the pubkey doesn't resolve to a known node.", Tag: "observers"},
- "GET /api/observers/metrics/summary": {Summary: "Observer metrics summary", Description: "Aggregate metrics across all observers.", Tag: "observers"},
+ "GET /api/observers": {Summary: "List observers", Description: "Returns all known packet observers/gateways.", Tag: "observers"},
+ "GET /api/observers/{id}": {Summary: "Get observer detail", Tag: "observers"},
+ "GET /api/observers/{id}/metrics": {Summary: "Get observer metrics", Description: "Packet rates, uptime, and performance metrics.", Tag: "observers"},
+ "GET /api/observers/{id}/analytics": {Summary: "Get observer analytics", Tag: "observers"},
+ "GET /api/observers/{id}/neighbors": {Summary: "Get an observer's direct (zero-hop) neighbors", Description: "Ground truth from the observer's own /neighbors firmware report (#1865) -- distinct from the packet-path-inferred neighbor graph. Empty `neighbors` (never null) and an empty `reportedAt` mean the observer has never sent a /neighbors report: opt-in firmware, unavailable on non-PSRAM hardware -- absence is normal, not a fault. Each entry's `scopes` is null unless the neighbor's OTA scope query responded (status=\"responded\"); `name`/`role` are null when the pubkey doesn't resolve to a known node. `seenViaPackets` cross-references the packet-path-inferred neighbor_edges graph: false means this firmware-confirmed neighbor has never had a resolved packet path between it and the observer, a diagnostic signal (possible coverage gap or packet loss), not itself a fault.", Tag: "observers"},
+ "GET /api/observers/{id}/neighbors/{pubkey}/metrics": {Summary: "Get SNR history for one observer<->neighbor direct-RF link", Description: "Raw (unaggregated) history of the snr/heard_secs_ago fields the observer's own /neighbors report carries per neighbor -- report volume per pair is inherently low so, unlike /api/observers/{id}/metrics, there is no resolution/downsampling. Defaults to the last 30 days.", Tag: "observers", QueryParams: []paramMeta{{Name: "since", Description: "RFC3339 lower bound (default: 30 days ago)", Type: "string"}, {Name: "until", Description: "RFC3339 upper bound (default: none)", Type: "string"}}},
+ "GET /api/observers/metrics/summary": {Summary: "Observer metrics summary", Description: "Aggregate metrics across all observers.", Tag: "observers"},
// Misc
"GET /api/resolve-hops": {Summary: "Resolve hop path", Description: "Resolves hash prefixes in a hop path to node names. Returns affinity scores and best candidates.", Tag: "nodes", QueryParams: []paramMeta{{Name: "hops", Description: "Comma-separated hop hash prefixes", Type: "string", Required: true}}},
diff --git a/cmd/server/routes.go b/cmd/server/routes.go
index ac4d1e33..bfb6be1f 100644
--- a/cmd/server/routes.go
+++ b/cmd/server/routes.go
@@ -349,6 +349,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/observers/{id}/metrics", s.handleObserverMetrics).Methods("GET")
r.HandleFunc("/api/observers/{id}/analytics", s.handleObserverAnalytics).Methods("GET")
r.HandleFunc("/api/observers/{id}/neighbors", s.handleObserverNeighbors).Methods("GET")
+ r.HandleFunc("/api/observers/{id}/neighbors/{pubkey}/metrics", s.handleObserverNeighborMetrics).Methods("GET")
r.HandleFunc("/api/observers/{id}", s.handleObserverDetail).Methods("GET")
r.HandleFunc("/api/observers", s.handleObservers).Methods("GET")
r.HandleFunc("/api/traces/{hash}", s.handleTraces).Methods("GET")
@@ -3251,6 +3252,33 @@ func (s *Server) handleObserverNeighbors(w http.ResponseWriter, r *http.Request)
})
}
+// handleObserverNeighborMetrics serves the SNR/heard_secs_ago history for
+// one observer<->neighbor direct-RF link (#1865 follow-up), for the Direct
+// Neighbors panel's per-row sparkline. Defaults to the last 30 days --
+// report volume per pair is inherently low, so this rarely needs trimming.
+func (s *Server) handleObserverNeighborMetrics(w http.ResponseWriter, r *http.Request) {
+ id := mux.Vars(r)["id"]
+ pubkey := strings.ToLower(mux.Vars(r)["pubkey"])
+
+ if s.cfg != nil && s.cfg.IsObserverBlacklisted(id) {
+ writeError(w, 404, "Observer not found")
+ return
+ }
+
+ since := r.URL.Query().Get("since")
+ until := r.URL.Query().Get("until")
+ if since == "" {
+ since = time.Now().UTC().AddDate(0, 0, -30).Format(time.RFC3339)
+ }
+
+ metrics, err := s.db.GetObserverNeighborMetrics(id, pubkey, since, until)
+ if err != nil {
+ writeError(w, 500, err.Error())
+ return
+ }
+ writeJSON(w, map[string]interface{}{"metrics": metrics})
+}
+
func (s *Server) handleObserverAnalytics(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
days := queryInt(r, "days", 7)
diff --git a/internal/dbschema/dbschema.go b/internal/dbschema/dbschema.go
index 0ff3d665..2d256596 100644
--- a/internal/dbschema/dbschema.go
+++ b/internal/dbschema/dbschema.go
@@ -100,6 +100,9 @@ func Apply(rw *sql.DB, logf Logger) error {
if err := ensureObserverNeighborsTable(rw, logf); err != nil {
return fmt.Errorf("ensure observer_neighbors: %w", err)
}
+ if err := ensureObserverNeighborMetricsTable(rw, logf); err != nil {
+ return fmt.Errorf("ensure observer_neighbor_metrics: %w", err)
+ }
// #1690: denormalized last_seen on transmissions so cold-load filters
// on effective recency rather than first-ever first_seen. The column
// add + index creation are cheap (single ALTER, indexed INTEGER
@@ -191,6 +194,7 @@ func AssertReady(ro *sql.DB) error {
// is the only writer.
mustCol("observers", "last_neighbors_report_at")
mustTable("observer_neighbors")
+ mustTable("observer_neighbor_metrics")
if len(missing) > 0 {
return fmt.Errorf("schema not migrated by ingestor; restart ingestor first. missing: %s",
@@ -795,3 +799,40 @@ func ensureObserverNeighborsTable(rw *sql.DB, logf Logger) error {
logf("[dbschema] created observer_neighbors table")
return nil
}
+
+// ensureObserverNeighborMetricsTable creates observer_neighbor_metrics: an
+// APPEND-ONLY SNR/heard_secs_ago history per observer<->neighbor pair,
+// unlike observer_neighbors' current-only snapshot. Every report is valid
+// historical data at its own timestamp regardless of arrival order, so
+// there is no ordering guard on the write side (contrast
+// ReplaceObserverNeighbors). Retention mirrors observer_metrics'
+// MetricsRetentionDays (30-day default) -- pruned by the ingestor's
+// PruneOldNeighborMetrics, not owned here.
+func ensureObserverNeighborMetricsTable(rw *sql.DB, logf Logger) error {
+ if err := ensureMigrationsTable(rw); err != nil {
+ return err
+ }
+ row := rw.QueryRow(`SELECT 1 FROM _migrations WHERE name = 'observer_neighbor_metrics_v1'`)
+ var one int
+ if err := row.Scan(&one); err == nil {
+ return nil // already applied
+ }
+ if _, err := rw.Exec(`CREATE TABLE IF NOT EXISTS observer_neighbor_metrics (
+ observer_id TEXT NOT NULL,
+ neighbor_pubkey TEXT NOT NULL,
+ timestamp TEXT NOT NULL,
+ snr REAL,
+ heard_secs_ago INTEGER,
+ PRIMARY KEY (observer_id, neighbor_pubkey, timestamp)
+ )`); err != nil {
+ return fmt.Errorf("create observer_neighbor_metrics: %w", err)
+ }
+ if _, err := rw.Exec(`CREATE INDEX IF NOT EXISTS idx_observer_neighbor_metrics_ts ON observer_neighbor_metrics(timestamp)`); err != nil {
+ return fmt.Errorf("create idx_observer_neighbor_metrics_ts: %w", err)
+ }
+ if _, err := rw.Exec(`INSERT OR IGNORE INTO _migrations (name) VALUES ('observer_neighbor_metrics_v1')`); err != nil {
+ return fmt.Errorf("record observer_neighbor_metrics_v1: %w", err)
+ }
+ logf("[dbschema] created observer_neighbor_metrics table")
+ return nil
+}
diff --git a/public/observer-detail.js b/public/observer-detail.js
index 886554b6..4bd93a82 100644
--- a/public/observer-detail.js
+++ b/public/observer-detail.js
@@ -198,15 +198,72 @@ window.ObserverDetailNaiveBanner = {
: (n.status === 'timeout'
? 'no reply'
: '—');
- return '
' + nameCell + '
' + scopeCell + '
';
+ // Cross-reference against the packet-derived neighbor_edges graph.
+ // false is a diagnostic signal (coverage gap / packet loss / just
+ // hasn't transmitted recently) -- worth noticing, but styled neutral
+ // rather than as an error since it's not necessarily a problem.
+ const evidenceCell = n.seenViaPackets
+ ? 'confirmed'
+ : 'not seen yet';
+ // Sparkline is loaded async (loadNeighborSnrSparklines) once this
+ // table is in the DOM -- placeholder id keyed by pubkey.
+ const snrCell = n.pubkey
+ ? '…'
+ : '—';
+ return '