diff --git a/public/analytics.js b/public/analytics.js index 50cda8fb..162cd50b 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -2709,6 +2709,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf window._analyticsRenderForeignTrafficTab = renderForeignTrafficTab; window._analyticsStopForeignTrafficRefresh = _stopForeignTrafficRefresh; window._analyticsComputeNodesWithoutScope = computeNodesWithoutScope; + window._analyticsComputeRepeatersNeverRelayingScope = computeRepeatersNeverRelayingScope; } // ─── Neighbor Graph Tab ───────────────────────────────────────────────────── @@ -4507,6 +4508,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf '
' + '
' + '
' + + '
' + '
'; // Attach window-button click listeners (once) @@ -4882,6 +4884,43 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf noScopeEl.innerHTML = '

Nodes Not Using Any Scope

Failed to load.

'; } } + + // Repeaters Never Relaying Any Scope: distinct from the section + // above — a repeater's own default_scope and whether it has ever + // relayed ANYONE's region-scoped traffic are different signals (see + // computeRepeatersNeverRelayingScope doc comment). Reuses the + // node list already fetched for the section above. + var neverRelayEl = document.getElementById('scopes-never-relay-scope'); + if (neverRelayEl) { + try { + var nodesForRelay = typeof allNodes !== 'undefined' ? allNodes : (await fetchAllNodes('', { ttl: CLIENT_TTL.nodeList })).nodes; + var neverRelay = computeRepeatersNeverRelayingScope(nodesForRelay, 100); + var neverRelayBody; + if (neverRelay.sortedCapped.length > 0) { + var neverRelayRows = neverRelay.sortedCapped.map(function(n) { + return '' + esc(n.name || n.public_key) + '' + + '' + esc(n.role || '—') + '' + + '' + (n.relay_count_24h || 0).toLocaleString() + '' + + '' + timeAgo(n.last_seen) + ''; + }).join(''); + neverRelayBody = '' + + '' + + '' + neverRelayRows + '' + + '
RepeaterRoleRelays (24h)Last Seen
'; + } else { + neverRelayBody = '

Every known repeater/room has relayed at least one region-scoped packet.

'; + } + neverRelayEl.innerHTML = + '

Repeaters Never Relaying Any Scope (' + neverRelay.total.toLocaleString() + ')

' + + '

' + + 'Repeater/room nodes that have never carried a single region-scoped (TRANSPORT_FLOOD/DIRECT) packet, ever — not the same set as "no default_scope" above: a repeater\'s hashRegions config can let it relay for others even when its own adverts never carry a matching transport code. ' + + 'Sorted by current relay volume — the busiest ones are the most consequential to configure first' + (neverRelay.truncated ? ' (showing the top ' + neverRelay.sortedCapped.length + ')' : '') + '.' + + '

' + + neverRelayBody; + } catch (e) { + neverRelayEl.innerHTML = '

Repeaters Never Relaying Any Scope

Failed to load.

'; + } + } } @@ -4922,6 +4961,33 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf }; } + // Pure (no DOM/network) computation behind the Scopes tab's "Repeaters + // Never Relaying Any Scope" section. Distinct from + // computeNodesWithoutScope: a repeater's OWN default_scope (does it send + // its own scoped adverts?) and transported_scopes (has it ever relayed + // ANYONE's region-scoped traffic, ever?) are different signals — on + // stg.meshview.dk ~14% of no-default_scope repeaters still relay scope + // for others (their hashRegions config enables relaying even though + // their own adverts never carry a matching transport code). Only + // repeater/room nodes can relay at all — every other role is filtered + // out rather than silently 0-counted alongside them. Sorted by + // relay_count_24h descending: the busiest unconfigured repeaters are the + // most consequential ones to fix first. + function computeRepeatersNeverRelayingScope(allNodes, cap) { + var candidates = allNodes.filter(function(n) { + return (n.role === 'repeater' || n.role === 'room') && + (!n.transported_scopes || n.transported_scopes.length === 0); + }); + var sorted = candidates.slice().sort(function(a, b) { + return Number(b.relay_count_24h || 0) - Number(a.relay_count_24h || 0); + }); + return { + total: candidates.length, + sortedCapped: sorted.slice(0, cap), + truncated: sorted.length > cap, + }; + } + // Repeaters relaying unscoped (route_type FLOOD) traffic, sorted by volume // — a quick, no-new-backend-work way to surface an existing but previously // unsurfaced metric (unscoped_relay_count_24h, see openapi.go). A little diff --git a/test-analytics-nodes-without-scope.js b/test-analytics-nodes-without-scope.js index 1b9a518f..108e94ed 100644 --- a/test-analytics-nodes-without-scope.js +++ b/test-analytics-nodes-without-scope.js @@ -90,6 +90,7 @@ function makeAnalyticsSandbox() { const ctx = makeAnalyticsSandbox(); const computeNodesWithoutScope = ctx.window._analyticsComputeNodesWithoutScope; +const computeRepeatersNeverRelayingScope = ctx.window._analyticsComputeRepeatersNeverRelayingScope; console.log('\n=== analytics.js: computeNodesWithoutScope ==='); @@ -172,6 +173,72 @@ test('returns zero total and empty rows when every node has a scope', () => { assert.deepStrictEqual(result.roleSummary, []); }); +console.log('\n=== analytics.js: computeRepeatersNeverRelayingScope ==='); + +test('is exported for testing', () => { + assert.strictEqual(typeof computeRepeatersNeverRelayingScope, 'function'); +}); + +test('includes only repeater/room nodes with no transported_scopes, excludes other roles entirely', () => { + const nodes = [ + { public_key: 'pk1', name: 'NeverRelays', role: 'repeater', transported_scopes: null }, + { public_key: 'pk2', name: 'RelaysDK', role: 'repeater', transported_scopes: ['#dk'] }, + { public_key: 'pk3', name: 'RoomNeverRelays', role: 'room', transported_scopes: [] }, + { public_key: 'pk4', name: 'CompanionNeverRelays', role: 'companion', transported_scopes: null }, + ]; + const result = computeRepeatersNeverRelayingScope(nodes, 100); + const names = result.sortedCapped.map(n => n.name); + assert.strictEqual(result.total, 2, 'only NeverRelays and RoomNeverRelays should count'); + assert.ok(names.includes('NeverRelays') && names.includes('RoomNeverRelays')); + assert.ok(!names.includes('RelaysDK'), 'a repeater that has relayed a scope must not appear'); + assert.ok(!names.includes('CompanionNeverRelays'), 'a companion can never relay at all — must not appear regardless of transported_scopes'); +}); + +test('a node missing default_scope but WITH transported_scopes is correctly excluded (the two signals differ)', () => { + // Real-world case found on stg.meshview.dk: a repeater's hashRegions + // config can let it relay for others even when its own adverts never + // carry a matching transport code (no default_scope). + const nodes = [ + { public_key: 'pk1', name: 'RelaysForOthers', role: 'repeater', default_scope: null, transported_scopes: ['#dk', '#dk3'] }, + ]; + const result = computeRepeatersNeverRelayingScope(nodes, 100); + assert.strictEqual(result.total, 0, 'a repeater with transported_scopes must not appear here even without its own default_scope'); +}); + +test('sorts by relay_count_24h descending — the busiest unconfigured repeaters first', () => { + const nodes = [ + { public_key: 'pkQuiet', name: 'Quiet', role: 'repeater', relay_count_24h: 2, transported_scopes: null }, + { public_key: 'pkBusy', name: 'Busy', role: 'repeater', relay_count_24h: 500, transported_scopes: null }, + { public_key: 'pkMid', name: 'Mid', role: 'room', relay_count_24h: 40, transported_scopes: [] }, + ]; + const result = computeRepeatersNeverRelayingScope(nodes, 100); + assert.deepStrictEqual(result.sortedCapped.map(n => n.name), ['Busy', 'Mid', 'Quiet']); +}); + +test('caps sortedCapped at `cap` and sets truncated accordingly', () => { + const nodes = []; + for (let i = 0; i < 5; i++) { + nodes.push({ public_key: 'pk' + i, name: 'N' + i, role: 'repeater', relay_count_24h: i, transported_scopes: null }); + } + const capped = computeRepeatersNeverRelayingScope(nodes, 3); + assert.strictEqual(capped.sortedCapped.length, 3); + assert.strictEqual(capped.total, 5); + assert.strictEqual(capped.truncated, true); + + const uncapped = computeRepeatersNeverRelayingScope(nodes, 10); + assert.strictEqual(uncapped.truncated, false); +}); + +test('returns zero total when every repeater/room has relayed at least one scope', () => { + const nodes = [ + { public_key: 'pk1', role: 'repeater', transported_scopes: ['#dk'] }, + { public_key: 'pk2', role: 'room', transported_scopes: ['#dk-oj'] }, + ]; + const result = computeRepeatersNeverRelayingScope(nodes, 100); + assert.strictEqual(result.total, 0); + assert.deepStrictEqual(result.sortedCapped, []); +}); + console.log('\n════════════════════════════════════════'); console.log(` Nodes Without Scope: ${passed} passed, ${failed} failed`); console.log('════════════════════════════════════════');