Files
meshcore-analyzer/cmd/server/node_analytics_coverage_test.go
efitenandClaude Opus 5.5 2bb000f2aa feat(notify): adaptive offline threshold and certain node analytics (#2260) (#2262)
Part 2 of #2260. **Builds on #2261** (certain liveness): review/merge
that first; until then this diff also shows its commit.

## Adaptive offline threshold

`userManagement.notifications.adaptiveOffline` — **off by default**:

```json
"notifications": {
  "enabled": true,
  "adaptiveOffline": { "enabled": true, "floorMinutes": 30, "minObservers": 3 }
}
```

The fixed `infraSilentHours` (72 h) treats every repeater the same. With
this on, a watched node's `node.offline` window is learned from its own
**certain** traffic (the #2261 rule):

1. Certain packets per hour over the week **before its last certain
packet**. Ending there, not at now, keeps an outage from dragging the
rate down until the fixed window returns and the node reads "back
online".
2. Empty runs are classified: ≤ 6 h and recurring on another day at the
same hours = normal silence (kept; the window is never shorter than the
longest + 1 h); any other run = outage, left out of the learning so it
neither stretches the window nor blocks learning.
3. λ = 10th percentile of the remaining hourly counts, so quiet nights
don't alert.
4. Window = 14/λ hours: silence at rate λ has a < 10⁻⁶ chance (Poisson,
ln 10⁶ ≈ 13.8).
5. Clamped to [`floorMinutes`, the role's silent window].

Kept on the fixed window: λ < 1/hour, under 24 h of history in memory,
or a 10th-percentile hour heard by fewer than `minObservers` observers
(one observer dropping out must not read as the node going offline).
Silence is measured up to the newest packet in the store, so a stalling
feed stops every node's clock. A node stored offline comes back only on
evidence from the moment it went offline on. The notifier caches each
learned window for 10 minutes and drops unwatched nodes.

For the #2260 repeater (~840 packets/day) the mail would have gone out
about 30 minutes into the outage, instead of never (or 72 h with #2261
alone).

## Certain node analytics

- Each `activityTimeline` bucket carries `certain`: the part that can
only be this node (`certainFor`, the packet-level form of the
`certainHeard` rule). `TimeBucket.Certain` is `omitempty`, so other
endpoints are unchanged.
- Availability and longest silence use certain buckets only; the longest
silence now includes one **still running** (an outage in progress is the
silence worth seeing).
- The chart stacks certain (`--accent`) and uncertain (`--text-muted`) —
existing theme variables, so no new customizer entry. Descriptions
updated.

## Performance

`CertainActivity` walks a week of packets per watched node, at most once
per node per 10 minutes, with the read lock taken **per node**:

```
BenchmarkCertainActivity-16   12.1 ms/op   (50 watched repeaters × 6,000 packets; ~0.25 ms per lock hold)
BenchmarkNoteCertainRelays-16  121 ns/op   0 allocs
```

Node analytics adds one `certainFor` per packet of the requested node (a
prefix-map lookup, or the cached decoded JSON for its own packets).

## Tests

- `adaptiveOfflineAfter`: busy repeater hits the floor; 5/hour; quiet
nights set the rate; > 10 % empty hours, short history, too few
observers → fixed.
- `CertainActivity` ends at the last certain packet and counts only
certain packets — verified red by mutating the window to end at now.
- Notifier end to end: learned window passed → bad; inside → good; too
few observers / setting off → fixed 72 h; cache refresh and prune.
- `certainFor`, `longestSilence` (incl. running silence), config
defaults, `/api/nodes/{pk}/analytics` splits a shared-prefix bucket.
- Frontend: `NodeAnalyticsTimeline.series` (new
`tests/unit/test-node-analytics-certain-timeline.js`).
- `cmd/server` `go test ./...` and `test-all.sh` pass locally, also on a
trial merge into a downstream fork.

## Validation

Server on the freshened e2e fixture: analytics buckets carry `certain`
(e.g. 89 of 90 packets certain for one repeater). **No browser
screenshot**: Playwright isn't installed on this machine.

## Later (rule 8)

`floorMinutes` / `minObservers` are server config, not theme settings;
an admin UI for notification settings would be the place, not the
customizer.

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

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 21:20:42 +02:00

363 lines
13 KiB
Go

package main
import (
"fmt"
"testing"
"time"
)
const coverageNodePK = "c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00"
// coverageAdvert is the node's own advert: certain evidence (#2260), so it
// counts toward availability.
const coverageAdvert = `{"pubKey":"` + coverageNodePK + `"}`
// setupCoverageStore holds one packet per hour for the node over the last
// memHours hours, and records that window as loaded the way a bounded load
// does (oldestLoaded): the in-memory window starts memHours ago (#748 M3a).
func setupCoverageStore(t *testing.T, memHours int) *PacketStore {
t.Helper()
db := setupTestDB(t)
t.Cleanup(func() { db.Close() })
if _, err := db.conn.Exec("INSERT INTO nodes (public_key, name, role) VALUES (?, ?, ?)", coverageNodePK, "Coverage", "repeater"); err != nil {
t.Fatal(err)
}
ps := NewPacketStore(db, nil)
now := time.Now().UTC()
pt := 4
ps.mu.Lock()
for h := memHours - 1; h >= 0; h-- {
tx := &StoreTx{
ID: memHours - h,
Hash: fmt.Sprintf("cov%03d", h),
PayloadType: &pt,
DecodedJSON: coverageAdvert,
FirstSeen: now.Add(-time.Duration(h)*time.Hour - time.Minute).Format(time.RFC3339),
}
ps.packets = append(ps.packets, tx)
ps.byHash[tx.Hash] = tx
ps.byTxID[tx.ID] = tx
ps.byNode[coverageNodePK] = append(ps.byNode[coverageNodePK], tx)
}
if memHours > 0 {
ps.oldestLoaded = ps.packets[0].FirstSeen
}
ps.mu.Unlock()
return ps
}
func rate(p *float64) float64 {
if p == nil {
return -1
}
return *p
}
func addCoverageTx(ps *PacketStore, id int, firstSeen time.Time, front bool) {
pt := 4
tx := &StoreTx{ID: id, Hash: fmt.Sprintf("extra%d", id), PayloadType: &pt, DecodedJSON: coverageAdvert, FirstSeen: firstSeen.Format(time.RFC3339)}
ps.mu.Lock()
defer ps.mu.Unlock()
if front {
ps.packets = append([]*StoreTx{tx}, ps.packets...)
} else {
ps.packets = append(ps.packets, tx)
}
ps.byHash[tx.Hash] = tx
ps.byTxID[tx.ID] = tx
ps.byNode[coverageNodePK] = append([]*StoreTx{tx}, ps.byNode[coverageNodePK]...)
}
func TestNodeAnalyticsCoverageWithinMemory(t *testing.T) {
// 10 days in memory, 7 requested: the full range is covered.
ps := setupCoverageStore(t, 10*24)
a, err := ps.GetNodeAnalytics(coverageNodePK, 7)
if err != nil || a == nil {
t.Fatal(err)
}
if a.TimeRange.Partial || a.TimeRange.DataFrom != "" {
t.Errorf("7d inside a 10d window: partial=%v dataFrom=%q", a.TimeRange.Partial, a.TimeRange.DataFrom)
}
if rate(a.ComputedStats.AvailabilityPct) != 100 {
t.Errorf("availability = %v, want 100 (one packet every hour)", rate(a.ComputedStats.AvailabilityPct))
}
if rate(a.ComputedStats.AvgPacketsPerDay) != 24 {
t.Errorf("packets/day = %v, want 24", rate(a.ComputedStats.AvgPacketsPerDay))
}
}
func TestNodeAnalyticsCoverageBeyondMemory(t *testing.T) {
// 3 days in memory, 30 requested: stats must be computed over the 3
// days that exist, not diluted over 30 (live showed 42.6% for a node
// that was 88.7% available).
ps := setupCoverageStore(t, 3*24)
a, err := ps.GetNodeAnalytics(coverageNodePK, 30)
if err != nil || a == nil {
t.Fatal(err)
}
if !a.TimeRange.Partial {
t.Fatal("30d over a 3d window must be partial")
}
ps.mu.RLock()
oldest := ps.packets[0].FirstSeen
ps.mu.RUnlock()
if a.TimeRange.DataFrom != oldest {
t.Errorf("dataFrom = %q, want the oldest packet in memory %q", a.TimeRange.DataFrom, oldest)
}
if a.TimeRange.Days != 30 {
t.Errorf("days = %d, want the requested 30", a.TimeRange.Days)
}
if got := rate(a.ComputedStats.AvailabilityPct); got < 98 || got > 100 {
t.Errorf("availability = %v, want ~100 over the covered 3 days", got)
}
if got := rate(a.ComputedStats.AvgPacketsPerDay); got < 23.5 || got > 24.5 {
t.Errorf("packets/day = %v, want ~24 over the covered 3 days", got)
}
}
func TestNodeAnalyticsCoverageEmptyStore(t *testing.T) {
// Nothing in memory: no partial claim, stats over the requested range.
ps := setupCoverageStore(t, 0)
a, err := ps.GetNodeAnalytics(coverageNodePK, 7)
if err != nil || a == nil {
t.Fatal(err)
}
if a.TimeRange.Partial || rate(a.ComputedStats.AvailabilityPct) != 0 {
t.Errorf("empty store: partial=%v availability=%v", a.TimeRange.Partial, rate(a.ComputedStats.AvailabilityPct))
}
}
// TestNodeAnalyticsWindowIgnoresLocalZone: the window bound is compared as a
// string with UTC FirstSeen values, so it must be formatted in UTC. With a
// +02:00 local zone the old code dropped the oldest two hours of a 7d range.
func TestNodeAnalyticsWindowIgnoresLocalZone(t *testing.T) {
saved := time.Local
time.Local = time.FixedZone("UTC+2", 2*3600)
t.Cleanup(func() { time.Local = saved })
ps := setupCoverageStore(t, 10*24)
a, err := ps.GetNodeAnalytics(coverageNodePK, 7)
if err != nil || a == nil {
t.Fatal(err)
}
if a.ComputedStats.TotalPackets != 7*24 {
t.Errorf("7d window holds %d packets, want %d", a.ComputedStats.TotalPackets, 7*24)
}
if a.TimeRange.From[len(a.TimeRange.From)-1] != 'Z' {
t.Errorf("from = %q, want a UTC timestamp", a.TimeRange.From)
}
h, err := ps.GetNodeHopAnalytics(coverageNodePK, 7)
if err != nil || h == nil {
t.Fatal(err)
}
if h.TimeRange.From[len(h.TimeRange.From)-1] != 'Z' {
t.Errorf("hop analytics from = %q, want a UTC timestamp", h.TimeRange.From)
}
}
// A transmission first heard long ago and heard again recently is loaded
// with a recent chunk and sits at the head of s.packets. It must neither
// move the window edge back nor show up as a lone bucket before the window.
func TestNodeAnalyticsCoverageIgnoresReheardOldTx(t *testing.T) {
ps := setupCoverageStore(t, 3*24)
ps.mu.Lock()
ps.retentionHours = 3 * 24
ps.mu.Unlock()
addCoverageTx(ps, 9001, time.Now().UTC().Add(-85*24*time.Hour), true)
a, err := ps.GetNodeAnalytics(coverageNodePK, 365)
if err != nil || a == nil {
t.Fatal(err)
}
from, err := time.Parse(time.RFC3339, a.TimeRange.DataFrom)
if err != nil || time.Since(from) > 73*time.Hour {
t.Errorf("dataFrom = %q, want the 72h retention edge", a.TimeRange.DataFrom)
}
if a.ComputedStats.TotalPackets != 3*24 {
t.Errorf("totalPackets = %d, want %d (the re-heard old one left out)", a.ComputedStats.TotalPackets, 3*24)
}
for _, b := range a.ActivityTimeline {
if b.Bucket != nil && *b.Bucket < a.TimeRange.DataFrom[:13] {
t.Errorf("bucket %s lies before dataFrom %s", *b.Bucket, a.TimeRange.DataFrom)
}
}
}
// Memory-pressure eviction cuts into the retention window; the edge it left
// is the start of the coverage.
func TestNodeAnalyticsCoverageAfterMemoryEviction(t *testing.T) {
ps := setupCoverageStore(t, 3*24)
edge := time.Now().UTC().Add(-24 * time.Hour).Truncate(time.Second)
ps.mu.Lock()
ps.retentionHours = 3 * 24
ps.evictedThrough = edge.Format(time.RFC3339)
ps.mu.Unlock()
a, err := ps.GetNodeAnalytics(coverageNodePK, 7)
if err != nil || a == nil {
t.Fatal(err)
}
if a.TimeRange.DataFrom != edge.Format(time.RFC3339) {
t.Errorf("dataFrom = %q, want the eviction edge %q", a.TimeRange.DataFrom, edge.Format(time.RFC3339))
}
}
func TestEvictionRecordsEvictedThrough(t *testing.T) {
ps := setupCoverageStore(t, 48)
ps.mu.Lock()
ps.retentionHours = 24
n := ps.EvictStale()
edge := ps.evictedThrough
remaining := ps.packets[0].FirstSeen
ps.mu.Unlock()
if n == 0 {
t.Fatal("nothing evicted")
}
if edge == "" || edge >= remaining {
t.Errorf("evictedThrough = %q, want just before the oldest remaining %q", edge, remaining)
}
}
// Retention equal to the requested range is full coverage, not partial
// (staging: retentionHours 168 marked every 7d request partial). An edge a
// few seconds after the start stays within coverageSlack.
func TestNodeAnalyticsCoverageRetentionEqualsRange(t *testing.T) {
ps := setupCoverageStore(t, 10*24)
ps.mu.Lock()
// The retention edge lies 30 s after the 7d start: inside the slack.
ps.retentionHours = 7*24 - 30.0/3600
ps.oldestLoaded = time.Now().UTC().Add(-7*24*time.Hour - time.Minute).Format(time.RFC3339)
ps.mu.Unlock()
for i := 0; i < 3; i++ {
a, err := ps.GetNodeAnalytics(coverageNodePK, 7)
if err != nil || a == nil {
t.Fatal(err)
}
if a.TimeRange.Partial {
t.Fatalf("run %d: 7d with 168h retention marked partial (dataFrom %q, from %q)", i, a.TimeRange.DataFrom, a.TimeRange.From)
}
}
if a, _ := ps.GetNodeAnalytics(coverageNodePK, 30); a == nil || !a.TimeRange.Partial {
t.Error("30d with 168h retention must be partial")
}
}
// The coverage notice names why the data starts late (#748 review): a store
// holding everything from the oldest packet in the database is not a memory
// limit.
func TestNodeAnalyticsCoverageReason(t *testing.T) {
cases := []struct {
name string
setup func(ps *PacketStore)
want string
}{
{"database younger than the range", func(ps *PacketStore) {}, "database"},
{"retention window", func(ps *PacketStore) { ps.retentionHours = 2 * 24 }, "memory"},
{"memory eviction", func(ps *PacketStore) {
ps.evictedThrough = time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339)
}, "memory"},
{"background load still running", func(ps *PacketStore) {
ps.hotStartupHours = 24
ps.oldestLoaded = time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339)
}, "loading"},
}
for _, c := range cases {
ps := setupCoverageStore(t, 3*24)
ps.mu.Lock()
c.setup(ps)
ps.mu.Unlock()
a, err := ps.GetNodeAnalytics(coverageNodePK, 30)
if err != nil || a == nil {
t.Fatal(err)
}
if !a.TimeRange.Partial || a.TimeRange.DataFromReason != c.want {
t.Errorf("%s: partial=%v reason=%q, want %q", c.name, a.TimeRange.Partial, a.TimeRange.DataFromReason, c.want)
}
}
ps := setupCoverageStore(t, 10*24)
if a, _ := ps.GetNodeAnalytics(coverageNodePK, 7); a == nil || a.TimeRange.DataFromReason != "" {
t.Errorf("full coverage must carry no reason, got %+v", a.TimeRange)
}
}
// Under minCoveredHours the rates are extrapolation (5 packets in 6 min read
// as 1200/day), so they are left out rather than reported (#748 review).
func TestNodeAnalyticsCoverageTooShortForRates(t *testing.T) {
ps := setupCoverageStore(t, 6)
a, err := ps.GetNodeAnalytics(coverageNodePK, 7)
if err != nil || a == nil {
t.Fatal(err)
}
if !a.TimeRange.Partial {
t.Fatal("6 h of data under a 7d range must be partial")
}
if a.ComputedStats.AvailabilityPct != nil || a.ComputedStats.AvgPacketsPerDay != nil {
t.Errorf("rates over 6 h: availability=%v packets/day=%v, want both null",
rate(a.ComputedStats.AvailabilityPct), rate(a.ComputedStats.AvgPacketsPerDay))
}
if a.ComputedStats.TotalPackets != 6 {
t.Errorf("totalPackets = %d, want 6 (counts stay)", a.ComputedStats.TotalPackets)
}
ps = setupCoverageStore(t, 25)
if a, _ := ps.GetNodeAnalytics(coverageNodePK, 7); a == nil || a.ComputedStats.AvailabilityPct == nil {
t.Error("25 h of data must yield rates")
}
}
// A load cut short by the memory cap leaves a gap between the last loaded
// row and what the poller picks up, so the store is only complete from the
// end of the load; packets[0] or the retention edge would overstate it.
func TestLoadChunkedMemoryCapSetsCoverageEdge(t *testing.T) {
store := openReverseTimeStore(t, 1100)
defer store.db.conn.Close()
store.maxMemoryMB = 1 // floors to 1000 packets
store.retentionHours = 30 * 24
before := time.Now().UTC().Truncate(time.Second)
if err := store.LoadChunked(500); err != nil {
t.Fatal(err)
}
if len(store.packets) >= 1100 {
t.Fatalf("cap did not apply: %d packets loaded", len(store.packets))
}
store.mu.RLock()
start, reason := store.coverageStart(time.Now().UTC())
store.mu.RUnlock()
if start.Before(before) || reason != "memory" {
t.Errorf("capped load: coverage start %v (%q), want at or after %v (\"memory\")", start, reason, before)
}
}
func TestLoadChunkedUncappedCoverageIsDatabase(t *testing.T) {
store := openReverseTimeStore(t, 50)
defer store.db.conn.Close()
if err := store.LoadChunked(20); err != nil {
t.Fatal(err)
}
store.mu.RLock()
start, reason := store.coverageStart(time.Now().UTC())
oldest := store.packets[0].FirstSeen
store.mu.RUnlock()
if start.Format(time.RFC3339) != oldest || reason != "database" {
t.Errorf("uncapped load: coverage start %v (%q), want %s (\"database\")", start, reason, oldest)
}
}
// Eviction removes a head prefix of s.packets; the edge is the newest
// FirstSeen in it, not whatever sits last should the slice be out of order.
func TestEvictionRecordsNewestEvicted(t *testing.T) {
ps := setupCoverageStore(t, 48)
ps.mu.Lock()
ps.retentionHours = 24
// Index 23 is the last packet past the 24 h retention (h=24); make it
// older than its neighbour so the slice is out of order there.
newest := ps.packets[22].FirstSeen
ps.packets[23].FirstSeen = time.Now().UTC().Add(-100 * time.Hour).Format(time.RFC3339)
ps.EvictStale()
edge := ps.evictedThrough
ps.mu.Unlock()
if edge != newest {
t.Errorf("evictedThrough = %q, want the newest evicted %q", edge, newest)
}
}