test-net/index.html

1114 lines
35 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Тест скорости сети</title>
<style>
:root {
color-scheme: dark;
--accent: #38bdf8;
--accent-strong: #0ea5e9;
--accent-soft: rgba(56, 189, 248, 0.18);
--bg: #08111f;
--surface: rgba(15, 23, 42, 0.82);
--surface-strong: #111c31;
--text: #e2e8f0;
--muted: #94a3b8;
--faint: #64748b;
--border: rgba(148, 163, 184, 0.22);
--shadow: 0 20px 60px rgba(0, 0, 0, 0.36);
--upload: #a78bfa;
--warning: #f59e0b;
}
[data-theme="light"] {
color-scheme: light;
--bg: #eef5ff;
--surface: rgba(255, 255, 255, 0.88);
--surface-strong: #ffffff;
--text: #0f172a;
--muted: #475569;
--faint: #64748b;
--border: rgba(15, 23, 42, 0.12);
--shadow: 0 20px 60px rgba(15, 23, 42, 0.12);
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
background:
radial-gradient(circle at top left, var(--accent-soft), transparent 34rem),
var(--bg);
color: var(--text);
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
}
button,
input,
select {
font: inherit;
}
.page {
width: min(1180px, calc(100% - 2rem));
margin: 0 auto;
padding: 2rem 0 3rem;
}
.hero,
.panel,
.metric-card,
.server-card {
border: 1px solid var(--border);
background: var(--surface);
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
}
.hero {
display: grid;
grid-template-columns: 1fr auto;
gap: 1.25rem;
align-items: start;
border-radius: 2rem;
padding: 1.5rem;
}
h1,
h2,
p {
margin: 0;
}
h1 {
font-size: clamp(2rem, 5vw, 4.25rem);
line-height: 0.95;
letter-spacing: -0.06em;
}
h2 {
color: var(--muted);
font-size: 0.95rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.lead {
max-width: 720px;
margin-top: 0.9rem;
color: var(--muted);
font-size: 1.05rem;
line-height: 1.6;
}
.theme-controls {
display: grid;
grid-template-columns: 1fr 4rem;
gap: 0.7rem;
min-width: 240px;
}
.field {
display: grid;
gap: 0.35rem;
color: var(--muted);
font-size: 0.85rem;
}
.field input,
.field select {
width: 100%;
border: 1px solid var(--border);
border-radius: 1rem;
padding: 0.75rem 0.9rem;
background: var(--surface-strong);
color: var(--text);
outline: none;
}
.field input:focus,
.field select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.field input[type="color"] {
height: 2.95rem;
padding: 0.25rem;
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 340px;
gap: 1rem;
margin-top: 1rem;
}
.panel {
border-radius: 1.5rem;
padding: 1.25rem;
}
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.server-grid {
display: grid;
gap: 0.75rem;
}
.server-card {
display: grid;
grid-template-columns: 3.5rem 1fr auto;
gap: 0.9rem;
align-items: center;
width: 100%;
border-radius: 1.25rem;
padding: 0.85rem;
color: var(--text);
cursor: pointer;
text-align: left;
transition:
border-color 0.2s,
transform 0.2s,
box-shadow 0.2s;
}
.server-card:hover,
.server-card.is-selected {
border-color: var(--accent);
transform: translateY(-2px);
box-shadow: 0 14px 34px var(--accent-soft);
}
.flag {
display: grid;
place-items: center;
width: 3.5rem;
height: 2.4rem;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 0.7rem;
background: var(--surface-strong);
color: var(--muted);
font-weight: 800;
}
.flag img {
width: 100%;
height: 100%;
object-fit: cover;
}
.server-name {
font-weight: 800;
}
.server-meta,
.ping {
color: var(--muted);
font-size: 0.82rem;
}
.ping {
min-width: 4.5rem;
text-align: right;
}
.pill {
display: inline-flex;
align-items: center;
gap: 0.4rem;
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.45rem 0.75rem;
background: var(--surface-strong);
color: var(--muted);
font-size: 0.84rem;
}
.controls-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.8rem;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 0.55rem;
min-height: 100%;
border: 1px solid var(--border);
border-radius: 1rem;
padding: 0.75rem 0.9rem;
color: var(--muted);
background: var(--surface-strong);
}
.checkbox-row input {
accent-color: var(--accent);
}
.start-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.8rem;
margin-top: 1rem;
}
.button {
border: 0;
border-radius: 999px;
padding: 0.95rem 1.4rem;
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
color: white;
font-weight: 800;
cursor: pointer;
transition:
transform 0.2s,
box-shadow 0.2s,
opacity 0.2s;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 14px 34px var(--accent-soft);
}
.button:disabled {
cursor: not-allowed;
opacity: 0.55;
transform: none;
box-shadow: none;
}
.button.secondary {
border: 1px solid var(--border);
background: var(--surface-strong);
color: var(--text);
}
.timer {
color: var(--warning);
font-size: 1.8rem;
font-weight: 900;
letter-spacing: 0.04em;
}
.metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.metric-card {
border-radius: 1.5rem;
padding: 1.25rem;
}
.metric-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
color: var(--muted);
}
.speed {
margin-top: 0.9rem;
color: var(--accent);
font-size: clamp(2.8rem, 8vw, 5rem);
font-weight: 900;
line-height: 1;
letter-spacing: -0.08em;
}
.metric-card.upload .speed {
color: var(--upload);
}
.unit {
color: var(--muted);
font-weight: 700;
}
.bar-bg {
height: 0.8rem;
margin-top: 1rem;
overflow: hidden;
border-radius: 999px;
background: color-mix(in srgb, var(--muted) 22%, transparent);
}
.bar {
width: 0%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--accent), var(--accent-strong));
transition: width 0.25s ease;
}
.upload .bar {
background: linear-gradient(90deg, var(--upload), #7c3aed);
}
.meta,
.average,
.status {
margin-top: 0.55rem;
color: var(--muted);
font-size: 0.9rem;
}
.graph-container {
margin-top: 1rem;
}
.chart-wrap {
height: 320px;
border: 1px solid var(--border);
border-radius: 1.25rem;
background: var(--surface-strong);
padding: 1rem;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
@media (max-width: 920px) {
.hero,
.layout,
.metrics,
.controls-grid {
grid-template-columns: 1fr;
}
.theme-controls {
width: 100%;
}
}
</style>
</head>
<body>
<main class="page">
<header class="hero">
<div>
<h1>Измерение скорости сети</h1>
<p class="lead">
Выберите сервер, проверьте задержку до каждого узла и запустите
download/upload тест. Настройки темы и акцентного цвета сохраняются
в браузере.
</p>
</div>
<div class="theme-controls" aria-label="Настройки внешнего вида">
<label class="field">
Тема
<select id="themeSelect">
<option value="dark">Темная</option>
<option value="light">Светлая</option>
</select>
</label>
<label class="field">
Акцент
<input id="accentInput" type="color" value="#38bdf8" />
</label>
</div>
</header>
<div class="layout">
<section class="panel" aria-labelledby="serversTitle">
<div class="section-title">
<h2 id="serversTitle">Серверы</h2>
<button class="button secondary" id="refreshPingBtn" type="button">
Проверить пинг
</button>
</div>
<div class="server-grid" id="serverGrid"></div>
<p class="status" id="serverStatus">Загружаю список серверов...</p>
</section>
<aside class="panel" aria-label="Параметры теста">
<div class="controls-grid">
<label class="field">
Длительность этапа, минут
<input
type="number"
id="durationInput"
value="0.5"
min="0.1"
max="10"
step="0.1"
/>
</label>
<label class="checkbox-row">
<input type="checkbox" id="simultaneousCheckbox" checked />
Одновременный тест
</label>
</div>
<div class="start-row">
<button class="button" id="startBtn" type="button">
Начать тест
</button>
<div class="timer" id="timerDisplay">--:--</div>
</div>
<p class="status" id="targetServerText">Сервер не выбран</p>
</aside>
</div>
<section class="metrics" aria-label="Результаты скорости">
<article class="metric-card">
<div class="metric-header">
<h2>Скачивание</h2>
<span class="pill" id="downloadPeak">Пик: --</span>
</div>
<div class="speed" id="downloadSpeed">--</div>
<div class="unit">Мбит/с</div>
<div class="bar-bg"><div class="bar" id="downloadBar"></div></div>
<div class="meta" id="downloadMeta">Скачано: 0 Б</div>
<div class="average" id="downloadAvg"></div>
</article>
<article class="metric-card upload">
<div class="metric-header">
<h2>Отдача</h2>
<span class="pill" id="uploadPeak">Пик: --</span>
</div>
<div class="speed" id="uploadSpeed">--</div>
<div class="unit">Мбит/с</div>
<div class="bar-bg"><div class="bar" id="uploadBar"></div></div>
<div class="meta" id="uploadMeta">Отправлено: 0 Б</div>
<div class="average" id="uploadAvg"></div>
</article>
</section>
<section class="panel graph-container" aria-labelledby="chartTitle">
<div class="section-title">
<h2 id="chartTitle">График скорости</h2>
<span class="pill" id="chartScale">Шкала: --</span>
</div>
<div class="chart-wrap">
<canvas id="chart"></canvas>
</div>
</section>
</main>
<script>
const elements = {
themeSelect: document.getElementById("themeSelect"),
accentInput: document.getElementById("accentInput"),
serverGrid: document.getElementById("serverGrid"),
serverStatus: document.getElementById("serverStatus"),
refreshPingBtn: document.getElementById("refreshPingBtn"),
targetServerText: document.getElementById("targetServerText"),
downloadSpeed: document.getElementById("downloadSpeed"),
uploadSpeed: document.getElementById("uploadSpeed"),
downloadBar: document.getElementById("downloadBar"),
uploadBar: document.getElementById("uploadBar"),
downloadMeta: document.getElementById("downloadMeta"),
uploadMeta: document.getElementById("uploadMeta"),
downloadAvg: document.getElementById("downloadAvg"),
uploadAvg: document.getElementById("uploadAvg"),
downloadPeak: document.getElementById("downloadPeak"),
uploadPeak: document.getElementById("uploadPeak"),
startBtn: document.getElementById("startBtn"),
durationInput: document.getElementById("durationInput"),
simultaneousCheckbox: document.getElementById("simultaneousCheckbox"),
timerDisplay: document.getElementById("timerDisplay"),
chartScale: document.getElementById("chartScale"),
};
const canvas = document.getElementById("chart");
const ctx = canvas.getContext("2d");
const settingsKey = "network-test-ui";
const MAX_POINTS = 900;
const MAX_CONCURRENT_UPLOADS = 3;
const UPLOAD_BLOB_SIZE = 10 * 1024 * 1024;
let servers = [];
let selectedServerId = localStorage.getItem("selected-server-id");
let pingResults = new Map();
let downloadBytes = 0;
let uploadBytes = 0;
let downloadSpeed = 0;
let uploadSpeed = 0;
let downloadPeak = 0;
let uploadPeak = 0;
let graphData = [];
let globalTestStartTime = 0;
let totalTestDurationMs = 30000;
let timerInterval = null;
let uploadActive = false;
let activeXhrs = [];
initAppearance();
resizeChart();
window.addEventListener("resize", () => {
resizeChart();
drawChart();
});
loadServers();
elements.themeSelect.addEventListener("change", () => {
applyAppearance(elements.themeSelect.value, elements.accentInput.value);
saveAppearance();
drawChart();
});
elements.accentInput.addEventListener("input", () => {
applyAppearance(elements.themeSelect.value, elements.accentInput.value);
saveAppearance();
drawChart();
});
elements.refreshPingBtn.addEventListener("click", () => {
refreshAllPings();
});
elements.startBtn.addEventListener("click", runSpeedTest);
function initAppearance() {
const saved = JSON.parse(localStorage.getItem(settingsKey) || "{}");
const theme = saved.theme || "dark";
const accent = saved.accent || "#38bdf8";
elements.themeSelect.value = theme;
elements.accentInput.value = accent;
applyAppearance(theme, accent);
}
function applyAppearance(theme, accent) {
document.documentElement.dataset.theme = theme;
document.documentElement.style.setProperty("--accent", accent);
document.documentElement.style.setProperty("--accent-strong", shadeColor(accent, -18));
document.documentElement.style.setProperty("--accent-soft", hexToRgba(accent, 0.18));
}
function saveAppearance() {
localStorage.setItem(
settingsKey,
JSON.stringify({
theme: elements.themeSelect.value,
accent: elements.accentInput.value,
}),
);
}
function shadeColor(hex, percent) {
const value = parseInt(hex.slice(1), 16);
const amount = Math.round(2.55 * percent);
const red = Math.max(0, Math.min(255, (value >> 16) + amount));
const green = Math.max(0, Math.min(255, ((value >> 8) & 0xff) + amount));
const blue = Math.max(0, Math.min(255, (value & 0xff) + amount));
return `#${(0x1000000 + red * 0x10000 + green * 0x100 + blue).toString(16).slice(1)}`;
}
function hexToRgba(hex, alpha) {
const value = parseInt(hex.slice(1), 16);
return `rgba(${value >> 16}, ${(value >> 8) & 255}, ${value & 255}, ${alpha})`;
}
async function loadServers() {
try {
const response = await fetch("/api/servers", { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
servers = payload.servers || [];
if (!servers.some((server) => server.id === selectedServerId)) {
selectedServerId = payload.current?.id || servers[0]?.id || null;
}
renderServers();
updateSelectedServerText();
await refreshAllPings();
} catch (error) {
elements.serverStatus.textContent = `Не удалось загрузить серверы: ${error.message}`;
}
}
function renderServers() {
elements.serverGrid.innerHTML = "";
servers.forEach((server) => {
const button = document.createElement("button");
button.type = "button";
button.className = `server-card${server.id === selectedServerId ? " is-selected" : ""}`;
button.addEventListener("click", () => selectServer(server.id));
const ping = pingResults.get(server.id);
const pingText = ping == null ? "пинг: --" : `пинг: ${Math.round(ping)} мс`;
const flagContent = server.flagUrl
? `<img src="${server.flagUrl}" alt="Флаг ${server.country}" loading="lazy" onerror="this.remove()" />`
: (server.countryCode || "--").toUpperCase();
button.innerHTML = `
<span class="flag">${flagContent}</span>
<span>
<span class="server-name">${escapeHtml(server.name)}</span>
<span class="server-meta">${escapeHtml(server.country)} · ${escapeHtml(server.address)}</span>
</span>
<span class="ping">${pingText}</span>
`;
elements.serverGrid.appendChild(button);
});
elements.serverStatus.textContent = servers.length
? "Выберите сервер для теста скорости."
: "В конфиге нет серверов.";
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function selectServer(serverId) {
selectedServerId = serverId;
localStorage.setItem("selected-server-id", serverId);
renderServers();
updateSelectedServerText();
}
function getSelectedServer() {
return servers.find((server) => server.id === selectedServerId) || servers[0];
}
function updateSelectedServerText() {
const server = getSelectedServer();
elements.targetServerText.textContent = server
? `Цель: ${server.name}, ${server.country} (${server.address})`
: "Сервер не выбран";
}
async function refreshAllPings() {
if (!servers.length) return;
elements.refreshPingBtn.disabled = true;
await Promise.all(
servers.map(async (server) => {
const ping = await measurePing(server);
pingResults.set(server.id, ping);
renderServers();
}),
);
elements.refreshPingBtn.disabled = false;
}
async function measurePing(server) {
const attempts = 3;
const values = [];
for (let i = 0; i < attempts; i += 1) {
const abortController = new AbortController();
const timeout = setTimeout(() => abortController.abort(), 2500);
const start = performance.now();
try {
const response = await fetch(`${getServerBaseUrl(server)}/ping?ts=${Date.now()}-${i}`, {
cache: "no-store",
signal: abortController.signal,
});
if (response.ok) values.push(performance.now() - start);
} catch {
values.push(null);
} finally {
clearTimeout(timeout);
}
}
const successful = values.filter((value) => typeof value === "number");
if (!successful.length) return null;
return Math.min(...successful);
}
function getServerBaseUrl(server) {
return String(server.address).replace(/\/+$/, "");
}
function formatBytes(bytes) {
if (bytes === 0) return "0 Б";
const units = ["Б", "КБ", "МБ", "ГБ", "ТБ"];
const unitIndex = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
return `${(bytes / Math.pow(1024, unitIndex)).toFixed(unitIndex > 0 ? 2 : 0)} ${units[unitIndex]}`;
}
function formatSpeed(mbps) {
if (mbps < 1) return mbps.toFixed(2);
if (mbps < 10) return mbps.toFixed(1);
return Math.round(mbps).toString();
}
function updateBar(bar, mbps) {
const dynamicMax = Math.max(100, downloadPeak, uploadPeak);
bar.style.width = `${Math.min(100, (mbps / dynamicMax) * 100)}%`;
}
function resetMetrics() {
downloadBytes = 0;
uploadBytes = 0;
downloadSpeed = 0;
uploadSpeed = 0;
downloadPeak = 0;
uploadPeak = 0;
graphData = [];
elements.downloadSpeed.textContent = "--";
elements.uploadSpeed.textContent = "--";
elements.downloadBar.style.width = "0%";
elements.uploadBar.style.width = "0%";
elements.downloadMeta.textContent = "Скачано: 0 Б";
elements.uploadMeta.textContent = "Отправлено: 0 Б";
elements.downloadAvg.textContent = "";
elements.uploadAvg.textContent = "";
elements.downloadPeak.textContent = "Пик: --";
elements.uploadPeak.textContent = "Пик: --";
elements.timerDisplay.textContent = "--:--";
drawChart();
}
function resizeChart() {
const ratio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.max(600, Math.floor(rect.width * ratio));
canvas.height = Math.max(260, Math.floor(rect.height * ratio));
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
}
function cssVar(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
function drawChart() {
const rect = canvas.getBoundingClientRect();
const width = rect.width;
const height = rect.height;
ctx.clearRect(0, 0, width, height);
const pad = { top: 30, right: 28, bottom: 40, left: 58 };
const plotWidth = width - pad.left - pad.right;
const plotHeight = height - pad.top - pad.bottom;
const textColor = cssVar("--muted");
const gridColor = cssVar("--border");
const downloadColor = cssVar("--accent");
const uploadColor = cssVar("--upload");
const maxSpeed = Math.max(10, Math.ceil(Math.max(downloadPeak, uploadPeak, 1) / 10) * 10);
elements.chartScale.textContent = `Шкала: 0-${Math.round(maxSpeed)} Мбит/с`;
ctx.font = "12px system-ui";
ctx.lineWidth = 1;
for (let i = 0; i <= 5; i += 1) {
const y = pad.top + (plotHeight * i) / 5;
const speed = maxSpeed * (1 - i / 5);
ctx.strokeStyle = gridColor;
ctx.beginPath();
ctx.moveTo(pad.left, y);
ctx.lineTo(width - pad.right, y);
ctx.stroke();
ctx.fillStyle = textColor;
ctx.fillText(Math.round(speed), 12, y + 4);
}
ctx.fillStyle = textColor;
ctx.fillText("Мбит/с", 12, 18);
drawLegend(width, downloadColor, uploadColor);
if (graphData.length < 2) return;
const firstTime = graphData[0].time;
const lastTime = graphData[graphData.length - 1].time;
const duration = Math.max(1, lastTime - firstTime);
for (let i = 0; i <= 4; i += 1) {
const x = pad.left + (plotWidth * i) / 4;
const sec = (duration * i) / 4;
ctx.fillStyle = textColor;
ctx.fillText(`${Math.round(sec)}с`, x - 10, height - 14);
}
drawSeries((point) => point.down, downloadColor, maxSpeed, pad, plotWidth, plotHeight, firstTime, duration);
drawSeries((point) => point.up, uploadColor, maxSpeed, pad, plotWidth, plotHeight, firstTime, duration);
}
function drawLegend(width, downloadColor, uploadColor) {
const items = [
["Скачивание", downloadColor],
["Отдача", uploadColor],
];
let x = width - 210;
items.forEach(([label, color]) => {
ctx.fillStyle = color;
ctx.fillRect(x, 12, 18, 4);
ctx.fillStyle = cssVar("--muted");
ctx.fillText(label, x + 26, 17);
x += 105;
});
}
function drawSeries(getValue, color, maxSpeed, pad, plotWidth, plotHeight, firstTime, duration) {
const points = graphData.map((point) => ({
x: pad.left + ((point.time - firstTime) / duration) * plotWidth,
y: pad.top + plotHeight * (1 - getValue(point) / maxSpeed),
}));
ctx.beginPath();
points.forEach((point, index) => {
if (index === 0) ctx.moveTo(point.x, point.y);
else ctx.lineTo(point.x, point.y);
});
ctx.strokeStyle = color;
ctx.lineWidth = 3;
ctx.lineJoin = "round";
ctx.lineCap = "round";
ctx.stroke();
const gradient = ctx.createLinearGradient(0, pad.top, 0, pad.top + plotHeight);
gradient.addColorStop(0, hexToRgba(color, 0.24));
gradient.addColorStop(1, hexToRgba(color, 0));
ctx.lineTo(points[points.length - 1].x, pad.top + plotHeight);
ctx.lineTo(points[0].x, pad.top + plotHeight);
ctx.closePath();
ctx.fillStyle = gradient;
ctx.fill();
}
function addGraphPoint() {
const nowSec = (performance.now() - globalTestStartTime) / 1000;
graphData.push({ time: nowSec, down: downloadSpeed, up: uploadSpeed });
if (graphData.length > MAX_POINTS) graphData.shift();
drawChart();
}
function updateTimer() {
if (!globalTestStartTime) return;
const elapsed = (performance.now() - globalTestStartTime) / 1000;
const remaining = Math.max(0, totalTestDurationMs / 1000 - elapsed);
const mins = Math.floor(remaining / 60);
const secs = Math.floor(remaining % 60);
elements.timerDisplay.textContent = `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
if (remaining <= 0) clearInterval(timerInterval);
}
async function runDownloadTest(server, durationMs) {
downloadBytes = 0;
downloadSpeed = 0;
const startTime = performance.now();
let lastUpdateTime = startTime;
let lastUpdateBytes = 0;
const abortController = new AbortController();
const timer = setTimeout(() => abortController.abort(), durationMs);
try {
const response = await fetch(`${getServerBaseUrl(server)}/download`, {
cache: "no-store",
signal: abortController.signal,
});
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
downloadBytes += value.length;
const now = performance.now();
if (now - lastUpdateTime >= 200) {
const bytesInInterval = downloadBytes - lastUpdateBytes;
const elapsed = (now - lastUpdateTime) / 1000;
downloadSpeed = (bytesInInterval * 8) / (elapsed * 1_000_000);
downloadPeak = Math.max(downloadPeak, downloadSpeed);
elements.downloadSpeed.textContent = formatSpeed(downloadSpeed);
elements.downloadMeta.textContent = `Скачано: ${formatBytes(downloadBytes)}`;
elements.downloadPeak.textContent = `Пик: ${formatSpeed(downloadPeak)}`;
updateBar(elements.downloadBar, downloadSpeed);
lastUpdateTime = now;
lastUpdateBytes = downloadBytes;
}
if (now - startTime >= durationMs) {
abortController.abort();
break;
}
}
} catch (error) {
if (error.name !== "AbortError") console.error(error);
} finally {
clearTimeout(timer);
const totalTime = (performance.now() - startTime) / 1000;
const totalMbps = downloadBytes ? (downloadBytes * 8) / (totalTime * 1_000_000) : 0;
downloadSpeed = totalMbps;
downloadPeak = Math.max(downloadPeak, totalMbps);
elements.downloadSpeed.textContent = formatSpeed(totalMbps);
elements.downloadMeta.textContent = `Всего скачано: ${formatBytes(downloadBytes)}`;
elements.downloadAvg.textContent = `Средняя: ${formatSpeed(totalMbps)} Мбит/с`;
elements.downloadPeak.textContent = `Пик: ${formatSpeed(downloadPeak)}`;
updateBar(elements.downloadBar, totalMbps);
}
}
function createUploadBlob() {
const data = new Uint8Array(UPLOAD_BLOB_SIZE);
for (let i = 0; i < UPLOAD_BLOB_SIZE; i += 4096) {
data[i] = Math.floor(Math.random() * 256);
}
return new Blob([data], { type: "application/octet-stream" });
}
function startUploadChain(server) {
uploadActive = true;
let completedBytes = 0;
let lastUploadBytes = 0;
let lastUpdateTime = performance.now();
uploadBytes = 0;
uploadSpeed = 0;
function updateUploadSpeed(currentTotal) {
const now = performance.now();
if (currentTotal <= lastUploadBytes) return;
const elapsed = (now - lastUpdateTime) / 1000;
if (elapsed >= 0.2) {
const delta = currentTotal - lastUploadBytes;
uploadSpeed = (delta * 8) / (elapsed * 1_000_000);
uploadPeak = Math.max(uploadPeak, uploadSpeed);
elements.uploadSpeed.textContent = formatSpeed(uploadSpeed);
elements.uploadMeta.textContent = `Отправлено: ${formatBytes(currentTotal)}`;
elements.uploadPeak.textContent = `Пик: ${formatSpeed(uploadPeak)}`;
updateBar(elements.uploadBar, uploadSpeed);
lastUpdateTime = now;
lastUploadBytes = currentTotal;
}
uploadBytes = currentTotal;
}
function sendOne() {
if (!uploadActive) return;
const xhr = new XMLHttpRequest();
xhr.open("POST", `${getServerBaseUrl(server)}/upload`, true);
xhr.responseType = "text";
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
updateUploadSpeed(completedBytes + event.loaded);
}
};
xhr.onload = () => {
const totalFromServer = parseInt(xhr.responseText, 10) || UPLOAD_BLOB_SIZE;
completedBytes += totalFromServer;
updateUploadSpeed(completedBytes);
activeXhrs = activeXhrs.filter((activeXhr) => activeXhr !== xhr);
if (uploadActive) sendOne();
};
xhr.onerror = () => {
activeXhrs = activeXhrs.filter((activeXhr) => activeXhr !== xhr);
if (uploadActive) sendOne();
};
xhr.onabort = () => {
activeXhrs = activeXhrs.filter((activeXhr) => activeXhr !== xhr);
};
activeXhrs.push(xhr);
xhr.send(createUploadBlob());
}
for (let i = 0; i < MAX_CONCURRENT_UPLOADS; i += 1) sendOne();
}
function stopUploadChain() {
uploadActive = false;
activeXhrs.forEach((xhr) => xhr.abort());
activeXhrs = [];
}
async function runUploadTest(server, durationMs) {
uploadBytes = 0;
const startTime = performance.now();
startUploadChain(server);
await new Promise((resolve) => setTimeout(resolve, durationMs));
stopUploadChain();
const totalTime = (performance.now() - startTime) / 1000;
const totalMbps = uploadBytes ? (uploadBytes * 8) / (totalTime * 1_000_000) : 0;
uploadSpeed = totalMbps;
uploadPeak = Math.max(uploadPeak, totalMbps);
elements.uploadSpeed.textContent = formatSpeed(totalMbps);
elements.uploadMeta.textContent = `Всего отправлено: ${formatBytes(uploadBytes)}`;
elements.uploadAvg.textContent = `Средняя: ${formatSpeed(totalMbps)} Мбит/с`;
elements.uploadPeak.textContent = `Пик: ${formatSpeed(uploadPeak)}`;
updateBar(elements.uploadBar, totalMbps);
}
async function runSpeedTest() {
const server = getSelectedServer();
if (!server) {
elements.serverStatus.textContent = "Сначала добавьте сервер в servers.json.";
return;
}
elements.startBtn.disabled = true;
resetMetrics();
const minutes = parseFloat(elements.durationInput.value) || 0.5;
const singleDurationMs = Math.max(5000, minutes * 60 * 1000);
const simultaneous = elements.simultaneousCheckbox.checked;
totalTestDurationMs = simultaneous ? singleDurationMs : singleDurationMs * 2;
globalTestStartTime = performance.now();
clearInterval(timerInterval);
timerInterval = setInterval(updateTimer, 200);
updateTimer();
const graphInterval = setInterval(addGraphPoint, 500);
try {
if (simultaneous) {
await Promise.all([
runDownloadTest(server, singleDurationMs),
runUploadTest(server, singleDurationMs),
]);
} else {
await runDownloadTest(server, singleDurationMs);
uploadSpeed = 0;
elements.uploadSpeed.textContent = "--";
elements.uploadBar.style.width = "0%";
await runUploadTest(server, singleDurationMs);
}
} finally {
clearInterval(timerInterval);
clearInterval(graphInterval);
elements.timerDisplay.textContent = "00:00";
addGraphPoint();
elements.startBtn.disabled = false;
refreshAllPings();
}
}
</script>
</body>
</html>