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
6 changes: 3 additions & 3 deletions public/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@
function statusYellow() { return cssVar('--status-yellow') || '#eab308'; }
function statusRed() { return cssVar('--status-red') || '#ef4444'; }
function accentColor() { return cssVar('--accent') || '#4a9eff'; }
function snrColor(snr) { return snr > 6 ? statusGreen() : snr > 0 ? statusYellow() : statusRed(); }

Check warning on line 154 in public/analytics.js

View workflow job for this annotation

GitHub Actions / ✅ JavaScript Unit Tests & Lint

'snrColor' is defined but never used. Allowed unused vars must match /^_/u

// --- SVG helpers ---
function sparkSvg(data, color, w = 120, h = 32) {
Expand Down Expand Up @@ -216,7 +216,7 @@
<div class="analytics-filters">
<div id="analyticsRegionFilter" class="region-filter-container"></div>
<div id="analyticsAreaFilter" style="display:none"></div>
<select id="analyticsTimeWindow" class="analytics-time-window-select" data-testid="analytics-time-window" aria-label="Time window">
<select id="analyticsTimeWindow" class="time-window-select" data-testid="analytics-time-window" aria-label="Time window">
<option value="">All data</option>
<option value="1h">Last 1 hour</option>
<option value="24h">Last 24 hours</option>
Expand Down Expand Up @@ -625,7 +625,7 @@

// ===================== OVERVIEW =====================
function renderOverview(el, d) {
const rf = d.rfData, topo = d.topoData, ch = d.chanData, hs = d.hashData;

Check warning on line 628 in public/analytics.js

View workflow job for this annotation

GitHub Actions / ✅ JavaScript Unit Tests & Lint

'hs' is assigned a value but never used. Allowed unused vars must match /^_/u
el.innerHTML = `
<div class="stats-grid">
<div class="stat-card">
Expand Down Expand Up @@ -682,7 +682,7 @@
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) => {

Check warning on line 685 in public/analytics.js

View workflow job for this annotation

GitHub Actions / ✅ JavaScript Unit Tests & Lint

'i' is defined but never used. Allowed unused args must match /^_/u
const hh = h.hour.slice(11, 13); // "HH"
const hourNum = parseInt(hh, 10);
if (hourNum % labelInterval === 0) {
Expand Down Expand Up @@ -3194,10 +3194,10 @@
<p class="text-muted" style="font-size:12px;margin-top:4px">Units — <strong>Usefulness</strong>: the #672 composite (0.30 bridge + 0.25 coverage + 0.25 redundancy + 0.20 traffic). <strong>Traffic share</strong>: % of non-advert traffic relayed. <strong>Bridge/Coverage/Redundancy</strong>: 0–100% (network-normalized). <strong>Relays (1h/24h)</strong> and <strong>Adverts</strong>: packet counts. Axes auto-scale to the data.</p>
<div style="display:flex;gap:20px;flex-wrap:wrap;align-items:center;margin-bottom:14px">
<label style="display:flex;gap:6px;align-items:center">X axis
<select id="metricScatterX" class="analytics-time-window-select" aria-label="X axis metric">${opts(xKey)}</select>
<select id="metricScatterX" class="time-window-select" aria-label="X axis metric">${opts(xKey)}</select>
</label>
<label style="display:flex;gap:6px;align-items:center">Y axis
<select id="metricScatterY" class="analytics-time-window-select" aria-label="Y axis metric">${opts(yKey)}</select>
<select id="metricScatterY" class="time-window-select" aria-label="Y axis metric">${opts(yKey)}</select>
</label>
<span class="text-muted">${points.length} repeater${points.length === 1 ? '' : 's'}</span>
</div>
Expand Down Expand Up @@ -3490,7 +3490,7 @@
}

function createGraphState(data) {
const nodes = (data.nodes || []).map((n, i) => ({

Check warning on line 3493 in public/analytics.js

View workflow job for this annotation

GitHub Actions / ✅ JavaScript Unit Tests & Lint

'i' is defined but never used. Allowed unused args must match /^_/u
...n,
x: 450 + (Math.random() - 0.5) * 400,
y: 300 + (Math.random() - 0.5) * 300,
Expand Down Expand Up @@ -4379,7 +4379,7 @@
btn.addEventListener('click', () => {
const panel = document.getElementById(btn.dataset.target);
if (!panel) return;
const open = panel.style.display === 'none' || panel.style.display === '';

Check warning on line 4382 in public/analytics.js

View workflow job for this annotation

GitHub Actions / ✅ JavaScript Unit Tests & Lint

'open' is assigned a value but never used. Allowed unused vars must match /^_/u
// Toggle: if currently hidden ('none'), open; else close.
const currentlyHidden = panel.style.display === 'none';
panel.style.display = currentlyHidden ? 'block' : 'none';
Expand Down Expand Up @@ -4494,7 +4494,7 @@
await loadRFHealthData(el);
}

async function loadRFHealthData(el) {

Check warning on line 4497 in public/analytics.js

View workflow job for this annotation

GitHub Actions / ✅ JavaScript Unit Tests & Lint

'el' is defined but never used. Allowed unused args must match /^_/u
const grid = document.getElementById('rfHealthGrid');
const detail = document.getElementById('rfHealthDetail');

Expand Down Expand Up @@ -4585,7 +4585,7 @@
}
}

async function loadRFSparkline(observerId) {

Check warning on line 4588 in public/analytics.js

View workflow job for this annotation

GitHub Actions / ✅ JavaScript Unit Tests & Lint

'loadRFSparkline' is defined but never used. Allowed unused vars must match /^_/u
const { since, until } = rfHealthTimeRangeToParams(_rfHealthState.range, _rfHealthState.customFrom, _rfHealthState.customTo);
try {
const data = await api(`/observers/${observerId}/metrics?since=${encodeURIComponent(since)}&until=${encodeURIComponent(until)}`);
Expand Down Expand Up @@ -4762,7 +4762,7 @@
return svg;
}

function rfXAxisLabels(data, sx, h, pad) {

Check warning on line 4765 in public/analytics.js

View workflow job for this annotation

GitHub Actions / ✅ JavaScript Unit Tests & Lint

'pad' is defined but never used. Allowed unused args must match /^_/u
let svg = '';
const xTicks = Math.min(6, data.length);
for (let i = 0; i < xTicks; i++) {
Expand Down
29 changes: 18 additions & 11 deletions public/perf.js
Original file line number Diff line number Diff line change
Expand Up @@ -292,12 +292,26 @@ function renderVersionCard(health) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
};

// The server overview row (requests, latency, uptime, slow queries, version).
// It sits above the WebSocket history in its own container, so the history's
// Period select stays outside the five-second #perfContent rebuild.
function renderPerfOverview(server, health) {
return `<div style="display:flex;gap:16px;flex-wrap:wrap;margin:16px 0;">
<div class="perf-card"><div class="perf-num">${escapeHtml(server.totalRequests)}</div><div class="perf-label">Total Requests</div></div>
<div class="perf-card"><div class="perf-num">${escapeHtml(server.avgMs)}ms</div><div class="perf-label">Avg Response</div></div>
<div class="perf-card"><div class="perf-num">${escapeHtml(health ? health.uptimeHuman : Math.round(server.uptime / 60) + 'm')}</div><div class="perf-label">Uptime</div></div>
<div class="perf-card"><div class="perf-num">${server.slowQueries.length}</div><div class="perf-label">Slow (&gt;100ms)</div></div>
${renderVersionCard(health)}
</div>`;
}

async function render(app) {
// The selector stays outside perfContent's existing five-second DOM
// rebuild, preserving keyboard focus and an open native select menu.
app.innerHTML = '<div id="perfWrapper" style="padding:16px 24px;"><h2><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lightning"/></svg> Performance Dashboard</h2>' +
app.innerHTML = '<div id="perfWrapper" style="padding:16px 24px;"><h2><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lightning"/></svg> Performance Dashboard</h2><div id="perfOverview"></div>' +
'<section id="wsHistorySection" aria-labelledby="wsHistoryHeading"><div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;">' +
'<h3 id="wsHistoryHeading">WebSocket clients</h3><label for="wsHistoryRange">Period <select id="wsHistoryRange" style="min-height:44px;padding:6px;">' +
'<h3 id="wsHistoryHeading"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chart-line"/></svg> WebSocket clients</h3>' +
'<label for="wsHistoryRange" class="perf-range-control"><span class="region-filter-label">Period</span><select id="wsHistoryRange" class="time-window-select">' +
'<option value="24h">24 hours</option><option value="7d">7 days</option><option value="30d">30 days</option></select></label></div>' +
'<div id="wsHistoryContent">Loading history…</div></section><div id="perfContent">Loading...</div></div>';
const selector = document.getElementById('wsHistoryRange');
Expand Down Expand Up @@ -346,18 +360,11 @@ function renderVersionCard(health) {
const historyEl = document.getElementById('wsHistoryContent');
if (historyEl) historyEl.innerHTML = renderWSClientHistory(wsHistory, health && health.websocket ? health.websocket.clients : null, range, false) ||
'<p class="text-muted">History is temporarily unavailable. Retrying on the next refresh.</p>';
const overviewEl = document.getElementById('perfOverview');
if (overviewEl) overviewEl.innerHTML = renderPerfOverview(server, health);

let html = '';

// Server overview
html += `<div style="display:flex;gap:16px;flex-wrap:wrap;margin:16px 0;">
<div class="perf-card"><div class="perf-num">${escapeHtml(server.totalRequests)}</div><div class="perf-label">Total Requests</div></div>
<div class="perf-card"><div class="perf-num">${escapeHtml(server.avgMs)}ms</div><div class="perf-label">Avg Response</div></div>
<div class="perf-card"><div class="perf-num">${escapeHtml(health ? health.uptimeHuman : Math.round(server.uptime / 60) + 'm')}</div><div class="perf-label">Uptime</div></div>
<div class="perf-card"><div class="perf-num">${server.slowQueries.length}</div><div class="perf-label">Slow (&gt;100ms)</div></div>
${renderVersionCard(health)}
</div>`;

// System health (memory, event loop / go runtime, WS)
if (health) {
const isGo = health.engine === 'go';
Expand Down
15 changes: 10 additions & 5 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -3714,12 +3714,15 @@ button.region-pill-active:hover { opacity: 0.85; color: var(--text-on-accent); }
margin-right: 2px; user-select: none; white-space: nowrap; flex-shrink: 0;
}
.analytics-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 8px 0; }
.analytics-time-window-select {
/* The one time-window / metric <select> style: Analytics filters and the
Perf page's WebSocket-history Period control. */
.time-window-select {
height: 34px; min-height: 34px; padding: 0 10px; border-radius: 6px; box-sizing: border-box;
font-size: 13px; font-weight: 500; cursor: pointer; border: 1px solid var(--border);
background: var(--input-bg); color: var(--text); transition: border-color 0.15s;
background: var(--input-bg); color: var(--text); font-family: var(--font); transition: border-color 0.15s;
}
.analytics-time-window-select:hover { border-color: var(--accent); }
.time-window-select:hover { border-color: var(--accent); }
.perf-range-control { display: inline-flex; align-items: center; gap: 8px; }
.region-dropdown-wrap { position: relative; display: inline-flex; align-items: center; }
.region-dropdown-trigger {
display: inline-flex; align-items: center; padding: 6px 10px; border-radius: 6px;
Expand Down Expand Up @@ -4626,12 +4629,14 @@ button.region-pill-active:hover { opacity: 0.85; color: var(--text-on-accent); }
.node-count-pill,
.analytics-time-range button,
.detail-back-btn,
.filter-toggle-btn {
.filter-toggle-btn,
.time-window-select {
min-height: 48px;
min-width: 48px;
}
.filter-bar input,
.filter-bar select {
.filter-bar select,
.time-window-select {
height: 48px;
}
.region-dropdown-trigger,
Expand Down
69 changes: 60 additions & 9 deletions test-perf-ws-clients.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ async function test(name, fn) {
function makeSandbox(hash = '#/perf') {
let capturedHtml = '';
let historyHtml = '';
let overviewHtml = '';
let now = Date.now();
class ClockDate extends Date { static now() { return now; } }
const handlers = {};
Expand All @@ -25,6 +26,7 @@ function makeSandbox(hash = '#/perf') {
getElementById: (id) => {
if (id === 'perfContent') return { set innerHTML(v) { capturedHtml = v; } };
if (id === 'wsHistoryContent') return { set innerHTML(v) { historyHtml = v; } };
if (id === 'perfOverview') return { set innerHTML(v) { overviewHtml = v; } };
if (id === 'wsHistoryRange') return rangeSelect;
if (id === 'perfRefresh' || id === 'perfReset') return { addEventListener: (ev, fn) => { handlers[id] = fn; } };
return null;
Expand All @@ -45,7 +47,7 @@ function makeSandbox(hash = '#/perf') {
ctx.window.document = ctx.document;
ctx.globalThis = ctx;
vm.runInNewContext(fs.readFileSync('public/perf.js', 'utf8'), ctx);
return { ctx, handlers, rangeSelect, advanceNow: ms => { now += ms; }, getHtml: () => capturedHtml, getHistoryHtml: () => historyHtml };
return { ctx, handlers, rangeSelect, advanceNow: ms => { now += ms; }, getHtml: () => capturedHtml, getHistoryHtml: () => historyHtml, getOverviewHtml: () => overviewHtml };
}

const flush = async () => { for (let i = 0; i < 10; i++) await new Promise(r => setImmediate(r)); };
Expand Down Expand Up @@ -392,10 +394,12 @@ const PERF_FIXTURE = () => ({
ctx.fetch = (url) => (url === '/api/perf/ws-clients' || url === '/api/health')
? Promise.reject(new Error('boom'))
: Promise.resolve({ ok: true, json: () => Promise.resolve(url === '/api/perf' ? perf : {}) });
ctx.document.getElementById = (id) => id === 'perfContent' ? { set innerHTML(v) { ctx.__html = v; } } : null;
ctx.document.getElementById = (id) => id === 'perfContent' ? { set innerHTML(v) { ctx.__html = v; } }
: id === 'perfOverview' ? { set innerHTML(v) { ctx.__overview = v; } } : null;
ctx.pages.perf.init({ set innerHTML(v) {} });
await flush();
assert.ok(String(ctx.__html).includes('Total Requests'), 'rest of the page still renders');
assert.ok(String(ctx.__overview).includes('Total Requests'), 'the overview still renders');
assert.ok(String(ctx.__html).includes('id="perfRefresh"'), 'rest of the page still renders');
});

await test('refresh: a 404/500 from ws-clients hides the card instead of hanging on "Collecting data…" (#433 F2)', async () => {
Expand All @@ -410,13 +414,15 @@ const PERF_FIXTURE = () => ({
}
return Promise.resolve({ ok: true, json: () => Promise.resolve(url === '/api/perf' ? perf : {}) });
};
ctx.document.getElementById = (id) => id === 'perfContent' ? { set innerHTML(v) { ctx.__html = v; } } : null;
ctx.document.getElementById = (id) => id === 'perfContent' ? { set innerHTML(v) { ctx.__html = v; } }
: id === 'perfOverview' ? { set innerHTML(v) { ctx.__overview = v; } } : null;
ctx.pages.perf.init({ set innerHTML(v) {} });
await flush();
const html = String(ctx.__html);
assert.ok(!getHistoryHtml().includes('<svg class="ws-history'), 'no chart expected');
assert.ok(!/collecting/i.test(getHistoryHtml()), '404 must not look like "still collecting"');
assert.ok(html.includes('Total Requests'), 'rest of the page still renders');
assert.ok(String(ctx.__overview).includes('Total Requests'), 'the overview still renders');
assert.ok(html.includes('id="perfRefresh"'), 'rest of the page still renders');
});

await test('refresh: the ws-clients ring is not re-fetched on every 5s tick (#433 F6)', async () => {
Expand Down Expand Up @@ -474,7 +480,7 @@ const PERF_FIXTURE = () => ({
// instead of being silently dropped.
releasers[0]();
await flush();
assert.ok(sb.getHtml().includes('Total Requests'), 'an overtaken-but-first-to-finish refresh must still render, not stay on Loading…');
assert.ok(sb.getOverviewHtml().includes('Total Requests'), 'an overtaken-but-first-to-finish refresh must still render, not stay on Loading…');
});

await test('refresh: an older response that completes after a newer one has rendered must not overwrite it (#463)', async () => {
Expand Down Expand Up @@ -502,10 +508,10 @@ const PERF_FIXTURE = () => ({
assert.strictEqual(releasers.length, 2, 'two overlapping refreshes');
releasers[1]();
await flush();
assert.ok(sb.getHtml().includes('2222'), 'the newer response renders first');
assert.ok(sb.getOverviewHtml().includes('2222'), 'the newer response renders first');
releasers[0]();
await flush();
assert.ok(sb.getHtml().includes('2222') && !sb.getHtml().includes('1111'), 'the older response must not overwrite the newer render');
assert.ok(sb.getOverviewHtml().includes('2222') && !sb.getOverviewHtml().includes('1111'), 'the older response must not overwrite the newer render');
});

await test('refresh: re-entering the page (init) forces a fresh ws-clients fetch, not a stale cache', async () => {
Expand All @@ -517,7 +523,8 @@ const PERF_FIXTURE = () => ({
if (url === '/api/perf/ws-clients') { wsCalls++; return Promise.resolve({ ok: true, json: () => Promise.resolve({ intervalSec: 60, samples: mk(10, i => i) }) }); }
return Promise.resolve({ ok: true, json: () => Promise.resolve(url === '/api/perf' ? perf : {}) });
};
ctx.document.getElementById = (id) => id === 'perfContent' ? { set innerHTML(v) { ctx.__html = v; } } : null;
ctx.document.getElementById = (id) => id === 'perfContent' ? { set innerHTML(v) { ctx.__html = v; } }
: id === 'perfOverview' ? { set innerHTML(v) { ctx.__overview = v; } } : null;
ctx.pages.perf.init({ set innerHTML(v) {} });
await flush();
ctx.pages.perf.destroy();
Expand All @@ -526,6 +533,50 @@ const PERF_FIXTURE = () => ({
assert.strictEqual(wsCalls, 2, 'leaving and re-entering the page should not reuse a cache from the previous visit');
});

await test('Period control uses the shared themed select + label classes, no inline styling', async () => {
const { ctx } = makeSandbox();
let appHtml = '';
ctx.pages.perf.init({ set innerHTML(v) { appHtml = v; } });
await flush();
const select = (appHtml.match(/<select id="wsHistoryRange"[^>]*>/) || [''])[0];
assert.ok(/class="time-window-select"/.test(select), 'select must use .time-window-select: ' + select);
assert.ok(!/style=/.test(select), 'select must not carry inline styles: ' + select);
assert.ok(/<span class="region-filter-label">Period<\/span>/.test(appHtml), 'label text must use .region-filter-label');
const heading = (appHtml.match(/<h3 id="wsHistoryHeading">[\s\S]*?<\/h3>/) || [''])[0];
assert.ok(/<svg class="ph-icon" aria-hidden="true">/.test(heading), 'heading needs the same decorative icon as the other sections');
assert.ok(/WebSocket clients<\/h3>$/.test(heading), 'accessible name stays "WebSocket clients"');
});

await test('layout: the overview cards sit above the WebSocket history, outside the 5s #perfContent rebuild', async () => {
const sb = makeSandbox();
sb.ctx.fetch = (url) => url === '/api/health'
? Promise.reject(new Error('no health'))
: Promise.resolve({ ok: true, json: () => Promise.resolve(url === '/api/perf' ? PERF_FIXTURE() : {}) });
let appHtml = '';
sb.ctx.pages.perf.init({ set innerHTML(v) { appHtml = v; } });
await flush();
const order = ['id="perfOverview"', 'id="wsHistorySection"', 'id="perfContent"'].map(m => appHtml.indexOf(m));
assert.ok(order.every(i => i >= 0) && order[0] < order[1] && order[1] < order[2], 'expected overview, then WS history, then the rest: ' + order);
assert.ok(sb.getOverviewHtml().includes('Total Requests'), 'overview cards render into #perfOverview');
assert.ok(!sb.getHtml().includes('Total Requests'), 'overview cards must not also render in #perfContent');
});

await test('time-window select styling is one shared rule used by Analytics and Perf', () => {
const css = fs.readFileSync('public/style.css', 'utf8');
const analytics = fs.readFileSync('public/analytics.js', 'utf8');
assert.ok(!/analytics-time-window-select/.test(css + analytics), 'old analytics-only class name must be gone');
assert.ok(/<select id="analyticsTimeWindow" class="time-window-select"/.test(analytics), 'Analytics time window must use the shared class');
const rule = (css.match(/(^|\n)\.time-window-select\s*\{[^}]*\}/) || [''])[0];
for (const decl of [/background:\s*var\(--input-bg\)/, /(^|[\s;])color:\s*var\(--text\)/, /border:\s*1px solid var\(--border\)/, /font-family:\s*var\(--font\)/]) {
assert.ok(decl.test(rule), 'shared rule missing ' + decl);
}
// Touch size comes from the #630-1 48px house-minimum block, not a one-off rule.
const houseBlock = (css.match(/#630-1: Touch targets[\s\S]*?\n\}/) || [''])[0];
assert.ok(/\.time-window-select\s*[,{][\s\S]*?min-height:\s*48px/.test(houseBlock), 'select must be in the #630-1 48px touch block');
assert.ok(/\.time-window-select\s*\{\s*height:\s*48px/.test(houseBlock), 'select height must be 48px on touch');
assert.ok(!/44px/.test(rule), 'no one-off 44px size on the shared rule');
});

console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
})();
Loading
Loading