From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id 456A11FF14C for ; Fri, 26 Jun 2026 16:05:55 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 2D6D5148F7; Fri, 26 Jun 2026 16:05:53 +0200 (CEST) From: Maximiliano Sandoval To: Thomas Lamprecht Subject: Re: [PATCH manager 12/13] ui: dc: add multipath health matrix and config editor In-Reply-To: <20260626121000.2095591-13-t.lamprecht@proxmox.com> (Thomas Lamprecht's message of "Fri, 26 Jun 2026 14:07:42 +0200") References: <20260626121000.2095591-1-t.lamprecht@proxmox.com> <20260626121000.2095591-13-t.lamprecht@proxmox.com> User-Agent: mu4e 1.12.9; emacs 30.1 Date: Fri, 26 Jun 2026 16:05:45 +0200 Message-ID: MIME-Version: 1.0 Content-Type: text/plain X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1782482739344 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.124 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 URIBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to URIBL was blocked. See http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more information. [rec.data,wikipedia.org,me.store] Message-ID-Hash: VQBYM4SIGBWPSTC7GTAKAHXO2BYLEBAO X-Message-ID-Hash: VQBYM4SIGBWPSTC7GTAKAHXO2BYLEBAO X-MailFrom: m.sandoval@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 CC: pve-devel@lists.proxmox.com X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: Thomas Lamprecht writes: > 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'), Imo this should be 'Used By' since there is another Header Capitalization below. In Header Capitalization 2-3 letter words are generally not capitalized, however, the last word in a sentence is generally capitalized. [1] https://en.wikipedia.org/wiki/Title_case > + 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()); > + }, > +}); -- Maximiliano