Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions cmd/ingestor/iata_drop_warn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
72 changes: 58 additions & 14 deletions public/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<div class="text-center text-muted" style="padding:40px">Loading analytics…</div>';
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; }
Expand Down Expand Up @@ -173,7 +199,7 @@
</div>
</div>
<div id="analyticsContent" class="analytics-content" aria-live="polite">
<div class="text-center text-muted" style="padding:40px">Loading analytics…</div>
${LOADING_HTML}
</div>
</div>`;

Expand Down Expand Up @@ -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(); // "&region=..." or ""
Expand All @@ -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 =
`<div class="text-muted" role="alert" aria-live="polite" style="padding:40px">Failed to load: ${e.message}</div>`;
if (gen !== _loadGen) return;
const ms = _retryAfterDelayMs(e);
if (e && e.status === 503 && Date.now() - startedAt + ms <= ANALYTICS_WARMUP_MAX_MS) {
_showLoadStatus('<div class="text-center text-muted" role="status" aria-live="polite" style="padding:40px">' +
'Analytics are still loading on the server after a restart.' +
'<div style="font-size:12px;margin-top:8px">Retrying in ' + Math.round(ms / 1000) + 's.</div></div>');
_loadRetryTimer = setTimeout(function () {
_loadRetryTimer = null;
if (gen === _loadGen) loadAnalytics(startedAt);
}, ms);
return;
}
_showLoadStatus(`<div class="text-muted" role="alert" aria-live="polite" style="padding:40px">Failed to load: ${esc(e && e.message)}</div>`);
}
}

Expand All @@ -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;
Expand Down Expand Up @@ -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 = '<div class="text-center text-muted" id="distanceBuilding" role="status" style="padding:40px">' +
'Building the distance index…' +
'<div style="font-size:12px;margin-top:8px">This runs once after the server starts. Retrying in ' + Math.round(ms / 1000) + 's.</div></div>';
Expand Down Expand Up @@ -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') {
Expand Down
28 changes: 21 additions & 7 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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
Expand All @@ -166,14 +169,19 @@ 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;
let notified = 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; }
Expand All @@ -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++;
Expand Down Expand Up @@ -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;
}

Expand Down
4 changes: 3 additions & 1 deletion public/nav-drawer.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 9 additions & 6 deletions public/rx-coverage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -228,18 +230,18 @@
});
});
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
// (fetched with a world bbox so it's independent of the current view), then the
// 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) {
Expand All @@ -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();
});
}
Expand All @@ -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();
Expand Down
Loading
Loading