mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-26 04:13:39 +00:00
Merge branch 'areas-meshguide-sync'
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 --
|
||||
|
||||
+5
-1
@@ -1116,6 +1116,7 @@ registerPage('tools-landing', {
|
||||
'<a href="#/tools/path-inspector" class="tools-card"><h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-magnifying-glass"/></svg> Path Inspector</h3><p>Resolve prefix paths to candidate full-pubkey routes with confidence scoring.</p></a>' +
|
||||
'<a href="#/tools/trace/" class="tools-card"><h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg> Trace Viewer</h3><p>View detailed packet traces by hash.</p></a>' +
|
||||
'<a href="#/ping-scores" class="tools-card"><h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-trophy"/></svg> Ping Scores</h3><p>Global highscore board and leaderboards from every "ping" ever sent in a channel.</p></a>' +
|
||||
'<a href="#/tools/observer-neighbors" class="tools-card"><h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-share-network"/></svg> Observer Neighbors</h3><p>Every observer\'s firmware-reported direct neighbors, network-wide in one searchable list.</p></a>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
},
|
||||
@@ -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');
|
||||
|
||||
@@ -217,6 +217,7 @@
|
||||
<script src="observers.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="mqtt-status-panel.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-detail.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-neighbors-tool.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="compare.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-reach-map.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
// 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, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function init(app) {
|
||||
container = app;
|
||||
allRows = [];
|
||||
filterText = '';
|
||||
sortState = { col: 'observer', dir: 'asc' };
|
||||
|
||||
container.innerHTML =
|
||||
'<div class="tools-landing" style="max-width:1100px">' +
|
||||
'<h2>Observer Neighbors</h2>' +
|
||||
'<p class="help-text">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.</p>' +
|
||||
'<div style="margin:12px 0"><input type="text" id="obs-nb-filter" class="input" placeholder="Filter by observer or neighbor name…" style="max-width:320px"></div>' +
|
||||
'<div id="obs-nb-status" class="text-muted" style="font-size:12px;margin-bottom:8px"></div>' +
|
||||
'<div id="obs-nb-table-wrap" class="table-fluid-wrap"></div>' +
|
||||
'</div>';
|
||||
|
||||
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 = '<p class="text-muted">Loading…</p>';
|
||||
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 '<span class="sort-arrow">⇅</span>';
|
||||
return '<span class="sort-arrow">' + (sortState.dir === 'asc' ? '↑' : '↓') + '</span>';
|
||||
}
|
||||
|
||||
function sortTh(col, label) {
|
||||
var cls = 'sortable' + (col === sortState.col ? ' sort-active' : '');
|
||||
return '<th class="' + cls + '" data-sort-col="' + col + '">' + label + sortArrow(col) + '</th>';
|
||||
}
|
||||
|
||||
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 = '<p class="text-muted">No observer has reported any direct neighbors yet.</p>';
|
||||
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 = '<p class="text-muted">No rows match "' + escapeHtml(filterText) + '".</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var rows = sorted.map(function (row) {
|
||||
var observerLabel = row.observerName ? escapeHtml(row.observerName) : escapeHtml(row.observerId);
|
||||
var observerCell = '<a href="#/observers/' + encodeURIComponent(row.observerId) + '">' + observerLabel + '</a>';
|
||||
|
||||
var neighborLabel = row.neighborName ? escapeHtml(row.neighborName) : escapeHtml(String(row.neighborPubkey).slice(0, 12)) + '…';
|
||||
var neighborCell = row.neighborName
|
||||
? '<a href="#/nodes/' + encodeURIComponent(row.neighborPubkey) + '">' + neighborLabel + '</a>'
|
||||
: '<span class="mono">' + neighborLabel + '</span>';
|
||||
|
||||
var scopeCell = row.scopes
|
||||
? '<span class="badge-region">' + escapeHtml(row.scopes) + '</span>'
|
||||
: (row.status === 'timeout'
|
||||
? '<span class="text-muted" title="Scope query timed out">no reply</span>'
|
||||
: '<span class="text-muted">—</span>');
|
||||
|
||||
var evidenceCell = row.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.">not seen yet</span>';
|
||||
|
||||
var reportedCell = row.reportedAt
|
||||
? '<span title="' + escapeHtml(row.reportedAt) + '">' + (typeof timeAgo === 'function' ? timeAgo(row.reportedAt) : escapeHtml(row.reportedAt)) + '</span>'
|
||||
: '<span class="text-muted">—</span>';
|
||||
|
||||
return '<tr><td>' + observerCell + '</td><td>' + neighborCell + '</td><td class="col-scope-list">' + scopeCell + '</td>' +
|
||||
'<td>' + evidenceCell + '</td><td>' + escapeHtml(row.status || '') + '</td><td>' + reportedCell + '</td></tr>';
|
||||
}).join('');
|
||||
|
||||
wrap.innerHTML =
|
||||
'<table class="data-table" id="obs-nb-table"><thead><tr>' +
|
||||
sortTh('observer', 'Observer') +
|
||||
sortTh('neighbor', 'Neighbor') +
|
||||
'<th>Configured Scope</th>' +
|
||||
sortTh('evidence', 'Packet Evidence') +
|
||||
sortTh('status', 'Status') +
|
||||
sortTh('reportedAt', 'Reported') +
|
||||
'</tr></thead><tbody>' + rows + '</tbody></table>';
|
||||
|
||||
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 });
|
||||
})();
|
||||
@@ -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 "═══════════════════════════════════════"
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
// 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('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;
|
||||
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);
|
||||
})();
|
||||
Reference in New Issue
Block a user