mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-05 05:36:07 +08:00
网络流量、磁盘 IO 图表显示优化
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { ReactNode } from 'react'
|
import { ReactNode, useId } from 'react'
|
||||||
import { RefreshCw } from 'lucide-react'
|
import { RefreshCw } from 'lucide-react'
|
||||||
import { useTheme } from '../contexts/ThemeContext'
|
import { useTheme } from '../contexts/ThemeContext'
|
||||||
|
|
||||||
@@ -9,17 +9,27 @@ export type ChartPoint = {
|
|||||||
value: number
|
value: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ResourceChartSeries = {
|
||||||
|
label: string
|
||||||
|
points: ChartPoint[]
|
||||||
|
current?: number
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
export type ResourceChartConfig = {
|
export type ResourceChartConfig = {
|
||||||
title: string
|
title: string
|
||||||
icon: ReactNode
|
icon: ReactNode
|
||||||
points: ChartPoint[]
|
points: ChartPoint[]
|
||||||
current: number
|
current: number
|
||||||
|
series?: ResourceChartSeries[]
|
||||||
detail?: string
|
detail?: string
|
||||||
max?: number
|
max?: number
|
||||||
unitLabel?: string
|
unitLabel?: string
|
||||||
formatValue: (value: number) => string
|
formatValue: (value: number) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chartPalette = ['#2563eb', '#16a34a', '#d97706', '#dc2626']
|
||||||
|
|
||||||
const rangeLabels: Record<StatsRangeKey, string> = {
|
const rangeLabels: Record<StatsRangeKey, string> = {
|
||||||
'30m': '30分钟',
|
'30m': '30分钟',
|
||||||
'1h': '1小时',
|
'1h': '1小时',
|
||||||
@@ -77,36 +87,52 @@ export default function ResourceStatsPanel({
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-2">
|
<div className="grid grid-cols-1 xl:grid-cols-2">
|
||||||
{charts.map((chart, index) => (
|
{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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DetailedChart({ chart, className }: { chart: ResourceChartConfig; className: string }) {
|
function DetailedChart({ chart, range, className }: { chart: ResourceChartConfig; range: StatsRangeKey; className: string }) {
|
||||||
const values = chart.points.map((point) => point.value)
|
const series = chart.series?.length
|
||||||
const avg = values.length > 0 ? values.reduce((sum, value) => sum + value, 0) / values.length : 0
|
? chart.series
|
||||||
const peak = values.length > 0 ? Math.max(...values) : 0
|
: [{ label: chart.title, points: chart.points, current: chart.current }]
|
||||||
|
const primaryStats = getSeriesStats(series[0], chart.current)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`p-4 ${className}`}>
|
<div className={`p-4 ${className}`}>
|
||||||
<div className="flex items-start justify-between gap-3 mb-2">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between mb-2">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-1.5 text-sm font-semibold text-gray-950 dark:text-white">
|
<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 className="text-gray-500 dark:text-gray-400">{chart.icon}</span>
|
||||||
<span>{chart.title}</span>
|
<span>{chart.title}</span>
|
||||||
</div>
|
</div>
|
||||||
{chart.detail && <p className="mt-0.5 text-[11px] text-gray-400 dark:text-gray-500">{chart.detail}</p>}
|
{chart.detail && <p className="mt-0.5 text-[11px] text-gray-400 dark:text-gray-500">{chart.detail}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-3 text-right">
|
{series.length > 1 ? (
|
||||||
<Stat label="当前" value={chart.formatValue(chart.current)} />
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-right sm:shrink-0">
|
||||||
<Stat label="平均" value={chart.formatValue(avg)} />
|
{series.map((item, index) => (
|
||||||
<Stat label="峰值" value={chart.formatValue(peak)} />
|
<SeriesStat
|
||||||
</div>
|
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>
|
</div>
|
||||||
<LineAreaChart
|
<LineAreaChart
|
||||||
points={chart.points}
|
series={series}
|
||||||
|
range={range}
|
||||||
max={chart.max}
|
max={chart.max}
|
||||||
formatValue={chart.formatValue}
|
formatValue={chart.formatValue}
|
||||||
unitLabel={chart.unitLabel}
|
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 }) {
|
function Stat({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<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({
|
function LineAreaChart({
|
||||||
points,
|
series,
|
||||||
|
range,
|
||||||
max,
|
max,
|
||||||
formatValue,
|
formatValue,
|
||||||
unitLabel,
|
unitLabel,
|
||||||
}: {
|
}: {
|
||||||
points: ChartPoint[]
|
series: ResourceChartSeries[]
|
||||||
|
range: StatsRangeKey
|
||||||
max?: number
|
max?: number
|
||||||
formatValue: (value: number) => string
|
formatValue: (value: number) => string
|
||||||
unitLabel?: string
|
unitLabel?: string
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const isDark = theme === 'dark'
|
const isDark = theme === 'dark'
|
||||||
|
const gradientId = `resource-chart-fill-${useId().replace(/:/g, '')}`
|
||||||
|
|
||||||
const width = 520
|
const width = 520
|
||||||
const height = 150
|
const height = 150
|
||||||
@@ -146,21 +213,21 @@ function LineAreaChart({
|
|||||||
const bottom = 28
|
const bottom = 28
|
||||||
const innerWidth = width - left - right
|
const innerWidth = width - left - right
|
||||||
const innerHeight = height - top - bottom
|
const innerHeight = height - top - bottom
|
||||||
const values = points.length > 0 ? points : [{ ts: Date.now(), value: 0 }]
|
const now = Date.now()
|
||||||
const maxValue = Math.max(max || 0, ...values.map((point) => point.value), 1)
|
const chartSeries = series.map((item) => {
|
||||||
const minTs = values[0]?.ts || Date.now()
|
const validPoints = item.points.filter((point) => Number.isFinite(point.ts) && Number.isFinite(point.value))
|
||||||
const maxTs = values[values.length - 1]?.ts || minTs + 1
|
return {
|
||||||
const span = Math.max(maxTs - minTs, 1)
|
...item,
|
||||||
|
points: validPoints.length > 0
|
||||||
const coords = values.map((point, index) => {
|
? validPoints
|
||||||
const x = left + ((point.ts - minTs) / span) * innerWidth
|
: [{ ts: now, value: Number.isFinite(item.current) ? Number(item.current) : 0 }],
|
||||||
const y = top + innerHeight - (point.value / maxValue) * innerHeight
|
}
|
||||||
return `${Number.isFinite(x) ? x : left},${Number.isFinite(y) ? y : top + innerHeight}`
|
|
||||||
})
|
})
|
||||||
const fallbackX = left
|
const allPoints = chartSeries.flatMap((item) => item.points)
|
||||||
const fallbackY = top + innerHeight
|
const maxValue = Math.max(max || 0, ...allPoints.map((point) => point.value), 1)
|
||||||
const line = coords.length > 1 ? coords.join(' ') : `${fallbackX},${fallbackY} ${left + innerWidth},${fallbackY}`
|
const maxTs = now
|
||||||
const area = `${left},${top + innerHeight} ${line} ${left + innerWidth},${top + innerHeight}`
|
const minTs = now - statsRanges[range]
|
||||||
|
const span = Math.max(maxTs - minTs, 1)
|
||||||
const yTicks = [1, 0.5, 0]
|
const yTicks = [1, 0.5, 0]
|
||||||
const xTicks = [0, 0.5, 1]
|
const xTicks = [0, 0.5, 1]
|
||||||
|
|
||||||
@@ -171,11 +238,13 @@ function LineAreaChart({
|
|||||||
const lineStroke = isDark ? '#f9fafb' : '#444'
|
const lineStroke = isDark ? '#f9fafb' : '#444'
|
||||||
const gradientTop = isDark ? '#f9fafb' : '#555'
|
const gradientTop = isDark ? '#f9fafb' : '#555'
|
||||||
const gradientBottom = isDark ? '#374151' : '#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 (
|
return (
|
||||||
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-[140px]" preserveAspectRatio="none">
|
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-[140px]" preserveAspectRatio="none">
|
||||||
<defs>
|
<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="0%" stopColor={gradientTop} stopOpacity="0.25" />
|
||||||
<stop offset="100%" stopColor={gradientBottom} stopOpacity="0.02" />
|
<stop offset="100%" stopColor={gradientBottom} stopOpacity="0.02" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
@@ -214,12 +283,45 @@ function LineAreaChart({
|
|||||||
|
|
||||||
<line x1={left} y1={top} x2={left} y2={top + innerHeight} stroke={axisStroke} />
|
<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} />
|
<line x1={left} y1={top + innerHeight} x2={left + innerWidth} y2={top + innerHeight} stroke={axisStroke} />
|
||||||
<polygon points={area} fill="url(#resource-chart-fill)" />
|
{chartSeries.length === 1 && <polygon points={area} fill={`url(#${gradientId})`} />}
|
||||||
<polyline points={line} fill="none" stroke={lineStroke} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
{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>
|
</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) {
|
function chartBorderClass(index: number) {
|
||||||
const right = index % 2 === 0 ? 'xl:border-r' : ''
|
const right = index % 2 === 0 ? 'xl:border-r' : ''
|
||||||
const top = index > 1 ? 'border-t' : ''
|
const top = index > 1 ? 'border-t' : ''
|
||||||
|
|||||||
@@ -89,8 +89,12 @@ type MetricPoint = {
|
|||||||
ts: number
|
ts: number
|
||||||
cpu: number
|
cpu: number
|
||||||
memory: number
|
memory: number
|
||||||
network: number
|
network?: number
|
||||||
diskIO: number
|
networkRx?: number
|
||||||
|
networkTx?: number
|
||||||
|
diskIO?: number
|
||||||
|
diskRead?: number
|
||||||
|
diskWrite?: number
|
||||||
}
|
}
|
||||||
type MappingDraft = {
|
type MappingDraft = {
|
||||||
index: number | null
|
index: number | null
|
||||||
@@ -214,15 +218,21 @@ export default function ContainerDetail() {
|
|||||||
const memoryPct = memoryTotalBytes > 0
|
const memoryPct = memoryTotalBytes > 0
|
||||||
? (nextUsage.memory_usage_bytes / memoryTotalBytes) * 100
|
? (nextUsage.memory_usage_bytes / memoryTotalBytes) * 100
|
||||||
: 0
|
: 0
|
||||||
const networkBps = (nextUsage.network_rx_bps || 0) + (nextUsage.network_tx_bps || 0)
|
const networkRx = nextUsage.network_rx_bps || 0
|
||||||
const diskIOBps = (nextUsage.disk_read_bps || 0) + (nextUsage.disk_write_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 = {
|
const point: MetricPoint = {
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
cpu: clamp((nextUsage.cpu_usage_pct || 0) / (currentContainer.vcpu || 1)),
|
cpu: clamp((nextUsage.cpu_usage_pct || 0) / (currentContainer.vcpu || 1)),
|
||||||
memory: clamp(memoryPct),
|
memory: clamp(memoryPct),
|
||||||
network: networkBps,
|
network: networkRx + networkTx,
|
||||||
diskIO: diskIOBps,
|
networkRx,
|
||||||
|
networkTx,
|
||||||
|
diskIO: diskRead + diskWrite,
|
||||||
|
diskRead,
|
||||||
|
diskWrite,
|
||||||
}
|
}
|
||||||
|
|
||||||
setHistory((prev) => {
|
setHistory((prev) => {
|
||||||
@@ -907,20 +917,23 @@ export default function ContainerDetail() {
|
|||||||
const ramPct = ramTotalBytes > 0 ? clamp(((usage?.memory_usage_bytes || 0) / ramTotalBytes) * 100) : 0
|
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 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 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 networkRxBps = usage?.network_rx_bps || 0
|
||||||
const rx = 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 networkDownLimit = resourceLimitValue(container.network_down_mbps, container.network_bw_mbps)
|
||||||
const networkUpLimit = resourceLimitValue(container.network_up_mbps, container.network_bw_mbps)
|
const networkUpLimit = resourceLimitValue(container.network_up_mbps, container.network_bw_mbps)
|
||||||
const netPct = Math.max(
|
const netPct = Math.max(
|
||||||
directionUsagePercent(usage?.network_rx_bps || 0, networkDownLimit, 125000, 125000000),
|
directionUsagePercent(networkRxBps, networkDownLimit, 125000, 125000000),
|
||||||
directionUsagePercent(usage?.network_tx_bps || 0, networkUpLimit, 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 ioReadLimit = resourceLimitValue(container.io_read_mbps, container.io_speed_mbps)
|
||||||
const ioWriteLimit = resourceLimitValue(container.io_write_mbps, container.io_speed_mbps)
|
const ioWriteLimit = resourceLimitValue(container.io_write_mbps, container.io_speed_mbps)
|
||||||
const diskIOPct = Math.max(
|
const diskIOPct = Math.max(
|
||||||
directionUsagePercent(usage?.disk_read_bps || 0, ioReadLimit, 1024 * 1024, 1024 * 1024 * 1024),
|
directionUsagePercent(diskReadBps, ioReadLimit, 1024 * 1024, 1024 * 1024 * 1024),
|
||||||
directionUsagePercent(usage?.disk_write_bps || 0, ioWriteLimit, 1024 * 1024, 1024 * 1024 * 1024),
|
directionUsagePercent(diskWriteBps, ioWriteLimit, 1024 * 1024, 1024 * 1024 * 1024),
|
||||||
)
|
)
|
||||||
const mappingCount = container.port_mappings?.length || 0
|
const mappingCount = container.port_mappings?.length || 0
|
||||||
const mappingLimit = Math.max(container.port_mapping_limit || 0, mappingCount)
|
const mappingLimit = Math.max(container.port_mapping_limit || 0, mappingCount)
|
||||||
@@ -967,16 +980,24 @@ export default function ContainerDetail() {
|
|||||||
icon: <Network className="w-5 h-5" />,
|
icon: <Network className="w-5 h-5" />,
|
||||||
current: networkBps,
|
current: networkBps,
|
||||||
points: toChartPoints(filtered, 'network'),
|
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,
|
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',
|
title: '磁盘IO',
|
||||||
icon: <HardDrive className="w-5 h-5" />,
|
icon: <HardDrive className="w-5 h-5" />,
|
||||||
current: diskIOBps,
|
current: diskIOBps,
|
||||||
points: toChartPoints(filtered, 'diskIO'),
|
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,
|
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)}`
|
return `${firstLabel} ${formatLimit(firstValue, unit)} / ${secondLabel} ${formatLimit(secondValue, unit)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function toChartPoints<T extends keyof Omit<MetricPoint, 'ts'>>(history: MetricPoint[], key: T): ChartPoint[] {
|
function toChartPoints(history: MetricPoint[], key: keyof Omit<MetricPoint, 'ts'>): ChartPoint[] {
|
||||||
return history.map((point) => ({ ts: point.ts, value: Number(point[key]) || 0 }))
|
return history.flatMap((point) => {
|
||||||
|
const value = Number(point[key])
|
||||||
|
return Number.isFinite(value) ? [{ ts: point.ts, value }] : []
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPercent(value: number): string {
|
function formatPercent(value: number): string {
|
||||||
|
|||||||
@@ -13,8 +13,12 @@ type HostMetricPoint = {
|
|||||||
ts: number
|
ts: number
|
||||||
cpu: number
|
cpu: number
|
||||||
memory: number
|
memory: number
|
||||||
network: number
|
network?: number
|
||||||
diskIO: number
|
networkRx?: number
|
||||||
|
networkTx?: number
|
||||||
|
diskIO?: number
|
||||||
|
diskRead?: number
|
||||||
|
diskWrite?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const hostHistoryKey = 'clicd_host_metric_history_v2'
|
const hostHistoryKey = 'clicd_host_metric_history_v2'
|
||||||
@@ -58,6 +62,10 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
const filtered = filterHistory(history, range)
|
const filtered = filterHistory(history, range)
|
||||||
const memoryPct = host && host.ram.total_mb > 0 ? (host.ram.used_mb / host.ram.total_mb) * 100 : 0
|
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 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)
|
const diskIOBps = (host?.disk_io.read_bps || 0) + (host?.disk_io.write_bps || 0)
|
||||||
|
|
||||||
@@ -85,16 +93,24 @@ export default function Dashboard() {
|
|||||||
icon: <Network className="w-5 h-5" />,
|
icon: <Network className="w-5 h-5" />,
|
||||||
current: networkBps,
|
current: networkBps,
|
||||||
points: toChartPoints(filtered, 'network'),
|
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,
|
formatValue: formatRate,
|
||||||
detail: `入 ${formatRate(host?.network.rx_bps || 0)} / 出 ${formatRate(host?.network.tx_bps || 0)}`,
|
detail: `入 ${formatRate(networkRxBps)} / 出 ${formatRate(networkTxBps)}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '磁盘IO',
|
title: '磁盘IO',
|
||||||
icon: <HardDrive className="w-5 h-5" />,
|
icon: <HardDrive className="w-5 h-5" />,
|
||||||
current: diskIOBps,
|
current: diskIOBps,
|
||||||
points: toChartPoints(filtered, 'diskIO'),
|
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,
|
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) {
|
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 = {
|
const point: HostMetricPoint = {
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
cpu: clamp(host.cpu.usage_pct),
|
cpu: clamp(host.cpu.usage_pct),
|
||||||
memory: host.ram.total_mb > 0 ? clamp((host.ram.used_mb / host.ram.total_mb) * 100) : 0,
|
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),
|
network: networkRx + networkTx,
|
||||||
diskIO: (host.disk_io.read_bps || 0) + (host.disk_io.write_bps || 0),
|
networkRx,
|
||||||
|
networkTx,
|
||||||
|
diskIO: diskRead + diskWrite,
|
||||||
|
diskRead,
|
||||||
|
diskWrite,
|
||||||
}
|
}
|
||||||
|
|
||||||
setHistory((prev) => {
|
setHistory((prev) => {
|
||||||
@@ -190,8 +214,11 @@ function filterHistory(history: HostMetricPoint[], range: StatsRangeKey) {
|
|||||||
return history.filter((point) => point.ts >= cutoff)
|
return history.filter((point) => point.ts >= cutoff)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toChartPoints<T extends keyof Omit<HostMetricPoint, 'ts'>>(history: HostMetricPoint[], key: T): ChartPoint[] {
|
function toChartPoints(history: HostMetricPoint[], key: keyof Omit<HostMetricPoint, 'ts'>): ChartPoint[] {
|
||||||
return history.map((point) => ({ ts: point.ts, value: Number(point[key]) || 0 }))
|
return history.flatMap((point) => {
|
||||||
|
const value = Number(point[key])
|
||||||
|
return Number.isFinite(value) ? [{ ts: point.ts, value }] : []
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function clamp(value: number) {
|
function clamp(value: number) {
|
||||||
|
|||||||
Reference in New Issue
Block a user