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 values (ms) p90durations: [], // sampled for percentile calc totalReqs: 0, failedReqs: 0, checksTotal: 0, checksPassed: 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 function ingestPoint(obj, live) { const { metric, data } = obj; if (metric === 'http_req_duration') { if (live.durations.length < 50000) live.durations.push(data.value); } else 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++; } } // 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 }); 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, }; } 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) { // Strip ANSI colour codes before matching const plain = output.replace(/\x1b\[[0-9;]*m/g, ''); const summary = {}; const patterns = { http_reqs: /http_reqs[\s.]+(\d+)\s+([\d.]+\/s)/, http_req_duration: /http_req_duration[\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]+)/, 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); } // Extract threshold results (✓ / ✗ 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}`));