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 7266A1FF142 for ; Fri, 03 Jul 2026 17:31:20 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 1381721496; Fri, 03 Jul 2026 17:30:59 +0200 (CEST) From: Thomas Lamprecht To: pve-devel@lists.proxmox.com Subject: [PATCH v2 manager 12/13] ui: dc: add multipath health matrix and config editor Date: Fri, 3 Jul 2026 14:46:12 +0200 Message-ID: <20260703124707.1172980-14-t.lamprecht@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260703124707.1172980-2-t.lamprecht@proxmox.com> References: <20260703124707.1172980-2-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: 1783092616405 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.021 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) 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: CFDRY4VYOCT5GSA4CN3JIKIH27ZVTNB5 X-Message-ID-Hash: CFDRY4VYOCT5GSA4CN3JIKIH27ZVTNB5 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 --- Changes in v2: - update the grid in place via a DiffStore instead of reloading - never load the diff store from its empty memory proxy: the stateful grid re-applies its state on reconfigure, and the resulting load wiped the first synced batch of rows right after opening the panel - give the base columns stable state ids, reconfigure invalidated their saved widths and order on every panel open - pass the force flag for removals in the query string, a request body is rejected for DELETE - warn about nodes that failed to apply the config - edit the overrides through their own endpoint; the v1 save never worked, its auto-sent digest was rejected by the PUT schema - refresh after edits via apiCallDone (taskDone never fired) and keep the selection-bound actions current on in-place row updates - HTML-encode all values from other nodes in the renderers - read the new { luns, nodes } shape; assorted review fixes www/manager6/Makefile | 1 + www/manager6/Utils.js | 25 ++ www/manager6/dc/Config.js | 6 + www/manager6/dc/Multipath.js | 444 +++++++++++++++++++++++++++++++++++ 4 files changed, 476 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..e5854cb73 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 ' ' + Ext.htmlEncode(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..fac353d5b --- /dev/null +++ b/www/manager6/dc/Multipath.js @@ -0,0 +1,444 @@ +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'), + method: 'POST', + url: '/cluster/multipath/alias', + + initComponent: function () { + let me = this; + + if (!me.wwid) { + throw 'no wwid specified'; + } + me.isCreate = !me.alias; + + 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 text 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/overrides', + + 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().rstore.load(); + }, + + selectedRecord: function () { + let sel = this.getView().getSelection(); + return sel.length ? sel[0] : undefined; + }, + + addWwid: function () { + let me = this; + Ext.create('PVE.dc.MultipathWwidEdit', { + apiCallDone: (success) => success && 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, + apiCallDone: (success) => success && 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; + } + // the flag must go into the query string, a request body is rejected for DELETE + let force = usedBy ? '?force=1' : ''; + Proxmox.Utils.API2Request({ + url: `/cluster/multipath/wwid/${encodeURIComponent(wwid)}${force}`, + 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, + }); + }, + + // 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, success) { + let me = this; + let view = me.getView(); + + if (!success) { + return; + } + + // surface nodes that could not apply the cluster config (sidecar to the per-WWID rows) + let nodeStatus = view.applyNodeStatus || {}; + let failed = Object.keys(nodeStatus).filter((n) => nodeStatus[n]['apply-error']); + let warn = view.down('#applyWarning'); + if (warn) { + if (failed.length) { + let lines = failed + .map( + (n) => + `${Ext.htmlEncode(n)}: ${Ext.htmlEncode(nodeStatus[n]['apply-error'])}`, + ) + .join('
'); + warn.setHtml( + ' ' + + gettext('Some nodes could not apply the multipath configuration:') + + '
' + + lines, + ); + } + warn.setHidden(!failed.length); + } + + let nodes = {}; + (records || []).forEach((rec) => { + Ext.Object.each(rec.data.nodes || {}, (node) => { + nodes[node] = true; + }); + }); + let nodeList = Object.keys(nodes).sort(); + + // the DiffStore updates selected rows in place, so refresh the selection-derived + // view-model values (alias may have changed without a selectionchange event) + me.syncSelection(); + + if (Ext.Array.equals(nodeList, view.shownNodes || [])) { + return; + } + view.shownNodes = nodeList; + + let nodeColumns = nodeList.map((node) => ({ + text: node, + stateId: `node-${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) { + let paths = `${st['paths-active']}/${st['paths-total']}`; + out += ` (${Ext.htmlEncode(paths)})`; + } + return out; + }, + })); + + view.reconfigure(undefined, view.baseColumns.concat(nodeColumns)); + }, + + syncSelection: function () { + let vm = this.getViewModel(); + let rec = this.selectedRecord(); + vm.set('wwid', rec ? rec.data.wwid : ''); + vm.set('alias', rec && rec.data.alias ? rec.data.alias : ''); + }, + + control: { + '#': { + selectionchange: 'syncSelection', + }, + }, + }, + + // explicit stateIds: the auto-generated ones change on every reconfigure() for the dynamic + // node columns, which would keep invalidating the saved widths and order of these + baseColumns: [ + { + 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('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('Cluster Health'), + dataIndex: 'cluster-state', + stateId: '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. Wrap the polling store in a DiffStore so only changed rows + // update instead of the whole grid rebuilding and flickering every interval. + let rstore = 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', + reader: { + // rows come from data.luns; stash the sibling per-node apply-status sidecar so + // onStoreLoad can flag nodes that failed to apply the config + rootProperty: 'data.luns', + transform: (raw) => { + me.applyNodeStatus = (raw && raw.data && raw.data.nodes) || {}; + return raw; + }, + }, + }, + }); + me.rstore = rstore; + + me.store = Ext.create('Proxmox.data.DiffStore', { + rstore: rstore, + sorters: [{ property: 'wwid' }], + }); + // the stateful grid re-applies its saved state on every reconfigure, and that issues a + // load on this store, replacing the records synced from the rstore with the empty + // memory-proxy content; data only ever comes from the rstore, so drop proxy loads + me.store.load = Ext.emptyFn; + + // shown when a node fails to apply the cluster config, populated from the status sidecar + me.dockedItems = [ + { + xtype: 'component', + itemId: 'applyWarning', + dock: 'top', + hidden: true, + padding: '5 10', + // themed hint surface (light/dark aware), matching other inline PVE warnings + userCls: 'pmx-hint', + html: '', + }, + ]; + + // 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-trash-o', + 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, 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()); + }, +}); -- 2.47.3