Files
netrunner-landing/components/sections/SpeedtestClient.tsx
2026-07-03 14:27:10 +07:00

574 lines
20 KiB
TypeScript
Raw Permalink 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.
"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>
);
}