feat: monitor chart

This commit is contained in:
hamster1963
2024-11-28 15:26:03 +08:00
parent 5f2e9fe38a
commit d7f0410dcd
13 changed files with 467 additions and 13 deletions

View File

@@ -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 <NetworkChartLoading />;
if (monitorData?.success && monitorData.data.length === 0) {
return (
<>
<div className="flex flex-col items-center justify-center">
<p className="text-sm font-medium opacity-40"></p>
<p className="text-sm font-medium opacity-40">
{t("monitor.noData")}
</p>
</div>
<NetworkChartLoading />
</>
);
}
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 (
<NetworkChartClient
chartDataKey={chartDataKey}
chartConfig={initChartConfig}
chartData={transformedData}
serverName={monitorData.data[0].server_name}
formattedData={formattedData}
/>
);
}
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) => (
<button
key={key}
data-active={activeChart === key}
className={`relative z-30 flex cursor-pointer flex-1 flex-col justify-center gap-1 border-b border-neutral-200 dark:border-neutral-800 px-6 py-4 text-left data-[active=true]:bg-muted/50 sm:border-l sm:border-t-0 sm:px-6`}
onClick={() => handleButtonClick(key)}
>
<span className="whitespace-nowrap text-xs text-muted-foreground">
{key}
</span>
<span className="text-md font-bold leading-none sm:text-lg">
{chartData[key][chartData[key].length - 1].avg_delay.toFixed(2)}ms
</span>
</button>
)),
[chartDataKey, activeChart, chartData, handleButtonClick],
);
const chartLines = useMemo(() => {
if (activeChart !== defaultChart) {
return (
<Line
isAnimationActive={false}
strokeWidth={1}
type="linear"
dot={false}
dataKey="avg_delay"
stroke={getColorByIndex(activeChart)}
/>
);
}
return chartDataKey.map((key) => (
<Line
key={key}
isAnimationActive={false}
strokeWidth={1}
type="linear"
dot={false}
dataKey={key}
stroke={getColorByIndex(key)}
connectNulls={true}
/>
));
}, [activeChart, defaultChart, chartDataKey, getColorByIndex]);
return (
<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 className="flex flex-none items-center gap-0.5 text-md">
{serverName}
</CardTitle>
<CardDescription className="text-xs">
{chartDataKey.length} {t("monitor.monitorCount")}
</CardDescription>
</div>
<div className="flex flex-wrap">{chartButtons}</div>
</CardHeader>
<CardContent className="pr-2 pl-0 py-4 sm:pt-6 sm:pb-6 sm:pr-6 sm:pl-2">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<LineChart
accessibilityLayer
data={
activeChart === defaultChart
? formattedData
: chartData[activeChart]
}
margin={{ left: 12, right: 12 }}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="created_at"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
interval={"preserveStartEnd"}
tickFormatter={(value) => formatRelativeTime(value)}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={15}
minTickGap={20}
tickFormatter={(value) => `${value}ms`}
/>
<ChartTooltip
isAnimationActive={false}
content={
<ChartTooltipContent
indicator={"line"}
labelKey="created_at"
labelFormatter={(_, payload) => {
return formatTime(payload[0].payload.created_at);
}}
/>
}
/>
{activeChart === defaultChart && (
<ChartLegend content={<ChartLegendContent />} />
)}
{chartLines}
</LineChart>
</ChartContainer>
</CardContent>
</Card>
);
});
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<number>();
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);
};

View File

@@ -0,0 +1,23 @@
import { Loader } from "@/components/loading/Loader";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export default function NetworkChartLoading() {
return (
<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 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>
</div>
<div className="hidden pr-4 pt-4 sm:block">
<Loader visible={true} />
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6">
<div className="aspect-auto h-[250px] w-full"></div>
</CardContent>
</Card>
);
}

View File

@@ -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 <ServerDetailChartLoading />;
}
const server = nezhaWsData.servers.find((s) => s.id === Number(id));
const server = nezhaWsData.servers.find((s) => s.id === Number(server_id));
if (!server) {
return <ServerDetailChartLoading />;

View File

@@ -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 <ServerDetailLoading />;
}
const server = nezhaWsData.servers.find((s) => s.id === Number(id));
const server = nezhaWsData.servers.find((s) => s.id === Number(server_id));
if (!server) {
return <ServerDetailLoading />;

View File

@@ -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 (
<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
key={tab}
onClick={() => 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 && (
<m.div
layoutId="tab-switch-active"
className="absolute inset-0 z-10 h-full w-full content-center bg-white shadow-lg shadow-black/5 dark:bg-stone-700 dark:shadow-white/5"
style={{
originY: "0px",
borderRadius: 46,
}}
/>
)}
<div className="relative z-20 flex items-center gap-1">
<p className="whitespace-nowrap">{t("tabSwitch."+tab)}</p>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -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) {