mirror of
https://github.com/hamster1963/nezha-dash.git
synced 2025-04-24 21:10:45 +08:00
commit
aafc19872b
@ -15,3 +15,4 @@ 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_Links='[{"link":"https://github.com/hamster1963/nezha-dash","name":"GitHub"},{"link":"https://buycoffee.top/coffee","name":"Buycoffee☕️"}]'
|
||||||
NEXT_PUBLIC_DisableIndex=false
|
NEXT_PUBLIC_DisableIndex=false
|
||||||
NEXT_PUBLIC_ShowTagCount=false
|
NEXT_PUBLIC_ShowTagCount=false
|
||||||
|
NEXT_PUBLIC_ShowIpInfo=false
|
||||||
|
116
app/(main)/ClientComponents/ServerIPInfo.tsx
Normal file
116
app/(main)/ClientComponents/ServerIPInfo.tsx
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { 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="flex flex-wrap gap-2 mb-4">
|
||||||
|
<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">{"ASN"}</p>
|
||||||
|
<div className="text-xs">
|
||||||
|
{data.asn.autonomous_system_organization}
|
||||||
|
</div>
|
||||||
|
</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("asn_number")}</p>
|
||||||
|
<div className="text-xs">
|
||||||
|
AS{data.asn.autonomous_system_number}
|
||||||
|
</div>
|
||||||
|
</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("registered_country")}
|
||||||
|
</p>
|
||||||
|
<div className="text-xs">
|
||||||
|
{data.city.registered_country?.names.en}
|
||||||
|
</div>
|
||||||
|
</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">{"ISO"}</p>
|
||||||
|
<div className="text-xs">{data.city.country?.iso_code}</div>
|
||||||
|
</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("city")}</p>
|
||||||
|
<div className="text-xs">{data.city.city?.names.en}</div>
|
||||||
|
</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("longitude")}</p>
|
||||||
|
<div className="text-xs">{data.city.location?.longitude}</div>
|
||||||
|
</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("latitude")}</p>
|
||||||
|
<div className="text-xs">{data.city.location?.latitude}</div>
|
||||||
|
</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("time_zone")}</p>
|
||||||
|
<div className="text-xs">{data.city.location?.time_zone}</div>
|
||||||
|
</section>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{data.city.postal && (
|
||||||
|
<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("postal_code")}
|
||||||
|
</p>
|
||||||
|
<div className="text-xs">{data.city.postal?.code}</div>
|
||||||
|
</section>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -5,8 +5,11 @@ import ServerDetailChartClient from "@/app/(main)/ClientComponents/ServerDetailC
|
|||||||
import ServerDetailClient from "@/app/(main)/ClientComponents/ServerDetailClient";
|
import ServerDetailClient from "@/app/(main)/ClientComponents/ServerDetailClient";
|
||||||
import TabSwitch from "@/components/TabSwitch";
|
import TabSwitch from "@/components/TabSwitch";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import getEnv from "@/lib/env-entry";
|
||||||
import { use, useState } from "react";
|
import { use, useState } from "react";
|
||||||
|
|
||||||
|
import ServerIPInfo from "../../ClientComponents/ServerIPInfo";
|
||||||
|
|
||||||
export default function Page(props: { params: Promise<{ id: string }> }) {
|
export default function Page(props: { params: Promise<{ id: string }> }) {
|
||||||
const params = use(props.params);
|
const params = use(props.params);
|
||||||
const tabs = ["Detail", "Network"];
|
const tabs = ["Detail", "Network"];
|
||||||
@ -32,6 +35,9 @@ export default function Page(props: { params: Promise<{ id: string }> }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: currentTab === tabs[1] ? "block" : "none" }}>
|
<div style={{ display: currentTab === tabs[1] ? "block" : "none" }}>
|
||||||
|
{getEnv("NEXT_PUBLIC_ShowIpInfo") && (
|
||||||
|
<ServerIPInfo server_id={Number(params.id)} />
|
||||||
|
)}
|
||||||
<NetworkChartClient
|
<NetworkChartClient
|
||||||
server_id={Number(params.id)}
|
server_id={Number(params.id)}
|
||||||
show={currentTab === tabs[1]}
|
show={currentTab === tabs[1]}
|
||||||
|
72
app/api/server-ip/route.ts
Normal file
72
app/api/server-ip/route.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { auth } from "@/auth";
|
||||||
|
import getEnv from "@/lib/env-entry";
|
||||||
|
import { GetServerIP } from "@/lib/serverFetch";
|
||||||
|
import fs from "fs";
|
||||||
|
import { AsnResponse, CityResponse, Reader } from "maxmind";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
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: "NEXT_PUBLIC_ShowIpInfo is disable" },
|
||||||
|
{ 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", "GeoLite2-City.mmdb");
|
||||||
|
|
||||||
|
const asnDbPath = path.join(process.cwd(), "lib", "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 });
|
||||||
|
}
|
||||||
|
}
|
BIN
lib/GeoLite2-ASN.mmdb
Normal file
BIN
lib/GeoLite2-ASN.mmdb
Normal file
Binary file not shown.
BIN
lib/GeoLite2-City.mmdb
Normal file
BIN
lib/GeoLite2-City.mmdb
Normal file
Binary file not shown.
After Width: | Height: | Size: 56 MiB |
@ -130,6 +130,57 @@ export async function GetServerMonitor({ server_id }: { server_id: number }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function GetServerIP({
|
||||||
|
server_id,
|
||||||
|
}: {
|
||||||
|
server_id: number;
|
||||||
|
}): Promise<string> {
|
||||||
|
let nezhaBaseUrl = getEnv("NezhaBaseUrl");
|
||||||
|
if (!nezhaBaseUrl) {
|
||||||
|
console.error("NezhaBaseUrl is not set");
|
||||||
|
throw new Error("NezhaBaseUrl is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove trailing slash
|
||||||
|
nezhaBaseUrl = nezhaBaseUrl.replace(/\/$/, "");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${nezhaBaseUrl}/api/v1/server/details`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: getEnv("NezhaAuth") as string,
|
||||||
|
},
|
||||||
|
next: {
|
||||||
|
revalidate: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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[];
|
||||||
|
|
||||||
|
// Find the server with the given ID
|
||||||
|
const server = nezhaData.find((element) => element.id === server_id);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
throw new Error(`Server with ID ${server_id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return server?.valid_ip || server?.ipv4 || server?.ipv6 || "";
|
||||||
|
} catch (error) {
|
||||||
|
console.error("GetNezhaData error:", error);
|
||||||
|
throw error; // Rethrow the error to be caught by the caller
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function GetServerDetail({ server_id }: { server_id: number }) {
|
export async function GetServerDetail({ server_id }: { server_id: number }) {
|
||||||
let nezhaBaseUrl = getEnv("NezhaBaseUrl");
|
let nezhaBaseUrl = getEnv("NezhaBaseUrl");
|
||||||
if (!nezhaBaseUrl) {
|
if (!nezhaBaseUrl) {
|
||||||
|
@ -55,6 +55,15 @@
|
|||||||
"avg_delay": "Latency",
|
"avg_delay": "Latency",
|
||||||
"chart_fetch_error_message": "Failed to fetch network data, please check if the server monitoring is enabled"
|
"chart_fetch_error_message": "Failed to fetch network data, please check if the server monitoring is enabled"
|
||||||
},
|
},
|
||||||
|
"IPInfo": {
|
||||||
|
"asn_number": "Origin ASN",
|
||||||
|
"registered_country": "Registered Country",
|
||||||
|
"time_zone": "Time Zone",
|
||||||
|
"postal_code": "Postal Code",
|
||||||
|
"city": "City",
|
||||||
|
"longitude": "Longitude",
|
||||||
|
"latitude": "Latitude"
|
||||||
|
},
|
||||||
"NetworkChart": {
|
"NetworkChart": {
|
||||||
"ServerMonitorCount": "Services"
|
"ServerMonitorCount": "Services"
|
||||||
},
|
},
|
||||||
|
@ -55,6 +55,15 @@
|
|||||||
"avg_delay": "遅延",
|
"avg_delay": "遅延",
|
||||||
"chart_fetch_error_message": "ネットワークデータの取得に失敗しました。サーバーの監視が有効になっているかどうかを確認してください"
|
"chart_fetch_error_message": "ネットワークデータの取得に失敗しました。サーバーの監視が有効になっているかどうかを確認してください"
|
||||||
},
|
},
|
||||||
|
"IPInfo": {
|
||||||
|
"asn_number": "ASN",
|
||||||
|
"registered_country": "Registered Country",
|
||||||
|
"time_zone": "時刻",
|
||||||
|
"postal_code": "郵便番号",
|
||||||
|
"city": "都市",
|
||||||
|
"longitude": "経度",
|
||||||
|
"latitude": "緯度"
|
||||||
|
},
|
||||||
"NetworkChart": {
|
"NetworkChart": {
|
||||||
"ServerMonitorCount": "サービス"
|
"ServerMonitorCount": "サービス"
|
||||||
},
|
},
|
||||||
|
@ -55,6 +55,15 @@
|
|||||||
"avg_delay": "延遲",
|
"avg_delay": "延遲",
|
||||||
"chart_fetch_error_message": "獲取網絡數據失敗,請檢查是否開啟服務端監控"
|
"chart_fetch_error_message": "獲取網絡數據失敗,請檢查是否開啟服務端監控"
|
||||||
},
|
},
|
||||||
|
"IPInfo": {
|
||||||
|
"asn_number": "ASN Number",
|
||||||
|
"registered_country": "注册地區",
|
||||||
|
"time_zone": "時區",
|
||||||
|
"postal_code": "郵遞區號",
|
||||||
|
"city": "城市",
|
||||||
|
"longitude": "經度",
|
||||||
|
"latitude": "緯度"
|
||||||
|
},
|
||||||
"NetworkChart": {
|
"NetworkChart": {
|
||||||
"ServerMonitorCount": "個監測服務"
|
"ServerMonitorCount": "個監測服務"
|
||||||
},
|
},
|
||||||
|
@ -58,6 +58,15 @@
|
|||||||
"NetworkChart": {
|
"NetworkChart": {
|
||||||
"ServerMonitorCount": "个监控服务"
|
"ServerMonitorCount": "个监控服务"
|
||||||
},
|
},
|
||||||
|
"IPInfo": {
|
||||||
|
"asn_number": "ASN 编号",
|
||||||
|
"registered_country": "注册地",
|
||||||
|
"time_zone": "时区",
|
||||||
|
"postal_code": "邮政编码",
|
||||||
|
"city": "城市",
|
||||||
|
"longitude": "经度",
|
||||||
|
"latitude": "纬度"
|
||||||
|
},
|
||||||
"ServerDetailClient": {
|
"ServerDetailClient": {
|
||||||
"detail_fetch_error_message": "获取服务器详情失败,请检查您的环境变量并检查服务器控制台",
|
"detail_fetch_error_message": "获取服务器详情失败,请检查您的环境变量并检查服务器控制台",
|
||||||
"status": "状态",
|
"status": "状态",
|
||||||
|
@ -39,6 +39,7 @@
|
|||||||
"framer-motion": "^12.0.0-alpha.2",
|
"framer-motion": "^12.0.0-alpha.2",
|
||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^0.454.0",
|
||||||
"luxon": "^3.5.0",
|
"luxon": "^3.5.0",
|
||||||
|
"maxmind": "^4.3.23",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
"next-auth": "^5.0.0-beta.25",
|
"next-auth": "^5.0.0-beta.25",
|
||||||
"next-intl": "^3.26.0",
|
"next-intl": "^3.26.0",
|
||||||
|
Loading…
Reference in New Issue
Block a user