mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-11 14:38:28 +00:00
## Summary Builds on the transport view from #2167 and #2169 (both merged); rebased onto `master` (`352a0ccd`), so the diff is this PR's six commits only. The Scope Audit was the one analysis page without the region selector. This adds the shared region filter to it, for both the declared and the transport view. `?region=SFO,SJC` means what it means on every other endpoint: count only forwarding **heard by observers in those regions**, not repeaters located there. - **API** (`GET /api/scope-audit`, both modes): - The region codes are resolved to their observers first (a handful of rows), then the audit's hop scan gets `AND o.observer_idx IN (…)` on v3, or `o.observer_id IN (…)` on v2. The 3.5M-row hop scan never joins `observers`. - `All` means no filter (shared `normalizeRegionCodes`). Codes with no observer match nothing, instead of silently widening to every observer. - Responses echo the normalised codes as `region`. The declared and transport caches and their singleflight keys now include the region, so each region set is cached separately with the existing TTLs. - Declared-region verification (`regionEvidence`) is deliberately left unfiltered: it corroborates what a repeater declares from its own traffic, which doesn't depend on where a hop was heard. - **Page:** the shared `RegionFilter` sits with the view and window controls; a change reloads the view, and the selection persists like every other page. The transport view's text box is relabelled **Scope** (it already sends `?scope=` since the rename in #2167), so the page no longer has two controls called Region. - **Docs:** `docs/api-spec.md` (parameter, both response shapes) and the OpenAPI parameters for the route (`mode`, `scope` and `region` were all missing). Partial fix for #2142 (its region filter for rollout tracking), together with #2167 and #2169. ## Performance The unfiltered path must not get slower: it's the same query with an empty clause. `BenchmarkComputeScopeTransport` (from #2167, 1,200 repeaters), the PR it stacks on vs this branch, `-count 8`: | | before | after | | |---|---|---|---| | sec/op | 220.3 ms | 218.0 ms | ~ (p=0.065) | | B/op | 43.77 MiB | 43.70 MiB | ~ | | allocs/op | 833.5k | 833.5k | ~ | **Filtered requests, measured on staging** (a copy of a production database): the first version drove `o.observer_idx IN (…)` from `idx_observations_observer_idx`, reading every observation the region's observers ever made. Fixed in `80414242` with a unary `+` (the same treatment as #2166), so the scan starts from the `first_seen` window and filters per row. Red test `67e063b1` asserts the query plan against a fixture with the real indexes. | scan, one region over 24h (13 observers) | plan | time | hop rows | |---|---|---|---| | `o.observer_idx IN (…)` | observer index | 13.44 s | 102,114 | | `+o.observer_idx IN (…)` | `first_seen` window | **0.25 s** | 102,114 | | unfiltered | `first_seen` window | 0.35 s | 225,983 | Cold end-to-end requests on staging after the fix: 24h declared, unfiltered 0.84 s · one region 0.48 s (was 16.7 s) · two regions 0.22 s · transport, one region 0.42 s · transport, six regions 0.75 s · 7d, one region 3.46 s. 24h transport, live data: 383 repeaters unfiltered, 204 heard by one country's observers. ## Tests - Red `629e4f36` (Go): extends the audit fixture with an `observers` table and `observations.observer_idx`. Covers the declared view ignoring a scope heard only by another region's observer, the transport view listing only repeaters heard in the region, `region=All`, per-region caching, a region with no observers, and the echoed codes. Five fail on assertions; `region=All` passes because the stub ignores the filter, which is the behaviour it pins. - Green `6f1b1e1c`: the implementation, plus a v2-schema case (`observer_id`), so both branches of the clause are covered. - Red `b4933d68` / green `08ecc172` (frontend): `apiPath` carries the selection in both views and adds nothing when none is selected. The region control on the page is a net-new UI surface. - `go test` (cmd/server) passes. Frontend suites: 193 of 194. The one failure, `test-issue-1956-release-routing.js`, needs CI's `GITHUB_REF_NAME` and fails the same way on `master` locally. ## Screenshot The same transport view with the shared region filter set to **SJC**: only forwarding heard by San Jose observers counts, so 7 repeaters are listed instead of 44, with their carried scopes counted from those observers alone. Taken against `test-fixtures/e2e-fixture.db` with fictional scopes.  ## Browser validation The branch's server was run locally with 14 configured regions (dropdown layout), checked in headless Chromium at 1280, 900, 700 and 390 px (touch): | check | 1280 | 900 | 700 | 390 touch | |---|---|---|---|---| | ticking SJC re-requests with `®ion=SJC` | yes | yes | yes | yes | | menu fully on screen | yes | yes | yes | yes | | page wider than viewport | no | no | no | no | | page errors | none | none | none | none | Found and fixed this way: the filter is the right-most control on desktop, and the menu (which opens rightwards) ran 66 px off the page. It now opens leftwards above 640 px. On a phone the controls wrap, the filter starts a line, and the default applies. ## SonarLint SonarQube Community rules (the SonarLint engine) were run on the files this PR touches, and only issues on lines it adds are listed. - **S3776 on `ScopeAuditForwardingHeardIn`:** complexity 55. The function it generalises, `ScopeAuditForwarding`, is 54 on `master`; the region clause adds one point. - **S3776 on `computeScopeAudit`:** complexity 65, unchanged from `master`. - Both pre-date this PR. Splitting them is better as a refactor PR of its own than mixed into a feature. - The page code's findings were fixed in the transport-view PR it stacks on. ## Checked against the repo rules - Rebased onto `master` (`673442cc`). Each red commit was re-run and still fails on its assertions; each green commit passes (TDD.md). - `make test`: all 16 modules pass. Frontend suites: all pass except `test-issue-1956-release-routing.js`, which needs CI's `GITHUB_REF_NAME` and fails the same way on `master` locally (#2160 addresses it). - No private data: test fixtures and examples use the codes the existing tests use (SJC/SFO/LAX, `#be`-style scopes). - No new `map[string]interface{}`, no hard-coded colours. `git add` was explicit, and there is one logical change per commit. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
670 lines
38 KiB
JavaScript
670 lines
38 KiB
JavaScript
/* === CoreScope — scope-audit.js ===
|
||
Network-wide scope audit (route #/scope-audit): the whole-network answer
|
||
to "which repeaters declare a region they are not actually forwarding".
|
||
Fetches GET /api/scope-audit once per window and renders one row per
|
||
declared repeater, sorted server-side so the interesting rows (missing a
|
||
declared region, or contradicting its own wildcard) sit at the top.
|
||
Reuses node-scopes.js's vocabulary: scope names are shown without the
|
||
leading '#' (the two backends spell them differently — see normScope
|
||
there), and '*' is never listed as a scope. */
|
||
'use strict';
|
||
(function () {
|
||
var loadGen = 0; // bumped per load; guards against in-flight races (mirrors node-reach.js)
|
||
var DEFAULT_WINDOW = '24h';
|
||
var WINDOWS = [
|
||
{ key: '1h', label: '1h' },
|
||
{ key: '24h', label: '24h' },
|
||
{ key: '7d', label: '7d' }
|
||
];
|
||
var win = DEFAULT_WINDOW;
|
||
// #2142: 'declared' is the declared-vs-observed audit; 'transport' lists every
|
||
// repeater seen forwarding, optionally split by one region (rollout tracking).
|
||
var MODES = [
|
||
{ key: 'declared', label: 'Declared' },
|
||
{ key: 'transport', label: 'Transport' }
|
||
];
|
||
var mode = 'declared';
|
||
var region = ''; // transport view only; normalised (no '#', lower case)
|
||
var searchQuery = ''; // free-text filter over name/pubkey/region, applied client-side
|
||
var searchIndex = {}; // publicKey -> lowercased searchable haystack, rebuilt every renderBody
|
||
var sortCtl = null; // tracked TableSort controller for destroy-before-reinit (mirrors observers.js)
|
||
var regionChangeHandler = null; // shared RegionFilter subscription, removed on destroy
|
||
|
||
// introHtml describes the view being shown: the declared view covers only
|
||
// repeaters that answered a declared-regions request, the transport view
|
||
// every repeater seen forwarding.
|
||
function introHtml(m) {
|
||
if (m === 'transport') {
|
||
return 'Every repeater seen forwarding in the window, with the region scopes it was seen carrying and, where it has answered, the regions it declares. ' +
|
||
'Type a region scope to see which repeaters carry it and which were active but did not. <a href="#/nodes">Per-node detail lives on each node\'s page</a>.';
|
||
}
|
||
return 'Network-wide comparison of declared vs. observed region-scope forwarding, across every repeater that has declared a region list over RF. ' +
|
||
'<a href="#/nodes">Per-node detail lives on each node\'s page</a>.';
|
||
}
|
||
|
||
function modeBtn(key, cur, label) {
|
||
var on = key === cur;
|
||
return '<button data-mode="' + key + '" aria-pressed="' + (on ? 'true' : 'false') + '"' +
|
||
(on ? ' class="active"' : '') + '>' + label + '</button>';
|
||
}
|
||
|
||
// normRegion mirrors the server's normalisation for ?region=: no leading '#',
|
||
// lower case, trimmed.
|
||
function normRegion(r) {
|
||
r = String(r || '').trim().toLowerCase();
|
||
return r.charAt(0) === '#' ? r.slice(1) : r;
|
||
}
|
||
|
||
// buildHash / apiPath carry the view state, so a transport view filtered to
|
||
// one region is shareable as a link (AGENTS.md deep-linking rule).
|
||
function modeQuery(m, rgn) {
|
||
if (m !== 'transport') return '';
|
||
var q = '&mode=transport';
|
||
if (rgn) q += '&scope=' + encodeURIComponent(rgn);
|
||
return q;
|
||
}
|
||
function buildHash(w, m, rgn) {
|
||
return '#/scope-audit?window=' + encodeURIComponent(w) + modeQuery(m, rgn);
|
||
}
|
||
// obsRegion is the shared region filter's selection ("SFO,SJC"): count only
|
||
// forwarding heard by observers there, as region= means on every page.
|
||
function apiPath(w, m, rgn, obsRegion) {
|
||
return '/scope-audit?window=' + encodeURIComponent(w) + modeQuery(m, rgn) +
|
||
(obsRegion ? '®ion=' + encodeURIComponent(obsRegion) : '');
|
||
}
|
||
|
||
function windowBtn(key, cur, label) {
|
||
var on = key === cur;
|
||
return '<button data-window="' + key + '" aria-pressed="' + (on ? 'true' : 'false') + '"' +
|
||
(on ? ' class="active"' : '') + '>' + label + '</button>';
|
||
}
|
||
|
||
// windowHonestyNote spells out, in the window's own words, why "declared
|
||
// but not observed" is weak evidence over a short window (a quiet region
|
||
// simply has no traffic) and strong evidence over a long one — the reader
|
||
// must never mistake a 1h result for a 7d one.
|
||
function windowHonestyNote(w) {
|
||
var strength = w === '7d' ? 'a strong signal — a full week with zero matching traffic is hard to explain as bad luck'
|
||
: (w === '24h' ? 'a moderate signal — worth checking, but a quiet region can still look this way for a day'
|
||
: 'weak evidence — an hour with no traffic proves very little; treat this as a hint to re-check on 24h or 7d');
|
||
return '<div class="sa-window-note">Rows below are ranked by declared regions with <strong>zero observed forwarding in the last ' +
|
||
escapeHtml(w) + '</strong>. At this window, that absence is ' + strength + '.</div>';
|
||
}
|
||
|
||
function pageHtml() {
|
||
return '<div class="sa-page">' +
|
||
'<div class="sa-head">' +
|
||
'<h2>Scope audit</h2>' +
|
||
'<div class="sa-controls">' +
|
||
'<div class="analytics-time-range" id="saMode" role="group" aria-label="View">' +
|
||
MODES.map(function (m) { return modeBtn(m.key, mode, m.label); }).join('') +
|
||
'</div>' +
|
||
'<div class="analytics-time-range" id="saWindow">' +
|
||
WINDOWS.map(function (w) { return windowBtn(w.key, win, w.label); }).join('') +
|
||
'</div>' +
|
||
'<div id="saObsRegionFilter" class="region-filter-container" title="Count only forwarding heard by observers in these regions"></div>' +
|
||
'</div></div>' +
|
||
'<div class="sa-region-bar" id="saRegionBar"' + (mode === 'transport' ? '' : ' hidden') + '>' +
|
||
'<label for="saRegion">Scope</label> ' +
|
||
'<input type="text" id="saRegion" class="nodes-search sa-region" list="saRegionList" placeholder="e.g. be" value="' + escapeHtml(region) + '" aria-describedby="saRegionHelp">' +
|
||
'<datalist id="saRegionList"></datalist>' +
|
||
' <span class="text-muted" id="saRegionHelp">Show which repeaters were seen carrying one region scope, and which were active but did not.</span>' +
|
||
'</div>' +
|
||
'<div class="sa-intro" id="saIntro">' + introHtml(mode) + '</div>' +
|
||
sourcesLineHtml() +
|
||
'<div class="sa-search-bar"><input type="text" class="nodes-search sa-search" id="saSearch" placeholder="Search by repeater, pubkey, or region…" aria-label="Search scope audit rows"></div>' +
|
||
'<div id="saBody"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading scope audit…</div></div>' +
|
||
'</div>';
|
||
}
|
||
|
||
function ageHtml(row) {
|
||
var age = timeAgo(row.declaredAt);
|
||
return '<span title="Declared regions answer captured ' + escapeHtml(row.declaredAt) + '">' + escapeHtml(age) + '</span>';
|
||
}
|
||
|
||
// sourcesLineHtml is the always-visible half of the provenance note. It has
|
||
// to carry the links itself: the fuller explanation lives in emptyStateHtml,
|
||
// which by definition never renders on an instance that HAS data, so an
|
||
// operator with a full table would otherwise never see where it came from.
|
||
function sourcesLineHtml() {
|
||
return '<div class="sa-sources">The <strong>declared</strong> side is the repeater’s own answer, read back off the node by an ' +
|
||
'<a href="https://observer.gessaman.com/" target="_blank" rel="noopener">ESP32 observer on the neighbour-report firmware</a> or by ' +
|
||
'<a href="https://github.com/efiten/coredrive-rx" target="_blank" rel="noopener">CoreDrive RX</a>. The newest answer per repeater wins, whichever collected it. ' +
|
||
'The <strong>observed</strong> side is forwarding CoreScope already sees in its own traffic.</div>';
|
||
}
|
||
|
||
// mergedScopeChips renders ONE chip per declared region, coloured by whether
|
||
// that region was actually observed forwarding in the window.
|
||
//
|
||
// This replaces the old DECLARED and NOT OBSERVED pair. They were never
|
||
// independent: notObserved is a strict subset of declaredRegions, checked
|
||
// against a live 197-row response where it held on 197 of 197 rows. The two
|
||
// columns printed the same set twice, once whole and once filtered, and left
|
||
// the reader to diff them. On a typical mixed row that meant comparing two
|
||
// lists of eight to find the one or two entries that differ. Here the
|
||
// observed ones are simply the green ones.
|
||
//
|
||
// No new claim is made about the data: a region present in declaredRegions
|
||
// and absent from notObserved is exactly what the server already means by
|
||
// "observed forwarding in this window".
|
||
// emptyStateHtml is what a stock install sees, so it carries the whole
|
||
// explanation rather than deferring to the intro: on a fresh deployment this
|
||
// IS the page. Named and returned rather than inlined so it can be asserted.
|
||
function emptyStateHtml() {
|
||
return '<div class="ns-empty sa-empty"><strong>No repeater has answered with its configured region list yet</strong>, so there is nothing to audit here.<br><br>That answer has to be collected from the repeater itself; nothing else in CoreScope knows which regions a node is <em>configured</em> for, only which ones its traffic was seen under. Two things can collect it, and neither ships with CoreScope, so an empty table is the normal state until you run one:<ul><li>an <a href="https://observer.gessaman.com/" target="_blank" rel="noopener">ESP32 observer on the neighbour-report firmware</a> (<code>set mqtt.neighbors on</code>), which reports its neighbours’ scopes every 24h</li><li>the <a href="https://github.com/efiten/coredrive-rx" target="_blank" rel="noopener">CoreDrive RX</a> companion app, which asks a repeater directly while you are in range of it</li></ul>The newest answer per repeater wins, whichever collected it.</div>';
|
||
}
|
||
|
||
function mergedScopeChips(row) {
|
||
var missing = Object.create(null);
|
||
row.notObserved.forEach(function (n) { missing[n] = true; });
|
||
var evidence = row.regionEvidence || {};
|
||
var chips = row.declaredRegions.map(function (n) {
|
||
var observed = !missing[n];
|
||
var hits = evidence[n] || 0;
|
||
// A green chip with evidence was established by verifying the repeater's
|
||
// own declaration against its own unnameable traffic, not by matching a
|
||
// configured region key. Same colour — it is observed either way — with a
|
||
// dotted underline, so the reader can tell the two apart without a third
|
||
// colour competing for attention in a column that already carries two.
|
||
var verified = observed && hits > 0;
|
||
var cls = 'sa-chip ' + (observed ? 'sa-chip-observed' : 'sa-chip-unobserved') + (verified ? ' sa-chip-verified' : '');
|
||
var title;
|
||
if (verified) {
|
||
title = n + ': observed — ' + hits + ' forwarded packet' + (hits === 1 ? '' : 's') +
|
||
' in this window derive to this region, verified against the repeater’s own declared list. ' +
|
||
'This instance holds no hashRegions key for it, so it could not be named directly.';
|
||
} else if (observed) {
|
||
title = n + ': observed forwarding in this window';
|
||
} else if (hits === 1) {
|
||
title = n + ': declared, and exactly one forwarded packet derives to it — that is one match in 65536 by chance alone, ' +
|
||
'so it is not treated as evidence. Two would be.';
|
||
} else {
|
||
title = n + ': declared, but no forwarding observed in this window';
|
||
}
|
||
return '<span class="' + cls + '" title="' + escapeHtml(title) + '">' + escapeHtml(n) + '</span>';
|
||
});
|
||
if (!chips.length) return '<span class="text-muted">—</span>';
|
||
return chips.join(' ');
|
||
}
|
||
|
||
// nameHtml renders the repeater identity cell. row.name == null means this
|
||
// instance holds NO nodes row for the target at all (a declared-regions
|
||
// answer can name a repeater the network has never recorded) — distinct
|
||
// from row.name === "" (a known node that simply has no name). A
|
||
// truthiness check collapses those two into the same pubkey-stub
|
||
// rendering, which defeats the point of the API making the field
|
||
// nullable. The unknown case also gets no link (FIX 4): #/nodes/<pubkey>
|
||
// cannot resolve a target we hold no row for.
|
||
function nameHtml(row) {
|
||
if (row.name == null) {
|
||
return '<span class="sa-name-unknown" title="No nodes row held for this target — a declared-regions answer can name a repeater this instance has never recorded.">' +
|
||
escapeHtml(row.publicKey.slice(0, 10)) + '… (unknown node)</span>';
|
||
}
|
||
var name = row.name ? escapeHtml(row.name) : escapeHtml(row.publicKey.slice(0, 10)) + '…';
|
||
return '<a href="#/nodes/' + encodeURIComponent(row.publicKey) + '">' + name + '</a>';
|
||
}
|
||
|
||
// CONFIG_STATES maps ScopeAuditRow.configState (server-computed from
|
||
// declaredRegions + declaredWildcard — see docs/api-spec.md) to a compact
|
||
// badge, reusing the same .ns-decl colour vocabulary the Status column
|
||
// already uses on this page (node-scopes.css) rather than inventing a new
|
||
// one. "no-scopes" gets the warn (red) treatment because it is the
|
||
// headline finding this classification exists to surface — roughly a
|
||
// third of repeaters answering at all fall in it — but its title text
|
||
// carries the caveat that "*" alone does not strictly prove no region is
|
||
// configured, only that no region is flood-allowed (see scopeAuditConfigState
|
||
// in cmd/server/scopes.go): a repeater with regions defined but every one
|
||
// marked deny-flood looks identical from this data.
|
||
var CONFIG_STATES = {
|
||
'full': {
|
||
label: 'Full', cls: 'ns-decl-yes', summary: 'fully configured',
|
||
title: 'Declares named regions and \'*\' — forwards both its declared regions and plain unscoped floods.'
|
||
},
|
||
'no-scopes': {
|
||
label: 'No scopes', cls: 'ns-decl-warn', summary: 'no scopes configured',
|
||
title: 'Declares only \'*\', no named regions — no region is flood-allowed. Almost always means no scopes are configured at all, but a repeater with regions defined that are ALL set to deny flooding would look identical from this data alone.'
|
||
},
|
||
'no-unscoped': {
|
||
label: 'No unscoped', cls: 'ns-decl-quiet', summary: 'not forwarding unscoped floods',
|
||
title: 'Declares named regions but not \'*\' — does not forward plain unscoped floods. Exact, not an inference.'
|
||
},
|
||
'no-flood': {
|
||
label: 'No flood', cls: 'ns-decl-unknown', summary: 'nothing flood-allowed',
|
||
title: 'Declares neither named regions nor \'*\' — an answered-but-empty list. Nothing is flood-allowed by this repeater, not even plain unscoped traffic.'
|
||
}
|
||
};
|
||
var CONFIG_STATE_ORDER = ['no-scopes', 'no-unscoped', 'full', 'no-flood'];
|
||
|
||
function configStateHtml(row) {
|
||
var meta = CONFIG_STATES[row.configState];
|
||
return '<span class="ns-decl ' + meta.cls + '" title="' + escapeHtml(meta.title) + '">' + meta.label + '</span>';
|
||
}
|
||
|
||
// configStateSummaryHtml gives the reader "13 repeaters have no scopes
|
||
// configured" at a glance without reading every row — the counts are
|
||
// tallied straight off d.repeaters[].configState, the same array the table
|
||
// below renders, so they can never drift out of sync with the rows.
|
||
function configStateSummaryHtml(repeaters) {
|
||
var counts = { full: 0, 'no-scopes': 0, 'no-unscoped': 0, 'no-flood': 0 };
|
||
repeaters.forEach(function (r) { counts[r.configState]++; });
|
||
return '<div class="sa-summary">' +
|
||
CONFIG_STATE_ORDER.map(function (state) {
|
||
var meta = CONFIG_STATES[state];
|
||
return '<span class="sa-summary-item" title="' + escapeHtml(meta.title) + '">' +
|
||
'<span class="ns-decl ' + meta.cls + '">' + counts[state] + '</span> ' + escapeHtml(meta.summary) + '</span>';
|
||
}).join('') +
|
||
'</div>';
|
||
}
|
||
|
||
// ambiguousCaveat flags rows with a non-zero FIX 1 ambiguity count: hops
|
||
// whose truncated hash prefix matched more than one declared target were
|
||
// credited to none of them, so a notObserved finding here could be a
|
||
// prefix collision rather than a confirmed gap.
|
||
function ambiguousCaveat(row) {
|
||
if (!row.ambiguousHops) return '';
|
||
return ' <span class="sa-chip sa-chip-ambiguous" title="' + row.ambiguousHops + ' forwarder hop' + (row.ambiguousHops === 1 ? '' : 's') +
|
||
' in this window matched more than one declared target\'s pubkey prefix and could not be attributed to any of them. Any “not observed” entry on this row may be explained by that prefix collision rather than a real gap.">possibly ambiguous</span>';
|
||
}
|
||
|
||
// unmatchedCaveat flags rows where this instance saw the repeater forward
|
||
// transport-scoped traffic it holds no region key for. The ingestor stores
|
||
// those packets with an empty scope_name (see scopeNameForDB), so they name
|
||
// no region and can never satisfy a declared one — a repeater forwarding a
|
||
// region this instance cannot name looks exactly like one forwarding
|
||
// nothing.
|
||
//
|
||
// Distinct from ambiguousCaveat, and the distinction is the whole point:
|
||
// that one is a prefix collision between two repeaters and is nobody's
|
||
// fault, this one is a missing entry in this instance's own hashRegions and
|
||
// the reader can fix it. Saying so is what stops them investigating an
|
||
// innocent repeater.
|
||
function unmatchedCaveat(row) {
|
||
var n = row.observedUnmatchedPackets;
|
||
if (typeof n !== 'number' || !isFinite(n) || n <= 0) return '';
|
||
// Traffic already accounted for by verification is explained, not
|
||
// mysterious. What is left over is the interesting case: this repeater
|
||
// forwards a region it does NOT declare and that this instance also cannot
|
||
// name. Reporting the full count here would re-raise a question the Scopes
|
||
// column has just answered.
|
||
// Only evidence that actually ESTABLISHED a region counts as an
|
||
// explanation. A region with a single deriving packet stays in notObserved
|
||
// by design — one match in 65536 is chance, not evidence — and subtracting
|
||
// it here would call that packet explained while the chip next to it says
|
||
// the region was not observed. Keyed on notObserved rather than on the
|
||
// count, so the corroboration threshold stays in one place: the server.
|
||
var established = Object.create(null);
|
||
Object.keys(row.regionEvidence || {}).forEach(function (k) { established[k] = true; });
|
||
(row.notObserved || []).forEach(function (rgn) { delete established[rgn]; });
|
||
|
||
var explained = 0;
|
||
var evidence = row.regionEvidence || {};
|
||
Object.keys(established).forEach(function (k) { explained += evidence[k]; });
|
||
// One packet can derive to two of this repeater's declared regions, so the
|
||
// values can sum past the number of distinct packets behind them.
|
||
if (explained > n) explained = n;
|
||
var left = n - explained;
|
||
if (left <= 0) return '';
|
||
|
||
// observedUnmatchedPackets counts every unnameable packet; the evidence can
|
||
// only come from the ones the verifier actually held (both the per-target
|
||
// list and the per-window sample are capped). Subtracting an undercounted
|
||
// number from a complete one overstates what is unexplained, so when the
|
||
// count was sampled the chip states a bound instead of a figure.
|
||
var sampled = typeof row.observedUnmatchedSampled === 'number' ? row.observedUnmatchedSampled : n;
|
||
var bounded = sampled < n;
|
||
var label = (bounded ? 'at most ' : '') + escapeHtml(left) + ' forwarded packet' + (left === 1 ? '' : 's');
|
||
return ' <span class="sa-chip sa-chip-unmatched" title="' + label +
|
||
' in this window carried a region scope this CoreScope instance holds no key for, and match none of this repeater's declared regions. ' +
|
||
'So this repeater forwards at least one region it does not declare, which this instance also cannot name.">' +
|
||
label + ' unexplained</span>';
|
||
}
|
||
|
||
// statusScore ranks a row's Status column numerically for sorting — a
|
||
// simple weighted count (notObserved dominates, matching the server's own
|
||
// findings-first ranking) rather than the badge text, which the Status
|
||
// column shows as chips, not a single sortable label.
|
||
function statusScore(row) {
|
||
return row.notObserved.length * 100 + (row.wildcardContradiction ? 10 : 0) + row.undeclaredObserved.length;
|
||
}
|
||
|
||
function rowHtml(row) {
|
||
var issues = [];
|
||
if (row.notObserved.length) issues.push('<span class="ns-decl ns-decl-quiet" title="Declared flood-allowed but not observed forwarding it this window.">' + row.notObserved.length + ' not observed</span>');
|
||
if (row.wildcardContradiction) issues.push('<span class="ns-decl ns-decl-warn" title="Observed forwarding plain (unscoped) floods, but the declared list omits the '*' wildcard that would allow that.">wildcard contradiction</span>');
|
||
if (row.undeclaredObserved.length) issues.push('<span class="ns-decl ns-decl-unknown" title="Observed forwarding scopes absent from the declared list.">' + row.undeclaredObserved.length + ' undeclared</span>');
|
||
var issuesHtml = issues.length ? issues.join(' ') : '<span class="ns-decl ns-decl-yes" title="Every declared region was observed forwarding, and nothing undeclared was observed.">agrees</span>';
|
||
|
||
// declaredAtMs is the underlying declaredAt timestamp in epoch ms, fed to
|
||
// the Declared age <td> as data-value (mirroring observers.js's
|
||
// _lastSeenMs) so TableSort's numeric comparator sorts on the real
|
||
// timestamp instead of the rendered "4h ago" text, which cannot be
|
||
// parsed back into a date.
|
||
var declaredAtMs = row.declaredAt ? new Date(row.declaredAt).getTime() : NaN;
|
||
var nameSortValue = row.name != null ? row.name : row.publicKey;
|
||
|
||
return '<tr data-pubkey="' + escapeHtml(row.publicKey) + '">' +
|
||
'<td class="sa-name" data-value="' + escapeHtml(nameSortValue) + '">' + nameHtml(row) + (row.role != null && row.role !== '' ? '<span class="text-muted sa-role"> ' + escapeHtml(row.role) + '</span>' : '') + '</td>' +
|
||
'<td data-value="' + statusScore(row) + '">' + issuesHtml + '</td>' +
|
||
'<td data-value="' + escapeHtml(CONFIG_STATES[row.configState].label) + '">' + configStateHtml(row) + '</td>' +
|
||
'<td data-value="' + row.notObserved.length + '">' + mergedScopeChips(row) + (row.declaredWildcard ? ' <span class="sa-chip sa-chip-wildcard" title="Declares the \'*\' wildcard — allows plain unscoped floods.">*</span>' : '') + ambiguousCaveat(row) + unmatchedCaveat(row) + '</td>' +
|
||
'<td data-value="' + (isNaN(declaredAtMs) ? '' : declaredAtMs) + '">' + ageHtml(row) + (row.truncated ? ' <span class="ns-truncated" title="Declared list was truncated by the repeater — a missing region here is not necessarily a real absence.">truncated</span>' : '') + '</td>' +
|
||
'</tr>';
|
||
}
|
||
|
||
// ===== Transport view (#2142) =====
|
||
// "Transported" means SEEN CARRYING in this window, never the repeater's
|
||
// configuration; the declared column stays separate for that.
|
||
|
||
// transportScopeChips: one chip per region scope the repeater was seen
|
||
// forwarding, most packets first (server order). The filtered region, if any,
|
||
// is marked so it stands out in a long list.
|
||
function transportScopeChips(row, rgn) {
|
||
if (!row.transported?.length) return '<span class="text-muted">—</span>';
|
||
return row.transported.map(function (so) {
|
||
var hit = rgn && String(so.scope).toLowerCase() === rgn;
|
||
var title = so.scope + ': seen carrying ' + so.packets + ' packet' + (so.packets === 1 ? '' : 's') +
|
||
' in this window (first ' + so.firstSeen + ', last ' + so.lastSeen + ')';
|
||
return '<span class="sa-chip sa-chip-transported' + (hit ? ' sa-chip-match' : '') + '" title="' + escapeHtml(title) + '">' +
|
||
escapeHtml(so.scope) + ' <span class="sa-chip-count">' + escapeHtml(so.packets) + '</span></span>';
|
||
}).join(' ');
|
||
}
|
||
|
||
// declaredCellHtml: the declared regions coloured by whether they were seen
|
||
// carried (same chips as the declared view), or "not asked" when no
|
||
// declared-regions answer exists. Never asked is not the same as declaring
|
||
// nothing, so the two are worded differently.
|
||
function declaredCellHtml(row) {
|
||
if (!row.asked) {
|
||
return '<span class="sa-not-asked" title="No declared-regions answer has been collected from this repeater, so its configuration is unknown.">not asked</span>';
|
||
}
|
||
var chips = mergedScopeChips({ declaredRegions: row.declaredRegions || [], notObserved: row.notObserved || [], regionEvidence: {} });
|
||
return chips + (row.declaredWildcard ? ' <span class="sa-chip sa-chip-wildcard" title="Declares the \'*\' wildcard — allows plain unscoped floods.">*</span>' : '');
|
||
}
|
||
|
||
function carriesHtml(row, rgn) {
|
||
if (row.carriesScope) {
|
||
return '<span class="ns-decl ns-decl-yes" title="Seen carrying ' + escapeHtml(rgn) + ' in this window.">carries</span>';
|
||
}
|
||
return '<span class="ns-decl ns-decl-quiet" title="Seen forwarding in this window, but not ' + escapeHtml(rgn) + '.">not seen</span>';
|
||
}
|
||
|
||
function transportRowHtml(row, rgn) {
|
||
var nameSortValue = row.name != null ? row.name : row.publicKey;
|
||
var counts = [];
|
||
var carriesValue = row.carriesScope ? 1 : 0;
|
||
var carriesCell = rgn ? '<td data-value="' + carriesValue + '">' + carriesHtml(row, rgn) + '</td>' : '';
|
||
if (row.unscopedPackets) counts.push('<span title="Plain unscoped floods forwarded in this window.">' + escapeHtml(row.unscopedPackets) + ' unscoped</span>');
|
||
if (row.unmatchedPackets) counts.push('<span title="Scoped packets forwarded whose region this CoreScope instance holds no key for.">' + escapeHtml(row.unmatchedPackets) + ' unmatched</span>');
|
||
if (row.ambiguousHops) counts.push('<span title="Hops whose prefix matched more than one repeater; credited to none of them.">' + escapeHtml(row.ambiguousHops) + ' ambiguous</span>');
|
||
return '<tr data-pubkey="' + escapeHtml(row.publicKey) + '">' +
|
||
'<td class="sa-name" data-value="' + escapeHtml(nameSortValue) + '">' + nameHtml(row) + (row.role != null && row.role !== '' ? '<span class="text-muted sa-role"> ' + escapeHtml(row.role) + '</span>' : '') + '</td>' +
|
||
carriesCell +
|
||
'<td data-value="' + (row.transported ? row.transported.length : 0) + '">' + transportScopeChips(row, rgn) + '</td>' +
|
||
'<td data-value="' + (row.asked ? 1 : 0) + '">' + declaredCellHtml(row) + '</td>' +
|
||
'<td class="text-muted sa-counts">' + (counts.length ? counts.join(' · ') : '—') + '</td>' +
|
||
'</tr>';
|
||
}
|
||
|
||
function transportSummaryHtml(d) {
|
||
var total = d.repeaters.length;
|
||
var asked = d.repeaters.filter(function (r) { return r.asked; }).length;
|
||
var s = '<div class="sa-summary"><span class="sa-summary-item"><strong>' + total + '</strong> repeater' + (total === 1 ? '' : 's') +
|
||
' seen forwarding in the last ' + escapeHtml(d.window) + '</span><span class="sa-summary-item"><strong>' + asked + '</strong> with a declared-regions answer</span>';
|
||
if (d.scope) {
|
||
s += '<span class="sa-summary-item"><span class="ns-decl ns-decl-yes">' + (d.carrying || 0) + '</span> carried ' + escapeHtml(d.scope) + '</span>' +
|
||
'<span class="sa-summary-item"><span class="ns-decl ns-decl-quiet">' + (d.notCarrying || 0) + '</span> active but not seen carrying it</span>';
|
||
}
|
||
return s + '</div>';
|
||
}
|
||
|
||
// transportRegions lists every region scope seen in the response, for the
|
||
// region box's suggestions.
|
||
function transportRegions(repeaters) {
|
||
var seen = Object.create(null);
|
||
repeaters.forEach(function (r) { (r.transported || []).forEach(function (so) { seen[String(so.scope).toLowerCase()] = true; }); });
|
||
return Object.keys(seen).sort(function (a, b) { return a.localeCompare(b); });
|
||
}
|
||
|
||
function renderTransport(d) {
|
||
var el = document.getElementById('saBody');
|
||
if (!el) return;
|
||
var list = document.getElementById('saRegionList');
|
||
if (list) list.innerHTML = transportRegions(d.repeaters).map(function (r) { return '<option value="' + escapeHtml(r) + '">'; }).join('');
|
||
if (!d.repeaters.length) {
|
||
searchIndex = {};
|
||
el.innerHTML = '<div class="ns-empty sa-empty">No repeater was seen forwarding in the last ' + escapeHtml(d.window) + '.</div>';
|
||
return;
|
||
}
|
||
var rows = d.repeaters.slice();
|
||
if (d.scope) {
|
||
// Rollout report: the repeaters NOT yet carrying the region come first.
|
||
rows.sort(function (a, b) { return (a.carriesScope ? 1 : 0) - (b.carriesScope ? 1 : 0); });
|
||
}
|
||
searchIndex = buildTransportSearchIndex(rows);
|
||
el.innerHTML = transportSummaryHtml(d) +
|
||
'<div class="sa-window-note"><strong>Transported</strong> means seen carrying traffic for that region in the last ' + escapeHtml(d.window) +
|
||
', not how the repeater is configured. A repeater whose region sees no traffic in this window looks the same as one that does not carry it, so use a long window for rollout decisions.</div>' +
|
||
'<div class="sa-table-wrap"><table class="ns-table sa-table" id="saTable"><thead><tr>' +
|
||
'<th data-sort-key="name">Repeater</th>' +
|
||
(d.scope ? '<th data-sort-key="carries" data-type="numeric">Carries ' + escapeHtml(d.scope) + '</th>' : '') +
|
||
'<th data-sort-key="transported" data-type="numeric" title="Region scopes seen forwarded in this window, with packet counts.">Seen carrying</th>' +
|
||
'<th data-sort-key="declared" data-type="numeric" title="The repeater\'s own declared regions where an answer exists; green = also seen carried.">Declared</th>' +
|
||
'<th>Other traffic</th>' +
|
||
'</tr></thead><tbody>' +
|
||
rows.map(function (r) { return transportRowHtml(r, d.scope || ''); }).join('') +
|
||
'</tbody></table></div>' +
|
||
'<div class="sa-count text-muted" id="saCount"></div>';
|
||
var saTbl = document.getElementById('saTable');
|
||
if (saTbl && window.TableSort) sortCtl = TableSort.init(saTbl, { storageKey: 'meshcore-scope-transport-sort' });
|
||
applyFilter();
|
||
}
|
||
|
||
function buildTransportSearchIndex(repeaters) {
|
||
var idx = {};
|
||
repeaters.forEach(function (row) {
|
||
var parts = [row.publicKey];
|
||
if (row.name) parts.push(row.name);
|
||
(row.transported || []).forEach(function (so) { parts.push(so.scope); });
|
||
(row.declaredRegions || []).forEach(function (r) { parts.push(r); });
|
||
if (!row.asked) parts.push('not asked');
|
||
idx[row.publicKey] = parts.join(' ').toLowerCase();
|
||
});
|
||
return idx;
|
||
}
|
||
|
||
// buildSearchIndex maps publicKey -> lowercased haystack (name, pubkey,
|
||
// and every region name this row mentions — declared, not-observed, and
|
||
// undeclared-observed) so applyFilter can match a row without re-deriving
|
||
// it from rendered chip text.
|
||
function buildSearchIndex(repeaters) {
|
||
var idx = {};
|
||
repeaters.forEach(function (row) {
|
||
var parts = [row.publicKey];
|
||
if (row.name) parts.push(row.name);
|
||
row.declaredRegions.forEach(function (r) { parts.push(r); });
|
||
row.notObserved.forEach(function (r) { parts.push(r); });
|
||
row.undeclaredObserved.forEach(function (o) { parts.push(o.scope); });
|
||
idx[row.publicKey] = parts.join(' ').toLowerCase();
|
||
});
|
||
return idx;
|
||
}
|
||
|
||
// applyFilter toggles row visibility against searchQuery and refreshes the
|
||
// shown-count line — independent of sort order, since it only sets
|
||
// style.display on whatever <tr> elements are currently in the tbody, so
|
||
// filtering a sorted table keeps the sort and sorting a filtered table
|
||
// keeps the filter.
|
||
function applyFilter() {
|
||
var tbody = document.querySelector('#saTable tbody');
|
||
var countEl = document.getElementById('saCount');
|
||
if (!tbody) return;
|
||
var q = searchQuery.trim().toLowerCase();
|
||
var rows = tbody.querySelectorAll('tr');
|
||
var shown = 0;
|
||
for (var i = 0; i < rows.length; i++) {
|
||
var pk = rows[i].getAttribute('data-pubkey');
|
||
var hay = searchIndex[pk] || '';
|
||
var match = !q || hay.indexOf(q) !== -1;
|
||
rows[i].style.display = match ? '' : 'none';
|
||
if (match) shown++;
|
||
}
|
||
if (!countEl) return;
|
||
if (q) {
|
||
countEl.textContent = 'Showing ' + shown + ' of ' + rows.length + ' repeater' + (rows.length === 1 ? '' : 's') + ' matching “' + searchQuery.trim() + '”.';
|
||
} else {
|
||
countEl.textContent = rows.length + ' repeater' + (rows.length === 1 ? '' : 's') +
|
||
(mode === 'transport' ? ' seen forwarding.' : ' with a declared region list.');
|
||
}
|
||
}
|
||
|
||
function renderBody(d) {
|
||
var el = document.getElementById('saBody');
|
||
if (!el) return;
|
||
if (sortCtl && typeof sortCtl.destroy === 'function') {
|
||
try { sortCtl.destroy(); } catch (e) { /* ignore */ }
|
||
}
|
||
sortCtl = null;
|
||
if (!d.repeaters.length) {
|
||
searchIndex = {};
|
||
el.innerHTML = emptyStateHtml();
|
||
return;
|
||
}
|
||
searchIndex = buildSearchIndex(d.repeaters);
|
||
el.innerHTML = configStateSummaryHtml(d.repeaters) +
|
||
windowHonestyNote(d.window) +
|
||
'<div class="sa-table-wrap"><table class="ns-table sa-table" id="saTable"><thead><tr>' +
|
||
'<th data-sort-key="name">Repeater</th>' +
|
||
'<th data-sort-key="status" data-type="numeric">Status</th>' +
|
||
'<th data-sort-key="config">Config</th>' +
|
||
'<th data-sort-key="notObserved" data-type="numeric" title="Declared regions, coloured by whether forwarding was observed in this window. Green = observed, red = declared but not observed.">Scopes</th>' +
|
||
'<th data-sort-key="declaredAt" data-type="numeric">Declared age</th>' +
|
||
'</tr></thead><tbody>' +
|
||
d.repeaters.map(rowHtml).join('') +
|
||
'</tbody></table></div>' +
|
||
'<div class="sa-count text-muted" id="saCount"></div>';
|
||
|
||
var saTbl = document.getElementById('saTable');
|
||
if (saTbl && window.TableSort) {
|
||
// No defaultColumn: the server's own findings-first order (most
|
||
// notObserved at the top) stays the default until the reader picks a
|
||
// column — that ordering is the reason this page exists.
|
||
sortCtl = TableSort.init(saTbl, { storageKey: 'meshcore-scope-audit-sort' });
|
||
} else if (saTbl && !window.TableSort) {
|
||
console.warn('[scope-audit] window.TableSort missing — table will not be sortable');
|
||
}
|
||
applyFilter();
|
||
}
|
||
|
||
function syncButtons(id, attr, cur) {
|
||
var bar = document.getElementById(id);
|
||
if (!bar) return;
|
||
var bs = bar.querySelectorAll('button[' + attr + ']');
|
||
for (var btn of bs) {
|
||
var on = btn.getAttribute(attr) === cur;
|
||
btn.classList.toggle('active', on);
|
||
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||
}
|
||
}
|
||
|
||
async function load(w) {
|
||
win = w;
|
||
var myGen = ++loadGen;
|
||
syncButtons('saWindow', 'data-window', w);
|
||
syncButtons('saMode', 'data-mode', mode);
|
||
var regionBar = document.getElementById('saRegionBar');
|
||
if (regionBar) regionBar.hidden = mode !== 'transport';
|
||
var intro = document.getElementById('saIntro');
|
||
if (intro) intro.innerHTML = introHtml(mode);
|
||
if (sortCtl && typeof sortCtl.destroy === 'function') {
|
||
try { sortCtl.destroy(); } catch (e) { /* ignore */ }
|
||
}
|
||
sortCtl = null;
|
||
var body = document.getElementById('saBody');
|
||
if (body) body.innerHTML = '<div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading scope audit…</div>';
|
||
var d;
|
||
try {
|
||
var obsRegion = (typeof RegionFilter !== 'undefined') ? RegionFilter.getRegionParam() : '';
|
||
d = await api(apiPath(w, mode, region, obsRegion), { ttl: 30000 });
|
||
} catch (e) {
|
||
if (myGen !== loadGen) return;
|
||
if (body) body.innerHTML = '<div class="ns-empty">Failed to load scope audit: ' + escapeHtml(e.message) + '</div>';
|
||
return;
|
||
}
|
||
if (myGen !== loadGen) return;
|
||
if (mode === 'transport') renderTransport(d); else renderBody(d);
|
||
syncHash();
|
||
}
|
||
|
||
function syncHash() {
|
||
try { history.replaceState(null, '', buildHash(win, mode, region)); } catch (e) {}
|
||
}
|
||
|
||
function init(container) {
|
||
win = DEFAULT_WINDOW;
|
||
mode = 'declared';
|
||
region = '';
|
||
searchQuery = '';
|
||
try {
|
||
var p = (typeof getHashParams === 'function') ? getHashParams() : null;
|
||
var qw = p ? p.get('window') : null;
|
||
if (qw && WINDOWS.some(function (w) { return w.key === qw; })) win = qw;
|
||
if (p?.get('mode') === 'transport') {
|
||
mode = 'transport';
|
||
region = normRegion(p.get('scope'));
|
||
}
|
||
} catch (e) {}
|
||
container.innerHTML = pageHtml();
|
||
var bar = document.getElementById('saWindow');
|
||
if (bar) bar.addEventListener('click', function (e) {
|
||
var b = e.target.closest('button[data-window]');
|
||
if (b) load(b.getAttribute('data-window'));
|
||
});
|
||
var modeBar = document.getElementById('saMode');
|
||
if (modeBar) modeBar.addEventListener('click', function (e) {
|
||
var b = e.target.closest('button[data-mode]');
|
||
if (!b || b.dataset.mode === mode) return;
|
||
mode = b.dataset.mode;
|
||
load(win);
|
||
});
|
||
var regionInput = document.getElementById('saRegion');
|
||
if (regionInput) regionInput.addEventListener('change', function (e) {
|
||
var next = normRegion(e.target.value);
|
||
if (next === region) return;
|
||
region = next;
|
||
load(win);
|
||
});
|
||
var search = document.getElementById('saSearch');
|
||
if (search) search.addEventListener('input', debounce(function (e) {
|
||
searchQuery = e.target.value;
|
||
applyFilter();
|
||
}, 250));
|
||
var rf = document.getElementById('saObsRegionFilter');
|
||
if (rf && typeof RegionFilter !== 'undefined') {
|
||
RegionFilter.init(rf);
|
||
regionChangeHandler = RegionFilter.onChange(function () { load(win); });
|
||
}
|
||
load(win);
|
||
}
|
||
|
||
function destroy() {
|
||
loadGen++;
|
||
if (regionChangeHandler && typeof RegionFilter !== 'undefined') RegionFilter.offChange(regionChangeHandler);
|
||
regionChangeHandler = null;
|
||
if (sortCtl && typeof sortCtl.destroy === 'function') {
|
||
try { sortCtl.destroy(); } catch (e) { /* ignore */ }
|
||
}
|
||
sortCtl = null;
|
||
}
|
||
|
||
if (typeof window !== 'undefined') {
|
||
// Exposed so the helper tests can assert what the Scopes column RENDERS
|
||
// rather than grepping this file, the same reason map.js exposes its label
|
||
// builder (#1356/#1933).
|
||
window.__meshcoreScopeAuditInternals = { mergedScopeChips: mergedScopeChips, emptyStateHtml: emptyStateHtml, sourcesLineHtml: sourcesLineHtml, unmatchedCaveat: unmatchedCaveat,
|
||
buildHash: buildHash, apiPath: apiPath, introHtml: introHtml, normRegion: normRegion, declaredCellHtml: declaredCellHtml, transportScopeChips: transportScopeChips,
|
||
transportRowHtml: transportRowHtml, transportSummaryHtml: transportSummaryHtml, transportRegions: transportRegions };
|
||
}
|
||
|
||
registerPage('scope-audit', { init: init, destroy: destroy });
|
||
})();
|