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 ( +
+

connecting...

+
+ ); + } + + // 解析消息 + 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 ( + + +
+
+

{"Process"}

+
+

{process}

+
+
+ + + + 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 ( + + +
+
+
+
+

{"Mem"}

+
+ +

{mem.toFixed(0)}%

+
+
+
+

{"Swap"}

+
+ +

{swap.toFixed(0)}%

+
+
+
+
+ + + + 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 ( + + +
+
+
+
+

TCP

+
+ +

{tcp}

+
+
+
+

UDP

+
+ +

{udp}

+
+
+
+
+ + + + 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 ( +
+

connecting...

+
+ ); + } + + // 解析消息 + 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 <= 90 && currentPercent >= 0 && ( + + )} + + + + {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 ( +