Files
meshcore-analyzer/cmd/server/relay_liveness_test.go
T
efiten ba6c2ac6ba feat: repeater liveness indicator with relay stats (#662) (#755)
## Summary

- **Backend**: adds `relayTimes` in-memory index (sorted unix-millis per
repeater pubkey), maintained in lockstep with `byPathHop`. Populated at
startup from all packet observations (not just best), updated on
ingest/evict/backfill. Exposes `relay_count_1h`, `relay_count_24h`,
`last_relayed` in both `/api/nodes` (for repeaters) and
`/api/nodes/{pubkey}/health`.
- **Frontend**: `getNodeStatus` extended to three-state (`relaying` /
`active` / `stale`) for repeaters based on relay_count_24h.
`getStatusInfo` is the single source of truth for status label,
explanation, and relay stats. Detail pane shows relay counts and last
relayed time. Nodes list gets a status emoji column with hover tooltip
showing relay info.
- **Correctness fixes**: relay index scans all observations per packet
(not just best); backfill now updates relay index after resolving paths;
pubkeys lowercased consistently throughout index.

## Changes

### `cmd/server/store.go`
- `relayTimes map[string][]int64` field added to `PacketStore`
- `addTxToRelayTimeIndex` / `removeFromRelayTimeIndex`: scan all
observations, idempotent sorted insert, lowercase keys
- `relayMetrics(times, nowMs)`: returns `(count1h, count24h,
lastRelayed)`
- `buildPathHopIndex`: populates `relayTimes` at startup
- `pollAndMerge`: updates relay index on ingest and eviction; new `else`
branch for path-unchanged observations
- `addTxToPathHopIndex` / `removeTxFromPathHopIndex`: lowercase resolved
pubkeys (fixes casing mismatch with lookup)

### `cmd/server/routes.go`
- `GetBulkHealth` / `GetNodeHealth`: include relay stats for repeater
nodes
- `handleNodes`: enriches repeater nodes with relay stats from
`relayTimes` so list view has same data as detail pane

### `cmd/server/neighbor_persist.go`
- `backfillResolvedPathsAsync`: calls `addTxToRelayTimeIndex` after
`pickBestObservation` to capture newly resolved pubkeys

### `public/roles.js`
- `getNodeStatus(role, lastSeenMs, relayCount24h)`: three-state logic
for repeaters
- `getStatusInfo(n)`: single source of truth returning status, label,
explanation, relay counts, last relayed

### `public/nodes.js`
- Detail pane: `n.stats` populated from health endpoint before
`getStatusInfo` call
- Nodes list: status emoji column with relay hover tooltip; status
filter uses `getStatusInfo`

### Tests
- `relay_liveness_test.go`: index functions, relay metrics, wiring
integration, bulk/single health endpoints
- `test-repeater-liveness.js`: three-state frontend logic, backward
compat

## Test plan
- [x] Repeater with recent relay traffic shows green relaying emoji in
list and detail pane
- [x] Repeater with no relay traffic in 24h shows yellow idle in both
views
- [x] Repeater not heard recently shows grey stale in both views
- [x] Non-repeater nodes unaffected (no relay stats, no status change)
- [x] Hover tooltip on list emoji shows relay count and last relayed
time
- [x] `go test ./...` passes
- [x] `node test-repeater-liveness.js` passes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-21 11:39:43 -07:00

157 lines
4.8 KiB
Go

package main
import (
"sort"
"strings"
"testing"
"time"
)
func TestAddTxToRelayTimeIndex_SingleNode(t *testing.T) {
idx := make(map[string][]int64)
pk := "aabbccdd11223344"
ts := time.Now().Add(-30 * time.Minute).UTC()
addTxToRelayTimeIndex(idx, ts.Format(time.RFC3339), []string{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)
}
}
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
addTxToRelayTimeIndex(idx, t2.Format(time.RFC3339), []string{pk})
addTxToRelayTimeIndex(idx, t1.Format(time.RFC3339), []string{pk})
if len(idx[pk]) != 2 {
t.Fatalf("expected 2 entries, got %d", len(idx[pk]))
}
if !sort.SliceIsSorted(idx[pk], func(i, j int) bool { return idx[pk][i] < idx[pk][j] }) {
t.Error("relayTimes slice not sorted ascending")
}
}
func TestAddTxToRelayTimeIndex_MultipleNodes(t *testing.T) {
idx := make(map[string][]int64)
pk1 := "aabbccdd11223344"
pk2 := "eeff001122334455"
ts := time.Now().Add(-10 * time.Minute).UTC()
addTxToRelayTimeIndex(idx, ts.Format(time.RFC3339), []string{pk1, pk2})
if len(idx[pk1]) != 1 {
t.Errorf("pk1: expected 1 entry, got %d", len(idx[pk1]))
}
if len(idx[pk2]) != 1 {
t.Errorf("pk2: expected 1 entry, got %d", len(idx[pk2]))
}
}
func TestAddTxToRelayTimeIndex_NilResolvedPath(t *testing.T) {
idx := make(map[string][]int64)
addTxToRelayTimeIndex(idx, time.Now().UTC().Format(time.RFC3339), nil) // must not panic
if len(idx) != 0 {
t.Error("expected empty index for nil pubkeys")
}
}
func TestAddTxToRelayTimeIndex_DuplicatePubkeyInPath(t *testing.T) {
idx := make(map[string][]int64)
pk := "aabbccdd11223344"
ts := time.Now().UTC()
addTxToRelayTimeIndex(idx, ts.Format(time.RFC3339), []string{pk, 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) {
idx := make(map[string][]int64)
pk := "aabbccdd11223344"
ts := time.Now().Add(-1 * time.Hour).UTC()
firstSeen := ts.Format(time.RFC3339)
addTxToRelayTimeIndex(idx, firstSeen, []string{pk})
if len(idx[pk]) != 1 {
t.Fatal("setup: expected 1 entry")
}
removeFromRelayTimeIndex(idx, firstSeen, []string{pk})
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()
fs1 := t1.Format(time.RFC3339)
fs2 := t2.Format(time.RFC3339)
addTxToRelayTimeIndex(idx, fs1, []string{pk})
addTxToRelayTimeIndex(idx, fs2, []string{pk})
removeFromRelayTimeIndex(idx, fs1, []string{pk})
if len(idx[pk]) != 1 {
t.Errorf("expected 1 entry after removing one, got %d", len(idx[pk]))
}
}
func TestRelayMetrics_Counts(t *testing.T) {
now := time.Now().UnixMilli()
times := []int64{
now - 90*60*1000, // 90 min ago — inside 24h, outside 1h
now - 30*60*1000, // 30 min ago — inside both
now - 10*60*1000, // 10 min ago — inside both
}
c1h, c24h, lastRelayed := relayMetrics(times, now)
if c1h != 2 {
t.Errorf("relay_count_1h: expected 2, got %d", c1h)
}
if c24h != 3 {
t.Errorf("relay_count_24h: expected 3, got %d", c24h)
}
wantLast := time.UnixMilli(times[2]).UTC().Format(time.RFC3339)
if lastRelayed != wantLast {
t.Errorf("last_relayed: got %q, want %q", lastRelayed, wantLast)
}
}
func TestRelayMetrics_EmptySlice(t *testing.T) {
c1h, c24h, lastRelayed := relayMetrics(nil, time.Now().UnixMilli())
if c1h != 0 || c24h != 0 || lastRelayed != "" {
t.Errorf("empty slice: expected zeros and empty string, got %d %d %q", c1h, c24h, lastRelayed)
}
}
func TestRelayMetrics_AllOutsideWindow(t *testing.T) {
now := time.Now().UnixMilli()
times := []int64{now - 30*24*60*60*1000} // 30 days ago
c1h, c24h, _ := relayMetrics(times, now)
if c1h != 0 || c24h != 0 {
t.Errorf("expected 0/0 for old entry, got %d/%d", c1h, c24h)
}
}
func TestAddTxToRelayTimeIndex_LowercasesKey(t *testing.T) {
idx := make(map[string][]int64)
pkUpper := "AABBCCDD11223344"
pkLower := strings.ToLower(pkUpper)
ts := time.Now().UTC()
addTxToRelayTimeIndex(idx, ts.Format(time.RFC3339), []string{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")
}
}