Files
meshcore-analyzer/cmd/server/certain_heard.go
T
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

219 lines
7.3 KiB
Go

package main
import (
"strings"
"time"
"github.com/meshcore-analyzer/packetpath"
)
// Certain liveness (#2260). certainHeard holds, per pubkey, the newest
// FirstSeen of a transmission that can only have come from that node:
//
// - its own packets (decoded pubKey / srcPubKey), and
// - flood relay hops whose prefix, at the packet's path hash size, matches
// exactly one node that can appear in a path, and no retired one
// (inactive_nodes) that may still relay.
//
// A 1-byte hop shared by several repeaters is not evidence: the resolver may
// still attribute it (affinity, geography) for analytics, but a silent node
// must not stay "Active" on traffic from a namesake prefix. The firmware
// lets the originator pick the hash size for the whole path
// (Mesh::sendFlood) and every relay appends its own hash at that size
// (Mesh::routeRecvPacket), so a multibyte repeater legitimately appears as a
// 1-byte hop; only its uniqueness decides. Direct-route paths list the hops
// still to come (Mesh::removeSelfFromPath), and TRACE reuses the path bytes
// for SNR, so neither proves a hop transmitted.
//
// Liveness reads it: Last Heard, the node status, the nodes list and the
// notify offline check. One entry per node that was ever certainly heard,
// dropped with the node's byNode list on eviction.
// noteCertainHeard records tx as certain evidence for pubkey. Caller holds
// s.mu for writing.
func (s *PacketStore) noteCertainHeard(pubkey string, tx *StoreTx) {
if pubkey == "" || tx == nil || tx.FirstSeen == "" {
return
}
if s.certainHeard == nil {
s.certainHeard = make(map[string]string)
}
if tx.FirstSeen > s.certainHeard[pubkey] {
s.certainHeard[pubkey] = tx.FirstSeen
}
}
// signatureFailed reports a decoded advert whose signature did not verify
// (signatureValid false): a corrupted or forged advert is no evidence the
// node transmitted. Absent means not checked, which is not a failure.
func (tx *StoreTx) signatureFailed() bool {
v, ok := tx.ParsedDecoded()["signatureValid"].(bool)
return ok && !v
}
// relayHopHexLen is the hex length of one relay hop in tx's path, or 0 when
// the path is no evidence of who transmitted: not a flood route, a TRACE, or
// an unreadable header.
func relayHopHexLen(tx *StoreTx) int {
if tx.PayloadType != nil && !packetpath.PathBytesAreHops(byte(*tx.PayloadType)) {
return 0
}
routeType, pathByte, ok := packetpath.PathByte(tx.RawHex)
if !ok || (routeType != packetpath.RouteFlood && routeType != packetpath.RouteTransportFlood) {
return 0
}
size := int(pathByte>>6) + 1
if size > 3 { // reserved (firmware Packet::isValidPathLen)
return 0
}
return 2 * size
}
// noteCertainRelays records tx for each resolved relay hop pubkey whose hop
// prefix is unique among path-capable nodes in pm. pm nil records nothing.
// Caller holds s.mu for writing.
func (s *PacketStore) noteCertainRelays(tx *StoreTx, pks []string, pm *prefixMap) {
if pm == nil || len(pks) == 0 {
return
}
n := relayHopHexLen(tx)
if n == 0 {
return
}
for _, pk := range pks {
if uniqueHopPrefix(pk, n, pm) {
s.noteCertainHeard(pk, tx)
}
}
}
// uniqueHopPrefix reports whether pk's first n hex characters, a relay hop
// at that hash size, match exactly one path-capable node in pm, pk itself,
// and no retired one. A repeater that never advertised is unknown to both
// and cannot be ruled out.
func uniqueHopPrefix(pk string, n int, pm *prefixMap) bool {
if pm == nil || n == 0 || len(pk) < n {
return false
}
prefix := strings.ToLower(pk[:n])
if _, shared := pm.retired[prefix]; shared {
return false
}
c := pm.relayCandidates(prefix)
return len(c) == 1 && strings.EqualFold(c[0].PublicKey, pk)
}
// markRetired loads the hop prefixes (1 to 3 bytes) of the path-capable
// nodes in inactive_nodes into pm.retired. Rows of nodes also in nodes are
// skipped: retention copies a node there and nothing removes it when the
// node is heard again, and a node is no namesake of itself. A database
// without the table leaves it empty.
func (pm *prefixMap) markRetired(db *DB) error {
if db == nil || !db.inactiveNodesPresent() {
return nil
}
rows, err := db.conn.Query(`SELECT public_key, COALESCE(role, '') FROM inactive_nodes
WHERE lower(public_key) NOT IN (SELECT lower(public_key) FROM nodes)`)
if err != nil {
return err
}
defer rows.Close()
retired := map[string]struct{}{}
for rows.Next() {
var pk, role string
if err := rows.Scan(&pk, &role); err != nil {
return err
}
if !canAppearInPath(role) {
continue
}
pk = strings.ToLower(pk)
for n := 2; n <= 6 && n <= len(pk); n += 2 {
retired[pk[:n]] = struct{}{}
}
}
if err := rows.Err(); err != nil {
return err
}
pm.retired = retired
return nil
}
// certainFor reports whether tx, listed in byNode[pk], is certain evidence
// for pk under the same rule as certainHeard: pk sent it, or it is a flood
// relay whose hop prefix only pk has. Packet-level counterpart of
// certainHeard for analytics and the adaptive offline threshold; it uses the
// prefix map of now, certainHeard the one at ingest. Reads tx only.
func certainFor(tx *StoreTx, pk string, pm *prefixMap) bool {
if tx == nil || pk == "" {
return false
}
if tx.DecodedJSON != "" && strings.Contains(tx.DecodedJSON, "ubKey") && !tx.signatureFailed() {
if d := tx.ParsedDecoded(); d != nil {
for _, f := range []string{"pubKey", "srcPubKey"} {
if v, _ := d[f].(string); v != "" && strings.EqualFold(v, pk) {
return true
}
}
}
}
return uniqueHopPrefix(pk, relayHopHexLen(tx), pm)
}
// lastHeardValue is a node's last_heard in /api/nodes and the node detail:
// the newer of its DB last_seen (adverts; nil or unparsable counts as
// never) and heard, its certain last heard from LastHeardMap (zero for
// none). Before #2260 last_heard was a plain copy of last_seen.
func lastHeardValue(lastSeen interface{}, heard time.Time) interface{} {
if heard.IsZero() {
return lastSeen
}
if s, ok := lastSeen.(string); ok {
if t, ok := parseRelayTS(s); ok && !heard.After(t) {
return lastSeen
}
}
return heard.UTC().Format("2006-01-02T15:04:05.000Z")
}
// longestSilence is the longest gap between consecutive hourly buckets
// (sorted RFC3339 keys) and where it started, including the silence from
// the start of the covered window (from) to the first bucket and the one
// still running from the last bucket to now: an outage in progress is
// exactly the silence worth seeing. No bucket at all is silence over the
// whole window.
func longestSilence(buckets []string, from, now time.Time) (int, interface{}) {
var longest int
var start interface{}
consider := func(begin string, to time.Time) {
t, err := time.Parse(time.RFC3339, begin)
if err != nil {
return
}
if gap := int(to.Sub(t).Milliseconds()); gap > longest {
longest, start = gap, begin
}
}
fromStr := from.UTC().Format(time.RFC3339)
if len(buckets) == 0 {
consider(fromStr, now)
return longest, start
}
if t, err := time.Parse(time.RFC3339, buckets[0]); err == nil && t.Sub(from) > time.Hour {
consider(fromStr, t)
}
for i := 1; i < len(buckets); i++ {
if t, err := time.Parse(time.RFC3339, buckets[i]); err == nil {
consider(buckets[i-1], t)
}
}
if len(buckets) > 0 {
// The last bucket covers its whole hour.
last := buckets[len(buckets)-1]
if t, err := time.Parse(time.RFC3339, last); err == nil && now.Sub(t) > time.Hour {
consider(last, now)
}
}
return longest, start
}