diff --git a/public/analytics.js b/public/analytics.js
index 0b9160a9..c56d2fa8 100644
--- a/public/analytics.js
+++ b/public/analytics.js
@@ -5539,11 +5539,12 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf
return (ms / 1000).toFixed(1) + 's';
}
- function sessionsHtml(sessions) {
+ function sessionsHtml(sessions, expanded) {
if (!sessions || sessions.length === 0) {
return '
No wardriving sessions in this window.
';
}
- var rows = sessions.map(function(s) {
+ var shown = expanded ? sessions : sessions.slice(0, TOP_N_LIMIT);
+ var rows = shown.map(function(s) {
return '| ' + senderTriggerHtml(s.sender, s.startTime, s.endTime) + ' | ' +
'' + (typeof timeAgo === 'function' ? timeAgo(s.startTime) : s.startTime) + ' | ' +
'' + formatSessionDuration(s.durationMinutes) + ' | ' +
@@ -5555,7 +5556,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf
return '' +
'| Sender | Started | Duration | Messages | Entry Points | Observers | Airtime |
' +
'' + rows + '' +
- '
';
+ '' + topNToggleHtml(sessions.length, expanded, 'sessions');
}
var TOP_N_LIMIT = 10;
@@ -5587,10 +5588,12 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf
// same unique_prefix-only discipline as the Foreign Traffic tab (a
// non-unique_prefix resolution is a genuine hash collision across
// multiple candidate repeaters — folded into "Ambiguous" rather than
- // guessing).
- async function entryPointsHtml(prefixes) {
+ // guessing). Split into an async resolve step (runs once) and a sync
+ // render step (runs once per collapse/expand toggle) so expanding
+ // doesn't re-hit /resolve-hops.
+ async function resolveEntryPoints(prefixes) {
if (!prefixes || prefixes.length === 0) {
- return 'No wardriving messages with a relay path in this window.
';
+ return { state: 'empty' };
}
try {
var resp = await api('/resolve-hops?hops=' + prefixes.map(function(p) { return p.prefix; }).join(','), { ttl: CLIENT_TTL.nodeDetail }).catch(function() { return null; });
@@ -5608,24 +5611,40 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf
}
});
named.sort(function(a, b) { return b.obs - a.obs; });
- var rows = named.map(function(e) {
- return '
| ' + esc(e.name) + ' | ' +
- '' + e.obs.toLocaleString() + ' | ' +
- '' + pct(e.obs, totalObs) + ' | ' +
- '' + e.msgs.toLocaleString() + ' |
';
- }).join('') + (ambiguousObs > 0
- ? '| Ambiguous (hash prefix collides across multiple candidate repeaters) | ' +
- '' + ambiguousObs.toLocaleString() + ' | ' + pct(ambiguousObs, totalObs) + ' | ' + ambiguousMsgs.toLocaleString() + ' |
'
- : '');
- return (named.length > 0 || ambiguousObs > 0)
- ? '' +
- '| Entry-Point Repeater | Observations | % of Observations | Distinct Messages |
' +
- '' + rows + '' +
- '
'
- : 'No traceable relay path yet for any wardriving message.
';
+ return { state: 'ok', named: named, ambiguousObs: ambiguousObs, ambiguousMsgs: ambiguousMsgs, totalObs: totalObs };
} catch (e) {
+ return { state: 'error' };
+ }
+ }
+
+ function entryPointsHtml(data, expanded) {
+ if (data.state === 'empty') {
+ return 'No wardriving messages with a relay path in this window.
';
+ }
+ if (data.state === 'error') {
return 'Failed to resolve entry points.
';
}
+ var named = data.named, ambiguousObs = data.ambiguousObs, ambiguousMsgs = data.ambiguousMsgs, totalObs = data.totalObs;
+ if (named.length === 0 && ambiguousObs === 0) {
+ return 'No traceable relay path yet for any wardriving message.
';
+ }
+ // The ambiguous bucket (if any) is a fixed, always-visible row — only
+ // the named repeaters collapse to top-N, so "Ambiguous" doesn't
+ // count against the limit or get hidden by it.
+ var shownNamed = expanded ? named : named.slice(0, TOP_N_LIMIT);
+ var rows = shownNamed.map(function(e) {
+ return '| ' + esc(e.name) + ' | ' +
+ '' + e.obs.toLocaleString() + ' | ' +
+ '' + pct(e.obs, totalObs) + ' | ' +
+ '' + e.msgs.toLocaleString() + ' |
';
+ }).join('') + (ambiguousObs > 0
+ ? '| Ambiguous (hash prefix collides across multiple candidate repeaters) | ' +
+ '' + ambiguousObs.toLocaleString() + ' | ' + pct(ambiguousObs, totalObs) + ' | ' + ambiguousMsgs.toLocaleString() + ' |
'
+ : '');
+ return '' +
+ '| Entry-Point Repeater | Observations | % of Observations | Distinct Messages |
' +
+ '' + rows + '' +
+ '
' + topNToggleHtml(named.length, expanded, 'entry-point repeaters');
}
function observersHtml(observers, expanded) {
@@ -5926,7 +5945,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf
var body;
try {
var d = await api('/analytics/wardriving?window=' + encodeURIComponent(w), { ttl: 30000 });
- var entryHtml = await entryPointsHtml(d.entryPoints || []);
+ var entryData = await resolveEntryPoints(d.entryPoints || []);
body =
'' + cardsHtml(d) + '
' +
'' + chartHtml(d.timeSeries) + '
' +
@@ -5941,10 +5960,10 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf
'' + sendersHtml(d.topSenders, d.totalMessages, false) + '
' +
'Sessions
' +
'Each sender\'s messages grouped into distinct runs — a gap of more than 15 minutes starts a new session.
' +
- '' + sessionsHtml(d.sessions) + '
' +
+ '' + sessionsHtml(d.sessions, false) + '
' +
'Entry Points
' +
'Which local repeater first relayed each wardriving message — the hop closest to the origin (path[0]) across every observed copy.
' +
- '' + entryHtml + '
' +
+ '' + entryPointsHtml(entryData, false) + '
' +
'Coverage by Observer
' +
'Which observer stations actually heard wardriving traffic — observers sit at fixed, known locations, so this is the reliable half of "how far did it reach."
' +
'' + observersHtml(d.observers, false) + '
' +
@@ -5971,6 +5990,8 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf
if (d) {
wireExpandToggle('wardrivingSenders', function(exp) { return sendersHtml(d.topSenders, d.totalMessages, exp); });
wireExpandToggle('wardrivingObservers', function(exp) { return observersHtml(d.observers, exp); });
+ wireExpandToggle('wardrivingSessions', function(exp) { return sessionsHtml(d.sessions, exp); });
+ wireExpandToggle('wardrivingEntryPoints', function(exp) { return entryPointsHtml(entryData, exp); });
}
}
diff --git a/test-analytics-wardriving-tab.js b/test-analytics-wardriving-tab.js
index b8717089..131a740d 100644
--- a/test-analytics-wardriving-tab.js
+++ b/test-analytics-wardriving-tab.js
@@ -251,6 +251,39 @@ function makeApiStub(wardrivingResp, resolveHopsResp) {
assert.ok(observersSection.includes('Show all 12 observers'), 'a "Show all" toggle should appear when there are more than 10 observers');
});
+ await testAsync('Sessions and Entry Points collapse to top 10 with a "Show all" toggle', async () => {
+ const manySessions = [];
+ for (let i = 0; i < 13; i++) {
+ manySessions.push({
+ sender: 'Sender' + i, startTime: '2026-07-20T0' + (i % 9) + ':00:00Z', endTime: '2026-07-20T0' + (i % 9) + ':05:00Z',
+ durationMinutes: 5, messageCount: 1, entryPointCount: 1, observerCount: 1, airtimeMs: 100,
+ });
+ }
+ const manyPrefixes = [];
+ const resolved = {};
+ for (let i = 0; i < 14; i++) {
+ const prefix = 'P' + String(i).padStart(3, '0');
+ manyPrefixes.push({ prefix, observationCount: 14 - i, messageCount: 1 });
+ resolved[prefix] = { name: 'Repeater' + i, pubkey: 'pk' + i, confidence: 'unique_prefix' };
+ }
+ const ctx = makeAnalyticsSandbox(makeApiStub(
+ makeWardrivingResponse({ sessions: manySessions, entryPoints: manyPrefixes }),
+ { resolved }
+ ));
+ const el = fakeEl();
+ await ctx.window._analyticsRenderWardrivingTab(el);
+
+ const sessionsSection = el.innerHTML.slice(el.innerHTML.indexOf('id="wardrivingSessions"'), el.innerHTML.indexOf('id="wardrivingEntryPoints"'));
+ assert.ok(sessionsSection.includes('>Sender9<'), 'the 10th session (index 9) should be shown');
+ assert.ok(!sessionsSection.includes('>Sender10<'), 'the 11th session should be collapsed away by default');
+ assert.ok(sessionsSection.includes('Show all 13 sessions'), 'a "Show all" toggle should appear when there are more than 10 sessions');
+
+ const entrySection = el.innerHTML.slice(el.innerHTML.indexOf('id="wardrivingEntryPoints"'), el.innerHTML.indexOf('id="wardrivingObservers"'));
+ assert.ok(entrySection.includes('Repeater9'), 'the 10th entry point (index 9, highest observation count) should be shown');
+ assert.ok(!entrySection.includes('Repeater10'), 'the 11th entry point should be collapsed away by default');
+ assert.ok(entrySection.includes('Show all 14 entry-point repeaters'), 'a "Show all" toggle should appear when there are more than 10 entry points');
+ });
+
await testAsync('Top Senders names are clickable drill-down triggers (whole-window, no since/until)', async () => {
const ctx = makeAnalyticsSandbox(makeApiStub(makeWardrivingResponse()));
const el = fakeEl();