mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-23 11:12:32 +00:00
Merge branch 'areas-meshguide-sync'
This commit is contained in:
commit
d34089e979
13 files changed
+383
-27
No files matched your search
@@ -224,6 +224,7 @@ func applySchema(db *sql.DB) error {
|
||||
noise_floor REAL,
|
||||
inactive INTEGER DEFAULT 0,
|
||||
last_packet_at TEXT DEFAULT NULL,
|
||||
last_neighbors_report_at TEXT DEFAULT NULL,
|
||||
clock_skew_seconds INTEGER DEFAULT NULL,
|
||||
clock_skew_count_24h INTEGER DEFAULT 0,
|
||||
clock_last_naive_at TEXT DEFAULT NULL,
|
||||
@@ -1711,6 +1712,28 @@ func (s *Store) UpdateNodeConfiguredScope(pubkey, scope, reportedAt string) erro
|
||||
return err
|
||||
}
|
||||
|
||||
// TouchObserverNeighborsReport records that the given observer sent a
|
||||
// /neighbors report (#1865 follow-up, cwichura on PR #1867): the feature is
|
||||
// opt-in firmware and unavailable on non-PSRAM devices, so operators asked
|
||||
// for a way to see which observers have it enabled. Called once per report,
|
||||
// regardless of whether any individual neighbor entry carried scope
|
||||
// evidence. Uses MAX so an out-of-order older report can't roll the
|
||||
// displayed timestamp backwards; a blank/unparseable reportedAt is a no-op
|
||||
// (never regresses a known-good value to empty).
|
||||
func (s *Store) TouchObserverNeighborsReport(observerID, reportedAt string) error {
|
||||
if observerID == "" {
|
||||
return nil
|
||||
}
|
||||
reportedAt = normalizeReportTS(reportedAt)
|
||||
if reportedAt == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE observers SET last_neighbors_report_at = MAX(COALESCE(last_neighbors_report_at, ''), ?) WHERE id = ?`,
|
||||
reportedAt, observerID)
|
||||
return err
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -1537,6 +1537,14 @@ func init() {
|
||||
func handleNeighborsReport(store *Store, tag string, observerID string, msg map[string]interface{}) {
|
||||
reportedAt, _ := msg["timestamp"].(string)
|
||||
|
||||
// #1865 follow-up: record that this observer sends /neighbors reports
|
||||
// at all, regardless of whether self/neighbors below carry usable scope
|
||||
// evidence. Lets the UI identify which observers have the opt-in
|
||||
// firmware feature enabled.
|
||||
if err := store.TouchObserverNeighborsReport(observerID, reportedAt); err != nil {
|
||||
log.Printf("MQTT [%s] neighbors report touch error for observer %.8s: %v", tag, observerID, err)
|
||||
}
|
||||
|
||||
// self: the observer's own configured scopes.
|
||||
originID, _ := msg["origin_id"].(string)
|
||||
if originID == "" {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// #1865 follow-up (cwichura, PR #1867): TouchObserverNeighborsReport records
|
||||
// that an observer sends /neighbors reports at all, so the UI can identify
|
||||
// which observers have the opt-in firmware feature enabled.
|
||||
func TestTouchObserverNeighborsReport(t *testing.T) {
|
||||
store := openNeighborsStore(t)
|
||||
if _, err := store.db.Exec(`INSERT INTO observers (id, name) VALUES (?, ?)`, "obs-a", "Observer A"); err != nil {
|
||||
t.Fatalf("seed observer: %v", err)
|
||||
}
|
||||
|
||||
getAt := func() string {
|
||||
var at string
|
||||
if err := store.db.QueryRow(`SELECT COALESCE(last_neighbors_report_at, '') FROM observers WHERE id = ?`, "obs-a").Scan(&at); err != nil {
|
||||
t.Fatalf("select last_neighbors_report_at: %v", err)
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
if at := getAt(); at != "" {
|
||||
t.Fatalf("pre-check: expected empty, got %q", at)
|
||||
}
|
||||
|
||||
if err := store.TouchObserverNeighborsReport("obs-a", "2026-07-25T12:00:00Z"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if at := getAt(); at != "2026-07-25T12:00:00Z" {
|
||||
t.Errorf("last_neighbors_report_at = %q, want '2026-07-25T12:00:00Z'", at)
|
||||
}
|
||||
|
||||
// Out-of-order older report must not roll the timestamp backwards.
|
||||
if err := store.TouchObserverNeighborsReport("obs-a", "2026-07-24T00:00:00Z"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if at := getAt(); at != "2026-07-25T12:00:00Z" {
|
||||
t.Errorf("after stale report, last_neighbors_report_at = %q, want unchanged '2026-07-25T12:00:00Z'", at)
|
||||
}
|
||||
|
||||
// A later report advances it, canonicalized to UTC.
|
||||
if err := store.TouchObserverNeighborsReport("obs-a", "2026-07-26T15:00:00+02:00"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if at := getAt(); at != "2026-07-26T13:00:00Z" {
|
||||
t.Errorf("last_neighbors_report_at = %q, want '2026-07-26T13:00:00Z'", at)
|
||||
}
|
||||
|
||||
// Blank/unparseable timestamp is a no-op, never regresses to empty.
|
||||
if err := store.TouchObserverNeighborsReport("obs-a", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if at := getAt(); at != "2026-07-26T13:00:00Z" {
|
||||
t.Errorf("after blank report, last_neighbors_report_at = %q, want unchanged", at)
|
||||
}
|
||||
|
||||
// Unknown observer_id matches no row — must not error.
|
||||
if err := store.TouchObserverNeighborsReport("no-such-observer", "2026-07-26T00:00:00Z"); err != nil {
|
||||
t.Errorf("touch for unknown observer should be a no-op, got error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleNeighborsReport must touch the observer's last_neighbors_report_at
|
||||
// even when the report carries no usable scope evidence (no self, no
|
||||
// responded neighbors) — the mere existence of the report is the signal.
|
||||
func TestHandleNeighborsReport_TouchesObserverEvenWithoutScopeEvidence(t *testing.T) {
|
||||
store := openNeighborsStore(t)
|
||||
if _, err := store.db.Exec(`INSERT INTO observers (id, name) VALUES (?, ?)`, "obs-b", "Observer B"); err != nil {
|
||||
t.Fatalf("seed observer: %v", err)
|
||||
}
|
||||
|
||||
report := map[string]interface{}{
|
||||
"timestamp": "2026-07-26T10:00:00Z",
|
||||
"neighbors": []interface{}{
|
||||
map[string]interface{}{"pubkey": "deadbeef", "scopes": "", "status": "timeout"},
|
||||
},
|
||||
}
|
||||
handleNeighborsReport(store, "test", "obs-b", report)
|
||||
|
||||
var at string
|
||||
if err := store.db.QueryRow(`SELECT COALESCE(last_neighbors_report_at, '') FROM observers WHERE id = ?`, "obs-b").Scan(&at); err != nil {
|
||||
t.Fatalf("select last_neighbors_report_at: %v", err)
|
||||
}
|
||||
if at != "2026-07-26T10:00:00Z" {
|
||||
t.Errorf("last_neighbors_report_at = %q, want '2026-07-26T10:00:00Z'", at)
|
||||
}
|
||||
}
|
||||
@@ -293,6 +293,12 @@ type Observer struct {
|
||||
UptimeSecs *int64 `json:"uptime_secs"`
|
||||
NoiseFloor *float64 `json:"noise_floor"`
|
||||
LastPacketAt *string `json:"last_packet_at"`
|
||||
// #1865 follow-up: timestamp of the observer's most recent /neighbors
|
||||
// MQTT report (opt-in firmware feature). Written by the ingestor's
|
||||
// TouchObserverNeighborsReport; nil means the observer has never sent
|
||||
// one (may be legacy firmware, non-PSRAM hardware, or feature disabled
|
||||
// — the UI must not present this as a fault, per cwichura's request).
|
||||
LastNeighborsReportAt *string `json:"last_neighbors_report_at"`
|
||||
// Issue #1478: per-observer naive-clock skew tracking.
|
||||
// Written by the ingestor in cmd/ingestor/db.go RecordNaiveSkew whenever
|
||||
// resolveRxTime clamps a naive envelope timestamp >15 min off UTC. The
|
||||
@@ -1228,6 +1234,7 @@ func (db *DB) GetObservers() ([]Observer, error) {
|
||||
}
|
||||
rows, err := db.conn.Query(`SELECT id, name, iata, last_seen, first_seen, packet_count,
|
||||
model, firmware, client_version, radio, battery_mv, uptime_secs, noise_floor, last_packet_at,
|
||||
last_neighbors_report_at,
|
||||
clock_skew_seconds, clock_skew_count_24h, clock_last_naive_at,
|
||||
` + canRelayClause + `, ` + canRelaySeenClause + `
|
||||
FROM observers WHERE inactive IS NULL OR inactive = 0 ORDER BY last_seen DESC`)
|
||||
@@ -1245,6 +1252,7 @@ func (db *DB) GetObservers() ([]Observer, error) {
|
||||
var canRelay, canRelaySeen int
|
||||
if err := rows.Scan(&o.ID, &o.Name, &o.IATA, &o.LastSeen, &o.FirstSeen, &o.PacketCount,
|
||||
&o.Model, &o.Firmware, &o.ClientVersion, &o.Radio, &batteryMv, &uptimeSecs, &noiseFloor, &o.LastPacketAt,
|
||||
&o.LastNeighborsReportAt,
|
||||
&clockSkewSec, &clockSkewCount, &o.ClockLastNaiveAt, &canRelay, &canRelaySeen); err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -1346,11 +1354,13 @@ func (db *DB) GetObserverByID(id string) (*Observer, error) {
|
||||
}
|
||||
err := db.conn.QueryRow(`SELECT id, name, iata, last_seen, first_seen, packet_count,
|
||||
model, firmware, client_version, radio, battery_mv, uptime_secs, noise_floor, last_packet_at,
|
||||
last_neighbors_report_at,
|
||||
clock_skew_seconds, clock_skew_count_24h, clock_last_naive_at,
|
||||
`+canRelayClause+`, `+canRelaySeenClause+`
|
||||
FROM observers WHERE id = ?`, id).
|
||||
Scan(&o.ID, &o.Name, &o.IATA, &o.LastSeen, &o.FirstSeen, &o.PacketCount,
|
||||
&o.Model, &o.Firmware, &o.ClientVersion, &o.Radio, &batteryMv, &uptimeSecs, &noiseFloor, &o.LastPacketAt,
|
||||
&o.LastNeighborsReportAt,
|
||||
&clockSkewSec, &clockSkewCount, &o.ClockLastNaiveAt, &canRelay, &canRelaySeen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -54,6 +54,7 @@ func setupTestDB(t *testing.T) *DB {
|
||||
noise_floor REAL,
|
||||
inactive INTEGER DEFAULT 0,
|
||||
last_packet_at TEXT DEFAULT NULL,
|
||||
last_neighbors_report_at TEXT DEFAULT NULL,
|
||||
clock_skew_seconds INTEGER DEFAULT NULL,
|
||||
clock_skew_count_24h INTEGER DEFAULT 0,
|
||||
clock_last_naive_at TEXT DEFAULT NULL
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
// #1865 follow-up (cwichura, PR #1867): /api/observers and
|
||||
// /api/observers/{id} must expose last_neighbors_report_at end-to-end.
|
||||
// This is a handler-level test on purpose: the DB layer (db.GetObservers/
|
||||
// GetObserverByID) can carry the field correctly while the HTTP handler's
|
||||
// separate ObserverResp DTO silently drops it during the field-by-field
|
||||
// conversion in routes.go -- exactly the gap that shipped once already.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHandleObservers_ExposesLastNeighborsReportAt(t *testing.T) {
|
||||
srv, router := setupTestServer(t)
|
||||
|
||||
ts := "2026-07-26T10:00:00Z"
|
||||
if _, err := srv.db.conn.Exec(`UPDATE observers SET last_neighbors_report_at = ? WHERE id = ?`, ts, "obs1"); err != nil {
|
||||
t.Fatalf("seed last_neighbors_report_at: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/observers", 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 {
|
||||
Observers []map[string]interface{} `json:"observers"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
|
||||
var got map[string]interface{}
|
||||
for _, o := range body.Observers {
|
||||
if o["id"] == "obs1" {
|
||||
got = o
|
||||
break
|
||||
}
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("obs1 not found in /api/observers response")
|
||||
}
|
||||
if v, ok := got["last_neighbors_report_at"]; !ok || v != ts {
|
||||
t.Errorf("last_neighbors_report_at = %v (present=%v), want %q", v, ok, ts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleObservers_NilLastNeighborsReportAtWhenNeverReported(t *testing.T) {
|
||||
srv, router := setupTestServer(t)
|
||||
_ = srv
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/observers", 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 {
|
||||
Observers []map[string]interface{} `json:"observers"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
if len(body.Observers) == 0 {
|
||||
t.Fatal("expected at least one seeded observer")
|
||||
}
|
||||
// seedTestData never sets last_neighbors_report_at -- key must be
|
||||
// present (not omitted) and null, so the frontend can distinguish
|
||||
// "field exists, never reported" from a stale/misdeployed backend.
|
||||
got := body.Observers[0]
|
||||
v, ok := got["last_neighbors_report_at"]
|
||||
if !ok {
|
||||
t.Fatal("last_neighbors_report_at key missing from response entirely")
|
||||
}
|
||||
if v != nil {
|
||||
t.Errorf("last_neighbors_report_at = %v, want nil for an observer that never reported", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleObserverDetail_ExposesLastNeighborsReportAt(t *testing.T) {
|
||||
srv, router := setupTestServer(t)
|
||||
|
||||
ts := "2026-07-26T11:00:00Z"
|
||||
if _, err := srv.db.conn.Exec(`UPDATE observers SET last_neighbors_report_at = ? WHERE id = ?`, ts, "obs1"); err != nil {
|
||||
t.Fatalf("seed last_neighbors_report_at: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/observers/obs1", 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 got map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
if v, ok := got["last_neighbors_report_at"]; !ok || v != ts {
|
||||
t.Errorf("last_neighbors_report_at = %v (present=%v), want %q", v, ok, ts)
|
||||
}
|
||||
}
|
||||
+10
-8
@@ -3167,10 +3167,11 @@ func (s *Server) buildObserversDefaultResponse() (ObserverListResponse, error) {
|
||||
Model: o.Model, Firmware: o.Firmware,
|
||||
ClientVersion: o.ClientVersion, Radio: o.Radio,
|
||||
BatteryMv: o.BatteryMv, UptimeSecs: o.UptimeSecs,
|
||||
NoiseFloor: o.NoiseFloor,
|
||||
LastPacketAt: o.LastPacketAt,
|
||||
PacketsLastHour: plh,
|
||||
Lat: lat, Lon: lon, NodeRole: nodeRole,
|
||||
NoiseFloor: o.NoiseFloor,
|
||||
LastPacketAt: o.LastPacketAt,
|
||||
LastNeighborsReportAt: o.LastNeighborsReportAt,
|
||||
PacketsLastHour: plh,
|
||||
Lat: lat, Lon: lon, NodeRole: nodeRole,
|
||||
CanRelay: o.CanRelay,
|
||||
}
|
||||
applyObserverNaiveClock(&resp, o, nowTime)
|
||||
@@ -3213,10 +3214,11 @@ func (s *Server) handleObserverDetail(w http.ResponseWriter, r *http.Request) {
|
||||
Model: obs.Model, Firmware: obs.Firmware,
|
||||
ClientVersion: obs.ClientVersion, Radio: obs.Radio,
|
||||
BatteryMv: obs.BatteryMv, UptimeSecs: obs.UptimeSecs,
|
||||
NoiseFloor: obs.NoiseFloor,
|
||||
LastPacketAt: obs.LastPacketAt,
|
||||
PacketsLastHour: plh,
|
||||
CanRelay: obs.CanRelay,
|
||||
NoiseFloor: obs.NoiseFloor,
|
||||
LastPacketAt: obs.LastPacketAt,
|
||||
LastNeighborsReportAt: obs.LastNeighborsReportAt,
|
||||
PacketsLastHour: plh,
|
||||
CanRelay: obs.CanRelay,
|
||||
}
|
||||
applyObserverNaiveClock(&resp, obs, time.Now().UTC())
|
||||
return resp
|
||||
|
||||
+22
-18
@@ -1290,24 +1290,28 @@ type ChannelMessagesResponse struct {
|
||||
// ─── Observers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type ObserverResp struct {
|
||||
ID string `json:"id"`
|
||||
Name interface{} `json:"name"`
|
||||
IATA interface{} `json:"iata"`
|
||||
LastSeen interface{} `json:"last_seen"`
|
||||
FirstSeen interface{} `json:"first_seen"`
|
||||
PacketCount int `json:"packet_count"`
|
||||
Model interface{} `json:"model"`
|
||||
Firmware interface{} `json:"firmware"`
|
||||
ClientVersion interface{} `json:"client_version"`
|
||||
Radio interface{} `json:"radio"`
|
||||
BatteryMv interface{} `json:"battery_mv"`
|
||||
UptimeSecs interface{} `json:"uptime_secs"`
|
||||
NoiseFloor interface{} `json:"noise_floor"`
|
||||
LastPacketAt interface{} `json:"last_packet_at"`
|
||||
PacketsLastHour int `json:"packetsLastHour"`
|
||||
Lat interface{} `json:"lat"`
|
||||
Lon interface{} `json:"lon"`
|
||||
NodeRole interface{} `json:"nodeRole"`
|
||||
ID string `json:"id"`
|
||||
Name interface{} `json:"name"`
|
||||
IATA interface{} `json:"iata"`
|
||||
LastSeen interface{} `json:"last_seen"`
|
||||
FirstSeen interface{} `json:"first_seen"`
|
||||
PacketCount int `json:"packet_count"`
|
||||
Model interface{} `json:"model"`
|
||||
Firmware interface{} `json:"firmware"`
|
||||
ClientVersion interface{} `json:"client_version"`
|
||||
Radio interface{} `json:"radio"`
|
||||
BatteryMv interface{} `json:"battery_mv"`
|
||||
UptimeSecs interface{} `json:"uptime_secs"`
|
||||
NoiseFloor interface{} `json:"noise_floor"`
|
||||
LastPacketAt interface{} `json:"last_packet_at"`
|
||||
// #1865 follow-up: timestamp of the observer's most recent /neighbors
|
||||
// MQTT report. nil means never reported -- opt-in firmware feature,
|
||||
// unavailable on non-PSRAM hardware, NOT a fault (cwichura on PR #1867).
|
||||
LastNeighborsReportAt interface{} `json:"last_neighbors_report_at"`
|
||||
PacketsLastHour int `json:"packetsLastHour"`
|
||||
Lat interface{} `json:"lat"`
|
||||
Lon interface{} `json:"lon"`
|
||||
NodeRole interface{} `json:"nodeRole"`
|
||||
// Issue #1478: surface naive-clock observers to the UI.
|
||||
// `clock_naive` is derived from clock_last_naive_at being within the
|
||||
// last 24h; once decayed, all three skew fields read as zero/null so the
|
||||
|
||||
@@ -94,6 +94,9 @@ func Apply(rw *sql.DB, logf Logger) error {
|
||||
if err := ensurePingTriggersTable(rw, logf); err != nil {
|
||||
return fmt.Errorf("ensure ping_triggers: %w", err)
|
||||
}
|
||||
if err := ensureLastNeighborsReportAtColumn(rw, logf); err != nil {
|
||||
return fmt.Errorf("ensure observers.last_neighbors_report_at: %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
|
||||
@@ -180,6 +183,10 @@ func AssertReady(ro *sql.DB) error {
|
||||
// unset (unknown state).
|
||||
mustCol("observers", "can_relay_seen")
|
||||
mustTable("ping_triggers")
|
||||
// #1865 follow-up: which observers send /neighbors reports (opt-in
|
||||
// firmware feature). Owned by ingestor — TouchObserverNeighborsReport
|
||||
// is the only writer.
|
||||
mustCol("observers", "last_neighbors_report_at")
|
||||
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("schema not migrated by ingestor; restart ingestor first. missing: %s",
|
||||
@@ -336,6 +343,27 @@ func ensureLastPacketAtColumn(rw *sql.DB, logf Logger) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureLastNeighborsReportAtColumn adds the timestamp of an observer's most
|
||||
// recent /neighbors MQTT report (#1865 follow-up: cwichura asked for a way
|
||||
// to identify which observers have the feature enabled, since it's opt-in
|
||||
// and unavailable on non-PSRAM devices). Written by the ingestor's
|
||||
// TouchObserverNeighborsReport whenever handleNeighborsReport fires,
|
||||
// regardless of whether any individual neighbor carried scope evidence.
|
||||
func ensureLastNeighborsReportAtColumn(rw *sql.DB, logf Logger) error {
|
||||
has, err := TableHasColumn(rw, "observers", "last_neighbors_report_at")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if has {
|
||||
return nil
|
||||
}
|
||||
if _, err := rw.Exec("ALTER TABLE observers ADD COLUMN last_neighbors_report_at TEXT"); err != nil {
|
||||
return err
|
||||
}
|
||||
logf("[dbschema] added last_neighbors_report_at column to observers")
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureObserverIATAColumn(rw *sql.DB, logf Logger) error {
|
||||
has, err := TableHasColumn(rw, "observers", "iata")
|
||||
if err != nil {
|
||||
|
||||
@@ -271,6 +271,10 @@ window.ObserverDetailNaiveBanner = {
|
||||
<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>
|
||||
</div>
|
||||
<div class="stat-card" title="/neighbors reports are opt-in firmware and unavailable on non-PSRAM hardware -- 'never' is expected for many observers, not a fault">
|
||||
<div class="stat-label">Last /neighbors Report</div>
|
||||
<div class="stat-value" style="font-size:0.85em">${obs.last_neighbors_report_at ? timeAgo(obs.last_neighbors_report_at) + '<br><span style="font-size:0.8em;color:var(--text-muted)">' + new Date(obs.last_neighbors_report_at).toLocaleString() + '</span>' : '<span style="color:var(--text-muted)">never</span>'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mono" style="font-size:0.75em;color:var(--text-muted);margin-bottom:20px;word-break:break-all">
|
||||
ID: ${escapeHtml(obs.id)}
|
||||
|
||||
+16
-1
@@ -320,6 +320,19 @@ window.preserveCompareSelection = function preserveCompareSelection(prevIds, tbo
|
||||
return timeAgo(o.last_packet_at);
|
||||
}
|
||||
|
||||
// #1865 follow-up (cwichura, PR #1867): /neighbors reports are opt-in
|
||||
// firmware and unavailable on non-PSRAM hardware, so absence is common
|
||||
// and NOT a fault — never render a warning icon or red styling here,
|
||||
// just a neutral dash with an explanatory title.
|
||||
function neighborsReportBadge(o) {
|
||||
const icon = '<svg class="ph-icon" aria-hidden="true" focusable="false"><use href="/icons/phosphor-sprite.svg#ph-share-network"></use></svg>';
|
||||
if (!o.last_neighbors_report_at) {
|
||||
return '<span class="text-muted" title="This observer has never sent a /neighbors report — the feature is opt-in firmware and unavailable on non-PSRAM hardware, so this is expected for many observers.">—</span>';
|
||||
}
|
||||
return `<span title="Last /neighbors report ${timeAgo(o.last_neighbors_report_at)}">${icon} ${timeAgo(o.last_neighbors_report_at)}</span>`;
|
||||
}
|
||||
window.neighborsReportBadge = neighborsReportBadge;
|
||||
|
||||
// #1789 — Firmware strings can be long, e.g.
|
||||
// "v1.16.0-07a3ca9 Build: 2025-..."; truncate the "Build:" suffix for the
|
||||
// display string, keeping the full value in the cell's title= attr.
|
||||
@@ -384,7 +397,7 @@ window.preserveCompareSelection = function preserveCompareSelection(prevIds, tbo
|
||||
<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="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="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="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="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="4" data-sort-key="neighbors_report" data-type="numeric" title="Whether this observer sends /neighbors reports (opt-in firmware feature)">Neighbors</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>
|
||||
<tbody>${filtered.map(o => {
|
||||
@@ -410,6 +423,7 @@ window.preserveCompareSelection = function preserveCompareSelection(prevIds, tbo
|
||||
const _healthRank = h.cls === 'health-green' ? 2 : (h.cls === 'health-yellow' ? 1 : 0);
|
||||
const _packetCount = (o.packet_count != null) ? o.packet_count : '';
|
||||
const _packetsHour = (o.packetsLastHour != null) ? o.packetsLastHour : '';
|
||||
const _neighborsReportMs = o.last_neighbors_report_at ? new Date(o.last_neighbors_report_at).getTime() : '';
|
||||
return `<tr style="cursor:pointer" tabindex="0" role="row" data-action="navigate" data-value="#/observers/${encodeURIComponent(o.id)}" data-observer-id="${escapeHtml(o.id)}" onclick="location.hash='#/observers/${encodeURIComponent(o.id)}'">
|
||||
<td data-value="${_healthRank}"><span class="health-dot ${h.cls}" title="${h.label}">${shapeIcon}</span> ${h.label}</td>
|
||||
<td data-testid="obs-cell-name" data-value="${escapeHtml(String(o.name || o.id))}" class="mono">${escapeHtml(o.name || o.id)}${window.ObserversNaiveChip.render(o)}${o.can_relay === false ? ' <span class="badge-listener" title="Firmware reported repeat:off — listener-only; excluded from path-hop disambiguator (issue #1290)">listener</span>' : (o.can_relay === true ? ' <span class="badge-repeater" title="Firmware reported repeat:on — eligible as a path hop">repeater</span>' : '')}</td>
|
||||
@@ -428,6 +442,7 @@ window.preserveCompareSelection = function preserveCompareSelection(prevIds, tbo
|
||||
<td data-value="${_uptimeMs}">${uptimeStr(o.first_seen)}</td>
|
||||
<td data-testid="obs-cell-firmware" data-value="${escapeHtml(String(o.firmware || ''))}" class="mono"${o.firmware ? ` title="${escapeHtml(String(o.firmware))}"` : ''}>${o.firmware ? escapeHtml(truncateBuildSuffix(String(o.firmware))) : '<span class="text-muted">—</span>'}</td>
|
||||
<td data-testid="obs-cell-client-version" data-value="${escapeHtml(String(o.client_version || ''))}" class="mono"${o.client_version ? ` title="${escapeHtml(String(o.client_version))}"` : ''}>${o.client_version ? escapeHtml(String(o.client_version)) : '<span class="text-muted">—</span>'}</td>
|
||||
<td data-testid="obs-cell-neighbors-report" data-value="${_neighborsReportMs}">${neighborsReportBadge(o)}</td>
|
||||
<td class="col-compare-select" onclick="event.stopPropagation()" style="text-align:center">
|
||||
<input type="checkbox" data-compare-select value="${escapeHtml(o.id)}"
|
||||
aria-label="Select ${escapeHtml(o.name || o.id)} for comparison"
|
||||
|
||||
@@ -75,6 +75,7 @@ node test-analytics-hop-depth-ui.js
|
||||
node test-channels-ping-bot-reply.js
|
||||
node test-packet-path-map.js
|
||||
node test-ping-scores.js
|
||||
node test-observer-neighbors-report-badge.js
|
||||
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════"
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/* #1865 follow-up (cwichura, PR #1867): identify which observers send
|
||||
* /neighbors reports (opt-in firmware, unavailable on non-PSRAM hardware).
|
||||
* Production code must expose:
|
||||
* window.neighborsReportBadge(o) -> string
|
||||
* Must render a plain, neutral "never" state (no warning icon, no red
|
||||
* styling) when the observer has never reported — cwichura explicitly asked
|
||||
* that the system not "shame" operators for a feature that's opt-in and
|
||||
* hardware-gated.
|
||||
*/
|
||||
'use strict';
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const assert = require('assert');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function test(name, fn) {
|
||||
try { fn(); passed++; console.log(` ✅ ${name}`); }
|
||||
catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); }
|
||||
}
|
||||
|
||||
function makeCtx() {
|
||||
const ctx = {
|
||||
window: {},
|
||||
document: { addEventListener: () => {}, querySelector: () => null },
|
||||
console, Date, Math, Array, Object, String, Number, Boolean, JSON,
|
||||
setInterval: () => 0, clearInterval: () => {},
|
||||
encodeURIComponent, decodeURIComponent,
|
||||
};
|
||||
ctx.registerPage = () => {};
|
||||
ctx.RegionFilter = { init: () => {}, onChange: () => () => {}, getSelected: () => null };
|
||||
ctx.timeAgo = (iso) => 'TIME_AGO(' + iso + ')';
|
||||
ctx.makeColumnsResizable = () => {};
|
||||
ctx.debouncedOnWS = (fn) => fn;
|
||||
ctx.offWS = () => {};
|
||||
ctx.api = () => Promise.resolve({ observers: [] });
|
||||
ctx.CLIENT_TTL = {};
|
||||
ctx.location = { hash: '' };
|
||||
vm.createContext(ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
console.log('\n=== #1865 follow-up — Observers "Neighbors" report badge ===');
|
||||
|
||||
const ctx = makeCtx();
|
||||
vm.runInContext(fs.readFileSync('public/observers.js', 'utf8'), ctx);
|
||||
|
||||
test('window.neighborsReportBadge exists', () => {
|
||||
assert.strictEqual(typeof ctx.window.neighborsReportBadge, 'function');
|
||||
});
|
||||
|
||||
test('never-reported observer renders a neutral dash, not a warning icon', () => {
|
||||
const html = ctx.window.neighborsReportBadge({ id: 'x', last_neighbors_report_at: null });
|
||||
assert.ok(/text-muted/.test(html), 'expected text-muted styling for the never state');
|
||||
assert.ok(!/ph-warning/.test(html), 'must NOT render a warning icon for never-reported observers');
|
||||
assert.ok(/opt-in/.test(html), 'title should explain the feature is opt-in');
|
||||
});
|
||||
|
||||
test('reported observer renders the share-network icon and timeAgo', () => {
|
||||
const html = ctx.window.neighborsReportBadge({ id: 'x', last_neighbors_report_at: '2026-07-26T10:00:00Z' });
|
||||
assert.ok(/ph-share-network/.test(html), 'expected share-network icon');
|
||||
assert.ok(/TIME_AGO\(2026-07-26T10:00:00Z\)/.test(html), 'expected timeAgo() to be called with the timestamp');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
Reference in new issue
Block a user