From: Dietmar Maurer <dietmar@proxmox.com>
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 [thread overview]
Message-ID: <20260731102156.3947857-18-dietmar@proxmox.com> (raw)
In-Reply-To: <20260731102156.3947857-1-dietmar@proxmox.com>
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 <dietmar@proxmox.com>
---
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}<br><br>${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: `<b>${typeEntry.text}</b> - ${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: `<b>${group.text}</b>`,
+ });
+ 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
next prev parent reply other threads:[~2026-07-31 10:25 UTC|newest]
Thread overview: 28+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-07-31 10:21 [RFC pve-storage/proxmox-widget-toolkit/pve-manager 00/27] add guided remote storage setup and SAN visibility Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-storage 01/27] diskmanage: collect disk transport type from lsblk Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-storage 02/27] diskmanage: add helper to list multipath devices Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-storage 03/27] diskmanage: qualify NVMe over fabrics transport Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-storage 04/27] disks: list: add include-remote parameter Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-storage 05/27] diskmanage: include iSCSI session devices in disk enumeration Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-storage 06/27] diskmanage: link multipath member disks to their map device Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-storage 07/27] iscsi: factor out session device map from device list Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-storage 08/27] api: scan: add san-luns method listing SAN LUN candidates Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-storage 09/27] disks: lvm: allow creating volume groups on multipath devices Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-storage 10/27] diskmanage: add helper querying multipath path state Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-storage 11/27] diskmanage: add helper querying NVMe native " Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-storage 12/27] api: scan: san-luns: report " Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-storage 13/27] iscsi plugin: list sessions of all transports and capture transport Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-storage 14/27] api: add node-level iSCSI initiator target and session API Dietmar Maurer
2026-07-31 10:21 ` [RFC proxmox-widget-toolkit 15/27] disk selectors: allow opting into remote devices Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-manager 16/27] ui: storage: allow switching the scan node of the NFS/CIFS scan combos Dietmar Maurer
2026-07-31 10:21 ` Dietmar Maurer [this message]
2026-07-31 10:21 ` [RFC pve-manager 18/27] ui: storage wizard: add SMB/CIFS support Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-manager 19/27] ui: storage wizard: add iSCSI support Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-manager 20/27] ui: storage wizard: add FC-attached SAN (shared LVM) support Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-manager 21/27] ui: storage wizard: add ZFS over iSCSI support Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-manager 22/27] ui: dc: storage: add remote storage wizard entry to the add menu Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-manager 23/27] ui: node: add SAN LUNs panel Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-manager 24/27] ui: san luns: show multipath path state Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-manager 25/27] api: nodes: add iSCSI initiator API endpoint Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-manager 26/27] pvenode: add iscsi commands Dietmar Maurer
2026-07-31 10:21 ` [RFC pve-manager 27/27] ui: san luns: show iSCSI targets and sessions Dietmar Maurer
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=20260731102156.3947857-18-dietmar@proxmox.com \
--to=dietmar@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.