From 65b32ec81e1734564218664085462af569bd9982 Mon Sep 17 00:00:00 2001
From: hamster1963 <1410514192@qq.com>
Date: Fri, 30 Jan 2026 09:14:41 +0800
Subject: [PATCH] feat: add server metrics fetching and update translations
- Implemented `fetchServerMetrics` function in `nezha-api.ts` to retrieve server metrics based on metric type and period.
- Added new metric types and periods to `nezha-api.ts` type definitions.
- Updated English and Chinese translations to include new terms for metrics and periods.
- Commented out `ServerDetailSummary` component in `ServerDetail.tsx` for future use.
---
src/components/ServerDetailChart.tsx | 1242 ++++++++++++++++++--------
src/lib/nezha-api.ts | 19 +
src/locales/en/translation.json | 6 +-
src/locales/zh-CN/translation.json | 6 +-
src/pages/ServerDetail.tsx | 5 +-
src/types/nezha-api.ts | 38 +
6 files changed, 951 insertions(+), 365 deletions(-)
diff --git a/src/components/ServerDetailChart.tsx b/src/components/ServerDetailChart.tsx
index 86e6542..fd9ad4e 100644
--- a/src/components/ServerDetailChart.tsx
+++ b/src/components/ServerDetailChart.tsx
@@ -1,4 +1,5 @@
-import { useEffect, useRef, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Area,
@@ -13,12 +14,19 @@ import { Card, CardContent } from "@/components/ui/card";
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
import { useWebSocketContext } from "@/hooks/use-websocket-context";
import { formatBytes } from "@/lib/format";
+import { fetchLoginUser, fetchServerMetrics } from "@/lib/nezha-api";
import { cn, formatNezhaInfo, formatRelativeTime } from "@/lib/utils";
-import type { NezhaServer, NezhaWebsocketResponse } from "@/types/nezha-api";
+import type {
+ MetricPeriod,
+ NezhaServer,
+ NezhaWebsocketResponse,
+} from "@/types/nezha-api";
import { ServerDetailChartLoading } from "./loading/ServerDetailLoading";
import AnimatedCircularProgressBar from "./ui/animated-circular-progress-bar";
+type ChartPeriod = "realtime" | MetricPeriod;
+
type gpuChartData = {
timeStamp: string;
gpu: number;
@@ -57,12 +65,86 @@ type connectChartData = {
udp: number;
};
+function PeriodSelector({
+ selectedPeriod,
+ onPeriodChange,
+ isLogin,
+}: {
+ selectedPeriod: ChartPeriod;
+ onPeriodChange: (period: ChartPeriod) => void;
+ isLogin: 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) => {
+ // Only realtime and 1d are available for non-logged-in users
+ const isLocked =
+ !isLogin && period.value !== "realtime" && period.value !== "1d";
+ return (
+
+ );
+ })}
+
+ );
+}
+
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;
+
+ // Reset period if user is not logged in and selected period is restricted
+ useEffect(() => {
+ if (!isLogin && selectedPeriod !== "realtime" && selectedPeriod !== "1d") {
+ setSelectedPeriod("1d");
+ }
+ }, [isLogin, selectedPeriod]);
if (!connected && !lastMessage) {
return ;
@@ -86,72 +168,130 @@ export default function ServerDetailChart({
const gpuList = server.host.gpu || [];
return (
-
-
+
- {gpuStats.length >= 1 && gpuList.length === gpuStats.length
- ? gpuList.map((gpu, index) => (
-
- ))
- : gpuStats.length > 0
- ? gpuStats.map((gpu, index) => (
+
+
+ {gpuStats.length >= 1 && gpuList.length === gpuStats.length
+ ? gpuList.map((gpu, index) => (
))
- : null}
-
-
-
-
-
+ : 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);
+
+ useEffect(() => {
+ if (period === "realtime") {
+ setHistoricalData([]);
+ return;
+ }
+
+ const fetchData = async () => {
+ 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),
+ );
+ setHistoricalData(transformedData);
+ }
+ } catch (error) {
+ console.error(`Failed to fetch ${metricName} metrics:`, error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchData();
+ }, [serverId, metricName, period, transformData]);
+
+ return { historicalData, isLoading };
+}
+
function GpuChart({
id,
index,
gpuStat,
gpuName,
messageHistory,
+ period,
}: {
now: number;
id: number;
@@ -159,6 +299,7 @@ function GpuChart({
gpuStat: number;
gpuName?: string;
messageHistory: { data: string }[];
+ period: ChartPeriod;
}) {
const [gpuChartData, setGpuChartData] = useState([]);
const hasInitialized = useRef(false);
@@ -169,9 +310,28 @@ function GpuChart({
? window.CustomBackgroundImage
: undefined;
+ const transformGpuData = useMemo(
+ () => (timestamp: number, value: number) => ({
+ timeStamp: timestamp.toString(),
+ gpu: value,
+ }),
+ [],
+ );
+
+ const { historicalData, isLoading } = useHistoricalData(
+ id,
+ "gpu",
+ period,
+ transformGpuData,
+ );
+
// 初始化历史数据
useEffect(() => {
- if (!hasInitialized.current && messageHistory.length > 0) {
+ if (
+ period === "realtime" &&
+ !hasInitialized.current &&
+ messageHistory.length > 0
+ ) {
const historyData = messageHistory
.map((msg) => {
const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse;
@@ -190,10 +350,18 @@ function GpuChart({
hasInitialized.current = true;
setHistoryLoaded(true);
}
- }, [messageHistory, id, index]);
+ }, [messageHistory, id, index, period]);
+
+ // Reset when switching to realtime
+ useEffect(() => {
+ if (period === "realtime") {
+ hasInitialized.current = false;
+ setHistoryLoaded(false);
+ }
+ }, [period]);
useEffect(() => {
- if (gpuStat && historyLoaded) {
+ if (gpuStat && historyLoaded && period === "realtime") {
const timestamp = Date.now().toString();
setGpuChartData((prevData) => {
let newData = [] as gpuChartData[];
@@ -211,7 +379,7 @@ function GpuChart({
return newData;
});
}
- }, [gpuStat, historyLoaded]);
+ }, [gpuStat, historyLoaded, period]);
const chartConfig = {
gpu: {
@@ -219,6 +387,8 @@ function GpuChart({
},
} satisfies ChartConfig;
+ const displayData = period === "realtime" ? gpuChartData : historicalData;
+
return (
-
-
- formatRelativeTime(value)}
- />
- `${value}%`}
- />
-
-
+ {isLoading ? (
+
+
+ Loading...
+
+
+ ) : (
+
+
+ formatRelativeTime(value)}
+ />
+ `${value}%`}
+ />
+
+
+ )}
@@ -296,10 +474,12 @@ function CpuChart({
now,
data,
messageHistory,
+ period,
}: {
now: number;
data: NezhaServer;
messageHistory: { data: string }[];
+ period: ChartPeriod;
}) {
const [cpuChartData, setCpuChartData] = useState([]);
const hasInitialized = useRef(false);
@@ -312,9 +492,28 @@ function CpuChart({
? window.CustomBackgroundImage
: undefined;
+ const transformCpuData = useMemo(
+ () => (timestamp: number, value: number) => ({
+ timeStamp: timestamp.toString(),
+ cpu: value,
+ }),
+ [],
+ );
+
+ const { historicalData, isLoading } = useHistoricalData(
+ data.id,
+ "cpu",
+ period,
+ transformCpuData,
+ );
+
// 初始化历史数据
useEffect(() => {
- if (!hasInitialized.current && messageHistory.length > 0) {
+ if (
+ period === "realtime" &&
+ !hasInitialized.current &&
+ messageHistory.length > 0
+ ) {
const historyData = messageHistory
.map((msg) => {
const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse;
@@ -327,17 +526,25 @@ function CpuChart({
};
})
.filter((item): item is cpuChartData => item !== null)
- .reverse(); // 保持时间顺序
+ .reverse();
setCpuChartData(historyData);
hasInitialized.current = true;
setHistoryLoaded(true);
}
- }, [messageHistory, data.id]);
+ }, [messageHistory, data.id, period]);
+
+ // Reset when switching to realtime
+ useEffect(() => {
+ if (period === "realtime") {
+ hasInitialized.current = false;
+ setHistoryLoaded(false);
+ }
+ }, [period]);
// 更新实时数据
useEffect(() => {
- if (data && historyLoaded) {
+ if (data && historyLoaded && period === "realtime") {
const timestamp = Date.now().toString();
setCpuChartData((prevData) => {
let newData = [] as cpuChartData[];
@@ -355,7 +562,7 @@ function CpuChart({
return newData;
});
}
- }, [data, historyLoaded, cpu]);
+ }, [data, historyLoaded, cpu, period]);
const chartConfig = {
cpu: {
@@ -363,6 +570,8 @@ function CpuChart({
},
} satisfies ChartConfig;
+ const displayData = period === "realtime" ? cpuChartData : historicalData;
+
return (
-
-
- formatRelativeTime(value)}
- />
- `${value}%`}
- />
-
-
+ {isLoading ? (
+
+
+ Loading...
+
+
+ ) : (
+
+
+ formatRelativeTime(value)}
+ />
+ `${value}%`}
+ />
+
+
+ )}
@@ -437,10 +654,12 @@ function ProcessChart({
now,
data,
messageHistory,
+ period,
}: {
now: number;
data: NezhaServer;
messageHistory: { data: string }[];
+ period: ChartPeriod;
}) {
const { t } = useTranslation();
const [processChartData, setProcessChartData] = useState(
@@ -456,9 +675,28 @@ function ProcessChart({
const { process } = formatNezhaInfo(now, data);
+ const transformProcessData = useMemo(
+ () => (timestamp: number, value: number) => ({
+ timeStamp: timestamp.toString(),
+ process: value,
+ }),
+ [],
+ );
+
+ const { historicalData, isLoading } = useHistoricalData(
+ data.id,
+ "process_count",
+ period,
+ transformProcessData,
+ );
+
// 初始化历史数据
useEffect(() => {
- if (!hasInitialized.current && messageHistory.length > 0) {
+ if (
+ period === "realtime" &&
+ !hasInitialized.current &&
+ messageHistory.length > 0
+ ) {
const historyData = messageHistory
.map((msg) => {
const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse;
@@ -477,11 +715,19 @@ function ProcessChart({
hasInitialized.current = true;
setHistoryLoaded(true);
}
- }, [messageHistory, data.id]);
+ }, [messageHistory, data.id, period]);
+
+ // Reset when switching to realtime
+ useEffect(() => {
+ if (period === "realtime") {
+ hasInitialized.current = false;
+ setHistoryLoaded(false);
+ }
+ }, [period]);
// 修改实时数据更新逻辑
useEffect(() => {
- if (data && historyLoaded) {
+ if (data && historyLoaded && period === "realtime") {
const timestamp = Date.now().toString();
setProcessChartData((prevData) => {
let newData = [] as processChartData[];
@@ -499,7 +745,7 @@ function ProcessChart({
return newData;
});
}
- }, [data, historyLoaded, process]);
+ }, [data, historyLoaded, process, period]);
const chartConfig = {
process: {
@@ -507,6 +753,8 @@ function ProcessChart({
},
} satisfies ChartConfig;
+ const displayData = period === "realtime" ? processChartData : historicalData;
+
return (
-
-
- formatRelativeTime(value)}
- />
-
-
-
+ {isLoading ? (
+
+
+ Loading...
+
+
+ ) : (
+
+
+ formatRelativeTime(value)}
+ />
+
+
+
+ )}
@@ -572,10 +828,12 @@ function MemChart({
now,
data,
messageHistory,
+ period,
}: {
now: number;
data: NezhaServer;
messageHistory: { data: string }[];
+ period: ChartPeriod;
}) {
const { t } = useTranslation();
const [memChartData, setMemChartData] = useState([] as memChartData[]);
@@ -589,9 +847,70 @@ function MemChart({
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);
+
+ useEffect(() => {
+ if (period === "realtime") {
+ setMemHistoricalData([]);
+ return;
+ }
+
+ const fetchMemData = async () => {
+ 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,
+ };
+ });
+ setMemHistoricalData(combinedData);
+ }
+ } catch (error) {
+ console.error("Failed to fetch memory metrics:", error);
+ } finally {
+ setIsLoadingMem(false);
+ }
+ };
+
+ fetchMemData();
+ }, [data.id, period, data.host.mem_total, data.host.swap_total]);
+
// 初始化历史数据
useEffect(() => {
- if (!hasInitialized.current && messageHistory.length > 0) {
+ if (
+ period === "realtime" &&
+ !hasInitialized.current &&
+ messageHistory.length > 0
+ ) {
const historyData = messageHistory
.map((msg) => {
const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse;
@@ -611,11 +930,19 @@ function MemChart({
hasInitialized.current = true;
setHistoryLoaded(true);
}
- }, [messageHistory, data.id]);
+ }, [messageHistory, data.id, period]);
+
+ // Reset when switching to realtime
+ useEffect(() => {
+ if (period === "realtime") {
+ hasInitialized.current = false;
+ setHistoryLoaded(false);
+ }
+ }, [period]);
// 修改实时数据更新逻辑
useEffect(() => {
- if (data && historyLoaded) {
+ if (data && historyLoaded && period === "realtime") {
const timestamp = Date.now().toString();
setMemChartData((prevData) => {
let newData = [] as memChartData[];
@@ -633,7 +960,7 @@ function MemChart({
return newData;
});
}
- }, [data, historyLoaded, mem, swap]);
+ }, [data, historyLoaded, mem, swap, period]);
const chartConfig = {
mem: {
@@ -644,6 +971,8 @@ function MemChart({
},
} satisfies ChartConfig;
+ const displayData = period === "realtime" ? memChartData : memHistoricalData;
+
return (
-
-
- formatRelativeTime(value)}
- />
- `${value}%`}
- />
-
-
-
+ {isLoadingMem ? (
+
+
+ Loading...
+
+
+ ) : (
+
+
+ formatRelativeTime(value)}
+ />
+ `${value}%`}
+ />
+
+
+
+ )}
@@ -761,10 +1098,12 @@ function DiskChart({
now,
data,
messageHistory,
+ period,
}: {
now: number;
data: NezhaServer;
messageHistory: { data: string }[];
+ period: ChartPeriod;
}) {
const { t } = useTranslation();
const [diskChartData, setDiskChartData] = useState([] as diskChartData[]);
@@ -778,9 +1117,33 @@ function DiskChart({
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 { historicalData, isLoading } = useHistoricalData(
+ data.id,
+ "disk",
+ period,
+ transformDiskData,
+ );
+
// 初始化历史数据
useEffect(() => {
- if (!hasInitialized.current && messageHistory.length > 0) {
+ if (
+ period === "realtime" &&
+ !hasInitialized.current &&
+ messageHistory.length > 0
+ ) {
const historyData = messageHistory
.map((msg) => {
const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse;
@@ -799,11 +1162,19 @@ function DiskChart({
hasInitialized.current = true;
setHistoryLoaded(true);
}
- }, [messageHistory, data.id]);
+ }, [messageHistory, data.id, period]);
+
+ // Reset when switching to realtime
+ useEffect(() => {
+ if (period === "realtime") {
+ hasInitialized.current = false;
+ setHistoryLoaded(false);
+ }
+ }, [period]);
// 修改实时数据更新逻辑
useEffect(() => {
- if (data && historyLoaded) {
+ if (data && historyLoaded && period === "realtime") {
const timestamp = Date.now().toString();
setDiskChartData((prevData) => {
let newData = [] as diskChartData[];
@@ -821,7 +1192,7 @@ function DiskChart({
return newData;
});
}
- }, [data, historyLoaded, disk]);
+ }, [data, historyLoaded, disk, period]);
const chartConfig = {
disk: {
@@ -829,6 +1200,8 @@ function DiskChart({
},
} satisfies ChartConfig;
+ const displayData = period === "realtime" ? diskChartData : historicalData;
+
return (
-
-
- formatRelativeTime(value)}
- />
- `${value}%`}
- />
-
-
+ {isLoading ? (
+
+
+ Loading...
+
+
+ ) : (
+
+
+ formatRelativeTime(value)}
+ />
+ `${value}%`}
+ />
+
+
+ )}
@@ -909,10 +1290,12 @@ function NetworkChart({
now,
data,
messageHistory,
+ period,
}: {
now: number;
data: NezhaServer;
messageHistory: { data: string }[];
+ period: ChartPeriod;
}) {
const { t } = useTranslation();
const [networkChartData, setNetworkChartData] = useState(
@@ -928,9 +1311,59 @@ function NetworkChart({
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);
+
+ useEffect(() => {
+ if (period === "realtime") {
+ setNetworkHistoricalData([]);
+ return;
+ }
+
+ const fetchNetworkData = async () => {
+ 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,
+ }));
+ setNetworkHistoricalData(combinedData);
+ }
+ } catch (error) {
+ console.error("Failed to fetch network metrics:", error);
+ } finally {
+ setIsLoadingNetwork(false);
+ }
+ };
+
+ fetchNetworkData();
+ }, [data.id, period]);
+
// 初始化历史数据
useEffect(() => {
- if (!hasInitialized.current && messageHistory.length > 0) {
+ if (
+ period === "realtime" &&
+ !hasInitialized.current &&
+ messageHistory.length > 0
+ ) {
const historyData = messageHistory
.map((msg) => {
const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse;
@@ -950,11 +1383,19 @@ function NetworkChart({
hasInitialized.current = true;
setHistoryLoaded(true);
}
- }, [messageHistory, data.id]);
+ }, [messageHistory, data.id, period]);
+
+ // Reset when switching to realtime
+ useEffect(() => {
+ if (period === "realtime") {
+ hasInitialized.current = false;
+ setHistoryLoaded(false);
+ }
+ }, [period]);
// 修改实时数据更新逻辑
useEffect(() => {
- if (data && historyLoaded) {
+ if (data && historyLoaded && period === "realtime") {
const timestamp = Date.now().toString();
setNetworkChartData((prevData) => {
let newData = [] as networkChartData[];
@@ -975,9 +1416,12 @@ function NetworkChart({
return newData;
});
}
- }, [data, historyLoaded, down, up]);
+ }, [data, historyLoaded, down, up, period]);
- let maxDownload = Math.max(...networkChartData.map((item) => item.download));
+ const displayData =
+ period === "realtime" ? networkChartData : networkHistoricalData;
+
+ let maxDownload = Math.max(...displayData.map((item) => item.download));
maxDownload = Math.ceil(maxDownload);
if (maxDownload < 1) {
maxDownload = 1;
@@ -1007,7 +1451,7 @@ function NetworkChart({
{t("serverDetailChart.upload")}
-
+
{up >= 1024
? `${(up / 1024).toFixed(2)}G/s`
@@ -1022,7 +1466,7 @@ function NetworkChart({
{t("serverDetailChart.download")}
-
+
{down >= 1024
? `${(down / 1024).toFixed(2)}G/s`
@@ -1038,53 +1482,61 @@ function NetworkChart({
config={chartConfig}
className="aspect-auto h-[130px] w-full"
>
-
-
- formatRelativeTime(value)}
- />
- `${value.toFixed(0)}M/s`}
- />
-
-
-
+ {isLoadingNetwork ? (
+
+
+ Loading...
+
+
+ ) : (
+
+
+ formatRelativeTime(value)}
+ />
+ `${value.toFixed(0)}M/s`}
+ />
+
+
+
+ )}
@@ -1096,10 +1548,12 @@ function ConnectChart({
now,
data,
messageHistory,
+ period,
}: {
now: number;
data: NezhaServer;
messageHistory: { data: string }[];
+ period: ChartPeriod;
}) {
const [connectChartData, setConnectChartData] = useState(
[] as connectChartData[],
@@ -1114,9 +1568,58 @@ function ConnectChart({
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);
+
+ useEffect(() => {
+ if (period === "realtime") {
+ setConnectHistoricalData([]);
+ return;
+ }
+
+ const fetchConnectData = async () => {
+ 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,
+ }));
+ setConnectHistoricalData(combinedData);
+ }
+ } catch (error) {
+ console.error("Failed to fetch connection metrics:", error);
+ } finally {
+ setIsLoadingConnect(false);
+ }
+ };
+
+ fetchConnectData();
+ }, [data.id, period]);
+
// 初始化历史数据
useEffect(() => {
- if (!hasInitialized.current && messageHistory.length > 0) {
+ if (
+ period === "realtime" &&
+ !hasInitialized.current &&
+ messageHistory.length > 0
+ ) {
const historyData = messageHistory
.map((msg) => {
const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse;
@@ -1136,11 +1639,19 @@ function ConnectChart({
hasInitialized.current = true;
setHistoryLoaded(true);
}
- }, [messageHistory, data.id]);
+ }, [messageHistory, data.id, period]);
+
+ // Reset when switching to realtime
+ useEffect(() => {
+ if (period === "realtime") {
+ hasInitialized.current = false;
+ setHistoryLoaded(false);
+ }
+ }, [period]);
// 修改实时数据更新逻辑
useEffect(() => {
- if (data && historyLoaded) {
+ if (data && historyLoaded && period === "realtime") {
const timestamp = Date.now().toString();
setConnectChartData((prevData) => {
let newData = [] as connectChartData[];
@@ -1158,7 +1669,7 @@ function ConnectChart({
return newData;
});
}
- }, [data, historyLoaded, tcp, udp]);
+ }, [data, historyLoaded, tcp, udp, period]);
const chartConfig = {
tcp: {
@@ -1169,6 +1680,9 @@ function ConnectChart({
},
} satisfies ChartConfig;
+ const displayData =
+ period === "realtime" ? connectChartData : connectHistoricalData;
+
return (
TCP
@@ -1199,50 +1713,58 @@ function ConnectChart({
config={chartConfig}
className="aspect-auto h-[130px] w-full"
>
-
-
- formatRelativeTime(value)}
- />
-
-
-
-
+ {isLoadingConnect ? (
+
+
+ Loading...
+
+
+ ) : (
+
+
+ formatRelativeTime(value)}
+ />
+
+
+
+
+ )}
diff --git a/src/lib/nezha-api.ts b/src/lib/nezha-api.ts
index 92d526e..02526c2 100644
--- a/src/lib/nezha-api.ts
+++ b/src/lib/nezha-api.ts
@@ -1,7 +1,10 @@
import type {
LoginUserResponse,
+ MetricPeriod,
+ MetricType,
MonitorResponse,
ServerGroupResponse,
+ ServerMetricsResponse,
ServiceResponse,
SettingResponse,
} from "@/types/nezha-api";
@@ -69,3 +72,19 @@ export const fetchSetting = async (): Promise
=> {
}
return data;
};
+
+export const fetchServerMetrics = async (
+ server_id: number,
+ metric: MetricType,
+ period?: MetricPeriod,
+): Promise => {
+ const query = period
+ ? `?metric=${metric}&period=${period}`
+ : `?metric=${metric}`;
+ const response = await fetch(`/api/v1/server/${server_id}/metrics${query}`);
+ const data = await response.json();
+ if (data.error) {
+ throw new Error(data.error);
+ }
+ return data;
+};
diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json
index 9077663..2ec5fb7 100644
--- a/src/locales/en/translation.json
+++ b/src/locales/en/translation.json
@@ -77,7 +77,11 @@
"mem": "Mem",
"swap": "Swap",
"upload": "Upload",
- "download": "Download"
+ "download": "Download",
+ "realtime": "Realtime",
+ "period1d": "1 Day",
+ "period7d": "7 Days",
+ "period30d": "30 Days"
},
"footer": {
"themeBy": "Theme by "
diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json
index 7e19a51..d8f9690 100644
--- a/src/locales/zh-CN/translation.json
+++ b/src/locales/zh-CN/translation.json
@@ -77,7 +77,11 @@
"mem": "内存",
"swap": "虚拟内存",
"upload": "上传",
- "download": "下载"
+ "download": "下载",
+ "realtime": "实时",
+ "period1d": "1 天",
+ "period7d": "7 天",
+ "period30d": "30 天"
},
"footer": {
"themeBy": "主题-"
diff --git a/src/pages/ServerDetail.tsx b/src/pages/ServerDetail.tsx
index e96aed2..959b55e 100644
--- a/src/pages/ServerDetail.tsx
+++ b/src/pages/ServerDetail.tsx
@@ -3,7 +3,6 @@ import { useNavigate, useParams } from "react-router-dom";
import { NetworkChart } from "@/components/NetworkChart";
import ServerDetailChart from "@/components/ServerDetailChart";
import ServerDetailOverview from "@/components/ServerDetailOverview";
-import ServerDetailSummary from "@/components/ServerDetailSummary";
import TabSwitch from "@/components/TabSwitch";
import { Separator } from "@/components/ui/separator";
@@ -39,9 +38,9 @@ export default function ServerDetail() {
- */}
diff --git a/src/types/nezha-api.ts b/src/types/nezha-api.ts
index 96469b9..de348c6 100644
--- a/src/types/nezha-api.ts
+++ b/src/types/nezha-api.ts
@@ -158,3 +158,41 @@ export interface SettingResponse {
version: string;
};
}
+
+export type MetricType =
+ | "cpu"
+ | "memory"
+ | "swap"
+ | "disk"
+ | "net_in_speed"
+ | "net_out_speed"
+ | "net_in_transfer"
+ | "net_out_transfer"
+ | "load1"
+ | "load5"
+ | "load15"
+ | "tcp_conn"
+ | "udp_conn"
+ | "process_count"
+ | "temperature"
+ | "uptime"
+ | "gpu";
+
+export type MetricPeriod = "1d" | "7d" | "30d";
+
+export interface MetricDataPoint {
+ ts: number;
+ value: number;
+}
+
+export interface ServerMetricsData {
+ server_id: number;
+ server_name: string;
+ metric: string;
+ data_points: MetricDataPoint[];
+}
+
+export interface ServerMetricsResponse {
+ success: boolean;
+ data: ServerMetricsData;
+}