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;
+}