all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: "Dominic Jäger" <d.jaeger@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [pve-devel] [PATCH v6 manager] gui: Add import for disk & VM
Date: Tue,  9 Mar 2021 11:43:18 +0100	[thread overview]
Message-ID: <20210309104318.317454-3-d.jaeger@proxmox.com> (raw)
In-Reply-To: <20210309104318.317454-1-d.jaeger@proxmox.com>

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@proxmox.com>
---
v5->v6:
Rebase
Move readovf to qemu-server

 PVE/API2/Nodes.pm                       |   7 +
 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, 897 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 ba6621c6..1cee6cb5 100644
--- a/PVE/API2/Nodes.pm
+++ b/PVE/API2/Nodes.pm
@@ -48,6 +48,7 @@ use PVE::API2::LXC;
 use PVE::API2::Network;
 use PVE::API2::NodeConfig;
 use PVE::API2::Qemu::CPU;
+use PVE::API2::Qemu::OVF;
 use PVE::API2::Qemu;
 use PVE::API2::Replication;
 use PVE::API2::Services;
@@ -76,6 +77,11 @@ __PACKAGE__->register_method ({
     path => 'cpu',
 });
 
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::Qemu::OVF",
+    path => 'readovf',
+});
+
 __PACKAGE__->register_method ({
     subclass => "PVE::API2::LXC",
     path => 'lxc',
@@ -2183,6 +2189,7 @@ __PACKAGE__->register_method ({
 	return undef;
     }});
 
+
 # bash completion helper
 
 sub complete_templet_repo {
diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index a2f7be6d..753cd1c0 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@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@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@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@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 470baa46..4d18d385 100644
--- a/www/manager6/qemu/HardwareView.js
+++ b/www/manager6/qemu/HardwareView.js
@@ -421,6 +421,30 @@ Ext.define('PVE.qemu.HardwareView', {
 	    handler: run_move,
 	});
 
+	var import_btn = new Proxmox.button.Button({
+	    text: gettext('Import disk'),
+	    hidden: Proxmox.UserName !== 'root@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'),
@@ -749,6 +773,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@pam',
+			// 	disabled: Proxmox.UserName !== 'root@pam',
+			// 	listeners: {
+			// 		change: (button,event,) => {
+			// 			var reader = new FileReader();
+			// 			let wizard = button.up('window');
+			// 			reader.onload = (e) => {
+			// 				let uploaded_ovf = e.target.result;
+			// 				// TODO set fields here
+			// 				// TODO When to upload disks to server?
+			// 			};
+			// 			reader.readAsText(event.target.files[0]);
+			// 			button.disable(); // TODO implement complete reload
+			// 			wizard.down('#successTextfield').show();
+			// 		}
+			// 	}
+			// },
+			{
+				xtype: 'label',
+				itemId: 'successTextfield',
+				hidden: true,
+				html: gettext('Manifest successfully uploaded'),
+				margin: '0 0 0 10',
+			},
+			{
+				xtype: 'textfield',
+				itemId: 'server_ovf_manifest',
+				name: 'ovf_textfield',
+				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




  parent reply	other threads:[~2021-03-09 10:44 UTC|newest]

Thread overview: 5+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2021-03-09 10:43 [pve-devel] [PATCH v6 storage] Optionally allow blockdev in abs_filesystem_path Dominic Jäger
2021-03-09 10:43 ` [pve-devel] [PATCH v6 qemu-server] Add API for import wizards Dominic Jäger
     [not found]   ` <<20210309104318.317454-2-d.jaeger@proxmox.com>
2021-03-15  9:25     ` Fabian Grünbichler
2021-03-09 10:43 ` Dominic Jäger [this message]
     [not found] ` <<20210309104318.317454-1-d.jaeger@proxmox.com>
2021-03-15  9:25   ` [pve-devel] [PATCH v6 storage] Optionally allow blockdev in abs_filesystem_path Fabian Grünbichler

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20210309104318.317454-3-d.jaeger@proxmox.com \
    --to=d.jaeger@proxmox.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal