diff --git a/src/models/volume.js b/src/models/volume.js index b8d5732b..40769763 100755 --- a/src/models/volume.js +++ b/src/models/volume.js @@ -36,6 +36,7 @@ export default { WorkloadDetailModalVisible: false, recurringJobModalVisible: false, attachHostModalVisible: false, + bulkCloneVolumeVisible: false, bulkAttachHostModalVisible: false, detachHostModalVisible: false, engineUpgradeModalVisible: false, @@ -218,10 +219,19 @@ export default { yield put({ type: 'query' }) } }, - *showCloneVolumeModalBefore({ + *bulkCloneVolume({ payload, }, { call, put }) { - yield put({ type: 'showCloneVolumeModal', payload }) + yield put({ type: 'hideBulkCloneVolume' }) + for (const vol of payload) { + yield call(create, vol) + } + yield put({ type: 'query' }) + }, + *showBulkCloneVolumeModalBefore({ + payload, + }, { call, put }) { + yield put({ type: 'showBulkCloneVolume', payload }) const nodeTags = yield call(getNodeTags, payload) const diskTags = yield call(getDiskTags, payload) @@ -231,6 +241,19 @@ export default { yield put({ type: 'changeTagsLoading', payload: { tagsLoading: false } }) } }, + *showCloneVolumeModalBefore({ + payload, + }, { call, put }) { + yield put({ type: 'showCloneVolumeModal', payload }) + + const nodeTags = yield call(getNodeTags) + const diskTags = yield call(getDiskTags) + if (nodeTags.status === 200 && diskTags.status === 200) { + yield put({ type: 'changeTagsLoading', payload: { nodeTags: nodeTags.data, diskTags: diskTags.data, tagsLoading: false } }) + } else { + yield put({ type: 'changeTagsLoading', payload: { tagsLoading: false } }) + } + }, *showCreateVolumeModalBefore({ payload, }, { call, put, all }) { @@ -802,9 +825,15 @@ export default { showAttachHostModal(state, action) { return { ...state, ...action.payload, attachHostModalVisible: true, attachHostModalKey: Math.random() } }, + showBulkCloneVolume(state, action) { + return { ...state, ...action.payload, bulkCloneVolumeVisible: true } + }, showBulkAttachHostModal(state, action) { return { ...state, ...action.payload, bulkAttachHostModalVisible: true, bulkAttachHostModalKey: Math.random() } }, + hideBulkCloneVolume(state) { + return { ...state, bulkCloneVolumeVisible: false } + }, hideAttachHostModal(state) { return { ...state, attachHostModalVisible: false } }, diff --git a/src/routes/volume/BulkCloneVolumeModal.js b/src/routes/volume/BulkCloneVolumeModal.js new file mode 100644 index 00000000..2ad23c13 --- /dev/null +++ b/src/routes/volume/BulkCloneVolumeModal.js @@ -0,0 +1,399 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import { + Form, + Input, + InputNumber, + Select, + Checkbox, + Spin, + Alert, + Button, + Tooltip, + message, + Tabs, +} from 'antd' +import { ModalBlur } from '../../components' +import { frontends } from './helper/index' +import { formatSize } from '../../utils/formatter' +const TabPane = Tabs.TabPane +const FormItem = Form.Item +const { Option } = Select + +const formItemLayout = { + labelCol: { span: 6 }, + wrapperCol: { span: 13 }, +} + +const modal = ({ + selectedRows = [], + visible, + onCancel, + onOk, + nodeTags, + diskTags, + defaultDataLocalityOption, + defaultDataLocalityValue, + backingImageOptions, + tagsLoading, + v1DataEngineEnabled, + v2DataEngineEnabled, + form: { + getFieldDecorator, + validateFields, + setFieldsValue, + getFieldsValue, + getFieldValue, + + getFieldsError, + }, +}) => { + const initConfigs = selectedRows.map((i) => ({ + id: i.id, + name: `cloned-${i.name}`, + size: i.size, + numberOfReplicas: i.numberOfReplicas, + frontend: i.frontend, + dataLocality: i.dataLocality || defaultDataLocalityValue, + accessMode: i.accessMode || null, + backingImage: i.backingImage, + dataSource: i.name, + encrypted: i.encrypted || false, + dataEngine: i.dataEngine || 'v1', + nodeSelector: i.nodeSelector || [], + diskSelector: i.diskSelector || [], + })) + const [tabIndex, setTabIndex] = useState(0) + const [volumeConfigs, setVolumeConfigs] = useState(initConfigs) + + const handleOk = () => { + const data = volumeConfigs.map(vol => ({ + ...vol, + dataSource: `vol://${vol.dataSource}`, + })) + onOk(data) + } + const updateVolumeConfig = (key, newValue) => { + setVolumeConfigs(prev => { + const newConfigs = [...prev] + const current = newConfigs[tabIndex] + const data = { + ...current, + [key]: newValue, + } + newConfigs.splice(tabIndex, 1, data) + return newConfigs + }) + } + + const handleApplyAll = () => { + // only apply below configs to other configs + const currentConfig = { + numberOfReplicas: getFieldValue('numberOfReplicas'), + frontend: getFieldValue('frontend'), + dataLocality: getFieldValue('dataLocality'), + accessMode: getFieldValue('accessMode'), + encrypted: getFieldValue('encrypted') || false, + dataEngine: getFieldValue('dataEngine'), + nodeSelector: getFieldValue('nodeSelector'), + diskSelector: getFieldValue('diskSelector'), + } + setVolumeConfigs(prev => { + const newConfigs = [...prev] + newConfigs.forEach((config, index) => { + if (index !== tabIndex) { + newConfigs.splice(index, 1, { ...config, ...currentConfig }) + } + }) + return newConfigs + }) + message.success(`Successfully apply ${getFieldValue('name')} config to all other cloned volumes`, 5) + } + + const tooltipTitle = `Apply ${getFieldValue('name')} configuration to other cloned volumes, this action will overwrite your previous filled-in configurations` + const allFieldsError = { ...getFieldsError() } + const hasFieldsError = Object.values(allFieldsError).some(fieldError => fieldError !== undefined) || false + + const handleTabClick = (key) => { + if (hasFieldsError) { + message.error('Please correct the error fields before switching to another tab', 5) + return + } + validateFields((errors) => { + if (errors) return errors + }) + + const newIndex = selectedRows.findIndex(i => i.name === key) + + if (newIndex !== -1) { + setTabIndex(newIndex) + const { + name, + numberOfReplicas, + frontend, + dataLocality, + accessMode, + backingImage, + encrypted, + dataEngine, + nodeSelector, + diskSelector } = volumeConfigs[newIndex] || {} + + setFieldsValue({ + name, + size: formatSize(volumeConfigs[newIndex]), + numberOfReplicas, + frontend, + dataLocality, + accessMode, + backingImage, + encrypted, + dataEngine, + nodeSelector, + diskSelector, + }) + } + } + + const handleNameChange = (e) => updateVolumeConfig('name', e.target.value) + const handleReplicasNumberChange = (newNumber) => updateVolumeConfig('numberOfReplicas', newNumber) + const handleFrontendChange = (value) => updateVolumeConfig('frontend', value) + const handleDataLocalityChange = (value) => updateVolumeConfig('dataLocality', value) + const handleAccessModeChange = (value) => updateVolumeConfig('accessMode', value) + const handleEncryptedCheck = (e) => updateVolumeConfig('encrypted', e.target.checked) + const handleDataEngineChange = (value) => updateVolumeConfig('dataEngine', value) + const handleNodeTagRemove = (value) => { + const oldNodeTags = volumeConfigs[tabIndex]?.nodeSelector + const newNodeSelector = oldNodeTags?.filter(tag => tag !== value) || [] + updateVolumeConfig('nodeSelector', newNodeSelector) + } + const handleNodeTagAdd = (value) => { + const oldNodeTags = volumeConfigs[tabIndex]?.nodeSelector + updateVolumeConfig('nodeSelector', [...oldNodeTags, value]) + } + const handleDiskTagRemove = (value) => { + const oldDiskTags = volumeConfigs[tabIndex]?.diskSelector + const newDiskSelector = oldDiskTags?.filter(tag => tag !== value) || [] + updateVolumeConfig('diskSelector', newDiskSelector) + } + const handleDiskTagAdd = (value) => { + const oldDiskTags = volumeConfigs[tabIndex]?.diskSelector + updateVolumeConfig('diskSelector', [...oldDiskTags, value]) + } + + const modalOpts = { + title: 'Clone Volumes', + visible, + onCancel, + width: 880, + onOk: handleOk, + footer: [ + Cancel, + + Apply All + , + + Ok + , + ], + style: { top: 0 }, + } + + const item = volumeConfigs[tabIndex] || {} + const activeKey = item.id // use original volume id as key + + return ( + + + + {selectedRows.map(i => )} + + + + {getFieldDecorator('name', { + initialValue: item.name, + rules: [ + { + required: true, + message: 'Please input volume name', + }, + ], + })()} + + + + {getFieldDecorator('size', { + initialValue: formatSize(item), + rules: [ + { + required: true, + message: 'Please input volume size', + }, { + validator: (_rule, value, callback) => { + if (value === '' || typeof value !== 'number') { + callback() + return + } + if (value < 0 || value > 65536) { + callback('The value should be between 0 and 65535') + } else if (!/^\d+([.]\d{1,2})?$/.test(value)) { + callback('This value should have at most two decimal places') + } else if (value < 10 && getFieldsValue().unit === 'Mi') { + callback('The volume size must be greater than 10 Mi') + } else if (value % 1 !== 0 && getFieldsValue().unit === 'Mi') { + callback('Decimals are not allowed') + } else { + callback() + } + }, + }, + ], + })()} + + + {getFieldDecorator('unit', { + initialValue: item.unit || 'Gi', + rules: [{ required: true, message: 'Please select your unit!' }], + })( + + Mi + Gi + , + )} + + + + {getFieldDecorator('numberOfReplicas', { + initialValue: item.numberOfReplicas, + rules: [ + { + required: true, + message: 'Please input the number of replicas', + }, + { + validator: (_rule, value, callback) => { + if (value === '' || typeof value !== 'number') { + callback() + return + } + if (value < 1 || value > 10) { + callback('The value should be between 1 and 10') + } else if (!/^\d+$/.test(value)) { + callback('The value must be a positive integer') + } else { + callback() + } + }, + }, + ], + })()} + + + {getFieldDecorator('frontend', { + initialValue: item.frontend || frontends[0].value, + rules: [ + { + required: true, + message: 'Please select a frontend', + }, + ], + })( + { frontends.map(opt => {opt.label}) } + )} + + + {getFieldDecorator('dataLocality', { + initialValue: item.dataLocality || defaultDataLocalityValue, + })( + { defaultDataLocalityOption.map(value => {value}) } + )} + + + {getFieldDecorator('accessMode', { + initialValue: item.accessMode || 'rwo', + })( + ReadWriteOnce + ReadWriteMany + )} + + + {getFieldDecorator('backingImage', { + initialValue: item.backingImage || '', + })( + { backingImageOptions.map(backingImage => {backingImage.name}) } + )} + + + {getFieldDecorator('dataSource', { + initialValue: item.id || '', + })()} + + + {getFieldDecorator('dataEngine', { + initialValue: item.dataEngine || 'v1', + rules: [ + { + validator: (_rule, value, callback) => { + if (value === 'v1' && !v1DataEngineEnabled) { + callback('v1 data engine is not enabled') + } else if (value === 'v2' && !v2DataEngineEnabled) { + callback('v2 data engine is not enabled') + } + callback() + }, + }, + ], + })( + v1 + v2 + )} + + + {getFieldDecorator('encrypted', { + valuePropName: 'checked', + initialValue: item.encrypted || false, + })()} + + + + {getFieldDecorator('nodeSelector', { + initialValue: item.nodeSelector || [], + })( + { nodeTags.map(opt => {opt.name}) } + )} + + + + + {getFieldDecorator('diskSelector', { + initialValue: item.diskSelector || [], + })( + { diskTags.map(opt => {opt.name}) } + )} + + + + + ) +} + +modal.propTypes = { + selectedRows: PropTypes.array.isRequired, + form: PropTypes.object.isRequired, + visible: PropTypes.bool, + onCancel: PropTypes.func, + onOk: PropTypes.func, + nodeTags: PropTypes.array, + diskTags: PropTypes.array, + defaultDataLocalityOption: PropTypes.array, + defaultDataLocalityValue: PropTypes.string, + tagsLoading: PropTypes.bool, + v1DataEngineEnabled: PropTypes.bool, + v2DataEngineEnabled: PropTypes.bool, + backingImageOptions: PropTypes.array, +} + +export default Form.create()(modal) diff --git a/src/routes/volume/CloneVolume.js b/src/routes/volume/CloneVolume.js index 2180e3e6..31209271 100644 --- a/src/routes/volume/CloneVolume.js +++ b/src/routes/volume/CloneVolume.js @@ -195,7 +195,7 @@ const modal = ({ {getFieldDecorator('backingImage', { initialValue: volume.backingImage || '', - })( + })( { backingImageOptions.map(backingImage => {backingImage.name}) } )} diff --git a/src/routes/volume/VolumeBulkActions.js b/src/routes/volume/VolumeBulkActions.js index 02491311..d2fdc72e 100644 --- a/src/routes/volume/VolumeBulkActions.js +++ b/src/routes/volume/VolumeBulkActions.js @@ -17,6 +17,7 @@ function bulkActions({ bulkExpandVolume, createPVAndPVC, showBulkDetachHost, + showBulkCloneVolume, commandKeyDown, showUpdateBulkReplicaCount, showUpdateBulkDataLocality, @@ -98,6 +99,9 @@ function bulkActions({ case 'detach': showBulkDetachHost(selectedRows) break + case 'bulkCloneVolume': + showBulkCloneVolume(selectedRows) + break case 'backup': bulkBackup(selectedRows) break @@ -189,6 +193,7 @@ function bulkActions({ { key: 'attach', name: 'Attach', disabled() { return selectedRows.length === 0 || selectedRows.some((item) => !attachable(item)) } }, { key: 'detach', name: 'Detach', disabled() { return selectedRows.length === 0 || selectedRows.some((item) => !detachable(item)) } }, { key: 'backup', name: 'Create Backup', disabled() { return selectedRows.length === 0 || isSnapshotDisabled() || hasDoingState() || isHasStandy() || hasVolumeRestoring() || !backupTargetAvailable }, toolTip: backupTargetMessage }, + { key: 'bulkCloneVolume', name: 'Clone Volume', disabled() { return selectedRows.length === 0 } }, ] const allDropDownActions = [ @@ -252,6 +257,7 @@ bulkActions.propTypes = { bulkBackup: PropTypes.func, createPVAndPVC: PropTypes.func, bulkExpandVolume: PropTypes.func, + showBulkCloneVolume: PropTypes.func, showUpdateBulkReplicaCount: PropTypes.func, showUpdateBulkDataLocality: PropTypes.func, showUpdateBulkAccessMode: PropTypes.func, diff --git a/src/routes/volume/index.js b/src/routes/volume/index.js index e3954a93..02b91bb9 100644 --- a/src/routes/volume/index.js +++ b/src/routes/volume/index.js @@ -6,9 +6,9 @@ import moment from 'moment' import { Row, Col, Button, Modal, Alert } from 'antd' import queryString from 'query-string' import VolumeList from './VolumeList' -import ExpansionVolumeSizeModal from './ExpansionVolumeSizeModal' -import ChangeVolumeModal from './ChangeVolumeModal' +import BulkCloneVolumeModal from './BulkCloneVolumeModal' import BulkChangeVolumeModal from './BulkChangeVolumeModal' +import ChangeVolumeModal from './ChangeVolumeModal' import CreateVolume from './CreateVolume' import CloneVolume from './CloneVolume' import CustomColumn from './CustomColumn' @@ -16,6 +16,7 @@ import CreatePVAndPVC from './CreatePVAndPVC' import CreatePVAndPVCSingle from './CreatePVAndPVCSingle' import CreateBackupModal from './detail/CreateBackupModal' import CommonModal from './components/CommonModal' +import ExpansionVolumeSizeModal from './ExpansionVolumeSizeModal' import WorkloadDetailModal from './WorkloadDetailModal' import RecurringJobModal from './RecurringJobModal' import AttachHost from './AttachHost' @@ -168,6 +169,7 @@ class Volume extends React.Component { previousNamespace, expansionVolumeSizeModalVisible, expansionVolumeSizeModalKey, + bulkCloneVolumeVisible, bulkExpandVolumeModalVisible, bulkExpandVolumeModalKey, updateBulkReplicaCountModalVisible, @@ -907,6 +909,28 @@ class Volume extends React.Component { }, } + const bulkCloneVolumeModalProps = { + selectedRows, + visible: bulkCloneVolumeVisible, + diskTags, + nodeTags, + tagsLoading, + v1DataEngineEnabled, + v2DataEngineEnabled, + defaultDataLocalityOption, + defaultDataLocalityValue, + backingImageOptions, + onOk(params) { + dispatch({ + type: 'volume/bulkCloneVolume', + payload: params, + }) + }, + onCancel() { + dispatch({ type: 'volume/hideBulkCloneVolume' }) + }, + } + const createPVAndPVCProps = { item: defaultNamespace, visible: createPVAndPVCVisible, @@ -1030,6 +1054,14 @@ class Volume extends React.Component { selectedRows: actions, }) }, + showBulkCloneVolume(record) { + dispatch({ + type: 'volume/showBulkCloneVolumeModalBefore', + payload: { + selectedRows: record, + }, + }) + }, bulkExpandVolume(actions) { dispatch({ type: 'volume/showBulkExpandVolumeModal', payload: actions }) }, @@ -1266,6 +1298,7 @@ class Volume extends React.Component { {WorkloadDetailModalVisible ? : ''} {recurringJobModalVisible ? : ''} {changeVolumeModalVisible ? : ''} + {bulkCloneVolumeVisible ? : ''} {bulkChangeVolumeModalVisible ? : ''} {expansionVolumeSizeModalVisible ? : ''} {bulkExpandVolumeModalVisible ? : '' }