test-net/index.html

1256 lines
39 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;
--danger: #ef4444;
}
[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);
overflow: hidden;
}
.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 minmax(0, 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);
}
.server-card:disabled {
cursor: not-allowed;
opacity: 0.62;
transform: none;
box-shadow: var(--shadow);
}
.server-card:disabled:hover {
border-color: var(--border);
}
.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 {
display: block;
font-weight: 800;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.server-info {
min-width: 0;
}
.server-meta,
.ping {
color: var(--muted);
font-size: 0.82rem;
}
.server-meta {
display: block;
margin-top: 0.15rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ping {
min-width: 4.5rem;
text-align: right;
white-space: nowrap;
}
.ping.is-offline {
color: var(--danger);
font-weight: 800;
}
.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: minmax(0, 1fr);
gap: 0.8rem;
}
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.85rem;
min-height: 100%;
border: 1px solid var(--border);
border-radius: 1rem;
padding: 0.75rem 0.9rem;
color: var(--muted);
background: var(--surface-strong);
cursor: pointer;
overflow: hidden;
}
.toggle-row input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.toggle-text {
display: grid;
gap: 0.15rem;
min-width: 0;
}
.toggle-title {
color: var(--text);
font-weight: 800;
}
.toggle-description {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.82rem;
}
.toggle-track {
position: relative;
flex: 0 0 auto;
width: 3.25rem;
height: 1.8rem;
border: 1px solid var(--border);
border-radius: 999px;
background: color-mix(in srgb, var(--muted) 22%, transparent);
transition:
background 0.2s,
border-color 0.2s;
}
.toggle-track::after {
content: "";
position: absolute;
top: 0.22rem;
left: 0.22rem;
width: 1.22rem;
height: 1.22rem;
border-radius: 50%;
background: var(--surface-strong);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.22);
transition: transform 0.2s;
}
.toggle-row input:checked + .toggle-track {
border-color: var(--accent);
background: var(--accent);
}
.toggle-row input:checked + .toggle-track::after {
transform: translateX(1.45rem);
}
.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;
overflow-wrap: anywhere;
}
.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">
Выберите ближайший сервер, проверьте задержку и запустите тест
скорости. Тема и акцентный цвет сохраняются в этом браузере.
</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="30"
min="5"
max="600"
step="5"
/>
</label>
<label class="toggle-row">
<span class="toggle-text">
<span class="toggle-title">Параллельный тест</span>
<span class="toggle-description">
Скачивание и отдача одновременно
</span>
</span>
<input type="checkbox" id="simultaneousCheckbox" />
<span class="toggle-track" aria-hidden="true"></span>
</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 ping = pingResults.get(server.id);
const isCheckingPing = !pingResults.has(server.id);
const isOffline = ping === null;
const button = document.createElement("button");
button.type = "button";
button.disabled = isOffline;
button.className = `server-card${server.id === selectedServerId ? " is-selected" : ""}`;
button.addEventListener("click", () => selectServer(server.id));
const pingText = isCheckingPing
? "проверка..."
: isOffline
? "нет ответа"
: `пинг: ${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 class="server-info">
<span class="server-name">${escapeHtml(server.name)}</span>
<span class="server-meta">${escapeHtml(server.country)} · ${escapeHtml(server.address)}</span>
</span>
<span class="ping${isOffline ? " is-offline" : ""}">${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) {
if (pingResults.get(serverId) === null) return;
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();
if (!server) {
elements.targetServerText.textContent = "Выберите сервер для теста.";
return;
}
if (pingResults.get(server.id) === null) {
elements.targetServerText.textContent =
`${server.name} не отвечает. Выберите другой сервер.`;
return;
}
elements.targetServerText.textContent =
`Тест будет запущен через ${server.name} (${server.country}).`;
}
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();
}),
);
ensureSelectableServer();
renderServers();
updateSelectedServerText();
elements.refreshPingBtn.disabled = false;
}
function ensureSelectableServer() {
if (selectedServerId && pingResults.get(selectedServerId) !== null) {
return;
}
const firstOnlineServer = servers.find((server) => pingResults.get(server.id) !== null);
selectedServerId = firstOnlineServer?.id || null;
if (selectedServerId) {
localStorage.setItem("selected-server-id", selectedServerId);
} else {
localStorage.removeItem("selected-server-id");
}
}
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;
}
if (pingResults.get(server.id) === null) {
elements.serverStatus.textContent =
"Выбранный сервер не отвечает. Запуск теста отключен.";
return;
}
elements.startBtn.disabled = true;
resetMetrics();
const seconds = parseFloat(elements.durationInput.value) || 30;
const singleDurationMs = Math.max(5000, seconds * 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>