feat: animated live gauge circles (PageSpeed-style)
Backend:
- Re-add --out json=- but properly route: JSON lines → metric
aggregation, non-JSON lines → text log (summary text lands in log)
- Aggregate http_req_duration / http_reqs / http_req_failed / checks
per test into live state (capped 50k samples)
- Broadcast { metrics: {...} } SSE events every second with
score, checksRate, httpOkRate, p90/p95/p99, req/s etc.
- computeScore() composite 0-100 based on p95 + error rate
Frontend:
- 3 animated SVG ring gauges: Score (/100), Checks OK (%), HTTP OK (%)
- Smooth CSS transition on stroke-dashoffset (0.7s cubic-bezier)
- Pulse animation while test is live, stops on completion
- Color: green ≥90/98/99, yellow mid-range, red below thresholds
- Stats strip: Requests, Req/s, Avg, p(90), p(95), p(99)
- p(95) cell color-coded green/yellow/red vs latency
- Threshold pass/fail banner at bottom of gauges panel
- Raw output collapsed in <details> by default
- History items show mini score ring gauge (44px) inline
- History detail expands 3 medium gauges + stat grid
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -169,8 +169,21 @@ app.post('/api/tests', (req, res) => {
|
||||
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,
|
||||
]);
|
||||
@@ -182,17 +195,47 @@ app.post('/api/tests', (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// stdout = final summary table (the important part)
|
||||
k6.stdout.on('data', (data) => broadcast(data.toString()));
|
||||
const broadcastMetrics = () => {
|
||||
const m = computeLiveMetrics(live);
|
||||
for (const client of clients) {
|
||||
client.write(`data: ${JSON.stringify({ metrics: m })}\n\n`);
|
||||
}
|
||||
};
|
||||
|
||||
// stderr = live progress bars + warnings
|
||||
// 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, () => {});
|
||||
|
||||
// Parse summary from output
|
||||
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';
|
||||
|
||||
@@ -262,6 +305,66 @@ app.delete('/api/tests/:id', (req, res) => {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user