diff --git a/src/pages/ethereum/slots/components/NodeResources/CpuUtilizationChart.tsx b/src/pages/ethereum/slots/components/NodeResources/CpuUtilizationChart.tsx index 03c6a2a7a..a6449b79b 100644 --- a/src/pages/ethereum/slots/components/NodeResources/CpuUtilizationChart.tsx +++ b/src/pages/ethereum/slots/components/NodeResources/CpuUtilizationChart.tsx @@ -8,6 +8,7 @@ import { ClientLogo } from '@/components/Ethereum/ClientLogo'; import { SelectMenu } from '@/components/Forms/SelectMenu'; import type { FctNodeCpuUtilizationByProcess } from '@/api/types.gen'; import { type CpuMetric, type AnnotationType, type AnnotationEvent, type HighlightRange } from './types'; +import { formatPercent } from './utils'; import { AnnotationSwimLanes } from './AnnotationSwimLanes'; function usToSeconds(us: number): number { @@ -320,7 +321,7 @@ export function CpuUtilizationChart({ yAxis={{ name: '% of all cores', min: 0, - formatter: (v: number) => `${v.toFixed(1)}%`, + formatter: (v: number) => formatPercent(v), }} height={inModal ? 500 : 350} showLegend @@ -345,7 +346,7 @@ export function CpuUtilizationChart({ html += `
`; html += `${p.marker}`; html += `${p.seriesName}`; - html += `${Number(val).toFixed(1)}%`; + html += `${formatPercent(Number(val))}`; html += `
`; } diff --git a/src/pages/ethereum/slots/components/NodeResources/DiskIoChart.tsx b/src/pages/ethereum/slots/components/NodeResources/DiskIoChart.tsx index 8352ad55d..d9fa9fe61 100644 --- a/src/pages/ethereum/slots/components/NodeResources/DiskIoChart.tsx +++ b/src/pages/ethereum/slots/components/NodeResources/DiskIoChart.tsx @@ -7,6 +7,7 @@ import { DEFAULT_BEACON_SLOT_PHASES } from '@/utils/beacon'; import { ClientLogo } from '@/components/Ethereum/ClientLogo'; import type { FctNodeDiskIoByProcess } from '@/api/types.gen'; import { type AnnotationType, type AnnotationEvent, type HighlightRange } from './types'; +import { getByteScale, formatScaled, seriesMax } from './utils'; import { AnnotationSwimLanes } from './AnnotationSwimLanes'; function usToSeconds(us: number): number { @@ -76,9 +77,9 @@ export function DiskIoChart({ [] ); - const { series, clClient, elClient } = useMemo(() => { + const { series, clClient, elClient, displayUnit } = useMemo(() => { if (data.length === 0) { - return { series: [] as SeriesData[], clClient: '', elClient: '' }; + return { series: [] as SeriesData[], clClient: '', elClient: '', displayUnit: 'KB' }; } const slotStartUs = Math.min(...data.map(d => d.wallclock_slot_start_date_time ?? 0).filter(v => v > 0)); @@ -201,7 +202,18 @@ export function DiskIoChart({ }); } - return { series: chartSeries, clClient: resolvedClClient, elClient: resolvedElClient }; + // Scale data to appropriate unit so ECharts picks nice round ticks + const maxKB = seriesMax(chartSeries); + const { divisor, unit } = getByteScale(maxKB, 'KB'); + if (divisor > 1) { + for (const s of chartSeries) { + for (const p of s.data as [number, number][]) { + p[1] /= divisor; + } + } + } + + return { series: chartSeries, clClient: resolvedClClient, elClient: resolvedElClient, displayUnit: unit }; }, [data, selectedNode, CHART_CATEGORICAL_COLORS]); const markLines = useMemo((): MarkLineConfig[] => { @@ -273,9 +285,9 @@ export function DiskIoChart({ formatter: (v: number | string) => `${v}s`, }} yAxis={{ - name: 'KB', + name: displayUnit, min: 0, - formatter: (v: number) => `${v.toFixed(0)} KB`, + formatter: (v: number) => formatScaled(v, displayUnit), }} height={inModal ? 500 : 350} showLegend @@ -300,7 +312,7 @@ export function DiskIoChart({ html += `
`; html += `${p.marker}`; html += `${p.seriesName}`; - html += `${Number(val).toFixed(1)} KB`; + html += `${formatScaled(Number(val), displayUnit)}`; html += `
`; } diff --git a/src/pages/ethereum/slots/components/NodeResources/MemoryUsageChart.tsx b/src/pages/ethereum/slots/components/NodeResources/MemoryUsageChart.tsx index 4f01586f6..ea20e3fbf 100644 --- a/src/pages/ethereum/slots/components/NodeResources/MemoryUsageChart.tsx +++ b/src/pages/ethereum/slots/components/NodeResources/MemoryUsageChart.tsx @@ -8,6 +8,7 @@ import { ClientLogo } from '@/components/Ethereum/ClientLogo'; import { SelectMenu } from '@/components/Forms/SelectMenu'; import type { FctNodeMemoryUsageByProcess } from '@/api/types.gen'; import { type AnnotationType, type AnnotationEvent, type HighlightRange } from './types'; +import { getByteScale, formatScaled, seriesMax } from './utils'; import { AnnotationSwimLanes } from './AnnotationSwimLanes'; function usToSeconds(us: number): number { @@ -110,9 +111,9 @@ export function MemoryUsageChart({ [] ); - const { series, clClient, elClient } = useMemo(() => { + const { series, clClient, elClient, displayUnit } = useMemo(() => { if (data.length === 0) { - return { series: [] as SeriesData[], clClient: '', elClient: '' }; + return { series: [] as SeriesData[], clClient: '', elClient: '', displayUnit: 'MB' }; } const slotStartUs = Math.min(...data.map(d => d.wallclock_slot_start_date_time ?? 0).filter(v => v > 0)); @@ -202,7 +203,18 @@ export function MemoryUsageChart({ }); } - return { series: chartSeries, clClient: resolvedClClient, elClient: resolvedElClient }; + // Scale data to appropriate unit so ECharts picks nice round ticks + const maxMB = seriesMax(chartSeries); + const { divisor, unit } = getByteScale(maxMB, 'MB'); + if (divisor > 1) { + for (const s of chartSeries) { + for (const p of s.data as [number, number][]) { + p[1] /= divisor; + } + } + } + + return { series: chartSeries, clClient: resolvedClClient, elClient: resolvedElClient, displayUnit: unit }; }, [data, selectedNode, metric, CHART_CATEGORICAL_COLORS]); const markLines = useMemo((): MarkLineConfig[] => { @@ -274,9 +286,9 @@ export function MemoryUsageChart({ formatter: (v: number | string) => `${v}s`, }} yAxis={{ - name: 'MB', + name: displayUnit, min: 0, - formatter: (v: number) => `${v.toFixed(0)} MB`, + formatter: (v: number) => formatScaled(v, displayUnit), }} height={inModal ? 500 : 350} showLegend @@ -301,7 +313,7 @@ export function MemoryUsageChart({ html += `
`; html += `${p.marker}`; html += `${p.seriesName}`; - html += `${Number(val).toFixed(1)} MB`; + html += `${formatScaled(Number(val), displayUnit)}`; html += `
`; } diff --git a/src/pages/ethereum/slots/components/NodeResources/NetworkIoChart.tsx b/src/pages/ethereum/slots/components/NodeResources/NetworkIoChart.tsx index c529abdff..7cc057551 100644 --- a/src/pages/ethereum/slots/components/NodeResources/NetworkIoChart.tsx +++ b/src/pages/ethereum/slots/components/NodeResources/NetworkIoChart.tsx @@ -6,6 +6,7 @@ import { getDataVizColors } from '@/utils'; import { DEFAULT_BEACON_SLOT_PHASES } from '@/utils/beacon'; import type { FctNodeNetworkIoByProcess } from '@/api/types.gen'; import { type AnnotationType, type AnnotationEvent, type HighlightRange } from './types'; +import { getByteScale, formatScaled, seriesMax } from './utils'; import { AnnotationSwimLanes } from './AnnotationSwimLanes'; function usToSeconds(us: number): number { @@ -19,19 +20,29 @@ function bytesToKB(bytes: number): number { /** Port labels that are hidden by default (internal/API traffic) */ const HIDDEN_PORTS = new Set(['cl_beacon_api', 'el_json_rpc', 'el_engine_api', 'el_ws', 'cl_grpc']); -const PORT_LABELS: Record = { - el_p2p_tcp: 'EL P2P', - cl_p2p_tcp: 'CL P2P', - cl_discovery: 'CL Discovery', - el_discovery: 'EL Discovery', - cl_beacon_api: 'CL Beacon API', - el_json_rpc: 'EL JSON-RPC', - el_engine_api: 'EL Engine API', - el_ws: 'EL WebSocket', - cl_grpc: 'CL gRPC', - unknown: 'Unknown', +/** Well-known abbreviations and expansions for port label segments */ +const PORT_LABEL_TOKENS: Record = { + cl: 'CL', + el: 'EL', + p2p: 'P2P', + tcp: 'TCP', + udp: 'UDP', + api: 'API', + rpc: 'RPC', + grpc: 'gRPC', + ws: 'WebSocket', + json: 'JSON', + io: 'I/O', }; +/** Convert a snake_case port label to a human-readable name */ +function formatPortLabel(label: string): string { + return label + .split('_') + .map(part => PORT_LABEL_TOKENS[part] ?? part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + const BUCKET_SIZE = 0.25; const ALL_BUCKETS = Array.from({ length: 49 }, (_, i) => i * BUCKET_SIZE); const toBucket = (offsetSec: number): number => Math.round(offsetSec / BUCKET_SIZE) * BUCKET_SIZE; @@ -87,8 +98,8 @@ export function NetworkIoChart({ [] ); - const series = useMemo(() => { - if (data.length === 0) return [] as SeriesData[]; + const { series, displayUnit } = useMemo(() => { + if (data.length === 0) return { series: [] as SeriesData[], displayUnit: 'KB' }; const slotStartUs = Math.min(...data.map(d => d.wallclock_slot_start_date_time ?? 0).filter(v => v > 0)); const chartSeries: SeriesData[] = []; @@ -123,7 +134,7 @@ export function NetworkIoChart({ t => [t, buckets.has(t) ? avg(buckets.get(t)!) : 0] as [number, number] ); - const label = PORT_LABELS[port] ?? port; + const label = formatPortLabel(port); const color = CHART_CATEGORICAL_COLORS[i % CHART_CATEGORICAL_COLORS.length]; chartSeries.push({ @@ -138,7 +149,18 @@ export function NetworkIoChart({ }); } - return chartSeries; + // Scale data to appropriate unit so ECharts picks nice round ticks + const maxKB = seriesMax(chartSeries); + const { divisor, unit } = getByteScale(maxKB, 'KB'); + if (divisor > 1) { + for (const s of chartSeries) { + for (const p of s.data as [number, number][]) { + p[1] /= divisor; + } + } + } + + return { series: chartSeries, displayUnit: unit }; }, [data, selectedNode, CHART_CATEGORICAL_COLORS]); const markLines = useMemo((): MarkLineConfig[] => { @@ -202,9 +224,9 @@ export function NetworkIoChart({ formatter: (v: number | string) => `${v}s`, }} yAxis={{ - name: 'KB', + name: displayUnit, min: 0, - formatter: (v: number) => `${v.toFixed(0)} KB`, + formatter: (v: number) => formatScaled(v, displayUnit), }} height={inModal ? 500 : 350} showLegend @@ -229,7 +251,7 @@ export function NetworkIoChart({ html += `
`; html += `${p.marker}`; html += `${p.seriesName}`; - html += `${Number(val).toFixed(1)} KB`; + html += `${formatScaled(Number(val), displayUnit)}`; html += `
`; } diff --git a/src/pages/ethereum/slots/components/NodeResources/NodeResourcesPanel.tsx b/src/pages/ethereum/slots/components/NodeResources/NodeResourcesPanel.tsx index 3f8444bbe..8c86cd39c 100644 --- a/src/pages/ethereum/slots/components/NodeResources/NodeResourcesPanel.tsx +++ b/src/pages/ethereum/slots/components/NodeResources/NodeResourcesPanel.tsx @@ -84,7 +84,7 @@ export function NodeResourcesPanel({ const referenceNodesOnly = search.refNodes ?? false; const selectedNode = search.node ?? null; const metric: CpuMetric = search.metric ?? 'mean'; - const memMetric: MemoryMetric = search.memMetric ?? 'vm_rss'; + const memMetric: MemoryMetric = search.memMetric ?? 'rss_anon'; const setReferenceNodesOnly = useCallback( (value: boolean) => { @@ -130,7 +130,7 @@ export function NodeResourcesPanel({ navigate({ to: '/ethereum/slots/$slot', params: { slot: String(slot) }, - search: { ...search, memMetric: value === 'vm_rss' ? undefined : value }, + search: { ...search, memMetric: value === 'rss_anon' ? undefined : value }, replace: true, resetScroll: false, }); diff --git a/src/pages/ethereum/slots/components/NodeResources/utils.ts b/src/pages/ethereum/slots/components/NodeResources/utils.ts new file mode 100644 index 000000000..b95b63470 --- /dev/null +++ b/src/pages/ethereum/slots/components/NodeResources/utils.ts @@ -0,0 +1,65 @@ +const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const; + +interface ByteScale { + divisor: number; + unit: string; +} + +/** + * Determine the appropriate display unit for byte-based values. + * Returns a divisor to apply to values so they render in a human-friendly unit. + * + * @param maxValue - The largest value in the dataset (in baseUnit) + * @param baseUnit - The unit the raw values are already expressed in + */ +export function getByteScale(maxValue: number, baseUnit: 'KB' | 'MB'): ByteScale { + const baseIndex = BYTE_UNITS.indexOf(baseUnit); + let divisor = 1; + let unitIndex = baseIndex; + + while (maxValue / divisor >= 1024 && unitIndex < BYTE_UNITS.length - 1) { + divisor *= 1024; + unitIndex++; + } + + return { divisor, unit: BYTE_UNITS[unitIndex] }; +} + +/** + * Format a numeric value with adaptive precision to avoid duplicate y-axis labels. + * Picks decimal places based on value magnitude. + */ +export function formatScaled(value: number, unit: string): string { + if (value === 0) return `0 ${unit}`; + const abs = Math.abs(value); + if (abs >= 100) return `${Math.round(value)} ${unit}`; + if (abs >= 1) return `${value.toFixed(1)} ${unit}`; + return `${value.toFixed(2)} ${unit}`; +} + +/** + * Format a percentage with adaptive precision to avoid duplicate y-axis labels. + */ +export function formatPercent(value: number): string { + if (value === 0) return '0%'; + const abs = Math.abs(value); + if (abs >= 10) return `${Math.round(value)}%`; + if (abs >= 1) return `${value.toFixed(1)}%`; + if (abs >= 0.1) return `${value.toFixed(2)}%`; + return `${value.toFixed(3)}%`; +} + +/** + * Find the max y-value across all series data points. + * Accepts the broad SeriesData.data union type. + */ +export function seriesMax(series: { data: unknown[] }[]): number { + let max = 0; + for (const s of series) { + for (const point of s.data) { + const y = Array.isArray(point) ? (point[1] as number) : (point as number); + if (typeof y === 'number' && isFinite(y) && y > max) max = y; + } + } + return max; +}