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', {
' Resolve prefix paths to candidate full-pubkey routes with confidence scoring. View detailed packet traces by hash. Global highscore board and leaderboards from every "ping" ever sent in a channel. Every observer\'s firmware-reported direct neighbors, network-wide in one searchable list. Path Inspector
Trace Viewer
Ping Scores
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.
' + + '' + + '' + + '' + + '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 sortTh(col, label) { + var cls = 'sortable' + (col === sortState.col ? ' sort-active' : ''); + return '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 '| Configured Scope | ' + + sortTh('evidence', 'Packet Evidence') + + sortTh('status', 'Status') + + sortTh('reportedAt', 'Reported') + + '
|---|