From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by lists.proxmox.com (Postfix) with ESMTPS id 6362C7293F for ; Tue, 13 Apr 2021 12:14:29 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 5890F296C8 for ; Tue, 13 Apr 2021 12:14:29 +0200 (CEST) Received: from proxmox-new.maurer-it.com (proxmox-new.maurer-it.com [212.186.127.180]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by firstgate.proxmox.com (Proxmox) with ESMTPS id A48C1296BB for ; Tue, 13 Apr 2021 12:14:25 +0200 (CEST) Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1]) by proxmox-new.maurer-it.com (Proxmox) with ESMTP id 65B9645A41 for ; Tue, 13 Apr 2021 12:14:25 +0200 (CEST) Date: Tue, 13 Apr 2021 12:11:20 +0200 From: Oguz Bektas To: Proxmox VE development discussion Message-ID: <20210413101120.GA14877@gaia.proxmox.com> Mail-Followup-To: Oguz Bektas , Proxmox VE development discussion References: <20210412100825.133698-1-d.jaeger@proxmox.com> <20210412100825.133698-2-d.jaeger@proxmox.com> MIME-Version: 1.0 Content-Type: text/plain; charset=iso-8859-1 Content-Disposition: inline Content-Transfer-Encoding: 8bit In-Reply-To: <20210412100825.133698-2-d.jaeger@proxmox.com> User-Agent: Mutt/1.10.1 (2018-07-13) X-SPAM-LEVEL: Spam detection results: 0 AWL 1.464 Adjusted score from AWL reputation of From: address KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record URIBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to URIBL was blocked. See http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more information. [nodes.pm, proxmox.com] Subject: Re: [pve-devel] [PATCH] Add GUI to import disk & VM X-BeenThere: pve-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox VE development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , X-List-Received-Date: Tue, 13 Apr 2021 10:14:29 -0000 hi, tested along with the qemu-server patch, it seems to work but had some issues in some cases (especially with windows VMs) we discussed already off-list with dominic during the testing, but i'll just put these down here anyway: - the "Next" button should be grayed out until remote manifest is loaded, or alternatively attempt to load manifest when next button is pressed (instead of having a separate button) - in the disk menu, pressing the "+" adds a disk but doesn't increment the id, which can lead to having multiple disks with the same name - windows VMs need some special treatment when setting config options: -> machine: q35 -> use sata disk instead of ide -> also UEFI can be made default? in my tests the windows 10 installation didn't boot until adding a EFI disk and changing the BIOS to OVMF but besides these, it looks okay to me, and works as advertised :) On Mon, Apr 12, 2021 at 12:08:25PM +0200, Dominic Jäger wrote: > Add GUI wizard to import whole VMs and a window to import single disks in > Hardware View. > > Signed-off-by: Dominic Jäger > --- > v8: > - Adapt to new API > - Some small fixes > - Much renaming > > PVE/API2/Nodes.pm | 7 + > www/manager6/Makefile | 2 + > www/manager6/Workspace.js | 15 ++ > www/manager6/form/ControllerSelector.js | 15 ++ > www/manager6/node/CmdMenu.js | 13 + > www/manager6/qemu/HDEdit.js | 149 ++++++++++- > www/manager6/qemu/HDEditCollection.js | 263 ++++++++++++++++++++ > www/manager6/qemu/HardwareView.js | 24 ++ > www/manager6/qemu/ImportWizard.js | 317 ++++++++++++++++++++++++ > www/manager6/window/Wizard.js | 2 + > 10 files changed, 795 insertions(+), 12 deletions(-) > create mode 100644 www/manager6/qemu/HDEditCollection.js > create mode 100644 www/manager6/qemu/ImportWizard.js > > diff --git a/PVE/API2/Nodes.pm b/PVE/API2/Nodes.pm > index ba6621c6..1cee6cb5 100644 > --- a/PVE/API2/Nodes.pm > +++ b/PVE/API2/Nodes.pm > @@ -48,6 +48,7 @@ use PVE::API2::LXC; > use PVE::API2::Network; > use PVE::API2::NodeConfig; > use PVE::API2::Qemu::CPU; > +use PVE::API2::Qemu::OVF; > use PVE::API2::Qemu; > use PVE::API2::Replication; > use PVE::API2::Services; > @@ -76,6 +77,11 @@ __PACKAGE__->register_method ({ > path => 'cpu', > }); > > +__PACKAGE__->register_method ({ > + subclass => "PVE::API2::Qemu::OVF", > + path => 'readovf', > +}); > + > __PACKAGE__->register_method ({ > subclass => "PVE::API2::LXC", > path => 'lxc', > @@ -2183,6 +2189,7 @@ __PACKAGE__->register_method ({ > return undef; > }}); > > + > # bash completion helper > > sub complete_templet_repo { > diff --git a/www/manager6/Makefile b/www/manager6/Makefile > index a2f7be6d..dbb85062 100644 > --- a/www/manager6/Makefile > +++ b/www/manager6/Makefile > @@ -196,8 +196,10 @@ JSSRC= \ > qemu/CmdMenu.js \ > qemu/Config.js \ > qemu/CreateWizard.js \ > + qemu/ImportWizard.js \ > qemu/DisplayEdit.js \ > qemu/HDEdit.js \ > + qemu/HDEditCollection.js \ > qemu/HDEfi.js \ > qemu/HDMove.js \ > qemu/HDResize.js \ > diff --git a/www/manager6/Workspace.js b/www/manager6/Workspace.js > index 0c1b9e0c..631739a0 100644 > --- a/www/manager6/Workspace.js > +++ b/www/manager6/Workspace.js > @@ -280,11 +280,25 @@ Ext.define('PVE.StdWorkspace', { > }, > }); > > + var importVM = Ext.createWidget('button', { > + pack: 'end', > + margin: '3 5 0 0', > + baseCls: 'x-btn', > + iconCls: 'fa fa-desktop', > + text: gettext("Import VM"), > + hidden: Proxmox.UserName !== 'root@pam', > + handler: function() { > + var wiz = Ext.create('PVE.qemu.ImportWizard', {}); > + wiz.show(); > + }, > + }); > + > sprovider.on('statechange', function(sp, key, value) { > if (key === 'GuiCap' && value) { > caps = value; > createVM.setDisabled(!caps.vms['VM.Allocate']); > createCT.setDisabled(!caps.vms['VM.Allocate']); > + importVM.setDisabled(!caps.vms['VM.Allocate']); > } > }); > > @@ -332,6 +346,7 @@ Ext.define('PVE.StdWorkspace', { > }, > createVM, > createCT, > + importVM, > { > pack: 'end', > margin: '0 5 0 0', > diff --git a/www/manager6/form/ControllerSelector.js b/www/manager6/form/ControllerSelector.js > index 23c61159..f515b220 100644 > --- a/www/manager6/form/ControllerSelector.js > +++ b/www/manager6/form/ControllerSelector.js > @@ -68,6 +68,21 @@ clist_loop: > deviceid.validate(); > }, > > + getValues: function() { > + return this.query('field').map(x => x.getValue()); > + }, > + > + getValuesAsString: function() { > + return this.getValues().join(''); > + }, > + > + setValue: function(value) { > + const regex = /([a-z]+)(\d+)/; > + const [_, controller, deviceid] = regex.exec(value); > + this.down('field[name=controller]').setValue(controller); > + this.down('field[name=deviceid]').setValue(deviceid); > + }, > + > initComponent: function() { > var me = this; > > diff --git a/www/manager6/node/CmdMenu.js b/www/manager6/node/CmdMenu.js > index b650bfa0..b66c7a6e 100644 > --- a/www/manager6/node/CmdMenu.js > +++ b/www/manager6/node/CmdMenu.js > @@ -29,6 +29,19 @@ Ext.define('PVE.node.CmdMenu', { > wiz.show(); > }, > }, > + { > + text: gettext("Import VM"), > + hidden: Proxmox.UserName !== 'root@pam', > + itemId: 'importvm', > + iconCls: 'fa fa-cube', > + handler: function() { > + const me = this.up('menu'); > + const wiz = Ext.create('PVE.qemu.ImportWizard', { > + nodename: me.nodename, > + }); > + wiz.show(); > + }, > + }, > { xtype: 'menuseparator' }, > { > text: gettext('Bulk Start'), > diff --git a/www/manager6/qemu/HDEdit.js b/www/manager6/qemu/HDEdit.js > index e22111bf..3af7e624 100644 > --- a/www/manager6/qemu/HDEdit.js > +++ b/www/manager6/qemu/HDEdit.js > @@ -58,6 +58,17 @@ Ext.define('PVE.qemu.HDInputPanel', { > }, > }, > > + /* > + All radiofields (esp. sourceRadioPath and sourceRadioStorage) have the > + same scope for name. But we need a different scope for each HDInputPanel in > + a HDInputPanelCollection to get the selection for each HDInputPanel => Make > + names so that those within one HDInputPanel are equal, but different from other > + HDInputPanels > + */ > + getSourceTypeID() { > + return 'sourceType_' + this.id; > + }, > + > onGetValues: function(values) { > var me = this; > > @@ -70,6 +81,8 @@ Ext.define('PVE.qemu.HDInputPanel', { > } else if (me.isCreate) { > if (values.hdimage) { > me.drive.file = values.hdimage; > + } else if (me.isImport) { > + me.drive.file = `${values.hdstorage}:-1`; > } else { > me.drive.file = values.hdstorage + ":" + values.disksize; > } > @@ -83,13 +96,21 @@ Ext.define('PVE.qemu.HDInputPanel', { > PVE.Utils.propertyStringSet(me.drive, values.iothread, 'iothread', 'on'); > PVE.Utils.propertyStringSet(me.drive, values.cache, 'cache'); > > - var names = ['mbps_rd', 'mbps_wr', 'iops_rd', 'iops_wr']; > - Ext.Array.each(names, function(name) { > - var burst_name = name + '_max'; > + var names = ['mbps_rd', 'mbps_wr', 'iops_rd', 'iops_wr']; > + Ext.Array.each(names, function(name) { > + var burst_name = name + '_max'; > PVE.Utils.propertyStringSet(me.drive, values[name], name); > PVE.Utils.propertyStringSet(me.drive, values[burst_name], burst_name); > - }); > + }); > > + const getSourceImageLocation = function() { > + const type = values[me.getSourceTypeID()]; > + return type === 'storage' ? values.sourceVolid : values.sourcePath; > + }; > + > + if (me.isImport) { > + params.import_sources = `${confid}=${getSourceImageLocation()}`; > + } > > params[confid] = PVE.Parser.printQemuDrive(me.drive); > > @@ -149,6 +170,10 @@ Ext.define('PVE.qemu.HDInputPanel', { > me.setValues(values); > }, > > + getDevice: function() { > + return this.bussel.getValuesAsString(); > + }, > + > setNodename: function(nodename) { > var me = this; > me.down('#hdstorage').setNodename(nodename); > @@ -169,10 +194,15 @@ Ext.define('PVE.qemu.HDInputPanel', { > me.advancedColumn2 = []; > > if (!me.confid || me.unused) { > + let controllerColumn = me.isImport ? me.column2 : me.column1; > me.bussel = Ext.create('PVE.form.ControllerSelector', { > + itemId: 'bussel', > vmconfig: me.insideWizard ? { ide2: 'cdrom' } : {}, > }); > - me.column1.push(me.bussel); > + if (me.isImport) { > + me.bussel.fieldLabel = 'Target Device'; > + } > + controllerColumn.push(me.bussel); > > me.scsiController = Ext.create('Ext.form.field.Display', { > fieldLabel: gettext('SCSI Controller'), > @@ -184,7 +214,7 @@ Ext.define('PVE.qemu.HDInputPanel', { > submitValue: false, > hidden: true, > }); > - me.column1.push(me.scsiController); > + controllerColumn.push(me.scsiController); > } > > if (me.unused) { > @@ -199,14 +229,21 @@ Ext.define('PVE.qemu.HDInputPanel', { > allowBlank: false, > }); > me.column1.push(me.unusedDisks); > - } else if (me.isCreate) { > - me.column1.push({ > + } else if (me.isCreate || me.isImport) { > + let selector = { > xtype: 'pveDiskStorageSelector', > storageContent: 'images', > name: 'disk', > nodename: me.nodename, > - autoSelect: me.insideWizard, > - }); > + hideSize: me.isImport, > + autoSelect: me.insideWizard || me.isImport, > + }; > + if (me.isImport) { > + selector.storageLabel = gettext('Target storage'); > + me.column2.push(selector); > + } else { > + me.column1.push(selector); > + } > } else { > me.column1.push({ > xtype: 'textfield', > @@ -217,6 +254,12 @@ Ext.define('PVE.qemu.HDInputPanel', { > }); > } > > + if (me.isImport) { > + me.column2.push({ > + xtype: 'box', > + autoEl: { tag: 'hr' }, > + }); > + } > me.column2.push( > { > xtype: 'CacheTypeSelector', > @@ -231,6 +274,84 @@ Ext.define('PVE.qemu.HDInputPanel', { > name: 'discard', > }, > ); > + if (me.isImport) { > + me.column1.unshift( > + { > + xtype: 'radiofield', > + itemId: 'sourceRadioStorage', > + name: me.getSourceTypeID(), > + inputValue: 'storage', > + boxLabel: gettext('Use a storage as source'), > + hidden: Proxmox.UserName !== 'root@pam', > + checked: true, > + listeners: { > + change: (_, newValue) => { > + me.down('#sourceStorageSelector').setHidden(!newValue); > + me.down('#sourceStorageSelector').setDisabled(!newValue); > + me.down('#sourceFileSelector').setHidden(!newValue); > + me.down('#sourceFileSelector').setDisabled(!newValue); > + }, > + }, > + }, { > + xtype: 'pveStorageSelector', > + itemId: 'sourceStorageSelector', > + name: 'inputImageStorage', > + nodename: me.nodename, > + fieldLabel: gettext('Source Storage'), > + storageContent: 'images', > + autoSelect: me.insideWizard, > + hidden: true, > + disabled: true, > + listeners: { > + change: function(_, selectedStorage) { > + me.down('#sourceFileSelector').setStorage(selectedStorage); > + }, > + }, > + }, { > + xtype: 'pveFileSelector', > + itemId: 'sourceFileSelector', > + name: 'sourceVolid', > + nodename: me.nodename, > + storageContent: 'images', > + hidden: true, > + disabled: true, > + fieldLabel: gettext('Source Image'), > + autoEl: { > + tag: 'div', > + 'data-qtip': gettext("Place your source images into a new folder /images/, for example /var/lib/vz/images/999"), > + }, > + }, { > + xtype: 'radiofield', > + itemId: 'sourceRadioPath', > + name: me.getSourceTypeID(), > + inputValue: 'path', > + boxLabel: gettext('Use an absolute path as source'), > + hidden: Proxmox.UserName !== 'root@pam', > + listeners: { > + change: (_, newValue) => { > + me.down('#sourcePathTextfield').setHidden(!newValue); > + me.down('#sourcePathTextfield').setDisabled(!newValue); > + }, > + }, > + }, { > + xtype: 'textfield', > + itemId: 'sourcePathTextfield', > + fieldLabel: gettext('Source Path'), > + name: 'sourcePath', > + autoEl: { > + tag: 'div', > + 'data-qtip': gettext('Absolute path to the source disk image, for example: /home/user/somedisk.qcow2'), > + }, > + hidden: true, > + disabled: true, > + validator: function(insertedText) { > + return insertedText.startsWith('/') || > + insertedText.startsWith('http') || > + gettext('Must be an absolute path or URL'); > + }, > + }, > + ); > + } > > me.advancedColumn1.push( > { > @@ -373,13 +494,17 @@ Ext.define('PVE.qemu.HDEdit', { > nodename: nodename, > unused: unused, > isCreate: me.isCreate, > + isImport: me.isImport, > }); > > - var subject; > if (unused) { > me.subject = gettext('Unused Disk'); > + } else if (me.isImport) { > + me.subject = gettext('Import Disk'); > + me.submitText = 'Import'; > + me.backgroundDelay = undefined; > } else if (me.isCreate) { > - me.subject = gettext('Hard Disk'); > + me.subject = gettext('Hard Disk'); > } else { > me.subject = gettext('Hard Disk') + ' (' + me.confid + ')'; > } > diff --git a/www/manager6/qemu/HDEditCollection.js b/www/manager6/qemu/HDEditCollection.js > new file mode 100644 > index 00000000..33f6193a > --- /dev/null > +++ b/www/manager6/qemu/HDEditCollection.js > @@ -0,0 +1,263 @@ > +Ext.define('PVE.qemu.HDInputPanelCollection', { > + extend: 'Proxmox.panel.InputPanel', > + alias: 'widget.pveQemuHDInputPanelCollection', > + > + insideWizard: false, > + > + hiddenDisks: [], > + > + leftColumnRatio: 0.25, > + > + column1: [ > + { > + // Adding to the panelContainer below automatically adds > + // items to this store > + xtype: 'gridpanel', > + scrollable: true, > + store: { > + xtype: 'store', > + storeId: 'importwizard_diskstorage', > + // Use the panel as id > + // Panels have are objects and therefore unique > + // E.g. while adding new panels 'device' is ambiguous > + fields: ['device', 'panel'], > + removeByPanel: function(panel) { > + const recordIndex = this.findBy(record => record.data.panel === panel); > + this.removeAt(recordIndex); > + return recordIndex; > + }, > + getLast: function() { > + const last = this.getCount() - 1; > + return this.getAt(last); > + }, > + }, > + columns: [ > + { > + text: gettext('Target device'), > + dataIndex: 'device', > + flex: 1, > + resizable: false, > + }, > + ], > + listeners: { > + select: function(_, record) { > + this.up('pveQemuHDInputPanelCollection') > + .down('#panelContainer') > + .setActiveItem(record.data.panel); > + }, > + }, > + anchor: '100% 90%', > + selectLast: function() { > + this.setSelection(this.store.getLast()); > + }, > + }, { > + xtype: 'container', > + layout: 'hbox', > + center: true, > + defaults: { > + margin: '5', > + xtype: 'button', > + }, > + items: [ > + { > + iconCls: 'fa fa-plus-circle', > + itemId: 'addDisk', > + handler: function(button) { > + button.up('pveQemuHDInputPanelCollection').addDisk(); > + }, > + }, { > + iconCls: 'fa fa-trash-o', > + itemId: 'removeDisk', > + handler: function(button) { > + button.up('pveQemuHDInputPanelCollection').removeCurrentDisk(); > + }, > + }, > + ], > + }, > + ], > + column2: [ > + { > + itemId: 'panelContainer', > + xtype: 'container', > + layout: 'card', > + items: [], > + listeners: { > + beforeRender: function() { > + // Initial disk if none have been added by manifest yet > + if (this.items.items.length === 0) { > + this.addDisk(); > + } > + }, > + add: function(container, newPanel) { > + const store = Ext.getStore('importwizard_diskstorage'); > + store.add({ device: newPanel.getDevice(), panel: newPanel }); > + container.setActiveItem(newPanel); > + }, > + remove: function(panelContainer, HDInputPanel, eOpts) { > + const store = Ext.getStore('importwizard_diskstorage'); > + store.removeByPanel(HDInputPanel); > + if (panelContainer.items.getCount() > 0) { > + panelContainer.setActiveItem(0); > + } > + }, > + }, > + defaultItem: { > + xtype: 'pveQemuHDInputPanel', > + bind: { > + nodename: '{nodename}', > + }, > + isCreate: true, > + isImport: true, > + insideWizard: true, > + setNodename: function(nodename) { > + this.down('#hdstorage').setNodename(nodename); > + this.down('#hdimage').setStorage(undefined, nodename); > + this.down('#sourceStorageSelector').setNodename(nodename); > + this.down('#sourceFileSelector').setNodename(nodename); > + }, > + listeners: { > + // newPanel ... this cloned + added defaultItem > + added: function(newPanel) { > + Ext.Array.each(newPanel.down('pveControllerSelector').query('field'), > + function(field) { > + // Add here because the fields don't exist earlier > + field.on('change', function() { > + const store = Ext.getStore('importwizard_diskstorage'); > + > + // find by panel object because it is unique > + const recordIndex = store.findBy(record => > + record.data.panel === field.up('pveQemuHDInputPanel'), > + ); > + const controllerSelector = field.up('pveControllerSelector'); > + const newControllerAndId = controllerSelector.getValuesAsString(); > + > + store.getAt(recordIndex).set('device', newControllerAndId); > + }); > + }, > + ); > + const wizard = this.up('pveQemuImportWizard'); > + Ext.Array.each(this.query('field'), function(field) { > + field.on('change', wizard.validcheck); > + field.on('validitychange', wizard.validcheck); > + }); > + }, > + }, > + validator: function() { > + var valid = true; > + var fields = this.query('field, fieldcontainer'); > + Ext.Array.each(fields, function(field) { > + // Note: not all fielcontainer have isValid() > + if (Ext.isFunction(field.isValid) && !field.isValid()) { > + valid = false; > + } > + }); > + return valid; > + }, > + }, > + > + // device ... device that the new disk should be assigned to, e.g. ide0, sata2 > + // path ... content of the sourcePathTextfield > + addDisk(device, path) { > + const item = Ext.clone(this.defaultItem); > + const added = this.add(item); > + // values in the storage will be updated by listeners > + if (path) { > + added.down('#sourceRadioPath').setValue(true); > + added.down('#sourcePathTextfield').setValue(path); > + } else { > + added.down('#sourceRadioStorage').setValue(true); > + added.down('#sourceStorageSelector').setHidden(false); > + added.down('#sourceFileSelector').setHidden(false); > + added.down('#sourceFileSelector').enable(); > + added.down('#sourceStorageSelector').enable(); > + } > + > + const sp = Ext.state.Manager.getProvider(); > + const advanced_checkbox = sp.get('proxmox-advanced-cb'); > + added.setAdvancedVisible(advanced_checkbox); > + > + if (device) { > + added.down('pveControllerSelector').setValue(device); > + } > + return added; > + }, > + removeCurrentDisk: function() { > + const activePanel = this.getLayout().activeItem; // panel = disk > + if (activePanel) { > + this.remove(activePanel); > + } > + }, > + }, > + ], > + > + addDisk: function(device, path) { > + this.down('#panelContainer').addDisk(device, path); > + this.down('gridpanel').selectLast(); > + }, > + removeCurrentDisk: function() { > + this.down('#panelContainer').removeCurrentDisk(); > + }, > + removeAllDisks: function() { > + const container = this.down('#panelContainer'); > + while (container.items.items.length > 0) { > + container.removeCurrentDisk(); > + } > + }, > + > + beforeRender: function() { > + const me = this; > + const leftColumnPanel = me.items.get(0).items.get(0); > + leftColumnPanel.setFlex(me.leftColumnRatio); > + // any other panel because this has no height yet > + const panelHeight = me.up('tabpanel').items.get(0).getHeight(); > + leftColumnPanel.setHeight(panelHeight); > + }, > + > + setNodename: function(nodename) { > + this.nodename = nodename; > + }, > + > + listeners: { > + afterrender: function() { > + const store = Ext.getStore('importwizard_diskstorage'); > + const first = store.getAt(0); > + if (first) { > + this.down('gridpanel').setSelection(first); > + } > + }, > + }, > + > + // values ... is optional > + hasDuplicateDevices: function(values) { > + if (!values) { > + values = this.up('form').getValues(); > + } > + if (!Array.isArray(values.controller)) { > + return false; > + } > + for (let i = 0; i < values.controller.length - 1; i++) { > + for (let j = i+1; j < values.controller.length; j++) { > + if ( > + values.controller[i] === values.controller[j] && > + values.deviceid[i] === values.deviceid[j] > + ) { > + return true; > + } > + } > + } > + return false; > + }, > + > + onGetValues: function(values) { > + if (this.hasDuplicateDevices(values)) { > + Ext.Msg.alert(gettext('Error'), 'Equal target devices are forbidden. Make all unique!'); > + } > + // Each child HDInputPanel has sufficient onGetValues() => Return nothing > + }, > + > + validator: function() { > + const me = this; > + const panels = me.down('#panelContainer').items.getRange(); > + return panels.every(panel => panel.validator()) && !me.hasDuplicateDevices(); > + }, > +}); > diff --git a/www/manager6/qemu/HardwareView.js b/www/manager6/qemu/HardwareView.js > index 98352e3f..be4e2d28 100644 > --- a/www/manager6/qemu/HardwareView.js > +++ b/www/manager6/qemu/HardwareView.js > @@ -431,6 +431,29 @@ Ext.define('PVE.qemu.HardwareView', { > handler: run_move, > }); > > + var import_btn = new Proxmox.button.Button({ > + text: gettext('Import disk'), > + hidden: Proxmox.UserName !== 'root@pam', > + handler: function() { > + var win = Ext.create('PVE.qemu.HDEdit', { > + method: 'POST', > + url: `/api2/extjs/${baseurl}`, > + pveSelNode: me.pveSelNode, > + isImport: true, > + listeners: { > + add: function(_, component) { > + component.down('#sourceStorageSelector').show(); > + component.down('#sourceStorageSelector').enable(); > + component.down('#sourceFileSelector').enable(); > + component.down('#sourceFileSelector').show(); > + }, > + }, > + }); > + win.on('destroy', me.reload, me); > + win.show(); > + }, > + }); > + > var remove_btn = new Proxmox.button.Button({ > text: gettext('Remove'), > defaultText: gettext('Remove'), > @@ -759,6 +782,7 @@ Ext.define('PVE.qemu.HardwareView', { > edit_btn, > resize_btn, > move_btn, > + import_btn, > revert_btn, > ], > rows: rows, > diff --git a/www/manager6/qemu/ImportWizard.js b/www/manager6/qemu/ImportWizard.js > new file mode 100644 > index 00000000..a9a63fe3 > --- /dev/null > +++ b/www/manager6/qemu/ImportWizard.js > @@ -0,0 +1,317 @@ > +/*jslint confusion: true*/ > +Ext.define('PVE.qemu.ImportWizard', { > + extend: 'PVE.window.Wizard', > + alias: 'widget.pveQemuImportWizard', > + mixins: ['Proxmox.Mixin.CBind'], > + > + viewModel: { > + data: { > + nodename: '', > + current: { > + scsihw: '', > + }, > + }, > + }, > + > + cbindData: { > + nodename: undefined, > + }, > + > + subject: gettext('Import Virtual Machine'), > + > + isImport: true, > + > + addDisk: function() { > + const me = this; > + const wizard = me.xtype === 'pveQemuImportWizard' ? me : me.up('window'); > + wizard.down('pveQemuHDInputPanelCollection').addDisk(); > + }, > + > + items: [ > + { > + xtype: 'inputpanel', > + title: gettext('Import'), > + itemId: 'importInputpanel', > + column1: [ > + { > + xtype: 'pveNodeSelector', > + name: 'nodename', > + cbind: { > + selectCurNode: '{!nodename}', > + preferredValue: '{nodename}', > + }, > + bind: { > + value: '{nodename}', > + }, > + fieldLabel: gettext('Node'), > + allowBlank: false, > + onlineValidator: true, > + }, > + { > + xtype: 'pveGuestIDSelector', > + name: 'vmid', > + guestType: 'qemu', > + value: '', > + loadNextFreeID: true, > + validateExists: false, > + }, > + ], > + column2: [ > + { > + xtype: 'label', > + itemId: 'successTextfield', > + hidden: true, > + html: gettext('Manifest successfully uploaded'), > + margin: '0 0 0 10', > + }, > + { > + xtype: 'textfield', > + name: 'ovfTextfield', > + emptyText: '/mnt/nfs/exported.ovf', > + fieldLabel: 'Absolute path to .ovf manifest on your PVE host', > + listeners: { > + validitychange: function(_, isValid) { > + const button = Ext.ComponentQuery.query('#load_remote_manifest_button').pop(); > + button.setDisabled(!isValid); > + }, > + }, > + validator: function(value) { > + return (value && value.startsWith('/')) || gettext("Must start with /"); > + }, > + }, > + { > + xtype: 'proxmoxButton', > + itemId: 'load_remote_manifest_button', > + text: gettext('Load remote manifest'), > + disabled: true, > + handler: function() { > + const inputpanel = this.up('#importInputpanel'); > + const nodename = inputpanel.down('pveNodeSelector').getValue(); > + const ovfTextfieldValue = inputpanel.down('textfield[name=ovfTextfield]').getValue(); > + const wizard = this.up('window'); > + Proxmox.Utils.API2Request({ > + url: '/nodes/' + nodename + '/readovf', > + method: 'GET', > + params: { > + manifest: ovfTextfieldValue, > + }, > + success: function(response) { > + const ovfdata = response.result.data; > + wizard.down('#vmNameTextfield').setValue(ovfdata.name); > + wizard.down('#cpupanel').getViewModel().set('coreCount', ovfdata.cores); > + wizard.down('#memorypanel').down('pveMemoryField').setValue(ovfdata.memory); > + delete ovfdata.name; > + delete ovfdata.cores; > + delete ovfdata.memory; > + delete ovfdata.digest; > + const devices = Object.keys(ovfdata); // e.g. ide0, sata2 > + const hdcollection = wizard.down('pveQemuHDInputPanelCollection'); > + hdcollection.removeAllDisks(); // does nothing if already empty > + devices.forEach(device => hdcollection.addDisk(device, ovfdata[device])); > + }, > + failure: function(response) { > + Ext.Msg.alert(gettext('Error'), response.htmlStatus); > + }, > + }); > + }, > + }, > + ], > + onGetValues: function(values) { > + delete values.ovfTextfield; > + return values; > + }, > + }, > + { > + xtype: 'inputpanel', > + title: gettext('General'), > + onlineHelp: 'qm_general_settings', > + column1: [ > + { > + xtype: 'textfield', > + name: 'name', > + itemId: 'vmNameTextfield', > + vtype: 'DnsName', > + value: '', > + fieldLabel: gettext('Name'), > + allowBlank: true, > + }, > + ], > + column2: [ > + { > + xtype: 'pvePoolSelector', > + fieldLabel: gettext('Resource Pool'), > + name: 'pool', > + value: '', > + allowBlank: true, > + }, > + ], > + advancedColumn1: [ > + { > + xtype: 'proxmoxcheckbox', > + name: 'onboot', > + uncheckedValue: 0, > + defaultValue: 0, > + deleteDefaultValue: true, > + fieldLabel: gettext('Start at boot'), > + }, > + ], > + advancedColumn2: [ > + { > + xtype: 'textfield', > + name: 'order', > + defaultValue: '', > + emptyText: 'any', > + labelWidth: 120, > + fieldLabel: gettext('Start/Shutdown order'), > + }, > + { > + xtype: 'textfield', > + name: 'up', > + defaultValue: '', > + emptyText: 'default', > + labelWidth: 120, > + fieldLabel: gettext('Startup delay'), > + }, > + { > + xtype: 'textfield', > + name: 'down', > + defaultValue: '', > + emptyText: 'default', > + labelWidth: 120, > + fieldLabel: gettext('Shutdown timeout'), > + }, > + ], > + onGetValues: function(values) { > + ['name', 'pool', 'onboot', 'agent'].forEach(function(field) { > + if (!values[field]) { > + delete values[field]; > + } > + }); > + > + var res = PVE.Parser.printStartup({ > + order: values.order, > + up: values.up, > + down: values.down, > + }); > + > + if (res) { > + values.startup = res; > + } > + > + delete values.order; > + delete values.up; > + delete values.down; > + > + return values; > + }, > + }, > + { > + xtype: 'pveQemuSystemPanel', > + title: gettext('System'), > + isCreate: true, > + insideWizard: true, > + }, > + { > + xtype: 'pveQemuHDInputPanelCollection', > + title: gettext('Hard Disk'), > + bind: { > + nodename: '{nodename}', > + }, > + isCreate: true, > + insideWizard: true, > + }, > + { > + itemId: 'cpupanel', > + xtype: 'pveQemuProcessorPanel', > + insideWizard: true, > + title: gettext('CPU'), > + }, > + { > + itemId: 'memorypanel', > + xtype: 'pveQemuMemoryPanel', > + insideWizard: true, > + title: gettext('Memory'), > + }, > + { > + xtype: 'pveQemuNetworkInputPanel', > + bind: { > + nodename: '{nodename}', > + }, > + title: gettext('Network'), > + insideWizard: true, > + }, > + { > + title: gettext('Confirm'), > + layout: 'fit', > + items: [ > + { > + xtype: 'grid', > + store: { > + model: 'KeyValue', > + sorters: [{ > + property: 'key', > + direction: 'ASC', > + }], > + }, > + columns: [ > + { header: 'Key', width: 150, dataIndex: 'key' }, > + { header: 'Value', flex: 1, dataIndex: 'value' }, > + ], > + }, > + ], > + dockedItems: [ > + { > + xtype: 'proxmoxcheckbox', > + name: 'start', > + dock: 'bottom', > + margin: '5 0 0 0', > + boxLabel: gettext('Start after created'), > + }, > + ], > + listeners: { > + show: function(panel) { > + var kv = this.up('window').getValues(); > + var data = []; > + Ext.Object.each(kv, function(key, value) { > + if (key === 'delete') { // ignore > + return; > + } > + data.push({ key: key, value: value }); > + }); > + > + var summarystore = panel.down('grid').getStore(); > + summarystore.suspendEvents(); > + summarystore.removeAll(); > + summarystore.add(data); > + summarystore.sort(); > + summarystore.resumeEvents(); > + summarystore.fireEvent('refresh'); > + }, > + }, > + onSubmit: function() { > + var wizard = this.up('window'); > + var params = wizard.getValues(); > + > + var nodename = params.nodename; > + delete params.nodename; > + delete params.delete; > + if (Array.isArray(params.import_sources)) { > + params.import_sources = params.import_sources.join('\0'); > + } > + > + Proxmox.Utils.API2Request({ > + url: `/nodes/${nodename}/qemu`, > + waitMsgTarget: wizard, > + method: 'POST', > + params: params, > + success: function() { > + wizard.close(); > + }, > + failure: function(response) { > + Ext.Msg.alert(gettext('Error'), response.htmlStatus); > + }, > + }); > + }, > + }, > + ], > +}); > diff --git a/www/manager6/window/Wizard.js b/www/manager6/window/Wizard.js > index 8b930bbd..a3e3b690 100644 > --- a/www/manager6/window/Wizard.js > +++ b/www/manager6/window/Wizard.js > @@ -261,6 +261,8 @@ Ext.define('PVE.window.Wizard', { > }; > field.on('change', validcheck); > field.on('validitychange', validcheck); > + // Make available for fields that get added later > + me.validcheck = validcheck; > }); > }, > }); > -- > 2.20.1 > > > _______________________________________________ > pve-devel mailing list > pve-devel@lists.proxmox.com > https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel