Replace OpenSpeedTest with custom speed test widget

Download uses fetch() with streaming progress on 100MB test file.
Upload sends 25MB to /api/upload on mtr-backend which reads the full
body before responding, giving accurate client-side timing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ivan Razin
2026-06-24 11:34:05 +03:00
co-authored by Claude Sonnet 4.6
parent 6bac6aa84c
commit 222d748f4e
3 changed files with 129 additions and 37 deletions
+99 -15
View File
@@ -54,14 +54,24 @@
border-radius: 2px;
}
/* Speed test embed */
#speedtest-frame {
width: 100%;
height: 520px;
border: none;
border-radius: var(--radius);
background: #000;
/* Speed test */
.speed-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 20px;
}
.speed-card {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px 16px;
text-align: center;
}
.speed-card-label { font-size: 0.85rem; color: var(--text-dim); margin-bottom: 10px; }
.speed-card-value { font-size: 2.8rem; font-weight: 700; color: #fff; line-height: 1; font-variant-numeric: tabular-nums; }
.speed-card-unit { font-size: 0.8rem; color: var(--text-dim); margin-top: 4px; }
.speed-card-note { font-size: 0.75rem; color: var(--text-dim); margin-top: 8px; min-height: 1em; }
/* MTR form */
.mtr-form { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; }
@@ -195,15 +205,26 @@
<!-- ── Speed Test ──────────────────────────────────────────────────── -->
<div class="section">
<div class="section-title">Speed Test</div>
<p style="font-size:0.85rem;color:var(--text-dim);margin-bottom:12px;">
Runs directly between your browser and this server. Served locally — no external connections.
<p style="font-size:0.85rem;color:var(--text-dim);margin-bottom:20px;">
Runs directly between your browser and this server — no external connections.
Download: 100 MB test file. Upload: 25 MB payload.
</p>
<iframe id="speedtest-frame"
src="__DIAG_PATH__speedtest/"
allow="fullscreen"
loading="lazy"
title="OpenSpeedTest">
</iframe>
<div class="speed-grid">
<div class="speed-card">
<div class="speed-card-label">↓ Download</div>
<div class="speed-card-value" id="dl-speed"></div>
<div class="speed-card-unit">Mbps</div>
<div class="speed-card-note" id="dl-note"></div>
</div>
<div class="speed-card">
<div class="speed-card-label">↑ Upload</div>
<div class="speed-card-value" id="ul-speed"></div>
<div class="speed-card-unit">Mbps</div>
<div class="speed-card-note" id="ul-note"></div>
</div>
</div>
<button class="btn" id="speed-btn" onclick="runSpeedTest()">Start Speed Test</button>
<div id="speed-status" style="margin-top:14px;font-size:0.85rem;min-height:1.4em;"></div>
</div>
<!-- ── MTR Test ───────────────────────────────────────────────────── -->
@@ -359,6 +380,69 @@ async function runMtr() {
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── Speed Test ──────────────────────────────────────────────────────────────
async function runSpeedTest() {
const btn = document.getElementById('speed-btn');
const status = document.getElementById('speed-status');
btn.disabled = true;
['dl-speed','ul-speed'].forEach(id => { document.getElementById(id).textContent = '—'; });
['dl-note','ul-note'].forEach(id => { document.getElementById(id).textContent = ''; });
// Download
try {
status.innerHTML = '<span class="status-tag tag-info">Testing download…</span>';
const mbps = await measureDownload();
document.getElementById('dl-speed').textContent = mbps.toFixed(1);
document.getElementById('dl-note').textContent = '100 MB file';
} catch(e) {
document.getElementById('dl-speed').textContent = 'ERR';
}
// Upload
try {
status.innerHTML = '<span class="status-tag tag-info">Testing upload…</span>';
const mbps = await measureUpload();
document.getElementById('ul-speed').textContent = mbps.toFixed(1);
document.getElementById('ul-note').textContent = '25 MB payload';
} catch(e) {
document.getElementById('ul-speed').textContent = 'ERR';
}
status.innerHTML = '<span class="status-tag tag-ok">Done</span>';
btn.disabled = false;
}
async function measureDownload() {
const url = '__DIAG_PATH__testfiles/test-100m.bin?t=' + Date.now();
const start = performance.now();
const resp = await fetch(url, { cache: 'no-store' });
const reader = resp.body.getReader();
let bytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.length;
const secs = (performance.now() - start) / 1000;
if (secs > 0.3)
document.getElementById('dl-speed').textContent = ((bytes * 8) / secs / 1e6).toFixed(1);
}
return (bytes * 8) / ((performance.now() - start) / 1000) / 1e6;
}
async function measureUpload() {
const size = 25 * 1024 * 1024;
const data = new Uint8Array(size);
const start = performance.now();
const resp = await fetch('__DIAG_PATH__api/upload', {
method: 'POST',
body: data,
headers: { 'Content-Type': 'application/octet-stream' }
});
await resp.json();
return (size * 8) / ((performance.now() - start) / 1000) / 1e6;
}
</script>
</body>
</html>
+16
View File
@@ -187,6 +187,22 @@ class Handler(BaseHTTPRequestHandler):
def do_POST(self):
parsed = urlparse(self.path)
# ── Upload speed test receiver ─────────────────────────────────────
# Reads the entire body before responding so the client timer is accurate
if parsed.path == "/api/upload" or parsed.path.endswith("/api/upload"):
content_length = int(self.headers.get("Content-Length", "0"))
to_read = min(content_length, 128 * 1024 * 1024) # cap at 128 MB
received = 0
buf = 65536
while received < to_read:
chunk = self.rfile.read(min(buf, to_read - received))
if not chunk:
break
received += len(chunk)
self._send_json(200, {"received": received, "ok": True})
return
if not (parsed.path in ("/mtr", "/api/mtr") or parsed.path.endswith("/api/mtr")):
self._send_json(404, {"error": "not found"})
return