mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-11 13:37:50 +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>
265 lines
8.9 KiB
Go
265 lines
8.9 KiB
Go
package main
|
|
|
|
import (
|
|
"math"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Adaptive offline threshold (#2260, part 2). The fixed infraSilentHours
|
|
// (72 h by default) treats every repeater the same: one heard every minute
|
|
// that goes quiet for an hour is suspicious, a node that sends a few adverts
|
|
// a day is not. For node.offline, a watched node's threshold is learned from
|
|
// its own certain traffic (certainFor), so an outage is mailed within the
|
|
// hour instead of three days later.
|
|
//
|
|
// Learning: certain packets per hour over the week before the node's last
|
|
// certain packet. Ending there, not at now, keeps a silence in progress out
|
|
// of the rate: otherwise the outage itself drags the rate down until the
|
|
// fixed threshold returns and the node reads "back online".
|
|
//
|
|
// Threshold: arrivals treated as Poisson, P(no packet in t) = e^(-λt); for
|
|
// that below one in a million t = ln(10⁶)/λ ≈ 14/λ hours, with λ the 10th
|
|
// percentile of the hourly counts so quiet nights do not alert.
|
|
//
|
|
// Empty runs of hours are classified first. A run of at most
|
|
// adaptiveMaxNormalRun hours that recurs (another empty run on a different
|
|
// day covers one of the same hours of the day) is the node's normal
|
|
// silence: it stays in the percentile, and the threshold is never shorter
|
|
// than the longest such run plus an hour, so it does not mail offline and
|
|
// online every night. Any other run is an outage: it is left out of the
|
|
// learning entirely, so one outage neither stretches the threshold for a
|
|
// week nor makes the node unlearnable.
|
|
//
|
|
// Clamped to [floor, the role's silent window]. Nodes with too little
|
|
// certain traffic (λ < 1/hour, under a day of history) keep the fixed
|
|
// window, and so do nodes whose 10th-percentile hour was heard by fewer
|
|
// than minObservers observers: when one observer carries the node, that
|
|
// observer dropping out must not read as the node going offline.
|
|
|
|
const (
|
|
adaptiveLearnWindow = 7 * 24 * time.Hour
|
|
adaptiveMinHistory = 24 // hours of history before a rate is trusted
|
|
adaptiveMinRate = 1.0 // certain packets per hour at the 10th percentile
|
|
adaptiveSurpriseHours = 13.815510557964 // ln(10⁶): P(silence) < 10⁻⁶
|
|
adaptiveMaxNormalRun = 6 // longest empty run in hours that may be normal silence
|
|
adaptiveRefreshEvery = 10 * time.Minute
|
|
)
|
|
|
|
// certainActivity is a node's certain traffic in its learning window.
|
|
type certainActivity struct {
|
|
From time.Time // start of Hourly[0], UTC, on the hour
|
|
Hourly []int // certain packets per hour, oldest first, zero hours included
|
|
HourObservers []int // distinct observers per hour of those packets
|
|
}
|
|
|
|
// CertainActivity returns, per pubkey with a certain last heard, its certain
|
|
// packets per hour over adaptiveLearnWindow up to that last heard (capped at
|
|
// the store's covered window) and how many observers heard each hour.
|
|
// O(packets of the given nodes), with the read lock taken per node so ingest
|
|
// waits for one node's walk at most, not all of them.
|
|
func (s *PacketStore) CertainActivity(pubkeys []string, now time.Time) map[string]certainActivity {
|
|
now = now.UTC() // FirstSeen is UTC; a local offset breaks the string bounds
|
|
_, pm := s.getCachedNodesAndPM()
|
|
s.mu.RLock()
|
|
covered, _ := s.coverageStart(now)
|
|
s.mu.RUnlock()
|
|
covered = covered.UTC()
|
|
out := make(map[string]certainActivity, len(pubkeys))
|
|
for _, pk := range pubkeys {
|
|
if act, ok := s.certainActivityOf(pk, covered, pm); ok {
|
|
out[pk] = act
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// certainActivityOf is CertainActivity for one node, under its own read lock.
|
|
func (s *PacketStore) certainActivityOf(pk string, covered time.Time, pm *prefixMap) (certainActivity, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
last, ok := parseRelayTS(s.certainHeard[pk])
|
|
if !ok {
|
|
return certainActivity{}, false
|
|
}
|
|
end := last.UTC().Truncate(time.Hour).Add(time.Hour)
|
|
from := end.Add(-adaptiveLearnWindow)
|
|
if covered.After(from) {
|
|
from = covered.Truncate(time.Hour).Add(time.Hour) // first whole hour held
|
|
}
|
|
hours := int(end.Sub(from) / time.Hour)
|
|
if hours <= 0 {
|
|
return certainActivity{}, false
|
|
}
|
|
act := certainActivity{From: from, Hourly: make([]int, hours), HourObservers: make([]int, hours)}
|
|
observers := make([]map[string]struct{}, hours)
|
|
// Pre-filter on the date only: legacy FirstSeen values use a space
|
|
// instead of T and would sort below a same-day RFC3339 bound. The parsed
|
|
// time below decides.
|
|
fromDate := from.Format("2006-01-02")
|
|
for _, tx := range s.byNode[pk] {
|
|
if tx == nil || len(tx.FirstSeen) < 10 || tx.FirstSeen[:10] < fromDate || !certainFor(tx, pk, pm) {
|
|
continue
|
|
}
|
|
t, ok := parseStoreTime(tx.FirstSeen)
|
|
if !ok || t.Before(from) || !t.Before(end) {
|
|
continue
|
|
}
|
|
h := int(t.Sub(from) / time.Hour)
|
|
act.Hourly[h]++
|
|
for _, o := range tx.Observations {
|
|
if o != nil && o.ObserverID != "" {
|
|
if observers[h] == nil {
|
|
observers[h] = map[string]struct{}{}
|
|
}
|
|
observers[h][o.ObserverID] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
for h, set := range observers {
|
|
act.HourObservers[h] = len(set)
|
|
}
|
|
return act, true
|
|
}
|
|
|
|
// adaptiveOfflineAfter is the learned silence after which a node with this
|
|
// activity counts as offline, floored at floor; ok is false when the
|
|
// activity is too thin to learn from (see the file comment). The caller
|
|
// caps it at the role's silent window.
|
|
func adaptiveOfflineAfter(act certainActivity, floor time.Duration, minObservers int) (time.Duration, bool) {
|
|
outage, normalRun := classifySilences(act)
|
|
var counts, observers []int
|
|
for h, c := range act.Hourly {
|
|
if outage[h] {
|
|
continue
|
|
}
|
|
counts = append(counts, c)
|
|
if c > 0 && h < len(act.HourObservers) {
|
|
observers = append(observers, act.HourObservers[h])
|
|
}
|
|
}
|
|
if len(counts) < adaptiveMinHistory || len(observers) == 0 {
|
|
return 0, false
|
|
}
|
|
sort.Ints(counts)
|
|
rate := float64(counts[len(counts)/10])
|
|
if rate < adaptiveMinRate {
|
|
return 0, false
|
|
}
|
|
sort.Ints(observers)
|
|
if observers[len(observers)/10] < minObservers {
|
|
return 0, false
|
|
}
|
|
after := time.Duration(math.Ceil(adaptiveSurpriseHours / rate * float64(time.Hour)))
|
|
if normalRun > 0 {
|
|
// k empty buckets in a row can hide up to k+1 hours of silence.
|
|
if seen := time.Duration(normalRun+1) * time.Hour; after < seen {
|
|
after = seen
|
|
}
|
|
}
|
|
if after < floor {
|
|
after = floor
|
|
}
|
|
return after, true
|
|
}
|
|
|
|
// classifySilences marks the hours of outage runs (empty runs that are not
|
|
// normal silence, see the file comment) and returns the longest normal run.
|
|
func classifySilences(act certainActivity) (outage []bool, longestNormal int) {
|
|
type run struct{ start, n int }
|
|
var runs []run
|
|
for h := 0; h < len(act.Hourly); h++ {
|
|
if act.Hourly[h] != 0 {
|
|
continue
|
|
}
|
|
r := run{start: h}
|
|
for h < len(act.Hourly) && act.Hourly[h] == 0 {
|
|
r.n++
|
|
h++
|
|
}
|
|
runs = append(runs, r)
|
|
}
|
|
day := func(h int) int { return int(act.From.Add(time.Duration(h)*time.Hour).Unix() / 86400) }
|
|
hod := func(h int) int { return act.From.Add(time.Duration(h) * time.Hour).Hour() }
|
|
recurs := func(i int) bool {
|
|
a := runs[i]
|
|
for j, b := range runs {
|
|
if j == i || day(b.start) == day(a.start) {
|
|
continue
|
|
}
|
|
for x := a.start; x < a.start+a.n; x++ {
|
|
for y := b.start; y < b.start+b.n; y++ {
|
|
if hod(x) == hod(y) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
outage = make([]bool, len(act.Hourly))
|
|
for i, r := range runs {
|
|
if r.n <= adaptiveMaxNormalRun && recurs(i) {
|
|
if r.n > longestNormal {
|
|
longestNormal = r.n
|
|
}
|
|
continue
|
|
}
|
|
for h := r.start; h < r.start+r.n; h++ {
|
|
outage[h] = true
|
|
}
|
|
}
|
|
return outage, longestNormal
|
|
}
|
|
|
|
// adaptiveEntry is one cached threshold; ok false caches "not learnable".
|
|
type adaptiveEntry struct {
|
|
after time.Duration
|
|
ok bool
|
|
at time.Time
|
|
}
|
|
|
|
// offlineWindows returns the learned offline window per watched pubkey that
|
|
// has one, recomputing entries older than adaptiveRefreshEvery: the rate
|
|
// moves slowly and the walk over a week of packets need not run every tick.
|
|
// Entries for nodes no longer watched are dropped. Loop goroutine only.
|
|
func (n *notifier) offlineWindows(pubkeys []string, now time.Time) map[string]time.Duration {
|
|
set := n.a.set.notify.adaptive
|
|
if !set.enabled || len(pubkeys) == 0 {
|
|
n.adaptive = nil
|
|
return nil
|
|
}
|
|
if n.adaptive == nil {
|
|
n.adaptive = map[string]adaptiveEntry{}
|
|
}
|
|
watched := make(map[string]bool, len(pubkeys))
|
|
var stale []string
|
|
for _, pk := range pubkeys {
|
|
pk = strings.ToLower(pk)
|
|
watched[pk] = true
|
|
if e, ok := n.adaptive[pk]; !ok || now.Sub(e.at) >= adaptiveRefreshEvery {
|
|
stale = append(stale, pk)
|
|
}
|
|
}
|
|
for pk := range n.adaptive {
|
|
if !watched[pk] {
|
|
delete(n.adaptive, pk)
|
|
}
|
|
}
|
|
if len(stale) > 0 {
|
|
acts := n.src.certainActivity(stale, now)
|
|
for _, pk := range stale {
|
|
after, ok := adaptiveOfflineAfter(acts[pk], set.floor, set.minObservers)
|
|
n.adaptive[pk] = adaptiveEntry{after: after, ok: ok, at: now}
|
|
}
|
|
}
|
|
out := make(map[string]time.Duration, len(n.adaptive))
|
|
for pk, e := range n.adaptive {
|
|
if e.ok {
|
|
out[pk] = e.after
|
|
}
|
|
}
|
|
return out
|
|
}
|