From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id DE09F1FF14C for ; Fri, 26 Jun 2026 14:11:42 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id A82D4FA54; Fri, 26 Jun 2026 14:10:57 +0200 (CEST) From: Thomas Lamprecht To: pve-devel@lists.proxmox.com Subject: [PATCH manager 12/13] ui: dc: add multipath health matrix and config editor Date: Fri, 26 Jun 2026 14:07:42 +0200 Message-ID: <20260626121000.2095591-13-t.lamprecht@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260626121000.2095591-1-t.lamprecht@proxmox.com> References: <20260626121000.2095591-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: 1782475802011 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.005 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment 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: 7KVFIXRSBWCLMWQ24ZXNYG7WO72G5MJ3 X-Message-ID-Hash: 7KVFIXRSBWCLMWQ24ZXNYG7WO72G5MJ3 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: Give cluster operators a single place to answer the question that no per-node view can: for each multipath LUN, is every node that should see it seeing it with full path redundancy? The grid loads the cluster status and lays out a WWID by node matrix, coloring each cell by the per-node map state and the combined cluster state, so a LUN that is degraded on just one node stands out at a glance. The same panel manages the cluster config: the WWID allow-list, per-WWID aliases, and the verbatim hardware override sections. Node columns are built from the loaded data, since a node only appears once it is actively multipathing. Signed-off-by: Thomas Lamprecht --- www/manager6/Makefile | 1 + www/manager6/Utils.js | 25 +++ www/manager6/dc/Config.js | 6 + www/manager6/dc/Multipath.js | 371 +++++++++++++++++++++++++++++++++++ 4 files changed, 403 insertions(+) create mode 100644 www/manager6/dc/Multipath.js diff --git a/www/manager6/Makefile b/www/manager6/Makefile index d4dd3f351..1a3b845c8 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -183,6 +183,7 @@ JSSRC= \ dc/Guests.js \ dc/Health.js \ dc/Log.js \ + dc/Multipath.js \ dc/NodeView.js \ dc/OptionView.js \ dc/PermissionView.js \ diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js index 040b5ae01..3b5044e1d 100644 --- a/www/manager6/Utils.js +++ b/www/manager6/Utils.js @@ -274,6 +274,31 @@ Ext.define('PVE.Utils', { return ' ' + value; }, + // Maps a multipath map state (optimal/degraded/missing/failed/unknown) to an icon; shared + // by the datacenter health matrix and the node view. + render_multipath_health: function (value) { + if (typeof value === 'undefined') { + return ''; + } + let iconCls = 'question-circle'; + switch (value) { + case 'optimal': + iconCls = 'check-circle good'; + break; + case 'degraded': + iconCls = 'exclamation-circle warning'; + break; + case 'missing': + case 'failed': + iconCls = 'times-circle critical'; + break; + case 'unknown': + default: + iconCls = 'question-circle faded'; + } + return ' ' + value; + }, + validateZfsBlocksize: function (value) { if (!value) { return true; diff --git a/www/manager6/dc/Config.js b/www/manager6/dc/Config.js index e17066368..dfba75e8a 100644 --- a/www/manager6/dc/Config.js +++ b/www/manager6/dc/Config.js @@ -120,6 +120,12 @@ Ext.define('PVE.dc.Config', { iconCls: 'fa fa-gear', itemId: 'options', }, + { + xtype: 'pveDcMultipath', + title: gettext('Multipath'), + iconCls: 'fa fa-road', + itemId: 'multipath', + }, ); } diff --git a/www/manager6/dc/Multipath.js b/www/manager6/dc/Multipath.js new file mode 100644 index 000000000..70f561f83 --- /dev/null +++ b/www/manager6/dc/Multipath.js @@ -0,0 +1,371 @@ +Ext.define('PVE-dc-multipath-status', { + extend: 'Ext.data.Model', + fields: ['wwid', 'alias', 'used-by', 'size', 'cluster-state', 'nodes'], + idProperty: 'wwid', +}); + +// Edit window to add a WWID to the cluster-wide multipath allow-list. +Ext.define('PVE.dc.MultipathWwidEdit', { + extend: 'Proxmox.window.Edit', + xtype: 'pveDcMultipathWwidEdit', + + subject: gettext('Multipath LUN'), + isCreate: true, + method: 'POST', + url: '/cluster/multipath/wwid', + + items: [ + { + xtype: 'proxmoxtextfield', + name: 'wwid', + fieldLabel: 'WWID', + allowBlank: false, + emptyText: '3600140500a1b2c3d4e5f6a7b8c9d0e1f', + }, + ], +}); + +// Edit window to set or replace a WWID's alias. +Ext.define('PVE.dc.MultipathAliasEdit', { + extend: 'Proxmox.window.Edit', + xtype: 'pveDcMultipathAliasEdit', + + subject: gettext('Multipath Alias'), + isCreate: true, + method: 'POST', + url: '/cluster/multipath/alias', + + initComponent: function () { + let me = this; + + if (!me.wwid) { + throw 'no wwid specified'; + } + + Ext.apply(me, { + items: [ + { + xtype: 'displayfield', + name: 'wwid', + fieldLabel: 'WWID', + value: me.wwid, + submitValue: true, + }, + { + xtype: 'proxmoxtextfield', + name: 'alias', + fieldLabel: gettext('Alias'), + allowBlank: false, + value: me.alias, + emptyText: 'san-a-lun0', + }, + ], + }); + + me.callParent(); + }, +}); + +// Edit window for the verbatim multipath.conf override sections (hardware- and SAN-specific +// 'device {}' / 'overrides {}' blocks). Loads the current config and writes the overrides back. +Ext.define('PVE.dc.MultipathOverridesEdit', { + extend: 'Proxmox.window.Edit', + xtype: 'pveDcMultipathOverridesEdit', + + subject: gettext('Multipath Overrides'), + width: 600, + autoLoad: true, + method: 'PUT', + url: '/cluster/multipath', + + items: [ + { + xtype: 'textarea', + name: 'overrides', + fieldLabel: gettext('Overrides'), + height: 320, + fieldStyle: 'font-family: monospace;', + emptyText: + "device {\n\tvendor \"...\"\n\tproduct \"...\"\n\tpath_grouping_policy ...\n}", + }, + ], +}); + +Ext.define('PVE.dc.MultipathView', { + extend: 'Ext.grid.Panel', + xtype: 'pveDcMultipath', + + stateful: true, + stateId: 'grid-dc-multipath', + + emptyText: gettext('No multipath LUNs configured or reported.'), + + viewModel: { + data: { + wwid: '', + alias: '', + }, + }, + + controller: { + xclass: 'Ext.app.ViewController', + + reload: function () { + this.getView().getStore().load(); + }, + + selectedRecord: function () { + let sel = this.getView().getSelection(); + return sel.length ? sel[0] : undefined; + }, + + addWwid: function () { + let me = this; + Ext.create('PVE.dc.MultipathWwidEdit', { + taskDone: () => me.reload(), + autoShow: true, + }); + }, + + setAlias: function () { + let me = this; + let rec = me.selectedRecord(); + if (!rec) { + return; + } + Ext.create('PVE.dc.MultipathAliasEdit', { + wwid: rec.data.wwid, + alias: rec.data.alias, + taskDone: () => me.reload(), + autoShow: true, + }); + }, + + removeAlias: function () { + let me = this; + let rec = me.selectedRecord(); + if (!rec || !rec.data.alias) { + return; + } + Proxmox.Utils.API2Request({ + url: `/cluster/multipath/alias/${encodeURIComponent(rec.data.wwid)}`, + method: 'DELETE', + waitMsgTarget: me.getView(), + failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), + success: () => me.reload(), + }); + }, + + removeWwid: function () { + let me = this; + let rec = me.selectedRecord(); + if (!rec) { + return; + } + let wwid = rec.data.wwid; + let usedBy = rec.data['used-by']; + let msg = usedBy + ? Ext.String.format( + gettext( + "WWID '{0}' is still used by storage '{1}'. Removing it" + + ' drops the multipath map and the storage loses its' + + ' device. Remove anyway?', + ), + wwid, + usedBy, + ) + : Ext.String.format( + gettext("Remove WWID '{0}' from the multipath allow-list?"), + wwid, + ); + Ext.Msg.confirm(gettext('Confirm'), msg, function (btn) { + if (btn !== 'yes') { + return; + } + Proxmox.Utils.API2Request({ + url: `/cluster/multipath/wwid/${encodeURIComponent(wwid)}`, + method: 'DELETE', + params: usedBy ? { force: 1 } : undefined, + 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', { + taskDone: () => me.reload(), + autoShow: true, + }); + }, + + // Rebuild the per-node columns from the loaded matrix; nodes only appear once they + // actively multipath, so the column set is data-driven. + onStoreLoad: function (store, records) { + let me = this; + let view = me.getView(); + + let nodes = {}; + (records || []).forEach((rec) => { + Ext.Object.each(rec.data.nodes || {}, (node) => { + nodes[node] = true; + }); + }); + let nodeList = Object.keys(nodes).sort(); + + if (Ext.Array.equals(nodeList, view.shownNodes || [])) { + return; + } + view.shownNodes = nodeList; + + let nodeColumns = nodeList.map((node) => ({ + text: node, + align: 'center', + width: 120, + sortable: false, + menuDisabled: true, + renderer: function (val, meta, rec) { + let st = (rec.data.nodes || {})[node]; + if (!st) { + return '-'; + } + let out = PVE.Utils.render_multipath_health(st.state); + if (st['paths-total'] !== undefined) { + out += ` (${st['paths-active']}/${st['paths-total']})`; + } + return out; + }, + })); + + view.reconfigure(undefined, view.baseColumns.concat(nodeColumns)); + }, + + control: { + '#': { + selectionchange: function (grid, selected) { + let vm = this.getViewModel(); + let rec = selected.length ? selected[0] : undefined; + vm.set('wwid', rec ? rec.data.wwid : ''); + vm.set('alias', rec && rec.data.alias ? rec.data.alias : ''); + }, + }, + }, + }, + + baseColumns: [ + { + text: 'WWID', + dataIndex: 'wwid', + flex: 2, + }, + { + text: gettext('Alias'), + dataIndex: 'alias', + width: 140, + renderer: (v) => v || '', + }, + { + text: gettext('Used by'), + dataIndex: 'used-by', + width: 120, + renderer: (v) => v || '', + }, + { + text: gettext('Size'), + dataIndex: 'size', + width: 90, + align: 'right', + renderer: (v) => (v ? Proxmox.Utils.format_size(v) : ''), + }, + { + text: gettext('Cluster Health'), + dataIndex: 'cluster-state', + width: 130, + renderer: PVE.Utils.render_multipath_health, + }, + ], + + initComponent: function () { + let me = this; + + let caps = Ext.state.Manager.get('GuiCap'); + let canModify = !!(caps && caps.dc && caps.dc['Sys.Modify']); + + me.columns = me.baseColumns; + + // health is live status: poll while the panel is shown so a path going down surfaces + // without a manual reload + me.store = Ext.create('Proxmox.data.UpdateStore', { + storeid: 'dc-multipath-status', + interval: 3000, + model: 'PVE-dc-multipath-status', + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/multipath/status', + }, + sorters: [{ property: 'wwid' }], + }); + + // a row selection only gates the modify actions when the user may modify + let selectionGate = canModify ? { bind: { disabled: '{!wwid}' } } : { disabled: true }; + + me.tbar = [ + { + text: gettext('Reload'), + iconCls: 'fa fa-refresh', + handler: 'reload', + }, + { + text: gettext('Add WWID'), + iconCls: 'fa fa-plus-circle', + disabled: !canModify, + handler: 'addWwid', + }, + Ext.apply( + { + text: gettext('Set Alias'), + iconCls: 'fa fa-tag', + handler: 'setAlias', + }, + selectionGate, + ), + Ext.apply( + { + text: gettext('Remove'), + iconCls: 'fa fa-bars', + menu: [ + { + text: gettext('Remove WWID'), + iconCls: 'fa fa-fw fa-trash-o', + handler: 'removeWwid', + }, + { + text: gettext('Remove Alias'), + iconCls: 'fa fa-fw fa-eraser', + bind: { disabled: '{!alias}' }, + handler: 'removeAlias', + }, + ], + }, + selectionGate, + ), + '->', + { + text: gettext('Edit Overrides'), + iconCls: 'fa fa-pencil', + disabled: !canModify, + handler: 'editOverrides', + }, + ]; + + me.callParent(); + + Proxmox.Utils.monStoreErrors(me, me.store, true); + me.mon(me.store, 'load', me.getController().onStoreLoad, me.getController()); + me.on('activate', () => me.store.startUpdate()); + me.on('deactivate', () => me.store.stopUpdate()); + me.on('destroy', () => me.store.stopUpdate()); + }, +}); -- 2.47.3