├── .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 |
nezhadash
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 | ![screen](/.github/v2-1.webp) 35 | ![screen](/.github/v2-2.webp) 36 | ![screen](/.github/v2-3.webp) 37 | ![screen](/.github/v2-4.webp) 38 | ![screen](/.github/v2-dark.webp) 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 | 43 | Interactive Map 44 | 45 | 46 | 47 | 48 | 49 | 50 | {/* Background rect to handle mouse events in empty areas */} 51 | setTooltipData(null)} 58 | /> 59 | {filteredFeatures.map((feature, index) => { 60 | const isHighlighted = countries.includes(feature.properties.iso_a2_eh) 61 | 62 | const serverCount = serverCounts[feature.properties.iso_a2_eh] || 0 63 | 64 | return ( 65 | { 74 | if (!isHighlighted) { 75 | setTooltipData(null) 76 | return 77 | } 78 | if (path.centroid(feature)) { 79 | const countryCode = feature.properties.iso_a2_eh 80 | const countryServers = nezhaServerList.result 81 | .filter( 82 | (server: any) => server.host.CountryCode?.toUpperCase() === countryCode, 83 | ) 84 | .map((server: any) => ({ 85 | id: server.id, 86 | name: server.name, 87 | status: server.online_status, 88 | })) 89 | setTooltipData({ 90 | centroid: path.centroid(feature), 91 | country: feature.properties.name, 92 | count: serverCount, 93 | servers: countryServers, 94 | }) 95 | } 96 | }} 97 | /> 98 | ) 99 | })} 100 | 101 | {/* 渲染不在 filteredFeatures 中的国家标记点 */} 102 | {countries.map((countryCode) => { 103 | // 检查该国家是否已经在 filteredFeatures 中 104 | const isInFilteredFeatures = filteredFeatures.some( 105 | (feature) => feature.properties.iso_a2_eh === countryCode, 106 | ) 107 | 108 | // 如果已经在 filteredFeatures 中,跳过 109 | if (isInFilteredFeatures) return null 110 | 111 | // 获取国家的经纬度 112 | const coords = countryCoordinates[countryCode] 113 | if (!coords) return null 114 | 115 | // 使用投影函数将经纬度转换为 SVG 坐标 116 | const [x, y] = projection([coords.lng, coords.lat]) || [0, 0] 117 | const serverCount = serverCounts[countryCode] || 0 118 | 119 | return ( 120 | { 123 | const countryServers = nezhaServerList.result 124 | .filter((server: any) => server.host.CountryCode?.toUpperCase() === countryCode) 125 | .map((server: any) => ({ 126 | id: server.id, 127 | name: server.name, 128 | status: server.online_status, 129 | })) 130 | setTooltipData({ 131 | centroid: [x, y], 132 | country: coords.name, 133 | count: serverCount, 134 | servers: countryServers, 135 | }) 136 | }} 137 | className="cursor-pointer" 138 | > 139 | 145 | 146 | ) 147 | })} 148 | 149 | 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 |
40 |
41 |

42 | {t("p_146-598_Findthecodeon")}{" "} 43 | {t("a_303-585_GitHub")} 44 | v{version} 45 |

46 |
47 | {t("section_607-869_2020")} 48 | {currentYear} {t("a_800-850_Hamster1963")} 49 |
50 |
51 |

52 | 53 | {isMac ? : "Ctrl "}K 54 | 55 |

56 |
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 |
57 | {links.map((link) => ( 58 | 65 | {link.name} 66 | 67 | ))} 68 |
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 | apple-touch-icon 132 | apple-touch-icon 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 | 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 | 90 ? "bg-red-500" : value > 70 ? "bg-orange-400" : "bg-green-500"} 14 | className={"h-[3px] rounded-sm"} 15 | /> 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /components/SignIn.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { getCsrfToken, signIn } from "next-auth/react" 4 | import { useTranslations } from "next-intl" 5 | import { useRouter } from "next/navigation" 6 | import { useEffect, useState } from "react" 7 | 8 | import { Loader } from "./loading/Loader" 9 | 10 | export function SignIn() { 11 | const t = useTranslations("SignIn") 12 | 13 | const [csrfToken, setCsrfToken] = useState("") 14 | const [loading, setLoading] = useState(false) 15 | const [errorState, setErrorState] = useState(false) 16 | const [successState, setSuccessState] = useState(false) 17 | 18 | const router = useRouter() 19 | 20 | useEffect(() => { 21 | async function loadProviders() { 22 | const csrf = await getCsrfToken() 23 | setCsrfToken(csrf) 24 | } 25 | loadProviders() 26 | }, []) 27 | 28 | const handleSubmit = async (e: React.FormEvent) => { 29 | e.preventDefault() 30 | setLoading(true) 31 | const formData = new FormData(e.currentTarget) 32 | const password = formData.get("password") as string 33 | const res = await signIn("credentials", { 34 | password: password, 35 | redirect: false, 36 | }) 37 | if (res?.error) { 38 | console.log("login error") 39 | setErrorState(true) 40 | setSuccessState(false) 41 | } else { 42 | console.log("login success") 43 | setErrorState(false) 44 | setSuccessState(true) 45 | router.push("/") 46 | router.refresh() 47 | } 48 | setLoading(false) 49 | } 50 | return ( 51 |
55 | 56 |
57 | 69 | 77 |
78 |
79 | ) 80 | } 81 | -------------------------------------------------------------------------------- /components/Switch.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import getEnv from "@/lib/env-entry" 4 | import { cn } from "@/lib/utils" 5 | import { useLocale, useTranslations } from "next-intl" 6 | import { createRef, useEffect, useRef, useState } from "react" 7 | 8 | export default function Switch({ 9 | allTag, 10 | nowTag, 11 | tagCountMap, 12 | onTagChange, 13 | }: { 14 | allTag: string[] 15 | nowTag: string 16 | tagCountMap: Record 17 | onTagChange: (tag: string) => void 18 | }) { 19 | const scrollRef = useRef(null) 20 | const tagRefs = useRef(allTag.map(() => createRef())) 21 | const t = useTranslations("ServerListClient") 22 | const locale = useLocale() 23 | const [indicator, setIndicator] = useState<{ x: number; w: number } | null>(null) 24 | const [isFirstRender, setIsFirstRender] = useState(true) 25 | 26 | useEffect(() => { 27 | const savedTag = sessionStorage.getItem("selectedTag") 28 | if (savedTag && allTag.includes(savedTag)) { 29 | onTagChange(savedTag) 30 | } 31 | }, [allTag, onTagChange]) 32 | 33 | useEffect(() => { 34 | const container = scrollRef.current 35 | if (!container) return 36 | 37 | const isOverflowing = container.scrollWidth > container.clientWidth 38 | if (!isOverflowing) return 39 | 40 | const onWheel = (e: WheelEvent) => { 41 | e.preventDefault() 42 | container.scrollLeft += e.deltaY 43 | } 44 | 45 | container.addEventListener("wheel", onWheel, { passive: false }) 46 | 47 | return () => { 48 | container.removeEventListener("wheel", onWheel) 49 | } 50 | }, []) 51 | 52 | useEffect(() => { 53 | const currentTagElement = tagRefs.current[allTag.indexOf(nowTag)]?.current 54 | if (currentTagElement) { 55 | setIndicator({ 56 | x: currentTagElement.offsetLeft, 57 | w: currentTagElement.offsetWidth, 58 | }) 59 | } 60 | 61 | if (isFirstRender) { 62 | setTimeout(() => { 63 | setIsFirstRender(false) 64 | }, 50) 65 | } 66 | }, [nowTag, locale, allTag, isFirstRender]) 67 | 68 | useEffect(() => { 69 | const currentTagElement = tagRefs.current[allTag.indexOf(nowTag)]?.current 70 | const container = scrollRef.current 71 | 72 | if (currentTagElement && container) { 73 | const containerRect = container.getBoundingClientRect() 74 | const tagRect = currentTagElement.getBoundingClientRect() 75 | 76 | const scrollLeft = currentTagElement.offsetLeft - (containerRect.width - tagRect.width) / 2 77 | 78 | container.scrollTo({ 79 | left: Math.max(0, scrollLeft), 80 | behavior: "smooth", 81 | }) 82 | } 83 | }, [nowTag, locale]) 84 | 85 | return ( 86 |
90 |
91 | {indicator && ( 92 |
101 | )} 102 | {allTag.map((tag, index) => ( 103 |
{ 107 | onTagChange(tag) 108 | sessionStorage.setItem("selectedTag", tag) 109 | }} 110 | className={cn( 111 | "relative cursor-pointer rounded-3xl px-2.5 py-[8px] font-[600] text-[13px]", 112 | "text-stone-400 transition-all duration-500 ease-in-out hover:text-stone-950 dark:text-stone-500 hover:dark:text-stone-50", 113 | { 114 | "text-stone-950 dark:text-stone-50": nowTag === tag, 115 | }, 116 | )} 117 | > 118 |
119 |
120 | {tag === "defaultTag" ? t("defaultTag") : tag}{" "} 121 | {getEnv("NEXT_PUBLIC_ShowTagCount") === "true" && tag !== "defaultTag" && ( 122 |
{tagCountMap[tag]}
123 | )} 124 |
125 |
126 |
127 | ))} 128 |
129 |
130 | ) 131 | } 132 | -------------------------------------------------------------------------------- /components/TabSwitch.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { cn } from "@/lib/utils" 4 | import { useLocale, useTranslations } from "next-intl" 5 | import { useEffect, useRef, useState } from "react" 6 | 7 | export default function TabSwitch({ 8 | tabs, 9 | currentTab, 10 | setCurrentTab, 11 | }: { 12 | tabs: string[] 13 | currentTab: string 14 | setCurrentTab: (tab: string) => void 15 | }) { 16 | const t = useTranslations("TabSwitch") 17 | const [indicator, setIndicator] = useState<{ x: number; w: number }>({ 18 | x: 0, 19 | w: 0, 20 | }) 21 | const tabRefs = useRef<(HTMLDivElement | null)[]>([]) 22 | const locale = useLocale() 23 | 24 | useEffect(() => { 25 | const currentTabElement = tabRefs.current[tabs.indexOf(currentTab)] 26 | if (currentTabElement) { 27 | const parentPadding = 1 28 | setIndicator({ 29 | x: 30 | tabs.indexOf(currentTab) !== 0 31 | ? currentTabElement.offsetLeft - parentPadding 32 | : currentTabElement.offsetLeft, 33 | w: currentTabElement.offsetWidth, 34 | }) 35 | } 36 | }, [currentTab, tabs, locale]) 37 | 38 | return ( 39 |
40 |
41 | {indicator.w > 0 && ( 42 |
51 | )} 52 | {tabs.map((tab: string, index) => ( 53 |
{ 56 | tabRefs.current[index] = el 57 | }} 58 | onClick={() => setCurrentTab(tab)} 59 | className={cn( 60 | "relative cursor-pointer rounded-3xl px-2.5 py-[8px] font-[600] text-[13px]", 61 | "text-stone-400 transition-all duration-500 ease-in-out hover:text-stone-950 dark:text-stone-500 hover:dark:text-stone-50", 62 | { 63 | "text-stone-950 dark:text-stone-50": currentTab === tab, 64 | }, 65 | )} 66 | > 67 |
68 |

{t(tab)}

69 |
70 |
71 | ))} 72 |
73 |
74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /components/ThemeColorManager.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useTheme } from "next-themes" 4 | import { useEffect } from "react" 5 | 6 | export function ThemeColorManager() { 7 | const { theme, systemTheme } = useTheme() 8 | 9 | useEffect(() => { 10 | const updateThemeColor = () => { 11 | const currentTheme = theme === "system" ? systemTheme : theme 12 | const meta = document.querySelector('meta[name="theme-color"]') 13 | 14 | if (!meta) { 15 | const newMeta = document.createElement("meta") 16 | newMeta.name = "theme-color" 17 | document.head.appendChild(newMeta) 18 | } 19 | 20 | const themeColor = 21 | currentTheme === "dark" 22 | ? "hsl(30 15% 8%)" // 深色模式背景色 23 | : "hsl(0 0% 98%)" // 浅色模式背景色 24 | 25 | document.querySelector('meta[name="theme-color"]')?.setAttribute("content", themeColor) 26 | } 27 | 28 | // Update on mount and theme change 29 | updateThemeColor() 30 | 31 | // Listen for system theme changes 32 | const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") 33 | mediaQuery.addEventListener("change", updateThemeColor) 34 | 35 | return () => mediaQuery.removeEventListener("change", updateThemeColor) 36 | }, [theme, systemTheme]) 37 | 38 | return null 39 | } 40 | -------------------------------------------------------------------------------- /components/ThemeSwitcher.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Button } from "@/components/ui/button" 4 | import { 5 | DropdownMenu, 6 | DropdownMenuContent, 7 | DropdownMenuTrigger, 8 | } from "@/components/ui/dropdown-menu" 9 | import { CheckIcon, MinusIcon, Moon, Sun } from "lucide-react" 10 | import { useTranslations } from "next-intl" 11 | import { useTheme } from "next-themes" 12 | import { useId } from "react" 13 | import { RadioGroup, RadioGroupItem } from "./ui/radio-group" 14 | 15 | const items = [ 16 | { value: "light", label: "Light", image: "/ui-light.png" }, 17 | { value: "dark", label: "Dark", image: "/ui-dark.png" }, 18 | { value: "system", label: "System", image: "/ui-system.png" }, 19 | ] 20 | 21 | export function ModeToggle() { 22 | const { setTheme, theme } = useTheme() 23 | const t = useTranslations("ThemeSwitcher") 24 | 25 | const handleSelect = (newTheme: string) => { 26 | setTheme(newTheme) 27 | } 28 | const id = useId() 29 | 30 | return ( 31 | 32 | 33 | 42 | 43 | 44 |
45 | 46 | {items.map((item) => ( 47 | 74 | ))} 75 | 76 |
77 |
78 |
79 | ) 80 | } 81 | -------------------------------------------------------------------------------- /components/loading/GlobalLoading.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Loader } from "@/components/loading/Loader" 4 | import { useTranslations } from "next-intl" 5 | 6 | export default function GlobalLoading() { 7 | const t = useTranslations("Global") 8 | return ( 9 |
10 |
11 | {t("Loading")} 12 | 13 |
14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /components/loading/Loader.tsx: -------------------------------------------------------------------------------- 1 | const bars = Array(8).fill(0) 2 | 3 | export const Loader = ({ visible }: { visible: boolean }) => { 4 | return ( 5 |
6 |
7 | {bars.map((_, i) => ( 8 |
9 | ))} 10 |
11 |
12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /components/loading/NetworkChartLoading.tsx: -------------------------------------------------------------------------------- 1 | import { Loader } from "@/components/loading/Loader" 2 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" 3 | 4 | export default function NetworkChartLoading() { 5 | return ( 6 | 7 | 8 |
9 | 10 |
11 | 12 |
13 |
14 |
15 | 16 |
17 | 18 | 19 |
20 | 21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /components/loading/ServerDetailLoading.tsx: -------------------------------------------------------------------------------- 1 | import { BackIcon } from "@/components/Icon" 2 | import { Skeleton } from "@/components/ui/skeleton" 3 | import { useRouter } from "next/navigation" 4 | 5 | export function ServerDetailChartLoading() { 6 | return ( 7 |
8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 | ) 18 | } 19 | 20 | export function ServerDetailLoading() { 21 | const router = useRouter() 22 | 23 | return ( 24 | <> 25 |
{ 27 | router.push("/") 28 | }} 29 | className="flex flex-none cursor-pointer items-center gap-0.5 break-all font-semibold text-xl leading-none tracking-tight" 30 | > 31 | 32 | 33 |
34 | 35 | 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /components/ui/animated-circular-progress-bar.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | interface Props { 4 | max: number 5 | value: number 6 | min: number 7 | className?: string 8 | primaryColor?: string 9 | } 10 | 11 | export default function AnimatedCircularProgressBar({ 12 | max = 100, 13 | min = 0, 14 | value = 0, 15 | primaryColor, 16 | className, 17 | }: Props) { 18 | const circumference = 2 * Math.PI * 45 19 | const percentPx = circumference / 100 20 | const currentPercent = ((value - min) / (max - min)) * 100 21 | 22 | return ( 23 |
40 | 41 | Circular Progress Bar 42 | {currentPercent <= 90 && currentPercent >= 0 && ( 43 | 65 | )} 66 | 92 | 93 | 97 | {currentPercent} 98 | 99 |
100 | ) 101 | } 102 | -------------------------------------------------------------------------------- /components/ui/animated-tooltip.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image" 2 | import Link from "next/link" 3 | 4 | export const AnimatedTooltip = ({ 5 | items, 6 | }: { 7 | items: { 8 | id: number 9 | name: string 10 | designation: string 11 | image: string 12 | }[] 13 | }) => { 14 | return ( 15 | <> 16 | {items.map((item) => ( 17 |
18 | 19 | {item.name} 27 | 28 |
29 | ))} 30 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | import { type VariantProps, cva } from "class-variance-authority" 3 | import type * as React from "react" 4 | 5 | const badgeVariants = cva( 6 | "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-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2", 7 | { 8 | variants: { 9 | variant: { 10 | default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 11 | secondary: 12 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 13 | destructive: 14 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 15 | outline: "text-foreground", 16 | }, 17 | }, 18 | defaultVariants: { 19 | variant: "default", 20 | }, 21 | }, 22 | ) 23 | 24 | export interface BadgeProps 25 | extends React.HTMLAttributes, 26 | VariantProps {} 27 | 28 | function Badge({ className, variant, ...props }: BadgeProps) { 29 | return
30 | } 31 | 32 | export { Badge, badgeVariants } 33 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { type VariantProps, cva } from "class-variance-authority" 4 | import * as React from "react" 5 | 6 | const buttonVariants = cva( 7 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 12 | destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", 13 | outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 14 | secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | ghost: "hover:bg-accent hover:text-accent-foreground", 16 | link: "text-primary underline-offset-4 hover:underline", 17 | }, 18 | size: { 19 | default: "h-10 px-4 py-2", 20 | sm: "h-9 rounded-md px-3", 21 | lg: "h-11 rounded-md px-8", 22 | icon: "h-10 w-10", 23 | }, 24 | }, 25 | defaultVariants: { 26 | variant: "default", 27 | size: "default", 28 | }, 29 | }, 30 | ) 31 | 32 | export interface ButtonProps 33 | extends React.ButtonHTMLAttributes, 34 | VariantProps { 35 | asChild?: boolean 36 | } 37 | 38 | const Button = React.forwardRef( 39 | ({ className, variant, size, asChild = false, ...props }, ref) => { 40 | const Comp = asChild ? Slot : "button" 41 | return ( 42 | 43 | ) 44 | }, 45 | ) 46 | Button.displayName = "Button" 47 | 48 | export { Button, buttonVariants } 49 | -------------------------------------------------------------------------------- /components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | import * as React from "react" 3 | 4 | const Card = React.forwardRef>( 5 | ({ className, ...props }, ref) => ( 6 |
14 | ), 15 | ) 16 | Card.displayName = "Card" 17 | 18 | const CardHeader = React.forwardRef>( 19 | ({ className, ...props }, ref) => ( 20 |
21 | ), 22 | ) 23 | CardHeader.displayName = "CardHeader" 24 | 25 | const CardTitle = React.forwardRef>( 26 | ({ className, ...props }, ref) => ( 27 |

32 | ), 33 | ) 34 | CardTitle.displayName = "CardTitle" 35 | 36 | const CardDescription = React.forwardRef< 37 | HTMLParagraphElement, 38 | React.HTMLAttributes 39 | >(({ className, ...props }, ref) => ( 40 |

41 | )) 42 | CardDescription.displayName = "CardDescription" 43 | 44 | const CardContent = React.forwardRef>( 45 | ({ className, ...props }, ref) => ( 46 |

47 | ), 48 | ) 49 | CardContent.displayName = "CardContent" 50 | 51 | const CardFooter = React.forwardRef>( 52 | ({ className, ...props }, ref) => ( 53 |
54 | ), 55 | ) 56 | CardFooter.displayName = "CardFooter" 57 | 58 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 59 | -------------------------------------------------------------------------------- /components/ui/command.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { type DialogProps, DialogTitle } from "@radix-ui/react-dialog" 4 | import { Command as CommandPrimitive } from "cmdk" 5 | import { Search } from "lucide-react" 6 | import * as React from "react" 7 | 8 | import { Dialog, DialogContent } from "@/components/ui/dialog" 9 | import { cn } from "@/lib/utils" 10 | 11 | const Command = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, ...props }, ref) => ( 15 | 23 | )) 24 | Command.displayName = CommandPrimitive.displayName 25 | 26 | const CommandDialog = ({ children, ...props }: DialogProps) => { 27 | return ( 28 | 29 | 30 | 31 | 32 | {children} 33 | 34 | 35 | 36 | ) 37 | } 38 | 39 | const CommandInput = React.forwardRef< 40 | React.ElementRef, 41 | React.ComponentPropsWithoutRef 42 | >(({ className, ...props }, ref) => ( 43 |
44 | 45 | 53 |
54 | )) 55 | 56 | CommandInput.displayName = CommandPrimitive.Input.displayName 57 | 58 | const CommandList = React.forwardRef< 59 | React.ElementRef, 60 | React.ComponentPropsWithoutRef 61 | >(({ className, ...props }, ref) => ( 62 | 67 | )) 68 | 69 | CommandList.displayName = CommandPrimitive.List.displayName 70 | 71 | const CommandEmpty = React.forwardRef< 72 | React.ElementRef, 73 | React.ComponentPropsWithoutRef 74 | >((props, ref) => ( 75 | 76 | )) 77 | 78 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName 79 | 80 | const CommandGroup = React.forwardRef< 81 | React.ElementRef, 82 | React.ComponentPropsWithoutRef 83 | >(({ className, ...props }, ref) => ( 84 | 92 | )) 93 | 94 | CommandGroup.displayName = CommandPrimitive.Group.displayName 95 | 96 | const CommandSeparator = React.forwardRef< 97 | React.ElementRef, 98 | React.ComponentPropsWithoutRef 99 | >(({ className, ...props }, ref) => ( 100 | 105 | )) 106 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName 107 | 108 | const CommandItem = React.forwardRef< 109 | React.ElementRef, 110 | React.ComponentPropsWithoutRef 111 | >(({ className, ...props }, ref) => ( 112 | 120 | )) 121 | 122 | CommandItem.displayName = CommandPrimitive.Item.displayName 123 | 124 | const CommandShortcut = ({ className, ...props }: React.HTMLAttributes) => { 125 | return ( 126 | 130 | ) 131 | } 132 | CommandShortcut.displayName = "CommandShortcut" 133 | 134 | export { 135 | Command, 136 | CommandDialog, 137 | CommandInput, 138 | CommandList, 139 | CommandEmpty, 140 | CommandGroup, 141 | CommandItem, 142 | CommandShortcut, 143 | CommandSeparator, 144 | } 145 | -------------------------------------------------------------------------------- /components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as DialogPrimitive from "@radix-ui/react-dialog" 4 | import { X } from "lucide-react" 5 | import * as React from "react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = DialogPrimitive.Portal 14 | 15 | const DialogClose = DialogPrimitive.Close 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )) 54 | DialogContent.displayName = DialogPrimitive.Content.displayName 55 | 56 | const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( 57 |
58 | ) 59 | DialogHeader.displayName = "DialogHeader" 60 | 61 | const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( 62 |
66 | ) 67 | DialogFooter.displayName = "DialogFooter" 68 | 69 | const DialogTitle = React.forwardRef< 70 | React.ElementRef, 71 | React.ComponentPropsWithoutRef 72 | >(({ className, ...props }, ref) => ( 73 | 78 | )) 79 | DialogTitle.displayName = DialogPrimitive.Title.displayName 80 | 81 | const DialogDescription = React.forwardRef< 82 | React.ElementRef, 83 | React.ComponentPropsWithoutRef 84 | >(({ className, ...props }, ref) => ( 85 | 90 | )) 91 | DialogDescription.displayName = DialogPrimitive.Description.displayName 92 | 93 | export { 94 | Dialog, 95 | DialogPortal, 96 | DialogOverlay, 97 | DialogClose, 98 | DialogTrigger, 99 | DialogContent, 100 | DialogHeader, 101 | DialogFooter, 102 | DialogTitle, 103 | DialogDescription, 104 | } 105 | -------------------------------------------------------------------------------- /components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { cn } from "@/lib/utils" 4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 5 | import { Check, ChevronRight, Circle } from "lucide-react" 6 | import * as React from "react" 7 | 8 | const DropdownMenu = DropdownMenuPrimitive.Root 9 | 10 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 11 | 12 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 13 | 14 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 15 | 16 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 17 | 18 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 19 | 20 | const DropdownMenuSubTrigger = React.forwardRef< 21 | React.ElementRef, 22 | React.ComponentPropsWithoutRef & { 23 | inset?: boolean 24 | } 25 | >(({ className, inset, children, ...props }, ref) => ( 26 | 35 | {children} 36 | 37 | 38 | )) 39 | DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName 40 | 41 | const DropdownMenuSubContent = React.forwardRef< 42 | React.ElementRef, 43 | React.ComponentPropsWithoutRef 44 | >(({ className, ...props }, ref) => ( 45 | 53 | )) 54 | DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName 55 | 56 | const DropdownMenuContent = React.forwardRef< 57 | React.ElementRef, 58 | React.ComponentPropsWithoutRef 59 | >(({ className, sideOffset = 4, ...props }, ref) => ( 60 | 61 | 70 | 71 | )) 72 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 73 | 74 | const DropdownMenuItem = React.forwardRef< 75 | React.ElementRef, 76 | React.ComponentPropsWithoutRef & { 77 | inset?: boolean 78 | } 79 | >(({ className, inset, ...props }, ref) => ( 80 | 89 | )) 90 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 91 | 92 | const DropdownMenuCheckboxItem = React.forwardRef< 93 | React.ElementRef, 94 | React.ComponentPropsWithoutRef 95 | >(({ className, children, checked, ...props }, ref) => ( 96 | 105 | 106 | 107 | 108 | 109 | 110 | {children} 111 | 112 | )) 113 | DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName 114 | 115 | const DropdownMenuRadioItem = React.forwardRef< 116 | React.ElementRef, 117 | React.ComponentPropsWithoutRef 118 | >(({ className, children, ...props }, ref) => ( 119 | 127 | 128 | 129 | 130 | 131 | 132 | {children} 133 | 134 | )) 135 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 136 | 137 | const DropdownMenuLabel = React.forwardRef< 138 | React.ElementRef, 139 | React.ComponentPropsWithoutRef & { 140 | inset?: boolean 141 | } 142 | >(({ className, inset, ...props }, ref) => ( 143 | 148 | )) 149 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 150 | 151 | const DropdownMenuSeparator = React.forwardRef< 152 | React.ElementRef, 153 | React.ComponentPropsWithoutRef 154 | >(({ className, ...props }, ref) => ( 155 | 160 | )) 161 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 162 | 163 | const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => { 164 | return 165 | } 166 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" 167 | 168 | export { 169 | DropdownMenu, 170 | DropdownMenuCheckboxItem, 171 | DropdownMenuContent, 172 | DropdownMenuGroup, 173 | DropdownMenuItem, 174 | DropdownMenuLabel, 175 | DropdownMenuPortal, 176 | DropdownMenuRadioGroup, 177 | DropdownMenuRadioItem, 178 | DropdownMenuSeparator, 179 | DropdownMenuShortcut, 180 | DropdownMenuSub, 181 | DropdownMenuSubContent, 182 | DropdownMenuSubTrigger, 183 | DropdownMenuTrigger, 184 | } 185 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | import * as React from "react" 3 | 4 | export type InputProps = React.InputHTMLAttributes 5 | 6 | const Input = React.forwardRef( 7 | ({ className, type, ...props }, ref) => { 8 | return ( 9 | 18 | ) 19 | }, 20 | ) 21 | Input.displayName = "Input" 22 | 23 | export { Input } 24 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { cn } from "@/lib/utils" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { type VariantProps, cva } from "class-variance-authority" 6 | import * as React from "react" 7 | 8 | const labelVariants = cva( 9 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", 10 | ) 11 | 12 | const Label = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef & VariantProps 15 | >(({ className, ...props }, ref) => ( 16 | 17 | )) 18 | Label.displayName = LabelPrimitive.Root.displayName 19 | 20 | export { Label } 21 | -------------------------------------------------------------------------------- /components/ui/navigation-menu.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu" 3 | import { cva } from "class-variance-authority" 4 | import { ChevronDown } from "lucide-react" 5 | import * as React from "react" 6 | 7 | const NavigationMenu = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, children, ...props }, ref) => ( 11 | 16 | {children} 17 | 18 | 19 | )) 20 | NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName 21 | 22 | const NavigationMenuList = React.forwardRef< 23 | React.ElementRef, 24 | React.ComponentPropsWithoutRef 25 | >(({ className, ...props }, ref) => ( 26 | 31 | )) 32 | NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName 33 | 34 | const NavigationMenuItem = NavigationMenuPrimitive.Item 35 | 36 | const navigationMenuTriggerStyle = cva( 37 | "group inline-flex h-10 w-max items-center justify-center rounded-md bg-transparent px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-hidden disabled:pointer-events-none disabled:opacity-50 data-active:bg-accent/50 data-[state=open]:bg-accent/50", 38 | ) 39 | 40 | const NavigationMenuTrigger = React.forwardRef< 41 | React.ElementRef, 42 | React.ComponentPropsWithoutRef 43 | >(({ className, children, ...props }, ref) => ( 44 | 49 | {children}{" "} 50 | 55 | )) 56 | NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName 57 | 58 | const NavigationMenuContent = React.forwardRef< 59 | React.ElementRef, 60 | React.ComponentPropsWithoutRef 61 | >(({ className, ...props }, ref) => ( 62 | 70 | )) 71 | NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName 72 | 73 | const NavigationMenuLink = NavigationMenuPrimitive.Link 74 | 75 | const NavigationMenuViewport = React.forwardRef< 76 | React.ElementRef, 77 | React.ComponentPropsWithoutRef 78 | >(({ className, ...props }, ref) => ( 79 |
80 | 88 |
89 | )) 90 | NavigationMenuViewport.displayName = NavigationMenuPrimitive.Viewport.displayName 91 | 92 | const NavigationMenuIndicator = React.forwardRef< 93 | React.ElementRef, 94 | React.ComponentPropsWithoutRef 95 | >(({ className, ...props }, ref) => ( 96 | 104 |
105 | 106 | )) 107 | NavigationMenuIndicator.displayName = NavigationMenuPrimitive.Indicator.displayName 108 | 109 | export { 110 | NavigationMenu, 111 | NavigationMenuContent, 112 | NavigationMenuIndicator, 113 | NavigationMenuItem, 114 | NavigationMenuLink, 115 | NavigationMenuList, 116 | NavigationMenuTrigger, 117 | navigationMenuTriggerStyle, 118 | NavigationMenuViewport, 119 | } 120 | -------------------------------------------------------------------------------- /components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { cn } from "@/lib/utils" 4 | import * as PopoverPrimitive from "@radix-ui/react-popover" 5 | import * as React from "react" 6 | 7 | const Popover = PopoverPrimitive.Root 8 | 9 | const PopoverTrigger = PopoverPrimitive.Trigger 10 | 11 | const PopoverContent = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 15 | 16 | 26 | 27 | )) 28 | PopoverContent.displayName = PopoverPrimitive.Content.displayName 29 | 30 | export { Popover, PopoverTrigger, PopoverContent } 31 | -------------------------------------------------------------------------------- /components/ui/progress.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { cn } from "@/lib/utils" 4 | import * as ProgressPrimitive from "@radix-ui/react-progress" 5 | import * as React from "react" 6 | 7 | const Progress = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef & { 10 | indicatorClassName?: string // 添加一个新的可选属性来自定义Indicator的类名 11 | } 12 | >(({ className, value, indicatorClassName, ...props }, ref) => ( 13 | 18 | 25 | 26 | )) 27 | Progress.displayName = ProgressPrimitive.Root.displayName 28 | 29 | export { Progress } 30 | -------------------------------------------------------------------------------- /components/ui/radio-group.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { cn } from "@/lib/utils" 4 | import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" 5 | import type * as React from "react" 6 | 7 | function RadioGroup({ 8 | className, 9 | ...props 10 | }: React.ComponentProps) { 11 | return ( 12 | 17 | ) 18 | } 19 | 20 | function RadioGroupItem({ 21 | className, 22 | ...props 23 | }: React.ComponentProps) { 24 | return ( 25 | 33 | 34 | 43 | 44 | 45 | 46 | 47 | ) 48 | } 49 | 50 | export { RadioGroup, RadioGroupItem } 51 | -------------------------------------------------------------------------------- /components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { cn } from "@/lib/utils" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | import * as React from "react" 6 | 7 | const Separator = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => ( 11 | 22 | )) 23 | Separator.displayName = SeparatorPrimitive.Root.displayName 24 | 25 | export { Separator } 26 | -------------------------------------------------------------------------------- /components/ui/sheet.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { cn } from "@/lib/utils" 4 | import * as SheetPrimitive from "@radix-ui/react-dialog" 5 | import { type VariantProps, cva } from "class-variance-authority" 6 | import { X } from "lucide-react" 7 | import * as React from "react" 8 | 9 | const Sheet = SheetPrimitive.Root 10 | 11 | const SheetTrigger = SheetPrimitive.Trigger 12 | 13 | const SheetClose = SheetPrimitive.Close 14 | 15 | const SheetPortal = SheetPrimitive.Portal 16 | 17 | const SheetOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName 31 | 32 | const sheetVariants = cva( 33 | "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", 34 | { 35 | variants: { 36 | side: { 37 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", 38 | bottom: 39 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", 40 | left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", 41 | right: 42 | "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", 43 | }, 44 | }, 45 | defaultVariants: { 46 | side: "right", 47 | }, 48 | }, 49 | ) 50 | 51 | interface SheetContentProps 52 | extends React.ComponentPropsWithoutRef, 53 | VariantProps {} 54 | 55 | const SheetContent = React.forwardRef< 56 | React.ElementRef, 57 | SheetContentProps 58 | >(({ side = "right", className, children, ...props }, ref) => ( 59 | 60 | 61 | 62 | {children} 63 | 64 | 65 | Close 66 | 67 | 68 | 69 | )) 70 | SheetContent.displayName = SheetPrimitive.Content.displayName 71 | 72 | const SheetHeader = ({ className, ...props }: React.HTMLAttributes) => ( 73 |
74 | ) 75 | SheetHeader.displayName = "SheetHeader" 76 | 77 | const SheetFooter = ({ className, ...props }: React.HTMLAttributes) => ( 78 |
82 | ) 83 | SheetFooter.displayName = "SheetFooter" 84 | 85 | const SheetTitle = React.forwardRef< 86 | React.ElementRef, 87 | React.ComponentPropsWithoutRef 88 | >(({ className, ...props }, ref) => ( 89 | 94 | )) 95 | SheetTitle.displayName = SheetPrimitive.Title.displayName 96 | 97 | const SheetDescription = React.forwardRef< 98 | React.ElementRef, 99 | React.ComponentPropsWithoutRef 100 | >(({ className, ...props }, ref) => ( 101 | 106 | )) 107 | SheetDescription.displayName = SheetPrimitive.Description.displayName 108 | 109 | export { 110 | Sheet, 111 | SheetClose, 112 | SheetContent, 113 | SheetDescription, 114 | SheetFooter, 115 | SheetHeader, 116 | SheetOverlay, 117 | SheetPortal, 118 | SheetTitle, 119 | SheetTrigger, 120 | } 121 | -------------------------------------------------------------------------------- /components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ className, ...props }: React.HTMLAttributes) { 4 | return
5 | } 6 | 7 | export { Skeleton } 8 | -------------------------------------------------------------------------------- /components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { cn } from "@/lib/utils" 4 | import * as SwitchPrimitives from "@radix-ui/react-switch" 5 | import * as React from "react" 6 | 7 | const Switch = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, ...props }, ref) => ( 11 | 19 | 24 | 25 | )) 26 | Switch.displayName = SwitchPrimitives.Root.displayName 27 | 28 | export { Switch } 29 | -------------------------------------------------------------------------------- /components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { cn } from "@/lib/utils" 4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 5 | import * as React from "react" 6 | 7 | const TooltipProvider = TooltipPrimitive.Provider 8 | 9 | const Tooltip = TooltipPrimitive.Root 10 | 11 | const TooltipTrigger = TooltipPrimitive.Trigger 12 | 13 | const TooltipContent = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef 16 | >(({ className, sideOffset = 4, ...props }, ref) => ( 17 | 26 | )) 27 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 28 | 29 | export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } 30 | -------------------------------------------------------------------------------- /docker/.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 -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | nezha-dash: 4 | container_name: nezha-dash 5 | image: hamster1963/nezha-dash:latest 6 | volumes: 7 | - ./.env:/app/.env 8 | restart: always 9 | ports: 10 | - "4123:3000" 11 | -------------------------------------------------------------------------------- /docker/docker-compose.yml.cn: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | nezha-dash: 4 | container_name: nezha-dash 5 | image: registry.cn-guangzhou.aliyuncs.com/hamster-home/nezha-dash:latest 6 | volumes: 7 | - ./.env:/app/.env 8 | restart: always 9 | ports: 10 | - "4123:3000" 11 | -------------------------------------------------------------------------------- /i18n-metadata.ts: -------------------------------------------------------------------------------- 1 | // @auto-i18n-check. Please do not delete the line. 2 | import getEnv from "./lib/env-entry" 3 | 4 | export const localeItems = [ 5 | { code: "en", name: "English" }, 6 | { code: "ja", name: "日本語" }, 7 | { code: "zh-TW", name: "中文繁體" }, 8 | { code: "zh", name: "中文简体" }, 9 | ] 10 | 11 | export const locales = localeItems.map((item) => item.code) 12 | export const defaultLocale = getEnv("DefaultLocale") || "en" 13 | -------------------------------------------------------------------------------- /i18n/locale.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import getEnv from "@/lib/env-entry" 4 | import { cookies } from "next/headers" 5 | 6 | const COOKIE_NAME = "NEXT_LOCALE" 7 | 8 | export async function getUserLocale() { 9 | return (await cookies()).get(COOKIE_NAME)?.value || (getEnv("DefaultLocale") ?? "en") 10 | } 11 | 12 | export async function setUserLocale(locale: string) { 13 | ;(await cookies()).set(COOKIE_NAME, locale) 14 | } 15 | -------------------------------------------------------------------------------- /i18n/request.ts: -------------------------------------------------------------------------------- 1 | import { getUserLocale } from "@/i18n/locale" 2 | import { getRequestConfig } from "next-intl/server" 3 | 4 | export default getRequestConfig(async () => { 5 | const locale = await getUserLocale() 6 | 7 | return { 8 | locale, 9 | messages: (await import(`../messages/${locale}.json`)).default, 10 | } 11 | }) 12 | -------------------------------------------------------------------------------- /lib/env-entry.ts: -------------------------------------------------------------------------------- 1 | import { getClientEnv, getServerEnv } from "./env" 2 | import type { EnvKey } from "./env" 3 | 4 | export default function getEnv(key: EnvKey): string | undefined { 5 | if (key.startsWith("NEXT_PUBLIC_")) { 6 | const clientKey = key.replace("NEXT_PUBLIC_", "") as any 7 | return getClientEnv(clientKey) 8 | } 9 | return getServerEnv(key as any) 10 | } 11 | -------------------------------------------------------------------------------- /lib/env.ts: -------------------------------------------------------------------------------- 1 | import { env } from "next-runtime-env" 2 | 3 | /** 4 | * Server-side environment variables 5 | */ 6 | export interface ServerEnvConfig { 7 | /** Nezha API base URL */ 8 | NezhaBaseUrl: string 9 | /** Nezha API authentication token */ 10 | NezhaAuth: string 11 | /** Default locale for the application */ 12 | DefaultLocale: string 13 | /** Force show all servers */ 14 | ForceShowAllServers: boolean 15 | /** Site password */ 16 | SitePassword: string 17 | } 18 | 19 | /** 20 | * Client-side environment variables (NEXT_PUBLIC_*) 21 | */ 22 | export interface ClientEnvConfig { 23 | /** Nezha data fetch interval in milliseconds */ 24 | NezhaFetchInterval: number 25 | /** Show country flags */ 26 | ShowFlag: boolean 27 | /** Disable cartoon effects */ 28 | DisableCartoon: boolean 29 | /** Show server tags */ 30 | ShowTag: boolean 31 | /** Show network transfer information */ 32 | ShowNetTransfer: boolean 33 | /** Force use SVG flags */ 34 | ForceUseSvgFlag: boolean 35 | /** Fix server names at the top */ 36 | FixedTopServerName: boolean 37 | /** Custom logo URL */ 38 | CustomLogo: string 39 | /** Custom site title */ 40 | CustomTitle: string 41 | /** Custom site description */ 42 | CustomDescription: string 43 | /** Custom navigation links */ 44 | Links: string 45 | /** Disable search engine indexing */ 46 | DisableIndex: boolean 47 | /** Show tag count */ 48 | ShowTagCount: boolean 49 | /** Show IP information */ 50 | ShowIpInfo: boolean 51 | } 52 | 53 | /** 54 | * 环境变量键的类型定义 55 | */ 56 | export type EnvKey = ServerEnvKey | ClientEnvKey 57 | 58 | /** 59 | * 服务器端环境变量键 60 | */ 61 | export type ServerEnvKey = keyof ServerEnvConfig 62 | 63 | /** 64 | * 客户端环境变量键 65 | */ 66 | export type ClientEnvKey = `NEXT_PUBLIC_${keyof ClientEnvConfig}` 67 | 68 | /** 69 | * Get a server-side environment variable 70 | * @param key - Environment variable key 71 | * @returns Environment variable value 72 | */ 73 | export function getServerEnv(key: K): string | undefined { 74 | const value = process.env[key] 75 | if (!value) { 76 | return undefined 77 | } 78 | return value 79 | } 80 | 81 | /** 82 | * Get a client-side environment variable 83 | * @param key - Environment variable key 84 | * @returns Environment variable value 85 | */ 86 | export function getClientEnv(key: K): string | undefined { 87 | const envKey = `NEXT_PUBLIC_${key}` 88 | const value = env(envKey) 89 | if (!value) { 90 | return undefined 91 | } 92 | return value 93 | } 94 | 95 | /** 96 | * Parse boolean environment variable 97 | * @param value - Environment variable value 98 | * @returns Parsed boolean value 99 | */ 100 | export function parseBoolean(value: string | undefined): boolean { 101 | return value?.toLowerCase() === "true" 102 | } 103 | 104 | /** 105 | * Parse number environment variable 106 | * @param value - Environment variable value 107 | * @param defaultValue - Default value if parsing fails 108 | * @returns Parsed number value 109 | */ 110 | export function parseNumber(value: string | undefined, defaultValue: number): number { 111 | if (!value) return defaultValue 112 | const parsed = Number.parseInt(value, 10) 113 | return Number.isNaN(parsed) ? defaultValue : parsed 114 | } 115 | 116 | /** 117 | * Get all environment variables with their current values 118 | */ 119 | export function getAllEnvConfig(): { server: ServerEnvConfig; client: ClientEnvConfig } { 120 | return { 121 | server: { 122 | NezhaBaseUrl: getServerEnv("NezhaBaseUrl") || "", 123 | NezhaAuth: getServerEnv("NezhaAuth") || "", 124 | DefaultLocale: getServerEnv("DefaultLocale") || "", 125 | ForceShowAllServers: parseBoolean(getServerEnv("ForceShowAllServers")), 126 | SitePassword: getServerEnv("SitePassword") || "", 127 | }, 128 | client: { 129 | NezhaFetchInterval: parseNumber(getClientEnv("NezhaFetchInterval"), 5000), 130 | ShowFlag: parseBoolean(getClientEnv("ShowFlag")), 131 | DisableCartoon: parseBoolean(getClientEnv("DisableCartoon")), 132 | ShowTag: parseBoolean(getClientEnv("ShowTag")), 133 | ShowNetTransfer: parseBoolean(getClientEnv("ShowNetTransfer")), 134 | ForceUseSvgFlag: parseBoolean(getClientEnv("ForceUseSvgFlag")), 135 | FixedTopServerName: parseBoolean(getClientEnv("FixedTopServerName")), 136 | CustomLogo: getClientEnv("CustomLogo") || "", 137 | CustomTitle: getClientEnv("CustomTitle") || "", 138 | CustomDescription: getClientEnv("CustomDescription") || "", 139 | Links: getClientEnv("Links") || "", 140 | DisableIndex: parseBoolean(getClientEnv("DisableIndex")), 141 | ShowTagCount: parseBoolean(getClientEnv("ShowTagCount")), 142 | ShowIpInfo: parseBoolean(getClientEnv("ShowIpInfo")), 143 | }, 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /lib/logo-class.tsx: -------------------------------------------------------------------------------- 1 | import type { SVGProps } from "react" 2 | 3 | export function GetFontLogoClass(platform: string): string { 4 | if ( 5 | [ 6 | "almalinux", 7 | "alpine", 8 | "aosc", 9 | "apple", 10 | "archlinux", 11 | "archlabs", 12 | "artix", 13 | "budgie", 14 | "centos", 15 | "coreos", 16 | "debian", 17 | "deepin", 18 | "devuan", 19 | "docker", 20 | "elementary", 21 | "fedora", 22 | "ferris", 23 | "flathub", 24 | "freebsd", 25 | "gentoo", 26 | "gnu-guix", 27 | "illumos", 28 | "kali-linux", 29 | "linuxmint", 30 | "mageia", 31 | "mandriva", 32 | "manjaro", 33 | "nixos", 34 | "openbsd", 35 | "opensuse", 36 | "pop-os", 37 | "raspberry-pi", 38 | "redhat", 39 | "rocky-linux", 40 | "sabayon", 41 | "slackware", 42 | "snappy", 43 | "solus", 44 | "tux", 45 | "ubuntu", 46 | "void", 47 | "zorin", 48 | ].indexOf(platform) > -1 49 | ) { 50 | return platform 51 | } 52 | if (platform === "darwin") { 53 | return "apple" 54 | } 55 | if (["openwrt", "linux", "immortalwrt"].indexOf(platform) > -1) { 56 | return "tux" 57 | } 58 | if (platform === "amazon") { 59 | return "redhat" 60 | } 61 | if (platform === "arch") { 62 | return "archlinux" 63 | } 64 | if (platform.toLowerCase().includes("opensuse")) { 65 | return "opensuse" 66 | } 67 | return "tux" 68 | } 69 | 70 | export function GetOsName(platform: string): string { 71 | if ( 72 | [ 73 | "almalinux", 74 | "alpine", 75 | "aosc", 76 | "apple", 77 | "archlinux", 78 | "archlabs", 79 | "artix", 80 | "budgie", 81 | "centos", 82 | "coreos", 83 | "debian", 84 | "deepin", 85 | "devuan", 86 | "docker", 87 | "fedora", 88 | "ferris", 89 | "flathub", 90 | "freebsd", 91 | "gentoo", 92 | "gnu-guix", 93 | "illumos", 94 | "linuxmint", 95 | "mageia", 96 | "mandriva", 97 | "manjaro", 98 | "nixos", 99 | "openbsd", 100 | "opensuse", 101 | "pop-os", 102 | "redhat", 103 | "sabayon", 104 | "slackware", 105 | "snappy", 106 | "solus", 107 | "tux", 108 | "ubuntu", 109 | "void", 110 | "zorin", 111 | ].indexOf(platform) > -1 112 | ) { 113 | return platform.charAt(0).toUpperCase() + platform.slice(1) 114 | } 115 | if (platform === "darwin") { 116 | return "macOS" 117 | } 118 | if (["openwrt", "linux", "immortalwrt"].indexOf(platform) > -1) { 119 | return "Linux" 120 | } 121 | if (platform === "amazon") { 122 | return "Redhat" 123 | } 124 | if (platform === "arch") { 125 | return "Archlinux" 126 | } 127 | if (platform.toLowerCase().includes("opensuse")) { 128 | return "Opensuse" 129 | } 130 | return "Linux" 131 | } 132 | 133 | export function MageMicrosoftWindows(props: SVGProps) { 134 | return ( 135 | 136 | Mage Microsoft Windows 137 | 141 | 142 | ) 143 | } 144 | -------------------------------------------------------------------------------- /lib/maxmind-db/GeoLite2-ASN.mmdb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hamster1963/nezha-dash/8cfc36872a3e429957d6e38d56b870d588ae2361/lib/maxmind-db/GeoLite2-ASN.mmdb -------------------------------------------------------------------------------- /lib/maxmind-db/GeoLite2-City.mmdb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hamster1963/nezha-dash/8cfc36872a3e429957d6e38d56b870d588ae2361/lib/maxmind-db/GeoLite2-City.mmdb -------------------------------------------------------------------------------- /lib/serverFetch.tsx: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import type { NezhaAPI, ServerApi } from "@/app/types/nezha-api" 4 | import type { MakeOptional } from "@/app/types/utils" 5 | import getEnv from "@/lib/env-entry" 6 | import { connection } from "next/server" 7 | 8 | export async function GetNezhaData() { 9 | await connection() 10 | 11 | let nezhaBaseUrl = getEnv("NezhaBaseUrl") 12 | if (!nezhaBaseUrl) { 13 | console.error("NezhaBaseUrl is not set") 14 | throw new Error("NezhaBaseUrl is not set") 15 | } 16 | 17 | // Remove trailing slash 18 | nezhaBaseUrl = nezhaBaseUrl.replace(/\/$/, "") 19 | 20 | try { 21 | const response = await fetch(`${nezhaBaseUrl}/api/v1/server/details`, { 22 | headers: { 23 | Authorization: getEnv("NezhaAuth") as string, 24 | }, 25 | next: { 26 | revalidate: 0, 27 | }, 28 | }) 29 | 30 | if (!response.ok) { 31 | const errorText = await response.text() 32 | throw new Error(`Failed to fetch data: ${response.status} ${errorText}`) 33 | } 34 | 35 | const resData = await response.json() 36 | 37 | if (!resData.result) { 38 | throw new Error("NezhaData fetch failed: 'result' field is missing") 39 | } 40 | 41 | const nezhaData = resData.result as NezhaAPI[] 42 | const data: ServerApi = { 43 | live_servers: 0, 44 | offline_servers: 0, 45 | total_out_bandwidth: 0, 46 | total_in_bandwidth: 0, 47 | total_in_speed: 0, 48 | total_out_speed: 0, 49 | result: [], 50 | } 51 | 52 | const forceShowAllServers = getEnv("ForceShowAllServers") === "true" 53 | const nezhaDataFiltered = forceShowAllServers 54 | ? nezhaData 55 | : nezhaData.filter((element) => !element.hide_for_guest) 56 | 57 | const timestamp = Date.now() / 1000 58 | data.result = nezhaDataFiltered.map( 59 | (element: MakeOptional) => { 60 | const isOnline = timestamp - element.last_active <= 180 61 | element.online_status = isOnline 62 | 63 | if (isOnline) { 64 | data.live_servers += 1 65 | data.total_out_bandwidth += element.status.NetOutTransfer 66 | data.total_in_bandwidth += element.status.NetInTransfer 67 | data.total_in_speed += element.status.NetInSpeed 68 | data.total_out_speed += element.status.NetOutSpeed 69 | } else { 70 | data.offline_servers += 1 71 | } 72 | 73 | // Remove unwanted properties 74 | element.ipv4 = undefined 75 | element.ipv6 = undefined 76 | element.valid_ip = undefined 77 | 78 | return element 79 | }, 80 | ) 81 | 82 | return data 83 | } catch (error) { 84 | console.error("GetNezhaData error:", error) 85 | throw error // Rethrow the error to be caught by the caller 86 | } 87 | } 88 | 89 | export async function GetServerMonitor({ server_id }: { server_id: number }) { 90 | await connection() 91 | 92 | let nezhaBaseUrl = getEnv("NezhaBaseUrl") 93 | if (!nezhaBaseUrl) { 94 | console.error("NezhaBaseUrl is not set") 95 | throw new Error("NezhaBaseUrl is not set") 96 | } 97 | 98 | // Remove trailing slash 99 | nezhaBaseUrl = nezhaBaseUrl.replace(/\/$/, "") 100 | 101 | try { 102 | const response = await fetch(`${nezhaBaseUrl}/api/v1/monitor/${server_id}`, { 103 | headers: { 104 | Authorization: getEnv("NezhaAuth") as string, 105 | }, 106 | next: { 107 | revalidate: 0, 108 | }, 109 | }) 110 | 111 | if (!response.ok) { 112 | const errorText = await response.text() 113 | throw new Error(`Failed to fetch data: ${response.status} ${errorText}`) 114 | } 115 | 116 | const resData = await response.json() 117 | const monitorData = resData.result 118 | 119 | if (!monitorData) { 120 | console.error("MonitorData fetch failed:", resData) 121 | throw new Error("MonitorData fetch failed: 'result' field is missing") 122 | } 123 | 124 | return monitorData 125 | } catch (error) { 126 | console.error("GetServerMonitor error:", error) 127 | throw error 128 | } 129 | } 130 | 131 | export async function GetServerIP({ 132 | server_id, 133 | }: { 134 | server_id: number 135 | }): Promise { 136 | await connection() 137 | 138 | let nezhaBaseUrl = getEnv("NezhaBaseUrl") 139 | if (!nezhaBaseUrl) { 140 | console.error("NezhaBaseUrl is not set") 141 | throw new Error("NezhaBaseUrl is not set") 142 | } 143 | 144 | // Remove trailing slash 145 | nezhaBaseUrl = nezhaBaseUrl.replace(/\/$/, "") 146 | 147 | try { 148 | const response = await fetch(`${nezhaBaseUrl}/api/v1/server/details`, { 149 | headers: { 150 | Authorization: getEnv("NezhaAuth") as string, 151 | }, 152 | next: { 153 | revalidate: 0, 154 | }, 155 | }) 156 | 157 | if (!response.ok) { 158 | const errorText = await response.text() 159 | throw new Error(`Failed to fetch data: ${response.status} ${errorText}`) 160 | } 161 | 162 | const resData = await response.json() 163 | 164 | if (!resData.result) { 165 | throw new Error("NezhaData fetch failed: 'result' field is missing") 166 | } 167 | 168 | const nezhaData = resData.result as NezhaAPI[] 169 | 170 | // Find the server with the given ID 171 | const server = nezhaData.find((element) => element.id === server_id) 172 | 173 | if (!server) { 174 | throw new Error(`Server with ID ${server_id} not found`) 175 | } 176 | 177 | return server?.valid_ip || server?.ipv4 || server?.ipv6 || "" 178 | } catch (error) { 179 | console.error("GetNezhaData error:", error) 180 | throw error // Rethrow the error to be caught by the caller 181 | } 182 | } 183 | 184 | export async function GetServerDetail({ server_id }: { server_id: number }) { 185 | await connection() 186 | let nezhaBaseUrl = getEnv("NezhaBaseUrl") 187 | if (!nezhaBaseUrl) { 188 | console.error("NezhaBaseUrl is not set") 189 | throw new Error("NezhaBaseUrl is not set") 190 | } 191 | 192 | // Remove trailing slash 193 | nezhaBaseUrl = nezhaBaseUrl.replace(/\/$/, "") 194 | 195 | try { 196 | const response = await fetch(`${nezhaBaseUrl}/api/v1/server/details?id=${server_id}`, { 197 | headers: { 198 | Authorization: getEnv("NezhaAuth") as string, 199 | }, 200 | next: { 201 | revalidate: 0, 202 | }, 203 | }) 204 | 205 | if (!response.ok) { 206 | const errorText = await response.text() 207 | throw new Error(`Failed to fetch data: ${response.status} ${errorText}`) 208 | } 209 | 210 | const resData = await response.json() 211 | const detailDataList = resData.result 212 | 213 | if (!detailDataList || !Array.isArray(detailDataList) || detailDataList.length === 0) { 214 | console.error("MonitorData fetch failed:", resData) 215 | throw new Error("MonitorData fetch failed: 'result' field is missing or empty") 216 | } 217 | 218 | const timestamp = Date.now() / 1000 219 | const detailData = detailDataList.map((element) => { 220 | element.online_status = timestamp - element.last_active <= 180 221 | element.ipv4 = undefined 222 | element.ipv6 = undefined 223 | element.valid_ip = undefined 224 | return element 225 | })[0] 226 | 227 | return detailData 228 | } catch (error) { 229 | console.error("GetServerDetail error:", error) 230 | throw error // Rethrow the error to be handled by the caller 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import type { NezhaAPISafe } from "@/app/types/nezha-api" 2 | import { type ClassValue, clsx } from "clsx" 3 | import { twMerge } from "tailwind-merge" 4 | 5 | export function cn(...inputs: ClassValue[]) { 6 | return twMerge(clsx(inputs)) 7 | } 8 | 9 | export function formatNezhaInfo(serverInfo: NezhaAPISafe) { 10 | return { 11 | ...serverInfo, 12 | cpu: serverInfo.status.CPU, 13 | gpu: serverInfo.status.GPU || 0, 14 | process: serverInfo.status.ProcessCount || 0, 15 | up: serverInfo.status.NetOutSpeed / 1024 / 1024 || 0, 16 | down: serverInfo.status.NetInSpeed / 1024 / 1024 || 0, 17 | last_active_time_string: serverInfo.last_active 18 | ? new Date(serverInfo.last_active * 1000).toLocaleString() 19 | : "", 20 | boot_time: serverInfo.host.BootTime, 21 | boot_time_string: serverInfo.host.BootTime 22 | ? new Date(serverInfo.host.BootTime * 1000).toLocaleString() 23 | : "", 24 | online: serverInfo.online_status, 25 | uptime: serverInfo.status.Uptime || 0, 26 | version: serverInfo.host.Version || null, 27 | tcp: serverInfo.status.TcpConnCount || 0, 28 | udp: serverInfo.status.UdpConnCount || 0, 29 | arch: serverInfo.host.Arch || "", 30 | mem_total: serverInfo.host.MemTotal || 0, 31 | swap_total: serverInfo.host.SwapTotal || 0, 32 | disk_total: serverInfo.host.DiskTotal || 0, 33 | platform: serverInfo.host.Platform || "", 34 | platform_version: serverInfo.host.PlatformVersion || "", 35 | mem: (serverInfo.status.MemUsed / serverInfo.host.MemTotal) * 100 || 0, 36 | swap: (serverInfo.status.SwapUsed / serverInfo.host.SwapTotal) * 100 || 0, 37 | disk: (serverInfo.status.DiskUsed / serverInfo.host.DiskTotal) * 100 || 0, 38 | stg: (serverInfo.status.DiskUsed / serverInfo.host.DiskTotal) * 100 || 0, 39 | net_out_transfer: serverInfo.status.NetOutTransfer || 0, 40 | net_in_transfer: serverInfo.status.NetInTransfer || 0, 41 | country_code: serverInfo.host.CountryCode, 42 | cpu_info: serverInfo.host.CPU || [], 43 | gpu_info: serverInfo.host.GPU || [], 44 | load_1: serverInfo.status.Load1?.toFixed(2) || 0.0, 45 | load_5: serverInfo.status.Load5?.toFixed(2) || 0.0, 46 | load_15: serverInfo.status.Load15?.toFixed(2) || 0.0, 47 | } 48 | } 49 | 50 | export function formatBytes(bytes: number, decimals = 2) { 51 | if (!+bytes) return "0 Bytes" 52 | 53 | const k = 1024 54 | const dm = decimals < 0 ? 0 : decimals 55 | const sizes = ["Bytes", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"] 56 | 57 | const i = Math.floor(Math.log(bytes) / Math.log(k)) 58 | 59 | return `${Number.parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}` 60 | } 61 | 62 | export function getDaysBetweenDates(date1: string, date2: string): number { 63 | const oneDay = 24 * 60 * 60 * 1000 // 一天的毫秒数 64 | const firstDate = new Date(date1) 65 | const secondDate = new Date(date2) 66 | 67 | // 计算两个日期之间的天数差异 68 | return Math.round(Math.abs((firstDate.getTime() - secondDate.getTime()) / oneDay)) 69 | } 70 | 71 | export const fetcher = (url: string) => 72 | fetch(url) 73 | .then((res) => { 74 | if (!res.ok) { 75 | throw new Error(res.statusText) 76 | } 77 | return res.json() 78 | }) 79 | .then((data) => data.data) 80 | .catch((err) => { 81 | console.error(err) 82 | throw err 83 | }) 84 | 85 | export const nezhaFetcher = async (url: string) => { 86 | const res = await fetch(url) 87 | 88 | if (!res.ok) { 89 | const error = new Error("An error occurred while fetching the data.") 90 | // @ts-expect-error - res.json() returns a Promise 91 | error.info = await res.json() 92 | // @ts-expect-error - res.status is a number 93 | error.status = res.status 94 | throw error 95 | } 96 | 97 | return res.json() 98 | } 99 | 100 | export function formatRelativeTime(timestamp: number): string { 101 | const now = Date.now() 102 | const diff = now - timestamp 103 | const hours = Math.floor(diff / (1000 * 60 * 60)) 104 | const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)) 105 | const seconds = Math.floor((diff % (1000 * 60)) / 1000) 106 | 107 | if (hours > 24) { 108 | const days = Math.floor(hours / 24) 109 | return `${days}d` 110 | } 111 | if (hours > 0) { 112 | return `${hours}h` 113 | } 114 | if (minutes > 0) { 115 | return `${minutes}m` 116 | } 117 | if (seconds >= 0) { 118 | return `${seconds}s` 119 | } 120 | return "0s" 121 | } 122 | 123 | export function formatTime(timestamp: number): string { 124 | const date = new Date(timestamp) 125 | const year = date.getFullYear() 126 | const month = date.getMonth() + 1 127 | const day = date.getDate() 128 | const hours = date.getHours().toString().padStart(2, "0") 129 | const minutes = date.getMinutes().toString().padStart(2, "0") 130 | const seconds = date.getSeconds().toString().padStart(2, "0") 131 | return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}` 132 | } 133 | 134 | export function formatTime12(timestamp: number): string { 135 | // example: 3:45 PM 136 | const date = new Date(timestamp) 137 | const hours = date.getHours() 138 | const minutes = date.getMinutes() 139 | const ampm = hours >= 12 ? "PM" : "AM" 140 | const hours12 = hours % 12 || 12 141 | return `${hours12}:${minutes.toString().padStart(2, "0")} ${ampm}` 142 | } 143 | -------------------------------------------------------------------------------- /messages/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "ServerOverviewClient": { 3 | "p_816-881_Totalservers": "Total servers", 4 | "p_1610-1676_Onlineservers": "Online servers", 5 | "p_2532-2599_Offlineservers": "Offline servers", 6 | "p_3463-3530_Totalbandwidth": "Total bandwidth", 7 | "speed": "speed", 8 | "network": "Network", 9 | "error_message": "Please check your environment variables and review the server console", 10 | "no_data_message": "No data" 11 | }, 12 | "ServerListClient": { 13 | "error_message": "Please check your environment variables and review the server console", 14 | "defaultTag": "All", 15 | "connecting": "Connecting" 16 | }, 17 | "ServerCard": { 18 | "System": "System", 19 | "CPU": "CPU", 20 | "Mem": "Mem", 21 | "STG": "STG", 22 | "Upload": "Upload", 23 | "Download": "Download", 24 | "Offline": "Offline", 25 | "Uptime": "Uptime", 26 | "TotalUpload": "Upload", 27 | "TotalDownload": "Download", 28 | "Region": "Region" 29 | }, 30 | "TabSwitch": { 31 | "Detail": "Detail", 32 | "Network": "Network" 33 | }, 34 | "SignIn": { 35 | "SignInMessage": "Please enter the password", 36 | "Submit": "Login", 37 | "ErrorMessage": "Invalid password", 38 | "SuccessMessage": "Login successful" 39 | }, 40 | "ServerCardPopover": { 41 | "System": "System", 42 | "CPU": "CPU", 43 | "Mem": "Mem", 44 | "STG": "STG", 45 | "Swap": "Swap", 46 | "Network": "Network", 47 | "Load": "Load", 48 | "Online": "Online", 49 | "Offline": "Offline", 50 | "Uptime": "Uptime", 51 | "Upload": "Upload", 52 | "Download": "Download", 53 | "Region": "Region" 54 | }, 55 | "NetworkChartClient": { 56 | "avg_delay": "Latency", 57 | "chart_fetch_error_message": "Failed to fetch network data, please check if the server monitoring is enabled" 58 | }, 59 | "IPInfo": { 60 | "asn_number": "Origin ASN", 61 | "registered_country": "Registered Country", 62 | "time_zone": "Time Zone", 63 | "postal_code": "Postal Code", 64 | "city": "City", 65 | "longitude": "Longitude", 66 | "latitude": "Latitude" 67 | }, 68 | "NetworkChart": { 69 | "ServerMonitorCount": "Services" 70 | }, 71 | "ServerDetailClient": { 72 | "detail_fetch_error_message": "Please check your environment variables and review the server console", 73 | "status": "Status", 74 | "Online": "Online", 75 | "Offline": "Offline", 76 | "Uptime": "Uptime", 77 | "Days": "Days", 78 | "Hours": "Hours", 79 | "Version": "Version", 80 | "Arch": "Arch", 81 | "Mem": "Mem", 82 | "Disk": "Disk", 83 | "Region": "Region", 84 | "System": "System", 85 | "CPU": "CPU", 86 | "Upload": "Upload", 87 | "Download": "Download", 88 | "Load": "Load", 89 | "LastActive": "Last Active", 90 | "BootTime": "Boot Time" 91 | }, 92 | "ServerDetailChartClient": { 93 | "chart_fetch_error_message": "Please check your environment variables and review the server console", 94 | "Process": "Process", 95 | "Disk": "Disk", 96 | "Mem": "Mem", 97 | "Swap": "Swap", 98 | "Upload": "Upload", 99 | "Download": "Download" 100 | }, 101 | "ThemeSwitcher": { 102 | "Light": "Light", 103 | "Dark": "Dark", 104 | "System": "System" 105 | }, 106 | "Footer": { 107 | "p_146-598_Findthecodeon": "Find the code on", 108 | "a_303-585_GitHub": "GitHub", 109 | "section_607-869_2020": "© 2020-", 110 | "a_800-850_Hamster1963": "@Hamster1963" 111 | }, 112 | "Header": { 113 | "p_1079-1199_Simpleandbeautifuldashbo": "Simple and beautiful dashboard" 114 | }, 115 | "Overview": { 116 | "p_2277-2331_Overview": "Overview", 117 | "p_2390-2457_wherethetimeis": "where the time is" 118 | }, 119 | "Global": { 120 | "Loading": "Loading...", 121 | "Distributions": "Servers are distributed in", 122 | "Regions": "Regions", 123 | "Servers": "servers" 124 | }, 125 | "NotFoundPage": { 126 | "h1_490-590_404NotFound": "Server Not Found", 127 | "h1_490-590_404NotFoundBack": " Press here to go back", 128 | "h1_490-590_Error": "Something went wrong" 129 | }, 130 | "DashCommand": { 131 | "TypeCommand": "Type a command or search...", 132 | "NoResults": "No results found.", 133 | "Servers": "Servers", 134 | "Shortcuts": "Shortcuts", 135 | "ToggleLightMode": "Toggle Light Mode", 136 | "ToggleDarkMode": "Toggle Dark Mode", 137 | "ToggleSystemMode": "Toggle System Mode", 138 | "Home": "Home" 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /messages/ja.json: -------------------------------------------------------------------------------- 1 | { 2 | "ServerOverviewClient": { 3 | "p_816-881_Totalservers": "サーバーの総数", 4 | "p_1610-1676_Onlineservers": "オンラインサーバー", 5 | "p_2532-2599_Offlineservers": "オフラインサーバー", 6 | "p_3463-3530_Totalbandwidth": "総流量", 7 | "speed": "速度", 8 | "network": "ネットワーク", 9 | "error_message": "環境変数を確認し、サーバーコンソールを確認してください", 10 | "no_data_message": "データなし" 11 | }, 12 | "ServerListClient": { 13 | "error_message": "環境変数を確認し、サーバーコンソールを確認してください", 14 | "defaultTag": "すべて", 15 | "connecting": "接続中" 16 | }, 17 | "ServerCard": { 18 | "System": "システム", 19 | "CPU": "CPU", 20 | "Mem": "Mem", 21 | "STG": "STG", 22 | "Upload": "Upload", 23 | "Download": "Download", 24 | "Offline": "Offline", 25 | "Uptime": "Uptime", 26 | "TotalUpload": "Upload", 27 | "TotalDownload": "Download", 28 | "Region": "Region" 29 | }, 30 | "TabSwitch": { 31 | "Detail": "詳細", 32 | "Network": "ネットワーク" 33 | }, 34 | "SignIn": { 35 | "SignInMessage": "パスワードを入力してください", 36 | "Submit": "ログイン", 37 | "ErrorMessage": "パスワードが間違っています", 38 | "SuccessMessage": "ログイン成功" 39 | }, 40 | "ServerCardPopover": { 41 | "System": "システム", 42 | "CPU": "CPU", 43 | "Mem": "メモリ", 44 | "STG": "ストレージ", 45 | "Swap": "スワップ", 46 | "Network": "ネットワーク", 47 | "Load": "負荷", 48 | "Online": "オンライン時間", 49 | "Offline": "オフライン", 50 | "Uptime": "アップタイム", 51 | "Upload": "アップロード", 52 | "Download": "ダウンロード", 53 | "Region": "地域" 54 | }, 55 | "NetworkChartClient": { 56 | "avg_delay": "遅延", 57 | "chart_fetch_error_message": "ネットワークデータの取得に失敗しました。サーバーの監視が有効になっているかどうかを確認してください" 58 | }, 59 | "IPInfo": { 60 | "asn_number": "ASN", 61 | "registered_country": "Registered Country", 62 | "time_zone": "時刻", 63 | "postal_code": "郵便番号", 64 | "city": "都市", 65 | "longitude": "経度", 66 | "latitude": "緯度" 67 | }, 68 | "NetworkChart": { 69 | "ServerMonitorCount": "サービス" 70 | }, 71 | "ServerDetailClient": { 72 | "detail_fetch_error_message": "環境変数を確認し、サーバーコンソールを確認してください", 73 | "status": "ステータス", 74 | "Online": "オンライン", 75 | "Offline": "オフライン", 76 | "Uptime": "稼働時間", 77 | "Days": "日", 78 | "Hours": "時間", 79 | "Version": "バージョン", 80 | "Arch": "アーキテクチャ", 81 | "Mem": "メモリ", 82 | "Disk": "ディスク", 83 | "Region": "地域", 84 | "System": "システム", 85 | "CPU": "CPU", 86 | "Load": "負荷", 87 | "Upload": "Upload", 88 | "Download": "Download", 89 | "LastActive": "Last Active", 90 | "BootTime": "Boot Time" 91 | }, 92 | "ServerDetailChartClient": { 93 | "chart_fetch_error_message": "環境変数を確認し、サーバーコンソールを確認してください", 94 | "Process": "進捗状況", 95 | "Disk": "ディスク", 96 | "Mem": "メモリ", 97 | "Swap": "スワップ", 98 | "Upload": "アップロード", 99 | "Download": "ダウンロード" 100 | }, 101 | "ThemeSwitcher": { 102 | "Light": "ライト", 103 | "Dark": "ダーク", 104 | "System": "システム" 105 | }, 106 | "Footer": { 107 | "p_146-598_Findthecodeon": "コードのオープンソース", 108 | "a_303-585_GitHub": "GitHub", 109 | "section_607-869_2020": "© 2020〜", 110 | "a_800-850_Hamster1963": "@Hamster1963" 111 | }, 112 | "Header": { 113 | "p_1079-1199_Simpleandbeautifuldashbo": "シンプルで美しいダッシュボード" 114 | }, 115 | "Overview": { 116 | "p_2277-2331_Overview": "概要", 117 | "p_2390-2457_wherethetimeis": "現在の時間" 118 | }, 119 | "Global": { 120 | "Loading": "Loading...", 121 | "Distributions": "サーバーは", 122 | "Regions": "つの地域に分散されています", 123 | "Servers": "サーバー" 124 | }, 125 | "NotFoundPage": { 126 | "h1_490-590_404NotFound": "サーバーは見つかりません", 127 | "h1_490-590_404NotFoundBack": "戻る", 128 | "h1_490-590_Error": "何らかの問題が発生しました" 129 | }, 130 | "DashCommand": { 131 | "TypeCommand": "コマンドを入力してください", 132 | "NoResults": "結果は見つかりませんでした", 133 | "Servers": "サーバー", 134 | "Shortcuts": "ショートカット", 135 | "ToggleLightMode": "ライトモードに切り替え", 136 | "ToggleDarkMode": "ダークモードに切り替え", 137 | "ToggleSystemMode": "システムモードに切り替え", 138 | "Home": "ホーム" 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /messages/zh-TW.json: -------------------------------------------------------------------------------- 1 | { 2 | "ServerOverviewClient": { 3 | "p_816-881_Totalservers": "伺服器總數", 4 | "p_1610-1676_Onlineservers": "在線伺服器", 5 | "p_2532-2599_Offlineservers": "離線伺服器", 6 | "p_3463-3530_Totalbandwidth": "總流量", 7 | "speed": "速率", 8 | "network": "網路", 9 | "error_message": "請檢查您的環境變數並檢查伺服器控制台", 10 | "no_data_message": "無資料" 11 | }, 12 | "ServerListClient": { 13 | "error_message": "請檢查您的環境變數並檢查伺服器控制台", 14 | "defaultTag": "全部", 15 | "connecting": "連接中" 16 | }, 17 | "ServerCard": { 18 | "System": "系統", 19 | "CPU": "CPU", 20 | "Mem": "記憶體", 21 | "STG": "儲存", 22 | "Upload": "上傳", 23 | "Download": "下載", 24 | "Offline": "離線", 25 | "Uptime": "稼働時間", 26 | "TotalUpload": "总上傳", 27 | "TotalDownload": "总下載", 28 | "Region": "地區" 29 | }, 30 | "TabSwitch": { 31 | "Detail": "詳細", 32 | "Network": "網路" 33 | }, 34 | "SignIn": { 35 | "SignInMessage": "請輸入密碼", 36 | "Submit": "登入", 37 | "ErrorMessage": "密碼錯誤", 38 | "SuccessMessage": "登入成功" 39 | }, 40 | "ServerCardPopover": { 41 | "System": "系統", 42 | "CPU": "CPU", 43 | "Mem": "記憶體", 44 | "STG": "儲存", 45 | "Swap": "虛擬記憶體", 46 | "Network": "網路", 47 | "Load": "負載", 48 | "Online": "在線時間", 49 | "Offline": "離線", 50 | "Uptime": "稼働時間", 51 | "Upload": "上傳", 52 | "Download": "下載", 53 | "Region": "地域" 54 | }, 55 | "NetworkChartClient": { 56 | "avg_delay": "延遲", 57 | "chart_fetch_error_message": "獲取網絡數據失敗,請檢查是否開啟服務端監控" 58 | }, 59 | "IPInfo": { 60 | "asn_number": "ASN Number", 61 | "registered_country": "注册地區", 62 | "time_zone": "時區", 63 | "postal_code": "郵遞區號", 64 | "city": "城市", 65 | "longitude": "經度", 66 | "latitude": "緯度" 67 | }, 68 | "NetworkChart": { 69 | "ServerMonitorCount": "個監測服務" 70 | }, 71 | "ServerDetailClient": { 72 | "detail_fetch_error_message": "獲取伺服器詳情失敗,請檢查您的環境變數並檢查伺服器控制台", 73 | "status": "狀態", 74 | "Online": "在線", 75 | "Offline": "離線", 76 | "Uptime": "稼働時間", 77 | "Days": "天", 78 | "Hours": "小時", 79 | "Version": "版本", 80 | "Arch": "架構", 81 | "Mem": "記憶體", 82 | "Disk": "磁碟", 83 | "Region": "地區", 84 | "System": "系統", 85 | "CPU": "CPU", 86 | "Upload": "上傳", 87 | "Download": "下載", 88 | "Load": "負載", 89 | "LastActive": "最後上報時間", 90 | "BootTime": "啟動時間" 91 | }, 92 | "ServerDetailChartClient": { 93 | "chart_fetch_error_message": "獲取伺服器詳情失敗,請檢查您的環境變數並檢查伺服器控制台", 94 | "Process": "進程", 95 | "Disk": "磁碟", 96 | "Mem": "記憶體", 97 | "Swap": "虛擬記憶體", 98 | "Upload": "上傳", 99 | "Download": "下載" 100 | }, 101 | "ThemeSwitcher": { 102 | "Light": "亮色", 103 | "Dark": "暗色", 104 | "System": "系統" 105 | }, 106 | "Footer": { 107 | "p_146-598_Findthecodeon": "程式碼開源", 108 | "a_303-585_GitHub": "GitHub", 109 | "section_607-869_2020": "© 2020-", 110 | "a_800-850_Hamster1963": "@Hamster1963" 111 | }, 112 | "Header": { 113 | "p_1079-1199_Simpleandbeautifuldashbo": "簡單美觀的儀錶板" 114 | }, 115 | "Overview": { 116 | "p_2277-2331_Overview": "概覽", 117 | "p_2390-2457_wherethetimeis": "當前時間" 118 | }, 119 | "Global": { 120 | "Loading": "載入中...", 121 | "Distributions": "伺服器分佈在", 122 | "Regions": "個地區", 123 | "Servers": "個伺服器" 124 | }, 125 | "NotFoundPage": { 126 | "h1_490-590_404NotFound": "伺服器未找到", 127 | "h1_490-590_404NotFoundBack": "返回", 128 | "h1_490-590_Error": "發生錯誤" 129 | }, 130 | "DashCommand": { 131 | "TypeCommand": "輸入命令或搜尋", 132 | "NoResults": "沒有結果", 133 | "Servers": "伺服器", 134 | "Shortcuts": "快捷鍵", 135 | "ToggleLightMode": "切換亮色模式", 136 | "ToggleDarkMode": "切換暗色模式", 137 | "ToggleSystemMode": "切換系統模式", 138 | "Home": "首頁" 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /messages/zh.json: -------------------------------------------------------------------------------- 1 | { 2 | "ServerOverviewClient": { 3 | "p_816-881_Totalservers": "服务器总数", 4 | "p_1610-1676_Onlineservers": "在线服务器", 5 | "p_2532-2599_Offlineservers": "离线服务器", 6 | "p_3463-3530_Totalbandwidth": "总流量", 7 | "speed": "速率", 8 | "network": "网络", 9 | "error_message": "请检查您的环境变量并检查服务器控制台", 10 | "no_data_message": "无数据" 11 | }, 12 | "ServerListClient": { 13 | "error_message": "请检查您的环境变量并检查服务器控制台", 14 | "defaultTag": "全部", 15 | "connecting": "连接中" 16 | }, 17 | "ServerCard": { 18 | "System": "系统", 19 | "CPU": "CPU", 20 | "Mem": "内存", 21 | "STG": "存储", 22 | "Upload": "上传", 23 | "Download": "下载", 24 | "Offline": "离线", 25 | "Uptime": "运行时间", 26 | "TotalUpload": "总上传", 27 | "TotalDownload": "总下载", 28 | "Region": "地区" 29 | }, 30 | "TabSwitch": { 31 | "Detail": "详情", 32 | "Network": "网络" 33 | }, 34 | "SignIn": { 35 | "SignInMessage": "请输入密码", 36 | "Submit": "登录", 37 | "ErrorMessage": "密码错误", 38 | "SuccessMessage": "登录成功" 39 | }, 40 | "ServerCardPopover": { 41 | "System": "系统", 42 | "CPU": "CPU", 43 | "Mem": "内存", 44 | "STG": "存储", 45 | "Swap": "虚拟内存", 46 | "Network": "网络", 47 | "Load": "负载", 48 | "Online": "在线时间", 49 | "Offline": "离线", 50 | "Uptime": "运行时间", 51 | "Upload": "上传", 52 | "Download": "下载", 53 | "Region": "地区" 54 | }, 55 | "NetworkChartClient": { 56 | "avg_delay": "延迟", 57 | "chart_fetch_error_message": "获取网络数据失败,请检查是否开启服务端监控" 58 | }, 59 | "NetworkChart": { 60 | "ServerMonitorCount": "个监控服务" 61 | }, 62 | "IPInfo": { 63 | "asn_number": "ASN 编号", 64 | "registered_country": "注册地", 65 | "time_zone": "时区", 66 | "postal_code": "邮政编码", 67 | "city": "城市", 68 | "longitude": "经度", 69 | "latitude": "纬度" 70 | }, 71 | "ServerDetailClient": { 72 | "detail_fetch_error_message": "获取服务器详情失败,请检查您的环境变量并检查服务器控制台", 73 | "status": "状态", 74 | "Online": "在线", 75 | "Offline": "离线", 76 | "Uptime": "运行时间", 77 | "Days": "天", 78 | "Hours": "小时", 79 | "Version": "版本", 80 | "Arch": "架构", 81 | "Mem": "内存", 82 | "Disk": "磁盘", 83 | "Region": "地区", 84 | "System": "系统", 85 | "CPU": "CPU", 86 | "Upload": "上传", 87 | "Download": "下载", 88 | "Load": "负载", 89 | "LastActive": "最后上报时间", 90 | "BootTime": "启动时间" 91 | }, 92 | "ServerDetailChartClient": { 93 | "chart_fetch_error_message": "获取服务器详情失败,请检查您的环境变量并检查服务器控制台", 94 | "Process": "进程", 95 | "Disk": "磁盘", 96 | "Mem": "内存", 97 | "Swap": "虚拟内存", 98 | "Upload": "上传", 99 | "Download": "下载" 100 | }, 101 | "ThemeSwitcher": { 102 | "Light": "亮色", 103 | "Dark": "暗色", 104 | "System": "系统" 105 | }, 106 | "Footer": { 107 | "p_146-598_Findthecodeon": "代码开源在", 108 | "a_303-585_GitHub": "GitHub", 109 | "section_607-869_2020": "© 2020-", 110 | "a_800-850_Hamster1963": "@Hamster1963" 111 | }, 112 | "Header": { 113 | "p_1079-1199_Simpleandbeautifuldashbo": "简单美观的仪表板" 114 | }, 115 | "Overview": { 116 | "p_2277-2331_Overview": "概览", 117 | "p_2390-2457_wherethetimeis": "当前时间" 118 | }, 119 | "Global": { 120 | "Loading": "加载中...", 121 | "Distributions": "服务器分布在", 122 | "Regions": "个地区", 123 | "Servers": "个服务器" 124 | }, 125 | "NotFoundPage": { 126 | "h1_490-590_404NotFound": "服务器不存在", 127 | "h1_490-590_404NotFoundBack": "返回", 128 | "h1_490-590_Error": "发生错误" 129 | }, 130 | "DashCommand": { 131 | "TypeCommand": "输入命令或搜索", 132 | "NoResults": "结果为空", 133 | "Servers": "服务器", 134 | "Shortcuts": "快捷键", 135 | "ToggleLightMode": "切换亮色模式", 136 | "ToggleDarkMode": "切换暗色模式", 137 | "ToggleSystemMode": "切换系统模式", 138 | "Home": "首页" 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | import withPWAInit from "@ducanh2912/next-pwa" 2 | import withBundleAnalyzer from "@next/bundle-analyzer" 3 | import createNextIntlPlugin from "next-intl/plugin" 4 | 5 | const bundleAnalyzer = withBundleAnalyzer({ 6 | enabled: process.env.ANALYZE === "true", 7 | }) 8 | 9 | const withNextIntl = createNextIntlPlugin() 10 | 11 | const withPWA = withPWAInit({ 12 | dest: "public", 13 | cacheOnFrontEndNav: true, 14 | aggressiveFrontEndNavCaching: true, 15 | reloadOnOnline: true, 16 | disable: false, 17 | workboxOptions: { 18 | disableDevLogs: true, 19 | }, 20 | }) 21 | 22 | /** @type {import('next').NextConfig} */ 23 | const nextConfig = { 24 | experimental: { 25 | webpackBuildWorker: true, 26 | parallelServerBuildTraces: true, 27 | parallelServerCompiles: true, 28 | inlineCss: true, 29 | reactCompiler: true, 30 | serverActions: { 31 | allowedOrigins: ["*"], 32 | }, 33 | }, 34 | output: "standalone", 35 | eslint: { 36 | // Warning: This allows production builds to successfully complete even if 37 | // your project has ESLint errors. 38 | ignoreDuringBuilds: true, 39 | }, 40 | logging: { 41 | fetches: { 42 | fullUrl: true, 43 | }, 44 | }, 45 | } 46 | export default bundleAnalyzer(withPWA(withNextIntl(nextConfig))) 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nezha-dash", 3 | "version": "2.9.6", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev -p 3040", 7 | "start": "node .next/standalone/server.js", 8 | "lint": "biome lint", 9 | "lint:fix": "biome lint --fix", 10 | "format": "biome format --write .", 11 | "check": "biome check", 12 | "check:fix": "biome check --fix", 13 | "build": "next build && cp -r .next/static .next/standalone/.next/ && cp -r public .next/standalone/" 14 | }, 15 | "dependencies": { 16 | "@ducanh2912/next-pwa": "^10.2.9", 17 | "@heroicons/react": "^2.2.0", 18 | "@radix-ui/react-dialog": "^1.1.11", 19 | "@radix-ui/react-dropdown-menu": "^2.1.12", 20 | "@radix-ui/react-label": "^2.1.4", 21 | "@radix-ui/react-navigation-menu": "^1.2.10", 22 | "@radix-ui/react-popover": "^1.1.11", 23 | "@radix-ui/react-progress": "^1.1.4", 24 | "@radix-ui/react-radio-group": "^1.3.4", 25 | "@radix-ui/react-separator": "^1.1.4", 26 | "@radix-ui/react-slot": "^1.2.0", 27 | "@radix-ui/react-switch": "^1.2.2", 28 | "@radix-ui/react-tooltip": "^1.2.4", 29 | "@trivago/prettier-plugin-sort-imports": "^5.2.2", 30 | "@types/crypto-js": "^4.2.2", 31 | "@types/d3-geo": "^3.1.0", 32 | "@types/luxon": "^3.6.2", 33 | "babel-plugin-react-compiler": "^19.0.0-beta-ebf51a3-20250411", 34 | "caniuse-lite": "^1.0.30001716", 35 | "class-variance-authority": "^0.7.1", 36 | "clsx": "^2.1.1", 37 | "cmdk": "^1.1.1", 38 | "country-flag-icons": "^1.5.19", 39 | "crypto-js": "^4.2.0", 40 | "d3-geo": "^3.1.1", 41 | "d3-selection": "^3.0.0", 42 | "flag-icons": "^7.3.2", 43 | "i18n-iso-countries": "^7.14.0", 44 | "lucide-react": "^0.474.0", 45 | "luxon": "^3.6.1", 46 | "maxmind": "^4.3.25", 47 | "next": "^15.3.1", 48 | "next-auth": "^5.0.0-beta.27", 49 | "next-intl": "^4.1.0", 50 | "next-runtime-env": "^3.3.0", 51 | "next-themes": "^0.4.6", 52 | "react": "^19.1.0", 53 | "react-device-detect": "^2.2.3", 54 | "react-dom": "^19.1.0", 55 | "react-intersection-observer": "^9.16.0", 56 | "react-wrap-balancer": "^1.1.1", 57 | "recharts": "^2.15.3", 58 | "sharp": "^0.33.5", 59 | "swr": "^2.3.3", 60 | "tailwind-merge": "^3.2.0", 61 | "tailwindcss-animate": "^1.0.7" 62 | }, 63 | "devDependencies": { 64 | "@biomejs/biome": "1.9.4", 65 | "@next/bundle-analyzer": "^15.3.1", 66 | "@tailwindcss/postcss": "^4.1.5", 67 | "@types/node": "^22.15.3", 68 | "@types/react": "^19.1.2", 69 | "@types/react-dom": "^19.1.3", 70 | "postcss": "^8.5.3", 71 | "tailwindcss": "^4.1.5", 72 | "typescript": "^5.8.3", 73 | "vercel": "^41.7.0" 74 | }, 75 | "overrides": { 76 | "react-is": "^19.0.0-rc-69d4b800-20241021" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "@tailwindcss/postcss": {}, 4 | }, 5 | } 6 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hamster1963/nezha-dash/8cfc36872a3e429957d6e38d56b870d588ae2361/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hamster1963/nezha-dash/8cfc36872a3e429957d6e38d56b870d588ae2361/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/apple-touch-icon-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hamster1963/nezha-dash/8cfc36872a3e429957d6e38d56b870d588ae2361/public/apple-touch-icon-dark.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hamster1963/nezha-dash/8cfc36872a3e429957d6e38d56b870d588ae2361/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/blog-man.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hamster1963/nezha-dash/8cfc36872a3e429957d6e38d56b870d588ae2361/public/blog-man.webp -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hamster1963/nezha-dash/8cfc36872a3e429957d6e38d56b870d588ae2361/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hamster1963/nezha-dash/8cfc36872a3e429957d6e38d56b870d588ae2361/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "NezhaDash PWA App", 3 | "short_name": "NezhaDash", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png", 9 | "purpose": "any maskable" 10 | }, 11 | { 12 | "src": "/icon-512x512.png", 13 | "sizes": "512x512", 14 | "type": "image/png" 15 | } 16 | ], 17 | "theme_color": "hsl(0 0% 98%)", 18 | "background_color": "hsl(0 0% 98%)", 19 | "start_url": "/", 20 | "display": "standalone", 21 | "orientation": "portrait" 22 | } 23 | -------------------------------------------------------------------------------- /public/ui-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hamster1963/nezha-dash/8cfc36872a3e429957d6e38d56b870d588ae2361/public/ui-dark.png -------------------------------------------------------------------------------- /public/ui-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hamster1963/nezha-dash/8cfc36872a3e429957d6e38d56b870d588ae2361/public/ui-light.png -------------------------------------------------------------------------------- /public/ui-system.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hamster1963/nezha-dash/8cfc36872a3e429957d6e38d56b870d588ae2361/public/ui-system.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | --------------------------------------------------------------------------------