feat: k6 load testing web UI with Docker

- Multi-stage Dockerfile builds k6 from source (grafana/k6)
- Express backend with SSE live output streaming
- SQLite-backed test history with delete support
- Frontend: URL input, VUs, duration, RPS limit, custom headers
- Persisted via Docker volume, listens on port 8118

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-01 19:36:21 +02:00
commit 71d3faa36c
8 changed files with 938 additions and 0 deletions

279
frontend/app.js Normal file
View File

@@ -0,0 +1,279 @@
'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';
});
// ---- 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() || '{}';
// 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 }),
});
} 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 = '';
const cards = [];
if (summary.http_reqs) {
cards.push({ label: 'Total Requests', value: summary.http_reqs[0], cls: 'info' });
cards.push({ label: 'Req/s', value: summary.http_reqs[1], cls: 'info' });
}
if (summary.http_req_duration) {
cards.push({ label: 'Avg Duration', value: summary.http_req_duration[0], cls: 'info' });
cards.push({ label: 'p(95) Duration', value: summary.http_req_duration[5], cls: 'info' });
cards.push({ label: 'Max Duration', value: summary.http_req_duration[3], cls: 'info' });
}
if (summary.http_req_failed) {
const pct = parseFloat(summary.http_req_failed[0]);
cards.push({ label: 'Failure Rate', value: summary.http_req_failed[0], cls: pct > 5 ? 'bad' : 'good' });
}
if (summary.iterations) {
cards.push({ label: 'Iterations', value: summary.iterations[0], cls: 'info' });
}
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>`;
summaryCards.appendChild(div);
}
}
// ---- 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(' · ');
}
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>
</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}">&#x2715;</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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

104
frontend/index.html Normal file
View File

@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>k6 Load Tester</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="app">
<header>
<h1>&#9889; k6 Load Tester</h1>
<nav>
<button class="tab-btn active" data-tab="run">Run Test</button>
<button class="tab-btn" data-tab="history">History</button>
</nav>
</header>
<!-- RUN TAB -->
<section id="tab-run" class="tab active">
<form id="test-form">
<div class="form-grid">
<div class="form-group full">
<label for="url">Target URL</label>
<input type="url" id="url" placeholder="https://example.com" required />
</div>
<div class="form-group">
<label for="httpMethod">HTTP Method</label>
<select id="httpMethod">
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="PATCH">PATCH</option>
<option value="DELETE">DELETE</option>
<option value="HEAD">HEAD</option>
</select>
</div>
<div class="form-group">
<label for="vus">Virtual Users (VUs)</label>
<div class="range-row">
<input type="range" id="vus" min="1" max="500" value="10" />
<input type="number" id="vus-num" min="1" max="500" value="10" />
</div>
</div>
<div class="form-group">
<label for="duration">Duration (seconds)</label>
<div class="range-row">
<input type="range" id="duration" min="5" max="300" value="30" />
<input type="number" id="duration-num" min="5" max="300" value="30" />
</div>
</div>
<div class="form-group">
<label for="rpsLimit">Max RPS <span class="hint">(0 = unlimited)</span></label>
<input type="number" id="rpsLimit" min="0" value="0" />
</div>
<div class="form-group full" id="body-group" style="display:none">
<label for="requestBody">Request Body</label>
<textarea id="requestBody" rows="4" placeholder='{"key": "value"}'></textarea>
</div>
<div class="form-group full">
<label for="headers">
Custom Headers <span class="hint">(JSON, optional)</span>
</label>
<input type="text" id="headers" placeholder='{"Authorization": "Bearer token"}' />
</div>
</div>
<button type="submit" id="start-btn" class="btn-primary">&#9654; Run Test</button>
</form>
<div id="result-panel" style="display:none">
<div class="result-header">
<h2>Test Running</h2>
<span id="result-status" class="badge running">running</span>
</div>
<pre id="output-log"></pre>
<div id="summary-panel" style="display:none">
<h3>Summary</h3>
<div id="summary-cards" class="summary-grid"></div>
</div>
</div>
</section>
<!-- HISTORY TAB -->
<section id="tab-history" class="tab">
<div class="history-header">
<h2>Test History</h2>
<button id="refresh-history" class="btn-secondary">&#8635; Refresh</button>
</div>
<div id="history-list">
<p class="empty">No tests yet.</p>
</div>
</section>
</div>
<script src="app.js"></script>
</body>
</html>

246
frontend/style.css Normal file
View File

@@ -0,0 +1,246 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f1117;
--surface: #1a1d27;
--border: #2a2d3a;
--accent: #7c3aed;
--accent2: #a78bfa;
--text: #e2e8f0;
--muted: #64748b;
--green: #22c55e;
--red: #ef4444;
--yellow: #f59e0b;
--mono: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
.app { max-width: 900px; margin: 0 auto; padding: 2rem 1.5rem; }
header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 2rem;
flex-wrap: wrap;
gap: 1rem;
}
h1 { font-size: 1.6rem; font-weight: 700; color: var(--accent2); }
nav { display: flex; gap: .5rem; }
.tab-btn {
padding: .45rem 1.1rem;
border-radius: 6px;
border: 1px solid var(--border);
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: .9rem;
transition: all .15s;
}
.tab-btn:hover { color: var(--text); border-color: var(--accent); }
.tab-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.tab { display: none; }
.tab.active { display: block; }
/* FORM */
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.2rem;
margin-bottom: 1.5rem;
}
.form-group { display: flex; flex-direction: column; gap: .4rem; }
.form-group.full { grid-column: 1 / -1; }
label { font-size: .85rem; color: var(--muted); font-weight: 500; }
.hint { font-weight: 400; font-size: .78rem; }
input[type=url],
input[type=text],
input[type=number],
select,
textarea {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
padding: .55rem .75rem;
font-size: .9rem;
outline: none;
transition: border-color .15s;
width: 100%;
}
input:focus, select:focus, textarea:focus { border-color: var(--accent); }
textarea { font-family: var(--mono); resize: vertical; }
.range-row { display: flex; align-items: center; gap: .75rem; }
.range-row input[type=range] { flex: 1; accent-color: var(--accent); }
.range-row input[type=number] { width: 80px; flex-shrink: 0; }
.btn-primary {
background: var(--accent);
color: #fff;
border: none;
border-radius: 8px;
padding: .65rem 2rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background .15s, opacity .15s;
}
.btn-primary:hover { background: #6d28d9; }
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
.btn-secondary {
background: transparent;
color: var(--accent2);
border: 1px solid var(--accent);
border-radius: 6px;
padding: .4rem 1rem;
font-size: .85rem;
cursor: pointer;
transition: background .15s;
}
.btn-secondary:hover { background: rgba(124,58,237,.15); }
/* RESULT */
.result-header {
display: flex;
align-items: center;
gap: 1rem;
margin: 2rem 0 1rem;
}
.result-header h2 { font-size: 1.1rem; }
.badge {
font-size: .75rem;
font-weight: 600;
padding: .25rem .65rem;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: .05em;
}
.badge.running { background: rgba(245,158,11,.15); color: var(--yellow); }
.badge.completed{ background: rgba(34,197,94,.15); color: var(--green); }
.badge.failed { background: rgba(239,68,68,.15); color: var(--red); }
#output-log {
background: #0a0c12;
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
font-family: var(--mono);
font-size: .8rem;
line-height: 1.6;
max-height: 400px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
color: #a5f3fc;
}
/* SUMMARY CARDS */
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1rem;
margin-top: .75rem;
}
.summary-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
}
.summary-card .label { font-size: .75rem; color: var(--muted); margin-bottom: .3rem; }
.summary-card .value { font-size: 1.2rem; font-weight: 700; font-family: var(--mono); }
.summary-card .value.good { color: var(--green); }
.summary-card .value.bad { color: var(--red); }
.summary-card .value.info { color: var(--accent2); }
/* HISTORY */
.history-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.history-item {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem 1.2rem;
margin-bottom: .75rem;
display: grid;
grid-template-columns: 1fr auto;
gap: .5rem;
cursor: pointer;
transition: border-color .15s;
}
.history-item:hover { border-color: var(--accent); }
.history-item .row1 {
display: flex;
align-items: center;
gap: .75rem;
flex-wrap: wrap;
}
.history-item .url { font-weight: 600; word-break: break-all; }
.history-item .meta { font-size: .8rem; color: var(--muted); }
.history-item .actions { display: flex; align-items: flex-start; gap: .5rem; }
.history-item .btn-del {
background: transparent;
border: none;
color: var(--muted);
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: .2rem;
border-radius: 4px;
transition: color .15s;
}
.history-item .btn-del:hover { color: var(--red); }
.history-summary { font-size: .8rem; color: var(--muted); margin-top: .4rem; font-family: var(--mono); }
.empty { color: var(--muted); font-size: .9rem; }
/* Detail modal / inline */
#history-detail {
margin-top: 1rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.2rem;
}
#history-detail pre {
background: #0a0c12;
border: 1px solid var(--border);
border-radius: 6px;
padding: .75rem;
font-family: var(--mono);
font-size: .78rem;
max-height: 350px;
overflow-y: auto;
white-space: pre-wrap;
color: #a5f3fc;
margin-top: .75rem;
}
@media (max-width: 600px) {
.form-grid { grid-template-columns: 1fr; }
.form-group.full { grid-column: 1; }
}