From c5f5c30b28ff688434d01487ee5998f7dbcad1e1 Mon Sep 17 00:00:00 2001 From: Tom Hudson Date: Sun, 15 Mar 2026 18:07:20 -0400 Subject: [PATCH 1/5] 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 --- Dockerfile | 1 + Dockerfile.alpine | 1 + docker/entrypoint.sh | 1 + docker/ui.php | 2 +- package.json | 6 +- stability.html | 793 +++++++++++++++++++++++++++++++++++++++++++ stability_worker.js | 216 ++++++++++++ 7 files changed, 1017 insertions(+), 3 deletions(-) create mode 100644 stability.html create mode 100644 stability_worker.js diff --git a/Dockerfile b/Dockerfile index 365f1b6..097d590 100755 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,7 @@ COPY results/*.php /speedtest/results/ COPY results/*.ttf /speedtest/results/ COPY *.js /speedtest/ +COPY stability.html /speedtest/ COPY favicon.ico /speedtest/ COPY docker/servers.json /servers.json diff --git a/Dockerfile.alpine b/Dockerfile.alpine index e513e88..755a925 100755 --- a/Dockerfile.alpine +++ b/Dockerfile.alpine @@ -34,6 +34,7 @@ COPY results/*.php /speedtest/results/ COPY results/*.ttf /speedtest/results/ COPY *.js /speedtest/ +COPY stability.html /speedtest/ COPY favicon.ico /speedtest/ COPY docker/servers.json /servers.json diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 4e63e91..c922a0f 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -12,6 +12,7 @@ rm -rf /var/www/html/* # Copy frontend files cp /speedtest/*.js /var/www/html/ +cp /speedtest/stability.html /var/www/html/ # Copy favicon cp /speedtest/favicon.ico /var/www/html/ diff --git a/docker/ui.php b/docker/ui.php index 7310d2a..62001dd 100755 --- a/docker/ui.php +++ b/docker/ui.php @@ -481,7 +481,7 @@ function initUI(){ - Source code + Stability Test | Source code
-
Loss
+
Failed Requests
%
@@ -813,7 +813,7 @@ ctx.lineJoin = "round"; ctx.stroke(); - // draw lost packet markers + // draw failed request markers for (var i = 0; i < visible.length; i++) { if (!visible[i].lost) continue; var px = padL + ((visible[i].t - timeStart) / timeSpan) * plotW; @@ -879,7 +879,7 @@ // CSV download function downloadCsv() { if (allPingData.length === 0) return; - var csv = "elapsed_s,ping_ms,lost\n"; + var csv = "elapsed_s,ping_ms,failed\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"; diff --git a/stability_worker.js b/stability_worker.js index 46f5916..e964618 100644 --- a/stability_worker.js +++ b/stability_worker.js @@ -11,7 +11,7 @@ let avgPing = 0; let minPing = -1; let maxPing = 0; let jitter = 0; -let packetLoss = 0; +let packetLoss = 0; // failed request percentage let elapsed = 0; let progress = 0; @@ -121,7 +121,7 @@ function recordPing(instspd) { } prevInstspd = instspd; - // packet loss + // failed request percentage packetLoss = totalSamples > 0 ? parseFloat(((failedSamples / totalSamples) * 100).toFixed(2)) : 0; // record data point From a9197302b0893fb62d51526b8e057058a8df1a86 Mon Sep 17 00:00:00 2001 From: Stefan Stidl Date: Sat, 16 May 2026 15:22:06 +0200 Subject: [PATCH 4/5] Fix stability test server list and timeouts --- docker/entrypoint.sh | 1 + stability.html | 53 ++++++++++++++++++++++++++++++++------------ stability_worker.js | 22 ++++++++++++++++-- 3 files changed, 60 insertions(+), 16 deletions(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index bff6908..57293a3 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -96,6 +96,7 @@ if [[ "$MODE" == "frontend" || "$MODE" == "dual" || "$MODE" == "standalone" ]]; SERVER_LIST_URL_ESCAPED=$(printf '%s\n' "$SERVER_LIST_URL" | sed 's/[&/\\]/\\&/g; s/\$/\\$/g') sed -i "s/var SPEEDTEST_SERVERS = \"server-list.json\";/var SPEEDTEST_SERVERS = \"$SERVER_LIST_URL_ESCAPED\";/" /var/www/html/index-modern.html sed -i "s/var SPEEDTEST_SERVERS = \\[/var SPEEDTEST_SERVERS = \"$SERVER_LIST_URL_ESCAPED\";\\n\\t\\t\\/\\*/" /var/www/html/index-classic.html + sed -i "s/var SPEEDTEST_SERVERS = \"server-list.json\";/var SPEEDTEST_SERVERS = \"$SERVER_LIST_URL_ESCAPED\";/" /var/www/html/stability.html fi # The stability page reads the same local server list as the main UI when present. diff --git a/stability.html b/stability.html index 60b90cc..28a5693 100644 --- a/stability.html +++ b/stability.html @@ -357,7 +357,8 @@ } // Server configuration (same pattern as index.html) - var SPEEDTEST_SERVERS = []; + // Set this to a different URL to load the server list from another location. + var SPEEDTEST_SERVERS = "server-list.json"; // State var worker = null; @@ -394,30 +395,51 @@ // Load server list dynamically (Docker exposes server-list.json; older setups may expose servers.json) function loadServers(callback) { + if (Array.isArray(SPEEDTEST_SERVERS)) { + callback(); + return; + } + + var serverListUrl = typeof SPEEDTEST_SERVERS === "string" ? SPEEDTEST_SERVERS : ""; + if (!serverListUrl) { + SPEEDTEST_SERVERS = []; + callback(); + return; + } + var xhr = new XMLHttpRequest(); xhr.onload = function () { + var loadedServers = null; try { var servers = JSON.parse(xhr.responseText); - if (Array.isArray(servers) && servers.length > 0) { - SPEEDTEST_SERVERS = servers; + if (Array.isArray(servers)) { + loadedServers = servers; } } catch (e) {} - if (SPEEDTEST_SERVERS.length > 0 || xhr._fallbackTried) callback(); - else { + + if (loadedServers !== null) { + SPEEDTEST_SERVERS = loadedServers; + callback(); + } else if (!xhr._fallbackTried) { xhr._fallbackTried = true; xhr.open("GET", "servers.json?r=" + Math.random()); xhr.send(); + } else { + SPEEDTEST_SERVERS = []; + callback(); } }; xhr.onerror = function () { - if (xhr._fallbackTried) callback(); - else { + if (xhr._fallbackTried) { + SPEEDTEST_SERVERS = []; + callback(); + } else { xhr._fallbackTried = true; xhr.open("GET", "servers.json?r=" + Math.random()); xhr.send(); } }; - xhr.open("GET", "server-list.json?r=" + Math.random()); + xhr.open("GET", serverListUrl + (serverListUrl.match(/\?/) ? "&" : "?") + "r=" + Math.random()); try { xhr.timeout = 2000; xhr.ontimeout = xhr.onerror; @@ -515,8 +537,10 @@ workerSettings.mpot = true; } - worker = new Worker("stability_worker.js?r=" + Math.random()); + var currentWorker = new Worker("stability_worker.js?r=" + Math.random()); + worker = currentWorker; worker.onmessage = function (e) { + if (worker !== currentWorker) return; var data = JSON.parse(e.data); latestData = data; @@ -554,12 +578,13 @@ } // request final status if (worker) { - worker.postMessage("status"); + var stoppedWorker = worker; + stoppedWorker.postMessage("status"); setTimeout(function () { - if (worker) { - try { - worker.terminate(); - } catch (e) {} + try { + stoppedWorker.terminate(); + } catch (e) {} + if (worker === stoppedWorker) { worker = null; } }, 500); diff --git a/stability_worker.js b/stability_worker.js index e964618..0dd47b3 100644 --- a/stability_worker.js +++ b/stability_worker.js @@ -26,6 +26,7 @@ let settings = { url_ping_external: "", // external URL to ping (uses fetch no-cors, e.g. "https://www.google.com/generate_204") duration: 60, // seconds ping_interval: 200, // minimum ms between pings to limit sample rate + ping_timeout: 5000, ping_allowPerformanceApi: true, mpot: false }; @@ -202,7 +203,7 @@ function doPing() { true ); try { - xhr.timeout = 5000; + xhr.timeout = settings.ping_timeout; } catch (e) {} xhr.send(); } @@ -210,16 +211,33 @@ function doPing() { // ping an external host using fetch with no-cors (opaque response, but timing still works) function doPingExternal() { const prevT = new Date().getTime(); + const remainingMs = Math.max(1, settings.duration * 1000 - (prevT - startTime)); + const timeoutMs = Math.min(settings.ping_timeout, remainingMs); const url = settings.url_ping_external + (settings.url_ping_external.indexOf("?") >= 0 ? "&" : "?") + "r=" + Math.random(); - fetch(url, { mode: "no-cors", cache: "no-store" }) + + let timeoutId = null; + const controller = typeof AbortController !== "undefined" ? new AbortController() : null; + const fetchOptions = { mode: "no-cors", cache: "no-store" }; + if (controller) fetchOptions.signal = controller.signal; + + const timeout = new Promise(function (_, reject) { + timeoutId = setTimeout(function () { + if (controller) controller.abort(); + reject(new Error("timeout")); + }, timeoutMs); + }); + + Promise.race([fetch(url, fetchOptions), timeout]) .then(function () { + if (timeoutId) clearTimeout(timeoutId); if (aborted || testState >= 4) return; const instspd = new Date().getTime() - prevT; recordPing(instspd); schedulePing(instspd); }) .catch(function () { + if (timeoutId) clearTimeout(timeoutId); if (aborted || testState >= 4) return; recordLoss(); schedulePing(0); From 94104e9bb326369d9ccfb4a35175bf73066d6d4f Mon Sep 17 00:00:00 2001 From: Stefan Stidl Date: Sat, 16 May 2026 17:23:34 +0200 Subject: [PATCH 5/5] Update README for stability test --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 00c21e0..9525ea5 100755 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Works with mobile versions too. * Telemetry (optional) * Results sharing (optional) * Multiple Points of Test (optional) +* Connection stability test with latency charting, loss tracking, threshold alerts, and CSV export ![Screenrecording of a running Speedtest](https://speedtest.fdossena.com/mpot_v7.gif) @@ -40,7 +41,7 @@ Works with mobile versions too. Assuming you have PHP and a web server installed, the installation steps are quite simple. 1. Download the source code and extract it -1. Copy the project files to your web server's shared folder (ie. `/var/www/html/speedtest` for Apache). For the current layout, the web root should contain `index.html`, `index-classic.html`, `index-modern.html`, `design-switch.js`, `config.json`, `speedtest.js`, `speedtest_worker.js`, `favicon.ico`, and the `backend` folder. +1. Copy the project files to your web server's shared folder (ie. `/var/www/html/speedtest` for Apache). For the current layout, the web root should contain `index.html`, `index-classic.html`, `index-modern.html`, `stability.html`, `design-switch.js`, `config.json`, `speedtest.js`, `speedtest_worker.js`, `stability_worker.js`, `favicon.ico`, and the `backend` folder. 1. Also copy the contents of `frontend/` into the same web root so the modern UI assets end up in `styling/`, `javascript/`, `images/`, and `fonts/` next to the HTML files. 1. Optionally, copy the results folder too, and set up the database using the config file in it. 1. Be sure your permissions allow read and execute access where needed. @@ -72,6 +73,12 @@ If you want to contribute or develop with LibreSpeed, see [DEVELOPMENT.md](DEVEL LibreSpeed supports both the classic and modern UI. The root `index.html` acts as a lightweight switcher and redirects to `index-classic.html` or `index-modern.html` based on `config.json` (`useNewDesign`) or URL overrides (`?design=new` / `?design=old`). For architecture and deployment details (including Docker behavior), see [DESIGN_SWITCH.md](DESIGN_SWITCH.md). +## Stability test + +LibreSpeed includes a standalone connection stability test at `stability.html`, linked from both the classic and modern interfaces. It repeatedly measures ping over a selected duration and reports current, average, minimum, maximum, jitter, and failed request percentage values with a live chart. + +The stability test can target the local LibreSpeed backend, one of the configured multiple points of test, or built-in external targets such as Google, Cloudflare, and Apple. It also supports optional latency threshold alerts and CSV export of the collected samples. Docker deployments copy `stability.html` and `stability_worker.js` into the web root and reuse the same server list configuration as the main UI. + ## Docker A docker image is available on [GitHub](https://github.com/librespeed/speedtest/pkgs/container/speedtest), check our [docker documentation](doc_docker.md) for more info about it.