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 a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox