[pve-devel] [PATCH] Add GUI to import disk & VM

Dominic Jäger d.jaeger at proxmox.com
Mon Apr 12 12:08:25 CEST 2021


Add GUI wizard to import whole VMs and a window to import single disks in
Hardware View.

Signed-off-by: Dominic Jäger <d.jaeger at proxmox.com>
---
v8:
- Adapt to new API
- Some small fixes
- Much renaming

 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             | 149 ++++++++++-
 www/manager6/qemu/HDEditCollection.js   | 263 ++++++++++++++++++++
 www/manager6/qemu/HardwareView.js       |  24 ++
 www/manager6/qemu/ImportWizard.js       | 317 ++++++++++++++++++++++++
 www/manager6/window/Wizard.js           |   2 +
 10 files changed, 795 insertions(+), 12 deletions(-)
 create mode 100644 www/manager6/qemu/HDEditCollection.js
 create mode 100644 www/manager6/qemu/ImportWizard.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..dbb85062 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/HDEditCollection.js				\
 	qemu/HDEfi.js					\
 	qemu/HDMove.js					\
 	qemu/HDResize.js				\
diff --git a/www/manager6/Workspace.js b/www/manager6/Workspace.js
index 0c1b9e0c..631739a0 100644
--- a/www/manager6/Workspace.js
+++ b/www/manager6/Workspace.js
@@ -280,11 +280,25 @@ Ext.define('PVE.StdWorkspace', {
 	    },
 	});
 
+	var importVM = Ext.createWidget('button', {
+	    pack: 'end',
+	    margin: '3 5 0 0',
+	    baseCls: 'x-btn',
+	    iconCls: 'fa fa-desktop',
+	    text: gettext("Import VM"),
+	    hidden: Proxmox.UserName !== 'root at pam',
+	    handler: function() {
+		var wiz = Ext.create('PVE.qemu.ImportWizard', {});
+		wiz.show();
+	    },
+	});
+
 	sprovider.on('statechange', function(sp, key, value) {
 	    if (key === 'GuiCap' && value) {
 		caps = value;
 		createVM.setDisabled(!caps.vms['VM.Allocate']);
 		createCT.setDisabled(!caps.vms['VM.Allocate']);
+		importVM.setDisabled(!caps.vms['VM.Allocate']);
 	    }
 	});
 
@@ -332,6 +346,7 @@ Ext.define('PVE.StdWorkspace', {
 			},
 			createVM,
 			createCT,
+			importVM,
 			{
 			    pack: 'end',
 			    margin: '0 5 0 0',
diff --git a/www/manager6/form/ControllerSelector.js b/www/manager6/form/ControllerSelector.js
index 23c61159..f515b220 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) {
+	const regex = /([a-z]+)(\d+)/;
+	const [_, 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..b66c7a6e 100644
--- a/www/manager6/node/CmdMenu.js
+++ b/www/manager6/node/CmdMenu.js
@@ -29,6 +29,19 @@ Ext.define('PVE.node.CmdMenu', {
 		wiz.show();
 	    },
 	},
+	{
+	    text: gettext("Import VM"),
+	    hidden: Proxmox.UserName !== 'root at pam',
+	    itemId: 'importvm',
+	    iconCls: 'fa fa-cube',
+	    handler: function() {
+		const me = this.up('menu');
+		const 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..3af7e624 100644
--- a/www/manager6/qemu/HDEdit.js
+++ b/www/manager6/qemu/HDEdit.js
@@ -58,6 +58,17 @@ 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 HDInputPanelCollection 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 +81,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}:-1`;
 	    } else {
 		me.drive.file = values.hdstorage + ":" + values.disksize;
 	    }
@@ -83,13 +96,21 @@ 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.isImport) {
+	    params.import_sources = `${confid}=${getSourceImageLocation()}`;
+	}
 
 	params[confid] = PVE.Parser.printQemuDrive(me.drive);
 
@@ -149,6 +170,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 +194,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 +214,7 @@ Ext.define('PVE.qemu.HDInputPanel', {
 		submitValue: false,
 		hidden: true,
 	    });
-	    me.column1.push(me.scsiController);
+	    controllerColumn.push(me.scsiController);
 	}
 
 	if (me.unused) {
@@ -199,14 +229,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 +254,12 @@ Ext.define('PVE.qemu.HDInputPanel', {
 	    });
 	}
 
+	if (me.isImport) {
+	    me.column2.push({
+		xtype: 'box',
+		autoEl: { tag: 'hr' },
+	    });
+	}
 	me.column2.push(
 	    {
 		xtype: 'CacheTypeSelector',
@@ -231,6 +274,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 at pam',
+		    checked: true,
+		    listeners: {
+			change: (_, newValue) => {
+			    me.down('#sourceStorageSelector').setHidden(!newValue);
+			    me.down('#sourceStorageSelector').setDisabled(!newValue);
+			    me.down('#sourceFileSelector').setHidden(!newValue);
+			    me.down('#sourceFileSelector').setDisabled(!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 at pam',
+		    listeners: {
+			change: (_, newValue) => {
+			    me.down('#sourcePathTextfield').setHidden(!newValue);
+			    me.down('#sourcePathTextfield').setDisabled(!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 +494,17 @@ Ext.define('PVE.qemu.HDEdit', {
 	    nodename: nodename,
 	    unused: unused,
 	    isCreate: me.isCreate,
+	    isImport: 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 + ')';
 	}
diff --git a/www/manager6/qemu/HDEditCollection.js b/www/manager6/qemu/HDEditCollection.js
new file mode 100644
index 00000000..33f6193a
--- /dev/null
+++ b/www/manager6/qemu/HDEditCollection.js
@@ -0,0 +1,263 @@
+Ext.define('PVE.qemu.HDInputPanelCollection', {
+    extend: 'Proxmox.panel.InputPanel',
+    alias: 'widget.pveQemuHDInputPanelCollection',
+
+    insideWizard: false,
+
+    hiddenDisks: [],
+
+    leftColumnRatio: 0.25,
+
+    column1: [
+	{
+	    // Adding to the panelContainer 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) {
+		    const recordIndex = this.findBy(record => record.data.panel === panel);
+		    this.removeAt(recordIndex);
+		    return recordIndex;
+		},
+		getLast: function() {
+		    const last = this.getCount() - 1;
+		    return this.getAt(last);
+		},
+	    },
+	    columns: [
+		{
+		    text: gettext('Target device'),
+		    dataIndex: 'device',
+		    flex: 1,
+		    resizable: false,
+		},
+	    ],
+	    listeners: {
+		select: function(_, record) {
+		    this.up('pveQemuHDInputPanelCollection')
+			.down('#panelContainer')
+			.setActiveItem(record.data.panel);
+		},
+	    },
+	    anchor: '100% 90%',
+	    selectLast: function() {
+		this.setSelection(this.store.getLast());
+	    },
+	}, {
+	    xtype: 'container',
+	    layout: 'hbox',
+	    center: true,
+	    defaults: {
+		margin: '5',
+		xtype: 'button',
+	    },
+	    items: [
+		{
+		    iconCls: 'fa fa-plus-circle',
+		    itemId: 'addDisk',
+		    handler: function(button) {
+			button.up('pveQemuHDInputPanelCollection').addDisk();
+		    },
+		}, {
+		    iconCls: 'fa fa-trash-o',
+		    itemId: 'removeDisk',
+		    handler: function(button) {
+			button.up('pveQemuHDInputPanelCollection').removeCurrentDisk();
+		    },
+		},
+	    ],
+	},
+    ],
+    column2: [
+	{
+	    itemId: 'panelContainer',
+	    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.addDisk();
+		    }
+		},
+		add: function(container, newPanel) {
+		    const store = Ext.getStore('importwizard_diskstorage');
+		    store.add({ device: newPanel.getDevice(), panel: newPanel });
+		    container.setActiveItem(newPanel);
+		},
+		remove: function(panelContainer, HDInputPanel, eOpts) {
+		    const store = Ext.getStore('importwizard_diskstorage');
+		    store.removeByPanel(HDInputPanel);
+		    if (panelContainer.items.getCount() > 0) {
+			panelContainer.setActiveItem(0);
+		    }
+		},
+	    },
+	    defaultItem: {
+		xtype: 'pveQemuHDInputPanel',
+		bind: {
+		    nodename: '{nodename}',
+		},
+		isCreate: true,
+		isImport: 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: {
+		    // newPanel ... this cloned + added defaultItem
+		    added: function(newPanel) {
+			Ext.Array.each(newPanel.down('pveControllerSelector').query('field'),
+			    function(field) {
+				// Add here because the fields don't exist earlier
+				field.on('change', function() {
+				    const store = Ext.getStore('importwizard_diskstorage');
+
+				    // find by panel object because it is unique
+				    const recordIndex = store.findBy(record =>
+					record.data.panel === field.up('pveQemuHDInputPanel'),
+				    );
+				    const controllerSelector = field.up('pveControllerSelector');
+				    const newControllerAndId = controllerSelector.getValuesAsString();
+
+				    store.getAt(recordIndex).set('device', newControllerAndId);
+				});
+			    },
+			);
+			const 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 ... content of the sourcePathTextfield
+	    addDisk(device, path) {
+		const item = Ext.clone(this.defaultItem);
+		const added = this.add(item);
+		// values in the storage will be updated by listeners
+		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();
+		}
+
+		const sp = Ext.state.Manager.getProvider();
+		const advanced_checkbox = sp.get('proxmox-advanced-cb');
+		added.setAdvancedVisible(advanced_checkbox);
+
+		if (device) {
+		    added.down('pveControllerSelector').setValue(device);
+		}
+		return added;
+	    },
+	    removeCurrentDisk: function() {
+		const activePanel = this.getLayout().activeItem; // panel = disk
+		if (activePanel) {
+		    this.remove(activePanel);
+		}
+	    },
+	},
+    ],
+
+    addDisk: function(device, path) {
+	this.down('#panelContainer').addDisk(device, path);
+	this.down('gridpanel').selectLast();
+    },
+    removeCurrentDisk: function() {
+	this.down('#panelContainer').removeCurrentDisk();
+    },
+    removeAllDisks: function() {
+	const container = this.down('#panelContainer');
+	while (container.items.items.length > 0) {
+	    container.removeCurrentDisk();
+	}
+    },
+
+    beforeRender: function() {
+	const me = this;
+	const leftColumnPanel = me.items.get(0).items.get(0);
+	leftColumnPanel.setFlex(me.leftColumnRatio);
+	// any other panel because this has no height yet
+	const panelHeight = me.up('tabpanel').items.get(0).getHeight();
+	leftColumnPanel.setHeight(panelHeight);
+    },
+
+    setNodename: function(nodename) {
+	this.nodename = nodename;
+    },
+
+    listeners: {
+	afterrender: function() {
+	    const store = Ext.getStore('importwizard_diskstorage');
+	    const first = store.getAt(0);
+	    if (first) {
+		this.down('gridpanel').setSelection(first);
+	    }
+	},
+    },
+
+    // values ... is optional
+    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] &&
+		    values.deviceid[i] === values.deviceid[j]
+		) {
+		    return true;
+		}
+	    }
+	}
+	return false;
+    },
+
+    onGetValues: function(values) {
+	if (this.hasDuplicateDevices(values)) {
+	    Ext.Msg.alert(gettext('Error'), 'Equal target devices are forbidden. Make all unique!');
+	}
+	// Each child HDInputPanel has sufficient onGetValues() => Return nothing
+    },
+
+    validator: function() {
+	const me = this;
+	const panels = me.down('#panelContainer').items.getRange();
+	return panels.every(panel => panel.validator()) && !me.hasDuplicateDevices();
+    },
+});
diff --git a/www/manager6/qemu/HardwareView.js b/www/manager6/qemu/HardwareView.js
index 98352e3f..be4e2d28 100644
--- a/www/manager6/qemu/HardwareView.js
+++ b/www/manager6/qemu/HardwareView.js
@@ -431,6 +431,29 @@ Ext.define('PVE.qemu.HardwareView', {
 	    handler: run_move,
 	});
 
+	var import_btn = new Proxmox.button.Button({
+	    text: gettext('Import disk'),
+	    hidden: Proxmox.UserName !== 'root at pam',
+	    handler: function() {
+		var win = Ext.create('PVE.qemu.HDEdit', {
+		    method: 'POST',
+		    url: `/api2/extjs/${baseurl}`,
+		    pveSelNode: me.pveSelNode,
+		    isImport: true,
+		    listeners: {
+			add: function(_, component) {
+			    component.down('#sourceStorageSelector').show();
+			    component.down('#sourceStorageSelector').enable();
+			    component.down('#sourceFileSelector').enable();
+			    component.down('#sourceFileSelector').show();
+			},
+		    },
+		});
+		win.on('destroy', me.reload, me);
+		win.show();
+	    },
+	});
+
 	var remove_btn = new Proxmox.button.Button({
 	    text: gettext('Remove'),
 	    defaultText: gettext('Remove'),
@@ -759,6 +782,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..a9a63fe3
--- /dev/null
+++ b/www/manager6/qemu/ImportWizard.js
@@ -0,0 +1,317 @@
+/*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'),
+
+    isImport: true,
+
+    addDisk: function() {
+	const me = this;
+	const wizard = me.xtype === 'pveQemuImportWizard' ? me : me.up('window');
+	wizard.down('pveQemuHDInputPanelCollection').addDisk();
+    },
+
+    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',
+		    name: 'ovfTextfield',
+		    emptyText: '/mnt/nfs/exported.ovf',
+		    fieldLabel: 'Absolute path to .ovf manifest on your PVE host',
+		    listeners: {
+			validitychange: function(_, isValid) {
+			    const 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() {
+			const inputpanel = this.up('#importInputpanel');
+			const nodename = inputpanel.down('pveNodeSelector').getValue();
+			const ovfTextfieldValue = inputpanel.down('textfield[name=ovfTextfield]').getValue();
+			const wizard = this.up('window');
+			Proxmox.Utils.API2Request({
+			    url: '/nodes/' + nodename + '/readovf',
+			    method: 'GET',
+			    params: {
+				manifest: ovfTextfieldValue,
+			    },
+			    success: function(response) {
+				const 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;
+				const devices = Object.keys(ovfdata); // e.g. ide0, sata2
+				const hdcollection = wizard.down('pveQemuHDInputPanelCollection');
+				hdcollection.removeAllDisks(); // does nothing if already empty
+				devices.forEach(device => hdcollection.addDisk(device, ovfdata[device]));
+			    },
+			    failure: function(response) {
+				Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+			    },
+			});
+		    },
+		},
+	    ],
+	    onGetValues: function(values) {
+		delete values.ovfTextfield;
+		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: 'pveQemuHDInputPanelCollection',
+	    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.import_sources)) {
+		    params.import_sources = params.import_sources.join('\0');
+		}
+
+		Proxmox.Utils.API2Request({
+		    url: `/nodes/${nodename}/qemu`,
+		    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/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





More information about the pve-devel mailing list