Merge branch 'cloudflare' into cloudflare-dev

This commit is contained in:
hamster1963 2024-10-24 21:30:42 +08:00
commit 51e66ef2f1
32 changed files with 2205 additions and 286 deletions

View File

@ -18,11 +18,10 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- 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
@ -50,30 +49,19 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
changelog:
name: Generate Changelog
release:
runs-on: ubuntu-latest
needs: build-and-push
outputs:
release_body: ${{ steps.git-cliff.outputs.content }}
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate a changelog
uses: orhun/git-cliff-action@v4
id: git-cliff
- name: Set node
uses: actions/setup-node@v4
with:
config: git-cliff-config/cliff.toml
args: -vv --latest --strip 'footer'
registry-url: https://registry.npmjs.org/
node-version: lts/*
- run: npx changelogithub
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 }}
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}

View File

@ -1,4 +1,4 @@
FROM oven/bun:1 AS base
FROM --platform=$BUILDPLATFORM oven/bun:1 AS base
# Stage 1: Install dependencies
FROM base AS deps

View File

@ -5,10 +5,10 @@
</div>
| 一键部署到 Vercel-推荐 | Docker部署 | Cloudflare部署 | 如何更新? |
| ----------------------------------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------- |
| [部署简易教程](https://buycoffee.top/blog/tech/nezha) | [Docker 部署教程](https://buycoffee.top/blog/tech/nezha-docker) | [Cloudflare 部署教程](https://buycoffee.top/blog/tech/nezha-cloudflare) | [更新教程](https://buycoffee.top/blog/tech/nezha-upgrade) |
| [Vercel-demo](https://nezha-vercel.buycoffee.top) | [Docker-demo](https://nezha-docker.buycoffee.tech) | [Cloudflare-demo](https://nezha-cloudflare.buycoffee.tech) |
| 一键部署到 Vercel-推荐 | Docker部署 | Cloudflare部署 | 如何更新? |
| ----------------------------------------------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------- |
| [部署简易教程](https://buycoffee.top/blog/tech/nezha) | [Docker 部署教程](https://buycoffee.top/blog/tech/nezha-docker) | [Cloudflare 部署教程](https://buycoffee.top/blog/tech/nezha-cloudflare) | [更新教程](https://buycoffee.top/blog/tech/nezha-upgrade) |
| [Vercel-demo](https://nezha-vercel.buycoffee.top) | [Docker-demo](https://nezha-docker.buycoffee.tech) | [Cloudflare-demo](https://nezha-cloudflare.buycoffee.tech) [密码: nezhadash] |
#### 环境变量
@ -16,7 +16,7 @@
| ------------------------------ | ------------------------ | ------------------------------------------------------------- |
| NezhaBaseUrl | nezha 面板地址 | http://120.x.x.x:8008 |
| NezhaAuth | nezha 面板 API Token | 5hAY3QX6Nl9B3Uxxxx26KMvOMyXS1Udi |
| SitePassword | 页面密码 | 123456 |
| SitePassword | 页面密码 | **默认**:无密码 |
| DefaultLocale | 面板默认显示语言 | **默认**en [简中:zh 繁中:zh-t 英语:en 日语:ja] |
| ForceShowAllServers | 是否强制显示所有服务器 | **默认**false |
| NEXT_PUBLIC_NezhaFetchInterval | 获取数据间隔(毫秒) | **默认**2000 |

View File

@ -51,7 +51,7 @@ export function NetworkChartClient({
{
refreshInterval:
Number(getEnv("NEXT_PUBLIC_NezhaFetchInterval")) || 15000,
isPaused: () => !show,
isVisible: () => show,
},
);
@ -181,13 +181,7 @@ export const NetworkChart = React.memo(function NetworkChart({
<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 />
<CardTitle className="flex flex-none items-center gap-0.5 text-md">
{serverName}
</CardTitle>
<CardDescription className="text-xs">

View File

@ -12,13 +12,7 @@ export default function NetworkChartLoading() {
<Card>
<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
onClick={() => {
router.push(`/${locale}/`);
}}
className="flex items-center cursor-pointer gap-0.5 text-xl"
>
<BackIcon />
<CardTitle className="flex items-center gap-0.5 text-xl">
<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>

View File

@ -1,7 +1,7 @@
"use client";
import { ServerDetailChartLoading } from "@/app/[locale]/(main)/ClientComponents/ServerDetailLoading";
import { NezhaAPISafe } from "@/app/[locale]/types/nezha-api";
import { NezhaAPISafe, ServerApi } from "@/app/[locale]/types/nezha-api";
import AnimatedCircularProgressBar from "@/components/ui/animated-circular-progress-bar";
import { Card, CardContent } from "@/components/ui/card";
import { ChartConfig, ChartContainer } from "@/components/ui/chart";
@ -19,6 +19,7 @@ import {
YAxis,
} from "recharts";
import useSWR from "swr";
import useSWRImmutable from "swr/immutable";
type cpuChartData = {
timeStamp: string;
@ -62,12 +63,23 @@ export default function ServerDetailChartClient({
}) {
const t = useTranslations("ServerDetailChartClient");
const { data: allFallbackData } = useSWRImmutable<ServerApi>(
"/api/server",
nezhaFetcher,
);
const fallbackData = allFallbackData?.result?.find(
(item) => item.id === server_id,
);
const { data, error } = useSWR<NezhaAPISafe>(
`/api/detail?server_id=${server_id}`,
nezhaFetcher,
{
refreshInterval: Number(getEnv("NEXT_PUBLIC_NezhaFetchInterval")) || 5000,
isPaused: () => !show,
isVisible: () => show,
fallbackData,
revalidateOnMount: false,
revalidateIfStale: false,
},
);
@ -128,7 +140,7 @@ function CpuChart({ data }: { data: NezhaAPISafe }) {
} satisfies ChartConfig;
return (
<Card className=" rounded-sm">
<Card>
<CardContent className="px-6 py-3">
<section className="flex flex-col gap-1">
<div className="flex items-center justify-between">
@ -231,7 +243,7 @@ function ProcessChart({ data }: { data: NezhaAPISafe }) {
} satisfies ChartConfig;
return (
<Card className=" rounded-sm">
<Card>
<CardContent className="px-6 py-3">
<section className="flex flex-col gap-1">
<div className="flex items-center justify-between">
@ -324,7 +336,7 @@ function MemChart({ data }: { data: NezhaAPISafe }) {
} satisfies ChartConfig;
return (
<Card className=" rounded-sm">
<Card>
<CardContent className="px-6 py-3">
<section className="flex flex-col gap-1">
<div className="flex items-center">
@ -445,7 +457,7 @@ function DiskChart({ data }: { data: NezhaAPISafe }) {
} satisfies ChartConfig;
return (
<Card className="rounded-sm">
<Card>
<CardContent className="px-6 py-3">
<section className="flex flex-col gap-1">
<div className="flex items-center justify-between">
@ -557,7 +569,7 @@ function NetworkChart({ data }: { data: NezhaAPISafe }) {
} satisfies ChartConfig;
return (
<Card className=" rounded-sm">
<Card>
<CardContent className="px-6 py-3">
<section className="flex flex-col gap-1">
<div className="flex items-center">
@ -677,7 +689,7 @@ function ConnectChart({ data }: { data: NezhaAPISafe }) {
} satisfies ChartConfig;
return (
<Card className="rounded-sm">
<Card>
<CardContent className="px-6 py-3">
<section className="flex flex-col gap-1">
<div className="flex items-center">

View File

@ -1,15 +1,18 @@
"use client";
import { ServerDetailLoading } from "@/app/[locale]/(main)/ClientComponents/ServerDetailLoading";
import { NezhaAPISafe } from "@/app/[locale]/types/nezha-api";
import { NezhaAPISafe, ServerApi } from "@/app/[locale]/types/nezha-api";
import { BackIcon } from "@/components/Icon";
import ServerFlag from "@/components/ServerFlag";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import getEnv from "@/lib/env-entry";
import { cn, formatBytes, nezhaFetcher } from "@/lib/utils";
import { useLocale, useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import useSWR from "swr";
import useSWRImmutable from "swr/immutable";
export default function ServerDetailClient({
server_id,
@ -19,11 +22,47 @@ export default function ServerDetailClient({
const t = useTranslations("ServerDetailClient");
const router = useRouter();
const locale = useLocale();
const [hasHistory, setHasHistory] = useState(false);
useEffect(() => {
window.scrollTo(0, 0);
}, []);
useEffect(() => {
const previousPath = sessionStorage.getItem("lastPath");
if (previousPath) {
setHasHistory(true);
} else {
const currentPath = window.location.pathname;
sessionStorage.setItem("lastPath", currentPath);
}
}, []);
const linkClick = () => {
if (hasHistory) {
router.back();
} else {
router.push(`/${locale}/`);
}
};
const { data: allFallbackData } = useSWRImmutable<ServerApi>(
"/api/server",
nezhaFetcher,
);
const fallbackData = allFallbackData?.result?.find(
(item) => item.id === server_id,
);
const { data, error } = useSWR<NezhaAPISafe>(
`/api/detail?server_id=${server_id}`,
nezhaFetcher,
{
refreshInterval: Number(getEnv("NEXT_PUBLIC_NezhaFetchInterval")) || 5000,
fallbackData,
revalidateOnMount: false,
revalidateIfStale: false,
},
);
@ -45,9 +84,7 @@ export default function ServerDetailClient({
return (
<div>
<div
onClick={() => {
router.push(`/${locale}/`);
}}
onClick={linkClick}
className="flex flex-none cursor-pointer font-semibold leading-none items-center break-all tracking-tight gap-0.5 text-xl"
>
<BackIcon />
@ -60,7 +97,7 @@ export default function ServerDetailClient({
<p className="text-xs text-muted-foreground">{t("status")}</p>
<Badge
className={cn(
"text-[10px] rounded-[6px] w-fit px-1 py-0 dark:text-white",
"text-[9px] rounded-[6px] w-fit px-1 py-0 -mt-[0.3px] dark:text-white",
{
" bg-green-800": data?.online_status,
" bg-red-600": !data?.online_status,
@ -115,6 +152,22 @@ export default function ServerDetailClient({
</section>
</CardContent>
</Card>
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">{t("Region")}</p>
<section className="flex items-start gap-1">
<div className="text-xs text-start">
{data?.host.CountryCode.toUpperCase()}
</div>
<ServerFlag
className="text-[11px] -mt-[1px]"
country_code={data?.host.CountryCode}
/>
</section>
</section>
</CardContent>
</Card>
</section>
<section className="flex flex-wrap gap-2 mt-1">
<Card className="rounded-[10px] bg-transparent border-none shadow-none">

View File

@ -6,17 +6,56 @@ import Switch from "@/components/Switch";
import getEnv from "@/lib/env-entry";
import { nezhaFetcher } from "@/lib/utils";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import useSWR from "swr";
export default function ServerListClient() {
const t = useTranslations("ServerListClient");
const containerRef = useRef<HTMLDivElement>(null);
const defaultTag = t("defaultTag");
const [tag, setTag] = useState<string>(t("defaultTag"));
const [tag, setTag] = useState<string>(defaultTag);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
const savedTag = sessionStorage.getItem("selectedTag") || defaultTag;
setTag(savedTag);
restoreScrollPosition();
setIsMounted(true);
}, []);
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 } = useSWR<ServerApi>("/api/server", nezhaFetcher, {
refreshInterval: Number(getEnv("NEXT_PUBLIC_NezhaFetchInterval")) || 2000,
});
if (error)
return (
<div className="flex flex-col items-center justify-center">
@ -24,32 +63,38 @@ export default function ServerListClient() {
<p className="text-sm font-medium opacity-40">{t("error_message")}</p>
</div>
);
if (!data?.result) return null;
if (!data?.result || !isMounted) 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 allTag = sortedServers.map((server) => server.tag).filter(Boolean);
const uniqueTags = [...new Set(allTag)];
uniqueTags.unshift(t("defaultTag"));
uniqueTags.unshift(defaultTag);
const filteredServers =
tag === t("defaultTag")
tag === defaultTag
? sortedServers
: sortedServers.filter((server) => server.tag === tag);
return (
<>
{getEnv("NEXT_PUBLIC_ShowTag") === "true" && uniqueTags.length > 1 && (
<Switch allTag={uniqueTags} nowTag={tag} setTag={setTag} />
<Switch
allTag={uniqueTags}
nowTag={tag}
onTagChange={handleTagChange}
/>
)}
<section className="grid grid-cols-1 gap-2 md:grid-cols-2">
<section
ref={containerRef}
className="grid grid-cols-1 gap-2 md:grid-cols-2"
>
{filteredServers.map((serverInfo) => (
<ServerCard key={serverInfo.id} serverInfo={serverInfo} />
))}

View File

@ -18,13 +18,16 @@ export default function ServerOverviewClient() {
);
const disableCartoon = getEnv("NEXT_PUBLIC_DisableCartoon") === "true";
if (error)
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">
Error status:{error.status} {error.info?.cause ?? error.message}
</p>
<p className="text-sm font-medium opacity-40">{t("error_message")}</p>
</div>
);
}
return (
<>

View File

@ -29,12 +29,18 @@ export default function Page({ params }: { params: { id: string } }) {
</div>
<Separator className="flex-1" />
</section>
{currentTab === tabs[0] && (
<ServerDetailChartClient server_id={Number(params.id)} show={true} />
)}
{currentTab === tabs[1] && (
<NetworkChartClient server_id={Number(params.id)} show={true} />
)}
<div style={{ display: currentTab === tabs[0] ? "block" : "none" }}>
<ServerDetailChartClient
server_id={Number(params.id)}
show={currentTab === tabs[0]}
/>
</div>
<div style={{ display: currentTab === tabs[1] ? "block" : "none" }}>
<NetworkChartClient
server_id={Number(params.id)}
show={currentTab === tabs[1]}
/>
</div>
</div>
);
}

View File

@ -1,7 +1,9 @@
import pack from "@/package.json";
import { useTranslations } from "next-intl";
export default function Footer() {
const t = useTranslations("Footer");
const version = pack.version;
return (
<footer className="mx-auto w-full max-w-5xl">
<section className="flex flex-col">
@ -14,6 +16,13 @@ export default function Footer() {
>
{t("a_303-585_GitHub")}
</a>
<a
href={`https://github.com/hamster1963/nezha-dash/releases/tag/v${version}`}
target="_blank"
className="cursor-pointer font-normal underline decoration-yellow-500 decoration-2 underline-offset-2 dark:decoration-yellow-500/50"
>
v{version}
</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")}

View File

@ -26,6 +26,7 @@ function Header() {
<section className="flex items-center justify-between">
<section
onClick={() => {
sessionStorage.removeItem("selectedTag");
router.push(`/${locale}/`);
}}
className="flex cursor-pointer items-center text-base font-medium"
@ -98,7 +99,9 @@ function Overview() {
</p>
{mouted ? (
<p className="opacity-1 text-sm font-medium">{timeString}</p>
) : <Skeleton className="h-[20px] w-[50px] rounded-[5px] bg-muted-foreground/10 animate-none"></Skeleton>}
) : (
<Skeleton className="h-[20px] w-[50px] rounded-[5px] bg-muted-foreground/10 animate-none"></Skeleton>
)}
</div>
</section>
);

View File

@ -1,9 +1,8 @@
import Footer from "@/app/[locale]/(main)/footer";
import Header from "@/app/[locale]/(main)/header";
import { auth } from "@/auth";
import { SignIn } from "@/components/sign-in";
import { SignIn } from "@/components/SignIn";
import getEnv from "@/lib/env-entry";
import { redirect } from "next/navigation";
import React from "react";
type DashboardProps = {
@ -12,19 +11,12 @@ type DashboardProps = {
export default async function MainLayout({ children }: DashboardProps) {
const session = await auth();
if (!session && getEnv("SitePassword")) {
// if (getEnv("CF_PAGES")) {
// redirect("/api/auth/signin");
// } else {
return <SignIn />;
// }
}
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}
{!session && getEnv("SitePassword") ? <SignIn /> : children}
<Footer />
</main>
</div>

View File

@ -1,5 +1,4 @@
// @auto-i18n-check. Please do not delete the line.
import { auth } from "@/auth";
import { locales } from "@/i18n-metadata";
import getEnv from "@/lib/env-entry";
import { cn } from "@/lib/utils";
@ -13,8 +12,6 @@ import { ThemeProvider } from "next-themes";
import { Inter as FontSans } from "next/font/google";
import React from "react";
import "/node_modules/flag-icons/css/flag-icons.min.css";
const fontSans = FontSans({
subsets: ["latin"],
variable: "--font-sans",
@ -65,6 +62,10 @@ export default function LocaleLayout({
<html lang={locale} suppressHydrationWarning>
<head>
<PublicEnvScript />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.0.0/css/flag-icons.min.css"
/>
</head>
<body
className={cn(

View File

@ -2,4 +2,4 @@ import { handlers } from "@/auth" // Referring to the auth.ts we just created
export const runtime = 'edge';
export const { GET, POST } = handlers
export const { GET, POST } = handlers;

View File

@ -2,36 +2,45 @@ import { NezhaAPISafe } from "@/app/[locale]/types/nezha-api";
import { auth } from "@/auth";
import getEnv from "@/lib/env-entry";
import { GetServerDetail } from "@/lib/serverFetch";
import { redirect } from "next/navigation";
import { NextResponse } from "next/server";
export const runtime = 'edge';
export const dynamic = "force-dynamic";
interface NezhaDataResponse {
error?: string;
data?: NezhaAPISafe;
}
export const GET = auth(async function GET(req) {
if (!req.auth && getEnv("SitePassword")) {
return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
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 },
);
}
const response = (await GetServerDetail({
server_id: parseInt(server_id),
})) as NezhaDataResponse;
if (response.error) {
console.log(response.error);
return NextResponse.json({ error: response.error }, { status: 400 });
try {
const serverIdNum = parseInt(server_id, 10);
if (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) {
console.error("Error in GET handler:", error);
// @ts-ignore
const statusCode = error.statusCode || 500;
// @ts-ignore
const message = error.message || "Internal Server Error";
return NextResponse.json({ error: message }, { status: statusCode });
}
return NextResponse.json(response, { status: 200 });
});

View File

@ -1,21 +1,16 @@
import { ServerMonitorChart } from "@/app/[locale]/types/nezha-api";
import { auth } from "@/auth";
import getEnv from "@/lib/env-entry";
import { GetServerMonitor } from "@/lib/serverFetch";
import { redirect } from "next/navigation";
import { NextResponse } from "next/server";
export const runtime = "edge";
export const dynamic = "force-dynamic";
interface NezhaDataResponse {
error?: string;
data?: ServerMonitorChart;
}
export const GET = auth(async function GET(req) {
if (!req.auth && getEnv("SitePassword")) {
return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
redirect("/");
}
const { searchParams } = new URL(req.url);
@ -26,12 +21,26 @@ export const GET = auth(async function GET(req) {
{ status: 400 },
);
}
const response = (await GetServerMonitor({
server_id: parseInt(server_id),
})) as NezhaDataResponse;
if (response.error) {
console.log(response.error);
return NextResponse.json({ error: response.error }, { status: 400 });
try {
const serverIdNum = parseInt(server_id, 10);
if (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) {
console.error("Error in GET handler:", error);
// @ts-ignore
const statusCode = error.statusCode || 500;
// @ts-ignore
const message = error.message || "Internal Server Error";
return NextResponse.json({ error: message }, { status: statusCode });
}
return NextResponse.json(response, { status: 200 });
});

View File

@ -1,83 +1,58 @@
import { NezhaAPI, ServerApi } from "@/app/[locale]/types/nezha-api";
import { MakeOptional } from "@/app/[locale]/types/utils";
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";
export const dynamic = "force-dynamic";
export const runtime = "edge";
interface NezhaDataResponse {
error?: string;
data?: ServerApi;
}
export const GET = auth(async function GET(req) {
if (!req.auth && getEnv("SitePassword")) {
return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
redirect("/");
}
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 });
});
async function GetNezhaData() {
var nezhaBaseUrl = getEnv("NezhaBaseUrl");
if (!nezhaBaseUrl) {
console.log("NezhaBaseUrl is not set");
return;
}
// Remove trailing slash
if (nezhaBaseUrl[nezhaBaseUrl.length - 1] === "/") {
nezhaBaseUrl = nezhaBaseUrl.slice(0, -1);
}
try {
const response = await fetch(nezhaBaseUrl + "/api/v1/server/details", {
headers: {
Authorization: getEnv("NezhaAuth") as string,
},
next: {
revalidate: 0,
},
});
const nezhaData = (await response.json()).result as NezhaAPI[];
const data: ServerApi = {
live_servers: 0,
offline_servers: 0,
total_in_bandwidth: 0,
total_out_bandwidth: 0,
result: [],
};
const timestamp = Date.now() / 1000;
data.result = nezhaData.map(
(element: MakeOptional<NezhaAPI, "ipv4" | "ipv6" | "valid_ip">) => {
if (timestamp - element.last_active > 300) {
data.offline_servers += 1;
element.online_status = false;
} else {
data.live_servers += 1;
element.online_status = true;
}
data.total_in_bandwidth += element.status.NetInTransfer;
data.total_out_bandwidth += element.status.NetOutTransfer;
delete element.ipv4;
delete element.ipv6;
delete element.valid_ip;
return element;
},
);
return data;
const data = await GetNezhaData();
return NextResponse.json(data, { status: 200 });
} catch (error) {
return error;
console.error("Error in GET handler:", error);
// @ts-ignore
const statusCode = error.statusCode || 500;
// @ts-ignore
const message = error.message || "Internal Server Error";
return NextResponse.json({ error: message }, { status: statusCode });
}
}
});
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";
export const dynamic = "force-dynamic";
export const runtime = "edge";
export const GET = auth(async function GET(req) {
if (!req.auth && getEnv("SitePassword")) {
redirect("/");
}
try {
const data = await GetNezhaData();
return NextResponse.json(data, { status: 200 });
} catch (error) {
console.error("Error in GET handler:", error);
// @ts-ignore
const statusCode = error.statusCode || 500;
// @ts-ignore
const message = error.message || "Internal Server Error";
return NextResponse.json({ error: message }, { status: statusCode });
}
});

31
auth.ts
View File

@ -1,21 +1,32 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import CredentialsProvider from "next-auth/providers/credentials";
import getEnv from "./lib/env-entry";
export const { handlers, signIn, signOut, auth } = NextAuth({
secret: "H7Fijn9veJRkbizIwUQEpBAzzhRwkv7/ZoB5sGF5cwm5",
secret: process.env.AUTH_SECRET ?? "this_is_nezha_dash_web_secret",
trustHost: (process.env.AUTH_TRUST_HOST as boolean | undefined) ?? true,
providers: [
Credentials({
credentials: {
password: {},
},
authorize: async (credentials) => {
if (credentials.password === getEnv("SitePassword")) {
return { id: "0" };
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 null;
return { error: "Invalid password" };
},
}),
],
callbacks: {
async signIn({ user }) {
// @ts-ignore
if (user.error) {
return false;
}
return true;
},
},
});

View File

@ -1,8 +1,15 @@
import getEnv from "@/lib/env-entry";
import { cn } from "@/lib/utils";
import getUnicodeFlagIcon from "country-flag-icons/unicode";
import { useEffect, useState } from "react";
export default function ServerFlag({ country_code }: { country_code: string }) {
export default function ServerFlag({
country_code,
className,
}: {
country_code: string;
className?: string;
}) {
const [supportsEmojiFlags, setSupportsEmojiFlags] = useState(false);
const useSvgFlag = getEnv("NEXT_PUBLIC_ForceUseSvgFlag") === "true";
@ -38,9 +45,9 @@ export default function ServerFlag({ country_code }: { country_code: string }) {
}
return (
<span className="text-[12px] text-muted-foreground">
<span className={cn("text-[12px] text-muted-foreground", className)}>
{useSvgFlag || !supportsEmojiFlags ? (
<span className={`fi fi-${country_code}`}></span>
<span className={`fi fi-${country_code}`} />
) : (
getUnicodeFlagIcon(country_code)
)}

85
components/SignIn.tsx Normal file
View File

@ -0,0 +1,85 @@
"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-col items-center justify-start 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="text-red-500 text-sm font-semibold">
{t("ErrorMessage")}
</p>
)}
{successState && (
<p className="text-green-500 text-sm font-semibold">
{t("SuccessMessage")}
</p>
)}
<p className="text-base font-semibold">{t("SignInMessage")}</p>
<input
className="px-1 border-[1px] rounded-[5px]"
name="password"
type="password"
/>
</label>
<button
className="px-1.5 py-0.5 w-fit flex items-center gap-1 text-sm font-semibold rounded-[8px] border bg-card hover:brightness-95 transition-all text-card-foreground shadow-lg shadow-neutral-200/40 dark:shadow-none"
disabled={loading}
>
{t("Submit")}
{loading && <Loader visible={true} />}
</button>
</section>
</form>
);
}

View File

@ -2,24 +2,68 @@
import { cn } from "@/lib/utils";
import { motion } from "framer-motion";
import React from "react";
import React, { createRef, useEffect, useRef, useState } from "react";
export default function Switch({
allTag,
nowTag,
setTag,
onTagChange,
}: {
allTag: string[];
nowTag: string;
setTag: (tag: string) => void;
onTagChange: (tag: string) => void;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const tagRefs = useRef(allTag.map(() => createRef<HTMLDivElement>()));
useEffect(() => {
const savedTag = sessionStorage.getItem("selectedTag");
if (savedTag && allTag.includes(savedTag)) {
onTagChange(savedTag);
}
}, [allTag]);
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 currentTagRef = tagRefs.current[allTag.indexOf(nowTag)];
if (currentTagRef && currentTagRef.current) {
currentTagRef.current.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "center",
});
}
}, [nowTag]);
return (
<div className="scrollbar-hidden z-50 flex flex-col items-start overflow-x-scroll rounded-[50px]">
<div
ref={scrollRef}
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) => (
{allTag.map((tag, index) => (
<div
key={tag}
onClick={() => setTag(tag)}
ref={tagRefs.current[index]}
onClick={() => onTagChange(tag)}
className={cn(
"relative cursor-pointer rounded-3xl px-2.5 py-[8px] text-[13px] font-[600] transition-all duration-500",
nowTag === tag

View File

@ -14,7 +14,7 @@ export default function TabSwitch({
setCurrentTab: (tab: string) => void;
}) {
return (
<div className="z-50 flex flex-col items-start overflow-x-scroll rounded-[50px]">
<div className="z-50 flex flex-col items-start rounded-[50px]">
<div className="flex items-center gap-1 rounded-[50px] bg-stone-100 p-[3px] dark:bg-stone-800">
{tabs.map((tab: string) => (
<div

View File

@ -8,18 +8,17 @@ import { unstable_noStore as noStore } from "next/cache";
export async function GetNezhaData() {
noStore();
var nezhaBaseUrl = getEnv("NezhaBaseUrl");
let nezhaBaseUrl = getEnv("NezhaBaseUrl");
if (!nezhaBaseUrl) {
console.log("NezhaBaseUrl is not set");
return { error: "NezhaBaseUrl is not set" };
console.error("NezhaBaseUrl is not set");
throw new Error("NezhaBaseUrl is not set");
}
// Remove trailing slash
if (nezhaBaseUrl[nezhaBaseUrl.length - 1] === "/") {
nezhaBaseUrl = nezhaBaseUrl.slice(0, -1);
}
nezhaBaseUrl = nezhaBaseUrl.replace(/\/$/, "");
try {
const response = await fetch(nezhaBaseUrl + "/api/v1/server/details", {
const response = await fetch(`${nezhaBaseUrl}/api/v1/server/details`, {
headers: {
Authorization: getEnv("NezhaAuth") as string,
},
@ -27,12 +26,19 @@ export async function GetNezhaData() {
revalidate: 0,
},
});
const resData = await response.json();
const nezhaData = resData.result as NezhaAPI[];
if (!nezhaData) {
console.log(resData);
return { error: "NezhaData fetch failed" };
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to fetch data: ${response.status} ${errorText}`);
}
const resData = await response.json();
if (!resData.result) {
throw new Error("NezhaData fetch failed: 'result' field is missing");
}
const nezhaData = resData.result as NezhaAPI[];
const data: ServerApi = {
live_servers: 0,
offline_servers: 0,
@ -41,30 +47,27 @@ export async function GetNezhaData() {
result: [],
};
var forceShowAllServers = getEnv("ForceShowAllServers");
let nezhaDataFiltered: NezhaAPI[];
if (forceShowAllServers === "true") {
nezhaDataFiltered = nezhaData;
} else {
// remove hidden servers
nezhaDataFiltered = nezhaData.filter(
(element) => !element.hide_for_guest,
);
}
const forceShowAllServers = getEnv("ForceShowAllServers") === "true";
const nezhaDataFiltered = forceShowAllServers
? nezhaData
: nezhaData.filter((element) => !element.hide_for_guest);
const timestamp = Date.now() / 1000;
data.result = nezhaDataFiltered.map(
(element: MakeOptional<NezhaAPI, "ipv4" | "ipv6" | "valid_ip">) => {
if (timestamp - element.last_active > 300) {
data.offline_servers += 1;
element.online_status = false;
} else {
const isOnline = timestamp - element.last_active <= 300;
element.online_status = isOnline;
if (isOnline) {
data.live_servers += 1;
element.online_status = true;
} else {
data.offline_servers += 1;
}
data.total_out_bandwidth += element.status.NetOutTransfer;
data.total_in_bandwidth += element.status.NetInTransfer;
// Remove unwanted properties
delete element.ipv4;
delete element.ipv6;
delete element.valid_ip;
@ -75,25 +78,24 @@ export async function GetNezhaData() {
return data;
} catch (error) {
return error;
console.error("GetNezhaData error:", error);
throw error; // Rethrow the error to be caught by the caller
}
}
export async function GetServerMonitor({ server_id }: { server_id: number }) {
var nezhaBaseUrl = getEnv("NezhaBaseUrl");
let nezhaBaseUrl = getEnv("NezhaBaseUrl");
if (!nezhaBaseUrl) {
console.log("NezhaBaseUrl is not set");
return { error: "NezhaBaseUrl is not set" };
console.error("NezhaBaseUrl is not set");
throw new Error("NezhaBaseUrl is not set");
}
// Remove trailing slash
if (nezhaBaseUrl[nezhaBaseUrl.length - 1] === "/") {
nezhaBaseUrl = nezhaBaseUrl.slice(0, -1);
}
nezhaBaseUrl = nezhaBaseUrl.replace(/\/$/, "");
try {
const response = await fetch(
nezhaBaseUrl + `/api/v1/monitor/${server_id}`,
`${nezhaBaseUrl}/api/v1/monitor/${server_id}`,
{
headers: {
Authorization: getEnv("NezhaAuth") as string,
@ -103,33 +105,40 @@ export async function GetServerMonitor({ server_id }: { server_id: number }) {
},
},
);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to fetch data: ${response.status} ${errorText}`);
}
const resData = await response.json();
const monitorData = resData.result;
if (!monitorData) {
console.log(resData);
return { error: "MonitorData fetch failed" };
console.error("MonitorData fetch failed:", resData);
throw new Error("MonitorData fetch failed: 'result' field is missing");
}
return monitorData;
} catch (error) {
return error;
console.error("GetServerMonitor error:", error);
throw error;
}
}
export async function GetServerDetail({ server_id }: { server_id: number }) {
var nezhaBaseUrl = getEnv("NezhaBaseUrl");
let nezhaBaseUrl = getEnv("NezhaBaseUrl");
if (!nezhaBaseUrl) {
console.log("NezhaBaseUrl is not set");
return { error: "NezhaBaseUrl is not set" };
console.error("NezhaBaseUrl is not set");
throw new Error("NezhaBaseUrl is not set");
}
// Remove trailing slash
if (nezhaBaseUrl[nezhaBaseUrl.length - 1] === "/") {
nezhaBaseUrl = nezhaBaseUrl.slice(0, -1);
}
nezhaBaseUrl = nezhaBaseUrl.replace(/\/$/, "");
try {
const response = await fetch(
nezhaBaseUrl + `/api/v1/server/details?id=${server_id}`,
`${nezhaBaseUrl}/api/v1/server/details?id=${server_id}`,
{
headers: {
Authorization: getEnv("NezhaAuth") as string,
@ -139,30 +148,38 @@ export async function GetServerDetail({ server_id }: { server_id: number }) {
},
},
);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to fetch data: ${response.status} ${errorText}`);
}
const resData = await response.json();
const detailDataList = resData.result;
if (!detailDataList) {
console.log(resData);
return { error: "MonitorData fetch failed" };
if (
!detailDataList ||
!Array.isArray(detailDataList) ||
detailDataList.length === 0
) {
console.error("MonitorData fetch failed:", resData);
throw new Error(
"MonitorData fetch failed: 'result' field is missing or empty",
);
}
const timestamp = Date.now() / 1000;
const detailData = detailDataList.map(
(element: MakeOptional<NezhaAPI, "ipv4" | "ipv6" | "valid_ip">) => {
if (timestamp - element.last_active > 300) {
element.online_status = false;
} else {
element.online_status = true;
}
delete element.ipv4;
delete element.ipv6;
delete element.valid_ip;
return element;
},
)[0];
const detailData = detailDataList.map((element) => {
element.online_status = timestamp - element.last_active <= 300;
delete element.ipv4;
delete element.ipv6;
delete element.valid_ip;
return element;
})[0];
return detailData;
} catch (error) {
return error;
console.error("GetServerDetail error:", error);
throw error; // Rethrow the error to be handled by the caller
}
}

View File

@ -71,19 +71,20 @@ export const fetcher = (url: string) =>
throw err;
});
export const nezhaFetcher = (url: string) =>
fetch(url)
.then((res) => {
if (!res.ok) {
throw new Error(res.statusText);
}
return res.json();
})
.then((data) => data)
.catch((err) => {
console.error(err);
throw err;
});
export const nezhaFetcher = async (url: string) => {
const res = await fetch(url);
if (!res.ok) {
const error = new Error("An error occurred while fetching the data.");
// @ts-ignore
error.info = await res.json();
// @ts-ignore
error.status = res.status;
throw error;
}
return res.json();
};
export function formatRelativeTime(timestamp: number): string {
const now = Date.now();

View File

@ -23,6 +23,12 @@
"Detail": "Detail",
"Network": "Network"
},
"SignIn": {
"SignInMessage": "Please enter the password",
"Submit": "Login",
"ErrorMessage": "Invalid password",
"SuccessMessage": "Login successful"
},
"ServerCardPopover": {
"System": "System",
"CPU": "CPU",
@ -52,6 +58,7 @@
"Arch": "Arch",
"Mem": "Mem",
"Disk": "Disk",
"Region": "Region",
"System": "System",
"CPU": "CPU"
},

View File

@ -23,6 +23,12 @@
"Detail": "詳細",
"Network": "ネットワーク"
},
"SignIn": {
"SignInMessage": "パスワードを入力してください",
"Submit": "ログイン",
"ErrorMessage": "パスワードが間違っています",
"SuccessMessage": "ログイン成功"
},
"ServerCardPopover": {
"System": "システム",
"CPU": "CPU",
@ -52,6 +58,7 @@
"Arch": "アーキテクチャ",
"Mem": "メモリ",
"Disk": "ディスク",
"Region": "地域",
"System": "システム",
"CPU": "CPU"
},

View File

@ -23,6 +23,12 @@
"Detail": "詳細",
"Network": "網路"
},
"SignIn": {
"SignInMessage": "請輸入密碼",
"Submit": "登入",
"ErrorMessage": "密碼錯誤",
"SuccessMessage": "登入成功"
},
"ServerCardPopover": {
"System": "系統",
"CPU": "CPU",
@ -52,6 +58,7 @@
"Arch": "架構",
"Mem": "記憶體",
"Disk": "磁碟",
"Region": "地區",
"System": "系統",
"CPU": "CPU"
},

View File

@ -23,6 +23,12 @@
"Detail": "详情",
"Network": "网络"
},
"SignIn": {
"SignInMessage": "请输入密码",
"Submit": "登录",
"ErrorMessage": "密码错误",
"SuccessMessage": "登录成功"
},
"ServerCardPopover": {
"System": "系统",
"CPU": "CPU",
@ -52,6 +58,7 @@
"Arch": "架构",
"Mem": "内存",
"Disk": "磁盘",
"Region": "地区",
"System": "系统",
"CPU": "CPU"
},

View File

@ -23,11 +23,6 @@ const withPWA = withPWAInit({
const nextConfig = {
output: "standalone",
reactStrictMode: true,
experimental: {
serverActions: {
allowedOrigins: ["*"],
},
},
logging: {
fetches: {
fullUrl: true,

View File

@ -1,6 +1,6 @@
{
"name": "nezha-dash",
"version": "0.1.0",
"version": "0.8.0",
"private": true,
"scripts": {
"dev": "next dev -p 3020",

1638
styles/flag-icons.min.css vendored Normal file

File diff suppressed because it is too large Load Diff