mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-23 08:52:21 +00:00
Merge branch 'areas-meshguide-sync'
This commit is contained in:
commit
41b6d93d53
12 files changed
+620
-9
No files matched your search
@@ -1734,6 +1734,64 @@ func (s *Store) TouchObserverNeighborsReport(observerID, reportedAt string) erro
|
||||
return err
|
||||
}
|
||||
|
||||
// ObserverNeighborEntry is one entry from an observer's /neighbors report,
|
||||
// carrying enough to populate the observer_neighbors table.
|
||||
type ObserverNeighborEntry struct {
|
||||
Pubkey string // lowercase, already validated non-empty by the caller
|
||||
Scopes string // normalized "#"-prefixed form; empty for timeout entries
|
||||
Status string // "responded" | "timeout"
|
||||
}
|
||||
|
||||
// ReplaceObserverNeighbors stores the observer's CURRENT direct (zero-hop)
|
||||
// neighbor set from its own firmware neighbor table -- ground truth, distinct
|
||||
// from the packet-path-inferred neighbor_edges graph (#1865 follow-up,
|
||||
// "Direct Neighbors" panel). Deliberately a full replace (delete + re-insert)
|
||||
// rather than an upsert: a neighbor that drops off a subsequent report is a
|
||||
// real signal (it's no longer a direct neighbor) and must disappear, not
|
||||
// linger as stale data.
|
||||
//
|
||||
// Call this AFTER TouchObserverNeighborsReport in the same handler: it reuses
|
||||
// the just-updated observers.last_neighbors_report_at as an out-of-order
|
||||
// guard. If the given reportedAt doesn't match what's now stored there, a
|
||||
// newer report already won the MAX race and this one is stale -- skip the
|
||||
// replace so an older, out-of-order report can't clobber a newer snapshot.
|
||||
func (s *Store) ReplaceObserverNeighbors(observerID string, neighbors []ObserverNeighborEntry, reportedAt string) error {
|
||||
if observerID == "" {
|
||||
return nil
|
||||
}
|
||||
reportedAt = normalizeReportTS(reportedAt)
|
||||
if reportedAt == "" {
|
||||
return nil
|
||||
}
|
||||
var curAt sql.NullString
|
||||
row := s.db.QueryRow(`SELECT last_neighbors_report_at FROM observers WHERE id = ?`, observerID)
|
||||
if row.Scan(&curAt) == nil && curAt.Valid && curAt.String != "" && curAt.String != reportedAt {
|
||||
return nil
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(`DELETE FROM observer_neighbors WHERE observer_id = ?`, observerID); err != nil {
|
||||
return err
|
||||
}
|
||||
stmt, err := tx.Prepare(`INSERT INTO observer_neighbors (observer_id, neighbor_pubkey, scopes, status, reported_at) VALUES (?, ?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
for _, n := range neighbors {
|
||||
if n.Pubkey == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := stmt.Exec(observerID, n.Pubkey, n.Scopes, n.Status, reportedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// normalizeConfiguredScopeList applies the same "#"-prefix normalization
|
||||
// default_scope already gets (regions.Normalize, via matchScope) to a
|
||||
// comma-separated /neighbors-report scope list, so both fields display
|
||||
|
||||
+16
-6
@@ -1559,26 +1559,36 @@ func handleNeighborsReport(store *Store, tag string, observerID string, msg map[
|
||||
}
|
||||
}
|
||||
|
||||
// neighbors[]: only status=="responded" carries usable scope evidence.
|
||||
// neighbors[]: only status=="responded" carries usable scope evidence,
|
||||
// but ALL entries (including timeout) are direct (zero-hop) neighbors
|
||||
// per the observer's own firmware neighbor table -- collected below for
|
||||
// the #1865 follow-up "Direct Neighbors" panel regardless of status.
|
||||
neighbors, _ := msg["neighbors"].([]interface{})
|
||||
entries := make([]ObserverNeighborEntry, 0, len(neighbors))
|
||||
for _, raw := range neighbors {
|
||||
n, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if status, _ := n["status"].(string); status != "responded" {
|
||||
continue
|
||||
}
|
||||
pubkey, _ := n["pubkey"].(string)
|
||||
pubkey = strings.ToLower(pubkey)
|
||||
if pubkey == "" {
|
||||
continue
|
||||
}
|
||||
status, _ := n["status"].(string)
|
||||
scopes, _ := n["scopes"].(string)
|
||||
if err := store.UpdateNodeConfiguredScope(pubkey, scopes, reportedAt); err != nil {
|
||||
log.Printf("MQTT [%s] neighbors scope error for %.8s: %v", tag, pubkey, err)
|
||||
if status == "responded" {
|
||||
if err := store.UpdateNodeConfiguredScope(pubkey, scopes, reportedAt); err != nil {
|
||||
log.Printf("MQTT [%s] neighbors scope error for %.8s: %v", tag, pubkey, err)
|
||||
}
|
||||
entries = append(entries, ObserverNeighborEntry{Pubkey: pubkey, Scopes: normalizeConfiguredScopeList(scopes), Status: status})
|
||||
} else {
|
||||
entries = append(entries, ObserverNeighborEntry{Pubkey: pubkey, Status: status})
|
||||
}
|
||||
}
|
||||
if err := store.ReplaceObserverNeighbors(observerID, entries, reportedAt); err != nil {
|
||||
log.Printf("MQTT [%s] neighbors replace error for observer %.8s: %v", tag, observerID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// shouldUpdateDefaultScope returns true when the packet carries a transport
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// #1865 follow-up "Direct Neighbors" panel: observer_neighbors is a
|
||||
// CURRENT-ONLY snapshot of an observer's firmware-reported zero-hop
|
||||
// neighbors, replaced wholesale on every /neighbors report.
|
||||
|
||||
func seedObserverForNeighbors(t *testing.T, store *Store, id string) {
|
||||
t.Helper()
|
||||
if _, err := store.db.Exec(`INSERT INTO observers (id, name) VALUES (?, ?)`, id, "obs_"+id); err != nil {
|
||||
t.Fatalf("seed observer %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func countObserverNeighbors(t *testing.T, store *Store, observerID string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := store.db.QueryRow(`SELECT COUNT(*) FROM observer_neighbors WHERE observer_id = ?`, observerID).Scan(&n); err != nil {
|
||||
t.Fatalf("count observer_neighbors: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestReplaceObserverNeighbors_Basic(t *testing.T) {
|
||||
store := openNeighborsStore(t)
|
||||
seedObserverForNeighbors(t, store, "obs-nb-1")
|
||||
|
||||
entries := []ObserverNeighborEntry{
|
||||
{Pubkey: "aaaa000000000000000000000000000000000000000000000000000000000001", Scopes: "#dk", Status: "responded"},
|
||||
{Pubkey: "bbbb000000000000000000000000000000000000000000000000000000000002", Status: "timeout"},
|
||||
}
|
||||
if err := store.ReplaceObserverNeighbors("obs-nb-1", entries, "2026-07-26T12:00:00Z"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := countObserverNeighbors(t, store, "obs-nb-1"); n != 2 {
|
||||
t.Fatalf("expected 2 neighbor rows, got %d", n)
|
||||
}
|
||||
|
||||
var status string
|
||||
if err := store.db.QueryRow(`SELECT status FROM observer_neighbors WHERE observer_id = ? AND neighbor_pubkey = ?`,
|
||||
"obs-nb-1", "bbbb000000000000000000000000000000000000000000000000000000000002").Scan(&status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "timeout" {
|
||||
t.Errorf("status = %q, want 'timeout'", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceObserverNeighbors_ShrinkingSetDropsStaleRows(t *testing.T) {
|
||||
store := openNeighborsStore(t)
|
||||
seedObserverForNeighbors(t, store, "obs-nb-2")
|
||||
|
||||
first := []ObserverNeighborEntry{
|
||||
{Pubkey: "cccc000000000000000000000000000000000000000000000000000000000003", Status: "responded"},
|
||||
{Pubkey: "dddd000000000000000000000000000000000000000000000000000000000004", Status: "responded"},
|
||||
}
|
||||
if err := store.ReplaceObserverNeighbors("obs-nb-2", first, "2026-07-26T12:00:00Z"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := countObserverNeighbors(t, store, "obs-nb-2"); n != 2 {
|
||||
t.Fatalf("expected 2 rows after first report, got %d", n)
|
||||
}
|
||||
|
||||
// One neighbor dropped off -- the next report only carries one entry.
|
||||
second := []ObserverNeighborEntry{
|
||||
{Pubkey: "cccc000000000000000000000000000000000000000000000000000000000003", Status: "responded"},
|
||||
}
|
||||
if err := store.ReplaceObserverNeighbors("obs-nb-2", second, "2026-07-26T13:00:00Z"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := countObserverNeighbors(t, store, "obs-nb-2"); n != 1 {
|
||||
t.Fatalf("expected 1 row after shrinking report, got %d (stale neighbor not dropped)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceObserverNeighbors_OutOfOrderReportIsIgnored(t *testing.T) {
|
||||
store := openNeighborsStore(t)
|
||||
seedObserverForNeighbors(t, store, "obs-nb-3")
|
||||
|
||||
newer := []ObserverNeighborEntry{
|
||||
{Pubkey: "eeee000000000000000000000000000000000000000000000000000000000005", Status: "responded"},
|
||||
}
|
||||
// TouchObserverNeighborsReport must run first in production (main.go
|
||||
// sequences it that way) -- replicate that here since Replace's guard
|
||||
// reads the post-touch value.
|
||||
if err := store.TouchObserverNeighborsReport("obs-nb-3", "2026-07-26T14:00:00Z"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.ReplaceObserverNeighbors("obs-nb-3", newer, "2026-07-26T14:00:00Z"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// An older, out-of-order report arrives after the newer one. Touch is a
|
||||
// no-op (MAX keeps the newer value); Replace must also refuse to
|
||||
// overwrite the newer neighbor snapshot with stale data.
|
||||
older := []ObserverNeighborEntry{
|
||||
{Pubkey: "ffff000000000000000000000000000000000000000000000000000000000006", Status: "responded"},
|
||||
}
|
||||
if err := store.TouchObserverNeighborsReport("obs-nb-3", "2026-07-26T10:00:00Z"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.ReplaceObserverNeighbors("obs-nb-3", older, "2026-07-26T10:00:00Z"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var pk string
|
||||
if err := store.db.QueryRow(`SELECT neighbor_pubkey FROM observer_neighbors WHERE observer_id = ?`, "obs-nb-3").Scan(&pk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pk != "eeee000000000000000000000000000000000000000000000000000000000005" {
|
||||
t.Errorf("neighbor_pubkey = %q, want the newer report's entry (stale report must not win)", pk)
|
||||
}
|
||||
if n := countObserverNeighbors(t, store, "obs-nb-3"); n != 1 {
|
||||
t.Errorf("expected exactly 1 row, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNeighborsReport_PopulatesDirectNeighbors(t *testing.T) {
|
||||
store := openNeighborsStore(t)
|
||||
seedObserverForNeighbors(t, store, "obs-nb-4")
|
||||
|
||||
report := map[string]interface{}{
|
||||
"timestamp": "2026-07-26T15:00:00Z",
|
||||
"neighbors": []interface{}{
|
||||
map[string]interface{}{"pubkey": "1111000000000000000000000000000000000000000000000000000000000011", "scopes": "dk,eu", "status": "responded"},
|
||||
map[string]interface{}{"pubkey": "2222000000000000000000000000000000000000000000000000000000000022", "scopes": "", "status": "timeout"},
|
||||
},
|
||||
}
|
||||
handleNeighborsReport(store, "test", "obs-nb-4", report)
|
||||
|
||||
if n := countObserverNeighbors(t, store, "obs-nb-4"); n != 2 {
|
||||
t.Fatalf("expected 2 direct-neighbor rows, got %d", n)
|
||||
}
|
||||
var scopes string
|
||||
if err := store.db.QueryRow(`SELECT scopes FROM observer_neighbors WHERE observer_id = ? AND neighbor_pubkey = ?`,
|
||||
"obs-nb-4", "1111000000000000000000000000000000000000000000000000000000000011").Scan(&scopes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if scopes != "#dk,#eu" {
|
||||
t.Errorf("scopes = %q, want normalized '#dk,#eu'", scopes)
|
||||
}
|
||||
}
|
||||
@@ -1389,6 +1389,63 @@ func (db *DB) GetObserverByID(id string) (*Observer, error) {
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
// ObserverNeighbor is one row of an observer's CURRENT direct (zero-hop)
|
||||
// neighbor set (#1865 follow-up "Direct Neighbors" panel) -- ground truth
|
||||
// from the observer's own firmware /neighbors report, joined against nodes
|
||||
// for a display name/role when the pubkey resolves to a known node.
|
||||
type ObserverNeighbor struct {
|
||||
Pubkey string `json:"pubkey"`
|
||||
Name *string `json:"name"`
|
||||
Role *string `json:"role"`
|
||||
Scopes *string `json:"scopes"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// GetObserverNeighbors returns the observer's current direct-neighbor
|
||||
// snapshot (empty slice if none/never reported -- not an error) alongside
|
||||
// the shared report timestamp all rows carry (from observer_neighbors.
|
||||
// reported_at, which the ingestor sets identically for every row in a
|
||||
// single replace).
|
||||
func (db *DB) GetObserverNeighbors(observerID string) ([]ObserverNeighbor, string, error) {
|
||||
rows, err := db.conn.Query(`
|
||||
SELECT on2.neighbor_pubkey, on2.scopes, on2.status, on2.reported_at, n.name, n.role
|
||||
FROM observer_neighbors on2
|
||||
LEFT JOIN nodes n ON n.public_key = on2.neighbor_pubkey
|
||||
WHERE on2.observer_id = ?
|
||||
ORDER BY on2.neighbor_pubkey`, observerID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := []ObserverNeighbor{}
|
||||
var reportedAt string
|
||||
for rows.Next() {
|
||||
var n ObserverNeighbor
|
||||
var scopes, reportedAtCol, name, role sql.NullString
|
||||
if err := rows.Scan(&n.Pubkey, &scopes, &n.Status, &reportedAtCol, &name, &role); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if scopes.Valid && scopes.String != "" {
|
||||
s := scopes.String
|
||||
n.Scopes = &s
|
||||
}
|
||||
if name.Valid {
|
||||
s := name.String
|
||||
n.Name = &s
|
||||
}
|
||||
if role.Valid {
|
||||
s := role.String
|
||||
n.Role = &s
|
||||
}
|
||||
if reportedAtCol.Valid {
|
||||
reportedAt = reportedAtCol.String
|
||||
}
|
||||
result = append(result, n)
|
||||
}
|
||||
return result, reportedAt, rows.Err()
|
||||
}
|
||||
|
||||
// GetObserverIdsForRegion returns observer IDs for given IATA codes.
|
||||
func (db *DB) GetObserverIdsForRegion(regionParam string) ([]string, error) {
|
||||
codes := normalizeRegionCodes(regionParam)
|
||||
|
||||
@@ -103,6 +103,15 @@ func setupTestDB(t *testing.T) *DB {
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_observer_metrics_timestamp ON observer_metrics(timestamp);
|
||||
|
||||
CREATE TABLE observer_neighbors (
|
||||
observer_id TEXT NOT NULL,
|
||||
neighbor_pubkey TEXT NOT NULL,
|
||||
scopes TEXT,
|
||||
status TEXT NOT NULL,
|
||||
reported_at TEXT,
|
||||
PRIMARY KEY (observer_id, neighbor_pubkey)
|
||||
);
|
||||
|
||||
-- Auto-populate from_pubkey for ADVERT rows so existing test fixtures
|
||||
-- (which only set decoded_json) still attribute correctly under #1143's
|
||||
-- exact-match column. Production migration handles legacy data; the
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package main
|
||||
|
||||
// #1865 follow-up "Direct Neighbors" panel: GET /api/observers/{id}/neighbors
|
||||
// serves the observer's own firmware-reported zero-hop neighbor set.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHandleObserverNeighbors_ReturnsJoinedNames(t *testing.T) {
|
||||
srv, router := setupTestServer(t)
|
||||
|
||||
if _, err := srv.db.conn.Exec(`INSERT INTO observer_neighbors (observer_id, neighbor_pubkey, scopes, status, reported_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
"obs1", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "#dk", "responded", "2026-07-26T10:00:00Z"); err != nil {
|
||||
t.Fatalf("seed observer_neighbors: %v", err)
|
||||
}
|
||||
if _, err := srv.db.conn.Exec(`INSERT INTO nodes (public_key, name, role) VALUES (?, ?, ?)`,
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "Neighbor Node", "repeater"); err != nil {
|
||||
t.Fatalf("seed node: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/observers/obs1/neighbors", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Neighbors []struct {
|
||||
Pubkey string `json:"pubkey"`
|
||||
Name *string `json:"name"`
|
||||
Role *string `json:"role"`
|
||||
Scopes *string `json:"scopes"`
|
||||
Status string `json:"status"`
|
||||
} `json:"neighbors"`
|
||||
ReportedAt string `json:"reportedAt"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
if len(body.Neighbors) != 1 {
|
||||
t.Fatalf("expected 1 neighbor, got %d", len(body.Neighbors))
|
||||
}
|
||||
n := body.Neighbors[0]
|
||||
if n.Name == nil || *n.Name != "Neighbor Node" {
|
||||
t.Errorf("Name = %v, want 'Neighbor Node' (join against nodes failed)", n.Name)
|
||||
}
|
||||
if n.Role == nil || *n.Role != "repeater" {
|
||||
t.Errorf("Role = %v, want 'repeater'", n.Role)
|
||||
}
|
||||
if n.Scopes == nil || *n.Scopes != "#dk" {
|
||||
t.Errorf("Scopes = %v, want '#dk'", n.Scopes)
|
||||
}
|
||||
if n.Status != "responded" {
|
||||
t.Errorf("Status = %q, want 'responded'", n.Status)
|
||||
}
|
||||
if body.ReportedAt != "2026-07-26T10:00:00Z" {
|
||||
t.Errorf("ReportedAt = %q, want '2026-07-26T10:00:00Z'", body.ReportedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleObserverNeighbors_NeverReportedReturnsEmptyNotError(t *testing.T) {
|
||||
srv, router := setupTestServer(t)
|
||||
_ = srv
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/observers/obs1/neighbors", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200 (absence is not a fault), got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Neighbors []interface{} `json:"neighbors"`
|
||||
ReportedAt string `json:"reportedAt"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
if body.Neighbors == nil {
|
||||
t.Error("neighbors must be an empty array, not null, when the observer never reported")
|
||||
}
|
||||
if len(body.Neighbors) != 0 {
|
||||
t.Errorf("expected 0 neighbors, got %d", len(body.Neighbors))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleObserverNeighbors_UnresolvedPubkeyHasNilNameAndRole(t *testing.T) {
|
||||
srv, router := setupTestServer(t)
|
||||
|
||||
if _, err := srv.db.conn.Exec(`INSERT INTO observer_neighbors (observer_id, neighbor_pubkey, scopes, status, reported_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
"obs1", "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "", "timeout", "2026-07-26T11:00:00Z"); err != nil {
|
||||
t.Fatalf("seed observer_neighbors: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/observers/obs1/neighbors", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Neighbors []struct {
|
||||
Pubkey string `json:"pubkey"`
|
||||
Name *string `json:"name"`
|
||||
Scopes *string `json:"scopes"`
|
||||
Status string `json:"status"`
|
||||
} `json:"neighbors"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
if len(body.Neighbors) != 1 {
|
||||
t.Fatalf("expected 1 neighbor, got %d", len(body.Neighbors))
|
||||
}
|
||||
n := body.Neighbors[0]
|
||||
if n.Name != nil {
|
||||
t.Errorf("Name = %v, want nil for a pubkey with no matching node", n.Name)
|
||||
}
|
||||
if n.Scopes != nil {
|
||||
t.Errorf("Scopes = %v, want nil for a timeout entry", n.Scopes)
|
||||
}
|
||||
if n.Status != "timeout" {
|
||||
t.Errorf("Status = %q, want 'timeout'", n.Status)
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,7 @@ func routeDescriptions() map[string]routeMeta {
|
||||
"GET /api/observers/{id}": {Summary: "Get observer detail", Tag: "observers"},
|
||||
"GET /api/observers/{id}/metrics": {Summary: "Get observer metrics", Description: "Packet rates, uptime, and performance metrics.", Tag: "observers"},
|
||||
"GET /api/observers/{id}/analytics": {Summary: "Get observer analytics", Tag: "observers"},
|
||||
"GET /api/observers/{id}/neighbors": {Summary: "Get an observer's direct (zero-hop) neighbors", Description: "Ground truth from the observer's own /neighbors firmware report (#1865) -- distinct from the packet-path-inferred neighbor graph. Empty `neighbors` (never null) and an empty `reportedAt` mean the observer has never sent a /neighbors report: opt-in firmware, unavailable on non-PSRAM hardware -- absence is normal, not a fault. Each entry's `scopes` is null unless the neighbor's OTA scope query responded (status=\"responded\"); `name`/`role` are null when the pubkey doesn't resolve to a known node.", Tag: "observers"},
|
||||
"GET /api/observers/metrics/summary": {Summary: "Observer metrics summary", Description: "Aggregate metrics across all observers.", Tag: "observers"},
|
||||
|
||||
// Misc
|
||||
|
||||
@@ -348,6 +348,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/api/observers/metrics/summary", s.handleMetricsSummary).Methods("GET")
|
||||
r.HandleFunc("/api/observers/{id}/metrics", s.handleObserverMetrics).Methods("GET")
|
||||
r.HandleFunc("/api/observers/{id}/analytics", s.handleObserverAnalytics).Methods("GET")
|
||||
r.HandleFunc("/api/observers/{id}/neighbors", s.handleObserverNeighbors).Methods("GET")
|
||||
r.HandleFunc("/api/observers/{id}", s.handleObserverDetail).Methods("GET")
|
||||
r.HandleFunc("/api/observers", s.handleObservers).Methods("GET")
|
||||
r.HandleFunc("/api/traces/{hash}", s.handleTraces).Methods("GET")
|
||||
@@ -3225,6 +3226,31 @@ func (s *Server) handleObserverDetail(w http.ResponseWriter, r *http.Request) {
|
||||
}())
|
||||
}
|
||||
|
||||
// handleObserverNeighbors serves the #1865 follow-up "Direct Neighbors"
|
||||
// panel: the observer's own firmware-reported zero-hop neighbor set, ground
|
||||
// truth from /neighbors distinct from the packet-path-inferred
|
||||
// neighbor_edges graph. Empty/never-reported is a normal 200 with an empty
|
||||
// list -- not an error (the feature is opt-in and hardware-gated; absence
|
||||
// must not read as a fault, per cwichura's request on PR #1867).
|
||||
func (s *Server) handleObserverNeighbors(w http.ResponseWriter, r *http.Request) {
|
||||
id := mux.Vars(r)["id"]
|
||||
|
||||
if s.cfg != nil && s.cfg.IsObserverBlacklisted(id) {
|
||||
writeError(w, 404, "Observer not found")
|
||||
return
|
||||
}
|
||||
|
||||
neighbors, reportedAt, err := s.db.GetObserverNeighbors(id)
|
||||
if err != nil {
|
||||
writeError(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"neighbors": neighbors,
|
||||
"reportedAt": reportedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleObserverAnalytics(w http.ResponseWriter, r *http.Request) {
|
||||
id := mux.Vars(r)["id"]
|
||||
days := queryInt(r, "days", 7)
|
||||
|
||||
@@ -97,6 +97,9 @@ func Apply(rw *sql.DB, logf Logger) error {
|
||||
if err := ensureLastNeighborsReportAtColumn(rw, logf); err != nil {
|
||||
return fmt.Errorf("ensure observers.last_neighbors_report_at: %w", err)
|
||||
}
|
||||
if err := ensureObserverNeighborsTable(rw, logf); err != nil {
|
||||
return fmt.Errorf("ensure observer_neighbors: %w", err)
|
||||
}
|
||||
// #1690: denormalized last_seen on transmissions so cold-load filters
|
||||
// on effective recency rather than first-ever first_seen. The column
|
||||
// add + index creation are cheap (single ALTER, indexed INTEGER
|
||||
@@ -187,6 +190,7 @@ func AssertReady(ro *sql.DB) error {
|
||||
// firmware feature). Owned by ingestor — TouchObserverNeighborsReport
|
||||
// is the only writer.
|
||||
mustCol("observers", "last_neighbors_report_at")
|
||||
mustTable("observer_neighbors")
|
||||
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("schema not migrated by ingestor; restart ingestor first. missing: %s",
|
||||
@@ -755,3 +759,39 @@ func ensurePingTriggersTable(rw *sql.DB, logf Logger) error {
|
||||
logf("[dbschema] created ping_triggers table")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureObserverNeighborsTable creates observer_neighbors for the #1865
|
||||
// follow-up "Direct Neighbors" panel: the observer's own firmware-reported
|
||||
// zero-hop neighbor table (ground truth from /neighbors, distinct from the
|
||||
// packet-path-inferred neighbor_edges graph). Deliberately a CURRENT-ONLY
|
||||
// snapshot, not history -- the ingestor's ReplaceObserverNeighbors deletes
|
||||
// and re-inserts the full set on every report, so a neighbor that drops off
|
||||
// naturally disappears rather than accumulating stale rows forever.
|
||||
func ensureObserverNeighborsTable(rw *sql.DB, logf Logger) error {
|
||||
if err := ensureMigrationsTable(rw); err != nil {
|
||||
return err
|
||||
}
|
||||
row := rw.QueryRow(`SELECT 1 FROM _migrations WHERE name = 'observer_neighbors_v1'`)
|
||||
var one int
|
||||
if err := row.Scan(&one); err == nil {
|
||||
return nil // already applied
|
||||
}
|
||||
if _, err := rw.Exec(`CREATE TABLE IF NOT EXISTS observer_neighbors (
|
||||
observer_id TEXT NOT NULL,
|
||||
neighbor_pubkey TEXT NOT NULL,
|
||||
scopes TEXT,
|
||||
status TEXT NOT NULL,
|
||||
reported_at TEXT,
|
||||
PRIMARY KEY (observer_id, neighbor_pubkey)
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("create observer_neighbors: %w", err)
|
||||
}
|
||||
if _, err := rw.Exec(`CREATE INDEX IF NOT EXISTS idx_observer_neighbors_observer ON observer_neighbors(observer_id)`); err != nil {
|
||||
return fmt.Errorf("create idx_observer_neighbors_observer: %w", err)
|
||||
}
|
||||
if _, err := rw.Exec(`INSERT OR IGNORE INTO _migrations (name) VALUES ('observer_neighbors_v1')`); err != nil {
|
||||
return fmt.Errorf("record observer_neighbors_v1: %w", err)
|
||||
}
|
||||
logf("[dbschema] created observer_neighbors table")
|
||||
return nil
|
||||
}
|
||||
@@ -143,17 +143,18 @@ window.ObserverDetailNaiveBanner = {
|
||||
try {
|
||||
destroyCharts();
|
||||
chartDefaults();
|
||||
const [obs, analytics, obsSkewArr] = await Promise.all([
|
||||
const [obs, analytics, obsSkewArr, neighborsData] = await Promise.all([
|
||||
api('/observers/' + encodeURIComponent(currentId)),
|
||||
api('/observers/' + encodeURIComponent(currentId) + '/analytics?days=' + currentDays),
|
||||
api('/observers/clock-skew', { ttl: 30000 }).catch(function() { return []; }),
|
||||
api('/observers/' + encodeURIComponent(currentId) + '/neighbors').catch(function() { return { neighbors: [], reportedAt: '' }; }),
|
||||
]);
|
||||
// Find this observer's calibration data.
|
||||
var obsSkew = null;
|
||||
(Array.isArray(obsSkewArr) ? obsSkewArr : []).forEach(function(s) {
|
||||
if (s && s.observerID === currentId) obsSkew = s;
|
||||
});
|
||||
renderDetail(obs, analytics, obsSkew);
|
||||
renderDetail(obs, analytics, obsSkew, neighborsData);
|
||||
} catch (e) {
|
||||
// SECURITY (OBS-2, PR #1539): use textContent for error messages.
|
||||
// Error.message is JS-controlled and shouldn't normally carry attacker
|
||||
@@ -167,7 +168,46 @@ window.ObserverDetailNaiveBanner = {
|
||||
}
|
||||
}
|
||||
|
||||
function renderDetail(obs, analytics, obsSkew) {
|
||||
function directNeighborRoleIcon(role) {
|
||||
switch (role) {
|
||||
case 'repeater': return '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg>';
|
||||
case 'room': return '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-house-line"/></svg>';
|
||||
case 'sensor': return '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-thermometer"/></svg>';
|
||||
default: return '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-radio"/></svg>';
|
||||
}
|
||||
}
|
||||
|
||||
// #1865 follow-up (cwichura, PR #1867): renders the observer's own
|
||||
// firmware-reported zero-hop neighbor set -- ground truth, distinct from
|
||||
// the packet-path-inferred neighbor graph elsewhere in the app. Absence
|
||||
// is normal (opt-in firmware, unavailable on non-PSRAM hardware) and
|
||||
// renders a plain explanatory note, never an error/warning treatment.
|
||||
function renderDirectNeighbors(neighborsData) {
|
||||
const neighbors = (neighborsData && Array.isArray(neighborsData.neighbors)) ? neighborsData.neighbors : [];
|
||||
if (neighbors.length === 0) {
|
||||
return '<div class="text-muted" style="font-size:12px" title="/neighbors reports are opt-in firmware and unavailable on non-PSRAM hardware">No direct-neighbor data reported yet.</div>';
|
||||
}
|
||||
const rows = neighbors.map(function(n) {
|
||||
const shortPubkey = escapeHtml(String(n.pubkey || '').slice(0, 12)) + '…';
|
||||
const label = n.name ? escapeHtml(n.name) : shortPubkey;
|
||||
const nameCell = n.name
|
||||
? '<a href="#/nodes/' + encodeURIComponent(n.pubkey) + '">' + directNeighborRoleIcon(n.role) + ' ' + label + '</a>'
|
||||
: directNeighborRoleIcon(n.role) + ' <span class="mono">' + label + '</span>';
|
||||
const scopeCell = n.scopes
|
||||
? '<span class="badge-region">' + escapeHtml(n.scopes) + '</span>'
|
||||
: (n.status === 'timeout'
|
||||
? '<span class="text-muted" title="Scope query timed out">no reply</span>'
|
||||
: '<span class="text-muted">—</span>');
|
||||
return '<tr><td>' + nameCell + '</td><td>' + scopeCell + '</td></tr>';
|
||||
}).join('');
|
||||
const asOf = neighborsData.reportedAt
|
||||
? '<div class="text-muted" style="font-size:11px;margin-top:6px">As of ' + timeAgo(neighborsData.reportedAt) + '</div>'
|
||||
: '';
|
||||
return '<div class="table-fluid-wrap"><table class="data-table"><thead><tr><th scope="col">Neighbor</th><th scope="col">Configured Scope</th></tr></thead><tbody>' + rows + '</tbody></table></div>' + asOf;
|
||||
}
|
||||
window.renderDirectNeighbors = renderDirectNeighbors;
|
||||
|
||||
function renderDetail(obs, analytics, obsSkew, neighborsData) {
|
||||
const el = document.getElementById('obsDetailContent');
|
||||
if (!el) return;
|
||||
|
||||
@@ -291,6 +331,10 @@ window.ObserverDetailNaiveBanner = {
|
||||
<strong>How this is computed:</strong> when this observer and another observer see the same packet, we compare their receive timestamps. The median deviation across all multi-observer packets is this observer's offset.
|
||||
</div>
|
||||
</div>` : ''}
|
||||
<div class="node-full-card" style="margin-bottom:20px;padding:12px">
|
||||
<h4 style="margin:0 0 6px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-share-network"/></svg> Direct Neighbors</h4>
|
||||
${renderDirectNeighbors(neighborsData)}
|
||||
</div>
|
||||
<div class="obs-charts" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(400px,1fr));gap:16px">
|
||||
<div class="chart-card" style="padding:12px">
|
||||
<h3 style="margin:0 0 8px;font-size:0.95em">Packets Over Time</h3>
|
||||
|
||||
@@ -76,6 +76,7 @@ node test-channels-ping-bot-reply.js
|
||||
node test-packet-path-map.js
|
||||
node test-ping-scores.js
|
||||
node test-observer-neighbors-report-badge.js
|
||||
node test-observer-direct-neighbors-panel.js
|
||||
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════"
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/* #1865 follow-up (cwichura, PR #1867): "Direct Neighbors" panel on the
|
||||
* observer detail page. Production code must expose:
|
||||
* window.renderDirectNeighbors(neighborsData) -> string
|
||||
* Must render a plain, neutral note (no warning icon) when there is no
|
||||
* neighbor data -- absence is normal (opt-in firmware, non-PSRAM hardware),
|
||||
* never a fault.
|
||||
*/
|
||||
'use strict';
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const assert = require('assert');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function test(name, fn) {
|
||||
try { fn(); passed++; console.log(` ✅ ${name}`); }
|
||||
catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); }
|
||||
}
|
||||
|
||||
function makeCtx() {
|
||||
const ctx = {
|
||||
window: { addEventListener: () => {}, dispatchEvent: () => {} },
|
||||
document: {
|
||||
readyState: 'complete',
|
||||
createElement: () => ({ id: '', textContent: '', innerHTML: '' }),
|
||||
head: { appendChild: () => {} },
|
||||
getElementById: () => null,
|
||||
addEventListener: () => {},
|
||||
querySelectorAll: () => [],
|
||||
querySelector: () => null,
|
||||
},
|
||||
console, Date, Math, Array, Object, String, Number, Boolean, JSON,
|
||||
setInterval: () => 0, clearInterval: () => {},
|
||||
setTimeout: (fn) => { try { fn(); } catch {} return 0; },
|
||||
encodeURIComponent, decodeURIComponent,
|
||||
fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
|
||||
};
|
||||
ctx.registerPage = () => {};
|
||||
ctx.timeAgo = (iso) => 'TIME_AGO(' + iso + ')';
|
||||
ctx.escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
})[c]);
|
||||
ctx.Chart = function () { return { destroy() {} }; };
|
||||
vm.createContext(ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
console.log('\n=== #1865 follow-up — Observer detail "Direct Neighbors" panel ===');
|
||||
|
||||
const ctx = makeCtx();
|
||||
vm.runInContext(fs.readFileSync('public/observer-detail.js', 'utf8'), ctx);
|
||||
|
||||
test('window.renderDirectNeighbors exists', () => {
|
||||
assert.strictEqual(typeof ctx.window.renderDirectNeighbors, 'function');
|
||||
});
|
||||
|
||||
test('empty/never-reported renders a neutral note, not a warning', () => {
|
||||
const html = ctx.window.renderDirectNeighbors({ neighbors: [], reportedAt: '' });
|
||||
assert.ok(/text-muted/.test(html));
|
||||
assert.ok(!/ph-warning/.test(html));
|
||||
assert.ok(/opt-in/.test(html));
|
||||
});
|
||||
|
||||
test('missing neighbors field (defensive) also renders the empty note', () => {
|
||||
const html = ctx.window.renderDirectNeighbors({});
|
||||
assert.ok(/No direct-neighbor data/.test(html));
|
||||
});
|
||||
|
||||
test('a known node renders a link + role icon + escaped name', () => {
|
||||
const html = ctx.window.renderDirectNeighbors({
|
||||
neighbors: [{ pubkey: 'abc123', name: '<b>Evil</b>', role: 'repeater', scopes: '#dk', status: 'responded' }],
|
||||
reportedAt: '2026-07-26T10:00:00Z',
|
||||
});
|
||||
assert.ok(html.includes('href="#/nodes/abc123"'), 'expected a link to the node detail page');
|
||||
assert.ok(html.includes('ph-broadcast'), 'expected the repeater role icon');
|
||||
assert.ok(!html.includes('<b>Evil</b>'), 'name must be HTML-escaped');
|
||||
assert.ok(html.includes('<b>'), 'expected escaped name markup');
|
||||
assert.ok(html.includes('#dk'), 'expected the scope badge');
|
||||
assert.ok(html.includes('TIME_AGO(2026-07-26T10:00:00Z)'), 'expected the "as of" timestamp');
|
||||
});
|
||||
|
||||
test('an unresolved pubkey (no name) renders truncated, unlinked', () => {
|
||||
const html = ctx.window.renderDirectNeighbors({
|
||||
neighbors: [{ pubkey: 'deadbeefdeadbeefdeadbeef', name: null, role: null, scopes: null, status: 'timeout' }],
|
||||
reportedAt: '',
|
||||
});
|
||||
assert.ok(!/href="#\/nodes\//.test(html), 'must not link an unresolved pubkey');
|
||||
assert.ok(html.includes('deadbeefdead'), 'expected a truncated pubkey');
|
||||
assert.ok(/no reply/.test(html), 'expected the timeout "no reply" label');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
Reference in new issue
Block a user