mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-11 11:37:32 +00:00
Closes #2270, closes #2271. The ingestor took only the confirmed region scopes out of an observer's `/neighbors` report (#1865) and dropped the rest. Nothing could therefore say when an observer last reported, or whom it heard. ## Ingestor `observer_neighbor_reports` (created in `dbschema.Apply`) keeps the latest report of each observer, one row per observer, replaced by every live report. The report is unauthenticated, so `neighbors[]` is stored sanitised: - only 64-hex keys - `snr` and `heard_secs_ago` finite and in range - `status` limited to `[a-z_]` - `scopes` clamped - at most 500 rows `total_neighbors` is believed only when it is not below the number of rows that arrived. A **retained replay** only fills a missing row, and it is dated by the report's own `timestamp`. After an ingestor restart, a long-dead observer therefore does not look as if it just reported. ## API The table is optional for the server (no `AssertReady` change); without it the fields are simply absent. - `GET /api/observers`: `neighbors_report_at` and `neighbors_count`, from one batch query in the existing cached builder. - `GET /api/observers/{id}`: also `neighbor_report`, which is `{received_at, reported_at, neighbor_count, queried_neighbors, self_scopes, self_default_scope, neighbors: [{pubkey, name, role, snr, heard_secs_ago, status, scopes}]}`. Names and roles come from the node table in one query. ## Frontend `public/observer-neighbors.js` is shared by both pages and escapes everything. - **#2270, observers list:** a sortable **Neighbors** column, e.g. `3h ago · 12`, or `—` when the observer never reported. The same value appears in the slide-over. - **#2270, observer page:** **Neighbors Report** and **Neighbors** stat cards. - **#2271, observer page:** a **Neighbors** table listing each neighbor by name (a link to the node) or key prefix, with SNR, heard, scope query status and scopes. The heading says when the list is shorter than the count, e.g. `(2 of 3 listed)`. ## Tests - **ingestor:** sanitising, the cap, live replace, the retained rules (fill only, dated by the report, undated ignored) and the MQTT topic end to end. The existing #1865 scope tests are unchanged and still pass. - **server:** the list summary, the detail with names, and both endpoints without the table. - **frontend:** - unit tests of the helpers - an XSS case for every rendered field; I checked that it fails when escaping is removed - `test-observers-headings` now expects the new column after `Last Packet` - **E2E:** `test-observer-neighbors-2270-e2e.js` intercepts the observer endpoints, since the fixture has no reports. It is listed in `scripts/non-unit-tests.json` and on shard 2. Gates that pass: - `go test -race` in `cmd/server`, `cmd/ingestor` and `internal/dbschema` - `sh test-all.sh` - `eslint@8`, with 0 errors - the XSS diff gate - these E2E suites: the new one, `test-issue-1639-observers-sort-e2e.js`, `test-observer-iata-1188-e2e.js` and `test-e2e-playwright.js` Performance: the list adds one primary-key-sized query per cache fill, and the detail adds two small queries per request. Also fixes #2330 (folded in from #2337, second commit). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
202bafea28
commit
417c8dd00a
23 files changed
+1018
-4
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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":"<img src=x>","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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -249,6 +249,7 @@
|
||||
<script src="audio-lab.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="drag-manager.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-neighbors.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="mqtt-status-panel.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-detail.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
|
||||
@@ -331,6 +331,14 @@ window.ObserverDetailNaiveBanner = {
|
||||
<div class="stat-label">Last Status Update</div>
|
||||
<div class="stat-value" style="font-size:0.85em">${obs.last_seen ? timeAgo(obs.last_seen) + '<br><span style="font-size:0.8em;color:var(--text-muted)">' + new Date(obs.last_seen).toLocaleString() + '</span>' : '—'}</div>
|
||||
</div>
|
||||
<div class="stat-card" data-testid="obs-stat-neighbors-report">
|
||||
<div class="stat-label">Neighbors Report</div>
|
||||
<div class="stat-value" style="font-size:0.85em">${obs.neighbors_report_at && window.ObserverNeighbors ? window.ObserverNeighbors.since(obs.neighbors_report_at, Date.now()) : '<span style="color:var(--text-muted)">never</span>'}</div>
|
||||
</div>
|
||||
<div class="stat-card" data-testid="obs-stat-neighbors-count">
|
||||
<div class="stat-label">Neighbors</div>
|
||||
<div class="stat-value">${obs.neighbors_count != null && isFinite(Number(obs.neighbors_count)) ? Number(obs.neighbors_count) : '—'}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Last Packet Observation</div>
|
||||
<div class="stat-value" style="font-size:0.85em">${obs.last_packet_at ? timeAgo(obs.last_packet_at) + '<br><span style="font-size:0.8em;color:var(--text-muted)">' + new Date(obs.last_packet_at).toLocaleString() + '</span>' : '<span style="color:var(--text-muted)">never</span>'}</div>
|
||||
@@ -341,6 +349,7 @@ window.ObserverDetailNaiveBanner = {
|
||||
</div>
|
||||
<div id="obsClockOffset" aria-busy="true"><div class="text-muted" role="status" style="margin-bottom:20px">Loading clock calibration…</div></div>
|
||||
${window.ObserverDetailRadioHistory ? window.ObserverDetailRadioHistory.render(obs.radio_history) : ''}
|
||||
${window.ObserverNeighbors ? window.ObserverNeighbors.detailHtml(obs.neighbor_report) : ''}
|
||||
<div id="obsAnalytics" aria-busy="true"><div class="text-muted" role="status">Loading analytics…</div></div>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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, '"').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 '<span class="text-muted" title="No /neighbors report received">—</span>';
|
||||
var n = Number(o.neighbors_count);
|
||||
var count = isFinite(n) ? n : 0;
|
||||
return '<span title="Latest /neighbors report ' + esc(o.neighbors_report_at) + ': ' + count +
|
||||
' neighbor' + (count === 1 ? '' : 's') + '">' + since(o.neighbors_report_at, nowMs || Date.now()) +
|
||||
' · ' + count + '</span>';
|
||||
}
|
||||
|
||||
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) : '<span class="mono">' + esc(key.slice(0, 8)) + '…</span>';
|
||||
return '<tr>' +
|
||||
'<td style="' + cellStyle + '"><a href="#/nodes/' + encodeURIComponent(key) + '">' + label + '</a>' +
|
||||
(n.role ? ' <span class="text-muted">' + esc(n.role) + '</span>' : '') + '</td>' +
|
||||
'<td style="' + cellStyle + '">' + snr(n.snr) + '</td>' +
|
||||
'<td style="' + cellStyle + '">' + heard(n.heard_secs_ago) + '</td>' +
|
||||
'<td style="' + cellStyle + '">' + (n.status ? esc(n.status) : '—') + '</td>' +
|
||||
'<td style="' + cellStyle + '" class="mono">' + (n.scopes ? esc(n.scopes) : '—') + '</td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
var head = '<th style="' + cellStyle + ';text-align:left">';
|
||||
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 '<div class="node-full-card" data-testid="observer-neighbors" style="margin-bottom:20px;padding:12px">' +
|
||||
'<h4 style="margin:0 0 6px">Neighbors' + more + '</h4>' +
|
||||
(meta.length ? '<div class="text-muted" style="font-size:12px;margin-bottom:6px">' + meta.join(' · ') + '</div>' : '') +
|
||||
(shown ? '<table style="border-collapse:collapse;font-size:0.85em"><thead><tr>' +
|
||||
head + 'Node</th>' + head + 'SNR</th>' + head + 'Heard</th>' + head + 'Scope query</th>' + head + 'Scopes</th>' +
|
||||
'</tr></thead><tbody>' + rows + '</tbody></table>'
|
||||
: '<div class="text-muted">The latest report lists no neighbors.</div>') +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
return { cell: cell, detailHtml: detailHtml, since: since };
|
||||
})();
|
||||
+9
-1
@@ -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) : '<span class="text-muted">—</span>';
|
||||
}
|
||||
|
||||
function packetBadge(o) {
|
||||
const warnIcon = '<svg class="ph-icon" aria-hidden="true" focusable="false"><use href="/icons/phosphor-sprite.svg#ph-warning"></use></svg>';
|
||||
const broadcastIcon = '<svg class="ph-icon" aria-hidden="true" focusable="false"><use href="/icons/phosphor-sprite.svg#ph-broadcast"></use></svg>';
|
||||
@@ -393,7 +398,7 @@ window.preserveCompareSelection = function preserveCompareSelection(prevIds, tbo
|
||||
<div class="obs-table-scroll table-fluid-wrap"><table class="data-table obs-table" id="obsTable">
|
||||
<caption class="sr-only">Observer status and statistics</caption>
|
||||
<thead><tr>
|
||||
<th scope="col" data-priority="1" data-sort-key="status" data-type="numeric">Status</th><th scope="col" data-priority="1" data-sort-key="name">Name</th><th scope="col" data-priority="3" data-sort-key="region">Region</th><th scope="col" data-priority="2" data-sort-key="last_seen" data-type="numeric">Last Status</th><th scope="col" data-priority="2" data-sort-key="last_packet_at" data-type="numeric">Last Packet</th>
|
||||
<th scope="col" data-priority="1" data-sort-key="status" data-type="numeric">Status</th><th scope="col" data-priority="1" data-sort-key="name">Name</th><th scope="col" data-priority="3" data-sort-key="region">Region</th><th scope="col" data-priority="2" data-sort-key="last_seen" data-type="numeric">Last Status</th><th scope="col" data-priority="2" data-sort-key="last_packet_at" data-type="numeric">Last Packet</th><th scope="col" data-priority="3" data-sort-key="neighbors" data-type="numeric" title="Latest /neighbors report: when it arrived and how many neighbors it named">Neighbors</th>
|
||||
<th scope="col" data-priority="3" data-sort-key="packet_health" data-type="numeric">Packet Health</th><th scope="col" data-priority="4" data-sort-key="packet_count" data-type="numeric">Total Packets</th><th scope="col" data-priority="3" data-sort-key="packets_hour" data-type="numeric">Packets/Hour</th><th scope="col" data-priority="3" data-sort-key="airtime_1h" data-type="numeric" title="TX airtime share over the last hour (duty cycle)">TX 1h</th><th scope="col" data-priority="4" data-sort-key="clock_offset" data-type="numeric">Clock Offset</th><th scope="col" data-priority="4" data-sort-key="uptime" data-type="numeric">Uptime</th><th scope="col" data-priority="4" data-sort-key="firmware">Firmware</th><th scope="col" data-priority="4" data-sort-key="client_version">Client</th>
|
||||
<th scope="col" data-priority="1" class="col-compare-select" style="width:32px"><span class="sr-only">Select for compare</span></th>
|
||||
</tr></thead>
|
||||
@@ -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
|
||||
<td data-value="${escapeHtml(o.iata || '')}">${o.iata ? `<span class="badge-region">${escapeHtml(o.iata)}</span>` : '—'}</td>
|
||||
<td data-value="${_lastSeenMs}">${timeAgo(o.last_seen)}</td>
|
||||
<td data-testid="obs-cell-last-packet" data-value="${_lastPktMs}">${o.last_packet_at ? timeAgo(o.last_packet_at) : '<span class="text-muted">—</span>'}</td>
|
||||
<td data-testid="obs-cell-neighbors" data-value="${_neighborsMs}">${neighborsCell(o)}</td>
|
||||
<td data-value="${_healthRank}">${packetBadge(o)}</td>
|
||||
<td data-testid="obs-cell-packet-count" data-value="${_packetCount}">${(o.packet_count || 0).toLocaleString()}</td>
|
||||
<td data-value="${_packetsHour}">${sparkBar(o.packetsLastHour || 0, maxPktsHr)}</td>
|
||||
@@ -540,6 +547,7 @@ window.preserveCompareSelection = function preserveCompareSelection(prevIds, tbo
|
||||
'<dt>Region</dt><dd>' + (o.iata ? '<span class="badge-region">' + escapeHtml(o.iata) + '</span>' : '—') + '</dd>' +
|
||||
'<dt>Last status</dt><dd>' + timeAgo(o.last_seen) + '</dd>' +
|
||||
'<dt>Last packet</dt><dd>' + (o.last_packet_at ? timeAgo(o.last_packet_at) : '—') + '</dd>' +
|
||||
'<dt>Neighbors</dt><dd>' + neighborsCell(o) + '</dd>' +
|
||||
'<dt>Total packets</dt><dd>' + (o.packet_count || 0).toLocaleString() + '</dd>' +
|
||||
'<dt>Packets/hr</dt><dd>' + pkts + '</dd>' +
|
||||
'<dt>Clock offset</dt><dd>' + skewLine + '</dd>' +
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -58,7 +58,7 @@ test('expected headings present and ordered', () => {
|
||||
const re = /<th[^>]*>([^<]+)<\/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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user