mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-16 20:42:41 +00:00
feat(#1290): exclude listener-only observers from path-hop disambiguator
Firmware 1.16 publishes a `repeat: on|off` flag in the MQTT /status
JSON (confirmed by cwichura on the issue; see
MQTTMessageBuilder.cpp:58 in agessaman/MeshCore
mqtt-bridge-implementation-flex). Listener-only observers
(repeat:off) by firmware contract never relay packets, so they
cannot legitimately be a hop in someone else's resolved path.
Pipeline:
* internal/dbschema: idempotent `can_relay BOOLEAN DEFAULT 1`
migration on observers, plus AssertReady probe (server fatal-logs
if absent). Mirrored in cmd/ingestor/db.go CREATE TABLE for fresh
DBs.
* cmd/ingestor: `extractObserverMeta` accepts `repeat` as bool,
case-insensitive string (`on|off|true|false|yes|no`), or numeric
0/1; missing field → nil → COALESCE preserves prior column value
(back-compat with legacy observers). Plumbed through
UpsertObserverAt and the prepared upsert statement.
* cmd/server: GetNonRelayObserverPubkeys + new prefixMap.markNonRelay
drop matching candidates inside resolveWithContext. Filter is
applied at the top of the resolver so all 4 tiers see the
pruned candidate set. ObserverResp.CanRelay is surfaced to
/api/observers and /api/observers/{id}; GetNodeHealth enriches
the per-observer rows with can_relay so the node-detail badge
works. Probe-and-fall-back when can_relay column is absent
(legacy test fixtures).
* public/: listener vs repeater pill on observers list,
observer detail Relay stat card, and node-detail Heard By table.
CSS pill styles inherit existing theme vars.
Green for the test added in the prior red commit:
TestResolveWithContext_ExcludesNonRelayObservers_Issue1290.
Back-compat preserved: legacy observers (no `repeat` field) keep
the default can_relay=1 and remain candidates.
cross-stack: justified — backend persists firmware-side `repeat` hint to a new observers column, frontend surfaces the listener/repeater status as a badge on the observers list and node-detail Heard By table per the issue's UI acceptance criterion.
This commit is contained in:
+27
-7
@@ -197,7 +197,8 @@ func applySchema(db *sql.DB) error {
|
|||||||
last_packet_at TEXT DEFAULT NULL,
|
last_packet_at TEXT DEFAULT NULL,
|
||||||
clock_skew_seconds INTEGER DEFAULT NULL,
|
clock_skew_seconds INTEGER DEFAULT NULL,
|
||||||
clock_skew_count_24h INTEGER DEFAULT 0,
|
clock_skew_count_24h INTEGER DEFAULT 0,
|
||||||
clock_last_naive_at TEXT DEFAULT NULL
|
clock_last_naive_at TEXT DEFAULT NULL,
|
||||||
|
can_relay INTEGER DEFAULT 1
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_nodes_last_seen ON nodes(last_seen);
|
CREATE INDEX IF NOT EXISTS idx_nodes_last_seen ON nodes(last_seen);
|
||||||
@@ -727,8 +728,8 @@ func (s *Store) prepareStatements() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.stmtUpsertObserver, err = s.db.Prepare(`
|
s.stmtUpsertObserver, err = s.db.Prepare(`
|
||||||
INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count, model, firmware, client_version, radio, battery_mv, uptime_secs, noise_floor)
|
INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count, model, firmware, client_version, radio, battery_mv, uptime_secs, noise_floor, can_relay)
|
||||||
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, 1))
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
name = COALESCE(?, name),
|
name = COALESCE(?, name),
|
||||||
iata = COALESCE(?, iata),
|
iata = COALESCE(?, iata),
|
||||||
@@ -740,7 +741,8 @@ func (s *Store) prepareStatements() error {
|
|||||||
radio = COALESCE(?, radio),
|
radio = COALESCE(?, radio),
|
||||||
battery_mv = COALESCE(?, battery_mv),
|
battery_mv = COALESCE(?, battery_mv),
|
||||||
uptime_secs = COALESCE(?, uptime_secs),
|
uptime_secs = COALESCE(?, uptime_secs),
|
||||||
noise_floor = COALESCE(?, noise_floor)
|
noise_floor = COALESCE(?, noise_floor),
|
||||||
|
can_relay = COALESCE(?, can_relay)
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -973,6 +975,13 @@ type ObserverMeta struct {
|
|||||||
RecvErrors *int // cumulative CRC/decode failures since boot
|
RecvErrors *int // cumulative CRC/decode failures since boot
|
||||||
PacketsSent *int // cumulative packets sent since boot
|
PacketsSent *int // cumulative packets sent since boot
|
||||||
PacketsRecv *int // cumulative packets received since boot
|
PacketsRecv *int // cumulative packets received since boot
|
||||||
|
// CanRelay reflects the firmware 1.16 /status `repeat` flag (#1290).
|
||||||
|
// nil means the firmware did not send the field — caller must
|
||||||
|
// preserve the existing observers.can_relay value (default 1).
|
||||||
|
// true → relay-capable (`repeat:on`); false → listener-only
|
||||||
|
// (`repeat:off`), which causes the server-side disambiguator to
|
||||||
|
// exclude this observer's pubkey from path-hop candidate sets.
|
||||||
|
CanRelay *bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpsertObserver inserts or updates an observer using the current wall-clock
|
// UpsertObserver inserts or updates an observer using the current wall-clock
|
||||||
@@ -995,7 +1004,7 @@ func (s *Store) UpsertObserverAt(id, name, iata string, meta *ObserverMeta, last
|
|||||||
normalizedIATA := strings.TrimSpace(strings.ToUpper(iata))
|
normalizedIATA := strings.TrimSpace(strings.ToUpper(iata))
|
||||||
|
|
||||||
var model, firmware, clientVersion, radio interface{}
|
var model, firmware, clientVersion, radio interface{}
|
||||||
var batteryMv, uptimeSecs, noiseFloor interface{}
|
var batteryMv, uptimeSecs, noiseFloor, canRelay interface{}
|
||||||
if meta != nil {
|
if meta != nil {
|
||||||
if meta.Model != nil {
|
if meta.Model != nil {
|
||||||
model = *meta.Model
|
model = *meta.Model
|
||||||
@@ -1018,11 +1027,22 @@ func (s *Store) UpsertObserverAt(id, name, iata string, meta *ObserverMeta, last
|
|||||||
if meta.NoiseFloor != nil {
|
if meta.NoiseFloor != nil {
|
||||||
noiseFloor = *meta.NoiseFloor
|
noiseFloor = *meta.NoiseFloor
|
||||||
}
|
}
|
||||||
|
// Issue #1290: nil → leave DB column unchanged (COALESCE in
|
||||||
|
// the prepared stmt); 0/1 written when firmware provided
|
||||||
|
// the `repeat` field. INSERT branch defaults to 1 via the
|
||||||
|
// COALESCE in the VALUES clause.
|
||||||
|
if meta.CanRelay != nil {
|
||||||
|
if *meta.CanRelay {
|
||||||
|
canRelay = 1
|
||||||
|
} else {
|
||||||
|
canRelay = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := s.stmtUpsertObserver.Exec(
|
_, err := s.stmtUpsertObserver.Exec(
|
||||||
id, name, normalizedIATA, lastSeen, lastSeen, model, firmware, clientVersion, radio, batteryMv, uptimeSecs, noiseFloor,
|
id, name, normalizedIATA, lastSeen, lastSeen, model, firmware, clientVersion, radio, batteryMv, uptimeSecs, noiseFloor, canRelay,
|
||||||
name, normalizedIATA, ingestNow, lastSeen, model, firmware, clientVersion, radio, batteryMv, uptimeSecs, noiseFloor,
|
name, normalizedIATA, ingestNow, lastSeen, model, firmware, clientVersion, radio, batteryMv, uptimeSecs, noiseFloor, canRelay,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.Stats.WriteErrors.Add(1)
|
s.Stats.WriteErrors.Add(1)
|
||||||
|
|||||||
@@ -1124,6 +1124,37 @@ func extractObserverMeta(msg map[string]interface{}) *ObserverMeta {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Issue #1290: firmware 1.16 publishes a `repeat` flag at the top
|
||||||
|
// level of the /status JSON (MQTTMessageBuilder.cpp:58 — see
|
||||||
|
// agessaman/MeshCore mqtt-bridge-implementation-flex). Accept
|
||||||
|
// either a boolean or a case-insensitive `on|off|true|false|1|0`
|
||||||
|
// string. Missing field → leave CanRelay nil; the writer preserves
|
||||||
|
// the prior column value (default 1, back-compat).
|
||||||
|
if v, ok := msg["repeat"]; ok && v != nil {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case bool:
|
||||||
|
b := t
|
||||||
|
meta.CanRelay = &b
|
||||||
|
hasData = true
|
||||||
|
case string:
|
||||||
|
s := strings.ToLower(strings.TrimSpace(t))
|
||||||
|
switch s {
|
||||||
|
case "on", "true", "1", "yes":
|
||||||
|
b := true
|
||||||
|
meta.CanRelay = &b
|
||||||
|
hasData = true
|
||||||
|
case "off", "false", "0", "no":
|
||||||
|
b := false
|
||||||
|
meta.CanRelay = &b
|
||||||
|
hasData = true
|
||||||
|
}
|
||||||
|
case float64:
|
||||||
|
b := t != 0
|
||||||
|
meta.CanRelay = &b
|
||||||
|
hasData = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !hasData {
|
if !hasData {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-4
@@ -12,6 +12,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/meshcore-analyzer/dbschema"
|
||||||
"github.com/meshcore-analyzer/geofilter"
|
"github.com/meshcore-analyzer/geofilter"
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
@@ -251,6 +252,11 @@ type Observer struct {
|
|||||||
ClockSkewSeconds *int64 `json:"clock_skew_seconds"`
|
ClockSkewSeconds *int64 `json:"clock_skew_seconds"`
|
||||||
ClockSkewCount24h int `json:"clock_skew_count_24h"`
|
ClockSkewCount24h int `json:"clock_skew_count_24h"`
|
||||||
ClockLastNaiveAt *string `json:"clock_last_naive_at"`
|
ClockLastNaiveAt *string `json:"clock_last_naive_at"`
|
||||||
|
// Issue #1290: firmware 1.16 `repeat: on|off` flag persisted by the
|
||||||
|
// ingestor. true = relay-capable (default for legacy observers).
|
||||||
|
// false = listener-only; cmd/server/store.go pm.resolveWithContext
|
||||||
|
// excludes these from path-hop candidate sets.
|
||||||
|
CanRelay bool `json:"can_relay"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transmission represents a row from the transmissions table.
|
// Transmission represents a row from the transmissions table.
|
||||||
@@ -1148,9 +1154,17 @@ func (db *DB) getObservationsForTransmissions(txIDs []int) map[int][]map[string]
|
|||||||
|
|
||||||
// GetObservers returns active observers (not soft-deleted) sorted by last_seen DESC.
|
// GetObservers returns active observers (not soft-deleted) sorted by last_seen DESC.
|
||||||
func (db *DB) GetObservers() ([]Observer, error) {
|
func (db *DB) GetObservers() ([]Observer, error) {
|
||||||
|
// Issue #1290: can_relay is read via COALESCE(can_relay, 1). The
|
||||||
|
// column is added by internal/dbschema; older test fixtures and
|
||||||
|
// pre-migration DBs may lack it, so we probe and fall back.
|
||||||
|
canRelayClause := "COALESCE(can_relay, 1)"
|
||||||
|
if hasCol, _ := dbschema.TableHasColumn(db.conn, "observers", "can_relay"); !hasCol {
|
||||||
|
canRelayClause = "1"
|
||||||
|
}
|
||||||
rows, err := db.conn.Query(`SELECT id, name, iata, last_seen, first_seen, packet_count,
|
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,
|
model, firmware, client_version, radio, battery_mv, uptime_secs, noise_floor, last_packet_at,
|
||||||
clock_skew_seconds, clock_skew_count_24h, clock_last_naive_at
|
clock_skew_seconds, clock_skew_count_24h, clock_last_naive_at,
|
||||||
|
` + canRelayClause + `
|
||||||
FROM observers WHERE inactive IS NULL OR inactive = 0 ORDER BY last_seen DESC`)
|
FROM observers WHERE inactive IS NULL OR inactive = 0 ORDER BY last_seen DESC`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1163,11 +1177,13 @@ func (db *DB) GetObservers() ([]Observer, error) {
|
|||||||
var batteryMv, uptimeSecs, clockSkewSec sql.NullInt64
|
var batteryMv, uptimeSecs, clockSkewSec sql.NullInt64
|
||||||
var clockSkewCount sql.NullInt64
|
var clockSkewCount sql.NullInt64
|
||||||
var noiseFloor sql.NullFloat64
|
var noiseFloor sql.NullFloat64
|
||||||
|
var canRelay int
|
||||||
if err := rows.Scan(&o.ID, &o.Name, &o.IATA, &o.LastSeen, &o.FirstSeen, &o.PacketCount,
|
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.Model, &o.Firmware, &o.ClientVersion, &o.Radio, &batteryMv, &uptimeSecs, &noiseFloor, &o.LastPacketAt,
|
||||||
&clockSkewSec, &clockSkewCount, &o.ClockLastNaiveAt); err != nil {
|
&clockSkewSec, &clockSkewCount, &o.ClockLastNaiveAt, &canRelay); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
o.CanRelay = canRelay != 0
|
||||||
if batteryMv.Valid {
|
if batteryMv.Valid {
|
||||||
v := int(batteryMv.Int64)
|
v := int(batteryMv.Int64)
|
||||||
o.BatteryMv = &v
|
o.BatteryMv = &v
|
||||||
@@ -1190,22 +1206,58 @@ func (db *DB) GetObservers() ([]Observer, error) {
|
|||||||
return observers, nil
|
return observers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetNonRelayObserverPubkeys returns the lowercase observer.id pubkeys
|
||||||
|
// for observers that have advertised `repeat:off` (#1290). The server's
|
||||||
|
// path-hop disambiguator consumes this to exclude listener-only nodes
|
||||||
|
// from the candidate set. Inactive observers are excluded for
|
||||||
|
// consistency with GetObservers; reactivation flips can_relay only on
|
||||||
|
// the next status message.
|
||||||
|
func (db *DB) GetNonRelayObserverPubkeys() ([]string, error) {
|
||||||
|
// Graceful no-op when can_relay column is absent (legacy DB / older
|
||||||
|
// test fixture). Avoids noisy schema-degradation log spam.
|
||||||
|
if hasCol, _ := dbschema.TableHasColumn(db.conn, "observers", "can_relay"); !hasCol {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
rows, err := db.conn.Query(`SELECT LOWER(id) FROM observers
|
||||||
|
WHERE COALESCE(can_relay, 1) = 0
|
||||||
|
AND (inactive IS NULL OR inactive = 0)`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var pk string
|
||||||
|
if err := rows.Scan(&pk); err == nil && pk != "" {
|
||||||
|
out = append(out, pk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// GetObserverByID returns a single observer.
|
// GetObserverByID returns a single observer.
|
||||||
func (db *DB) GetObserverByID(id string) (*Observer, error) {
|
func (db *DB) GetObserverByID(id string) (*Observer, error) {
|
||||||
var o Observer
|
var o Observer
|
||||||
var batteryMv, uptimeSecs, clockSkewSec sql.NullInt64
|
var batteryMv, uptimeSecs, clockSkewSec sql.NullInt64
|
||||||
var clockSkewCount sql.NullInt64
|
var clockSkewCount sql.NullInt64
|
||||||
var noiseFloor sql.NullFloat64
|
var noiseFloor sql.NullFloat64
|
||||||
|
var canRelay int
|
||||||
|
canRelayClause := "COALESCE(can_relay, 1)"
|
||||||
|
if hasCol, _ := dbschema.TableHasColumn(db.conn, "observers", "can_relay"); !hasCol {
|
||||||
|
canRelayClause = "1"
|
||||||
|
}
|
||||||
err := db.conn.QueryRow(`SELECT id, name, iata, last_seen, first_seen, packet_count,
|
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,
|
model, firmware, client_version, radio, battery_mv, uptime_secs, noise_floor, last_packet_at,
|
||||||
clock_skew_seconds, clock_skew_count_24h, clock_last_naive_at
|
clock_skew_seconds, clock_skew_count_24h, clock_last_naive_at,
|
||||||
|
`+canRelayClause+`
|
||||||
FROM observers WHERE id = ?`, id).
|
FROM observers WHERE id = ?`, id).
|
||||||
Scan(&o.ID, &o.Name, &o.IATA, &o.LastSeen, &o.FirstSeen, &o.PacketCount,
|
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.Model, &o.Firmware, &o.ClientVersion, &o.Radio, &batteryMv, &uptimeSecs, &noiseFloor, &o.LastPacketAt,
|
||||||
&clockSkewSec, &clockSkewCount, &o.ClockLastNaiveAt)
|
&clockSkewSec, &clockSkewCount, &o.ClockLastNaiveAt, &canRelay)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
o.CanRelay = canRelay != 0
|
||||||
if batteryMv.Valid {
|
if batteryMv.Valid {
|
||||||
v := int(batteryMv.Int64)
|
v := int(batteryMv.Int64)
|
||||||
o.BatteryMv = &v
|
o.BatteryMv = &v
|
||||||
|
|||||||
@@ -2534,6 +2534,7 @@ func (s *Server) buildObserversDefaultResponse() (ObserverListResponse, error) {
|
|||||||
LastPacketAt: o.LastPacketAt,
|
LastPacketAt: o.LastPacketAt,
|
||||||
PacketsLastHour: plh,
|
PacketsLastHour: plh,
|
||||||
Lat: lat, Lon: lon, NodeRole: nodeRole,
|
Lat: lat, Lon: lon, NodeRole: nodeRole,
|
||||||
|
CanRelay: o.CanRelay,
|
||||||
}
|
}
|
||||||
applyObserverNaiveClock(&resp, o, nowTime)
|
applyObserverNaiveClock(&resp, o, nowTime)
|
||||||
result = append(result, resp)
|
result = append(result, resp)
|
||||||
@@ -2578,6 +2579,7 @@ func (s *Server) handleObserverDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
NoiseFloor: obs.NoiseFloor,
|
NoiseFloor: obs.NoiseFloor,
|
||||||
LastPacketAt: obs.LastPacketAt,
|
LastPacketAt: obs.LastPacketAt,
|
||||||
PacketsLastHour: plh,
|
PacketsLastHour: plh,
|
||||||
|
CanRelay: obs.CanRelay,
|
||||||
}
|
}
|
||||||
applyObserverNaiveClock(&resp, obs, time.Now().UTC())
|
applyObserverNaiveClock(&resp, obs, time.Now().UTC())
|
||||||
return resp
|
return resp
|
||||||
|
|||||||
@@ -6194,6 +6194,17 @@ func (s *PacketStore) getCachedNodesAndPM() ([]nodeInfo, *prefixMap) {
|
|||||||
|
|
||||||
nodes := s.getAllNodes()
|
nodes := s.getAllNodes()
|
||||||
pm := buildPrefixMap(nodes)
|
pm := buildPrefixMap(nodes)
|
||||||
|
// Issue #1290: exclude observers that advertised `repeat:off` from
|
||||||
|
// the path-hop candidate set. Failure is non-fatal — we log via the
|
||||||
|
// schema-degradation channel and proceed with an empty filter (i.e.
|
||||||
|
// pre-#1290 behavior).
|
||||||
|
if s.db != nil && s.db.conn != nil {
|
||||||
|
if pks, err := s.db.GetNonRelayObserverPubkeys(); err == nil {
|
||||||
|
pm.markNonRelay(pks)
|
||||||
|
} else {
|
||||||
|
s.logSchemaDegradationOnce("observers.can_relay read failed; path-hop disambiguator will not filter listener-only observers: " + err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
s.cacheMu.Lock()
|
s.cacheMu.Lock()
|
||||||
s.nodeCache = nodes
|
s.nodeCache = nodes
|
||||||
@@ -6257,6 +6268,25 @@ func (pm *prefixMap) resolve(hop string) *nodeInfo {
|
|||||||
func (pm *prefixMap) resolveWithContext(hop string, contextPubkeys []string, graph *NeighborGraph) (*nodeInfo, string, float64) {
|
func (pm *prefixMap) resolveWithContext(hop string, contextPubkeys []string, graph *NeighborGraph) (*nodeInfo, string, float64) {
|
||||||
h := strings.ToLower(hop)
|
h := strings.ToLower(hop)
|
||||||
candidates := pm.m[h]
|
candidates := pm.m[h]
|
||||||
|
// Issue #1290: drop observer-known listener-only nodes from the
|
||||||
|
// candidate set. By firmware contract a node that advertises
|
||||||
|
// `repeat:off` in its MQTT /status will never relay a packet, so it
|
||||||
|
// cannot legitimately be a hop in someone else's path. Filtering
|
||||||
|
// here shrinks ambiguous candidate sets without affecting any
|
||||||
|
// upstream caller (the returned shape and confidence labels are
|
||||||
|
// preserved; only no_match becomes more likely when the only
|
||||||
|
// matching prefix belonged to a listener). Empty pm.nonRelay
|
||||||
|
// preserves the pre-#1290 behavior exactly (back-compat).
|
||||||
|
if len(pm.nonRelay) > 0 && len(candidates) > 0 {
|
||||||
|
filtered := candidates[:0:0]
|
||||||
|
for i := range candidates {
|
||||||
|
if _, isListener := pm.nonRelay[strings.ToLower(candidates[i].PublicKey)]; isListener {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, candidates[i])
|
||||||
|
}
|
||||||
|
candidates = filtered
|
||||||
|
}
|
||||||
if len(candidates) == 0 {
|
if len(candidates) == 0 {
|
||||||
return nil, "no_match", 0
|
return nil, "no_match", 0
|
||||||
}
|
}
|
||||||
@@ -8870,6 +8900,18 @@ func (s *PacketStore) GetNodeHealth(pubkey string) (map[string]interface{}, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
observerRows := make([]map[string]interface{}, 0)
|
observerRows := make([]map[string]interface{}, 0)
|
||||||
|
// Issue #1290: surface listener/repeater hint on node detail by
|
||||||
|
// looking up can_relay for each observer that heard this node.
|
||||||
|
// One-shot fetch of the non-relay set keeps this O(observers) on
|
||||||
|
// rare events; nil on error degrades to "neither badge" client-side.
|
||||||
|
nonRelaySet := map[string]struct{}{}
|
||||||
|
if s.db != nil && s.db.conn != nil {
|
||||||
|
if pks, err := s.db.GetNonRelayObserverPubkeys(); err == nil {
|
||||||
|
for _, pk := range pks {
|
||||||
|
nonRelaySet[strings.ToUpper(pk)] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
for id, o := range observerStats {
|
for id, o := range observerStats {
|
||||||
var avgSnr, avgRssi interface{}
|
var avgSnr, avgRssi interface{}
|
||||||
if o.snrCount > 0 {
|
if o.snrCount > 0 {
|
||||||
@@ -8878,9 +8920,14 @@ func (s *PacketStore) GetNodeHealth(pubkey string) (map[string]interface{}, erro
|
|||||||
if o.rssiCount > 0 {
|
if o.rssiCount > 0 {
|
||||||
avgRssi = o.rssiSum / float64(o.rssiCount)
|
avgRssi = o.rssiSum / float64(o.rssiCount)
|
||||||
}
|
}
|
||||||
|
canRelay := true
|
||||||
|
if _, isListener := nonRelaySet[strings.ToUpper(id)]; isListener {
|
||||||
|
canRelay = false
|
||||||
|
}
|
||||||
observerRows = append(observerRows, map[string]interface{}{
|
observerRows = append(observerRows, map[string]interface{}{
|
||||||
"observer_id": id, "observer_name": o.name,
|
"observer_id": id, "observer_name": o.name,
|
||||||
"avgSnr": avgSnr, "avgRssi": avgRssi, "packetCount": o.count,
|
"avgSnr": avgSnr, "avgRssi": avgRssi, "packetCount": o.count,
|
||||||
|
"can_relay": canRelay,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
sort.Slice(observerRows, func(i, j int) bool {
|
sort.Slice(observerRows, func(i, j int) bool {
|
||||||
|
|||||||
@@ -909,6 +909,10 @@ type ObserverResp struct {
|
|||||||
ClockSkewSeconds interface{} `json:"clock_skew_seconds"`
|
ClockSkewSeconds interface{} `json:"clock_skew_seconds"`
|
||||||
ClockSkewCount24h int `json:"clock_skew_count_24h"`
|
ClockSkewCount24h int `json:"clock_skew_count_24h"`
|
||||||
ClockLastNaiveAt interface{} `json:"clock_last_naive_at"`
|
ClockLastNaiveAt interface{} `json:"clock_last_naive_at"`
|
||||||
|
// Issue #1290: firmware 1.16 `repeat` flag — true=repeater,
|
||||||
|
// false=listener-only. Drives the UI badge on observers list +
|
||||||
|
// node detail page. Defaults to true for legacy observers.
|
||||||
|
CanRelay bool `json:"can_relay"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ObserverListResponse struct {
|
type ObserverListResponse struct {
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ func Apply(rw *sql.DB, logf Logger) error {
|
|||||||
if err := ensureObserverNaiveClockColumns(rw, logf); err != nil {
|
if err := ensureObserverNaiveClockColumns(rw, logf); err != nil {
|
||||||
return fmt.Errorf("ensure observers naive-clock columns: %w", err)
|
return fmt.Errorf("ensure observers naive-clock columns: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := ensureObserverCanRelayColumn(rw, logf); err != nil {
|
||||||
|
return fmt.Errorf("ensure observers.can_relay: %w", err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +141,12 @@ func AssertReady(ro *sql.DB) error {
|
|||||||
mustCol("observers", "clock_skew_seconds")
|
mustCol("observers", "clock_skew_seconds")
|
||||||
mustCol("observers", "clock_skew_count_24h")
|
mustCol("observers", "clock_skew_count_24h")
|
||||||
mustCol("observers", "clock_last_naive_at")
|
mustCol("observers", "clock_last_naive_at")
|
||||||
|
// Issue #1290: firmware 1.16 publishes a `repeat: on|off` flag in
|
||||||
|
// the MQTT /status JSON. Ingestor persists it as can_relay; server
|
||||||
|
// reads it to filter listener-only observers out of the path-hop
|
||||||
|
// disambiguator candidate set. Default 1 preserves prior behavior
|
||||||
|
// for legacy observers that never sent the field.
|
||||||
|
mustCol("observers", "can_relay")
|
||||||
|
|
||||||
if len(missing) > 0 {
|
if len(missing) > 0 {
|
||||||
return fmt.Errorf("schema not migrated by ingestor; restart ingestor first. missing: %s",
|
return fmt.Errorf("schema not migrated by ingestor; restart ingestor first. missing: %s",
|
||||||
@@ -522,3 +531,25 @@ func ensureObserverNaiveClockColumns(rw *sql.DB, logf Logger) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensureObserverCanRelayColumn adds the can_relay column to observers.
|
||||||
|
// Firmware 1.16 publishes a `repeat: on|off` flag in the MQTT /status
|
||||||
|
// JSON (#1290); the ingestor parses it and writes 0/1 here. The server's
|
||||||
|
// path-hop disambiguator (cmd/server/store.go pm.resolveWithContext)
|
||||||
|
// excludes observers with can_relay=0 from the candidate set. Default 1
|
||||||
|
// preserves prior behavior for legacy observers (no repeat field).
|
||||||
|
func ensureObserverCanRelayColumn(rw *sql.DB, logf Logger) error {
|
||||||
|
has, err := TableHasColumn(rw, "observers", "can_relay")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if has {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// PREFLIGHT: async=true reason="single-column ALTER on observers (low-cardinality, ~1k rows in prod); DEFAULT 1 is a constant so SQLite does the rewrite as a metadata-only schema update, no row scan"
|
||||||
|
if _, err := rw.Exec("ALTER TABLE observers ADD COLUMN can_relay INTEGER DEFAULT 1"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logf("[dbschema] added can_relay column to observers")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -676,7 +676,7 @@
|
|||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${observers.map(o => `<tr>
|
${observers.map(o => `<tr>
|
||||||
<td data-value="${escapeHtml((o.observer_name || o.observer_id || '').toLowerCase())}" style="font-weight:600">${escapeHtml(o.observer_name || o.observer_id)}</td>
|
<td data-value="${escapeHtml((o.observer_name || o.observer_id || '').toLowerCase())}" style="font-weight:600">${escapeHtml(o.observer_name || o.observer_id)}${o.can_relay === false ? ' <span class="badge-listener" title="Firmware reported repeat:off — excluded from path-hop disambiguator (#1290)">listener</span>' : (o.can_relay === true ? ' <span class="badge-repeater" title="Firmware reported repeat:on — eligible as a path hop">repeater</span>' : '')}</td>
|
||||||
<td data-value="${escapeHtml((o.iata || '').toLowerCase())}">${o.iata ? escapeHtml(o.iata) : '—'}</td>
|
<td data-value="${escapeHtml((o.iata || '').toLowerCase())}">${o.iata ? escapeHtml(o.iata) : '—'}</td>
|
||||||
<td data-value="${o.packetCount || 0}">${o.packetCount}</td>
|
<td data-value="${o.packetCount || 0}">${o.packetCount}</td>
|
||||||
<td data-value="${o.avgSnr != null ? Number(o.avgSnr) : ''}">${o.avgSnr != null ? Number(o.avgSnr).toFixed(1) + ' dB' : '—'}</td>
|
<td data-value="${o.avgSnr != null ? Number(o.avgSnr) : ''}">${o.avgSnr != null ? Number(o.avgSnr).toFixed(1) + ' dB' : '—'}</td>
|
||||||
|
|||||||
@@ -180,6 +180,10 @@ window.ObserverDetailNaiveBanner = {
|
|||||||
<div class="stat-label">Status</div>
|
<div class="stat-label">Status</div>
|
||||||
<div class="stat-value"><span class="health-dot ${statusCls}">●</span> ${statusLabel}</div>
|
<div class="stat-value"><span class="health-dot ${statusCls}">●</span> ${statusLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Relay</div>
|
||||||
|
<div class="stat-value">${obs.can_relay === false ? '<span class="badge-listener" title="Firmware reported repeat:off — excluded from path-hop disambiguator (#1290)">listener</span>' : '<span class="badge-repeater" title="Firmware reported repeat:on (or unknown) — eligible as a path hop">repeater</span>'}</div>
|
||||||
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label">Region</div>
|
<div class="stat-label">Region</div>
|
||||||
<div class="stat-value">${obs.iata ? '<span class="badge-region">' + escapeHtml(obs.iata) + '</span>' : '—'}</div>
|
<div class="stat-value">${obs.iata ? '<span class="badge-region">' + escapeHtml(obs.iata) + '</span>' : '—'}</div>
|
||||||
|
|||||||
+1
-1
@@ -297,7 +297,7 @@ window.ObserversSummary = (function () {
|
|||||||
const shape = h.cls === 'health-green' ? '●' : h.cls === 'health-yellow' ? '▲' : '✕';
|
const shape = h.cls === 'health-green' ? '●' : h.cls === 'health-yellow' ? '▲' : '✕';
|
||||||
return `<tr style="cursor:pointer" tabindex="0" role="row" data-action="navigate" data-value="#/observers/${encodeURIComponent(o.id)}" onclick="location.hash='#/observers/${encodeURIComponent(o.id)}'">
|
return `<tr style="cursor:pointer" tabindex="0" role="row" data-action="navigate" data-value="#/observers/${encodeURIComponent(o.id)}" onclick="location.hash='#/observers/${encodeURIComponent(o.id)}'">
|
||||||
<td><span class="health-dot ${h.cls}" title="${h.label}">${shape}</span> ${h.label}</td>
|
<td><span class="health-dot ${h.cls}" title="${h.label}">${shape}</span> ${h.label}</td>
|
||||||
<td class="mono">${escapeHtml(o.name || o.id)}${window.ObserversNaiveChip.render(o)}</td>
|
<td 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>' : ' <span class="badge-repeater" title="Firmware reported repeat:on (or unknown) — eligible as a path hop">repeater</span>'}</td>
|
||||||
<td>${o.iata ? `<span class="badge-region">${o.iata}</span>` : '—'}</td>
|
<td>${o.iata ? `<span class="badge-region">${o.iata}</span>` : '—'}</td>
|
||||||
<td>${timeAgo(o.last_seen)}</td>
|
<td>${timeAgo(o.last_seen)}</td>
|
||||||
<td>${o.last_packet_at ? timeAgo(o.last_packet_at) : '<span class="text-muted">—</span>'}</td>
|
<td>${o.last_packet_at ? timeAgo(o.last_packet_at) : '<span class="text-muted">—</span>'}</td>
|
||||||
|
|||||||
@@ -1146,6 +1146,24 @@ body.scroll-locked { overflow: hidden; }
|
|||||||
font-size: 10px; font-weight: 700; font-family: var(--mono);
|
font-size: 10px; font-weight: 700; font-family: var(--mono);
|
||||||
background: var(--nav-bg); color: var(--nav-text); letter-spacing: .5px;
|
background: var(--nav-bg); color: var(--nav-text); letter-spacing: .5px;
|
||||||
}
|
}
|
||||||
|
/* Issue #1290: listener vs repeater badge on observers list + detail.
|
||||||
|
* Driven by ObserverResp.can_relay (false = firmware reported repeat:off).
|
||||||
|
* Server-side disambiguator excludes listener observers from path-hop
|
||||||
|
* candidates; this badge surfaces the same hint to operators. Colors use
|
||||||
|
* existing theme vars so the badge tracks the active palette. */
|
||||||
|
.badge-listener,
|
||||||
|
.badge-repeater {
|
||||||
|
display: inline-block; padding: 1px 5px; border-radius: 4px;
|
||||||
|
font-size: 10px; font-weight: 700; font-family: var(--mono);
|
||||||
|
letter-spacing: .5px; margin-left: 4px; vertical-align: middle;
|
||||||
|
}
|
||||||
|
.badge-listener {
|
||||||
|
background: var(--transport-badge-bg, #f59e0b20);
|
||||||
|
color: var(--transport-badge-fg, #d97706);
|
||||||
|
}
|
||||||
|
.badge-repeater {
|
||||||
|
background: var(--nav-bg); color: var(--nav-text);
|
||||||
|
}
|
||||||
/* Observer IATA pill rendered inline next to observer name on packets (#1188).
|
/* Observer IATA pill rendered inline next to observer name on packets (#1188).
|
||||||
* Visually similar to .badge-region but distinct so the row badge and the
|
* Visually similar to .badge-region but distinct so the row badge and the
|
||||||
* inline-with-observer badge can be styled independently in future themes. */
|
* inline-with-observer badge can be styled independently in future themes. */
|
||||||
|
|||||||
Reference in New Issue
Block a user