From: "Dominic Jäger" <d.jaeger@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [pve-devel] [PATCH v9 manager] Add GUI to import disk & VM
Date: Thu, 10 Jun 2021 12:20:24 +0200 [thread overview]
Message-ID: <20210610102024.59173-2-d.jaeger@proxmox.com> (raw)
In-Reply-To: <20210610102024.59173-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>
---
v9: Use Tabpanel instead of Advanced Checkbox for disk options
PVE/API2/Nodes.pm | 7 +
www/manager6/Makefile | 4 +
www/manager6/form/ControllerSelector.js | 15 +
www/manager6/form/DiskStorageSelector.js | 27 +-
www/manager6/form/FileSelector.js | 6 +
www/manager6/qemu/CDEdit.js | 3 -
www/manager6/qemu/CreateWizard.js | 102 ++++++-
www/manager6/qemu/HardwareView.js | 4 +-
www/manager6/qemu/OSDefaults.js | 13 +
www/manager6/qemu/OSTypeEdit.js | 12 +-
www/manager6/qemu/disk/DiskBasic.js | 365 +++++++++++++++++++++++
www/manager6/qemu/disk/DiskCollection.js | 275 +++++++++++++++++
www/manager6/qemu/disk/DiskOptions.js | 243 +++++++++++++++
www/manager6/qemu/disk/HardDisk.js | 137 +++++++++
www/manager6/window/Wizard.js | 2 +
15 files changed, 1203 insertions(+), 12 deletions(-)
create mode 100644 www/manager6/qemu/disk/DiskBasic.js
create mode 100644 www/manager6/qemu/disk/DiskCollection.js
create mode 100644 www/manager6/qemu/disk/DiskOptions.js
create mode 100644 www/manager6/qemu/disk/HardDisk.js
diff --git a/PVE/API2/Nodes.pm b/PVE/API2/Nodes.pm
index f4d3382c..94faeab1 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;
@@ -70,6 +71,11 @@ __PACKAGE__->register_method ({
path => 'qemu',
});
+__PACKAGE__->register_method ({
+ subclass => "PVE::API2::Qemu::OVF",
+ path => 'readovf',
+});
+
__PACKAGE__->register_method ({
subclass => "PVE::API2::LXC",
path => 'lxc',
@@ -2152,6 +2158,7 @@ __PACKAGE__->register_method ({
return undef;
}});
+
# bash completion helper
sub complete_templet_repo {
diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index 6776d4ce..85b12e1c 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -199,6 +199,10 @@ JSSRC= \
qemu/CreateWizard.js \
qemu/DisplayEdit.js \
qemu/HDEdit.js \
+ qemu/disk/DiskCollection.js \
+ qemu/disk/HardDisk.js \
+ qemu/disk/DiskBasic.js \
+ qemu/disk/DiskOptions.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 daca2432..eefa36ac 100644
--- a/www/manager6/form/ControllerSelector.js
+++ b/www/manager6/form/ControllerSelector.js
@@ -72,6 +72,21 @@ Ext.define('PVE.form.ControllerSelector', {
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/form/DiskStorageSelector.js b/www/manager6/form/DiskStorageSelector.js
index cf73f2e2..15ac236e 100644
--- a/www/manager6/form/DiskStorageSelector.js
+++ b/www/manager6/form/DiskStorageSelector.js
@@ -25,8 +25,9 @@ Ext.define('PVE.form.DiskStorageSelector', {
// hideSelection is not true
hideSelection: undefined,
- // hides the size field (e.g, for the efi disk dialog)
+ // hides and disables the size field (e.g, for the efi disk dialog)
hideSize: false,
+ disableSize: false, // only disable the size field
// sets the initial size value
// string because else we get a type confusion
@@ -72,7 +73,7 @@ Ext.define('PVE.form.DiskStorageSelector', {
hdfilesel.setStorage(value);
}
- hdsizesel.setDisabled(select || me.hideSize);
+ hdsizesel.setDisabled(select || me.hideSize || me.disableSize);
hdsizesel.setVisible(!select && !me.hideSize);
},
@@ -85,6 +86,26 @@ Ext.define('PVE.form.DiskStorageSelector', {
hdfilesel.setNodename(nodename);
},
+ setSize: function(size) {
+ const me = this;
+ const hdsizesel = me.getComponent('disksize');
+ hdsizesel.setValue(size);
+ },
+
+ getSize: function() {
+ return this.getComponent('disksize').getValue();
+ },
+
+ fixAndGetSize: function() {
+ const me = this;
+ const field = me.getComponent('disksize');
+ if (!field.isValid()) {
+ field.clearInvalid();
+ field.setValue(me.defaultSize);
+ }
+ return field.getValue();
+ },
+
setDisabled: function(value) {
var me = this;
var hdstorage = me.getComponent('hdstorage');
@@ -140,7 +161,7 @@ Ext.define('PVE.form.DiskStorageSelector', {
name: 'disksize',
fieldLabel: gettext('Disk size') + ' (GiB)',
hidden: me.hideSize,
- disabled: me.hideSize,
+ disabled: me.hideSize || me.disableSize,
minValue: 0.001,
maxValue: 128*1024,
decimalPrecision: 3,
diff --git a/www/manager6/form/FileSelector.js b/www/manager6/form/FileSelector.js
index ef2bedf9..d426e7f4 100644
--- a/www/manager6/form/FileSelector.js
+++ b/www/manager6/form/FileSelector.js
@@ -51,6 +51,12 @@ Ext.define('PVE.form.FileSelector', {
this.setStorage(undefined, nodename);
},
+ getCurrentSize: function() {
+ const me = this;
+ const id = me.getValue();
+ return id ? me.store.getById(id).get('size') : 0;
+ },
+
store: {
model: 'pve-storage-content',
},
diff --git a/www/manager6/qemu/CDEdit.js b/www/manager6/qemu/CDEdit.js
index 72c01037..27092d32 100644
--- a/www/manager6/qemu/CDEdit.js
+++ b/www/manager6/qemu/CDEdit.js
@@ -84,9 +84,6 @@ Ext.define('PVE.qemu.CDInputPanel', {
checked: true,
listeners: {
change: function(f, value) {
- if (!me.rendered) {
- return;
- }
me.down('field[name=cdstorage]').setDisabled(!value);
var cdImageField = me.down('field[name=cdimage]');
cdImageField.setDisabled(!value);
diff --git a/www/manager6/qemu/CreateWizard.js b/www/manager6/qemu/CreateWizard.js
index d4535c9d..d066bd47 100644
--- a/www/manager6/qemu/CreateWizard.js
+++ b/www/manager6/qemu/CreateWizard.js
@@ -16,12 +16,25 @@ Ext.define('PVE.qemu.CreateWizard', {
nodename: undefined,
},
+ setImport: function(isImport = true) {
+ const me = this;
+ if (me.xtype !== 'pveQemuCreateWizard') {
+ throw "Unexpected xtype";
+ }
+ me.down('pveQemuOSTypePanel').ignoreDisks = isImport; // prefer values from OVF
+ // radiofield onChange behavior does not deactivate remaining radiofields
+ // when the panel is not yet rendered in ExtJS>=7.0
+ me.down('radiofield[inputValue=iso]').setValue(false);
+ me.down('radiofield[inputValue=none]').setValue(true);
+ },
+
subject: gettext('Virtual Machine'),
items: [
{
xtype: 'inputpanel',
title: gettext('General'),
+ itemId: 'generalPanel',
onlineHelp: 'qm_general_settings',
column1: [
{
@@ -63,6 +76,75 @@ Ext.define('PVE.qemu.CreateWizard', {
value: '',
allowBlank: true,
},
+ {
+ 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: '.ovf manifest',
+ autoEl: {
+ tag: 'div',
+ 'data-qtip': gettext('Absolute path to an .ovf manifest on the PVE host'),
+ },
+ value: '/mnt/pve/nasi_private/importing/from_hyperv/pve_ovf/pve/pve.ovf', // TODO DOMINIC Remove after testing
+ 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 .ovf'),
+ disabled: true,
+ handler: function() {
+ const inputpanel = this.up('#generalPanel');
+ 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('textfield[name=name]').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('pveQemuDiskCollection');
+ hdcollection.removeAllDisks(); // does nothing if already empty
+ devices.forEach(device => hdcollection.addDisk(device, ovfdata[device]));
+
+ wizard.setImport();
+ wizard.validcheck();
+ },
+ failure: function(response) {
+ Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+ },
+ });
+ },
+ },
],
advancedColumn1: [
{
@@ -120,6 +202,7 @@ Ext.define('PVE.qemu.CreateWizard', {
delete values.order;
delete values.up;
delete values.down;
+ delete values.ovfTextfield;
return values;
},
@@ -154,7 +237,7 @@ Ext.define('PVE.qemu.CreateWizard', {
insideWizard: true,
},
{
- xtype: 'pveQemuHDInputPanel',
+ xtype: 'pveQemuDiskCollection',
bind: {
nodename: '{nodename}',
},
@@ -163,11 +246,13 @@ Ext.define('PVE.qemu.CreateWizard', {
insideWizard: true,
},
{
+ itemId: 'cpupanel',
xtype: 'pveQemuProcessorPanel',
insideWizard: true,
title: gettext('CPU'),
},
{
+ itemId: 'memorypanel',
xtype: 'pveQemuMemoryPanel',
insideWizard: true,
title: gettext('Memory'),
@@ -235,6 +320,7 @@ Ext.define('PVE.qemu.CreateWizard', {
var nodename = kv.nodename;
delete kv.nodename;
+ delete kv.delete;
Proxmox.Utils.API2Request({
url: '/nodes/' + nodename + '/qemu',
@@ -251,6 +337,20 @@ Ext.define('PVE.qemu.CreateWizard', {
},
},
],
+
+ getValues: function() {
+ let values = this.callParent();
+ for (const [key, value] of Object.entries(values)) {
+ const re = /ide\d+|sata\d+|virtio\d+|scsi\d+|import_sources/;
+ if (key.match(re) && Array.isArray(value)) {
+ // Collected from different panels => array
+ // But API & some GUI functions expect not array
+ const sep = key === 'import_sources' ? '\0' : ',';
+ values[key] = value.join(sep);
+ }
+ }
+ return values;
+ },
});
diff --git a/www/manager6/qemu/HardwareView.js b/www/manager6/qemu/HardwareView.js
index 200e3c28..1618fd17 100644
--- a/www/manager6/qemu/HardwareView.js
+++ b/www/manager6/qemu/HardwareView.js
@@ -630,9 +630,9 @@ Ext.define('PVE.qemu.HardwareView', {
iconCls: 'fa fa-fw fa-hdd-o black',
disabled: !caps.vms['VM.Config.Disk'],
handler: function() {
- let win = Ext.create('PVE.qemu.HDEdit', {
+ let win = Ext.create('PVE.qemu.HardDiskWindow', {
url: '/api2/extjs/' + baseurl,
- pveSelNode: me.pveSelNode,
+ nodename: me.pveSelNode.data.node,
});
win.on('destroy', me.reload, me);
win.show();
diff --git a/www/manager6/qemu/OSDefaults.js b/www/manager6/qemu/OSDefaults.js
index eed9eebc..9faf3ad6 100644
--- a/www/manager6/qemu/OSDefaults.js
+++ b/www/manager6/qemu/OSDefaults.js
@@ -72,6 +72,19 @@ Ext.define('PVE.qemu.OSDefaults', {
pveOS: 'wxp',
parent: 'w2k',
});
+ addOS({
+ pveOS: 'win10',
+ parent: 'generic',
+ busPriority: {
+ sata: 4, // for compatibility
+ ide: 3,
+ virtio: 2,
+ scsi: 1,
+ },
+ networkCard: 'e1000',
+ scsihw: '',
+ });
+
me.getDefaults = function(ostype) {
if (PVE.qemu.OSDefaults[ostype]) {
diff --git a/www/manager6/qemu/OSTypeEdit.js b/www/manager6/qemu/OSTypeEdit.js
index 438d7c6b..641d9394 100644
--- a/www/manager6/qemu/OSTypeEdit.js
+++ b/www/manager6/qemu/OSTypeEdit.js
@@ -3,6 +3,7 @@ Ext.define('PVE.qemu.OSTypeInputPanel', {
alias: 'widget.pveQemuOSTypePanel',
onlineHelp: 'qm_os_settings',
insideWizard: false,
+ ignoreDisks: false,
controller: {
xclass: 'Ext.app.ViewController',
@@ -20,13 +21,18 @@ Ext.define('PVE.qemu.OSTypeInputPanel', {
},
onOSTypeChange: function(field) {
var me = this, ostype = field.getValue();
- if (!me.getView().insideWizard) {
+ const view = me.getView();
+ if (!view.insideWizard) {
return;
}
var targetValues = PVE.qemu.OSDefaults.getDefaults(ostype);
-
- me.setWidget('pveBusSelector', targetValues.busType);
+ if (!view.ignoreDisks) {
+ const ids = Ext.ComponentQuery.query('pveBusSelector')
+ .reduce((acc, cur) => acc.concat(cur.id), []);
+ ids.forEach(i => me.setWidget(`#${i}`, targetValues.busType));
+ }
me.setWidget('pveNetworkCardSelector', targetValues.networkCard);
+ me.setWidget('pveQemuBiosSelector', targetValues.bios);
var scsihw = targetValues.scsihw || '__default__';
this.getViewModel().set('current.scsihw', scsihw);
},
diff --git a/www/manager6/qemu/disk/DiskBasic.js b/www/manager6/qemu/disk/DiskBasic.js
new file mode 100644
index 00000000..c89bbd37
--- /dev/null
+++ b/www/manager6/qemu/disk/DiskBasic.js
@@ -0,0 +1,365 @@
+/* 'change' property is assigned a string and then a function */
+Ext.define('PVE.qemu.DiskBasic', {
+ extend: 'Proxmox.panel.InputPanel',
+ alias: 'widget.pveQemuDiskBasic',
+ onlineHelp: 'qm_hard_disk',
+
+ insideWizard: false,
+
+ unused: false,
+
+ padding: '10 10 10 10',
+
+ vmconfig: {}, // used to select usused disks
+
+ viewModel: {},
+
+ /**
+ * All radiofields in pveQemuDiskCollection have the same scope
+ * Make name of radiofields unique for each disk
+ */
+ getRadioName() {
+ return 'radio_' + this.id;
+ },
+
+ onGetValues: function(values) {
+ let me = this;
+
+ let params = {};
+ let confid = me.confid || values.controller + values.deviceid;
+
+ const isImport = values.sourceVolid || values.sourcePath;
+ if (me.unused) {
+ me.drive.file = me.vmconfig[values.unusedId];
+ confid = values.controller + values.deviceid;
+ } else if (me.isCreate) {
+ if (values.hdimage) {
+ me.drive.file = values.hdimage;
+ } else if (isImport) {
+ me.drive.file = `${values.hdstorage}:-1`;
+ } else {
+ me.drive.file = values.hdstorage + ":" + values.disksize;
+ }
+ me.drive.format = values.diskformat;
+ }
+
+ PVE.Utils.propertyStringSet(me.drive, values.discard, 'discard', 'on');
+ PVE.Utils.propertyStringSet(me.drive, values.cache, 'cache');
+
+ if (isImport) {
+ // exactly 1 of sourceVolid and sourcePath must be defined
+ params.import_sources = `${confid}=${isImport}`;
+ }
+
+ params[confid] = PVE.Parser.printQemuDrive(me.drive);
+
+ return params;
+ },
+
+ setVMConfig: function(vmconfig) {
+ let me = this;
+
+ me.vmconfig = vmconfig;
+
+ if (me.bussel) {
+ me.bussel.setVMConfig(vmconfig);
+ me.scsiController.setValue(vmconfig.scsihw);
+ }
+ if (me.unusedDisks) {
+ let disklist = [];
+ Ext.Object.each(vmconfig, function(key, value) {
+ if (key.match(/^unused\d+$/)) {
+ disklist.push([key, value]);
+ }
+ });
+ me.unusedDisks.store.loadData(disklist);
+ me.unusedDisks.setValue(me.confid);
+ }
+ },
+
+ setDrive: function(drive) {
+ let me = this;
+
+ me.drive = drive;
+
+ let values = {};
+ let match = drive.file.match(/^([^:]+):/);
+ if (match) {
+ values.hdstorage = match[1];
+ }
+
+ values.hdimage = drive.file;
+ values.backup = PVE.Parser.parseBoolean(drive.backup, 1);
+ values.noreplicate = !PVE.Parser.parseBoolean(drive.replicate, 1);
+ values.diskformat = drive.format || 'raw';
+ values.cache = drive.cache || '__default__';
+ values.discard = drive.discard === 'on';
+ values.ssd = PVE.Parser.parseBoolean(drive.ssd);
+ values.iothread = PVE.Parser.parseBoolean(drive.iothread);
+
+ values.mbps_rd = drive.mbps_rd;
+ values.mbps_wr = drive.mbps_wr;
+ values.iops_rd = drive.iops_rd;
+ values.iops_wr = drive.iops_wr;
+ values.mbps_rd_max = drive.mbps_rd_max;
+ values.mbps_wr_max = drive.mbps_wr_max;
+ values.iops_rd_max = drive.iops_rd_max;
+ values.iops_wr_max = drive.iops_wr_max;
+
+ me.setValues(values);
+ },
+
+ getDevice: function() {
+ return this.bussel.getValuesAsString();
+ },
+
+ setNodename: function(nodename) {
+ let me = this;
+ me.down('#hdstorage').setNodename(nodename);
+ me.down('#sourceStorageSelector').setNodename(nodename);
+ me.down('field[name=sourceVolid]').setNodename(nodename);
+ },
+
+ initComponent: function() {
+ let me = this;
+
+
+ me.drive = {};
+
+ me.column1 = [];
+ me.column2 = [];
+
+ if (!me.confid || me.unused) {
+ const controllerColumn = me.column2;
+ me.scsiController = Ext.create('Ext.form.field.Display', {
+ fieldLabel: gettext('SCSI Controller'),
+ reference: 'scsiController',
+ name: 'scsiController',
+ bind: me.insideWizard ? {
+ value: '{current.scsihw}',
+ } : undefined,
+ renderer: PVE.Utils.render_scsihw,
+ submitValue: false,
+ hidden: true,
+ });
+
+ me.bussel = Ext.create('PVE.form.ControllerSelector', {
+ itemId: 'bussel',
+ vmconfig: me.insideWizard ? { ide2: 'cdrom' } : {},
+ });
+
+ me.bussel.down('field[name=controller]').addListener('change', function(_, newValue) {
+ const allowIOthread = newValue.match(/^(virtio|scsi)/);
+ const iothreadField = me.next('pveQemuDiskOptions').down('field[name=iothread]');
+ iothreadField.setDisabled(!allowIOthread);
+ if (!allowIOthread) {
+ iothreadField.setValue(false);
+ }
+
+ const virtio = newValue.match(/^virtio/);
+ const ssdField = me.next('pveQemuDiskOptions').down('field[name=ssd]');
+ ssdField.setDisabled(virtio);
+ if (virtio) {
+ ssdField.setValue(false);
+ }
+
+ me.scsiController.setVisible(newValue.match(/^scsi/));
+ });
+
+ controllerColumn.push(me.bussel);
+ controllerColumn.push(me.scsiController);
+ }
+
+ if (me.unused) {
+ me.unusedDisks = Ext.create('Proxmox.form.KVComboBox', {
+ name: 'unusedId',
+ fieldLabel: gettext('Disk image'),
+ matchFieldWidth: false,
+ listConfig: {
+ width: 350,
+ },
+ data: [],
+ allowBlank: false,
+ });
+ me.column1.push(me.unusedDisks);
+ } else if (me.isCreate) {
+ let selector = {
+ xtype: 'pveDiskStorageSelector',
+ storageContent: 'images',
+ name: 'disk',
+ nodename: me.nodename,
+ autoSelect: me.insideWizard,
+ };
+ selector.storageLabel = gettext('Target storage');
+ me.column2.push(selector);
+ } else {
+ me.column1.push({
+ xtype: 'textfield',
+ disabled: true,
+ submitValue: false,
+ fieldLabel: gettext('Disk image'),
+ name: 'hdimage',
+ });
+ }
+
+ me.column2.push(
+ {
+ xtype: 'CacheTypeSelector',
+ name: 'cache',
+ value: '__default__',
+ fieldLabel: gettext('Cache'),
+ },
+ {
+ xtype: 'proxmoxcheckbox',
+ fieldLabel: gettext('Discard'),
+ reference: 'discard',
+ name: 'discard',
+ },
+ );
+ me.column1.unshift(
+ {
+ xtype: 'radiofield',
+ itemId: 'empty',
+ name: me.getRadioName(),
+ inputValue: 'empty',
+ boxLabel: gettext('Add empty disk'),
+ hidden: Proxmox.UserName !== 'root@pam',
+ checked: true,
+ listeners: {
+ /**
+ *
+ * @param field - The radiofield
+ * @param nowSelected - True if the field has just been clicked on, false if
+ * any other radiofield has been clicked
+ */
+ change: function(field, nowSelected) {
+ // clicking buttons
+ me.down('#disksize').setDisabled(!nowSelected);
+ me.down('#disksize').clearInvalid();
+ // overrule storage specific setting (including initial load)
+ me.down('pveDiskStorageSelector').disableSize = !nowSelected;
+
+ const targetSelector = field.up('pveQemuHardDisk')
+ .down('pveDiskStorageSelector');
+ if (nowSelected) {
+ if (!me.newDiskSize) {
+ me.newDiskSize = targetSelector.defaultSize;
+ }
+ targetSelector.setSize(me.newDiskSize);
+ }
+ },
+ },
+ },
+ {
+ xtype: 'radiofield',
+ name: me.getRadioName(),
+ inputValue: 'storage',
+ boxLabel: gettext('Use a storage as source'),
+ hidden: Proxmox.UserName !== 'root@pam',
+ listeners: {
+ change: (field, nowSelected) => {
+ field.next('#sourceStorageSelector').setHidden(!nowSelected);
+ field.next('#sourceStorageSelector').setDisabled(!nowSelected);
+ field.next('pveFileSelector[name=sourceVolid]').setHidden(!nowSelected);
+ field.next('pveFileSelector[name=sourceVolid]').setDisabled(!nowSelected);
+
+ // changing radiofields without changing source image
+ if (nowSelected) {
+ const targetSelector = me.down('pveDiskStorageSelector');
+ if (field.getGroupValue() === 'empty') {
+ // in this case the change listener of the 'empty' field fires with false AFTER this listener fires with true
+ me.newDiskSize = targetSelector.fixAndGetSize();
+ }
+ const sourceField = field.next('pveFileSelector');
+ const size = Proxmox.Utils.format_size(sourceField.getCurrentSize());
+ targetSelector.setSize(size);
+ }
+ },
+ },
+ }, {
+ xtype: 'pveStorageSelector',
+ itemId: 'sourceStorageSelector',
+ nodename: me.nodename,
+ fieldLabel: gettext('Storage'),
+ storageContent: 'images',
+ autoSelect: me.insideWizard,
+ hidden: true,
+ disabled: true,
+ listeners: {
+ change: function(selector, selectedStorage) {
+ selector.next('pveFileSelector').setStorage(
+ selectedStorage,
+ me.getViewModel().get('nodename'),
+ );
+ },
+ },
+ }, {
+ xtype: 'pveFileSelector',
+ name: 'sourceVolid',
+ nodename: me.nodename,
+ storageContent: 'images',
+ hidden: true,
+ disabled: true,
+ fieldLabel: gettext('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"),
+ },
+ listeners: {
+ change: function(field, nowSelected) {
+ if (nowSelected) {
+ const size = Proxmox.Utils.format_size(this.getCurrentSize());
+ const targetSelector = this.up('pveQemuDiskBasic')
+ .down('pveDiskStorageSelector');
+
+ targetSelector.setSize(size);
+ targetSelector.down('#disksize').clearInvalid();
+ }
+ },
+ },
+ }, {
+ xtype: 'radiofield',
+ name: me.getRadioName(),
+ inputValue: 'path',
+ boxLabel: gettext('Use an absolute path as source'),
+ hidden: Proxmox.UserName !== 'root@pam',
+ listeners: {
+ change: (radiofield, nowSelected) => {
+ const field = radiofield.next('textfield[name=sourcePath]');
+ field.setHidden(!nowSelected);
+ field.setDisabled(!nowSelected);
+
+ const targetSelector = me.down('pveDiskStorageSelector');
+ if (nowSelected) {
+ targetSelector.setSize(0);
+ targetSelector.down('#disksize').clearInvalid();
+ }
+ },
+ enable: function() {
+ console.log('enable absolute path field');
+ },
+ disable: function() {
+ console.log('disable absolute path field');
+ },
+ },
+ }, {
+ xtype: 'textfield',
+ fieldLabel: gettext('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.callParent();
+ },
+});
diff --git a/www/manager6/qemu/disk/DiskCollection.js b/www/manager6/qemu/disk/DiskCollection.js
new file mode 100644
index 00000000..11d39b46
--- /dev/null
+++ b/www/manager6/qemu/disk/DiskCollection.js
@@ -0,0 +1,275 @@
+Ext.define('PVE.qemu.DiskCollection', {
+ extend: 'Proxmox.panel.InputPanel',
+ alias: 'widget.pveQemuDiskCollection',
+
+ insideWizard: false,
+
+ hiddenDisks: [],
+
+ leftColumnRatio: 0.25,
+
+ column1: [
+ {
+ // Adding to the panelContainer below automatically adds
+ // items to the store
+ xtype: 'gridpanel',
+ scrollable: true,
+ width: 100,
+ height: 50,
+ store: {
+ xtype: 'store',
+ storeId: '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('Device'),
+ dataIndex: 'device',
+ flex: 3,
+ resizable: false,
+ },
+ {
+ flex: 1,
+ xtype: 'actioncolumn',
+ align: 'center',
+ menuDisabled: true,
+ items: [
+ {
+ iconCls: 'x-fa fa-trash',
+ tooltip: 'Delete',
+ handler: function(button) {
+ button.up('pveQemuDiskCollection').removeCurrentDisk();
+ },
+ },
+ ],
+ },
+ ],
+ listeners: {
+ select: function(_, record) {
+ this.up('pveQemuDiskCollection')
+ .down('#panelContainer')
+ .setActiveItem(record.data.panel);
+ },
+ },
+ anchor: '100% 90%',
+ selectLast: function() {
+ this.setSelection(this.store.getLast());
+ },
+ dockedItems: [
+ {
+ xtype: 'toolbar',
+ dock: 'bottom',
+ ui: 'footer',
+ style: {
+ backgroundColor: 'transparent',
+ },
+ layout: {
+ pack: 'center',
+ },
+ items: [
+ {
+ iconCls: 'fa fa-plus-circle',
+ itemId: 'addDisk',
+ minWidth: '60',
+ handler: function(button) {
+ button.up('pveQemuDiskCollection').addDisk();
+ },
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ 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('diskstorage');
+ store.add({ device: newPanel.getDevice(), panel: newPanel });
+ container.setActiveItem(newPanel);
+ },
+ remove: function(panelContainer, panel, eOpts) {
+ const store = Ext.getStore('diskstorage');
+ store.removeByPanel(panel);
+ if (panelContainer.items.getCount() > 0) {
+ panelContainer.setActiveItem(0);
+ }
+ },
+ },
+ defaultItem: {
+ xtype: 'pveQemuHardDisk',
+ bind: {
+ nodename: '{nodename}',
+ },
+ listeners: {
+ // newPanel ... cloned + added defaultItem
+ added: function(newPanel) {
+ Ext.Array.each(newPanel.down('pveControllerSelector').query('field'),
+ function(field) {
+ //the fields don't exist earlier
+ field.on('change', function() {
+ const store = Ext.getStore('diskstorage');
+
+ // find by panel object because it is unique
+ const recordIndex = store.findBy(record =>
+ record.data.panel === field.up('pveQemuHardDisk'),
+ );
+ const controllerSelector = field.up('pveControllerSelector');
+ const newControllerAndId = controllerSelector.getValuesAsString();
+ store.getAt(recordIndex).set('device', newControllerAndId);
+ });
+ },
+ );
+ const wizard = this.up('pveQemuCreateWizard');
+ Ext.Array.each(this.query('field'), function(field) {
+ field.on('change', wizard.validcheck);
+ field.on('validitychange', wizard.validcheck);
+ });
+ },
+ },
+ validator: function() {
+ let valid = true;
+ const fields = this.query('field, fieldcontainer');
+ Ext.Array.each(fields, function(field) {
+ 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 textfield with source path
+ addDisk(device, path) {
+ const initialValues = this.up('window').getValues();
+ const item = Ext.clone(this.defaultItem);
+ item.insideWizard = this.insideWizard;
+ const added = this.add(item);
+ // values in the storage will be updated by listeners
+ if (path) {
+ // Need to explicitly deactivate when not rendered
+ added.down('radiofield[inputValue=empty]').setValue(false);
+ added.down('radiofield[inputValue=path]').setValue(true);
+ added.down('textfield[name=sourcePath]').setValue(path);
+ } else {
+ added.down('#empty').setValue(true);
+ }
+ const selector = added.down('pveControllerSelector');
+ if (device) {
+ selector.setValue(device);
+ } else {
+ selector.setVMConfig(initialValues);
+ }
+
+ 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); // not the gridpanel
+ leftColumnPanel.setFlex(me.leftColumnRatio);
+ // any other panel because this has no height yet
+ const panelHeight = me.up('tabpanel').items.get(0).getHeight();
+ me.down('gridpanel').setHeight(panelHeight);
+ },
+
+ setNodename: function(nodename) {
+ this.nodename = nodename;
+ this.query('pveQemuHardDisk').forEach(p => p.setNodename(nodename));
+ },
+
+ listeners: {
+ afterrender: function() {
+ const store = Ext.getStore('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 panel 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();
+ },
+
+ initComponent: function() {
+ this.callParent();
+ this.down('tableview').markDirty = false;
+ this.down('#panelContainer').insideWizard = this.insideWizard;
+ },
+});
diff --git a/www/manager6/qemu/disk/DiskOptions.js b/www/manager6/qemu/disk/DiskOptions.js
new file mode 100644
index 00000000..cbd38e56
--- /dev/null
+++ b/www/manager6/qemu/disk/DiskOptions.js
@@ -0,0 +1,243 @@
+/* 'change' property is assigned a string and then a function */
+Ext.define('PVE.qemu.DiskOptions', {
+ extend: 'Proxmox.panel.InputPanel',
+ alias: 'widget.pveQemuDiskOptions',
+ onlineHelp: 'qm_hard_disk',
+
+ insideWizard: false,
+
+ unused: false, // ADD usused disk imaged
+
+ padding: '10 10 10 10',
+
+ vmconfig: {}, // used to select usused disks
+
+ viewModel: {},
+
+ /**
+ * All radiofields in pveQemuDiskCollection have the same scope
+ * Make name of radiofields unique for each disk panel
+ */
+ getRadioName() {
+ return 'radio_' + this.id;
+ },
+
+ onGetValues: function(values) {
+ let me = this;
+
+ let params = {};
+
+ const simpleValues = me.up('pveQemuHardDisk').down('pveQemuDiskBasic').getValues();
+ const confidArray = Object.entries(simpleValues).filter(([key, _]) => key !== "import_sources");
+ // confidArray contains 1 array of length 2, e.g. confidArray = [["sata1", "local:-1,format=qcow2"]]
+ const confid = confidArray.shift().shift();
+ me.drive.file = ''; // append to drive of simple panel
+
+ PVE.Utils.propertyStringSet(me.drive, !values.backup, 'backup', '0');
+ PVE.Utils.propertyStringSet(me.drive, values.noreplicate, 'replicate', 'no');
+ PVE.Utils.propertyStringSet(me.drive, values.ssd, 'ssd', 'on');
+ PVE.Utils.propertyStringSet(me.drive, values.iothread, 'iothread', 'on');
+
+ let names = ['mbps_rd', 'mbps_wr', 'iops_rd', 'iops_wr'];
+ Ext.Array.each(names, function(name) {
+ let burst_name = name + '_max';
+ PVE.Utils.propertyStringSet(me.drive, values[name], name);
+ PVE.Utils.propertyStringSet(me.drive, values[burst_name], burst_name);
+ });
+
+ params[confid] = PVE.Parser.printQemuDrive(me.drive).replace(/^,/, "");
+
+ return params;
+ },
+
+ setVMConfig: function(vmconfig) {
+ let me = this;
+
+ me.vmconfig = vmconfig;
+
+ if (me.bussel) {
+ me.bussel.setVMConfig(vmconfig);
+ me.scsiController.setValue(vmconfig.scsihw);
+ }
+ if (me.unusedDisks) {
+ let disklist = [];
+ Ext.Object.each(vmconfig, function(key, value) {
+ if (key.match(/^unused\d+$/)) {
+ disklist.push([key, value]);
+ }
+ });
+ me.unusedDisks.store.loadData(disklist);
+ me.unusedDisks.setValue(me.confid);
+ }
+ },
+
+ setDrive: function(drive) {
+ let me = this;
+
+ me.drive = drive;
+
+ let values = {};
+ let match = drive.file.match(/^([^:]+):/);
+ if (match) {
+ values.hdstorage = match[1];
+ }
+
+ values.hdimage = drive.file;
+ values.backup = PVE.Parser.parseBoolean(drive.backup, 1);
+ values.noreplicate = !PVE.Parser.parseBoolean(drive.replicate, 1);
+ values.diskformat = drive.format || 'raw';
+ values.cache = drive.cache || '__default__';
+ values.discard = drive.discard === 'on';
+ values.ssd = PVE.Parser.parseBoolean(drive.ssd);
+ values.iothread = PVE.Parser.parseBoolean(drive.iothread);
+
+ values.mbps_rd = drive.mbps_rd;
+ values.mbps_wr = drive.mbps_wr;
+ values.iops_rd = drive.iops_rd;
+ values.iops_wr = drive.iops_wr;
+ values.mbps_rd_max = drive.mbps_rd_max;
+ values.mbps_wr_max = drive.mbps_wr_max;
+ values.iops_rd_max = drive.iops_rd_max;
+ values.iops_wr_max = drive.iops_wr_max;
+
+ me.setValues(values);
+ },
+
+
+ setNodename: function(nodename) {
+ // nothing
+ },
+
+ initComponent: function() {
+ let me = this;
+
+ let labelWidth = 140;
+
+ me.drive = {};
+
+ me.column1 = [];
+ me.column2 = [];
+
+ me.column1.push(
+ {
+ xtype: 'proxmoxcheckbox',
+ disabled: me.confid && me.confid.match(/^virtio/),
+ fieldLabel: gettext('SSD emulation'),
+ labelWidth: labelWidth,
+ name: 'ssd',
+ reference: 'ssd',
+ },
+ {
+ xtype: 'proxmoxcheckbox',
+ disabled: me.confid && !me.confid.match(/^(virtio|scsi)/),
+ fieldLabel: 'IO thread',
+ labelWidth: labelWidth,
+ reference: 'iothread',
+ name: 'iothread',
+ listeners: {
+ change: function(f, value) {
+ const disk = f.up('pveQemuHardDisk');
+ if (disk.insideWizard) {
+ const vmScsiType = value ? 'virtio-scsi-single' : 'virtio-scsi-pci';
+ disk.down('field[name=scsiController]').setValue(vmScsiType);
+ }
+ },
+ },
+ },
+ {
+ xtype: 'numberfield',
+ name: 'mbps_rd',
+ minValue: 1,
+ step: 1,
+ fieldLabel: gettext('Read limit') + ' (MB/s)',
+ labelWidth: labelWidth,
+ emptyText: gettext('unlimited'),
+ },
+ {
+ xtype: 'numberfield',
+ name: 'mbps_wr',
+ minValue: 1,
+ step: 1,
+ fieldLabel: gettext('Write limit') + ' (MB/s)',
+ labelWidth: labelWidth,
+ emptyText: gettext('unlimited'),
+ },
+ {
+ xtype: 'proxmoxintegerfield',
+ name: 'iops_rd',
+ minValue: 10,
+ step: 10,
+ fieldLabel: gettext('Read limit') + ' (ops/s)',
+ labelWidth: labelWidth,
+ emptyText: gettext('unlimited'),
+ },
+ {
+ xtype: 'proxmoxintegerfield',
+ name: 'iops_wr',
+ minValue: 10,
+ step: 10,
+ fieldLabel: gettext('Write limit') + ' (ops/s)',
+ labelWidth: labelWidth,
+ emptyText: gettext('unlimited'),
+ },
+ );
+
+ me.column2.push(
+ {
+ xtype: 'proxmoxcheckbox',
+ fieldLabel: gettext('Backup'),
+ autoEl: {
+ tag: 'div',
+ 'data-qtip': gettext('Include volume in backup job'),
+ },
+ labelWidth: labelWidth,
+ name: 'backup',
+ value: me.isCreate,
+ },
+ {
+ xtype: 'proxmoxcheckbox',
+ fieldLabel: gettext('Skip replication'),
+ labelWidth: labelWidth,
+ name: 'noreplicate',
+ },
+ {
+ xtype: 'numberfield',
+ name: 'mbps_rd_max',
+ minValue: 1,
+ step: 1,
+ fieldLabel: gettext('Read max burst') + ' (MB)',
+ labelWidth: labelWidth,
+ emptyText: gettext('default'),
+ },
+ {
+ xtype: 'numberfield',
+ name: 'mbps_wr_max',
+ minValue: 1,
+ step: 1,
+ fieldLabel: gettext('Write max burst') + ' (MB)',
+ labelWidth: labelWidth,
+ emptyText: gettext('default'),
+ },
+ {
+ xtype: 'proxmoxintegerfield',
+ name: 'iops_rd_max',
+ minValue: 10,
+ step: 10,
+ fieldLabel: gettext('Read max burst') + ' (ops)',
+ labelWidth: labelWidth,
+ emptyText: gettext('default'),
+ },
+ {
+ xtype: 'proxmoxintegerfield',
+ name: 'iops_wr_max',
+ minValue: 10,
+ step: 10,
+ fieldLabel: gettext('Write max burst') + ' (ops)',
+ labelWidth: labelWidth,
+ emptyText: gettext('default'),
+ },
+ );
+
+ me.callParent();
+ },
+});
diff --git a/www/manager6/qemu/disk/HardDisk.js b/www/manager6/qemu/disk/HardDisk.js
new file mode 100644
index 00000000..6fc8c55f
--- /dev/null
+++ b/www/manager6/qemu/disk/HardDisk.js
@@ -0,0 +1,137 @@
+/* 'change' property is assigned a string and then a function */
+Ext.define('PVE.qemu.HardDisk', {
+ extend: 'Ext.tab.Panel',
+ alias: 'widget.pveQemuHardDisk',
+ onlineHelp: 'qm_hard_disk',
+
+ tabPosition: 'bottom',
+ plain: true,
+
+ bind: {
+ nodename: '{nodename}',
+ },
+
+ insideWizard: false,
+
+ setNodename: function(nodename) {
+ this.nodename = nodename;
+ this.items.each(panel => panel.setNodename(nodename));
+ },
+
+ getDevice: function() {
+ return this.down('pveQemuDiskBasic').getDevice();
+ },
+
+ items: [
+ {
+ title: gettext('Basic'),
+ xtype: 'pveQemuDiskBasic',
+ isCreate: true,
+ bind: {
+ nodename: '{nodename}',
+ },
+ },
+ {
+ title: gettext('Options'),
+ xtype: 'pveQemuDiskOptions',
+ isCreate: true,
+ bind: {
+ nodename: '{nodename}',
+ },
+ },
+ ],
+
+ beforeRender: function() {
+ const me = this;
+ // any other panel because this has no height yet
+ if (me.insideWizard) {
+ const panelHeight = me.up('tabpanel').items.get(0).getHeight();
+ me.setHeight(panelHeight);
+ }
+ },
+ initComponent: function() {
+ const me = this;
+ me.items.forEach(i => { i.insideWizard = me.insideWizard; });
+ me.callParent();
+ },
+
+ setVMConfig: function(vmconfig) {
+ this.items.each(panel => panel.setVMConfig(vmconfig));
+ },
+ });
+
+Ext.define('PVE.qemu.HardDiskWindow', {
+ extend: 'Proxmox.window.Edit',
+
+ isAdd: true,
+
+ backgroundDelay: 5,
+
+ setNodename: function(nodename) {
+ this.nodename = nodename;
+ this.down('pveQemuHDTabpanel').setNodename(nodename);
+ },
+
+ initComponent: function() {
+ let me = this;
+
+ if (!me.nodename) {
+ throw "no node name specified";
+ }
+
+ let unused = me.confid && me.confid.match(/^unused\d+$/);
+
+ me.isCreate = me.confid ? unused : true;
+
+ let ipanel = Ext.create('PVE.qemu.HardDisk', {
+ confid: me.confid,
+ unused: unused,
+ isCreate: me.isCreate,
+ });
+ ipanel.setNodename(me.nodename);
+
+ if (unused) {
+ me.subject = gettext('Unused Disk');
+ } else if (me.isCreate) {
+ me.subject = gettext('Hard Disk');
+ } else {
+ me.subject = gettext('Hard Disk') + ' (' + me.confid + ')';
+ }
+
+ me.items = [ipanel];
+
+ me.callParent();
+ /* 'data' is assigned an empty array in same file, and here we
+ * use it like an object
+ */
+ me.load({
+ success: function(response, options) {
+ ipanel.setVMConfig(response.result.data);
+ if (me.confid) {
+ let value = response.result.data[me.confid];
+ let drive = PVE.Parser.parseQemuDrive(me.confid, value);
+ if (!drive) {
+ Ext.Msg.alert(gettext('Error'), 'Unable to parse drive options');
+ me.close();
+ return;
+ }
+ ipanel.setDrive(drive);
+ me.isValid(); // trigger validation
+ }
+ },
+ });
+ },
+ getValues: function() {
+ let values = this.callParent();
+ for (const [key, value] of Object.entries(values)) {
+ const re = /ide\d+|sata\d+|virtio\d+|scsi\d+|import_sources/;
+ if (key.match(re) && Array.isArray(value)) {
+ // Collected from different panels => array
+ // But API & some GUI functions expect not array
+ const sep = key === 'import_sources' ? '\0' : ',';
+ values[key] = value.join(sep);
+ }
+ }
+ return values;
+ },
+});
diff --git a/www/manager6/window/Wizard.js b/www/manager6/window/Wizard.js
index 47d60b8e..de935fd0 100644
--- a/www/manager6/window/Wizard.js
+++ b/www/manager6/window/Wizard.js
@@ -245,6 +245,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.30.2
prev parent reply other threads:[~2021-06-10 10:21 UTC|newest]
Thread overview: 2+ messages / expand[flat|nested] mbox.gz Atom feed top
2021-06-10 10:20 [pve-devel] [PATCH v9 qemu-server] Add API for VM import Dominic Jäger
2021-06-10 10:20 ` Dominic Jäger [this message]
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=20210610102024.59173-2-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.