diff --git a/frontend/locales/en/strings.json b/frontend/locales/en/strings.json index 17d268ec..2e972246 100644 --- a/frontend/locales/en/strings.json +++ b/frontend/locales/en/strings.json @@ -276,6 +276,7 @@ "queues": { "validation": { "instanceTypeUnique": "Instance types must be unique within a Queue.", + "instanceTypeMissing": "Select at least once instance type.", "selectSubnet": "You must select a Subnet.", "setRootVolumeSize": "You must set a RootVolume size.", "rootVolumeMinimum": "You must use an integer >= 35GB for Root Volume Size.", @@ -300,6 +301,11 @@ "dismissAriaLabel": "Close alert" } }, + "allocationStrategy": { + "title": "Allocation strategy", + "lowestPrice": "Lowest price", + "capacityOptimized": "Capacity optimized" + }, "schedulableMemory": { "name": "Schedulable Memory (MiB)", "description": "Amount of memory in MiB to be made available to jobs on the compute nodes of the compute resource", diff --git a/frontend/src/feature-flags/__tests__/FeatureFlagsProvider.test.ts b/frontend/src/feature-flags/__tests__/FeatureFlagsProvider.test.ts index 68d7eb40..dcad3fff 100644 --- a/frontend/src/feature-flags/__tests__/FeatureFlagsProvider.test.ts +++ b/frontend/src/feature-flags/__tests__/FeatureFlagsProvider.test.ts @@ -52,6 +52,7 @@ describe('given a feature flags provider and a list of rules', () => { 'memory_based_scheduling', 'slurm_queue_update_strategy', 'slurm_accounting', + 'queues_multiple_instance_types', ]) }) }) diff --git a/frontend/src/feature-flags/featureFlagsProvider.ts b/frontend/src/feature-flags/featureFlagsProvider.ts index 50487a95..e41f709e 100644 --- a/frontend/src/feature-flags/featureFlagsProvider.ts +++ b/frontend/src/feature-flags/featureFlagsProvider.ts @@ -20,7 +20,7 @@ const versionToFeaturesMap: Record = { 'multiuser_cluster', 'slurm_queue_update_strategy', ], - '3.3.0': ['slurm_accounting'], + '3.3.0': ['slurm_accounting', 'queues_multiple_instance_types'], } function composeFlagsListByVersion(currentVersion: string): AvailableFeature[] { diff --git a/frontend/src/feature-flags/types.ts b/frontend/src/feature-flags/types.ts index 064626d6..acf5d221 100644 --- a/frontend/src/feature-flags/types.ts +++ b/frontend/src/feature-flags/types.ts @@ -16,3 +16,4 @@ export type AvailableFeature = | 'memory_based_scheduling' | 'slurm_accounting' | 'slurm_queue_update_strategy' + | 'queues_multiple_instance_types' diff --git a/frontend/src/feature-flags/useFeatureFlag.ts b/frontend/src/feature-flags/useFeatureFlag.ts index af68bfd7..959178f6 100644 --- a/frontend/src/feature-flags/useFeatureFlag.ts +++ b/frontend/src/feature-flags/useFeatureFlag.ts @@ -14,6 +14,13 @@ import {AvailableFeature} from './types' export function useFeatureFlag(feature: AvailableFeature): boolean { const version = useState(['app', 'version', 'full']) + return isFeatureEnabled(version, feature) +} + +export function isFeatureEnabled( + version: string, + feature: AvailableFeature, +): boolean { const features = new Set(featureFlagsProvider(version)) return features.has(feature) diff --git a/frontend/src/old-pages/Configure/Cluster.tsx b/frontend/src/old-pages/Configure/Cluster.tsx index 991c5c81..1983bef1 100644 --- a/frontend/src/old-pages/Configure/Cluster.tsx +++ b/frontend/src/old-pages/Configure/Cluster.tsx @@ -34,6 +34,9 @@ import {LoadAwsConfig} from '../../model' // Components import {LabeledIcon, CustomAMISettings} from './Components' import {useFeatureFlag} from '../../feature-flags/useFeatureFlag' +import {useComputeResourceAdapter} from './Queues/Queues' +import {createComputeResource as singleCreate} from './Queues/SingleInstanceComputeResource' +import {createComputeResource as multiCreate} from './Queues/MultiInstanceComputeResource' // Constants const errorsPath = ['app', 'wizard', 'errors', 'cluster'] @@ -342,6 +345,9 @@ function Cluster() { let defaultRegion = useState(['aws', 'region']) || '' const region = useState(['app', 'selectedRegion']) || defaultRegion const isMultiuserClusterActive = useFeatureFlag('multiuser_cluster') + const isMultipleInstanceTypesActive = useFeatureFlag( + 'queues_multiple_instance_types', + ) React.useEffect(() => { const configPath = ['app', 'wizard', 'config'] @@ -366,13 +372,13 @@ function Cluster() { [ { Name: 'queue0', + AllocationStrategy: isMultipleInstanceTypesActive + ? 'lowest-price' + : undefined, ComputeResources: [ - { - Name: 'queue0-t2-micro', - MinCount: 0, - MaxCount: 4, - InstanceType: 't2.micro', - }, + isMultipleInstanceTypesActive + ? multiCreate(0, 0) + : singleCreate(0, 0), ], }, ], diff --git a/frontend/src/old-pages/Configure/Components.tsx b/frontend/src/old-pages/Configure/Components.tsx index ef1e83e1..dd1085a4 100644 --- a/frontend/src/old-pages/Configure/Components.tsx +++ b/frontend/src/old-pages/Configure/Components.tsx @@ -176,21 +176,12 @@ function SubnetSelect({value, onChange, disabled}: any) { ) } -function InstanceSelect({path, selectId, callback, disabled}: any) { - const value = useState(path) || '' +// instance type, description, image +type InstanceGroup = [string, string, string][] +export function useInstanceGroups(): Record { const instanceTypes = useState(['aws', 'instanceTypes']) || [] - let groupNames = [ - 'General Purpose', - 'Compute', - 'HPC', - 'High Memory', - 'Graviton', - 'Mixed', - 'GPU', - ] - let groups: {[key: string]: [string, string, string][]} = {} for (let instance of instanceTypes) { @@ -227,8 +218,12 @@ function InstanceSelect({path, selectId, callback, disabled}: any) { groups[group].push([instance.InstanceType, desc, img]) } + return groups +} - groupNames = groupNames.filter(name => name in groups) +function InstanceSelect({path, selectId, callback, disabled}: any) { + const value = useState(path) || '' + const instanceGroups = useInstanceGroups() // @ts-expect-error TS(7031) FIXME: Binding element 'value' implicitly has an 'any' ty... Remove this comment to see the full error message const instanceToOption = ([value, label, icon]) => { @@ -255,10 +250,10 @@ function InstanceSelect({path, selectId, callback, disabled}: any) { ariaLabel="Instance Selector" placeholder="Instance Type" empty="No matches found" - options={groupNames.map(groupName => { + options={Object.keys(instanceGroups).map(groupName => { return { label: groupName, - options: groups[groupName].map(instanceToOption), + options: instanceGroups[groupName].map(instanceToOption), } })} /> diff --git a/frontend/src/old-pages/Configure/Configure.tsx b/frontend/src/old-pages/Configure/Configure.tsx index 89c10fa0..f74bca6b 100644 --- a/frontend/src/old-pages/Configure/Configure.tsx +++ b/frontend/src/old-pages/Configure/Configure.tsx @@ -38,7 +38,7 @@ import {Cluster, clusterValidate} from './Cluster' import {HeadNode, headNodeValidate} from './HeadNode' import {MultiUser, multiUserValidate} from './MultiUser' import {Storage, storageValidate} from './Storage' -import {Queues, queuesValidate} from './Queues' +import {Queues, queuesValidate} from './Queues/Queues' import { Create, createValidate, diff --git a/frontend/src/old-pages/Configure/Queues/MultiInstanceComputeResource.tsx b/frontend/src/old-pages/Configure/Queues/MultiInstanceComputeResource.tsx new file mode 100644 index 00000000..61a22f25 --- /dev/null +++ b/frontend/src/old-pages/Configure/Queues/MultiInstanceComputeResource.tsx @@ -0,0 +1,295 @@ +import { + Box, + Button, + ColumnLayout, + FormField, + Input, + Multiselect, + MultiselectProps, + Toggle, +} from '@awsui/components-react' +import {NonCancelableEventHandler} from '@awsui/components-react/internal/events' +import {useCallback, useMemo} from 'react' +import {Trans, useTranslation} from 'react-i18next' +import {clearState, setState, useState} from '../../../store' +import {HelpTextInput, useInstanceGroups} from '../Components' +import { + ComputeResourceInstance, + MultiInstanceComputeResource, + QueueValidationErrors, +} from './queues.types' + +const queuesPath = ['app', 'wizard', 'config', 'Scheduling', 'SlurmQueues'] +const queuesErrorsPath = ['app', 'wizard', 'errors', 'queues'] +const defaultInstanceType = 'c5n.large' + +export function allInstancesSupportEFA( + instances: ComputeResourceInstance[], + efaInstances: Set, +): boolean { + if (!instances || !instances.length) { + return false + } + return instances.every(instance => efaInstances.has(instance.InstanceType)) +} + +export function ComputeResource({index, queueIndex, computeResource}: any) { + const parentPath = useMemo(() => [...queuesPath, queueIndex], [queueIndex]) + const computeResources: MultiInstanceComputeResource[] = useState([ + ...parentPath, + 'ComputeResources', + ]) + const path = useMemo( + () => [...parentPath, 'ComputeResources', index], + [index, parentPath], + ) + const errorsPath = [...queuesErrorsPath, queueIndex, 'computeResource', index] + const typeError = useState([...errorsPath, 'type']) + + const instanceTypePath = useMemo(() => [...path, 'Instances'], [path]) + const instances: ComputeResourceInstance[] = useState(instanceTypePath) || [] + + const memoryBasedSchedulingEnabledPath = [ + 'app', + 'wizard', + 'config', + 'Scheduling', + 'SlurmSettings', + 'EnableMemoryBasedScheduling', + ] + const enableMemoryBasedScheduling = useState(memoryBasedSchedulingEnabledPath) + + const disableHTPath = [...path, 'DisableSimultaneousMultithreading'] + const disableHT = useState(disableHTPath) + + const efaPath = [...path, 'Efa'] + + const efaInstances = new Set(useState(['aws', 'efa_instance_types'])) + const enableEFAPath = [...path, 'Efa', 'Enabled'] + const enableEFA = useState(enableEFAPath) || false + + const enablePlacementGroupPath = [ + ...parentPath, + 'Networking', + 'PlacementGroup', + 'Enabled', + ] + + const minCount = useState([...path, 'MinCount']) + const maxCount = useState([...path, 'MaxCount']) + + const instanceGroups = useInstanceGroups() + const instanceOptions = useMemo( + () => + Object.keys(instanceGroups).map(groupName => { + return { + label: groupName, + options: instanceGroups[groupName].map(([value, label, icon]) => ({ + label: value, + iconUrl: icon, + description: label, + value: value, + })), + } + }), + [instanceGroups], + ) + + const {t} = useTranslation() + + const remove = () => { + setState( + [...parentPath, 'ComputeResources'], + [ + ...computeResources.slice(0, index), + ...computeResources.slice(index + 1), + ], + ) + } + + const setMinCount = (staticCount: any) => { + const dynamicCount = maxCount - minCount + if (staticCount > 0) + setState([...path, 'MinCount'], !isNaN(staticCount) ? staticCount : 0) + else clearState([...path, 'MinCount']) + setState( + [...path, 'MaxCount'], + (!isNaN(staticCount) ? staticCount : 0) + + (!isNaN(dynamicCount) ? dynamicCount : 0), + ) + } + + const setMaxCount = (dynamicCount: any) => { + const staticCount = minCount + setState( + [...path, 'MaxCount'], + (!isNaN(staticCount) ? staticCount : 0) + + (!isNaN(dynamicCount) ? dynamicCount : 0), + ) + } + + const setSchedulableMemory = ( + schedulableMemoryPath: string[], + schedulableMemory: string, + ) => { + let schedulableMemoryNumber = parseInt(schedulableMemory) + if (enableMemoryBasedScheduling && !isNaN(schedulableMemoryNumber)) { + setState(schedulableMemoryPath, schedulableMemoryNumber) + } else { + clearState(schedulableMemoryPath) + } + } + + const setDisableHT = (disable: any) => { + if (disable) setState(disableHTPath, disable) + else clearState(disableHTPath) + } + + const setEnableEFA = useCallback( + (enable: any) => { + if (enable) { + setState(enableEFAPath, enable) + setState(enablePlacementGroupPath, enable) + } else { + clearState(efaPath) + clearState(enablePlacementGroupPath) + } + }, + [efaPath, enablePlacementGroupPath, enableEFAPath], + ) + + const setInstances: NonCancelableEventHandler = + useCallback( + ({detail}) => { + const selectedInstances = (detail.selectedOptions.map(option => ({ + InstanceType: option.value, + })) || []) as ComputeResourceInstance[] + setState(instanceTypePath, selectedInstances) + if (!allInstancesSupportEFA(selectedInstances, efaInstances)) { + setEnableEFA(false) + } + }, + [efaInstances, instanceTypePath, setEnableEFA], + ) + + return ( +
+
+ + {index > 0 && } + + +
+ + setMinCount(parseInt(detail.value))} + /> + + + setMaxCount(parseInt(detail.value))} + /> + +
+ + ({ + value: instance.InstanceType, + label: instance.InstanceType, + }))} + tokenLimit={3} + onChange={setInstances} + options={instanceOptions} + /> + + {enableMemoryBasedScheduling && ( + + setSchedulableMemory( + [...path, 'SchedulableMemory'], + detail.value, + ) + } + description={t('wizard.queues.schedulableMemory.description')} + placeholder={t('wizard.queues.schedulableMemory.placeholder')} + help={t('wizard.queues.schedulableMemory.help')} + type="number" + /> + )} +
+
+ { + setDisableHT(!disableHT) + }} + > + + + { + setEnableEFA(!enableEFA) + }} + > + + +
+
+
+ ) +} + +export function createComputeResource( + queueIndex: number, + crIndex: number, +): MultiInstanceComputeResource { + return { + Name: `queue${queueIndex}-compute-resource-${crIndex}`, + Instances: [ + { + InstanceType: defaultInstanceType, + }, + ], + MinCount: 0, + MaxCount: 4, + } +} + +export function updateComputeResourcesNames( + computeResources: MultiInstanceComputeResource[], + newQueueName: string, +): MultiInstanceComputeResource[] { + return computeResources.map((cr, i) => ({ + ...cr, + Name: `${newQueueName}-compute-resource-${i}`, + })) +} + +export function validateComputeResources( + computeResources: MultiInstanceComputeResource[], +): [boolean, QueueValidationErrors] { + let errors = computeResources.reduce((acc, cr, i) => { + if (!cr.Instances || !cr.Instances.length) { + acc[i] = 'instance_types_empty' + } + return acc + }, {}) + return [Object.keys(errors).length === 0, errors] +} diff --git a/frontend/src/old-pages/Configure/Queues/Queues.test.tsx b/frontend/src/old-pages/Configure/Queues/Queues.test.tsx new file mode 100644 index 00000000..79a1435b --- /dev/null +++ b/frontend/src/old-pages/Configure/Queues/Queues.test.tsx @@ -0,0 +1,93 @@ +import { + allInstancesSupportEFA, + createComputeResource, + validateComputeResources, +} from './MultiInstanceComputeResource' + +describe('Given a list of instances', () => { + const subject = allInstancesSupportEFA + const efaInstances = new Set(['t2.micro', 't2.medium']) + + describe("when it's empty", () => { + it('should deactivate EFA', () => { + expect(subject([], efaInstances)).toBe(false) + }) + }) + + describe('when every instance supports EFA', () => { + it('should enable EFA', () => { + expect( + subject( + [{InstanceType: 't2.micro'}, {InstanceType: 't2.medium'}], + efaInstances, + ), + ).toBe(true) + }) + }) + + describe('when not every instance supports EFA', () => { + it('should deactivate EFA', () => { + expect( + subject( + [{InstanceType: 't2.micro'}, {InstanceType: 't2.large'}], + efaInstances, + ), + ).toBe(false) + }) + }) +}) + +describe('Given a list of queues', () => { + const subject = createComputeResource + describe('when creating a new compute resource', () => { + it('should create it with a default instance type', () => { + expect(subject(0, 0).Instances).toHaveLength(1) + }) + }) +}) + +describe('Given a list of compute resources', () => { + const subject = validateComputeResources + describe('when all of them have at least one instance type', () => { + it('should not return an error', () => { + const [valid] = subject([ + { + Name: 'test1', + MinCount: 0, + MaxCount: 2, + Instances: [{InstanceType: 't2.micro'}, {InstanceType: 't2.medium'}], + }, + { + Name: 'test2', + MinCount: 0, + MaxCount: 2, + Instances: [{InstanceType: 't2.micro'}], + }, + ]) + + expect(valid).toBe(true) + }) + }) + + describe('when one of these compute resources has no instance types', () => { + it('should return a validation error', () => { + const [valid, errors] = subject([ + { + Name: 'test1', + MinCount: 0, + MaxCount: 2, + Instances: [{InstanceType: 't2.micro'}, {InstanceType: 't2.medium'}], + }, + { + Name: 'test2', + MinCount: 0, + MaxCount: 2, + Instances: [], + }, + ]) + + expect(valid).toBe(false) + expect(errors[1]).toBe('instance_types_empty') + }) + }) +}) diff --git a/frontend/src/old-pages/Configure/Queues.tsx b/frontend/src/old-pages/Configure/Queues/Queues.tsx similarity index 55% rename from frontend/src/old-pages/Configure/Queues.tsx rename to frontend/src/old-pages/Configure/Queues/Queues.tsx index 1515adc7..7137beed 100644 --- a/frontend/src/old-pages/Configure/Queues.tsx +++ b/frontend/src/old-pages/Configure/Queues/Queues.tsx @@ -11,7 +11,7 @@ // limitations under the License. import * as React from 'react' import i18next from 'i18next' -import {findFirst} from '../../util' +import {findFirst} from '../../../util' // UI Elements import { @@ -29,29 +29,31 @@ import { } from '@awsui/components-react' // State -import {setState, getState, useState, clearState} from '../../store' +import {setState, getState, useState, clearState} from '../../../store' // Components import { ActionsEditor, CustomAMISettings, - InstanceSelect, LabeledIcon, RootVolume, SubnetSelect, SecurityGroups, IamPoliciesEditor, - HelpTextInput, -} from './Components' -import HelpTooltip from '../../components/HelpTooltip' +} from '../Components' import {Trans, useTranslation} from 'react-i18next' import {SlurmMemorySettings} from './SlurmMemorySettings' -import {useFeatureFlag} from '../../feature-flags/useFeatureFlag' +import { + isFeatureEnabled, + useFeatureFlag, +} from '../../../feature-flags/useFeatureFlag' +import * as SingleInstanceCR from './SingleInstanceComputeResource' +import * as MultiInstanceCR from './MultiInstanceComputeResource' +import {AllocationStrategy, ComputeResource} from './queues.types' // Constants const queuesPath = ['app', 'wizard', 'config', 'Scheduling', 'SlurmQueues'] const queuesErrorsPath = ['app', 'wizard', 'errors', 'queues'] -const defaultInstanceType = 'c5n.large' // Helper Functions // @ts-expect-error TS(7031) FIXME: Binding element 'value' implicitly has an 'any' ty... Remove this comment to see the full error message @@ -179,19 +181,32 @@ function queueValidate(queueIndex: any) { setState([...errorsPath, 'subnet'], null) } - let seenInstances = new Set() - for (let i = 0; i < computeResources.length; i++) { - let computeResource = computeResources[i] - if (seenInstances.has(computeResource.InstanceType)) { - setState( - [...errorsPath, 'computeResource', i, 'type'], - i18next.t('wizard.queues.validation.instanceTypeUnique'), - ) - valid = false - } else { - seenInstances.add(computeResource.InstanceType) - setState([...errorsPath, 'computeResource', i, 'type'], null) - } + const version = getState(['app', 'version', 'full']) + const isMultiInstanceTypesActive = isFeatureEnabled( + version, + 'queues_multiple_instance_types', + ) + const {validateComputeResources} = !isMultiInstanceTypesActive + ? SingleInstanceCR + : MultiInstanceCR + const [computeResourcesValid, computeResourcesErrors] = + validateComputeResources(computeResources) + if (!computeResourcesValid) { + valid = false + computeResources.forEach((_: ComputeResource, i: number) => { + const error = computeResourcesErrors[i] + if (error) { + let message: string + if (error === 'instance_type_unique') { + message = i18next.t('wizard.queues.validation.instanceTypeUnique') + } else { + message = i18next.t('wizard.queues.validation.instanceTypeMissing') + } + setState([...errorsPath, 'computeResource', i, 'type'], message) + } else { + setState([...errorsPath, 'computeResource', i, 'type'], null) + } + }) } return valid @@ -213,254 +228,12 @@ function queuesValidate() { return valid } -function ComputeResource({index, queueIndex, computeResource}: any) { - const parentPath = [...queuesPath, queueIndex] - const queue = useState(parentPath) - const computeResources = useState([...parentPath, 'ComputeResources']) - const path = [...parentPath, 'ComputeResources', index] - const errorsPath = [...queuesErrorsPath, queueIndex, 'computeResource', index] - const typeError = useState([...errorsPath, 'type']) - - const tInstances = new Set(['t2.micro', 't2.medium']) - const gravitonInstances = new Set([]) - - const instanceTypePath = [...path, 'InstanceType'] - const instanceType: string = useState(instanceTypePath) - const memoryBasedSchedulingEnabledPath = [ - 'app', - 'wizard', - 'config', - 'Scheduling', - 'SlurmSettings', - 'EnableMemoryBasedScheduling', - ] - const enableMemoryBasedScheduling = useState(memoryBasedSchedulingEnabledPath) - - const disableHTPath = [...path, 'DisableSimultaneousMultithreading'] - const disableHT = useState(disableHTPath) - - const efaPath = [...path, 'Efa'] - - const efaInstances = new Set(useState(['aws', 'efa_instance_types'])) - const enableEFAPath = [...path, 'Efa', 'Enabled'] - const enableEFA = useState(enableEFAPath) || false - - const enablePlacementGroupPath = [ - ...parentPath, - 'Networking', - 'PlacementGroup', - 'Enabled', - ] - - const enableGPUDirectPath = [...path, 'Efa', 'GdrSupport'] || false - const enableGPUDirect = useState(enableGPUDirectPath) - - const instanceSupportsGdr = instanceType === 'p4d.24xlarge' - - const minCount = useState([...path, 'MinCount']) - const maxCount = useState([...path, 'MaxCount']) - - const {t} = useTranslation() - - const remove = () => { - setState( - [...parentPath, 'ComputeResources'], - [ - ...computeResources.slice(0, index), - ...computeResources.slice(index + 1), - ], - ) - } - - const setMinCount = (staticCount: any) => { - const dynamicCount = maxCount - minCount - if (staticCount > 0) - setState([...path, 'MinCount'], !isNaN(staticCount) ? staticCount : 0) - else clearState([...path, 'MinCount']) - setState( - [...path, 'MaxCount'], - (!isNaN(staticCount) ? staticCount : 0) + - (!isNaN(dynamicCount) ? dynamicCount : 0), - ) - } - - const setMaxCount = (dynamicCount: any) => { - const staticCount = minCount - setState( - [...path, 'MaxCount'], - (!isNaN(staticCount) ? staticCount : 0) + - (!isNaN(dynamicCount) ? dynamicCount : 0), - ) - } - - const setSchedulableMemory = ( - schedulableMemoryPath: string[], - schedulableMemory: string, - ) => { - let schedulableMemoryNumber = parseInt(schedulableMemory) - if (enableMemoryBasedScheduling && !isNaN(schedulableMemoryNumber)) { - setState(schedulableMemoryPath, schedulableMemoryNumber) - } else { - clearState(schedulableMemoryPath) - } - } - - const setDisableHT = (disable: any) => { - if (disable) setState(disableHTPath, disable) - else clearState(disableHTPath) - } - - const setEnableEFA = (enable: any) => { - if (enable) { - setState(enableEFAPath, enable) - setState(enablePlacementGroupPath, enable) - } else { - clearState(efaPath) - clearState(enablePlacementGroupPath) - } - } - - const setEnableGPUDirect = (enable: any) => { - if (enable) setState(enableGPUDirectPath, enable) - else clearState(enableGPUDirectPath) - } - - const setInstanceType = (instanceType: any) => { - // setting the instance type on the queue happens in the component - // this updates the name which is derived from the instance type - setState( - [...path, 'Name'], - `${queue.Name}-${instanceType.replace('.', '')}`, - ) - - if (!getState(enableEFAPath) && efaInstances.has(instanceType)) - setEnableEFA(true) - else if (getState(enableEFAPath) && !efaInstances.has(instanceType)) - setEnableEFA(false) - } - - React.useEffect(() => { - if (!instanceType) - setState( - [...queuesPath, queueIndex, 'ComputeResources', index, 'InstanceType'], - defaultInstanceType, - ) - }, [queueIndex, index, instanceType]) - - return ( -
-
- - {index > 0 && } - - -
- - setMinCount(parseInt(detail.value))} - /> - - - setMaxCount(parseInt(detail.value))} - /> - -
- - - - {enableMemoryBasedScheduling && ( - - setSchedulableMemory( - [...path, 'SchedulableMemory'], - detail.value, - ) - } - description={t('wizard.queues.schedulableMemory.description')} - placeholder={t('wizard.queues.schedulableMemory.placeholder')} - help={t('wizard.queues.schedulableMemory.help')} - type="number" - /> - )} -
-
- { - setDisableHT(!disableHT) - }} - > - - - { - setEnableEFA(!enableEFA) - }} - > - - -
- { - setEnableGPUDirect(!enableGPUDirect) - }} - > - - - - - - - -
-
-
-
- ) -} - function ComputeResources({queue, index}: any) { + const {ViewComponent} = useComputeResourceAdapter() return ( {queue.ComputeResources.map((computeResource: any, i: any) => ( - { + const {t} = useTranslation() + const options = React.useMemo( + () => [ + { + label: t('wizard.queues.allocationStrategy.lowestPrice'), + value: 'lowest-price', + }, + { + label: t('wizard.queues.allocationStrategy.capacityOptimized'), + value: 'capacity-optimized', + }, + ], + [t], + ) + return options +} + function Queue({index}: any) { const {t} = useTranslation() const queues = useState(queuesPath) const [editingName, setEditingName] = React.useState(false) + const computeResourceAdapter = useComputeResourceAdapter() const queue = useState([...queuesPath, index]) const enablePlacementGroupPath = [ ...queuesPath, @@ -486,9 +278,17 @@ function Queue({index}: any) { ] const enablePlacementGroup = useState(enablePlacementGroupPath) + const allocationStrategyOptions = useAllocationStrategyOptions() + const errorsPath = [...queuesErrorsPath, index] const subnetError = useState([...errorsPath, 'subnet']) + const allocationStrategy: AllocationStrategy = useState([ + ...queuesPath, + index, + 'AllocationStrategy', + ]) + const capacityTypes: [string, string, string][] = [ ['ONDEMAND', 'On-Demand', '/img/od.svg'], ['SPOT', 'Spot', '/img/spot.svg'], @@ -506,16 +306,12 @@ function Queue({index}: any) { ) } const addComputeResource = () => { + const existingCRs = queue.ComputeResources || [] setState([...queuesPath, index], { ...queue, ComputeResources: [ - ...(queue.ComputeResources || []), - { - Name: `queue${index}-${defaultInstanceType.replace('.', '')}`, - InstanceType: defaultInstanceType, - MinCount: 0, - MaxCount: 4, - }, + ...existingCRs, + computeResourceAdapter.createComputeResource(index, existingCRs.length), ], }) } @@ -530,14 +326,24 @@ function Queue({index}: any) { index, 'ComputeResources', ]) - for (let i = 0; i < computeResources.length; i++) { - const cr = computeResources[i] - const crName = `${newName}-${cr.InstanceType.replace('.', '')}` - setState([...queuesPath, index, 'ComputeResources', i, 'Name'], crName) - } + const updatedCRs = computeResourceAdapter.updateComputeResourcesNames( + computeResources, + newName, + ) setState([...queuesPath, index, 'Name'], newName) + setState([...queuesPath, index, 'ComputeResources'], updatedCRs) } + const setAllocationStrategy = React.useCallback( + ({detail}) => { + setState( + [...queuesPath, index, 'AllocationStrategy'], + detail.selectedOption.value, + ) + }, + [index], + ) + return (
@@ -610,6 +416,21 @@ function Queue({index}: any) { options={capacityTypes.map(itemToIconOption)} /> + {queue.AllocationStrategy ? ( + + setMinCount(parseInt(detail.value))} + /> + + + setMaxCount(parseInt(detail.value))} + /> + +
+ + + + {enableMemoryBasedScheduling && ( + + setSchedulableMemory( + [...path, 'SchedulableMemory'], + detail.value, + ) + } + description={t('wizard.queues.schedulableMemory.description')} + placeholder={t('wizard.queues.schedulableMemory.placeholder')} + help={t('wizard.queues.schedulableMemory.help')} + type="number" + /> + )} + +
+ { + setDisableHT(!disableHT) + }} + > + + + { + setEnableEFA(!enableEFA) + }} + > + + +
+ { + setEnableGPUDirect(!enableGPUDirect) + }} + > + + + + + + + +
+
+
+ + ) +} + +export function createComputeResource(queueIndex: number, crIndex: number) { + return { + Name: `queue${queueIndex}-${defaultInstanceType.replace('.', '')}`, + InstanceType: defaultInstanceType, + MinCount: 0, + MaxCount: 4, + } +} + +export function updateComputeResourcesNames( + computeResources: SingleInstanceComputeResource[], + newQueueName: string, +) { + return computeResources.map(cr => ({ + ...cr, + Name: `${newQueueName}-${cr.InstanceType.replace('.', '')}`, + })) +} + +export function validateComputeResources( + computeResources: SingleInstanceComputeResource[], +): [boolean, QueueValidationErrors] { + let seenInstances = new Set() + let errors: QueueValidationErrors = {} + let valid = true + for (let i = 0; i < computeResources.length; i++) { + let computeResource = computeResources[i] + if (seenInstances.has(computeResource.InstanceType)) { + errors[i] = 'instance_type_unique' + valid = false + } else { + seenInstances.add(computeResource.InstanceType) + } + } + return [valid, errors] +} diff --git a/frontend/src/old-pages/Configure/SlurmMemorySettings.tsx b/frontend/src/old-pages/Configure/Queues/SlurmMemorySettings.tsx similarity index 98% rename from frontend/src/old-pages/Configure/SlurmMemorySettings.tsx rename to frontend/src/old-pages/Configure/Queues/SlurmMemorySettings.tsx index c0da22d5..77258218 100644 --- a/frontend/src/old-pages/Configure/SlurmMemorySettings.tsx +++ b/frontend/src/old-pages/Configure/Queues/SlurmMemorySettings.tsx @@ -20,7 +20,7 @@ import { Popover, Link, } from '@awsui/components-react' -import {setState, getState, useState, clearState} from '../../store' +import {setState, getState, useState, clearState} from '../../../store' function SlurmMemorySettings() { const {t} = useTranslation() diff --git a/frontend/src/old-pages/Configure/Queues/queues.types.ts b/frontend/src/old-pages/Configure/Queues/queues.types.ts new file mode 100644 index 00000000..2f47f293 --- /dev/null +++ b/frontend/src/old-pages/Configure/Queues/queues.types.ts @@ -0,0 +1,22 @@ +export type QueueValidationErrors = Record< + number, + 'instance_type_unique' | 'instance_types_empty' +> + +export type ComputeResource = { + Name: string + MinCount: number + MaxCount: number +} + +export type SingleInstanceComputeResource = ComputeResource & { + InstanceType: string +} + +export type AllocationStrategy = 'lowest-price' | 'capacity-optimized' + +export type ComputeResourceInstance = {InstanceType: string} + +export type MultiInstanceComputeResource = ComputeResource & { + Instances: ComputeResourceInstance[] +}