mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-29 17:39:22 +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>
179 lines
7.2 KiB
JavaScript
179 lines
7.2 KiB
JavaScript
/* === CoreScope: node-hop-analytics.js === */
|
|
'use strict';
|
|
// Hop count at this node (issue #1812): for every flood packet the node
|
|
// forwarded, the number of hops the packet already had when the node's
|
|
// flood.max check ran. Data: GET /api/nodes/{pubkey}/hop_analytics?days=N.
|
|
(function () {
|
|
// One filter per repeater CLI limit (firmware src/helpers/RoutingPolicy.h):
|
|
// flood.max applies to every flood, flood.max.unscoped to un-scoped floods,
|
|
// flood.max.advert to adverts.
|
|
const FILTERS = [
|
|
{ id: 'flood', label: 'flood.max', tag: null, noun: 'flood packets' },
|
|
{ id: 'flood_unscoped', label: 'flood.max.unscoped', tag: 'unscoped', noun: 'un-scoped flood packets' },
|
|
{ id: 'flood_adverts', label: 'flood.max.advert', tag: 'advert', noun: 'flood adverts' },
|
|
];
|
|
|
|
let chart = null;
|
|
|
|
function filterById(id) {
|
|
return FILTERS.find(f => f.id === id) || FILTERS[0];
|
|
}
|
|
|
|
function filterHops(packets, filterId) {
|
|
const tag = filterById(filterId).tag;
|
|
const out = [];
|
|
for (const p of packets || []) {
|
|
if (!tag || (p.tags && p.tags.indexOf(tag) >= 0)) out.push(Number(p.hops));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function hopHistogram(values) {
|
|
let max = -1;
|
|
for (const v of values) if (v > max) max = v;
|
|
const counts = new Array(max + 1).fill(0);
|
|
for (const v of values) counts[v]++;
|
|
return counts;
|
|
}
|
|
|
|
// Quartiles by linear interpolation between closest ranks, Tukey whiskers at
|
|
// 1.5 IQR. Hop counts are small integers, so sorting a copy stays cheap.
|
|
function hopBoxStats(values) {
|
|
if (!values.length) return null;
|
|
const s = values.slice().sort((a, b) => a - b);
|
|
const q = p => {
|
|
const h = (s.length - 1) * p;
|
|
const lo = Math.floor(h);
|
|
return lo + 1 < s.length ? s[lo] + (h - lo) * (s[lo + 1] - s[lo]) : s[lo];
|
|
};
|
|
const q1 = q(0.25), median = q(0.5), q3 = q(0.75);
|
|
const lowFence = q1 - 1.5 * (q3 - q1), highFence = q3 + 1.5 * (q3 - q1);
|
|
let whiskerLow = q1, whiskerHigh = q3, outliers = 0;
|
|
for (const v of s) {
|
|
if (v < lowFence || v > highFence) { outliers++; continue; }
|
|
if (v < whiskerLow) whiskerLow = v;
|
|
if (v > whiskerHigh) whiskerHigh = v;
|
|
}
|
|
return { n: s.length, min: s[0], q1, median, q3, max: s[s.length - 1], whiskerLow, whiskerHigh, outliers };
|
|
}
|
|
|
|
function plural(n, word) {
|
|
return n + ' ' + word + (n === 1 ? '' : 's');
|
|
}
|
|
|
|
function renderHopSection(data, filterId) {
|
|
const filter = filterById(filterId);
|
|
const values = filterHops(data.packets, filter.id);
|
|
const stats = hopBoxStats(values);
|
|
const ambiguous = Number(data.ambiguous) || 0;
|
|
const chips = FILTERS.map(f =>
|
|
`<button type="button" data-hop-filter="${f.id}" aria-pressed="${f.id === filter.id}"${f.id === filter.id ? ' class="active"' : ''}>${f.label}</button>`
|
|
).join('');
|
|
const summary = stats
|
|
? `${plural(stats.n, 'packet')} (${filter.noun}) · median ${stats.median} · middle half ${stats.q1} to ${stats.q3} · max ${stats.max} hops`
|
|
: '';
|
|
const body = stats
|
|
? `<canvas id="hopCountChart" role="img" aria-label="Histogram and box plot of hop counts at this node for ${filter.noun}"></canvas>`
|
|
: `<div style="padding:20px;text-align:center;color:var(--text-muted);font-size:12px">No forwarded ${filter.noun} attributed to this node in this window.</div>`;
|
|
const note = ambiguous
|
|
? `<div class="analytics-chart-desc">${plural(ambiguous, 'packet')} left out: the path prefix of this node is shared with another node there, so its hop position is unknown.</div>`
|
|
: '';
|
|
return `
|
|
<div class="analytics-time-range" role="group" aria-label="Hop limit to inspect">${chips}</div>
|
|
<div style="font-size:12px;margin-bottom:6px">${summary}</div>
|
|
${body}
|
|
${note}`;
|
|
}
|
|
|
|
function cssVar(name) {
|
|
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
}
|
|
|
|
// Box plot drawn in the chart's top padding, on the histogram's own x axis:
|
|
// the category scale puts hop count h at index h, so fractional quartiles are
|
|
// interpolated between neighbouring category centres.
|
|
function boxPlotPlugin(stats) {
|
|
return {
|
|
id: 'hopBoxPlot',
|
|
afterDatasetsDraw(c) {
|
|
const x = c.scales.x;
|
|
const px = v => {
|
|
const lo = Math.floor(v);
|
|
const a = x.getPixelForValue(lo);
|
|
return v === lo ? a : a + (v - lo) * (x.getPixelForValue(lo + 1) - a);
|
|
};
|
|
const ctx = c.ctx;
|
|
const mid = c.chartArea.top - 16;
|
|
ctx.save();
|
|
ctx.strokeStyle = cssVar('--text');
|
|
ctx.fillStyle = cssVar('--accent-bg');
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(px(stats.whiskerLow), mid); ctx.lineTo(px(stats.q1), mid);
|
|
ctx.moveTo(px(stats.q3), mid); ctx.lineTo(px(stats.whiskerHigh), mid);
|
|
ctx.moveTo(px(stats.whiskerLow), mid - 5); ctx.lineTo(px(stats.whiskerLow), mid + 5);
|
|
ctx.moveTo(px(stats.whiskerHigh), mid - 5); ctx.lineTo(px(stats.whiskerHigh), mid + 5);
|
|
ctx.stroke();
|
|
ctx.fillRect(px(stats.q1), mid - 8, px(stats.q3) - px(stats.q1), 16);
|
|
ctx.strokeRect(px(stats.q1), mid - 8, px(stats.q3) - px(stats.q1), 16);
|
|
ctx.beginPath();
|
|
ctx.moveTo(px(stats.median), mid - 8); ctx.lineTo(px(stats.median), mid + 8);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
};
|
|
}
|
|
|
|
function drawChart(data, filterId) {
|
|
if (chart) { chart.destroy(); chart = null; }
|
|
const canvas = document.getElementById('hopCountChart');
|
|
if (!canvas) return;
|
|
const values = filterHops(data.packets, filterId);
|
|
const counts = hopHistogram(values);
|
|
chart = new Chart(canvas, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: counts.map((_, h) => String(h)),
|
|
datasets: [{ label: 'Packets', data: counts, backgroundColor: cssVar('--accent'), borderWidth: 0 }]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
layout: { padding: { top: 32 } },
|
|
plugins: { legend: { display: false } },
|
|
scales: {
|
|
x: { title: { display: true, text: 'Hops already in the path when this node forwarded' } },
|
|
y: { beginAtZero: true, title: { display: true, text: 'Packets' } }
|
|
}
|
|
},
|
|
plugins: [boxPlotPlugin(hopBoxStats(values))]
|
|
});
|
|
}
|
|
|
|
function render(container, data, filterId) {
|
|
container.innerHTML = renderHopSection(data, filterId);
|
|
container.querySelectorAll('[data-hop-filter]').forEach(btn => {
|
|
btn.addEventListener('click', () => render(container, data, btn.dataset.hopFilter));
|
|
});
|
|
drawChart(data, filterId);
|
|
}
|
|
|
|
async function load(container, pubkey, days) {
|
|
destroy();
|
|
let data;
|
|
try {
|
|
data = await api('/nodes/' + encodeURIComponent(pubkey) + '/hop_analytics?days=' + days, { ttl: CLIENT_TTL.nodeAnalytics });
|
|
} catch (e) {
|
|
container.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text-muted);font-size:12px">Hop counts unavailable: ' + escapeHtml(e.message) + '</div>';
|
|
return;
|
|
}
|
|
if (!container.isConnected) return;
|
|
render(container, data, 'flood');
|
|
}
|
|
|
|
function destroy() {
|
|
if (chart) { chart.destroy(); chart = null; }
|
|
}
|
|
|
|
window.NodeHopAnalytics = { FILTERS, filterHops, hopHistogram, hopBoxStats, renderHopSection, load, destroy };
|
|
})();
|