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 151386AE5C for ; Fri, 26 Mar 2021 13:32:43 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id E510B2789A for ; Fri, 26 Mar 2021 13:32:42 +0100 (CET) Received: from dev.dominic.proxmox.com (unknown [94.136.29.99]) by firstgate.proxmox.com (Proxmox) with ESMTP id EF0F62786E for ; Fri, 26 Mar 2021 13:32:38 +0100 (CET) Received: by dev.dominic.proxmox.com (Postfix, from userid 0) id BF7AD22BA5; Fri, 26 Mar 2021 13:32:32 +0100 (CET) From: =?UTF-8?q?Dominic=20J=C3=A4ger?= To: pve-devel@lists.proxmox.com Date: Fri, 26 Mar 2021 13:32:27 +0100 Message-Id: <20210326123227.137243-3-d.jaeger@proxmox.com> X-Mailer: git-send-email 2.20.1 In-Reply-To: <20210326123227.137243-1-d.jaeger@proxmox.com> References: <20210326123227.137243-1-d.jaeger@proxmox.com> MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 2 KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment KAM_LAZY_DOMAIN_SECURITY 1 Sending domain does not have any anti-forgery methods NO_DNS_FOR_FROM 0.379 Envelope sender has no MX or A DNS records RDNS_NONE 1.274 Delivered to internal network by a host with no rDNS SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_NONE 0.001 SPF: sender does not publish an 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, params.storage] Subject: [pve-devel] [PATCH v7 manager] gui: Add import for 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: Fri, 26 Mar 2021 12:32:43 -0000 Add GUI wizard to import whole VMs and a window to import single disks in Hardware View. Signed-off-by: Dominic Jäger --- v6->v7: - Update to API changes - Add helpers to Utils - Whitespace & line breaks according to style guide - Making conditional branches in HDEdit easier to read PVE/API2/Nodes.pm | 7 + www/manager6/Makefile | 2 + www/manager6/Utils.js | 12 + www/manager6/Workspace.js | 15 ++ www/manager6/form/ControllerSelector.js | 15 ++ www/manager6/node/CmdMenu.js | 13 + www/manager6/qemu/HDEdit.js | 169 +++++++++++- www/manager6/qemu/HardwareView.js | 25 ++ www/manager6/qemu/ImportWizard.js | 332 ++++++++++++++++++++++++ www/manager6/qemu/MultiHDEdit.js | 277 ++++++++++++++++++++ www/manager6/window/Wizard.js | 2 + 11 files changed, 856 insertions(+), 13 deletions(-) create mode 100644 www/manager6/qemu/ImportWizard.js create mode 100644 www/manager6/qemu/MultiHDEdit.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..753cd1c0 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/MultiHDEdit.js \ qemu/HDEfi.js \ qemu/HDMove.js \ qemu/HDResize.js \ diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js index f502950f..dbfd65ce 100644 --- a/www/manager6/Utils.js +++ b/www/manager6/Utils.js @@ -1708,6 +1708,16 @@ Ext.define('PVE.Utils', { }); }, + // collection ... collection of strings of a subset of the descendants of container + // visible ... true to show and enable, false to hide and disable + setDescendantsVisible: function(container, collection, visible = 1) { + const hide = (element, value) => { + element.setHidden(value); + element.setDisabled(value); + }; + collection.map(e => container.down(e)).forEach(e => hide(e, !visible)); + }, + cpu_vendor_map: { 'default': 'QEMU', 'AuthenticAMD': 'AMD', @@ -1787,6 +1797,8 @@ Ext.define('PVE.Utils', { hastop: ['HA', gettext('Stop')], imgcopy: ['', gettext('Copy data')], imgdel: ['', gettext('Erase data')], + importdisk: ['VM', gettext('Import disk')], + importvm: ['VM', gettext('Import VM')], lvmcreate: [gettext('LVM Storage'), gettext('Create')], lvmthincreate: [gettext('LVM-Thin Storage'), gettext('Create')], migrateall: ['', gettext('Migrate all VMs and Containers')], 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..8e9aee98 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) { + let regex = /([a-z]+)(\d+)/; + let [_, 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..407cf2d0 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() { + var me = this.up('menu'); + var 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..a2f6c95a 100644 --- a/www/manager6/qemu/HDEdit.js +++ b/www/manager6/qemu/HDEdit.js @@ -58,6 +58,21 @@ Ext.define('PVE.qemu.HDInputPanel', { }, }, + isImport: function() { + return this.isImportVM || this.isImportDisk; + }, + + /* + All radiofields (esp. sourceRadioPath and sourceRadioStorage) have the + same scope for name. But we need a different scope for each HDInputPanel in + a MultiHDInputPanel 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 +85,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}:0`; } else { me.drive.file = values.hdstorage + ":" + values.disksize; } @@ -83,15 +100,33 @@ 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.isImportVM) { + params.diskimage = `${confid}=${getSourceImageLocation()}`; + } + + const options = PVE.Parser.printQemuDrive(me.drive); - params[confid] = PVE.Parser.printQemuDrive(me.drive); + if (me.isImportDisk) { + params.device = confid; + params.device_options = options; + params.source = getSourceImageLocation(); + params.device = values.controller + values.deviceid; + params.storage = values.hdstorage; + } else { + params[confid] = options; + } return params; }, @@ -149,6 +184,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 +208,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 +228,7 @@ Ext.define('PVE.qemu.HDInputPanel', { submitValue: false, hidden: true, }); - me.column1.push(me.scsiController); + controllerColumn.push(me.scsiController); } if (me.unused) { @@ -199,14 +243,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 +268,12 @@ Ext.define('PVE.qemu.HDInputPanel', { }); } + if (me.isImport()) { + me.column2.push({ + xtype: 'box', + autoEl: { tag: 'hr' }, + }); + } me.column2.push( { xtype: 'CacheTypeSelector', @@ -231,6 +288,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) => { + const selectors = [ + '#sourceStorageSelector', + '#sourceFileSelector', + ]; + PVE.Utils.setDescendantsVisible(me, selectors, 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) => { + PVE.Utils.setDescendantsVisible(me, ['#sourcePathTextfield'], 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 +508,18 @@ Ext.define('PVE.qemu.HDEdit', { nodename: nodename, unused: unused, isCreate: me.isCreate, + isImportVM: me.isImportVM, + isImportDisk: me.isImportDisk, }); - var subject; if (unused) { me.subject = gettext('Unused Disk'); + } else if (me.isImportDisk) { + 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 + ')'; } @@ -404,6 +544,9 @@ Ext.define('PVE.qemu.HDEdit', { ipanel.setDrive(drive); me.isValid(); // trigger validation } + if (me.isImportDisk) { + me.url = me.url.replace(/\/config$/, "/importdisk"); + } }, }); }, diff --git a/www/manager6/qemu/HardwareView.js b/www/manager6/qemu/HardwareView.js index 98352e3f..4fbf0e5e 100644 --- a/www/manager6/qemu/HardwareView.js +++ b/www/manager6/qemu/HardwareView.js @@ -431,6 +431,30 @@ 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, + isImportDisk: true, + listeners: { + add: function(_, component) { + const selectors = [ + '#sourceStorageSelector', + '#sourceFileSelector', + ]; + PVE.Utils.setDescendantsVisible(component, selectors); + }, + }, + }); + win.on('destroy', me.reload, me); + win.show(); + }, + }); + var remove_btn = new Proxmox.button.Button({ text: gettext('Remove'), defaultText: gettext('Remove'), @@ -759,6 +783,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..0066adc4 --- /dev/null +++ b/www/manager6/qemu/ImportWizard.js @@ -0,0 +1,332 @@ +/*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'), + + isImportVM: true, + + addDiskFunction: function() { + let me = this; + let wizard; + if (me.xtype === 'button') { + wizard = me.up('window'); + } else if (me.xtype === 'pveQemuImportWizard') { + wizard = me; + } + let multihd = wizard.down('pveQemuMultiHDInputPanel'); + multihd.addDiskFunction(); + }, + + 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', + itemId: 'server_ovf_manifest', + name: 'ovf_textfield', + emptyText: '/mnt/nfs/exported.ovf', + fieldLabel: 'Absolute path to .ovf manifest on your PVE host', + listeners: { + validitychange: function(_, isValid) { + let 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() { + let inputpanel = this.up('#importInputpanel'); + let nodename = inputpanel.down('pveNodeSelector').getValue(); + // independent of onGetValues(), so that value of + // ovf_textfield can be removed for submit + let ovf_textfield_value = inputpanel.down('textfield[name=ovf_textfield]').getValue(); + let wizard = this.up('window'); + Proxmox.Utils.API2Request({ + url: '/nodes/' + nodename + '/readovf', + method: 'GET', + params: { + manifest: ovf_textfield_value, + }, + success: function(response) { + let 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; + let devices = Object.keys(ovfdata); // e.g. ide0, sata2 + let multihd = wizard.down('pveQemuMultiHDInputPanel'); + if (devices.length > 0) { + multihd.removeAllDisks(); + } + for (var device of devices) { + multihd.addDiskFunction(device, ovfdata[device]); + } + }, + failure: function(response, opts) { + console.warn("Failure of load manifest button"); + console.warn(response); + }, + }); + }, + }, + ], + onGetValues: function(values) { + delete values.server_ovf_manifest; + delete values.ovf_textfield; + 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: 'pveQemuMultiHDInputPanel', + 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.diskimages)) { + params.diskimages = params.diskimages.join(','); + } + + Proxmox.Utils.API2Request({ + url: `/nodes/${nodename}/qemu/${params.vmid}/importvm`, + 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/qemu/MultiHDEdit.js b/www/manager6/qemu/MultiHDEdit.js new file mode 100644 index 00000000..641a802f --- /dev/null +++ b/www/manager6/qemu/MultiHDEdit.js @@ -0,0 +1,277 @@ +Ext.define('PVE.qemu.MultiHDInputPanel', { + extend: 'Proxmox.panel.InputPanel', + alias: 'widget.pveQemuMultiHDInputPanel', + + insideWizard: false, + + hiddenDisks: [], + + leftColumnRatio: 0.25, + + column1: [ + { + // Adding to the HDInputPanelContainer 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) { + let recordIndex = this.findBy(record => record.data.panel === panel); + this.removeAt(recordIndex); + return recordIndex; + }, + }, + columns: [ + { + text: gettext('Target device'), + dataIndex: 'device', + flex: 1, + resizable: false, + }, + ], + listeners: { + select: function(_, record) { + this.up('pveQemuMultiHDInputPanel') + .down('#HDInputPanelContainer') + .setActiveItem(record.data.panel); + }, + }, + anchor: '100% 90%', + }, { + xtype: 'container', + layout: 'hbox', + center: true, + defaults: { + margin: '5', + xtype: 'button', + }, + items: [ + { + iconCls: 'fa fa-plus-circle', + itemId: 'addDisk', + handler: function(button) { + button.up('pveQemuMultiHDInputPanel').addDiskFunction(); + }, + }, { + iconCls: 'fa fa-trash-o', + itemId: 'removeDisk', + handler: function(button) { + button.up('pveQemuMultiHDInputPanel').removeCurrentDisk(); + }, + }, + ], + }, + ], + column2: [ + { + itemId: 'HDInputPanelContainer', + 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.addDiskFunction(); + } + }, + add: function(container, newPanel, index) { + let store = Ext.getStore('importwizard_diskstorage'); + store.add({ device: newPanel.getDevice(), panel: newPanel }); + container.setActiveItem(newPanel); + }, + remove: function(HDInputPanelContainer, HDInputPanel, eOpts) { + let store = Ext.getStore('importwizard_diskstorage'); + let indexOfRemoved = store.removeByPanel(HDInputPanel); + if (HDInputPanelContainer.items.getCount() > 0) { + HDInputPanelContainer.setActiveItem(indexOfRemoved - 1); + } + }, + }, + defaultItem: { + xtype: 'pveQemuHDInputPanel', + bind: { + nodename: '{nodename}', + }, + isCreate: true, + isImportVM: true, + returnSingleKey: 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: { + // newHDInputPanel ... the defaultItem that has just been + // cloned and added into HDInputPnaleContainer parameter + // HDInputPanelContainer ... the container from column2 + // where all the new panels go into + added: function(newHDInputPanel, HDInputPanelContainer, pos) { + // The listeners cannot be added earlier, because its fields don't exist earlier + Ext.Array.each(this.down('pveControllerSelector') + .query('field'), function(field) { + field.on('change', function() { + // Note that one setValues in a controller + // selector makes one setValue in each of + // the two fields, so this listener fires + // two times in a row so to say e.g. + // changing controller selector from ide0 to + // sata1 makes ide0->sata0 and then + // sata0->sata1 + let store = Ext.getStore('importwizard_diskstorage'); + let controllerSelector = field.up('pveQemuHDInputPanel') + .down('pveControllerSelector'); + /* + * controller+device (ide0) might be + * ambiguous during creation => find by + * panel object instead + * + * There is no function that takes a + * function and returns the model directly + * => index & getAt + */ + let recordIndex = store.findBy(record => + record.data.panel === field.up('pveQemuHDInputPanel'), + ); + let newControllerAndId = controllerSelector.getValuesAsString(); + store.getAt(recordIndex).set('device', newControllerAndId); + }); + }, + ); + let 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 ... if this is set to x then the disk will + // backed/imported from the path x, that is, the textfield will + // contain the value x + addDiskFunction(device, path) { + // creating directly removes binding => no storage found? + let item = Ext.clone(this.defaultItem); + let added = this.add(item); + // At this point the 'added' listener has fired and the fields + // in the variable added have the change listeners that update + // the store Therefore we can now set values only on the field + // and they will be updated in the store + 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(); + } + + let sp = Ext.state.Manager.getProvider(); + let advanced_checkbox = sp.get('proxmox-advanced-cb'); + added.setAdvancedVisible(advanced_checkbox); + + if (device) { + // This happens after the 'add' and 'added' listeners of the + // item/defaultItem clone/pveQemuHDInputPanel/added have fired + added.down('pveControllerSelector').setValue(device); + } + }, + removeCurrentDisk: function() { + let activePanel = this.getLayout().activeItem; // panel = disk + if (activePanel) { + this.remove(activePanel); + } + }, + }, + ], + + addDiskFunction: function(device, path) { + this.down('#HDInputPanelContainer').addDiskFunction(device, path); + }, + removeCurrentDisk: function() { + this.down('#HDInputPanelContainer').removeCurrentDisk(); + }, + removeAllDisks: function() { + let container = this.down('#HDInputPanelContainer'); + while (container.items.items.length > 0) { + container.removeCurrentDisk(); + } + }, + + beforeRender: function() { + let leftColumnPanel = this.items.get(0).items.get(0); + leftColumnPanel.setFlex(this.leftColumnRatio); + // any other panel because this has no height yet + let panelHeight = this.up('tabpanel').items.items[0].getHeight(); + leftColumnPanel.setHeight(panelHeight); + }, + + setNodename: function(nodename) { + this.nodename = nodename; + }, + + // Call with defined parameter or without + 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]) { + if (values.deviceid[i] === values.deviceid[j]) { + return true; + } + } + } + } + return false; + }, + + onGetValues: function(values) { + // Returning anything here would give wrong data in the form at the end + // of the wizrad Each HDInputPanel in this MultiHD panel already has a + // sufficient onGetValues() function for the form at the end of the + // wizard + if (this.hasDuplicateDevices(values)) { + Ext.Msg.alert(gettext('Error'), 'Equal target devices are forbidden. Make all unique!'); + } + }, + + validator: function() { + let inputpanels = this.down('#HDInputPanelContainer').items.getRange(); + if (inputpanels.some(panel => !panel.validator())) { + return false; + } + if (this.hasDuplicateDevices()) { + return false; + } + return true; + }, +}); 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