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:
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
35
Dockerfile
Normal file
35
Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Build k6 from source
|
||||||
|
FROM golang:1.22-alpine AS k6-builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
RUN git clone --depth=1 https://github.com/grafana/k6.git .
|
||||||
|
RUN go build -o /usr/local/bin/k6 .
|
||||||
|
|
||||||
|
# Final image
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
# Copy k6 binary from builder
|
||||||
|
COPY --from=k6-builder /usr/local/bin/k6 /usr/local/bin/k6
|
||||||
|
|
||||||
|
# Install backend dependencies
|
||||||
|
WORKDIR /app/backend
|
||||||
|
COPY backend/package.json .
|
||||||
|
RUN npm install --production
|
||||||
|
|
||||||
|
COPY backend/server.js .
|
||||||
|
|
||||||
|
# Copy frontend
|
||||||
|
COPY frontend/ /app/frontend/
|
||||||
|
|
||||||
|
# Data volume for SQLite history
|
||||||
|
RUN mkdir -p /data
|
||||||
|
VOLUME /data
|
||||||
|
|
||||||
|
ENV PORT=8118
|
||||||
|
ENV DB_PATH=/data/history.db
|
||||||
|
|
||||||
|
EXPOSE 8118
|
||||||
|
|
||||||
|
CMD ["node", "/app/backend/server.js"]
|
||||||
14
backend/package.json
Normal file
14
backend/package.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "k6-web-ui-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Web UI backend for k6 load testing",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"better-sqlite3": "^9.4.3",
|
||||||
|
"uuid": "^9.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
246
backend/server.js
Normal file
246
backend/server.js
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.static(path.join(__dirname, '../frontend')));
|
||||||
|
|
||||||
|
// Init SQLite
|
||||||
|
const DB_PATH = process.env.DB_PATH || '/data/history.db';
|
||||||
|
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
||||||
|
const db = new Database(DB_PATH);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS tests (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
vus INTEGER NOT NULL,
|
||||||
|
duration INTEGER NOT NULL,
|
||||||
|
rps_limit INTEGER,
|
||||||
|
http_method TEXT NOT NULL DEFAULT 'GET',
|
||||||
|
request_body TEXT,
|
||||||
|
headers TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
finished_at INTEGER,
|
||||||
|
summary TEXT,
|
||||||
|
output TEXT
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Stream active test outputs (testId -> {process, clients})
|
||||||
|
const activeTests = {};
|
||||||
|
|
||||||
|
// Generate a k6 script from test params
|
||||||
|
function generateScript(params) {
|
||||||
|
const { url, vus, duration, rpsLimit, httpMethod, requestBody, headers } = params;
|
||||||
|
|
||||||
|
const parsedHeaders = (() => {
|
||||||
|
try { return JSON.parse(headers || '{}'); } catch { return {}; }
|
||||||
|
})();
|
||||||
|
|
||||||
|
const headersJs = Object.entries(parsedHeaders)
|
||||||
|
.map(([k, v]) => ` '${k.replace(/'/g, "\\'")}': '${v.replace(/'/g, "\\'")}',`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const bodyLine = (httpMethod !== 'GET' && httpMethod !== 'HEAD' && requestBody)
|
||||||
|
? `const body = \`${requestBody.replace(/`/g, '\\`')}\`;`
|
||||||
|
: 'const body = null;';
|
||||||
|
|
||||||
|
const rpsOption = rpsLimit && rpsLimit > 0
|
||||||
|
? ` rps: ${rpsLimit},`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `import http from 'k6/http';
|
||||||
|
import { check, sleep } from 'k6';
|
||||||
|
import { Trend, Rate, Counter } from 'k6/metrics';
|
||||||
|
|
||||||
|
const reqDuration = new Trend('req_duration');
|
||||||
|
const errorRate = new Rate('error_rate');
|
||||||
|
const requestCount = new Counter('request_count');
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
vus: ${vus},
|
||||||
|
duration: '${duration}s',
|
||||||
|
${rpsOption}
|
||||||
|
thresholds: {
|
||||||
|
http_req_duration: ['p(95)<5000'],
|
||||||
|
error_rate: ['rate<0.1'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
${bodyLine}
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
const params = {
|
||||||
|
headers: {
|
||||||
|
${headersJs}
|
||||||
|
},
|
||||||
|
timeout: '30s',
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = http.${httpMethod.toLowerCase()}('${url}'${httpMethod !== 'GET' && httpMethod !== 'HEAD' ? ', body' : ''}, params);
|
||||||
|
|
||||||
|
const ok = check(res, {
|
||||||
|
'status is 2xx': (r) => r.status >= 200 && r.status < 300,
|
||||||
|
'response time < 5s': (r) => r.timings.duration < 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
reqDuration.add(res.timings.duration);
|
||||||
|
errorRate.add(!ok);
|
||||||
|
requestCount.add(1);
|
||||||
|
|
||||||
|
sleep(0.1);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/tests — start a new test
|
||||||
|
app.post('/api/tests', (req, res) => {
|
||||||
|
const {
|
||||||
|
url,
|
||||||
|
vus = 10,
|
||||||
|
duration = 30,
|
||||||
|
rpsLimit = 0,
|
||||||
|
httpMethod = 'GET',
|
||||||
|
requestBody = '',
|
||||||
|
headers = '{}',
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!url || !url.startsWith('http')) {
|
||||||
|
return res.status(400).json({ error: 'Valid URL required (must start with http/https)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = uuidv4();
|
||||||
|
const createdAt = Date.now();
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO tests (id, url, vus, duration, rps_limit, http_method, request_body, headers, status, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'running', ?)
|
||||||
|
`).run(id, url, vus, duration, rpsLimit || null, httpMethod, requestBody, headers, createdAt);
|
||||||
|
|
||||||
|
const script = generateScript({ url, vus, duration, rpsLimit, httpMethod, requestBody, headers });
|
||||||
|
const tmpScript = path.join(os.tmpdir(), `k6-script-${id}.js`);
|
||||||
|
fs.writeFileSync(tmpScript, script);
|
||||||
|
|
||||||
|
let outputBuffer = '';
|
||||||
|
const clients = new Set();
|
||||||
|
activeTests[id] = { clients };
|
||||||
|
|
||||||
|
const k6 = spawn('k6', ['run', '--out', 'json=-', tmpScript]);
|
||||||
|
|
||||||
|
const broadcast = (data) => {
|
||||||
|
outputBuffer += data;
|
||||||
|
for (const client of clients) {
|
||||||
|
client.write(`data: ${JSON.stringify({ chunk: data })}\n\n`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
k6.stdout.on('data', (data) => {
|
||||||
|
// k6 JSON metrics go to stdout when using --out json=-
|
||||||
|
// We collect but don't broadcast raw JSON (it's noisy)
|
||||||
|
});
|
||||||
|
|
||||||
|
k6.stderr.on('data', (data) => {
|
||||||
|
broadcast(data.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
k6.on('close', (code) => {
|
||||||
|
fs.unlink(tmpScript, () => {});
|
||||||
|
|
||||||
|
// Parse summary from output
|
||||||
|
const summary = parseSummary(outputBuffer);
|
||||||
|
const finishedAt = Date.now();
|
||||||
|
const status = code === 0 ? 'completed' : 'failed';
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE tests SET status=?, finished_at=?, summary=?, output=? WHERE id=?
|
||||||
|
`).run(status, finishedAt, JSON.stringify(summary), outputBuffer, id);
|
||||||
|
|
||||||
|
for (const client of clients) {
|
||||||
|
client.write(`data: ${JSON.stringify({ done: true, status, summary })}\n\n`);
|
||||||
|
client.end();
|
||||||
|
}
|
||||||
|
delete activeTests[id];
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ id });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/tests/:id/stream — SSE stream of live output
|
||||||
|
app.get('/api/tests/:id/stream', (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
res.flushHeaders();
|
||||||
|
|
||||||
|
const test = db.prepare('SELECT * FROM tests WHERE id=?').get(id);
|
||||||
|
if (!test) {
|
||||||
|
res.write(`data: ${JSON.stringify({ error: 'Test not found' })}\n\n`);
|
||||||
|
return res.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If already finished, send stored output and done
|
||||||
|
if (test.status !== 'running') {
|
||||||
|
if (test.output) res.write(`data: ${JSON.stringify({ chunk: test.output })}\n\n`);
|
||||||
|
res.write(`data: ${JSON.stringify({ done: true, status: test.status, summary: JSON.parse(test.summary || 'null') })}\n\n`);
|
||||||
|
return res.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise subscribe to live output
|
||||||
|
const active = activeTests[id];
|
||||||
|
if (!active) {
|
||||||
|
res.write(`data: ${JSON.stringify({ done: true, status: 'completed' })}\n\n`);
|
||||||
|
return res.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
active.clients.add(res);
|
||||||
|
req.on('close', () => active.clients.delete(res));
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/tests — list all tests
|
||||||
|
app.get('/api/tests', (req, res) => {
|
||||||
|
const rows = db.prepare('SELECT id, url, vus, duration, rps_limit, http_method, status, created_at, finished_at, summary FROM tests ORDER BY created_at DESC LIMIT 50').all();
|
||||||
|
res.json(rows.map(r => ({ ...r, summary: r.summary ? JSON.parse(r.summary) : null })));
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/tests/:id — single test detail
|
||||||
|
app.get('/api/tests/:id', (req, res) => {
|
||||||
|
const row = db.prepare('SELECT * FROM tests WHERE id=?').get(req.params.id);
|
||||||
|
if (!row) return res.status(404).json({ error: 'Not found' });
|
||||||
|
res.json({ ...row, summary: row.summary ? JSON.parse(row.summary) : null });
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/tests/:id — delete a test from history
|
||||||
|
app.delete('/api/tests/:id', (req, res) => {
|
||||||
|
db.prepare('DELETE FROM tests WHERE id=?').run(req.params.id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parse k6 terminal output for key metrics
|
||||||
|
function parseSummary(output) {
|
||||||
|
const summary = {};
|
||||||
|
const patterns = {
|
||||||
|
http_reqs: /http_reqs\.*\s+([\d.]+)\s+([\d.]+\/s)/,
|
||||||
|
http_req_duration: /http_req_duration\.*\s+avg=([\d.]+\w+)\s+min=([\d.]+\w+)\s+med=([\d.]+\w+)\s+max=([\d.]+\w+)\s+p\(90\)=([\d.]+\w+)\s+p\(95\)=([\d.]+\w+)/,
|
||||||
|
http_req_failed: /http_req_failed\.*\s+([\d.]+%)/,
|
||||||
|
vus: /vus\.*\s+(\d+)\s+min=(\d+)\s+max=(\d+)/,
|
||||||
|
iterations: /iterations\.*\s+([\d.]+)\s+([\d.]+\/s)/,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [key, re] of Object.entries(patterns)) {
|
||||||
|
const m = output.match(re);
|
||||||
|
if (m) summary[key] = m.slice(1);
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 8118;
|
||||||
|
app.listen(PORT, () => console.log(`k6 Web UI running on http://0.0.0.0:${PORT}`));
|
||||||
11
docker-compose.yml
Normal file
11
docker-compose.yml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
services:
|
||||||
|
k6-ui:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8118:8118"
|
||||||
|
volumes:
|
||||||
|
- k6-data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
k6-data:
|
||||||
279
frontend/app.js
Normal file
279
frontend/app.js
Normal 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}">✕</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, '"');
|
||||||
|
}
|
||||||
104
frontend/index.html
Normal file
104
frontend/index.html
Normal 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>⚡ 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">▶ 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">↻ 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
246
frontend/style.css
Normal 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; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user