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 (
+ <>
+
+
+ >
+ );
+}
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 (
-
- );
+ return ;
}
- const server = nezhaWsData.servers.find(s => s.id === Number(id));
+ const server = nezhaWsData.servers.find((s) => s.id === Number(id));
if (!server) {
- return (
-
- );
+ 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
+ )}
+
+
+
+
);
}