236 lines
8.1 KiB
TypeScript
236 lines
8.1 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef } from "react";
|
|
import { motion } from "framer-motion";
|
|
import { Activity, Download, Upload, Server, RefreshCw } from "lucide-react";
|
|
import { CyberButton } from "../ui/cyber-button";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type TestStatus =
|
|
| "idle"
|
|
| "pinging"
|
|
| "downloading"
|
|
| "uploading"
|
|
| "done"
|
|
| "error";
|
|
|
|
export function SpeedtestClient() {
|
|
const [status, setStatus] = useState<TestStatus>("idle");
|
|
const [metrics, setMetrics] = useState({
|
|
ping: 0,
|
|
download: 0.0,
|
|
upload: 0.0,
|
|
});
|
|
|
|
// Ref для принудительной отмены зависших тестов
|
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
|
|
const getProxyUrl = () => {
|
|
return process.env.NODE_ENV === "production"
|
|
? "/api/proxy"
|
|
: "http://localhost:3001";
|
|
};
|
|
|
|
const runTest = 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 {
|
|
// 1. Замер PING (RTT)
|
|
let totalPing = 0;
|
|
let pingSuccessCount = 0;
|
|
for (let i = 0; i < 5; i++) {
|
|
const start = performance.now();
|
|
try {
|
|
await fetch(`${baseUrl}/api/speedtest/ping?nc=${Date.now()}`, {
|
|
cache: "no-store",
|
|
signal,
|
|
});
|
|
totalPing += performance.now() - start;
|
|
pingSuccessCount++;
|
|
} catch (e: any) {
|
|
if (e?.name === "AbortError") throw e;
|
|
// Игнорируем единичные дропы пакетов VPN при пинге
|
|
}
|
|
}
|
|
if (pingSuccessCount === 0) throw new Error("Ping failed completely");
|
|
setMetrics((m) => ({
|
|
...m,
|
|
ping: Math.round(totalPing / pingSuccessCount),
|
|
}));
|
|
|
|
// 2. Замер DOWNLOAD
|
|
setStatus("downloading");
|
|
const dlStart = performance.now();
|
|
const dlRes = await fetch(
|
|
`${baseUrl}/api/speedtest/download?nocache=${Date.now()}`,
|
|
{ cache: "no-store", mode: "cors", signal },
|
|
);
|
|
|
|
const reader = dlRes.body?.getReader();
|
|
let receivedBytes = 0;
|
|
const MAX_DL_TIME_MS = 10000; // Ограничиваем скачивание 10 секундами для медленных VPN
|
|
|
|
if (reader) {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (value) receivedBytes += value.length;
|
|
|
|
const timeElapsed = performance.now() - dlStart;
|
|
if (done || timeElapsed > MAX_DL_TIME_MS) {
|
|
if (!done) await reader.cancel(); // Прерываем поток, если вышли за лимит времени
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
const dlTime = (performance.now() - dlStart) / 1000;
|
|
const dlMbps = (receivedBytes * 8) / (1024 * 1024) / dlTime;
|
|
setMetrics((m) => ({ ...m, download: Number(dlMbps.toFixed(2)) }));
|
|
|
|
// 3. Замер UPLOAD
|
|
setStatus("uploading");
|
|
const UPLOAD_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
const uploadPayload = new Uint8Array(UPLOAD_SIZE);
|
|
const chunk = new Uint8Array(65536); // Максимум для window.crypto
|
|
|
|
// Полностью забиваем массив рандомом, иначе VPN его сожмет в ноль
|
|
crypto.getRandomValues(chunk);
|
|
for (let i = 0; i < UPLOAD_SIZE; i += chunk.length) {
|
|
uploadPayload.set(
|
|
chunk.subarray(0, Math.min(chunk.length, UPLOAD_SIZE - i)),
|
|
i,
|
|
);
|
|
}
|
|
|
|
const ulStart = performance.now();
|
|
|
|
// Страховка от вечного зависания аплоада при обрыве туннеля
|
|
const uploadTimeoutId = setTimeout(() => controller.abort(), 15000);
|
|
|
|
await fetch(`${baseUrl}/api/speedtest/upload?nocache=${Date.now()}`, {
|
|
method: "POST",
|
|
body: uploadPayload,
|
|
signal,
|
|
headers: {
|
|
"Content-Type": "application/octet-stream",
|
|
},
|
|
});
|
|
|
|
clearTimeout(uploadTimeoutId);
|
|
|
|
const ulTime = (performance.now() - ulStart) / 1000;
|
|
const ulMbps = (UPLOAD_SIZE * 8) / (1024 * 1024) / ulTime;
|
|
setMetrics((m) => ({ ...m, upload: Number(ulMbps.toFixed(2)) }));
|
|
|
|
setStatus("done");
|
|
} catch (err: any) {
|
|
if (err.name === "AbortError") {
|
|
console.warn("Speedtest aborted by timeout.");
|
|
} else {
|
|
console.error("Speedtest error:", err);
|
|
}
|
|
setStatus("error");
|
|
} finally {
|
|
abortControllerRef.current = null;
|
|
}
|
|
};
|
|
|
|
const isRunning =
|
|
status !== "idle" && status !== "done" && status !== "error";
|
|
|
|
return (
|
|
<div className="w-full max-w-3xl mx-auto bg-card/40 backdrop-blur-xl border border-border/50 rounded-[2rem] p-8 shadow-2xl relative overflow-hidden font-mono">
|
|
{/* Декоративный фон */}
|
|
<div className="absolute top-0 right-0 w-64 h-64 bg-primary/10 blur-[80px] rounded-full pointer-events-none -z-10" />
|
|
|
|
<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="text-primary size-6" />
|
|
Uplink Diagnostics
|
|
</h2>
|
|
<p className="text-muted-foreground text-sm mt-2">
|
|
Direct measurement through Netrunner MUX tunnel.
|
|
</p>
|
|
</div>
|
|
|
|
<CyberButton onClick={runTest} disabled={isRunning} variant="primary">
|
|
{!isRunning ? (
|
|
<span className="flex items-center gap-2">
|
|
<Activity className="size-4" />{" "}
|
|
{status === "error" ? "RETRY TEST" : "INITIATE TEST"}
|
|
</span>
|
|
) : (
|
|
<span className="flex items-center gap-2 animate-pulse">
|
|
<RefreshCw className="size-4 animate-spin" />{" "}
|
|
{status.toUpperCase()}...
|
|
</span>
|
|
)}
|
|
</CyberButton>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
{/* PING */}
|
|
<div
|
|
className={cn(
|
|
"p-6 rounded-2xl border transition-colors",
|
|
status === "pinging"
|
|
? "bg-primary/10 border-primary/50"
|
|
: "bg-muted/20 border-border/50",
|
|
)}
|
|
>
|
|
<div className="text-muted-foreground text-xs uppercase tracking-widest mb-2 flex items-center gap-2">
|
|
<Activity className="size-4 text-secondary" /> Latency
|
|
</div>
|
|
<div className="text-4xl font-black text-foreground">
|
|
{metrics.ping}{" "}
|
|
<span className="text-sm text-muted-foreground">ms</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* DOWNLOAD */}
|
|
<div
|
|
className={cn(
|
|
"p-6 rounded-2xl border transition-colors",
|
|
status === "downloading"
|
|
? "bg-primary/10 border-primary/50"
|
|
: "bg-muted/20 border-border/50",
|
|
)}
|
|
>
|
|
<div className="text-muted-foreground text-xs uppercase tracking-widest mb-2 flex items-center gap-2">
|
|
<Download className="size-4 text-green-500" /> Download
|
|
</div>
|
|
<div className="text-4xl font-black text-foreground">
|
|
{metrics.download}{" "}
|
|
<span className="text-sm text-muted-foreground">Mbps</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* UPLOAD */}
|
|
<div
|
|
className={cn(
|
|
"p-6 rounded-2xl border transition-colors",
|
|
status === "uploading"
|
|
? "bg-primary/10 border-primary/50"
|
|
: "bg-muted/20 border-border/50",
|
|
)}
|
|
>
|
|
<div className="text-muted-foreground text-xs uppercase tracking-widest mb-2 flex items-center gap-2">
|
|
<Upload className="size-4 text-primary" /> Upload
|
|
</div>
|
|
<div className="text-4xl font-black text-foreground">
|
|
{metrics.upload}{" "}
|
|
<span className="text-sm text-muted-foreground">Mbps</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|