mirror of
https://github.com/Buriburizaem0n/nezha-dash-v1.git
synced 2026-02-04 20:50:08 +00:00
* feat: enable multi-selection for charts in NetworkChart component * feat: add clear selections button for active charts in NetworkChart component * chore: auto-fix linting and formatting issues
389 lines
13 KiB
TypeScript
389 lines
13 KiB
TypeScript
"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 { cn, formatTime } from "@/lib/utils"
|
||
import { NezhaMonitor, ServerMonitorChart } from "@/types/nezha-api"
|
||
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 { Label } from "./ui/label"
|
||
import { Switch } from "./ui/switch"
|
||
|
||
interface ResultItem {
|
||
created_at: number
|
||
[key: string]: number
|
||
}
|
||
|
||
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) {
|
||
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 mb-4">{t("monitor.noData")}</p>
|
||
</div>
|
||
<NetworkChartLoading />
|
||
</>
|
||
)
|
||
}
|
||
|
||
const transformedData = transformData(monitorData.data)
|
||
|
||
const formattedData = formatData(monitorData.data)
|
||
|
||
const chartDataKey = Object.keys(transformedData)
|
||
|
||
const initChartConfig = {
|
||
avg_delay: {
|
||
label: t("monitor.avgDelay"),
|
||
},
|
||
...chartDataKey.reduce((acc, key) => {
|
||
acc[key] = {
|
||
label: key,
|
||
}
|
||
return acc
|
||
}, {} as ChartConfig),
|
||
} satisfies ChartConfig
|
||
|
||
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 customBackgroundImage = (window.CustomBackgroundImage as string) !== "" ? window.CustomBackgroundImage : undefined
|
||
|
||
const forcePeakCutEnabled = (window.ForcePeakCutEnabled as boolean) ?? false
|
||
|
||
// Change from string to string array for multi-selection
|
||
const [activeCharts, setActiveCharts] = React.useState<string[]>([])
|
||
const [isPeakEnabled, setIsPeakEnabled] = React.useState(forcePeakCutEnabled)
|
||
|
||
// Function to clear all selected charts
|
||
const clearAllSelections = useCallback(() => {
|
||
setActiveCharts([])
|
||
}, [])
|
||
|
||
// Updated to handle multiple selections
|
||
const handleButtonClick = useCallback((chart: string) => {
|
||
setActiveCharts((prev) => {
|
||
// If chart is already selected, remove it
|
||
if (prev.includes(chart)) {
|
||
return prev.filter((c) => c !== chart)
|
||
}
|
||
// Otherwise, add it to selected charts
|
||
return [...prev, chart]
|
||
})
|
||
}, [])
|
||
|
||
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={activeCharts.includes(key)}
|
||
className={`relative z-30 flex cursor-pointer grow basis-0 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, activeCharts, chartData, handleButtonClick],
|
||
)
|
||
|
||
const chartLines = useMemo(() => {
|
||
// If we have active charts selected, render only those
|
||
if (activeCharts.length > 0) {
|
||
return activeCharts.map((chart) => (
|
||
<Line
|
||
key={chart}
|
||
isAnimationActive={false}
|
||
strokeWidth={1}
|
||
type="linear"
|
||
dot={false}
|
||
dataKey={chart} // Change from "avg_delay" to the actual chart key name
|
||
stroke={getColorByIndex(chart)}
|
||
name={chart}
|
||
connectNulls={true}
|
||
/>
|
||
))
|
||
}
|
||
// Otherwise show all charts (default view)
|
||
return chartDataKey.map((key) => (
|
||
<Line
|
||
key={key}
|
||
isAnimationActive={false}
|
||
strokeWidth={1}
|
||
type="linear"
|
||
dot={false}
|
||
dataKey={key}
|
||
stroke={getColorByIndex(key)}
|
||
connectNulls={true}
|
||
/>
|
||
))
|
||
}, [activeCharts, chartDataKey, getColorByIndex])
|
||
|
||
const processedData = useMemo(() => {
|
||
if (!isPeakEnabled) {
|
||
// Always use formattedData when multiple charts are selected or none selected
|
||
return formattedData
|
||
}
|
||
|
||
// For peak cutting, always use the formatted data which contains all series
|
||
const data = formattedData
|
||
|
||
const windowSize = 11 // 增加窗口大小以获取更好的统计效果
|
||
const alpha = 0.3 // EWMA平滑因子
|
||
|
||
// 辅助函数:计算中位数
|
||
const getMedian = (arr: number[]) => {
|
||
const sorted = [...arr].sort((a, b) => a - b)
|
||
const mid = Math.floor(sorted.length / 2)
|
||
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
|
||
}
|
||
|
||
// 辅助函数:异常值处理
|
||
const processValues = (values: number[]) => {
|
||
if (values.length === 0) return null
|
||
|
||
const median = getMedian(values)
|
||
const deviations = values.map((v) => Math.abs(v - median))
|
||
const medianDeviation = getMedian(deviations) * 1.4826 // MAD估计器
|
||
|
||
// 使用中位数绝对偏差(MAD)进行异常值检测
|
||
const validValues = values.filter(
|
||
(v) =>
|
||
Math.abs(v - median) <= 3 * medianDeviation && // 更严格的异常值判定
|
||
v <= median * 3, // 限制最大值不超过中位数的3倍
|
||
)
|
||
|
||
if (validValues.length === 0) return median // 如果没有有效值,返回中位数
|
||
|
||
// 计算EWMA
|
||
let ewma = validValues[0]
|
||
for (let i = 1; i < validValues.length; i++) {
|
||
ewma = alpha * validValues[i] + (1 - alpha) * ewma
|
||
}
|
||
|
||
return ewma
|
||
}
|
||
|
||
// 初始化EWMA历史值
|
||
const ewmaHistory: { [key: string]: number } = {}
|
||
|
||
return data.map((point, index) => {
|
||
if (index < windowSize - 1) return point
|
||
|
||
const window = data.slice(index - windowSize + 1, index + 1)
|
||
const smoothed = { ...point } as ResultItem
|
||
|
||
// Process all chart keys or just the selected ones
|
||
const keysToProcess = activeCharts.length > 0 ? activeCharts : chartDataKey
|
||
|
||
keysToProcess.forEach((key) => {
|
||
const values = window.map((w) => w[key]).filter((v) => v !== undefined && v !== null) as number[]
|
||
|
||
if (values.length > 0) {
|
||
const processed = processValues(values)
|
||
if (processed !== null) {
|
||
// Apply EWMA smoothing
|
||
if (ewmaHistory[key] === undefined) {
|
||
ewmaHistory[key] = processed
|
||
} else {
|
||
ewmaHistory[key] = alpha * processed + (1 - alpha) * ewmaHistory[key]
|
||
}
|
||
smoothed[key] = ewmaHistory[key]
|
||
}
|
||
}
|
||
})
|
||
|
||
return smoothed
|
||
})
|
||
}, [isPeakEnabled, activeCharts, formattedData, chartDataKey])
|
||
|
||
return (
|
||
<Card
|
||
className={cn({
|
||
"bg-card/70": customBackgroundImage,
|
||
})}
|
||
>
|
||
<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 className="flex items-center mt-0.5 space-x-2">
|
||
<Switch id="Peak" checked={isPeakEnabled} onCheckedChange={setIsPeakEnabled} />
|
||
<Label className="text-xs" htmlFor="Peak">
|
||
Peak cut
|
||
</Label>
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-wrap w-full">{chartButtons}</div>
|
||
</CardHeader>
|
||
<CardContent className="pr-2 pl-0 py-4 sm:pt-6 sm:pb-6 sm:pr-6 sm:pl-2">
|
||
<div className="relative">
|
||
{activeCharts.length > 0 && (
|
||
<button
|
||
className="absolute -top-2 right-1 z-10 text-xs px-2 py-1 bg-stone-100/80 backdrop-blur-sm rounded-[5px] text-muted-foreground hover:text-foreground transition-colors"
|
||
onClick={clearAllSelections}
|
||
>
|
||
{t("monitor.clearSelections", "Clear")} ({activeCharts.length})
|
||
</button>
|
||
)}
|
||
<ChartContainer config={chartConfig} className="aspect-auto h-[250px] w-full">
|
||
<LineChart accessibilityLayer data={processedData} margin={{ left: 12, right: 12 }}>
|
||
<CartesianGrid vertical={false} />
|
||
<XAxis
|
||
dataKey="created_at"
|
||
tickLine={true}
|
||
tickSize={3}
|
||
axisLine={false}
|
||
tickMargin={8}
|
||
minTickGap={80}
|
||
ticks={processedData
|
||
.filter((item, index, array) => {
|
||
if (array.length < 6) {
|
||
return index === 0 || index === array.length - 1
|
||
}
|
||
|
||
// 计算数据的总时间跨度(毫秒)
|
||
const timeSpan = array[array.length - 1].created_at - array[0].created_at
|
||
const hours = timeSpan / (1000 * 60 * 60)
|
||
|
||
// 根据时间跨度调整显示间隔
|
||
if (hours <= 12) {
|
||
// 12小时内,每60分钟显示一个刻度
|
||
return index === 0 || index === array.length - 1 || new Date(item.created_at).getMinutes() % 60 === 0
|
||
}
|
||
// 超过12小时,每2小时显示一个刻度
|
||
const date = new Date(item.created_at)
|
||
return date.getMinutes() === 0 && date.getHours() % 2 === 0
|
||
})
|
||
.map((item) => item.created_at)}
|
||
tickFormatter={(value) => {
|
||
const date = new Date(value)
|
||
const minutes = date.getMinutes()
|
||
return minutes === 0 ? `${date.getHours()}:00` : `${date.getHours()}:${minutes}`
|
||
}}
|
||
/>
|
||
<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)
|
||
}}
|
||
/>
|
||
}
|
||
/>
|
||
<ChartLegend content={<ChartLegendContent />} />
|
||
{chartLines}
|
||
</LineChart>
|
||
</ChartContainer>
|
||
</div>
|
||
</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)
|
||
// @ts-expect-error - avg_delay is an array
|
||
result[time][monitor_name] = timeIndex !== -1 ? avg_delay[timeIndex] : null
|
||
})
|
||
})
|
||
|
||
return Object.values(result).sort((a, b) => a.created_at - b.created_at)
|
||
}
|