From 001e53a7335c72e624d461a5d4d2f93beac7e926 Mon Sep 17 00:00:00 2001 From: sanne raymaekers Date: Thu, 7 May 2020 12:43:51 +0200 Subject: [PATCH] components: Move image creation to PF4 wizard --- src/Routes.js | 2 + .../CreateImageWizard/CreateImageWizard.js | 489 ++++++++++++++++++ .../LandingPage/CreateImageCard.js | 58 +-- src/SmartComponents/LandingPage/ImagesCard.js | 9 +- .../LandingPage/LandingPage.js | 3 +- src/SmartComponents/redux/reducers.js | 4 +- src/api.js | 23 + src/constants.js | 1 + 8 files changed, 532 insertions(+), 57 deletions(-) create mode 100644 src/SmartComponents/CreateImageWizard/CreateImageWizard.js create mode 100644 src/api.js create mode 100644 src/constants.js diff --git a/src/Routes.js b/src/Routes.js index 8ee0272d..0ae6919a 100644 --- a/src/Routes.js +++ b/src/Routes.js @@ -4,6 +4,7 @@ import React from 'react'; import asyncComponent from './Utilities/asyncComponent'; const LandingPage = asyncComponent(() => import('./SmartComponents/LandingPage/LandingPage')); +const CreateImageWizard = asyncComponent(() => import('./SmartComponents/CreateImageWizard/CreateImageWizard')); const InsightsRoute = ({ component: Component, rootClass, ...rest }) => { const root = document.getElementById('root'); @@ -22,6 +23,7 @@ export const Routes = () => { return ( + ); diff --git a/src/SmartComponents/CreateImageWizard/CreateImageWizard.js b/src/SmartComponents/CreateImageWizard/CreateImageWizard.js new file mode 100644 index 00000000..48d4ec5f --- /dev/null +++ b/src/SmartComponents/CreateImageWizard/CreateImageWizard.js @@ -0,0 +1,489 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { withRouter } from 'react-router-dom'; +import { connect } from 'react-redux'; +import { actions } from '../redux'; + +import { + Alert, + Flex, + FlexItem, + FlexModifiers, + Form, + FormGroup, + FormSelect, + FormSelectOption, + Radio, + TextContent, + TextInput, + Title, + Wizard, +} from '@patternfly/react-core'; + +import { ExclamationCircleIcon } from '@patternfly/react-icons'; + +import api from './../../api.js'; + +const ReleaseComponent = (props) => { + const options = [ + { value: 'rhel-8', label: 'Red Hat Enterprise Linux (RHEL) 8.2' }, + ]; + return ( +
+ + props.setRelease(value) } + aria-label="Select release input" id="release-select"> + { options.map(option => ) } + + +
+ ); +}; + +ReleaseComponent.propTypes = { + setRelease: PropTypes.func, + value: PropTypes.string, +}; + +const AmazonUploadComponent = (props) => { + const serviceOptions = [ + { value: 'ec2', label: 'Amazon Elastic Compute Cloud (ec2)' }, + { value: 's3', label: 'Amazon Simple Storage Service (s3)' }, + ]; + + return ( + <> + + props.setUploadOptions(Object.assign(props.upload.options, { access_key_id: value })) }/> + + + props.setUploadOptions(Object.assign(props.upload.options, { secret_access_key: value })) }/> + + + props.setUploadOptions(Object.assign(props.upload.options, { service: value })) } > + { serviceOptions.map(option => ) } + + + + props.setUploadOptions(Object.assign(props.upload.options, { region: value })) }/> + + { props.upload.options.service === 's3' && + + props.setUploadOptions(Object.assign(props.upload.options, { bucket: value })) }/> + } + + ); +}; + +AmazonUploadComponent.propTypes = { + setUploadOptions: PropTypes.func, + upload: PropTypes.object, + errors: PropTypes.object, +}; + +const UploadComponent = (props) => { + const uploadTypes = [ + { value: 'aws', label: 'Amazon Machine Image (.vhdx)' }, + ]; + + return ( + <> +
+ + props.setUpload({ type: value, options: null }) } aria-label="Select upload destination"> + { uploadTypes.map(type => ) } + + + { props.upload.type === 'aws' && + } + + + ); +}; + +UploadComponent.propTypes = { + setUpload: PropTypes.func, + setUploadOptions: PropTypes.func, + upload: PropTypes.object, + errors: PropTypes.object, +}; + +const SubscriptionComponent = (props) => { + return ( +
+ + props.setSubscribeNow(true) }/> + props.setSubscribeNow(false) } /> + + { props.subscribeNow && + <> + + + + + props.setSubscription(Object.assign(props.subscription, { 'activation-key': value })) }/> + + } +
+ ); +}; + +SubscriptionComponent.propTypes = { + setSubscription: PropTypes.func, + setSubscribeNow: PropTypes.func, + subscription: PropTypes.object, + subscribeNow: PropTypes.bool, + errors: PropTypes.object, +}; + +const ReviewComponent = (props) => { + return ( + <> + { (Object.keys(props.uploadErrors).length > 0 || + Object.keys(props.subscriptionErrors).length > 0) && + } + +

Create image

+ + Review the information and click Create image + to create the image using the following criteria. + +

Release

+ + + Release + + + { props.release } + + +

Image output

+ + + Destination + + + { props.upload && <>{ props.upload.type } } + + + { Object.entries(props.uploadErrors).map(([ key, error ]) => { + return ( + + { error.label } + + + { error.value } + + ); + })} +

Registration

+ + + Subscription + + { !props.subscribeNow && + + Register the system later + } + { props.subscribeNow && + + Register the system on first boot + } + + { Object.entries(props.subscriptionErrors).map(([ key, error ]) => { + return ( + + { error.label } + + + { error.value } + + ); + })} +
+ + ); +}; + +ReviewComponent.propTypes = { + release: PropTypes.string, + upload: PropTypes.object, + subscription: PropTypes.object, + subscribeNow: PropTypes.bool, + uploadErrors: PropTypes.object, + subscriptionErrors: PropTypes.object, +}; + +class CreateImageWizard extends Component { + constructor(props) { + super(props); + + this.setRelease = this.setRelease.bind(this); + this.setUpload = this.setUpload.bind(this); + this.setUploadOptions = this.setUploadOptions.bind(this); + this.setSubscription = this.setSubscription.bind(this); + this.setSubscribeNow = this.setSubscribeNow.bind(this); + this.onStep = this.onStep.bind(this); + this.onSave = this.onSave.bind(this); + this.onClose = this.onClose.bind(this); + this.validate = this.validate.bind(this); + this.validateUploadAmazon = this.validateUploadAmazon.bind(this); + this.validateSubscription = this.validateSubscription.bind(this); + + this.state = { + release: 'rhel-8', + upload: { + type: 'aws', + options: { + service: 'ec2', + region: 'eu-west-2', + access_key_id: null, + secret_access_key: null, + bucket: null, + } + }, + subscription: { + organization: null, + 'activation-key': null, + 'server-url': 'subscription.rhsm.redhat.com', + 'base-url': 'http:cdn.redhat.com/', + insights: true + }, + subscribeNow: false, + /* errors take form of $fieldId: error */ + uploadErrors: {}, + subscriptionErrors: {}, + }; + } + + async componentDidMount() { + let user = await insights.chrome.auth.getUser(); + this.setState({ + subscription: { + organization: Number(user.identity.internal.org_id) + } + }); + } + + onStep(step) { + if (step.name === 'Review') { + this.validate(); + } + } + + validate() { + /* upload */ + if (this.state.upload.type === 'aws') { + this.validateUploadAmazon(); + } else { + this.setState({ uploadErrors: {}}); + } + + /* subscription */ + if (this.state.subscribeNow) { + this.validateSubscription(); + } else { + this.setState({ subscriptionErrors: {}}); + } + } + + validateUploadAmazon() { + let uploadErrors = {}; + if (!this.state.upload.options.access_key_id) { + uploadErrors['amazon-access-id'] = + { label: 'Access key ID', value: 'A value is required' }; + } + + if (!this.state.upload.options.secret_access_key) { + uploadErrors['amazon-access-secret'] = + { label: 'Secret access key', value: 'A value is required' }; + } + + if (!this.state.upload.options.region) { + uploadErrors['amazon-region'] = + { label: 'Region', value: 'A value is required' }; + } + + if (this.state.upload.options.service === 's3' && + !this.state.upload.options.bucket) { + uploadErrors['amazon-bucket'] = + { label: 'Bucket', value: 'A value is required' }; + } + + this.setState({ uploadErrors }); + } + + validateSubscription() { + let subscriptionErrors = {}; + if (!this.state.subscription['activation-key']) { + subscriptionErrors['subscription-activation'] = + { label: 'Activation key', value: 'A value is required' }; + } + + this.setState({ subscriptionErrors }); + } + + setRelease(release) { + this.setState({ release }); + } + + setUpload(upload) { + this.setState({ upload }); + } + + setUploadOptions(uploadOptions) { + this.setState(oldState => { + return { + upload: { + type: oldState.upload.type, + options: uploadOptions + } + }; + }); + } + + setSubscribeNow(subscribeNow) { + this.setState({ subscribeNow }); + } + + setSubscription(subscription) { + this.setState({ subscription }, this.validate); + } + + onSave () { + let request = { + distribution: this.state.release, + image_requests: [ + { + architecture: 'x86_64', + image_type: 'qcow2', + upload_requests: [{ + type: 'aws', + options: { + region: this.state.upload.options.region, + s3: { + access_key_id: this.state.upload.options.access_key_id, + secret_access_key: this.state.upload.options.secret_access_key, + bucket: this.state.upload.options.bucket, + }, + ec2: { + access_key_id: this.state.upload.options.access_key_id, + secret_access_key: this.state.upload.options.secret_access_key, + }, + }, + }], + }], + customizations: { + subscription: this.state.subscription, + }, + }; + + let { updateCompose } = this.props; + api.composeImage(request).then(response => { + let compose = {}; + compose[response.id] = { + status: 'request sent', + distribution: request.distribution, + architecture: request.image_requests[0].architecture, + image_type: request.image_requests[0].image_type, + }; + updateCompose(compose); + this.props.history.push('/landing'); + }); + } + + onClose () { + this.props.history.push('/landing'); + } + + render() { + const steps = [ + { + name: 'Release', + component: }, + { + name: 'Target environment', + component: }, + { + name: 'Registration', + component: }, + { + name: 'Review', + component: , + nextButtonText: 'Create', + } + ]; + + return ( + <> +
+ + Create a new image + +
+ + + ); + } +} + +function mapDispatchToProps(dispatch) { + return { + updateCompose: (compose) => dispatch(actions.updateCompose(compose)), + }; +} + +CreateImageWizard.propTypes = { + updateCompose: PropTypes.func, + history: PropTypes.object, +}; + +export default connect(null, mapDispatchToProps)(withRouter(CreateImageWizard)); diff --git a/src/SmartComponents/LandingPage/CreateImageCard.js b/src/SmartComponents/LandingPage/CreateImageCard.js index 6065b2ab..d5dcab0b 100644 --- a/src/SmartComponents/LandingPage/CreateImageCard.js +++ b/src/SmartComponents/LandingPage/CreateImageCard.js @@ -1,7 +1,5 @@ -import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { actions } from '../redux'; +import { Link } from 'react-router-dom'; import { Button, @@ -14,42 +12,10 @@ import { FormGroup, } from '@patternfly/react-core'; -import { DefaultApi } from '@redhat-cloud-services/osbuild-installer'; - class CreateImageCard extends Component { constructor(props) { super(props); - this.buildImage = this.buildImage.bind(this); - } - - buildImage() { - let request = { - image_builds: [ - { - distribution: 'fedora-31', - architecture: 'x86_64', - image_type: 'qcow2', - repositories: [{ baseurl: 'http://download.fedoraproject.org/pub/fedora/linux/releases/30/Everything/x86_64/os/' }], - }] - }; - let api = new DefaultApi(); - let { updateCompose } = this.props; - api.composeImage(request).then(response => { - /* request failed? */ - if (response.data.compose_id === undefined) { - return; - } - - let compose = {}; - compose[response.data.compose_id] = { - status: 'request sent', - distribution: request.image_builds[0].distribution, - architecture: request.image_builds[0].architecture, - image_type: request.image_builds[0].image_type, - }; - updateCompose(compose); - }); } render() { @@ -58,10 +24,12 @@ class CreateImageCard extends Component { Create a new image
- - - - + + + + + +
@@ -69,14 +37,4 @@ class CreateImageCard extends Component { } } -function mapDispatchToProps(dispatch) { - return { - updateCompose: (compose) => dispatch(actions.updateCompose(compose)), - }; -} - -CreateImageCard.propTypes = { - updateCompose: PropTypes.func, -}; - -export default connect(() => {}, mapDispatchToProps)(CreateImageCard); +export default CreateImageCard; diff --git a/src/SmartComponents/LandingPage/ImagesCard.js b/src/SmartComponents/LandingPage/ImagesCard.js index 7c538a99..b536e31f 100644 --- a/src/SmartComponents/LandingPage/ImagesCard.js +++ b/src/SmartComponents/LandingPage/ImagesCard.js @@ -9,7 +9,7 @@ import { CardBody, } from '@patternfly/react-core'; -import { DefaultApi } from '@redhat-cloud-services/osbuild-installer'; +import api from '../../api.js'; class ImagesCard extends Component { constructor(props) { @@ -21,13 +21,16 @@ class ImagesCard extends Component { this.interval = setInterval(() => this.pollComposeStatuses(), 8000); } + componentWillUnmount() { + clearInterval(this.interval); + } + pollComposeStatuses() { - let api = new DefaultApi(); let { updateCompose, composes } = this.props; Object.entries(composes).map(([ id, compose ]) => { api.getComposeStatus(id).then(response => { let newCompose = {}; - newCompose[id] = Object.assign({}, compose, { status: response.data.status }); + newCompose[id] = Object.assign({}, compose, { status: response.status }); updateCompose(newCompose); }); }); diff --git a/src/SmartComponents/LandingPage/LandingPage.js b/src/SmartComponents/LandingPage/LandingPage.js index 39eefdf4..3f83fb8b 100644 --- a/src/SmartComponents/LandingPage/LandingPage.js +++ b/src/SmartComponents/LandingPage/LandingPage.js @@ -22,6 +22,7 @@ class LandingPage extends Component { constructor(props) { super(props); } + render() { return ( @@ -33,7 +34,7 @@ class LandingPage extends Component { - + diff --git a/src/SmartComponents/redux/reducers.js b/src/SmartComponents/redux/reducers.js index 758f1f8e..53f3fb9e 100644 --- a/src/SmartComponents/redux/reducers.js +++ b/src/SmartComponents/redux/reducers.js @@ -15,8 +15,6 @@ export function composeReducer(state = { }, action) { case types.UPDATE_COMPOSE: return Object.assign({}, state, action.compose); default: - return { - ...state - }; + return state; } } diff --git a/src/api.js b/src/api.js new file mode 100644 index 00000000..64351a16 --- /dev/null +++ b/src/api.js @@ -0,0 +1,23 @@ +import axios from 'axios'; +import { + OSBUILD_INSTALLER_API, +} from './constants'; + +const postHeaders = { headers: { 'Content-Type': 'application/json' }}; + +async function composeImage(body) { + let path = '/compose'; + const request = await axios.post(OSBUILD_INSTALLER_API.concat(path), body, postHeaders); + return request.data; +} + +async function getComposeStatus(id) { + let path = '/compose/' + id; + const request = await axios.get(OSBUILD_INSTALLER_API.concat(path)); + return request.data; +} + +export default { + composeImage, + getComposeStatus, +}; diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 00000000..5b1af6b8 --- /dev/null +++ b/src/constants.js @@ -0,0 +1 @@ +export const OSBUILD_INSTALLER_API = '/api/osbuild-installer/v1';