From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [IPv6:2a0f:8001:1:32::40]) by lore.proxmox.com (Postfix) with ESMTPS id 0B8E11FF0ED for ; Fri, 31 Jul 2026 12:25:10 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 4D24B21854; Fri, 31 Jul 2026 12:22:11 +0200 (CEST) From: Dietmar Maurer To: pve-devel@lists.proxmox.com Subject: [RFC pve-manager 17/27] ui: storage: add guided remote storage wizard with NFS support Date: Fri, 31 Jul 2026 12:21:46 +0200 Message-ID: <20260731102156.3947857-18-dietmar@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260731102156.3947857-1-dietmar@proxmox.com> References: <20260731102156.3947857-1-dietmar@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 2 AWL -0.314 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) KAM_LAZY_DOMAIN_SECURITY 1 Sending domain does not have any anti-forgery methods POISEN_SPAM_PILL 0.1 Meta: its spam POISEN_SPAM_PILL_1 0.1 random spam to be learned in bayes POISEN_SPAM_PILL_3 0.1 random spam to be learned in bayes RDNS_NONE 1.274 Delivered to internal network by a host with no rDNS SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_NONE 0.001 SPF: sender does not publish an SPF Record Message-ID-Hash: R5DN5MX3JBUQWGUMYG3Z5W3VKSV5GGEF X-Message-ID-Hash: R5DN5MX3JBUQWGUMYG3Z5W3VKSV5GGEF X-MailFrom: dietmar@zilli.proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: Adding remote storage currently requires picking the right plugin type from the Add menu and filling a single type-specific dialog, which assumes the user already knows the PVE storage model. Introduce a question-driven wizard that first asks what kind of storage should be added and then walks through connection, selection and common settings with a final confirmation step. The wizard builds its tab chain from a per-type registry, so each storage type is contained in its own file. Changing the answer on the type question swaps the window for a freshly built wizard, since the wizard base wires field validity tracking only at creation time. The submit path runs a chain of API requests, preparing for types that need to create multiple storage entries in one go. This adds the infrastructure, the shared settings tab and the NFS flow; the wizard is not yet reachable from the UI. Signed-off-by: Dietmar Maurer --- www/manager6/Makefile | 3 + www/manager6/storage/wizard/CommonSettings.js | 73 +++++ www/manager6/storage/wizard/NFS.js | 91 ++++++ www/manager6/storage/wizard/Wizard.js | 268 ++++++++++++++++++ 4 files changed, 435 insertions(+) create mode 100644 www/manager6/storage/wizard/CommonSettings.js create mode 100644 www/manager6/storage/wizard/NFS.js create mode 100644 www/manager6/storage/wizard/Wizard.js diff --git a/www/manager6/Makefile b/www/manager6/Makefile index eb0e9d9c..10340b5a 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -377,6 +377,9 @@ JSSRC= \ storage/ZFSEdit.js \ storage/ZFSPoolEdit.js \ storage/ESXIEdit.js \ + storage/wizard/Wizard.js \ + storage/wizard/CommonSettings.js \ + storage/wizard/NFS.js \ Workspace.js \ # end of JSSRC list diff --git a/www/manager6/storage/wizard/CommonSettings.js b/www/manager6/storage/wizard/CommonSettings.js new file mode 100644 index 00000000..a49bb8f8 --- /dev/null +++ b/www/manager6/storage/wizard/CommonSettings.js @@ -0,0 +1,73 @@ +Ext.define('PVE.storage.wizard.CommonSettings', { + extend: 'Proxmox.panel.InputPanel', + xtype: 'pveStorageWizardCommonSettings', + + onlineHelp: 'chapter_storage', + + // content types offered by the content selector (undefined for all) + cts: undefined, + // preselected content types + defaultContent: undefined, + // content is not user-selectable, the submit handler uses this value + fixedContent: undefined, + // extra config merged into the content selector, for example + // mode-dependent bindings + contentFieldConfig: undefined, + + onGetValues: function (values) { + values.disable = values.enable ? 0 : 1; + delete values.enable; + return values; + }, + + initComponent: function () { + let me = this; + + me.column1 = [ + { + xtype: 'textfield', + name: 'storage', + fieldLabel: 'ID', + vtype: 'StorageId', + allowBlank: false, + }, + ]; + + if (!me.fixedContent) { + me.column1.push( + Ext.apply( + { + xtype: 'pveContentTypeSelector', + cts: me.cts, + name: 'content', + value: me.defaultContent, + multiSelect: true, + fieldLabel: gettext('Content'), + allowBlank: false, + }, + me.contentFieldConfig, + ), + ); + } + + me.column2 = [ + { + xtype: 'pveNodeSelector', + name: 'nodes', + fieldLabel: gettext('Nodes'), + emptyText: gettext('All') + ' (' + gettext('No restrictions') + ')', + multiSelect: true, + autoSelect: false, + }, + { + xtype: 'proxmoxcheckbox', + name: 'enable', + checked: true, + uncheckedValue: 0, + fieldLabel: gettext('Enable'), + }, + ]; + + me.callParent(); + }, +}); diff --git a/www/manager6/storage/wizard/NFS.js b/www/manager6/storage/wizard/NFS.js new file mode 100644 index 00000000..afb4f455 --- /dev/null +++ b/www/manager6/storage/wizard/NFS.js @@ -0,0 +1,91 @@ +Ext.define('PVE.storage.wizard.NFSConnection', { + extend: 'Proxmox.panel.InputPanel', + xtype: 'pveStorageWizardNFSConnection', + + onlineHelp: 'storage_nfs', + + onGetValues: function (values) { + if (values.nfsversion && values.nfsversion !== '__default__') { + values.options = `vers=${values.nfsversion}`; + } + delete values.nfsversion; + return values; + }, + + initComponent: function () { + let me = this; + + me.column1 = [ + { + xtype: 'textfield', + name: 'server', + fieldLabel: gettext('Server'), + allowBlank: false, + listeners: { + change: function (f, value) { + let exportField = me.down('field[name=export]'); + exportField.setServer(value); + exportField.setValue(''); + }, + }, + }, + { + xtype: 'pveNFSScan', + name: 'export', + fieldLabel: 'Export', + allowBlank: false, + }, + ]; + + me.column2 = []; + if (!PVE.Utils.isStandaloneNode()) { + me.column2.push({ + xtype: 'pveStorageScanNodeSelector', + listeners: { + change: function (f, value) { + me.down('field[name=export]').setNodeName(value); + let wizard = me.up('window'); + wizard.scanNode = value; + wizard.down('field[name=nodes]').setValue(value); + }, + }, + }); + } + + me.advancedColumn1 = [ + { + xtype: 'proxmoxKVComboBox', + fieldLabel: gettext('NFS Version'), + name: 'nfsversion', + value: '__default__', + deleteEmpty: false, + comboItems: [ + ['__default__', Proxmox.Utils.defaultText], + ['3', '3'], + ['4', '4'], + ['4.1', '4.1'], + ['4.2', '4.2'], + ], + }, + ]; + + me.callParent(); + }, +}); + +PVE.storage.wizard.types.nfs = { + text: 'NFS', + description: gettext('Shared folder on a NAS or file server, accessed via the NFS protocol.'), + group: 'nas', + apiType: 'nfs', + steps: () => [ + { + xtype: 'pveStorageWizardNFSConnection', + title: gettext('Connection'), + }, + ], + settings: { + onlineHelp: 'storage_nfs', + defaultContent: ['images'], + }, +}; diff --git a/www/manager6/storage/wizard/Wizard.js b/www/manager6/storage/wizard/Wizard.js new file mode 100644 index 00000000..f4118a79 --- /dev/null +++ b/www/manager6/storage/wizard/Wizard.js @@ -0,0 +1,268 @@ +Ext.ns('PVE.storage.wizard'); + +// Registry of storage types offered by the remote storage wizard. Each +// per-type file registers an entry with the following properties: +// - text, description: chooser radio label and explanation +// - group: chooser group key, see PVE.storage.wizard.groups +// - apiType: storage plugin type for a plain single-storage creation +// - steps(): tab configs shown between the chooser and the settings tab +// - settings: extra config for PVE.storage.wizard.CommonSettings +// - viewModel: optional view model config for cross-tab state +// - summaryNotes(values): optional list of hints shown on the confirm tab +// - submit(wizard, values): custom submit handler, defaults to a single +// POST to /storage with type set to apiType +PVE.storage.wizard.types = {}; + +// Chooser groups, in display order. +PVE.storage.wizard.groups = [ + { key: 'nas', text: gettext('File Storage (NAS)') }, + { key: 'san', text: gettext('Block Storage (SAN)') }, +]; + +Ext.define('PVE.storage.wizard.RemoteStorage', { + extend: 'PVE.window.Wizard', + alias: 'widget.pveRemoteStorageWizard', + + subject: gettext('Remote Storage'), + + wizardType: 'nfs', + + // called on destroy, when the wizard may have changed the storage config + reloadCallback: undefined, + + // node used for storage scans, updated by the scan node selectors + scanNode: undefined, + + // the view model config must be in place before the component config + // system runs, initComponent would be too late for the field bindings + constructor: function (config) { + let me = this; + let entry = PVE.storage.wizard.types[config?.wizardType || me.wizardType]; + if (entry?.viewModel) { + config = Ext.apply({ viewModel: Ext.clone(entry.viewModel) }, config); + } + me.callParent([config]); + }, + + changeWizardType: function (type) { + let me = this; + if (!type || type === me.wizardType) { + return; + } + Ext.create('PVE.storage.wizard.RemoteStorage', { + wizardType: type, + reloadCallback: me.reloadCallback, + autoShow: true, + }); + me.close(); + }, + + // run async submit steps in order and close the wizard when all + // succeeded; a step calls next() on success and simply returns after + // reporting a failure, so a later Finish retries the remaining steps + runSubmitChain: function (steps) { + let me = this; + let run = function (idx) { + if (idx >= steps.length) { + me.close(); + return; + } + steps[idx](() => run(idx + 1)); + }; + run(0); + }, + + // POST one storage creation, dropping empty optional values; errorHtml + // (safe HTML) is prepended to the error message, for example to explain + // what earlier steps already created + createStorage: function (params, next, errorHtml) { + let me = this; + params = Ext.apply({}, params); + Ext.Object.each(params, function (key, value) { + if (value === undefined || value === null || (Ext.isArray(value) && !value.length)) { + delete params[key]; + } + }); + Proxmox.Utils.API2Request({ + url: '/storage', + method: 'POST', + waitMsgTarget: me, + params: params, + success: () => next(), + failure: function (response) { + let msg = response.htmlStatus; + if (errorHtml) { + msg = `${errorHtml}

${msg}`; + } + Ext.Msg.alert(gettext('Error'), msg); + }, + }); + }, + + submitSimple: function (values) { + let me = this; + let entry = PVE.storage.wizard.types[me.wizardType]; + values.type = entry.apiType; + if (entry.settings?.fixedContent) { + values.content = entry.settings.fixedContent; + } + me.runSubmitChain([(next) => me.createStorage(values, next)]); + }, + + initComponent: function () { + let me = this; + + let entry = PVE.storage.wizard.types[me.wizardType]; + if (!entry) { + throw `unknown wizard storage type: ${me.wizardType}`; + } + + let chooserItems = [ + { + xtype: 'displayfield', + value: gettext('What kind of storage do you want to add?'), + }, + ]; + for (const group of PVE.storage.wizard.groups) { + let typeRadios = Object.entries(PVE.storage.wizard.types) + .filter(([, typeEntry]) => typeEntry.group === group.key) + .map(([type, typeEntry]) => ({ + boxLabel: `${typeEntry.text} - ${typeEntry.description}`, + name: 'wizardType', + inputValue: type, + checked: type === me.wizardType, + submitValue: false, + margin: '0 0 10 0', + })); + if (!typeRadios.length) { + continue; + } + chooserItems.push({ + xtype: 'displayfield', + value: `${group.text}`, + }); + chooserItems.push({ + xtype: 'radiogroup', + columns: 1, + vertical: true, + margin: '0 0 0 15', + items: typeRadios, + listeners: { + change: function (rg, value) { + // ignore the uncheck event fired when a radio in + // another group takes over the selection + if (!value.wizardType) { + return; + } + let wizard = rg.up('window'); + // let the radio group finish its change + // handling before it gets destroyed + Ext.defer(() => wizard.changeWizardType(value.wizardType), 10); + }, + }, + }); + } + + me.items = [ + { + xtype: 'inputpanel', + title: gettext('Storage Type'), + onlineHelp: 'chapter_storage', + items: chooserItems, + }, + ...entry.steps(), + Ext.apply( + { + xtype: 'pveStorageWizardCommonSettings', + title: gettext('General'), + }, + entry.settings, + ), + { + title: gettext('Confirm'), + layout: { type: 'vbox', align: 'stretch' }, + defaults: { border: false }, + items: [ + { + xtype: 'grid', + flex: 1, + store: { + model: 'KeyValue', + sorters: [{ property: 'key', direction: 'ASC' }], + }, + columns: [ + { header: gettext('Key'), width: 150, dataIndex: 'key' }, + { + header: gettext('Value'), + flex: 1, + dataIndex: 'value', + renderer: Ext.htmlEncode, + }, + ], + }, + { + xtype: 'container', + itemId: 'summaryNotes', + padding: '5 0 0 0', + defaults: { + xtype: 'displayfield', + userCls: 'pmx-hint', + margin: 0, + }, + }, + ], + listeners: { + show: function (panel) { + let wizard = panel.up('window'); + let values = wizard.getValues(); + + let data = []; + Ext.Object.each(values, function (key, value) { + if (key === 'delete') { + return; + } + if (key === 'password') { + value = '********'; + } + data.push({ key, value }); + }); + + let summarystore = panel.down('grid').getStore(); + summarystore.suspendEvents(); + summarystore.removeAll(); + summarystore.add(data); + summarystore.sort(); + summarystore.resumeEvents(); + summarystore.fireEvent('refresh'); + + let typeEntry = PVE.storage.wizard.types[wizard.wizardType]; + let notes = panel.down('#summaryNotes'); + notes.removeAll(); + (typeEntry.summaryNotes?.(values) || []).forEach((note) => + notes.add({ value: note }), + ); + }, + }, + onSubmit: function () { + let wizard = this.up('window'); + let values = wizard.getValues(); + delete values.delete; + let typeEntry = PVE.storage.wizard.types[wizard.wizardType]; + if (typeEntry.submit) { + typeEntry.submit(wizard, values); + } else { + wizard.submitSimple(values); + } + }, + }, + ]; + + me.on('destroy', function () { + if (me.reloadCallback) { + me.reloadCallback(); + } + }); + + me.callParent(); + }, +}); -- 2.47.3