diff --git a/frontend/src/components/ResourceStatsPanel.tsx b/frontend/src/components/ResourceStatsPanel.tsx index 0fd9f47..2943160 100644 --- a/frontend/src/components/ResourceStatsPanel.tsx +++ b/frontend/src/components/ResourceStatsPanel.tsx @@ -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 = { '30m': '30分钟', '1h': '1小时', @@ -77,36 +87,52 @@ export default function ResourceStatsPanel({
{charts.map((chart, index) => ( - + ))}
) } -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 (
-
-
+
+
{chart.icon} {chart.title}
{chart.detail &&

{chart.detail}

}
-
- - - -
+ {series.length > 1 ? ( +
+ {series.map((item, index) => ( + + ))} +
+ ) : ( +
+ + + +
+ )}
string +}) { + return ( +
+
+ + {label} +
+
+ {formatValue(stats.current)} +
+
+ 均 {formatValue(stats.avg)} / 峰 {formatValue(stats.peak)} +
+
+ ) +} + function Stat({ label, value }: { label: string; value: string }) { return (
@@ -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 ( - + @@ -214,12 +283,45 @@ function LineAreaChart({ - - + {chartSeries.length === 1 && } + {chartSeries.map((item, index) => ( + + ))} ) } +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' : '' diff --git a/frontend/src/pages/ContainerDetail.tsx b/frontend/src/pages/ContainerDetail.tsx index fef8614..c4fbfa8 100644 --- a/frontend/src/pages/ContainerDetail.tsx +++ b/frontend/src/pages/ContainerDetail.tsx @@ -89,8 +89,12 @@ type MetricPoint = { ts: number cpu: number memory: number - network: number - diskIO: number + network?: number + networkRx?: number + networkTx?: number + diskIO?: number + diskRead?: number + diskWrite?: number } type MappingDraft = { index: number | null @@ -214,15 +218,21 @@ export default function ContainerDetail() { const memoryPct = memoryTotalBytes > 0 ? (nextUsage.memory_usage_bytes / memoryTotalBytes) * 100 : 0 - const networkBps = (nextUsage.network_rx_bps || 0) + (nextUsage.network_tx_bps || 0) - const diskIOBps = (nextUsage.disk_read_bps || 0) + (nextUsage.disk_write_bps || 0) + const networkRx = nextUsage.network_rx_bps || 0 + const networkTx = nextUsage.network_tx_bps || 0 + const diskRead = nextUsage.disk_read_bps || 0 + const diskWrite = nextUsage.disk_write_bps || 0 const point: MetricPoint = { ts: Date.now(), cpu: clamp((nextUsage.cpu_usage_pct || 0) / (currentContainer.vcpu || 1)), memory: clamp(memoryPct), - network: networkBps, - diskIO: diskIOBps, + network: networkRx + networkTx, + networkRx, + networkTx, + diskIO: diskRead + diskWrite, + diskRead, + diskWrite, } setHistory((prev) => { @@ -907,20 +917,23 @@ export default function ContainerDetail() { const ramPct = ramTotalBytes > 0 ? clamp(((usage?.memory_usage_bytes || 0) / ramTotalBytes) * 100) : 0 const loadPct = container.vcpu > 0 ? ((usage?.load1 || 0) / container.vcpu) * 100 : 0 const diskPct = container.disk_gb > 0 ? clamp(((usage?.disk_usage_bytes || 0) / (container.disk_gb * 1024 * 1024 * 1024)) * 100) : 0 - const networkBps = (usage?.network_rx_bps || 0) + (usage?.network_tx_bps || 0) - const rx = usage?.network_rx_bps || 0 + const networkRxBps = usage?.network_rx_bps || 0 + const networkTxBps = usage?.network_tx_bps || 0 + const networkBps = networkRxBps + networkTxBps const networkDownLimit = resourceLimitValue(container.network_down_mbps, container.network_bw_mbps) const networkUpLimit = resourceLimitValue(container.network_up_mbps, container.network_bw_mbps) const netPct = Math.max( - directionUsagePercent(usage?.network_rx_bps || 0, networkDownLimit, 125000, 125000000), - directionUsagePercent(usage?.network_tx_bps || 0, networkUpLimit, 125000, 125000000), + directionUsagePercent(networkRxBps, networkDownLimit, 125000, 125000000), + directionUsagePercent(networkTxBps, networkUpLimit, 125000, 125000000), ) - const diskIOBps = (usage?.disk_read_bps || 0) + (usage?.disk_write_bps || 0) + const diskReadBps = usage?.disk_read_bps || 0 + const diskWriteBps = usage?.disk_write_bps || 0 + const diskIOBps = diskReadBps + diskWriteBps const ioReadLimit = resourceLimitValue(container.io_read_mbps, container.io_speed_mbps) const ioWriteLimit = resourceLimitValue(container.io_write_mbps, container.io_speed_mbps) const diskIOPct = Math.max( - directionUsagePercent(usage?.disk_read_bps || 0, ioReadLimit, 1024 * 1024, 1024 * 1024 * 1024), - directionUsagePercent(usage?.disk_write_bps || 0, ioWriteLimit, 1024 * 1024, 1024 * 1024 * 1024), + directionUsagePercent(diskReadBps, ioReadLimit, 1024 * 1024, 1024 * 1024 * 1024), + directionUsagePercent(diskWriteBps, ioWriteLimit, 1024 * 1024, 1024 * 1024 * 1024), ) const mappingCount = container.port_mappings?.length || 0 const mappingLimit = Math.max(container.port_mapping_limit || 0, mappingCount) @@ -967,16 +980,24 @@ export default function ContainerDetail() { icon: , current: networkBps, points: toChartPoints(filtered, 'network'), + series: [ + { label: '入', points: toChartPoints(filtered, 'networkRx'), current: networkRxBps, color: '#2563eb' }, + { label: '出', points: toChartPoints(filtered, 'networkTx'), current: networkTxBps, color: '#16a34a' }, + ], formatValue: formatRate, - detail: `入 ${formatRate(usage?.network_rx_bps || 0)} / 出 ${formatRate(usage?.network_tx_bps || 0)},限速占用 ${netPct.toFixed(1)}%,累计 ${formatBytes((usage?.network_rx_bytes || 0) + (usage?.network_tx_bytes || 0))}`, + detail: `入 ${formatRate(networkRxBps)} / 出 ${formatRate(networkTxBps)},限速占用 ${netPct.toFixed(1)}%,累计 ${formatBytes((usage?.network_rx_bytes || 0) + (usage?.network_tx_bytes || 0))}`, }, { title: '磁盘IO', icon: , current: diskIOBps, points: toChartPoints(filtered, 'diskIO'), + series: [ + { label: '读', points: toChartPoints(filtered, 'diskRead'), current: diskReadBps, color: '#d97706' }, + { label: '写', points: toChartPoints(filtered, 'diskWrite'), current: diskWriteBps, color: '#dc2626' }, + ], formatValue: formatRate, - detail: `读 ${formatRate(usage?.disk_read_bps || 0)} / 写 ${formatRate(usage?.disk_write_bps || 0)},限速占用 ${diskIOPct.toFixed(1)}%,累计 ${formatBytes((usage?.disk_read_bytes || 0) + (usage?.disk_write_bytes || 0))},容量 ${diskPct.toFixed(1)}%`, + detail: `读 ${formatRate(diskReadBps)} / 写 ${formatRate(diskWriteBps)},限速占用 ${diskIOPct.toFixed(1)}%,累计 ${formatBytes((usage?.disk_read_bytes || 0) + (usage?.disk_write_bytes || 0))},容量 ${diskPct.toFixed(1)}%`, }, ] @@ -2514,8 +2535,11 @@ function formatDirectionalLimit(firstLabel: string, firstValue: number, secondLa return `${firstLabel} ${formatLimit(firstValue, unit)} / ${secondLabel} ${formatLimit(secondValue, unit)}` } -function toChartPoints>(history: MetricPoint[], key: T): ChartPoint[] { - return history.map((point) => ({ ts: point.ts, value: Number(point[key]) || 0 })) +function toChartPoints(history: MetricPoint[], key: keyof Omit): ChartPoint[] { + return history.flatMap((point) => { + const value = Number(point[key]) + return Number.isFinite(value) ? [{ ts: point.ts, value }] : [] + }) } function formatPercent(value: number): string { diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 2a0c626..c2a29af 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -13,8 +13,12 @@ type HostMetricPoint = { ts: number cpu: number memory: number - network: number - diskIO: number + network?: number + networkRx?: number + networkTx?: number + diskIO?: number + diskRead?: number + diskWrite?: number } const hostHistoryKey = 'clicd_host_metric_history_v2' @@ -58,6 +62,10 @@ export default function Dashboard() { const filtered = filterHistory(history, range) const memoryPct = host && host.ram.total_mb > 0 ? (host.ram.used_mb / host.ram.total_mb) * 100 : 0 + const networkRxBps = host?.network.rx_bps || 0 + const networkTxBps = host?.network.tx_bps || 0 + const diskReadBps = host?.disk_io.read_bps || 0 + const diskWriteBps = host?.disk_io.write_bps || 0 const networkBps = (host?.network.rx_bps || 0) + (host?.network.tx_bps || 0) const diskIOBps = (host?.disk_io.read_bps || 0) + (host?.disk_io.write_bps || 0) @@ -85,16 +93,24 @@ export default function Dashboard() { icon: , current: networkBps, points: toChartPoints(filtered, 'network'), + series: [ + { label: '入', points: toChartPoints(filtered, 'networkRx'), current: networkRxBps, color: '#2563eb' }, + { label: '出', points: toChartPoints(filtered, 'networkTx'), current: networkTxBps, color: '#16a34a' }, + ], formatValue: formatRate, - detail: `入 ${formatRate(host?.network.rx_bps || 0)} / 出 ${formatRate(host?.network.tx_bps || 0)}`, + detail: `入 ${formatRate(networkRxBps)} / 出 ${formatRate(networkTxBps)}`, }, { title: '磁盘IO', icon: , current: diskIOBps, points: toChartPoints(filtered, 'diskIO'), + series: [ + { label: '读', points: toChartPoints(filtered, 'diskRead'), current: diskReadBps, color: '#d97706' }, + { label: '写', points: toChartPoints(filtered, 'diskWrite'), current: diskWriteBps, color: '#dc2626' }, + ], formatValue: formatRate, - detail: `读 ${formatRate(host?.disk_io.read_bps || 0)} / 写 ${formatRate(host?.disk_io.write_bps || 0)}`, + detail: `读 ${formatRate(diskReadBps)} / 写 ${formatRate(diskWriteBps)}`, }, ] @@ -157,12 +173,20 @@ function SummaryCard({ } function appendHostPoint(host: HostInfo, setHistory: (updater: (prev: HostMetricPoint[]) => HostMetricPoint[]) => void) { + const networkRx = host.network.rx_bps || 0 + const networkTx = host.network.tx_bps || 0 + const diskRead = host.disk_io.read_bps || 0 + const diskWrite = host.disk_io.write_bps || 0 const point: HostMetricPoint = { ts: Date.now(), cpu: clamp(host.cpu.usage_pct), memory: host.ram.total_mb > 0 ? clamp((host.ram.used_mb / host.ram.total_mb) * 100) : 0, - network: (host.network.rx_bps || 0) + (host.network.tx_bps || 0), - diskIO: (host.disk_io.read_bps || 0) + (host.disk_io.write_bps || 0), + network: networkRx + networkTx, + networkRx, + networkTx, + diskIO: diskRead + diskWrite, + diskRead, + diskWrite, } setHistory((prev) => { @@ -190,8 +214,11 @@ function filterHistory(history: HostMetricPoint[], range: StatsRangeKey) { return history.filter((point) => point.ts >= cutoff) } -function toChartPoints>(history: HostMetricPoint[], key: T): ChartPoint[] { - return history.map((point) => ({ ts: point.ts, value: Number(point[key]) || 0 })) +function toChartPoints(history: HostMetricPoint[], key: keyof Omit): ChartPoint[] { + return history.flatMap((point) => { + const value = Number(point[key]) + return Number.isFinite(value) ? [{ ts: point.ts, value }] : [] + }) } function clamp(value: number) {