- Cache-mode selector: Normal, No-cache headers, Cache-bust URL+headers - Cache-bust appends random ?_cb= per iteration to bypass CDN/proxy - gzip toggle: on = k6 default (Accept-Encoding: gzip/br), off = identity - Both options stored in DB with non-destructive migration - History items show cache-mode and gzip pills - Schema migration handles existing DBs gracefully Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
333 lines
12 KiB
JavaScript
333 lines
12 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 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 = `<strong>${allPass ? '✓ All thresholds passed' : '✗ Some thresholds failed'}</strong>
|
|
<ul>${summary.thresholds.map(t =>
|
|
`<li class="${t.pass ? 'pass' : 'fail'}">${t.pass ? '✓' : '✗'} ${escHtml(t.label)}</li>`
|
|
).join('')}</ul>`;
|
|
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 = `<div class="label">${c.label}</div><div class="value ${c.cls}">${c.value}</div>`;
|
|
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 = '<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`;
|
|
|
|
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'
|
|
? '<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>
|
|
<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>
|
|
${cacheBadge}${gzipBadge}
|
|
</div>
|
|
<div class="meta">${date}</div>
|
|
${summaryLine ? `<div class="history-summary">${escHtml(summaryLine)}</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) {
|
|
// 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 && `<tr><td>Total Requests</td><td>${s.http_reqs[0]} (${s.http_reqs[1]})</td></tr>`,
|
|
s.http_req_duration && `<tr><td>Duration avg/med/p95/max</td><td>${s.http_req_duration[0]} / ${s.http_req_duration[2]} / ${s.http_req_duration[5]} / ${s.http_req_duration[3]}</td></tr>`,
|
|
s.http_req_failed && `<tr><td>Failure Rate</td><td>${s.http_req_failed[0]}</td></tr>`,
|
|
s.iterations && `<tr><td>Iterations</td><td>${s.iterations[0]} (${s.iterations[1]})</td></tr>`,
|
|
].filter(Boolean).join('');
|
|
if (rows) summaryHtml = `<table style="width:100%;border-collapse:collapse;font-size:.85rem;margin-bottom:.75rem">
|
|
<tbody>${rows}</tbody></table>`;
|
|
}
|
|
|
|
detail.innerHTML = `
|
|
<h3 style="margin-bottom:.75rem">Details: ${escHtml(test.url)}</h3>
|
|
${summaryHtml}
|
|
<pre>${escHtml(test.output || '(no output)')}</pre>
|
|
`;
|
|
|
|
// 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, '>')
|
|
.replace(/"/g, '"');
|
|
}
|