diff --git a/Dockerfile b/Dockerfile index f74012946..8aeca244c 100755 --- a/Dockerfile +++ b/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 diff --git a/Dockerfile.alpine b/Dockerfile.alpine index 1a99be981..47f694bd5 100755 --- a/Dockerfile.alpine +++ b/Dockerfile.alpine @@ -1,7 +1,9 @@ -FROM php:8-alpine +FROM alpine:3.23 RUN apk add --quiet --no-cache \ bash \ apache2 \ + ca-certificates \ + php \ php-apache2 \ php-ctype \ php-phar \ @@ -15,12 +17,6 @@ RUN apk add --quiet --no-cache \ php-session \ php-sqlite3 -# # use docker-php-extension-installer for automatically get the right packages installed -# ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/ - -# # Install extensions -# RUN install-php-extensions iconv gd pdo pdo_mysql pdo_pgsql pgsql - RUN ln -sf /dev/stdout /var/log/apache2/access.log && \ ln -sf /dev/stderr /var/log/apache2/error.log @@ -39,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 diff --git a/README.md b/README.md index 00c21e004..9525ea536 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. diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 7ede3a7bf..88be059ef 100755 --- a/docker/entrypoint.sh +++ b/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/ @@ -52,6 +53,11 @@ else fi +# Copy servers.json for stability page (frontend/dual modes) +if [[ "$MODE" == "frontend" || "$MODE" == "dual" ]]; then + cp /servers.json /var/www/html/servers.json +fi + # Set up backend side for standlone modes if [[ "$MODE" == "standalone" || "$MODE" == "dual" ]]; then cp -r /speedtest/backend/ /var/www/html/backend @@ -73,14 +79,14 @@ if [[ "$MODE" == "frontend" || "$MODE" == "dual" || "$MODE" == "standalone" ]]; cp /speedtest/index.html /var/www/html/ cp /speedtest/index-classic.html /var/www/html/ cp /speedtest/index-modern.html /var/www/html/ - + cp /speedtest/stability.html /var/www/html/ # Copy frontend assets directly to root-level subdirectories (no frontend/ parent dir) mkdir -p /var/www/html/styling /var/www/html/javascript /var/www/html/images /var/www/html/fonts cp -a /speedtest/frontend/styling/* /var/www/html/styling/ cp -a /speedtest/frontend/javascript/* /var/www/html/javascript/ cp -a /speedtest/frontend/images/* /var/www/html/images/ cp -a /speedtest/frontend/fonts/* /var/www/html/fonts/ 2>/dev/null || true - + # Copy frontend config files cp /speedtest/frontend/settings.json /var/www/html/settings.json 2>/dev/null || true if [ -f /servers.json ]; then @@ -96,6 +102,12 @@ 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. + if [ -f /var/www/html/server-list.json ]; then + cp /var/www/html/server-list.json /var/www/html/servers.json fi # Replace title placeholders if TITLE is set @@ -117,7 +129,7 @@ if [[ "$MODE" == "frontend" || "$MODE" == "dual" || "$MODE" == "standalone" ]]; TAGLINE_ESCAPED=$(sed_escape "$TAGLINE_HTML_ESCAPED") sed -i "s/

No Flash, No Java, No Websockets, No Bullsh\\*t<\\/p>/

$TAGLINE_ESCAPED<\\/p>/g" /var/www/html/index-modern.html fi - + # Support legacy EMAIL env var as fallback for GDPR_EMAIL if [ -z "$GDPR_EMAIL" ] && [ ! -z "$EMAIL" ]; then echo "WARNING: EMAIL env var is deprecated, please use GDPR_EMAIL instead" >&2 @@ -129,7 +141,7 @@ if [[ "$MODE" == "frontend" || "$MODE" == "dual" || "$MODE" == "standalone" ]]; if [ ! -z "$GDPR_EMAIL" ]; then # Escape special sed characters: & (replacement), / (delimiter), \ (escape), $ (variable) GDPR_EMAIL_ESCAPED=$(printf '%s\n' "$GDPR_EMAIL" | sed 's/[&/\\]/\\&/g; s/\$/\\$/g') - + for html_file in /var/www/html/index-modern.html /var/www/html/index-classic.html; do if [ -f "$html_file" ]; then sed -i "s/TO BE FILLED BY DEVELOPER/$GDPR_EMAIL_ESCAPED/g; s/PUT@YOUR_EMAIL.HERE/$GDPR_EMAIL_ESCAPED/g" "$html_file" diff --git a/frontend/index.html b/frontend/index.html index cf7b7b04a..8a804d7a2 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -75,6 +75,7 @@

Upload

diff --git a/frontend/javascript/index.js b/frontend/javascript/index.js index ba4eb0ead..9935bd4c3 100644 --- a/frontend/javascript/index.js +++ b/frontend/javascript/index.js @@ -230,8 +230,34 @@ function populateDropdown(servers) { }); } + // Sort servers by country, then by city within the same country. + // Name formats: "City, Country", "City, Country (qualifier)", "City, Country, Provider", "Country" + const parseServerName = (name) => { + const parts = (name || "").split(",").map((s) => s.trim()); + let country, city; + if (parts.length >= 3) { + // "City, Country, Provider" — use second part as country + country = parts[1]; + city = parts[0]; + } else if (parts.length === 2) { + country = parts[1]; + city = parts[0]; + } else { + country = parts[0]; + city = ""; + } + // Strip parenthetical qualifiers for sorting: "Germany (1) (Hetzner)" → "Germany" + country = country.replace(/\s*\([^)]*\)\s*/g, "").trim(); + return { country, city }; + }; + const sorted = [...servers].sort((a, b) => { + const pa = parseServerName(a.name); + const pb = parseServerName(b.name); + return pa.country.localeCompare(pb.country) || pa.city.localeCompare(pb.city); + }); + // Populate the list to choose from - servers.forEach((server) => { + sorted.forEach((server) => { const item = document.createElement("li"); const link = document.createElement("a"); link.href = "#"; diff --git a/index-classic.html b/index-classic.html index 19bf2e20b..d546c494b 100644 --- a/index-classic.html +++ b/index-classic.html @@ -50,13 +50,41 @@ s.selectServer(function (server) { if (server != null) { //at least 1 server is available I("loading").className = "hidden"; //hide loading message + //sort servers by country, then by city + //name formats: "City, Country", "City, Country (qualifier)", "City, Country, Provider", "Country" + function parseServerName(name) { + var parts = (name || "").split(","); + for (var p = 0; p < parts.length; p++) parts[p] = parts[p].trim(); + var country, city; + if (parts.length >= 3) { + country = parts[1]; + city = parts[0]; + } else if (parts.length === 2) { + country = parts[1]; + city = parts[0]; + } else { + country = parts[0]; + city = ""; + } + country = country.replace(/\s*\([^)]*\)\s*/g, "").trim(); + return { country: country, city: city }; + } + var indexed = []; + for (var j = 0; j < SPEEDTEST_SERVERS.length; j++) { + indexed.push({ idx: j, server: SPEEDTEST_SERVERS[j] }); + } + indexed.sort(function (a, b) { + var pa = parseServerName(a.server.name); + var pb = parseServerName(b.server.name); + return pa.country.localeCompare(pb.country) || pa.city.localeCompare(pb.city); + }); //populate server list for manual selection - for (var i = 0; i < SPEEDTEST_SERVERS.length; i++) { - if (SPEEDTEST_SERVERS[i].pingT == -1) continue; + for (var i = 0; i < indexed.length; i++) { + if (indexed[i].server.pingT == -1) continue; var option = document.createElement("option"); - option.value = i; - option.textContent = SPEEDTEST_SERVERS[i].name; - if (SPEEDTEST_SERVERS[i] === server) option.selected = true; + option.value = indexed[i].idx; + option.textContent = indexed[i].server.name; + if (indexed[i].server === server) option.selected = true; I("server").appendChild(option); } //show test UI @@ -539,7 +567,7 @@

Share results

Try the modern design
- Source code + Stability Test | Source code