diff --git a/public/analytics.js b/public/analytics.js index 2144c869..33b094b1 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -5471,6 +5471,35 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf if (!total) return '—'; return (n / total * 100).toFixed(1) + '%'; } + + // "Show all N / Show fewer" collapse for the two lists below, same + // pattern (and same TOP_N_LIMIT) as the Wardriving tab's Sessions/ + // Entry Points sections. + var TOP_N_LIMIT = 10; + function topNToggleHtml(total, expanded, noun) { + if (total <= TOP_N_LIMIT) return ''; + var label = expanded ? 'Show fewer' : 'Show all ' + total.toLocaleString() + ' ' + noun; + return '
'; + } + // Re-renders just one section's content on click and rewires the + // (freshly-created) toggle button, since innerHTML replacement drops + // the old node's listener. + function wireExpandToggle(containerId, renderFn) { + var expanded = false; + function attach() { + var container = document.getElementById(containerId); + if (!container) return; + var btn = container.querySelector('[data-ft-toggle]'); + if (btn) { + btn.addEventListener('click', function() { + expanded = !expanded; + container.innerHTML = renderFn(expanded); + attach(); + }); + } + } + attach(); + } // Classifies live from the node's own lat/lon against the currently // configured geo_filter box/polygon (window.MC_GEO_FILTER, set from // /api/config/client — see public/roles.js), NOT the node's `foreign` @@ -5485,6 +5514,50 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf function isForeignNode(n) { return !nodePassesGeoFilter(n.lat, n.lon, window.MC_GEO_FILTER); } + function relaysHtml(relays, expanded) { + if (relays.length === 0) { + return '

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

'; + } + var shown = expanded ? relays : relays.slice(0, TOP_N_LIMIT); + var rows = shown.map(function(n) { + var total = n.relay_count_24h || 0; + var 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(''); + return '' + + '' + + '' + rows + '' + + '
RepeaterRoleUnscoped Relays (24h)Total Relays (24h)% Unscoped
' + topNToggleHtml(relays.length, expanded, 'repeaters'); + } + + function foreignNodesHtml(foreignNodes, expanded) { + if (foreignNodes.length === 0) { + return '

None yet — a node is only listed here after it advertises with a GPS position outside the configured geo_filter.

'; + } + var shown = expanded ? foreignNodes : foreignNodes.slice(0, TOP_N_LIMIT); + var rows = shown.map(function(n) { + var lat = typeof n.lat === 'number' ? n.lat.toFixed(3) : '—'; + var lon = typeof n.lon === 'number' ? n.lon.toFixed(3) : '—'; + return '' + + '' + esc(n.name || n.public_key) + '' + + '' + esc(n.role || '—') + '' + + '' + lat + ', ' + lon + '' + + '' + timeAgo(n.first_seen) + '' + + '' + timeAgo(n.last_seen) + '' + + ''; + }).join(''); + return '' + + '' + + '' + rows + '' + + '
NodeRoleLat, LonFirst SeenLast Seen
' + topNToggleHtml(foreignNodes.length, expanded, 'nodes'); + } + async function load() { try { const nodesResp = await fetchAllNodes('', { ttl: CLIENT_TTL.nodeList }); @@ -5495,27 +5568,6 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf relays.sort(function(a, b) { return Number(b.unscoped_relay_count_24h || 0) - Number(a.unscoped_relay_count_24h || 0); }); const foreignCount = allNodes.filter(isForeignNode).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.

'; - } - 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.'; @@ -5531,26 +5583,6 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf .map(function(n) { return { n: n, t: new Date(n.last_seen || 0).getTime() }; }) .sort(function(a, b) { return b.t - a.t; }) .forEach(function(entry, i) { foreignNodes[i] = entry.n; }); - let foreignNodesBody; - if (foreignNodes.length > 0) { - const foreignRows = foreignNodes.map(function(n) { - const lat = typeof n.lat === 'number' ? n.lat.toFixed(3) : '—'; - const lon = typeof n.lon === 'number' ? n.lon.toFixed(3) : '—'; - return '' + - '' + esc(n.name || n.public_key) + '' + - '' + esc(n.role || '—') + '' + - '' + lat + ', ' + lon + '' + - '' + timeAgo(n.first_seen) + '' + - '' + timeAgo(n.last_seen) + '' + - ''; - }).join(''); - foreignNodesBody = '' + - '' + - '' + foreignRows + '' + - '
NodeRoleLat, LonFirst SeenLast Seen
'; - } else { - foreignNodesBody = '

None yet — a node is only listed here after it advertises with a GPS position outside the configured geo_filter.

'; - } el.innerHTML = '

Foreign Traffic

' + @@ -5564,9 +5596,12 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf 'Nodes whose most recent advertised GPS position fell outside the configured geo_filter — self-reported GPS from the node\'s own ADVERT. ' + 'A FLOOD packet can legitimately travel many hops through a dense mesh, so a node whose traffic you can trace through a real local relay chain (check its Trace) is genuinely reachable via that chain — that\'s foreign traffic actually entering the mesh, not a data artifact.' + '

' + - foreignNodesBody + + '
' + foreignNodesHtml(foreignNodes, false) + '
' + '

Repeaters Relaying Unscoped Traffic

' + - body; + '
' + relaysHtml(relays, false) + '
'; + + wireExpandToggle('foreignTrafficFlaggedNodes', function(exp) { return foreignNodesHtml(foreignNodes, exp); }); + wireExpandToggle('foreignTrafficRelays', function(exp) { return relaysHtml(relays, exp); }); } catch (e) { el.innerHTML = '

Failed to load repeater relay stats.

'; } diff --git a/test-analytics-foreign-traffic-tab.js b/test-analytics-foreign-traffic-tab.js index bef0dfd7..864e43db 100644 --- a/test-analytics-foreign-traffic-tab.js +++ b/test-analytics-foreign-traffic-tab.js @@ -268,6 +268,56 @@ function fakeEl() { assert.deepStrictEqual(ctx.__clearedIntervalIds, [registeredId], 'a second stop() call must not clear anything again — the timer reference should already be null'); }); + await testAsync('Foreign-Flagged Nodes collapses to top 10 with a "Show all" toggle', async () => { + const fixture = []; + for (let i = 0; i < 15; i++) { + // Newest-heard-first sort means index 0 is the most recent -- give + // decreasing last_seen so Foreign0..Foreign9 are exactly the shown set. + fixture.push({ + public_key: 'pkForeign' + i, name: 'Foreign' + i, role: 'client', + lat: 40.7, lon: -74.0, + first_seen: '2026-07-01T00:00:00Z', + last_seen: '2026-07-' + String(20 - i).padStart(2, '0') + 'T00:00:00Z', + }); + } + const ctx = makeAnalyticsSandbox(fixture); + const el = fakeEl(); + await ctx.window._analyticsRenderForeignTrafficTab(el); + const startIdx = el.innerHTML.indexOf('id="foreignTrafficFlaggedNodes"'); + const endIdx = el.innerHTML.indexOf('Repeaters Relaying Unscoped Traffic'); + const section = el.innerHTML.slice(startIdx, endIdx); + assert.ok(section.includes('Foreign9'), 'the 10th foreign node (index 9) should be shown'); + assert.ok(!section.includes('Foreign10'), 'the 11th foreign node should be collapsed away by default'); + assert.ok(section.includes('Show all 15 nodes'), 'a "Show all" toggle should appear when there are more than 10 foreign nodes'); + }); + + await testAsync('Repeaters Relaying Unscoped Traffic collapses to top 10 with a "Show all" toggle', async () => { + const fixture = []; + for (let i = 0; i < 15; i++) { + fixture.push({ + public_key: 'pkRelay' + i, name: 'Relay' + i, role: 'repeater', + unscoped_relay_count_24h: 15 - i, relay_count_24h: 20, + }); + } + const ctx = makeAnalyticsSandbox(fixture); + const el = fakeEl(); + await ctx.window._analyticsRenderForeignTrafficTab(el); + const section = el.innerHTML.slice(el.innerHTML.indexOf('id="foreignTrafficRelays"')); + assert.ok(section.includes('Relay9'), 'the 10th relay (index 9, unscoped_relay_count_24h=6) should be shown'); + assert.ok(!section.includes('Relay10'), 'the 11th relay should be collapsed away by default'); + assert.ok(section.includes('Show all 15 repeaters'), 'a "Show all" toggle should appear when there are more than 10 relaying repeaters'); + }); + + await testAsync('no "Show all" toggle when a list has 10 or fewer entries', async () => { + const ctx = makeAnalyticsSandbox([ + { public_key: 'pkA', name: 'RepeaterA', role: 'repeater', unscoped_relay_count_24h: 5, relay_count_24h: 5 }, + { public_key: 'pkForeign', name: 'ForeignNode', role: 'client', lat: 40.7, lon: -74.0 }, + ]); + const el = fakeEl(); + await ctx.window._analyticsRenderForeignTrafficTab(el); + assert.ok(!el.innerHTML.includes('Show all'), 'no toggle should render when neither list exceeds 10 entries'); + }); + console.log('\n════════════════════════════════════════'); console.log(` Foreign Traffic tab: ${passed} passed, ${failed} failed`); console.log('════════════════════════════════════════');