diff --git a/.env.example b/.env.example index e2c860e..b46311b 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,13 @@ NezhaBaseUrl=http://124.XX.XX.XX:8008 NezhaAuth=your-nezha-api-token DefaultLocale=zh +ForceShowAllServers=false NEXT_PUBLIC_NezhaFetchInterval=5000 NEXT_PUBLIC_ShowFlag=true -NEXT_PUBLIC_DisableCartoon=true \ No newline at end of file +NEXT_PUBLIC_DisableCartoon=false +NEXT_PUBLIC_ShowTag=true +NEXT_PUBLIC_ShowNetTransfer=false +NEXT_PUBLIC_ForceUseSvgFlag=false +NEXT_PUBLIC_CustomLogo=https://nezha-cf.buycoffee.top/apple-touch-icon.png +NEXT_PUBLIC_CustomTitle=NezhaDash +NEXT_PUBLIC_CustomDescription=NezhaDash is a dashboard for Nezha. diff --git a/.github/get-token.png b/.github/get-token.png deleted file mode 100644 index 4b21dda..0000000 Binary files a/.github/get-token.png and /dev/null differ diff --git a/.github/shot-1-dark.png b/.github/shot-1-dark.png new file mode 100644 index 0000000..630ba25 Binary files /dev/null and b/.github/shot-1-dark.png differ diff --git a/.github/shot-1.png b/.github/shot-1.png new file mode 100644 index 0000000..c1ac9ff Binary files /dev/null and b/.github/shot-1.png differ diff --git a/.github/shot-2-dark.png b/.github/shot-2-dark.png new file mode 100644 index 0000000..4c3975e Binary files /dev/null and b/.github/shot-2-dark.png differ diff --git a/.github/shot-2.png b/.github/shot-2.png new file mode 100644 index 0000000..8bd53a7 Binary files /dev/null and b/.github/shot-2.png differ diff --git a/.github/shot-3-dark.png b/.github/shot-3-dark.png new file mode 100644 index 0000000..25288c7 Binary files /dev/null and b/.github/shot-3-dark.png differ diff --git a/.github/shot-3.png b/.github/shot-3.png new file mode 100644 index 0000000..ba6dae5 Binary files /dev/null and b/.github/shot-3.png differ diff --git a/.github/workflows/Deploy.yml b/.github/workflows/Deploy.yml index 5e2c76f..76be8c1 100644 --- a/.github/workflows/Deploy.yml +++ b/.github/workflows/Deploy.yml @@ -10,33 +10,6 @@ env: ALIYUN_REGISTRY_IMAGE: registry.cn-guangzhou.aliyuncs.com/hamster-home/nezha-dash jobs: - changelog: - name: Generate Changelog - runs-on: ubuntu-latest - outputs: - release_body: ${{ steps.git-cliff.outputs.content }} - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Generate a changelog - uses: orhun/git-cliff-action@v4 - id: git-cliff - with: - config: git-cliff-config/cliff.toml - args: -vv --latest --strip 'footer' - env: - OUTPUT: CHANGES.md - - name: Release - uses: softprops/action-gh-release@v2 - if: startsWith(github.ref, 'refs/tags/') - with: - body: ${{ steps.git-cliff.outputs.content }} - token: ${{ secrets.GITHUB_TOKEN }} - env: - GITHUB_REPOSITORY: ${{ github.repository }} - build-and-push: name: Build and push Docker image runs-on: ubuntu-latest @@ -83,3 +56,31 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + + changelog: + name: Generate Changelog + runs-on: ubuntu-latest + needs: build-and-push + outputs: + release_body: ${{ steps.git-cliff.outputs.content }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Generate a changelog + uses: orhun/git-cliff-action@v4 + id: git-cliff + with: + config: git-cliff-config/cliff.toml + args: -vv --latest --strip 'footer' + env: + OUTPUT: CHANGES.md + - name: Release + uses: softprops/action-gh-release@v2 + if: startsWith(github.ref, 'refs/tags/') + with: + body: ${{ steps.git-cliff.outputs.content }} + token: ${{ secrets.GITHUB_TOKEN }} + env: + GITHUB_REPOSITORY: ${{ github.repository }} diff --git a/Dockerfile b/Dockerfile index fcfe033..776669f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,43 +1,25 @@ FROM oven/bun:1 AS base +# Stage 1: Install dependencies FROM base AS deps WORKDIR /app +COPY package.json bun.lockb ./ +RUN bun install --frozen-lockfile - -COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* bun.lockb* ./ -RUN bun install - - +# Stage 2: Build the application FROM base AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . - - RUN bun run build - -FROM node:21-alpine AS runner - +# Stage 3: Production image +FROM oven/bun:1-alpine AS runner WORKDIR /app - -ENV NODE_ENV production -ENV NEXT_TELEMETRY_DISABLED 1 - -RUN addgroup --system --gid 1001 nodejs -RUN adduser --system --uid 1001 nextjs - +ENV NODE_ENV=production COPY --from=builder /app/public ./public - -COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ -COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static -COPY --from=builder --chown=nextjs:nodejs /app/.env.example ./.env - -USER nextjs +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static EXPOSE 3000 - -ENV PORT 3000 -ENV HOSTNAME "0.0.0.0" - -CMD ["node", "server.js"] +CMD ["bun", "run", "server.js"] diff --git a/README.md b/README.md index 2291b6d..3857359 100644 --- a/README.md +++ b/README.md @@ -5,21 +5,28 @@ -| 一键部署到 Vercel-推荐 | Docker部署 | Cloudflare部署 | -| ----------------------------------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------- | -| [部署简易教程](https://buycoffee.top/blog/tech/nezha) | [Docker 部署教程](https://buycoffee.top/blog/tech/nezha-docker) | [Cloudflare 部署教程](https://buycoffee.top/blog/tech/nezha-cloudflare) | +| 一键部署到 Vercel-推荐 | Docker部署 | Cloudflare部署 | 如何更新? | +| ----------------------------------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------- | +| [部署简易教程](https://buycoffee.top/blog/tech/nezha) | [Docker 部署教程](https://buycoffee.top/blog/tech/nezha-docker) | [Cloudflare 部署教程](https://buycoffee.top/blog/tech/nezha-cloudflare) | [更新教程](https://buycoffee.top/blog/tech/nezha-upgrade) | | [Vercel-demo](https://nezha-dash-ruddy.vercel.app) | [Docker-demo](https://nezha-docker.buycoffee.tech) | [Cloudflare-demo](https://nezha-cloudflare.buycoffee.tech) | #### 环境变量 -| 变量名 | 含义 | 示例 | -| ------------------------------ | -------------------------------- | -------------------------------- | -| NezhaBaseUrl | nezha 面板地址 | http://120.x.x.x:8008 | -| NezhaAuth | nezha 面板 API Token | 5hAY3QX6Nl9B3Uxxxx26KMvOMyXS1Udi | -| DefaultLocale | 面板默认显示语言(代码参考下表) | **默认**:en | -| NEXT_PUBLIC_NezhaFetchInterval | 获取数据间隔(毫秒) | **默认**:2000 | -| NEXT_PUBLIC_ShowFlag | 是否显示旗帜 | **默认**:false | -| NEXT_PUBLIC_DisableCartoon | 是否禁用卡通人物 | **默认**:false | +| 变量名 | 含义 | 示例 | +| ------------------------------ | -------------------------------- | ------------------------------------------------------------- | +| NezhaBaseUrl | nezha 面板地址 | http://120.x.x.x:8008 | +| NezhaAuth | nezha 面板 API Token | 5hAY3QX6Nl9B3Uxxxx26KMvOMyXS1Udi | +| DefaultLocale | 面板默认显示语言(代码参考下表) | **默认**:en | +| ForceShowAllServers | 是否强制显示所有服务器 | **默认**:false | +| NEXT_PUBLIC_NezhaFetchInterval | 获取数据间隔(毫秒) | **默认**:2000 | +| NEXT_PUBLIC_ShowFlag | 是否显示旗帜 | **默认**:false | +| NEXT_PUBLIC_DisableCartoon | 是否禁用卡通人物 | **默认**:false | +| NEXT_PUBLIC_ShowTag | 是否显示标签 | **默认**:false | +| NEXT_PUBLIC_ShowNetTransfer | 是否显示流量信息 | **默认**:false | +| NEXT_PUBLIC_ForceUseSvgFlag | 是否强制使用SVG旗帜 | **默认**:false | +| NEXT_PUBLIC_CustomLogo | 自定义Logo | **示例**:https://nezha-cf.buycoffee.top/apple-touch-icon.png | +| NEXT_PUBLIC_CustomTitle | 自定义标题 | | +| NEXT_PUBLIC_CustomDescription | 自定义描述(无多语言支持) | | #### 多语言支持 @@ -30,5 +37,9 @@ | 英语 | en | 是 | | 日语 | ja | 是 | -![screen-shot-one](/.github/shotOne.png) -![screen-shot-two](/.github/shotTwo.png) +![screen](/.github/shot-1.png) +![screen](/.github/shot-2.png) +![screen](/.github/shot-3.png) +![screen](/.github/shot-1-dark.png) +![screen](/.github/shot-2-dark.png) +![screen](/.github/shot-3-dark.png) diff --git a/app/[locale]/(main)/ClientComponents/NetworkChart.tsx b/app/[locale]/(main)/ClientComponents/NetworkChart.tsx new file mode 100644 index 0000000..8a69ecc --- /dev/null +++ b/app/[locale]/(main)/ClientComponents/NetworkChart.tsx @@ -0,0 +1,300 @@ +"use client"; + +import NetworkChartLoading from "@/app/[locale]/(main)/ClientComponents/NetworkChartLoading"; +import { + NezhaAPIMonitor, + ServerMonitorChart, +} from "@/app/[locale]/types/nezha-api"; +import { BackIcon } from "@/components/Icon"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + ChartConfig, + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import getEnv from "@/lib/env-entry"; +import { formatTime, nezhaFetcher } from "@/lib/utils"; +import { formatRelativeTime } from "@/lib/utils"; +import { useLocale } from "next-intl"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import * as React from "react"; +import { useCallback, useMemo } from "react"; +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; +import useSWR from "swr"; + +interface ResultItem { + created_at: number; + [key: string]: number | null; +} + +export function NetworkChartClient({ + server_id, + show, +}: { + server_id: number; + show: boolean; +}) { + const t = useTranslations("NetworkChartClient"); + const { data, error } = useSWR( + `/api/monitor?server_id=${server_id}`, + nezhaFetcher, + { + refreshInterval: + Number(getEnv("NEXT_PUBLIC_NezhaFetchInterval")) || 15000, + isPaused: () => !show, + }, + ); + + if (error) { + return ( + <> +
+

{error.message}

+

+ {t("chart_fetch_error_message")} +

+
+ + + ); + } + + if (!data) return ; + + const transformedData = transformData(data); + + const formattedData = formatData(data); + + const initChartConfig = { + avg_delay: { + label: t("avg_delay"), + }, + } satisfies ChartConfig; + + const chartDataKey = Object.keys(transformedData); + + return ( + + ); +} + +export const NetworkChart = React.memo(function NetworkChart({ + chartDataKey, + chartConfig, + chartData, + serverName, + formattedData, +}: { + chartDataKey: string[]; + chartConfig: ChartConfig; + chartData: ServerMonitorChart; + serverName: string; + formattedData: ResultItem[]; +}) { + const t = useTranslations("NetworkChart"); + const router = useRouter(); + const locale = useLocale(); + + const defaultChart = "All"; + + const [activeChart, setActiveChart] = React.useState(defaultChart); + + const handleButtonClick = useCallback( + (chart: string) => { + setActiveChart((prev) => (prev === chart ? defaultChart : chart)); + }, + [defaultChart], + ); + + const getColorByIndex = useCallback( + (chart: string) => { + const index = chartDataKey.indexOf(chart); + return `hsl(var(--chart-${(index % 10) + 1}))`; + }, + [chartDataKey], + ); + + const chartButtons = useMemo( + () => + chartDataKey.map((key) => ( + + )), + [chartDataKey, activeChart, chartData, handleButtonClick], + ); + + const chartLines = useMemo(() => { + if (activeChart !== defaultChart) { + return ( + + ); + } + return chartDataKey.map((key) => ( + + )); + }, [activeChart, defaultChart, chartDataKey, getColorByIndex]); + + return ( + + +
+ { + router.push(`/${locale}/`); + }} + className="flex flex-none cursor-pointer items-center gap-0.5 text-xl" + > + + {serverName} + + + {chartDataKey.length} {t("ServerMonitorCount")} + +
+
{chartButtons}
+
+ + + + + formatRelativeTime(value)} + /> + `${value}ms`} + /> + { + return formatTime(payload[0].payload.created_at); + }} + /> + } + /> + {activeChart === defaultChart && ( + } /> + )} + {chartLines} + + + +
+ ); +}); + +const transformData = (data: NezhaAPIMonitor[]) => { + const monitorData: ServerMonitorChart = {}; + + data.forEach((item) => { + const monitorName = item.monitor_name; + + if (!monitorData[monitorName]) { + monitorData[monitorName] = []; + } + + for (let i = 0; i < item.created_at.length; i++) { + monitorData[monitorName].push({ + created_at: item.created_at[i], + avg_delay: item.avg_delay[i], + }); + } + }); + + return monitorData; +}; + +const formatData = (rawData: NezhaAPIMonitor[]) => { + const result: { [time: number]: ResultItem } = {}; + + const allTimes = new Set(); + rawData.forEach((item) => { + item.created_at.forEach((time) => allTimes.add(time)); + }); + + const allTimeArray = Array.from(allTimes).sort((a, b) => a - b); + + rawData.forEach((item) => { + const { monitor_name, created_at, avg_delay } = item; + + allTimeArray.forEach((time) => { + if (!result[time]) { + result[time] = { created_at: time }; + } + + const timeIndex = created_at.indexOf(time); + result[time][monitor_name] = + timeIndex !== -1 ? avg_delay[timeIndex] : null; + }); + }); + + return Object.values(result).sort((a, b) => a.created_at - b.created_at); +}; diff --git a/app/[locale]/(main)/ClientComponents/NetworkChartLoading.tsx b/app/[locale]/(main)/ClientComponents/NetworkChartLoading.tsx new file mode 100644 index 0000000..3a9c908 --- /dev/null +++ b/app/[locale]/(main)/ClientComponents/NetworkChartLoading.tsx @@ -0,0 +1,35 @@ +import { BackIcon } from "@/components/Icon"; +import { Loader } from "@/components/loading/Loader"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useLocale } from "next-intl"; +import { useRouter } from "next/navigation"; + +export default function NetworkChartLoading() { + const router = useRouter(); + const locale = useLocale(); + + return ( + + +
+ { + router.push(`/${locale}/`); + }} + className="flex items-center cursor-pointer gap-0.5 text-xl" + > + +
+
+
+
+
+ +
+
+ +
+
+
+ ); +} diff --git a/app/[locale]/(main)/ClientComponents/ServerDetailChartClient.tsx b/app/[locale]/(main)/ClientComponents/ServerDetailChartClient.tsx new file mode 100644 index 0000000..9e05bcb --- /dev/null +++ b/app/[locale]/(main)/ClientComponents/ServerDetailChartClient.tsx @@ -0,0 +1,754 @@ +"use client"; + +import AnimatedCircularProgressBar from "@/components/ui/animated-circular-progress-bar"; +import { Card, CardContent } from "@/components/ui/card"; +import { ChartConfig, ChartContainer } from "@/components/ui/chart"; +import getEnv from "@/lib/env-entry"; +import { formatNezhaInfo, formatRelativeTime, nezhaFetcher } from "@/lib/utils"; +import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; +import { + Area, + AreaChart, + CartesianGrid, + Line, + LineChart, + XAxis, + YAxis, +} from "recharts"; +import useSWR from "swr"; + +import { NezhaAPISafe } from "../../types/nezha-api"; + +type cpuChartData = { + timeStamp: string; + cpu: number; +}; + +type processChartData = { + timeStamp: string; + process: number; +}; + +type diskChartData = { + timeStamp: string; + disk: number; +}; + +type memChartData = { + timeStamp: string; + mem: number; + swap: number; +}; + +type networkChartData = { + timeStamp: string; + upload: number; + download: number; +}; + +type connectChartData = { + timeStamp: string; + tcp: number; + udp: number; +}; + +export default function ServerDetailChartClient({ + server_id, + show, +}: { + server_id: number; + show: boolean; +}) { + const t = useTranslations("ServerDetailChartClient"); + + const { data, error } = useSWR( + `/api/detail?server_id=${server_id}`, + nezhaFetcher, + { + refreshInterval: Number(getEnv("NEXT_PUBLIC_NezhaFetchInterval")) || 5000, + isPaused: () => !show, + }, + ); + + if (error) { + return ( + <> +
+

{error.message}

+

+ {t("chart_fetch_error_message")} +

+
+ + ); + } + if (!data) return null; + + return ( +
+ + + + + + +
+ ); +} + +function CpuChart({ data }: { data: NezhaAPISafe }) { + const [cpuChartData, setCpuChartData] = useState([] as cpuChartData[]); + + const { cpu } = formatNezhaInfo(data); + + useEffect(() => { + if (data) { + const timestamp = Date.now().toString(); + let newData = [] as cpuChartData[]; + if (cpuChartData.length === 0) { + newData = [ + { timeStamp: timestamp, cpu: cpu }, + { timeStamp: timestamp, cpu: cpu }, + ]; + } else { + newData = [...cpuChartData, { timeStamp: timestamp, cpu: cpu }]; + } + if (newData.length > 30) { + newData.shift(); + } + setCpuChartData(newData); + } + }, [data]); + + const chartConfig = { + cpu: { + label: "CPU", + }, + } satisfies ChartConfig; + + return ( + + +
+
+

CPU

+
+

+ {cpu.toFixed(0)}% +

+ +
+
+ + + + formatRelativeTime(value)} + /> + `${value}%`} + /> + + + +
+
+
+ ); +} + +function ProcessChart({ data }: { data: NezhaAPISafe }) { + const t = useTranslations("ServerDetailChartClient"); + + const [processChartData, setProcessChartData] = useState( + [] as processChartData[], + ); + + const { process } = formatNezhaInfo(data); + + useEffect(() => { + if (data) { + const timestamp = Date.now().toString(); + let newData = [] as processChartData[]; + if (processChartData.length === 0) { + newData = [ + { timeStamp: timestamp, process: process }, + { timeStamp: timestamp, process: process }, + ]; + } else { + newData = [ + ...processChartData, + { timeStamp: timestamp, process: process }, + ]; + } + if (newData.length > 30) { + newData.shift(); + } + setProcessChartData(newData); + } + }, [data]); + + const chartConfig = { + process: { + label: "Process", + }, + } satisfies ChartConfig; + + return ( + + +
+
+

{t("Process")}

+
+

{process}

+
+
+ + + + formatRelativeTime(value)} + /> + + + + +
+
+
+ ); +} + +function MemChart({ data }: { data: NezhaAPISafe }) { + const t = useTranslations("ServerDetailChartClient"); + + const [memChartData, setMemChartData] = useState([] as memChartData[]); + + const { mem, swap } = formatNezhaInfo(data); + + useEffect(() => { + if (data) { + const timestamp = Date.now().toString(); + let newData = [] as memChartData[]; + if (memChartData.length === 0) { + newData = [ + { timeStamp: timestamp, mem: mem, swap: swap }, + { timeStamp: timestamp, mem: mem, swap: swap }, + ]; + } else { + newData = [ + ...memChartData, + { timeStamp: timestamp, mem: mem, swap: swap }, + ]; + } + if (newData.length > 30) { + newData.shift(); + } + setMemChartData(newData); + } + }, [data]); + + const chartConfig = { + mem: { + label: "Mem", + }, + swap: { + label: "Swap", + }, + } satisfies ChartConfig; + + return ( + + +
+
+
+
+

{t("Mem")}

+
+ +

{mem.toFixed(0)}%

+
+
+
+

{t("Swap")}

+
+ +

{swap.toFixed(0)}%

+
+
+
+
+ + + + formatRelativeTime(value)} + /> + `${value}%`} + /> + + + + +
+
+
+ ); +} + +function DiskChart({ data }: { data: NezhaAPISafe }) { + const t = useTranslations("ServerDetailChartClient"); + + const [diskChartData, setDiskChartData] = useState([] as diskChartData[]); + + const { disk } = formatNezhaInfo(data); + + useEffect(() => { + if (data) { + const timestamp = Date.now().toString(); + let newData = [] as diskChartData[]; + if (diskChartData.length === 0) { + newData = [ + { timeStamp: timestamp, disk: disk }, + { timeStamp: timestamp, disk: disk }, + ]; + } else { + newData = [...diskChartData, { timeStamp: timestamp, disk: disk }]; + } + if (newData.length > 30) { + newData.shift(); + } + setDiskChartData(newData); + } + }, [data]); + + const chartConfig = { + disk: { + label: "Disk", + }, + } satisfies ChartConfig; + + return ( + + +
+
+

{t("Disk")}

+
+

+ {disk.toFixed(0)}% +

+ +
+
+ + + + formatRelativeTime(value)} + /> + `${value}%`} + /> + + + +
+
+
+ ); +} + +function NetworkChart({ data }: { data: NezhaAPISafe }) { + const t = useTranslations("ServerDetailChartClient"); + + const [networkChartData, setNetworkChartData] = useState( + [] as networkChartData[], + ); + + const { up, down } = formatNezhaInfo(data); + + useEffect(() => { + if (data) { + const timestamp = Date.now().toString(); + let newData = [] as networkChartData[]; + if (networkChartData.length === 0) { + newData = [ + { timeStamp: timestamp, upload: up, download: down }, + { timeStamp: timestamp, upload: up, download: down }, + ]; + } else { + newData = [ + ...networkChartData, + { timeStamp: timestamp, upload: up, download: down }, + ]; + } + if (newData.length > 30) { + newData.shift(); + } + setNetworkChartData(newData); + } + }, [data]); + + let maxDownload = Math.max(...networkChartData.map((item) => item.download)); + maxDownload = Math.ceil(maxDownload); + if (maxDownload < 1) { + maxDownload = 1; + } + + const chartConfig = { + upload: { + label: "Upload", + }, + download: { + label: "Download", + }, + } satisfies ChartConfig; + + return ( + + +
+
+
+
+

{t("Upload")}

+
+ +

{up.toFixed(2)} M/s

+
+
+
+

+ {t("Download")} +

+
+ +

{down.toFixed(2)} M/s

+
+
+
+
+ + + + formatRelativeTime(value)} + /> + `${value.toFixed(0)}M/s`} + /> + + + + +
+
+
+ ); +} + +function ConnectChart({ data }: { data: NezhaAPISafe }) { + const [connectChartData, setConnectChartData] = useState( + [] as connectChartData[], + ); + + const { tcp, udp } = formatNezhaInfo(data); + + useEffect(() => { + if (data) { + const timestamp = Date.now().toString(); + let newData = [] as connectChartData[]; + if (connectChartData.length === 0) { + newData = [ + { timeStamp: timestamp, tcp: tcp, udp: udp }, + { timeStamp: timestamp, tcp: tcp, udp: udp }, + ]; + } else { + newData = [ + ...connectChartData, + { timeStamp: timestamp, tcp: tcp, udp: udp }, + ]; + } + if (newData.length > 30) { + newData.shift(); + } + setConnectChartData(newData); + } + }, [data]); + + const chartConfig = { + tcp: { + label: "TCP", + }, + udp: { + label: "UDP", + }, + } satisfies ChartConfig; + + return ( + + +
+
+
+
+

TCP

+
+ +

{tcp}

+
+
+
+

UDP

+
+ +

{udp}

+
+
+
+
+ + + + formatRelativeTime(value)} + /> + + + + + +
+
+
+ ); +} diff --git a/app/[locale]/(main)/ClientComponents/ServerDetailClient.tsx b/app/[locale]/(main)/ClientComponents/ServerDetailClient.tsx new file mode 100644 index 0000000..718f814 --- /dev/null +++ b/app/[locale]/(main)/ClientComponents/ServerDetailClient.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { NezhaAPISafe } from "@/app/[locale]/types/nezha-api"; +import { BackIcon } from "@/components/Icon"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import getEnv from "@/lib/env-entry"; +import { cn, formatBytes, nezhaFetcher } from "@/lib/utils"; +import { useLocale, useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import useSWR from "swr"; + +import ServerDetailLoading from "./ServerDetailLoading"; + +export default function ServerDetailClient({ + server_id, +}: { + server_id: number; +}) { + const t = useTranslations("ServerDetailClient"); + const router = useRouter(); + const locale = useLocale(); + const { data, error } = useSWR( + `/api/detail?server_id=${server_id}`, + nezhaFetcher, + { + refreshInterval: Number(getEnv("NEXT_PUBLIC_NezhaFetchInterval")) || 5000, + }, + ); + + if (error) { + return ( + <> +
+

{error.message}

+

+ {t("detail_fetch_error_message")} +

+
+ + ); + } + + if (!data) return ; + + return ( +
+
{ + router.push(`/${locale}/`); + }} + className="flex flex-none cursor-pointer font-semibold leading-none items-center break-all tracking-tight gap-0.5 text-xl" + > + + {data?.name} +
+
+ + +
+

{t("status")}

+ + {data?.online_status ? t("Online") : t("Offline")} + +
+
+
+ + +
+

{t("Uptime")}

+
+ {" "} + {(data?.status.Uptime / 86400).toFixed(0)} {t("Days")}{" "} +
+
+
+
+ + +
+

{t("Version")}

+
{data?.host.Version || "Unknown"}
+
+
+
+ + +
+

{t("Arch")}

+
{data?.host.Arch || "Unknown"}
+
+
+
+ + +
+

{t("Mem")}

+
{formatBytes(data?.host.MemTotal)}
+
+
+
+ + +
+

{t("Disk")}

+
{formatBytes(data?.host.DiskTotal)}
+
+
+
+
+
+ + +
+

{t("System")}

+ {data?.host.Platform ? ( +
+ {" "} + {data?.host.Platform || "Unknown"} -{" "} + {data?.host.PlatformVersion}{" "} +
+ ) : ( +
Unknown
+ )} +
+
+
+ + +
+

{t("CPU")}

+ {data?.host.CPU ? ( +
{data?.host.CPU}
+ ) : ( +
Unknown
+ )} +
+
+
+
+
+ ); +} diff --git a/app/[locale]/(main)/ClientComponents/ServerDetailLoading.tsx b/app/[locale]/(main)/ClientComponents/ServerDetailLoading.tsx new file mode 100644 index 0000000..2dc538b --- /dev/null +++ b/app/[locale]/(main)/ClientComponents/ServerDetailLoading.tsx @@ -0,0 +1,33 @@ +import { BackIcon } from "@/components/Icon"; +import { Separator } from "@/components/ui/separator"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useLocale } from "next-intl"; +import { useRouter } from "next/navigation"; + +export default function ServerDetailLoading() { + const router = useRouter(); + const locale = useLocale(); + return ( +
+
{ + router.push(`/${locale}/`); + }} + className="flex flex-none cursor-pointer font-semibold leading-none items-center break-all tracking-tight gap-0.5 text-xl" + > + + +
+ + +
+ + + + + + +
+
+ ); +} diff --git a/app/[locale]/(main)/ClientComponents/ServerListClient.tsx b/app/[locale]/(main)/ClientComponents/ServerListClient.tsx index 6d4b586..2f163ba 100644 --- a/app/[locale]/(main)/ClientComponents/ServerListClient.tsx +++ b/app/[locale]/(main)/ClientComponents/ServerListClient.tsx @@ -1,11 +1,19 @@ "use client"; -import { ServerApi } from "../../types/nezha-api"; -import ServerCard from "../../../../components/ServerCard"; -import { nezhaFetcher } from "../../../../lib/utils"; +import { ServerApi } from "@/app/[locale]/types/nezha-api"; +import ServerCard from "@/components/ServerCard"; +import Switch from "@/components/Switch"; +import getEnv from "@/lib/env-entry"; +import { nezhaFetcher } from "@/lib/utils"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; import useSWR from "swr"; -import getEnv from "../../../../lib/env-entry"; + export default function ServerListClient() { + const t = useTranslations("ServerListClient"); + + const [tag, setTag] = useState(t("defaultTag")); + const { data, error } = useSWR("/api/server", nezhaFetcher, { refreshInterval: Number(getEnv("NEXT_PUBLIC_NezhaFetchInterval")) || 2000, }); @@ -13,13 +21,10 @@ export default function ServerListClient() { return (

{error.message}

-

- Please check your environment variables and review the server console - logs for more details. -

+

{t("error_message")}

); - if (!data) return null; + if (!data?.result) return null; const { result } = data; @@ -29,11 +34,26 @@ export default function ServerListClient() { return a.id - b.id; }); + const allTag = sortedServers.map((server) => server.tag).filter((tag) => tag); + const uniqueTags = [...new Set(allTag)]; + + uniqueTags.unshift(t("defaultTag")); + + const filteredServers = + tag === t("defaultTag") + ? sortedServers + : sortedServers.filter((server) => server.tag === tag); + return ( -
- {sortedServers.map((serverInfo) => ( - - ))} -
+ <> + {getEnv("NEXT_PUBLIC_ShowTag") === "true" && uniqueTags.length > 1 && ( + + )} +
+ {filteredServers.map((serverInfo) => ( + + ))} +
+ ); } diff --git a/app/[locale]/(main)/ClientComponents/ServerOverviewClient.tsx b/app/[locale]/(main)/ClientComponents/ServerOverviewClient.tsx index 85a6af6..ebfa63c 100644 --- a/app/[locale]/(main)/ClientComponents/ServerOverviewClient.tsx +++ b/app/[locale]/(main)/ClientComponents/ServerOverviewClient.tsx @@ -1,118 +1,143 @@ "use client"; +import { ServerApi } from "@/app/[locale]/types/nezha-api"; +import { Loader } from "@/components/loading/Loader"; +import { Card, CardContent } from "@/components/ui/card"; +import getEnv from "@/lib/env-entry"; +import { formatBytes, nezhaFetcher } from "@/lib/utils"; +import blogMan from "@/public/blog-man.webp"; import { useTranslations } from "next-intl"; -import { Card, CardContent } from "../../../../components/ui/card"; -import blogMan from "../../../../public/blog-man.webp"; import Image from "next/image"; import useSWR from "swr"; -import { formatBytes, nezhaFetcher } from "../../../../lib/utils"; -import { Loader } from "../../../../components/loading/Loader"; -import { ServerApi } from "../../types/nezha-api"; -import getEnv from "../../../../lib/env-entry"; + export default function ServerOverviewClient() { const t = useTranslations("ServerOverviewClient"); - const { data } = useSWR("/api/server", nezhaFetcher); + const { data, error, isLoading } = useSWR( + "/api/server", + nezhaFetcher, + ); const disableCartoon = getEnv("NEXT_PUBLIC_DisableCartoon") === "true"; + + if (error) + return ( +
+

{error.message}

+

{t("error_message")}

+
+ ); + return ( -
- - -
-

- {t("p_816-881_Totalservers")} -

-
- - - - {data ? ( -
- {data?.result.length} -
- ) : ( -
- -
- )} -
-
-
-
- - -
-

- {t("p_1610-1676_Onlineservers")} -

-
- - - - - {data ? ( -
- {data?.live_servers} -
- ) : ( -
- -
- )} -
-
-
-
- - -
-

- {t("p_2532-2599_Offlineservers")} -

-
- - - - - {data ? ( -
- {data?.offline_servers} -
- ) : ( -
- -
- )} -
-
-
-
- - -
-

- {t("p_3463-3530_Totalbandwidth")} -

- {data ? ( -

- {formatBytes(data?.total_bandwidth)} + <> +

+ + +
+

+ {t("p_816-881_Totalservers")}

- ) : ( -
- +
+ + + + {data?.result ? ( +
+ {data?.result.length} +
+ ) : ( +
+ +
+ )}
+
+
+
+ + +
+

+ {t("p_1610-1676_Onlineservers")} +

+
+ + + + + {data?.result ? ( +
+ {data?.live_servers} +
+ ) : ( +
+ +
+ )} +
+
+
+
+ + +
+

+ {t("p_2532-2599_Offlineservers")} +

+
+ + + + + {data?.result ? ( +
+ {data?.offline_servers} +
+ ) : ( +
+ +
+ )} +
+
+
+
+ + +
+

+ {t("p_3463-3530_Totalbandwidth")} +

+ {data?.result ? ( +
+

+ ↑{formatBytes(data?.total_out_bandwidth)} +

+

+ ↓{formatBytes(data?.total_in_bandwidth)} +

+
+ ) : ( +
+ +
+ )} +
+ {!disableCartoon && ( + {"Hamster1963"} )} -
- {!disableCartoon && ( - {"Hamster1963"} - )} - - -
+
+
+
+ {data?.result === undefined && !isLoading && ( +
+

{t("error_message")}

+
+ )} + ); } diff --git a/app/[locale]/(main)/[id]/page.tsx b/app/[locale]/(main)/[id]/page.tsx new file mode 100644 index 0000000..8695fb3 --- /dev/null +++ b/app/[locale]/(main)/[id]/page.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { NetworkChartClient } from "@/app/[locale]/(main)/ClientComponents/NetworkChart"; +import ServerDetailChartClient from "@/app/[locale]/(main)/ClientComponents/ServerDetailChartClient"; +import ServerDetailClient from "@/app/[locale]/(main)/ClientComponents/ServerDetailClient"; +import TabSwitch from "@/components/TabSwitch"; +import { Separator } from "@/components/ui/separator"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; + +export default function Page({ params }: { params: { id: string } }) { + const t = useTranslations("TabSwitch"); + + const tabs = [t("Detail"), t("Network")]; + const [currentTab, setCurrentTab] = useState(tabs[0]); + return ( +
+ +
+ +
+ +
+ +
+ {currentTab === tabs[0] && ( + + )} + {currentTab === tabs[1] && ( + + )} +
+ ); +} diff --git a/app/[locale]/(main)/footer.tsx b/app/[locale]/(main)/footer.tsx index 4bfcffc..7d2fb36 100644 --- a/app/[locale]/(main)/footer.tsx +++ b/app/[locale]/(main)/footer.tsx @@ -1,4 +1,5 @@ import { useTranslations } from "next-intl"; + export default function Footer() { const t = useTranslations("Footer"); return ( diff --git a/app/[locale]/(main)/header.tsx b/app/[locale]/(main)/header.tsx index 9fd6266..08944ee 100644 --- a/app/[locale]/(main)/header.tsx +++ b/app/[locale]/(main)/header.tsx @@ -1,35 +1,53 @@ "use client"; -import { useTranslations } from "next-intl"; -import React, { useEffect, useRef, useState } from "react"; -import Image from "next/image"; -import { Separator } from "../../../components/ui/separator"; -import { DateTime } from "luxon"; -import { ModeToggle } from "../../../components/ThemeSwitcher"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; +import { ModeToggle } from "@/components/ThemeSwitcher"; +import { Separator } from "@/components/ui/separator"; +import getEnv from "@/lib/env-entry"; +import { DateTime } from "luxon"; +import { useTranslations } from "next-intl"; +import { useLocale } from "next-intl"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import React, { useEffect, useRef, useState } from "react"; + function Header() { const t = useTranslations("Header"); + const customLogo = getEnv("NEXT_PUBLIC_CustomLogo"); + const customTitle = getEnv("NEXT_PUBLIC_CustomTitle"); + const customDescription = getEnv("NEXT_PUBLIC_CustomDescription"); + + const router = useRouter(); + const locale = useLocale(); + return (
-
+
{ + router.push(`/${locale}/`); + }} + className="flex cursor-pointer items-center text-base font-medium" + >
apple-touch-icon
- NezhaDash + {customTitle ? customTitle : "NezhaDash"}

- {t("p_1079-1199_Simpleandbeautifuldashbo")} + {customDescription + ? customDescription + : t("p_1079-1199_Simpleandbeautifuldashbo")}

diff --git a/app/[locale]/(main)/layout.tsx b/app/[locale]/(main)/layout.tsx index 137532e..84bd06e 100644 --- a/app/[locale]/(main)/layout.tsx +++ b/app/[locale]/(main)/layout.tsx @@ -1,6 +1,6 @@ -import React from "react"; +import Footer from "@/app/[locale]/(main)/footer"; import Header from "@/app/[locale]/(main)/header"; -import Footer from "./footer"; +import React from "react"; type DashboardProps = { children: React.ReactNode; diff --git a/app/[locale]/(main)/page.tsx b/app/[locale]/(main)/page.tsx index 4de86cc..79b4bd9 100644 --- a/app/[locale]/(main)/page.tsx +++ b/app/[locale]/(main)/page.tsx @@ -1,6 +1,6 @@ +import ServerList from "@/components/ServerList"; +import ServerOverview from "@/components/ServerOverview"; import { unstable_setRequestLocale } from "next-intl/server"; -import ServerList from "../../../components/ServerList"; -import ServerOverview from "../../../components/ServerOverview"; export default function Home({ params: { locale }, diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index 2410b08..4671348 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -1,31 +1,34 @@ // @auto-i18n-check. Please do not delete the line. - -import "@/styles/globals.css"; -import "/node_modules/flag-icons/css/flag-icons.min.css"; - -import React from "react"; -import { NextIntlClientProvider, useMessages } from "next-intl"; -import { PublicEnvScript } from "next-runtime-env"; -import type { Metadata } from "next"; -import { Inter as FontSans } from "next/font/google"; -import { ThemeProvider } from "next-themes"; -import { Viewport } from "next"; -import { cn } from "@/lib/utils"; import { locales } from "@/i18n-metadata"; +import getEnv from "@/lib/env-entry"; +import { cn } from "@/lib/utils"; +import "@/styles/globals.css"; +import type { Metadata } from "next"; +import { Viewport } from "next"; +import { NextIntlClientProvider, useMessages } from "next-intl"; import { unstable_setRequestLocale } from "next-intl/server"; +import { PublicEnvScript } from "next-runtime-env"; +import { ThemeProvider } from "next-themes"; +import { Inter as FontSans } from "next/font/google"; +import React from "react"; + +import "/node_modules/flag-icons/css/flag-icons.min.css"; const fontSans = FontSans({ subsets: ["latin"], variable: "--font-sans", }); +const customTitle = getEnv("NEXT_PUBLIC_CustomTitle"); +const customDescription = getEnv("NEXT_PUBLIC_CustomDescription"); + export const metadata: Metadata = { manifest: "/manifest.json", - title: "NezhaDash", - description: "A dashboard for nezha", + title: customTitle || "NezhaDash", + description: customDescription || "A dashboard for nezha", appleWebApp: { capable: true, - title: "NezhaDash", + title: customTitle || "NezhaDash", statusBarStyle: "black-translucent", }, }; @@ -37,8 +40,6 @@ export const viewport: Viewport = { userScalable: false, }; -export const dynamic = "force-static"; - export async function generateStaticParams() { return locales.map((locale) => ({ locale })); } diff --git a/app/[locale]/types/nezha-api.ts b/app/[locale]/types/nezha-api.ts index bc91e7f..899809a 100644 --- a/app/[locale]/types/nezha-api.ts +++ b/app/[locale]/types/nezha-api.ts @@ -1,7 +1,8 @@ export type ServerApi = { live_servers: number; offline_servers: number; - total_bandwidth: number; + total_out_bandwidth: number; + total_in_bandwidth: number; result: NezhaAPISafe[]; }; @@ -17,6 +18,7 @@ export interface NezhaAPI { ipv6: string; valid_ip: string; display_index: number; + hide_for_guest: boolean; host: NezhaAPIHost; status: NezhaAPIStatus; } @@ -55,3 +57,19 @@ export interface NezhaAPIStatus { Temperatures: number; GPU: number; } + +export type ServerMonitorChart = { + [key: string]: { + created_at: number; + avg_delay: number; + }[]; +}; + +export interface NezhaAPIMonitor { + monitor_id: number; + monitor_name: string; + server_id: number; + server_name: string; + created_at: number[]; + avg_delay: number[]; +} diff --git a/app/api/detail/route.ts b/app/api/detail/route.ts new file mode 100644 index 0000000..b0f8bc1 --- /dev/null +++ b/app/api/detail/route.ts @@ -0,0 +1,29 @@ +import { NezhaAPISafe } from "@/app/[locale]/types/nezha-api"; +import { GetServerDetail } from "@/lib/serverFetch"; +import { NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; + +interface NezhaDataResponse { + error?: string; + data?: NezhaAPISafe; +} + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const server_id = searchParams.get("server_id"); + if (!server_id) { + return NextResponse.json( + { error: "server_id is required" }, + { status: 400 }, + ); + } + const response = (await GetServerDetail({ + server_id: parseInt(server_id), + })) as NezhaDataResponse; + if (response.error) { + console.log(response.error); + return NextResponse.json({ error: response.error }, { status: 400 }); + } + return NextResponse.json(response, { status: 200 }); +} diff --git a/app/api/monitor/route.ts b/app/api/monitor/route.ts new file mode 100644 index 0000000..e03fe89 --- /dev/null +++ b/app/api/monitor/route.ts @@ -0,0 +1,29 @@ +import { ServerMonitorChart } from "@/app/[locale]/types/nezha-api"; +import { GetServerMonitor } from "@/lib/serverFetch"; +import { NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; + +interface NezhaDataResponse { + error?: string; + data?: ServerMonitorChart; +} + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const server_id = searchParams.get("server_id"); + if (!server_id) { + return NextResponse.json( + { error: "server_id is required" }, + { status: 400 }, + ); + } + const response = (await GetServerMonitor({ + server_id: parseInt(server_id), + })) as NezhaDataResponse; + if (response.error) { + console.log(response.error); + return NextResponse.json({ error: response.error }, { status: 400 }); + } + return NextResponse.json(response, { status: 200 }); +} diff --git a/bun.lockb b/bun.lockb index 6139404..e99d417 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..6517a5c --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[install] +registry = "https://registry.npmmirror.com/" diff --git a/components/BlurLayer.tsx b/components/BlurLayer.tsx index 745b752..45b86de 100644 --- a/components/BlurLayer.tsx +++ b/components/BlurLayer.tsx @@ -1,4 +1,5 @@ import React from "react"; + const BlurLayers = () => { const computeLayerStyle = (index: number) => { const blurAmount = index * 3.7037; diff --git a/components/Icon.tsx b/components/Icon.tsx index aa97669..f690beb 100644 --- a/components/Icon.tsx +++ b/components/Icon.tsx @@ -1,3 +1,5 @@ +import Image from "next/image"; + export function GitHubIcon(props: React.ComponentPropsWithoutRef<"svg">) { return ( @@ -5,3 +7,28 @@ export function GitHubIcon(props: React.ComponentPropsWithoutRef<"svg">) { ); } + +export function BackIcon() { + return ( + <> + BackIcon + BackIcon + + ); +} diff --git a/components/LanguageSwitcher.tsx b/components/LanguageSwitcher.tsx index 0d5fb51..5f944a2 100644 --- a/components/LanguageSwitcher.tsx +++ b/components/LanguageSwitcher.tsx @@ -1,10 +1,5 @@ "use client"; -import { useLocale } from "next-intl"; -import { localeItems } from "../i18n-metadata"; -import { useRouter, usePathname } from "next/navigation"; -import * as React from "react"; - import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -12,6 +7,11 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { useLocale } from "next-intl"; +import { usePathname, useRouter } from "next/navigation"; +import * as React from "react"; + +import { localeItems } from "../i18n-metadata"; export function LanguageSwitcher() { const locale = useLocale(); diff --git a/components/ServerCard.tsx b/components/ServerCard.tsx index 8111f24..ff07e5c 100644 --- a/components/ServerCard.tsx +++ b/components/ServerCard.tsx @@ -1,17 +1,18 @@ -import { useTranslations } from "next-intl"; -import { NezhaAPISafe } from "../app/[locale]/types/nezha-api"; +import { NezhaAPISafe } from "@/app/[locale]/types/nezha-api"; +import ServerCardPopover from "@/components/ServerCardPopover"; +import ServerFlag from "@/components/ServerFlag"; import ServerUsageBar from "@/components/ServerUsageBar"; +import { Badge } from "@/components/ui/badge"; import { Card } from "@/components/ui/card"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; -import { cn, formatNezhaInfo } from "@/lib/utils"; -import ServerCardPopover from "./ServerCardPopover"; - -import { env } from "next-runtime-env"; -import ServerFlag from "./ServerFlag"; +import getEnv from "@/lib/env-entry"; +import { cn, formatBytes, formatNezhaInfo } from "@/lib/utils"; +import { useLocale, useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; export default function ServerCard({ serverInfo, @@ -19,84 +20,130 @@ export default function ServerCard({ serverInfo: NezhaAPISafe; }) { const t = useTranslations("ServerCard"); - const { name, country_code, online, cpu, up, down, mem, stg, ...props } = + const router = useRouter(); + const { id, name, country_code, online, cpu, up, down, mem, stg, ...props } = formatNezhaInfo(serverInfo); - const showFlag = env("NEXT_PUBLIC_ShowFlag") === "true"; + const showFlag = getEnv("NEXT_PUBLIC_ShowFlag") === "true"; + + const showNetTransfer = getEnv("NEXT_PUBLIC_ShowNetTransfer") === "true"; + + const locale = useLocale(); return online ? ( { + router.push(`/${locale}/${id}`); + }} > - - -
- {showFlag ? : null} -

- {name} -

- -
-
- - - -
-
-
-

{t("CPU")}

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

{t("Mem")}

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

{t("STG")}

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

{t("Upload")}

-
- {up.toFixed(2)} - Mb/s -
-
-
-

{t("Download")}

-
- {down.toFixed(2)} - Mb/s -
+
+ +
+ {showFlag ? : null}
+

+ {name} +

+
+
+
+

{t("CPU")}

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

{t("Mem")}

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

{t("STG")}

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

{t("Upload")}

+
+ {up.toFixed(2)}M/s +
+
+
+

{t("Download")}

+
+ {down.toFixed(2)}M/s +
+
+
+ {showNetTransfer && ( +
{ + router.push(`/${locale}/network/${id}`); + }} + className={"flex items-center justify-between gap-1"} + > + + {t("Upload")}:{formatBytes(serverInfo.status.NetOutTransfer)} + + + {t("Download")}:{formatBytes(serverInfo.status.NetInTransfer)} + +
+ )} +
) : ( -
- {showFlag ? : null} +
+ +
+ {showFlag ? : null} +

{name}

-
diff --git a/components/ServerCardPopover.tsx b/components/ServerCardPopover.tsx index b886ec5..9638802 100644 --- a/components/ServerCardPopover.tsx +++ b/components/ServerCardPopover.tsx @@ -1,6 +1,6 @@ -import { useTranslations } from "next-intl"; -import { NezhaAPISafe } from "../app/[locale]/types/nezha-api"; +import { NezhaAPISafe } from "@/app/[locale]/types/nezha-api"; import { cn, formatBytes } from "@/lib/utils"; +import { useTranslations } from "next-intl"; export function ServerCardPopoverCard({ className, diff --git a/components/ServerFlag.tsx b/components/ServerFlag.tsx index f128c27..74bb09b 100644 --- a/components/ServerFlag.tsx +++ b/components/ServerFlag.tsx @@ -1,10 +1,19 @@ -import { useEffect, useState } from "react"; +import getEnv from "@/lib/env-entry"; import getUnicodeFlagIcon from "country-flag-icons/unicode"; +import { useEffect, useState } from "react"; export default function ServerFlag({ country_code }: { country_code: string }) { const [supportsEmojiFlags, setSupportsEmojiFlags] = useState(false); + const useSvgFlag = getEnv("NEXT_PUBLIC_ForceUseSvgFlag") === "true"; + useEffect(() => { + if (useSvgFlag) { + // 如果环境变量要求直接使用 SVG,则无需检查 Emoji 支持 + setSupportsEmojiFlags(false); + return; + } + const checkEmojiSupport = () => { const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); @@ -20,13 +29,17 @@ export default function ServerFlag({ country_code }: { country_code: string }) { }; checkEmojiSupport(); - }, []); + }, [useSvgFlag]); // 将 `useSvgFlag` 作为依赖,当其变化时重新触发 if (!country_code) return null; + if (supportsEmojiFlags && country_code.toLowerCase() === "tw") { + country_code = "cn"; + } + return ( - {!supportsEmojiFlags ? ( + {useSvgFlag || !supportsEmojiFlags ? ( ) : ( getUnicodeFlagIcon(country_code) diff --git a/components/ServerList.tsx b/components/ServerList.tsx index 9448640..2c6abb2 100644 --- a/components/ServerList.tsx +++ b/components/ServerList.tsx @@ -1,7 +1,6 @@ +import ServerListClient from "@/app/[locale]/(main)/ClientComponents/ServerListClient"; import React from "react"; -import ServerListClient from "../app/[locale]/(main)/ClientComponents/ServerListClient"; - export default async function ServerList() { return ; } diff --git a/components/ServerOverview.tsx b/components/ServerOverview.tsx index 359dc63..900fd90 100644 --- a/components/ServerOverview.tsx +++ b/components/ServerOverview.tsx @@ -1,4 +1,4 @@ -import ServerOverviewClient from "../app/[locale]/(main)/ClientComponents/ServerOverviewClient"; +import ServerOverviewClient from "@/app/[locale]/(main)/ClientComponents/ServerOverviewClient"; export default async function ServerOverview() { return ; diff --git a/components/ServerUsageBar.tsx b/components/ServerUsageBar.tsx index 8c5b72b..108d90c 100644 --- a/components/ServerUsageBar.tsx +++ b/components/ServerUsageBar.tsx @@ -1,6 +1,5 @@ -import React from "react"; - import { Progress } from "@/components/ui/progress"; +import React from "react"; type ServerUsageBarProps = { value: number; diff --git a/components/Switch.tsx b/components/Switch.tsx new file mode 100644 index 0000000..607889e --- /dev/null +++ b/components/Switch.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { motion } from "framer-motion"; +import React from "react"; + +export default function Switch({ + allTag, + nowTag, + setTag, +}: { + allTag: string[]; + nowTag: string; + setTag: (tag: string) => void; +}) { + return ( +
+
+ {allTag.map((tag) => ( +
setTag(tag)} + className={cn( + "relative cursor-pointer rounded-3xl px-2.5 py-[8px] text-[13px] font-[600] transition-all duration-500", + nowTag === tag + ? "text-black dark:text-white" + : "text-stone-400 dark:text-stone-500", + )} + > + {nowTag === tag && ( + + )} +
+

{tag}

+
+
+ ))} +
+
+ ); +} diff --git a/components/TabSwitch.tsx b/components/TabSwitch.tsx new file mode 100644 index 0000000..d2913ad --- /dev/null +++ b/components/TabSwitch.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { motion } from "framer-motion"; +import React, { useState } from "react"; + +export default function TabSwitch({ + tabs, + currentTab, + setCurrentTab, +}: { + tabs: string[]; + currentTab: string; + setCurrentTab: (tab: string) => void; +}) { + return ( +
+
+ {tabs.map((tab: string) => ( +
setCurrentTab(tab)} + className={cn( + "relative cursor-pointer rounded-3xl px-2.5 py-[8px] text-[13px] font-[600] transition-all duration-500", + currentTab === tab + ? "text-black dark:text-white" + : "text-stone-400 dark:text-stone-500", + )} + > + {currentTab === tab && ( + + )} +
+

{tab}

+
+
+ ))} +
+
+ ); +} diff --git a/components/ThemeSwitcher.tsx b/components/ThemeSwitcher.tsx index d98794e..80e30b4 100644 --- a/components/ThemeSwitcher.tsx +++ b/components/ThemeSwitcher.tsx @@ -1,10 +1,5 @@ "use client"; -import { useTranslations } from "next-intl"; -import { Moon, Sun } from "lucide-react"; -import { useTheme } from "next-themes"; -import * as React from "react"; - import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -12,6 +7,10 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { Moon, Sun } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useTheme } from "next-themes"; +import * as React from "react"; export function ModeToggle() { const { setTheme } = useTheme(); diff --git a/components/ui/animated-circular-progress-bar.tsx b/components/ui/animated-circular-progress-bar.tsx new file mode 100644 index 0000000..dd96fe8 --- /dev/null +++ b/components/ui/animated-circular-progress-bar.tsx @@ -0,0 +1,107 @@ +import { cn } from "@/lib/utils"; + +interface Props { + max: number; + value: number; + min: number; + className?: string; + primaryColor?: string; +} + +export default function AnimatedCircularProgressBar({ + max = 100, + min = 0, + value = 0, + primaryColor, + className, +}: Props) { + const circumference = 2 * Math.PI * 45; + const percentPx = circumference / 100; + const currentPercent = ((value - min) / (max - min)) * 100; + + return ( +
+ + {currentPercent <= 90 && currentPercent >= 0 && ( + + )} + + + + {currentPercent} + +
+ ); +} diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx index b12af0a..7c304ec 100644 --- a/components/ui/badge.tsx +++ b/components/ui/badge.tsx @@ -1,10 +1,9 @@ -import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; +import { type VariantProps, cva } from "class-variance-authority"; import * as React from "react"; -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", + "inline-flex items-center text-nowarp rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors pointer-events-none focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", { variants: { variant: { diff --git a/components/ui/button.tsx b/components/ui/button.tsx index 856f6e5..7223303 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -1,8 +1,7 @@ -import { Slot } from "@radix-ui/react-slot"; -import { cva, type VariantProps } from "class-variance-authority"; -import * as React from "react"; - import { cn } from "@/lib/utils"; +import { Slot } from "@radix-ui/react-slot"; +import { type VariantProps, cva } from "class-variance-authority"; +import * as React from "react"; const buttonVariants = cva( "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50", diff --git a/components/ui/card.tsx b/components/ui/card.tsx index c5b32be..ab6efa5 100644 --- a/components/ui/card.tsx +++ b/components/ui/card.tsx @@ -1,6 +1,5 @@ -import * as React from "react"; - import { cn } from "@/lib/utils"; +import * as React from "react"; const Card = React.forwardRef< HTMLDivElement, @@ -78,9 +77,9 @@ CardFooter.displayName = "CardFooter"; export { Card, - CardContent, - CardDescription, - CardFooter, CardHeader, + CardFooter, CardTitle, + CardDescription, + CardContent, }; diff --git a/components/ui/chart.tsx b/components/ui/chart.tsx new file mode 100644 index 0000000..34560f4 --- /dev/null +++ b/components/ui/chart.tsx @@ -0,0 +1,375 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import * as React from "react"; +import * as RechartsPrimitive from "recharts"; + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const; + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ); +}; + +type ChartContextProps = { + config: ChartConfig; +}; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + + if (!context) { + throw new Error("useChart must be used within a "); + } + + return context; +} + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + config: ChartConfig; + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"]; + } +>(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId(); + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; + + return ( + +
+ + + {children} + +
+
+ ); +}); +ChartContainer.displayName = "Chart"; + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([_, config]) => config.theme || config.color, + ); + + if (!colorConfig.length) { + return null; + } + + return ( +