import { useQuery } from "@tanstack/react-query"; import { m } from "framer-motion"; import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis, } from "recharts"; import { Card, CardContent } from "@/components/ui/card"; import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { useWebSocketContext } from "@/hooks/use-websocket-context"; import { formatBytes } from "@/lib/format"; import { fetchLoginUser, fetchServerMetrics, fetchSetting, } from "@/lib/nezha-api"; import { cn, formatNezhaInfo, formatRelativeTime, formatTime, } from "@/lib/utils"; import type { MetricPeriod, NezhaServer, NezhaWebsocketResponse, } from "@/types/nezha-api"; import ChartSkeleton from "./loading/ChartSkeleton"; import { ServerDetailChartLoading } from "./loading/ServerDetailLoading"; import AnimatedCircularProgressBar from "./ui/animated-circular-progress-bar"; type ChartPeriod = "realtime" | MetricPeriod; type gpuChartData = { timeStamp: string; gpu: number; }; type cpuChartData = { timeStamp: string; cpu: number; }; type processChartData = { timeStamp: string; process: number; }; type diskChartData = { timeStamp: string; disk: number; }; type memChartData = { timeStamp: string; mem: number; swap: number; }; type networkChartData = { timeStamp: string; upload: number; download: number; }; type connectChartData = { timeStamp: string; tcp: number; udp: number; }; const MIN_HISTORY_LOADING_MS = 300; const sleep = (ms: number) => new Promise((resolve) => { setTimeout(resolve, ms); }); function PeriodSelector({ selectedPeriod, onPeriodChange, isLogin, isTsdbEnabled, }: { selectedPeriod: ChartPeriod; onPeriodChange: (period: ChartPeriod) => void; isLogin: boolean; isTsdbEnabled: boolean; }) { const { t } = useTranslation(); const periods: { value: ChartPeriod; label: string }[] = [ { value: "realtime", label: t("serverDetailChart.realtime") }, { value: "1d", label: t("serverDetailChart.period1d") }, { value: "7d", label: t("serverDetailChart.period7d") }, { value: "30d", label: t("serverDetailChart.period30d") }, ]; return (
{periods.map((period) => { const isHistoryPeriod = period.value !== "realtime"; const isLockedByTsdb = !isTsdbEnabled && isHistoryPeriod; // Only realtime and 1d are available for non-logged-in users const isLockedByLogin = !isLockedByTsdb && !isLogin && period.value !== "realtime" && period.value !== "1d"; const isLocked = isLockedByTsdb || isLockedByLogin; const periodItem = (
{ if (!isLocked) { onPeriodChange(period.value); } }} className={cn( "relative cursor-pointer rounded-full px-3 py-1.5 text-xs font-medium transition-colors duration-300", selectedPeriod === period.value ? "text-foreground" : "text-muted-foreground hover:text-foreground", isLocked && "cursor-not-allowed opacity-40 grayscale", )} > {selectedPeriod === period.value && ( )}
{period.value === "realtime" && ( )} {period.label}
); if (isLockedByTsdb || isLockedByLogin) { return ( {periodItem} {isLockedByTsdb ? t( "serverDetailChart.tsdbRequired", "Enable TSDB to use historical data", ) : t( "serverDetailChart.loginRequired", "Please login to view", )} ); } return
{periodItem}
; })}
); } export default function ServerDetailChart({ server_id, }: { server_id: string; }) { const { lastMessage, connected, messageHistory } = useWebSocketContext(); const [selectedPeriod, setSelectedPeriod] = useState("realtime"); // Check if user is logged in const { data: userData, isError: isLoginError } = useQuery({ queryKey: ["login-user"], queryFn: () => fetchLoginUser(), refetchOnMount: false, refetchOnWindowFocus: true, refetchIntervalInBackground: true, refetchInterval: 1000 * 30, retry: 0, }); const isLogin = isLoginError ? false : userData ? !!userData?.data?.id && !!document.cookie : false; const { data: settingData } = useQuery({ queryKey: ["setting"], queryFn: () => fetchSetting(), refetchOnMount: true, refetchOnWindowFocus: true, }); const isTsdbEnabled = settingData?.data?.tsdb_enabled ?? true; useEffect(() => { if (!isTsdbEnabled && selectedPeriod !== "realtime") { setSelectedPeriod("realtime"); } }, [isTsdbEnabled, selectedPeriod]); // Reset period if user is not logged in and selected period is restricted useEffect(() => { if ( isTsdbEnabled && !isLogin && selectedPeriod !== "realtime" && selectedPeriod !== "1d" ) { setSelectedPeriod("1d"); } }, [isLogin, isTsdbEnabled, selectedPeriod]); if (!connected && !lastMessage) { return ; } const nezhaWsData = lastMessage ? (JSON.parse(lastMessage.data) as NezhaWebsocketResponse) : null; if (!nezhaWsData) { return ; } const server = nezhaWsData.servers.find((s) => s.id === Number(server_id)); if (!server) { return ; } const gpuStats = server.state.gpu || []; const gpuList = server.host.gpu || []; return (
{gpuStats.length >= 1 && gpuList.length === gpuStats.length ? gpuList.map((gpu, index) => ( )) : gpuStats.length > 0 ? gpuStats.map((gpu, index) => ( )) : null}
); } function useHistoricalData( serverId: number, metricName: string, period: ChartPeriod, transformData: (timestamp: number, value: number) => T, ) { const [historicalData, setHistoricalData] = useState([]); const [isLoading, setIsLoading] = useState(false); const [displayData, setDisplayData] = useState([]); const [loadedPeriod, setLoadedPeriod] = useState("realtime"); useEffect(() => { let cancelled = false; if (period === "realtime") { setHistoricalData([]); setDisplayData([]); setIsLoading(false); setLoadedPeriod("realtime"); return () => { cancelled = true; }; } const fetchData = async () => { const loadingStartedAt = Date.now(); setIsLoading(true); try { const response = await fetchServerMetrics( serverId, metricName as Parameters[1], period as MetricPeriod, ); if (response.success && response.data?.data_points) { const transformedData = response.data.data_points.map((point) => transformData(point.ts, point.value), ); if (!cancelled) { setHistoricalData(transformedData); setDisplayData(transformedData); } } } catch (error) { console.error(`Failed to fetch ${metricName} metrics:`, error); } finally { const elapsed = Date.now() - loadingStartedAt; if (elapsed < MIN_HISTORY_LOADING_MS) { await sleep(MIN_HISTORY_LOADING_MS - elapsed); } if (!cancelled) { setIsLoading(false); setLoadedPeriod(period); } } }; fetchData(); return () => { cancelled = true; }; }, [serverId, metricName, period, transformData]); const isHistoricalLoading = period !== "realtime" && (isLoading || loadedPeriod !== period); return { historicalData, displayData, isLoading: isHistoricalLoading }; } function GpuChart({ id, index, gpuStat, gpuName, messageHistory, period, }: { now: number; id: number; index: number; gpuStat: number; gpuName?: string; messageHistory: { data: string }[]; period: ChartPeriod; }) { const [gpuChartData, setGpuChartData] = useState([]); const hasInitialized = useRef(false); const [historyLoaded, setHistoryLoaded] = useState(false); const customBackgroundImage = (window.CustomBackgroundImage as string) !== "" ? window.CustomBackgroundImage : undefined; const transformGpuData = useMemo( () => (timestamp: number, value: number) => ({ timeStamp: timestamp.toString(), gpu: value, }), [], ); const { displayData: gpuHistoricalData, isLoading } = useHistoricalData(id, "gpu", period, transformGpuData); // 初始化历史数据 useEffect(() => { if ( period === "realtime" && !hasInitialized.current && messageHistory.length > 0 ) { const historyData = messageHistory .map((msg) => { const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse; const server = wsData.servers.find((s) => s.id === id); if (!server) return null; const { gpu } = formatNezhaInfo(wsData.now, server); return { timeStamp: wsData.now.toString(), gpu: gpu[index], }; }) .filter((item): item is gpuChartData => item !== null) .reverse(); setGpuChartData(historyData); hasInitialized.current = true; setHistoryLoaded(true); } }, [messageHistory, id, index, period]); // Reset when switching to realtime useEffect(() => { if (period === "realtime") { hasInitialized.current = false; setHistoryLoaded(false); } }, [period]); useEffect(() => { if (gpuStat && historyLoaded && period === "realtime") { const timestamp = Date.now().toString(); setGpuChartData((prevData) => { let newData = [] as gpuChartData[]; if (prevData.length === 0) { newData = [ { timeStamp: timestamp, gpu: gpuStat }, { timeStamp: timestamp, gpu: gpuStat }, ]; } else { newData = [...prevData, { timeStamp: timestamp, gpu: gpuStat }]; if (newData.length > 30) { newData.shift(); } } return newData; }); } }, [gpuStat, historyLoaded, period]); const chartConfig = { gpu: { label: "GPU", }, } satisfies ChartConfig; const displayData = period === "realtime" ? gpuChartData : gpuHistoricalData; return (
{!gpuName &&

GPU

} {gpuName &&

GPU: {gpuName}

}

{gpuStat.toFixed(2)}%

{isLoading ? ( ) : ( formatRelativeTime(value)} /> `${value}%`} /> { return formatTime( Number(payload[0]?.payload?.timeStamp), ); }} formatter={(value) => (
GPU {Number(value).toFixed(1)}%
)} /> } />
)}
); } function CpuChart({ now, data, messageHistory, period, }: { now: number; data: NezhaServer; messageHistory: { data: string }[]; period: ChartPeriod; }) { const [cpuChartData, setCpuChartData] = useState([]); const hasInitialized = useRef(false); const [historyLoaded, setHistoryLoaded] = useState(false); const { cpu } = formatNezhaInfo(now, data); const customBackgroundImage = (window.CustomBackgroundImage as string) !== "" ? window.CustomBackgroundImage : undefined; const transformCpuData = useMemo( () => (timestamp: number, value: number) => ({ timeStamp: timestamp.toString(), cpu: value, }), [], ); const { displayData: cpuHistoricalData, isLoading } = useHistoricalData(data.id, "cpu", period, transformCpuData); // 初始化历史数据 useEffect(() => { if ( period === "realtime" && !hasInitialized.current && messageHistory.length > 0 ) { const historyData = messageHistory .map((msg) => { const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse; const server = wsData.servers.find((s) => s.id === data.id); if (!server) return null; const { cpu } = formatNezhaInfo(wsData.now, server); return { timeStamp: wsData.now.toString(), cpu: cpu, }; }) .filter((item): item is cpuChartData => item !== null) .reverse(); setCpuChartData(historyData); hasInitialized.current = true; setHistoryLoaded(true); } }, [messageHistory, data.id, period]); // Reset when switching to realtime useEffect(() => { if (period === "realtime") { hasInitialized.current = false; setHistoryLoaded(false); } }, [period]); // 更新实时数据 useEffect(() => { if (data && historyLoaded && period === "realtime") { const timestamp = Date.now().toString(); setCpuChartData((prevData) => { let newData = [] as cpuChartData[]; if (prevData.length === 0) { newData = [ { timeStamp: timestamp, cpu: cpu }, { timeStamp: timestamp, cpu: cpu }, ]; } else { newData = [...prevData, { timeStamp: timestamp, cpu: cpu }]; if (newData.length > 30) { newData.shift(); } } return newData; }); } }, [data, historyLoaded, cpu, period]); const chartConfig = { cpu: { label: "CPU", }, } satisfies ChartConfig; const displayData = period === "realtime" ? cpuChartData : cpuHistoricalData; return (

CPU

{cpu.toFixed(2)}%

{isLoading ? ( ) : ( formatRelativeTime(value)} /> `${value}%`} /> { return formatTime( Number(payload[0]?.payload?.timeStamp), ); }} formatter={(value) => (
CPU {Number(value).toFixed(1)}%
)} /> } />
)}
); } function ProcessChart({ now, data, messageHistory, period, }: { now: number; data: NezhaServer; messageHistory: { data: string }[]; period: ChartPeriod; }) { const { t } = useTranslation(); const [processChartData, setProcessChartData] = useState( [] as processChartData[], ); const hasInitialized = useRef(false); const [historyLoaded, setHistoryLoaded] = useState(false); const customBackgroundImage = (window.CustomBackgroundImage as string) !== "" ? window.CustomBackgroundImage : undefined; const { process } = formatNezhaInfo(now, data); const transformProcessData = useMemo( () => (timestamp: number, value: number) => ({ timeStamp: timestamp.toString(), process: value, }), [], ); const { displayData: processHistoricalData, isLoading } = useHistoricalData( data.id, "process_count", period, transformProcessData, ); // 初始化历史数据 useEffect(() => { if ( period === "realtime" && !hasInitialized.current && messageHistory.length > 0 ) { const historyData = messageHistory .map((msg) => { const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse; const server = wsData.servers.find((s) => s.id === data.id); if (!server) return null; const { process } = formatNezhaInfo(wsData.now, server); return { timeStamp: wsData.now.toString(), process, }; }) .filter((item): item is processChartData => item !== null) .reverse(); setProcessChartData(historyData); hasInitialized.current = true; setHistoryLoaded(true); } }, [messageHistory, data.id, period]); // Reset when switching to realtime useEffect(() => { if (period === "realtime") { hasInitialized.current = false; setHistoryLoaded(false); } }, [period]); // 修改实时数据更新逻辑 useEffect(() => { if (data && historyLoaded && period === "realtime") { const timestamp = Date.now().toString(); setProcessChartData((prevData) => { let newData = [] as processChartData[]; if (prevData.length === 0) { newData = [ { timeStamp: timestamp, process }, { timeStamp: timestamp, process }, ]; } else { newData = [...prevData, { timeStamp: timestamp, process }]; if (newData.length > 30) { newData.shift(); } } return newData; }); } }, [data, historyLoaded, process, period]); const chartConfig = { process: { label: "Process", }, } satisfies ChartConfig; const displayData = period === "realtime" ? processChartData : processHistoricalData; return (

{t("serverDetailChart.process")}

{process}

{isLoading ? ( ) : ( formatRelativeTime(value)} /> { return formatTime( Number(payload[0]?.payload?.timeStamp), ); }} formatter={(value) => (
{t("serverDetailChart.process")} {Number(value).toFixed(0)}
)} /> } />
)}
); } function MemChart({ now, data, messageHistory, period, }: { now: number; data: NezhaServer; messageHistory: { data: string }[]; period: ChartPeriod; }) { const { t } = useTranslation(); const [memChartData, setMemChartData] = useState([] as memChartData[]); const hasInitialized = useRef(false); const [historyLoaded, setHistoryLoaded] = useState(false); const customBackgroundImage = (window.CustomBackgroundImage as string) !== "" ? window.CustomBackgroundImage : undefined; const { mem, swap } = formatNezhaInfo(now, data); // For memory, we fetch memory and swap separately and combine them const [memHistoricalData, setMemHistoricalData] = useState( [], ); const [isLoadingMem, setIsLoadingMem] = useState(false); const [loadedPeriodMem, setLoadedPeriodMem] = useState("realtime"); useEffect(() => { let cancelled = false; if (period === "realtime") { setMemHistoricalData([]); setIsLoadingMem(false); setLoadedPeriodMem("realtime"); return () => { cancelled = true; }; } const fetchMemData = async () => { const loadingStartedAt = Date.now(); setIsLoadingMem(true); try { const [memResponse, swapResponse] = await Promise.all([ fetchServerMetrics(data.id, "memory", period as MetricPeriod), fetchServerMetrics(data.id, "swap", period as MetricPeriod), ]); if (memResponse.success && memResponse.data?.data_points) { const swapMap = new Map(); if (swapResponse.success && swapResponse.data?.data_points) { for (const point of swapResponse.data.data_points) { // Convert bytes to percentage const swapPercent = data.host.swap_total > 0 ? (point.value / data.host.swap_total) * 100 : 0; swapMap.set(point.ts, swapPercent); } } const combinedData = memResponse.data.data_points.map((point) => { // Convert bytes to percentage const memPercent = data.host.mem_total > 0 ? (point.value / data.host.mem_total) * 100 : 0; return { timeStamp: point.ts.toString(), mem: memPercent, swap: swapMap.get(point.ts) || 0, }; }); if (!cancelled) { setMemHistoricalData(combinedData); } } } catch (error) { console.error("Failed to fetch memory metrics:", error); } finally { const elapsed = Date.now() - loadingStartedAt; if (elapsed < MIN_HISTORY_LOADING_MS) { await sleep(MIN_HISTORY_LOADING_MS - elapsed); } if (!cancelled) { setIsLoadingMem(false); setLoadedPeriodMem(period); } } }; fetchMemData(); return () => { cancelled = true; }; }, [data.id, period, data.host.mem_total, data.host.swap_total]); // 初始化历史数据 useEffect(() => { if ( period === "realtime" && !hasInitialized.current && messageHistory.length > 0 ) { const historyData = messageHistory .map((msg) => { const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse; const server = wsData.servers.find((s) => s.id === data.id); if (!server) return null; const { mem, swap } = formatNezhaInfo(wsData.now, server); return { timeStamp: wsData.now.toString(), mem, swap, }; }) .filter((item): item is memChartData => item !== null) .reverse(); setMemChartData(historyData); hasInitialized.current = true; setHistoryLoaded(true); } }, [messageHistory, data.id, period]); // Reset when switching to realtime useEffect(() => { if (period === "realtime") { hasInitialized.current = false; setHistoryLoaded(false); } }, [period]); // 修改实时数据更新逻辑 useEffect(() => { if (data && historyLoaded && period === "realtime") { const timestamp = Date.now().toString(); setMemChartData((prevData) => { let newData = [] as memChartData[]; if (prevData.length === 0) { newData = [ { timeStamp: timestamp, mem, swap }, { timeStamp: timestamp, mem, swap }, ]; } else { newData = [...prevData, { timeStamp: timestamp, mem, swap }]; if (newData.length > 30) { newData.shift(); } } return newData; }); } }, [data, historyLoaded, mem, swap, period]); const chartConfig = { mem: { label: "Mem", }, swap: { label: "Swap", }, } satisfies ChartConfig; const displayData = period === "realtime" ? memChartData : memHistoricalData; const isMemLoading = period !== "realtime" && (isLoadingMem || loadedPeriodMem !== period); return (

{t("serverDetailChart.mem")}

{mem.toFixed(0)}%

{t("serverDetailChart.swap")}

{swap.toFixed(0)}%

{formatBytes(data.state.mem_used)} /{" "} {formatBytes(data.host.mem_total)}
{data.host.swap_total ? ( <> swap: {formatBytes(data.state.swap_used)} /{" "} {formatBytes(data.host.swap_total)} ) : ( <>no swap )}
{isMemLoading ? ( ) : ( formatRelativeTime(value)} /> `${value}%`} /> { return formatTime( Number(payload[0]?.payload?.timeStamp), ); }} formatter={(value, name) => { const label = name === "mem" ? t("serverDetailChart.mem") : t("serverDetailChart.swap"); return (
{label} {Number(value).toFixed(1)}%
); }} /> } />
)}
); } function DiskChart({ now, data, messageHistory, period, }: { now: number; data: NezhaServer; messageHistory: { data: string }[]; period: ChartPeriod; }) { const { t } = useTranslation(); const [diskChartData, setDiskChartData] = useState([] as diskChartData[]); const hasInitialized = useRef(false); const [historyLoaded, setHistoryLoaded] = useState(false); const customBackgroundImage = (window.CustomBackgroundImage as string) !== "" ? window.CustomBackgroundImage : undefined; const { disk } = formatNezhaInfo(now, data); const transformDiskData = useMemo( () => (timestamp: number, value: number) => { // Convert bytes to percentage const diskPercent = data.host.disk_total > 0 ? (value / data.host.disk_total) * 100 : 0; return { timeStamp: timestamp.toString(), disk: diskPercent, }; }, [data.host.disk_total], ); const { displayData: diskHistoricalData, isLoading } = useHistoricalData( data.id, "disk", period, transformDiskData, ); // 初始化历史数据 useEffect(() => { if ( period === "realtime" && !hasInitialized.current && messageHistory.length > 0 ) { const historyData = messageHistory .map((msg) => { const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse; const server = wsData.servers.find((s) => s.id === data.id); if (!server) return null; const { disk } = formatNezhaInfo(wsData.now, server); return { timeStamp: wsData.now.toString(), disk, }; }) .filter((item): item is diskChartData => item !== null) .reverse(); setDiskChartData(historyData); hasInitialized.current = true; setHistoryLoaded(true); } }, [messageHistory, data.id, period]); // Reset when switching to realtime useEffect(() => { if (period === "realtime") { hasInitialized.current = false; setHistoryLoaded(false); } }, [period]); // 修改实时数据更新逻辑 useEffect(() => { if (data && historyLoaded && period === "realtime") { const timestamp = Date.now().toString(); setDiskChartData((prevData) => { let newData = [] as diskChartData[]; if (prevData.length === 0) { newData = [ { timeStamp: timestamp, disk }, { timeStamp: timestamp, disk }, ]; } else { newData = [...prevData, { timeStamp: timestamp, disk }]; if (newData.length > 30) { newData.shift(); } } return newData; }); } }, [data, historyLoaded, disk, period]); const chartConfig = { disk: { label: "Disk", }, } satisfies ChartConfig; const displayData = period === "realtime" ? diskChartData : diskHistoricalData; return (

{t("serverDetailChart.disk")}

{disk.toFixed(0)}%

{formatBytes(data.state.disk_used)} /{" "} {formatBytes(data.host.disk_total)}
{isLoading ? ( ) : ( formatRelativeTime(value)} /> `${value}%`} /> { return formatTime( Number(payload[0]?.payload?.timeStamp), ); }} formatter={(value) => (
{t("serverDetailChart.disk")} {Number(value).toFixed(1)}%
)} /> } />
)}
); } function NetworkChart({ now, data, messageHistory, period, }: { now: number; data: NezhaServer; messageHistory: { data: string }[]; period: ChartPeriod; }) { const { t } = useTranslation(); const [networkChartData, setNetworkChartData] = useState( [] as networkChartData[], ); const hasInitialized = useRef(false); const [historyLoaded, setHistoryLoaded] = useState(false); const customBackgroundImage = (window.CustomBackgroundImage as string) !== "" ? window.CustomBackgroundImage : undefined; const { up, down } = formatNezhaInfo(now, data); // For network, we fetch upload and download separately and combine them const [networkHistoricalData, setNetworkHistoricalData] = useState< networkChartData[] >([]); const [isLoadingNetwork, setIsLoadingNetwork] = useState(false); const [loadedPeriodNetwork, setLoadedPeriodNetwork] = useState("realtime"); useEffect(() => { let cancelled = false; if (period === "realtime") { setNetworkHistoricalData([]); setIsLoadingNetwork(false); setLoadedPeriodNetwork("realtime"); return () => { cancelled = true; }; } const fetchNetworkData = async () => { const loadingStartedAt = Date.now(); setIsLoadingNetwork(true); try { const [uploadResponse, downloadResponse] = await Promise.all([ fetchServerMetrics(data.id, "net_out_speed", period as MetricPeriod), fetchServerMetrics(data.id, "net_in_speed", period as MetricPeriod), ]); if (uploadResponse.success && uploadResponse.data?.data_points) { const downloadMap = new Map(); if (downloadResponse.success && downloadResponse.data?.data_points) { for (const point of downloadResponse.data.data_points) { // Convert bytes to MB downloadMap.set(point.ts, point.value / 1024 / 1024); } } const combinedData = uploadResponse.data.data_points.map((point) => ({ timeStamp: point.ts.toString(), upload: point.value / 1024 / 1024, // Convert bytes to MB download: downloadMap.get(point.ts) || 0, })); if (!cancelled) { setNetworkHistoricalData(combinedData); } } } catch (error) { console.error("Failed to fetch network metrics:", error); } finally { const elapsed = Date.now() - loadingStartedAt; if (elapsed < MIN_HISTORY_LOADING_MS) { await sleep(MIN_HISTORY_LOADING_MS - elapsed); } if (!cancelled) { setIsLoadingNetwork(false); setLoadedPeriodNetwork(period); } } }; fetchNetworkData(); return () => { cancelled = true; }; }, [data.id, period]); // 初始化历史数据 useEffect(() => { if ( period === "realtime" && !hasInitialized.current && messageHistory.length > 0 ) { const historyData = messageHistory .map((msg) => { const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse; const server = wsData.servers.find((s) => s.id === data.id); if (!server) return null; const { up, down } = formatNezhaInfo(wsData.now, server); return { timeStamp: wsData.now.toString(), upload: up, download: down, }; }) .filter((item): item is networkChartData => item !== null) .reverse(); setNetworkChartData(historyData); hasInitialized.current = true; setHistoryLoaded(true); } }, [messageHistory, data.id, period]); // Reset when switching to realtime useEffect(() => { if (period === "realtime") { hasInitialized.current = false; setHistoryLoaded(false); } }, [period]); // 修改实时数据更新逻辑 useEffect(() => { if (data && historyLoaded && period === "realtime") { const timestamp = Date.now().toString(); setNetworkChartData((prevData) => { let newData = [] as networkChartData[]; if (prevData.length === 0) { newData = [ { timeStamp: timestamp, upload: up, download: down }, { timeStamp: timestamp, upload: up, download: down }, ]; } else { newData = [ ...prevData, { timeStamp: timestamp, upload: up, download: down }, ]; if (newData.length > 30) { newData.shift(); } } return newData; }); } }, [data, historyLoaded, down, up, period]); const displayData = period === "realtime" ? networkChartData : networkHistoricalData; const isNetworkLoading = period !== "realtime" && (isLoadingNetwork || loadedPeriodNetwork !== period); let maxDownload = Math.max(...displayData.map((item) => item.download)); maxDownload = Math.ceil(maxDownload); if (maxDownload < 1) { maxDownload = 1; } const chartConfig = { upload: { label: "Upload", }, download: { label: "Download", }, } satisfies ChartConfig; return (

{t("serverDetailChart.upload")}

{up >= 1024 ? `${(up / 1024).toFixed(2)}G/s` : up >= 1 ? `${up.toFixed(2)}M/s` : `${(up * 1024).toFixed(2)}K/s`}

{t("serverDetailChart.download")}

{down >= 1024 ? `${(down / 1024).toFixed(2)}G/s` : down >= 1 ? `${down.toFixed(2)}M/s` : `${(down * 1024).toFixed(2)}K/s`}

{isNetworkLoading ? ( ) : ( formatRelativeTime(value)} /> `${value.toFixed(0)}M/s`} /> { return formatTime( Number(payload[0]?.payload?.timeStamp), ); }} formatter={(value, name) => { const label = name === "upload" ? t("serverDetailChart.upload") : t("serverDetailChart.download"); return (
{label} {Number(value).toFixed(2)} MB/s
); }} /> } />
)}
); } function ConnectChart({ now, data, messageHistory, period, }: { now: number; data: NezhaServer; messageHistory: { data: string }[]; period: ChartPeriod; }) { const [connectChartData, setConnectChartData] = useState( [] as connectChartData[], ); const hasInitialized = useRef(false); const [historyLoaded, setHistoryLoaded] = useState(false); const customBackgroundImage = (window.CustomBackgroundImage as string) !== "" ? window.CustomBackgroundImage : undefined; const { tcp, udp } = formatNezhaInfo(now, data); // For connections, we fetch TCP and UDP separately and combine them const [connectHistoricalData, setConnectHistoricalData] = useState< connectChartData[] >([]); const [isLoadingConnect, setIsLoadingConnect] = useState(false); const [loadedPeriodConnect, setLoadedPeriodConnect] = useState("realtime"); useEffect(() => { let cancelled = false; if (period === "realtime") { setConnectHistoricalData([]); setIsLoadingConnect(false); setLoadedPeriodConnect("realtime"); return () => { cancelled = true; }; } const fetchConnectData = async () => { const loadingStartedAt = Date.now(); setIsLoadingConnect(true); try { const [tcpResponse, udpResponse] = await Promise.all([ fetchServerMetrics(data.id, "tcp_conn", period as MetricPeriod), fetchServerMetrics(data.id, "udp_conn", period as MetricPeriod), ]); if (tcpResponse.success && tcpResponse.data?.data_points) { const udpMap = new Map(); if (udpResponse.success && udpResponse.data?.data_points) { for (const point of udpResponse.data.data_points) { udpMap.set(point.ts, point.value); } } const combinedData = tcpResponse.data.data_points.map((point) => ({ timeStamp: point.ts.toString(), tcp: point.value, udp: udpMap.get(point.ts) || 0, })); if (!cancelled) { setConnectHistoricalData(combinedData); } } } catch (error) { console.error("Failed to fetch connection metrics:", error); } finally { const elapsed = Date.now() - loadingStartedAt; if (elapsed < MIN_HISTORY_LOADING_MS) { await sleep(MIN_HISTORY_LOADING_MS - elapsed); } if (!cancelled) { setIsLoadingConnect(false); setLoadedPeriodConnect(period); } } }; fetchConnectData(); return () => { cancelled = true; }; }, [data.id, period]); // 初始化历史数据 useEffect(() => { if ( period === "realtime" && !hasInitialized.current && messageHistory.length > 0 ) { const historyData = messageHistory .map((msg) => { const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse; const server = wsData.servers.find((s) => s.id === data.id); if (!server) return null; const { tcp, udp } = formatNezhaInfo(wsData.now, server); return { timeStamp: wsData.now.toString(), tcp, udp, }; }) .filter((item): item is connectChartData => item !== null) .reverse(); setConnectChartData(historyData); hasInitialized.current = true; setHistoryLoaded(true); } }, [messageHistory, data.id, period]); // Reset when switching to realtime useEffect(() => { if (period === "realtime") { hasInitialized.current = false; setHistoryLoaded(false); } }, [period]); // 修改实时数据更新逻辑 useEffect(() => { if (data && historyLoaded && period === "realtime") { const timestamp = Date.now().toString(); setConnectChartData((prevData) => { let newData = [] as connectChartData[]; if (prevData.length === 0) { newData = [ { timeStamp: timestamp, tcp, udp }, { timeStamp: timestamp, tcp, udp }, ]; } else { newData = [...prevData, { timeStamp: timestamp, tcp, udp }]; if (newData.length > 30) { newData.shift(); } } return newData; }); } }, [data, historyLoaded, tcp, udp, period]); const chartConfig = { tcp: { label: "TCP", }, udp: { label: "UDP", }, } satisfies ChartConfig; const displayData = period === "realtime" ? connectChartData : connectHistoricalData; const isConnectLoading = period !== "realtime" && (isLoadingConnect || loadedPeriodConnect !== period); return (

TCP

{tcp}

UDP

{udp}

{isConnectLoading ? ( ) : ( formatRelativeTime(value)} /> { return formatTime( Number(payload[0]?.payload?.timeStamp), ); }} formatter={(value, name) => { const label = name === "tcp" ? "TCP" : "UDP"; return (
{label} {Number(value).toFixed(0)}
); }} /> } />
)}
); }