diff --git a/public/analytics.js b/public/analytics.js index 7851bdb2c..b69b451f4 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -71,6 +71,40 @@ return `Sparkline showing trend of ${data.length} data points`; } + // Packets / Hour (#2193): a long history rolls up to coarser bars (at most + // 180, so about 4 units each in barChart's plot) with about 10 labels. + // Under "All data" (no opts.window) the chart starts on the first UTC day + // with at least 2% of a typical recent day (the median of the seven days + // before the last one), at its first hour with a tenth of a typical hour: a + // short store also keeps a few old packets that were heard again, weeks + // before the rest. The card then says where it starts. In a picked window + // the server has already cut the data to it, so nothing is cropped. + function packetsPerHourCard(pph, opts) { + const icon = ''; + if (!pph || !pph.length) return { html: `

${icon} Packets / Hour

No packets in this period.

`, from: null }; + const at = (h) => Date.parse(h.hour.replace(' ', 'T') + ':00:00Z'); + const days = new Map(); + pph.forEach((h) => days.set(h.hour.slice(0, 10), (days.get(h.hour.slice(0, 10)) || 0) + h.count)); + const recent = [...days.values()].slice(-8, -1).sort((a, b) => a - b); + const floor = (recent.length ? recent[recent.length >> 1] : 0) * 0.02; + const day = [...days.keys()].find((d) => days.get(d) >= floor); + const first = at(pph[0]); + const start = opts && opts.window ? first : at(pph.find((h) => h.hour.slice(0, 10) >= day && h.count >= floor * 5 / 24) || pph[0]); + const last = at(pph[pph.length - 1]), spanH = (last - start) / 3600e3 + 1; + const [bh, name] = [[1, 'Hour'], [6, '6 hours'], [24, 'Day']].find(([b]) => spanH / b <= 180) || [168, 'Week']; + const step = bh * 3600e3, from = Math.floor(start / step) * step, counts = []; + for (let t = from; t <= last; t += step) counts.push(0); + pph.forEach((h) => { const k = Math.floor((at(h) - from) / step); if (k >= 0) counts[k] += h.count; }); + const every = [1, 3, 6, 12, 24, 48, 72, 168, 336, 672, 1344].find((e) => e >= bh && counts.length * bh / e <= 10) || 2688; + const labels = counts.map((_, i) => { + const t = from + i * step, d = new Date(t), hh = d.getUTCHours(); + if ((t / 3600e3) % every) return ''; + return spanH > 48 && (every >= 24 || hh === 0) ? d.toISOString().slice(5, 10) : String(hh).padStart(2, '0') + 'h'; + }); + const since = from > first ? ` since ${new Date(from).toISOString().slice(5, 10)}` : ''; + return { html: `

${icon} Packets / ${name}${since}

${barChart(counts, labels, 'var(--accent)')}`, from }; + } + function barChart(data, labels, colors, w = 800, h = 220, pad = 40) { const max = Math.max(...data, 1); const barW = Math.max(1, Math.min((w - pad * 2) / data.length - 2, 30)); @@ -260,6 +294,9 @@ // 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; } + // Before the answers land there is nothing to re-render; loadAnalytics() + // renders the tab with the current theme when they do. + if (!_analyticsData.rfData) return; renderTab(_currentTab); }; window.addEventListener('theme-refresh', _themeRefreshHandler); @@ -299,7 +336,7 @@ api('/analytics/hash-collisions' + sepBase, { ttl: CLIENT_TTL.analyticsRF }), api('/analytics/relay-airtime-share' + sepWin, { ttl: CLIENT_TTL.analyticsRF }).catch(() => ({ rows: [] })), ]); - _analyticsData = { hashData, rfData, topoData, chanData, collisionData, airtimeData }; + _analyticsData = { hashData, rfData, topoData, chanData, collisionData, airtimeData, window: twVal }; renderTab(_currentTab); } catch (e) { document.getElementById('analyticsContent').innerHTML = @@ -405,28 +442,7 @@
-
-

Packets / Hour

- ${(() => { - const pph = rf.packetsPerHour; - const counts = pph.map(h => h.count); - // Decimate x-axis labels to avoid overlap - const totalHours = pph.length; - // Pick label interval: <=24h show every 6h, <=72h every 12h, else every 24h - const labelInterval = totalHours <= 24 ? 6 : totalHours <= 72 ? 12 : 24; - const labels = pph.map((h, i) => { - const hh = h.hour.slice(11, 13); // "HH" - const hourNum = parseInt(hh, 10); - if (hourNum % labelInterval === 0) { - // For multi-day ranges, show date on 00h boundaries - if (totalHours > 48 && hourNum === 0) return h.hour.slice(5, 10); - return hh + 'h'; - } - return ''; // skip label - }); - return barChart(counts, labels, 'var(--accent)'); - })()} -
+
${packetsPerHourCard(rf.packetsPerHour, { window: d.window }).html}
@@ -3184,6 +3200,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet window._analyticsRetransmissionBucketFor = retransmissionBucketFor; window._analyticsRenderRetransmissionChart = renderRetransmissionChart; window._analyticsScopeAdvertsByRoleHtml = scopeAdvertsByRoleHtml; + window._analyticsPacketsPerHourCard = packetsPerHourCard; } // ─── Neighbor Graph Tab ───────────────────────────────────────────────────── diff --git a/test-all.sh b/test-all.sh index 2b17ee0a7..88e744621 100755 --- a/test-all.sh +++ b/test-all.sh @@ -122,6 +122,8 @@ node tests/unit/test-issue-1668-m3-typography.js node tests/unit/test-issue-1668-m4-per-route.js node tests/unit/test-issue-1697-mqtt-mobile-e2e.js node tests/unit/test-issue-1699-retransmission-chart.js +node tests/unit/test-issue-2193-packets-per-hour.js +node tests/unit/test-analytics-theme-refresh-before-data.js node tests/unit/test-issue-1705-subpath-contrast.js node tests/unit/test-issue-1753-copy-url-slash.js node tests/unit/test-issue-1770-mobile-row-clamp.js diff --git a/tests/unit/test-analytics-theme-refresh-before-data.js b/tests/unit/test-analytics-theme-refresh-before-data.js new file mode 100644 index 000000000..e3e26c785 --- /dev/null +++ b/tests/unit/test-analytics-theme-refresh-before-data.js @@ -0,0 +1,76 @@ +/** + * Analytics: a theme-refresh that fires before the analytics answers land. + * + * Every page load fires a theme-refresh about 300ms after the theme resolves. + * On a slow server the answers land later, and re-rendering the Overview from + * the empty data object threw (reading 'totalTransmissions'). The handler + * must leave the tab alone until loadAnalytics() renders it. + * + * Usage: node tests/unit/test-analytics-theme-refresh-before-data.js + */ +'use strict'; +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +let passed = 0, failed = 0; +function assert(cond, msg) { + if (cond) { passed++; console.log(' ✓ ' + msg); } + else { failed++; console.error(' ✗ ' + msg); } +} + +function el() { + return { + style: {}, dataset: {}, value: '', innerHTML: '', + classList: { add() {}, remove() {}, toggle() {}, contains: () => false }, + addEventListener() {}, removeEventListener() {}, + querySelector: () => null, querySelectorAll: () => [], + }; +} +const els = {}; +let contentLookups = 0; +const listeners = {}; +const ctx = { + console, Math, JSON, Date, Number, String, Array, Object, Set, Map, RegExp, URLSearchParams, Promise, + setTimeout, clearTimeout, requestAnimationFrame: () => 0, + getComputedStyle: () => ({ getPropertyValue: () => '' }), + localStorage: { getItem: () => null, setItem() {}, removeItem() {} }, + location: { hash: '#/analytics' }, + addEventListener: (type, fn) => { listeners[type] = fn; }, + removeEventListener() {}, + initTabBar() {}, CLIENT_TTL: {}, + // The answers never land: the refresh below fires while they are pending. + api: () => new Promise(() => {}), + RegionFilter: { init() {}, onChange() {}, regionQueryString: () => '' }, + AreaFilter: { init() {}, onChange() {}, areaQueryString: () => '' }, +}; +let page = null; +ctx.registerPage = (name, p) => { if (name === 'analytics') page = p; }; +ctx.window = ctx; +ctx.document = { + documentElement: {}, createElement: el, + addEventListener() {}, removeEventListener() {}, querySelector: () => null, querySelectorAll: () => [], + getElementById: (id) => { + if (id === 'analyticsContent') contentLookups++; + return els[id] || (els[id] = el()); + }, +}; +vm.createContext(ctx); +vm.runInContext(fs.readFileSync(path.join(__dirname, '..', '..', 'public/analytics.js'), 'utf8'), ctx); + +const rejections = []; +process.on('unhandledRejection', (e) => rejections.push(e)); + +(async () => { + await page.init(el()); + const refresh = listeners['theme-refresh']; + assert(typeof refresh === 'function', 'init registers a theme-refresh handler'); + const before = contentLookups; + let threw = null; + try { refresh(); } catch (e) { threw = e; } + await new Promise((r) => setTimeout(r, 20)); + assert(!threw && rejections.length === 0, 'a refresh before the data lands does not throw (' + (threw || rejections[0] || 'ok') + ')'); + assert(contentLookups === before, 'and does not re-render the tab'); + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); +})(); diff --git a/tests/unit/test-issue-2193-packets-per-hour.js b/tests/unit/test-issue-2193-packets-per-hour.js new file mode 100644 index 000000000..b85037702 --- /dev/null +++ b/tests/unit/test-issue-2193-packets-per-hour.js @@ -0,0 +1,88 @@ +/** + * #2193: the Packets / Hour chart on long-retention instances. + * + * Drives window._analyticsPacketsPerHourCard(pph) from public/analytics.js: + * months of hours roll up to daily bars with few labels, missing hours count + * as zero, and the chart starts where the data does, not at a thin run of + * old packets a short store keeps because they were heard again. + * + * Usage: node tests/unit/test-issue-2193-packets-per-hour.js + */ +'use strict'; +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +let passed = 0, failed = 0; +function assert(cond, msg) { + if (cond) { passed++; console.log(' ✓ ' + msg); } + else { failed++; console.error(' ✗ ' + msg); } +} + +const ctx = { + console, Math, JSON, Date, Number, String, Array, Object, Set, Map, RegExp, URLSearchParams, + setTimeout, clearTimeout, requestAnimationFrame: () => 0, + getComputedStyle: () => ({ getPropertyValue: () => '' }), + localStorage: { getItem: () => null, setItem() {}, removeItem() {} }, + registerPage: () => {}, api: async () => ({}), CLIENT_TTL: {}, + RegionFilter: { regionQueryString: () => '' }, AreaFilter: { areaQueryString: () => '' }, +}; +ctx.window = ctx; +ctx.document = { + documentElement: {}, createElement: () => ({ style: {}, addEventListener() {} }), + addEventListener() {}, removeEventListener() {}, querySelector: () => null, querySelectorAll: () => [], getElementById: () => null, +}; +vm.createContext(ctx); +vm.runInContext(fs.readFileSync(path.join(__dirname, '..', '..', 'public/analytics.js'), 'utf8'), ctx); +const card = ctx._analyticsPacketsPerHourCard; +if (typeof card !== 'function') { console.error(' ✗ _analyticsPacketsPerHourCard not exposed'); process.exit(1); } + +// n hours ending 2026-10-12T17; count(k) gives each hour's count (0 = left +// out, the way the server sends empty hours). +const END = Date.UTC(2026, 9, 12, 17); +function hours(n, count) { + const out = []; + for (let k = 0; k < n; k++) { + const c = count ? count(k, n - 1 - k) : 20 + (k % 24); + if (c) out.push({ hour: new Date(END - (n - 1 - k) * 3600e3).toISOString().slice(0, 13), count: c }); + } + return out; +} +const bars = (html) => (html.match(/ (html.match(/]*font-size="9"/g) || []).length; + +let c = card(hours(2932)); +assert(/Packets \/ Day/.test(c.html) && bars(c.html) === 123, '122 days: 123 daily bars, titled Packets / Day (got ' + bars(c.html) + ')'); +assert(labels(c.html) >= 4 && labels(c.html) <= 12, 'with 4 to 12 labels (got ' + labels(c.html) + ')'); + +c = card(hours(168)); +assert(/Packets \/ Hour/.test(c.html) && bars(c.html) === 168, '7 days: 168 hourly bars (got ' + bars(c.html) + ')'); + +c = card(hours(48, (k) => (k >= 10 && k < 15 ? 0 : 5))); +assert(bars(c.html) === 48, 'five missing hours drawn as zero: 48 bars (got ' + bars(c.html) + ')'); + +// A 7-day store: weeks of a few old packets a day, then 8 days of data. +c = card(hours(44 * 24, (k, back) => (back < 8 * 24 ? 300 + (k % 140) : k % 24 === 9 ? 1 + (k % 30) : 0))); +assert(c.from === Date.UTC(2026, 9, 4, 18), 'starts on the hour the data does, not at the first straggler (from ' + new Date(c.from || 0).toISOString() + ')'); + +c = card(hours(60 * 24, (k) => (k >= 240 && k < 360 ? 0 : 50))); +assert(c.from === Date.UTC(2026, 7, 13), 'an outage after the data starts is not taken for its start (from ' + new Date(c.from || 0).toISOString() + ')'); + +assert(/No packets/.test(card([]).html), 'no data says so'); + +// The crop is for "All data", where a short store's stragglers sit weeks +// before the rest. In a window the user picked, the server has already cut +// to it, so a quieter early stretch is real and stays: 90 days, the first +// 30 at 1 packet an hour, then 100 an hour. +const quietStart = hours(90 * 24, (k) => (k < 30 * 24 ? 1 : 100)); +c = card(quietStart, { window: '30d' }); +assert(bars(c.html) === 91 && !/since/.test(c.html), 'in a picked window nothing is cropped: 91 daily bars (got ' + bars(c.html) + ')'); + +// Under "All data" a crop is said on the card, so it is never invisible. +c = card(hours(44 * 24, (k, back) => (back < 8 * 24 ? 300 + (k % 140) : k % 24 === 9 ? 1 + (k % 30) : 0)), { window: '' }); +assert(/since 10-04/.test(c.html), 'a cropped chart says where it starts (since 10-04)'); +c = card(hours(2932), { window: '' }); +assert(!/since/.test(c.html), 'and an uncropped one says nothing'); + +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed > 0 ? 1 : 0);