Files
meshcore-analyzer/public/node-reach.js
T
efiten 22fe929da2 feat: opt-in mobile client-RX coverage (crowdsourced RF reach) + /api/nodes/resolve (#1728)
Implements #1727.

## What this adds

**Mobile client-RX coverage** — an opt-in, crowdsourced RF-coverage
feature. A roaming MeshCore **companion** radio (driven by the
open-source [corescope-rx](https://github.com/efiten/corescope-rx) PWA,
GPLv3) reports which nodes it heard directly, tagged with the phone's
GPS and the packet's SNR/RSSI. CoreScope ingests these into a new
`client_receptions` table and renders per-node **hex coverage** on the
Reach page, plus a standalone **Coverage dashboard** (`#/rx-coverage`)
with a top-mobile-observers leaderboard.

Also includes **`GET /api/nodes/resolve?prefix=<hex>`** — a read-only
node-name lookup by pubkey prefix (`{name, pubkey, ambiguous}`), used by
the companion app for friendly names.

## Opt-in — default OFF (zero impact on existing deployments)

The whole feature is gated behind one config flag, **disabled by
default**:

```jsonc
"clientRxCoverage": { "enabled": false }
```

When disabled (the default): the ingestor writes **no**
`client_receptions`; the three coverage endpoints return a clean
**404**; the UI hides the Coverage nav link, the `#/rx-coverage` route,
and the Reach-page toggle. `/api/nodes/resolve` is always available (not
coverage-specific).

## How it works

```
companion ──BLE 0x88 (snr+rssi+raw)──▶ corescope-rx PWA ──▶ MQTT meshcore/client/{pubkey}/packets
                                                                      │
                                          ingestor (gated) ──▶ client_receptions (GPS + SNR + heard-key)
                                                                      │
              server: pure-Go hex grid ──▶ GeoJSON ──▶ Reach hex overlay + Coverage dashboard
```

- **Direct-only capture:** records only what the companion heard itself
and directly — a 0-hop advert's pubkey, or `path[last]` (last forwarder)
for FLOOD routes; ≥2-byte path-hash required. Upstream hops discarded.
- **No new deps:** hexbins are a pure-Go pointy-top grid over Web
Mercator (`cmd/server/hexgrid.go`) computed at query time
(`CGO_ENABLED=0` / `modernc.org/sqlite` friendly); frontend uses the
existing Leaflet.
- **Trust:** companion pubkey = identity; an EMQX ACL binds each client
to publish only to its own `meshcore/client/{pubkey}/packets` topic.
Payload contract in `docs/client-rx-coverage.md`.

## How to enable / try it

1. In `config.json`, set `"clientRxCoverage": { "enabled": true }` and
restart server + ingestor.
2. Point an EMQX (or any broker) listener so a client can publish to
`meshcore/client/<pubkey>/packets`; the ingestor already subscribes
under `meshcore/#`.
3. Run the [corescope-rx](https://github.com/efiten/corescope-rx) PWA on
an Android phone paired (BLE) to a MeshCore companion — it captures
heard nodes + GPS and publishes.
4. View results: per-node Reach page → toggle **coverage**, or the
**Coverage** dashboard at `#/rx-coverage`.

## What's where

- **Ingestor:** `cmd/ingestor/client_reception.go` (ingest), `db.go`
(`client_receptions` + `client_observers` schema), `main.go` (gated
dispatch), `config.go` (flag).
- **Server:** `cmd/server/rx_coverage.go` + `rx_dashboard.go`
(endpoints, self-guard 404 when off), `hexgrid.go` (pure-Go grid),
`node_resolve.go` (resolve), `routes.go` / `types.go` / `config.go`
(wiring + flag + `/api/config/client` field).
- **Frontend:** `public/rx-coverage.js` (dashboard),
`node-reach-coverage.js` + `.css` (overlay), `node-reach.js` (Reach
toggle, flag-gated), `roles.js` (reads the flag, hides nav when off).
- **Docs:** `docs/client-rx-coverage.md`.

## Testing

- Go: `cd cmd/server && go test ./...` and `cd cmd/ingestor && go test
./...` — green, including new gate tests (`coverage_gate_test.go` in
both: off → no rows / 404, on → works) and the rx-coverage / resolve /
hexgrid suites.
- JS: `node test-coverage-gate.js`, `node test-node-reach-coverage.js`
(wired into CI). The Playwright `test-node-reach-coverage-e2e.js` is
wired into the e2e job and **skips when `clientRxCoverage` is
disabled**, so it's safe under the default-off config.

## Notes for reviewers

- The four new routes are registered in
`cmd/server/openapi_known_gaps.json` (the existing OpenAPI-completeness
ratchet), matching how other not-yet-spec'd routes are tracked. Happy to
write full OpenAPI spec entries instead if you prefer.
- Commits are split per layer (ingestor / server endpoints / resolve /
frontend / CI) for review.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Erwin Fiten <e.fiten@opteco.be>
2026-06-19 11:37:16 -07:00

282 lines
15 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)
var coverageOn = false; // mobile-client RX coverage hex layer (deep-linked ?coverage=1)
var covHandle = null; // handle from NodeReachCoverage.addLayer (off() to remove)
// setCoverageHash reflects the coverage toggle in the URL hash without
// re-triggering the router (history.replaceState, not location.hash =).
function setCoverageHash(on) {
var h = location.hash.replace(/([?&])coverage=1/, '$1').replace(/[?&]$/, '');
if (on) { h += (h.indexOf('?') >= 0 ? '&' : '?') + 'coverage=1'; }
try { history.replaceState(null, '', h || location.pathname + location.search); } catch (e) {}
}
// 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;
// Wait for server config so the coverage flag (read just below and in the
// actions bar markup) is populated even on a direct land on this route — a
// race that otherwise hides the coverage toggle (#13).
try { await window.MeshConfigReady; } catch (e) {}
if (myGen !== loadGen) return; // superseded by a newer load while waiting
current = { pubkey: pubkey, days: days };
coverageOn = window.MC_CLIENT_RX_COVERAGE === true && (typeof getHashParams === 'function' && getHashParams().get('coverage') === '1');
if (covHandle) { try { covHandle.off(); } catch (e) {} covHandle = null; }
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>' +
(window.MC_CLIENT_RX_COVERAGE ? '<label for="nqCoverage"><input type="checkbox" id="nqCoverage"' + (coverageOn ? ' checked' : '') + '> coverage <span class="nq-dir">(where clients heard it)</span></label>' : '') +
'<span id="nqCount" class="nq-count" aria-live="polite"></span>' +
'<button id="nqPrintBtn" class="btn-primary nq-print-btn">Print / PDF</button>' +
'</div>' +
(window.MC_CLIENT_RX_COVERAGE ? '<div class="nq-cov-legend nq-noprint' + (coverageOn ? '' : ' is-hidden') + '" id="nqCovLegend">' +
'<span><i style="background:var(--nq-cov-strong)"></i>strong</span>' +
'<span><i style="background:var(--nq-cov-mid)"></i>medium</span>' +
'<span><i style="background:var(--nq-cov-weak)"></i>weak</span>' +
'<span><i style="background:var(--nq-cov-grey)"></i>no signal</span>' +
'</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);
}
var lastList = []; // most recent filtered link list (so the coverage toggle can restore lines)
// 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.' : '';
lastList = list;
if (qmap) qmap.setLinks(coverageOn ? [] : list);
}
paint();
document.getElementById('nqIncoming').addEventListener('change', paint);
document.getElementById('nqOutgoing').addEventListener('change', paint);
document.getElementById('nqPrintBtn').addEventListener('click', printReport);
// Coverage overlay (fork): toggles the mobile-client RX hex layer in place and
// hides the link lines under it (declutter) — the table still lists every link.
var covLegend = document.getElementById('nqCovLegend');
function applyCoverage() {
if (covLegend) covLegend.classList.toggle('is-hidden', !coverageOn);
if (coverageOn) {
if (qmap && window.NodeReachCoverage && !covHandle) covHandle = window.NodeReachCoverage.addLayer(qmap.map, pubkey);
} else if (covHandle) {
try { covHandle.off(); } catch (e) {}
covHandle = null;
}
if (qmap) qmap.setLinks(coverageOn ? [] : lastList);
}
var covCb = document.getElementById('nqCoverage');
if (covCb) covCb.addEventListener('change', function (e) {
coverageOn = e.target.checked;
setCoverageHash(coverageOn);
applyCoverage();
});
if (coverageOn) applyCoverage(); // deep-linked ?coverage=1: add layer + hide lines now
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 (covHandle) { try { covHandle.off(); } catch (e) {} covHandle = null; }
if (qmap) { qmap.destroy(); qmap = null; }
current = null;
}
registerPage('node-reach', { init: init, destroy: destroy });
})();