From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id E44ED1FF0AF for ; Fri, 06 Nov 2026 02:01:36 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 999A721807; Fri, 06 Nov 2026 01:53:16 +0100 (CET) From: Thomas Lamprecht To: pve-devel@lists.proxmox.com Subject: [PATCH v3 manager 53/76] ui: storage wizard: add Fibre Channel shared LVM setup Date: Fri, 6 Nov 2026 00:31:28 +0100 Message-ID: <20261105233336.2178064-54-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: 1793926181107 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.287 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) 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: X75CDKZ6E3WKDB4MNX6OODG6CGXBAAEA X-Message-ID-Hash: X75CDKZ6E3WKDB4MNX6OODG6CGXBAAEA 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: Fibre Channel exposes block devices rather than a storage plugin. Guide shared LVM setup using unused devices or existing volume groups, showing storage ownership and local usage alongside device identity. Accept only Fibre Channel devices. Local and unknown transports remain display-only under "Show all devices": identity alone does not establish shared access. Require stable by-id links for new groups because kernel names can be reused before the task runs. Bind inventory responses and selections to their scan node and request, rejecting obsolete selections. Reuse these responses for cluster visibility checks without silently excluding unchecked nodes. The wizard tracks volume-group creation and keeps partial-setup settings fixed. Resume status checks for known tasks, but never repeat an unanswered creation request; direct users to task history if its task ID is unknown. Co-authored-by: Dietmar Maurer Signed-off-by: Dietmar Maurer Signed-off-by: Thomas Lamprecht --- www/manager6/Makefile | 3 + www/manager6/SANUtils.js | 179 +++++ www/manager6/Utils.js | 4 + www/manager6/storage/wizard/CommonSettings.js | 2 + www/manager6/storage/wizard/FibreChannel.js | 674 ++++++++++++++++++ www/manager6/storage/wizard/LunPicker.js | 25 + www/manager6/storage/wizard/Wizard.js | 169 ++++- 7 files changed, 1054 insertions(+), 2 deletions(-) create mode 100644 www/manager6/SANUtils.js create mode 100644 www/manager6/storage/wizard/FibreChannel.js create mode 100644 www/manager6/storage/wizard/LunPicker.js diff --git a/www/manager6/Makefile b/www/manager6/Makefile index 7002518e0..602e3b9b9 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -7,6 +7,7 @@ JSSRC= \ StateProvider.js \ qemu/Architecture.js \ Utils.js \ + SANUtils.js \ UIOptions.js \ Toolkit.js \ VNCConsole.js \ @@ -380,8 +381,10 @@ JSSRC= \ storage/ESXIEdit.js \ storage/wizard/Wizard.js \ storage/wizard/CommonSettings.js \ + storage/wizard/LunPicker.js \ storage/wizard/NFS.js \ storage/wizard/CIFS.js \ + storage/wizard/FibreChannel.js \ Workspace.js \ # end of JSSRC list diff --git a/www/manager6/SANUtils.js b/www/manager6/SANUtils.js new file mode 100644 index 000000000..3b31ec614 --- /dev/null +++ b/www/manager6/SANUtils.js @@ -0,0 +1,179 @@ +Ext.define('PVE.SANUtils', { + singleton: true, + + lun_fields: [ + 'devpath', + 'size', + 'vendor', + 'model', + 'paths-total', + 'usage', + 'used-by-storage', + 'vgname', + 'pv-in-use', + 'detail', + 'by-id', + ], + + render_san_lun_device: function (value, metaData, rec) { + let text = Ext.htmlEncode(value); + // only multipath mapped devices report their path count + let paths = rec.get('paths-total'); + if (paths) { + text += ` (${paths} ${ngettext('path', 'paths', paths)})`; + } + return text; + }, + + // Unchecked nodes (offline, failed or timed out) may still see a LUN. + scan_nodes_for_luns: function ( + callback, + request = (options) => Proxmox.Utils.API2Request(options), + ) { + let wwids = {}; + let unchecked = []; + let nodes = PVE.data.ResourceStore.getNodes(); + let pending = nodes.length; + let finish = function () { + callback({ + sees: (wwid) => + Object.keys(wwids) + .filter((node) => wwids[node].has(wwid)) + .sort(), + unchecked: unchecked.sort(), + }); + }; + if (!pending) { + finish(); + return; + } + let done = function () { + if (--pending === 0) { + finish(); + } + }; + for (const node of nodes) { + if (node.status !== 'online') { + unchecked.push(node.node); + done(); + continue; + } + request({ + url: `/nodes/${node.node}/scan/san-luns`, + method: 'GET', + timeout: 20000, + failure: function () { + unchecked.push(node.node); + done(); + }, + success: function (response) { + let rows = (response.result.data || []).filter( + (row) => !['sata', 'usb'].includes(row.transport), + ); + if (rows.some((row) => row['wwid-unknown'])) { + unchecked.push(node.node); + } + wwids[node.node] = new Set(rows.map((row) => row.wwid).filter((w) => w)); + done(); + }, + }); + } + }, + + request_uncertain: function (response) { + return response.timedout || response.status === 0 || response.result?.success === undefined; + }, + + render_san_lun_usage: function (value, metaData, rec) { + let text = PVE.SANUtils.san_lun_usage_text(rec.data); + metaData.tdAttr = `data-qtip="${Ext.htmlEncode(text)}"`; + return text; + }, + + san_storage_only_connects: function (storeid) { + let rec = PVE.data.ResourceStore.findRecord('storage', storeid, 0, false, true, true); + if (!rec || !(rec.get('plugintype') === 'iscsi')) { + return false; + } + let content = `${rec.get('content') ?? ''}`; + return content === '' || content === 'none'; + }, + + san_lun_usage_text: function (row) { + let value = row.usage; + if (value === 'unknown') { + return Ext.htmlEncode( + Ext.String.format( + gettext('Could not check this LUN for data: {0}'), + row.detail || gettext('unknown'), + ), + ); + } + let sid = row['used-by-storage']; + // 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') { + if (connects) { + return Ext.htmlEncode( + Ext.String.format(gettext('unused, connected through storage "{0}"'), sid), + ); + } + if (sid) { + return Ext.htmlEncode(Ext.String.format(gettext('used by storage "{0}"'), sid)); + } + return gettext('unused'); + } + if (connects) { + sid = undefined; + } + if (value === 'lvm' || (value === 'other' && row.detail === 'LVM2_member')) { + let vgname = row.vgname; + if (vgname) { + let text = Ext.String.format(gettext('VG "{0}"'), vgname); + if (sid === vgname) { + text = Ext.String.format(gettext('VG of storage "{0}"'), sid); + } else if (sid) { + text = Ext.String.format( + gettext('VG "{0}", used by storage "{1}"'), + vgname, + sid, + ); + } + return Ext.htmlEncode(text); + } + let orphan = [0, '0', false].includes(row['pv-in-use']); + let text; + if (orphan) { + text = gettext('LVM physical volume, no volume group'); + } else { + text = gettext('LVM physical volume, volume group not known on this node'); + } + return Ext.htmlEncode( + sid ? Ext.String.format(gettext('{0}, used by storage "{1}"'), text, sid) : text, + ); + } + return Ext.htmlEncode(row.detail || gettext('in use')); + }, + + render_san_transport: function (value) { + if (!value) { + return ''; + } + return ( + { + iscsi: 'iSCSI', + iser: 'iSER', + fc: 'FC', + fcoe: 'FCoE', + sas: 'SAS', + nvme: 'NVMe', + 'nvme-tcp': 'NVMe/TCP', + 'nvme-rdma': 'NVMe/RDMA', + 'nvme-fc': 'NVMe/FC', + 'nvme-loop': 'NVMe loop', + tcp: 'TCP', + rdma: 'RDMA', + }[value] ?? Ext.htmlEncode(value) + ); + }, +}); diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js index ca850158b..bc5171712 100644 --- a/www/manager6/Utils.js +++ b/www/manager6/Utils.js @@ -274,6 +274,10 @@ Ext.define('PVE.Utils', { return ' ' + value; }, + note_text: function (text) { + return `${gettext('Note:')} ${text}`; + }, + validateZfsBlocksize: function (value) { if (!value) { return true; diff --git a/www/manager6/storage/wizard/CommonSettings.js b/www/manager6/storage/wizard/CommonSettings.js index a2bd240fd..fedd6dc31 100644 --- a/www/manager6/storage/wizard/CommonSettings.js +++ b/www/manager6/storage/wizard/CommonSettings.js @@ -10,6 +10,7 @@ Ext.define('PVE.storage.wizard.CommonSettings', { fixedContent: undefined, contentFieldConfig: undefined, nodesFieldConfig: undefined, + extraColumn1: undefined, onGetValues: function (values) { values.disable = values.enable ? 0 : 1; @@ -28,6 +29,7 @@ Ext.define('PVE.storage.wizard.CommonSettings', { vtype: 'StorageId', allowBlank: false, }, + ...(me.extraColumn1 || []), ]; if (!me.fixedContent) { diff --git a/www/manager6/storage/wizard/FibreChannel.js b/www/manager6/storage/wizard/FibreChannel.js new file mode 100644 index 000000000..4affbd8a7 --- /dev/null +++ b/www/manager6/storage/wizard/FibreChannel.js @@ -0,0 +1,674 @@ +Ext.define('PVE.storage.wizard.FCLunSelect', { + extend: 'Proxmox.panel.InputPanel', + xtype: 'pveStorageWizardFCLunSelect', + mixins: ['PVE.storage.wizard.LunPicker'], + + onlineHelp: 'storage_lvm', + + // { lun } of the selected row + selection: undefined, + lunScan: undefined, + + // include unchecked nodes, one left out cannot use the storage after a migration + updateNodes: function () { + let me = this; + let wizard = me.up('window'); + let lun = me.selection?.lun; + if (PVE.Utils.isStandaloneNode() || !lun?.wwid || !me.lunScan) { + return; + } + let nodes = new Set([ + wizard.getScanNode(), + ...me.lunScan.sees(lun.wwid), + ...me.lunScan.unchecked, + ]); + wizard.down('field[name=nodes]').setValue([...nodes].sort()); + }, + + validator: function () { + let wizard = this.up('window'); + return !wizard.busy && !!wizard.getViewModel().get('vgmode'); + }, + + updateActions: function () { + let me = this; + let wizard = me.up('window'); + let blocked = wizard.busy; + let grid = me.down('#lunGrid'); + grid.setDisabled(blocked || me.scanning); + me.down('#rescan').setDisabled(blocked); + me.down('pveStorageScanNodeSelector')?.setDisabled(blocked); + if (wizard.down('#wizcontent').getActiveTab() === me) { + wizard.down('#next').setDisabled(!me.validator()); + } + }, + + currentSelection: function (values) { + let me = this; + let wizard = me.up('window'); + let rec = me.down('#lunGrid').getSelection()[0]; + if ( + me.scanning || + !rec?.get('selectable') || + rec.data !== me.selection?.lun || + rec.get('scanNode') !== wizard.getScanNode() || + rec.get('scanGeneration') !== me.scanGeneration + ) { + return false; + } + return values.vgmode === 'existing' + ? rec.get('usage') === 'lvm' && values.vgname === rec.get('vgname') + : values.vgmode === 'create' && + rec.get('usage') === 'unused' && + !!rec.get('by-id') && + values.device === rec.get('by-id'); + }, + + updateEmptyText: function () { + let me = this; + let text = Ext.String.format( + gettext( + 'No Fibre Channel devices found on node "{0}". Check zoning and array access, and rescan the host bus adapters on the node. Then press Rescan.', + ), + Ext.htmlEncode(me.up('window').getScanNode()), + ); + if (!me.down('#showAllDevices').getValue()) { + text += + ' ' + + gettext( + '"Show all devices" also lists local devices and devices with an unknown transport.', + ); + } + me.down('#lunGrid').setEmptyText(text); + }, + + updatePrivilegeNote: function () { + let me = this; + let note = me.down('#privilegeNote'); + if (!note || me.canModify) { + return; + } + let rows = me.down('#lunGrid').getStore().getRange(); + let stuck = + me.scanDone && + !rows.some((rec) => rec.get('selectable')) && + rows.some((rec) => rec.get('usage') === 'unused' && !rec.get('reason')); + let text = gettext( + 'Without the Sys.Modify privilege on "/", only LUNs with an existing volume group can be selected.', + ); + note.setValue( + stuck + ? ` ${text}` + : PVE.Utils.note_text(text), + ); + note[stuck ? 'addCls' : 'removeCls']('pmx-hint'); + }, + + refreshLuns: function () { + let me = this; + if (!me.rendered || me.destroyed) { + return; + } + me.updateEmptyText(); + let wizard = me.up('window'); + let node = wizard.getScanNode(); + let generation = (me.scanGeneration = (me.scanGeneration ?? 0) + 1); + let current = () => + !me.destroyed && generation === me.scanGeneration && node === wizard.getScanNode(); + let grid = me.down('#lunGrid'); + me.scanning = true; + me.scanDone = false; + grid.getSelectionModel().deselectAll(); + me.selection = undefined; + me.lunScan = undefined; + wizard.getViewModel().set({ vgmode: '', device: '', vgname: '' }); + grid.getStore().setData([]); + Proxmox.Utils.setErrorMask(grid, false); + grid.setLoading(true); + wizard.updateActions(); + // the grid and the cluster-wide check share the raw local scan within this load + let scans = new Map(); + let request = function (options) { + let scan = scans.get(options.url); + if (scan) { + if (scan.outcome) { + options[scan.outcome](scan.response); + } else { + scan.waiting.push(options); + } + return; + } + scan = { waiting: [options] }; + scans.set(options.url, scan); + let finish = function (outcome, response) { + scan.outcome = outcome; + scan.response = response; + scan.waiting.forEach((listener) => listener[outcome](response)); + scan.waiting = []; + }; + Proxmox.Utils.API2Request({ + ...options, + failure: (response) => finish('failure', response), + success: (response) => finish('success', response), + }); + }; + request({ + url: `/nodes/${node}/scan/san-luns`, + method: 'GET', + timeout: 20000, + failure: function (response) { + if (!current()) { + return; + } + me.scanning = false; + grid.setLoading(false); + Proxmox.Utils.setErrorMask(grid, response.htmlStatus); + me.updatePrivilegeNote(); + wizard.updateActions(); + }, + success: function (response) { + if (!current()) { + return; + } + grid.getStore().setData( + (response.result.data || []).map((row) => ({ + ...row, + scanNode: node, + scanGeneration: generation, + })), + ); + me.scanning = false; + me.scanDone = true; + grid.setLoading(false); + me.updatePrivilegeNote(); + wizard.updateActions(); + }, + }); + if (!PVE.Utils.isStandaloneNode()) { + PVE.SANUtils.scan_nodes_for_luns(function (result) { + if (!current()) { + return; + } + me.lunScan = result; + me.updateNodes(); + }, request); + } + }, + + initComponent: function () { + let me = this; + + let sanTransports = { fc: 1, 'nvme-fc': 1 }; + + // these need an initiator connection, which this storage type does not set up on other + // nodes + let remoteTransports = { + fcoe: 1, + iscsi: 1, + 'nvme-tcp': 1, + 'nvme-rdma': 1, + 'nvme-loop': 1, + }; + + // a device on these is attached to this node only; for SAS or an unknown transport the scan + // cannot tell whether other nodes reach it + let localTransports = { ata: 1, mmc: 1, nvme: 1, sata: 1, sbp: 1, usb: 1, virtio: 1 }; + + let sanTransportFilter = new Ext.util.Filter({ + id: 'san-transport', + filterFn: (rec) => !!sanTransports[rec.get('transport')], + }); + + let remoteTransportFilter = new Ext.util.Filter({ + id: 'remote-transport', + filterFn: (rec) => !remoteTransports[rec.get('transport')], + }); + + // creating the volume group needs Sys.Modify on / + let caps = Ext.state.Manager.get('GuiCap'); + let canModify = !!caps.dc['Sys.Modify']; + me.canModify = canModify; + + let notOffered = function (data) { + if (!sanTransports[data.transport]) { + return localTransports[data.transport] + ? gettext('Not offered: local device') + : gettext('Not offered: shared access cannot be verified for this transport'); + } + // a kernel name may point to another device by the time the volume group is created + if (data.usage === 'unused' && !data['used-by-storage'] && !data['by-id']) { + return gettext('The LUN has no stable device name for creating a volume group.'); + } + return undefined; + }; + + let columns = me.lunColumns(); + let store = Ext.create('Ext.data.Store', { + fields: [ + ...PVE.SANUtils.lun_fields, + 'scanNode', + 'scanGeneration', + 'transport', + 'serial', + 'wwn', + 'wwid', + 'iscsi-lun', + { + name: 'reason', + calculate: notOffered, + }, + { + name: 'selectable', + calculate: function (data) { + if (notOffered(data) || data['used-by-storage']) { + return false; + } + if (data.usage === 'unused') { + return canModify; + } + return data.usage === 'lvm' && !!data.vgname; + }, + }, + ], + filters: [remoteTransportFilter, sanTransportFilter], + sorters: [ + { + sorterFn: function (a, b) { + let rank = (rec) => (sanTransports[rec.data.transport] ? 0 : 1); + return rank(a) - rank(b) || a.data.devpath.localeCompare(b.data.devpath); + }, + }, + ], + }); + + let updateSelection = function (rec) { + let wizard = me.up('window'); + me.selection = rec ? { lun: rec.data } : undefined; + let vm = wizard.getViewModel(); + if (!rec || !rec.get('selectable')) { + vm.set({ vgmode: '', device: '', vgname: '' }); + } else if (rec.get('usage') === 'unused') { + vm.set({ vgmode: 'create', device: rec.get('by-id'), vgname: '' }); + } else { + vm.set({ vgmode: 'existing', device: '', vgname: rec.get('vgname') }); + } + me.updateNodes(); + }; + + me.items = [ + { + xtype: 'displayfield', + value: gettext('Select the SAN LUN for the volume group:'), + }, + { + xtype: 'grid', + itemId: 'lunGrid', + height: 220, + store: store, + viewConfig: { + trackOver: false, + getRowClass: (rec) => (rec.get('selectable') ? '' : 'x-item-disabled'), + }, + listeners: { + beforeselect: (grid, rec) => rec.get('selectable'), + selectionchange: (sm, selection) => updateSelection(selection[0]), + }, + columns: [ + { + header: gettext('Device'), + dataIndex: 'devpath', + flex: 3, + renderer: PVE.SANUtils.render_san_lun_device, + }, + { + header: gettext('Transport'), + dataIndex: 'transport', + width: 80, + renderer: PVE.SANUtils.render_san_transport, + }, + columns.size, + columns.model, + { + header: gettext('Usage'), + dataIndex: 'usage', + flex: 2, + renderer: function (value, metaData, rec) { + let reason = rec.get('reason'); + if (!reason) { + return PVE.SANUtils.render_san_lun_usage(value, metaData, rec); + } + let text = Ext.htmlEncode(reason); + metaData.tdAttr = `data-qtip="${Ext.htmlEncode(text)}"`; + return text; + }, + }, + ], + }, + { + xtype: 'displayfield', + itemId: 'privilegeNote', + hidden: canModify, + }, + { + xtype: 'container', + layout: 'hbox', + items: [ + { + xtype: 'button', + itemId: 'rescan', + text: gettext('Rescan'), + iconCls: 'fa fa-search', + handler: () => me.refreshLuns(), + }, + ], + }, + { + xtype: 'proxmoxcheckbox', + itemId: 'showAllDevices', + boxLabel: gettext('Show all devices'), + submitValue: false, + autoEl: { + tag: 'div', + 'data-qtip': gettext('Also list devices with local or unknown transports.'), + }, + listeners: { + change: function (f, value) { + me.updateEmptyText(); + let filters = store.getFilters(); + if (value) { + filters.remove(sanTransportFilter); + } else { + filters.add(sanTransportFilter); + let sm = me.down('#lunGrid').getSelectionModel(); + let selected = sm.getSelection()[0]; + if (selected && store.indexOf(selected) < 0) { + sm.deselectAll(); + } + } + me.updatePrivilegeNote(); + }, + }, + }, + { + xtype: 'textfield', + name: 'vgmode', + hidden: true, + allowBlank: false, + bind: { value: '{vgmode}' }, + }, + { + xtype: 'textfield', + name: 'device', + hidden: true, + allowBlank: true, + bind: { value: '{device}' }, + }, + ]; + + if (!PVE.Utils.isStandaloneNode()) { + me.items.unshift({ + xtype: 'pveStorageScanNodeSelector', + listeners: { + change: function () { + me.refreshLuns(); + }, + }, + }); + } + + // a step is created before its window + me.on( + 'afterrender', + function () { + me.mon(me.up('window'), 'setupchange', me.updateActions, me); + me.refreshLuns(); + }, + me, + { single: true }, + ); + me.callParent(); + me.updatePrivilegeNote(); + }, +}); + +PVE.storage.wizard.types.fc = { + text: gettext('Fibre Channel'), + description: gettext('Block storage (LUNs) on a SAN, attached via Fibre Channel.'), + group: 'san', + apiType: 'lvm', + viewModel: { + data: { + vgmode: '', + device: '', + vgname: '', + }, + formulas: { + vgCreate: (get) => get('vgmode') === 'create', + }, + }, + steps: () => [ + { + xtype: 'pveStorageWizardFCLunSelect', + title: gettext('SAN LUN'), + }, + ], + settings: { + onlineHelp: 'storage_lvm', + cts: ['images', 'rootdir'], + defaultContent: ['images', 'rootdir'], + extraColumn1: [ + { + xtype: 'textfield', + name: 'vgname', + fieldLabel: gettext('Volume group'), + allowBlank: true, + emptyText: gettext('use storage ID'), + bind: { + value: '{vgname}', + readOnly: '{!vgCreate}', + }, + }, + ], + advancedColumn1: [ + { + xtype: 'proxmoxcheckbox', + name: 'saferemove', + uncheckedValue: 0, + fieldLabel: gettext('Wipe Removed Volumes'), + }, + ], + }, + + canSubmit: function (wizard) { + return !!wizard.getValues().vgmode; + }, + + summaryRows: function (values, wizard) { + let step = wizard.down('pveStorageWizardFCLunSelect'); + let { lun = {} } = step.selection ?? {}; + let rows = []; + if (values.vgmode === 'create') { + rows.push({ + label: gettext('Volume group'), + value: Ext.String.format( + gettext('Create "{0}" on {1} ({2})'), + values.vgname || values.storage, + values.device, + Proxmox.Utils.format_size(lun.size), + ), + weight: 20, + }); + } else { + rows.push({ + label: gettext('Volume group'), + value: Ext.String.format(gettext('Use existing "{0}"'), values.vgname), + weight: 20, + }); + } + let unchecked = (step.lunScan?.unchecked ?? []).filter((node) => + [] + .concat(values.nodes ?? []) + .join(',') + .split(',') + .includes(node), + ); + if (unchecked.length) { + rows.push({ + label: gettext('Not checked'), + value: Ext.String.format( + gettext('Could not check whether these nodes see the LUN: {0}.'), + unchecked.join(', '), + ), + weight: 33, + }); + } + return { rows, skip: ['vgmode', 'vgname', 'device'] }; + }, + + submit: function (wizard, values) { + let step = wizard.down('pveStorageWizardFCLunSelect'); + if (!step.currentSelection(values)) { + Ext.Msg.alert( + gettext('Error'), + gettext('Select a LUN from the current scan before continuing.'), + ); + return; + } + let enc = Ext.htmlEncode; + let mode = values.vgmode; + delete values.vgmode; + + let params = { + storage: values.storage, + type: 'lvm', + vgname: values.vgname || values.storage, + shared: 1, + content: values.content, + nodes: values.nodes, + disable: values.disable, + saferemove: values.saferemove, + }; + + if (mode === 'existing') { + wizard.runSubmitChain([(next) => wizard.createStorage(params, next)]); + return; + } + + let created = (wizard.created ??= {}); + let node = wizard.getScanNode(); + let device = values.device; + + let vgCreatedHint = () => + Ext.String.format( + gettext( + 'The volume group "{0}" was created on node "{1}". Press {2} to retry the remaining steps, or complete the setup under Datacenter > Storage > Add > LVM.', + ), + enc(params.vgname), + enc(node), + enc(wizard.down('#submit').text), + ); + + let volumeGroupFailure = function (msg, final) { + if (final) { + delete created.task; + wizard.unfreeze(); + } else { + msg += + '

' + + Ext.String.format( + gettext( + 'If the task outcome stays unknown, check whether the volume group "{0}" exists on node "{1}" and complete the setup under Datacenter > Storage > Add > LVM.', + ), + enc(params.vgname), + enc(node), + ); + } + wizard.updateActions(); + Ext.Msg.alert(gettext('Error'), msg); + }; + let unknownVolumeGroup = function () { + wizard.unknownVolumeGroup = true; + wizard.freeze(); + wizard.showOperationNotice( + Ext.String.format( + gettext( + 'The node did not answer in time, so a volume group task may still be running on node "{0}". Check its task history. Once the task is done, run the wizard again; it offers the volume group "{1}" if the task created it.', + ), + enc(node), + enc(params.vgname), + ), + ); + wizard.updateActions(); + }; + let waitForVolumeGroup = function (next) { + wizard.waitForTask(node, created.task, { + msg: gettext('Creating the volume group...'), + texts: { + running: Ext.String.format( + gettext( + 'The task on node "{0}" is still running. Check its task log, or press {1} to wait for it again. If it creates volume group "{2}", you can select that group when you run the wizard again.', + ), + enc(node), + enc(wizard.down('#submit').text), + enc(params.vgname), + ), + }, + success: function () { + created.volumeGroup = params.vgname; + delete created.task; + next(); + }, + failure: volumeGroupFailure, + }); + }; + + wizard.runSubmitChain([ + (next) => { + if (created.task || created.volumeGroup) { + next(); + return; + } + wizard.assertStorageIdsFree([params.storage], next); + }, + (next) => { + if (created.volumeGroup) { + next(); + return; + } + // a task started earlier is still the one to wait for, a second create would + // race it on the same device + if (created.task) { + waitForVolumeGroup(next); + return; + } + wizard.request({ + url: `/nodes/${node}/disks/lvm`, + method: 'POST', + params: { + name: params.vgname, + device: device, + }, + failure: function (response) { + if (PVE.SANUtils.request_uncertain(response)) { + unknownVolumeGroup(); + } else { + volumeGroupFailure(response.htmlStatus, true); + } + }, + success: function (response) { + try { + Proxmox.Utils.parse_task_upid(response.result.data); + } catch (_error) { + unknownVolumeGroup(); + return; + } + created.task = response.result.data; + wizard.freeze(); + waitForVolumeGroup(next); + }, + }); + }, + (next) => wizard.createStorage(params, next, vgCreatedHint()), + ]); + }, +}; diff --git a/www/manager6/storage/wizard/LunPicker.js b/www/manager6/storage/wizard/LunPicker.js new file mode 100644 index 000000000..cb3eba7b3 --- /dev/null +++ b/www/manager6/storage/wizard/LunPicker.js @@ -0,0 +1,25 @@ +Ext.define('PVE.storage.wizard.LunPicker', { + extend: 'Ext.Mixin', + + mixinConfig: { + id: 'lunPicker', + }, + + lunColumns: function () { + return { + size: { + header: gettext('Size'), + dataIndex: 'size', + width: 90, + renderer: Proxmox.Utils.render_size, + }, + model: { + header: gettext('Vendor') + '/' + gettext('Model'), + dataIndex: 'model', + flex: 2, + renderer: (value, metaData, rec) => + Ext.htmlEncode(`${rec.get('vendor') || ''} ${value || ''}`.trim()), + }, + }; + }, +}); diff --git a/www/manager6/storage/wizard/Wizard.js b/www/manager6/storage/wizard/Wizard.js index ef3e505eb..b4978d296 100644 --- a/www/manager6/storage/wizard/Wizard.js +++ b/www/manager6/storage/wizard/Wizard.js @@ -82,6 +82,8 @@ Ext.define('PVE.storage.wizard.RemoteStorage', { busy: false, + unknownVolumeGroup: false, + // the continuation lives in this window: block closing and the buttons while waiting, then // restore them setBusy: function (busy, msg) { @@ -103,6 +105,42 @@ Ext.define('PVE.storage.wizard.RemoteStorage', { me.down('#back').setDisabled(true); } } + me.updateActions(); + }, + + canSubmit: function () { + let me = this; + if (me.busy || me.unknownVolumeGroup) { + return false; + } + let canSubmit = PVE.storage.wizard.types[me.wizardType].canSubmit; + if (canSubmit) { + return canSubmit(me); + } + return true; + }, + + actionText: function () { + return gettext('Finish'); + }, + + updateActions: function () { + let me = this; + if (!me.down('#submit')) { + return; + } + me.down('#submit').setText(me.actionText()); + // 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) { + me.down('#submit').setDisabled(!me.canSubmit()); + me.down('#back').setDisabled(!!me.frozenValues || me.busy); + } + me.fireEvent('setupchange', me); + // after the listeners, so no step can enable an action again + if (me.busy || me.unknownVolumeGroup) { + ['#back', '#next', '#submit'].forEach((id) => me.down(id).disable()); + } }, // keeps the window busy, so it cannot close with the continuation pending @@ -150,13 +188,117 @@ Ext.define('PVE.storage.wizard.RemoteStorage', { me.down('#back').setDisabled(true); }, + unfreeze: function () { + let me = this; + if (!me.frozenValues) { + return; + } + delete me.frozenValues; + me.down('#wizcontent').items.each((tab) => tab.enable()); + me.down('#back').setDisabled(false); + }, + getValues: function (dirtyOnly) { let me = this; return me.frozenValues ? Ext.clone(me.frozenValues) : me.callParent([dirtyOnly]); }, - // a step calls next() on success and just returns after a failure, so Finish retries from - // there + // Wait without a TaskProgress window, as closing it would drop the continuation. An unreachable + // task status is not final: the next submit resumes waiting. + waitForTask: function (node, upid, options) { + let me = this; + let transportFailures = 0; + let deadline = Date.now() + 180000; + let ended = false; + let lastRunning = 0; + let unknown = + options.texts?.unknown ?? + Ext.String.format( + gettext('The task outcome is unknown. Press {0} to wait for it again.'), + Ext.htmlEncode(me.actionText()), + ); + let timer; + let fail = function (msg, final) { + if (me.destroyed || ended) { + return; + } + ended = true; + clearTimeout(timer); + me.setBusy(false); + options.failure(msg, final); + }; + timer = Ext.defer( + () => + fail( + lastRunning && Date.now() - lastRunning < 3000 + ? (options.texts?.running ?? unknown) + : unknown, + false, + ), + 180000, + ); + let poll = function () { + if (me.destroyed || ended) { + return; + } + if (Date.now() >= deadline) { + fail(unknown, false); + return; + } + Proxmox.Utils.API2Request({ + url: `/nodes/${node}/tasks/${encodeURIComponent(upid)}/status`, + method: 'GET', + timeout: Math.max(1, Math.min(30000, deadline - Date.now())), + failure: function (response) { + if (me.destroyed || ended) { + return; + } + lastRunning = 0; + if (++transportFailures <= 10 && Date.now() < deadline) { + Ext.defer(poll, 3000); + return; + } + fail(`${unknown}

${response.htmlStatus}`, false); + }, + success: function (response) { + if (me.destroyed || ended) { + return; + } + transportFailures = 0; + let task = response.result.data; + if (task?.status === 'running') { + lastRunning = Date.now(); + if (Date.now() >= deadline) { + fail(options.texts?.running ?? unknown, false); + return; + } + Ext.defer(poll, 1000); + return; + } + let outcome = + task?.status === 'stopped' && task.exitstatus + ? Proxmox.Utils.parse_task_status(task.exitstatus) + : 'unknown'; + if (outcome === 'unknown') { + fail(unknown, false); + } else if (outcome === 'error') { + fail(Ext.htmlEncode(task.exitstatus), true); + } else { + // a task that finished with warnings did its job + ended = true; + clearTimeout(timer); + me.setBusy(false); + options.success(); + } + }, + }); + }; + me.setBusy(true, options.msg); + poll(); + }, + + // a step calls next() on success and just returns after a failure, so the next submit retries + // from there runSubmitChain: function (steps) { let me = this; let run = function (idx) { @@ -207,6 +349,14 @@ Ext.define('PVE.storage.wizard.RemoteStorage', { }); }, + showOperationNotice: function (text) { + let notice = this.down('#operationNotice'); + notice.setValue(text ? ` ${text}` : ''); + notice.setHidden(!text); + this.down('#summaryNotes').setHidden(!!text); + this.updateActions(); + }, + // errorHtml (safe HTML) is prepended to an error createStorage: function (params, next, errorHtml) { let me = this; @@ -317,6 +467,7 @@ Ext.define('PVE.storage.wizard.RemoteStorage', { for (const note of typeEntry.summaryNotes?.(values, me) || []) { notes.add({ value: note }); } + me.updateActions(); }, submitSimple: function (values) { @@ -422,6 +573,11 @@ Ext.define('PVE.storage.wizard.RemoteStorage', { }, ], }, + { + xtype: 'displayfield', + itemId: 'operationNotice', + hidden: true, + }, { xtype: 'container', itemId: 'summaryNotes', @@ -437,6 +593,9 @@ Ext.define('PVE.storage.wizard.RemoteStorage', { }, onSubmit: function () { let wizard = this.up('window'); + if (!wizard.canSubmit()) { + return; + } let values = wizard.getValues(); delete values.delete; let typeEntry = PVE.storage.wizard.types[wizard.wizardType]; @@ -467,5 +626,11 @@ Ext.define('PVE.storage.wizard.RemoteStorage', { }); me.callParent(); + me.query('field').forEach((field) => { + field.on('change', () => me.updateActions()); + field.on('validitychange', () => me.updateActions()); + }); + me.down('#wizcontent').on('tabchange', () => me.updateActions()); + me.down('#wizcontent').on('beforetabchange', () => !me.busy && !me.frozenValues); }, }); -- 2.47.3