diff --git a/bun.lockb b/bun.lockb
index e282bb9..4c2db6c 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/eslint.config.js b/eslint.config.js
index 79a552e..c1d602f 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -23,6 +23,7 @@ export default tseslint.config(
"warn",
{ allowConstantExport: true },
],
+ "react-hooks/exhaustive-deps": "off",
},
},
);
diff --git a/package.json b/package.json
index c857601..fb2182d 100644
--- a/package.json
+++ b/package.json
@@ -27,12 +27,13 @@
"clsx": "^2.1.1",
"country-flag-icons": "^1.5.13",
"framer-motion": "^11.11.17",
- "lucide-react": "^0.453.0",
+ "lucide-react": "^0.460.0",
"luxon": "^3.5.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"react-use-websocket": "^4.11.1",
+ "recharts": "^2.13.3",
"sonner": "^1.7.0",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7"
diff --git a/src/components/ServerDetailChart.tsx b/src/components/ServerDetailChart.tsx
new file mode 100644
index 0000000..70cec45
--- /dev/null
+++ b/src/components/ServerDetailChart.tsx
@@ -0,0 +1,742 @@
+"use client";
+
+import { Card, CardContent } from "@/components/ui/card";
+import { ChartConfig, ChartContainer } from "@/components/ui/chart";
+import { formatNezhaInfo, formatRelativeTime } from "@/lib/utils";
+import { NezhaAPI, NezhaAPIResponse } from "@/types/nezha-api";
+import { useEffect, useState } from "react";
+import { useParams } from "react-router-dom";
+import useWebSocket from "react-use-websocket";
+import {
+ Area,
+ AreaChart,
+ CartesianGrid,
+ Line,
+ LineChart,
+ XAxis,
+ YAxis,
+} from "recharts";
+import { ServerDetailChartLoading } from "./loading/ServerDetailLoading";
+import AnimatedCircularProgressBar from "./ui/animated-circular-progress-bar";
+
+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;
+};
+
+export default function ServerDetailChart() {
+ const { id } = useParams();
+ const { lastMessage, readyState } = useWebSocket("/api/v1/ws/server", {
+ shouldReconnect: () => true,
+ reconnectInterval: 3000,
+ });
+
+ // 检查连接状态
+ if (readyState !== 1) {
+ return (
+
+ );
+ }
+
+ // 解析消息
+ const nezhaWsData = lastMessage
+ ? (JSON.parse(lastMessage.data) as NezhaAPIResponse)
+ : null;
+
+ if (!nezhaWsData) {
+ return ;
+ }
+
+ const server = nezhaWsData.servers.find((s) => s.id === Number(id));
+
+ if (!server) {
+ return ;
+ }
+
+ return (
+
+ );
+}
+
+function CpuChart({ data }: { data: NezhaAPI }) {
+ const [cpuChartData, setCpuChartData] = useState([] as cpuChartData[]);
+
+ const { cpu } = formatNezhaInfo(data);
+
+ useEffect(() => {
+ if (data) {
+ const timestamp = Date.now().toString();
+ let newData = [] as cpuChartData[];
+ if (cpuChartData.length === 0) {
+ newData = [
+ { timeStamp: timestamp, cpu: cpu },
+ { timeStamp: timestamp, cpu: cpu },
+ ];
+ } else {
+ newData = [...cpuChartData, { timeStamp: timestamp, cpu: cpu }];
+ }
+ if (newData.length > 30) {
+ newData.shift();
+ }
+ setCpuChartData(newData);
+ }
+ }, [data]);
+
+ const chartConfig = {
+ cpu: {
+ label: "CPU",
+ },
+ } satisfies ChartConfig;
+
+ return (
+
+
+
+
+
CPU
+
+
+ {cpu.toFixed(0)}%
+
+
+
+
+
+
+
+ formatRelativeTime(value)}
+ />
+ `${value}%`}
+ />
+
+
+
+
+
+
+ );
+}
+
+function ProcessChart({ data }: { data: NezhaAPI }) {
+ const [processChartData, setProcessChartData] = useState(
+ [] as processChartData[],
+ );
+
+ const { process } = formatNezhaInfo(data);
+
+ useEffect(() => {
+ if (data) {
+ const timestamp = Date.now().toString();
+ let newData = [] as processChartData[];
+ if (processChartData.length === 0) {
+ newData = [
+ { timeStamp: timestamp, process: process },
+ { timeStamp: timestamp, process: process },
+ ];
+ } else {
+ newData = [
+ ...processChartData,
+ { timeStamp: timestamp, process: process },
+ ];
+ }
+ if (newData.length > 30) {
+ newData.shift();
+ }
+ setProcessChartData(newData);
+ }
+ }, [data]);
+
+ const chartConfig = {
+ process: {
+ label: "Process",
+ },
+ } satisfies ChartConfig;
+
+ return (
+
+
+
+
+
+
+
+ formatRelativeTime(value)}
+ />
+
+
+
+
+
+
+
+ );
+}
+
+function MemChart({ data }: { data: NezhaAPI }) {
+ const [memChartData, setMemChartData] = useState([] as memChartData[]);
+
+ const { mem, swap } = formatNezhaInfo(data);
+
+ useEffect(() => {
+ if (data) {
+ const timestamp = Date.now().toString();
+ let newData = [] as memChartData[];
+ if (memChartData.length === 0) {
+ newData = [
+ { timeStamp: timestamp, mem: mem, swap: swap },
+ { timeStamp: timestamp, mem: mem, swap: swap },
+ ];
+ } else {
+ newData = [
+ ...memChartData,
+ { timeStamp: timestamp, mem: mem, swap: swap },
+ ];
+ }
+ if (newData.length > 30) {
+ newData.shift();
+ }
+ setMemChartData(newData);
+ }
+ }, [data]);
+
+ const chartConfig = {
+ mem: {
+ label: "Mem",
+ },
+ swap: {
+ label: "Swap",
+ },
+ } satisfies ChartConfig;
+
+ return (
+
+
+
+
+
+
+
+ formatRelativeTime(value)}
+ />
+ `${value}%`}
+ />
+
+
+
+
+
+
+
+ );
+}
+
+function DiskChart({ data }: { data: NezhaAPI }) {
+ const [diskChartData, setDiskChartData] = useState([] as diskChartData[]);
+
+ const { disk } = formatNezhaInfo(data);
+
+ useEffect(() => {
+ if (data) {
+ const timestamp = Date.now().toString();
+ let newData = [] as diskChartData[];
+ if (diskChartData.length === 0) {
+ newData = [
+ { timeStamp: timestamp, disk: disk },
+ { timeStamp: timestamp, disk: disk },
+ ];
+ } else {
+ newData = [...diskChartData, { timeStamp: timestamp, disk: disk }];
+ }
+ if (newData.length > 30) {
+ newData.shift();
+ }
+ setDiskChartData(newData);
+ }
+ }, [data]);
+
+ const chartConfig = {
+ disk: {
+ label: "Disk",
+ },
+ } satisfies ChartConfig;
+
+ return (
+
+
+
+
+
{"Disk"}
+
+
+ {disk.toFixed(0)}%
+
+
+
+
+
+
+
+ formatRelativeTime(value)}
+ />
+ `${value}%`}
+ />
+
+
+
+
+
+
+ );
+}
+
+function NetworkChart({ data }: { data: NezhaAPI }) {
+ const [networkChartData, setNetworkChartData] = useState(
+ [] as networkChartData[],
+ );
+
+ const { up, down } = formatNezhaInfo(data);
+
+ useEffect(() => {
+ if (data) {
+ const timestamp = Date.now().toString();
+ let newData = [] as networkChartData[];
+ if (networkChartData.length === 0) {
+ newData = [
+ { timeStamp: timestamp, upload: up, download: down },
+ { timeStamp: timestamp, upload: up, download: down },
+ ];
+ } else {
+ newData = [
+ ...networkChartData,
+ { timeStamp: timestamp, upload: up, download: down },
+ ];
+ }
+ if (newData.length > 30) {
+ newData.shift();
+ }
+ setNetworkChartData(newData);
+ }
+ }, [data]);
+
+ let maxDownload = Math.max(...networkChartData.map((item) => item.download));
+ maxDownload = Math.ceil(maxDownload);
+ if (maxDownload < 1) {
+ maxDownload = 1;
+ }
+
+ const chartConfig = {
+ upload: {
+ label: "Upload",
+ },
+ download: {
+ label: "Download",
+ },
+ } satisfies ChartConfig;
+
+ return (
+
+
+
+
+
+
+
{"Upload"}
+
+
+
{up.toFixed(2)} M/s
+
+
+
+
{"Download"}
+
+
+
{down.toFixed(2)} M/s
+
+
+
+
+
+
+
+ formatRelativeTime(value)}
+ />
+ `${value.toFixed(0)}M/s`}
+ />
+
+
+
+
+
+
+
+ );
+}
+
+function ConnectChart({ data }: { data: NezhaAPI }) {
+ const [connectChartData, setConnectChartData] = useState(
+ [] as connectChartData[],
+ );
+
+ const { tcp, udp } = formatNezhaInfo(data);
+
+ useEffect(() => {
+ if (data) {
+ const timestamp = Date.now().toString();
+ let newData = [] as connectChartData[];
+ if (connectChartData.length === 0) {
+ newData = [
+ { timeStamp: timestamp, tcp: tcp, udp: udp },
+ { timeStamp: timestamp, tcp: tcp, udp: udp },
+ ];
+ } else {
+ newData = [
+ ...connectChartData,
+ { timeStamp: timestamp, tcp: tcp, udp: udp },
+ ];
+ }
+ if (newData.length > 30) {
+ newData.shift();
+ }
+ setConnectChartData(newData);
+ }
+ }, [data]);
+
+ const chartConfig = {
+ tcp: {
+ label: "TCP",
+ },
+ udp: {
+ label: "UDP",
+ },
+ } satisfies ChartConfig;
+
+ return (
+
+
+
+
+
+
+
+ formatRelativeTime(value)}
+ />
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/ServerDetailOverview.tsx b/src/components/ServerDetailOverview.tsx
new file mode 100644
index 0000000..35cf6f2
--- /dev/null
+++ b/src/components/ServerDetailOverview.tsx
@@ -0,0 +1,171 @@
+import { BackIcon } from "@/components/Icon";
+import { ServerDetailLoading } from "@/components/loading/ServerDetailLoading";
+import ServerFlag from "@/components/ServerFlag";
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent } from "@/components/ui/card";
+import { cn, formatBytes, formatNezhaInfo } from "@/lib/utils";
+import { NezhaAPIResponse } from "@/types/nezha-api";
+import { useNavigate, useParams } from "react-router-dom";
+import useWebSocket from "react-use-websocket";
+
+export default function ServerDetailOverview() {
+ const navigate = useNavigate();
+ const { id } = useParams();
+ const { lastMessage, readyState } = useWebSocket("/api/v1/ws/server", {
+ shouldReconnect: () => true,
+ reconnectInterval: 3000,
+ });
+
+ // 检查连接状态
+ if (readyState !== 1) {
+ return (
+
+ );
+ }
+
+ // 解析消息
+ const nezhaWsData = lastMessage
+ ? (JSON.parse(lastMessage.data) as NezhaAPIResponse)
+ : null;
+
+ if (!nezhaWsData) {
+ return ;
+ }
+
+ const server = nezhaWsData.servers.find((s) => s.id === Number(id));
+
+ if (!server) {
+ return ;
+ }
+
+ const { name, online, uptime, version } = formatNezhaInfo(server);
+
+ return (
+
+
navigate("/")}
+ className="flex flex-none cursor-pointer font-semibold leading-none items-center break-all tracking-tight gap-0.5 text-xl"
+ >
+
+ {name}
+
+
+
+
+
+ {"Status"}
+
+ {online ? "Online" : "Offline"}
+
+
+
+
+
+
+
+ {"Uptime"}
+
+ {" "}
+ {online ? (uptime / 86400).toFixed(0) : "N/A"} {"Days"}{" "}
+
+
+
+
+
+
+
+ {"Version"}
+ {version || "Unknown"}
+
+
+
+
+
+
+ {"Arch"}
+ {server.host.arch || "Unknown"}
+
+
+
+
+
+
+ {"Mem"}
+
+ {formatBytes(server.host.mem_total)}
+
+
+
+
+
+
+
+ {"Disk"}
+
+ {formatBytes(server.host.disk_total)}
+
+
+
+
+
+
+
+ {"Region"}
+
+
+ {server.host.country_code?.toUpperCase() || "Unknown"}
+
+ {server.host.country_code && (
+
+ )}
+
+
+
+
+
+
+
+
+
+ {"System"}
+ {server.host.platform ? (
+
+ {" "}
+ {server.host.platform || "Unknown"} -{" "}
+ {server.host.platform_version}{" "}
+
+ ) : (
+ Unknown
+ )}
+
+
+
+
+
+
+ {"CPU"}
+ {server.host.cpu ? (
+ {server.host.cpu}
+ ) : (
+ Unknown
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/components/loading/ServerDetailLoading.tsx b/src/components/loading/ServerDetailLoading.tsx
index 7e19723..5e78fe7 100644
--- a/src/components/loading/ServerDetailLoading.tsx
+++ b/src/components/loading/ServerDetailLoading.tsx
@@ -1,4 +1,3 @@
-
import { Skeleton } from "@/components/ui/skeleton";
import { BackIcon } from "../Icon";
import { useNavigate } from "react-router-dom";
diff --git a/src/components/ui/animated-circular-progress-bar.tsx b/src/components/ui/animated-circular-progress-bar.tsx
new file mode 100644
index 0000000..dd96fe8
--- /dev/null
+++ b/src/components/ui/animated-circular-progress-bar.tsx
@@ -0,0 +1,107 @@
+import { cn } from "@/lib/utils";
+
+interface Props {
+ max: number;
+ value: number;
+ min: number;
+ className?: string;
+ primaryColor?: string;
+}
+
+export default function AnimatedCircularProgressBar({
+ max = 100,
+ min = 0,
+ value = 0,
+ primaryColor,
+ className,
+}: Props) {
+ const circumference = 2 * Math.PI * 45;
+ const percentPx = circumference / 100;
+ const currentPercent = ((value - min) / (max - min)) * 100;
+
+ return (
+
+
+
+ {currentPercent}
+
+
+ );
+}
diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx
index f000e3e..d3d5d60 100644
--- a/src/components/ui/badge.tsx
+++ b/src/components/ui/badge.tsx
@@ -1,7 +1,7 @@
-import * as React from "react"
-import { cva, type VariantProps } from "class-variance-authority"
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
-import { cn } from "@/lib/utils"
+import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
@@ -20,8 +20,8 @@ const badgeVariants = cva(
defaultVariants: {
variant: "default",
},
- }
-)
+ },
+);
export interface BadgeProps
extends React.HTMLAttributes,
@@ -30,7 +30,7 @@ export interface BadgeProps
function Badge({ className, variant, ...props }: BadgeProps) {
return (
- )
+ );
}
-export { Badge, badgeVariants }
+export { Badge, badgeVariants };
diff --git a/src/components/ui/chart.tsx b/src/components/ui/chart.tsx
new file mode 100644
index 0000000..ba084ed
--- /dev/null
+++ b/src/components/ui/chart.tsx
@@ -0,0 +1,363 @@
+import * as React from "react";
+import * as RechartsPrimitive from "recharts";
+
+import { cn } from "@/lib/utils";
+
+// Format: { THEME_NAME: CSS_SELECTOR }
+const THEMES = { light: "", dark: ".dark" } as const;
+
+export type ChartConfig = {
+ [k in string]: {
+ label?: React.ReactNode;
+ icon?: React.ComponentType;
+ } & (
+ | { color?: string; theme?: never }
+ | { color?: never; theme: Record }
+ );
+};
+
+type ChartContextProps = {
+ config: ChartConfig;
+};
+
+const ChartContext = React.createContext(null);
+
+function useChart() {
+ const context = React.useContext(ChartContext);
+
+ if (!context) {
+ throw new Error("useChart must be used within a ");
+ }
+
+ return context;
+}
+
+const ChartContainer = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div"> & {
+ config: ChartConfig;
+ children: React.ComponentProps<
+ typeof RechartsPrimitive.ResponsiveContainer
+ >["children"];
+ }
+>(({ id, className, children, config, ...props }, ref) => {
+ const uniqueId = React.useId();
+ const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
+
+ return (
+
+
+
+
+ {children}
+
+
+
+ );
+});
+ChartContainer.displayName = "Chart";
+
+const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
+ const colorConfig = Object.entries(config).filter(
+ ([_, config]) => config.theme || config.color,
+ );
+
+ if (!colorConfig.length) {
+ return null;
+ }
+
+ return (
+