feat: animated live gauge circles (PageSpeed-style)

Backend:
- Re-add --out json=- but properly route: JSON lines → metric
  aggregation, non-JSON lines → text log (summary text lands in log)
- Aggregate http_req_duration / http_reqs / http_req_failed / checks
  per test into live state (capped 50k samples)
- Broadcast { metrics: {...} } SSE events every second with
  score, checksRate, httpOkRate, p90/p95/p99, req/s etc.
- computeScore() composite 0-100 based on p95 + error rate

Frontend:
- 3 animated SVG ring gauges: Score (/100), Checks OK (%), HTTP OK (%)
- Smooth CSS transition on stroke-dashoffset (0.7s cubic-bezier)
- Pulse animation while test is live, stops on completion
- Color: green ≥90/98/99, yellow mid-range, red below thresholds
- Stats strip: Requests, Req/s, Avg, p(90), p(95), p(99)
- p(95) cell color-coded green/yellow/red vs latency
- Threshold pass/fail banner at bottom of gauges panel
- Raw output collapsed in <details> by default
- History items show mini score ring gauge (44px) inline
- History detail expands 3 medium gauges + stat grid

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-01 20:24:59 +02:00
parent cf51a4620b
commit 175ba3ec7a
4 changed files with 645 additions and 229 deletions

View File

@@ -1,6 +1,6 @@
'use strict';
// Tab switching
// ─── Tab switching ────────────────────────────────────────────────────────────
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.dataset.tab;
@@ -12,25 +12,24 @@ document.querySelectorAll('.tab-btn').forEach(btn => {
});
});
// Sync range <-> number inputs
// ─── Sync range number ──────────────────────────────────────────────────────
function syncRangeNumber(rangeId, numId) {
const range = document.getElementById(rangeId);
const num = document.getElementById(numId);
range.addEventListener('input', () => num.value = range.value);
num.addEventListener('input', () => range.value = num.value);
num.addEventListener('input', () => range.value = num.value);
}
syncRangeNumber('vus', 'vus-num');
syncRangeNumber('vus', 'vus-num');
syncRangeNumber('duration', 'duration-num');
// Show/hide request body for non-GET methods
// ─── Show/hide body for non-GET ───────────────────────────────────────────────
const methodSel = document.getElementById('httpMethod');
const bodyGroup = document.getElementById('body-group');
methodSel.addEventListener('change', () => {
const m = methodSel.value;
bodyGroup.style.display = (m !== 'GET' && m !== 'HEAD') ? '' : 'none';
bodyGroup.style.display = (methodSel.value !== 'GET' && methodSel.value !== 'HEAD') ? '' : 'none';
});
// gzip toggle hint
// ─── gzip hint ────────────────────────────────────────────────────────────────
const gzipChk = document.getElementById('gzip');
const gzipHint = document.getElementById('gzip-hint');
gzipChk.addEventListener('change', () => {
@@ -39,11 +38,7 @@ gzipChk.addEventListener('change', () => {
: 'Sends Accept-Encoding: identity — forces uncompressed response';
});
// Device toggle hint
const UA = {
desktop: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
mobile: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1',
};
// ─── Device hint ─────────────────────────────────────────────────────────────
const uaHint = document.getElementById('ua-hint');
document.querySelectorAll('input[name="device"]').forEach(radio => {
radio.addEventListener('change', () => {
@@ -53,14 +48,95 @@ document.querySelectorAll('input[name="device"]').forEach(radio => {
});
});
// ---- Run Test ----
const form = document.getElementById('test-form');
const startBtn = document.getElementById('start-btn');
// ─── Gauge engine ─────────────────────────────────────────────────────────────
const CIRC = 2 * Math.PI * 50; // r=50 → 314.16
function setGauge(id, pct, color) {
const el = document.getElementById(id);
if (!el) return;
const ring = el.querySelector('.gauge-ring');
const val = el.querySelector('.gauge-value');
const clamped = Math.max(0, Math.min(100, pct));
ring.style.strokeDasharray = CIRC;
ring.style.strokeDashoffset = CIRC * (1 - clamped / 100);
ring.style.stroke = color;
val.textContent = Math.round(clamped);
}
function gaugeColor(val, good, warn) {
if (val >= good) return 'var(--green)';
if (val >= warn) return 'var(--yellow)';
return 'var(--red)';
}
function fmtMs(ms) {
if (!ms || ms === 0) return '--';
return ms >= 1000 ? (ms / 1000).toFixed(2) + 's' : Math.round(ms) + 'ms';
}
function setStat(id, val) {
const el = document.getElementById(id);
if (el) el.querySelector('.sc-val').textContent = val;
}
function updateGauges(m) {
if (!m || m.totalReqs === 0) return;
setGauge('gauge-score', m.score, gaugeColor(m.score, 90, 50));
setGauge('gauge-checks', m.checksRate, gaugeColor(m.checksRate,98, 90));
setGauge('gauge-http', m.httpOkRate, gaugeColor(m.httpOkRate,99, 95));
setStat('sc-reqs', m.totalReqs.toLocaleString());
setStat('sc-rps', m.reqPerSec + '/s');
setStat('sc-avg', fmtMs(m.avg));
setStat('sc-p90', fmtMs(m.p90));
setStat('sc-p95', fmtMs(m.p95));
setStat('sc-p99', fmtMs(m.p99));
// colour p95 cell based on threshold (5 s)
const p95cell = document.getElementById('sc-p95');
if (p95cell) {
p95cell.querySelector('.sc-val').style.color =
m.p95 < 1000 ? 'var(--green)' : m.p95 < 3000 ? 'var(--yellow)' : 'var(--red)';
}
}
function resetGauges() {
['gauge-score','gauge-checks','gauge-http'].forEach(id => {
const el = document.getElementById(id);
if (!el) return;
const ring = el.querySelector('.gauge-ring');
ring.style.strokeDasharray = CIRC;
ring.style.strokeDashoffset = CIRC;
ring.style.stroke = 'var(--border)';
el.querySelector('.gauge-value').textContent = '--';
});
['sc-reqs','sc-rps','sc-avg','sc-p90','sc-p95','sc-p99'].forEach(id => setStat(id, '--'));
const tb = document.getElementById('threshold-banner');
if (tb) { tb.style.display = 'none'; tb.innerHTML = ''; }
}
function renderThresholds(summary) {
const tb = document.getElementById('threshold-banner');
if (!tb) return;
const thresholds = summary.thresholds;
if (!thresholds || !thresholds.length) return;
const allPass = thresholds.every(t => t.pass);
tb.className = `threshold-banner ${allPass ? 'pass' : 'fail'}`;
tb.innerHTML = `<strong>${allPass ? '✓ All thresholds passed' : '✗ Some thresholds failed'}</strong>
<ul>${thresholds.map(t =>
`<li class="${t.pass ? 'pass' : 'fail'}">${t.pass ? '✓' : '✗'} ${escHtml(t.label)}</li>`
).join('')}</ul>`;
tb.style.display = '';
}
// ─── Form submit ──────────────────────────────────────────────────────────────
const form = document.getElementById('test-form');
const startBtn = document.getElementById('start-btn');
const resultPanel = document.getElementById('result-panel');
const outputLog = document.getElementById('output-log');
const resultStatus= document.getElementById('result-status');
const summaryPanel= document.getElementById('summary-panel');
const summaryCards= document.getElementById('summary-cards');
form.addEventListener('submit', async (e) => {
e.preventDefault();
@@ -76,7 +152,6 @@ form.addEventListener('submit', async (e) => {
const gzip = document.getElementById('gzip').checked;
const device = document.querySelector('input[name="device"]:checked').value;
// Validate headers JSON
try { JSON.parse(headers); } catch {
alert('Custom Headers must be valid JSON (or leave empty).');
return;
@@ -84,11 +159,16 @@ form.addEventListener('submit', async (e) => {
startBtn.disabled = true;
startBtn.textContent = '⏳ Running…';
outputLog.textContent = '';
summaryPanel.style.display = 'none';
document.getElementById('log-details').open = false;
resultPanel.style.display = '';
resultStatus.textContent = 'running';
resultStatus.className = 'badge running';
resetGauges();
// Add pulsing class while running
document.getElementById('gauges-panel').classList.add('live');
let res;
try {
@@ -122,19 +202,30 @@ function streamTest(id) {
if (data.chunk) {
outputLog.textContent += data.chunk;
outputLog.scrollTop = outputLog.scrollHeight;
if (document.getElementById('log-details').open)
outputLog.scrollTop = outputLog.scrollHeight;
}
if (data.metrics) {
updateGauges(data.metrics);
}
if (data.done) {
es.close();
document.getElementById('gauges-panel').classList.remove('live');
resultStatus.textContent = data.status;
resultStatus.className = `badge ${data.status}`;
if (data.summary) renderSummary(data.summary);
// Final update from parsed summary + live data
if (data.summary) {
if (data.summary.live) updateGauges(data.summary.live);
renderThresholds(data.summary);
}
resetBtn();
}
if (data.error) {
es.close();
document.getElementById('gauges-panel').classList.remove('live');
outputLog.textContent += '\n[error] ' + data.error;
resetBtn();
}
@@ -142,6 +233,7 @@ function streamTest(id) {
es.onerror = () => {
es.close();
document.getElementById('gauges-panel').classList.remove('live');
resetBtn();
};
}
@@ -151,74 +243,9 @@ function resetBtn() {
startBtn.textContent = '▶ Run Test';
}
function renderSummary(summary) {
if (!summary || Object.keys(summary).length === 0) return;
summaryPanel.style.display = '';
summaryCards.innerHTML = '';
// ---- Threshold pass/fail banner ----
if (summary.thresholds && summary.thresholds.length) {
const allPass = summary.thresholds.every(t => t.pass);
const banner = document.createElement('div');
banner.className = `threshold-banner ${allPass ? 'pass' : 'fail'}`;
banner.innerHTML = `<strong>${allPass ? '✓ All thresholds passed' : '✗ Some thresholds failed'}</strong>
<ul>${summary.thresholds.map(t =>
`<li class="${t.pass ? 'pass' : 'fail'}">${t.pass ? '✓' : '✗'} ${escHtml(t.label)}</li>`
).join('')}</ul>`;
summaryCards.appendChild(banner);
}
// ---- Metric cards ----
const cards = [];
if (summary.http_reqs) {
cards.push({ label: 'Total Requests', value: summary.http_reqs[0], cls: 'info' });
cards.push({ label: 'Throughput', value: summary.http_reqs[1], cls: 'info' });
}
if (summary.http_req_failed) {
const pct = parseFloat(summary.http_req_failed[0]);
const detail = summary.http_req_failed[1] != null
? ` (${summary.http_req_failed[1]}/${summary.http_req_failed[2]})`
: '';
cards.push({ label: 'Failed Requests', value: summary.http_req_failed[0] + detail, cls: pct > 0 ? 'bad' : 'good' });
}
if (summary.checks) {
const pct = parseFloat(summary.checks[0]);
cards.push({ label: 'Checks Passed', value: `${summary.checks[0]} (${summary.checks[1]}/${summary.checks[2]})`, cls: pct < 100 ? 'bad' : 'good' });
}
if (summary.http_req_duration) {
const d = summary.http_req_duration;
// d = [avg, min, med, max, p90, p95, p99]
cards.push({ label: 'Avg Response', value: d[0], cls: 'info' });
cards.push({ label: 'Median (p50)', value: d[2], cls: 'info' });
cards.push({ label: 'p(90)', value: d[4], cls: 'info' });
cards.push({ label: 'p(95)', value: d[5], cls: 'info' });
cards.push({ label: 'p(99)', value: d[6], cls: 'info' });
cards.push({ label: 'Max Response', value: d[3], cls: 'info' });
}
if (summary.iterations) {
cards.push({ label: 'Iterations', value: summary.iterations[0], cls: 'info' });
cards.push({ label: 'Iter/s', value: summary.iterations[1], cls: 'info' });
}
if (summary.data_received) {
cards.push({ label: 'Data Received', value: summary.data_received[0], cls: 'info' });
}
const grid = document.createElement('div');
grid.className = 'summary-grid';
for (const c of cards) {
const div = document.createElement('div');
div.className = 'summary-card';
div.innerHTML = `<div class="label">${c.label}</div><div class="value ${c.cls}">${c.value}</div>`;
grid.appendChild(div);
}
summaryCards.appendChild(grid);
}
// ---- History ----
// ─── History ──────────────────────────────────────────────────────────────────
async function loadHistory() {
const container = document.getElementById('history-list');
let tests;
try {
const res = await fetch('/api/tests');
@@ -243,14 +270,27 @@ async function loadHistory() {
const date = new Date(t.created_at).toLocaleString();
const dur = t.finished_at ? `${Math.round((t.finished_at - t.created_at) / 1000)}s` : `${t.duration}s`;
let summaryLine = '';
if (t.summary) {
const s = t.summary;
const parts = [];
if (s.http_reqs) parts.push(`${s.http_reqs[0]} reqs @ ${s.http_reqs[1]}`);
if (s.http_req_duration) parts.push(`avg ${s.http_req_duration[0]}`);
if (s.http_req_failed) parts.push(`${s.http_req_failed[0]} failed`);
summaryLine = parts.join(' · ');
// Score mini-gauge from saved live data
const live = t.summary && t.summary.live;
const scorePct = live ? live.score : null;
const scoreColor = scorePct === null ? 'var(--border)'
: scorePct >= 90 ? 'var(--green)' : scorePct >= 50 ? 'var(--yellow)' : 'var(--red)';
const miniGauge = `
<svg viewBox="0 0 40 40" class="mini-gauge" aria-label="Score ${scorePct ?? '--'}">
<circle cx="20" cy="20" r="16" fill="none" stroke="var(--border)" stroke-width="4"/>
<circle cx="20" cy="20" r="16" fill="none" stroke="${scoreColor}" stroke-width="4"
stroke-dasharray="${2 * Math.PI * 16}"
stroke-dashoffset="${2 * Math.PI * 16 * (1 - (scorePct ?? 0) / 100)}"
stroke-linecap="round" transform="rotate(-90 20 20)"/>
<text x="20" y="24" text-anchor="middle" font-size="10" fill="${scoreColor}" font-weight="700">${scorePct ?? '--'}</text>
</svg>`;
let metaLine = '';
if (live) {
metaLine = `${live.totalReqs.toLocaleString()} reqs · ${live.reqPerSec}/s · p95 ${fmtMs(live.p95)}`;
} else if (t.summary && t.summary.http_reqs) {
metaLine = `${t.summary.http_reqs[0]} reqs @ ${t.summary.http_reqs[1]}`;
}
const deviceBadge = t.device === 'mobile'
@@ -266,7 +306,10 @@ async function loadHistory() {
: '<span class="pill no-gzip">no-gzip</span>';
item.innerHTML = `
<div>
<div class="history-left">
${miniGauge}
</div>
<div class="history-body">
<div class="row1">
<span class="badge ${t.status}">${t.status}</span>
<span class="url">${escHtml(t.url)}</span>
@@ -274,7 +317,7 @@ async function loadHistory() {
${deviceBadge}${cacheBadge}${gzipBadge}
</div>
<div class="meta">${date}</div>
${summaryLine ? `<div class="history-summary">${escHtml(summaryLine)}</div>` : ''}
${metaLine ? `<div class="history-summary">${escHtml(metaLine)}</div>` : ''}
</div>
<div class="actions">
<button class="btn-del" title="Delete" data-id="${t.id}">&#x2715;</button>
@@ -289,13 +332,11 @@ async function loadHistory() {
});
item.addEventListener('click', () => showHistoryDetail(t.id, item));
container.appendChild(item);
}
}
async function showHistoryDetail(id, itemEl) {
// Toggle: if detail already open for this item, close it
const existing = document.getElementById('history-detail');
if (existing) {
const wasThis = existing.dataset.for === id;
@@ -303,48 +344,70 @@ async function showHistoryDetail(id, itemEl) {
if (wasThis) return;
}
const res = await fetch(`/api/tests/${id}`);
const res = await fetch(`/api/tests/${id}`);
const test = await res.json();
const live = test.summary && test.summary.live;
const detail = document.createElement('div');
detail.id = 'history-detail';
detail.dataset.for = id;
let summaryHtml = '';
if (test.summary && Object.keys(test.summary).length) {
const s = test.summary;
const rows = [
s.http_reqs && `<tr><td>Total Requests</td><td>${s.http_reqs[0]} (${s.http_reqs[1]})</td></tr>`,
s.http_req_duration && `<tr><td>Duration avg/med/p95/max</td><td>${s.http_req_duration[0]} / ${s.http_req_duration[2]} / ${s.http_req_duration[5]} / ${s.http_req_duration[3]}</td></tr>`,
s.http_req_failed && `<tr><td>Failure Rate</td><td>${s.http_req_failed[0]}</td></tr>`,
s.iterations && `<tr><td>Iterations</td><td>${s.iterations[0]} (${s.iterations[1]})</td></tr>`,
].filter(Boolean).join('');
if (rows) summaryHtml = `<table style="width:100%;border-collapse:collapse;font-size:.85rem;margin-bottom:.75rem">
<tbody>${rows}</tbody></table>`;
// Mini gauges row
let gaugesHtml = '';
if (live) {
const scoreC = live.score >= 90 ? 'var(--green)' : live.score >= 50 ? 'var(--yellow)' : 'var(--red)';
const checkC = live.checksRate >= 98 ? 'var(--green)' : live.checksRate >= 90 ? 'var(--yellow)' : 'var(--red)';
const httpC = live.httpOkRate >= 99 ? 'var(--green)' : live.httpOkRate >= 95 ? 'var(--yellow)' : 'var(--red)';
gaugesHtml = `<div class="detail-gauges">
${detailGaugeSvg('Score', live.score, '/100', scoreC)}
${detailGaugeSvg('Checks OK',live.checksRate, '%', checkC)}
${detailGaugeSvg('HTTP OK', live.httpOkRate, '%', httpC)}
</div>
<div class="detail-stats">
${detailStat('Requests', live.totalReqs.toLocaleString())}
${detailStat('Req/s', live.reqPerSec + '/s')}
${detailStat('Avg', fmtMs(live.avg))}
${detailStat('p(90)', fmtMs(live.p90))}
${detailStat('p(95)', fmtMs(live.p95))}
${detailStat('p(99)', fmtMs(live.p99))}
</div>`;
}
detail.innerHTML = `
<h3 style="margin-bottom:.75rem">Details: ${escHtml(test.url)}</h3>
${summaryHtml}
<pre>${escHtml(test.output || '(no output)')}</pre>
<h3 class="detail-title">${escHtml(test.url)}</h3>
${gaugesHtml}
<details><summary>Raw Output</summary><pre>${escHtml(test.output || '(no output)')}</pre></details>
`;
// Add style to table cells inline
detail.querySelectorAll('td').forEach((td, i) => {
td.style.padding = '.3rem .5rem';
td.style.borderBottom = '1px solid var(--border)';
if (i % 2 === 0) td.style.color = 'var(--muted)';
});
itemEl.insertAdjacentElement('afterend', detail);
}
function detailGaugeSvg(title, pct, unit, color) {
const r = 28, c = 2 * Math.PI * r;
const offset = c * (1 - Math.min(100, Math.max(0, pct || 0)) / 100);
return `<div class="dg-wrap">
<svg viewBox="0 0 72 72" class="dg-svg">
<circle cx="36" cy="36" r="${r}" fill="none" stroke="var(--border)" stroke-width="5"/>
<circle cx="36" cy="36" r="${r}" fill="none" stroke="${color}" stroke-width="5"
stroke-dasharray="${c.toFixed(2)}" stroke-dashoffset="${offset.toFixed(2)}"
stroke-linecap="round" transform="rotate(-90 36 36)"/>
</svg>
<div class="dg-inner">
<span class="dg-val" style="color:${color}">${Math.round(pct ?? 0)}</span>
<span class="dg-unit">${unit}</span>
<span class="dg-title">${title}</span>
</div>
</div>`;
}
function detailStat(label, value) {
return `<div class="detail-stat"><span class="ds-val">${value}</span><span class="ds-lbl">${label}</span></div>`;
}
document.getElementById('refresh-history').addEventListener('click', loadHistory);
function escHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}