-{{ if .NoPassword }}
- Please set statistics_password in settings.toml to enable access.
-{{ else if .LoggedIn }}
-
-
-
- {{ range $i, $v := .Data }}
-
-
Test ID
{{ $v.UUID }}
-
Date and time
{{ $v.Timestamp }}
-
IP and ISP Info
{{ $v.IPAddress }} {{ $v.ISPInfo }}
-
User agent and locale
{{ $v.UserAgent }} {{ $v.Language }}
-
Download speed
{{ $v.Download }}
-
Upload speed
{{ $v.Upload }}
-
Ping
{{ $v.Ping }}
-
Jitter
{{ $v.Jitter }}
-
Log
{{ $v.Log }}
-
Extra info
{{ $v.Extra }}
-
+
+ {{ if .NoPassword }}
+
+
Statistics Disabled
+
Please set statistics_password in settings.toml to enable access.
+
+ {{ else if .LoggedIn }}
+
+
+
π Speed Test Admin
+
View and manage test results
+
+ {{ if .Filters.HasAny }}
+ π filtered: {{ .TotalTests }} match{{ if gt .TotalTests 1 }}es{{ end }}
+ β clear filters
+ {{ else }}
+ π {{ .TotalTests }} total test{{ if gt .TotalTests 1 }}s{{ end }}
+ {{ if gt .UniqueDevices 0 }}π₯ {{ .UniqueDevices }} unique device{{ if gt .UniqueDevices 1 }}s{{ end }}{{ end }}
+ {{ len .UniqueIPs }} unique IP{{ if gt (len .UniqueIPs) 1 }}s{{ end }} on this page
+ {{ end }}
+
+
+
+
+
+
+
+ {{ range .DeviceGroups }}
+
+
+ {{ if .ClientID }}
+ π₯ Device
+ {{ .ClientID }}
+ {{ else }}
+ β Unknown device
+ no client identifier
+ {{ end }}
+ {{ .TestCount }} test{{ if gt .TestCount 1 }}s{{ end }} Β· {{ len .IPs }} IP{{ if gt (len .IPs) 1 }}s{{ end }}{{ range .IPs }} Β· {{ . }}{{ end }}
+
+
+
+ {{ range .Tests }}
+
+
+
+
{{ .Timestamp }}
+
{{ .UUID }}
+
+
+
+
+
+
Download
+
{{ .Download }}
+
Mbps
+
+
+
Upload
+
{{ .Upload }}
+
Mbps
+
+
+
Ping
+
{{ .Ping }}
+
ms
+
+
+
Jitter
+
{{ .Jitter }}
+
ms
+
+
+
+
+
IP: {{ .IPAddress }}
+
+
+ {{ end }}
+
+
+ {{ end }}
+
+
+ {{ if gt .CurrentPage 1 }}
+ β Prev
+ {{ else }}
+ β Prev
+ {{ end }}
+
+ Page {{ .CurrentPage }} of {{ .TotalPages }} ({{ .TotalTests }} results)
+
+ {{ if lt .CurrentPage .TotalPages }}
+ Next β
+ {{ else }}
+ Next β
+ {{ end }}
+
+
+
+
+
+`
diff --git a/settings.toml b/settings.toml
index 4ef8a47..3f927bf 100644
--- a/settings.toml
+++ b/settings.toml
@@ -46,3 +46,9 @@ enable_http2=false
# if you use HTTP/2 or TLS, you need to prepare certificates and private keys
# tls_cert_file="cert.pem"
# tls_key_file="privkey.pem"
+
+# redirect_from: if set, a plain-HTTP listener is started on this port and
+# redirects all requests to the main HTTPS listener (301).
+# Useful when running directly on port 443 without a reverse proxy.
+# Example: redirect_from="80"
+# redirect_from=""
diff --git a/web/assets/design-switch.js b/web/assets/design-switch.js
index 4469baa..2028880 100644
--- a/web/assets/design-switch.js
+++ b/web/assets/design-switch.js
@@ -30,8 +30,8 @@
return;
}
- // Default to classic design
- redirectToOldDesign();
+ // Default to modern design
+ redirectToNewDesign();
function redirectToNewDesign() {
const currentParams = window.location.search;
diff --git a/web/assets/index.html b/web/assets/index.html
index c7b3620..7180d3f 100644
--- a/web/assets/index.html
+++ b/web/assets/index.html
@@ -1,16 +1,1567 @@
-
-
+
-
-
-
-
- LibreSpeed
-
+
+
+
+
+
+ LibreSpeed
+
+
+
+
-
Loading...
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
β
+
Download
+
Mbps
+
+
+
β
+
Upload
+
Mbps
+
+
+
β
+
Ping
+
ms
+
+
+
β
+
Jitter
+
ms
+
+
+
β
+
Packet Loss
+
%
+
+
+
+
+
+
+ β
+ Buffer Bloat
+
+
+
+
Latency under load
+
+ Baseline ping
+ β
+ ms
+
+
+
+ Under download
+ β
+ ms
+
+
+
+ Under upload
+ β
+ ms
+
+
+
+
+
+
+
+
+ Test History
+
+
+
+
+
+
+
Time
+
Download
+
Upload
+
Ping
+
Jitter
+
Loss
+
Bloat
+
Share
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/assets/javascript/index.js b/web/assets/javascript/index.js
index ba4eb0e..120fb1c 100644
--- a/web/assets/javascript/index.js
+++ b/web/assets/javascript/index.js
@@ -1,447 +1,427 @@
/**
- * 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
+ * LibreSpeed β modern UI with canvas gauges
*/
-// 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,
+const appState = {
+ ui: INITIALIZING,
speedtest: null,
servers: [],
selectedServerDirty: false,
- testData: null,
- testDataDirty: false,
+ data: null,
+ dataDirty: 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);
+// ββ Gauge geometry ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// 240Β° arc, open at bottom (lower-left β top β lower-right, clockwise)
+const G_START = (5 * Math.PI) / 6; // 150Β° β lower-left
+const G_END = Math.PI / 6; // 30Β° β lower-right
+const G_SWEEP = (4 * Math.PI) / 3; // 240Β°
+
+function valueToAngle(value, maxValue, isLog) {
+ let r = isLog
+ ? Math.log10(Math.max(0.001, value) + 1) / Math.log10(maxValue + 1)
+ : value / maxValue;
+ r = Math.max(0, Math.min(1, r));
+ return G_START + r * G_SWEEP;
}
-/**
- * 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())
- );
- });
+// ββ Tick builders βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+function buildLogTicks() {
+ const ticks = [];
+ // major labeled
+ [1, 10, 100, 1000, 10000].forEach(v => {
+ ticks.push({ v, label: v >= 1000 ? (v / 1000) + 'G' : String(v), major: true });
+ });
+ // minor
+ [2,3,4,5,6,7,8,9,
+ 20,30,40,50,60,70,80,90,
+ 200,300,400,500,600,700,800,900,
+ 2000,3000,4000,5000,6000,7000,8000,9000].forEach(v => {
+ ticks.push({ v, major: false });
+ });
+ return ticks;
}
-/**
- * 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;
- }
+function buildLinearTicks(max, minorVals, majorVals) {
+ const map = new Map();
+ minorVals.forEach(v => map.set(v, { v, major: false }));
+ majorVals.forEach(v => map.set(v, { v, label: String(v), major: true }));
+ return Array.from(map.values());
}
-/**
- * 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);
-}
+// ββ Gauge config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+const GAUGES = {
+ dl: {
+ color: '#22d3ee', glow: 'rgba(34,211,238,0.35)',
+ label: 'DOWNLOAD', unit: 'Mbps',
+ isLog: true, maxValue: 10000,
+ ticks: buildLogTicks(),
+ canvas: null,
+ },
+ ul: {
+ color: '#a78bfa', glow: 'rgba(167,139,250,0.35)',
+ label: 'UPLOAD', unit: 'Mbps',
+ isLog: true, maxValue: 10000,
+ ticks: buildLogTicks(),
+ canvas: null,
+ },
+ ping: {
+ color: '#34d399', glow: 'rgba(52,211,153,0.3)',
+ label: 'PING', unit: 'ms',
+ isLog: false, maxValue: 500,
+ ticks: buildLinearTicks(500,
+ [0, 50, 100, 150, 200, 250, 300, 400, 500],
+ [0, 100, 200, 300, 500]),
+ canvas: null,
+ },
+ jitter: {
+ color: '#fbbf24', glow: 'rgba(251,191,36,0.3)',
+ label: 'JITTER', unit: 'ms',
+ isLog: false, maxValue: 150,
+ ticks: buildLinearTicks(150,
+ [0, 25, 50, 75, 100, 125, 150],
+ [0, 50, 100, 150]),
+ canvas: null,
+ },
+};
-/**
- * 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");
+// ββ Core draw function ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+function drawGauge(cfg, value, progress, active, dimmed) {
+ const canvas = cfg.canvas;
+ if (!canvas) return;
+ const ctx = canvas.getContext('2d');
+ const dpr = window.devicePixelRatio || 1;
+ const W = canvas.clientWidth * dpr;
+ const H = canvas.clientHeight * dpr;
+ if (W === 0 || H === 0) return;
+ if (canvas.width !== W || canvas.height !== H) {
+ canvas.width = W; canvas.height = H;
+ }
+ ctx.clearRect(0, 0, W, H);
+
+ // geometry
+ const cx = W / 2;
+ const R = Math.min(W * 0.36, H * 0.55);
+ const cy = R + H * 0.08; // arc center β top portion
+ const tw = R * 0.09; // track width
+
+ const alpha = dimmed ? 0.35 : 1;
+
+ // ββ background track
+ ctx.save();
+ ctx.globalAlpha = alpha;
+ ctx.beginPath();
+ ctx.arc(cx, cy, R, G_START, G_END, false);
+ ctx.strokeStyle = 'rgba(255,255,255,0.07)';
+ ctx.lineWidth = tw;
+ ctx.lineCap = 'round';
+ ctx.stroke();
+
+ // ββ tick marks
+ cfg.ticks.forEach(({ v, label, major }) => {
+ const a = valueToAngle(v, cfg.maxValue, cfg.isLog);
+ const cos = Math.cos(a), sin = Math.sin(a);
+ const outerR = R + tw * 0.15;
+ const innerR = major ? R - tw * 0.9 : R - tw * 0.45;
+
+ ctx.beginPath();
+ ctx.moveTo(cx + outerR * cos, cy + outerR * sin);
+ ctx.lineTo(cx + innerR * cos, cy + innerR * sin);
+ ctx.strokeStyle = major ? 'rgba(255,255,255,0.35)' : 'rgba(255,255,255,0.12)';
+ ctx.lineWidth = major ? 1.5 * dpr : 0.8 * dpr;
+ ctx.lineCap = 'butt';
+ ctx.stroke();
+
+ if (label && major) {
+ const lr = R - tw * 1.75;
+ ctx.font = `${Math.round(9.5 * dpr)}px Inter,sans-serif`;
+ ctx.fillStyle = 'rgba(255,255,255,0.4)';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(label, cx + lr * cos, cy + lr * sin);
}
- 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");
- }
+ });
+ ctx.restore();
+
+ // ββ value arc + glow
+ if (value > 0) {
+ const va = valueToAngle(value, cfg.maxValue, cfg.isLog);
+
+ if (active) {
+ ctx.save();
+ ctx.globalAlpha = 0.45;
+ ctx.beginPath();
+ ctx.arc(cx, cy, R, G_START, va, false);
+ ctx.strokeStyle = cfg.color;
+ ctx.lineWidth = tw * 2.8;
+ ctx.lineCap = 'round';
+ ctx.filter = `blur(${tw * 0.8}px)`;
+ ctx.stroke();
+ ctx.restore();
}
- } 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");
+ ctx.save();
+ ctx.globalAlpha = alpha;
+ ctx.beginPath();
+ ctx.arc(cx, cy, R, G_START, va, false);
+ ctx.strokeStyle = cfg.color;
+ ctx.lineWidth = tw;
+ ctx.lineCap = 'round';
+ if (active) {
+ ctx.shadowColor = cfg.color;
+ ctx.shadowBlur = 10 * dpr;
}
+ ctx.stroke();
+ ctx.restore();
+
+ // tip dot
+ ctx.save();
+ ctx.globalAlpha = alpha;
+ ctx.beginPath();
+ ctx.arc(cx + R * Math.cos(va), cy + R * Math.sin(va), tw * 0.65, 0, 2 * Math.PI);
+ ctx.fillStyle = '#fff';
+ ctx.shadowColor = cfg.color;
+ ctx.shadowBlur = active ? 14 * dpr : 6 * dpr;
+ ctx.fill();
+ ctx.restore();
+ }
- testState.servers = servers;
+ // ββ progress ring (thin outer arc)
+ if (progress > 0 && progress < 1) {
+ const pa = G_START + progress * G_SWEEP;
+ ctx.save();
+ ctx.globalAlpha = 0.5;
+ ctx.beginPath();
+ ctx.arc(cx, cy, R + tw * 1.05, G_START, pa, false);
+ ctx.strokeStyle = cfg.color;
+ ctx.lineWidth = 2 * dpr;
+ ctx.lineCap = 'round';
+ ctx.stroke();
+ ctx.restore();
+ }
- // If there's only one server, just show it. No reachability checks needed.
- if (servers.length === 1) {
- populateDropdown(servers);
- return;
- }
+ // ββ value text (inside bowl, below arc center)
+ const textAlpha = dimmed ? 0.25 : (value > 0 ? 1 : 0.2);
+ const displayVal = value <= 0 ? 'β' : numberToText(value);
+
+ // main number
+ const numSz = Math.round(R * 0.38);
+ ctx.save();
+ ctx.globalAlpha = textAlpha;
+ ctx.font = `200 ${numSz}px Inter,sans-serif`;
+ ctx.fillStyle = value > 0 ? '#fff' : 'rgba(255,255,255,0.3)';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'alphabetic';
+ const numY = cy + R * 0.28;
+ ctx.fillText(displayVal, cx, numY);
+ ctx.restore();
+
+ // unit
+ const unitSz = Math.round(R * 0.13);
+ ctx.save();
+ ctx.globalAlpha = dimmed ? 0.2 : 0.75;
+ ctx.font = `500 ${unitSz}px Inter,sans-serif`;
+ ctx.fillStyle = cfg.color;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'top';
+ ctx.fillText(cfg.unit, cx, numY + unitSz * 0.3);
+ ctx.restore();
+
+ // label
+ const lblSz = Math.round(R * 0.105);
+ ctx.save();
+ ctx.globalAlpha = dimmed ? 0.2 : 0.45;
+ ctx.font = `700 ${lblSz}px Inter,sans-serif`;
+ ctx.fillStyle = '#fff';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'top';
+ ctx.fillText(cfg.label, cx, numY + unitSz * 0.3 + unitSz * 1.5);
+ ctx.restore();
+}
- // 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);
+// ββ Number formatter ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+function numberToText(v) {
+ v = Number(v);
+ if (!v || isNaN(v)) return '0.00';
+ if (v < 10) return v.toFixed(2);
+ if (v < 100) return v.toFixed(1);
+ return v.toFixed(0);
+}
+// ββ Bootstrap βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+window.addEventListener('DOMContentLoaded', () => {
+ GAUGES.dl.canvas = document.getElementById('dl-gauge');
+ GAUGES.ul.canvas = document.getElementById('ul-gauge');
+ GAUGES.ping.canvas = document.getElementById('ping-gauge');
+ GAUGES.jitter.canvas = document.getElementById('jitter-gauge');
- 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);
- }
+ // draw initial empty state
+ Object.values(GAUGES).forEach(g => drawGauge(g, 0, 0, false, false));
+
+ createSpeedtest();
+ hookUpButtons();
+ startRenderLoop();
+ applySettingsJSON();
+ applyServerListJSON();
+});
+
+function createSpeedtest() {
+ appState.speedtest = new Speedtest();
+ appState.speedtest.onupdate = data => {
+ appState.data = data;
+ appState.dataDirty = true;
+ };
+ appState.speedtest.onend = aborted => {
+ appState.ui = aborted ? READY : FINISHED;
+ };
}
-/**
- * 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");
+function hookUpButtons() {
+ document.getElementById('start-button').addEventListener('click', () => {
+ if (appState.ui === READY || appState.ui === FINISHED) {
+ document.getElementById('results-panel').classList.add('hidden');
+ document.getElementById('share-results').classList.add('hidden');
+ appState.speedtest.start();
+ appState.ui = RUNNING;
+ } else if (appState.ui === RUNNING) {
+ appState.speedtest.abort();
+ }
+ });
- // Make the dropdown open and close (hook only once)
- if (serverSelector.dataset.hooked !== "1") {
- serverSelector.dataset.hooked = "1";
+ document.getElementById('choose-privacy')
+ ?.addEventListener('click', () => document.getElementById('privacy').showModal());
- serverSelector.addEventListener("click", () => {
- serverList.classList.toggle("active");
- });
- document.addEventListener("click", (e) => {
- if (e.target.closest("div.server-selector") !== serverSelector)
- serverList.classList.remove("active");
+ document.getElementById('share-results')
+ ?.addEventListener('click', () => document.getElementById('share').showModal());
+
+ document.getElementById('copy-link')
+ ?.addEventListener('click', async () => {
+ const link = document.querySelector('img#results')?.src;
+ if (!link) return;
+ await navigator.clipboard.writeText(link);
+ const btn = document.getElementById('copy-link');
+ btn.textContent = 'Copied!';
+ setTimeout(() => btn.textContent = 'Copy link', 3000);
});
- }
- // 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 ? ` (${server.sponsorName})` : ""
- }`;
- link.addEventListener("click", () => selectServer(server));
- item.appendChild(link);
- serverList.appendChild(item);
- });
+ document.querySelectorAll('.close-dialog, #close-privacy').forEach(el =>
+ el.addEventListener('click', () =>
+ document.querySelectorAll('dialog').forEach(d => d.close())
+ )
+ );
}
-/**
- * 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;
+async function applySettingsJSON() {
+ try {
+ const res = await fetch('settings.json');
+ const cfg = await res.json();
+ for (const k in cfg) {
+ appState.speedtest.setParameter(k, cfg[k]);
+ if (k === 'telemetry_level' && cfg[k] && !['off','disabled','false'].includes(String(cfg[k]))) {
+ appState.telemetryEnabled = true;
+ document.getElementById('privacy-warning')?.classList.remove('hidden');
+ }
+ }
+ } catch (_) {}
}
-/**
- * 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",
+async function applyServerListJSON() {
+ try {
+ const src = typeof globalThis.SPEEDTEST_SERVERS !== 'undefined'
+ ? globalThis.SPEEDTEST_SERVERS
+ : 'server-list.json';
+ const servers = Array.isArray(src)
+ ? src
+ : await fetch(src).then(r => r.json());
+
+ if (!servers?.length) return console.error('Server list empty');
+ const server = servers[0];
+ appState.speedtest.setSelectedServer(server);
+ appState.selectedServerDirty = true;
+ appState.ui = READY;
+ } catch (e) {
+ console.error('Failed to load server list', e);
+ }
+}
+
+// ββ Render loop βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+function startRenderLoop() {
+ const startBtn = document.getElementById('start-button');
+ const selectedEl = document.getElementById('selected-server');
+ const ipEl = document.getElementById('ip-display');
+ const resultsPanel = document.getElementById('results-panel');
+ const shareBtn = document.getElementById('share-results');
+ const resultsImg = document.getElementById('results');
+
+ const btnLabel = {
+ [INITIALIZING]: 'Loadingβ¦',
+ [READY]: 'Start Test',
+ [RUNNING]: 'Abort',
+ [FINISHED]: 'Test Again',
};
- // 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: ${server.sponsorName}`;
- } else {
- sponsor.textContent = `Sponsor: ${server.sponsorName}`;
- }
- } else {
- sponsor.innerHTML = " ";
- }
- testState.selectedServerDirty = false;
+ function render() {
+ startBtn.textContent = btnLabel[appState.ui];
+ startBtn.classList.toggle('disabled', appState.ui === INITIALIZING);
+ startBtn.classList.toggle('active', appState.ui === RUNNING);
+
+ if (appState.selectedServerDirty) {
+ try {
+ selectedEl.textContent = appState.speedtest.getSelectedServer().name;
+ } catch (_) {}
+ appState.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");
+ if (appState.dataDirty && appState.data) {
+ const d = appState.data;
+ const ts = d.testState; // 1=dl 2=ping 3=ul
+ const running = appState.ui === RUNNING;
+ const done = appState.ui === FINISHED;
+ const osc = (running && ts === 1) ? 1 + 0.015 * Math.sin(Date.now() / 120) : 1;
+ const oscU = (running && ts === 3) ? 1 + 0.015 * Math.sin(Date.now() / 120) : 1;
+
+ const dlVal = (parseFloat(d.dlStatus) || 0) * osc;
+ const ulVal = (parseFloat(d.ulStatus) || 0) * oscU;
+ const pingVal = parseFloat(d.pingStatus) || 0;
+ const jitterVal = parseFloat(d.jitterStatus) || 0;
+
+ drawGauge(GAUGES.dl, dlVal, parseFloat(d.dlProgress) || 0, ts === 1, running && ts !== 1 && !done);
+ drawGauge(GAUGES.ul, ulVal, parseFloat(d.ulProgress) || 0, ts === 3, running && ts !== 3 && !done);
+ drawGauge(GAUGES.ping, pingVal, parseFloat(d.pingProgress) || 0, ts === 2, running && ts !== 2 && !done);
+ drawGauge(GAUGES.jitter, jitterVal, 0, ts === 2, running && ts !== 2 && !done);
+
+ // IP info
+ if (d.clientIp) {
+ ipEl.innerHTML = `Connected via ${d.clientIp}`;
}
- // 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;
+ // results panel
+ if (done) {
+ document.getElementById('result-dl').textContent = numberToText(d.dlStatus);
+ document.getElementById('result-ul').textContent = numberToText(d.ulStatus);
+ document.getElementById('result-ping').textContent = numberToText(d.pingStatus);
+ document.getElementById('result-jitter').textContent = numberToText(d.jitterStatus);
+ resultsPanel.classList.remove('hidden');
+
+ if (appState.telemetryEnabled && d.testId) {
+ shareBtn?.classList.remove('hidden');
+ if (resultsImg) {
+ resultsImg.src = window.location.href.replace(/[^/]*$/, '') + 'results/?id=' + d.testId;
+ }
+ }
}
- testState.testDataDirty = false;
+ appState.dataDirty = false;
}
- requestAnimationFrame(renderUI);
+ requestAnimationFrame(render);
}
- 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);
+ render();
}
diff --git a/web/assets/speedtest_worker.js b/web/assets/speedtest_worker.js
index 8626b7a..889e37d 100755
--- a/web/assets/speedtest_worker.js
+++ b/web/assets/speedtest_worker.js
@@ -17,6 +17,13 @@ let ulProgress = 0; //progress of upload test 0-1
let pingProgress = 0; //progress of ping+jitter test 0-1
let testId = null; //test ID (sent back by telemetry if used, null otherwise)
+// Chart data for real-time graph
+let dlChartData = []; // array of {t, v} for download
+let ulChartData = []; // array of {t, v} for upload
+let pingDuringTest = { dl: [], ul: [] }; // ping measurements during DL/UL tests
+let latencyUnderload = ""; // ping during load (ms)
+let basePing = 0; // baseline ping before load tests
+
let log = ""; //telemetry log
function tlog(s) {
if (settings.telemetry_level >= 2) {
@@ -39,11 +46,11 @@ function twarn(s) {
let settings = {
mpot: false, //set to true when in MPOT mode
test_order: "IP_D_U", //order in which tests will be performed as a string. D=Download, U=Upload, P=Ping+Jitter, I=IP, _=1 second delay
- time_ul_max: 15, // max duration of upload test in seconds
- time_dl_max: 15, // max duration of download test in seconds
+ time_ul_max: 20, // max duration of upload test in seconds
+ time_dl_max: 20, // max duration of download test in seconds
time_auto: true, // if set to true, tests will take less time on faster connections
- time_ulGraceTime: 3, //time to wait in seconds before actually measuring ul speed (wait for buffers to fill)
- time_dlGraceTime: 1.5, //time to wait in seconds before actually measuring dl speed (wait for TCP window to increase)
+ time_ulGraceTime: 2, //time to wait in seconds before actually measuring ul speed (wait for buffers to fill)
+ time_dlGraceTime: 2, //time to wait in seconds before actually measuring dl speed (wait for TCP window to increase)
count_ping: 10, // number of pings to perform in ping test
url_dl: "backend/garbage.php", // path to a large file or garbage.php, used for download test. must be relative to this js file
url_ul: "backend/empty.php", // path to an empty file, used for upload test. must be relative to this js file
@@ -52,7 +59,7 @@ let settings = {
getIp_ispInfo: true, //if set to true, the server will include ISP info with the IP address
getIp_ispInfo_distance: "km", //km or mi=estimate distance from server in km/mi; set to false to disable distance estimation. getIp_ispInfo must be enabled in order for this to work
xhr_dlMultistream: 6, // number of download streams to use (can be different if enable_quirks is active)
- xhr_ulMultistream: 3, // number of upload streams to use (can be different if enable_quirks is active)
+ xhr_ulMultistream: 6, // number of upload streams to use (can be different if enable_quirks is active)
xhr_multistreamDelay: 300, //how much concurrent requests should be delayed
xhr_ignoreErrors: 1, // 0=fail on errors, 1=attempt to restart a stream if it fails, 2=ignore all errors
xhr_dlUseBlob: false, // if set to true, it reduces ram usage but uses the hard drive (useful with large garbagePhp_chunkSize and/or high xhr_dlMultistream)
@@ -65,7 +72,8 @@ let settings = {
telemetry_level: 0, // 0=disabled, 1=basic (results only), 2=full (results and timing) 3=debug (results+log)
url_telemetry: "results/telemetry.php", // path to the script that adds telemetry data to the database
telemetry_extra: "", //extra data that can be passed to the telemetry through the settings
- forceIE11Workaround: false //when set to true, it will force the IE11 upload test on all browsers. Debug only
+ forceIE11Workaround: false, //when set to true, it will force the IE11 upload test on all browsers. Debug only
+ client_id: "" // stable browser-generated device identifier (clientId:fingerprint)
};
let xhr = null; // array of currently active xhr requests
@@ -102,7 +110,11 @@ this.addEventListener("message", function(e) {
dlProgress: dlProgress,
ulProgress: ulProgress,
pingProgress: pingProgress,
- testId: testId
+ testId: testId,
+ dlChartData: dlChartData,
+ ulChartData: ulChartData,
+ pingDuringTest: pingDuringTest,
+ latencyUnderload: latencyUnderload
})
);
}
@@ -180,6 +192,15 @@ this.addEventListener("message", function(e) {
if (testState == 5) return;
if (test_pointer >= settings.test_order.length) {
//test is finished
+ // Calculate latency underload
+ if (pingDuringTest.dl.length > 0 || pingDuringTest.ul.length > 0) {
+ const allPings = [...pingDuringTest.dl, ...pingDuringTest.ul].filter(p => p !== null && !isNaN(p));
+ if (allPings.length > 0) {
+ const avgPingUnderload = allPings.reduce((a, b) => a + b, 0) / allPings.length;
+ const pingDiff = avgPingUnderload - basePing;
+ latencyUnderload = pingDiff > 0 ? pingDiff.toFixed(2) : "0";
+ }
+ }
if (settings.telemetry_level > 0)
sendTelemetry(function(id) {
testState = 4;
@@ -409,6 +430,11 @@ function dlTest(done) {
}
//update status
dlStatus = ((speed * 8 * settings.overheadCompensationFactor) / (settings.useMebibits ? 1048576 : 1000000)).toFixed(2); // speed is multiplied by 8 to go from bytes to bits, overhead compensation is applied, then everything is divided by 1048576 or 1000000 to go to megabits/mebibits
+ // Capture chart data point
+ const chartTime = (t - 1000 * settings.time_dlGraceTime) / 1000;
+ if (chartTime >= 0 && dlStatus !== "Fail") {
+ dlChartData.push({ t: chartTime, v: parseFloat(dlStatus) });
+ }
if ((t + bonusT) / 1000.0 > settings.time_dl_max || failed) {
// test is over, stop streams and timer
if (failed || isNaN(dlStatus)) dlStatus = "Fail";
@@ -416,7 +442,13 @@ function dlTest(done) {
clearInterval(interval);
dlProgress = 1;
tlog("dlTest: " + dlStatus + ", took " + (new Date().getTime() - startT) + "ms");
- done();
+ // Measure ping after download for latency underload
+ measurePing(function(pingTime) {
+ if (pingTime !== null) {
+ pingDuringTest.dl.push(pingTime);
+ }
+ done();
+ });
}
}
}.bind(this),
@@ -557,6 +589,11 @@ function ulTest(done) {
}
//update status
ulStatus = ((speed * 8 * settings.overheadCompensationFactor) / (settings.useMebibits ? 1048576 : 1000000)).toFixed(2); // speed is multiplied by 8 to go from bytes to bits, overhead compensation is applied, then everything is divided by 1048576 or 1000000 to go to megabits/mebibits
+ // Capture chart data point
+ const chartTime = (t - 1000 * settings.time_ulGraceTime) / 1000;
+ if (chartTime >= 0 && ulStatus !== "Fail") {
+ ulChartData.push({ t: chartTime, v: parseFloat(ulStatus) });
+ }
if ((t + bonusT) / 1000.0 > settings.time_ul_max || failed) {
// test is over, stop streams and timer
if (failed || isNaN(ulStatus)) ulStatus = "Fail";
@@ -564,7 +601,13 @@ function ulTest(done) {
clearInterval(interval);
ulProgress = 1;
tlog("ulTest: " + ulStatus + ", took " + (new Date().getTime() - startT) + "ms");
- done();
+ // Measure ping after upload for latency underload
+ measurePing(function(pingTime) {
+ if (pingTime !== null) {
+ pingDuringTest.ul.push(pingTime);
+ }
+ done();
+ });
}
}
}.bind(this),
@@ -667,6 +710,7 @@ function pingTest(done) {
else {
// more pings to do?
pingProgress = 1;
+ basePing = ping;
tlog("ping: " + pingStatus + " jitter: " + jitterStatus + ", took " + (new Date().getTime() - startT) + "ms");
done();
}
@@ -678,7 +722,37 @@ function pingTest(done) {
}.bind(this);
doPing(); // start first ping
}
+// Simple ping measurement for latency underload
+function measurePing(callback) {
+ const startT = Date.now();
+ const xhr = new XMLHttpRequest();
+ xhr.onload = function() {
+ const pingTime = Date.now() - startT;
+ callback(pingTime);
+ };
+ xhr.onerror = function() {
+ callback(null);
+ };
+ xhr.open("GET", settings.url_ping + url_sep(settings.url_ping) + (settings.mpot ? "cors=true&" : "") + "r=" + Math.random(), true);
+ xhr.send();
+}
// telemetry
+function computeGrade(dl, ul, ping, jitter, latencyUnderload) {
+ const dlMbps = parseFloat(dl) || 0;
+ const ulMbps = parseFloat(ul) || 0;
+ const pingMs = parseFloat(ping) || 999;
+ const jitterMs = parseFloat(jitter) || 999;
+ const latMs = parseFloat(latencyUnderload) || 0;
+ let score = 100;
+ if (dlMbps < 5) score -= 30; else if (dlMbps < 25) score -= 15; else if (dlMbps < 100) score -= 5;
+ if (ulMbps < 2) score -= 20; else if (ulMbps < 10) score -= 10; else if (ulMbps < 50) score -= 5;
+ if (pingMs > 150) score -= 20; else if (pingMs > 80) score -= 10; else if (pingMs > 40) score -= 5;
+ if (jitterMs > 50) score -= 15; else if (jitterMs > 20) score -= 8; else if (jitterMs > 10) score -= 3;
+ if (latMs > 100) score -= 10; else if (latMs > 50) score -= 5;
+ let grade = score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 60 ? 'C' : score >= 45 ? 'D' : score >= 30 ? 'E' : 'F';
+ return JSON.stringify({ grade, criteria: { dl: dlMbps, ul: ulMbps, ping: pingMs, jitter: jitterMs, latencyUnderload: latMs } });
+}
+
function sendTelemetry(done) {
if (settings.telemetry_level < 1) return;
xhr = new XMLHttpRequest();
@@ -715,9 +789,27 @@ function sendTelemetry(done) {
fd.append("jitter", jitterStatus);
fd.append("log", settings.telemetry_level > 1 ? log : "");
fd.append("extra", settings.telemetry_extra);
+ fd.append("client_id", settings.client_id);
+ const gradeData = computeGrade(dlStatus, ulStatus, pingStatus, jitterStatus, latencyUnderload);
+ fd.append("grade_data", gradeData);
+ fd.append("chart_data", JSON.stringify({ dl: dlChartData, ul: ulChartData }));
+ fd.append("latency_underload", latencyUnderload);
+ fd.append("ping_during_test", JSON.stringify(pingDuringTest));
xhr.send(fd);
} catch (ex) {
- const postData = "extra=" + encodeURIComponent(settings.telemetry_extra) + "&ispinfo=" + encodeURIComponent(JSON.stringify(telemetryIspInfo)) + "&dl=" + encodeURIComponent(dlStatus) + "&ul=" + encodeURIComponent(ulStatus) + "&ping=" + encodeURIComponent(pingStatus) + "&jitter=" + encodeURIComponent(jitterStatus) + "&log=" + encodeURIComponent(settings.telemetry_level > 1 ? log : "");
+ const gradeData = computeGrade(dlStatus, ulStatus, pingStatus, jitterStatus, latencyUnderload);
+ const postData = "extra=" + encodeURIComponent(settings.telemetry_extra)
+ + "&ispinfo=" + encodeURIComponent(JSON.stringify(telemetryIspInfo))
+ + "&dl=" + encodeURIComponent(dlStatus)
+ + "&ul=" + encodeURIComponent(ulStatus)
+ + "&ping=" + encodeURIComponent(pingStatus)
+ + "&jitter=" + encodeURIComponent(jitterStatus)
+ + "&log=" + encodeURIComponent(settings.telemetry_level > 1 ? log : "")
+ + "&client_id=" + encodeURIComponent(settings.client_id)
+ + "&grade_data=" + encodeURIComponent(gradeData)
+ + "&chart_data=" + encodeURIComponent(JSON.stringify({ dl: dlChartData, ul: ulChartData }))
+ + "&latency_underload=" + encodeURIComponent(latencyUnderload)
+ + "&ping_during_test=" + encodeURIComponent(JSON.stringify(pingDuringTest));
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
xhr.send(postData);
}
diff --git a/web/web.go b/web/web.go
index 9fcc21f..588d98d 100644
--- a/web/web.go
+++ b/web/web.go
@@ -3,6 +3,7 @@ package web
import (
"embed"
"encoding/json"
+ "errors"
"io"
"io/fs"
"io/ioutil"
@@ -11,6 +12,8 @@ import (
"os"
"regexp"
"strconv"
+ "strings"
+ "syscall"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
@@ -70,6 +73,7 @@ func ListenAndServe(conf *config.Config) error {
r.Get(conf.BaseURL+"/backend/garbage", garbage)
r.Get(conf.BaseURL+"/getIP", getIP)
r.Get(conf.BaseURL+"/backend/getIP", getIP)
+ r.Get(conf.BaseURL+"/results/view", results.ViewPage)
r.Get(conf.BaseURL+"/results", results.DrawPNG)
r.Get(conf.BaseURL+"/results/", results.DrawPNG)
r.Get(conf.BaseURL+"/backend/results", results.DrawPNG)
@@ -96,10 +100,42 @@ func ListenAndServe(conf *config.Config) error {
r.Get(conf.BaseURL+"/backend/results/json.php", results.JSONResult)
go listenProxyProtocol(conf, r)
+ go listenRedirect(conf)
return startListener(conf, r)
}
+func listenRedirect(conf *config.Config) {
+ if conf.RedirectPort == "" || conf.RedirectPort == "0" {
+ return
+ }
+ scheme := "http"
+ if conf.EnableTLS {
+ scheme = "https"
+ }
+ addr := net.JoinHostPort(conf.BindAddress, conf.RedirectPort)
+ log.Infof("Starting HTTPβ%s redirect listener on %s", strings.ToUpper(scheme), addr)
+ targetPort := conf.Port
+ standardPort := map[string]string{"http": "80", "https": "443"}
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ host := r.Host
+ // Strip any port from the incoming Host header
+ if h, _, err := net.SplitHostPort(host); err == nil {
+ host = h
+ }
+ // Only append port when it's non-standard for the target scheme
+ if targetPort != "" && targetPort != standardPort[scheme] {
+ host = net.JoinHostPort(host, targetPort)
+ }
+ url := scheme + "://" + host + r.RequestURI
+ http.Redirect(w, r, url, http.StatusMovedPermanently)
+ })
+
+ if err := http.ListenAndServe(addr, handler); err != nil {
+ log.Errorf("HTTP redirect listener error: %s", err)
+ }
+}
+
func listenProxyProtocol(conf *config.Config, r *chi.Mux) {
if conf.ProxyProtocolPort != "0" {
addr := net.JoinHostPort(conf.BindAddress, conf.ProxyProtocolPort)
@@ -186,12 +222,35 @@ func garbage(w http.ResponseWriter, r *http.Request) {
for i := 0; i < chunks; i++ {
if _, err := w.Write(randomData); err != nil {
- log.Errorf("Error writing back to client at chunk number %d: %s", i, err)
+ // Client disconnects are expected during a speed test: the browser
+ // aborts its download streams when the timed test ends. Don't spam
+ // the log for those β only surface genuinely unexpected errors.
+ if !isClientGone(err) {
+ log.Errorf("Error writing back to client at chunk number %d: %s", i, err)
+ }
break
}
}
}
+// isClientGone reports whether err is a normal client-side disconnect (the peer
+// closed the connection / aborted the HTTP2 stream), which happens routinely
+// when a speed test finishes and is not a server error.
+func isClientGone(err error) bool {
+ if err == nil {
+ return false
+ }
+ if errors.Is(err, syscall.EPIPE) || errors.Is(err, syscall.ECONNRESET) {
+ return true
+ }
+ msg := strings.ToLower(err.Error())
+ return strings.Contains(msg, "stream closed") ||
+ strings.Contains(msg, "broken pipe") ||
+ strings.Contains(msg, "connection reset by peer") ||
+ strings.Contains(msg, "client disconnected") ||
+ strings.Contains(msg, "context canceled")
+}
+
func getIP(w http.ResponseWriter, r *http.Request) {
var ret results.Result
@@ -208,7 +267,9 @@ func getIP(w http.ResponseWriter, r *http.Request) {
ret.ProcessedString = clientIP + " - " + desc
b, _ := json.Marshal(&ret)
if _, err := w.Write(b); err != nil {
- log.Errorf("Error writing to client: %s", err)
+ if !isClientGone(err) {
+ log.Errorf("Error writing to client: %s", err)
+ }
}
return
}