diff --git a/backend/server.js b/backend/server.js index 7d738cd..669da12 100644 --- a/backend/server.js +++ b/backend/server.js @@ -169,8 +169,21 @@ app.post('/api/tests', (req, res) => { const clients = new Set(); activeTests[id] = { clients }; + // Live metric aggregation state + const live = { + durations: [], // http_req_duration values (ms) + p90durations: [], // sampled for percentile calc + totalReqs: 0, + failedReqs: 0, + checksTotal: 0, + checksPassed: 0, + startTime: Date.now(), + lastBroadcast: 0, + }; + const k6 = spawn('k6', [ 'run', + '--out', 'json=-', '--summary-trend-stats', 'avg,min,med,max,p(90),p(95),p(99)', tmpScript, ]); @@ -182,17 +195,47 @@ app.post('/api/tests', (req, res) => { } }; - // stdout = final summary table (the important part) - k6.stdout.on('data', (data) => broadcast(data.toString())); + const broadcastMetrics = () => { + const m = computeLiveMetrics(live); + for (const client of clients) { + client.write(`data: ${JSON.stringify({ metrics: m })}\n\n`); + } + }; - // stderr = live progress bars + warnings + // stdout = JSON metric data points + final summary text (non-JSON lines) + let jsonBuf = ''; + k6.stdout.on('data', (chunk) => { + jsonBuf += chunk.toString(); + const lines = jsonBuf.split('\n'); + jsonBuf = lines.pop(); // keep incomplete last line + for (const line of lines) { + if (!line.trim()) { broadcast('\n'); continue; } + try { + const obj = JSON.parse(line); + if (obj.type === 'Point') ingestPoint(obj, live); + // throttle metric broadcasts to once per second + const now = Date.now(); + if (now - live.lastBroadcast >= 1000) { + live.lastBroadcast = now; + broadcastMetrics(); + } + } catch { + // Not JSON — it's the human-readable summary; forward to log + broadcast(line + '\n'); + } + } + }); + + // stderr = progress bars + warnings k6.stderr.on('data', (data) => broadcast(data.toString())); k6.on('close', (code) => { fs.unlink(tmpScript, () => {}); - // Parse summary from output const summary = parseSummary(outputBuffer); + // Merge live computed metrics into summary for persistence + summary.live = computeLiveMetrics(live); + const finishedAt = Date.now(); const status = code === 0 ? 'completed' : 'failed'; @@ -262,6 +305,66 @@ app.delete('/api/tests/:id', (req, res) => { res.json({ ok: true }); }); +// Ingest a single k6 JSON metric point into the live aggregation state +function ingestPoint(obj, live) { + const { metric, data } = obj; + if (metric === 'http_req_duration') { + if (live.durations.length < 50000) live.durations.push(data.value); + } else if (metric === 'http_reqs') { + live.totalReqs++; + } else if (metric === 'http_req_failed') { + if (data.value === 1) live.failedReqs++; + } else if (metric === 'checks') { + live.checksTotal++; + if (data.value === 1) live.checksPassed++; + } +} + +// Compute snapshot of live metrics for broadcast +function computeLiveMetrics(live) { + const elapsed = Math.max(1, (Date.now() - live.startTime) / 1000); + const sorted = live.durations.length ? [...live.durations].sort((a, b) => a - b) : []; + const pct = (p) => sorted.length ? sorted[Math.min(Math.floor(sorted.length * p / 100), sorted.length - 1)] : 0; + const avg = sorted.length ? sorted.reduce((a, b) => a + b, 0) / sorted.length : 0; + + const checksRate = live.checksTotal > 0 ? live.checksPassed / live.checksTotal * 100 : 100; + const httpOkRate = live.totalReqs > 0 ? (1 - live.failedReqs / live.totalReqs) * 100 : 100; + const httpErrRate = live.totalReqs > 0 ? live.failedReqs / live.totalReqs * 100 : 0; + + const p95 = pct(95); + const score = computeScore({ p95, httpErrRate, checksRate }); + + return { + totalReqs: live.totalReqs, + reqPerSec: +(live.totalReqs / elapsed).toFixed(1), + failedReqs: live.failedReqs, + httpErrRate: +httpErrRate.toFixed(2), + httpOkRate: +httpOkRate.toFixed(2), + checksTotal: live.checksTotal, + checksPassed:live.checksPassed, + checksRate: +checksRate.toFixed(2), + avg: +avg.toFixed(1), + p90: +pct(90).toFixed(1), + p95: +p95.toFixed(1), + p99: +pct(99).toFixed(1), + max: sorted.length ? +sorted[sorted.length - 1].toFixed(1) : 0, + score, + }; +} + +function computeScore({ p95, httpErrRate, checksRate }) { + let s = 100; + const p95s = (p95 || 0) / 1000; + if (p95s > 5) s -= 50; + else if (p95s > 3) s -= 30; + else if (p95s > 2) s -= 20; + else if (p95s > 1) s -= 10; + else if (p95s > 0.5) s -= 5; + s -= Math.min(40, (httpErrRate || 0) * 5); + s -= Math.min(20, (100 - (checksRate || 100)) * 2); + return Math.max(0, Math.round(s)); +} + // Parse k6 terminal output for key metrics function parseSummary(output) { // Strip ANSI colour codes before matching diff --git a/frontend/app.js b/frontend/app.js index f15d203..8a659c2 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -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 = `${allPass ? '✓ All thresholds passed' : '✗ Some thresholds failed'} + `; + 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 = `${allPass ? '✓ All thresholds passed' : '✗ Some thresholds failed'} - `; - 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 = `
${c.label}
${c.value}
`; - 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 = ` + + + + ${scorePct ?? '--'} + `; + + 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() { : 'no-gzip'; item.innerHTML = ` -
+
+ ${miniGauge} +
+
${t.status} ${escHtml(t.url)} @@ -274,7 +317,7 @@ async function loadHistory() { ${deviceBadge}${cacheBadge}${gzipBadge}
${date}
- ${summaryLine ? `
${escHtml(summaryLine)}
` : ''} + ${metaLine ? `
${escHtml(metaLine)}
` : ''}
@@ -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 && `Total Requests${s.http_reqs[0]} (${s.http_reqs[1]})`, - s.http_req_duration && `Duration avg/med/p95/max${s.http_req_duration[0]} / ${s.http_req_duration[2]} / ${s.http_req_duration[5]} / ${s.http_req_duration[3]}`, - s.http_req_failed && `Failure Rate${s.http_req_failed[0]}`, - s.iterations && `Iterations${s.iterations[0]} (${s.iterations[1]})`, - ].filter(Boolean).join(''); - if (rows) summaryHtml = ` - ${rows}
`; + // 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 = `
+ ${detailGaugeSvg('Score', live.score, '/100', scoreC)} + ${detailGaugeSvg('Checks OK',live.checksRate, '%', checkC)} + ${detailGaugeSvg('HTTP OK', live.httpOkRate, '%', httpC)} +
+
+ ${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))} +
`; } detail.innerHTML = ` -

Details: ${escHtml(test.url)}

- ${summaryHtml} -
${escHtml(test.output || '(no output)')}
+

${escHtml(test.url)}

+ ${gaugesHtml} +
Raw Output
${escHtml(test.output || '(no output)')}
`; - // 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 `
+ + + + +
+ ${Math.round(pct ?? 0)} + ${unit} + ${title} +
+
`; +} + +function detailStat(label, value) { + return `
${value}${label}
`; +} + document.getElementById('refresh-history').addEventListener('click', loadHistory); function escHtml(s) { return String(s) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } diff --git a/frontend/index.html b/frontend/index.html index b99066c..f33cfc5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -108,14 +108,83 @@