mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-03 22:21:18 +00:00
ba6c2ac6ba
## 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>
157 lines
4.8 KiB
Go
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")
|
|
}
|
|
}
|