Files
meshcore-analyzer/cmd/server/observer_neighbors.go
T
TeTeHackoandClaude Opus 5.5 417c8dd00a feat(observers): latest /neighbors report on the observers list and page (#2270, #2271) (#2300)
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>
2026-10-29 22:36:53 +01:00

149 lines
4.3 KiB
Go

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
}
}
}