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 4CBFB7139E for ; Thu, 10 Jun 2021 12:21:02 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 392BA2D5D4 for ; Thu, 10 Jun 2021 12:21:02 +0200 (CEST) Received: from proxmox-new.maurer-it.com (proxmox-new.maurer-it.com [94.136.29.106]) (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 323E82D5C8 for ; Thu, 10 Jun 2021 12:20:59 +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 9573646767 for ; Thu, 10 Jun 2021 12:20:58 +0200 (CEST) From: =?UTF-8?q?Dominic=20J=C3=A4ger?= To: pve-devel@lists.proxmox.com Date: Thu, 10 Jun 2021 12:20:24 +0200 Message-Id: <20210610102024.59173-2-d.jaeger@proxmox.com> X-Mailer: git-send-email 2.30.2 In-Reply-To: <20210610102024.59173-1-d.jaeger@proxmox.com> References: <20210610102024.59173-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: 0 AWL 1.511 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment 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, me.drive, result.data] Subject: [pve-devel] [PATCH v9 manager] 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: Thu, 10 Jun 2021 10:21:02 -0000 Add GUI wizard to import whole VMs and a window to import single disks in Hardware View. Signed-off-by: Dominic Jäger --- v9: Use Tabpanel instead of Advanced Checkbox for disk options PVE/API2/Nodes.pm | 7 + www/manager6/Makefile | 4 + www/manager6/form/ControllerSelector.js | 15 + www/manager6/form/DiskStorageSelector.js | 27 +- www/manager6/form/FileSelector.js | 6 + www/manager6/qemu/CDEdit.js | 3 - www/manager6/qemu/CreateWizard.js | 102 ++++++- www/manager6/qemu/HardwareView.js | 4 +- www/manager6/qemu/OSDefaults.js | 13 + www/manager6/qemu/OSTypeEdit.js | 12 +- www/manager6/qemu/disk/DiskBasic.js | 365 +++++++++++++++++++++++ www/manager6/qemu/disk/DiskCollection.js | 275 +++++++++++++++++ www/manager6/qemu/disk/DiskOptions.js | 243 +++++++++++++++ www/manager6/qemu/disk/HardDisk.js | 137 +++++++++ www/manager6/window/Wizard.js | 2 + 15 files changed, 1203 insertions(+), 12 deletions(-) create mode 100644 www/manager6/qemu/disk/DiskBasic.js create mode 100644 www/manager6/qemu/disk/DiskCollection.js create mode 100644 www/manager6/qemu/disk/DiskOptions.js create mode 100644 www/manager6/qemu/disk/HardDisk.js diff --git a/PVE/API2/Nodes.pm b/PVE/API2/Nodes.pm index f4d3382c..94faeab1 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; @@ -70,6 +71,11 @@ __PACKAGE__->register_method ({ path => 'qemu', }); +__PACKAGE__->register_method ({ + subclass => "PVE::API2::Qemu::OVF", + path => 'readovf', +}); + __PACKAGE__->register_method ({ subclass => "PVE::API2::LXC", path => 'lxc', @@ -2152,6 +2158,7 @@ __PACKAGE__->register_method ({ return undef; }}); + # bash completion helper sub complete_templet_repo { diff --git a/www/manager6/Makefile b/www/manager6/Makefile index 6776d4ce..85b12e1c 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -199,6 +199,10 @@ JSSRC= \ qemu/CreateWizard.js \ qemu/DisplayEdit.js \ qemu/HDEdit.js \ + qemu/disk/DiskCollection.js \ + qemu/disk/HardDisk.js \ + qemu/disk/DiskBasic.js \ + qemu/disk/DiskOptions.js \ qemu/HDEfi.js \ qemu/HDMove.js \ qemu/HDResize.js \ diff --git a/www/manager6/form/ControllerSelector.js b/www/manager6/form/ControllerSelector.js index daca2432..eefa36ac 100644 --- a/www/manager6/form/ControllerSelector.js +++ b/www/manager6/form/ControllerSelector.js @@ -72,6 +72,21 @@ Ext.define('PVE.form.ControllerSelector', { 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/form/DiskStorageSelector.js b/www/manager6/form/DiskStorageSelector.js index cf73f2e2..15ac236e 100644 --- a/www/manager6/form/DiskStorageSelector.js +++ b/www/manager6/form/DiskStorageSelector.js @@ -25,8 +25,9 @@ Ext.define('PVE.form.DiskStorageSelector', { // hideSelection is not true hideSelection: undefined, - // hides the size field (e.g, for the efi disk dialog) + // hides and disables the size field (e.g, for the efi disk dialog) hideSize: false, + disableSize: false, // only disable the size field // sets the initial size value // string because else we get a type confusion @@ -72,7 +73,7 @@ Ext.define('PVE.form.DiskStorageSelector', { hdfilesel.setStorage(value); } - hdsizesel.setDisabled(select || me.hideSize); + hdsizesel.setDisabled(select || me.hideSize || me.disableSize); hdsizesel.setVisible(!select && !me.hideSize); }, @@ -85,6 +86,26 @@ Ext.define('PVE.form.DiskStorageSelector', { hdfilesel.setNodename(nodename); }, + setSize: function(size) { + const me = this; + const hdsizesel = me.getComponent('disksize'); + hdsizesel.setValue(size); + }, + + getSize: function() { + return this.getComponent('disksize').getValue(); + }, + + fixAndGetSize: function() { + const me = this; + const field = me.getComponent('disksize'); + if (!field.isValid()) { + field.clearInvalid(); + field.setValue(me.defaultSize); + } + return field.getValue(); + }, + setDisabled: function(value) { var me = this; var hdstorage = me.getComponent('hdstorage'); @@ -140,7 +161,7 @@ Ext.define('PVE.form.DiskStorageSelector', { name: 'disksize', fieldLabel: gettext('Disk size') + ' (GiB)', hidden: me.hideSize, - disabled: me.hideSize, + disabled: me.hideSize || me.disableSize, minValue: 0.001, maxValue: 128*1024, decimalPrecision: 3, diff --git a/www/manager6/form/FileSelector.js b/www/manager6/form/FileSelector.js index ef2bedf9..d426e7f4 100644 --- a/www/manager6/form/FileSelector.js +++ b/www/manager6/form/FileSelector.js @@ -51,6 +51,12 @@ Ext.define('PVE.form.FileSelector', { this.setStorage(undefined, nodename); }, + getCurrentSize: function() { + const me = this; + const id = me.getValue(); + return id ? me.store.getById(id).get('size') : 0; + }, + store: { model: 'pve-storage-content', }, diff --git a/www/manager6/qemu/CDEdit.js b/www/manager6/qemu/CDEdit.js index 72c01037..27092d32 100644 --- a/www/manager6/qemu/CDEdit.js +++ b/www/manager6/qemu/CDEdit.js @@ -84,9 +84,6 @@ Ext.define('PVE.qemu.CDInputPanel', { checked: true, listeners: { change: function(f, value) { - if (!me.rendered) { - return; - } me.down('field[name=cdstorage]').setDisabled(!value); var cdImageField = me.down('field[name=cdimage]'); cdImageField.setDisabled(!value); diff --git a/www/manager6/qemu/CreateWizard.js b/www/manager6/qemu/CreateWizard.js index d4535c9d..d066bd47 100644 --- a/www/manager6/qemu/CreateWizard.js +++ b/www/manager6/qemu/CreateWizard.js @@ -16,12 +16,25 @@ Ext.define('PVE.qemu.CreateWizard', { nodename: undefined, }, + setImport: function(isImport = true) { + const me = this; + if (me.xtype !== 'pveQemuCreateWizard') { + throw "Unexpected xtype"; + } + me.down('pveQemuOSTypePanel').ignoreDisks = isImport; // prefer values from OVF + // radiofield onChange behavior does not deactivate remaining radiofields + // when the panel is not yet rendered in ExtJS>=7.0 + me.down('radiofield[inputValue=iso]').setValue(false); + me.down('radiofield[inputValue=none]').setValue(true); + }, + subject: gettext('Virtual Machine'), items: [ { xtype: 'inputpanel', title: gettext('General'), + itemId: 'generalPanel', onlineHelp: 'qm_general_settings', column1: [ { @@ -63,6 +76,75 @@ Ext.define('PVE.qemu.CreateWizard', { value: '', allowBlank: true, }, + { + 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: '.ovf manifest', + autoEl: { + tag: 'div', + 'data-qtip': gettext('Absolute path to an .ovf manifest on the PVE host'), + }, + value: '/mnt/pve/nasi_private/importing/from_hyperv/pve_ovf/pve/pve.ovf', // TODO DOMINIC Remove after testing + 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 .ovf'), + disabled: true, + handler: function() { + const inputpanel = this.up('#generalPanel'); + 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('textfield[name=name]').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('pveQemuDiskCollection'); + hdcollection.removeAllDisks(); // does nothing if already empty + devices.forEach(device => hdcollection.addDisk(device, ovfdata[device])); + + wizard.setImport(); + wizard.validcheck(); + }, + failure: function(response) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + }); + }, + }, ], advancedColumn1: [ { @@ -120,6 +202,7 @@ Ext.define('PVE.qemu.CreateWizard', { delete values.order; delete values.up; delete values.down; + delete values.ovfTextfield; return values; }, @@ -154,7 +237,7 @@ Ext.define('PVE.qemu.CreateWizard', { insideWizard: true, }, { - xtype: 'pveQemuHDInputPanel', + xtype: 'pveQemuDiskCollection', bind: { nodename: '{nodename}', }, @@ -163,11 +246,13 @@ Ext.define('PVE.qemu.CreateWizard', { insideWizard: true, }, { + itemId: 'cpupanel', xtype: 'pveQemuProcessorPanel', insideWizard: true, title: gettext('CPU'), }, { + itemId: 'memorypanel', xtype: 'pveQemuMemoryPanel', insideWizard: true, title: gettext('Memory'), @@ -235,6 +320,7 @@ Ext.define('PVE.qemu.CreateWizard', { var nodename = kv.nodename; delete kv.nodename; + delete kv.delete; Proxmox.Utils.API2Request({ url: '/nodes/' + nodename + '/qemu', @@ -251,6 +337,20 @@ Ext.define('PVE.qemu.CreateWizard', { }, }, ], + + getValues: function() { + let values = this.callParent(); + for (const [key, value] of Object.entries(values)) { + const re = /ide\d+|sata\d+|virtio\d+|scsi\d+|import_sources/; + if (key.match(re) && Array.isArray(value)) { + // Collected from different panels => array + // But API & some GUI functions expect not array + const sep = key === 'import_sources' ? '\0' : ','; + values[key] = value.join(sep); + } + } + return values; + }, }); diff --git a/www/manager6/qemu/HardwareView.js b/www/manager6/qemu/HardwareView.js index 200e3c28..1618fd17 100644 --- a/www/manager6/qemu/HardwareView.js +++ b/www/manager6/qemu/HardwareView.js @@ -630,9 +630,9 @@ Ext.define('PVE.qemu.HardwareView', { iconCls: 'fa fa-fw fa-hdd-o black', disabled: !caps.vms['VM.Config.Disk'], handler: function() { - let win = Ext.create('PVE.qemu.HDEdit', { + let win = Ext.create('PVE.qemu.HardDiskWindow', { url: '/api2/extjs/' + baseurl, - pveSelNode: me.pveSelNode, + nodename: me.pveSelNode.data.node, }); win.on('destroy', me.reload, me); win.show(); diff --git a/www/manager6/qemu/OSDefaults.js b/www/manager6/qemu/OSDefaults.js index eed9eebc..9faf3ad6 100644 --- a/www/manager6/qemu/OSDefaults.js +++ b/www/manager6/qemu/OSDefaults.js @@ -72,6 +72,19 @@ Ext.define('PVE.qemu.OSDefaults', { pveOS: 'wxp', parent: 'w2k', }); + addOS({ + pveOS: 'win10', + parent: 'generic', + busPriority: { + sata: 4, // for compatibility + ide: 3, + virtio: 2, + scsi: 1, + }, + networkCard: 'e1000', + scsihw: '', + }); + me.getDefaults = function(ostype) { if (PVE.qemu.OSDefaults[ostype]) { diff --git a/www/manager6/qemu/OSTypeEdit.js b/www/manager6/qemu/OSTypeEdit.js index 438d7c6b..641d9394 100644 --- a/www/manager6/qemu/OSTypeEdit.js +++ b/www/manager6/qemu/OSTypeEdit.js @@ -3,6 +3,7 @@ Ext.define('PVE.qemu.OSTypeInputPanel', { alias: 'widget.pveQemuOSTypePanel', onlineHelp: 'qm_os_settings', insideWizard: false, + ignoreDisks: false, controller: { xclass: 'Ext.app.ViewController', @@ -20,13 +21,18 @@ Ext.define('PVE.qemu.OSTypeInputPanel', { }, onOSTypeChange: function(field) { var me = this, ostype = field.getValue(); - if (!me.getView().insideWizard) { + const view = me.getView(); + if (!view.insideWizard) { return; } var targetValues = PVE.qemu.OSDefaults.getDefaults(ostype); - - me.setWidget('pveBusSelector', targetValues.busType); + if (!view.ignoreDisks) { + const ids = Ext.ComponentQuery.query('pveBusSelector') + .reduce((acc, cur) => acc.concat(cur.id), []); + ids.forEach(i => me.setWidget(`#${i}`, targetValues.busType)); + } me.setWidget('pveNetworkCardSelector', targetValues.networkCard); + me.setWidget('pveQemuBiosSelector', targetValues.bios); var scsihw = targetValues.scsihw || '__default__'; this.getViewModel().set('current.scsihw', scsihw); }, diff --git a/www/manager6/qemu/disk/DiskBasic.js b/www/manager6/qemu/disk/DiskBasic.js new file mode 100644 index 00000000..c89bbd37 --- /dev/null +++ b/www/manager6/qemu/disk/DiskBasic.js @@ -0,0 +1,365 @@ +/* 'change' property is assigned a string and then a function */ +Ext.define('PVE.qemu.DiskBasic', { + extend: 'Proxmox.panel.InputPanel', + alias: 'widget.pveQemuDiskBasic', + onlineHelp: 'qm_hard_disk', + + insideWizard: false, + + unused: false, + + padding: '10 10 10 10', + + vmconfig: {}, // used to select usused disks + + viewModel: {}, + + /** + * All radiofields in pveQemuDiskCollection have the same scope + * Make name of radiofields unique for each disk + */ + getRadioName() { + return 'radio_' + this.id; + }, + + onGetValues: function(values) { + let me = this; + + let params = {}; + let confid = me.confid || values.controller + values.deviceid; + + const isImport = values.sourceVolid || values.sourcePath; + if (me.unused) { + me.drive.file = me.vmconfig[values.unusedId]; + confid = values.controller + values.deviceid; + } else if (me.isCreate) { + if (values.hdimage) { + me.drive.file = values.hdimage; + } else if (isImport) { + me.drive.file = `${values.hdstorage}:-1`; + } else { + me.drive.file = values.hdstorage + ":" + values.disksize; + } + me.drive.format = values.diskformat; + } + + PVE.Utils.propertyStringSet(me.drive, values.discard, 'discard', 'on'); + PVE.Utils.propertyStringSet(me.drive, values.cache, 'cache'); + + if (isImport) { + // exactly 1 of sourceVolid and sourcePath must be defined + params.import_sources = `${confid}=${isImport}`; + } + + params[confid] = PVE.Parser.printQemuDrive(me.drive); + + return params; + }, + + setVMConfig: function(vmconfig) { + let me = this; + + me.vmconfig = vmconfig; + + if (me.bussel) { + me.bussel.setVMConfig(vmconfig); + me.scsiController.setValue(vmconfig.scsihw); + } + if (me.unusedDisks) { + let disklist = []; + Ext.Object.each(vmconfig, function(key, value) { + if (key.match(/^unused\d+$/)) { + disklist.push([key, value]); + } + }); + me.unusedDisks.store.loadData(disklist); + me.unusedDisks.setValue(me.confid); + } + }, + + setDrive: function(drive) { + let me = this; + + me.drive = drive; + + let values = {}; + let match = drive.file.match(/^([^:]+):/); + if (match) { + values.hdstorage = match[1]; + } + + values.hdimage = drive.file; + values.backup = PVE.Parser.parseBoolean(drive.backup, 1); + values.noreplicate = !PVE.Parser.parseBoolean(drive.replicate, 1); + values.diskformat = drive.format || 'raw'; + values.cache = drive.cache || '__default__'; + values.discard = drive.discard === 'on'; + values.ssd = PVE.Parser.parseBoolean(drive.ssd); + values.iothread = PVE.Parser.parseBoolean(drive.iothread); + + values.mbps_rd = drive.mbps_rd; + values.mbps_wr = drive.mbps_wr; + values.iops_rd = drive.iops_rd; + values.iops_wr = drive.iops_wr; + values.mbps_rd_max = drive.mbps_rd_max; + values.mbps_wr_max = drive.mbps_wr_max; + values.iops_rd_max = drive.iops_rd_max; + values.iops_wr_max = drive.iops_wr_max; + + me.setValues(values); + }, + + getDevice: function() { + return this.bussel.getValuesAsString(); + }, + + setNodename: function(nodename) { + let me = this; + me.down('#hdstorage').setNodename(nodename); + me.down('#sourceStorageSelector').setNodename(nodename); + me.down('field[name=sourceVolid]').setNodename(nodename); + }, + + initComponent: function() { + let me = this; + + + me.drive = {}; + + me.column1 = []; + me.column2 = []; + + if (!me.confid || me.unused) { + const controllerColumn = me.column2; + me.scsiController = Ext.create('Ext.form.field.Display', { + fieldLabel: gettext('SCSI Controller'), + reference: 'scsiController', + name: 'scsiController', + bind: me.insideWizard ? { + value: '{current.scsihw}', + } : undefined, + renderer: PVE.Utils.render_scsihw, + submitValue: false, + hidden: true, + }); + + me.bussel = Ext.create('PVE.form.ControllerSelector', { + itemId: 'bussel', + vmconfig: me.insideWizard ? { ide2: 'cdrom' } : {}, + }); + + me.bussel.down('field[name=controller]').addListener('change', function(_, newValue) { + const allowIOthread = newValue.match(/^(virtio|scsi)/); + const iothreadField = me.next('pveQemuDiskOptions').down('field[name=iothread]'); + iothreadField.setDisabled(!allowIOthread); + if (!allowIOthread) { + iothreadField.setValue(false); + } + + const virtio = newValue.match(/^virtio/); + const ssdField = me.next('pveQemuDiskOptions').down('field[name=ssd]'); + ssdField.setDisabled(virtio); + if (virtio) { + ssdField.setValue(false); + } + + me.scsiController.setVisible(newValue.match(/^scsi/)); + }); + + controllerColumn.push(me.bussel); + controllerColumn.push(me.scsiController); + } + + if (me.unused) { + me.unusedDisks = Ext.create('Proxmox.form.KVComboBox', { + name: 'unusedId', + fieldLabel: gettext('Disk image'), + matchFieldWidth: false, + listConfig: { + width: 350, + }, + data: [], + allowBlank: false, + }); + me.column1.push(me.unusedDisks); + } else if (me.isCreate) { + let selector = { + xtype: 'pveDiskStorageSelector', + storageContent: 'images', + name: 'disk', + nodename: me.nodename, + autoSelect: me.insideWizard, + }; + selector.storageLabel = gettext('Target storage'); + me.column2.push(selector); + } else { + me.column1.push({ + xtype: 'textfield', + disabled: true, + submitValue: false, + fieldLabel: gettext('Disk image'), + name: 'hdimage', + }); + } + + me.column2.push( + { + xtype: 'CacheTypeSelector', + name: 'cache', + value: '__default__', + fieldLabel: gettext('Cache'), + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Discard'), + reference: 'discard', + name: 'discard', + }, + ); + me.column1.unshift( + { + xtype: 'radiofield', + itemId: 'empty', + name: me.getRadioName(), + inputValue: 'empty', + boxLabel: gettext('Add empty disk'), + hidden: Proxmox.UserName !== 'root@pam', + checked: true, + listeners: { + /** + * + * @param field - The radiofield + * @param nowSelected - True if the field has just been clicked on, false if + * any other radiofield has been clicked + */ + change: function(field, nowSelected) { + // clicking buttons + me.down('#disksize').setDisabled(!nowSelected); + me.down('#disksize').clearInvalid(); + // overrule storage specific setting (including initial load) + me.down('pveDiskStorageSelector').disableSize = !nowSelected; + + const targetSelector = field.up('pveQemuHardDisk') + .down('pveDiskStorageSelector'); + if (nowSelected) { + if (!me.newDiskSize) { + me.newDiskSize = targetSelector.defaultSize; + } + targetSelector.setSize(me.newDiskSize); + } + }, + }, + }, + { + xtype: 'radiofield', + name: me.getRadioName(), + inputValue: 'storage', + boxLabel: gettext('Use a storage as source'), + hidden: Proxmox.UserName !== 'root@pam', + listeners: { + change: (field, nowSelected) => { + field.next('#sourceStorageSelector').setHidden(!nowSelected); + field.next('#sourceStorageSelector').setDisabled(!nowSelected); + field.next('pveFileSelector[name=sourceVolid]').setHidden(!nowSelected); + field.next('pveFileSelector[name=sourceVolid]').setDisabled(!nowSelected); + + // changing radiofields without changing source image + if (nowSelected) { + const targetSelector = me.down('pveDiskStorageSelector'); + if (field.getGroupValue() === 'empty') { + // in this case the change listener of the 'empty' field fires with false AFTER this listener fires with true + me.newDiskSize = targetSelector.fixAndGetSize(); + } + const sourceField = field.next('pveFileSelector'); + const size = Proxmox.Utils.format_size(sourceField.getCurrentSize()); + targetSelector.setSize(size); + } + }, + }, + }, { + xtype: 'pveStorageSelector', + itemId: 'sourceStorageSelector', + nodename: me.nodename, + fieldLabel: gettext('Storage'), + storageContent: 'images', + autoSelect: me.insideWizard, + hidden: true, + disabled: true, + listeners: { + change: function(selector, selectedStorage) { + selector.next('pveFileSelector').setStorage( + selectedStorage, + me.getViewModel().get('nodename'), + ); + }, + }, + }, { + xtype: 'pveFileSelector', + name: 'sourceVolid', + nodename: me.nodename, + storageContent: 'images', + hidden: true, + disabled: true, + fieldLabel: gettext('Image'), + autoEl: { + tag: 'div', + 'data-qtip': gettext("Place your source images into a new folder /images/, for example /var/lib/vz/images/999"), + }, + listeners: { + change: function(field, nowSelected) { + if (nowSelected) { + const size = Proxmox.Utils.format_size(this.getCurrentSize()); + const targetSelector = this.up('pveQemuDiskBasic') + .down('pveDiskStorageSelector'); + + targetSelector.setSize(size); + targetSelector.down('#disksize').clearInvalid(); + } + }, + }, + }, { + xtype: 'radiofield', + name: me.getRadioName(), + inputValue: 'path', + boxLabel: gettext('Use an absolute path as source'), + hidden: Proxmox.UserName !== 'root@pam', + listeners: { + change: (radiofield, nowSelected) => { + const field = radiofield.next('textfield[name=sourcePath]'); + field.setHidden(!nowSelected); + field.setDisabled(!nowSelected); + + const targetSelector = me.down('pveDiskStorageSelector'); + if (nowSelected) { + targetSelector.setSize(0); + targetSelector.down('#disksize').clearInvalid(); + } + }, + enable: function() { + console.log('enable absolute path field'); + }, + disable: function() { + console.log('disable absolute path field'); + }, + }, + }, { + xtype: 'textfield', + fieldLabel: gettext('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.callParent(); + }, +}); diff --git a/www/manager6/qemu/disk/DiskCollection.js b/www/manager6/qemu/disk/DiskCollection.js new file mode 100644 index 00000000..11d39b46 --- /dev/null +++ b/www/manager6/qemu/disk/DiskCollection.js @@ -0,0 +1,275 @@ +Ext.define('PVE.qemu.DiskCollection', { + extend: 'Proxmox.panel.InputPanel', + alias: 'widget.pveQemuDiskCollection', + + insideWizard: false, + + hiddenDisks: [], + + leftColumnRatio: 0.25, + + column1: [ + { + // Adding to the panelContainer below automatically adds + // items to the store + xtype: 'gridpanel', + scrollable: true, + width: 100, + height: 50, + store: { + xtype: 'store', + storeId: '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('Device'), + dataIndex: 'device', + flex: 3, + resizable: false, + }, + { + flex: 1, + xtype: 'actioncolumn', + align: 'center', + menuDisabled: true, + items: [ + { + iconCls: 'x-fa fa-trash', + tooltip: 'Delete', + handler: function(button) { + button.up('pveQemuDiskCollection').removeCurrentDisk(); + }, + }, + ], + }, + ], + listeners: { + select: function(_, record) { + this.up('pveQemuDiskCollection') + .down('#panelContainer') + .setActiveItem(record.data.panel); + }, + }, + anchor: '100% 90%', + selectLast: function() { + this.setSelection(this.store.getLast()); + }, + dockedItems: [ + { + xtype: 'toolbar', + dock: 'bottom', + ui: 'footer', + style: { + backgroundColor: 'transparent', + }, + layout: { + pack: 'center', + }, + items: [ + { + iconCls: 'fa fa-plus-circle', + itemId: 'addDisk', + minWidth: '60', + handler: function(button) { + button.up('pveQemuDiskCollection').addDisk(); + }, + }, + ], + }, + ], + }, + ], + 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('diskstorage'); + store.add({ device: newPanel.getDevice(), panel: newPanel }); + container.setActiveItem(newPanel); + }, + remove: function(panelContainer, panel, eOpts) { + const store = Ext.getStore('diskstorage'); + store.removeByPanel(panel); + if (panelContainer.items.getCount() > 0) { + panelContainer.setActiveItem(0); + } + }, + }, + defaultItem: { + xtype: 'pveQemuHardDisk', + bind: { + nodename: '{nodename}', + }, + listeners: { + // newPanel ... cloned + added defaultItem + added: function(newPanel) { + Ext.Array.each(newPanel.down('pveControllerSelector').query('field'), + function(field) { + //the fields don't exist earlier + field.on('change', function() { + const store = Ext.getStore('diskstorage'); + + // find by panel object because it is unique + const recordIndex = store.findBy(record => + record.data.panel === field.up('pveQemuHardDisk'), + ); + const controllerSelector = field.up('pveControllerSelector'); + const newControllerAndId = controllerSelector.getValuesAsString(); + store.getAt(recordIndex).set('device', newControllerAndId); + }); + }, + ); + const wizard = this.up('pveQemuCreateWizard'); + Ext.Array.each(this.query('field'), function(field) { + field.on('change', wizard.validcheck); + field.on('validitychange', wizard.validcheck); + }); + }, + }, + validator: function() { + let valid = true; + const fields = this.query('field, fieldcontainer'); + Ext.Array.each(fields, function(field) { + 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 textfield with source path + addDisk(device, path) { + const initialValues = this.up('window').getValues(); + const item = Ext.clone(this.defaultItem); + item.insideWizard = this.insideWizard; + const added = this.add(item); + // values in the storage will be updated by listeners + if (path) { + // Need to explicitly deactivate when not rendered + added.down('radiofield[inputValue=empty]').setValue(false); + added.down('radiofield[inputValue=path]').setValue(true); + added.down('textfield[name=sourcePath]').setValue(path); + } else { + added.down('#empty').setValue(true); + } + const selector = added.down('pveControllerSelector'); + if (device) { + selector.setValue(device); + } else { + selector.setVMConfig(initialValues); + } + + 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); // not the gridpanel + leftColumnPanel.setFlex(me.leftColumnRatio); + // any other panel because this has no height yet + const panelHeight = me.up('tabpanel').items.get(0).getHeight(); + me.down('gridpanel').setHeight(panelHeight); + }, + + setNodename: function(nodename) { + this.nodename = nodename; + this.query('pveQemuHardDisk').forEach(p => p.setNodename(nodename)); + }, + + listeners: { + afterrender: function() { + const store = Ext.getStore('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 panel 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(); + }, + + initComponent: function() { + this.callParent(); + this.down('tableview').markDirty = false; + this.down('#panelContainer').insideWizard = this.insideWizard; + }, +}); diff --git a/www/manager6/qemu/disk/DiskOptions.js b/www/manager6/qemu/disk/DiskOptions.js new file mode 100644 index 00000000..cbd38e56 --- /dev/null +++ b/www/manager6/qemu/disk/DiskOptions.js @@ -0,0 +1,243 @@ +/* 'change' property is assigned a string and then a function */ +Ext.define('PVE.qemu.DiskOptions', { + extend: 'Proxmox.panel.InputPanel', + alias: 'widget.pveQemuDiskOptions', + onlineHelp: 'qm_hard_disk', + + insideWizard: false, + + unused: false, // ADD usused disk imaged + + padding: '10 10 10 10', + + vmconfig: {}, // used to select usused disks + + viewModel: {}, + + /** + * All radiofields in pveQemuDiskCollection have the same scope + * Make name of radiofields unique for each disk panel + */ + getRadioName() { + return 'radio_' + this.id; + }, + + onGetValues: function(values) { + let me = this; + + let params = {}; + + const simpleValues = me.up('pveQemuHardDisk').down('pveQemuDiskBasic').getValues(); + const confidArray = Object.entries(simpleValues).filter(([key, _]) => key !== "import_sources"); + // confidArray contains 1 array of length 2, e.g. confidArray = [["sata1", "local:-1,format=qcow2"]] + const confid = confidArray.shift().shift(); + me.drive.file = ''; // append to drive of simple panel + + PVE.Utils.propertyStringSet(me.drive, !values.backup, 'backup', '0'); + PVE.Utils.propertyStringSet(me.drive, values.noreplicate, 'replicate', 'no'); + PVE.Utils.propertyStringSet(me.drive, values.ssd, 'ssd', 'on'); + PVE.Utils.propertyStringSet(me.drive, values.iothread, 'iothread', 'on'); + + let names = ['mbps_rd', 'mbps_wr', 'iops_rd', 'iops_wr']; + Ext.Array.each(names, function(name) { + let burst_name = name + '_max'; + PVE.Utils.propertyStringSet(me.drive, values[name], name); + PVE.Utils.propertyStringSet(me.drive, values[burst_name], burst_name); + }); + + params[confid] = PVE.Parser.printQemuDrive(me.drive).replace(/^,/, ""); + + return params; + }, + + setVMConfig: function(vmconfig) { + let me = this; + + me.vmconfig = vmconfig; + + if (me.bussel) { + me.bussel.setVMConfig(vmconfig); + me.scsiController.setValue(vmconfig.scsihw); + } + if (me.unusedDisks) { + let disklist = []; + Ext.Object.each(vmconfig, function(key, value) { + if (key.match(/^unused\d+$/)) { + disklist.push([key, value]); + } + }); + me.unusedDisks.store.loadData(disklist); + me.unusedDisks.setValue(me.confid); + } + }, + + setDrive: function(drive) { + let me = this; + + me.drive = drive; + + let values = {}; + let match = drive.file.match(/^([^:]+):/); + if (match) { + values.hdstorage = match[1]; + } + + values.hdimage = drive.file; + values.backup = PVE.Parser.parseBoolean(drive.backup, 1); + values.noreplicate = !PVE.Parser.parseBoolean(drive.replicate, 1); + values.diskformat = drive.format || 'raw'; + values.cache = drive.cache || '__default__'; + values.discard = drive.discard === 'on'; + values.ssd = PVE.Parser.parseBoolean(drive.ssd); + values.iothread = PVE.Parser.parseBoolean(drive.iothread); + + values.mbps_rd = drive.mbps_rd; + values.mbps_wr = drive.mbps_wr; + values.iops_rd = drive.iops_rd; + values.iops_wr = drive.iops_wr; + values.mbps_rd_max = drive.mbps_rd_max; + values.mbps_wr_max = drive.mbps_wr_max; + values.iops_rd_max = drive.iops_rd_max; + values.iops_wr_max = drive.iops_wr_max; + + me.setValues(values); + }, + + + setNodename: function(nodename) { + // nothing + }, + + initComponent: function() { + let me = this; + + let labelWidth = 140; + + me.drive = {}; + + me.column1 = []; + me.column2 = []; + + me.column1.push( + { + xtype: 'proxmoxcheckbox', + disabled: me.confid && me.confid.match(/^virtio/), + fieldLabel: gettext('SSD emulation'), + labelWidth: labelWidth, + name: 'ssd', + reference: 'ssd', + }, + { + xtype: 'proxmoxcheckbox', + disabled: me.confid && !me.confid.match(/^(virtio|scsi)/), + fieldLabel: 'IO thread', + labelWidth: labelWidth, + reference: 'iothread', + name: 'iothread', + listeners: { + change: function(f, value) { + const disk = f.up('pveQemuHardDisk'); + if (disk.insideWizard) { + const vmScsiType = value ? 'virtio-scsi-single' : 'virtio-scsi-pci'; + disk.down('field[name=scsiController]').setValue(vmScsiType); + } + }, + }, + }, + { + xtype: 'numberfield', + name: 'mbps_rd', + minValue: 1, + step: 1, + fieldLabel: gettext('Read limit') + ' (MB/s)', + labelWidth: labelWidth, + emptyText: gettext('unlimited'), + }, + { + xtype: 'numberfield', + name: 'mbps_wr', + minValue: 1, + step: 1, + fieldLabel: gettext('Write limit') + ' (MB/s)', + labelWidth: labelWidth, + emptyText: gettext('unlimited'), + }, + { + xtype: 'proxmoxintegerfield', + name: 'iops_rd', + minValue: 10, + step: 10, + fieldLabel: gettext('Read limit') + ' (ops/s)', + labelWidth: labelWidth, + emptyText: gettext('unlimited'), + }, + { + xtype: 'proxmoxintegerfield', + name: 'iops_wr', + minValue: 10, + step: 10, + fieldLabel: gettext('Write limit') + ' (ops/s)', + labelWidth: labelWidth, + emptyText: gettext('unlimited'), + }, + ); + + me.column2.push( + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Backup'), + autoEl: { + tag: 'div', + 'data-qtip': gettext('Include volume in backup job'), + }, + labelWidth: labelWidth, + name: 'backup', + value: me.isCreate, + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Skip replication'), + labelWidth: labelWidth, + name: 'noreplicate', + }, + { + xtype: 'numberfield', + name: 'mbps_rd_max', + minValue: 1, + step: 1, + fieldLabel: gettext('Read max burst') + ' (MB)', + labelWidth: labelWidth, + emptyText: gettext('default'), + }, + { + xtype: 'numberfield', + name: 'mbps_wr_max', + minValue: 1, + step: 1, + fieldLabel: gettext('Write max burst') + ' (MB)', + labelWidth: labelWidth, + emptyText: gettext('default'), + }, + { + xtype: 'proxmoxintegerfield', + name: 'iops_rd_max', + minValue: 10, + step: 10, + fieldLabel: gettext('Read max burst') + ' (ops)', + labelWidth: labelWidth, + emptyText: gettext('default'), + }, + { + xtype: 'proxmoxintegerfield', + name: 'iops_wr_max', + minValue: 10, + step: 10, + fieldLabel: gettext('Write max burst') + ' (ops)', + labelWidth: labelWidth, + emptyText: gettext('default'), + }, + ); + + me.callParent(); + }, +}); diff --git a/www/manager6/qemu/disk/HardDisk.js b/www/manager6/qemu/disk/HardDisk.js new file mode 100644 index 00000000..6fc8c55f --- /dev/null +++ b/www/manager6/qemu/disk/HardDisk.js @@ -0,0 +1,137 @@ +/* 'change' property is assigned a string and then a function */ +Ext.define('PVE.qemu.HardDisk', { + extend: 'Ext.tab.Panel', + alias: 'widget.pveQemuHardDisk', + onlineHelp: 'qm_hard_disk', + + tabPosition: 'bottom', + plain: true, + + bind: { + nodename: '{nodename}', + }, + + insideWizard: false, + + setNodename: function(nodename) { + this.nodename = nodename; + this.items.each(panel => panel.setNodename(nodename)); + }, + + getDevice: function() { + return this.down('pveQemuDiskBasic').getDevice(); + }, + + items: [ + { + title: gettext('Basic'), + xtype: 'pveQemuDiskBasic', + isCreate: true, + bind: { + nodename: '{nodename}', + }, + }, + { + title: gettext('Options'), + xtype: 'pveQemuDiskOptions', + isCreate: true, + bind: { + nodename: '{nodename}', + }, + }, + ], + + beforeRender: function() { + const me = this; + // any other panel because this has no height yet + if (me.insideWizard) { + const panelHeight = me.up('tabpanel').items.get(0).getHeight(); + me.setHeight(panelHeight); + } + }, + initComponent: function() { + const me = this; + me.items.forEach(i => { i.insideWizard = me.insideWizard; }); + me.callParent(); + }, + + setVMConfig: function(vmconfig) { + this.items.each(panel => panel.setVMConfig(vmconfig)); + }, + }); + +Ext.define('PVE.qemu.HardDiskWindow', { + extend: 'Proxmox.window.Edit', + + isAdd: true, + + backgroundDelay: 5, + + setNodename: function(nodename) { + this.nodename = nodename; + this.down('pveQemuHDTabpanel').setNodename(nodename); + }, + + initComponent: function() { + let me = this; + + if (!me.nodename) { + throw "no node name specified"; + } + + let unused = me.confid && me.confid.match(/^unused\d+$/); + + me.isCreate = me.confid ? unused : true; + + let ipanel = Ext.create('PVE.qemu.HardDisk', { + confid: me.confid, + unused: unused, + isCreate: me.isCreate, + }); + ipanel.setNodename(me.nodename); + + if (unused) { + me.subject = gettext('Unused Disk'); + } else if (me.isCreate) { + me.subject = gettext('Hard Disk'); + } else { + me.subject = gettext('Hard Disk') + ' (' + me.confid + ')'; + } + + me.items = [ipanel]; + + me.callParent(); + /* 'data' is assigned an empty array in same file, and here we + * use it like an object + */ + me.load({ + success: function(response, options) { + ipanel.setVMConfig(response.result.data); + if (me.confid) { + let value = response.result.data[me.confid]; + let drive = PVE.Parser.parseQemuDrive(me.confid, value); + if (!drive) { + Ext.Msg.alert(gettext('Error'), 'Unable to parse drive options'); + me.close(); + return; + } + ipanel.setDrive(drive); + me.isValid(); // trigger validation + } + }, + }); + }, + getValues: function() { + let values = this.callParent(); + for (const [key, value] of Object.entries(values)) { + const re = /ide\d+|sata\d+|virtio\d+|scsi\d+|import_sources/; + if (key.match(re) && Array.isArray(value)) { + // Collected from different panels => array + // But API & some GUI functions expect not array + const sep = key === 'import_sources' ? '\0' : ','; + values[key] = value.join(sep); + } + } + return values; + }, +}); diff --git a/www/manager6/window/Wizard.js b/www/manager6/window/Wizard.js index 47d60b8e..de935fd0 100644 --- a/www/manager6/window/Wizard.js +++ b/www/manager6/window/Wizard.js @@ -245,6 +245,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.30.2