From: Thomas Lamprecht <t.lamprecht@proxmox.com>
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 [thread overview]
Message-ID: <20260626121000.2095591-13-t.lamprecht@proxmox.com> (raw)
In-Reply-To: <20260626121000.2095591-1-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>
---
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 '<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> ' + 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 '<span style="opacity:0.5">-</span>';
+ }
+ 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
next prev parent reply other threads:[~2026-06-26 12:11 UTC|newest]
Thread overview: 16+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-06-26 12:07 [PATCH storage,cluster,manager 0/13] multipath: cluster-wide config, storage and health overview Thomas Lamprecht
2026-06-26 12:07 ` [PATCH storage 01/13] multipath: add helper library and managed configuration Thomas Lamprecht
2026-06-26 14:43 ` Maximiliano Sandoval
2026-06-26 12:07 ` [PATCH storage 02/13] api: disks: add read-only multipath status endpoint Thomas Lamprecht
2026-06-26 12:07 ` [PATCH storage 03/13] api: multipath: add cluster-wide configuration endpoints Thomas Lamprecht
2026-06-26 12:07 ` [PATCH storage 04/13] multipath: add storage plugin for multipath LUNs Thomas Lamprecht
2026-06-26 12:07 ` [PATCH storage 05/13] lvm: allow a multipath storage as the base device Thomas Lamprecht
2026-06-26 12:07 ` [PATCH storage 06/13] multipath: broadcast per-node map health to the cluster KV store Thomas Lamprecht
2026-06-26 12:07 ` [PATCH storage 07/13] api: multipath: add cluster-wide health status endpoint Thomas Lamprecht
2026-06-26 12:07 ` [PATCH cluster 08/13] pmxcfs: track cluster-wide multipath configuration Thomas Lamprecht
2026-06-26 12:07 ` [PATCH manager 09/13] pvestatd: apply the cluster-wide multipath config on each node Thomas Lamprecht
2026-06-26 12:07 ` [PATCH manager 10/13] api: cluster: mount the multipath configuration endpoint Thomas Lamprecht
2026-06-26 12:07 ` [PATCH manager 11/13] pvestatd: broadcast multipath map health to the cluster Thomas Lamprecht
2026-06-26 12:07 ` Thomas Lamprecht [this message]
2026-06-26 14:05 ` [PATCH manager 12/13] ui: dc: add multipath health matrix and config editor Maximiliano Sandoval
2026-06-26 12:07 ` [PATCH 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=20260626121000.2095591-13-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.