diff --git a/ui/src/components/compare/ResourceComparisonCharts.tsx b/ui/src/components/compare/ResourceComparisonCharts.tsx index 4974a7d56..65d2c4fba 100644 --- a/ui/src/components/compare/ResourceComparisonCharts.tsx +++ b/ui/src/components/compare/ResourceComparisonCharts.tsx @@ -1,66 +1,41 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import ReactECharts from 'echarts-for-react' import { Cpu } from 'lucide-react' -import type { TestEntry, ResourceTotals, SuiteTest } from '@/api/types' +import type { TestEntry, StepResult, SuiteTest } from '@/api/types' import { formatBytes } from '@/utils/format' import { type ChartType, type CompareRun, type LabelMode, RUN_SLOTS, formatRunLabel } from './constants' import type { ZoomRange } from './MGasComparisonChart' import { useChartAreaClick } from './useChartAreaClick' import { formatTestNameLong } from '@/utils/eestName' import { useNameDisplayMode } from '@/hooks/useNameDisplayMode' - -interface AggregatedResourceData { - totals: ResourceTotals - timeTotalNs: number - memoryBytes: number +import { SegmentedControl } from '@/components/shared/SegmentedControl' +import { + aggregateResourceByStep, + DEFAULT_RESOURCE_STEP, + RESOURCE_STEP_OPTIONS, + type AggregatedResource, + type ResourceStep, + type StepResource, +} from '@/utils/resourceStep' + +// stepResource normalises a per-test-result step into the shared helper's input. +function stepResource(step?: StepResult): StepResource | undefined { + if (!step?.aggregated) return undefined + + return { resourceTotals: step.aggregated.resource_totals, timeTotalNs: step.aggregated.time_total } } -function getAggregatedResourceData(entry: TestEntry): AggregatedResourceData | undefined { +function getAggregatedResourceData(entry: TestEntry, step: ResourceStep): AggregatedResource | undefined { if (!entry.steps) return undefined - const steps = [entry.steps.setup, entry.steps.test, entry.steps.cleanup].filter((s) => s?.aggregated?.resource_totals) - - if (steps.length === 0) return undefined - - let cpuUsec = 0 - let memoryDelta = 0 - let diskRead = 0 - let diskWrite = 0 - let diskReadOps = 0 - let diskWriteOps = 0 - let timeTotalNs = 0 - let memoryBytes = 0 - - for (const step of steps) { - if (step?.aggregated) { - timeTotalNs += step.aggregated.time_total ?? 0 - if (step.aggregated.resource_totals) { - const res = step.aggregated.resource_totals - cpuUsec += res.cpu_usec ?? 0 - memoryDelta += res.memory_delta_bytes ?? 0 - diskRead += res.disk_read_bytes ?? 0 - diskWrite += res.disk_write_bytes ?? 0 - diskReadOps += res.disk_read_iops ?? 0 - diskWriteOps += res.disk_write_iops ?? 0 - const stepMemory = res.memory_bytes ?? 0 - if (stepMemory > memoryBytes) memoryBytes = stepMemory - } - } - } - - return { - totals: { - cpu_usec: cpuUsec, - memory_delta_bytes: memoryDelta, - memory_bytes: memoryBytes, - disk_read_bytes: diskRead, - disk_write_bytes: diskWrite, - disk_read_iops: diskReadOps, - disk_write_iops: diskWriteOps, + return aggregateResourceByStep( + { + setup: stepResource(entry.steps.setup), + test: stepResource(entry.steps.test), + cleanup: stepResource(entry.steps.cleanup), }, - timeTotalNs, - memoryBytes, - } + step, + ) } function useDarkMode() { @@ -114,7 +89,7 @@ function formatOps(ops: number): string { return `${(ops / 1_000_000).toFixed(1)}M` } -function buildDataPoints(tests: Record, nameFilter?: (name: string) => boolean, suiteTests?: SuiteTest[]): ResourceDataPoint[] { +function buildDataPoints(tests: Record, resStep: ResourceStep, nameFilter?: (name: string) => boolean, suiteTests?: SuiteTest[]): ResourceDataPoint[] { const suiteOrder = new Map() if (suiteTests) { suiteTests.forEach((t, i) => suiteOrder.set(t.name, i + 1)) @@ -130,7 +105,7 @@ function buildDataPoints(tests: Record, nameFilter?: (name: s const points: ResourceDataPoint[] = [] sortedTests.forEach(([testName, test], index) => { - const agg = getAggregatedResourceData(test) + const agg = getAggregatedResourceData(test, resStep) if (agg) { const res = agg.totals let cpuPercent = 0 @@ -201,6 +176,7 @@ export function ResourceComparisonCharts({ runs, labelMode, testNameFilter, suit const [internalZoom, setInternalZoom] = useState({ start: 0, end: 100 }) const zoomRange = externalZoom ?? internalZoom const prevZoomRef = useRef(zoomRange) + const [resStep, setResStep] = useState(DEFAULT_RESOURCE_STEP) const handleZoom = useCallback((start: number, end: number) => { if (prevZoomRef.current.start !== start || prevZoomRef.current.end !== end) { @@ -212,8 +188,8 @@ export function ResourceComparisonCharts({ runs, labelMode, testNameFilter, suit }, [onZoomChange]) const pointsPerRun = useMemo( - () => runs.map((r) => r.result ? buildDataPoints(r.result.tests, testNameFilter, suiteTests) : []), - [runs, testNameFilter, suiteTests], + () => runs.map((r) => r.result ? buildDataPoints(r.result.tests, resStep, testNameFilter, suiteTests) : []), + [runs, resStep, testNameFilter, suiteTests], ) const highlightedTestRef = useRef(null) @@ -440,6 +416,12 @@ export function ResourceComparisonCharts({ runs, labelMode, testNameFilter, suit ) })} +
diff --git a/ui/src/components/run-detail/ResourceUsageCharts.tsx b/ui/src/components/run-detail/ResourceUsageCharts.tsx index 4e37af56f..37149b2bb 100644 --- a/ui/src/components/run-detail/ResourceUsageCharts.tsx +++ b/ui/src/components/run-detail/ResourceUsageCharts.tsx @@ -1,73 +1,41 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import ReactECharts from 'echarts-for-react' import { Cpu } from 'lucide-react' -import type { TestEntry, ResourceTotals, SuiteTest } from '@/api/types' +import type { TestEntry, StepResult, SuiteTest } from '@/api/types' import { formatBytes } from '@/utils/format' import { compileQuery } from '@/utils/eestNameFilter' import { formatTestNameLong } from '@/utils/eestName' import { useNameDisplayMode } from '@/hooks/useNameDisplayMode' import { getAggregatedStats, ALL_STEP_TYPES } from '@/pages/RunDetailPage' - -// Aggregated resource data from all steps of a test entry -interface AggregatedResourceData { - totals: ResourceTotals - timeTotalNs: number - memoryBytes: number +import { SegmentedControl } from '@/components/shared/SegmentedControl' +import { + aggregateResourceByStep, + DEFAULT_RESOURCE_STEP, + RESOURCE_STEP_OPTIONS, + type AggregatedResource, + type ResourceStep, + type StepResource, +} from '@/utils/resourceStep' + +// stepResource normalises a per-test-result step into the shared helper's input. +function stepResource(step?: StepResult): StepResource | undefined { + if (!step?.aggregated) return undefined + + return { resourceTotals: step.aggregated.resource_totals, timeTotalNs: step.aggregated.time_total } } -// Get aggregated resource totals from all steps of a test entry -function getAggregatedResourceData(entry: TestEntry): AggregatedResourceData | undefined { +// Aggregate a test entry's resource usage for the selected step(s). +function getAggregatedResourceData(entry: TestEntry, step: ResourceStep): AggregatedResource | undefined { if (!entry.steps) return undefined - const steps = [entry.steps.setup, entry.steps.test, entry.steps.cleanup].filter((s) => s?.aggregated?.resource_totals) - - if (steps.length === 0) return undefined - - // Sum up resource totals from all steps - let cpuUsec = 0 - let memoryDelta = 0 - let diskRead = 0 - let diskWrite = 0 - let diskReadOps = 0 - let diskWriteOps = 0 - let timeTotalNs = 0 - let memoryBytes = 0 - - for (const step of steps) { - if (step?.aggregated) { - timeTotalNs += step.aggregated.time_total ?? 0 - - if (step.aggregated.resource_totals) { - const res = step.aggregated.resource_totals - cpuUsec += res.cpu_usec ?? 0 - memoryDelta += res.memory_delta_bytes ?? 0 - diskRead += res.disk_read_bytes ?? 0 - diskWrite += res.disk_write_bytes ?? 0 - diskReadOps += res.disk_read_iops ?? 0 - diskWriteOps += res.disk_write_iops ?? 0 - - // Take max absolute memory across steps (it's a snapshot, not cumulative) - const stepMemory = res.memory_bytes ?? 0 - if (stepMemory > memoryBytes) { - memoryBytes = stepMemory - } - } - } - } - - return { - totals: { - cpu_usec: cpuUsec, - memory_delta_bytes: memoryDelta, - memory_bytes: memoryBytes, - disk_read_bytes: diskRead, - disk_write_bytes: diskWrite, - disk_read_iops: diskReadOps, - disk_write_iops: diskWriteOps, + return aggregateResourceByStep( + { + setup: stepResource(entry.steps.setup), + test: stepResource(entry.steps.test), + cleanup: stepResource(entry.steps.cleanup), }, - timeTotalNs, - memoryBytes, - } + step, + ) } function useDarkMode() { @@ -232,6 +200,7 @@ export function ResourceUsageCharts({ tests, suiteTests, searchQuery, statusFilt const isDark = useDarkMode() const { mode: nameMode } = useNameDisplayMode() const [zoomRange, setZoomRange] = useState({ start: 0, end: 100 }) + const [resStep, setResStep] = useState(DEFAULT_RESOURCE_STEP) const highlightedTestRef = useRef(null) const handleZoom = useCallback((start: number, end: number) => { @@ -291,7 +260,7 @@ export function ResourceUsageCharts({ tests, suiteTests, searchQuery, statusFilt sortedTests.forEach(([testName, test], index) => { const testIndex = index + 1 const testNumber = suiteOrder?.get(testName) ?? testIndex - const agg = getAggregatedResourceData(test) + const agg = getAggregatedResourceData(test, resStep) if (agg) { hasData = true const res = agg.totals @@ -350,7 +319,7 @@ export function ResourceUsageCharts({ tests, suiteTests, searchQuery, statusFilt } return { dataPoints: points, hasResourceData: hasData, hasMemoryMBData: hasMemoryMB, summaryStats: stats } - }, [tests, suiteTests, searchQuery, statusFilter]) + }, [tests, suiteTests, searchQuery, statusFilter, resStep]) const chartOptions = useMemo(() => { @@ -708,23 +677,31 @@ export function ResourceUsageCharts({ tests, suiteTests, searchQuery, statusFilt Resource Usage - {resourceCollectionMethod && ( - - Collection via{' '} - - {resourceCollectionMethod} +
+ {resourceCollectionMethod && ( + + Collection via{' '} + + {resourceCollectionMethod} + - - )} + )} + +
{/* Summary Stats Row */} diff --git a/ui/src/components/shared/SegmentedControl.tsx b/ui/src/components/shared/SegmentedControl.tsx new file mode 100644 index 000000000..47e94fdbc --- /dev/null +++ b/ui/src/components/shared/SegmentedControl.tsx @@ -0,0 +1,50 @@ +import clsx from 'clsx' + +interface SegmentedControlOption { + value: T + label: string +} + +interface SegmentedControlProps { + value: T + onChange: (value: T) => void + options: SegmentedControlOption[] + ariaLabel?: string + className?: string +} + +// SegmentedControl is a compact single-select button group (e.g. a setup | +// test | sum toggle). Values are string literals; the active option is filled. +export function SegmentedControl({ + value, + onChange, + options, + ariaLabel, + className, +}: SegmentedControlProps) { + return ( +
+ {options.map((option, index) => ( + + ))} +
+ ) +} diff --git a/ui/src/components/suite-detail/ResourceCharts.tsx b/ui/src/components/suite-detail/ResourceCharts.tsx index aef88fb47..20a618d52 100644 --- a/ui/src/components/suite-detail/ResourceCharts.tsx +++ b/ui/src/components/suite-detail/ResourceCharts.tsx @@ -4,6 +4,13 @@ import clsx from 'clsx' import type { IndexEntry, ResourceTotals } from '@/api/types' import { getClientChartColor } from '@/utils/client-colors' import { formatBytes } from '@/utils/format' +import { SegmentedControl } from '@/components/shared/SegmentedControl' +import { + aggregateResourceByStep, + DEFAULT_RESOURCE_STEP, + RESOURCE_STEP_OPTIONS, + type ResourceStep, +} from '@/utils/resourceStep' export type XAxisMode = 'time' | 'runCount' @@ -12,6 +19,10 @@ interface ResourceChartsProps { isDark?: boolean xAxisMode?: XAxisMode onXAxisModeChange?: (mode: XAxisMode) => void + // resStep can be controlled by the page (which renders its own toggle when + // hideControls is set); otherwise the component manages it internally. + resStep?: ResourceStep + onResStepChange?: (step: ResourceStep) => void onRunClick?: (runId: string) => void hideControls?: boolean zoomRange?: { start: number; end: number } @@ -54,33 +65,18 @@ function formatOps(ops: number): string { type MetricKey = 'cpu_usec' | 'memory_delta_bytes' | 'disk_read_bytes' | 'disk_write_bytes' | 'disk_read_iops' | 'disk_write_iops' -// Aggregates resource totals from all steps (setup, test, cleanup) -function getAggregatedResourceTotals(entry: IndexEntry): ResourceTotals | undefined { +// Aggregates resource totals for the selected step(s) of an index entry. +function getAggregatedResourceTotals(entry: IndexEntry, step: ResourceStep): ResourceTotals | undefined { const steps = entry.tests.steps - let hasData = false - const totals: ResourceTotals = { - cpu_usec: 0, - memory_delta_bytes: 0, - disk_read_bytes: 0, - disk_write_bytes: 0, - disk_read_iops: 0, - disk_write_iops: 0, - } - - const stepList = [steps.setup, steps.test, steps.cleanup] - for (const step of stepList) { - if (step?.resource_totals) { - hasData = true - totals.cpu_usec += step.resource_totals.cpu_usec - totals.memory_delta_bytes += step.resource_totals.memory_delta_bytes - totals.disk_read_bytes += step.resource_totals.disk_read_bytes - totals.disk_write_bytes += step.resource_totals.disk_write_bytes - totals.disk_read_iops += step.resource_totals.disk_read_iops - totals.disk_write_iops += step.resource_totals.disk_write_iops - } - } - return hasData ? totals : undefined + return aggregateResourceByStep( + { + setup: { resourceTotals: steps.setup?.resource_totals }, + test: { resourceTotals: steps.test?.resource_totals }, + cleanup: { resourceTotals: steps.cleanup?.resource_totals }, + }, + step, + )?.totals } interface MetricConfig { @@ -104,19 +100,20 @@ interface SingleChartProps { runs: IndexEntry[] isDark: boolean xAxisMode: XAxisMode + resStep: ResourceStep onRunClick?: (runId: string) => void isLargeDataset: boolean zoomRange: { start: number; end: number } onZoom: (start: number, end: number) => void } -function SingleChart({ metric, runs, isDark, xAxisMode, onRunClick, isLargeDataset, zoomRange, onZoom }: SingleChartProps) { +function SingleChart({ metric, runs, isDark, xAxisMode, resStep, onRunClick, isLargeDataset, zoomRange, onZoom }: SingleChartProps) { const { clientGroups: chartData, maxRunIndex } = useMemo(() => { const clientGroups = new Map() let maxRunIndex = 1 for (const run of runs) { - const resourceTotals = getAggregatedResourceTotals(run) + const resourceTotals = getAggregatedResourceTotals(run, resStep) if (!resourceTotals) continue const value = resourceTotals[metric.key] @@ -147,7 +144,7 @@ function SingleChart({ metric, runs, isDark, xAxisMode, onRunClick, isLargeDatas } return { clientGroups, maxRunIndex } - }, [runs, metric.key]) + }, [runs, metric.key, resStep]) const series = useMemo(() => { return Array.from(chartData.entries()).map(([client, data]) => ({ @@ -347,6 +344,8 @@ export function ResourceCharts({ isDark = false, xAxisMode: controlledMode, onXAxisModeChange, + resStep: controlledResStep, + onResStepChange, onRunClick, hideControls = false, zoomRange: controlledZoom, @@ -363,6 +362,17 @@ export function ResourceCharts({ } } + const [internalResStep, setInternalResStep] = useState(DEFAULT_RESOURCE_STEP) + const resStep = controlledResStep ?? internalResStep + + const setResStep = (step: ResourceStep) => { + if (onResStepChange) { + onResStepChange(step) + } else { + setInternalResStep(step) + } + } + const [internalZoom, setInternalZoom] = useState({ start: 0, end: 100 }) const activeZoom = controlledZoom ?? internalZoom @@ -383,7 +393,7 @@ export function ResourceCharts({ }, [runs]) const hasResourceData = useMemo(() => { - return runs.some((run) => getAggregatedResourceTotals(run) !== undefined) + return runs.some((run) => getAggregatedResourceTotals(run, 'sum') !== undefined) }, [runs]) if (!hasResourceData) { @@ -397,7 +407,13 @@ export function ResourceCharts({ return (
{!hideControls && ( -
+
+