From 31fc669979f26fa4194ed7456b18d02b145a6c9e Mon Sep 17 00:00:00 2001 From: efiten Date: Wed, 15 Apr 2026 11:24:42 +0200 Subject: [PATCH 01/21] docs: repeater liveness design spec (#662) --- .../2026-04-15-repeater-liveness-design.md | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-15-repeater-liveness-design.md diff --git a/docs/superpowers/specs/2026-04-15-repeater-liveness-design.md b/docs/superpowers/specs/2026-04-15-repeater-liveness-design.md new file mode 100644 index 00000000..d9cde1d2 --- /dev/null +++ b/docs/superpowers/specs/2026-04-15-repeater-liveness-design.md @@ -0,0 +1,163 @@ +# Repeater Liveness — Design Spec + +**Issue:** Kpa-clawbot/CoreScope#662 +**Date:** 2026-04-15 +**Status:** Approved + +--- + +## Problem + +CoreScope conflates two distinct repeater states into "active": + +| State | Adverts | In paths | Meaning | +|---|---|---|---| +| Relaying | Recent | Yes | Up and forwarding traffic | +| Alive but idle | Recent | No | Up, but nothing routed through it | +| Down | None | None | Offline | + +An operator cannot tell whether a repeater is healthy and carrying traffic, or healthy but not actually relaying anything. + +--- + +## Scope + +M1 (backend relay metrics) + M2 (frontend three-state indicator). M3 (repeater dashboard) is out of scope for this implementation. + +--- + +## Architecture + +### Data Structure (Backend) + +Add a parallel index to `PacketStore` in `store.go`: + +```go +relayTimes map[string][]int64 // lowercase pubkey → sorted []int64 unix-millis +``` + +- Indexed by **full pubkeys only** (from `tx.ResolvedPath`) — not raw hop prefixes, avoiding hash-collision noise +- Maintained under the existing `s.mu` RWMutex — no new lock needed +- Lives parallel to `byPathHop`, sharing the same add/remove call sites + +### Index Maintenance + +**On add** — called from `addTxToPathHopIndex`: +- For each non-nil entry in `tx.ResolvedPath` +- Parse `tx.FirstSeen` → unix millis +- Binary-search insert into sorted slice + +**On remove** — called from `removeTxFromPathHopIndex`: +- For each non-nil entry in `tx.ResolvedPath` +- Remove the millis value from the sorted slice + +**On build** — called from `buildPathHopIndex`: +- Populate `relayTimes` in the same pass + +### Query (O(log n)) + +```go +now := time.Now().UnixMilli() +times := s.relayTimes[lowerPK] +count1h := len(times) - sort.Search(len(times), func(i int) bool { return times[i] >= now-3600000 }) +count24h := len(times) - sort.Search(len(times), func(i int) bool { return times[i] >= now-86400000 }) +lastRelayed := times[len(times)-1] // free — last element of sorted slice +``` + +--- + +## API + +No new endpoints. Relay metrics are added to existing health responses. + +### `GET /api/nodes/bulk-health` and `GET /api/nodes/{pubkey}/health` + +Added to the `stats` sub-object, **repeater nodes only** (fields absent for other roles): + +```json +{ + "stats": { + "lastHeard": "2026-04-15T10:00:00Z", + "packetsToday": 12, + "relay_count_1h": 3, + "relay_count_24h": 47, + "last_relayed": "2026-04-15T09:58:00Z" + } +} +``` + +`last_relayed` is an RFC3339 string (consistent with existing timestamp fields). Omitted when `relayTimes[pk]` is empty. + +--- + +## Frontend + +### `roles.js` — extend `getNodeStatus` + +```js +// Signature change: +// Before: getNodeStatus(role, lastSeenMs) → 'active' | 'stale' +// After: getNodeStatus(role, lastSeenMs, relayCount24h) → 'relaying' | 'active' | 'stale' +``` + +Logic: +- Non-repeaters: `relayCount24h` ignored, returns `'active'` or `'stale'` as before — **no behaviour change** +- Repeaters: + - Stale threshold exceeded → `'stale'` + - Within threshold + `relayCount24h > 0` → `'relaying'` + - Within threshold + `relayCount24h == 0` → `'active'` (alive but idle) + +### `nodes.js` — `getStatusInfo` and render + +- Extract `relay_count_24h`, `relay_count_1h`, `last_relayed` from `n.stats` +- Pass `relay_count_24h` into `getNodeStatus` + +**Status labels (repeaters):** + +| Status | Label | Explanation | +|---|---|---| +| `'relaying'` | `🟢 Relaying` | `"Relayed N packets in last 24h, last X ago"` | +| `'active'` | `🟡 Idle` | `"Alive but no relay traffic in 24h"` | +| `'stale'` | `⚪ Stale` | existing text | + +**Non-repeaters:** labels unchanged (`🟢 Active` / `⚪ Stale`). + +**Node detail pane:** relay stats row added to the stats table for repeaters: +- `Relay (1h)` / `Relay (24h)` / `Last Relayed` +- Row hidden for non-repeater roles + +**Status filter buttons:** `'relaying'` maps to the `active` bucket — filter UI unchanged. + +--- + +## Testing + +### Backend (Go) — new test cases in `store_test.go` or dedicated file + +- `addTxToRelayTimeIndex`: insert packets with known timestamps → verify sorted order +- Count at 1h/24h boundaries: packets straddling the window edge → correct counts +- `removeFromRelayTimeIndex`: add then remove → slice returns to original state +- `GetBulkHealth` relay fields: repeater with relay activity → fields present; companion → fields absent +- Eviction: add packets, evict oldest → relay_count_24h drops correctly +- `last_relayed`: equals timestamp of most recently relayed packet +- Empty `relayTimes`: no panic, fields omitted from response +- Node with no pubkeys in `ResolvedPath`: `relayTimes` unchanged (raw hops ignored) + +### Frontend (Node.js) — `test-repeater-liveness.js` + +- `getNodeStatus('repeater', recentMs, 5)` → `'relaying'` +- `getNodeStatus('repeater', recentMs, 0)` → `'active'` +- `getNodeStatus('repeater', staleMs, 5)` → `'stale'` +- `getNodeStatus('companion', recentMs, 0)` → `'active'` (no three-state for non-repeaters) +- `getNodeStatus('companion', recentMs, 99)` → `'active'` (relay count ignored for non-repeaters) +- Status label: `'relaying'` → `🟢 Relaying` +- Status label: `'active'` on repeater → `🟡 Idle` + +--- + +## Limitations + +1. **Observer coverage gaps**: if no observer hears traffic through a repeater, relay activity won't be recorded even if the repeater is relaying. Inherent to passive observation. +2. **Low-traffic networks**: zero relay activity ≠ broken. The "Idle" label must be clearly worded. +3. **Hash collisions**: mitigated by indexing full pubkeys only (resolved path), not raw hop prefixes. +4. **Memory**: `relayTimes` adds one `int64` per relay event per node. Bounded by store packet limit — acceptable. From fe5febef5bc3b83d2b10688c9326c768e861e97b Mon Sep 17 00:00:00 2001 From: efiten Date: Wed, 15 Apr 2026 11:31:49 +0200 Subject: [PATCH 02/21] docs: repeater liveness implementation plan (#662) --- .../plans/2026-04-15-repeater-liveness.md | 1059 +++++++++++++++++ 1 file changed, 1059 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-15-repeater-liveness.md diff --git a/docs/superpowers/plans/2026-04-15-repeater-liveness.md b/docs/superpowers/plans/2026-04-15-repeater-liveness.md new file mode 100644 index 00000000..53badc61 --- /dev/null +++ b/docs/superpowers/plans/2026-04-15-repeater-liveness.md @@ -0,0 +1,1059 @@ +# Repeater Liveness Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Distinguish repeaters that are actively relaying traffic from those that are alive but idle, using a precomputed O(log n) sorted-timestamp index. + +**Architecture:** Add `relayTimes map[string][]int64` to `PacketStore`, maintained in lockstep with `byPathHop`. Relay counts for 1h/24h windows are computed via binary search at query time and injected into the existing `/api/nodes/bulk-health` and `/api/nodes/{pubkey}/health` responses. Frontend extends `getNodeStatus` to return a third state (`'relaying'`) for repeaters with recent relay activity. + +**Tech Stack:** Go (backend), vanilla JS (frontend), Node.js (frontend tests), SQLite (no schema changes needed) + +--- + +## File Map + +| File | Action | What changes | +|---|---|---| +| `cmd/server/store.go` | Modify | Add `relayTimes` field; add `addTxToRelayTimeIndex`, `removeFromRelayTimeIndex`, `relayMetrics` functions; wire into `buildPathHopIndex`, `addTxToPathHopIndex`, `removeTxFromPathHopIndex`; enrich `GetBulkHealth` and `GetNodeHealth` | +| `cmd/server/relay_liveness_test.go` | Create | Go unit + integration tests for all relay index functions and API enrichment | +| `public/roles.js` | Modify | Extend `getNodeStatus` with optional third param `relayCount24h`; add `'relaying'` return value | +| `public/nodes.js` | Modify | Update `getStatusInfo`, `getStatusTooltip`, node list row, node detail pane stats table | +| `public/style.css` | Modify | Add `.last-seen-idle` CSS class | +| `test-repeater-liveness.js` | Create | Frontend unit tests for the three-state logic | + +--- + +## Task 1: Backend — `relayTimes` index field and pure functions + +**Files:** +- Modify: `cmd/server/store.go` +- Create: `cmd/server/relay_liveness_test.go` + +- [ ] **Step 1: Write failing tests for the pure index functions** + +Create `cmd/server/relay_liveness_test.go`: + +```go +package main + +import ( + "sort" + "strings" + "testing" + "time" +) + +func makeRp(s string) *string { return &s } + +func TestAddTxToRelayTimeIndex_SingleNode(t *testing.T) { + idx := make(map[string][]int64) + pk := "aabbccdd11223344" + ts := time.Now().Add(-30 * time.Minute).UTC() + tx := &StoreTx{ + FirstSeen: ts.Format(time.RFC3339), + ResolvedPath: []*string{makeRp(pk)}, + } + addTxToRelayTimeIndex(idx, tx) + if len(idx[pk]) != 1 { + t.Fatalf("expected 1 entry, got %d", len(idx[pk])) + } + wantMs := ts.UnixMilli() + // RFC3339 has second precision, so allow ±1000ms + if diff := idx[pk][0] - wantMs; diff < -1000 || diff > 1000 { + t.Errorf("timestamp mismatch: got %d, want ~%d", idx[pk][0], wantMs) + } +} + +func TestAddTxToRelayTimeIndex_SortedOrder(t *testing.T) { + idx := make(map[string][]int64) + pk := "aabbccdd11223344" + t1 := time.Now().Add(-2 * time.Hour).UTC() + t2 := time.Now().Add(-30 * time.Minute).UTC() + + // Insert newer first, expect sorted ascending + tx2 := &StoreTx{FirstSeen: t2.Format(time.RFC3339), ResolvedPath: []*string{makeRp(pk)}} + tx1 := &StoreTx{FirstSeen: t1.Format(time.RFC3339), ResolvedPath: []*string{makeRp(pk)}} + addTxToRelayTimeIndex(idx, tx2) + addTxToRelayTimeIndex(idx, tx1) + + 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 TestAddTxToRelayTimeIndex_MultipleNodes(t *testing.T) { + idx := make(map[string][]int64) + pk1 := "aabbccdd11223344" + pk2 := "eeff001122334455" + ts := time.Now().Add(-10 * time.Minute).UTC() + tx := &StoreTx{ + FirstSeen: ts.Format(time.RFC3339), + ResolvedPath: []*string{makeRp(pk1), makeRp(pk2)}, + } + addTxToRelayTimeIndex(idx, tx) + 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 TestAddTxToRelayTimeIndex_NilResolvedPath(t *testing.T) { + idx := make(map[string][]int64) + tx := &StoreTx{FirstSeen: time.Now().UTC().Format(time.RFC3339), ResolvedPath: nil} + addTxToRelayTimeIndex(idx, tx) // must not panic + if len(idx) != 0 { + t.Error("expected empty index for nil ResolvedPath") + } +} + +func TestAddTxToRelayTimeIndex_DuplicatePubkeyInPath(t *testing.T) { + idx := make(map[string][]int64) + pk := "aabbccdd11223344" + ts := time.Now().UTC() + tx := &StoreTx{ + FirstSeen: ts.Format(time.RFC3339), + ResolvedPath: []*string{makeRp(pk), makeRp(pk)}, // same pubkey twice + } + addTxToRelayTimeIndex(idx, tx) + if len(idx[pk]) != 1 { + t.Errorf("duplicate pubkey should produce only 1 entry, got %d", len(idx[pk])) + } +} + +func TestRemoveFromRelayTimeIndex_RemovesEntry(t *testing.T) { + idx := make(map[string][]int64) + pk := "aabbccdd11223344" + ts := time.Now().Add(-1 * time.Hour).UTC() + tx := &StoreTx{FirstSeen: ts.Format(time.RFC3339), ResolvedPath: []*string{makeRp(pk)}} + + addTxToRelayTimeIndex(idx, tx) + if len(idx[pk]) != 1 { + t.Fatal("setup: expected 1 entry") + } + removeFromRelayTimeIndex(idx, tx) + if _, ok := idx[pk]; ok { + t.Error("expected key deleted after last entry removed") + } +} + +func TestRemoveFromRelayTimeIndex_PartialRemove(t *testing.T) { + idx := make(map[string][]int64) + pk := "aabbccdd11223344" + t1 := time.Now().Add(-2 * time.Hour).UTC() + t2 := time.Now().Add(-30 * time.Minute).UTC() + tx1 := &StoreTx{FirstSeen: t1.Format(time.RFC3339), ResolvedPath: []*string{makeRp(pk)}} + tx2 := &StoreTx{FirstSeen: t2.Format(time.RFC3339), ResolvedPath: []*string{makeRp(pk)}} + + addTxToRelayTimeIndex(idx, tx1) + addTxToRelayTimeIndex(idx, tx2) + removeFromRelayTimeIndex(idx, tx1) + + 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) + } + if lastRelayed == "" { + t.Error("last_relayed should not be empty") + } +} + +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 TestAddTxToRelayTimeIndex_LowercasesKey(t *testing.T) { + idx := make(map[string][]int64) + pkUpper := "AABBCCDD11223344" + pkLower := strings.ToLower(pkUpper) + ts := time.Now().UTC() + tx := &StoreTx{FirstSeen: ts.Format(time.RFC3339), ResolvedPath: []*string{makeRp(pkUpper)}} + addTxToRelayTimeIndex(idx, tx) + 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") + } +} +``` + +- [ ] **Step 2: Run tests to confirm they all fail** + +```bash +cd cmd/server && go test -run "TestAddTxToRelayTimeIndex|TestRemoveFromRelayTimeIndex|TestRelayMetrics" ./... 2>&1 +``` + +Expected: compile error — `addTxToRelayTimeIndex`, `removeFromRelayTimeIndex`, `relayMetrics` undefined. + +- [ ] **Step 3: Add `relayTimes` field to `PacketStore` struct** + +In `cmd/server/store.go`, find the struct field block around line 134 that has `byPathHop`: + +```go +byPathHop map[string][]*StoreTx // lowercase hop/pubkey → transmissions with that hop in path +``` + +Add after it: + +```go +relayTimes map[string][]int64 // lowercase pubkey → sorted unix-millis of relay events (full pubkeys only) +``` + +- [ ] **Step 4: Initialize `relayTimes` in `newPacketStore`** + +Find the block around line 289 that initializes `byPathHop`: + +```go +byPathHop: make(map[string][]*StoreTx), +``` + +Add after it: + +```go +relayTimes: make(map[string][]int64), +``` + +- [ ] **Step 5: Implement `addTxToRelayTimeIndex`, `removeFromRelayTimeIndex`, `relayMetrics`** + +Add these three functions to `cmd/server/store.go` directly after the `addTxToPathHopIndex` function (around line 2452): + +```go +// addTxToRelayTimeIndex records the relay timestamp for each full pubkey in +// tx.ResolvedPath. Maintains sorted ascending order for O(log n) window queries. +// Must be called with s.mu held (or during build before store is live). +func addTxToRelayTimeIndex(idx map[string][]int64, tx *StoreTx) { + if len(tx.ResolvedPath) == 0 { + return + } + ms, err := time.Parse(time.RFC3339, tx.FirstSeen) + if err != nil { + return + } + millis := ms.UnixMilli() + seen := make(map[string]bool, len(tx.ResolvedPath)) + for _, rp := range tx.ResolvedPath { + 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 }) + slice = append(slice, 0) + copy(slice[i+1:], slice[i:]) + slice[i] = millis + idx[pk] = slice + } +} + +// removeFromRelayTimeIndex removes the relay timestamp for each full pubkey in +// tx.ResolvedPath. Inverse of addTxToRelayTimeIndex. +func removeFromRelayTimeIndex(idx map[string][]int64, tx *StoreTx) { + if len(tx.ResolvedPath) == 0 { + return + } + ms, err := time.Parse(time.RFC3339, tx.FirstSeen) + if err != nil { + return + } + millis := ms.UnixMilli() + seen := make(map[string]bool, len(tx.ResolvedPath)) + for _, rp := range tx.ResolvedPath { + 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 { + idx[pk] = append(slice[:i], slice[i+1:]...) + if len(idx[pk]) == 0 { + delete(idx, pk) + } + } + } +} + +// 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). +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 +} +``` + +- [ ] **Step 6: Run tests — expect pass** + +```bash +cd cmd/server && go test -run "TestAddTxToRelayTimeIndex|TestRemoveFromRelayTimeIndex|TestRelayMetrics" ./... -v 2>&1 +``` + +Expected: all 10 tests PASS. + +- [ ] **Step 7: Commit** + +```bash +git add cmd/server/store.go cmd/server/relay_liveness_test.go +git commit -m "feat(store): add relayTimes index and relay metrics functions (#662)" +``` + +--- + +## Task 2: Backend — wire `relayTimes` into ingest/evict/build paths + +**Files:** +- Modify: `cmd/server/store.go` + +- [ ] **Step 1: Wire into `addTxToPathHopIndex`** + +Find the function `addTxToPathHopIndex` (around line 2432). It ends with the closing `}`. Add a call to `addTxToRelayTimeIndex` — but `addTxToPathHopIndex` takes `idx map[string][]*StoreTx`, not the store itself. The relay index needs to be passed separately. + +The three call sites of `addTxToPathHopIndex` all hold `s.mu`. Change each call site instead of the function signature — add a paired call right after each `addTxToPathHopIndex`: + +**Site 1** — ingest (around line 1485): +```go +addTxToPathHopIndex(s.byPathHop, tx) +addTxToRelayTimeIndex(s.relayTimes, tx) +``` + +**Site 2** — path resolution update (around line 1871): +```go +addTxToPathHopIndex(s.byPathHop, tx) +addTxToRelayTimeIndex(s.relayTimes, tx) +``` + +- [ ] **Step 2: Wire into `removeTxFromPathHopIndex` call sites** + +Find `removeTxFromPathHopIndex(s.byPathHop, tx)` (two call sites — eviction around line 2793 and path-reindex around line 1862). Add paired remove call after each: + +**Site 1** — eviction (around line 2793): +```go +removeTxFromPathHopIndex(s.byPathHop, tx) +removeFromRelayTimeIndex(s.relayTimes, tx) +``` + +**Site 2** — path reindex (around line 1862): +```go +removeTxFromPathHopIndex(s.byPathHop, tx) +removeFromRelayTimeIndex(s.relayTimes, tx) +``` + +- [ ] **Step 3: Wire into `buildPathHopIndex`** + +Find `buildPathHopIndex` (around line 2422). It currently does: + +```go +func (s *PacketStore) buildPathHopIndex() { + s.byPathHop = make(map[string][]*StoreTx, 4096) + for _, tx := range s.packets { + addTxToPathHopIndex(s.byPathHop, tx) + } + log.Printf("[store] Built path-hop index: %d unique keys", len(s.byPathHop)) +} +``` + +Replace with: + +```go +func (s *PacketStore) buildPathHopIndex() { + s.byPathHop = make(map[string][]*StoreTx, 4096) + s.relayTimes = make(map[string][]int64, 4096) + for _, tx := range s.packets { + addTxToPathHopIndex(s.byPathHop, tx) + addTxToRelayTimeIndex(s.relayTimes, tx) + } + log.Printf("[store] Built path-hop index: %d unique keys, %d relay-time keys", len(s.byPathHop), len(s.relayTimes)) +} +``` + +- [ ] **Step 4: Write integration test for wired ingest** + +Add to `cmd/server/relay_liveness_test.go`: + +```go +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") + } + // relayTimes will only be populated if test packets have ResolvedPath entries. + // At minimum it must not panic and must be initialised. + if srv.store.relayTimes == nil { + t.Fatal("relayTimes map is nil after load") + } + t.Logf("byPathHop keys: %d, relayTimes keys: %d", hopKeys, relayKeys) +} +``` + +- [ ] **Step 5: Run all relay tests** + +```bash +cd cmd/server && go test -run "TestAddTxToRelayTimeIndex|TestRemoveFromRelayTimeIndex|TestRelayMetrics|TestRelayTimesWired" ./... -v 2>&1 +``` + +Expected: all pass. + +- [ ] **Step 6: Run full backend test suite** + +```bash +cd cmd/server && go test ./... 2>&1 +``` + +Expected: no regressions. + +- [ ] **Step 7: Commit** + +```bash +git add cmd/server/store.go cmd/server/relay_liveness_test.go +git commit -m "feat(store): wire relayTimes into ingest, evict, and build paths (#662)" +``` + +--- + +## Task 3: Backend API — enrich bulk-health and node-health with relay metrics + +**Files:** +- Modify: `cmd/server/store.go` + +- [ ] **Step 1: Write failing tests for relay fields in API responses** + +Add to `cmd/server/relay_liveness_test.go`: + +```go +func TestGetBulkHealthRepeaterRelayFields(t *testing.T) { + srv, _ := setupTestServer(t) + + // Insert a synthetic repeater node into the DB if none exists + _, 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) + } + + // Inject a relay timestamp within the last hour + pk := "relay662test0001" + now := time.Now().UnixMilli() + recentMs := now - 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) + } + + // Give the companion a relay entry (should be ignored by role gate) + 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) + } + + // No entry in relayTimes for this node + 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") +} +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +cd cmd/server && go test -run "TestGetBulkHealth" ./... 2>&1 +``` + +Expected: FAIL — relay fields missing from stats map. + +- [ ] **Step 3: Enrich `GetBulkHealth` with relay metrics** + +In `GetBulkHealth` (around line 5945), find where the `stats` map is built (around line 6099–6106): + +```go +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, +}) +``` + +Replace the `stats` map construction with: + +```go +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[n.pk], time.Now().UnixMilli()) + statsMap["relay_count_1h"] = c1h + statsMap["relay_count_24h"] = c24h + if lastRel != "" { + 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": statsMap, + "observers": observerRows, +}) +``` + +- [ ] **Step 4: Enrich `GetNodeHealth` with relay metrics** + +In `GetNodeHealth` (around line 6117), find where the final result map is assembled (look for `"lastHeard"` key in the stats sub-map — it's around line 6230–6260 depending on exact file state). The stats map will have a shape like: + +```go +"stats": map[string]interface{}{ + "totalTransmissions": ..., + "totalObservations": ..., + "totalPackets": ..., + "packetsToday": ..., + "avgSnr": ..., + "lastHeard": lastHeardVal, + "avgHops": ..., +}, +``` + +After assembling that `stats` map (name it `nodeStats` if it isn't already named), add before the final `return`: + +```go +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 != "" { + nodeStats["last_relayed"] = lastRel + } +} +``` + +> Note: `GetNodeHealth` releases `s.mu` via `defer s.mu.RUnlock()` at entry — relay metrics are computed inside the lock, which is correct. + +- [ ] **Step 5: Run all relay tests** + +```bash +cd cmd/server && go test -run "TestGetBulkHealth|TestAddTxToRelayTimeIndex|TestRemoveFromRelayTimeIndex|TestRelayMetrics|TestRelayTimesWired" ./... -v 2>&1 +``` + +Expected: all pass. + +- [ ] **Step 6: Run full backend test suite** + +```bash +cd cmd/server && go test ./... 2>&1 +``` + +Expected: no regressions. + +- [ ] **Step 7: Commit** + +```bash +git add cmd/server/store.go cmd/server/relay_liveness_test.go +git commit -m "feat(api): add relay_count_1h/24h/last_relayed to node health responses (#662)" +``` + +--- + +## Task 4: Frontend — extend `getNodeStatus` to three-state (TDD) + +**Files:** +- Modify: `public/roles.js` +- Create: `test-repeater-liveness.js` + +- [ ] **Step 1: Write the failing frontend test file** + +Create `test-repeater-liveness.js` at the repo root: + +```js +'use strict'; +const vm = require('vm'); +const fs = require('fs'); + +// Minimal browser environment +const ctx = { + window: {}, + console, + fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }), + Date, +}; +vm.createContext(ctx); +vm.runInContext(fs.readFileSync('public/roles.js', 'utf8'), ctx); + +const { getNodeStatus, HEALTH_THRESHOLDS } = ctx.window; + +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 - (HEALTH_THRESHOLDS.infraSilentMs + 1); // just past silent threshold + +// --- Repeater three-state --- +test('repeater + recent + relay > 0 → relaying', + () => assert(getNodeStatus('repeater', recentMs, 5) === 'relaying')); + +test('repeater + recent + relay == 0 → active (idle)', + () => assert(getNodeStatus('repeater', recentMs, 0) === 'active')); + +test('repeater + stale + relay > 0 → stale (stale beats relay)', + () => assert(getNodeStatus('repeater', staleMs, 99) === 'stale')); + +test('repeater + stale + relay == 0 → stale', + () => assert(getNodeStatus('repeater', staleMs, 0) === 'stale')); + +// --- Non-repeater roles unaffected --- +test('companion + recent + relay 0 → active', + () => assert(getNodeStatus('companion', recentMs, 0) === 'active')); + +test('companion + recent + relay > 0 → active (relay ignored)', + () => assert(getNodeStatus('companion', recentMs, 99) === 'active')); + +test('room + recent + relay 0 → active', + () => assert(getNodeStatus('room', recentMs, 0) === 'active')); + +test('sensor + recent + relay 0 → active', + () => assert(getNodeStatus('sensor', recentMs, 0) === 'active')); + +// --- Backward compatibility: omitting third arg --- +test('getNodeStatus(repeater, recent) with no relay arg → active (not relaying)', + () => assert(getNodeStatus('repeater', recentMs) === 'active')); + +test('getNodeStatus(companion, recent) with no relay arg → active', + () => assert(getNodeStatus('companion', recentMs) === 'active')); + +console.log(`\n${pass} passed, ${fail} failed`); +if (fail > 0) process.exit(1); +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +node test-repeater-liveness.js 2>&1 +``` + +Expected: FAIL on `repeater + recent + relay > 0 → relaying` (returns `'active'` instead of `'relaying'`). + +- [ ] **Step 3: Extend `getNodeStatus` in `public/roles.js`** + +Find the existing function (around line 88): + +```js +window.getNodeStatus = function (role, lastSeenMs) { + 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'; +}; +``` + +Replace with: + +```js +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; + if (age >= staleMs) return 'stale'; + if (role === 'repeater') { + return (typeof relayCount24h === 'number' && relayCount24h > 0) ? 'relaying' : 'active'; + } + return 'active'; +}; +``` + +- [ ] **Step 4: Run tests to confirm they pass** + +```bash +node test-repeater-liveness.js 2>&1 +``` + +Expected: all 10 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add public/roles.js test-repeater-liveness.js +git commit -m "feat(frontend): extend getNodeStatus to three-state for repeaters (#662)" +``` + +--- + +## Task 5: Frontend — render three-state labels, CSS, and node detail pane + +**Files:** +- Modify: `public/nodes.js` +- Modify: `public/style.css` + +- [ ] **Step 1: Add `.last-seen-idle` CSS class to `public/style.css`** + +Find these two lines (around line 1590–1591): + +```css +.last-seen-active { color: var(--status-green); } +.last-seen-stale { color: var(--text-muted); } +``` + +Add after them: + +```css +.last-seen-idle { color: var(--status-yellow); } +``` + +- [ ] **Step 2: Update `getStatusTooltip` in `public/nodes.js`** + +Find the function (around line 113): + +```js +function getStatusTooltip(role, status) { + 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 === 'active') { + return 'Active \u2014 heard within the last ' + threshold + '.' + (isInfra ? ' Repeaters typically advertise every 12-24h.' : ''); + } + if (role === 'companion') { + return 'Stale \u2014 not heard for over ' + threshold + '. Companions only advertise when the user initiates \u2014 this may be normal.'; + } + if (role === 'sensor') { + return 'Stale \u2014 not heard for over ' + threshold + '. This sensor may be offline.'; + } + return 'Stale \u2014 not heard for over ' + threshold + '. This ' + role + ' may be offline or out of range.'; +} +``` + +Replace with: + +```js +function getStatusTooltip(role, status) { + 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') { + return 'Stale \u2014 not heard for over ' + threshold + '. Companions only advertise when the user initiates \u2014 this may be normal.'; + } + if (role === 'sensor') { + return 'Stale \u2014 not heard for over ' + threshold + '. This sensor may be offline.'; + } + return 'Stale \u2014 not heard for over ' + threshold + '. This ' + role + ' may be offline or out of range.'; +} +``` + +- [ ] **Step 3: Update `getStatusInfo` in `public/nodes.js`** + +Find the function (around line 129): + +```js +function getStatusInfo(n) { + // Single source of truth for all status-related info + const role = (n.role || '').toLowerCase(); + const roleColor = ROLE_COLORS[n.role] || '#6b7280'; + // 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 statusTooltip = getStatusTooltip(role, status); + const statusLabel = status === 'active' ? '🟢 Active' : '⚪ Stale'; + const statusAge = lastHeardMs ? (Date.now() - lastHeardMs) : Infinity; + + let explanation = ''; + if (status === 'active') { + explanation = 'Last heard ' + (lastHeardTime ? renderNodeTimestampText(lastHeardTime) : 'unknown'); + } else { + const ageDays = Math.floor(statusAge / 86400000); + const ageHours = Math.floor(statusAge / 3600000); + const ageStr = ageDays >= 1 ? ageDays + 'd' : ageHours + 'h'; + const isInfra = role === 'repeater' || role === 'room'; + const reason = isInfra + ? 'repeaters typically advertise every 12-24h' + : 'companions only advertise when user initiates, this may be normal'; + explanation = 'Not heard for ' + ageStr + ' — ' + reason; + } + + return { status, statusLabel, statusTooltip, statusAge, explanation, roleColor, lastHeardMs, role }; +} +``` + +Replace with: + +```js +function getStatusInfo(n) { + // Single source of truth for all status-related info + const role = (n.role || '').toLowerCase(); + const roleColor = ROLE_COLORS[n.role] || '#6b7280'; + // 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 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 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 === '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'; + } else if (status === 'active') { + explanation = 'Last heard ' + (lastHeardTime ? renderNodeTimestampText(lastHeardTime) : 'unknown'); + } else { + const ageDays = Math.floor(statusAge / 86400000); + const ageHours = Math.floor(statusAge / 3600000); + const ageStr = ageDays >= 1 ? ageDays + 'd' : ageHours + 'h'; + const isInfra = role === 'repeater' || role === 'room'; + const reason = isInfra + ? 'repeaters typically advertise every 12-24h' + : 'companions only advertise when user initiates, this may be normal'; + explanation = 'Not heard for ' + ageStr + ' — ' + reason; + } + + return { status, statusLabel, statusTooltip, statusAge, explanation, roleColor, lastHeardMs, role, relayCount1h, relayCount24h, lastRelayed }; +} +``` + +- [ ] **Step 4: Update the node list row CSS class mapping** + +Find the node list rendering (around line 1030–1031): + +```js +const status = getNodeStatus(n.role || 'companion', lastSeenTime ? new Date(lastSeenTime).getTime() : 0); +const lastSeenClass = status === 'active' ? 'last-seen-active' : 'last-seen-stale'; +``` + +Replace with: + +```js +const relayCount24h = (n.stats && typeof n.stats.relay_count_24h === 'number') ? n.stats.relay_count_24h : undefined; +const status = getNodeStatus((n.role || 'companion').toLowerCase(), lastSeenTime ? new Date(lastSeenTime).getTime() : 0, relayCount24h); +const lastSeenClass = status === 'relaying' ? 'last-seen-active' : status === 'active' ? ((n.role || '').toLowerCase() === 'repeater' ? 'last-seen-idle' : 'last-seen-active') : 'last-seen-stale'; +``` + +- [ ] **Step 5: Add relay stats rows to the node detail pane** + +Find the stats table in the detail pane (around line 490–494): + +```js +${stats.avgHops ? `Avg Hops${stats.avgHops}` : ''} +${hasLoc ? `Location...` : ''} +``` + +Add relay rows after `avgHops`: + +```js +${stats.avgHops ? `Avg Hops${stats.avgHops}` : ''} +${si.role === 'repeater' ? ` + Relay (1h)${typeof si.relayCount1h === 'number' ? si.relayCount1h + ' packet' + (si.relayCount1h === 1 ? '' : 's') : '—'} + Relay (24h)${typeof si.relayCount24h === 'number' ? si.relayCount24h + ' packet' + (si.relayCount24h === 1 ? '' : 's') : '—'} + ${si.lastRelayed ? `Last Relayed${renderNodeTimestampHtml(si.lastRelayed)}` : ''} +` : ''} +${hasLoc ? `Location${Number(n.lat).toFixed(5)}, ${Number(n.lon).toFixed(5)}` : ''} +``` + +> Note: `si` is the return value of `getStatusInfo(n)` — already in scope in the detail render function. Verify the local variable name by checking the block around line 453. + +- [ ] **Step 6: Find and check the second detail panel (around line 1075)** + +There is a second node detail render path around line 1072. Apply the same relay rows addition there too — find the equivalent `avgHops` row and add the same relay block after it. + +- [ ] **Step 7: Run frontend tests** + +```bash +node test-repeater-liveness.js && node test-packet-filter.js && node test-frontend-helpers.js && node test-live.js && node test-packets.js 2>&1 +``` + +Expected: all pass. + +- [ ] **Step 8: Run backend tests** + +```bash +cd cmd/server && go test ./... 2>&1 +``` + +Expected: no regressions. + +- [ ] **Step 9: Commit** + +```bash +git add public/nodes.js public/style.css +git commit -m "feat(ui): three-state repeater liveness indicator and relay stats in detail pane (#662)" +``` + +--- + +## Done + +All four tasks complete. Verify end-to-end: + +```bash +# Backend +cd cmd/server && go test ./... -v 2>&1 | tail -20 + +# Frontend +node test-repeater-liveness.js && node test-packet-filter.js && node test-frontend-helpers.js && node test-live.js && node test-packets.js +``` + +Then start the server and open the Nodes page. A repeater with recent relay activity should show 🟢 Relaying; one that is alive but quiet should show 🟡 Idle. From 57dec30d0a7766c0cf367993dca082e2240cd6cf Mon Sep 17 00:00:00 2001 From: efiten Date: Wed, 15 Apr 2026 11:36:24 +0200 Subject: [PATCH 03/21] feat(store): add relayTimes index and relay metrics functions (#662) Co-Authored-By: Claude Sonnet 4.6 --- cmd/server/relay_liveness_test.go | 173 ++++++++++++++++++++++++++++++ cmd/server/store.go | 77 +++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 cmd/server/relay_liveness_test.go diff --git a/cmd/server/relay_liveness_test.go b/cmd/server/relay_liveness_test.go new file mode 100644 index 00000000..860dcf36 --- /dev/null +++ b/cmd/server/relay_liveness_test.go @@ -0,0 +1,173 @@ +package main + +import ( + "sort" + "strings" + "testing" + "time" +) + +func makeRp(s string) *string { return &s } + +func TestAddTxToRelayTimeIndex_SingleNode(t *testing.T) { + idx := make(map[string][]int64) + pk := "aabbccdd11223344" + ts := time.Now().Add(-30 * time.Minute).UTC() + tx := &StoreTx{ + FirstSeen: ts.Format(time.RFC3339), + ResolvedPath: []*string{makeRp(pk)}, + } + addTxToRelayTimeIndex(idx, tx) + if len(idx[pk]) != 1 { + t.Fatalf("expected 1 entry, got %d", len(idx[pk])) + } + wantMs := ts.UnixMilli() + // RFC3339 has second precision, so allow ±1000ms + if diff := idx[pk][0] - wantMs; diff < -1000 || diff > 1000 { + t.Errorf("timestamp mismatch: got %d, want ~%d", idx[pk][0], wantMs) + } +} + +func TestAddTxToRelayTimeIndex_SortedOrder(t *testing.T) { + idx := make(map[string][]int64) + pk := "aabbccdd11223344" + t1 := time.Now().Add(-2 * time.Hour).UTC() + t2 := time.Now().Add(-30 * time.Minute).UTC() + + // Insert newer first, expect sorted ascending + tx2 := &StoreTx{FirstSeen: t2.Format(time.RFC3339), ResolvedPath: []*string{makeRp(pk)}} + tx1 := &StoreTx{FirstSeen: t1.Format(time.RFC3339), ResolvedPath: []*string{makeRp(pk)}} + addTxToRelayTimeIndex(idx, tx2) + addTxToRelayTimeIndex(idx, tx1) + + 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 TestAddTxToRelayTimeIndex_MultipleNodes(t *testing.T) { + idx := make(map[string][]int64) + pk1 := "aabbccdd11223344" + pk2 := "eeff001122334455" + ts := time.Now().Add(-10 * time.Minute).UTC() + tx := &StoreTx{ + FirstSeen: ts.Format(time.RFC3339), + ResolvedPath: []*string{makeRp(pk1), makeRp(pk2)}, + } + addTxToRelayTimeIndex(idx, tx) + 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 TestAddTxToRelayTimeIndex_NilResolvedPath(t *testing.T) { + idx := make(map[string][]int64) + tx := &StoreTx{FirstSeen: time.Now().UTC().Format(time.RFC3339), ResolvedPath: nil} + addTxToRelayTimeIndex(idx, tx) // must not panic + if len(idx) != 0 { + t.Error("expected empty index for nil ResolvedPath") + } +} + +func TestAddTxToRelayTimeIndex_DuplicatePubkeyInPath(t *testing.T) { + idx := make(map[string][]int64) + pk := "aabbccdd11223344" + ts := time.Now().UTC() + tx := &StoreTx{ + FirstSeen: ts.Format(time.RFC3339), + ResolvedPath: []*string{makeRp(pk), makeRp(pk)}, // same pubkey twice + } + addTxToRelayTimeIndex(idx, tx) + if len(idx[pk]) != 1 { + t.Errorf("duplicate pubkey should produce only 1 entry, got %d", len(idx[pk])) + } +} + +func TestRemoveFromRelayTimeIndex_RemovesEntry(t *testing.T) { + idx := make(map[string][]int64) + pk := "aabbccdd11223344" + ts := time.Now().Add(-1 * time.Hour).UTC() + tx := &StoreTx{FirstSeen: ts.Format(time.RFC3339), ResolvedPath: []*string{makeRp(pk)}} + + addTxToRelayTimeIndex(idx, tx) + if len(idx[pk]) != 1 { + t.Fatal("setup: expected 1 entry") + } + removeFromRelayTimeIndex(idx, tx) + if _, ok := idx[pk]; ok { + t.Error("expected key deleted after last entry removed") + } +} + +func TestRemoveFromRelayTimeIndex_PartialRemove(t *testing.T) { + idx := make(map[string][]int64) + pk := "aabbccdd11223344" + t1 := time.Now().Add(-2 * time.Hour).UTC() + t2 := time.Now().Add(-30 * time.Minute).UTC() + tx1 := &StoreTx{FirstSeen: t1.Format(time.RFC3339), ResolvedPath: []*string{makeRp(pk)}} + tx2 := &StoreTx{FirstSeen: t2.Format(time.RFC3339), ResolvedPath: []*string{makeRp(pk)}} + + addTxToRelayTimeIndex(idx, tx1) + addTxToRelayTimeIndex(idx, tx2) + removeFromRelayTimeIndex(idx, tx1) + + 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) + } + if lastRelayed == "" { + t.Error("last_relayed should not be empty") + } +} + +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 TestAddTxToRelayTimeIndex_LowercasesKey(t *testing.T) { + idx := make(map[string][]int64) + pkUpper := "AABBCCDD11223344" + pkLower := strings.ToLower(pkUpper) + ts := time.Now().UTC() + tx := &StoreTx{FirstSeen: ts.Format(time.RFC3339), ResolvedPath: []*string{makeRp(pkUpper)}} + addTxToRelayTimeIndex(idx, tx) + 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") + } +} diff --git a/cmd/server/store.go b/cmd/server/store.go index 496edac1..8b3532b2 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), @@ -2705,6 +2707,81 @@ func addTxToPathHopIndex(idx map[string][]*StoreTx, tx *StoreTx) { } } +// addTxToRelayTimeIndex records the relay timestamp for each resolved pubkey. +// pubkeys is the pre-extracted list (use extractResolvedPubkeys on the decoded path). +// Maintains sorted ascending order for O(log n) window queries. +// Must be called with s.mu held (or during build before store is live). +func addTxToRelayTimeIndex(idx map[string][]int64, firstSeen string, pubkeys []string) { + if len(pubkeys) == 0 { + return + } + ms, err := time.Parse(time.RFC3339, firstSeen) + if err != nil { + return + } + millis := ms.UnixMilli() + seen := make(map[string]bool, len(pubkeys)) + for _, pk := range pubkeys { + pk = strings.ToLower(pk) + if pk == "" || 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 + } + slice = append(slice, 0) + copy(slice[i+1:], slice[i:]) + slice[i] = millis + idx[pk] = slice + } +} + +// removeFromRelayTimeIndex removes the relay timestamp for each resolved pubkey. +// Inverse of addTxToRelayTimeIndex. +func removeFromRelayTimeIndex(idx map[string][]int64, firstSeen string, pubkeys []string) { + if len(pubkeys) == 0 { + return + } + ms, err := time.Parse(time.RFC3339, firstSeen) + if err != nil { + return + } + millis := ms.UnixMilli() + seen := make(map[string]bool, len(pubkeys)) + for _, pk := range pubkeys { + pk = strings.ToLower(pk) + if pk == "" || 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 { + idx[pk] = append(slice[:i], slice[i+1:]...) + if len(idx[pk]) == 0 { + delete(idx, pk) + } + } + } +} + +// 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). +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) { From dc83f3cef16735f32ec5c4645596ecbfbeca0480 Mon Sep 17 00:00:00 2001 From: efiten Date: Wed, 15 Apr 2026 11:49:50 +0200 Subject: [PATCH 04/21] fix(store): safe slice removal in removeFromRelayTimeIndex, strengthen test (#662) --- cmd/server/relay_liveness_test.go | 5 +++-- cmd/server/store.go | 7 +++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/cmd/server/relay_liveness_test.go b/cmd/server/relay_liveness_test.go index 860dcf36..718c84c5 100644 --- a/cmd/server/relay_liveness_test.go +++ b/cmd/server/relay_liveness_test.go @@ -136,8 +136,9 @@ func TestRelayMetrics_Counts(t *testing.T) { if c24h != 3 { t.Errorf("relay_count_24h: expected 3, got %d", c24h) } - if lastRelayed == "" { - t.Error("last_relayed should not be empty") + wantLast := time.UnixMilli(times[2]).UTC().Format(time.RFC3339) + if lastRelayed != wantLast { + t.Errorf("last_relayed: got %q, want %q", lastRelayed, wantLast) } } diff --git a/cmd/server/store.go b/cmd/server/store.go index 8b3532b2..2c7610cb 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -2760,8 +2760,11 @@ func removeFromRelayTimeIndex(idx map[string][]int64, firstSeen string, pubkeys slice := idx[pk] i := sort.Search(len(slice), func(j int) bool { return slice[j] >= millis }) if i < len(slice) && slice[i] == millis { - idx[pk] = append(slice[:i], slice[i+1:]...) - if len(idx[pk]) == 0 { + newSlice := make([]int64, 0, len(slice)-1) + newSlice = append(newSlice, slice[:i]...) + newSlice = append(newSlice, slice[i+1:]...) + idx[pk] = newSlice + if len(newSlice) == 0 { delete(idx, pk) } } From 5bfb7fe10526bddd0888dc7a0ad299d63a6a8f8a Mon Sep 17 00:00:00 2001 From: efiten Date: Wed, 15 Apr 2026 11:51:59 +0200 Subject: [PATCH 05/21] feat(store): wire relayTimes into ingest, evict, and build paths (#662) --- cmd/server/relay_liveness_test.go | 19 +++++++++++++++++++ cmd/server/store.go | 8 +++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/cmd/server/relay_liveness_test.go b/cmd/server/relay_liveness_test.go index 718c84c5..81139401 100644 --- a/cmd/server/relay_liveness_test.go +++ b/cmd/server/relay_liveness_test.go @@ -158,6 +158,25 @@ func TestRelayMetrics_AllOutsideWindow(t *testing.T) { } } +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") + } + // relayTimes will only be populated if test packets have ResolvedPath entries. + // At minimum it must not panic and must be initialised. + if srv.store.relayTimes == nil { + t.Fatal("relayTimes map is nil after load") + } + t.Logf("byPathHop keys: %d, relayTimes keys: %d", hopKeys, relayKeys) +} + func TestAddTxToRelayTimeIndex_LowercasesKey(t *testing.T) { idx := make(map[string][]int64) pkUpper := "AABBCCDD11223344" diff --git a/cmd/server/store.go b/cmd/server/store.go index 2c7610cb..37ee6ec5 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -1668,6 +1668,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac s.spTotalPaths++ } addTxToPathHopIndex(s.byPathHop, tx) + addTxToRelayTimeIndex(s.relayTimes, tx) } // Incrementally update precomputed distance index with new transmissions @@ -2091,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) + removeFromRelayTimeIndex(s.relayTimes, tx) tx.parsedPath, tx.pathParsed = saved, savedFlag } // pickBestObservation already set pathParsed=false so @@ -2099,6 +2101,7 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string] s.spTotalPaths++ } addTxToPathHopIndex(s.byPathHop, tx) + addTxToRelayTimeIndex(s.relayTimes, tx) } } @@ -2684,10 +2687,12 @@ 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) for _, tx := range s.packets { addTxToPathHopIndex(s.byPathHop, tx) + addTxToRelayTimeIndex(s.relayTimes, 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. @@ -3217,6 +3222,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) + removeFromRelayTimeIndex(s.relayTimes, tx) } // Batch-remove from byObserver: single pass per affected observer slice From 119893ccce2f1978fb1d15ef64199dc0f37ba0a7 Mon Sep 17 00:00:00 2001 From: efiten Date: Wed, 15 Apr 2026 12:23:17 +0200 Subject: [PATCH 06/21] test(store): assert relayTimes is populated in wiring integration test (#662) --- cmd/server/relay_liveness_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cmd/server/relay_liveness_test.go b/cmd/server/relay_liveness_test.go index 81139401..b452f3fa 100644 --- a/cmd/server/relay_liveness_test.go +++ b/cmd/server/relay_liveness_test.go @@ -174,6 +174,9 @@ func TestRelayTimesWiredIntoIngest(t *testing.T) { 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) + } t.Logf("byPathHop keys: %d, relayTimes keys: %d", hopKeys, relayKeys) } From b46777de5a7e762b86e1fc48e65f2c414c0b8b17 Mon Sep 17 00:00:00 2001 From: efiten Date: Wed, 15 Apr 2026 12:27:36 +0200 Subject: [PATCH 07/21] feat(api): add relay_count_1h/24h/last_relayed to node health responses (#662) Co-Authored-By: Claude Sonnet 4.6 --- cmd/server/relay_liveness_test.go | 101 ++++++++++++++++++++++++++++++ cmd/server/store.go | 63 +++++++++++++------ 2 files changed, 144 insertions(+), 20 deletions(-) diff --git a/cmd/server/relay_liveness_test.go b/cmd/server/relay_liveness_test.go index b452f3fa..673dcd9b 100644 --- a/cmd/server/relay_liveness_test.go +++ b/cmd/server/relay_liveness_test.go @@ -180,6 +180,107 @@ func TestRelayTimesWiredIntoIngest(t *testing.T) { t.Logf("byPathHop keys: %d, relayTimes keys: %d", hopKeys, relayKeys) } +func TestGetBulkHealthRepeaterRelayFields(t *testing.T) { + srv, _ := setupTestServer(t) + + // Insert a synthetic repeater node into the DB if none exists + _, 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) + } + + // Inject a relay timestamp within the last hour + pk := "relay662test0001" + now := time.Now().UnixMilli() + recentMs := now - 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) + } + + // Give the companion a relay entry (should be ignored by role gate) + 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) + } + + // No entry in relayTimes for this node + 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 TestAddTxToRelayTimeIndex_LowercasesKey(t *testing.T) { idx := make(map[string][]int64) pkUpper := "AABBCCDD11223344" diff --git a/cmd/server/store.go b/cmd/server/store.go index 37ee6ec5..85141798 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -6557,21 +6557,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[n.pk], time.Now().UnixMilli()) + statsMap["relay_count_1h"] = c1h + statsMap["relay_count_24h"] = c24h + if lastRel != "" { + 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, }) } @@ -6708,18 +6717,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 != "" { + 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 } From a83a486742ff7709ac16be3103e3580fa5810aa0 Mon Sep 17 00:00:00 2001 From: efiten Date: Wed, 15 Apr 2026 12:59:02 +0200 Subject: [PATCH 08/21] fix(api): lowercase pubkey for relayTimes lookup in GetBulkHealth, add GetNodeHealth test (#662) --- cmd/server/relay_liveness_test.go | 40 +++++++++++++++++++++++++++++++ cmd/server/store.go | 2 +- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/cmd/server/relay_liveness_test.go b/cmd/server/relay_liveness_test.go index 673dcd9b..b98f00d7 100644 --- a/cmd/server/relay_liveness_test.go +++ b/cmd/server/relay_liveness_test.go @@ -295,3 +295,43 @@ func TestAddTxToRelayTimeIndex_LowercasesKey(t *testing.T) { t.Errorf("expected no entry at uppercase key") } } + +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) + } + + now := time.Now().UnixMilli() + recentMs := now - 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/store.go b/cmd/server/store.go index 85141798..8adada86 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -6566,7 +6566,7 @@ func (s *PacketStore) GetBulkHealth(limit int, region string) []map[string]inter "lastHeard": lhVal, } if strings.ToLower(n.role) == "repeater" { - c1h, c24h, lastRel := relayMetrics(s.relayTimes[n.pk], time.Now().UnixMilli()) + 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 != "" { From e5cfa8ba44a805b3764112ce763bcf836488b79f Mon Sep 17 00:00:00 2001 From: efiten Date: Wed, 15 Apr 2026 13:03:48 +0200 Subject: [PATCH 09/21] feat(frontend): extend getNodeStatus to three-state for repeaters (#662) --- public/roles.js | 12 ++++--- test-repeater-liveness.js | 76 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 test-repeater-liveness.js diff --git a/public/roles.js b/public/roles.js index 27937988..98b377e6 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 staleMs = isInfra ? window.HEALTH_THRESHOLDS.infraSilentMs : window.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/test-repeater-liveness.js b/test-repeater-liveness.js new file mode 100644 index 00000000..9b91d184 --- /dev/null +++ b/test-repeater-liveness.js @@ -0,0 +1,76 @@ +'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); + +// 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); From 9b2ef8d9ad95c587dfdda5529cc23ec76e4bfa0a Mon Sep 17 00:00:00 2001 From: efiten Date: Wed, 15 Apr 2026 13:09:08 +0200 Subject: [PATCH 10/21] feat(ui): three-state repeater liveness indicator and relay stats in detail pane (#662) Co-Authored-By: Claude Sonnet 4.6 --- public/nodes.js | 43 ++++++++++++++++++++++++++++++++++------ public/style.css | 1 + test-frontend-helpers.js | 8 ++++---- 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/public/nodes.js b/public/nodes.js index 5d3355e8..114137b6 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,27 @@ // 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'; + } else if (status === 'active') { explanation = 'Last heard ' + (lastHeardTime ? renderNodeTimestampText(lastHeardTime) : 'unknown'); } else { const ageDays = Math.floor(statusAge / 86400000); @@ -152,7 +172,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) { @@ -512,6 +532,11 @@ 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') : '—'} + Relay (24h)${typeof si.relayCount24h === 'number' ? si.relayCount24h + ' packet' + (si.relayCount24h === 1 ? '' : 's') : '—'} + ${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' : ''} @@ -1128,8 +1153,9 @@ 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 relayCount24h = (n.stats && typeof n.stats.relay_count_24h === 'number') ? n.stats.relay_count_24h : undefined; + const status = getNodeStatus((n.role || 'companion').toLowerCase(), lastSeenTime ? new Date(lastSeenTime).getTime() : 0, relayCount24h); + const lastSeenClass = status === 'relaying' ? 'last-seen-active' : status === 'active' ? ((n.role || '').toLowerCase() === 'repeater' ? 'last-seen-idle' : 'last-seen-active') : 'last-seen-stale'; const cs = _fleetSkew && _fleetSkew[n.public_key]; const skewBadgeHtml = cs && cs.severity && cs.severity !== 'ok' ? renderSkewBadge(cs.severity, window.currentSkewValue(cs), cs) : ''; return ` @@ -1226,6 +1252,11 @@
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') : '—'}
+
Relay (24h)
${typeof si.relayCount24h === 'number' ? si.relayCount24h + ' packet' + (si.relayCount24h === 1 ? '' : 's') : '—'}
+ ${si.lastRelayed ? `
Last Relayed
${renderNodeTimestampHtml(si.lastRelayed)}
` : ''} +` : ''} ${hasLoc ? `
Location
${Number(n.lat).toFixed(5)}, ${Number(n.lon).toFixed(5)}
` : ''} 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-frontend-helpers.js b/test-frontend-helpers.js index af076c29..57ab6448 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(); @@ -4232,9 +4232,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', () => { From 65561d93d3d4c27e34dae38f0892295976a90fdd Mon Sep 17 00:00:00 2001 From: efiten Date: Wed, 15 Apr 2026 13:11:55 +0200 Subject: [PATCH 11/21] test(frontend): add coverage for relaying state in getStatusInfo (#662) --- test-frontend-helpers.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index 57ab6448..c98ea1e4 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -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) { From 48682cc1474aaaa444b9162fa20ce23189989a55 Mon Sep 17 00:00:00 2001 From: efiten Date: Thu, 16 Apr 2026 10:58:32 +0200 Subject: [PATCH 12/21] fix: address PR review remarks (#662) - removeFromRelayTimeIndex: use in-place append instead of extra allocation - roles.js: revert window.HEALTH_THRESHOLDS to bare HEALTH_THRESHOLDS; fix test to expose bare global in VM context - docs: remove plan/spec process artifacts from repo - relayMetrics: document last_relayed behavior for entries older than 24h - addTxToRelayTimeIndex: add comment explaining FirstSeen parse rationale - nodes.js: show last relayed time in idle repeater explanation when available - nodes.js: fix relay stats template indentation in both detail pane blocks - TestRelayTimesWiredIntoIngest: add relayKeys <= hopKeys bounds assertion Co-Authored-By: Claude Sonnet 4.6 --- cmd/server/relay_liveness_test.go | 3 + cmd/server/store.go | 9 +- .../plans/2026-04-15-repeater-liveness.md | 1059 ----------------- .../2026-04-15-repeater-liveness-design.md | 163 --- public/nodes.js | 17 +- public/roles.js | 2 +- test-repeater-liveness.js | 3 + 7 files changed, 19 insertions(+), 1237 deletions(-) delete mode 100644 docs/superpowers/plans/2026-04-15-repeater-liveness.md delete mode 100644 docs/superpowers/specs/2026-04-15-repeater-liveness-design.md diff --git a/cmd/server/relay_liveness_test.go b/cmd/server/relay_liveness_test.go index b98f00d7..95f999d4 100644 --- a/cmd/server/relay_liveness_test.go +++ b/cmd/server/relay_liveness_test.go @@ -177,6 +177,9 @@ func TestRelayTimesWiredIntoIngest(t *testing.T) { 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) } diff --git a/cmd/server/store.go b/cmd/server/store.go index 8adada86..011771bc 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -2765,11 +2765,8 @@ func removeFromRelayTimeIndex(idx map[string][]int64, firstSeen string, pubkeys slice := idx[pk] i := sort.Search(len(slice), func(j int) bool { return slice[j] >= millis }) if i < len(slice) && slice[i] == millis { - newSlice := make([]int64, 0, len(slice)-1) - newSlice = append(newSlice, slice[:i]...) - newSlice = append(newSlice, slice[i+1:]...) - idx[pk] = newSlice - if len(newSlice) == 0 { + idx[pk] = append(slice[:i], slice[i+1:]...) + if len(idx[pk]) == 0 { delete(idx, pk) } } @@ -2778,6 +2775,8 @@ func removeFromRelayTimeIndex(idx map[string][]int64, firstSeen string, pubkeys // 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, "" diff --git a/docs/superpowers/plans/2026-04-15-repeater-liveness.md b/docs/superpowers/plans/2026-04-15-repeater-liveness.md deleted file mode 100644 index 53badc61..00000000 --- a/docs/superpowers/plans/2026-04-15-repeater-liveness.md +++ /dev/null @@ -1,1059 +0,0 @@ -# Repeater Liveness Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Distinguish repeaters that are actively relaying traffic from those that are alive but idle, using a precomputed O(log n) sorted-timestamp index. - -**Architecture:** Add `relayTimes map[string][]int64` to `PacketStore`, maintained in lockstep with `byPathHop`. Relay counts for 1h/24h windows are computed via binary search at query time and injected into the existing `/api/nodes/bulk-health` and `/api/nodes/{pubkey}/health` responses. Frontend extends `getNodeStatus` to return a third state (`'relaying'`) for repeaters with recent relay activity. - -**Tech Stack:** Go (backend), vanilla JS (frontend), Node.js (frontend tests), SQLite (no schema changes needed) - ---- - -## File Map - -| File | Action | What changes | -|---|---|---| -| `cmd/server/store.go` | Modify | Add `relayTimes` field; add `addTxToRelayTimeIndex`, `removeFromRelayTimeIndex`, `relayMetrics` functions; wire into `buildPathHopIndex`, `addTxToPathHopIndex`, `removeTxFromPathHopIndex`; enrich `GetBulkHealth` and `GetNodeHealth` | -| `cmd/server/relay_liveness_test.go` | Create | Go unit + integration tests for all relay index functions and API enrichment | -| `public/roles.js` | Modify | Extend `getNodeStatus` with optional third param `relayCount24h`; add `'relaying'` return value | -| `public/nodes.js` | Modify | Update `getStatusInfo`, `getStatusTooltip`, node list row, node detail pane stats table | -| `public/style.css` | Modify | Add `.last-seen-idle` CSS class | -| `test-repeater-liveness.js` | Create | Frontend unit tests for the three-state logic | - ---- - -## Task 1: Backend — `relayTimes` index field and pure functions - -**Files:** -- Modify: `cmd/server/store.go` -- Create: `cmd/server/relay_liveness_test.go` - -- [ ] **Step 1: Write failing tests for the pure index functions** - -Create `cmd/server/relay_liveness_test.go`: - -```go -package main - -import ( - "sort" - "strings" - "testing" - "time" -) - -func makeRp(s string) *string { return &s } - -func TestAddTxToRelayTimeIndex_SingleNode(t *testing.T) { - idx := make(map[string][]int64) - pk := "aabbccdd11223344" - ts := time.Now().Add(-30 * time.Minute).UTC() - tx := &StoreTx{ - FirstSeen: ts.Format(time.RFC3339), - ResolvedPath: []*string{makeRp(pk)}, - } - addTxToRelayTimeIndex(idx, tx) - if len(idx[pk]) != 1 { - t.Fatalf("expected 1 entry, got %d", len(idx[pk])) - } - wantMs := ts.UnixMilli() - // RFC3339 has second precision, so allow ±1000ms - if diff := idx[pk][0] - wantMs; diff < -1000 || diff > 1000 { - t.Errorf("timestamp mismatch: got %d, want ~%d", idx[pk][0], wantMs) - } -} - -func TestAddTxToRelayTimeIndex_SortedOrder(t *testing.T) { - idx := make(map[string][]int64) - pk := "aabbccdd11223344" - t1 := time.Now().Add(-2 * time.Hour).UTC() - t2 := time.Now().Add(-30 * time.Minute).UTC() - - // Insert newer first, expect sorted ascending - tx2 := &StoreTx{FirstSeen: t2.Format(time.RFC3339), ResolvedPath: []*string{makeRp(pk)}} - tx1 := &StoreTx{FirstSeen: t1.Format(time.RFC3339), ResolvedPath: []*string{makeRp(pk)}} - addTxToRelayTimeIndex(idx, tx2) - addTxToRelayTimeIndex(idx, tx1) - - 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 TestAddTxToRelayTimeIndex_MultipleNodes(t *testing.T) { - idx := make(map[string][]int64) - pk1 := "aabbccdd11223344" - pk2 := "eeff001122334455" - ts := time.Now().Add(-10 * time.Minute).UTC() - tx := &StoreTx{ - FirstSeen: ts.Format(time.RFC3339), - ResolvedPath: []*string{makeRp(pk1), makeRp(pk2)}, - } - addTxToRelayTimeIndex(idx, tx) - 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 TestAddTxToRelayTimeIndex_NilResolvedPath(t *testing.T) { - idx := make(map[string][]int64) - tx := &StoreTx{FirstSeen: time.Now().UTC().Format(time.RFC3339), ResolvedPath: nil} - addTxToRelayTimeIndex(idx, tx) // must not panic - if len(idx) != 0 { - t.Error("expected empty index for nil ResolvedPath") - } -} - -func TestAddTxToRelayTimeIndex_DuplicatePubkeyInPath(t *testing.T) { - idx := make(map[string][]int64) - pk := "aabbccdd11223344" - ts := time.Now().UTC() - tx := &StoreTx{ - FirstSeen: ts.Format(time.RFC3339), - ResolvedPath: []*string{makeRp(pk), makeRp(pk)}, // same pubkey twice - } - addTxToRelayTimeIndex(idx, tx) - if len(idx[pk]) != 1 { - t.Errorf("duplicate pubkey should produce only 1 entry, got %d", len(idx[pk])) - } -} - -func TestRemoveFromRelayTimeIndex_RemovesEntry(t *testing.T) { - idx := make(map[string][]int64) - pk := "aabbccdd11223344" - ts := time.Now().Add(-1 * time.Hour).UTC() - tx := &StoreTx{FirstSeen: ts.Format(time.RFC3339), ResolvedPath: []*string{makeRp(pk)}} - - addTxToRelayTimeIndex(idx, tx) - if len(idx[pk]) != 1 { - t.Fatal("setup: expected 1 entry") - } - removeFromRelayTimeIndex(idx, tx) - if _, ok := idx[pk]; ok { - t.Error("expected key deleted after last entry removed") - } -} - -func TestRemoveFromRelayTimeIndex_PartialRemove(t *testing.T) { - idx := make(map[string][]int64) - pk := "aabbccdd11223344" - t1 := time.Now().Add(-2 * time.Hour).UTC() - t2 := time.Now().Add(-30 * time.Minute).UTC() - tx1 := &StoreTx{FirstSeen: t1.Format(time.RFC3339), ResolvedPath: []*string{makeRp(pk)}} - tx2 := &StoreTx{FirstSeen: t2.Format(time.RFC3339), ResolvedPath: []*string{makeRp(pk)}} - - addTxToRelayTimeIndex(idx, tx1) - addTxToRelayTimeIndex(idx, tx2) - removeFromRelayTimeIndex(idx, tx1) - - 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) - } - if lastRelayed == "" { - t.Error("last_relayed should not be empty") - } -} - -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 TestAddTxToRelayTimeIndex_LowercasesKey(t *testing.T) { - idx := make(map[string][]int64) - pkUpper := "AABBCCDD11223344" - pkLower := strings.ToLower(pkUpper) - ts := time.Now().UTC() - tx := &StoreTx{FirstSeen: ts.Format(time.RFC3339), ResolvedPath: []*string{makeRp(pkUpper)}} - addTxToRelayTimeIndex(idx, tx) - 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") - } -} -``` - -- [ ] **Step 2: Run tests to confirm they all fail** - -```bash -cd cmd/server && go test -run "TestAddTxToRelayTimeIndex|TestRemoveFromRelayTimeIndex|TestRelayMetrics" ./... 2>&1 -``` - -Expected: compile error — `addTxToRelayTimeIndex`, `removeFromRelayTimeIndex`, `relayMetrics` undefined. - -- [ ] **Step 3: Add `relayTimes` field to `PacketStore` struct** - -In `cmd/server/store.go`, find the struct field block around line 134 that has `byPathHop`: - -```go -byPathHop map[string][]*StoreTx // lowercase hop/pubkey → transmissions with that hop in path -``` - -Add after it: - -```go -relayTimes map[string][]int64 // lowercase pubkey → sorted unix-millis of relay events (full pubkeys only) -``` - -- [ ] **Step 4: Initialize `relayTimes` in `newPacketStore`** - -Find the block around line 289 that initializes `byPathHop`: - -```go -byPathHop: make(map[string][]*StoreTx), -``` - -Add after it: - -```go -relayTimes: make(map[string][]int64), -``` - -- [ ] **Step 5: Implement `addTxToRelayTimeIndex`, `removeFromRelayTimeIndex`, `relayMetrics`** - -Add these three functions to `cmd/server/store.go` directly after the `addTxToPathHopIndex` function (around line 2452): - -```go -// addTxToRelayTimeIndex records the relay timestamp for each full pubkey in -// tx.ResolvedPath. Maintains sorted ascending order for O(log n) window queries. -// Must be called with s.mu held (or during build before store is live). -func addTxToRelayTimeIndex(idx map[string][]int64, tx *StoreTx) { - if len(tx.ResolvedPath) == 0 { - return - } - ms, err := time.Parse(time.RFC3339, tx.FirstSeen) - if err != nil { - return - } - millis := ms.UnixMilli() - seen := make(map[string]bool, len(tx.ResolvedPath)) - for _, rp := range tx.ResolvedPath { - 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 }) - slice = append(slice, 0) - copy(slice[i+1:], slice[i:]) - slice[i] = millis - idx[pk] = slice - } -} - -// removeFromRelayTimeIndex removes the relay timestamp for each full pubkey in -// tx.ResolvedPath. Inverse of addTxToRelayTimeIndex. -func removeFromRelayTimeIndex(idx map[string][]int64, tx *StoreTx) { - if len(tx.ResolvedPath) == 0 { - return - } - ms, err := time.Parse(time.RFC3339, tx.FirstSeen) - if err != nil { - return - } - millis := ms.UnixMilli() - seen := make(map[string]bool, len(tx.ResolvedPath)) - for _, rp := range tx.ResolvedPath { - 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 { - idx[pk] = append(slice[:i], slice[i+1:]...) - if len(idx[pk]) == 0 { - delete(idx, pk) - } - } - } -} - -// 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). -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 -} -``` - -- [ ] **Step 6: Run tests — expect pass** - -```bash -cd cmd/server && go test -run "TestAddTxToRelayTimeIndex|TestRemoveFromRelayTimeIndex|TestRelayMetrics" ./... -v 2>&1 -``` - -Expected: all 10 tests PASS. - -- [ ] **Step 7: Commit** - -```bash -git add cmd/server/store.go cmd/server/relay_liveness_test.go -git commit -m "feat(store): add relayTimes index and relay metrics functions (#662)" -``` - ---- - -## Task 2: Backend — wire `relayTimes` into ingest/evict/build paths - -**Files:** -- Modify: `cmd/server/store.go` - -- [ ] **Step 1: Wire into `addTxToPathHopIndex`** - -Find the function `addTxToPathHopIndex` (around line 2432). It ends with the closing `}`. Add a call to `addTxToRelayTimeIndex` — but `addTxToPathHopIndex` takes `idx map[string][]*StoreTx`, not the store itself. The relay index needs to be passed separately. - -The three call sites of `addTxToPathHopIndex` all hold `s.mu`. Change each call site instead of the function signature — add a paired call right after each `addTxToPathHopIndex`: - -**Site 1** — ingest (around line 1485): -```go -addTxToPathHopIndex(s.byPathHop, tx) -addTxToRelayTimeIndex(s.relayTimes, tx) -``` - -**Site 2** — path resolution update (around line 1871): -```go -addTxToPathHopIndex(s.byPathHop, tx) -addTxToRelayTimeIndex(s.relayTimes, tx) -``` - -- [ ] **Step 2: Wire into `removeTxFromPathHopIndex` call sites** - -Find `removeTxFromPathHopIndex(s.byPathHop, tx)` (two call sites — eviction around line 2793 and path-reindex around line 1862). Add paired remove call after each: - -**Site 1** — eviction (around line 2793): -```go -removeTxFromPathHopIndex(s.byPathHop, tx) -removeFromRelayTimeIndex(s.relayTimes, tx) -``` - -**Site 2** — path reindex (around line 1862): -```go -removeTxFromPathHopIndex(s.byPathHop, tx) -removeFromRelayTimeIndex(s.relayTimes, tx) -``` - -- [ ] **Step 3: Wire into `buildPathHopIndex`** - -Find `buildPathHopIndex` (around line 2422). It currently does: - -```go -func (s *PacketStore) buildPathHopIndex() { - s.byPathHop = make(map[string][]*StoreTx, 4096) - for _, tx := range s.packets { - addTxToPathHopIndex(s.byPathHop, tx) - } - log.Printf("[store] Built path-hop index: %d unique keys", len(s.byPathHop)) -} -``` - -Replace with: - -```go -func (s *PacketStore) buildPathHopIndex() { - s.byPathHop = make(map[string][]*StoreTx, 4096) - s.relayTimes = make(map[string][]int64, 4096) - for _, tx := range s.packets { - addTxToPathHopIndex(s.byPathHop, tx) - addTxToRelayTimeIndex(s.relayTimes, tx) - } - log.Printf("[store] Built path-hop index: %d unique keys, %d relay-time keys", len(s.byPathHop), len(s.relayTimes)) -} -``` - -- [ ] **Step 4: Write integration test for wired ingest** - -Add to `cmd/server/relay_liveness_test.go`: - -```go -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") - } - // relayTimes will only be populated if test packets have ResolvedPath entries. - // At minimum it must not panic and must be initialised. - if srv.store.relayTimes == nil { - t.Fatal("relayTimes map is nil after load") - } - t.Logf("byPathHop keys: %d, relayTimes keys: %d", hopKeys, relayKeys) -} -``` - -- [ ] **Step 5: Run all relay tests** - -```bash -cd cmd/server && go test -run "TestAddTxToRelayTimeIndex|TestRemoveFromRelayTimeIndex|TestRelayMetrics|TestRelayTimesWired" ./... -v 2>&1 -``` - -Expected: all pass. - -- [ ] **Step 6: Run full backend test suite** - -```bash -cd cmd/server && go test ./... 2>&1 -``` - -Expected: no regressions. - -- [ ] **Step 7: Commit** - -```bash -git add cmd/server/store.go cmd/server/relay_liveness_test.go -git commit -m "feat(store): wire relayTimes into ingest, evict, and build paths (#662)" -``` - ---- - -## Task 3: Backend API — enrich bulk-health and node-health with relay metrics - -**Files:** -- Modify: `cmd/server/store.go` - -- [ ] **Step 1: Write failing tests for relay fields in API responses** - -Add to `cmd/server/relay_liveness_test.go`: - -```go -func TestGetBulkHealthRepeaterRelayFields(t *testing.T) { - srv, _ := setupTestServer(t) - - // Insert a synthetic repeater node into the DB if none exists - _, 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) - } - - // Inject a relay timestamp within the last hour - pk := "relay662test0001" - now := time.Now().UnixMilli() - recentMs := now - 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) - } - - // Give the companion a relay entry (should be ignored by role gate) - 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) - } - - // No entry in relayTimes for this node - 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") -} -``` - -- [ ] **Step 2: Run tests to confirm they fail** - -```bash -cd cmd/server && go test -run "TestGetBulkHealth" ./... 2>&1 -``` - -Expected: FAIL — relay fields missing from stats map. - -- [ ] **Step 3: Enrich `GetBulkHealth` with relay metrics** - -In `GetBulkHealth` (around line 5945), find where the `stats` map is built (around line 6099–6106): - -```go -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, -}) -``` - -Replace the `stats` map construction with: - -```go -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[n.pk], time.Now().UnixMilli()) - statsMap["relay_count_1h"] = c1h - statsMap["relay_count_24h"] = c24h - if lastRel != "" { - 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": statsMap, - "observers": observerRows, -}) -``` - -- [ ] **Step 4: Enrich `GetNodeHealth` with relay metrics** - -In `GetNodeHealth` (around line 6117), find where the final result map is assembled (look for `"lastHeard"` key in the stats sub-map — it's around line 6230–6260 depending on exact file state). The stats map will have a shape like: - -```go -"stats": map[string]interface{}{ - "totalTransmissions": ..., - "totalObservations": ..., - "totalPackets": ..., - "packetsToday": ..., - "avgSnr": ..., - "lastHeard": lastHeardVal, - "avgHops": ..., -}, -``` - -After assembling that `stats` map (name it `nodeStats` if it isn't already named), add before the final `return`: - -```go -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 != "" { - nodeStats["last_relayed"] = lastRel - } -} -``` - -> Note: `GetNodeHealth` releases `s.mu` via `defer s.mu.RUnlock()` at entry — relay metrics are computed inside the lock, which is correct. - -- [ ] **Step 5: Run all relay tests** - -```bash -cd cmd/server && go test -run "TestGetBulkHealth|TestAddTxToRelayTimeIndex|TestRemoveFromRelayTimeIndex|TestRelayMetrics|TestRelayTimesWired" ./... -v 2>&1 -``` - -Expected: all pass. - -- [ ] **Step 6: Run full backend test suite** - -```bash -cd cmd/server && go test ./... 2>&1 -``` - -Expected: no regressions. - -- [ ] **Step 7: Commit** - -```bash -git add cmd/server/store.go cmd/server/relay_liveness_test.go -git commit -m "feat(api): add relay_count_1h/24h/last_relayed to node health responses (#662)" -``` - ---- - -## Task 4: Frontend — extend `getNodeStatus` to three-state (TDD) - -**Files:** -- Modify: `public/roles.js` -- Create: `test-repeater-liveness.js` - -- [ ] **Step 1: Write the failing frontend test file** - -Create `test-repeater-liveness.js` at the repo root: - -```js -'use strict'; -const vm = require('vm'); -const fs = require('fs'); - -// Minimal browser environment -const ctx = { - window: {}, - console, - fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }), - Date, -}; -vm.createContext(ctx); -vm.runInContext(fs.readFileSync('public/roles.js', 'utf8'), ctx); - -const { getNodeStatus, HEALTH_THRESHOLDS } = ctx.window; - -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 - (HEALTH_THRESHOLDS.infraSilentMs + 1); // just past silent threshold - -// --- Repeater three-state --- -test('repeater + recent + relay > 0 → relaying', - () => assert(getNodeStatus('repeater', recentMs, 5) === 'relaying')); - -test('repeater + recent + relay == 0 → active (idle)', - () => assert(getNodeStatus('repeater', recentMs, 0) === 'active')); - -test('repeater + stale + relay > 0 → stale (stale beats relay)', - () => assert(getNodeStatus('repeater', staleMs, 99) === 'stale')); - -test('repeater + stale + relay == 0 → stale', - () => assert(getNodeStatus('repeater', staleMs, 0) === 'stale')); - -// --- Non-repeater roles unaffected --- -test('companion + recent + relay 0 → active', - () => assert(getNodeStatus('companion', recentMs, 0) === 'active')); - -test('companion + recent + relay > 0 → active (relay ignored)', - () => assert(getNodeStatus('companion', recentMs, 99) === 'active')); - -test('room + recent + relay 0 → active', - () => assert(getNodeStatus('room', recentMs, 0) === 'active')); - -test('sensor + recent + relay 0 → active', - () => assert(getNodeStatus('sensor', recentMs, 0) === 'active')); - -// --- Backward compatibility: omitting third arg --- -test('getNodeStatus(repeater, recent) with no relay arg → active (not relaying)', - () => assert(getNodeStatus('repeater', recentMs) === 'active')); - -test('getNodeStatus(companion, recent) with no relay arg → active', - () => assert(getNodeStatus('companion', recentMs) === 'active')); - -console.log(`\n${pass} passed, ${fail} failed`); -if (fail > 0) process.exit(1); -``` - -- [ ] **Step 2: Run tests to confirm they fail** - -```bash -node test-repeater-liveness.js 2>&1 -``` - -Expected: FAIL on `repeater + recent + relay > 0 → relaying` (returns `'active'` instead of `'relaying'`). - -- [ ] **Step 3: Extend `getNodeStatus` in `public/roles.js`** - -Find the existing function (around line 88): - -```js -window.getNodeStatus = function (role, lastSeenMs) { - 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'; -}; -``` - -Replace with: - -```js -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; - if (age >= staleMs) return 'stale'; - if (role === 'repeater') { - return (typeof relayCount24h === 'number' && relayCount24h > 0) ? 'relaying' : 'active'; - } - return 'active'; -}; -``` - -- [ ] **Step 4: Run tests to confirm they pass** - -```bash -node test-repeater-liveness.js 2>&1 -``` - -Expected: all 10 tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add public/roles.js test-repeater-liveness.js -git commit -m "feat(frontend): extend getNodeStatus to three-state for repeaters (#662)" -``` - ---- - -## Task 5: Frontend — render three-state labels, CSS, and node detail pane - -**Files:** -- Modify: `public/nodes.js` -- Modify: `public/style.css` - -- [ ] **Step 1: Add `.last-seen-idle` CSS class to `public/style.css`** - -Find these two lines (around line 1590–1591): - -```css -.last-seen-active { color: var(--status-green); } -.last-seen-stale { color: var(--text-muted); } -``` - -Add after them: - -```css -.last-seen-idle { color: var(--status-yellow); } -``` - -- [ ] **Step 2: Update `getStatusTooltip` in `public/nodes.js`** - -Find the function (around line 113): - -```js -function getStatusTooltip(role, status) { - 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 === 'active') { - return 'Active \u2014 heard within the last ' + threshold + '.' + (isInfra ? ' Repeaters typically advertise every 12-24h.' : ''); - } - if (role === 'companion') { - return 'Stale \u2014 not heard for over ' + threshold + '. Companions only advertise when the user initiates \u2014 this may be normal.'; - } - if (role === 'sensor') { - return 'Stale \u2014 not heard for over ' + threshold + '. This sensor may be offline.'; - } - return 'Stale \u2014 not heard for over ' + threshold + '. This ' + role + ' may be offline or out of range.'; -} -``` - -Replace with: - -```js -function getStatusTooltip(role, status) { - 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') { - return 'Stale \u2014 not heard for over ' + threshold + '. Companions only advertise when the user initiates \u2014 this may be normal.'; - } - if (role === 'sensor') { - return 'Stale \u2014 not heard for over ' + threshold + '. This sensor may be offline.'; - } - return 'Stale \u2014 not heard for over ' + threshold + '. This ' + role + ' may be offline or out of range.'; -} -``` - -- [ ] **Step 3: Update `getStatusInfo` in `public/nodes.js`** - -Find the function (around line 129): - -```js -function getStatusInfo(n) { - // Single source of truth for all status-related info - const role = (n.role || '').toLowerCase(); - const roleColor = ROLE_COLORS[n.role] || '#6b7280'; - // 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 statusTooltip = getStatusTooltip(role, status); - const statusLabel = status === 'active' ? '🟢 Active' : '⚪ Stale'; - const statusAge = lastHeardMs ? (Date.now() - lastHeardMs) : Infinity; - - let explanation = ''; - if (status === 'active') { - explanation = 'Last heard ' + (lastHeardTime ? renderNodeTimestampText(lastHeardTime) : 'unknown'); - } else { - const ageDays = Math.floor(statusAge / 86400000); - const ageHours = Math.floor(statusAge / 3600000); - const ageStr = ageDays >= 1 ? ageDays + 'd' : ageHours + 'h'; - const isInfra = role === 'repeater' || role === 'room'; - const reason = isInfra - ? 'repeaters typically advertise every 12-24h' - : 'companions only advertise when user initiates, this may be normal'; - explanation = 'Not heard for ' + ageStr + ' — ' + reason; - } - - return { status, statusLabel, statusTooltip, statusAge, explanation, roleColor, lastHeardMs, role }; -} -``` - -Replace with: - -```js -function getStatusInfo(n) { - // Single source of truth for all status-related info - const role = (n.role || '').toLowerCase(); - const roleColor = ROLE_COLORS[n.role] || '#6b7280'; - // 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 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 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 === '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'; - } else if (status === 'active') { - explanation = 'Last heard ' + (lastHeardTime ? renderNodeTimestampText(lastHeardTime) : 'unknown'); - } else { - const ageDays = Math.floor(statusAge / 86400000); - const ageHours = Math.floor(statusAge / 3600000); - const ageStr = ageDays >= 1 ? ageDays + 'd' : ageHours + 'h'; - const isInfra = role === 'repeater' || role === 'room'; - const reason = isInfra - ? 'repeaters typically advertise every 12-24h' - : 'companions only advertise when user initiates, this may be normal'; - explanation = 'Not heard for ' + ageStr + ' — ' + reason; - } - - return { status, statusLabel, statusTooltip, statusAge, explanation, roleColor, lastHeardMs, role, relayCount1h, relayCount24h, lastRelayed }; -} -``` - -- [ ] **Step 4: Update the node list row CSS class mapping** - -Find the node list rendering (around line 1030–1031): - -```js -const status = getNodeStatus(n.role || 'companion', lastSeenTime ? new Date(lastSeenTime).getTime() : 0); -const lastSeenClass = status === 'active' ? 'last-seen-active' : 'last-seen-stale'; -``` - -Replace with: - -```js -const relayCount24h = (n.stats && typeof n.stats.relay_count_24h === 'number') ? n.stats.relay_count_24h : undefined; -const status = getNodeStatus((n.role || 'companion').toLowerCase(), lastSeenTime ? new Date(lastSeenTime).getTime() : 0, relayCount24h); -const lastSeenClass = status === 'relaying' ? 'last-seen-active' : status === 'active' ? ((n.role || '').toLowerCase() === 'repeater' ? 'last-seen-idle' : 'last-seen-active') : 'last-seen-stale'; -``` - -- [ ] **Step 5: Add relay stats rows to the node detail pane** - -Find the stats table in the detail pane (around line 490–494): - -```js -${stats.avgHops ? `Avg Hops${stats.avgHops}` : ''} -${hasLoc ? `Location...` : ''} -``` - -Add relay rows after `avgHops`: - -```js -${stats.avgHops ? `Avg Hops${stats.avgHops}` : ''} -${si.role === 'repeater' ? ` - Relay (1h)${typeof si.relayCount1h === 'number' ? si.relayCount1h + ' packet' + (si.relayCount1h === 1 ? '' : 's') : '—'} - Relay (24h)${typeof si.relayCount24h === 'number' ? si.relayCount24h + ' packet' + (si.relayCount24h === 1 ? '' : 's') : '—'} - ${si.lastRelayed ? `Last Relayed${renderNodeTimestampHtml(si.lastRelayed)}` : ''} -` : ''} -${hasLoc ? `Location${Number(n.lat).toFixed(5)}, ${Number(n.lon).toFixed(5)}` : ''} -``` - -> Note: `si` is the return value of `getStatusInfo(n)` — already in scope in the detail render function. Verify the local variable name by checking the block around line 453. - -- [ ] **Step 6: Find and check the second detail panel (around line 1075)** - -There is a second node detail render path around line 1072. Apply the same relay rows addition there too — find the equivalent `avgHops` row and add the same relay block after it. - -- [ ] **Step 7: Run frontend tests** - -```bash -node test-repeater-liveness.js && node test-packet-filter.js && node test-frontend-helpers.js && node test-live.js && node test-packets.js 2>&1 -``` - -Expected: all pass. - -- [ ] **Step 8: Run backend tests** - -```bash -cd cmd/server && go test ./... 2>&1 -``` - -Expected: no regressions. - -- [ ] **Step 9: Commit** - -```bash -git add public/nodes.js public/style.css -git commit -m "feat(ui): three-state repeater liveness indicator and relay stats in detail pane (#662)" -``` - ---- - -## Done - -All four tasks complete. Verify end-to-end: - -```bash -# Backend -cd cmd/server && go test ./... -v 2>&1 | tail -20 - -# Frontend -node test-repeater-liveness.js && node test-packet-filter.js && node test-frontend-helpers.js && node test-live.js && node test-packets.js -``` - -Then start the server and open the Nodes page. A repeater with recent relay activity should show 🟢 Relaying; one that is alive but quiet should show 🟡 Idle. diff --git a/docs/superpowers/specs/2026-04-15-repeater-liveness-design.md b/docs/superpowers/specs/2026-04-15-repeater-liveness-design.md deleted file mode 100644 index d9cde1d2..00000000 --- a/docs/superpowers/specs/2026-04-15-repeater-liveness-design.md +++ /dev/null @@ -1,163 +0,0 @@ -# Repeater Liveness — Design Spec - -**Issue:** Kpa-clawbot/CoreScope#662 -**Date:** 2026-04-15 -**Status:** Approved - ---- - -## Problem - -CoreScope conflates two distinct repeater states into "active": - -| State | Adverts | In paths | Meaning | -|---|---|---|---| -| Relaying | Recent | Yes | Up and forwarding traffic | -| Alive but idle | Recent | No | Up, but nothing routed through it | -| Down | None | None | Offline | - -An operator cannot tell whether a repeater is healthy and carrying traffic, or healthy but not actually relaying anything. - ---- - -## Scope - -M1 (backend relay metrics) + M2 (frontend three-state indicator). M3 (repeater dashboard) is out of scope for this implementation. - ---- - -## Architecture - -### Data Structure (Backend) - -Add a parallel index to `PacketStore` in `store.go`: - -```go -relayTimes map[string][]int64 // lowercase pubkey → sorted []int64 unix-millis -``` - -- Indexed by **full pubkeys only** (from `tx.ResolvedPath`) — not raw hop prefixes, avoiding hash-collision noise -- Maintained under the existing `s.mu` RWMutex — no new lock needed -- Lives parallel to `byPathHop`, sharing the same add/remove call sites - -### Index Maintenance - -**On add** — called from `addTxToPathHopIndex`: -- For each non-nil entry in `tx.ResolvedPath` -- Parse `tx.FirstSeen` → unix millis -- Binary-search insert into sorted slice - -**On remove** — called from `removeTxFromPathHopIndex`: -- For each non-nil entry in `tx.ResolvedPath` -- Remove the millis value from the sorted slice - -**On build** — called from `buildPathHopIndex`: -- Populate `relayTimes` in the same pass - -### Query (O(log n)) - -```go -now := time.Now().UnixMilli() -times := s.relayTimes[lowerPK] -count1h := len(times) - sort.Search(len(times), func(i int) bool { return times[i] >= now-3600000 }) -count24h := len(times) - sort.Search(len(times), func(i int) bool { return times[i] >= now-86400000 }) -lastRelayed := times[len(times)-1] // free — last element of sorted slice -``` - ---- - -## API - -No new endpoints. Relay metrics are added to existing health responses. - -### `GET /api/nodes/bulk-health` and `GET /api/nodes/{pubkey}/health` - -Added to the `stats` sub-object, **repeater nodes only** (fields absent for other roles): - -```json -{ - "stats": { - "lastHeard": "2026-04-15T10:00:00Z", - "packetsToday": 12, - "relay_count_1h": 3, - "relay_count_24h": 47, - "last_relayed": "2026-04-15T09:58:00Z" - } -} -``` - -`last_relayed` is an RFC3339 string (consistent with existing timestamp fields). Omitted when `relayTimes[pk]` is empty. - ---- - -## Frontend - -### `roles.js` — extend `getNodeStatus` - -```js -// Signature change: -// Before: getNodeStatus(role, lastSeenMs) → 'active' | 'stale' -// After: getNodeStatus(role, lastSeenMs, relayCount24h) → 'relaying' | 'active' | 'stale' -``` - -Logic: -- Non-repeaters: `relayCount24h` ignored, returns `'active'` or `'stale'` as before — **no behaviour change** -- Repeaters: - - Stale threshold exceeded → `'stale'` - - Within threshold + `relayCount24h > 0` → `'relaying'` - - Within threshold + `relayCount24h == 0` → `'active'` (alive but idle) - -### `nodes.js` — `getStatusInfo` and render - -- Extract `relay_count_24h`, `relay_count_1h`, `last_relayed` from `n.stats` -- Pass `relay_count_24h` into `getNodeStatus` - -**Status labels (repeaters):** - -| Status | Label | Explanation | -|---|---|---| -| `'relaying'` | `🟢 Relaying` | `"Relayed N packets in last 24h, last X ago"` | -| `'active'` | `🟡 Idle` | `"Alive but no relay traffic in 24h"` | -| `'stale'` | `⚪ Stale` | existing text | - -**Non-repeaters:** labels unchanged (`🟢 Active` / `⚪ Stale`). - -**Node detail pane:** relay stats row added to the stats table for repeaters: -- `Relay (1h)` / `Relay (24h)` / `Last Relayed` -- Row hidden for non-repeater roles - -**Status filter buttons:** `'relaying'` maps to the `active` bucket — filter UI unchanged. - ---- - -## Testing - -### Backend (Go) — new test cases in `store_test.go` or dedicated file - -- `addTxToRelayTimeIndex`: insert packets with known timestamps → verify sorted order -- Count at 1h/24h boundaries: packets straddling the window edge → correct counts -- `removeFromRelayTimeIndex`: add then remove → slice returns to original state -- `GetBulkHealth` relay fields: repeater with relay activity → fields present; companion → fields absent -- Eviction: add packets, evict oldest → relay_count_24h drops correctly -- `last_relayed`: equals timestamp of most recently relayed packet -- Empty `relayTimes`: no panic, fields omitted from response -- Node with no pubkeys in `ResolvedPath`: `relayTimes` unchanged (raw hops ignored) - -### Frontend (Node.js) — `test-repeater-liveness.js` - -- `getNodeStatus('repeater', recentMs, 5)` → `'relaying'` -- `getNodeStatus('repeater', recentMs, 0)` → `'active'` -- `getNodeStatus('repeater', staleMs, 5)` → `'stale'` -- `getNodeStatus('companion', recentMs, 0)` → `'active'` (no three-state for non-repeaters) -- `getNodeStatus('companion', recentMs, 99)` → `'active'` (relay count ignored for non-repeaters) -- Status label: `'relaying'` → `🟢 Relaying` -- Status label: `'active'` on repeater → `🟡 Idle` - ---- - -## Limitations - -1. **Observer coverage gaps**: if no observer hears traffic through a repeater, relay activity won't be recorded even if the repeater is relaying. Inherent to passive observation. -2. **Low-traffic networks**: zero relay activity ≠ broken. The "Idle" label must be clearly worded. -3. **Hash collisions**: mitigated by indexing full pubkeys only (resolved path), not raw hop prefixes. -4. **Memory**: `relayTimes` adds one `int64` per relay event per node. Bounded by store packet limit — acceptable. diff --git a/public/nodes.js b/public/nodes.js index 114137b6..5c8a0c0c 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -158,7 +158,8 @@ 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'; + 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 { @@ -533,10 +534,9 @@ ${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') : '—'} - Relay (24h)${typeof si.relayCount24h === 'number' ? si.relayCount24h + ' packet' + (si.relayCount24h === 1 ? '' : 's') : '—'} - ${si.lastRelayed ? `Last Relayed${renderNodeTimestampHtml(si.lastRelayed)}` : ''} -` : ''} + Relay (1h)${typeof si.relayCount1h === 'number' ? si.relayCount1h + ' packet' + (si.relayCount1h === 1 ? '' : 's') : '—'} + Relay (24h)${typeof si.relayCount24h === 'number' ? si.relayCount24h + ' packet' + (si.relayCount24h === 1 ? '' : 's') : '—'} + ${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' : ''} @@ -1253,10 +1253,9 @@ ${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') : '—'}
-
Relay (24h)
${typeof si.relayCount24h === 'number' ? si.relayCount24h + ' packet' + (si.relayCount24h === 1 ? '' : 's') : '—'}
- ${si.lastRelayed ? `
Last Relayed
${renderNodeTimestampHtml(si.lastRelayed)}
` : ''} -` : ''} +
Relay (1h)
${typeof si.relayCount1h === 'number' ? si.relayCount1h + ' packet' + (si.relayCount1h === 1 ? '' : 's') : '—'}
+
Relay (24h)
${typeof si.relayCount24h === 'number' ? si.relayCount24h + ' packet' + (si.relayCount24h === 1 ? '' : 's') : '—'}
+ ${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 98b377e6..1700a279 100644 --- a/public/roles.js +++ b/public/roles.js @@ -87,7 +87,7 @@ // 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 ? window.HEALTH_THRESHOLDS.infraSilentMs : window.HEALTH_THRESHOLDS.nodeSilentMs; + var staleMs = isInfra ? HEALTH_THRESHOLDS.infraSilentMs : HEALTH_THRESHOLDS.nodeSilentMs; var age = typeof lastSeenMs === 'number' ? (Date.now() - lastSeenMs) : Infinity; if (age >= staleMs) return 'stale'; if (role === 'repeater') { diff --git a/test-repeater-liveness.js b/test-repeater-liveness.js index 9b91d184..4238c507 100644 --- a/test-repeater-liveness.js +++ b/test-repeater-liveness.js @@ -19,6 +19,9 @@ const ctx = { }; vm.createContext(ctx); vm.runInContext(fs.readFileSync('public/roles.js', 'utf8'), ctx); +// Mirror browser semantics: in a real browser window === globalThis, so bare +// HEALTH_THRESHOLDS resolves the same as window.HEALTH_THRESHOLDS. Expose it here. +ctx.HEALTH_THRESHOLDS = ctx.window.HEALTH_THRESHOLDS; // Run tests inside the VM context to preserve closures const testCode = ` From ffe5e790f6f0442996ce9790e42a3b933b36efe4 Mon Sep 17 00:00:00 2001 From: efiten Date: Thu, 16 Apr 2026 11:39:26 +0200 Subject: [PATCH 13/21] fix(store): update relayTimes in backfill when resolved paths are set (#662) backfillResolvedPathsAsync was updating tx.ResolvedPath via pickBestObservation but never updating relayTimes. On a fresh deploy (or any instance where resolved_path was NULL in the DB), all repeaters showed as Idle despite active relay traffic. Co-Authored-By: Claude Sonnet 4.6 --- cmd/server/neighbor_persist.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/server/neighbor_persist.go b/cmd/server/neighbor_persist.go index 53f83437..a6ad4aa2 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) + addTxToRelayTimeIndex(store.relayTimes, tx.FirstSeen, pks) // Update byNode for relay nodes for _, pk := range pks { store.addToByNode(tx, pk) From 6b8ed37cb5d99268264a43c5798d1082df687aef Mon Sep 17 00:00:00 2001 From: efiten Date: Thu, 16 Apr 2026 11:57:49 +0200 Subject: [PATCH 14/21] fix(relay): scan all observations for relay times, fix detail pane stats (#662) Two bugs: 1. addTxToRelayTimeIndex only used tx.ResolvedPath (best observation). If the best observer received a packet directly while another observer received it via a repeater, that repeater's relay activity was invisible. Fix: scan ALL observations' resolved paths. Insert is now idempotent so the function can be called multiple times safely. removeFromRelayTimeIndex updated symmetrically to avoid orphaned entries on eviction. Also add an else branch in pollAndMerge so new observations on existing packets (path unchanged) still update relay times immediately. 2. renderDetail/loadFullNode called getStatusInfo(n) where n = data.node (from /api/nodes/{pubkey}, no relay stats). Relay counts and status were always undefined / Idle. Fix: assign n.stats = stats (from the health endpoint) before calling getStatusInfo. Co-Authored-By: Claude Sonnet 4.6 --- cmd/server/neighbor_persist.go | 2 +- cmd/server/store.go | 82 +++++++++++++++++++++------------- public/nodes.js | 4 +- 3 files changed, 55 insertions(+), 33 deletions(-) diff --git a/cmd/server/neighbor_persist.go b/cmd/server/neighbor_persist.go index a6ad4aa2..5f9e5170 100644 --- a/cmd/server/neighbor_persist.go +++ b/cmd/server/neighbor_persist.go @@ -510,7 +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) - addTxToRelayTimeIndex(store.relayTimes, tx.FirstSeen, pks) + store.addTxToRelayTimeIndex(tx) // Update byNode for relay nodes for _, pk := range pks { store.addToByNode(tx, pk) diff --git a/cmd/server/store.go b/cmd/server/store.go index 011771bc..4b16a728 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -1668,7 +1668,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac s.spTotalPaths++ } addTxToPathHopIndex(s.byPathHop, tx) - addTxToRelayTimeIndex(s.relayTimes, tx) + s.addTxToRelayTimeIndex(tx) } // Incrementally update precomputed distance index with new transmissions @@ -2092,7 +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) - removeFromRelayTimeIndex(s.relayTimes, tx) + s.removeFromRelayTimeIndex(tx) tx.parsedPath, tx.pathParsed = saved, savedFlag } // pickBestObservation already set pathParsed=false so @@ -2101,7 +2101,11 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string] s.spTotalPaths++ } addTxToPathHopIndex(s.byPathHop, tx) - addTxToRelayTimeIndex(s.relayTimes, 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) } } @@ -2690,7 +2694,7 @@ func (s *PacketStore) buildPathHopIndex() { s.relayTimes = make(map[string][]int64, 4096) for _, tx := range s.packets { addTxToPathHopIndex(s.byPathHop, tx) - addTxToRelayTimeIndex(s.relayTimes, tx) + s.addTxToRelayTimeIndex(tx) } log.Printf("[store] Built path-hop index: %d unique keys, %d relay-time keys", len(s.byPathHop), len(s.relayTimes)) } @@ -2712,54 +2716,65 @@ func addTxToPathHopIndex(idx map[string][]*StoreTx, tx *StoreTx) { } } -// addTxToRelayTimeIndex records the relay timestamp for each resolved pubkey. -// pubkeys is the pre-extracted list (use extractResolvedPubkeys on the decoded path). -// Maintains sorted ascending order for O(log n) window queries. +// addTxToRelayTimeIndex records the relay timestamp for each full pubkey 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. +// Insert is idempotent: if the same timestamp already exists for a pubkey it is +// not duplicated, so the function may be called multiple times safely. // Must be called with s.mu held (or during build before store is live). -func addTxToRelayTimeIndex(idx map[string][]int64, firstSeen string, pubkeys []string) { - if len(pubkeys) == 0 { - return - } - ms, err := time.Parse(time.RFC3339, firstSeen) +func (s *PacketStore) addTxToRelayTimeIndex(tx *StoreTx) { + ms, err := time.Parse(time.RFC3339, tx.FirstSeen) if err != nil { return } millis := ms.UnixMilli() - seen := make(map[string]bool, len(pubkeys)) - for _, pk := range pubkeys { - pk = strings.ToLower(pk) - if pk == "" || seen[pk] { - continue + idx := s.relayTimes + seen := make(map[string]bool) + insert := func(rp *string) { + if rp == nil { + return + } + pk := strings.ToLower(*rp) + if seen[pk] { + return } 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 + return // idempotent: already present } slice = append(slice, 0) copy(slice[i+1:], slice[i:]) slice[i] = millis idx[pk] = slice } + for _, rp := range s.fetchResolvedPathsForTx(tx.ID) { + for _, ptr := range rp { + insert(ptr) + } + } } -// removeFromRelayTimeIndex removes the relay timestamp for each resolved pubkey. -// Inverse of addTxToRelayTimeIndex. -func removeFromRelayTimeIndex(idx map[string][]int64, firstSeen string, pubkeys []string) { - if len(pubkeys) == 0 { - return - } - ms, err := time.Parse(time.RFC3339, firstSeen) +// 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() - seen := make(map[string]bool, len(pubkeys)) - for _, pk := range pubkeys { - pk = strings.ToLower(pk) - if pk == "" || seen[pk] { - continue + idx := s.relayTimes + seen := make(map[string]bool) + remove := func(rp *string) { + if rp == nil { + return + } + pk := strings.ToLower(*rp) + if seen[pk] { + return } seen[pk] = true slice := idx[pk] @@ -2771,6 +2786,11 @@ func removeFromRelayTimeIndex(idx map[string][]int64, firstSeen string, pubkeys } } } + for _, rp := range s.fetchResolvedPathsForTx(tx.ID) { + for _, ptr := range rp { + remove(ptr) + } + } } // relayMetrics computes relay_count_1h, relay_count_24h, and last_relayed from a @@ -3221,7 +3241,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) - removeFromRelayTimeIndex(s.relayTimes, tx) + s.removeFromRelayTimeIndex(tx) } // Batch-remove from byObserver: single pass per affected observer slice diff --git a/public/nodes.js b/public/nodes.js index 5c8a0c0c..a3f47e5e 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -492,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; @@ -1217,6 +1218,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; From fe92e60c1fdf0ea178b11231c5f45b3a8a5d75e1 Mon Sep 17 00:00:00 2001 From: efiten Date: Thu, 16 Apr 2026 12:08:04 +0200 Subject: [PATCH 15/21] feat(ui): add status emoji column to nodes list with hover tooltip (#662) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New 'St.' column after Last Seen shows 🟢/🟡/⚪ for each node. Hovering shows full context: 'Relaying — Relayed 2592 packets in last 24h, last 26s ago' (or 'Idle — Alive but no relay traffic...', 'Stale — Not heard for...'). Also replaces the manual status/class computation in renderRows with a single getStatusInfo() call for consistency. Co-Authored-By: Claude Sonnet 4.6 --- public/nodes.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/public/nodes.js b/public/nodes.js index a3f47e5e..aa5e0597 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -1031,6 +1031,7 @@ Public Key Role Last Seen + St. Adverts @@ -1151,19 +1152,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 relayCount24h = (n.stats && typeof n.stats.relay_count_24h === 'number') ? n.stats.relay_count_24h : undefined; - const status = getNodeStatus((n.role || 'companion').toLowerCase(), lastSeenTime ? new Date(lastSeenTime).getTime() : 0, relayCount24h); - const lastSeenClass = status === 'relaying' ? 'last-seen-active' : status === 'active' ? ((n.role || '').toLowerCase() === 'repeater' ? 'last-seen-idle' : '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(''); From 4a5826378277017a7b596719b98f8669ca717222 Mon Sep 17 00:00:00 2001 From: efiten Date: Thu, 16 Apr 2026 12:19:32 +0200 Subject: [PATCH 16/21] fix(ui): enrich /api/nodes with relay stats so overview emoji is correct The nodes list was calling getStatusInfo(n) on nodes that had no stats (the list endpoint only returned DB data). This caused all repeaters to show as yellow/idle in the overview even when actively relaying. Fix: handleNodes in routes.go now enriches each repeater node with relay_count_1h, relay_count_24h, and last_relayed from the in-memory relayTimes index, matching exactly what the single-node health endpoint returns. The status filter also updated to use getStatusInfo() so relay state is considered when filtering active/stale. Co-Authored-By: Claude Sonnet 4.6 --- cmd/server/routes.go | 22 ++++++++++++++++++++-- public/nodes.js | 7 +++---- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 70839b52..80cf42bc 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -1087,11 +1087,29 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) { } if s.store != nil { hashInfo := s.store.GetNodeHashSizeInfo() + now := time.Now().UnixMilli() + s.store.mu.RLock() 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(s.store.relayTimes[lk], now) + stats := map[string]interface{}{ + "relay_count_1h": c1h, + "relay_count_24h": c24h, + } + if lastRel != "" { + stats["last_relayed"] = lastRel + } + node["stats"] = stats } } + s.store.mu.RUnlock() } if s.cfg.GeoFilter != nil { filtered := nodes[:0] diff --git a/public/nodes.js b/public/nodes.js index aa5e0597..7fd770b6 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -940,10 +940,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; From 6476b59af5b5edca9f195f75d9c48064b7235db4 Mon Sep 17 00:00:00 2001 From: efiten Date: Fri, 17 Apr 2026 10:43:57 +0200 Subject: [PATCH 17/21] fix(store): cap relay time index to last 24h on startup to prevent OOM buildPathHopIndex was indexing relay timestamps for all historical packets. On a live instance with 2.8M observations this allocated ~14 GB causing the OOM killer to terminate the server process. relayMetrics only queries 1h and 24h windows so any timestamp older than 24h is useless in the index. Skip addTxToRelayTimeIndex for packets older than 24h during the initial build. Co-Authored-By: Claude Sonnet 4.6 --- cmd/server/store.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/server/store.go b/cmd/server/store.go index 4b16a728..d05ec888 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -2692,9 +2692,12 @@ func (s *PacketStore) buildSubpathIndex() { 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) - s.addTxToRelayTimeIndex(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, %d relay-time keys", len(s.byPathHop), len(s.relayTimes)) } From 9e904c80f638d47dd6b92f13023d5c6dfa088bed Mon Sep 17 00:00:00 2001 From: efiten Date: Fri, 17 Apr 2026 11:58:51 +0200 Subject: [PATCH 18/21] fix(store): limit initial packet load to retentionHours window to prevent OOM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loading all 366K transmissions and 2.8M observations at startup caused the server to consume 14+ GB and get OOM-killed on the live instance. The Load() SQL now adds a WHERE clause filtering to the configured retentionHours window when set. With retentionHours=168 (7 days), only ~7 days of packets are loaded into memory — sufficient for all in-memory queries. Historical data remains in SQLite and is available via DB queries. Co-Authored-By: Claude Sonnet 4.6 --- cmd/server/store.go | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/cmd/server/store.go b/cmd/server/store.go index d05ec888..c6be522f 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -465,13 +465,10 @@ func (s *PacketStore) Load() error { if s.db.hasObsRawHex { obsRawHexCol = ", o.raw_hex" } - - limitClause := "" - if maxPackets > 0 { - limitClause = fmt.Sprintf( - "\n\t\t\tWHERE t.id IN (SELECT id FROM transmissions ORDER BY first_seen DESC LIMIT %d)", maxPackets) + whereClause := "" + if s.retentionHours > 0 { + whereClause = fmt.Sprintf("\n\t\t\tWHERE t.first_seen >= datetime('now', '-%.0f hours')", s.retentionHours) } - 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, @@ -479,7 +476,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, @@ -487,7 +484,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` } From 0c35da5c5e9e10ac7d78b3eba1fb7c8535815ed2 Mon Sep 17 00:00:00 2001 From: efiten Date: Sun, 19 Apr 2026 16:35:46 +0200 Subject: [PATCH 19/21] fix(repeater-liveness): address issue #755 review feedback Must-fix items: - addTxToRelayTimeIndex now accepts pre-parsed millis to avoid 30K+ RFC3339 re-parses during bulk startup rebuild under the write lock - removeFromRelayTimeIndex uses copy+reslice instead of append to avoid per-eviction heap allocation proportional to path length - handleNodes snapshots relay times under RLock then releases before enrichment, keeping the lock scope to a quick map copy rather than the full node loop - Suppress last_relayed in API response when both counts are zero across all three endpoints (handleNodes, bulk health, single-node health) Should-fix items: - Add NOTE comment on HEALTH_THRESHOLDS mirror in test-repeater-liveness.js - Flatten multiline repeater template block into per-row inline conditionals in both table and dl views to match avgSnr/avgHops style Co-Authored-By: Claude Sonnet 4.6 --- cmd/server/relay_liveness_test.go | 26 +++++++++++++------------- cmd/server/routes.go | 25 ++++++++++++++++++++++--- cmd/server/store.go | 11 +++++++---- public/nodes.js | 14 ++++++-------- test-repeater-liveness.js | 2 +- 5 files changed, 49 insertions(+), 29 deletions(-) diff --git a/cmd/server/relay_liveness_test.go b/cmd/server/relay_liveness_test.go index 95f999d4..0190bbf1 100644 --- a/cmd/server/relay_liveness_test.go +++ b/cmd/server/relay_liveness_test.go @@ -17,7 +17,7 @@ func TestAddTxToRelayTimeIndex_SingleNode(t *testing.T) { FirstSeen: ts.Format(time.RFC3339), ResolvedPath: []*string{makeRp(pk)}, } - addTxToRelayTimeIndex(idx, tx) + addTxToRelayTimeIndex(idx, tx, ts.UnixMilli()) if len(idx[pk]) != 1 { t.Fatalf("expected 1 entry, got %d", len(idx[pk])) } @@ -37,8 +37,8 @@ func TestAddTxToRelayTimeIndex_SortedOrder(t *testing.T) { // Insert newer first, expect sorted ascending tx2 := &StoreTx{FirstSeen: t2.Format(time.RFC3339), ResolvedPath: []*string{makeRp(pk)}} tx1 := &StoreTx{FirstSeen: t1.Format(time.RFC3339), ResolvedPath: []*string{makeRp(pk)}} - addTxToRelayTimeIndex(idx, tx2) - addTxToRelayTimeIndex(idx, tx1) + addTxToRelayTimeIndex(idx, tx2, t2.UnixMilli()) + addTxToRelayTimeIndex(idx, tx1, t1.UnixMilli()) if len(idx[pk]) != 2 { t.Fatalf("expected 2 entries, got %d", len(idx[pk])) @@ -57,7 +57,7 @@ func TestAddTxToRelayTimeIndex_MultipleNodes(t *testing.T) { FirstSeen: ts.Format(time.RFC3339), ResolvedPath: []*string{makeRp(pk1), makeRp(pk2)}, } - addTxToRelayTimeIndex(idx, tx) + addTxToRelayTimeIndex(idx, tx, ts.UnixMilli()) if len(idx[pk1]) != 1 { t.Errorf("pk1: expected 1 entry, got %d", len(idx[pk1])) } @@ -69,7 +69,7 @@ func TestAddTxToRelayTimeIndex_MultipleNodes(t *testing.T) { func TestAddTxToRelayTimeIndex_NilResolvedPath(t *testing.T) { idx := make(map[string][]int64) tx := &StoreTx{FirstSeen: time.Now().UTC().Format(time.RFC3339), ResolvedPath: nil} - addTxToRelayTimeIndex(idx, tx) // must not panic + addTxToRelayTimeIndex(idx, tx, time.Now().UnixMilli()) // must not panic if len(idx) != 0 { t.Error("expected empty index for nil ResolvedPath") } @@ -83,7 +83,7 @@ func TestAddTxToRelayTimeIndex_DuplicatePubkeyInPath(t *testing.T) { FirstSeen: ts.Format(time.RFC3339), ResolvedPath: []*string{makeRp(pk), makeRp(pk)}, // same pubkey twice } - addTxToRelayTimeIndex(idx, tx) + addTxToRelayTimeIndex(idx, tx, ts.UnixMilli()) if len(idx[pk]) != 1 { t.Errorf("duplicate pubkey should produce only 1 entry, got %d", len(idx[pk])) } @@ -92,10 +92,10 @@ func TestAddTxToRelayTimeIndex_DuplicatePubkeyInPath(t *testing.T) { func TestRemoveFromRelayTimeIndex_RemovesEntry(t *testing.T) { idx := make(map[string][]int64) pk := "aabbccdd11223344" - ts := time.Now().Add(-1 * time.Hour).UTC() + ts := time.Now().Add(-1 * time.Hour).Truncate(time.Second).UTC() tx := &StoreTx{FirstSeen: ts.Format(time.RFC3339), ResolvedPath: []*string{makeRp(pk)}} - addTxToRelayTimeIndex(idx, tx) + addTxToRelayTimeIndex(idx, tx, ts.UnixMilli()) if len(idx[pk]) != 1 { t.Fatal("setup: expected 1 entry") } @@ -108,13 +108,13 @@ func TestRemoveFromRelayTimeIndex_RemovesEntry(t *testing.T) { func TestRemoveFromRelayTimeIndex_PartialRemove(t *testing.T) { idx := make(map[string][]int64) pk := "aabbccdd11223344" - t1 := time.Now().Add(-2 * time.Hour).UTC() - t2 := time.Now().Add(-30 * time.Minute).UTC() + t1 := time.Now().Add(-2 * time.Hour).Truncate(time.Second).UTC() + t2 := time.Now().Add(-30 * time.Minute).Truncate(time.Second).UTC() tx1 := &StoreTx{FirstSeen: t1.Format(time.RFC3339), ResolvedPath: []*string{makeRp(pk)}} tx2 := &StoreTx{FirstSeen: t2.Format(time.RFC3339), ResolvedPath: []*string{makeRp(pk)}} - addTxToRelayTimeIndex(idx, tx1) - addTxToRelayTimeIndex(idx, tx2) + addTxToRelayTimeIndex(idx, tx1, t1.UnixMilli()) + addTxToRelayTimeIndex(idx, tx2, t2.UnixMilli()) removeFromRelayTimeIndex(idx, tx1) if len(idx[pk]) != 1 { @@ -290,7 +290,7 @@ func TestAddTxToRelayTimeIndex_LowercasesKey(t *testing.T) { pkLower := strings.ToLower(pkUpper) ts := time.Now().UTC() tx := &StoreTx{FirstSeen: ts.Format(time.RFC3339), ResolvedPath: []*string{makeRp(pkUpper)}} - addTxToRelayTimeIndex(idx, tx) + addTxToRelayTimeIndex(idx, tx, ts.UnixMilli()) if len(idx[pkLower]) != 1 { t.Errorf("expected index keyed by lowercase, found %d entries at lowercase key", len(idx[pkLower])) } diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 80cf42bc..d092825a 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -1088,7 +1088,27 @@ 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 { pk, ok := node["public_key"].(string) if !ok { @@ -1098,18 +1118,17 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) { role, _ := node["role"].(string) if strings.ToLower(role) == "repeater" { lk := strings.ToLower(pk) - c1h, c24h, lastRel := relayMetrics(s.store.relayTimes[lk], now) + c1h, c24h, lastRel := relayMetrics(relaySnap[lk], now) stats := map[string]interface{}{ "relay_count_1h": c1h, "relay_count_24h": c24h, } - if lastRel != "" { + if lastRel != "" && (c1h > 0 || c24h > 0) { stats["last_relayed"] = lastRel } node["stats"] = stats } } - s.store.mu.RUnlock() } if s.cfg.GeoFilter != nil { filtered := nodes[:0] diff --git a/cmd/server/store.go b/cmd/server/store.go index c6be522f..b77c8588 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -2780,9 +2780,12 @@ func (s *PacketStore) removeFromRelayTimeIndex(tx *StoreTx) { slice := idx[pk] i := sort.Search(len(slice), func(j int) bool { return slice[j] >= millis }) if i < len(slice) && slice[i] == millis { - idx[pk] = append(slice[:i], slice[i+1:]...) - if len(idx[pk]) == 0 { + copy(slice[i:], slice[i+1:]) + slice = slice[:len(slice)-1] + if len(slice) == 0 { delete(idx, pk) + } else { + idx[pk] = slice } } } @@ -6588,7 +6591,7 @@ func (s *PacketStore) GetBulkHealth(limit int, region string) []map[string]inter 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 != "" { + if lastRel != "" && (c1h > 0 || c24h > 0) { statsMap["last_relayed"] = lastRel } } @@ -6754,7 +6757,7 @@ func (s *PacketStore) GetNodeHealth(pubkey string) (map[string]interface{}, erro c1h, c24h, lastRel := relayMetrics(s.relayTimes[lowerPK], time.Now().UnixMilli()) nodeStats["relay_count_1h"] = c1h nodeStats["relay_count_24h"] = c24h - if lastRel != "" { + if lastRel != "" && (c1h > 0 || c24h > 0) { nodeStats["last_relayed"] = lastRel } } diff --git a/public/nodes.js b/public/nodes.js index 7fd770b6..63079e58 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -534,10 +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') : '—'} - Relay (24h)${typeof si.relayCount24h === 'number' ? si.relayCount24h + ' packet' + (si.relayCount24h === 1 ? '' : 's') : '—'} - ${si.lastRelayed ? `Last Relayed${renderNodeTimestampHtml(si.lastRelayed)}` : ''}` : ''} + ${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' : ''} @@ -1254,10 +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') : '—'}
-
Relay (24h)
${typeof si.relayCount24h === 'number' ? si.relayCount24h + ' packet' + (si.relayCount24h === 1 ? '' : 's') : '—'}
- ${si.lastRelayed ? `
Last Relayed
${renderNodeTimestampHtml(si.lastRelayed)}
` : ''}` : ''} + ${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/test-repeater-liveness.js b/test-repeater-liveness.js index 4238c507..39902ad0 100644 --- a/test-repeater-liveness.js +++ b/test-repeater-liveness.js @@ -20,7 +20,7 @@ const ctx = { vm.createContext(ctx); vm.runInContext(fs.readFileSync('public/roles.js', 'utf8'), ctx); // Mirror browser semantics: in a real browser window === globalThis, so bare -// HEALTH_THRESHOLDS resolves the same as window.HEALTH_THRESHOLDS. Expose it here. +// 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 From 9ec7f95951d09f8d36534858c05cda34927c893b Mon Sep 17 00:00:00 2001 From: efiten Date: Mon, 27 Apr 2026 10:32:52 +0200 Subject: [PATCH 20/21] fix(store): extract relayIndexInsertPaths/RemovePaths helpers; restore maxPackets fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit relay_liveness_test.go used StoreTx.ResolvedPath (removed by #800) and called addTxToRelayTimeIndex as a standalone function — both no longer existed after the rebase. Fix by extracting relayIndexInsertPaths / relayIndexRemovePaths as package-level helpers that operate on map[string][]int64 directly; store methods delegate to them. Tests rewritten to call helpers without StoreTx involvement. Also restores the maxPackets whereClause fallback dropped when retentionHours support was added, fixing TestBoundedLoad_LimitedMemory. Co-Authored-By: Claude Sonnet 4.6 --- cmd/server/relay_liveness_test.go | 121 ++++++++++++------------------ cmd/server/store.go | 87 +++++++++++---------- 2 files changed, 94 insertions(+), 114 deletions(-) diff --git a/cmd/server/relay_liveness_test.go b/cmd/server/relay_liveness_test.go index 0190bbf1..dc36f1e5 100644 --- a/cmd/server/relay_liveness_test.go +++ b/cmd/server/relay_liveness_test.go @@ -9,36 +9,28 @@ import ( func makeRp(s string) *string { return &s } -func TestAddTxToRelayTimeIndex_SingleNode(t *testing.T) { +func TestRelayIndexInsertPaths_SingleNode(t *testing.T) { idx := make(map[string][]int64) pk := "aabbccdd11223344" - ts := time.Now().Add(-30 * time.Minute).UTC() - tx := &StoreTx{ - FirstSeen: ts.Format(time.RFC3339), - ResolvedPath: []*string{makeRp(pk)}, - } - addTxToRelayTimeIndex(idx, tx, ts.UnixMilli()) + 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])) } - wantMs := ts.UnixMilli() - // RFC3339 has second precision, so allow ±1000ms - if diff := idx[pk][0] - wantMs; diff < -1000 || diff > 1000 { - t.Errorf("timestamp mismatch: got %d, want ~%d", idx[pk][0], wantMs) + if idx[pk][0] != millis { + t.Errorf("timestamp mismatch: got %d, want %d", idx[pk][0], millis) } } -func TestAddTxToRelayTimeIndex_SortedOrder(t *testing.T) { +func TestRelayIndexInsertPaths_SortedOrder(t *testing.T) { idx := make(map[string][]int64) pk := "aabbccdd11223344" - t1 := time.Now().Add(-2 * time.Hour).UTC() - t2 := time.Now().Add(-30 * time.Minute).UTC() + ms1 := time.Now().Add(-2 * time.Hour).UnixMilli() + ms2 := time.Now().Add(-30 * time.Minute).UnixMilli() // Insert newer first, expect sorted ascending - tx2 := &StoreTx{FirstSeen: t2.Format(time.RFC3339), ResolvedPath: []*string{makeRp(pk)}} - tx1 := &StoreTx{FirstSeen: t1.Format(time.RFC3339), ResolvedPath: []*string{makeRp(pk)}} - addTxToRelayTimeIndex(idx, tx2, t2.UnixMilli()) - addTxToRelayTimeIndex(idx, tx1, t1.UnixMilli()) + 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])) @@ -48,16 +40,12 @@ func TestAddTxToRelayTimeIndex_SortedOrder(t *testing.T) { } } -func TestAddTxToRelayTimeIndex_MultipleNodes(t *testing.T) { +func TestRelayIndexInsertPaths_MultipleNodes(t *testing.T) { idx := make(map[string][]int64) pk1 := "aabbccdd11223344" pk2 := "eeff001122334455" - ts := time.Now().Add(-10 * time.Minute).UTC() - tx := &StoreTx{ - FirstSeen: ts.Format(time.RFC3339), - ResolvedPath: []*string{makeRp(pk1), makeRp(pk2)}, - } - addTxToRelayTimeIndex(idx, tx, ts.UnixMilli()) + 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])) } @@ -66,56 +54,64 @@ func TestAddTxToRelayTimeIndex_MultipleNodes(t *testing.T) { } } -func TestAddTxToRelayTimeIndex_NilResolvedPath(t *testing.T) { +func TestRelayIndexInsertPaths_NilPaths(t *testing.T) { idx := make(map[string][]int64) - tx := &StoreTx{FirstSeen: time.Now().UTC().Format(time.RFC3339), ResolvedPath: nil} - addTxToRelayTimeIndex(idx, tx, time.Now().UnixMilli()) // must not panic + relayIndexInsertPaths(idx, time.Now().UnixMilli(), nil) // must not panic if len(idx) != 0 { - t.Error("expected empty index for nil ResolvedPath") + t.Error("expected empty index for nil paths") } } -func TestAddTxToRelayTimeIndex_DuplicatePubkeyInPath(t *testing.T) { +func TestRelayIndexInsertPaths_DuplicatePubkey(t *testing.T) { idx := make(map[string][]int64) pk := "aabbccdd11223344" - ts := time.Now().UTC() - tx := &StoreTx{ - FirstSeen: ts.Format(time.RFC3339), - ResolvedPath: []*string{makeRp(pk), makeRp(pk)}, // same pubkey twice - } - addTxToRelayTimeIndex(idx, tx, ts.UnixMilli()) + 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 TestRemoveFromRelayTimeIndex_RemovesEntry(t *testing.T) { +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" - ts := time.Now().Add(-1 * time.Hour).Truncate(time.Second).UTC() - tx := &StoreTx{FirstSeen: ts.Format(time.RFC3339), ResolvedPath: []*string{makeRp(pk)}} + millis := time.Now().Add(-1 * time.Hour).UnixMilli() + paths := []*string{makeRp(pk)} - addTxToRelayTimeIndex(idx, tx, ts.UnixMilli()) + relayIndexInsertPaths(idx, millis, paths) if len(idx[pk]) != 1 { t.Fatal("setup: expected 1 entry") } - removeFromRelayTimeIndex(idx, tx) + relayIndexRemovePaths(idx, millis, paths) if _, ok := idx[pk]; ok { t.Error("expected key deleted after last entry removed") } } -func TestRemoveFromRelayTimeIndex_PartialRemove(t *testing.T) { +func TestRelayIndexRemovePaths_PartialRemove(t *testing.T) { idx := make(map[string][]int64) pk := "aabbccdd11223344" - t1 := time.Now().Add(-2 * time.Hour).Truncate(time.Second).UTC() - t2 := time.Now().Add(-30 * time.Minute).Truncate(time.Second).UTC() - tx1 := &StoreTx{FirstSeen: t1.Format(time.RFC3339), ResolvedPath: []*string{makeRp(pk)}} - tx2 := &StoreTx{FirstSeen: t2.Format(time.RFC3339), ResolvedPath: []*string{makeRp(pk)}} + ms1 := time.Now().Add(-2 * time.Hour).UnixMilli() + ms2 := time.Now().Add(-30 * time.Minute).UnixMilli() + paths := []*string{makeRp(pk)} - addTxToRelayTimeIndex(idx, tx1, t1.UnixMilli()) - addTxToRelayTimeIndex(idx, tx2, t2.UnixMilli()) - removeFromRelayTimeIndex(idx, tx1) + 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])) @@ -169,8 +165,6 @@ func TestRelayTimesWiredIntoIngest(t *testing.T) { if hopKeys == 0 { t.Skip("no path-hop data in test store — skipping relay wiring test") } - // relayTimes will only be populated if test packets have ResolvedPath entries. - // At minimum it must not panic and must be initialised. if srv.store.relayTimes == nil { t.Fatal("relayTimes map is nil after load") } @@ -186,17 +180,14 @@ func TestRelayTimesWiredIntoIngest(t *testing.T) { func TestGetBulkHealthRepeaterRelayFields(t *testing.T) { srv, _ := setupTestServer(t) - // Insert a synthetic repeater node into the DB if none exists _, 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) } - // Inject a relay timestamp within the last hour pk := "relay662test0001" - now := time.Now().UnixMilli() - recentMs := now - 10*60*1000 // 10 min ago + recentMs := time.Now().UnixMilli() - 10*60*1000 // 10 min ago srv.store.mu.Lock() srv.store.relayTimes[pk] = []int64{recentMs} srv.store.mu.Unlock() @@ -239,7 +230,6 @@ func TestGetBulkHealthCompanionNoRelayFields(t *testing.T) { t.Fatalf("insert test node: %v", err) } - // Give the companion a relay entry (should be ignored by role gate) pk := "comp662test0001" srv.store.mu.Lock() srv.store.relayTimes[pk] = []int64{time.Now().UnixMilli() - 5*60*1000} @@ -267,7 +257,6 @@ func TestGetBulkHealthRepeaterNoRelayActivity(t *testing.T) { t.Fatalf("insert test node: %v", err) } - // No entry in relayTimes for this node results := srv.store.GetBulkHealth(200, "") for _, r := range results { if r["public_key"] == "relay662idle001" { @@ -284,21 +273,6 @@ func TestGetBulkHealthRepeaterNoRelayActivity(t *testing.T) { t.Fatal("idle repeater not found in results") } -func TestAddTxToRelayTimeIndex_LowercasesKey(t *testing.T) { - idx := make(map[string][]int64) - pkUpper := "AABBCCDD11223344" - pkLower := strings.ToLower(pkUpper) - ts := time.Now().UTC() - tx := &StoreTx{FirstSeen: ts.Format(time.RFC3339), ResolvedPath: []*string{makeRp(pkUpper)}} - addTxToRelayTimeIndex(idx, tx, ts.UnixMilli()) - 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 TestGetNodeHealthRepeaterRelayFields(t *testing.T) { srv, _ := setupTestServer(t) @@ -309,8 +283,7 @@ func TestGetNodeHealthRepeaterRelayFields(t *testing.T) { t.Fatalf("insert test node: %v", err) } - now := time.Now().UnixMilli() - recentMs := now - 15*60*1000 // 15 min ago + recentMs := time.Now().UnixMilli() - 15*60*1000 // 15 min ago srv.store.mu.Lock() srv.store.relayTimes[pk] = []int64{recentMs} srv.store.mu.Unlock() diff --git a/cmd/server/store.go b/cmd/server/store.go index b77c8588..0d928816 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -468,6 +468,9 @@ func (s *PacketStore) Load() error { 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, @@ -2717,64 +2720,43 @@ func addTxToPathHopIndex(idx map[string][]*StoreTx, tx *StoreTx) { } // addTxToRelayTimeIndex records the relay timestamp for each full pubkey 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. -// Insert is idempotent: if the same timestamp already exists for a pubkey it is -// not duplicated, so the function may be called multiple times safely. -// 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() - idx := s.relayTimes - seen := make(map[string]bool) - insert := func(rp *string) { +// 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 { - return + continue } pk := strings.ToLower(*rp) if seen[pk] { - return + 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 { - return // idempotent: already present + continue // idempotent: already present } slice = append(slice, 0) copy(slice[i+1:], slice[i:]) slice[i] = millis idx[pk] = slice } - for _, rp := range s.fetchResolvedPathsForTx(tx.ID) { - for _, ptr := range rp { - insert(ptr) - } - } } -// 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() - idx := s.relayTimes - seen := make(map[string]bool) - remove := func(rp *string) { +// 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 { - return + continue } pk := strings.ToLower(*rp) if seen[pk] { - return + continue } seen[pk] = true slice := idx[pk] @@ -2789,10 +2771,35 @@ func (s *PacketStore) removeFromRelayTimeIndex(tx *StoreTx) { } } } +} + +// 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) { - for _, ptr := range rp { - remove(ptr) - } + 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) } } From d1d4a01656f67d4bacb3ff6ac720a5de61d771fc Mon Sep 17 00:00:00 2001 From: efiten Date: Mon, 27 Apr 2026 13:47:54 +0200 Subject: [PATCH 21/21] test(e2e): fix detached-element race in node detail tests page.$() captures an element handle at query time. When the nodes page WebSocket auto-refresh fires between the querySelector and the .click() call, the table is re-rendered and the element is detached, causing "Element is not attached to the DOM". Replace both occurrences with page.click(selector), which re-queries the DOM at click time and retries until the element is stable. Co-Authored-By: Claude Sonnet 4.6 --- test-e2e-playwright.js | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) 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/"]');