From: Thomas Lamprecht <t.lamprecht@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH v3 manager 64/76] ui: dc: add a cluster-wide multipath view
Date: Fri, 6 Nov 2026 00:31:39 +0100 [thread overview]
Message-ID: <20261105233336.2178064-65-t.lamprecht@proxmox.com> (raw)
In-Reply-To: <20261105233336.2178064-1-t.lamprecht@proxmox.com>
Administrators need to see which nodes manage each LUN and whether its
configured paths are usable. Add the cluster-wide multipath overview,
entry editor and node details, including the time of each node report.
Keep health counts visible with faults first and distinguish the
multipath service from the health of the maps it manages. Show
configuration failures and missing reports rather than presenting them
as healthy paths. An optimal LUN with a node in an unknown state is
marked incomplete, as the aggregate leaves that node out.
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
test/Makefile | 6 +
test/SANUtils_test.js | 54 +++
www/manager6/Makefile | 4 +
www/manager6/SANUtils.js | 165 ++++++++
www/manager6/dc/Config.js | 28 +-
www/manager6/dc/Multipath.js | 547 +++++++++++++++++++++++++++
www/manager6/dc/MultipathWwidEdit.js | 183 +++++++++
www/manager6/dc/SAN.js | 326 ++++++++++++++++
www/manager6/panel/SANNotice.js | 26 ++
9 files changed, 1333 insertions(+), 6 deletions(-)
create mode 100644 test/SANUtils_test.js
create mode 100644 www/manager6/dc/Multipath.js
create mode 100644 www/manager6/dc/MultipathWwidEdit.js
create mode 100644 www/manager6/dc/SAN.js
create mode 100644 www/manager6/panel/SANNotice.js
diff --git a/test/Makefile b/test/Makefile
index 2dfd20a41..d7521c0b3 100644
--- a/test/Makefile
+++ b/test/Makefile
@@ -7,6 +7,12 @@ export PERLLIB=..
.PHONY: check
check: test-replication test-balloon test-vzdump test-osd test-ceph-health test-ceph-services test-ceph-auth test-ceph-key-migration test-ceph-lockbox-migration test-custom-cpu-models test-pvesh
+check: test-san-utils
+
+.PHONY: test-san-utils
+test-san-utils:
+ node --test SANUtils_test.js
+
.PHONY: test-balloon
test-balloon:
./balloontest.pl
diff --git a/test/SANUtils_test.js b/test/SANUtils_test.js
new file mode 100644
index 000000000..d22307664
--- /dev/null
+++ b/test/SANUtils_test.js
@@ -0,0 +1,54 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const vm = require('node:vm');
+const { test } = require('node:test');
+
+// load PVE.SANUtils with stubs of the ExtJS and translation helpers it uses
+function sanUtils(translations = {}) {
+ const sandbox = {
+ PVE: {},
+ gettext: (text) => translations[text] ?? text,
+ Ext: {
+ define: (name, config) => {
+ sandbox.PVE.SANUtils = config;
+ },
+ htmlEncode: (text) =>
+ String(text).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<'),
+ String: {
+ format: (text, ...args) => text.replace(/\{(\d+)\}/g, (_, index) => args[index]),
+ },
+ },
+ };
+ vm.createContext(sandbox);
+ const source = path.join(__dirname, '../www/manager6/SANUtils.js');
+ vm.runInContext(fs.readFileSync(source, 'utf8'), sandbox, { filename: source });
+ return sandbox.PVE.SANUtils;
+}
+
+test('cluster health of a LUN', () => {
+ const utils = sanUtils({ 'Optimal (incomplete)': 'Optimal (unvollständig)' });
+ const render = (value, nodes, extra = {}) => {
+ const metaData = {};
+ const html = utils.render_multipath_cluster_health(value, metaData, { nodes, ...extra });
+ return { html, tip: metaData.tdAttr };
+ };
+ const optimal = { state: 'optimal' };
+
+ let res = render('optimal', { n1: optimal, n2: optimal });
+ assert.match(res.html, /> Optimal$/);
+ assert.equal(res.tip, undefined, 'no tooltip while every node is known');
+
+ res = render('optimal', { n2: { state: 'unknown', reason: 'stale' }, n1: optimal, n3: {} });
+ assert.match(res.html, /check-circle good.*> Optimal \(unvollständig\)$/);
+ assert.equal(res.tip, 'data-qtip="State unknown on: n2, n3"', 'unknown nodes, sorted');
+
+ res = render('degraded', { n1: { state: 'degraded' }, n2: { state: 'unknown' } });
+ assert.match(res.html, /> Degraded$/, 'a worse known state needs no qualifier');
+
+ res = render('optimal', { n1: optimal, n2: { reason: 'not-derived' } });
+ assert.match(res.html, /> Optimal$/, 'a node that does not observe the LUN is not unknown');
+
+ res = render('missing', { n1: { state: 'unknown' } }, { 'not-observed': 1 });
+ assert.match(res.html, /> Not currently observed$/);
+});
diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index 3b8780790..252c5761a 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -119,6 +119,7 @@ JSSRC= \
panel/TemplateStatusView.js \
panel/MultiDiskEdit.js \
panel/TagConfig.js \
+ panel/SANNotice.js \
tree/ResourceTree.js \
tree/SnapshotTree.js \
tree/ResourceMapTree.js \
@@ -185,6 +186,8 @@ JSSRC= \
dc/Guests.js \
dc/Health.js \
dc/Log.js \
+ dc/Multipath.js \
+ dc/MultipathWwidEdit.js \
dc/NodeView.js \
dc/OptionView.js \
dc/PermissionView.js \
@@ -192,6 +195,7 @@ JSSRC= \
dc/PoolView.js \
dc/RoleEdit.js \
dc/RoleView.js \
+ dc/SAN.js \
dc/SecurityGroups.js \
dc/StorageView.js \
dc/Summary.js \
diff --git a/www/manager6/SANUtils.js b/www/manager6/SANUtils.js
index b48ad969d..c101ebb75 100644
--- a/www/manager6/SANUtils.js
+++ b/www/manager6/SANUtils.js
@@ -16,6 +16,138 @@ Ext.define('PVE.SANUtils', {
'by-id',
],
+ multipath_health_display: function (value) {
+ return (
+ {
+ optimal: { iconCls: 'check-circle good', label: gettext('Optimal') },
+ degraded: { iconCls: 'exclamation-circle warning', label: gettext('Degraded') },
+ missing: { iconCls: 'times-circle critical', label: gettext('Missing') },
+ failed: { iconCls: 'times-circle critical', label: gettext('Failed') },
+ unknown: { iconCls: 'question-circle faded', label: gettext('Unknown') },
+ 'not-observed': {
+ iconCls: 'eye-slash faded',
+ label: gettext('Not currently observed'),
+ },
+ }[value] ?? { iconCls: 'question-circle faded', label: Ext.htmlEncode(value ?? '') }
+ );
+ },
+
+ render_multipath_health: function (value) {
+ if (typeof value === 'undefined') {
+ return '';
+ }
+ let { iconCls, label } = PVE.SANUtils.multipath_health_display(value);
+ return `<i class="fa fa-${iconCls}"></i> ${label}`;
+ },
+
+ // The aggregate leaves out nodes without a current observation, so an optimal LUN is marked
+ // incomplete while its state on one of them is unknown.
+ render_multipath_cluster_health: function (value, metaData, data) {
+ if (data['not-observed']) {
+ return PVE.SANUtils.render_multipath_health('not-observed');
+ }
+ let nodes = data.nodes || {};
+ let unknown = Object.keys(nodes)
+ .filter((node) => PVE.SANUtils.multipath_count_state(nodes[node]) === 'unknown')
+ .sort();
+ if (value !== 'optimal' || !unknown.length) {
+ return PVE.SANUtils.render_multipath_health(value);
+ }
+ let tip = Ext.String.format(gettext('State unknown on: {0}'), unknown.join(', '));
+ if (metaData) {
+ metaData.tdAttr = `data-qtip="${Ext.htmlEncode(Ext.htmlEncode(tip))}"`;
+ }
+ let { iconCls } = PVE.SANUtils.multipath_health_display(value);
+ return `<i class="fa fa-${iconCls}"></i> ${gettext('Optimal (incomplete)')}`;
+ },
+
+ render_multipath_reason: function (value) {
+ if (!value) {
+ return '';
+ }
+ return (
+ {
+ offline: gettext('Node is offline'),
+ stale: gettext('Report is stale'),
+ 'not-running': gettext('multipathd is not running'),
+ unsupported: gettext('multipath-tools is not installed'),
+ 'query-error': gettext('Querying the multipath maps failed'),
+ 'summary-only': gettext('Too many LUNs to list, see the SAN view of the node'),
+ 'no-report': gettext('Not reporting, the node may run an older version'),
+ stalled: gettext('Status updates of the node are stalled'),
+ 'not-derived': gettext('The storage no longer finds the LUN on the target'),
+ }[value] ?? Ext.htmlEncode(value)
+ );
+ },
+
+ multipath_count_state: function (entry) {
+ if (entry?.reason === 'not-derived') {
+ return 'not-observed';
+ }
+ return entry?.state ?? 'unknown';
+ },
+
+ // the nodes on which missing multipath tools or a stopped multipathd are a problem
+ multipath_required_nodes: function (status) {
+ let required = {};
+ for (const node of status?.['required-nodes'] || []) {
+ required[node] = true;
+ }
+ for (const lun of status?.luns || []) {
+ Object.keys(lun.nodes || {}).forEach((node) => (required[node] = true));
+ }
+ Ext.Object.each(status?.nodes || {}, function (node, report) {
+ if (report?.summary?.luns || report?.['unresolved-luns']) {
+ required[node] = true;
+ }
+ });
+ return required;
+ },
+
+ render_san_state_counts: function (value) {
+ let counts = value?.counts;
+ if (!counts) {
+ return '';
+ }
+ let order = ['failed', 'missing', 'degraded', 'unknown', 'not-observed', 'optimal'];
+ let states = [
+ ...order.filter((state) => counts[state]),
+ ...Object.keys(counts).filter((state) => !order.includes(state) && counts[state]),
+ ];
+ if (!states.length) {
+ return gettext('None');
+ }
+ let text = states
+ .map(
+ (state) => `${counts[state]} ${PVE.SANUtils.multipath_health_display(state).label}`,
+ )
+ .join(', ');
+ if (value.summary) {
+ let qtip = Ext.htmlEncode(
+ gettext(
+ 'The node has too many LUNs to list and reports only the number of LUNs in each state.',
+ ),
+ );
+ text += ` <i class="fa fa-info-circle" data-qtip="${qtip}"></i>`;
+ }
+ return text;
+ },
+
+ count_states: function (entries, stateFn) {
+ let counts = {};
+ for (const entry of entries) {
+ let state = stateFn(entry);
+ counts[state] = (counts[state] ?? 0) + 1;
+ }
+ return counts;
+ },
+
+ // empty without nodes, where render_san_state_counts() says None
+ render_node_states: function (nodes, stateFn) {
+ let counts = PVE.SANUtils.count_states(Object.values(nodes || {}), stateFn);
+ return Object.keys(counts).length ? PVE.SANUtils.render_san_state_counts({ counts }) : '';
+ },
+
// headers and cells are HTML
render_detail_table: function (headers, rows) {
let header = headers.map((text) => `<th style="text-align: left;">${text}</th>`).join('');
@@ -23,6 +155,19 @@ Ext.define('PVE.SANUtils', {
return `<table style="border-spacing: 10px 2px;"><tr>${header}</tr>${body}</table>`;
},
+ // a stalled report keeps its problems but not its optimal LUNs
+ multipath_summary_counts: function (report) {
+ let { luns, ...counts } = report.summary;
+ if (report.reason) {
+ return { counts: { unknown: luns }, summary: true };
+ }
+ if (report.stalled && counts.optimal) {
+ counts.unknown = (counts.unknown ?? 0) + counts.optimal;
+ delete counts.optimal;
+ }
+ return { counts, summary: true };
+ },
+
render_san_lun_device: function (value, metaData, rec) {
let text = Ext.htmlEncode(value);
// only multipath mapped devices report their path count
@@ -98,10 +243,30 @@ Ext.define('PVE.SANUtils', {
}
},
+ // { text, degraded } of the usable paths, undefined without counts; an absent path counts
+ // against the expected count, without one (FC) only the found paths are known
+ format_san_paths: function (active, found, expected) {
+ if (!Ext.isNumeric(found) && !Ext.isNumeric(expected)) {
+ return undefined;
+ }
+ let total = Math.max(found || 0, expected || 0);
+ return {
+ text: `${Ext.isNumeric(active) ? active : '?'}/${total}`,
+ degraded:
+ (Ext.isNumeric(found) && found < total) ||
+ (Ext.isNumeric(active) && active < total),
+ };
+ },
+
request_uncertain: function (response) {
return response.timedout || response.status === 0 || response.result?.success === undefined;
},
+ // the extjs API format reports the HTTP status of a refused request as a string
+ request_denied: function (response) {
+ return Number(response?.result?.status) === 403;
+ },
+
render_san_lun_usage: function (value, metaData, rec) {
let text = PVE.SANUtils.san_lun_usage_text(rec.data);
metaData.tdAttr = `data-qtip="${Ext.htmlEncode(text)}"`;
diff --git a/www/manager6/dc/Config.js b/www/manager6/dc/Config.js
index 83c48c3d6..8a1e25205 100644
--- a/www/manager6/dc/Config.js
+++ b/www/manager6/dc/Config.js
@@ -124,12 +124,28 @@ Ext.define('PVE.dc.Config', {
}
if (caps.storage['Datastore.Allocate'] || caps.dc['Sys.Audit']) {
- me.items.push({
- xtype: 'pveStorageView',
- title: gettext('Storage'),
- iconCls: 'fa fa-database',
- itemId: 'storage',
- });
+ me.items.push(
+ {
+ xtype: 'pveStorageView',
+ title: gettext('Storage'),
+ iconCls: 'fa fa-database',
+ itemId: 'storage',
+ },
+ // storage admins need the node identities for the target ACLs too
+ {
+ xtype: 'pveDcSAN',
+ title: 'SAN',
+ iconCls: 'fa fa-cubes',
+ itemId: 'san',
+ },
+ {
+ xtype: 'pveDcMultipath',
+ title: gettext('Multipath'),
+ iconCls: 'fa fa-code-fork',
+ itemId: 'multipath',
+ groups: ['san'],
+ },
+ );
}
if (caps.dc['Sys.Audit']) {
diff --git a/www/manager6/dc/Multipath.js b/www/manager6/dc/Multipath.js
new file mode 100644
index 000000000..9646b9d72
--- /dev/null
+++ b/www/manager6/dc/Multipath.js
@@ -0,0 +1,547 @@
+Ext.define('pve-dc-multipath-status', {
+ extend: 'Ext.data.Model',
+ fields: [
+ 'wwid',
+ 'source',
+ 'multipath-storage',
+ 'alias',
+ 'used-by',
+ 'size',
+ 'cluster-state',
+ 'not-observed',
+ 'nodes',
+ ],
+ idProperty: 'wwid',
+});
+
+Ext.define('PVE.dc.MultipathOverridesEdit', {
+ extend: 'Proxmox.window.Edit',
+ xtype: 'pveDcMultipathOverridesEdit',
+
+ subject: gettext('Multipath Overrides'),
+ onlineHelp: 'storage_multipath',
+ 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:
+ 'devices {\n\tdevice {\n\t\tvendor "..."\n\t\tproduct "..."\n' +
+ '\t\tpath_grouping_policy ...\n\t}\n}',
+ },
+ ],
+});
+
+Ext.define('PVE.dc.MultipathView', {
+ extend: 'Ext.grid.Panel',
+ xtype: 'pveDcMultipath',
+
+ onlineHelp: 'storage_multipath',
+
+ stateful: true,
+ stateId: 'grid-dc-multipath',
+
+ emptyText: gettext(
+ 'No managed LUNs. To add some, create an iSCSI storage with the Multipath option under Datacenter > Storage > Add > Remote Storage, or enable multipath for a LUN under Node > SAN.',
+ ),
+
+ viewModel: {
+ data: {
+ wwid: '',
+ manual: false,
+ },
+ },
+
+ controller: {
+ xclass: 'Ext.app.ViewController',
+
+ reload: function () {
+ this.getView().rstore.load();
+ },
+
+ selectedRecord: function () {
+ let sel = this.getView().getSelection();
+ return sel.length ? sel[0] : undefined;
+ },
+
+ editWwid: function () {
+ let me = this;
+ let rec = me.selectedRecord();
+ if (!rec) {
+ return;
+ }
+
+ let isCreate = rec.data.source !== 'manual';
+ let hint;
+ if (rec.data.source === 'kept') {
+ // nodes without a current report cannot say whether they keep the map, offer all
+ // listed ones
+ let listed = Object.keys(rec.data.nodes || {});
+ let keeping = listed.filter((node) => rec.data.nodes[node]?.kept);
+ if (!keeping.length) {
+ keeping = listed;
+ }
+ Ext.create('PVE.dc.MultipathWwidEdit', {
+ wwid: rec.data.wwid,
+ isCreate: true,
+ nodes: keeping.length ? keeping : undefined,
+ hint: gettext(
+ 'No storage or entry requires this LUN anymore, so nodes keep its map only while it is in use. Saving creates an explicit entry that keeps the LUN configured on the selected nodes.',
+ ),
+ apiCallDone: (success) => success && me.reload(),
+ autoShow: true,
+ });
+ return;
+ }
+ if (isCreate) {
+ hint = gettext(
+ 'Saving creates a separate explicit entry with these settings, which stays even when no storage requires the LUN anymore.',
+ );
+ if (rec.data['multipath-storage']) {
+ let storage = Ext.String.format(
+ gettext('Storage "{0}" enables multipath for this LUN.'),
+ Ext.htmlEncode(rec.data['multipath-storage']),
+ );
+ hint = `${storage} ${hint}`;
+ }
+ }
+
+ let open = (nodes) =>
+ Ext.create('PVE.dc.MultipathWwidEdit', {
+ wwid: rec.data.wwid,
+ isCreate: isCreate,
+ nodes: nodes,
+ hint: hint,
+ apiCallDone: (success) => success && me.reload(),
+ autoShow: true,
+ });
+
+ // start on the nodes of the storage, others never see the LUN and would only count as
+ // missing
+ if (!isCreate || !rec.data['multipath-storage']) {
+ open(undefined);
+ return;
+ }
+ Proxmox.Utils.API2Request({
+ url: `/storage/${encodeURIComponent(rec.data['multipath-storage'])}`,
+ method: 'GET',
+ waitMsgTarget: me.getView(),
+ failure: () => open(undefined),
+ success: (response) => open(response.result.data?.nodes?.split(',')),
+ });
+ },
+
+ removeWwid: function () {
+ let me = this;
+ let rec = me.selectedRecord();
+ if (!rec || rec.data.source !== 'manual') {
+ return;
+ }
+ let wwid = rec.data.wwid;
+
+ let msg =
+ Ext.String.format(
+ gettext('Remove the multipath entry of LUN {0}?'),
+ Ext.htmlEncode(wwid),
+ ) +
+ '<br><br>' +
+ gettext('Nodes that still use the map keep it until it is no longer in use.');
+
+ Ext.Msg.confirm(gettext('Confirm'), msg, function (btn) {
+ if (btn !== 'yes') {
+ return;
+ }
+ Proxmox.Utils.API2Request({
+ url: `/cluster/multipath/wwid/${encodeURIComponent(wwid)}`,
+ 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,
+ });
+ },
+
+ collectWarnings: function (records) {
+ let nodeStatus = this.getView().nodeStatus || {};
+ let enc = Ext.htmlEncode;
+ let lines = [];
+ let notes = [];
+ // the apply error of these nodes already says that multipath-tools is missing
+ let toolsMissing = {};
+ for (const [node, status] of Object.entries(nodeStatus)) {
+ toolsMissing[node] =
+ status?.['apply-error']?.trim() === 'multipath-tools is not installed';
+ }
+
+ for (const node of Object.keys(nodeStatus).sort()) {
+ let status = nodeStatus[node];
+ if (status['apply-error']) {
+ lines.push(
+ Ext.String.format(
+ gettext('Node "{0}" could not apply the multipath configuration: {1}'),
+ enc(node),
+ enc(status['apply-error']),
+ ),
+ );
+ }
+ if (status['report-error']) {
+ lines.push(
+ Ext.String.format(
+ gettext('Node "{0}" could not query its multipath maps: {1}'),
+ enc(node),
+ enc(status['report-error']),
+ ),
+ );
+ }
+ if (status.unmanaged) {
+ notes.push(
+ Ext.String.format(
+ ngettext(
+ 'Node "{0}" has {1} multipath map outside the cluster-wide configuration, see its SAN view.',
+ 'Node "{0}" has {1} multipath maps outside the cluster-wide configuration, see its SAN view.',
+ status.unmanaged,
+ ),
+ enc(node),
+ status.unmanaged,
+ ),
+ );
+ }
+ if (status['unresolved-luns']) {
+ lines.push(
+ Ext.String.format(
+ ngettext(
+ 'Node "{0}" cannot identify {1} LUN of its multipath-enabled storages because the identifiers of its paths are unavailable or could not be verified.',
+ 'Node "{0}" cannot identify {1} LUNs of its multipath-enabled storages because the identifiers of their paths are unavailable or could not be verified.',
+ status['unresolved-luns'],
+ ),
+ enc(node),
+ status['unresolved-luns'],
+ ),
+ );
+ }
+ if (status.summary) {
+ let { counts } = PVE.SANUtils.multipath_summary_counts(status);
+ let line = Ext.String.format(
+ gettext(
+ 'Node "{0}" has too many LUNs to list here ({1}), see its SAN view.',
+ ),
+ enc(node),
+ PVE.SANUtils.render_san_state_counts({ counts }),
+ );
+ let reason = status.reason ?? (status.stalled ? 'stalled' : undefined);
+ if (reason && !(reason === 'unsupported' && toolsMissing[node])) {
+ line += ` ${PVE.SANUtils.render_multipath_reason(reason)}.`;
+ }
+ let healthy = Object.keys(counts).every(
+ (state) => state === 'optimal' || !counts[state],
+ );
+ (healthy ? notes : lines).push(line);
+ }
+ }
+
+ let unknown = {};
+ for (const rec of records || []) {
+ Ext.Object.each(rec.data.nodes || {}, function (node, entry) {
+ // a node with too many LUNs to list, or whose apply error already says that
+ // multipath-tools is missing, has its own line above
+ if (
+ (entry?.state === 'unknown' || entry?.reason === 'stalled') &&
+ entry.reason &&
+ entry.reason !== 'summary-only' &&
+ !(entry.reason === 'unsupported' && toolsMissing[node])
+ ) {
+ (unknown[entry.reason] ??= {})[node] = true;
+ }
+ });
+ }
+ for (const reason of Object.keys(unknown).sort()) {
+ lines.push(
+ `${enc(Object.keys(unknown[reason]).sort().join(', '))}: ` +
+ PVE.SANUtils.render_multipath_reason(reason),
+ );
+ }
+
+ return { faults: lines, notes };
+ },
+
+ onStoreLoad: function (store, records, success) {
+ let me = this;
+
+ if (!success) {
+ return;
+ }
+
+ // the DiffStore updates a selected row in place, without a selectionchange event
+ me.syncSelection();
+
+ let { faults, notes } = me.collectWarnings(records);
+ let view = me.getView();
+ view.down('#nodeWarnings').setLines(faults);
+ view.down('#nodeNotes').setLines(notes);
+ },
+
+ syncSelection: function () {
+ let vm = this.getViewModel();
+ let rec = this.selectedRecord();
+ vm.set('wwid', rec ? rec.data.wwid : '');
+ vm.set('manual', !!rec && rec.data.source === 'manual');
+ },
+
+ control: {
+ '#': {
+ selectionchange: 'syncSelection',
+ },
+ },
+ },
+
+ plugins: [
+ {
+ ptype: 'rowexpander',
+ expandOnDblClick: false,
+ scrollIntoViewOnExpand: false,
+ rowBodyTpl: [
+ '{[this.renderNodes(values)]}',
+ {
+ renderNodes: function (values) {
+ let enc = Ext.htmlEncode;
+ let nodes = values.nodes || {};
+ let names = Object.keys(nodes).sort();
+ if (!names.length) {
+ return gettext('No node reports this LUN.');
+ }
+
+ let rows = names.map((node) => {
+ let entry = nodes[node] || {};
+ let expected = entry['paths-expected'];
+ let paths = PVE.SANUtils.format_san_paths(
+ entry['paths-active'],
+ entry['paths-total'],
+ expected,
+ );
+ let pathsCell = paths ? enc(paths.text) : '';
+ if (paths && !Ext.isNumeric(expected)) {
+ let qtip = gettext(
+ 'Only the paths found are counted; the expected number of paths is not known.',
+ );
+ pathsCell = `<span data-qtip="${enc(qtip)}">${pathsCell}</span>`;
+ }
+ return [
+ enc(node),
+ PVE.SANUtils.render_multipath_health(
+ PVE.SANUtils.multipath_count_state(entry),
+ ),
+ pathsCell,
+ PVE.SANUtils.render_san_transport(entry.transport),
+ [
+ entry.kept
+ ? Ext.String.format(
+ gettext('Kept while in use ({0})'),
+ enc(entry.kept),
+ )
+ : '',
+ PVE.SANUtils.render_multipath_reason(entry.reason),
+ entry['report-time'] !== undefined
+ ? Ext.String.format(
+ gettext('Last report: {0}'),
+ enc(
+ Proxmox.Utils.render_timestamp(
+ entry['report-time'],
+ ),
+ ),
+ )
+ : '',
+ ]
+ .filter((note) => note !== '')
+ .join('; '),
+ ];
+ });
+
+ return PVE.SANUtils.render_detail_table(
+ [
+ gettext('Node'),
+ gettext('State'),
+ gettext('Paths'),
+ gettext('Transport'),
+ gettext('Notes'),
+ ],
+ rows,
+ );
+ },
+ },
+ ],
+ },
+ ],
+
+ columns: [
+ {
+ 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('Source'),
+ dataIndex: 'source',
+ stateId: 'source',
+ width: 160,
+ renderer: function (value, metaData, rec) {
+ if (value === 'manual') {
+ return gettext('Explicit entry');
+ } else if (value === 'kept') {
+ return gettext('Kept while in use');
+ } else if (value !== 'storage') {
+ return '';
+ }
+ let storeid = rec.data['multipath-storage'];
+ return storeid
+ ? Ext.htmlEncode(Ext.String.format(gettext('Storage "{0}"'), storeid))
+ : gettext('Storage');
+ },
+ },
+ {
+ 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('Nodes'),
+ dataIndex: 'nodes',
+ stateId: 'nodes-health',
+ minWidth: 180,
+ cellWrap: true,
+ width: 220,
+ sortable: false,
+ renderer: (value) =>
+ PVE.SANUtils.render_node_states(value, PVE.SANUtils.multipath_count_state),
+ },
+ {
+ text: gettext('Cluster Health'),
+ dataIndex: 'cluster-state',
+ stateId: 'cluster-state',
+ width: 170,
+ renderer: (value, metaData, rec) =>
+ PVE.SANUtils.render_multipath_cluster_health(value, metaData, rec.data),
+ },
+ ],
+
+ initComponent: function () {
+ let me = this;
+
+ let caps = Ext.state.Manager.get('GuiCap');
+ // the editors load the entry first, which needs Sys.Audit
+ let canModify = !!(caps?.dc?.['Sys.Modify'] && caps?.dc?.['Sys.Audit']);
+
+ let rstore = Ext.create('Proxmox.data.UpdateStore', {
+ storeid: 'dc-multipath-status',
+ interval: 5000,
+ model: 'pve-dc-multipath-status',
+ proxy: {
+ type: 'proxmox',
+ url: '/api2/json/cluster/multipath/status',
+ reader: {
+ rootProperty: 'data.luns',
+ transform: (raw) => {
+ me.nodeStatus = raw?.data?.nodes || {};
+ // in the row data, so a changed report time refreshes an expanded row
+ for (const lun of raw?.data?.luns || []) {
+ for (const [node, entry] of Object.entries(lun.nodes || {})) {
+ entry['report-time'] = me.nodeStatus[node]?.time;
+ }
+ }
+ return raw;
+ },
+ },
+ },
+ });
+ me.rstore = rstore;
+
+ me.store = Ext.create('Proxmox.data.DiffStore', {
+ rstore: rstore,
+ sorters: [{ property: 'wwid' }],
+ });
+ // data only ever comes from the rstore, so drop loads through the memory proxy: they
+ // would replace the synced records with the proxy's empty content
+ me.store.load = Ext.emptyFn;
+
+ me.dockedItems = [
+ { xtype: 'pveSANNotice', itemId: 'nodeWarnings', kind: 'warning' },
+ { xtype: 'pveSANNotice', itemId: 'nodeNotes' },
+ ];
+
+ // only an own entry can be removed, a derived one goes with its storage
+ let hasSelection = canModify ? { bind: { disabled: '{!wwid}' } } : { disabled: true };
+ let isManual = canModify ? { bind: { disabled: '{!manual}' } } : { disabled: true };
+
+ me.tbar = [
+ {
+ text: gettext('Reload'),
+ iconCls: 'fa fa-refresh',
+ handler: 'reload',
+ },
+ Ext.apply(
+ {
+ text: gettext('Edit'),
+ iconCls: 'fa fa-pencil',
+ handler: 'editWwid',
+ },
+ hasSelection,
+ ),
+ Ext.apply(
+ {
+ text: gettext('Remove'),
+ iconCls: 'fa fa-trash-o',
+ handler: 'removeWwid',
+ },
+ isManual,
+ ),
+ '->',
+ {
+ 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());
+ },
+});
diff --git a/www/manager6/dc/MultipathWwidEdit.js b/www/manager6/dc/MultipathWwidEdit.js
new file mode 100644
index 000000000..68a598596
--- /dev/null
+++ b/www/manager6/dc/MultipathWwidEdit.js
@@ -0,0 +1,183 @@
+Ext.define('PVE.dc.MultipathWwidEdit', {
+ extend: 'Proxmox.window.Edit',
+ xtype: 'pveDcMultipathWwidEdit',
+
+ subject: gettext('Multipath LUN'),
+ onlineHelp: 'storage_multipath',
+ width: 520,
+ fieldDefaults: {
+ labelWidth: 150,
+ },
+
+ wwid: undefined,
+ nodes: undefined,
+
+ hint: undefined,
+
+ initComponent: function () {
+ let me = this;
+
+ if (!me.isCreate && !me.wwid) {
+ throw 'no wwid specified';
+ }
+
+ let isCreate = !!me.isCreate;
+
+ let items = [
+ me.wwid
+ ? {
+ xtype: 'displayfield',
+ name: 'wwid',
+ fieldLabel: 'WWID',
+ value: me.wwid,
+ // updates take the WWID from the URL, only creation passes it as a parameter
+ submitValue: isCreate,
+ }
+ : {
+ xtype: 'proxmoxtextfield',
+ name: 'wwid',
+ fieldLabel: 'WWID',
+ allowBlank: false,
+ emptyText: '3600140500a1b2c3d4e5f6a7b8c9d0e1f',
+ },
+ {
+ xtype: 'proxmoxtextfield',
+ name: 'alias',
+ fieldLabel: gettext('Alias'),
+ emptyText: 'san-a-lun0',
+ maxLength: 64,
+ deleteEmpty: !isCreate,
+ regex: /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/,
+ regexText: gettext(
+ 'The alias becomes the name of the map. It must start with a letter or digit and may contain only letters, digits, dots, dashes, and underscores.',
+ ),
+ },
+ {
+ xtype: 'pveNodeSelector',
+ name: 'nodes',
+ fieldLabel: gettext('Nodes'),
+ emptyText: gettext('All') + ' (' + gettext('No restrictions') + ')',
+ multiSelect: true,
+ autoSelect: false,
+ allowBlank: true,
+ skipEmptyText: true,
+ deleteEmpty: !isCreate,
+ value: me.nodes,
+ },
+ ];
+
+ if (me.hint) {
+ items.push({
+ xtype: 'displayfield',
+ value: PVE.Utils.note_text(me.hint),
+ });
+ }
+
+ let advancedItems = [
+ {
+ xtype: 'proxmoxtextfield',
+ name: 'no-path-retry',
+ deleteEmpty: !isCreate,
+ fieldLabel: 'no_path_retry',
+ emptyText: Proxmox.Utils.defaultText,
+ regex: /^(queue|fail|\d+)$/,
+ regexText: gettext(
+ "Either 'queue', 'fail', or the number of polling intervals to retry for.",
+ ),
+ },
+ {
+ xtype: 'proxmoxKVComboBox',
+ name: 'path-grouping-policy',
+ fieldLabel: 'path_grouping_policy',
+ deleteEmpty: !isCreate,
+ value: '__default__',
+ autoEl: {
+ tag: 'div',
+ 'data-qtip': gettext(
+ 'ALUA detection can override this setting, see the documentation.',
+ ),
+ },
+ comboItems: [
+ ['__default__', Proxmox.Utils.defaultText],
+ ['failover', 'failover'],
+ ['multibus', 'multibus'],
+ ['group_by_serial', 'group_by_serial'],
+ ['group_by_prio', 'group_by_prio'],
+ ['group_by_node_name', 'group_by_node_name'],
+ ['group_by_tpg', 'group_by_tpg'],
+ ],
+ },
+ {
+ xtype: 'proxmoxtextfield',
+ name: 'failback',
+ deleteEmpty: !isCreate,
+ fieldLabel: 'failback',
+ emptyText: Proxmox.Utils.defaultText,
+ regex: /^(manual|immediate|followover|\d+)$/,
+ regexText: gettext(
+ "Either 'manual', 'immediate', 'followover', or a number of seconds.",
+ ),
+ },
+ {
+ xtype: 'proxmoxtextfield',
+ name: 'path-selector',
+ deleteEmpty: !isCreate,
+ fieldLabel: 'path_selector',
+ emptyText: 'service-time 0',
+ regex: /^(round-robin|queue-length|service-time|historical-service-time|io-affinity) \d+$/,
+ regexText: gettext(
+ "A path selector as in multipath.conf, for example 'service-time 0'.",
+ ),
+ },
+ {
+ xtype: 'proxmoxintegerfield',
+ name: 'rr-min-io-rq',
+ deleteEmpty: !isCreate,
+ fieldLabel: 'rr_min_io_rq',
+ emptyText: Proxmox.Utils.defaultText,
+ minValue: 1,
+ allowBlank: true,
+ },
+ {
+ xtype: 'proxmoxKVComboBox',
+ name: 'rr-weight',
+ fieldLabel: 'rr_weight',
+ deleteEmpty: !isCreate,
+ value: '__default__',
+ comboItems: [
+ ['__default__', Proxmox.Utils.defaultText],
+ ['priorities', 'priorities'],
+ ['uniform', 'uniform'],
+ ],
+ },
+ ];
+
+ Ext.apply(me, {
+ items: [
+ {
+ xtype: 'inputpanel',
+ items: items,
+ advancedItems: advancedItems,
+ },
+ ],
+ });
+
+ if (isCreate) {
+ me.method = 'POST';
+ me.url = '/cluster/multipath/wwid';
+ } else {
+ me.method = 'PUT';
+ me.url = `/cluster/multipath/wwid/${encodeURIComponent(me.wwid)}`;
+ me.autoLoad = true;
+ me.autoLoadOptions = {
+ success: function (response) {
+ let values = response.result.data;
+ values.nodes = values.nodes ? values.nodes.split(',') : [];
+ me.setValues(values);
+ },
+ };
+ }
+
+ me.callParent();
+ },
+});
diff --git a/www/manager6/dc/SAN.js b/www/manager6/dc/SAN.js
new file mode 100644
index 000000000..586911ef2
--- /dev/null
+++ b/www/manager6/dc/SAN.js
@@ -0,0 +1,326 @@
+Ext.define('pve-dc-san-node', {
+ extend: 'Ext.data.Model',
+ fields: ['node', 'multipath', 'managed', 'initiator', 'initiator-note'],
+ idProperty: 'node',
+});
+
+Ext.define('PVE.dc.SANView', {
+ extend: 'Ext.grid.Panel',
+ xtype: 'pveDcSAN',
+
+ onlineHelp: 'storage_multipath',
+
+ stateful: true,
+ stateId: 'grid-dc-san',
+
+ emptyText: gettext('No node reported its SAN state yet.'),
+
+ sources: undefined,
+
+ columns: [
+ {
+ text: gettext('Node'),
+ dataIndex: 'node',
+ width: 140,
+ renderer: Ext.htmlEncode,
+ },
+ {
+ text: gettext('Multipath Service'),
+ dataIndex: 'multipath',
+ width: 200,
+ renderer: (value) => PVE.dc.SANView.renderNodeMultipath(value),
+ },
+ {
+ text: gettext('Managed LUNs'),
+ dataIndex: 'managed',
+ stateId: 'managed-health',
+ minWidth: 180,
+ cellWrap: true,
+ flex: 1,
+ renderer: (value) => PVE.SANUtils.render_san_state_counts(value),
+ },
+ {
+ text: gettext('iSCSI Initiator Name'),
+ dataIndex: 'initiator',
+ flex: 2,
+ renderer: (value, metaData, rec) =>
+ PVE.dc.SANView.renderIdentity(value, rec, 'initiator-note'),
+ },
+ ],
+
+ statics: {
+ renderIdentity: function (value, rec, noteField) {
+ if (!value) {
+ return '';
+ }
+ let text = Ext.htmlEncode(value);
+ let note = rec.get(noteField);
+ if (note) {
+ let qtip = Ext.htmlEncode(note);
+ text = `<i class="fa fa-clock-o faded" data-qtip="${qtip}"></i> ${text}`;
+ }
+ return text;
+ },
+
+ reportNote: function (entry) {
+ if (entry?.reason === 'offline' || entry?.reason === 'stale') {
+ return PVE.SANUtils.render_multipath_reason(entry.reason);
+ }
+ return entry?.stalled ? PVE.SANUtils.render_multipath_reason('stalled') : undefined;
+ },
+
+ renderNodeMultipath: function (value) {
+ let report = value?.report;
+ let icon = (cls, text, qtip) => {
+ let tip = qtip ? ` data-qtip="${Ext.htmlEncode(qtip)}"` : '';
+ return `<i class="fa fa-fw ${cls}"${tip}></i> ${text}`;
+ };
+ let problem = value?.required
+ ? 'warning fa-exclamation-circle'
+ : 'faded fa-minus-circle';
+ if (!report) {
+ return icon('faded fa-question-circle', gettext('No report'));
+ }
+ if (report.reason === 'offline') {
+ return icon('faded fa-question-circle', gettext('Node is offline'));
+ }
+ if (report.reason === 'stale') {
+ return icon('warning fa-exclamation-circle', gettext('Report is stale'));
+ }
+ if (report['apply-error']) {
+ return icon(
+ 'critical fa-times-circle',
+ gettext('Configuration not applied'),
+ report['apply-error'],
+ );
+ }
+ if (!report.supported) {
+ return icon(problem, gettext('Not installed'));
+ }
+ if (!report.running) {
+ return icon(problem, gettext('Not running'));
+ }
+ if (report['report-error']) {
+ return icon(
+ 'warning fa-exclamation-circle',
+ gettext('Query failed'),
+ report['report-error'],
+ );
+ }
+ if (report.stalled) {
+ return icon('warning fa-exclamation-circle', gettext('Status updates stalled'));
+ }
+ let notes = [];
+ if (report['unresolved-luns']) {
+ notes.push(
+ Ext.String.format(
+ ngettext(
+ '{0} LUN not identified',
+ '{0} LUNs not identified',
+ report['unresolved-luns'],
+ ),
+ report['unresolved-luns'],
+ ),
+ );
+ }
+ if (report.unmanaged) {
+ notes.push(
+ Ext.String.format(
+ ngettext('{0} unmanaged map', '{0} unmanaged maps', report.unmanaged),
+ report.unmanaged,
+ ),
+ );
+ }
+ if (notes.length) {
+ return icon('warning fa-exclamation-circle', notes.join(', '));
+ }
+ return icon('good fa-check-circle', gettext('Running'));
+ },
+ },
+
+ // every field is set, so a failed source empties its cells instead of keeping stale values
+ updateRows: function () {
+ let me = this;
+ let mp = me.sources.multipath;
+
+ let rows = {};
+ let row = (node) =>
+ (rows[node] ??= {
+ node,
+ multipath: undefined,
+ managed: undefined,
+ initiator: undefined,
+ 'initiator-note': undefined,
+ });
+ for (const node of PVE.data.ResourceStore.getNodes()) {
+ row(node.node);
+ }
+
+ let inventory = 0;
+ if (mp.data) {
+ let nodes = mp.data.nodes || {};
+ Object.keys(nodes).forEach((node) => row(node));
+ let required = PVE.SANUtils.multipath_required_nodes(mp.data);
+ let entries = {};
+ for (const lun of mp.data.luns || []) {
+ inventory++;
+ Ext.Object.each(lun.nodes || {}, (node, entry) =>
+ (entries[node] ??= []).push(entry),
+ );
+ }
+ for (const node of Object.keys(rows)) {
+ let report = nodes[node];
+ rows[node].multipath = { report, required: !!required[node] };
+ rows[node].initiator = report?.['iscsi-initiator-name'];
+ rows[node]['initiator-note'] = PVE.dc.SANView.reportNote(report);
+ // a node with too many LUNs lists none of them, its own counts stand for all
+ if (report?.summary) {
+ inventory += report.summary.luns ?? 0;
+ rows[node].managed = PVE.SANUtils.multipath_summary_counts(report);
+ } else {
+ rows[node].managed = {
+ counts: PVE.SANUtils.count_states(
+ entries[node] ?? [],
+ PVE.SANUtils.multipath_count_state,
+ ),
+ };
+ }
+ }
+ }
+
+ // update the records in place, a reload keeps the selection
+ let store = me.getStore();
+ store.suspendEvents();
+ store.remove(store.getRange().filter((rec) => !rows[rec.getId()]));
+ Ext.Object.each(rows, function (node, data) {
+ let rec = store.getById(node);
+ if (rec) {
+ rec.set(data, { dirty: false });
+ } else {
+ store.add(data);
+ }
+ });
+ store.resumeEvents();
+ store.fireEvent('refresh', store);
+ me.getView().refresh();
+ // the copy actions follow what the selected row holds now
+ let sm = me.getSelectionModel();
+ sm.fireEvent('selectionchange', sm, sm.getSelection());
+
+ me.updateHints(inventory);
+ },
+
+ updateHints: function (inventory) {
+ let me = this;
+ let faults = [];
+ let notes = [];
+ Ext.Object.each(me.sources, function (key, source) {
+ let label = Ext.htmlEncode(source.label);
+ if (source.denied) {
+ notes.push(
+ Ext.String.format(
+ gettext(
+ '{0}: reading the status needs the Sys.Audit privilege on "/" or Datastore.Allocate on "/storage".',
+ ),
+ label,
+ ),
+ );
+ } else if (source.error) {
+ faults.push(`${label}: ${source.error}`);
+ }
+ });
+ let loaded = Object.values(me.sources).every((source) => source.data);
+ if (loaded && !inventory) {
+ notes.push(
+ gettext(
+ 'No managed LUNs are listed. Add a storage under Datacenter > Storage > Add > Remote Storage, or enable multipath for a LUN under Node > SAN.',
+ ),
+ );
+ }
+ me.down('#faults').setLines(faults);
+ me.down('#notes').setLines(notes);
+ },
+
+ loadSources: function () {
+ let me = this;
+ Ext.Object.each(me.sources, function (key, source) {
+ Proxmox.Utils.API2Request({
+ url: source.url,
+ method: 'GET',
+ failure: function (response) {
+ if (me.destroyed) {
+ return;
+ }
+ source.denied = PVE.SANUtils.request_denied(response);
+ source.error = response.htmlStatus;
+ source.data = undefined;
+ me.updateRows();
+ },
+ success: function (response) {
+ if (me.destroyed) {
+ return;
+ }
+ source.denied = false;
+ source.error = undefined;
+ source.data = response.result.data || {};
+ me.updateRows();
+ },
+ });
+ });
+ },
+
+ copySelected: function (field) {
+ let me = this;
+ let value = me.getSelection()[0]?.get(field);
+ if (!value) {
+ return;
+ }
+ PVE.Utils.copyToClipboard(value);
+ },
+
+ initComponent: function () {
+ let me = this;
+
+ me.sources = {
+ multipath: { label: gettext('Multipath'), url: '/cluster/multipath/status' },
+ };
+
+ me.store = Ext.create('Ext.data.Store', {
+ model: 'pve-dc-san-node',
+ sorters: [{ property: 'node' }],
+ });
+
+ let copy = (text, field) => ({
+ xtype: 'proxmoxButton',
+ text: text,
+ iconCls: 'fa fa-clipboard',
+ disabled: true,
+ enableFn: (rec) => !!rec.get(field),
+ handler: () => me.copySelected(field),
+ });
+ me.tbar = [
+ {
+ text: gettext('Reload'),
+ iconCls: 'fa fa-refresh',
+ handler: () => me.loadSources(),
+ },
+ copy(gettext('Copy Initiator Name'), 'initiator'),
+ ];
+
+ me.dockedItems = [
+ { xtype: 'pveSANNotice', itemId: 'faults', kind: 'warning' },
+ { xtype: 'pveSANNotice', itemId: 'notes' },
+ ];
+
+ me.callParent();
+
+ let task = Ext.create('Ext.util.TaskRunner').newTask({
+ run: () => me.loadSources(),
+ interval: 5000,
+ fireOnStart: true,
+ });
+ me.on('activate', () => task.start());
+ me.on('deactivate', () => task.stop());
+ me.on('destroy', () => task.destroy());
+ },
+});
diff --git a/www/manager6/panel/SANNotice.js b/www/manager6/panel/SANNotice.js
new file mode 100644
index 000000000..08a26abb4
--- /dev/null
+++ b/www/manager6/panel/SANNotice.js
@@ -0,0 +1,26 @@
+Ext.define('PVE.panel.SANNotice', {
+ extend: 'Ext.Component',
+ alias: 'widget.pveSANNotice',
+
+ dock: 'top',
+ hidden: true,
+ padding: '5 10',
+
+ kind: 'info',
+
+ // lines are safe HTML
+ setLines: function (lines) {
+ let me = this;
+ let icon = me.kind === 'warning' ? 'fa-exclamation-triangle warning' : 'fa-info-circle';
+ me.setHtml(lines.map((line) => `<i class="fa ${icon}"></i> ${line}`).join('<br>'));
+ me.setHidden(!lines.length);
+ },
+
+ initComponent: function () {
+ let me = this;
+ if (me.kind === 'warning') {
+ me.userCls = 'pmx-hint';
+ }
+ me.callParent();
+ },
+});
--
2.47.3
next prev parent reply other threads:[~2026-11-06 1:02 UTC|newest]
Thread overview: 77+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-11-05 23:30 [PATCH v3 proxmox,proxmox-perl-rs,cluster,storage,docs,manager 00/76] SAN: cluster-wide multipath, SAN views and a guided storage setup Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 proxmox 01/76] add proxmox-multipath crate Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 proxmox-perl-rs 02/76] pve-rs: add PVE::RS::Multipath bindings for multipathd status parsing Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 cluster 03/76] cfs: observe the multipath configuration files Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 04/76] storage: distinguish status-only activation in plugins Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 05/76] lvm: find a shared volume group created by another node Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 06/76] storage: bound LVM status commands Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 07/76] iscsi: rate-limit session rescans per target Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 08/76] iscsi: list the LUNs of each session, not of its SCSI host Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 09/76] lvm: refuse to create a volume group on a device that holds data Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 10/76] lvm: refuse a base volume of a target that serves disk images Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 11/76] iscsi: refuse virtual devices other than multipath maps as volumes Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 12/76] diskmanage: collect disk transport type from lsblk Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 13/76] diskmanage: add helper to list multipath devices Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 14/76] diskmanage: qualify NVMe over fabrics transport Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 15/76] disks: list: add include-remote parameter Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 16/76] diskmanage: include iSCSI session devices in disk enumeration Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 17/76] diskmanage: link multipath member disks to their map device Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 18/76] iscsi: factor out session device map from device list Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 19/76] api: scan: add a SAN LUN inventory Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 20/76] pvesm: expose the " Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 21/76] disks: lvm: allow volume groups on SAN LUNs and multipath maps Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 22/76] multipath: add a library around multipathd Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 23/76] diskmanage: collect native NVMe multipath path state Thomas Lamprecht
2026-11-05 23:30 ` [PATCH v3 storage 24/76] api: scan: san-luns: report " Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 25/76] diskmanage: collect local block device users Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 26/76] api: add node-level iSCSI status and maintenance Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 27/76] api: iscsi: add session logout with last-path protection Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 28/76] multipath: add a cluster-wide configuration Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 29/76] iscsi: add an option to manage storage LUNs with multipath Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 30/76] lvm: activate a missing base volume before using the volume group Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 31/76] multipath: apply the configuration on every node Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 32/76] iscsi: share the initiator identity reader Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 33/76] multipath: publish per-node status Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 34/76] api: add endpoints for the cluster-wide multipath configuration Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 35/76] api: multipath: add the cluster-wide health status Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 36/76] multipath: collect identities of non-iSCSI SCSI paths Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 37/76] api: scan: san-luns: report the multipath state of each LUN Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 38/76] api: scan: flag duplicate physical volumes on distinct LUNs Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 39/76] api: disks: recheck local users and retain wipe device references Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 40/76] iscsi: name plain LUNs after their identity link Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 41/76] api: disks: guard SAN wipes with stable identities and storage checks Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 storage 42/76] api: scan: refresh SAN usage with bounded probes Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 docs 43/76] storage: lvm: note the rescan after extending a shared volume group Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 docs 44/76] storage: document the cluster-wide multipath management Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 45/76] api: ceph: osd: keep iSCSI LUNs out of OSD creation Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 46/76] pvestatd: track pending storage status commands across re-exec Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 47/76] ui: storage: factor out the scan picker error mask Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 48/76] ui: storage: nfs: show scan errors and empty results Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 49/76] ui: storage: allow changing the node for NFS/CIFS scans Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 50/76] ui: storage: add guided remote storage wizard with NFS support Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 51/76] ui: storage wizard: add SMB/CIFS support Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 52/76] ui: storage wizard: preserve settings after partial setup Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 53/76] ui: storage wizard: add Fibre Channel shared LVM setup Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 54/76] ui: storage wizard: reconcile unanswered storage creation Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 55/76] ui: storage wizard: add ZFS over iSCSI support Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 56/76] ui: dc: storage: add remote storage wizard entry to the add menu Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 57/76] ui: node: add SAN LUNs panel Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 58/76] ui: san luns: show multipath path state Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 59/76] api: nodes: expose iSCSI status and maintenance Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 60/76] pvenode: add iscsi commands Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 61/76] ui: node: san: add iSCSI target and session management Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 62/76] pvestatd: apply the multipath configuration and report the node status Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 63/76] api: cluster: add the multipath endpoints Thomas Lamprecht
2026-11-05 23:31 ` Thomas Lamprecht [this message]
2026-11-05 23:31 ` [PATCH v3 manager 65/76] ui: storage: iscsi: add the multipath option Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 66/76] ui: san luns: show multipath state and manage LUN entries Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 67/76] ui: storage wizard: add iSCSI support Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 68/76] ui: storage wizard: iscsi: show cluster initiator names Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 69/76] ui: storage wizard: iscsi: enable multipath for shared LVM Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 70/76] ui: node: san: wipe a LUN that no storage uses Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 71/76] ui: storage wizard: iscsi: reuse existing target connections Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 72/76] ui: storage wizard: fibre channel: enable multipath for a LUN Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 73/76] ui: storage wizard: let root@pam wipe a LUN with data Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 74/76] ui: storage wizard: do not offer LUNs in use for a new volume group Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 75/76] ui: san: show the local users of a LUN in its usage tooltip Thomas Lamprecht
2026-11-05 23:31 ` [PATCH v3 manager 76/76] ui: san: check the other nodes for users before wiping a LUN 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=20261105233336.2178064-65-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.