mirror of
https://github.com/librespeed/speedtest.git
synced 2026-05-14 00:36:39 +00:00
* Implement fromScratch design * Add some documentation * enable new design in docker (no alpine yet) * merge fixed docker images * alpine docker added new design * fix #685 * Implement fromScratch design * Add some documentation * enable new design in docker (no alpine yet) * alpine docker added new design * fix #685 * fix database permissions alpine, remove baby * hide serverselector on only one server * Update frontend/styling/server-selector.css Co-authored-by: qodo-merge-pro-for-open-source[bot] <189517486+qodo-merge-pro-for-open-source[bot]@users.noreply.github.com> * fix alpine image again * adjust settings.json in entrypoint so env vars are honored * Update frontend/javascript/index.js Co-authored-by: qodo-merge-for-open-source[bot] <189517486+qodo-merge-for-open-source[bot]@users.noreply.github.com> * Add feature switch for new design via config file, URL parameters, and Docker (#742) * Initial plan * Add feature switch for new design with config and URL parameter support Co-authored-by: sstidl <12804296+sstidl@users.noreply.github.com> * Improve error handling and prevent infinite redirect loops Co-authored-by: sstidl <12804296+sstidl@users.noreply.github.com> * Update Dockerfiles and entrypoint to support design feature switch Co-authored-by: sstidl <12804296+sstidl@users.noreply.github.com> * Update design-switch.js Co-authored-by: qodo-free-for-open-source-projects[bot] <189517486+qodo-free-for-open-source-projects[bot]@users.noreply.github.com> * fix: copy actions in entrypoint * Restructure design switch to place both designs at root level Co-authored-by: sstidl <12804296+sstidl@users.noreply.github.com> * Flatten frontend assets in Docker to eliminate frontend directory Co-authored-by: sstidl <12804296+sstidl@users.noreply.github.com> * fix: entrypoint settings & server-list disable entrypoint bash debug * add link to modern design --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sstidl <12804296+sstidl@users.noreply.github.com> Co-authored-by: sstidl <sstidl@users.noreply.github.com> Co-authored-by: qodo-free-for-open-source-projects[bot] <189517486+qodo-free-for-open-source-projects[bot]@users.noreply.github.com> * add armv7 * reformat * Add GDPR_EMAIL environment variable for Docker deployments (#743) * Initial plan * Add GDPR_EMAIL environment variable for Docker deployments Co-authored-by: sstidl <12804296+sstidl@users.noreply.github.com> * Improve GDPR_EMAIL handling with proper escaping and loop Co-authored-by: sstidl <12804296+sstidl@users.noreply.github.com> * Refine GDPR_EMAIL processing - skip index.html and improve escaping Co-authored-by: sstidl <12804296+sstidl@users.noreply.github.com> * Optimize GDPR_EMAIL sed commands and improve escaping Co-authored-by: sstidl <12804296+sstidl@users.noreply.github.com> * Clarify sed escaping comment for GDPR_EMAIL Co-authored-by: sstidl <12804296+sstidl@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sstidl <12804296+sstidl@users.noreply.github.com> * cleanup old EMAIL ENV Var * fix: line break in html prevented sed replacement * version 6.0.0pre1 * test: add mssql docker compose tests * Update Speedtest screen recording link in README * Filter unreachable servers from selector (newdesign UI) (#769) * Filter unreachable servers from selector (newdesign UI) * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * keep // servers in list Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix misleading comment --------- Co-authored-by: Lumi <lumi@openclaw.local> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Stefan Stidl <sti-github@stidl.com> * fix: server.json handling * Use server-list.json in classic frontend by default * Add configurable server list URLs to frontend and Docker --------- Co-authored-by: Timendus <mail@timendus.com> Co-authored-by: Stefan Stidl <stefan.stidl@ffg.at> Co-authored-by: qodo-merge-pro-for-open-source[bot] <189517486+qodo-merge-pro-for-open-source[bot]@users.noreply.github.com> Co-authored-by: qodo-merge-for-open-source[bot] <189517486+qodo-merge-for-open-source[bot]@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: sstidl <12804296+sstidl@users.noreply.github.com> Co-authored-by: qodo-free-for-open-source-projects[bot] <189517486+qodo-free-for-open-source-projects[bot]@users.noreply.github.com> Co-authored-by: Stefan Stidl <sti-github@stidl.com> Co-authored-by: Lumi <lumi@openclaw.local> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
447 lines
14 KiB
JavaScript
447 lines
14 KiB
JavaScript
/**
|
|
* Design by fromScratch Studio - 2022, 2023 (fromscratch.io)
|
|
* Implementation in HTML/CSS/JS by Timendus - 2024 (https://github.com/Timendus)
|
|
*
|
|
* See https://github.com/librespeed/speedtest/issues/585
|
|
*/
|
|
|
|
// States the UI can be in
|
|
const INITIALIZING = 0;
|
|
const READY = 1;
|
|
const RUNNING = 2;
|
|
const FINISHED = 3;
|
|
|
|
// Keep some global state here
|
|
const testState = {
|
|
state: INITIALIZING,
|
|
speedtest: null,
|
|
servers: [],
|
|
selectedServerDirty: false,
|
|
testData: null,
|
|
testDataDirty: false,
|
|
telemetryEnabled: false,
|
|
};
|
|
|
|
// Bootstrap the application when the DOM is ready
|
|
window.addEventListener("DOMContentLoaded", async () => {
|
|
createSpeedtest();
|
|
hookUpButtons();
|
|
startRenderingLoop();
|
|
applySettingsJSON();
|
|
applyServerListJSON();
|
|
});
|
|
|
|
/**
|
|
* Create a new Speedtest and hook it into the global state
|
|
*/
|
|
function createSpeedtest() {
|
|
testState.speedtest = new Speedtest();
|
|
testState.speedtest.onupdate = (data) => {
|
|
testState.testData = data;
|
|
testState.testDataDirty = true;
|
|
};
|
|
testState.speedtest.onend = (aborted) =>
|
|
(testState.state = aborted ? READY : FINISHED);
|
|
}
|
|
|
|
/**
|
|
* Make all the buttons respond to the right clicks
|
|
*/
|
|
function hookUpButtons() {
|
|
document
|
|
.querySelector("#start-button")
|
|
.addEventListener("click", startButtonClickHandler);
|
|
document
|
|
.querySelector("#choose-privacy")
|
|
.addEventListener("click", () =>
|
|
document.querySelector("#privacy").showModal()
|
|
);
|
|
document
|
|
.querySelector("#share-results")
|
|
.addEventListener("click", () =>
|
|
document.querySelector("#share").showModal()
|
|
);
|
|
document
|
|
.querySelector("#copy-link")
|
|
.addEventListener("click", copyLinkButtonClickHandler);
|
|
document
|
|
.querySelectorAll(".close-dialog, #close-privacy")
|
|
.forEach((element) => {
|
|
element.addEventListener("click", () =>
|
|
document.querySelectorAll("dialog").forEach((modal) => modal.close())
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Event listener for clicks on the main start button
|
|
*/
|
|
function startButtonClickHandler() {
|
|
switch (testState.state) {
|
|
case READY:
|
|
case FINISHED:
|
|
testState.speedtest.start();
|
|
testState.state = RUNNING;
|
|
return;
|
|
case RUNNING:
|
|
testState.speedtest.abort();
|
|
// testState.state is updated by `onend` handler of speedtest
|
|
return;
|
|
default:
|
|
return;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Event listener for clicks on the "Copy link" button in the modal
|
|
*/
|
|
async function copyLinkButtonClickHandler() {
|
|
const link = document.querySelector("img#results").src;
|
|
await navigator.clipboard.writeText(link);
|
|
const button = document.querySelector("#copy-link");
|
|
button.classList.add("active");
|
|
button.textContent = "Copied!";
|
|
setTimeout(() => {
|
|
button.classList.remove("active");
|
|
button.textContent = "Copy link";
|
|
}, 3000);
|
|
}
|
|
|
|
/**
|
|
* Load settings from settings.json on the server and apply them
|
|
*/
|
|
async function applySettingsJSON() {
|
|
try {
|
|
const response = await fetch("settings.json");
|
|
const settings = await response.json();
|
|
if (!settings || typeof settings !== "object") {
|
|
return console.error("Settings are empty or malformed");
|
|
}
|
|
for (let setting in settings) {
|
|
testState.speedtest.setParameter(setting, settings[setting]);
|
|
if (
|
|
setting == "telemetry_level" &&
|
|
settings[setting] &&
|
|
settings[setting] != "off" &&
|
|
settings[setting] != "disabled" &&
|
|
settings[setting] != "false"
|
|
) {
|
|
testState.telemetryEnabled = true;
|
|
document.querySelector("#privacy-warning").classList.remove("hidden");
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to fetch settings:", error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load server list from the configured source and populate the dropdown
|
|
*/
|
|
async function applyServerListJSON() {
|
|
try {
|
|
const serverSource =
|
|
typeof globalThis.SPEEDTEST_SERVERS !== "undefined"
|
|
? globalThis.SPEEDTEST_SERVERS
|
|
: "server-list.json";
|
|
const servers = Array.isArray(serverSource)
|
|
? serverSource
|
|
: await fetch(serverSource).then((response) => response.json());
|
|
if (!servers || !Array.isArray(servers) || servers.length === 0) {
|
|
return console.error("Server list is empty or malformed");
|
|
}
|
|
|
|
testState.servers = servers;
|
|
|
|
// If there's only one server, just show it. No reachability checks needed.
|
|
if (servers.length === 1) {
|
|
populateDropdown(servers);
|
|
return;
|
|
}
|
|
|
|
// For multiple servers: first run the built-in selection (which pings servers
|
|
// and annotates them with pingT). Only then populate the dropdown so that
|
|
// dead servers don't appear.
|
|
testState.speedtest.addTestPoints(servers);
|
|
testState.speedtest.selectServer((bestServer) => {
|
|
const aliveServers = testState.servers.filter((s) => {
|
|
// Keep servers that responded to ping (pingT !== -1).
|
|
if (s.pingT !== -1) return true;
|
|
// Also keep protocol-relative servers ("//...") as a defensive fallback.
|
|
// LibreSpeed normalizes them to the page protocol before pinging, so they
|
|
// are normally treated like any other server and get a real pingT value.
|
|
return typeof s.server === "string" && s.server.startsWith("//");
|
|
});
|
|
|
|
// Prefer to show only reachable servers, but if none are reachable,
|
|
// fall back to the full list so users can still pick a server manually.
|
|
if (aliveServers.length > 0) {
|
|
testState.servers = aliveServers;
|
|
}
|
|
populateDropdown(testState.servers);
|
|
|
|
|
|
if (bestServer) {
|
|
selectServer(bestServer);
|
|
} else {
|
|
alert(
|
|
"Can't reach any of the speedtest servers! But you're on this page. Something weird is going on with your network."
|
|
);
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error("Failed to load server list:", error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add all the servers to the server selection dropdown and make it actually
|
|
* work.
|
|
* @param {Array} servers - an array of server objects
|
|
*/
|
|
function populateDropdown(servers) {
|
|
const serverSelector = document.querySelector("div.server-selector");
|
|
const serverList = serverSelector.querySelector("ul.servers");
|
|
|
|
// Reset previous state (populateDropdown can be called multiple times)
|
|
serverSelector.classList.remove("single-server");
|
|
serverSelector.classList.remove("active");
|
|
serverList.classList.remove("active");
|
|
serverList.innerHTML = "";
|
|
|
|
// If we have only a single server, just show it
|
|
if (servers.length === 1) {
|
|
serverSelector.classList.add("single-server");
|
|
selectServer(servers[0]);
|
|
return;
|
|
}
|
|
serverSelector.classList.add("active");
|
|
|
|
// Make the dropdown open and close (hook only once)
|
|
if (serverSelector.dataset.hooked !== "1") {
|
|
serverSelector.dataset.hooked = "1";
|
|
|
|
serverSelector.addEventListener("click", () => {
|
|
serverList.classList.toggle("active");
|
|
});
|
|
document.addEventListener("click", (e) => {
|
|
if (e.target.closest("div.server-selector") !== serverSelector)
|
|
serverList.classList.remove("active");
|
|
});
|
|
}
|
|
|
|
// Populate the list to choose from
|
|
servers.forEach((server) => {
|
|
const item = document.createElement("li");
|
|
const link = document.createElement("a");
|
|
link.href = "#";
|
|
link.innerHTML = `${server.name}${
|
|
server.sponsorName ? ` <span>(${server.sponsorName})</span>` : ""
|
|
}`;
|
|
link.addEventListener("click", () => selectServer(server));
|
|
item.appendChild(link);
|
|
serverList.appendChild(item);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Set the given server as the selected server for the speedtest
|
|
* @param {Object} server - a server object
|
|
*/
|
|
function selectServer(server) {
|
|
testState.speedtest.setSelectedServer(server);
|
|
testState.selectedServerDirty = true;
|
|
testState.state = READY;
|
|
}
|
|
|
|
/**
|
|
* Start the requestAnimationFrame UI rendering loop
|
|
*/
|
|
function startRenderingLoop() {
|
|
// Do these queries once to speed up the rendering itself
|
|
const serverSelector = document.querySelector("div.server-selector");
|
|
const selectedServer = serverSelector.querySelector("#selected-server");
|
|
const sponsor = serverSelector.querySelector("#sponsor");
|
|
const startButton = document.querySelector("#start-button");
|
|
const privacyWarning = document.querySelector("#privacy-warning");
|
|
|
|
const gauges = document.querySelectorAll("#download-gauge, #upload-gauge");
|
|
const downloadProgress = document.querySelector("#download-gauge .progress");
|
|
const uploadProgress = document.querySelector("#upload-gauge .progress");
|
|
const downloadGauge = document.querySelector("#download-gauge .speed");
|
|
const uploadGauge = document.querySelector("#upload-gauge .speed");
|
|
const downloadText = document.querySelector("#download-gauge span");
|
|
const uploadText = document.querySelector("#upload-gauge span");
|
|
|
|
const pingAndJitter = document.querySelectorAll(".ping, .jitter");
|
|
const ping = document.querySelector("#ping");
|
|
const jitter = document.querySelector("#jitter");
|
|
const shareResults = document.querySelector("#share-results");
|
|
const copyLink = document.querySelector("#copy-link");
|
|
const resultsImage = document.querySelector("#results");
|
|
|
|
const buttonTexts = {
|
|
[INITIALIZING]: "Loading...",
|
|
[READY]: "Let's start",
|
|
[RUNNING]: "Abort",
|
|
[FINISHED]: "Restart",
|
|
};
|
|
|
|
// Show copy link button only if navigator.clipboard is available
|
|
copyLink.classList.toggle("hidden", !navigator.clipboard);
|
|
|
|
function renderUI() {
|
|
// Make the main button reflect the current state
|
|
startButton.textContent = buttonTexts[testState.state];
|
|
startButton.classList.toggle("disabled", testState.state === INITIALIZING);
|
|
startButton.classList.toggle("active", testState.state === RUNNING);
|
|
|
|
// Disable the server selector while test is running
|
|
serverSelector.classList.toggle("disabled", testState.state === RUNNING);
|
|
|
|
// Show selected server
|
|
if (testState.selectedServerDirty) {
|
|
const server = testState.speedtest.getSelectedServer();
|
|
selectedServer.textContent = server.name;
|
|
if (server.sponsorName) {
|
|
if (server.sponsorURL) {
|
|
sponsor.innerHTML = `Sponsor: <a href="${server.sponsorURL}">${server.sponsorName}</a>`;
|
|
} else {
|
|
sponsor.textContent = `Sponsor: ${server.sponsorName}`;
|
|
}
|
|
} else {
|
|
sponsor.innerHTML = " ";
|
|
}
|
|
testState.selectedServerDirty = false;
|
|
}
|
|
|
|
// Activate the gauges when test running or finished
|
|
gauges.forEach((e) =>
|
|
e.classList.toggle(
|
|
"enabled",
|
|
testState.state === RUNNING || testState.state === FINISHED
|
|
)
|
|
);
|
|
|
|
// Show ping and jitter if data is available
|
|
pingAndJitter.forEach((e) =>
|
|
e.classList.toggle(
|
|
"hidden",
|
|
!(
|
|
testState.testData &&
|
|
testState.testData.pingStatus &&
|
|
testState.testData.jitterStatus
|
|
)
|
|
)
|
|
);
|
|
|
|
// Show share button after test if server supports it
|
|
shareResults.classList.toggle(
|
|
"hidden",
|
|
!(
|
|
testState.state === FINISHED &&
|
|
testState.telemetryEnabled &&
|
|
testState.testData.testId
|
|
)
|
|
);
|
|
|
|
if (testState.testDataDirty) {
|
|
// Set gauge rotations
|
|
downloadProgress.style = `--progress-rotation: ${
|
|
testState.testData.dlProgress * 180
|
|
}deg`;
|
|
uploadProgress.style = `--progress-rotation: ${
|
|
testState.testData.ulProgress * 180
|
|
}deg`;
|
|
downloadGauge.style = `--speed-rotation: ${mbpsToRotation(
|
|
testState.testData.dlStatus,
|
|
testState.testData.testState === 1
|
|
)}deg`;
|
|
uploadGauge.style = `--speed-rotation: ${mbpsToRotation(
|
|
testState.testData.ulStatus,
|
|
testState.testData.testState === 3
|
|
)}deg`;
|
|
|
|
// Set numeric values
|
|
downloadText.textContent = numberToText(testState.testData.dlStatus);
|
|
uploadText.textContent = numberToText(testState.testData.ulStatus);
|
|
ping.textContent = numberToText(testState.testData.pingStatus);
|
|
jitter.textContent = numberToText(testState.testData.jitterStatus);
|
|
|
|
// Set user's IP and provider
|
|
if (testState.testData.clientIp) {
|
|
// Clear previous content
|
|
privacyWarning.innerHTML = '';
|
|
|
|
const connectedThrough = document.createElement('span');
|
|
connectedThrough.textContent = 'You are connected through:';
|
|
|
|
const ipAddress = document.createTextNode(testState.testData.clientIp);
|
|
|
|
privacyWarning.appendChild(connectedThrough);
|
|
privacyWarning.appendChild(document.createElement('br'));
|
|
privacyWarning.appendChild(ipAddress);
|
|
|
|
privacyWarning.classList.remove("hidden");
|
|
}
|
|
|
|
// Set image for sharing results
|
|
if (testState.testData.testId) {
|
|
resultsImage.src =
|
|
window.location.href.substring(
|
|
0,
|
|
window.location.href.lastIndexOf("/")
|
|
) +
|
|
"/results/?id=" +
|
|
testState.testData.testId;
|
|
}
|
|
|
|
testState.testDataDirty = false;
|
|
}
|
|
|
|
requestAnimationFrame(renderUI);
|
|
}
|
|
|
|
renderUI();
|
|
}
|
|
|
|
/**
|
|
* Convert a speed in Mbits per second to a rotation for the gauge
|
|
* @param {string} speed Speed in Mbits
|
|
* @param {boolean} oscillate If the gauge should wiggle a bit
|
|
* @returns {number} Rotation for the gauge in degrees
|
|
*/
|
|
function mbpsToRotation(speed, oscillate) {
|
|
speed = Number(speed);
|
|
if (speed <= 0) return 0;
|
|
|
|
const minSpeed = 0;
|
|
const maxSpeed = 10000; // 10 Gbps maxes out the gauge
|
|
const minRotation = 0;
|
|
const maxRotation = 180;
|
|
|
|
// Can't do log10 of values less than one, +1 all to keep it fair
|
|
const logMinSpeed = Math.log10(minSpeed + 1);
|
|
const logMaxSpeed = Math.log10(maxSpeed + 1);
|
|
const logSpeed = Math.log10(speed + 1);
|
|
|
|
const power = (logSpeed - logMinSpeed) / (logMaxSpeed - logMinSpeed);
|
|
const oscillation = oscillate ? 1 + 0.01 * Math.sin(Date.now() / 100) : 1;
|
|
const rotation = power * oscillation * maxRotation;
|
|
|
|
// Make sure we stay within bounds at all times
|
|
return Math.max(Math.min(rotation, maxRotation), minRotation);
|
|
}
|
|
|
|
/**
|
|
* Convert a number to a user friendly version
|
|
* @param {string} value Speed, ping or jitter
|
|
* @returns {string} A text version with proper decimals
|
|
*/
|
|
function numberToText(value) {
|
|
if (!value) return "00";
|
|
value = Number(value);
|
|
if (value < 10) return value.toFixed(2);
|
|
if (value < 100) return value.toFixed(1);
|
|
return value.toFixed(0);
|
|
}
|