mirror of
https://github.com/librespeed/speedtest.git
synced 2026-08-04 14:48:38 +00:00
Merge pull request #805 from librespeed/internet-stability
add internet stability test
This commit is contained in:
commit
23b7780056
12 changed files with 1401 additions and 13 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||

|
||||
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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/<p class=\"tagline\">No Flash, No Java, No Websockets, No Bullsh\\*t<\\/p>/<p class=\"tagline\">$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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@
|
|||
</main>
|
||||
<footer>
|
||||
<p class="source">
|
||||
<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>
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
|
|
|
|||
|
|
@ -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/",
|
||||
|
|
|
|||
991
stability.html
Normal file
991
stability.html
Normal file
|
|
@ -0,0 +1,991 @@
|
|||
<!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.disabled {
|
||||
color: #a0a0a0;
|
||||
border-color: #c0c0c0;
|
||||
cursor: default;
|
||||
opacity: 0.8;
|
||||
}
|
||||
#startBtn.disabled:hover {
|
||||
box-shadow:
|
||||
0 0 0 rgba(0, 0, 0, 0.1),
|
||||
inset 0 0 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
#startBtn:before {
|
||||
content: "Start";
|
||||
}
|
||||
#startBtn.finding:before {
|
||||
content: "Finding...";
|
||||
}
|
||||
#startBtn.running {
|
||||
background-color: #ff3030;
|
||||
border-color: #ff6060;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
opacity: 1;
|
||||
}
|
||||
#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.disabled {
|
||||
color: #707070;
|
||||
border-color: #505050;
|
||||
}
|
||||
#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" onchange="updateStartButtonState()">
|
||||
<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" class="disabled finding" onclick="startStop()" aria-disabled="true" title="Finding best server..."></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">Failed Requests</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)
|
||||
// Set this to a different URL to load the server list from another location.
|
||||
var SPEEDTEST_SERVERS = "server-list.json";
|
||||
|
||||
// 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;
|
||||
var localServerReady = false;
|
||||
var serverDiscoveryPending = false;
|
||||
|
||||
// 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;
|
||||
|
||||
function joinServerUrl(server, path) {
|
||||
if (!server) return path;
|
||||
if (!path) return server;
|
||||
if (server.charAt(server.length - 1) === "/" || path.charAt(0) === "/") return server + path;
|
||||
return server + "/" + path;
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
loadedServers = servers;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
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) {
|
||||
SPEEDTEST_SERVERS = [];
|
||||
callback();
|
||||
} else {
|
||||
xhr._fallbackTried = true;
|
||||
xhr.open("GET", "servers.json?r=" + Math.random());
|
||||
xhr.send();
|
||||
}
|
||||
};
|
||||
xhr.open("GET", serverListUrl + (serverListUrl.match(/\?/) ? "&" : "?") + "r=" + Math.random());
|
||||
try {
|
||||
xhr.timeout = 2000;
|
||||
xhr.ontimeout = xhr.onerror;
|
||||
} catch (e) {}
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
// Server initialization
|
||||
function initServers() {
|
||||
selectedServer = null;
|
||||
localServerReady = false;
|
||||
serverDiscoveryPending = false;
|
||||
I("server").innerHTML = "";
|
||||
if (SPEEDTEST_SERVERS.length === 0) {
|
||||
I("serverArea").style.display = "none";
|
||||
localServerReady = true;
|
||||
updateStartButtonState();
|
||||
} else {
|
||||
I("serverArea").style.display = "";
|
||||
serverDiscoveryPending = true;
|
||||
I("server").disabled = true;
|
||||
var pendingOpt = document.createElement("option");
|
||||
pendingOpt.value = "";
|
||||
pendingOpt.textContent = "Finding best server...";
|
||||
I("server").appendChild(pendingOpt);
|
||||
updateStartButtonState();
|
||||
// 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
|
||||
I("server").innerHTML = "";
|
||||
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;
|
||||
serverDiscoveryPending = false;
|
||||
localServerReady = selectedServer !== null || I("server").options.length > 0;
|
||||
I("server").disabled = !localServerReady;
|
||||
updateStartButtonState();
|
||||
}
|
||||
});
|
||||
})(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pingServer(server, callback) {
|
||||
var baseUrl = joinServerUrl(server.server, server.pingURL);
|
||||
var url = baseUrl + (baseUrl.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) {
|
||||
if (idx === "") return;
|
||||
selectedServer = SPEEDTEST_SERVERS[idx];
|
||||
updateStartButtonState();
|
||||
}
|
||||
|
||||
function canStartTest() {
|
||||
return !!I("targetSelect").value || localServerReady;
|
||||
}
|
||||
|
||||
function updateStartButtonState() {
|
||||
if (running) return;
|
||||
|
||||
var startBtn = I("startBtn");
|
||||
var canStart = canStartTest();
|
||||
startBtn.className = canStart ? "" : serverDiscoveryPending ? "disabled finding" : "disabled";
|
||||
startBtn.setAttribute("aria-disabled", canStart ? "false" : "true");
|
||||
startBtn.title = canStart ? "" : serverDiscoveryPending ? "Finding best server..." : "No reachable local server found";
|
||||
I("server").disabled = !localServerReady || serverDiscoveryPending;
|
||||
}
|
||||
|
||||
// Start/Stop
|
||||
function startStop() {
|
||||
if (running) {
|
||||
// abort
|
||||
if (worker) worker.postMessage("abort");
|
||||
stopTest();
|
||||
} else {
|
||||
if (!canStartTest()) return;
|
||||
startTest();
|
||||
}
|
||||
}
|
||||
|
||||
function startTest() {
|
||||
if (!canStartTest()) return;
|
||||
|
||||
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 = joinServerUrl(selectedServer.server, selectedServer.pingURL);
|
||||
workerSettings.mpot = true;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// 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;
|
||||
updateStartButtonState();
|
||||
if (updater) {
|
||||
clearInterval(updater);
|
||||
updater = null;
|
||||
}
|
||||
// request final status
|
||||
if (worker) {
|
||||
var stoppedWorker = worker;
|
||||
stoppedWorker.postMessage("status");
|
||||
setTimeout(function () {
|
||||
try {
|
||||
stoppedWorker.terminate();
|
||||
} catch (e) {}
|
||||
if (worker === stoppedWorker) {
|
||||
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;
|
||||
}
|
||||
|
||||
// binary search to find first visible data point
|
||||
var visStart = 0;
|
||||
var lo = 0,
|
||||
hi = data.length - 1;
|
||||
while (lo <= hi) {
|
||||
var mid = (lo + hi) >> 1;
|
||||
if (data[mid].t < timeStart) lo = mid + 1;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
visStart = lo;
|
||||
|
||||
// determine Y-axis scale from visible data only
|
||||
var yMax = 50; // minimum 50ms scale
|
||||
for (var i = visStart; i < data.length && data[i].t <= timeEnd; i++) {
|
||||
if (!data[i].lost && 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 (using binary search start index)
|
||||
var visible = [];
|
||||
for (var i = visStart; i < data.length && data[i].t <= timeEnd; i++) {
|
||||
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 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;
|
||||
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,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";
|
||||
}
|
||||
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 — load server list first (async), then initialize UI
|
||||
loadServers(function () {
|
||||
initServers();
|
||||
drawChart();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
245
stability_worker.js
Normal file
245
stability_worker.js
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
/*
|
||||
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; // failed request percentage
|
||||
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_interval: 200, // minimum ms between pings to limit sample rate
|
||||
ping_timeout: 5000,
|
||||
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;
|
||||
|
||||
// failed request percentage
|
||||
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 });
|
||||
}
|
||||
|
||||
// pace pings to avoid excessive sample rates on low-latency links
|
||||
function schedulePing(rtt) {
|
||||
const delay = Math.max(0, settings.ping_interval - rtt);
|
||||
if (delay > 0) {
|
||||
setTimeout(doPing, delay);
|
||||
} else {
|
||||
doPing();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
schedulePing(instspd);
|
||||
};
|
||||
xhr.onerror = function () {
|
||||
if (aborted || testState >= 4) return;
|
||||
recordLoss();
|
||||
schedulePing(0);
|
||||
};
|
||||
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 = settings.ping_timeout;
|
||||
} 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 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();
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,12 +1,17 @@
|
|||
function modernStartButton(page) {
|
||||
return page.locator('#start-button');
|
||||
return page.locator("#start-button");
|
||||
}
|
||||
|
||||
function classicStartButton(page) {
|
||||
return page.locator('#startStopBtn');
|
||||
return page.locator("#startStopBtn");
|
||||
}
|
||||
|
||||
function stabilityStartButton(page) {
|
||||
return page.locator("#startBtn");
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
modernStartButton,
|
||||
classicStartButton,
|
||||
stabilityStartButton
|
||||
};
|
||||
|
|
|
|||
125
tests/e2e/stability.spec.js
Normal file
125
tests/e2e/stability.spec.js
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
const fs = require("node:fs");
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { baseUrls } = require("./helpers/env");
|
||||
const { stabilityStartButton } = require("./helpers/ui");
|
||||
|
||||
async function setShortDuration(page) {
|
||||
await page.evaluate(() => {
|
||||
const select = document.querySelector("#durationSelect");
|
||||
const option = document.createElement("option");
|
||||
option.value = "1";
|
||||
option.textContent = "1 Second";
|
||||
select.insertBefore(option, select.firstChild);
|
||||
select.value = "1";
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
async function setAlertThreshold(page, value) {
|
||||
await page.evaluate(threshold => {
|
||||
const input = document.querySelector("#alertThreshold");
|
||||
input.value = String(threshold);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}, value);
|
||||
}
|
||||
|
||||
async function waitForSamples(page) {
|
||||
await expect.poll(() => page.evaluate(() => window.allPingData.length), { timeout: 10_000 }).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
async function waitForLocalServer(page, serverName) {
|
||||
await expect(page.locator("#serverArea")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.locator("#server option")).toContainText(serverName, { timeout: 10_000 });
|
||||
}
|
||||
|
||||
test.describe("Stability test", () => {
|
||||
test("keeps the local start control disabled until server discovery completes", async ({ page }) => {
|
||||
let releaseServerProbe;
|
||||
const serverProbe = new Promise(resolve => {
|
||||
releaseServerProbe = resolve;
|
||||
});
|
||||
|
||||
await page.route(/\/backend\/empty\.php\?cors=true/, async route => {
|
||||
await serverProbe;
|
||||
await route.fulfill({ status: 200, body: "" });
|
||||
});
|
||||
|
||||
await page.goto(`${baseUrls.standalone}/stability.html`);
|
||||
|
||||
await expect(stabilityStartButton(page)).toHaveClass(/disabled/);
|
||||
await expect(stabilityStartButton(page)).toHaveClass(/finding/);
|
||||
await expect(stabilityStartButton(page)).toHaveAttribute("aria-disabled", "true");
|
||||
await expect(stabilityStartButton(page)).toHaveAttribute("title", "Finding best server...");
|
||||
await expect(page.locator("#server")).toBeDisabled();
|
||||
|
||||
releaseServerProbe();
|
||||
|
||||
await expect(page.locator("#server option")).toContainText("local", { timeout: 10_000 });
|
||||
await expect(stabilityStartButton(page)).not.toHaveClass(/disabled/);
|
||||
await expect(stabilityStartButton(page)).toHaveAttribute("aria-disabled", "false");
|
||||
await expect(stabilityStartButton(page)).toHaveAttribute("title", "");
|
||||
await expect(page.locator("#server")).toBeEnabled();
|
||||
});
|
||||
|
||||
test("runs a short local measurement and exports CSV data", async ({ page }) => {
|
||||
await page.goto(`${baseUrls.standalone}/stability.html`);
|
||||
|
||||
await expect(page).toHaveTitle("LibreSpeed - Stability Test");
|
||||
await waitForLocalServer(page, "local");
|
||||
|
||||
await setShortDuration(page);
|
||||
await stabilityStartButton(page).click();
|
||||
|
||||
await expect(stabilityStartButton(page)).toHaveClass(/running/);
|
||||
await expect(page.locator("#durationSelect")).toBeDisabled();
|
||||
await expect(page.locator("#targetSelect")).toBeDisabled();
|
||||
await expect(page.locator("#server")).toBeDisabled();
|
||||
|
||||
await waitForSamples(page);
|
||||
await expect(page.locator("#statAvg")).not.toHaveText("", { timeout: 5_000 });
|
||||
await expect(page.locator("#rating")).not.toHaveText("--", { timeout: 5_000 });
|
||||
|
||||
await expect(stabilityStartButton(page)).not.toHaveClass(/running/, { timeout: 10_000 });
|
||||
await expect(page.locator("#durationSelect")).toBeEnabled();
|
||||
|
||||
const [download] = await Promise.all([page.waitForEvent("download"), page.locator("#downloadCsvBtn").click()]);
|
||||
expect(download.suggestedFilename()).toMatch(/^stability_test_.*\.csv$/);
|
||||
|
||||
const csvPath = await download.path();
|
||||
const csv = fs.readFileSync(csvPath, "utf8");
|
||||
expect(csv).toContain("elapsed_s,ping_ms,failed\n");
|
||||
expect(csv.trim().split("\n").length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
test("supports threshold display, abort, and reset controls", async ({ page }) => {
|
||||
await page.goto(`${baseUrls.standalone}/stability.html`);
|
||||
await waitForLocalServer(page, "local");
|
||||
|
||||
await setAlertThreshold(page, 40);
|
||||
await expect(page.locator("#thresholdValue")).toHaveText("40 ms");
|
||||
|
||||
await stabilityStartButton(page).click();
|
||||
await expect(stabilityStartButton(page)).toHaveClass(/running/);
|
||||
await waitForSamples(page);
|
||||
|
||||
await stabilityStartButton(page).click();
|
||||
await expect(stabilityStartButton(page)).not.toHaveClass(/running/);
|
||||
await expect(page.locator("#durationSelect")).toBeEnabled();
|
||||
await expect(page.locator("#targetSelect")).toBeEnabled();
|
||||
|
||||
await page.waitForTimeout(700);
|
||||
await page.locator("#resetBtn").click();
|
||||
|
||||
await expect(page.locator("#rating")).toHaveText("--");
|
||||
await expect(page.locator("#statAvg")).toHaveText("");
|
||||
await expect.poll(() => page.evaluate(() => window.allPingData.length)).toBe(0);
|
||||
await expect.poll(() => page.evaluate(() => window.latestData)).toBeNull();
|
||||
});
|
||||
|
||||
test("loads the configured dual-mode server list", async ({ page }) => {
|
||||
await page.goto(`${baseUrls.dual}/stability.html`);
|
||||
|
||||
await expect(page.locator("#serverArea")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.locator("#server option")).toContainText("Local dual backend", { timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue