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 '' + nameCell + '' + scopeCell + '' + evidenceCell + '' + snrCell + ''; }).join(''); const asOf = neighborsData.reportedAt ? '
As of ' + timeAgo(neighborsData.reportedAt) + '
' : ''; - return '
' + rows + '
NeighborConfigured Scope
' + asOf; + return '
' + rows + '
NeighborConfigured ScopePacket EvidenceSNR Trend
' + asOf; } window.renderDirectNeighbors = renderDirectNeighbors; + // #1865 follow-up: lightweight inline-SVG sparkline, same technique as + // the RF Health tab's rfNFSparkline (public/analytics.js) -- no Chart.js + // overhead for a tiny per-row indicator. Unlike noise floor, higher SNR + // is better, so no axis inversion. + function neighborSnrSparkline(values, w, h) { + if (!values.length) return ''; + const min = Math.min.apply(null, values); + const max = Math.max.apply(null, values); + const range = max - min || 1; + const pts = values.map(function(v, i) { + const x = (i / Math.max(values.length - 1, 1)) * w; + const y = h - 2 - ((v - min) / range) * (h - 4); + return x.toFixed(1) + ',' + y.toFixed(1); + }).join(' '); + return 'SNR trend'; + } + window.neighborSnrSparkline = neighborSnrSparkline; + + // Fetched per-row, after the Direct Neighbors table is in the DOM -- + // mirrors the RF Health grid's loadRFSparkline pattern (async, non-fatal + // on failure, since the sparkline is a nice-to-have, not core content). + async function loadNeighborSnrSparklines(observerId, neighborsData) { + const neighbors = (neighborsData && Array.isArray(neighborsData.neighbors)) ? neighborsData.neighbors : []; + for (const n of neighbors) { + if (!n.pubkey) continue; + const container = document.getElementById('nb-spark-' + n.pubkey); + if (!container) continue; + try { + const data = await api('/observers/' + encodeURIComponent(observerId) + '/neighbors/' + encodeURIComponent(n.pubkey) + '/metrics'); + const values = (data.metrics || []).map(function(m) { return m.snr; }).filter(function(v) { return v != null; }); + if (values.length > 1) { + const latest = values[values.length - 1]; + container.outerHTML = neighborSnrSparkline(values, 80, 20) + ' ' + latest.toFixed(1) + ' dB'; + } else if (values.length === 1) { + container.outerHTML = '' + values[0].toFixed(1) + ' dB'; + } else { + container.outerHTML = 'no data'; + } + } catch (e) { + if (container) container.outerHTML = '—'; + } + } + } + window.loadNeighborSnrSparklines = loadNeighborSnrSparklines; + function renderDetail(obs, analytics, obsSkew, neighborsData) { const el = document.getElementById('obsDetailContent'); if (!el) return; @@ -374,6 +431,7 @@ window.ObserverDetailNaiveBanner = { if (analytics.recentPackets) { renderRecentPackets(analytics.recentPackets); } + loadNeighborSnrSparklines(currentId, neighborsData); } function renderTimelineChart(timeline) { diff --git a/test-observer-direct-neighbors-panel.js b/test-observer-direct-neighbors-panel.js index 9a0fcfe3..dbaaba96 100644 --- a/test-observer-direct-neighbors-panel.js +++ b/test-observer-direct-neighbors-panel.js @@ -11,9 +11,21 @@ const fs = require('fs'); const assert = require('assert'); let passed = 0, failed = 0; +const pending = []; function test(name, fn) { - try { fn(); passed++; console.log(` ✅ ${name}`); } - catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); } + try { + const result = fn(); + if (result && typeof result.then === 'function') { + // Async test (the sparkline loader tests) -- defer accounting until + // it settles; the final summary waits on `pending` before printing. + pending.push(result.then( + () => { passed++; console.log(` ✅ ${name}`); }, + (e) => { failed++; console.log(` ❌ ${name}: ${e.message}`); } + )); + } else { + passed++; console.log(` ✅ ${name}`); + } + } catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); } } function makeCtx() { @@ -44,6 +56,39 @@ function makeCtx() { return ctx; } +// Richer sandbox for the async sparkline loader: needs a mockable api() +// and a document.getElementById that returns a settable-outerHTML stub. +function makeLoaderCtx(apiImpl, elementsById) { + const ctx = { + window: { addEventListener: () => {}, dispatchEvent: () => {} }, + document: { + readyState: 'complete', + createElement: () => ({ id: '', textContent: '', innerHTML: '' }), + head: { appendChild: () => {} }, + getElementById: (id) => elementsById[id] || null, + addEventListener: () => {}, + querySelectorAll: () => [], + querySelector: () => null, + }, + console, Date, Math, Array, Object, String, Number, Boolean, JSON, Promise, + setInterval: () => 0, clearInterval: () => {}, + setTimeout: (fn) => { try { fn(); } catch {} return 0; }, + encodeURIComponent, decodeURIComponent, + api: apiImpl, + }; + ctx.registerPage = () => {}; + ctx.timeAgo = (iso) => 'TIME_AGO(' + iso + ')'; + ctx.escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + })[c]); + ctx.Chart = function () { return { destroy() {} }; }; + vm.createContext(ctx); + return ctx; +} +function mockElement() { + return { _outerHTML: '', set outerHTML(v) { this._outerHTML = v; }, get outerHTML() { return this._outerHTML; } }; +} + console.log('\n=== #1865 follow-up — Observer detail "Direct Neighbors" panel ==='); const ctx = makeCtx(); @@ -88,5 +133,98 @@ test('an unresolved pubkey (no name) renders truncated, unlinked', () => { assert.ok(/no reply/.test(html), 'expected the timeout "no reply" label'); }); -console.log(`\n${passed} passed, ${failed} failed\n`); -if (failed > 0) process.exit(1); +test('seenViaPackets=true renders "confirmed", not an error/warning treatment', () => { + const html = ctx.window.renderDirectNeighbors({ + neighbors: [{ pubkey: 'abc123', name: 'Repeater A', role: 'repeater', scopes: '#dk', status: 'responded', seenViaPackets: true }], + reportedAt: '', + }); + assert.ok(/confirmed/.test(html)); + assert.ok(!/ph-warning/.test(html)); +}); + +test('seenViaPackets=false surfaces the coverage-gap diagnostic, styled neutrally', () => { + const html = ctx.window.renderDirectNeighbors({ + neighbors: [{ pubkey: 'abc123', name: 'Repeater A', role: 'repeater', scopes: '#dk', status: 'responded', seenViaPackets: false }], + reportedAt: '', + }); + assert.ok(/not seen yet/.test(html)); + assert.ok(/coverage gap/.test(html), 'expected the explanatory tooltip'); + assert.ok(!/ph-warning/.test(html), 'must not use the warning icon treatment'); +}); + +test('renders a placeholder span per row for the async SNR sparkline loader', () => { + const html = ctx.window.renderDirectNeighbors({ + neighbors: [{ pubkey: 'abc123', name: 'Repeater A', role: 'repeater', scopes: '#dk', status: 'responded' }], + reportedAt: '', + }); + assert.ok(html.includes('id="nb-spark-abc123"'), 'expected a placeholder span keyed by pubkey'); +}); + +console.log('\n=== #1865 follow-up — SNR sparkline (pure helper) ==='); + +test('window.neighborSnrSparkline exists', () => { + assert.strictEqual(typeof ctx.window.neighborSnrSparkline, 'function'); +}); + +test('empty data returns empty string', () => { + assert.strictEqual(ctx.window.neighborSnrSparkline([], 80, 20), ''); +}); + +test('renders an SVG polyline for 2+ points', () => { + const html = ctx.window.neighborSnrSparkline([1, 5, 3], 80, 20); + assert.ok(html.includes(' { + const el = mockElement(); + const apiCtx = makeLoaderCtx( + () => Promise.resolve({ metrics: [{ timestamp: 't1', snr: 5 }, { timestamp: 't2', snr: 8 }] }), + { 'nb-spark-abc123': el } + ); + vm.runInContext(fs.readFileSync('public/observer-detail.js', 'utf8'), apiCtx); + await apiCtx.window.loadNeighborSnrSparklines('obs1', { neighbors: [{ pubkey: 'abc123' }] }); + assert.ok(el.outerHTML.includes(' { + const el = mockElement(); + const apiCtx = makeLoaderCtx( + () => Promise.resolve({ metrics: [{ timestamp: 't1', snr: 4.5 }] }), + { 'nb-spark-abc123': el } + ); + vm.runInContext(fs.readFileSync('public/observer-detail.js', 'utf8'), apiCtx); + await apiCtx.window.loadNeighborSnrSparklines('obs1', { neighbors: [{ pubkey: 'abc123' }] }); + assert.ok(!el.outerHTML.includes(' { + const el = mockElement(); + const apiCtx = makeLoaderCtx( + () => Promise.resolve({ metrics: [] }), + { 'nb-spark-abc123': el } + ); + vm.runInContext(fs.readFileSync('public/observer-detail.js', 'utf8'), apiCtx); + await apiCtx.window.loadNeighborSnrSparklines('obs1', { neighbors: [{ pubkey: 'abc123' }] }); + assert.ok(/no data/.test(el.outerHTML)); +}); + +test('a failed fetch degrades to a neutral dash, not a thrown error', async () => { + const el = mockElement(); + const apiCtx = makeLoaderCtx( + () => Promise.reject(new Error('network error')), + { 'nb-spark-abc123': el } + ); + vm.runInContext(fs.readFileSync('public/observer-detail.js', 'utf8'), apiCtx); + await apiCtx.window.loadNeighborSnrSparklines('obs1', { neighbors: [{ pubkey: 'abc123' }] }); + assert.ok(el.outerHTML.length > 0, 'expected a fallback rendered, not left blank/thrown'); +}); + +Promise.all(pending).then(() => { + console.log(`\n${passed} passed, ${failed} failed\n`); + if (failed > 0) process.exit(1); +});