mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-12 08:37:33 +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>
149 lines
4.3 KiB
Go
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
|
|
}
|
|
}
|
|
}
|