Files
netrunner-landing/components/sections/SpeedtestClient.tsx
T
2026-07-02 23:13:24 +07:00

386 lines
12 KiB
TypeScript
Raw 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 } 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>
);
}