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:
@@ -171,13 +171,19 @@ app.post('/api/tests', (req, res) => {
|
|||||||
|
|
||||||
// Live metric aggregation state
|
// Live metric aggregation state
|
||||||
const live = {
|
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,
|
totalReqs: 0,
|
||||||
failedReqs: 0,
|
failedReqs: 0,
|
||||||
checksTotal: 0,
|
checksTotal: 0,
|
||||||
checksPassed: 0,
|
checksPassed: 0,
|
||||||
bytesIn: 0, // data_received (bytes)
|
bytesIn: 0,
|
||||||
bytesOut: 0, // data_sent (bytes)
|
bytesOut: 0,
|
||||||
startTime: Date.now(),
|
startTime: Date.now(),
|
||||||
lastBroadcast: 0,
|
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
|
// 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) {
|
function ingestPoint(obj, live) {
|
||||||
const { metric, data } = obj;
|
const { metric, data } = obj;
|
||||||
if (metric === 'http_req_duration') {
|
const arr = PHASE_MAP[metric];
|
||||||
if (live.durations.length < 50000) live.durations.push(data.value);
|
if (arr) {
|
||||||
} else if (metric === 'http_reqs') {
|
if (live[arr].length < 20000) live[arr].push(data.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (metric === 'http_reqs') {
|
||||||
live.totalReqs++;
|
live.totalReqs++;
|
||||||
} else if (metric === 'http_req_failed') {
|
} else if (metric === 'http_req_failed') {
|
||||||
if (data.value === 1) live.failedReqs++;
|
if (data.value === 1) live.failedReqs++;
|
||||||
@@ -339,11 +358,29 @@ function computeLiveMetrics(live) {
|
|||||||
const p95 = pct(95);
|
const p95 = pct(95);
|
||||||
const score = computeScore({ p95, httpErrRate, checksRate });
|
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 bwOutMBps = +(live.bytesOut / elapsed / 1048576).toFixed(2);
|
||||||
const avgRespKB = live.totalReqs > 0
|
const avgRespKB = live.totalReqs > 0
|
||||||
? +(live.bytesIn / live.totalReqs / 1024).toFixed(1)
|
? +(live.bytesIn / live.totalReqs / 1024).toFixed(1) : 0;
|
||||||
: 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 {
|
return {
|
||||||
totalReqs: live.totalReqs,
|
totalReqs: live.totalReqs,
|
||||||
@@ -363,6 +400,7 @@ function computeLiveMetrics(live) {
|
|||||||
bwInMBps,
|
bwInMBps,
|
||||||
bwOutMBps,
|
bwOutMBps,
|
||||||
avgRespKB,
|
avgRespKB,
|
||||||
|
phases,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,13 +419,26 @@ function computeScore({ p95, httpErrRate, checksRate }) {
|
|||||||
|
|
||||||
// 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 plain = output.replace(/\x1b\[[0-9;]*m/g, '');
|
||||||
const summary = {};
|
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 = {
|
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.µ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+)/,
|
http_req_failed:/http_req_failed[\s.]+(\d+\.?\d*%)\s+(\d+) out of (\d+)/,
|
||||||
checks: /checks[\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)/,
|
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_received: /data_received[\s.]+([\d.]+ \w+)\s+([\d.]+ \w+\/s)/,
|
||||||
data_sent: /data_sent[\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 = plain.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)
|
// Threshold pass/fail lines
|
||||||
const thresholds = [];
|
const thresholds = [];
|
||||||
for (const m of plain.matchAll(/(✓|✗)\s+(.+)/g)) {
|
for (const m of plain.matchAll(/(✓|✗)\s+(.+)/g)) {
|
||||||
thresholds.push({ pass: m[1] === '✓', label: m[2].trim() });
|
thresholds.push({ pass: m[1] === '✓', label: m[2].trim() });
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ function updateGauges(m) {
|
|||||||
setStat('sc-p95', fmtMs(m.p95));
|
setStat('sc-p95', fmtMs(m.p95));
|
||||||
setStat('sc-p99', fmtMs(m.p99));
|
setStat('sc-p99', fmtMs(m.p99));
|
||||||
updateBandwidth(m);
|
updateBandwidth(m);
|
||||||
|
updateWaterfall(m);
|
||||||
|
|
||||||
// colour p95 cell based on threshold (5 s)
|
// colour p95 cell based on threshold (5 s)
|
||||||
const p95cell = document.getElementById('sc-p95');
|
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) {
|
function updateBandwidth(m) {
|
||||||
const inEl = document.getElementById('bw-in');
|
const inEl = document.getElementById('bw-in');
|
||||||
const outEl = document.getElementById('bw-out');
|
const outEl = document.getElementById('bw-out');
|
||||||
@@ -140,6 +177,10 @@ function resetGauges() {
|
|||||||
if (bwSize) bwSize.textContent = '-- KB';
|
if (bwSize) bwSize.textContent = '-- KB';
|
||||||
const bwWarn = document.getElementById('bw-warning');
|
const bwWarn = document.getElementById('bw-warning');
|
||||||
if (bwWarn) bwWarn.style.display = 'none';
|
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) {
|
function renderThresholds(summary) {
|
||||||
|
|||||||
@@ -154,7 +154,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Live stat strip -->
|
<!-- Live stat strip — response times -->
|
||||||
<div class="stats-strip" id="stats-strip">
|
<div class="stats-strip" id="stats-strip">
|
||||||
<div class="stat-cell" id="sc-reqs">
|
<div class="stat-cell" id="sc-reqs">
|
||||||
<span class="sc-val">--</span><span class="sc-lbl">Requests</span>
|
<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>
|
<span class="sc-val">--</span><span class="sc-lbl">Req / s</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-cell" id="sc-avg">
|
<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>
|
||||||
<div class="stat-cell" id="sc-p90">
|
<div class="stat-cell" id="sc-p90">
|
||||||
<span class="sc-val">--</span><span class="sc-lbl">p(90)</span>
|
<span class="sc-val">--</span><span class="sc-lbl">p(90)</span>
|
||||||
@@ -176,6 +176,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Bandwidth row -->
|
||||||
<div class="bw-row" id="bw-row">
|
<div class="bw-row" id="bw-row">
|
||||||
<div class="bw-item">
|
<div class="bw-item">
|
||||||
|
|||||||
@@ -307,6 +307,64 @@ textarea { font-family: var(--mono); resize: vertical; }
|
|||||||
letter-spacing: .05em;
|
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 ──────────────────────────────────────────────────────────── */
|
/* ── BANDWIDTH ROW ──────────────────────────────────────────────────────────── */
|
||||||
.bw-row {
|
.bw-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user