From: Dominik Csapak Date: Fri, 8 Mar 2024 14:38:44 +0000 (+0100) Subject: ui: add wizard to allow importing from ESXi attached as storage X-Git-Url: https://git.proxmox.com/?a=commitdiff_plain;h=bb3fa9def4755039946a19f7532b68cd790ec33e;p=pve-manager.git ui: add wizard to allow importing from ESXi attached as storage Add a new 'import' panel for storages supporting the 'import' content type that shows a list of configs to import. When opening the wizard, we query the meta info from the new import-metadata API endpoint, and pre-fill the fields and shows potential warnings or things to watch out for, returned by the API. For disks and networks we allow to select which one to use and which storage/bridge to import to. Additionally, users can opt-in to a live-import, where the VM is immediately started and storage requests are fetched from the target on demand while importing the rest in the background. Signed-off-by: Dominik Csapak [ TL: some fixes, clean-ups and commit message rewording ] Signed-off-by: Thomas Lamprecht --- diff --git a/www/manager6/Makefile b/www/manager6/Makefile index 43428df5..c756cae6 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -134,6 +134,7 @@ JSSRC= \ window/TreeSettingsEdit.js \ window/PCIMapEdit.js \ window/USBMapEdit.js \ + window/GuestImport.js \ ha/Fencing.js \ ha/GroupEdit.js \ ha/GroupSelector.js \ diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js index 8c2beb8c..287d651a 100644 --- a/www/manager6/Utils.js +++ b/www/manager6/Utils.js @@ -1022,6 +1022,8 @@ Ext.define('PVE.Utils', { result = "CH " + Ext.String.leftPad(data.channel, 2, '0') + " ID " + data.id + " LUN " + data.lun; + } else if (data.content === 'import') { + result = data.volid.replace(/^.*?:/, ''); } else { result = data.volid.replace(/^.*?:(.*?\/)?/, ''); } diff --git a/www/manager6/storage/Browser.js b/www/manager6/storage/Browser.js index d651c4ce..f0688541 100644 --- a/www/manager6/storage/Browser.js +++ b/www/manager6/storage/Browser.js @@ -121,6 +121,42 @@ Ext.define('PVE.storage.Browser', { pluginType: plugin, }); } + if (contents.includes('import')) { + me.items.push({ + xtype: 'pveStorageContentView', + title: gettext('Import'), + iconCls: 'fa fa-cloud-download', + itemId: 'contentImport', + content: 'import', + useCustomRemoveButton: true, // hide default remove button + showColumns: ['name', 'format'], + tbar: [ + { + xtype: 'proxmoxButton', + disabled: true, + text: gettext('Start Import'), + handler: function() { + let grid = this.up('pveStorageContentView'); + let selection = grid.getSelection()?.[0]; + + if (!selection) { + return; + } + + let volumeName = selection.data.volid.replace(/^.*?:/, ''); + + Ext.create('PVE.window.GuestImport', { + storage: storeid, + volumeName, + nodename, + autoShow: true, + }); + }, + }, + ], + pluginType: plugin, + }); + } } if (caps.storage['Permissions.Modify']) { diff --git a/www/manager6/window/GuestImport.js b/www/manager6/window/GuestImport.js new file mode 100644 index 00000000..8570cba6 --- /dev/null +++ b/www/manager6/window/GuestImport.js @@ -0,0 +1,403 @@ +Ext.define('PVE.window.GuestImport', { + extend: 'Proxmox.window.Edit', // fixme: Proxmox.window.Edit? + alias: 'widget.pveGuestImportWindow', + + title: gettext('Import Guest'), + + submitUrl: function() { + let me = this; + return `/nodes/${me.nodename}/qemu`; + }, + + isAdd: true, + isCreate: true, + submitText: gettext('Import'), + showTaskViewer: true, + method: 'POST', + + loadUrl: function(_url, { storage, nodename, volumeName }) { + let args = Ext.Object.toQueryString({ volume: volumeName }); + return `/nodes/${nodename}/storage/${storage}/import-metadata?${args}`; + }, + + controller: { + xclass: 'Ext.app.ViewController', + + setNodename: function(_column, widget) { + let me = this; + let view = me.getView(); + widget.setNodename(view.nodename); + }, + + setIsos: function(ignoredVolumes) { + let me = this; + let isos = Object.entries(ignoredVolumes).map(([id, value]) => `${id}: ${value.replace(/^.*\//, '')}`); + if (!isos) { + return; + } + let warning = gettext('The following cd images were detected, but will not be carried over:'); + warning += '
' + isos.join('
'); + let warnings = me.getViewModel().get('warnings'); + warnings.push(warning); + me.getViewModel().set('warnings', warnings); + }, + + storageChange: function(storageSelector, value) { + let me = this; + + let grid = me.lookup('diskGrid'); + let rec = storageSelector.getWidgetRecord(); + let validFormats = storageSelector.store.getById(value).data.format; + grid.query('pveDiskFormatSelector').some((selector) => { + if (selector.getWidgetRecord().data.id !== rec.data.id) { + return false; + } + + if (validFormats?.[0]?.qcow2) { + selector.setDisabled(false); + selector.setValue('qcow2'); + } else { + selector.setValue('raw'); + selector.setDisabled(true); + } + + return true; + }); + }, + + control: { + 'grid field': { + // update records from widgetcolumns + change: function(widget, value) { + let rec = widget.getWidgetRecord(); + rec.set(widget.name, value); + rec.commit(); + }, + }, + 'pveStorageSelector': { + change: 'storageChange', + }, + }, + }, + + viewModel: { + data: { + coreCount: 1, + socketCount: 1, + warnings: [], + }, + + formulas: { + totalCoreCount: get => get('socketCount') * get('coreCount'), + hideWarnings: get => get('warnings').length === 0, + warningsText: get => get('warnings').join('

'), + }, + }, + + items: [ + { + xtype: 'inputpanel', + onGetValues: function(values) { + let me = this; + let grid = me.up('pveGuestImportWindow'); + + let config = Ext.apply(grid.vmConfig, values); + + if (config.scsi0) { + config.scsi0 = config.scsi0.replace('local:0,', 'local:0,format=qcow2,'); + } + + grid.lookup('diskGrid').getStore().each((rec) => { + if (!rec.data.enable) { + return; + } + let id = rec.data.id; + delete rec.data.enable; + delete rec.data.id; + rec.data.file += ':0'; // for our special api format + if (id === 'efidisk0') { + delete rec.data['import-from']; + } + config[id] = PVE.Parser.printQemuDrive(rec.data); + }); + + grid.lookup('netGrid').getStore().each((rec) => { + if (!rec.data.enable) { + return; + } + let id = rec.data.id; + delete rec.data.enable; + delete rec.data.id; + config[id] = PVE.Parser.printQemuNetwork(rec.data); + }); + + if (grid.lookup('liveimport').getValue()) { + config['live-restore'] = 1; + } + + return config; + }, + + column1: [ + { + xtype: 'pveGuestIDSelector', + name: 'vmid', + fieldLabel: 'VM', + guestType: 'qemu', + loadNextFreeID: true, + }, + { + xtype: 'proxmoxintegerfield', + fieldLabel: gettext('Sockets'), + name: 'sockets', + reference: 'socketsField', + value: 1, + minValue: 1, + maxValue: 4, + allowBlank: true, + bind: { + value: '{socketCount}', + }, + }, + { + xtype: 'proxmoxintegerfield', + fieldLabel: gettext('Cores'), + name: 'cores', + reference: 'coresField', + value: 1, + minValue: 1, + maxValue: 128, + allowBlank: true, + bind: { + value: '{coreCount}', + }, + }, + { + xtype: 'pveMemoryField', + fieldLabel: gettext('Memory'), + name: 'memory', + reference: 'memoryField', + value: 512, + allowBlank: true, + }, + ], + + column2: [ + { + xtype: 'textfield', + fieldLabel: gettext('Name'), + name: 'name', + vtype: 'DnsName', + reference: 'nameField', + allowBlank: true, + }, + { + xtype: 'CPUModelSelector', + name: 'cpu', + reference: 'cputype', + value: 'x86-64-v2-AES', + fieldLabel: gettext('Type'), + }, + { + xtype: 'displayfield', + fieldLabel: gettext('Total cores'), + name: 'totalcores', + isFormField: false, + bind: { + value: '{totalCoreCount}', + }, + }, + ], + columnB: [ + { + xtype: 'displayfield', + fieldLabel: gettext('Disks'), + labelWidth: 200, + }, + { + xtype: 'grid', + reference: 'diskGrid', + maxHeight: 150, + store: { data: [] }, + columns: [ + { + xtype: 'checkcolumn', + header: gettext('Use'), + width: 50, + dataIndex: 'enable', + listeners: { + checkchange: function(_column, _rowIndex, _checked, record) { + record.commit(); + }, + }, + }, + { + text: gettext('Disk'), + dataIndex: 'id', + }, + { + text: gettext('Source'), + dataIndex: 'import-from', + flex: 1, + renderer: function(value) { + return value.replace(/^.*\//, ''); + }, + }, + { + text: gettext('Storage'), + dataIndex: 'file', + xtype: 'widgetcolumn', + width: 150, + widget: { + xtype: 'pveStorageSelector', + isFormField: false, + name: 'file', + storageContent: 'images', + }, + onWidgetAttach: 'setNodename', + }, + { + text: gettext('Format'), + dataIndex: 'format', + xtype: 'widgetcolumn', + width: 150, + widget: { + xtype: 'pveDiskFormatSelector', + name: 'format', + isFormField: false, + matchFieldWidth: false, + }, + }, + ], + }, + { + xtype: 'displayfield', + fieldLabel: gettext('Network Interfaces'), + labelWidth: 200, + }, + { + xtype: 'grid', + maxHeight: 150, + reference: 'netGrid', + store: { data: [] }, + columns: [ + { + xtype: 'checkcolumn', + header: gettext('Use'), + width: 50, + dataIndex: 'enable', + listeners: { + checkchange: function(_column, _rowIndex, _checked, record) { + record.commit(); + }, + }, + }, + { + text: gettext('ID'), + dataIndex: 'id', + }, + { + text: gettext('MAC address'), + flex: 1, + dataIndex: 'macaddr', + }, + { + text: gettext('Model'), + flex: 1, + dataIndex: 'model', + }, + { + text: gettext('Bridge'), + dataIndex: 'bridge', + xtype: 'widgetcolumn', + widget: { + xtype: 'PVE.form.BridgeSelector', + name: 'bridge', + isFormField: false, + allowBlank: false, + }, + onWidgetAttach: 'setNodename', + }, + ], + }, + { + xtype: 'displayfield', + fieldLabel: gettext('Warnings'), + labelWidth: 200, + hidden: true, + bind: { + hidden: '{hideWarnings}', + }, + }, + { + xtype: 'displayfield', + reference: 'warningText', + userCls: 'pmx-hint', + hidden: true, + bind: { + hidden: '{hideWarnings}', + value: '{warningsText}', + }, + }, + ], + }, + ], + + initComponent: function() { + let me = this; + + if (!me.volumeName) { + throw "no volumeName given"; + } + + if (!me.storage) { + throw "no storage given"; + } + + if (!me.nodename) { + throw "no nodename given"; + } + + me.callParent(); + + me.query('toolbar')?.[0]?.insert(0, { + xtype: 'proxmoxcheckbox', + reference: 'liveimport', + boxLabelAlign: 'before', + boxLabel: gettext('Live Import'), + }); + + me.setTitle(Ext.String.format(gettext('Import Guest - {0}'), `${me.storage}:${me.volumeName}`)); + + me.load({ + success: function(response) { + let data = response.result.data; + me.vmConfig = data['create-args']; + + let disks = []; + for (const [id, value] of Object.entries(data.disks ?? {})) { + disks.push({ + id, + enable: true, + 'import-from': id === 'efidisk0' ? Ext.htmlEncode('') : value, + format: 'raw', + }); + } + + let nets = []; + for (const [id, parsed] of Object.entries(data.net ?? {})) { + parsed.id = id; + parsed.enable = true; + nets.push(parsed); + } + me.lookup('diskGrid').getStore().setData(disks); + me.lookup('netGrid').getStore().setData(nets); + + me.getViewModel().set('warnings', data.warnings.map(warning => warning.message)); + me.getController().setIsos(data['ignored-volumes']); + + me.setValues(me.vmConfig); + }, + }); + }, +});