[pve-devel] [PATCH manager 4/6 v2] gui: Add import VM wizard
Dominic Jäger
d.jaeger at proxmox.com
Fri Nov 20 10:38:10 CET 2020
Signed-off-by: Dominic Jäger <d.jaeger at proxmox.com>
---
v2: This patch is unchanged, but the lines are changed by patch 6. I'll rebase that soon.
PVE/API2/Nodes.pm | 48 +++
www/manager6/Makefile | 2 +
www/manager6/form/ControllerSelector.js | 26 +-
www/manager6/qemu/HDEdit.js | 219 +++++++++-----
www/manager6/qemu/ImportWizard.js | 379 ++++++++++++++++++++++++
www/manager6/qemu/MultiHDEdit.js | 267 +++++++++++++++++
www/manager6/window/Wizard.js | 153 +++++-----
7 files changed, 940 insertions(+), 154 deletions(-)
create mode 100644 www/manager6/qemu/ImportWizard.js
create mode 100644 www/manager6/qemu/MultiHDEdit.js
diff --git a/PVE/API2/Nodes.pm b/PVE/API2/Nodes.pm
index 1b133352..b0e386f9 100644
--- a/PVE/API2/Nodes.pm
+++ b/PVE/API2/Nodes.pm
@@ -27,6 +27,7 @@ use PVE::HA::Env::PVE2;
use PVE::HA::Config;
use PVE::QemuConfig;
use PVE::QemuServer;
+use PVE::QemuServer::OVF;
use PVE::API2::Subscription;
use PVE::API2::Services;
use PVE::API2::Network;
@@ -224,6 +225,7 @@ __PACKAGE__->register_method ({
{ name => 'subscription' },
{ name => 'report' },
{ name => 'tasks' },
+ { name => 'readovf' },
{ name => 'rrd' }, # fixme: remove?
{ name => 'rrddata' },# fixme: remove?
{ name => 'replication' },
@@ -2137,6 +2139,52 @@ __PACKAGE__->register_method ({
return undef;
}});
+__PACKAGE__->register_method ({
+ name => 'readovf',
+ path => 'readovf',
+ method => 'GET',
+ protected => 1, # for worker upid file
+ proxyto => 'node',
+ description => "Read an .ovf manifest.",
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ node => get_standard_option('pve-node'),
+ manifest => {
+ description => ".ovf manifest",
+ type => 'string',
+ },
+ },
+ },
+ returns => {
+ description => "VM config according to .ovf manifest and digest of manifest",
+ type => "object",
+ properties => PVE::QemuServer::json_config_properties({
+ digest => {
+ type => 'string',
+ description => 'SHA1 digest of configuration file. This can be used to prevent concurrent modifications.',
+ },
+ }),
+ },
+ code => sub {
+ my ($param) = @_;
+
+ my $filename = '/tmp/readovflog';
+ open (my $fh, '>', $filename) or die "could not open file $filename";
+ my $parsed = PVE::QemuServer::OVF::parse_ovf($param->{manifest}, 1, 1);
+ my $result;
+ $result->{digest} = Digest::SHA::sha1_hex($param->{manifest});
+ $result->{cores} = $parsed->{qm}->{cores};
+ $result->{name} = $parsed->{qm}->{name};
+ $result->{memory} = $parsed->{qm}->{memory};
+
+ my $disks = $parsed->{disks};
+ foreach my $disk (@$disks) {
+ $result->{$disk->{disk_address}} = "importsource=".$disk->{backing_file};
+ }
+ return $result;
+}});
+
# bash completion helper
sub complete_templet_repo {
diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index 4fa8e1a3..bcd55fad 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -194,8 +194,10 @@ JSSRC= \
qemu/CmdMenu.js \
qemu/Config.js \
qemu/CreateWizard.js \
+ qemu/ImportWizard.js \
qemu/DisplayEdit.js \
qemu/HDEdit.js \
+ qemu/MultiHDEdit.js \
qemu/HDEfi.js \
qemu/HDMove.js \
qemu/HDResize.js \
diff --git a/www/manager6/form/ControllerSelector.js b/www/manager6/form/ControllerSelector.js
index 9fdae5d1..d9fbfe66 100644
--- a/www/manager6/form/ControllerSelector.js
+++ b/www/manager6/form/ControllerSelector.js
@@ -68,6 +68,22 @@ clist_loop:
deviceid.validate();
},
+ getValues: function() {
+ return this.query('field').map(x => x.getValue());
+ },
+
+ getValuesAsString: function() {
+ return this.getValues().join('');
+ },
+
+ setValue: function(value) {
+ console.assert(value);
+ let regex = /([a-z]+)(\d+)/;
+ [_, controller, deviceid] = regex.exec(value);
+ this.query('field[name=controller]').pop().setValue(controller);
+ this.query('field[name=deviceid]').pop().setValue(deviceid);
+ },
+
initComponent: function() {
var me = this;
@@ -85,16 +101,6 @@ clist_loop:
noVirtIO: me.noVirtIO,
allowBlank: false,
flex: 2,
- listeners: {
- change: function(t, value) {
- if (!value) {
- return;
- }
- var field = me.down('field[name=deviceid]');
- field.setMaxValue(PVE.Utils.diskControllerMaxIDs[value]);
- field.validate();
- }
- }
},
{
xtype: 'proxmoxintegerfield',
diff --git a/www/manager6/qemu/HDEdit.js b/www/manager6/qemu/HDEdit.js
index 5e0a3981..f8e811e1 100644
--- a/www/manager6/qemu/HDEdit.js
+++ b/www/manager6/qemu/HDEdit.js
@@ -8,6 +8,10 @@ Ext.define('PVE.qemu.HDInputPanel', {
unused: false, // ADD usused disk imaged
+ showSourcePathTextfield: false, // to import a disk from an aritrary path
+
+ returnSingleKey: true, // {vmid}/importdisk expects multiple keys => false
+
vmconfig: {}, // used to select usused disks
viewModel: {},
@@ -58,6 +62,38 @@ Ext.define('PVE.qemu.HDInputPanel', {
}
},
+ /*
+ All radiofields (esp. sourceRadioPath and sourceRadioStorage) have the
+ same scope for name. But we need a different scope for each HDInputPanel in
+ a MultiHDInputPanel to get the selectionf or each HDInputPanel => Make
+ names so that those in one HDInputPanel are equal but different from other
+ HDInputPanels
+ */
+ getSourceTypeIdentifier() {
+ console.assert(this.id);
+ return 'sourceType_' + this.id;
+ },
+
+ // values ... the values from onGetValues
+ getSourceValue: function(values) {
+ console.assert(values);
+ let result;
+ let type = values[this.getSourceTypeIdentifier()];
+ console.assert(type === 'storage' || type === 'path',
+ `type must be 'storage' or 'path' but is ${type}`);
+ if (type === 'storage') {
+ console.assert(values.sourceVolid,
+ "sourceVolid must be set when type is storage");
+ result = values.sourceVolid;
+ } else {
+ console.assert(values.sourcePath,
+ "sourcePath must be set when type is path");
+ result = values.sourcePath;
+ }
+ console.assert(result);
+ return result;
+ },
+
onGetValues: function(values) {
var me = this;
@@ -67,16 +103,18 @@ Ext.define('PVE.qemu.HDInputPanel', {
if (me.unused) {
me.drive.file = me.vmconfig[values.unusedId];
confid = values.controller + values.deviceid;
- } else if (me.isCreate && !me.isImport) {
+ } else if (me.isCreate) {
// disk format & size should not be part of propertyString for import
if (values.hdimage) {
me.drive.file = values.hdimage;
+ } else if (me.isImport) {
+ me.drive.file = `${values.hdstorage}:0`; // so that API allows it
} else {
me.drive.file = values.hdstorage + ":" + values.disksize;
}
me.drive.format = values.diskformat;
}
-
+
PVE.Utils.propertyStringSet(me.drive, !values.backup, 'backup', '0');
PVE.Utils.propertyStringSet(me.drive, values.noreplicate, 'replicate', 'no');
PVE.Utils.propertyStringSet(me.drive, values.discard, 'discard', 'on');
@@ -90,15 +128,22 @@ Ext.define('PVE.qemu.HDInputPanel', {
PVE.Utils.propertyStringSet(me.drive, values[name], name);
PVE.Utils.propertyStringSet(me.drive, values[burst_name], burst_name);
});
- if (me.isImport) {
+
+ if (me.returnSingleKey) {
+ if (me.isImport) {
+ me.drive.importsource = this.getSourceValue(values);
+ }
+ params[confid] = PVE.Parser.printQemuDrive(me.drive);
+ } else {
+ console.assert(me.isImport,
+ "Returning multiple key/values is only allowed in import");
params.device_options = PVE.Parser.printPropertyString(me.drive);
- params.source = values.sourceType === 'storage'
- ? values.sourceVolid : values.sourcePath;
+ params.source = this.getSourceValue(values);
params.device = values.controller + values.deviceid;
params.storage = values.hdstorage;
- if (values.diskformat) params.format = values.diskformat;
- } else {
- params[confid] = PVE.Parser.printQemuDrive(me.drive);
+ if (values.diskformat) {
+ params.format = values.diskformat;
+ }
}
return params;
},
@@ -156,10 +201,16 @@ Ext.define('PVE.qemu.HDInputPanel', {
me.setValues(values);
},
+ getDevice: function() {
+ return this.bussel.getValuesAsString();
+ },
+
setNodename: function(nodename) {
var me = this;
me.down('#hdstorage').setNodename(nodename);
me.down('#hdimage').setStorage(undefined, nodename);
+ // me.down('#sourceStorageSelector').setNodename(nodename);
+ // me.down('#sourceFileSelector').setNodename(nodename);
},
initComponent : function() {
@@ -175,12 +226,16 @@ Ext.define('PVE.qemu.HDInputPanel', {
me.advancedColumn1 = [];
me.advancedColumn2 = [];
+ let nodename = this.getViewModel().get('nodename'); // TODO hacky whacky
+
+
if (!me.confid || me.unused) {
- let controllerColumn = me.isImport ? me.column2 : me.column1;
+ let controllerColumn = me.showSourcePathTextfield ? me.column2 : me.column1;
me.bussel = Ext.create('PVE.form.ControllerSelector', {
+ itemId: 'bussel',
vmconfig: me.insideWizard ? {ide2: 'cdrom'} : {}
});
- if (me.isImport) {
+ if (me.showSourcePathTextfield) {
me.bussel.fieldLabel = 'Target Device';
}
controllerColumn.push(me.bussel);
@@ -210,16 +265,16 @@ Ext.define('PVE.qemu.HDInputPanel', {
allowBlank: false
});
me.column1.push(me.unusedDisks);
- } else if (me.isCreate || me.isImport) {
+ } else if (me.isCreate || me.showSourcePathTextfield) {
let selector = {
xtype: 'pveDiskStorageSelector',
storageContent: 'images',
name: 'disk',
- nodename: me.nodename,
- hideSize: me.isImport,
- autoSelect: me.insideWizard || me.isImport,
+ nodename: nodename,
+ hideSize: me.showSourcePathTextfield,
+ autoSelect: me.insideWizard || me.showSourcePathTextfield,
};
- if (me.isImport) {
+ if (me.showSourcePathTextfield) {
selector.storageLabel = gettext('Target storage');
me.column2.push(selector);
} else {
@@ -235,7 +290,7 @@ Ext.define('PVE.qemu.HDInputPanel', {
});
}
- if (me.isImport) {
+ if (me.showSourcePathTextfield) {
me.column2.push({
xtype: 'box',
autoEl: { tag: 'hr' },
@@ -255,72 +310,83 @@ Ext.define('PVE.qemu.HDInputPanel', {
name: 'discard'
}
);
- if (me.isImport) {
+ if (me.showSourcePathTextfield) {
let show = (element, value) => {
element.setHidden(!value);
element.setDisabled(!value);
};
- me.sourceRadioStorage = Ext.create('Ext.form.field.Radio', {
- name: 'sourceType',
- inputValue: 'storage',
- boxLabel: gettext('Use a storage as source'),
- checked: true,
- hidden: Proxmox.UserName !== 'root at pam',
- listeners: {
- added: () => show(me.sourcePathTextfield, false),
- change: (_, storageRadioChecked) => {
- show(me.sourcePathTextfield, !storageRadioChecked);
- let selectors = [
- me.sourceStorageSelector,
- me.sourceFileSelector,
- ];
- for (const selector of selectors) {
- show(selector, storageRadioChecked);
- }
+
+ me.column1.unshift(
+ {
+ xtype: 'radiofield',
+ itemId: 'sourceRadioStorage',
+ name: me.getSourceTypeIdentifier(),
+ inputValue: 'storage',
+ boxLabel: gettext('Use a storage as source'),
+ hidden: Proxmox.UserName !== 'root at pam',
+ checked: true,
+ listeners: {
+ change: (_, newValue) => {
+ let storageSelectors = [
+ me.down('#sourceStorageSelector'),
+ me.down('#sourceFileSelector'),
+ ];
+ for (const selector of storageSelectors) {
+ show(selector, newValue);
+ }
+ },
},
- },
- });
- me.sourceStorageSelector = Ext.create('PVE.form.StorageSelector', {
- name: 'inputImageStorage',
- nodename: me.nodename,
- fieldLabel: gettext('Source Storage'),
- storageContent: 'images',
- autoSelect: me.insideWizard,
- listeners: {
- change: function(_, selectedStorage) {
- me.sourceFileSelector.setStorage(selectedStorage);
+ }, {
+ xtype: 'pveStorageSelector',
+ itemId: 'sourceStorageSelector',
+ name: 'inputImageStorage',
+ nodename: nodename,
+ fieldLabel: gettext('Source Storage'),
+ storageContent: 'images',
+ autoSelect: me.insideWizard,
+ hidden: true,
+ disabled: true,
+ listeners: {
+ change: function (_, selectedStorage) {
+ me.down('#sourceFileSelector').setStorage(selectedStorage);
+ },
},
+ }, {
+ xtype: 'pveFileSelector',
+ itemId: 'sourceFileSelector',
+ name: 'sourceVolid', // TODO scope of itemId is container, this breaks onGetValues, only one thingy is selected for multiple inputpanels
+ nodename: nodename,
+ storageContent: 'images',
+ hidden: true,
+ disabled: true,
+ fieldLabel: gettext('Source Image'),
+ }, {
+ xtype: 'radiofield',
+ itemId: 'sourceRadioPath',
+ name: me.getSourceTypeIdentifier(),
+ inputValue: 'path',
+ boxLabel: gettext('Use an absolute path as source'),
+ hidden: Proxmox.UserName !== 'root at pam',
+ listeners: {
+ change: (_, newValue) => {
+ show(me.down('#sourcePathTextfield'), newValue);
+ },
+ },
+ }, {
+ xtype: 'textfield',
+ itemId: 'sourcePathTextfield',
+ fieldLabel: gettext('Source Path'),
+ name: 'sourcePath',
+ autoEl: {
+ tag: 'div',
+ 'data-qtip': gettext('Absolute path to the source disk image, for example: /home/user/somedisk.qcow2'),
+ },
+ hidden: true,
+ disabled: true,
+ validator: (insertedText) =>
+ insertedText.startsWith('/') ||
+ gettext('Must be an absolute path'),
},
- });
- me.sourceFileSelector = Ext.create('PVE.form.FileSelector', {
- name: 'sourceVolid',
- nodename: me.nodename,
- storageContent: 'images',
- fieldLabel: gettext('Source Image'),
- });
- me.sourceRadioPath = Ext.create('Ext.form.field.Radio', {
- name: 'sourceType',
- inputValue: 'path',
- boxLabel: gettext('Use an absolute path as source'),
- hidden: Proxmox.UserName !== 'root at pam',
- });
- me.sourcePathTextfield = Ext.create('Ext.form.field.Text', {
- xtype: 'textfield',
- fieldLabel: gettext('Source Path'),
- name: 'sourcePath',
- emptyText: '/home/user/disk.qcow2',
- hidden: Proxmox.UserName !== 'root at pam',
- validator: function(insertedText) {
- return insertedText.startsWith('/') ||
- gettext('Must be an absolute path');
- },
- });
- me.column1.unshift(
- me.sourceRadioStorage,
- me.sourceStorageSelector,
- me.sourceFileSelector,
- me.sourceRadioPath,
- me.sourcePathTextfield,
);
}
@@ -465,7 +531,8 @@ Ext.define('PVE.qemu.HDEdit', {
nodename: nodename,
unused: unused,
isCreate: me.isCreate,
- isImport: me.isImport,
+ showSourcePathTextfield: me.isImport,
+ returnSingleKey: !me.isImport,
});
var subject;
diff --git a/www/manager6/qemu/ImportWizard.js b/www/manager6/qemu/ImportWizard.js
new file mode 100644
index 00000000..c6e91a48
--- /dev/null
+++ b/www/manager6/qemu/ImportWizard.js
@@ -0,0 +1,379 @@
+/*jslint confusion: true*/
+Ext.define('PVE.qemu.ImportWizard', {
+ extend: 'PVE.window.Wizard',
+ alias: 'widget.pveQemuImportWizard',
+ mixins: ['Proxmox.Mixin.CBind'],
+
+ viewModel: {
+ data: {
+ nodename: '',
+ current: {
+ scsihw: '' // TODO there is some error with apply after render_scsihw??
+ }
+ }
+ },
+
+ cbindData: {
+ nodename: undefined
+ },
+
+ subject: gettext('Import Virtual Machine'),
+
+ isImport: true,
+
+ addDiskFunction: function () {
+ let me = this;
+ let wizard;
+ if (me.xtype === 'button') {
+ wizard = me.up('window');
+ } else if (me.xtype === 'pveQemuImportWizard') {
+ wizard = me;
+ }
+ console.assert(wizard.xtype === 'pveQemuImportWizard');
+ let multihd = wizard.down('pveQemuMultiHDInputPanel');
+ multihd.addDiskFunction();
+ },
+
+ items: [
+ {
+ xtype: 'inputpanel',
+ title: gettext('Import'),
+ column1: [
+ {
+ xtype: 'pveNodeSelector',
+ name: 'nodename',
+ cbind: {
+ selectCurNode: '{!nodename}',
+ preferredValue: '{nodename}'
+ },
+ bind: {
+ value: '{nodename}'
+ },
+ fieldLabel: gettext('Node'),
+ allowBlank: false,
+ onlineValidator: true
+ }, {
+ xtype: 'pveGuestIDSelector',
+ name: 'vmid',
+ guestType: 'qemu',
+ value: '',
+ loadNextFreeID: true,
+ validateExists: false
+ },
+ ],
+ column2: [
+ // { // TODO implement the rest
+ // xtype: 'filebutton',
+ // text: gettext('Load local manifest ...'),
+ // allowBlank: true,
+ // hidden: Proxmox.UserName !== 'root at pam',
+ // disabled: Proxmox.UserName !== 'root at pam',
+ // listeners: {
+ // change: (button,event,) => {
+ // var reader = new FileReader();
+ // let wizard = button.up('window');
+ // reader.onload = (e) => {
+ // let uploaded_ovf = e.target.result;
+ // // TODO set fields here
+ // // TODO When to upload disks to server?
+ // };
+ // reader.readAsText(event.target.files[0]);
+ // button.disable(); // TODO implement complete reload
+ // wizard.down('#successTextfield').show();
+ // }
+ // }
+ // },
+ {
+ xtype: 'label',
+ itemId: 'successTextfield',
+ hidden: true,
+ html: gettext('Manifest successfully uploaded'),
+ margin: '0 0 0 10',
+ },
+ {
+ xtype: 'textfield',
+ itemId: 'server_ovf_manifest',
+ name: 'ovf_textfield',
+ value: '/mnt/pve/cifs/importing/ovf_from_hyperv/pve/pve.ovf',
+ emptyText: '/mnt/nfs/exported.ovf',
+ fieldLabel: 'Absolute path to .ovf manifest on your PVE host',
+ },
+ {
+ xtype: 'proxmoxButton',
+ text: gettext('Load remote manifest'),
+ handler: function() {
+ let panel = this.up('panel');
+ let nodename = panel.down('pveNodeSelector').getValue();
+ // independent of onGetValues(), so that value of
+ // ovf_textfield can be removed for submit
+ let ovf_textfield_value = panel.down('#server_ovf_manifest').getValue();
+ let wizard = this.up('window');
+ Proxmox.Utils.API2Request({
+ url: '/nodes/' + nodename + '/readovf',
+ method: 'GET',
+ params: {
+ manifest: ovf_textfield_value,
+ },
+ success: function(response){
+ let ovfdata = response.result.data;
+ wizard.down('#vmNameTextfield').setValue(ovfdata.name);
+ wizard.down('#cpupanel').getViewModel().set('coreCount', ovfdata.cores);
+ wizard.down('#memorypanel').down('pveMemoryField').setValue(ovfdata.memory);
+ delete ovfdata.name;
+ delete ovfdata.cores;
+ delete ovfdata.memory;
+ delete ovfdata.digest;
+ let devices = Object.keys(ovfdata); // e.g. ide0, sata2
+ let multihd = wizard.down('pveQemuMultiHDInputPanel');
+ if (devices.length > 0) {
+ multihd.removeAllDisks();
+ }
+ for (var device of devices) {
+ let path = ovfdata[device].split('=')[1];
+ multihd.addDiskFunction(device, path);
+ }
+ },
+ failure: function(response, opts) {
+ console.warn("Failure of load manifest button");
+ console.warn(response);
+ },
+ });
+
+ },
+ },
+ ],
+ onGetValues: function(values) {
+ delete values.server_ovf_manifest;
+ delete values.ovf_textfield;
+ return values;
+ }
+ },
+ {
+ xtype: 'inputpanel',
+ title: gettext('General'),
+ onlineHelp: 'qm_general_settings',
+ column1: [
+ {
+ xtype: 'textfield',
+ name: 'name',
+ itemId: 'vmNameTextfield',
+ vtype: 'DnsName',
+ value: '',
+ fieldLabel: gettext('Name'),
+ allowBlank: true,
+ }
+ ],
+ column2: [
+ {
+ xtype: 'pvePoolSelector',
+ fieldLabel: gettext('Resource Pool'),
+ name: 'pool',
+ value: '',
+ allowBlank: true
+ }
+ ],
+ advancedColumn1: [
+ {
+ xtype: 'proxmoxcheckbox',
+ name: 'onboot',
+ uncheckedValue: 0,
+ defaultValue: 0,
+ deleteDefaultValue: true,
+ fieldLabel: gettext('Start at boot')
+ }
+ ],
+ advancedColumn2: [
+ {
+ xtype: 'textfield',
+ name: 'order',
+ defaultValue: '',
+ emptyText: 'any',
+ labelWidth: 120,
+ fieldLabel: gettext('Start/Shutdown order')
+ },
+ {
+ xtype: 'textfield',
+ name: 'up',
+ defaultValue: '',
+ emptyText: 'default',
+ labelWidth: 120,
+ fieldLabel: gettext('Startup delay')
+ },
+ {
+ xtype: 'textfield',
+ name: 'down',
+ defaultValue: '',
+ emptyText: 'default',
+ labelWidth: 120,
+ fieldLabel: gettext('Shutdown timeout')
+ }
+ ],
+ onGetValues: function(values) {
+
+ ['name', 'pool', 'onboot', 'agent'].forEach(function(field) {
+ if (!values[field]) {
+ delete values[field];
+ }
+ });
+
+ var res = PVE.Parser.printStartup({
+ order: values.order,
+ up: values.up,
+ down: values.down
+ });
+
+ if (res) {
+ values.startup = res;
+ }
+
+ delete values.order;
+ delete values.up;
+ delete values.down;
+
+ return values;
+ }
+ },
+ {
+ xtype: 'pveQemuSystemPanel',
+ title: gettext('System'),
+ isCreate: true,
+ insideWizard: true
+ },
+ {
+ xtype: 'pveQemuMultiHDInputPanel',
+ title: 'Hard Disk',
+ },
+ {
+ itemId: 'cpupanel',
+ xtype: 'pveQemuProcessorPanel',
+ insideWizard: true,
+ title: gettext('CPU')
+ },
+ {
+ itemId: 'memorypanel',
+ xtype: 'pveQemuMemoryPanel',
+ insideWizard: true,
+ title: gettext('Memory')
+ },
+ {
+ xtype: 'pveQemuNetworkInputPanel',
+ bind: {
+ nodename: '{nodename}'
+ },
+ title: gettext('Network'),
+ insideWizard: true
+ },
+ {
+ title: gettext('Confirm'),
+ layout: 'fit',
+ items: [
+ {
+ xtype: 'grid',
+ store: {
+ model: 'KeyValue',
+ sorters: [{
+ property : 'key',
+ direction: 'ASC'
+ }]
+ },
+ columns: [
+ {header: 'Key', width: 150, dataIndex: 'key'},
+ {header: 'Value', flex: 1, dataIndex: 'value'}
+ ]
+ }
+ ],
+ dockedItems: [
+ {
+ xtype: 'proxmoxcheckbox',
+ name: 'start',
+ dock: 'bottom',
+ margin: '5 0 0 0',
+ boxLabel: gettext('Start after created')
+ }
+ ],
+ listeners: {
+ show: function(panel) {
+ var kv = this.up('window').getValues();
+ var data = [];
+ Ext.Object.each(kv, function(key, value) {
+ if (key === 'delete') { // ignore
+ return;
+ }
+ data.push({ key: key, value: value });
+ });
+
+ var summarystore = panel.down('grid').getStore();
+ summarystore.suspendEvents();
+ summarystore.removeAll();
+ summarystore.add(data);
+ summarystore.sort();
+ summarystore.resumeEvents();
+ summarystore.fireEvent('refresh');
+
+ }
+ },
+ onSubmit: function() {
+ var wizard = this.up('window');
+ var kv = wizard.getValues();
+ delete kv['delete'];
+
+ var nodename = kv.nodename;
+ delete kv.nodename;
+
+ Proxmox.Utils.API2Request({
+ url: '/nodes/' + nodename + '/qemu',
+ waitMsgTarget: wizard,
+ method: 'POST',
+ params: kv,
+ success: function(response){
+ wizard.close();
+ },
+ failure: function(response, opts) {
+ Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+ }
+ });
+ }
+ }
+ ],
+ initComponent: function () {
+ var me = this;
+ me.callParent();
+
+ let addDiskButton = {
+ text: gettext('Add disk'),
+ disabled: true,
+ itemId: 'addDisk',
+ minWidth: 60,
+ handler: me.addDiskFunction,
+ isValid: function () {
+ let isValid = true;
+ if (!me.isImport) {
+ isValid = false;
+ }
+ let type = me.down('#wizcontent').getActiveTab().xtype;
+ if (type !== 'pveQemuHDInputPanel') {
+ isValid=false;
+ }
+ return isValid;
+ },
+ };
+
+ let removeDiskButton = {
+ text: gettext('Remove disk'), // TODO implement
+ disabled: false,
+ itemId: 'removeDisk',
+ minWidth: 60,
+ handler: function() {
+ console.assert(me.xtype === 'pveQemuImportWizard');
+ let multihd = me.down('pveQemuMultiHDInputPanel');
+ multihd.removeCurrentDisk();
+ },
+ };
+ me.down('toolbar').insert(4, addDiskButton);
+ me.down('toolbar').insert(5, removeDiskButton);
+ },
+});
+
+
+
+
diff --git a/www/manager6/qemu/MultiHDEdit.js b/www/manager6/qemu/MultiHDEdit.js
new file mode 100644
index 00000000..632199ba
--- /dev/null
+++ b/www/manager6/qemu/MultiHDEdit.js
@@ -0,0 +1,267 @@
+Ext.define('PVE.qemu.MultiHDInputPanel', {
+ extend: 'Proxmox.panel.InputPanel',
+ alias: 'widget.pveQemuMultiHDInputPanel',
+
+ insideWizard: false,
+
+ hiddenDisks: [],
+
+ leftColumnRatio: 0.2,
+
+ column1: [
+ {
+ // Adding to the HDInputPanelContainer below automatically adds
+ // items to this store
+ xtype: 'gridpanel',
+ scrollable: true,
+ store: {
+ xtype: 'store',
+ storeId: 'importwizard_diskstorage',
+ // Use the panel as id
+ // Panels have are objects and therefore unique
+ // E.g. while adding new panels 'device' is ambiguous
+ fields: ['device', 'panel'],
+ removeByPanel: function(panel) {
+ console.assert(panel.xtype === 'pveQemuHDInputPanel');
+ let recordIndex = this.findBy(record =>
+ record.data.panel === panel,
+ );
+ this.removeAt(recordIndex);
+ return recordIndex;
+ },
+ },
+ columns: [
+ {
+ text: gettext('Target device'),
+ dataIndex: 'device',
+ flex: 1,
+ resizable: false,
+ },
+ ],
+ listeners: {
+ select: function(_, record) {
+ this.up('pveQemuMultiHDInputPanel')
+ .down('#HDInputPanelContainer')
+ .setActiveItem(record.data.panel);
+ },
+ },
+ anchor: '100% 100%', // Required because resize does not happen yet
+ },
+ ],
+ column2: [
+ {
+ itemId: 'HDInputPanelContainer',
+ xtype: 'container',
+ layout: 'card',
+ items: [],
+ listeners: {
+ beforeRender: function() {
+ // Initial disk if none have been added by manifest yet
+ if (this.items.items.length === 0) {
+ this.addDiskFunction();
+ }
+ },
+ add: function(container, newPanel, index) {
+ let store = Ext.getStore('importwizard_diskstorage');
+ store.add({ device: newPanel.getDevice(), panel: newPanel });
+ container.setActiveItem(newPanel);
+ },
+ remove: function(HDInputPanelContainer, HDInputPanel, eOpts) {
+ let store = Ext.getStore('importwizard_diskstorage');
+ let previousCount = store.data.getCount();
+ let indexOfRemoved = store.removeByPanel(HDInputPanel);
+ console.assert(store.data.getCount() === previousCount - 1,
+ 'Nothing has been removed from the store.' +
+ `It still has ${store.data.getCount()} items.`,
+ );
+ if (HDInputPanelContainer.items.getCount() > 0) {
+ console.assert(indexOfRemoved >= 1);
+ HDInputPanelContainer.setActiveItem(indexOfRemoved - 1);
+ }
+ },
+ },
+ defaultItem: {
+ xtype: 'pveQemuHDInputPanel',
+ bind: {
+ nodename: '{nodename}',
+ viewModel: '{viewModel}',
+ },
+ isCreate: true,
+ isImport: true,
+ showSourcePathTextfield: true,
+ returnSingleKey: true,
+ insideWizard: true,
+ listeners: {
+ // newHDInputPanel ... the defaultItem that has just been
+ // cloned and added into HDInputPnaleContainer parameter
+ // HDInputPanelContainer ... the container from column2
+ // where all the new panels go into
+ added: function(newHDInputPanel, HDInputPanelContainer, pos) {
+ // The listeners cannot be added earlier, because its fields don't exist earlier
+ Ext.Array.each(this.down('pveControllerSelector')
+ .query('field'), function(field) {
+ field.on('change', function() {
+ // Note that one setValues in a controller
+ // selector makes one setValue in each of
+ // the two fields, so this listener fires
+ // two times in a row so to say e.g.
+ // changing controller selector from ide0 to
+ // sata1 makes ide0->sata0 and then
+ // sata0->sata1
+ let store = Ext.getStore('importwizard_diskstorage');
+ let controllerSelector = field.up('pveQemuHDInputPanel')
+ .down('pveControllerSelector');
+ /*
+ * controller+device (ide0) might be
+ * ambiguous during creation => find by
+ * panel object instead
+ *
+ * There is no function that takes a
+ * function and returns the model directly
+ * => index & getAt
+ */
+ let recordIndex = store.findBy(record =>
+ record.data.panel === field.up('pveQemuHDInputPanel'),
+ );
+ console.assert(
+ newHDInputPanel === field.up('pveQemuHDInputPanel'),
+ 'Those panels should be the same',
+ );
+ console.assert(recordIndex !== -1);
+ let newControllerAndId = controllerSelector.getValuesAsString();
+ store.getAt(recordIndex).set('device', newControllerAndId);
+ });
+ },
+ );
+ },
+ beforerender: function() {
+ let wizard = this.up('pveQemuImportWizard');
+ Ext.Array.each(this.query('field'), function(field) {
+ field.on('change', wizard.validcheck);
+ field.on('validitychange', wizard.validcheck);
+ });
+ },
+ },
+ validator: function() {
+ console.debug('hdedit validator');
+ var valid = true;
+ var fields = this.query('field, fieldcontainer');
+ if (this.isXType('fieldcontainer')) {
+ console.assert(false);
+ fields.unshift(this);
+ }
+ Ext.Array.each(fields, function(field) {
+ // Note: not all fielcontainer have isValid()
+ if (Ext.isFunction(field.isValid) && !field.isValid()) {
+ valid = false;
+ console.debug('field is invalid');
+ console.debug(field);
+ }
+ });
+ return valid;
+ }
+ },
+
+ // device ... device that the new disk should be assigned to, e.g.
+ // ide0, sata2
+ // path ... if this is set to x then the disk will
+ // backed/imported from the path x, that is, the textfield will
+ // contain the value x
+ addDiskFunction(device, path) {
+ // creating directly removes binding => no storage found?
+ let item = Ext.clone(this.defaultItem);
+ let added = this.add(item);
+ // At this point the 'added' listener has fired and the fields
+ // in the variable added have the change listeners that update
+ // the store Therefore we can now set values only on the field
+ // and they will be updated in the store
+ if (path) {
+ added.down('#sourceRadioPath').setValue(true);
+ added.down('#sourcePathTextfield').setValue(path);
+ } else {
+ added.down('#sourceRadioStorage').setValue(true);
+ added.down('#sourceStorageSelector').setHidden(false);
+ added.down('#sourceFileSelector').setHidden(false);
+ added.down('#sourceFileSelector').enable();
+ added.down('#sourceStorageSelector').enable();
+ }
+ if (device) {
+ // This happens after the 'add' and 'added' listeners of the
+ // item/defaultItem clone/pveQemuHDInputPanel/added have fired
+ added.down('pveControllerSelector').setValue(device);
+ }
+ },
+ removeCurrentDisk: function() {
+ let activePanel = this.getLayout().activeItem; // panel = disk
+ if (activePanel) {
+ this.remove(activePanel);
+ } else {
+ // TODO Add tooltip to Remove disk button
+ }
+ },
+ },
+ ],
+
+ addDiskFunction: function(device, path) {
+ this.down('#HDInputPanelContainer').addDiskFunction(device, path);
+ },
+ removeCurrentDisk: function() {
+ this.down('#HDInputPanelContainer').removeCurrentDisk();
+ },
+ removeAllDisks: function() {
+ let container = this.down('#HDInputPanelContainer');
+ while (container.items.items.length > 0) {
+ container.removeCurrentDisk();
+ }
+ },
+
+ beforeRender: function() {
+ // any other panel because this has no height yet
+ let panelHeight = this.up('tabpanel').items.items[0].getHeight();
+ let leftColumnContainer = this.items.items[0];
+ let rightColumnContainer = this.items.items[1];
+ leftColumnContainer.setHeight(panelHeight);
+
+ leftColumnContainer.columnWidth = this.leftColumnRatio;
+ rightColumnContainer.columnWidth = 1 - this.leftColumnRatio;
+ },
+
+ // Call with defined parameter or without (static function so to say)
+ hasDuplicateDevices: function(values) {
+ if (!values) {
+ values = this.up('form').getValues();
+ }
+ console.assert(values);
+ for (let i = 0; i < values.controller.length - 1; i++) {
+ for (let j = i+1; j < values.controller.length; j++) {
+ if (values.controller[i] === values.controller[j]) {
+ if (values.deviceid[i] === values.deviceid[j]) {
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+ },
+
+ onGetValues: function(values) {
+ // Returning anything here would give wrong data in the form at the end
+ // of the wizrad Each HDInputPanel in this MultiHD panel already has a
+ // sufficient onGetValues() function for the form at the end of the
+ // wizard
+ if (this.hasDuplicateDevices(values)) {
+ Ext.Msg.alert(gettext('Error'), 'Equal target devices are forbidden. Make all unique!');
+ }
+ },
+
+ validator: function() {
+ let inputpanels = this.down('#HDInputPanelContainer').items.getRange();
+ if (inputpanels.some(panel => !panel.validator())) {
+ return false;
+ }
+ if (this.hasDuplicateDevices()) {
+ return false;
+ }
+ return true;
+ },
+});
diff --git a/www/manager6/window/Wizard.js b/www/manager6/window/Wizard.js
index 87e4bf0a..f16ba107 100644
--- a/www/manager6/window/Wizard.js
+++ b/www/manager6/window/Wizard.js
@@ -35,6 +35,75 @@ Ext.define('PVE.window.Wizard', {
return values;
},
+ check_card: function(card) {
+ var valid = true;
+ var fields = card.query('field, fieldcontainer');
+ if (card.isXType('fieldcontainer')) {
+ fields.unshift(card);
+ }
+ Ext.Array.each(fields, function(field) {
+ // Note: not all fielcontainer have isValid()
+ if (Ext.isFunction(field.isValid) && !field.isValid()) {
+ valid = false;
+ }
+ });
+
+ if (Ext.isFunction(card.validator)) {
+ return card.validator();
+ }
+
+ return valid;
+ },
+
+ disable_at: function(card) {
+ let window = this;
+ var topbar = window.down('#wizcontent');
+ var idx = topbar.items.indexOf(card);
+ for(;idx < topbar.items.getCount();idx++) {
+ var nc = topbar.items.getAt(idx);
+ if (nc) {
+ nc.disable();
+ }
+ }
+ },
+
+ validcheck: function() {
+ console.debug('Validcheck');
+ let window = this.up('window');
+ var topbar = window.down('#wizcontent');
+
+ // check tabs from current to the last enabled for validity
+ // since we might have changed a validity on a later one
+ var i;
+ for (i = topbar.curidx; i <= topbar.maxidx && i < topbar.items.getCount(); i++) {
+ var tab = topbar.items.getAt(i);
+ var valid = window.check_card(tab);
+
+ // only set the buttons on the current panel
+ if (i === topbar.curidx) {
+ if (window.isImport) {
+ console.debug('valid in window?');
+ console.debug(valid);
+ console.debug('because tab is');
+ console.debug(tab);
+ window.down('#addDisk').setDisabled(!valid);
+ }
+ window.down('#next').setDisabled(!valid);
+ window.down('#submit').setDisabled(!valid);
+ }
+
+ // if a panel is invalid, then disable it and all following,
+ // else enable it and go to the next
+ var ntab = topbar.items.getAt(i + 1);
+ if (!valid) {
+ window.disable_at(ntab);
+ return;
+ } else if (ntab && !tab.onSubmit) {
+ ntab.enable();
+ }
+ }
+ },
+
initComponent: function() {
var me = this;
@@ -53,40 +122,6 @@ Ext.define('PVE.window.Wizard', {
});
tabs[0].disabled = false;
- var maxidx = 0;
- var curidx = 0;
-
- var check_card = function(card) {
- var valid = true;
- var fields = card.query('field, fieldcontainer');
- if (card.isXType('fieldcontainer')) {
- fields.unshift(card);
- }
- Ext.Array.each(fields, function(field) {
- // Note: not all fielcontainer have isValid()
- if (Ext.isFunction(field.isValid) && !field.isValid()) {
- valid = false;
- }
- });
-
- if (Ext.isFunction(card.validator)) {
- return card.validator();
- }
-
- return valid;
- };
-
- var disable_at = function(card) {
- var tp = me.down('#wizcontent');
- var idx = tp.items.indexOf(card);
- for(;idx < tp.items.getCount();idx++) {
- var nc = tp.items.getAt(idx);
- if (nc) {
- nc.disable();
- }
- }
- };
-
var tabchange = function(tp, newcard, oldcard) {
if (newcard.onSubmit) {
me.down('#next').setVisible(false);
@@ -95,16 +130,23 @@ Ext.define('PVE.window.Wizard', {
me.down('#next').setVisible(true);
me.down('#submit').setVisible(false);
}
- var valid = check_card(newcard);
+ var valid = me.check_card(newcard);
+ let addDiskButton = me.down('#addDisk'); // TODO undefined in first invocation?
+ if (me.isImport && addDiskButton) {
+ addDiskButton.setDisabled(!valid); // TODO check me
+ addDiskButton.setHidden(!addDiskButton.isValid());
+ addDiskButton.setDisabled(false);
+ addDiskButton.setHidden(false);
+ }
me.down('#next').setDisabled(!valid);
me.down('#submit').setDisabled(!valid);
me.down('#back').setDisabled(tp.items.indexOf(newcard) == 0);
var idx = tp.items.indexOf(newcard);
- if (idx > maxidx) {
- maxidx = idx;
+ if (idx > tp.maxidx) {
+ tp.maxidx = idx;
}
- curidx = idx;
+ tp.curidx = idx;
var next = idx + 1;
var ntab = tp.items.getAt(next);
@@ -135,6 +177,8 @@ Ext.define('PVE.window.Wizard', {
items: [{
itemId: 'wizcontent',
xtype: 'tabpanel',
+ maxidx: 0,
+ curidx: 0,
activeItem: 0,
bodyPadding: 10,
listeners: {
@@ -201,7 +245,7 @@ Ext.define('PVE.window.Wizard', {
var tp = me.down('#wizcontent');
var atab = tp.getActiveTab();
- if (!check_card(atab)) {
+ if (!me.check_card(atab)) {
return;
}
@@ -234,35 +278,8 @@ Ext.define('PVE.window.Wizard', {
});
Ext.Array.each(me.query('field'), function(field) {
- var validcheck = function() {
- var tp = me.down('#wizcontent');
-
- // check tabs from current to the last enabled for validity
- // since we might have changed a validity on a later one
- var i;
- for (i = curidx; i <= maxidx && i < tp.items.getCount(); i++) {
- var tab = tp.items.getAt(i);
- var valid = check_card(tab);
-
- // only set the buttons on the current panel
- if (i === curidx) {
- me.down('#next').setDisabled(!valid);
- me.down('#submit').setDisabled(!valid);
- }
-
- // if a panel is invalid, then disable it and all following,
- // else enable it and go to the next
- var ntab = tp.items.getAt(i + 1);
- if (!valid) {
- disable_at(ntab);
- return;
- } else if (ntab && !tab.onSubmit) {
- ntab.enable();
- }
- }
- };
- field.on('change', validcheck);
- field.on('validitychange', validcheck);
+ field.on('change', me.validcheck);
+ field.on('validitychange', me.validcheck);
});
}
});
--
2.20.1
More information about the pve-devel
mailing list