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 E95141FF0ED for ; Fri, 31 Jul 2026 12:24:40 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 032FA217D5; Fri, 31 Jul 2026 12:22:10 +0200 (CEST) From: Dietmar Maurer To: pve-devel@lists.proxmox.com Subject: [RFC pve-manager 20/27] ui: storage wizard: add FC-attached SAN (shared LVM) support Date: Fri, 31 Jul 2026 12:21:49 +0200 Message-ID: <20260731102156.3947857-21-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.183 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 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: BELEDONQWC725KQO66NJMYZ4H5FXMPH5 X-Message-ID-Hash: BELEDONQWC725KQO66NJMYZ4H5FXMPH5 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: There is no dedicated storage plugin for Fibre Channel SANs; the LUN shows up as a block device on every attached node and is used as a shared LVM volume group. This setup is not obvious from the current Add menu, so give it an explicit wizard entry. The wizard lists the node's block devices via the new san-luns scan API and autodetects their usage: selecting an unused LUN initializes it as a new volume group through the node disk management API, while a LUN that already carries a volume group is reused. Devices whose volume group is already referenced by another storage definition, or that are otherwise in use, are shown but not selectable. Requires libpve-storage-perl with the san-luns scan method and multipath support for volume group creation. Signed-off-by: Dietmar Maurer --- www/manager6/Makefile | 1 + www/manager6/Utils.js | 35 ++ www/manager6/storage/wizard/CommonSettings.js | 3 + www/manager6/storage/wizard/FibreChannel.js | 373 ++++++++++++++++++ 4 files changed, 412 insertions(+) create mode 100644 www/manager6/storage/wizard/FibreChannel.js diff --git a/www/manager6/Makefile b/www/manager6/Makefile index e0a11e5e..4a413d56 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -382,6 +382,7 @@ JSSRC= \ storage/wizard/NFS.js \ storage/wizard/CIFS.js \ storage/wizard/IScsi.js \ + storage/wizard/FibreChannel.js \ Workspace.js \ # end of JSSRC list diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js index 040b5ae0..5972e1aa 100644 --- a/www/manager6/Utils.js +++ b/www/manager6/Utils.js @@ -1097,6 +1097,41 @@ Ext.define('PVE.Utils', { return Ext.String.htmlEncode(result); }, + render_san_lun_device: function (value, metaData, rec) { + let text = Ext.htmlEncode(value); + // only multipath mapped devices report their path count + let paths = rec.get('paths'); + if (paths) { + text += ` (${paths} ${ngettext('path', 'paths', paths)})`; + } + return text; + }, + + render_san_lun_usage: function (value, metaData, rec) { + if (value === 'unused') { + let sid = rec.get('used-by-storage'); + if (sid) { + // for example a LUN of a configured iSCSI storage + return Ext.htmlEncode(Ext.String.format(gettext('used by storage "{0}"'), sid)); + } + return gettext('unused'); + } + if (value === 'lvm') { + let vgname = rec.get('vgname'); + let sid = rec.get('used-by-storage'); + if (sid) { + return Ext.htmlEncode( + Ext.String.format(gettext('VG "{0}", used by storage "{1}"'), vgname, sid), + ); + } + if (vgname) { + return Ext.htmlEncode(Ext.String.format(gettext('VG "{0}"'), vgname)); + } + return 'LVM'; + } + return Ext.htmlEncode(rec.get('detail') || gettext('in use')); + }, + render_serverity: function (value) { return PVE.Utils.log_severity_hash[value] || value; }, diff --git a/www/manager6/storage/wizard/CommonSettings.js b/www/manager6/storage/wizard/CommonSettings.js index a49bb8f8..64a514de 100644 --- a/www/manager6/storage/wizard/CommonSettings.js +++ b/www/manager6/storage/wizard/CommonSettings.js @@ -13,6 +13,8 @@ Ext.define('PVE.storage.wizard.CommonSettings', { // extra config merged into the content selector, for example // mode-dependent bindings contentFieldConfig: undefined, + // extra fields shown between the storage ID and the content selector + extraColumn1: undefined, onGetValues: function (values) { values.disable = values.enable ? 0 : 1; @@ -31,6 +33,7 @@ Ext.define('PVE.storage.wizard.CommonSettings', { vtype: 'StorageId', allowBlank: false, }, + ...(me.extraColumn1 || []), ]; if (!me.fixedContent) { diff --git a/www/manager6/storage/wizard/FibreChannel.js b/www/manager6/storage/wizard/FibreChannel.js new file mode 100644 index 00000000..8aee5283 --- /dev/null +++ b/www/manager6/storage/wizard/FibreChannel.js @@ -0,0 +1,373 @@ +Ext.define('PVE.storage.wizard.FCLunSelect', { + extend: 'Proxmox.panel.InputPanel', + xtype: 'pveStorageWizardFCLunSelect', + + onlineHelp: 'storage_lvm', + + initComponent: function () { + let me = this; + + let sanTransports = { fc: 1, 'nvme-fc': 1 }; + + // transports that depend on a software-managed initiator connection, + // which this storage type does not set up on the other nodes + let remoteTransports = { + fcoe: 1, + iscsi: 1, + 'nvme-tcp': 1, + 'nvme-rdma': 1, + 'nvme-loop': 1, + }; + + // hide devices with local or unknown transports unless 'Show all' is enabled + let sanTransportFilter = new Ext.util.Filter({ + id: 'san-transport', + filterFn: (rec) => !!sanTransports[rec.get('transport')], + }); + + // devices with remote transports are never usable here, always hide them + let remoteTransportFilter = new Ext.util.Filter({ + id: 'remote-transport', + filterFn: (rec) => !remoteTransports[rec.get('transport')], + }); + + let store = Ext.create('Ext.data.Store', { + autoLoad: true, + fields: [ + 'devpath', + 'size', + 'transport', + 'vendor', + 'model', + 'serial', + 'wwn', + 'paths', + 'usage', + 'vgname', + 'used-by-storage', + 'detail', + { + name: 'selectable', + calculate: (data) => + !data['used-by-storage'] && + (data.usage === 'unused' || (data.usage === 'lvm' && !!data.vgname)), + }, + ], + proxy: { + type: 'proxmox', + url: `/api2/json/nodes/${Proxmox.NodeName}/scan/san-luns`, + }, + filters: [remoteTransportFilter, sanTransportFilter], + sorters: [ + { + // SAN attached devices are the likely candidates, list them first + sorterFn: function (a, b) { + let rank = (rec) => (sanTransports[rec.data.transport] ? 0 : 1); + return rank(a) - rank(b) || a.data.devpath.localeCompare(b.data.devpath); + }, + }, + ], + }); + + let updateSelection = function (rec) { + let vm = me.up('window').getViewModel(); + let hint = me.down('#selectionHint'); + let setHint = function (text) { + hint.setValue(text); + hint.setHidden(!text); + }; + + if (!rec) { + vm.set({ vgmode: '', device: '', vgname: '' }); + setHint(''); + } else if (rec.get('usage') === 'unused') { + vm.set({ vgmode: 'create', device: rec.get('devpath'), vgname: '' }); + setHint( + Ext.String.format( + gettext( + 'A new volume group will be created on "{0}", erasing all data on it!', + ), + rec.get('devpath'), + ), + ); + } else { + vm.set({ vgmode: 'existing', device: '', vgname: rec.get('vgname') }); + setHint( + Ext.String.format( + gettext('The existing volume group "{0}" will be used.'), + rec.get('vgname'), + ), + ); + } + }; + + me.items = [ + { + xtype: 'displayfield', + value: gettext('Select the SAN LUN for the volume group:'), + }, + { + xtype: 'grid', + itemId: 'lunGrid', + height: 220, + store: store, + emptyText: gettext( + 'No SAN attached devices found - "Show devices with local or unknown transports" lists additional devices', + ), + viewConfig: { + trackOver: false, + getRowClass: (rec) => (rec.get('selectable') ? '' : 'x-item-disabled'), + }, + listeners: { + beforeselect: (grid, rec) => rec.get('selectable'), + selectionchange: (sm, selection) => updateSelection(selection[0]), + }, + columns: [ + { + header: gettext('Device'), + dataIndex: 'devpath', + flex: 3, + renderer: PVE.Utils.render_san_lun_device, + }, + { + header: gettext('Transport'), + dataIndex: 'transport', + width: 80, + }, + { + header: gettext('Size'), + dataIndex: 'size', + width: 90, + renderer: Proxmox.Utils.format_size, + }, + { + header: gettext('Vendor') + '/' + gettext('Model'), + dataIndex: 'model', + flex: 2, + renderer: (value, metaData, rec) => + Ext.htmlEncode(`${rec.get('vendor') || ''} ${value || ''}`.trim()), + }, + { + header: gettext('Usage'), + dataIndex: 'usage', + flex: 2, + renderer: PVE.Utils.render_san_lun_usage, + }, + ], + }, + { + xtype: 'proxmoxcheckbox', + itemId: 'showAllDevices', + boxLabel: gettext('Show devices with local or unknown transports'), + submitValue: false, + autoEl: { + tag: 'div', + 'data-qtip': gettext( + 'Also list devices with local or unknown transports. Only use devices that are visible on all selected nodes!', + ), + }, + listeners: { + change: function (f, value) { + let filters = store.getFilters(); + if (value) { + filters.remove(sanTransportFilter); + } else { + filters.add(sanTransportFilter); + // drop a selection that got filtered away + let sm = me.down('#lunGrid').getSelectionModel(); + let selected = sm.getSelection()[0]; + if (selected && store.indexOf(selected) < 0) { + sm.deselectAll(); + } + } + }, + }, + }, + { + xtype: 'textfield', + name: 'vgmode', + hidden: true, + allowBlank: false, + bind: { value: '{vgmode}' }, + }, + { + xtype: 'textfield', + name: 'device', + hidden: true, + allowBlank: true, + bind: { value: '{device}' }, + }, + { + xtype: 'displayfield', + itemId: 'selectionHint', + userCls: 'pmx-hint', + hidden: true, + value: '', + }, + ]; + + if (!PVE.Utils.isStandaloneNode()) { + me.items.unshift({ + xtype: 'pveStorageScanNodeSelector', + listeners: { + change: function (f, value) { + me.up('window').scanNode = value; + let node = value || Proxmox.NodeName; + store.getProxy().setUrl(`/api2/json/nodes/${node}/scan/san-luns`); + me.down('#lunGrid').getSelectionModel().deselectAll(); + store.load(); + }, + }, + }); + } + + me.callParent(); + + Proxmox.Utils.monStoreErrors(me.down('#lunGrid'), store); + }, +}); + +PVE.storage.wizard.types.fc = { + text: gettext('Fibre Channel'), + description: gettext('Block storage (LUNs) on a SAN, attached via Fibre Channel.'), + group: 'san', + apiType: 'lvm', + viewModel: { + data: { + vgmode: '', + device: '', + vgname: '', + }, + formulas: { + vgCreate: (get) => get('vgmode') === 'create', + }, + }, + steps: () => [ + { + xtype: 'pveStorageWizardFCLunSelect', + title: gettext('SAN LUN'), + }, + ], + settings: { + onlineHelp: 'storage_lvm', + cts: ['images', 'rootdir'], + defaultContent: ['images', 'rootdir'], + extraColumn1: [ + { + xtype: 'textfield', + name: 'vgname', + fieldLabel: gettext('Volume group'), + allowBlank: true, + emptyText: gettext('use storage ID'), + bind: { + value: '{vgname}', + readOnly: '{!vgCreate}', + }, + }, + ], + advancedColumn1: [ + { + xtype: 'proxmoxcheckbox', + name: 'saferemove', + uncheckedValue: 0, + fieldLabel: gettext('Wipe Removed Volumes'), + }, + ], + columnB: [ + { + xtype: 'displayfield', + userCls: 'pmx-hint', + value: gettext( + 'The storage will be marked as shared. The SAN LUN must be visible on all selected nodes.', + ), + }, + ], + }, + summaryNotes: function (values) { + let notes = [ + gettext( + 'The storage will be marked as shared. The SAN LUN must be visible on all selected nodes.', + ), + ]; + if (values.vgmode === 'create') { + notes.push( + Ext.String.format( + gettext( + 'The volume group "{0}" is created on "{1}". Existing data on the device will be destroyed!', + ), + values.vgname || values.storage, + values.device, + ), + ); + } else { + notes.push( + Ext.String.format( + gettext('The existing volume group "{0}" will be used.'), + values.vgname, + ), + ); + } + return notes; + }, + submit: function (wizard, values) { + let mode = values.vgmode; + delete values.vgmode; + + let params = { + storage: values.storage, + type: 'lvm', + vgname: values.vgname || values.storage, + shared: 1, + content: values.content, + nodes: values.nodes, + disable: values.disable, + saferemove: values.saferemove, + }; + + if (mode === 'existing') { + wizard.runSubmitChain([(next) => wizard.createStorage(params, next)]); + return; + } + + let node = wizard.scanNode || Proxmox.NodeName; + let vgCreatedHint = Ext.String.format( + gettext( + 'The volume group "{0}" was already created on node "{1}". You can complete the setup later via Add -> LVM.', + ), + Ext.htmlEncode(params.vgname), + Ext.htmlEncode(node), + ); + + wizard.runSubmitChain([ + (next) => { + if (wizard.createdVolumeGroup) { + next(); + return; + } + Proxmox.Utils.API2Request({ + url: `/nodes/${node}/disks/lvm`, + method: 'POST', + waitMsgTarget: wizard, + params: { + name: params.vgname, + device: values.device, + }, + failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), + success: function (response) { + Ext.create('Proxmox.window.TaskProgress', { + upid: response.result.data, + autoShow: true, + taskDone: function (success) { + if (success) { + wizard.createdVolumeGroup = true; + next(); + } + }, + }); + }, + }); + }, + (next) => wizard.createStorage(params, next, vgCreatedHint), + ]); + }, +}; -- 2.47.3