Files
nezha-dash-v1/src/components/NetworkChart.tsx
仓鼠 ed35e8c122 Feat: Multiple choice (#41)
* 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
2025-04-25 10:50:01 +08:00

389 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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)
}