Files
meshcore-analyzer/public/scope-audit.js
T
efiten 8c164c5315 feat(scope-audit): verify a declared region against the repeater's own traffic (#1990)
Follow-up to #1987, and the point of counting that traffic in the first
place.

A region this instance holds no `hashRegions` key for is **unnameable,
not absent**. #1987 says so with a caveat chip. This settles it wherever
the evidence allows: derive `SHA256("#region")[:16]` from the repeater's
own declaration and HMAC that repeater's own unmatched packets with it.
Same computation the ingestor performs at ingest, with the candidate set
narrowed from every configured key to this repeater's handful of
declarations.

Where it fires, a grey "declared but not observed" chip becomes a green
one and the caveat count shrinks by the packets it explained.

## Two packets, not one

`code1` is two bytes, so an unrelated name matches a given packet with
probability 1/65536. Across ~400 unmatched packets and ~124 declared
names, chance alone produces roughly one false match per refresh. Two
matches on the same region for the same repeater is (1/65536)², about
one in four billion.

Lowering the threshold to one would not make this noisy, it would make
it **unsound**, so the constant carries that arithmetic and a test
rather than a comment. A region with exactly one hit stays grey and
reports its single hit, so the page can say why it is still shown as not
observed instead of leaving the reader to wonder.

## What it deliberately does not do

**It writes nothing.** Read-time only. A wrong answer expires with the
window instead of sitting in `transmissions.scope_name` until someone
runs a repair, and `cmd/server` stays read-only per the invariant in
AGENTS.md.

**`notObserved` remains the single source of chip colour.**
`regionEvidence` says only HOW a region was established. Two fields that
can disagree about the same fact is how this column got confusing in the
first place.

## Rule 0, including the part that was wrong at first

The naive shape is `targets × names × packets` HMACs: 205 × 124 × 400 ≈
10M.

Caching per `(region, transmission)` pair cuts the HMACs to ~50k. **That
measured 501ms**, because the HMACs had become a rounding error while
the *iteration* stayed cubic at 10.2M map lookups. Re-keyed per region,
holding the set of matching transmissions, it is **36ms** at the same
worst-case shape: a region is HMACed over every packet once, and a
target then asks one question per declared region instead of one per
(region, packet). Most declared regions match nothing, so the common
case is a single map lookup and no packet loop at all.

`hmacCount` exists so a test can assert the first mistake cannot come
back; the benchmark exists because only it caught the second.

## Both axes are bounded, because neither is bounded by the data

The "~400 packets in a 7 day window" this was sized against is a
property of one instance's configuration, not of the feature: the
ingestor stores the unnameable state for every transport-scoped packet
no configured key names, so an instance with few or no `hashRegions`
entries — the stock state, and the one this helps most — has **every**
scoped packet in that set.

- the window query takes the 4096 most recent candidates and reports
truncation, which the handler logs, so a grey chip on a sampled refresh
is not read as "not forwarded"
- the declared list is capped at 32 names per repeater: it arrives from
a collector that validates each entry's shape but never how many entries
there are
- measured at the cap: **306ms** for 205 targets over 124 names, against
29ms for the shape a real network produces

Because both caps make the evidence a sample, the response carries
`observedUnmatchedSampled`. Without it a client subtracts a capped
numerator from an uncapped total and overstates the unexplained traffic
with no way to know it is doing so. The chip subtracts only evidence for
regions **absent** from `notObserved` — a single-hit region the server
refused to accept is not called explained either — and says "at most N"
when the count was sampled.

## Verified on live data

Six repeaters clear the threshold in a 7d window on a real instance. One
of them: `nl-nb` green with 3 corroborating packets and the tooltip
stating the count, `belml` still grey on 1, and the caveat chip reading
31 of 34 packets unexplained rather than 30.

## Tests

`scope_verify_test.go` covers the HMAC-input walk against a real
transport-flood packet captured from a live instance (a hand-built
fixture would only prove the parser agrees with itself), that
`regionCode` does not fold case, the threshold in both directions, the
memo's HMAC count, both bounds with their truncation flag, and the
benchmark at cap size. Handler-level tests cover a region verified into
green, a single hit left grey with its count reported, and the
sample-size field.

`cd cmd/server && go test ./...` passes (77s), frontend 723 assertions
pass, `go vet` and `gofmt -l` clean.
2026-09-09 17:58:09 +02:00

440 lines
25 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 — 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;
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)
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="analytics-time-range" id="saWindow">' +
WINDOWS.map(function (w) { return windowBtn(w.key, win, w.label); }).join('') +
'</div></div>' +
'<div class="sa-intro">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>.</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&rsquo;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&rsquo; 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&#39;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 &#39;*&#39; 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>';
}
// 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') + ' 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();
}
async function load(w) {
win = w;
var myGen = ++loadGen;
var head = document.getElementById('saWindow');
if (head) {
WINDOWS.forEach(function (wd) {
var b = head.querySelector('button[data-window="' + wd.key + '"]');
if (b) { b.classList.toggle('active', wd.key === w); b.setAttribute('aria-pressed', wd.key === w ? 'true' : 'false'); }
});
}
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('/scope-audit?window=' + encodeURIComponent(w), { 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;
renderBody(d);
syncHash();
}
function syncHash() {
try { history.replaceState(null, '', '#/scope-audit?window=' + win); } catch (e) {}
}
function init(container) {
win = DEFAULT_WINDOW;
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;
} 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 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 };
}
registerPage('scope-audit', { init: init, destroy: destroy });
})();