mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-14 22:17:43 +00:00
> **Requires #2167 to be merged first.** This page calls the transport view API that #2167 adds (`/api/scope-audit?mode=transport`), so it cannot be split from it; until #2167 merges this PR also shows its commits. ## Summary Page half of #2142. **Stacked on #2167** (the API); review that first. #2171 adds the observer region filter on top. Partial fix for #2142 until all three are in. Adds a **Declared / Transport** toggle to `#/scope-audit`. The transport view calls `?mode=transport` and lists every repeater seen forwarding in the window: - **Seen carrying:** one chip per region scope with its packet count (first/last seen in the tooltip); - **Declared:** the repeater's declared regions coloured by whether they were seen carried (the declared view's own chips), or *not asked* when no declared-regions answer exists. Never asked is worded differently from declaring nothing; - **Other traffic:** unscoped, unmatched and ambiguous-hop counts. A **Region** box (suggestions from the scopes seen) adds a *Carries X* column, lists the repeaters not yet carrying it first, and summarises carriers against active non-carriers: the rollout report the issue asks for. Window, view and region are deep-linked (`#/scope-audit?window=7d&mode=transport&scope=be`) and survive a reload. The page says plainly that *transported* means seen carrying, not configured, and that over a short window a quiet region looks the same as a missing one. The intro text changes with the view. ## Screenshot The transport view with scope `be` typed in: every repeater seen forwarding in the last 24 h, whether it carries `be` (non-carriers first), the scopes it was seen carrying with counts, and its declared regions or *not asked*. Taken against `test-fixtures/e2e-fixture.db` with fictional `be` / `be-van` / `nl` scopes.  ## Browser validation (staging, real data) Headless Chromium against a staging copy of a production database with its live feed: - `#/scope-audit?window=24h`: the declared view, 16 rows, as before. - Clicking **Transport**: 385 rows; the hash gains `mode=transport`. - Typing a scope as `#XX-YY` in capitals: normalised to `xx-yy`; the hash gains it (now `scope=xx-yy`); summary "385 repeaters seen forwarding in the last 24h · 16 with a declared-regions answer · 51 carried xx-yy · 334 active but not seen carrying it"; the first rows are the non-carriers. - Reloading that hash restores view and region. Switching back to Declared hides the Region box and restores the declared intro. No page errors. - Checked at 1280 px and 390 px wide. Two layout bugs found this way were fixed before opening: the region box inherited `width: 100%` from `.nodes-search`, and `display: flex` overrode the `hidden` attribute on the region bar. ## Tests `tests/unit/test-frontend-helpers.js` gains 10 cases on rendered markup and state, using the page's exported internals like the existing scope-audit tests: hash and API paths (and that a region never leaks into the declared view's link), region normalisation (`#`, capitals, empty), *not asked* vs declared chips (with the `*` wildcard), carried-scope chips with counts and the filtered region marked, the carries column only with a region, HTML escaping of names, the summary counts, region suggestions, and the per-view intro. 768 passing in that file. Net-new UI surface, so per TDD.md the tests land in this PR rather than as a preceding red commit. Frontend suites on this branch: 193 of 194; the one failure, `test-issue-1956-release-routing.js`, needs CI's `GITHUB_REF_NAME` and fails identically on `master` locally (#2149 and #2160 address that). ## Review notes - Colours only through existing CSS variables (no hex literals); the filtered region is marked with the link colour, not a new status colour. - The window note's wording is in the same spirit as the declared view's `windowHonestyNote`. - No API calls per row: one request per view/window/region, cached client-side for 30 s like the declared view. ## SonarLint SonarQube Community rules (the SonarLint engine) were run on the files this PR touches, and only issues on lines it adds are listed. - **Fixed** in `df53e770`: - one `modeQuery` helper for the page hash and the API path (S3358; the two copies were identical); - the carries-scope cell built before the row (S3358); - `localeCompare` for the region suggestions (S2871), `for…of` and `.dataset` (S4138, S7761), optional chaining (S6582); - object spread in the helper tests (S6661). - Optional chaining and spread are already used throughout `public/`. No behaviour change. ## 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>
656 lines
37 KiB
JavaScript
656 lines
37 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)
|
||
|
||
// 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 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);
|
||
}
|
||
function apiPath(w, m, rgn) {
|
||
return '/scope-audit?window=' + encodeURIComponent(w) + modeQuery(m, rgn);
|
||
}
|
||
|
||
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></div>' +
|
||
'<div class="sa-region-bar" id="saRegionBar"' + (mode === 'transport' ? '' : ' hidden') + '>' +
|
||
'<label for="saRegion">Region</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, 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 {
|
||
d = await api(apiPath(w, mode, region), { 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));
|
||
load(win);
|
||
}
|
||
|
||
function destroy() {
|
||
loadGen++;
|
||
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 });
|
||
})();
|