网络流量、磁盘 IO 图表显示优化

This commit is contained in:
MengMengCode
2026-06-17 19:16:28 +08:00
parent 98ed716225
commit 49d8093f45
3 changed files with 211 additions and 58 deletions
+135 -33
View File
@@ -1,4 +1,4 @@
import { ReactNode } from 'react'
import { ReactNode, useId } from 'react'
import { RefreshCw } from 'lucide-react'
import { useTheme } from '../contexts/ThemeContext'
@@ -9,17 +9,27 @@ export type ChartPoint = {
value: number
}
export type ResourceChartSeries = {
label: string
points: ChartPoint[]
current?: number
color?: string
}
export type ResourceChartConfig = {
title: string
icon: ReactNode
points: ChartPoint[]
current: number
series?: ResourceChartSeries[]
detail?: string
max?: number
unitLabel?: string
formatValue: (value: number) => string
}
const chartPalette = ['#2563eb', '#16a34a', '#d97706', '#dc2626']
const rangeLabels: Record<StatsRangeKey, string> = {
'30m': '30分钟',
'1h': '1小时',
@@ -77,36 +87,52 @@ export default function ResourceStatsPanel({
<div className="grid grid-cols-1 xl:grid-cols-2">
{charts.map((chart, index) => (
<DetailedChart key={chart.title} chart={chart} className={chartBorderClass(index)} />
<DetailedChart key={chart.title} chart={chart} range={range} className={chartBorderClass(index)} />
))}
</div>
</section>
)
}
function DetailedChart({ chart, className }: { chart: ResourceChartConfig; className: string }) {
const values = chart.points.map((point) => point.value)
const avg = values.length > 0 ? values.reduce((sum, value) => sum + value, 0) / values.length : 0
const peak = values.length > 0 ? Math.max(...values) : 0
function DetailedChart({ chart, range, className }: { chart: ResourceChartConfig; range: StatsRangeKey; className: string }) {
const series = chart.series?.length
? chart.series
: [{ label: chart.title, points: chart.points, current: chart.current }]
const primaryStats = getSeriesStats(series[0], chart.current)
return (
<div className={`p-4 ${className}`}>
<div className="flex items-start justify-between gap-3 mb-2">
<div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between mb-2">
<div className="min-w-0">
<div className="flex items-center gap-1.5 text-sm font-semibold text-gray-950 dark:text-white">
<span className="text-gray-500 dark:text-gray-400">{chart.icon}</span>
<span>{chart.title}</span>
</div>
{chart.detail && <p className="mt-0.5 text-[11px] text-gray-400 dark:text-gray-500">{chart.detail}</p>}
</div>
<div className="grid grid-cols-3 gap-3 text-right">
<Stat label="当前" value={chart.formatValue(chart.current)} />
<Stat label="平均" value={chart.formatValue(avg)} />
<Stat label="峰值" value={chart.formatValue(peak)} />
</div>
{series.length > 1 ? (
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-right sm:shrink-0">
{series.map((item, index) => (
<SeriesStat
key={item.label}
color={item.color || chartPalette[index % chartPalette.length]}
label={item.label}
stats={getSeriesStats(item, item.current)}
formatValue={chart.formatValue}
/>
))}
</div>
) : (
<div className="grid grid-cols-3 gap-3 text-right sm:shrink-0">
<Stat label="当前" value={chart.formatValue(primaryStats.current)} />
<Stat label="平均" value={chart.formatValue(primaryStats.avg)} />
<Stat label="峰值" value={chart.formatValue(primaryStats.peak)} />
</div>
)}
</div>
<LineAreaChart
points={chart.points}
series={series}
range={range}
max={chart.max}
formatValue={chart.formatValue}
unitLabel={chart.unitLabel}
@@ -115,6 +141,33 @@ function DetailedChart({ chart, className }: { chart: ResourceChartConfig; class
)
}
function SeriesStat({
color,
label,
stats,
formatValue,
}: {
color: string
label: string
stats: { current: number; avg: number; peak: number }
formatValue: (value: number) => string
}) {
return (
<div className="min-w-[104px]">
<div className="flex items-center justify-end gap-1 text-[10px] text-gray-400 dark:text-gray-500">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: color }} />
<span>{label}</span>
</div>
<div className="text-xs font-semibold text-gray-900 dark:text-gray-100 tabular-nums whitespace-nowrap">
{formatValue(stats.current)}
</div>
<div className="text-[10px] text-gray-400 dark:text-gray-500 tabular-nums whitespace-nowrap">
{formatValue(stats.avg)} / {formatValue(stats.peak)}
</div>
</div>
)
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
@@ -124,19 +177,33 @@ function Stat({ label, value }: { label: string; value: string }) {
)
}
function getSeriesStats(series: ResourceChartSeries, fallbackCurrent = 0) {
const values = series.points
.map((point) => point.value)
.filter((value) => Number.isFinite(value))
const current = Number.isFinite(series.current) ? Number(series.current) : fallbackCurrent
const samples = values.length > 0 ? values : [current]
const avg = samples.reduce((sum, value) => sum + value, 0) / samples.length
const peak = Math.max(current, ...samples, 0)
return { current, avg, peak }
}
function LineAreaChart({
points,
series,
range,
max,
formatValue,
unitLabel,
}: {
points: ChartPoint[]
series: ResourceChartSeries[]
range: StatsRangeKey
max?: number
formatValue: (value: number) => string
unitLabel?: string
}) {
const { theme } = useTheme()
const isDark = theme === 'dark'
const gradientId = `resource-chart-fill-${useId().replace(/:/g, '')}`
const width = 520
const height = 150
@@ -146,21 +213,21 @@ function LineAreaChart({
const bottom = 28
const innerWidth = width - left - right
const innerHeight = height - top - bottom
const values = points.length > 0 ? points : [{ ts: Date.now(), value: 0 }]
const maxValue = Math.max(max || 0, ...values.map((point) => point.value), 1)
const minTs = values[0]?.ts || Date.now()
const maxTs = values[values.length - 1]?.ts || minTs + 1
const span = Math.max(maxTs - minTs, 1)
const coords = values.map((point, index) => {
const x = left + ((point.ts - minTs) / span) * innerWidth
const y = top + innerHeight - (point.value / maxValue) * innerHeight
return `${Number.isFinite(x) ? x : left},${Number.isFinite(y) ? y : top + innerHeight}`
const now = Date.now()
const chartSeries = series.map((item) => {
const validPoints = item.points.filter((point) => Number.isFinite(point.ts) && Number.isFinite(point.value))
return {
...item,
points: validPoints.length > 0
? validPoints
: [{ ts: now, value: Number.isFinite(item.current) ? Number(item.current) : 0 }],
}
})
const fallbackX = left
const fallbackY = top + innerHeight
const line = coords.length > 1 ? coords.join(' ') : `${fallbackX},${fallbackY} ${left + innerWidth},${fallbackY}`
const area = `${left},${top + innerHeight} ${line} ${left + innerWidth},${top + innerHeight}`
const allPoints = chartSeries.flatMap((item) => item.points)
const maxValue = Math.max(max || 0, ...allPoints.map((point) => point.value), 1)
const maxTs = now
const minTs = now - statsRanges[range]
const span = Math.max(maxTs - minTs, 1)
const yTicks = [1, 0.5, 0]
const xTicks = [0, 0.5, 1]
@@ -171,11 +238,13 @@ function LineAreaChart({
const lineStroke = isDark ? '#f9fafb' : '#444'
const gradientTop = isDark ? '#f9fafb' : '#555'
const gradientBottom = isDark ? '#374151' : '#555'
const primaryLine = buildLine(chartSeries[0]?.points || [{ ts: now, value: 0 }], minTs, span, left, top, innerWidth, innerHeight, maxValue)
const area = `${left},${top + innerHeight} ${primaryLine} ${left + innerWidth},${top + innerHeight}`
return (
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-[140px]" preserveAspectRatio="none">
<defs>
<linearGradient id="resource-chart-fill" x1="0" x2="0" y1="0" y2="1">
<linearGradient id={gradientId} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={gradientTop} stopOpacity="0.25" />
<stop offset="100%" stopColor={gradientBottom} stopOpacity="0.02" />
</linearGradient>
@@ -214,12 +283,45 @@ function LineAreaChart({
<line x1={left} y1={top} x2={left} y2={top + innerHeight} stroke={axisStroke} />
<line x1={left} y1={top + innerHeight} x2={left + innerWidth} y2={top + innerHeight} stroke={axisStroke} />
<polygon points={area} fill="url(#resource-chart-fill)" />
<polyline points={line} fill="none" stroke={lineStroke} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
{chartSeries.length === 1 && <polygon points={area} fill={`url(#${gradientId})`} />}
{chartSeries.map((item, index) => (
<polyline
key={item.label || index}
points={buildLine(item.points, minTs, span, left, top, innerWidth, innerHeight, maxValue)}
fill="none"
stroke={item.color || (chartSeries.length === 1 ? lineStroke : chartPalette[index % chartPalette.length])}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
))}
</svg>
)
}
function buildLine(
points: ChartPoint[],
minTs: number,
span: number,
left: number,
top: number,
innerWidth: number,
innerHeight: number,
maxValue: number,
) {
const coords = points.map((point) => {
const x = left + ((point.ts - minTs) / span) * innerWidth
const y = top + innerHeight - (point.value / maxValue) * innerHeight
return `${Number.isFinite(x) ? x : left},${Number.isFinite(y) ? y : top + innerHeight}`
})
if (coords.length > 1) return coords.join(' ')
const [, yText] = (coords[0] || `${left},${top + innerHeight}`).split(',')
const y = Number(yText)
const safeY = Number.isFinite(y) ? y : top + innerHeight
return `${left},${safeY} ${left + innerWidth},${safeY}`
}
function chartBorderClass(index: number) {
const right = index % 2 === 0 ? 'xl:border-r' : ''
const top = index > 1 ? 'border-t' : ''