[pve-devel] [PATCH v4 manager] gui: Add import wizard for disk & VM

Fabian Grünbichler f.gruenbichler at proxmox.com
Wed Feb 10 10:49:41 CET 2021


haven't taken a closer look at the GUI stuff as I think the backend will 
potentially change a bit more.

also regarding the permissions and the problem of importing from 
arbitrary paths, I wonder whether a simple file upload to a dir storage 
to

import/$authuser/$file

if the user has permissions to allocate space on the storage would help? 
those files could then be referenced for import purposes by that user 
without needing root privileges. and we just pass them to qemu-img 
convert which would make the attack surface not that high hopefully 
(barring issues in the handling of formats, but we could just allow raw 
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äger wrote:
> Add GUI wizard to import whole VMs and a window to import single disks in
> Hardware View.
> 
> 
> Signed-off-by: Dominic Jäger <d.jaeger at proxmox.com>
> ---
> The wizard works, but there is still quite some refactoring to do
> 
> v3->v4:
> * Allow only root
> * Adapt to API changes
> 
> 
>  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
> 
> 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 => 'subscription' },
>  	    { name => 'report' },
>  	    { name => 'tasks' },
> +	    { name => 'readovf' },
>  	    { name => 'rrd' }, # fixme: remove?
>  	    { name => 'rrddata' },# fixme: remove?
>  	    { name => 'replication' },
> @@ -2173,6 +2175,44 @@ __PACKAGE__->register_method ({
>  	return undef;
>      }});

this API endpoint belongs in qemu-server?

>  
> +__PACKAGE__->register_method ({
> +    name => 'readovf',
> +    path => 'readovf',
> +    method => 'GET',
> +    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",

according to the code below, this has a defined schema?

> +    },
> +    code => sub {
> +	my ($param) = @_;
> +
> +	my $manifest = $param->{manifest};
> +	die "$manifest: non-existent or non-regular file\n" if (! -f $manifest);
> +
> +	my $parsed = PVE::QemuServer::OVF::parse_ovf($manifest, 0, 1);
> +	my $result;
> +	$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}} = $disk->{backing_file};
> +	}
> +	return $result;
> +}});
> +
>  # bash completion helper
>  
>  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= 							\
>  	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', {
>  	    },
>  	});
>  
> +	var importVM = Ext.createWidget('button', {
> +	    pack: 'end',
> +	    margin: '3 5 0 0',
> +	    baseCls: 'x-btn',
> +	    iconCls: 'fa fa-desktop',
> +	    text: gettext("Import VM"),
> +	    hidden: Proxmox.UserName !== 'root at pam',
> +	    handler: function() {
> +		var wiz = Ext.create('PVE.qemu.ImportWizard', {});
> +		wiz.show();
> +	    },
> +	});
> +
>  	sprovider.on('statechange', function(sp, key, value) {
>  	    if (key === 'GuiCap' && value) {
>  		caps = value;
>  		createVM.setDisabled(!caps.vms['VM.Allocate']);
>  		createCT.setDisabled(!caps.vms['VM.Allocate']);
> +		importVM.setDisabled(!caps.vms['VM.Allocate']);
>  	    }
>  	});
>  
> @@ -332,6 +346,7 @@ Ext.define('PVE.StdWorkspace', {
>  			},
>  			createVM,
>  			createCT,
> +			importVM,
>  			{
>  			    pack: 'end',
>  			    margin: '0 5 0 0',
> diff --git a/www/manager6/form/ControllerSelector.js b/www/manager6/form/ControllerSelector.js
> index 23c61159..8e9aee98 100644
> --- a/www/manager6/form/ControllerSelector.js
> +++ b/www/manager6/form/ControllerSelector.js
> @@ -68,6 +68,21 @@ clist_loop:
>  	deviceid.validate();
>      },
>  
> +    getValues: function() {
> +	return this.query('field').map(x => x.getValue());
> +    },
> +
> +    getValuesAsString: function() {
> +	return this.getValues().join('');
> +    },
> +
> +    setValue: function(value) {
> +	let regex = /([a-z]+)(\d+)/;
> +	let [_, controller, deviceid] = regex.exec(value);
> +	this.down('field[name=controller]').setValue(controller);
> +	this.down('field[name=deviceid]').setValue(deviceid);
> +    },
> +
>      initComponent: function() {
>  	var me = this;
>  
> diff --git a/www/manager6/node/CmdMenu.js b/www/manager6/node/CmdMenu.js
> index b650bfa0..407cf2d0 100644
> --- a/www/manager6/node/CmdMenu.js
> +++ b/www/manager6/node/CmdMenu.js
> @@ -29,6 +29,19 @@ Ext.define('PVE.node.CmdMenu', {
>  		wiz.show();
>  	    },
>  	},
> +	{
> +	    text: gettext("Import VM"),
> +	    hidden: Proxmox.UserName !== 'root at pam',
> +	    itemId: 'importvm',
> +	    iconCls: 'fa fa-cube',
> +	    handler: function() {
> +		var me = this.up('menu');
> +		var wiz = Ext.create('PVE.qemu.ImportWizard', {
> +		    nodename: me.nodename,
> +		});
> +		wiz.show();
> +	    },
> +	},
>  	{ xtype: 'menuseparator' },
>  	{
>  	    text: gettext('Bulk Start'),
> diff --git a/www/manager6/qemu/HDEdit.js b/www/manager6/qemu/HDEdit.js
> index e22111bf..5d039134 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,29 @@ 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() {
> +	return 'sourceType_' + this.id;
> +    },
> +
> +    // values ... the values from onGetValues
> +    getSourceValue: function(values) {
> +	let result;
> +	let type = values[this.getSourceTypeIdentifier()];
> +	if (type === 'storage') {
> +	    result = values.sourceVolid;
> +	} else {
> +	    result = values.sourcePath;
> +	}
> +	return result;
> +    },
> +
>      onGetValues: function(values) {
>  	var me = this;
>  
> @@ -68,8 +95,12 @@ Ext.define('PVE.qemu.HDInputPanel', {
>  	    me.drive.file = me.vmconfig[values.unusedId];
>  	    confid = values.controller + values.deviceid;
>  	} 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
> +		me.test = `test`;
>  	    } else {
>  		me.drive.file = 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');
>  
> -        var names = ['mbps_rd', 'mbps_wr', 'iops_rd', 'iops_wr'];
> -        Ext.Array.each(names, function(name) {
> -            var burst_name = name + '_max';
> +	var names = ['mbps_rd', 'mbps_wr', 'iops_rd', 'iops_wr'];
> +	Ext.Array.each(names, function(name) {
> +	    var burst_name = name + '_max';
>  	    PVE.Utils.propertyStringSet(me.drive, values[name], name);
>  	    PVE.Utils.propertyStringSet(me.drive, values[burst_name], burst_name);
> -        });
> -
> -
> -	params[confid] = PVE.Parser.printQemuDrive(me.drive);
> +	});
>  
> +	if (me.returnSingleKey) {
> +	    if (me.isImport) {
> +		me.drive.importsource = this.getSourceValue(values);
> +		params.diskimages = [confid, me.drive.importsource].join('=');
> +	    }
> +	    delete me.drive.importsource;
> +	    params[confid] = PVE.Parser.printQemuDrive(me.drive);
> +	} else {
> +	    delete me.drive.file;
> +	    delete me.drive.format;
> +	    params.device_options = PVE.Parser.printPropertyString(me.drive);
> +	    params.source = this.getSourceValue(values);
> +	    params.device = values.controller + values.deviceid;
> +	    params.storage = values.hdstorage;
> +	    if (values.diskformat) {
> +		params.format = values.diskformat;
> +	    }
> +	}
>  	return params;
>      },
>  
> @@ -149,10 +195,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() {
> @@ -168,11 +220,18 @@ Ext.define('PVE.qemu.HDInputPanel', {
>  	me.advancedColumn1 = [];
>  	me.advancedColumn2 = [];
>  
> +
> +	let nodename = me.nodename;
>  	if (!me.confid || me.unused) {
> +	    let controllerColumn = me.showSourcePathTextfield ? me.column2 : me.column1;
>  	    me.bussel = Ext.create('PVE.form.ControllerSelector', {
> +		itemId: 'bussel',
>  		vmconfig: me.insideWizard ? { ide2: 'cdrom' } : {},
>  	    });
> -	    me.column1.push(me.bussel);
> +	    if (me.showSourcePathTextfield) {
> +		me.bussel.fieldLabel = 'Target Device';
> +	    }
> +	    controllerColumn.push(me.bussel);
>  
>  	    me.scsiController = 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);
>  	}
>  
>  	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 = {
>  		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 = 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', {
>  	    });
>  	}
>  
> +	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 = (element, value) => {
> +		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 !== 'root at pam',
> +		    checked: true,
> +		    listeners: {
> +			change: (_, newValue) => {
> +			    let storageSelectors = [
> +				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 <storageRoot>/images/<newVMID>, 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 !== 'root at 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 or URL to the source disk image, for example: /home/user/somedisk.qcow2, http://example.com/WindowsImage.zip'),
> +			'data-qtip': gettext('Absolute path to the source disk image, for example: /home/user/somedisk.qcow2'),
> +		    },
> +		    hidden: true,
> +		    disabled: true,
> +		    validator: (insertedText) =>
> +			insertedText.startsWith('/') || insertedText.startsWith('http') ||
> +			    gettext('Must be an absolute path or URL'),
> +		},
> +	    );
> +	}
>  
>  	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,
>  	});
>  
>  	var subject;
>  	if (unused) {
>  	    me.subject = gettext('Unused Disk');
> +	} else if (me.isImport) {
> +	    me.subject = gettext('Import Disk');
> +	    me.submitText = 'Import';
> +	    me.backgroundDelay = undefined;
>  	} else if (me.isCreate) {
> -            me.subject = gettext('Hard Disk');
> +	    me.subject = gettext('Hard Disk');
>  	} else {
>             me.subject = gettext('Hard Disk') + ' (' + me.confid + ')';
>  	}
> @@ -404,6 +567,9 @@ Ext.define('PVE.qemu.HDEdit', {
>  		    ipanel.setDrive(drive);
>  		    me.isValid(); // trigger validation
>  		}
> +		if (me.isImport) {
> +		    me.url = me.url.replace(/\/config$/, "/importdisk");
> +		}
>  	    },
>  	});
>      },
> diff --git a/www/manager6/qemu/HardwareView.js b/www/manager6/qemu/HardwareView.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,
>  	});
>  
> +	var import_btn = new Proxmox.button.Button({
> +	    text: gettext('Import disk'),
> +	    hidden: Proxmox.UserName !== 'root at pam',
> +	    handler: function() {
> +		var win = Ext.create('PVE.qemu.HDEdit', {
> +		    method: 'POST',
> +		    url: `/api2/extjs/${baseurl}`,
> +		    pveSelNode: me.pveSelNode,
> +		    isImport: true,
> +		    listeners: {
> +			add: function(_, component) {
> +			    component.down('#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 = 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/ImportWizard.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 = this;
> +	let wizard;
> +	if (me.xtype === 'button') {
> +		wizard = me.up('window');
> +	} else if (me.xtype === 'pveQemuImportWizard') {
> +		wizard = me;
> +	}
> +	let multihd = wizard.down('pveQemuMultiHDInputPanel');
> +	multihd.addDiskFunction();
> +    },
> +
> +    items: [
> +	{
> +		xtype: 'inputpanel',
> +		title: gettext('Import'),
> +		itemId: 'importInputpanel',
> +		column1: [
> +			{
> +				xtype: 'pveNodeSelector',
> +				name: 'nodename',
> +				cbind: {
> +				    selectCurNode: '{!nodename}',
> +				    preferredValue: '{nodename}',
> +				},
> +				bind: {
> +				    value: '{nodename}',
> +				},
> +				fieldLabel: gettext('Node'),
> +				allowBlank: false,
> +				onlineValidator: true,
> +			    }, {
> +				xtype: 'pveGuestIDSelector',
> +				name: 'vmid',
> +				guestType: 'qemu',
> +				value: '',
> +				loadNextFreeID: true,
> +				validateExists: false,
> +			    },
> +		],
> +		column2: [
> +			// { // TODO implement the rest
> +			// 	xtype: 'filebutton',
> +			// 	text: gettext('Load local manifest ...'),
> +			// 	allowBlank: true,
> +			// 	hidden: Proxmox.UserName !== 'root at pam',
> +			// 	disabled: Proxmox.UserName !== 'root at 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',
> +				emptyText: '/mnt/nfs/exported.ovf',
> +				fieldLabel: 'Absolute path to .ovf manifest on your PVE host',
> +				listeners: {
> +					validitychange: function(_, isValid) {
> +						let button = Ext.ComponentQuery.query('#load_remote_manifest_button').pop();
> +						button.setDisabled(!isValid);
> +					},
> +				},
> +				validator: function(value) {
> +					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 = this.up('#importInputpanel');
> +					let nodename = inputpanel.down('pveNodeSelector').getValue();
> +					 // independent of onGetValues(), so that value of
> +					 // ovf_textfield can be removed for submit
> +					let ovf_textfield_value = inputpanel.down('textfield[name=ovf_textfield]').getValue();
> +					let wizard = this.up('window');
> +					Proxmox.Utils.API2Request({
> +						url: '/nodes/' + nodename + '/readovf',
> +						method: 'GET',
> +						params: {
> +							manifest: ovf_textfield_value,
> +						},
> +						success: function(response) {
> +						    let ovfdata = response.result.data;
> +						    wizard.down('#vmNameTextfield').setValue(ovfdata.name);
> +						    wizard.down('#cpupanel').getViewModel().set('coreCount', ovfdata.cores);
> +						    wizard.down('#memorypanel').down('pveMemoryField').setValue(ovfdata.memory);
> +						    delete ovfdata.name;
> +						    delete ovfdata.cores;
> +						    delete ovfdata.memory;
> +						    delete ovfdata.digest;
> +						    let devices = Object.keys(ovfdata); // e.g. ide0, sata2
> +						    let multihd = wizard.down('pveQemuMultiHDInputPanel');
> +						    if (devices.length > 0) {
> +							multihd.removeAllDisks();
> +						    }
> +						    for (var device of devices) {
> +							multihd.addDiskFunction(device, ovfdata[device]);
> +						    }
> +						},
> +						failure: function(response, opts) {
> +						    console.warn("Failure of load manifest button");
> +						    console.warn(response);
> +						},
> +					    });
> +				},
> +			},
> +		],
> +		onGetValues: function(values) {
> +			delete values.server_ovf_manifest;
> +			delete values.ovf_textfield;
> +			return values;
> +		},
> +	},
> +	{
> +	    xtype: 'inputpanel',
> +	    title: gettext('General'),
> +	    onlineHelp: 'qm_general_settings',
> +	    column1: [
> +		{
> +		    xtype: 'textfield',
> +		    name: 'name',
> +		    itemId: 'vmNameTextfield',
> +		    vtype: 'DnsName',
> +		    value: '',
> +		    fieldLabel: gettext('Name'),
> +		    allowBlank: true,
> +		},
> +	    ],
> +	    column2: [
> +		{
> +		    xtype: 'pvePoolSelector',
> +		    fieldLabel: gettext('Resource Pool'),
> +		    name: 'pool',
> +		    value: '',
> +		    allowBlank: true,
> +		},
> +	    ],
> +	    advancedColumn1: [
> +		{
> +		    xtype: 'proxmoxcheckbox',
> +		    name: 'onboot',
> +		    uncheckedValue: 0,
> +		    defaultValue: 0,
> +		    deleteDefaultValue: true,
> +		    fieldLabel: gettext('Start at boot'),
> +		},
> +	    ],
> +	    advancedColumn2: [
> +		{
> +		    xtype: 'textfield',
> +		    name: 'order',
> +		    defaultValue: '',
> +		    emptyText: 'any',
> +		    labelWidth: 120,
> +		    fieldLabel: gettext('Start/Shutdown order'),
> +		},
> +		{
> +		    xtype: 'textfield',
> +		    name: 'up',
> +		    defaultValue: '',
> +		    emptyText: 'default',
> +		    labelWidth: 120,
> +		    fieldLabel: gettext('Startup delay'),
> +		},
> +		{
> +		    xtype: 'textfield',
> +		    name: 'down',
> +		    defaultValue: '',
> +		    emptyText: 'default',
> +		    labelWidth: 120,
> +		    fieldLabel: gettext('Shutdown timeout'),
> +		},
> +	    ],
> +	    onGetValues: function(values) {
> +		['name', 'pool', 'onboot', 'agent'].forEach(function(field) {
> +		    if (!values[field]) {
> +			delete values[field];
> +		    }
> +		});
> +
> +		var res = PVE.Parser.printStartup({
> +		    order: values.order,
> +		    up: values.up,
> +		    down: values.down,
> +		});
> +
> +		if (res) {
> +		    values.startup = res;
> +		}
> +
> +		delete values.order;
> +		delete values.up;
> +		delete values.down;
> +
> +		return values;
> +	    },
> +	},
> +	{
> +	    xtype: 'pveQemuSystemPanel',
> +	    title: gettext('System'),
> +	    isCreate: true,
> +	    insideWizard: true,
> +	},
> +	{
> +	    xtype: 'pveQemuMultiHDInputPanel',
> +	    title: gettext('Hard Disk'),
> +	    bind: {
> +		nodename: '{nodename}',
> +	    },
> +	    isCreate: true,
> +	    insideWizard: true,
> +	},
> +	{
> +	    itemId: 'cpupanel',
> +	    xtype: 'pveQemuProcessorPanel',
> +	    insideWizard: true,
> +	    title: gettext('CPU'),
> +	},
> +	{
> +	    itemId: 'memorypanel',
> +	    xtype: 'pveQemuMemoryPanel',
> +	    insideWizard: true,
> +	    title: gettext('Memory'),
> +	},
> +	{
> +	    xtype: 'pveQemuNetworkInputPanel',
> +	    bind: {
> +		nodename: '{nodename}',
> +	    },
> +	    title: gettext('Network'),
> +	    insideWizard: true,
> +	},
> +	{
> +	    title: gettext('Confirm'),
> +	    layout: 'fit',
> +	    items: [
> +		{
> +		    xtype: 'grid',
> +		    store: {
> +			model: 'KeyValue',
> +			sorters: [{
> +			    property: 'key',
> +			    direction: 'ASC',
> +			}],
> +		    },
> +		    columns: [
> +			{ header: 'Key', width: 150, dataIndex: 'key' },
> +			{ header: 'Value', flex: 1, dataIndex: 'value' },
> +		    ],
> +		},
> +	    ],
> +	    dockedItems: [
> +		{
> +		    xtype: 'proxmoxcheckbox',
> +		    name: 'start',
> +		    dock: 'bottom',
> +		    margin: '5 0 0 0',
> +		    boxLabel: gettext('Start after created'),
> +		},
> +	    ],
> +	    listeners: {
> +		show: function(panel) {
> +		    var kv = this.up('window').getValues();
> +		    var data = [];
> +		    Ext.Object.each(kv, function(key, value) {
> +			if (key === 'delete') { // ignore
> +			    return;
> +			}
> +			data.push({ key: key, value: value });
> +		    });
> +
> +		    var summarystore = panel.down('grid').getStore();
> +		    summarystore.suspendEvents();
> +		    summarystore.removeAll();
> +		    summarystore.add(data);
> +		    summarystore.sort();
> +		    summarystore.resumeEvents();
> +		    summarystore.fireEvent('refresh');
> +		},
> +	    },
> +	    onSubmit: function() {
> +		var wizard = this.up('window');
> +		var params = wizard.getValues();
> +
> +		var nodename = params.nodename;
> +		delete params.nodename;
> +		delete params.delete;
> +		if (Array.isArray(params.diskimages)) {
> +			params.diskimages = params.diskimages.join(',');
> +		}
> +
> +		Proxmox.Utils.API2Request({
> +		    url: `/nodes/${nodename}/qemu/${params.vmid}/importvm`,
> +		    waitMsgTarget: wizard,
> +		    method: 'POST',
> +		    params: params,
> +		    success: function() {
> +			wizard.close();
> +		    },
> +		    failure: function(response) {
> +			Ext.Msg.alert(gettext('Error'), response.htmlStatus);
> +		    },
> +		});
> +	    },
> +	},
> +	],
> +
> +});
> diff --git a/www/manager6/qemu/MultiHDEdit.js b/www/manager6/qemu/MultiHDEdit.js
> new file mode 100644
> index 00000000..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 = this.findBy(record =>
> +			record.data.panel === panel,
> +		    );
> +		    this.removeAt(recordIndex);
> +		    return recordIndex;
> +		},
> +	    },
> +	    columns: [
> +		{
> +		    text: gettext('Target device'),
> +		    dataIndex: 'device',
> +		    flex: 1,
> +		    resizable: false,
> +		},
> +	    ],
> +	    listeners: {
> +		select: function(_, record) {
> +		    this.up('pveQemuMultiHDInputPanel')
> +			.down('#HDInputPanelContainer')
> +			.setActiveItem(record.data.panel);
> +		},
> +	    },
> +	    anchor: '100% 90%', // 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 === 0) {
> +			this.addDiskFunction();
> +		    }
> +		},
> +		add: function(container, newPanel, index) {
> +		    let store = Ext.getStore('importwizard_diskstorage');
> +		    store.add({ device: newPanel.getDevice(), panel: newPanel });
> +		    container.setActiveItem(newPanel);
> +		},
> +		remove: function(HDInputPanelContainer, HDInputPanel, eOpts) {
> +		    let store = Ext.getStore('importwizard_diskstorage');
> +		    let indexOfRemoved = store.removeByPanel(HDInputPanel);
> +		    if (HDInputPanelContainer.items.getCount() > 0) {
> +			HDInputPanelContainer.setActiveItem(indexOfRemoved - 1);
> +		    }
> +		},
> +	    },
> +	    defaultItem: {
> +		xtype: 'pveQemuHDInputPanel',
> +		bind: {
> +		    nodename: '{nodename}',
> +		},
> +		isCreate: true,
> +		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 = Ext.getStore('importwizard_diskstorage');
> +				    let controllerSelector = field.up('pveQemuHDInputPanel')
> +					.down('pveControllerSelector');
> +				    /*
> +				     * controller+device (ide0) might be
> +				     * ambiguous during creation => find by
> +				     * panel object instead
> +				     *
> +				     * There is no function that takes a
> +				     * function and returns the model directly
> +				     * => index & getAt
> +				     */
> +				    let recordIndex = store.findBy(record =>
> +					record.data.panel === field.up('pveQemuHDInputPanel'),
> +				    );
> +				    let newControllerAndId = controllerSelector.getValuesAsString();
> +				    store.getAt(recordIndex).set('device', newControllerAndId);
> +				});
> +			    },
> +			);
> +			let wizard = this.up('pveQemuImportWizard');
> +				Ext.Array.each(this.query('field'), function(field) {
> +					field.on('change', wizard.validcheck);
> +					field.on('validitychange', wizard.validcheck);
> +				});
> +		    },
> +		},
> +		validator: function() {
> +		    var valid = true;
> +		    var fields = this.query('field, fieldcontainer');
> +		    Ext.Array.each(fields, function(field) {
> +			// Note: not all fielcontainer have isValid()
> +			if (Ext.isFunction(field.isValid) && !field.isValid()) {
> +			    valid = false;
> +			}
> +		    });
> +		    return valid;
> +		},
> +	    },
> +
> +	    // device ... device that the new disk should be assigned to, e.g.
> +	    //   ide0, sata2
> +	    // path ... if this is set to x then the disk will
> +	    //   backed/imported from the path x, that is, the textfield will
> +	    //   contain the value x
> +	    addDiskFunction(device, path) {
> +		// creating directly removes binding => no storage found?
> +		let item = Ext.clone(this.defaultItem);
> +		let added = this.add(item);
> +		// At this point the 'added' listener has fired and the fields
> +		// in the variable added have the change listeners that update
> +		// the store Therefore we can now set values only on the field
> +		// and they will be updated in the store
> +		if (path) {
> +		    added.down('#sourceRadioPath').setValue(true);
> +		    added.down('#sourcePathTextfield').setValue(path);
> +		} else {
> +		    added.down('#sourceRadioStorage').setValue(true);
> +		    added.down('#sourceStorageSelector').setHidden(false);
> +		    added.down('#sourceFileSelector').setHidden(false);
> +		    added.down('#sourceFileSelector').enable();
> +		    added.down('#sourceStorageSelector').enable();
> +		}
> +
> +		let sp = Ext.state.Manager.getProvider();
> +		let advanced_checkbox = sp.get('proxmox-advanced-cb');
> +		added.setAdvancedVisible(advanced_checkbox);
> +
> +		if (device) {
> +		    // This happens after the 'add' and 'added' listeners of the
> +		    // item/defaultItem clone/pveQemuHDInputPanel/added have fired
> +		    added.down('pveControllerSelector').setValue(device);
> +		}
> +	    },
> +	    removeCurrentDisk: function() {
> +		let activePanel = this.getLayout().activeItem; // panel = disk
> +		if (activePanel) {
> +		    this.remove(activePanel);
> +		} 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() {
> +	let leftColumnPanel = this.items.get(0).items.get(0);
> +	leftColumnPanel.setFlex(this.leftColumnRatio);
> +	// any other panel because this has no height yet
> +	let panelHeight = this.up('tabpanel').items.items[0].getHeight();
> +	leftColumnPanel.setHeight(panelHeight);
> +    },
> +
> +    setNodename: function(nodename) {
> +	this.nodename = nodename;
> +    },
> +
> +    // Call with defined parameter or without (static function so to say)
> +    hasDuplicateDevices: function(values) {
> +	if (!values) {
> +	    values = this.up('form').getValues();
> +	}
> +	if (!Array.isArray(values.controller)) {
> +	    return false;
> +	}
> +	for (let i = 0; i < values.controller.length - 1; i++) {
> +		for (let j = i+1; j < values.controller.length; j++) {
> +		    if (values.controller[i] === values.controller[j]) {
> +			if (values.deviceid[i] === values.deviceid[j]) {
> +			    return true;
> +			}
> +		    }
> +		}
> +	    }
> +	return false;
> +    },
> +
> +    onGetValues: function(values) {
> +	// Returning anything here would give wrong data in the form at the end
> +	// of the wizrad Each HDInputPanel in this MultiHD panel already has a
> +	// sufficient onGetValues() function for the form at the end of the
> +	// wizard
> +	if (this.hasDuplicateDevices(values)) {
> +	    Ext.Msg.alert(gettext('Error'), 'Equal target devices are forbidden. Make all unique!');
> +	}
> +    },
> +
> +    validator: function() {
> +	let inputpanels = this.down('#HDInputPanelContainer').items.getRange();
> +	if (inputpanels.some(panel => !panel.validator())) {
> +	    return false;
> +	}
> +	if (this.hasDuplicateDevices()) {
> +	    return false;
> +	}
> +	return true;
> +    },
> +});
> diff --git a/www/manager6/window/Wizard.js b/www/manager6/window/Wizard.js
> index 8b930bbd..a3e3b690 100644
> --- a/www/manager6/window/Wizard.js
> +++ b/www/manager6/window/Wizard.js
> @@ -261,6 +261,8 @@ Ext.define('PVE.window.Wizard', {
>  	    };
>  	    field.on('change', validcheck);
>  	    field.on('validitychange', validcheck);
> +	    // Make available for fields that get added later
> +	    me.validcheck = validcheck;
>  	});
>      },
>  });
> -- 
> 2.20.1
> 
> 
> _______________________________________________
> pve-devel mailing list
> pve-devel at lists.proxmox.com
> https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel
> 




More information about the pve-devel mailing list