mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-25 20:23:40 +00:00
## Summary
Adds per-node hop-count statistics so repeater operators can choose
`flood.max`, `flood.max.unscoped` and `flood.max.advert` from what their
node actually sees.
- New endpoint `GET /api/nodes/{pubkey}/hop_analytics?days=N`
(`cmd/server/routes.go:299`, `cmd/server/node_hop_analytics.go:312`),
separate from `/analytics` as requested in the issue.
- New card "Hop Count at This Node" on the node analytics page
(`public/node-hop-analytics.js`, wired at
`public/node-analytics.js:130,174`): histogram of hop counts with a box
plot on the same x axis, filters `flood.max` (default),
`flood.max.advert`, `flood.max.unscoped`, driven by the existing range
picker.
- The existing "Hop Distribution" chart is unchanged: it shows path
length at the observer, a different quantity.
- No `direct` tag, although the issue lists one: for DIRECT packets the
path is the remaining route and no flood limit applies, so there is no
hop count to report.
## Hop count definition (firmware 0679dbef)
- `src/helpers/RoutingPolicy.h:15-21`: limits compare
`getPathHashCount()`; `.unscoped` applies to route type FLOOD, `.advert`
to adverts.
- `src/Mesh.cpp:344-350`: `routeRecvPacket` checks with n hashes in the
path, then writes its own hash at index n. So hops = the node's
zero-based index in the path, no +1.
- `src/Mesh.cpp:265-285`: a node forwards a flood once;
`src/Mesh.cpp:651,680`: an originator never forwards its own flood.
- DIRECT packets are excluded: their path is the remaining route
(`src/Mesh.cpp:78-103,334-341`).
Response: `{timeRange, packets: [{hash, timestamp, hops, tags}],
ambiguous}`. Tags: `flood`, `scoped` or `unscoped`, `advert`. Documented
in `docs/api-spec.md:679` and `cmd/server/openapi.go:90`.
## Attribution
`cmd/server/node_hop_analytics.go:198-309`. The result depends only on
the observed paths, the prefix map and the neighbor graph, so it is the
same after a restart as after live ingest.
- Every observation of every flood packet in the window is read.
`byNode` holds the server resolver's pick at ingest and other picks
after a cold load; `byPathHop` indexes only each packet's longest path,
which for a busy relay often runs through another branch of the flood.
- A packet counts when the node's prefix sits at exactly one index
across its observations, and either the node is the only relay candidate
for that prefix (`prefixMap.relayCandidates`,
`cmd/server/store.go:6795`), or the hop resolves to the node under the
ingestor's strict rule (`cmd/ingestor/path_resolver.go:143-214`) in at
least one observation and to another node in none. Strict rule: earlier
hops identified without a tiebreak, exactly one candidate adjacent in
`neighbor_edges` to the previous hop (the originator for hop 0 of an
advert), nodes already on the path excluded.
- The server resolver's tiebreaks (affinity, GPS distance, advert count,
pubkey order) are not used.
- Everything else with the node's prefix goes to `ambiguous`. In
practice that is most packets with a colliding 1-byte path hash.
On a read-only 7-day dump of a 1,669-node mesh DB, for one busy
repeater: 23,081 packets attributed, 11,437 ambiguous. Taking candidates
from `byPathHop` instead gave 9,995 attributed, with the histogram mode
moved from 2 to 3-5 hops.
## Performance
Scans `s.packets` under the read lock, no SQL per packet. Per
observation: one substring test for the node's first prefix byte; the
hop scan only for observations containing it; the strict walk only for
colliding prefixes, with per-request caches for candidates and
adjacency. `BenchmarkNodeHopPackets` models one 7-day request at that
scale (73,782 flood packets, 1,430,280 observations): 44-87 ms/op, 13.4
MB, 40 allocs on a throttling laptop.
Response size for that repeater over 7 days: about 23k entries, 2.3 MB
JSON, 375 KB gzipped. `hash` and `timestamp` are 61% of the raw and 91%
of the gzipped bytes; they stay because the issue asks for them so a
client can join entries to packets and bin by time.
## Tests
- Go: `cmd/server/node_hop_analytics_test.go`: 12 unit tests, a
live-ingest test through `IngestNewFromDB` (a colliding prefix without
independent attribution goes to `ambiguous`, not to the node the
resolver picked), live ingest versus cold load of the same DB, route
test, benchmark. 15 mutations of the attribution logic each fail a test.
- JS: `test-node-hop-analytics.js` (filters, histogram, quartiles and
whiskers with a fixture that separates 1.5 IQR from 3 IQR, render),
registered in `test-all.sh` and `.github/workflows/deploy.yml`.
- `gofmt`, `go vet ./...`, `go test ./...` in `cmd/server`,
`scripts/check-css-vars.js` pass.
## Staging validation
Build `c646310f` (this PR's review follow-up together with the other
open follow-ups), after a container restart and full load, on a busy
Belgian repeater:
- `hop_analytics?days=7`: 23,302 packets, 11,548 ambiguous, median 4,
adverts never above hop 7 (matching the firmware default
`flood_max_advert = 8`, `examples/simple_repeater/MyMesh.cpp:922`), 1.2
s. The first version reported 23,035 packets and 86 ambiguous in 534 ms,
because it trusted the resolver's pick for colliding prefixes.
- The card rendered on the first version with no console errors; the
rework does not touch the frontend beyond a test fixture.
## Not verified
- Response time and lock hold for 30 days on the busiest node on a
14-day store.
- Server relay candidates exclude companions and listeners while the
ingestor's prefix index does not, so a few strict attributions can
differ from the ingestor's persisted `resolved_path`.
- Identical numbers across a second container restart were shown in a Go
test, not repeated on staging.
- Dark theme, phone width, and switching the range picker in the
browser.
- Filter state is not reflected in the URL hash (the range picker is not
either).
Fixes #1812
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
85 lines
3.8 KiB
JavaScript
85 lines
3.8 KiB
JavaScript
'use strict';
|
|
// Issue #1812: hop count at this node, node analytics page. Loads the browser
|
|
// IIFE in a vm sandbox (pattern from test-node-reach-coverage.js) and exercises
|
|
// the filter, histogram, box statistics and section render.
|
|
const assert = require('assert');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const vm = require('vm');
|
|
|
|
const code = fs.readFileSync(path.join(__dirname, 'public', 'node-hop-analytics.js'), 'utf8');
|
|
const sandbox = { window: {}, console };
|
|
vm.createContext(sandbox);
|
|
vm.runInContext(code, sandbox);
|
|
const H = sandbox.window.NodeHopAnalytics;
|
|
|
|
let passed = 0;
|
|
function test(name, fn) {
|
|
fn();
|
|
passed++;
|
|
console.log(' ok ' + name);
|
|
}
|
|
|
|
const packets = [
|
|
{ hash: 'a', hops: 0, tags: ['flood', 'unscoped'] },
|
|
{ hash: 'b', hops: 2, tags: ['flood', 'scoped'] },
|
|
{ hash: 'c', hops: 5, tags: ['flood', 'unscoped', 'advert'] },
|
|
{ hash: 'd', hops: 1, tags: ['flood', 'scoped', 'advert'] },
|
|
];
|
|
|
|
test('filters follow the firmware limits: flood.max all, .unscoped and .advert by tag', () => {
|
|
assert.deepStrictEqual(Array.from(H.filterHops(packets, 'flood')), [0, 2, 5, 1]);
|
|
assert.deepStrictEqual(Array.from(H.filterHops(packets, 'flood_unscoped')), [0, 5]);
|
|
assert.deepStrictEqual(Array.from(H.filterHops(packets, 'flood_adverts')), [5, 1]);
|
|
assert.deepStrictEqual(Array.from(H.filterHops(packets, 'bogus')), [0, 2, 5, 1], 'unknown filter falls back to flood');
|
|
});
|
|
|
|
test('histogram has one bucket per hop count from 0 to max', () => {
|
|
assert.deepStrictEqual(Array.from(H.hopHistogram([0, 1, 1, 3])), [1, 2, 0, 1]);
|
|
assert.deepStrictEqual(Array.from(H.hopHistogram([])), []);
|
|
});
|
|
|
|
test('box stats: interpolated quartiles, 1.5 IQR whiskers, outliers counted', () => {
|
|
// 8 lies between the 1.5 IQR fence (6) and a 3 IQR fence (9), so the
|
|
// fence factor decides whether it is an outlier.
|
|
const s = H.hopBoxStats([8, 0, 1, 3, 1]);
|
|
assert.strictEqual(s.n, 5);
|
|
assert.strictEqual(s.min, 0);
|
|
assert.strictEqual(s.q1, 1);
|
|
assert.strictEqual(s.median, 1);
|
|
assert.strictEqual(s.q3, 3);
|
|
assert.strictEqual(s.max, 8);
|
|
assert.strictEqual(s.whiskerLow, 0);
|
|
assert.strictEqual(s.whiskerHigh, 3, 'upper fence is q3 + 1.5*IQR = 6, so the whisker stops at 3');
|
|
assert.strictEqual(s.outliers, 1);
|
|
|
|
const even = H.hopBoxStats([4, 1, 3, 2]);
|
|
assert.strictEqual(even.q1, 1.75);
|
|
assert.strictEqual(even.median, 2.5);
|
|
assert.strictEqual(even.q3, 3.25);
|
|
assert.strictEqual(H.hopBoxStats([]), null);
|
|
});
|
|
|
|
test('section render: firmware-named chips, active filter, summary and ambiguous note', () => {
|
|
const html = H.renderHopSection({ packets, ambiguous: 2 }, 'flood_adverts');
|
|
assert.ok(html.includes('>flood.max<'), 'flood.max chip');
|
|
assert.ok(html.includes('>flood.max.unscoped<'), 'flood.max.unscoped chip');
|
|
assert.ok(html.includes('>flood.max.advert<'), 'flood.max.advert chip');
|
|
assert.ok(/data-hop-filter="flood_adverts"[^>]*aria-pressed="true"/.test(html), 'selected chip is pressed');
|
|
assert.ok(/data-hop-filter="flood"[^>]*aria-pressed="false"/.test(html), 'other chips are not pressed');
|
|
assert.ok(html.includes('2 packets'), 'packet count for the advert filter');
|
|
assert.ok(html.includes('median 3'), 'median of [5, 1]');
|
|
assert.ok(html.includes('max 5'), 'max of [5, 1]');
|
|
assert.ok(html.includes('id="hopCountChart"'), 'chart canvas');
|
|
assert.ok(html.includes('2 packets left out'), 'ambiguous packets are reported');
|
|
});
|
|
|
|
test('section render: empty filter shows a message instead of a chart', () => {
|
|
const html = H.renderHopSection({ packets: [packets[1]], ambiguous: 0 }, 'flood_unscoped');
|
|
assert.ok(!html.includes('id="hopCountChart"'), 'no canvas without data');
|
|
assert.ok(html.includes('No forwarded'), 'empty message');
|
|
assert.ok(!html.includes('left out'), 'no ambiguous note when zero');
|
|
});
|
|
|
|
console.log('node-hop-analytics: ' + passed + ' tests passed');
|