'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 inputs 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 request body for non-GET methods const methodSel = document.getElementById('httpMethod'); const bodyGroup = document.getElementById('body-group'); methodSel.addEventListener('change', () => { const m = methodSel.value; bodyGroup.style.display = (m !== 'GET' && m !== 'HEAD') ? '' : 'none'; }); // gzip toggle 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'; }); // ---- Run Test ---- 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'); const summaryPanel= document.getElementById('summary-panel'); const summaryCards= document.getElementById('summary-cards'); 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; // Validate headers JSON try { JSON.parse(headers); } catch { alert('Custom Headers must be valid JSON (or leave empty).'); return; } startBtn.disabled = true; startBtn.textContent = '⏳ Running…'; outputLog.textContent = ''; summaryPanel.style.display = 'none'; resultPanel.style.display = ''; resultStatus.textContent = 'running'; resultStatus.className = 'badge running'; 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 }), }); } 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; outputLog.scrollTop = outputLog.scrollHeight; } if (data.done) { es.close(); resultStatus.textContent = data.status; resultStatus.className = `badge ${data.status}`; if (data.summary) renderSummary(data.summary); resetBtn(); } if (data.error) { es.close(); outputLog.textContent += '\n[error] ' + data.error; resetBtn(); } }; es.onerror = () => { es.close(); resetBtn(); }; } function resetBtn() { startBtn.disabled = false; startBtn.textContent = '▶ Run Test'; } function renderSummary(summary) { if (!summary || Object.keys(summary).length === 0) return; summaryPanel.style.display = ''; summaryCards.innerHTML = ''; // ---- Threshold pass/fail banner ---- if (summary.thresholds && summary.thresholds.length) { const allPass = summary.thresholds.every(t => t.pass); const banner = document.createElement('div'); banner.className = `threshold-banner ${allPass ? 'pass' : 'fail'}`; banner.innerHTML = `${allPass ? '✓ All thresholds passed' : '✗ Some thresholds failed'} `; summaryCards.appendChild(banner); } // ---- Metric cards ---- const cards = []; if (summary.http_reqs) { cards.push({ label: 'Total Requests', value: summary.http_reqs[0], cls: 'info' }); cards.push({ label: 'Throughput', value: summary.http_reqs[1], cls: 'info' }); } if (summary.http_req_failed) { const pct = parseFloat(summary.http_req_failed[0]); const detail = summary.http_req_failed[1] != null ? ` (${summary.http_req_failed[1]}/${summary.http_req_failed[2]})` : ''; cards.push({ label: 'Failed Requests', value: summary.http_req_failed[0] + detail, cls: pct > 0 ? 'bad' : 'good' }); } if (summary.checks) { const pct = parseFloat(summary.checks[0]); cards.push({ label: 'Checks Passed', value: `${summary.checks[0]} (${summary.checks[1]}/${summary.checks[2]})`, cls: pct < 100 ? 'bad' : 'good' }); } if (summary.http_req_duration) { const d = summary.http_req_duration; // d = [avg, min, med, max, p90, p95, p99] cards.push({ label: 'Avg Response', value: d[0], cls: 'info' }); cards.push({ label: 'Median (p50)', value: d[2], cls: 'info' }); cards.push({ label: 'p(90)', value: d[4], cls: 'info' }); cards.push({ label: 'p(95)', value: d[5], cls: 'info' }); cards.push({ label: 'p(99)', value: d[6], cls: 'info' }); cards.push({ label: 'Max Response', value: d[3], cls: 'info' }); } if (summary.iterations) { cards.push({ label: 'Iterations', value: summary.iterations[0], cls: 'info' }); cards.push({ label: 'Iter/s', value: summary.iterations[1], cls: 'info' }); } if (summary.data_received) { cards.push({ label: 'Data Received', value: summary.data_received[0], cls: 'info' }); } const grid = document.createElement('div'); grid.className = 'summary-grid'; for (const c of cards) { const div = document.createElement('div'); div.className = 'summary-card'; div.innerHTML = `
${c.label}
${c.value}
`; grid.appendChild(div); } summaryCards.appendChild(grid); } // ---- 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`; let summaryLine = ''; if (t.summary) { const s = t.summary; const parts = []; if (s.http_reqs) parts.push(`${s.http_reqs[0]} reqs @ ${s.http_reqs[1]}`); if (s.http_req_duration) parts.push(`avg ${s.http_req_duration[0]}`); if (s.http_req_failed) parts.push(`${s.http_req_failed[0]} failed`); summaryLine = parts.join(' · '); } 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 = `
${t.status} ${escHtml(t.url)} ${t.http_method} · ${t.vus} VUs · ${dur} ${cacheBadge}${gzipBadge}
${date}
${summaryLine ? `
${escHtml(summaryLine)}
` : ''}
`; 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) { // Toggle: if detail already open for this item, close it 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 detail = document.createElement('div'); detail.id = 'history-detail'; detail.dataset.for = id; let summaryHtml = ''; if (test.summary && Object.keys(test.summary).length) { const s = test.summary; const rows = [ s.http_reqs && `Total Requests${s.http_reqs[0]} (${s.http_reqs[1]})`, s.http_req_duration && `Duration avg/med/p95/max${s.http_req_duration[0]} / ${s.http_req_duration[2]} / ${s.http_req_duration[5]} / ${s.http_req_duration[3]}`, s.http_req_failed && `Failure Rate${s.http_req_failed[0]}`, s.iterations && `Iterations${s.iterations[0]} (${s.iterations[1]})`, ].filter(Boolean).join(''); if (rows) summaryHtml = `${rows}
`; } detail.innerHTML = `

Details: ${escHtml(test.url)}

${summaryHtml}
${escHtml(test.output || '(no output)')}
`; // Add style to table cells inline detail.querySelectorAll('td').forEach((td, i) => { td.style.padding = '.3rem .5rem'; td.style.borderBottom = '1px solid var(--border)'; if (i % 2 === 0) td.style.color = 'var(--muted)'; }); itemEl.insertAdjacentElement('afterend', detail); } document.getElementById('refresh-history').addEventListener('click', loadHistory); function escHtml(s) { return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); }