From 0cd0f96bab8623c3c36cd5b5cc4e2f72a5e591dd Mon Sep 17 00:00:00 2001 From: Malin Date: Fri, 1 May 2026 19:46:39 +0200 Subject: [PATCH] fix: broadcast k6 stdout summary and show full stats - Remove --out json=- (was flooding stdout and hiding summary) - Broadcast both stdout (summary table) and stderr (live warnings) - Add --summary-trend-stats with p(90)/p(95)/p(99) - Parse checks, failed count/total, data received, all percentiles - Strip ANSI codes before regex matching - Add threshold pass/fail banner with per-threshold status - Show all duration percentiles as individual cards Co-Authored-By: Claude Sonnet 4.6 --- backend/server.js | 43 +++++++++++++++++++++++++++-------------- frontend/app.js | 48 ++++++++++++++++++++++++++++++++++++++-------- frontend/style.css | 17 +++++++++++++++- 3 files changed, 85 insertions(+), 23 deletions(-) diff --git a/backend/server.js b/backend/server.js index 1938943..8953b65 100644 --- a/backend/server.js +++ b/backend/server.js @@ -132,7 +132,11 @@ app.post('/api/tests', (req, res) => { const clients = new Set(); activeTests[id] = { clients }; - const k6 = spawn('k6', ['run', '--out', 'json=-', tmpScript]); + const k6 = spawn('k6', [ + 'run', + '--summary-trend-stats', 'avg,min,med,max,p(90),p(95),p(99)', + tmpScript, + ]); const broadcast = (data) => { outputBuffer += data; @@ -141,14 +145,11 @@ app.post('/api/tests', (req, res) => { } }; - k6.stdout.on('data', (data) => { - // k6 JSON metrics go to stdout when using --out json=- - // We collect but don't broadcast raw JSON (it's noisy) - }); + // stdout = final summary table (the important part) + k6.stdout.on('data', (data) => broadcast(data.toString())); - k6.stderr.on('data', (data) => { - broadcast(data.toString()); - }); + // stderr = live progress bars + warnings + k6.stderr.on('data', (data) => broadcast(data.toString())); k6.on('close', (code) => { fs.unlink(tmpScript, () => {}); @@ -226,19 +227,33 @@ app.delete('/api/tests/:id', (req, res) => { // Parse k6 terminal output for key metrics function parseSummary(output) { + // Strip ANSI colour codes before matching + const plain = output.replace(/\x1b\[[0-9;]*m/g, ''); const summary = {}; + const patterns = { - http_reqs: /http_reqs\.*\s+([\d.]+)\s+([\d.]+\/s)/, - http_req_duration: /http_req_duration\.*\s+avg=([\d.]+\w+)\s+min=([\d.]+\w+)\s+med=([\d.]+\w+)\s+max=([\d.]+\w+)\s+p\(90\)=([\d.]+\w+)\s+p\(95\)=([\d.]+\w+)/, - http_req_failed: /http_req_failed\.*\s+([\d.]+%)/, - vus: /vus\.*\s+(\d+)\s+min=(\d+)\s+max=(\d+)/, - iterations: /iterations\.*\s+([\d.]+)\s+([\d.]+\/s)/, + http_reqs: /http_reqs[\s.]+(\d+)\s+([\d.]+\/s)/, + http_req_duration: /http_req_duration[\s.]+avg=([\d.µms]+)\s+min=([\d.µms]+)\s+med=([\d.µms]+)\s+max=([\d.µms]+)\s+p\(90\)=([\d.µms]+)\s+p\(95\)=([\d.µms]+)\s+p\(99\)=([\d.µms]+)/, + http_req_failed: /http_req_failed[\s.]+(\d+\.?\d*%)\s+(\d+) out of (\d+)/, + checks: /checks[\s.]+(\d+\.?\d*%)\s+(\d+) out of (\d+)/, + iterations: /iterations[\s.]+(\d+)\s+([\d.]+\/s)/, + vus: /vus[\s.]+(\d+)\s+min=(\d+)\s+max=(\d+)/, + data_received: /data_received[\s.]+([\d.]+ \w+)\s+([\d.]+ \w+\/s)/, + data_sent: /data_sent[\s.]+([\d.]+ \w+)\s+([\d.]+ \w+\/s)/, }; for (const [key, re] of Object.entries(patterns)) { - const m = output.match(re); + const m = plain.match(re); if (m) summary[key] = m.slice(1); } + + // Extract threshold results (✓ / ✗ lines) + const thresholds = []; + for (const m of plain.matchAll(/(✓|✗)\s+(.+)/g)) { + thresholds.push({ pass: m[1] === '✓', label: m[2].trim() }); + } + if (thresholds.length) summary.thresholds = thresholds; + return summary; } diff --git a/frontend/app.js b/frontend/app.js index 10e7ecf..602e900 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -130,31 +130,63 @@ function renderSummary(summary) { 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: 'Req/s', value: summary.http_reqs[1], cls: 'info' }); - } - if (summary.http_req_duration) { - cards.push({ label: 'Avg Duration', value: summary.http_req_duration[0], cls: 'info' }); - cards.push({ label: 'p(95) Duration', value: summary.http_req_duration[5], cls: 'info' }); - cards.push({ label: 'Max Duration', value: summary.http_req_duration[3], 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]); - cards.push({ label: 'Failure Rate', value: summary.http_req_failed[0], cls: pct > 5 ? 'bad' : 'good' }); + 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}
`; - summaryCards.appendChild(div); + grid.appendChild(div); } + summaryCards.appendChild(grid); } // ---- History ---- diff --git a/frontend/style.css b/frontend/style.css index 4453fd5..786a48d 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -150,10 +150,25 @@ textarea { font-family: var(--mono); resize: vertical; } color: #a5f3fc; } +/* THRESHOLD BANNER */ +.threshold-banner { + border-radius: 8px; + padding: .85rem 1rem; + margin-bottom: 1rem; + border: 1px solid; +} +.threshold-banner.pass { background: rgba(34,197,94,.08); border-color: rgba(34,197,94,.3); color: var(--green); } +.threshold-banner.fail { background: rgba(239,68,68,.08); border-color: rgba(239,68,68,.3); color: var(--red); } +.threshold-banner ul { list-style: none; margin-top: .4rem; padding: 0; } +.threshold-banner li { font-size: .85rem; padding: .15rem 0; font-family: var(--mono); } +.threshold-banner li.pass { color: var(--green); } +.threshold-banner li.fail { color: var(--red); } + /* SUMMARY CARDS */ +#summary-cards { margin-top: .5rem; } .summary-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 1rem; margin-top: .75rem; }