mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-11 21:18:04 +00:00
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>
363 lines
13 KiB
Go
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)
|
|
}
|
|
}
|