diff --git a/public/analytics.js b/public/analytics.js index bc8b1dcff..5cbf4d7f4 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -253,15 +253,14 @@ // Deep-link: #/analytics?tab=collisions&window=7d const hashParams = location.hash.split('?')[1] || ''; const _ap = new URLSearchParams(hashParams); + // Every mount starts from the URL, falling back to Overview: the tab + // selected before leaving the page must not survive into this mount (#183). const urlTab = _ap.get('tab'); - if (urlTab) { - const tabBtn = analyticsTabs.querySelector(`[data-tab="${urlTab}"]`); - if (tabBtn) { - analyticsTabs.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); - tabBtn.classList.add('active'); - _currentTab = urlTab; - } - } + const urlTabBtn = urlTab && analyticsTabs.querySelector(`[data-tab="${urlTab}"]`); + _currentTab = urlTabBtn ? urlTab : 'overview'; + const activeBtn = urlTabBtn || analyticsTabs.querySelector('[data-tab="overview"]'); + analyticsTabs.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); + if (activeBtn) activeBtn.classList.add('active'); // #749 — restore time window from URL. const urlWindow = _ap.get('window'); if (urlWindow) { @@ -328,6 +327,12 @@ var _themeRefreshHandler = null; let _currentTab = 'overview'; + // Filter fragments ("®ion=…", "&area=…", "&window=…") all start with + // '&'; turn their concatenation into the path's query string (#179). + function withQuery(path, frag) { + return frag ? path + '?' + frag.slice(1) : path; + } + async function loadAnalytics(startedAt) { _cancelLoadRetry(); const gen = _loadGen; @@ -344,23 +349,20 @@ const twVal = twEl ? twEl.value : ''; const tws = twVal ? '&window=' + encodeURIComponent(twVal) : ''; // hash-sizes / hash-collisions: region + area, no window - const baseQS = (rqs + aqs).slice(1); - const sepBase = baseQS ? '?' + baseQS : ''; + const baseQ = rqs + aqs; // rf / topology: region + area + window - const windowedQS = (rqs + aqs + tws).slice(1); - const sepWin = windowedQS ? '?' + windowedQS : ''; + const windowedQ = rqs + aqs + tws; // channels: region + window (no area per original PR intent) - const chanQS = (rqs + tws).slice(1); - const sepChan = chanQS ? '?' + chanQS : ''; + const chanQ = rqs + tws; // This load retries 503s itself (retry503:false), see _loadGen. const opts = { ttl: CLIENT_TTL.analyticsRF, retry503: false }; const [hashData, rfData, topoData, chanData, collisionData, airtimeData] = await Promise.all([ - api('/analytics/hash-sizes' + sepBase, opts), - api('/analytics/rf' + sepWin, opts), - api('/analytics/topology' + sepWin, opts), - api('/analytics/channels' + sepChan, opts), - api('/analytics/hash-collisions' + sepBase, opts), - api('/analytics/relay-airtime-share' + sepWin, { ttl: CLIENT_TTL.analyticsRF }).catch(() => ({ rows: [] })), + api(withQuery('/analytics/hash-sizes', baseQ), opts), + api(withQuery('/analytics/rf', windowedQ), opts), + api(withQuery('/analytics/topology', windowedQ), opts), + api(withQuery('/analytics/channels', chanQ), opts), + api(withQuery('/analytics/hash-collisions', baseQ), opts), + api(withQuery('/analytics/relay-airtime-share', windowedQ), { ttl: CLIENT_TTL.analyticsRF }).catch(() => ({ rows: [] })), ]); if (gen !== _loadGen) return; _analyticsData = { hashData, rfData, topoData, chanData, collisionData, airtimeData }; @@ -3050,8 +3052,7 @@ const gen = _distanceGen; try { const rqs = RegionFilter.regionQueryString(); - const sep = rqs ? '?' + rqs.slice(1) : ''; - const data = await api('/analytics/distance' + sep, { ttl: CLIENT_TTL.analyticsRF }); + const data = await api(withQuery('/analytics/distance', rqs), { ttl: CLIENT_TTL.analyticsRF }); if (gen !== _distanceGen) return; // re-rendered, switched tab or left meanwhile if (_distanceIsBuilding(data)) { const ms = _retryAfterDelayMs(data); @@ -3232,10 +3233,9 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf // Load data const rqs = RegionFilter.regionQueryString(); - const sep = rqs ? '?' + rqs.slice(1) : ''; let graphData; try { - graphData = await api('/analytics/neighbor-graph' + sep + (sep ? '&' : '?') + 'min_count=1&min_score=0', { ttl: CLIENT_TTL.analyticsRF }); + graphData = await api(withQuery('/analytics/neighbor-graph', rqs + '&min_count=1&min_score=0'), { ttl: CLIENT_TTL.analyticsRF }); } catch (e) { el.innerHTML = `

Failed to load neighbor graph: ${esc(e.message)}

`; return; @@ -3714,7 +3714,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf // #1270: fetch CONFIGURED-hash-size counts so the Network Overview // tells the operational story (matching Hash Stats "By Repeaters"), // not just a math-only count of unique pubkey slices. - api('/analytics/hash-sizes' + rq, { ttl: CLIENT_TTL.analyticsRF }).catch(() => null), + api(withQuery('/analytics/hash-sizes', rq), { ttl: CLIENT_TTL.analyticsRF }).catch(() => null), ]); } catch (e) { el.innerHTML = ``; diff --git a/test-all.sh b/test-all.sh index 6a8b15d2f..1eb9ecff4 100755 --- a/test-all.sh +++ b/test-all.sh @@ -144,6 +144,7 @@ run test-issue-111-drawer-version.js run test-packet-filter-time.js run test-confidence-indicator.js run test-1659-analytics-warmup.js +run test-analytics-tab-state-and-query.js run test-channels-merge-1498-unit.js run test-issue-1518-home-url.js run test-live-region-filter.js diff --git a/test-analytics-tab-state-and-query.js b/test-analytics-tab-state-and-query.js new file mode 100644 index 000000000..8bfa628e2 --- /dev/null +++ b/test-analytics-tab-state-and-query.js @@ -0,0 +1,252 @@ +/* test-analytics-tab-state-and-query.js + * + * #179: the Prefix Tool fetched `/analytics/hash-sizes®ion=…` (no `?`) + * whenever a region or area filter was active, and got the SPA HTML. + * #183: leaving #/analytics and coming back without ?tab= marked Overview + * active but rendered the tab selected before leaving. + * + * Runs the REAL app.js api() and the REAL analytics page in one vm with a + * fake fetch and a small fake DOM whose tab buttons come from the markup + * init() writes, so the active button is whatever the page set. + */ +'use strict'; + +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +let passed = 0, failed = 0; +function test(name, fn) { + return Promise.resolve() + .then(fn) + .then(() => { passed++; console.log(' ✅ ' + name); }) + .catch((e) => { failed++; console.log(' ❌ ' + name + ': ' + (e && e.message || e)); }); +} + +const flush = async () => { for (let i = 0; i < 30; i++) await new Promise((r) => setImmediate(r)); }; + +// Real responses of the CI fixture server for the shared endpoints (#172). +const REAL = JSON.parse(fs.readFileSync('test-fixtures/analytics-tabs-172.json', 'utf8')); +const MARKERS = { overview: /Total Transmissions/, topology: /Per-Observer Reachability/ }; + +function fakeClassList(initial) { + const s = new Set(initial); + return { add: (c) => s.add(c), remove: (c) => s.delete(c), contains: (c) => s.has(c), toggle() {} }; +} + +function fakeEl(id) { + let html = ''; + const listeners = {}; + return { + id, value: '', style: {}, dataset: {}, parentElement: null, + get innerHTML() { return html; }, + set innerHTML(v) { html = String(v); }, + classList: fakeClassList([]), + addEventListener(type, fn) { (listeners[type] = listeners[type] || []).push(fn); }, removeEventListener() {}, + dispatch(type, ev) { for (const fn of listeners[type] || []) fn(ev); }, + contains: () => true, + querySelector: () => null, querySelectorAll: () => [], + appendChild: (c) => c, insertBefore: (c) => c, setAttribute() {}, getAttribute: () => null, + }; +} + +function pageEnv() { + const fetchLog = []; + let region = '', area = ''; + const respond = (body) => ({ + ok: true, status: 200, headers: { get: () => null }, + json: async () => JSON.parse(JSON.stringify(body)), + }); + const fetchImpl = async (url) => { + fetchLog.push(url); + const m = /\/api\/analytics\/(rf|topology|channels|hash-sizes|hash-collisions)(\?|$)/.exec(url); + const key = m && { rf: 'rfData', topology: 'topoData', channels: 'chanData', 'hash-sizes': 'hashData', 'hash-collisions': 'collisionData' }[m[1]]; + if (key) return respond(REAL[key]); + if (/\/api\/nodes\?/.test(url)) return respond({ nodes: [{ public_key: 'aabbccddeeff', name: 'R1', role: 'repeater' }] }); + return respond({}); + }; + + // A new app.innerHTML is a new page: every element below it is new, so + // listeners of the previous mount are gone, as in a browser. + let els = {}; + const app = fakeEl('app'); + let appHtml = ''; + Object.defineProperty(app, 'innerHTML', { + get: () => appHtml, + set: (v) => { appHtml = String(v); els = {}; buttons = null; }, + }); + let buttons = null; + const tabButtons = () => { + if (!buttons) { + buttons = []; + const re = /