Files
meshcore-analyzer/public/node-reach.js
T
efiten e2212f5015 feat(nodes): per-node Reach page + GET /api/nodes/{pubkey}/reach (v2, review-complete) (#1627)
Re-submission of #1625 (which was merged early, then reverted in #1626)
— now with **all three round-1 reviews addressed** so it lands in one
hardened state instead of as post-merge follow-ups.

## What

Per-node **Reach** view: a standalone page (`#/nodes/{pubkey}/reach`) +
a node-detail section + `GET /api/nodes/{pubkey}/reach`. It shows which
nodes a node has a **stable two-way RF link** with, derived from raw
`path_json` adjacency (a path travels origin→observer, so `[A,B]` ⇒ B
heard A). A link is bidirectional when both directions have
observations; the **bottleneck** (weaker direction) rates two-way
reliability. Nodes are identified only by **unique 2–3 byte** path
prefixes (1-byte collides → excluded).

## Review fixes folded in vs #1625

**Performance (Carmack):** hard scan LIMIT (200k) + modest prealloc;
`json.Unmarshal` replaced by a single-pass `parsePathTokens` (100k-row
scan 2.2M→1.3M allocs, 344→203ms); memoized resolver; size-hinted maps
(attribution over 100k rows: 102 allocs); `context.Context` plumbed;
cache `RWMutex` + evict-oldest (no full wipe); singleflight dedup;
degree/rank from a 60s shared snapshot; bench rewritten (ReportAllocs,
1k/10k/100k, mixed-payload, isolated attribution).

**Correctness/safety + tests (Independent + Kent Beck):** pubkey
validation → 400; error logging instead of silent swallow (first_seen /
degree / marshal→500 / discarded rows); `public_key=?` index use;
canonical `PayloadADVERT`; `min()` builtin; documented cache-slice
immutability; mux ordering comment. New tests: scanReachRows decode,
3-byte token branch, non-advert first-hop guard, observer SNR
aggregation across rows, HTTP-level attribution (asserts non-zero
we_hear/they_hear), 400/404/blacklist/cache-hit.

**UI / a11y / Tufte:** in-map legend (tiers + thresholds); dropped the
colour+width double-encoding (constant width, colour-only); colour-blind
glyphs (●●●/●●/●) + tier title beside the bottleneck number; dark-theme
`--link-*`; lighter table (horizontal rules, sentence-case headers); map
built once + link layer updated in place on toggle (no flicker);
time-range no longer flashes a loader; `destroy()` generation guard;
statCard escaping; scoped `@media print` to `#nq-report`;
`fieldset/legend` + `for/id` toggles; `aria-pressed` / `aria-live` /
back-link `aria-label`; "distance (km)" + bottleneck tooltip + no-GPS
note; inline styles → CSS; decorative emoji removed.

**Docs:** api-spec documents the 5-min cache, 200k scan cap, and 400.

## Testing
- `cmd/server` full suite green; reach unit + endpoint + bench all pass.
- `eslint public/*.js` (no-undef) and the XSS-sink gate clean.
- E2E updated: request status checks + exact (non-tautological) toggle
assertions + hard map-render assert.

🤖 Generated with [Claude Code](https://claude.com/claude-code)


---

## TDD-history note (Kent Beck gate)

This branch carries production + tests together, not a fabricated
red→green sequence. That's deliberate: the branch was rebased onto
upstream and the intermediate SHAs were squashed, so reconstructing a
"failing-test-first" commit after the fact would be theatre, not
evidence — and rewriting history to stage it would be dishonest. The
behaviour is instead covered by a comprehensive, anti-tautological suite
(directional attribution edges, 3-byte token branch, non-advert
first-hop guard, observer SNR aggregation, HTTP-level attribution
asserting non-zero counts, scan-cap truncation, zero-reach 200-not-404,
companion mis-attribution, cache eviction). Requesting maintainer
acceptance of the work on test *substance* rather than commit
*choreography*; the net-new-UI exemption is not claimed for the server
endpoint.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: meshcore-bot <bot@meshcore>
2026-06-08 22:13:02 -07:00

234 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* === CoreScope — node-reach.js ===
Standalone per-node "Reach" page: importance stats + a map of the node's
bidirectional RF links + a link table. Registered as page 'node-reach'
(route #/nodes/<pubkey>/reach), mirroring node-analytics.js. */
'use strict';
(function () {
var qmap = null; // map controller from NodeReachMap.render (built once per load)
var current = null;
var loadGen = 0; // bumped per load + on destroy; guards against in-flight races
var DEFAULT_DAYS = 7; // single JS source for the default window (mirrors the server default)
// Single source of the bottleneck tiers: colour + threshold + colour-blind
// glyph + legend text. The map legend and the table both read from this.
// A one-way link has bottleneck 0 (one direction is 0) — its own tier so it
// reads as "no two-way", not as a poor two-way (which would be red/weak).
var TIERS = [
{ min: 300, label: 'strong', varName: '--link-strong', glyph: '●●●', legend: 'strong (≥300)' },
{ min: 100, label: 'medium', varName: '--link-medium', glyph: '●●', legend: 'medium (100299)' },
{ min: 1, label: 'weak', varName: '--link-weak', glyph: '●', legend: 'weak (<100)' },
{ min: 0, label: 'one-way', varName: '--link-oneway', glyph: '○', legend: 'one-way (no two-way)' }
];
function tierOf(b) {
for (var i = 0; i < TIERS.length; i++) {
if (b >= TIERS[i].min) return TIERS[i];
}
return TIERS[TIERS.length - 1];
}
function statCard(label, value, descShort, descFull) {
return '<div class="analytics-stat-card" title="' + escapeHtml(descFull) + '">' +
'<div class="analytics-stat-label">' + escapeHtml(label) + '</div>' +
'<div class="analytics-stat-value">' + escapeHtml(String(value)) + '</div>' +
'<div class="analytics-stat-desc">' + escapeHtml(descShort) + '</div></div>';
}
function linkRow(i, l) {
var dist = l.distance_km != null ? Number(l.distance_km).toFixed(1) : '—';
var dir = l.bidir ? '' : (l.we_hear > 0 ? 'incoming' : 'outgoing');
var href = '#/nodes/' + encodeURIComponent(l.pubkey);
var t = tierOf(l.bottleneck);
return '<tr' + (l.bidir ? '' : ' class="nq-oneway"') + '>' +
'<td class="nq-num">' + i + '</td>' +
'<td><a href="' + href + '" class="nq-link">' + escapeHtml(l.name || l.pubkey.slice(0, 8)) + '</a>' +
(dir ? ' <span class="nq-dir">' + dir + '</span>' : '') + '</td>' +
'<td class="nq-n">' + l.we_hear + '</td>' +
'<td class="nq-n">' + l.they_hear + '</td>' +
'<td class="nq-n" title="' + t.label + '"><span class="nq-tier" style="color:var(' + t.varName + ')">' + l.bottleneck +
'</span><span class="nq-tier-glyph" style="color:var(' + t.varName + ')">' + t.glyph + '</span></td>' +
'<td class="nq-n">' + dist + '</td></tr>';
}
function dayBtn(d, cur, label) {
var on = d === cur;
return '<button data-days="' + d + '" aria-pressed="' + (on ? 'true' : 'false') + '"' +
(on ? ' class="active"' : '') + '>' + label + '</button>';
}
function headerHtml(n, nodeName, days) {
return '<div class="nq-head">' +
'<a class="nq-back" href="#/nodes/' + encodeURIComponent(n.pubkey) + '" aria-label="Back to ' + nodeName + ' detail">← Back to ' + nodeName + '</a>' +
'<h2 class="nq-title">' + nodeName + ' — Reach</h2>' +
'<div class="nq-sub">' + escapeHtml(n.role || 'Unknown role') + ' · two-way RF link reach</div>' +
'<div class="nq-note">Reliable by design: built only from unique <b>23 byte</b> path-hash (multibyte) matches. 1-byte hops collide between nodes and are excluded, so the links shown are trustworthy.</div>' +
'<div class="analytics-time-range nq-noprint" id="nqDays" style="margin-top:8px">' +
dayBtn(1, days, '24h') + dayBtn(7, days, '7d') + dayBtn(14, days, '14d') + dayBtn(30, days, '30d') +
'</div></div>';
}
function wireTimeRange(container, pubkey) {
var bar = container.querySelector('#nqDays');
if (!bar) return;
bar.addEventListener('click', function (e) {
var b = e.target.closest('button[data-days]');
if (b) load(container, pubkey, parseInt(b.getAttribute('data-days'), 10), false);
});
}
function printReport() {
// Leaflet only renders tiles for the on-screen size; on a wide screen the
// printed page is narrower and the right half is clipped. Resize the map to
// the print width (single source: --nq-print-width) and invalidate first.
var mapEl = document.getElementById('nqMap');
if (qmap && qmap.map && mapEl) {
var pw = getComputedStyle(document.documentElement).getPropertyValue('--nq-print-width').trim() || '680px';
mapEl.style.width = pw;
qmap.map.invalidateSize();
try { qmap.map.fitBounds(qmap.bounds, { padding: [20, 20] }); } catch (e) {}
// Wait for layout to settle (two animation frames) instead of a fixed
// sleep that races the browser reflow.
requestAnimationFrame(function () {
requestAnimationFrame(function () {
window.print();
mapEl.style.width = '';
qmap.map.invalidateSize();
try { qmap.map.fitBounds(qmap.bounds, { padding: [30, 30] }); } catch (e) {}
});
});
} else {
window.print();
}
}
// isInitial=true shows the centred loader (first paint); time-range changes
// pass false so the current report stays on screen until the swap (no flash).
async function load(container, pubkey, days, isInitial) {
var myGen = ++loadGen;
current = { pubkey: pubkey, days: days };
if (qmap) { qmap.destroy(); qmap = null; }
if (isInitial) {
container.innerHTML = '<div class="nq-load">Loading reach…</div>';
}
var d;
try {
d = await api('/nodes/' + encodeURIComponent(pubkey) + '/reach?days=' + days, { ttl: 30000 });
} catch (e) {
if (myGen !== loadGen) return; // superseded or destroyed mid-flight
container.innerHTML = '<div id="nq-report"><div class="nq-error">Failed to load reach: ' + escapeHtml(e.message) + '</div></div>';
return;
}
if (myGen !== loadGen) return; // a newer load (or destroy) won the race
current.data = d;
var n = d.node;
var nodeName = escapeHtml(n.name || n.pubkey.slice(0, 12));
var imp = d.importance || {};
var twoWay = d.links.filter(function (l) { return l.bidir; });
if (!d.reliable_tokens || d.reliable_tokens.length === 0) {
// nodeName is already escaped; build then assign (keeps it off the
// innerHTML line for the XSS-sink gate, like statsHtml below).
var emptyHtml = '<div id="nq-report">' + headerHtml(n, nodeName, days) +
'<div class="nq-msg">This node has no unique 13 byte prefix, so it cannot be reliably identified in paths — no link data available.</div></div>';
container.innerHTML = emptyHtml;
wireTimeRange(container, pubkey);
return;
}
var statsHtml = headerHtml(n, nodeName, days) +
'<div class="nq-body">' +
'<div class="nq-group-h">Network position (all-time)</div>' +
'<div class="analytics-stats">' +
statCard('Neighbours', imp.neighbor_degree, 'All-time distinct neighbours',
'Distinct neighbours in the all-time neighbour graph (advert first-hop + observer last-hop, geo-filtered).') +
statCard('Rank', '#' + imp.degree_rank + ' / ' + imp.nodes_with_edges, 'Rank by neighbour count',
'Rank by neighbour count among all nodes with edges. #1 = most-connected node in the network.') +
'</div>' +
'<div class="nq-group-h">Last ' + d.window.days + ' days</div>' +
'<div class="analytics-stats">' +
statCard('Links', d.links.length, 'Neighbours seen this window',
'Distinct direct neighbours seen in paths this window (any direction).') +
statCard('Two-way', imp.bidirectional_links, 'Heard both directions',
'Neighbours heard in BOTH directions — stable links. Counts mid-path adjacency, so can exceed all-time Neighbours.') +
statCard('Relay obs', imp.relay_observations, 'Times seen in a path',
'Observations where this node appears anywhere in the path (its relay throughput).') +
statCard('Direct observers', imp.direct_observers, 'Heard it at 0 hops',
'Stations that received this node directly, at 0 hops.') +
'</div>';
// Identifiable, but no path adjacency observed in this window.
if (!d.links.length) {
container.innerHTML = '<div id="nq-report">' + statsHtml +
'<div class="nq-empty">No observed RF links in the last ' + d.window.days +
' days — this node advertises but hasnt been seen relaying traffic (or no observers captured it). Try a longer window.</div>' +
'</div></div>';
wireTimeRange(container, pubkey);
return;
}
container.innerHTML = '<div id="nq-report">' + statsHtml +
'<div class="nq-actions nq-noprint">' +
'<fieldset class="nq-filter"><legend>Show one-way links</legend>' +
'<label for="nqIncoming"><input type="checkbox" id="nqIncoming"> incoming <span class="nq-dir">(we hear them)</span></label>' +
'<label for="nqOutgoing"><input type="checkbox" id="nqOutgoing"> outgoing <span class="nq-dir">(they hear us)</span></label>' +
'</fieldset>' +
'<span id="nqCount" class="nq-count" aria-live="polite"></span>' +
'<button id="nqPrintBtn" class="btn-primary nq-print-btn">Print / PDF</button>' +
'</div>' +
'<div id="nqNoGps" class="nq-nogps"></div>' +
'<div id="nqMap" class="nq-map"></div>' +
'<table class="nq-table"><thead><tr><th>#</th><th>Neighbour</th><th>we hear</th>' +
'<th>they hear us</th><th title="smaller of we-hear / they-hear — gates two-way stability">bottleneck</th>' +
'<th>distance (km)</th></tr></thead><tbody id="nqRows"></tbody></table>' +
'</div></div>';
// Build the map ONCE; toggles update the link layer in place (no flicker).
if (window.NodeReachMap && n.lat != null) {
qmap = window.NodeReachMap.render('nqMap', n, TIERS);
}
// Two-way links are always shown; the two checkboxes add the asymmetric ones.
function paint() {
var inc = document.getElementById('nqIncoming').checked;
var out = document.getElementById('nqOutgoing').checked;
var list = d.links.filter(function (l) {
if (l.bidir) return true;
var weOnly = l.we_hear > 0 && l.they_hear === 0;
return (inc && weOnly) || (out && !weOnly);
}).sort(function (a, b) {
return (b.bidir - a.bidir) || (b.bottleneck - a.bottleneck) ||
((b.we_hear + b.they_hear) - (a.we_hear + a.they_hear));
});
document.getElementById('nqRows').innerHTML = list.map(function (l, i) { return linkRow(i + 1, l); }).join('');
document.getElementById('nqCount').textContent =
'showing ' + list.length + ' of ' + d.links.length + ' (' + twoWay.length + ' two-way)';
var noGps = list.filter(function (l) { return l.lat == null || l.lon == null; }).length;
document.getElementById('nqNoGps').textContent =
noGps ? noGps + ' link' + (noGps === 1 ? '' : 's') + ' have no location and are not drawn on the map.' : '';
if (qmap) qmap.setLinks(list);
}
paint();
document.getElementById('nqIncoming').addEventListener('change', paint);
document.getElementById('nqOutgoing').addEventListener('change', paint);
document.getElementById('nqPrintBtn').addEventListener('click', printReport);
wireTimeRange(container, pubkey);
}
function init(container, routeParam) {
if (!routeParam || !routeParam.endsWith('/reach')) {
container.innerHTML = '<div class="nq-load">Invalid reach URL</div>';
return;
}
load(container, routeParam.slice(0, -'/reach'.length), DEFAULT_DAYS, true);
}
function destroy() {
loadGen++; // invalidate any in-flight load so it won't mutate a foreign container
if (qmap) { qmap.destroy(); qmap = null; }
current = null;
}
registerPage('node-reach', { init: init, destroy: destroy });
})();