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'} +
${escHtml(test.output || '(no output)')}
+ ${escHtml(test.output || '(no output)')}