386 lines
12 KiB
TypeScript
386 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useRef } 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"];
|
||
}
|
||
|
||
/**
|
||
* Виджет спидтеста: пинг + скорость скачивания/отдачи через Rust-шлюз proxy-ws
|
||
* (эндпоинты /api/speedtest/ping|download|upload, см. next.config.ts rewrites).
|
||
*
|
||
* В dev-режиме (NODE_ENV !== "production") реальные запросы не делаются — вместо
|
||
* этого крутится runMockTest() с рандомными числами и искусственными задержками,
|
||
* чтобы можно было верстать/проверять анимацию без поднятого локального proxy-ws.
|
||
* В проде runRealTest() честно измеряет: пинг — 5 HEAD-подобных запросов подряд,
|
||
* скачивание — читает поток ответа чанками и на лету пересчитывает Mbps раз в 500мс
|
||
* (с потолком в 10 секунд, чтобы не скачивать бесконечно), отдача — грузит 10MB
|
||
* случайных байт с таймаутом 15 секунд через AbortController.
|
||
*/
|
||
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 {
|
||
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;
|
||
}
|
||
}
|
||
if (pingSuccessCount === 0) throw new Error("Ping failed");
|
||
setMetrics((m) => ({
|
||
...m,
|
||
ping: Math.round(totalPing / pingSuccessCount),
|
||
}));
|
||
|
||
setStatus("downloading");
|
||
const dlStart = performance.now();
|
||
const dlRes = await fetch(
|
||
`${baseUrl}/api/speedtest/download?nc=${Date.now()}`,
|
||
{ cache: "no-store", signal },
|
||
);
|
||
const reader = dlRes.body?.getReader();
|
||
let receivedBytes = 0;
|
||
const MAX_DL_TIME_MS = 10000;
|
||
|
||
if (reader) {
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (value) receivedBytes += value.length;
|
||
|
||
const timeElapsed = performance.now() - dlStart;
|
||
if (timeElapsed > 500) {
|
||
const currentMbps =
|
||
(receivedBytes * 8) / (1024 * 1024) / (timeElapsed / 1000);
|
||
setMetrics((m) => ({
|
||
...m,
|
||
download: Number(currentMbps.toFixed(2)),
|
||
}));
|
||
}
|
||
|
||
if (done || timeElapsed > MAX_DL_TIME_MS) {
|
||
if (!done) await reader.cancel();
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
setStatus("uploading");
|
||
const UPLOAD_SIZE = 10 * 1024 * 1024;
|
||
const uploadPayload = new Uint8Array(UPLOAD_SIZE);
|
||
const chunk = new Uint8Array(65536);
|
||
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?nc=${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.error("Speedtest error:", err);
|
||
setStatus("error");
|
||
} finally {
|
||
abortControllerRef.current = null;
|
||
}
|
||
};
|
||
|
||
const runTest = () => {
|
||
if (process.env.NODE_ENV !== "production") {
|
||
runMockTest();
|
||
} else {
|
||
runRealTest();
|
||
}
|
||
};
|
||
|
||
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>
|
||
);
|
||
}
|