mirror of
https://github.com/woodchen-ink/nezha-dash-v1.git
synced 2025-07-18 09:31:55 +08:00
feat: server page
This commit is contained in:
parent
d1558b71c4
commit
963b6a54a6
10
index.html
10
index.html
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
|
@ -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">
|
||||
|
128
src/components/ServerCard.tsx
Normal file
128
src/components/ServerCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
48
src/components/ServerFlag.tsx
Normal file
48
src/components/ServerFlag.tsx
Normal 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>
|
||||
);
|
||||
}
|
110
src/components/ServerOverview.tsx
Normal file
110
src/components/ServerOverview.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
23
src/components/ServerUsageBar.tsx
Normal file
23
src/components/ServerUsageBar.tsx
Normal 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"}
|
||||
/>
|
||||
);
|
||||
}
|
85
src/components/ui/card.tsx
Normal file
85
src/components/ui/card.tsx
Normal 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,
|
||||
};
|
28
src/components/ui/progress.tsx
Normal file
28
src/components/ui/progress.tsx
Normal 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 }
|
@ -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;
|
||||
|
@ -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
148
src/lib/logo-class.tsx
Normal 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>
|
||||
);
|
||||
}
|
119
src/lib/utils.ts
119
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<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}`;
|
||||
}
|
||||
|
@ -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;
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>,
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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,
|
||||
},
|
||||
|
Loading…
x
Reference in New Issue
Block a user