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 8F1241FF0AF for ; Fri, 06 Nov 2026 02:02:02 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 2262C21871; Fri, 06 Nov 2026 01:54:06 +0100 (CET) From: Thomas Lamprecht To: pve-devel@lists.proxmox.com Subject: [PATCH v3 manager 73/76] ui: storage wizard: let root@pam wipe a LUN with data Date: Fri, 6 Nov 2026 00:31:48 +0100 Message-ID: <20261105233336.2178064-74-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: 1793926182624 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.236 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) PROLO_LEO1 0.1 Meta Catches all Leo drug variations so far 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: KIC7HTURMNYLI7NL35XBQGZLODYVKMTL X-Message-ID-Hash: KIC7HTURMNYLI7NL35XBQGZLODYVKMTL 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: Let root@pam wipe a data-bearing LUN without leaving shared LVM setup, using the Node > SAN confirmation and refusal checks. Unknown usage or volume-group membership never makes a device wipeable. Track the same wipe across scans. Block selection and submission until its outcome is known: resume checks for known tasks, but require a task-history check before dismissing an unanswered request. After failed Fibre Channel volume-group creation, recheck the original device and storage configuration before offering recovery on Confirm. Distinguish an untouched LUN, an existing group, and a refused wipe from probe failures. Discard obsolete recovery attempts and block recovery while creation is pending. Verify the pinned device is unused after every wipe outcome before retrying. Display-only devices remain ineligible for wiping. Signed-off-by: Thomas Lamprecht --- www/manager6/Makefile | 2 + www/manager6/SANUtils.js | 8 +- www/manager6/storage/wizard/FCRecovery.js | 242 ++++++++++++++++++++ www/manager6/storage/wizard/FibreChannel.js | 89 ++++++- www/manager6/storage/wizard/LunPicker.js | 70 ++++++ www/manager6/storage/wizard/LunSelect.js | 70 ++++-- www/manager6/storage/wizard/WipeStatus.js | 34 +++ www/manager6/storage/wizard/Wizard.js | 116 +++++++++- 8 files changed, 597 insertions(+), 34 deletions(-) create mode 100644 www/manager6/storage/wizard/FCRecovery.js create mode 100644 www/manager6/storage/wizard/WipeStatus.js diff --git a/www/manager6/Makefile b/www/manager6/Makefile index 8717d041a..8b410d91c 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -390,11 +390,13 @@ JSSRC= \ storage/wizard/CommonSettings.js \ storage/wizard/LunPicker.js \ storage/wizard/LunSelect.js \ + storage/wizard/WipeStatus.js \ storage/wizard/NodeIdentities.js \ storage/wizard/SanLvm.js \ storage/wizard/NFS.js \ storage/wizard/CIFS.js \ storage/wizard/IScsi.js \ + storage/wizard/FCRecovery.js \ storage/wizard/FibreChannel.js \ storage/wizard/ZFSOverISCSI.js \ Workspace.js \ diff --git a/www/manager6/SANUtils.js b/www/manager6/SANUtils.js index 7bbb287e2..801f77777 100644 --- a/www/manager6/SANUtils.js +++ b/www/manager6/SANUtils.js @@ -367,10 +367,14 @@ Ext.define('PVE.SANUtils', { return Number(response?.result?.status) === 403; }, - // returns { text, tip } as HTML; an attribution to ownStorage alone does not count as use - san_lun_usage: function (row, { ownStorage } = {}) { + // returns { text, tip } as HTML; an attribution to ownStorage alone does not count as use, and + // refusal says why the row cannot be wiped + san_lun_usage: function (row, { ownStorage, refusal } = {}) { let text = PVE.SANUtils.san_lun_usage_text(row, ownStorage); let tooltip = [text]; + if (refusal) { + tooltip.push(Ext.htmlEncode(refusal)); + } if (row['duplicate-pv']) { let tip = gettext( 'Another LUN has an LVM physical volume with the same UUID. LVM refuses to activate or change its volume group.', diff --git a/www/manager6/storage/wizard/FCRecovery.js b/www/manager6/storage/wizard/FCRecovery.js new file mode 100644 index 000000000..d4a337c1c --- /dev/null +++ b/www/manager6/storage/wizard/FCRecovery.js @@ -0,0 +1,242 @@ +Ext.define('PVE.storage.wizard.FCRecovery', { + extend: 'Ext.container.Container', + xtype: 'pveStorageWizardFCRecovery', + layout: { type: 'vbox', align: 'stretch' }, + + invalidate: function () { + delete this.attempt; + this.hide(); + }, + + initComponent: function () { + let me = this; + me.callParent(); + // only added to the confirm tab of a constructed wizard + me.on( + 'added', + function () { + let wizard = me.up('window'); + me.mon(wizard, 'setupchange', function () { + me.down('#wipe').setDisabled(me.blocked()); + me.down('#check').setDisabled(me.blocked()); + }); + me.mon(wizard.down('#wizcontent'), 'tabchange', function (tp, tab) { + if (tab !== wizard.down('#confirm')) { + me.invalidate(); + } + }); + }, + me, + { single: true }, + ); + }, + + discover: function (node, lun, vgname) { + let me = this; + let field = 'wwid'; + me.attempt = { node, identity: lun?.[field], field, vgname }; + me.check(); + }, + + blocked: function () { + let wizard = this.up('window'); + return !!( + wizard.busy || + wizard.wipeOperation || + wizard.created?.task || + wizard.created?.volumeGroup || + wizard.unconfirmedStorage || + wizard.unknownVolumeGroup + ); + }, + + check: function () { + let me = this; + let wizard = me.up('window'); + let attempt = me.attempt; + if (!attempt || me.blocked()) { + return; + } + let token = (me.checkToken = (me.checkToken ?? 0) + 1); + let current = () => !me.destroyed && me.attempt === attempt && token === me.checkToken; + me.show(); + me.down('#wipe').hide(); + me.down('#check').hide(); + me.down('#note').setValue(gettext('Checking the selected device...')); + let failure = function () { + if (!current()) { + return; + } + me.down('#note').setValue( + Ext.htmlEncode( + Ext.String.format( + gettext( + 'Could not check LUN "{0}" for a leftover physical volume. Press Check Again.', + ), + attempt.identity || Proxmox.Utils.unknownText, + ), + ), + ); + me.down('#check').show(); + }; + if (!attempt.identity || attempt.identity === 'unknown') { + failure(); + return; + } + Proxmox.Utils.API2Request({ + url: `/nodes/${attempt.node}/scan/san-luns`, + method: 'GET', + failure, + success: function (response) { + if (!current()) { + return; + } + let rows = PVE.SANUtils.group_san_lun_paths(response.result.data || []).filter( + (row) => row[attempt.field] === attempt.identity, + ); + if (rows.length !== 1) { + failure(); + return; + } + let lun = rows[0]; + Proxmox.Utils.API2Request({ + url: '/storage', + method: 'GET', + failure, + success: function (config) { + if (!current()) { + return; + } + if ( + attempt.verifyUnused && + lun.usage === 'unused' && + !lun['used-by-storage'] && + lun['in-use'] === 'no' + ) { + attempt.ready = true; + me.down('#note').setValue( + Ext.htmlEncode( + Ext.String.format( + gettext( + 'LUN "{0}" is now unused. Press Retry Setup to create volume group "{1}" on this LUN.', + ), + attempt.identity, + attempt.vgname, + ), + ), + ); + wizard.updateActions(); + return; + } + if (lun.usage === 'unused' && !attempt.verifyUnused) { + me.down('#note').setValue( + Ext.htmlEncode( + Ext.String.format( + gettext( + 'No leftover physical volume was found on LUN "{0}".', + ), + attempt.identity, + ), + ), + ); + return; + } + if (!['lvm', 'other'].includes(lun.usage)) { + failure(); + return; + } + // an empty VG name alone does not prove an orphan PV + let refusal = PVE.window.SANLunWipe.reason(lun, config.result.data || []); + if (refusal) { + me.down('#note').setValue(Ext.htmlEncode(refusal)); + me.down('#check').show(); + return; + } + if (lun.vgname) { + me.down('#note').setValue( + Ext.htmlEncode( + Ext.String.format( + gettext( + 'LUN "{0}" contains volume group "{1}". If this setup created it, complete the setup under Datacenter > Storage > Add > LVM.', + ), + attempt.identity, + lun.vgname, + ), + ), + ); + return; + } + if (lun.usage !== 'lvm') { + failure(); + return; + } + attempt.lun = lun; + me.down('#note').setValue( + Ext.htmlEncode( + Ext.String.format( + gettext( + 'An LVM physical volume without a volume group remains on LUN "{0}" on node "{1}". Wipe it only if it is no longer needed anywhere.', + ), + attempt.identity, + attempt.node, + ), + ), + ); + me.down('#wipe').show(); + wizard.updateActions(); + }, + }); + }, + }); + }, + + wipe: function () { + let me = this; + let wizard = me.up('window'); + let attempt = me.attempt; + if (!attempt?.lun || Proxmox.UserName !== 'root@pam' || me.blocked()) { + return; + } + wizard.openWipe(attempt.node, attempt.lun, function (wiped) { + if (me.destroyed || me.attempt !== attempt) { + return; + } + if (wizard.created?.entry) { + attempt.verifyUnused = true; + attempt.ready = false; + me.check(); + } else { + wizard.down('pveStorageWizardFCLunSelect').refreshLuns(); + if (wiped) { + me.show(); + me.down('#note').setValue( + gettext( + 'Wipe completed. Go back to the SAN LUN step and select a LUN before retrying.', + ), + ); + me.down('#wipe').hide(); + me.down('#check').hide(); + } + } + }); + }, + + items: [ + { xtype: 'displayfield', itemId: 'note' }, + { + xtype: 'button', + itemId: 'check', + text: gettext('Check Again'), + hidden: true, + handler: (button) => button.up('pveStorageWizardFCRecovery').check(), + }, + { + xtype: 'button', + itemId: 'wipe', + text: gettext('Wipe...'), + hidden: true, + cls: 'pve-dangerous-button', + handler: (button) => button.up('pveStorageWizardFCRecovery').wipe(), + }, + ], +}); diff --git a/www/manager6/storage/wizard/FibreChannel.js b/www/manager6/storage/wizard/FibreChannel.js index b0f5922f4..b44ec393a 100644 --- a/www/manager6/storage/wizard/FibreChannel.js +++ b/www/manager6/storage/wizard/FibreChannel.js @@ -121,21 +121,39 @@ Ext.define('PVE.storage.wizard.FCLunSelect', { return check; }, + wipeable: function (rec) { + let me = this; + return ( + !!rec && + me.allowWipe && + !rec.get('reason') && + !PVE.window.SANLunWipe.reason(rec.data, me.storageConfig) + ); + }, + validator: function () { let wizard = this.up('window'); - return !wizard.busy && !!wizard.getViewModel().get('vgmode'); + return !wizard.busy && !wizard.wipeOperation && !!wizard.getViewModel().get('vgmode'); }, updateActions: function () { let me = this; let wizard = me.up('window'); let pinned = !!wizard.created?.entry; - let blocked = wizard.busy || pinned; + let blocked = wizard.busy || !!wizard.wipeOperation || pinned; let grid = me.down('#lunGrid'); grid.setDisabled(blocked || me.scanning); + let optin = me.down('#allowWipe'); + optin.setDisabled(blocked); + optin.setHidden(pinned || Proxmox.UserName !== 'root@pam'); + if (pinned && optin.getValue()) { + optin.setValue(false); + } me.down('#rescan').setHidden(pinned); + me.down('#wipeButton').setHidden(pinned || !me.allowWipe); me.down('#rescan').setDisabled(blocked); me.down('pveStorageScanNodeSelector')?.setDisabled(blocked); + me.down('#wipeButton').setDisabled(blocked || !me.wipeable(grid.getSelection()[0])); if (wizard.down('#wizcontent').getActiveTab() === me) { wizard.down('#next').setDisabled(!me.validator()); } @@ -216,6 +234,7 @@ Ext.define('PVE.storage.wizard.FCLunSelect', { let grid = me.down('#lunGrid'); me.scanning = true; me.scanDone = false; + wizard.down('pveStorageWizardFCRecovery')?.invalidate(); grid.getSelectionModel().deselectAll(); me.selection = undefined; me.lunScan = undefined; @@ -406,6 +425,7 @@ Ext.define('PVE.storage.wizard.FCLunSelect', { let updateSelection = function (rec) { let wizard = me.up('window'); let multipath = rec?.get('multipath'); + wizard.down('pveStorageWizardFCRecovery')?.invalidate(); delete me.entryCheck; me.selection = rec ? { @@ -421,6 +441,8 @@ Ext.define('PVE.storage.wizard.FCLunSelect', { } : undefined; let vm = wizard.getViewModel(); + me.down('#wipeButton')?.setDisabled(!me.wipeable(rec)); + // a row selected only for wiping is not the storage's LUN, so the step stays incomplete if (!rec || !rec.get('selectable')) { vm.set({ vgmode: '', device: '', vgname: '' }); } else if (rec.get('usage') === 'unused') { @@ -439,6 +461,7 @@ Ext.define('PVE.storage.wizard.FCLunSelect', { xtype: 'displayfield', value: gettext('Select the SAN LUN for the volume group:'), }, + { xtype: 'pveStorageWizardWipeStatus' }, { xtype: 'grid', itemId: 'lunGrid', @@ -446,10 +469,11 @@ Ext.define('PVE.storage.wizard.FCLunSelect', { store: store, viewConfig: { trackOver: false, - getRowClass: (rec) => (rec.get('selectable') ? '' : 'x-item-disabled'), + getRowClass: (rec) => + rec.get('selectable') || me.wipeable(rec) ? '' : 'x-item-disabled', }, listeners: { - beforeselect: (grid, rec) => rec.get('selectable'), + beforeselect: (grid, rec) => rec.get('selectable') || me.wipeable(rec), selectionchange: (sm, selection) => updateSelection(selection[0]), }, columns: [ @@ -498,15 +522,21 @@ Ext.define('PVE.storage.wizard.FCLunSelect', { { xtype: 'container', layout: 'hbox', - items: [ - { - xtype: 'button', - itemId: 'rescan', - text: gettext('Rescan'), - iconCls: 'fa fa-search', - handler: () => me.refreshLuns(), + items: me.wipeControls(() => me.refreshLuns(), { + margin: '0 0 0 10', + handler: function () { + let rec = me.down('#lunGrid').getSelection()[0]; + if (!me.wipeable(rec)) { + return; + } + let wizard = me.up('window'); + wizard.openWipe(wizard.getScanNode(), rec.data, () => { + if (!me.destroyed) { + me.refreshLuns(); + } + }); }, - ], + }), }, { xtype: 'proxmoxcheckbox', @@ -564,6 +594,7 @@ Ext.define('PVE.storage.wizard.FCLunSelect', { xtype: 'pveStorageScanNodeSelector', listeners: { change: function () { + me.up('window').down('pveStorageWizardFCRecovery')?.invalidate(); me.refreshLuns(); }, }, @@ -639,7 +670,16 @@ PVE.storage.wizard.types.fc = { ], }, + confirmItems: () => [ + { xtype: 'pveStorageWizardWipeStatus' }, + { xtype: 'pveStorageWizardFCRecovery', hidden: true }, + ], + canSubmit: function (wizard) { + let attempt = wizard.down('pveStorageWizardFCRecovery')?.attempt; + if (attempt?.verifyUnused && !attempt.ready) { + return false; + } let values = wizard.getValues(); let step = wizard.down('pveStorageWizardFCLunSelect'); let check = step.checkMultipathEntry(values.nodes); @@ -760,6 +800,7 @@ PVE.storage.wizard.types.fc = { ); return; } + wizard.down('pveStorageWizardFCRecovery')?.invalidate(); let enc = Ext.htmlEncode; let mode = values.vgmode; delete values.vgmode; @@ -892,6 +933,13 @@ PVE.storage.wizard.types.fc = { poll(); }; + let failedLun = Ext.clone(step.selection.lun); + let offerWipe = function () { + if (Proxmox.UserName === 'root@pam') { + wizard.down('pveStorageWizardFCRecovery').discover(node, failedLun, params.vgname); + } + }; + let volumeGroupFailure = function (msg, final) { if (final) { delete created.task; @@ -899,6 +947,22 @@ PVE.storage.wizard.types.fc = { if (!created.entry) { wizard.unfreeze(); } + offerWipe(); + if (Proxmox.UserName === 'root@pam') { + msg = Ext.String.format( + gettext( + 'Volume group setup did not complete: {0}. The Confirm page checks this LUN for a leftover physical volume and offers Wipe... if the local checks allow it. If volume group "{1}" already exists, complete the setup under Datacenter > Storage > Add > LVM.', + ), + msg, + enc(params.vgname), + ); + } else { + msg += + '

' + + gettext( + 'If a leftover physical volume remains, root@pam can wipe it under Node > SAN before retrying. If the volume group already exists, complete the setup under Datacenter > Storage > Add > LVM.', + ); + } } else { msg += '

' + @@ -941,6 +1005,7 @@ PVE.storage.wizard.types.fc = { ), }, success: function () { + wizard.down('pveStorageWizardFCRecovery')?.invalidate(); created.volumeGroup = params.vgname; delete created.task; next(); diff --git a/www/manager6/storage/wizard/LunPicker.js b/www/manager6/storage/wizard/LunPicker.js index 7ae6b0564..5bcaace44 100644 --- a/www/manager6/storage/wizard/LunPicker.js +++ b/www/manager6/storage/wizard/LunPicker.js @@ -1,3 +1,4 @@ +// the picker provides wipeable(rec) and a #lunGrid Ext.define('PVE.storage.wizard.LunPicker', { extend: 'Ext.Mixin', @@ -5,6 +6,75 @@ Ext.define('PVE.storage.wizard.LunPicker', { id: 'lunPicker', }, + allowWipe: false, + storageConfig: undefined, + allowWipeText: gettext('Allow wiping LUNs with data'), + + setAllowWipe: function (allow) { + let me = this; + let grid = me.down('#lunGrid'); + let token = (me.wipeConfigToken = (me.wipeConfigToken ?? 0) + 1); + let apply = function (storages) { + if (me.destroyed || token !== me.wipeConfigToken) { + return; + } + me.allowWipe = allow; + me.storageConfig = storages; + me.down('#wipeButton').setHidden(!allow); + let rec = grid.getSelection()[0]; + if (rec && !rec.get('selectable') && !me.wipeable(rec)) { + grid.getSelectionModel().deselectAll(); + } + grid.getView().refresh(); + me.up('window').updateActions(); + }; + if (!allow) { + apply(undefined); + return; + } + // the wipe check needs the storages as they are now, a base storage may be new + Proxmox.Utils.API2Request({ + url: '/storage', + method: 'GET', + failure: () => apply(undefined), + success: (response) => apply(response.result.data || []), + }); + }, + + // wipeButton completes the config of the Wipe... button + wipeControls: function (rescan, wipeButton) { + let me = this; + return [ + { + xtype: 'button', + itemId: 'rescan', + text: gettext('Rescan'), + iconCls: 'fa fa-search', + handler: rescan, + }, + { + xtype: 'proxmoxcheckbox', + itemId: 'allowWipe', + margin: '0 10', + hidden: Proxmox.UserName !== 'root@pam', + boxLabel: me.allowWipeText, + submitValue: false, + listeners: { + change: (field, value) => me.setAllowWipe(value), + }, + }, + { + xtype: 'button', + itemId: 'wipeButton', + text: gettext('Wipe...'), + cls: 'pve-dangerous-button', + hidden: true, + disabled: true, + ...wipeButton, + }, + ]; + }, + lunColumns: function () { return { paths: { diff --git a/www/manager6/storage/wizard/LunSelect.js b/www/manager6/storage/wizard/LunSelect.js index 4e138f787..947105f4d 100644 --- a/www/manager6/storage/wizard/LunSelect.js +++ b/www/manager6/storage/wizard/LunSelect.js @@ -23,9 +23,14 @@ Ext.define('PVE.storage.wizard.LunSelect', { tabTitle: gettext('LUN'), selectText: gettext('Select an unused LUN for the volume group.'), + wipeSelectText: gettext( + 'Select an unused LUN for the volume group. If a LUN holds data that is no longer needed anywhere and no configured storage uses it for data, you can wipe it first.', + ), 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}).'), + wipeText: gettext('LUN {0} ({1} {2}, {3}) holds data. Press Wipe... to review and wipe it.'), + wipedText: gettext('Wipe completed. Select a LUN to continue.'), 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.', ), @@ -69,6 +74,15 @@ Ext.define('PVE.storage.wizard.LunSelect', { return undefined; }, + wipeable: function (rec) { + let me = this; + return ( + me.allowWipe && + !rec.get('reason') && + !PVE.window.SANLunWipe.reason(rec.data, me.storageConfig) + ); + }, + getSelection: function () { return this.down('#lunGrid').getSelection()[0]?.data; }, @@ -93,10 +107,20 @@ Ext.define('PVE.storage.wizard.LunSelect', { let grid = me.down('#lunGrid'); let rec = grid.getSelection()[0]; let row = pinned ? wizard.created.lun : rec?.data; - let blocked = wizard.busy; + let blocked = wizard.busy || !!wizard.wipeOperation; grid.setDisabled(!!pinned || blocked || me.scanning || me.scanError); me.down('#rescan').setHidden(!!pinned); me.down('#rescan').setDisabled(blocked || me.scanning); + let optin = me.down('#allowWipe'); + optin.setHidden(!!pinned || Proxmox.UserName !== 'root@pam'); + optin.setDisabled(blocked || me.scanning); + if (pinned && optin.getValue()) { + optin.setValue(false); + } + me.down('#wipeButton').setHidden(!!pinned || !me.allowWipe); + me.down('#wipeButton').setDisabled( + blocked || me.scanning || me.scanError || !rec || !me.wipeable(rec), + ); me.down('#footnote').setHidden(!!pinned || !!wizard.targetUse?.reuse); let text; if (wizard.unconfirmedStorage) { @@ -108,7 +132,7 @@ Ext.define('PVE.storage.wizard.LunSelect', { } else if (me.scanError) { text = ''; } else if (row) { - text = me.selectedText; + text = rec.get('selectable') ? me.selectedText : me.wipeText; } else if (me.activationOutcome && !grid.getStore().getCount()) { let outcome = me.activationOutcome; text = Ext.String.format( @@ -122,15 +146,17 @@ Ext.define('PVE.storage.wizard.LunSelect', { me.node, me.initiator || Proxmox.Utils.unknownText, ); + } else if (me.wiped) { + text = me.wipedText; } else if ( !grid .getStore() .getRange() - .some((r) => r.get('selectable')) + .some((r) => r.get('selectable') || me.wipeable(r)) ) { text = Ext.String.format(me.noneText, me.initiator || Proxmox.Utils.unknownText); } else { - text = me.selectText; + text = me.allowWipe ? me.wipeSelectText : me.selectText; } if (row && !wizard.unconfirmedStorage && !me.scanning && !me.scanError) { text = Ext.String.format( @@ -142,7 +168,7 @@ Ext.define('PVE.storage.wizard.LunSelect', { wizard.actionText(), ); } - me.down('#stateLine').setValue(Ext.htmlEncode(text || '')); + me.down('#stateLine').setStatus(text); }, showScanError: function (errorHtml) { @@ -197,7 +223,7 @@ Ext.define('PVE.storage.wizard.LunSelect', { value: me.recap, }, { - xtype: 'displayfield', + xtype: 'pveStorageWizardWipeStatus', itemId: 'stateLine', }, { @@ -207,10 +233,11 @@ Ext.define('PVE.storage.wizard.LunSelect', { store: store, viewConfig: { trackOver: false, - getRowClass: (rec) => (rec.get('selectable') ? '' : 'x-item-disabled'), + getRowClass: (rec) => + rec.get('selectable') || me.wipeable(rec) ? '' : 'x-item-disabled', }, listeners: { - beforeselect: (grid, rec) => rec.get('selectable'), + beforeselect: (grid, rec) => rec.get('selectable') || me.wipeable(rec), selectionchange: function () { me.selectionGeneration++; me.updateButtons(); @@ -247,9 +274,14 @@ Ext.define('PVE.storage.wizard.LunSelect', { metaData.tdAttr = `data-qtip="${Ext.htmlEncode(text)}"`; return text; } + let refusal = + me.allowWipe && !rec.get('selectable') + ? PVE.window.SANLunWipe.reason(rec.data, me.storageConfig) + : undefined; let { text, tip } = PVE.SANUtils.san_lun_usage(rec.data, { // every LUN of the storage carries its attribution ownStorage: me.storage, + refusal, }); metaData.tdAttr = `data-qtip="${Ext.htmlEncode(tip)}"`; return text; @@ -259,15 +291,20 @@ Ext.define('PVE.storage.wizard.LunSelect', { }, ]; - let controls = [ - { - xtype: 'button', - itemId: 'rescan', - text: gettext('Rescan'), - iconCls: 'fa fa-search', - handler: () => me.doRescan(), + let controls = me.wipeControls(() => me.doRescan(), { + handler: function () { + let rec = me.down('#lunGrid').getSelection()[0]; + if (!rec || !me.wipeable(rec) || me.up('window').created?.baseVolume) { + return; + } + me.up('window').openWipe(me.node, rec.data, function (wiped) { + if (!me.destroyed) { + me.wiped = wiped; + me.waitForLuns(false); + } + }); }, - ]; + }); me.items.push({ xtype: 'container', layout: 'hbox', margin: '5 0', items: controls }); me.items.push({ xtype: 'displayfield', itemId: 'footnote', value: me.footnote }); @@ -440,6 +477,7 @@ Ext.define('PVE.storage.wizard.LunSelect', { doRescan: function () { let me = this; + me.wiped = false; me.preselectionConsumed = true; me.waitForLuns(false); }, diff --git a/www/manager6/storage/wizard/WipeStatus.js b/www/manager6/storage/wizard/WipeStatus.js new file mode 100644 index 000000000..a79c25aec --- /dev/null +++ b/www/manager6/storage/wizard/WipeStatus.js @@ -0,0 +1,34 @@ +Ext.define('PVE.storage.wizard.WipeStatus', { + extend: 'Ext.container.Container', + xtype: 'pveStorageWizardWipeStatus', + layout: { type: 'hbox', align: 'middle' }, + + setStatus: function (text) { + let me = this; + let wizard = me.up('window'); + let op = wizard.wipeOperation; + let warning = + op?.state === 'unknown' ? ' ' : ''; + me.down('#text').setValue(warning + Ext.htmlEncode(wizard.wipeStatusText() || text || '')); + me.down('#resume').setVisible(!!op?.upid && !wizard.busy); + me.down('#clear').setVisible(op?.state === 'unknown' && !op.upid && !wizard.busy); + }, + + items: [ + { xtype: 'displayfield', itemId: 'text', flex: 1, value: '' }, + { + xtype: 'button', + itemId: 'resume', + text: gettext('Check Task Status'), + hidden: true, + handler: (button) => button.up('window').waitForWipe(), + }, + { + xtype: 'button', + itemId: 'clear', + text: gettext('Dismiss'), + hidden: true, + handler: (button) => button.up('window').clearWipe(), + }, + ], +}); diff --git a/www/manager6/storage/wizard/Wizard.js b/www/manager6/storage/wizard/Wizard.js index 433612b1a..d50291584 100644 --- a/www/manager6/storage/wizard/Wizard.js +++ b/www/manager6/storage/wizard/Wizard.js @@ -155,7 +155,7 @@ Ext.define('PVE.storage.wizard.RemoteStorage', { canSubmit: function () { let me = this; - if (me.busy || me.unknownVolumeGroup) { + if (me.busy || me.wipeOperation || me.unknownVolumeGroup) { return false; } let canSubmit = PVE.storage.wizard.types[me.wizardType].canSubmit; @@ -171,6 +171,7 @@ Ext.define('PVE.storage.wizard.RemoteStorage', { me.lunView && me.targetUse?.reuse && !me.busy && + !me.wipeOperation && !me.unconfirmedStorage && Object.keys(me.created ?? {}).every((key) => key === 'baseStorage') ); @@ -202,15 +203,115 @@ Ext.define('PVE.storage.wizard.RemoteStorage', { let confirmActive = me.down('#wizcontent').getActiveTab() === me.down('#confirm'); if (PVE.storage.wizard.types[me.wizardType].canSubmit && confirmActive) { me.down('#submit').setDisabled(!me.canSubmit()); - me.down('#back').setDisabled(!!me.frozenValues || me.busy); + me.down('#back').setDisabled(!!me.frozenValues || me.busy || !!me.wipeOperation); } + me.query('pveStorageWizardWipeStatus').forEach((status) => { + if (status !== me.lunView?.down('#stateLine')) { + status.setStatus(); + } + }); me.fireEvent('setupchange', me); // after the listeners, so no step can enable an action again - if (me.busy || me.unknownVolumeGroup) { + if (me.busy || me.wipeOperation || me.unknownVolumeGroup) { ['#back', '#next', '#submit'].forEach((id) => me.down(id).disable()); } }, + wipeStatusText: function () { + let op = this.wipeOperation; + if (!op) { + return ''; + } + let t = PVE.window.SANLunWipe.texts(op.lun); + if (op.state === 'pending' || op.state === 'waiting') { + return Ext.String.format(t.wiping, op.identity, op.node); + } + if (!op.upid) { + return Ext.String.format(t.unknownRequest, op.node); + } + return Ext.String.format( + t.unknownTask, + op.lun['iscsi-lun'] ?? op.lun.devpath, + 'WWID', + op.identity, + ); + }, + + openWipe: function (node, lun, refresh) { + let me = this; + if (me.busy || me.wipeOperation) { + return; + } + Ext.create('PVE.window.SANLunWipe', { + node, + lun, + autoShow: true, + onSubmit: function (op) { + op.refresh = refresh; + me.wipeOperation = op; + me.setBusy(true, gettext('Wiping...')); + }, + onClose: function (op) { + if (me.destroyed || me.wipeOperation !== op) { + return; + } + if (op.state !== 'unknown') { + delete me.wipeOperation; + } + me.setBusy(false); + refresh(false); + me.updateActions(); + }, + callback: function (op) { + if (me.destroyed || me.wipeOperation !== op) { + return; + } + me.waitForWipe(); + }, + }); + }, + + waitForWipe: function () { + let me = this; + let op = me.wipeOperation; + if (!op?.upid) { + return; + } + op.state = 'waiting'; + let done = function (success, final, msg) { + if (me.destroyed || me.wipeOperation !== op) { + return; + } + if (final) { + delete me.wipeOperation; + } else { + op.state = 'unknown'; + } + op.refresh(success); + me.updateActions(); + if (final && !success) { + Ext.Msg.alert(gettext('Error'), msg); + } + }; + me.waitForTask(op.node, op.upid, { + msg: Ext.htmlEncode(me.wipeStatusText()), + texts: { unknown: gettext('Could not determine the task outcome') }, + success: () => done(true, true), + failure: (msg, final) => done(false, final, msg), + }); + }, + + clearWipe: function () { + let me = this; + let op = me.wipeOperation; + if (me.busy || op?.state !== 'unknown' || op.upid) { + return; + } + delete me.wipeOperation; + op.refresh(false); + me.updateActions(); + }, + activateLunView: function (config) { let me = this; if (me.lunView) { @@ -843,6 +944,10 @@ Ext.define('PVE.storage.wizard.RemoteStorage', { }); me.callParent(); + let confirmItems = entry.confirmItems?.(); + if (confirmItems) { + me.down('#confirm').add(confirmItems); + } let back = me.down('#back'); let backHandler = back.handler; back.setHandler(function () { @@ -857,6 +962,9 @@ Ext.define('PVE.storage.wizard.RemoteStorage', { field.on('validitychange', () => me.updateActions()); }); me.down('#wizcontent').on('tabchange', () => me.updateActions()); - me.down('#wizcontent').on('beforetabchange', () => !me.busy && !me.frozenValues); + me.down('#wizcontent').on( + 'beforetabchange', + () => !me.busy && !me.wipeOperation && !me.frozenValues, + ); }, }); -- 2.47.3