diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b1b05a2e..cbeb12e7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -589,6 +589,7 @@ jobs: suite 1 test-a11y-axe-1668.js BASE_URL=$B AXE_SCREENSHOT_DIR=/tmp/axe-1668 suite 2 test-filter-ux-e2e.js BASE_URL=$B suite 2 test-radio-setting-1869-e2e.js BASE_URL=$B + suite 2 test-observer-neighbors-2270-e2e.js BASE_URL=$B suite 1 test-channel-issue-1087-e2e.js BASE_URL=$B suite 1 test-channel-issue-1111-e2e.js BASE_URL=$B suite 2 test-map-modal-fluid-e2e.js BASE_URL=$B diff --git a/cmd/ingestor/db.go b/cmd/ingestor/db.go index e6438b35..f3847dce 100644 --- a/cmd/ingestor/db.go +++ b/cmd/ingestor/db.go @@ -2265,6 +2265,11 @@ func (s *Store) PurgeStaleObservers(purgeDays int) (int64, error) { `DELETE FROM observer_radios WHERE observer_id NOT IN (SELECT id FROM observers)`); err != nil { log.Printf("[purge] observer radio history: %v", err) } + // And so do their /neighbors reports (#2330). + if _, err := s.instrumentedExec("purge_observers", + `DELETE FROM observer_neighbor_reports WHERE observer_id NOT IN (SELECT id FROM observers)`); err != nil { + log.Printf("[purge] observer neighbor reports: %v", err) + } } return purged, nil } diff --git a/cmd/ingestor/main.go b/cmd/ingestor/main.go index 461c7990..be0250a8 100644 --- a/cmd/ingestor/main.go +++ b/cmd/ingestor/main.go @@ -1007,6 +1007,11 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message, // does not apply. if len(parts) >= 4 && parts[3] == "neighbors" { handleNeighborsReport(store, tag, parts[2], msg) + // #2270/#2271: keep the report itself for the observer pages. A + // retained replay must not pose as a fresh report. + if err := store.UpsertNeighborReport(clampObserverField(parts[2], maxObserverIDLen), msg, m.Retained(), time.Now()); err != nil { + log.Printf("MQTT [%s] neighbors report store error: %v", tag, err) + } return } diff --git a/cmd/ingestor/neighbor_reports.go b/cmd/ingestor/neighbor_reports.go new file mode 100644 index 00000000..8b6d67ad --- /dev/null +++ b/cmd/ingestor/neighbor_reports.go @@ -0,0 +1,132 @@ +package main + +import ( + "encoding/json" + "math" + "regexp" + "strings" + "time" +) + +// Latest /neighbors report per observer (#2270, #2271); schema in +// internal/dbschema/observer_neighbors.go. +// +// The report is unauthenticated, so what is stored is a sanitised copy: +// neighbors with a 64-hex key only, numbers finite and in range, strings +// clamped, at most maxStoredNeighbors rows. + +const maxStoredNeighbors = 500 + +var neighborStatusRe = regexp.MustCompile(`^[a-z_]{1,32}$`) + +// StoredNeighbor is one neighbors[] entry as kept in neighbors_json. +type StoredNeighbor struct { + Pubkey string `json:"pubkey"` + SNR *float64 `json:"snr,omitempty"` + HeardSecsAgo *int64 `json:"heard_secs_ago,omitempty"` + Status string `json:"status,omitempty"` + Scopes string `json:"scopes,omitempty"` +} + +// sanitizeNeighbors keeps the well-formed entries of a report's neighbors[]. +func sanitizeNeighbors(raw []interface{}) []StoredNeighbor { + out := make([]StoredNeighbor, 0, len(raw)) + for _, item := range raw { + if len(out) >= maxStoredNeighbors { + break + } + n, ok := item.(map[string]interface{}) + if !ok { + continue + } + pk, _ := n["pubkey"].(string) + pk = strings.ToLower(strings.TrimSpace(pk)) + if !targetPubkeyRe.MatchString(pk) { + continue + } + e := StoredNeighbor{Pubkey: pk} + if f, ok := toFloat64(n["snr"]); ok && !math.IsNaN(f) && f >= -64 && f <= 64 { + f = math.Round(f*100) / 100 + e.SNR = &f + } + if f, ok := toFloat64(n["heard_secs_ago"]); ok && f >= 0 && f <= 10*365*86400 { + v := int64(f) + e.HeardSecsAgo = &v + } + if s, _ := n["status"].(string); neighborStatusRe.MatchString(s) { + e.Status = s + } + if s, _ := n["scopes"].(string); s != "" { + e.Scopes = clampObserverField(strings.TrimSpace(s), 256) + } + out = append(out, e) + } + return out +} + +// UpsertNeighborReport stores the observer's latest report. +// +// Only a live report is dated "now". A replay is dated by the observer's own +// timestamp and replaces the stored row only when it is newer, so it can fill +// a gap but never make a long-dead observer look fresh. What counts as a +// replay is judged like status liveness (#2330): the retain flag, or a +// payload timestamp older than statusLivenessMaxAge. The flag alone is not +// enough, because a mosquitto bridge republishes retained messages with +// RETAIN cleared (statusIsLiveness explains how). +func (s *Store) UpsertNeighborReport(observerID string, msg map[string]interface{}, retained bool, now time.Time) error { + if observerID == "" { + return nil + } + rawList, _ := msg["neighbors"].([]interface{}) + neighbors := sanitizeNeighbors(rawList) + count := len(neighbors) + // total_neighbors is the observer's own count (firmware may truncate the + // list); trust it only when it is at least what arrived. + if f, ok := toFloat64(msg["total_neighbors"]); ok && f >= float64(len(rawList)) && f <= 100000 { + count = int(f) + } + var queried interface{} + if f, ok := toFloat64(msg["queried_neighbors"]); ok && f >= 0 && f <= 100000 { + queried = int(f) + } + var selfScopes, selfDefault interface{} + if self, ok := msg["self"].(map[string]interface{}); ok { + if v, ok := self["scopes"].(string); ok { + selfScopes = clampObserverField(strings.TrimSpace(v), 256) + } + if v, ok := self["default_scope"].(string); ok && v != "" { + selfDefault = clampObserverField(strings.TrimSpace(v), 64) + } + } + reportedAt, _ := msg["timestamp"].(string) + body, err := json.Marshal(neighbors) + if err != nil { + return err + } + receivedAt := now.UTC() + // A live report replaces the row; a replay only when it is newer. + onConflict := "" + if t, _, perr := parseEnvelopeTime(reportedAt); perr == nil && (retained || t.Before(now.Add(-statusLivenessMaxAge))) { + if t.Before(receivedAt) { + receivedAt = t.UTC() + } + onConflict = " WHERE excluded.received_at > observer_neighbor_reports.received_at" + } else if retained { + // A replay we cannot date says nothing about when it was made. + return nil + } + _, err = s.db.Exec(`INSERT INTO observer_neighbor_reports + (observer_id, received_at, reported_at, neighbor_count, queried_neighbors, self_scopes, self_default_scope, neighbors_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(observer_id) DO UPDATE SET + received_at = excluded.received_at, reported_at = excluded.reported_at, + neighbor_count = excluded.neighbor_count, queried_neighbors = excluded.queried_neighbors, + self_scopes = excluded.self_scopes, self_default_scope = excluded.self_default_scope, + neighbors_json = excluded.neighbors_json`+onConflict, + observerID, receivedAt.Format(time.RFC3339), nilIfEmpty(clampObserverField(reportedAt, 40)), + count, queried, selfScopes, selfDefault, string(body)) + if err != nil { + s.Stats.WriteErrors.Add(1) + } + return err +} diff --git a/cmd/ingestor/neighbor_reports_test.go b/cmd/ingestor/neighbor_reports_test.go new file mode 100644 index 00000000..3c610155 --- /dev/null +++ b/cmd/ingestor/neighbor_reports_test.go @@ -0,0 +1,245 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +// #2270 / #2271: the latest /neighbors report per observer. + +const ( + nbKeyA = "aa00000000000000000000000000000000000000000000000000000000000001" + nbKeyB = "bb00000000000000000000000000000000000000000000000000000000000002" +) + +func neighborsMsg(t *testing.T, payload string) map[string]interface{} { + t.Helper() + var msg map[string]interface{} + if err := json.Unmarshal([]byte(payload), &msg); err != nil { + t.Fatal(err) + } + return msg +} + +func TestSanitizeNeighbors(t *testing.T) { + msg := neighborsMsg(t, `{"neighbors":[ + {"pubkey":"`+strings.ToUpper(nbKeyA)+`","snr":7.256,"heard_secs_ago":42,"status":"responded","scopes":"#cz,#cz-ulk"}, + {"pubkey":"not-a-key","snr":1}, + {"pubkey":"`+nbKeyB+`","snr":"NaN","heard_secs_ago":-5,"status":"","scopes":"`+strings.Repeat("x", 400)+`"}, + "garbage"]}`) + got := sanitizeNeighbors(msg["neighbors"].([]interface{})) + if len(got) != 2 { + t.Fatalf("kept %d entries, want 2: %+v", len(got), got) + } + a := got[0] + if a.Pubkey != nbKeyA || a.SNR == nil || *a.SNR != 7.26 || a.HeardSecsAgo == nil || *a.HeardSecsAgo != 42 || + a.Status != "responded" || a.Scopes != "#cz,#cz-ulk" { + t.Errorf("first entry = %+v", a) + } + b := got[1] + if b.SNR != nil || b.HeardSecsAgo != nil || b.Status != "" || len(b.Scopes) > 256 { + t.Errorf("out-of-range fields kept: %+v (scopes %d chars)", b, len(b.Scopes)) + } +} + +func TestSanitizeNeighborsCap(t *testing.T) { + raw := make([]interface{}, maxStoredNeighbors+50) + for i := range raw { + raw[i] = map[string]interface{}{"pubkey": nbKeyA} + } + if n := len(sanitizeNeighbors(raw)); n != maxStoredNeighbors { + t.Errorf("kept %d, want %d", n, maxStoredNeighbors) + } +} + +type storedReport struct { + receivedAt, reportedAt string + count int + neighbors []StoredNeighbor +} + +func loadReport(t *testing.T, s *Store, observer string) *storedReport { + t.Helper() + var r storedReport + var reported *string + var body string + err := s.db.QueryRow(`SELECT received_at, reported_at, neighbor_count, neighbors_json + FROM observer_neighbor_reports WHERE observer_id = ?`, observer).Scan(&r.receivedAt, &reported, &r.count, &body) + if err != nil { + return nil + } + if reported != nil { + r.reportedAt = *reported + } + if err := json.Unmarshal([]byte(body), &r.neighbors); err != nil { + t.Fatal(err) + } + return &r +} + +func TestUpsertNeighborReportLiveReplaces(t *testing.T) { + s := newTestStore(t) + t0 := time.Date(2026, 10, 25, 10, 0, 0, 0, time.UTC) + first := neighborsMsg(t, `{"timestamp":"2026-10-25T09:59:58Z","total_neighbors":2, + "neighbors":[{"pubkey":"`+nbKeyA+`"},{"pubkey":"`+nbKeyB+`"}]}`) + if err := s.UpsertNeighborReport("OBS1", first, false, t0); err != nil { + t.Fatal(err) + } + second := neighborsMsg(t, `{"timestamp":"2026-10-25T16:00:00Z","total_neighbors":40,"neighbors":[{"pubkey":"`+nbKeyB+`"}]}`) + if err := s.UpsertNeighborReport("OBS1", second, false, t0.Add(6*time.Hour)); err != nil { + t.Fatal(err) + } + r := loadReport(t, s, "OBS1") + if r == nil || r.receivedAt != "2026-10-25T16:00:00Z" || r.count != 40 || len(r.neighbors) != 1 || r.neighbors[0].Pubkey != nbKeyB { + t.Errorf("after second report: %+v", r) + } +} + +// total_neighbors below what arrived is not believed. +func TestUpsertNeighborReportCountNotBelowList(t *testing.T) { + s := newTestStore(t) + msg := neighborsMsg(t, `{"total_neighbors":0,"neighbors":[{"pubkey":"`+nbKeyA+`"},{"pubkey":"`+nbKeyB+`"}]}`) + if err := s.UpsertNeighborReport("OBS1", msg, false, time.Now()); err != nil { + t.Fatal(err) + } + if r := loadReport(t, s, "OBS1"); r == nil || r.count != 2 { + t.Errorf("count = %+v, want 2", r) + } +} + +// A retained replay fills a missing row, dated by the report, and never +// overwrites a live one. +func TestUpsertNeighborReportRetained(t *testing.T) { + s := newTestStore(t) + now := time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC) + old := neighborsMsg(t, `{"timestamp":"2026-10-20T08:00:00.123Z","neighbors":[{"pubkey":"`+nbKeyA+`"}]}`) + if err := s.UpsertNeighborReport("OBS1", old, true, now); err != nil { + t.Fatal(err) + } + if r := loadReport(t, s, "OBS1"); r == nil || r.receivedAt != "2026-10-20T08:00:00Z" { + t.Errorf("retained into empty: %+v, want dated 2026-10-20T08:00:00Z", r) + } + + live := neighborsMsg(t, `{"timestamp":"2026-10-25T11:59:59Z","neighbors":[{"pubkey":"`+nbKeyB+`"}]}`) + if err := s.UpsertNeighborReport("OBS2", live, false, now); err != nil { + t.Fatal(err) + } + if err := s.UpsertNeighborReport("OBS2", old, true, now.Add(time.Hour)); err != nil { + t.Fatal(err) + } + if r := loadReport(t, s, "OBS2"); r == nil || r.receivedAt != "2026-10-25T12:00:00Z" || r.neighbors[0].Pubkey != nbKeyB { + t.Errorf("retained replaced a live report: %+v", r) + } + + undated := neighborsMsg(t, `{"neighbors":[{"pubkey":"`+nbKeyA+`"}]}`) + if err := s.UpsertNeighborReport("OBS3", undated, true, now); err != nil { + t.Fatal(err) + } + if r := loadReport(t, s, "OBS3"); r != nil { + t.Errorf("undated retained report stored: %+v", r) + } +} + +// End to end through the MQTT handler; the scope side of the report (#1865) +// keeps working. +func TestNeighborsTopicStoresReport(t *testing.T) { + s := newTestStore(t) + msg := &mockMessage{topic: "meshcore/TST/OBS9/neighbors", + payload: []byte(`{"timestamp":"2026-10-25T10:00:00Z","origin":"o","origin_id":"OBS9", + "total_neighbors":1,"queried_neighbors":1,"self":{"scopes":"#tst","default_scope":"*"}, + "neighbors":[{"pubkey":"` + nbKeyA + `","snr":5.5,"heard_secs_ago":30,"status":"responded","scopes":"#tst"}]}`)} + handleMessage(s, "test", MQTTSource{Name: "test"}, msg, nil, nil, &Config{}) + r := loadReport(t, s, "OBS9") + if r == nil || r.count != 1 || len(r.neighbors) != 1 || r.neighbors[0].Status != "responded" { + t.Fatalf("stored report = %+v", r) + } + var selfScopes, def string + if err := s.db.QueryRow(`SELECT self_scopes, self_default_scope FROM observer_neighbor_reports WHERE observer_id='OBS9'`). + Scan(&selfScopes, &def); err != nil || selfScopes != "#tst" || def != "*" { + t.Errorf("self = %q %q (%v)", selfScopes, def, err) + } +} + +// #2330: a bridge republishes retained reports with RETAIN cleared, so an +// old payload is a replay whatever the flag says. It never refreshes or +// replaces a live row, and into an empty row it goes dated by itself. +func TestUpsertNeighborReportOldUnretainedIsReplay(t *testing.T) { + s := newTestStore(t) + now := time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC) + old := neighborsMsg(t, `{"timestamp":"2026-10-20T08:00:00Z","neighbors":[{"pubkey":"`+nbKeyA+`"}]}`) + + if err := s.UpsertNeighborReport("OBS1", old, false, now); err != nil { + t.Fatal(err) + } + if r := loadReport(t, s, "OBS1"); r == nil || r.receivedAt != "2026-10-20T08:00:00Z" { + t.Errorf("old report into empty row: %+v, want dated 2026-10-20T08:00:00Z", r) + } + + live := neighborsMsg(t, `{"timestamp":"2026-10-25T11:59:59Z","neighbors":[{"pubkey":"`+nbKeyB+`"}]}`) + if err := s.UpsertNeighborReport("OBS2", live, false, now); err != nil { + t.Fatal(err) + } + if err := s.UpsertNeighborReport("OBS2", old, false, now.Add(time.Hour)); err != nil { + t.Fatal(err) + } + if r := loadReport(t, s, "OBS2"); r == nil || r.receivedAt != "2026-10-25T12:00:00Z" || r.neighbors[0].Pubkey != nbKeyB { + t.Errorf("old unretained report replaced a live one: %+v", r) + } +} + +// #2330: a replay newer than the stored row still replaces it; one older +// than the stored row does not. +func TestUpsertNeighborReportReplayKeepsNewest(t *testing.T) { + s := newTestStore(t) + now := time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC) + older := neighborsMsg(t, `{"timestamp":"2026-10-18T08:00:00Z","neighbors":[{"pubkey":"`+nbKeyA+`"}]}`) + newer := neighborsMsg(t, `{"timestamp":"2026-10-20T08:00:00Z","neighbors":[{"pubkey":"`+nbKeyB+`"}]}`) + + for _, m := range []map[string]interface{}{older, newer, older} { + if err := s.UpsertNeighborReport("OBS1", m, true, now); err != nil { + t.Fatal(err) + } + } + if r := loadReport(t, s, "OBS1"); r == nil || r.receivedAt != "2026-10-20T08:00:00Z" || r.neighbors[0].Pubkey != nbKeyB { + t.Errorf("after older, newer, older replay: %+v, want the 2026-10-20 report", r) + } +} + +// #2330: a retained report with a zone-less timestamp (#1478) is stored and +// keeps its own age instead of being dropped. +func TestUpsertNeighborReportRetainedNaiveTimestamp(t *testing.T) { + s := newTestStore(t) + now := time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC) + for id, ts := range map[string]string{"OBS1": "2026-10-20T08:00:00", "OBS2": "2026-10-20T08:00:00.123456"} { + msg := neighborsMsg(t, `{"timestamp":"`+ts+`","neighbors":[{"pubkey":"`+nbKeyA+`"}]}`) + if err := s.UpsertNeighborReport(id, msg, true, now); err != nil { + t.Fatal(err) + } + if r := loadReport(t, s, id); r == nil || r.receivedAt != "2026-10-20T08:00:00Z" { + t.Errorf("%s: retained %q stored as %+v, want dated 2026-10-20T08:00:00Z", id, ts, r) + } + } +} + +// #2330 end to end: an old report arriving without RETAIN (as through a +// bridge) does not refresh the observer's report. +func TestNeighborsTopicOldReportDoesNotRefresh(t *testing.T) { + s := newTestStore(t) + payload := func(ts string) []byte { + return []byte(`{"timestamp":"` + ts + `","neighbors":[{"pubkey":"` + nbKeyA + `"}]}`) + } + live := time.Now().UTC().Add(-time.Minute).Format(time.RFC3339) + handleMessage(s, "test", MQTTSource{Name: "test"}, + &mockMessage{topic: "meshcore/TST/OBS9/neighbors", payload: payload(live)}, nil, nil, &Config{}) + before := loadReport(t, s, "OBS9") + if before == nil { + t.Fatal("live report not stored") + } + handleMessage(s, "test", MQTTSource{Name: "test"}, + &mockMessage{topic: "meshcore/TST/OBS9/neighbors", payload: payload("2026-01-01T00:00:00Z")}, nil, nil, &Config{}) + if after := loadReport(t, s, "OBS9"); after == nil || after.receivedAt != before.receivedAt || after.reportedAt != live { + t.Errorf("old report changed the row: before %+v, after %+v", before, after) + } +} diff --git a/cmd/ingestor/observer_purge_test.go b/cmd/ingestor/observer_purge_test.go index 8a3f6a6a..5a8f672f 100644 --- a/cmd/ingestor/observer_purge_test.go +++ b/cmd/ingestor/observer_purge_test.go @@ -220,3 +220,32 @@ func TestObserverPurgeDaysOrZero(t *testing.T) { }) } } + +// #2330: a purged observer's /neighbors report goes with it; a kept +// observer's report stays. +func TestPurgeStaleObserversDeletesNeighborReport(t *testing.T) { + store := newTestStore(t) + staleObserver(t, store, "obs-gone") + if err := store.UpsertObserver("obs-live", "obs-live", "LAX", nil); err != nil { + t.Fatal(err) + } + msg := map[string]interface{}{"neighbors": []interface{}{}} + for _, id := range []string{"obs-gone", "obs-live"} { + if err := store.UpsertNeighborReport(id, msg, false, time.Now()); err != nil { + t.Fatal(err) + } + } + + if purged, err := store.PurgeStaleObservers(30); err != nil || purged != 1 { + t.Fatalf("purged=%d err=%v, want 1", purged, err) + } + for id, want := range map[string]int{"obs-gone": 0, "obs-live": 1} { + var n int + if err := store.db.QueryRow(`SELECT COUNT(*) FROM observer_neighbor_reports WHERE observer_id = ?`, id).Scan(&n); err != nil { + t.Fatal(err) + } + if n != want { + t.Errorf("%s: %d neighbor report rows, want %d", id, n, want) + } + } +} diff --git a/cmd/server/observer_neighbors.go b/cmd/server/observer_neighbors.go new file mode 100644 index 00000000..f92732be --- /dev/null +++ b/cmd/server/observer_neighbors.go @@ -0,0 +1,148 @@ +package main + +import ( + "database/sql" + "encoding/json" + "strings" +) + +// Latest /neighbors report per observer (#2270, #2271), written by the +// ingestor (cmd/ingestor/neighbor_reports.go). The table is optional for the +// server: absent, it reads as "no report" and is probed again next time. + +// ObserverNeighbor is one neighbor of the latest report, with the node's name +// when the analyzer knows it. +type ObserverNeighbor struct { + Pubkey string `json:"pubkey"` + Name *string `json:"name"` + Role *string `json:"role"` + SNR *float64 `json:"snr"` + HeardSecsAgo *int64 `json:"heard_secs_ago"` + Status string `json:"status,omitempty"` + Scopes string `json:"scopes,omitempty"` +} + +// ObserverNeighborReport is the latest report of one observer. +type ObserverNeighborReport struct { + ReceivedAt string `json:"received_at"` + ReportedAt *string `json:"reported_at"` + NeighborCount int `json:"neighbor_count"` + QueriedNeighbors *int `json:"queried_neighbors"` + SelfScopes *string `json:"self_scopes"` + SelfDefaultScope *string `json:"self_default_scope"` + Neighbors []ObserverNeighbor `json:"neighbors"` +} + +// neighborSummary is what the observers list shows: when the last report +// arrived and how many neighbors it named. +type neighborSummary struct { + receivedAt string + count int +} + +func (db *DB) hasNeighborReports() bool { + var n int + return db.conn.QueryRow(`SELECT 1 FROM sqlite_master WHERE type='table' AND name='observer_neighbor_reports'`).Scan(&n) == nil +} + +// GetObserverNeighborSummaries returns observer id -> latest report summary. +func (db *DB) GetObserverNeighborSummaries() map[string]neighborSummary { + out := map[string]neighborSummary{} + if !db.hasNeighborReports() { + return out + } + rows, err := db.conn.Query(`SELECT observer_id, received_at, neighbor_count FROM observer_neighbor_reports`) + if err != nil { + return out + } + defer rows.Close() + for rows.Next() { + var id string + var s neighborSummary + if rows.Scan(&id, &s.receivedAt, &s.count) == nil { + out[id] = s + } + } + return out +} + +// GetObserverNeighborReport returns the observer's latest report, nil when it +// never sent one. +func (db *DB) GetObserverNeighborReport(observerID string) (*ObserverNeighborReport, error) { + if !db.hasNeighborReports() { + return nil, nil + } + var rep ObserverNeighborReport + var reportedAt, selfScopes, selfDefault sql.NullString + var queried sql.NullInt64 + var body string + err := db.conn.QueryRow(`SELECT received_at, reported_at, neighbor_count, queried_neighbors, + self_scopes, self_default_scope, neighbors_json + FROM observer_neighbor_reports WHERE observer_id = ?`, observerID). + Scan(&rep.ReceivedAt, &reportedAt, &rep.NeighborCount, &queried, &selfScopes, &selfDefault, &body) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + if reportedAt.Valid { + rep.ReportedAt = &reportedAt.String + } + if queried.Valid { + q := int(queried.Int64) + rep.QueriedNeighbors = &q + } + if selfScopes.Valid { + rep.SelfScopes = &selfScopes.String + } + if selfDefault.Valid { + rep.SelfDefaultScope = &selfDefault.String + } + rep.Neighbors = []ObserverNeighbor{} + if err := json.Unmarshal([]byte(body), &rep.Neighbors); err != nil { + return nil, err + } + db.nameNeighbors(rep.Neighbors) + return &rep, nil +} + +// nameNeighbors fills name and role from nodes, one query for the report. +func (db *DB) nameNeighbors(neighbors []ObserverNeighbor) { + if len(neighbors) == 0 { + return + } + args := make([]interface{}, len(neighbors)) + for i, n := range neighbors { + args[i] = strings.ToLower(n.Pubkey) + } + rows, err := db.conn.Query(`SELECT public_key, name, role FROM nodes WHERE public_key IN (?`+ + strings.Repeat(",?", len(args)-1)+`)`, args...) + if err != nil { + return + } + defer rows.Close() + type nodeInfo struct{ name, role sql.NullString } + byKey := map[string]nodeInfo{} + for rows.Next() { + var pk string + var ni nodeInfo + if rows.Scan(&pk, &ni.name, &ni.role) == nil { + byKey[strings.ToLower(pk)] = ni + } + } + for i := range neighbors { + ni, ok := byKey[strings.ToLower(neighbors[i].Pubkey)] + if !ok { + continue + } + if ni.name.Valid && ni.name.String != "" { + name := ni.name.String + neighbors[i].Name = &name + } + if ni.role.Valid && ni.role.String != "" { + role := ni.role.String + neighbors[i].Role = &role + } + } +} diff --git a/cmd/server/observer_neighbors_test.go b/cmd/server/observer_neighbors_test.go new file mode 100644 index 00000000..b83fd2c0 --- /dev/null +++ b/cmd/server/observer_neighbors_test.go @@ -0,0 +1,104 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// #2270 / #2271: the latest /neighbors report on the observer endpoints. + +func seedNeighborReport(t *testing.T, srv *Server) { + t.Helper() + for _, s := range []string{ + `CREATE TABLE IF NOT EXISTS observer_neighbor_reports (observer_id TEXT PRIMARY KEY, received_at TEXT NOT NULL, + reported_at TEXT, neighbor_count INTEGER NOT NULL, queried_neighbors INTEGER, self_scopes TEXT, + self_default_scope TEXT, neighbors_json TEXT NOT NULL)`, + // aabbccdd11223344 is the fixture's TestRepeater; the second key is unknown. + `INSERT INTO observer_neighbor_reports VALUES ('obs1', '2026-10-25T10:00:00Z', '2026-10-25T09:59:58Z', 2, 2, + '#tst', '*', '[{"pubkey":"aabbccdd11223344","snr":6.5,"heard_secs_ago":30,"status":"responded","scopes":"#tst"}, + {"pubkey":"ff00ff00ff00ff00","status":"timeout"}]')`, + } { + if _, err := srv.db.conn.Exec(s); err != nil { + t.Fatal(err) + } + } +} + +func getObservers(t *testing.T, srv *Server, router http.Handler, url string, into interface{}) { + t.Helper() + srv.observersCacheV2.ptr.Store(nil) // the list is cached for 30 s + req := httptest.NewRequest("GET", url, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("%s: %d %s", url, w.Code, w.Body.String()) + } + if err := json.Unmarshal(w.Body.Bytes(), into); err != nil { + t.Fatal(err) + } +} + +func TestObserversListNeighborSummary(t *testing.T) { + srv, router := setupTestServer(t) + seedNeighborReport(t, srv) + var list ObserverListResponse + getObservers(t, srv, router, "/api/observers", &list) + got := map[string]ObserverResp{} + for _, o := range list.Observers { + got[o.ID] = o + } + o1 := got["obs1"] + if o1.NeighborsReportAt == nil || *o1.NeighborsReportAt != "2026-10-25T10:00:00Z" || o1.NeighborsCount == nil || *o1.NeighborsCount != 2 { + t.Errorf("obs1 = at %v count %v", o1.NeighborsReportAt, o1.NeighborsCount) + } + if o1.NeighborReport != nil { + t.Error("the list must not carry the full report") + } + if o2 := got["obs2"]; o2.NeighborsReportAt != nil || o2.NeighborsCount != nil { + t.Errorf("obs2 never reported but has %v / %v", o2.NeighborsReportAt, o2.NeighborsCount) + } +} + +func TestObserverDetailNeighborReport(t *testing.T) { + srv, router := setupTestServer(t) + seedNeighborReport(t, srv) + var detail ObserverResp + getObservers(t, srv, router, "/api/observers/obs1", &detail) + rep := detail.NeighborReport + if rep == nil || rep.NeighborCount != 2 || len(rep.Neighbors) != 2 || rep.SelfScopes == nil || *rep.SelfScopes != "#tst" { + t.Fatalf("report = %+v", rep) + } + known, unknown := rep.Neighbors[0], rep.Neighbors[1] + if known.Name == nil || *known.Name != "TestRepeater" || known.Role == nil || *known.Role != "repeater" || + known.SNR == nil || *known.SNR != 6.5 || known.Status != "responded" { + t.Errorf("known neighbor = %+v", known) + } + if unknown.Name != nil || unknown.Status != "timeout" { + t.Errorf("unknown neighbor = %+v", unknown) + } + var none ObserverResp + getObservers(t, srv, router, "/api/observers/obs2", &none) + if none.NeighborReport != nil || none.NeighborsReportAt != nil { + t.Errorf("obs2 detail has a report: %+v", none.NeighborReport) + } +} + +// Without the table (a server ahead of its ingestor) the endpoints still +// answer, just without neighbor data. +func TestObserversWithoutNeighborTable(t *testing.T) { + srv, router := setupTestServer(t) + var list ObserverListResponse + getObservers(t, srv, router, "/api/observers", &list) + for _, o := range list.Observers { + if o.NeighborsReportAt != nil { + t.Errorf("%v has a report without the table", o.ID) + } + } + var detail ObserverResp + getObservers(t, srv, router, "/api/observers/obs1", &detail) + if detail.NeighborReport != nil { + t.Error("detail has a report without the table") + } +} diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index b3bc8e62..b3ea471e 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -238,9 +238,9 @@ func routeDescriptions() map[string]routeMeta { "GET /api/channels/{hash}/messages": {Summary: "Get channel messages", Description: "Returns messages for a specific channel.", Tag: "channels"}, // Observers - "GET /api/observers": {Summary: "List observers", Description: "Returns all known packet observers/gateways.", Tag: "observers"}, + "GET /api/observers": {Summary: "List observers", Description: "Returns all known packet observers/gateways. neighbors_report_at (when the latest /neighbors report arrived) and neighbors_count (how many neighbors it named) are present for observers that ever sent one (#2270).", Tag: "observers"}, "GET /api/observers/clock-skew": {Summary: "Observer clock calibrations", Description: "Returns an array of observer clock calibrations: observerID, offsetSec (seconds; positive means the observer clock is ahead) and samples (number of multi-observer packet samples used). Returns an empty array when the store or calibration data is unavailable.", Tag: "observers", Response: schemaRef("ObserverClockSkewResponse")}, - "GET /api/observers/{id}": {Summary: "Get observer detail", Description: "radio_history lists every radio setting the observer reported, oldest first: {radio_id, radio, first_seen, last_seen}. radio_id is empty for the main radio and the node's own id for an entry of a multi-radio status radios map (#1869).", Tag: "observers"}, + "GET /api/observers/{id}": {Summary: "Get observer detail", Description: "radio_history lists every radio setting the observer reported, oldest first: {radio_id, radio, first_seen, last_seen}. radio_id is empty for the main radio and the node's own id for an entry of a multi-radio status radios map (#1869). neighbor_report is the latest /neighbors report (#2271): {received_at, reported_at, neighbor_count, queried_neighbors, self_scopes, self_default_scope, neighbors: [{pubkey, name, role, snr, heard_secs_ago, status, scopes}]}; name and role come from the node table when known. Absent when the observer never sent one.", Tag: "observers"}, "GET /api/radios": {Summary: "Radio settings heard on", Description: "Every radio setting (canonical \"MHz,kHz,SF,CR\") some observer has listened on, with those observers and the span of time, for the radio filter of /api/packets (#1869).", 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"}, diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 3507274d..d89ada5b 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -3098,6 +3098,7 @@ func (s *Server) buildObserversDefaultResponse() (ObserverListResponse, error) { observerIDs[i] = o.ID } nodeLocations := s.db.GetNodeLocationsByKeys(observerIDs) + neighborSummaries := s.db.GetObserverNeighborSummaries() result := make([]ObserverResp, 0, len(observers)) nowTime := time.Now().UTC() @@ -3131,6 +3132,10 @@ func (s *Server) buildObserversDefaultResponse() (ObserverListResponse, error) { Lat: lat, Lon: lon, NodeRole: nodeRole, CanRelay: o.CanRelay, } + if ns, ok := neighborSummaries[o.ID]; ok { + at, count := ns.receivedAt, ns.count + resp.NeighborsReportAt, resp.NeighborsCount = &at, &count + } applyObserverNaiveClock(&resp, o, nowTime) result = append(result, resp) } @@ -3176,6 +3181,10 @@ func (s *Server) handleObserverDetail(w http.ResponseWriter, r *http.Request) { PacketsLastHour: plh, CanRelay: obs.CanRelay, } + if rep, err := s.db.GetObserverNeighborReport(obs.ID); err == nil && rep != nil { + at, count := rep.ReceivedAt, rep.NeighborCount + resp.NeighborsReportAt, resp.NeighborsCount, resp.NeighborReport = &at, &count, rep + } applyObserverNaiveClock(&resp, obs, time.Now().UTC()) resp.RadioHistory = observerRadioStints(obs.ID) return resp diff --git a/cmd/server/types.go b/cmd/server/types.go index f02adf07..40588660 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -1004,6 +1004,12 @@ type ObserverResp struct { // RadioHistory is every setting the observer reported, oldest first // (#1869). Only on GET /api/observers/{id}. RadioHistory []RadioStint `json:"radio_history,omitempty"` + // Latest /neighbors report (#2270): when it arrived and how many + // neighbors it named; absent when the observer never sent one. + NeighborsReportAt *string `json:"neighbors_report_at,omitempty"` + NeighborsCount *int `json:"neighbors_count,omitempty"` + // The report itself, GET /api/observers/{id} only (#2271). + NeighborReport *ObserverNeighborReport `json:"neighbor_report,omitempty"` } type ObserverListResponse struct { diff --git a/internal/dbschema/dbschema.go b/internal/dbschema/dbschema.go index e463a367..c3dc6b3c 100644 --- a/internal/dbschema/dbschema.go +++ b/internal/dbschema/dbschema.go @@ -117,6 +117,9 @@ func Apply(rw *sql.DB, logf Logger) error { if err := ensureObserverRadios(rw, logf); err != nil { return fmt.Errorf("ensure observer radios: %w", err) } + if err := ensureObserverNeighborReports(rw); err != nil { + return fmt.Errorf("ensure observer neighbor reports: %w", err) + } // Advert evidence is intentionally not required by AssertReady: a legacy // read-only server reports unknown until this table/backfill is available, // and re-probes absence instead of caching the startup race permanently. diff --git a/internal/dbschema/observer_neighbors.go b/internal/dbschema/observer_neighbors.go new file mode 100644 index 00000000..f823ac75 --- /dev/null +++ b/internal/dbschema/observer_neighbors.go @@ -0,0 +1,38 @@ +package dbschema + +import ( + "database/sql" + "fmt" +) + +// observer_neighbor_reports keeps the latest /neighbors report of each +// observer (#2270, #2271). Until now the ingestor only took the confirmed +// region scopes out of it (#1865, nodes.configured_scope) and dropped the +// rest, so nothing could say when an observer last reported or whom it +// heard. +// +// One row per observer, replaced by every live report. neighbors_json is the +// ingestor's sanitised copy of the report's neighbors[] (the report is +// unauthenticated input), not the raw payload. +// +// Not required by AssertReady: a server that comes up before the ingestor +// created it shows no neighbor data and probes again on the next request. +// +// PREFLIGHT: async=false reason="creates one empty table keyed by observer id; no scan of existing data" +func ensureObserverNeighborReports(rw *sql.DB) error { + if _, err := rw.Exec(` + CREATE TABLE IF NOT EXISTS observer_neighbor_reports ( + observer_id TEXT PRIMARY KEY, + received_at TEXT NOT NULL, + reported_at TEXT, + neighbor_count INTEGER NOT NULL, + queried_neighbors INTEGER, + self_scopes TEXT, + self_default_scope TEXT, + neighbors_json TEXT NOT NULL + ) + `); err != nil { + return fmt.Errorf("create observer_neighbor_reports: %w", err) + } + return nil +} diff --git a/public/index.html b/public/index.html index b31c7704..a920edc7 100644 --- a/public/index.html +++ b/public/index.html @@ -249,6 +249,7 @@ + diff --git a/public/observer-detail.js b/public/observer-detail.js index 5c6e9435..c1b370dd 100644 --- a/public/observer-detail.js +++ b/public/observer-detail.js @@ -331,6 +331,14 @@ window.ObserverDetailNaiveBanner = {
Last Status Update
${obs.last_seen ? timeAgo(obs.last_seen) + '
' + new Date(obs.last_seen).toLocaleString() + '' : '—'}
+
+
Neighbors Report
+
${obs.neighbors_report_at && window.ObserverNeighbors ? window.ObserverNeighbors.since(obs.neighbors_report_at, Date.now()) : 'never'}
+
+
+
Neighbors
+
${obs.neighbors_count != null && isFinite(Number(obs.neighbors_count)) ? Number(obs.neighbors_count) : '—'}
+
Last Packet Observation
${obs.last_packet_at ? timeAgo(obs.last_packet_at) + '
' + new Date(obs.last_packet_at).toLocaleString() + '' : 'never'}
@@ -341,6 +349,7 @@ window.ObserverDetailNaiveBanner = {
Loading clock calibration…
${window.ObserverDetailRadioHistory ? window.ObserverDetailRadioHistory.render(obs.radio_history) : ''} + ${window.ObserverNeighbors ? window.ObserverNeighbors.detailHtml(obs.neighbor_report) : ''}
Loading analytics…
`; } diff --git a/public/observer-neighbors.js b/public/observer-neighbors.js new file mode 100644 index 00000000..ccd000f6 --- /dev/null +++ b/public/observer-neighbors.js @@ -0,0 +1,89 @@ +/* === CoreScope — observer-neighbors.js === + * #2270 / #2271: the latest /neighbors report of an observer, on the + * observers list (cell) and the observer page (cards + table). Pure render + * helpers, no fetching; window global so observers.js and observer-detail.js + * share them and the XSS sink test can call them directly. + * + * Every string in a report is observer-published (MQTT) or mesh-published + * (node names), so everything is escaped here. */ +'use strict'; + +window.ObserverNeighbors = (function () { + function esc(s) { + return String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + } + + function ago(ms) { + if (!isFinite(ms) || ms < 0) return '—'; + var s = Math.floor(ms / 1000); + if (s < 60) return s + 's ago'; + var m = Math.floor(s / 60); + if (m < 60) return m + 'm ago'; + var h = Math.floor(m / 60); + if (h < 48) return h + 'h ago'; + return Math.floor(h / 24) + 'd ago'; + } + + function since(iso, nowMs) { + var t = Date.parse(iso); + return isNaN(t) ? '—' : ago(nowMs - t); + } + + // cell: "3h ago · 12" for the observers table, "—" when never reported. + function cell(o, nowMs) { + if (!o || !o.neighbors_report_at) return '—'; + var n = Number(o.neighbors_count); + var count = isFinite(n) ? n : 0; + return '' + since(o.neighbors_report_at, nowMs || Date.now()) + + ' · ' + count + ''; + } + + function snr(v) { + var n = Number(v); + return v == null || !isFinite(n) ? '—' : n.toFixed(1) + ' dB'; + } + + function heard(secs) { + var n = Number(secs); + return secs == null || !isFinite(n) ? '—' : ago(n * 1000); + } + + // detailHtml: the report as a table for the observer page, "" without one. + function detailHtml(rep) { + if (!rep || !Array.isArray(rep.neighbors)) return ''; + var cellStyle = 'padding:3px 8px'; + var rows = rep.neighbors.map(function (n) { + var key = String(n.pubkey || ''); + var label = n.name ? esc(n.name) : '' + esc(key.slice(0, 8)) + '…'; + return '' + + '' + label + '' + + (n.role ? ' ' + esc(n.role) + '' : '') + '' + + '' + snr(n.snr) + '' + + '' + heard(n.heard_secs_ago) + '' + + '' + (n.status ? esc(n.status) : '—') + '' + + '' + (n.scopes ? esc(n.scopes) : '—') + '' + + ''; + }).join(''); + var head = ''; + var meta = []; + if (rep.reported_at) meta.push('reported ' + esc(rep.reported_at)); + if (rep.queried_neighbors != null) meta.push(esc(rep.queried_neighbors) + ' scope queries sent'); + if (rep.self_scopes) meta.push('own scopes ' + esc(rep.self_scopes)); + if (rep.self_default_scope) meta.push('default scope ' + esc(rep.self_default_scope)); + var shown = rep.neighbors.length; + var count = Number(rep.neighbor_count); + var more = isFinite(count) && count > shown ? ' (' + shown + ' of ' + count + ' listed)' : ''; + return '
' + + '

Neighbors' + more + '

' + + (meta.length ? '
' + meta.join(' · ') + '
' : '') + + (shown ? '' + + head + 'Node' + head + 'SNR' + head + 'Heard' + head + 'Scope query' + head + 'Scopes' + + '' + rows + '
' + : '
The latest report lists no neighbors.
') + + '
'; + } + + return { cell: cell, detailHtml: detailHtml, since: since }; +})(); diff --git a/public/observers.js b/public/observers.js index 7caf780a..56bbc313 100644 --- a/public/observers.js +++ b/public/observers.js @@ -317,6 +317,11 @@ window.preserveCompareSelection = function preserveCompareSelection(prevIds, tbo // parallel thresholds, the new ObserversSummary regression test breaks. window.observerHealthStatus = healthStatus; + // #2270: latest /neighbors report, shared with the observer page. + function neighborsCell(o) { + return window.ObserverNeighbors ? window.ObserverNeighbors.cell(o) : '—'; + } + function packetBadge(o) { const warnIcon = ''; const broadcastIcon = ''; @@ -393,7 +398,7 @@ window.preserveCompareSelection = function preserveCompareSelection(prevIds, tbo
- + @@ -409,6 +414,7 @@ window.preserveCompareSelection = function preserveCompareSelection(prevIds, tbo // NaN last). Empty string ⇒ NaN ⇒ missing-sorts-last. const _lastSeenMs = o.last_seen ? new Date(o.last_seen).getTime() : ''; const _lastPktMs = o.last_packet_at ? new Date(o.last_packet_at).getTime() : ''; + const _neighborsMs = o.neighbors_report_at ? new Date(o.neighbors_report_at).getTime() : ''; const _firstSeenMs = o.first_seen ? new Date(o.first_seen).getTime() : 0; const _uptimeMs = _firstSeenMs ? (Date.now() - _firstSeenMs) : ''; const _skewSec = (obsSkewMap[o.id] && obsSkewMap[o.id].samples) @@ -426,6 +432,7 @@ window.preserveCompareSelection = function preserveCompareSelection(prevIds, tbo + @@ -540,6 +547,7 @@ window.preserveCompareSelection = function preserveCompareSelection(prevIds, tbo '
Region
' + (o.iata ? '' + escapeHtml(o.iata) + '' : '—') + '
' + '
Last status
' + timeAgo(o.last_seen) + '
' + '
Last packet
' + (o.last_packet_at ? timeAgo(o.last_packet_at) : '—') + '
' + + '
Neighbors
' + neighborsCell(o) + '
' + '
Total packets
' + (o.packet_count || 0).toLocaleString() + '
' + '
Packets/hr
' + pkts + '
' + '
Clock offset
' + skewLine + '
' + diff --git a/scripts/non-unit-tests.json b/scripts/non-unit-tests.json index f2c373a9..bf31dce4 100644 --- a/scripts/non-unit-tests.json +++ b/scripts/non-unit-tests.json @@ -135,6 +135,7 @@ "files": [ "tests/e2e/test-region-quick-picks.js", "tests/e2e/test-radio-setting-1869-e2e.js", + "tests/e2e/test-observer-neighbors-2270-e2e.js", "tests/e2e/test-table-sort.js" ] }, diff --git a/test-all.sh b/test-all.sh index 70add528..fa15077b 100755 --- a/test-all.sh +++ b/test-all.sh @@ -224,6 +224,7 @@ node tests/unit/test-traces.js node tests/unit/test-url-state.js node tests/unit/test-user-management-ui.js node tests/unit/test-vendored-libs.js +node tests/unit/test-observer-neighbors.js node tests/unit/test-warmup-banner.js node tests/unit/test-ws-stale-watchdog-1074.js node tests/unit/test-xss-escape-sinks.js diff --git a/tests/e2e/test-observer-neighbors-2270-e2e.js b/tests/e2e/test-observer-neighbors-2270-e2e.js new file mode 100644 index 00000000..102ea658 --- /dev/null +++ b/tests/e2e/test-observer-neighbors-2270-e2e.js @@ -0,0 +1,102 @@ +/** + * E2E test (#2270, #2271): the latest /neighbors report on the observers + * list (Neighbors column) and on the observer page (two cards + table). + * + * The fixture has no reports, so the observer endpoints are intercepted and + * the report fields added to the real responses. + * + * Usage: BASE_URL=http://localhost:13581 node test-observer-neighbors-2270-e2e.js + */ +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:3000'; + +async function test(name, fn) { + try { + await fn(); + console.log(` ✅ ${name}`); + } catch (err) { + console.log(` ❌ ${name}: ${err.message}`); + process.exit(1); + } +} + +function assert(cond, msg) { + if (!cond) throw new Error(msg || 'Assertion failed'); +} + +async function run() { + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'] + }); + const page = await (await browser.newContext({ viewport: { width: 1600, height: 950 } })).newPage(); + page.setDefaultTimeout(15000); + const pageErrors = []; + page.on('pageerror', (e) => pageErrors.push(e.message)); + console.log('#2270/#2271 observer neighbors:'); + + const reportAt = new Date(Date.now() - 3 * 3600 * 1000).toISOString(); + let reported; + await page.route(/\/api\/observers(\?|$)/, async (route) => { + const res = await route.fetch(); + const body = await res.json(); + reported = body.observers[0].id; + body.observers[0].neighbors_report_at = reportAt; + body.observers[0].neighbors_count = 12; + await route.fulfill({ response: res, json: body }); + }); + + await test('observers list shows the Neighbors column', async () => { + await page.goto(BASE + '#/observers', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('[data-testid="obs-cell-neighbors"]'); + const header = await page.locator('#obsTable th[data-sort-key="neighbors"]').count(); + assert(header === 1, 'Neighbors header missing'); + const cell = page.locator(`tr[data-observer-id="${reported}"] [data-testid="obs-cell-neighbors"]`); + const text = (await cell.textContent()).trim(); + assert(/^3h ago · 12$/.test(text), 'reporting observer cell: ' + text); + const dashes = await page.locator('[data-testid="obs-cell-neighbors"]').allTextContents(); + assert(dashes.filter((t) => t.trim() === '—').length === dashes.length - 1, 'other observers should show a dash'); + }); + + await test('observer page shows the report cards and the neighbors table', async () => { + await page.route(new RegExp('/api/observers/' + encodeURIComponent(reported) + '(\\?|$)'), async (route) => { + const res = await route.fetch(); + const obs = await res.json(); + obs.neighbors_report_at = reportAt; + obs.neighbors_count = 3; + obs.neighbor_report = { + received_at: reportAt, reported_at: reportAt, neighbor_count: 3, queried_neighbors: 2, + self_scopes: '#tst', self_default_scope: '*', + neighbors: [ + { pubkey: 'aa' + '0'.repeat(62), name: 'E2E Neighbor', role: 'repeater', snr: 6.5, heard_secs_ago: 90, status: 'responded', scopes: '#tst' }, + { pubkey: 'bb' + '0'.repeat(62), name: null, role: null, snr: null, heard_secs_ago: null, status: 'timeout' }, + ], + }; + await route.fulfill({ response: res, json: obs }); + }); + await page.goto(BASE + '#/observers/' + encodeURIComponent(reported), { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('[data-testid="observer-neighbors"]'); + const age = (await page.textContent('[data-testid="obs-stat-neighbors-report"] .stat-value')).trim(); + const count = (await page.textContent('[data-testid="obs-stat-neighbors-count"] .stat-value')).trim(); + assert(age === '3h ago' && count === '3', `cards: ${age} / ${count}`); + const rows = await page.locator('[data-testid="observer-neighbors"] tbody tr').allTextContents(); + assert(rows.length === 2, 'rows: ' + rows.length); + assert(/E2E Neighbor/.test(rows[0]) && /6\.5 dB/.test(rows[0]) && /responded/.test(rows[0]), rows[0]); + assert(/bb000000…/.test(rows[1]) && /timeout/.test(rows[1]), rows[1]); + const heading = await page.textContent('[data-testid="observer-neighbors"] h4'); + assert(/2 of 3 listed/.test(heading), heading); + }); + + await test('no page errors', async () => { + assert(pageErrors.length === 0, pageErrors.join(' | ')); + }); + + await browser.close(); +} + +run().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/tests/unit/test-observer-neighbors.js b/tests/unit/test-observer-neighbors.js new file mode 100644 index 00000000..85829122 --- /dev/null +++ b/tests/unit/test-observer-neighbors.js @@ -0,0 +1,63 @@ +/** + * #2270 / #2271: render helpers for the latest /neighbors report + * (public/observer-neighbors.js), loaded the way the page loads them. + * + * Usage: node tests/unit/test-observer-neighbors.js + */ +'use strict'; +const fs = require('fs'); +const path = require('path'); + +const window = {}; +new Function('window', fs.readFileSync(path.join(__dirname, '..', '..', 'public', 'observer-neighbors.js'), 'utf8'))(window); +const ON = window.ObserverNeighbors; +let pass = 0, fail = 0; +function test(name, fn) { + try { fn(); pass++; console.log(' ✅ ' + name); } catch (e) { fail++; console.log(' ❌ ' + name + ': ' + e.message); } +} +function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } + +const NOW = Date.parse('2026-10-25T13:00:00Z'); + +test('cell: never reported is a dash', () => { + assert(/—/.test(ON.cell({}, NOW))); + assert(/—/.test(ON.cell(null, NOW))); +}); +test('cell: age and count', () => { + const html = ON.cell({ neighbors_report_at: '2026-10-25T10:00:00Z', neighbors_count: 12 }, NOW); + assert(html.includes('3h ago · 12'), html); + assert(html.includes('12 neighbors'), html); +}); +test('cell: one neighbor is singular, bad count is 0', () => { + assert(ON.cell({ neighbors_report_at: '2026-10-25T12:59:30Z', neighbors_count: 1 }, NOW).includes('30s ago · 1')); + assert(ON.cell({ neighbors_report_at: '2026-10-25T12:00:00Z', neighbors_count: 'x' }, NOW).includes('· 0')); +}); +test('since: days and unparseable', () => { + assert(ON.since('2026-10-20T13:00:00Z', NOW) === '5d ago'); + assert(ON.since('garbage', NOW) === '—'); +}); +test('detailHtml: nothing without a report', () => { + assert(ON.detailHtml(null) === '' && ON.detailHtml({}) === ''); +}); +test('detailHtml: named and unnamed neighbors, metadata', () => { + const html = ON.detailHtml({ + neighbor_count: 3, queried_neighbors: 2, self_scopes: '#tst', self_default_scope: '*', + reported_at: '2026-10-25T09:59:58Z', + neighbors: [ + { pubkey: 'aabbccdd' + '0'.repeat(56), name: 'Rpt One', role: 'repeater', snr: 6.5, heard_secs_ago: 90, status: 'responded', scopes: '#tst' }, + { pubkey: 'ff00ff00' + '0'.repeat(56), status: 'timeout' }, + ], + }); + assert(html.includes('Rpt One') && html.includes('repeater'), 'name/role'); + assert(html.includes('ff00ff00…'), 'unknown node shown by key prefix'); + assert(html.includes('#/nodes/aabbccdd'), 'links to the node'); + assert(html.includes('6.5 dB') && html.includes('1m ago'), 'snr/heard'); + assert(html.includes('(2 of 3 listed)'), 'truncated list noted'); + assert(html.includes('own scopes #tst') && html.includes('default scope *'), 'self'); +}); +test('detailHtml: empty list says so', () => { + assert(ON.detailHtml({ neighbor_count: 0, neighbors: [] }).includes('lists no neighbors')); +}); + +console.log(`\nObserver neighbors: ${pass} passed, ${fail} failed`); +process.exit(fail > 0 ? 1 : 0); diff --git a/tests/unit/test-observers-headings.js b/tests/unit/test-observers-headings.js index a6d945a4..5af7a84e 100644 --- a/tests/unit/test-observers-headings.js +++ b/tests/unit/test-observers-headings.js @@ -58,7 +58,7 @@ test('expected headings present and ordered', () => { const re = /]*>([^<]+)<\/th>/g; let m; while ((m = re.exec(thead)) !== null) labels.push(m[1].trim()); - const expected = ['Status', 'Name', 'Region', 'Last Status', 'Last Packet', + const expected = ['Status', 'Name', 'Region', 'Last Status', 'Last Packet', 'Neighbors', 'Packet Health', 'Total Packets', 'Packets/Hour', 'TX 1h', 'Clock Offset', 'Uptime', 'Firmware', 'Client']; assert.deepStrictEqual(labels, expected, diff --git a/tests/unit/test-xss-escape-sinks.js b/tests/unit/test-xss-escape-sinks.js index 291c76b5..91ff9778 100644 --- a/tests/unit/test-xss-escape-sinks.js +++ b/tests/unit/test-xss-escape-sinks.js @@ -530,6 +530,21 @@ test('OBS-1 radio history: radio, radio_id and timestamps are escaped', () => { assertNoXss(html, 'OBS-1 radio history'); }); +// #2270 / #2271: the neighbors report renders observer- and mesh-published +// strings (node names, scopes, statuses, timestamps). +test('ObserverNeighbors: cell and detail table escape every field', () => { + const window = {}; + new Function('window', fs.readFileSync('public/observer-neighbors.js', 'utf8'))(window); + const evil = TAG_PAYLOAD + ATTR_PAYLOAD; + assertNoXss(window.ObserverNeighbors.cell({ neighbors_report_at: evil, neighbors_count: evil }), 'neighbors cell'); + const html = window.ObserverNeighbors.detailHtml({ + neighbor_count: evil, queried_neighbors: evil, self_scopes: evil, self_default_scope: evil, reported_at: evil, + neighbors: [{ pubkey: evil, name: evil, role: evil, snr: evil, heard_secs_ago: evil, status: evil, scopes: evil }, + { pubkey: evil, status: evil }], + }); + assertNoXss(html, 'neighbors table'); +}); + test('OBS-1 expanded: obs.iata payload is escaped', () => { const html = renderObsDetail({ id: 'obs-1', iata: TAG_PAYLOAD + ATTR_PAYLOAD }); assertNoXss(html, 'OBS-1 obs.iata');
Observer status and statistics
StatusNameRegionLast StatusLast PacketStatusNameRegionLast StatusLast PacketNeighbors Packet HealthTotal PacketsPackets/HourTX 1hClock OffsetUptimeFirmwareClient Select for compare
${o.iata ? `${escapeHtml(o.iata)}` : '—'} ${timeAgo(o.last_seen)} ${o.last_packet_at ? timeAgo(o.last_packet_at) : '—'}${neighborsCell(o)} ${packetBadge(o)} ${(o.packet_count || 0).toLocaleString()} ${sparkBar(o.packetsLastHour || 0, maxPktsHr)}