From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [IPv6:2a0f:8001:1:32::40]) by lore.proxmox.com (Postfix) with ESMTPS id C1EF81FF0AF for ; Fri, 06 Nov 2026 02:01:49 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id D7D2721835; Fri, 06 Nov 2026 01:53:44 +0100 (CET) From: Thomas Lamprecht 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 Message-ID: <20261105233336.2178064-67-t.lamprecht@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20261105233336.2178064-1-t.lamprecht@proxmox.com> References: <20261105233336.2178064-1-t.lamprecht@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1793926182074 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.286 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: 5MVU7B3YBUROMCLDEPZOWNXDRTPXWXDM X-Message-ID-Hash: 5MVU7B3YBUROMCLDEPZOWNXDRTPXWXDM X-MailFrom: t.lamprecht@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: 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 --- 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 = - ` ` + - `${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 ` ${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 += + ` ` + + gettext('duplicate PV'); + } + return { text, tip: tooltip.join('
') }; + }, + 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 ` ${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(`WWN: ${enc(values.wwn)}`); + let line = (label, value) => lines.push(`${label}: ${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(`${gettext('Serial')}: ${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( - `${gettext('Path Failures Since Map Creation')}: ${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.')}

${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