Add internet stability test feature

Add a prolonged ping-based stability test with real-time canvas chart,
stats (avg/min/max/jitter/packet loss), stability rating, external ping
targets, CSV export, and Docker support. Link from main page to stability test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Hudson 2026-03-15 18:07:20 -04:00 committed by Stefan Stidl
parent 5cd7ce2efa
commit 7839a4331c
9 changed files with 1019 additions and 5 deletions

View file

@ -25,9 +25,9 @@ COPY index.html /speedtest/
COPY index-classic.html /speedtest/
COPY index-modern.html /speedtest/
COPY config.json /speedtest/
COPY stability.html /speedtest/
COPY favicon.ico /speedtest/
COPY docker/*.php /speedtest/
COPY docker/entrypoint.sh /
# Prepare default environment variables

View file

@ -35,9 +35,9 @@ COPY index.html /speedtest/
COPY index-classic.html /speedtest/
COPY index-modern.html /speedtest/
COPY config.json /speedtest/
COPY stability.html /speedtest/
COPY favicon.ico /speedtest/
COPY docker/*.php /speedtest/
COPY docker/entrypoint.sh /
# Prepare default environment variables

View file

@ -35,6 +35,7 @@ rm -rf /var/www/html/*
# Copy frontend files
cp /speedtest/*.js /var/www/html/
cp /speedtest/stability.html /var/www/html/
# Copy design switch files
cp /speedtest/config.json /var/www/html/

View file

@ -75,6 +75,7 @@
</main>
<footer>
<p class="source">
<a href="stability.html">stability test</a> |
<a href="https://github.com/librespeed/speedtest">source code</a>
</p>
</footer>

View file

@ -567,7 +567,7 @@
</div>
</div>
<a href="index.html?design=new">Try the modern design</a><br>
<a href="https://github.com/librespeed/speedtest">Source code</a>
<a href="stability.html">Stability Test</a> | <a href="https://github.com/librespeed/speedtest">Source code</a>
</div>
<div id="privacyPolicy" style="display:none">
<h2>Privacy Policy</h2>

View file

@ -75,6 +75,7 @@
</main>
<footer>
<p class="source">
<a href="stability.html">stability test</a> |
<a href="https://github.com/librespeed/speedtest">source code</a>
</p>
</footer>

View file

@ -7,8 +7,8 @@
"test": "echo \"No automated tests configured yet\" && exit 0",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"lint": "eslint speedtest.js speedtest_worker.js",
"lint:fix": "eslint --fix speedtest.js speedtest_worker.js",
"lint": "eslint speedtest.js speedtest_worker.js stability_worker.js",
"lint:fix": "eslint --fix speedtest.js speedtest_worker.js stability_worker.js",
"format": "prettier --write \"*.js\"",
"format:check": "prettier --check \"*.js\"",
"validate": "npm run format:check && npm run lint",
@ -46,6 +46,8 @@
"files": [
"speedtest.js",
"speedtest_worker.js",
"stability_worker.js",
"stability.html",
"index.html",
"favicon.ico",
"backend/",

793
stability.html Normal file
View file

@ -0,0 +1,793 @@
<!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="/">&larr; 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>

216
stability_worker.js Normal file
View file

@ -0,0 +1,216 @@
/*
LibreSpeed - Stability Test Worker
https://github.com/librespeed/speedtest/
GNU LGPLv3 License
*/
// data reported to main thread
let testState = -1; // -1=idle, 0=starting, 1=running, 4=finished, 5=aborted
let currentPing = 0;
let avgPing = 0;
let minPing = -1;
let maxPing = 0;
let jitter = 0;
let packetLoss = 0;
let elapsed = 0;
let progress = 0;
let pingData = []; // all ping data points {t: elapsedMs, ping: ms, lost: bool}
let lastReportedIndex = 0; // for delta delivery
let totalSamples = 0;
let failedSamples = 0;
let pingSum = 0;
let settings = {
url_ping: "backend/empty.php",
url_ping_external: "", // external URL to ping (uses fetch no-cors, e.g. "https://www.google.com/generate_204")
duration: 60, // seconds
ping_allowPerformanceApi: true,
mpot: false
};
let xhr = null;
let startTime = 0;
let prevInstspd = 0;
let aborted = false;
function url_sep(url) {
return url.match(/\?/) ? "&" : "?";
}
this.addEventListener("message", function (e) {
const params = e.data.split(" ");
if (params[0] === "status") {
// return current state with delta ping data
const newData = pingData.slice(lastReportedIndex);
lastReportedIndex = pingData.length;
postMessage(
JSON.stringify({
testState: testState,
currentPing: currentPing,
avgPing: avgPing,
minPing: minPing,
maxPing: maxPing,
jitter: jitter,
packetLoss: packetLoss,
elapsed: elapsed,
duration: settings.duration,
progress: progress,
pingData: newData,
totalSamples: totalSamples,
failedSamples: failedSamples
})
);
}
if (params[0] === "start" && testState === -1) {
testState = 0;
try {
let s = {};
try {
const ss = e.data.substring(6);
if (ss) s = JSON.parse(ss);
} catch (e) {
console.warn("Error parsing settings JSON");
}
for (let key in s) {
if (typeof settings[key] !== "undefined") settings[key] = s[key];
}
} catch (e) {
console.warn("Error applying settings: " + e);
}
// start the stability test
aborted = false;
startTime = new Date().getTime();
testState = 1;
doPing();
}
if (params[0] === "abort") {
if (testState >= 4) return;
aborted = true;
testState = 5;
if (xhr) {
try {
xhr.abort();
} catch (e) {}
}
}
});
function recordPing(instspd) {
// guard against 0ms pings
if (instspd < 1) instspd = prevInstspd;
if (instspd < 1) instspd = 1;
totalSamples++;
currentPing = instspd;
pingSum += instspd;
avgPing = parseFloat((pingSum / (totalSamples - failedSamples)).toFixed(2));
if (minPing === -1 || instspd < minPing) minPing = instspd;
if (instspd > maxPing) maxPing = instspd;
// jitter calculation (same weighted formula as speedtest_worker.js)
if (totalSamples > 1 && prevInstspd > 0) {
const instjitter = Math.abs(instspd - prevInstspd);
if (totalSamples === 2) {
jitter = instjitter;
} else {
jitter = instjitter > jitter ? jitter * 0.3 + instjitter * 0.7 : jitter * 0.8 + instjitter * 0.2;
}
}
prevInstspd = instspd;
// packet loss
packetLoss = totalSamples > 0 ? parseFloat(((failedSamples / totalSamples) * 100).toFixed(2)) : 0;
// record data point
const now = new Date().getTime();
elapsed = (now - startTime) / 1000;
pingData.push({ t: elapsed, ping: parseFloat(instspd.toFixed(2)), lost: false });
}
function recordLoss() {
const now = new Date().getTime();
totalSamples++;
failedSamples++;
packetLoss = parseFloat(((failedSamples / totalSamples) * 100).toFixed(2));
elapsed = (now - startTime) / 1000;
pingData.push({ t: elapsed, ping: 0, lost: true });
}
function doPing() {
if (aborted || testState >= 4) return;
// check if duration exceeded
const now = new Date().getTime();
elapsed = (now - startTime) / 1000;
progress = Math.min(1, elapsed / settings.duration);
if (elapsed >= settings.duration) {
testState = 4;
progress = 1;
return;
}
// external ping mode: use fetch with no-cors
if (settings.url_ping_external) {
doPingExternal();
return;
}
const prevT = new Date().getTime();
xhr = new XMLHttpRequest();
xhr.onload = function () {
if (aborted || testState >= 4) return;
const now = new Date().getTime();
let instspd = now - prevT;
if (settings.ping_allowPerformanceApi) {
try {
let p = performance.getEntries();
p = p[p.length - 1];
let d = p.responseStart - p.requestStart;
if (d <= 0) d = p.duration;
if (d > 0 && d < instspd) instspd = d;
} catch (e) {
// Performance API not available, use estimate
}
}
recordPing(instspd);
doPing();
};
xhr.onerror = function () {
if (aborted || testState >= 4) return;
recordLoss();
doPing();
};
xhr.ontimeout = xhr.onerror;
xhr.open(
"GET",
settings.url_ping + url_sep(settings.url_ping) + (settings.mpot ? "cors=true&" : "") + "r=" + Math.random(),
true
);
try {
xhr.timeout = 5000;
} catch (e) {}
xhr.send();
}
// ping an external host using fetch with no-cors (opaque response, but timing still works)
function doPingExternal() {
const prevT = new Date().getTime();
const url =
settings.url_ping_external + (settings.url_ping_external.indexOf("?") >= 0 ? "&" : "?") + "r=" + Math.random();
fetch(url, { mode: "no-cors", cache: "no-store" })
.then(function () {
if (aborted || testState >= 4) return;
const instspd = new Date().getTime() - prevT;
recordPing(instspd);
doPing();
})
.catch(function () {
if (aborted || testState >= 4) return;
recordLoss();
doPing();
});
}