diff --git a/backend/server.js b/backend/server.js index 2116ebf..41aed26 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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() }); diff --git a/frontend/app.js b/frontend/app.js index f56ffc3..ac2d486 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -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) { diff --git a/frontend/index.html b/frontend/index.html index 75ce88a..c4610e4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -154,7 +154,7 @@ - +
--Requests @@ -163,7 +163,7 @@ --Req / s
- --Avg + --Avg total
--p(90) @@ -176,6 +176,49 @@
+ +
+
Request timing breakdown (avg per phase)
+
+
+ DNS / Blocked +
+ -- +
+
+ TCP connect +
+ -- +
+
+ TLS handshake +
+ -- +
+
+ Sending +
+ -- +
+
+ TTFB (waiting) +
+ -- +
+
+ Receiving +
+ -- +
+
+ +
+
diff --git a/frontend/style.css b/frontend/style.css index d71e76a..8f3f09e 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -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;