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 v7 manager] gui: Add import for disk & VM
Date: Fri, 26 Mar 2021 13:32:27 +0100	[thread overview]
Message-ID: <20210326123227.137243-3-d.jaeger@proxmox.com> (raw)
In-Reply-To: <20210326123227.137243-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>

---
v6->v7:
- Update to API changes
- Add helpers to Utils
- Whitespace & line breaks according to style guide
- Making conditional branches in HDEdit easier to read

 PVE/API2/Nodes.pm                       |   7 +
 www/manager6/Makefile                   |   2 +
 www/manager6/Utils.js                   |  12 +
 www/manager6/Workspace.js               |  15 ++
 www/manager6/form/ControllerSelector.js |  15 ++
 www/manager6/node/CmdMenu.js            |  13 +
 www/manager6/qemu/HDEdit.js             | 169 +++++++++++-
 www/manager6/qemu/HardwareView.js       |  25 ++
 www/manager6/qemu/ImportWizard.js       | 332 ++++++++++++++++++++++++
 www/manager6/qemu/MultiHDEdit.js        | 277 ++++++++++++++++++++
 www/manager6/window/Wizard.js           |   2 +
 11 files changed, 856 insertions(+), 13 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/Utils.js b/www/manager6/Utils.js
index f502950f..dbfd65ce 100644
--- a/www/manager6/Utils.js
+++ b/www/manager6/Utils.js
@@ -1708,6 +1708,16 @@ Ext.define('PVE.Utils', {
 	});
     },
 
+    // collection ... collection of strings of a subset of the descendants of container
+    // visible ... true to show and enable, false to hide and disable
+    setDescendantsVisible: function(container, collection, visible = 1) {
+	const hide = (element, value) => {
+	    element.setHidden(value);
+	    element.setDisabled(value);
+	};
+	collection.map(e => container.down(e)).forEach(e => hide(e, !visible));
+    },
+
     cpu_vendor_map: {
 	'default': 'QEMU',
 	'AuthenticAMD': 'AMD',
@@ -1787,6 +1797,8 @@ Ext.define('PVE.Utils', {
 	    hastop: ['HA', gettext('Stop')],
 	    imgcopy: ['', gettext('Copy data')],
 	    imgdel: ['', gettext('Erase data')],
+	    importdisk: ['VM', gettext('Import disk')],
+	    importvm: ['VM', gettext('Import VM')],
 	    lvmcreate: [gettext('LVM Storage'), gettext('Create')],
 	    lvmthincreate: [gettext('LVM-Thin Storage'), gettext('Create')],
 	    migrateall: ['', gettext('Migrate all VMs and Containers')],
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..a2f6c95a 100644
--- a/www/manager6/qemu/HDEdit.js
+++ b/www/manager6/qemu/HDEdit.js
@@ -58,6 +58,21 @@ Ext.define('PVE.qemu.HDInputPanel', {
 	},
     },
 
+    isImport: function() {
+	return this.isImportVM || this.isImportDisk;
+    },
+
+    /*
+    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 selection for each HDInputPanel => Make
+    names so that those within one HDInputPanel are equal, but different from other
+    HDInputPanels
+    */
+    getSourceTypeID() {
+	return 'sourceType_' + this.id;
+    },
+
     onGetValues: function(values) {
 	var me = this;
 
@@ -70,6 +85,8 @@ Ext.define('PVE.qemu.HDInputPanel', {
 	} else if (me.isCreate) {
 	    if (values.hdimage) {
 		me.drive.file = values.hdimage;
+	    } else if (me.isImport()) {
+		me.drive.file = `${values.hdstorage}:0`;
 	    } else {
 		me.drive.file = values.hdstorage + ":" + values.disksize;
 	    }
@@ -83,15 +100,33 @@ 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);
-        });
+	});
+
+	const getSourceImageLocation = function() {
+	    const type = values[me.getSourceTypeID()];
+	    return type === 'storage' ? values.sourceVolid : values.sourcePath;
+	};
 
+	if (me.isImportVM) {
+	    params.diskimage = `${confid}=${getSourceImageLocation()}`;
+	}
+
+	const options = PVE.Parser.printQemuDrive(me.drive);
 
-	params[confid] = PVE.Parser.printQemuDrive(me.drive);
+	if (me.isImportDisk) {
+	    params.device = confid;
+	    params.device_options = options;
+	    params.source = getSourceImageLocation();
+	    params.device = values.controller + values.deviceid;
+	    params.storage = values.hdstorage;
+	} else {
+	    params[confid] = options;
+	}
 
 	return params;
     },
@@ -149,6 +184,10 @@ 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);
@@ -169,10 +208,15 @@ Ext.define('PVE.qemu.HDInputPanel', {
 	me.advancedColumn2 = [];
 
 	if (!me.confid || me.unused) {
+	    let controllerColumn = me.isImport() ? 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.isImport()) {
+		me.bussel.fieldLabel = 'Target Device';
+	    }
+	    controllerColumn.push(me.bussel);
 
 	    me.scsiController = Ext.create('Ext.form.field.Display', {
 		fieldLabel: gettext('SCSI Controller'),
@@ -184,7 +228,7 @@ Ext.define('PVE.qemu.HDInputPanel', {
 		submitValue: false,
 		hidden: true,
 	    });
-	    me.column1.push(me.scsiController);
+	    controllerColumn.push(me.scsiController);
 	}
 
 	if (me.unused) {
@@ -199,14 +243,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.isImport()) {
+	    let selector = {
 		xtype: 'pveDiskStorageSelector',
 		storageContent: 'images',
 		name: 'disk',
 		nodename: me.nodename,
-		autoSelect: me.insideWizard,
-	    });
+		hideSize: me.isImport(),
+		autoSelect: me.insideWizard || me.isImport(),
+	    };
+	    if (me.isImport()) {
+		selector.storageLabel = gettext('Target storage');
+		me.column2.push(selector);
+	    } else {
+		me.column1.push(selector);
+	    }
 	} else {
 	    me.column1.push({
 		xtype: 'textfield',
@@ -217,6 +268,12 @@ Ext.define('PVE.qemu.HDInputPanel', {
 	    });
 	}
 
+	if (me.isImport()) {
+	    me.column2.push({
+		xtype: 'box',
+		autoEl: { tag: 'hr' },
+	    });
+	}
 	me.column2.push(
 	    {
 		xtype: 'CacheTypeSelector',
@@ -231,6 +288,84 @@ Ext.define('PVE.qemu.HDInputPanel', {
 		name: 'discard',
 	    },
 	);
+	if (me.isImport()) {
+	    me.column1.unshift(
+		{
+		    xtype: 'radiofield',
+		    itemId: 'sourceRadioStorage',
+		    name: me.getSourceTypeID(),
+		    inputValue: 'storage',
+		    boxLabel: gettext('Use a storage as source'),
+		    hidden: Proxmox.UserName !== 'root@pam',
+		    checked: true,
+		    listeners: {
+			change: (_, newValue) => {
+			    const selectors = [
+				'#sourceStorageSelector',
+				'#sourceFileSelector',
+			    ];
+			    PVE.Utils.setDescendantsVisible(me, selectors, newValue);
+			},
+		    },
+		}, {
+		    xtype: 'pveStorageSelector',
+		    itemId: 'sourceStorageSelector',
+		    name: 'inputImageStorage',
+		    nodename: me.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: me.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.getSourceTypeID(),
+		    inputValue: 'path',
+		    boxLabel: gettext('Use an absolute path as source'),
+		    hidden: Proxmox.UserName !== 'root@pam',
+		    listeners: {
+			change: (_, newValue) => {
+			    PVE.Utils.setDescendantsVisible(me, ['#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: function(insertedText) {
+			return insertedText.startsWith('/') ||
+			    insertedText.startsWith('http') ||
+			    gettext('Must be an absolute path or URL');
+		    },
+		},
+	    );
+	}
 
 	me.advancedColumn1.push(
 	    {
@@ -373,13 +508,18 @@ Ext.define('PVE.qemu.HDEdit', {
 	    nodename: nodename,
 	    unused: unused,
 	    isCreate: me.isCreate,
+	    isImportVM: me.isImportVM,
+	    isImportDisk: me.isImportDisk,
 	});
 
-	var subject;
 	if (unused) {
 	    me.subject = gettext('Unused Disk');
+	} else if (me.isImportDisk) {
+	    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 +544,9 @@ Ext.define('PVE.qemu.HDEdit', {
 		    ipanel.setDrive(drive);
 		    me.isValid(); // trigger validation
 		}
+		if (me.isImportDisk) {
+		    me.url = me.url.replace(/\/config$/, "/importdisk");
+		}
 	    },
 	});
     },
diff --git a/www/manager6/qemu/HardwareView.js b/www/manager6/qemu/HardwareView.js
index 98352e3f..4fbf0e5e 100644
--- a/www/manager6/qemu/HardwareView.js
+++ b/www/manager6/qemu/HardwareView.js
@@ -431,6 +431,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,
+		    isImportDisk: true,
+		    listeners: {
+			add: function(_, component) {
+			    const selectors = [
+				'#sourceStorageSelector',
+				'#sourceFileSelector',
+			    ];
+			    PVE.Utils.setDescendantsVisible(component, selectors);
+			},
+		    },
+		});
+		win.on('destroy', me.reload, me);
+		win.show();
+	    },
+	});
+
 	var remove_btn = new Proxmox.button.Button({
 	    text: gettext('Remove'),
 	    defaultText: gettext('Remove'),
@@ -759,6 +783,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..0066adc4
--- /dev/null
+++ b/www/manager6/qemu/ImportWizard.js
@@ -0,0 +1,332 @@
+/*jslint confusion: true*/
+Ext.define('PVE.qemu.ImportWizard', {
+    extend: 'PVE.window.Wizard',
+    alias: 'widget.pveQemuImportWizard',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    viewModel: {
+	data: {
+	    nodename: '',
+	    current: {
+		scsihw: '',
+	    },
+	},
+    },
+
+    cbindData: {
+	nodename: undefined,
+    },
+
+    subject: gettext('Import Virtual Machine'),
+
+    isImportVM: 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: [
+		{
+		    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) {
+			return (value && value.startsWith('/')) || gettext("Must start with /");
+		    },
+		},
+		{
+		    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..641a802f
--- /dev/null
+++ b/www/manager6/qemu/MultiHDEdit.js
@@ -0,0 +1,277 @@
+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%',
+	}, {
+	    xtype: 'container',
+	    layout: 'hbox',
+	    center: true,
+	    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,
+		isImportVM: 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);
+		}
+	    },
+	},
+    ],
+
+    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
+    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-26 12:32 UTC|newest]

Thread overview: 7+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2021-03-26 12:32 [pve-devel] [PATCH v7 storage] Optionally allow blockdev in abs_filesystem_path Dominic Jäger
2021-03-26 12:32 ` [pve-devel] [PATCH v7 qemu-server] Add API for import wizards Dominic Jäger
2021-03-31 15:12   ` Fabian Grünbichler
2021-04-01 10:19     ` Dominic Jäger
2021-04-01 11:30       ` Fabian Grünbichler
2021-03-26 12:32 ` Dominic Jäger [this message]
2021-04-01 13:40 ` [pve-devel] applied: [PATCH v7 storage] Optionally allow blockdev in abs_filesystem_path Thomas Lamprecht

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=20210326123227.137243-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