diff --git a/api/PclusterApiHandler.py b/api/PclusterApiHandler.py index c29714ab2..79e9441af 100644 --- a/api/PclusterApiHandler.py +++ b/api/PclusterApiHandler.py @@ -32,13 +32,14 @@ USER_POOL_ID = os.getenv("USER_POOL_ID") AUTH_PATH = os.getenv("AUTH_PATH") API_BASE_URL = os.getenv("API_BASE_URL") -API_VERSION = os.getenv("API_VERSION", "3.1.0") +API_VERSION = sorted(set(os.getenv("API_VERSION", "3.1.0").strip().split(",")), key=lambda x: [-int(n) for n in x.split('.')]) +# Default version must be highest version so that it can be used for read operations due to backwards compatibility +DEFAULT_API_VERSION = API_VERSION[0] API_USER_ROLE = os.getenv("API_USER_ROLE") OIDC_PROVIDER = os.getenv("OIDC_PROVIDER") CLIENT_ID = os.getenv("CLIENT_ID") CLIENT_SECRET = os.getenv("CLIENT_SECRET") SECRET_ID = os.getenv("SECRET_ID") -SITE_URL = os.getenv("SITE_URL", API_BASE_URL) SCOPES_LIST = os.getenv("SCOPES_LIST") REGION = os.getenv("AWS_DEFAULT_REGION") TOKEN_URL = os.getenv("TOKEN_URL", f"{AUTH_PATH}/oauth2/token") @@ -48,6 +49,7 @@ AUDIENCE = os.getenv("AUDIENCE") USER_ROLES_CLAIM = os.getenv("USER_ROLES_CLAIM", "cognito:groups") SSM_LOG_GROUP_NAME = os.getenv("SSM_LOG_GROUP_NAME") +ARG_VERSION="version" try: if (not USER_POOL_ID or USER_POOL_ID == "") and SECRET_ID: @@ -63,6 +65,19 @@ JWKS_URL = os.getenv("JWKS_URL", f"https://cognito-idp.{REGION}.amazonaws.com/{USER_POOL_ID}/" ".well-known/jwks.json") +def create_url_map(url_list): + url_map = {} + if url_list: + for url in url_list.split(","): + if url: + pair=url.split("=") + url_map[pair[0]] = pair[1] + return url_map + +API_BASE_URL_MAPPING = create_url_map(API_BASE_URL) +SITE_URL = os.getenv("SITE_URL", API_BASE_URL_MAPPING.get(DEFAULT_API_VERSION)) + + def jwt_decode(token, audience=None, access_token=None): return jwt.decode( @@ -165,7 +180,7 @@ def authenticate(groups): if (not groups): return abort(403) - + jwt_roles = set(decoded.get(USER_ROLES_CLAIM, [])) groups_granted = groups.intersection(jwt_roles) if len(groups_granted) == 0: @@ -191,7 +206,7 @@ def get_scopes_list(): def get_redirect_uri(): return f"{SITE_URL}/login" - + # Local Endpoints @@ -233,9 +248,9 @@ def ec2_action(): def get_cluster_config_text(cluster_name, region=None): url = f"/v3/clusters/{cluster_name}" if region: - info_resp = sigv4_request("GET", API_BASE_URL, url, params={"region": region}) + info_resp = sigv4_request("GET", get_base_url(request), url, params={"region": region}) else: - info_resp = sigv4_request("GET", API_BASE_URL, url) + info_resp = sigv4_request("GET", get_base_url(request), url) if info_resp.status_code != 200: abort(info_resp.status_code) @@ -365,7 +380,7 @@ def sacct(): user, f"sacct {sacct_args} --json " + "| jq -c .jobs[0:120]\\|\\map\\({name,user,partition,state,job_id,exit_code\\}\\)", - ) + ) if type(accounting) is tuple: return accounting else: @@ -484,7 +499,7 @@ def get_dcv_session(): def get_custom_image_config(): - image_info = sigv4_request("GET", API_BASE_URL, f"/v3/images/custom/{request.args.get('image_id')}").json() + image_info = sigv4_request("GET", get_base_url(request), f"/v3/images/custom/{request.args.get('image_id')}").json() configuration = requests.get(image_info["imageConfiguration"]["url"]) return configuration.text @@ -596,9 +611,9 @@ def _get_identity_from_token(decoded, claims): identity["username"] = decoded["username"] for claim in claims: - if claim in decoded: - identity["attributes"][claim] = decoded[claim] - + if claim in decoded: + identity["attributes"][claim] = decoded[claim] + return identity def get_identity(): @@ -735,6 +750,12 @@ def _get_params(_request): params.pop("path") return params +def get_base_url(request): + version = request.args.get(ARG_VERSION) + if version and str(version) in API_VERSION: + return API_BASE_URL_MAPPING[str(version)] + return API_BASE_URL_MAPPING[DEFAULT_API_VERSION] + pc = Blueprint('pc', __name__) @@ -742,7 +763,7 @@ def _get_params(_request): @authenticated({'admin'}) @validated(params=PCProxyArgs) def pc_proxy_get(): - response = sigv4_request(request.method, API_BASE_URL, request.args.get("path"), _get_params(request)) + response = sigv4_request(request.method, get_base_url(request), request.args.get("path"), _get_params(request)) return response.json(), response.status_code @pc.route('/', methods=['POST','PUT','PATCH','DELETE'], strict_slashes=False) @@ -756,5 +777,5 @@ def pc_proxy(): except: pass - response = sigv4_request(request.method, API_BASE_URL, request.args.get("path"), _get_params(request), body=body) + response = sigv4_request(request.method, get_base_url(request), request.args.get("path"), _get_params(request), body=body) return response.json(), response.status_code diff --git a/api/tests/test_pcluster_api_handler.py b/api/tests/test_pcluster_api_handler.py index 472e24b85..812b2c835 100644 --- a/api/tests/test_pcluster_api_handler.py +++ b/api/tests/test_pcluster_api_handler.py @@ -1,5 +1,10 @@ from unittest import mock -from api.PclusterApiHandler import login +from api.PclusterApiHandler import login, get_base_url, create_url_map + +class MockRequest: + cookies = {'int_value': 100} + args = {'version': '3.12.0'} + json = {'username': 'user@email.com'} @mock.patch("api.PclusterApiHandler.requests.post") @@ -27,3 +32,14 @@ def test_login_with_no_access_token_returns_401(mocker, app): login() mock_abort.assert_called_once_with(401) + +def test_get_base_url(monkeypatch): + monkeypatch.setattr('api.PclusterApiHandler.API_VERSION', ['3.12.0', '3.11.0']) + monkeypatch.setattr('api.PclusterApiHandler.API_BASE_URL', '3.12.0=https://example.com,3.11.0=https://example1.com,') + monkeypatch.setattr('api.PclusterApiHandler.API_BASE_URL_MAPPING', {'3.12.0': 'https://example.com', '3.11.0': 'https://example1.com'}) + + assert 'https://example.com' == get_base_url(MockRequest()) + +def test_create_url_map(): + assert {'3.12.0': 'https://example.com', '3.11.0': 'https://example1.com'} == create_url_map('3.12.0=https://example.com,3.11.0=https://example1.com,') + diff --git a/frontend/locales/en/strings.json b/frontend/locales/en/strings.json index 855dd9e54..f9a1250f8 100644 --- a/frontend/locales/en/strings.json +++ b/frontend/locales/en/strings.json @@ -101,7 +101,7 @@ "ariaLabel": "Info" }, "cluster": { - "editAlert": "This cluster cannot be edited as it was created using a different version of AWS ParallelCluster.", + "editAlert": "This cluster cannot be edited as it was created using an incompatible version of AWS ParallelCluster.", "tabs": { "details": "Details", "instances": "Instances", @@ -493,6 +493,18 @@ "collapsedStepsLabel": "Step {{stepNumber}} of {{stepsCount}}", "steps": "Steps" }, + "version": { + "label": "Cluster Version", + "title": "Version", + "placeholder": "Cluster version", + "description": "Select the AWS ParallelCluster version to use for this cluster.", + "help": { + "main": "Choose the version of AWS ParallelCluster to use for creating and managing your cluster." + }, + "validation": { + "versionSelect": "You must select a version." + } + }, "cluster": { "title": "Cluster", "description": "Configure the settings that apply to all cluster resources.", @@ -1407,6 +1419,8 @@ }, "dialogs": { "buildImage": { + "versionLabel": "Version", + "versionPlaceholder": "Select version", "closeAriaLabel": "Close", "title": "Image configuration", "cancel": "Cancel", @@ -1430,6 +1444,14 @@ "href": "https://docs.aws.amazon.com/parallelcluster/latest/ug/support-policy.html" } }, + "actions": { + "versionSelect": { + "selectedAriaLabel": "Selected version", + "versionText": "Version {{version}}", + "placeholder": "Select a version" + }, + "refresh": "Refresh" + }, "list": { "columns": { "id": "ID", diff --git a/frontend/src/__tests__/CreateCluster.test.ts b/frontend/src/__tests__/CreateCluster.test.ts index 34f5ef6a8..ef73b2bcf 100644 --- a/frontend/src/__tests__/CreateCluster.test.ts +++ b/frontend/src/__tests__/CreateCluster.test.ts @@ -8,6 +8,7 @@ const mockRequest = executeRequest as jest.Mock describe('given a CreateCluster command and a cluster configuration', () => { const clusterName = 'any-name' + const clusterVersion = 'some-version' const clusterConfiguration = 'Imds:\n ImdsSupport: v2.0' const mockRegion = 'some-region' const mockSelectedRegion = 'some-region' @@ -37,11 +38,12 @@ describe('given a CreateCluster command and a cluster configuration', () => { clusterConfiguration, mockRegion, mockSelectedRegion, + clusterVersion, ) expect(mockRequest).toHaveBeenCalledTimes(1) expect(mockRequest).toHaveBeenCalledWith( 'post', - 'api?path=/v3/clusters®ion=some-region', + 'api?path=/v3/clusters®ion=some-region&version=some-version', expectedBody, expect.any(Object), expect.any(Object), @@ -55,6 +57,7 @@ describe('given a CreateCluster command and a cluster configuration', () => { clusterConfiguration, mockRegion, mockSelectedRegion, + clusterVersion, false, mockSuccessCallback, ) @@ -71,12 +74,13 @@ describe('given a CreateCluster command and a cluster configuration', () => { clusterConfiguration, mockRegion, mockSelectedRegion, + clusterVersion, mockDryRun, ) expect(mockRequest).toHaveBeenCalledTimes(1) expect(mockRequest).toHaveBeenCalledWith( 'post', - 'api?path=/v3/clusters&dryrun=true®ion=some-region', + 'api?path=/v3/clusters&dryrun=true®ion=some-region&version=some-version', expect.any(Object), expect.any(Object), expect.any(Object), @@ -106,6 +110,7 @@ describe('given a CreateCluster command and a cluster configuration', () => { clusterConfiguration, mockRegion, mockSelectedRegion, + clusterVersion, false, undefined, mockErrorCallback, @@ -128,6 +133,7 @@ describe('given a CreateCluster command and a cluster configuration', () => { 'Imds:\n ImdsSupport: v2.0', mockRegion, mockSelectedRegion, + clusterVersion, ) expect(mockRequest).toHaveBeenCalledWith( @@ -154,6 +160,7 @@ describe('given a CreateCluster command and a cluster configuration', () => { 'Imds:\n ImdsSupport: v2.0\nTags:\n - Key: foo\n Value: bar', mockRegion, mockSelectedRegion, + clusterVersion, ) expect(mockRequest).toHaveBeenCalledWith( @@ -180,6 +187,7 @@ describe('given a CreateCluster command and a cluster configuration', () => { "Imds:\n ImdsSupport: v2.0\nTags:\n - Key: parallelcluster-ui\n Value: 'true'", mockRegion, mockSelectedRegion, + clusterVersion, ) expect(mockRequest).not.toHaveBeenCalledWith( diff --git a/frontend/src/__tests__/GetVersion.test.ts b/frontend/src/__tests__/GetVersion.test.ts index a7f946932..a3db708e6 100644 --- a/frontend/src/__tests__/GetVersion.test.ts +++ b/frontend/src/__tests__/GetVersion.test.ts @@ -30,7 +30,7 @@ describe('given a GetVersion command', () => { describe('when the PC version can be retrieved successfully', () => { beforeEach(() => { const mockResponse = { - version: '3.5.0', + version: ['3.5.0', '3.6.0'], } mockGet.mockResolvedValueOnce({data: mockResponse}) @@ -39,13 +39,13 @@ describe('given a GetVersion command', () => { it('should return the PC version', async () => { const data = await GetVersion() - expect(data).toEqual({full: '3.5.0'}) + expect(data).toEqual({full: ['3.5.0', '3.6.0']}) }) it('should store the PC version', async () => { await GetVersion() - expect(setState).toHaveBeenCalledWith(['app', 'version'], {full: '3.5.0'}) + expect(setState).toHaveBeenCalledWith(['app', 'version'], {full: ['3.5.0', '3.6.0']}) }) }) diff --git a/frontend/src/__tests__/ListClusters.test.ts b/frontend/src/__tests__/ListClusters.test.ts index 50219c5e4..d53a48ede 100644 --- a/frontend/src/__tests__/ListClusters.test.ts +++ b/frontend/src/__tests__/ListClusters.test.ts @@ -14,7 +14,7 @@ const mockCluster1: ClusterInfoSummary = { const mockCluster2: ClusterInfoSummary = { clusterName: 'test-cluster-2', clusterStatus: ClusterStatus.CreateComplete, - version: '3.8.0', + version: '3.9.0', cloudformationStackArn: 'arn', region: 'region', cloudformationStackStatus: CloudFormationStackStatus.CreateComplete, diff --git a/frontend/src/components/__tests__/useLoadingState.test.tsx b/frontend/src/components/__tests__/useLoadingState.test.tsx index 43cc04d45..a433f1d17 100644 --- a/frontend/src/components/__tests__/useLoadingState.test.tsx +++ b/frontend/src/components/__tests__/useLoadingState.test.tsx @@ -76,7 +76,7 @@ describe('given a hook to load all the data necessary for the app to boot', () = someKey: 'some-value', }, app: { - version: {full: '3.5.0'}, + version: {full: ['3.5.0', '3.7.0']}, appConfig: { someKey: 'some-value', }, @@ -109,7 +109,7 @@ describe('given a hook to load all the data necessary for the app to boot', () = mockStore.getState.mockReturnValue({ identity: null, app: { - version: {full: '3.5.0'}, + wizard: {version: '3.5.0'}, appConfig: { someKey: 'some-value', }, @@ -131,7 +131,9 @@ describe('given a hook to load all the data necessary for the app to boot', () = someKey: 'some-value', }, app: { - version: {full: '3.5.0'}, + wizard: { + version: '3.5.0', + }, appConfig: null, }, }) diff --git a/frontend/src/feature-flags/useFeatureFlag.ts b/frontend/src/feature-flags/useFeatureFlag.ts index a70335c12..85410d730 100644 --- a/frontend/src/feature-flags/useFeatureFlag.ts +++ b/frontend/src/feature-flags/useFeatureFlag.ts @@ -13,7 +13,7 @@ import {featureFlagsProvider} from './featureFlagsProvider' import {AvailableFeature} from './types' export function useFeatureFlag(feature: AvailableFeature): boolean { - const version = useState(['app', 'version', 'full']) + const version = useState(['app', 'wizard', 'version']) const region = useState(['aws', 'region']) return isFeatureEnabled(version, region, feature) } diff --git a/frontend/src/model.tsx b/frontend/src/model.tsx index b7959d5cf..1f7a2521e 100644 --- a/frontend/src/model.tsx +++ b/frontend/src/model.tsx @@ -103,6 +103,7 @@ function CreateCluster( clusterConfig: string, region: string, selectedRegion: string, + version: string, dryrun = false, successCallback?: Callback, errorCallback?: Callback, @@ -110,6 +111,7 @@ function CreateCluster( var url = 'api?path=/v3/clusters' url += dryrun ? '&dryrun=true' : '' url += region ? `®ion=${region}` : '' + url += version ? `&version=${version}` : '' var body = { clusterName: clusterName, clusterConfiguration: mapAndApplyTags(clusterConfig), @@ -159,12 +161,14 @@ function UpdateCluster( clusterConfig: any, dryrun = false, forceUpdate: any, + version: any, successCallback?: Callback, errorCallback?: Callback, ) { var url = `api?path=/v3/clusters/${clusterName}` url += dryrun ? '&dryrun=true' : '' url += forceUpdate ? '&forceUpdate=true' : '' + url += version ? `&version=${version}` : '' var body = {clusterConfiguration: clusterConfig} request('put', url, body) .then((response: any) => { @@ -444,8 +448,9 @@ function GetCustomImageConfiguration(imageId: any, callback?: Callback) { }) } -async function BuildImage(imageId: string, imageConfig: string) { +async function BuildImage(imageId: string, imageConfig: string, version: string) { var url = 'api?path=/v3/images/custom' + url += version ? `&version=${version}` : '' var body = {imageId: imageId, imageConfiguration: imageConfig} const {data} = await request('post', url, body) notify(`Successfully queued build for ${imageId}.`, 'success') @@ -455,8 +460,8 @@ async function BuildImage(imageId: string, imageConfig: string) { return data } -async function ListOfficialImages(region?: string) { - const url = `api?path=/v3/images/official${region ? `®ion=${region}` : ''}` +async function ListOfficialImages(region?: string, version?: string) { + const url = `api?path=/v3/images/official${region ? `®ion=${region}` : ''}${version ? `&version=${version}` : ''}` try { const {data} = await request('get', url) return data?.images || [] diff --git a/frontend/src/old-pages/Clusters/Actions.tsx b/frontend/src/old-pages/Clusters/Actions.tsx index 66765a1f1..831091130 100644 --- a/frontend/src/old-pages/Clusters/Actions.tsx +++ b/frontend/src/old-pages/Clusters/Actions.tsx @@ -9,7 +9,7 @@ // OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions and // limitations under the License. import {ClusterStatus} from '../../types/clusters' -import React from 'react' +import React, {useMemo} from 'react' import {useNavigate} from 'react-router-dom' import {setState, useState, ssmPolicy, consoleDomain} from '../../store' @@ -45,6 +45,9 @@ export default function Actions() { const apiVersion = useState(['app', 'version', 'full']) const clusterVersion = useState([...clusterPath, 'version']) + const versionSupported = useMemo(() => { + return new Set(apiVersion).has(clusterVersion); + }, [apiVersion, clusterVersion]); const fleetStatus = useState([...clusterPath, 'computeFleetStatus']) const clusterStatus = useState([...clusterPath, 'clusterStatus']) @@ -75,7 +78,7 @@ export default function Actions() { clusterStatus === ClusterStatus.DeleteInProgress || clusterStatus === ClusterStatus.UpdateInProgress || clusterStatus === ClusterStatus.CreateFailed || - clusterVersion !== apiVersion + !versionSupported const isStartFleetDisabled = fleetStatus !== 'STOPPED' const isStopFleetDisabled = fleetStatus !== 'RUNNING' const isDeleteDisabled = @@ -93,12 +96,13 @@ export default function Actions() { const editConfiguration = React.useCallback(() => { setState(['app', 'wizard', 'clusterName'], clusterName) - setState(['app', 'wizard', 'page'], 'cluster') + setState(['app', 'wizard', 'page'], 'version') setState(['app', 'wizard', 'editing'], true) + setState(['app', 'wizard', 'version'], clusterVersion) navigate('/configure') loadTemplateFromCluster(clusterName) - }, [clusterName, navigate]) + }, [clusterName, navigate, clusterVersion]) const deleteCluster = React.useCallback(() => { console.log(`Deleting: ${clusterName}`) diff --git a/frontend/src/old-pages/Clusters/Costs/__tests__/EnableCostMonitoringButton.test.tsx b/frontend/src/old-pages/Clusters/Costs/__tests__/EnableCostMonitoringButton.test.tsx index 2d2486405..8bb07801e 100644 --- a/frontend/src/old-pages/Clusters/Costs/__tests__/EnableCostMonitoringButton.test.tsx +++ b/frontend/src/old-pages/Clusters/Costs/__tests__/EnableCostMonitoringButton.test.tsx @@ -59,7 +59,7 @@ describe('given a component to activate cost monitoring for the account', () => beforeEach(() => { mockStore.getState.mockReturnValue({ app: { - version: {full: '3.2.0'}, + wizard: {version: '3.2.0'}, }, aws: { region: 'us-west-1', @@ -92,7 +92,7 @@ describe('given a component to activate cost monitoring for the account', () => beforeEach(() => { mockStore.getState.mockReturnValue({ app: { - version: {full: '3.2.0'}, + wizard: {version: '3.2.0'}, }, aws: { region: 'us-west-1', @@ -121,7 +121,7 @@ describe('given a component to activate cost monitoring for the account', () => beforeEach(() => { mockStore.getState.mockReturnValue({ app: { - version: {full: '3.1.5'}, + wizard: {version: '3.1.5'}, }, aws: { region: 'us-west-1', diff --git a/frontend/src/old-pages/Clusters/Costs/__tests__/useCostMonitoringStatus.test.tsx b/frontend/src/old-pages/Clusters/Costs/__tests__/useCostMonitoringStatus.test.tsx index 1d6f461b2..89b39e5f7 100644 --- a/frontend/src/old-pages/Clusters/Costs/__tests__/useCostMonitoringStatus.test.tsx +++ b/frontend/src/old-pages/Clusters/Costs/__tests__/useCostMonitoringStatus.test.tsx @@ -15,6 +15,7 @@ import {useCostMonitoringStatus} from '../costs.queries' import {mock} from 'jest-mock-extended' import {Store} from '@reduxjs/toolkit' import {Provider} from 'react-redux' +import {GetCostMonitoringStatus} from "../../../../model"; const mockQueryClient = new QueryClient({ defaultOptions: {queries: {retry: false}}, @@ -28,6 +29,15 @@ const wrapper: React.FC> = ({children}) => ( const mockGetCostMonitoringStatus = jest.fn() +const mockUseState = jest.fn() +const mockSetState = jest.fn() + +jest.mock('../../../../store', () => ({ + ...(jest.requireActual('../../../../store') as any), + setState: (...args: unknown[]) => mockSetState(...args), + useState: (...args: unknown[]) => mockUseState(...args), +})) + jest.mock('../../../../model', () => { const originalModule = jest.requireActual('../../../../model') @@ -48,12 +58,13 @@ describe('given a hook to get the cost monitoring status', () => { beforeEach(() => { mockStore.getState.mockReturnValue({ app: { - version: {full: '3.2.0'}, + version: {full: ['3.2.0', '3.1.5']}, }, aws: { region: 'us-west-1', }, }) + mockUseState.mockReturnValue('3.2.0') }) it('should request the cost monitoring status', async () => { @@ -65,15 +76,9 @@ describe('given a hook to get the cost monitoring status', () => { describe('when PC version is less than 3.2.0', () => { beforeEach(() => { - mockStore.getState.mockReturnValue({ - app: { - version: {full: '3.1.5'}, - }, - aws: { - region: 'us-west-1', - }, - }) + mockUseState.mockReturnValue('3.1.5') }) + it('should not request the cost monitoring status', async () => { renderHook(() => useCostMonitoringStatus(), {wrapper}) @@ -85,7 +90,7 @@ describe('given a hook to get the cost monitoring status', () => { beforeEach(() => { mockStore.getState.mockReturnValue({ app: { - version: {full: '3.2.0'}, + wizard: {version: '3.2.0'}, }, aws: { region: 'us-gov-west-1', diff --git a/frontend/src/old-pages/Clusters/Details.tsx b/frontend/src/old-pages/Clusters/Details.tsx index a94faafe8..5e28663f3 100644 --- a/frontend/src/old-pages/Clusters/Details.tsx +++ b/frontend/src/old-pages/Clusters/Details.tsx @@ -56,7 +56,7 @@ export default function ClusterTabs() { return cluster ? ( <> - {cluster.version !== apiVersion ? ( + {!apiVersion.includes(cluster.version) ? ( {t('cluster.editAlert')} ) : null} { }, ], }, - app: {version: {full: '3.5.0'}}, + app: {version: {full: ['3.5.0']}}, }) screen = render( diff --git a/frontend/src/old-pages/Clusters/FromClusterModal/__tests__/useClustersToCopyFrom.test.tsx b/frontend/src/old-pages/Clusters/FromClusterModal/__tests__/useClustersToCopyFrom.test.tsx index fd5b46dc8..900b212a9 100644 --- a/frontend/src/old-pages/Clusters/FromClusterModal/__tests__/useClustersToCopyFrom.test.tsx +++ b/frontend/src/old-pages/Clusters/FromClusterModal/__tests__/useClustersToCopyFrom.test.tsx @@ -45,7 +45,7 @@ describe('given a hook to list the clusters that can be copied from', () => { mockStore.getState.mockReturnValue({ app: { version: { - full: '3.5.0', + full: ['3.5.0'], }, }, clusters: { @@ -82,7 +82,7 @@ describe('given a hook to list the clusters that can be copied from', () => { mockStore.getState.mockReturnValue({ app: { version: { - full: '3.5.0', + full: ['3.5.0', '3.6.0'], }, }, clusters: { @@ -111,7 +111,7 @@ describe('given a hook to list the clusters that can be copied from', () => { mockStore.getState.mockReturnValue({ app: { version: { - full: '3.5.0', + full: ['3.5.0'], }, }, clusters: { @@ -144,7 +144,7 @@ describe('given a hook to list the clusters that can be copied from', () => { mockStore.getState.mockReturnValue({ app: { version: { - full: '3.5.0', + full: ['3.5.0'], }, }, clusters: { diff --git a/frontend/src/old-pages/Clusters/FromClusterModal/useClustersToCopyFrom.ts b/frontend/src/old-pages/Clusters/FromClusterModal/useClustersToCopyFrom.ts index 2bb881227..efb701faf 100644 --- a/frontend/src/old-pages/Clusters/FromClusterModal/useClustersToCopyFrom.ts +++ b/frontend/src/old-pages/Clusters/FromClusterModal/useClustersToCopyFrom.ts @@ -33,17 +33,19 @@ function matchUpToMinor(semVer1: string, semVer2: string) { function canCopyFromCluster( cluster: ClusterInfoSummary, - currentVersion: string, + possibleVersions: string, ) { if (cluster.clusterStatus === ClusterStatus.DeleteInProgress) { return false } - if (!matchUpToMinor(cluster.version, currentVersion)) { - return false + for (const version of possibleVersions) { + if (matchUpToMinor(cluster.version, version)) { + return true + } } - return true + return false } export function useClustersToCopyFrom() { diff --git a/frontend/src/old-pages/Clusters/Properties.tsx b/frontend/src/old-pages/Clusters/Properties.tsx index e491aa34e..269b47ff5 100644 --- a/frontend/src/old-pages/Clusters/Properties.tsx +++ b/frontend/src/old-pages/Clusters/Properties.tsx @@ -9,7 +9,7 @@ // OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions and // limitations under the License. import {ClusterStatus, ClusterDescription} from '../../types/clusters' -import React, {PropsWithChildren, ReactElement} from 'react' +import React from 'react' import {Trans, useTranslation} from 'react-i18next' import {findFirst, clusterDefaultUser} from '../../util' import {useState, setState, ssmPolicy} from '../../store' diff --git a/frontend/src/old-pages/Clusters/__tests__/Tabs.test.tsx b/frontend/src/old-pages/Clusters/__tests__/Tabs.test.tsx index 786040850..2a0ff1f51 100644 --- a/frontend/src/old-pages/Clusters/__tests__/Tabs.test.tsx +++ b/frontend/src/old-pages/Clusters/__tests__/Tabs.test.tsx @@ -40,8 +40,11 @@ describe('Given a cluster and a list of tabs', () => { clusters: { selected: 'foo', }, + wizard: { + version: '3.5.0', + }, version: { - full: '3.5.0', + full: ['3.5.0', '3.6.0'], }, }, clusters: { @@ -72,8 +75,11 @@ describe('Given a cluster and a list of tabs', () => { clusters: { selected: 'foo', }, + wizard: { + version: '3.5.0', + }, version: { - full: '3.5.0', + full: ['3.5.0', '3.6.0'], }, }, clusters: { diff --git a/frontend/src/old-pages/Configure/Cluster/__tests__/ClusterNameField.test.tsx b/frontend/src/old-pages/Configure/Cluster/__tests__/ClusterNameField.test.tsx index 57f162193..8d2fd3c19 100644 --- a/frontend/src/old-pages/Configure/Cluster/__tests__/ClusterNameField.test.tsx +++ b/frontend/src/old-pages/Configure/Cluster/__tests__/ClusterNameField.test.tsx @@ -86,10 +86,6 @@ describe('given a component to set the ClusterName', () => { , ) - userEvent.type( - screen.getByPlaceholderText('wizard.cluster.clusterName.placeholder'), - 'some-name', - ) }) it('should be disabled', () => { diff --git a/frontend/src/old-pages/Configure/Cluster/__tests__/ImdsSupportFormField.test.tsx b/frontend/src/old-pages/Configure/Cluster/__tests__/ImdsSupportFormField.test.tsx index 3906b388f..c27f03f2d 100644 --- a/frontend/src/old-pages/Configure/Cluster/__tests__/ImdsSupportFormField.test.tsx +++ b/frontend/src/old-pages/Configure/Cluster/__tests__/ImdsSupportFormField.test.tsx @@ -54,9 +54,10 @@ describe('given a component to select the IMDS supported version', () => { mockStore.getState.mockReturnValue({ app: { version: { - full: '3.3.0', + full: ['3.3.0'], }, wizard: { + version: '3.3.0', config: {Imds: null}, }, }, @@ -81,9 +82,10 @@ describe('given a component to select the IMDS supported version', () => { mockStore.getState.mockReturnValue({ app: { version: { - full: '3.3.0', + full: ['3.3.0'], }, wizard: { + version: '3.3.0', config: { Imds: {ImdsSupport: 'v2.0'}, }, @@ -126,9 +128,10 @@ describe('given a component to select the IMDS supported version', () => { mockStore.getState.mockReturnValue({ app: { version: { - full: '3.3.0', + full: ['3.3.0'], }, wizard: { + version: '3.3.0', editing: true, }, }, @@ -160,7 +163,10 @@ describe('given a component to select the IMDS supported version', () => { mockStore.getState.mockReturnValue({ app: { version: { - full: '3.2.0', + full: ['3.2.0'], + }, + wizard: { + version: '3.2.0', }, }, }) diff --git a/frontend/src/old-pages/Configure/Cluster/__tests__/OsFormField.test.tsx b/frontend/src/old-pages/Configure/Cluster/__tests__/OsFormField.test.tsx index 6e045c5e2..d47211666 100644 --- a/frontend/src/old-pages/Configure/Cluster/__tests__/OsFormField.test.tsx +++ b/frontend/src/old-pages/Configure/Cluster/__tests__/OsFormField.test.tsx @@ -15,18 +15,17 @@ import i18n from 'i18next' import {mock} from 'jest-mock-extended' import {I18nextProvider, initReactI18next} from 'react-i18next' import {Provider} from 'react-redux' -import {setState as mockSetState} from '../../../../store' import {OsFormField} from '../OsFormField' -jest.mock('../../../../store', () => { - const originalModule = jest.requireActual('../../../../store') - return { - __esModule: true, // Use it when dealing with esModules - ...originalModule, - setState: jest.fn(), - } -}) +const mockUseState = jest.fn() +const mockSetState = jest.fn() + +jest.mock('../../../../store', () => ({ + ...(jest.requireActual('../../../../store') as any), + setState: (...args: unknown[]) => mockSetState(...args), + useState: (...args: unknown[]) => mockUseState(...args), +})) i18n.use(initReactI18next).init({ resources: {}, @@ -72,6 +71,7 @@ describe('given a component to select an Operating System', () => { mockStore.getState.mockReturnValue({ app: {wizard: {editing: true}}, }) + mockUseState.mockReturnValue('3.6.0') }) describe('when user selects an option', () => { @@ -93,9 +93,7 @@ describe('given a component to select an Operating System', () => { describe('when the version is >= 3.6.0', () => { beforeEach(() => { - mockStore.getState.mockReturnValue({ - app: {version: {full: '3.6.0'}}, - }) + mockUseState.mockReturnValue('3.6.0') }) describe('when the user choose between supported oses', () => { @@ -115,9 +113,7 @@ describe('given a component to select an Operating System', () => { describe('when the version is < 3.6.0', () => { beforeEach(() => { - mockStore.getState.mockReturnValue({ - app: {version: {full: '3.3.0'}}, - }) + mockUseState.mockReturnValue('3.3.0') }) describe('when the user choose between supported oses', () => { diff --git a/frontend/src/old-pages/Configure/Configure.tsx b/frontend/src/old-pages/Configure/Configure.tsx index 267271e3d..01849fc01 100644 --- a/frontend/src/old-pages/Configure/Configure.tsx +++ b/frontend/src/old-pages/Configure/Configure.tsx @@ -27,6 +27,7 @@ import { HeadNodePropertiesHelpPanel, headNodeValidate, } from './HeadNode' +import {Version, versionValidate} from './Version' import {Storage, StorageHelpPanel, storageValidate} from './Storage' import { useClusterResourcesLimits, @@ -60,10 +61,11 @@ import InfoLink from '../../components/InfoLink' import {useFeatureFlag} from '../../feature-flags/useFeatureFlag' const validators: {[key: string]: (...args: any[]) => boolean} = { + version: versionValidate, cluster: clusterValidate, headNode: headNodeValidate, - storage: storageValidate, queues: queuesValidate, + storage: storageValidate, create: createValidate, } const validate = (page: string): boolean => validators[page]() @@ -89,6 +91,7 @@ function clearWizardState( clearErrorsOnly: boolean, ) { if (!clearErrorsOnly) { + clearState(['app', 'wizard', 'version']) clearState(['app', 'wizard', 'config']) clearState(['app', 'wizard', 'clusterConfigYaml']) clearState(['app', 'wizard', 'clusterName']) @@ -251,6 +254,11 @@ function Configure() { refreshing || loadingExistingConfiguration || isSubmittingWizard } steps={[ + { + title: t('wizard.version.title'), + description: t('wizard.version.description'), + content: , + }, { title: t('wizard.cluster.title'), description: t('wizard.cluster.description'), diff --git a/frontend/src/old-pages/Configure/Create.tsx b/frontend/src/old-pages/Configure/Create.tsx index 032fb5831..d16fc5bf7 100644 --- a/frontend/src/old-pages/Configure/Create.tsx +++ b/frontend/src/old-pages/Configure/Create.tsx @@ -96,6 +96,7 @@ function handleCreate( const dryRun = false const region = getState(['app', 'wizard', 'config', 'Region']) const selectedRegion = getState(['app', 'selectedRegion']) + const version = getState(['app', 'wizard', 'version']) setClusterLoadingMsg(clusterName, editing, dryRun) setState(wizardSubmissionLoading, true) @@ -122,6 +123,7 @@ function handleCreate( clusterConfig, dryRun, forceUpdate, + version, successHandler, errHandler, ) @@ -131,6 +133,7 @@ function handleCreate( clusterConfig, region, selectedRegion, + version, dryRun, successHandler, errHandler, @@ -145,6 +148,7 @@ function handleDryRun() { const clusterConfig = getState(configPath) || '' const region = getState(['app', 'wizard', 'config', 'Region']) const selectedRegion = getState(['app', 'selectedRegion']) + const version = getState(['app', 'wizard', 'version']) const dryRun = true setClusterLoadingMsg(clusterName, editing, dryRun) setState(wizardSubmissionLoading, true) @@ -163,6 +167,7 @@ function handleDryRun() { UpdateCluster( clusterName, clusterConfig, + version, dryRun, forceUpdate, successHandler, @@ -174,6 +179,7 @@ function handleDryRun() { clusterConfig, region, selectedRegion, + version, dryRun, successHandler, errHandler, diff --git a/frontend/src/old-pages/Configure/Queues/Queues.test.tsx b/frontend/src/old-pages/Configure/Queues/Queues.test.tsx index 1436f22cf..d48e1207c 100644 --- a/frontend/src/old-pages/Configure/Queues/Queues.test.tsx +++ b/frontend/src/old-pages/Configure/Queues/Queues.test.tsx @@ -97,10 +97,8 @@ describe('Given a list of queues', () => { subnets: [], }, app: { - version: { - full: version, - }, wizard: { + version: version, config: { Scheduling: { SlurmQueues: queues, @@ -150,10 +148,8 @@ describe('Given a list of queues', () => { subnets: [], }, app: { - version: { - full: version, - }, wizard: { + version: version, config: { Scheduling: { SlurmQueues: queues, @@ -189,10 +185,8 @@ describe('Given a list of queues', () => { subnets: [], }, app: { - version: { - full: version, - }, wizard: { + version: version, config: { Scheduling: { SlurmQueues: [ @@ -241,10 +235,8 @@ describe('Given a list of queues', () => { subnets: [], }, app: { - version: { - full: version, - }, wizard: { + version: version, config: { Scheduling: { SlurmQueues: [ @@ -305,10 +297,8 @@ describe('Given a list of queues', () => { subnets: [], }, app: { - version: { - full: '3.6.0', - }, wizard: { + version: '3.6.0', config: { Scheduling: { SlurmQueues: queues, @@ -380,10 +370,8 @@ describe('Given a queue', () => { subnets: [], }, app: { - version: { - full: '3.4.0', - }, wizard: { + version: '3.4.0', config: { HeadNode: { Networking: { @@ -474,10 +462,8 @@ describe('Given a queue', () => { subnets: [], }, app: { - version: { - full: '3.4.0', - }, wizard: { + version: '3.6.0', config: { Scheduling: { SlurmQueues: [ @@ -529,10 +515,8 @@ describe('Given a queue', () => { subnets: [], }, app: { - version: { - full: '3.4.0', - }, wizard: { + version: '3.4.0', config: { Scheduling: { SlurmQueues: [ diff --git a/frontend/src/old-pages/Configure/Queues/Queues.tsx b/frontend/src/old-pages/Configure/Queues/Queues.tsx index 130b481cb..2c5583b87 100644 --- a/frontend/src/old-pages/Configure/Queues/Queues.tsx +++ b/frontend/src/old-pages/Configure/Queues/Queues.tsx @@ -203,7 +203,7 @@ function queueValidate(queueIndex: any) { clearState([...errorsPath, 'customAmi']) } - const version = getState(['app', 'version', 'full']) + const version = getState(['app', 'wizard', 'version']) const isMultiAZActive = isFeatureEnabled(version, defaultRegion, 'multi_az') if (!queueSubnet) { let message: string diff --git a/frontend/src/old-pages/Configure/SlurmSettings/__tests__/SlurmSettings.test.tsx b/frontend/src/old-pages/Configure/SlurmSettings/__tests__/SlurmSettings.test.tsx index 9f417c000..89d2f5ee3 100644 --- a/frontend/src/old-pages/Configure/SlurmSettings/__tests__/SlurmSettings.test.tsx +++ b/frontend/src/old-pages/Configure/SlurmSettings/__tests__/SlurmSettings.test.tsx @@ -38,9 +38,13 @@ describe('given a component to configure the SlurmSettings', () => { mockStore.getState.mockReturnValue({ app: { version: { - full: '3.3.0', + full: ['3.3.0'], }, + wizard: { + version: '3.3.0' + } }, + }) screen = render( @@ -63,7 +67,7 @@ describe('given a component to configure the SlurmSettings', () => { mockStore.getState.mockReturnValue({ app: { version: { - full: '3.2.0', + full: ['3.2.0'], }, }, }) diff --git a/frontend/src/old-pages/Configure/Version.tsx b/frontend/src/old-pages/Configure/Version.tsx new file mode 100644 index 000000000..d73022a5b --- /dev/null +++ b/frontend/src/old-pages/Configure/Version.tsx @@ -0,0 +1,73 @@ +import React, {useEffect} from 'react' +import i18next from 'i18next' +import {useTranslation} from 'react-i18next' +import {Container, Header, SpaceBetween} from '@cloudscape-design/components' +import {ClusterVersionField} from './Version/ClusterVersionField' +import InfoLink from '../../components/InfoLink' +import TitleDescriptionHelpPanel from '../../components/help-panel/TitleDescriptionHelpPanel' +import {getState, setState, clearState, useState} from '../../store' +import {useHelpPanel} from "../../components/help-panel/HelpPanel"; + +const errorsPath = ['app', 'wizard', 'errors', 'version'] + +function versionValidate() { + const version = getState(['app', 'wizard', 'version']) + if (!version) { + setState( + [...errorsPath, 'version'], + i18next.t('wizard.version.validation.versionSelect'), + ) + return false + } + clearState([...errorsPath, 'version']) + return true +} + + +function Version() { + const {t} = useTranslation() + const editing = useState(['app', 'wizard', 'editing']) + + useHelpPanel() + + useEffect(() => { + // Get the current version + const currentVersion = getState(['app', 'wizard', 'version']) + + // Clear version only if we're not editing + if (!editing) { + setState(['app', 'wizard', 'version'], null) + } + }, [editing]) + + return ( + + } />} + > + {t('wizard.version.label')} + + } + > + + + + + + ) +} + +const VersionHelpPanel = () => { + const {t} = useTranslation() + return ( + + ) +} + +export {Version, versionValidate} \ No newline at end of file diff --git a/frontend/src/old-pages/Configure/Version/ClusterVersionField.tsx b/frontend/src/old-pages/Configure/Version/ClusterVersionField.tsx new file mode 100644 index 000000000..2a14db9d4 --- /dev/null +++ b/frontend/src/old-pages/Configure/Version/ClusterVersionField.tsx @@ -0,0 +1,77 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance +// with the License. A copy of the License is located at +// +// http://aws.amazon.com/apache2.0/ +// +// or in the "LICENSE.txt" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +// OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions and +// limitations under the License. + +import { + FormField, + Input, + InputProps, + NonCancelableCustomEvent, + Select, + SelectProps +} from '@cloudscape-design/components' +import {NonCancelableEventHandler} from '@cloudscape-design/components/internal/events' +import {useCallback} from 'react' +import {useTranslation} from 'react-i18next' +import {setState, useState} from '../../../store' + +const clusterVersionPath = ['app', 'wizard', 'version'] +const clusterVersionErrorPath = [ + 'app', + 'wizard', + 'errors', + 'source', + 'version', +] +const editingPath = ['app', 'wizard', 'editing'] + +interface ClusterVersionFieldProps { + hideLabel?: boolean; +} + +export function ClusterVersionField({ hideLabel = false }: ClusterVersionFieldProps) { + const {t} = useTranslation() + const version = useState(clusterVersionPath) || '' + const clusterVersionError = useState(clusterVersionErrorPath) + const editing = !!useState(editingPath) + const versions = useState(['app', 'version', 'full']) + + const options = (versions || []).map((version: string) => ({ + label: version, + value: version + })) + + const onChange = useCallback( + ({detail}: NonCancelableCustomEvent) => { + setState(clusterVersionPath, detail.selectedOption.value) + }, + [] + ) + + return ( + + setSelectedVersion(detail.selectedOption.value)} + options={versions.map((version: any) => ({ label: version, value: version }))} + placeholder={t('customImages.dialogs.buildImage.versionPlaceholder')} + /> + { - setState([...imageBuildPath, 'config'], detail.value) - }} + config={imageConfig} + onChange={({detail}: any) => { + setState([...imageBuildPath, 'config'], detail.value) + }} /> - } + ) diff --git a/frontend/src/old-pages/Images/CustomImages/__tests__/CustomImages.test.tsx b/frontend/src/old-pages/Images/CustomImages/__tests__/CustomImages.test.tsx new file mode 100644 index 000000000..83926d6ea --- /dev/null +++ b/frontend/src/old-pages/Images/CustomImages/__tests__/CustomImages.test.tsx @@ -0,0 +1,151 @@ +import {render, waitFor, screen, within, fireEvent} from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import {I18nextProvider} from 'react-i18next' +import {QueryClient, QueryClientProvider} from 'react-query' +import {Provider} from 'react-redux' +import {BrowserRouter} from 'react-router-dom' +import i18n from '../../../../i18n' +import {BuildImage, ListCustomImages, DescribeCustomImage} from '../../../../model' +import CustomImages from '../CustomImages' +import {ImageBuildStatus, ImageInfoSummary} from '../../../../types/images' +import {Ec2AmiState} from '../../../../types/images' +import {CloudFormationStackStatus} from "../../../../types/base" +import {createStore} from 'redux' +import {setState, getState} from '../../../../store' +import {act} from "react-dom/test-utils"; +import {Store} from "@reduxjs/toolkit"; +import {mock} from "jest-mock-extended"; + +const queryClient = new QueryClient() +const mockImages: ImageInfoSummary[] = [ + { + imageId: 'test-image', + imageBuildStatus: ImageBuildStatus.BuildComplete, + region: 'us-east-1', + version: '3.12.0', + ec2AmiInfo: { + tags: [], + amiName: 'test', + architecture: 'x86_64', + description: "test ami", + amiId: 'ami-12345', + state: Ec2AmiState.Available, + }, + cloudformationStackArn: "example-arn", + cloudformationStackStatus: CloudFormationStackStatus.CreateComplete, + }, +] + +const mockInitialState = { + customImages: { + list: mockImages + }, + app: { + version: { + full: ['3.12.0', '3.11.0', '3.10.0'] + }, + customImages: { + selectedImageStatus: 'AVAILABLE', + imageBuild: { + ImageId: 'test-build-image', + dialog: true, + imageId: '', + config: '' + } + } + } +} + +const mockStore = mock() +const wrapper = (props: any) => ( + {props.children} +) + + +const MockProviders = ({children}: {children: React.ReactNode}) => ( + + + + {children} + + + +) + +jest.mock('../../../../model', () => ({ + ListCustomImages: jest.fn(), + DescribeCustomImage: jest.fn(), + BuildImage: jest.fn(), +})) + +describe('CustomImages', () => { + beforeEach(() => { + jest.clearAllMocks() + mockStore.getState.mockReturnValue(mockInitialState) + ;(ListCustomImages as jest.Mock).mockImplementation(() => { + mockStore.dispatch({ type: 'SET_IMAGES', payload: mockImages }) + return Promise.resolve(mockImages) + }) + ;(DescribeCustomImage as jest.Mock).mockResolvedValue(mockImages[0]) + }) + + describe('CustomImagesList', () => { + it('should render the images list', async () => { + const {container} = render( + + + , + ) + + await waitFor(() => { + expect(ListCustomImages).toHaveBeenCalled() + }) + + + await waitFor(() => { + const tableElement = container.querySelector('table') + expect(tableElement).toBeTruthy() + + const cellContent = container.textContent + expect(cellContent).toContain('test-image') + expect(cellContent).toContain('ami-12345') + expect(cellContent).toContain('us-east-1') + expect(cellContent).toContain('3.12.0') + }) + }) + + it('should handle image selection', async () => { + const {container} = render( + + + , + ) + + await waitFor(() => { + expect(container.textContent).toContain('test-image') + }) + + const radio = container.querySelector('input[type="radio"]') + if (radio) { + await userEvent.click(radio) + expect(DescribeCustomImage).toHaveBeenCalledWith('test-image') + } + }) + + it('should handle status filter changes', async () => { + render( + + + , + ) + + const statusSelect = screen.getByRole('button', {name: /available/i}) + await userEvent.click(statusSelect) + + const pendingOption = await screen.findByText('Pending') + await userEvent.click(pendingOption) + + expect(ListCustomImages).toHaveBeenCalledWith(Ec2AmiState.Pending) + }) + }) +}) diff --git a/frontend/src/old-pages/Images/CustomImages/__tests__/ImageBuildDialog.test.tsx b/frontend/src/old-pages/Images/CustomImages/__tests__/ImageBuildDialog.test.tsx new file mode 100644 index 000000000..77d4c76f0 --- /dev/null +++ b/frontend/src/old-pages/Images/CustomImages/__tests__/ImageBuildDialog.test.tsx @@ -0,0 +1,155 @@ +import React from 'react'; +import {render, screen, fireEvent, waitFor, within} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import i18n from '../../../../i18n'; +import ImageBuildDialog from '../ImageBuildDialog'; +import { BuildImage } from '../../../../model'; +import {act} from "react-dom/test-utils"; + +jest.mock('../../../../model', () => ({ + BuildImage: jest.fn(), +})); + + +const mockUseState = jest.fn() +const mockSetState = jest.fn() +const mockGetState = jest.fn() +const mockClearState = jest.fn() + + +jest.mock('../../../../store', () => ({ + ...(jest.requireActual('../../../../store') as any), + setState: (...args: unknown[]) => mockSetState(...args), + useState: (...args: unknown[]) => mockUseState(...args), + getState: (...args: unknown[]) => mockGetState(...args), + clearState: (...args: unknown[]) => mockClearState(...args), + +})) + + +jest.mock('../../../../components/FileChooser', () => ({ + __esModule: true, + default: () => , +})); + +describe('ImageBuildDialog', () => { + beforeEach(() => { + mockUseState.mockReturnValue(['3.12.0', '3.6.0', '3.7.0']) + + }); + + it('renders correctly when open', () => { + render( + + + + ); + + expect(screen.getByText('Build image')).toBeTruthy(); + expect(screen.getByPlaceholderText('Enter image AMI ID')).toBeTruthy(); + expect(screen.getByText('Upload File')).toBeTruthy(); + }); + + it('handles image ID input', async () => { + render( + + + + ); + + const input = screen.getByPlaceholderText('Enter image AMI ID'); + + await act(async () => { + fireEvent.change(input, { + target: { value: 'test-image-id' }, + detail: { value: 'test-image-id' }, + bubbles: true, + }); + }); + + await waitFor(() => { + expect(mockSetState).toHaveBeenCalledWith( + ['app', 'customImages', 'imageBuild', 'imageId'], + 'test-image-id' + ); + }); + }); + + it('handles version selection', async () => { + render( + + + + ); + + const versionSelect = screen.getByLabelText('Version'); + await userEvent.click(versionSelect); + + const version = await screen.findByText('3.7.0'); + await userEvent.click(version); + + expect(screen.getByText('3.7.0')).toBeTruthy(); + + await userEvent.click(versionSelect); + + const version2 = await screen.findByText('3.12.0'); + await userEvent.click(version2); + + expect(screen.getByText('3.12.0')).toBeTruthy(); + + }); + + it('handles build button click', async () => { + (mockUseState as jest.Mock).mockImplementation((path) => { + if (path.join('.') === 'app.version.full') return ['3.12.0', '3.6.0', '3.7.0']; + if (path.join('.') === 'app.customImages.imageBuild.imageId') return 'test-image-id' + if (path.join('.') === 'app.customImages.imageBuild.config') return 'test-config'; + + return null; + }); + + mockGetState.mockImplementation((path: string[]) => { + if (Array.isArray(path)) { + switch (path.join('.')) { + case 'app.customImages.imageBuild.imageId': + return 'test-image-id'; + case 'app.customImages.imageBuild.config': + return 'test-config'; + } + } + return undefined; + }); + + render( + + + + ); + + const buildButton = screen.getByText('Build image'); + expect(buildButton).toBeTruthy(); + + await act(async () => { + await userEvent.click(buildButton); + }); + + await waitFor(() => { + expect(BuildImage).toHaveBeenCalledWith('test-image-id', 'test-config', '3.12.0'); + }); + }); + + it('closes the dialog', async () => { + render( + + + + ); + + const cancelButton = screen.getByText('Cancel'); + await userEvent.click(cancelButton); + + expect(mockSetState).toHaveBeenCalledWith(['app', 'customImages', 'imageBuild', 'dialog'], false); + expect(mockClearState).toHaveBeenCalledWith(['app', 'customImages', 'imageBuild', 'errors']); + }); +}); diff --git a/frontend/src/old-pages/Images/OfficialImages/OfficialImages.tsx b/frontend/src/old-pages/Images/OfficialImages/OfficialImages.tsx index f4b451d81..46480c56d 100644 --- a/frontend/src/old-pages/Images/OfficialImages/OfficialImages.tsx +++ b/frontend/src/old-pages/Images/OfficialImages/OfficialImages.tsx @@ -9,6 +9,8 @@ // OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions and // limitations under the License. import React, {useMemo} from 'react' +import {Select, SpaceBetween} from '@cloudscape-design/components' + import {ListOfficialImages} from '../../../model' import {useCollection} from '@cloudscape-design/collection-hooks' @@ -25,7 +27,7 @@ import { // Components import EmptyState from '../../../components/EmptyState' import {useQuery} from 'react-query' -import {useState} from '../../../store' +import {useState, setState} from '../../../store' import {useHelpPanel} from '../../../components/help-panel/HelpPanel' import {Trans, useTranslation} from 'react-i18next' import TitleDescriptionHelpPanel from '../../../components/help-panel/TitleDescriptionHelpPanel' @@ -39,6 +41,29 @@ type Image = { version: string } +function VersionSelect() { + const {t} = useTranslation() + const versions = useState(['app', 'version', 'full']) + const [selectedVersion, setSelectedVersion] = React.useState(versions[0]) + + return ( +