Backend: - Track all k6 HTTP phase metrics via PHASE_MAP in ingestPoint: http_req_blocked (DNS+wait), http_req_connecting, http_req_tls_handshaking, http_req_sending, http_req_waiting (TTFB), http_req_receiving - Compute avg + p95 per phase in computeLiveMetrics, broadcast every second - parseSummary now captures all trend metrics with generic trendRe() Frontend: - Live waterfall bar chart updates every second during the test - Each phase has a distinct colour: indigo/sky/purple/emerald/amber/blue - Bar width = phase avg as % of total request time - TTFB p95 + p99 shown below the waterfall - Bars animate smoothly with CSS transitions - Waterfall resets cleanly on new test start Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
270 lines
11 KiB
HTML
270 lines
11 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>k6 Load Tester</title>
|
|
<link rel="stylesheet" href="style.css" />
|
|
</head>
|
|
<body>
|
|
<div class="app">
|
|
<header>
|
|
<h1>⚡ k6 Load Tester</h1>
|
|
<nav>
|
|
<button class="tab-btn active" data-tab="run">Run Test</button>
|
|
<button class="tab-btn" data-tab="history">History</button>
|
|
</nav>
|
|
</header>
|
|
|
|
<!-- RUN TAB -->
|
|
<section id="tab-run" class="tab active">
|
|
<form id="test-form">
|
|
<div class="form-grid">
|
|
<div class="form-group full">
|
|
<label for="url">Target URL</label>
|
|
<input type="url" id="url" placeholder="https://example.com" required />
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="httpMethod">HTTP Method</label>
|
|
<select id="httpMethod">
|
|
<option value="GET">GET</option>
|
|
<option value="POST">POST</option>
|
|
<option value="PUT">PUT</option>
|
|
<option value="PATCH">PATCH</option>
|
|
<option value="DELETE">DELETE</option>
|
|
<option value="HEAD">HEAD</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="vus">Virtual Users (VUs)</label>
|
|
<div class="range-row">
|
|
<input type="range" id="vus" min="1" max="500" value="10" />
|
|
<input type="number" id="vus-num" min="1" max="500" value="10" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="duration">Duration (seconds)</label>
|
|
<div class="range-row">
|
|
<input type="range" id="duration" min="5" max="300" value="30" />
|
|
<input type="number" id="duration-num" min="5" max="300" value="30" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="rpsLimit">Max RPS <span class="hint">(0 = unlimited)</span></label>
|
|
<input type="number" id="rpsLimit" min="0" value="0" />
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="cacheMode">Cache Mode</label>
|
|
<select id="cacheMode">
|
|
<option value="normal">Normal (allow cache)</option>
|
|
<option value="no-cache">No-cache headers (revalidate)</option>
|
|
<option value="bust">Cache-bust URL + headers (full bypass)</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="device">Device</label>
|
|
<div class="device-toggle">
|
|
<input type="radio" name="device" id="device-desktop" value="desktop" checked />
|
|
<label for="device-desktop" class="device-btn">🖥 Desktop</label>
|
|
<input type="radio" name="device" id="device-mobile" value="mobile" />
|
|
<label for="device-mobile" class="device-btn">📱 Mobile</label>
|
|
</div>
|
|
<div class="hint" id="ua-hint">Chrome 124 on Windows</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Encoding</label>
|
|
<div class="toggle-row">
|
|
<label class="toggle">
|
|
<input type="checkbox" id="gzip" checked />
|
|
<span class="toggle-track"></span>
|
|
<span class="toggle-label">Accept gzip / br</span>
|
|
</label>
|
|
</div>
|
|
<div class="hint" id="gzip-hint">k6 default — server may compress response</div>
|
|
</div>
|
|
|
|
<div class="form-group full" id="body-group" style="display:none">
|
|
<label for="requestBody">Request Body</label>
|
|
<textarea id="requestBody" rows="4" placeholder='{"key": "value"}'></textarea>
|
|
</div>
|
|
|
|
<div class="form-group full">
|
|
<label for="headers">
|
|
Custom Headers <span class="hint">(JSON, optional — merged with above options)</span>
|
|
</label>
|
|
<input type="text" id="headers" placeholder='{"Authorization": "Bearer token"}' />
|
|
</div>
|
|
</div>
|
|
|
|
<button type="submit" id="start-btn" class="btn-primary">▶ Run Test</button>
|
|
</form>
|
|
|
|
<div id="result-panel" style="display:none">
|
|
<div class="result-header">
|
|
<h2>Test Results</h2>
|
|
<span id="result-status" class="badge running">running</span>
|
|
</div>
|
|
|
|
<!-- Gauge circles -->
|
|
<div id="gauges-panel">
|
|
<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 — response times -->
|
|
<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 total</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>
|
|
|
|
<!-- Live timing phases waterfall -->
|
|
<div class="waterfall-wrap" id="waterfall-wrap">
|
|
<div class="waterfall-title">Request timing breakdown <span class="wf-hint">(avg per phase)</span></div>
|
|
<div class="waterfall" id="waterfall">
|
|
<div class="wf-row" id="wf-blocked">
|
|
<span class="wf-label">DNS / Blocked</span>
|
|
<div class="wf-bar-wrap"><div class="wf-bar wf-blocked"></div></div>
|
|
<span class="wf-val">--</span>
|
|
</div>
|
|
<div class="wf-row" id="wf-connecting">
|
|
<span class="wf-label">TCP connect</span>
|
|
<div class="wf-bar-wrap"><div class="wf-bar wf-connecting"></div></div>
|
|
<span class="wf-val">--</span>
|
|
</div>
|
|
<div class="wf-row" id="wf-tls">
|
|
<span class="wf-label">TLS handshake</span>
|
|
<div class="wf-bar-wrap"><div class="wf-bar wf-tls"></div></div>
|
|
<span class="wf-val">--</span>
|
|
</div>
|
|
<div class="wf-row" id="wf-sending">
|
|
<span class="wf-label">Sending</span>
|
|
<div class="wf-bar-wrap"><div class="wf-bar wf-sending"></div></div>
|
|
<span class="wf-val">--</span>
|
|
</div>
|
|
<div class="wf-row" id="wf-ttfb">
|
|
<span class="wf-label">TTFB (waiting)</span>
|
|
<div class="wf-bar-wrap"><div class="wf-bar wf-ttfb"></div></div>
|
|
<span class="wf-val">--</span>
|
|
</div>
|
|
<div class="wf-row" id="wf-receiving">
|
|
<span class="wf-label">Receiving</span>
|
|
<div class="wf-bar-wrap"><div class="wf-bar wf-receiving"></div></div>
|
|
<span class="wf-val">--</span>
|
|
</div>
|
|
</div>
|
|
<div class="wf-p95-row" id="wf-p95-row" style="display:none">
|
|
<span class="wf-p95-label">TTFB p(95)</span>
|
|
<span class="wf-p95-val" id="wf-ttfb-p95">--</span>
|
|
<span class="wf-p95-label" style="margin-left:1rem">TTFB p(99)</span>
|
|
<span class="wf-p95-val" id="wf-ttfb-p99">--</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Bandwidth row -->
|
|
<div class="bw-row" id="bw-row">
|
|
<div class="bw-item">
|
|
<span class="bw-label">↓ RX</span>
|
|
<span class="bw-val" id="bw-in">-- MB/s</span>
|
|
</div>
|
|
<div class="bw-divider"></div>
|
|
<div class="bw-item">
|
|
<span class="bw-label">↑ TX</span>
|
|
<span class="bw-val" id="bw-out">-- MB/s</span>
|
|
</div>
|
|
<div class="bw-divider"></div>
|
|
<div class="bw-item">
|
|
<span class="bw-label">Avg size</span>
|
|
<span class="bw-val" id="bw-size">-- KB</span>
|
|
</div>
|
|
<div id="bw-warning" class="bw-warning" style="display:none">
|
|
⚠ High bandwidth — may be causing p99 spikes
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Threshold results -->
|
|
<div id="threshold-banner" style="display:none"></div>
|
|
</div>
|
|
|
|
<!-- Raw log (collapsible) -->
|
|
<details id="log-details">
|
|
<summary>Raw Output</summary>
|
|
<pre id="output-log"></pre>
|
|
</details>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- HISTORY TAB -->
|
|
<section id="tab-history" class="tab">
|
|
<div class="history-header">
|
|
<h2>Test History</h2>
|
|
<button id="refresh-history" class="btn-secondary">↻ Refresh</button>
|
|
</div>
|
|
<div id="history-list">
|
|
<p class="empty">No tests yet.</p>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<script src="app.js"></script>
|
|
</body>
|
|
</html>
|