Backend: - Track all k6 HTTP phase metrics via PHASE_MAP in ingestPoint: http_req_blocked (DNS+wait), http_req_connecting, http_req_tls_handshaking, http_req_sending, http_req_waiting (TTFB), http_req_receiving - Compute avg + p95 per phase in computeLiveMetrics, broadcast every second - parseSummary now captures all trend metrics with generic trendRe() Frontend: - Live waterfall bar chart updates every second during the test - Each phase has a distinct colour: indigo/sky/purple/emerald/amber/blue - Bar width = phase avg as % of total request time - TTFB p95 + p99 shown below the waterfall - Bars animate smoothly with CSS transitions - Waterfall resets cleanly on new test start Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
466 lines
16 KiB
JavaScript
466 lines
16 KiB
JavaScript
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,
|
|
cache_mode TEXT NOT NULL DEFAULT 'normal',
|
|
gzip INTEGER NOT NULL DEFAULT 1,
|
|
device TEXT NOT NULL DEFAULT 'desktop',
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
created_at INTEGER NOT NULL,
|
|
finished_at INTEGER,
|
|
summary TEXT,
|
|
output TEXT
|
|
)
|
|
`);
|
|
|
|
// Non-destructive migration for existing DBs
|
|
try { db.exec(`ALTER TABLE tests ADD COLUMN cache_mode TEXT NOT NULL DEFAULT 'normal'`); } catch {}
|
|
try { db.exec(`ALTER TABLE tests ADD COLUMN gzip INTEGER NOT NULL DEFAULT 1`); } catch {}
|
|
try { db.exec(`ALTER TABLE tests ADD COLUMN device TEXT NOT NULL DEFAULT 'desktop'`); } catch {}
|
|
|
|
// Stream active test outputs (testId -> {process, clients})
|
|
const activeTests = {};
|
|
|
|
const USER_AGENTS = {
|
|
desktop: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
|
mobile: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1',
|
|
};
|
|
|
|
// Generate a k6 script from test params
|
|
function generateScript(params) {
|
|
const { url, vus, duration, rpsLimit, httpMethod, requestBody, headers, cacheMode, gzip, device } = params;
|
|
|
|
// Build the merged headers object
|
|
const userHeaders = (() => {
|
|
try { return JSON.parse(headers || '{}'); } catch { return {}; }
|
|
})();
|
|
|
|
// User-Agent
|
|
userHeaders['User-Agent'] = USER_AGENTS[device] || USER_AGENTS.desktop;
|
|
|
|
// Cache control headers
|
|
if (cacheMode === 'no-cache' || cacheMode === 'bust') {
|
|
userHeaders['Cache-Control'] = 'no-cache, no-store';
|
|
userHeaders['Pragma'] = 'no-cache';
|
|
}
|
|
|
|
// Encoding header
|
|
if (gzip === false || gzip === 0) {
|
|
userHeaders['Accept-Encoding'] = 'identity';
|
|
}
|
|
// When gzip=true, omit the header entirely so k6 sends its default (gzip, deflate, br)
|
|
|
|
const headersJs = Object.entries(userHeaders)
|
|
.map(([k, v]) => ` '${k.replace(/'/g, "\\'")}': '${v.replace(/'/g, "\\'")}',`)
|
|
.join('\n');
|
|
|
|
// For cache-bust mode, append a random query param per iteration
|
|
const targetUrl = cacheMode === 'bust'
|
|
? `\`${url}${url.includes('?') ? '&' : '?'}_cb=\${Date.now()}-\${Math.random().toString(36).slice(2)}\``
|
|
: `'${url}'`;
|
|
|
|
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()}(${targetUrl}${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 = '{}',
|
|
cacheMode = 'normal',
|
|
gzip = true,
|
|
device = 'desktop',
|
|
} = 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, cache_mode, gzip, device, status, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', ?)
|
|
`).run(id, url, vus, duration, rpsLimit || null, httpMethod, requestBody, headers, cacheMode, gzip ? 1 : 0, device, createdAt);
|
|
|
|
const script = generateScript({ url, vus, duration, rpsLimit, httpMethod, requestBody, headers, cacheMode, gzip, device });
|
|
const tmpScript = path.join(os.tmpdir(), `k6-script-${id}.js`);
|
|
fs.writeFileSync(tmpScript, script);
|
|
|
|
let outputBuffer = '';
|
|
const clients = new Set();
|
|
activeTests[id] = { clients };
|
|
|
|
// Live metric aggregation state
|
|
const live = {
|
|
durations: [], // http_req_duration (ms)
|
|
ttfb: [], // http_req_waiting (ms)
|
|
connecting: [], // http_req_connecting (ms)
|
|
tls: [], // http_req_tls_handshaking (ms)
|
|
sending: [], // http_req_sending (ms)
|
|
receiving: [], // http_req_receiving (ms)
|
|
blocked: [], // http_req_blocked (ms)
|
|
totalReqs: 0,
|
|
failedReqs: 0,
|
|
checksTotal: 0,
|
|
checksPassed: 0,
|
|
bytesIn: 0,
|
|
bytesOut: 0,
|
|
startTime: Date.now(),
|
|
lastBroadcast: 0,
|
|
};
|
|
|
|
const k6 = spawn('k6', [
|
|
'run',
|
|
'--out', 'json=-',
|
|
'--summary-trend-stats', 'avg,min,med,max,p(90),p(95),p(99)',
|
|
tmpScript,
|
|
]);
|
|
|
|
const broadcast = (data) => {
|
|
outputBuffer += data;
|
|
for (const client of clients) {
|
|
client.write(`data: ${JSON.stringify({ chunk: data })}\n\n`);
|
|
}
|
|
};
|
|
|
|
const broadcastMetrics = () => {
|
|
const m = computeLiveMetrics(live);
|
|
for (const client of clients) {
|
|
client.write(`data: ${JSON.stringify({ metrics: m })}\n\n`);
|
|
}
|
|
};
|
|
|
|
// stdout = JSON metric data points + final summary text (non-JSON lines)
|
|
let jsonBuf = '';
|
|
k6.stdout.on('data', (chunk) => {
|
|
jsonBuf += chunk.toString();
|
|
const lines = jsonBuf.split('\n');
|
|
jsonBuf = lines.pop(); // keep incomplete last line
|
|
for (const line of lines) {
|
|
if (!line.trim()) { broadcast('\n'); continue; }
|
|
try {
|
|
const obj = JSON.parse(line);
|
|
if (obj.type === 'Point') ingestPoint(obj, live);
|
|
// throttle metric broadcasts to once per second
|
|
const now = Date.now();
|
|
if (now - live.lastBroadcast >= 1000) {
|
|
live.lastBroadcast = now;
|
|
broadcastMetrics();
|
|
}
|
|
} catch {
|
|
// Not JSON — it's the human-readable summary; forward to log
|
|
broadcast(line + '\n');
|
|
}
|
|
}
|
|
});
|
|
|
|
// stderr = progress bars + warnings
|
|
k6.stderr.on('data', (data) => broadcast(data.toString()));
|
|
|
|
k6.on('close', (code) => {
|
|
fs.unlink(tmpScript, () => {});
|
|
|
|
const summary = parseSummary(outputBuffer);
|
|
// Merge live computed metrics into summary for persistence
|
|
summary.live = computeLiveMetrics(live);
|
|
|
|
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, cache_mode, gzip, device, 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 });
|
|
});
|
|
|
|
// Ingest a single k6 JSON metric point into the live aggregation state
|
|
const PHASE_MAP = {
|
|
http_req_duration: 'durations',
|
|
http_req_waiting: 'ttfb',
|
|
http_req_connecting: 'connecting',
|
|
http_req_tls_handshaking:'tls',
|
|
http_req_sending: 'sending',
|
|
http_req_receiving: 'receiving',
|
|
http_req_blocked: 'blocked',
|
|
};
|
|
|
|
function ingestPoint(obj, live) {
|
|
const { metric, data } = obj;
|
|
const arr = PHASE_MAP[metric];
|
|
if (arr) {
|
|
if (live[arr].length < 20000) live[arr].push(data.value);
|
|
return;
|
|
}
|
|
if (metric === 'http_reqs') {
|
|
live.totalReqs++;
|
|
} else if (metric === 'http_req_failed') {
|
|
if (data.value === 1) live.failedReqs++;
|
|
} else if (metric === 'checks') {
|
|
live.checksTotal++;
|
|
if (data.value === 1) live.checksPassed++;
|
|
} else if (metric === 'data_received') {
|
|
live.bytesIn += data.value;
|
|
} else if (metric === 'data_sent') {
|
|
live.bytesOut += data.value;
|
|
}
|
|
}
|
|
|
|
// Compute snapshot of live metrics for broadcast
|
|
function computeLiveMetrics(live) {
|
|
const elapsed = Math.max(1, (Date.now() - live.startTime) / 1000);
|
|
const sorted = live.durations.length ? [...live.durations].sort((a, b) => a - b) : [];
|
|
const pct = (p) => sorted.length ? sorted[Math.min(Math.floor(sorted.length * p / 100), sorted.length - 1)] : 0;
|
|
const avg = sorted.length ? sorted.reduce((a, b) => a + b, 0) / sorted.length : 0;
|
|
|
|
const checksRate = live.checksTotal > 0 ? live.checksPassed / live.checksTotal * 100 : 100;
|
|
const httpOkRate = live.totalReqs > 0 ? (1 - live.failedReqs / live.totalReqs) * 100 : 100;
|
|
const httpErrRate = live.totalReqs > 0 ? live.failedReqs / live.totalReqs * 100 : 0;
|
|
|
|
const p95 = pct(95);
|
|
const score = computeScore({ p95, httpErrRate, checksRate });
|
|
|
|
const bwInMBps = +(live.bytesIn / elapsed / 1048576).toFixed(2);
|
|
const bwOutMBps = +(live.bytesOut / elapsed / 1048576).toFixed(2);
|
|
const avgRespKB = live.totalReqs > 0
|
|
? +(live.bytesIn / live.totalReqs / 1024).toFixed(1) : 0;
|
|
|
|
// Helper: avg of an array
|
|
const arrAvg = (arr) => arr.length
|
|
? +(arr.reduce((a, b) => a + b, 0) / arr.length).toFixed(2) : 0;
|
|
// Helper: percentile of an array
|
|
const arrPct = (arr, p) => {
|
|
if (!arr.length) return 0;
|
|
const s = [...arr].sort((a, b) => a - b);
|
|
return +s[Math.min(Math.floor(s.length * p / 100), s.length - 1)].toFixed(2);
|
|
};
|
|
|
|
const phases = {
|
|
blocked: { avg: arrAvg(live.blocked), p95: arrPct(live.blocked, 95) },
|
|
connecting: { avg: arrAvg(live.connecting), p95: arrPct(live.connecting, 95) },
|
|
tls: { avg: arrAvg(live.tls), p95: arrPct(live.tls, 95) },
|
|
sending: { avg: arrAvg(live.sending), p95: arrPct(live.sending, 95) },
|
|
ttfb: { avg: arrAvg(live.ttfb), p95: arrPct(live.ttfb, 95), p99: arrPct(live.ttfb, 99) },
|
|
receiving: { avg: arrAvg(live.receiving), p95: arrPct(live.receiving, 95) },
|
|
};
|
|
|
|
return {
|
|
totalReqs: live.totalReqs,
|
|
reqPerSec: +(live.totalReqs / elapsed).toFixed(1),
|
|
failedReqs: live.failedReqs,
|
|
httpErrRate: +httpErrRate.toFixed(2),
|
|
httpOkRate: +httpOkRate.toFixed(2),
|
|
checksTotal: live.checksTotal,
|
|
checksPassed:live.checksPassed,
|
|
checksRate: +checksRate.toFixed(2),
|
|
avg: +avg.toFixed(1),
|
|
p90: +pct(90).toFixed(1),
|
|
p95: +p95.toFixed(1),
|
|
p99: +pct(99).toFixed(1),
|
|
max: sorted.length ? +sorted[sorted.length - 1].toFixed(1) : 0,
|
|
score,
|
|
bwInMBps,
|
|
bwOutMBps,
|
|
avgRespKB,
|
|
phases,
|
|
};
|
|
}
|
|
|
|
function computeScore({ p95, httpErrRate, checksRate }) {
|
|
let s = 100;
|
|
const p95s = (p95 || 0) / 1000;
|
|
if (p95s > 5) s -= 50;
|
|
else if (p95s > 3) s -= 30;
|
|
else if (p95s > 2) s -= 20;
|
|
else if (p95s > 1) s -= 10;
|
|
else if (p95s > 0.5) s -= 5;
|
|
s -= Math.min(40, (httpErrRate || 0) * 5);
|
|
s -= Math.min(20, (100 - (checksRate || 100)) * 2);
|
|
return Math.max(0, Math.round(s));
|
|
}
|
|
|
|
// Parse k6 terminal output for key metrics
|
|
function parseSummary(output) {
|
|
const plain = output.replace(/\x1b\[[0-9;]*m/g, '');
|
|
const summary = {};
|
|
|
|
const trendRe = (name) => new RegExp(
|
|
`${name}[\\s.]+avg=([\\d.µms]+)\\s+min=([\\d.µms]+)\\s+med=([\\d.µms]+)\\s+max=([\\d.µms]+)\\s+p\\(90\\)=([\\d.µms]+)\\s+p\\(95\\)=([\\d.µms]+)\\s+p\\(99\\)=([\\d.µms]+)`
|
|
);
|
|
|
|
// Trend metrics: [avg, min, med, max, p90, p95, p99]
|
|
for (const name of [
|
|
'http_req_duration', 'http_req_waiting', 'http_req_blocked',
|
|
'http_req_connecting', 'http_req_tls_handshaking',
|
|
'http_req_sending', 'http_req_receiving', 'iteration_duration',
|
|
]) {
|
|
const m = plain.match(trendRe(name));
|
|
if (m) summary[name] = { avg: m[1], min: m[2], med: m[3], max: m[4], p90: m[5], p95: m[6], p99: m[7] };
|
|
}
|
|
|
|
// Counter / rate metrics
|
|
const patterns = {
|
|
http_reqs: /http_reqs[\s.]+(\d+)\s+([\d.]+\/s)/,
|
|
http_req_failed:/http_req_failed[\s.]+(\d+\.?\d*%)\s+(\d+) out of (\d+)/,
|
|
checks: /checks[\s.]+(\d+\.?\d*%)\s+(\d+) out of (\d+)/,
|
|
iterations: /iterations[\s.]+(\d+)\s+([\d.]+\/s)/,
|
|
vus: /vus[\s.]+(\d+)\s+min=(\d+)\s+max=(\d+)/,
|
|
data_received: /data_received[\s.]+([\d.]+ \w+)\s+([\d.]+ \w+\/s)/,
|
|
data_sent: /data_sent[\s.]+([\d.]+ \w+)\s+([\d.]+ \w+\/s)/,
|
|
};
|
|
for (const [key, re] of Object.entries(patterns)) {
|
|
const m = plain.match(re);
|
|
if (m) summary[key] = m.slice(1);
|
|
}
|
|
|
|
// Threshold pass/fail lines
|
|
const thresholds = [];
|
|
for (const m of plain.matchAll(/(✓|✗)\s+(.+)/g)) {
|
|
thresholds.push({ pass: m[1] === '✓', label: m[2].trim() });
|
|
}
|
|
if (thresholds.length) summary.thresholds = thresholds;
|
|
|
|
return summary;
|
|
}
|
|
|
|
const PORT = process.env.PORT || 8118;
|
|
app.listen(PORT, () => console.log(`k6 Web UI running on http://0.0.0.0:${PORT}`));
|