Merge branch 'areas-meshguide-sync'

This commit is contained in:
dborup committed 2026-07-26 18:48:15 +02:00
commit 41b6d93d53
12 files changed
+620 -9

No files matched your search

+58
View File
@@ -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
View File
@@ -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)
}
}
+57
View File
@@ -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)
+9
View File
@@ -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)
}
}
+1
View File
@@ -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
+26
View File
@@ -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)
+40
View File
@@ -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
}
+47 -3
View File
@@ -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>
+1
View File
@@ -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 "═══════════════════════════════════════"
+92
View File
@@ -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) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
})[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('&lt;b&gt;'), '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);