Procházet zdrojové kódy

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>
Tom Hudson před 4 měsíci
rodič
revize
7839a4331c
9 změnil soubory, kde provedl 1019 přidání a 5 odebrání
  1. 1 1
      Dockerfile
  2. 1 1
      Dockerfile.alpine
  3. 1 0
      docker/entrypoint.sh
  4. 1 0
      frontend/index.html
  5. 1 1
      index-classic.html
  6. 1 0
      index-modern.html
  7. 4 2
      package.json
  8. 793 0
      stability.html
  9. 216 0
      stability_worker.js

+ 1 - 1
Dockerfile

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

+ 1 - 1
Dockerfile.alpine

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

+ 1 - 0
docker/entrypoint.sh

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

+ 1 - 0
frontend/index.html

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

+ 1 - 1
index-classic.html

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

+ 1 - 0
index-modern.html

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

+ 4 - 2
package.json

@@ -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 - 0
stability.html

@@ -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 - 0
stability_worker.js

@@ -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();
+    });
+}