Merge branch 'areas-meshguide-sync'

This commit is contained in:
dborup
2026-07-26 19:27:10 +02:00
12 changed files with 835 additions and 19 deletions
+61 -4
View File
@@ -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
+19 -2
View File
@@ -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
@@ -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)
}
}
+98 -1
View File
@@ -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)
+17
View File
@@ -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
@@ -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)
}
}
@@ -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)
}
}
+7 -6
View File
@@ -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}}},
+28
View File
@@ -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)
+41
View File
@@ -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
}
+60 -2
View File
@@ -198,15 +198,72 @@ window.ObserverDetailNaiveBanner = {
: (n.status === 'timeout'
? '<span class="text-muted" title="Scope query timed out">no reply</span>'
: '<span class="text-muted">—</span>');
return '<tr><td>' + nameCell + '</td><td>' + scopeCell + '</td></tr>';
// 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
? '<span class="text-muted" title="A packet path connecting this station and the observer has been resolved">confirmed</span>'
: '<span style="color:var(--text-muted)" title="Firmware reports this as a direct RF neighbor, but no packet path between the two has been resolved yet -- possible coverage gap, packet loss, or the neighbor simply hasn\'t transmitted recently.">not seen yet</span>';
// Sparkline is loaded async (loadNeighborSnrSparklines) once this
// table is in the DOM -- placeholder id keyed by pubkey.
const snrCell = n.pubkey
? '<span id="nb-spark-' + escapeHtml(n.pubkey) + '" class="text-muted" style="font-size:10px">…</span>'
: '<span class="text-muted">—</span>';
return '<tr><td>' + nameCell + '</td><td>' + scopeCell + '</td><td>' + evidenceCell + '</td><td>' + snrCell + '</td></tr>';
}).join('');
const asOf = neighborsData.reportedAt
? '<div class="text-muted" style="font-size:11px;margin-top:6px">As of ' + timeAgo(neighborsData.reportedAt) + '</div>'
: '';
return '<div class="table-fluid-wrap"><table class="data-table"><thead><tr><th scope="col">Neighbor</th><th scope="col">Configured Scope</th></tr></thead><tbody>' + rows + '</tbody></table></div>' + asOf;
return '<div class="table-fluid-wrap"><table class="data-table"><thead><tr><th scope="col">Neighbor</th><th scope="col">Configured Scope</th><th scope="col" title="Cross-referenced against the packet-derived neighbor graph">Packet Evidence</th><th scope="col" title="SNR trend from this observer\'s /neighbors reports, most recent 30 days">SNR Trend</th></tr></thead><tbody>' + rows + '</tbody></table></div>' + 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 '<svg viewBox="0 0 ' + w + ' ' + h + '" style="width:' + w + 'px;height:' + h + 'px" role="img" aria-label="SNR trend"><title>SNR trend</title><polyline points="' + pts + '" fill="none" stroke="var(--accent)" stroke-width="1.5"/></svg>';
}
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) + ' <span class="text-muted" style="font-size:10px">' + latest.toFixed(1) + ' dB</span>';
} else if (values.length === 1) {
container.outerHTML = '<span class="text-muted" style="font-size:10px">' + values[0].toFixed(1) + ' dB</span>';
} else {
container.outerHTML = '<span class="text-muted" style="font-size:10px">no data</span>';
}
} catch (e) {
if (container) container.outerHTML = '<span class="text-muted" style="font-size:10px">—</span>';
}
}
}
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) {
+142 -4
View File
@@ -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) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
})[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('<svg'));
assert.ok(html.includes('<polyline'));
});
console.log('\n=== #1865 follow-up — SNR sparkline async loader ===');
test('loads and injects a sparkline + latest value on success', async () => {
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('<svg'), 'expected a sparkline to be injected');
assert.ok(el.outerHTML.includes('8.0 dB'), 'expected the latest SNR value shown');
});
test('shows a single value (no sparkline) when only one data point exists', async () => {
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('<svg'), 'a single point should not render a sparkline');
assert.ok(el.outerHTML.includes('4.5 dB'));
});
test('shows a neutral "no data" state, not an error, when metrics is empty', async () => {
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);
});