[pve-devel] [PATCH manager 4/6 v2] gui: Add import VM wizard

Dominic Jäger d.jaeger at proxmox.com
Fri Nov 20 10:38:10 CET 2020


Signed-off-by: Dominic Jäger <d.jaeger at proxmox.com>
---
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 at 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 at 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 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 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 at 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 at 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 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',
+				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





More information about the pve-devel mailing list