From 352e1e322c6ceb7dcc81f84d859853109e5d41fa Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 21 Jul 2026 07:56:07 +0200 Subject: [PATCH 1/2] feat: reorder Wardriving tab and collapse Top Senders/Coverage to top 10 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signal Quality Trends now renders right after the message-volume chart, above Top Senders and Coverage by Observer (previously it was lower, after Sessions/Entry Points). Top Senders and Coverage by Observer collapse to the top 10 rows by default, with a "Show all N" / "Show fewer" toggle — same pattern already used for node neighbor lists (nodes.js). Coverage's "% of Observations" stays relative to ALL observers, not just the shown top-10 slice, so the percentages don't lie when collapsed. Co-Authored-By: Claude Sonnet 5 --- public/analytics.js | 70 +++++++++++++++++++++++++------- test-analytics-wardriving-tab.js | 30 ++++++++++++++ 2 files changed, 85 insertions(+), 15 deletions(-) diff --git a/public/analytics.js b/public/analytics.js index cbf36502..0b9160a9 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -5558,17 +5558,29 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf ''; } - function sendersHtml(senders, totalMessages) { + var TOP_N_LIMIT = 10; + + // Shared "Show all N / Show fewer" toggle trigger for any collapsible + // table below. The click handler itself is wired generically by + // wireExpandToggle, keyed on the data-wd-toggle attribute. + function topNToggleHtml(total, expanded, noun) { + if (total <= TOP_N_LIMIT) return ''; + var label = expanded ? 'Show fewer' : 'Show all ' + total.toLocaleString() + ' ' + noun; + return '
'; + } + + function sendersHtml(senders, totalMessages, expanded) { if (!senders || senders.length === 0) { return '

No wardriving messages in this window.

'; } - var rows = senders.map(function(s) { + var shown = expanded ? senders : senders.slice(0, TOP_N_LIMIT); + var rows = shown.map(function(s) { return '' + senderTriggerHtml(s.sender) + '' + s.count.toLocaleString() + '' + pct(s.count, totalMessages) + ''; }).join(''); return '' + '' + '' + rows + '' + - '
SenderMessages% of Total
'; + '' + topNToggleHtml(senders.length, expanded, 'senders'); } // Entry Points — resolve raw path[0] hash prefixes to repeater names, @@ -5616,12 +5628,15 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf } } - function observersHtml(observers) { + function observersHtml(observers, expanded) { if (!observers || observers.length === 0) { return '

No observer has heard wardriving traffic in this window.

'; } + // % of Observations stays relative to ALL observers, not just the + // shown top-N slice, so the numbers stay meaningful when collapsed. var totalObsCount = observers.reduce(function(sum, o) { return sum + o.observationCount; }, 0); - var rows = observers.map(function(o) { + var shown = expanded ? observers : observers.slice(0, TOP_N_LIMIT); + var rows = shown.map(function(o) { var loc = (o.lat != null && o.lon != null) ? (o.lat.toFixed(2) + ', ' + o.lon.toFixed(2)) : '—'; return '' + esc(o.observerName) + '' + '' + esc(o.iata || '—') + '' + @@ -5633,7 +5648,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf return '' + '' + '' + rows + '' + - '
ObserverRegionLat, LonObservations% of ObservationsDistinct Messages
'; + '' + topNToggleHtml(observers.length, expanded, 'observers'); } function mapLinkHtml(lat, lon) { @@ -5886,6 +5901,27 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf }); } + // Generic "Show all / Show fewer" wiring for a collapsible section — + // re-renders just that 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-wd-toggle]'); + if (btn) { + btn.addEventListener('click', function() { + expanded = !expanded; + container.innerHTML = renderFn(expanded); + attach(); + }); + } + } + attach(); + } + async function load(w) { var body; try { @@ -5894,9 +5930,15 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf body = '
' + cardsHtml(d) + '
' + '
' + chartHtml(d.timeSeries) + '
' + - '

Top Senders

' + + '

Signal Quality Trends

' + + '

Average SNR and RSSI across every observation of wardriving traffic in each time bucket — a rough proxy for link quality, not tied to any one observer.

' + + '
' + + '
Avg SNR (dB)
' + signalChartHtml(d.signalTimeSeries, 'avgSnr', 'var(--accent)', 'Average SNR over time') + '
' + + '
Avg RSSI (dBm)
' + signalChartHtml(d.signalTimeSeries, 'avgRssi', 'var(--warning, #f39c12)', 'Average RSSI over time') + '
' + + '
' + + '

Top Senders

' + '

Who\'s actively wardriving in this window, by message count.

' + - '
' + sendersHtml(d.topSenders, d.totalMessages) + '
' + + '
' + 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) + '
' + @@ -5905,13 +5947,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf '
' + entryHtml + '
' + '

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) + '
' + - '

Signal Quality Trends

' + - '

Average SNR and RSSI across every observation of wardriving traffic in each time bucket — a rough proxy for link quality, not tied to any one observer.

' + - '
' + - '
Avg SNR (dB)
' + signalChartHtml(d.signalTimeSeries, 'avgSnr', 'var(--accent)', 'Average SNR over time') + '
' + - '
Avg RSSI (dBm)
' + signalChartHtml(d.signalTimeSeries, 'avgRssi', 'var(--warning, #f39c12)', 'Average RSSI over time') + '
' + - '
' + + '
' + observersHtml(d.observers, false) + '
' + '

GPS Sharing

' + '

Senders whose client appended their own position after the standard token — a deliberate choice by that sender to share their location, not something inferred from the anonymous token itself.

' + '
' + gpsSharesHtml(d.gpsShares) + '
'; @@ -5932,6 +5968,10 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf body; attachWindowButtons(); attachSenderDrilldown(); + if (d) { + wireExpandToggle('wardrivingSenders', function(exp) { return sendersHtml(d.topSenders, d.totalMessages, exp); }); + wireExpandToggle('wardrivingObservers', function(exp) { return observersHtml(d.observers, exp); }); + } } await load(selectedWindow); diff --git a/test-analytics-wardriving-tab.js b/test-analytics-wardriving-tab.js index f8499820..b8717089 100644 --- a/test-analytics-wardriving-tab.js +++ b/test-analytics-wardriving-tab.js @@ -221,6 +221,36 @@ function makeApiStub(wardrivingResp, resolveHopsResp) { assert.ok(el.innerHTML.includes('66.7%'), 'Alice row should show 66.7% of total messages'); }); + await testAsync('Signal Quality Trends renders above Top Senders (moved up per layout request)', async () => { + const ctx = makeAnalyticsSandbox(makeApiStub(makeWardrivingResponse())); + const el = fakeEl(); + await ctx.window._analyticsRenderWardrivingTab(el); + const idxSignal = el.innerHTML.indexOf('Signal Quality Trends'); + const idxSenders = el.innerHTML.indexOf('>Top Senders<'); + assert.ok(idxSignal > -1 && idxSenders > -1, 'both sections should render'); + assert.ok(idxSignal < idxSenders, 'Signal Quality Trends should render before Top Senders'); + }); + + await testAsync('Top Senders and Coverage by Observer collapse to top 10 with a "Show all" toggle', async () => { + const manySenders = []; + for (let i = 0; i < 15; i++) manySenders.push({ sender: 'Sender' + i, count: 15 - i }); + const manyObservers = []; + for (let i = 0; i < 12; i++) manyObservers.push({ observerId: String(i), observerName: 'Observer' + i, observationCount: 12 - i, messageCount: 1 }); + const ctx = makeAnalyticsSandbox(makeApiStub(makeWardrivingResponse({ topSenders: manySenders, observers: manyObservers }))); + const el = fakeEl(); + await ctx.window._analyticsRenderWardrivingTab(el); + + const sendersSection = el.innerHTML.slice(el.innerHTML.indexOf('id="wardrivingSenders"'), el.innerHTML.indexOf('id="wardrivingSessions"')); + assert.ok(sendersSection.includes('Sender9'), 'the 10th sender (index 9) should be shown'); + assert.ok(!sendersSection.includes('Sender10'), 'the 11th sender should be collapsed away by default'); + assert.ok(sendersSection.includes('Show all 15 senders'), 'a "Show all" toggle should appear when there are more than 10 senders'); + + const observersSection = el.innerHTML.slice(el.innerHTML.indexOf('id="wardrivingObservers"'), el.innerHTML.indexOf('id="wardrivingGPSShares"')); + assert.ok(observersSection.includes('Observer9'), 'the 10th observer (index 9) should be shown'); + assert.ok(!observersSection.includes('Observer10'), 'the 11th observer should be collapsed away by default'); + assert.ok(observersSection.includes('Show all 12 observers'), 'a "Show all" toggle should appear when there are more than 10 observers'); + }); + await testAsync('Top Senders names are clickable drill-down triggers (whole-window, no since/until)', async () => { const ctx = makeAnalyticsSandbox(makeApiStub(makeWardrivingResponse())); const el = fakeEl(); From 8344b08683be9ccbb42c2c8cfa4178cfa5316ac6 Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 21 Jul 2026 08:11:54 +0200 Subject: [PATCH 2/2] feat: collapse Sessions and Entry Points to top 10 too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same "Show all N / Show fewer" pattern just added for Top Senders and Coverage by Observer, extended to Sessions and Entry Points — a 7-day window can have a lot of rows in all four tables. Entry Points splits the resolve step (async /resolve-hops call, runs once) from the render step (sync, runs once per toggle) so expanding doesn't re-hit the API. The Ambiguous bucket row, when present, stays always-visible and doesn't count against the top-10 limit. Co-Authored-By: Claude Sonnet 5 --- public/analytics.js | 69 +++++++++++++++++++++----------- test-analytics-wardriving-tab.js | 33 +++++++++++++++ 2 files changed, 78 insertions(+), 24 deletions(-) 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 '' + '' + '' + rows + '' + - '
SenderStartedDurationMessagesEntry PointsObserversAirtime
'; + '' + 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) - ? '' + - '' + - '' + rows + '' + - '
Entry-Point RepeaterObservations% of ObservationsDistinct Messages
' - : '

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 '' + + '' + + '' + rows + '' + + '
Entry-Point RepeaterObservations% of ObservationsDistinct Messages
' + 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();