diff --git a/public/analytics.js b/public/analytics.js index 3a15013b..308f2dfc 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -31,6 +31,10 @@ function _stopScopesRefresh() { if (_scopesRefreshTimer) { clearInterval(_scopesRefreshTimer); _scopesRefreshTimer = null; } } + var _foreignTrafficRefreshTimer = null; + function _stopForeignTrafficRefresh() { + if (_foreignTrafficRefreshTimer) { clearInterval(_foreignTrafficRefreshTimer); _foreignTrafficRefreshTimer = null; } + } // --- Status color helpers (read from CSS variables for theme support) --- function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); } @@ -178,6 +182,7 @@ // #1085 — Roles tab owns its own 60s auto-refresh; stop it on switch. if (_currentTab !== 'roles') _stopRolesRefresh(); if (_currentTab !== 'scopes') _stopScopesRefresh(); + if (_currentTab !== 'foreign-traffic') _stopForeignTrafficRefresh(); _updateAnalyticsUrl(); renderTab(_currentTab); }); @@ -2686,7 +2691,7 @@ } } -function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData = {}; _channelData = null; if (_ngState && _ngState.animId) { cancelAnimationFrame(_ngState.animId); } _ngState = null; if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; } } +function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTrafficRefresh(); _analyticsData = {}; _channelData = null; if (_ngState && _ngState.animId) { cancelAnimationFrame(_ngState.animId); } _ngState = null; if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; } } // Expose for testing if (typeof window !== 'undefined') { @@ -2701,6 +2706,8 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData = window._analyticsRenderMultiByteAdopters = renderMultiByteAdopters; window._analyticsHashStatCardsHtml = hashStatCardsHtml; window._analyticsRenderCollisionsFromServer = renderCollisionsFromServer; + window._analyticsRenderForeignTrafficTab = renderForeignTrafficTab; + window._analyticsStopForeignTrafficRefresh = _stopForeignTrafficRefresh; } // ─── Neighbor Graph Tab ───────────────────────────────────────────────────── @@ -4857,56 +4864,69 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData = // accumulate first, since it's only set going forward from when geo_filter // was configured — not backfilled for existing history. async function renderForeignTrafficTab(el) { - el.innerHTML = '
Loading repeater relay stats…
'; function pct(n, total) { if (!total) return '—'; return (n / total * 100).toFixed(1) + '%'; } - try { - const nodesResp = await fetchAllNodes('', { ttl: CLIENT_TTL.nodeList }); - const allNodes = nodesResp.nodes || nodesResp; - const relays = allNodes.filter(function(n) { - return (n.role === 'repeater' || n.role === 'room') && (n.unscoped_relay_count_24h || 0) > 0; - }); - relays.sort(function(a, b) { return (b.unscoped_relay_count_24h || 0) - (a.unscoped_relay_count_24h || 0); }); - const foreignCount = allNodes.filter(function(n) { return n.foreign; }).length; + async function load() { + try { + const nodesResp = await fetchAllNodes('', { ttl: CLIENT_TTL.nodeList }); + const allNodes = nodesResp.nodes || nodesResp; + const relays = allNodes.filter(function(n) { + return (n.role === 'repeater' || n.role === 'room') && (n.unscoped_relay_count_24h || 0) > 0; + }); + relays.sort(function(a, b) { return (b.unscoped_relay_count_24h || 0) - (a.unscoped_relay_count_24h || 0); }); + const foreignCount = allNodes.filter(function(n) { return n.foreign; }).length; - let body; - if (relays.length > 0) { - const rows = relays.map(function(n) { - const total = n.relay_count_24h || 0; - const unscoped = n.unscoped_relay_count_24h || 0; - return '' + - '' + esc(n.name || n.public_key) + '' + - '' + esc(n.role) + '' + - '' + unscoped.toLocaleString() + '' + - '' + total.toLocaleString() + '' + - '' + pct(unscoped, total) + '' + - ''; - }).join(''); - body = '' + - '' + - '' + rows + '' + - '
RepeaterRoleUnscoped Relays (24h)Total Relays (24h)% Unscoped
'; - } else { - body = '

No repeater has relayed an unscoped flood packet in the last 24 hours.

'; + let body; + if (relays.length > 0) { + const rows = relays.map(function(n) { + const total = n.relay_count_24h || 0; + const unscoped = n.unscoped_relay_count_24h || 0; + return '' + + '' + esc(n.name || n.public_key) + '' + + '' + esc(n.role) + '' + + '' + unscoped.toLocaleString() + '' + + '' + total.toLocaleString() + '' + + '' + pct(unscoped, total) + '' + + ''; + }).join(''); + body = '' + + '' + + '' + rows + '' + + '
RepeaterRoleUnscoped Relays (24h)Total Relays (24h)% Unscoped
'; + } else { + body = '

No repeater has relayed an unscoped flood packet in the last 24 hours.

'; + } + + const foreignNote = foreignCount > 0 + ? foreignCount.toLocaleString() + ' node(s) so far carry an advertised GPS position outside the configured geo_filter — cross-referencing which rows below actually trace back to those is a planned follow-up once more accumulates.' + : 'geo_filter is configured, but no foreign-origin node has advertised since — check back once traffic accumulates to cross-reference which of the unscoped relays below trace back to a foreign sender.'; + + el.innerHTML = + '

Foreign Traffic

' + + '

' + + 'Repeaters relaying unscoped (route_type FLOOD) packets in the last 24 hours, sorted by volume. ' + + 'A well-configured repeater sets flood.max.unscoped 0 — a non-trivial count here flags a base-config problem worth investigating on that specific node. ' + + foreignNote + + '

' + + body; + } catch (e) { + el.innerHTML = '

Failed to load repeater relay stats.

'; } - - const foreignNote = foreignCount > 0 - ? foreignCount.toLocaleString() + ' node(s) so far carry an advertised GPS position outside the configured geo_filter — cross-referencing which rows below actually trace back to those is a planned follow-up once more accumulates.' - : 'geo_filter is configured, but no foreign-origin node has advertised since — check back once traffic accumulates to cross-reference which of the unscoped relays below trace back to a foreign sender.'; - - el.innerHTML = - '

Foreign Traffic

' + - '

' + - 'Repeaters relaying unscoped (route_type FLOOD) packets in the last 24 hours, sorted by volume. ' + - 'A well-configured repeater sets flood.max.unscoped 0 — a non-trivial count here flags a base-config problem worth investigating on that specific node. ' + - foreignNote + - '

' + - body; - } catch (e) { - el.innerHTML = '

Failed to load repeater relay stats.

'; } + + el.innerHTML = '
Loading repeater relay stats…
'; + await load(); + + // Auto-refresh every 60s while this tab is active (matches Roles/Scopes). + _stopForeignTrafficRefresh(); + _foreignTrafficRefreshTimer = setInterval(function() { + if (_currentTab !== 'foreign-traffic') { _stopForeignTrafficRefresh(); return; } + var cur = document.getElementById('analyticsContent'); + if (!cur) { _stopForeignTrafficRefresh(); return; } + load(); + }, 60000); } // #1085 — Roles tab (folded in from former /#/roles page). diff --git a/test-all.sh b/test-all.sh index 914fcee0..5d78df78 100755 --- a/test-all.sh +++ b/test-all.sh @@ -28,6 +28,7 @@ node test-channel-qr-wiring.js node test-channel-issue-1087.js node test-issue-1409-no-encrypted-flood.js node test-analytics-channels-integration.js +node test-analytics-foreign-traffic-tab.js node test-observers-headings.js node test-issue-1789-observer-firmware-cols.js node test-issue-1648-m1-emoji-scan.js diff --git a/test-analytics-foreign-traffic-tab.js b/test-analytics-foreign-traffic-tab.js new file mode 100644 index 00000000..aedc9c9e --- /dev/null +++ b/test-analytics-foreign-traffic-tab.js @@ -0,0 +1,180 @@ +/** + * DOM-rendering tests for the "Foreign Traffic" Analytics tab + * (renderForeignTrafficTab, public/analytics.js). + * + * Per bot review on PR #1852 (comment 5012304813): the tab shipped with + * zero tests, repeating the same TDD-policy violation flagged on a prior + * round. This seeds a fetchAllNodes() stub with a mix of + * unscoped_relay_count_24h values and asserts the rendered row order, + * exclusion rules, and the foreignCount summary note — plus that the + * 60s auto-refresh timer the bot also flagged as missing now exists and + * is safely stoppable. + */ +'use strict'; + +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +let passed = 0, failed = 0; +async function testAsync(name, fn) { + try { + await fn(); + passed++; + console.log(` ✅ ${name}`); + } catch (e) { + failed++; + console.log(` ❌ ${name}: ${e.message}`); + } +} + +// Faithful copy of test-frontend-helpers.js's proven sandbox (same file +// this suite's makeAnalyticsSandbox() pattern is borrowed from) — rolling +// a fresh one by hand tends to miss a global roles.js/app.js touch eagerly +// at load time (window.addEventListener, fetch, etc.). +function makeSandbox() { + const ctx = { + window: { addEventListener: () => {}, dispatchEvent: () => {} }, + document: { + readyState: 'complete', + createElement: () => ({ id: '', textContent: '', innerHTML: '' }), + head: { appendChild: () => {} }, + getElementById: () => null, + addEventListener: () => {}, + querySelectorAll: () => [], + querySelector: () => null, + }, + console, Date, Infinity, Math, Array, Object, String, Number, JSON, RegExp, + Error, TypeError, parseInt, parseFloat, isNaN, isFinite, + encodeURIComponent, decodeURIComponent, + setTimeout: () => {}, clearTimeout: () => {}, setInterval: () => {}, clearInterval: () => {}, + fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }), + performance: { now: () => Date.now() }, + localStorage: (() => { const s = {}; return { getItem: k => s[k] || null, setItem: (k, v) => { s[k] = String(v); }, removeItem: k => { delete s[k]; } }; })(), + location: { hash: '' }, + getHashParams: function() { return new URLSearchParams((ctx.location.hash.split('?')[1] || '')); }, + CustomEvent: class CustomEvent {}, + Map, Promise, URLSearchParams, + addEventListener: () => {}, + dispatchEvent: () => {}, + requestAnimationFrame: (cb) => setTimeout(cb, 0), + }; + vm.createContext(ctx); + return ctx; +} + +function loadInCtx(ctx, file) { + if (!ctx.__payloadLabelsLoaded && file !== 'public/payload-labels.js') { + ctx.__payloadLabelsLoaded = true; + vm.runInContext(fs.readFileSync('public/payload-labels.js', 'utf8'), ctx); + } + vm.runInContext(fs.readFileSync(file, 'utf8'), ctx); + for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; +} + +function makeAnalyticsSandbox(nodesFixture) { + const ctx = makeSandbox(); + ctx.getComputedStyle = () => ({ getPropertyValue: () => '' }); + ctx.registerPage = () => {}; + ctx.api = () => Promise.resolve({}); + ctx.timeAgo = (iso) => iso ? 'x ago' : '—'; + ctx.RegionFilter = { init: () => {}, onChange: () => {}, regionQueryString: () => '' }; + ctx.onWS = () => {}; + ctx.offWS = () => {}; + ctx.connectWS = () => {}; + ctx.invalidateApiCache = () => {}; + ctx.makeColumnsResizable = () => {}; + ctx.initTabBar = () => {}; + ctx.IATA_COORDS_GEO = {}; + loadInCtx(ctx, 'public/roles.js'); + loadInCtx(ctx, 'public/app.js'); + // Override fetchAllNodes (loaded from app.js) with a stub that hands + // back a fixed node list, instead of exercising its real pagination + // loop against the stubbed api(). + ctx.fetchAllNodes = async () => ({ nodes: nodesFixture }); + try { loadInCtx(ctx, 'public/analytics.js'); } catch (e) { + for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; + } + return ctx; +} + +function fakeEl() { + return { innerHTML: '' }; +} + +(async () => { + console.log('\n=== analytics.js: renderForeignTrafficTab ==='); + + await testAsync('sorts repeaters by unscoped_relay_count_24h descending', async () => { + const ctx = makeAnalyticsSandbox([ + { public_key: 'pkA', name: 'RepeaterLow', role: 'repeater', unscoped_relay_count_24h: 10, relay_count_24h: 20, foreign: false }, + { public_key: 'pkB', name: 'RepeaterHigh', role: 'repeater', unscoped_relay_count_24h: 50, relay_count_24h: 50, foreign: false }, + { public_key: 'pkC', name: 'RoomMid', role: 'room', unscoped_relay_count_24h: 25, relay_count_24h: 30, foreign: false }, + ]); + const el = fakeEl(); + await ctx.window._analyticsRenderForeignTrafficTab(el); + const idxHigh = el.innerHTML.indexOf('RepeaterHigh'); + const idxMid = el.innerHTML.indexOf('RoomMid'); + const idxLow = el.innerHTML.indexOf('RepeaterLow'); + assert.ok(idxHigh > -1 && idxMid > -1 && idxLow > -1, 'all three repeaters should be rendered'); + assert.ok(idxHigh < idxMid && idxMid < idxLow, 'rows should be sorted by unscoped_relay_count_24h descending'); + }); + + await testAsync('excludes non-repeater/room roles and zero-unscoped repeaters', async () => { + const ctx = makeAnalyticsSandbox([ + { public_key: 'pkClient', name: 'NoisyClient', role: 'client', unscoped_relay_count_24h: 999, relay_count_24h: 999, foreign: false }, + { public_key: 'pkClean', name: 'CleanRepeater', role: 'repeater', unscoped_relay_count_24h: 0, relay_count_24h: 40, foreign: false }, + { public_key: 'pkDirty', name: 'DirtyRepeater', role: 'repeater', unscoped_relay_count_24h: 5, relay_count_24h: 40, foreign: false }, + ]); + const el = fakeEl(); + await ctx.window._analyticsRenderForeignTrafficTab(el); + assert.ok(!el.innerHTML.includes('NoisyClient'), 'a client-role node must never appear, regardless of its unscoped count'); + assert.ok(!el.innerHTML.includes('CleanRepeater'), 'a repeater with zero unscoped relays must not appear'); + assert.ok(el.innerHTML.includes('DirtyRepeater'), 'a repeater with unscoped relays > 0 must appear'); + }); + + await testAsync('shows the empty-state message when no repeater has unscoped relays', async () => { + const ctx = makeAnalyticsSandbox([ + { public_key: 'pkClean', name: 'CleanRepeater', role: 'repeater', unscoped_relay_count_24h: 0, relay_count_24h: 40, foreign: false }, + ]); + const el = fakeEl(); + await ctx.window._analyticsRenderForeignTrafficTab(el); + assert.ok(el.innerHTML.includes('No repeater has relayed an unscoped flood packet'), 'empty state message should be shown'); + }); + + await testAsync('foreignCount note reflects the number of foreign-flagged nodes', async () => { + const ctx = makeAnalyticsSandbox([ + { public_key: 'pkA', name: 'RepeaterA', role: 'repeater', unscoped_relay_count_24h: 5, relay_count_24h: 5, foreign: false }, + { public_key: 'pkForeign1', name: 'ForeignNode1', role: 'client', unscoped_relay_count_24h: 0, relay_count_24h: 0, foreign: true }, + { public_key: 'pkForeign2', name: 'ForeignNode2', role: 'client', unscoped_relay_count_24h: 0, relay_count_24h: 0, foreign: true }, + ]); + const el = fakeEl(); + await ctx.window._analyticsRenderForeignTrafficTab(el); + assert.ok(el.innerHTML.includes('2 node(s)'), 'note should mention the count of foreign-flagged nodes (2)'); + }); + + await testAsync('foreignCount note falls back to the no-foreign-yet message when none are flagged', async () => { + const ctx = makeAnalyticsSandbox([ + { public_key: 'pkA', name: 'RepeaterA', role: 'repeater', unscoped_relay_count_24h: 5, relay_count_24h: 5, foreign: false }, + ]); + const el = fakeEl(); + await ctx.window._analyticsRenderForeignTrafficTab(el); + assert.ok(el.innerHTML.includes('no foreign-origin node has advertised'), 'should show the zero-foreign fallback message'); + }); + + await testAsync('a stop-refresh hook is exported and safe to call repeatedly (idempotent)', async () => { + const ctx = makeAnalyticsSandbox([]); + const stop = ctx.window._analyticsStopForeignTrafficRefresh; + assert.strictEqual(typeof stop, 'function', '_stopForeignTrafficRefresh must be exported for testing/cleanup'); + // Before any render, and after — must not throw either way. + stop(); + await ctx.window._analyticsRenderForeignTrafficTab(fakeEl()); + stop(); + stop(); + }); + + console.log('\n════════════════════════════════════════'); + console.log(` Foreign Traffic tab: ${passed} passed, ${failed} failed`); + console.log('════════════════════════════════════════'); + process.exit(failed === 0 ? 0 : 1); +})();