diff --git a/cmd/ingestor/iata_drop_warn_test.go b/cmd/ingestor/iata_drop_warn_test.go
index 8d4b1617c..af57537ac 100644
--- a/cmd/ingestor/iata_drop_warn_test.go
+++ b/cmd/ingestor/iata_drop_warn_test.go
@@ -251,6 +251,48 @@ func TestIATADropThrottleOldestTracksStaggeredEntries(t *testing.T) {
}
}
+// A sweep that frees only part of the table must leave oldest at the
+// oldest entry it kept (#172). Left at the swept entries' time, oldest
+// stays expired, and every later drop on the refilled table sweeps the
+// whole table again although nothing can expire before the kept entries
+// do.
+func TestIATADropThrottleNoResweepAfterPartialSweep(t *testing.T) {
+ var th iataDropThrottle
+ iv := time.Hour
+ half := iataWarnMaxTracked / 2
+ fillIATA(t, &th, "A", half, t0IATA, iv)
+ fillIATA(t, &th, "B", iataWarnMaxTracked-half, t0IATA.Add(30*time.Minute), iv)
+
+ // +1h: a partial sweep frees the A half; refill to the cap
+ if w, o := th.shouldWarn("N1", t0IATA.Add(time.Hour), iv); !w || o {
+ t.Fatalf("+1h: warn=%v overflow=%v, want an own slot (A entries expired)", w, o)
+ }
+ fillIATA(t, &th, "C", half-1, t0IATA.Add(time.Hour), iv)
+ if len(th.last) != iataWarnMaxTracked || th.sweeps != 1 {
+ t.Fatalf("after the refill: %d entries after %d sweeps, want %d after 1", len(th.last), th.sweeps, iataWarnMaxTracked)
+ }
+
+ // +1h..+1h29m: the oldest kept entry (+30m) has not expired, so new
+ // regions overflow without another sweep
+ for i := 0; i < 100; i++ {
+ at := t0IATA.Add(time.Hour + time.Duration(i)*17*time.Second)
+ if _, o := th.shouldWarn(fmt.Sprintf("X%04d", i), at, iv); !o {
+ t.Fatalf("X%04d at %v: own slot in a full table of fresh entries", i, at.Sub(t0IATA))
+ }
+ }
+ if th.sweeps != 1 {
+ t.Fatalf("%d sweeps before +1h30m, want 1: oldest was not refreshed after the partial sweep", th.sweeps)
+ }
+
+ // +1h30m: the B half expires; the next new region sweeps once more
+ if w, o := th.shouldWarn("N2", t0IATA.Add(90*time.Minute), iv); !w || o {
+ t.Fatalf("+1h30m: warn=%v overflow=%v, want an own slot (B entries expired)", w, o)
+ }
+ if th.sweeps != 2 {
+ t.Fatalf("+1h30m: %d sweeps, want 2", th.sweeps)
+ }
+}
+
// A drop can carry an earlier time than the current oldest entry (the
// caller takes the time before the lock). oldest must drop to it, or that
// entry outlives its interval in a full table.
diff --git a/public/analytics.js b/public/analytics.js
index 7d55addad..bc8b1dcff 100644
--- a/public/analytics.js
+++ b/public/analytics.js
@@ -50,14 +50,40 @@
function _distanceIsBuilding(data) {
return !!(data && data.status === 'building' && !data.summary);
}
- // Retry-After header (passed on by api()), else the body's
- // retry_after_seconds, else 5s; clamped to 1..30s.
- function _distanceRetryDelayMs(data) {
+ // Retry-After header (passed on by api() on a 202 body or a 503 error),
+ // else the body's retry_after_seconds, else 5s; clamped to 1..30s.
+ function _retryAfterDelayMs(data) {
var s = Number(data && data.retryAfterSeconds);
if (!(isFinite(s) && s > 0)) s = Number(data && data.retry_after_seconds);
if (!(isFinite(s) && s > 0)) s = 5;
return Math.min(Math.max(s, 1), 30) * 1000;
}
+ // #172 — rf/topology/channels answer 503 + Retry-After while the server
+ // warms up after a restart (#1659), for up to its 60s force-open. The
+ // page shows a "still loading" state and retries on the server's
+ // interval until ANALYTICS_WARMUP_MAX_MS has passed since the load
+ // began, and only then shows the error. Like the distance tab (#120),
+ // every load and destroy() bump _loadGen, so a response or retry from an
+ // older load never renders, and at most one retry timer exists.
+ var ANALYTICS_WARMUP_MAX_MS = 120000;
+ var _loadRetryTimer = null;
+ var _loadGen = 0;
+ // #172: the tabs that render from that load's _analyticsData. Until it
+ // has data, they show the load's status instead (a click during the
+ // warm-up threw a TypeError), and the status is written only while one
+ // of them is shown, never over a tab that fetches its own data.
+ var LOAD_TABS = new Set(['overview', 'rf', 'topology', 'channels', 'hashsizes', 'collisions']);
+ var LOADING_HTML = '
Loading analytics…
';
+ var _loadStatusHtml = LOADING_HTML;
+ function _showLoadStatus(html) {
+ _loadStatusHtml = html;
+ var el = LOAD_TABS.has(_currentTab) && document.getElementById('analyticsContent');
+ if (el) el.innerHTML = html;
+ }
+ function _cancelLoadRetry() {
+ _loadGen++;
+ if (_loadRetryTimer) { clearTimeout(_loadRetryTimer); _loadRetryTimer = null; }
+ }
var _wardrivingRefreshTimer = null;
function _stopWardrivingRefresh() {
if (_wardrivingRefreshTimer) { clearInterval(_wardrivingRefreshTimer); _wardrivingRefreshTimer = null; }
@@ -173,7 +199,7 @@
-
Loading analytics…
+ ${LOADING_HTML}
`;
@@ -302,7 +328,10 @@
var _themeRefreshHandler = null;
let _currentTab = 'overview';
- async function loadAnalytics() {
+ async function loadAnalytics(startedAt) {
+ _cancelLoadRetry();
+ const gen = _loadGen;
+ if (startedAt === undefined) { startedAt = Date.now(); _loadStatusHtml = LOADING_HTML; }
try {
_analyticsData = {};
const rqs = RegionFilter.regionQueryString(); // "®ion=..." or ""
@@ -323,19 +352,33 @@
// channels: region + window (no area per original PR intent)
const chanQS = (rqs + tws).slice(1);
const sepChan = chanQS ? '?' + chanQS : '';
+ // 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, { ttl: CLIENT_TTL.analyticsRF }),
- api('/analytics/rf' + sepWin, { ttl: CLIENT_TTL.analyticsRF }),
- api('/analytics/topology' + sepWin, { ttl: CLIENT_TTL.analyticsRF }),
- api('/analytics/channels' + sepChan, { ttl: CLIENT_TTL.analyticsRF }),
- api('/analytics/hash-collisions' + sepBase, { ttl: CLIENT_TTL.analyticsRF }),
+ 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: [] })),
]);
+ if (gen !== _loadGen) return;
_analyticsData = { hashData, rfData, topoData, chanData, collisionData, airtimeData };
renderTab(_currentTab);
} catch (e) {
- document.getElementById('analyticsContent').innerHTML =
- `Failed to load: ${e.message}
`;
+ if (gen !== _loadGen) return;
+ const ms = _retryAfterDelayMs(e);
+ if (e && e.status === 503 && Date.now() - startedAt + ms <= ANALYTICS_WARMUP_MAX_MS) {
+ _showLoadStatus('' +
+ 'Analytics are still loading on the server after a restart.' +
+ '
Retrying in ' + Math.round(ms / 1000) + 's.
');
+ _loadRetryTimer = setTimeout(function () {
+ _loadRetryTimer = null;
+ if (gen === _loadGen) loadAnalytics(startedAt);
+ }, ms);
+ return;
+ }
+ _showLoadStatus(`Failed to load: ${esc(e && e.message)}
`);
}
}
@@ -356,6 +399,7 @@
async function renderTab(tab) {
const el = document.getElementById('analyticsContent');
const d = _analyticsData;
+ if (LOAD_TABS.has(tab) && !d.rfData) { el.innerHTML = _loadStatusHtml; return; }
switch (tab) {
case 'overview': renderOverview(el, d); break;
case 'rf': renderRF(el, d.rfData); break;
@@ -3010,7 +3054,7 @@
const data = await api('/analytics/distance' + sep, { ttl: CLIENT_TTL.analyticsRF });
if (gen !== _distanceGen) return; // re-rendered, switched tab or left meanwhile
if (_distanceIsBuilding(data)) {
- const ms = _distanceRetryDelayMs(data);
+ const ms = _retryAfterDelayMs(data);
el.innerHTML = '' +
'Building the distance index…' +
'
This runs once after the server starts. Retrying in ' + Math.round(ms / 1000) + 's.
';
@@ -3101,7 +3145,7 @@
}
}
-function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTrafficRefresh(); _stopWardrivingRefresh(); _stopAreasRefresh(); _leaveDistanceTab(); _analyticsData = {}; _channelData = null; if (_ngState && _ngState.animId) { cancelAnimationFrame(_ngState.animId); } _ngState = null; if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; } }
+function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTrafficRefresh(); _stopWardrivingRefresh(); _stopAreasRefresh(); _leaveDistanceTab(); _cancelLoadRetry(); _analyticsData = {}; _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') {
diff --git a/public/app.js b/public/app.js
index 7b9fef991..960be42b3 100644
--- a/public/app.js
+++ b/public/app.js
@@ -138,7 +138,7 @@ fetch('/api/config/cache').then(r => r.json()).then(cfg => {
if (k in CLIENT_TTL && typeof v === 'number') CLIENT_TTL[k] = v * 1000;
}
}).catch(() => {});
-async function api(path, { ttl = 0, bust = false } = {}) {
+async function api(path, { ttl = 0, bust = false, retry503 = true } = {}) {
const t0 = performance.now();
if (!bust && ttl > 0) {
const cached = _apiCache.get(path);
@@ -150,8 +150,11 @@ async function api(path, { ttl = 0, bust = false } = {}) {
return cached.data;
}
}
- // Deduplicate in-flight requests
- if (_inflight.has(path)) return _inflight.get(path);
+ // Deduplicate in-flight requests. #172: per retry503 too, so a caller
+ // that retries 503s itself never waits on the retry loop below, and a
+ // default caller never gets a 503 that loop would have ridden out.
+ const inflightKey = retry503 ? path : path + '\n#no-retry503';
+ if (_inflight.has(inflightKey)) return _inflight.get(inflightKey);
const promise = (async () => {
// Issue #1659: 503 with Retry-After indicates server-side warm-up
// (analytics recomputer first-pass, index build, etc.). Retry with
@@ -166,6 +169,11 @@ async function api(path, { ttl = 0, bust = false } = {}) {
// per attempt, decremented at most once) and exhausted-retries
// threw without decrementing at all — banner stuck across three
// analytics endpoints, multiplied.
+ //
+ // #172: retry503:false skips this loop for a caller that retries on
+ // its own (analytics.js outlasts the server's 60s warm-up and cancels
+ // its retries on navigation). Every error carries the HTTP status, and
+ // a 503's valid Retry-After as retryAfterSeconds.
let attempt = 0;
let delay = 1000;
const maxAttempts = 6;
@@ -173,7 +181,7 @@ async function api(path, { ttl = 0, bust = false } = {}) {
try {
while (true) {
const res = await fetch('/api' + path);
- if (res.status === 503 && attempt < maxAttempts) {
+ if (res.status === 503 && retry503 && attempt < maxAttempts) {
const ra = parseInt(res.headers.get('Retry-After'), 10);
const wait = isFinite(ra) && ra > 0 ? ra * 1000 : delay;
if (!notified) { _warmupNotify_1659(true); notified = true; }
@@ -182,7 +190,13 @@ async function api(path, { ttl = 0, bust = false } = {}) {
attempt++;
continue;
}
- if (!res.ok) throw new Error(`API ${res.status}: ${path}`);
+ if (!res.ok) {
+ const err = new Error(`API ${res.status}: ${path}`);
+ err.status = res.status;
+ const ra = parseInt(res.headers.get('Retry-After'), 10);
+ if (res.status === 503 && isFinite(ra) && ra > 0) err.retryAfterSeconds = ra;
+ throw err;
+ }
const data = await res.json();
const ms = performance.now() - t0;
_apiPerf.calls++;
@@ -211,14 +225,14 @@ async function api(path, { ttl = 0, bust = false } = {}) {
if (notified) _warmupNotify_1659(false);
}
})();
- _inflight.set(path, promise);
+ _inflight.set(inflightKey, promise);
// `.finally()` returns its own derived promise that mirrors `promise`'s
// outcome; discarding it uncaught leaves the real caller's rejection
// (delivered via the returned `promise` below, unaffected by this)
// duplicated as a second, unobserved rejection on this derived one.
// The `.catch()` here only silences that duplicate -- it does not
// touch `promise` itself or its resolution to callers.
- promise.finally(() => _inflight.delete(path)).catch(() => {});
+ promise.finally(() => _inflight.delete(inflightKey)).catch(() => {});
return promise;
}
diff --git a/public/nav-drawer.css b/public/nav-drawer.css
index dd556b299..4decb15a2 100644
--- a/public/nav-drawer.css
+++ b/public/nav-drawer.css
@@ -91,7 +91,9 @@
background: transparent;
border: none;
border-radius: 6px;
- color: var(--nav-text-muted);
+ /* --nav-text like the title: --nav-text-muted on --nav-bg2 is under
+ 4.5:1 in the forest, sunset and mono presets (#172). */
+ color: var(--nav-text);
font-size: 20px;
line-height: 1;
cursor: pointer;
diff --git a/public/rx-coverage.js b/public/rx-coverage.js
index da1ba9df2..3936a126d 100644
--- a/public/rx-coverage.js
+++ b/public/rx-coverage.js
@@ -14,8 +14,9 @@
// Request sequence per data stream (#150): a coverage or leaderboard
// response renders only if no newer request of its kind has started since,
// so a slow response for an earlier days (or rx, or viewport) cannot
- // overwrite newer data.
- var coverageSeq = 0, boardSeq = 0;
+ // overwrite newer data. An observer fit is dropped the same way when a
+ // newer fit starts, or when days or All changes what it was for (#172).
+ var coverageSeq = 0, boardSeq = 0, fitSeq = 0;
// Initial viewport (#124), first valid one wins: explicit URL lat/lon/zoom,
// this page's own saved view, /api/config/map, then this offline fallback
@@ -29,6 +30,7 @@
function isLive(gen) { return !destroyed && gen === generation; }
function isLatestCoverage(gen, seq) { return isLive(gen) && seq === coverageSeq; }
function isLatestBoard(gen, seq) { return isLive(gen) && seq === boardSeq; }
+ function isLatestFit(gen, seq) { return isLive(gen) && seq === fitSeq; }
// validView returns {lat, lon, zoom} when all three are present, numeric and
// in range; anything invalid, partial or out of range gives null.
@@ -228,7 +230,7 @@
});
});
var all = document.getElementById('rxAll');
- if (all) all.addEventListener('click', function () { selectedRx = ''; selectedName = ''; renderBoard(); drawCoverage(); syncHash(); });
+ if (all) all.addEventListener('click', function () { selectedRx = ''; selectedName = ''; fitSeq++; renderBoard(); drawCoverage(); syncHash(); });
}
// fitToObserver zooms the map to the selected observer's full coverage extent
@@ -236,10 +238,10 @@
// resulting moveend redraws the hexes at the fitted resolution.
function fitToObserver() {
if (!map || !selectedRx) { drawCoverage(); return; }
- var gen = generation;
+ var gen = generation, seq = ++fitSeq;
var url = '/api/rx-coverage?bbox=-90,-180,90,180&z=' + Math.max(8, map.getZoom()) + '&days=' + days + '&rx=' + encodeURIComponent(selectedRx);
fetch(url).then(function (r) { return r.json(); }).then(function (fc) {
- if (!isLive(gen) || !map) return;
+ if (!isLatestFit(gen, seq) || !map) return;
var minLat = 90, minLon = 180, maxLat = -90, maxLon = -180, any = false;
(fc.features || []).forEach(function (f) {
(f.geometry.coordinates[0] || []).forEach(function (c) {
@@ -252,7 +254,7 @@
map.fitBounds([[minLat, minLon], [maxLat, maxLon]], { padding: [30, 30], maxZoom: 15 });
drawCoverage(); // fitBounds may not fire moveend if the view is unchanged
}).catch(function (e) {
- if (!isLive(gen)) return;
+ if (!isLatestFit(gen, seq)) return;
console.warn('rx-coverage: observer extent fetch failed', e); drawCoverage();
});
}
@@ -271,6 +273,7 @@
function setDays(d) {
days = d;
+ fitSeq++; // a pending observer fit was for the old days
var bar = document.getElementById('rxDays');
if (bar) bar.querySelectorAll('button').forEach(function (b) { b.classList.toggle('active', +b.dataset.days === d); });
loadBoard(); drawCoverage(); syncHash();
diff --git a/test-1659-analytics-warmup.js b/test-1659-analytics-warmup.js
index b0db50ab3..94cff61d7 100644
--- a/test-1659-analytics-warmup.js
+++ b/test-1659-analytics-warmup.js
@@ -20,10 +20,13 @@ const assert = require('assert');
// Some tests intentionally cause api() to throw; the inflight tracker
// inside app.js may surface that as an unhandled rejection a tick later
// even after the test has caught it. Swallow them — they're expected
-// for the "cap retries" test path.
+// for the "cap retries" test path. Any other unhandled rejection (e.g. a
+// TypeError from an analytics tab rendered without data, #172) is kept
+// so the test that caused it can fail on it.
+const unexpectedRejections = [];
process.on('unhandledRejection', (e) => {
if (e && /API \d+/.test(e.message || '')) return;
- throw e;
+ unexpectedRejections.push(e);
});
let passed = 0, failed = 0;
@@ -34,7 +37,7 @@ function test(name, fn) {
.catch(e => { failed++; console.log(' ❌ ' + name + ': ' + (e && e.message || e)); });
}
-function makeCtx(fetchImpl) {
+function makeCtx(fetchImpl, clock) {
const ctx = {
console, Date, Math, Promise, Error, isFinite, parseInt, JSON,
performance: { now: () => 0 },
@@ -46,6 +49,8 @@ function makeCtx(fetchImpl) {
clearInterval: () => {},
Map, Set,
};
+ // #172: with a fake clock, backoff sleeps only run when it advances.
+ if (clock) { ctx.setTimeout = clock.setTimeout; ctx.clearTimeout = clock.clearTimeout; }
vm.createContext(ctx);
// Provide the no-op fetch('/api/config/cache') hit that app.js makes
// on module load by returning a thenable that ignores.
@@ -61,6 +66,179 @@ function makeCtx(fetchImpl) {
return ctx;
}
+// ---------------------------------------------------------------- #172
+
+// A fake clock: setTimeout only queues; advance(ms) runs what is due, in
+// time order, then lets the promise chains settle.
+function fakeClock() {
+ let now = 0, nextId = 1;
+ const timers = new Map();
+ const flush = async () => { for (let i = 0; i < 30; i++) await new Promise((r) => setImmediate(r)); };
+ return {
+ now: () => now,
+ setTimeout: (fn, ms) => { const id = nextId++; timers.set(id, { fn, at: now + (Number(ms) || 0) }); return id; },
+ clearTimeout: (id) => { timers.delete(id); },
+ pending: () => [...timers.values()],
+ async advance(ms) {
+ const end = now + ms;
+ for (;;) {
+ let next = null;
+ for (const [id, t] of timers) if (t.at <= end && (!next || t.at < next[1].at)) next = [id, t];
+ if (!next) break;
+ timers.delete(next[0]);
+ now = Math.max(now, next[1].at);
+ next[1].fn();
+ await flush();
+ }
+ now = end;
+ await flush();
+ },
+ };
+}
+
+const WARM_RF = {
+ totalTransmissions: 4321, totalPackets: 1234, packetsPerHour: [{ hour: '2026-10-02T12:00:00Z', count: 5 }],
+ snr: { avg: 5, min: -10, max: 12 }, rssi: { avg: -90, min: -120, max: -40 },
+ avgPacketSize: 40, minPacketSize: 10, maxPacketSize: 200, timeSpanHours: 24, payloadTypes: [],
+};
+const WARM_TOPO = { uniqueNodes: 42, avgHops: 1.5, maxHops: 5, hopDistribution: [] };
+const WARM_CHAN = { activeChannels: 3, decryptable: 1 };
+// #172 r2: real responses of the CI fixture server for the five shared
+// endpoints, arrays cut to two items, names and keys anonymised. The
+// tab tests need every tab's real shape, not just the overview's.
+const REAL = JSON.parse(fs.readFileSync('test-fixtures/analytics-tabs-172.json', 'utf8'));
+
+function fakeEl(id) {
+ let html = '', dataWrites = 0;
+ const listeners = {};
+ return {
+ id, value: '', style: {}, dataset: {}, parentElement: null,
+ get innerHTML() { return html; },
+ set innerHTML(v) { html = String(v); if (/Total Transmissions/.test(html)) dataWrites++; },
+ dataWrites: () => dataWrites,
+ classList: { add() {}, remove() {}, contains: () => false, toggle() {} },
+ 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,
+ };
+}
+
+// The analytics page on a server whose rf/topology/channels answer 503 +
+// Retry-After: 5 until warmMs on the fake clock, each response after
+// latencyMs (0: at once). opts.status and opts.retryAfter replace the
+// 503 and its Retry-After value; opts.realData answers with REAL. The
+// tabs that fetch their own data get a small answer of their own (OWN_TABS).
+function pageEnv(warmMs, latencyMs, opts) {
+ const errStatus = (opts && opts.status) || 503;
+ const errRetryAfter = opts && 'retryAfter' in opts ? opts.retryAfter : '5';
+ const realData = !!(opts && opts.realData);
+ const clock = fakeClock();
+ const els = {};
+ const el = (id) => els[id] || (els[id] = fakeEl(id));
+ const fetchLog = [];
+ const respond = (status, body, retryAfter) => ({
+ ok: status >= 200 && status < 300, status,
+ headers: { get: (k) => (String(k).toLowerCase() === 'retry-after' ? retryAfter || null : null) },
+ json: async () => JSON.parse(JSON.stringify(body)),
+ });
+ const fetchImpl = async (url) => {
+ fetchLog.push(url);
+ if (latencyMs) await new Promise((r) => clock.setTimeout(r, latencyMs));
+ if (/\/api\/analytics\/(rf|topology|channels)\b/.test(url) && clock.now() < warmMs) {
+ return respond(errStatus, { error: 'analytics warming up', retry_after_s: 5 }, errRetryAfter);
+ }
+ if (realData) {
+ const m = /\/api\/analytics\/(rf|topology|channels|hash-sizes|hash-collisions)\b/.exec(url);
+ const key = m && { rf: 'rfData', topology: 'topoData', channels: 'chanData', 'hash-sizes': 'hashData', 'hash-collisions': 'collisionData' }[m[1]];
+ if (key) return respond(200, REAL[key]);
+ }
+ if (/\/api\/analytics\/rf\b/.test(url)) {
+ return respond(200, Object.assign({}, WARM_RF, /region=CPH/.test(url) ? { totalTransmissions: 8765 } : {}));
+ }
+ if (/\/api\/analytics\/topology\b/.test(url)) return respond(200, WARM_TOPO);
+ if (/\/api\/analytics\/channels\b/.test(url)) return respond(200, WARM_CHAN);
+ if (/relay-airtime-share/.test(url)) return respond(200, { rows: [] });
+ for (const t of Object.values(OWN_TABS)) for (const [re, body] of t.responses) if (re.test(url)) return respond(200, body);
+ return respond(200, {});
+ };
+ class FakeDate extends Date { static now() { return 1790000000000 + clock.now(); } }
+ let regionCb = null, region = '';
+ const pages = {};
+ const winListeners = {};
+ const ctx = {
+ window: {
+ addEventListener(type, fn) { (winListeners[type] = winListeners[type] || []).push(fn); },
+ removeEventListener(type, fn) { winListeners[type] = (winListeners[type] || []).filter((f) => f !== fn); },
+ dispatchEvent() {},
+ },
+ document: {
+ readyState: 'complete', body: { appendChild() {} }, head: { appendChild() {} },
+ createElement: () => fakeEl(''), getElementById: el, addEventListener() {},
+ querySelectorAll: () => [], querySelector: () => null, documentElement: fakeEl('html'),
+ },
+ console: { log() {}, warn() {}, error: console.error }, Date: FakeDate, Infinity, Math, Array, Object, String, Number,
+ JSON, RegExp, Error, TypeError, parseInt, parseFloat, isNaN, isFinite, encodeURIComponent, decodeURIComponent,
+ setTimeout: clock.setTimeout, clearTimeout: clock.clearTimeout, setInterval: () => 0, clearInterval() {},
+ requestAnimationFrame: () => 0, cancelAnimationFrame() {},
+ fetch: fetchImpl, performance: { now: () => clock.now() },
+ localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
+ location: { hash: '#/analytics' }, history: { replaceState() {} },
+ CustomEvent: class CustomEvent {}, Map, Set, Promise, URLSearchParams,
+ getComputedStyle: () => ({ getPropertyValue: () => '' }),
+ timeAgo: () => 'x ago', initTabBar() {}, makeColumnsResizable() {},
+ onWS() {}, offWS() {}, connectWS() {}, invalidateApiCache() {}, IATA_COORDS_GEO: {},
+ RegionFilter: { init() {}, onChange: (fn) => { regionCb = fn; }, regionQueryString: () => region },
+ AreaFilter: { init() {}, onChange() {}, areaQueryString: () => '' },
+ };
+ vm.createContext(ctx);
+ const load = (f) => { vm.runInContext(fs.readFileSync(f, 'utf8'), ctx); for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; };
+ load('public/payload-labels.js');
+ load('public/roles.js');
+ try { load('public/app.js'); } catch (e) { /* DOM-only tail */ }
+ ctx.registerPage = (name, obj) => { pages[name] = obj; }; // app.js defines its own
+ load('public/analytics.js');
+ return {
+ clock, el, page: pages.analytics,
+ regionChanged: (r) => { region = r ? '®ion=' + r : ''; regionCb(); },
+ // A click on the analytics tab button for `tab`, as the browser sends it.
+ clickTab: (tab) => {
+ const btn = { dataset: { tab }, classList: { add() {}, remove() {} }, setAttribute() {} };
+ el('analyticsTabs').dispatch('click', { target: { closest: () => btn } });
+ },
+ analyticsFetches: () => fetchLog.filter((u) => /\/api\/analytics\/(rf|topology|channels)\b/.test(u)).length,
+ fetchesOf: (re) => fetchLog.filter((u) => re.test(u)).length,
+ // What app.js's theme debounce dispatches on window (#1925).
+ themeRefresh: () => { for (const fn of winListeners['theme-refresh'] || []) fn(); },
+ themeListeners: () => (winListeners['theme-refresh'] || []).length,
+ };
+}
+
+// #172 r3: three tabs that fetch their own data, each with the endpoint it
+// fetches, a small answer for it and a marker that only its own render
+// function writes.
+const OWN_TABS = {
+ subpaths: {
+ fetches: /\/api\/analytics\/subpaths-bulk\b/,
+ responses: [[/\/api\/analytics\/subpaths-bulk\b/, { results: [0, 1, 2, 3].map(() => ({ subpaths: [], totalPaths: 0 })) }]],
+ marker: /Route Pattern Analysis/,
+ },
+ roles: {
+ fetches: /\/api\/analytics\/roles\b/,
+ responses: [[/\/api\/analytics\/roles\b/, { totalNodes: 3, roles: [{ role: 'repeater', nodeCount: 3, okCount: 3 }] }]],
+ marker: /data-role="repeater"/,
+ },
+ nodes: {
+ fetches: /\/api\/nodes\/bulk-health\b/,
+ responses: [
+ [/\/api\/nodes\/bulk-health\b/, [{ public_key: 'aa'.repeat(32), stats: { totalTransmissions: 9, avgSnr: 4 }, observers: [] }]],
+ [/\/api\/nodes\?/, { nodes: [{ public_key: 'aa'.repeat(32), name: 'Node A', role: 'repeater' }], total: 1, counts: { repeater: 1 } }],
+ ],
+ marker: /Node A/,
+ },
+};
+
(async () => {
console.log('\n=== #1659: api() retries on 503 with Retry-After ===');
@@ -212,6 +390,308 @@ function makeCtx(fetchImpl) {
assert.strictEqual(events[events.length - 1], false, 'banner must end hidden');
});
+ console.log('\n=== #172: the analytics page outlasts the server warm-up ===');
+
+ // The server answers 503 + Retry-After: 5 on rf/topology/channels until
+ // its background load is done, for up to its 60 s force-open
+ // (cmd/server/analytics_warmup_1659.go). These run the REAL app.js api()
+ // and the REAL analytics page in one vm, with a fake fetch and a fake
+ // clock that drives setTimeout and Date.now.
+
+ await test('a warm-up longer than 30 s ends with data and never shows an error', async () => {
+ const env = pageEnv(45000);
+ env.page.init(env.el('app'));
+ const content = env.el('analyticsContent');
+ let sawLoading = false;
+ for (let t = 0; t < 60; t++) {
+ await env.clock.advance(1000);
+ assert.ok(!/Failed to load/.test(content.innerHTML), 'error shown at ' + env.clock.now() + ' ms: ' + content.innerHTML);
+ if (env.clock.now() < 45000 && /still loading/i.test(content.innerHTML)) sawLoading = true;
+ }
+ assert.ok(sawLoading, 'no "still loading" state while the server warmed up');
+ assert.ok(/Total Transmissions/.test(content.innerHTML) && /4,321|4321/.test(content.innerHTML),
+ 'no data after the warm-up: ' + content.innerHTML.slice(0, 200));
+ });
+
+ await test('a permanent 503 keeps retrying past the 60 s force-open, then shows the error', async () => {
+ const env = pageEnv(Infinity);
+ env.page.init(env.el('app'));
+ const content = env.el('analyticsContent');
+ let errorAt = null;
+ for (let t = 0; t < 200 && errorAt === null; t++) {
+ await env.clock.advance(1000);
+ if (/Failed to load/.test(content.innerHTML)) errorAt = env.clock.now();
+ }
+ assert.ok(errorAt !== null, 'no error after 200 s of 503s: the retry loop is unbounded');
+ assert.ok(errorAt >= 90000, 'gave up after ' + errorAt + ' ms, before the 90 s floor (server force-open is 60 s)');
+ assert.ok(errorAt <= 125000, 'gave up only after ' + errorAt + ' ms, past the 120 s cap');
+ const before = env.analyticsFetches();
+ await env.clock.advance(60000);
+ assert.strictEqual(env.analyticsFetches(), before, 'still fetching after giving up');
+ });
+
+ await test('leaving the page during the warm-up stops the retries and writes nothing', async () => {
+ const env = pageEnv(45000);
+ env.page.init(env.el('app'));
+ const content = env.el('analyticsContent');
+ await env.clock.advance(12000);
+ env.page.destroy();
+ const fetches = env.analyticsFetches();
+ const html = content.innerHTML;
+ for (let t = 0; t < 150; t++) await env.clock.advance(1000);
+ assert.strictEqual(env.analyticsFetches(), fetches, 'warm-up fetches after destroy()');
+ assert.strictEqual(content.innerHTML, html, 'the left page was written after destroy()');
+ assert.strictEqual(env.clock.pending().length, 0, 'retry timers left after destroy(): ' + env.clock.pending().length);
+ });
+
+ await test('a new load during the warm-up replaces the old one: one retry timer, one render', async () => {
+ const env = pageEnv(45000);
+ env.page.init(env.el('app'));
+ const content = env.el('analyticsContent');
+ await env.clock.advance(7000);
+ env.regionChanged(); // e.g. a region filter change starts a new load
+ let maxTimers = 0;
+ for (let t = 0; t < 60; t++) {
+ await env.clock.advance(1000);
+ maxTimers = Math.max(maxTimers, env.clock.pending().length);
+ }
+ assert.ok(maxTimers <= 1, maxTimers + ' retry timers pending at once');
+ assert.strictEqual(content.dataWrites(), 1, 'the data was rendered ' + content.dataWrites() + ' times (the superseded load rendered too)');
+ });
+
+ await test('a slow response of a superseded load does not render over the newer one', async () => {
+ const env = pageEnv(0, 3000);
+ env.page.init(env.el('app')); // all regions: answers at 3 s
+ const content = env.el('analyticsContent');
+ await env.clock.advance(1000);
+ env.regionChanged('CPH'); // region CPH: answers at 4 s
+ await env.clock.advance(2500);
+ assert.strictEqual(content.dataWrites(), 0, 'the superseded all-regions load rendered: ' + content.innerHTML.slice(0, 120));
+ await env.clock.advance(2000);
+ assert.strictEqual(content.dataWrites(), 1, 'the CPH load did not render once: ' + content.dataWrites());
+ assert.ok(/8,765|8765/.test(content.innerHTML), 'not the CPH data: ' + content.innerHTML.slice(0, 200));
+ });
+
+ await test('a slow error of a superseded load is not shown during the newer one', async () => {
+ // rf answers 500 until 3.5 s, every response after 3 s.
+ const env = pageEnv(3500, 3000, { status: 500, retryAfter: null });
+ env.page.init(env.el('app')); // all regions: the 500 at 3 s
+ const content = env.el('analyticsContent');
+ await env.clock.advance(1000);
+ env.regionChanged('CPH'); // region CPH: the data at 4 s
+ await env.clock.advance(2500);
+ assert.ok(!/Failed to load/.test(content.innerHTML), 'the superseded load showed its error: ' + content.innerHTML.slice(0, 200));
+ await env.clock.advance(1000);
+ assert.ok(/8,765|8765/.test(content.innerHTML), 'not the CPH data: ' + content.innerHTML.slice(0, 200));
+ });
+
+ console.log('\n=== #172 r2: in-flight sharing respects retry503 ===');
+
+ // One request per path is shared while in flight, but a retry503:false
+ // caller (analytics.js) and a default caller want different promises:
+ // the first must see the 503 at once, the second must ride out the
+ // warm-up. rf answers 503 + Retry-After: 5 until 10 s on the fake clock.
+ function inflightEnv() {
+ const clock = fakeClock();
+ let rfFetches = 0;
+ const ctx = makeCtx(async (url) => {
+ if (!/analytics\/rf/.test(url || '')) {
+ return { ok: true, status: 200, headers: { get: () => null }, json: async () => ({}) };
+ }
+ rfFetches++;
+ if (clock.now() < 10000) {
+ return { ok: false, status: 503, headers: { get: (k) => (k.toLowerCase() === 'retry-after' ? '5' : null) }, json: async () => ({}) };
+ }
+ return { ok: true, status: 200, headers: { get: () => null }, json: async () => ({ totalPackets: 7 }) };
+ }, clock);
+ const track = (p) => {
+ const r = { settled: false, value: undefined, error: undefined };
+ p.then((v) => { r.settled = true; r.value = v; }, (e) => { r.settled = true; r.error = e; });
+ return r;
+ };
+ return { clock, ctx, track, rfFetches: () => rfFetches };
+ }
+
+ await test('a default caller does not join a retry503:false request and gets the data', async () => {
+ const env = inflightEnv();
+ const noRetry = env.track(env.ctx.api('/analytics/rf', { retry503: false }));
+ const dflt = env.track(env.ctx.api('/analytics/rf'));
+ await env.clock.advance(1000);
+ assert.ok(noRetry.settled && noRetry.error && noRetry.error.status === 503,
+ 'the retry503:false caller did not get the 503 at once');
+ assert.ok(!dflt.settled, 'the default caller settled after 1 s: ' + (dflt.error ? dflt.error.message : JSON.stringify(dflt.value)));
+ await env.clock.advance(30000);
+ assert.ok(dflt.settled && !dflt.error, 'the default caller failed: ' + (dflt.error && dflt.error.message));
+ assert.deepStrictEqual(dflt.value, { totalPackets: 7 });
+ });
+
+ await test('a retry503:false caller does not join a retrying request and gets the 503 at once', async () => {
+ const env = inflightEnv();
+ const dflt = env.track(env.ctx.api('/analytics/rf'));
+ await env.clock.advance(100); // the default call is now sleeping on Retry-After
+ const noRetry = env.track(env.ctx.api('/analytics/rf', { retry503: false }));
+ await env.clock.advance(100);
+ assert.ok(noRetry.settled, 'the retry503:false caller is waiting on the retrying request');
+ assert.ok(noRetry.error && noRetry.error.status === 503, 'not a 503 error: ' + (noRetry.error && noRetry.error.message));
+ await env.clock.advance(30000);
+ assert.ok(dflt.settled && !dflt.error, 'the default caller failed: ' + (dflt.error && dflt.error.message));
+ });
+
+ await test('callers with the same retry503 still share one request', async () => {
+ const env = inflightEnv();
+ await env.clock.advance(10000); // warm: one 200
+ const a = env.ctx.api('/analytics/rf', { retry503: false });
+ const b = env.ctx.api('/analytics/rf', { retry503: false });
+ const c = env.ctx.api('/analytics/rf');
+ const d = env.ctx.api('/analytics/rf');
+ await env.clock.advance(100);
+ await Promise.all([a, b, c, d]);
+ assert.strictEqual(env.rfFetches(), 2, 'expected one fetch per retry503 value, got ' + env.rfFetches());
+ });
+
+ console.log('\n=== #172 r2: what the analytics page does with an error ===');
+
+ for (const status of [500, 404]) {
+ await test('a ' + status + ' shows the error at once and is not retried', async () => {
+ const env = pageEnv(Infinity, 0, { status, retryAfter: null });
+ env.page.init(env.el('app'));
+ const content = env.el('analyticsContent');
+ await env.clock.advance(1000);
+ assert.ok(/Failed to load/.test(content.innerHTML), 'no error after 1 s: ' + content.innerHTML.slice(0, 200));
+ assert.ok(!/still loading/i.test(content.innerHTML), 'a ' + status + ' is shown as "still loading"');
+ const fetches = env.analyticsFetches();
+ await env.clock.advance(130000);
+ assert.strictEqual(env.analyticsFetches(), fetches, 'a ' + status + ' was retried');
+ assert.strictEqual(env.clock.pending().length, 0, 'retry timers pending after a ' + status);
+ });
+ }
+
+ // The retry follows the server's Retry-After, clamped to 1..30 s, and the
+ // status line says so (role="status", not an alert).
+ for (const [ra, wantMs] of [['1', 1000], ['12', 12000], ['60', 30000], ['abc', 5000]]) {
+ await test('a 503 with Retry-After: ' + ra + ' retries after ' + wantMs / 1000 + ' s', async () => {
+ const env = pageEnv(Infinity, 0, { retryAfter: ra });
+ env.page.init(env.el('app'));
+ const content = env.el('analyticsContent');
+ await env.clock.advance(1);
+ const timers = env.clock.pending();
+ assert.strictEqual(timers.length, 1, timers.length + ' timers pending after the first 503');
+ assert.strictEqual(timers[0].at - env.clock.now(), wantMs, 'retry timer');
+ assert.ok(/role="status"/.test(content.innerHTML), 'the loading state is not role="status": ' + content.innerHTML.slice(0, 200));
+ assert.ok(!/role="alert"/.test(content.innerHTML), 'the loading state is an alert');
+ assert.ok(content.innerHTML.includes('Retrying in ' + wantMs / 1000 + 's'), 'status text: ' + content.innerHTML.slice(0, 300));
+ const fetches = env.analyticsFetches();
+ await env.clock.advance(wantMs - 1);
+ assert.strictEqual(env.analyticsFetches(), fetches, 'retried before ' + wantMs + ' ms');
+ await env.clock.advance(1);
+ assert.ok(env.analyticsFetches() > fetches, 'not retried at ' + wantMs + ' ms');
+ });
+ }
+
+ console.log('\n=== #172 r2: tab clicks while the analytics are still loading ===');
+
+ // These six tabs render from the shared load (_analyticsData). The other
+ // tabs fetch their own data and are covered by the browser check.
+ const DATA_TABS = {
+ overview: /Total Transmissions/, rf: /SNR Distribution/, topology: /Hop Count Distribution/,
+ channels: /Channel Activity/, hashsizes: /Hash Size Distribution/, collisions: /hashMatrixTitle/,
+ };
+ for (const [tab, marker] of Object.entries(DATA_TABS)) {
+ await test('clicking "' + tab + '" during the warm-up shows the loading state, then that tab\'s data', async () => {
+ const env = pageEnv(45000, 0, { realData: true });
+ env.page.init(env.el('app'));
+ const content = env.el('analyticsContent');
+ await env.clock.advance(6000);
+ const before = unexpectedRejections.length;
+ env.clickTab(tab);
+ await env.clock.advance(100);
+ const errs = unexpectedRejections.splice(before);
+ assert.strictEqual(errs.length, 0, 'the click threw: ' + errs.map((e) => e && e.message).join('; '));
+ assert.ok(/still loading/i.test(content.innerHTML) && /role="status"/.test(content.innerHTML),
+ 'no loading state after the click: ' + content.innerHTML.slice(0, 200));
+ await env.clock.advance(15000); // a retry while the tab is shown
+ assert.ok(/still loading/i.test(content.innerHTML), 'loading state lost on a retry: ' + content.innerHTML.slice(0, 200));
+ await env.clock.advance(40000);
+ const late = unexpectedRejections.splice(before);
+ assert.strictEqual(late.length, 0, 'rendering threw: ' + late.map((e) => e && e.message).join('; '));
+ assert.ok(!/still loading|Failed to load/i.test(content.innerHTML), 'not rendered after the warm-up: ' + content.innerHTML.slice(0, 200));
+ assert.ok(marker.test(content.innerHTML), 'not the ' + tab + ' tab: ' + content.innerHTML.slice(0, 200));
+ if (tab !== 'overview') assert.ok(!/Total Transmissions/.test(content.innerHTML), 'the overview was rendered instead of ' + tab);
+ });
+ }
+
+ // #172 r3: the tab's OWN render function must run during the warm-up
+ // (its fetch goes out and its output is shown), and the load's 503
+ // retries must then leave that output alone.
+ for (const [tab, own] of Object.entries(OWN_TABS)) {
+ await test('clicking "' + tab + '" during the warm-up renders that tab, and the retries leave it alone', async () => {
+ const env = pageEnv(45000);
+ env.page.init(env.el('app'));
+ const content = env.el('analyticsContent');
+ await env.clock.advance(6000);
+ assert.ok(/still loading/i.test(content.innerHTML), 'not in the warm-up: ' + content.innerHTML.slice(0, 200));
+ const before = unexpectedRejections.length;
+ env.clickTab(tab);
+ await env.clock.advance(100);
+ const errs = unexpectedRejections.splice(before);
+ assert.strictEqual(errs.length, 0, 'the click threw: ' + errs.map((e) => e && e.message).join('; '));
+ assert.ok(env.fetchesOf(own.fetches) > 0, 'the ' + tab + ' tab did not fetch its own data');
+ assert.ok(own.marker.test(content.innerHTML), 'the ' + tab + ' tab was not rendered: ' + content.innerHTML.slice(0, 200));
+ assert.ok(!/still loading|Loading analytics/i.test(content.innerHTML), 'the load status is shown on ' + tab);
+ await env.clock.advance(20000); // several 503 retries
+ assert.ok(own.marker.test(content.innerHTML), 'the loading state replaced the ' + tab + ' tab: ' + content.innerHTML.slice(0, 200));
+ });
+ }
+
+ console.log('\n=== #172 r3: a new load starts from "Loading", and theme-refresh during a load ===');
+
+ await test('after a failed load, a data tab clicked during the next load shows "Loading", not the old error', async () => {
+ // rf answers 500 until 5 s, every response after 3 s.
+ const env = pageEnv(5000, 3000, { status: 500, retryAfter: null, realData: true });
+ env.page.init(env.el('app'));
+ const content = env.el('analyticsContent');
+ await env.clock.advance(4000);
+ assert.ok(/Failed to load/.test(content.innerHTML), 'no error from the first load: ' + content.innerHTML.slice(0, 200));
+ env.regionChanged('CPH'); // a new load; its answers come at 9 s
+ await env.clock.advance(2000);
+ env.clickTab('rf');
+ await env.clock.advance(100);
+ assert.ok(!/Failed to load/.test(content.innerHTML), 'the old error is shown during the new load: ' + content.innerHTML.slice(0, 200));
+ assert.ok(/Loading analytics/.test(content.innerHTML), 'no loading state during the new load: ' + content.innerHTML.slice(0, 200));
+ await env.clock.advance(3000);
+ assert.ok(/SNR Distribution/.test(content.innerHTML), 'the new load did not render the rf tab: ' + content.innerHTML.slice(0, 200));
+ });
+
+ for (const tab of ['overview', 'rf']) {
+ await test('theme-refresh on "' + tab + '" during the warm-up keeps the loading state, then the data renders', async () => {
+ const env = pageEnv(45000, 0, { realData: true });
+ env.page.init(env.el('app'));
+ const content = env.el('analyticsContent');
+ assert.strictEqual(env.themeListeners(), 1, 'the page did not listen for theme-refresh');
+ await env.clock.advance(6000);
+ if (tab !== 'overview') env.clickTab(tab);
+ await env.clock.advance(100);
+ const before = unexpectedRejections.length;
+ env.themeRefresh();
+ await env.clock.advance(100);
+ const errs = unexpectedRejections.splice(before);
+ assert.strictEqual(errs.length, 0, 'theme-refresh threw: ' + errs.map((e) => e && e.message).join('; '));
+ assert.ok(/still loading/i.test(content.innerHTML) && /role="status"/.test(content.innerHTML),
+ 'no loading state after theme-refresh: ' + content.innerHTML.slice(0, 200));
+ await env.clock.advance(45000);
+ env.themeRefresh(); // once more, now with data
+ await env.clock.advance(100);
+ const late = unexpectedRejections.splice(before);
+ assert.strictEqual(late.length, 0, 'rendering threw: ' + late.map((e) => e && e.message).join('; '));
+ assert.ok(DATA_TABS[tab].test(content.innerHTML), 'not the ' + tab + ' data: ' + content.innerHTML.slice(0, 200));
+ });
+ }
+
+ if (unexpectedRejections.length) {
+ failed++;
+ console.log(' ❌ unexpected unhandled rejections: ' + unexpectedRejections.map((e) => e && e.message).join('; '));
+ }
+
console.log('\n' + passed + ' passed, ' + failed + ' failed');
if (failed > 0) process.exit(1);
})();
diff --git a/test-fixtures/analytics-tabs-172.json b/test-fixtures/analytics-tabs-172.json
new file mode 100644
index 000000000..60bb9a0db
--- /dev/null
+++ b/test-fixtures/analytics-tabs-172.json
@@ -0,0 +1 @@
+{"rfData":{"avgPacketSize":64,"maxPacketSize":242,"minPacketSize":12,"packetSizes":{"bins":[{"count":9,"w":9.2,"x":12},{"count":157,"w":9.2,"x":21.2}],"max":242,"min":12},"packetsPerHour":[{"hour":"2026-05-15T00","count":1},{"hour":"2026-10-03T03","count":14}],"payloadTypes":[{"type":5,"name":"Node 1","count":167},{"type":4,"name":"Node 2","count":102}],"rssi":{"avg":-92.33333333333333,"max":-90,"median":-92,"min":-95,"stddev":2.0548046676563256},"rssiValues":{"bins":[{"count":1,"w":0.25,"x":-95},{"count":0,"w":0.25,"x":-94.75}],"max":-90,"min":-95},"scatterData":[{"snr":5,"rssi":-95},{"snr":5.5,"rssi":-92}],"signalOverTime":[{"hour":"2026-05-15T00","count":3,"avgSnr":5.5}],"snr":{"avg":5.5,"max":6,"median":5.5,"min":5,"stddev":0.408248290463863},"snrByType":[{"name":"Node 1","count":3,"avg":5.5,"min":5,"max":6}],"snrValues":{"bins":[{"count":1,"w":0.05,"x":5},{"count":0,"w":0.05,"x":5.05}],"max":6,"min":5},"timeSpanHours":3390.0602777777776,"totalAllPackets":502,"totalPackets":3,"totalTransmissions":500},"topoData":{"avgHops":7.3375,"bestPathList":[{"hop":"3957","minDist":1,"name":"Node 3","observer_id":"63538D0B8F8607168388DAF7FAB21468C36193D95DA7646F66E78FCFE407AC93","observer_name":"Node 4","pubkey":"39cfb3d583eee85c367aee5318cedecc1b88c86b96ebf8827864a08e77f6eddf"},{"hop":"04","minDist":1,"name":"Node 5","observer_id":"B71892E2BE5EE20F27B6CF8D1014F09C5E9C278CF3033F6A28D6F3430CE04523","observer_name":"Node 6","pubkey":"bbcbb8b3071f79fb869bf7e31403eda1dd0c040236386fc0f72ff83fa61f5246"}],"hopDistribution":[{"count":29,"hops":1},{"count":9,"hops":2}],"hopsVsSnr":[{"avgSnr":5,"count":1,"hops":1}],"maxHops":37,"medianHops":7,"multiObsNodes":[{"hop":"10","name":"Node 7","observers":[{"count":4,"minDist":1,"observer_id":"5162D84286F5226AE22F0CB0B015C2D4AFC5D59DEB388B45491AEF7C7B6010FF","observer_name":"Node 8"},{"count":1,"minDist":1,"observer_id":"D6D6FD7889A0EC8756CBF0EC74CD0CD4CC756CBA709439835DA730C34EFA5B20","observer_name":"Node 9"}],"pubkey":"936983eb45bdaa8e8b672e30bf488b201651c2b1a0cb3d9170c5a485e0a112da"},{"hop":"20","name":"Node 10","observers":[{"count":3,"minDist":1,"observer_id":"5162D84286F5226AE22F0CB0B015C2D4AFC5D59DEB388B45491AEF7C7B6010FF","observer_name":"Node 8"},{"count":8,"minDist":1,"observer_id":"753BCF9C2B7F1E40CA7DA215FCF077AB63547982B68FBA3E143381C105D7381F","observer_name":"Node 11"}],"pubkey":"31ec23e3dcde0d51fa936d86e1530cbc263bc4f3dd2a71b4c1232973631322d0"}],"observers":[{"id":"7D31532B52A5EA780B80359916B469E9CCA1D9FA2D0D015D3DD67E36CB35E88A","name":"Node 12"},{"id":"A7656605AC8F0AE03E6D219E84AC6690F7BAF7F0EE8E444A70898646BC58CB1F","name":"Node 13"}],"perObserverReach":{"74D71204F174AED6A9104EA4EA2148B093ED58E83CECCE3E1BDEFFCCEAAAFE7D":{"observer_name":"Node 14","rings":[{"hops":1,"nodes":[{"count":7,"distRange":"1-3","hop":"30","name":"Node 15","pubkey":"03363cdac563771cdfe37340213e46515911efeee712a4db50f6fba6ab608071"},{"count":4,"distRange":"1-6","hop":"1C","name":null,"pubkey":null}]},{"hops":2,"nodes":[{"count":4,"distRange":"2-4","hop":"9B","name":"Node 16","pubkey":"96a32f6dd9386af934894f123b9a560da004e857fc09942446178ccedafcf3a3"},{"count":4,"distRange":"2-3","hop":"57","name":"Node 17","pubkey":"9044c66c0a61da8a7c714b85a065014df81191351b2e4a1d4184b4f209b76d63"}]}]},"BAF0043FCCDDEDF29D105CDC6B37519096D6D1BEA2A30C54F7C3A12493880BCC":{"observer_name":"Node 18","rings":[{"hops":1,"nodes":[{"count":6,"distRange":"1-4","hop":"A3","name":"Node 19","pubkey":"6091d74d0644c60b4341f0018f1cdcc6346c41154a2a5defc5987ae0bfd56b34"},{"count":4,"distRange":"1-2","hop":"F8","name":"Node 20","pubkey":"8b5334f07a400a08cda720c7b2a27ad562f2e6312de9bcde5dfe940da802d807"}]},{"hops":2,"nodes":[{"count":2,"distRange":"2-3","hop":"EC","name":"Node 12","pubkey":"7d31532b52a5ea780b80359916b469e9cca1d9fa2d0d015d3dd67e36cb35e88a"},{"count":2,"distRange":"2-3","hop":"BE","name":null,"pubkey":null}]}]}},"topPairs":[{"count":38,"hopA":"33","hopB":"8A","nameA":"Node 21","nameB":null,"pubkeyA":"83a248387ffe78a3b2388e1bb62094e2546e359e11a470acf99350442500fc58","pubkeyB":null},{"count":38,"hopA":"33","hopB":"D6","nameA":"Node 21","nameB":null,"pubkeyA":"83a248387ffe78a3b2388e1bb62094e2546e359e11a470acf99350442500fc58","pubkeyB":null}],"topRepeaters":[{"count":220,"hop":"1000","name":"Node 7","pubkey":"936983eb45bdaa8e8b672e30bf488b201651c2b1a0cb3d9170c5a485e0a112da"},{"count":94,"hop":"D6","name":null,"pubkey":null}],"uniqueNodes":202},"chanData":{"activeChannels":20,"channelTimeline":[{"channel":"ch?","count":1,"hour":"2026-05-15T00"},{"channel":"#test","count":2,"hour":"2026-10-03T03"}],"channels":[{"encrypted":false,"hash":"217","lastActivity":"2026-10-03T03:56:07Z","messages":37,"name":"#test","senders":8},{"encrypted":true,"hash":"195","lastActivity":"2026-10-03T04:01:47Z","messages":26,"name":"Node 22","senders":0}],"decryptable":5,"msgLengths":[13,51],"topSenders":[{"count":26,"name":"Node 23"},{"count":13,"name":"Node 24"}]},"hashData":{"attributionMethod":"observer","distribution":{"1":365,"2":34,"3":1},"distributionByRepeaters":{"0":10,"1":46,"2":20,"3":0},"hourly":[{"1":1,"2":0,"3":0,"hour":"2026-05-15T00"},{"1":13,"2":1,"3":0,"hour":"2026-10-03T03"}],"multiByteCapability":[{"pubkey":"e828bfc7bdee3c1662d12104288bfbdd180fb2a8f11339e69d616a2410fb191a","name":"Node 25","role":"repeater","status":"confirmed","evidence":"advert","maxHashSize":2,"lastSeen":"2026-10-03T05:23:33Z"},{"pubkey":"4819920bf01f889c17af1d5297e3870d029cfda6cbdf8f15bcb08d1dbc2b2e7e","name":"Node 26","role":"repeater","status":"confirmed","evidence":"advert","maxHashSize":2,"lastSeen":"2026-10-03T05:27:31Z"}],"multiByteNodes":[{"hashSize":2,"lastSeen":"2026-10-03T05:25:39Z","name":"Node 20","packets":2,"pubkey":"8b5334f07a400a08cda720c7b2a27ad562f2e6312de9bcde5dfe940da802d807","role":"repeater"},{"hashSize":2,"lastSeen":"2026-10-03T04:52:26Z","name":"Node 27","packets":1,"pubkey":"5c23812892dc17ca7a4910845f7bb69dba26b2568ff73476c1d2515e44dbc8d6","role":"repeater"}],"topHops":[{"count":195,"hex":"10","name":"Node 7","pubkey":"936983eb45bdaa8e8b672e30bf488b201651c2b1a0cb3d9170c5a485e0a112da","size":1},{"count":94,"hex":"D6","name":null,"pubkey":null,"size":1}],"total":400},"collisionData":{"by_size":{"1":{"collisions":[],"one_byte_cells":{"00":[],"01":[]},"stats":{"collision_count":0,"nodes_for_byte":46,"pct_used":17.96875,"space_size":256,"total_nodes":202,"unique_prefixes":46,"using_this_size":59}},"2":{"collisions":[],"stats":{"collision_count":0,"nodes_for_byte":20,"pct_used":0.030517578125,"space_size":65536,"total_nodes":202,"unique_prefixes":20,"using_this_size":21},"two_byte_cells":{"00":{"group_nodes":[],"two_byte_map":{},"max_collision":0,"collision_count":0},"01":{"group_nodes":[],"two_byte_map":{},"max_collision":0,"collision_count":0}}},"3":{"collisions":[],"stats":{"collision_count":0,"nodes_for_byte":0,"pct_used":0,"space_size":16777216,"total_nodes":202,"unique_prefixes":0,"using_this_size":0}}},"inconsistent_nodes":[]}}
\ No newline at end of file
diff --git a/test-issue-124-rx-coverage-viewport.js b/test-issue-124-rx-coverage-viewport.js
index 147b26d73..8c01b8cd0 100644
--- a/test-issue-124-rx-coverage-viewport.js
+++ b/test-issue-124-rx-coverage-viewport.js
@@ -11,7 +11,9 @@
* through. Only `rx-coverage-view` is read or written, never the main map's
* `map-view`. days/rx stay in the URL as the viewport changes, an
* observer-only rx= link still fits that observer, and delayed responses
- * never touch a destroyed or replaced page.
+ * never touch a destroyed or replaced page. A slow response for an earlier
+ * days, observer or All never overwrites newer data or moves the map
+ * (#150, #172).
*/
'use strict';
const vm = require('vm');
@@ -335,6 +337,64 @@ function assertView(env, want, tag) {
assert(/NEWEST/.test(board.innerHTML), 'a failed older leaderboard request replaced the newest one: ' + board.innerHTML);
});
+ // #172: an observer-only link fits that observer once the map settles;
+ // the extent request for it (days=7) stays pending. The page's days bar,
+ // All button and leaderboard rows are wired so the tests can click them.
+ const extent = (lat) => ({ features: [{ geometry: { coordinates: [[[10, lat], [11, lat + 1]]] } }] });
+ async function startFit() {
+ const env = makeEnv({ hash: '#/rx-coverage?rx=abcdef' });
+ const bar = env.sandbox.document.getElementById('rxDays');
+ bar.addEventListener = (ev, fn) => { if (ev === 'click') bar.onclick = fn; };
+ env.pickDays = (d) => bar.onclick({ target: { closest: () => ({ dataset: { days: String(d) } }) } });
+ const all = env.sandbox.document.getElementById('rxAll');
+ all.addEventListener = (ev, fn) => { if (ev === 'click') env.clickAll = fn; };
+ const rows = ['abcdef', 'bbbbbb'].map((rx) => {
+ const r = { dataset: { rx, name: rx }, addEventListener(ev, fn) { if (ev === 'click') r.click = fn; } };
+ return r;
+ });
+ env.sandbox.document.getElementById('rxBoard').querySelectorAll = (sel) => (/rxb-row/.test(sel) ? rows : []);
+ env.clickRow = (rx) => rows.find((r) => r.dataset.rx === rx).click();
+ await mount(env, { center: [55.68, 12.57], zoom: 9 });
+ env.runTimers();
+ // the leaderboard renders, which wires All and the rows
+ const obs = (pubkey) => ({ pubkey, name: pubkey, score: 1, cells: 1, nodes: 1, receptions: 1 });
+ assert(env.respond(/rx-leaderboard/, { observers: [obs('abcdef'), obs('bbbbbb')] }), 'no leaderboard request');
+ await flush();
+ assert(env.pending.some((p) => /bbox=-90,-180,90,180.*days=7&rx=abcdef/.test(p.url)), 'no days=7 extent request for the observer');
+ return env;
+ }
+
+ await test('14a. #172: a pending observer fit is dropped when days changes', async () => {
+ const env = await startFit();
+ env.pickDays(30);
+ assert(env.respond(/bbox=-90,-180,90,180.*days=7&rx=abcdef/, extent(55)));
+ await flush();
+ assert.strictEqual(env.maps[0].fits.length, 0, 'the stale days=7 extent moved the map after the switch to days=30');
+ });
+
+ await test('14b. #172: a pending observer fit is dropped when All is picked', async () => {
+ const env = await startFit();
+ assert(typeof env.clickAll === 'function', 'All was not wired');
+ env.clickAll();
+ assert(env.respond(/bbox=-90,-180,90,180.*rx=abcdef/, extent(55)));
+ await flush();
+ assert.strictEqual(env.maps[0].fits.length, 0, 'the stale observer extent moved the map after All');
+ });
+
+ await test('14c. #172: a pending observer fit is dropped when another observer is picked; the newer one fits', async () => {
+ const env = await startFit();
+ env.clickRow('bbbbbb');
+ await flush();
+ assert(env.pending.some((p) => /bbox=-90,-180,90,180.*rx=bbbbbb/.test(p.url)), 'no extent request for the new observer');
+ assert(env.respond(/bbox=-90,-180,90,180.*rx=abcdef/, extent(55)));
+ await flush();
+ assert.strictEqual(env.maps[0].fits.length, 0, 'the stale abcdef extent moved the map after picking bbbbbb');
+ assert(env.respond(/bbox=-90,-180,90,180.*rx=bbbbbb/, extent(60)));
+ await flush();
+ assert.strictEqual(env.maps[0].fits.length, 1, 'the bbbbbb extent did not fit the map');
+ assert.strictEqual(env.maps[0].fits[0][0][0], 60, 'fitted to the wrong extent: ' + JSON.stringify(env.maps[0].fits));
+ });
+
await test('11. coverage filtering and leaderboard requests are unchanged', async () => {
const env = makeEnv({ hash: '#/rx-coverage?days=14' });
await mount(env, { center: [55.68, 12.57], zoom: 9 });
diff --git a/test-nav-drawer-1064-e2e.js b/test-nav-drawer-1064-e2e.js
index 414985b9e..86e35138d 100644
--- a/test-nav-drawer-1064-e2e.js
+++ b/test-nav-drawer-1064-e2e.js
@@ -273,20 +273,44 @@ async function edgeSwipe(page, x0, y0, x1, y1, steps) {
await wideCtx.close();
- // (k) #150: the header title (and close button) must be readable on the
- // header background in both themes. The light theme used to paint the
- // header with --surface-2 (white) under the white --nav-text title.
+ // (k) #150, #172: the header title and the close button must be readable
+ // (4.5:1) on the header background in both themes and with every
+ // customizer preset. The light theme used to paint the header with
+ // --surface-2 (white) under the white --nav-text title (#150), and the
+ // close button's --nav-text-muted fell below 4.5:1 on --nav-bg2 in the
+ // forest, sunset and mono presets (#172).
for (const theme of ['light', 'dark']) {
- await step(`(k) ${theme} theme: header title ≥ 4.5:1 and close button ≥ 3:1 on the header background`, async () => {
- const ctx = await browser.newContext({ viewport: { width: 1024, height: 800 }, colorScheme: theme });
- const p = await ctx.newPage();
- p.setDefaultTimeout(10000);
- await p.addInitScript((t) => { try { localStorage.setItem('meshcore-theme', t); } catch (_) {} }, theme);
- try {
- await p.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' });
- await p.waitForFunction(() => !!(window.__navDrawer && window.__navDrawer.open));
+ const ctx = await browser.newContext({ viewport: { width: 1024, height: 800 }, colorScheme: theme });
+ const p = await ctx.newPage();
+ p.setDefaultTimeout(10000);
+ await p.addInitScript((t) => { try { localStorage.setItem('meshcore-theme', t); localStorage.removeItem('cs-theme-overrides'); } catch (_) {} }, theme);
+ let presets = [];
+ await step(`(k) ${theme} theme: list the customizer presets`, async () => {
+ await p.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' });
+ await p.waitForFunction(() => !!(window.__navDrawer && window.__navDrawer.open && window._customizerV2 && window._customizerV2.initDone));
+ await p.click('#customizeToggle');
+ await p.waitForSelector('.cust-overlay:not(.hidden)');
+ const tabBtn = await p.$('.cust-tab[data-tab="theme"]');
+ if (tabBtn) await tabBtn.click();
+ presets = await p.$$eval('.cust-preset-btn[data-preset]', (els) => els.map((e) => e.getAttribute('data-preset')));
+ for (const id of ['default', 'forest', 'sunset', 'mono']) assert(presets.includes(id), 'preset ' + id + ' missing: ' + presets.join(','));
+ });
+ for (const id of presets) {
+ await step(`(k) ${theme} / ${id}: header title and close button ≥ 4.5:1 on the header background`, async () => {
+ await p.$eval(`.cust-preset-btn[data-preset="${id}"]`, (b) => b.click());
+ // the preset is applied: --nav-bg2 is the preset's (default: no overrides)
+ await p.waitForFunction((t) => {
+ const raw = localStorage.getItem('cs-theme-overrides');
+ if (!raw) return true;
+ const o = JSON.parse(raw);
+ const want = ((t === 'dark' ? o.themeDark : o.theme) || {}).navBg2;
+ const got = getComputedStyle(document.documentElement).getPropertyValue('--nav-bg2').trim();
+ return !want || got.toLowerCase() === want.toLowerCase();
+ }, theme);
await p.evaluate(() => window.__navDrawer.open());
await p.waitForSelector('[data-nav-drawer] .nav-drawer-title', { state: 'visible' });
+ // measure the settled colours, not a colour transition's midpoint
+ await p.waitForFunction(() => document.getAnimations().every((a) => a.playState !== 'running'));
const r = await p.evaluate(() => {
function rgba(s) {
const m = /rgba?\(([^)]+)\)/.exec(s);
@@ -326,15 +350,18 @@ async function edgeSwipe(page, x0, y0, x1, y1, steps) {
theme: document.documentElement.getAttribute('data-theme'),
bg: getComputedStyle(header).backgroundColor,
title: getComputedStyle(title).color,
+ close: getComputedStyle(close).color,
titleRatio: ratio(rgba(getComputedStyle(title).color), bg),
closeRatio: ratio(rgba(getComputedStyle(close).color), backdrop(close)),
};
});
+ await p.evaluate(() => window.__navDrawer.close());
assert(r.theme === theme, 'data-theme is ' + r.theme + ', want ' + theme);
assert(r.titleRatio >= 4.5, `title ${r.title} on header ${r.bg}: ${r.titleRatio.toFixed(2)}:1 < 4.5:1`);
- assert(r.closeRatio >= 3, `close button on header ${r.bg}: ${r.closeRatio.toFixed(2)}:1 < 3:1`);
- } finally { await ctx.close(); }
- });
+ assert(r.closeRatio >= 4.5, `close button ${r.close} on header ${r.bg}: ${r.closeRatio.toFixed(2)}:1 < 4.5:1`);
+ });
+ }
+ await ctx.close();
}
// ── Narrow viewport (Option A): drawer disabled ──