public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Thomas Lamprecht <t.lamprecht@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH v3 manager 53/76] ui: storage wizard: add Fibre Channel shared LVM setup
Date: Fri,  6 Nov 2026 00:31:28 +0100	[thread overview]
Message-ID: <20261105233336.2178064-54-t.lamprecht@proxmox.com> (raw)
In-Reply-To: <20261105233336.2178064-1-t.lamprecht@proxmox.com>

Fibre Channel exposes block devices rather than a storage plugin.
Guide shared LVM setup using unused devices or existing volume groups,
showing storage ownership and local usage alongside device identity.

Accept only Fibre Channel devices. Local and unknown transports remain
display-only under "Show all devices": identity alone does not establish
shared access. Require stable by-id links for new groups because kernel
names can be reused before the task runs.

Bind inventory responses and selections to their scan node and request,
rejecting obsolete selections. Reuse these responses for cluster
visibility checks without silently excluding unchecked nodes.

The wizard tracks volume-group creation and keeps partial-setup settings
fixed. Resume status checks for known tasks, but never repeat an
unanswered creation request; direct users to task history if its task ID
is unknown.

Co-authored-by: Dietmar Maurer <dietmar@proxmox.com>
Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
 www/manager6/Makefile                         |   3 +
 www/manager6/SANUtils.js                      | 179 +++++
 www/manager6/Utils.js                         |   4 +
 www/manager6/storage/wizard/CommonSettings.js |   2 +
 www/manager6/storage/wizard/FibreChannel.js   | 674 ++++++++++++++++++
 www/manager6/storage/wizard/LunPicker.js      |  25 +
 www/manager6/storage/wizard/Wizard.js         | 169 ++++-
 7 files changed, 1054 insertions(+), 2 deletions(-)
 create mode 100644 www/manager6/SANUtils.js
 create mode 100644 www/manager6/storage/wizard/FibreChannel.js
 create mode 100644 www/manager6/storage/wizard/LunPicker.js

diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index 7002518e0..602e3b9b9 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -7,6 +7,7 @@ JSSRC= 							\
 	StateProvider.js				\
 	qemu/Architecture.js				\
 	Utils.js					\
+	SANUtils.js					\
 	UIOptions.js					\
 	Toolkit.js					\
 	VNCConsole.js					\
@@ -380,8 +381,10 @@ JSSRC= 							\
 	storage/ESXIEdit.js				\
 	storage/wizard/Wizard.js			\
 	storage/wizard/CommonSettings.js		\
+	storage/wizard/LunPicker.js			\
 	storage/wizard/NFS.js				\
 	storage/wizard/CIFS.js				\
+	storage/wizard/FibreChannel.js			\
 	Workspace.js					\
 # end of JSSRC list
 
diff --git a/www/manager6/SANUtils.js b/www/manager6/SANUtils.js
new file mode 100644
index 000000000..3b31ec614
--- /dev/null
+++ b/www/manager6/SANUtils.js
@@ -0,0 +1,179 @@
+Ext.define('PVE.SANUtils', {
+    singleton: true,
+
+    lun_fields: [
+        'devpath',
+        'size',
+        'vendor',
+        'model',
+        'paths-total',
+        'usage',
+        'used-by-storage',
+        'vgname',
+        'pv-in-use',
+        'detail',
+        'by-id',
+    ],
+
+    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-total');
+        if (paths) {
+            text += ` (${paths} ${ngettext('path', 'paths', paths)})`;
+        }
+        return text;
+    },
+
+    // Unchecked nodes (offline, failed or timed out) may still see a LUN.
+    scan_nodes_for_luns: function (
+        callback,
+        request = (options) => Proxmox.Utils.API2Request(options),
+    ) {
+        let wwids = {};
+        let unchecked = [];
+        let nodes = PVE.data.ResourceStore.getNodes();
+        let pending = nodes.length;
+        let finish = function () {
+            callback({
+                sees: (wwid) =>
+                    Object.keys(wwids)
+                        .filter((node) => wwids[node].has(wwid))
+                        .sort(),
+                unchecked: unchecked.sort(),
+            });
+        };
+        if (!pending) {
+            finish();
+            return;
+        }
+        let done = function () {
+            if (--pending === 0) {
+                finish();
+            }
+        };
+        for (const node of nodes) {
+            if (node.status !== 'online') {
+                unchecked.push(node.node);
+                done();
+                continue;
+            }
+            request({
+                url: `/nodes/${node.node}/scan/san-luns`,
+                method: 'GET',
+                timeout: 20000,
+                failure: function () {
+                    unchecked.push(node.node);
+                    done();
+                },
+                success: function (response) {
+                    let rows = (response.result.data || []).filter(
+                        (row) => !['sata', 'usb'].includes(row.transport),
+                    );
+                    if (rows.some((row) => row['wwid-unknown'])) {
+                        unchecked.push(node.node);
+                    }
+                    wwids[node.node] = new Set(rows.map((row) => row.wwid).filter((w) => w));
+                    done();
+                },
+            });
+        }
+    },
+
+    request_uncertain: function (response) {
+        return response.timedout || response.status === 0 || response.result?.success === undefined;
+    },
+
+    render_san_lun_usage: function (value, metaData, rec) {
+        let text = PVE.SANUtils.san_lun_usage_text(rec.data);
+        metaData.tdAttr = `data-qtip="${Ext.htmlEncode(text)}"`;
+        return text;
+    },
+
+    san_storage_only_connects: function (storeid) {
+        let rec = PVE.data.ResourceStore.findRecord('storage', storeid, 0, false, true, true);
+        if (!rec || !(rec.get('plugintype') === 'iscsi')) {
+            return false;
+        }
+        let content = `${rec.get('content') ?? ''}`;
+        return content === '' || content === 'none';
+    },
+
+    san_lun_usage_text: function (row) {
+        let value = row.usage;
+        if (value === 'unknown') {
+            return Ext.htmlEncode(
+                Ext.String.format(
+                    gettext('Could not check this LUN for data: {0}'),
+                    row.detail || gettext('unknown'),
+                ),
+            );
+        }
+        let sid = row['used-by-storage'];
+        // the scan also attributes LUNs to a connection-only storage, which leaves them free
+        let connects = sid && PVE.SANUtils.san_storage_only_connects(sid);
+        if (value === 'unused') {
+            if (connects) {
+                return Ext.htmlEncode(
+                    Ext.String.format(gettext('unused, connected through storage "{0}"'), sid),
+                );
+            }
+            if (sid) {
+                return Ext.htmlEncode(Ext.String.format(gettext('used by storage "{0}"'), sid));
+            }
+            return gettext('unused');
+        }
+        if (connects) {
+            sid = undefined;
+        }
+        if (value === 'lvm' || (value === 'other' && row.detail === 'LVM2_member')) {
+            let vgname = row.vgname;
+            if (vgname) {
+                let text = Ext.String.format(gettext('VG "{0}"'), vgname);
+                if (sid === vgname) {
+                    text = Ext.String.format(gettext('VG of storage "{0}"'), sid);
+                } else if (sid) {
+                    text = Ext.String.format(
+                        gettext('VG "{0}", used by storage "{1}"'),
+                        vgname,
+                        sid,
+                    );
+                }
+                return Ext.htmlEncode(text);
+            }
+            let orphan = [0, '0', false].includes(row['pv-in-use']);
+            let text;
+            if (orphan) {
+                text = gettext('LVM physical volume, no volume group');
+            } else {
+                text = gettext('LVM physical volume, volume group not known on this node');
+            }
+            return Ext.htmlEncode(
+                sid ? Ext.String.format(gettext('{0}, used by storage "{1}"'), text, sid) : text,
+            );
+        }
+        return Ext.htmlEncode(row.detail || gettext('in use'));
+    },
+
+    render_san_transport: function (value) {
+        if (!value) {
+            return '';
+        }
+        return (
+            {
+                iscsi: 'iSCSI',
+                iser: 'iSER',
+                fc: 'FC',
+                fcoe: 'FCoE',
+                sas: 'SAS',
+                nvme: 'NVMe',
+                'nvme-tcp': 'NVMe/TCP',
+                'nvme-rdma': 'NVMe/RDMA',
+                'nvme-fc': 'NVMe/FC',
+                'nvme-loop': 'NVMe loop',
+                tcp: 'TCP',
+                rdma: 'RDMA',
+            }[value] ?? Ext.htmlEncode(value)
+        );
+    },
+});
diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js
index ca850158b..bc5171712 100644
--- a/www/manager6/Utils.js
+++ b/www/manager6/Utils.js
@@ -274,6 +274,10 @@ Ext.define('PVE.Utils', {
             return '<i class="fa fa-' + iconCls + '"></i> ' + value;
         },
 
+        note_text: function (text) {
+            return `<span class="pmx-hint">${gettext('Note:')}</span> ${text}`;
+        },
+
         validateZfsBlocksize: function (value) {
             if (!value) {
                 return true;
diff --git a/www/manager6/storage/wizard/CommonSettings.js b/www/manager6/storage/wizard/CommonSettings.js
index a2bd240fd..fedd6dc31 100644
--- a/www/manager6/storage/wizard/CommonSettings.js
+++ b/www/manager6/storage/wizard/CommonSettings.js
@@ -10,6 +10,7 @@ Ext.define('PVE.storage.wizard.CommonSettings', {
     fixedContent: undefined,
     contentFieldConfig: undefined,
     nodesFieldConfig: undefined,
+    extraColumn1: undefined,
 
     onGetValues: function (values) {
         values.disable = values.enable ? 0 : 1;
@@ -28,6 +29,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 000000000..4affbd8a7
--- /dev/null
+++ b/www/manager6/storage/wizard/FibreChannel.js
@@ -0,0 +1,674 @@
+Ext.define('PVE.storage.wizard.FCLunSelect', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pveStorageWizardFCLunSelect',
+    mixins: ['PVE.storage.wizard.LunPicker'],
+
+    onlineHelp: 'storage_lvm',
+
+    // { lun } of the selected row
+    selection: undefined,
+    lunScan: undefined,
+
+    // include unchecked nodes, one left out cannot use the storage after a migration
+    updateNodes: function () {
+        let me = this;
+        let wizard = me.up('window');
+        let lun = me.selection?.lun;
+        if (PVE.Utils.isStandaloneNode() || !lun?.wwid || !me.lunScan) {
+            return;
+        }
+        let nodes = new Set([
+            wizard.getScanNode(),
+            ...me.lunScan.sees(lun.wwid),
+            ...me.lunScan.unchecked,
+        ]);
+        wizard.down('field[name=nodes]').setValue([...nodes].sort());
+    },
+
+    validator: function () {
+        let wizard = this.up('window');
+        return !wizard.busy && !!wizard.getViewModel().get('vgmode');
+    },
+
+    updateActions: function () {
+        let me = this;
+        let wizard = me.up('window');
+        let blocked = wizard.busy;
+        let grid = me.down('#lunGrid');
+        grid.setDisabled(blocked || me.scanning);
+        me.down('#rescan').setDisabled(blocked);
+        me.down('pveStorageScanNodeSelector')?.setDisabled(blocked);
+        if (wizard.down('#wizcontent').getActiveTab() === me) {
+            wizard.down('#next').setDisabled(!me.validator());
+        }
+    },
+
+    currentSelection: function (values) {
+        let me = this;
+        let wizard = me.up('window');
+        let rec = me.down('#lunGrid').getSelection()[0];
+        if (
+            me.scanning ||
+            !rec?.get('selectable') ||
+            rec.data !== me.selection?.lun ||
+            rec.get('scanNode') !== wizard.getScanNode() ||
+            rec.get('scanGeneration') !== me.scanGeneration
+        ) {
+            return false;
+        }
+        return values.vgmode === 'existing'
+            ? rec.get('usage') === 'lvm' && values.vgname === rec.get('vgname')
+            : values.vgmode === 'create' &&
+                  rec.get('usage') === 'unused' &&
+                  !!rec.get('by-id') &&
+                  values.device === rec.get('by-id');
+    },
+
+    updateEmptyText: function () {
+        let me = this;
+        let text = Ext.String.format(
+            gettext(
+                'No Fibre Channel devices found on node "{0}". Check zoning and array access, and rescan the host bus adapters on the node. Then press Rescan.',
+            ),
+            Ext.htmlEncode(me.up('window').getScanNode()),
+        );
+        if (!me.down('#showAllDevices').getValue()) {
+            text +=
+                ' ' +
+                gettext(
+                    '"Show all devices" also lists local devices and devices with an unknown transport.',
+                );
+        }
+        me.down('#lunGrid').setEmptyText(text);
+    },
+
+    updatePrivilegeNote: function () {
+        let me = this;
+        let note = me.down('#privilegeNote');
+        if (!note || me.canModify) {
+            return;
+        }
+        let rows = me.down('#lunGrid').getStore().getRange();
+        let stuck =
+            me.scanDone &&
+            !rows.some((rec) => rec.get('selectable')) &&
+            rows.some((rec) => rec.get('usage') === 'unused' && !rec.get('reason'));
+        let text = gettext(
+            'Without the Sys.Modify privilege on "/", only LUNs with an existing volume group can be selected.',
+        );
+        note.setValue(
+            stuck
+                ? `<i class="fa fa-exclamation-triangle warning"></i> ${text}`
+                : PVE.Utils.note_text(text),
+        );
+        note[stuck ? 'addCls' : 'removeCls']('pmx-hint');
+    },
+
+    refreshLuns: function () {
+        let me = this;
+        if (!me.rendered || me.destroyed) {
+            return;
+        }
+        me.updateEmptyText();
+        let wizard = me.up('window');
+        let node = wizard.getScanNode();
+        let generation = (me.scanGeneration = (me.scanGeneration ?? 0) + 1);
+        let current = () =>
+            !me.destroyed && generation === me.scanGeneration && node === wizard.getScanNode();
+        let grid = me.down('#lunGrid');
+        me.scanning = true;
+        me.scanDone = false;
+        grid.getSelectionModel().deselectAll();
+        me.selection = undefined;
+        me.lunScan = undefined;
+        wizard.getViewModel().set({ vgmode: '', device: '', vgname: '' });
+        grid.getStore().setData([]);
+        Proxmox.Utils.setErrorMask(grid, false);
+        grid.setLoading(true);
+        wizard.updateActions();
+        // the grid and the cluster-wide check share the raw local scan within this load
+        let scans = new Map();
+        let request = function (options) {
+            let scan = scans.get(options.url);
+            if (scan) {
+                if (scan.outcome) {
+                    options[scan.outcome](scan.response);
+                } else {
+                    scan.waiting.push(options);
+                }
+                return;
+            }
+            scan = { waiting: [options] };
+            scans.set(options.url, scan);
+            let finish = function (outcome, response) {
+                scan.outcome = outcome;
+                scan.response = response;
+                scan.waiting.forEach((listener) => listener[outcome](response));
+                scan.waiting = [];
+            };
+            Proxmox.Utils.API2Request({
+                ...options,
+                failure: (response) => finish('failure', response),
+                success: (response) => finish('success', response),
+            });
+        };
+        request({
+            url: `/nodes/${node}/scan/san-luns`,
+            method: 'GET',
+            timeout: 20000,
+            failure: function (response) {
+                if (!current()) {
+                    return;
+                }
+                me.scanning = false;
+                grid.setLoading(false);
+                Proxmox.Utils.setErrorMask(grid, response.htmlStatus);
+                me.updatePrivilegeNote();
+                wizard.updateActions();
+            },
+            success: function (response) {
+                if (!current()) {
+                    return;
+                }
+                grid.getStore().setData(
+                    (response.result.data || []).map((row) => ({
+                        ...row,
+                        scanNode: node,
+                        scanGeneration: generation,
+                    })),
+                );
+                me.scanning = false;
+                me.scanDone = true;
+                grid.setLoading(false);
+                me.updatePrivilegeNote();
+                wizard.updateActions();
+            },
+        });
+        if (!PVE.Utils.isStandaloneNode()) {
+            PVE.SANUtils.scan_nodes_for_luns(function (result) {
+                if (!current()) {
+                    return;
+                }
+                me.lunScan = result;
+                me.updateNodes();
+            }, request);
+        }
+    },
+
+    initComponent: function () {
+        let me = this;
+
+        let sanTransports = { fc: 1, 'nvme-fc': 1 };
+
+        // these need an initiator connection, which this storage type does not set up on other
+        // nodes
+        let remoteTransports = {
+            fcoe: 1,
+            iscsi: 1,
+            'nvme-tcp': 1,
+            'nvme-rdma': 1,
+            'nvme-loop': 1,
+        };
+
+        // a device on these is attached to this node only; for SAS or an unknown transport the scan
+        // cannot tell whether other nodes reach it
+        let localTransports = { ata: 1, mmc: 1, nvme: 1, sata: 1, sbp: 1, usb: 1, virtio: 1 };
+
+        let sanTransportFilter = new Ext.util.Filter({
+            id: 'san-transport',
+            filterFn: (rec) => !!sanTransports[rec.get('transport')],
+        });
+
+        let remoteTransportFilter = new Ext.util.Filter({
+            id: 'remote-transport',
+            filterFn: (rec) => !remoteTransports[rec.get('transport')],
+        });
+
+        // creating the volume group needs Sys.Modify on /
+        let caps = Ext.state.Manager.get('GuiCap');
+        let canModify = !!caps.dc['Sys.Modify'];
+        me.canModify = canModify;
+
+        let notOffered = function (data) {
+            if (!sanTransports[data.transport]) {
+                return localTransports[data.transport]
+                    ? gettext('Not offered: local device')
+                    : gettext('Not offered: shared access cannot be verified for this transport');
+            }
+            // a kernel name may point to another device by the time the volume group is created
+            if (data.usage === 'unused' && !data['used-by-storage'] && !data['by-id']) {
+                return gettext('The LUN has no stable device name for creating a volume group.');
+            }
+            return undefined;
+        };
+
+        let columns = me.lunColumns();
+        let store = Ext.create('Ext.data.Store', {
+            fields: [
+                ...PVE.SANUtils.lun_fields,
+                'scanNode',
+                'scanGeneration',
+                'transport',
+                'serial',
+                'wwn',
+                'wwid',
+                'iscsi-lun',
+                {
+                    name: 'reason',
+                    calculate: notOffered,
+                },
+                {
+                    name: 'selectable',
+                    calculate: function (data) {
+                        if (notOffered(data) || data['used-by-storage']) {
+                            return false;
+                        }
+                        if (data.usage === 'unused') {
+                            return canModify;
+                        }
+                        return data.usage === 'lvm' && !!data.vgname;
+                    },
+                },
+            ],
+            filters: [remoteTransportFilter, sanTransportFilter],
+            sorters: [
+                {
+                    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 wizard = me.up('window');
+            me.selection = rec ? { lun: rec.data } : undefined;
+            let vm = wizard.getViewModel();
+            if (!rec || !rec.get('selectable')) {
+                vm.set({ vgmode: '', device: '', vgname: '' });
+            } else if (rec.get('usage') === 'unused') {
+                vm.set({ vgmode: 'create', device: rec.get('by-id'), vgname: '' });
+            } else {
+                vm.set({ vgmode: 'existing', device: '', vgname: rec.get('vgname') });
+            }
+            me.updateNodes();
+        };
+
+        me.items = [
+            {
+                xtype: 'displayfield',
+                value: gettext('Select the SAN LUN for the volume group:'),
+            },
+            {
+                xtype: 'grid',
+                itemId: 'lunGrid',
+                height: 220,
+                store: store,
+                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.SANUtils.render_san_lun_device,
+                    },
+                    {
+                        header: gettext('Transport'),
+                        dataIndex: 'transport',
+                        width: 80,
+                        renderer: PVE.SANUtils.render_san_transport,
+                    },
+                    columns.size,
+                    columns.model,
+                    {
+                        header: gettext('Usage'),
+                        dataIndex: 'usage',
+                        flex: 2,
+                        renderer: function (value, metaData, rec) {
+                            let reason = rec.get('reason');
+                            if (!reason) {
+                                return PVE.SANUtils.render_san_lun_usage(value, metaData, rec);
+                            }
+                            let text = Ext.htmlEncode(reason);
+                            metaData.tdAttr = `data-qtip="${Ext.htmlEncode(text)}"`;
+                            return text;
+                        },
+                    },
+                ],
+            },
+            {
+                xtype: 'displayfield',
+                itemId: 'privilegeNote',
+                hidden: canModify,
+            },
+            {
+                xtype: 'container',
+                layout: 'hbox',
+                items: [
+                    {
+                        xtype: 'button',
+                        itemId: 'rescan',
+                        text: gettext('Rescan'),
+                        iconCls: 'fa fa-search',
+                        handler: () => me.refreshLuns(),
+                    },
+                ],
+            },
+            {
+                xtype: 'proxmoxcheckbox',
+                itemId: 'showAllDevices',
+                boxLabel: gettext('Show all devices'),
+                submitValue: false,
+                autoEl: {
+                    tag: 'div',
+                    'data-qtip': gettext('Also list devices with local or unknown transports.'),
+                },
+                listeners: {
+                    change: function (f, value) {
+                        me.updateEmptyText();
+                        let filters = store.getFilters();
+                        if (value) {
+                            filters.remove(sanTransportFilter);
+                        } else {
+                            filters.add(sanTransportFilter);
+                            let sm = me.down('#lunGrid').getSelectionModel();
+                            let selected = sm.getSelection()[0];
+                            if (selected && store.indexOf(selected) < 0) {
+                                sm.deselectAll();
+                            }
+                        }
+                        me.updatePrivilegeNote();
+                    },
+                },
+            },
+            {
+                xtype: 'textfield',
+                name: 'vgmode',
+                hidden: true,
+                allowBlank: false,
+                bind: { value: '{vgmode}' },
+            },
+            {
+                xtype: 'textfield',
+                name: 'device',
+                hidden: true,
+                allowBlank: true,
+                bind: { value: '{device}' },
+            },
+        ];
+
+        if (!PVE.Utils.isStandaloneNode()) {
+            me.items.unshift({
+                xtype: 'pveStorageScanNodeSelector',
+                listeners: {
+                    change: function () {
+                        me.refreshLuns();
+                    },
+                },
+            });
+        }
+
+        // a step is created before its window
+        me.on(
+            'afterrender',
+            function () {
+                me.mon(me.up('window'), 'setupchange', me.updateActions, me);
+                me.refreshLuns();
+            },
+            me,
+            { single: true },
+        );
+        me.callParent();
+        me.updatePrivilegeNote();
+    },
+});
+
+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'),
+            },
+        ],
+    },
+
+    canSubmit: function (wizard) {
+        return !!wizard.getValues().vgmode;
+    },
+
+    summaryRows: function (values, wizard) {
+        let step = wizard.down('pveStorageWizardFCLunSelect');
+        let { lun = {} } = step.selection ?? {};
+        let rows = [];
+        if (values.vgmode === 'create') {
+            rows.push({
+                label: gettext('Volume group'),
+                value: Ext.String.format(
+                    gettext('Create "{0}" on {1} ({2})'),
+                    values.vgname || values.storage,
+                    values.device,
+                    Proxmox.Utils.format_size(lun.size),
+                ),
+                weight: 20,
+            });
+        } else {
+            rows.push({
+                label: gettext('Volume group'),
+                value: Ext.String.format(gettext('Use existing "{0}"'), values.vgname),
+                weight: 20,
+            });
+        }
+        let unchecked = (step.lunScan?.unchecked ?? []).filter((node) =>
+            []
+                .concat(values.nodes ?? [])
+                .join(',')
+                .split(',')
+                .includes(node),
+        );
+        if (unchecked.length) {
+            rows.push({
+                label: gettext('Not checked'),
+                value: Ext.String.format(
+                    gettext('Could not check whether these nodes see the LUN: {0}.'),
+                    unchecked.join(', '),
+                ),
+                weight: 33,
+            });
+        }
+        return { rows, skip: ['vgmode', 'vgname', 'device'] };
+    },
+
+    submit: function (wizard, values) {
+        let step = wizard.down('pveStorageWizardFCLunSelect');
+        if (!step.currentSelection(values)) {
+            Ext.Msg.alert(
+                gettext('Error'),
+                gettext('Select a LUN from the current scan before continuing.'),
+            );
+            return;
+        }
+        let enc = Ext.htmlEncode;
+        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 created = (wizard.created ??= {});
+        let node = wizard.getScanNode();
+        let device = values.device;
+
+        let vgCreatedHint = () =>
+            Ext.String.format(
+                gettext(
+                    'The volume group "{0}" was created on node "{1}". Press {2} to retry the remaining steps, or complete the setup under Datacenter > Storage > Add > LVM.',
+                ),
+                enc(params.vgname),
+                enc(node),
+                enc(wizard.down('#submit').text),
+            );
+
+        let volumeGroupFailure = function (msg, final) {
+            if (final) {
+                delete created.task;
+                wizard.unfreeze();
+            } else {
+                msg +=
+                    '<br><br>' +
+                    Ext.String.format(
+                        gettext(
+                            'If the task outcome stays unknown, check whether the volume group "{0}" exists on node "{1}" and complete the setup under Datacenter > Storage > Add > LVM.',
+                        ),
+                        enc(params.vgname),
+                        enc(node),
+                    );
+            }
+            wizard.updateActions();
+            Ext.Msg.alert(gettext('Error'), msg);
+        };
+        let unknownVolumeGroup = function () {
+            wizard.unknownVolumeGroup = true;
+            wizard.freeze();
+            wizard.showOperationNotice(
+                Ext.String.format(
+                    gettext(
+                        'The node did not answer in time, so a volume group task may still be running on node "{0}". Check its task history. Once the task is done, run the wizard again; it offers the volume group "{1}" if the task created it.',
+                    ),
+                    enc(node),
+                    enc(params.vgname),
+                ),
+            );
+            wizard.updateActions();
+        };
+        let waitForVolumeGroup = function (next) {
+            wizard.waitForTask(node, created.task, {
+                msg: gettext('Creating the volume group...'),
+                texts: {
+                    running: Ext.String.format(
+                        gettext(
+                            'The task on node "{0}" is still running. Check its task log, or press {1} to wait for it again. If it creates volume group "{2}", you can select that group when you run the wizard again.',
+                        ),
+                        enc(node),
+                        enc(wizard.down('#submit').text),
+                        enc(params.vgname),
+                    ),
+                },
+                success: function () {
+                    created.volumeGroup = params.vgname;
+                    delete created.task;
+                    next();
+                },
+                failure: volumeGroupFailure,
+            });
+        };
+
+        wizard.runSubmitChain([
+            (next) => {
+                if (created.task || created.volumeGroup) {
+                    next();
+                    return;
+                }
+                wizard.assertStorageIdsFree([params.storage], next);
+            },
+            (next) => {
+                if (created.volumeGroup) {
+                    next();
+                    return;
+                }
+                // a task started earlier is still the one to wait for, a second create would
+                // race it on the same device
+                if (created.task) {
+                    waitForVolumeGroup(next);
+                    return;
+                }
+                wizard.request({
+                    url: `/nodes/${node}/disks/lvm`,
+                    method: 'POST',
+                    params: {
+                        name: params.vgname,
+                        device: device,
+                    },
+                    failure: function (response) {
+                        if (PVE.SANUtils.request_uncertain(response)) {
+                            unknownVolumeGroup();
+                        } else {
+                            volumeGroupFailure(response.htmlStatus, true);
+                        }
+                    },
+                    success: function (response) {
+                        try {
+                            Proxmox.Utils.parse_task_upid(response.result.data);
+                        } catch (_error) {
+                            unknownVolumeGroup();
+                            return;
+                        }
+                        created.task = response.result.data;
+                        wizard.freeze();
+                        waitForVolumeGroup(next);
+                    },
+                });
+            },
+            (next) => wizard.createStorage(params, next, vgCreatedHint()),
+        ]);
+    },
+};
diff --git a/www/manager6/storage/wizard/LunPicker.js b/www/manager6/storage/wizard/LunPicker.js
new file mode 100644
index 000000000..cb3eba7b3
--- /dev/null
+++ b/www/manager6/storage/wizard/LunPicker.js
@@ -0,0 +1,25 @@
+Ext.define('PVE.storage.wizard.LunPicker', {
+    extend: 'Ext.Mixin',
+
+    mixinConfig: {
+        id: 'lunPicker',
+    },
+
+    lunColumns: function () {
+        return {
+            size: {
+                header: gettext('Size'),
+                dataIndex: 'size',
+                width: 90,
+                renderer: Proxmox.Utils.render_size,
+            },
+            model: {
+                header: gettext('Vendor') + '/' + gettext('Model'),
+                dataIndex: 'model',
+                flex: 2,
+                renderer: (value, metaData, rec) =>
+                    Ext.htmlEncode(`${rec.get('vendor') || ''} ${value || ''}`.trim()),
+            },
+        };
+    },
+});
diff --git a/www/manager6/storage/wizard/Wizard.js b/www/manager6/storage/wizard/Wizard.js
index ef3e505eb..b4978d296 100644
--- a/www/manager6/storage/wizard/Wizard.js
+++ b/www/manager6/storage/wizard/Wizard.js
@@ -82,6 +82,8 @@ Ext.define('PVE.storage.wizard.RemoteStorage', {
 
     busy: false,
 
+    unknownVolumeGroup: false,
+
     // the continuation lives in this window: block closing and the buttons while waiting, then
     // restore them
     setBusy: function (busy, msg) {
@@ -103,6 +105,42 @@ Ext.define('PVE.storage.wizard.RemoteStorage', {
                 me.down('#back').setDisabled(true);
             }
         }
+        me.updateActions();
+    },
+
+    canSubmit: function () {
+        let me = this;
+        if (me.busy || me.unknownVolumeGroup) {
+            return false;
+        }
+        let canSubmit = PVE.storage.wizard.types[me.wizardType].canSubmit;
+        if (canSubmit) {
+            return canSubmit(me);
+        }
+        return true;
+    },
+
+    actionText: function () {
+        return gettext('Finish');
+    },
+
+    updateActions: function () {
+        let me = this;
+        if (!me.down('#submit')) {
+            return;
+        }
+        me.down('#submit').setText(me.actionText());
+        // a type with its own submit condition decides the state of the confirm buttons
+        let confirmActive = me.down('#wizcontent').getActiveTab() === me.down('#confirm');
+        if (PVE.storage.wizard.types[me.wizardType].canSubmit && confirmActive) {
+            me.down('#submit').setDisabled(!me.canSubmit());
+            me.down('#back').setDisabled(!!me.frozenValues || me.busy);
+        }
+        me.fireEvent('setupchange', me);
+        // after the listeners, so no step can enable an action again
+        if (me.busy || me.unknownVolumeGroup) {
+            ['#back', '#next', '#submit'].forEach((id) => me.down(id).disable());
+        }
     },
 
     // keeps the window busy, so it cannot close with the continuation pending
@@ -150,13 +188,117 @@ Ext.define('PVE.storage.wizard.RemoteStorage', {
         me.down('#back').setDisabled(true);
     },
 
+    unfreeze: function () {
+        let me = this;
+        if (!me.frozenValues) {
+            return;
+        }
+        delete me.frozenValues;
+        me.down('#wizcontent').items.each((tab) => tab.enable());
+        me.down('#back').setDisabled(false);
+    },
+
     getValues: function (dirtyOnly) {
         let me = this;
         return me.frozenValues ? Ext.clone(me.frozenValues) : me.callParent([dirtyOnly]);
     },
 
-    // a step calls next() on success and just returns after a failure, so Finish retries from
-    // there
+    // Wait without a TaskProgress window, as closing it would drop the continuation. An unreachable
+    // task status is not final: the next submit resumes waiting.
+    waitForTask: function (node, upid, options) {
+        let me = this;
+        let transportFailures = 0;
+        let deadline = Date.now() + 180000;
+        let ended = false;
+        let lastRunning = 0;
+        let unknown =
+            options.texts?.unknown ??
+            Ext.String.format(
+                gettext('The task outcome is unknown. Press {0} to wait for it again.'),
+                Ext.htmlEncode(me.actionText()),
+            );
+        let timer;
+        let fail = function (msg, final) {
+            if (me.destroyed || ended) {
+                return;
+            }
+            ended = true;
+            clearTimeout(timer);
+            me.setBusy(false);
+            options.failure(msg, final);
+        };
+        timer = Ext.defer(
+            () =>
+                fail(
+                    lastRunning && Date.now() - lastRunning < 3000
+                        ? (options.texts?.running ?? unknown)
+                        : unknown,
+                    false,
+                ),
+            180000,
+        );
+        let poll = function () {
+            if (me.destroyed || ended) {
+                return;
+            }
+            if (Date.now() >= deadline) {
+                fail(unknown, false);
+                return;
+            }
+            Proxmox.Utils.API2Request({
+                url: `/nodes/${node}/tasks/${encodeURIComponent(upid)}/status`,
+                method: 'GET',
+                timeout: Math.max(1, Math.min(30000, deadline - Date.now())),
+                failure: function (response) {
+                    if (me.destroyed || ended) {
+                        return;
+                    }
+                    lastRunning = 0;
+                    if (++transportFailures <= 10 && Date.now() < deadline) {
+                        Ext.defer(poll, 3000);
+                        return;
+                    }
+                    fail(`${unknown}<br><br>${response.htmlStatus}`, false);
+                },
+                success: function (response) {
+                    if (me.destroyed || ended) {
+                        return;
+                    }
+                    transportFailures = 0;
+                    let task = response.result.data;
+                    if (task?.status === 'running') {
+                        lastRunning = Date.now();
+                        if (Date.now() >= deadline) {
+                            fail(options.texts?.running ?? unknown, false);
+                            return;
+                        }
+                        Ext.defer(poll, 1000);
+                        return;
+                    }
+                    let outcome =
+                        task?.status === 'stopped' && task.exitstatus
+                            ? Proxmox.Utils.parse_task_status(task.exitstatus)
+                            : 'unknown';
+                    if (outcome === 'unknown') {
+                        fail(unknown, false);
+                    } else if (outcome === 'error') {
+                        fail(Ext.htmlEncode(task.exitstatus), true);
+                    } else {
+                        // a task that finished with warnings did its job
+                        ended = true;
+                        clearTimeout(timer);
+                        me.setBusy(false);
+                        options.success();
+                    }
+                },
+            });
+        };
+        me.setBusy(true, options.msg);
+        poll();
+    },
+
+    // a step calls next() on success and just returns after a failure, so the next submit retries
+    // from there
     runSubmitChain: function (steps) {
         let me = this;
         let run = function (idx) {
@@ -207,6 +349,14 @@ Ext.define('PVE.storage.wizard.RemoteStorage', {
         });
     },
 
+    showOperationNotice: function (text) {
+        let notice = this.down('#operationNotice');
+        notice.setValue(text ? `<i class="fa fa-exclamation-triangle warning"></i> ${text}` : '');
+        notice.setHidden(!text);
+        this.down('#summaryNotes').setHidden(!!text);
+        this.updateActions();
+    },
+
     // errorHtml (safe HTML) is prepended to an error
     createStorage: function (params, next, errorHtml) {
         let me = this;
@@ -317,6 +467,7 @@ Ext.define('PVE.storage.wizard.RemoteStorage', {
         for (const note of typeEntry.summaryNotes?.(values, me) || []) {
             notes.add({ value: note });
         }
+        me.updateActions();
     },
 
     submitSimple: function (values) {
@@ -422,6 +573,11 @@ Ext.define('PVE.storage.wizard.RemoteStorage', {
                             },
                         ],
                     },
+                    {
+                        xtype: 'displayfield',
+                        itemId: 'operationNotice',
+                        hidden: true,
+                    },
                     {
                         xtype: 'container',
                         itemId: 'summaryNotes',
@@ -437,6 +593,9 @@ Ext.define('PVE.storage.wizard.RemoteStorage', {
                 },
                 onSubmit: function () {
                     let wizard = this.up('window');
+                    if (!wizard.canSubmit()) {
+                        return;
+                    }
                     let values = wizard.getValues();
                     delete values.delete;
                     let typeEntry = PVE.storage.wizard.types[wizard.wizardType];
@@ -467,5 +626,11 @@ Ext.define('PVE.storage.wizard.RemoteStorage', {
         });
 
         me.callParent();
+        me.query('field').forEach((field) => {
+            field.on('change', () => me.updateActions());
+            field.on('validitychange', () => me.updateActions());
+        });
+        me.down('#wizcontent').on('tabchange', () => me.updateActions());
+        me.down('#wizcontent').on('beforetabchange', () => !me.busy && !me.frozenValues);
     },
 });
-- 
2.47.3




  parent reply	other threads:[~2026-11-06  1:01 UTC|newest]

Thread overview: 77+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-11-05 23:30 [PATCH v3 proxmox,proxmox-perl-rs,cluster,storage,docs,manager 00/76] SAN: cluster-wide multipath, SAN views and a guided storage setup Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 proxmox 01/76] add proxmox-multipath crate Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 proxmox-perl-rs 02/76] pve-rs: add PVE::RS::Multipath bindings for multipathd status parsing Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 cluster 03/76] cfs: observe the multipath configuration files Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 04/76] storage: distinguish status-only activation in plugins Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 05/76] lvm: find a shared volume group created by another node Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 06/76] storage: bound LVM status commands Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 07/76] iscsi: rate-limit session rescans per target Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 08/76] iscsi: list the LUNs of each session, not of its SCSI host Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 09/76] lvm: refuse to create a volume group on a device that holds data Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 10/76] lvm: refuse a base volume of a target that serves disk images Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 11/76] iscsi: refuse virtual devices other than multipath maps as volumes Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 12/76] diskmanage: collect disk transport type from lsblk Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 13/76] diskmanage: add helper to list multipath devices Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 14/76] diskmanage: qualify NVMe over fabrics transport Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 15/76] disks: list: add include-remote parameter Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 16/76] diskmanage: include iSCSI session devices in disk enumeration Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 17/76] diskmanage: link multipath member disks to their map device Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 18/76] iscsi: factor out session device map from device list Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 19/76] api: scan: add a SAN LUN inventory Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 20/76] pvesm: expose the " Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 21/76] disks: lvm: allow volume groups on SAN LUNs and multipath maps Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 22/76] multipath: add a library around multipathd Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 23/76] diskmanage: collect native NVMe multipath path state Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 24/76] api: scan: san-luns: report " Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 25/76] diskmanage: collect local block device users Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 26/76] api: add node-level iSCSI status and maintenance Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 27/76] api: iscsi: add session logout with last-path protection Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 28/76] multipath: add a cluster-wide configuration Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 29/76] iscsi: add an option to manage storage LUNs with multipath Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 30/76] lvm: activate a missing base volume before using the volume group Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 31/76] multipath: apply the configuration on every node Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 32/76] iscsi: share the initiator identity reader Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 33/76] multipath: publish per-node status Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 34/76] api: add endpoints for the cluster-wide multipath configuration Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 35/76] api: multipath: add the cluster-wide health status Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 36/76] multipath: collect identities of non-iSCSI SCSI paths Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 37/76] api: scan: san-luns: report the multipath state of each LUN Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 38/76] api: scan: flag duplicate physical volumes on distinct LUNs Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 39/76] api: disks: recheck local users and retain wipe device references Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 40/76] iscsi: name plain LUNs after their identity link Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 41/76] api: disks: guard SAN wipes with stable identities and storage checks Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 42/76] api: scan: refresh SAN usage with bounded probes Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 docs 43/76] storage: lvm: note the rescan after extending a shared volume group Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 docs 44/76] storage: document the cluster-wide multipath management Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 45/76] api: ceph: osd: keep iSCSI LUNs out of OSD creation Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 46/76] pvestatd: track pending storage status commands across re-exec Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 47/76] ui: storage: factor out the scan picker error mask Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 48/76] ui: storage: nfs: show scan errors and empty results Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 49/76] ui: storage: allow changing the node for NFS/CIFS scans Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 50/76] ui: storage: add guided remote storage wizard with NFS support Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 51/76] ui: storage wizard: add SMB/CIFS support Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 52/76] ui: storage wizard: preserve settings after partial setup Thomas Lamprecht
2026-11-05 23:31 ` Thomas Lamprecht [this message]
2026-11-05 23:31 ` [PATCH v3 manager 54/76] ui: storage wizard: reconcile unanswered storage creation Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 55/76] ui: storage wizard: add ZFS over iSCSI support Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 56/76] ui: dc: storage: add remote storage wizard entry to the add menu Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 57/76] ui: node: add SAN LUNs panel Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 58/76] ui: san luns: show multipath path state Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 59/76] api: nodes: expose iSCSI status and maintenance Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 60/76] pvenode: add iscsi commands Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 61/76] ui: node: san: add iSCSI target and session management Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 62/76] pvestatd: apply the multipath configuration and report the node status Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 63/76] api: cluster: add the multipath endpoints Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 64/76] ui: dc: add a cluster-wide multipath view Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 65/76] ui: storage: iscsi: add the multipath option Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 66/76] ui: san luns: show multipath state and manage LUN entries Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 67/76] ui: storage wizard: add iSCSI support Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 68/76] ui: storage wizard: iscsi: show cluster initiator names Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 69/76] ui: storage wizard: iscsi: enable multipath for shared LVM Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 70/76] ui: node: san: wipe a LUN that no storage uses Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 71/76] ui: storage wizard: iscsi: reuse existing target connections Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 72/76] ui: storage wizard: fibre channel: enable multipath for a LUN Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 73/76] ui: storage wizard: let root@pam wipe a LUN with data Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 74/76] ui: storage wizard: do not offer LUNs in use for a new volume group Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 75/76] ui: san: show the local users of a LUN in its usage tooltip Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 76/76] ui: san: check the other nodes for users before wiping a LUN Thomas Lamprecht

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=20261105233336.2178064-54-t.lamprecht@proxmox.com \
    --to=t.lamprecht@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