574 lines
20 KiB
TypeScript
574 lines
20 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useRef, useEffect } from "react";
|
||
import { motion, AnimatePresence } from "framer-motion";
|
||
import {
|
||
Activity,
|
||
Download,
|
||
Upload,
|
||
Server,
|
||
RefreshCw,
|
||
Zap,
|
||
} from "lucide-react";
|
||
import { CyberButton } from "../ui/cyber-button";
|
||
import { cn } from "@/lib/utils";
|
||
import { Dictionary } from "@/lib/IDict";
|
||
|
||
type TestStatus =
|
||
| "idle"
|
||
| "pinging"
|
||
| "downloading"
|
||
| "uploading"
|
||
| "done"
|
||
| "error";
|
||
|
||
interface SpeedtestClientProps {
|
||
dict: Dictionary["speedtest"];
|
||
}
|
||
|
||
// Мегабит везде считаем в десятичном виде (1 Мбит = 1_000_000 бит), как это
|
||
// делают провайдеры и остальные спидтесты (Ookla, fast.com) — иначе цифра на
|
||
// экране будет тише реальной на ~5% при том же подписанном "Мбит/с".
|
||
const MEGABIT = 1_000_000;
|
||
const bytesToMbps = (bytes: number, seconds: number) =>
|
||
seconds > 0 ? (bytes * 8) / MEGABIT / seconds : 0;
|
||
|
||
const median = (values: number[]) => {
|
||
const sorted = [...values].sort((a, b) => a - b);
|
||
const mid = Math.floor(sorted.length / 2);
|
||
return sorted.length % 2 !== 0
|
||
? sorted[mid]
|
||
: (sorted[mid - 1] + sorted[mid]) / 2;
|
||
};
|
||
|
||
/**
|
||
* Прогревочный + N таймированных запросов на /ping. Первый запрос выкидываем
|
||
* из выборки — на нём поднимается TCP/TLS-соединение, и его RTT в 3-5 раз
|
||
* больше "боевого", что раздувает среднее. Из таймированных сэмплов берём
|
||
* медиану, а не среднее — она устойчива к единичным всплескам (например,
|
||
* фоновая задача ОС притормозила один из запросов).
|
||
*/
|
||
async function measurePing(
|
||
baseUrl: string,
|
||
signal: AbortSignal,
|
||
): Promise<number> {
|
||
try {
|
||
await fetch(`${baseUrl}/api/speedtest/ping?nc=${Date.now()}`, {
|
||
cache: "no-store",
|
||
signal,
|
||
});
|
||
} catch (e: any) {
|
||
if (e?.name === "AbortError") throw e;
|
||
}
|
||
|
||
const SAMPLES = 8;
|
||
const samples: number[] = [];
|
||
for (let i = 0; i < SAMPLES; i++) {
|
||
const start = performance.now();
|
||
try {
|
||
await fetch(`${baseUrl}/api/speedtest/ping?nc=${Date.now()}-${i}`, {
|
||
cache: "no-store",
|
||
signal,
|
||
});
|
||
samples.push(performance.now() - start);
|
||
} catch (e: any) {
|
||
if (e?.name === "AbortError") throw e;
|
||
}
|
||
}
|
||
|
||
if (samples.length === 0) throw new Error("Ping failed");
|
||
return Math.round(median(samples));
|
||
}
|
||
|
||
/**
|
||
* Скачивание несколькими параллельными потоками в течение фиксированного
|
||
* окна времени (а не фиксированного объёма!). Один TCP-поток не насыщает
|
||
* канал на быстрых/дальних соединениях (окно перегрузки не успевает
|
||
* разогнаться) — поэтому качаем сразу несколько потоков одновременно, и как
|
||
* только один исчерпал свой блоб — тут же открываем новый, не давая "трубе"
|
||
* простаивать. Первую секунду (WARMUP_MS) не учитываем в расчёте — это
|
||
* TCP slow-start, а не установившаяся скорость канала.
|
||
*/
|
||
async function measureDownload(
|
||
baseUrl: string,
|
||
outerSignal: AbortSignal,
|
||
onProgress: (mbps: number) => void,
|
||
): Promise<number> {
|
||
const STREAMS = 4;
|
||
const WARMUP_MS = 1000;
|
||
const DURATION_MS = 8000;
|
||
|
||
const controller = new AbortController();
|
||
const onAbort = () => controller.abort();
|
||
outerSignal.addEventListener("abort", onAbort);
|
||
|
||
const testStart = performance.now();
|
||
const deadline = testStart + WARMUP_MS + DURATION_MS;
|
||
|
||
let totalBytes = 0;
|
||
let bytesAtWarmupEnd: number | null = null;
|
||
|
||
const runStream = async () => {
|
||
while (performance.now() < deadline) {
|
||
let res: Response;
|
||
try {
|
||
res = await fetch(
|
||
`${baseUrl}/api/speedtest/download?nc=${Date.now()}-${Math.random()}`,
|
||
{ cache: "no-store", signal: controller.signal },
|
||
);
|
||
} catch (e: any) {
|
||
if (e?.name === "AbortError") return;
|
||
throw e;
|
||
}
|
||
const reader = res.body?.getReader();
|
||
if (!reader) return;
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (value) totalBytes += value.length;
|
||
if (done) break;
|
||
if (performance.now() >= deadline) {
|
||
await reader.cancel().catch(() => {});
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
const warmupTimer = setTimeout(() => {
|
||
bytesAtWarmupEnd = totalBytes;
|
||
}, WARMUP_MS);
|
||
|
||
let lastBytes = 0;
|
||
let lastTime = testStart;
|
||
const progressTimer = setInterval(() => {
|
||
if (bytesAtWarmupEnd === null) return;
|
||
const now = performance.now();
|
||
const dBytes = totalBytes - lastBytes;
|
||
const dTime = (now - lastTime) / 1000;
|
||
lastBytes = totalBytes;
|
||
lastTime = now;
|
||
onProgress(bytesToMbps(dBytes, dTime));
|
||
}, 250);
|
||
|
||
try {
|
||
await Promise.all(Array.from({ length: STREAMS }, runStream));
|
||
} finally {
|
||
clearTimeout(warmupTimer);
|
||
clearInterval(progressTimer);
|
||
outerSignal.removeEventListener("abort", onAbort);
|
||
}
|
||
|
||
const measuredBytes = totalBytes - (bytesAtWarmupEnd ?? totalBytes);
|
||
const measuredSeconds = (performance.now() - (testStart + WARMUP_MS)) / 1000;
|
||
return bytesToMbps(measuredBytes, measuredSeconds);
|
||
}
|
||
|
||
const MAX_RANDOM_CHUNK = 65536; // лимит crypto.getRandomValues() за один вызов
|
||
|
||
function makeRandomBytes(size: number): Uint8Array<ArrayBuffer> {
|
||
const buf = new Uint8Array(size);
|
||
for (let offset = 0; offset < size; offset += MAX_RANDOM_CHUNK) {
|
||
crypto.getRandomValues(
|
||
buf.subarray(offset, Math.min(offset + MAX_RANDOM_CHUNK, size)),
|
||
);
|
||
}
|
||
return buf;
|
||
}
|
||
|
||
/**
|
||
* Отдача — тем же приёмом, что и скачивание: несколько параллельных потоков,
|
||
* фиксированное окно времени, первые полсекунды не считаем. У fetch() нет
|
||
* события прогресса отправки, поэтому вместо одного гигантского запроса шлём
|
||
* последовательность отдельных чанков — так время оборота каждого запроса
|
||
* меньше влияет на итоговую точность, и мы можем адаптировать размер чанка
|
||
* под скорость канала (AIMD: чанк удался в срок — увеличиваем вдвое,
|
||
* не уложились в таймаут — уменьшаем вдвое), вместо того чтобы гадать с
|
||
* одним фиксированным размером на все каналы, от мобильного 3G до гигабита.
|
||
* Каждый чанк — свежие случайные байты (а не повторяющийся блок), чтобы
|
||
* сервер или прокси на пути не мог его сжать и исказить измерение.
|
||
*/
|
||
async function measureUpload(
|
||
baseUrl: string,
|
||
outerSignal: AbortSignal,
|
||
onProgress: (mbps: number) => void,
|
||
): Promise<number> {
|
||
const STREAMS = 3;
|
||
const WARMUP_MS = 500;
|
||
const DURATION_MS = 6000;
|
||
const MIN_CHUNK = 256 * 1024;
|
||
const MAX_CHUNK = 4 * 1024 * 1024;
|
||
const REQUEST_TIMEOUT_MS = 4000;
|
||
|
||
const controller = new AbortController();
|
||
const onAbort = () => controller.abort();
|
||
outerSignal.addEventListener("abort", onAbort);
|
||
|
||
const testStart = performance.now();
|
||
const deadline = testStart + WARMUP_MS + DURATION_MS;
|
||
|
||
let totalBytes = 0;
|
||
let bytesAtWarmupEnd: number | null = null;
|
||
|
||
const runStream = async () => {
|
||
let chunkSize = MIN_CHUNK;
|
||
while (performance.now() < deadline) {
|
||
const payload = makeRandomBytes(chunkSize);
|
||
const reqController = new AbortController();
|
||
const onOuterAbort = () => reqController.abort();
|
||
controller.signal.addEventListener("abort", onOuterAbort);
|
||
const timeoutId = setTimeout(
|
||
() => reqController.abort(),
|
||
REQUEST_TIMEOUT_MS,
|
||
);
|
||
|
||
try {
|
||
await fetch(
|
||
`${baseUrl}/api/speedtest/upload?nc=${Date.now()}-${Math.random()}`,
|
||
{
|
||
method: "POST",
|
||
body: payload,
|
||
signal: reqController.signal,
|
||
headers: { "Content-Type": "application/octet-stream" },
|
||
},
|
||
);
|
||
totalBytes += payload.length;
|
||
chunkSize = Math.min(chunkSize * 2, MAX_CHUNK);
|
||
} catch (e: any) {
|
||
if (controller.signal.aborted) return;
|
||
chunkSize = Math.max(Math.floor(chunkSize / 2), MIN_CHUNK);
|
||
} finally {
|
||
clearTimeout(timeoutId);
|
||
controller.signal.removeEventListener("abort", onOuterAbort);
|
||
}
|
||
}
|
||
};
|
||
|
||
const warmupTimer = setTimeout(() => {
|
||
bytesAtWarmupEnd = totalBytes;
|
||
}, WARMUP_MS);
|
||
|
||
let lastBytes = 0;
|
||
let lastTime = testStart;
|
||
const progressTimer = setInterval(() => {
|
||
if (bytesAtWarmupEnd === null) return;
|
||
const now = performance.now();
|
||
const dBytes = totalBytes - lastBytes;
|
||
const dTime = (now - lastTime) / 1000;
|
||
lastBytes = totalBytes;
|
||
lastTime = now;
|
||
onProgress(bytesToMbps(dBytes, dTime));
|
||
}, 250);
|
||
|
||
try {
|
||
await Promise.all(Array.from({ length: STREAMS }, runStream));
|
||
} finally {
|
||
clearTimeout(warmupTimer);
|
||
clearInterval(progressTimer);
|
||
outerSignal.removeEventListener("abort", onAbort);
|
||
}
|
||
|
||
const measuredBytes = totalBytes - (bytesAtWarmupEnd ?? totalBytes);
|
||
const measuredSeconds = (performance.now() - (testStart + WARMUP_MS)) / 1000;
|
||
return bytesToMbps(measuredBytes, measuredSeconds);
|
||
}
|
||
|
||
/**
|
||
* Виджет спидтеста: пинг + скорость скачивания/отдачи через Rust-шлюз proxy-ws
|
||
* (эндпоинты /api/speedtest/ping|download|upload, см. next.config.ts rewrites).
|
||
*
|
||
* В dev-режиме (NODE_ENV !== "production") реальные запросы не делаются — вместо
|
||
* этого крутится runMockTest() с рандомными числами и искусственными задержками,
|
||
* чтобы можно было верстать/проверять анимацию без поднятого локального proxy-ws.
|
||
* В проде runRealTest() измеряет параллельными потоками в течение фиксированного
|
||
* окна времени (см. measurePing/measureDownload/measureUpload выше) — так же,
|
||
* как это делают Ookla/fast.com/Cloudflare speedtest, а не одним запросом в один
|
||
* TCP-поток, который на быстрых каналах не успевает разогнаться и занижает результат.
|
||
*/
|
||
export function SpeedtestClient({ dict }: SpeedtestClientProps) {
|
||
const [status, setStatus] = useState<TestStatus>("idle");
|
||
const [metrics, setMetrics] = useState({
|
||
ping: 0,
|
||
download: 0.0,
|
||
upload: 0.0,
|
||
});
|
||
|
||
const abortControllerRef = useRef<AbortController | null>(null);
|
||
|
||
const getProxyUrl = () => {
|
||
return process.env.NODE_ENV === "production"
|
||
? "/api/proxy"
|
||
: "http://localhost:3001";
|
||
};
|
||
|
||
const runMockTest = async () => {
|
||
const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms));
|
||
|
||
setStatus("pinging");
|
||
setMetrics({ ping: 0, download: 0, upload: 0 });
|
||
await sleep(800);
|
||
setMetrics((m) => ({ ...m, ping: Math.floor(Math.random() * 15) + 12 }));
|
||
|
||
setStatus("downloading");
|
||
for (let i = 1; i <= 20; i++) {
|
||
await sleep(150);
|
||
setMetrics((m) => ({
|
||
...m,
|
||
download: +(15.5 * i + Math.random() * 10).toFixed(2),
|
||
}));
|
||
}
|
||
|
||
setStatus("uploading");
|
||
for (let i = 1; i <= 20; i++) {
|
||
await sleep(150);
|
||
setMetrics((m) => ({
|
||
...m,
|
||
upload: +(8.2 * i + Math.random() * 5).toFixed(2),
|
||
}));
|
||
}
|
||
|
||
setStatus("done");
|
||
};
|
||
|
||
const runRealTest = async () => {
|
||
const baseUrl = getProxyUrl();
|
||
setStatus("pinging");
|
||
setMetrics({ ping: 0, download: 0, upload: 0 });
|
||
|
||
const controller = new AbortController();
|
||
abortControllerRef.current = controller;
|
||
const signal = controller.signal;
|
||
|
||
try {
|
||
const ping = await measurePing(baseUrl, signal);
|
||
setMetrics((m) => ({ ...m, ping }));
|
||
|
||
setStatus("downloading");
|
||
const download = await measureDownload(baseUrl, signal, (mbps) => {
|
||
setMetrics((m) => ({ ...m, download: Number(mbps.toFixed(2)) }));
|
||
});
|
||
setMetrics((m) => ({ ...m, download: Number(download.toFixed(2)) }));
|
||
|
||
setStatus("uploading");
|
||
const upload = await measureUpload(baseUrl, signal, (mbps) => {
|
||
setMetrics((m) => ({ ...m, upload: Number(mbps.toFixed(2)) }));
|
||
});
|
||
setMetrics((m) => ({ ...m, upload: Number(upload.toFixed(2)) }));
|
||
|
||
setStatus("done");
|
||
} catch (err: any) {
|
||
if (err?.name !== "AbortError") console.error("Speedtest error:", err);
|
||
setStatus("error");
|
||
} finally {
|
||
abortControllerRef.current = null;
|
||
}
|
||
};
|
||
|
||
const runTest = () => {
|
||
if (process.env.NODE_ENV !== "production") {
|
||
runMockTest();
|
||
} else {
|
||
runRealTest();
|
||
}
|
||
};
|
||
|
||
// Если пользователь уходит со страницы посреди теста — гасим все ещё
|
||
// летящие запросы, а не оставляем их фоном дожёвывать сеть впустую.
|
||
useEffect(() => {
|
||
return () => abortControllerRef.current?.abort();
|
||
}, []);
|
||
|
||
const isRunning =
|
||
status !== "idle" && status !== "done" && status !== "error";
|
||
|
||
const getStatusLabel = () => {
|
||
switch (status) {
|
||
case "pinging":
|
||
return dict.statusPinging;
|
||
case "downloading":
|
||
return dict.statusDownloading;
|
||
case "uploading":
|
||
return dict.statusUploading;
|
||
default:
|
||
return status.toUpperCase();
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="w-full max-w-4xl mx-auto bg-card/40 backdrop-blur-xl border border-border/50 rounded-[2rem] p-8 shadow-2xl relative overflow-hidden font-mono">
|
||
<AnimatePresence>
|
||
{isRunning && (
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
className="absolute inset-0 bg-gradient-to-tr from-primary/5 via-transparent to-secondary/5 pointer-events-none -z-10"
|
||
/>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
<div className="flex flex-col md:flex-row items-center justify-between gap-8 mb-8 border-b border-border/50 pb-8">
|
||
<div>
|
||
<h2 className="text-2xl font-black uppercase text-foreground flex items-center gap-3">
|
||
<Server
|
||
className={cn(
|
||
"size-6",
|
||
isRunning
|
||
? "text-primary animate-pulse"
|
||
: "text-muted-foreground",
|
||
)}
|
||
/>
|
||
{dict.title}
|
||
</h2>
|
||
<p className="text-muted-foreground text-sm mt-2 flex items-center gap-2">
|
||
{process.env.NODE_ENV !== "production" ? (
|
||
<span className="text-yellow-500 flex items-center gap-1">
|
||
<Zap className="size-3" /> {dict.mockMode}
|
||
</span>
|
||
) : (
|
||
dict.prodMode
|
||
)}
|
||
</p>
|
||
</div>
|
||
|
||
<CyberButton onClick={runTest} disabled={isRunning} variant="primary">
|
||
{!isRunning ? (
|
||
<span className="flex items-center gap-2">
|
||
<Activity className="size-4" />{" "}
|
||
{status === "error"
|
||
? dict.btnError
|
||
: status === "done"
|
||
? dict.btnDone
|
||
: dict.btnIdle}
|
||
</span>
|
||
) : (
|
||
<span className="flex items-center gap-2 text-primary-foreground">
|
||
<RefreshCw className="size-4 animate-spin" /> {getStatusLabel()}
|
||
...
|
||
</span>
|
||
)}
|
||
</CyberButton>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||
<MetricCard
|
||
title={dict.latency}
|
||
value={metrics.ping}
|
||
unit={dict.unitMs}
|
||
icon={<Activity className="size-4" />}
|
||
isActive={status === "pinging"}
|
||
isDone={
|
||
status === "downloading" ||
|
||
status === "uploading" ||
|
||
status === "done"
|
||
}
|
||
color="text-secondary"
|
||
glowColor="shadow-secondary/20"
|
||
/>
|
||
|
||
<MetricCard
|
||
title={dict.download}
|
||
value={metrics.download}
|
||
unit={dict.unitMbps}
|
||
icon={<Download className="size-4" />}
|
||
isActive={status === "downloading"}
|
||
isDone={status === "uploading" || status === "done"}
|
||
color="text-green-500"
|
||
glowColor="shadow-green-500/20"
|
||
/>
|
||
|
||
<MetricCard
|
||
title={dict.upload}
|
||
value={metrics.upload}
|
||
unit={dict.unitMbps}
|
||
icon={<Upload className="size-4" />}
|
||
isActive={status === "uploading"}
|
||
isDone={status === "done"}
|
||
color="text-primary"
|
||
glowColor="shadow-primary/20"
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MetricCard({
|
||
title,
|
||
value,
|
||
unit,
|
||
icon,
|
||
isActive,
|
||
isDone,
|
||
color,
|
||
glowColor,
|
||
}: any) {
|
||
return (
|
||
<motion.div
|
||
animate={{
|
||
scale: isActive ? 1.02 : 1,
|
||
borderColor: isActive
|
||
? "rgba(var(--primary), 0.5)"
|
||
: "rgba(255,255,255,0.1)",
|
||
}}
|
||
className={cn(
|
||
"relative p-6 rounded-2xl border transition-all duration-300 overflow-hidden bg-muted/10",
|
||
isActive &&
|
||
`shadow-[0_0_30px_-5px_rgba(0,0,0,0.3)] ${glowColor} bg-muted/30`,
|
||
)}
|
||
>
|
||
<AnimatePresence>
|
||
{isActive && (
|
||
<motion.div
|
||
initial={{ top: "-10%" }}
|
||
animate={{ top: "110%" }}
|
||
transition={{ repeat: Infinity, duration: 1.5, ease: "linear" }}
|
||
className={cn(
|
||
"absolute left-0 right-0 h-[2px] blur-[1px] opacity-50 z-0",
|
||
color.replace("text-", "bg-"),
|
||
)}
|
||
/>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
<div className="relative z-10 text-muted-foreground text-xs uppercase tracking-widest mb-2 flex items-center justify-between">
|
||
<span className="flex items-center gap-2">
|
||
<span
|
||
className={cn(isActive || isDone ? color : "text-muted-foreground")}
|
||
>
|
||
{icon}
|
||
</span>
|
||
{title}
|
||
</span>
|
||
{isActive && (
|
||
<span
|
||
className="flex size-2 rounded-full bg-current animate-ping"
|
||
style={{ color: "currentColor" }}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
<div
|
||
className={cn(
|
||
"relative z-10 text-4xl font-black transition-colors duration-300",
|
||
isActive || isDone ? "text-foreground" : "text-muted-foreground/50",
|
||
)}
|
||
>
|
||
{value === 0 && !isDone && !isActive ? "--" : value}{" "}
|
||
<span className="text-sm font-normal text-muted-foreground">
|
||
{unit}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/20">
|
||
<motion.div
|
||
className={cn("h-full", color.replace("text-", "bg-"))}
|
||
initial={{ width: "0%" }}
|
||
animate={{
|
||
width: isDone ? "100%" : isActive ? ["0%", "80%", "95%"] : "0%",
|
||
}}
|
||
transition={{ duration: isActive ? 10 : 0.5, ease: "easeOut" }}
|
||
/>
|
||
</div>
|
||
</motion.div>
|
||
);
|
||
}
|