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 = `Failed to load: ${esc(e.message)}
`;
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 = /