247 lines
7.4 KiB
JavaScript
247 lines
7.4 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,
|
||
|
|
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}`));
|