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 66/76] ui: san luns: show multipath state and manage LUN entries
Date: Fri,  6 Nov 2026 00:31:41 +0100	[thread overview]
Message-ID: <20261105233336.2178064-67-t.lamprecht@proxmox.com> (raw)
In-Reply-To: <20261105233336.2178064-1-t.lamprecht@proxmox.com>

Show each LUN's map state and paths with the appropriate entry or
storage action. Preselect nodes that can see the LUN and those whose
visibility could not be checked. Ask administrators to review the node
restriction. Share source rendering with the cluster view.

A failed entry read does not prove absence: check the list before
offering creation, preserve errors otherwise, and discard obsolete
callbacks. Explain that stopped multipathd means unknown health, not
necessarily stopped I/O, and that enabling multipath does not move
active logical volumes.

Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
 test/SANUtils_test.js                       |  36 ++
 www/manager6/SANUtils.js                    | 166 ++++++-
 www/manager6/dc/Multipath.js                |  14 +-
 www/manager6/dc/MultipathWwidEdit.js        |  50 +++
 www/manager6/node/SANLuns.js                | 459 ++++++++++++++++++--
 www/manager6/storage/wizard/FibreChannel.js |   5 +-
 www/manager6/storage/wizard/LunPicker.js    |   6 +
 7 files changed, 671 insertions(+), 65 deletions(-)

diff --git a/test/SANUtils_test.js b/test/SANUtils_test.js
index d22307664..b13d75166 100644
--- a/test/SANUtils_test.js
+++ b/test/SANUtils_test.js
@@ -10,6 +10,7 @@ function sanUtils(translations = {}) {
         PVE: {},
         gettext: (text) => translations[text] ?? text,
         Ext: {
+            apply: (target, source) => Object.assign(target, source),
             define: (name, config) => {
                 sandbox.PVE.SANUtils = config;
             },
@@ -52,3 +53,38 @@ test('cluster health of a LUN', () => {
     res = render('missing', { n1: { state: 'unknown' } }, { 'not-observed': 1 });
     assert.match(res.html, /> Not currently observed$/);
 });
+
+test('paths of a LUN without a map', () => {
+    const utils = sanUtils();
+    const path = (devpath, inUse, users = '') => ({
+        devpath,
+        wwid: '3600a0980383235',
+        multipath: 'none',
+        usage: 'unused',
+        'in-use': inUse,
+        'in-use-by': users,
+    });
+    const group = (...rows) => {
+        const res = utils.group_san_lun_paths(rows);
+        assert.equal(res.length, 1, 'one row per LUN');
+        return res[0];
+    };
+
+    let lun = group(path('/dev/sdb', 'no'), path('/dev/sdc', 'no'));
+    assert.equal(lun['in-use'], 'no');
+    assert.equal(lun['path-devices'].join(' '), '/dev/sdb /dev/sdc');
+
+    lun = group(path('/dev/sdb', 'no'), path('/dev/sdc', 'yes', 'dm-5'));
+    assert.equal(lun['in-use'], 'yes', 'a later path in use marks the LUN');
+    assert.equal(lun['in-use-by'], 'dm-5');
+
+    lun = group(path('/dev/sdb', 'yes', 'dm-5'), path('/dev/sdc', 'yes', 'dm-5, /mnt'));
+    assert.equal(lun['in-use-by'], 'dm-5, /mnt', 'users of all paths, each once');
+
+    lun = group(path('/dev/sdb', 'no'), path('/dev/sdc', 'unknown', 'check failed: timeout'));
+    assert.equal(lun['in-use'], 'unknown', 'unused only if every path was checked');
+    assert.equal(lun['in-use-by'], 'check failed: timeout');
+
+    lun = group(path('/dev/sdb', 'unknown', 'check failed'), path('/dev/sdc', 'yes', 'dm-5'));
+    assert.equal(lun['in-use'], 'yes', 'a known user wins over a failed check');
+});
diff --git a/www/manager6/SANUtils.js b/www/manager6/SANUtils.js
index ee75b5abb..4893cd562 100644
--- a/www/manager6/SANUtils.js
+++ b/www/manager6/SANUtils.js
@@ -8,11 +8,16 @@ Ext.define('PVE.SANUtils', {
         'model',
         'paths-total',
         'paths-active',
+        'paths-expected',
+        'path-devices',
         'usage',
         'used-by-storage',
         'vgname',
         'pv-in-use',
         'detail',
+        'multipath',
+        'multipath-storage',
+        'duplicate-pv',
         'by-id',
     ],
 
@@ -186,32 +191,67 @@ Ext.define('PVE.SANUtils', {
         return text;
     },
 
-    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) {
-            let active = rec.get('paths-active');
-            if (Ext.isNumeric(active) && active < paths) {
-                let qtip = Ext.htmlEncode(
-                    gettext('Some paths failed, the multipath device is degraded'),
-                );
-                text =
-                    `<i data-qtip="${qtip}" class="fa fa-exclamation-triangle warning"></i> ` +
-                    `${text} (${active}/${paths} ${gettext('paths')})`;
-            } else {
-                text += ` (${paths} ${ngettext('path', 'paths', paths)})`;
+    // the scan lists a LUN without a multipath map once per path, fold those rows by WWID
+    group_san_lun_paths: function (rows) {
+        let groups = {};
+        let res = [];
+        for (const row of rows) {
+            let unassembled = row.multipath === 'none' || row.multipath === 'pending';
+            if (!unassembled || !row.wwid) {
+                res.push(row);
+                continue;
             }
+            let group = groups[row.wwid];
+            if (!group) {
+                group = Ext.apply({}, row);
+                group['path-devices'] = [];
+                groups[row.wwid] = group;
+                res.push(group);
+            }
+            // Every path must prove orphan status; one path reporting membership wins over
+            // missing or contradictory proof on another path.
+            let proof = [group['pv-in-use'], row['pv-in-use']];
+            group['pv-in-use'] = proof.some((value) => [1, '1', true].includes(value))
+                ? 1
+                : proof.every((value) => [0, '0', false].includes(value))
+                  ? 0
+                  : undefined;
+            if (row.usage === 'unknown' && group.usage === 'unused') {
+                group.usage = 'unknown';
+                group.detail = row.detail;
+            }
+            // the LUN is in use if one of its paths is, and unused only if all of them are
+            if (row['in-use'] === 'yes') {
+                let users = group['in-use'] === 'yes' ? group['in-use-by'].split(', ') : [];
+                for (const user of row['in-use-by'].split(', ')) {
+                    if (!users.includes(user)) {
+                        users.push(user);
+                    }
+                }
+                group['in-use'] = 'yes';
+                group['in-use-by'] = users.join(', ');
+            } else if (row['in-use'] !== 'no' && group['in-use'] === 'no') {
+                group['in-use'] = row['in-use'];
+                group['in-use-by'] = row['in-use-by'];
+            }
+            group['path-devices'].push(row.devpath);
+            // one allow-listed path means the map is on its way for the whole LUN
+            if (row.multipath === 'pending') {
+                group.multipath = 'pending';
+            }
+            group['paths-total'] = group['path-devices'].length;
         }
-        return text;
+        return res;
     },
 
-    // Unchecked nodes (offline, failed or timed out) may still see a LUN.
+    // Unchecked nodes (offline, failed or timed out) may still see a LUN. pathUsers misses process
+    // opens, so an empty list proves nothing.
     scan_nodes_for_luns: function (
         callback,
         request = (options) => Proxmox.Utils.API2Request(options),
     ) {
         let wwids = {};
+        let pathUsers = {};
         let unchecked = [];
         let nodes = PVE.data.ResourceStore.getNodes();
         let pending = nodes.length;
@@ -221,6 +261,11 @@ Ext.define('PVE.SANUtils', {
                     Object.keys(wwids)
                         .filter((node) => wwids[node].has(wwid))
                         .sort(),
+                pathUsers: (wwid) =>
+                    Object.keys(pathUsers)
+                        .sort()
+                        .filter((node) => pathUsers[node][wwid])
+                        .map((node) => ({ node, users: pathUsers[node][wwid].join(', ') })),
                 unchecked: unchecked.sort(),
             });
         };
@@ -255,6 +300,17 @@ Ext.define('PVE.SANUtils', {
                         unchecked.push(node.node);
                     }
                     wwids[node.node] = new Set(rows.map((row) => row.wwid).filter((w) => w));
+                    for (const row of rows) {
+                        let onPath = ['none', 'pending'].includes(row.multipath);
+                        if (!row.wwid || !onPath || !['yes', 'unknown'].includes(row['in-use'])) {
+                            continue;
+                        }
+                        let users = ((pathUsers[node.node] ??= {})[row.wwid] ??= []);
+                        users.push(
+                            row['in-use-by'] ||
+                                (row['in-use'] === 'unknown' ? gettext('unknown') : row.devpath),
+                        );
+                    }
                     done();
                 },
             });
@@ -276,6 +332,30 @@ Ext.define('PVE.SANUtils', {
         };
     },
 
+    render_san_lun_paths: function (value, metaData, rec) {
+        let expected = rec.get('paths-expected');
+        let paths = PVE.SANUtils.format_san_paths(
+            rec.get('paths-active'),
+            rec.get('paths-total'),
+            expected,
+        );
+        if (!paths) {
+            return '';
+        }
+        if (!Ext.isNumeric(expected)) {
+            metaData.tdAttr = `data-qtip="${Ext.htmlEncode(
+                gettext(
+                    'Only the paths found are counted; the expected number of paths is not known.',
+                ),
+            )}"`;
+        }
+        if (paths.degraded) {
+            let qtip = Ext.htmlEncode(gettext('Some paths are missing or have failed.'));
+            return `<i data-qtip="${qtip}" class="fa fa-exclamation-triangle warning"></i> ${paths.text}`;
+        }
+        return paths.text;
+    },
+
     request_uncertain: function (response) {
         return response.timedout || response.status === 0 || response.result?.success === undefined;
     },
@@ -285,9 +365,25 @@ Ext.define('PVE.SANUtils', {
         return Number(response?.result?.status) === 403;
     },
 
+    // returns { text, tip } as HTML
+    san_lun_usage: function (row) {
+        let text = PVE.SANUtils.san_lun_usage_text(row);
+        let tooltip = [text];
+        if (row['duplicate-pv']) {
+            let tip = gettext(
+                'Another LUN has an LVM physical volume with the same UUID. LVM refuses to activate or change its volume group.',
+            );
+            tooltip.push(Ext.htmlEncode(tip));
+            text +=
+                ` <i class="fa fa-exclamation-triangle warning" data-qtip="${Ext.htmlEncode(tip)}"></i> ` +
+                gettext('duplicate PV');
+        }
+        return { text, tip: tooltip.join('<br>') };
+    },
+
     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)}"`;
+        let { text, tip } = PVE.SANUtils.san_lun_usage(rec.data);
+        metaData.tdAttr = `data-qtip="${Ext.htmlEncode(tip)}"`;
         return text;
     },
 
@@ -356,6 +452,38 @@ Ext.define('PVE.SANUtils', {
         return Ext.htmlEncode(row.detail || gettext('in use'));
     },
 
+    render_san_lun_multipath: function (value, metaData, rec) {
+        if (!value) {
+            return '';
+        }
+        if (value === 'storage') {
+            let sid = rec.get('multipath-storage');
+            return sid
+                ? Ext.htmlEncode(Ext.String.format(gettext('Storage "{0}"'), sid))
+                : gettext('Storage');
+        }
+        if (value === 'none') {
+            if (!(rec.get('paths-total') > 1)) {
+                return gettext('Single path');
+            }
+            let qtip = Ext.htmlEncode(
+                gettext(
+                    'The paths of this LUN are separate devices until multipath is enabled for it.',
+                ),
+            );
+            return `<i data-qtip="${qtip}" class="fa fa-exclamation-triangle warning"></i> ${gettext('Not enabled')}`;
+        }
+
+        return (
+            {
+                pending: gettext('Not assembled yet'),
+                manual: gettext('Explicit entry'),
+                kept: gettext('Kept while in use'),
+                unmanaged: gettext('Unmanaged'),
+            }[value] ?? Ext.htmlEncode(value)
+        );
+    },
+
     render_san_transport: function (value) {
         if (!value) {
             return '';
diff --git a/www/manager6/dc/Multipath.js b/www/manager6/dc/Multipath.js
index 9646b9d72..75ea8d30d 100644
--- a/www/manager6/dc/Multipath.js
+++ b/www/manager6/dc/Multipath.js
@@ -407,19 +407,7 @@ Ext.define('PVE.dc.MultipathView', {
             dataIndex: 'source',
             stateId: 'source',
             width: 160,
-            renderer: function (value, metaData, rec) {
-                if (value === 'manual') {
-                    return gettext('Explicit entry');
-                } else if (value === 'kept') {
-                    return gettext('Kept while in use');
-                } else if (value !== 'storage') {
-                    return '';
-                }
-                let storeid = rec.data['multipath-storage'];
-                return storeid
-                    ? Ext.htmlEncode(Ext.String.format(gettext('Storage "{0}"'), storeid))
-                    : gettext('Storage');
-            },
+            renderer: PVE.SANUtils.render_san_lun_multipath,
         },
         {
             text: gettext('Used By'),
diff --git a/www/manager6/dc/MultipathWwidEdit.js b/www/manager6/dc/MultipathWwidEdit.js
index 68a598596..3a6491454 100644
--- a/www/manager6/dc/MultipathWwidEdit.js
+++ b/www/manager6/dc/MultipathWwidEdit.js
@@ -12,7 +12,43 @@ Ext.define('PVE.dc.MultipathWwidEdit', {
     wwid: undefined,
     nodes: undefined,
 
+    // nodes to add to the restriction of an existing entry once it is loaded
+    addNodes: undefined,
+
     hint: undefined,
+    activationWarning: undefined,
+
+    statics: {
+        // The entry list tells a failed entry request apart from a missing entry. If the list
+        // request fails too, failure(original, list) gets both. current() guards each continuation.
+        lookup: function (wwid, callbacks) {
+            let { found, missing, failure } = callbacks;
+            let request = callbacks.request ?? ((options) => Proxmox.Utils.API2Request(options));
+            let current = callbacks.current ?? (() => true);
+            request({
+                url: `/cluster/multipath/wwid/${encodeURIComponent(wwid)}`,
+                method: 'GET',
+                failure: (original) =>
+                    current() &&
+                    request({
+                        url: '/cluster/multipath/wwid',
+                        method: 'GET',
+                        failure: (list) => current() && failure(original, list),
+                        success: function (response) {
+                            if (!current()) {
+                                return;
+                            }
+                            if ((response.result.data || []).some((entry) => entry.wwid === wwid)) {
+                                failure(original);
+                            } else {
+                                missing();
+                            }
+                        },
+                    }),
+                success: (response) => current() && found(response.result.data),
+            });
+        },
+    },
 
     initComponent: function () {
         let me = this;
@@ -73,6 +109,14 @@ Ext.define('PVE.dc.MultipathWwidEdit', {
             });
         }
 
+        if (me.activationWarning) {
+            items.push({
+                xtype: 'displayfield',
+                userCls: 'pmx-hint',
+                value: me.activationWarning,
+            });
+        }
+
         let advancedItems = [
             {
                 xtype: 'proxmoxtextfield',
@@ -174,6 +218,12 @@ Ext.define('PVE.dc.MultipathWwidEdit', {
                     let values = response.result.data;
                     values.nodes = values.nodes ? values.nodes.split(',') : [];
                     me.setValues(values);
+                    // set after loading so the form sees a change; an unrestricted entry covers
+                    // every node
+                    let added = (me.addNodes ?? []).filter((node) => !values.nodes.includes(node));
+                    if (values.nodes.length && added.length) {
+                        me.down('field[name=nodes]').setValue([...values.nodes, ...added]);
+                    }
                 },
             };
         }
diff --git a/www/manager6/node/SANLuns.js b/www/manager6/node/SANLuns.js
index fe30125d8..01c21f2b7 100644
--- a/www/manager6/node/SANLuns.js
+++ b/www/manager6/node/SANLuns.js
@@ -2,11 +2,11 @@ Ext.define('PVE.node.SANLunList', {
     extend: 'Ext.grid.GridPanel',
     xtype: 'pveSANLunList',
 
+    onlineHelp: 'storage_multipath',
+
     stateful: true,
     stateId: 'grid-node-san-luns',
 
-    disableSelection: true,
-
     emptyDeviceText: function (showAll) {
         let text = Ext.String.format(
             gettext(
@@ -24,10 +24,6 @@ Ext.define('PVE.node.SANLunList', {
         return text;
     },
 
-    viewConfig: {
-        trackOver: false,
-    },
-
     plugins: [
         {
             ptype: 'rowexpander',
@@ -39,17 +35,26 @@ Ext.define('PVE.node.SANLunList', {
                     renderDetails: function (values) {
                         let enc = Ext.htmlEncode;
                         let lines = [];
-                        if (values.wwn) {
-                            lines.push(`<b>WWN:</b> ${enc(values.wwn)}`);
+                        let line = (label, value) => lines.push(`<b>${label}:</b> ${enc(value)}`);
+                        if (values.wwid) {
+                            line('WWID', values.wwid);
+                        }
+                        // the WWID of a map is the WWN of the LUN
+                        if (values.wwn && values.wwn !== values.wwid) {
+                            line('WWN', values.wwn);
                         }
                         if (values.serial) {
-                            lines.push(`<b>${gettext('Serial')}:</b> ${enc(values.serial)}`);
+                            line(gettext('Serial'), values.serial);
+                        }
+                        if (values['iscsi-target']) {
+                            line(gettext('Target'), values['iscsi-target']);
+                        }
+                        if (values['path-devices']) {
+                            line(gettext('Devices'), values['path-devices'].join(', '));
                         }
                         let faults = values['path-faults'];
                         if (Ext.isNumeric(faults)) {
-                            lines.push(
-                                `<b>${gettext('Path Failures Since Map Creation')}:</b> ${enc(faults)}`,
-                            );
+                            line(gettext('Path Failures Since Map Creation'), faults);
                         }
                         let paths = values['path-list'] || [];
                         if (paths.length) {
@@ -100,7 +105,19 @@ Ext.define('PVE.node.SANLunList', {
             header: gettext('Device'),
             dataIndex: 'devpath',
             flex: 2,
-            renderer: PVE.SANUtils.render_san_lun_device,
+            renderer: Ext.htmlEncode,
+        },
+        {
+            header: gettext('Paths'),
+            dataIndex: 'paths-total',
+            width: 80,
+            renderer: PVE.SANUtils.render_san_lun_paths,
+        },
+        {
+            // the number the storage system knows the LUN by
+            header: 'LUN',
+            dataIndex: 'lun',
+            width: 60,
         },
         {
             header: gettext('Transport'),
@@ -125,6 +142,7 @@ Ext.define('PVE.node.SANLunList', {
             header: 'WWN',
             dataIndex: 'wwn',
             width: 200,
+            hidden: true,
         },
         {
             header: gettext('Serial'),
@@ -132,6 +150,18 @@ Ext.define('PVE.node.SANLunList', {
             width: 140,
             hidden: true,
         },
+        {
+            header: 'WWID',
+            dataIndex: 'wwid',
+            width: 200,
+            hidden: true,
+        },
+        {
+            header: gettext('Multipath'),
+            dataIndex: 'multipath',
+            flex: 1,
+            renderer: PVE.SANUtils.render_san_lun_multipath,
+        },
         {
             header: gettext('Usage'),
             dataIndex: 'usage',
@@ -141,7 +171,287 @@ Ext.define('PVE.node.SANLunList', {
     ],
 
     reload: function () {
-        this.getStore().load();
+        let me = this;
+        if (me.destroyed) {
+            return;
+        }
+        me.getSelectionModel().deselectAll();
+        me.getStore().load();
+        me.loadNodeStatus();
+    },
+
+    loadNodeStatus: function () {
+        let me = this;
+        let show = ({ faults, notes = [] }) => {
+            if (me.destroyed) {
+                return;
+            }
+            me.down('#nodeProblems').setLines(faults);
+            me.down('#nodeNotes').setLines(notes);
+        };
+        Proxmox.Utils.API2Request({
+            url: '/cluster/multipath/status',
+            method: 'GET',
+            // an auditor of this node only may not read the cluster-wide status, which is no fault
+            failure: (response) =>
+                show({
+                    faults: PVE.SANUtils.request_denied(response)
+                        ? []
+                        : [
+                              `${gettext('The multipath status could not be read')}: ${response.htmlStatus}`,
+                          ],
+                }),
+            success: function (response) {
+                let data = response.result.data || {};
+                let report = data.nodes?.[me.nodename];
+                let required = !!PVE.SANUtils.multipath_required_nodes(data)[me.nodename];
+                show(PVE.node.SANLunList.nodeProblems(report, required, me.canModify));
+            },
+        });
+    },
+
+    statics: {
+        nodeProblems: function (report, required, canManage) {
+            let enc = Ext.htmlEncode;
+            let lines = [];
+            let notes = [];
+            if (!report) {
+                return {
+                    faults: required
+                        ? [gettext('The node does not report its multipath status.')]
+                        : [],
+                    notes,
+                };
+            }
+            if (report.reason === 'stale') {
+                lines.push(gettext('The multipath status of the node is not current.'));
+            }
+            if (report['apply-error']) {
+                lines.push(
+                    `${gettext('The multipath configuration could not be applied')}: ${enc(report['apply-error'])}`,
+                );
+            }
+            // missing multipath-tools can already be the apply error
+            let toolsMissing = report['apply-error']?.trim() === 'multipath-tools is not installed';
+            if (required && !report.supported) {
+                if (!toolsMissing) {
+                    lines.push(PVE.SANUtils.render_multipath_reason('unsupported'));
+                }
+            } else if (required && report.supported && !report.running) {
+                lines.push(
+                    gettext(
+                        'multipathd is not running. Multipath map health is unknown; existing maps may still carry I/O. Check the service and its log.',
+                    ),
+                );
+            }
+            if (report['report-error']) {
+                lines.push(
+                    `${gettext('Querying the multipath maps failed')}: ${enc(report['report-error'])}`,
+                );
+            }
+            if (report.stalled) {
+                lines.push(PVE.SANUtils.render_multipath_reason('stalled'));
+            }
+            if (report.unmanaged) {
+                notes.push(
+                    Ext.String.format(
+                        canManage
+                            ? ngettext(
+                                  '{0} multipath map is outside the cluster-wide configuration. To take it over, select it and press Manage.',
+                                  '{0} multipath maps are outside the cluster-wide configuration. To take them over, select each map and press Manage.',
+                                  report.unmanaged,
+                              )
+                            : ngettext(
+                                  '{0} multipath map is outside the cluster-wide configuration.',
+                                  '{0} multipath maps are outside the cluster-wide configuration.',
+                                  report.unmanaged,
+                              ),
+                        report.unmanaged,
+                    ),
+                );
+            }
+            if (report['unresolved-luns']) {
+                lines.push(
+                    Ext.String.format(
+                        ngettext(
+                            'The node cannot identify {0} LUN of its multipath-enabled storages because the identifiers of its paths are unavailable or could not be verified.',
+                            'The node cannot identify {0} LUNs of its multipath-enabled storages because the identifiers of their paths are unavailable or could not be verified.',
+                            report['unresolved-luns'],
+                        ),
+                        report['unresolved-luns'],
+                    ),
+                );
+            }
+            return { faults: lines, notes };
+        },
+    },
+
+    // guards async editor lookups against newer actions and destruction
+    beginEditOperation: function () {
+        let me = this;
+        let token = (me.editGeneration = (me.editGeneration ?? 0) + 1);
+        return () => !me.destroyed && !me.destroying && token === me.editGeneration;
+    },
+
+    openMultipathEdit: function (rec) {
+        let me = this;
+        let current = me.beginEditOperation();
+        if (!current()) {
+            return;
+        }
+        me.setLoading(false);
+        let state = rec.get('multipath');
+        let pathUsers = [];
+        let open = function (config) {
+            if (!current()) {
+                return;
+            }
+            Ext.create('PVE.dc.MultipathWwidEdit', {
+                wwid: rec.get('wwid'),
+                apiCallDone: (success) => current() && success && me.reload(),
+                autoShow: true,
+                ...config,
+                activationWarning:
+                    state === 'none'
+                        ? PVE.SANUtils.multipath_activation_hint(pathUsers)
+                        : undefined,
+            });
+        };
+        if (state === 'manual') {
+            open({ isCreate: false });
+            return;
+        }
+        if (state === 'none') {
+            // Keep this node included even if the fresh inventory no longer reports the LUN.
+            me.setLoading(gettext('Checking which nodes see the LUN...'));
+            PVE.SANUtils.scan_nodes_for_luns(function (result) {
+                if (!current()) {
+                    return;
+                }
+                me.setLoading(false);
+                pathUsers = result.pathUsers(rec.get('wwid'));
+                let nodes = [...new Set([me.nodename, ...result.sees(rec.get('wwid'))])];
+                let hint = gettext(
+                    'Preselected: this node and the nodes that see this LUN. Review which nodes should use it.',
+                );
+                if (result.unchecked.length) {
+                    nodes.push(...result.unchecked.filter((node) => !nodes.includes(node)));
+                    hint +=
+                        ' ' +
+                        Ext.String.format(
+                            gettext('Also included, as they could not be checked: {0}.'),
+                            result.unchecked.join(', '),
+                        );
+                }
+                me.openEntryFor(rec, open, nodes.sort(), Ext.htmlEncode(hint), current);
+            });
+            return;
+        }
+        let hint = {
+            unmanaged: gettext(
+                'This map was set up outside the cluster-wide multipath configuration. Saving adds it to that configuration for the selected nodes.',
+            ),
+            kept: gettext(
+                'No storage or entry requires multipath for this LUN anymore, so this node keeps the map only while it is in use. Saving creates an explicit entry that keeps the LUN configured on the selected nodes.',
+            ),
+        }[state];
+        me.openEntryFor(rec, open, [me.nodename], hint, current);
+    },
+
+    // an entry restricted to other nodes is extended instead of creating one
+    openEntryFor: function (rec, open, nodes, hint, current) {
+        let me = this;
+        if (!current()) {
+            return;
+        }
+        me.setLoading(true);
+        PVE.dc.MultipathWwidEdit.lookup(rec.get('wwid'), {
+            current,
+            missing: function () {
+                me.setLoading(false);
+                open({ isCreate: true, nodes, hint });
+            },
+            failure: function (original, list) {
+                me.setLoading(false);
+                Ext.Msg.alert(
+                    gettext('Error'),
+                    list
+                        ? `${gettext('The multipath entries could not be read.')}<br><br>${original.htmlStatus}`
+                        : original.htmlStatus,
+                );
+            },
+            found: function (entry) {
+                me.setLoading(false);
+                let nodes = entry?.nodes?.split(',') ?? [];
+                if (!nodes.length || nodes.includes(me.nodename)) {
+                    open({
+                        isCreate: false,
+                        hint: gettext(
+                            'The LUN already has an entry for this node. Its map is assembled once the node has applied the configuration.',
+                        ),
+                    });
+                    return;
+                }
+                open({
+                    isCreate: false,
+                    addNodes: [me.nodename],
+                    hint: Ext.String.format(
+                        gettext(
+                            'The LUN has an entry restricted to other nodes. Saving adds node "{0}" to it.',
+                        ),
+                        Ext.htmlEncode(me.nodename),
+                    ),
+                });
+            },
+        });
+    },
+
+    editStorage: function (storeid) {
+        let me = this;
+        let current = me.beginEditOperation();
+        if (!current()) {
+            return;
+        }
+        me.setLoading(true);
+        Proxmox.Utils.API2Request({
+            url: `/storage/${encodeURIComponent(storeid)}`,
+            method: 'GET',
+            failure: function (response) {
+                if (!current()) {
+                    return;
+                }
+                me.setLoading(false);
+                Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+            },
+            success: function (response) {
+                if (!current()) {
+                    return;
+                }
+                me.setLoading(false);
+                let type = response.result.data.type;
+                let schema = PVE.Utils.storageSchema[type];
+                if (!schema || !schema.ipanel) {
+                    Ext.Msg.alert(
+                        gettext('Error'),
+                        Ext.String.format(
+                            gettext('There is no editor for storage type "{0}".'),
+                            Ext.htmlEncode(type),
+                        ),
+                    );
+                    return;
+                }
+                Ext.create('PVE.storage.BaseEdit', {
+                    paneltype: `PVE.storage.${schema.ipanel}`,
+                    type: type,
+                    storageId: storeid,
+                    canDoBackups: schema.backups,
+                    autoShow: true,
+                    listeners: {
+                        destroy: () => current() && me.reload(),
+                    },
+                });
+            },
+        });
     },
 
     listeners: {
@@ -181,44 +491,131 @@ Ext.define('PVE.node.SANLunList', {
                 'transport',
                 'serial',
                 'wwn',
+                'wwid',
                 'path-faults',
                 'path-list',
+                'iscsi-target',
+                'iscsi-lun',
+                {
+                    name: 'lun',
+                    depends: ['iscsi-lun'],
+                    calculate: (data) => data['iscsi-lun'],
+                },
             ],
             proxy: {
                 type: 'proxmox',
                 url: `/api2/json/nodes/${me.nodename}/scan/san-luns`,
+                reader: {
+                    type: 'json',
+                    rootProperty: 'data',
+                    transform: (raw) => ({
+                        ...raw,
+                        data: PVE.SANUtils.group_san_lun_paths(raw?.data ?? []),
+                    }),
+                },
             },
             filters: [sanTransportFilter],
-            sorters: 'devpath',
+            sorters: ['devpath'],
         });
         me.store = store;
 
-        me.tbar = [
+        let sm = Ext.create('Ext.selection.RowModel', {});
+        me.selModel = sm;
+
+        // the editor loads the entry, which needs Sys.Audit
+        let caps = Ext.state.Manager.get('GuiCap');
+        let canModify = !!caps?.dc?.['Sys.Modify'] && !!caps?.dc?.['Sys.Audit'];
+        me.canModify = canModify;
+
+        let items = [
             {
                 text: gettext('Reload'),
                 iconCls: 'fa fa-refresh',
                 handler: () => me.reload(),
             },
-            '->',
             {
-                xtype: 'proxmoxcheckbox',
-                boxLabel: gettext('Show all devices'),
-                autoEl: {
-                    tag: 'div',
-                    'data-qtip': gettext('Also list devices with local or unknown transports.'),
-                },
+                xtype: 'proxmoxButton',
+                text: gettext('Copy WWID'),
+                iconCls: 'fa fa-clipboard',
+                selModel: sm,
+                disabled: true,
+                enableFn: (rec) => !!rec.get('wwid'),
+                handler: (btn, event, rec) => PVE.Utils.copyToClipboard(rec.get('wwid')),
+            },
+        ];
+
+        if (canModify) {
+            let entryText = {
+                none: gettext('Enable Multipath'),
+                unmanaged: gettext('Manage'),
+                kept: gettext('Manage'),
+                manual: gettext('Edit'),
+            };
+            items.push({
+                xtype: 'proxmoxButton',
+                itemId: 'entryButton',
+                text: entryText.none,
+                iconCls: 'fa fa-code-fork',
+                selModel: sm,
+                disabled: true,
+                enableFn: (rec) => !!rec.get('wwid') && !!entryText[rec.get('multipath')],
+                handler: (btn, event, rec) => me.openMultipathEdit(rec),
                 listeners: {
-                    change: function (f, value) {
-                        me.setEmptyText(me.emptyDeviceText(value));
-                        let filters = store.getFilters();
-                        if (value) {
-                            filters.remove(sanTransportFilter);
-                        } else {
-                            filters.add(sanTransportFilter);
-                        }
+                    // keep the toolbar from moving when the label changes
+                    render: function (btn) {
+                        let width = Math.max(
+                            ...Object.values(entryText).map((text) => {
+                                btn.setText(text);
+                                return btn.getSize().width;
+                            }),
+                        );
+                        btn.setText(entryText.none);
+                        btn.setSize({ width });
                     },
                 },
+            });
+            sm.on('selectionchange', function (model, selection) {
+                let text = entryText[selection[0]?.get('multipath')];
+                me.down('#entryButton').setText(text ?? entryText.none);
+            });
+        }
+
+        if (caps?.storage?.['Datastore.Allocate']) {
+            items.push({
+                xtype: 'proxmoxButton',
+                text: gettext('Edit Storage'),
+                iconCls: 'fa fa-database',
+                selModel: sm,
+                disabled: true,
+                enableFn: (rec) => !!rec.get('multipath-storage'),
+                handler: (btn, event, rec) => me.editStorage(rec.get('multipath-storage')),
+            });
+        }
+
+        items.push('->', {
+            xtype: 'proxmoxcheckbox',
+            boxLabel: gettext('Show all devices'),
+            autoEl: {
+                tag: 'div',
+                'data-qtip': gettext('Also list devices with local or unknown transports.'),
             },
+            listeners: {
+                change: function (f, value) {
+                    me.setEmptyText(me.emptyDeviceText(value));
+                    let filters = store.getFilters();
+                    if (value) {
+                        filters.remove(sanTransportFilter);
+                    } else {
+                        filters.add(sanTransportFilter);
+                    }
+                },
+            },
+        });
+        me.tbar = items;
+
+        me.dockedItems = [
+            { xtype: 'pveSANNotice', itemId: 'nodeProblems', kind: 'warning' },
+            { xtype: 'pveSANNotice', itemId: 'nodeNotes' },
         ];
 
         me.callParent();
diff --git a/www/manager6/storage/wizard/FibreChannel.js b/www/manager6/storage/wizard/FibreChannel.js
index 4affbd8a7..eec8a7790 100644
--- a/www/manager6/storage/wizard/FibreChannel.js
+++ b/www/manager6/storage/wizard/FibreChannel.js
@@ -171,7 +171,7 @@ Ext.define('PVE.storage.wizard.FCLunSelect', {
                     return;
                 }
                 grid.getStore().setData(
-                    (response.result.data || []).map((row) => ({
+                    PVE.SANUtils.group_san_lun_paths(response.result.data || []).map((row) => ({
                         ...row,
                         scanNode: node,
                         scanGeneration: generation,
@@ -318,8 +318,9 @@ Ext.define('PVE.storage.wizard.FCLunSelect', {
                         header: gettext('Device'),
                         dataIndex: 'devpath',
                         flex: 3,
-                        renderer: PVE.SANUtils.render_san_lun_device,
+                        renderer: Ext.htmlEncode,
                     },
+                    columns.paths,
                     {
                         header: gettext('Transport'),
                         dataIndex: 'transport',
diff --git a/www/manager6/storage/wizard/LunPicker.js b/www/manager6/storage/wizard/LunPicker.js
index cb3eba7b3..7ae6b0564 100644
--- a/www/manager6/storage/wizard/LunPicker.js
+++ b/www/manager6/storage/wizard/LunPicker.js
@@ -7,6 +7,12 @@ Ext.define('PVE.storage.wizard.LunPicker', {
 
     lunColumns: function () {
         return {
+            paths: {
+                header: gettext('Paths'),
+                dataIndex: 'paths-total',
+                width: 70,
+                renderer: PVE.SANUtils.render_san_lun_paths,
+            },
             size: {
                 header: gettext('Size'),
                 dataIndex: 'size',
-- 
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 ` [PATCH v3 manager 53/76] ui: storage wizard: add Fibre Channel shared LVM setup Thomas Lamprecht
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 ` Thomas Lamprecht [this message]
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-67-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