From a4400cc19e16fe35f404234170c04121db66f86d Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 28 Jul 2026 06:06:29 +0200 Subject: [PATCH 1/2] feat: Observer Neighbors tool -- network-wide list of every observer's reported direct neighbors dborup wanted a single place to see all direct neighbors observed across every observer, rather than clicking into each one individually. Discussed placement -- landed on Tools, matching the existing Path Inspector/Trace Viewer pattern. Backend: GetAllObserverNeighbors flattens every observer_neighbors row network-wide, joined with observer/neighbor display names, cross-referenced against the packet-derived neighbor_edges graph exactly like the per-observer endpoint (memoized per observer_id to avoid redundant queries). Reads the full result set into memory before running any packetGraphNeighbors lookups -- issuing a query per row while the main cursor is still open is the single-connection-pool deadlock class from earlier this session. New GET /api/observers/neighbors, registered before /api/observers/{id} since both are 3-segment patterns and gorilla/mux matches by registration order. Blacklisted observers excluded. Frontend: new Tools > Observer Neighbors page (observer-neighbors-tool.js), sortable by any column + a client-side observer/neighbor name filter, reusing the col-scope-list wrap fix from the per-observer panel and the same row semantics (unresolved pubkey truncated/unlinked, "no reply" for scope-query timeouts, packet-evidence confirmed/not-seen-yet). Co-Authored-By: Claude Sonnet 5 --- cmd/server/db.go | 103 +++++++++ .../observer_all_neighbors_endpoint_test.go | 203 ++++++++++++++++++ cmd/server/openapi.go | 25 +++ cmd/server/routes.go | 30 +++ public/app.js | 6 +- public/index.html | 1 + public/observer-neighbors-tool.js | 179 +++++++++++++++ test-all.sh | 1 + test-observer-neighbors-tool.js | 165 ++++++++++++++ 9 files changed, 712 insertions(+), 1 deletion(-) create mode 100644 cmd/server/observer_all_neighbors_endpoint_test.go create mode 100644 public/observer-neighbors-tool.js create mode 100644 test-observer-neighbors-tool.js diff --git a/cmd/server/db.go b/cmd/server/db.go index 38a98819..db74bb91 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -1565,6 +1565,109 @@ func (db *DB) GetObserverNeighbors(observerID string) ([]ObserverNeighbor, strin return result, reportedAt, rows.Err() } +// AllObserverNeighborsEntry is one row of the network-wide "every +// observer's reported direct neighbors" listing (Tools > Observer +// Neighbors), flattening ObserverNeighbor with which observer it came +// from -- dborup asked for a single place to see this across ALL +// observers, distinct from the per-observer Direct Neighbors panel this +// reuses the same fields/semantics from. +type AllObserverNeighborsEntry struct { + ObserverID string `json:"observerId"` + ObserverName *string `json:"observerName"` + ObserverIATA *string `json:"observerIata"` + NeighborPubkey string `json:"neighborPubkey"` + NeighborName *string `json:"neighborName"` + NeighborRole *string `json:"neighborRole"` + Scopes *string `json:"scopes"` + Status string `json:"status"` + SeenViaPackets bool `json:"seenViaPackets"` + ReportedAt string `json:"reportedAt"` +} + +// GetAllObserverNeighbors returns every observer_neighbors row network-wide, +// joined with observer/neighbor display names and cross-referenced against +// the packet-derived neighbor_edges graph exactly like GetObserverNeighbors +// does per-observer. Reads the whole result set into memory FIRST and closes +// that cursor before running any packetGraphNeighbors lookups -- issuing a +// second query per row while the first cursor is still open is the +// single-connection-pool deadlock class documented on schemaFlag; memoizing +// per distinct observer_id (mesh has few observers, many neighbor rows each) +// also avoids redundant repeat queries for the same observer. +func (db *DB) GetAllObserverNeighbors() ([]AllObserverNeighborsEntry, error) { + rows, err := db.conn.Query(` + SELECT on2.observer_id, obs.name, obs.iata, on2.neighbor_pubkey, n.name, n.role, on2.scopes, on2.status, on2.reported_at + FROM observer_neighbors on2 + LEFT JOIN observers obs ON obs.id = on2.observer_id + LEFT JOIN nodes n ON n.public_key = on2.neighbor_pubkey + ORDER BY on2.observer_id, on2.neighbor_pubkey`) + if err != nil { + return nil, err + } + type rawRow struct { + observerID string + observerName, observerIATA sql.NullString + neighborPubkey string + neighborName, neighborRole, scopes, reportedAt sql.NullString + status string + } + var raws []rawRow + for rows.Next() { + var r rawRow + if err := rows.Scan(&r.observerID, &r.observerName, &r.observerIATA, &r.neighborPubkey, &r.neighborName, &r.neighborRole, &r.scopes, &r.status, &r.reportedAt); err != nil { + rows.Close() + return nil, err + } + raws = append(raws, r) + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, err + } + rows.Close() + + packetNeighborsCache := make(map[string]map[string]bool) + result := make([]AllObserverNeighborsEntry, 0, len(raws)) + for _, r := range raws { + observerLower := strings.ToLower(r.observerID) + set, cached := packetNeighborsCache[observerLower] + if !cached { + set, _ = db.packetGraphNeighbors(observerLower) + packetNeighborsCache[observerLower] = set + } + entry := AllObserverNeighborsEntry{ + ObserverID: r.observerID, + NeighborPubkey: r.neighborPubkey, + Status: r.status, + SeenViaPackets: set[r.neighborPubkey], + } + if r.observerName.Valid { + s := r.observerName.String + entry.ObserverName = &s + } + if r.observerIATA.Valid { + s := r.observerIATA.String + entry.ObserverIATA = &s + } + if r.neighborName.Valid { + s := r.neighborName.String + entry.NeighborName = &s + } + if r.neighborRole.Valid { + s := r.neighborRole.String + entry.NeighborRole = &s + } + if r.scopes.Valid && r.scopes.String != "" { + s := r.scopes.String + entry.Scopes = &s + } + if r.reportedAt.Valid { + entry.ReportedAt = r.reportedAt.String + } + result = append(result, entry) + } + return result, nil +} + // 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 diff --git a/cmd/server/observer_all_neighbors_endpoint_test.go b/cmd/server/observer_all_neighbors_endpoint_test.go new file mode 100644 index 00000000..7d2016fc --- /dev/null +++ b/cmd/server/observer_all_neighbors_endpoint_test.go @@ -0,0 +1,203 @@ +package main + +// Tools > Observer Neighbors: GET /api/observers/neighbors flattens every +// observer's reported direct-neighbor set into one network-wide list, +// requested by dborup as a single place to see this instead of clicking +// into each observer individually. + +import ( + "encoding/json" + "net/http/httptest" + "testing" +) + +func TestHandleAllObserverNeighbors_JoinsAcrossMultipleObservers(t *testing.T) { + srv, router := setupTestServer(t) + + // obs1 ("Observer One", "SJC") and obs2 ("Observer Two", "SFO") are + // already seeded by setupTestServer's shared fixture (seedTestData). + if _, err := srv.db.conn.Exec(`INSERT INTO nodes (public_key, name, role) VALUES (?, 'Neighbor A', 'repeater')`, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); err != nil { + t.Fatalf("seed node: %v", err) + } + if _, err := srv.db.conn.Exec(`INSERT INTO observer_neighbors (observer_id, neighbor_pubkey, scopes, status, reported_at) VALUES + ('obs1', ?, '#dk', 'responded', '2026-07-28T10:00:00Z'), + ('obs2', ?, '', 'timeout', '2026-07-28T11:00:00Z')`, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); err != nil { + t.Fatalf("seed observer_neighbors: %v", err) + } + + req := httptest.NewRequest("GET", "/api/observers/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 { + ObserverID string `json:"observerId"` + ObserverName *string `json:"observerName"` + ObserverIATA *string `json:"observerIata"` + NeighborPubkey string `json:"neighborPubkey"` + NeighborName *string `json:"neighborName"` + NeighborRole *string `json:"neighborRole"` + Scopes *string `json:"scopes"` + Status string `json:"status"` + ReportedAt string `json:"reportedAt"` + } `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) != 2 { + t.Fatalf("expected 2 rows across both observers, got %d: %+v", len(body.Neighbors), body.Neighbors) + } + + byObserver := map[string]int{} + for _, n := range body.Neighbors { + byObserver[n.ObserverID]++ + if n.ObserverID == "obs1" { + if n.ObserverName == nil || *n.ObserverName != "Observer One" { + t.Errorf("obs1 row ObserverName = %v, want 'Observer One'", n.ObserverName) + } + if n.ObserverIATA == nil || *n.ObserverIATA != "SJC" { + t.Errorf("obs1 row ObserverIATA = %v, want 'SJC'", n.ObserverIATA) + } + if n.NeighborName == nil || *n.NeighborName != "Neighbor A" { + t.Errorf("obs1 row NeighborName = %v, want 'Neighbor A' (join against nodes failed)", n.NeighborName) + } + if n.NeighborRole == nil || *n.NeighborRole != "repeater" { + t.Errorf("obs1 row NeighborRole = %v, want 'repeater'", n.NeighborRole) + } + if n.Scopes == nil || *n.Scopes != "#dk" { + t.Errorf("obs1 row Scopes = %v, want '#dk'", n.Scopes) + } + if n.ReportedAt != "2026-07-28T10:00:00Z" { + t.Errorf("obs1 row ReportedAt = %q, want '2026-07-28T10:00:00Z'", n.ReportedAt) + } + } + if n.ObserverID == "obs2" { + if n.NeighborName != nil { + t.Errorf("obs2 row NeighborName = %v, want nil (pubkey doesn't resolve to a known node)", n.NeighborName) + } + if n.Scopes != nil { + t.Errorf("obs2 row Scopes = %v, want nil (timeout entry)", n.Scopes) + } + if n.Status != "timeout" { + t.Errorf("obs2 row Status = %q, want 'timeout'", n.Status) + } + } + } + if byObserver["obs1"] != 1 || byObserver["obs2"] != 1 { + t.Errorf("byObserver = %+v, want exactly 1 row each for obs1 and obs2", byObserver) + } +} + +func TestHandleAllObserverNeighbors_EmptyReturnsEmptyArrayNotError(t *testing.T) { + _, router := setupTestServer(t) + + req := httptest.NewRequest("GET", "/api/observers/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"` + } + 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 no observer has ever reported") + } + if len(body.Neighbors) != 0 { + t.Errorf("expected 0 rows, got %d", len(body.Neighbors)) + } +} + +// SeenViaPackets is memoized per distinct observer_id -- verify it's +// computed correctly for TWO different observers in the same response, +// one with packet-path evidence and one without, guarding against a bug +// where the cache accidentally shares state across observers. +func TestHandleAllObserverNeighbors_SeenViaPacketsPerObserver(t *testing.T) { + srv, router := setupTestServer(t) + + observerWithEvidence := "1111111111111111111111111111111111111111111111111111111111111111" + observerWithoutEvidence := "2222222222222222222222222222222222222222222222222222222222222222" + neighborA := "3333333333333333333333333333333333333333333333333333333333333333" + neighborB := "4444444444444444444444444444444444444444444444444444444444444444" + + if _, err := srv.db.conn.Exec(`INSERT INTO observer_neighbors (observer_id, neighbor_pubkey, scopes, status, reported_at) VALUES + (?, ?, '', 'responded', '2026-07-28T12:00:00Z'), + (?, ?, '', 'responded', '2026-07-28T12:00:00Z')`, + observerWithEvidence, neighborA, observerWithoutEvidence, neighborB); err != nil { + t.Fatalf("seed observer_neighbors: %v", err) + } + if _, err := srv.db.conn.Exec(`INSERT INTO neighbor_edges (node_a, node_b, count, last_seen) VALUES (?, ?, 5, '2026-07-28T11:00:00Z')`, + observerWithEvidence, neighborA); err != nil { + t.Fatalf("seed neighbor_edges: %v", err) + } + + req := httptest.NewRequest("GET", "/api/observers/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 { + ObserverID string `json:"observerId"` + 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()) + } + byObserver := map[string]bool{} + for _, n := range body.Neighbors { + byObserver[n.ObserverID] = n.SeenViaPackets + } + if !byObserver[observerWithEvidence] { + t.Errorf("seenViaPackets for %s = false, want true", observerWithEvidence) + } + if byObserver[observerWithoutEvidence] { + t.Errorf("seenViaPackets for %s = true, want false", observerWithoutEvidence) + } +} + +func TestHandleAllObserverNeighbors_ExcludesBlacklistedObserver(t *testing.T) { + srv, router := setupTestServer(t) + srv.cfg.ObserverBlacklist = []string{"blacklisted-obs"} + + if _, err := srv.db.conn.Exec(`INSERT INTO observer_neighbors (observer_id, neighbor_pubkey, scopes, status, reported_at) VALUES + ('blacklisted-obs', ?, '', 'responded', '2026-07-28T13:00:00Z'), + ('normal-obs', ?, '', 'responded', '2026-07-28T13:00:00Z')`, + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"); err != nil { + t.Fatalf("seed observer_neighbors: %v", err) + } + + req := httptest.NewRequest("GET", "/api/observers/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 { + ObserverID string `json:"observerId"` + } `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].ObserverID != "normal-obs" { + t.Fatalf("expected only normal-obs's row, got %+v", body.Neighbors) + } +} diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 535ca25d..42c6a1d7 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -139,6 +139,8 @@ func routeDescriptions() map[string]routeMeta { "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"}, + "GET /api/observers/neighbors": {Summary: "Every observer's reported direct neighbors, network-wide", Description: "Flattens /api/observers/{id}/neighbors across ALL observers into one list -- Tools > Observer Neighbors. Same per-entry semantics (scopes null unless the OTA scope query responded, seenViaPackets cross-references the packet-derived neighbor_edges graph, observer/neighbor name null when unresolved). Blacklisted observers (config.json observerBlacklist) are excluded. Empty list (not an error) when no observer has ever sent a /neighbors report.", Tag: "observers", + Response: schemaRef("AllObserverNeighborsResponse")}, // 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}}}, @@ -509,6 +511,29 @@ func componentSchemas() map[string]interface{} { "estimatedNodes": map[string]interface{}{"type": "array", "items": schemaRef("EstimatedAreaNode"), "description": "Flat, network-wide list of every node behind positionGaps' approximated counts, with actual estimated coordinates for plotting on a map."}, }, }, + "AllObserverNeighborsEntry": map[string]interface{}{ + "type": "object", + "description": "One observer's firmware-reported direct neighbor, flattened with which observer it came from.", + "properties": map[string]interface{}{ + "observerId": str("The reporting observer's ID."), + "observerName": map[string]interface{}{"type": "string", "nullable": true, "description": "Observer display name, null when unresolved."}, + "observerIata": map[string]interface{}{"type": "string", "nullable": true, "description": "Observer's IATA region code, when set."}, + "neighborPubkey": str("The neighbor's pubkey."), + "neighborName": map[string]interface{}{"type": "string", "nullable": true, "description": "Neighbor display name, null when the pubkey doesn't resolve to a known node."}, + "neighborRole": map[string]interface{}{"type": "string", "nullable": true}, + "scopes": map[string]interface{}{"type": "string", "nullable": true, "description": "Null unless the neighbor's OTA scope query responded (status=\"responded\")."}, + "status": str("The /neighbors report's status for this entry (e.g. \"responded\", \"timeout\")."), + "seenViaPackets": map[string]interface{}{"type": "boolean", "description": "Cross-references the packet-path-inferred neighbor_edges graph -- false means this firmware-confirmed neighbor has never had a resolved packet path to the observer (possible coverage gap or packet loss, not necessarily a fault)."}, + "reportedAt": str("RFC3339 timestamp of the /neighbors report this row came from."), + }, + }, + "AllObserverNeighborsResponse": map[string]interface{}{ + "type": "object", + "description": "Every observer's reported direct neighbors, network-wide (Tools > Observer Neighbors). Empty (not an error) when no observer has ever sent a /neighbors report.", + "properties": map[string]interface{}{ + "neighbors": map[string]interface{}{"type": "array", "items": schemaRef("AllObserverNeighborsEntry")}, + }, + }, } } diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 1db12c89..a880dfb5 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -360,6 +360,11 @@ func (s *Server) RegisterRoutes(r *mux.Router) { 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") + // Must be registered before /api/observers/{id} below -- both are + // 3-segment patterns and gorilla/mux matches registration order, so + // a static /api/observers/neighbors registered after {id} would be + // swallowed by it (id="neighbors") instead of reaching this handler. + r.HandleFunc("/api/observers/neighbors", s.handleAllObserverNeighbors).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") @@ -3315,6 +3320,31 @@ func (s *Server) handleObserverNeighbors(w http.ResponseWriter, r *http.Request) }) } +// handleAllObserverNeighbors serves the Tools > Observer Neighbors page: +// every observer's reported direct-neighbor set network-wide in one flat +// list (dborup asked for a single place to see this, rather than clicking +// into each observer individually). Same fields/semantics as +// handleObserverNeighbors, just not scoped to one observer -- including +// the empty-list-not-error convention for a network with no /neighbors +// data reported yet. +func (s *Server) handleAllObserverNeighbors(w http.ResponseWriter, r *http.Request) { + entries, err := s.db.GetAllObserverNeighbors() + if err != nil { + writeError(w, 500, err.Error()) + return + } + if s.cfg != nil && len(s.cfg.ObserverBlacklist) > 0 { + filtered := entries[:0] + for _, e := range entries { + if !s.cfg.IsObserverBlacklisted(e.ObserverID) { + filtered = append(filtered, e) + } + } + entries = filtered + } + writeJSON(w, map[string]interface{}{"neighbors": entries}) +} + // 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 -- diff --git a/public/app.js b/public/app.js index 25af92ef..cac11cda 100644 --- a/public/app.js +++ b/public/app.js @@ -1116,6 +1116,7 @@ registerPage('tools-landing', { '

Path Inspector

Resolve prefix paths to candidate full-pubkey routes with confidence scoring.

' + '

Trace Viewer

View detailed packet traces by hash.

' + '

Ping Scores

Global highscore board and leaderboards from every "ping" ever sent in a channel.

' + + '

Observer Neighbors

Every observer\'s firmware-reported direct neighbors, network-wide in one searchable list.

' + '' + ''; }, @@ -1196,6 +1197,9 @@ function navigate() { } else if (routeParam === 'path-inspector' || (routeParam && routeParam.startsWith('path-inspector'))) { basePage = 'path-inspector'; routeParam = null; + } else if (routeParam === 'observer-neighbors') { + basePage = 'observer-neighbors-tool'; + routeParam = null; } else if (!routeParam) { // Default tools landing shows menu with both entries. basePage = 'tools-landing'; @@ -1208,7 +1212,7 @@ function navigate() { // Update nav active state document.querySelectorAll('.nav-link[data-route]').forEach(el => { - el.classList.toggle('active', el.dataset.route === basePage || (el.dataset.route === 'tools' && (basePage === 'traces' || basePage === 'path-inspector' || basePage === 'tools-landing'))); + el.classList.toggle('active', el.dataset.route === basePage || (el.dataset.route === 'tools' && (basePage === 'traces' || basePage === 'path-inspector' || basePage === 'observer-neighbors-tool' || basePage === 'tools-landing'))); }); // Update "More" button to show active state if a low-priority page is selected var moreBtn = document.getElementById('navMoreBtn'); diff --git a/public/index.html b/public/index.html index 4a51d96b..b73656bd 100644 --- a/public/index.html +++ b/public/index.html @@ -217,6 +217,7 @@ + diff --git a/public/observer-neighbors-tool.js b/public/observer-neighbors-tool.js new file mode 100644 index 00000000..aa150856 --- /dev/null +++ b/public/observer-neighbors-tool.js @@ -0,0 +1,179 @@ +// Observer Neighbors tool — network-wide list of every observer's +// firmware-reported direct (zero-hop) neighbors, flattened across all +// observers into one searchable/sortable table (Tools > Observer +// Neighbors). Requested by dborup as a single place to see this instead +// of clicking into each observer's Direct Neighbors panel individually +// (public/observer-detail.js's renderDirectNeighbors, whose row shape and +// col-scope-list wrap-fix this reuses). +(function () { + 'use strict'; + + var container = null; + var allRows = []; + var filterText = ''; + var sortState = { col: 'observer', dir: 'asc' }; + + function escapeHtml(s) { + return s == null ? '' : String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + } + + function init(app) { + container = app; + allRows = []; + filterText = ''; + sortState = { col: 'observer', dir: 'asc' }; + + container.innerHTML = + '
' + + '

Observer Neighbors

' + + '

Every observer\'s firmware-reported direct (zero-hop) neighbors, network-wide. Ground truth from each observer\'s own /neighbors report -- distinct from the packet-path-inferred neighbor graph. Click a column header to sort.

' + + '
' + + '
' + + '
' + + '
'; + + var filterInput = document.getElementById('obs-nb-filter'); + if (filterInput) { + filterInput.addEventListener('input', function () { + filterText = filterInput.value.toLowerCase(); + renderTable(); + }); + } + + load(); + } + + function destroy() { + container = null; + allRows = []; + } + + function load() { + var statusEl = document.getElementById('obs-nb-status'); + var wrap = document.getElementById('obs-nb-table-wrap'); + if (wrap) wrap.innerHTML = '

Loading…

'; + fetch('/api/observers/neighbors') + .then(function (r) { + if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || 'Request failed'); }); + return r.json(); + }) + .then(function (data) { + allRows = (data && Array.isArray(data.neighbors)) ? data.neighbors : []; + renderTable(); + }) + .catch(function (e) { + if (wrap) wrap.innerHTML = ''; + if (statusEl) statusEl.textContent = 'Failed to load: ' + e.message; + }); + } + + function sortValue(row, col) { + switch (col) { + case 'observer': return (row.observerName || row.observerId || '').toLowerCase(); + case 'neighbor': return (row.neighborName || row.neighborPubkey || '').toLowerCase(); + case 'evidence': return row.seenViaPackets ? 1 : 0; + case 'status': return (row.status || '').toLowerCase(); + case 'reportedAt': return row.reportedAt || ''; + default: return ''; + } + } + + function sortArrow(col) { + if (col !== sortState.col) return '⇅'; + return '' + (sortState.dir === 'asc' ? '↑' : '↓') + ''; + } + + function matchesFilter(row) { + if (!filterText) return true; + var observer = (row.observerName || row.observerId || '').toLowerCase(); + var neighbor = (row.neighborName || row.neighborPubkey || '').toLowerCase(); + return observer.indexOf(filterText) !== -1 || neighbor.indexOf(filterText) !== -1; + } + + function renderTable() { + var statusEl = document.getElementById('obs-nb-status'); + var wrap = document.getElementById('obs-nb-table-wrap'); + if (!wrap) return; + + if (allRows.length === 0) { + if (statusEl) statusEl.textContent = ''; + wrap.innerHTML = '

No observer has reported any direct neighbors yet.

'; + return; + } + + var filtered = allRows.filter(matchesFilter); + var mult = sortState.dir === 'asc' ? 1 : -1; + var sorted = filtered.slice().sort(function (a, b) { + var av = sortValue(a, sortState.col), bv = sortValue(b, sortState.col); + if (av < bv) return -1 * mult; + if (av > bv) return 1 * mult; + return 0; + }); + + if (statusEl) { + statusEl.textContent = filtered.length.toLocaleString() + ' of ' + allRows.length.toLocaleString() + ' neighbor pairs' + + (filterText ? ' (filtered)' : ''); + } + + if (sorted.length === 0) { + wrap.innerHTML = '

No rows match "' + escapeHtml(filterText) + '".

'; + return; + } + + var rows = sorted.map(function (row) { + var observerLabel = row.observerName ? escapeHtml(row.observerName) : escapeHtml(row.observerId); + var observerCell = '' + observerLabel + ''; + + var neighborLabel = row.neighborName ? escapeHtml(row.neighborName) : escapeHtml(String(row.neighborPubkey).slice(0, 12)) + '…'; + var neighborCell = row.neighborName + ? '' + neighborLabel + '' + : '' + neighborLabel + ''; + + var scopeCell = row.scopes + ? '' + escapeHtml(row.scopes) + '' + : (row.status === 'timeout' + ? 'no reply' + : '—'); + + var evidenceCell = row.seenViaPackets + ? 'confirmed' + : 'not seen yet'; + + var reportedCell = row.reportedAt + ? '' + (typeof timeAgo === 'function' ? timeAgo(row.reportedAt) : escapeHtml(row.reportedAt)) + '' + : '—'; + + return '' + observerCell + '' + neighborCell + '' + scopeCell + '' + + '' + evidenceCell + '' + escapeHtml(row.status || '') + '' + reportedCell + ''; + }).join(''); + + wrap.innerHTML = + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + rows + '
Observer' + sortArrow('observer') + 'Neighbor' + sortArrow('neighbor') + 'Configured ScopePacket Evidence' + sortArrow('evidence') + 'Status' + sortArrow('status') + 'Reported' + sortArrow('reportedAt') + '
'; + + var table = document.getElementById('obs-nb-table'); + if (table) { + table.querySelectorAll('th[data-sort-col]').forEach(function (th) { + th.addEventListener('click', function () { + var col = th.dataset.sortCol; + if (sortState.col === col) { + sortState.dir = sortState.dir === 'asc' ? 'desc' : 'asc'; + } else { + sortState.col = col; + sortState.dir = (col === 'observer' || col === 'neighbor' || col === 'status') ? 'asc' : 'desc'; + } + renderTable(); + }); + }); + } + } + + window.ObserverNeighborsTool = { init: init, destroy: destroy, sortValue: sortValue }; + if (typeof registerPage === 'function') registerPage('observer-neighbors-tool', { init: init, destroy: destroy }); +})(); diff --git a/test-all.sh b/test-all.sh index 51af8f0d..269c7c00 100755 --- a/test-all.sh +++ b/test-all.sh @@ -78,6 +78,7 @@ node test-ping-scores.js node test-observer-neighbors-report-badge.js node test-observer-direct-neighbors-panel.js node test-analytics-areas-tab.js +node test-observer-neighbors-tool.js echo "" echo "═══════════════════════════════════════" diff --git a/test-observer-neighbors-tool.js b/test-observer-neighbors-tool.js new file mode 100644 index 00000000..6bee2e33 --- /dev/null +++ b/test-observer-neighbors-tool.js @@ -0,0 +1,165 @@ +// test-observer-neighbors-tool.js — vm.createContext sandbox tests for +// public/observer-neighbors-tool.js (Tools > Observer Neighbors page). +'use strict'; +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +let passed = 0, failed = 0; +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(` ✅ ${name}`); + } catch (e) { + failed++; + console.log(` ❌ ${name}: ${e.message}`); + } +} + +function makeRow(overrides) { + return Object.assign({ + observerId: 'obs1', + observerName: 'Observer One', + observerIata: 'SJC', + neighborPubkey: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + neighborName: 'Neighbor A', + neighborRole: 'repeater', + scopes: '#dk', + status: 'responded', + seenViaPackets: true, + reportedAt: '2026-07-28T10:00:00Z', + }, overrides); +} + +function createSandbox(rows) { + const docStore = {}; + const listeners = {}; + function fakeEl(id) { + if (!docStore[id]) { + docStore[id] = { + id: id, + innerHTML: '', + textContent: '', + value: '', + dataset: {}, + _listeners: {}, + addEventListener: function (evt, fn) { this._listeners[evt] = fn; }, + querySelectorAll: function () { return []; }, + querySelector: function () { return null; }, + }; + } + return docStore[id]; + } + + const sandbox = { + window: {}, + document: { + getElementById: (id) => fakeEl(id), + querySelectorAll: () => [], + querySelector: () => null, + }, + location: { hash: '#/tools/observer-neighbors' }, + fetch: () => Promise.resolve({ ok: true, json: () => Promise.resolve({ neighbors: rows }) }), + URLSearchParams: URLSearchParams, + registerPage: function () {}, + timeAgo: (iso) => 'TIME_AGO(' + iso + ')', + encodeURIComponent: encodeURIComponent, + console: console, + __docStore: docStore, + }; + sandbox.self = sandbox; + sandbox.globalThis = sandbox; + const ctx = vm.createContext(sandbox); + const src = fs.readFileSync(__dirname + '/public/observer-neighbors-tool.js', 'utf8'); + vm.runInContext(src, ctx); + return sandbox; +} + +// init() builds real DOM via template literals assigned to a fake +// container's innerHTML, then queries document.getElementById for the +// sub-elements it just described. Our fakeEl() stub doesn't parse HTML, +// so getElementById always returns a *fresh* stub the first time it's +// asked for a given id, regardless of what init() wrote into innerHTML. +// That's fine for these tests: we only need to observe what renderTable() +// assigns to '#obs-nb-table-wrap'.innerHTML and '#obs-nb-status'.textContent +// after fetch resolves, and that async load() runs inside init(). +function initWith(rows) { + const sb = createSandbox(rows); + const container = { innerHTML: '' }; + sb.window.ObserverNeighborsTool.init(container); + return sb; +} + +function waitForLoad() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +(async () => { + console.log('\n=== observer-neighbors-tool.js: Observer Neighbors page ==='); + + await test('window.ObserverNeighborsTool exists with init/destroy/sortValue', () => { + const sb = createSandbox([]); + assert.strictEqual(typeof sb.window.ObserverNeighborsTool.init, 'function'); + assert.strictEqual(typeof sb.window.ObserverNeighborsTool.destroy, 'function'); + assert.strictEqual(typeof sb.window.ObserverNeighborsTool.sortValue, 'function'); + }); + + await test('empty result set shows a neutral "no observer has reported" message, not an error', async () => { + const sb = initWith([]); + await waitForLoad(); + const wrap = sb.__docStore['obs-nb-table-wrap']; + assert.ok(wrap.innerHTML.includes('No observer has reported any direct neighbors yet'), `got: ${wrap.innerHTML}`); + }); + + await test('renders a row with observer link, neighbor link, scope badge, and packet-evidence label', async () => { + const sb = initWith([makeRow()]); + await waitForLoad(); + const wrap = sb.__docStore['obs-nb-table-wrap']; + assert.ok(wrap.innerHTML.includes('href="#/observers/obs1"'), 'expected observer link'); + assert.ok(wrap.innerHTML.includes('Observer One'), 'expected observer display name'); + assert.ok(wrap.innerHTML.includes('href="#/nodes/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"'), 'expected neighbor node link'); + assert.ok(wrap.innerHTML.includes('Neighbor A'), 'expected neighbor display name'); + assert.ok(wrap.innerHTML.includes('#dk'), 'expected the scope badge'); + assert.ok(wrap.innerHTML.includes('confirmed'), 'expected packet-evidence "confirmed" label'); + assert.ok(wrap.innerHTML.includes('class="col-scope-list"'), 'expected the Configured Scope cell to reuse the col-scope-list wrap fix'); + }); + + await test('an unresolved neighbor pubkey renders truncated and unlinked, and "no reply" for a timeout with no scope', async () => { + const sb = initWith([makeRow({ neighborName: null, neighborRole: null, scopes: null, status: 'timeout', seenViaPackets: false })]); + await waitForLoad(); + const wrap = sb.__docStore['obs-nb-table-wrap']; + assert.ok(!/href="#\/nodes\//.test(wrap.innerHTML), 'must not link an unresolved neighbor pubkey'); + assert.ok(wrap.innerHTML.includes('aaaaaaaaaaaa'), 'expected a truncated pubkey'); + assert.ok(wrap.innerHTML.includes('no reply'), 'expected the "no reply" label for a timeout with no scope'); + assert.ok(wrap.innerHTML.includes('not seen yet'), 'expected the "not seen yet" packet-evidence label'); + }); + + await test('status line shows the row count', async () => { + const sb = initWith([makeRow(), makeRow({ observerId: 'obs2', observerName: 'Observer Two' })]); + await waitForLoad(); + const status = sb.__docStore['obs-nb-status']; + assert.ok(status.textContent.includes('2 of 2 neighbor pairs'), `got: ${status.textContent}`); + }); + + await test('sortValue: observer/neighbor fall back to id/pubkey when unresolved, lowercased', () => { + const sb = createSandbox([]); + const sv = sb.window.ObserverNeighborsTool.sortValue; + assert.strictEqual(sv({ observerName: 'Observer One' }, 'observer'), 'observer one'); + assert.strictEqual(sv({ observerId: 'obs1' }, 'observer'), 'obs1'); + assert.strictEqual(sv({ neighborName: 'Neighbor A' }, 'neighbor'), 'neighbor a'); + assert.strictEqual(sv({ neighborPubkey: 'AABB' }, 'neighbor'), 'aabb'); + }); + + await test('sortValue: evidence sorts booleans as 1/0', () => { + const sb = createSandbox([]); + const sv = sb.window.ObserverNeighborsTool.sortValue; + assert.strictEqual(sv({ seenViaPackets: true }, 'evidence'), 1); + assert.strictEqual(sv({ seenViaPackets: false }, 'evidence'), 0); + }); + + console.log('\n════════════════════════════════════════'); + console.log(` Observer Neighbors tool: ${passed} passed, ${failed} failed`); + console.log('════════════════════════════════════════'); + process.exit(failed === 0 ? 0 : 1); +})(); From aeb441540d8c75225c2de0c99cf4cf9ba6a6eb2b Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 28 Jul 2026 06:11:07 +0200 Subject: [PATCH 2/2] fix: Observer Neighbors tool header never actually got the sort-active highlight class Caught live on stg: clicking a column sorted correctly (confirmed via sortValue/data ordering) but no header ever visually highlighted as the active sort column -- the thead template hardcoded class="sortable" on every and never added sort-active, even though sortArrow() computed the right up/down glyph. Refactored into a sortTh(col, label) helper so the class and arrow can't drift apart again; added a regression test. Co-Authored-By: Claude Sonnet 5 --- public/observer-neighbors-tool.js | 15 ++++++++++----- test-observer-neighbors-tool.js | 9 +++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/public/observer-neighbors-tool.js b/public/observer-neighbors-tool.js index aa150856..cc17225d 100644 --- a/public/observer-neighbors-tool.js +++ b/public/observer-neighbors-tool.js @@ -83,6 +83,11 @@ return '' + (sortState.dir === 'asc' ? '↑' : '↓') + ''; } + function sortTh(col, label) { + var cls = 'sortable' + (col === sortState.col ? ' sort-active' : ''); + return '' + label + sortArrow(col) + ''; + } + function matchesFilter(row) { if (!filterText) return true; var observer = (row.observerName || row.observerId || '').toLowerCase(); @@ -149,12 +154,12 @@ wrap.innerHTML = '' + - '' + - '' + + sortTh('observer', 'Observer') + + sortTh('neighbor', 'Neighbor') + '' + - '' + - '' + - '' + + sortTh('evidence', 'Packet Evidence') + + sortTh('status', 'Status') + + sortTh('reportedAt', 'Reported') + '' + rows + '
Observer' + sortArrow('observer') + 'Neighbor' + sortArrow('neighbor') + 'Configured ScopePacket Evidence' + sortArrow('evidence') + 'Status' + sortArrow('status') + 'Reported' + sortArrow('reportedAt') + '
'; var table = document.getElementById('obs-nb-table'); diff --git a/test-observer-neighbors-tool.js b/test-observer-neighbors-tool.js index 6bee2e33..4a19a51b 100644 --- a/test-observer-neighbors-tool.js +++ b/test-observer-neighbors-tool.js @@ -151,6 +151,15 @@ function waitForLoad() { assert.strictEqual(sv({ neighborPubkey: 'AABB' }, 'neighbor'), 'aabb'); }); + await test('the default-sorted column (Observer) header carries sort-active', async () => { + const sb = initWith([makeRow(), makeRow({ observerId: 'obs2', observerName: 'Observer Two' })]); + await waitForLoad(); + const wrap = sb.__docStore['obs-nb-table-wrap']; + assert.ok(/data-sort-col="observer"[^>]*class="sortable sort-active"|class="sortable sort-active"[^>]*data-sort-col="observer"/.test(wrap.innerHTML), + `expected Observer header to carry sort-active by default; got: ${wrap.innerHTML.slice(0, 400)}`); + assert.ok(!/data-sort-col="neighbor"[^>]*sort-active/.test(wrap.innerHTML), 'Neighbor header should not be marked active'); + }); + await test('sortValue: evidence sorts booleans as 1/0', () => { const sb = createSandbox([]); const sv = sb.window.ObserverNeighborsTool.sortValue;