├── .env.example
├── .github
├── ISSUE_TEMPLATE
│ ├── bug-提交.md
│ ├── 功能申请.md
│ └── 改善建议.md
├── v2-1.webp
├── v2-2.webp
├── v2-3.webp
├── v2-4.webp
├── v2-dark.webp
└── workflows
│ ├── Deploy.yml
│ ├── auto-fix-lint-format-commit.yml
│ └── sync.yml
├── .gitignore
├── Dockerfile
├── LICENSE
├── README.md
├── app
├── (main)
│ ├── ClientComponents
│ │ ├── detail
│ │ │ ├── NetworkChart.tsx
│ │ │ ├── ServerDetailChartClient.tsx
│ │ │ ├── ServerDetailClient.tsx
│ │ │ └── ServerIPInfo.tsx
│ │ └── main
│ │ │ ├── Global.tsx
│ │ │ ├── GlobalInfo.tsx
│ │ │ ├── InteractiveMap.tsx
│ │ │ ├── MapTooltip.tsx
│ │ │ ├── ServerListClient.tsx
│ │ │ └── ServerOverviewClient.tsx
│ ├── footer.tsx
│ ├── header.tsx
│ ├── layout.tsx
│ ├── page.tsx
│ └── server
│ │ └── [id]
│ │ └── page.tsx
├── android-chrome-192x192.png
├── android-chrome-512x512.png
├── api
│ ├── auth
│ │ └── [...nextauth]
│ │ │ └── route.ts
│ ├── detail
│ │ └── route.ts
│ ├── monitor
│ │ └── route.ts
│ ├── server-ip
│ │ └── route.ts
│ └── server
│ │ └── route.ts
├── apple-touch-icon.png
├── context
│ ├── network-filter-context.tsx
│ ├── server-data-context.tsx
│ ├── status-context.tsx
│ └── tooltip-context.tsx
├── favicon-16x16.png
├── favicon-32x32.png
├── favicon.ico
├── layout.tsx
├── not-found.tsx
└── types
│ ├── nezha-api.ts
│ └── utils.ts
├── auth.ts
├── biome.json
├── bun.lockb
├── changelogithub.config.json
├── components.json
├── components
├── AnimatedCount.tsx
├── DashCommand.tsx
├── Icon.tsx
├── LanguageSwitcher.tsx
├── ServerCard.tsx
├── ServerCardInline.tsx
├── ServerFlag.tsx
├── ServerUsageBar.tsx
├── SignIn.tsx
├── Switch.tsx
├── TabSwitch.tsx
├── ThemeColorManager.tsx
├── ThemeSwitcher.tsx
├── loading
│ ├── GlobalLoading.tsx
│ ├── Loader.tsx
│ ├── NetworkChartLoading.tsx
│ └── ServerDetailLoading.tsx
└── ui
│ ├── animated-circular-progress-bar.tsx
│ ├── animated-tooltip.tsx
│ ├── badge.tsx
│ ├── button.tsx
│ ├── card.tsx
│ ├── chart.tsx
│ ├── command.tsx
│ ├── dialog.tsx
│ ├── dropdown-menu.tsx
│ ├── input.tsx
│ ├── label.tsx
│ ├── navigation-menu.tsx
│ ├── popover.tsx
│ ├── progress.tsx
│ ├── radio-group.tsx
│ ├── separator.tsx
│ ├── sheet.tsx
│ ├── skeleton.tsx
│ ├── switch.tsx
│ └── tooltip.tsx
├── docker
├── .env.example
├── docker-compose.yml
└── docker-compose.yml.cn
├── i18n-metadata.ts
├── i18n
├── locale.ts
└── request.ts
├── lib
├── env-entry.ts
├── env.ts
├── geo
│ ├── geo-json-string.ts
│ └── geo-limit.ts
├── logo-class.tsx
├── maxmind-db
│ ├── GeoLite2-ASN.mmdb
│ └── GeoLite2-City.mmdb
├── serverFetch.tsx
└── utils.ts
├── messages
├── en.json
├── ja.json
├── zh-TW.json
└── zh.json
├── next.config.mjs
├── package.json
├── postcss.config.js
├── public
├── android-chrome-192x192.png
├── android-chrome-512x512.png
├── apple-touch-icon-dark.png
├── apple-touch-icon.png
├── blog-man.webp
├── favicon-16x16.png
├── favicon-32x32.png
├── manifest.json
├── ui-dark.png
├── ui-light.png
└── ui-system.png
├── styles
└── globals.css
└── tsconfig.json
/.env.example:
--------------------------------------------------------------------------------
1 | NezhaBaseUrl=http://124.XX.XX.XX:8008
2 | NezhaAuth=your-nezha-api-token
3 | DefaultLocale=zh
4 | ForceShowAllServers=false
5 | NEXT_PUBLIC_NezhaFetchInterval=5000
6 | NEXT_PUBLIC_ShowFlag=true
7 | NEXT_PUBLIC_DisableCartoon=false
8 | NEXT_PUBLIC_ShowTag=true
9 | NEXT_PUBLIC_ShowNetTransfer=false
10 | NEXT_PUBLIC_ForceUseSvgFlag=false
11 | NEXT_PUBLIC_FixedTopServerName=false
12 | NEXT_PUBLIC_CustomLogo=https://nezha-cf.buycoffee.top/apple-touch-icon.png
13 | NEXT_PUBLIC_CustomTitle=NezhaDash
14 | NEXT_PUBLIC_CustomDescription=NezhaDash is a dashboard for Nezha.
15 | NEXT_PUBLIC_Links='[{"link":"https://github.com/hamster1963/nezha-dash","name":"GitHub"},{"link":"https://buycoffee.top/coffee","name":"Buycoffee☕️"}]'
16 | NEXT_PUBLIC_DisableIndex=false
17 | NEXT_PUBLIC_ShowTagCount=false
18 | NEXT_PUBLIC_ShowIpInfo=false
19 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug-提交.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug 提交
3 | about: 提交 bug,让面板变得更好。
4 | title: "[BUG]"
5 | labels: ""
6 | assignees: ""
7 | ---
8 |
9 | **面板版本(二选一)**
10 | V0 | V1
11 |
12 | **描述 bug**
13 | 在这里描述 bug 的相关信息
14 |
15 | **屏幕截图**
16 | 有屏幕截图可以帮助更快定位到问题
17 |
18 | **额外信息**
19 | 可附上其他需要的额外信息
20 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/功能申请.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: 功能申请
3 | about: 描述需求
4 | title: "[FEAT]"
5 | labels: ""
6 | assignees: ""
7 | ---
8 |
9 | **面板版本(二选一)**
10 | V0 | V1
11 |
12 | **需要什么?**
13 |
14 | **额外信息**
15 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/改善建议.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: 改善建议
3 | about: 交流面板需要改进的地方
4 | title: "[SUGGEST]"
5 | labels: ""
6 | assignees: ""
7 | ---
8 |
9 | **面板版本(二选一)**
10 | V0 | V1
11 |
12 | **需要改进的?**
13 |
14 | -
15 |
--------------------------------------------------------------------------------
/.github/v2-1.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hamster1963/nezha-dash/8cfc36872a3e429957d6e38d56b870d588ae2361/.github/v2-1.webp
--------------------------------------------------------------------------------
/.github/v2-2.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hamster1963/nezha-dash/8cfc36872a3e429957d6e38d56b870d588ae2361/.github/v2-2.webp
--------------------------------------------------------------------------------
/.github/v2-3.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hamster1963/nezha-dash/8cfc36872a3e429957d6e38d56b870d588ae2361/.github/v2-3.webp
--------------------------------------------------------------------------------
/.github/v2-4.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hamster1963/nezha-dash/8cfc36872a3e429957d6e38d56b870d588ae2361/.github/v2-4.webp
--------------------------------------------------------------------------------
/.github/v2-dark.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hamster1963/nezha-dash/8cfc36872a3e429957d6e38d56b870d588ae2361/.github/v2-dark.webp
--------------------------------------------------------------------------------
/.github/workflows/Deploy.yml:
--------------------------------------------------------------------------------
1 | name: Build and push Docker image
2 | permissions:
3 | contents: write
4 |
5 | on:
6 | push:
7 | tags:
8 | - "v*"
9 |
10 | env:
11 | REGISTRY_IMAGE: hamster1963/nezha-dash
12 | ALIYUN_REGISTRY_IMAGE: registry.cn-guangzhou.aliyuncs.com/hamster-home/nezha-dash
13 |
14 | jobs:
15 | build-and-push:
16 | name: Build and push Docker image
17 | runs-on: ubuntu-latest
18 | environment: Production
19 | steps:
20 | - name: Checkout code
21 | uses: actions/checkout@v4
22 | with:
23 | fetch-depth: 0
24 |
25 | - name: Set up Docker Buildx
26 | uses: docker/setup-buildx-action@v3
27 | with:
28 | driver-opts: network=host
29 |
30 | - name: Login to Docker Hub
31 | uses: docker/login-action@v3
32 | with:
33 | username: ${{ secrets.DOCKER_USERNAME }}
34 | password: ${{ secrets.DOCKERHUB_TOKEN }}
35 |
36 | - name: Login to AliYun Container Registry
37 | uses: docker/login-action@v3
38 | with:
39 | registry: registry.cn-guangzhou.aliyuncs.com
40 | username: ${{ secrets.ALI_USERNAME }}
41 | password: ${{ secrets.ALI_TOKEN }}
42 |
43 | - name: Extract metadata (tags, labels) for Docker
44 | id: meta
45 | uses: docker/metadata-action@v5
46 | with:
47 | images: |
48 | ${{ env.REGISTRY_IMAGE }}
49 | ${{ env.ALIYUN_REGISTRY_IMAGE }}
50 | tags: |
51 | type=raw,value=latest
52 | type=ref,event=tag
53 |
54 | - name: Build and push Docker image
55 | uses: docker/build-push-action@v6
56 | with:
57 | context: .
58 | platforms: linux/amd64,linux/arm64
59 | push: true
60 | tags: ${{ steps.meta.outputs.tags }}
61 | labels: ${{ steps.meta.outputs.labels }}
62 |
63 | - name: Set up Bun
64 | uses: oven-sh/setup-bun@v1
65 | with:
66 | bun-version: "latest"
67 |
68 | - name: Changelog
69 | run: bun x changelogithub
70 | env:
71 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
72 |
--------------------------------------------------------------------------------
/.github/workflows/auto-fix-lint-format-commit.yml:
--------------------------------------------------------------------------------
1 | name: Auto Fix Lint and Format
2 | permissions:
3 | contents: write
4 | pull-requests: write
5 |
6 | on:
7 | pull_request:
8 | types: [opened, synchronize]
9 |
10 | jobs:
11 | auto-fix:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - name: Checkout code
15 | uses: actions/checkout@v4
16 | with:
17 | fetch-depth: 0
18 | ref: ${{ github.head_ref }}
19 | repository: ${{ github.event.pull_request.head.repo.full_name }}
20 |
21 | - name: Set up Bun
22 | uses: oven-sh/setup-bun@v1
23 | with:
24 | bun-version: "latest"
25 |
26 | - name: Install dependencies
27 | run: bun install
28 |
29 | - name: Run linter & formatter and fix issues
30 | run: bun run check:fix
31 |
32 | - name: Check for changes
33 | id: check_changes
34 | run: |
35 | git diff --exit-code || echo "has_changes=true" >> $GITHUB_ENV
36 |
37 | - name: Commit and push changes
38 | if: steps.check_changes.outputs.has_changes == 'true' || env.has_changes == 'true'
39 | uses: stefanzweifel/git-auto-commit-action@v5
40 | with:
41 | commit_message: "chore: auto-fix linting and formatting issues"
42 | commit_options: "--no-verify"
43 | file_pattern: "."
44 |
45 | - name: Add PR comment
46 | if: steps.check_changes.outputs.has_changes == 'true' || env.has_changes == 'true'
47 | uses: actions/github-script@v7
48 | with:
49 | github-token: ${{secrets.GITHUB_TOKEN}}
50 | script: |
51 | github.rest.issues.createComment({
52 | issue_number: context.issue.number,
53 | owner: context.repo.owner,
54 | repo: context.repo.repo,
55 | body: 'Linting and formatting issues were automatically fixed. Please review the changes.'
56 | });
57 |
--------------------------------------------------------------------------------
/.github/workflows/sync.yml:
--------------------------------------------------------------------------------
1 | name: Upstream Sync
2 |
3 | permissions:
4 | contents: write
5 |
6 | on:
7 | schedule:
8 | - cron: "0 0 * * *" # every day
9 | workflow_dispatch:
10 |
11 | jobs:
12 | sync_latest_from_upstream:
13 | name: Sync latest commits from upstream repo
14 | runs-on: ubuntu-latest
15 | if: ${{ github.event.repository.fork }}
16 |
17 | steps:
18 | # Step 1: run a standard checkout action
19 | - name: Checkout target repo
20 | uses: actions/checkout@v3
21 |
22 | # Step 2: run the sync action
23 | - name: Sync upstream changes
24 | id: sync
25 | uses: aormsby/Fork-Sync-With-Upstream-action@v3.4
26 | with:
27 | upstream_sync_repo: hamster1963/nezha-dash
28 | upstream_sync_branch: main
29 | target_sync_branch: main
30 | target_repo_token: ${{ secrets.GITHUB_TOKEN }} # automatically generated, no need to set
31 |
32 | # Set test_mode true to run tests instead of the true action!!
33 | test_mode: false
34 |
35 | - name: Sync check
36 | if: failure()
37 | run: |
38 | echo "[Error] 由于上游仓库的 workflow 文件变更,导致 GitHub 自动暂停了本次自动更新,你需要手动 Sync Fork 一次,详细教程请查看:https://github.com/Yidadaa/ChatGPT-Next-Web/blob/main/README_CN.md#%E6%89%93%E5%BC%80%E8%87%AA%E5%8A%A8%E6%9B%B4%E6%96%B0"
39 | echo "[Error] Due to a change in the workflow file of the upstream repository, GitHub has automatically suspended the scheduled automatic update. You need to manually sync your fork. Please refer to the detailed tutorial for instructions: https://github.com/Yidadaa/ChatGPT-Next-Web#enable-automatic-updates"
40 | exit 1
41 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
38 | # pwa
39 | /public/sw.js
40 | /public/sw.js.map
41 | /public/swe-worker-*.js
42 | /public/workbox*.js
43 | /public/workbox*.js.map
44 |
45 | /.idea/
46 |
47 | .env
48 |
49 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM --platform=$BUILDPLATFORM oven/bun:1 AS base
2 |
3 | # Stage 1: Install dependencies
4 | FROM base AS deps
5 | WORKDIR /app
6 | COPY package.json bun.lockb ./
7 | RUN bun install --frozen-lockfile
8 |
9 | # Stage 2: Build the application
10 | FROM base AS builder
11 | WORKDIR /app
12 | COPY --from=deps /app/node_modules ./node_modules
13 | COPY . .
14 | RUN bun run build
15 |
16 | # Stage 3: Production image
17 | FROM node:23-alpine AS runner
18 | WORKDIR /app
19 | ENV NODE_ENV=production
20 | COPY --from=builder /app/public ./public
21 | COPY --from=builder /app/.next/standalone ./
22 | COPY --from=builder /app/.next/static ./.next/static
23 |
24 | EXPOSE 3000
25 | CMD ["node", "server.js"]
26 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |

2 | NezhaDash 是一个基于 Next.js 和 哪吒监控 的仪表盘
3 |
4 |
5 |
6 |
7 | > [!CAUTION]
8 | > 此为 V0 兼容版本,与 V1 内置版本功能上可能有所不同
9 | >
10 | > V0 | V1 版本 issue 请在当前仓库发起
11 |
12 | > [!TIP]
13 | > 有关 V1 版本 pr 可移步 https://github.com/hamster1963/nezha-dash-v1
14 |
15 | ### 部署
16 |
17 | 支持部署环境:
18 |
19 | - Vercel
20 | - Cloudflare
21 | - Docker
22 |
23 | [演示站点](https://nezha-vercel.vercel.app)
24 | [说明文档](https://nezhadash-docs.vercel.app)
25 |
26 | ### 如何更新
27 |
28 | [更新教程](https://buycoffee.top/blog/tech/nezha-upgrade)
29 |
30 | ### 环境变量
31 |
32 | [环境变量介绍](https://nezhadash-docs.vercel.app/environment)
33 |
34 | 
35 | 
36 | 
37 | 
38 | 
39 |
--------------------------------------------------------------------------------
/app/(main)/ClientComponents/detail/ServerIPInfo.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import type { IPInfo } from "@/app/api/server-ip/route"
4 | import { Loader } from "@/components/loading/Loader"
5 | import { Card, CardContent } from "@/components/ui/card"
6 | import { nezhaFetcher } from "@/lib/utils"
7 | import { useTranslations } from "next-intl"
8 | import useSWRImmutable from "swr/immutable"
9 |
10 | export default function ServerIPInfo({ server_id }: { server_id: number }) {
11 | const t = useTranslations("IPInfo")
12 |
13 | const { data } = useSWRImmutable(`/api/server-ip?server_id=${server_id}`, nezhaFetcher)
14 |
15 | if (!data) {
16 | return (
17 |
18 |
19 |
20 | )
21 | }
22 |
23 | return (
24 | <>
25 |
26 | {data.asn?.autonomous_system_organization && (
27 |
28 |
29 |
30 | {"ASN"}
31 | {data.asn.autonomous_system_organization}
32 |
33 |
34 |
35 | )}
36 | {data.asn?.autonomous_system_number && (
37 |
38 |
39 |
40 | {t("asn_number")}
41 | AS{data.asn.autonomous_system_number}
42 |
43 |
44 |
45 | )}
46 | {data.city?.registered_country?.names.en && (
47 |
48 |
49 |
50 | {t("registered_country")}
51 | {data.city.registered_country?.names.en}
52 |
53 |
54 |
55 | )}
56 | {data.city?.country?.iso_code && (
57 |
58 |
59 |
60 | {"ISO"}
61 | {data.city.country?.iso_code}
62 |
63 |
64 |
65 | )}
66 | {data.city?.city?.names.en && (
67 |
68 |
69 |
70 | {t("city")}
71 | {data.city.city?.names.en}
72 |
73 |
74 |
75 | )}
76 | {data.city?.location?.longitude && (
77 |
78 |
79 |
80 | {t("longitude")}
81 | {data.city.location?.longitude}
82 |
83 |
84 |
85 | )}
86 | {data.city?.location?.latitude && (
87 |
88 |
89 |
90 | {t("latitude")}
91 | {data.city.location?.latitude}
92 |
93 |
94 |
95 | )}
96 | {data.city?.location?.time_zone && (
97 |
98 |
99 |
100 | {t("time_zone")}
101 | {data.city.location?.time_zone}
102 |
103 |
104 |
105 | )}
106 | {data.city?.postal && (
107 |
108 |
109 |
110 | {t("postal_code")}
111 | {data.city.postal?.code}
112 |
113 |
114 |
115 | )}
116 |
117 | >
118 | )
119 | }
120 |
--------------------------------------------------------------------------------
/app/(main)/ClientComponents/main/Global.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import GlobalInfo from "@/app/(main)/ClientComponents/main/GlobalInfo"
4 | import { InteractiveMap } from "@/app/(main)/ClientComponents/main/InteractiveMap"
5 | import { useServerData } from "@/app/context/server-data-context"
6 | import { TooltipProvider } from "@/app/context/tooltip-context"
7 | import GlobalLoading from "@/components/loading/GlobalLoading"
8 | import { geoJsonString } from "@/lib/geo/geo-json-string"
9 |
10 | export default function ServerGlobal() {
11 | const { data: nezhaServerList, error } = useServerData()
12 |
13 | if (error)
14 | return (
15 |
16 |
{error.message}
17 |
18 | )
19 |
20 | if (!nezhaServerList) {
21 | return
22 | }
23 |
24 | const countryList: string[] = []
25 | const serverCounts: { [key: string]: number } = {}
26 |
27 | for (const server of nezhaServerList.result) {
28 | if (server.host.CountryCode) {
29 | const countryCode = server.host.CountryCode.toUpperCase()
30 | if (!countryList.includes(countryCode)) {
31 | countryList.push(countryCode)
32 | }
33 | serverCounts[countryCode] = (serverCounts[countryCode] || 0) + 1
34 | }
35 | }
36 |
37 | const width = 900
38 | const height = 500
39 |
40 | const geoJson = JSON.parse(geoJsonString)
41 | const filteredFeatures = geoJson.features.filter(
42 | (feature: any) => feature.properties.iso_a3_eh !== "",
43 | )
44 |
45 | return (
46 |
47 |
48 |
49 |
50 |
58 |
59 |
60 |
61 | )
62 | }
63 |
--------------------------------------------------------------------------------
/app/(main)/ClientComponents/main/GlobalInfo.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useTranslations } from "next-intl"
4 |
5 | type GlobalInfoProps = {
6 | countries: string[]
7 | }
8 |
9 | export default function GlobalInfo({ countries }: GlobalInfoProps) {
10 | const t = useTranslations("Global")
11 | return (
12 |
13 |
14 | {t("Distributions")} {countries.length} {t("Regions")}
15 |
16 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/app/(main)/ClientComponents/main/InteractiveMap.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import MapTooltip from "@/app/(main)/ClientComponents/main/MapTooltip"
4 | import { useTooltip } from "@/app/context/tooltip-context"
5 | import { countryCoordinates } from "@/lib/geo/geo-limit"
6 | import { geoEquirectangular, geoPath } from "d3-geo"
7 |
8 | interface InteractiveMapProps {
9 | countries: string[]
10 | serverCounts: { [key: string]: number }
11 | width: number
12 | height: number
13 | filteredFeatures: any[]
14 | nezhaServerList: any
15 | }
16 |
17 | export function InteractiveMap({
18 | countries,
19 | serverCounts,
20 | width,
21 | height,
22 | filteredFeatures,
23 | nezhaServerList,
24 | }: InteractiveMapProps) {
25 | const { setTooltipData } = useTooltip()
26 |
27 | const projection = geoEquirectangular()
28 | .scale(140)
29 | .translate([width / 2, height / 2])
30 | .rotate([-12, 0, 0])
31 |
32 | const path = geoPath().projection(projection)
33 |
34 | return (
35 | setTooltipData(null)}>
36 |
150 |
151 |
152 | )
153 | }
154 |
--------------------------------------------------------------------------------
/app/(main)/ClientComponents/main/MapTooltip.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useTooltip } from "@/app/context/tooltip-context"
4 | import { useTranslations } from "next-intl"
5 | import Link from "next/link"
6 | import { memo } from "react"
7 |
8 | const MapTooltip = memo(function MapTooltip() {
9 | const { tooltipData } = useTooltip()
10 | const t = useTranslations("Global")
11 |
12 | if (!tooltipData) return null
13 |
14 | const sortedServers = tooltipData.servers.sort((a, b) => {
15 | return a.status === b.status ? 0 : a.status ? 1 : -1
16 | })
17 |
18 | const saveSession = () => {
19 | sessionStorage.setItem("fromMainPage", "true")
20 | }
21 |
22 | return (
23 | {
32 | e.stopPropagation()
33 | }}
34 | >
35 |
36 |
37 | {tooltipData.country === "China" ? "Mainland China" : tooltipData.country}
38 |
39 |
40 | {tooltipData.count} {t("Servers")}
41 |
42 |
43 |
50 | {sortedServers.map((server) => (
51 |
57 |
62 | {server.name}
63 |
64 | ))}
65 |
66 |
67 | )
68 | })
69 |
70 | export default MapTooltip
71 |
--------------------------------------------------------------------------------
/app/(main)/ClientComponents/main/ServerListClient.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useFilter } from "@/app/context/network-filter-context"
4 | import { useServerData } from "@/app/context/server-data-context"
5 | import { useStatus } from "@/app/context/status-context"
6 | import ServerCard from "@/components/ServerCard"
7 | import ServerCardInline from "@/components/ServerCardInline"
8 | import Switch from "@/components/Switch"
9 | import GlobalLoading from "@/components/loading/GlobalLoading"
10 | import { Loader } from "@/components/loading/Loader"
11 | import getEnv from "@/lib/env-entry"
12 | import { cn } from "@/lib/utils"
13 | import { MapIcon, ViewColumnsIcon } from "@heroicons/react/20/solid"
14 | import { useTranslations } from "next-intl"
15 | import dynamic from "next/dynamic"
16 | import { useEffect, useRef, useState } from "react"
17 |
18 | const ServerGlobal = dynamic(() => import("./Global"), {
19 | ssr: false,
20 | loading: () => ,
21 | })
22 |
23 | const sortServersByDisplayIndex = (servers: any[]) => {
24 | return servers.sort((a, b) => {
25 | const displayIndexDiff = (b.display_index || 0) - (a.display_index || 0)
26 | return displayIndexDiff !== 0 ? displayIndexDiff : a.id - b.id
27 | })
28 | }
29 |
30 | const filterServersByStatus = (servers: any[], status: string) => {
31 | return status === "all"
32 | ? servers
33 | : servers.filter((server) => [status].includes(server.online_status ? "online" : "offline"))
34 | }
35 |
36 | const filterServersByTag = (servers: any[], tag: string, defaultTag: string) => {
37 | return tag === defaultTag ? servers : servers.filter((server) => server.tag === tag)
38 | }
39 |
40 | const sortServersByNetwork = (servers: any[]) => {
41 | return [...servers].sort((a, b) => {
42 | if (!a.online_status && b.online_status) return 1
43 | if (a.online_status && !b.online_status) return -1
44 | if (!a.online_status && !b.online_status) return 0
45 | return b.status.NetInSpeed + b.status.NetOutSpeed - (a.status.NetInSpeed + a.status.NetOutSpeed)
46 | })
47 | }
48 |
49 | const getTagCounts = (servers: any[]) => {
50 | return servers.reduce((acc: Record, server) => {
51 | if (server.tag) {
52 | acc[server.tag] = (acc[server.tag] || 0) + 1
53 | }
54 | return acc
55 | }, {})
56 | }
57 |
58 | const LoadingState = ({ t }: { t: any }) => (
59 |
60 |
61 |
62 | {t("connecting")}...
63 |
64 |
65 | )
66 |
67 | const ErrorState = ({ error, t }: { error: Error; t: any }) => (
68 |
69 |
{error.message}
70 |
{t("error_message")}
71 |
72 | )
73 |
74 | const ServerList = ({
75 | servers,
76 | inline,
77 | containerRef,
78 | }: { servers: any[]; inline: string; containerRef: any }) => {
79 | if (inline === "1") {
80 | return (
81 |
85 | {servers.map((serverInfo) => (
86 |
87 | ))}
88 |
89 | )
90 | }
91 |
92 | return (
93 |
94 | {servers.map((serverInfo) => (
95 |
96 | ))}
97 |
98 | )
99 | }
100 |
101 | export default function ServerListClient() {
102 | const { status } = useStatus()
103 | const { filter } = useFilter()
104 | const t = useTranslations("ServerListClient")
105 | const containerRef = useRef(null)
106 | const defaultTag = "defaultTag"
107 |
108 | const [tag, setTag] = useState(defaultTag)
109 | const [showMap, setShowMap] = useState(false)
110 | const [inline, setInline] = useState("0")
111 |
112 | useEffect(() => {
113 | const inlineState = localStorage.getItem("inline")
114 | if (inlineState !== null) {
115 | setInline(inlineState)
116 | }
117 |
118 | const showMapState = localStorage.getItem("showMap")
119 | if (showMapState !== null) {
120 | setShowMap(showMapState === "true")
121 | }
122 |
123 | const savedTag = sessionStorage.getItem("selectedTag") || defaultTag
124 | setTag(savedTag)
125 | restoreScrollPosition()
126 | }, [])
127 |
128 | const handleTagChange = (newTag: string) => {
129 | setTag(newTag)
130 | sessionStorage.setItem("selectedTag", newTag)
131 | sessionStorage.setItem("scrollPosition", String(containerRef.current?.scrollTop || 0))
132 | }
133 |
134 | const restoreScrollPosition = () => {
135 | const savedPosition = sessionStorage.getItem("scrollPosition")
136 | if (savedPosition && containerRef.current) {
137 | containerRef.current.scrollTop = Number(savedPosition)
138 | }
139 | }
140 |
141 | useEffect(() => {
142 | const handleRouteChange = () => {
143 | restoreScrollPosition()
144 | }
145 |
146 | window.addEventListener("popstate", handleRouteChange)
147 | return () => {
148 | window.removeEventListener("popstate", handleRouteChange)
149 | }
150 | }, [])
151 |
152 | const { data, error } = useServerData()
153 |
154 | if (error) return
155 | if (!data?.result) return
156 |
157 | const { result } = data
158 | const sortedServers = sortServersByDisplayIndex(result)
159 | const filteredServersByStatus = filterServersByStatus(sortedServers, status)
160 | const allTag = filteredServersByStatus.map((server) => server.tag).filter(Boolean)
161 | const uniqueTags = [...new Set(allTag)]
162 | uniqueTags.unshift(defaultTag)
163 |
164 | let filteredServers = filterServersByTag(filteredServersByStatus, tag, defaultTag)
165 |
166 | if (filter) {
167 | filteredServers = sortServersByNetwork(filteredServers)
168 | }
169 |
170 | const tagCountMap = getTagCounts(filteredServersByStatus)
171 |
172 | return (
173 | <>
174 |
175 |
192 |
209 | {getEnv("NEXT_PUBLIC_ShowTag") === "true" && (
210 |
216 | )}
217 |
218 | {showMap && }
219 |
220 | >
221 | )
222 | }
223 |
--------------------------------------------------------------------------------
/app/(main)/footer.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import pack from "@/package.json"
4 | import { useTranslations } from "next-intl"
5 | import { useEffect, useState } from "react"
6 | const GITHUB_URL = "https://github.com/hamster1963/nezha-dash"
7 | const PERSONAL_URL = "https://buycoffee.top"
8 |
9 | type LinkProps = {
10 | href: string
11 | children: React.ReactNode
12 | }
13 |
14 | const FooterLink = ({ href, children }: LinkProps) => (
15 |
21 | {children}
22 |
23 | )
24 |
25 | const baseTextStyles =
26 | "text-[13px] font-light tracking-tight text-neutral-600/50 dark:text-neutral-300/50"
27 |
28 | export default function Footer() {
29 | const t = useTranslations("Footer")
30 | const version = pack.version
31 | const currentYear = new Date().getFullYear()
32 | const [isMac, setIsMac] = useState(true)
33 |
34 | useEffect(() => {
35 | setIsMac(/macintosh|mac os x/i.test(navigator.userAgent))
36 | }, [])
37 |
38 | return (
39 |
57 | )
58 | }
59 |
--------------------------------------------------------------------------------
/app/(main)/header.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import AnimateCountClient from "@/components/AnimatedCount"
4 | import { LanguageSwitcher } from "@/components/LanguageSwitcher"
5 | import { ModeToggle } from "@/components/ThemeSwitcher"
6 | import { Separator } from "@/components/ui/separator"
7 | import { Skeleton } from "@/components/ui/skeleton"
8 |
9 | import getEnv from "@/lib/env-entry"
10 | import { DateTime } from "luxon"
11 | import { useTranslations } from "next-intl"
12 | import { useRouter } from "next/navigation"
13 | import { memo, useCallback, useEffect, useState } from "react"
14 |
15 | interface TimeState {
16 | hh: number
17 | mm: number
18 | ss: number
19 | }
20 |
21 | interface CustomLink {
22 | link: string
23 | name: string
24 | }
25 |
26 | const useCurrentTime = () => {
27 | const [time, setTime] = useState({
28 | hh: DateTime.now().setLocale("en-US").hour,
29 | mm: DateTime.now().setLocale("en-US").minute,
30 | ss: DateTime.now().setLocale("en-US").second,
31 | })
32 |
33 | useEffect(() => {
34 | const intervalId = setInterval(() => {
35 | const now = DateTime.now().setLocale("en-US")
36 | setTime({
37 | hh: now.hour,
38 | mm: now.minute,
39 | ss: now.second,
40 | })
41 | }, 1000)
42 |
43 | return () => clearInterval(intervalId)
44 | }, [])
45 |
46 | return time
47 | }
48 |
49 | const Links = memo(function Links() {
50 | const linksEnv = getEnv("NEXT_PUBLIC_Links")
51 | const links: CustomLink[] | null = linksEnv ? JSON.parse(linksEnv) : null
52 |
53 | if (!links) return null
54 |
55 | return (
56 |
69 | )
70 | })
71 |
72 | const Overview = memo(function Overview() {
73 | const t = useTranslations("Overview")
74 | const time = useCurrentTime()
75 | const [mounted, setMounted] = useState(false)
76 |
77 | useEffect(() => {
78 | setMounted(true)
79 | }, [])
80 |
81 | return (
82 |
83 | {t("p_2277-2331_Overview")}
84 |
85 |
{t("p_2390-2457_wherethetimeis")}
86 | {mounted ? (
87 |
88 |
89 |
:
90 |
91 |
:
92 |
93 |
94 |
95 |
96 | ) : (
97 |
98 | )}
99 |
100 |
101 | )
102 | })
103 |
104 | function Header() {
105 | const t = useTranslations("Header")
106 | const customLogo = getEnv("NEXT_PUBLIC_CustomLogo")
107 | const customTitle = getEnv("NEXT_PUBLIC_CustomTitle")
108 | const customDescription = getEnv("NEXT_PUBLIC_CustomDescription")
109 |
110 | const router = useRouter()
111 |
112 | const handleLogoClick = useCallback(() => {
113 | sessionStorage.removeItem("selectedTag")
114 | router.push("/")
115 | }, [router])
116 |
117 | return (
118 |
119 |
120 |
124 |
125 |

132 |

139 |
140 | {customTitle ? customTitle : "NezhaDash"}
141 |
142 |
143 | {customDescription ? customDescription : t("p_1079-1199_Simpleandbeautifuldashbo")}
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 | )
160 | }
161 |
162 | export default Header
163 |
--------------------------------------------------------------------------------
/app/(main)/layout.tsx:
--------------------------------------------------------------------------------
1 | import Footer from "@/app/(main)/footer"
2 | import Header from "@/app/(main)/header"
3 | import { ServerDataProvider } from "@/app/context/server-data-context"
4 | import { auth } from "@/auth"
5 | import { DashCommand } from "@/components/DashCommand"
6 | import { SignIn } from "@/components/SignIn"
7 | import getEnv from "@/lib/env-entry"
8 | import type React from "react"
9 |
10 | type DashboardProps = {
11 | children: React.ReactNode
12 | }
13 | export default function MainLayout({ children }: DashboardProps) {
14 | return (
15 |
16 |
17 |
18 |
19 |
20 | {children}
21 |
22 |
23 |
24 |
25 |
26 |
27 | )
28 | }
29 |
30 | async function AuthProtected({ children }: DashboardProps) {
31 | if (getEnv("SitePassword")) {
32 | const session = await auth()
33 | if (!session) {
34 | return
35 | }
36 | }
37 | return children
38 | }
39 |
--------------------------------------------------------------------------------
/app/(main)/page.tsx:
--------------------------------------------------------------------------------
1 | import ServerListClient from "@/app/(main)/ClientComponents/main/ServerListClient"
2 | import ServerOverviewClient from "@/app/(main)/ClientComponents/main/ServerOverviewClient"
3 |
4 | export default async function Home() {
5 | return (
6 |
7 |
8 |
9 |
10 | )
11 | }
12 |
--------------------------------------------------------------------------------
/app/(main)/server/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { NetworkChartClient } from "@/app/(main)/ClientComponents/detail/NetworkChart"
4 | import ServerDetailChartClient from "@/app/(main)/ClientComponents/detail/ServerDetailChartClient"
5 | import ServerDetailClient from "@/app/(main)/ClientComponents/detail/ServerDetailClient"
6 | import ServerIPInfo from "@/app/(main)/ClientComponents/detail/ServerIPInfo"
7 | import TabSwitch from "@/components/TabSwitch"
8 | import { Separator } from "@/components/ui/separator"
9 | import getEnv from "@/lib/env-entry"
10 | import { use, useState } from "react"
11 |
12 | type PageProps = {
13 | params: Promise<{ id: string }>
14 | }
15 |
16 | type TabType = "Detail" | "Network"
17 |
18 | export default function Page({ params }: PageProps) {
19 | const { id } = use(params)
20 | const serverId = Number(id)
21 | const tabs: TabType[] = ["Detail", "Network"]
22 | const [currentTab, setCurrentTab] = useState(tabs[0])
23 |
24 | const tabContent = {
25 | Detail: ,
26 | Network: (
27 | <>
28 | {getEnv("NEXT_PUBLIC_ShowIpInfo") && }
29 |
30 | >
31 | ),
32 | }
33 |
34 | return (
35 |
36 |
37 |
38 |
49 |
50 | {tabContent[currentTab]}
51 |
52 | )
53 | }
54 |
--------------------------------------------------------------------------------
/app/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hamster1963/nezha-dash/8cfc36872a3e429957d6e38d56b870d588ae2361/app/android-chrome-192x192.png
--------------------------------------------------------------------------------
/app/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hamster1963/nezha-dash/8cfc36872a3e429957d6e38d56b870d588ae2361/app/android-chrome-512x512.png
--------------------------------------------------------------------------------
/app/api/auth/[...nextauth]/route.ts:
--------------------------------------------------------------------------------
1 | import { handlers } from "@/auth"
2 |
3 | export const { GET, POST } = handlers
4 |
--------------------------------------------------------------------------------
/app/api/detail/route.ts:
--------------------------------------------------------------------------------
1 | import { auth } from "@/auth"
2 | import getEnv from "@/lib/env-entry"
3 | import { GetServerDetail } from "@/lib/serverFetch"
4 | import { redirect } from "next/navigation"
5 | import { type NextRequest, NextResponse } from "next/server"
6 |
7 | export const dynamic = "force-dynamic"
8 |
9 | interface ResError extends Error {
10 | statusCode: number
11 | message: string
12 | }
13 |
14 | export async function GET(req: NextRequest) {
15 | if (getEnv("SitePassword")) {
16 | const session = await auth()
17 | if (!session) {
18 | redirect("/")
19 | }
20 | }
21 |
22 | const { searchParams } = new URL(req.url)
23 | const server_id = searchParams.get("server_id")
24 |
25 | if (!server_id) {
26 | return NextResponse.json({ error: "server_id is required" }, { status: 400 })
27 | }
28 |
29 | try {
30 | const serverIdNum = Number.parseInt(server_id, 10)
31 | if (Number.isNaN(serverIdNum)) {
32 | return NextResponse.json({ error: "server_id must be a valid number" }, { status: 400 })
33 | }
34 |
35 | const detailData = await GetServerDetail({ server_id: serverIdNum })
36 | return NextResponse.json(detailData, { status: 200 })
37 | } catch (error) {
38 | const err = error as ResError
39 | console.error("Error in GET handler:", err)
40 | const statusCode = err.statusCode || 500
41 | const message = err.message || "Internal Server Error"
42 | return NextResponse.json({ error: message }, { status: statusCode })
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/app/api/monitor/route.ts:
--------------------------------------------------------------------------------
1 | import { auth } from "@/auth"
2 | import getEnv from "@/lib/env-entry"
3 | import { GetServerMonitor } from "@/lib/serverFetch"
4 | import { redirect } from "next/navigation"
5 | import { type NextRequest, NextResponse } from "next/server"
6 |
7 | export const dynamic = "force-dynamic"
8 |
9 | interface ResError extends Error {
10 | statusCode: number
11 | message: string
12 | }
13 |
14 | export async function GET(req: NextRequest) {
15 | if (getEnv("SitePassword")) {
16 | const session = await auth()
17 | if (!session) {
18 | redirect("/")
19 | }
20 | }
21 |
22 | const { searchParams } = new URL(req.url)
23 | const server_id = searchParams.get("server_id")
24 |
25 | if (!server_id) {
26 | return NextResponse.json({ error: "server_id is required" }, { status: 400 })
27 | }
28 |
29 | try {
30 | const serverIdNum = Number.parseInt(server_id, 10)
31 | if (Number.isNaN(serverIdNum)) {
32 | return NextResponse.json({ error: "server_id must be a number" }, { status: 400 })
33 | }
34 |
35 | const monitorData = await GetServerMonitor({
36 | server_id: serverIdNum,
37 | })
38 | return NextResponse.json(monitorData, { status: 200 })
39 | } catch (error) {
40 | const err = error as ResError
41 | console.error("Error in GET handler:", err)
42 | const statusCode = err.statusCode || 500
43 | const message = err.message || "Internal Server Error"
44 | return NextResponse.json({ error: message }, { status: statusCode })
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/app/api/server-ip/route.ts:
--------------------------------------------------------------------------------
1 | import fs from "node:fs"
2 | import path from "node:path"
3 | import { auth } from "@/auth"
4 | import getEnv from "@/lib/env-entry"
5 | import { GetServerIP } from "@/lib/serverFetch"
6 | import { type AsnResponse, type CityResponse, Reader } from "maxmind"
7 | import { redirect } from "next/navigation"
8 | import { type NextRequest, NextResponse } from "next/server"
9 |
10 | export const dynamic = "force-dynamic"
11 |
12 | interface ResError extends Error {
13 | statusCode: number
14 | message: string
15 | }
16 |
17 | export type IPInfo = {
18 | city: CityResponse
19 | asn: AsnResponse
20 | }
21 |
22 | export async function GET(req: NextRequest) {
23 | if (getEnv("SitePassword")) {
24 | const session = await auth()
25 | if (!session) {
26 | redirect("/")
27 | }
28 | }
29 |
30 | if (!getEnv("NEXT_PUBLIC_ShowIpInfo")) {
31 | return NextResponse.json({ error: "ip info is disabled" }, { status: 400 })
32 | }
33 |
34 | const { searchParams } = new URL(req.url)
35 | const server_id = searchParams.get("server_id")
36 |
37 | if (!server_id) {
38 | return NextResponse.json({ error: "server_id is required" }, { status: 400 })
39 | }
40 |
41 | try {
42 | const ip = await GetServerIP({ server_id: Number(server_id) })
43 |
44 | const cityDbPath = path.join(process.cwd(), "lib", "maxmind-db", "GeoLite2-City.mmdb")
45 |
46 | const asnDbPath = path.join(process.cwd(), "lib", "maxmind-db", "GeoLite2-ASN.mmdb")
47 |
48 | const cityDbBuffer = fs.readFileSync(cityDbPath)
49 | const asnDbBuffer = fs.readFileSync(asnDbPath)
50 |
51 | const cityLookup = new Reader(cityDbBuffer)
52 | const asnLookup = new Reader(asnDbBuffer)
53 |
54 | const data: IPInfo = {
55 | city: cityLookup.get(ip) as CityResponse,
56 | asn: asnLookup.get(ip) as AsnResponse,
57 | }
58 | return NextResponse.json(data, { status: 200 })
59 | } catch (error) {
60 | const err = error as ResError
61 | console.error("Error in GET handler:", err)
62 | const statusCode = err.statusCode || 500
63 | const message = err.message || "Internal Server Error"
64 | return NextResponse.json({ error: message }, { status: statusCode })
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/app/api/server/route.ts:
--------------------------------------------------------------------------------
1 | import { auth } from "@/auth"
2 | import getEnv from "@/lib/env-entry"
3 | import { GetNezhaData } from "@/lib/serverFetch"
4 | import { redirect } from "next/navigation"
5 | import { NextResponse } from "next/server"
6 |
7 | export const dynamic = "force-dynamic"
8 |
9 | interface ResError extends Error {
10 | statusCode: number
11 | message: string
12 | }
13 |
14 | export async function GET() {
15 | if (getEnv("SitePassword")) {
16 | const session = await auth()
17 | if (!session) {
18 | redirect("/")
19 | }
20 | }
21 |
22 | try {
23 | const data = await GetNezhaData()
24 | return NextResponse.json(data, { status: 200 })
25 | } catch (error) {
26 | const err = error as ResError
27 | console.error("Error in GET handler:", err)
28 | const statusCode = err.statusCode || 500
29 | const message = err.message || "Internal Server Error"
30 | return NextResponse.json({ error: message }, { status: statusCode })
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/app/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hamster1963/nezha-dash/8cfc36872a3e429957d6e38d56b870d588ae2361/app/apple-touch-icon.png
--------------------------------------------------------------------------------
/app/context/network-filter-context.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { type ReactNode, createContext, useContext, useState } from "react"
4 |
5 | interface FilterContextType {
6 | filter: boolean
7 | setFilter: (filter: boolean) => void
8 | }
9 |
10 | const FilterContext = createContext(undefined)
11 |
12 | export function FilterProvider({ children }: { children: ReactNode }) {
13 | const [filter, setFilter] = useState(false)
14 |
15 | return {children}
16 | }
17 |
18 | export function useFilter() {
19 | const context = useContext(FilterContext)
20 | if (context === undefined) {
21 | throw new Error("useFilter must be used within a FilterProvider")
22 | }
23 | return context
24 | }
25 |
--------------------------------------------------------------------------------
/app/context/server-data-context.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import type { ServerApi } from "@/app/types/nezha-api"
4 | import getEnv from "@/lib/env-entry"
5 | import { nezhaFetcher } from "@/lib/utils"
6 | import { type ReactNode, createContext, useContext, useEffect, useState } from "react"
7 | import useSWR from "swr"
8 |
9 | export interface ServerDataWithTimestamp {
10 | timestamp: number
11 | data: ServerApi
12 | }
13 |
14 | interface ServerDataContextType {
15 | data: ServerApi | undefined
16 | error: Error | undefined
17 | isLoading: boolean
18 | history: ServerDataWithTimestamp[]
19 | }
20 |
21 | const ServerDataContext = createContext(undefined)
22 |
23 | export const MAX_HISTORY_LENGTH = 30
24 |
25 | export function ServerDataProvider({ children }: { children: ReactNode }) {
26 | const [history, setHistory] = useState([])
27 |
28 | const { data, error, isLoading } = useSWR("/api/server", nezhaFetcher, {
29 | refreshInterval: Number(getEnv("NEXT_PUBLIC_NezhaFetchInterval")) || 2000,
30 | dedupingInterval: 1000,
31 | })
32 |
33 | useEffect(() => {
34 | if (data) {
35 | setHistory((prev) => {
36 | const newHistory = [
37 | {
38 | timestamp: Date.now(),
39 | data: data,
40 | },
41 | ...prev,
42 | ].slice(0, MAX_HISTORY_LENGTH)
43 |
44 | return newHistory
45 | })
46 | }
47 | }, [data])
48 |
49 | return (
50 |
51 | {children}
52 |
53 | )
54 | }
55 |
56 | export function useServerData() {
57 | const context = useContext(ServerDataContext)
58 | if (context === undefined) {
59 | throw new Error("useServerData must be used within a ServerDataProvider")
60 | }
61 | return context
62 | }
63 |
--------------------------------------------------------------------------------
/app/context/status-context.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { type ReactNode, createContext, useContext, useState } from "react"
4 |
5 | type Status = "all" | "online" | "offline"
6 |
7 | interface StatusContextType {
8 | status: Status
9 | setStatus: (status: Status) => void
10 | }
11 |
12 | const StatusContext = createContext(undefined)
13 |
14 | export function StatusProvider({ children }: { children: ReactNode }) {
15 | const [status, setStatus] = useState("all")
16 |
17 | return {children}
18 | }
19 |
20 | export function useStatus() {
21 | const context = useContext(StatusContext)
22 | if (context === undefined) {
23 | throw new Error("useStatus must be used within a StatusProvider")
24 | }
25 | return context
26 | }
27 |
--------------------------------------------------------------------------------
/app/context/tooltip-context.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { type ReactNode, createContext, useContext, useState } from "react"
4 |
5 | export interface TooltipData {
6 | centroid: [number, number]
7 | country: string
8 | count: number
9 | servers: Array<{
10 | id: string
11 | name: string
12 | status: boolean
13 | }>
14 | }
15 |
16 | interface TooltipContextType {
17 | tooltipData: TooltipData | null
18 | setTooltipData: (data: TooltipData | null) => void
19 | }
20 |
21 | const TooltipContext = createContext(undefined)
22 |
23 | export function TooltipProvider({ children }: { children: ReactNode }) {
24 | const [tooltipData, setTooltipData] = useState(null)
25 |
26 | return (
27 |
28 | {children}
29 |
30 | )
31 | }
32 |
33 | export function useTooltip() {
34 | const context = useContext(TooltipContext)
35 | if (context === undefined) {
36 | throw new Error("useTooltip must be used within a TooltipProvider")
37 | }
38 | return context
39 | }
40 |
--------------------------------------------------------------------------------
/app/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hamster1963/nezha-dash/8cfc36872a3e429957d6e38d56b870d588ae2361/app/favicon-16x16.png
--------------------------------------------------------------------------------
/app/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hamster1963/nezha-dash/8cfc36872a3e429957d6e38d56b870d588ae2361/app/favicon-32x32.png
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hamster1963/nezha-dash/8cfc36872a3e429957d6e38d56b870d588ae2361/app/favicon.ico
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { FilterProvider } from "@/app/context/network-filter-context"
2 | import { StatusProvider } from "@/app/context/status-context"
3 | import { ThemeColorManager } from "@/components/ThemeColorManager"
4 | import getEnv from "@/lib/env-entry"
5 | import { cn } from "@/lib/utils"
6 | import "@/styles/globals.css"
7 | import type { Metadata } from "next"
8 | import type { Viewport } from "next"
9 | import { NextIntlClientProvider } from "next-intl"
10 | import { getLocale, getMessages } from "next-intl/server"
11 | import { PublicEnvScript } from "next-runtime-env"
12 | import { ThemeProvider } from "next-themes"
13 | import { Inter as FontSans } from "next/font/google"
14 | import type React from "react"
15 |
16 | const fontSans = FontSans({
17 | subsets: ["latin"],
18 | variable: "--font-sans",
19 | })
20 |
21 | const customTitle = getEnv("NEXT_PUBLIC_CustomTitle")
22 | const customDescription = getEnv("NEXT_PUBLIC_CustomDescription")
23 | const disableIndex = getEnv("NEXT_PUBLIC_DisableIndex")
24 |
25 | export const metadata: Metadata = {
26 | manifest: "/manifest.json",
27 | title: customTitle || "NezhaDash",
28 | description: customDescription || "A dashboard for nezha",
29 | appleWebApp: {
30 | capable: true,
31 | title: customTitle || "NezhaDash",
32 | statusBarStyle: "default",
33 | },
34 | robots: {
35 | index: !disableIndex,
36 | follow: !disableIndex,
37 | },
38 | }
39 |
40 | export const viewport: Viewport = {
41 | width: "device-width",
42 | initialScale: 1,
43 | maximumScale: 1,
44 | userScalable: false,
45 | }
46 |
47 | export default async function LocaleLayout({
48 | children,
49 | }: {
50 | children: React.ReactNode
51 | }) {
52 | const locale = await getLocale()
53 | const messages = await getMessages()
54 |
55 | return (
56 |
57 |
58 |
59 |
63 |
67 |
68 |
69 |
75 |
76 |
77 |
78 |
79 | {children}
80 |
81 |
82 |
83 |
84 |
85 |
86 | )
87 | }
88 |
--------------------------------------------------------------------------------
/app/not-found.tsx:
--------------------------------------------------------------------------------
1 | import Footer from "@/app/(main)/footer"
2 | import Header from "@/app/(main)/header"
3 | import { useTranslations } from "next-intl"
4 | import Link from "next/link"
5 |
6 | export default function NotFoundPage() {
7 | const t = useTranslations("NotFoundPage")
8 | return (
9 |
10 |
11 |
12 |
13 | {t("h1_490-590_404NotFound")}
14 |
15 | {t("h1_490-590_404NotFoundBack")}
16 |
17 |
18 |
19 |
20 |
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/app/types/nezha-api.ts:
--------------------------------------------------------------------------------
1 | export type ServerApi = {
2 | live_servers: number
3 | offline_servers: number
4 | total_out_bandwidth: number
5 | total_in_bandwidth: number
6 | total_out_speed: number
7 | total_in_speed: number
8 | result: NezhaAPISafe[]
9 | }
10 |
11 | export type NezhaAPISafe = Omit
12 |
13 | export interface NezhaAPI {
14 | id: number
15 | name: string
16 | tag: string
17 | last_active: number
18 | online_status: boolean
19 | ipv4: string
20 | ipv6: string
21 | valid_ip: string
22 | display_index: number
23 | hide_for_guest: boolean
24 | host: NezhaAPIHost
25 | status: NezhaAPIStatus
26 | }
27 |
28 | export interface NezhaAPIHost {
29 | Platform: string
30 | PlatformVersion: string
31 | CPU: string[]
32 | MemTotal: number
33 | DiskTotal: number
34 | SwapTotal: number
35 | Arch: string
36 | Virtualization: string
37 | BootTime: number
38 | CountryCode: string
39 | Version: string
40 | GPU: string[]
41 | }
42 |
43 | export interface NezhaAPIStatus {
44 | CPU: number
45 | MemUsed: number
46 | SwapUsed: number
47 | DiskUsed: number
48 | NetInTransfer: number
49 | NetOutTransfer: number
50 | NetInSpeed: number
51 | NetOutSpeed: number
52 | Uptime: number
53 | Load1: number
54 | Load5: number
55 | Load15: number
56 | TcpConnCount: number
57 | UdpConnCount: number
58 | ProcessCount: number
59 | Temperatures: number
60 | GPU: number
61 | }
62 |
63 | export type ServerMonitorChart = {
64 | [key: string]: {
65 | created_at: number
66 | avg_delay: number
67 | }[]
68 | }
69 |
70 | export interface NezhaAPIMonitor {
71 | monitor_id: number
72 | monitor_name: string
73 | server_id: number
74 | server_name: string
75 | created_at: number[]
76 | avg_delay: number[]
77 | }
78 |
--------------------------------------------------------------------------------
/app/types/utils.ts:
--------------------------------------------------------------------------------
1 | export type MakeOptional = Omit & Partial>
2 |
--------------------------------------------------------------------------------
/auth.ts:
--------------------------------------------------------------------------------
1 | import getEnv from "@/lib/env-entry"
2 | import CryptoJS from "crypto-js"
3 | import NextAuth from "next-auth"
4 | import CredentialsProvider from "next-auth/providers/credentials"
5 |
6 | export const { handlers, signIn, signOut, auth } = NextAuth({
7 | secret:
8 | process.env.AUTH_SECRET ??
9 | CryptoJS.MD5(`this_is_nezha_dash_web_secret_${getEnv("SitePassword")}`).toString(),
10 | trustHost: (process.env.AUTH_TRUST_HOST as boolean | undefined) ?? true,
11 | providers: [
12 | CredentialsProvider({
13 | type: "credentials",
14 | credentials: { password: { label: "Password", type: "password" } },
15 | // authorization function
16 | async authorize(credentials) {
17 | const { password } = credentials
18 | if (password === getEnv("SitePassword")) {
19 | return { id: "nezha-dash-auth" }
20 | }
21 | return { error: "Invalid password" }
22 | },
23 | }),
24 | ],
25 | callbacks: {
26 | async signIn({ user }) {
27 | // @ts-expect-error user is not null
28 | if (user.error) {
29 | return false
30 | }
31 | return true
32 | },
33 | },
34 | })
35 |
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
3 | "vcs": { "enabled": false, "clientKind": "git", "useIgnoreFile": false },
4 | "files": { "ignoreUnknown": false, "ignore": [".next", "public", "styles/globals.css"] },
5 | "formatter": {
6 | "enabled": true,
7 | "useEditorconfig": true,
8 | "formatWithErrors": false,
9 | "indentStyle": "space",
10 | "indentWidth": 2,
11 | "lineWidth": 100,
12 | "attributePosition": "auto",
13 | "bracketSpacing": true
14 | },
15 | "organizeImports": { "enabled": true },
16 | "linter": {
17 | "enabled": true,
18 | "rules": {
19 | "recommended": true,
20 | "nursery": {
21 | "useSortedClasses": "error"
22 | },
23 | "a11y": {
24 | "useKeyWithClickEvents": "off",
25 | "noLabelWithoutControl": "off"
26 | },
27 | "security": {
28 | "noDangerouslySetInnerHtml": "off"
29 | },
30 | "complexity": { "noUselessTypeConstraint": "error" },
31 | "correctness": {
32 | "noUnusedVariables": "error",
33 | "useArrayLiterals": "off",
34 | "useExhaustiveDependencies": "off"
35 | },
36 | "style": { "noNamespace": "error", "useAsConstAssertion": "error" },
37 | "suspicious": {
38 | "noExplicitAny": "off",
39 | "noExtraNonNullAssertion": "error",
40 | "noMisleadingInstantiator": "error",
41 | "noUnsafeDeclarationMerging": "error",
42 | "useNamespaceKeyword": "error"
43 | }
44 | }
45 | },
46 | "javascript": {
47 | "formatter": {
48 | "jsxQuoteStyle": "double",
49 | "quoteProperties": "asNeeded",
50 | "trailingCommas": "all",
51 | "semicolons": "asNeeded",
52 | "arrowParentheses": "always",
53 | "bracketSameLine": false,
54 | "quoteStyle": "double",
55 | "attributePosition": "auto",
56 | "bracketSpacing": true
57 | }
58 | },
59 | "overrides": [
60 | {
61 | "include": ["*.ts", "*.tsx", "*.mts", "*.cts"],
62 | "linter": {
63 | "rules": {
64 | "correctness": {
65 | "noUnusedImports": "error"
66 | },
67 | "style": {
68 | "noArguments": "error",
69 | "noVar": "error",
70 | "useConst": "error"
71 | },
72 | "suspicious": {
73 | "noClassAssign": "off",
74 | "noDuplicateClassMembers": "off",
75 | "noDuplicateObjectKeys": "off",
76 | "noDuplicateParameters": "off",
77 | "noFunctionAssign": "off",
78 | "noRedeclare": "off",
79 | "noUnsafeNegation": "off",
80 | "useGetterReturn": "off"
81 | }
82 | }
83 | }
84 | }
85 | ]
86 | }
87 |
--------------------------------------------------------------------------------
/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hamster1963/nezha-dash/8cfc36872a3e429957d6e38d56b870d588ae2361/bun.lockb
--------------------------------------------------------------------------------
/changelogithub.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "types": {
3 | "feat": { "title": "🚀 Features" },
4 | "fix": { "title": "🔧 Bug Fixes" },
5 | "docs": { "title": "📚 Documentation" },
6 | "style": { "title": "💄 Styles" },
7 | "refactor": { "title": "🔨 Refactor" },
8 | "perf": { "title": "🏎 Performance" },
9 | "test": { "title": "🚨 Tests" },
10 | "build": { "title": "🛠 Build" },
11 | "ci": { "title": "👷 CI" },
12 | "chore": { "title": "🛗 Chore" },
13 | "revert": { "title": "⏪ Revert" }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "app/globals.css",
9 | "baseColor": "stone",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/components/AnimatedCount.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 | import { useEffect, useState } from "react"
3 |
4 | export default function AnimateCountClient({
5 | count,
6 | className,
7 | minDigits,
8 | }: {
9 | count: number
10 | className?: string
11 | minDigits?: number
12 | }) {
13 | const [previousCount, setPreviousCount] = useState(count)
14 |
15 | useEffect(() => {
16 | if (count !== previousCount) {
17 | setTimeout(() => {
18 | setPreviousCount(count)
19 | }, 300)
20 | }
21 | }, [count])
22 | return (
23 |
30 | {count}
31 |
32 | )
33 | }
34 |
35 | export function AnimateCount({
36 | children: count,
37 | className,
38 | preCount,
39 | minDigits = 1,
40 | ...props
41 | }: {
42 | children: number
43 | className?: string
44 | preCount?: number
45 | minDigits?: number
46 | }) {
47 | const currentDigits = count.toString().split("")
48 | const previousDigits = (
49 | preCount !== undefined ? preCount.toString() : count - 1 >= 0 ? (count - 1).toString() : "0"
50 | ).split("")
51 |
52 | // Ensure both numbers meet the minimum length requirement and maintain the same length for animation
53 | const maxLength = Math.max(previousDigits.length, currentDigits.length, minDigits)
54 | while (previousDigits.length < maxLength) {
55 | previousDigits.unshift("0")
56 | }
57 | while (currentDigits.length < maxLength) {
58 | currentDigits.unshift("0")
59 | }
60 |
61 | return (
62 |
63 | {currentDigits.map((digit, index) => {
64 | const hasChanged = digit !== previousDigits[index]
65 | return (
66 |
72 |
80 | {previousDigits[index]}
81 |
82 |
89 | {digit}
90 |
91 |
92 | )
93 | })}
94 |
95 | )
96 | }
97 |
--------------------------------------------------------------------------------
/components/DashCommand.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { Home, Languages, Moon, Sun, SunMoon } from "lucide-react"
4 |
5 | import { useServerData } from "@/app/context/server-data-context"
6 | import {
7 | CommandDialog,
8 | CommandEmpty,
9 | CommandGroup,
10 | CommandInput,
11 | CommandItem,
12 | CommandList,
13 | CommandSeparator,
14 | } from "@/components/ui/command"
15 | import { localeItems } from "@/i18n-metadata"
16 | import { setUserLocale } from "@/i18n/locale"
17 | import { useTranslations } from "next-intl"
18 | import { useTheme } from "next-themes"
19 | import { useRouter } from "next/navigation"
20 | import { useEffect, useState } from "react"
21 |
22 | export function DashCommand() {
23 | const [open, setOpen] = useState(false)
24 | const [search, setSearch] = useState("")
25 | const { data } = useServerData()
26 | const router = useRouter()
27 | const { setTheme } = useTheme()
28 | const t = useTranslations("DashCommand")
29 |
30 | useEffect(() => {
31 | const down = (e: KeyboardEvent) => {
32 | if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
33 | e.preventDefault()
34 | setOpen((open) => !open)
35 | }
36 | }
37 |
38 | document.addEventListener("keydown", down)
39 | return () => document.removeEventListener("keydown", down)
40 | }, [])
41 |
42 | if (!data?.result) return null
43 |
44 | const sortedServers = data.result.sort((a, b) => {
45 | const displayIndexDiff = (b.display_index || 0) - (a.display_index || 0)
46 | if (displayIndexDiff !== 0) return displayIndexDiff
47 | return a.id - b.id
48 | })
49 |
50 | const languageShortcuts = localeItems.map((item) => ({
51 | keywords: ["language", "locale", item.code.toLowerCase()],
52 | icon: ,
53 | label: item.name,
54 | action: () => setUserLocale(item.code),
55 | value: `language ${item.name.toLowerCase()} ${item.code}`,
56 | }))
57 |
58 | const shortcuts = [
59 | {
60 | keywords: ["home", "homepage"],
61 | icon: ,
62 | label: t("Home"),
63 | action: () => router.push("/"),
64 | },
65 | {
66 | keywords: ["light", "theme", "lightmode"],
67 | icon: ,
68 | label: t("ToggleLightMode"),
69 | action: () => setTheme("light"),
70 | },
71 | {
72 | keywords: ["dark", "theme", "darkmode"],
73 | icon: ,
74 | label: t("ToggleDarkMode"),
75 | action: () => setTheme("dark"),
76 | },
77 | {
78 | keywords: ["system", "theme", "systemmode"],
79 | icon: ,
80 | label: t("ToggleSystemMode"),
81 | action: () => setTheme("system"),
82 | },
83 | ...languageShortcuts,
84 | ].map((item) => ({
85 | ...item,
86 | value: `${item.keywords.join(" ")} ${item.label}`,
87 | }))
88 |
89 | return (
90 | <>
91 |
92 |
93 |
94 | {t("NoResults")}
95 |
96 | {sortedServers.map((server) => (
97 | {
101 | router.push(`/server/${server.id}`)
102 | setOpen(false)
103 | }}
104 | >
105 | {server.online_status ? (
106 |
107 | ) : (
108 |
109 | )}
110 | {server.name}
111 |
112 | ))}
113 |
114 |
115 |
116 |
117 | {shortcuts.map((item) => (
118 | {
122 | item.action()
123 | setOpen(false)
124 | }}
125 | >
126 | {item.icon}
127 | {item.label}
128 |
129 | ))}
130 |
131 |
132 |
133 | >
134 | )
135 | }
136 |
--------------------------------------------------------------------------------
/components/LanguageSwitcher.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { Button } from "@/components/ui/button"
4 | import {
5 | DropdownMenu,
6 | DropdownMenuContent,
7 | DropdownMenuItem,
8 | DropdownMenuTrigger,
9 | } from "@/components/ui/dropdown-menu"
10 | import { localeItems } from "@/i18n-metadata"
11 | import { setUserLocale } from "@/i18n/locale"
12 | import { cn } from "@/lib/utils"
13 | import { CheckCircleIcon, LanguageIcon } from "@heroicons/react/20/solid"
14 | import { useLocale } from "next-intl"
15 |
16 | export function LanguageSwitcher() {
17 | const locale = useLocale()
18 |
19 | const handleSelect = (e: Event, newLocale: string) => {
20 | e.preventDefault() // 阻止默认的关闭行为
21 | setUserLocale(newLocale)
22 | }
23 |
24 | return (
25 |
26 |
27 |
35 |
36 |
37 | {localeItems.map((item, index) => (
38 | handleSelect(e, item.code)}
41 | className={cn(
42 | {
43 | "gap-3 bg-muted font-semibold": locale === item.code,
44 | },
45 | {
46 | "rounded-t-[5px]": index === localeItems.length - 1,
47 | "rounded-[5px]": index !== 0 && index !== localeItems.length - 1,
48 | "rounded-b-[5px]": index === 0,
49 | },
50 | )}
51 | >
52 | {item.name} {locale === item.code && }
53 |
54 | ))}
55 |
56 |
57 | )
58 | }
59 |
--------------------------------------------------------------------------------
/components/ServerCard.tsx:
--------------------------------------------------------------------------------
1 | import type { NezhaAPISafe } from "@/app/types/nezha-api"
2 | import ServerFlag from "@/components/ServerFlag"
3 | import ServerUsageBar from "@/components/ServerUsageBar"
4 | import { Badge } from "@/components/ui/badge"
5 | import { Card } from "@/components/ui/card"
6 | import getEnv from "@/lib/env-entry"
7 | import { GetFontLogoClass, GetOsName, MageMicrosoftWindows } from "@/lib/logo-class"
8 | import { cn, formatBytes, formatNezhaInfo } from "@/lib/utils"
9 | import { useTranslations } from "next-intl"
10 | import Link from "next/link"
11 |
12 | export default function ServerCard({
13 | serverInfo,
14 | }: {
15 | serverInfo: NezhaAPISafe
16 | }) {
17 | const t = useTranslations("ServerCard")
18 | const { id, name, country_code, online, cpu, up, down, mem, stg, host } =
19 | formatNezhaInfo(serverInfo)
20 |
21 | const showFlag = getEnv("NEXT_PUBLIC_ShowFlag") === "true"
22 | const showNetTransfer = getEnv("NEXT_PUBLIC_ShowNetTransfer") === "true"
23 | const fixedTopServerName = getEnv("NEXT_PUBLIC_FixedTopServerName") === "true"
24 |
25 | const saveSession = () => {
26 | sessionStorage.setItem("fromMainPage", "true")
27 | }
28 |
29 | return online ? (
30 |
31 |
40 |
46 |
47 |
53 | {showFlag ? : null}
54 |
55 |
56 |
62 | {name}
63 |
64 |
65 |
66 |
67 |
72 | {fixedTopServerName && (
73 |
74 |
75 | {host.Platform.includes("Windows") ? (
76 |
77 | ) : (
78 |
79 | )}
80 |
81 |
82 |
{t("System")}
83 |
84 | {host.Platform.includes("Windows") ? "Windows" : GetOsName(host.Platform)}
85 |
86 |
87 |
88 | )}
89 |
90 |
{t("CPU")}
91 |
{cpu.toFixed(2)}%
92 |
93 |
94 |
95 |
{t("Mem")}
96 |
{mem.toFixed(2)}%
97 |
98 |
99 |
100 |
{t("STG")}
101 |
{stg.toFixed(2)}%
102 |
103 |
104 |
105 |
{t("Upload")}
106 |
107 | {up >= 1024 ? `${(up / 1024).toFixed(2)}G/s` : `${up.toFixed(2)}M/s`}
108 |
109 |
110 |
111 |
{t("Download")}
112 |
113 | {down >= 1024 ? `${(down / 1024).toFixed(2)}G/s` : `${down.toFixed(2)}M/s`}
114 |
115 |
116 |
117 | {showNetTransfer && (
118 |
119 |
123 | {t("Upload")}:{formatBytes(serverInfo.status.NetOutTransfer)}
124 |
125 |
129 | {t("Download")}:{formatBytes(serverInfo.status.NetInTransfer)}
130 |
131 |
132 | )}
133 |
134 |
135 |
136 | ) : (
137 |
138 |
148 |
154 |
155 |
161 | {showFlag ? : null}
162 |
163 |
164 |
167 | {name}
168 |
169 |
170 |
171 |
172 |
173 | )
174 | }
175 |
--------------------------------------------------------------------------------
/components/ServerCardInline.tsx:
--------------------------------------------------------------------------------
1 | import type { NezhaAPISafe } from "@/app/types/nezha-api"
2 | import ServerFlag from "@/components/ServerFlag"
3 | import ServerUsageBar from "@/components/ServerUsageBar"
4 | import { Card } from "@/components/ui/card"
5 | import getEnv from "@/lib/env-entry"
6 | import { GetFontLogoClass, GetOsName, MageMicrosoftWindows } from "@/lib/logo-class"
7 | import { cn, formatBytes, formatNezhaInfo } from "@/lib/utils"
8 | import { useTranslations } from "next-intl"
9 | import Link from "next/link"
10 |
11 | import { Separator } from "./ui/separator"
12 |
13 | export default function ServerCardInline({
14 | serverInfo,
15 | }: {
16 | serverInfo: NezhaAPISafe
17 | }) {
18 | const t = useTranslations("ServerCard")
19 | const { id, name, country_code, online, cpu, up, down, mem, stg, host } =
20 | formatNezhaInfo(serverInfo)
21 |
22 | const showFlag = getEnv("NEXT_PUBLIC_ShowFlag") === "true"
23 |
24 | const saveSession = () => {
25 | sessionStorage.setItem("fromMainPage", "true")
26 | }
27 |
28 | return online ? (
29 |
30 |
35 |
39 |
40 |
46 | {showFlag ? : null}
47 |
48 |
49 |
55 | {name}
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 | {host.Platform.includes("Windows") ? (
65 |
66 | ) : (
67 |
68 | )}
69 |
70 |
71 |
{t("System")}
72 |
73 | {host.Platform.includes("Windows") ? "Windows" : GetOsName(host.Platform)}
74 |
75 |
76 |
77 |
78 |
{t("Uptime")}
79 |
80 | {(serverInfo?.status.Uptime / 86400).toFixed(0)} {"Days"}
81 |
82 |
83 |
84 |
{t("CPU")}
85 |
{cpu.toFixed(2)}%
86 |
87 |
88 |
89 |
{t("Mem")}
90 |
{mem.toFixed(2)}%
91 |
92 |
93 |
94 |
{t("STG")}
95 |
{stg.toFixed(2)}%
96 |
97 |
98 |
99 |
{t("Upload")}
100 |
101 | {up >= 1024 ? `${(up / 1024).toFixed(2)}G/s` : `${up.toFixed(2)}M/s`}
102 |
103 |
104 |
105 |
{t("Download")}
106 |
107 | {down >= 1024 ? `${(down / 1024).toFixed(2)}G/s` : `${down.toFixed(2)}M/s`}
108 |
109 |
110 |
111 |
{t("TotalUpload")}
112 |
113 | {formatBytes(serverInfo.status.NetOutTransfer)}
114 |
115 |
116 |
117 |
{t("TotalDownload")}
118 |
119 | {formatBytes(serverInfo.status.NetInTransfer)}
120 |
121 |
122 |
123 |
124 |
125 |
126 | ) : (
127 |
128 |
133 |
137 |
138 |
144 | {showFlag ? : null}
145 |
146 |
147 |
150 | {name}
151 |
152 |
153 |
154 |
155 |
156 | )
157 | }
158 |
--------------------------------------------------------------------------------
/components/ServerFlag.tsx:
--------------------------------------------------------------------------------
1 | import getEnv from "@/lib/env-entry"
2 | import { cn } from "@/lib/utils"
3 | import getUnicodeFlagIcon from "country-flag-icons/unicode"
4 | import { useEffect, useState } from "react"
5 |
6 | export default function ServerFlag({
7 | country_code,
8 | className,
9 | }: {
10 | country_code: string
11 | className?: string
12 | }) {
13 | const [supportsEmojiFlags, setSupportsEmojiFlags] = useState(true)
14 |
15 | const useSvgFlag = getEnv("NEXT_PUBLIC_ForceUseSvgFlag") === "true"
16 |
17 | useEffect(() => {
18 | if (useSvgFlag) {
19 | // 如果环境变量要求直接使用 SVG,则无需检查 Emoji 支持
20 | setSupportsEmojiFlags(false)
21 | return
22 | }
23 |
24 | const checkEmojiSupport = () => {
25 | const canvas = document.createElement("canvas")
26 | const ctx = canvas.getContext("2d")
27 | const emojiFlag = "🇺🇸" // 使用美国国旗作为测试
28 | if (!ctx) return
29 | ctx.fillStyle = "#000"
30 | ctx.textBaseline = "top"
31 | ctx.font = "32px Arial"
32 | ctx.fillText(emojiFlag, 0, 0)
33 |
34 | const support = ctx.getImageData(16, 16, 1, 1).data[3] !== 0
35 | setSupportsEmojiFlags(support)
36 | }
37 |
38 | checkEmojiSupport()
39 | }, [useSvgFlag]) // 将 `useSvgFlag` 作为依赖,当其变化时重新触发
40 |
41 | if (!country_code) return null
42 |
43 | return (
44 |
45 | {useSvgFlag || !supportsEmojiFlags ? (
46 |
47 | ) : (
48 | getUnicodeFlagIcon(country_code)
49 | )}
50 |
51 | )
52 | }
53 |
--------------------------------------------------------------------------------
/components/ServerUsageBar.tsx:
--------------------------------------------------------------------------------
1 | import { Progress } from "@/components/ui/progress"
2 |
3 | type ServerUsageBarProps = {
4 | value: number
5 | }
6 |
7 | export default function ServerUsageBar({ value }: ServerUsageBarProps) {
8 | return (
9 |