feat: role/search filter for Scopes tab "Nodes Without a Default Scope"

Clarifies the section is specifically about default_scope (not the
separate "never relayed a scope" signal below it), and adds role filter
buttons (e.g. companions only) plus a name/key search box so the list is
usable beyond the top 100 most-recently-active nodes.
This commit is contained in:
dborup committed 2026-07-19 22:46:26 +02:00
1 parent 99b471c41b
commit f2fc3931a4
2 files changed
+173 -37

No files matched your search

+127 -37
View File
@@ -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 = '<button type="button" class="tab-btn' + (!noScopeFilter.role ? ' active' : '') +
'" data-role-filter="">All (' + noScope.total.toLocaleString() + ')</button>' +
noScope.roleSummary.map(function(r) {
return '<button type="button" class="tab-btn' + (noScopeFilter.role === r.role ? ' active' : '') +
'" data-role-filter="' + esc(r.role) + '">' + esc(r.role) + ' (' + r.count.toLocaleString() + ')</button>';
}).join('');
var controlsHtml = '<div style="display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin:4px 0 10px">' +
roleButtons +
'<input type="text" id="noScopeSearch" placeholder="Search by name or key…" value="' + esc(noScopeFilter.q) + '" ' +
'style="margin-left:4px;padding:4px 8px;background:var(--card-bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:0.85em;min-width:180px">' +
'</div>';
var resultsBody;
if (noScope.sortedCapped.length > 0) {
var noScopeRows = noScope.sortedCapped.map(function(n) {
return '<tr><td><a href="#/nodes/' + encodeURIComponent(n.public_key) + '">' + esc(n.name || n.public_key) + '</a></td>' +
'<td>' + esc(n.role || '—') + '</td>' +
'<td>' + timeAgo(n.last_seen) + '</td></tr>';
}).join('');
resultsBody = '<table class="data-table analytics-table">' +
'<thead><tr><th>Node</th><th>Role</th><th>Last Seen</th></tr></thead>' +
'<tbody>' + noScopeRows + '</tbody>' +
'</table>';
} else if (noScopeFilter.role || noScopeFilter.q) {
resultsBody = '<p class="text-muted" style="font-size:0.85em">No nodes without a default scope match this filter.</p>';
} else {
resultsBody = '<p class="text-muted" style="font-size:0.85em">Every known node has a configured default scope.</p>';
}
setSectionHtml(noScopeElInner, detailsSection(
'Nodes Without a Default Scope (' + noScope.total.toLocaleString() + ' of ' + allNodes.length.toLocaleString() + ')',
'Nodes with no <code>default_scope</code> 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 '<tr><td><a href="#/nodes/' + encodeURIComponent(n.public_key) + '">' + esc(n.name || n.public_key) + '</a></td>' +
'<td>' + esc(n.role || '—') + '</td>' +
'<td>' + timeAgo(n.last_seen) + '</td></tr>';
}).join('');
noScopeBody = '<table class="data-table analytics-table">' +
'<thead><tr><th>Node</th><th>Role</th><th>Last Seen</th></tr></thead>' +
'<tbody>' + noScopeRows + '</tbody>' +
'</table>';
} else {
noScopeBody = '<p class="text-muted" style="font-size:0.85em">Every known node has a configured scope.</p>';
}
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, '<p class="text-muted">Failed to load.</p>', 'no-scope'));
setSectionHtml(noScopeEl, detailsSection('Nodes Without a Default Scope', null, '<p class="text-muted">Failed to load.</p>', '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,
+46
View File
@@ -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', () => {