diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c80b6688b..1357fda7b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -767,6 +767,7 @@ jobs: suite 3 test-observer-iata-1188-e2e.js BASE_URL=$B suite 2 test-issue-1639-observers-sort-e2e.js BASE_URL=$B suite 2 test-issue-1758-ng-filter-rerenders-e2e.js CHROMIUM_REQUIRE=1 BASE_URL=$B + suite 2 test-analytics-loading-e2e.js BASE_URL=$B suite 2 test-nav-fluid-1055-e2e.js CHROMIUM_REQUIRE=1 BASE_URL=$B suite 3 test-nav-priority-1102-e2e.js CHROMIUM_REQUIRE=1 BASE_URL=$B suite 3 test-nav-priority-1311-e2e.js CHROMIUM_REQUIRE=1 BASE_URL=$B diff --git a/public/analytics.js b/public/analytics.js index 7851bdb2c..ec17474ee 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -2,10 +2,28 @@ 'use strict'; (function () { - let _analyticsData = {}; + let _analyticsData = null; + let _analyticsLoadId = 0; + let _analyticsRenderId = 0; + const _analyticsRequests = new WeakMap(); + let _analyticsLoadError = ''; + const _coreAnalyticsTabs = new Set(['overview', 'rf', 'topology', 'channels', 'hashsizes', 'collisions']); const sf = (v, d) => (v != null ? v.toFixed(d) : '–'); // safe toFixed function esc(s) { return s ? String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''') : ''; } + // Capture the current page/filter/tab render. A target also makes requests + // within that view latest-wins (detail panels, range picks, auto-refresh). + // Weak keys let replaced DOM nodes and their request tokens be collected. + function analyticsGuard(target) { + const el = document.getElementById('analyticsContent'); + const loadId = _analyticsLoadId, renderId = _analyticsRenderId; + const request = {}; + if (target) _analyticsRequests.set(target, request); + return () => loadId === _analyticsLoadId && renderId === _analyticsRenderId && + document.getElementById('analyticsContent') === el && + (!target || _analyticsRequests.get(target) === request); + } + // #1085 — Roles tab helpers (hoisted from renderRolesTab so they're not // re-allocated per render). function _rolesEmoji(role) { @@ -259,7 +277,10 @@ // still picks up the new theme: node colors are read live per frame // from window.ROLE_COLORS, role swatches use CSS tokens, and the one // cached value (_labelColor) is re-read on restart. - if (_currentTab === 'neighbor-graph' && _ngState) { startGraphRenderer(); return; } + if (_currentTab === 'neighbor-graph') { + if (_ngState) startGraphRenderer(); + return; + } renderTab(_currentTab); }; window.addEventListener('theme-refresh', _themeRefreshHandler); @@ -270,9 +291,21 @@ var _themeRefreshHandler = null; let _currentTab = 'overview'; + function renderAnalyticsLoadState(el) { + el.innerHTML = _analyticsLoadError + ? `` + : '
Loading analytics…
'; + } + async function loadAnalytics() { + const el = document.getElementById('analyticsContent'); + if (!el) return; + const loadId = ++_analyticsLoadId; + _analyticsData = null; + _analyticsLoadError = ''; + if (_coreAnalyticsTabs.has(_currentTab)) renderAnalyticsLoadState(el); + else renderTab(_currentTab); try { - _analyticsData = {}; const rqs = RegionFilter.regionQueryString(); // "®ion=..." or "" const aqs = AreaFilter.areaQueryString(); // "&area=..." or "" // Time window picker (#842) — append &window=… when set. @@ -299,16 +332,34 @@ api('/analytics/hash-collisions' + sepBase, { ttl: CLIENT_TTL.analyticsRF }), api('/analytics/relay-airtime-share' + sepWin, { ttl: CLIENT_TTL.analyticsRF }).catch(() => ({ rows: [] })), ]); + // A filter change or page leave makes every earlier response obsolete. + if (loadId !== _analyticsLoadId || document.getElementById('analyticsContent') !== el) return; _analyticsData = { hashData, rfData, topoData, chanData, collisionData, airtimeData }; - renderTab(_currentTab); + if (_coreAnalyticsTabs.has(_currentTab)) renderTab(_currentTab); } catch (e) { - document.getElementById('analyticsContent').innerHTML = - ``; + if (loadId !== _analyticsLoadId || document.getElementById('analyticsContent') !== el) return; + _analyticsLoadError = String(e.message || e); + if (_coreAnalyticsTabs.has(_currentTab)) renderAnalyticsLoadState(el); } } async function renderTab(tab) { const el = document.getElementById('analyticsContent'); + if (!el) return; + _analyticsRenderId++; + const isCurrent = analyticsGuard(); + _stopRolesRefresh(); + _stopScopesRefresh(); + _stopDistanceRetry(); + el.onclick = null; + if (_ngState && _ngState.animId) cancelAnimationFrame(_ngState.animId); + _ngState = null; + // Theme refreshes and tab clicks can arrive before the bulk load settles. + // Lazy tabs use their own data and remain available during that load. + if (!_analyticsData && _coreAnalyticsTabs.has(tab)) { + renderAnalyticsLoadState(el); + return; + } const d = _analyticsData; switch (tab) { case 'overview': renderOverview(el, d); break; @@ -329,8 +380,10 @@ case 'prefix-tool': await renderPrefixTool(el); break; case 'scopes': await renderScopesTab(el); break; } + if (!isCurrent()) return; // Auto-apply column resizing to all analytics tables requestAnimationFrame(() => { + if (!isCurrent()) return; el.querySelectorAll('.analytics-table').forEach((tbl, i) => { tbl.id = tbl.id || `analytics-tbl-${tab}-${i}`; if (typeof makeColumnsResizable === 'function') makeColumnsResizable('#' + tbl.id, `meshcore-analytics-${tab}-${i}-col-widths`); @@ -349,6 +402,7 @@ const sectionId = new URLSearchParams((location.hash.split('?')[1] || '')).get('section'); if (sectionId) { setTimeout(() => { + if (!isCurrent()) return; const target = document.getElementById(sectionId); if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 400); @@ -357,6 +411,7 @@ // ===================== OVERVIEW ===================== function renderOverview(el, d) { + const isCurrent = analyticsGuard(); const rf = d.rfData, topo = d.topoData, ch = d.chanData, hs = d.hashData; el.innerHTML = `
@@ -456,6 +511,7 @@ fetch('/api/debug/affinity', { headers: { 'X-API-Key': apiKey } }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (data) { + if (!isCurrent()) return; if (!data || !data.stats) return; var s = data.stats; var total = s.resolvedCount + s.ambiguousCount + s.unresolvedCount; @@ -857,6 +913,7 @@ var _rtxRequestSeq = 0; async function loadRetransmissionPressure() { if (!document.getElementById('rtxPressureChart')) return; + const isCurrent = analyticsGuard(); var seq = ++_rtxRequestSeq; var twEl = document.getElementById('analyticsTimeWindow'); var tw = twEl ? twEl.value : ''; @@ -866,13 +923,14 @@ var html; try { var data = await api('/analytics/retransmissions?' + qs.slice(1), { ttl: CLIENT_TTL.analyticsRF }); + if (!isCurrent()) return; html = renderRetransmissionChart(data); } catch (e) { html = ''; } // A newer request (window/region change) or a tab switch wins. var host = document.getElementById('rtxPressureChart'); - if (seq !== _rtxRequestSeq || !host) return; + if (!isCurrent() || seq !== _rtxRequestSeq || !host) return; host.innerHTML = html; } @@ -1315,6 +1373,7 @@ } function renderChannels(el, ch) { + const isCurrent = analyticsGuard(); // Decorate first so grouping/display name reflect locally-stored PSK keys. // buildHashKeyMap is async; render once with a sync best-effort empty map, // then upgrade once keys resolve. That keeps first paint fast and avoids @@ -1334,7 +1393,7 @@ // newer rawChannels / decoration with stale-key data. var myGen = ++_channelRenderGen; function applyDecorate(map) { - if (myGen !== _channelRenderGen) return; // superseded + if (!isCurrent() || myGen !== _channelRenderGen) return; // superseded var labels = (typeof ChannelDecrypt !== 'undefined' && ChannelDecrypt.getLabels) ? ChannelDecrypt.getLabels() : {}; _channelData = decorateAnalyticsChannels(rawChannels, map, labels); @@ -1544,6 +1603,7 @@ } function renderMultiByteAdopters(nodes, caps) { + const isCurrent = analyticsGuard(); // Merge capability status into adopter nodes var capByPubkey = {}; (caps || []).forEach(function(c) { capByPubkey[c.pubkey] = c; }); @@ -1619,6 +1679,7 @@ // Use setTimeout for event delegation on the stable section container setTimeout(function() { + if (!isCurrent()) return; var section = document.getElementById('mbAdoptersSection'); if (!section) return; var currentFilter = 'all'; @@ -2191,7 +2252,8 @@ Distant >${t200}: beyond 915MHz range — internet bridge, MQTT gateway, or separate networks
`; } - async function renderSubpaths(el) { + async function renderSubpaths(el) { + const isCurrent = analyticsGuard(el); el.innerHTML = '
Analyzing route patterns…
'; try { const rq = RegionFilter.regionQueryString(); @@ -2201,6 +2263,7 @@ const twVal = twEl ? twEl.value : ''; const tws = twVal ? '&window=' + encodeURIComponent(twVal) : ''; const bulk = await api('/analytics/subpaths-bulk?groups=2-2:50,3-3:30,4-4:20,5-8:15' + rq + tws, { ttl: CLIENT_TTL.analyticsRF }); + if (!isCurrent()) return; const [d2, d3, d4, d5] = bulk.results; function renderTable(data, title) { @@ -2289,13 +2352,14 @@ `; // Click handler for rows - el.addEventListener('click', e => { + el.onclick = e => { + if (!isCurrent()) return; const tr = e.target.closest('tr[data-hops]'); if (!tr) return; el.querySelectorAll('tr.subpath-selected').forEach(r => r.classList.remove('subpath-selected')); tr.classList.add('subpath-selected'); loadSubpathDetail(tr.dataset.hops); - }); + }; // Jump nav — scroll within list panel el.querySelectorAll('.subpath-jump-nav a').forEach(a => { @@ -2316,18 +2380,23 @@ toggle.addEventListener('change', applyCollisionFilter); applyCollisionFilter(); } catch (e) { + if (!isCurrent()) return; el.innerHTML = `
Error loading subpath data: ${e.message}
`; } } async function loadSubpathDetail(hopsStr) { const panel = document.getElementById('subpathDetail'); + if (!panel) return; + const isCurrent = analyticsGuard(panel); panel.classList.remove('collapsed'); panel.innerHTML = '
Loading…
'; try { const data = await api('/analytics/subpath-detail?hops=' + encodeURIComponent(hopsStr), { ttl: CLIENT_TTL.analyticsRF }); + if (!isCurrent()) return; renderSubpathDetail(panel, data); } catch (e) { + if (!isCurrent()) return; panel.innerHTML = `
Error: ${e.message}
`; } } @@ -2431,6 +2500,7 @@ } async function renderNodesTab(el) { + const isCurrent = analyticsGuard(el); el.innerHTML = '
Loading node analytics…
'; try { const rq = RegionFilter.regionQueryString() + AreaFilter.areaQueryString(); @@ -2438,6 +2508,7 @@ fetchAllNodes('&sortBy=lastSeen' + rq, { ttl: CLIENT_TTL.nodeList }), api('/nodes/bulk-health?limit=50' + rq, { ttl: CLIENT_TTL.analyticsRF }) ]); + if (!isCurrent()) return; const nodes = nodesResp.nodes || nodesResp; const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]'); const myKeys = new Set(myNodes.map(n => n.pubkey)); @@ -2587,6 +2658,7 @@ `; } catch (e) { + if (!isCurrent()) return; el.innerHTML = `
Failed to load node analytics: ${esc(e.message)}
`; } } @@ -2630,6 +2702,7 @@ } async function renderMyRepeatersTab(el) { + const isCurrent = analyticsGuard(el); const haveFavs = () => (typeof getFavorites === 'function' ? getFavorites() : []); if (!haveFavs().length) { el.innerHTML = _mrEmptyState(); return; } el.innerHTML = '
Loading your repeaters…
'; @@ -2650,6 +2723,7 @@ api('/nodes/clock-skew', { ttl: CLIENT_TTL.analyticsRF }).catch(() => []), api('/analytics/neighbor-graph?min_count=1&min_score=0', { ttl: CLIENT_TTL.analyticsRF }).catch(() => ({ nodes: [], edges: [] })), ]); + if (!isCurrent()) return; const allNodes = nodesResp.nodes || nodesResp; const clockByPk = {}; (Array.isArray(clockData) ? clockData : []).forEach(c => { if (c && c.pubkey) clockByPk[c.pubkey] = c; }); @@ -2687,6 +2761,7 @@ // un-star drops the row with no refetch. Re-binds its stars after each // paint (the innerHTML reset clears listeners). function paint() { + if (!isCurrent()) return; const favs = new Set(haveFavs()); if (!favs.size) { el.innerHTML = _mrEmptyState(); return; } const myNodes = allNodes.filter(n => favs.has(n.public_key) && (n.role === 'repeater' || n.role === 'room')); @@ -2807,6 +2882,7 @@ } paint(); } catch (e) { + if (!isCurrent()) return; el.innerHTML = `
Failed to load your repeaters: ${esc(e.message)}
`; } } @@ -2966,10 +3042,12 @@ } async function renderRepeaterMetricsTab(el) { + const isCurrent = analyticsGuard(el); el.innerHTML = '
Loading repeater metrics…
'; try { const rq = RegionFilter.regionQueryString() + AreaFilter.areaQueryString(); const resp = await fetchAllNodes('&sortBy=lastSeen' + rq, { ttl: CLIENT_TTL.nodeList }); + if (!isCurrent()) return; const nodes = resp.nodes || resp; const favs = new Set(typeof getFavorites === 'function' ? getFavorites() : []); const points = _toScatterPoints(nodes, favs); @@ -3047,11 +3125,13 @@ wireAxis(ySel, 'meshcore-repeater-scatter-y'); draw(); } catch (e) { + if (!isCurrent()) return; el.innerHTML = `
Failed to load repeater metrics: ${esc(e.message)}
`; } } async function renderDistanceTab(el) { + const isCurrent = analyticsGuard(el); // A new render supersedes any retry still pending from an earlier one, // so the two can never both write into the tab. _stopDistanceRetry(); @@ -3059,6 +3139,7 @@ const rqs = RegionFilter.regionQueryString(); const sep = rqs ? '?' + rqs.slice(1) : ''; const data = await api('/analytics/distance' + sep, { ttl: CLIENT_TTL.analyticsRF }); + if (!isCurrent()) return; // #1997: the lazy index (#1011) answers 202 {status:"building"} with no // summary until it has been built. Rendering that as data is what threw @@ -3069,12 +3150,9 @@ 'Building the distance index…' + '
This runs once per instance. Retrying in ' + secs + 's.
'; _distanceRetryTimer = setTimeout(function () { + if (!isCurrent()) return; _distanceRetryTimer = null; - // The user may have switched tabs or left while this was pending. - if (_currentTab !== 'distance') return; - const cur = document.getElementById('analyticsContent'); - if (!cur) return; - renderDistanceTab(cur); + renderDistanceTab(el); }, _distanceRetryDelayMs(data)); return; } @@ -3160,11 +3238,12 @@ }); }); } catch (e) { + if (!isCurrent()) return; el.innerHTML = `
Failed to load distance analytics: ${esc(e.message)}
`; } } -function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRetry(); _analyticsData = {}; _channelData = null; if (_ngState && _ngState.animId) { cancelAnimationFrame(_ngState.animId); } _ngState = null; if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; } } +function destroy() { _analyticsLoadId++; _analyticsLoadError = ''; _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRetry(); _analyticsData = null; _channelData = null; if (_ngState && _ngState.animId) { cancelAnimationFrame(_ngState.animId); } _ngState = null; if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; } } // Expose for testing if (typeof window !== 'undefined') { @@ -3191,6 +3270,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet let _ngState = null; // neighbor graph state async function renderNeighborGraphTab(el) { + const isCurrent = analyticsGuard(el); el.innerHTML = `

Neighbor Graph

@@ -3243,10 +3323,12 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet try { graphData = await api('/analytics/neighbor-graph' + sep + (sep ? '&' : '?') + 'min_count=1&min_score=0', { ttl: CLIENT_TTL.analyticsRF }); } catch (e) { + if (!isCurrent()) return; el.innerHTML = `

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

`; return; } + if (!isCurrent()) return; _ngState = createGraphState(graphData); // Render through the filter path so the first paint already respects the // default filters (observers unchecked, saved min-score) and the @@ -3270,7 +3352,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet // Debounce the O(N+E) refilter so dragging the slider doesn't rebuild the // node set on every pixel (review of #1758). clearTimeout(ngScoreDebounce); - ngScoreDebounce = setTimeout(applyNGFilters, 50); + ngScoreDebounce = setTimeout(() => { if (isCurrent()) applyNGFilters(); }, 50); }); document.getElementById('ngConfidence').addEventListener('change', applyNGFilters); rcEl.addEventListener('change', applyNGFilters); @@ -3522,6 +3604,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet function startGraphRenderer() { if (!_ngState) return; + const state = _ngState; // Re-entrant: the filter path calls this to restart against the new node // set, so cancel any loop already running before deciding what to do. @@ -3591,8 +3674,8 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet // Performance: 500 nodes brute-force repulsion: avg ~4ms/frame = 250fps headroom (measured Chrome 120, M1) var _perfFrameTimes = [], _perfLastTime = 0; function tick() { - if (_ngState._runEpoch !== myEpoch) { return; } // a newer startGraphRenderer run superseded this loop - if (!document.getElementById('ngCanvas')) { _ngState.animId = null; return; } + if (_ngState !== state || state._runEpoch !== myEpoch) return; + if (document.getElementById('ngCanvas') !== canvas) { state.animId = null; return; } var now = performance.now(); if (_perfLastTime) _perfFrameTimes.push(now - _perfLastTime); _perfLastTime = now; @@ -3708,6 +3791,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet // --- Prefix Tool --- async function renderPrefixTool(el) { + const isCurrent = analyticsGuard(el); el.innerHTML = '
Loading prefix data…
'; const rq = RegionFilter.regionQueryString() + AreaFilter.areaQueryString(); @@ -3723,10 +3807,12 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet api('/analytics/hash-sizes' + rq, { ttl: CLIENT_TTL.analyticsRF }).catch(() => null), ]); } catch (e) { + if (!isCurrent()) return; el.innerHTML = ``; return; } + if (!isCurrent()) return; // Deduplicate by public_key, require at least 6 hex chars to build all 3 tiers const nodeMap = new Map(); (nodesResp.nodes || nodesResp).forEach(n => { @@ -4179,10 +4265,10 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet // Auto-run from URL params if (initPrefix) { doCheck(initPrefix); - setTimeout(() => { document.getElementById('ptChecker')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 150); + setTimeout(() => { if (isCurrent()) document.getElementById('ptChecker')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 150); } else if (initGenerate) { doGenerate(); - setTimeout(() => { document.getElementById('ptGenerator')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 150); + setTimeout(() => { if (isCurrent()) document.getElementById('ptGenerator')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 150); } } @@ -4218,6 +4304,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet } async function renderRFHealthTab(el) { + const isCurrent = analyticsGuard(); // Restore state from URL const hashParams = new URLSearchParams((location.hash.split('?')[1] || '')); if (hashParams.get('range')) _rfHealthState.range = hashParams.get('range'); @@ -4255,8 +4342,11 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet // Range button handlers el.querySelectorAll('.rf-range-btn[data-range]').forEach(btn => { btn.addEventListener('click', () => { + if (!isCurrent()) return; const range = btn.dataset.range; _rfHealthState.range = range; + _analyticsRequests.delete(document.getElementById('rfHealthGrid')); + _analyticsRequests.delete(document.getElementById('rfHealthDetail')); el.querySelectorAll('.rf-range-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); const customInputs = el.querySelector('.rf-custom-inputs'); @@ -4271,6 +4361,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet const applyBtn = document.getElementById('rfCustomApply'); if (applyBtn) { applyBtn.addEventListener('click', () => { + if (!isCurrent()) return; _rfHealthState.customFrom = document.getElementById('rfFrom').value; _rfHealthState.customTo = document.getElementById('rfTo').value; rfHealthUpdateHash(); @@ -4284,12 +4375,17 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet async function loadRFHealthData(el) { const grid = document.getElementById('rfHealthGrid'); const detail = document.getElementById('rfHealthDetail'); + const isCurrent = analyticsGuard(grid); + // A range change invalidates any detail already in flight, even while + // its new summary is still loading or no longer contains that observer. + if (detail) _analyticsRequests.delete(detail); try { // Compute window string for summary endpoint const windowMap = { '1h':'1h', '3h':'3h', '6h':'6h', '12h':'12h', '24h':'24h', '3d':'3d', '7d':'7d', '30d':'30d' }; const window = windowMap[_rfHealthState.range] || '24h'; const summaryData = await api('/observers/metrics/summary?window=' + window + (RegionFilter.regionQueryString() || '')); + if (!isCurrent()) return; const observers = summaryData.observers || []; // Filter to observers with sufficient sparkline data (≥2 non-null noise_floor values) @@ -4337,6 +4433,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet // Click handler for cells grid.querySelectorAll('.rf-cell').forEach(cell => { cell.addEventListener('click', () => { + if (!isCurrent()) return; const obsId = cell.dataset.observer; grid.querySelectorAll('.rf-cell').forEach(c => c.classList.remove('rf-cell-selected')); cell.classList.add('rf-cell-selected'); @@ -4367,17 +4464,21 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet } } } catch (e) { + if (!isCurrent()) return; grid.innerHTML = `
Failed to load RF health data: ${esc(e.message)}
`; } } async function loadRFSparkline(observerId) { + const container = document.getElementById(`rf-spark-${observerId}`); + if (!container) return; + const isCurrent = analyticsGuard(container); const { since, until } = rfHealthTimeRangeToParams(_rfHealthState.range, _rfHealthState.customFrom, _rfHealthState.customTo); try { const data = await api(`/observers/${observerId}/metrics?since=${encodeURIComponent(since)}&until=${encodeURIComponent(until)}`); + if (!isCurrent()) return; const metrics = data.metrics || []; const nfValues = metrics.map(m => m.noise_floor).filter(v => v != null); - const container = document.getElementById(`rf-spark-${observerId}`); if (container && nfValues.length > 1) { container.innerHTML = rfNFSparkline(nfValues, 140, 24); } else if (container) { @@ -4412,6 +4513,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet } async function loadRFHealthDetail(observerId, container) { + const isCurrent = analyticsGuard(container); container.classList.remove('rf-panel-empty'); container.innerHTML = '
Loading detail…
'; @@ -4423,6 +4525,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet try { const data = await api(`/observers/${observerId}/metrics?since=${encodeURIComponent(since)}&until=${encodeURIComponent(until)}&resolution=${resolution}`); + if (!isCurrent()) return; const metrics = data.metrics || []; const reboots = (data.reboots || []).map(r => new Date(r).getTime()); const name = data.observer_name || observerId.substring(0, 8); @@ -4472,6 +4575,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet // Close button container.querySelector('.rf-detail-close').addEventListener('click', () => { + _analyticsRequests.delete(container); container.classList.add('rf-panel-empty'); container.innerHTML = 'Select an observer to view details'; _rfHealthState.selectedObserver = null; @@ -4516,6 +4620,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet } } } catch (e) { + if (!isCurrent()) return; container.innerHTML = `
Failed to load detail: ${esc(e.message)}
`; } } @@ -4845,10 +4950,12 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet // #690 — Clock Health fleet view (M3) async function renderClockHealthTab(el) { + const isCurrent = analyticsGuard(el); el.innerHTML = '
Loading clock health data…
'; try { const aqs = AreaFilter.areaQueryString(); var data = await (await fetch('/api/nodes/clock-skew' + (aqs ? '?' + aqs.slice(1) : ''))).json(); + if (!isCurrent()) return; if (!Array.isArray(data) || !data.length) { el.innerHTML = '
No clock skew data available. Nodes need recent adverts for clock analysis.
'; return; @@ -4947,6 +5054,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet render(); } catch (err) { + if (!isCurrent()) return; el.innerHTML = '
Failed to load clock health data: ' + esc(String(err)) + '
'; } } @@ -4980,6 +5088,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet } async function renderScopesTab(el) { + const isCurrent = analyticsGuard(); var winKey = 'scopes_window'; var selectedWindow = (typeof sessionStorage !== 'undefined' && sessionStorage.getItem(winKey)) || '24h'; @@ -5004,28 +5113,34 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet '
' + '
'; - // Attach window-button click listeners (once) - el.querySelectorAll('[data-win]').forEach(function(btn) { - btn.addEventListener('click', function() { - selectedWindow = btn.dataset.win; - if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(winKey, selectedWindow); - el.querySelectorAll('[data-win]').forEach(function(b) { b.classList.toggle('active', b.dataset.win === selectedWindow); }); - load(selectedWindow); - }); - }); } + // The frame survives theme/filter refreshes; replace its handlers so + // window picks and the refresh timer share the current render's state. + el.querySelectorAll('[data-win]').forEach(function(btn) { + btn.onclick = function() { + if (!isCurrent()) return; + selectedWindow = btn.dataset.win; + if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(winKey, selectedWindow); + el.querySelectorAll('[data-win]').forEach(function(b) { b.classList.toggle('active', b.dataset.win === selectedWindow); }); + load(selectedWindow); + }; + }); + function pct(n, total) { if (!total) return '—'; return (n / total * 100).toFixed(1) + '%'; } async function load(w) { + if (!isCurrent()) return; + const requestCurrent = analyticsGuard(document.getElementById('scopes-cards')); var loadingEl = document.getElementById('scopes-loading'); if (loadingEl) loadingEl.style.display = ''; try { // Fix 4: use api() instead of raw fetch() var data = await api('/scope-stats?window=' + encodeURIComponent(w), { ttl: 30000 }); + if (!requestCurrent()) return; if (loadingEl) loadingEl.style.display = 'none'; if (data.error) { var cardsEl2 = document.getElementById('scopes-cards'); @@ -5034,6 +5149,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet } updateData(data, w); } catch (err) { + if (!requestCurrent()) return; if (loadingEl) loadingEl.style.display = 'none'; var cardsEl3 = document.getElementById('scopes-cards'); if (cardsEl3) cardsEl3.innerHTML = '
Failed to load scope stats: ' + esc(String(err)) + '
'; @@ -5141,9 +5257,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet // Fix 6: auto-refresh every 60s _stopScopesRefresh(); _scopesRefreshTimer = setInterval(function() { - if (_currentTab !== 'scopes') { _stopScopesRefresh(); return; } - var cur = document.getElementById('analyticsContent'); - if (!cur) { _stopScopesRefresh(); return; } + if (!isCurrent()) return; load(selectedWindow); }, 60000); } @@ -5153,22 +5267,23 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet // Auto-refreshes every 60s while the Roles tab is active (matches the // behavior of the former standalone roles-page.js). async function renderRolesTab(el) { + const isCurrent = analyticsGuard(); el.innerHTML = '
Loading roles…
'; await _renderRolesTabBody(el); + if (!isCurrent()) return; // (Re)start the 60s auto-refresh. _stopRolesRefresh(); _rolesRefreshTimer = setInterval(function () { - // Bail if the user navigated away from the Roles tab. - if (_currentTab !== 'roles') { _stopRolesRefresh(); return; } - var cur = document.getElementById('analyticsContent'); - if (!cur) { _stopRolesRefresh(); return; } - _renderRolesTabBody(cur); + if (!isCurrent()) return; + _renderRolesTabBody(el); }, 60000); } async function _renderRolesTabBody(el) { + const isCurrent = analyticsGuard(el); try { var data = await api('/analytics/roles', { ttl: CLIENT_TTL.analyticsRF }); + if (!isCurrent()) return; var roles = (data && data.roles) || []; var total = (data && data.totalNodes) || 0; if (!roles.length) { @@ -5220,6 +5335,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet '' + rows + '' + ''; } catch (err) { + if (!isCurrent()) return; el.innerHTML = '
Failed to load roles: ' + esc(String(err.message || err)) + '
'; } } diff --git a/public/app.js b/public/app.js index 7a04414f0..a68060653 100644 --- a/public/app.js +++ b/public/app.js @@ -203,7 +203,9 @@ async function api(path, { ttl = 0, bust = false } = {}) { } })(); _inflight.set(path, promise); - promise.finally(() => _inflight.delete(path)); + // Handle both outcomes so cleanup does not create an unobserved rejection. + const cleanup = () => _inflight.delete(path); + promise.then(cleanup, cleanup); return promise; } diff --git a/scripts/non-unit-tests.json b/scripts/non-unit-tests.json index 0a1c87d1b..9b4594a84 100644 --- a/scripts/non-unit-tests.json +++ b/scripts/non-unit-tests.json @@ -5,6 +5,7 @@ "tests/e2e/test-1110-live-filter.js", "tests/e2e/test-a11y-axe-1668.js", "tests/e2e/test-analytics-fluid-charts.js", + "tests/e2e/test-analytics-loading-e2e.js", "tests/e2e/test-audio-lab-1297-e2e.js", "tests/e2e/test-audio-live-1297-e2e.js", "tests/e2e/test-bottom-nav-1061-e2e.js", diff --git a/test-all.sh b/test-all.sh index d765d4225..83a67df29 100755 --- a/test-all.sh +++ b/test-all.sh @@ -18,7 +18,9 @@ node tests/unit/test-a11y-axe-routes-coverage.js node tests/unit/test-admin-dashboard-ui.js node tests/unit/test-aging.js node tests/unit/test-analytics-channels-integration.js +node tests/unit/test-analytics-loading.js node tests/unit/test-anl1-tooltip-render.js +node tests/unit/test-api-inflight-cleanup.js node tests/unit/test-area-filter.js node tests/unit/test-canadaverse-branding.js node tests/unit/test-channel-color-picker.js diff --git a/tests/e2e/test-analytics-loading-e2e.js b/tests/e2e/test-analytics-loading-e2e.js new file mode 100644 index 000000000..57689648f --- /dev/null +++ b/tests/e2e/test-analytics-loading-e2e.js @@ -0,0 +1,204 @@ +#!/usr/bin/env node +/* Local browser regression: delayed analytics, theme refreshes and filter races. */ +'use strict'; +const assert = require('assert'); +const { chromium } = require('playwright'); +const BASE = process.env.BASE_URL || 'http://localhost:13581'; +assert.ok(['localhost', '127.0.0.1', '[::1]'].includes(new URL(BASE).hostname), 'Use a local fixture server, never production'); + +function deferred() { + let resolve; + const promise = new Promise(r => { resolve = r; }); + return { promise, resolve }; +} + +const FIXTURES = { + 'hash-sizes': { total: 0, distribution: {}, hourly: [], topHops: [], multiByteNodes: [] }, + topology: { uniqueNodes: 4, avgHops: 1, maxHops: 1, hopDistribution: [] }, + channels: { activeChannels: 0, decryptable: 0 }, + 'hash-collisions': {}, + 'relay-airtime-share': { rows: [] }, + 'subpaths-bulk': { results: Array.from({ length: 4 }, () => ({ subpaths: [] })) }, + 'neighbor-graph': { + nodes: [ + { pubkey: 'aa'.repeat(32), role: 'companion', neighbor_count: 1 }, + { pubkey: 'bb'.repeat(32), role: 'companion', neighbor_count: 1 }, + { pubkey: 'cc'.repeat(32), role: 'repeater', neighbor_count: 1 }, + { pubkey: 'dd'.repeat(32), role: 'repeater', neighbor_count: 1 }, + ], + edges: [ + { source: 'aa'.repeat(32), target: 'bb'.repeat(32), score: 0.9, ambiguous: false }, + { source: 'cc'.repeat(32), target: 'dd'.repeat(32), score: 0.9, ambiguous: false }, + ], + stats: {}, + }, +}; + +(async () => { + const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROMIUM_PATH || undefined }); + try { + const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + page.setDefaultTimeout(15000); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + const gates = new Map(['', '1h', '24h', '7d'].map(window => [window, { response: deferred(), arrived: deferred() }])); + const lazyGates = new Map(['E2E-OLD', 'E2E-NEW', 'E2E-LATE', 'E2E-ERROR', 'E2E-FAIL', 'E2E-LEFT'].map(region => [region, { response: deferred(), arrived: deferred() }])); + await page.route('**/api/analytics/*', async route => { + const url = new URL(route.request().url()); + const endpoint = url.pathname.split('/').pop(); + let body = FIXTURES[endpoint]; + if (endpoint === 'rf') { + const gate = gates.get(url.searchParams.get('window') || ''); + gate.arrived.resolve(url.pathname.slice('/api'.length) + url.search); + const total = await gate.response.promise; + body = { + totalTransmissions: total, totalPackets: total, packetsPerHour: [], payloadTypes: [], + snr: { avg: 0, min: 0, max: 0 }, rssi: { avg: -100, min: -100, max: -100 }, + avgPacketSize: 20, minPacketSize: 20, maxPacketSize: 20, timeSpanHours: 1, + }; + } + if (body === undefined) return route.continue(); + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); + }); + // Use synthetic nodes and bulk health; hold the lazy response independently + // of the six core requests so these exercise the actual renderer race. + await page.route('**/api/nodes**', async route => { + const url = new URL(route.request().url()); + const region = url.searchParams.get('region'); + const gate = lazyGates.get(region); + if (!gate || !['/api/nodes', '/api/nodes/bulk-health'].includes(url.pathname)) return route.continue(); + const publicKey = 'ab'.repeat(32); + let status = 200; + let body = { nodes: [{ public_key: publicKey, name: region + ' synthetic node', role: 'repeater', last_seen: new Date().toISOString() }], has_more: false, counts: { repeater: 1 }, total: 1 }; + if (url.pathname.endsWith('/bulk-health')) { + gate.arrived.resolve(url.pathname.slice('/api'.length) + url.search); + status = await gate.response.promise; + body = [{ public_key: publicKey, stats: { totalPackets: 22, packetsToday: 2, avgSnr: 3 }, observers: [] }]; + } + await route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }); + }); + + async function release(window, total) { + const gate = gates.get(window); + const requestPath = await gate.arrived.promise; + gate.response.resolve(total); + // Wait until the real api() has consumed the response, not just until + // the server has sent it; then its Promise.all continuation has run. + await page.waitForFunction(path => _apiPerf.log.some(entry => entry.path === path), requestPath); + } + async function theme() { + await page.evaluate(() => window.dispatchEvent(new CustomEvent('theme-refresh'))); + } + async function selectWindow(window) { + const request = page.waitForRequest(req => { + const url = new URL(req.url()); + return url.pathname === '/api/analytics/rf' && url.searchParams.get('window') === window; + }); + await page.locator('#analyticsTimeWindow').selectOption(window); + await request; + } + async function selectRegion(region, window) { + await page.evaluate(region => RegionFilter.setSelected([region]), region); + await selectWindow(window); + } + async function releaseLazy(region, status = 200) { + const gate = lazyGates.get(region); + const requestPath = await gate.arrived.promise; + gate.response.resolve(status); + // Errors do not enter _apiPerf.log. Wait for the real api() cleanup for + // both success and rejection, then inspect the renderer's settled view. + await page.waitForFunction(path => !_inflight.has(path), requestPath); + } + + await page.goto(BASE + '/#/analytics', { waitUntil: 'domcontentloaded' }); + await page.locator('#analyticsTimeWindow').waitFor(); + await theme(); + await page.locator('#analyticsTabs [data-tab="hashsizes"]').click(); + await theme(); + assert.match(await page.locator('#analyticsContent').innerText(), /Loading analytics/); + assert.deepStrictEqual(errors, []); + console.log(' ✓ pending core tabs and theme refresh retain the loading state'); + + await page.locator('#analyticsTabs [data-tab="neighbor-graph"]').click(); + const companion = page.locator('#ngRoleChecks input[data-role="companion"]'); + await page.waitForFunction(() => document.querySelector('#ngStats .stat-value')?.textContent === '4'); + await companion.uncheck(); + const canvas = await page.locator('#ngCanvas').elementHandle(); + await release('', 111); + await theme(); + assert.strictEqual(await companion.isChecked(), false, 'bulk completion and theme refresh must preserve graph filters'); + assert.strictEqual(await canvas.evaluate(el => el.isConnected), true, 'the graph DOM must not be rebuilt'); + console.log(' ✓ a lazy graph retains its filters and canvas after core data and theme refresh'); + + await page.locator('#analyticsTabs [data-tab="overview"]').click(); + assert.strictEqual(await page.locator('#analyticsContent .stat-value').first().innerText(), '111'); + await selectWindow('1h'); + await theme(); + assert.match(await page.locator('#analyticsContent').innerText(), /Loading analytics/); + await selectWindow('24h'); + await release('24h', 222); + await page.waitForFunction(() => document.querySelector('#analyticsContent .stat-value')?.textContent === '222'); + await release('1h', 999); + await theme(); + assert.strictEqual(await page.locator('#analyticsContent .stat-value').first().innerText(), '222'); + assert.strictEqual(await page.locator('#analyticsTimeWindow').inputValue(), '24h'); + console.log(' ✓ the newest time window wins when responses complete out of order'); + + await selectWindow('7d'); + await page.evaluate(() => { location.hash = '#/home'; }); + await page.locator('#analyticsContent').waitFor({ state: 'detached' }); + await release('7d', 333); + assert.strictEqual(await page.locator('#analyticsContent').count(), 0); + assert.deepStrictEqual(errors, [], 'analytics must not throw during loading, refresh or page leave'); + console.log(' ✓ late completion after page leave is harmless'); + + await page.evaluate(() => { + RegionFilter.setSelected(['E2E-OLD']); + location.hash = '#/analytics?tab=nodes'; + }); + await lazyGates.get('E2E-OLD').arrived.promise; + assert.match(await page.locator('#analyticsContent').innerText(), /Loading node analytics/); + await selectRegion('E2E-NEW', '24h'); + await releaseLazy('E2E-NEW'); + await page.getByRole('link', { name: 'E2E-NEW synthetic node', exact: true }).first().waitFor(); + await releaseLazy('E2E-OLD'); + assert.match(await page.locator('#analyticsContent').innerText(), /E2E-NEW synthetic node/); + assert.doesNotMatch(await page.locator('#analyticsContent').innerText(), /E2E-OLD synthetic node/); + console.log(' ✓ lazy Nodes keeps the newest filter when the old response arrives last'); + + for (const [region, status] of [['E2E-LATE', 200], ['E2E-ERROR', 500]]) { + await page.evaluate(region => RegionFilter.setSelected([region]), region); + await page.locator('#analyticsTabs [data-tab="nodes"]').click(); + await lazyGates.get(region).arrived.promise; + await page.locator('#analyticsTabs [data-tab="subpaths"]').click(); + await page.getByRole('heading', { name: 'Route Pattern Analysis' }).waitFor(); + await releaseLazy(region, status); + assert.match(await page.locator('#analyticsContent').innerText(), /Route Pattern Analysis/); + assert.doesNotMatch(await page.locator('#analyticsContent').innerText(), /Failed to load node|synthetic node/); + } + console.log(' ✓ late lazy success and HTTP 500 failure preserve the newly selected tab'); + + await page.evaluate(() => RegionFilter.setSelected(['E2E-FAIL'])); + await page.locator('#analyticsTabs [data-tab="nodes"]').click(); + await releaseLazy('E2E-FAIL', 500); + await page.getByText(/Failed to load node analytics: API 500/).waitFor(); + assert.deepStrictEqual(errors, [], 'handled lazy HTTP failures must not become unhandled rejections'); + lazyGates.set('E2E-FAIL', { response: deferred(), arrived: deferred() }); + await theme(); + await releaseLazy('E2E-FAIL'); + await page.getByRole('link', { name: 'E2E-FAIL synthetic node', exact: true }).first().waitFor(); + console.log(' ✓ a current lazy failure is visible and a subsequent refresh recovers'); + + await selectRegion('E2E-LEFT', '1h'); + await lazyGates.get('E2E-LEFT').arrived.promise; + await page.evaluate(() => { location.hash = '#/home'; }); + await page.locator('#analyticsContent').waitFor({ state: 'detached' }); + await releaseLazy('E2E-LEFT'); + assert.strictEqual(await page.locator('#analyticsContent').count(), 0); + assert.deepStrictEqual(errors, [], 'lazy completion after destroy must not throw or restart rendering'); + console.log(' ✓ a delayed lazy response after page leave is harmless'); + console.log('Analytics loading browser regression: 8 passed'); + } finally { + await browser.close(); + } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/unit/test-analytics-loading.js b/tests/unit/test-analytics-loading.js new file mode 100644 index 000000000..b671b782a --- /dev/null +++ b/tests/unit/test-analytics-loading.js @@ -0,0 +1,471 @@ +/* Exercise the real analytics page lifecycle with controllable API promises. */ +'use strict'; +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const source = fs.readFileSync(path.join(__dirname, '../../public/analytics.js'), 'utf8'); +const settle = () => new Promise(resolve => setImmediate(resolve)); +const unhandled = []; +process.on('unhandledRejection', error => unhandled.push(error.message)); + +function fixture(url, total) { + switch (url.split('?')[0]) { + case '/analytics/rf': return { totalTransmissions: total, totalPackets: total, packetsPerHour: [], payloadTypes: [], snr: {}, rssi: {} }; + case '/analytics/topology': return { uniqueNodes: 0, hopDistribution: [] }; + case '/analytics/hash-sizes': return { total: 0, distribution: {}, hourly: [], topHops: [], multiByteNodes: [] }; + case '/analytics/channels': return { activeChannels: 0, decryptable: 0 }; + case '/nodes': return { nodes: [], total }; + case '/nodes/bulk-health': + case '/api/nodes/clock-skew': + case '/nodes/clock-skew': return []; + case '/analytics/subpaths-bulk': return { results: Array.from({ length: 4 }, () => ({ subpaths: [] })) }; + case '/analytics/neighbor-graph': return { nodes: [], edges: [], stats: {} }; + case '/analytics/distance': return { status: 'building', retry_after_seconds: 5 }; + case '/observers/metrics/summary': return { observers: [] }; + case '/analytics/roles': return { roles: [], totalNodes: 0 }; + case '/scope-stats': return { summary: { transportTotal: total, scoped: 0, unscoped: total, unknownScope: 0 } }; + default: return { rows: [] }; + } +} + +async function harness(initialTab = '') { + let page; + const requests = [], events = new Map(), timers = new Map(), frames = []; + let timerId = 0; + function element() { + let html = ''; + return { + get innerHTML() { return html; }, + set innerHTML(value) { html = value; this.writes++; }, + writes: 0, value: '', style: {}, listeners: {}, + classList: { add() {}, remove() {}, toggle() {}, contains: () => false }, + addEventListener(name, fn) { this.listeners[name] = fn; }, + setAttribute() {}, + querySelectorAll() { return []; }, + querySelector(selector) { return elements[selector.slice(1)] || null; }, + getContext() { return { scale() {} }; }, + }; + } + const elements = Object.fromEntries(['analyticsContent', 'analyticsTabs', 'analyticsTimeWindow', 'analyticsRegionFilter', 'analyticsAreaFilter'].map(id => [id, element()])); + elements.analyticsTabs.querySelector = () => ({ classList: { add() {} } }); + function filter() { + return { init() {}, onChange(fn) { this.change = fn; }, regionQueryString: () => '', areaQueryString: () => '' }; + } + const sandbox = { + window: null, console, URLSearchParams, + document: { getElementById: id => elements[id] || null, querySelectorAll: () => [], documentElement: {} }, + location: { hash: '#/analytics' + (initialTab ? '?tab=' + initialTab : '') }, + history: { replaceState() {} }, + localStorage: { getItem: () => null, setItem() {} }, + sessionStorage: { getItem: () => null, setItem() {} }, + RegionFilter: filter(), AreaFilter: filter(), + CLIENT_TTL: { analyticsRF: 1000 }, + initTabBar() {}, + registerPage(name, handlers) { assert.strictEqual(name, 'analytics'); page = handlers; }, + addEventListener(name, fn) { events.set(name, fn); }, + removeEventListener(name) { events.delete(name); }, + requestAnimationFrame(fn) { frames.push(fn); return frames.length; }, cancelAnimationFrame() {}, + setInterval(fn) { timers.set(++timerId, fn); return timerId; }, + setTimeout(fn) { const id = ++timerId; timers.set(id, () => { timers.delete(id); fn(); }); return id; }, + clearInterval(id) { timers.delete(id); }, clearTimeout(id) { timers.delete(id); }, + getComputedStyle: () => ({ getPropertyValue: () => '' }), + getFavorites: () => [], + api(url) { + return new Promise((resolve, reject) => requests.push({ url, resolve, reject })); + }, + }; + sandbox.fetchAllNodes = query => sandbox.api('/nodes?' + query); + sandbox.fetch = url => sandbox.api(url).then(data => ({ ok: true, json: async () => data })); + sandbox.window = sandbox; + vm.runInNewContext(source, sandbox, { filename: 'analytics.js' }); + async function init() { + elements.analyticsContent = element(); + elements.analyticsContent.innerHTML = 'Loading analytics…'; + await page.init(element()); + } + await init(); + return { + page, elements, requests, init, timers, frames, + element, + add(...ids) { ids.forEach(id => { elements[id] = element(); }); }, + favorites() { sandbox.getFavorites = () => ['aa'.repeat(32)]; }, + html: () => elements.analyticsContent.innerHTML, + theme: () => events.get('theme-refresh')?.(), + tab(tab) { + const button = { dataset: { tab }, classList: { add() {} } }; + elements.analyticsTabs.listeners.click({ target: { closest: () => button } }); + }, + window(value) { + elements.analyticsTimeWindow.value = value; + elements.analyticsTimeWindow.listeners.change(); + }, + refresh: () => sandbox.RegionFilter.change(), + async resolve(batch, total) { + requests.slice(batch * 6, batch * 6 + 6).forEach(r => r.resolve(fixture(r.url, total))); + await settle(); + }, + async reject(batch, message) { + requests[batch * 6].reject(new Error(message)); + await settle(); + }, + }; +} + +let passed = 0, failed = 0; +async function test(name, fn) { + const before = unhandled.length; + try { + try { await fn(); } finally { await settle(); } + assert.deepStrictEqual(unhandled.slice(before), [], 'page lifecycle must not reject in the background'); + passed++; + console.log(' ✓ ' + name); + } catch (error) { + failed++; + console.error(' ✗ ' + name + ': ' + error.message.split('\n')[0]); + } +} + +(async () => { + await test('theme refresh and every core tab wait for complete analytics', async () => { + const h = await harness(); + h.theme(); + for (const tab of ['overview', 'rf', 'topology', 'channels', 'hashsizes', 'collisions']) { + h.tab(tab); + h.theme(); + assert.match(h.html(), /Loading analytics/); + } + assert.strictEqual(h.requests.length, 6, 'refreshes must not issue extra requests'); + }); + + await test('ready data still renders and tabs and theme refresh keep working', async () => { + const h = await harness(); + await h.resolve(0, 111); + assert.match(h.html(), /stat-value">111111 { + const h = await harness(); + h.window('1h'); + assert.match(h.requests[7].url, /window=1h/); + await h.resolve(1, 222); + assert.match(h.html(), /stat-value">222222 { + const h = await harness(); + h.refresh(); + await h.resolve(1, 222); + await h.reject(0, 'stale failure'); + assert.match(h.html(), /stat-value">222222 { + const h = await harness(); + await h.reject(0, ''); + for (const tab of ['overview', 'hashsizes']) { + h.tab(tab); + h.theme(); + assert.match(h.html(), /Failed to load: <img/); + assert.doesNotMatch(h.html(), /333 { + const h = await harness(); + await h.resolve(0, 111); + h.window('7d'); + h.theme(); + assert.match(h.html(), /Loading analytics/); + assert.doesNotMatch(h.html(), /stat-value">111 { + const h = await harness(); + h.tab('my-repeaters'); + assert.doesNotMatch(h.html(), /Loading analytics/); + assert.match(h.html(), /repeater/i); + h.tab('overview'); + assert.match(h.html(), /Loading analytics/); + }); + + await test('deep-linked lazy tabs do not wait for the core requests', async () => { + const h = await harness('my-repeaters'); + assert.match(h.html(), /repeater/i); + assert.doesNotMatch(h.html(), /Loading analytics/); + assert.strictEqual(h.requests.length, 6); + }); + + await test('core success or failure cannot rebuild an interactive lazy tab', async () => { + for (const reject of [false, true]) { + const h = await harness(); + h.tab('my-repeaters'); + const writes = h.elements.analyticsContent.writes; + if (reject) await h.reject(0, 'core failure'); + else await h.resolve(0, 111); + assert.strictEqual(h.elements.analyticsContent.writes, writes); + h.tab('overview'); + assert.match(h.html(), reject ? /Failed to load: core failure/ : /stat-value">111 { + for (const reject of [false, true]) { + const h = await harness(); + h.page.destroy(); + delete h.elements.analyticsContent; + if (reject) await h.reject(0, 'left page'); + else await h.resolve(0, 111); + } + }); + + await test('old results cannot overwrite a new analytics visit', async () => { + const h = await harness(); + h.page.destroy(); + await h.init(); + await h.resolve(0, 111); + assert.match(h.html(), /Loading analytics/); + await h.resolve(1, 222); + assert.match(h.html(), /stat-value">222 reject ? r.reject(new Error('obsolete response')) : r.resolve(fixture(r.url, total))); + await settle(); + } + + for (const tab of lazyTabs) { + for (const reject of [false, true]) { + await test(`${tab}: stale ${reject ? 'failure' : 'success'} cannot write or restart work after a tab switch`, async () => { + const h = await harness(); + addLazyElements(h); + const start = h.requests.length; + h.tab(tab); + const pending = h.requests.slice(start); + assert.ok(pending.length, 'exercise the real lazy request'); + h.tab('overview'); + const writes = Object.values(h.elements).reduce((n, el) => n + el.writes, 0); + const frames = h.frames.length; + await complete(pending, reject); + assert.match(h.html(), /Loading analytics/); + assert.strictEqual(Object.values(h.elements).reduce((n, el) => n + el.writes, 0), writes, 'stale result must not mutate any view'); + assert.strictEqual(h.frames.length, frames, 'stale completion must not schedule layout or graph work'); + assert.strictEqual(h.timers.size, 0, 'stale completion must not restart retries or refresh timers'); + }); + } + } + + await test('Nodes uses the newest filter response and ignores an older failure', async () => { + for (const reject of [false, true]) { + const h = await harness(); + h.tab('nodes'); + const old = h.requests.slice(6); + const next = h.requests.length; + h.refresh(); + await complete(h.requests.slice(next, next + 2), false, 222); + assert.match(h.html(), />222222111 { + for (const tab of lazyTabs) { + const h = await harness(); + addLazyElements(h); + h.tab(tab); + const pending = h.requests.slice(6); + const previous = h.elements.analyticsContent; + h.page.destroy(); + const writes = previous.writes; + await h.init(); + const currentWrites = h.elements.analyticsContent.writes; + await complete(pending); + assert.strictEqual(previous.writes, writes, tab + ' must not write into the detached page'); + assert.strictEqual(h.elements.analyticsContent.writes, currentWrites, tab + ' must not write into the new page'); + } + }); + + await test('current lazy responses render normally and distance retries only while current', async () => { + for (const tab of lazyTabs) { + const h = await harness(); + addLazyElements(h); + h.tab(tab); + await complete(h.requests.slice(6)); + assert.doesNotMatch(h.html(), /Failed to load|Error loading|Loading node|Loading roles/); + if (tab === 'distance') { + assert.match(h.html(), /Building the distance index/); + const retry = [...h.timers.values()][0]; + const before = h.requests.length; + retry(); + assert.strictEqual(h.requests.length, before + 1); + h.tab('overview'); + retry(); + assert.strictEqual(h.requests.length, before + 1, 'a queued stale retry cannot fetch again'); + await complete(h.requests.slice(before)); + assert.strictEqual(h.timers.size, 0); + } + } + }); + + await test('Roles refreshes are latest-wins and obsolete timer callbacks cannot restart work', async () => { + const h = await harness('roles'); + await complete(h.requests.slice(0, 1)); + const refresh = [...h.timers.values()][0]; + assert.ok(refresh, 'current response starts the refresh timer'); + refresh(); + const old = h.requests.at(-1); + refresh(); + const current = h.requests.at(-1); + current.resolve({ totalNodes: 222, roles: [{ role: 'repeater', nodeCount: 222 }] }); + await settle(); + old.resolve({ totalNodes: 111, roles: [{ role: 'repeater', nodeCount: 111 }] }); + await settle(); + assert.match(h.html(), /222<\/strong>/); + h.tab('overview'); + const before = h.requests.length; + refresh(); + assert.strictEqual(h.requests.length, before); + assert.strictEqual(h.timers.size, 0); + }); + + await test('subpath detail selections are latest-wins and stop after a filter change', async () => { + const h = await harness(); + addLazyElements(h); + h.tab('subpaths'); + await complete(h.requests.slice(6)); + function select(hops) { + const row = h.element(); + row.dataset = { hops }; + h.elements.analyticsContent.onclick({ target: { closest: () => row } }); + return h.requests.at(-1); + } + function detail(count) { + return { nodes: [], hops: ['aa', 'bb'], totalMatches: count, hourDistribution: [], signal: {}, observers: [], parentPaths: [] }; + } + const old = select('aa,bb'), current = select('cc,dd'); + current.resolve(detail(222)); + await settle(); + old.reject(new Error('obsolete detail')); + await settle(); + assert.match(h.elements.subpathDetail.innerHTML, /222 occurrences/); + const pending = select('ee,ff'); + h.refresh(); + const writes = h.elements.subpathDetail.writes; + pending.resolve(detail(111)); + await settle(); + assert.strictEqual(h.elements.subpathDetail.writes, writes); + }); + + await test('RF range and observer selections reject stale successes and failures', async () => { + const h = await harness(); + addLazyElements(h); + const range = h.element(); + range.dataset = { range: '1h' }; + h.elements.analyticsContent.querySelectorAll = selector => selector.includes('data-range') ? [range] : []; + const cells = ['observer-a', 'observer-b'].map(id => Object.assign(h.element(), { dataset: { observer: id } })); + h.elements.rfHealthGrid.querySelectorAll = () => cells; + const summary = { observers: cells.map(c => ({ observer_id: c.dataset.observer, sparkline: [-110, -111], sample_count: 2 })) }; + h.tab('rf-health'); + const old = h.requests.at(-1); + range.listeners.click(); + const current = h.requests.at(-1); + current.resolve(summary); + await settle(); + const gridWrites = h.elements.rfHealthGrid.writes; + old.reject(new Error('obsolete summary')); + await settle(); + assert.strictEqual(h.elements.rfHealthGrid.writes, gridWrites); + cells[0].listeners.click(); + const firstDetail = h.requests.at(-1); + cells[1].listeners.click(); + const secondDetail = h.requests.at(-1); + secondDetail.resolve({ observer_name: 'Newest observer', metrics: [] }); + await settle(); + firstDetail.resolve({ observer_name: 'Old observer', metrics: [] }); + await settle(); + assert.match(h.elements.rfHealthDetail.innerHTML, /Newest observer/); + cells[0].listeners.click(); + const pending = h.requests.at(-1); + range.dataset.range = 'custom'; + range.listeners.click(); + const detailWrites = h.elements.rfHealthDetail.writes; + pending.reject(new Error('obsolete range detail')); + await settle(); + assert.strictEqual(h.elements.rfHealthDetail.writes, detailWrites); + }); + + await test('Scopes keeps current window handlers after refresh and ignores overlapping requests', async () => { + const h = await harness(); + addLazyElements(h); + const button = Object.assign(h.element(), { dataset: { win: '1h' } }); + h.elements.analyticsContent.querySelectorAll = selector => selector === '[data-win]' ? [button] : []; + h.tab('scopes'); + const old = h.requests.at(-1); + h.theme(); + button.onclick(); + const current = h.requests.at(-1); + assert.match(current.url, /window=1h/); + current.resolve(fixture(current.url, 222)); + await settle(); + old.reject(new Error('obsolete scopes')); + await settle(); + assert.match(h.elements['scopes-cards'].innerHTML, />222 { + const h = await harness(); + addLazyElements(h); + h.tab('neighbor-graph'); + await complete(h.requests.slice(6)); + const slider = h.elements.ngMinScore; + slider.value = '80'; + slider.listeners.input.call(slider); + const debounce = [...h.timers.values()][0]; + const frames = h.frames.slice(); + h.page.destroy(); + delete h.elements.analyticsContent; + delete h.elements.ngCanvas; + debounce(); + frames.forEach(fn => fn()); + assert.strictEqual(h.frames.length, frames.length, 'old callbacks cannot schedule more animation or layout'); + }); + + console.log(`Analytics loading: ${passed} passed, ${failed} failed`); + process.exitCode = failed ? 1 : 0; +})(); diff --git a/tests/unit/test-api-inflight-cleanup.js b/tests/unit/test-api-inflight-cleanup.js new file mode 100644 index 000000000..ade3592ad --- /dev/null +++ b/tests/unit/test-api-inflight-cleanup.js @@ -0,0 +1,145 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const source = fs.readFileSync(path.join(__dirname, '../../public/app.js'), 'utf8'); +const start = source.indexOf('// --- Utilities ---'); +const end = source.indexOf('// Fetch the COMPLETE /api/nodes set', start); +assert.ok(start >= 0 && end > start, 'locate the real API/cache/warmup helpers'); + +function response(status, data = {}) { + return { status, ok: status >= 200 && status < 300, headers: { get: () => '1' }, json: async () => data }; +} + +function makeContext(fetchImpl) { + const events = []; + const clock = { now: 1000 }; + const context = vm.createContext({ + console, + Date: { now: () => clock.now }, + performance: { now: () => 0 }, + window: { onWarmup_1659: visible => events.push(visible) }, + fetch: url => url === '/api/config/cache' ? Promise.resolve(response(200)) : fetchImpl(url), + setTimeout: fn => { fn(); return 0; }, + }); + // Execute the actual helpers, including their maps and warmup counter, without SPA startup. + vm.runInContext(source.slice(start, end), context, { filename: 'public/app.js' }); + return { context, events, clock }; +} + +function assertSettled(context) { + assert.strictEqual(vm.runInContext('_inflight.size', context), 0, 'settled requests leave the inflight map'); + assert.strictEqual(vm.runInContext('_warmupInflight_1659', context), 0, 'warmup counter stays balanced'); +} + +let passed = 0; +let failed = 0; +async function test(name, fn) { + const unhandled = []; + const capture = reason => unhandled.push(reason); + process.on('unhandledRejection', capture); + let failure; + try { await fn(); } catch (error) { failure = error; } + // Node reports unhandled rejections after the promise microtasks have drained. + await new Promise(setImmediate); + process.removeListener('unhandledRejection', capture); + try { + if (failure) throw failure; + assert.strictEqual(unhandled.length, 0, + 'handled API requests must not create unhandled rejections: ' + unhandled.map(error => error.message).join(', ')); + passed++; + console.log(' PASS ' + name); + } catch (error) { + failed++; + console.error(' FAIL ' + name + ': ' + error.message); + } +} + +(async () => { + console.log('\n=== API inflight cleanup ==='); + + await test('success shares one request, clears inflight, and preserves cache TTL and bust', async () => { + let release; + const pending = new Promise(resolve => { release = resolve; }); + let calls = 0; + const { context, clock } = makeContext(async () => { + calls++; + return calls === 1 ? pending : response(200, { call: calls }); + }); + const first = context.api('/cleanup', { ttl: 100 }); + const second = context.api('/cleanup', { ttl: 100 }); + assert.strictEqual(calls, 1, 'concurrent callers share one fetch'); + assert.strictEqual(vm.runInContext('_inflight.size', context), 1); + const body = { call: 1 }; + release(response(200, body)); + const results = await Promise.all([first, second]); + results.forEach(result => assert.strictEqual(result, body)); + assertSettled(context); + clock.now += 99; + assert.strictEqual(await context.api('/cleanup', { ttl: 100 }), body); + assert.strictEqual(calls, 1, 'unexpired cached data avoids another fetch'); + clock.now++; + assert.deepStrictEqual(await context.api('/cleanup', { ttl: 100 }), { call: 2 }); + assert.deepStrictEqual(await context.api('/cleanup', { ttl: 100, bust: true }), { call: 3 }); + assert.strictEqual(calls, 3, 'expiry and bust each start a fresh request'); + assertSettled(context); + }); + + const networkError = new TypeError('Network unavailable'); + const jsonError = new SyntaxError('Invalid JSON'); + const failures = [ + { name: 'HTTP failure', fetch: () => response(500), message: /^API 500: \/cleanup$/, calls: 1 }, + { name: 'network failure', fetch: () => { throw networkError; }, error: networkError, calls: 1 }, + { name: 'JSON parse failure', fetch: () => ({ ...response(200), json: async () => { throw jsonError; } }), error: jsonError, calls: 1 }, + { name: 'exhausted warmup retries', fetch: () => response(503), message: /^API 503: \/cleanup$/, calls: 7, warmup: true }, + { name: 'network failure after warmup', fetch: call => { + if (call === 1) return response(503); + throw networkError; + }, error: networkError, calls: 2, warmup: true }, + ]; + + for (const fixture of failures) { + await test(fixture.name + ' reaches both callers without an extra rejection and permits retry', async () => { + let calls = 0; + let failing = true; + const body = { recovered: true }; + const { context, events } = makeContext(async () => { + calls++; + return failing ? fixture.fetch(calls) : response(200, body); + }); + const results = await Promise.allSettled([ + context.api('/cleanup', { ttl: 100 }), + context.api('/cleanup', { ttl: 100 }), + ]); + assert.strictEqual(calls, fixture.calls, 'concurrent callers share one request/retry sequence'); + results.forEach(result => assert.strictEqual(result.status, 'rejected', 'the caller still receives the failure')); + assert.strictEqual(results[0].reason, results[1].reason, 'both callers receive the original error'); + if (fixture.error) assert.strictEqual(results[0].reason, fixture.error); + else assert.match(results[0].reason.message, fixture.message); + assertSettled(context); + assert.strictEqual(vm.runInContext('_apiCache.size', context), 0, 'failures are not cached'); + assert.deepStrictEqual(events, fixture.warmup ? [true, false] : []); + failing = false; + assert.strictEqual(await context.api('/cleanup', { ttl: 100 }), body); + assert.strictEqual(calls, fixture.calls + 1, 'a failed request does not block a fresh fetch'); + assertSettled(context); + }); + } + + await test('successful warmup retries share one sequence and balance the banner', async () => { + let calls = 0; + const body = { ready: true }; + const { context, events } = makeContext(async () => ++calls < 3 ? response(503) : response(200, body)); + const results = await Promise.all([context.api('/cleanup'), context.api('/cleanup')]); + results.forEach(result => assert.strictEqual(result, body)); + assert.strictEqual(calls, 3); + assert.deepStrictEqual(events, [true, false]); + assertSettled(context); + }); + + console.log('\n' + passed + ' passed, ' + failed + ' failed'); + if (failed) process.exitCode = 1; +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/unit/test-my-repeaters-dashboard.js b/tests/unit/test-my-repeaters-dashboard.js index 30664a579..98d35ef86 100644 --- a/tests/unit/test-my-repeaters-dashboard.js +++ b/tests/unit/test-my-repeaters-dashboard.js @@ -67,10 +67,10 @@ const window = { ROLE_COLORS: { repeater: '#3b82f6', room: '#a855f7' }, aaBadgeS const M = new Function( 'esc', 'getFavorites', 'RegionFilter', 'AreaFilter', 'fetchAllNodes', 'api', 'CLIENT_TTL', - 'getHealthThresholds', 'renderSkewBadge', 'currentSkewValue', 'favStar', 'timeAgo', 'bindFavStars', 'window', + 'getHealthThresholds', 'renderSkewBadge', 'currentSkewValue', 'favStar', 'timeAgo', 'bindFavStars', 'window', 'analyticsGuard', block + '\nreturn { renderMyRepeatersTab, _mrStatusCell, _mrSummaryCard };')( esc, getFavorites, RegionFilter, AreaFilter, fetchAllNodes, api, CLIENT_TTL, - getHealthThresholds, renderSkewBadge, currentSkewValue, favStar, timeAgo, bindFavStars, window); + getHealthThresholds, renderSkewBadge, currentSkewValue, favStar, timeAgo, bindFavStars, window, () => () => true); function makeEl() { return { _h: '', set innerHTML(v) { this._h = v; }, get innerHTML() { return this._h; } }; } const tick = () => new Promise(r => setTimeout(r, 0));