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>
481 lines
18 KiB
JavaScript
481 lines
18 KiB
JavaScript
'use strict';
|
|
|
|
// ─── Tab switching ────────────────────────────────────────────────────────────
|
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const tab = btn.dataset.tab;
|
|
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
document.getElementById(`tab-${tab}`).classList.add('active');
|
|
if (tab === 'history') loadHistory();
|
|
});
|
|
});
|
|
|
|
// ─── Sync range ↔ number ──────────────────────────────────────────────────────
|
|
function syncRangeNumber(rangeId, numId) {
|
|
const range = document.getElementById(rangeId);
|
|
const num = document.getElementById(numId);
|
|
range.addEventListener('input', () => num.value = range.value);
|
|
num.addEventListener('input', () => range.value = num.value);
|
|
}
|
|
syncRangeNumber('vus', 'vus-num');
|
|
syncRangeNumber('duration', 'duration-num');
|
|
|
|
// ─── Show/hide body for non-GET ───────────────────────────────────────────────
|
|
const methodSel = document.getElementById('httpMethod');
|
|
const bodyGroup = document.getElementById('body-group');
|
|
methodSel.addEventListener('change', () => {
|
|
bodyGroup.style.display = (methodSel.value !== 'GET' && methodSel.value !== 'HEAD') ? '' : 'none';
|
|
});
|
|
|
|
// ─── gzip hint ────────────────────────────────────────────────────────────────
|
|
const gzipChk = document.getElementById('gzip');
|
|
const gzipHint = document.getElementById('gzip-hint');
|
|
gzipChk.addEventListener('change', () => {
|
|
gzipHint.textContent = gzipChk.checked
|
|
? 'k6 default — server may compress response'
|
|
: 'Sends Accept-Encoding: identity — forces uncompressed response';
|
|
});
|
|
|
|
// ─── Device hint ─────────────────────────────────────────────────────────────
|
|
const uaHint = document.getElementById('ua-hint');
|
|
document.querySelectorAll('input[name="device"]').forEach(radio => {
|
|
radio.addEventListener('change', () => {
|
|
uaHint.textContent = radio.value === 'mobile'
|
|
? 'iPhone Safari 17 (iOS 17.4)'
|
|
: 'Chrome 124 on Windows';
|
|
});
|
|
});
|
|
|
|
// ─── Gauge engine ─────────────────────────────────────────────────────────────
|
|
const CIRC = 2 * Math.PI * 50; // r=50 → 314.16
|
|
|
|
function setGauge(id, pct, color) {
|
|
const el = document.getElementById(id);
|
|
if (!el) return;
|
|
const ring = el.querySelector('.gauge-ring');
|
|
const val = el.querySelector('.gauge-value');
|
|
const clamped = Math.max(0, Math.min(100, pct));
|
|
ring.style.strokeDasharray = CIRC;
|
|
ring.style.strokeDashoffset = CIRC * (1 - clamped / 100);
|
|
ring.style.stroke = color;
|
|
val.textContent = Math.round(clamped);
|
|
}
|
|
|
|
function gaugeColor(val, good, warn) {
|
|
if (val >= good) return 'var(--green)';
|
|
if (val >= warn) return 'var(--yellow)';
|
|
return 'var(--red)';
|
|
}
|
|
|
|
function fmtMs(ms) {
|
|
if (!ms || ms === 0) return '--';
|
|
return ms >= 1000 ? (ms / 1000).toFixed(2) + 's' : Math.round(ms) + 'ms';
|
|
}
|
|
|
|
function setStat(id, val) {
|
|
const el = document.getElementById(id);
|
|
if (el) el.querySelector('.sc-val').textContent = val;
|
|
}
|
|
|
|
function updateGauges(m) {
|
|
if (!m || m.totalReqs === 0) return;
|
|
|
|
setGauge('gauge-score', m.score, gaugeColor(m.score, 90, 50));
|
|
setGauge('gauge-checks', m.checksRate, gaugeColor(m.checksRate,98, 90));
|
|
setGauge('gauge-http', m.httpOkRate, gaugeColor(m.httpOkRate,99, 95));
|
|
|
|
setStat('sc-reqs', m.totalReqs.toLocaleString());
|
|
setStat('sc-rps', m.reqPerSec + '/s');
|
|
setStat('sc-avg', fmtMs(m.avg));
|
|
setStat('sc-p90', fmtMs(m.p90));
|
|
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');
|
|
if (p95cell) {
|
|
p95cell.querySelector('.sc-val').style.color =
|
|
m.p95 < 1000 ? 'var(--green)' : m.p95 < 3000 ? 'var(--yellow)' : 'var(--red)';
|
|
}
|
|
}
|
|
|
|
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');
|
|
const sizeEl = document.getElementById('bw-size');
|
|
const warn = document.getElementById('bw-warning');
|
|
if (!inEl) return;
|
|
|
|
inEl.textContent = m.bwInMBps + ' MB/s';
|
|
outEl.textContent = m.bwOutMBps + ' MB/s';
|
|
sizeEl.textContent = m.avgRespKB + ' KB';
|
|
|
|
// Flag if inbound bandwidth looks high (>20 MB/s = ~160 Mbps — notable)
|
|
const high = m.bwInMBps > 20;
|
|
inEl.style.color = m.bwInMBps > 50 ? 'var(--red)' : m.bwInMBps > 20 ? 'var(--yellow)' : 'var(--green)';
|
|
warn.style.display = high ? '' : 'none';
|
|
}
|
|
|
|
function resetGauges() {
|
|
['gauge-score','gauge-checks','gauge-http'].forEach(id => {
|
|
const el = document.getElementById(id);
|
|
if (!el) return;
|
|
const ring = el.querySelector('.gauge-ring');
|
|
ring.style.strokeDasharray = CIRC;
|
|
ring.style.strokeDashoffset = CIRC;
|
|
ring.style.stroke = 'var(--border)';
|
|
el.querySelector('.gauge-value').textContent = '--';
|
|
});
|
|
['sc-reqs','sc-rps','sc-avg','sc-p90','sc-p95','sc-p99'].forEach(id => setStat(id, '--'));
|
|
const tb = document.getElementById('threshold-banner');
|
|
if (tb) { tb.style.display = 'none'; tb.innerHTML = ''; }
|
|
const bwIn = document.getElementById('bw-in');
|
|
if (bwIn) { bwIn.textContent = '-- MB/s'; bwIn.style.color = ''; }
|
|
const bwOut = document.getElementById('bw-out');
|
|
if (bwOut) bwOut.textContent = '-- MB/s';
|
|
const bwSize = document.getElementById('bw-size');
|
|
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) {
|
|
const tb = document.getElementById('threshold-banner');
|
|
if (!tb) return;
|
|
const thresholds = summary.thresholds;
|
|
if (!thresholds || !thresholds.length) return;
|
|
|
|
const allPass = thresholds.every(t => t.pass);
|
|
tb.className = `threshold-banner ${allPass ? 'pass' : 'fail'}`;
|
|
tb.innerHTML = `<strong>${allPass ? '✓ All thresholds passed' : '✗ Some thresholds failed'}</strong>
|
|
<ul>${thresholds.map(t =>
|
|
`<li class="${t.pass ? 'pass' : 'fail'}">${t.pass ? '✓' : '✗'} ${escHtml(t.label)}</li>`
|
|
).join('')}</ul>`;
|
|
tb.style.display = '';
|
|
}
|
|
|
|
// ─── Form submit ──────────────────────────────────────────────────────────────
|
|
const form = document.getElementById('test-form');
|
|
const startBtn = document.getElementById('start-btn');
|
|
const resultPanel = document.getElementById('result-panel');
|
|
const outputLog = document.getElementById('output-log');
|
|
const resultStatus= document.getElementById('result-status');
|
|
|
|
form.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
|
|
const url = document.getElementById('url').value.trim();
|
|
const vus = parseInt(document.getElementById('vus-num').value, 10);
|
|
const duration = parseInt(document.getElementById('duration-num').value, 10);
|
|
const rpsLimit = parseInt(document.getElementById('rpsLimit').value, 10) || 0;
|
|
const httpMethod = document.getElementById('httpMethod').value;
|
|
const requestBody = document.getElementById('requestBody').value.trim();
|
|
const headers = document.getElementById('headers').value.trim() || '{}';
|
|
const cacheMode = document.getElementById('cacheMode').value;
|
|
const gzip = document.getElementById('gzip').checked;
|
|
const device = document.querySelector('input[name="device"]:checked').value;
|
|
|
|
try { JSON.parse(headers); } catch {
|
|
alert('Custom Headers must be valid JSON (or leave empty).');
|
|
return;
|
|
}
|
|
|
|
startBtn.disabled = true;
|
|
startBtn.textContent = '⏳ Running…';
|
|
|
|
outputLog.textContent = '';
|
|
document.getElementById('log-details').open = false;
|
|
resultPanel.style.display = '';
|
|
resultStatus.textContent = 'running';
|
|
resultStatus.className = 'badge running';
|
|
resetGauges();
|
|
|
|
// Add pulsing class while running
|
|
document.getElementById('gauges-panel').classList.add('live');
|
|
|
|
let res;
|
|
try {
|
|
res = await fetch('/api/tests', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ url, vus, duration, rpsLimit, httpMethod, requestBody, headers, cacheMode, gzip, device }),
|
|
});
|
|
} catch (err) {
|
|
alert('Failed to start test: ' + err.message);
|
|
resetBtn();
|
|
return;
|
|
}
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
alert('Error: ' + err.error);
|
|
resetBtn();
|
|
return;
|
|
}
|
|
|
|
const { id } = await res.json();
|
|
streamTest(id);
|
|
});
|
|
|
|
function streamTest(id) {
|
|
const es = new EventSource(`/api/tests/${id}/stream`);
|
|
|
|
es.onmessage = (e) => {
|
|
const data = JSON.parse(e.data);
|
|
|
|
if (data.chunk) {
|
|
outputLog.textContent += data.chunk;
|
|
if (document.getElementById('log-details').open)
|
|
outputLog.scrollTop = outputLog.scrollHeight;
|
|
}
|
|
|
|
if (data.metrics) {
|
|
updateGauges(data.metrics);
|
|
}
|
|
|
|
if (data.done) {
|
|
es.close();
|
|
document.getElementById('gauges-panel').classList.remove('live');
|
|
resultStatus.textContent = data.status;
|
|
resultStatus.className = `badge ${data.status}`;
|
|
// Final update from parsed summary + live data
|
|
if (data.summary) {
|
|
if (data.summary.live) updateGauges(data.summary.live);
|
|
renderThresholds(data.summary);
|
|
}
|
|
resetBtn();
|
|
}
|
|
|
|
if (data.error) {
|
|
es.close();
|
|
document.getElementById('gauges-panel').classList.remove('live');
|
|
outputLog.textContent += '\n[error] ' + data.error;
|
|
resetBtn();
|
|
}
|
|
};
|
|
|
|
es.onerror = () => {
|
|
es.close();
|
|
document.getElementById('gauges-panel').classList.remove('live');
|
|
resetBtn();
|
|
};
|
|
}
|
|
|
|
function resetBtn() {
|
|
startBtn.disabled = false;
|
|
startBtn.textContent = '▶ Run Test';
|
|
}
|
|
|
|
// ─── History ──────────────────────────────────────────────────────────────────
|
|
async function loadHistory() {
|
|
const container = document.getElementById('history-list');
|
|
let tests;
|
|
try {
|
|
const res = await fetch('/api/tests');
|
|
tests = await res.json();
|
|
} catch {
|
|
container.innerHTML = '<p class="empty">Failed to load history.</p>';
|
|
return;
|
|
}
|
|
|
|
if (!tests.length) {
|
|
container.innerHTML = '<p class="empty">No tests yet.</p>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = '';
|
|
|
|
for (const t of tests) {
|
|
const item = document.createElement('div');
|
|
item.className = 'history-item';
|
|
item.dataset.id = t.id;
|
|
|
|
const date = new Date(t.created_at).toLocaleString();
|
|
const dur = t.finished_at ? `${Math.round((t.finished_at - t.created_at) / 1000)}s` : `${t.duration}s`;
|
|
|
|
// Score mini-gauge from saved live data
|
|
const live = t.summary && t.summary.live;
|
|
const scorePct = live ? live.score : null;
|
|
const scoreColor = scorePct === null ? 'var(--border)'
|
|
: scorePct >= 90 ? 'var(--green)' : scorePct >= 50 ? 'var(--yellow)' : 'var(--red)';
|
|
|
|
const miniGauge = `
|
|
<svg viewBox="0 0 40 40" class="mini-gauge" aria-label="Score ${scorePct ?? '--'}">
|
|
<circle cx="20" cy="20" r="16" fill="none" stroke="var(--border)" stroke-width="4"/>
|
|
<circle cx="20" cy="20" r="16" fill="none" stroke="${scoreColor}" stroke-width="4"
|
|
stroke-dasharray="${2 * Math.PI * 16}"
|
|
stroke-dashoffset="${2 * Math.PI * 16 * (1 - (scorePct ?? 0) / 100)}"
|
|
stroke-linecap="round" transform="rotate(-90 20 20)"/>
|
|
<text x="20" y="24" text-anchor="middle" font-size="10" fill="${scoreColor}" font-weight="700">${scorePct ?? '--'}</text>
|
|
</svg>`;
|
|
|
|
let metaLine = '';
|
|
if (live) {
|
|
metaLine = `${live.totalReqs.toLocaleString()} reqs · ${live.reqPerSec}/s · p95 ${fmtMs(live.p95)}`;
|
|
} else if (t.summary && t.summary.http_reqs) {
|
|
metaLine = `${t.summary.http_reqs[0]} reqs @ ${t.summary.http_reqs[1]}`;
|
|
}
|
|
|
|
const deviceBadge = t.device === 'mobile'
|
|
? '<span class="pill mobile">📱 mobile</span>'
|
|
: '<span class="pill desktop">🖥 desktop</span>';
|
|
const cacheBadge = t.cache_mode === 'bust'
|
|
? '<span class="pill bust">cache-bust</span>'
|
|
: t.cache_mode === 'no-cache'
|
|
? '<span class="pill no-cache">no-cache</span>'
|
|
: '<span class="pill cached">cached</span>';
|
|
const gzipBadge = t.gzip
|
|
? '<span class="pill gzip">gzip</span>'
|
|
: '<span class="pill no-gzip">no-gzip</span>';
|
|
|
|
item.innerHTML = `
|
|
<div class="history-left">
|
|
${miniGauge}
|
|
</div>
|
|
<div class="history-body">
|
|
<div class="row1">
|
|
<span class="badge ${t.status}">${t.status}</span>
|
|
<span class="url">${escHtml(t.url)}</span>
|
|
<span class="meta">${t.http_method} · ${t.vus} VUs · ${dur}</span>
|
|
${deviceBadge}${cacheBadge}${gzipBadge}
|
|
</div>
|
|
<div class="meta">${date}</div>
|
|
${metaLine ? `<div class="history-summary">${escHtml(metaLine)}</div>` : ''}
|
|
</div>
|
|
<div class="actions">
|
|
<button class="btn-del" title="Delete" data-id="${t.id}">✕</button>
|
|
</div>
|
|
`;
|
|
|
|
item.querySelector('.btn-del').addEventListener('click', async (e) => {
|
|
e.stopPropagation();
|
|
if (!confirm('Delete this test from history?')) return;
|
|
await fetch(`/api/tests/${t.id}`, { method: 'DELETE' });
|
|
loadHistory();
|
|
});
|
|
|
|
item.addEventListener('click', () => showHistoryDetail(t.id, item));
|
|
container.appendChild(item);
|
|
}
|
|
}
|
|
|
|
async function showHistoryDetail(id, itemEl) {
|
|
const existing = document.getElementById('history-detail');
|
|
if (existing) {
|
|
const wasThis = existing.dataset.for === id;
|
|
existing.remove();
|
|
if (wasThis) return;
|
|
}
|
|
|
|
const res = await fetch(`/api/tests/${id}`);
|
|
const test = await res.json();
|
|
const live = test.summary && test.summary.live;
|
|
|
|
const detail = document.createElement('div');
|
|
detail.id = 'history-detail';
|
|
detail.dataset.for = id;
|
|
|
|
// Mini gauges row
|
|
let gaugesHtml = '';
|
|
if (live) {
|
|
const scoreC = live.score >= 90 ? 'var(--green)' : live.score >= 50 ? 'var(--yellow)' : 'var(--red)';
|
|
const checkC = live.checksRate >= 98 ? 'var(--green)' : live.checksRate >= 90 ? 'var(--yellow)' : 'var(--red)';
|
|
const httpC = live.httpOkRate >= 99 ? 'var(--green)' : live.httpOkRate >= 95 ? 'var(--yellow)' : 'var(--red)';
|
|
gaugesHtml = `<div class="detail-gauges">
|
|
${detailGaugeSvg('Score', live.score, '/100', scoreC)}
|
|
${detailGaugeSvg('Checks OK',live.checksRate, '%', checkC)}
|
|
${detailGaugeSvg('HTTP OK', live.httpOkRate, '%', httpC)}
|
|
</div>
|
|
<div class="detail-stats">
|
|
${detailStat('Requests', live.totalReqs.toLocaleString())}
|
|
${detailStat('Req/s', live.reqPerSec + '/s')}
|
|
${detailStat('Avg', fmtMs(live.avg))}
|
|
${detailStat('p(90)', fmtMs(live.p90))}
|
|
${detailStat('p(95)', fmtMs(live.p95))}
|
|
${detailStat('p(99)', fmtMs(live.p99))}
|
|
</div>`;
|
|
}
|
|
|
|
detail.innerHTML = `
|
|
<h3 class="detail-title">${escHtml(test.url)}</h3>
|
|
${gaugesHtml}
|
|
<details><summary>Raw Output</summary><pre>${escHtml(test.output || '(no output)')}</pre></details>
|
|
`;
|
|
|
|
itemEl.insertAdjacentElement('afterend', detail);
|
|
}
|
|
|
|
function detailGaugeSvg(title, pct, unit, color) {
|
|
const r = 28, c = 2 * Math.PI * r;
|
|
const offset = c * (1 - Math.min(100, Math.max(0, pct || 0)) / 100);
|
|
return `<div class="dg-wrap">
|
|
<svg viewBox="0 0 72 72" class="dg-svg">
|
|
<circle cx="36" cy="36" r="${r}" fill="none" stroke="var(--border)" stroke-width="5"/>
|
|
<circle cx="36" cy="36" r="${r}" fill="none" stroke="${color}" stroke-width="5"
|
|
stroke-dasharray="${c.toFixed(2)}" stroke-dashoffset="${offset.toFixed(2)}"
|
|
stroke-linecap="round" transform="rotate(-90 36 36)"/>
|
|
</svg>
|
|
<div class="dg-inner">
|
|
<span class="dg-val" style="color:${color}">${Math.round(pct ?? 0)}</span>
|
|
<span class="dg-unit">${unit}</span>
|
|
<span class="dg-title">${title}</span>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function detailStat(label, value) {
|
|
return `<div class="detail-stat"><span class="ds-val">${value}</span><span class="ds-lbl">${label}</span></div>`;
|
|
}
|
|
|
|
document.getElementById('refresh-history').addEventListener('click', loadHistory);
|
|
|
|
function escHtml(s) {
|
|
return String(s)
|
|
.replace(/&/g, '&').replace(/</g, '<')
|
|
.replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|