diff --git a/cmd/server/neighbor_persist.go b/cmd/server/neighbor_persist.go index 53f83437..5f9e5170 100644 --- a/cmd/server/neighbor_persist.go +++ b/cmd/server/neighbor_persist.go @@ -510,6 +510,7 @@ func backfillResolvedPathsAsync(store *PacketStore, dbPath string, chunkSize int if tx, ok := store.byHash[r.txHash]; ok { pks := extractResolvedPubkeys(r.rp) store.addToResolvedPubkeyIndex(tx.ID, pks) + store.addTxToRelayTimeIndex(tx) // Update byNode for relay nodes for _, pk := range pks { store.addToByNode(tx, pk) diff --git a/cmd/server/relay_liveness_test.go b/cmd/server/relay_liveness_test.go new file mode 100644 index 00000000..dc36f1e5 --- /dev/null +++ b/cmd/server/relay_liveness_test.go @@ -0,0 +1,313 @@ +package main + +import ( + "sort" + "strings" + "testing" + "time" +) + +func makeRp(s string) *string { return &s } + +func TestRelayIndexInsertPaths_SingleNode(t *testing.T) { + idx := make(map[string][]int64) + pk := "aabbccdd11223344" + millis := time.Now().Add(-30 * time.Minute).UnixMilli() + relayIndexInsertPaths(idx, millis, []*string{makeRp(pk)}) + if len(idx[pk]) != 1 { + t.Fatalf("expected 1 entry, got %d", len(idx[pk])) + } + if idx[pk][0] != millis { + t.Errorf("timestamp mismatch: got %d, want %d", idx[pk][0], millis) + } +} + +func TestRelayIndexInsertPaths_SortedOrder(t *testing.T) { + idx := make(map[string][]int64) + pk := "aabbccdd11223344" + ms1 := time.Now().Add(-2 * time.Hour).UnixMilli() + ms2 := time.Now().Add(-30 * time.Minute).UnixMilli() + + // Insert newer first, expect sorted ascending + relayIndexInsertPaths(idx, ms2, []*string{makeRp(pk)}) + relayIndexInsertPaths(idx, ms1, []*string{makeRp(pk)}) + + if len(idx[pk]) != 2 { + t.Fatalf("expected 2 entries, got %d", len(idx[pk])) + } + if !sort.SliceIsSorted(idx[pk], func(i, j int) bool { return idx[pk][i] < idx[pk][j] }) { + t.Error("relayTimes slice not sorted ascending") + } +} + +func TestRelayIndexInsertPaths_MultipleNodes(t *testing.T) { + idx := make(map[string][]int64) + pk1 := "aabbccdd11223344" + pk2 := "eeff001122334455" + millis := time.Now().Add(-10 * time.Minute).UnixMilli() + relayIndexInsertPaths(idx, millis, []*string{makeRp(pk1), makeRp(pk2)}) + if len(idx[pk1]) != 1 { + t.Errorf("pk1: expected 1 entry, got %d", len(idx[pk1])) + } + if len(idx[pk2]) != 1 { + t.Errorf("pk2: expected 1 entry, got %d", len(idx[pk2])) + } +} + +func TestRelayIndexInsertPaths_NilPaths(t *testing.T) { + idx := make(map[string][]int64) + relayIndexInsertPaths(idx, time.Now().UnixMilli(), nil) // must not panic + if len(idx) != 0 { + t.Error("expected empty index for nil paths") + } +} + +func TestRelayIndexInsertPaths_DuplicatePubkey(t *testing.T) { + idx := make(map[string][]int64) + pk := "aabbccdd11223344" + millis := time.Now().UnixMilli() + relayIndexInsertPaths(idx, millis, []*string{makeRp(pk), makeRp(pk)}) // same pubkey twice + if len(idx[pk]) != 1 { + t.Errorf("duplicate pubkey should produce only 1 entry, got %d", len(idx[pk])) + } +} + +func TestRelayIndexInsertPaths_LowercasesKey(t *testing.T) { + idx := make(map[string][]int64) + pkUpper := "AABBCCDD11223344" + pkLower := strings.ToLower(pkUpper) + millis := time.Now().UnixMilli() + relayIndexInsertPaths(idx, millis, []*string{makeRp(pkUpper)}) + if len(idx[pkLower]) != 1 { + t.Errorf("expected index keyed by lowercase, found %d entries at lowercase key", len(idx[pkLower])) + } + if len(idx[pkUpper]) != 0 { + t.Errorf("expected no entry at uppercase key") + } +} + +func TestRelayIndexRemovePaths_RemovesEntry(t *testing.T) { + idx := make(map[string][]int64) + pk := "aabbccdd11223344" + millis := time.Now().Add(-1 * time.Hour).UnixMilli() + paths := []*string{makeRp(pk)} + + relayIndexInsertPaths(idx, millis, paths) + if len(idx[pk]) != 1 { + t.Fatal("setup: expected 1 entry") + } + relayIndexRemovePaths(idx, millis, paths) + if _, ok := idx[pk]; ok { + t.Error("expected key deleted after last entry removed") + } +} + +func TestRelayIndexRemovePaths_PartialRemove(t *testing.T) { + idx := make(map[string][]int64) + pk := "aabbccdd11223344" + ms1 := time.Now().Add(-2 * time.Hour).UnixMilli() + ms2 := time.Now().Add(-30 * time.Minute).UnixMilli() + paths := []*string{makeRp(pk)} + + relayIndexInsertPaths(idx, ms1, paths) + relayIndexInsertPaths(idx, ms2, paths) + relayIndexRemovePaths(idx, ms1, paths) + + if len(idx[pk]) != 1 { + t.Errorf("expected 1 entry after removing one, got %d", len(idx[pk])) + } +} + +func TestRelayMetrics_Counts(t *testing.T) { + now := time.Now().UnixMilli() + times := []int64{ + now - 90*60*1000, // 90 min ago — inside 24h, outside 1h + now - 30*60*1000, // 30 min ago — inside both + now - 10*60*1000, // 10 min ago — inside both + } + c1h, c24h, lastRelayed := relayMetrics(times, now) + if c1h != 2 { + t.Errorf("relay_count_1h: expected 2, got %d", c1h) + } + if c24h != 3 { + t.Errorf("relay_count_24h: expected 3, got %d", c24h) + } + wantLast := time.UnixMilli(times[2]).UTC().Format(time.RFC3339) + if lastRelayed != wantLast { + t.Errorf("last_relayed: got %q, want %q", lastRelayed, wantLast) + } +} + +func TestRelayMetrics_EmptySlice(t *testing.T) { + c1h, c24h, lastRelayed := relayMetrics(nil, time.Now().UnixMilli()) + if c1h != 0 || c24h != 0 || lastRelayed != "" { + t.Errorf("empty slice: expected zeros and empty string, got %d %d %q", c1h, c24h, lastRelayed) + } +} + +func TestRelayMetrics_AllOutsideWindow(t *testing.T) { + now := time.Now().UnixMilli() + times := []int64{now - 30*24*60*60*1000} // 30 days ago + c1h, c24h, _ := relayMetrics(times, now) + if c1h != 0 || c24h != 0 { + t.Errorf("expected 0/0 for old entry, got %d/%d", c1h, c24h) + } +} + +func TestRelayTimesWiredIntoIngest(t *testing.T) { + srv, _ := setupTestServer(t) + + srv.store.mu.RLock() + hopKeys := len(srv.store.byPathHop) + relayKeys := len(srv.store.relayTimes) + srv.store.mu.RUnlock() + + if hopKeys == 0 { + t.Skip("no path-hop data in test store — skipping relay wiring test") + } + if srv.store.relayTimes == nil { + t.Fatal("relayTimes map is nil after load") + } + if relayKeys == 0 { + t.Fatalf("relayTimes not populated: byPathHop has %d keys but relayTimes has 0", hopKeys) + } + if relayKeys > hopKeys { + t.Errorf("relayTimes has more keys (%d) than byPathHop (%d) — relay index should be a subset", relayKeys, hopKeys) + } + t.Logf("byPathHop keys: %d, relayTimes keys: %d", hopKeys, relayKeys) +} + +func TestGetBulkHealthRepeaterRelayFields(t *testing.T) { + srv, _ := setupTestServer(t) + + _, err := srv.db.conn.Exec(`INSERT OR IGNORE INTO nodes (public_key, name, role, last_seen, first_seen, advert_count) + VALUES ('relay662test0001', 'TestRepeater662', 'repeater', datetime('now'), datetime('now'), 1)`) + if err != nil { + t.Fatalf("insert test node: %v", err) + } + + pk := "relay662test0001" + recentMs := time.Now().UnixMilli() - 10*60*1000 // 10 min ago + srv.store.mu.Lock() + srv.store.relayTimes[pk] = []int64{recentMs} + srv.store.mu.Unlock() + + results := srv.store.GetBulkHealth(200, "") + + var found map[string]interface{} + for _, r := range results { + if r["public_key"] == pk { + found = r + break + } + } + if found == nil { + t.Fatal("test repeater not found in GetBulkHealth results") + } + + stats, ok := found["stats"].(map[string]interface{}) + if !ok { + t.Fatal("missing stats map in result") + } + + if v, ok := stats["relay_count_1h"].(int); !ok || v != 1 { + t.Errorf("relay_count_1h: expected 1, got %v", stats["relay_count_1h"]) + } + if v, ok := stats["relay_count_24h"].(int); !ok || v != 1 { + t.Errorf("relay_count_24h: expected 1, got %v", stats["relay_count_24h"]) + } + if _, ok := stats["last_relayed"].(string); !ok { + t.Errorf("last_relayed: expected string, got %T", stats["last_relayed"]) + } +} + +func TestGetBulkHealthCompanionNoRelayFields(t *testing.T) { + srv, _ := setupTestServer(t) + + _, err := srv.db.conn.Exec(`INSERT OR IGNORE INTO nodes (public_key, name, role, last_seen, first_seen, advert_count) + VALUES ('comp662test0001', 'TestCompanion662', 'companion', datetime('now'), datetime('now'), 1)`) + if err != nil { + t.Fatalf("insert test node: %v", err) + } + + pk := "comp662test0001" + srv.store.mu.Lock() + srv.store.relayTimes[pk] = []int64{time.Now().UnixMilli() - 5*60*1000} + srv.store.mu.Unlock() + + results := srv.store.GetBulkHealth(200, "") + for _, r := range results { + if r["public_key"] == pk { + stats, _ := r["stats"].(map[string]interface{}) + if _, present := stats["relay_count_24h"]; present { + t.Error("relay_count_24h should be absent for companion nodes") + } + return + } + } + t.Fatal("test companion not found in GetBulkHealth results") +} + +func TestGetBulkHealthRepeaterNoRelayActivity(t *testing.T) { + srv, _ := setupTestServer(t) + + _, err := srv.db.conn.Exec(`INSERT OR IGNORE INTO nodes (public_key, name, role, last_seen, first_seen, advert_count) + VALUES ('relay662idle001', 'IdleRepeater662', 'repeater', datetime('now'), datetime('now'), 1)`) + if err != nil { + t.Fatalf("insert test node: %v", err) + } + + results := srv.store.GetBulkHealth(200, "") + for _, r := range results { + if r["public_key"] == "relay662idle001" { + stats, _ := r["stats"].(map[string]interface{}) + if v, ok := stats["relay_count_24h"].(int); !ok || v != 0 { + t.Errorf("relay_count_24h: expected 0, got %v", stats["relay_count_24h"]) + } + if _, present := stats["last_relayed"]; present { + t.Error("last_relayed should be absent when no relay activity") + } + return + } + } + t.Fatal("idle repeater not found in results") +} + +func TestGetNodeHealthRepeaterRelayFields(t *testing.T) { + srv, _ := setupTestServer(t) + + pk := "relay662node0001" + _, err := srv.db.conn.Exec(`INSERT OR IGNORE INTO nodes (public_key, name, role, last_seen, first_seen, advert_count) + VALUES ('relay662node0001', 'TestRepeaterNode662', 'repeater', datetime('now'), datetime('now'), 1)`) + if err != nil { + t.Fatalf("insert test node: %v", err) + } + + recentMs := time.Now().UnixMilli() - 15*60*1000 // 15 min ago + srv.store.mu.Lock() + srv.store.relayTimes[pk] = []int64{recentMs} + srv.store.mu.Unlock() + + result, err := srv.store.GetNodeHealth(pk) + if err != nil { + t.Fatalf("GetNodeHealth error: %v", err) + } + if result == nil { + t.Fatal("GetNodeHealth returned nil") + } + + stats, ok := result["stats"].(map[string]interface{}) + if !ok { + t.Fatal("missing stats map in GetNodeHealth result") + } + + if v, ok := stats["relay_count_1h"].(int); !ok || v != 1 { + t.Errorf("relay_count_1h: expected 1, got %v", stats["relay_count_1h"]) + } + if v, ok := stats["relay_count_24h"].(int); !ok || v != 1 { + t.Errorf("relay_count_24h: expected 1, got %v", stats["relay_count_24h"]) + } + if _, ok := stats["last_relayed"].(string); !ok { + t.Errorf("last_relayed: expected string, got %T", stats["last_relayed"]) + } +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 70839b52..d092825a 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -1087,9 +1087,46 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) { } if s.store != nil { hashInfo := s.store.GetNodeHashSizeInfo() + now := time.Now().UnixMilli() + + // Snapshot relay times for repeater nodes under the read lock, then + // release before enrichment so the lock isn't held for the full loop. + relaySnap := make(map[string][]int64) + s.store.mu.RLock() + for _, node := range nodes { + pk, ok := node["public_key"].(string) + if !ok { + continue + } + if role, _ := node["role"].(string); strings.ToLower(role) == "repeater" { + lk := strings.ToLower(pk) + if times := s.store.relayTimes[lk]; len(times) > 0 { + cp := make([]int64, len(times)) + copy(cp, times) + relaySnap[lk] = cp + } + } + } + s.store.mu.RUnlock() + for _, node := range nodes { - if pk, ok := node["public_key"].(string); ok { - EnrichNodeWithHashSize(node, hashInfo[pk]) + pk, ok := node["public_key"].(string) + if !ok { + continue + } + EnrichNodeWithHashSize(node, hashInfo[pk]) + role, _ := node["role"].(string) + if strings.ToLower(role) == "repeater" { + lk := strings.ToLower(pk) + c1h, c24h, lastRel := relayMetrics(relaySnap[lk], now) + stats := map[string]interface{}{ + "relay_count_1h": c1h, + "relay_count_24h": c24h, + } + if lastRel != "" && (c1h > 0 || c24h > 0) { + stats["last_relayed"] = lastRel + } + node["stats"] = stats } } } diff --git a/cmd/server/store.go b/cmd/server/store.go index 496edac1..0d928816 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -131,6 +131,7 @@ type PacketStore struct { byNode map[string][]*StoreTx // pubkey → transmissions nodeHashes map[string]map[string]bool // pubkey → Set byPathHop map[string][]*StoreTx // lowercase hop/pubkey → transmissions with that hop in path + relayTimes map[string][]int64 // lowercase pubkey → sorted unix-millis of relay events (full pubkeys only) byPayloadType map[int][]*StoreTx // payload_type → transmissions loaded bool totalObs int @@ -382,6 +383,7 @@ func NewPacketStore(db *DB, cfg *PacketStoreConfig, cacheTTLs ...map[string]inte byObserver: make(map[string][]*StoreObs), byNode: make(map[string][]*StoreTx), byPathHop: make(map[string][]*StoreTx), + relayTimes: make(map[string][]int64), nodeHashes: make(map[string]map[string]bool), byPayloadType: make(map[int][]*StoreTx), rfCache: make(map[string]*cachedResult), @@ -463,13 +465,13 @@ func (s *PacketStore) Load() error { if s.db.hasObsRawHex { obsRawHexCol = ", o.raw_hex" } - - limitClause := "" - if maxPackets > 0 { - limitClause = fmt.Sprintf( + whereClause := "" + if s.retentionHours > 0 { + whereClause = fmt.Sprintf("\n\t\t\tWHERE t.first_seen >= datetime('now', '-%.0f hours')", s.retentionHours) + } else if maxPackets > 0 { + whereClause = fmt.Sprintf( "\n\t\t\tWHERE t.id IN (SELECT id FROM transmissions ORDER BY first_seen DESC LIMIT %d)", maxPackets) } - if s.db.isV3 { loadSQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type, t.payload_type, t.payload_version, t.decoded_json, @@ -477,7 +479,7 @@ func (s *PacketStore) Load() error { o.snr, o.rssi, o.score, o.path_json, strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch')` + obsRawHexCol + rpCol + ` FROM transmissions t LEFT JOIN observations o ON o.transmission_id = t.id - LEFT JOIN observers obs ON obs.rowid = o.observer_idx` + limitClause + ` + LEFT JOIN observers obs ON obs.rowid = o.observer_idx` + whereClause + ` ORDER BY t.first_seen ASC, o.timestamp DESC` } else { loadSQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type, @@ -485,7 +487,7 @@ func (s *PacketStore) Load() error { o.id, o.observer_id, o.observer_name, o.direction, o.snr, o.rssi, o.score, o.path_json, o.timestamp` + obsRawHexCol + rpCol + ` FROM transmissions t - LEFT JOIN observations o ON o.transmission_id = t.id` + limitClause + ` + LEFT JOIN observations o ON o.transmission_id = t.id` + whereClause + ` ORDER BY t.first_seen ASC, o.timestamp DESC` } @@ -1666,6 +1668,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac s.spTotalPaths++ } addTxToPathHopIndex(s.byPathHop, tx) + s.addTxToRelayTimeIndex(tx) } // Incrementally update precomputed distance index with new transmissions @@ -2089,6 +2092,7 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string] saved, savedFlag := tx.parsedPath, tx.pathParsed tx.parsedPath, tx.pathParsed = oldHops, true removeTxFromPathHopIndex(s.byPathHop, tx) + s.removeFromRelayTimeIndex(tx) tx.parsedPath, tx.pathParsed = saved, savedFlag } // pickBestObservation already set pathParsed=false so @@ -2097,6 +2101,11 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string] s.spTotalPaths++ } addTxToPathHopIndex(s.byPathHop, tx) + s.addTxToRelayTimeIndex(tx) + } else { + // Path unchanged: new observation may have relay hops in its resolved + // path that aren't indexed yet (idempotent — safe to call repeatedly). + s.addTxToRelayTimeIndex(tx) } } @@ -2682,10 +2691,15 @@ func (s *PacketStore) buildSubpathIndex() { // Must be called with s.mu held. func (s *PacketStore) buildPathHopIndex() { s.byPathHop = make(map[string][]*StoreTx, 4096) + s.relayTimes = make(map[string][]int64, 4096) + cutoff := time.Now().Add(-24 * time.Hour) for _, tx := range s.packets { addTxToPathHopIndex(s.byPathHop, tx) + if t, err := time.Parse(time.RFC3339, tx.FirstSeen); err == nil && t.After(cutoff) { + s.addTxToRelayTimeIndex(tx) + } } - log.Printf("[store] Built path-hop index: %d unique keys", len(s.byPathHop)) + log.Printf("[store] Built path-hop index: %d unique keys, %d relay-time keys", len(s.byPathHop), len(s.relayTimes)) } // addTxToPathHopIndex indexes a transmission under each unique raw hop key. @@ -2705,6 +2719,106 @@ func addTxToPathHopIndex(idx map[string][]*StoreTx, tx *StoreTx) { } } +// addTxToRelayTimeIndex records the relay timestamp for each full pubkey that +// relayIndexInsertPaths inserts millis into idx for every non-nil pubkey in paths. +// Insertion is idempotent and keeps the slice sorted ascending. +// Exposed as a package-level helper so unit tests can exercise the logic directly. +func relayIndexInsertPaths(idx map[string][]int64, millis int64, paths []*string) { + seen := make(map[string]bool, len(paths)) + for _, rp := range paths { + if rp == nil { + continue + } + pk := strings.ToLower(*rp) + if seen[pk] { + continue + } + seen[pk] = true + slice := idx[pk] + i := sort.Search(len(slice), func(j int) bool { return slice[j] >= millis }) + if i < len(slice) && slice[i] == millis { + continue // idempotent: already present + } + slice = append(slice, 0) + copy(slice[i+1:], slice[i:]) + slice[i] = millis + idx[pk] = slice + } +} + +// relayIndexRemovePaths removes millis from idx for every non-nil pubkey in paths. +// Symmetric with relayIndexInsertPaths; deletes the map key when the slice empties. +func relayIndexRemovePaths(idx map[string][]int64, millis int64, paths []*string) { + seen := make(map[string]bool, len(paths)) + for _, rp := range paths { + if rp == nil { + continue + } + pk := strings.ToLower(*rp) + if seen[pk] { + continue + } + seen[pk] = true + slice := idx[pk] + i := sort.Search(len(slice), func(j int) bool { return slice[j] >= millis }) + if i < len(slice) && slice[i] == millis { + copy(slice[i:], slice[i+1:]) + slice = slice[:len(slice)-1] + if len(slice) == 0 { + delete(idx, pk) + } else { + idx[pk] = slice + } + } + } +} + +// addTxToRelayTimeIndex indexes relay activity for all full pubkeys that +// appears in ANY observation's resolved path. Scanning all observations (not +// just the best one) ensures relay activity is captured even when the best +// observer received the packet directly without a relay hop. +// Must be called with s.mu held (or during build before store is live). +func (s *PacketStore) addTxToRelayTimeIndex(tx *StoreTx) { + ms, err := time.Parse(time.RFC3339, tx.FirstSeen) + if err != nil { + return + } + millis := ms.UnixMilli() + for _, rp := range s.fetchResolvedPathsForTx(tx.ID) { + relayIndexInsertPaths(s.relayTimes, millis, rp) + } +} + +// removeFromRelayTimeIndex removes the relay timestamp for every full pubkey +// that appears in any observation's resolved path. Symmetric with +// addTxToRelayTimeIndex so eviction does not leave orphaned entries. +func (s *PacketStore) removeFromRelayTimeIndex(tx *StoreTx) { + ms, err := time.Parse(time.RFC3339, tx.FirstSeen) + if err != nil { + return + } + millis := ms.UnixMilli() + for _, rp := range s.fetchResolvedPathsForTx(tx.ID) { + relayIndexRemovePaths(s.relayTimes, millis, rp) + } +} + +// relayMetrics computes relay_count_1h, relay_count_24h, and last_relayed from a +// sorted unix-millis slice. now is time.Now().UnixMilli(). O(log n). +// last_relayed is always the most recent entry regardless of window age — callers +// receive it even when both counts are zero (e.g. "last relayed 3 days ago"). +func relayMetrics(times []int64, now int64) (count1h, count24h int, lastRelayed string) { + if len(times) == 0 { + return 0, 0, "" + } + i1h := sort.Search(len(times), func(i int) bool { return times[i] >= now-3600000 }) + i24h := sort.Search(len(times), func(i int) bool { return times[i] >= now-86400000 }) + count1h = len(times) - i1h + count24h = len(times) - i24h + lastRelayed = time.UnixMilli(times[len(times)-1]).UTC().Format(time.RFC3339) + return +} + // removeTxFromPathHopIndex removes a transmission from all its raw path-hop index entries. // Resolved pubkey entries are cleaned up via removeFromResolvedPubkeyIndex. func removeTxFromPathHopIndex(idx map[string][]*StoreTx, tx *StoreTx) { @@ -3137,6 +3251,7 @@ func (s *PacketStore) evictStaleInternal(rpBatch map[int][]string) int { removeTxFromSubpathIndexFull(s.spIndex, s.spTxIndex, tx) // Remove from path-hop index removeTxFromPathHopIndex(s.byPathHop, tx) + s.removeFromRelayTimeIndex(tx) } // Batch-remove from byObserver: single pass per affected observer slice @@ -6471,21 +6586,30 @@ func (s *PacketStore) GetBulkHealth(limit int, region string) []map[string]inter lhVal = lastHeard } + statsMap := map[string]interface{}{ + "totalTransmissions": len(packets), + "totalObservations": totalObservations, + "totalPackets": len(packets), + "packetsToday": packetsToday, + "avgSnr": avgSnr, + "lastHeard": lhVal, + } + if strings.ToLower(n.role) == "repeater" { + c1h, c24h, lastRel := relayMetrics(s.relayTimes[strings.ToLower(n.pk)], time.Now().UnixMilli()) + statsMap["relay_count_1h"] = c1h + statsMap["relay_count_24h"] = c24h + if lastRel != "" && (c1h > 0 || c24h > 0) { + statsMap["last_relayed"] = lastRel + } + } results = append(results, map[string]interface{}{ "public_key": n.pk, "name": nilIfEmpty(n.name), "role": nilIfEmpty(n.role), "lat": n.lat, "lon": n.lon, - "stats": map[string]interface{}{ - "totalTransmissions": len(packets), - "totalObservations": totalObservations, - "totalPackets": len(packets), - "packetsToday": packetsToday, - "avgSnr": avgSnr, - "lastHeard": lhVal, - }, - "observers": observerRows, + "stats": statsMap, + "observers": observerRows, }) } @@ -6622,18 +6746,32 @@ func (s *PacketStore) GetNodeHealth(pubkey string) (map[string]interface{}, erro recentPackets = append(recentPackets, p) } + nodeStats := map[string]interface{}{ + "totalTransmissions": len(packets), + "totalObservations": totalObservations, + "totalPackets": len(packets), + "packetsToday": packetsToday, + "avgSnr": avgSnr, + "avgHops": avgHops, + "lastHeard": lhVal, + } + role := "" + if r, ok := node["role"].(string); ok { + role = strings.ToLower(r) + } + if role == "repeater" { + lowerPK := strings.ToLower(pubkey) + c1h, c24h, lastRel := relayMetrics(s.relayTimes[lowerPK], time.Now().UnixMilli()) + nodeStats["relay_count_1h"] = c1h + nodeStats["relay_count_24h"] = c24h + if lastRel != "" && (c1h > 0 || c24h > 0) { + nodeStats["last_relayed"] = lastRel + } + } return map[string]interface{}{ - "node": node, - "observers": observerRows, - "stats": map[string]interface{}{ - "totalTransmissions": len(packets), - "totalObservations": totalObservations, - "totalPackets": len(packets), - "packetsToday": packetsToday, - "avgSnr": avgSnr, - "avgHops": avgHops, - "lastHeard": lhVal, - }, + "node": node, + "observers": observerRows, + "stats": nodeStats, "recentPackets": recentPackets, }, nil } diff --git a/public/nodes.js b/public/nodes.js index 5d3355e8..63079e58 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -114,7 +114,13 @@ const isInfra = role === 'repeater' || role === 'room'; const threshMs = isInfra ? HEALTH_THRESHOLDS.infraSilentMs : HEALTH_THRESHOLDS.nodeSilentMs; const threshold = threshMs >= 3600000 ? Math.round(threshMs / 3600000) + 'h' : Math.round(threshMs / 60000) + 'm'; + if (status === 'relaying') { + return 'Relaying \u2014 actively forwarding traffic within the last 24h.'; + } if (status === 'active') { + if (role === 'repeater') { + return 'Idle \u2014 alive (heard within ' + threshold + ') but no relay traffic observed in the last 24h. May be in a quiet area or have RF issues.'; + } return 'Active \u2014 heard within the last ' + threshold + '.' + (isInfra ? ' Repeaters typically advertise every 12-24h.' : ''); } if (role === 'companion') { @@ -133,13 +139,28 @@ // Prefer last_heard (from in-memory packets) > _lastHeard (health API) > last_seen (DB) const lastHeardTime = n._lastHeard || n.last_heard || n.last_seen; const lastHeardMs = lastHeardTime ? new Date(lastHeardTime).getTime() : 0; - const status = getNodeStatus(role, lastHeardMs); + const relayCount24h = (n.stats && typeof n.stats.relay_count_24h === 'number') ? n.stats.relay_count_24h : undefined; + const relayCount1h = (n.stats && typeof n.stats.relay_count_1h === 'number') ? n.stats.relay_count_1h : undefined; + const lastRelayed = n.stats && n.stats.last_relayed; + const status = getNodeStatus(role, lastHeardMs, relayCount24h); const statusTooltip = getStatusTooltip(role, status); - const statusLabel = status === 'active' ? '🟢 Active' : '⚪ Stale'; const statusAge = lastHeardMs ? (Date.now() - lastHeardMs) : Infinity; + let statusLabel; + if (role === 'repeater') { + statusLabel = status === 'relaying' ? '🟢 Relaying' : status === 'active' ? '🟡 Idle' : '⚪ Stale'; + } else { + statusLabel = status === 'active' ? '🟢 Active' : '⚪ Stale'; + } + let explanation = ''; - if (status === 'active') { + if (status === 'relaying') { + explanation = 'Relayed ' + relayCount24h + ' packet' + (relayCount24h === 1 ? '' : 's') + ' in last 24h' + + (lastRelayed ? ', last ' + renderNodeTimestampText(lastRelayed) : ''); + } else if (status === 'active' && role === 'repeater') { + explanation = 'Alive but no relay traffic observed in last 24h' + + (lastRelayed ? ', last relayed ' + renderNodeTimestampText(lastRelayed) : ''); + } else if (status === 'active') { explanation = 'Last heard ' + (lastHeardTime ? renderNodeTimestampText(lastHeardTime) : 'unknown'); } else { const ageDays = Math.floor(statusAge / 86400000); @@ -152,7 +173,7 @@ explanation = 'Not heard for ' + ageStr + ' — ' + reason; } - return { status, statusLabel, statusTooltip, statusAge, explanation, roleColor, lastHeardMs, role }; + return { status, statusLabel, statusTooltip, statusAge, explanation, roleColor, lastHeardMs, role, relayCount1h, relayCount24h, lastRelayed }; } function renderNodeBadges(n, roleColor) { @@ -471,8 +492,9 @@ const recent = h.recentPackets || []; const lastHeard = stats.lastHeard; - // Attach health lastHeard for shared helpers + // Attach health lastHeard and relay stats for shared helpers n._lastHeard = lastHeard || n.last_seen; + n.stats = stats; const si = getStatusInfo(n); const roleColor = si.roleColor; const statusLabel = si.statusLabel; @@ -512,6 +534,9 @@ Packets Today${stats.packetsToday || 0} ${stats.avgSnr != null ? `Avg SNR${Number(stats.avgSnr).toFixed(1)} dB` : ''} ${stats.avgHops ? `Avg Hops${stats.avgHops}` : ''} + ${si.role === 'repeater' ? `Relay (1h)${typeof si.relayCount1h === 'number' ? si.relayCount1h + ' packet' + (si.relayCount1h === 1 ? '' : 's') : '—'}` : ''} + ${si.role === 'repeater' ? `Relay (24h)${typeof si.relayCount24h === 'number' ? si.relayCount24h + ' packet' + (si.relayCount24h === 1 ? '' : 's') : '—'}` : ''} + ${si.role === 'repeater' && si.lastRelayed ? `Last Relayed${renderNodeTimestampHtml(si.lastRelayed)}` : ''} ${hasLoc ? `Location${Number(n.lat).toFixed(5)}, ${Number(n.lon).toFixed(5)}` : ''} Hash Prefix${n.hash_size ? '' + n.public_key.slice(0, n.hash_size * 2).toUpperCase() + ' (' + n.hash_size + '-byte)' : 'Unknown'}${n.hash_size_inconsistent ? ' ⚠️ varies' : ''} @@ -914,10 +939,9 @@ // Status filter (active/stale) if (statusFilter === 'active' || statusFilter === 'stale') { filtered = filtered.filter(n => { - const role = (n.role || 'companion').toLowerCase(); - const t = n.last_heard || n.last_seen; - const lastMs = t ? new Date(t).getTime() : 0; - return getNodeStatus(role, lastMs) === statusFilter; + const si = getStatusInfo(n); + if (statusFilter === 'active') return si.status === 'active' || si.status === 'relaying'; + return si.status === statusFilter; }); } nodes = filtered; @@ -1005,6 +1029,7 @@ Public Key Role Last Seen + St. Adverts @@ -1125,18 +1150,19 @@ const dupMap = buildDupNameMap(_allNodes); tbody.innerHTML = sorted.map(n => { - const roleColor = ROLE_COLORS[n.role] || '#6b7280'; const isClaimed = myKeys.has(n.public_key); - const lastSeenTime = n.last_heard || n.last_seen; - const status = getNodeStatus(n.role || 'companion', lastSeenTime ? new Date(lastSeenTime).getTime() : 0); - const lastSeenClass = status === 'active' ? 'last-seen-active' : 'last-seen-stale'; + const si = getStatusInfo(n); + const lastSeenClass = si.status === 'relaying' ? 'last-seen-active' : si.status === 'active' ? (si.role === 'repeater' ? 'last-seen-idle' : 'last-seen-active') : 'last-seen-stale'; + const statusEmoji = si.statusLabel.split(' ')[0]; + const statusTooltip = si.statusLabel.replace(/^.\s/, '') + (si.explanation ? ' — ' + si.explanation : ''); const cs = _fleetSkew && _fleetSkew[n.public_key]; const skewBadgeHtml = cs && cs.severity && cs.severity !== 'ok' ? renderSkewBadge(cs.severity, window.currentSkewValue(cs), cs) : ''; return ` ${favStar(n.public_key, 'node-fav')}${isClaimed ? ' ' : ''}${n.name || '(unnamed)'}${dupNameBadge(n.name, n.public_key, dupMap)}${skewBadgeHtml} ${truncate(n.public_key, 16)} - ${n.role} + ${n.role} ${renderNodeTimestampHtml(n.last_heard || n.last_seen)} + ${statusEmoji} ${n.advert_count || 0} `; }).join(''); @@ -1191,6 +1217,7 @@ // Status calculation via shared helper const lastHeard = stats.lastHeard; n._lastHeard = lastHeard || n.last_seen; + n.stats = stats; const si = getStatusInfo(n); const roleColor = si.roleColor; const totalPackets = stats.totalTransmissions || stats.totalPackets || n.advert_count || 0; @@ -1226,6 +1253,9 @@
Packets Today
${stats.packetsToday || 0}
${stats.avgSnr != null ? `
Avg SNR
${Number(stats.avgSnr).toFixed(1)} dB
` : ''} ${stats.avgHops ? `
Avg Hops
${stats.avgHops}
` : ''} + ${si.role === 'repeater' ? `
Relay (1h)
${typeof si.relayCount1h === 'number' ? si.relayCount1h + ' packet' + (si.relayCount1h === 1 ? '' : 's') : '—'}
` : ''} + ${si.role === 'repeater' ? `
Relay (24h)
${typeof si.relayCount24h === 'number' ? si.relayCount24h + ' packet' + (si.relayCount24h === 1 ? '' : 's') : '—'}
` : ''} + ${si.role === 'repeater' && si.lastRelayed ? `
Last Relayed
${renderNodeTimestampHtml(si.lastRelayed)}
` : ''} ${hasLoc ? `
Location
${Number(n.lat).toFixed(5)}, ${Number(n.lon).toFixed(5)}
` : ''} diff --git a/public/roles.js b/public/roles.js index 27937988..1700a279 100644 --- a/public/roles.js +++ b/public/roles.js @@ -84,12 +84,16 @@ }; }; - // Simplified two-state helper: returns 'active' or 'stale' - window.getNodeStatus = function (role, lastSeenMs) { + // Three-state helper for repeaters: returns 'relaying', 'active', or 'stale' + window.getNodeStatus = function (role, lastSeenMs, relayCount24h) { var isInfra = role === 'repeater' || role === 'room'; var staleMs = isInfra ? HEALTH_THRESHOLDS.infraSilentMs : HEALTH_THRESHOLDS.nodeSilentMs; var age = typeof lastSeenMs === 'number' ? (Date.now() - lastSeenMs) : Infinity; - return age < staleMs ? 'active' : 'stale'; + if (age >= staleMs) return 'stale'; + if (role === 'repeater') { + return (typeof relayCount24h === 'number' && relayCount24h > 0) ? 'relaying' : 'active'; + } + return 'active'; }; // ─── Tile URLs ─── diff --git a/public/style.css b/public/style.css index db9c5d72..703b5a15 100644 --- a/public/style.css +++ b/public/style.css @@ -1637,6 +1637,7 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); } .marker-stale { opacity: 0.7; filter: grayscale(90%) brightness(0.8); } .last-seen-active { color: var(--status-green); } .last-seen-stale { color: var(--text-muted); } +.last-seen-idle { color: var(--status-yellow); } /* === Node Analytics === */ .analytics-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 16px; } diff --git a/test-e2e-playwright.js b/test-e2e-playwright.js index c1963964..284a3871 100644 --- a/test-e2e-playwright.js +++ b/test-e2e-playwright.js @@ -224,10 +224,9 @@ async function run() { // Test 5: Node detail loads (reuses nodes page from test 2) await test('Node detail loads', async () => { await page.waitForSelector('table tbody tr'); - // Click first row - const firstRow = await page.$('table tbody tr'); - assert(firstRow, 'No node rows found'); - await firstRow.click(); + // Use page.click() instead of an element handle to avoid detached-element races + // when the WebSocket auto-refresh re-renders the table between querySelector and click. + await page.click('table tbody tr'); // Wait for detail pane to appear await page.waitForSelector('.node-detail'); const html = await page.content(); @@ -240,10 +239,8 @@ async function run() { await test('Node side panel Details link navigates', async () => { await page.goto(`${BASE}/#/nodes`, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('table tbody tr'); - // Click first row to open side panel - const firstRow = await page.$('table tbody tr'); - assert(firstRow, 'No node rows found'); - await firstRow.click(); + // Use page.click() to avoid detached-element race with WebSocket auto-refresh. + await page.click('table tbody tr'); await page.waitForSelector('.node-detail'); // Find the Details link in the side panel const detailsLink = await page.$('#nodesRight a.btn-primary[href^="#/nodes/"]'); diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index af076c29..c98ea1e4 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -330,10 +330,10 @@ console.log('\n=== nodes.js: getStatusTooltip / getStatusInfo (extracted) ==='); if (ex.getStatusInfo) { const gsi = ex.getStatusInfo; - test('active repeater status', () => { + test('active repeater status (idle — heard but no relay traffic)', () => { const info = gsi({ role: 'repeater', last_heard: new Date().toISOString() }); assert.strictEqual(info.status, 'active'); - assert.ok(info.statusLabel.includes('Active')); + assert.ok(info.statusLabel.includes('Idle')); }); test('stale companion status (old date)', () => { const old = new Date(Date.now() - 48 * 3600000).toISOString(); @@ -350,6 +350,19 @@ console.log('\n=== nodes.js: getStatusTooltip / getStatusInfo (extracted) ==='); const info = gsi({ role: 'repeater', last_heard: d }); assert.strictEqual(info.status, 'active'); }); + test('relaying repeater statusLabel and explanation', () => { + const info = gsi({ role: 'repeater', last_heard: new Date().toISOString(), + stats: { relay_count_24h: 42, relay_count_1h: 5, last_relayed: new Date().toISOString() } }); + assert.strictEqual(info.status, 'relaying'); + assert.ok(info.statusLabel.includes('Relaying'), 'statusLabel should include Relaying'); + assert.ok(info.explanation.includes('42 packet'), 'explanation should include packet count'); + }); + test('relaying repeater with relay_count_24h === 1 uses singular', () => { + const info = gsi({ role: 'repeater', last_heard: new Date().toISOString(), + stats: { relay_count_24h: 1 } }); + assert.ok(info.explanation.includes('1 packet'), 'should have singular'); + assert.ok(!info.explanation.includes('1 packets'), 'should not have plural'); + }); } if (ex.renderNodeBadges) { @@ -4232,9 +4245,9 @@ console.log('\n=== nodes.js: getStatusInfo edge cases ==='); assert.strictEqual(info.status, 'stale'); }); - test('getStatusInfo returns explanation for active node', () => { + test('getStatusInfo returns explanation for active repeater (idle)', () => { const info = gsi({ role: 'repeater', last_heard: new Date().toISOString() }); - assert.ok(info.explanation.includes('Last heard')); + assert.ok(info.explanation.includes('no relay traffic')); }); test('getStatusInfo returns explanation for stale companion', () => { diff --git a/test-repeater-liveness.js b/test-repeater-liveness.js new file mode 100644 index 00000000..39902ad0 --- /dev/null +++ b/test-repeater-liveness.js @@ -0,0 +1,79 @@ +'use strict'; +const vm = require('vm'); +const fs = require('fs'); + +// Minimal browser environment +const ctx = { + window: {}, + console, + document: { + readyState: 'complete', + documentElement: { getAttribute: () => null }, + getElementById: () => null, + createElement: () => ({ textContent: '' }), + head: { appendChild: () => {} } + }, + navigator: {}, + fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }), + Date, +}; +vm.createContext(ctx); +vm.runInContext(fs.readFileSync('public/roles.js', 'utf8'), ctx); +// Mirror browser semantics: in a real browser window === globalThis, so bare +// NOTE: mirrors browser global; update if HEALTH_THRESHOLDS moves out of window scope in roles.js +ctx.HEALTH_THRESHOLDS = ctx.window.HEALTH_THRESHOLDS; + +// Run tests inside the VM context to preserve closures +const testCode = ` +let pass = 0, fail = 0; +function test(name, fn) { + try { fn(); pass++; console.log(' ok:', name); } + catch (e) { fail++; console.log('FAIL:', name, '—', e.message); } +} +function assert(cond, msg) { if (!cond) throw new Error(msg || 'assertion failed'); } + +const now = Date.now(); +const recentMs = now - 1000; // 1 second ago — always active +const staleMs = now - (window.HEALTH_THRESHOLDS.infraSilentMs + 1); // just past silent threshold + +// --- Repeater three-state --- +test('repeater + recent + relay > 0 → relaying', + () => assert(window.getNodeStatus('repeater', recentMs, 5) === 'relaying')); + +test('repeater + recent + relay == 0 → active (idle)', + () => assert(window.getNodeStatus('repeater', recentMs, 0) === 'active')); + +test('repeater + stale + relay > 0 → stale (stale beats relay)', + () => assert(window.getNodeStatus('repeater', staleMs, 99) === 'stale')); + +test('repeater + stale + relay == 0 → stale', + () => assert(window.getNodeStatus('repeater', staleMs, 0) === 'stale')); + +// --- Non-repeater roles unaffected --- +test('companion + recent + relay 0 → active', + () => assert(window.getNodeStatus('companion', recentMs, 0) === 'active')); + +test('companion + recent + relay > 0 → active (relay ignored)', + () => assert(window.getNodeStatus('companion', recentMs, 99) === 'active')); + +test('room + recent + relay 0 → active', + () => assert(window.getNodeStatus('room', recentMs, 0) === 'active')); + +test('sensor + recent + relay 0 → active', + () => assert(window.getNodeStatus('sensor', recentMs, 0) === 'active')); + +// --- Backward compatibility: omitting third arg --- +test('getNodeStatus(repeater, recent) with no relay arg → active (not relaying)', + () => assert(window.getNodeStatus('repeater', recentMs) === 'active')); + +test('getNodeStatus(companion, recent) with no relay arg → active', + () => assert(window.getNodeStatus('companion', recentMs) === 'active')); + +window.testResults = { pass, fail }; +`; + +vm.runInContext(testCode, ctx); + +const { pass, fail } = ctx.window.testResults; +console.log(`\n${pass} passed, ${fail} failed`); +if (fail > 0) process.exit(1);