Files
netrunner-landing/components/sections/SpeedtestClient.tsx
T

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>
);
}