diff --git a/cmd/ingestor/db.go b/cmd/ingestor/db.go index 47a65afa..f8ab1213 100644 --- a/cmd/ingestor/db.go +++ b/cmd/ingestor/db.go @@ -1734,6 +1734,64 @@ func (s *Store) TouchObserverNeighborsReport(observerID, reportedAt string) erro return err } +// ObserverNeighborEntry is one entry from an observer's /neighbors report, +// carrying enough to populate the observer_neighbors table. +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" +} + +// ReplaceObserverNeighbors stores the observer's CURRENT direct (zero-hop) +// neighbor set from its own firmware neighbor table -- ground truth, distinct +// from the packet-path-inferred neighbor_edges graph (#1865 follow-up, +// "Direct Neighbors" panel). Deliberately a full replace (delete + re-insert) +// rather than an upsert: a neighbor that drops off a subsequent report is a +// real signal (it's no longer a direct neighbor) and must disappear, not +// linger as stale data. +// +// Call this AFTER TouchObserverNeighborsReport in the same handler: it reuses +// the just-updated observers.last_neighbors_report_at as an out-of-order +// guard. If the given reportedAt doesn't match what's now stored there, a +// newer report already won the MAX race and this one is stale -- skip the +// replace so an older, out-of-order report can't clobber a newer snapshot. +func (s *Store) ReplaceObserverNeighbors(observerID string, neighbors []ObserverNeighborEntry, reportedAt string) error { + if observerID == "" { + return nil + } + reportedAt = normalizeReportTS(reportedAt) + if reportedAt == "" { + return nil + } + var curAt sql.NullString + row := s.db.QueryRow(`SELECT last_neighbors_report_at FROM observers WHERE id = ?`, observerID) + if row.Scan(&curAt) == nil && curAt.Valid && curAt.String != "" && curAt.String != reportedAt { + return nil + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(`DELETE FROM observer_neighbors WHERE observer_id = ?`, observerID); err != nil { + return err + } + stmt, err := tx.Prepare(`INSERT INTO observer_neighbors (observer_id, neighbor_pubkey, scopes, status, reported_at) VALUES (?, ?, ?, ?, ?)`) + if err != nil { + return err + } + defer stmt.Close() + for _, n := range neighbors { + if n.Pubkey == "" { + continue + } + if _, err := stmt.Exec(observerID, n.Pubkey, n.Scopes, n.Status, reportedAt); err != nil { + return err + } + } + return tx.Commit() +} + // 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 5fe52095..7c0e08df 100644 --- a/cmd/ingestor/main.go +++ b/cmd/ingestor/main.go @@ -1559,26 +1559,36 @@ func handleNeighborsReport(store *Store, tag string, observerID string, msg map[ } } - // neighbors[]: only status=="responded" carries usable scope evidence. + // neighbors[]: only status=="responded" carries usable scope evidence, + // but ALL entries (including timeout) are direct (zero-hop) neighbors + // per the observer's own firmware neighbor table -- collected below for + // the #1865 follow-up "Direct Neighbors" panel regardless of status. neighbors, _ := msg["neighbors"].([]interface{}) + entries := make([]ObserverNeighborEntry, 0, len(neighbors)) for _, raw := range neighbors { n, ok := raw.(map[string]interface{}) if !ok { continue } - if status, _ := n["status"].(string); status != "responded" { - continue - } pubkey, _ := n["pubkey"].(string) pubkey = strings.ToLower(pubkey) if pubkey == "" { continue } + status, _ := n["status"].(string) scopes, _ := n["scopes"].(string) - if err := store.UpdateNodeConfiguredScope(pubkey, scopes, reportedAt); err != nil { - log.Printf("MQTT [%s] neighbors scope error for %.8s: %v", tag, pubkey, err) + 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}) + } else { + entries = append(entries, ObserverNeighborEntry{Pubkey: pubkey, Status: status}) } } + if err := store.ReplaceObserverNeighbors(observerID, entries, reportedAt); err != nil { + log.Printf("MQTT [%s] neighbors replace error for observer %.8s: %v", tag, observerID, err) + } } // shouldUpdateDefaultScope returns true when the packet carries a transport diff --git a/cmd/ingestor/observer_direct_neighbors_test.go b/cmd/ingestor/observer_direct_neighbors_test.go new file mode 100644 index 00000000..216f79b8 --- /dev/null +++ b/cmd/ingestor/observer_direct_neighbors_test.go @@ -0,0 +1,143 @@ +package main + +import "testing" + +// #1865 follow-up "Direct Neighbors" panel: observer_neighbors is a +// CURRENT-ONLY snapshot of an observer's firmware-reported zero-hop +// neighbors, replaced wholesale on every /neighbors report. + +func seedObserverForNeighbors(t *testing.T, store *Store, id string) { + t.Helper() + if _, err := store.db.Exec(`INSERT INTO observers (id, name) VALUES (?, ?)`, id, "obs_"+id); err != nil { + t.Fatalf("seed observer %s: %v", id, err) + } +} + +func countObserverNeighbors(t *testing.T, store *Store, observerID string) int { + t.Helper() + var n int + if err := store.db.QueryRow(`SELECT COUNT(*) FROM observer_neighbors WHERE observer_id = ?`, observerID).Scan(&n); err != nil { + t.Fatalf("count observer_neighbors: %v", err) + } + return n +} + +func TestReplaceObserverNeighbors_Basic(t *testing.T) { + store := openNeighborsStore(t) + seedObserverForNeighbors(t, store, "obs-nb-1") + + entries := []ObserverNeighborEntry{ + {Pubkey: "aaaa000000000000000000000000000000000000000000000000000000000001", Scopes: "#dk", Status: "responded"}, + {Pubkey: "bbbb000000000000000000000000000000000000000000000000000000000002", Status: "timeout"}, + } + if err := store.ReplaceObserverNeighbors("obs-nb-1", entries, "2026-07-26T12:00:00Z"); err != nil { + t.Fatal(err) + } + if n := countObserverNeighbors(t, store, "obs-nb-1"); n != 2 { + t.Fatalf("expected 2 neighbor rows, got %d", n) + } + + var status string + if err := store.db.QueryRow(`SELECT status FROM observer_neighbors WHERE observer_id = ? AND neighbor_pubkey = ?`, + "obs-nb-1", "bbbb000000000000000000000000000000000000000000000000000000000002").Scan(&status); err != nil { + t.Fatal(err) + } + if status != "timeout" { + t.Errorf("status = %q, want 'timeout'", status) + } +} + +func TestReplaceObserverNeighbors_ShrinkingSetDropsStaleRows(t *testing.T) { + store := openNeighborsStore(t) + seedObserverForNeighbors(t, store, "obs-nb-2") + + first := []ObserverNeighborEntry{ + {Pubkey: "cccc000000000000000000000000000000000000000000000000000000000003", Status: "responded"}, + {Pubkey: "dddd000000000000000000000000000000000000000000000000000000000004", Status: "responded"}, + } + if err := store.ReplaceObserverNeighbors("obs-nb-2", first, "2026-07-26T12:00:00Z"); err != nil { + t.Fatal(err) + } + if n := countObserverNeighbors(t, store, "obs-nb-2"); n != 2 { + t.Fatalf("expected 2 rows after first report, got %d", n) + } + + // One neighbor dropped off -- the next report only carries one entry. + second := []ObserverNeighborEntry{ + {Pubkey: "cccc000000000000000000000000000000000000000000000000000000000003", Status: "responded"}, + } + if err := store.ReplaceObserverNeighbors("obs-nb-2", second, "2026-07-26T13:00:00Z"); err != nil { + t.Fatal(err) + } + if n := countObserverNeighbors(t, store, "obs-nb-2"); n != 1 { + t.Fatalf("expected 1 row after shrinking report, got %d (stale neighbor not dropped)", n) + } +} + +func TestReplaceObserverNeighbors_OutOfOrderReportIsIgnored(t *testing.T) { + store := openNeighborsStore(t) + seedObserverForNeighbors(t, store, "obs-nb-3") + + newer := []ObserverNeighborEntry{ + {Pubkey: "eeee000000000000000000000000000000000000000000000000000000000005", Status: "responded"}, + } + // TouchObserverNeighborsReport must run first in production (main.go + // sequences it that way) -- replicate that here since Replace's guard + // reads the post-touch value. + if err := store.TouchObserverNeighborsReport("obs-nb-3", "2026-07-26T14:00:00Z"); err != nil { + t.Fatal(err) + } + if err := store.ReplaceObserverNeighbors("obs-nb-3", newer, "2026-07-26T14:00:00Z"); err != nil { + t.Fatal(err) + } + + // An older, out-of-order report arrives after the newer one. Touch is a + // no-op (MAX keeps the newer value); Replace must also refuse to + // overwrite the newer neighbor snapshot with stale data. + older := []ObserverNeighborEntry{ + {Pubkey: "ffff000000000000000000000000000000000000000000000000000000000006", Status: "responded"}, + } + if err := store.TouchObserverNeighborsReport("obs-nb-3", "2026-07-26T10:00:00Z"); err != nil { + t.Fatal(err) + } + if err := store.ReplaceObserverNeighbors("obs-nb-3", older, "2026-07-26T10:00:00Z"); err != nil { + t.Fatal(err) + } + + var pk string + if err := store.db.QueryRow(`SELECT neighbor_pubkey FROM observer_neighbors WHERE observer_id = ?`, "obs-nb-3").Scan(&pk); err != nil { + t.Fatal(err) + } + if pk != "eeee000000000000000000000000000000000000000000000000000000000005" { + t.Errorf("neighbor_pubkey = %q, want the newer report's entry (stale report must not win)", pk) + } + if n := countObserverNeighbors(t, store, "obs-nb-3"); n != 1 { + t.Errorf("expected exactly 1 row, got %d", n) + } +} + +func TestHandleNeighborsReport_PopulatesDirectNeighbors(t *testing.T) { + store := openNeighborsStore(t) + seedObserverForNeighbors(t, store, "obs-nb-4") + + report := map[string]interface{}{ + "timestamp": "2026-07-26T15:00:00Z", + "neighbors": []interface{}{ + map[string]interface{}{"pubkey": "1111000000000000000000000000000000000000000000000000000000000011", "scopes": "dk,eu", "status": "responded"}, + map[string]interface{}{"pubkey": "2222000000000000000000000000000000000000000000000000000000000022", "scopes": "", "status": "timeout"}, + }, + } + handleNeighborsReport(store, "test", "obs-nb-4", report) + + if n := countObserverNeighbors(t, store, "obs-nb-4"); n != 2 { + t.Fatalf("expected 2 direct-neighbor rows, got %d", n) + } + var scopes string + if err := store.db.QueryRow(`SELECT scopes FROM observer_neighbors WHERE observer_id = ? AND neighbor_pubkey = ?`, + "obs-nb-4", "1111000000000000000000000000000000000000000000000000000000000011").Scan(&scopes); err != nil { + t.Fatal(err) + } + if scopes != "#dk,#eu" { + t.Errorf("scopes = %q, want normalized '#dk,#eu'", scopes) + } +} diff --git a/cmd/server/db.go b/cmd/server/db.go index 96a5ed2c..48500dd5 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -1389,6 +1389,63 @@ func (db *DB) GetObserverByID(id string) (*Observer, error) { return &o, nil } +// ObserverNeighbor is one row of an observer's CURRENT direct (zero-hop) +// neighbor set (#1865 follow-up "Direct Neighbors" panel) -- ground truth +// from the observer's own firmware /neighbors report, joined against nodes +// for a display name/role when the pubkey resolves to a known node. +type ObserverNeighbor struct { + Pubkey string `json:"pubkey"` + Name *string `json:"name"` + Role *string `json:"role"` + Scopes *string `json:"scopes"` + Status string `json:"status"` +} + +// 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). +func (db *DB) GetObserverNeighbors(observerID string) ([]ObserverNeighbor, string, error) { + rows, err := db.conn.Query(` + SELECT on2.neighbor_pubkey, on2.scopes, on2.status, on2.reported_at, n.name, n.role + FROM observer_neighbors on2 + LEFT JOIN nodes n ON n.public_key = on2.neighbor_pubkey + WHERE on2.observer_id = ? + ORDER BY on2.neighbor_pubkey`, observerID) + if err != nil { + return nil, "", err + } + defer rows.Close() + + result := []ObserverNeighbor{} + var reportedAt string + for rows.Next() { + var n ObserverNeighbor + var scopes, reportedAtCol, name, role sql.NullString + if err := rows.Scan(&n.Pubkey, &scopes, &n.Status, &reportedAtCol, &name, &role); err != nil { + return nil, "", err + } + if scopes.Valid && scopes.String != "" { + s := scopes.String + n.Scopes = &s + } + if name.Valid { + s := name.String + n.Name = &s + } + if role.Valid { + s := role.String + n.Role = &s + } + if reportedAtCol.Valid { + reportedAt = reportedAtCol.String + } + result = append(result, n) + } + return result, reportedAt, 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 a434f091..3a3c244d 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -103,6 +103,15 @@ func setupTestDB(t *testing.T) *DB { CREATE INDEX IF NOT EXISTS idx_observer_metrics_timestamp ON observer_metrics(timestamp); + CREATE TABLE observer_neighbors ( + observer_id TEXT NOT NULL, + neighbor_pubkey TEXT NOT NULL, + scopes TEXT, + status TEXT NOT NULL, + reported_at TEXT, + PRIMARY KEY (observer_id, neighbor_pubkey) + ); + -- 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 new file mode 100644 index 00000000..28f88330 --- /dev/null +++ b/cmd/server/observer_direct_neighbors_endpoint_test.go @@ -0,0 +1,130 @@ +package main + +// #1865 follow-up "Direct Neighbors" panel: GET /api/observers/{id}/neighbors +// serves the observer's own firmware-reported zero-hop neighbor set. + +import ( + "encoding/json" + "net/http/httptest" + "testing" +) + +func TestHandleObserverNeighbors_ReturnsJoinedNames(t *testing.T) { + srv, router := setupTestServer(t) + + if _, err := srv.db.conn.Exec(`INSERT INTO observer_neighbors (observer_id, neighbor_pubkey, scopes, status, reported_at) VALUES (?, ?, ?, ?, ?)`, + "obs1", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "#dk", "responded", "2026-07-26T10:00:00Z"); err != nil { + t.Fatalf("seed observer_neighbors: %v", err) + } + if _, err := srv.db.conn.Exec(`INSERT INTO nodes (public_key, name, role) VALUES (?, ?, ?)`, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "Neighbor Node", "repeater"); err != nil { + t.Fatalf("seed node: %v", err) + } + + req := httptest.NewRequest("GET", "/api/observers/obs1/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"` + Name *string `json:"name"` + Role *string `json:"role"` + Scopes *string `json:"scopes"` + Status string `json:"status"` + } `json:"neighbors"` + ReportedAt string `json:"reportedAt"` + } + 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 { + t.Fatalf("expected 1 neighbor, got %d", len(body.Neighbors)) + } + n := body.Neighbors[0] + if n.Name == nil || *n.Name != "Neighbor Node" { + t.Errorf("Name = %v, want 'Neighbor Node' (join against nodes failed)", n.Name) + } + if n.Role == nil || *n.Role != "repeater" { + t.Errorf("Role = %v, want 'repeater'", n.Role) + } + if n.Scopes == nil || *n.Scopes != "#dk" { + t.Errorf("Scopes = %v, want '#dk'", n.Scopes) + } + if n.Status != "responded" { + t.Errorf("Status = %q, want 'responded'", n.Status) + } + if body.ReportedAt != "2026-07-26T10:00:00Z" { + t.Errorf("ReportedAt = %q, want '2026-07-26T10:00:00Z'", body.ReportedAt) + } +} + +func TestHandleObserverNeighbors_NeverReportedReturnsEmptyNotError(t *testing.T) { + srv, router := setupTestServer(t) + _ = srv + + req := httptest.NewRequest("GET", "/api/observers/obs1/neighbors", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("expected 200 (absence is not a fault), got %d body=%s", w.Code, w.Body.String()) + } + + var body struct { + Neighbors []interface{} `json:"neighbors"` + ReportedAt string `json:"reportedAt"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v body=%s", err, w.Body.String()) + } + if body.Neighbors == nil { + t.Error("neighbors must be an empty array, not null, when the observer never reported") + } + if len(body.Neighbors) != 0 { + t.Errorf("expected 0 neighbors, got %d", len(body.Neighbors)) + } +} + +func TestHandleObserverNeighbors_UnresolvedPubkeyHasNilNameAndRole(t *testing.T) { + srv, router := setupTestServer(t) + + if _, err := srv.db.conn.Exec(`INSERT INTO observer_neighbors (observer_id, neighbor_pubkey, scopes, status, reported_at) VALUES (?, ?, ?, ?, ?)`, + "obs1", "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "", "timeout", "2026-07-26T11:00:00Z"); err != nil { + t.Fatalf("seed observer_neighbors: %v", err) + } + + req := httptest.NewRequest("GET", "/api/observers/obs1/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"` + Name *string `json:"name"` + Scopes *string `json:"scopes"` + Status string `json:"status"` + } `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 { + t.Fatalf("expected 1 neighbor, got %d", len(body.Neighbors)) + } + n := body.Neighbors[0] + if n.Name != nil { + t.Errorf("Name = %v, want nil for a pubkey with no matching node", n.Name) + } + if n.Scopes != nil { + t.Errorf("Scopes = %v, want nil for a timeout entry", n.Scopes) + } + if n.Status != "timeout" { + t.Errorf("Status = %q, want 'timeout'", n.Status) + } +} diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 26460b80..aaeae925 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -136,6 +136,7 @@ func routeDescriptions() map[string]routeMeta { "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"}, // Misc diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 011416e3..ac4d1e33 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -348,6 +348,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) { r.HandleFunc("/api/observers/metrics/summary", s.handleMetricsSummary).Methods("GET") 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}", s.handleObserverDetail).Methods("GET") r.HandleFunc("/api/observers", s.handleObservers).Methods("GET") r.HandleFunc("/api/traces/{hash}", s.handleTraces).Methods("GET") @@ -3225,6 +3226,31 @@ func (s *Server) handleObserverDetail(w http.ResponseWriter, r *http.Request) { }()) } +// handleObserverNeighbors serves the #1865 follow-up "Direct Neighbors" +// panel: the observer's own firmware-reported zero-hop neighbor set, ground +// truth from /neighbors distinct from the packet-path-inferred +// neighbor_edges graph. Empty/never-reported is a normal 200 with an empty +// list -- not an error (the feature is opt-in and hardware-gated; absence +// must not read as a fault, per cwichura's request on PR #1867). +func (s *Server) handleObserverNeighbors(w http.ResponseWriter, r *http.Request) { + id := mux.Vars(r)["id"] + + if s.cfg != nil && s.cfg.IsObserverBlacklisted(id) { + writeError(w, 404, "Observer not found") + return + } + + neighbors, reportedAt, err := s.db.GetObserverNeighbors(id) + if err != nil { + writeError(w, 500, err.Error()) + return + } + writeJSON(w, map[string]interface{}{ + "neighbors": neighbors, + "reportedAt": reportedAt, + }) +} + 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 2612ba8f..0ff3d665 100644 --- a/internal/dbschema/dbschema.go +++ b/internal/dbschema/dbschema.go @@ -97,6 +97,9 @@ func Apply(rw *sql.DB, logf Logger) error { if err := ensureLastNeighborsReportAtColumn(rw, logf); err != nil { return fmt.Errorf("ensure observers.last_neighbors_report_at: %w", err) } + if err := ensureObserverNeighborsTable(rw, logf); err != nil { + return fmt.Errorf("ensure observer_neighbors: %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 @@ -187,6 +190,7 @@ func AssertReady(ro *sql.DB) error { // firmware feature). Owned by ingestor — TouchObserverNeighborsReport // is the only writer. mustCol("observers", "last_neighbors_report_at") + mustTable("observer_neighbors") if len(missing) > 0 { return fmt.Errorf("schema not migrated by ingestor; restart ingestor first. missing: %s", @@ -755,3 +759,39 @@ func ensurePingTriggersTable(rw *sql.DB, logf Logger) error { logf("[dbschema] created ping_triggers table") return nil } + +// ensureObserverNeighborsTable creates observer_neighbors for the #1865 +// follow-up "Direct Neighbors" panel: the observer's own firmware-reported +// zero-hop neighbor table (ground truth from /neighbors, distinct from the +// packet-path-inferred neighbor_edges graph). Deliberately a CURRENT-ONLY +// snapshot, not history -- the ingestor's ReplaceObserverNeighbors deletes +// and re-inserts the full set on every report, so a neighbor that drops off +// naturally disappears rather than accumulating stale rows forever. +func ensureObserverNeighborsTable(rw *sql.DB, logf Logger) error { + if err := ensureMigrationsTable(rw); err != nil { + return err + } + row := rw.QueryRow(`SELECT 1 FROM _migrations WHERE name = 'observer_neighbors_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_neighbors ( + observer_id TEXT NOT NULL, + neighbor_pubkey TEXT NOT NULL, + scopes TEXT, + status TEXT NOT NULL, + reported_at TEXT, + PRIMARY KEY (observer_id, neighbor_pubkey) + )`); err != nil { + return fmt.Errorf("create observer_neighbors: %w", err) + } + if _, err := rw.Exec(`CREATE INDEX IF NOT EXISTS idx_observer_neighbors_observer ON observer_neighbors(observer_id)`); err != nil { + return fmt.Errorf("create idx_observer_neighbors_observer: %w", err) + } + if _, err := rw.Exec(`INSERT OR IGNORE INTO _migrations (name) VALUES ('observer_neighbors_v1')`); err != nil { + return fmt.Errorf("record observer_neighbors_v1: %w", err) + } + logf("[dbschema] created observer_neighbors table") + return nil +} diff --git a/public/observer-detail.js b/public/observer-detail.js index c83017d2..886554b6 100644 --- a/public/observer-detail.js +++ b/public/observer-detail.js @@ -143,17 +143,18 @@ window.ObserverDetailNaiveBanner = { try { destroyCharts(); chartDefaults(); - const [obs, analytics, obsSkewArr] = await Promise.all([ + const [obs, analytics, obsSkewArr, neighborsData] = await Promise.all([ api('/observers/' + encodeURIComponent(currentId)), api('/observers/' + encodeURIComponent(currentId) + '/analytics?days=' + currentDays), api('/observers/clock-skew', { ttl: 30000 }).catch(function() { return []; }), + api('/observers/' + encodeURIComponent(currentId) + '/neighbors').catch(function() { return { neighbors: [], reportedAt: '' }; }), ]); // Find this observer's calibration data. var obsSkew = null; (Array.isArray(obsSkewArr) ? obsSkewArr : []).forEach(function(s) { if (s && s.observerID === currentId) obsSkew = s; }); - renderDetail(obs, analytics, obsSkew); + renderDetail(obs, analytics, obsSkew, neighborsData); } catch (e) { // SECURITY (OBS-2, PR #1539): use textContent for error messages. // Error.message is JS-controlled and shouldn't normally carry attacker @@ -167,7 +168,46 @@ window.ObserverDetailNaiveBanner = { } } - function renderDetail(obs, analytics, obsSkew) { + function directNeighborRoleIcon(role) { + switch (role) { + case 'repeater': return ''; + case 'room': return ''; + case 'sensor': return ''; + default: return ''; + } + } + + // #1865 follow-up (cwichura, PR #1867): renders the observer's own + // firmware-reported zero-hop neighbor set -- ground truth, distinct from + // the packet-path-inferred neighbor graph elsewhere in the app. Absence + // is normal (opt-in firmware, unavailable on non-PSRAM hardware) and + // renders a plain explanatory note, never an error/warning treatment. + function renderDirectNeighbors(neighborsData) { + const neighbors = (neighborsData && Array.isArray(neighborsData.neighbors)) ? neighborsData.neighbors : []; + if (neighbors.length === 0) { + return '
| Neighbor | Configured Scope |
|---|