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 E6DFB6151F for ; Fri, 20 Nov 2020 10:38:21 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id E20ACEFC3 for ; Fri, 20 Nov 2020 10:38:21 +0100 (CET) Received: from dev.dominic.proxmox.com (212-186-127-178.static.upcbusiness.at [212.186.127.178]) by firstgate.proxmox.com (Proxmox) with ESMTP id 9EF17EF2F for ; Fri, 20 Nov 2020 10:38:14 +0100 (CET) Received: by dev.dominic.proxmox.com (Postfix, from userid 0) id 7C5C222365; Fri, 20 Nov 2020 10:38:14 +0100 (CET) From: =?UTF-8?q?Dominic=20J=C3=A4ger?= To: pve-devel@lists.proxmox.com Date: Fri, 20 Nov 2020 10:38:10 +0100 Message-Id: <20201120093812.65554-5-d.jaeger@proxmox.com> X-Mailer: git-send-email 2.20.1 In-Reply-To: <20201120093812.65554-1-d.jaeger@proxmox.com> References: <20201120093812.65554-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: 1 AWL -0.358 Adjusted score from AWL reputation of From: address 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 KHOP_HELO_FCRDNS 0.399 Relay HELO differs from its IP's reverse DNS NO_DNS_FOR_FROM 0.379 Envelope sender has no MX or A DNS records 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. [params.storage] Subject: [pve-devel] [PATCH manager 4/6 v2] gui: Add import VM wizard 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, 20 Nov 2020 09:38:21 -0000 Signed-off-by: Dominic Jäger --- v2: This patch is unchanged, but the lines are changed by patch 6. I'll rebase that soon. PVE/API2/Nodes.pm | 48 +++ www/manager6/Makefile | 2 + www/manager6/form/ControllerSelector.js | 26 +- www/manager6/qemu/HDEdit.js | 219 +++++++++----- www/manager6/qemu/ImportWizard.js | 379 ++++++++++++++++++++++++ www/manager6/qemu/MultiHDEdit.js | 267 +++++++++++++++++ www/manager6/window/Wizard.js | 153 +++++----- 7 files changed, 940 insertions(+), 154 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 1b133352..b0e386f9 100644 --- a/PVE/API2/Nodes.pm +++ b/PVE/API2/Nodes.pm @@ -27,6 +27,7 @@ use PVE::HA::Env::PVE2; use PVE::HA::Config; use PVE::QemuConfig; use PVE::QemuServer; +use PVE::QemuServer::OVF; use PVE::API2::Subscription; use PVE::API2::Services; use PVE::API2::Network; @@ -224,6 +225,7 @@ __PACKAGE__->register_method ({ { name => 'subscription' }, { name => 'report' }, { name => 'tasks' }, + { name => 'readovf' }, { name => 'rrd' }, # fixme: remove? { name => 'rrddata' },# fixme: remove? { name => 'replication' }, @@ -2137,6 +2139,52 @@ __PACKAGE__->register_method ({ return undef; }}); +__PACKAGE__->register_method ({ + name => 'readovf', + path => 'readovf', + method => 'GET', + protected => 1, # for worker upid file + proxyto => 'node', + description => "Read an .ovf manifest.", + parameters => { + additionalProperties => 0, + properties => { + node => get_standard_option('pve-node'), + manifest => { + description => ".ovf manifest", + type => 'string', + }, + }, + }, + returns => { + description => "VM config according to .ovf manifest and digest of manifest", + type => "object", + properties => PVE::QemuServer::json_config_properties({ + digest => { + type => 'string', + description => 'SHA1 digest of configuration file. This can be used to prevent concurrent modifications.', + }, + }), + }, + code => sub { + my ($param) = @_; + + my $filename = '/tmp/readovflog'; + open (my $fh, '>', $filename) or die "could not open file $filename"; + my $parsed = PVE::QemuServer::OVF::parse_ovf($param->{manifest}, 1, 1); + my $result; + $result->{digest} = Digest::SHA::sha1_hex($param->{manifest}); + $result->{cores} = $parsed->{qm}->{cores}; + $result->{name} = $parsed->{qm}->{name}; + $result->{memory} = $parsed->{qm}->{memory}; + + my $disks = $parsed->{disks}; + foreach my $disk (@$disks) { + $result->{$disk->{disk_address}} = "importsource=".$disk->{backing_file}; + } + return $result; +}}); + # bash completion helper sub complete_templet_repo { diff --git a/www/manager6/Makefile b/www/manager6/Makefile index 4fa8e1a3..bcd55fad 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -194,8 +194,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/form/ControllerSelector.js b/www/manager6/form/ControllerSelector.js index 9fdae5d1..d9fbfe66 100644 --- a/www/manager6/form/ControllerSelector.js +++ b/www/manager6/form/ControllerSelector.js @@ -68,6 +68,22 @@ clist_loop: deviceid.validate(); }, + getValues: function() { + return this.query('field').map(x => x.getValue()); + }, + + getValuesAsString: function() { + return this.getValues().join(''); + }, + + setValue: function(value) { + console.assert(value); + let regex = /([a-z]+)(\d+)/; + [_, controller, deviceid] = regex.exec(value); + this.query('field[name=controller]').pop().setValue(controller); + this.query('field[name=deviceid]').pop().setValue(deviceid); + }, + initComponent: function() { var me = this; @@ -85,16 +101,6 @@ clist_loop: noVirtIO: me.noVirtIO, allowBlank: false, flex: 2, - listeners: { - change: function(t, value) { - if (!value) { - return; - } - var field = me.down('field[name=deviceid]'); - field.setMaxValue(PVE.Utils.diskControllerMaxIDs[value]); - field.validate(); - } - } }, { xtype: 'proxmoxintegerfield', diff --git a/www/manager6/qemu/HDEdit.js b/www/manager6/qemu/HDEdit.js index 5e0a3981..f8e811e1 100644 --- a/www/manager6/qemu/HDEdit.js +++ b/www/manager6/qemu/HDEdit.js @@ -8,6 +8,10 @@ Ext.define('PVE.qemu.HDInputPanel', { unused: false, // ADD usused disk imaged + showSourcePathTextfield: false, // to import a disk from an aritrary path + + returnSingleKey: true, // {vmid}/importdisk expects multiple keys => false + vmconfig: {}, // used to select usused disks viewModel: {}, @@ -58,6 +62,38 @@ 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 MultiHDInputPanel to get the selectionf or each HDInputPanel => Make + names so that those in one HDInputPanel are equal but different from other + HDInputPanels + */ + getSourceTypeIdentifier() { + console.assert(this.id); + return 'sourceType_' + this.id; + }, + + // values ... the values from onGetValues + getSourceValue: function(values) { + console.assert(values); + let result; + let type = values[this.getSourceTypeIdentifier()]; + console.assert(type === 'storage' || type === 'path', + `type must be 'storage' or 'path' but is ${type}`); + if (type === 'storage') { + console.assert(values.sourceVolid, + "sourceVolid must be set when type is storage"); + result = values.sourceVolid; + } else { + console.assert(values.sourcePath, + "sourcePath must be set when type is path"); + result = values.sourcePath; + } + console.assert(result); + return result; + }, + onGetValues: function(values) { var me = this; @@ -67,16 +103,18 @@ Ext.define('PVE.qemu.HDInputPanel', { if (me.unused) { me.drive.file = me.vmconfig[values.unusedId]; confid = values.controller + values.deviceid; - } else if (me.isCreate && !me.isImport) { + } else if (me.isCreate) { // disk format & size should not be part of propertyString for import if (values.hdimage) { me.drive.file = values.hdimage; + } else if (me.isImport) { + me.drive.file = `${values.hdstorage}:0`; // so that API allows it } else { me.drive.file = values.hdstorage + ":" + values.disksize; } me.drive.format = values.diskformat; } - + PVE.Utils.propertyStringSet(me.drive, !values.backup, 'backup', '0'); PVE.Utils.propertyStringSet(me.drive, values.noreplicate, 'replicate', 'no'); PVE.Utils.propertyStringSet(me.drive, values.discard, 'discard', 'on'); @@ -90,15 +128,22 @@ Ext.define('PVE.qemu.HDInputPanel', { PVE.Utils.propertyStringSet(me.drive, values[name], name); PVE.Utils.propertyStringSet(me.drive, values[burst_name], burst_name); }); - if (me.isImport) { + + if (me.returnSingleKey) { + if (me.isImport) { + me.drive.importsource = this.getSourceValue(values); + } + params[confid] = PVE.Parser.printQemuDrive(me.drive); + } else { + console.assert(me.isImport, + "Returning multiple key/values is only allowed in import"); params.device_options = PVE.Parser.printPropertyString(me.drive); - params.source = values.sourceType === 'storage' - ? values.sourceVolid : values.sourcePath; + params.source = this.getSourceValue(values); params.device = values.controller + values.deviceid; params.storage = values.hdstorage; - if (values.diskformat) params.format = values.diskformat; - } else { - params[confid] = PVE.Parser.printQemuDrive(me.drive); + if (values.diskformat) { + params.format = values.diskformat; + } } return params; }, @@ -156,10 +201,16 @@ 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); me.down('#hdimage').setStorage(undefined, nodename); + // me.down('#sourceStorageSelector').setNodename(nodename); + // me.down('#sourceFileSelector').setNodename(nodename); }, initComponent : function() { @@ -175,12 +226,16 @@ Ext.define('PVE.qemu.HDInputPanel', { me.advancedColumn1 = []; me.advancedColumn2 = []; + let nodename = this.getViewModel().get('nodename'); // TODO hacky whacky + + if (!me.confid || me.unused) { - let controllerColumn = me.isImport ? me.column2 : me.column1; + let controllerColumn = me.showSourcePathTextfield ? me.column2 : me.column1; me.bussel = Ext.create('PVE.form.ControllerSelector', { + itemId: 'bussel', vmconfig: me.insideWizard ? {ide2: 'cdrom'} : {} }); - if (me.isImport) { + if (me.showSourcePathTextfield) { me.bussel.fieldLabel = 'Target Device'; } controllerColumn.push(me.bussel); @@ -210,16 +265,16 @@ Ext.define('PVE.qemu.HDInputPanel', { allowBlank: false }); me.column1.push(me.unusedDisks); - } else if (me.isCreate || me.isImport) { + } else if (me.isCreate || me.showSourcePathTextfield) { let selector = { xtype: 'pveDiskStorageSelector', storageContent: 'images', name: 'disk', - nodename: me.nodename, - hideSize: me.isImport, - autoSelect: me.insideWizard || me.isImport, + nodename: nodename, + hideSize: me.showSourcePathTextfield, + autoSelect: me.insideWizard || me.showSourcePathTextfield, }; - if (me.isImport) { + if (me.showSourcePathTextfield) { selector.storageLabel = gettext('Target storage'); me.column2.push(selector); } else { @@ -235,7 +290,7 @@ Ext.define('PVE.qemu.HDInputPanel', { }); } - if (me.isImport) { + if (me.showSourcePathTextfield) { me.column2.push({ xtype: 'box', autoEl: { tag: 'hr' }, @@ -255,72 +310,83 @@ Ext.define('PVE.qemu.HDInputPanel', { name: 'discard' } ); - if (me.isImport) { + if (me.showSourcePathTextfield) { let show = (element, value) => { element.setHidden(!value); element.setDisabled(!value); }; - me.sourceRadioStorage = Ext.create('Ext.form.field.Radio', { - name: 'sourceType', - inputValue: 'storage', - boxLabel: gettext('Use a storage as source'), - checked: true, - hidden: Proxmox.UserName !== 'root@pam', - listeners: { - added: () => show(me.sourcePathTextfield, false), - change: (_, storageRadioChecked) => { - show(me.sourcePathTextfield, !storageRadioChecked); - let selectors = [ - me.sourceStorageSelector, - me.sourceFileSelector, - ]; - for (const selector of selectors) { - show(selector, storageRadioChecked); - } + + me.column1.unshift( + { + xtype: 'radiofield', + itemId: 'sourceRadioStorage', + name: me.getSourceTypeIdentifier(), + inputValue: 'storage', + boxLabel: gettext('Use a storage as source'), + hidden: Proxmox.UserName !== 'root@pam', + checked: true, + listeners: { + change: (_, newValue) => { + let storageSelectors = [ + me.down('#sourceStorageSelector'), + me.down('#sourceFileSelector'), + ]; + for (const selector of storageSelectors) { + show(selector, newValue); + } + }, }, - }, - }); - me.sourceStorageSelector = Ext.create('PVE.form.StorageSelector', { - name: 'inputImageStorage', - nodename: me.nodename, - fieldLabel: gettext('Source Storage'), - storageContent: 'images', - autoSelect: me.insideWizard, - listeners: { - change: function(_, selectedStorage) { - me.sourceFileSelector.setStorage(selectedStorage); + }, { + xtype: 'pveStorageSelector', + itemId: 'sourceStorageSelector', + name: 'inputImageStorage', + nodename: 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', // TODO scope of itemId is container, this breaks onGetValues, only one thingy is selected for multiple inputpanels + nodename: nodename, + storageContent: 'images', + hidden: true, + disabled: true, + fieldLabel: gettext('Source Image'), + }, { + xtype: 'radiofield', + itemId: 'sourceRadioPath', + name: me.getSourceTypeIdentifier(), + inputValue: 'path', + boxLabel: gettext('Use an absolute path as source'), + hidden: Proxmox.UserName !== 'root@pam', + listeners: { + change: (_, newValue) => { + show(me.down('#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: (insertedText) => + insertedText.startsWith('/') || + gettext('Must be an absolute path'), }, - }); - me.sourceFileSelector = Ext.create('PVE.form.FileSelector', { - name: 'sourceVolid', - nodename: me.nodename, - storageContent: 'images', - fieldLabel: gettext('Source Image'), - }); - me.sourceRadioPath = Ext.create('Ext.form.field.Radio', { - name: 'sourceType', - inputValue: 'path', - boxLabel: gettext('Use an absolute path as source'), - hidden: Proxmox.UserName !== 'root@pam', - }); - me.sourcePathTextfield = Ext.create('Ext.form.field.Text', { - xtype: 'textfield', - fieldLabel: gettext('Source Path'), - name: 'sourcePath', - emptyText: '/home/user/disk.qcow2', - hidden: Proxmox.UserName !== 'root@pam', - validator: function(insertedText) { - return insertedText.startsWith('/') || - gettext('Must be an absolute path'); - }, - }); - me.column1.unshift( - me.sourceRadioStorage, - me.sourceStorageSelector, - me.sourceFileSelector, - me.sourceRadioPath, - me.sourcePathTextfield, ); } @@ -465,7 +531,8 @@ Ext.define('PVE.qemu.HDEdit', { nodename: nodename, unused: unused, isCreate: me.isCreate, - isImport: me.isImport, + showSourcePathTextfield: me.isImport, + returnSingleKey: !me.isImport, }); var subject; diff --git a/www/manager6/qemu/ImportWizard.js b/www/manager6/qemu/ImportWizard.js new file mode 100644 index 00000000..c6e91a48 --- /dev/null +++ b/www/manager6/qemu/ImportWizard.js @@ -0,0 +1,379 @@ +/*jslint confusion: true*/ +Ext.define('PVE.qemu.ImportWizard', { + extend: 'PVE.window.Wizard', + alias: 'widget.pveQemuImportWizard', + mixins: ['Proxmox.Mixin.CBind'], + + viewModel: { + data: { + nodename: '', + current: { + scsihw: '' // TODO there is some error with apply after render_scsihw?? + } + } + }, + + cbindData: { + nodename: undefined + }, + + subject: gettext('Import Virtual Machine'), + + isImport: true, + + addDiskFunction: function () { + let me = this; + let wizard; + if (me.xtype === 'button') { + wizard = me.up('window'); + } else if (me.xtype === 'pveQemuImportWizard') { + wizard = me; + } + console.assert(wizard.xtype === 'pveQemuImportWizard'); + let multihd = wizard.down('pveQemuMultiHDInputPanel'); + multihd.addDiskFunction(); + }, + + items: [ + { + xtype: 'inputpanel', + title: gettext('Import'), + 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: [ + // { // TODO implement the rest + // xtype: 'filebutton', + // text: gettext('Load local manifest ...'), + // allowBlank: true, + // hidden: Proxmox.UserName !== 'root@pam', + // disabled: Proxmox.UserName !== 'root@pam', + // listeners: { + // change: (button,event,) => { + // var reader = new FileReader(); + // let wizard = button.up('window'); + // reader.onload = (e) => { + // let uploaded_ovf = e.target.result; + // // TODO set fields here + // // TODO When to upload disks to server? + // }; + // reader.readAsText(event.target.files[0]); + // button.disable(); // TODO implement complete reload + // wizard.down('#successTextfield').show(); + // } + // } + // }, + { + 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', + value: '/mnt/pve/cifs/importing/ovf_from_hyperv/pve/pve.ovf', + emptyText: '/mnt/nfs/exported.ovf', + fieldLabel: 'Absolute path to .ovf manifest on your PVE host', + }, + { + xtype: 'proxmoxButton', + text: gettext('Load remote manifest'), + handler: function() { + let panel = this.up('panel'); + let nodename = panel.down('pveNodeSelector').getValue(); + // independent of onGetValues(), so that value of + // ovf_textfield can be removed for submit + let ovf_textfield_value = panel.down('#server_ovf_manifest').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) { + let path = ovfdata[device].split('=')[1]; + multihd.addDiskFunction(device, path); + } + }, + 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: 'Hard Disk', + }, + { + 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 kv = wizard.getValues(); + delete kv['delete']; + + var nodename = kv.nodename; + delete kv.nodename; + + Proxmox.Utils.API2Request({ + url: '/nodes/' + nodename + '/qemu', + waitMsgTarget: wizard, + method: 'POST', + params: kv, + success: function(response){ + wizard.close(); + }, + failure: function(response, opts) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + } + }); + } + } + ], + initComponent: function () { + var me = this; + me.callParent(); + + let addDiskButton = { + text: gettext('Add disk'), + disabled: true, + itemId: 'addDisk', + minWidth: 60, + handler: me.addDiskFunction, + isValid: function () { + let isValid = true; + if (!me.isImport) { + isValid = false; + } + let type = me.down('#wizcontent').getActiveTab().xtype; + if (type !== 'pveQemuHDInputPanel') { + isValid=false; + } + return isValid; + }, + }; + + let removeDiskButton = { + text: gettext('Remove disk'), // TODO implement + disabled: false, + itemId: 'removeDisk', + minWidth: 60, + handler: function() { + console.assert(me.xtype === 'pveQemuImportWizard'); + let multihd = me.down('pveQemuMultiHDInputPanel'); + multihd.removeCurrentDisk(); + }, + }; + me.down('toolbar').insert(4, addDiskButton); + me.down('toolbar').insert(5, removeDiskButton); + }, +}); + + + + diff --git a/www/manager6/qemu/MultiHDEdit.js b/www/manager6/qemu/MultiHDEdit.js new file mode 100644 index 00000000..632199ba --- /dev/null +++ b/www/manager6/qemu/MultiHDEdit.js @@ -0,0 +1,267 @@ +Ext.define('PVE.qemu.MultiHDInputPanel', { + extend: 'Proxmox.panel.InputPanel', + alias: 'widget.pveQemuMultiHDInputPanel', + + insideWizard: false, + + hiddenDisks: [], + + leftColumnRatio: 0.2, + + 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) { + console.assert(panel.xtype === 'pveQemuHDInputPanel'); + 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% 100%', // Required because resize does not happen yet + }, + ], + 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 previousCount = store.data.getCount(); + let indexOfRemoved = store.removeByPanel(HDInputPanel); + console.assert(store.data.getCount() === previousCount - 1, + 'Nothing has been removed from the store.' + + `It still has ${store.data.getCount()} items.`, + ); + if (HDInputPanelContainer.items.getCount() > 0) { + console.assert(indexOfRemoved >= 1); + HDInputPanelContainer.setActiveItem(indexOfRemoved - 1); + } + }, + }, + defaultItem: { + xtype: 'pveQemuHDInputPanel', + bind: { + nodename: '{nodename}', + viewModel: '{viewModel}', + }, + isCreate: true, + isImport: true, + showSourcePathTextfield: true, + returnSingleKey: true, + insideWizard: true, + 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'), + ); + console.assert( + newHDInputPanel === field.up('pveQemuHDInputPanel'), + 'Those panels should be the same', + ); + console.assert(recordIndex !== -1); + let newControllerAndId = controllerSelector.getValuesAsString(); + store.getAt(recordIndex).set('device', newControllerAndId); + }); + }, + ); + }, + beforerender: function() { + 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() { + console.debug('hdedit validator'); + var valid = true; + var fields = this.query('field, fieldcontainer'); + if (this.isXType('fieldcontainer')) { + console.assert(false); + fields.unshift(this); + } + Ext.Array.each(fields, function(field) { + // Note: not all fielcontainer have isValid() + if (Ext.isFunction(field.isValid) && !field.isValid()) { + valid = false; + console.debug('field is invalid'); + console.debug(field); + } + }); + 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(); + } + 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); + } else { + // TODO Add tooltip to Remove disk button + } + }, + }, + ], + + 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() { + // any other panel because this has no height yet + let panelHeight = this.up('tabpanel').items.items[0].getHeight(); + let leftColumnContainer = this.items.items[0]; + let rightColumnContainer = this.items.items[1]; + leftColumnContainer.setHeight(panelHeight); + + leftColumnContainer.columnWidth = this.leftColumnRatio; + rightColumnContainer.columnWidth = 1 - this.leftColumnRatio; + }, + + // Call with defined parameter or without (static function so to say) + hasDuplicateDevices: function(values) { + if (!values) { + values = this.up('form').getValues(); + } + console.assert(values); + 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 87e4bf0a..f16ba107 100644 --- a/www/manager6/window/Wizard.js +++ b/www/manager6/window/Wizard.js @@ -35,6 +35,75 @@ Ext.define('PVE.window.Wizard', { return values; }, + check_card: function(card) { + var valid = true; + var fields = card.query('field, fieldcontainer'); + if (card.isXType('fieldcontainer')) { + fields.unshift(card); + } + Ext.Array.each(fields, function(field) { + // Note: not all fielcontainer have isValid() + if (Ext.isFunction(field.isValid) && !field.isValid()) { + valid = false; + } + }); + + if (Ext.isFunction(card.validator)) { + return card.validator(); + } + + return valid; + }, + + disable_at: function(card) { + let window = this; + var topbar = window.down('#wizcontent'); + var idx = topbar.items.indexOf(card); + for(;idx < topbar.items.getCount();idx++) { + var nc = topbar.items.getAt(idx); + if (nc) { + nc.disable(); + } + } + }, + + validcheck: function() { + console.debug('Validcheck'); + let window = this.up('window'); + var topbar = window.down('#wizcontent'); + + // check tabs from current to the last enabled for validity + // since we might have changed a validity on a later one + var i; + for (i = topbar.curidx; i <= topbar.maxidx && i < topbar.items.getCount(); i++) { + var tab = topbar.items.getAt(i); + var valid = window.check_card(tab); + + // only set the buttons on the current panel + if (i === topbar.curidx) { + if (window.isImport) { + console.debug('valid in window?'); + console.debug(valid); + console.debug('because tab is'); + console.debug(tab); + window.down('#addDisk').setDisabled(!valid); + } + window.down('#next').setDisabled(!valid); + window.down('#submit').setDisabled(!valid); + } + + // if a panel is invalid, then disable it and all following, + // else enable it and go to the next + var ntab = topbar.items.getAt(i + 1); + if (!valid) { + window.disable_at(ntab); + return; + } else if (ntab && !tab.onSubmit) { + ntab.enable(); + } + } + }, + initComponent: function() { var me = this; @@ -53,40 +122,6 @@ Ext.define('PVE.window.Wizard', { }); tabs[0].disabled = false; - var maxidx = 0; - var curidx = 0; - - var check_card = function(card) { - var valid = true; - var fields = card.query('field, fieldcontainer'); - if (card.isXType('fieldcontainer')) { - fields.unshift(card); - } - Ext.Array.each(fields, function(field) { - // Note: not all fielcontainer have isValid() - if (Ext.isFunction(field.isValid) && !field.isValid()) { - valid = false; - } - }); - - if (Ext.isFunction(card.validator)) { - return card.validator(); - } - - return valid; - }; - - var disable_at = function(card) { - var tp = me.down('#wizcontent'); - var idx = tp.items.indexOf(card); - for(;idx < tp.items.getCount();idx++) { - var nc = tp.items.getAt(idx); - if (nc) { - nc.disable(); - } - } - }; - var tabchange = function(tp, newcard, oldcard) { if (newcard.onSubmit) { me.down('#next').setVisible(false); @@ -95,16 +130,23 @@ Ext.define('PVE.window.Wizard', { me.down('#next').setVisible(true); me.down('#submit').setVisible(false); } - var valid = check_card(newcard); + var valid = me.check_card(newcard); + let addDiskButton = me.down('#addDisk'); // TODO undefined in first invocation? + if (me.isImport && addDiskButton) { + addDiskButton.setDisabled(!valid); // TODO check me + addDiskButton.setHidden(!addDiskButton.isValid()); + addDiskButton.setDisabled(false); + addDiskButton.setHidden(false); + } me.down('#next').setDisabled(!valid); me.down('#submit').setDisabled(!valid); me.down('#back').setDisabled(tp.items.indexOf(newcard) == 0); var idx = tp.items.indexOf(newcard); - if (idx > maxidx) { - maxidx = idx; + if (idx > tp.maxidx) { + tp.maxidx = idx; } - curidx = idx; + tp.curidx = idx; var next = idx + 1; var ntab = tp.items.getAt(next); @@ -135,6 +177,8 @@ Ext.define('PVE.window.Wizard', { items: [{ itemId: 'wizcontent', xtype: 'tabpanel', + maxidx: 0, + curidx: 0, activeItem: 0, bodyPadding: 10, listeners: { @@ -201,7 +245,7 @@ Ext.define('PVE.window.Wizard', { var tp = me.down('#wizcontent'); var atab = tp.getActiveTab(); - if (!check_card(atab)) { + if (!me.check_card(atab)) { return; } @@ -234,35 +278,8 @@ Ext.define('PVE.window.Wizard', { }); Ext.Array.each(me.query('field'), function(field) { - var validcheck = function() { - var tp = me.down('#wizcontent'); - - // check tabs from current to the last enabled for validity - // since we might have changed a validity on a later one - var i; - for (i = curidx; i <= maxidx && i < tp.items.getCount(); i++) { - var tab = tp.items.getAt(i); - var valid = check_card(tab); - - // only set the buttons on the current panel - if (i === curidx) { - me.down('#next').setDisabled(!valid); - me.down('#submit').setDisabled(!valid); - } - - // if a panel is invalid, then disable it and all following, - // else enable it and go to the next - var ntab = tp.items.getAt(i + 1); - if (!valid) { - disable_at(ntab); - return; - } else if (ntab && !tab.onSubmit) { - ntab.enable(); - } - } - }; - field.on('change', validcheck); - field.on('validitychange', validcheck); + field.on('change', me.validcheck); + field.on('validitychange', me.validcheck); }); } }); -- 2.20.1