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,13 +419,26 @@ 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 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_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)/,
@@ -395,13 +446,12 @@ function parseSummary(output) {
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() });

View File

@@ -93,6 +93,7 @@ function updateGauges(m) {
setStat('sc-p95', fmtMs(m.p95));
setStat('sc-p99', fmtMs(m.p99));
updateBandwidth(m);
updateWaterfall(m);
// colour p95 cell based on threshold (5 s)
const p95cell = document.getElementById('sc-p95');
@@ -102,6 +103,42 @@ function updateGauges(m) {
}
}
function updateWaterfall(m) {
if (!m.phases) return;
const p = m.phases;
// Total avg across all phases (use this to normalise bar widths)
const total = (p.blocked.avg + p.connecting.avg + p.tls.avg +
p.sending.avg + p.ttfb.avg + p.receiving.avg) || 1;
const rows = [
{ id: 'wf-blocked', val: p.blocked.avg, p95: p.blocked.p95 },
{ id: 'wf-connecting', val: p.connecting.avg, p95: p.connecting.p95 },
{ id: 'wf-tls', val: p.tls.avg, p95: p.tls.p95 },
{ id: 'wf-sending', val: p.sending.avg, p95: p.sending.p95 },
{ id: 'wf-ttfb', val: p.ttfb.avg, p95: p.ttfb.p95, p99: p.ttfb.p99 },
{ id: 'wf-receiving', val: p.receiving.avg, p95: p.receiving.p95 },
];
for (const row of rows) {
const el = document.getElementById(row.id);
if (!el) continue;
const bar = el.querySelector('.wf-bar');
const valEl = el.querySelector('.wf-val');
const pct = Math.max(2, (row.val / total) * 100);
bar.style.width = pct + '%';
valEl.textContent = fmtMs(row.val);
}
// TTFB p95/p99 detail line
const p95row = document.getElementById('wf-p95-row');
if (p95row && p.ttfb.avg > 0) {
p95row.style.display = '';
document.getElementById('wf-ttfb-p95').textContent = fmtMs(p.ttfb.p95);
document.getElementById('wf-ttfb-p99').textContent = fmtMs(p.ttfb.p99);
}
}
function updateBandwidth(m) {
const inEl = document.getElementById('bw-in');
const outEl = document.getElementById('bw-out');
@@ -140,6 +177,10 @@ function resetGauges() {
if (bwSize) bwSize.textContent = '-- KB';
const bwWarn = document.getElementById('bw-warning');
if (bwWarn) bwWarn.style.display = 'none';
document.querySelectorAll('.wf-bar').forEach(b => b.style.width = '0%');
document.querySelectorAll('.wf-val').forEach(v => v.textContent = '--');
const p95row = document.getElementById('wf-p95-row');
if (p95row) p95row.style.display = 'none';
}
function renderThresholds(summary) {

View File

@@ -154,7 +154,7 @@
</div>
<!-- Live stat strip -->
<!-- Live stat strip — response times -->
<div class="stats-strip" id="stats-strip">
<div class="stat-cell" id="sc-reqs">
<span class="sc-val">--</span><span class="sc-lbl">Requests</span>
@@ -163,7 +163,7 @@
<span class="sc-val">--</span><span class="sc-lbl">Req / s</span>
</div>
<div class="stat-cell" id="sc-avg">
<span class="sc-val">--</span><span class="sc-lbl">Avg</span>
<span class="sc-val">--</span><span class="sc-lbl">Avg total</span>
</div>
<div class="stat-cell" id="sc-p90">
<span class="sc-val">--</span><span class="sc-lbl">p(90)</span>
@@ -176,6 +176,49 @@
</div>
</div>
<!-- Live timing phases waterfall -->
<div class="waterfall-wrap" id="waterfall-wrap">
<div class="waterfall-title">Request timing breakdown <span class="wf-hint">(avg per phase)</span></div>
<div class="waterfall" id="waterfall">
<div class="wf-row" id="wf-blocked">
<span class="wf-label">DNS / Blocked</span>
<div class="wf-bar-wrap"><div class="wf-bar wf-blocked"></div></div>
<span class="wf-val">--</span>
</div>
<div class="wf-row" id="wf-connecting">
<span class="wf-label">TCP connect</span>
<div class="wf-bar-wrap"><div class="wf-bar wf-connecting"></div></div>
<span class="wf-val">--</span>
</div>
<div class="wf-row" id="wf-tls">
<span class="wf-label">TLS handshake</span>
<div class="wf-bar-wrap"><div class="wf-bar wf-tls"></div></div>
<span class="wf-val">--</span>
</div>
<div class="wf-row" id="wf-sending">
<span class="wf-label">Sending</span>
<div class="wf-bar-wrap"><div class="wf-bar wf-sending"></div></div>
<span class="wf-val">--</span>
</div>
<div class="wf-row" id="wf-ttfb">
<span class="wf-label">TTFB (waiting)</span>
<div class="wf-bar-wrap"><div class="wf-bar wf-ttfb"></div></div>
<span class="wf-val">--</span>
</div>
<div class="wf-row" id="wf-receiving">
<span class="wf-label">Receiving</span>
<div class="wf-bar-wrap"><div class="wf-bar wf-receiving"></div></div>
<span class="wf-val">--</span>
</div>
</div>
<div class="wf-p95-row" id="wf-p95-row" style="display:none">
<span class="wf-p95-label">TTFB p(95)</span>
<span class="wf-p95-val" id="wf-ttfb-p95">--</span>
<span class="wf-p95-label" style="margin-left:1rem">TTFB p(99)</span>
<span class="wf-p95-val" id="wf-ttfb-p99">--</span>
</div>
</div>
<!-- Bandwidth row -->
<div class="bw-row" id="bw-row">
<div class="bw-item">

View File

@@ -307,6 +307,64 @@ textarea { font-family: var(--mono); resize: vertical; }
letter-spacing: .05em;
}
/* ── TIMING WATERFALL ───────────────────────────────────────────────────────── */
.waterfall-wrap {
margin-top: .75rem;
border-top: 1px solid var(--border);
padding-top: .9rem;
}
.waterfall-title {
font-size: .78rem;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: .06em;
margin-bottom: .6rem;
}
.wf-hint { font-weight: 400; text-transform: none; letter-spacing: 0; }
.wf-row {
display: grid;
grid-template-columns: 120px 1fr 70px;
align-items: center;
gap: .5rem;
margin-bottom: .35rem;
}
.wf-label { font-size: .78rem; color: var(--muted); white-space: nowrap; }
.wf-bar-wrap {
background: var(--border);
border-radius: 999px;
height: 8px;
overflow: hidden;
}
.wf-bar {
height: 100%;
border-radius: 999px;
width: 0%;
transition: width .6s cubic-bezier(.4,0,.2,1);
}
.wf-val { font-size: .78rem; font-family: var(--mono); color: var(--text); text-align: right; }
/* Phase colours */
.wf-blocked { background: #818cf8; } /* indigo — DNS/blocked */
.wf-connecting { background: #38bdf8; } /* sky — TCP connect */
.wf-tls { background: #a78bfa; } /* purple — TLS */
.wf-sending { background: #34d399; } /* emerald — sending */
.wf-ttfb { background: #f59e0b; } /* amber — TTFB (most important) */
.wf-receiving { background: #60a5fa; } /* blue — receiving */
.wf-p95-row {
display: flex;
align-items: center;
gap: .5rem;
margin-top: .4rem;
padding-top: .4rem;
border-top: 1px dashed var(--border);
flex-wrap: wrap;
}
.wf-p95-label { font-size: .72rem; color: var(--muted); }
.wf-p95-val { font-size: .85rem; font-weight: 700; font-family: var(--mono); color: var(--yellow); }
/* ── BANDWIDTH ROW ──────────────────────────────────────────────────────────── */
.bw-row {
display: flex;