feat(scope-audit): transport view on the Scope Audit page (#2142, page) (#2169)

> **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.

![Scope Audit transport
view](https://raw.githubusercontent.com/A13xB0/CoreScope/pr-screenshots/2026-10/scope-audit-transport-view.png)

## 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>
This commit is contained in:
Alex B
2026-10-10 21:22:37 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 696906315a
commit deb55dc82e
3 changed files with 333 additions and 13 deletions
+15
View File
@@ -103,3 +103,18 @@
region IS observed. The dotted underline says how that was established
without introducing a third colour into a column that already carries two. */
.sa-chip-verified { text-decoration: underline dotted; text-underline-offset: 2px; }
/* #2142 transport view: view toggle, region filter, carried-scope chips. */
.sa-controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.sa-region-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0 0 10px; font-size: 12px; }
.sa-region-bar[hidden] { display: none; } /* display:flex above would otherwise override the hidden attribute */
.sa-region-bar label { font-weight: 600; }
/* .nodes-search sets width:100%; the region box only needs room for one code. */
.sa-region-bar .sa-region { width: 160px; max-width: 100%; flex: none; }
.sa-chip-transported { background: var(--section-bg, var(--card-bg)); color: var(--text); border: 1px solid var(--border); }
.sa-chip-transported .sa-chip-count { color: var(--text-muted); }
/* The region being filtered on: same chip, heavier outline in the link colour,
so it stands out without adding a third status colour. */
.sa-chip-match { border-color: var(--link-color); box-shadow: inset 0 0 0 1px var(--link-color); }
.sa-not-asked { color: var(--text-muted); font-style: italic; font-size: 12px; }
.sa-counts { font-size: 11px; white-space: nowrap; }
+229 -13
View File
@@ -17,10 +17,58 @@
{ 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') + '"' +
@@ -43,10 +91,20 @@
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 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>' +
'</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>' +
@@ -287,6 +345,127 @@
'</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
@@ -327,7 +506,8 @@
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.';
countEl.textContent = rows.length + ' repeater' + (rows.length === 1 ? '' : 's') +
(mode === 'transport' ? ' seen forwarding.' : ' with a declared region list.');
}
}
@@ -369,42 +549,62 @@
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;
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'); }
});
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('/scope-audit?window=' + encodeURIComponent(w), { ttl: 30000 });
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;
renderBody(d);
if (mode === 'transport') renderTransport(d); else renderBody(d);
syncHash();
}
function syncHash() {
try { history.replaceState(null, '', '#/scope-audit?window=' + win); } catch (e) {}
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');
@@ -412,6 +612,20 @@
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;
@@ -432,7 +646,9 @@
// 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 };
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 });
+89
View File
@@ -7704,3 +7704,92 @@ console.log('\n=== live.js: one WebSocket per viewer ===');
assert.doesNotThrow(() => handler(null));
});
}
// ===== scope-audit.js: transport view (#2142) =====
// One row per repeater seen forwarding, declared regions where known and "not
// asked" where not, and a region filter for rollout tracking. These assert
// rendered markup and the deep-link state, not the source.
console.log('\n=== scope-audit.js: transport view (#2142) ===');
{
const ctx = makeSandbox();
ctx.registerPage = () => {};
loadInCtx(ctx, 'public/app.js');
loadInCtx(ctx, 'public/scope-audit.js');
const sa = ctx.__meshcoreScopeAuditInternals;
const pk = 'ab'.repeat(32);
const so = (scope, packets) => ({ scope, packets, firstSeen: '2026-10-09T10:00:00Z', lastSeen: '2026-10-09T11:00:00Z' });
const neverAsked = { publicKey: pk, name: 'Bravo', role: 'repeater', transported: [so('fr', 2)], unscopedPackets: 3,
unmatchedPackets: 0, ambiguousHops: 0, asked: false, declaredRegions: null, notObserved: null, declaredWildcard: false };
const asked = { publicKey: 'cd'.repeat(32), name: 'Alpha', role: 'repeater', transported: [so('be', 5), so('fr', 1)],
unscopedPackets: 0, unmatchedPackets: 0, ambiguousHops: 0, asked: true, declaredRegions: ['be', 'nl'], notObserved: ['nl'],
declaredWildcard: true };
test('the transport view is deep-linked with its region; the declared view keeps the old link', () => {
assert.strictEqual(sa.buildHash('7d', 'transport', 'be'), '#/scope-audit?window=7d&mode=transport&scope=be');
assert.strictEqual(sa.buildHash('24h', 'transport', ''), '#/scope-audit?window=24h&mode=transport');
assert.strictEqual(sa.buildHash('24h', 'declared', 'be'), '#/scope-audit?window=24h', 'a region means nothing in the declared view');
assert.strictEqual(sa.apiPath('1h', 'transport', 'be-van'), '/scope-audit?window=1h&mode=transport&scope=be-van');
});
test('a region typed with # or capitals is normalised like the server does', () => {
assert.strictEqual(sa.normRegion(' #BE '), 'be');
assert.strictEqual(sa.normRegion(''), '');
assert.strictEqual(sa.normRegion(null), '');
});
test('a repeater that never answered says "not asked", not "declares nothing"', () => {
const h = sa.declaredCellHtml(neverAsked);
assert.ok(h.includes('not asked'));
assert.ok(!h.includes('sa-chip-unobserved'));
});
test('a repeater that answered shows its declared regions coloured by what it was seen carrying', () => {
const h = sa.declaredCellHtml(asked);
assert.ok(h.includes('sa-chip-observed') && h.includes('sa-chip-unobserved'), 'be seen, nl not');
assert.ok(h.includes('sa-chip-wildcard'), "the '*' wildcard is shown");
assert.ok(!h.includes('not asked'));
});
test('carried scopes show packet counts and mark the filtered region', () => {
const h = sa.transportScopeChips(asked, 'fr');
assert.strictEqual((h.match(/sa-chip-transported/g) || []).length, 2);
assert.strictEqual((h.match(/sa-chip-match/g) || []).length, 1, 'only fr is marked');
assert.ok(/>be <span class="sa-chip-count">5</.test(h));
assert.ok(!h.includes('#'), 'scope names are shown without #');
});
test('a row has a carries column only when a region is set', () => {
const plain = sa.transportRowHtml(neverAsked, '');
const filtered = sa.transportRowHtml({ ...neverAsked, carriesScope: true }, 'fr');
assert.strictEqual((plain.match(/<td/g) || []).length, 4);
assert.strictEqual((filtered.match(/<td/g) || []).length, 5);
assert.ok(filtered.includes('carries'));
const missing = sa.transportRowHtml({ ...asked, carriesScope: false }, 'be');
assert.ok(missing.includes('not seen'));
assert.ok(plain.includes('3 unscoped'), 'other traffic is counted');
});
test('names are escaped', () => {
const h = sa.transportRowHtml({ ...neverAsked, name: '<img src=x>' }, '');
assert.ok(!h.includes('<img src=x>') && h.includes('&lt;img'));
});
test('the summary counts repeaters, answers and, with a region, carriers and non-carriers', () => {
const d = { window: '24h', repeaters: [neverAsked, asked] };
const h = sa.transportSummaryHtml(d);
assert.ok(/<strong>2<\/strong> repeaters seen forwarding in the last 24h/.test(h));
assert.ok(/<strong>1<\/strong> with a declared-regions answer/.test(h));
const r = sa.transportSummaryHtml({ scope: 'fr', carrying: 2, notCarrying: 0, ...d });
assert.ok(r.includes('carried fr') && r.includes('active but not seen carrying it'));
});
test('the intro describes the view being shown', () => {
assert.ok(sa.introHtml('transport').includes('Every repeater seen forwarding'));
assert.ok(!sa.introHtml('transport').includes('has declared a region list'));
assert.ok(sa.introHtml('declared').includes('has declared a region list'));
});
test('region suggestions list every carried scope once, sorted', () => {
assert.deepStrictEqual(Array.from(sa.transportRegions([neverAsked, asked])), ['be', 'fr']);
});
}