1256 lines
39 KiB
HTML
1256 lines
39 KiB
HTML
<!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("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """);
|
||
}
|
||
|
||
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>
|