From 89941d4a3d8a2551f692f8700c7eaf2824403376 Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Fri, 3 Apr 2026 12:17:47 -0500 Subject: [PATCH 1/6] refactor: extract shared CustomGpuValuePanel from CustomCosts and CustomPowers --- .../components/inference/ui/CustomCosts.tsx | 300 +---------------- .../inference/ui/CustomGpuValuePanel.tsx | 297 +++++++++++++++++ .../components/inference/ui/CustomPowers.tsx | 303 +----------------- .../ui/custom-gpu-value-panel-utils.test.ts | 73 +++++ .../ui/custom-gpu-value-panel-utils.ts | 74 +++++ 5 files changed, 451 insertions(+), 596 deletions(-) create mode 100644 packages/app/src/components/inference/ui/CustomGpuValuePanel.tsx create mode 100644 packages/app/src/components/inference/ui/custom-gpu-value-panel-utils.test.ts create mode 100644 packages/app/src/components/inference/ui/custom-gpu-value-panel-utils.ts diff --git a/packages/app/src/components/inference/ui/CustomCosts.tsx b/packages/app/src/components/inference/ui/CustomCosts.tsx index c95bbb1..b2ee978 100644 --- a/packages/app/src/components/inference/ui/CustomCosts.tsx +++ b/packages/app/src/components/inference/ui/CustomCosts.tsx @@ -1,305 +1,13 @@ 'use client'; -import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { memo } from 'react'; -import { track } from '@/lib/analytics'; - -import { Button } from '@/components/ui/button'; -import { - InputGroup, - InputGroupAddon, - InputGroupInput, - InputGroupText, -} from '@/components/ui/input-group'; -import { Skeleton } from '@/components/ui/skeleton'; - -import { useInference } from '@/components/inference/InferenceContext'; -import { GPU_SPECS } from '@/lib/constants'; - -// Memoized InputGroup component for GPU cost inputs -const GpuCostInputGroup = memo( - ({ - gpuKey, - gpuLabel, - inputValue, - error, - onChange, - }: { - gpuKey: string; - gpuLabel: string; - inputValue: string; - error: string; - onChange: (value: string) => void; - }) => { - return ( -
- - - {gpuLabel}: - - { - onChange(e.target.value); - }} - className={error ? 'text-destructive' : ''} - aria-invalid={!!error} - /> - - {error &&

{error}

} -
- ); - }, -); - -GpuCostInputGroup.displayName = 'GpuCostInputGroup'; +import CustomGpuValuePanel from '@/components/inference/ui/CustomGpuValuePanel'; const CustomCosts = memo(({ loading }: { loading: boolean }) => { - // Use the shared hardware context to ensure both charts use the same state - - const { selectedYAxisMetric, selectedPrecisions, selectedModel, selectedSequence, setUserCosts } = - useInference(); - - const [inputErrors, setInputErrors] = useState<{ [gpuKey: string]: string }>({}); - const [defaultValues, setDefaultValues] = useState<{ [gpuKey: string]: string }>({}); - const [lastCalculatedValues, setLastCalculatedValues] = useState<{ - [gpuKey: string]: string | number; - }>({}); - - // Track previous filter values to detect changes within this component - const previousFiltersRef = useRef({ - model: selectedModel, - sequence: selectedSequence, - precisions: selectedPrecisions, - yAxisMetric: selectedYAxisMetric, - }); - - // One cost input per physical GPU — derived from GPU_SPECS (deduplicated by design). - const stableGpus = React.useMemo(() => { - return Object.entries(GPU_SPECS) - .filter(([, specs]) => specs.costr > 0) - .map(([base, specs]) => ({ base, label: base.toUpperCase(), specs })); - }, []); - - // Initialize default values and auto-apply so chart renders immediately - useEffect(() => { - const defaults: { [gpuKey: string]: string } = {}; - const numericDefaults: { [gpuKey: string]: number } = {}; - - stableGpus.forEach((gpu) => { - defaults[gpu.base] = gpu.specs.costr.toString(); - numericDefaults[gpu.base] = gpu.specs.costr; - }); - - setDefaultValues(defaults); - setLastCalculatedValues(defaults); - setInputErrors({}); - setUserCosts(numericDefaults); - }, [stableGpus, setUserCosts]); - - // Reset last calculated values when filters change (model, sequence, precision, y-axis) - useEffect(() => { - const prevFilters = previousFiltersRef.current; - const filtersChanged = - prevFilters.model !== selectedModel || - prevFilters.sequence !== selectedSequence || - prevFilters.precisions.join(',') !== selectedPrecisions.join(',') || - prevFilters.yAxisMetric !== selectedYAxisMetric; - - if (filtersChanged) { - // Reset last calculated values to defaults when filters change - setLastCalculatedValues(defaultValues); - setInputErrors({}); - - // Update previous filters - previousFiltersRef.current = { - model: selectedModel, - sequence: selectedSequence, - precisions: selectedPrecisions, - yAxisMetric: selectedYAxisMetric, - }; - } - }, [selectedModel, selectedSequence, selectedPrecisions, selectedYAxisMetric, defaultValues]); - - // Validate input value - const validateInput = useCallback((value: string): string => { - if (!value.trim()) { - return ''; - } - - const numValue = parseFloat(value); - if (isNaN(numValue)) { - return 'Must be a valid number'; - } - if (numValue < 0) { - return 'Must be a positive number'; - } - - return ''; - }, []); - - // Handle input change with validation - const handleInputChange = useCallback( - (gpuKey: string, value: string) => { - const validationError = validateInput(value); - - setInputErrors((prev) => ({ - ...prev, - [gpuKey]: validationError, - })); - setLastCalculatedValues((prev) => ({ - ...prev, - [gpuKey]: value, - })); - }, - [validateInput, setLastCalculatedValues, setUserCosts], - ); - - // Handle reset button click - const handleReset = useCallback(() => { - track('inference_custom_costs_reset', { - metric: selectedYAxisMetric, - gpuCount: stableGpus.length, - }); - const defaultInputs: { [gpuKey: string]: number } = {}; - - stableGpus.forEach((gpu) => { - defaultInputs[gpu.base] = gpu.specs.costr; - }); - - setUserCosts(defaultInputs); - setLastCalculatedValues(defaultInputs); - setInputErrors({}); - - // Don't update lastCalculatedValues here - we want to keep the last calculated values - // so that when reset is clicked, we can compare the new (default) values with the last calculated values - }, [stableGpus]); - - // Handle recalculate button click - const handleRecalculate = useCallback(() => { - const hasErrors = Object.values(inputErrors).some((error) => error !== ''); - if (hasErrors) { - return; - } - track('inference_custom_costs_calculated', { - metric: selectedYAxisMetric, - gpuCount: stableGpus.length, - }); - - // Store the current values as the last calculated values before calculating - const currentValues: { [gpuKey: string]: number } = {}; - stableGpus.forEach((gpu) => { - const currentValue = lastCalculatedValues[gpu.base] ?? 0; - if (currentValue) { - currentValues[gpu.base] = parseFloat(currentValue.toString()); - } - }); - setUserCosts(currentValues); - - // costs.calculateCosts(); - }, [inputErrors, stableGpus, lastCalculatedValues]); - - // Show skeleton when hardware data is loading - // Use loading flag from useChartData to ensure consistency with parent component - if (loading || stableGpus.length === 0) { - return ( -
-
-

Custom GPU Costs

-

- Enter your own TCO (Total Cost of Ownership) values for each GPU in $/GPU/hr. These - values will be used to calculate custom cost metrics. -

-
-
-
- {Array.from({ length: 6 }).map((_, index) => { - const skeletonId = `skeleton-input-${index + 1}`; - return ( -
-
- -
-
- ); - })} -
-
- - -
-
-
- ); - } - - return ( -
-
-

Custom GPU Costs

-

- Enter your own TCO (Total Cost of Ownership) values for each GPU in $/GPU/hr. These values - will be used to calculate custom cost metrics. -

-
- -
-
- {stableGpus.map((gpu) => { - const inputValue = lastCalculatedValues[gpu.base] ?? ''; - const error = inputErrors[gpu.base]; - - return ( - { - handleInputChange(gpu.base, value); - }} - /> - ); - })} -
-
- - -
-
-
- ); + return ; }); -CustomCosts.displayName = 'UserCostInputs'; +CustomCosts.displayName = 'CustomCosts'; export default CustomCosts; diff --git a/packages/app/src/components/inference/ui/CustomGpuValuePanel.tsx b/packages/app/src/components/inference/ui/CustomGpuValuePanel.tsx new file mode 100644 index 0000000..3d35812 --- /dev/null +++ b/packages/app/src/components/inference/ui/CustomGpuValuePanel.tsx @@ -0,0 +1,297 @@ +'use client'; + +import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; + +import { useInference } from '@/components/inference/InferenceContext'; +import { + buildAppliedCustomGpuValues, + buildDefaultCustomGpuValues, + didCustomGpuPanelFiltersChange, + validateCustomGpuValueInput, + type CustomGpuPanelFilters, +} from '@/components/inference/ui/custom-gpu-value-panel-utils'; +import { Button } from '@/components/ui/button'; +import { + InputGroup, + InputGroupAddon, + InputGroupInput, + InputGroupText, +} from '@/components/ui/input-group'; +import { Skeleton } from '@/components/ui/skeleton'; +import { track } from '@/lib/analytics'; +import { GPU_SPECS } from '@/lib/constants'; + +type GpuSpec = (typeof GPU_SPECS)[keyof typeof GPU_SPECS]; +type GpuValuePanelKind = 'costs' | 'powers'; + +const PANEL_CONFIG: Record< + GpuValuePanelKind, + { + title: string; + description: string; + sectionTestId: string; + calculateTestId: string; + inputIdPrefix: string; + className: string; + skeletonClassName: string; + resetEvent: string; + calculatedEvent: string; + getDefaultValue: (specs: GpuSpec) => number; + } +> = { + costs: { + title: 'Custom GPU Costs', + description: + 'Enter your own TCO (Total Cost of Ownership) values for each GPU in $/GPU/hr. These values will be used to calculate custom cost metrics.', + sectionTestId: 'custom-costs-section', + calculateTestId: 'custom-costs-calculate', + inputIdPrefix: 'cost-input', + className: 'space-y-4 p-4 lg:p-8 border rounded-md bg-muted/10', + skeletonClassName: 'space-y-4 p-4 lg:p-8 border rounded-md bg-muted/30', + resetEvent: 'inference_custom_costs_reset', + calculatedEvent: 'inference_custom_costs_calculated', + getDefaultValue: (specs) => specs.costr, + }, + powers: { + title: 'Custom GPU Powers', + description: + 'Enter your own Token Throughput per All in Utility MW (tok/s/MW) values for each GPU. These values will be used to calculate custom power metrics.', + sectionTestId: 'custom-powers-section', + calculateTestId: 'custom-powers-calculate', + // Preserve legacy input IDs so existing Cypress selectors keep passing. + inputIdPrefix: 'cost-input', + className: 'space-y-4 p-4 lg:p-8 border rounded-md bg-muted/10 mb-6', + skeletonClassName: 'space-y-4 p-4 lg:p-8 border rounded-md bg-muted/30 mb-6', + resetEvent: 'inference_custom_powers_reset', + calculatedEvent: 'inference_custom_powers_calculated', + getDefaultValue: (specs) => specs.power, + }, +}; + +interface GpuValueInputGroupProps { + gpuKey: string; + gpuLabel: string; + inputIdPrefix: string; + inputValue: string; + error: string; + onChange: (value: string) => void; +} + +const GpuValueInputGroup = memo( + ({ gpuKey, gpuLabel, inputIdPrefix, inputValue, error, onChange }: GpuValueInputGroupProps) => { + return ( +
+ + + {gpuLabel}: + + { + onChange(e.target.value); + }} + className={error ? 'text-destructive' : ''} + aria-invalid={!!error} + /> + + {error &&

{error}

} +
+ ); + }, +); + +GpuValueInputGroup.displayName = 'GpuValueInputGroup'; + +function renderSkeleton(title: string, description: string, className: string) { + return ( +
+
+

{title}

+

{description}

+
+
+
+ {Array.from({ length: 6 }).map((_, index) => ( +
+
+ +
+
+ ))} +
+
+ + +
+
+
+ ); +} + +const CustomGpuValuePanel = memo( + ({ loading, kind }: { loading: boolean; kind: GpuValuePanelKind }) => { + const { + selectedYAxisMetric, + selectedPrecisions, + selectedModel, + selectedSequence, + setUserCosts, + setUserPowers, + } = useInference(); + + const config = PANEL_CONFIG[kind]; + const applyValues = kind === 'costs' ? setUserCosts : setUserPowers; + + const [inputErrors, setInputErrors] = useState>({}); + const [defaultValues, setDefaultValues] = useState>({}); + const [lastCalculatedValues, setLastCalculatedValues] = useState< + Record + >({}); + + const previousFiltersRef = useRef({ + model: selectedModel, + sequence: selectedSequence, + precisions: selectedPrecisions, + yAxisMetric: selectedYAxisMetric, + }); + + const stableGpus = React.useMemo(() => { + // One input per physical GPU, and GPUs with a zero default for this metric are omitted. + return Object.entries(GPU_SPECS) + .filter(([, specs]) => config.getDefaultValue(specs) > 0) + .map(([base, specs]) => ({ base, label: base.toUpperCase(), specs })); + }, [config]); + + useEffect(() => { + const { defaultValues: defaults, numericDefaults } = buildDefaultCustomGpuValues( + stableGpus, + config.getDefaultValue, + ); + + setDefaultValues(defaults); + setLastCalculatedValues(defaults); + setInputErrors({}); + applyValues(numericDefaults); + }, [applyValues, config, stableGpus]); + + useEffect(() => { + const prevFilters = previousFiltersRef.current; + const currentFilters: CustomGpuPanelFilters = { + model: selectedModel, + sequence: selectedSequence, + precisions: selectedPrecisions, + yAxisMetric: selectedYAxisMetric, + }; + const filtersChanged = didCustomGpuPanelFiltersChange(prevFilters, currentFilters); + + if (filtersChanged) { + setLastCalculatedValues(defaultValues); + setInputErrors({}); + previousFiltersRef.current = currentFilters; + } + }, [defaultValues, selectedModel, selectedPrecisions, selectedSequence, selectedYAxisMetric]); + + const handleInputChange = useCallback((gpuKey: string, value: string) => { + const validationError = validateCustomGpuValueInput(value); + + setInputErrors((prev) => ({ + ...prev, + [gpuKey]: validationError, + })); + setLastCalculatedValues((prev) => ({ + ...prev, + [gpuKey]: value, + })); + }, []); + + const handleReset = useCallback(() => { + track(config.resetEvent, { + metric: selectedYAxisMetric, + gpuCount: stableGpus.length, + }); + + const { numericDefaults } = buildDefaultCustomGpuValues(stableGpus, config.getDefaultValue); + + applyValues(numericDefaults); + setLastCalculatedValues(numericDefaults); + setInputErrors({}); + }, [applyValues, config, selectedYAxisMetric, stableGpus]); + + const handleRecalculate = useCallback(() => { + const hasErrors = Object.values(inputErrors).some((error) => error !== ''); + if (hasErrors) return; + + track(config.calculatedEvent, { + metric: selectedYAxisMetric, + gpuCount: stableGpus.length, + }); + + const currentValues = buildAppliedCustomGpuValues(stableGpus, lastCalculatedValues); + applyValues(currentValues); + }, [applyValues, config, inputErrors, lastCalculatedValues, selectedYAxisMetric, stableGpus]); + + if (loading || stableGpus.length === 0) { + return renderSkeleton(config.title, config.description, config.skeletonClassName); + } + + return ( +
+
+

{config.title}

+

{config.description}

+
+ +
+
+ {stableGpus.map((gpu) => ( + { + handleInputChange(gpu.base, value); + }} + /> + ))} +
+
+ + +
+
+
+ ); + }, +); + +CustomGpuValuePanel.displayName = 'CustomGpuValuePanel'; + +export default CustomGpuValuePanel; diff --git a/packages/app/src/components/inference/ui/CustomPowers.tsx b/packages/app/src/components/inference/ui/CustomPowers.tsx index 429900d..303a2f0 100644 --- a/packages/app/src/components/inference/ui/CustomPowers.tsx +++ b/packages/app/src/components/inference/ui/CustomPowers.tsx @@ -1,308 +1,11 @@ 'use client'; -import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { memo } from 'react'; -import { track } from '@/lib/analytics'; - -import { Button } from '@/components/ui/button'; -import { - InputGroup, - InputGroupAddon, - InputGroupInput, - InputGroupText, -} from '@/components/ui/input-group'; -import { Skeleton } from '@/components/ui/skeleton'; - -import { useInference } from '@/components/inference/InferenceContext'; -import { GPU_SPECS } from '@/lib/constants'; - -// Memoized InputGroup component for GPU cost inputs -const GpuCostInputGroup = memo( - ({ - gpuKey, - gpuLabel, - inputValue, - error, - onChange, - }: { - gpuKey: string; - gpuLabel: string; - inputValue: string; - error: string; - onChange: (value: string) => void; - }) => { - return ( -
- - - {gpuLabel}: - - { - onChange(e.target.value); - }} - className={error ? 'text-destructive' : ''} - aria-invalid={!!error} - /> - - {error &&

{error}

} -
- ); - }, -); - -GpuCostInputGroup.displayName = 'GpuCostInputGroup'; +import CustomGpuValuePanel from '@/components/inference/ui/CustomGpuValuePanel'; const CustomPowers = memo(({ loading }: { loading: boolean }) => { - // Use the shared hardware context to ensure both charts use the same state - - const { - selectedYAxisMetric, - selectedPrecisions, - selectedModel, - selectedSequence, - setUserPowers, - } = useInference(); - - const [inputErrors, setInputErrors] = useState<{ [gpuKey: string]: string }>({}); - const [defaultValues, setDefaultValues] = useState<{ [gpuKey: string]: string }>({}); - const [lastCalculatedValues, setLastCalculatedValues] = useState<{ - [gpuKey: string]: string | number; - }>({}); - - // Track previous filter values to detect changes within this component - const previousFiltersRef = useRef({ - model: selectedModel, - sequence: selectedSequence, - precisions: selectedPrecisions, - yAxisMetric: selectedYAxisMetric, - }); - - // One power input per physical GPU — derived from GPU_SPECS (deduplicated by design). - const stableGpus = React.useMemo(() => { - return Object.entries(GPU_SPECS) - .filter(([, specs]) => specs.power > 0) - .map(([base, specs]) => ({ base, label: base.toUpperCase(), specs })); - }, []); - - // Initialize default values and auto-apply so chart renders immediately - useEffect(() => { - const defaults: { [gpuKey: string]: string } = {}; - const numericDefaults: { [gpuKey: string]: number } = {}; - - stableGpus.forEach((gpu) => { - defaults[gpu.base] = gpu.specs.power.toString(); - numericDefaults[gpu.base] = gpu.specs.power; - }); - - setDefaultValues(defaults); - setLastCalculatedValues(defaults); - setInputErrors({}); - setUserPowers(numericDefaults); - }, [stableGpus, setUserPowers]); - - // Reset last calculated values when filters change (model, sequence, precision, y-axis) - useEffect(() => { - const prevFilters = previousFiltersRef.current; - const filtersChanged = - prevFilters.model !== selectedModel || - prevFilters.sequence !== selectedSequence || - prevFilters.precisions.join(',') !== selectedPrecisions.join(',') || - prevFilters.yAxisMetric !== selectedYAxisMetric; - - if (filtersChanged) { - // Reset last calculated values to defaults when filters change - setLastCalculatedValues(defaultValues); - setInputErrors({}); - - // Update previous filters - previousFiltersRef.current = { - model: selectedModel, - sequence: selectedSequence, - precisions: selectedPrecisions, - yAxisMetric: selectedYAxisMetric, - }; - } - }, [selectedModel, selectedSequence, selectedPrecisions, selectedYAxisMetric, defaultValues]); - - // Validate input value - const validateInput = useCallback((value: string): string => { - if (!value.trim()) { - return ''; - } - - const numValue = parseFloat(value); - if (isNaN(numValue)) { - return 'Must be a valid number'; - } - if (numValue < 0) { - return 'Must be a positive number'; - } - - return ''; - }, []); - - // Handle input change with validation - const handleInputChange = useCallback( - (gpuKey: string, value: string) => { - const validationError = validateInput(value); - - setInputErrors((prev) => ({ - ...prev, - [gpuKey]: validationError, - })); - setLastCalculatedValues((prev) => ({ - ...prev, - [gpuKey]: value, - })); - }, - [validateInput, setLastCalculatedValues, setUserPowers], - ); - - // Handle reset button click - const handleReset = useCallback(() => { - track('inference_custom_powers_reset', { - metric: selectedYAxisMetric, - gpuCount: stableGpus.length, - }); - const defaultInputs: { [gpuKey: string]: number } = {}; - - stableGpus.forEach((gpu) => { - defaultInputs[gpu.base] = gpu.specs.power; - }); - - setUserPowers(defaultInputs); - setLastCalculatedValues(defaultInputs); - setInputErrors({}); - - // Don't update lastCalculatedValues here - we want to keep the last calculated values - // so that when reset is clicked, we can compare the new (default) values with the last calculated values - }, [stableGpus]); - - // Handle recalculate button click - const handleRecalculate = useCallback(() => { - const hasErrors = Object.values(inputErrors).some((error) => error !== ''); - if (hasErrors) { - return; - } - track('inference_custom_powers_calculated', { - metric: selectedYAxisMetric, - gpuCount: stableGpus.length, - }); - - // Store the current values as the last calculated values before calculating - const currentValues: { [gpuKey: string]: number } = {}; - stableGpus.forEach((gpu) => { - const currentValue = lastCalculatedValues[gpu.base] ?? 0; - if (currentValue) { - currentValues[gpu.base] = parseFloat(currentValue.toString()); - } - }); - setUserPowers(currentValues); - - // costs.calculateCosts(); - }, [inputErrors, stableGpus, lastCalculatedValues]); - - // Show skeleton when hardware data is loading - // Use loading flag from useChartData to ensure consistency with parent component - if (loading || stableGpus.length === 0) { - return ( -
-
-

Custom GPU Costs

-

- Enter your own Token Throughput per All in Utility MW (tok/s/MW) values for each GPU. - These values will be used to calculate custom power metrics. -

-
-
-
- {Array.from({ length: 6 }).map((_, index) => { - const skeletonId = `skeleton-input-${index + 1}`; - return ( -
-
- -
-
- ); - })} -
-
- - -
-
-
- ); - } - - return ( -
-
-

Custom GPU Powers

-

- Enter your own Token Throughput per All in Utility MW (tok/s/MW) values for each GPU. - These values will be used to calculate custom power metrics. -

-
- -
-
- {stableGpus.map((gpu) => { - const inputValue = lastCalculatedValues[gpu.base] ?? ''; - const error = inputErrors[gpu.base]; - - return ( - { - handleInputChange(gpu.base, value); - }} - /> - ); - })} -
-
- - -
-
-
- ); + return ; }); CustomPowers.displayName = 'CustomPowers'; diff --git a/packages/app/src/components/inference/ui/custom-gpu-value-panel-utils.test.ts b/packages/app/src/components/inference/ui/custom-gpu-value-panel-utils.test.ts new file mode 100644 index 0000000..0623b38 --- /dev/null +++ b/packages/app/src/components/inference/ui/custom-gpu-value-panel-utils.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildAppliedCustomGpuValues, + buildDefaultCustomGpuValues, + didCustomGpuPanelFiltersChange, + validateCustomGpuValueInput, + type CustomGpuPanelFilters, +} from '@/components/inference/ui/custom-gpu-value-panel-utils'; + +const baseFilters: CustomGpuPanelFilters = { + model: 'llama-3-1-8b', + sequence: '128-128', + precisions: ['fp8'], + yAxisMetric: 'tok/sec', +}; + +describe('validateCustomGpuValueInput', () => { + it('accepts blank and zero values, and rejects invalid or negative values', () => { + expect(validateCustomGpuValueInput('')).toBe(''); + expect(validateCustomGpuValueInput(' ')).toBe(''); + expect(validateCustomGpuValueInput('0')).toBe(''); + expect(validateCustomGpuValueInput('wat')).toBe('Must be a valid number'); + expect(validateCustomGpuValueInput('-1')).toBe('Must be a non-negative number'); + }); +}); + +describe('didCustomGpuPanelFiltersChange', () => { + it('returns false when filter values are unchanged', () => { + expect(didCustomGpuPanelFiltersChange(baseFilters, { ...baseFilters })).toBe(false); + }); + + it('returns true when a filter dimension changes', () => { + expect( + didCustomGpuPanelFiltersChange(baseFilters, { + ...baseFilters, + sequence: '2048-2048', + }), + ).toBe(true); + }); +}); + +describe('buildDefaultCustomGpuValues', () => { + it('builds numeric and string defaults from the selected GPU metric', () => { + expect( + buildDefaultCustomGpuValues( + [ + { base: 'h100', specs: { power: 0.73 } }, + { base: 'b200', specs: { power: 1.2 } }, + ], + (specs) => specs.power, + ), + ).toEqual({ + defaultValues: { h100: '0.73', b200: '1.2' }, + numericDefaults: { h100: 0.73, b200: 1.2 }, + }); + }); +}); + +describe('buildAppliedCustomGpuValues', () => { + it('keeps zero values and omits blank entries only', () => { + expect( + buildAppliedCustomGpuValues([{ base: 'h100' }, { base: 'b200' }, { base: 'gb200' }], { + h100: '', + b200: '0', + gb200: 2.5, + }), + ).toEqual({ + b200: 0, + gb200: 2.5, + }); + }); +}); diff --git a/packages/app/src/components/inference/ui/custom-gpu-value-panel-utils.ts b/packages/app/src/components/inference/ui/custom-gpu-value-panel-utils.ts new file mode 100644 index 0000000..580ea92 --- /dev/null +++ b/packages/app/src/components/inference/ui/custom-gpu-value-panel-utils.ts @@ -0,0 +1,74 @@ +export interface CustomGpuPanelFilters { + model: string; + sequence: string; + precisions: string[]; + yAxisMetric: string; +} + +export interface CustomGpuPanelEntry { + base: string; + specs: TSpecs; +} + +export function validateCustomGpuValueInput(value: string): string { + if (!value.trim()) return ''; + + const numValue = parseFloat(value); + if (isNaN(numValue)) return 'Must be a valid number'; + if (numValue < 0) return 'Must be a non-negative number'; + + return ''; +} + +export function didCustomGpuPanelFiltersChange( + prevFilters: CustomGpuPanelFilters, + currentFilters: CustomGpuPanelFilters, +): boolean { + return ( + prevFilters.model !== currentFilters.model || + prevFilters.sequence !== currentFilters.sequence || + prevFilters.precisions.join(',') !== currentFilters.precisions.join(',') || + prevFilters.yAxisMetric !== currentFilters.yAxisMetric + ); +} + +export function buildDefaultCustomGpuValues( + stableGpus: CustomGpuPanelEntry[], + getDefaultValue: (specs: TSpecs) => number, +): { + defaultValues: Record; + numericDefaults: Record; +} { + const defaultValues: Record = {}; + const numericDefaults: Record = {}; + + stableGpus.forEach((gpu) => { + const defaultValue = getDefaultValue(gpu.specs); + defaultValues[gpu.base] = defaultValue.toString(); + numericDefaults[gpu.base] = defaultValue; + }); + + return { defaultValues, numericDefaults }; +} + +export function buildAppliedCustomGpuValues( + stableGpus: Array<{ base: string }>, + lastCalculatedValues: Record, +): Record { + const currentValues: Record = {}; + + stableGpus.forEach((gpu) => { + const currentValue = lastCalculatedValues[gpu.base]; + + if (typeof currentValue === 'string') { + if (!currentValue.trim()) return; + currentValues[gpu.base] = parseFloat(currentValue); + return; + } + + if (currentValue == null) return; + currentValues[gpu.base] = currentValue; + }); + + return currentValues; +} From dcb1300ab5b1b86bd08303ae6e77e94c7f9bae84 Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:34:33 -0500 Subject: [PATCH 2/6] style: use Card components in CustomGpuValuePanel --- .../inference/ui/CustomGpuValuePanel.tsx | 43 ++++++++----------- 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/packages/app/src/components/inference/ui/CustomGpuValuePanel.tsx b/packages/app/src/components/inference/ui/CustomGpuValuePanel.tsx index 3d35812..8383d66 100644 --- a/packages/app/src/components/inference/ui/CustomGpuValuePanel.tsx +++ b/packages/app/src/components/inference/ui/CustomGpuValuePanel.tsx @@ -11,6 +11,7 @@ import { type CustomGpuPanelFilters, } from '@/components/inference/ui/custom-gpu-value-panel-utils'; import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { InputGroup, InputGroupAddon, @@ -32,8 +33,6 @@ const PANEL_CONFIG: Record< sectionTestId: string; calculateTestId: string; inputIdPrefix: string; - className: string; - skeletonClassName: string; resetEvent: string; calculatedEvent: string; getDefaultValue: (specs: GpuSpec) => number; @@ -46,8 +45,6 @@ const PANEL_CONFIG: Record< sectionTestId: 'custom-costs-section', calculateTestId: 'custom-costs-calculate', inputIdPrefix: 'cost-input', - className: 'space-y-4 p-4 lg:p-8 border rounded-md bg-muted/10', - skeletonClassName: 'space-y-4 p-4 lg:p-8 border rounded-md bg-muted/30', resetEvent: 'inference_custom_costs_reset', calculatedEvent: 'inference_custom_costs_calculated', getDefaultValue: (specs) => specs.costr, @@ -60,8 +57,6 @@ const PANEL_CONFIG: Record< calculateTestId: 'custom-powers-calculate', // Preserve legacy input IDs so existing Cypress selectors keep passing. inputIdPrefix: 'cost-input', - className: 'space-y-4 p-4 lg:p-8 border rounded-md bg-muted/10 mb-6', - skeletonClassName: 'space-y-4 p-4 lg:p-8 border rounded-md bg-muted/30 mb-6', resetEvent: 'inference_custom_powers_reset', calculatedEvent: 'inference_custom_powers_calculated', getDefaultValue: (specs) => specs.power, @@ -107,14 +102,14 @@ const GpuValueInputGroup = memo( GpuValueInputGroup.displayName = 'GpuValueInputGroup'; -function renderSkeleton(title: string, description: string, className: string) { +function renderSkeleton(title: string, description: string) { return ( -
-
-

{title}

-

{description}

-
-
+ + + {title} + {description} + +
{Array.from({ length: 6 }).map((_, index) => (
@@ -128,8 +123,8 @@ function renderSkeleton(title: string, description: string, className: string) {
-
-
+ + ); } @@ -236,17 +231,17 @@ const CustomGpuValuePanel = memo( }, [applyValues, config, inputErrors, lastCalculatedValues, selectedYAxisMetric, stableGpus]); if (loading || stableGpus.length === 0) { - return renderSkeleton(config.title, config.description, config.skeletonClassName); + return renderSkeleton(config.title, config.description); } return ( -
-
-

{config.title}

-

{config.description}

-
+ + + {config.title} + {config.description} + -
+
{stableGpus.map((gpu) => (
-
-
+ + ); }, ); From 07cb844c129bc05586281a5d65b9cea49e59f91d Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:35:37 -0500 Subject: [PATCH 3/6] style: add spacing between card header and content --- .../app/src/components/inference/ui/CustomGpuValuePanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/inference/ui/CustomGpuValuePanel.tsx b/packages/app/src/components/inference/ui/CustomGpuValuePanel.tsx index 8383d66..e1b1271 100644 --- a/packages/app/src/components/inference/ui/CustomGpuValuePanel.tsx +++ b/packages/app/src/components/inference/ui/CustomGpuValuePanel.tsx @@ -104,7 +104,7 @@ GpuValueInputGroup.displayName = 'GpuValueInputGroup'; function renderSkeleton(title: string, description: string) { return ( - + {title} {description} From 0004b353cada8facce8c576f9cc396b3dff1bc98 Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:38:38 -0500 Subject: [PATCH 4/6] style: use plain Card layout matching chart cards --- .../inference/ui/CustomGpuValuePanel.tsx | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/app/src/components/inference/ui/CustomGpuValuePanel.tsx b/packages/app/src/components/inference/ui/CustomGpuValuePanel.tsx index e1b1271..83f2de4 100644 --- a/packages/app/src/components/inference/ui/CustomGpuValuePanel.tsx +++ b/packages/app/src/components/inference/ui/CustomGpuValuePanel.tsx @@ -11,7 +11,7 @@ import { type CustomGpuPanelFilters, } from '@/components/inference/ui/custom-gpu-value-panel-utils'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Card } from '@/components/ui/card'; import { InputGroup, InputGroupAddon, @@ -105,11 +105,11 @@ GpuValueInputGroup.displayName = 'GpuValueInputGroup'; function renderSkeleton(title: string, description: string) { return ( - - {title} - {description} - - +
+

{title}

+

{description}

+
+
{Array.from({ length: 6 }).map((_, index) => (
@@ -123,7 +123,7 @@ function renderSkeleton(title: string, description: string) {
- +
); } @@ -235,13 +235,13 @@ const CustomGpuValuePanel = memo( } return ( - - - {config.title} - {config.description} - + +
+

{config.title}

+

{config.description}

+
- +
{stableGpus.map((gpu) => (
- +
); }, From ff4b58b658fde7a72af1a31d3996a807d289beaa Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:39:33 -0500 Subject: [PATCH 5/6] style: match Card layout pattern used across other tabs --- .../inference/ui/CustomGpuValuePanel.tsx | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/app/src/components/inference/ui/CustomGpuValuePanel.tsx b/packages/app/src/components/inference/ui/CustomGpuValuePanel.tsx index 83f2de4..2fea371 100644 --- a/packages/app/src/components/inference/ui/CustomGpuValuePanel.tsx +++ b/packages/app/src/components/inference/ui/CustomGpuValuePanel.tsx @@ -104,11 +104,9 @@ GpuValueInputGroup.displayName = 'GpuValueInputGroup'; function renderSkeleton(title: string, description: string) { return ( - -
-

{title}

-

{description}

-
+ +

{title}

+

{description}

{Array.from({ length: 6 }).map((_, index) => ( @@ -235,11 +233,9 @@ const CustomGpuValuePanel = memo( } return ( - -
-

{config.title}

-

{config.description}

-
+ +

{config.title}

+

{config.description}

From 250f5fffd10fd40a22b7f7d9b536004fb1468628 Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:43:21 -0500 Subject: [PATCH 6/6] fix: remove dead loading conditional in Calculate button --- .../inference/ui/CustomGpuValuePanel.tsx | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/packages/app/src/components/inference/ui/CustomGpuValuePanel.tsx b/packages/app/src/components/inference/ui/CustomGpuValuePanel.tsx index 2fea371..ff03550 100644 --- a/packages/app/src/components/inference/ui/CustomGpuValuePanel.tsx +++ b/packages/app/src/components/inference/ui/CustomGpuValuePanel.tsx @@ -262,19 +262,8 @@ const CustomGpuValuePanel = memo( > Reset -