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();
|
const clients = new Set();
|
||||||
activeTests[id] = { clients };
|
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', [
|
const k6 = spawn('k6', [
|
||||||
'run',
|
'run',
|
||||||
|
'--out', 'json=-',
|
||||||
'--summary-trend-stats', 'avg,min,med,max,p(90),p(95),p(99)',
|
'--summary-trend-stats', 'avg,min,med,max,p(90),p(95),p(99)',
|
||||||
tmpScript,
|
tmpScript,
|
||||||
]);
|
]);
|
||||||
@@ -182,17 +195,47 @@ app.post('/api/tests', (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// stdout = final summary table (the important part)
|
const broadcastMetrics = () => {
|
||||||
k6.stdout.on('data', (data) => broadcast(data.toString()));
|
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.stderr.on('data', (data) => broadcast(data.toString()));
|
||||||
|
|
||||||
k6.on('close', (code) => {
|
k6.on('close', (code) => {
|
||||||
fs.unlink(tmpScript, () => {});
|
fs.unlink(tmpScript, () => {});
|
||||||
|
|
||||||
// Parse summary from output
|
|
||||||
const summary = parseSummary(outputBuffer);
|
const summary = parseSummary(outputBuffer);
|
||||||
|
// Merge live computed metrics into summary for persistence
|
||||||
|
summary.live = computeLiveMetrics(live);
|
||||||
|
|
||||||
const finishedAt = Date.now();
|
const finishedAt = Date.now();
|
||||||
const status = code === 0 ? 'completed' : 'failed';
|
const status = code === 0 ? 'completed' : 'failed';
|
||||||
|
|
||||||
@@ -262,6 +305,66 @@ app.delete('/api/tests/:id', (req, res) => {
|
|||||||
res.json({ ok: true });
|
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
|
// Parse k6 terminal output for key metrics
|
||||||
function parseSummary(output) {
|
function parseSummary(output) {
|
||||||
// Strip ANSI colour codes before matching
|
// Strip ANSI colour codes before matching
|
||||||
|
|||||||
315
frontend/app.js
315
frontend/app.js
@@ -1,6 +1,6 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// Tab switching
|
// ─── Tab switching ────────────────────────────────────────────────────────────
|
||||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const tab = btn.dataset.tab;
|
const tab = btn.dataset.tab;
|
||||||
@@ -12,25 +12,24 @@ document.querySelectorAll('.tab-btn').forEach(btn => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sync range <-> number inputs
|
// ─── Sync range ↔ number ──────────────────────────────────────────────────────
|
||||||
function syncRangeNumber(rangeId, numId) {
|
function syncRangeNumber(rangeId, numId) {
|
||||||
const range = document.getElementById(rangeId);
|
const range = document.getElementById(rangeId);
|
||||||
const num = document.getElementById(numId);
|
const num = document.getElementById(numId);
|
||||||
range.addEventListener('input', () => num.value = range.value);
|
range.addEventListener('input', () => num.value = range.value);
|
||||||
num.addEventListener('input', () => range.value = num.value);
|
num.addEventListener('input', () => range.value = num.value);
|
||||||
}
|
}
|
||||||
syncRangeNumber('vus', 'vus-num');
|
syncRangeNumber('vus', 'vus-num');
|
||||||
syncRangeNumber('duration', 'duration-num');
|
syncRangeNumber('duration', 'duration-num');
|
||||||
|
|
||||||
// Show/hide request body for non-GET methods
|
// ─── Show/hide body for non-GET ───────────────────────────────────────────────
|
||||||
const methodSel = document.getElementById('httpMethod');
|
const methodSel = document.getElementById('httpMethod');
|
||||||
const bodyGroup = document.getElementById('body-group');
|
const bodyGroup = document.getElementById('body-group');
|
||||||
methodSel.addEventListener('change', () => {
|
methodSel.addEventListener('change', () => {
|
||||||
const m = methodSel.value;
|
bodyGroup.style.display = (methodSel.value !== 'GET' && methodSel.value !== 'HEAD') ? '' : 'none';
|
||||||
bodyGroup.style.display = (m !== 'GET' && m !== 'HEAD') ? '' : 'none';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// gzip toggle hint
|
// ─── gzip hint ────────────────────────────────────────────────────────────────
|
||||||
const gzipChk = document.getElementById('gzip');
|
const gzipChk = document.getElementById('gzip');
|
||||||
const gzipHint = document.getElementById('gzip-hint');
|
const gzipHint = document.getElementById('gzip-hint');
|
||||||
gzipChk.addEventListener('change', () => {
|
gzipChk.addEventListener('change', () => {
|
||||||
@@ -39,11 +38,7 @@ gzipChk.addEventListener('change', () => {
|
|||||||
: 'Sends Accept-Encoding: identity — forces uncompressed response';
|
: 'Sends Accept-Encoding: identity — forces uncompressed response';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Device toggle hint
|
// ─── Device hint ─────────────────────────────────────────────────────────────
|
||||||
const UA = {
|
|
||||||
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',
|
|
||||||
};
|
|
||||||
const uaHint = document.getElementById('ua-hint');
|
const uaHint = document.getElementById('ua-hint');
|
||||||
document.querySelectorAll('input[name="device"]').forEach(radio => {
|
document.querySelectorAll('input[name="device"]').forEach(radio => {
|
||||||
radio.addEventListener('change', () => {
|
radio.addEventListener('change', () => {
|
||||||
@@ -53,14 +48,95 @@ document.querySelectorAll('input[name="device"]').forEach(radio => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Run Test ----
|
// ─── Gauge engine ─────────────────────────────────────────────────────────────
|
||||||
const form = document.getElementById('test-form');
|
const CIRC = 2 * Math.PI * 50; // r=50 → 314.16
|
||||||
const startBtn = document.getElementById('start-btn');
|
|
||||||
|
function setGauge(id, pct, color) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
const ring = el.querySelector('.gauge-ring');
|
||||||
|
const val = el.querySelector('.gauge-value');
|
||||||
|
const clamped = Math.max(0, Math.min(100, pct));
|
||||||
|
ring.style.strokeDasharray = CIRC;
|
||||||
|
ring.style.strokeDashoffset = CIRC * (1 - clamped / 100);
|
||||||
|
ring.style.stroke = color;
|
||||||
|
val.textContent = Math.round(clamped);
|
||||||
|
}
|
||||||
|
|
||||||
|
function gaugeColor(val, good, warn) {
|
||||||
|
if (val >= good) return 'var(--green)';
|
||||||
|
if (val >= warn) return 'var(--yellow)';
|
||||||
|
return 'var(--red)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtMs(ms) {
|
||||||
|
if (!ms || ms === 0) return '--';
|
||||||
|
return ms >= 1000 ? (ms / 1000).toFixed(2) + 's' : Math.round(ms) + 'ms';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStat(id, val) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.querySelector('.sc-val').textContent = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGauges(m) {
|
||||||
|
if (!m || m.totalReqs === 0) return;
|
||||||
|
|
||||||
|
setGauge('gauge-score', m.score, gaugeColor(m.score, 90, 50));
|
||||||
|
setGauge('gauge-checks', m.checksRate, gaugeColor(m.checksRate,98, 90));
|
||||||
|
setGauge('gauge-http', m.httpOkRate, gaugeColor(m.httpOkRate,99, 95));
|
||||||
|
|
||||||
|
setStat('sc-reqs', m.totalReqs.toLocaleString());
|
||||||
|
setStat('sc-rps', m.reqPerSec + '/s');
|
||||||
|
setStat('sc-avg', fmtMs(m.avg));
|
||||||
|
setStat('sc-p90', fmtMs(m.p90));
|
||||||
|
setStat('sc-p95', fmtMs(m.p95));
|
||||||
|
setStat('sc-p99', fmtMs(m.p99));
|
||||||
|
|
||||||
|
// colour p95 cell based on threshold (5 s)
|
||||||
|
const p95cell = document.getElementById('sc-p95');
|
||||||
|
if (p95cell) {
|
||||||
|
p95cell.querySelector('.sc-val').style.color =
|
||||||
|
m.p95 < 1000 ? 'var(--green)' : m.p95 < 3000 ? 'var(--yellow)' : 'var(--red)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetGauges() {
|
||||||
|
['gauge-score','gauge-checks','gauge-http'].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
const ring = el.querySelector('.gauge-ring');
|
||||||
|
ring.style.strokeDasharray = CIRC;
|
||||||
|
ring.style.strokeDashoffset = CIRC;
|
||||||
|
ring.style.stroke = 'var(--border)';
|
||||||
|
el.querySelector('.gauge-value').textContent = '--';
|
||||||
|
});
|
||||||
|
['sc-reqs','sc-rps','sc-avg','sc-p90','sc-p95','sc-p99'].forEach(id => setStat(id, '--'));
|
||||||
|
const tb = document.getElementById('threshold-banner');
|
||||||
|
if (tb) { tb.style.display = 'none'; tb.innerHTML = ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderThresholds(summary) {
|
||||||
|
const tb = document.getElementById('threshold-banner');
|
||||||
|
if (!tb) return;
|
||||||
|
const thresholds = summary.thresholds;
|
||||||
|
if (!thresholds || !thresholds.length) return;
|
||||||
|
|
||||||
|
const allPass = thresholds.every(t => t.pass);
|
||||||
|
tb.className = `threshold-banner ${allPass ? 'pass' : 'fail'}`;
|
||||||
|
tb.innerHTML = `<strong>${allPass ? '✓ All thresholds passed' : '✗ Some thresholds failed'}</strong>
|
||||||
|
<ul>${thresholds.map(t =>
|
||||||
|
`<li class="${t.pass ? 'pass' : 'fail'}">${t.pass ? '✓' : '✗'} ${escHtml(t.label)}</li>`
|
||||||
|
).join('')}</ul>`;
|
||||||
|
tb.style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Form submit ──────────────────────────────────────────────────────────────
|
||||||
|
const form = document.getElementById('test-form');
|
||||||
|
const startBtn = document.getElementById('start-btn');
|
||||||
const resultPanel = document.getElementById('result-panel');
|
const resultPanel = document.getElementById('result-panel');
|
||||||
const outputLog = document.getElementById('output-log');
|
const outputLog = document.getElementById('output-log');
|
||||||
const resultStatus= document.getElementById('result-status');
|
const resultStatus= document.getElementById('result-status');
|
||||||
const summaryPanel= document.getElementById('summary-panel');
|
|
||||||
const summaryCards= document.getElementById('summary-cards');
|
|
||||||
|
|
||||||
form.addEventListener('submit', async (e) => {
|
form.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -76,7 +152,6 @@ form.addEventListener('submit', async (e) => {
|
|||||||
const gzip = document.getElementById('gzip').checked;
|
const gzip = document.getElementById('gzip').checked;
|
||||||
const device = document.querySelector('input[name="device"]:checked').value;
|
const device = document.querySelector('input[name="device"]:checked').value;
|
||||||
|
|
||||||
// Validate headers JSON
|
|
||||||
try { JSON.parse(headers); } catch {
|
try { JSON.parse(headers); } catch {
|
||||||
alert('Custom Headers must be valid JSON (or leave empty).');
|
alert('Custom Headers must be valid JSON (or leave empty).');
|
||||||
return;
|
return;
|
||||||
@@ -84,11 +159,16 @@ form.addEventListener('submit', async (e) => {
|
|||||||
|
|
||||||
startBtn.disabled = true;
|
startBtn.disabled = true;
|
||||||
startBtn.textContent = '⏳ Running…';
|
startBtn.textContent = '⏳ Running…';
|
||||||
|
|
||||||
outputLog.textContent = '';
|
outputLog.textContent = '';
|
||||||
summaryPanel.style.display = 'none';
|
document.getElementById('log-details').open = false;
|
||||||
resultPanel.style.display = '';
|
resultPanel.style.display = '';
|
||||||
resultStatus.textContent = 'running';
|
resultStatus.textContent = 'running';
|
||||||
resultStatus.className = 'badge running';
|
resultStatus.className = 'badge running';
|
||||||
|
resetGauges();
|
||||||
|
|
||||||
|
// Add pulsing class while running
|
||||||
|
document.getElementById('gauges-panel').classList.add('live');
|
||||||
|
|
||||||
let res;
|
let res;
|
||||||
try {
|
try {
|
||||||
@@ -122,19 +202,30 @@ function streamTest(id) {
|
|||||||
|
|
||||||
if (data.chunk) {
|
if (data.chunk) {
|
||||||
outputLog.textContent += data.chunk;
|
outputLog.textContent += data.chunk;
|
||||||
outputLog.scrollTop = outputLog.scrollHeight;
|
if (document.getElementById('log-details').open)
|
||||||
|
outputLog.scrollTop = outputLog.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.metrics) {
|
||||||
|
updateGauges(data.metrics);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.done) {
|
if (data.done) {
|
||||||
es.close();
|
es.close();
|
||||||
|
document.getElementById('gauges-panel').classList.remove('live');
|
||||||
resultStatus.textContent = data.status;
|
resultStatus.textContent = data.status;
|
||||||
resultStatus.className = `badge ${data.status}`;
|
resultStatus.className = `badge ${data.status}`;
|
||||||
if (data.summary) renderSummary(data.summary);
|
// Final update from parsed summary + live data
|
||||||
|
if (data.summary) {
|
||||||
|
if (data.summary.live) updateGauges(data.summary.live);
|
||||||
|
renderThresholds(data.summary);
|
||||||
|
}
|
||||||
resetBtn();
|
resetBtn();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
es.close();
|
es.close();
|
||||||
|
document.getElementById('gauges-panel').classList.remove('live');
|
||||||
outputLog.textContent += '\n[error] ' + data.error;
|
outputLog.textContent += '\n[error] ' + data.error;
|
||||||
resetBtn();
|
resetBtn();
|
||||||
}
|
}
|
||||||
@@ -142,6 +233,7 @@ function streamTest(id) {
|
|||||||
|
|
||||||
es.onerror = () => {
|
es.onerror = () => {
|
||||||
es.close();
|
es.close();
|
||||||
|
document.getElementById('gauges-panel').classList.remove('live');
|
||||||
resetBtn();
|
resetBtn();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -151,74 +243,9 @@ function resetBtn() {
|
|||||||
startBtn.textContent = '▶ Run Test';
|
startBtn.textContent = '▶ Run Test';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSummary(summary) {
|
// ─── History ──────────────────────────────────────────────────────────────────
|
||||||
if (!summary || Object.keys(summary).length === 0) return;
|
|
||||||
summaryPanel.style.display = '';
|
|
||||||
summaryCards.innerHTML = '';
|
|
||||||
|
|
||||||
// ---- Threshold pass/fail banner ----
|
|
||||||
if (summary.thresholds && summary.thresholds.length) {
|
|
||||||
const allPass = summary.thresholds.every(t => t.pass);
|
|
||||||
const banner = document.createElement('div');
|
|
||||||
banner.className = `threshold-banner ${allPass ? 'pass' : 'fail'}`;
|
|
||||||
banner.innerHTML = `<strong>${allPass ? '✓ All thresholds passed' : '✗ Some thresholds failed'}</strong>
|
|
||||||
<ul>${summary.thresholds.map(t =>
|
|
||||||
`<li class="${t.pass ? 'pass' : 'fail'}">${t.pass ? '✓' : '✗'} ${escHtml(t.label)}</li>`
|
|
||||||
).join('')}</ul>`;
|
|
||||||
summaryCards.appendChild(banner);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Metric cards ----
|
|
||||||
const cards = [];
|
|
||||||
|
|
||||||
if (summary.http_reqs) {
|
|
||||||
cards.push({ label: 'Total Requests', value: summary.http_reqs[0], cls: 'info' });
|
|
||||||
cards.push({ label: 'Throughput', value: summary.http_reqs[1], cls: 'info' });
|
|
||||||
}
|
|
||||||
if (summary.http_req_failed) {
|
|
||||||
const pct = parseFloat(summary.http_req_failed[0]);
|
|
||||||
const detail = summary.http_req_failed[1] != null
|
|
||||||
? ` (${summary.http_req_failed[1]}/${summary.http_req_failed[2]})`
|
|
||||||
: '';
|
|
||||||
cards.push({ label: 'Failed Requests', value: summary.http_req_failed[0] + detail, cls: pct > 0 ? 'bad' : 'good' });
|
|
||||||
}
|
|
||||||
if (summary.checks) {
|
|
||||||
const pct = parseFloat(summary.checks[0]);
|
|
||||||
cards.push({ label: 'Checks Passed', value: `${summary.checks[0]} (${summary.checks[1]}/${summary.checks[2]})`, cls: pct < 100 ? 'bad' : 'good' });
|
|
||||||
}
|
|
||||||
if (summary.http_req_duration) {
|
|
||||||
const d = summary.http_req_duration;
|
|
||||||
// d = [avg, min, med, max, p90, p95, p99]
|
|
||||||
cards.push({ label: 'Avg Response', value: d[0], cls: 'info' });
|
|
||||||
cards.push({ label: 'Median (p50)', value: d[2], cls: 'info' });
|
|
||||||
cards.push({ label: 'p(90)', value: d[4], cls: 'info' });
|
|
||||||
cards.push({ label: 'p(95)', value: d[5], cls: 'info' });
|
|
||||||
cards.push({ label: 'p(99)', value: d[6], cls: 'info' });
|
|
||||||
cards.push({ label: 'Max Response', value: d[3], cls: 'info' });
|
|
||||||
}
|
|
||||||
if (summary.iterations) {
|
|
||||||
cards.push({ label: 'Iterations', value: summary.iterations[0], cls: 'info' });
|
|
||||||
cards.push({ label: 'Iter/s', value: summary.iterations[1], cls: 'info' });
|
|
||||||
}
|
|
||||||
if (summary.data_received) {
|
|
||||||
cards.push({ label: 'Data Received', value: summary.data_received[0], cls: 'info' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const grid = document.createElement('div');
|
|
||||||
grid.className = 'summary-grid';
|
|
||||||
for (const c of cards) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.className = 'summary-card';
|
|
||||||
div.innerHTML = `<div class="label">${c.label}</div><div class="value ${c.cls}">${c.value}</div>`;
|
|
||||||
grid.appendChild(div);
|
|
||||||
}
|
|
||||||
summaryCards.appendChild(grid);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- History ----
|
|
||||||
async function loadHistory() {
|
async function loadHistory() {
|
||||||
const container = document.getElementById('history-list');
|
const container = document.getElementById('history-list');
|
||||||
|
|
||||||
let tests;
|
let tests;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/tests');
|
const res = await fetch('/api/tests');
|
||||||
@@ -243,14 +270,27 @@ async function loadHistory() {
|
|||||||
const date = new Date(t.created_at).toLocaleString();
|
const date = new Date(t.created_at).toLocaleString();
|
||||||
const dur = t.finished_at ? `${Math.round((t.finished_at - t.created_at) / 1000)}s` : `${t.duration}s`;
|
const dur = t.finished_at ? `${Math.round((t.finished_at - t.created_at) / 1000)}s` : `${t.duration}s`;
|
||||||
|
|
||||||
let summaryLine = '';
|
// Score mini-gauge from saved live data
|
||||||
if (t.summary) {
|
const live = t.summary && t.summary.live;
|
||||||
const s = t.summary;
|
const scorePct = live ? live.score : null;
|
||||||
const parts = [];
|
const scoreColor = scorePct === null ? 'var(--border)'
|
||||||
if (s.http_reqs) parts.push(`${s.http_reqs[0]} reqs @ ${s.http_reqs[1]}`);
|
: scorePct >= 90 ? 'var(--green)' : scorePct >= 50 ? 'var(--yellow)' : 'var(--red)';
|
||||||
if (s.http_req_duration) parts.push(`avg ${s.http_req_duration[0]}`);
|
|
||||||
if (s.http_req_failed) parts.push(`${s.http_req_failed[0]} failed`);
|
const miniGauge = `
|
||||||
summaryLine = parts.join(' · ');
|
<svg viewBox="0 0 40 40" class="mini-gauge" aria-label="Score ${scorePct ?? '--'}">
|
||||||
|
<circle cx="20" cy="20" r="16" fill="none" stroke="var(--border)" stroke-width="4"/>
|
||||||
|
<circle cx="20" cy="20" r="16" fill="none" stroke="${scoreColor}" stroke-width="4"
|
||||||
|
stroke-dasharray="${2 * Math.PI * 16}"
|
||||||
|
stroke-dashoffset="${2 * Math.PI * 16 * (1 - (scorePct ?? 0) / 100)}"
|
||||||
|
stroke-linecap="round" transform="rotate(-90 20 20)"/>
|
||||||
|
<text x="20" y="24" text-anchor="middle" font-size="10" fill="${scoreColor}" font-weight="700">${scorePct ?? '--'}</text>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
let metaLine = '';
|
||||||
|
if (live) {
|
||||||
|
metaLine = `${live.totalReqs.toLocaleString()} reqs · ${live.reqPerSec}/s · p95 ${fmtMs(live.p95)}`;
|
||||||
|
} else if (t.summary && t.summary.http_reqs) {
|
||||||
|
metaLine = `${t.summary.http_reqs[0]} reqs @ ${t.summary.http_reqs[1]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const deviceBadge = t.device === 'mobile'
|
const deviceBadge = t.device === 'mobile'
|
||||||
@@ -266,7 +306,10 @@ async function loadHistory() {
|
|||||||
: '<span class="pill no-gzip">no-gzip</span>';
|
: '<span class="pill no-gzip">no-gzip</span>';
|
||||||
|
|
||||||
item.innerHTML = `
|
item.innerHTML = `
|
||||||
<div>
|
<div class="history-left">
|
||||||
|
${miniGauge}
|
||||||
|
</div>
|
||||||
|
<div class="history-body">
|
||||||
<div class="row1">
|
<div class="row1">
|
||||||
<span class="badge ${t.status}">${t.status}</span>
|
<span class="badge ${t.status}">${t.status}</span>
|
||||||
<span class="url">${escHtml(t.url)}</span>
|
<span class="url">${escHtml(t.url)}</span>
|
||||||
@@ -274,7 +317,7 @@ async function loadHistory() {
|
|||||||
${deviceBadge}${cacheBadge}${gzipBadge}
|
${deviceBadge}${cacheBadge}${gzipBadge}
|
||||||
</div>
|
</div>
|
||||||
<div class="meta">${date}</div>
|
<div class="meta">${date}</div>
|
||||||
${summaryLine ? `<div class="history-summary">${escHtml(summaryLine)}</div>` : ''}
|
${metaLine ? `<div class="history-summary">${escHtml(metaLine)}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn-del" title="Delete" data-id="${t.id}">✕</button>
|
<button class="btn-del" title="Delete" data-id="${t.id}">✕</button>
|
||||||
@@ -289,13 +332,11 @@ async function loadHistory() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
item.addEventListener('click', () => showHistoryDetail(t.id, item));
|
item.addEventListener('click', () => showHistoryDetail(t.id, item));
|
||||||
|
|
||||||
container.appendChild(item);
|
container.appendChild(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showHistoryDetail(id, itemEl) {
|
async function showHistoryDetail(id, itemEl) {
|
||||||
// Toggle: if detail already open for this item, close it
|
|
||||||
const existing = document.getElementById('history-detail');
|
const existing = document.getElementById('history-detail');
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const wasThis = existing.dataset.for === id;
|
const wasThis = existing.dataset.for === id;
|
||||||
@@ -303,48 +344,70 @@ async function showHistoryDetail(id, itemEl) {
|
|||||||
if (wasThis) return;
|
if (wasThis) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(`/api/tests/${id}`);
|
const res = await fetch(`/api/tests/${id}`);
|
||||||
const test = await res.json();
|
const test = await res.json();
|
||||||
|
const live = test.summary && test.summary.live;
|
||||||
|
|
||||||
const detail = document.createElement('div');
|
const detail = document.createElement('div');
|
||||||
detail.id = 'history-detail';
|
detail.id = 'history-detail';
|
||||||
detail.dataset.for = id;
|
detail.dataset.for = id;
|
||||||
|
|
||||||
let summaryHtml = '';
|
// Mini gauges row
|
||||||
if (test.summary && Object.keys(test.summary).length) {
|
let gaugesHtml = '';
|
||||||
const s = test.summary;
|
if (live) {
|
||||||
const rows = [
|
const scoreC = live.score >= 90 ? 'var(--green)' : live.score >= 50 ? 'var(--yellow)' : 'var(--red)';
|
||||||
s.http_reqs && `<tr><td>Total Requests</td><td>${s.http_reqs[0]} (${s.http_reqs[1]})</td></tr>`,
|
const checkC = live.checksRate >= 98 ? 'var(--green)' : live.checksRate >= 90 ? 'var(--yellow)' : 'var(--red)';
|
||||||
s.http_req_duration && `<tr><td>Duration avg/med/p95/max</td><td>${s.http_req_duration[0]} / ${s.http_req_duration[2]} / ${s.http_req_duration[5]} / ${s.http_req_duration[3]}</td></tr>`,
|
const httpC = live.httpOkRate >= 99 ? 'var(--green)' : live.httpOkRate >= 95 ? 'var(--yellow)' : 'var(--red)';
|
||||||
s.http_req_failed && `<tr><td>Failure Rate</td><td>${s.http_req_failed[0]}</td></tr>`,
|
gaugesHtml = `<div class="detail-gauges">
|
||||||
s.iterations && `<tr><td>Iterations</td><td>${s.iterations[0]} (${s.iterations[1]})</td></tr>`,
|
${detailGaugeSvg('Score', live.score, '/100', scoreC)}
|
||||||
].filter(Boolean).join('');
|
${detailGaugeSvg('Checks OK',live.checksRate, '%', checkC)}
|
||||||
if (rows) summaryHtml = `<table style="width:100%;border-collapse:collapse;font-size:.85rem;margin-bottom:.75rem">
|
${detailGaugeSvg('HTTP OK', live.httpOkRate, '%', httpC)}
|
||||||
<tbody>${rows}</tbody></table>`;
|
</div>
|
||||||
|
<div class="detail-stats">
|
||||||
|
${detailStat('Requests', live.totalReqs.toLocaleString())}
|
||||||
|
${detailStat('Req/s', live.reqPerSec + '/s')}
|
||||||
|
${detailStat('Avg', fmtMs(live.avg))}
|
||||||
|
${detailStat('p(90)', fmtMs(live.p90))}
|
||||||
|
${detailStat('p(95)', fmtMs(live.p95))}
|
||||||
|
${detailStat('p(99)', fmtMs(live.p99))}
|
||||||
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
detail.innerHTML = `
|
detail.innerHTML = `
|
||||||
<h3 style="margin-bottom:.75rem">Details: ${escHtml(test.url)}</h3>
|
<h3 class="detail-title">${escHtml(test.url)}</h3>
|
||||||
${summaryHtml}
|
${gaugesHtml}
|
||||||
<pre>${escHtml(test.output || '(no output)')}</pre>
|
<details><summary>Raw Output</summary><pre>${escHtml(test.output || '(no output)')}</pre></details>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Add style to table cells inline
|
|
||||||
detail.querySelectorAll('td').forEach((td, i) => {
|
|
||||||
td.style.padding = '.3rem .5rem';
|
|
||||||
td.style.borderBottom = '1px solid var(--border)';
|
|
||||||
if (i % 2 === 0) td.style.color = 'var(--muted)';
|
|
||||||
});
|
|
||||||
|
|
||||||
itemEl.insertAdjacentElement('afterend', detail);
|
itemEl.insertAdjacentElement('afterend', detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function detailGaugeSvg(title, pct, unit, color) {
|
||||||
|
const r = 28, c = 2 * Math.PI * r;
|
||||||
|
const offset = c * (1 - Math.min(100, Math.max(0, pct || 0)) / 100);
|
||||||
|
return `<div class="dg-wrap">
|
||||||
|
<svg viewBox="0 0 72 72" class="dg-svg">
|
||||||
|
<circle cx="36" cy="36" r="${r}" fill="none" stroke="var(--border)" stroke-width="5"/>
|
||||||
|
<circle cx="36" cy="36" r="${r}" fill="none" stroke="${color}" stroke-width="5"
|
||||||
|
stroke-dasharray="${c.toFixed(2)}" stroke-dashoffset="${offset.toFixed(2)}"
|
||||||
|
stroke-linecap="round" transform="rotate(-90 36 36)"/>
|
||||||
|
</svg>
|
||||||
|
<div class="dg-inner">
|
||||||
|
<span class="dg-val" style="color:${color}">${Math.round(pct ?? 0)}</span>
|
||||||
|
<span class="dg-unit">${unit}</span>
|
||||||
|
<span class="dg-title">${title}</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailStat(label, value) {
|
||||||
|
return `<div class="detail-stat"><span class="ds-val">${value}</span><span class="ds-lbl">${label}</span></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('refresh-history').addEventListener('click', loadHistory);
|
document.getElementById('refresh-history').addEventListener('click', loadHistory);
|
||||||
|
|
||||||
function escHtml(s) {
|
function escHtml(s) {
|
||||||
return String(s)
|
return String(s)
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&').replace(/</g, '<')
|
||||||
.replace(/</g, '<')
|
.replace(/>/g, '>').replace(/"/g, '"');
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,14 +108,83 @@
|
|||||||
|
|
||||||
<div id="result-panel" style="display:none">
|
<div id="result-panel" style="display:none">
|
||||||
<div class="result-header">
|
<div class="result-header">
|
||||||
<h2>Test Running</h2>
|
<h2>Test Results</h2>
|
||||||
<span id="result-status" class="badge running">running</span>
|
<span id="result-status" class="badge running">running</span>
|
||||||
</div>
|
</div>
|
||||||
<pre id="output-log"></pre>
|
|
||||||
<div id="summary-panel" style="display:none">
|
<!-- Gauge circles -->
|
||||||
<h3>Summary</h3>
|
<div id="gauges-panel">
|
||||||
<div id="summary-cards" class="summary-grid"></div>
|
<div class="gauges-main">
|
||||||
|
|
||||||
|
<div class="gauge-wrap" id="gauge-score">
|
||||||
|
<svg viewBox="0 0 120 120" class="gauge-svg" aria-label="Performance score">
|
||||||
|
<circle class="gauge-track" cx="60" cy="60" r="50"/>
|
||||||
|
<circle class="gauge-ring" cx="60" cy="60" r="50"/>
|
||||||
|
</svg>
|
||||||
|
<div class="gauge-inner">
|
||||||
|
<span class="gauge-value" aria-live="polite">--</span>
|
||||||
|
<span class="gauge-sub">/100</span>
|
||||||
|
<span class="gauge-title">Score</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="gauge-wrap" id="gauge-checks">
|
||||||
|
<svg viewBox="0 0 120 120" class="gauge-svg" aria-label="Checks passed">
|
||||||
|
<circle class="gauge-track" cx="60" cy="60" r="50"/>
|
||||||
|
<circle class="gauge-ring" cx="60" cy="60" r="50"/>
|
||||||
|
</svg>
|
||||||
|
<div class="gauge-inner">
|
||||||
|
<span class="gauge-value" aria-live="polite">--</span>
|
||||||
|
<span class="gauge-sub">%</span>
|
||||||
|
<span class="gauge-title">Checks OK</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="gauge-wrap" id="gauge-http">
|
||||||
|
<svg viewBox="0 0 120 120" class="gauge-svg" aria-label="HTTP success rate">
|
||||||
|
<circle class="gauge-track" cx="60" cy="60" r="50"/>
|
||||||
|
<circle class="gauge-ring" cx="60" cy="60" r="50"/>
|
||||||
|
</svg>
|
||||||
|
<div class="gauge-inner">
|
||||||
|
<span class="gauge-value" aria-live="polite">--</span>
|
||||||
|
<span class="gauge-sub">%</span>
|
||||||
|
<span class="gauge-title">HTTP OK</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live stat strip -->
|
||||||
|
<div class="stats-strip" id="stats-strip">
|
||||||
|
<div class="stat-cell" id="sc-reqs">
|
||||||
|
<span class="sc-val">--</span><span class="sc-lbl">Requests</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-cell" id="sc-rps">
|
||||||
|
<span class="sc-val">--</span><span class="sc-lbl">Req / s</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-cell" id="sc-avg">
|
||||||
|
<span class="sc-val">--</span><span class="sc-lbl">Avg</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-cell" id="sc-p90">
|
||||||
|
<span class="sc-val">--</span><span class="sc-lbl">p(90)</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-cell" id="sc-p95">
|
||||||
|
<span class="sc-val">--</span><span class="sc-lbl">p(95)</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-cell" id="sc-p99">
|
||||||
|
<span class="sc-val">--</span><span class="sc-lbl">p(99)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Threshold results -->
|
||||||
|
<div id="threshold-banner" style="display:none"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Raw log (collapsible) -->
|
||||||
|
<details id="log-details">
|
||||||
|
<summary>Raw Output</summary>
|
||||||
|
<pre id="output-log"></pre>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ body {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app { max-width: 900px; margin: 0 auto; padding: 2rem 1.5rem; }
|
.app { max-width: 920px; margin: 0 auto; padding: 2rem 1.5rem; }
|
||||||
|
|
||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -33,7 +33,6 @@ header {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h1 { font-size: 1.6rem; font-weight: 700; color: var(--accent2); }
|
h1 { font-size: 1.6rem; font-weight: 700; color: var(--accent2); }
|
||||||
|
|
||||||
nav { display: flex; gap: .5rem; }
|
nav { display: flex; gap: .5rem; }
|
||||||
|
|
||||||
.tab-btn {
|
.tab-btn {
|
||||||
@@ -46,25 +45,24 @@ nav { display: flex; gap: .5rem; }
|
|||||||
font-size: .9rem;
|
font-size: .9rem;
|
||||||
transition: all .15s;
|
transition: all .15s;
|
||||||
}
|
}
|
||||||
.tab-btn:hover { color: var(--text); border-color: var(--accent); }
|
.tab-btn:hover { color: var(--text); border-color: var(--accent); }
|
||||||
.tab-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
.tab-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||||
|
|
||||||
.tab { display: none; }
|
.tab { display: none; }
|
||||||
.tab.active { display: block; }
|
.tab.active { display: block; }
|
||||||
|
|
||||||
/* FORM */
|
/* ── FORM ───────────────────────────────────────────────────────────────────── */
|
||||||
.form-grid {
|
.form-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 1.2rem;
|
gap: 1.2rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group { display: flex; flex-direction: column; gap: .4rem; }
|
.form-group { display: flex; flex-direction: column; gap: .4rem; }
|
||||||
.form-group.full { grid-column: 1 / -1; }
|
.form-group.full { grid-column: 1 / -1; }
|
||||||
|
|
||||||
label { font-size: .85rem; color: var(--muted); font-weight: 500; }
|
label { font-size: .85rem; color: var(--muted); font-weight: 500; }
|
||||||
.hint { font-weight: 400; font-size: .78rem; }
|
.hint { font-weight: 400; font-size: .78rem; color: var(--muted); }
|
||||||
|
|
||||||
input[type=url],
|
input[type=url],
|
||||||
input[type=text],
|
input[type=text],
|
||||||
@@ -85,7 +83,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
|||||||
textarea { font-family: var(--mono); resize: vertical; }
|
textarea { font-family: var(--mono); resize: vertical; }
|
||||||
|
|
||||||
.range-row { display: flex; align-items: center; gap: .75rem; }
|
.range-row { display: flex; align-items: center; gap: .75rem; }
|
||||||
.range-row input[type=range] { flex: 1; accent-color: var(--accent); }
|
.range-row input[type=range] { flex: 1; accent-color: var(--accent); }
|
||||||
.range-row input[type=number] { width: 80px; flex-shrink: 0; }
|
.range-row input[type=number] { width: 80px; flex-shrink: 0; }
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@@ -99,7 +97,7 @@ textarea { font-family: var(--mono); resize: vertical; }
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background .15s, opacity .15s;
|
transition: background .15s, opacity .15s;
|
||||||
}
|
}
|
||||||
.btn-primary:hover { background: #6d28d9; }
|
.btn-primary:hover { background: #6d28d9; }
|
||||||
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
|
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
@@ -112,6 +110,7 @@ textarea { font-family: var(--mono); resize: vertical; }
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background .15s;
|
transition: background .15s;
|
||||||
}
|
}
|
||||||
|
.btn-secondary:hover { background: rgba(124,58,237,.15); }
|
||||||
|
|
||||||
/* TOGGLE SWITCH */
|
/* TOGGLE SWITCH */
|
||||||
.toggle-row { display: flex; align-items: center; gap: .75rem; margin-top: .15rem; }
|
.toggle-row { display: flex; align-items: center; gap: .75rem; margin-top: .15rem; }
|
||||||
@@ -138,23 +137,6 @@ textarea { font-family: var(--mono); resize: vertical; }
|
|||||||
.toggle input:checked + .toggle-track::after { transform: translateX(18px); }
|
.toggle input:checked + .toggle-track::after { transform: translateX(18px); }
|
||||||
.toggle-label { font-size: .88rem; color: var(--text); }
|
.toggle-label { font-size: .88rem; color: var(--text); }
|
||||||
|
|
||||||
/* PILLS */
|
|
||||||
.pill {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: .72rem;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: .15rem .5rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
letter-spacing: .04em;
|
|
||||||
}
|
|
||||||
.pill.cached { background: rgba(34,197,94,.12); color: #4ade80; }
|
|
||||||
.pill.no-cache { background: rgba(245,158,11,.12); color: #fbbf24; }
|
|
||||||
.pill.bust { background: rgba(239,68,68,.12); color: #f87171; }
|
|
||||||
.pill.gzip { background: rgba(124,58,237,.15); color: var(--accent2); }
|
|
||||||
.pill.no-gzip { background: rgba(100,116,139,.12); color: var(--muted); }
|
|
||||||
.pill.desktop { background: rgba(56,189,248,.12); color: #38bdf8; }
|
|
||||||
.pill.mobile { background: rgba(251,146,60,.12); color: #fb923c; }
|
|
||||||
|
|
||||||
/* DEVICE TOGGLE */
|
/* DEVICE TOGGLE */
|
||||||
.device-toggle { display: flex; gap: 0; margin-top: .15rem; }
|
.device-toggle { display: flex; gap: 0; margin-top: .15rem; }
|
||||||
.device-toggle input[type=radio] { display: none; }
|
.device-toggle input[type=radio] { display: none; }
|
||||||
@@ -172,83 +154,214 @@ textarea { font-family: var(--mono); resize: vertical; }
|
|||||||
.device-btn:first-of-type { border-radius: 6px 0 0 6px; }
|
.device-btn:first-of-type { border-radius: 6px 0 0 6px; }
|
||||||
.device-btn:last-of-type { border-radius: 0 6px 6px 0; border-left: none; }
|
.device-btn:last-of-type { border-radius: 0 6px 6px 0; border-left: none; }
|
||||||
.device-toggle input[type=radio]:checked + .device-btn {
|
.device-toggle input[type=radio]:checked + .device-btn {
|
||||||
background: var(--accent);
|
background: var(--accent); color: #fff; border-color: var(--accent);
|
||||||
color: #fff;
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
}
|
||||||
.btn-secondary:hover { background: rgba(124,58,237,.15); }
|
|
||||||
|
|
||||||
/* RESULT */
|
/* PILLS */
|
||||||
|
.pill {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: .72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: .15rem .5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
.pill.cached { background: rgba(34,197,94,.12); color: #4ade80; }
|
||||||
|
.pill.no-cache { background: rgba(245,158,11,.12); color: #fbbf24; }
|
||||||
|
.pill.bust { background: rgba(239,68,68,.12); color: #f87171; }
|
||||||
|
.pill.gzip { background: rgba(124,58,237,.15); color: var(--accent2); }
|
||||||
|
.pill.no-gzip { background: rgba(100,116,139,.12); color: var(--muted); }
|
||||||
|
.pill.desktop { background: rgba(56,189,248,.12); color: #38bdf8; }
|
||||||
|
.pill.mobile { background: rgba(251,146,60,.12); color: #fb923c; }
|
||||||
|
|
||||||
|
/* ── RESULT HEADER ──────────────────────────────────────────────────────────── */
|
||||||
.result-header {
|
.result-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin: 2rem 0 1rem;
|
margin: 2rem 0 1.5rem;
|
||||||
}
|
}
|
||||||
.result-header h2 { font-size: 1.1rem; }
|
.result-header h2 { font-size: 1.1rem; }
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
font-size: .75rem;
|
font-size: .75rem; font-weight: 600;
|
||||||
font-weight: 600;
|
|
||||||
padding: .25rem .65rem;
|
padding: .25rem .65rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: .05em;
|
letter-spacing: .05em;
|
||||||
}
|
}
|
||||||
.badge.running { background: rgba(245,158,11,.15); color: var(--yellow); }
|
.badge.running { background: rgba(245,158,11,.15); color: var(--yellow); }
|
||||||
.badge.completed{ background: rgba(34,197,94,.15); color: var(--green); }
|
.badge.completed { background: rgba(34,197,94,.15); color: var(--green); }
|
||||||
.badge.failed { background: rgba(239,68,68,.15); color: var(--red); }
|
.badge.failed { background: rgba(239,68,68,.15); color: var(--red); }
|
||||||
|
|
||||||
#output-log {
|
/* ── GAUGE CIRCLES ──────────────────────────────────────────────────────────── */
|
||||||
background: #0a0c12;
|
#gauges-panel {
|
||||||
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
padding: 1rem;
|
padding: 1.5rem 1rem 1.2rem;
|
||||||
font-family: var(--mono);
|
margin-bottom: 1rem;
|
||||||
font-size: .8rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
max-height: 400px;
|
|
||||||
overflow-y: auto;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-all;
|
|
||||||
color: #a5f3fc;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* THRESHOLD BANNER */
|
.gauges-main {
|
||||||
.threshold-banner {
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 130px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-svg {
|
||||||
|
width: 130px;
|
||||||
|
height: 130px;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-track {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--border);
|
||||||
|
stroke-width: 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-ring {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--border);
|
||||||
|
stroke-width: 8;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-dasharray: 314.16;
|
||||||
|
stroke-dashoffset: 314.16;
|
||||||
|
transition: stroke-dashoffset .7s cubic-bezier(.4,0,.2,1),
|
||||||
|
stroke .5s ease;
|
||||||
|
filter: drop-shadow(0 0 5px currentColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pulse animation while test is live */
|
||||||
|
@keyframes gauge-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: .7; }
|
||||||
|
}
|
||||||
|
#gauges-panel.live .gauge-ring {
|
||||||
|
animation: gauge-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-inner {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.gauge-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
font-family: var(--mono);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.gauge-sub {
|
||||||
|
font-size: .75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: .1rem;
|
||||||
|
}
|
||||||
|
.gauge-title {
|
||||||
|
font-size: .72rem;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
margin-top: .35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── STATS STRIP ────────────────────────────────────────────────────────────── */
|
||||||
|
.stats-strip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
gap: .5rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: .2rem;
|
||||||
|
}
|
||||||
|
.sc-val {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--mono);
|
||||||
|
transition: color .4s;
|
||||||
|
}
|
||||||
|
.sc-lbl {
|
||||||
|
font-size: .68rem;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── THRESHOLD BANNER ───────────────────────────────────────────────────────── */
|
||||||
|
#threshold-banner {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: .85rem 1rem;
|
padding: .85rem 1rem;
|
||||||
margin-bottom: 1rem;
|
margin-top: 1rem;
|
||||||
border: 1px solid;
|
border: 1px solid;
|
||||||
}
|
}
|
||||||
.threshold-banner.pass { background: rgba(34,197,94,.08); border-color: rgba(34,197,94,.3); color: var(--green); }
|
.threshold-banner.pass { background: rgba(34,197,94,.08); border-color: rgba(34,197,94,.3); color: var(--green); }
|
||||||
.threshold-banner.fail { background: rgba(239,68,68,.08); border-color: rgba(239,68,68,.3); color: var(--red); }
|
.threshold-banner.fail { background: rgba(239,68,68,.08); border-color: rgba(239,68,68,.3); color: var(--red); }
|
||||||
.threshold-banner ul { list-style: none; margin-top: .4rem; padding: 0; }
|
.threshold-banner ul { list-style: none; margin-top: .4rem; padding: 0; }
|
||||||
.threshold-banner li { font-size: .85rem; padding: .15rem 0; font-family: var(--mono); }
|
.threshold-banner li { font-size: .85rem; padding: .15rem 0; font-family: var(--mono); }
|
||||||
.threshold-banner li.pass { color: var(--green); }
|
.threshold-banner li.pass { color: var(--green); }
|
||||||
.threshold-banner li.fail { color: var(--red); }
|
.threshold-banner li.fail { color: var(--red); }
|
||||||
|
|
||||||
/* SUMMARY CARDS */
|
/* ── COLLAPSIBLE LOG ────────────────────────────────────────────────────────── */
|
||||||
#summary-cards { margin-top: .5rem; }
|
#log-details {
|
||||||
.summary-grid {
|
margin-top: 1rem;
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
margin-top: .75rem;
|
|
||||||
}
|
|
||||||
.summary-card {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1rem;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.summary-card .label { font-size: .75rem; color: var(--muted); margin-bottom: .3rem; }
|
#log-details summary {
|
||||||
.summary-card .value { font-size: 1.2rem; font-weight: 700; font-family: var(--mono); }
|
padding: .6rem 1rem;
|
||||||
.summary-card .value.good { color: var(--green); }
|
cursor: pointer;
|
||||||
.summary-card .value.bad { color: var(--red); }
|
font-size: .85rem;
|
||||||
.summary-card .value.info { color: var(--accent2); }
|
color: var(--muted);
|
||||||
|
user-select: none;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
}
|
||||||
|
#log-details summary::before {
|
||||||
|
content: '▶';
|
||||||
|
font-size: .7rem;
|
||||||
|
transition: transform .15s;
|
||||||
|
}
|
||||||
|
#log-details[open] summary::before { transform: rotate(90deg); }
|
||||||
|
#log-details summary:hover { color: var(--text); background: rgba(255,255,255,.02); }
|
||||||
|
|
||||||
/* HISTORY */
|
#output-log {
|
||||||
|
background: #0a0c12;
|
||||||
|
padding: 1rem;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: .78rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
max-height: 380px;
|
||||||
|
overflow-y: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
color: #a5f3fc;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── HISTORY ────────────────────────────────────────────────────────────────── */
|
||||||
.history-header {
|
.history-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -259,67 +372,135 @@ textarea { font-family: var(--mono); resize: vertical; }
|
|||||||
.history-item {
|
.history-item {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
padding: 1rem 1.2rem;
|
padding: .9rem 1.1rem;
|
||||||
margin-bottom: .75rem;
|
margin-bottom: .75rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 48px 1fr auto;
|
||||||
gap: .5rem;
|
gap: .85rem;
|
||||||
|
align-items: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color .15s;
|
transition: border-color .15s;
|
||||||
}
|
}
|
||||||
.history-item:hover { border-color: var(--accent); }
|
.history-item:hover { border-color: var(--accent); }
|
||||||
|
|
||||||
.history-item .row1 {
|
.history-left { display: flex; align-items: center; justify-content: center; }
|
||||||
|
|
||||||
|
.mini-gauge { width: 44px; height: 44px; overflow: visible; }
|
||||||
|
|
||||||
|
.history-body { min-width: 0; }
|
||||||
|
.history-body .row1 {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: .75rem;
|
gap: .6rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.history-item .url { font-weight: 600; word-break: break-all; }
|
.history-body .url { font-weight: 600; word-break: break-all; font-size: .9rem; }
|
||||||
.history-item .meta { font-size: .8rem; color: var(--muted); }
|
.history-body .meta { font-size: .78rem; color: var(--muted); }
|
||||||
|
.history-summary { font-size: .78rem; color: var(--muted); margin-top: .3rem; font-family: var(--mono); }
|
||||||
|
|
||||||
.history-item .actions { display: flex; align-items: flex-start; gap: .5rem; }
|
.history-item .actions { display: flex; align-items: center; }
|
||||||
.history-item .btn-del {
|
.btn-del {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
line-height: 1;
|
padding: .25rem;
|
||||||
padding: .2rem;
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: color .15s;
|
transition: color .15s;
|
||||||
}
|
}
|
||||||
.history-item .btn-del:hover { color: var(--red); }
|
.btn-del:hover { color: var(--red); }
|
||||||
|
|
||||||
.history-summary { font-size: .8rem; color: var(--muted); margin-top: .4rem; font-family: var(--mono); }
|
|
||||||
|
|
||||||
.empty { color: var(--muted); font-size: .9rem; }
|
.empty { color: var(--muted); font-size: .9rem; }
|
||||||
|
|
||||||
/* Detail modal / inline */
|
/* ── HISTORY DETAIL ─────────────────────────────────────────────────────────── */
|
||||||
#history-detail {
|
#history-detail {
|
||||||
margin-top: 1rem;
|
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--accent);
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
padding: 1.2rem;
|
padding: 1.2rem;
|
||||||
|
margin-bottom: .75rem;
|
||||||
}
|
}
|
||||||
|
.detail-title {
|
||||||
|
font-size: .95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--accent2);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-gauges {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.dg-wrap { position: relative; width: 80px; text-align: center; }
|
||||||
|
.dg-svg { width: 80px; height: 80px; transform: rotate(-90deg); overflow: visible; }
|
||||||
|
.dg-inner {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.dg-val { font-size: 1.2rem; font-weight: 800; font-family: var(--mono); line-height: 1; }
|
||||||
|
.dg-unit { font-size: .65rem; color: var(--muted); }
|
||||||
|
.dg-title { font-size: .6rem; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; margin-top: .3rem; }
|
||||||
|
|
||||||
|
.detail-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
|
||||||
|
gap: .5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.detail-stat {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: .5rem .6rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: .2rem;
|
||||||
|
}
|
||||||
|
.ds-val { font-size: .95rem; font-weight: 700; font-family: var(--mono); }
|
||||||
|
.ds-lbl { font-size: .65rem; color: var(--muted); text-transform: uppercase; }
|
||||||
|
|
||||||
|
#history-detail details { margin-top: .5rem; }
|
||||||
|
#history-detail details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: .82rem;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: .3rem 0;
|
||||||
|
user-select: none;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
#history-detail details summary:hover { color: var(--text); }
|
||||||
#history-detail pre {
|
#history-detail pre {
|
||||||
background: #0a0c12;
|
background: #0a0c12;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: .75rem;
|
padding: .75rem;
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: .78rem;
|
font-size: .75rem;
|
||||||
max-height: 350px;
|
max-height: 300px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
color: #a5f3fc;
|
color: #a5f3fc;
|
||||||
margin-top: .75rem;
|
margin-top: .5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
/* ── RESPONSIVE ─────────────────────────────────────────────────────────────── */
|
||||||
.form-grid { grid-template-columns: 1fr; }
|
@media (max-width: 640px) {
|
||||||
|
.form-grid { grid-template-columns: 1fr; }
|
||||||
.form-group.full { grid-column: 1; }
|
.form-group.full { grid-column: 1; }
|
||||||
|
.stats-strip { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
.gauges-main { gap: 1.5rem; }
|
||||||
|
.gauge-wrap, .gauge-svg { width: 110px; height: 110px; }
|
||||||
|
.gauge-value { font-size: 1.6rem; }
|
||||||
|
.history-item { grid-template-columns: 40px 1fr auto; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user