From: Dietmar Maurer <dietmar@proxmox.com>
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 [thread overview]
Message-ID: <20260731102156.3947857-21-dietmar@proxmox.com> (raw)
In-Reply-To: <20260731102156.3947857-1-dietmar@proxmox.com>
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 <dietmar@proxmox.com>
---
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
next prev parent reply other threads:[~2026-07-31 10:24 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 ` [RFC pve-manager 17/27] ui: storage: add guided remote storage wizard with NFS support Dietmar Maurer
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 ` Dietmar Maurer [this message]
2026-07-31 10:21 ` [RFC pve-manager 21/27] ui: storage wizard: add ZFS over " 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-21-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.