From d7f0410dcd96e5ade3182b93d8388191129d08f7 Mon Sep 17 00:00:00 2001
From: hamster1963 <1410514192@qq.com>
Date: Thu, 28 Nov 2024 15:26:03 +0800
Subject: [PATCH] feat: monitor chart
---
src/components/NetworkChart.tsx | 290 ++++++++++++++++++++++++
src/components/NetworkChartLoading.tsx | 23 ++
src/components/ServerDetailChart.tsx | 6 +-
src/components/ServerDetailOverview.tsx | 8 +-
src/components/TabSwitch.tsx | 48 ++++
src/components/ui/chart.tsx | 2 +-
src/lib/nezha-api.ts | 12 +-
src/locales/en/translation.json | 9 +
src/locales/zh-CN/translation.json | 9 +
src/locales/zh-TW/translation.json | 9 +
src/pages/ServerDetail.tsx | 40 +++-
src/types/nezha-api.ts | 22 ++
tsconfig.app.tsbuildinfo | 2 +-
13 files changed, 467 insertions(+), 13 deletions(-)
create mode 100644 src/components/NetworkChart.tsx
create mode 100644 src/components/NetworkChartLoading.tsx
create mode 100644 src/components/TabSwitch.tsx
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