From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id E982B1FF146 for ; Tue, 07 Jul 2026 15:04:43 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 0C496214A2; Tue, 07 Jul 2026 15:04:30 +0200 (CEST) Message-ID: Date: Tue, 7 Jul 2026 15:04:23 +0200 MIME-Version: 1.0 User-Agent: Mozilla Thunderbird Beta Subject: Re: [PATCH v2 manager 12/13] ui: dc: add multipath health matrix and config editor To: Thomas Lamprecht , pve-devel@lists.proxmox.com References: <20260703124707.1172980-2-t.lamprecht@proxmox.com> <20260703124707.1172980-14-t.lamprecht@proxmox.com> Content-Language: en-US From: Dominik Csapak In-Reply-To: <20260703124707.1172980-14-t.lamprecht@proxmox.com> Content-Type: text/plain; charset=UTF-8; format=flowed Content-Transfer-Encoding: 7bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1783429456580 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.063 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: 72JSKMOQVKONOP47OV7KARDAG6IWMMAH X-Message-ID-Hash: 72JSKMOQVKONOP47OV7KARDAG6IWMMAH X-MailFrom: d.csapak@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: On 7/3/26 5:31 PM, Thomas Lamprecht wrote: > 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}', not sure if the error is here or in the backend, but 'device' is not an allowed global section from us ('devices' is) IMHO we should show an example that could work > + }, > + ], > +}); > + > +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. I'm not completely sure that this is the best way to visualize it here. It works out for a small/medium number of nodes (e.g. 5-10), but i guess for larger clusters (>30 nodes) the amount of columns does not really help anymore) I'd be more in favor of an expandable item (like on the node multipath list) with a single columns that shows an aggregate state? (maybe with a tooltip or button to open a more detailed window) > + 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()); > + }, > +});