| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793 |
- <!DOCTYPE html>
- <html>
- <head>
- <link rel="shortcut icon" href="favicon.ico">
- <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no" />
- <meta charset="UTF-8" />
- <title>LibreSpeed - Stability Test</title>
- <style type="text/css">
- html,body{
- border:none; padding:0; margin:0;
- background:#FFFFFF;
- color:#202020;
- }
- body{
- text-align:center;
- font-family:"Roboto",sans-serif;
- }
- h1{
- color:#404040;
- }
- nav{
- margin-bottom:1em;
- }
- nav a{
- color:#6060AA;
- text-decoration:none;
- font-size:0.95em;
- }
- nav a:hover{
- text-decoration:underline;
- }
- #controls{
- margin:1em auto;
- }
- #durationSelect, #targetSelect{
- font-size:1em;
- padding:0.3em 0.5em;
- margin-right:1em;
- }
- #startBtn{
- display:inline-block;
- margin:0 auto;
- color:#6060AA;
- background-color:rgba(0,0,0,0);
- border:0.15em solid #6060FF;
- border-radius:0.3em;
- transition:all 0.3s;
- box-sizing:border-box;
- width:8em; height:3em;
- line-height:2.7em;
- cursor:pointer;
- box-shadow: 0 0 0 rgba(0,0,0,0.1), inset 0 0 0 rgba(0,0,0,0.1);
- }
- #startBtn:hover{
- box-shadow: 0 0 2em rgba(0,0,0,0.1), inset 0 0 1em rgba(0,0,0,0.1);
- }
- #startBtn:before{
- content:"Start";
- }
- #startBtn.running{
- background-color:#FF3030;
- border-color:#FF6060;
- color:#FFFFFF;
- }
- #startBtn.running:before{
- content:"Abort";
- }
- #resetBtn{
- display:inline-block;
- color:#808080;
- background-color:rgba(0,0,0,0);
- border:0.15em solid #A0A0A0;
- border-radius:0.3em;
- transition:all 0.3s;
- box-sizing:border-box;
- width:6em; height:3em;
- line-height:2.7em;
- cursor:pointer;
- margin-left:0.5em;
- box-shadow: 0 0 0 rgba(0,0,0,0.1), inset 0 0 0 rgba(0,0,0,0.1);
- }
- #resetBtn:hover{
- box-shadow: 0 0 2em rgba(0,0,0,0.1), inset 0 0 1em rgba(0,0,0,0.1);
- }
- #serverArea{
- margin:0.8em 0;
- }
- #server{
- font-size:1em;
- padding:0.2em;
- }
- #test{
- margin-top:2em;
- margin-bottom:2em;
- }
- div.testGroup{
- display:block;
- margin:0 auto;
- }
- div.testArea2{
- display:inline-block;
- width:10em;
- height:7em;
- position:relative;
- box-sizing:border-box;
- text-align:center;
- }
- div.testArea2 div.testName{
- display:block;
- text-align:center;
- font-size:1.4em;
- }
- div.testArea2 div.meterText{
- display:inline-block;
- font-size:2.5em;
- }
- div.meterText:empty:before{
- content:"0.00";
- }
- div.testArea2 div.unit{
- display:inline-block;
- }
- #rating{
- font-size:1.3em;
- font-weight:bold;
- margin:0.5em 0;
- }
- #rating.great{ color:#2ecc71; }
- #rating.good{ color:#27ae60; }
- #rating.poor{ color:#e67e22; }
- #rating.bad{ color:#e74c3c; }
- #rating.none{ color:#808080; }
- #chartContainer{
- margin:1em auto;
- width:95%;
- max-width:60em;
- position:relative;
- }
- #pingChart{
- width:100%;
- height:280px;
- border:1px solid #E0E0E0;
- border-radius:4px;
- background:#FAFAFA;
- }
- #optionalControls{
- margin:1.5em auto;
- max-width:40em;
- }
- #optionalControls label{
- display:block;
- margin:0.5em 0;
- }
- #alertThreshold{
- vertical-align:middle;
- width:200px;
- }
- #downloadCsvBtn{
- margin-top:0.8em;
- font-size:0.9em;
- padding:0.4em 1em;
- cursor:pointer;
- border:1px solid #6060AA;
- background:none;
- color:#6060AA;
- border-radius:0.3em;
- }
- #downloadCsvBtn:hover{
- background:#6060AA;
- color:#FFFFFF;
- }
- .footer{
- margin:2em 0 1em;
- }
- .footer a{
- color:#808080;
- font-size:0.85em;
- }
- @media all and (max-width:40em){
- body{ font-size:0.8em; }
- #pingChart{ height:200px; }
- }
- @media all and (prefers-color-scheme: dark){
- html,body{
- background:#202020;
- color:#F4F4F4;
- color-scheme:dark;
- }
- h1{ color:#E0E0E0; }
- nav a{ color:#9090FF; }
- #pingChart{
- border-color:#404040;
- background:#1a1a1a;
- }
- a{ color:#9090FF; }
- #startBtn{
- color:#9090FF;
- border-color:#7070FF;
- }
- #startBtn.running{
- background-color:#FF3030;
- border-color:#FF6060;
- color:#FFFFFF;
- }
- #resetBtn{
- color:#A0A0A0;
- border-color:#606060;
- }
- #downloadCsvBtn{
- color:#9090FF;
- border-color:#9090FF;
- }
- #downloadCsvBtn:hover{
- background:#9090FF;
- color:#202020;
- }
- .footer a{ color:#A0A0A0; }
- }
- </style>
- </head>
- <body>
- <h1>LibreSpeed - Stability Test</h1>
- <nav><a href="/">← Speed Test</a></nav>
- <div id="controls">
- <select id="durationSelect">
- <option value="60">60 Seconds</option>
- <option value="90">90 Seconds</option>
- <option value="120">2 Minutes</option>
- <option value="180">3 Minutes</option>
- <option value="300">5 Minutes</option>
- </select>
- <select id="targetSelect">
- <option value="">Local Server</option>
- <option value="https://www.google.com/generate_204">Google</option>
- <option value="https://www.cloudflare.com/cdn-cgi/trace">Cloudflare</option>
- <option value="https://www.apple.com/library/test/success.html">Apple</option>
- </select>
- <div id="startBtn" onclick="startStop()"></div>
- <div id="resetBtn" onclick="resetTest()">Reset</div>
- </div>
- <div id="serverArea" style="display:none">
- Server: <select id="server" onchange="onServerChange(this.value)"></select>
- </div>
- <div id="rating" class="none">--</div>
- <div id="test">
- <div class="testGroup">
- <div class="testArea2">
- <div class="testName">Current</div>
- <div id="statCurrent" class="meterText" style="color:#AA6060"></div>
- <div class="unit">ms</div>
- </div>
- <div class="testArea2">
- <div class="testName">Average</div>
- <div id="statAvg" class="meterText" style="color:#AA6060"></div>
- <div class="unit">ms</div>
- </div>
- <div class="testArea2">
- <div class="testName">Jitter</div>
- <div id="statJitter" class="meterText" style="color:#AA6060"></div>
- <div class="unit">ms</div>
- </div>
- </div>
- <div class="testGroup">
- <div class="testArea2">
- <div class="testName">Min</div>
- <div id="statMin" class="meterText"></div>
- <div class="unit">ms</div>
- </div>
- <div class="testArea2">
- <div class="testName">Max</div>
- <div id="statMax" class="meterText"></div>
- <div class="unit">ms</div>
- </div>
- <div class="testArea2">
- <div class="testName">Loss</div>
- <div id="statLoss" class="meterText"></div>
- <div class="unit">%</div>
- </div>
- <div class="testArea2">
- <div class="testName">Elapsed</div>
- <div id="statElapsed" class="meterText">00:00</div>
- <div class="unit"></div>
- </div>
- </div>
- </div>
- <div id="chartContainer">
- <canvas id="pingChart"></canvas>
- </div>
- <div id="optionalControls">
- <label>
- Alert threshold:
- <input type="range" id="alertThreshold" min="0" max="500" step="10" value="0" oninput="updateThreshold(this.value)">
- <span id="thresholdValue">Off</span>
- </label>
- <button id="downloadCsvBtn" onclick="downloadCsv()">Download CSV</button>
- </div>
- <div class="footer">
- <a href="https://github.com/librespeed/speedtest">Source code</a>
- </div>
- <script type="text/javascript">
- function I(i) { return document.getElementById(i); }
- // Server configuration (same pattern as index.html)
- var SPEEDTEST_SERVERS = [];
- // State
- var worker = null;
- var updater = null;
- var running = false;
- var allPingData = []; // full dataset for chart and CSV
- var latestData = null;
- var alertThresholdMs = 0;
- var lastBeepTime = 0;
- var audioCtx = null;
- var selectedServer = null;
- // Dark mode detection for canvas colors
- var isDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
- try {
- window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", function(e) {
- isDark = e.matches;
- });
- } catch (e) {}
- // Chart constants
- var VISIBLE_SECONDS = 60;
- var CHART_PADDING_LEFT = 50;
- var CHART_PADDING_RIGHT = 15;
- var CHART_PADDING_TOP = 15;
- var CHART_PADDING_BOTTOM = 30;
- // Server initialization
- function initServers() {
- if (SPEEDTEST_SERVERS.length === 0) {
- I("serverArea").style.display = "none";
- } else {
- I("serverArea").style.display = "";
- // ping servers to find best one
- var completed = 0;
- var best = null;
- for (var i = 0; i < SPEEDTEST_SERVERS.length; i++) {
- (function(idx) {
- pingServer(SPEEDTEST_SERVERS[idx], function(server, rtt) {
- SPEEDTEST_SERVERS[idx].pingT = rtt;
- if (rtt > 0 && (best === null || rtt < best.pingT)) best = SPEEDTEST_SERVERS[idx];
- completed++;
- if (completed === SPEEDTEST_SERVERS.length) {
- // populate dropdown
- for (var j = 0; j < SPEEDTEST_SERVERS.length; j++) {
- if (SPEEDTEST_SERVERS[j].pingT <= 0) continue;
- var opt = document.createElement("option");
- opt.value = j;
- opt.textContent = SPEEDTEST_SERVERS[j].name;
- if (SPEEDTEST_SERVERS[j] === best) opt.selected = true;
- I("server").appendChild(opt);
- }
- selectedServer = best;
- }
- });
- })(i);
- }
- }
- }
- function pingServer(server, callback) {
- var url = server.server + server.pingURL + (server.pingURL.match(/\?/) ? "&" : "?") + "cors=true&r=" + Math.random();
- var xhr = new XMLHttpRequest();
- var t = new Date().getTime();
- xhr.onload = function() {
- callback(server, new Date().getTime() - t);
- };
- xhr.onerror = function() {
- callback(server, -1);
- };
- xhr.open("GET", url);
- try { xhr.timeout = 2000; xhr.ontimeout = xhr.onerror; } catch (e) {}
- xhr.send();
- }
- function onServerChange(idx) {
- selectedServer = SPEEDTEST_SERVERS[idx];
- }
- // Start/Stop
- function startStop() {
- if (running) {
- // abort
- if (worker) worker.postMessage("abort");
- stopTest();
- } else {
- startTest();
- }
- }
- function startTest() {
- allPingData = [];
- latestData = null;
- resetUI();
- running = true;
- I("startBtn").className = "running";
- I("durationSelect").disabled = true;
- I("targetSelect").disabled = true;
- I("server").disabled = true;
- var externalTarget = I("targetSelect").value;
- var workerSettings = {
- duration: parseInt(I("durationSelect").value),
- ping_allowPerformanceApi: true,
- url_ping_external: externalTarget
- };
- if (!externalTarget && selectedServer) {
- workerSettings.url_ping = selectedServer.server + selectedServer.pingURL;
- workerSettings.mpot = true;
- }
- worker = new Worker("stability_worker.js?r=" + Math.random());
- worker.onmessage = function(e) {
- var data = JSON.parse(e.data);
- latestData = data;
- // accumulate ping data
- if (data.pingData && data.pingData.length > 0) {
- for (var i = 0; i < data.pingData.length; i++) {
- allPingData.push(data.pingData[i]);
- }
- }
- // check alert threshold
- if (alertThresholdMs > 0 && data.currentPing > alertThresholdMs) {
- playBeep();
- }
- if (data.testState >= 4) {
- stopTest();
- }
- };
- updater = setInterval(function() {
- if (worker) worker.postMessage("status");
- }, 200);
- worker.postMessage("start " + JSON.stringify(workerSettings));
- }
- function stopTest() {
- running = false;
- I("startBtn").className = "";
- I("durationSelect").disabled = false;
- I("targetSelect").disabled = false;
- I("server").disabled = false;
- if (updater) {
- clearInterval(updater);
- updater = null;
- }
- // request final status
- if (worker) {
- worker.postMessage("status");
- setTimeout(function() {
- if (worker) {
- try { worker.terminate(); } catch (e) {}
- worker = null;
- }
- }, 500);
- }
- }
- function resetTest() {
- if (running) {
- if (worker) worker.postMessage("abort");
- stopTest();
- }
- allPingData = [];
- latestData = null;
- resetUI();
- drawChart();
- }
- function resetUI() {
- I("statCurrent").textContent = "";
- I("statAvg").textContent = "";
- I("statMin").textContent = "";
- I("statMax").textContent = "";
- I("statJitter").textContent = "";
- I("statLoss").textContent = "";
- I("statElapsed").textContent = "";
- I("rating").textContent = "--";
- I("rating").className = "none";
- }
- // Format helpers
- function fmt(v) {
- v = Number(v);
- if (isNaN(v) || v <= 0) return "--";
- if (v < 10) return v.toFixed(2);
- if (v < 100) return v.toFixed(1);
- return v.toFixed(0);
- }
- function fmtTime(sec) {
- var m = Math.floor(sec / 60);
- var s = Math.floor(sec % 60);
- return (m < 10 ? "0" : "") + m + ":" + (s < 10 ? "0" : "") + s;
- }
- // Rating calculation
- function getRating(avg, jit, loss) {
- if (avg <= 0) return { text: "--", cls: "none" };
- if (avg < 30 && jit < 5 && loss < 0.5) return { text: "Great", cls: "great" };
- if (avg < 60 && jit < 15 && loss < 2) return { text: "Good", cls: "good" };
- if (avg < 100 && jit < 30 && loss < 5) return { text: "Poor", cls: "poor" };
- return { text: "Bad", cls: "bad" };
- }
- // UI update loop
- function updateUI() {
- if (!latestData) return;
- var d = latestData;
- I("statCurrent").textContent = fmt(d.currentPing);
- I("statAvg").textContent = fmt(d.avgPing);
- I("statMin").textContent = d.minPing > 0 ? fmt(d.minPing) : "--";
- I("statMax").textContent = fmt(d.maxPing);
- I("statJitter").textContent = fmt(d.jitter);
- I("statLoss").textContent = d.totalSamples > 0 ? d.packetLoss.toFixed(1) : "--";
- I("statElapsed").textContent = fmtTime(d.elapsed);
- var r = getRating(d.avgPing, d.jitter, d.packetLoss);
- I("rating").textContent = r.text;
- I("rating").className = r.cls;
- }
- // Canvas chart rendering
- function drawChart() {
- var canvas = I("pingChart");
- var ctx = canvas.getContext("2d");
- var dp = window.devicePixelRatio || 1;
- var cw = canvas.clientWidth * dp;
- var ch = canvas.clientHeight * dp;
- if (canvas.width !== cw || canvas.height !== ch) {
- canvas.width = cw;
- canvas.height = ch;
- }
- ctx.clearRect(0, 0, cw, ch);
- var padL = CHART_PADDING_LEFT * dp;
- var padR = CHART_PADDING_RIGHT * dp;
- var padT = CHART_PADDING_TOP * dp;
- var padB = CHART_PADDING_BOTTOM * dp;
- var plotW = cw - padL - padR;
- var plotH = ch - padT - padB;
- // colors based on theme
- var lineColor = isDark ? "#9090FF" : "#6060AA";
- var fillColor = isDark ? "rgba(144,144,255,0.15)" : "rgba(96,96,170,0.15)";
- var gridColor = isDark ? "#404040" : "#E0E0E0";
- var textColor = isDark ? "#A0A0A0" : "#808080";
- var lostColor = "#e74c3c";
- var thresholdColor = "rgba(231,76,60,0.6)";
- // determine visible time window
- var data = allPingData;
- var maxTime = 0;
- if (data.length > 0) maxTime = data[data.length - 1].t;
- var timeStart, timeEnd;
- if (maxTime <= VISIBLE_SECONDS) {
- timeStart = 0;
- timeEnd = Math.max(VISIBLE_SECONDS, maxTime);
- } else {
- timeEnd = maxTime;
- timeStart = maxTime - VISIBLE_SECONDS;
- }
- // determine Y-axis scale from visible data
- var yMax = 50; // minimum 50ms scale
- for (var i = 0; i < data.length; i++) {
- if (data[i].t >= timeStart && data[i].t <= timeEnd && !data[i].lost) {
- if (data[i].ping > yMax) yMax = data[i].ping;
- }
- }
- yMax = Math.ceil(yMax * 1.2 / 10) * 10; // round up to nearest 10 with 20% headroom
- var fontSize = 11 * dp;
- ctx.font = fontSize + "px sans-serif";
- ctx.textBaseline = "middle";
- // grid lines - horizontal
- var ySteps = getYSteps(yMax);
- ctx.strokeStyle = gridColor;
- ctx.lineWidth = dp;
- ctx.fillStyle = textColor;
- ctx.textAlign = "right";
- for (var s = 0; s < ySteps.length; s++) {
- var yVal = ySteps[s];
- if (yVal > yMax) continue;
- var y = padT + plotH - (yVal / yMax) * plotH;
- ctx.beginPath();
- ctx.moveTo(padL, y);
- ctx.lineTo(padL + plotW, y);
- ctx.stroke();
- ctx.fillText(yVal + "", padL - 5 * dp, y);
- }
- // grid lines - vertical (time ticks)
- ctx.textAlign = "center";
- ctx.textBaseline = "top";
- var timeSpan = timeEnd - timeStart;
- var tickInterval = timeSpan <= 60 ? 10 : timeSpan <= 120 ? 15 : 30;
- var firstTick = Math.ceil(timeStart / tickInterval) * tickInterval;
- for (var t = firstTick; t <= timeEnd; t += tickInterval) {
- var x = padL + ((t - timeStart) / timeSpan) * plotW;
- ctx.beginPath();
- ctx.strokeStyle = gridColor;
- ctx.moveTo(x, padT);
- ctx.lineTo(x, padT + plotH);
- ctx.stroke();
- ctx.fillStyle = textColor;
- ctx.fillText(fmtTime(t), x, padT + plotH + 4 * dp);
- }
- // axis labels
- ctx.fillStyle = textColor;
- ctx.textAlign = "right";
- ctx.textBaseline = "top";
- ctx.fillText("ms", padL - 5 * dp, padT - 2 * dp);
- // plot border
- ctx.strokeStyle = gridColor;
- ctx.lineWidth = dp;
- ctx.strokeRect(padL, padT, plotW, plotH);
- // alert threshold line
- if (alertThresholdMs > 0 && alertThresholdMs <= yMax) {
- var threshY = padT + plotH - (alertThresholdMs / yMax) * plotH;
- ctx.save();
- ctx.setLineDash([6 * dp, 4 * dp]);
- ctx.strokeStyle = thresholdColor;
- ctx.lineWidth = 1.5 * dp;
- ctx.beginPath();
- ctx.moveTo(padL, threshY);
- ctx.lineTo(padL + plotW, threshY);
- ctx.stroke();
- ctx.restore();
- }
- if (data.length < 2) return;
- // filter visible data
- var visible = [];
- for (var i = 0; i < data.length; i++) {
- if (data[i].t >= timeStart && data[i].t <= timeEnd) visible.push(data[i]);
- }
- if (visible.length < 1) return;
- // draw filled area
- ctx.beginPath();
- var started = false;
- for (var i = 0; i < visible.length; i++) {
- if (visible[i].lost) continue;
- var px = padL + ((visible[i].t - timeStart) / timeSpan) * plotW;
- var py = padT + plotH - (visible[i].ping / yMax) * plotH;
- if (!started) {
- ctx.moveTo(px, padT + plotH);
- ctx.lineTo(px, py);
- started = true;
- } else {
- ctx.lineTo(px, py);
- }
- }
- // close the fill path
- if (started) {
- // find last non-lost point
- for (var i = visible.length - 1; i >= 0; i--) {
- if (!visible[i].lost) {
- var px = padL + ((visible[i].t - timeStart) / timeSpan) * plotW;
- ctx.lineTo(px, padT + plotH);
- break;
- }
- }
- ctx.closePath();
- ctx.fillStyle = fillColor;
- ctx.fill();
- }
- // draw line
- ctx.beginPath();
- started = false;
- for (var i = 0; i < visible.length; i++) {
- if (visible[i].lost) {
- started = false;
- continue;
- }
- var px = padL + ((visible[i].t - timeStart) / timeSpan) * plotW;
- var py = padT + plotH - (visible[i].ping / yMax) * plotH;
- if (!started) {
- ctx.moveTo(px, py);
- started = true;
- } else {
- ctx.lineTo(px, py);
- }
- }
- ctx.strokeStyle = lineColor;
- ctx.lineWidth = 2 * dp;
- ctx.lineJoin = "round";
- ctx.stroke();
- // draw lost packet markers
- for (var i = 0; i < visible.length; i++) {
- if (!visible[i].lost) continue;
- var px = padL + ((visible[i].t - timeStart) / timeSpan) * plotW;
- ctx.beginPath();
- ctx.arc(px, padT + plotH - 10 * dp, 3 * dp, 0, Math.PI * 2);
- ctx.fillStyle = lostColor;
- ctx.fill();
- }
- }
- function getYSteps(yMax) {
- // generate sensible Y-axis grid steps
- if (yMax <= 50) return [10, 20, 30, 40, 50];
- if (yMax <= 100) return [20, 40, 60, 80, 100];
- if (yMax <= 200) return [50, 100, 150, 200];
- if (yMax <= 500) return [100, 200, 300, 400, 500];
- // for very high values
- var step = Math.ceil(yMax / 5 / 100) * 100;
- var steps = [];
- for (var v = step; v <= yMax; v += step) steps.push(v);
- return steps;
- }
- // Animation frame loop
- window.requestAnimationFrame = window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || window.msRequestAnimationFrame || (function(callback) { setTimeout(callback, 1000 / 60); });
- function frame() {
- requestAnimationFrame(frame);
- updateUI();
- drawChart();
- }
- frame();
- // Alert threshold
- function updateThreshold(val) {
- alertThresholdMs = parseInt(val);
- I("thresholdValue").textContent = alertThresholdMs > 0 ? alertThresholdMs + " ms" : "Off";
- }
- function playBeep() {
- var now = new Date().getTime();
- if (now - lastBeepTime < 1000) return; // debounce 1s
- lastBeepTime = now;
- try {
- if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
- var osc = audioCtx.createOscillator();
- var gain = audioCtx.createGain();
- osc.frequency.value = 800;
- gain.gain.value = 0.3;
- osc.connect(gain);
- gain.connect(audioCtx.destination);
- osc.start();
- osc.stop(audioCtx.currentTime + 0.1);
- } catch (e) {}
- }
- // CSV download
- function downloadCsv() {
- if (allPingData.length === 0) return;
- var csv = "elapsed_s,ping_ms,lost\n";
- for (var i = 0; i < allPingData.length; i++) {
- var d = allPingData[i];
- csv += d.t.toFixed(3) + "," + d.ping.toFixed(2) + "," + (d.lost ? "1" : "0") + "\n";
- }
- var blob = new Blob([csv], { type: "text/csv" });
- var url = URL.createObjectURL(blob);
- var a = document.createElement("a");
- a.href = url;
- a.download = "stability_test_" + new Date().toISOString().slice(0, 19).replace(/:/g, "-") + ".csv";
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
- }
- // Init
- initServers();
- drawChart();
- </script>
- </body>
- </html>
|