public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
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




  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 a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal