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 2D6CF1FF0AF for ; Fri, 06 Nov 2026 01:58:17 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 7A7EF21DDE; Fri, 06 Nov 2026 01:51:20 +0100 (CET) From: Thomas Lamprecht To: pve-devel@lists.proxmox.com Subject: [PATCH v3 manager 50/76] ui: storage: add guided remote storage wizard with NFS support Date: Fri, 6 Nov 2026 00:31:25 +0100 Message-ID: <20261105233336.2178064-51-t.lamprecht@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20261105233336.2178064-1-t.lamprecht@proxmox.com> References: <20261105233336.2178064-1-t.lamprecht@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1793926180868 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.301 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) RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: UDN2YNH6XXHCDOMFIWRYABFV7HXTGWE6 X-Message-ID-Hash: UDN2YNH6XXHCDOMFIWRYABFV7HXTGWE6 X-MailFrom: t.lamprecht@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: From: Dietmar Maurer The storage Add menu assumes users know which plugin and settings they need. Introduce a wizard that asks for the storage type, then guides the user through connection, selection, common settings, and confirmation. Keep each type's steps in a registry. Rebuild the window when the type changes because field validity tracking is wired only at creation. Support chained API requests for types requiring several storage entries. Include shared settings and the NFS flow; a separate patch adds the menu entry. Signed-off-by: Dietmar Maurer [TL: label and order the confirmation rows by the setup, with positive labels and readable values; let types present values, add rows and supply plain-text notes; refresh the summary during setup, keep the wizard busy and not closable while its request runs and read the scan node from its selector instead of a copy on the window; widen the Setting column of the summary so that longer labels fit] Signed-off-by: Thomas Lamprecht --- www/manager6/Makefile | 3 + www/manager6/storage/wizard/CommonSettings.js | 69 ++++ www/manager6/storage/wizard/NFS.js | 89 ++++ www/manager6/storage/wizard/Wizard.js | 389 ++++++++++++++++++ 4 files changed, 550 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 d2ea786be..b467e7ef5 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -378,6 +378,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 000000000..97e49e981 --- /dev/null +++ b/www/manager6/storage/wizard/CommonSettings.js @@ -0,0 +1,69 @@ +Ext.define('PVE.storage.wizard.CommonSettings', { + extend: 'Proxmox.panel.InputPanel', + xtype: 'pveStorageWizardCommonSettings', + + onlineHelp: 'chapter_storage', + + cts: undefined, + defaultContent: undefined, + // submitted as content instead of offering a selector + fixedContent: undefined, + 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 000000000..8e7a485c4 --- /dev/null +++ b/www/manager6/storage/wizard/NFS.js @@ -0,0 +1,89 @@ +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); + me.up('window').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 000000000..ce28f6845 --- /dev/null +++ b/www/manager6/storage/wizard/Wizard.js @@ -0,0 +1,389 @@ +Ext.ns('PVE.storage.wizard'); + +PVE.storage.wizard.types = {}; + +PVE.storage.wizard.groups = [ + { key: 'nas', text: gettext('File Storage (NAS)') }, + { key: 'san', text: gettext('Block Storage (SAN)') }, +]; + +// submitted values without a field to label them: derived in onGetValues or from a radio group +PVE.storage.wizard.summaryFields = { + disable: { label: gettext('Enabled'), isBoolean: true, invert: true, weight: 31 }, + content: { + label: gettext('Content'), + render: (value) => PVE.Utils.format_content_types([].concat(value).join(',')), + weight: 30, + }, + nodes: { + label: gettext('Nodes'), + empty: gettext('All') + ' (' + gettext('No restrictions') + ')', + weight: 32, + }, + options: { label: gettext('Mount Options') }, +}; + +Ext.define('PVE.storage.wizard.RemoteStorage', { + extend: 'PVE.window.Wizard', + alias: 'widget.pveRemoteStorageWizard', + + subject: gettext('Remote Storage'), + + wizardType: 'nfs', + + reloadCallback: 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]); + }, + + getScanNode: function () { + return this.down('pveStorageScanNodeSelector')?.getValue() || Proxmox.NodeName; + }, + + 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(); + }, + + busy: false, + + // the continuation lives in this window: block closing and the buttons while waiting, then + // restore them + setBusy: function (busy, msg) { + let me = this; + me.busy = busy; + me.setLoading(busy ? msg || true : false); + let buttons = ['#back', '#next', '#submit'] + .map((itemId) => me.down(itemId)) + .filter((button) => !!button); + if (busy) { + me.buttonStateBeforeBusy ??= buttons.map((button) => button.isDisabled()); + buttons.forEach((button) => button.setDisabled(true)); + } else { + buttons.forEach((button, idx) => + button.setDisabled(me.buttonStateBeforeBusy?.[idx] ?? false), + ); + delete me.buttonStateBeforeBusy; + } + }, + + // keeps the window busy, so it cannot close with the continuation pending + request: function (options) { + let me = this; + me.setBusy(true, options.msg); + Proxmox.Utils.API2Request({ + url: options.url, + method: options.method, + params: options.params, + success: function (response) { + if (me.destroyed) { + return; + } + me.setBusy(false); + options.success(response); + }, + failure: function (response) { + if (me.destroyed) { + return; + } + me.setBusy(false); + options.failure(response); + }, + }); + }, + + // a step calls next() on success and just returns after a failure, so Finish retries from + // there + runSubmitChain: function (steps) { + let me = this; + let run = function (idx) { + if (me.destroyed) { + return; + } + if (idx >= steps.length) { + me.close(); + return; + } + steps[idx](() => run(idx + 1)); + }; + run(0); + }, + + // errorHtml (safe HTML) is prepended to an error + 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]; + } + }); + me.request({ + url: '/storage', + method: 'POST', + params: params, + success: () => next(), + failure: function (response) { + let msg = response.htmlStatus; + if (errorHtml) { + msg = `${errorHtml}

${msg}`; + } + Ext.Msg.alert(gettext('Error'), msg); + }, + }); + }, + + summaryField: function (key) { + let me = this; + let typeEntry = PVE.storage.wizard.types[me.wizardType]; + return typeEntry.summaryFields?.[key] ?? PVE.storage.wizard.summaryFields[key]; + }, + + summaryLabel: function (key) { + let me = this; + let entry = me.summaryField(key); + if (entry) { + return entry.label; + } + return me.down(`field[name="${key}"]`)?.fieldLabel || key; + }, + + summaryValue: function (key, value) { + let me = this; + let entry = me.summaryField(key); + let empty = value === undefined || value === null || value === ''; + if (Ext.isArray(value)) { + empty = !value.length; + } + if (empty && entry?.empty) { + return entry.empty; + } + if (entry?.render) { + return entry.render(value); + } + if (Ext.isArray(value)) { + value = value.join(', '); + } + let labeled = entry?.values?.[value]; + if (labeled !== undefined) { + return labeled; + } + let field = me.down(`field[name="${key}"]`); + // a radio group submits the selected option and is a checkbox as far as ExtJS is concerned + let isBoolean = + entry?.isBoolean || (!!field && field.isXType('checkbox') && !field.isXType('radio')); + if (isBoolean) { + let set = !!value && value !== '0'; + return Proxmox.Utils.format_boolean(entry?.invert ? !set : set); + } + return empty ? '' : String(value); + }, + + updateSummary: function () { + let me = this; + let panel = me.down('#confirm'); + if (!panel) { + return; + } + let values = me.getValues(); + let typeEntry = PVE.storage.wizard.types[me.wizardType]; + let derived = typeEntry.summaryRows?.(values, me) ?? {}; + let skip = new Set(['delete', ...(derived.skip ?? [])]); + + let data = []; + Ext.Object.each(values, function (key, value) { + let entry = me.summaryField(key); + if (skip.has(key) || entry?.skip?.(values)) { + return; + } + data.push({ + key: me.summaryLabel(key), + value: key === 'password' ? '********' : me.summaryValue(key, value), + weight: entry?.weight ?? 50, + }); + }); + for (const row of derived.rows ?? []) { + data.push({ key: row.label, value: row.value, weight: row.weight ?? 20 }); + } + + let summarystore = panel.down('grid').getStore(); + summarystore.suspendEvents(); + summarystore.removeAll(); + summarystore.add(data); + summarystore.sort(); + summarystore.resumeEvents(); + summarystore.fireEvent('refresh'); + + let notes = panel.down('#summaryNotes'); + notes.removeAll(); + for (const note of typeEntry.summaryNotes?.(values) || []) { + notes.add({ value: note }); + } + }, + + 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 fired when a radio in another group takes over + if (!value.wizardType) { + return; + } + let wizard = rg.up('window'); + // let the radio group finish its change handling before it is 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'), + itemId: 'confirm', + layout: { type: 'vbox', align: 'stretch' }, + defaults: { border: false }, + items: [ + { + xtype: 'grid', + flex: 1, + store: { + fields: ['key', 'value', 'weight'], + sorters: [ + { property: 'weight', direction: 'ASC' }, + { property: 'key', direction: 'ASC' }, + ], + }, + columns: [ + { header: gettext('Setting'), width: 180, dataIndex: 'key' }, + { + header: gettext('Value'), + flex: 1, + dataIndex: 'value', + renderer: Ext.htmlEncode, + }, + ], + }, + { + xtype: 'container', + itemId: 'summaryNotes', + padding: '5 0 0 0', + defaults: { + xtype: 'displayfield', + margin: 0, + }, + }, + ], + listeners: { + show: (panel) => panel.up('window').updateSummary(), + }, + 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('beforeclose', function () { + if (!me.busy) { + return true; + } + Ext.Msg.alert( + gettext('Please wait'), + gettext('The wizard is still waiting for an operation it started.'), + ); + return false; + }); + + me.on('destroy', function () { + if (me.reloadCallback) { + me.reloadCallback(); + } + }); + + me.callParent(); + }, +}); -- 2.47.3