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:
2026-05-01 20:24:59 +02:00
parent cf51a4620b
commit 175ba3ec7a
4 changed files with 645 additions and 229 deletions

View File

@@ -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