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 6B0841FF0AF for ; Fri, 06 Nov 2026 02:02:16 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 5CBA4218AA; Fri, 06 Nov 2026 01:54:20 +0100 (CET) From: Thomas Lamprecht To: pve-devel@lists.proxmox.com Subject: [PATCH v3 manager 64/76] ui: dc: add a cluster-wide multipath view Date: Fri, 6 Nov 2026 00:31:39 +0100 Message-ID: <20261105233336.2178064-65-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-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1793926181944 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.135 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) POISEN_SPAM_PILL 0.1 Meta: its spam POISEN_SPAM_PILL_1 0.1 random spam to be learned in bayes POISEN_SPAM_PILL_3 0.1 random spam to be learned in bayes 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: G2V2KCUQQUY4VDV5R5XOMBAPUBKBGMI2 X-Message-ID-Hash: G2V2KCUQQUY4VDV5R5XOMBAPUBKBGMI2 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: Administrators need to see which nodes manage each LUN and whether its configured paths are usable. Add the cluster-wide multipath overview, entry editor and node details, including the time of each node report. Keep health counts visible with faults first and distinguish the multipath service from the health of the maps it manages. Show configuration failures and missing reports rather than presenting them as healthy paths. An optimal LUN with a node in an unknown state is marked incomplete, as the aggregate leaves that node out. Signed-off-by: Thomas Lamprecht --- test/Makefile | 6 + test/SANUtils_test.js | 54 +++ www/manager6/Makefile | 4 + www/manager6/SANUtils.js | 165 ++++++++ www/manager6/dc/Config.js | 28 +- www/manager6/dc/Multipath.js | 547 +++++++++++++++++++++++++++ www/manager6/dc/MultipathWwidEdit.js | 183 +++++++++ www/manager6/dc/SAN.js | 326 ++++++++++++++++ www/manager6/panel/SANNotice.js | 26 ++ 9 files changed, 1333 insertions(+), 6 deletions(-) create mode 100644 test/SANUtils_test.js create mode 100644 www/manager6/dc/Multipath.js create mode 100644 www/manager6/dc/MultipathWwidEdit.js create mode 100644 www/manager6/dc/SAN.js create mode 100644 www/manager6/panel/SANNotice.js diff --git a/test/Makefile b/test/Makefile index 2dfd20a41..d7521c0b3 100644 --- a/test/Makefile +++ b/test/Makefile @@ -7,6 +7,12 @@ export PERLLIB=.. .PHONY: check check: test-replication test-balloon test-vzdump test-osd test-ceph-health test-ceph-services test-ceph-auth test-ceph-key-migration test-ceph-lockbox-migration test-custom-cpu-models test-pvesh +check: test-san-utils + +.PHONY: test-san-utils +test-san-utils: + node --test SANUtils_test.js + .PHONY: test-balloon test-balloon: ./balloontest.pl diff --git a/test/SANUtils_test.js b/test/SANUtils_test.js new file mode 100644 index 000000000..d22307664 --- /dev/null +++ b/test/SANUtils_test.js @@ -0,0 +1,54 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { test } = require('node:test'); + +// load PVE.SANUtils with stubs of the ExtJS and translation helpers it uses +function sanUtils(translations = {}) { + const sandbox = { + PVE: {}, + gettext: (text) => translations[text] ?? text, + Ext: { + define: (name, config) => { + sandbox.PVE.SANUtils = config; + }, + htmlEncode: (text) => + String(text).replace(/&/g, '&').replace(/"/g, '"').replace(/ text.replace(/\{(\d+)\}/g, (_, index) => args[index]), + }, + }, + }; + vm.createContext(sandbox); + const source = path.join(__dirname, '../www/manager6/SANUtils.js'); + vm.runInContext(fs.readFileSync(source, 'utf8'), sandbox, { filename: source }); + return sandbox.PVE.SANUtils; +} + +test('cluster health of a LUN', () => { + const utils = sanUtils({ 'Optimal (incomplete)': 'Optimal (unvollständig)' }); + const render = (value, nodes, extra = {}) => { + const metaData = {}; + const html = utils.render_multipath_cluster_health(value, metaData, { nodes, ...extra }); + return { html, tip: metaData.tdAttr }; + }; + const optimal = { state: 'optimal' }; + + let res = render('optimal', { n1: optimal, n2: optimal }); + assert.match(res.html, /> Optimal$/); + assert.equal(res.tip, undefined, 'no tooltip while every node is known'); + + res = render('optimal', { n2: { state: 'unknown', reason: 'stale' }, n1: optimal, n3: {} }); + assert.match(res.html, /check-circle good.*> Optimal \(unvollständig\)$/); + assert.equal(res.tip, 'data-qtip="State unknown on: n2, n3"', 'unknown nodes, sorted'); + + res = render('degraded', { n1: { state: 'degraded' }, n2: { state: 'unknown' } }); + assert.match(res.html, /> Degraded$/, 'a worse known state needs no qualifier'); + + res = render('optimal', { n1: optimal, n2: { reason: 'not-derived' } }); + assert.match(res.html, /> Optimal$/, 'a node that does not observe the LUN is not unknown'); + + res = render('missing', { n1: { state: 'unknown' } }, { 'not-observed': 1 }); + assert.match(res.html, /> Not currently observed$/); +}); diff --git a/www/manager6/Makefile b/www/manager6/Makefile index 3b8780790..252c5761a 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -119,6 +119,7 @@ JSSRC= \ panel/TemplateStatusView.js \ panel/MultiDiskEdit.js \ panel/TagConfig.js \ + panel/SANNotice.js \ tree/ResourceTree.js \ tree/SnapshotTree.js \ tree/ResourceMapTree.js \ @@ -185,6 +186,8 @@ JSSRC= \ dc/Guests.js \ dc/Health.js \ dc/Log.js \ + dc/Multipath.js \ + dc/MultipathWwidEdit.js \ dc/NodeView.js \ dc/OptionView.js \ dc/PermissionView.js \ @@ -192,6 +195,7 @@ JSSRC= \ dc/PoolView.js \ dc/RoleEdit.js \ dc/RoleView.js \ + dc/SAN.js \ dc/SecurityGroups.js \ dc/StorageView.js \ dc/Summary.js \ diff --git a/www/manager6/SANUtils.js b/www/manager6/SANUtils.js index b48ad969d..c101ebb75 100644 --- a/www/manager6/SANUtils.js +++ b/www/manager6/SANUtils.js @@ -16,6 +16,138 @@ Ext.define('PVE.SANUtils', { 'by-id', ], + multipath_health_display: function (value) { + return ( + { + optimal: { iconCls: 'check-circle good', label: gettext('Optimal') }, + degraded: { iconCls: 'exclamation-circle warning', label: gettext('Degraded') }, + missing: { iconCls: 'times-circle critical', label: gettext('Missing') }, + failed: { iconCls: 'times-circle critical', label: gettext('Failed') }, + unknown: { iconCls: 'question-circle faded', label: gettext('Unknown') }, + 'not-observed': { + iconCls: 'eye-slash faded', + label: gettext('Not currently observed'), + }, + }[value] ?? { iconCls: 'question-circle faded', label: Ext.htmlEncode(value ?? '') } + ); + }, + + render_multipath_health: function (value) { + if (typeof value === 'undefined') { + return ''; + } + let { iconCls, label } = PVE.SANUtils.multipath_health_display(value); + return ` ${label}`; + }, + + // The aggregate leaves out nodes without a current observation, so an optimal LUN is marked + // incomplete while its state on one of them is unknown. + render_multipath_cluster_health: function (value, metaData, data) { + if (data['not-observed']) { + return PVE.SANUtils.render_multipath_health('not-observed'); + } + let nodes = data.nodes || {}; + let unknown = Object.keys(nodes) + .filter((node) => PVE.SANUtils.multipath_count_state(nodes[node]) === 'unknown') + .sort(); + if (value !== 'optimal' || !unknown.length) { + return PVE.SANUtils.render_multipath_health(value); + } + let tip = Ext.String.format(gettext('State unknown on: {0}'), unknown.join(', ')); + if (metaData) { + metaData.tdAttr = `data-qtip="${Ext.htmlEncode(Ext.htmlEncode(tip))}"`; + } + let { iconCls } = PVE.SANUtils.multipath_health_display(value); + return ` ${gettext('Optimal (incomplete)')}`; + }, + + render_multipath_reason: function (value) { + if (!value) { + return ''; + } + return ( + { + offline: gettext('Node is offline'), + stale: gettext('Report is stale'), + 'not-running': gettext('multipathd is not running'), + unsupported: gettext('multipath-tools is not installed'), + 'query-error': gettext('Querying the multipath maps failed'), + 'summary-only': gettext('Too many LUNs to list, see the SAN view of the node'), + 'no-report': gettext('Not reporting, the node may run an older version'), + stalled: gettext('Status updates of the node are stalled'), + 'not-derived': gettext('The storage no longer finds the LUN on the target'), + }[value] ?? Ext.htmlEncode(value) + ); + }, + + multipath_count_state: function (entry) { + if (entry?.reason === 'not-derived') { + return 'not-observed'; + } + return entry?.state ?? 'unknown'; + }, + + // the nodes on which missing multipath tools or a stopped multipathd are a problem + multipath_required_nodes: function (status) { + let required = {}; + for (const node of status?.['required-nodes'] || []) { + required[node] = true; + } + for (const lun of status?.luns || []) { + Object.keys(lun.nodes || {}).forEach((node) => (required[node] = true)); + } + Ext.Object.each(status?.nodes || {}, function (node, report) { + if (report?.summary?.luns || report?.['unresolved-luns']) { + required[node] = true; + } + }); + return required; + }, + + render_san_state_counts: function (value) { + let counts = value?.counts; + if (!counts) { + return ''; + } + let order = ['failed', 'missing', 'degraded', 'unknown', 'not-observed', 'optimal']; + let states = [ + ...order.filter((state) => counts[state]), + ...Object.keys(counts).filter((state) => !order.includes(state) && counts[state]), + ]; + if (!states.length) { + return gettext('None'); + } + let text = states + .map( + (state) => `${counts[state]} ${PVE.SANUtils.multipath_health_display(state).label}`, + ) + .join(', '); + if (value.summary) { + let qtip = Ext.htmlEncode( + gettext( + 'The node has too many LUNs to list and reports only the number of LUNs in each state.', + ), + ); + text += ` `; + } + return text; + }, + + count_states: function (entries, stateFn) { + let counts = {}; + for (const entry of entries) { + let state = stateFn(entry); + counts[state] = (counts[state] ?? 0) + 1; + } + return counts; + }, + + // empty without nodes, where render_san_state_counts() says None + render_node_states: function (nodes, stateFn) { + let counts = PVE.SANUtils.count_states(Object.values(nodes || {}), stateFn); + return Object.keys(counts).length ? PVE.SANUtils.render_san_state_counts({ counts }) : ''; + }, + // headers and cells are HTML render_detail_table: function (headers, rows) { let header = headers.map((text) => `${text}`).join(''); @@ -23,6 +155,19 @@ Ext.define('PVE.SANUtils', { return `${header}${body}
`; }, + // a stalled report keeps its problems but not its optimal LUNs + multipath_summary_counts: function (report) { + let { luns, ...counts } = report.summary; + if (report.reason) { + return { counts: { unknown: luns }, summary: true }; + } + if (report.stalled && counts.optimal) { + counts.unknown = (counts.unknown ?? 0) + counts.optimal; + delete counts.optimal; + } + return { counts, summary: true }; + }, + render_san_lun_device: function (value, metaData, rec) { let text = Ext.htmlEncode(value); // only multipath mapped devices report their path count @@ -98,10 +243,30 @@ Ext.define('PVE.SANUtils', { } }, + // { text, degraded } of the usable paths, undefined without counts; an absent path counts + // against the expected count, without one (FC) only the found paths are known + format_san_paths: function (active, found, expected) { + if (!Ext.isNumeric(found) && !Ext.isNumeric(expected)) { + return undefined; + } + let total = Math.max(found || 0, expected || 0); + return { + text: `${Ext.isNumeric(active) ? active : '?'}/${total}`, + degraded: + (Ext.isNumeric(found) && found < total) || + (Ext.isNumeric(active) && active < total), + }; + }, + request_uncertain: function (response) { return response.timedout || response.status === 0 || response.result?.success === undefined; }, + // the extjs API format reports the HTTP status of a refused request as a string + request_denied: function (response) { + return Number(response?.result?.status) === 403; + }, + 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)}"`; diff --git a/www/manager6/dc/Config.js b/www/manager6/dc/Config.js index 83c48c3d6..8a1e25205 100644 --- a/www/manager6/dc/Config.js +++ b/www/manager6/dc/Config.js @@ -124,12 +124,28 @@ Ext.define('PVE.dc.Config', { } if (caps.storage['Datastore.Allocate'] || caps.dc['Sys.Audit']) { - me.items.push({ - xtype: 'pveStorageView', - title: gettext('Storage'), - iconCls: 'fa fa-database', - itemId: 'storage', - }); + me.items.push( + { + xtype: 'pveStorageView', + title: gettext('Storage'), + iconCls: 'fa fa-database', + itemId: 'storage', + }, + // storage admins need the node identities for the target ACLs too + { + xtype: 'pveDcSAN', + title: 'SAN', + iconCls: 'fa fa-cubes', + itemId: 'san', + }, + { + xtype: 'pveDcMultipath', + title: gettext('Multipath'), + iconCls: 'fa fa-code-fork', + itemId: 'multipath', + groups: ['san'], + }, + ); } if (caps.dc['Sys.Audit']) { diff --git a/www/manager6/dc/Multipath.js b/www/manager6/dc/Multipath.js new file mode 100644 index 000000000..9646b9d72 --- /dev/null +++ b/www/manager6/dc/Multipath.js @@ -0,0 +1,547 @@ +Ext.define('pve-dc-multipath-status', { + extend: 'Ext.data.Model', + fields: [ + 'wwid', + 'source', + 'multipath-storage', + 'alias', + 'used-by', + 'size', + 'cluster-state', + 'not-observed', + 'nodes', + ], + idProperty: 'wwid', +}); + +Ext.define('PVE.dc.MultipathOverridesEdit', { + extend: 'Proxmox.window.Edit', + xtype: 'pveDcMultipathOverridesEdit', + + subject: gettext('Multipath Overrides'), + onlineHelp: 'storage_multipath', + width: 600, + autoLoad: true, + method: 'PUT', + url: '/cluster/multipath/overrides', + + items: [ + { + xtype: 'textarea', + name: 'overrides', + fieldLabel: gettext('Overrides'), + height: 320, + fieldStyle: 'font-family: monospace;', + emptyText: + 'devices {\n\tdevice {\n\t\tvendor "..."\n\t\tproduct "..."\n' + + '\t\tpath_grouping_policy ...\n\t}\n}', + }, + ], +}); + +Ext.define('PVE.dc.MultipathView', { + extend: 'Ext.grid.Panel', + xtype: 'pveDcMultipath', + + onlineHelp: 'storage_multipath', + + stateful: true, + stateId: 'grid-dc-multipath', + + emptyText: gettext( + 'No managed LUNs. To add some, create an iSCSI storage with the Multipath option under Datacenter > Storage > Add > Remote Storage, or enable multipath for a LUN under Node > SAN.', + ), + + viewModel: { + data: { + wwid: '', + manual: false, + }, + }, + + controller: { + xclass: 'Ext.app.ViewController', + + reload: function () { + this.getView().rstore.load(); + }, + + selectedRecord: function () { + let sel = this.getView().getSelection(); + return sel.length ? sel[0] : undefined; + }, + + editWwid: function () { + let me = this; + let rec = me.selectedRecord(); + if (!rec) { + return; + } + + let isCreate = rec.data.source !== 'manual'; + let hint; + if (rec.data.source === 'kept') { + // nodes without a current report cannot say whether they keep the map, offer all + // listed ones + let listed = Object.keys(rec.data.nodes || {}); + let keeping = listed.filter((node) => rec.data.nodes[node]?.kept); + if (!keeping.length) { + keeping = listed; + } + Ext.create('PVE.dc.MultipathWwidEdit', { + wwid: rec.data.wwid, + isCreate: true, + nodes: keeping.length ? keeping : undefined, + hint: gettext( + 'No storage or entry requires this LUN anymore, so nodes keep its map only while it is in use. Saving creates an explicit entry that keeps the LUN configured on the selected nodes.', + ), + apiCallDone: (success) => success && me.reload(), + autoShow: true, + }); + return; + } + if (isCreate) { + hint = gettext( + 'Saving creates a separate explicit entry with these settings, which stays even when no storage requires the LUN anymore.', + ); + if (rec.data['multipath-storage']) { + let storage = Ext.String.format( + gettext('Storage "{0}" enables multipath for this LUN.'), + Ext.htmlEncode(rec.data['multipath-storage']), + ); + hint = `${storage} ${hint}`; + } + } + + let open = (nodes) => + Ext.create('PVE.dc.MultipathWwidEdit', { + wwid: rec.data.wwid, + isCreate: isCreate, + nodes: nodes, + hint: hint, + apiCallDone: (success) => success && me.reload(), + autoShow: true, + }); + + // start on the nodes of the storage, others never see the LUN and would only count as + // missing + if (!isCreate || !rec.data['multipath-storage']) { + open(undefined); + return; + } + Proxmox.Utils.API2Request({ + url: `/storage/${encodeURIComponent(rec.data['multipath-storage'])}`, + method: 'GET', + waitMsgTarget: me.getView(), + failure: () => open(undefined), + success: (response) => open(response.result.data?.nodes?.split(',')), + }); + }, + + removeWwid: function () { + let me = this; + let rec = me.selectedRecord(); + if (!rec || rec.data.source !== 'manual') { + return; + } + let wwid = rec.data.wwid; + + let msg = + Ext.String.format( + gettext('Remove the multipath entry of LUN {0}?'), + Ext.htmlEncode(wwid), + ) + + '

' + + gettext('Nodes that still use the map keep it until it is no longer in use.'); + + Ext.Msg.confirm(gettext('Confirm'), msg, function (btn) { + if (btn !== 'yes') { + return; + } + Proxmox.Utils.API2Request({ + url: `/cluster/multipath/wwid/${encodeURIComponent(wwid)}`, + method: 'DELETE', + waitMsgTarget: me.getView(), + failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), + success: () => me.reload(), + }); + }); + }, + + editOverrides: function () { + let me = this; + Ext.create('PVE.dc.MultipathOverridesEdit', { + apiCallDone: (success) => success && me.reload(), + autoShow: true, + }); + }, + + collectWarnings: function (records) { + let nodeStatus = this.getView().nodeStatus || {}; + let enc = Ext.htmlEncode; + let lines = []; + let notes = []; + // the apply error of these nodes already says that multipath-tools is missing + let toolsMissing = {}; + for (const [node, status] of Object.entries(nodeStatus)) { + toolsMissing[node] = + status?.['apply-error']?.trim() === 'multipath-tools is not installed'; + } + + for (const node of Object.keys(nodeStatus).sort()) { + let status = nodeStatus[node]; + if (status['apply-error']) { + lines.push( + Ext.String.format( + gettext('Node "{0}" could not apply the multipath configuration: {1}'), + enc(node), + enc(status['apply-error']), + ), + ); + } + if (status['report-error']) { + lines.push( + Ext.String.format( + gettext('Node "{0}" could not query its multipath maps: {1}'), + enc(node), + enc(status['report-error']), + ), + ); + } + if (status.unmanaged) { + notes.push( + Ext.String.format( + ngettext( + 'Node "{0}" has {1} multipath map outside the cluster-wide configuration, see its SAN view.', + 'Node "{0}" has {1} multipath maps outside the cluster-wide configuration, see its SAN view.', + status.unmanaged, + ), + enc(node), + status.unmanaged, + ), + ); + } + if (status['unresolved-luns']) { + lines.push( + Ext.String.format( + ngettext( + 'Node "{0}" cannot identify {1} LUN of its multipath-enabled storages because the identifiers of its paths are unavailable or could not be verified.', + 'Node "{0}" cannot identify {1} LUNs of its multipath-enabled storages because the identifiers of their paths are unavailable or could not be verified.', + status['unresolved-luns'], + ), + enc(node), + status['unresolved-luns'], + ), + ); + } + if (status.summary) { + let { counts } = PVE.SANUtils.multipath_summary_counts(status); + let line = Ext.String.format( + gettext( + 'Node "{0}" has too many LUNs to list here ({1}), see its SAN view.', + ), + enc(node), + PVE.SANUtils.render_san_state_counts({ counts }), + ); + let reason = status.reason ?? (status.stalled ? 'stalled' : undefined); + if (reason && !(reason === 'unsupported' && toolsMissing[node])) { + line += ` ${PVE.SANUtils.render_multipath_reason(reason)}.`; + } + let healthy = Object.keys(counts).every( + (state) => state === 'optimal' || !counts[state], + ); + (healthy ? notes : lines).push(line); + } + } + + let unknown = {}; + for (const rec of records || []) { + Ext.Object.each(rec.data.nodes || {}, function (node, entry) { + // a node with too many LUNs to list, or whose apply error already says that + // multipath-tools is missing, has its own line above + if ( + (entry?.state === 'unknown' || entry?.reason === 'stalled') && + entry.reason && + entry.reason !== 'summary-only' && + !(entry.reason === 'unsupported' && toolsMissing[node]) + ) { + (unknown[entry.reason] ??= {})[node] = true; + } + }); + } + for (const reason of Object.keys(unknown).sort()) { + lines.push( + `${enc(Object.keys(unknown[reason]).sort().join(', '))}: ` + + PVE.SANUtils.render_multipath_reason(reason), + ); + } + + return { faults: lines, notes }; + }, + + onStoreLoad: function (store, records, success) { + let me = this; + + if (!success) { + return; + } + + // the DiffStore updates a selected row in place, without a selectionchange event + me.syncSelection(); + + let { faults, notes } = me.collectWarnings(records); + let view = me.getView(); + view.down('#nodeWarnings').setLines(faults); + view.down('#nodeNotes').setLines(notes); + }, + + syncSelection: function () { + let vm = this.getViewModel(); + let rec = this.selectedRecord(); + vm.set('wwid', rec ? rec.data.wwid : ''); + vm.set('manual', !!rec && rec.data.source === 'manual'); + }, + + control: { + '#': { + selectionchange: 'syncSelection', + }, + }, + }, + + plugins: [ + { + ptype: 'rowexpander', + expandOnDblClick: false, + scrollIntoViewOnExpand: false, + rowBodyTpl: [ + '{[this.renderNodes(values)]}', + { + renderNodes: function (values) { + let enc = Ext.htmlEncode; + let nodes = values.nodes || {}; + let names = Object.keys(nodes).sort(); + if (!names.length) { + return gettext('No node reports this LUN.'); + } + + let rows = names.map((node) => { + let entry = nodes[node] || {}; + let expected = entry['paths-expected']; + let paths = PVE.SANUtils.format_san_paths( + entry['paths-active'], + entry['paths-total'], + expected, + ); + let pathsCell = paths ? enc(paths.text) : ''; + if (paths && !Ext.isNumeric(expected)) { + let qtip = gettext( + 'Only the paths found are counted; the expected number of paths is not known.', + ); + pathsCell = `${pathsCell}`; + } + return [ + enc(node), + PVE.SANUtils.render_multipath_health( + PVE.SANUtils.multipath_count_state(entry), + ), + pathsCell, + PVE.SANUtils.render_san_transport(entry.transport), + [ + entry.kept + ? Ext.String.format( + gettext('Kept while in use ({0})'), + enc(entry.kept), + ) + : '', + PVE.SANUtils.render_multipath_reason(entry.reason), + entry['report-time'] !== undefined + ? Ext.String.format( + gettext('Last report: {0}'), + enc( + Proxmox.Utils.render_timestamp( + entry['report-time'], + ), + ), + ) + : '', + ] + .filter((note) => note !== '') + .join('; '), + ]; + }); + + return PVE.SANUtils.render_detail_table( + [ + gettext('Node'), + gettext('State'), + gettext('Paths'), + gettext('Transport'), + gettext('Notes'), + ], + rows, + ); + }, + }, + ], + }, + ], + + columns: [ + { + text: 'WWID', + dataIndex: 'wwid', + stateId: 'wwid', + flex: 2, + renderer: Ext.htmlEncode, + }, + { + text: gettext('Alias'), + dataIndex: 'alias', + stateId: 'alias', + width: 140, + renderer: (v) => Ext.htmlEncode(v || ''), + }, + { + text: gettext('Source'), + 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'); + }, + }, + { + text: gettext('Used By'), + dataIndex: 'used-by', + stateId: 'used-by', + width: 120, + renderer: (v) => Ext.htmlEncode(v || ''), + }, + { + text: gettext('Size'), + dataIndex: 'size', + stateId: 'size', + width: 90, + align: 'right', + renderer: (v) => (v ? Proxmox.Utils.format_size(v) : ''), + }, + { + text: gettext('Nodes'), + dataIndex: 'nodes', + stateId: 'nodes-health', + minWidth: 180, + cellWrap: true, + width: 220, + sortable: false, + renderer: (value) => + PVE.SANUtils.render_node_states(value, PVE.SANUtils.multipath_count_state), + }, + { + text: gettext('Cluster Health'), + dataIndex: 'cluster-state', + stateId: 'cluster-state', + width: 170, + renderer: (value, metaData, rec) => + PVE.SANUtils.render_multipath_cluster_health(value, metaData, rec.data), + }, + ], + + initComponent: function () { + let me = this; + + let caps = Ext.state.Manager.get('GuiCap'); + // the editors load the entry first, which needs Sys.Audit + let canModify = !!(caps?.dc?.['Sys.Modify'] && caps?.dc?.['Sys.Audit']); + + let rstore = Ext.create('Proxmox.data.UpdateStore', { + storeid: 'dc-multipath-status', + interval: 5000, + model: 'pve-dc-multipath-status', + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/multipath/status', + reader: { + rootProperty: 'data.luns', + transform: (raw) => { + me.nodeStatus = raw?.data?.nodes || {}; + // in the row data, so a changed report time refreshes an expanded row + for (const lun of raw?.data?.luns || []) { + for (const [node, entry] of Object.entries(lun.nodes || {})) { + entry['report-time'] = me.nodeStatus[node]?.time; + } + } + return raw; + }, + }, + }, + }); + me.rstore = rstore; + + me.store = Ext.create('Proxmox.data.DiffStore', { + rstore: rstore, + sorters: [{ property: 'wwid' }], + }); + // data only ever comes from the rstore, so drop loads through the memory proxy: they + // would replace the synced records with the proxy's empty content + me.store.load = Ext.emptyFn; + + me.dockedItems = [ + { xtype: 'pveSANNotice', itemId: 'nodeWarnings', kind: 'warning' }, + { xtype: 'pveSANNotice', itemId: 'nodeNotes' }, + ]; + + // only an own entry can be removed, a derived one goes with its storage + let hasSelection = canModify ? { bind: { disabled: '{!wwid}' } } : { disabled: true }; + let isManual = canModify ? { bind: { disabled: '{!manual}' } } : { disabled: true }; + + me.tbar = [ + { + text: gettext('Reload'), + iconCls: 'fa fa-refresh', + handler: 'reload', + }, + Ext.apply( + { + text: gettext('Edit'), + iconCls: 'fa fa-pencil', + handler: 'editWwid', + }, + hasSelection, + ), + Ext.apply( + { + text: gettext('Remove'), + iconCls: 'fa fa-trash-o', + handler: 'removeWwid', + }, + isManual, + ), + '->', + { + text: gettext('Edit Overrides'), + iconCls: 'fa fa-pencil', + disabled: !canModify, + handler: 'editOverrides', + }, + ]; + + me.callParent(); + + Proxmox.Utils.monStoreErrors(me, rstore, true); + me.mon(rstore, 'load', me.getController().onStoreLoad, me.getController()); + me.on('activate', () => rstore.startUpdate()); + me.on('deactivate', () => rstore.stopUpdate()); + me.on('destroy', () => rstore.stopUpdate()); + }, +}); diff --git a/www/manager6/dc/MultipathWwidEdit.js b/www/manager6/dc/MultipathWwidEdit.js new file mode 100644 index 000000000..68a598596 --- /dev/null +++ b/www/manager6/dc/MultipathWwidEdit.js @@ -0,0 +1,183 @@ +Ext.define('PVE.dc.MultipathWwidEdit', { + extend: 'Proxmox.window.Edit', + xtype: 'pveDcMultipathWwidEdit', + + subject: gettext('Multipath LUN'), + onlineHelp: 'storage_multipath', + width: 520, + fieldDefaults: { + labelWidth: 150, + }, + + wwid: undefined, + nodes: undefined, + + hint: undefined, + + initComponent: function () { + let me = this; + + if (!me.isCreate && !me.wwid) { + throw 'no wwid specified'; + } + + let isCreate = !!me.isCreate; + + let items = [ + me.wwid + ? { + xtype: 'displayfield', + name: 'wwid', + fieldLabel: 'WWID', + value: me.wwid, + // updates take the WWID from the URL, only creation passes it as a parameter + submitValue: isCreate, + } + : { + xtype: 'proxmoxtextfield', + name: 'wwid', + fieldLabel: 'WWID', + allowBlank: false, + emptyText: '3600140500a1b2c3d4e5f6a7b8c9d0e1f', + }, + { + xtype: 'proxmoxtextfield', + name: 'alias', + fieldLabel: gettext('Alias'), + emptyText: 'san-a-lun0', + maxLength: 64, + deleteEmpty: !isCreate, + regex: /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/, + regexText: gettext( + 'The alias becomes the name of the map. It must start with a letter or digit and may contain only letters, digits, dots, dashes, and underscores.', + ), + }, + { + xtype: 'pveNodeSelector', + name: 'nodes', + fieldLabel: gettext('Nodes'), + emptyText: gettext('All') + ' (' + gettext('No restrictions') + ')', + multiSelect: true, + autoSelect: false, + allowBlank: true, + skipEmptyText: true, + deleteEmpty: !isCreate, + value: me.nodes, + }, + ]; + + if (me.hint) { + items.push({ + xtype: 'displayfield', + value: PVE.Utils.note_text(me.hint), + }); + } + + let advancedItems = [ + { + xtype: 'proxmoxtextfield', + name: 'no-path-retry', + deleteEmpty: !isCreate, + fieldLabel: 'no_path_retry', + emptyText: Proxmox.Utils.defaultText, + regex: /^(queue|fail|\d+)$/, + regexText: gettext( + "Either 'queue', 'fail', or the number of polling intervals to retry for.", + ), + }, + { + xtype: 'proxmoxKVComboBox', + name: 'path-grouping-policy', + fieldLabel: 'path_grouping_policy', + deleteEmpty: !isCreate, + value: '__default__', + autoEl: { + tag: 'div', + 'data-qtip': gettext( + 'ALUA detection can override this setting, see the documentation.', + ), + }, + comboItems: [ + ['__default__', Proxmox.Utils.defaultText], + ['failover', 'failover'], + ['multibus', 'multibus'], + ['group_by_serial', 'group_by_serial'], + ['group_by_prio', 'group_by_prio'], + ['group_by_node_name', 'group_by_node_name'], + ['group_by_tpg', 'group_by_tpg'], + ], + }, + { + xtype: 'proxmoxtextfield', + name: 'failback', + deleteEmpty: !isCreate, + fieldLabel: 'failback', + emptyText: Proxmox.Utils.defaultText, + regex: /^(manual|immediate|followover|\d+)$/, + regexText: gettext( + "Either 'manual', 'immediate', 'followover', or a number of seconds.", + ), + }, + { + xtype: 'proxmoxtextfield', + name: 'path-selector', + deleteEmpty: !isCreate, + fieldLabel: 'path_selector', + emptyText: 'service-time 0', + regex: /^(round-robin|queue-length|service-time|historical-service-time|io-affinity) \d+$/, + regexText: gettext( + "A path selector as in multipath.conf, for example 'service-time 0'.", + ), + }, + { + xtype: 'proxmoxintegerfield', + name: 'rr-min-io-rq', + deleteEmpty: !isCreate, + fieldLabel: 'rr_min_io_rq', + emptyText: Proxmox.Utils.defaultText, + minValue: 1, + allowBlank: true, + }, + { + xtype: 'proxmoxKVComboBox', + name: 'rr-weight', + fieldLabel: 'rr_weight', + deleteEmpty: !isCreate, + value: '__default__', + comboItems: [ + ['__default__', Proxmox.Utils.defaultText], + ['priorities', 'priorities'], + ['uniform', 'uniform'], + ], + }, + ]; + + Ext.apply(me, { + items: [ + { + xtype: 'inputpanel', + items: items, + advancedItems: advancedItems, + }, + ], + }); + + if (isCreate) { + me.method = 'POST'; + me.url = '/cluster/multipath/wwid'; + } else { + me.method = 'PUT'; + me.url = `/cluster/multipath/wwid/${encodeURIComponent(me.wwid)}`; + me.autoLoad = true; + me.autoLoadOptions = { + success: function (response) { + let values = response.result.data; + values.nodes = values.nodes ? values.nodes.split(',') : []; + me.setValues(values); + }, + }; + } + + me.callParent(); + }, +}); diff --git a/www/manager6/dc/SAN.js b/www/manager6/dc/SAN.js new file mode 100644 index 000000000..586911ef2 --- /dev/null +++ b/www/manager6/dc/SAN.js @@ -0,0 +1,326 @@ +Ext.define('pve-dc-san-node', { + extend: 'Ext.data.Model', + fields: ['node', 'multipath', 'managed', 'initiator', 'initiator-note'], + idProperty: 'node', +}); + +Ext.define('PVE.dc.SANView', { + extend: 'Ext.grid.Panel', + xtype: 'pveDcSAN', + + onlineHelp: 'storage_multipath', + + stateful: true, + stateId: 'grid-dc-san', + + emptyText: gettext('No node reported its SAN state yet.'), + + sources: undefined, + + columns: [ + { + text: gettext('Node'), + dataIndex: 'node', + width: 140, + renderer: Ext.htmlEncode, + }, + { + text: gettext('Multipath Service'), + dataIndex: 'multipath', + width: 200, + renderer: (value) => PVE.dc.SANView.renderNodeMultipath(value), + }, + { + text: gettext('Managed LUNs'), + dataIndex: 'managed', + stateId: 'managed-health', + minWidth: 180, + cellWrap: true, + flex: 1, + renderer: (value) => PVE.SANUtils.render_san_state_counts(value), + }, + { + text: gettext('iSCSI Initiator Name'), + dataIndex: 'initiator', + flex: 2, + renderer: (value, metaData, rec) => + PVE.dc.SANView.renderIdentity(value, rec, 'initiator-note'), + }, + ], + + statics: { + renderIdentity: function (value, rec, noteField) { + if (!value) { + return ''; + } + let text = Ext.htmlEncode(value); + let note = rec.get(noteField); + if (note) { + let qtip = Ext.htmlEncode(note); + text = ` ${text}`; + } + return text; + }, + + reportNote: function (entry) { + if (entry?.reason === 'offline' || entry?.reason === 'stale') { + return PVE.SANUtils.render_multipath_reason(entry.reason); + } + return entry?.stalled ? PVE.SANUtils.render_multipath_reason('stalled') : undefined; + }, + + renderNodeMultipath: function (value) { + let report = value?.report; + let icon = (cls, text, qtip) => { + let tip = qtip ? ` data-qtip="${Ext.htmlEncode(qtip)}"` : ''; + return ` ${text}`; + }; + let problem = value?.required + ? 'warning fa-exclamation-circle' + : 'faded fa-minus-circle'; + if (!report) { + return icon('faded fa-question-circle', gettext('No report')); + } + if (report.reason === 'offline') { + return icon('faded fa-question-circle', gettext('Node is offline')); + } + if (report.reason === 'stale') { + return icon('warning fa-exclamation-circle', gettext('Report is stale')); + } + if (report['apply-error']) { + return icon( + 'critical fa-times-circle', + gettext('Configuration not applied'), + report['apply-error'], + ); + } + if (!report.supported) { + return icon(problem, gettext('Not installed')); + } + if (!report.running) { + return icon(problem, gettext('Not running')); + } + if (report['report-error']) { + return icon( + 'warning fa-exclamation-circle', + gettext('Query failed'), + report['report-error'], + ); + } + if (report.stalled) { + return icon('warning fa-exclamation-circle', gettext('Status updates stalled')); + } + let notes = []; + if (report['unresolved-luns']) { + notes.push( + Ext.String.format( + ngettext( + '{0} LUN not identified', + '{0} LUNs not identified', + report['unresolved-luns'], + ), + report['unresolved-luns'], + ), + ); + } + if (report.unmanaged) { + notes.push( + Ext.String.format( + ngettext('{0} unmanaged map', '{0} unmanaged maps', report.unmanaged), + report.unmanaged, + ), + ); + } + if (notes.length) { + return icon('warning fa-exclamation-circle', notes.join(', ')); + } + return icon('good fa-check-circle', gettext('Running')); + }, + }, + + // every field is set, so a failed source empties its cells instead of keeping stale values + updateRows: function () { + let me = this; + let mp = me.sources.multipath; + + let rows = {}; + let row = (node) => + (rows[node] ??= { + node, + multipath: undefined, + managed: undefined, + initiator: undefined, + 'initiator-note': undefined, + }); + for (const node of PVE.data.ResourceStore.getNodes()) { + row(node.node); + } + + let inventory = 0; + if (mp.data) { + let nodes = mp.data.nodes || {}; + Object.keys(nodes).forEach((node) => row(node)); + let required = PVE.SANUtils.multipath_required_nodes(mp.data); + let entries = {}; + for (const lun of mp.data.luns || []) { + inventory++; + Ext.Object.each(lun.nodes || {}, (node, entry) => + (entries[node] ??= []).push(entry), + ); + } + for (const node of Object.keys(rows)) { + let report = nodes[node]; + rows[node].multipath = { report, required: !!required[node] }; + rows[node].initiator = report?.['iscsi-initiator-name']; + rows[node]['initiator-note'] = PVE.dc.SANView.reportNote(report); + // a node with too many LUNs lists none of them, its own counts stand for all + if (report?.summary) { + inventory += report.summary.luns ?? 0; + rows[node].managed = PVE.SANUtils.multipath_summary_counts(report); + } else { + rows[node].managed = { + counts: PVE.SANUtils.count_states( + entries[node] ?? [], + PVE.SANUtils.multipath_count_state, + ), + }; + } + } + } + + // update the records in place, a reload keeps the selection + let store = me.getStore(); + store.suspendEvents(); + store.remove(store.getRange().filter((rec) => !rows[rec.getId()])); + Ext.Object.each(rows, function (node, data) { + let rec = store.getById(node); + if (rec) { + rec.set(data, { dirty: false }); + } else { + store.add(data); + } + }); + store.resumeEvents(); + store.fireEvent('refresh', store); + me.getView().refresh(); + // the copy actions follow what the selected row holds now + let sm = me.getSelectionModel(); + sm.fireEvent('selectionchange', sm, sm.getSelection()); + + me.updateHints(inventory); + }, + + updateHints: function (inventory) { + let me = this; + let faults = []; + let notes = []; + Ext.Object.each(me.sources, function (key, source) { + let label = Ext.htmlEncode(source.label); + if (source.denied) { + notes.push( + Ext.String.format( + gettext( + '{0}: reading the status needs the Sys.Audit privilege on "/" or Datastore.Allocate on "/storage".', + ), + label, + ), + ); + } else if (source.error) { + faults.push(`${label}: ${source.error}`); + } + }); + let loaded = Object.values(me.sources).every((source) => source.data); + if (loaded && !inventory) { + notes.push( + gettext( + 'No managed LUNs are listed. Add a storage under Datacenter > Storage > Add > Remote Storage, or enable multipath for a LUN under Node > SAN.', + ), + ); + } + me.down('#faults').setLines(faults); + me.down('#notes').setLines(notes); + }, + + loadSources: function () { + let me = this; + Ext.Object.each(me.sources, function (key, source) { + Proxmox.Utils.API2Request({ + url: source.url, + method: 'GET', + failure: function (response) { + if (me.destroyed) { + return; + } + source.denied = PVE.SANUtils.request_denied(response); + source.error = response.htmlStatus; + source.data = undefined; + me.updateRows(); + }, + success: function (response) { + if (me.destroyed) { + return; + } + source.denied = false; + source.error = undefined; + source.data = response.result.data || {}; + me.updateRows(); + }, + }); + }); + }, + + copySelected: function (field) { + let me = this; + let value = me.getSelection()[0]?.get(field); + if (!value) { + return; + } + PVE.Utils.copyToClipboard(value); + }, + + initComponent: function () { + let me = this; + + me.sources = { + multipath: { label: gettext('Multipath'), url: '/cluster/multipath/status' }, + }; + + me.store = Ext.create('Ext.data.Store', { + model: 'pve-dc-san-node', + sorters: [{ property: 'node' }], + }); + + let copy = (text, field) => ({ + xtype: 'proxmoxButton', + text: text, + iconCls: 'fa fa-clipboard', + disabled: true, + enableFn: (rec) => !!rec.get(field), + handler: () => me.copySelected(field), + }); + me.tbar = [ + { + text: gettext('Reload'), + iconCls: 'fa fa-refresh', + handler: () => me.loadSources(), + }, + copy(gettext('Copy Initiator Name'), 'initiator'), + ]; + + me.dockedItems = [ + { xtype: 'pveSANNotice', itemId: 'faults', kind: 'warning' }, + { xtype: 'pveSANNotice', itemId: 'notes' }, + ]; + + me.callParent(); + + let task = Ext.create('Ext.util.TaskRunner').newTask({ + run: () => me.loadSources(), + interval: 5000, + fireOnStart: true, + }); + me.on('activate', () => task.start()); + me.on('deactivate', () => task.stop()); + me.on('destroy', () => task.destroy()); + }, +}); diff --git a/www/manager6/panel/SANNotice.js b/www/manager6/panel/SANNotice.js new file mode 100644 index 000000000..08a26abb4 --- /dev/null +++ b/www/manager6/panel/SANNotice.js @@ -0,0 +1,26 @@ +Ext.define('PVE.panel.SANNotice', { + extend: 'Ext.Component', + alias: 'widget.pveSANNotice', + + dock: 'top', + hidden: true, + padding: '5 10', + + kind: 'info', + + // lines are safe HTML + setLines: function (lines) { + let me = this; + let icon = me.kind === 'warning' ? 'fa-exclamation-triangle warning' : 'fa-info-circle'; + me.setHtml(lines.map((line) => ` ${line}`).join('
')); + me.setHidden(!lines.length); + }, + + initComponent: function () { + let me = this; + if (me.kind === 'warning') { + me.userCls = 'pmx-hint'; + } + me.callParent(); + }, +}); -- 2.47.3