mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-11 11:57:57 +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>
62 lines
2.4 KiB
JavaScript
62 lines
2.4 KiB
JavaScript
/**
|
|
* #2260 — node analytics timeline: each bucket splits into packets that can
|
|
* only be this node (certain) and packets attributed through a hop prefix it
|
|
* shares (uncertain). Loads the real public/node-analytics.js in a vm sandbox.
|
|
*/
|
|
'use strict';
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const vm = require('vm');
|
|
const assert = require('assert');
|
|
|
|
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
let passed = 0, failed = 0;
|
|
function t(name, fn) {
|
|
try { fn(); passed++; console.log(' ✓ ' + name); }
|
|
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
|
|
}
|
|
|
|
const ctx = { window: {}, registerPage: () => {}, Date, Math, Number, String, isNaN, console };
|
|
vm.createContext(ctx);
|
|
vm.runInContext(fs.readFileSync(path.join(REPO_ROOT, 'public', 'node-analytics.js'), 'utf8'), ctx);
|
|
const series = ctx.window.NodeAnalyticsTimeline && ctx.window.NodeAnalyticsTimeline.series;
|
|
|
|
console.log('node-analytics.js certain timeline (#2260)');
|
|
|
|
t('exposes NodeAnalyticsTimeline.series', () => {
|
|
assert.strictEqual(typeof series, 'function');
|
|
});
|
|
|
|
t('splits each bucket into certain and uncertain', () => {
|
|
const s = series([{ count: 80, certain: 80 }, { count: 3, certain: 0 }, { count: 10, certain: 4 }]);
|
|
assert.deepStrictEqual(Array.from(s.certain), [80, 0, 4]);
|
|
assert.deepStrictEqual(Array.from(s.uncertain), [0, 3, 6]);
|
|
});
|
|
|
|
t('the silent repeater of #2260: after the outage only uncertain bars remain', () => {
|
|
const s = series([{ count: 94, certain: 94 }, { count: 2, certain: 0 }, { count: 1, certain: 0 }]);
|
|
assert.deepStrictEqual(Array.from(s.certain), [94, 0, 0]);
|
|
assert.strictEqual(s.uncertain.reduce((a, b) => a + b, 0), 3);
|
|
});
|
|
|
|
t('a server without the certain field counts everything as certain', () => {
|
|
const s = series([{ count: 5 }, { count: 0 }]);
|
|
assert.deepStrictEqual(Array.from(s.certain), [5, 0]);
|
|
assert.deepStrictEqual(Array.from(s.uncertain), [0, 0]);
|
|
});
|
|
|
|
t('certain never exceeds count, strings are cast', () => {
|
|
const s = series([{ count: '4', certain: '9' }]);
|
|
assert.deepStrictEqual(Array.from(s.certain), [4]);
|
|
assert.deepStrictEqual(Array.from(s.uncertain), [0]);
|
|
});
|
|
|
|
t('missing timeline yields empty series', () => {
|
|
const s = series(undefined);
|
|
assert.strictEqual(s.certain.length, 0);
|
|
assert.strictEqual(s.uncertain.length, 0);
|
|
});
|
|
|
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
if (failed) process.exit(1);
|