diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx new file mode 100644 index 0000000..8fa77f3 --- /dev/null +++ b/src/components/Icon.tsx @@ -0,0 +1,32 @@ +export function GitHubIcon(props: React.ComponentPropsWithoutRef<"svg">) { + return ( + + + + ); +} + +export function BackIcon() { + return ( + <> + BackIcon + BackIcon + + ); +} diff --git a/src/components/loading/ServerDetailLoading.tsx b/src/components/loading/ServerDetailLoading.tsx new file mode 100644 index 0000000..7e19723 --- /dev/null +++ b/src/components/loading/ServerDetailLoading.tsx @@ -0,0 +1,38 @@ + +import { Skeleton } from "@/components/ui/skeleton"; +import { BackIcon } from "../Icon"; +import { useNavigate } from "react-router-dom"; + +export function ServerDetailChartLoading() { + return ( +
+
+ + + + + + +
+
+ ); +} + +export function ServerDetailLoading() { + const navigate = useNavigate(); + + return ( +
+
{ + navigate("/"); + }} + className="flex flex-none cursor-pointer font-semibold leading-none items-center break-all tracking-tight gap-0.5 text-xl" + > + + +
+ +
+ ); +} diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..f000e3e --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +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", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/src/hooks/use-websocket.tsx b/src/hooks/use-websocket.tsx deleted file mode 100644 index afa5bf4..0000000 --- a/src/hooks/use-websocket.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { useState, useEffect, useRef, useCallback } from "react"; - -export interface WebSocketHook { - socket: WebSocket | null; - connected: boolean; - message: string | null; - sendMessage: (msg: string) => void; -} - -export default function useWebSocket(url: string): WebSocketHook { - const [socket, setSocket] = useState(null); - const [message, setMessage] = useState(null); - const [connected, setConnected] = useState(false); - const socketRef = useRef(null); - const reconnectAttempts = useRef(0); - const reconnectTimeout = useRef(null); - const isUnmounted = useRef(false); - - 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; - - ws.onopen = () => { - setConnected(true); - reconnectAttempts.current = 0; - }; - - ws.onmessage = (event: MessageEvent) => { - setMessage(event.data); - }; - - ws.onerror = (error) => { - console.error("WebSocket Error:", error); - // 在错误发生时主动关闭连接,触发重连 - if (ws.readyState === WebSocket.OPEN) { - ws.close(); - } - }; - - ws.onclose = () => { - setConnected(false); - // 清理当前的 socket - socketRef.current = null; - - if (!isUnmounted.current) { - // 检查是否已经在重连中 - if (reconnectTimeout.current) { - clearTimeout(reconnectTimeout.current); - } - - // Attempt to reconnect with increased max attempts - if (reconnectAttempts.current < 10) { - const timeout = Math.min( - Math.pow(2, reconnectAttempts.current) * 1000, - 30000, - ); // 最大30秒 - reconnectAttempts.current += 1; - console.log( - `Attempting to reconnect in ${timeout / 1000} seconds...`, - ); - reconnectTimeout.current = setTimeout(() => { - connect(); - }, timeout); - } else { - console.warn( - "Max reconnect attempts reached. Please refresh the page to try again.", - ); - } - } - }; - }, [url]); - - useEffect(() => { - connect(); - - return () => { - isUnmounted.current = true; - if (socketRef.current) { - socketRef.current.close(); - } - if (reconnectTimeout.current) { - clearTimeout(reconnectTimeout.current); - } - }; - }, [connect]); - - // Function to send messages - const sendMessage = useCallback((msg: string) => { - if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) { - socketRef.current.send(msg); - } else { - console.warn( - "WebSocket is not open. Ready state:", - socketRef.current?.readyState, - ); - } - }, []); - - return { socket, message, sendMessage, connected }; -} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index fae75a3..12c9db3 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -15,6 +15,8 @@ export function formatNezhaInfo(serverInfo: NezhaAPI) { up: serverInfo.state.net_out_speed / 1024 / 1024 || 0, down: serverInfo.state.net_in_speed / 1024 / 1024 || 0, online: Date.now() - lastActiveTime <= 300000, + uptime: serverInfo.state.uptime || 0, + version: serverInfo.host.version || null, 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, diff --git a/src/pages/ServerDetail.tsx b/src/pages/ServerDetail.tsx index b5ddb72..11e3769 100644 --- a/src/pages/ServerDetail.tsx +++ b/src/pages/ServerDetail.tsx @@ -1,8 +1,17 @@ -import { useParams } from "react-router-dom"; -import useWebSocket from "react-use-websocket"; + +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 ServerDetail() { + const navigate = useNavigate(); const { id } = useParams(); const { lastMessage, readyState } = useWebSocket("/api/v1/ws/server", { shouldReconnect: () => true, @@ -25,27 +34,140 @@ export default function ServerDetail() { : null; if (!nezhaWsData) { - return ( -
-

processing...

-
- ); + return ; } - const server = nezhaWsData.servers.find(s => s.id === Number(id)); + const server = nezhaWsData.servers.find((s) => s.id === Number(id)); if (!server) { - return ( -
-

Server not found

-
- ); + return ; } + const { name, online,uptime,version } = + formatNezhaInfo(server); + + + return (
-

{server.name}

- {/* TODO: Add more server details here */} +
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
+ )} +
+
+
+
); }