Merge branch 'areas-meshguide-sync'

This commit is contained in:
dborup committed 2026-07-26 18:19:40 +02:00
commit d34089e979
13 files changed
+383 -27

No files matched your search

+23
View File
@@ -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
+8
View File
@@ -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)
}
}
+10
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+28
View File
@@ -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 {
+4
View File
@@ -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
View File
@@ -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"
+1
View File
@@ -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 "═══════════════════════════════════════"
+65
View File
@@ -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);