Compare commits

..

No commits in common. "main" and "v0.5.0" have entirely different histories.
main ... v0.5.0

158 changed files with 2330 additions and 7143 deletions

View File

@ -1,18 +1,7 @@
NezhaBaseUrl=http://124.XX.XX.XX:8008
NezhaAuth=your-nezha-api-token
DefaultLocale=zh
ForceShowAllServers=false
NEXT_PUBLIC_NezhaFetchInterval=5000
NEXT_PUBLIC_ShowFlag=true
NEXT_PUBLIC_DisableCartoon=false
NEXT_PUBLIC_ShowTag=true
NEXT_PUBLIC_ShowNetTransfer=false
NEXT_PUBLIC_ForceUseSvgFlag=false
NEXT_PUBLIC_FixedTopServerName=false
NEXT_PUBLIC_CustomLogo=https://nezha-cf.buycoffee.top/apple-touch-icon.png
NEXT_PUBLIC_CustomTitle=NezhaDash
NEXT_PUBLIC_CustomDescription=NezhaDash is a dashboard for Nezha.
NEXT_PUBLIC_Links='[{"link":"https://github.com/hamster1963/nezha-dash","name":"GitHub"},{"link":"https://buycoffee.top/coffee","name":"Buycoffee☕"}]'
NEXT_PUBLIC_DisableIndex=false
NEXT_PUBLIC_ShowTagCount=false
NEXT_PUBLIC_ShowIpInfo=false
NEXT_PUBLIC_DisableCartoon=true
NEXT_PUBLIC_ShowTag=true

View File

@ -1,19 +0,0 @@
---
name: Bug 提交
about: 提交 bug让面板变得更好。
title: "[BUG]"
labels: ""
assignees: ""
---
**面板版本(二选一)**
V0 | V1
**描述 bug**
在这里描述 bug 的相关信息
**屏幕截图**
有屏幕截图可以帮助更快定位到问题
**额外信息**
可附上其他需要的额外信息

View File

@ -1,14 +0,0 @@
---
name: 功能申请
about: 描述需求
title: "[FEAT]"
labels: ""
assignees: ""
---
**面板版本(二选一)**
V0 | V1
**需要什么?**
**额外信息**

View File

@ -1,14 +0,0 @@
---
name: 改善建议
about: 交流面板需要改进的地方
title: "[SUGGEST]"
labels: ""
assignees: ""
---
**面板版本(二选一)**
V0 | V1
**需要改进的?**
-

BIN
.github/get-token.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
.github/shot-1.jpeg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

BIN
.github/shot-2.jpeg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

BIN
.github/shot-3.jpeg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

BIN
.github/shot-4.jpeg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

BIN
.github/shotOne.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 597 KiB

BIN
.github/shotTwo.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 KiB

BIN
.github/v2-1.webp vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

BIN
.github/v2-2.webp vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

BIN
.github/v2-3.webp vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

BIN
.github/v2-4.webp vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

BIN
.github/v2-dark.webp vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

View File

@ -1,7 +1,5 @@
name: Build and push Docker image
permissions:
contents: write
on:
push:
tags:
@ -12,6 +10,33 @@ env:
ALIYUN_REGISTRY_IMAGE: registry.cn-guangzhou.aliyuncs.com/hamster-home/nezha-dash
jobs:
changelog:
name: Generate Changelog
runs-on: ubuntu-latest
outputs:
release_body: ${{ steps.git-cliff.outputs.content }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate a changelog
uses: orhun/git-cliff-action@v4
id: git-cliff
with:
config: git-cliff-config/cliff.toml
args: -vv --latest --strip 'footer'
env:
OUTPUT: CHANGES.md
- name: Release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with:
body: ${{ steps.git-cliff.outputs.content }}
token: ${{ secrets.GITHUB_TOKEN }}
env:
GITHUB_REPOSITORY: ${{ github.repository }}
build-and-push:
name: Build and push Docker image
runs-on: ubuntu-latest
@ -19,13 +44,12 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host
- name: Login to Docker Hub
uses: docker/login-action@v3
@ -59,13 +83,3 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Set up Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: "latest"
- name: Changelog
run: bun x changelogithub
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}

View File

@ -1,56 +0,0 @@
name: Auto Fix Lint and Format
permissions:
contents: write
pull-requests: write
on:
pull_request:
types: [opened, synchronize]
jobs:
auto-fix:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Set up Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: "latest"
- name: Install dependencies
run: bun install
- name: Run linter & formatter and fix issues
run: bun run check:fix
- name: Check for changes
id: check_changes
run: |
git diff --exit-code || echo "has_changes=true" >> $GITHUB_ENV
- name: Commit and push changes
if: steps.check_changes.outputs.has_changes == 'true' || env.has_changes == 'true'
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "chore: auto-fix linting and formatting issues"
commit_options: "--no-verify"
file_pattern: "."
- name: Add PR comment
if: steps.check_changes.outputs.has_changes == 'true' || env.has_changes == 'true'
uses: actions/github-script@v7
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'Linting and formatting issues were automatically fixed. Please review the changes.'
});

View File

@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM oven/bun:1 AS base
FROM oven/bun:1 AS base
# Stage 1: Install dependencies
FROM base AS deps
@ -14,7 +14,7 @@ COPY . .
RUN bun run build
# Stage 3: Production image
FROM node:23-alpine AS runner
FROM oven/bun:1-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
@ -22,4 +22,4 @@ COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]
CMD ["bun", "run", "server.js"]

201
LICENSE
View File

@ -1,201 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -1,38 +1,37 @@
<div align="center"><img width="600" alt="nezhadash" src="https://github.com/user-attachments/assets/0a5768e1-96f2-4f8a-b77f-01488ed3b237"></div>
<h3 align="center">NezhaDash 是一个基于 Next.js 和 哪吒监控 的仪表盘</h3>
<h1 align="center">NezhaDash</h1>
<strong>NezhaDash 是一个基于 Next.js 和 哪吒监控 的仪表盘</strong>
<br>
</div>
> [!CAUTION]
> 此为 V0 兼容版本,与 V1 内置版本功能上可能有所不同
>
> V0 | V1 版本 issue 请在当前仓库发起
| 一键部署到 Vercel-推荐 | Docker部署 | Cloudflare部署 |
| ----------------------------------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------- |
| [部署简易教程](https://buycoffee.top/blog/tech/nezha) | [Docker 部署教程](https://buycoffee.top/blog/tech/nezha-docker) | [Cloudflare 部署教程](https://buycoffee.top/blog/tech/nezha-cloudflare) |
| [Vercel-demo](https://nezha-dash-ruddy.vercel.app) | [Docker-demo](https://nezha-docker.buycoffee.tech) | [Cloudflare-demo](https://nezha-cloudflare.buycoffee.tech) |
> [!TIP]
> 有关 V1 版本 pr 可移步 https://github.com/hamster1963/nezha-dash-v1
#### 环境变量
### 部署
| 变量名 | 含义 | 示例 |
| ------------------------------ | -------------------------------- | -------------------------------- |
| NezhaBaseUrl | nezha 面板地址 | http://120.x.x.x:8008 |
| NezhaAuth | nezha 面板 API Token | 5hAY3QX6Nl9B3Uxxxx26KMvOMyXS1Udi |
| DefaultLocale | 面板默认显示语言(代码参考下表) | **默认**en |
| NEXT_PUBLIC_NezhaFetchInterval | 获取数据间隔(毫秒) | **默认**2000 |
| NEXT_PUBLIC_ShowFlag | 是否显示旗帜 | **默认**false |
| NEXT_PUBLIC_DisableCartoon | 是否禁用卡通人物 | **默认**false |
| NEXT_PUBLIC_ShowTag | 是否显示标签 | **默认**false |
支持部署环境:
#### 多语言支持
- Vercel
- Cloudflare
- Docker
| 语言 | 代码 | 是否完成翻译 |
| -------- | ---- | ------------ |
| 简体中文 | zh | 是 |
| 繁体中文 | zh-t | 是 |
| 英语 | en | 是 |
| 日语 | ja | 是 |
[演示站点](https://nezha-vercel.vercel.app)
[说明文档](https://nezhadash-docs.vercel.app)
### 如何更新
[更新教程](https://buycoffee.top/blog/tech/nezha-upgrade)
### 环境变量
[环境变量介绍](https://nezhadash-docs.vercel.app/environment)
![screen](/.github/v2-1.webp)
![screen](/.github/v2-2.webp)
![screen](/.github/v2-3.webp)
![screen](/.github/v2-4.webp)
![screen](/.github/v2-dark.webp)
![screen-shot-one](/.github/shot-1.jpeg)
![screen-shot-three](/.github/shot-3.jpeg)
![screen-shot-two](/.github/shot-2.jpeg)
![screen-shot-four](/.github/shot-4.jpeg)

View File

@ -1,398 +0,0 @@
"use client"
import type { NezhaAPIMonitor, ServerMonitorChart } from "@/app/types/nezha-api"
import NetworkChartLoading from "@/components/loading/NetworkChartLoading"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import getEnv from "@/lib/env-entry"
import { formatTime, nezhaFetcher } from "@/lib/utils"
import { useTranslations } from "next-intl"
import * as React from "react"
import { useCallback, useMemo } from "react"
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
import useSWR from "swr"
interface ResultItem {
created_at: number
[key: string]: number
}
export function NetworkChartClient({
server_id,
show,
}: {
server_id: number
show: boolean
}) {
const t = useTranslations("NetworkChartClient")
const { data, error } = useSWR<NezhaAPIMonitor[]>(
`/api/monitor?server_id=${server_id}`,
nezhaFetcher,
{
refreshInterval: Number(getEnv("NEXT_PUBLIC_NezhaFetchInterval")) || 15000,
isVisible: () => show,
},
)
if (error) {
return (
<>
<div className="flex flex-col items-center justify-center">
<p className="font-medium text-sm opacity-40">{error.message}</p>
<p className="font-medium text-sm opacity-40">{t("chart_fetch_error_message")}</p>
</div>
<NetworkChartLoading />
</>
)
}
if (!data) return <NetworkChartLoading />
const transformedData = transformData(data)
const formattedData = formatData(data)
const initChartConfig = {
avg_delay: {
label: t("avg_delay"),
},
} satisfies ChartConfig
const chartDataKey = Object.keys(transformedData)
return (
<NetworkChart
chartDataKey={chartDataKey}
chartConfig={initChartConfig}
chartData={transformedData}
serverName={data[0].server_name}
formattedData={formattedData}
/>
)
}
export const NetworkChart = React.memo(function NetworkChart({
chartDataKey,
chartConfig,
chartData,
serverName,
formattedData,
}: {
chartDataKey: string[]
chartConfig: ChartConfig
chartData: ServerMonitorChart
serverName: string
formattedData: ResultItem[]
}) {
const t = useTranslations("NetworkChart")
const defaultChart = "All"
const [activeChart, setActiveChart] = React.useState(defaultChart)
const [isPeakEnabled, setIsPeakEnabled] = React.useState(false)
const handleButtonClick = useCallback(
(chart: string) => {
setActiveChart((prev) => (prev === chart ? defaultChart : chart))
},
[defaultChart],
)
const getColorByIndex = useCallback(
(chart: string) => {
const index = chartDataKey.indexOf(chart)
return `hsl(var(--chart-${(index % 10) + 1}))`
},
[chartDataKey],
)
const chartButtons = useMemo(
() =>
chartDataKey.map((key) => (
<button
type="button"
key={key}
data-active={activeChart === key}
className={
"relative z-30 flex grow basis-0 cursor-pointer flex-col justify-center gap-1 border-neutral-200 border-b px-6 py-4 text-left data-[active=true]:bg-muted/50 sm:border-t-0 sm:border-l sm:px-6 dark:border-neutral-800"
}
onClick={() => handleButtonClick(key)}
>
<span className="whitespace-nowrap text-muted-foreground text-xs">{key}</span>
<span className="font-bold text-md leading-none sm:text-lg">
{chartData[key][chartData[key].length - 1].avg_delay.toFixed(2)}ms
</span>
</button>
)),
[chartDataKey, activeChart, chartData, handleButtonClick],
)
const chartLines = useMemo(() => {
if (activeChart !== defaultChart) {
return (
<Line
isAnimationActive={false}
strokeWidth={1}
type="linear"
dot={false}
dataKey="avg_delay"
stroke={getColorByIndex(activeChart)}
/>
)
}
return chartDataKey.map((key) => (
<Line
key={key}
isAnimationActive={false}
strokeWidth={1}
type="linear"
dot={false}
dataKey={key}
stroke={getColorByIndex(key)}
connectNulls={true}
/>
))
}, [activeChart, defaultChart, chartDataKey, getColorByIndex])
const processedData = useMemo(() => {
if (!isPeakEnabled) {
return activeChart === defaultChart ? formattedData : chartData[activeChart]
}
const data = (
activeChart === defaultChart ? formattedData : chartData[activeChart]
) as ResultItem[]
const windowSize = 11 // 增加窗口大小以获取更好的统计效果
const alpha = 0.3 // EWMA平滑因子
// 辅助函数:计算中位数
const getMedian = (arr: number[]) => {
const sorted = [...arr].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
}
// 辅助函数:异常值处理
const processValues = (values: number[]) => {
if (values.length === 0) return null
const median = getMedian(values)
const deviations = values.map((v) => Math.abs(v - median))
const medianDeviation = getMedian(deviations) * 1.4826 // MAD估计器
// 使用中位数绝对偏差(MAD)进行异常值检测
const validValues = values.filter(
(v) =>
Math.abs(v - median) <= 3 * medianDeviation && // 更严格的异常值判定
v <= median * 3, // 限制最大值不超过中位数的3倍
)
if (validValues.length === 0) return median // 如果没有有效值,返回中位数
// 计算EWMA
let ewma = validValues[0]
for (let i = 1; i < validValues.length; i++) {
ewma = alpha * validValues[i] + (1 - alpha) * ewma
}
return ewma
}
// 初始化EWMA历史值
const ewmaHistory: { [key: string]: number } = {}
return data.map((point, index) => {
if (index < windowSize - 1) return point
const window = data.slice(index - windowSize + 1, index + 1)
const smoothed = { ...point } as ResultItem
if (activeChart === defaultChart) {
for (const key of chartDataKey) {
const values = window
.map((w) => w[key])
.filter((v) => v !== undefined && v !== null) as number[]
if (values.length > 0) {
const processed = processValues(values)
if (processed !== null) {
// 应用EWMA平滑
if (ewmaHistory[key] === undefined) {
ewmaHistory[key] = processed
} else {
ewmaHistory[key] = alpha * processed + (1 - alpha) * ewmaHistory[key]
}
smoothed[key] = ewmaHistory[key]
}
}
}
} else {
const values = window
.map((w) => w.avg_delay)
.filter((v) => v !== undefined && v !== null) as number[]
if (values.length > 0) {
const processed = processValues(values)
if (processed !== null) {
// 应用EWMA平滑
if (ewmaHistory.current === undefined) {
ewmaHistory.current = processed
} else {
ewmaHistory.current = alpha * processed + (1 - alpha) * ewmaHistory.current
}
smoothed.avg_delay = ewmaHistory.current
}
}
}
return smoothed
})
}, [isPeakEnabled, activeChart, formattedData, chartData, chartDataKey, defaultChart])
return (
<Card>
<CardHeader className="flex flex-col items-stretch space-y-0 p-0 sm:flex-row">
<div className="flex flex-none flex-col justify-center gap-1 border-b px-6 py-4">
<CardTitle className="flex flex-none items-center gap-0.5 text-md">
{serverName}
</CardTitle>
<CardDescription className="text-xs">
{chartDataKey.length} {t("ServerMonitorCount")}
</CardDescription>
<div className="mt-0.5 flex items-center space-x-2">
<Switch id="Peak" checked={isPeakEnabled} onCheckedChange={setIsPeakEnabled} />
<Label className="text-xs" htmlFor="Peak">
Peak cut
</Label>
</div>
</div>
<div className="flex w-full flex-wrap">{chartButtons}</div>
</CardHeader>
<CardContent className="py-4 pr-2 pl-0 sm:pt-6 sm:pr-6 sm:pb-6 sm:pl-2">
<ChartContainer config={chartConfig} className="aspect-auto h-[250px] w-full">
<LineChart accessibilityLayer data={processedData} margin={{ left: 12, right: 12 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="created_at"
tickLine={true}
tickSize={3}
axisLine={false}
tickMargin={8}
minTickGap={80}
ticks={processedData
.filter((item, index, array) => {
if (array.length < 6) {
return index === 0 || index === array.length - 1
}
// 计算数据的总时间跨度(毫秒)
const timeSpan = array[array.length - 1].created_at - array[0].created_at
const hours = timeSpan / (1000 * 60 * 60)
// 根据时间跨度调整显示间隔
if (hours <= 12) {
// 12小时内每60分钟显示一个刻度
return (
index === 0 ||
index === array.length - 1 ||
new Date(item.created_at).getMinutes() % 60 === 0
)
}
// 超过12小时每2小时显示一个刻度
const date = new Date(item.created_at)
return date.getMinutes() === 0 && date.getHours() % 2 === 0
})
.map((item) => item.created_at)}
tickFormatter={(value) => {
const date = new Date(value)
const minutes = date.getMinutes()
return minutes === 0 ? `${date.getHours()}:00` : `${date.getHours()}:${minutes}`
}}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={15}
minTickGap={20}
tickFormatter={(value) => `${value}ms`}
/>
<ChartTooltip
isAnimationActive={false}
content={
<ChartTooltipContent
indicator={"line"}
labelKey="created_at"
labelFormatter={(_, payload) => {
return formatTime(payload[0].payload.created_at)
}}
/>
}
/>
{activeChart === defaultChart && <ChartLegend content={<ChartLegendContent />} />}
{chartLines}
</LineChart>
</ChartContainer>
</CardContent>
</Card>
)
})
const transformData = (data: NezhaAPIMonitor[]) => {
const monitorData: ServerMonitorChart = {}
for (const item of data) {
const monitorName = item.monitor_name
if (!monitorData[monitorName]) {
monitorData[monitorName] = []
}
for (let i = 0; i < item.created_at.length; i++) {
monitorData[monitorName].push({
created_at: item.created_at[i],
avg_delay: item.avg_delay[i],
})
}
}
return monitorData
}
const formatData = (rawData: NezhaAPIMonitor[]) => {
const result: { [time: number]: ResultItem } = {}
const allTimes = new Set<number>()
for (const item of rawData) {
for (const time of item.created_at) {
allTimes.add(time)
}
}
const allTimeArray = Array.from(allTimes).sort((a, b) => a - b)
for (const item of rawData) {
const { monitor_name, created_at, avg_delay } = item
for (const time of allTimeArray) {
if (!result[time]) {
result[time] = { created_at: time }
}
const timeIndex = created_at.indexOf(time)
// @ts-expect-error - avg_delay is an array
result[time][monitor_name] = timeIndex !== -1 ? avg_delay[timeIndex] : null
}
}
return Object.values(result).sort((a, b) => a.created_at - b.created_at)
}

View File

@ -1,892 +0,0 @@
"use client"
import {
MAX_HISTORY_LENGTH,
type ServerDataWithTimestamp,
useServerData,
} from "@/app/context/server-data-context"
import type { NezhaAPISafe } from "@/app/types/nezha-api"
import { ServerDetailChartLoading } from "@/components/loading/ServerDetailLoading"
import AnimatedCircularProgressBar from "@/components/ui/animated-circular-progress-bar"
import { Card, CardContent } from "@/components/ui/card"
import { type ChartConfig, ChartContainer } from "@/components/ui/chart"
import { formatBytes, formatNezhaInfo, formatRelativeTime } from "@/lib/utils"
import { useTranslations } from "next-intl"
import { useEffect, useRef, useState } from "react"
import { Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
type cpuChartData = {
timeStamp: string
cpu: number
}
type processChartData = {
timeStamp: string
process: number
}
type diskChartData = {
timeStamp: string
disk: number
}
type memChartData = {
timeStamp: string
mem: number
swap: number
}
type networkChartData = {
timeStamp: string
upload: number
download: number
}
type connectChartData = {
timeStamp: string
tcp: number
udp: number
}
export default function ServerDetailChartClient({
server_id,
}: {
server_id: number
show: boolean
}) {
const t = useTranslations("ServerDetailChartClient")
const { data: serverList, error, history } = useServerData()
const data = serverList?.result?.find((item) => item.id === server_id)
if (error) {
return (
<>
<div className="flex flex-col items-center justify-center">
<p className="font-medium text-sm opacity-40">{error.message}</p>
<p className="font-medium text-sm opacity-40">{t("chart_fetch_error_message")}</p>
</div>
</>
)
}
if (!data) return <ServerDetailChartLoading />
return (
<section className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
<CpuChart data={data} history={history} />
<ProcessChart data={data} history={history} />
<DiskChart data={data} history={history} />
<MemChart data={data} history={history} />
<NetworkChart data={data} history={history} />
<ConnectChart data={data} history={history} />
</section>
)
}
function CpuChart({
history,
data,
}: {
history: ServerDataWithTimestamp[]
data: NezhaAPISafe
}) {
const [cpuChartData, setCpuChartData] = useState([] as cpuChartData[])
const hasInitialized = useRef(false)
const [historyLoaded, setHistoryLoaded] = useState(false)
useEffect(() => {
if (!hasInitialized.current && history.length > 0) {
const historyData = history
.map((msg) => {
const server = msg.data?.result?.find((item) => item.id === data.id)
if (!server) return null
const { cpu } = formatNezhaInfo(server)
return {
timeStamp: msg.timestamp.toString(),
cpu: cpu,
}
})
.filter((item): item is cpuChartData => item !== null)
.reverse() // 保持时间顺序
setCpuChartData(historyData)
hasInitialized.current = true
setHistoryLoaded(true)
} else if (history.length === 0) {
setHistoryLoaded(true)
}
}, [])
const { cpu } = formatNezhaInfo(data)
useEffect(() => {
if (data && historyLoaded) {
const timestamp = Date.now().toString()
let newData = [] as cpuChartData[]
if (cpuChartData.length === 0) {
newData = [
{ timeStamp: timestamp, cpu: cpu },
{ timeStamp: timestamp, cpu: cpu },
]
} else {
newData = [...cpuChartData, { timeStamp: timestamp, cpu: cpu }]
}
if (newData.length > MAX_HISTORY_LENGTH) {
newData.shift()
}
setCpuChartData(newData)
}
}, [data, historyLoaded])
const chartConfig = {
cpu: {
label: "CPU",
},
} satisfies ChartConfig
return (
<Card>
<CardContent className="px-6 py-3">
<section className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="font-medium text-md">CPU</p>
<section className="flex items-center gap-2">
<p className="w-10 text-end font-medium text-xs">{cpu.toFixed(0)}%</p>
<AnimatedCircularProgressBar
className="size-3 text-[0px]"
max={100}
min={0}
value={cpu}
primaryColor="hsl(var(--chart-1))"
/>
</section>
</div>
<ChartContainer config={chartConfig} className="aspect-auto h-[130px] w-full">
<AreaChart
accessibilityLayer
data={cpuChartData}
margin={{
top: 12,
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="timeStamp"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={200}
interval="preserveStartEnd"
tickFormatter={(value) => formatRelativeTime(value)}
/>
<YAxis
tickLine={false}
axisLine={false}
mirror={true}
tickMargin={-15}
domain={[0, 100]}
tickFormatter={(value) => `${value}%`}
/>
<Area
isAnimationActive={false}
dataKey="cpu"
type="step"
fill="hsl(var(--chart-1))"
fillOpacity={0.3}
stroke="hsl(var(--chart-1))"
/>
</AreaChart>
</ChartContainer>
</section>
</CardContent>
</Card>
)
}
function ProcessChart({
data,
history,
}: {
data: NezhaAPISafe
history: ServerDataWithTimestamp[]
}) {
const t = useTranslations("ServerDetailChartClient")
const [processChartData, setProcessChartData] = useState([] as processChartData[])
const hasInitialized = useRef(false)
const [historyLoaded, setHistoryLoaded] = useState(false)
useEffect(() => {
if (!hasInitialized.current && history.length > 0) {
const historyData = history
.map((msg) => {
const server = msg.data?.result?.find((item) => item.id === data.id)
if (!server) return null
const { process } = formatNezhaInfo(server)
return {
timeStamp: msg.timestamp.toString(),
process: process,
}
})
.filter((item): item is processChartData => item !== null)
.reverse()
setProcessChartData(historyData)
hasInitialized.current = true
setHistoryLoaded(true)
} else if (history.length === 0) {
setHistoryLoaded(true)
}
}, [])
const { process } = formatNezhaInfo(data)
useEffect(() => {
if (data && historyLoaded) {
const timestamp = Date.now().toString()
let newData = [] as processChartData[]
if (processChartData.length === 0) {
newData = [
{ timeStamp: timestamp, process: process },
{ timeStamp: timestamp, process: process },
]
} else {
newData = [...processChartData, { timeStamp: timestamp, process: process }]
}
if (newData.length > MAX_HISTORY_LENGTH) {
newData.shift()
}
setProcessChartData(newData)
}
}, [data, historyLoaded])
const chartConfig = {
process: {
label: "Process",
},
} satisfies ChartConfig
return (
<Card>
<CardContent className="px-6 py-3">
<section className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="font-medium text-md">{t("Process")}</p>
<section className="flex items-center gap-2">
<p className="w-10 text-end font-medium text-xs">{process}</p>
</section>
</div>
<ChartContainer config={chartConfig} className="aspect-auto h-[130px] w-full">
<AreaChart
accessibilityLayer
data={processChartData}
margin={{
top: 12,
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="timeStamp"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={200}
interval="preserveStartEnd"
tickFormatter={(value) => formatRelativeTime(value)}
/>
<YAxis tickLine={false} axisLine={false} mirror={true} tickMargin={-15} />
<Area
isAnimationActive={false}
dataKey="process"
type="step"
fill="hsl(var(--chart-2))"
fillOpacity={0.3}
stroke="hsl(var(--chart-2))"
/>
</AreaChart>
</ChartContainer>
</section>
</CardContent>
</Card>
)
}
function MemChart({
data,
history,
}: {
data: NezhaAPISafe
history: ServerDataWithTimestamp[]
}) {
const t = useTranslations("ServerDetailChartClient")
const [memChartData, setMemChartData] = useState([] as memChartData[])
const hasInitialized = useRef(false)
const [historyLoaded, setHistoryLoaded] = useState(false)
useEffect(() => {
if (!hasInitialized.current && history.length > 0) {
const historyData = history
.map((msg) => {
const server = msg.data?.result?.find((item) => item.id === data.id)
if (!server) return null
const { mem, swap } = formatNezhaInfo(server)
return {
timeStamp: msg.timestamp.toString(),
mem: mem,
swap: swap,
}
})
.filter((item): item is memChartData => item !== null)
.reverse()
setMemChartData(historyData)
hasInitialized.current = true
setHistoryLoaded(true)
} else if (history.length === 0) {
setHistoryLoaded(true)
}
}, [])
const { mem, swap } = formatNezhaInfo(data)
useEffect(() => {
if (data && historyLoaded) {
const timestamp = Date.now().toString()
let newData = [] as memChartData[]
if (memChartData.length === 0) {
newData = [
{ timeStamp: timestamp, mem: mem, swap: swap },
{ timeStamp: timestamp, mem: mem, swap: swap },
]
} else {
newData = [...memChartData, { timeStamp: timestamp, mem: mem, swap: swap }]
}
if (newData.length > MAX_HISTORY_LENGTH) {
newData.shift()
}
setMemChartData(newData)
}
}, [data, historyLoaded])
const chartConfig = {
mem: {
label: "Mem",
},
swap: {
label: "Swap",
},
} satisfies ChartConfig
return (
<Card>
<CardContent className="px-6 py-3">
<section className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<section className="flex items-center gap-4">
<div className="flex flex-col">
<p className=" text-muted-foreground text-xs">{t("Mem")}</p>
<div className="flex items-center gap-2">
<AnimatedCircularProgressBar
className="size-3 text-[0px]"
max={100}
min={0}
value={mem}
primaryColor="hsl(var(--chart-8))"
/>
<p className="font-medium text-xs">{mem.toFixed(0)}%</p>
</div>
</div>
<div className="flex flex-col">
<p className=" text-muted-foreground text-xs">{t("Swap")}</p>
<div className="flex items-center gap-2">
<AnimatedCircularProgressBar
className="size-3 text-[0px]"
max={100}
min={0}
value={swap}
primaryColor="hsl(var(--chart-10))"
/>
<p className="font-medium text-xs">{swap.toFixed(0)}%</p>
</div>
</div>
</section>
<section className="flex flex-col items-end gap-0.5">
<div className="flex items-center gap-2 font-medium text-[11px]">
{formatBytes(data.status.MemUsed)} / {formatBytes(data.host.MemTotal)}
</div>
<div className="flex items-center gap-2 font-medium text-[11px]">
swap: {formatBytes(data.status.SwapUsed)}
</div>
</section>
</div>
<ChartContainer config={chartConfig} className="aspect-auto h-[130px] w-full">
<AreaChart
accessibilityLayer
data={memChartData}
margin={{
top: 12,
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="timeStamp"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={200}
interval="preserveStartEnd"
tickFormatter={(value) => formatRelativeTime(value)}
/>
<YAxis
tickLine={false}
axisLine={false}
mirror={true}
tickMargin={-15}
domain={[0, 100]}
tickFormatter={(value) => `${value}%`}
/>
<Area
isAnimationActive={false}
dataKey="mem"
type="step"
fill="hsl(var(--chart-8))"
fillOpacity={0.3}
stroke="hsl(var(--chart-8))"
/>
<Area
isAnimationActive={false}
dataKey="swap"
type="step"
fill="hsl(var(--chart-10))"
fillOpacity={0.3}
stroke="hsl(var(--chart-10))"
/>
</AreaChart>
</ChartContainer>
</section>
</CardContent>
</Card>
)
}
function DiskChart({
data,
history,
}: {
data: NezhaAPISafe
history: ServerDataWithTimestamp[]
}) {
const t = useTranslations("ServerDetailChartClient")
const [diskChartData, setDiskChartData] = useState([] as diskChartData[])
const hasInitialized = useRef(false)
const [historyLoaded, setHistoryLoaded] = useState(false)
useEffect(() => {
if (!hasInitialized.current && history.length > 0) {
const historyData = history
.map((msg) => {
const server = msg.data?.result?.find((item) => item.id === data.id)
if (!server) return null
const { disk } = formatNezhaInfo(server)
return {
timeStamp: msg.timestamp.toString(),
disk: disk,
}
})
.filter((item): item is diskChartData => item !== null)
.reverse()
setDiskChartData(historyData)
hasInitialized.current = true
setHistoryLoaded(true)
} else if (history.length === 0) {
setHistoryLoaded(true)
}
}, [])
const { disk } = formatNezhaInfo(data)
useEffect(() => {
if (data && historyLoaded) {
const timestamp = Date.now().toString()
let newData = [] as diskChartData[]
if (diskChartData.length === 0) {
newData = [
{ timeStamp: timestamp, disk: disk },
{ timeStamp: timestamp, disk: disk },
]
} else {
newData = [...diskChartData, { timeStamp: timestamp, disk: disk }]
}
if (newData.length > MAX_HISTORY_LENGTH) {
newData.shift()
}
setDiskChartData(newData)
}
}, [data, historyLoaded])
const chartConfig = {
disk: {
label: "Disk",
},
} satisfies ChartConfig
return (
<Card>
<CardContent className="px-6 py-3">
<section className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="font-medium text-md">{t("Disk")}</p>
<section className="flex flex-col items-end gap-0.5">
<section className="flex items-center gap-2">
<p className="w-10 text-end font-medium text-xs">{disk.toFixed(0)}%</p>
<AnimatedCircularProgressBar
className="size-3 text-[0px]"
max={100}
min={0}
value={disk}
primaryColor="hsl(var(--chart-5))"
/>
</section>
<div className="flex items-center gap-2 font-medium text-[11px]">
{formatBytes(data.status.DiskUsed)} / {formatBytes(data.host.DiskTotal)}
</div>
</section>
</div>
<ChartContainer config={chartConfig} className="aspect-auto h-[130px] w-full">
<AreaChart
accessibilityLayer
data={diskChartData}
margin={{
top: 12,
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="timeStamp"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={200}
interval="preserveStartEnd"
tickFormatter={(value) => formatRelativeTime(value)}
/>
<YAxis
tickLine={false}
axisLine={false}
mirror={true}
tickMargin={-15}
domain={[0, 100]}
tickFormatter={(value) => `${value}%`}
/>
<Area
isAnimationActive={false}
dataKey="disk"
type="step"
fill="hsl(var(--chart-5))"
fillOpacity={0.3}
stroke="hsl(var(--chart-5))"
/>
</AreaChart>
</ChartContainer>
</section>
</CardContent>
</Card>
)
}
function NetworkChart({
data,
history,
}: {
data: NezhaAPISafe
history: ServerDataWithTimestamp[]
}) {
const t = useTranslations("ServerDetailChartClient")
const [networkChartData, setNetworkChartData] = useState([] as networkChartData[])
const hasInitialized = useRef(false)
const [historyLoaded, setHistoryLoaded] = useState(false)
useEffect(() => {
if (!hasInitialized.current && history.length > 0) {
const historyData = history
.map((msg) => {
const server = msg.data?.result?.find((item) => item.id === data.id)
if (!server) return null
const { up, down } = formatNezhaInfo(server)
return {
timeStamp: msg.timestamp.toString(),
upload: up,
download: down,
}
})
.filter((item): item is networkChartData => item !== null)
.reverse()
setNetworkChartData(historyData)
hasInitialized.current = true
setHistoryLoaded(true)
} else if (history.length === 0) {
setHistoryLoaded(true)
}
}, [])
const { up, down } = formatNezhaInfo(data)
useEffect(() => {
if (data && historyLoaded) {
const timestamp = Date.now().toString()
let newData = [] as networkChartData[]
if (networkChartData.length === 0) {
newData = [
{ timeStamp: timestamp, upload: up, download: down },
{ timeStamp: timestamp, upload: up, download: down },
]
} else {
newData = [...networkChartData, { timeStamp: timestamp, upload: up, download: down }]
}
if (newData.length > MAX_HISTORY_LENGTH) {
newData.shift()
}
setNetworkChartData(newData)
}
}, [data, historyLoaded])
let maxDownload = Math.max(...networkChartData.map((item) => item.download))
maxDownload = Math.ceil(maxDownload)
if (maxDownload < 1) {
maxDownload = 1
}
const chartConfig = {
upload: {
label: "Upload",
},
download: {
label: "Download",
},
} satisfies ChartConfig
return (
<Card>
<CardContent className="px-6 py-3">
<section className="flex flex-col gap-1">
<div className="flex items-center">
<section className="flex items-center gap-4">
<div className="flex w-20 flex-col">
<p className="text-muted-foreground text-xs">{t("Upload")}</p>
<div className="flex items-center gap-1">
<span className="relative inline-flex size-1.5 rounded-full bg-[hsl(var(--chart-1))]" />
<p className="font-medium text-xs">{up.toFixed(2)} M/s</p>
</div>
</div>
<div className="flex w-20 flex-col">
<p className=" text-muted-foreground text-xs">{t("Download")}</p>
<div className="flex items-center gap-1">
<span className="relative inline-flex size-1.5 rounded-full bg-[hsl(var(--chart-4))]" />
<p className="font-medium text-xs">{down.toFixed(2)} M/s</p>
</div>
</div>
</section>
</div>
<ChartContainer config={chartConfig} className="aspect-auto h-[130px] w-full">
<LineChart
accessibilityLayer
data={networkChartData}
margin={{
top: 12,
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="timeStamp"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={200}
interval="preserveStartEnd"
tickFormatter={(value) => formatRelativeTime(value)}
/>
<YAxis
tickLine={false}
axisLine={false}
mirror={true}
tickMargin={-15}
type="number"
minTickGap={50}
interval="preserveStartEnd"
domain={[1, maxDownload]}
tickFormatter={(value) => `${value.toFixed(0)}M/s`}
/>
<Line
isAnimationActive={false}
dataKey="upload"
type="linear"
stroke="hsl(var(--chart-1))"
strokeWidth={1}
dot={false}
/>
<Line
isAnimationActive={false}
dataKey="download"
type="linear"
stroke="hsl(var(--chart-4))"
strokeWidth={1}
dot={false}
/>
</LineChart>
</ChartContainer>
</section>
</CardContent>
</Card>
)
}
function ConnectChart({
data,
history,
}: {
data: NezhaAPISafe
history: ServerDataWithTimestamp[]
}) {
const [connectChartData, setConnectChartData] = useState([] as connectChartData[])
const hasInitialized = useRef(false)
const [historyLoaded, setHistoryLoaded] = useState(false)
useEffect(() => {
if (!hasInitialized.current && history.length > 0) {
const historyData = history
.map((msg) => {
const server = msg.data?.result?.find((item) => item.id === data.id)
if (!server) return null
const { tcp, udp } = formatNezhaInfo(server)
return {
timeStamp: msg.timestamp.toString(),
tcp: tcp,
udp: udp,
}
})
.filter((item): item is connectChartData => item !== null)
.reverse()
setConnectChartData(historyData)
hasInitialized.current = true
setHistoryLoaded(true)
} else if (history.length === 0) {
setHistoryLoaded(true)
}
}, [])
const { tcp, udp } = formatNezhaInfo(data)
useEffect(() => {
if (data && historyLoaded) {
const timestamp = Date.now().toString()
let newData = [] as connectChartData[]
if (connectChartData.length === 0) {
newData = [
{ timeStamp: timestamp, tcp: tcp, udp: udp },
{ timeStamp: timestamp, tcp: tcp, udp: udp },
]
} else {
newData = [...connectChartData, { timeStamp: timestamp, tcp: tcp, udp: udp }]
}
if (newData.length > MAX_HISTORY_LENGTH) {
newData.shift()
}
setConnectChartData(newData)
}
}, [data, historyLoaded])
const chartConfig = {
tcp: {
label: "TCP",
},
udp: {
label: "UDP",
},
} satisfies ChartConfig
return (
<Card>
<CardContent className="px-6 py-3">
<section className="flex flex-col gap-1">
<div className="flex items-center">
<section className="flex items-center gap-4">
<div className="flex w-12 flex-col">
<p className="text-muted-foreground text-xs">TCP</p>
<div className="flex items-center gap-1">
<span className="relative inline-flex size-1.5 rounded-full bg-[hsl(var(--chart-1))]" />
<p className="font-medium text-xs">{tcp}</p>
</div>
</div>
<div className="flex w-12 flex-col">
<p className=" text-muted-foreground text-xs">UDP</p>
<div className="flex items-center gap-1">
<span className="relative inline-flex size-1.5 rounded-full bg-[hsl(var(--chart-4))]" />
<p className="font-medium text-xs">{udp}</p>
</div>
</div>
</section>
</div>
<ChartContainer config={chartConfig} className="aspect-auto h-[130px] w-full">
<LineChart
accessibilityLayer
data={connectChartData}
margin={{
top: 12,
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="timeStamp"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={200}
interval="preserveStartEnd"
tickFormatter={(value) => formatRelativeTime(value)}
/>
<YAxis
tickLine={false}
axisLine={false}
mirror={true}
tickMargin={-15}
type="number"
interval="preserveStartEnd"
/>
<Line
isAnimationActive={false}
dataKey="tcp"
type="linear"
stroke="hsl(var(--chart-1))"
strokeWidth={1}
dot={false}
/>
<Line
isAnimationActive={false}
dataKey="udp"
type="linear"
stroke="hsl(var(--chart-4))"
strokeWidth={1}
dot={false}
/>
</LineChart>
</ChartContainer>
</section>
</CardContent>
</Card>
)
}

View File

@ -1,276 +0,0 @@
"use client"
import { useServerData } from "@/app/context/server-data-context"
import { BackIcon } from "@/components/Icon"
import ServerFlag from "@/components/ServerFlag"
import { ServerDetailLoading } from "@/components/loading/ServerDetailLoading"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card"
import { cn, formatBytes, formatNezhaInfo } from "@/lib/utils"
import countries from "i18n-iso-countries"
import enLocale from "i18n-iso-countries/langs/en.json"
import { useTranslations } from "next-intl"
import { notFound, useRouter } from "next/navigation"
import { useEffect, useState } from "react"
countries.registerLocale(enLocale)
export default function ServerDetailClient({
server_id,
}: {
server_id: number
}) {
const t = useTranslations("ServerDetailClient")
const router = useRouter()
const [hasHistory, setHasHistory] = useState(false)
useEffect(() => {
window.scrollTo({ top: 0, left: 0, behavior: "instant" })
}, [])
useEffect(() => {
const previousPath = sessionStorage.getItem("fromMainPage")
if (previousPath) {
setHasHistory(true)
}
}, [])
const linkClick = () => {
if (hasHistory) {
router.back()
} else {
router.push("/")
}
}
const { data: serverList, error, isLoading } = useServerData()
const serverData = serverList?.result?.find((item) => item.id === server_id)
if (!serverData && !isLoading) {
notFound()
}
if (error) {
return (
<>
<div className="flex flex-col items-center justify-center">
<p className="font-medium text-sm opacity-40">{error.message}</p>
<p className="font-medium text-sm opacity-40">{t("detail_fetch_error_message")}</p>
</div>
</>
)
}
if (!serverData) return <ServerDetailLoading />
const {
name,
online,
uptime,
version,
arch,
mem_total,
disk_total,
country_code,
platform,
platform_version,
cpu_info,
gpu_info,
load_1,
load_5,
load_15,
net_out_transfer,
net_in_transfer,
last_active_time_string,
boot_time_string,
} = formatNezhaInfo(serverData)
return (
<div>
<div
onClick={linkClick}
className="flex flex-none cursor-pointer items-center gap-0.5 break-all font-semibold text-xl leading-none tracking-tight transition-opacity duration-300 hover:opacity-50"
>
<BackIcon />
{name}
</div>
<section className="mt-3 flex flex-wrap gap-2">
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("status")}</p>
<Badge
className={cn(
"-mt-[0.3px] w-fit rounded-[6px] px-1 py-0 text-[9px] dark:text-white",
{
" bg-green-800": online,
" bg-red-600": !online,
},
)}
>
{online ? t("Online") : t("Offline")}
</Badge>
</section>
</CardContent>
</Card>
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("Uptime")}</p>
<div className="text-xs">
{" "}
{uptime / 86400 >= 1
? `${Math.floor(uptime / 86400)} ${t("Days")} ${Math.floor((uptime % 86400) / 3600)} ${t("Hours")}`
: `${Math.floor(uptime / 3600)} ${t("Hours")}`}
</div>
</section>
</CardContent>
</Card>
{version && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("Version")}</p>
<div className="text-xs">{version} </div>
</section>
</CardContent>
</Card>
)}
{arch && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("Arch")}</p>
<div className="text-xs">{arch} </div>
</section>
</CardContent>
</Card>
)}
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("Mem")}</p>
<div className="text-xs">{formatBytes(mem_total)}</div>
</section>
</CardContent>
</Card>
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("Disk")}</p>
<div className="text-xs">{formatBytes(disk_total)}</div>
</section>
</CardContent>
</Card>
{country_code && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("Region")}</p>
<section className="flex items-start gap-1">
<div className="text-start text-xs">{countries.getName(country_code, "en")}</div>
<ServerFlag className="-mt-[1px] text-[11px]" country_code={country_code} />
</section>
</section>
</CardContent>
</Card>
)}
</section>
<section className="mt-1 flex flex-wrap gap-2">
{platform && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("System")}</p>
<div className="text-xs">
{" "}
{platform} - {platform_version}{" "}
</div>
</section>
</CardContent>
</Card>
)}
{cpu_info && cpu_info.length > 0 && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("CPU")}</p>
<div className="text-xs"> {cpu_info.join(", ")}</div>
</section>
</CardContent>
</Card>
)}
{gpu_info && gpu_info.length > 0 && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{"GPU"}</p>
<div className="text-xs"> {gpu_info.join(", ")}</div>
</section>
</CardContent>
</Card>
)}
</section>
<section className="mt-1 flex flex-wrap gap-2">
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("Load")}</p>
<div className="text-xs">
{load_1 || "0.00"} / {load_5 || "0.00"} / {load_15 || "0.00"}
</div>
</section>
</CardContent>
</Card>
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("Upload")}</p>
{net_out_transfer ? (
<div className="text-xs"> {formatBytes(net_out_transfer)} </div>
) : (
<div className="text-xs">Unknown</div>
)}
</section>
</CardContent>
</Card>
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("Download")}</p>
{net_in_transfer ? (
<div className="text-xs"> {formatBytes(net_in_transfer)} </div>
) : (
<div className="text-xs">Unknown</div>
)}
</section>
</CardContent>
</Card>
</section>
<section className="mt-1 flex flex-wrap gap-2">
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("BootTime")}</p>
<div className="text-xs">{boot_time_string ? boot_time_string : "N/A"}</div>
</section>
</CardContent>
</Card>
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("LastActive")}</p>
<div className="text-xs">
{last_active_time_string ? last_active_time_string : "N/A"}
</div>
</section>
</CardContent>
</Card>
</section>
</div>
)
}

View File

@ -1,119 +0,0 @@
"use client"
import type { IPInfo } from "@/app/api/server-ip/route"
import { Loader } from "@/components/loading/Loader"
import { Card, CardContent } from "@/components/ui/card"
import { nezhaFetcher } from "@/lib/utils"
import { useTranslations } from "next-intl"
import useSWRImmutable from "swr/immutable"
export default function ServerIPInfo({ server_id }: { server_id: number }) {
const t = useTranslations("IPInfo")
const { data } = useSWRImmutable<IPInfo>(`/api/server-ip?server_id=${server_id}`, nezhaFetcher)
if (!data) {
return (
<div className="mb-11">
<Loader visible />
</div>
)
}
return (
<>
<section className="mb-4 flex flex-wrap gap-2">
{data.asn?.autonomous_system_organization && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{"ASN"}</p>
<div className="text-xs">{data.asn.autonomous_system_organization}</div>
</section>
</CardContent>
</Card>
)}
{data.asn?.autonomous_system_number && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("asn_number")}</p>
<div className="text-xs">AS{data.asn.autonomous_system_number}</div>
</section>
</CardContent>
</Card>
)}
{data.city?.registered_country?.names.en && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("registered_country")}</p>
<div className="text-xs">{data.city.registered_country?.names.en}</div>
</section>
</CardContent>
</Card>
)}
{data.city?.country?.iso_code && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{"ISO"}</p>
<div className="text-xs">{data.city.country?.iso_code}</div>
</section>
</CardContent>
</Card>
)}
{data.city?.city?.names.en && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("city")}</p>
<div className="text-xs">{data.city.city?.names.en}</div>
</section>
</CardContent>
</Card>
)}
{data.city?.location?.longitude && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("longitude")}</p>
<div className="text-xs">{data.city.location?.longitude}</div>
</section>
</CardContent>
</Card>
)}
{data.city?.location?.latitude && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("latitude")}</p>
<div className="text-xs">{data.city.location?.latitude}</div>
</section>
</CardContent>
</Card>
)}
{data.city?.location?.time_zone && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("time_zone")}</p>
<div className="text-xs">{data.city.location?.time_zone}</div>
</section>
</CardContent>
</Card>
)}
{data.city?.postal && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("postal_code")}</p>
<div className="text-xs">{data.city.postal?.code}</div>
</section>
</CardContent>
</Card>
)}
</section>
</>
)
}

View File

@ -1,62 +0,0 @@
"use client"
import GlobalInfo from "@/app/(main)/ClientComponents/main/GlobalInfo"
import { InteractiveMap } from "@/app/(main)/ClientComponents/main/InteractiveMap"
import { useServerData } from "@/app/context/server-data-context"
import { TooltipProvider } from "@/app/context/tooltip-context"
import GlobalLoading from "@/components/loading/GlobalLoading"
import { geoJsonString } from "@/lib/geo/geo-json-string"
export default function ServerGlobal() {
const { data: nezhaServerList, error } = useServerData()
if (error)
return (
<div className="flex flex-col items-center justify-center">
<p className="font-medium text-sm opacity-40">{error.message}</p>
</div>
)
if (!nezhaServerList) {
return <GlobalLoading />
}
const countryList: string[] = []
const serverCounts: { [key: string]: number } = {}
for (const server of nezhaServerList.result) {
if (server.host.CountryCode) {
const countryCode = server.host.CountryCode.toUpperCase()
if (!countryList.includes(countryCode)) {
countryList.push(countryCode)
}
serverCounts[countryCode] = (serverCounts[countryCode] || 0) + 1
}
}
const width = 900
const height = 500
const geoJson = JSON.parse(geoJsonString)
const filteredFeatures = geoJson.features.filter(
(feature: any) => feature.properties.iso_a3_eh !== "",
)
return (
<section className="mt-[3.2px] flex flex-col gap-4">
<GlobalInfo countries={countryList} />
<div className="w-full overflow-x-auto">
<TooltipProvider>
<InteractiveMap
countries={countryList}
serverCounts={serverCounts}
width={width}
height={height}
filteredFeatures={filteredFeatures}
nezhaServerList={nezhaServerList}
/>
</TooltipProvider>
</div>
</section>
)
}

View File

@ -1,18 +0,0 @@
"use client"
import { useTranslations } from "next-intl"
type GlobalInfoProps = {
countries: string[]
}
export default function GlobalInfo({ countries }: GlobalInfoProps) {
const t = useTranslations("Global")
return (
<section className="flex items-center justify-between">
<p className="font-medium text-sm opacity-40">
{t("Distributions")} {countries.length} {t("Regions")}
</p>
</section>
)
}

View File

@ -1,153 +0,0 @@
"use client"
import MapTooltip from "@/app/(main)/ClientComponents/main/MapTooltip"
import { useTooltip } from "@/app/context/tooltip-context"
import { countryCoordinates } from "@/lib/geo/geo-limit"
import { geoEquirectangular, geoPath } from "d3-geo"
interface InteractiveMapProps {
countries: string[]
serverCounts: { [key: string]: number }
width: number
height: number
filteredFeatures: any[]
nezhaServerList: any
}
export function InteractiveMap({
countries,
serverCounts,
width,
height,
filteredFeatures,
nezhaServerList,
}: InteractiveMapProps) {
const { setTooltipData } = useTooltip()
const projection = geoEquirectangular()
.scale(140)
.translate([width / 2, height / 2])
.rotate([-12, 0, 0])
const path = geoPath().projection(projection)
return (
<div className="relative aspect-[2/1] w-full" onMouseLeave={() => setTooltipData(null)}>
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
xmlns="http://www.w3.org/2000/svg"
className="h-auto w-full"
>
<title>Interactive Map</title>
<defs>
<pattern id="dots" width="2" height="2" patternUnits="userSpaceOnUse">
<circle cx="1" cy="1" r="0.5" fill="currentColor" />
</pattern>
</defs>
<g>
{/* Background rect to handle mouse events in empty areas */}
<rect
x="0"
y="0"
width={width}
height={height}
fill="transparent"
onMouseEnter={() => setTooltipData(null)}
/>
{filteredFeatures.map((feature, index) => {
const isHighlighted = countries.includes(feature.properties.iso_a2_eh)
const serverCount = serverCounts[feature.properties.iso_a2_eh] || 0
return (
<path
key={feature.properties.iso_a2_eh + String(index)}
d={path(feature) || ""}
className={
isHighlighted
? "cursor-pointer fill-green-700 transition-all hover:fill-green-600 dark:fill-green-900 dark:hover:fill-green-700"
: "fill-neutral-200/50 stroke-[0.5] stroke-neutral-300/40 dark:fill-neutral-800 dark:stroke-neutral-700"
}
onMouseEnter={() => {
if (!isHighlighted) {
setTooltipData(null)
return
}
if (path.centroid(feature)) {
const countryCode = feature.properties.iso_a2_eh
const countryServers = nezhaServerList.result
.filter(
(server: any) => server.host.CountryCode?.toUpperCase() === countryCode,
)
.map((server: any) => ({
id: server.id,
name: server.name,
status: server.online_status,
}))
setTooltipData({
centroid: path.centroid(feature),
country: feature.properties.name,
count: serverCount,
servers: countryServers,
})
}
}}
/>
)
})}
{/* 渲染不在 filteredFeatures 中的国家标记点 */}
{countries.map((countryCode) => {
// 检查该国家是否已经在 filteredFeatures 中
const isInFilteredFeatures = filteredFeatures.some(
(feature) => feature.properties.iso_a2_eh === countryCode,
)
// 如果已经在 filteredFeatures 中,跳过
if (isInFilteredFeatures) return null
// 获取国家的经纬度
const coords = countryCoordinates[countryCode]
if (!coords) return null
// 使用投影函数将经纬度转换为 SVG 坐标
const [x, y] = projection([coords.lng, coords.lat]) || [0, 0]
const serverCount = serverCounts[countryCode] || 0
return (
<g
key={countryCode}
onMouseEnter={() => {
const countryServers = nezhaServerList.result
.filter((server: any) => server.host.CountryCode?.toUpperCase() === countryCode)
.map((server: any) => ({
id: server.id,
name: server.name,
status: server.online_status,
}))
setTooltipData({
centroid: [x, y],
country: coords.name,
count: serverCount,
servers: countryServers,
})
}}
className="cursor-pointer"
>
<circle
cx={x}
cy={y}
r={4}
className="fill-sky-700 stroke-white transition-all hover:fill-sky-600 dark:fill-sky-900 dark:hover:fill-sky-700"
/>
</g>
)
})}
</g>
</svg>
<MapTooltip />
</div>
)
}

View File

@ -1,70 +0,0 @@
"use client"
import { useTooltip } from "@/app/context/tooltip-context"
import { useTranslations } from "next-intl"
import Link from "next/link"
import { memo } from "react"
const MapTooltip = memo(function MapTooltip() {
const { tooltipData } = useTooltip()
const t = useTranslations("Global")
if (!tooltipData) return null
const sortedServers = tooltipData.servers.sort((a, b) => {
return a.status === b.status ? 0 : a.status ? 1 : -1
})
const saveSession = () => {
sessionStorage.setItem("fromMainPage", "true")
}
return (
<div
className="tooltip-animate absolute z-50 hidden rounded bg-white px-2 py-1 text-sm shadow-lg lg:block dark:border dark:border-neutral-700 dark:bg-neutral-800"
key={tooltipData.country}
style={{
left: tooltipData.centroid[0],
top: tooltipData.centroid[1],
transform: "translate(10%, -50%)",
}}
onMouseEnter={(e) => {
e.stopPropagation()
}}
>
<div>
<p className="font-medium">
{tooltipData.country === "China" ? "Mainland China" : tooltipData.country}
</p>
<p className="mb-1 font-light text-neutral-600 text-xs dark:text-neutral-400">
{tooltipData.count} {t("Servers")}
</p>
</div>
<div
className="border-t pt-1 dark:border-neutral-700"
style={{
maxHeight: "200px",
overflowY: "auto",
}}
>
{sortedServers.map((server) => (
<Link
onClick={saveSession}
href={`/server/${server.id}`}
key={server.name}
className="flex items-center gap-1.5 py-0.5 text-neutral-500 transition-colors hover:text-black dark:text-neutral-400 dark:hover:text-white"
>
<span
className={`h-1.5 w-1.5 shrink-0 rounded-full ${
server.status ? "bg-green-500" : "bg-red-500"
}`}
/>
<span className="text-xs">{server.name}</span>
</Link>
))}
</div>
</div>
)
})
export default MapTooltip

View File

@ -1,222 +0,0 @@
"use client"
import { useFilter } from "@/app/context/network-filter-context"
import { useServerData } from "@/app/context/server-data-context"
import { useStatus } from "@/app/context/status-context"
import ServerCard from "@/components/ServerCard"
import ServerCardInline from "@/components/ServerCardInline"
import Switch from "@/components/Switch"
import GlobalLoading from "@/components/loading/GlobalLoading"
import { Loader } from "@/components/loading/Loader"
import getEnv from "@/lib/env-entry"
import { cn } from "@/lib/utils"
import { MapIcon, ViewColumnsIcon } from "@heroicons/react/20/solid"
import { useTranslations } from "next-intl"
import dynamic from "next/dynamic"
import { useEffect, useRef, useState } from "react"
const ServerGlobal = dynamic(() => import("./Global"), {
ssr: false,
loading: () => <GlobalLoading />,
})
const sortServersByDisplayIndex = (servers: any[]) => {
return servers.sort((a, b) => {
const displayIndexDiff = (b.display_index || 0) - (a.display_index || 0)
return displayIndexDiff !== 0 ? displayIndexDiff : a.id - b.id
})
}
const filterServersByStatus = (servers: any[], status: string) => {
return status === "all"
? servers
: servers.filter((server) => [status].includes(server.online_status ? "online" : "offline"))
}
const filterServersByTag = (servers: any[], tag: string, defaultTag: string) => {
return tag === defaultTag ? servers : servers.filter((server) => server.tag === tag)
}
const sortServersByNetwork = (servers: any[]) => {
return [...servers].sort((a, b) => {
if (!a.online_status && b.online_status) return 1
if (a.online_status && !b.online_status) return -1
if (!a.online_status && !b.online_status) return 0
return b.status.NetInSpeed + b.status.NetOutSpeed - (a.status.NetInSpeed + a.status.NetOutSpeed)
})
}
const getTagCounts = (servers: any[]) => {
return servers.reduce((acc: Record<string, number>, server) => {
if (server.tag) {
acc[server.tag] = (acc[server.tag] || 0) + 1
}
return acc
}, {})
}
const LoadingState = ({ t }: { t: any }) => (
<div className="flex min-h-96 flex-col items-center justify-center ">
<div className="flex items-center gap-2 font-semibold text-sm">
<Loader visible={true} />
{t("connecting")}...
</div>
</div>
)
const ErrorState = ({ error, t }: { error: Error; t: any }) => (
<div className="flex flex-col items-center justify-center">
<p className="font-medium text-sm opacity-40">{error.message}</p>
<p className="font-medium text-sm opacity-40">{t("error_message")}</p>
</div>
)
const ServerList = ({
servers,
inline,
containerRef,
}: { servers: any[]; inline: string; containerRef: any }) => {
if (inline === "1") {
return (
<section
ref={containerRef}
className="scrollbar-hidden flex flex-col gap-2 overflow-x-scroll"
>
{servers.map((serverInfo) => (
<ServerCardInline key={serverInfo.id} serverInfo={serverInfo} />
))}
</section>
)
}
return (
<section ref={containerRef} className="grid grid-cols-1 gap-2 md:grid-cols-2">
{servers.map((serverInfo) => (
<ServerCard key={serverInfo.id} serverInfo={serverInfo} />
))}
</section>
)
}
export default function ServerListClient() {
const { status } = useStatus()
const { filter } = useFilter()
const t = useTranslations("ServerListClient")
const containerRef = useRef<HTMLDivElement>(null)
const defaultTag = "defaultTag"
const [tag, setTag] = useState<string>(defaultTag)
const [showMap, setShowMap] = useState<boolean>(false)
const [inline, setInline] = useState<string>("0")
useEffect(() => {
const inlineState = localStorage.getItem("inline")
if (inlineState !== null) {
setInline(inlineState)
}
const showMapState = localStorage.getItem("showMap")
if (showMapState !== null) {
setShowMap(showMapState === "true")
}
const savedTag = sessionStorage.getItem("selectedTag") || defaultTag
setTag(savedTag)
restoreScrollPosition()
}, [])
const handleTagChange = (newTag: string) => {
setTag(newTag)
sessionStorage.setItem("selectedTag", newTag)
sessionStorage.setItem("scrollPosition", String(containerRef.current?.scrollTop || 0))
}
const restoreScrollPosition = () => {
const savedPosition = sessionStorage.getItem("scrollPosition")
if (savedPosition && containerRef.current) {
containerRef.current.scrollTop = Number(savedPosition)
}
}
useEffect(() => {
const handleRouteChange = () => {
restoreScrollPosition()
}
window.addEventListener("popstate", handleRouteChange)
return () => {
window.removeEventListener("popstate", handleRouteChange)
}
}, [])
const { data, error } = useServerData()
if (error) return <ErrorState error={error} t={t} />
if (!data?.result) return <LoadingState t={t} />
const { result } = data
const sortedServers = sortServersByDisplayIndex(result)
const filteredServersByStatus = filterServersByStatus(sortedServers, status)
const allTag = filteredServersByStatus.map((server) => server.tag).filter(Boolean)
const uniqueTags = [...new Set(allTag)]
uniqueTags.unshift(defaultTag)
let filteredServers = filterServersByTag(filteredServersByStatus, tag, defaultTag)
if (filter) {
filteredServers = sortServersByNetwork(filteredServers)
}
const tagCountMap = getTagCounts(filteredServersByStatus)
return (
<>
<section className="flex w-full items-center gap-2 overflow-hidden">
<button
type="button"
onClick={() => {
const newShowMap = !showMap
setShowMap(newShowMap)
localStorage.setItem("showMap", String(newShowMap))
}}
className={cn(
"inset-shadow-2xs inset-shadow-white/20 flex cursor-pointer flex-col items-center gap-0 rounded-[50px] bg-blue-100 p-[10px] text-blue-600 transition-all dark:bg-blue-900 dark:text-blue-100 ",
{
"inset-shadow-black/20 bg-blue-600 text-white dark:bg-blue-100 dark:text-blue-600":
showMap,
},
)}
>
<MapIcon className="size-[13px]" />
</button>
<button
type="button"
onClick={() => {
const newInline = inline === "0" ? "1" : "0"
setInline(newInline)
localStorage.setItem("inline", newInline)
}}
className={cn(
"inset-shadow-2xs inset-shadow-white/20 flex cursor-pointer flex-col items-center gap-0 rounded-[50px] bg-blue-100 p-[10px] text-blue-600 transition-all dark:bg-blue-900 dark:text-blue-100 ",
{
"inset-shadow-black/20 bg-blue-600 text-white dark:bg-blue-100 dark:text-blue-600":
inline === "1",
},
)}
>
<ViewColumnsIcon className="size-[13px]" />
</button>
{getEnv("NEXT_PUBLIC_ShowTag") === "true" && (
<Switch
allTag={uniqueTags}
nowTag={tag}
tagCountMap={tagCountMap}
onTagChange={handleTagChange}
/>
)}
</section>
{showMap && <ServerGlobal />}
<ServerList servers={filteredServers} inline={inline} containerRef={containerRef} />
</>
)
}

File diff suppressed because one or more lines are too long

View File

@ -1,58 +0,0 @@
"use client"
import pack from "@/package.json"
import { useTranslations } from "next-intl"
import { useEffect, useState } from "react"
const GITHUB_URL = "https://github.com/hamster1963/nezha-dash"
const PERSONAL_URL = "https://buycoffee.top"
type LinkProps = {
href: string
children: React.ReactNode
}
const FooterLink = ({ href, children }: LinkProps) => (
<a
href={href}
target="_blank"
className="cursor-pointer font-normal underline decoration-2 decoration-yellow-500 underline-offset-2 transition-colors hover:decoration-yellow-600 dark:decoration-yellow-500/60 dark:hover:decoration-yellow-500/80"
rel="noreferrer"
>
{children}
</a>
)
const baseTextStyles =
"text-[13px] font-light tracking-tight text-neutral-600/50 dark:text-neutral-300/50"
export default function Footer() {
const t = useTranslations("Footer")
const version = pack.version
const currentYear = new Date().getFullYear()
const [isMac, setIsMac] = useState(true)
useEffect(() => {
setIsMac(/macintosh|mac os x/i.test(navigator.userAgent))
}, [])
return (
<footer className="mx-auto flex w-full max-w-5xl items-center justify-between">
<section className="flex flex-col">
<p className={`mt-3 flex gap-1 ${baseTextStyles}`}>
{t("p_146-598_Findthecodeon")}{" "}
<FooterLink href={GITHUB_URL}>{t("a_303-585_GitHub")}</FooterLink>
<FooterLink href={`${GITHUB_URL}/releases/tag/v${version}`}>v{version}</FooterLink>
</p>
<section className={`mt-1 flex items-center gap-2 ${baseTextStyles}`}>
{t("section_607-869_2020")}
{currentYear} <FooterLink href={PERSONAL_URL}>{t("a_800-850_Hamster1963")}</FooterLink>
</section>
</section>
<p className={`mt-1 ${baseTextStyles}`}>
<kbd className="pointer-events-none mx-1 inline-flex h-4 select-none items-center gap-1 rounded border bg-muted px-1.5 font-medium font-mono text-[10px] text-muted-foreground opacity-100">
{isMac ? <span className="text-xs"></span> : "Ctrl "}K
</kbd>
</p>
</footer>
)
}

View File

@ -1,162 +0,0 @@
"use client"
import AnimateCountClient from "@/components/AnimatedCount"
import { LanguageSwitcher } from "@/components/LanguageSwitcher"
import { ModeToggle } from "@/components/ThemeSwitcher"
import { Separator } from "@/components/ui/separator"
import { Skeleton } from "@/components/ui/skeleton"
import getEnv from "@/lib/env-entry"
import { DateTime } from "luxon"
import { useTranslations } from "next-intl"
import { useRouter } from "next/navigation"
import { memo, useCallback, useEffect, useState } from "react"
interface TimeState {
hh: number
mm: number
ss: number
}
interface CustomLink {
link: string
name: string
}
const useCurrentTime = () => {
const [time, setTime] = useState<TimeState>({
hh: DateTime.now().setLocale("en-US").hour,
mm: DateTime.now().setLocale("en-US").minute,
ss: DateTime.now().setLocale("en-US").second,
})
useEffect(() => {
const intervalId = setInterval(() => {
const now = DateTime.now().setLocale("en-US")
setTime({
hh: now.hour,
mm: now.minute,
ss: now.second,
})
}, 1000)
return () => clearInterval(intervalId)
}, [])
return time
}
const Links = memo(function Links() {
const linksEnv = getEnv("NEXT_PUBLIC_Links")
const links: CustomLink[] | null = linksEnv ? JSON.parse(linksEnv) : null
if (!links) return null
return (
<div className="flex items-center gap-2">
{links.map((link) => (
<a
key={link.link}
href={link.link}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 font-medium text-sm opacity-50 transition-opacity hover:opacity-100"
>
{link.name}
</a>
))}
</div>
)
})
const Overview = memo(function Overview() {
const t = useTranslations("Overview")
const time = useCurrentTime()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
return (
<section className={"mt-10 flex flex-col md:mt-16"}>
<p className="font-semibold text-base">{t("p_2277-2331_Overview")}</p>
<div className="flex items-center gap-1">
<p className="font-medium text-sm opacity-50">{t("p_2390-2457_wherethetimeis")}</p>
{mounted ? (
<div className="flex items-center font-medium text-sm">
<AnimateCountClient count={time.hh} minDigits={2} />
<span className="mb-[1px] font-medium text-sm opacity-50">:</span>
<AnimateCountClient count={time.mm} minDigits={2} />
<span className="mb-[1px] font-medium text-sm opacity-50">:</span>
<span className="font-medium text-sm">
<AnimateCountClient count={time.ss} minDigits={2} />
</span>
</div>
) : (
<Skeleton className="h-[21px] w-16 animate-none rounded-[5px] bg-muted-foreground/10" />
)}
</div>
</section>
)
})
function Header() {
const t = useTranslations("Header")
const customLogo = getEnv("NEXT_PUBLIC_CustomLogo")
const customTitle = getEnv("NEXT_PUBLIC_CustomTitle")
const customDescription = getEnv("NEXT_PUBLIC_CustomDescription")
const router = useRouter()
const handleLogoClick = useCallback(() => {
sessionStorage.removeItem("selectedTag")
router.push("/")
}, [router])
return (
<div className="mx-auto w-full max-w-5xl">
<section className="flex items-center justify-between">
<section
onClick={handleLogoClick}
className="flex cursor-pointer items-center font-medium text-base transition-opacity duration-300 hover:opacity-50"
>
<div className="mr-1 flex flex-row items-center justify-start">
<img
width={40}
height={40}
alt="apple-touch-icon"
src={customLogo ? customLogo : "/apple-touch-icon.png"}
className="relative m-0! h-6 w-6 border-2 border-transparent object-cover object-top p-0! dark:hidden"
/>
<img
width={40}
height={40}
alt="apple-touch-icon"
src={customLogo ? customLogo : "/apple-touch-icon-dark.png"}
className="relative m-0! hidden h-6 w-6 border-2 border-transparent object-cover object-top p-0! dark:block"
/>
</div>
{customTitle ? customTitle : "NezhaDash"}
<Separator orientation="vertical" className="mx-2 hidden h-4 w-[1px] md:block" />
<p className="hidden font-medium text-sm opacity-40 md:block">
{customDescription ? customDescription : t("p_1079-1199_Simpleandbeautifuldashbo")}
</p>
</section>
<section className="flex items-center gap-2">
<div className="hidden sm:block">
<Links />
</div>
<LanguageSwitcher />
<ModeToggle />
</section>
</section>
<div className="mt-1 flex w-full justify-end sm:hidden">
<Links />
</div>
<Overview />
</div>
)
}
export default Header

View File

@ -1,38 +0,0 @@
import Footer from "@/app/(main)/footer"
import Header from "@/app/(main)/header"
import { ServerDataProvider } from "@/app/context/server-data-context"
import { auth } from "@/auth"
import { DashCommand } from "@/components/DashCommand"
import { SignIn } from "@/components/SignIn"
import getEnv from "@/lib/env-entry"
import type React from "react"
type DashboardProps = {
children: React.ReactNode
}
export default function MainLayout({ children }: DashboardProps) {
return (
<div className="flex min-h-screen w-full flex-col">
<main className="flex min-h-[calc(100vh-calc(var(--spacing)*16))] flex-1 flex-col gap-4 bg-background p-4 md:p-10 md:pt-8">
<Header />
<AuthProtected>
<ServerDataProvider>
{children}
<DashCommand />
</ServerDataProvider>
</AuthProtected>
<Footer />
</main>
</div>
)
}
async function AuthProtected({ children }: DashboardProps) {
if (getEnv("SitePassword")) {
const session = await auth()
if (!session) {
return <SignIn />
}
}
return children
}

View File

@ -1,11 +0,0 @@
import ServerListClient from "@/app/(main)/ClientComponents/main/ServerListClient"
import ServerOverviewClient from "@/app/(main)/ClientComponents/main/ServerOverviewClient"
export default async function Home() {
return (
<div className="mx-auto grid w-full max-w-5xl gap-4 md:gap-6">
<ServerOverviewClient />
<ServerListClient />
</div>
)
}

View File

@ -1,53 +0,0 @@
"use client"
import { NetworkChartClient } from "@/app/(main)/ClientComponents/detail/NetworkChart"
import ServerDetailChartClient from "@/app/(main)/ClientComponents/detail/ServerDetailChartClient"
import ServerDetailClient from "@/app/(main)/ClientComponents/detail/ServerDetailClient"
import ServerIPInfo from "@/app/(main)/ClientComponents/detail/ServerIPInfo"
import TabSwitch from "@/components/TabSwitch"
import { Separator } from "@/components/ui/separator"
import getEnv from "@/lib/env-entry"
import { use, useState } from "react"
type PageProps = {
params: Promise<{ id: string }>
}
type TabType = "Detail" | "Network"
export default function Page({ params }: PageProps) {
const { id } = use(params)
const serverId = Number(id)
const tabs: TabType[] = ["Detail", "Network"]
const [currentTab, setCurrentTab] = useState<TabType>(tabs[0])
const tabContent = {
Detail: <ServerDetailChartClient server_id={serverId} show={currentTab === "Detail"} />,
Network: (
<>
{getEnv("NEXT_PUBLIC_ShowIpInfo") && <ServerIPInfo server_id={serverId} />}
<NetworkChartClient server_id={serverId} show={currentTab === "Network"} />
</>
),
}
return (
<main className="mx-auto grid w-full max-w-5xl gap-2">
<ServerDetailClient server_id={serverId} />
<nav className="my-2 flex w-full items-center">
<Separator className="flex-1" />
<div className="flex w-full max-w-[200px] justify-center">
<TabSwitch
tabs={tabs}
currentTab={currentTab}
setCurrentTab={(tab: string) => setCurrentTab(tab as TabType)}
/>
</div>
<Separator className="flex-1" />
</nav>
{tabContent[currentTab]}
</main>
)
}

View File

@ -0,0 +1,258 @@
"use client";
import * as React from "react";
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import useSWR from "swr";
import { NezhaAPIMonitor, ServerMonitorChart } from "../../types/nezha-api";
import { formatTime, nezhaFetcher } from "@/lib/utils";
import { formatRelativeTime } from "@/lib/utils";
import { BackIcon } from "@/components/Icon";
import { useRouter } from "next/navigation";
import { useLocale } from "next-intl";
import { useTranslations } from "next-intl";
import NetworkChartLoading from "./NetworkChartLoading";
interface ResultItem {
created_at: number;
[key: string]: number;
}
export function NetworkChartClient({ server_id }: { server_id: number }) {
const t = useTranslations("NetworkChartClient");
const { data, error } = useSWR<NezhaAPIMonitor[]>(
`/api/monitor?server_id=${server_id}`,
nezhaFetcher,
);
if (error)
return (
<div className="flex flex-col items-center justify-center">
<p className="text-sm font-medium opacity-40">{error.message}</p>
</div>
);
if (!data) return <NetworkChartLoading />;
function transformData(data: NezhaAPIMonitor[]) {
const monitorData: ServerMonitorChart = {};
data.forEach((item) => {
const monitorName = item.monitor_name;
if (!monitorData[monitorName]) {
monitorData[monitorName] = [];
}
for (let i = 0; i < item.created_at.length; i++) {
monitorData[monitorName].push({
created_at: item.created_at[i],
avg_delay: item.avg_delay[i],
});
}
});
return monitorData;
}
const formatData = (rawData: NezhaAPIMonitor[]) => {
const result: { [time: number]: ResultItem } = {};
// 遍历每个监控项
rawData.forEach((item) => {
const { monitor_name, created_at, avg_delay } = item;
created_at.forEach((time, index) => {
if (!result[time]) {
result[time] = { created_at: time };
}
result[time][monitor_name] = parseFloat(avg_delay[index].toFixed(2));
});
});
return Object.values(result).sort((a, b) => a.created_at - b.created_at);
};
const transformedData = transformData(data);
const formattedData = formatData(data);
const initChartConfig = {
avg_delay: {
label: t("avg_delay"),
},
} satisfies ChartConfig;
const chartDataKey = Object.keys(transformedData);
return (
<NetworkChart
chartDataKey={chartDataKey}
chartConfig={initChartConfig}
chartData={transformedData}
serverName={data[0].server_name}
formattedData={formattedData}
/>
);
}
export function NetworkChart({
chartDataKey,
chartConfig,
chartData,
serverName,
formattedData,
}: {
chartDataKey: string[];
chartConfig: ChartConfig;
chartData: ServerMonitorChart;
serverName: string;
formattedData: ResultItem[];
}) {
const t = useTranslations("NetworkChart");
const router = useRouter();
const locale = useLocale();
const defaultChart = "All";
const [activeChart, setActiveChart] = React.useState(defaultChart);
const handleButtonClick = (chart: string) => {
if (chart === activeChart) {
setActiveChart(defaultChart);
} else {
setActiveChart(chart);
}
};
const getColorByIndex = (chart: string) => {
const index = chartDataKey.indexOf(chart);
return `hsl(var(--chart-${(index % 10) + 1}))`;
};
return (
<Card>
<CardHeader className="flex flex-col items-stretch space-y-0 p-0 sm:flex-row">
<div className="flex flex-none flex-col justify-center gap-1 border-b px-6 py-4">
<CardTitle
onClick={() => {
router.push(`/${locale}/`);
}}
className="flex flex-none cursor-pointer items-center gap-0.5 text-xl"
>
<BackIcon />
{serverName}
</CardTitle>
<CardDescription className="text-xs">
{chartDataKey.length} {t("ServerMonitorCount")}
</CardDescription>
</div>
<div className="flex flex-wrap">
{chartDataKey.map((key) => {
return (
<button
key={key}
data-active={activeChart === key}
className={`relative z-30 flex flex-1 flex-col justify-center gap-1 border-b px-6 py-4 text-left data-[active=true]:bg-muted/50 sm:border-l sm:border-t-0 sm:px-6`}
onClick={() => handleButtonClick(key)}
>
<span className="whitespace-nowrap text-xs text-muted-foreground">
{key}
</span>
<span className="text-md font-bold leading-none sm:text-lg">
{chartData[key][chartData[key].length - 1].avg_delay.toFixed(
2,
)}
ms
</span>
</button>
);
})}
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<LineChart
accessibilityLayer
data={
activeChart === defaultChart
? formattedData
: chartData[activeChart]
}
margin={{
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="created_at"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => formatRelativeTime(value)}
/>
<YAxis
tickLine={false}
axisLine={false}
mirror={true}
tickMargin={-15}
minTickGap={20}
tickFormatter={(value) => `${value}ms`}
/>
<ChartTooltip
content={
<ChartTooltipContent
indicator={"dot"}
className="gap-2"
labelKey="created_at"
labelClassName="text-muted-foreground"
labelFormatter={(_, payload) => {
return formatTime(payload[0].payload.created_at);
}}
/>
}
/>
{activeChart !== defaultChart && (
<Line
isAnimationActive={false}
strokeWidth={2}
type="linear"
dot={false}
dataKey="avg_delay"
stroke={getColorByIndex(activeChart)}
/>
)}
{activeChart === defaultChart &&
chartDataKey.map((key) => (
<Line
key={key}
isAnimationActive={false}
strokeWidth={2}
type="linear"
dot={false}
dataKey={key}
stroke={getColorByIndex(key)}
/>
))}
</LineChart>
</ChartContainer>
</CardContent>
</Card>
);
}

View File

@ -1,5 +1,6 @@
import { Loader } from "@/components/loading/Loader"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { BackIcon } from "@/components/Icon";
import { Loader } from "@/components/loading/Loader";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export default function NetworkChartLoading() {
return (
@ -7,17 +8,18 @@ export default function NetworkChartLoading() {
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row">
<div className="flex flex-1 flex-col justify-center gap-1 px-6 py-5">
<CardTitle className="flex items-center gap-0.5 text-xl">
<div className="aspect-auto h-[20px] w-24 bg-muted" />
<BackIcon />
<div className="aspect-auto h-[20px] w-24 bg-muted"></div>
</CardTitle>
<div className="mt-[2px] aspect-auto h-[14px] w-32 bg-muted" />
<div className="mt-[2px] aspect-auto h-[14px] w-32 bg-muted"></div>
</div>
<div className="hidden pt-4 pr-4 sm:block">
<div className="hidden pr-4 pt-4 sm:block">
<Loader visible={true} />
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6">
<div className="aspect-auto h-[250px] w-full" />
<div className="aspect-auto h-[250px] w-full"></div>
</CardContent>
</Card>
)
);
}

View File

@ -0,0 +1,59 @@
"use client";
import { ServerApi } from "../../types/nezha-api";
import ServerCard from "../../../../components/ServerCard";
import { nezhaFetcher } from "../../../../lib/utils";
import useSWR from "swr";
import getEnv from "../../../../lib/env-entry";
import Switch from "@/components/Switch";
import { useState } from "react";
import { useTranslations } from "next-intl";
export default function ServerListClient() {
const t = useTranslations("ServerListClient");
const [tag, setTag] = useState<string>(t("defaultTag"));
const { data, error } = useSWR<ServerApi>("/api/server", nezhaFetcher, {
refreshInterval: Number(getEnv("NEXT_PUBLIC_NezhaFetchInterval")) || 2000,
});
if (error)
return (
<div className="flex flex-col items-center justify-center">
<p className="text-sm font-medium opacity-40">{error.message}</p>
<p className="text-sm font-medium opacity-40">{t("error_message")}</p>
</div>
);
if (!data) return null;
const { result } = data;
const sortedServers = result.sort((a, b) => {
const displayIndexDiff = (b.display_index || 0) - (a.display_index || 0);
if (displayIndexDiff !== 0) return displayIndexDiff;
return a.id - b.id;
});
const allTag = sortedServers.map((server) => server.tag).filter((tag) => tag);
const uniqueTags = [...new Set(allTag)];
uniqueTags.unshift(t("defaultTag"));
const filteredServers =
tag === t("defaultTag")
? sortedServers
: sortedServers.filter((server) => server.tag === tag);
return (
<>
{getEnv("NEXT_PUBLIC_ShowTag") === "true" && uniqueTags.length > 1 && (
<Switch allTag={uniqueTags} nowTag={tag} setTag={setTag} />
)}
<section className="grid grid-cols-1 gap-2 md:grid-cols-2">
{filteredServers.map((serverInfo) => (
<ServerCard key={serverInfo.id} serverInfo={serverInfo} />
))}
</section>
</>
);
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,9 @@
import { NetworkChartClient } from "../ClientComponents/NetworkChart";
export default function Page({ params }: { params: { id: string } }) {
return (
<div className="mx-auto grid w-full max-w-5xl gap-4 md:gap-6">
<NetworkChartClient server_id={Number(params.id)} />
</div>
);
}

View File

@ -0,0 +1,25 @@
import { useTranslations } from "next-intl";
export default function Footer() {
const t = useTranslations("Footer");
return (
<footer className="mx-auto w-full max-w-5xl">
<section className="flex flex-col">
<p className="mt-3 flex gap-1 text-[13px] font-light tracking-tight text-neutral-600/50 dark:text-neutral-300/50">
{t("p_146-598_Findthecodeon")}{" "}
<a
href="https://github.com/hamster1963/nezha-dash"
target="_blank"
className="cursor-pointer font-normal underline decoration-yellow-500 decoration-2 underline-offset-2 dark:decoration-yellow-500/50"
>
{t("a_303-585_GitHub")}
</a>
</p>
<section className="mt-1 flex items-center gap-2 text-[13px] font-light tracking-tight text-neutral-600/50 dark:text-neutral-300/50">
{t("section_607-869_2020")}
{new Date().getFullYear()}{" "}
<a href={"https://buycoffee.top"}>{t("a_800-850_Hamster1963")}</a>
</section>
</section>
</footer>
);
}

View File

@ -0,0 +1,87 @@
"use client";
import { useTranslations } from "next-intl";
import React, { useEffect, useRef, useState } from "react";
import Image from "next/image";
import { Separator } from "../../../components/ui/separator";
import { DateTime } from "luxon";
import { ModeToggle } from "../../../components/ThemeSwitcher";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
function Header() {
const t = useTranslations("Header");
return (
<div className="mx-auto w-full max-w-5xl">
<section className="flex items-center justify-between">
<section className="flex items-center text-base font-medium">
<div className="mr-1 flex flex-row items-center justify-start">
<Image
width={40}
height={40}
unoptimized
alt="apple-touch-icon"
src={"/apple-touch-icon.png"}
className="relative !m-0 h-6 w-6 border-2 border-white object-cover object-top !p-0 transition duration-500 group-hover:z-30 group-hover:scale-105"
/>
</div>
NezhaDash
<Separator
orientation="vertical"
className="mx-2 hidden h-4 w-[1px] md:block"
/>
<p className="hidden text-sm font-medium opacity-40 md:block">
{t("p_1079-1199_Simpleandbeautifuldashbo")}
</p>
</section>
<section className="flex items-center gap-2">
<LanguageSwitcher />
<ModeToggle />
</section>
</section>
<Overview />
</div>
);
}
// https://github.com/streamich/react-use/blob/master/src/useInterval.ts
const useInterval = (callback: Function, delay?: number | null) => {
const savedCallback = useRef<Function>(() => {});
useEffect(() => {
savedCallback.current = callback;
});
useEffect(() => {
if (delay !== null) {
const interval = setInterval(() => savedCallback.current(), delay || 0);
return () => clearInterval(interval);
}
return undefined;
}, [delay]);
};
function Overview() {
const t = useTranslations("Overview");
const [mouted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const timeOption = DateTime.TIME_SIMPLE;
timeOption.hour12 = true;
const [timeString, setTimeString] = useState(
DateTime.now().setLocale("en-US").toLocaleString(timeOption),
);
useInterval(() => {
setTimeString(DateTime.now().setLocale("en-US").toLocaleString(timeOption));
}, 1000);
return (
<section className={"mt-10 flex flex-col md:mt-16"}>
<p className="text-base font-semibold">{t("p_2277-2331_Overview")}</p>
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium opacity-50">
{t("p_2390-2457_wherethetimeis")}
</p>
{mouted && (
<p className="opacity-1 text-sm font-medium">{timeString}</p>
)}
</div>
</section>
);
}
export default Header;

View File

@ -0,0 +1,18 @@
import React from "react";
import Header from "@/app/[locale]/(main)/header";
import Footer from "./footer";
type DashboardProps = {
children: React.ReactNode;
};
export default function MainLayout({ children }: DashboardProps) {
return (
<div className="flex min-h-screen w-full flex-col">
<main className="flex min-h-[calc(100vh_-_theme(spacing.16))] flex-1 flex-col gap-4 bg-muted/40 p-4 md:p-10 md:pt-8">
<Header />
{children}
<Footer />
</main>
</div>
);
}

View File

@ -0,0 +1,17 @@
import { unstable_setRequestLocale } from "next-intl/server";
import ServerList from "../../../components/ServerList";
import ServerOverview from "../../../components/ServerOverview";
export default function Home({
params: { locale },
}: {
params: { locale: string };
}) {
unstable_setRequestLocale(locale);
return (
<div className="mx-auto grid w-full max-w-5xl gap-4 md:gap-6">
<ServerOverview />
<ServerList />
</div>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 B

78
app/[locale]/layout.tsx Normal file
View File

@ -0,0 +1,78 @@
// @auto-i18n-check. Please do not delete the line.
import "@/styles/globals.css";
import "/node_modules/flag-icons/css/flag-icons.min.css";
import React from "react";
import { NextIntlClientProvider, useMessages } from "next-intl";
import { PublicEnvScript } from "next-runtime-env";
import type { Metadata } from "next";
import { Inter as FontSans } from "next/font/google";
import { ThemeProvider } from "next-themes";
import { Viewport } from "next";
import { cn } from "@/lib/utils";
import { locales } from "@/i18n-metadata";
import { unstable_setRequestLocale } from "next-intl/server";
const fontSans = FontSans({
subsets: ["latin"],
variable: "--font-sans",
});
export const metadata: Metadata = {
manifest: "/manifest.json",
title: "NezhaDash",
description: "A dashboard for nezha",
appleWebApp: {
capable: true,
title: "NezhaDash",
statusBarStyle: "black-translucent",
},
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
};
export async function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
export default function LocaleLayout({
children,
params: { locale },
}: {
children: React.ReactNode;
params: { locale: string };
}) {
unstable_setRequestLocale(locale);
const messages = useMessages();
return (
<html lang={locale} suppressHydrationWarning>
<head>
<PublicEnvScript />
</head>
<body
className={cn(
"min-h-screen bg-background font-sans antialiased",
fontSans.variable,
)}
>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<NextIntlClientProvider locale={locale} messages={messages}>
{children}
</NextIntlClientProvider>
</ThemeProvider>
</body>
</html>
);
}

View File

@ -0,0 +1,30 @@
import { useTranslations } from "next-intl";
import Image from "next/image";
import Link from "next/link";
export default function NotFoundPage() {
const t = useTranslations("NotFoundPage");
return (
<main className="relative h-screen w-full">
<div className="absolute inset-0 m-4 flex items-center justify-center">
<Image
priority
className="rounded-3xl object-cover"
src="/tardis.jpg"
fill={true}
alt="TARDIS"
/>
<div className="text-container absolute right-4 p-4 md:right-20">
<h1 className="text-2xl font-bold opacity-80 md:text-5xl">
{t("h1_490-590_404NotFound")}
</h1>
<p className="text-lg opacity-60 md:text-base">
{t("p_601-665_TARDISERROR")}
</p>
<Link href={"/"} className="text-2xl opacity-80 md:text-3xl">
{t("Link_676-775_Doctor")}
</Link>
</div>
</div>
</main>
);
}

View File

@ -0,0 +1,73 @@
export type ServerApi = {
live_servers: number;
offline_servers: number;
total_bandwidth: number;
result: NezhaAPISafe[];
};
export type NezhaAPISafe = Omit<NezhaAPI, "ipv4" | "ipv6" | "valid_ip">;
export interface NezhaAPI {
id: number;
name: string;
tag: string;
last_active: number;
online_status: boolean;
ipv4: string;
ipv6: string;
valid_ip: string;
display_index: number;
host: NezhaAPIHost;
status: NezhaAPIStatus;
}
export interface NezhaAPIHost {
Platform: string;
PlatformVersion: string;
CPU: string[];
MemTotal: number;
DiskTotal: number;
SwapTotal: number;
Arch: string;
Virtualization: string;
BootTime: number;
CountryCode: string;
Version: string;
GPU: null;
}
export interface NezhaAPIStatus {
CPU: number;
MemUsed: number;
SwapUsed: number;
DiskUsed: number;
NetInTransfer: number;
NetOutTransfer: number;
NetInSpeed: number;
NetOutSpeed: number;
Uptime: number;
Load1: number;
Load5: number;
Load15: number;
TcpConnCount: number;
UdpConnCount: number;
ProcessCount: number;
Temperatures: number;
GPU: number;
}
export type ServerMonitorChart = {
[key: string]: {
created_at: number;
avg_delay: number;
}[];
};
export interface NezhaAPIMonitor {
monitor_id: number;
monitor_name: string;
server_id: number;
server_name: string;
created_at: number[];
avg_delay: number[];
}

View File

@ -0,0 +1,2 @@
export type MakeOptional<T, K extends keyof T> = Omit<T, K> &
Partial<Pick<T, K>>;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View File

@ -1,3 +0,0 @@
import { handlers } from "@/auth"
export const { GET, POST } = handlers

View File

@ -1,44 +0,0 @@
import { auth } from "@/auth"
import getEnv from "@/lib/env-entry"
import { GetServerDetail } from "@/lib/serverFetch"
import { redirect } from "next/navigation"
import { type NextRequest, NextResponse } from "next/server"
export const dynamic = "force-dynamic"
interface ResError extends Error {
statusCode: number
message: string
}
export async function GET(req: NextRequest) {
if (getEnv("SitePassword")) {
const session = await auth()
if (!session) {
redirect("/")
}
}
const { searchParams } = new URL(req.url)
const server_id = searchParams.get("server_id")
if (!server_id) {
return NextResponse.json({ error: "server_id is required" }, { status: 400 })
}
try {
const serverIdNum = Number.parseInt(server_id, 10)
if (Number.isNaN(serverIdNum)) {
return NextResponse.json({ error: "server_id must be a valid number" }, { status: 400 })
}
const detailData = await GetServerDetail({ server_id: serverIdNum })
return NextResponse.json(detailData, { status: 200 })
} catch (error) {
const err = error as ResError
console.error("Error in GET handler:", err)
const statusCode = err.statusCode || 500
const message = err.message || "Internal Server Error"
return NextResponse.json({ error: message }, { status: statusCode })
}
}

View File

@ -1,46 +1,27 @@
import { auth } from "@/auth"
import getEnv from "@/lib/env-entry"
import { GetServerMonitor } from "@/lib/serverFetch"
import { redirect } from "next/navigation"
import { type NextRequest, NextResponse } from "next/server"
import { ServerMonitorChart } from "@/app/[locale]/types/nezha-api";
import { GetServerMonitor } from "@/lib/serverFetch";
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic"
interface ResError extends Error {
statusCode: number
message: string
interface NezhaDataResponse {
error?: string;
data?: ServerMonitorChart;
}
export async function GET(req: NextRequest) {
if (getEnv("SitePassword")) {
const session = await auth()
if (!session) {
redirect("/")
}
}
const { searchParams } = new URL(req.url)
const server_id = searchParams.get("server_id")
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const server_id = searchParams.get("server_id");
if (!server_id) {
return NextResponse.json({ error: "server_id is required" }, { status: 400 })
return NextResponse.json(
{ error: "server_id is required" },
{ status: 400 },
);
}
try {
const serverIdNum = Number.parseInt(server_id, 10)
if (Number.isNaN(serverIdNum)) {
return NextResponse.json({ error: "server_id must be a number" }, { status: 400 })
}
const monitorData = await GetServerMonitor({
server_id: serverIdNum,
})
return NextResponse.json(monitorData, { status: 200 })
} catch (error) {
const err = error as ResError
console.error("Error in GET handler:", err)
const statusCode = err.statusCode || 500
const message = err.message || "Internal Server Error"
return NextResponse.json({ error: message }, { status: statusCode })
const response = (await GetServerMonitor({
server_id: parseInt(server_id),
})) as NezhaDataResponse;
if (response.error) {
console.log(response.error);
return NextResponse.json({ error: response.error }, { status: 400 });
}
return NextResponse.json(response, { status: 200 });
}

View File

@ -1,66 +0,0 @@
import fs from "node:fs"
import path from "node:path"
import { auth } from "@/auth"
import getEnv from "@/lib/env-entry"
import { GetServerIP } from "@/lib/serverFetch"
import { type AsnResponse, type CityResponse, Reader } from "maxmind"
import { redirect } from "next/navigation"
import { type NextRequest, NextResponse } from "next/server"
export const dynamic = "force-dynamic"
interface ResError extends Error {
statusCode: number
message: string
}
export type IPInfo = {
city: CityResponse
asn: AsnResponse
}
export async function GET(req: NextRequest) {
if (getEnv("SitePassword")) {
const session = await auth()
if (!session) {
redirect("/")
}
}
if (!getEnv("NEXT_PUBLIC_ShowIpInfo")) {
return NextResponse.json({ error: "ip info is disabled" }, { status: 400 })
}
const { searchParams } = new URL(req.url)
const server_id = searchParams.get("server_id")
if (!server_id) {
return NextResponse.json({ error: "server_id is required" }, { status: 400 })
}
try {
const ip = await GetServerIP({ server_id: Number(server_id) })
const cityDbPath = path.join(process.cwd(), "lib", "maxmind-db", "GeoLite2-City.mmdb")
const asnDbPath = path.join(process.cwd(), "lib", "maxmind-db", "GeoLite2-ASN.mmdb")
const cityDbBuffer = fs.readFileSync(cityDbPath)
const asnDbBuffer = fs.readFileSync(asnDbPath)
const cityLookup = new Reader<CityResponse>(cityDbBuffer)
const asnLookup = new Reader<AsnResponse>(asnDbBuffer)
const data: IPInfo = {
city: cityLookup.get(ip) as CityResponse,
asn: asnLookup.get(ip) as AsnResponse,
}
return NextResponse.json(data, { status: 200 })
} catch (error) {
const err = error as ResError
console.error("Error in GET handler:", err)
const statusCode = err.statusCode || 500
const message = err.message || "Internal Server Error"
return NextResponse.json({ error: message }, { status: statusCode })
}
}

View File

@ -1,32 +1,19 @@
import { auth } from "@/auth"
import getEnv from "@/lib/env-entry"
import { GetNezhaData } from "@/lib/serverFetch"
import { redirect } from "next/navigation"
import { NextResponse } from "next/server"
import { ServerApi } from "@/app/[locale]/types/nezha-api";
import { GetNezhaData } from "@/lib/serverFetch";
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic"
export const dynamic = "force-dynamic";
interface ResError extends Error {
statusCode: number
message: string
interface NezhaDataResponse {
error?: string;
data?: ServerApi;
}
export async function GET() {
if (getEnv("SitePassword")) {
const session = await auth()
if (!session) {
redirect("/")
}
}
try {
const data = await GetNezhaData()
return NextResponse.json(data, { status: 200 })
} catch (error) {
const err = error as ResError
console.error("Error in GET handler:", err)
const statusCode = err.statusCode || 500
const message = err.message || "Internal Server Error"
return NextResponse.json({ error: message }, { status: statusCode })
export async function GET(_: Request) {
const response = (await GetNezhaData()) as NezhaDataResponse;
if (response.error) {
console.log(response.error);
return NextResponse.json({ error: response.error }, { status: 400 });
}
return NextResponse.json(response, { status: 200 });
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -1,24 +0,0 @@
"use client"
import { type ReactNode, createContext, useContext, useState } from "react"
interface FilterContextType {
filter: boolean
setFilter: (filter: boolean) => void
}
const FilterContext = createContext<FilterContextType | undefined>(undefined)
export function FilterProvider({ children }: { children: ReactNode }) {
const [filter, setFilter] = useState<boolean>(false)
return <FilterContext.Provider value={{ filter, setFilter }}>{children}</FilterContext.Provider>
}
export function useFilter() {
const context = useContext(FilterContext)
if (context === undefined) {
throw new Error("useFilter must be used within a FilterProvider")
}
return context
}

View File

@ -1,62 +0,0 @@
"use client"
import type { ServerApi } from "@/app/types/nezha-api"
import getEnv from "@/lib/env-entry"
import { nezhaFetcher } from "@/lib/utils"
import { type ReactNode, createContext, useContext, useEffect, useState } from "react"
import useSWR from "swr"
export interface ServerDataWithTimestamp {
timestamp: number
data: ServerApi
}
interface ServerDataContextType {
data: ServerApi | undefined
error: Error | undefined
isLoading: boolean
history: ServerDataWithTimestamp[]
}
const ServerDataContext = createContext<ServerDataContextType | undefined>(undefined)
export const MAX_HISTORY_LENGTH = 30
export function ServerDataProvider({ children }: { children: ReactNode }) {
const [history, setHistory] = useState<ServerDataWithTimestamp[]>([])
const { data, error, isLoading } = useSWR<ServerApi>("/api/server", nezhaFetcher, {
refreshInterval: Number(getEnv("NEXT_PUBLIC_NezhaFetchInterval")) || 2000,
dedupingInterval: 1000,
})
useEffect(() => {
if (data) {
setHistory((prev) => {
const newHistory = [
{
timestamp: Date.now(),
data: data,
},
...prev,
].slice(0, MAX_HISTORY_LENGTH)
return newHistory
})
}
}, [data])
return (
<ServerDataContext.Provider value={{ data, error, isLoading, history }}>
{children}
</ServerDataContext.Provider>
)
}
export function useServerData() {
const context = useContext(ServerDataContext)
if (context === undefined) {
throw new Error("useServerData must be used within a ServerDataProvider")
}
return context
}

View File

@ -1,26 +0,0 @@
"use client"
import { type ReactNode, createContext, useContext, useState } from "react"
type Status = "all" | "online" | "offline"
interface StatusContextType {
status: Status
setStatus: (status: Status) => void
}
const StatusContext = createContext<StatusContextType | undefined>(undefined)
export function StatusProvider({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<Status>("all")
return <StatusContext.Provider value={{ status, setStatus }}>{children}</StatusContext.Provider>
}
export function useStatus() {
const context = useContext(StatusContext)
if (context === undefined) {
throw new Error("useStatus must be used within a StatusProvider")
}
return context
}

View File

@ -1,39 +0,0 @@
"use client"
import { type ReactNode, createContext, useContext, useState } from "react"
export interface TooltipData {
centroid: [number, number]
country: string
count: number
servers: Array<{
id: string
name: string
status: boolean
}>
}
interface TooltipContextType {
tooltipData: TooltipData | null
setTooltipData: (data: TooltipData | null) => void
}
const TooltipContext = createContext<TooltipContextType | undefined>(undefined)
export function TooltipProvider({ children }: { children: ReactNode }) {
const [tooltipData, setTooltipData] = useState<TooltipData | null>(null)
return (
<TooltipContext.Provider value={{ tooltipData, setTooltipData }}>
{children}
</TooltipContext.Provider>
)
}
export function useTooltip() {
const context = useContext(TooltipContext)
if (context === undefined) {
throw new Error("useTooltip must be used within a TooltipProvider")
}
return context
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 476 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 769 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -1,87 +0,0 @@
import { FilterProvider } from "@/app/context/network-filter-context"
import { StatusProvider } from "@/app/context/status-context"
import { ThemeColorManager } from "@/components/ThemeColorManager"
import getEnv from "@/lib/env-entry"
import { cn } from "@/lib/utils"
import "@/styles/globals.css"
import type { Metadata } from "next"
import type { Viewport } from "next"
import { NextIntlClientProvider } from "next-intl"
import { getLocale, getMessages } from "next-intl/server"
import { PublicEnvScript } from "next-runtime-env"
import { ThemeProvider } from "next-themes"
import { Inter as FontSans } from "next/font/google"
import type React from "react"
const fontSans = FontSans({
subsets: ["latin"],
variable: "--font-sans",
})
const customTitle = getEnv("NEXT_PUBLIC_CustomTitle")
const customDescription = getEnv("NEXT_PUBLIC_CustomDescription")
const disableIndex = getEnv("NEXT_PUBLIC_DisableIndex")
export const metadata: Metadata = {
manifest: "/manifest.json",
title: customTitle || "NezhaDash",
description: customDescription || "A dashboard for nezha",
appleWebApp: {
capable: true,
title: customTitle || "NezhaDash",
statusBarStyle: "default",
},
robots: {
index: !disableIndex,
follow: !disableIndex,
},
}
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
}
export default async function LocaleLayout({
children,
}: {
children: React.ReactNode
}) {
const locale = await getLocale()
const messages = await getMessages()
return (
<html lang={locale} suppressHydrationWarning>
<head>
<PublicEnvScript />
<link
rel="stylesheet"
href="https://fastly.jsdelivr.net/gh/lipis/flag-icons@7.0.0/css/flag-icons.min.css"
/>
<link
rel="stylesheet"
href="https://fastly.jsdelivr.net/npm/font-logos@1/assets/font-logos.css"
/>
</head>
<body className={cn("min-h-screen bg-background font-sans antialiased", fontSans.variable)}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<NextIntlClientProvider messages={messages}>
<FilterProvider>
<StatusProvider>
<ThemeColorManager />
{children}
</StatusProvider>
</FilterProvider>
</NextIntlClientProvider>
</ThemeProvider>
</body>
</html>
)
}

View File

@ -1,22 +0,0 @@
import Footer from "@/app/(main)/footer"
import Header from "@/app/(main)/header"
import { useTranslations } from "next-intl"
import Link from "next/link"
export default function NotFoundPage() {
const t = useTranslations("NotFoundPage")
return (
<div className="flex min-h-screen w-full flex-col">
<main className="flex min-h-[calc(100vh-calc(var(--spacing)*16))] flex-1 flex-col gap-4 bg-background p-4 md:p-10 md:pt-8">
<Header />
<section className="flex flex-1 flex-col items-center justify-center gap-2">
<p className="font-semibold text-sm">{t("h1_490-590_404NotFound")}</p>
<Link href="/" className="flex items-center gap-1">
<p className="font-medium text-sm opacity-40">{t("h1_490-590_404NotFoundBack")}</p>
</Link>
</section>
<Footer />
</main>
</div>
)
}

View File

@ -1,77 +0,0 @@
export type ServerApi = {
live_servers: number
offline_servers: number
total_out_bandwidth: number
total_in_bandwidth: number
total_out_speed: number
total_in_speed: number
result: NezhaAPISafe[]
}
export type NezhaAPISafe = Omit<NezhaAPI, "ipv4" | "ipv6" | "valid_ip">
export interface NezhaAPI {
id: number
name: string
tag: string
last_active: number
online_status: boolean
ipv4: string
ipv6: string
valid_ip: string
display_index: number
hide_for_guest: boolean
host: NezhaAPIHost
status: NezhaAPIStatus
}
export interface NezhaAPIHost {
Platform: string
PlatformVersion: string
CPU: string[]
MemTotal: number
DiskTotal: number
SwapTotal: number
Arch: string
Virtualization: string
BootTime: number
CountryCode: string
Version: string
GPU: string[]
}
export interface NezhaAPIStatus {
CPU: number
MemUsed: number
SwapUsed: number
DiskUsed: number
NetInTransfer: number
NetOutTransfer: number
NetInSpeed: number
NetOutSpeed: number
Uptime: number
Load1: number
Load5: number
Load15: number
TcpConnCount: number
UdpConnCount: number
ProcessCount: number
Temperatures: number
GPU: number
}
export type ServerMonitorChart = {
[key: string]: {
created_at: number
avg_delay: number
}[]
}
export interface NezhaAPIMonitor {
monitor_id: number
monitor_name: string
server_id: number
server_name: string
created_at: number[]
avg_delay: number[]
}

View File

@ -1 +0,0 @@
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>

34
auth.ts
View File

@ -1,34 +0,0 @@
import getEnv from "@/lib/env-entry"
import CryptoJS from "crypto-js"
import NextAuth from "next-auth"
import CredentialsProvider from "next-auth/providers/credentials"
export const { handlers, signIn, signOut, auth } = NextAuth({
secret:
process.env.AUTH_SECRET ??
CryptoJS.MD5(`this_is_nezha_dash_web_secret_${getEnv("SitePassword")}`).toString(),
trustHost: (process.env.AUTH_TRUST_HOST as boolean | undefined) ?? true,
providers: [
CredentialsProvider({
type: "credentials",
credentials: { password: { label: "Password", type: "password" } },
// authorization function
async authorize(credentials) {
const { password } = credentials
if (password === getEnv("SitePassword")) {
return { id: "nezha-dash-auth" }
}
return { error: "Invalid password" }
},
}),
],
callbacks: {
async signIn({ user }) {
// @ts-expect-error user is not null
if (user.error) {
return false
}
return true
},
},
})

10
auto-i18n-config.json Normal file
View File

@ -0,0 +1,10 @@
{
"defaultLang": "en",
"translatorServerName": "azure",
"needLangs": ["en", "zh", "zh-t", "ja"],
"brandWords": [],
"unMoveToLocaleDirFiles": [],
"enableStaticRendering": false,
"enableSubPageRedirectToLocale": false,
"disableDefaultLangRedirect": true
}

View File

@ -1,86 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": { "enabled": false, "clientKind": "git", "useIgnoreFile": false },
"files": { "ignoreUnknown": false, "ignore": [".next", "public", "styles/globals.css"] },
"formatter": {
"enabled": true,
"useEditorconfig": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100,
"attributePosition": "auto",
"bracketSpacing": true
},
"organizeImports": { "enabled": true },
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"nursery": {
"useSortedClasses": "error"
},
"a11y": {
"useKeyWithClickEvents": "off",
"noLabelWithoutControl": "off"
},
"security": {
"noDangerouslySetInnerHtml": "off"
},
"complexity": { "noUselessTypeConstraint": "error" },
"correctness": {
"noUnusedVariables": "error",
"useArrayLiterals": "off",
"useExhaustiveDependencies": "off"
},
"style": { "noNamespace": "error", "useAsConstAssertion": "error" },
"suspicious": {
"noExplicitAny": "off",
"noExtraNonNullAssertion": "error",
"noMisleadingInstantiator": "error",
"noUnsafeDeclarationMerging": "error",
"useNamespaceKeyword": "error"
}
}
},
"javascript": {
"formatter": {
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "all",
"semicolons": "asNeeded",
"arrowParentheses": "always",
"bracketSameLine": false,
"quoteStyle": "double",
"attributePosition": "auto",
"bracketSpacing": true
}
},
"overrides": [
{
"include": ["*.ts", "*.tsx", "*.mts", "*.cts"],
"linter": {
"rules": {
"correctness": {
"noUnusedImports": "error"
},
"style": {
"noArguments": "error",
"noVar": "error",
"useConst": "error"
},
"suspicious": {
"noClassAssign": "off",
"noDuplicateClassMembers": "off",
"noDuplicateObjectKeys": "off",
"noDuplicateParameters": "off",
"noFunctionAssign": "off",
"noRedeclare": "off",
"noUnsafeNegation": "off",
"useGetterReturn": "off"
}
}
}
}
]
}

BIN
bun.lockb

Binary file not shown.

View File

@ -1,15 +0,0 @@
{
"types": {
"feat": { "title": "🚀 Features" },
"fix": { "title": "🔧 Bug Fixes" },
"docs": { "title": "📚 Documentation" },
"style": { "title": "💄 Styles" },
"refactor": { "title": "🔨 Refactor" },
"perf": { "title": "🏎 Performance" },
"test": { "title": "🚨 Tests" },
"build": { "title": "🛠 Build" },
"ci": { "title": "👷 CI" },
"chore": { "title": "🛗 Chore" },
"revert": { "title": "⏪ Revert" }
}
}

View File

@ -1,96 +0,0 @@
import { cn } from "@/lib/utils"
import { useEffect, useState } from "react"
export default function AnimateCountClient({
count,
className,
minDigits,
}: {
count: number
className?: string
minDigits?: number
}) {
const [previousCount, setPreviousCount] = useState(count)
useEffect(() => {
if (count !== previousCount) {
setTimeout(() => {
setPreviousCount(count)
}, 300)
}
}, [count])
return (
<AnimateCount
key={count}
preCount={previousCount}
className={cn("inline-flex items-center leading-none", className)}
minDigits={minDigits}
data-issues-count-animation
>
{count}
</AnimateCount>
)
}
export function AnimateCount({
children: count,
className,
preCount,
minDigits = 1,
...props
}: {
children: number
className?: string
preCount?: number
minDigits?: number
}) {
const currentDigits = count.toString().split("")
const previousDigits = (
preCount !== undefined ? preCount.toString() : count - 1 >= 0 ? (count - 1).toString() : "0"
).split("")
// Ensure both numbers meet the minimum length requirement and maintain the same length for animation
const maxLength = Math.max(previousDigits.length, currentDigits.length, minDigits)
while (previousDigits.length < maxLength) {
previousDigits.unshift("0")
}
while (currentDigits.length < maxLength) {
currentDigits.unshift("0")
}
return (
<div {...props} className={cn("flex h-[1em] items-center", className)}>
{currentDigits.map((digit, index) => {
const hasChanged = digit !== previousDigits[index]
return (
<div
key={`${index}-${digit}`}
className={cn("relative flex h-full min-w-[0.6em] items-center text-center", {
"min-w-[0.2em]": digit === ".",
})}
>
<div
aria-hidden
data-issues-count-exit
className={cn(
"absolute inset-0 flex items-center justify-center",
hasChanged ? "animate" : "opacity-0",
)}
>
{previousDigits[index]}
</div>
<div
data-issues-count-enter
className={cn(
"absolute inset-0 flex items-center justify-center",
hasChanged && "animate",
)}
>
{digit}
</div>
</div>
)
})}
</div>
)
}

34
components/BlurLayer.tsx Normal file
View File

@ -0,0 +1,34 @@
import React from "react";
const BlurLayers = () => {
const computeLayerStyle = (index: number) => {
const blurAmount = index * 3.7037;
const maskStart = index * 10;
let maskEnd = maskStart + 20;
if (maskEnd > 100) {
maskEnd = 100;
}
return {
backdropFilter: `blur(${blurAmount}px)`,
WebkitBackdropFilter: `blur(${blurAmount}px)`,
zIndex: index + 1,
maskImage: `linear-gradient(rgba(0, 0, 0, 0) ${maskStart}%, rgb(0, 0, 0) ${maskEnd}%)`,
};
};
// 根据层数动态生成层
const layers = Array.from({ length: 5 }).map((_, index) => (
<div
key={index}
className={"absolute inset-0 h-full w-full"}
style={computeLayerStyle(index)}
/>
));
return (
<div className={"fixed bottom-0 left-0 right-0 z-50 h-[140px]"}>
<div className={"relative h-full"}>{layers}</div>
</div>
);
};
export default BlurLayers;

View File

@ -1,135 +0,0 @@
"use client"
import { Home, Languages, Moon, Sun, SunMoon } from "lucide-react"
import { useServerData } from "@/app/context/server-data-context"
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command"
import { localeItems } from "@/i18n-metadata"
import { setUserLocale } from "@/i18n/locale"
import { useTranslations } from "next-intl"
import { useTheme } from "next-themes"
import { useRouter } from "next/navigation"
import { useEffect, useState } from "react"
export function DashCommand() {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState("")
const { data } = useServerData()
const router = useRouter()
const { setTheme } = useTheme()
const t = useTranslations("DashCommand")
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
setOpen((open) => !open)
}
}
document.addEventListener("keydown", down)
return () => document.removeEventListener("keydown", down)
}, [])
if (!data?.result) return null
const sortedServers = data.result.sort((a, b) => {
const displayIndexDiff = (b.display_index || 0) - (a.display_index || 0)
if (displayIndexDiff !== 0) return displayIndexDiff
return a.id - b.id
})
const languageShortcuts = localeItems.map((item) => ({
keywords: ["language", "locale", item.code.toLowerCase()],
icon: <Languages />,
label: item.name,
action: () => setUserLocale(item.code),
value: `language ${item.name.toLowerCase()} ${item.code}`,
}))
const shortcuts = [
{
keywords: ["home", "homepage"],
icon: <Home />,
label: t("Home"),
action: () => router.push("/"),
},
{
keywords: ["light", "theme", "lightmode"],
icon: <Sun />,
label: t("ToggleLightMode"),
action: () => setTheme("light"),
},
{
keywords: ["dark", "theme", "darkmode"],
icon: <Moon />,
label: t("ToggleDarkMode"),
action: () => setTheme("dark"),
},
{
keywords: ["system", "theme", "systemmode"],
icon: <SunMoon />,
label: t("ToggleSystemMode"),
action: () => setTheme("system"),
},
...languageShortcuts,
].map((item) => ({
...item,
value: `${item.keywords.join(" ")} ${item.label}`,
}))
return (
<>
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder={t("TypeCommand")} value={search} onValueChange={setSearch} />
<CommandList className="border-t">
<CommandEmpty>{t("NoResults")}</CommandEmpty>
<CommandGroup heading={t("Servers")}>
{sortedServers.map((server) => (
<CommandItem
key={server.id}
value={server.name}
onSelect={() => {
router.push(`/server/${server.id}`)
setOpen(false)
}}
>
{server.online_status ? (
<span className="h-2 w-2 shrink-0 self-center rounded-full bg-green-500" />
) : (
<span className="h-2 w-2 shrink-0 self-center rounded-full bg-red-500" />
)}
<span>{server.name}</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading={t("Shortcuts")}>
{shortcuts.map((item) => (
<CommandItem
key={item.label}
value={item.value}
onSelect={() => {
item.action()
setOpen(false)
}}
>
{item.icon}
<span>{item.label}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</CommandDialog>
</>
)
}

File diff suppressed because one or more lines are too long

View File

@ -1,58 +1,70 @@
"use client"
"use client";
import { Button } from "@/components/ui/button"
import { useLocale } from "next-intl";
import { localeItems } from "../i18n-metadata";
import { useRouter, usePathname } from "next/navigation";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { localeItems } from "@/i18n-metadata"
import { setUserLocale } from "@/i18n/locale"
import { cn } from "@/lib/utils"
import { CheckCircleIcon, LanguageIcon } from "@heroicons/react/20/solid"
import { useLocale } from "next-intl"
} from "@/components/ui/dropdown-menu";
export function LanguageSwitcher() {
const locale = useLocale()
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const handleSelect = (e: Event, newLocale: string) => {
e.preventDefault() // 阻止默认的关闭行为
setUserLocale(newLocale)
}
const handleChange = (code: string) => {
const newLocale = code;
const rootPath = "/";
const currentLocalePath = `/${locale}`;
const newLocalePath = `/${newLocale}`;
// Function to construct new path with locale prefix
const constructLocalePath = (path: string, newLocale: string) => {
if (path.startsWith(currentLocalePath)) {
return path.replace(currentLocalePath, `/${newLocale}`);
} else {
return `/${newLocale}${path}`;
}
};
if (pathname === rootPath || !pathname) {
router.push(newLocalePath);
} else if (
pathname === currentLocalePath ||
pathname === `${currentLocalePath}/`
) {
router.push(newLocalePath);
} else {
const newPath = constructLocalePath(pathname, newLocale);
router.push(newPath);
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="cursor-pointer rounded-full bg-white px-[9px] hover:bg-accent/50 dark:bg-black dark:hover:bg-accent/50"
>
<LanguageIcon className="size-4" />
<Button variant="outline" size="sm" className="rounded-full px-[9px]">
{localeItems.find((item) => item.code === locale)?.name}
<span className="sr-only">Change language</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="flex flex-col gap-0.5" align="end">
{localeItems.map((item, index) => (
<DropdownMenuContent align="end">
{localeItems.map((item) => (
<DropdownMenuItem
key={item.code}
onSelect={(e) => handleSelect(e, item.code)}
className={cn(
{
"gap-3 bg-muted font-semibold": locale === item.code,
},
{
"rounded-t-[5px]": index === localeItems.length - 1,
"rounded-[5px]": index !== 0 && index !== localeItems.length - 1,
"rounded-b-[5px]": index === 0,
},
)}
onClick={() => handleChange(item.code)}
>
{item.name} {locale === item.code && <CheckCircleIcon className="size-4" />}
{item.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
);
}

View File

@ -1,174 +1,124 @@
import type { NezhaAPISafe } from "@/app/types/nezha-api"
import ServerFlag from "@/components/ServerFlag"
import ServerUsageBar from "@/components/ServerUsageBar"
import { Badge } from "@/components/ui/badge"
import { Card } from "@/components/ui/card"
import getEnv from "@/lib/env-entry"
import { GetFontLogoClass, GetOsName, MageMicrosoftWindows } from "@/lib/logo-class"
import { cn, formatBytes, formatNezhaInfo } from "@/lib/utils"
import { useTranslations } from "next-intl"
import Link from "next/link"
import { useLocale, useTranslations } from "next-intl";
import { NezhaAPISafe } from "../app/[locale]/types/nezha-api";
import ServerUsageBar from "@/components/ServerUsageBar";
import { Card } from "@/components/ui/card";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn, formatNezhaInfo } from "@/lib/utils";
import ServerCardPopover from "./ServerCardPopover";
import { env } from "next-runtime-env";
import ServerFlag from "./ServerFlag";
import { useRouter } from "next/navigation";
export default function ServerCard({
serverInfo,
}: {
serverInfo: NezhaAPISafe
serverInfo: NezhaAPISafe;
}) {
const t = useTranslations("ServerCard")
const { id, name, country_code, online, cpu, up, down, mem, stg, host } =
formatNezhaInfo(serverInfo)
const t = useTranslations("ServerCard");
const router = useRouter();
const { id, name, country_code, online, cpu, up, down, mem, stg, ...props } =
formatNezhaInfo(serverInfo);
const showFlag = getEnv("NEXT_PUBLIC_ShowFlag") === "true"
const showNetTransfer = getEnv("NEXT_PUBLIC_ShowNetTransfer") === "true"
const fixedTopServerName = getEnv("NEXT_PUBLIC_FixedTopServerName") === "true"
const showFlag = env("NEXT_PUBLIC_ShowFlag") === "true";
const saveSession = () => {
sessionStorage.setItem("fromMainPage", "true")
}
const locale = useLocale();
return online ? (
<Link onClick={saveSession} href={`/server/${id}`} prefetch={true}>
<Card
className={cn(
"flex cursor-pointer flex-col items-center justify-start gap-3 p-3 hover:border-stone-300 hover:shadow-md md:px-5 dark:hover:border-stone-700",
{
"flex-col": fixedTopServerName,
"lg:flex-row": !fixedTopServerName,
},
)}
>
<section
className={cn("grid items-center gap-2", {
"lg:w-40": !fixedTopServerName,
})}
style={{ gridTemplateColumns: "auto auto 1fr" }}
>
<span className="h-2 w-2 shrink-0 self-center rounded-full bg-green-500" />
<div
className={cn(
"flex items-center justify-center",
showFlag ? "min-w-[17px]" : "min-w-0",
)}
>
<Card
className={
"flex flex-col items-center justify-start gap-3 p-3 md:px-5 lg:flex-row"
}
>
<Popover>
<PopoverTrigger asChild>
<section className={"flex items-center justify-start gap-2 lg:w-28"}>
{showFlag ? <ServerFlag country_code={country_code} /> : null}
</div>
<div className="relative">
<p
className={cn(
"break-all font-bold tracking-tight",
showFlag ? "text-xs " : "text-sm",
showFlag ? "text-xs" : "text-sm",
)}
>
{name}
</p>
</div>
</section>
<div className="flex flex-col gap-2">
<section
className={cn("grid grid-cols-5 items-center gap-3", {
"lg:grid-cols-6 lg:gap-4": fixedTopServerName,
})}
>
{fixedTopServerName && (
<div className={"col-span-1 hidden items-center gap-2 lg:flex lg:flex-row"}>
<div className="font-semibold text-xs">
{host.Platform.includes("Windows") ? (
<MageMicrosoftWindows className="size-[10px]" />
) : (
<p className={`fl-${GetFontLogoClass(host.Platform)}`} />
)}
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-muted-foreground text-xs">{t("System")}</p>
<div className="flex items-center font-semibold text-[10.5px]">
{host.Platform.includes("Windows") ? "Windows" : GetOsName(host.Platform)}
</div>
</div>
</div>
)}
<div className={"flex w-14 flex-col"}>
<p className="text-muted-foreground text-xs">{t("CPU")}</p>
<div className="flex items-center font-semibold text-xs">{cpu.toFixed(2)}%</div>
<ServerUsageBar value={cpu} />
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-muted-foreground text-xs">{t("Mem")}</p>
<div className="flex items-center font-semibold text-xs">{mem.toFixed(2)}%</div>
<ServerUsageBar value={mem} />
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-muted-foreground text-xs">{t("STG")}</p>
<div className="flex items-center font-semibold text-xs">{stg.toFixed(2)}%</div>
<ServerUsageBar value={stg} />
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-muted-foreground text-xs">{t("Upload")}</p>
<div className="flex items-center font-semibold text-xs">
{up >= 1024 ? `${(up / 1024).toFixed(2)}G/s` : `${up.toFixed(2)}M/s`}
</div>
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-muted-foreground text-xs">{t("Download")}</p>
<div className="flex items-center font-semibold text-xs">
{down >= 1024 ? `${(down / 1024).toFixed(2)}G/s` : `${down.toFixed(2)}M/s`}
</div>
</div>
<span className="h-2 w-2 shrink-0 rounded-full bg-green-500"></span>
</section>
{showNetTransfer && (
<section className={"flex items-center justify-between gap-1"}>
<Badge
variant="secondary"
className="flex-1 items-center justify-center text-nowrap rounded-[8px] border-muted-50 text-[11px] shadow-md shadow-neutral-200/30 dark:shadow-none"
>
{t("Upload")}:{formatBytes(serverInfo.status.NetOutTransfer)}
</Badge>
<Badge
variant="outline"
className="flex-1 items-center justify-center text-nowrap rounded-[8px] text-[11px] shadow-md shadow-neutral-200/30 dark:shadow-none"
>
{t("Download")}:{formatBytes(serverInfo.status.NetInTransfer)}
</Badge>
</section>
)}
</div>
</Card>
</Link>
) : (
<Link onClick={saveSession} href={`/server/${id}`} prefetch={true}>
<Card
className={cn(
"flex cursor-pointer flex-col items-center justify-start gap-3 p-3 hover:border-stone-300 hover:shadow-md md:px-5 dark:hover:border-stone-700",
showNetTransfer ? "min-h-[123px] lg:min-h-[91px]" : "min-h-[93px] lg:min-h-[61px]",
{
"flex-col": fixedTopServerName,
"lg:flex-row": !fixedTopServerName,
},
)}
</PopoverTrigger>
<PopoverContent side="top">
<ServerCardPopover status={props.status} host={props.host} />
</PopoverContent>
</Popover>
<section
onClick={() => {
router.push(`/${locale}/${id}`);
}}
className={"grid cursor-pointer grid-cols-5 items-center gap-3"}
>
<section
className={cn("grid items-center gap-2", {
"lg:w-40": !fixedTopServerName,
})}
style={{ gridTemplateColumns: "auto auto 1fr" }}
>
<span className="h-2 w-2 shrink-0 self-center rounded-full bg-red-500" />
<div
className={cn(
"flex items-center justify-center",
showFlag ? "min-w-[17px]" : "min-w-0",
)}
>
{showFlag ? <ServerFlag country_code={country_code} /> : null}
<div className={"flex w-14 flex-col"}>
<p className="text-xs text-muted-foreground">{t("CPU")}</p>
<div className="flex items-center text-xs font-semibold">
{cpu.toFixed(2)}%
</div>
<div className="relative">
<ServerUsageBar value={cpu} />
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-xs text-muted-foreground">{t("Mem")}</p>
<div className="flex items-center text-xs font-semibold">
{mem.toFixed(2)}%
</div>
<ServerUsageBar value={mem} />
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-xs text-muted-foreground">{t("STG")}</p>
<div className="flex items-center text-xs font-semibold">
{stg.toFixed(2)}%
</div>
<ServerUsageBar value={stg} />
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-xs text-muted-foreground">{t("Upload")}</p>
<div className="flex items-center text-xs font-semibold">
{up.toFixed(2)}M/s
</div>
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-xs text-muted-foreground">{t("Download")}</p>
<div className="flex items-center text-xs font-semibold">
{down.toFixed(2)}M/s
</div>
</div>
</section>
</Card>
) : (
<Card
className={
"flex flex-col items-center justify-start gap-3 p-3 md:px-5 lg:flex-row"
}
>
<Popover>
<PopoverTrigger asChild>
<section className={"flex items-center justify-start gap-2 lg:w-28"}>
{showFlag ? <ServerFlag country_code={country_code} /> : null}
<p
className={cn("break-all font-bold tracking-tight", showFlag ? "text-xs" : "text-sm")}
className={cn(
"break-all font-bold tracking-tight",
showFlag ? "text-xs" : "text-sm",
)}
>
{name}
</p>
</div>
</section>
</Card>
</Link>
)
<span className="h-2 w-2 shrink-0 rounded-full bg-red-500"></span>
</section>
</PopoverTrigger>
<PopoverContent className="w-fit p-2" side="top">
<p className="text-sm text-muted-foreground">{t("Offline")}</p>
</PopoverContent>
</Popover>
</Card>
);
}

View File

@ -1,157 +0,0 @@
import type { NezhaAPISafe } from "@/app/types/nezha-api"
import ServerFlag from "@/components/ServerFlag"
import ServerUsageBar from "@/components/ServerUsageBar"
import { Card } from "@/components/ui/card"
import getEnv from "@/lib/env-entry"
import { GetFontLogoClass, GetOsName, MageMicrosoftWindows } from "@/lib/logo-class"
import { cn, formatBytes, formatNezhaInfo } from "@/lib/utils"
import { useTranslations } from "next-intl"
import Link from "next/link"
import { Separator } from "./ui/separator"
export default function ServerCardInline({
serverInfo,
}: {
serverInfo: NezhaAPISafe
}) {
const t = useTranslations("ServerCard")
const { id, name, country_code, online, cpu, up, down, mem, stg, host } =
formatNezhaInfo(serverInfo)
const showFlag = getEnv("NEXT_PUBLIC_ShowFlag") === "true"
const saveSession = () => {
sessionStorage.setItem("fromMainPage", "true")
}
return online ? (
<Link onClick={saveSession} href={`/server/${id}`} prefetch={true}>
<Card
className={cn(
"flex w-full min-w-[900px] cursor-pointer items-center justify-start gap-3 p-3 hover:border-stone-300 hover:shadow-md md:px-5 lg:flex-row dark:hover:border-stone-700",
)}
>
<section
className={cn("grid items-center gap-2 lg:w-36")}
style={{ gridTemplateColumns: "auto auto 1fr" }}
>
<span className="h-2 w-2 shrink-0 self-center rounded-full bg-green-500" />
<div
className={cn(
"flex items-center justify-center",
showFlag ? "min-w-[17px]" : "min-w-0",
)}
>
{showFlag ? <ServerFlag country_code={country_code} /> : null}
</div>
<div className="relative w-28">
<p
className={cn(
"break-all font-bold tracking-tight",
showFlag ? "text-xs " : "text-sm",
)}
>
{name}
</p>
</div>
</section>
<Separator orientation="vertical" className="mx-0 ml-2 h-8" />
<div className="flex flex-col gap-2">
<section className={cn("grid flex-1 grid-cols-9 items-center gap-3")}>
<div className={"flex flex-row items-center gap-2 whitespace-nowrap"}>
<div className="font-semibold text-xs">
{host.Platform.includes("Windows") ? (
<MageMicrosoftWindows className="size-[10px]" />
) : (
<p className={`fl-${GetFontLogoClass(host.Platform)}`} />
)}
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-muted-foreground text-xs">{t("System")}</p>
<div className="flex items-center font-semibold text-[10.5px]">
{host.Platform.includes("Windows") ? "Windows" : GetOsName(host.Platform)}
</div>
</div>
</div>
<div className={"flex w-20 flex-col"}>
<p className="text-muted-foreground text-xs">{t("Uptime")}</p>
<div className="flex items-center font-semibold text-xs">
{(serverInfo?.status.Uptime / 86400).toFixed(0)} {"Days"}
</div>
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-muted-foreground text-xs">{t("CPU")}</p>
<div className="flex items-center font-semibold text-xs">{cpu.toFixed(2)}%</div>
<ServerUsageBar value={cpu} />
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-muted-foreground text-xs">{t("Mem")}</p>
<div className="flex items-center font-semibold text-xs">{mem.toFixed(2)}%</div>
<ServerUsageBar value={mem} />
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-muted-foreground text-xs">{t("STG")}</p>
<div className="flex items-center font-semibold text-xs">{stg.toFixed(2)}%</div>
<ServerUsageBar value={stg} />
</div>
<div className={"flex w-16 flex-col"}>
<p className="text-muted-foreground text-xs">{t("Upload")}</p>
<div className="flex items-center font-semibold text-xs">
{up >= 1024 ? `${(up / 1024).toFixed(2)}G/s` : `${up.toFixed(2)}M/s`}
</div>
</div>
<div className={"flex w-16 flex-col"}>
<p className="text-muted-foreground text-xs">{t("Download")}</p>
<div className="flex items-center font-semibold text-xs">
{down >= 1024 ? `${(down / 1024).toFixed(2)}G/s` : `${down.toFixed(2)}M/s`}
</div>
</div>
<div className={"flex w-20 flex-col"}>
<p className="text-muted-foreground text-xs">{t("TotalUpload")}</p>
<div className="flex items-center font-semibold text-xs">
{formatBytes(serverInfo.status.NetOutTransfer)}
</div>
</div>
<div className={"flex w-20 flex-col"}>
<p className="text-muted-foreground text-xs">{t("TotalDownload")}</p>
<div className="flex items-center font-semibold text-xs">
{formatBytes(serverInfo.status.NetInTransfer)}
</div>
</div>
</section>
</div>
</Card>
</Link>
) : (
<Link onClick={saveSession} href={`/server/${id}`} prefetch={true}>
<Card
className={cn(
"flex min-h-[61px] min-w-[900px] flex-row items-center justify-start gap-3 p-3 hover:border-stone-300 hover:shadow-md md:px-5 dark:hover:border-stone-700",
)}
>
<section
className={cn("grid items-center gap-2 lg:w-40")}
style={{ gridTemplateColumns: "auto auto 1fr" }}
>
<span className="h-2 w-2 shrink-0 self-center rounded-full bg-red-500" />
<div
className={cn(
"flex items-center justify-center",
showFlag ? "min-w-[17px]" : "min-w-0",
)}
>
{showFlag ? <ServerFlag country_code={country_code} /> : null}
</div>
<div className="relative w-28">
<p
className={cn("break-all font-bold tracking-tight", showFlag ? "text-xs" : "text-sm")}
>
{name}
</p>
</div>
</section>
</Card>
</Link>
)
}

View File

@ -0,0 +1,73 @@
import { useTranslations } from "next-intl";
import { NezhaAPISafe } from "../app/[locale]/types/nezha-api";
import { cn, formatBytes } from "@/lib/utils";
export function ServerCardPopoverCard({
className,
title,
content,
children,
}: {
className?: string;
title: string;
content?: string;
children?: React.ReactNode;
}) {
return (
<div className={cn("mb-[6px] flex w-full flex-col", className)}>
<div className="text-sm font-semibold">{title}</div>
{children ? (
children
) : (
<div className="break-all text-xs font-medium">{content}</div>
)}
</div>
);
}
export default function ServerCardPopover({
host,
status,
}: {
host: NezhaAPISafe["host"];
status: NezhaAPISafe["status"];
}) {
const t = useTranslations("ServerCardPopover");
return (
<section className="max-w-[300px]">
<ServerCardPopoverCard
title={t("System")}
content={`${host.Platform}-${host.PlatformVersion} [${host.Virtualization}: ${host.Arch}]`}
/>
<ServerCardPopoverCard
title={t("CPU")}
content={`${host.CPU.map((item) => item).join(", ")}`}
/>
<ServerCardPopoverCard
title={t("Mem")}
content={`${formatBytes(status.MemUsed)} / ${formatBytes(host.MemTotal)}`}
/>
<ServerCardPopoverCard
title={t("STG")}
content={`${formatBytes(status.DiskUsed)} / ${formatBytes(host.DiskTotal)}`}
/>
<ServerCardPopoverCard
title={t("Swap")}
content={`${formatBytes(status.SwapUsed)} / ${formatBytes(host.SwapTotal)}`}
/>
<ServerCardPopoverCard
title={t("Network")}
content={`${formatBytes(status.NetOutTransfer)} / ${formatBytes(status.NetInTransfer)}`}
/>
<ServerCardPopoverCard
title={t("Load")}
content={`${status.Load1.toFixed(2)} / ${status.Load5.toFixed(2)} / ${status.Load15.toFixed(2)}`}
/>
<ServerCardPopoverCard
className="mb-0"
title={t("Online")}
content={`${(status.Uptime / 86400).toFixed(0)} Days`}
/>
</section>
);
}

View File

@ -1,52 +1,36 @@
import getEnv from "@/lib/env-entry"
import { cn } from "@/lib/utils"
import getUnicodeFlagIcon from "country-flag-icons/unicode"
import { useEffect, useState } from "react"
import { useEffect, useState } from "react";
import getUnicodeFlagIcon from "country-flag-icons/unicode";
export default function ServerFlag({
country_code,
className,
}: {
country_code: string
className?: string
}) {
const [supportsEmojiFlags, setSupportsEmojiFlags] = useState(true)
const useSvgFlag = getEnv("NEXT_PUBLIC_ForceUseSvgFlag") === "true"
export default function ServerFlag({ country_code }: { country_code: string }) {
const [supportsEmojiFlags, setSupportsEmojiFlags] = useState(false);
useEffect(() => {
if (useSvgFlag) {
// 如果环境变量要求直接使用 SVG则无需检查 Emoji 支持
setSupportsEmojiFlags(false)
return
}
const checkEmojiSupport = () => {
const canvas = document.createElement("canvas")
const ctx = canvas.getContext("2d")
const emojiFlag = "🇺🇸" // 使用美国国旗作为测试
if (!ctx) return
ctx.fillStyle = "#000"
ctx.textBaseline = "top"
ctx.font = "32px Arial"
ctx.fillText(emojiFlag, 0, 0)
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const emojiFlag = "🇺🇸"; // 使用美国国旗作为测试
if (!ctx) return;
ctx.fillStyle = "#000";
ctx.textBaseline = "top";
ctx.font = "32px Arial";
ctx.fillText(emojiFlag, 0, 0);
const support = ctx.getImageData(16, 16, 1, 1).data[3] !== 0
setSupportsEmojiFlags(support)
}
const support = ctx.getImageData(16, 16, 1, 1).data[3] !== 0;
setSupportsEmojiFlags(support);
};
checkEmojiSupport()
}, [useSvgFlag]) // 将 `useSvgFlag` 作为依赖,当其变化时重新触发
checkEmojiSupport();
}, []);
if (!country_code) return null
if (!country_code) return null;
return (
<span className={cn("text-[12px] text-muted-foreground", className)}>
{useSvgFlag || !supportsEmojiFlags ? (
<span className={`fi fi-${country_code}`} />
<span className="text-[12px] text-muted-foreground">
{!supportsEmojiFlags ? (
<span className={`fi fi-${country_code}`}></span>
) : (
getUnicodeFlagIcon(country_code)
)}
</span>
)
);
}

View File

@ -0,0 +1,7 @@
import React from "react";
import ServerListClient from "../app/[locale]/(main)/ClientComponents/ServerListClient";
export default async function ServerList() {
return <ServerListClient />;
}

View File

@ -0,0 +1,5 @@
import ServerOverviewClient from "../app/[locale]/(main)/ClientComponents/ServerOverviewClient";
export default async function ServerOverview() {
return <ServerOverviewClient />;
}

View File

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

View File

@ -1,80 +0,0 @@
"use client"
import { getCsrfToken, signIn } from "next-auth/react"
import { useTranslations } from "next-intl"
import { useRouter } from "next/navigation"
import { useEffect, useState } from "react"
import { Loader } from "./loading/Loader"
export function SignIn() {
const t = useTranslations("SignIn")
const [csrfToken, setCsrfToken] = useState("")
const [loading, setLoading] = useState(false)
const [errorState, setErrorState] = useState(false)
const [successState, setSuccessState] = useState(false)
const router = useRouter()
useEffect(() => {
async function loadProviders() {
const csrf = await getCsrfToken()
setCsrfToken(csrf)
}
loadProviders()
}, [])
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setLoading(true)
const formData = new FormData(e.currentTarget)
const password = formData.get("password") as string
const res = await signIn("credentials", {
password: password,
redirect: false,
})
if (res?.error) {
console.log("login error")
setErrorState(true)
setSuccessState(false)
} else {
console.log("login success")
setErrorState(false)
setSuccessState(true)
router.push("/")
router.refresh()
}
setLoading(false)
}
return (
<form
className="flex flex-1 flex-col items-center justify-center gap-4 p-4 "
onSubmit={handleSubmit}
>
<input type="hidden" name="csrfToken" value={csrfToken} />
<section className="flex flex-col items-start gap-2">
<label className="flex flex-col items-start gap-1 ">
{errorState && <p className="font-semibold text-red-500 text-sm">{t("ErrorMessage")}</p>}
{successState && (
<p className="font-semibold text-green-500 text-sm">{t("SuccessMessage")}</p>
)}
<p className="font-semibold text-base">{t("SignInMessage")}</p>
<input
className="rounded-[5px] border-[1px] border-stone-300 px-1 dark:border-stone-800"
name="password"
type="password"
/>
</label>
<button
type="submit"
className="flex w-fit items-center gap-1 rounded-[8px] border border-stone-300 bg-card px-1.5 py-0.5 font-semibold text-card-foreground text-sm shadow-lg shadow-neutral-200/40 transition-all hover:brightness-95 dark:border-stone-800 dark:shadow-none"
disabled={loading}
>
{t("Submit")}
{loading && <Loader visible={true} />}
</button>
</section>
</form>
)
}

View File

@ -1,131 +1,48 @@
"use client"
"use client";
import getEnv from "@/lib/env-entry"
import { cn } from "@/lib/utils"
import { useLocale, useTranslations } from "next-intl"
import { createRef, useEffect, useRef, useState } from "react"
import React from "react";
import { cn } from "@/lib/utils";
import { motion } from "framer-motion";
export default function Switch({
allTag,
nowTag,
tagCountMap,
onTagChange,
setTag,
}: {
allTag: string[]
nowTag: string
tagCountMap: Record<string, number>
onTagChange: (tag: string) => void
allTag: string[];
nowTag: string;
setTag: (tag: string) => void;
}) {
const scrollRef = useRef<HTMLDivElement>(null)
const tagRefs = useRef(allTag.map(() => createRef<HTMLDivElement>()))
const t = useTranslations("ServerListClient")
const locale = useLocale()
const [indicator, setIndicator] = useState<{ x: number; w: number } | null>(null)
const [isFirstRender, setIsFirstRender] = useState(true)
useEffect(() => {
const savedTag = sessionStorage.getItem("selectedTag")
if (savedTag && allTag.includes(savedTag)) {
onTagChange(savedTag)
}
}, [allTag, onTagChange])
useEffect(() => {
const container = scrollRef.current
if (!container) return
const isOverflowing = container.scrollWidth > container.clientWidth
if (!isOverflowing) return
const onWheel = (e: WheelEvent) => {
e.preventDefault()
container.scrollLeft += e.deltaY
}
container.addEventListener("wheel", onWheel, { passive: false })
return () => {
container.removeEventListener("wheel", onWheel)
}
}, [])
useEffect(() => {
const currentTagElement = tagRefs.current[allTag.indexOf(nowTag)]?.current
if (currentTagElement) {
setIndicator({
x: currentTagElement.offsetLeft,
w: currentTagElement.offsetWidth,
})
}
if (isFirstRender) {
setTimeout(() => {
setIsFirstRender(false)
}, 50)
}
}, [nowTag, locale, allTag, isFirstRender])
useEffect(() => {
const currentTagElement = tagRefs.current[allTag.indexOf(nowTag)]?.current
const container = scrollRef.current
if (currentTagElement && container) {
const containerRect = container.getBoundingClientRect()
const tagRect = currentTagElement.getBoundingClientRect()
const scrollLeft = currentTagElement.offsetLeft - (containerRect.width - tagRect.width) / 2
container.scrollTo({
left: Math.max(0, scrollLeft),
behavior: "smooth",
})
}
}, [nowTag, locale])
return (
<div
ref={scrollRef}
className="scrollbar-hidden z-50 flex flex-col items-start overflow-x-scroll rounded-[50px]"
>
<div className="relative flex items-center gap-1 rounded-[50px] bg-stone-100 p-[3px] dark:bg-stone-800">
{indicator && (
<div
className="absolute top-[3px] left-0 z-10 h-[35px] bg-white shadow-black/5 shadow-lg dark:bg-stone-700 dark:shadow-white/5"
style={{
borderRadius: 24,
width: `${indicator.w}px`,
transform: `translateX(${indicator.x}px)`,
transition: isFirstRender ? "none" : "all 0.5s cubic-bezier(0.4, 0, 0.2, 1)",
}}
/>
)}
{allTag.map((tag, index) => (
<div className="scrollbar-hidden z-50 flex flex-col items-start overflow-x-scroll rounded-[50px]">
<div className="flex items-center gap-1 rounded-[50px] bg-stone-100 p-[3px] dark:bg-stone-800">
{allTag.map((tag) => (
<div
key={tag}
ref={tagRefs.current[index]}
onClick={() => {
onTagChange(tag)
sessionStorage.setItem("selectedTag", tag)
}}
onClick={() => setTag(tag)}
className={cn(
"relative cursor-pointer rounded-3xl px-2.5 py-[8px] font-[600] text-[13px]",
"text-stone-400 transition-all duration-500 ease-in-out hover:text-stone-950 dark:text-stone-500 hover:dark:text-stone-50",
{
"text-stone-950 dark:text-stone-50": nowTag === tag,
},
"relative cursor-pointer rounded-3xl px-2.5 py-[8px] text-[13px] font-[600] transition-all duration-500",
nowTag === tag
? "text-black dark:text-white"
: "text-stone-400 dark:text-stone-500",
)}
>
{nowTag === tag && (
<motion.div
layoutId="nav-item"
className="absolute inset-0 z-10 h-full w-full content-center bg-white shadow-lg shadow-black/5 dark:bg-stone-700 dark:shadow-white/5"
style={{
originY: "0px",
borderRadius: 46,
}}
/>
)}
<div className="relative z-20 flex items-center gap-1">
<div className="flex items-center gap-2 whitespace-nowrap">
{tag === "defaultTag" ? t("defaultTag") : tag}{" "}
{getEnv("NEXT_PUBLIC_ShowTagCount") === "true" && tag !== "defaultTag" && (
<div className="w-fit rounded-full bg-muted px-1.5">{tagCountMap[tag]}</div>
)}
</div>
<p className="whitespace-nowrap">{tag}</p>
</div>
</div>
))}
</div>
</div>
)
);
}

View File

@ -1,75 +0,0 @@
"use client"
import { cn } from "@/lib/utils"
import { useLocale, useTranslations } from "next-intl"
import { useEffect, useRef, useState } from "react"
export default function TabSwitch({
tabs,
currentTab,
setCurrentTab,
}: {
tabs: string[]
currentTab: string
setCurrentTab: (tab: string) => void
}) {
const t = useTranslations("TabSwitch")
const [indicator, setIndicator] = useState<{ x: number; w: number }>({
x: 0,
w: 0,
})
const tabRefs = useRef<(HTMLDivElement | null)[]>([])
const locale = useLocale()
useEffect(() => {
const currentTabElement = tabRefs.current[tabs.indexOf(currentTab)]
if (currentTabElement) {
const parentPadding = 1
setIndicator({
x:
tabs.indexOf(currentTab) !== 0
? currentTabElement.offsetLeft - parentPadding
: currentTabElement.offsetLeft,
w: currentTabElement.offsetWidth,
})
}
}, [currentTab, tabs, locale])
return (
<div className="z-50 flex flex-col items-start rounded-[50px]">
<div className="relative flex items-center gap-1 rounded-[50px] bg-stone-100 p-[3px] dark:bg-stone-800">
{indicator.w > 0 && (
<div
className="absolute top-[3px] left-0 z-10 h-[35px] bg-white shadow-black/5 shadow-lg dark:bg-stone-700 dark:shadow-white/5"
style={{
borderRadius: 24,
width: `${indicator.w}px`,
transform: `translateX(${indicator.x}px)`,
transition: "all 0.5s cubic-bezier(0.4, 0, 0.2, 1)",
}}
/>
)}
{tabs.map((tab: string, index) => (
<div
key={tab}
ref={(el) => {
tabRefs.current[index] = el
}}
onClick={() => setCurrentTab(tab)}
className={cn(
"relative cursor-pointer rounded-3xl px-2.5 py-[8px] font-[600] text-[13px]",
"text-stone-400 transition-all duration-500 ease-in-out hover:text-stone-950 dark:text-stone-500 hover:dark:text-stone-50",
{
"text-stone-950 dark:text-stone-50": currentTab === tab,
},
)}
>
<div className="relative z-20 flex items-center gap-1">
<p className="whitespace-nowrap">{t(tab)}</p>
</div>
</div>
))}
</div>
</div>
)
}

View File

@ -1,39 +0,0 @@
"use client"
import { useTheme } from "next-themes"
import { useEffect } from "react"
export function ThemeColorManager() {
const { theme, systemTheme } = useTheme()
useEffect(() => {
const updateThemeColor = () => {
const currentTheme = theme === "system" ? systemTheme : theme
const meta = document.querySelector('meta[name="theme-color"]')
if (!meta) {
const newMeta = document.createElement("meta")
newMeta.name = "theme-color"
document.head.appendChild(newMeta)
}
const themeColor =
currentTheme === "dark"
? "hsl(30 15% 8%)" // 深色模式背景色
: "hsl(0 0% 98%)" // 浅色模式背景色
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", themeColor)
}
// Update on mount and theme change
updateThemeColor()
// Listen for system theme changes
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
mediaQuery.addEventListener("change", updateThemeColor)
return () => mediaQuery.removeEventListener("change", updateThemeColor)
}, [theme, systemTheme])
return null
}

View File

@ -1,80 +1,41 @@
"use client"
"use client";
import { Button } from "@/components/ui/button"
import { useTranslations } from "next-intl";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { CheckIcon, MinusIcon, Moon, Sun } from "lucide-react"
import { useTranslations } from "next-intl"
import { useTheme } from "next-themes"
import { useId } from "react"
import { RadioGroup, RadioGroupItem } from "./ui/radio-group"
const items = [
{ value: "light", label: "Light", image: "/ui-light.png" },
{ value: "dark", label: "Dark", image: "/ui-dark.png" },
{ value: "system", label: "System", image: "/ui-system.png" },
]
} from "@/components/ui/dropdown-menu";
export function ModeToggle() {
const { setTheme, theme } = useTheme()
const t = useTranslations("ThemeSwitcher")
const handleSelect = (newTheme: string) => {
setTheme(newTheme)
}
const id = useId()
const { setTheme } = useTheme();
const t = useTranslations("ThemeSwitcher");
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="cursor-pointer rounded-full bg-white px-[9px] hover:bg-accent/50 dark:bg-black dark:hover:bg-accent/50"
>
<Sun className="dark:-rotate-90 h-4 w-4 rotate-0 scale-100 transition-all dark:scale-0" />
<Button variant="outline" size="sm" className="rounded-full px-[9px]">
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="px-2 pt-2 pb-1.5" align="end">
<fieldset className="space-y-4">
<RadioGroup className="flex gap-2" defaultValue={theme} onValueChange={handleSelect}>
{items.map((item) => (
<label key={`${id}-${item.value}`}>
<RadioGroupItem
id={`${id}-${item.value}`}
value={item.value}
className="peer sr-only after:absolute after:inset-0"
/>
<img
src={item.image}
alt={item.label}
width={88}
height={70}
className="relative cursor-pointer overflow-hidden rounded-[8px] border border-neutral-300 shadow-xs outline-none transition-[color,box-shadow] peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 peer-data-disabled:cursor-not-allowed peer-data-[state=checked]:bg-accent peer-data-disabled:opacity-50 dark:border-neutral-700"
/>
<span className="group mt-2 flex items-center gap-1 peer-data-[state=unchecked]:text-muted-foreground/70">
<CheckIcon
size={16}
className="group-peer-data-[state=unchecked]:hidden"
aria-hidden="true"
/>
<MinusIcon
size={16}
className="group-peer-data-[state=checked]:hidden"
aria-hidden="true"
/>
<span className="font-medium text-xs">{t(item.label)}</span>
</span>
</label>
))}
</RadioGroup>
</fieldset>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
{t("Light")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
{t("Dark")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
{t("System")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
);
}

View File

@ -1,16 +0,0 @@
"use client"
import { Loader } from "@/components/loading/Loader"
import { useTranslations } from "next-intl"
export default function GlobalLoading() {
const t = useTranslations("Global")
return (
<section className="mt-[3.2px] flex flex-col gap-4">
<div className="flex min-h-40 flex-col items-center justify-center font-medium text-sm">
{t("Loading")}
<Loader visible={true} />
</div>
</section>
)
}

View File

@ -1,13 +1,13 @@
const bars = Array(8).fill(0)
const bars = Array(8).fill(0);
export const Loader = ({ visible }: { visible: boolean }) => {
return (
<div className="hamster-loading-wrapper" data-visible={visible}>
<div className="hamster-spinner">
{bars.map((_, i) => (
<div className="hamster-loading-bar" key={`hamster-bar-${i + 1}`} />
<div className="hamster-loading-bar" key={`hamster-bar-${i}`} />
))}
</div>
</div>
)
}
);
};

View File

@ -1,37 +0,0 @@
import { BackIcon } from "@/components/Icon"
import { Skeleton } from "@/components/ui/skeleton"
import { useRouter } from "next/navigation"
export function ServerDetailChartLoading() {
return (
<div>
<section className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
<Skeleton className="h-[182px] w-full animate-none rounded-[5px] bg-muted-foreground/10" />
<Skeleton className="h-[182px] w-full animate-none rounded-[5px] bg-muted-foreground/10" />
<Skeleton className="h-[182px] w-full animate-none rounded-[5px] bg-muted-foreground/10" />
<Skeleton className="h-[182px] w-full animate-none rounded-[5px] bg-muted-foreground/10" />
<Skeleton className="h-[182px] w-full animate-none rounded-[5px] bg-muted-foreground/10" />
<Skeleton className="h-[182px] w-full animate-none rounded-[5px] bg-muted-foreground/10" />
</section>
</div>
)
}
export function ServerDetailLoading() {
const router = useRouter()
return (
<>
<div
onClick={() => {
router.push("/")
}}
className="flex flex-none cursor-pointer items-center gap-0.5 break-all font-semibold text-xl leading-none tracking-tight"
>
<BackIcon />
<Skeleton className="h-[20px] w-24 animate-none rounded-[5px] bg-muted-foreground/10" />
</div>
<Skeleton className="mt-3 flex h-[81px] w-1/2 animate-none flex-wrap gap-2 rounded-[5px] bg-muted-foreground/10" />
</>
)
}

View File

@ -1,101 +0,0 @@
import { cn } from "@/lib/utils"
interface Props {
max: number
value: number
min: number
className?: string
primaryColor?: string
}
export default function AnimatedCircularProgressBar({
max = 100,
min = 0,
value = 0,
primaryColor,
className,
}: Props) {
const circumference = 2 * Math.PI * 45
const percentPx = circumference / 100
const currentPercent = ((value - min) / (max - min)) * 100
return (
<div
className={cn("relative size-40 font-semibold text-2xl", className)}
style={
{
"--circle-size": "100px",
"--circumference": circumference,
"--percent-to-px": `${percentPx}px`,
"--gap-percent": "5",
"--offset-factor": "0",
"--transition-length": "1s",
"--transition-step": "200ms",
"--delay": "0s",
"--percent-to-deg": "3.6deg",
transform: "translateZ(0)",
} as React.CSSProperties
}
>
<svg fill="none" className="size-full" strokeWidth="2" viewBox="0 0 100 100">
<title>Circular Progress Bar</title>
{currentPercent <= 90 && currentPercent >= 0 && (
<circle
cx="50"
cy="50"
r="45"
strokeWidth="10"
strokeDashoffset="0"
strokeLinecap="round"
strokeLinejoin="round"
className="stroke-muted opacity-100"
style={
{
"--stroke-percent": 90 - currentPercent,
"--offset-factor-secondary": "calc(1 - var(--offset-factor))",
strokeDasharray:
"calc(var(--stroke-percent) * var(--percent-to-px)) var(--circumference)",
transform:
"rotate(calc(1turn - 90deg - (var(--gap-percent) * var(--percent-to-deg) * var(--offset-factor-secondary)))) scaleY(-1)",
transition: "all var(--transition-length) ease var(--delay)",
transformOrigin: "calc(var(--circle-size) / 2) calc(var(--circle-size) / 2)",
} as React.CSSProperties
}
/>
)}
<circle
cx="50"
cy="50"
r="45"
strokeWidth="10"
strokeDashoffset="0"
strokeLinecap="round"
strokeLinejoin="round"
className={cn("stroke-current opacity-100", {
"stroke-[var(--stroke-primary-color)]": primaryColor,
})}
style={
{
"--stroke-primary-color": primaryColor,
"--stroke-percent": currentPercent,
strokeDasharray:
"calc(var(--stroke-percent) * var(--percent-to-px)) var(--circumference)",
transition:
"var(--transition-length) ease var(--delay),stroke var(--transition-length) ease var(--delay)",
transitionProperty: "stroke-dasharray,transform",
transform:
"rotate(calc(-90deg + var(--gap-percent) * var(--offset-factor) * var(--percent-to-deg)))",
transformOrigin: "calc(var(--circle-size) / 2) calc(var(--circle-size) / 2)",
} as React.CSSProperties
}
/>
</svg>
<span
data-current-value={currentPercent}
className="fade-in absolute inset-0 m-auto size-fit animate-in delay-[var(--delay)] duration-[var(--transition-length)] ease-linear"
>
{currentPercent}
</span>
</div>
)
}

View File

@ -1,20 +1,21 @@
import Image from "next/image"
import Link from "next/link"
import Image from "next/image";
import Link from "next/link";
import React from "react";
export const AnimatedTooltip = ({
items,
}: {
items: {
id: number
name: string
designation: string
image: string
}[]
id: number;
name: string;
designation: string;
image: string;
}[];
}) => {
return (
<>
{items.map((item) => (
<div className="group -mr-4 relative" key={item.name}>
<div className="group relative -mr-4" key={item.name}>
<Link href="https://buycoffee.top" target="_blank">
<Image
width={40}
@ -22,11 +23,11 @@ export const AnimatedTooltip = ({
unoptimized
src={item.image}
alt={item.name}
className="relative m-0! h-6 w-6 rounded-full border-2 border-white object-cover object-top p-0! transition duration-500 group-hover:z-30 group-hover:scale-105"
className="relative !m-0 h-6 w-6 rounded-full border-2 border-white object-cover object-top !p-0 transition duration-500 group-hover:z-30 group-hover:scale-105"
/>
</Link>
</div>
))}
</>
)
}
);
};

Some files were not shown because too many files have changed in this diff Show More