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 <noreply@anthropic.com>
This commit is contained in:
@@ -132,7 +132,11 @@ app.post('/api/tests', (req, res) => {
|
|||||||
const clients = new Set();
|
const clients = new Set();
|
||||||
activeTests[id] = { clients };
|
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) => {
|
const broadcast = (data) => {
|
||||||
outputBuffer += data;
|
outputBuffer += data;
|
||||||
@@ -141,14 +145,11 @@ app.post('/api/tests', (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
k6.stdout.on('data', (data) => {
|
// stdout = final summary table (the important part)
|
||||||
// k6 JSON metrics go to stdout when using --out json=-
|
k6.stdout.on('data', (data) => broadcast(data.toString()));
|
||||||
// We collect but don't broadcast raw JSON (it's noisy)
|
|
||||||
});
|
|
||||||
|
|
||||||
k6.stderr.on('data', (data) => {
|
// stderr = live progress bars + warnings
|
||||||
broadcast(data.toString());
|
k6.stderr.on('data', (data) => broadcast(data.toString()));
|
||||||
});
|
|
||||||
|
|
||||||
k6.on('close', (code) => {
|
k6.on('close', (code) => {
|
||||||
fs.unlink(tmpScript, () => {});
|
fs.unlink(tmpScript, () => {});
|
||||||
@@ -226,19 +227,33 @@ app.delete('/api/tests/:id', (req, res) => {
|
|||||||
|
|
||||||
// Parse k6 terminal output for key metrics
|
// Parse k6 terminal output for key metrics
|
||||||
function parseSummary(output) {
|
function parseSummary(output) {
|
||||||
|
// Strip ANSI colour codes before matching
|
||||||
|
const plain = output.replace(/\x1b\[[0-9;]*m/g, '');
|
||||||
const summary = {};
|
const summary = {};
|
||||||
|
|
||||||
const patterns = {
|
const patterns = {
|
||||||
http_reqs: /http_reqs\.*\s+([\d.]+)\s+([\d.]+\/s)/,
|
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_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.]+%)/,
|
http_req_failed: /http_req_failed[\s.]+(\d+\.?\d*%)\s+(\d+) out of (\d+)/,
|
||||||
vus: /vus\.*\s+(\d+)\s+min=(\d+)\s+max=(\d+)/,
|
checks: /checks[\s.]+(\d+\.?\d*%)\s+(\d+) out of (\d+)/,
|
||||||
iterations: /iterations\.*\s+([\d.]+)\s+([\d.]+\/s)/,
|
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)) {
|
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);
|
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;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,31 +130,63 @@ function renderSummary(summary) {
|
|||||||
summaryPanel.style.display = '';
|
summaryPanel.style.display = '';
|
||||||
summaryCards.innerHTML = '';
|
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 = [];
|
const cards = [];
|
||||||
|
|
||||||
if (summary.http_reqs) {
|
if (summary.http_reqs) {
|
||||||
cards.push({ label: 'Total Requests', value: summary.http_reqs[0], cls: 'info' });
|
cards.push({ label: 'Total Requests', value: summary.http_reqs[0], cls: 'info' });
|
||||||
cards.push({ label: 'Req/s', value: summary.http_reqs[1], cls: 'info' });
|
cards.push({ label: 'Throughput', 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' });
|
|
||||||
}
|
}
|
||||||
if (summary.http_req_failed) {
|
if (summary.http_req_failed) {
|
||||||
const pct = parseFloat(summary.http_req_failed[0]);
|
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) {
|
if (summary.iterations) {
|
||||||
cards.push({ label: 'Iterations', value: summary.iterations[0], cls: 'info' });
|
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) {
|
for (const c of cards) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'summary-card';
|
div.className = 'summary-card';
|
||||||
div.innerHTML = `<div class="label">${c.label}</div><div class="value ${c.cls}">${c.value}</div>`;
|
div.innerHTML = `<div class="label">${c.label}</div><div class="value ${c.cls}">${c.value}</div>`;
|
||||||
summaryCards.appendChild(div);
|
grid.appendChild(div);
|
||||||
}
|
}
|
||||||
|
summaryCards.appendChild(grid);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- History ----
|
// ---- History ----
|
||||||
|
|||||||
@@ -150,10 +150,25 @@ textarea { font-family: var(--mono); resize: vertical; }
|
|||||||
color: #a5f3fc;
|
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 */
|
||||||
|
#summary-cards { margin-top: .5rem; }
|
||||||
.summary-grid {
|
.summary-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-top: .75rem;
|
margin-top: .75rem;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user