mirror of
https://github.com/Buriburizaem0n/nezha-dash-v1.git
synced 2026-05-06 05:48:41 +00:00
2108 lines
54 KiB
TypeScript
2108 lines
54 KiB
TypeScript
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<void>((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 (
|
|
<TooltipProvider delayDuration={120}>
|
|
<div className="flex gap-0.5 mb-3 flex-wrap sm:-mt-5 -mt-3 p-0.5 bg-muted dark:bg-muted/40 rounded-full w-fit border border-border/60 dark:border-border">
|
|
{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 = (
|
|
<div
|
|
onClick={() => {
|
|
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 && (
|
|
<m.div
|
|
layoutId="period-selector-active"
|
|
className="absolute inset-0 z-10 h-full w-full bg-white dark:bg-background rounded-full ring-1 ring-border/60 dark:ring-border/40"
|
|
transition={{ type: "spring", stiffness: 250, damping: 30 }}
|
|
/>
|
|
)}
|
|
<div className="relative z-20 flex items-center gap-1.5">
|
|
{period.value === "realtime" && (
|
|
<span className="inline-flex rounded-full h-1.5 w-1.5 bg-emerald-500 dark:bg-emerald-400"></span>
|
|
)}
|
|
{period.label}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
if (isLockedByTsdb || isLockedByLogin) {
|
|
return (
|
|
<Tooltip key={period.value}>
|
|
<TooltipTrigger asChild>{periodItem}</TooltipTrigger>
|
|
<TooltipContent>
|
|
{isLockedByTsdb
|
|
? t(
|
|
"serverDetailChart.tsdbRequired",
|
|
"Enable TSDB to use historical data",
|
|
)
|
|
: t(
|
|
"serverDetailChart.loginRequired",
|
|
"Please login to view",
|
|
)}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
return <div key={period.value}>{periodItem}</div>;
|
|
})}
|
|
</div>
|
|
</TooltipProvider>
|
|
);
|
|
}
|
|
|
|
export default function ServerDetailChart({
|
|
server_id,
|
|
}: {
|
|
server_id: string;
|
|
}) {
|
|
const { lastMessage, connected, messageHistory } = useWebSocketContext();
|
|
const [selectedPeriod, setSelectedPeriod] = useState<ChartPeriod>("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 <ServerDetailChartLoading />;
|
|
}
|
|
|
|
const nezhaWsData = lastMessage
|
|
? (JSON.parse(lastMessage.data) as NezhaWebsocketResponse)
|
|
: null;
|
|
|
|
if (!nezhaWsData) {
|
|
return <ServerDetailChartLoading />;
|
|
}
|
|
|
|
const server = nezhaWsData.servers.find((s) => s.id === Number(server_id));
|
|
|
|
if (!server) {
|
|
return <ServerDetailChartLoading />;
|
|
}
|
|
|
|
const gpuStats = server.state.gpu || [];
|
|
const gpuList = server.host.gpu || [];
|
|
|
|
return (
|
|
<section className="flex flex-col">
|
|
<PeriodSelector
|
|
selectedPeriod={selectedPeriod}
|
|
onPeriodChange={setSelectedPeriod}
|
|
isLogin={isLogin}
|
|
isTsdbEnabled={isTsdbEnabled}
|
|
/>
|
|
<section className="grid md:grid-cols-2 lg:grid-cols-3 grid-cols-1 gap-3 server-charts">
|
|
<CpuChart
|
|
now={nezhaWsData.now}
|
|
data={server}
|
|
messageHistory={messageHistory}
|
|
period={selectedPeriod}
|
|
/>
|
|
{gpuStats.length >= 1 && gpuList.length === gpuStats.length
|
|
? gpuList.map((gpu, index) => (
|
|
<GpuChart
|
|
index={index}
|
|
id={server.id}
|
|
now={nezhaWsData.now}
|
|
gpuStat={gpuStats[index]}
|
|
gpuName={gpu}
|
|
messageHistory={messageHistory}
|
|
period={selectedPeriod}
|
|
key={index}
|
|
/>
|
|
))
|
|
: gpuStats.length > 0
|
|
? gpuStats.map((gpu, index) => (
|
|
<GpuChart
|
|
index={index}
|
|
id={server.id}
|
|
now={nezhaWsData.now}
|
|
gpuStat={gpu}
|
|
gpuName={`#${index + 1}`}
|
|
messageHistory={messageHistory}
|
|
period={selectedPeriod}
|
|
key={index}
|
|
/>
|
|
))
|
|
: null}
|
|
<MemChart
|
|
now={nezhaWsData.now}
|
|
data={server}
|
|
messageHistory={messageHistory}
|
|
period={selectedPeriod}
|
|
/>
|
|
<DiskChart
|
|
now={nezhaWsData.now}
|
|
data={server}
|
|
messageHistory={messageHistory}
|
|
period={selectedPeriod}
|
|
/>
|
|
<ProcessChart
|
|
now={nezhaWsData.now}
|
|
data={server}
|
|
messageHistory={messageHistory}
|
|
period={selectedPeriod}
|
|
/>
|
|
<NetworkChart
|
|
now={nezhaWsData.now}
|
|
data={server}
|
|
messageHistory={messageHistory}
|
|
period={selectedPeriod}
|
|
/>
|
|
<ConnectChart
|
|
now={nezhaWsData.now}
|
|
data={server}
|
|
messageHistory={messageHistory}
|
|
period={selectedPeriod}
|
|
/>
|
|
</section>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function useHistoricalData<T>(
|
|
serverId: number,
|
|
metricName: string,
|
|
period: ChartPeriod,
|
|
transformData: (timestamp: number, value: number) => T,
|
|
) {
|
|
const [historicalData, setHistoricalData] = useState<T[]>([]);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [displayData, setDisplayData] = useState<T[]>([]);
|
|
const [loadedPeriod, setLoadedPeriod] = useState<ChartPeriod>("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<typeof fetchServerMetrics>[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<gpuChartData[]>([]);
|
|
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<gpuChartData>(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 (
|
|
<Card
|
|
className={cn({
|
|
"bg-card/70": customBackgroundImage,
|
|
})}
|
|
>
|
|
<CardContent className="px-6 py-3">
|
|
<section className="flex flex-col gap-1">
|
|
<div className="flex items-center justify-between">
|
|
<section className="flex flex-col items-center gap-2">
|
|
{!gpuName && <p className="text-md font-medium">GPU</p>}
|
|
{gpuName && <p className="text-xs mt-1 mb-1.5">GPU: {gpuName}</p>}
|
|
</section>
|
|
<section className="flex items-center gap-2">
|
|
<p className="text-xs text-end w-10 font-medium">
|
|
{gpuStat.toFixed(2)}%
|
|
</p>
|
|
<AnimatedCircularProgressBar
|
|
className="size-3 text-[0px]"
|
|
max={100}
|
|
min={0}
|
|
value={gpuStat}
|
|
primaryColor="hsl(var(--chart-3))"
|
|
/>
|
|
</section>
|
|
</div>
|
|
<ChartContainer
|
|
config={chartConfig}
|
|
className="aspect-auto h-[130px] w-full"
|
|
>
|
|
{isLoading ? (
|
|
<ChartSkeleton />
|
|
) : (
|
|
<AreaChart
|
|
syncId="serverDetailCharts"
|
|
accessibilityLayer
|
|
data={displayData}
|
|
margin={{
|
|
top: 12,
|
|
left: 12,
|
|
right: 12,
|
|
}}
|
|
>
|
|
<CartesianGrid vertical={false} />
|
|
<XAxis
|
|
dataKey="timeStamp"
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickMargin={8}
|
|
minTickGap={200}
|
|
interval="preserveStartEnd"
|
|
tickFormatter={(value) => formatRelativeTime(value)}
|
|
/>
|
|
<YAxis
|
|
tickLine={false}
|
|
axisLine={false}
|
|
mirror={true}
|
|
tickMargin={-15}
|
|
domain={[0, 100]}
|
|
tickFormatter={(value) => `${value}%`}
|
|
/>
|
|
<ChartTooltip
|
|
isAnimationActive={false}
|
|
content={
|
|
<ChartTooltipContent
|
|
indicator="dot"
|
|
labelFormatter={(_, payload) => {
|
|
return formatTime(
|
|
Number(payload[0]?.payload?.timeStamp),
|
|
);
|
|
}}
|
|
formatter={(value) => (
|
|
<div className="flex flex-1 items-center justify-between leading-none">
|
|
<span className="text-muted-foreground">GPU</span>
|
|
<span className="ml-2 font-medium text-foreground tabular-nums">
|
|
{Number(value).toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
)}
|
|
/>
|
|
}
|
|
/>
|
|
<Area
|
|
isAnimationActive={false}
|
|
dataKey="gpu"
|
|
type="step"
|
|
fill="hsl(var(--chart-3))"
|
|
fillOpacity={0.3}
|
|
stroke="hsl(var(--chart-3))"
|
|
/>
|
|
</AreaChart>
|
|
)}
|
|
</ChartContainer>
|
|
</section>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function CpuChart({
|
|
now,
|
|
data,
|
|
messageHistory,
|
|
period,
|
|
}: {
|
|
now: number;
|
|
data: NezhaServer;
|
|
messageHistory: { data: string }[];
|
|
period: ChartPeriod;
|
|
}) {
|
|
const [cpuChartData, setCpuChartData] = useState<cpuChartData[]>([]);
|
|
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<cpuChartData>(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 (
|
|
<Card
|
|
className={cn({
|
|
"bg-card/70": customBackgroundImage,
|
|
})}
|
|
>
|
|
<CardContent className="px-6 py-3">
|
|
<section className="flex flex-col gap-1">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-md font-medium">CPU</p>
|
|
<section className="flex items-center gap-2">
|
|
<p className="text-xs text-end w-10 font-medium">
|
|
{cpu.toFixed(2)}%
|
|
</p>
|
|
<AnimatedCircularProgressBar
|
|
className="size-3 text-[0px]"
|
|
max={100}
|
|
min={0}
|
|
value={cpu}
|
|
primaryColor="hsl(var(--chart-1))"
|
|
/>
|
|
</section>
|
|
</div>
|
|
<ChartContainer
|
|
config={chartConfig}
|
|
className="aspect-auto h-[130px] w-full"
|
|
>
|
|
{isLoading ? (
|
|
<ChartSkeleton />
|
|
) : (
|
|
<AreaChart
|
|
syncId="serverDetailCharts"
|
|
accessibilityLayer
|
|
data={displayData}
|
|
margin={{
|
|
top: 12,
|
|
left: 12,
|
|
right: 12,
|
|
}}
|
|
>
|
|
<CartesianGrid vertical={false} />
|
|
<XAxis
|
|
dataKey="timeStamp"
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickMargin={8}
|
|
minTickGap={200}
|
|
interval="preserveStartEnd"
|
|
tickFormatter={(value) => formatRelativeTime(value)}
|
|
/>
|
|
<YAxis
|
|
tickLine={false}
|
|
axisLine={false}
|
|
mirror={true}
|
|
tickMargin={-15}
|
|
domain={[0, 100]}
|
|
tickFormatter={(value) => `${value}%`}
|
|
/>
|
|
<ChartTooltip
|
|
isAnimationActive={false}
|
|
content={
|
|
<ChartTooltipContent
|
|
indicator="dot"
|
|
labelFormatter={(_, payload) => {
|
|
return formatTime(
|
|
Number(payload[0]?.payload?.timeStamp),
|
|
);
|
|
}}
|
|
formatter={(value) => (
|
|
<div className="flex flex-1 items-center justify-between leading-none">
|
|
<span className="text-muted-foreground">CPU</span>
|
|
<span className="ml-2 font-medium text-foreground tabular-nums">
|
|
{Number(value).toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
)}
|
|
/>
|
|
}
|
|
/>
|
|
<Area
|
|
isAnimationActive={false}
|
|
dataKey="cpu"
|
|
type="step"
|
|
fill="hsl(var(--chart-1))"
|
|
fillOpacity={0.3}
|
|
stroke="hsl(var(--chart-1))"
|
|
/>
|
|
</AreaChart>
|
|
)}
|
|
</ChartContainer>
|
|
</section>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
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<processChartData>(
|
|
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 (
|
|
<Card
|
|
className={cn({
|
|
"bg-card/70": customBackgroundImage,
|
|
})}
|
|
>
|
|
<CardContent className="px-6 py-3">
|
|
<section className="flex flex-col gap-1">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-md font-medium">
|
|
{t("serverDetailChart.process")}
|
|
</p>
|
|
<section className="flex items-center gap-2">
|
|
<p className="text-xs text-end w-10 font-medium">{process}</p>
|
|
</section>
|
|
</div>
|
|
<ChartContainer
|
|
config={chartConfig}
|
|
className="aspect-auto h-[130px] w-full"
|
|
>
|
|
{isLoading ? (
|
|
<ChartSkeleton />
|
|
) : (
|
|
<AreaChart
|
|
syncId="serverDetailCharts"
|
|
accessibilityLayer
|
|
data={displayData}
|
|
margin={{
|
|
top: 12,
|
|
left: 12,
|
|
right: 12,
|
|
}}
|
|
>
|
|
<CartesianGrid vertical={false} />
|
|
<XAxis
|
|
dataKey="timeStamp"
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickMargin={8}
|
|
minTickGap={200}
|
|
interval="preserveStartEnd"
|
|
tickFormatter={(value) => formatRelativeTime(value)}
|
|
/>
|
|
<YAxis
|
|
tickLine={false}
|
|
axisLine={false}
|
|
mirror={true}
|
|
tickMargin={-15}
|
|
/>
|
|
<ChartTooltip
|
|
isAnimationActive={false}
|
|
content={
|
|
<ChartTooltipContent
|
|
indicator={"dot"}
|
|
labelFormatter={(_, payload) => {
|
|
return formatTime(
|
|
Number(payload[0]?.payload?.timeStamp),
|
|
);
|
|
}}
|
|
formatter={(value) => (
|
|
<div className="flex flex-1 items-center justify-between leading-none">
|
|
<span className="text-muted-foreground">
|
|
{t("serverDetailChart.process")}
|
|
</span>
|
|
<span className="ml-2 font-medium text-foreground tabular-nums">
|
|
{Number(value).toFixed(0)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
/>
|
|
}
|
|
/>
|
|
<Area
|
|
isAnimationActive={false}
|
|
dataKey="process"
|
|
type="step"
|
|
fill="hsl(var(--chart-2))"
|
|
fillOpacity={0.3}
|
|
stroke="hsl(var(--chart-2))"
|
|
/>
|
|
</AreaChart>
|
|
)}
|
|
</ChartContainer>
|
|
</section>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
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<memChartData[]>(
|
|
[],
|
|
);
|
|
const [isLoadingMem, setIsLoadingMem] = useState(false);
|
|
const [loadedPeriodMem, setLoadedPeriodMem] =
|
|
useState<ChartPeriod>("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<number, number>();
|
|
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 (
|
|
<Card
|
|
className={cn({
|
|
"bg-card/70": customBackgroundImage,
|
|
})}
|
|
>
|
|
<CardContent className="px-6 py-3">
|
|
<section className="flex flex-col gap-1">
|
|
<div className="flex items-center justify-between">
|
|
<section className="flex items-center gap-4">
|
|
<div className="flex flex-col">
|
|
<p className=" text-xs text-muted-foreground">
|
|
{t("serverDetailChart.mem")}
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
<AnimatedCircularProgressBar
|
|
className="size-3 text-[0px]"
|
|
max={100}
|
|
min={0}
|
|
value={mem}
|
|
primaryColor="hsl(var(--chart-8))"
|
|
/>
|
|
<p className="text-xs font-medium">{mem.toFixed(0)}%</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col">
|
|
<p className=" text-xs text-muted-foreground">
|
|
{t("serverDetailChart.swap")}
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
<AnimatedCircularProgressBar
|
|
className="size-3 text-[0px]"
|
|
max={100}
|
|
min={0}
|
|
value={swap}
|
|
primaryColor="hsl(var(--chart-10))"
|
|
/>
|
|
<p className="text-xs font-medium">{swap.toFixed(0)}%</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<section className="flex flex-col items-end gap-0.5">
|
|
<div className="flex text-[11px] font-medium items-center gap-2">
|
|
{formatBytes(data.state.mem_used)} /{" "}
|
|
{formatBytes(data.host.mem_total)}
|
|
</div>
|
|
<div className="flex text-[11px] font-medium items-center gap-2">
|
|
{data.host.swap_total ? (
|
|
<>
|
|
swap: {formatBytes(data.state.swap_used)} /{" "}
|
|
{formatBytes(data.host.swap_total)}
|
|
</>
|
|
) : (
|
|
<>no swap</>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
<ChartContainer
|
|
config={chartConfig}
|
|
className="aspect-auto h-[130px] w-full"
|
|
>
|
|
{isMemLoading ? (
|
|
<ChartSkeleton />
|
|
) : (
|
|
<AreaChart
|
|
syncId="serverDetailCharts"
|
|
accessibilityLayer
|
|
data={displayData}
|
|
margin={{
|
|
top: 12,
|
|
left: 12,
|
|
right: 12,
|
|
}}
|
|
>
|
|
<CartesianGrid vertical={false} />
|
|
<XAxis
|
|
dataKey="timeStamp"
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickMargin={8}
|
|
minTickGap={200}
|
|
interval="preserveStartEnd"
|
|
tickFormatter={(value) => formatRelativeTime(value)}
|
|
/>
|
|
<YAxis
|
|
tickLine={false}
|
|
axisLine={false}
|
|
mirror={true}
|
|
tickMargin={-15}
|
|
domain={[0, 100]}
|
|
tickFormatter={(value) => `${value}%`}
|
|
/>
|
|
<ChartTooltip
|
|
isAnimationActive={false}
|
|
content={
|
|
<ChartTooltipContent
|
|
indicator="dot"
|
|
labelFormatter={(_, payload) => {
|
|
return formatTime(
|
|
Number(payload[0]?.payload?.timeStamp),
|
|
);
|
|
}}
|
|
formatter={(value, name) => {
|
|
const label =
|
|
name === "mem"
|
|
? t("serverDetailChart.mem")
|
|
: t("serverDetailChart.swap");
|
|
return (
|
|
<div className="flex flex-1 items-center justify-between leading-none">
|
|
<span className="text-muted-foreground">
|
|
{label}
|
|
</span>
|
|
<span className="ml-2 font-medium text-foreground tabular-nums">
|
|
{Number(value).toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
);
|
|
}}
|
|
/>
|
|
}
|
|
/>
|
|
<Area
|
|
isAnimationActive={false}
|
|
dataKey="mem"
|
|
type="step"
|
|
fill="hsl(var(--chart-8))"
|
|
fillOpacity={0.3}
|
|
stroke="hsl(var(--chart-8))"
|
|
/>
|
|
<Area
|
|
isAnimationActive={false}
|
|
dataKey="swap"
|
|
type="step"
|
|
fill="hsl(var(--chart-10))"
|
|
fillOpacity={0.3}
|
|
stroke="hsl(var(--chart-10))"
|
|
/>
|
|
</AreaChart>
|
|
)}
|
|
</ChartContainer>
|
|
</section>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
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<diskChartData>(
|
|
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 (
|
|
<Card
|
|
className={cn({
|
|
"bg-card/70": customBackgroundImage,
|
|
})}
|
|
>
|
|
<CardContent className="px-6 py-3">
|
|
<section className="flex flex-col gap-1">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-md font-medium">{t("serverDetailChart.disk")}</p>
|
|
<section className="flex flex-col items-end gap-0.5">
|
|
<section className="flex items-center gap-2">
|
|
<p className="text-xs text-end w-10 font-medium">
|
|
{disk.toFixed(0)}%
|
|
</p>
|
|
<AnimatedCircularProgressBar
|
|
className="size-3 text-[0px]"
|
|
max={100}
|
|
min={0}
|
|
value={disk}
|
|
primaryColor="hsl(var(--chart-5))"
|
|
/>
|
|
</section>
|
|
<div className="flex text-[11px] font-medium items-center gap-2">
|
|
{formatBytes(data.state.disk_used)} /{" "}
|
|
{formatBytes(data.host.disk_total)}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
<ChartContainer
|
|
config={chartConfig}
|
|
className="aspect-auto h-[130px] w-full"
|
|
>
|
|
{isLoading ? (
|
|
<ChartSkeleton />
|
|
) : (
|
|
<AreaChart
|
|
syncId="serverDetailCharts"
|
|
accessibilityLayer
|
|
data={displayData}
|
|
margin={{
|
|
top: 12,
|
|
left: 12,
|
|
right: 12,
|
|
}}
|
|
>
|
|
<CartesianGrid vertical={false} />
|
|
<XAxis
|
|
dataKey="timeStamp"
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickMargin={8}
|
|
minTickGap={200}
|
|
interval="preserveStartEnd"
|
|
tickFormatter={(value) => formatRelativeTime(value)}
|
|
/>
|
|
<YAxis
|
|
tickLine={false}
|
|
axisLine={false}
|
|
mirror={true}
|
|
tickMargin={-15}
|
|
domain={[0, 100]}
|
|
tickFormatter={(value) => `${value}%`}
|
|
/>
|
|
<ChartTooltip
|
|
isAnimationActive={false}
|
|
content={
|
|
<ChartTooltipContent
|
|
indicator="dot"
|
|
labelFormatter={(_, payload) => {
|
|
return formatTime(
|
|
Number(payload[0]?.payload?.timeStamp),
|
|
);
|
|
}}
|
|
formatter={(value) => (
|
|
<div className="flex flex-1 items-center justify-between leading-none">
|
|
<span className="text-muted-foreground">
|
|
{t("serverDetailChart.disk")}
|
|
</span>
|
|
<span className="ml-2 font-medium text-foreground tabular-nums">
|
|
{Number(value).toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
)}
|
|
/>
|
|
}
|
|
/>
|
|
<Area
|
|
isAnimationActive={false}
|
|
dataKey="disk"
|
|
type="step"
|
|
fill="hsl(var(--chart-5))"
|
|
fillOpacity={0.3}
|
|
stroke="hsl(var(--chart-5))"
|
|
/>
|
|
</AreaChart>
|
|
)}
|
|
</ChartContainer>
|
|
</section>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
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<ChartPeriod>("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<number, number>();
|
|
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 (
|
|
<Card
|
|
className={cn({
|
|
"bg-card/70": customBackgroundImage,
|
|
})}
|
|
>
|
|
<CardContent className="px-6 py-3">
|
|
<section className="flex flex-col gap-1">
|
|
<div className="flex items-center">
|
|
<section className="flex items-center gap-4">
|
|
<div className="flex flex-col w-20">
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("serverDetailChart.upload")}
|
|
</p>
|
|
<div className="flex items-center gap-1">
|
|
<span className="relative inline-flex size-1.5 rounded-full bg-[hsl(var(--chart-1))]" />
|
|
<p className="text-xs font-medium">
|
|
{up >= 1024
|
|
? `${(up / 1024).toFixed(2)}G/s`
|
|
: up >= 1
|
|
? `${up.toFixed(2)}M/s`
|
|
: `${(up * 1024).toFixed(2)}K/s`}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col w-20">
|
|
<p className=" text-xs text-muted-foreground">
|
|
{t("serverDetailChart.download")}
|
|
</p>
|
|
<div className="flex items-center gap-1">
|
|
<span className="relative inline-flex size-1.5 rounded-full bg-[hsl(var(--chart-4))]" />
|
|
<p className="text-xs font-medium">
|
|
{down >= 1024
|
|
? `${(down / 1024).toFixed(2)}G/s`
|
|
: down >= 1
|
|
? `${down.toFixed(2)}M/s`
|
|
: `${(down * 1024).toFixed(2)}K/s`}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
<ChartContainer
|
|
config={chartConfig}
|
|
className="aspect-auto h-[130px] w-full"
|
|
>
|
|
{isNetworkLoading ? (
|
|
<ChartSkeleton />
|
|
) : (
|
|
<LineChart
|
|
syncId="serverDetailCharts"
|
|
accessibilityLayer
|
|
data={displayData}
|
|
margin={{
|
|
top: 12,
|
|
left: 12,
|
|
right: 12,
|
|
}}
|
|
>
|
|
<CartesianGrid vertical={false} />
|
|
<XAxis
|
|
dataKey="timeStamp"
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickMargin={8}
|
|
minTickGap={200}
|
|
interval="preserveStartEnd"
|
|
tickFormatter={(value) => formatRelativeTime(value)}
|
|
/>
|
|
<YAxis
|
|
tickLine={false}
|
|
axisLine={false}
|
|
mirror={true}
|
|
tickMargin={-15}
|
|
type="number"
|
|
minTickGap={50}
|
|
interval="preserveStartEnd"
|
|
domain={[1, maxDownload]}
|
|
tickFormatter={(value) => `${value.toFixed(0)}M/s`}
|
|
/>
|
|
<ChartTooltip
|
|
isAnimationActive={false}
|
|
content={
|
|
<ChartTooltipContent
|
|
indicator="dot"
|
|
labelFormatter={(_, payload) => {
|
|
return formatTime(
|
|
Number(payload[0]?.payload?.timeStamp),
|
|
);
|
|
}}
|
|
formatter={(value, name) => {
|
|
const label =
|
|
name === "upload"
|
|
? t("serverDetailChart.upload")
|
|
: t("serverDetailChart.download");
|
|
return (
|
|
<div className="flex flex-1 items-center justify-between leading-none">
|
|
<span className="text-muted-foreground">
|
|
{label}
|
|
</span>
|
|
<span className="ml-2 font-medium text-foreground tabular-nums">
|
|
{Number(value).toFixed(2)} MB/s
|
|
</span>
|
|
</div>
|
|
);
|
|
}}
|
|
/>
|
|
}
|
|
/>
|
|
<Line
|
|
isAnimationActive={false}
|
|
dataKey="upload"
|
|
type="linear"
|
|
stroke="hsl(var(--chart-1))"
|
|
strokeWidth={1}
|
|
dot={false}
|
|
/>
|
|
<Line
|
|
isAnimationActive={false}
|
|
dataKey="download"
|
|
type="linear"
|
|
stroke="hsl(var(--chart-4))"
|
|
strokeWidth={1}
|
|
dot={false}
|
|
/>
|
|
</LineChart>
|
|
)}
|
|
</ChartContainer>
|
|
</section>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
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<ChartPeriod>("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<number, number>();
|
|
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 (
|
|
<Card
|
|
className={cn({
|
|
"bg-card/70": customBackgroundImage,
|
|
})}
|
|
>
|
|
<CardContent className="px-6 py-3">
|
|
<section className="flex flex-col gap-1">
|
|
<div className="flex items-center">
|
|
<section className="flex items-center gap-4">
|
|
<div className="flex flex-col w-12">
|
|
<p className="text-xs text-muted-foreground">TCP</p>
|
|
<div className="flex items-center gap-1">
|
|
<span className="relative inline-flex size-1.5 rounded-full bg-[hsl(var(--chart-1))]" />
|
|
<p className="text-xs font-medium">{tcp}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col w-12">
|
|
<p className=" text-xs text-muted-foreground">UDP</p>
|
|
<div className="flex items-center gap-1">
|
|
<span className="relative inline-flex size-1.5 rounded-full bg-[hsl(var(--chart-4))]" />
|
|
<p className="text-xs font-medium">{udp}</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
<ChartContainer
|
|
config={chartConfig}
|
|
className="aspect-auto h-[130px] w-full"
|
|
>
|
|
{isConnectLoading ? (
|
|
<ChartSkeleton />
|
|
) : (
|
|
<LineChart
|
|
syncId="serverDetailCharts"
|
|
accessibilityLayer
|
|
data={displayData}
|
|
margin={{
|
|
top: 12,
|
|
left: 12,
|
|
right: 12,
|
|
}}
|
|
>
|
|
<CartesianGrid vertical={false} />
|
|
<XAxis
|
|
dataKey="timeStamp"
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickMargin={8}
|
|
minTickGap={200}
|
|
interval="preserveStartEnd"
|
|
tickFormatter={(value) => formatRelativeTime(value)}
|
|
/>
|
|
<YAxis
|
|
tickLine={false}
|
|
axisLine={false}
|
|
mirror={true}
|
|
tickMargin={-15}
|
|
type="number"
|
|
interval="preserveStartEnd"
|
|
/>
|
|
<ChartTooltip
|
|
isAnimationActive={false}
|
|
content={
|
|
<ChartTooltipContent
|
|
indicator="dot"
|
|
labelFormatter={(_, payload) => {
|
|
return formatTime(
|
|
Number(payload[0]?.payload?.timeStamp),
|
|
);
|
|
}}
|
|
formatter={(value, name) => {
|
|
const label = name === "tcp" ? "TCP" : "UDP";
|
|
return (
|
|
<div className="flex flex-1 items-center justify-between leading-none">
|
|
<span className="text-muted-foreground">
|
|
{label}
|
|
</span>
|
|
<span className="ml-2 font-medium text-foreground tabular-nums">
|
|
{Number(value).toFixed(0)}
|
|
</span>
|
|
</div>
|
|
);
|
|
}}
|
|
/>
|
|
}
|
|
/>
|
|
<Line
|
|
isAnimationActive={false}
|
|
dataKey="tcp"
|
|
type="linear"
|
|
stroke="hsl(var(--chart-1))"
|
|
strokeWidth={1}
|
|
dot={false}
|
|
/>
|
|
<Line
|
|
isAnimationActive={false}
|
|
dataKey="udp"
|
|
type="linear"
|
|
stroke="hsl(var(--chart-4))"
|
|
strokeWidth={1}
|
|
dot={false}
|
|
/>
|
|
</LineChart>
|
|
)}
|
|
</ChartContainer>
|
|
</section>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|