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 3F0BC63578 for ; Wed, 10 Feb 2021 10:49:55 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 33992F116 for ; Wed, 10 Feb 2021 10:49:55 +0100 (CET) Received: from proxmox-new.maurer-it.com (proxmox-new.maurer-it.com [212.186.127.180]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by firstgate.proxmox.com (Proxmox) with ESMTPS id 61EF9F10C for ; Wed, 10 Feb 2021 10:49:52 +0100 (CET) Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1]) by proxmox-new.maurer-it.com (Proxmox) with ESMTP id 25B9D41C81 for ; Wed, 10 Feb 2021 10:49:52 +0100 (CET) Date: Wed, 10 Feb 2021 10:49:41 +0100 From: Fabian =?iso-8859-1?q?Gr=FCnbichler?= To: Proxmox VE development discussion References: <20210205100442.28163-1-d.jaeger@proxmox.com> <20210205100442.28163-2-d.jaeger@proxmox.com> In-Reply-To: <20210205100442.28163-2-d.jaeger@proxmox.com> MIME-Version: 1.0 User-Agent: astroid/0.15.0 (https://github.com/astroidmail/astroid) Message-Id: <1612950071.k22hsf3al3.astroid@nora.none> Content-Type: text/plain; charset=utf-8 Content-Transfer-Encoding: quoted-printable X-SPAM-LEVEL: Spam detection results: 0 AWL 0.026 Adjusted score from AWL reputation of From: address KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record URIBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to URIBL was blocked. See http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more information. [params.storage, proxmox.com, nodes.pm] Subject: Re: [pve-devel] [PATCH v4 manager] gui: Add import wizard 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: Wed, 10 Feb 2021 09:49:55 -0000 haven't taken a closer look at the GUI stuff as I think the backend will=20 potentially change a bit more. also regarding the permissions and the problem of importing from=20 arbitrary paths, I wonder whether a simple file upload to a dir storage=20 to import/$authuser/$file if the user has permissions to allocate space on the storage would help?=20 those files could then be referenced for import purposes by that user=20 without needing root privileges. and we just pass them to qemu-img=20 convert which would make the attack surface not that high hopefully=20 (barring issues in the handling of formats, but we could just allow raw=20 to avoid that in the beginning ;)) anyway, might be a nice follow-up once this series has landed.. On February 5, 2021 11:04 am, Dominic J=C3=A4ger wrote: > Add GUI wizard to import whole VMs and a window to import single disks in > Hardware View. >=20 >=20 > Signed-off-by: Dominic J=C3=A4ger > --- > The wizard works, but there is still quite some refactoring to do >=20 > v3->v4: > * Allow only root > * Adapt to API changes >=20 >=20 > PVE/API2/Nodes.pm | 40 +++ > www/manager6/Makefile | 2 + > www/manager6/Workspace.js | 15 + > www/manager6/form/ControllerSelector.js | 15 + > www/manager6/node/CmdMenu.js | 13 + > www/manager6/qemu/HDEdit.js | 194 ++++++++++++- > www/manager6/qemu/HardwareView.js | 25 ++ > www/manager6/qemu/ImportWizard.js | 356 ++++++++++++++++++++++++ > www/manager6/qemu/MultiHDEdit.js | 282 +++++++++++++++++++ > www/manager6/window/Wizard.js | 2 + > 10 files changed, 930 insertions(+), 14 deletions(-) > create mode 100644 www/manager6/qemu/ImportWizard.js > create mode 100644 www/manager6/qemu/MultiHDEdit.js >=20 > diff --git a/PVE/API2/Nodes.pm b/PVE/API2/Nodes.pm > index 8172231e..9bf75ab7 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 =3D> 'subscription' }, > { name =3D> 'report' }, > { name =3D> 'tasks' }, > + { name =3D> 'readovf' }, > { name =3D> 'rrd' }, # fixme: remove? > { name =3D> 'rrddata' },# fixme: remove? > { name =3D> 'replication' }, > @@ -2173,6 +2175,44 @@ __PACKAGE__->register_method ({ > return undef; > }}); this API endpoint belongs in qemu-server? > =20 > +__PACKAGE__->register_method ({ > + name =3D> 'readovf', > + path =3D> 'readovf', > + method =3D> 'GET', > + proxyto =3D> 'node', > + description =3D> "Read an .ovf manifest.", > + parameters =3D> { > + additionalProperties =3D> 0, > + properties =3D> { > + node =3D> get_standard_option('pve-node'), > + manifest =3D> { > + description =3D> ".ovf manifest", > + type =3D> 'string', > + }, > + }, > + }, > + returns =3D> { > + description =3D> "VM config according to .ovf manifest and digest of ma= nifest", > + type =3D> "object", according to the code below, this has a defined schema? > + }, > + code =3D> sub { > + my ($param) =3D @_; > + > + my $manifest =3D $param->{manifest}; > + die "$manifest: non-existent or non-regular file\n" if (! -f $manifest)= ; > + > + my $parsed =3D PVE::QemuServer::OVF::parse_ovf($manifest, 0, 1); > + my $result; > + $result->{cores} =3D $parsed->{qm}->{cores}; > + $result->{name} =3D $parsed->{qm}->{name}; > + $result->{memory} =3D $parsed->{qm}->{memory}; > + my $disks =3D $parsed->{disks}; > + foreach my $disk (@$disks) { > + $result->{$disk->{disk_address}} =3D $disk->{backing_file}; > + } > + return $result; > +}}); > + > # bash completion helper > =20 > sub complete_templet_repo { > diff --git a/www/manager6/Makefile b/www/manager6/Makefile > index 85f90ecd..2969ed19 100644 > --- a/www/manager6/Makefile > +++ b/www/manager6/Makefile > @@ -196,8 +196,10 @@ JSSRC=3D \ > 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/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', { > }, > }); > =20 > + var importVM =3D Ext.createWidget('button', { > + pack: 'end', > + margin: '3 5 0 0', > + baseCls: 'x-btn', > + iconCls: 'fa fa-desktop', > + text: gettext("Import VM"), > + hidden: Proxmox.UserName !=3D=3D 'root@pam', > + handler: function() { > + var wiz =3D Ext.create('PVE.qemu.ImportWizard', {}); > + wiz.show(); > + }, > + }); > + > sprovider.on('statechange', function(sp, key, value) { > if (key =3D=3D=3D 'GuiCap' && value) { > caps =3D value; > createVM.setDisabled(!caps.vms['VM.Allocate']); > createCT.setDisabled(!caps.vms['VM.Allocate']); > + importVM.setDisabled(!caps.vms['VM.Allocate']); > } > }); > =20 > @@ -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(); > }, > =20 > + getValues: function() { > + return this.query('field').map(x =3D> x.getValue()); > + }, > + > + getValuesAsString: function() { > + return this.getValues().join(''); > + }, > + > + setValue: function(value) { > + let regex =3D /([a-z]+)(\d+)/; > + let [_, controller, deviceid] =3D regex.exec(value); > + this.down('field[name=3Dcontroller]').setValue(controller); > + this.down('field[name=3Ddeviceid]').setValue(deviceid); > + }, > + > initComponent: function() { > var me =3D this; > =20 > 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 !=3D=3D 'root@pam', > + itemId: 'importvm', > + iconCls: 'fa fa-cube', > + handler: function() { > + var me =3D this.up('menu'); > + var wiz =3D 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..5d039134 100644 > --- a/www/manager6/qemu/HDEdit.js > +++ b/www/manager6/qemu/HDEdit.js > @@ -8,6 +8,10 @@ Ext.define('PVE.qemu.HDInputPanel', { > =20 > unused: false, // ADD usused disk imaged > =20 > + showSourcePathTextfield: false, // to import a disk from an aritrary= path > + > + returnSingleKey: true, // {vmid}/importdisk expects multiple keys = =3D> false > + > vmconfig: {}, // used to select usused disks > =20 > viewModel: {}, > @@ -58,6 +62,29 @@ Ext.define('PVE.qemu.HDInputPanel', { > }, > }, > =20 > + /* > + All radiofields (esp. sourceRadioPath and sourceRadioStorage) have t= he > + same scope for name. But we need a different scope for each HDInputP= anel in > + a MultiHDInputPanel to get the selectionf or each HDInputPanel =3D> = Make > + names so that those in one HDInputPanel are equal but different from= other > + HDInputPanels > + */ > + getSourceTypeIdentifier() { > + return 'sourceType_' + this.id; > + }, > + > + // values ... the values from onGetValues > + getSourceValue: function(values) { > + let result; > + let type =3D values[this.getSourceTypeIdentifier()]; > + if (type =3D=3D=3D 'storage') { > + result =3D values.sourceVolid; > + } else { > + result =3D values.sourcePath; > + } > + return result; > + }, > + > onGetValues: function(values) { > var me =3D this; > =20 > @@ -68,8 +95,12 @@ Ext.define('PVE.qemu.HDInputPanel', { > me.drive.file =3D me.vmconfig[values.unusedId]; > confid =3D values.controller + values.deviceid; > } else if (me.isCreate) { > + // disk format & size should not be part of propertyString for impo= rt > if (values.hdimage) { > me.drive.file =3D values.hdimage; > + } else if (me.isImport) { > + me.drive.file =3D `${values.hdstorage}:0`; // so that API allows it > + me.test =3D `test`; > } else { > me.drive.file =3D values.hdstorage + ":" + values.disksize; > } > @@ -83,16 +114,31 @@ Ext.define('PVE.qemu.HDInputPanel', { > PVE.Utils.propertyStringSet(me.drive, values.iothread, 'iothread', 'on'= ); > PVE.Utils.propertyStringSet(me.drive, values.cache, 'cache'); > =20 > - var names =3D ['mbps_rd', 'mbps_wr', 'iops_rd', 'iops_wr']; > - Ext.Array.each(names, function(name) { > - var burst_name =3D name + '_max'; > + var names =3D ['mbps_rd', 'mbps_wr', 'iops_rd', 'iops_wr']; > + Ext.Array.each(names, function(name) { > + var burst_name =3D name + '_max'; > PVE.Utils.propertyStringSet(me.drive, values[name], name); > PVE.Utils.propertyStringSet(me.drive, values[burst_name], burst_nam= e); > - }); > - > - > - params[confid] =3D PVE.Parser.printQemuDrive(me.drive); > + }); > =20 > + if (me.returnSingleKey) { > + if (me.isImport) { > + me.drive.importsource =3D this.getSourceValue(values); > + params.diskimages =3D [confid, me.drive.importsource].join('=3D'); > + } > + delete me.drive.importsource; > + params[confid] =3D PVE.Parser.printQemuDrive(me.drive); > + } else { > + delete me.drive.file; > + delete me.drive.format; > + params.device_options =3D PVE.Parser.printPropertyString(me.drive); > + params.source =3D this.getSourceValue(values); > + params.device =3D values.controller + values.deviceid; > + params.storage =3D values.hdstorage; > + if (values.diskformat) { > + params.format =3D values.diskformat; > + } > + } > return params; > }, > =20 > @@ -149,10 +195,16 @@ Ext.define('PVE.qemu.HDInputPanel', { > me.setValues(values); > }, > =20 > + getDevice: function() { > + return this.bussel.getValuesAsString(); > + }, > + > setNodename: function(nodename) { > var me =3D this; > me.down('#hdstorage').setNodename(nodename); > me.down('#hdimage').setStorage(undefined, nodename); > + // me.down('#sourceStorageSelector').setNodename(nodename); > + // me.down('#sourceFileSelector').setNodename(nodename); > }, > =20 > initComponent: function() { > @@ -168,11 +220,18 @@ Ext.define('PVE.qemu.HDInputPanel', { > me.advancedColumn1 =3D []; > me.advancedColumn2 =3D []; > =20 > + > + let nodename =3D me.nodename; > if (!me.confid || me.unused) { > + let controllerColumn =3D me.showSourcePathTextfield ? me.column2 : = me.column1; > me.bussel =3D Ext.create('PVE.form.ControllerSelector', { > + itemId: 'bussel', > vmconfig: me.insideWizard ? { ide2: 'cdrom' } : {}, > }); > - me.column1.push(me.bussel); > + if (me.showSourcePathTextfield) { > + me.bussel.fieldLabel =3D 'Target Device'; > + } > + controllerColumn.push(me.bussel); > =20 > me.scsiController =3D Ext.create('Ext.form.field.Display', { > fieldLabel: gettext('SCSI Controller'), > @@ -184,7 +243,7 @@ Ext.define('PVE.qemu.HDInputPanel', { > submitValue: false, > hidden: true, > }); > - me.column1.push(me.scsiController); > + controllerColumn.push(me.scsiController); > } > =20 > if (me.unused) { > @@ -199,14 +258,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.showSourcePathTextfield) { > + let selector =3D { > xtype: 'pveDiskStorageSelector', > storageContent: 'images', > name: 'disk', > nodename: me.nodename, > - autoSelect: me.insideWizard, > - }); > + hideSize: me.showSourcePathTextfield, > + autoSelect: me.insideWizard || me.showSourcePathTextfield, > + }; > + if (me.showSourcePathTextfield) { > + selector.storageLabel =3D gettext('Target storage'); > + me.column2.push(selector); > + } else { > + me.column1.push(selector); > + } > } else { > me.column1.push({ > xtype: 'textfield', > @@ -217,6 +283,12 @@ Ext.define('PVE.qemu.HDInputPanel', { > }); > } > =20 > + if (me.showSourcePathTextfield) { > + me.column2.push({ > + xtype: 'box', > + autoEl: { tag: 'hr' }, > + }); > + } > me.column2.push( > { > xtype: 'CacheTypeSelector', > @@ -231,6 +303,90 @@ Ext.define('PVE.qemu.HDInputPanel', { > name: 'discard', > }, > ); > + if (me.showSourcePathTextfield) { > + let show =3D (element, value) =3D> { > + element.setHidden(!value); > + element.setDisabled(!value); > + }; > + > + me.column1.unshift( > + { > + xtype: 'radiofield', > + itemId: 'sourceRadioStorage', > + name: me.getSourceTypeIdentifier(), > + inputValue: 'storage', > + boxLabel: gettext('Use a storage as source'), > + hidden: Proxmox.UserName !=3D=3D 'root@pam', > + checked: true, > + listeners: { > + change: (_, newValue) =3D> { > + let storageSelectors =3D [ > + me.down('#sourceStorageSelector'), > + me.down('#sourceFileSelector'), > + ]; > + for (const selector of storageSelectors) { > + show(selector, newValue); > + } > + }, > + }, > + }, { > + 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', > + nodename: 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.getSourceTypeIdentifier(), > + inputValue: 'path', > + boxLabel: gettext('Use an absolute path as source'), > + hidden: Proxmox.UserName !=3D=3D 'root@pam', > + listeners: { > + change: (_, newValue) =3D> { > + show(me.down('#sourcePathTextfield'), newValue); > + }, > + }, > + }, { > + xtype: 'textfield', > + itemId: 'sourcePathTextfield', > + fieldLabel: gettext('Source Path'), > + name: 'sourcePath', > + autoEl: { > + tag: 'div', > + // 'data-qtip': gettext('Absolute path or URL to the source disk imag= e, for example: /home/user/somedisk.qcow2, http://example.com/WindowsImage.= zip'), > + 'data-qtip': gettext('Absolute path to the source disk image, for exa= mple: /home/user/somedisk.qcow2'), > + }, > + hidden: true, > + disabled: true, > + validator: (insertedText) =3D> > + insertedText.startsWith('/') || insertedText.startsWith('http') || > + gettext('Must be an absolute path or URL'), > + }, > + ); > + } > =20 > me.advancedColumn1.push( > { > @@ -373,13 +529,20 @@ Ext.define('PVE.qemu.HDEdit', { > nodename: nodename, > unused: unused, > isCreate: me.isCreate, > + showSourcePathTextfield: me.isImport, > + isImport: me.isImport, > + returnSingleKey: !me.isImport, > }); > =20 > var subject; > if (unused) { > me.subject =3D gettext('Unused Disk'); > + } else if (me.isImport) { > + me.subject =3D gettext('Import Disk'); > + me.submitText =3D 'Import'; > + me.backgroundDelay =3D undefined; > } else if (me.isCreate) { > - me.subject =3D gettext('Hard Disk'); > + me.subject =3D gettext('Hard Disk'); > } else { > me.subject =3D gettext('Hard Disk') + ' (' + me.confid + ')'; > } > @@ -404,6 +567,9 @@ Ext.define('PVE.qemu.HDEdit', { > ipanel.setDrive(drive); > me.isValid(); // trigger validation > } > + if (me.isImport) { > + me.url =3D me.url.replace(/\/config$/, "/importdisk"); > + } > }, > }); > }, > diff --git a/www/manager6/qemu/HardwareView.js b/www/manager6/qemu/Hardwa= reView.js > index 77640e53..aeb2b762 100644 > --- a/www/manager6/qemu/HardwareView.js > +++ b/www/manager6/qemu/HardwareView.js > @@ -436,6 +436,30 @@ Ext.define('PVE.qemu.HardwareView', { > handler: run_move, > }); > =20 > + var import_btn =3D new Proxmox.button.Button({ > + text: gettext('Import disk'), > + hidden: Proxmox.UserName !=3D=3D 'root@pam', > + handler: function() { > + var win =3D Ext.create('PVE.qemu.HDEdit', { > + method: 'POST', > + url: `/api2/extjs/${baseurl}`, > + pveSelNode: me.pveSelNode, > + isImport: true, > + listeners: { > + add: function(_, component) { > + component.down('#sourceRadioStorage').setValue(true); > + component.down('#sourceStorageSelector').setHidden(false); > + component.down('#sourceFileSelector').setHidden(false); > + component.down('#sourceFileSelector').enable(); > + component.down('#sourceStorageSelector').enable(); > + }, > + }, > + }); > + win.on('destroy', me.reload, me); > + win.show(); > + }, > + }); > + > var remove_btn =3D new Proxmox.button.Button({ > text: gettext('Remove'), > defaultText: gettext('Remove'), > @@ -752,6 +776,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/Import= Wizard.js > new file mode 100644 > index 00000000..2aabe74e > --- /dev/null > +++ b/www/manager6/qemu/ImportWizard.js > @@ -0,0 +1,356 @@ > +/*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 =3D this; > + let wizard; > + if (me.xtype =3D=3D=3D 'button') { > + wizard =3D me.up('window'); > + } else if (me.xtype =3D=3D=3D 'pveQemuImportWizard') { > + wizard =3D me; > + } > + let multihd =3D 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: [ > + // { // TODO implement the rest > + // xtype: 'filebutton', > + // text: gettext('Load local manifest ...'), > + // allowBlank: true, > + // hidden: Proxmox.UserName !=3D=3D 'root@pam', > + // disabled: Proxmox.UserName !=3D=3D 'root@pam', > + // listeners: { > + // change: (button,event,) =3D> { > + // var reader =3D new FileReader(); > + // let wizard =3D button.up('window'); > + // reader.onload =3D (e) =3D> { > + // let uploaded_ovf =3D 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', > + emptyText: '/mnt/nfs/exported.ovf', > + fieldLabel: 'Absolute path to .ovf manifest on your PVE host', > + listeners: { > + validitychange: function(_, isValid) { > + let button =3D Ext.ComponentQuery.query('#load_remote_manifest_but= ton').pop(); > + button.setDisabled(!isValid); > + }, > + }, > + validator: function(value) { > + if (value && !value.startsWith('/')) { > + return gettext("Must start with /"); > + } > + return true; > + }, > + }, > + { > + xtype: 'proxmoxButton', > + itemId: 'load_remote_manifest_button', > + text: gettext('Load remote manifest'), > + disabled: true, > + handler: function() { > + let inputpanel =3D this.up('#importInputpanel'); > + let nodename =3D inputpanel.down('pveNodeSelector').getValue(); > + // independent of onGetValues(), so that value of > + // ovf_textfield can be removed for submit > + let ovf_textfield_value =3D inputpanel.down('textfield[name=3Dovf_t= extfield]').getValue(); > + let wizard =3D this.up('window'); > + Proxmox.Utils.API2Request({ > + url: '/nodes/' + nodename + '/readovf', > + method: 'GET', > + params: { > + manifest: ovf_textfield_value, > + }, > + success: function(response) { > + let ovfdata =3D response.result.data; > + wizard.down('#vmNameTextfield').setValue(ovfdata.name); > + wizard.down('#cpupanel').getViewModel().set('coreCount', ovfda= ta.cores); > + wizard.down('#memorypanel').down('pveMemoryField').setValue(ov= fdata.memory); > + delete ovfdata.name; > + delete ovfdata.cores; > + delete ovfdata.memory; > + delete ovfdata.digest; > + let devices =3D Object.keys(ovfdata); // e.g. ide0, sata2 > + let multihd =3D 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 =3D PVE.Parser.printStartup({ > + order: values.order, > + up: values.up, > + down: values.down, > + }); > + > + if (res) { > + values.startup =3D 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 =3D this.up('window').getValues(); > + var data =3D []; > + Ext.Object.each(kv, function(key, value) { > + if (key =3D=3D=3D 'delete') { // ignore > + return; > + } > + data.push({ key: key, value: value }); > + }); > + > + var summarystore =3D panel.down('grid').getStore(); > + summarystore.suspendEvents(); > + summarystore.removeAll(); > + summarystore.add(data); > + summarystore.sort(); > + summarystore.resumeEvents(); > + summarystore.fireEvent('refresh'); > + }, > + }, > + onSubmit: function() { > + var wizard =3D this.up('window'); > + var params =3D wizard.getValues(); > + > + var nodename =3D params.nodename; > + delete params.nodename; > + delete params.delete; > + if (Array.isArray(params.diskimages)) { > + params.diskimages =3D 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/MultiHD= Edit.js > new file mode 100644 > index 00000000..403ad6df > --- /dev/null > +++ b/www/manager6/qemu/MultiHDEdit.js > @@ -0,0 +1,282 @@ > +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 =3D this.findBy(record =3D> > + record.data.panel =3D=3D=3D 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%', // TODO Resize to parent > + }, { > + xtype: 'container', > + layout: 'hbox', > + center: true, // TODO fix me > + 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 =3D=3D=3D 0) { > + this.addDiskFunction(); > + } > + }, > + add: function(container, newPanel, index) { > + let store =3D Ext.getStore('importwizard_diskstorage'); > + store.add({ device: newPanel.getDevice(), panel: newPanel }); > + container.setActiveItem(newPanel); > + }, > + remove: function(HDInputPanelContainer, HDInputPanel, eOpts) { > + let store =3D Ext.getStore('importwizard_diskstorage'); > + let indexOfRemoved =3D store.removeByPanel(HDInputPanel); > + if (HDInputPanelContainer.items.getCount() > 0) { > + HDInputPanelContainer.setActiveItem(indexOfRemoved - 1); > + } > + }, > + }, > + defaultItem: { > + xtype: 'pveQemuHDInputPanel', > + bind: { > + nodename: '{nodename}', > + }, > + isCreate: true, > + isImport: true, > + showSourcePathTextfield: 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 =3D Ext.getStore('importwizard_diskstorage'); > + let controllerSelector =3D field.up('pveQemuHDInputPanel') > + .down('pveControllerSelector'); > + /* > + * controller+device (ide0) might be > + * ambiguous during creation =3D> find by > + * panel object instead > + * > + * There is no function that takes a > + * function and returns the model directly > + * =3D> index & getAt > + */ > + let recordIndex =3D store.findBy(record =3D> > + record.data.panel =3D=3D=3D field.up('pveQemuHDInputPanel'), > + ); > + let newControllerAndId =3D controllerSelector.getValuesAsString(= ); > + store.getAt(recordIndex).set('device', newControllerAndId); > + }); > + }, > + ); > + let wizard =3D 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 =3D true; > + var fields =3D this.query('field, fieldcontainer'); > + Ext.Array.each(fields, function(field) { > + // Note: not all fielcontainer have isValid() > + if (Ext.isFunction(field.isValid) && !field.isValid()) { > + valid =3D 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 =3D> no storage found? > + let item =3D Ext.clone(this.defaultItem); > + let added =3D 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 =3D Ext.state.Manager.getProvider(); > + let advanced_checkbox =3D 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 =3D this.getLayout().activeItem; // panel =3D 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 =3D this.down('#HDInputPanelContainer'); > + while (container.items.items.length > 0) { > + container.removeCurrentDisk(); > + } > + }, > + > + beforeRender: function() { > + let leftColumnPanel =3D this.items.get(0).items.get(0); > + leftColumnPanel.setFlex(this.leftColumnRatio); > + // any other panel because this has no height yet > + let panelHeight =3D this.up('tabpanel').items.items[0].getHeight(); > + leftColumnPanel.setHeight(panelHeight); > + }, > + > + setNodename: function(nodename) { > + this.nodename =3D nodename; > + }, > + > + // Call with defined parameter or without (static function so to say= ) > + hasDuplicateDevices: function(values) { > + if (!values) { > + values =3D this.up('form').getValues(); > + } > + if (!Array.isArray(values.controller)) { > + return false; > + } > + for (let i =3D 0; i < values.controller.length - 1; i++) { > + for (let j =3D i+1; j < values.controller.length; j++) { > + if (values.controller[i] =3D=3D=3D values.controller[j]) { > + if (values.deviceid[i] =3D=3D=3D 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 =3D this.down('#HDInputPanelContainer').items.getRange(= ); > + if (inputpanels.some(panel =3D> !panel.validator())) { > + return false; > + } > + if (this.hasDuplicateDevices()) { > + return false; > + } > + return true; > + }, > +}); > diff --git a/www/manager6/window/Wizard.js b/www/manager6/window/Wizard.j= s > 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 =3D validcheck; > }); > }, > }); > --=20 > 2.20.1 >=20 >=20 > _______________________________________________ > pve-devel mailing list > pve-devel@lists.proxmox.com > https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel >=20 =