Compare commits

...

12 Commits

Author SHA1 Message Date
hamster1963
d075ab5e0e v1.8.0-fix 2024-12-12 11:22:42 +08:00
hamster1963
1e881f5d9f fix(detail): detail page api dedupingInterval 2024-12-12 11:22:16 +08:00
hamster1963
2b426c8d18 v1.8.0 2024-12-12 11:10:13 +08:00
仓鼠
aafc19872b
Merge pull request #178 from hamster1963/ip-info
Ip info
2024-12-12 11:08:50 +08:00
hamster1963
e0e9faaa24 feat: ip info i18n 2024-12-12 11:00:38 +08:00
hamster1963
33d22fa8b3 feat: ip info 2024-12-12 10:46:37 +08:00
hamster1963
d72cd29446 fix: lint 2024-12-12 09:41:46 +08:00
hamster1963
4036c157c9 feat: use api 2024-12-12 09:39:51 +08:00
hamster1963
d64683a617 fix: path 2024-12-12 00:59:44 +08:00
hamster1963
dfef790884 fix: use fs 2024-12-12 00:52:19 +08:00
hamster1963
8f38cf94d8 fix: get file 2024-12-12 00:45:04 +08:00
hamster1963
b573b62452 feat: get ip info 2024-12-12 00:31:06 +08:00
15 changed files with 288 additions and 11 deletions

View File

@ -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_DisableIndex=false
NEXT_PUBLIC_ShowTagCount=false
NEXT_PUBLIC_ShowIpInfo=false

View File

@ -5,7 +5,6 @@ import { NezhaAPISafe, ServerApi } from "@/app/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";
import getEnv from "@/lib/env-entry";
import {
formatBytes,
formatNezhaInfo,
@ -23,7 +22,6 @@ import {
XAxis,
YAxis,
} from "recharts";
import useSWR from "swr";
import useSWRImmutable from "swr/immutable";
type cpuChartData = {
@ -61,7 +59,6 @@ type connectChartData = {
export default function ServerDetailChartClient({
server_id,
show,
}: {
server_id: number;
show: boolean;
@ -76,15 +73,11 @@ export default function ServerDetailChartClient({
(item) => item.id === server_id,
);
const { data, error } = useSWR<NezhaAPISafe>(
const { data, error } = useSWRImmutable<NezhaAPISafe>(
`/api/detail?server_id=${server_id}`,
nezhaFetcher,
{
refreshInterval: Number(getEnv("NEXT_PUBLIC_NezhaFetchInterval")) || 5000,
isVisible: () => show,
fallbackData,
revalidateOnMount: false,
revalidateIfStale: false,
},
);

View File

@ -60,6 +60,7 @@ export default function ServerDetailClient({
nezhaFetcher,
{
refreshInterval: Number(getEnv("NEXT_PUBLIC_NezhaFetchInterval")) || 5000,
dedupingInterval: 1000,
fallbackData,
revalidateOnMount: false,
revalidateIfStale: false,

View 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>
</>
);
}

View File

@ -5,8 +5,11 @@ import ServerDetailChartClient from "@/app/(main)/ClientComponents/ServerDetailC
import ServerDetailClient from "@/app/(main)/ClientComponents/ServerDetailClient";
import TabSwitch from "@/components/TabSwitch";
import { Separator } from "@/components/ui/separator";
import getEnv from "@/lib/env-entry";
import { use, useState } from "react";
import ServerIPInfo from "../../ClientComponents/ServerIPInfo";
export default function Page(props: { params: Promise<{ id: string }> }) {
const params = use(props.params);
const tabs = ["Detail", "Network"];
@ -32,6 +35,9 @@ export default function Page(props: { params: Promise<{ id: string }> }) {
/>
</div>
<div style={{ display: currentTab === tabs[1] ? "block" : "none" }}>
{getEnv("NEXT_PUBLIC_ShowIpInfo") && (
<ServerIPInfo server_id={Number(params.id)} />
)}
<NetworkChartClient
server_id={Number(params.id)}
show={currentTab === tabs[1]}

View 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
bun.lockb

Binary file not shown.

BIN
lib/GeoLite2-ASN.mmdb Normal file

Binary file not shown.

BIN
lib/GeoLite2-City.mmdb Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 MiB

View File

@ -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 }) {
let nezhaBaseUrl = getEnv("NezhaBaseUrl");
if (!nezhaBaseUrl) {

View File

@ -55,6 +55,15 @@
"avg_delay": "Latency",
"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": {
"ServerMonitorCount": "Services"
},

View File

@ -55,6 +55,15 @@
"avg_delay": "遅延",
"chart_fetch_error_message": "ネットワークデータの取得に失敗しました。サーバーの監視が有効になっているかどうかを確認してください"
},
"IPInfo": {
"asn_number": "ASN",
"registered_country": "Registered Country",
"time_zone": "時刻",
"postal_code": "郵便番号",
"city": "都市",
"longitude": "経度",
"latitude": "緯度"
},
"NetworkChart": {
"ServerMonitorCount": "サービス"
},

View File

@ -55,6 +55,15 @@
"avg_delay": "延遲",
"chart_fetch_error_message": "獲取網絡數據失敗,請檢查是否開啟服務端監控"
},
"IPInfo": {
"asn_number": "ASN Number",
"registered_country": "注册地區",
"time_zone": "時區",
"postal_code": "郵遞區號",
"city": "城市",
"longitude": "經度",
"latitude": "緯度"
},
"NetworkChart": {
"ServerMonitorCount": "個監測服務"
},

View File

@ -58,6 +58,15 @@
"NetworkChart": {
"ServerMonitorCount": "个监控服务"
},
"IPInfo": {
"asn_number": "ASN 编号",
"registered_country": "注册地",
"time_zone": "时区",
"postal_code": "邮政编码",
"city": "城市",
"longitude": "经度",
"latitude": "纬度"
},
"ServerDetailClient": {
"detail_fetch_error_message": "获取服务器详情失败,请检查您的环境变量并检查服务器控制台",
"status": "状态",

View File

@ -1,6 +1,6 @@
{
"name": "nezha-dash",
"version": "1.7.6",
"version": "1.8.0-fix",
"private": true,
"scripts": {
"dev": "next dev -p 3040",
@ -39,9 +39,10 @@
"framer-motion": "^12.0.0-alpha.2",
"lucide-react": "^0.454.0",
"luxon": "^3.5.0",
"maxmind": "^4.3.23",
"next": "^15.1.0",
"next-auth": "^5.0.0-beta.25",
"next-intl": "^3.26.0",
"next-intl": "^3.26.1",
"next-runtime-env": "^3.2.2",
"next-themes": "^0.4.4",
"react": "^19.0.0",
@ -59,7 +60,7 @@
"devDependencies": {
"@next/bundle-analyzer": "^15.1.0",
"@tailwindcss/postcss": "^4.0.0-beta.6",
"@types/node": "^22.10.1",
"@types/node": "^22.10.2",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.2",
"eslint": "^9.16.0",