From: Thomas Lamprecht <t.lamprecht@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH v3 manager 67/76] ui: storage wizard: add iSCSI support
Date: Fri, 6 Nov 2026 00:31:42 +0100 [thread overview]
Message-ID: <20261105233336.2178064-68-t.lamprecht@proxmox.com> (raw)
In-Reply-To: <20261105233336.2178064-1-t.lamprecht@proxmox.com>
Guide iSCSI connection and shared LVM setup, with direct LUN use as an
alternative. Offer shared LVM only with the required privileges and keep
volume-group creation on the node serving the web UI.
Connect and Select LUN creates and activates the connection storage,
then lists LUNs on Confirm. Bound scans and discard obsolete responses.
If the scan is empty after failed or unconfirmed activation, report that
rather than suggesting target access rules are wrong. Offer unused LUNs
with usable identities and paths. Retain the last inventory after a
scan timeout and preselect a sole candidate only on the first settled
scan.
Create Volume Group is the explicit destructive action. Verify the
selected LUN identity and require the submitted volume name to encode
that identity, rather than joining separate observations by LUN number.
Keep retries on that device and skip completed steps. After a failure,
keep the connection storage and explain what remains. Share LVM
submission across transports through submitSanLvm().
Co-authored-by: Dietmar Maurer <dietmar@proxmox.com>
Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
debian/control | 1 +
test/Makefile | 6 +-
test/StorageWizard_test.js | 117 ++++++
www/manager6/Makefile | 3 +
www/manager6/SANUtils.js | 10 +-
www/manager6/storage/wizard/IScsi.js | 461 +++++++++++++++++++++++
www/manager6/storage/wizard/LunSelect.js | 446 ++++++++++++++++++++++
www/manager6/storage/wizard/SanLvm.js | 114 ++++++
www/manager6/storage/wizard/Wizard.js | 45 ++-
9 files changed, 1194 insertions(+), 9 deletions(-)
create mode 100644 test/StorageWizard_test.js
create mode 100644 www/manager6/storage/wizard/IScsi.js
create mode 100644 www/manager6/storage/wizard/LunSelect.js
create mode 100644 www/manager6/storage/wizard/SanLvm.js
diff --git a/debian/control b/debian/control
index abffe183b..04addf09a 100644
--- a/debian/control
+++ b/debian/control
@@ -21,6 +21,7 @@ Build-Depends: debhelper-compat (= 13),
libtemplate-perl,
libtest-mockmodule-perl,
lintian,
+ nodejs,
proxmox-biome,
proxmox-widget-toolkit (>= 4.1.4),
pve-cluster,
diff --git a/test/Makefile b/test/Makefile
index d7521c0b3..2854688c8 100644
--- a/test/Makefile
+++ b/test/Makefile
@@ -7,12 +7,16 @@ 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
+check: test-san-utils test-storage-wizard
.PHONY: test-san-utils
test-san-utils:
node --test SANUtils_test.js
+.PHONY: test-storage-wizard
+test-storage-wizard:
+ node --test StorageWizard_test.js
+
.PHONY: test-balloon
test-balloon:
./balloontest.pl
diff --git a/test/StorageWizard_test.js b/test/StorageWizard_test.js
new file mode 100644
index 000000000..48305ccb3
--- /dev/null
+++ b/test/StorageWizard_test.js
@@ -0,0 +1,117 @@
+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');
+
+const selectedWWID = '36000000000000000000000000000000a';
+const otherWWID = '36000000000000000000000000000000b';
+const target = 'iqn.test';
+
+function submit({ name, multipath = false, freshWWID = selectedWWID, lun = 7 }) {
+ const alerts = [];
+ const submitted = [];
+ const sandbox = {
+ PVE: { storage: { wizard: { types: {}, reconcile: {} } } },
+ Proxmox: { NodeName: 'n1' },
+ gettext: (text) => text,
+ ngettext: (single, plural, count) => (count === 1 ? single : plural),
+ Ext: {
+ define: () => undefined,
+ htmlEncode: (text) => String(text),
+ clone: (value) => structuredClone(value),
+ String: {
+ format: (text, ...args) => text.replace(/\{(\d+)\}/g, (_, index) => args[index]),
+ },
+ Msg: { alert: (...args) => alerts.push(args) },
+ },
+ };
+ vm.createContext(sandbox);
+ for (const file of ['SanLvm.js', 'IScsi.js']) {
+ const source = path.join(__dirname, '../www/manager6/storage/wizard', file);
+ vm.runInContext(fs.readFileSync(source, 'utf8'), sandbox, { filename: source });
+ }
+ const selected = {
+ 'iscsi-target': target,
+ 'iscsi-lun': 7,
+ wwid: selectedWWID,
+ };
+ const wizard = {
+ created: { baseStorage: 'base' },
+ lunView: {
+ scanGeneration: 1,
+ selectionGeneration: 1,
+ getSelection: () => selected,
+ waitForLuns: () => undefined,
+ },
+ getScanNode: () => 'n1',
+ down: (selector) =>
+ selector === 'pveStorageWizardIScsiTarget' ? { targetPortals: 1 } : undefined,
+ actionText: () => 'Create Volume Group',
+ updateSummary: () => undefined,
+ canSubmit: () => true,
+ activateLunView: () => true,
+ runSubmitChain: (steps) => {
+ let index = 0;
+ const next = () => {
+ if (index < steps.length) {
+ steps[index++](next);
+ }
+ };
+ next();
+ },
+ request: (request) => {
+ const data = request.url.endsWith('/content')
+ ? [{ volid: `base:0.0.${lun}.${name}` }]
+ : [{ 'iscsi-target': target, 'iscsi-lun': 7, wwid: freshWWID }];
+ request.success({ result: { data } });
+ },
+ createStorage: (params, next) => {
+ submitted.push(params);
+ next();
+ },
+ };
+ sandbox.PVE.storage.wizard.types.iscsi.submit(wizard, {
+ usage: 'lvm',
+ storage: 'vg',
+ 'base-storage': 'base',
+ target,
+ portal: '192.0.2.1',
+ content: 'images',
+ multipath,
+ });
+ return { submitted, alerts, created: wizard.created };
+}
+
+for (const [label, params, accepted] of [
+ ['plain path with the selected identity', { name: `scsi-${selectedWWID}` }, true],
+ ['unmanaged map with the selected identity', { name: `dm-uuid-mpath-${selectedWWID}` }, true],
+ [
+ 'different content identity despite a matching fresh scan',
+ { name: `scsi-${otherWWID}` },
+ false,
+ ],
+ ['different unmanaged map identity', { name: `dm-uuid-mpath-${otherWWID}` }, false],
+ ['opaque alias', { name: 'dm-name-array-lun' }, false],
+ ['partition of the selected LUN', { name: `scsi-${selectedWWID}-part1` }, false],
+ ['different LUN number', { name: `scsi-${selectedWWID}`, lun: 8 }, false],
+ [
+ 'identity changed in the fresh scan',
+ { name: `scsi-${selectedWWID}`, freshWWID: otherWWID },
+ false,
+ ],
+]) {
+ test(label, () => {
+ const { submitted, alerts, created } = submit(params);
+ if (accepted) {
+ assert.equal(alerts.length, 0);
+ assert.equal(submitted.length, 1);
+ assert.equal(submitted[0].base, `base:0.0.7.${params.name}`);
+ assert.equal(created.lun.wwid, selectedWWID);
+ } else {
+ assert.equal(submitted.length, 0);
+ assert.equal(alerts.length, 1);
+ assert.equal(created.baseVolume, undefined);
+ }
+ });
+}
diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index 252c5761a..a90d5e0fd 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -388,8 +388,11 @@ JSSRC= \
storage/wizard/Wizard.js \
storage/wizard/CommonSettings.js \
storage/wizard/LunPicker.js \
+ storage/wizard/LunSelect.js \
+ storage/wizard/SanLvm.js \
storage/wizard/NFS.js \
storage/wizard/CIFS.js \
+ storage/wizard/IScsi.js \
storage/wizard/FibreChannel.js \
storage/wizard/ZFSOverISCSI.js \
Workspace.js \
diff --git a/www/manager6/SANUtils.js b/www/manager6/SANUtils.js
index 4893cd562..bd8115e31 100644
--- a/www/manager6/SANUtils.js
+++ b/www/manager6/SANUtils.js
@@ -365,9 +365,9 @@ Ext.define('PVE.SANUtils', {
return Number(response?.result?.status) === 403;
},
- // returns { text, tip } as HTML
- san_lun_usage: function (row) {
- let text = PVE.SANUtils.san_lun_usage_text(row);
+ // returns { text, tip } as HTML; an attribution to ownStorage alone does not count as use
+ san_lun_usage: function (row, { ownStorage } = {}) {
+ let text = PVE.SANUtils.san_lun_usage_text(row, ownStorage);
let tooltip = [text];
if (row['duplicate-pv']) {
let tip = gettext(
@@ -396,7 +396,7 @@ Ext.define('PVE.SANUtils', {
return content === '' || content === 'none';
},
- san_lun_usage_text: function (row) {
+ san_lun_usage_text: function (row, ownStorage) {
let value = row.usage;
if (value === 'unknown') {
return Ext.htmlEncode(
@@ -406,7 +406,7 @@ Ext.define('PVE.SANUtils', {
),
);
}
- let sid = row['used-by-storage'];
+ let sid = row['used-by-storage'] !== ownStorage ? row['used-by-storage'] : undefined;
// the scan also attributes LUNs to a connection-only storage, which leaves them free
let connects = sid && PVE.SANUtils.san_storage_only_connects(sid);
if (value === 'unused') {
diff --git a/www/manager6/storage/wizard/IScsi.js b/www/manager6/storage/wizard/IScsi.js
new file mode 100644
index 000000000..fb4decb38
--- /dev/null
+++ b/www/manager6/storage/wizard/IScsi.js
@@ -0,0 +1,461 @@
+Ext.define('PVE.storage.wizard.IScsiTarget', {
+ extend: 'Proxmox.panel.InputPanel',
+ xtype: 'pveStorageWizardIScsiTarget',
+
+ onlineHelp: 'storage_open_iscsi',
+
+ initComponent: function () {
+ let me = this;
+
+ me.column1 = [
+ {
+ xtype: 'textfield',
+ name: 'portal',
+ fieldLabel: 'Portal',
+ allowBlank: false,
+ listeners: {
+ change: {
+ fn: function (f, value) {
+ let targetField = me.down('field[name=target]');
+ targetField.setDisabled(!value);
+ targetField.setPortal(value);
+ targetField.setValue('');
+ },
+ buffer: 500,
+ },
+ },
+ },
+ {
+ xtype: 'pveIScsiScan',
+ name: 'target',
+ fieldLabel: gettext('Target'),
+ disabled: true,
+ showNodeSelector: false,
+ },
+ ];
+
+ me.column2 = [];
+ if (!PVE.Utils.isStandaloneNode()) {
+ me.column2.push({
+ xtype: 'pveStorageScanNodeSelector',
+ listeners: {
+ change: (f, value) => me.down('field[name=target]').setNodeName(value),
+ },
+ });
+ }
+
+ me.callParent();
+ },
+});
+
+Ext.define('PVE.storage.wizard.IScsiUsage', {
+ extend: 'Proxmox.panel.InputPanel',
+ xtype: 'pveStorageWizardIScsiUsage',
+
+ onlineHelp: 'storage_open_iscsi',
+
+ // returns { text (HTML), actionText, action } when the usage cannot proceed; submit rechecks
+ blockedReason: function () {
+ let me = this;
+ let wizard = me.up('window');
+ if (wizard.getViewModel().get('usage') !== 'lvm') {
+ return undefined;
+ }
+ if (wizard.getScanNode() !== Proxmox.NodeName) {
+ return {
+ text: Ext.String.format(
+ gettext(
+ 'Shared LVM is set up from node "{0}", which this web UI is connected to.',
+ ),
+ Ext.htmlEncode(Proxmox.NodeName),
+ ),
+ actionText: Ext.String.format(gettext('Scan from "{0}"'), Proxmox.NodeName),
+ action: () => wizard.down('pveStorageScanNodeSelector')?.setValue(Proxmox.NodeName),
+ };
+ }
+ return undefined;
+ },
+
+ validator: function () {
+ return !this.blockedReason();
+ },
+
+ updateBlockedReason: function () {
+ let me = this;
+ let reason = me.blockedReason();
+ let hint = me.down('#blockedReason');
+ hint.setValue(reason?.text ?? '');
+ hint.setHidden(!reason);
+ let button = me.down('#blockedAction');
+ button.setText(Ext.htmlEncode(reason?.actionText ?? ''));
+ button.setHidden(!reason?.action);
+ me.blockedAction = reason?.action;
+ // the wizard validates on field changes only, a reason found later must disable Next itself
+ let wizard = me.up('window');
+ if (wizard?.down('#wizcontent')?.getActiveTab() === me) {
+ wizard.down('#next').setDisabled(!!reason);
+ }
+ },
+
+ initComponent: function () {
+ let me = this;
+
+ // the LVM branch reads LUNs and volumes before creating anything, check the privileges up
+ // front
+ let caps = Ext.state.Manager.get('GuiCap');
+ let lvmAllowed = !!(
+ caps.storage['Datastore.Allocate'] &&
+ (caps.storage['Datastore.Audit'] || caps.storage['Datastore.AllocateSpace'])
+ );
+
+ me.items = [
+ {
+ xtype: 'displayfield',
+ value: gettext('How do you want to use the SAN storage?'),
+ },
+ {
+ xtype: 'radiogroup',
+ columns: 1,
+ vertical: true,
+ items: [
+ {
+ boxLabel:
+ `<b>${gettext('Shared LVM on a LUN (recommended)')}</b> - ` +
+ gettext(
+ 'Create an LVM volume group on a LUN. Disk images are logical volumes in it, shared by the nodes of the storage.',
+ ),
+ name: 'usage',
+ inputValue: 'lvm',
+ checked: lvmAllowed,
+ disabled: !lvmAllowed,
+ margin: '0 0 10 0',
+ },
+ {
+ boxLabel:
+ `<b>${gettext('Use LUNs directly')}</b> - ` +
+ gettext(
+ 'Each LUN is used as one disk image. You create, resize, and remove the LUNs on the SAN.',
+ ),
+ name: 'usage',
+ inputValue: 'direct',
+ checked: !lvmAllowed,
+ },
+ ],
+ listeners: {
+ change: function (rg, value) {
+ rg.up('window').getViewModel().set('usage', value.usage);
+ me.updateBlockedReason();
+ },
+ // the view model defaults to lvm, which the privileges may rule out
+ afterrender: function (rg) {
+ rg.up('window').getViewModel().set('usage', rg.getValue().usage);
+ },
+ },
+ },
+ {
+ xtype: 'displayfield',
+ hidden: lvmAllowed,
+ value: PVE.Utils.note_text(
+ gettext(
+ 'Shared LVM requires Datastore.Allocate and either Datastore.Audit or Datastore.AllocateSpace on /storage.',
+ ),
+ ),
+ },
+ {
+ xtype: 'displayfield',
+ itemId: 'blockedReason',
+ userCls: 'pmx-hint',
+ hidden: true,
+ value: '',
+ },
+ {
+ xtype: 'button',
+ itemId: 'blockedAction',
+ hidden: true,
+ handler: function () {
+ me.blockedAction?.();
+ me.updateBlockedReason();
+ },
+ },
+ ];
+
+ me.callParent();
+
+ // the steps before may have changed when this one comes up again
+ me.on('activate', () => me.updateBlockedReason());
+ },
+});
+
+PVE.storage.wizard.reconcile.iscsi = {
+ keys: ['target', 'portal', 'multipath'],
+ booleans: ['multipath'],
+};
+
+PVE.storage.wizard.types.iscsi = {
+ text: 'iSCSI',
+ description: gettext('Block storage (LUNs) on a SAN, accessed over the network.'),
+ group: 'san',
+ apiType: 'iscsi',
+ viewModel: {
+ data: {
+ usage: 'lvm',
+ },
+ formulas: {
+ isLvmUsage: (get) => get('usage') === 'lvm',
+ },
+ },
+ steps: () => [
+ {
+ xtype: 'pveStorageWizardIScsiTarget',
+ title: gettext('Target'),
+ },
+ {
+ xtype: 'pveStorageWizardIScsiUsage',
+ title: gettext('Usage'),
+ },
+ ],
+ settings: {
+ onlineHelp: 'storage_open_iscsi',
+ cts: ['images', 'rootdir'],
+ defaultContent: ['images', 'rootdir'],
+ contentFieldConfig: {
+ bind: {
+ disabled: '{!isLvmUsage}',
+ hidden: '{!isLvmUsage}',
+ },
+ },
+ nodesFieldConfig: {
+ validator: PVE.storage.wizard.lvmNodesValidator,
+ },
+ advancedColumn1: [
+ {
+ xtype: 'textfield',
+ name: 'base-storage',
+ fieldLabel: gettext('iSCSI storage ID'),
+ emptyText: gettext('ID') + '-base',
+ vtype: 'StorageId',
+ allowBlank: true,
+ bind: {
+ disabled: '{!isLvmUsage}',
+ hidden: '{!isLvmUsage}',
+ },
+ },
+ ],
+ },
+ summaryFields: {
+ usage: {
+ label: gettext('Usage'),
+ values: {
+ lvm: gettext('Shared LVM on a LUN'),
+ direct: gettext('Use LUNs directly'),
+ },
+ weight: 10,
+ },
+ },
+
+ confirmCard: true,
+
+ actionText: function (wizard) {
+ if (wizard.getValues().usage !== 'lvm') {
+ return undefined;
+ }
+ return gettext('Connect and Select LUN');
+ },
+
+ summaryRows: function (values, wizard) {
+ if (values.usage !== 'lvm') {
+ return {};
+ }
+ let created = wizard.created ?? {};
+ let base = values['base-storage'] || `${values.storage}-base`;
+ if (created.baseStorage) {
+ base = Ext.String.format(gettext('{0} (created)'), created.baseStorage);
+ }
+ let rows = [
+ { label: gettext('LVM storage'), value: values.storage, weight: 20 },
+ { label: gettext('iSCSI storage'), value: base, weight: 21 },
+ {
+ label: 'LUN',
+ value: created.baseVolume ?? gettext('Selected in the next step'),
+ weight: 22,
+ },
+ ];
+ let skip = ['storage', 'base-storage'];
+ return { rows, skip };
+ },
+
+ summaryNotes: function (values, wizard) {
+ if (values.usage === 'lvm') {
+ let base = values['base-storage'] || `${values.storage}-base`;
+ return [
+ Ext.String.format(
+ gettext(
+ 'Press Connect and Select LUN to add the iSCSI storage "{0}" and list its LUNs. The volume group is created on the LUN you select next.',
+ ),
+ Ext.htmlEncode(base),
+ ),
+ ];
+ }
+ return [];
+ },
+
+ submit: function (wizard, values) {
+ let enc = Ext.htmlEncode;
+ let usage = values.usage;
+ delete values.usage;
+
+ if (usage === 'direct') {
+ delete values['base-storage'];
+ values.type = 'iscsi';
+ values.content = 'images';
+ wizard.runSubmitChain([(next) => wizard.createStorage(values, next)]);
+ return;
+ }
+
+ PVE.storage.wizard.submitSanLvm(wizard, values, {
+ sameIdText: gettext('The iSCSI storage and the LVM storage need different IDs.'),
+ volumeGroupText: gettext(
+ 'Press {2} to retry the remaining steps. If the volume group "{0}" already exists on the LUN, you can also complete the setup under Datacenter > Storage > Add > LVM, with base volume "{1}" and volume group "{0}".',
+ ),
+ sameUnitText: gettext(
+ 'Press {1} to try the same LUN again, for example after wiping it. The iSCSI storage "{0}" remains after closing the wizard. Complete its setup under Datacenter > Storage > Add > LVM.',
+ ),
+ // the LUNs of a target that another storage already logs in to are that storage's, and
+ // may be in use through it
+ checkTarget: (entries) => {
+ let other = entries.find(
+ (entry) => entry.type === 'iscsi' && entry.target === values.target,
+ );
+ return other
+ ? Ext.String.format(
+ gettext(
+ 'The iSCSI storage "{0}" already uses this target. Create the LVM storage on top of it under Datacenter > Storage > Add > LVM.',
+ ),
+ enc(other.storage),
+ )
+ : undefined;
+ },
+ baseParams: (baseId) => ({
+ storage: baseId,
+ type: 'iscsi',
+ portal: values.portal,
+ target: values.target,
+ content: 'none',
+ nodes: values.nodes,
+ }),
+ lunView: (node, baseId) => ({
+ xtype: 'pveStorageWizardLunSelect',
+ footnote: Ext.String.format(
+ gettext(
+ 'Closing the wizard keeps storage "{0}". Complete the setup under Datacenter > Storage > Add > LVM if the wizard is closed.',
+ ),
+ enc(baseId),
+ ),
+ belongs: (row) => row['iscsi-target'] === values.target,
+ connectionUrl: `/nodes/${node}/iscsi/target`,
+ connections: (rows) =>
+ rows
+ .filter(
+ (row) =>
+ row.target === values.target && row['session-id'] !== undefined,
+ )
+ .map((row) => row['session-id']),
+ rescanUrl: `/nodes/${node}/iscsi/rescan`,
+ rescanParam: 'session-id',
+ }),
+ verifyVolume: function ({ node, baseId, lun, pin }) {
+ let view = wizard.lunView;
+ let scan = view.scanGeneration;
+ let selection = view.selectionGeneration;
+ let current = () =>
+ !view.destroyed &&
+ scan === view.scanGeneration &&
+ selection === view.selectionGeneration;
+ let unverified = function (changed) {
+ if (!current()) {
+ return;
+ }
+ view.waitForLuns(false);
+ Ext.Msg.alert(
+ gettext('Error'),
+ changed
+ ? Ext.String.format(
+ gettext(
+ 'LUN {0} of the target is a different LUN now. Select the LUN again.',
+ ),
+ enc(lun['iscsi-lun']),
+ )
+ : gettext('Could not verify the selected LUN. Press Rescan.'),
+ );
+ };
+ if (!lun.wwid || lun.wwid === 'unknown') {
+ unverified(false);
+ return;
+ }
+ wizard.request({
+ url: `/nodes/${node}/storage/${baseId}/content`,
+ method: 'GET',
+ params: { content: 'images' },
+ failure: () => {
+ if (!current()) {
+ return;
+ }
+ unverified(false);
+ },
+ success: function (response) {
+ if (!current()) {
+ return;
+ }
+ // LUN numbers can be reassigned between requests. The submitted by-id
+ // name must encode the selected identity for the backend to check it.
+ let volume = (response.result.data || []).find((entry) => {
+ let [, , , number, name] =
+ entry.volid
+ .replace(/^[^:]+:/, '')
+ .match(/^(\d+)\.(\d+)\.(\d+)\.(.+)$/) ?? [];
+ return (
+ Number(number) === lun['iscsi-lun'] &&
+ (name === `dm-uuid-mpath-${lun.wwid}` ||
+ name === `scsi-${lun.wwid}`)
+ );
+ });
+ if (!volume) {
+ unverified(false);
+ return;
+ }
+ wizard.request({
+ url: `/nodes/${node}/scan/san-luns`,
+ method: 'GET',
+ failure: () => unverified(false),
+ success: function (fresh) {
+ if (!current()) {
+ return;
+ }
+ let rows = (fresh.result.data || []).filter(
+ (row) =>
+ row['iscsi-target'] === values.target &&
+ Number(row['iscsi-lun']) === Number(lun['iscsi-lun']),
+ );
+ if (
+ !rows.length ||
+ rows.some(
+ (row) =>
+ !row.wwid ||
+ row.wwid === 'unknown' ||
+ row['wwid-unknown'],
+ )
+ ) {
+ unverified(false);
+ } else if (rows.some((row) => row.wwid !== lun.wwid)) {
+ unverified(true);
+ } else {
+ pin(volume);
+ }
+ },
+ });
+ },
+ });
+ },
+ });
+ },
+};
diff --git a/www/manager6/storage/wizard/LunSelect.js b/www/manager6/storage/wizard/LunSelect.js
new file mode 100644
index 000000000..270daaa73
--- /dev/null
+++ b/www/manager6/storage/wizard/LunSelect.js
@@ -0,0 +1,446 @@
+// The node re-checks the LUN before creating the volume group, so selecting it is the only
+// confirmation: no double click or Enter submit.
+Ext.define('PVE.storage.wizard.LunSelect', {
+ extend: 'Ext.panel.Panel',
+ xtype: 'pveStorageWizardLunSelect',
+ mixins: ['PVE.storage.wizard.LunPicker'],
+
+ border: false,
+ layout: { type: 'vbox', align: 'stretch' },
+
+ node: undefined,
+ storage: undefined,
+ belongs: undefined,
+ multipath: false,
+ initiator: undefined,
+ recap: undefined,
+ footnote: undefined,
+ connectionUrl: undefined,
+ connections: undefined,
+ rescanUrl: undefined,
+ rescanParam: undefined,
+
+ tabTitle: gettext('LUN'),
+
+ selectText: gettext('Select an unused LUN for the volume group.'),
+ waitText: gettext('Waiting for node "{0}" to list the LUNs of the target...'),
+ errorText: gettext('Could not list the LUNs of the target on node "{0}": {1}'),
+ selectedText: gettext('The volume group will be created on LUN {0} ({1} {2}, {3}).'),
+ pinnedText: gettext(
+ 'Press {4} to retry on LUN {0} ({1} {2}, {3}). The wizard keeps this LUN, as the previous attempt may have written to it.',
+ ),
+ noneText: gettext(
+ 'No unused LUN on the target. Present an empty LUN to initiator "{0}", then press Rescan.',
+ ),
+ noSessionText: gettext(
+ 'Node "{0}" has no session to the target. Check that the target allows initiator "{1}" and that a portal is reachable, then press Rescan.',
+ ),
+ activationErrorText: gettext('Activating storage "{0}" failed: {1}'),
+ activationUnknownText: gettext('The outcome of activating storage "{0}" is unknown: {1}'),
+
+ // not one usable device yet: no identity, or with multipath a path still waiting for its map
+ assembling: function (row) {
+ let me = this;
+ if (!row.wwid || row.wwid === 'unknown') {
+ return true;
+ }
+ if (!me.multipath) {
+ return false;
+ }
+ return (
+ row.multipath === 'pending' ||
+ (row.multipath === 'none' && row['multipath-storage'] === me.storage)
+ );
+ },
+
+ // I/O to a map without a usable path could stay queued indefinitely
+ notReady: function (row) {
+ let me = this;
+ if (me.assembling(row)) {
+ return gettext('Not ready');
+ }
+ let mapped = ['storage', 'manual', 'unmanaged', 'kept'].includes(row.multipath);
+ if (me.multipath && !mapped) {
+ return gettext('Not ready');
+ }
+ if (mapped && !(row['paths-active'] > 0)) {
+ return gettext('No usable path');
+ }
+ return undefined;
+ },
+
+ getSelection: function () {
+ return this.down('#lunGrid').getSelection()[0]?.data;
+ },
+
+ canSubmit: function () {
+ let me = this;
+ return (
+ !me.scanning &&
+ !me.scanError &&
+ (!!me.up('window').created?.baseVolume || !!me.getSelection()?.selectable)
+ );
+ },
+
+ updateButtons: function () {
+ this.up('window').updateActions();
+ },
+
+ updateState: function () {
+ let me = this;
+ let wizard = me.up('window');
+ let pinned = wizard.created?.baseVolume;
+ let grid = me.down('#lunGrid');
+ let rec = grid.getSelection()[0];
+ let row = pinned ? wizard.created.lun : rec?.data;
+ let blocked = wizard.busy;
+ grid.setDisabled(!!pinned || blocked || me.scanning || me.scanError);
+ me.down('#rescan').setHidden(!!pinned);
+ me.down('#rescan').setDisabled(blocked || me.scanning);
+ me.down('#footnote').setHidden(!!pinned);
+ let text;
+ if (wizard.unconfirmedStorage) {
+ text = wizard.storageUnknownText(false);
+ } else if (pinned) {
+ text = me.pinnedText;
+ } else if (me.scanning) {
+ text = Ext.String.format(me.waitText, me.node);
+ } else if (me.scanError) {
+ text = '';
+ } else if (row) {
+ text = me.selectedText;
+ } else if (me.activationOutcome && !grid.getStore().getCount()) {
+ let outcome = me.activationOutcome;
+ text = Ext.String.format(
+ outcome.uncertain ? me.activationUnknownText : me.activationErrorText,
+ me.storage,
+ outcome.message,
+ );
+ } else if (me.noSession) {
+ text = Ext.String.format(
+ me.noSessionText,
+ me.node,
+ me.initiator || Proxmox.Utils.unknownText,
+ );
+ } else if (
+ !grid
+ .getStore()
+ .getRange()
+ .some((r) => r.get('selectable'))
+ ) {
+ text = Ext.String.format(me.noneText, me.initiator || Proxmox.Utils.unknownText);
+ } else {
+ text = me.selectText;
+ }
+ if (row && !wizard.unconfirmedStorage && !me.scanning && !me.scanError) {
+ text = Ext.String.format(
+ text,
+ row['iscsi-lun'],
+ 'WWID',
+ row.wwid,
+ Proxmox.Utils.format_size(row.size),
+ wizard.actionText(),
+ );
+ }
+ me.down('#stateLine').setValue(Ext.htmlEncode(text || ''));
+ },
+
+ showScanError: function (errorHtml) {
+ let me = this;
+ me.scanning = false;
+ me.scanError = true;
+ let grid = me.down('#lunGrid');
+ grid.setLoading(false);
+ Proxmox.Utils.setErrorMask(
+ grid,
+ Ext.String.format(me.errorText, Ext.htmlEncode(me.node), errorHtml),
+ );
+ me.updateButtons();
+ },
+
+ scanGeneration: 0,
+ selectionGeneration: 0,
+
+ invalidateScan: function () {
+ this.scanGeneration++;
+ },
+
+ initComponent: function () {
+ let me = this;
+
+ if (!me.storage || !me.node || !me.belongs) {
+ throw 'no storage, node or filter specified';
+ }
+
+ let columns = me.lunColumns();
+ let store = Ext.create('Ext.data.Store', {
+ fields: [
+ ...PVE.SANUtils.lun_fields,
+ 'wwid',
+ 'iscsi-lun',
+ 'reason',
+ {
+ name: 'selectable',
+ depends: ['reason', 'usage', 'used-by-storage'],
+ calculate: (data) =>
+ !data.reason &&
+ data.usage === 'unused' &&
+ (!data['used-by-storage'] || data['used-by-storage'] === me.storage),
+ },
+ ],
+ sorters: [{ property: 'iscsi-lun' }, { property: 'devpath' }],
+ });
+
+ me.items = [
+ {
+ xtype: 'displayfield',
+ value: me.recap,
+ },
+ {
+ xtype: 'displayfield',
+ itemId: 'stateLine',
+ },
+ {
+ xtype: 'grid',
+ itemId: 'lunGrid',
+ flex: 1,
+ store: store,
+ viewConfig: {
+ trackOver: false,
+ getRowClass: (rec) => (rec.get('selectable') ? '' : 'x-item-disabled'),
+ },
+ listeners: {
+ beforeselect: (grid, rec) => rec.get('selectable'),
+ selectionchange: function () {
+ me.selectionGeneration++;
+ me.updateButtons();
+ },
+ },
+ columns: [
+ {
+ header: 'LUN',
+ dataIndex: 'iscsi-lun',
+ width: 60,
+ },
+ {
+ header: gettext('Device'),
+ dataIndex: 'devpath',
+ flex: 2,
+ renderer: Ext.htmlEncode,
+ },
+ columns.paths,
+ {
+ header: 'WWID',
+ dataIndex: 'wwid',
+ flex: 3,
+ renderer: Ext.String.htmlEncode,
+ },
+ columns.size,
+ columns.model,
+ {
+ header: gettext('Usage'),
+ dataIndex: 'usage',
+ flex: 2,
+ renderer: function (value, metaData, rec) {
+ if (rec.get('reason')) {
+ let text = Ext.htmlEncode(rec.get('reason'));
+ metaData.tdAttr = `data-qtip="${Ext.htmlEncode(text)}"`;
+ return text;
+ }
+ let { text, tip } = PVE.SANUtils.san_lun_usage(rec.data, {
+ // every LUN of the storage carries its attribution
+ ownStorage: me.storage,
+ });
+ metaData.tdAttr = `data-qtip="${Ext.htmlEncode(tip)}"`;
+ return text;
+ },
+ },
+ ],
+ },
+ ];
+
+ let controls = [
+ {
+ xtype: 'button',
+ itemId: 'rescan',
+ text: gettext('Rescan'),
+ iconCls: 'fa fa-search',
+ handler: () => me.doRescan(),
+ },
+ ];
+ me.items.push({ xtype: 'container', layout: 'hbox', margin: '5 0', items: controls });
+ me.items.push({ xtype: 'displayfield', itemId: 'footnote', value: me.footnote });
+
+ me.callParent();
+ me.on('beforedestroy', () => me.invalidateScan());
+ },
+
+ prepareScan: function (deadline, current, done) {
+ let me = this;
+ let timeout = () => Math.max(1, deadline - Date.now());
+ let fail = (response) => current() && me.showScanError(response.htmlStatus);
+ let enumerate = function () {
+ if (!current()) {
+ return;
+ }
+ Proxmox.Utils.API2Request({
+ url: me.connectionUrl,
+ method: 'GET',
+ timeout: timeout(),
+ failure: fail,
+ success: function (response) {
+ if (!current()) {
+ return;
+ }
+ let connections = me.connections(response.result.data || []);
+ me.noSession = connections.length === 0;
+ let caps = Ext.state.Manager.get('GuiCap');
+ if (!caps.dc['Sys.Modify']) {
+ done();
+ return;
+ }
+ let next = function () {
+ if (!current()) {
+ return;
+ }
+ if (!connections.length || Date.now() >= deadline) {
+ done();
+ return;
+ }
+ Proxmox.Utils.API2Request({
+ url: me.rescanUrl,
+ method: 'POST',
+ timeout: timeout(),
+ params: { [me.rescanParam]: connections.shift() },
+ failure: fail,
+ success: next,
+ });
+ };
+ next();
+ },
+ });
+ };
+ Proxmox.Utils.API2Request({
+ url: `/nodes/${me.node}/storage/${encodeURIComponent(me.storage)}/content`,
+ method: 'GET',
+ timeout: Math.min(15000, timeout()),
+ // listing the content activates the storage; a failure can explain an empty scan but
+ // does not prove one
+ failure: function (response) {
+ if (current()) {
+ me.activationOutcome = {
+ uncertain: PVE.SANUtils.request_uncertain(response),
+ // htmlStatus is HTML, the state line encodes plain text
+ message:
+ response.result?.message?.trim() ||
+ Ext.htmlDecode(response.htmlStatus ?? '') ||
+ Proxmox.Utils.unknownText,
+ };
+ }
+ enumerate();
+ },
+ success: enumerate,
+ });
+ },
+
+ // a rescan never preselects, a LUN appearing then may not be the intended one
+ waitForLuns: function (preselect) {
+ let me = this;
+ let grid = me.down('#lunGrid');
+ let generation = ++me.scanGeneration;
+ let current = () => !me.destroyed && generation === me.scanGeneration;
+ let deadline = Date.now() + PVE.storage.wizard.scanTimeout;
+ let previous;
+ let lastRows = [];
+ let settle = function (settled) {
+ grid.setLoading(false);
+ me.scanning = false;
+ me.showRows(lastRows, settled && preselect && !me.preselectionConsumed);
+ me.preselectionConsumed = true;
+ };
+ me.noSession = false;
+ me.activationOutcome = undefined;
+ me.scanning = true;
+ me.scanError = false;
+ grid.getSelectionModel().deselectAll();
+ Proxmox.Utils.setErrorMask(grid, false);
+ grid.setLoading(Ext.String.format(me.waitText, Ext.htmlEncode(me.node)));
+ me.updateButtons();
+ let poll = function () {
+ if (!current()) {
+ return;
+ }
+ if (Date.now() >= deadline) {
+ settle();
+ return;
+ }
+ Proxmox.Utils.API2Request({
+ url: `/nodes/${me.node}/scan/san-luns`,
+ method: 'GET',
+ // a scan can block on a map without a usable path, it must not outlast the wait
+ timeout: Math.max(deadline - Date.now(), 1),
+ failure: function (response) {
+ if (!current()) {
+ return;
+ }
+ me.showScanError(response.htmlStatus);
+ },
+ success: function (response) {
+ if (!current()) {
+ return;
+ }
+ let rows = PVE.SANUtils.group_san_lun_paths(response.result.data || []).filter(
+ (row) => me.belongs(row),
+ );
+ lastRows = rows;
+ let signature = JSON.stringify(
+ rows
+ .map((row) => [
+ row.devpath,
+ row.multipath,
+ row['paths-active'],
+ row.usage,
+ ])
+ .sort(),
+ );
+ let settled =
+ rows.length > 0 &&
+ signature === previous &&
+ !rows.some((row) => me.assembling(row));
+ previous = signature;
+ if (settled || Date.now() >= deadline) {
+ settle(settled);
+ } else {
+ Ext.defer(
+ poll,
+ Math.min(
+ PVE.storage.wizard.scanInterval,
+ Math.max(1, deadline - Date.now()),
+ ),
+ );
+ }
+ },
+ });
+ };
+ me.prepareScan(deadline, current, poll);
+ },
+
+ showRows: function (rows, preselect) {
+ let me = this;
+ let grid = me.down('#lunGrid');
+ let store = grid.getStore();
+ store.setData(rows.map((row) => ({ ...row, reason: me.notReady(row) })));
+ let selectable = store.getRange().filter((rec) => rec.get('selectable'));
+ if (preselect && selectable.length === 1) {
+ grid.getSelectionModel().select(selectable[0]);
+ }
+ me.updateButtons();
+ grid.focus();
+ },
+
+ doRescan: function () {
+ let me = this;
+ me.preselectionConsumed = true;
+ me.waitForLuns(false);
+ },
+});
diff --git a/www/manager6/storage/wizard/SanLvm.js b/www/manager6/storage/wizard/SanLvm.js
new file mode 100644
index 000000000..657b75e45
--- /dev/null
+++ b/www/manager6/storage/wizard/SanLvm.js
@@ -0,0 +1,114 @@
+PVE.storage.wizard.submitSanLvm = function (wizard, values, transport) {
+ let enc = Ext.htmlEncode;
+ let created = (wizard.created ??= {});
+ let baseId = values['base-storage'] || `${values.storage}-base`;
+ if (baseId === values.storage) {
+ Ext.Msg.alert(gettext('Error'), transport.sameIdText);
+ return;
+ }
+
+ // the storage API creates the volume group on the node it runs on
+ let node = Proxmox.NodeName;
+ let nodesList = values.nodes ? [].concat(values.nodes).join(',').split(',') : [];
+ nodesList = nodesList.filter((entry) => entry !== '');
+ if (wizard.getScanNode() !== node || (nodesList.length && !nodesList.includes(node))) {
+ Ext.Msg.alert(
+ gettext('Error'),
+ Ext.String.format(
+ gettext(
+ 'The volume group is created from node "{0}", which this web UI is connected to. Scan the target from that node and include it in the node restriction, or open the web UI on a node that should do the setup.',
+ ),
+ enc(node),
+ ),
+ );
+ return;
+ }
+
+ let volumeGroupHint = () =>
+ Ext.String.format(
+ transport.volumeGroupText,
+ enc(values.storage),
+ enc(created.baseVolume),
+ enc(wizard.actionText()),
+ ) +
+ '<br><br>' +
+ Ext.String.format(transport.sameUnitText, enc(baseId), enc(wizard.actionText()));
+
+ let selected;
+
+ wizard.runSubmitChain([
+ (next) => {
+ if (created.baseStorage) {
+ next();
+ return;
+ }
+ wizard.assertStorageIdsFree([baseId, values.storage], next, transport.checkTarget);
+ },
+ (next) => {
+ if (created.baseStorage) {
+ next();
+ return;
+ }
+ wizard.createStorage(transport.baseParams(baseId), () => {
+ created.baseStorage = baseId;
+ wizard.freeze();
+ wizard.updateSummary();
+ next();
+ });
+ },
+ (next) => {
+ if (created.baseVolume) {
+ next();
+ return;
+ }
+ let active = wizard.activateLunView({
+ ...transport.lunView(node, baseId),
+ recap: Ext.String.format(
+ gettext(
+ 'Connection storage "{1}" created. Setting up volume group and LVM storage "{0}".',
+ ),
+ enc(values.storage),
+ enc(baseId),
+ ),
+ node: node,
+ storage: baseId,
+ });
+ if (active && wizard.canSubmit()) {
+ selected = wizard.lunView.getSelection();
+ next();
+ }
+ },
+ (next) => {
+ if (created.baseVolume) {
+ next();
+ return;
+ }
+ transport.verifyVolume({
+ node,
+ baseId,
+ lun: selected,
+ pin: function (volume) {
+ created.baseVolume = volume.volid;
+ created.lun = Ext.clone(selected);
+ wizard.updateSummary();
+ next();
+ },
+ });
+ },
+ (next) =>
+ wizard.createStorage(
+ {
+ storage: values.storage,
+ type: 'lvm',
+ vgname: values.storage,
+ base: created.baseVolume,
+ shared: 1,
+ content: values.content,
+ nodes: values.nodes,
+ disable: values.disable,
+ },
+ next,
+ volumeGroupHint(),
+ ),
+ ]);
+};
diff --git a/www/manager6/storage/wizard/Wizard.js b/www/manager6/storage/wizard/Wizard.js
index ce0eccb73..c5ffa2b8e 100644
--- a/www/manager6/storage/wizard/Wizard.js
+++ b/www/manager6/storage/wizard/Wizard.js
@@ -23,6 +23,10 @@ PVE.storage.wizard.summaryFields = {
options: { label: gettext('Mount Options') },
};
+// the first scans can be empty until pvestatd logs in and applies the multipath allow-list
+PVE.storage.wizard.scanTimeout = 45000;
+PVE.storage.wizard.scanInterval = 3000;
+
PVE.storage.wizard.lvmNodesValidator = function () {
let wizard = this.up('window');
if (wizard?.getViewModel().get('usage') !== 'lvm') {
@@ -158,7 +162,7 @@ Ext.define('PVE.storage.wizard.RemoteStorage', {
if (canSubmit) {
return canSubmit(me);
}
- return true;
+ return !me.lunView || me.lunView.canSubmit();
},
actionText: function () {
@@ -166,7 +170,10 @@ Ext.define('PVE.storage.wizard.RemoteStorage', {
if (me.unconfirmedStorage) {
return gettext('Check and Retry');
}
- return gettext('Finish');
+ if (me.lunView) {
+ return me.created?.baseVolume ? gettext('Retry Setup') : gettext('Create Volume Group');
+ }
+ return PVE.storage.wizard.types[me.wizardType].actionText?.(me) ?? gettext('Finish');
},
updateActions: function () {
@@ -175,6 +182,11 @@ Ext.define('PVE.storage.wizard.RemoteStorage', {
return;
}
me.down('#submit').setText(me.actionText());
+ if (me.lunView) {
+ me.lunView.updateState();
+ me.down('#submit').setDisabled(!me.canSubmit());
+ me.down('#back').setDisabled(true);
+ }
// a type with its own submit condition decides the state of the confirm buttons
let confirmActive = me.down('#wizcontent').getActiveTab() === me.down('#confirm');
if (PVE.storage.wizard.types[me.wizardType].canSubmit && confirmActive) {
@@ -188,6 +200,20 @@ Ext.define('PVE.storage.wizard.RemoteStorage', {
}
},
+ activateLunView: function (config) {
+ let me = this;
+ if (me.lunView) {
+ return true;
+ }
+ let panel = me.down('#confirm');
+ me.lunView = panel.add(config);
+ panel.getLayout().setActiveItem(me.lunView);
+ panel.setTitle(me.lunView.tabTitle);
+ me.lunView.waitForLuns(true);
+ me.updateActions();
+ return false;
+ },
+
// keeps the window busy, so it cannot close with the continuation pending
request: function (options) {
let me = this;
@@ -572,7 +598,7 @@ Ext.define('PVE.storage.wizard.RemoteStorage', {
data.push({ key: row.label, value: row.value, weight: row.weight ?? 20 });
}
- let summarystore = panel.down('grid').getStore();
+ let summarystore = panel.down('#summaryGrid').getStore();
summarystore.suspendEvents();
summarystore.removeAll();
summarystore.add(data);
@@ -673,6 +699,7 @@ Ext.define('PVE.storage.wizard.RemoteStorage', {
items: [
{
xtype: 'grid',
+ itemId: 'summaryGrid',
flex: 1,
store: {
fields: ['key', 'value', 'weight'],
@@ -730,6 +757,18 @@ Ext.define('PVE.storage.wizard.RemoteStorage', {
},
];
+ if (entry.confirmCard) {
+ let confirm = me.items[me.items.length - 1];
+ confirm.items = [
+ {
+ xtype: 'container',
+ layout: confirm.layout,
+ items: confirm.items,
+ },
+ ];
+ confirm.layout = 'card';
+ }
+
me.on('beforeclose', function () {
if (!me.busy) {
return true;
--
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 ` [PATCH v3 manager 64/76] ui: dc: add a cluster-wide multipath view Thomas Lamprecht
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 ` Thomas Lamprecht [this message]
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-68-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.