From: Thomas Lamprecht <t.lamprecht@proxmox.com>
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 [thread overview]
Message-ID: <20260703124707.1172980-14-t.lamprecht@proxmox.com> (raw)
In-Reply-To: <20260703124707.1172980-2-t.lamprecht@proxmox.com>
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 <t.lamprecht@proxmox.com>
---
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 '<i class="fa fa-' + iconCls + '"></i> ' + 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 '<i class="fa fa-' + iconCls + '"></i> ' + 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('<br>');
+ warn.setHtml(
+ '<i class="fa fa-exclamation-triangle warning"></i> ' +
+ gettext('Some nodes could not apply the multipath configuration:') +
+ '<br>' +
+ 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 '<span style="opacity:0.5">-</span>';
+ }
+ 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
next prev parent reply other threads:[~2026-07-03 15:31 UTC|newest]
Thread overview: 14+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-07-03 12:46 [PATCH v2 storage,cluster,manager 0/13] multipath: cluster-wide config, storage and health overview Thomas Lamprecht
2026-07-03 12:46 ` [PATCH v2 storage 01/13] multipath: add helper library and managed configuration Thomas Lamprecht
2026-07-03 12:46 ` [PATCH v2 storage 02/13] api: disks: add read-only multipath status endpoint Thomas Lamprecht
2026-07-03 12:46 ` [PATCH v2 storage 03/13] api: multipath: add cluster-wide configuration endpoints Thomas Lamprecht
2026-07-03 12:46 ` [PATCH v2 storage 04/13] multipath: add storage plugin for multipath LUNs Thomas Lamprecht
2026-07-03 12:46 ` [PATCH v2 storage 05/13] lvm: allow a multipath storage as the base device Thomas Lamprecht
2026-07-03 12:46 ` [PATCH v2 storage 06/13] multipath: broadcast per-node map health to the cluster KV store Thomas Lamprecht
2026-07-03 12:46 ` [PATCH v2 storage 07/13] api: multipath: add cluster-wide health status endpoint Thomas Lamprecht
2026-07-03 12:46 ` [PATCH v2 cluster 08/13] pmxcfs: track cluster-wide multipath configuration Thomas Lamprecht
2026-07-03 12:46 ` [PATCH v2 manager 09/13] pvestatd: apply the cluster-wide multipath config on each node Thomas Lamprecht
2026-07-03 12:46 ` [PATCH v2 manager 10/13] api: cluster: mount the multipath configuration endpoint Thomas Lamprecht
2026-07-03 12:46 ` [PATCH v2 manager 11/13] pvestatd: broadcast multipath map health to the cluster Thomas Lamprecht
2026-07-03 12:46 ` Thomas Lamprecht [this message]
2026-07-03 12:46 ` [PATCH v2 manager 13/13] ui: node: show multipath maps and their paths under Disks Thomas Lamprecht
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260703124707.1172980-14-t.lamprecht@proxmox.com \
--to=t.lamprecht@proxmox.com \
--cc=pve-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.