"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("idle"); const [metrics, setMetrics] = useState({ ping: 0, download: 0.0, upload: 0.0, }); const abortControllerRef = useRef(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 (
{isRunning && ( )}

{dict.title}

{process.env.NODE_ENV !== "production" ? ( {dict.mockMode} ) : ( dict.prodMode )}

{!isRunning ? ( {" "} {status === "error" ? dict.btnError : status === "done" ? dict.btnDone : dict.btnIdle} ) : ( {getStatusLabel()} ... )}
} isActive={status === "pinging"} isDone={ status === "downloading" || status === "uploading" || status === "done" } color="text-secondary" glowColor="shadow-secondary/20" /> } isActive={status === "downloading"} isDone={status === "uploading" || status === "done"} color="text-green-500" glowColor="shadow-green-500/20" /> } isActive={status === "uploading"} isDone={status === "done"} color="text-primary" glowColor="shadow-primary/20" />
); } function MetricCard({ title, value, unit, icon, isActive, isDone, color, glowColor, }: any) { return ( {isActive && ( )}
{icon} {title} {isActive && ( )}
{value === 0 && !isDone && !isActive ? "--" : value}{" "} {unit}
); }