feat: live timing waterfall with TTFB, DNS, TLS, TCP, sending, receiving

Backend:
- Track all k6 HTTP phase metrics via PHASE_MAP in ingestPoint:
  http_req_blocked (DNS+wait), http_req_connecting, http_req_tls_handshaking,
  http_req_sending, http_req_waiting (TTFB), http_req_receiving
- Compute avg + p95 per phase in computeLiveMetrics, broadcast every second
- parseSummary now captures all trend metrics with generic trendRe()

Frontend:
- Live waterfall bar chart updates every second during the test
- Each phase has a distinct colour: indigo/sky/purple/emerald/amber/blue
- Bar width = phase avg as % of total request time
- TTFB p95 + p99 shown below the waterfall
- Bars animate smoothly with CSS transitions
- Waterfall resets cleanly on new test start

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-01 20:47:03 +02:00
parent d3991416e8
commit 812131df4b
4 changed files with 215 additions and 23 deletions

View File

@@ -171,13 +171,19 @@ app.post('/api/tests', (req, res) => {
// Live metric aggregation state
const live = {
durations: [],
durations: [], // http_req_duration (ms)
ttfb: [], // http_req_waiting (ms)
connecting: [], // http_req_connecting (ms)
tls: [], // http_req_tls_handshaking (ms)
sending: [], // http_req_sending (ms)
receiving: [], // http_req_receiving (ms)
blocked: [], // http_req_blocked (ms)
totalReqs: 0,
failedReqs: 0,
checksTotal: 0,
checksPassed: 0,
bytesIn: 0, // data_received (bytes)
bytesOut: 0, // data_sent (bytes)
bytesIn: 0,
bytesOut: 0,
startTime: Date.now(),
lastBroadcast: 0,
};
@@ -307,11 +313,24 @@ app.delete('/api/tests/:id', (req, res) => {
});
// Ingest a single k6 JSON metric point into the live aggregation state
const PHASE_MAP = {
http_req_duration: 'durations',
http_req_waiting: 'ttfb',
http_req_connecting: 'connecting',
http_req_tls_handshaking:'tls',
http_req_sending: 'sending',
http_req_receiving: 'receiving',
http_req_blocked: 'blocked',
};
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') {
const arr = PHASE_MAP[metric];
if (arr) {
if (live[arr].length < 20000) live[arr].push(data.value);
return;
}
if (metric === 'http_reqs') {
live.totalReqs++;
} else if (metric === 'http_req_failed') {
if (data.value === 1) live.failedReqs++;
@@ -339,11 +358,29 @@ function computeLiveMetrics(live) {
const p95 = pct(95);
const score = computeScore({ p95, httpErrRate, checksRate });
const bwInMBps = +(live.bytesIn / elapsed / 1048576).toFixed(2); // MB/s
const bwInMBps = +(live.bytesIn / elapsed / 1048576).toFixed(2);
const bwOutMBps = +(live.bytesOut / elapsed / 1048576).toFixed(2);
const avgRespKB = live.totalReqs > 0
? +(live.bytesIn / live.totalReqs / 1024).toFixed(1)
: 0;
? +(live.bytesIn / live.totalReqs / 1024).toFixed(1) : 0;
// Helper: avg of an array
const arrAvg = (arr) => arr.length
? +(arr.reduce((a, b) => a + b, 0) / arr.length).toFixed(2) : 0;
// Helper: percentile of an array
const arrPct = (arr, p) => {
if (!arr.length) return 0;
const s = [...arr].sort((a, b) => a - b);
return +s[Math.min(Math.floor(s.length * p / 100), s.length - 1)].toFixed(2);
};
const phases = {
blocked: { avg: arrAvg(live.blocked), p95: arrPct(live.blocked, 95) },
connecting: { avg: arrAvg(live.connecting), p95: arrPct(live.connecting, 95) },
tls: { avg: arrAvg(live.tls), p95: arrPct(live.tls, 95) },
sending: { avg: arrAvg(live.sending), p95: arrPct(live.sending, 95) },
ttfb: { avg: arrAvg(live.ttfb), p95: arrPct(live.ttfb, 95), p99: arrPct(live.ttfb, 99) },
receiving: { avg: arrAvg(live.receiving), p95: arrPct(live.receiving, 95) },
};
return {
totalReqs: live.totalReqs,
@@ -363,6 +400,7 @@ function computeLiveMetrics(live) {
bwInMBps,
bwOutMBps,
avgRespKB,
phases,
};
}
@@ -381,27 +419,39 @@ function computeScore({ p95, httpErrRate, checksRate }) {
// 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.µ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)/,
};
const trendRe = (name) => new RegExp(
`${name}[\\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]+)`
);
// Trend metrics: [avg, min, med, max, p90, p95, p99]
for (const name of [
'http_req_duration', 'http_req_waiting', 'http_req_blocked',
'http_req_connecting', 'http_req_tls_handshaking',
'http_req_sending', 'http_req_receiving', 'iteration_duration',
]) {
const m = plain.match(trendRe(name));
if (m) summary[name] = { avg: m[1], min: m[2], med: m[3], max: m[4], p90: m[5], p95: m[6], p99: m[7] };
}
// Counter / rate metrics
const patterns = {
http_reqs: /http_reqs[\s.]+(\d+)\s+([\d.]+\/s)/,
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 = plain.match(re);
if (m) summary[key] = m.slice(1);
}
// Extract threshold results (✓ / ✗ lines)
// Threshold pass/fail lines
const thresholds = [];
for (const m of plain.matchAll(/(✓|✗)\s+(.+)/g)) {
thresholds.push({ pass: m[1] === '✓', label: m[2].trim() });