EditReplica.jsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529
  1. /*
  2. Copyright (C) 2019 Cloudbase Solutions SRL
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <http://www.gnu.org/licenses/>.
  13. */
  14. // @flow
  15. import React from 'react'
  16. import { observer } from 'mobx-react'
  17. import styled from 'styled-components'
  18. import providerStore, { getFieldChangeOptions } from '../../../stores/ProviderStore'
  19. import replicaStore from '../../../stores/ReplicaStore'
  20. import migrationStore from '../../../stores/MigrationStore'
  21. import endpointStore from '../../../stores/EndpointStore'
  22. import Button from '../../atoms/Button'
  23. import StatusImage from '../../atoms/StatusImage'
  24. import Modal from '../../molecules/Modal'
  25. import Panel from '../../molecules/Panel'
  26. import { isOptionsPageValid } from '../../organisms/WizardPageContent'
  27. import WizardNetworks from '../../organisms/WizardNetworks'
  28. import WizardOptions from '../../organisms/WizardOptions'
  29. import WizardStorage from '../WizardStorage/WizardStorage'
  30. import type { MainItem, UpdateData } from '../../../types/MainItem'
  31. import type { NavigationItem } from '../../molecules/Panel'
  32. import type { Endpoint, StorageBackend, StorageMap } from '../../../types/Endpoint'
  33. import type { Field } from '../../../types/Field'
  34. import type { Instance, Nic, Disk } from '../../../types/Instance'
  35. import type { Network, NetworkMap } from '../../../types/Network'
  36. import { providerTypes } from '../../../constants'
  37. import configLoader from '../../../utils/Config'
  38. import StyleProps from '../../styleUtils/StyleProps'
  39. const PanelContent = styled.div`
  40. padding: 32px;
  41. display: flex;
  42. flex-direction: column;
  43. justify-content: space-between;
  44. flex-grow: 1;
  45. min-height: 0;
  46. `
  47. const LoadingWrapper = styled.div`
  48. display: flex;
  49. flex-direction: column;
  50. align-items: center;
  51. margin: 32px 0;
  52. `
  53. const LoadingText = styled.div`
  54. font-size: 18px;
  55. margin-top: 32px;
  56. `
  57. const Buttons = styled.div`
  58. margin-top: 32px;
  59. display: flex;
  60. flex-shrink: 0;
  61. justify-content: space-between;
  62. `
  63. type Props = {
  64. type?: 'replica' | 'migration',
  65. isOpen: boolean,
  66. onRequestClose: () => void,
  67. onUpdateComplete: (redirectTo: string) => void,
  68. replica: MainItem,
  69. destinationEndpoint: Endpoint,
  70. sourceEndpoint: Endpoint,
  71. instancesDetails: Instance[],
  72. instancesDetailsLoading: boolean,
  73. networks: Network[],
  74. networksLoading: boolean,
  75. onReloadClick: () => void,
  76. }
  77. type State = {
  78. selectedPanel: ?string,
  79. destinationData: any,
  80. sourceData: any,
  81. updateDisabled: boolean,
  82. selectedNetworks: NetworkMap[],
  83. storageMap: StorageMap[],
  84. }
  85. @observer
  86. class EditReplica extends React.Component<Props, State> {
  87. state = {
  88. selectedPanel: null,
  89. destinationData: {},
  90. sourceData: {},
  91. updateDisabled: false,
  92. selectedNetworks: [],
  93. storageMap: [],
  94. }
  95. scrollableRef: HTMLElement
  96. componentWillMount() {
  97. this.loadData(true)
  98. this.setState({ selectedPanel: this.hasSourceOptions() ? 'source_options' : 'dest_options' })
  99. }
  100. async loadData(useCache: boolean) {
  101. await providerStore.loadProviders()
  102. if (this.hasStorageMap()) {
  103. endpointStore.loadStorage(this.props.destinationEndpoint.id, {})
  104. }
  105. this.loadDestinationOptions(useCache)
  106. if (!this.hasSourceOptions()) {
  107. return
  108. }
  109. this.loadOptions(this.props.sourceEndpoint, 'source', useCache)
  110. }
  111. async loadDestinationOptions(useCache: boolean) {
  112. await this.loadOptions(this.props.destinationEndpoint, 'destination', useCache)
  113. this.loadEnvDestinationOptions()
  114. }
  115. async loadOptions(endpoint: Endpoint, optionsType: 'source' | 'destination', useCache: boolean) {
  116. await providerStore.loadOptionsSchema({
  117. providerName: endpoint.type,
  118. schemaType: this.props.type || 'replica',
  119. optionsType,
  120. useCache,
  121. })
  122. await providerStore.getOptionsValues({
  123. optionsType,
  124. endpointId: endpoint.id,
  125. providerName: endpoint.type,
  126. useCache,
  127. })
  128. }
  129. hasStorageMap(): boolean {
  130. return providerStore.providers && providerStore.providers[this.props.destinationEndpoint.type] ?
  131. !!providerStore.providers[this.props.destinationEndpoint.type].types.find(t => t === providerTypes.STORAGE)
  132. : false
  133. }
  134. hasSourceOptions(): boolean {
  135. return Boolean(configLoader.config.sourceOptionsProviders.find(p => p === this.props.sourceEndpoint.type))
  136. }
  137. isUpdateDisabled() {
  138. let isLoadingDestOptions = this.state.selectedPanel === 'dest_options'
  139. && (providerStore.destinationSchemaLoading || providerStore.destinationOptionsLoading)
  140. let isLoadingSourceOptions = this.state.selectedPanel === 'source_options'
  141. && (providerStore.sourceSchemaLoading || providerStore.sourceOptionsLoading)
  142. let isLoadingNetwork = this.state.selectedPanel === 'network_mapping' && this.props.instancesDetailsLoading
  143. let isLoadingStorage = this.state.selectedPanel === 'storage_mapping'
  144. && (this.props.instancesDetailsLoading || endpointStore.storageLoading)
  145. return this.state.updateDisabled || isLoadingSourceOptions || isLoadingDestOptions || isLoadingNetwork || isLoadingStorage
  146. }
  147. parseReplicaData(environment: ?{ [string]: mixed }) {
  148. let data = {}
  149. let env = environment
  150. if (!env) {
  151. return data
  152. }
  153. Object.keys(env).forEach(key => {
  154. if (env[key] && typeof env[key] === 'object' && !Array.isArray(JSON.parse(JSON.stringify(env[key])))) {
  155. Object.keys(env[key]).forEach(subkey => {
  156. let destParent: any = env[key]
  157. if (destParent[subkey]) {
  158. data[`${key}/${subkey}`] = destParent[subkey]
  159. }
  160. })
  161. } else {
  162. data[key] = env[key]
  163. }
  164. })
  165. return data
  166. }
  167. loadEnvDestinationOptions(field?: Field) {
  168. let envData = getFieldChangeOptions({
  169. providerName: this.props.destinationEndpoint.type,
  170. schema: providerStore.destinationSchema,
  171. data: {
  172. ...this.parseReplicaData(this.props.replica.destination_environment),
  173. ...this.state.destinationData,
  174. },
  175. field,
  176. type: 'destination',
  177. })
  178. if (envData) {
  179. providerStore.getOptionsValues({
  180. optionsType: 'destination',
  181. endpointId: this.props.destinationEndpoint.id,
  182. providerName: this.props.destinationEndpoint.type,
  183. useCache: true,
  184. envData,
  185. })
  186. }
  187. }
  188. validateOptions(type: 'source' | 'destination') {
  189. let env = type === 'source' ? this.props.replica.source_environment : this.props.replica.destination_environment
  190. let data = type === 'source' ? this.state.sourceData : this.state.destinationData
  191. let schema = type === 'source' ? providerStore.sourceSchema : providerStore.destinationSchema
  192. let isValid = isOptionsPageValid({
  193. ...this.parseReplicaData(env),
  194. ...data,
  195. }, schema)
  196. this.setState({ updateDisabled: !isValid })
  197. }
  198. handlePanelChange(panel: string) {
  199. this.setState({ selectedPanel: panel })
  200. }
  201. handleReload() {
  202. this.props.onReloadClick()
  203. this.loadData(false)
  204. }
  205. handleFieldChange(type: 'source' | 'destination', field: Field, value: any) {
  206. let data = type === 'source' ? { ...this.state.sourceData } : { ...this.state.destinationData }
  207. if (field.type === 'array') {
  208. let oldValues: string[] = data[field.name] || []
  209. if (oldValues.find(v => v === value)) {
  210. data[field.name] = oldValues.filter(v => v !== value)
  211. } else {
  212. data[field.name] = [...oldValues, value]
  213. }
  214. } else {
  215. data[field.name] = value
  216. }
  217. if (type === 'source') {
  218. this.setState({ sourceData: data }, () => {
  219. this.validateOptions('source')
  220. })
  221. } else {
  222. this.setState({ destinationData: data }, () => {
  223. if (field.type !== 'string' || field.enum) {
  224. this.loadEnvDestinationOptions(field)
  225. }
  226. this.validateOptions('destination')
  227. })
  228. }
  229. }
  230. async handleUpdateClick() {
  231. this.setState({ updateDisabled: true })
  232. let updateData: UpdateData = {
  233. source: this.state.sourceData,
  234. destination: this.state.destinationData,
  235. network: this.state.selectedNetworks.length > 0 ? this.getSelectedNetworks() : [],
  236. storage: this.state.storageMap,
  237. }
  238. if (this.props.type === 'replica') {
  239. let storageConfigDefault = this.getFieldValue('destination', 'default_storage') || endpointStore.storageConfigDefault
  240. try {
  241. await replicaStore.update(this.props.replica, this.props.destinationEndpoint, updateData, storageConfigDefault)
  242. this.props.onRequestClose()
  243. this.props.onUpdateComplete(`/replica/executions/${this.props.replica.id}`)
  244. } catch (err) {
  245. this.setState({ updateDisabled: false })
  246. }
  247. } else {
  248. try {
  249. let migration: MainItem = await migrationStore.recreate(this.props.replica, this.props.sourceEndpoint, this.props.destinationEndpoint, updateData)
  250. migrationStore.clearDetails()
  251. this.props.onRequestClose()
  252. this.props.onUpdateComplete(`/migration/tasks/${migration.id}`)
  253. } catch (err) {
  254. this.setState({ updateDisabled: false })
  255. }
  256. }
  257. }
  258. handleNetworkChange(sourceNic: Nic, targetNetwork: Network) {
  259. let networkMap = this.state.selectedNetworks.filter(n => n.sourceNic.network_name !== sourceNic.network_name)
  260. this.setState({
  261. selectedNetworks: [...networkMap, { sourceNic, targetNetwork }],
  262. })
  263. }
  264. handleStorageChange(source: Disk, target: StorageBackend, type: 'backend' | 'disk') {
  265. let diskFieldName = type === 'backend' ? 'storage_backend_identifier' : 'id'
  266. let storageMap = this.state.storageMap
  267. .filter(n => n.type !== type || n.source[diskFieldName] !== source[diskFieldName])
  268. storageMap.push({ source, target, type })
  269. this.setState({ storageMap })
  270. }
  271. getFieldValue(type: 'source' | 'destination', fieldName: string, defaultValue: any) {
  272. let currentData = type === 'source' ? this.state.sourceData : this.state.destinationData
  273. if (currentData[fieldName] === undefined) {
  274. let replicaData = this.parseReplicaData(type === 'source' ? this.props.replica.source_environment
  275. : this.props.replica.destination_environment)
  276. if (replicaData[fieldName] !== undefined) {
  277. return replicaData[fieldName]
  278. }
  279. let osMapping = /^(windows|linux)_os_image$/.exec(fieldName)
  280. if (osMapping) {
  281. let osData = replicaData[`migr_image_map/${osMapping[1]}`]
  282. return osData
  283. }
  284. return defaultValue
  285. }
  286. return currentData[fieldName]
  287. }
  288. getSelectedNetworks(): NetworkMap[] {
  289. let selectedNetworks: NetworkMap[] = []
  290. let networkMap = this.props.replica.network_map
  291. if (networkMap) {
  292. Object.keys(networkMap).forEach(sourceNetworkName => {
  293. let network = this.props.networks.find(n => n.name === networkMap[sourceNetworkName] || n.id === networkMap[sourceNetworkName])
  294. if (!network) {
  295. return
  296. }
  297. selectedNetworks.push({
  298. sourceNic: { id: '', network_name: sourceNetworkName, mac_address: '', network_id: '' },
  299. targetNetwork: network,
  300. })
  301. })
  302. }
  303. selectedNetworks = selectedNetworks.map(mapping => {
  304. let updatedMapping = this.state.selectedNetworks.find(m => m.sourceNic.network_name === mapping.sourceNic.network_name)
  305. return updatedMapping || mapping
  306. })
  307. return selectedNetworks
  308. }
  309. getStorageMap(): StorageMap[] {
  310. let storageMap: StorageMap[] = []
  311. let currentStorage = this.props.replica.storage_mappings || {}
  312. let buildStorageMap = (type: 'backend' | 'disk', mapping: any) => {
  313. return {
  314. type,
  315. source: { storage_backend_identifier: mapping.source, id: mapping.disk_id },
  316. target: { name: mapping.destination, id: mapping.destination },
  317. }
  318. }
  319. let backendMappings = currentStorage.backend_mappings || []
  320. backendMappings.forEach(mapping => {
  321. storageMap.push(buildStorageMap('backend', mapping))
  322. })
  323. let diskMappings = currentStorage.disk_mappings || []
  324. diskMappings.forEach(mapping => {
  325. storageMap.push(buildStorageMap('disk', mapping))
  326. })
  327. this.state.storageMap.forEach(mapping => {
  328. let fieldName = mapping.type === 'backend' ? 'storage_backend_identifier' : 'id'
  329. let existingMapping = storageMap.find(m => m.type === mapping.type &&
  330. m.source[fieldName] === String(mapping.source[fieldName])
  331. )
  332. if (existingMapping) {
  333. existingMapping.target = mapping.target
  334. } else {
  335. storageMap.push(mapping)
  336. }
  337. })
  338. return storageMap
  339. }
  340. renderOptions(type: 'source' | 'destination') {
  341. let loading = type === 'source' ? (providerStore.sourceSchemaLoading || providerStore.sourceOptionsLoading) :
  342. providerStore.destinationSchemaLoading || providerStore.destinationOptionsLoading
  343. if (loading) {
  344. return this.renderLoading(`Loading ${type === 'source' ? 'source' : 'target'} options ...`)
  345. }
  346. let schema = type === 'source' ? providerStore.sourceSchema : providerStore.destinationSchema
  347. let fields = this.props.type === 'replica' ? schema.filter(f => !f.readOnly) : schema
  348. return (
  349. <WizardOptions
  350. wizardType={`replica-${type}-options-edit`}
  351. getFieldValue={(f, d) => this.getFieldValue(type, f, d)}
  352. fields={fields}
  353. hasStorageMap={type === 'source' ? false : this.hasStorageMap()}
  354. storageBackends={endpointStore.storageBackends}
  355. storageConfigDefault={endpointStore.storageConfigDefault}
  356. onChange={(f, v) => { this.handleFieldChange(type, f, v) }}
  357. oneColumnStyle={{ marginTop: '-16px', display: 'flex', flexDirection: 'column', width: '100%', alignItems: 'center' }}
  358. columnStyle={{ marginRight: 0 }}
  359. fieldWidth={StyleProps.inputSizes.large.width}
  360. onScrollableRef={ref => { this.scrollableRef = ref }}
  361. availableHeight={384}
  362. useAdvancedOptions
  363. layout="modal"
  364. />
  365. )
  366. }
  367. renderStorageMapping() {
  368. if (this.props.instancesDetailsLoading) {
  369. return this.renderLoading('Loading instances details ...')
  370. }
  371. if (endpointStore.storageLoading) {
  372. return this.renderLoading('Loading storage ...')
  373. }
  374. return (
  375. <WizardStorage
  376. storageBackends={endpointStore.storageBackends}
  377. instancesDetails={this.props.instancesDetails}
  378. storageMap={this.getStorageMap()}
  379. onChange={(s, t, type) => { this.handleStorageChange(s, t, type) }}
  380. />
  381. )
  382. }
  383. renderNetworkMapping() {
  384. return (
  385. <WizardNetworks
  386. instancesDetails={this.props.instancesDetails}
  387. loadingInstancesDetails={this.props.instancesDetailsLoading}
  388. networks={this.props.networks}
  389. loading={this.props.networksLoading}
  390. onChange={(nic, network) => { this.handleNetworkChange(nic, network) }}
  391. selectedNetworks={this.getSelectedNetworks()}
  392. />
  393. )
  394. }
  395. renderContent() {
  396. let content = null
  397. switch (this.state.selectedPanel) {
  398. case 'source_options':
  399. content = this.renderOptions('source')
  400. break
  401. case 'dest_options':
  402. content = this.renderOptions('destination')
  403. break
  404. case 'network_mapping':
  405. content = this.renderNetworkMapping()
  406. break
  407. case 'storage_mapping':
  408. content = this.renderStorageMapping()
  409. break
  410. default:
  411. content = null
  412. }
  413. return (
  414. <PanelContent>
  415. {content}
  416. <Buttons>
  417. <Button
  418. large
  419. onClick={this.props.onRequestClose}
  420. secondary
  421. >Cancel</Button>
  422. <Button
  423. large
  424. onClick={() => { this.handleUpdateClick() }}
  425. disabled={this.isUpdateDisabled()}
  426. >
  427. {this.props.type === 'replica' ? 'Update' : 'Create'}
  428. </Button>
  429. </Buttons>
  430. </PanelContent>
  431. )
  432. }
  433. renderLoading(message: string) {
  434. let loadingMessage = message || 'Loading ...'
  435. return (
  436. <LoadingWrapper>
  437. <StatusImage loading />
  438. <LoadingText>{loadingMessage}</LoadingText>
  439. </LoadingWrapper>
  440. )
  441. }
  442. render() {
  443. let navigationItems: NavigationItem[] = [
  444. { value: 'dest_options', label: 'Target Options' },
  445. { value: 'network_mapping', label: 'Network Mapping' },
  446. ]
  447. if (this.hasStorageMap()) {
  448. navigationItems.push({ value: 'storage_mapping', label: 'Storage Mapping' })
  449. }
  450. if (this.hasSourceOptions()) {
  451. navigationItems.splice(0, 0, { value: 'source_options', label: 'Source Options' })
  452. }
  453. return (
  454. <Modal
  455. isOpen={this.props.isOpen}
  456. title={`${this.props.type === 'replica' ? 'Edit Replica' : 'Recreate Migration'}`}
  457. onRequestClose={this.props.onRequestClose}
  458. contentStyle={{ width: '800px' }}
  459. onScrollableRef={() => this.scrollableRef}
  460. fixedHeight={512}
  461. >
  462. <Panel
  463. navigationItems={navigationItems}
  464. content={this.renderContent()}
  465. onChange={navItem => { this.handlePanelChange(navItem.value) }}
  466. selectedValue={this.state.selectedPanel}
  467. onReloadClick={() => { this.handleReload() }}
  468. />
  469. </Modal>
  470. )
  471. }
  472. }
  473. export default EditReplica