'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); // 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 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'; } 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 = `${allPass ? '✓ All thresholds passed' : '✗ Some thresholds failed'} `; 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 = '

Failed to load history.

'; return; } if (!tests.length) { container.innerHTML = '

No tests yet.

'; 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 = ` ${scorePct ?? '--'} `; 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' ? '📱 mobile' : '🖥 desktop'; const cacheBadge = t.cache_mode === 'bust' ? 'cache-bust' : t.cache_mode === 'no-cache' ? 'no-cache' : 'cached'; const gzipBadge = t.gzip ? 'gzip' : 'no-gzip'; item.innerHTML = `
${miniGauge}
${t.status} ${escHtml(t.url)} ${t.http_method} · ${t.vus} VUs · ${dur} ${deviceBadge}${cacheBadge}${gzipBadge}
${date}
${metaLine ? `
${escHtml(metaLine)}
` : ''}
`; 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 = `
${detailGaugeSvg('Score', live.score, '/100', scoreC)} ${detailGaugeSvg('Checks OK',live.checksRate, '%', checkC)} ${detailGaugeSvg('HTTP OK', live.httpOkRate, '%', httpC)}
${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))}
`; } detail.innerHTML = `

${escHtml(test.url)}

${gaugesHtml}
Raw Output
${escHtml(test.output || '(no output)')}
`; 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 `
${Math.round(pct ?? 0)} ${unit} ${title}
`; } function detailStat(label, value) { return `
${value}${label}
`; } document.getElementById('refresh-history').addEventListener('click', loadHistory); function escHtml(s) { return String(s) .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); }