feat: server page

This commit is contained in:
hamster1963 2024-11-23 19:28:55 +08:00
parent d1558b71c4
commit 963b6a54a6
21 changed files with 784 additions and 96 deletions

BIN
bun.lockb

Binary file not shown.

View File

@ -4,7 +4,15 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.0.0/css/flag-icons.min.css"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/font-logos@1/assets/font-logos.css"
/>
<title>NEZHA</title>
</head>
<body>
<div id="root"></div>

View File

@ -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"

View File

@ -7,9 +7,12 @@ const Footer: React.FC = () => {
<section className="flex flex-col">
<section className="mt-1 flex items-center gap-2 text-[13px] font-light tracking-tight text-neutral-600/50 dark:text-neutral-300/50">
©2020-{new Date().getFullYear()}{" "}
<a href={"https://nezha.wiki"} target="_blank">
<a href={"https://github.com/naiba/nezha"} target="_blank">
Nezha
</a>
<a href={"https://github.com/hamster1963/nezha-dash-react"} target="_blank">
Nezha-Dash
</a>
</section>
</section>
</footer>

View File

@ -21,13 +21,13 @@ function Header() {
className="relative m-0! border-2 border-transparent h-6 w-6 object-cover object-top p-0!"
/>
</div>
{"NezhaDash"}
{"NEZHA"}
<Separator
orientation="vertical"
className="mx-2 hidden h-4 w-[1px] md:block"
/>
<p className="hidden text-sm font-medium opacity-40 md:block">
</p>
</section>
<section className="flex items-center gap-2">

View File

@ -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 ? (
<section >
<Card
className={cn(
"flex flex-col items-center justify-start gap-3 p-3 md:px-5 lg:flex-row",
)}
>
<section
className={cn("grid items-center gap-2 lg:w-40")}
style={{ gridTemplateColumns: "auto auto 1fr" }}
>
<span className="h-2 w-2 shrink-0 rounded-full bg-green-500 self-center"></span>
<div
className={cn(
"flex items-center justify-center",
showFlag ? "min-w-[17px]" : "min-w-0",
)}
>
{showFlag ? <ServerFlag country_code={country_code} /> : null}
</div>
<div className="relative">
<p
className={cn(
"break-all font-bold tracking-tight",
showFlag ? "text-xs " : "text-sm",
)}
>
{name}
</p>
</div>
</section>
<div className="flex flex-col gap-2">
<section
className={cn("grid grid-cols-5 items-center gap-3")}
>
<div className={"flex w-14 flex-col"}>
<p className="text-xs text-muted-foreground">{"CPU"}</p>
<div className="flex items-center text-xs font-semibold">
{cpu.toFixed(2)}%
</div>
<ServerUsageBar value={cpu} />
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-xs text-muted-foreground">{"MEM"}</p>
<div className="flex items-center text-xs font-semibold">
{mem.toFixed(2)}%
</div>
<ServerUsageBar value={mem} />
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-xs text-muted-foreground">{"STG"}</p>
<div className="flex items-center text-xs font-semibold">
{stg.toFixed(2)}%
</div>
<ServerUsageBar value={stg} />
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-xs text-muted-foreground">{"Upload"}</p>
<div className="flex items-center text-xs font-semibold">
{up >= 1024
? `${(up / 1024).toFixed(2)}G/s`
: `${up.toFixed(2)}M/s`}
</div>
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-xs text-muted-foreground">{"Download"}</p>
<div className="flex items-center text-xs font-semibold">
{down >= 1024
? `${(down / 1024).toFixed(2)}G/s`
: `${down.toFixed(2)}M/s`}
</div>
</div>
</section>
</div>
</Card>
</section>
) : (
<Card
className={cn(
"flex flex-col items-center justify-start gap-3 p-3 md:px-5 lg:flex-row",
)}
>
<section
className={cn("grid items-center gap-2 lg:w-40")}
style={{ gridTemplateColumns: "auto auto 1fr" }}
>
<span className="h-2 w-2 shrink-0 rounded-full bg-red-500 self-center"></span>
<div
className={cn(
"flex items-center justify-center",
showFlag ? "min-w-[17px]" : "min-w-0",
)}
>
{showFlag ? <ServerFlag country_code={country_code} /> : null}
</div>
<div className="relative">
<p
className={cn(
"break-all font-bold tracking-tight",
showFlag ? "text-xs" : "text-sm",
)}
>
{name}
</p>
</div>
</section>
</Card>
);
}

View File

@ -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 (
<span className={cn("text-[12px] text-muted-foreground", className)}>
{ !supportsEmojiFlags ? (
<span className={`fi fi-${country_code}`} />
) : (
getUnicodeFlagIcon(country_code)
)}
</span>
);
}

View File

@ -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 (
<>
<section className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<Card
className={cn("hover:border-blue-500 transition-all")}
>
<CardContent className="px-6 py-3">
<section className="flex flex-col gap-1">
<p className="text-sm font-medium md:text-base">
{"Totalservers"}
</p>
<div className="flex items-center gap-2">
<span className="relative flex h-2 w-2">
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500"></span>
</span>
<div className="text-lg font-semibold">
{total}
</div>
</div>
</section>
</CardContent>
</Card>
<Card
className={cn(
" hover:ring-green-500 ring-1 ring-transparent transition-all",
)}
>
<CardContent className="px-6 py-3">
<section className="flex flex-col gap-1">
<p className="text-sm font-medium md:text-base">
{"Onlineservers"}
</p>
<div className="flex items-center gap-2">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-500 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-green-500"></span>
</span>
<div className="text-lg font-semibold">
{online}
</div>
</div>
</section>
</CardContent>
</Card>
<Card
className={cn(
" hover:ring-red-500 ring-1 ring-transparent transition-all",
)}
>
<CardContent className="px-6 py-3">
<section className="flex flex-col gap-1">
<p className="text-sm font-medium md:text-base">
{"Offlineservers"}
</p>
<div className="flex items-center gap-2">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-500 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-red-500"></span>
</span>
<div className="text-lg font-semibold">
{offline}
</div>
</div>
</section>
</CardContent>
</Card>
<Card
className={cn(
" hover:ring-purple-500 ring-1 ring-transparent transition-all",
)}
>
<CardContent className="relative px-6 py-3">
<section className="flex flex-col gap-1">
<p className="text-sm font-medium md:text-base">
{"Totalbandwidth"}
</p>
<section className="flex flex-col sm:flex-row pt-[8px] sm:items-center items-start gap-1">
<p className="text-[12px] text-nowrap font-semibold">
{formatBytes(up)}
</p>
<p className="text-[12px] text-nowrap font-semibold">
{formatBytes(down)}
</p>
</section>
</section>
</CardContent>
</Card>
</section>
</>
);
}

View File

@ -0,0 +1,23 @@
import { Progress } from "@/components/ui/progress";
type ServerUsageBarProps = {
value: number;
};
export default function ServerUsageBar({ value }: ServerUsageBarProps) {
return (
<Progress
aria-label={"Server Usage Bar"}
aria-labelledby={"Server Usage Bar"}
value={value}
indicatorClassName={
value > 90
? "bg-red-500"
: value > 70
? "bg-orange-400"
: "bg-green-500"
}
className={"h-[3px] rounded-sm"}
/>
);
}

View File

@ -0,0 +1,85 @@
import { cn } from "@/lib/utils";
import * as React from "react";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-lg shadow-neutral-200/40 dark:shadow-none",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View File

@ -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<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
indicatorClassName?: string
}
>(({ className, value, indicatorClassName, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className={cn("h-full w-full flex-1 bg-primary transition-all", indicatorClassName)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@ -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;

View File

@ -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%;

148
src/lib/logo-class.tsx Normal file
View File

@ -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<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
{...props}
>
<path
fill="currentColor"
d="M2.75 7.189V2.865c0-.102 0-.115.115-.115h8.622c.128 0 .14 0 .14.128V11.5c0 .128 0 .128-.14.128H2.865c-.102 0-.115 0-.115-.116zM7.189 21.25H2.865c-.102 0-.115 0-.115-.116V12.59c0-.128 0-.128.128-.128h8.635c.102 0 .115 0 .115.115v8.57c0 .09 0 .103-.116.103zM21.25 7.189v4.31c0 .116 0 .116-.116.116h-8.557c-.102 0-.128 0-.128-.115V2.865c0-.09 0-.102.115-.102h8.48c.206 0 .206 0 .206.205zm-8.763 9.661v-4.273c0-.09 0-.115.103-.09h8.621c.026 0 0 .09 0 .142v8.518a.06.06 0 0 1-.017.06a.06.06 0 0 1-.06.017H12.54s-.09 0-.077-.09V16.85z"
></path>
</svg>
);
}

View File

@ -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<any>
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}`;
}

View File

@ -1,14 +0,0 @@
import { createContext, useContext } from "react";
import { WebSocketHook } from "../hooks/use-websocket";
export const WebSocketContext = createContext<WebSocketHook | undefined>(undefined);
export const useWebSocketContext = (): WebSocketHook => {
const context = useContext(WebSocketContext);
if (!context) {
throw new Error(
"useWebSocketContext must be used within a WebSocketProvider",
);
}
return context;
};

View File

@ -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 (
<WebSocketContext.Provider value={ws}>{children}</WebSocketContext.Provider>
);
};

View File

@ -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(
<React.StrictMode>
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<WebSocketProvider>
<QueryClientProvider client={queryClient}>
<App />
<Toaster richColors position="top-right" />
<ReactQueryDevtools />
</QueryClientProvider>
</WebSocketProvider>
</ThemeProvider>
</React.StrictMode>,
);

View File

@ -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 (
<p>...</p>
)
// 检查连接状态
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 <div className='flex flex-col items-center justify-center '><p className='font-semibold text-sm'>...</p></div>;
}
// 计算服务器总数和在线数量
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 (
<div className="mx-auto w-full max-w-5xl px-4 lg:px-0">
<div className="flex justify-between mb-4 mt-4 items-center">
<section className="flex flex-col gap-2">
<h2 className="mt-0 scroll-m-20 text-3xl font-semibold tracking-tight transition-colors">
</h2>
<p className="text-sm font-medium">
<a
href="#"
className="font-medium text-primary underline underline-offset-4"
<div className="mx-auto w-full max-w-5xl px-0">
<ServerOverview total={totalServers} online={onlineServers} offline={offlineServers} up={up} down={down} />
<section
className="grid grid-cols-1 gap-2 md:grid-cols-2 mt-6"
>
</a>
</p>
{nezhaWsData.servers.map((serverInfo) => (
<ServerCard key={serverInfo.id} serverInfo={serverInfo} />
))}
</section>
</div>
</div>
);
}

View File

@ -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;
}

View File

@ -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,
},