diff --git a/bun.lockb b/bun.lockb index 2c56be6..c75ce23 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/index.html b/index.html index e4b78ea..1513e72 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,15 @@ - Vite + React + TS + + + NEZHA
diff --git a/package.json b/package.json index 3e2c1c0..b43ce1d 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@tanstack/react-query": "^5.59.16", @@ -24,12 +25,14 @@ "@types/luxon": "^3.4.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "country-flag-icons": "^1.5.13", "framer-motion": "^11.11.10", "lucide-react": "^0.453.0", "luxon": "^3.5.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.27.0", + "react-use-websocket": "^4.11.1", "sonner": "^1.5.0", "tailwind-merge": "^2.5.4", "tailwindcss-animate": "^1.0.7" diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 4870e24..7f3162f 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -7,9 +7,12 @@ const Footer: React.FC = () => {
©2020-{new Date().getFullYear()}{" "} - + Nezha + + Nezha-Dash +
diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 388f2c2..49ed0ff 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -21,13 +21,13 @@ function Header() { className="relative m-0! border-2 border-transparent h-6 w-6 object-cover object-top p-0!" /> - {"NezhaDash"} + {"NEZHA"}

- 哪吒监控面板 + 哪吒监控

diff --git a/src/components/ServerCard.tsx b/src/components/ServerCard.tsx new file mode 100644 index 0000000..06ce7df --- /dev/null +++ b/src/components/ServerCard.tsx @@ -0,0 +1,128 @@ +import ServerFlag from "@/components/ServerFlag"; +import ServerUsageBar from "@/components/ServerUsageBar"; + +import { cn, formatNezhaInfo } from "@/lib/utils"; +import { NezhaAPI } from "@/types/nezha-api"; +import { Card } from "./ui/card"; + +export default function ServerCard({ + serverInfo, +}: { + serverInfo: NezhaAPI; +}) { + + const { name, country_code, online, cpu, up, down, mem, stg } = + formatNezhaInfo(serverInfo); + + const showFlag = true + + + return online ? ( +
+ +
+ +
+ {showFlag ? : null} +
+
+

+ {name} +

+
+
+
+
+
+

{"CPU"}

+
+ {cpu.toFixed(2)}% +
+ +
+
+

{"MEM"}

+
+ {mem.toFixed(2)}% +
+ +
+
+

{"STG"}

+
+ {stg.toFixed(2)}% +
+ +
+
+

{"Upload"}

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

{"Download"}

+
+ {down >= 1024 + ? `${(down / 1024).toFixed(2)}G/s` + : `${down.toFixed(2)}M/s`} +
+
+
+
+
+
+ ) : ( + +
+ +
+ {showFlag ? : null} +
+
+

+ {name} +

+
+
+
+ ); +} diff --git a/src/components/ServerFlag.tsx b/src/components/ServerFlag.tsx new file mode 100644 index 0000000..82fdbca --- /dev/null +++ b/src/components/ServerFlag.tsx @@ -0,0 +1,48 @@ +import { cn } from "@/lib/utils"; +import getUnicodeFlagIcon from "country-flag-icons/unicode"; +import { useEffect, useState } from "react"; + +export default function ServerFlag({ + country_code, + className, +}: { + country_code: string; + className?: string; +}) { + const [supportsEmojiFlags, setSupportsEmojiFlags] = useState(false); + + + useEffect(() => { + const checkEmojiSupport = () => { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + const emojiFlag = "🇺🇸"; // 使用美国国旗作为测试 + if (!ctx) return; + ctx.fillStyle = "#000"; + ctx.textBaseline = "top"; + ctx.font = "32px Arial"; + ctx.fillText(emojiFlag, 0, 0); + + const support = ctx.getImageData(16, 16, 1, 1).data[3] !== 0; + setSupportsEmojiFlags(support); + }; + + checkEmojiSupport(); + }, []); + + if (!country_code) return null; + + if (supportsEmojiFlags && country_code.toLowerCase() === "tw") { + country_code = "cn"; + } + + return ( + + { !supportsEmojiFlags ? ( + + ) : ( + getUnicodeFlagIcon(country_code) + )} + + ); +} diff --git a/src/components/ServerOverview.tsx b/src/components/ServerOverview.tsx new file mode 100644 index 0000000..f2d3d4a --- /dev/null +++ b/src/components/ServerOverview.tsx @@ -0,0 +1,110 @@ +import { Card, CardContent } from "@/components/ui/card"; +import { cn, formatBytes } from "@/lib/utils"; + +type ServerOverviewProps = { + online: number; + offline: number; + total: number; + up: number; + down: number; +} + + +export default function ServerOverview({ online, offline, total, up, down }: ServerOverviewProps) { + + return ( + <> +
+ + +
+

+ {"Totalservers"} +

+
+ + + +
+ {total} +
+
+
+
+
+ + +
+

+ {"Onlineservers"} +

+
+ + + + + +
+ {online} +
+
+
+
+
+ + +
+

+ {"Offlineservers"} +

+
+ + + + +
+ {offline} +
+
+
+
+
+ + +
+

+ {"Totalbandwidth"} +

+ +
+

+ ↑{formatBytes(up)} +

+

+ ↓{formatBytes(down)} +

+
+
+ +
+
+
+ + ); +} diff --git a/src/components/ServerUsageBar.tsx b/src/components/ServerUsageBar.tsx new file mode 100644 index 0000000..e4e1efb --- /dev/null +++ b/src/components/ServerUsageBar.tsx @@ -0,0 +1,23 @@ +import { Progress } from "@/components/ui/progress"; + +type ServerUsageBarProps = { + value: number; +}; + +export default function ServerUsageBar({ value }: ServerUsageBarProps) { + return ( + 90 + ? "bg-red-500" + : value > 70 + ? "bg-orange-400" + : "bg-green-500" + } + className={"h-[3px] rounded-sm"} + /> + ); +} diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..ab6efa5 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,85 @@ +import { cn } from "@/lib/utils"; +import * as React from "react"; + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardFooter.displayName = "CardFooter"; + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +}; diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx new file mode 100644 index 0000000..9b5e72e --- /dev/null +++ b/src/components/ui/progress.tsx @@ -0,0 +1,28 @@ +import * as React from "react" +import * as ProgressPrimitive from "@radix-ui/react-progress" + +import { cn } from "@/lib/utils" + +const Progress = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + indicatorClassName?: string + } +>(({ className, value, indicatorClassName, ...props }, ref) => ( + + + +)) +Progress.displayName = ProgressPrimitive.Root.displayName + +export { Progress } diff --git a/src/hooks/use-websocket.tsx b/src/hooks/use-websocket.tsx index 62fdd8a..e4b2219 100644 --- a/src/hooks/use-websocket.tsx +++ b/src/hooks/use-websocket.tsx @@ -19,6 +19,10 @@ export default function useWebSocket(url: string): WebSocketHook { const connect = useCallback(() => { if (isUnmounted.current) return; + console.log("Connecting to WebSocket..."); + + console.log("WebSocket URL:", url); + const ws = new WebSocket(url); setSocket(ws); socketRef.current = ws; diff --git a/src/index.css b/src/index.css index 2a5cd60..8e36c62 100644 --- a/src/index.css +++ b/src/index.css @@ -19,7 +19,7 @@ @layer base { :root { - --background: 0 0% 100%; + --background: 0 0% 98%; --foreground: 20 14.3% 4.1%; --card: 0 0% 100%; --card-foreground: 20 14.3% 4.1%; @@ -52,7 +52,7 @@ } .dark { - --background: 20 14.3% 4.1%; + --background: 30 15% 8%; --foreground: 60 9.1% 97.8%; --card: 20 14.3% 4.1%; --card-foreground: 60 9.1% 97.8%; diff --git a/src/lib/logo-class.tsx b/src/lib/logo-class.tsx new file mode 100644 index 0000000..99ce400 --- /dev/null +++ b/src/lib/logo-class.tsx @@ -0,0 +1,148 @@ +import type { SVGProps } from "react"; + +export function GetFontLogoClass(platform: string): string { + if ( + [ + "almalinux", + "alpine", + "aosc", + "apple", + "archlinux", + "archlabs", + "artix", + "budgie", + "centos", + "coreos", + "debian", + "deepin", + "devuan", + "docker", + "elementary", + "fedora", + "ferris", + "flathub", + "freebsd", + "gentoo", + "gnu-guix", + "illumos", + "kali-linux", + "linuxmint", + "mageia", + "mandriva", + "manjaro", + "nixos", + "openbsd", + "opensuse", + "pop-os", + "raspberry-pi", + "redhat", + "rocky-linux", + "sabayon", + "slackware", + "snappy", + "solus", + "tux", + "ubuntu", + "void", + "zorin", + ].indexOf(platform) > -1 + ) { + return platform; + } + if (platform == "darwin") { + return "apple"; + } + if (["openwrt", "linux", "immortalwrt"].indexOf(platform) > -1) { + return "tux"; + } + if (platform == "amazon") { + return "redhat"; + } + if (platform == "arch") { + return "archlinux"; + } + if (platform.toLowerCase().includes("opensuse")) { + return "opensuse"; + } + return "tux"; +} + +export function GetOsName(platform: string): string { + if ( + [ + "almalinux", + "alpine", + "aosc", + "apple", + "archlinux", + "archlabs", + "artix", + "budgie", + "centos", + "coreos", + "debian", + "deepin", + "devuan", + "docker", + "fedora", + "ferris", + "flathub", + "freebsd", + "gentoo", + "gnu-guix", + "illumos", + "linuxmint", + "mageia", + "mandriva", + "manjaro", + "nixos", + "openbsd", + "opensuse", + "pop-os", + "redhat", + "sabayon", + "slackware", + "snappy", + "solus", + "tux", + "ubuntu", + "void", + "zorin", + ].indexOf(platform) > -1 + ) { + return platform.charAt(0).toUpperCase() + platform.slice(1); + } + if (platform == "darwin") { + return "macOS"; + } + if (["openwrt", "linux", "immortalwrt"].indexOf(platform) > -1) { + return "Linux"; + } + if (platform == "amazon") { + return "Redhat"; + } + if (platform == "arch") { + return "Archlinux"; + } + if (platform.toLowerCase().includes("opensuse")) { + return "Opensuse"; + } + return "Linux"; +} + +export function MageMicrosoftWindows(props: SVGProps) { + return ( + + + + ); +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index a5ef193..fae75a3 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,123 @@ -import { clsx, type ClassValue } from "clsx"; +import { NezhaAPI } from "@/types/nezha-api"; +import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +export function formatNezhaInfo(serverInfo: NezhaAPI) { + const lastActiveTime = parseISOTimestamp(serverInfo.last_active); + return { + ...serverInfo, + cpu: serverInfo.state.cpu || 0, + process: serverInfo.state.process_count || 0, + up: serverInfo.state.net_out_speed / 1024 / 1024 || 0, + down: serverInfo.state.net_in_speed / 1024 / 1024 || 0, + online: Date.now() - lastActiveTime <= 300000, + tcp: serverInfo.state.tcp_conn_count || 0, + udp: serverInfo.state.udp_conn_count || 0, + mem: (serverInfo.state.mem_used / serverInfo.host.mem_total) * 100 || 0, + swap: (serverInfo.state.swap_used / serverInfo.host.swap_total) * 100 || 0, + disk: (serverInfo.state.disk_used / serverInfo.host.disk_total) * 100 || 0, + stg: (serverInfo.state.disk_used / serverInfo.host.disk_total) * 100 || 0, + country_code: serverInfo.host.country_code, + }; +} + +export function formatBytes(bytes: number, decimals: number = 2) { + if (!+bytes) return "0 Bytes"; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = [ + "Bytes", + "KiB", + "MiB", + "GiB", + "TiB", + "PiB", + "EiB", + "ZiB", + "YiB", + ]; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`; +} + +export function getDaysBetweenDates(date1: string, date2: string): number { + const oneDay = 24 * 60 * 60 * 1000; // 一天的毫秒数 + const firstDate = new Date(date1); + const secondDate = new Date(date2); + + // 计算两个日期之间的天数差异 + return Math.round( + Math.abs((firstDate.getTime() - secondDate.getTime()) / oneDay), + ); +} + +export const fetcher = (url: string) => + fetch(url) + .then((res) => { + if (!res.ok) { + throw new Error(res.statusText); + } + return res.json(); + }) + .then((data) => data.data) + .catch((err) => { + console.error(err); + throw err; + }); + +export const nezhaFetcher = async (url: string) => { + const res = await fetch(url); + + if (!res.ok) { + const error = new Error("An error occurred while fetching the data."); + // @ts-expect-error - res.json() returns a Promise + error.info = await res.json(); + // @ts-expect-error - res.status is a number + error.status = res.status; + throw error; + } + + return res.json(); +}; + +export function parseISOTimestamp(isoString: string): number { + return new Date(isoString).getTime(); +} + +export function formatRelativeTime(timestamp: number): string { + const now = Date.now(); + const diff = now - timestamp; + const hours = Math.floor(diff / (1000 * 60 * 60)); + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((diff % (1000 * 60)) / 1000); + + if (hours > 24) { + const days = Math.floor(hours / 24); + return `${days}d`; + } else if (hours > 0) { + return `${hours}h`; + } else if (minutes > 0) { + return `${minutes}m`; + } else if (seconds >= 0) { + return `${seconds}s`; + } + return "0s"; +} + +export function formatTime(timestamp: number): string { + const date = new Date(timestamp); + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + const hours = date.getHours().toString().padStart(2, "0"); + const minutes = date.getMinutes().toString().padStart(2, "0"); + const seconds = date.getSeconds().toString().padStart(2, "0"); + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; +} diff --git a/src/lib/websocketContext.tsx b/src/lib/websocketContext.tsx deleted file mode 100644 index 16f6ca8..0000000 --- a/src/lib/websocketContext.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { createContext, useContext } from "react"; -import { WebSocketHook } from "../hooks/use-websocket"; - -export const WebSocketContext = createContext(undefined); - -export const useWebSocketContext = (): WebSocketHook => { - const context = useContext(WebSocketContext); - if (!context) { - throw new Error( - "useWebSocketContext must be used within a WebSocketProvider", - ); - } - return context; -}; diff --git a/src/lib/websocketProvider.tsx b/src/lib/websocketProvider.tsx deleted file mode 100644 index b951e12..0000000 --- a/src/lib/websocketProvider.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { ReactNode } from "react"; -import useWebSocket from "../hooks/use-websocket"; -import { WebSocketContext } from "./websocketContext"; - -interface WebSocketProviderProps { - children: ReactNode; -} - -export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { - const ws = useWebSocket('/api/v1/ws/server'); - return ( - {children} - ); -}; diff --git a/src/main.tsx b/src/main.tsx index daa8280..6b7ab3a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,20 +6,18 @@ import { ThemeProvider } from "./components/ThemeProvider"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Toaster } from "sonner"; -import { WebSocketProvider } from "./lib/websocketProvider"; + const queryClient = new QueryClient(); ReactDOM.createRoot(document.getElementById("root")!).render( - - , ); diff --git a/src/pages/Server.tsx b/src/pages/Server.tsx index 61b6208..5984ab1 100644 --- a/src/pages/Server.tsx +++ b/src/pages/Server.tsx @@ -1,38 +1,44 @@ -import { useWebSocketContext } from "@/lib/websocketContext"; -import { NezhaAPI } from "@/types/nezha-api"; - +import useWebSocket from 'react-use-websocket'; +import { NezhaAPIResponse } from "@/types/nezha-api"; +import ServerCard from '@/components/ServerCard'; +import { formatNezhaInfo } from '@/lib/utils'; +import ServerOverview from '@/components/ServerOverview'; export default function Servers() { - const { connected, message } = useWebSocketContext() + const { lastMessage, readyState } = useWebSocket('/api/v1/ws/server', { + shouldReconnect: () => true, // 自动重连 + reconnectInterval: 3000, // 重连间隔 + }); - if (!connected || !message) { - return ( -

连接中...

- ) + // 检查连接状态 + if (readyState !== 1) { + return null; } - const nezhaWsData = JSON.parse(message) as NezhaAPI[] + // 解析消息 + const nezhaWsData = lastMessage ? JSON.parse(lastMessage.data) as NezhaAPIResponse : null; - console.log(nezhaWsData) + if (!nezhaWsData) { + return

等待数据...

; + } + + // 计算服务器总数和在线数量 + const totalServers = nezhaWsData.servers.length; + const onlineServers = nezhaWsData.servers.filter(server => formatNezhaInfo(server).online).length; + const offlineServers = nezhaWsData.servers.filter(server => !formatNezhaInfo(server).online).length; + const up = nezhaWsData.servers.reduce((total, server) => total + server.state.net_out_transfer, 0); + const down = nezhaWsData.servers.reduce((total, server) => total + server.state.net_in_transfer, 0); return ( -
-
-
-

- 服务器 -

-

- 你可以在这里查看和管理全部的服务器。 - - 了解更多↗ - -

-
-
+
+ +
+ {nezhaWsData.servers.map((serverInfo) => ( + + ))} +
); -} +} \ No newline at end of file diff --git a/src/types/nezha-api.ts b/src/types/nezha-api.ts index f46aa77..6546e1a 100644 --- a/src/types/nezha-api.ts +++ b/src/types/nezha-api.ts @@ -1,39 +1,46 @@ +export interface NezhaAPIResponse { + now: number; + servers: NezhaAPI[]; +} + + export interface NezhaAPI { id: number; name: string; + last_active: string; host: NezhaAPIHost; - status: NezhaAPIStatus; + state: NezhaAPIStatus; } export interface NezhaAPIHost { - Platform: string; - PlatformVersion: string; - CPU: string[]; - MemTotal: number; - DiskTotal: number; - SwapTotal: number; - Arch: string; - BootTime: number; - CountryCode: string; - Version: string; + platform: string; + platform_version: string; + cpu: string[]; + mem_total: number; + disk_total: number; + swap_total: number; + arch: string; + boot_time: number; + country_code: string; + version: string; } export interface NezhaAPIStatus { - CPU: number; - MemUsed: number; - SwapUsed: number; - DiskUsed: number; - NetInTransfer: number; - NetOutTransfer: number; - NetInSpeed: number; - NetOutSpeed: number; - Uptime: number; - Load1: number; - Load5: number; - Load15: number; - TcpConnCount: number; - UdpConnCount: number; - ProcessCount: number; - Temperatures: number; - GPU: number; + cpu: number; + mem_used: number; + swap_used: number; + disk_used: number; + net_in_transfer: number; + net_out_transfer: number; + net_in_speed: number; + net_out_speed: number; + uptime: number; + load_1: number; + load_5: number; + load_15: number; + tcp_conn_count: number; + udp_conn_count: number; + process_count: number; + temperatures: number; + gpu: number; } diff --git a/vite.config.ts b/vite.config.ts index bdb68f3..ef849d0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,8 +12,8 @@ export default defineConfig({ }, server: { proxy: { - '/api/v1/ws': { - target: 'http://localhost:8008', + '/api/v1/ws/server': { + target: 'ws://localhost:8080', changeOrigin: true, ws: true, },