mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-13 16:23:07 +00:00
45f30fcadc
## Summary Implements repeater liveness detection per #662 — distinguishes a repeater that is **actively relaying traffic** from one that is **alive but idle** (only sending its own adverts). ## Approach The backend already maintains a `byPathHop` index keyed by lowercase hop/pubkey for every transmission. Decode-window writes also key it by **resolved pubkey** for relay hops. We just weren't surfacing it. `GetRepeaterRelayInfo(pubkey, windowHours)`: - Reads `byPathHop[pubkey]`. - Skips packets whose `payload_type == 4` (advert) — a self-advert proves liveness, not relaying. - Returns the most recent `FirstSeen` as `lastRelayed`, plus `relayActive` (within window) and the `windowHours` actually used. ## Three states (per issue) | State | Indicator | Condition | |---|---|---| | 🟢 Relaying | green | `last_relayed` within `relayActiveHours` | | 🟡 Alive (idle) | yellow | repeater is in the DB but `relay_active=false` (no recent path-hop appearance, or none ever) | | ⚪ Stale | existing | falls out of the existing `getNodeStatus` logic | ## API - `GET /api/nodes` — repeater/room rows now include `last_relayed` (omitted if never observed) and `relay_active`. - `GET /api/nodes/{pubkey}` — same fields plus `relay_window_hours`. ## Config New optional field under `healthThresholds`: ```json "healthThresholds": { ..., "relayActiveHours": 24 } ``` Default 24h. Documented in `config.example.json`. ## Frontend Node detail page gains a **Last Relayed** row for repeaters/rooms with the 🟢/🟡 state badge. Tooltip explains the distinction from "Last Heard". ## TDD - **Red commit** `4445f91`: `repeater_liveness_test.go` + stub `GetRepeaterRelayInfo` returning zero. Active and Stale tests fail on assertion (LastRelayed empty / mismatched). Idle and IgnoresAdverts already match the desired behavior under the stub. Compiles, runs, fails on assertions — not on imports. - **Green commit** `5fcfb57`: Implementation. All four tests pass. Full `cmd/server` suite green (~22s). ## Performance `O(N)` over `byPathHop[pubkey]` per call. The index is bounded by store eviction; a single repeater has at most a few hundred entries on real data. The `/api/nodes` loop adds one map read + scan per repeater row — negligible against the existing enrichment work. ## Limitations (per issue body) 1. Observer coverage gaps — if no observer hears a repeater's relay, it'll show as idle even when actively relaying. This is inherent to passive observation. 2. Low-traffic networks — a repeater in a quiet area legitimately shows idle. The 🟡 indicator copy makes that explicit ("alive (idle)"). 3. Hash collisions are mitigated by the existing `resolveWithContext` path before pubkeys land in `byPathHop`. Fixes #662 --------- Co-authored-by: clawbot <bot@corescope.local>
163 lines
5.4 KiB
Go
163 lines
5.4 KiB
Go
package main
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestRepeaterRelayActivity_Active verifies that a repeater whose pubkey
|
|
// appears as a relay hop in a recent (non-advert) packet is reported with
|
|
// a non-zero lastRelayed timestamp and relayActive=true.
|
|
func TestRepeaterRelayActivity_Active(t *testing.T) {
|
|
db := setupCapabilityTestDB(t)
|
|
defer db.conn.Close()
|
|
|
|
pubkey := "aabbccdd11223344"
|
|
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
|
pubkey, "RepActive", "repeater", recentTS(1))
|
|
|
|
store := NewPacketStore(db, nil)
|
|
|
|
// A non-advert packet (payload_type=1, TXT_MSG) with the repeater pubkey
|
|
// indexed as a path hop. Index by lowercase pubkey directly to mirror
|
|
// the resolved-path entries that decode-window writes.
|
|
pt := 1
|
|
relayed := &StoreTx{
|
|
RawHex: "0100",
|
|
PayloadType: &pt,
|
|
PathJSON: `["aa"]`,
|
|
FirstSeen: recentTS(2),
|
|
}
|
|
store.mu.Lock()
|
|
relayed.ID = len(store.packets) + 1
|
|
relayed.Hash = "test-relay-1"
|
|
store.packets = append(store.packets, relayed)
|
|
store.byHash[relayed.Hash] = relayed
|
|
store.byTxID[relayed.ID] = relayed
|
|
store.byPathHop[pubkey] = append(store.byPathHop[pubkey], relayed)
|
|
store.mu.Unlock()
|
|
|
|
info := store.GetRepeaterRelayInfo(pubkey, 24)
|
|
if info.LastRelayed == "" {
|
|
t.Fatalf("expected non-empty LastRelayed for active relayer, got empty (RelayActive=%v)", info.RelayActive)
|
|
}
|
|
if !info.RelayActive {
|
|
t.Errorf("expected RelayActive=true within 24h window, got false (LastRelayed=%s)", info.LastRelayed)
|
|
}
|
|
if info.RelayCount1h != 0 {
|
|
t.Errorf("expected RelayCount1h=0 (relay was 2h ago, outside 1h window), got %d", info.RelayCount1h)
|
|
}
|
|
if info.RelayCount24h != 1 {
|
|
t.Errorf("expected RelayCount24h=1 (relay was 2h ago, inside 24h window), got %d", info.RelayCount24h)
|
|
}
|
|
}
|
|
|
|
// TestRepeaterRelayActivity_Idle verifies that a repeater whose pubkey
|
|
// has not appeared as a relay hop reports an empty LastRelayed and
|
|
// relayActive=false.
|
|
func TestRepeaterRelayActivity_Idle(t *testing.T) {
|
|
db := setupCapabilityTestDB(t)
|
|
defer db.conn.Close()
|
|
|
|
pubkey := "ccddeeff55667788"
|
|
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
|
pubkey, "RepIdle", "repeater", recentTS(1))
|
|
|
|
store := NewPacketStore(db, nil)
|
|
|
|
info := store.GetRepeaterRelayInfo(pubkey, 24)
|
|
if info.LastRelayed != "" {
|
|
t.Errorf("expected empty LastRelayed for idle repeater, got %q", info.LastRelayed)
|
|
}
|
|
if info.RelayActive {
|
|
t.Errorf("expected RelayActive=false for idle repeater, got true")
|
|
}
|
|
if info.RelayCount1h != 0 || info.RelayCount24h != 0 {
|
|
t.Errorf("expected zero relay counts for idle repeater, got 1h=%d 24h=%d", info.RelayCount1h, info.RelayCount24h)
|
|
}
|
|
}
|
|
|
|
// TestRepeaterRelayActivity_Stale verifies that a repeater whose only
|
|
// relay-hop appearances are older than the configured window reports
|
|
// a non-empty LastRelayed but relayActive=false.
|
|
func TestRepeaterRelayActivity_Stale(t *testing.T) {
|
|
db := setupCapabilityTestDB(t)
|
|
defer db.conn.Close()
|
|
|
|
pubkey := "1122334455667788"
|
|
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
|
pubkey, "RepStale", "repeater", recentTS(1))
|
|
|
|
store := NewPacketStore(db, nil)
|
|
|
|
pt := 1
|
|
staleTS := time.Now().UTC().Add(-48 * time.Hour).Format("2006-01-02T15:04:05.000Z")
|
|
old := &StoreTx{
|
|
RawHex: "0100",
|
|
PayloadType: &pt,
|
|
PathJSON: `["11"]`,
|
|
FirstSeen: staleTS,
|
|
}
|
|
store.mu.Lock()
|
|
old.ID = len(store.packets) + 1
|
|
old.Hash = "test-relay-stale"
|
|
store.packets = append(store.packets, old)
|
|
store.byHash[old.Hash] = old
|
|
store.byTxID[old.ID] = old
|
|
store.byPathHop[pubkey] = append(store.byPathHop[pubkey], old)
|
|
store.mu.Unlock()
|
|
|
|
info := store.GetRepeaterRelayInfo(pubkey, 24)
|
|
if info.LastRelayed != staleTS {
|
|
t.Errorf("expected LastRelayed=%q (stale ts), got %q", staleTS, info.LastRelayed)
|
|
}
|
|
if info.RelayActive {
|
|
t.Errorf("expected RelayActive=false for relay older than window, got true")
|
|
}
|
|
if info.RelayCount1h != 0 || info.RelayCount24h != 0 {
|
|
t.Errorf("expected zero relay counts for stale (>24h) repeater, got 1h=%d 24h=%d", info.RelayCount1h, info.RelayCount24h)
|
|
}
|
|
}
|
|
|
|
// TestRepeaterRelayActivity_IgnoresAdverts verifies that adverts originated
|
|
// by the repeater itself (payload_type=4) are NOT counted as relay activity —
|
|
// adverts demonstrate liveness, not relaying.
|
|
func TestRepeaterRelayActivity_IgnoresAdverts(t *testing.T) {
|
|
db := setupCapabilityTestDB(t)
|
|
defer db.conn.Close()
|
|
|
|
pubkey := "deadbeef00000001"
|
|
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
|
pubkey, "RepAdvertOnly", "repeater", recentTS(1))
|
|
|
|
store := NewPacketStore(db, nil)
|
|
|
|
// Self-advert with the repeater as its own first hop. Should NOT count.
|
|
pt := 4
|
|
adv := &StoreTx{
|
|
RawHex: "0140de",
|
|
PayloadType: &pt,
|
|
PathJSON: `["de"]`,
|
|
FirstSeen: recentTS(2),
|
|
}
|
|
store.mu.Lock()
|
|
adv.ID = len(store.packets) + 1
|
|
adv.Hash = "test-advert-1"
|
|
store.packets = append(store.packets, adv)
|
|
store.byHash[adv.Hash] = adv
|
|
store.byTxID[adv.ID] = adv
|
|
store.byPathHop[pubkey] = append(store.byPathHop[pubkey], adv)
|
|
store.mu.Unlock()
|
|
|
|
info := store.GetRepeaterRelayInfo(pubkey, 24)
|
|
if info.LastRelayed != "" {
|
|
t.Errorf("expected empty LastRelayed (adverts ignored), got %q", info.LastRelayed)
|
|
}
|
|
if info.RelayActive {
|
|
t.Errorf("expected RelayActive=false (adverts ignored), got true")
|
|
}
|
|
if info.RelayCount1h != 0 || info.RelayCount24h != 0 {
|
|
t.Errorf("expected zero relay counts (adverts ignored), got 1h=%d 24h=%d", info.RelayCount1h, info.RelayCount24h)
|
|
}
|
|
}
|