mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-11 23:38:17 +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>
219 lines
7.3 KiB
Go
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
|
|
}
|