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 743781FF09B for ; Mon, 31 Aug 2026 14:31:45 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 978CF216DC; Mon, 31 Aug 2026 14:30:15 +0200 (CEST) From: Dominik Csapak To: pve-devel@lists.proxmox.com Subject: [PATCH manager v4 09/12] ui: qemu: hardware view: separate disks into own grid Date: Mon, 31 Aug 2026 14:27:44 +0200 Message-ID: <20260831122953.2980232-10-d.csapak@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260831122953.2980232-1-d.csapak@proxmox.com> References: <20260831122953.2980232-1-d.csapak@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL 0.610 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: 5Z7GSHR3FRZZOYPP64LDTH576EKHLZRO X-Message-ID-Hash: 5Z7GSHR3FRZZOYPP64LDTH576EKHLZRO X-MailFrom: d.csapak@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: by using the newly added HardwareDiskGrid. For this to work, the selection model as well as the button logic has to be changed a bit. There are now two add/edit/remove/revert buttons and the one of the disk grid enable/disable themselves with the enableFn instead of being enabled in the set_button_status. The disk action menu is split out into separate buttons in the disk grid, so the enableFns of these take care of enabling/disabling now. This slightly duplicates some checks, but it's now defined with each button when its enabled and disabled and we can simplify some variables. While at it, change the order of the edit and remove button in the general grid to have it more aligned with all our other grids. Signed-off-by: Dominik Csapak --- www/manager6/qemu/HardwareView.js | 419 +++++++++++++++++++----------- 1 file changed, 266 insertions(+), 153 deletions(-) diff --git a/www/manager6/qemu/HardwareView.js b/www/manager6/qemu/HardwareView.js index fd565e36..c4318851 100644 --- a/www/manager6/qemu/HardwareView.js +++ b/www/manager6/qemu/HardwareView.js @@ -6,6 +6,11 @@ Ext.define('PVE.qemu.HardwareView', { referenceHolder: true, + layout: { + type: 'vbox', + align: 'stretch', + }, + // helpers to redirect the methods to the hardwareGrid getObjectValue: function () { @@ -403,7 +408,23 @@ Ext.define('PVE.qemu.HardwareView', { let baseurl = `nodes/${nodename}/qemu/${vmid}/config`; - let sm = Ext.create('Ext.selection.RowModel', {}); + let sm = Ext.create('Ext.selection.Model', { + getSelection: () => { + let selection = []; + me.items.each((item) => { + if (item.isXType('grid')) { + let itemSelection = item.getSelection(); + if (itemSelection.length > 0) { + selection = itemSelection; + return false; + } + } + return true; + }); + + return selection; + }, + }); let run_editor = function () { let rec = sm.getSelection()[0]; @@ -488,74 +509,6 @@ Ext.define('PVE.qemu.HardwareView', { }); }; - let move_menuitem = new Ext.menu.Item({ - text: gettext('Move Storage'), - tooltip: gettext('Move disk to another storage'), - iconCls: 'fa fa-database', - selModel: sm, - handler: () => { - let rec = sm.getSelection()[0]; - if (!rec) { - return; - } - Ext.create('PVE.window.HDMove', { - autoShow: true, - disk: rec.data.key, - nodename: nodename, - vmid: vmid, - type: 'qemu', - listeners: { - destroy: () => me.reload(), - }, - }); - }, - }); - - let reassign_menuitem = new Ext.menu.Item({ - text: gettext('Reassign Owner'), - tooltip: gettext('Reassign disk to another VM'), - iconCls: 'fa fa-desktop', - selModel: sm, - handler: () => { - let rec = sm.getSelection()[0]; - if (!rec) { - return; - } - - Ext.create('PVE.window.GuestDiskReassign', { - autoShow: true, - disk: rec.data.key, - nodename: nodename, - vmid: vmid, - type: 'qemu', - listeners: { - destroy: () => me.reload(), - }, - }); - }, - }); - - let resize_menuitem = new Ext.menu.Item({ - text: gettext('Resize'), - iconCls: 'fa fa-plus', - selModel: sm, - handler: () => { - let rec = sm.getSelection()[0]; - if (!rec) { - return; - } - Ext.create('PVE.window.HDResize', { - autoShow: true, - disk: rec.data.key, - nodename: nodename, - vmid: vmid, - listeners: { - destroy: () => me.reload(), - }, - }); - }, - }); - const efiEnrollMsg = gettext( 'Enroll the UEFI 2023 certificates from Microsoft required for secure boot update.', @@ -578,35 +531,6 @@ Ext.define('PVE.qemu.HardwareView', { gettext( 'Otherwise, you will be prompted for the BitLocker recovery key on the next boot!', ); - let efiEnrollMenuItem = new Ext.menu.Item({ - text: gettext('Enroll Updated Certificates'), - iconCls: 'fa fa-refresh', - selModel: sm, - disabled: true, - hidden: true, - handler: () => { - Ext.Msg.show({ - title: gettext('Confirm'), - icon: Ext.Msg.QUESTION, - message: efiEnrollMsg, - buttons: Ext.Msg.YESNO, - callback: function (btn) { - if (btn !== 'yes') { - return; - } - runEfiEnroll(); - }, - }); - }, - }); - - let diskaction_btn = new Proxmox.button.Button({ - text: gettext('Disk Action'), - disabled: true, - menu: { - items: [move_menuitem, reassign_menuitem, resize_menuitem, efiEnrollMenuItem], - }, - }); let remove_btn = new PVE.button.ConfigRemove({ baseurl, @@ -650,15 +574,15 @@ Ext.define('PVE.qemu.HardwareView', { let set_button_status = function () { let hardwareGrid = me.lookup('hardwareGrid'); - let selection_model = hardwareGrid.getSelectionModel(); - let rec = selection_model.getSelection()[0]; + let rec = hardwareGrid.getSelection()[0]; counts = {}; // en/disable hardwarebuttons let hasCloudInit = false; - me.rstore.getData().items.forEach(function ({ id, data }) { + let items = me.rstore.getData(); + (items.getSource() ?? items).each(function ({ id, data }) { if (!hasCloudInit && (isCloudInitKey(data.value) || isCloudInitKey(data.pending))) { hasCloudInit = true; - return; + return true; } let match = id.match(/^([^\d]+)\d+$/); @@ -692,7 +616,6 @@ Ext.define('PVE.qemu.HardwareView', { if (!rec) { remove_btn.disable(); edit_btn.disable(); - diskaction_btn.disable(); revert_btn.disable(); return; } @@ -701,7 +624,6 @@ Ext.define('PVE.qemu.HardwareView', { const deleted = !!rec.data.delete; const pending = deleted || hardwareGrid.hasPendingChanges(key); - const isRunning = me.pveSelNode.data.running; const isCloudInit = isCloudInitKey(value); const isCDRom = value && PVE.Utils.diskIsCdrom(value); @@ -709,31 +631,12 @@ Ext.define('PVE.qemu.HardwareView', { const isUnusedDisk = key.match(/^unused\d+/); const isUsedDisk = !isUnusedDisk && row.isOnStorageBus && !isCDRom; const isDisk = isUnusedDisk || isUsedDisk; - const isEfi = key === 'efidisk0'; - const tpmMoveable = key === 'tpmstate0' && !isRunning; let cannotDelete = deleted || row.never_delete; cannotDelete ||= isCDRom && !cdromCap; - cannotDelete ||= isDisk && !diskCap; cannotDelete ||= isCloudInit && noVMConfigCloudinitPerm; remove_btn.setDisabled(cannotDelete); - remove_btn.setText( - isUsedDisk && !isCloudInit ? remove_btn.altText : remove_btn.defaultText, - ); - remove_btn.RESTMethod = isUnusedDisk || (isDisk && isRunning) ? 'POST' : 'PUT'; - - let suggestEfiEnroll = false; - if (isEfi) { - let drive = PVE.Parser.parsePropertyString(value, 'file'); - suggestEfiEnroll = - !pending && - PVE.Parser.parseBoolean(drive['pre-enrolled-keys'], false) && - drive['ms-cert'] !== '2023k'; - } - efiEnrollMenuItem.setDisabled(!suggestEfiEnroll); - efiEnrollMenuItem.setHidden(!isEfi); - edit_btn.setDisabled( deleted || !row.editor || @@ -742,12 +645,6 @@ Ext.define('PVE.qemu.HardwareView', { (isDisk && !diskCap), ); - diskaction_btn.setDisabled( - pending || !diskCap || isCloudInit || !(isDisk || isEfi || tpmMoveable), - ); - reassign_menuitem.setDisabled(pending || isEfi || tpmMoveable); - resize_menuitem.setDisabled(pending || !isUsedDisk); - revert_btn.setDisabled(!pending); }; @@ -767,23 +664,47 @@ Ext.define('PVE.qemu.HardwareView', { }); }; + let diskGridFilter = function (rec) { + let val = rec.get('value') ?? rec.get('pending'); + let key = rec.get('key'); + return ( + key.match(/^(ide|sata|scsi|virtio|unused|efidisk|tpmstate)\d+$/) && + !PVE.Utils.diskIsCdrom(val) && + !isCloudInitKey(val) + ); + }; + me.rstore = Ext.create('Proxmox.data.ObjectStore', { model: 'KeyValuePendingDelete', readArray: true, url: `/api2/json/nodes/${nodename}/qemu/${vmid}/pending`, interval: 5000, rows, + filters: [(rec) => !diskGridFilter(rec)], }); + let onSelectionChange = function (sm, selected) { + let selectionGrid = this; + if (selected.length) { + me.items.each((item) => { + if (item.isXType('grid') && item !== selectionGrid) { + item.getSelectionModel().deselectAll(); + } + }); + } + set_button_status(); + }; + Ext.apply(me, { items: [ { + title: gettext('General'), + iconCls: 'fa fa-desktop', xtype: 'proxmoxPendingObjectGrid', reference: 'hardwareGrid', border: false, rstore: me.rstore, interval: 5000, - selModel: sm, run_editor: run_editor, renderKey: function (key, metaData, rec, rowIndex, colIndex, store) { var me = this; @@ -829,18 +750,6 @@ Ext.define('PVE.qemu.HardwareView', { menu: new Ext.menu.Menu({ cls: 'pve-add-hw-menu', items: [ - { - text: gettext('Hard Disk'), - iconCls: 'fa fa-fw fa-hdd-o black', - disabled: !caps.vms['VM.Config.Disk'], - handler: editorFactory('HDEdit'), - }, - { - text: gettext('Import Hard Disk'), - iconCls: 'fa fa-fw fa-cloud-download', - disabled: !caps.vms['VM.Config.Disk'], - handler: editorFactory('HDEdit', { importDisk: true }), - }, { text: gettext('CD/DVD Drive'), iconCls: 'pve-itype-icon-cdrom', @@ -854,14 +763,6 @@ Ext.define('PVE.qemu.HardwareView', { disabled: !caps.vms['VM.Config.Network'], handler: editorFactory('NetworkEdit'), }, - efidisk_menuitem, - { - text: gettext('TPM State'), - itemId: 'addTpmState', - iconCls: 'fa fa-fw fa-hdd-o black', - disabled: !caps.vms['VM.Config.Disk'], - handler: editorFactory('TPMDiskEdit'), - }, { text: gettext('USB Device'), itemId: 'addUsb', @@ -922,17 +823,229 @@ Ext.define('PVE.qemu.HardwareView', { ], }), }, - remove_btn, edit_btn, - diskaction_btn, + remove_btn, revert_btn, ], rows: rows, sorterFn: sorterFn, listeners: { itemdblclick: run_editor, - selectionchange: set_button_status, + selectionchange: onSelectionChange, + }, + }, + // spacer with bottom border + { + xtype: 'panel', + border: true, + height: 1, + margin: '20 0 0 0', + }, + { + xtype: 'pveHardwareDiskGrid', + rstore: me.rstore, + store: { + sorter: sorterFn, + }, + renderKey: function () { + let hardwareGrid = me.lookup('hardwareGrid'); + return hardwareGrid.renderKey(...arguments); }, + storeFilter: diskGridFilter, + listeners: { + itemdblclick: run_editor, + selectionchange: onSelectionChange, + }, + tbar: [ + { + text: gettext('Add'), + menu: { + cls: 'pve-add-hw-menu', + items: [ + { + text: gettext('Hard Disk'), + iconCls: 'fa fa-fw fa-hdd-o black', + disabled: !caps.vms['VM.Config.Disk'], + handler: editorFactory('HDEdit'), + }, + { + text: gettext('Import Hard Disk'), + iconCls: 'fa fa-fw fa-cloud-download', + disabled: !caps.vms['VM.Config.Disk'], + handler: editorFactory('HDEdit', { importDisk: true }), + }, + efidisk_menuitem, + { + text: gettext('TPM State'), + itemId: 'addTpmState', + iconCls: 'fa fa-fw fa-hdd-o black', + disabled: !caps.vms['VM.Config.Disk'], + handler: editorFactory('TPMDiskEdit'), + }, + ], + }, + }, + { + xtype: 'proxmoxButton', + text: gettext('Edit'), + disabled: true, + enableFn: (rec) => + diskCap && !rec.data.deleted && !!rows[rec.data.key].editor, + handler: run_editor, + }, + { + xtype: 'pveConfigRemoveButton', + baseurl, + renderKey: function () { + return me.lookup('hardwareGrid').renderKey(...arguments); + }, + rows, + reloadCallback: () => me.reload(), + enableFn: function (rec) { + let button = this; + const isRunning = me.pveSelNode.data.running; + const isUnusedDisk = rec.data.key.match(/^unused\d+/); + const isDisk = rows[rec.data.key].isOnStorageBus; + button.setText(isUnusedDisk ? button.defaultText : button.altText); + button.RESTMethod = + isUnusedDisk || (isDisk && isRunning) ? 'POST' : 'PUT'; + return ( + !rec.data.deleted && !rows[rec.data.key].never_delete && diskCap + ); + }, + }, + { + xtype: 'pvePendingRevertButton', + apiurl: '/api2/extjs/' + baseurl, + parentXType: 'pvePendingGrid', + enableFn: (rec) => !!rec.data.deleted || !!rec.data.pending, + reloadCallback: () => me.reload(), + }, + '-', + { + xtype: 'proxmoxButton', + text: gettext('Move Storage'), + tooltip: gettext('Move disk to another storage'), + iconCls: 'fa fa-database', + enableFn: (rec) => + diskCap && + !rec.data.deleted && + !rec.data.pending && + (rec.data.key !== 'tpmstate0' || !me.pveSelNode.data.running), + handler: () => { + let rec = sm.getSelection()[0]; + if (!rec) { + return; + } + Ext.create('PVE.window.HDMove', { + autoShow: true, + disk: rec.data.key, + nodename: nodename, + vmid: vmid, + type: 'qemu', + listeners: { + destroy: () => me.reload(), + }, + }); + }, + }, + { + xtype: 'proxmoxButton', + text: gettext('Reassign Owner'), + tooltip: gettext('Reassign disk to another VM'), + iconCls: 'fa fa-desktop', + enableFn: function (rec) { + if (!diskCap || !!rec.data.deleted || !!rec.data.pending) { + return false; + } + if (rec.data.key === 'tpmstate0' && me.pveSelNode.data.running) { + return false; + } + if (rec.data.key === 'efidisk0') { + return false; + } + return true; + }, + handler: () => { + let rec = sm.getSelection()[0]; + if (!rec) { + return; + } + + Ext.create('PVE.window.GuestDiskReassign', { + autoShow: true, + disk: rec.data.key, + nodename: nodename, + vmid: vmid, + type: 'qemu', + listeners: { + destroy: () => me.reload(), + }, + }); + }, + }, + { + xtype: 'proxmoxButton', + text: gettext('Resize'), + iconCls: 'fa fa-plus', + enableFn: function (rec) { + if (!diskCap || !!rec.data.deleted || !!rec.data.pending) { + return false; + } + return ( + !rec.data.key.match(/^unused\d+/) && + !!rows[rec.data.key].isOnStorageBus + ); + }, + handler: () => { + let rec = sm.getSelection()[0]; + if (!rec) { + return; + } + Ext.create('PVE.window.HDResize', { + autoShow: true, + disk: rec.data.key, + nodename: nodename, + vmid: vmid, + listeners: { + destroy: () => me.reload(), + }, + }); + }, + }, + { + text: gettext('Enroll Updated Certificates'), + iconCls: 'fa fa-refresh', + disabled: true, + enableFn: function (rec) { + if (!diskCap || !!rec.data.deleted || !!rec.data.pending) { + return false; + } + if (rec.data.key !== 'efidisk0') { + return false; + } + let drive = PVE.Parser.parsePropertyString(rec.data.value, 'file'); + return ( + PVE.Parser.parseBoolean(drive['pre-enrolled-keys'], false) && + drive['ms-cert'] !== '2023k' + ); + }, + handler: () => { + Ext.Msg.show({ + title: gettext('Confirm'), + icon: Ext.Msg.QUESTION, + message: efiEnrollMsg, + buttons: Ext.Msg.YESNO, + callback: function (btn) { + if (btn !== 'yes') { + return; + } + runEfiEnroll(); + }, + }); + }, + }, + ], }, ], }); -- 2.47.3