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 = '' +
- '| Repeater | Role | Unscoped Relays (24h) | Total Relays (24h) | % Unscoped |
' +
- '' + rows + '' +
- '
';
- } 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 = '' +
+ '| Repeater | Role | Unscoped Relays (24h) | Total Relays (24h) | % Unscoped |
' +
+ '' + rows + '' +
+ '
';
+ } 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);
+})();