mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-15 14:29:59 +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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
696906315a
commit
deb55dc82e
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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('<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']);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user