diff --git a/public/analytics.js b/public/analytics.js index e9898dc1..b114ea31 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -4495,6 +4495,13 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf var subtabKey = 'scopes_subtab'; var selectedSubtab = (typeof sessionStorage !== 'undefined' && sessionStorage.getItem(subtabKey)) || 'overview'; + // Role/text filter for the "Nodes Without a Default Scope" section + // below. Lives at this scope (not inside updateData) so it survives + // the 60s auto-refresh re-render — same reasoning as selectedWindow + // above, just kept in memory rather than sessionStorage since it's a + // finer-grained, more transient filter. + var noScopeFilter = { role: '', q: '' }; + // Fix 5: write static frame only once if (!el.querySelector('#scopes-cards')) { el.innerHTML = @@ -4611,6 +4618,89 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf } } + // Renders (and re-renders, on every filter change) the "Nodes Without + // a Default Scope" section from an already-fetched node list — role + // buttons and the search box filter that same in-memory list, so + // narrowing to e.g. just companions never re-fetches. The search + // input's own re-render (triggered by typing) would otherwise steal + // focus every keystroke since setSectionHtml rebuilds the whole + // section including the input; focus + caret are captured before and + // restored after for that reason. + function renderNoScopeSection(allNodes) { + var noScopeElInner = document.getElementById('scopes-no-scope'); + if (!noScopeElInner) return; + + var searchFocused = document.activeElement && document.activeElement.id === 'noScopeSearch'; + var caretPos = searchFocused ? document.activeElement.selectionStart : null; + + var noScope = computeNodesWithoutScope(allNodes, 100, { role: noScopeFilter.role, q: noScopeFilter.q }); + var roleSummaryText = noScope.roleSummary.map(function(r) { return r.count.toLocaleString() + ' ' + esc(r.role); }).join(', '); + + var roleButtons = '' + + noScope.roleSummary.map(function(r) { + return ''; + }).join(''); + + var controlsHtml = '
| Node | Role | Last Seen |
|---|
No nodes without a default scope match this filter.
'; + } else { + resultsBody = 'Every known node has a configured default scope.
'; + } + + setSectionHtml(noScopeElInner, detailsSection( + 'Nodes Without a Default Scope (' + noScope.total.toLocaleString() + ' of ' + allNodes.length.toLocaleString() + ')', + 'Nodes with nodefault_scope configured — they never set a hashRegions region for themselves in their own adverts. By role: ' + (roleSummaryText || 'none') + '. ' +
+ 'Sorted by most-recently-active first' +
+ (noScope.filteredTotal !== noScope.total ? ', filtered to ' + noScope.filteredTotal.toLocaleString() + ' matching below' : '') +
+ (noScope.truncated ? ' (capped at ' + noScope.sortedCapped.length + ')' : '') + '.',
+ controlsHtml + resultsBody,
+ 'no-scope'
+ ));
+
+ var sectionEl = document.getElementById('scopes-no-scope');
+ if (!sectionEl) return;
+
+ sectionEl.querySelectorAll('[data-role-filter]').forEach(function(btn) {
+ btn.addEventListener('click', function() {
+ noScopeFilter.role = btn.dataset.roleFilter || '';
+ renderNoScopeSection(allNodes);
+ });
+ });
+
+ var searchInput = sectionEl.querySelector('#noScopeSearch');
+ if (searchInput) {
+ searchInput.addEventListener('input', debounce(function() {
+ noScopeFilter.q = searchInput.value;
+ renderNoScopeSection(allNodes);
+ }, 200));
+ if (searchFocused) {
+ searchInput.focus();
+ if (caretPos != null) {
+ try { searchInput.setSelectionRange(caretPos, caretPos); } catch (e) { /* ignore */ }
+ }
+ }
+ }
+ }
+
async function load(w) {
var loadingEl = document.getElementById('scopes-loading');
if (loadingEl) loadingEl.style.display = '';
@@ -4930,43 +5020,23 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf
'All-time — nodes whose OWN configured scope is this region (not just relaying it for others). This is a much smaller, more specific set than "Repeaters by Region" above.',
d.originatingNodesByRegion, 'node');
- // Nodes Not Using Any Scope: the flip side of "Nodes Running This
- // Region" — every node with no default_scope at all, i.e. never
- // configured a hashRegions region for itself. Computed client-side
- // from the full node list (default_scope isn't windowed/aggregated
- // server-side the way the other scope-stats fields are).
+ // Nodes Without a Default Scope: the flip side of "Nodes Running
+ // This Region" — every node with no default_scope at all, i.e.
+ // never configured a hashRegions region for itself. Computed
+ // client-side from the full node list (default_scope isn't
+ // windowed/aggregated server-side the way the other scope-stats
+ // fields are). Role filter + name/key search re-filter this same
+ // in-memory node list without a re-fetch; the debounced search
+ // input restores focus/caret after each re-render since the whole
+ // section (search box included) is rebuilt via setSectionHtml.
var noScopeEl = document.getElementById('scopes-no-scope');
if (noScopeEl) {
try {
var allNodesResp = await fetchAllNodes('', { ttl: CLIENT_TTL.nodeList });
var allNodes = allNodesResp.nodes || allNodesResp;
- var noScope = computeNodesWithoutScope(allNodes, 100);
- var roleSummary = noScope.roleSummary.map(function(r) { return r.count.toLocaleString() + ' ' + esc(r.role); }).join(', ');
-
- var noScopeBody;
- if (noScope.sortedCapped.length > 0) {
- var noScopeRows = noScope.sortedCapped.map(function(n) {
- return '| Node | Role | Last Seen |
|---|
Every known node has a configured scope.
'; - } - - setSectionHtml(noScopeEl, detailsSection( - 'Nodes Not Using Any Scope (' + noScope.total.toLocaleString() + ' of ' + allNodes.length.toLocaleString() + ')', - 'Nodes with no default_scope at all — never configured a hashRegions region for themselves. By role: ' + (roleSummary || 'none') + '. ' + - 'Sorted by most-recently-active first' + (noScope.truncated ? ' (showing the ' + noScope.sortedCapped.length + ' most recent)' : '') + '.', - noScopeBody, - 'no-scope' - )); + renderNoScopeSection(allNodes); } catch (e) { - setSectionHtml(noScopeEl, detailsSection('Nodes Not Using Any Scope', null, 'Failed to load.
', 'no-scope')); + setSectionHtml(noScopeEl, detailsSection('Nodes Without a Default Scope', null, 'Failed to load.
', 'no-scope')); } } @@ -5021,11 +5091,17 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf }, 60000); } - // Pure (no DOM/network) computation behind the Scopes tab's "Nodes Not - // Using Any Scope" section: filters to nodes with no default_scope, - // tallies by role (most common first), and returns a most-recently - // -active-first slice capped at `cap` rows. - function computeNodesWithoutScope(allNodes, cap) { + // Pure (no DOM/network) computation behind the Scopes tab's "Nodes + // Without a Default Scope" section: filters to nodes with no + // default_scope, tallies by role (most common first — always over the + // FULL unfiltered set, so the role filter buttons' counts don't shift + // as you filter), then optionally narrows further by `opts.role` and/or + // a case-insensitive `opts.q` match against name or public_key, and + // returns a most-recently-active-first slice capped at `cap` rows. + // `total` is always the full unfiltered count; `filteredTotal` reflects + // opts. + function computeNodesWithoutScope(allNodes, cap, opts) { + opts = opts || {}; var noScopeNodes = allNodes.filter(function(n) { return !n.default_scope; }); var roleCounts = {}; noScopeNodes.forEach(function(n) { @@ -5035,11 +5111,25 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf var roleSummary = Object.keys(roleCounts) .sort(function(a, b) { return roleCounts[b] - roleCounts[a]; }) .map(function(r) { return { role: r, count: roleCounts[r] }; }); - var sorted = noScopeNodes.slice().sort(function(a, b) { + + var filtered = noScopeNodes; + if (opts.role) { + filtered = filtered.filter(function(n) { return (n.role || 'unknown') === opts.role; }); + } + if (opts.q) { + var q = String(opts.q).toLowerCase(); + filtered = filtered.filter(function(n) { + return ((n.name || '').toLowerCase().indexOf(q) !== -1) || + ((n.public_key || '').toLowerCase().indexOf(q) !== -1); + }); + } + + var sorted = filtered.slice().sort(function(a, b) { return new Date(b.last_seen || 0) - new Date(a.last_seen || 0); }); return { total: noScopeNodes.length, + filteredTotal: filtered.length, roleSummary: roleSummary, sortedCapped: sorted.slice(0, cap), truncated: sorted.length > cap, diff --git a/test-analytics-nodes-without-scope.js b/test-analytics-nodes-without-scope.js index 108e94ed..f60b173f 100644 --- a/test-analytics-nodes-without-scope.js +++ b/test-analytics-nodes-without-scope.js @@ -173,6 +173,52 @@ test('returns zero total and empty rows when every node has a scope', () => { assert.deepStrictEqual(result.roleSummary, []); }); +test('opts.role narrows sortedCapped to that role but leaves total/roleSummary at the full count', () => { + const nodes = [ + { public_key: 'pk1', name: 'Rep1', role: 'repeater', default_scope: null }, + { public_key: 'pk2', name: 'Comp1', role: 'companion', default_scope: null }, + { public_key: 'pk3', name: 'Comp2', role: 'companion', default_scope: '' }, + ]; + const result = computeNodesWithoutScope(nodes, 100, { role: 'companion' }); + assert.strictEqual(result.total, 3, 'total stays the full unfiltered count'); + assert.strictEqual(result.filteredTotal, 2, 'filteredTotal reflects the role filter'); + assert.deepStrictEqual(result.sortedCapped.map(n => n.name).sort(), ['Comp1', 'Comp2']); + assert.strictEqual(result.roleSummary.length, 2, 'roleSummary is unaffected by the filter, still shows both roles'); +}); + +test('opts.q matches case-insensitively against name or public_key', () => { + const nodes = [ + { public_key: 'pkABC123', name: 'SkyMesh Node', role: 'repeater', default_scope: null }, + { public_key: 'pkXYZ999', name: 'Other Repeater', role: 'repeater', default_scope: null }, + ]; + const byName = computeNodesWithoutScope(nodes, 100, { q: 'skymesh' }); + assert.strictEqual(byName.filteredTotal, 1); + assert.strictEqual(byName.sortedCapped[0].name, 'SkyMesh Node'); + + const byKey = computeNodesWithoutScope(nodes, 100, { q: 'xyz999' }); + assert.strictEqual(byKey.filteredTotal, 1); + assert.strictEqual(byKey.sortedCapped[0].name, 'Other Repeater'); +}); + +test('opts.role and opts.q combine (AND, not OR)', () => { + const nodes = [ + { public_key: 'pk1', name: 'Alpha', role: 'companion', default_scope: null }, + { public_key: 'pk2', name: 'Alpha', role: 'repeater', default_scope: null }, + ]; + const result = computeNodesWithoutScope(nodes, 100, { role: 'companion', q: 'alpha' }); + assert.strictEqual(result.filteredTotal, 1); + assert.strictEqual(result.sortedCapped[0].role, 'companion'); +}); + +test('no opts (or empty opts) behaves exactly as before — filteredTotal equals total', () => { + const nodes = [ + { public_key: 'pk1', name: 'A', role: 'repeater', default_scope: null }, + { public_key: 'pk2', name: 'B', role: 'repeater', default_scope: null }, + ]; + const result = computeNodesWithoutScope(nodes, 100); + assert.strictEqual(result.filteredTotal, result.total); +}); + console.log('\n=== analytics.js: computeRepeatersNeverRelayingScope ==='); test('is exported for testing', () => {