diff --git a/src/components/NetworkChart.tsx b/src/components/NetworkChart.tsx new file mode 100644 index 0000000..e22f640 --- /dev/null +++ b/src/components/NetworkChart.tsx @@ -0,0 +1,290 @@ +"use client"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + ChartConfig, + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { fetchMonitor } from "@/lib/nezha-api"; +import { formatTime } from "@/lib/utils"; +import { formatRelativeTime } from "@/lib/utils"; +import { useQuery } from "@tanstack/react-query"; +import * as React from "react"; +import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; +import NetworkChartLoading from "./NetworkChartLoading"; +import { NezhaMonitor, ServerMonitorChart } from "@/types/nezha-api"; + + +interface ResultItem { + created_at: number; + [key: string]: number | null; +} + +export function NetworkChart({ + server_id, + show, +}: { + server_id: number; + show: boolean; +}) { + const { t } = useTranslation(); + + const { data: monitorData} = useQuery( + { + queryKey: ["monitor", server_id], + queryFn: () => fetchMonitor(server_id), + enabled: show, + refetchOnMount: true, + refetchOnWindowFocus: true, + refetchInterval: 10000, + } + ) + + if (!monitorData) return ; + + if (monitorData?.success && monitorData.data.length === 0) { + return ( + <> +
+

+

+ {t("monitor.noData")} +

+
+ + + ); + } + + + + const transformedData = transformData(monitorData.data); + + const formattedData = formatData(monitorData.data); + + const initChartConfig = { + avg_delay: { + label: t("monitor.avgDelay"), + }, + } satisfies ChartConfig; + + const chartDataKey = Object.keys(transformedData); + + return ( + + ); +} + +export const NetworkChartClient = React.memo(function NetworkChart({ + chartDataKey, + chartConfig, + chartData, + serverName, + formattedData, +}: { + chartDataKey: string[]; + chartConfig: ChartConfig; + chartData: ServerMonitorChart; + serverName: string; + formattedData: ResultItem[]; +}) { + const { t } = useTranslation(); + + const defaultChart = "All"; + + const [activeChart, setActiveChart] = React.useState(defaultChart); + + 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) => ( + + )), + [chartDataKey, activeChart, chartData, handleButtonClick], + ); + + const chartLines = useMemo(() => { + if (activeChart !== defaultChart) { + return ( + + ); + } + return chartDataKey.map((key) => ( + + )); + }, [activeChart, defaultChart, chartDataKey, getColorByIndex]); + + return ( + + +
+ + {serverName} + + + {chartDataKey.length} {t("monitor.monitorCount")} + +
+
{chartButtons}
+
+ + + + + formatRelativeTime(value)} + /> + `${value}ms`} + /> + { + return formatTime(payload[0].payload.created_at); + }} + /> + } + /> + {activeChart === defaultChart && ( + } /> + )} + {chartLines} + + + +
+ ); +}); + +const transformData = (data: NezhaMonitor[]) => { + 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: NezhaMonitor[]) => { + const result: { [time: number]: ResultItem } = {}; + + const allTimes = new Set(); + rawData.forEach((item) => { + item.created_at.forEach((time) => allTimes.add(time)); + }); + + const allTimeArray = Array.from(allTimes).sort((a, b) => a - b); + + rawData.forEach((item) => { + const { monitor_name, created_at, avg_delay } = item; + + allTimeArray.forEach((time) => { + if (!result[time]) { + result[time] = { created_at: time }; + } + + const timeIndex = created_at.indexOf(time); + result[time][monitor_name] = + timeIndex !== -1 ? avg_delay[timeIndex] : null; + }); + }); + + return Object.values(result).sort((a, b) => a.created_at - b.created_at); +}; diff --git a/src/components/NetworkChartLoading.tsx b/src/components/NetworkChartLoading.tsx new file mode 100644 index 0000000..c9c58c7 --- /dev/null +++ b/src/components/NetworkChartLoading.tsx @@ -0,0 +1,23 @@ +import { Loader } from "@/components/loading/Loader"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +export default function NetworkChartLoading() { + return ( + + +
+ +
+
+
+
+
+ +
+
+ +
+
+
+ ); +} diff --git a/src/components/ServerDetailChart.tsx b/src/components/ServerDetailChart.tsx index 174eb4e..4cae648 100644 --- a/src/components/ServerDetailChart.tsx +++ b/src/components/ServerDetailChart.tsx @@ -3,7 +3,6 @@ import { ChartConfig, ChartContainer } from "@/components/ui/chart"; import { formatNezhaInfo, formatRelativeTime } from "@/lib/utils"; import { NezhaServer, NezhaWebsocketResponse } from "@/types/nezha-api"; import { useEffect, useState } from "react"; -import { useParams } from "react-router-dom"; import { Area, AreaChart, @@ -51,8 +50,7 @@ type connectChartData = { udp: number; }; -export default function ServerDetailChart() { - const { id } = useParams(); +export default function ServerDetailChart({server_id}: {server_id: string}) { const { lastMessage, readyState } = useWebSocketContext(); if (readyState !== 1) { @@ -67,7 +65,7 @@ export default function ServerDetailChart() { return ; } - const server = nezhaWsData.servers.find((s) => s.id === Number(id)); + const server = nezhaWsData.servers.find((s) => s.id === Number(server_id)); if (!server) { return ; diff --git a/src/components/ServerDetailOverview.tsx b/src/components/ServerDetailOverview.tsx index 34c4034..02330e8 100644 --- a/src/components/ServerDetailOverview.tsx +++ b/src/components/ServerDetailOverview.tsx @@ -6,13 +6,13 @@ import { Card, CardContent } from "@/components/ui/card"; import { useWebSocketContext } from "@/hooks/use-websocket-context"; import { cn, formatBytes, formatNezhaInfo } from "@/lib/utils"; import { NezhaWebsocketResponse } from "@/types/nezha-api"; -import { useNavigate, useParams } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; -export default function ServerDetailOverview() { +export default function ServerDetailOverview({server_id}: {server_id: string}) { const { t } = useTranslation(); const navigate = useNavigate(); - const { id } = useParams(); + const { lastMessage, readyState } = useWebSocketContext(); if (readyState !== 1) { @@ -27,7 +27,7 @@ export default function ServerDetailOverview() { return ; } - const server = nezhaWsData.servers.find((s) => s.id === Number(id)); + const server = nezhaWsData.servers.find((s) => s.id === Number(server_id)); if (!server) { return ; diff --git a/src/components/TabSwitch.tsx b/src/components/TabSwitch.tsx new file mode 100644 index 0000000..6baa93f --- /dev/null +++ b/src/components/TabSwitch.tsx @@ -0,0 +1,48 @@ +import { cn } from "@/lib/utils"; +import { m } from "framer-motion"; +import { useTranslation } from "react-i18next"; + + +export default function TabSwitch({ + tabs, + currentTab, + setCurrentTab, +}: { + tabs: string[]; + currentTab: string; + setCurrentTab: (tab: string) => void; +}) { + const { t } = useTranslation(); + return ( +
+
+ {tabs.map((tab: string) => ( +
setCurrentTab(tab)} + className={cn( + "relative cursor-pointer rounded-3xl px-2.5 py-[8px] text-[13px] font-[600] transition-all duration-500", + currentTab === tab + ? "text-black dark:text-white" + : "text-stone-400 dark:text-stone-500", + )} + > + {currentTab === tab && ( + + )} +
+

{t("tabSwitch."+tab)}

+
+
+ ))} +
+
+ ); +} diff --git a/src/components/ui/chart.tsx b/src/components/ui/chart.tsx index ba084ed..c277371 100644 --- a/src/components/ui/chart.tsx +++ b/src/components/ui/chart.tsx @@ -67,7 +67,7 @@ ChartContainer.displayName = "Chart"; const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { const colorConfig = Object.entries(config).filter( - ([_, config]) => config.theme || config.color, + ([, config]) => config.theme || config.color, ); if (!colorConfig.length) { diff --git a/src/lib/nezha-api.ts b/src/lib/nezha-api.ts index 3a47bc8..c44e51e 100644 --- a/src/lib/nezha-api.ts +++ b/src/lib/nezha-api.ts @@ -1,4 +1,4 @@ -import { LoginUserResponse, ServerGroupResponse } from "@/types/nezha-api"; +import { LoginUserResponse, MonitorResponse, ServerGroupResponse } from "@/types/nezha-api"; export const fetchServerGroup = async (): Promise => { const response = await fetch("/api/v1/server-group"); @@ -17,3 +17,13 @@ export const fetchLoginUser = async (): Promise => { } return data; }; + + +export const fetchMonitor = async (server_id: number): Promise => { + const response = await fetch(`/api/v1/service/${server_id}`); + const data = await response.json(); + if (data.error) { + throw new Error(data.error); + } + return data; +}; \ No newline at end of file diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index b205f6e..85cc6fd 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -57,5 +57,14 @@ "error": { "pageNotFound": "Page not found", "backToHome": "Back to home" + }, + "tabSwitch":{ + "Detail": "Detail", + "Network": "Network" + }, + "monitor":{ + "noData": "No server monitor data", + "avgDelay": "Latency", + "monitorCount": "Services" } } diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index 5b7a6db..786323b 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -57,5 +57,14 @@ "error": { "pageNotFound": "页面不存在", "backToHome": "回到主页" + }, + "tabSwitch": { + "Detail": "详情", + "Network": "网络" + }, + "monitor": { + "noData": "没有服务器监控数据", + "avgDelay": "延迟", + "monitorCount": "个监控服务" } } diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index 64dc93f..296e5c0 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -57,5 +57,14 @@ "error": { "pageNotFound": "頁面不存在", "backToHome": "回到主頁" + }, + "tabSwitch": { + "detail": "詳細資訊", + "network": "網路" + }, + "monitor": { + "noData": "沒有服務器監控數據", + "avgDelay": "延遲", + "monitorCount": "個監控" } } diff --git a/src/pages/ServerDetail.tsx b/src/pages/ServerDetail.tsx index 8e5afde..fc9ed9b 100644 --- a/src/pages/ServerDetail.tsx +++ b/src/pages/ServerDetail.tsx @@ -1,11 +1,47 @@ +import { NetworkChart } from "@/components/NetworkChart"; import ServerDetailChart from "@/components/ServerDetailChart"; import ServerDetailOverview from "@/components/ServerDetailOverview"; +import TabSwitch from "@/components/TabSwitch"; +import { Separator } from "@/components/ui/separator"; +import { useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; export default function ServerDetail() { + const navigate = useNavigate(); + + const tabs = ["Detail", "Network"]; + const [currentTab, setCurrentTab] = useState(tabs[0]); + + const { id: server_id } = useParams(); + + if (!server_id) { + navigate('/404'); + return null; + } + return (
- - + +
+ +
+ +
+ +
+
+ +
+
+ +
); } diff --git a/src/types/nezha-api.ts b/src/types/nezha-api.ts index b3b5ed8..877cd37 100644 --- a/src/types/nezha-api.ts +++ b/src/types/nezha-api.ts @@ -70,3 +70,25 @@ export interface LoginUserResponse { updated_at: string; }; } + + +export interface MonitorResponse { + success: boolean; + data: NezhaMonitor[]; +} + +export type ServerMonitorChart = { + [key: string]: { + created_at: number; + avg_delay: number; + }[]; +}; + +export interface NezhaMonitor { + monitor_id: number; + monitor_name: string; + server_id: number; + server_name: string; + created_at: number[]; + avg_delay: number[]; +} diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo index fcc3e2f..cccbb05 100644 --- a/tsconfig.app.tsbuildinfo +++ b/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/footer.tsx","./src/components/groupswitch.tsx","./src/components/header.tsx","./src/components/icon.tsx","./src/components/languageswitcher.tsx","./src/components/servercard.tsx","./src/components/serverdetailchart.tsx","./src/components/serverdetailoverview.tsx","./src/components/serverflag.tsx","./src/components/serveroverview.tsx","./src/components/serverusagebar.tsx","./src/components/themeprovider.tsx","./src/components/themeswitcher.tsx","./src/components/loading/loader.tsx","./src/components/loading/serverdetailloading.tsx","./src/components/motion/framer-lazy-feature.ts","./src/components/motion/motion-provider.tsx","./src/components/ui/animated-circular-progress-bar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/chart.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/progress.tsx","./src/components/ui/separator.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/table.tsx","./src/context/websocket-context.ts","./src/context/websocket-provider.tsx","./src/hooks/use-theme.ts","./src/hooks/use-websocket-context.ts","./src/lib/logo-class.tsx","./src/lib/nav-router.ts","./src/lib/nezha-api.ts","./src/lib/utils.ts","./src/pages/notfound.tsx","./src/pages/server.tsx","./src/pages/serverdetail.tsx","./src/types/nezha-api.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/footer.tsx","./src/components/groupswitch.tsx","./src/components/header.tsx","./src/components/icon.tsx","./src/components/languageswitcher.tsx","./src/components/networkchart.tsx","./src/components/networkchartloading.tsx","./src/components/servercard.tsx","./src/components/serverdetailchart.tsx","./src/components/serverdetailoverview.tsx","./src/components/serverflag.tsx","./src/components/serveroverview.tsx","./src/components/serverusagebar.tsx","./src/components/tabswitch.tsx","./src/components/themeprovider.tsx","./src/components/themeswitcher.tsx","./src/components/loading/loader.tsx","./src/components/loading/serverdetailloading.tsx","./src/components/motion/framer-lazy-feature.ts","./src/components/motion/motion-provider.tsx","./src/components/ui/animated-circular-progress-bar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/chart.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/progress.tsx","./src/components/ui/separator.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/table.tsx","./src/context/websocket-context.ts","./src/context/websocket-provider.tsx","./src/hooks/use-theme.ts","./src/hooks/use-websocket-context.ts","./src/lib/logo-class.tsx","./src/lib/nav-router.ts","./src/lib/nezha-api.ts","./src/lib/utils.ts","./src/pages/notfound.tsx","./src/pages/server.tsx","./src/pages/serverdetail.tsx","./src/types/nezha-api.ts"],"version":"5.6.3"} \ No newline at end of file