diff --git a/cmd/ingestor/db.go b/cmd/ingestor/db.go index 13ec47c4..47a65afa 100644 --- a/cmd/ingestor/db.go +++ b/cmd/ingestor/db.go @@ -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 diff --git a/cmd/ingestor/main.go b/cmd/ingestor/main.go index 17d58069..5fe52095 100644 --- a/cmd/ingestor/main.go +++ b/cmd/ingestor/main.go @@ -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 == "" { diff --git a/cmd/ingestor/observer_neighbors_report_test.go b/cmd/ingestor/observer_neighbors_report_test.go new file mode 100644 index 00000000..d21cd590 --- /dev/null +++ b/cmd/ingestor/observer_neighbors_report_test.go @@ -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) + } +} diff --git a/cmd/server/db.go b/cmd/server/db.go index 7844fce6..96a5ed2c 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -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 diff --git a/cmd/server/db_test.go b/cmd/server/db_test.go index 22095c25..a434f091 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -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 diff --git a/cmd/server/observer_neighbors_report_endpoint_test.go b/cmd/server/observer_neighbors_report_endpoint_test.go new file mode 100644 index 00000000..c576af13 --- /dev/null +++ b/cmd/server/observer_neighbors_report_endpoint_test.go @@ -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) + } +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 6d9213a6..011416e3 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -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 diff --git a/cmd/server/types.go b/cmd/server/types.go index e8e2287d..0e7c59f9 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -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 diff --git a/internal/dbschema/dbschema.go b/internal/dbschema/dbschema.go index 0083b63e..2612ba8f 100644 --- a/internal/dbschema/dbschema.go +++ b/internal/dbschema/dbschema.go @@ -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 { diff --git a/public/observer-detail.js b/public/observer-detail.js index 87092790..c83017d2 100644 --- a/public/observer-detail.js +++ b/public/observer-detail.js @@ -271,6 +271,10 @@ window.ObserverDetailNaiveBanner = {
Last Packet Observation
${obs.last_packet_at ? timeAgo(obs.last_packet_at) + '
' + new Date(obs.last_packet_at).toLocaleString() + '' : 'never'}
+
+
Last /neighbors Report
+
${obs.last_neighbors_report_at ? timeAgo(obs.last_neighbors_report_at) + '
' + new Date(obs.last_neighbors_report_at).toLocaleString() + '' : 'never'}
+
ID: ${escapeHtml(obs.id)} diff --git a/public/observers.js b/public/observers.js index d0dfdda4..ceeb270e 100644 --- a/public/observers.js +++ b/public/observers.js @@ -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 = ''; + if (!o.last_neighbors_report_at) { + return '—'; + } + return `${icon} ${timeAgo(o.last_neighbors_report_at)}`; + } + 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 Observer status and statistics StatusNameRegionLast StatusLast Packet - Packet HealthTotal PacketsPackets/HourClock OffsetUptimeFirmwareClient + Packet HealthTotal PacketsPackets/HourClock OffsetUptimeFirmwareClientNeighbors Select for compare ${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 ` ${shapeIcon} ${h.label} ${escapeHtml(o.name || o.id)}${window.ObserversNaiveChip.render(o)}${o.can_relay === false ? ' listener' : (o.can_relay === true ? ' repeater' : '')} @@ -428,6 +442,7 @@ window.preserveCompareSelection = function preserveCompareSelection(prevIds, tbo ${uptimeStr(o.first_seen)} ${o.firmware ? escapeHtml(truncateBuildSuffix(String(o.firmware))) : '—'} ${o.client_version ? escapeHtml(String(o.client_version)) : '—'} + ${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);