From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [IPv6:2a0f:8001:1:32::40]) by lore.proxmox.com (Postfix) with ESMTPS id 21C551FF0AF for ; Fri, 06 Nov 2026 02:02:31 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 991D7218EC; Fri, 06 Nov 2026 01:54:34 +0100 (CET) From: Thomas Lamprecht 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 Message-ID: <20261105233336.2178064-68-t.lamprecht@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20261105233336.2178064-1-t.lamprecht@proxmox.com> References: <20261105233336.2178064-1-t.lamprecht@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1793926182151 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.135 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) POISEN_SPAM_PILL 0.1 Meta: its spam POISEN_SPAM_PILL_1 0.1 random spam to be learned in bayes POISEN_SPAM_PILL_3 0.1 random spam to be learned in bayes RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: C5DNZMAWCVMH5ZU6ADYQSU3E7YAHVABE X-Message-ID-Hash: C5DNZMAWCVMH5ZU6ADYQSU3E7YAHVABE X-MailFrom: t.lamprecht@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: 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 Signed-off-by: Dietmar Maurer Signed-off-by: Thomas Lamprecht --- 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: + `${gettext('Shared LVM on a LUN (recommended)')} - ` + + 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: + `${gettext('Use LUNs directly')} - ` + + 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()), + ) + + '

' + + 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