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
63 changes: 40 additions & 23 deletions public/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,40 @@
return `<svg viewBox="0 0 ${w} ${h}" style="width:${w}px;height:${h}px" role="img" aria-label="Sparkline showing trend of ${data.length} data points"><title>Sparkline showing trend of ${data.length} data points</title><polyline points="${pts}" fill="none" stroke="${color}" stroke-width="1.5"/></svg>`;
}

// 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 = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-trend-up"/></svg>';
if (!pph || !pph.length) return { html: `<h3>${icon} Packets / Hour</h3><p class="text-muted">No packets in this period.</p>`, 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 ? ` <span class="text-muted">since ${new Date(from).toISOString().slice(5, 10)}</span>` : '';
return { html: `<h3>${icon} Packets / ${name}${since}</h3>${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));
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -405,28 +442,7 @@
</div>

<div class="analytics-row">
<div class="analytics-card flex-1">
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-trend-up"/></svg> Packets / Hour</h3>
${(() => {
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)');
})()}
</div>
<div class="analytics-card flex-1">${packetsPerHourCard(rf.packetsPerHour, { window: d.window }).html}</div>
</div>

<div class="analytics-row">
Expand Down Expand Up @@ -3184,6 +3200,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet
window._analyticsRetransmissionBucketFor = retransmissionBucketFor;
window._analyticsRenderRetransmissionChart = renderRetransmissionChart;
window._analyticsScopeAdvertsByRoleHtml = scopeAdvertsByRoleHtml;
window._analyticsPacketsPerHourCard = packetsPerHourCard;
}

// ─── Neighbor Graph Tab ─────────────────────────────────────────────────────
Expand Down
2 changes: 2 additions & 0 deletions test-all.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions tests/unit/test-analytics-theme-refresh-before-data.js
Original file line number Diff line number Diff line change
@@ -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);
})();
88 changes: 88 additions & 0 deletions tests/unit/test-issue-2193-packets-per-hour.js
Original file line number Diff line number Diff line change
@@ -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(/<rect /g) || []).length;
const labels = (html) => (html.match(/<text [^>]*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);
Loading