mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-27 03:07:54 +00:00
Merge areas-meshguide-sync into master: collapse Foreign Traffic lists to top 10
Same "Show all N / Show fewer" pattern as Wardriving's Sessions/Entry Points. No meshguide.dk-specific content (git kept the prior merges' exclusion of ops/meshguide-sync/ automatically).
This commit is contained in:
+78
-43
@@ -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 '<div style="margin-top:6px;text-align:right"><button type="button" data-ft-toggle class="btn-link" style="font-size:12px;cursor:pointer;background:none;border:none;color:var(--link-color);padding:0">' + label + '</button></div>';
|
||||
}
|
||||
// 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 '<p class="text-muted" style="font-size:0.85em">No repeater has relayed an unscoped flood packet in the last 24 hours.</p>';
|
||||
}
|
||||
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 '<tr>' +
|
||||
'<td><a href="#/nodes/' + encodeURIComponent(n.public_key) + '">' + esc(n.name || n.public_key) + '</a></td>' +
|
||||
'<td>' + esc(n.role) + '</td>' +
|
||||
'<td>' + unscoped.toLocaleString() + '</td>' +
|
||||
'<td>' + total.toLocaleString() + '</td>' +
|
||||
'<td>' + pct(unscoped, total) + '</td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
return '<table class="data-table analytics-table">' +
|
||||
'<thead><tr><th>Repeater</th><th>Role</th><th>Unscoped Relays (24h)</th><th>Total Relays (24h)</th><th>% Unscoped</th></tr></thead>' +
|
||||
'<tbody>' + rows + '</tbody>' +
|
||||
'</table>' + topNToggleHtml(relays.length, expanded, 'repeaters');
|
||||
}
|
||||
|
||||
function foreignNodesHtml(foreignNodes, expanded) {
|
||||
if (foreignNodes.length === 0) {
|
||||
return '<p class="text-muted" style="font-size:0.85em">None yet — a node is only listed here after it advertises with a GPS position outside the configured geo_filter.</p>';
|
||||
}
|
||||
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 '<tr>' +
|
||||
'<td><a href="#/nodes/' + encodeURIComponent(n.public_key) + '">' + esc(n.name || n.public_key) + '</a></td>' +
|
||||
'<td>' + esc(n.role || '—') + '</td>' +
|
||||
'<td>' + lat + ', ' + lon + '</td>' +
|
||||
'<td>' + timeAgo(n.first_seen) + '</td>' +
|
||||
'<td>' + timeAgo(n.last_seen) + '</td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
return '<table class="data-table analytics-table">' +
|
||||
'<thead><tr><th>Node</th><th>Role</th><th>Lat, Lon</th><th>First Seen</th><th>Last Seen</th></tr></thead>' +
|
||||
'<tbody>' + rows + '</tbody>' +
|
||||
'</table>' + 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 '<tr>' +
|
||||
'<td><a href="#/nodes/' + encodeURIComponent(n.public_key) + '">' + esc(n.name || n.public_key) + '</a></td>' +
|
||||
'<td>' + esc(n.role) + '</td>' +
|
||||
'<td>' + unscoped.toLocaleString() + '</td>' +
|
||||
'<td>' + total.toLocaleString() + '</td>' +
|
||||
'<td>' + pct(unscoped, total) + '</td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
body = '<table class="data-table analytics-table">' +
|
||||
'<thead><tr><th>Repeater</th><th>Role</th><th>Unscoped Relays (24h)</th><th>Total Relays (24h)</th><th>% Unscoped</th></tr></thead>' +
|
||||
'<tbody>' + rows + '</tbody>' +
|
||||
'</table>';
|
||||
} else {
|
||||
body = '<p class="text-muted" style="font-size:0.85em">No repeater has relayed an unscoped flood packet in the last 24 hours.</p>';
|
||||
}
|
||||
|
||||
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 '<tr>' +
|
||||
'<td><a href="#/nodes/' + encodeURIComponent(n.public_key) + '">' + esc(n.name || n.public_key) + '</a></td>' +
|
||||
'<td>' + esc(n.role || '—') + '</td>' +
|
||||
'<td>' + lat + ', ' + lon + '</td>' +
|
||||
'<td>' + timeAgo(n.first_seen) + '</td>' +
|
||||
'<td>' + timeAgo(n.last_seen) + '</td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
foreignNodesBody = '<table class="data-table analytics-table">' +
|
||||
'<thead><tr><th>Node</th><th>Role</th><th>Lat, Lon</th><th>First Seen</th><th>Last Seen</th></tr></thead>' +
|
||||
'<tbody>' + foreignRows + '</tbody>' +
|
||||
'</table>';
|
||||
} else {
|
||||
foreignNodesBody = '<p class="text-muted" style="font-size:0.85em">None yet — a node is only listed here after it advertises with a GPS position outside the configured geo_filter.</p>';
|
||||
}
|
||||
|
||||
el.innerHTML =
|
||||
'<h3 style="margin:0 0 4px">Foreign Traffic</h3>' +
|
||||
@@ -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.' +
|
||||
'</p>' +
|
||||
foreignNodesBody +
|
||||
'<div id="foreignTrafficFlaggedNodes">' + foreignNodesHtml(foreignNodes, false) + '</div>' +
|
||||
'<h4 style="margin:24px 0 4px">Repeaters Relaying Unscoped Traffic</h4>' +
|
||||
body;
|
||||
'<div id="foreignTrafficRelays">' + relaysHtml(relays, false) + '</div>';
|
||||
|
||||
wireExpandToggle('foreignTrafficFlaggedNodes', function(exp) { return foreignNodesHtml(foreignNodes, exp); });
|
||||
wireExpandToggle('foreignTrafficRelays', function(exp) { return relaysHtml(relays, exp); });
|
||||
} catch (e) {
|
||||
el.innerHTML = '<p class="text-muted">Failed to load repeater relay stats.</p>';
|
||||
}
|
||||
|
||||
@@ -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('════════════════════════════════════════');
|
||||
|
||||
Reference in New Issue
Block a user