mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-26 00:33:38 +00:00
Merge branch 'areas-meshguide-sync'
This commit is contained in:
+61
-4
@@ -1735,11 +1735,14 @@ func (s *Store) TouchObserverNeighborsReport(observerID, reportedAt string) erro
|
||||
}
|
||||
|
||||
// ObserverNeighborEntry is one entry from an observer's /neighbors report,
|
||||
// carrying enough to populate the observer_neighbors table.
|
||||
// carrying enough to populate the observer_neighbors and
|
||||
// observer_neighbor_metrics tables.
|
||||
type ObserverNeighborEntry struct {
|
||||
Pubkey string // lowercase, already validated non-empty by the caller
|
||||
Scopes string // normalized "#"-prefixed form; empty for timeout entries
|
||||
Status string // "responded" | "timeout"
|
||||
Pubkey string // lowercase, already validated non-empty by the caller
|
||||
Scopes string // normalized "#"-prefixed form; empty for timeout entries
|
||||
Status string // "responded" | "timeout"
|
||||
SNR *float64 // dBm signal-to-noise for this direct neighbor; present regardless of status
|
||||
HeardSecsAgo *int // seconds since the observer last heard this neighbor directly
|
||||
}
|
||||
|
||||
// ReplaceObserverNeighbors stores the observer's CURRENT direct (zero-hop)
|
||||
@@ -1792,6 +1795,60 @@ func (s *Store) ReplaceObserverNeighbors(observerID string, neighbors []Observer
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// RecordObserverNeighborMetrics appends one SNR/heard_secs_ago history row
|
||||
// per neighbor entry that carries an SNR reading (#1865 follow-up: dborup
|
||||
// noticed the raw report also carries snr/heard_secs_ago per neighbor,
|
||||
// previously dropped entirely). Unlike ReplaceObserverNeighbors' current-
|
||||
// only snapshot, this is pure time-series -- every report is valid
|
||||
// historical data at its own timestamp regardless of arrival order, so
|
||||
// there is deliberately NO ordering guard here. The
|
||||
// (observer_id, neighbor_pubkey, timestamp) primary key makes a retried/
|
||||
// duplicate MQTT delivery a no-op via INSERT OR IGNORE.
|
||||
func (s *Store) RecordObserverNeighborMetrics(observerID string, neighbors []ObserverNeighborEntry, reportedAt string) error {
|
||||
if observerID == "" {
|
||||
return nil
|
||||
}
|
||||
reportedAt = normalizeReportTS(reportedAt)
|
||||
if reportedAt == "" {
|
||||
return nil
|
||||
}
|
||||
stmt, err := s.db.Prepare(`INSERT OR IGNORE INTO observer_neighbor_metrics (observer_id, neighbor_pubkey, timestamp, snr, heard_secs_ago) VALUES (?, ?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
for _, n := range neighbors {
|
||||
if n.Pubkey == "" || n.SNR == nil {
|
||||
continue
|
||||
}
|
||||
var heardSecsAgo interface{}
|
||||
if n.HeardSecsAgo != nil {
|
||||
heardSecsAgo = *n.HeardSecsAgo
|
||||
}
|
||||
if _, err := stmt.Exec(observerID, n.Pubkey, reportedAt, *n.SNR, heardSecsAgo); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PruneOldNeighborMetrics deletes observer_neighbor_metrics rows older than
|
||||
// retentionDays, mirroring PruneOldMetrics' retention model for
|
||||
// observer_metrics (same MetricsRetentionDays config knob, no separate
|
||||
// setting for this table).
|
||||
func (s *Store) PruneOldNeighborMetrics(retentionDays int) (int64, error) {
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -retentionDays).Format(time.RFC3339)
|
||||
result, err := s.instrumentedExec("prune_neighbor_metrics", `DELETE FROM observer_neighbor_metrics WHERE timestamp < ?`, cutoff)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("prune neighbor metrics: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n > 0 {
|
||||
log.Printf("[neighbor-metrics] Pruned %d rows older than %d days", n, retentionDays)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// normalizeConfiguredScopeList applies the same "#"-prefix normalization
|
||||
// default_scope already gets (regions.Normalize, via matchScope) to a
|
||||
// comma-separated /neighbors-report scope list, so both fields display
|
||||
|
||||
+19
-2
@@ -267,6 +267,7 @@ func main() {
|
||||
// Metrics retention: prune old metrics on startup
|
||||
metricsDays := cfg.MetricsRetentionDays()
|
||||
store.PruneOldMetrics(metricsDays)
|
||||
store.PruneOldNeighborMetrics(metricsDays)
|
||||
store.PruneDroppedPackets(metricsDays)
|
||||
|
||||
// Packet (transmissions) retention: previously lived in cmd/server,
|
||||
@@ -334,6 +335,7 @@ func main() {
|
||||
go func() {
|
||||
for range metricsRetentionTicker.C {
|
||||
store.PruneOldMetrics(metricsDays)
|
||||
store.PruneOldNeighborMetrics(metricsDays)
|
||||
store.PruneDroppedPackets(metricsDays)
|
||||
store.RunIncrementalVacuum(vacuumPages)
|
||||
}
|
||||
@@ -1577,18 +1579,33 @@ func handleNeighborsReport(store *Store, tag string, observerID string, msg map[
|
||||
}
|
||||
status, _ := n["status"].(string)
|
||||
scopes, _ := n["scopes"].(string)
|
||||
// snr/heard_secs_ago are present regardless of scope-query status --
|
||||
// they come from the firmware's own RF neighbor table, not the OTA
|
||||
// scope query (#1865 follow-up, spotted by dborup in a live payload).
|
||||
var snr *float64
|
||||
if v, ok := n["snr"].(float64); ok {
|
||||
snr = &v
|
||||
}
|
||||
var heardSecsAgo *int
|
||||
if v, ok := n["heard_secs_ago"].(float64); ok {
|
||||
hs := int(v)
|
||||
heardSecsAgo = &hs
|
||||
}
|
||||
if status == "responded" {
|
||||
if err := store.UpdateNodeConfiguredScope(pubkey, scopes, reportedAt); err != nil {
|
||||
log.Printf("MQTT [%s] neighbors scope error for %.8s: %v", tag, pubkey, err)
|
||||
}
|
||||
entries = append(entries, ObserverNeighborEntry{Pubkey: pubkey, Scopes: normalizeConfiguredScopeList(scopes), Status: status})
|
||||
entries = append(entries, ObserverNeighborEntry{Pubkey: pubkey, Scopes: normalizeConfiguredScopeList(scopes), Status: status, SNR: snr, HeardSecsAgo: heardSecsAgo})
|
||||
} else {
|
||||
entries = append(entries, ObserverNeighborEntry{Pubkey: pubkey, Status: status})
|
||||
entries = append(entries, ObserverNeighborEntry{Pubkey: pubkey, Status: status, SNR: snr, HeardSecsAgo: heardSecsAgo})
|
||||
}
|
||||
}
|
||||
if err := store.ReplaceObserverNeighbors(observerID, entries, reportedAt); err != nil {
|
||||
log.Printf("MQTT [%s] neighbors replace error for observer %.8s: %v", tag, observerID, err)
|
||||
}
|
||||
if err := store.RecordObserverNeighborMetrics(observerID, entries, reportedAt); err != nil {
|
||||
log.Printf("MQTT [%s] neighbor metrics record error for observer %.8s: %v", tag, observerID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// shouldUpdateDefaultScope returns true when the packet carries a transport
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// #1865 follow-up: dborup spotted that the raw /neighbors payload also
|
||||
// carries snr/heard_secs_ago per neighbor, previously dropped entirely.
|
||||
// observer_neighbor_metrics is an APPEND-ONLY history (unlike
|
||||
// observer_neighbors' current-only snapshot), inspired by the existing
|
||||
// RF Health tab's observer_metrics pattern.
|
||||
|
||||
func floatPtr(f float64) *float64 { return &f }
|
||||
func intPtr(i int) *int { return &i }
|
||||
|
||||
func countObserverNeighborMetrics(t *testing.T, store *Store, observerID, pubkey string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := store.db.QueryRow(`SELECT COUNT(*) FROM observer_neighbor_metrics WHERE observer_id = ? AND neighbor_pubkey = ?`,
|
||||
observerID, pubkey).Scan(&n); err != nil {
|
||||
t.Fatalf("count observer_neighbor_metrics: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestRecordObserverNeighborMetrics_Basic(t *testing.T) {
|
||||
store := openNeighborsStore(t)
|
||||
seedObserverForNeighbors(t, store, "obs-metrics-1")
|
||||
|
||||
entries := []ObserverNeighborEntry{
|
||||
{Pubkey: "aaaa000000000000000000000000000000000000000000000000000000000001", Status: "responded", SNR: floatPtr(10.5), HeardSecsAgo: intPtr(75)},
|
||||
{Pubkey: "bbbb000000000000000000000000000000000000000000000000000000000002", Status: "timeout", SNR: floatPtr(-8.75), HeardSecsAgo: intPtr(77)},
|
||||
}
|
||||
if err := store.RecordObserverNeighborMetrics("obs-metrics-1", entries, "2026-07-26T12:00:00Z"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var snr float64
|
||||
var heardSecsAgo int
|
||||
if err := store.db.QueryRow(`SELECT snr, heard_secs_ago FROM observer_neighbor_metrics WHERE observer_id = ? AND neighbor_pubkey = ? AND timestamp = ?`,
|
||||
"obs-metrics-1", "aaaa000000000000000000000000000000000000000000000000000000000001", "2026-07-26T12:00:00Z").Scan(&snr, &heardSecsAgo); err != nil {
|
||||
t.Fatalf("select: %v", err)
|
||||
}
|
||||
if snr != 10.5 || heardSecsAgo != 75 {
|
||||
t.Errorf("snr=%v heardSecsAgo=%v, want 10.5/75", snr, heardSecsAgo)
|
||||
}
|
||||
// Timeout entries still carry snr -- must be recorded too, not just
|
||||
// scope-query "responded" entries.
|
||||
if n := countObserverNeighborMetrics(t, store, "obs-metrics-1", "bbbb000000000000000000000000000000000000000000000000000000000002"); n != 1 {
|
||||
t.Errorf("expected 1 row for the timeout neighbor's SNR reading, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordObserverNeighborMetrics_AccumulatesAcrossReports(t *testing.T) {
|
||||
store := openNeighborsStore(t)
|
||||
seedObserverForNeighbors(t, store, "obs-metrics-2")
|
||||
pk := "cccc000000000000000000000000000000000000000000000000000000000003"
|
||||
|
||||
if err := store.RecordObserverNeighborMetrics("obs-metrics-2", []ObserverNeighborEntry{
|
||||
{Pubkey: pk, Status: "responded", SNR: floatPtr(5)},
|
||||
}, "2026-07-26T12:00:00Z"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.RecordObserverNeighborMetrics("obs-metrics-2", []ObserverNeighborEntry{
|
||||
{Pubkey: pk, Status: "responded", SNR: floatPtr(6)},
|
||||
}, "2026-07-26T13:00:00Z"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Unlike ReplaceObserverNeighbors, this is a time-series -- both rows
|
||||
// must survive, not just the latest.
|
||||
if n := countObserverNeighborMetrics(t, store, "obs-metrics-2", pk); n != 2 {
|
||||
t.Fatalf("expected 2 accumulated history rows, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordObserverNeighborMetrics_OutOfOrderStillRecorded(t *testing.T) {
|
||||
store := openNeighborsStore(t)
|
||||
seedObserverForNeighbors(t, store, "obs-metrics-3")
|
||||
pk := "dddd000000000000000000000000000000000000000000000000000000000004"
|
||||
|
||||
// Newer report first, then an older out-of-order one -- unlike
|
||||
// ReplaceObserverNeighbors' snapshot guard, BOTH are valid history at
|
||||
// their own timestamp and must both be recorded.
|
||||
if err := store.RecordObserverNeighborMetrics("obs-metrics-3", []ObserverNeighborEntry{
|
||||
{Pubkey: pk, Status: "responded", SNR: floatPtr(9)},
|
||||
}, "2026-07-26T14:00:00Z"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.RecordObserverNeighborMetrics("obs-metrics-3", []ObserverNeighborEntry{
|
||||
{Pubkey: pk, Status: "responded", SNR: floatPtr(3)},
|
||||
}, "2026-07-26T10:00:00Z"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := countObserverNeighborMetrics(t, store, "obs-metrics-3", pk); n != 2 {
|
||||
t.Errorf("expected both the newer and the out-of-order older reading recorded, got %d rows", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordObserverNeighborMetrics_SkipsEntriesWithoutSNR(t *testing.T) {
|
||||
store := openNeighborsStore(t)
|
||||
seedObserverForNeighbors(t, store, "obs-metrics-4")
|
||||
pk := "eeee000000000000000000000000000000000000000000000000000000000005"
|
||||
|
||||
if err := store.RecordObserverNeighborMetrics("obs-metrics-4", []ObserverNeighborEntry{
|
||||
{Pubkey: pk, Status: "timeout", SNR: nil},
|
||||
}, "2026-07-26T12:00:00Z"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := countObserverNeighborMetrics(t, store, "obs-metrics-4", pk); n != 0 {
|
||||
t.Errorf("expected no row when SNR is nil, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneOldNeighborMetrics(t *testing.T) {
|
||||
store := openNeighborsStore(t)
|
||||
seedObserverForNeighbors(t, store, "obs-metrics-5")
|
||||
pk := "ffff000000000000000000000000000000000000000000000000000000000006"
|
||||
|
||||
old := "2020-01-01T00:00:00Z"
|
||||
recent := "2026-07-26T12:00:00Z"
|
||||
if err := store.RecordObserverNeighborMetrics("obs-metrics-5", []ObserverNeighborEntry{{Pubkey: pk, SNR: floatPtr(1)}}, old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.RecordObserverNeighborMetrics("obs-metrics-5", []ObserverNeighborEntry{{Pubkey: pk, SNR: floatPtr(2)}}, recent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, err := store.PruneOldNeighborMetrics(30); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if n != 1 {
|
||||
t.Fatalf("expected 1 row pruned, got %d", n)
|
||||
}
|
||||
if n := countObserverNeighborMetrics(t, store, "obs-metrics-5", pk); n != 1 {
|
||||
t.Errorf("expected 1 row remaining after prune, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNeighborsReport_RecordsSnrHistory(t *testing.T) {
|
||||
store := openNeighborsStore(t)
|
||||
seedObserverForNeighbors(t, store, "obs-metrics-6")
|
||||
|
||||
report := map[string]interface{}{
|
||||
"timestamp": "2026-07-26T16:45:06.000000+00:00",
|
||||
"neighbors": []interface{}{
|
||||
map[string]interface{}{"pubkey": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", "snr": 10.0, "heard_secs_ago": 75.0, "scopes": "", "status": "timeout"},
|
||||
map[string]interface{}{"pubkey": "2102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", "snr": 14.0, "heard_secs_ago": 83.0, "scopes": "dk", "status": "responded"},
|
||||
},
|
||||
}
|
||||
handleNeighborsReport(store, "test", "obs-metrics-6", report)
|
||||
|
||||
var snr float64
|
||||
var heardSecsAgo int
|
||||
if err := store.db.QueryRow(`SELECT snr, heard_secs_ago FROM observer_neighbor_metrics WHERE observer_id = ? AND neighbor_pubkey = ?`,
|
||||
"obs-metrics-6", "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20").Scan(&snr, &heardSecsAgo); err != nil {
|
||||
t.Fatalf("select: %v", err)
|
||||
}
|
||||
if snr != 10.0 || heardSecsAgo != 75 {
|
||||
t.Errorf("snr=%v heardSecsAgo=%v, want 10.0/75 (timeout entry must still record SNR)", snr, heardSecsAgo)
|
||||
}
|
||||
}
|
||||
+98
-1
@@ -1399,14 +1399,55 @@ type ObserverNeighbor struct {
|
||||
Role *string `json:"role"`
|
||||
Scopes *string `json:"scopes"`
|
||||
Status string `json:"status"`
|
||||
// SeenViaPackets is true when this firmware-confirmed neighbor also has
|
||||
// an edge in the packet-path-inferred neighbor_edges graph. false is a
|
||||
// diagnostic signal, NOT necessarily a fault: it means we've never
|
||||
// resolved a packet path connecting these two stations despite RF
|
||||
// adjacency, which can point at a coverage gap, packet loss, or simply
|
||||
// that the neighbor hasn't transmitted since neighbor_edges last built
|
||||
// (#1865 follow-up, requested by dborup to help "make the disambiguator
|
||||
// smarter" -- this surfaces the mismatch; it does not yet feed the
|
||||
// disambiguator's own scoring, which would be a separate, larger change).
|
||||
SeenViaPackets bool `json:"seenViaPackets"`
|
||||
}
|
||||
|
||||
// packetGraphNeighbors returns the set of lowercase pubkeys that
|
||||
// neighbor_edges records as adjacent to pubkey, in either edge direction
|
||||
// (canonEdge in cmd/ingestor/neighbor_builder.go stores node_a<=node_b, so
|
||||
// callers must check both columns rather than assuming a side).
|
||||
func (db *DB) packetGraphNeighbors(pubkey string) (map[string]bool, error) {
|
||||
rows, err := db.conn.Query(`SELECT node_a, node_b FROM neighbor_edges WHERE node_a = ? OR node_b = ?`, pubkey, pubkey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
set := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var a, b string
|
||||
if err := rows.Scan(&a, &b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a == pubkey {
|
||||
set[b] = true
|
||||
} else {
|
||||
set[a] = true
|
||||
}
|
||||
}
|
||||
return set, rows.Err()
|
||||
}
|
||||
|
||||
// GetObserverNeighbors returns the observer's current direct-neighbor
|
||||
// snapshot (empty slice if none/never reported -- not an error) alongside
|
||||
// the shared report timestamp all rows carry (from observer_neighbors.
|
||||
// reported_at, which the ingestor sets identically for every row in a
|
||||
// single replace).
|
||||
// single replace). Each entry is cross-referenced against the
|
||||
// packet-derived neighbor_edges graph via SeenViaPackets.
|
||||
func (db *DB) GetObserverNeighbors(observerID string) ([]ObserverNeighbor, string, error) {
|
||||
packetNeighbors, err := db.packetGraphNeighbors(strings.ToLower(observerID))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
rows, err := db.conn.Query(`
|
||||
SELECT on2.neighbor_pubkey, on2.scopes, on2.status, on2.reported_at, n.name, n.role
|
||||
FROM observer_neighbors on2
|
||||
@@ -1426,6 +1467,7 @@ func (db *DB) GetObserverNeighbors(observerID string) ([]ObserverNeighbor, strin
|
||||
if err := rows.Scan(&n.Pubkey, &scopes, &n.Status, &reportedAtCol, &name, &role); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
n.SeenViaPackets = packetNeighbors[n.Pubkey]
|
||||
if scopes.Valid && scopes.String != "" {
|
||||
s := scopes.String
|
||||
n.Scopes = &s
|
||||
@@ -1446,6 +1488,61 @@ func (db *DB) GetObserverNeighbors(observerID string) ([]ObserverNeighbor, strin
|
||||
return result, reportedAt, rows.Err()
|
||||
}
|
||||
|
||||
// NeighborMetricPoint is one time-series sample of an observer<->neighbor
|
||||
// direct-RF link (#1865 follow-up: the /neighbors report's snr and
|
||||
// heard_secs_ago fields, previously dropped). Mirrors MetricsSample's
|
||||
// shape but deliberately simpler -- report volume per neighbor pair is
|
||||
// inherently low (one row per /neighbors report, which arrive hours
|
||||
// apart), so unlike GetObserverMetrics there's no resolution/downsampling.
|
||||
type NeighborMetricPoint struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
SNR *float64 `json:"snr"`
|
||||
HeardSecsAgo *int `json:"heardSecsAgo"`
|
||||
}
|
||||
|
||||
// GetObserverNeighborMetrics returns raw SNR/heard_secs_ago history for one
|
||||
// observer<->neighbor pair, oldest first, optionally bounded by since/until
|
||||
// (RFC3339; either may be "" to leave that bound open).
|
||||
func (db *DB) GetObserverNeighborMetrics(observerID, neighborPubkey, since, until string) ([]NeighborMetricPoint, error) {
|
||||
query := `SELECT timestamp, snr, heard_secs_ago FROM observer_neighbor_metrics WHERE observer_id = ? AND neighbor_pubkey = ?`
|
||||
args := []interface{}{observerID, neighborPubkey}
|
||||
if since != "" {
|
||||
query += ` AND timestamp >= ?`
|
||||
args = append(args, since)
|
||||
}
|
||||
if until != "" {
|
||||
query += ` AND timestamp <= ?`
|
||||
args = append(args, until)
|
||||
}
|
||||
query += ` ORDER BY timestamp ASC`
|
||||
|
||||
rows, err := db.conn.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := []NeighborMetricPoint{}
|
||||
for rows.Next() {
|
||||
var p NeighborMetricPoint
|
||||
var snr sql.NullFloat64
|
||||
var heardSecsAgo sql.NullInt64
|
||||
if err := rows.Scan(&p.Timestamp, &snr, &heardSecsAgo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if snr.Valid {
|
||||
v := snr.Float64
|
||||
p.SNR = &v
|
||||
}
|
||||
if heardSecsAgo.Valid {
|
||||
v := int(heardSecsAgo.Int64)
|
||||
p.HeardSecsAgo = &v
|
||||
}
|
||||
result = append(result, p)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// GetObserverIdsForRegion returns observer IDs for given IATA codes.
|
||||
func (db *DB) GetObserverIdsForRegion(regionParam string) ([]string, error) {
|
||||
codes := normalizeRegionCodes(regionParam)
|
||||
|
||||
@@ -112,6 +112,23 @@ func setupTestDB(t *testing.T) *DB {
|
||||
PRIMARY KEY (observer_id, neighbor_pubkey)
|
||||
);
|
||||
|
||||
CREATE TABLE neighbor_edges (
|
||||
node_a TEXT NOT NULL,
|
||||
node_b TEXT NOT NULL,
|
||||
count INTEGER DEFAULT 1,
|
||||
last_seen TEXT,
|
||||
PRIMARY KEY (node_a, node_b)
|
||||
);
|
||||
|
||||
CREATE TABLE observer_neighbor_metrics (
|
||||
observer_id TEXT NOT NULL,
|
||||
neighbor_pubkey TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
snr REAL,
|
||||
heard_secs_ago INTEGER,
|
||||
PRIMARY KEY (observer_id, neighbor_pubkey, timestamp)
|
||||
);
|
||||
|
||||
-- Auto-populate from_pubkey for ADVERT rows so existing test fixtures
|
||||
-- (which only set decoded_json) still attribute correctly under #1143's
|
||||
-- exact-match column. Production migration handles legacy data; the
|
||||
|
||||
@@ -128,3 +128,94 @@ func TestHandleObserverNeighbors_UnresolvedPubkeyHasNilNameAndRole(t *testing.T)
|
||||
t.Errorf("Status = %q, want 'timeout'", n.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// More ambitious use, requested by dborup after the panel shipped:
|
||||
// cross-reference the firmware-confirmed direct neighbor against the
|
||||
// packet-derived neighbor_edges graph and flag mismatches.
|
||||
func TestHandleObserverNeighbors_SeenViaPacketsCrossReference(t *testing.T) {
|
||||
srv, router := setupTestServer(t)
|
||||
|
||||
observerPubkey := "1111111111111111111111111111111111111111111111111111111111111111"
|
||||
confirmedAndSeen := "2222222222222222222222222222222222222222222222222222222222222222"
|
||||
confirmedButNeverSeen := "3333333333333333333333333333333333333333333333333333333333333333"
|
||||
|
||||
if _, err := srv.db.conn.Exec(`INSERT INTO observer_neighbors (observer_id, neighbor_pubkey, scopes, status, reported_at) VALUES
|
||||
(?, ?, '', 'responded', '2026-07-26T12:00:00Z'),
|
||||
(?, ?, '', 'responded', '2026-07-26T12:00:00Z')`,
|
||||
observerPubkey, confirmedAndSeen, observerPubkey, confirmedButNeverSeen); err != nil {
|
||||
t.Fatalf("seed observer_neighbors: %v", err)
|
||||
}
|
||||
// Packet-path evidence exists for observerPubkey<->confirmedAndSeen but
|
||||
// NOT for observerPubkey<->confirmedButNeverSeen.
|
||||
if _, err := srv.db.conn.Exec(`INSERT INTO neighbor_edges (node_a, node_b, count, last_seen) VALUES (?, ?, 5, '2026-07-26T11:00:00Z')`,
|
||||
observerPubkey, confirmedAndSeen); err != nil {
|
||||
t.Fatalf("seed neighbor_edges: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/observers/"+observerPubkey+"/neighbors", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Neighbors []struct {
|
||||
Pubkey string `json:"pubkey"`
|
||||
SeenViaPackets bool `json:"seenViaPackets"`
|
||||
} `json:"neighbors"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, n := range body.Neighbors {
|
||||
got[n.Pubkey] = n.SeenViaPackets
|
||||
}
|
||||
if !got[confirmedAndSeen] {
|
||||
t.Errorf("seenViaPackets for %s = false, want true (edge exists in neighbor_edges)", confirmedAndSeen)
|
||||
}
|
||||
if got[confirmedButNeverSeen] {
|
||||
t.Errorf("seenViaPackets for %s = true, want false (no edge in neighbor_edges)", confirmedButNeverSeen)
|
||||
}
|
||||
}
|
||||
|
||||
// The edge might be stored with the observer as node_b rather than node_a
|
||||
// (canonEdge orders node_a<=node_b) -- must still be detected.
|
||||
func TestHandleObserverNeighbors_SeenViaPacketsChecksBothEdgeColumns(t *testing.T) {
|
||||
srv, router := setupTestServer(t)
|
||||
|
||||
observerPubkey := "9999999999999999999999999999999999999999999999999999999999999999"
|
||||
neighborPubkey := "1000000000000000000000000000000000000000000000000000000000000000"
|
||||
|
||||
if _, err := srv.db.conn.Exec(`INSERT INTO observer_neighbors (observer_id, neighbor_pubkey, scopes, status, reported_at) VALUES (?, ?, '', 'responded', '2026-07-26T12:00:00Z')`,
|
||||
observerPubkey, neighborPubkey); err != nil {
|
||||
t.Fatalf("seed observer_neighbors: %v", err)
|
||||
}
|
||||
// neighborPubkey < observerPubkey lexicographically, so canonEdge would
|
||||
// store it as node_a=neighborPubkey, node_b=observerPubkey.
|
||||
if _, err := srv.db.conn.Exec(`INSERT INTO neighbor_edges (node_a, node_b, count, last_seen) VALUES (?, ?, 1, '2026-07-26T11:00:00Z')`,
|
||||
neighborPubkey, observerPubkey); err != nil {
|
||||
t.Fatalf("seed neighbor_edges: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/observers/"+observerPubkey+"/neighbors", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Neighbors []struct {
|
||||
Pubkey string `json:"pubkey"`
|
||||
SeenViaPackets bool `json:"seenViaPackets"`
|
||||
} `json:"neighbors"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
if len(body.Neighbors) != 1 || !body.Neighbors[0].SeenViaPackets {
|
||||
t.Fatalf("expected 1 neighbor with seenViaPackets=true (edge stored as node_a), got %+v", body.Neighbors)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package main
|
||||
|
||||
// #1865 follow-up: GET /api/observers/{id}/neighbors/{pubkey}/metrics
|
||||
// serves SNR/heard_secs_ago history for one observer<->neighbor link.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHandleObserverNeighborMetrics_ReturnsOrderedHistory(t *testing.T) {
|
||||
srv, router := setupTestServer(t)
|
||||
|
||||
pubkey := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
rows := []struct {
|
||||
ts string
|
||||
snr float64
|
||||
hsa int
|
||||
}{
|
||||
{"2026-07-26T14:00:00Z", 6.0, 60},
|
||||
{"2026-07-26T12:00:00Z", 5.0, 75},
|
||||
}
|
||||
for _, r := range rows {
|
||||
if _, err := srv.db.conn.Exec(`INSERT INTO observer_neighbor_metrics (observer_id, neighbor_pubkey, timestamp, snr, heard_secs_ago) VALUES (?, ?, ?, ?, ?)`,
|
||||
"obs1", pubkey, r.ts, r.snr, r.hsa); err != nil {
|
||||
t.Fatalf("seed observer_neighbor_metrics: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/observers/obs1/neighbors/"+pubkey+"/metrics?since=2026-07-01T00:00:00Z", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Metrics []struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
SNR *float64 `json:"snr"`
|
||||
HeardSecsAgo *int `json:"heardSecsAgo"`
|
||||
} `json:"metrics"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
if len(body.Metrics) != 2 {
|
||||
t.Fatalf("expected 2 metric points, got %d", len(body.Metrics))
|
||||
}
|
||||
// Oldest first.
|
||||
if body.Metrics[0].Timestamp != "2026-07-26T12:00:00Z" || body.Metrics[1].Timestamp != "2026-07-26T14:00:00Z" {
|
||||
t.Errorf("expected chronological order, got %+v", body.Metrics)
|
||||
}
|
||||
if body.Metrics[0].SNR == nil || *body.Metrics[0].SNR != 5.0 {
|
||||
t.Errorf("first SNR = %v, want 5.0", body.Metrics[0].SNR)
|
||||
}
|
||||
if body.Metrics[0].HeardSecsAgo == nil || *body.Metrics[0].HeardSecsAgo != 75 {
|
||||
t.Errorf("first HeardSecsAgo = %v, want 75", body.Metrics[0].HeardSecsAgo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleObserverNeighborMetrics_NoDataReturnsEmptyNotError(t *testing.T) {
|
||||
srv, router := setupTestServer(t)
|
||||
_ = srv
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/observers/obs1/neighbors/deadbeef/metrics", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Metrics []interface{} `json:"metrics"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
if body.Metrics == nil {
|
||||
t.Error("metrics must be an empty array, not null")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleObserverNeighborMetrics_SinceFiltersOlderRows(t *testing.T) {
|
||||
srv, router := setupTestServer(t)
|
||||
|
||||
pubkey := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
if _, err := srv.db.conn.Exec(`INSERT INTO observer_neighbor_metrics (observer_id, neighbor_pubkey, timestamp, snr) VALUES (?, ?, ?, ?)`,
|
||||
"obs1", pubkey, "2020-01-01T00:00:00Z", 1.0); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if _, err := srv.db.conn.Exec(`INSERT INTO observer_neighbor_metrics (observer_id, neighbor_pubkey, timestamp, snr) VALUES (?, ?, ?, ?)`,
|
||||
"obs1", pubkey, "2026-07-26T00:00:00Z", 2.0); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/observers/obs1/neighbors/"+pubkey+"/metrics?since=2026-01-01T00:00:00Z", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Metrics []struct{ Timestamp string } `json:"metrics"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(body.Metrics) != 1 || body.Metrics[0].Timestamp != "2026-07-26T00:00:00Z" {
|
||||
t.Errorf("expected only the recent row, got %+v", body.Metrics)
|
||||
}
|
||||
}
|
||||
@@ -132,12 +132,13 @@ 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/{id}": {Summary: "Get observer detail", Tag: "observers"},
|
||||
"GET /api/observers/{id}/metrics": {Summary: "Get observer metrics", Description: "Packet rates, uptime, and performance metrics.", Tag: "observers"},
|
||||
"GET /api/observers/{id}/analytics": {Summary: "Get observer analytics", Tag: "observers"},
|
||||
"GET /api/observers/{id}/neighbors": {Summary: "Get an observer's direct (zero-hop) neighbors", Description: "Ground truth from the observer's own /neighbors firmware report (#1865) -- distinct from the packet-path-inferred neighbor graph. Empty `neighbors` (never null) and an empty `reportedAt` mean the observer has never sent a /neighbors report: opt-in firmware, unavailable on non-PSRAM hardware -- absence is normal, not a fault. Each entry's `scopes` is null unless the neighbor's OTA scope query responded (status=\"responded\"); `name`/`role` are null when the pubkey doesn't resolve to a known node.", Tag: "observers"},
|
||||
"GET /api/observers/metrics/summary": {Summary: "Observer metrics summary", Description: "Aggregate metrics across all observers.", Tag: "observers"},
|
||||
"GET /api/observers": {Summary: "List observers", Description: "Returns all known packet observers/gateways.", Tag: "observers"},
|
||||
"GET /api/observers/{id}": {Summary: "Get observer detail", Tag: "observers"},
|
||||
"GET /api/observers/{id}/metrics": {Summary: "Get observer metrics", Description: "Packet rates, uptime, and performance metrics.", Tag: "observers"},
|
||||
"GET /api/observers/{id}/analytics": {Summary: "Get observer analytics", Tag: "observers"},
|
||||
"GET /api/observers/{id}/neighbors": {Summary: "Get an observer's direct (zero-hop) neighbors", Description: "Ground truth from the observer's own /neighbors firmware report (#1865) -- distinct from the packet-path-inferred neighbor graph. Empty `neighbors` (never null) and an empty `reportedAt` mean the observer has never sent a /neighbors report: opt-in firmware, unavailable on non-PSRAM hardware -- absence is normal, not a fault. Each entry's `scopes` is null unless the neighbor's OTA scope query responded (status=\"responded\"); `name`/`role` are null when the pubkey doesn't resolve to a known node. `seenViaPackets` cross-references the packet-path-inferred neighbor_edges graph: false means this firmware-confirmed neighbor has never had a resolved packet path between it and the observer, a diagnostic signal (possible coverage gap or packet loss), not itself a fault.", Tag: "observers"},
|
||||
"GET /api/observers/{id}/neighbors/{pubkey}/metrics": {Summary: "Get SNR history for one observer<->neighbor direct-RF link", Description: "Raw (unaggregated) history of the snr/heard_secs_ago fields the observer's own /neighbors report carries per neighbor -- report volume per pair is inherently low so, unlike /api/observers/{id}/metrics, there is no resolution/downsampling. Defaults to the last 30 days.", Tag: "observers", QueryParams: []paramMeta{{Name: "since", Description: "RFC3339 lower bound (default: 30 days ago)", Type: "string"}, {Name: "until", Description: "RFC3339 upper bound (default: none)", Type: "string"}}},
|
||||
"GET /api/observers/metrics/summary": {Summary: "Observer metrics summary", Description: "Aggregate metrics across all observers.", Tag: "observers"},
|
||||
|
||||
// Misc
|
||||
"GET /api/resolve-hops": {Summary: "Resolve hop path", Description: "Resolves hash prefixes in a hop path to node names. Returns affinity scores and best candidates.", Tag: "nodes", QueryParams: []paramMeta{{Name: "hops", Description: "Comma-separated hop hash prefixes", Type: "string", Required: true}}},
|
||||
|
||||
@@ -349,6 +349,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/api/observers/{id}/metrics", s.handleObserverMetrics).Methods("GET")
|
||||
r.HandleFunc("/api/observers/{id}/analytics", s.handleObserverAnalytics).Methods("GET")
|
||||
r.HandleFunc("/api/observers/{id}/neighbors", s.handleObserverNeighbors).Methods("GET")
|
||||
r.HandleFunc("/api/observers/{id}/neighbors/{pubkey}/metrics", s.handleObserverNeighborMetrics).Methods("GET")
|
||||
r.HandleFunc("/api/observers/{id}", s.handleObserverDetail).Methods("GET")
|
||||
r.HandleFunc("/api/observers", s.handleObservers).Methods("GET")
|
||||
r.HandleFunc("/api/traces/{hash}", s.handleTraces).Methods("GET")
|
||||
@@ -3251,6 +3252,33 @@ func (s *Server) handleObserverNeighbors(w http.ResponseWriter, r *http.Request)
|
||||
})
|
||||
}
|
||||
|
||||
// handleObserverNeighborMetrics serves the SNR/heard_secs_ago history for
|
||||
// one observer<->neighbor direct-RF link (#1865 follow-up), for the Direct
|
||||
// Neighbors panel's per-row sparkline. Defaults to the last 30 days --
|
||||
// report volume per pair is inherently low, so this rarely needs trimming.
|
||||
func (s *Server) handleObserverNeighborMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
id := mux.Vars(r)["id"]
|
||||
pubkey := strings.ToLower(mux.Vars(r)["pubkey"])
|
||||
|
||||
if s.cfg != nil && s.cfg.IsObserverBlacklisted(id) {
|
||||
writeError(w, 404, "Observer not found")
|
||||
return
|
||||
}
|
||||
|
||||
since := r.URL.Query().Get("since")
|
||||
until := r.URL.Query().Get("until")
|
||||
if since == "" {
|
||||
since = time.Now().UTC().AddDate(0, 0, -30).Format(time.RFC3339)
|
||||
}
|
||||
|
||||
metrics, err := s.db.GetObserverNeighborMetrics(id, pubkey, since, until)
|
||||
if err != nil {
|
||||
writeError(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"metrics": metrics})
|
||||
}
|
||||
|
||||
func (s *Server) handleObserverAnalytics(w http.ResponseWriter, r *http.Request) {
|
||||
id := mux.Vars(r)["id"]
|
||||
days := queryInt(r, "days", 7)
|
||||
|
||||
@@ -100,6 +100,9 @@ func Apply(rw *sql.DB, logf Logger) error {
|
||||
if err := ensureObserverNeighborsTable(rw, logf); err != nil {
|
||||
return fmt.Errorf("ensure observer_neighbors: %w", err)
|
||||
}
|
||||
if err := ensureObserverNeighborMetricsTable(rw, logf); err != nil {
|
||||
return fmt.Errorf("ensure observer_neighbor_metrics: %w", err)
|
||||
}
|
||||
// #1690: denormalized last_seen on transmissions so cold-load filters
|
||||
// on effective recency rather than first-ever first_seen. The column
|
||||
// add + index creation are cheap (single ALTER, indexed INTEGER
|
||||
@@ -191,6 +194,7 @@ func AssertReady(ro *sql.DB) error {
|
||||
// is the only writer.
|
||||
mustCol("observers", "last_neighbors_report_at")
|
||||
mustTable("observer_neighbors")
|
||||
mustTable("observer_neighbor_metrics")
|
||||
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("schema not migrated by ingestor; restart ingestor first. missing: %s",
|
||||
@@ -795,3 +799,40 @@ func ensureObserverNeighborsTable(rw *sql.DB, logf Logger) error {
|
||||
logf("[dbschema] created observer_neighbors table")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureObserverNeighborMetricsTable creates observer_neighbor_metrics: an
|
||||
// APPEND-ONLY SNR/heard_secs_ago history per observer<->neighbor pair,
|
||||
// unlike observer_neighbors' current-only snapshot. Every report is valid
|
||||
// historical data at its own timestamp regardless of arrival order, so
|
||||
// there is no ordering guard on the write side (contrast
|
||||
// ReplaceObserverNeighbors). Retention mirrors observer_metrics'
|
||||
// MetricsRetentionDays (30-day default) -- pruned by the ingestor's
|
||||
// PruneOldNeighborMetrics, not owned here.
|
||||
func ensureObserverNeighborMetricsTable(rw *sql.DB, logf Logger) error {
|
||||
if err := ensureMigrationsTable(rw); err != nil {
|
||||
return err
|
||||
}
|
||||
row := rw.QueryRow(`SELECT 1 FROM _migrations WHERE name = 'observer_neighbor_metrics_v1'`)
|
||||
var one int
|
||||
if err := row.Scan(&one); err == nil {
|
||||
return nil // already applied
|
||||
}
|
||||
if _, err := rw.Exec(`CREATE TABLE IF NOT EXISTS observer_neighbor_metrics (
|
||||
observer_id TEXT NOT NULL,
|
||||
neighbor_pubkey TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
snr REAL,
|
||||
heard_secs_ago INTEGER,
|
||||
PRIMARY KEY (observer_id, neighbor_pubkey, timestamp)
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("create observer_neighbor_metrics: %w", err)
|
||||
}
|
||||
if _, err := rw.Exec(`CREATE INDEX IF NOT EXISTS idx_observer_neighbor_metrics_ts ON observer_neighbor_metrics(timestamp)`); err != nil {
|
||||
return fmt.Errorf("create idx_observer_neighbor_metrics_ts: %w", err)
|
||||
}
|
||||
if _, err := rw.Exec(`INSERT OR IGNORE INTO _migrations (name) VALUES ('observer_neighbor_metrics_v1')`); err != nil {
|
||||
return fmt.Errorf("record observer_neighbor_metrics_v1: %w", err)
|
||||
}
|
||||
logf("[dbschema] created observer_neighbor_metrics table")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -198,15 +198,72 @@ window.ObserverDetailNaiveBanner = {
|
||||
: (n.status === 'timeout'
|
||||
? '<span class="text-muted" title="Scope query timed out">no reply</span>'
|
||||
: '<span class="text-muted">—</span>');
|
||||
return '<tr><td>' + nameCell + '</td><td>' + scopeCell + '</td></tr>';
|
||||
// Cross-reference against the packet-derived neighbor_edges graph.
|
||||
// false is a diagnostic signal (coverage gap / packet loss / just
|
||||
// hasn't transmitted recently) -- worth noticing, but styled neutral
|
||||
// rather than as an error since it's not necessarily a problem.
|
||||
const evidenceCell = n.seenViaPackets
|
||||
? '<span class="text-muted" title="A packet path connecting this station and the observer has been resolved">confirmed</span>'
|
||||
: '<span style="color:var(--text-muted)" title="Firmware reports this as a direct RF neighbor, but no packet path between the two has been resolved yet -- possible coverage gap, packet loss, or the neighbor simply hasn\'t transmitted recently.">not seen yet</span>';
|
||||
// Sparkline is loaded async (loadNeighborSnrSparklines) once this
|
||||
// table is in the DOM -- placeholder id keyed by pubkey.
|
||||
const snrCell = n.pubkey
|
||||
? '<span id="nb-spark-' + escapeHtml(n.pubkey) + '" class="text-muted" style="font-size:10px">…</span>'
|
||||
: '<span class="text-muted">—</span>';
|
||||
return '<tr><td>' + nameCell + '</td><td>' + scopeCell + '</td><td>' + evidenceCell + '</td><td>' + snrCell + '</td></tr>';
|
||||
}).join('');
|
||||
const asOf = neighborsData.reportedAt
|
||||
? '<div class="text-muted" style="font-size:11px;margin-top:6px">As of ' + timeAgo(neighborsData.reportedAt) + '</div>'
|
||||
: '';
|
||||
return '<div class="table-fluid-wrap"><table class="data-table"><thead><tr><th scope="col">Neighbor</th><th scope="col">Configured Scope</th></tr></thead><tbody>' + rows + '</tbody></table></div>' + asOf;
|
||||
return '<div class="table-fluid-wrap"><table class="data-table"><thead><tr><th scope="col">Neighbor</th><th scope="col">Configured Scope</th><th scope="col" title="Cross-referenced against the packet-derived neighbor graph">Packet Evidence</th><th scope="col" title="SNR trend from this observer\'s /neighbors reports, most recent 30 days">SNR Trend</th></tr></thead><tbody>' + rows + '</tbody></table></div>' + asOf;
|
||||
}
|
||||
window.renderDirectNeighbors = renderDirectNeighbors;
|
||||
|
||||
// #1865 follow-up: lightweight inline-SVG sparkline, same technique as
|
||||
// the RF Health tab's rfNFSparkline (public/analytics.js) -- no Chart.js
|
||||
// overhead for a tiny per-row indicator. Unlike noise floor, higher SNR
|
||||
// is better, so no axis inversion.
|
||||
function neighborSnrSparkline(values, w, h) {
|
||||
if (!values.length) return '';
|
||||
const min = Math.min.apply(null, values);
|
||||
const max = Math.max.apply(null, values);
|
||||
const range = max - min || 1;
|
||||
const pts = values.map(function(v, i) {
|
||||
const x = (i / Math.max(values.length - 1, 1)) * w;
|
||||
const y = h - 2 - ((v - min) / range) * (h - 4);
|
||||
return x.toFixed(1) + ',' + y.toFixed(1);
|
||||
}).join(' ');
|
||||
return '<svg viewBox="0 0 ' + w + ' ' + h + '" style="width:' + w + 'px;height:' + h + 'px" role="img" aria-label="SNR trend"><title>SNR trend</title><polyline points="' + pts + '" fill="none" stroke="var(--accent)" stroke-width="1.5"/></svg>';
|
||||
}
|
||||
window.neighborSnrSparkline = neighborSnrSparkline;
|
||||
|
||||
// Fetched per-row, after the Direct Neighbors table is in the DOM --
|
||||
// mirrors the RF Health grid's loadRFSparkline pattern (async, non-fatal
|
||||
// on failure, since the sparkline is a nice-to-have, not core content).
|
||||
async function loadNeighborSnrSparklines(observerId, neighborsData) {
|
||||
const neighbors = (neighborsData && Array.isArray(neighborsData.neighbors)) ? neighborsData.neighbors : [];
|
||||
for (const n of neighbors) {
|
||||
if (!n.pubkey) continue;
|
||||
const container = document.getElementById('nb-spark-' + n.pubkey);
|
||||
if (!container) continue;
|
||||
try {
|
||||
const data = await api('/observers/' + encodeURIComponent(observerId) + '/neighbors/' + encodeURIComponent(n.pubkey) + '/metrics');
|
||||
const values = (data.metrics || []).map(function(m) { return m.snr; }).filter(function(v) { return v != null; });
|
||||
if (values.length > 1) {
|
||||
const latest = values[values.length - 1];
|
||||
container.outerHTML = neighborSnrSparkline(values, 80, 20) + ' <span class="text-muted" style="font-size:10px">' + latest.toFixed(1) + ' dB</span>';
|
||||
} else if (values.length === 1) {
|
||||
container.outerHTML = '<span class="text-muted" style="font-size:10px">' + values[0].toFixed(1) + ' dB</span>';
|
||||
} else {
|
||||
container.outerHTML = '<span class="text-muted" style="font-size:10px">no data</span>';
|
||||
}
|
||||
} catch (e) {
|
||||
if (container) container.outerHTML = '<span class="text-muted" style="font-size:10px">—</span>';
|
||||
}
|
||||
}
|
||||
}
|
||||
window.loadNeighborSnrSparklines = loadNeighborSnrSparklines;
|
||||
|
||||
function renderDetail(obs, analytics, obsSkew, neighborsData) {
|
||||
const el = document.getElementById('obsDetailContent');
|
||||
if (!el) return;
|
||||
@@ -374,6 +431,7 @@ window.ObserverDetailNaiveBanner = {
|
||||
if (analytics.recentPackets) {
|
||||
renderRecentPackets(analytics.recentPackets);
|
||||
}
|
||||
loadNeighborSnrSparklines(currentId, neighborsData);
|
||||
}
|
||||
|
||||
function renderTimelineChart(timeline) {
|
||||
|
||||
@@ -11,9 +11,21 @@ const fs = require('fs');
|
||||
const assert = require('assert');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
const pending = [];
|
||||
function test(name, fn) {
|
||||
try { fn(); passed++; console.log(` ✅ ${name}`); }
|
||||
catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); }
|
||||
try {
|
||||
const result = fn();
|
||||
if (result && typeof result.then === 'function') {
|
||||
// Async test (the sparkline loader tests) -- defer accounting until
|
||||
// it settles; the final summary waits on `pending` before printing.
|
||||
pending.push(result.then(
|
||||
() => { passed++; console.log(` ✅ ${name}`); },
|
||||
(e) => { failed++; console.log(` ❌ ${name}: ${e.message}`); }
|
||||
));
|
||||
} else {
|
||||
passed++; console.log(` ✅ ${name}`);
|
||||
}
|
||||
} catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); }
|
||||
}
|
||||
|
||||
function makeCtx() {
|
||||
@@ -44,6 +56,39 @@ function makeCtx() {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// Richer sandbox for the async sparkline loader: needs a mockable api()
|
||||
// and a document.getElementById that returns a settable-outerHTML stub.
|
||||
function makeLoaderCtx(apiImpl, elementsById) {
|
||||
const ctx = {
|
||||
window: { addEventListener: () => {}, dispatchEvent: () => {} },
|
||||
document: {
|
||||
readyState: 'complete',
|
||||
createElement: () => ({ id: '', textContent: '', innerHTML: '' }),
|
||||
head: { appendChild: () => {} },
|
||||
getElementById: (id) => elementsById[id] || null,
|
||||
addEventListener: () => {},
|
||||
querySelectorAll: () => [],
|
||||
querySelector: () => null,
|
||||
},
|
||||
console, Date, Math, Array, Object, String, Number, Boolean, JSON, Promise,
|
||||
setInterval: () => 0, clearInterval: () => {},
|
||||
setTimeout: (fn) => { try { fn(); } catch {} return 0; },
|
||||
encodeURIComponent, decodeURIComponent,
|
||||
api: apiImpl,
|
||||
};
|
||||
ctx.registerPage = () => {};
|
||||
ctx.timeAgo = (iso) => 'TIME_AGO(' + iso + ')';
|
||||
ctx.escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
})[c]);
|
||||
ctx.Chart = function () { return { destroy() {} }; };
|
||||
vm.createContext(ctx);
|
||||
return ctx;
|
||||
}
|
||||
function mockElement() {
|
||||
return { _outerHTML: '', set outerHTML(v) { this._outerHTML = v; }, get outerHTML() { return this._outerHTML; } };
|
||||
}
|
||||
|
||||
console.log('\n=== #1865 follow-up — Observer detail "Direct Neighbors" panel ===');
|
||||
|
||||
const ctx = makeCtx();
|
||||
@@ -88,5 +133,98 @@ test('an unresolved pubkey (no name) renders truncated, unlinked', () => {
|
||||
assert.ok(/no reply/.test(html), 'expected the timeout "no reply" label');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
test('seenViaPackets=true renders "confirmed", not an error/warning treatment', () => {
|
||||
const html = ctx.window.renderDirectNeighbors({
|
||||
neighbors: [{ pubkey: 'abc123', name: 'Repeater A', role: 'repeater', scopes: '#dk', status: 'responded', seenViaPackets: true }],
|
||||
reportedAt: '',
|
||||
});
|
||||
assert.ok(/confirmed/.test(html));
|
||||
assert.ok(!/ph-warning/.test(html));
|
||||
});
|
||||
|
||||
test('seenViaPackets=false surfaces the coverage-gap diagnostic, styled neutrally', () => {
|
||||
const html = ctx.window.renderDirectNeighbors({
|
||||
neighbors: [{ pubkey: 'abc123', name: 'Repeater A', role: 'repeater', scopes: '#dk', status: 'responded', seenViaPackets: false }],
|
||||
reportedAt: '',
|
||||
});
|
||||
assert.ok(/not seen yet/.test(html));
|
||||
assert.ok(/coverage gap/.test(html), 'expected the explanatory tooltip');
|
||||
assert.ok(!/ph-warning/.test(html), 'must not use the warning icon treatment');
|
||||
});
|
||||
|
||||
test('renders a placeholder span per row for the async SNR sparkline loader', () => {
|
||||
const html = ctx.window.renderDirectNeighbors({
|
||||
neighbors: [{ pubkey: 'abc123', name: 'Repeater A', role: 'repeater', scopes: '#dk', status: 'responded' }],
|
||||
reportedAt: '',
|
||||
});
|
||||
assert.ok(html.includes('id="nb-spark-abc123"'), 'expected a placeholder span keyed by pubkey');
|
||||
});
|
||||
|
||||
console.log('\n=== #1865 follow-up — SNR sparkline (pure helper) ===');
|
||||
|
||||
test('window.neighborSnrSparkline exists', () => {
|
||||
assert.strictEqual(typeof ctx.window.neighborSnrSparkline, 'function');
|
||||
});
|
||||
|
||||
test('empty data returns empty string', () => {
|
||||
assert.strictEqual(ctx.window.neighborSnrSparkline([], 80, 20), '');
|
||||
});
|
||||
|
||||
test('renders an SVG polyline for 2+ points', () => {
|
||||
const html = ctx.window.neighborSnrSparkline([1, 5, 3], 80, 20);
|
||||
assert.ok(html.includes('<svg'));
|
||||
assert.ok(html.includes('<polyline'));
|
||||
});
|
||||
|
||||
console.log('\n=== #1865 follow-up — SNR sparkline async loader ===');
|
||||
|
||||
test('loads and injects a sparkline + latest value on success', async () => {
|
||||
const el = mockElement();
|
||||
const apiCtx = makeLoaderCtx(
|
||||
() => Promise.resolve({ metrics: [{ timestamp: 't1', snr: 5 }, { timestamp: 't2', snr: 8 }] }),
|
||||
{ 'nb-spark-abc123': el }
|
||||
);
|
||||
vm.runInContext(fs.readFileSync('public/observer-detail.js', 'utf8'), apiCtx);
|
||||
await apiCtx.window.loadNeighborSnrSparklines('obs1', { neighbors: [{ pubkey: 'abc123' }] });
|
||||
assert.ok(el.outerHTML.includes('<svg'), 'expected a sparkline to be injected');
|
||||
assert.ok(el.outerHTML.includes('8.0 dB'), 'expected the latest SNR value shown');
|
||||
});
|
||||
|
||||
test('shows a single value (no sparkline) when only one data point exists', async () => {
|
||||
const el = mockElement();
|
||||
const apiCtx = makeLoaderCtx(
|
||||
() => Promise.resolve({ metrics: [{ timestamp: 't1', snr: 4.5 }] }),
|
||||
{ 'nb-spark-abc123': el }
|
||||
);
|
||||
vm.runInContext(fs.readFileSync('public/observer-detail.js', 'utf8'), apiCtx);
|
||||
await apiCtx.window.loadNeighborSnrSparklines('obs1', { neighbors: [{ pubkey: 'abc123' }] });
|
||||
assert.ok(!el.outerHTML.includes('<svg'), 'a single point should not render a sparkline');
|
||||
assert.ok(el.outerHTML.includes('4.5 dB'));
|
||||
});
|
||||
|
||||
test('shows a neutral "no data" state, not an error, when metrics is empty', async () => {
|
||||
const el = mockElement();
|
||||
const apiCtx = makeLoaderCtx(
|
||||
() => Promise.resolve({ metrics: [] }),
|
||||
{ 'nb-spark-abc123': el }
|
||||
);
|
||||
vm.runInContext(fs.readFileSync('public/observer-detail.js', 'utf8'), apiCtx);
|
||||
await apiCtx.window.loadNeighborSnrSparklines('obs1', { neighbors: [{ pubkey: 'abc123' }] });
|
||||
assert.ok(/no data/.test(el.outerHTML));
|
||||
});
|
||||
|
||||
test('a failed fetch degrades to a neutral dash, not a thrown error', async () => {
|
||||
const el = mockElement();
|
||||
const apiCtx = makeLoaderCtx(
|
||||
() => Promise.reject(new Error('network error')),
|
||||
{ 'nb-spark-abc123': el }
|
||||
);
|
||||
vm.runInContext(fs.readFileSync('public/observer-detail.js', 'utf8'), apiCtx);
|
||||
await apiCtx.window.loadNeighborSnrSparklines('obs1', { neighbors: [{ pubkey: 'abc123' }] });
|
||||
assert.ok(el.outerHTML.length > 0, 'expected a fallback rendered, not left blank/thrown');
|
||||
});
|
||||
|
||||
Promise.all(pending).then(() => {
|
||||
console.log(`\n${passed} passed, ${failed} failed\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user