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

374 lines
11 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"];
}
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>
);
}