Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 35 additions & 53 deletions ui/src/components/compare/ResourceComparisonCharts.tsx
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -114,7 +89,7 @@ function formatOps(ops: number): string {
return `${(ops / 1_000_000).toFixed(1)}M`
}

function buildDataPoints(tests: Record<string, TestEntry>, nameFilter?: (name: string) => boolean, suiteTests?: SuiteTest[]): ResourceDataPoint[] {
function buildDataPoints(tests: Record<string, TestEntry>, resStep: ResourceStep, nameFilter?: (name: string) => boolean, suiteTests?: SuiteTest[]): ResourceDataPoint[] {
const suiteOrder = new Map<string, number>()
if (suiteTests) {
suiteTests.forEach((t, i) => suiteOrder.set(t.name, i + 1))
Expand All @@ -130,7 +105,7 @@ function buildDataPoints(tests: Record<string, TestEntry>, 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
Expand Down Expand Up @@ -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<ResourceStep>(DEFAULT_RESOURCE_STEP)

const handleZoom = useCallback((start: number, end: number) => {
if (prevZoomRef.current.start !== start || prevZoomRef.current.end !== end) {
Expand All @@ -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<string | null>(null)
Expand Down Expand Up @@ -440,6 +416,12 @@ export function ResourceComparisonCharts({ runs, labelMode, testNameFilter, suit
)
})}
</div>
<SegmentedControl
value={resStep}
onChange={setResStep}
options={RESOURCE_STEP_OPTIONS}
ariaLabel="Resource usage step"
/>
</div>

<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
Expand Down
127 changes: 52 additions & 75 deletions ui/src/components/run-detail/ResourceUsageCharts.tsx
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -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<ResourceStep>(DEFAULT_RESOURCE_STEP)
const highlightedTestRef = useRef<string | null>(null)

const handleZoom = useCallback((start: number, end: number) => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -708,23 +677,31 @@ export function ResourceUsageCharts({ tests, suiteTests, searchQuery, statusFilt
<Cpu className="size-4 text-gray-400 dark:text-gray-500" />
Resource Usage
</h3>
{resourceCollectionMethod && (
<span className="text-xs/5 text-gray-500 dark:text-gray-400">
Collection via{' '}
<span
className="cursor-help rounded-xs bg-gray-100 px-1.5 py-0.5 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
title={
resourceCollectionMethod === 'cgroupv2'
? 'Metrics collected directly from Linux cgroup v2 filesystem (low overhead, high precision)'
: resourceCollectionMethod === 'dockerstats'
? 'Metrics collected via Docker Stats API (fallback when cgroup access is unavailable)'
: undefined
}
>
{resourceCollectionMethod}
<div className="flex items-center gap-3">
{resourceCollectionMethod && (
<span className="text-xs/5 text-gray-500 dark:text-gray-400">
Collection via{' '}
<span
className="cursor-help rounded-xs bg-gray-100 px-1.5 py-0.5 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
title={
resourceCollectionMethod === 'cgroupv2'
? 'Metrics collected directly from Linux cgroup v2 filesystem (low overhead, high precision)'
: resourceCollectionMethod === 'dockerstats'
? 'Metrics collected via Docker Stats API (fallback when cgroup access is unavailable)'
: undefined
}
>
{resourceCollectionMethod}
</span>
</span>
</span>
)}
)}
<SegmentedControl
value={resStep}
onChange={setResStep}
options={RESOURCE_STEP_OPTIONS}
ariaLabel="Resource usage step"
/>
</div>
</div>

{/* Summary Stats Row */}
Expand Down
50 changes: 50 additions & 0 deletions ui/src/components/shared/SegmentedControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import clsx from 'clsx'

interface SegmentedControlOption<T extends string> {
value: T
label: string
}

interface SegmentedControlProps<T extends string> {
value: T
onChange: (value: T) => void
options: SegmentedControlOption<T>[]
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<T extends string>({
value,
onChange,
options,
ariaLabel,
className,
}: SegmentedControlProps<T>) {
return (
<div
role="group"
aria-label={ariaLabel}
className={clsx('inline-flex rounded-sm border border-gray-300 dark:border-gray-600', className)}
>
{options.map((option, index) => (
<button
key={option.value}
type="button"
aria-pressed={value === option.value}
onClick={() => onChange(option.value)}
className={clsx(
'px-3 py-1 text-xs/5 font-medium transition-colors',
index > 0 && 'border-l border-gray-300 dark:border-gray-600',
value === option.value
? 'bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900'
: 'bg-white text-gray-700 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700',
)}
>
{option.label}
</button>
))}
</div>
)
}
Loading