From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id 9E39A1FF141 for ; Fri, 27 Feb 2026 10:11:08 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 46BDFAD3A; Fri, 27 Feb 2026 10:12:07 +0100 (CET) From: Dominik Csapak To: pve-devel@lists.proxmox.com Subject: [PATCH manager] ui: qemu hardware view: split out disks and nics into grids Date: Fri, 27 Feb 2026 10:05:05 +0100 Message-ID: <20260227091158.715596-1-d.csapak@proxmox.com> X-Mailer: git-send-email 2.47.3 MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -1.032 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment RCVD_IN_VALIDITY_CERTIFIED_BLOCKED 1.158 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. RCVD_IN_VALIDITY_RPBL_BLOCKED 0.306 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. RCVD_IN_VALIDITY_SAFE_BLOCKED 0.668 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. 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: BVZCXRD52IM6UWBAR4EUO6KSJO3NGXPV X-Message-ID-Hash: BVZCXRD52IM6UWBAR4EUO6KSJO3NGXPV 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: This is done to improve visibility of configuration of disks and nics. Currently only the raw property string is shown in the UI, which leads to bad UX when having many options configured and especially when having pending changes in these situations because it's hard to parse visually. The disks and nics are shown below the main options, each as their own grids with relevant columns. By default all columns that can be edited in the gui are shown (with the exception of the bandwidth limits, since they're not often used and blow up the number of columns). All other options from the config are there but hidden by default. As a fallback, in case there are new options not yet in the ui, a 'Raw Config' column was added, which contains the raw porperty string again (but it's hidden by default). Some notes on the implementation: The stores for the new grids are populated on each load of the main grid, and the we have one combined pending store that's just there to save the parsed pending values. To keep the diff smaller, some helpers are introduced to forward some methods to the main hardware grid. The selection change introduces some logic to keep the selection only in one grid, so there can be no conflict on the selected values. In the PendingRevert button, the set 'selModel' is used first to try and get the current record, otherwise the whole handler would have to be overwritten. Signed-off-by: Dominik Csapak --- If this is the way we want to continue with this, I'd send additional patches for unifying the resources panel for contains in the same manner. Namely splitting out the disks in a panel there and merging the network panel into the resources. Also what we could do with this now is to e.g. introduce action columns for the disks/nics to move the 'disk action' menu to, making it less confusing and cleaning up the toolbar. (though for that we would probably have to move the efidisk to the disks too, but lets do it one step at a time) www/manager6/button/Revert.js | 2 +- www/manager6/qemu/HardwareView.js | 521 ++++++++++++++++++++++++++++-- 2 files changed, 495 insertions(+), 28 deletions(-) diff --git a/www/manager6/button/Revert.js b/www/manager6/button/Revert.js index 14b13f5b..7d48f83f 100644 --- a/www/manager6/button/Revert.js +++ b/www/manager6/button/Revert.js @@ -18,7 +18,7 @@ Ext.define('PVE.button.PendingRevert', { } let view = this.pendingGrid; - let rec = view.getSelectionModel().getSelection()[0]; + let rec = (this.selModel ?? view.getSelectionModel()).getSelection()[0]; if (!rec) { return; } diff --git a/www/manager6/qemu/HardwareView.js b/www/manager6/qemu/HardwareView.js index cf5e2a0f..577b4a8e 100644 --- a/www/manager6/qemu/HardwareView.js +++ b/www/manager6/qemu/HardwareView.js @@ -1,9 +1,15 @@ Ext.define('PVE.qemu.HardwareView', { - extend: 'Proxmox.grid.PendingObjectGrid', + extend: 'Ext.panel.Panel', alias: ['widget.PVE.qemu.HardwareView'], onlineHelp: 'qm_virtual_machines_settings', + layout: { + type: 'vbox', + align: 'stretch', + }, + scrollable: true, + renderKey: function (key, metaData, rec, rowIndex, colIndex, store) { var me = this; var rows = me.rows; @@ -43,6 +49,22 @@ Ext.define('PVE.qemu.HardwareView', { } }, + referenceHolder: true, + + // helpers to redirect the methods to the hardwareGrid + + getObjectValue: function () { + let me = this; + let hardwareGrid = me.lookup('hardwareGrid'); + return hardwareGrid.getObjectValue(...arguments); + }, + + reload: function () { + let me = this; + let hardwareGrid = me.lookup('hardwareGrid'); + return hardwareGrid.reload(); + }, + initComponent: function () { var me = this; @@ -393,9 +415,444 @@ Ext.define('PVE.qemu.HardwareView', { let baseurl = `nodes/${nodename}/qemu/${vmid}/config`; - let sm = Ext.create('Ext.selection.RowModel', {}); + let hardwareGrid = Ext.create('Proxmox.grid.PendingObjectGrid', { + reference: 'hardwareGrid', + title: gettext('General'), + iconCls: 'fa fa-desktop', + pveSelNode: me.pveSelNode, + url: `/api2/json/nodes/${nodename}/qemu/${vmid}/pending`, + interval: 5000, + rows, + sorterFn, + border: false, + renderKey: me.renderKey, + }); + + hardwareGrid.getStore().addFilter({ + filterFn: function (rec) { + let val = rec.get('value') || rec.get('pending'); + let key = rec.get('key'); + if (key.match(/^(ide|sata|scsi|virtio|unused)\d+$/)) { + let isCdrom = val && val.match(/media=cdrom/); + let isCloudInit = isCloudInitKey(val); + if (!isCdrom && !isCloudInit) { + return false; + } + } else if (key.match(/^net\d+$/)) { + return false; + } + return true; + }, + }); + + let pendingStore = Ext.create('Ext.data.Store'); + let pendingRenderer = function (originalRenderer) { + originalRenderer ??= Ext.htmlEncode; + return function (val, mD, rec, rI, cI, store, view) { + let txt = originalRenderer(val, mD, rec, rI, cI, store, view); + let pending = pendingStore.getById(rec.get('id')); + if (pending) { + let dataIndex = view.up().getColumns()[cI].dataIndex; + let value = pending.get(dataIndex); + let pendingTxt = originalRenderer(value, mD, rec, rI, cI, store, view); + if (pendingTxt !== txt) { + if (txt) { + if (pendingTxt) { + txt += '
'; + txt += `${pendingTxt}`; + } else { + txt = `
${txt}
`; + } + } else { + txt = `${pendingTxt}`; + } + } + } else { + let hwRec = hardwareGrid.getStore().getById(rec.get('id')); + if (hwRec.data.delete && txt) { + txt = `
${txt}
`; + } + } + + return txt; + }; + }; + + let diskGrid = Ext.create('Ext.grid.Panel', { + reference: 'diskGrid', + title: gettext('Hard Disks'), + stateful: true, + stateId: 'pve-qemu-hardware-disk', + border: false, + expandable: false, + iconCls: 'fa fa-hdd-o', + minHeight: 100, + emptyText: gettext('No hard disks configured'), + store: {}, + columns: [ + { + header: gettext('Bus/Device'), + dataIndex: 'id', + width: 100, + renderer: pendingRenderer(), + }, + { + header: gettext('Volume/File'), + dataIndex: 'file', + flex: 1, + renderer: pendingRenderer(), + }, + { + header: gettext('Size'), + dataIndex: 'size', + width: 80, + renderer: pendingRenderer((value) => { + if (value === undefined || value === null) { + return ''; + } + + let size = value; + if (!size.toString().slice(-1).match(/[0-9]/)) { + // IEC unit is implicit in config, but parser expects it as 'i' suffix + size = `${value}i`; + } + + return Proxmox.Utils.autoscale_size_unit(size); + }), + }, + { + header: gettext('Cache'), + dataIndex: 'cache', + width: 140, + renderer: pendingRenderer( + (v) => v ?? `${Proxmox.Utils.defaultText} (${gettext('No cache')})`, + ), + }, + { + header: gettext('Discard'), + dataIndex: 'discard', + width: 80, + renderer: pendingRenderer(Proxmox.Utils.format_boolean), + }, + { + header: gettext('IO thread'), + dataIndex: 'iothread', + width: 100, + renderer: pendingRenderer(Proxmox.Utils.format_boolean), + }, + { + header: gettext('SSD emulation'), + dataIndex: 'ssd', + width: 120, + renderer: pendingRenderer(Proxmox.Utils.format_boolean), + }, + { + header: gettext('Backup'), + dataIndex: 'backup', + width: 80, + renderer: pendingRenderer((v) => Proxmox.Utils.format_boolean(v ?? true)), + }, + { + header: gettext('Read-Only'), + dataIndex: 'ro', + width: 100, + renderer: pendingRenderer(Proxmox.Utils.format_boolean), + }, + { + header: gettext('Replicate'), + dataIndex: 'replicate', + width: 100, + renderer: pendingRenderer(Proxmox.Utils.format_boolean), + }, + { + header: gettext('Async I/O'), + dataIndex: 'aio', + width: 100, + renderer: pendingRenderer(), + }, + { + header: gettext('Bandwidth Limits'), + hidden: true, + columns: [ + { + header: gettext('Read limit (MiB/s)'), + dataIndex: 'mbps_rd', + width: 110, + renderer: pendingRenderer(), + }, + { + header: gettext('Write limit (MiB/s)'), + dataIndex: 'mbps_wr', + width: 110, + renderer: pendingRenderer(), + }, + { + header: gettext('Read limit (ops/s)'), + dataIndex: 'iops_rd', + width: 110, + renderer: pendingRenderer(), + }, + { + header: gettext('Write limit (ops/s)'), + dataIndex: 'iops_wr', + width: 110, + renderer: pendingRenderer(), + }, + { + header: gettext('Read Burst (MiB/s)'), + dataIndex: 'mbps_rd_max', + width: 110, + renderer: pendingRenderer(), + }, + { + header: gettext('Write Burst (MiB/s)'), + dataIndex: 'mbps_wr_max', + width: 110, + renderer: pendingRenderer(), + }, + { + header: gettext('Read Burst (ops/s)'), + dataIndex: 'iops_rd_max', + width: 110, + renderer: pendingRenderer(), + }, + { + header: gettext('Write Burst (ops/s)'), + dataIndex: 'iops_wr_max', + width: 110, + renderer: pendingRenderer(), + }, + ], + }, + { + header: gettext('Detect Zeroes'), + dataIndex: 'detect_zeroes', + hidden: true, + renderer: pendingRenderer(Proxmox.Utils.format_boolean), + }, + { + header: gettext('Vendor'), + dataIndex: 'vendor', + hidden: true, + renderer: pendingRenderer(), + }, + { + header: gettext('Product'), + dataIndex: 'product', + hidden: true, + renderer: pendingRenderer(), + }, + { + header: gettext('Serial'), + dataIndex: 'serial', + hidden: true, + renderer: pendingRenderer(), + }, + { + header: gettext('Queues'), + dataIndex: 'queues', + hidden: true, + renderer: pendingRenderer(), + }, + { + header: gettext('Shared'), + dataIndex: 'shared', + hidden: true, + renderer: pendingRenderer(Proxmox.Utils.format_boolean), + }, + { + header: gettext('Write Error Action'), + dataIndex: 'werror', + hidden: true, + renderer: pendingRenderer(), + }, + { + header: 'WWN', + dataIndex: 'wwn', + hidden: true, + renderer: pendingRenderer(), + }, + { + header: gettext('Raw Config'), + dataIndex: 'raw', + hidden: true, + flex: 1, + renderer: pendingRenderer(), + }, + ], + }); + + let netGrid = Ext.create('Ext.grid.Panel', { + reference: 'netGrid', + title: gettext('Network Interfaces'), + border: false, + stateful: true, + stateId: 'pve-qemu-hardware-net', + expandable: false, + iconCls: 'fa fa-exchange', + minHeight: 100, + emptyText: gettext('No network interfaces configured'), + store: {}, + columns: [ + { + header: gettext('ID'), + dataIndex: 'id', + width: 100, + renderer: pendingRenderer(), + }, + { + header: gettext('Model'), + dataIndex: 'model', + width: 100, + renderer: pendingRenderer(), + }, + { + header: gettext('MAC address'), + dataIndex: 'macaddr', + width: 200, + renderer: pendingRenderer(), + }, + { + header: gettext('Bridge'), + dataIndex: 'bridge', + width: 100, + renderer: pendingRenderer(), + }, + { + header: gettext('Firewall enabled'), + dataIndex: 'firewall', + width: 150, + renderer: pendingRenderer(Proxmox.Utils.format_boolean), + }, + { + header: gettext('VLAN tag'), + dataIndex: 'tag', + width: 80, + renderer: pendingRenderer(), + }, + { + header: gettext('MTU'), + dataIndex: 'mtu', + width: 100, + renderer: pendingRenderer(), + }, + { + header: gettext('Multiqueue'), + dataIndex: 'queues', + width: 100, + renderer: pendingRenderer(), + }, + { + header: gettext('Rate limit (MB/s)'), + dataIndex: 'rate', + width: 200, + renderer: pendingRenderer(), + }, + { + header: gettext('Disconnected'), + dataIndex: 'link_down', + width: 100, + renderer: pendingRenderer(Proxmox.Utils.format_boolean), + }, + { + header: gettext('Trunks'), + dataIndex: 'trunks', + width: 100, + }, + ], + }); + + hardwareGrid.mon(hardwareGrid.getStore(), 'refresh', function () { + let diskData = []; + let nicData = []; + let pendingData = []; + let data = hardwareGrid.getStore().getData(); + (data.getSource() ?? data).each(function (rec) { + let key = rec.get('key'); + let val = rec.get('value'); + let pending = rec.get('pending'); + if (key.match(/^(ide|sata|scsi|virtio|unused)\d+$/)) { + let parsed = PVE.Parser.parseQemuDrive(key, val); + if (parsed) { + let isCdrom = parsed.media === 'cdrom'; + let isCloudInit = isCloudInitKey(val); + if (!isCdrom && !isCloudInit && val) { + parsed.id = key; + parsed.raw = val; + diskData.push(parsed); + if (pending) { + parsed = PVE.Parser.parseQemuDrive(key, pending); + parsed.id = key; + parsed.raw = pending; + pendingData.push(parsed); + } + } + } + } else if (key.match(/^net\d+$/)) { + let parsed = PVE.Parser.parseQemuNetwork(key, val); + if (parsed) { + parsed.id = key; + nicData.push(parsed); + if (pending) { + parsed = PVE.Parser.parseQemuNetwork(key, pending); + parsed.id = key; + pendingData.push(parsed); + } + } + } + }); + + pendingStore.loadData(pendingData); + netGrid.getStore().loadData(nicData); + diskGrid.getStore().loadData(diskData); + }); + + let sm = Ext.create('Ext.selection.Model', { + getSelection: () => { + let hardwareSelection = hardwareGrid.getSelection(); + if (hardwareSelection.length > 0) { + return hardwareSelection; + } + + let diskSelection = diskGrid.getSelection(); + if (diskSelection.length > 0) { + let key = diskSelection[0].get('id'); + // Return original record with 'pending'/'value' fields + return [hardwareGrid.rstore.getById(key)]; + } + + let nicSelection = netGrid.getSelection(); + if (nicSelection.length > 0) { + let key = nicSelection[0].get('id'); + // Return original record with 'pending'/'value' fields + return [hardwareGrid.rstore.getById(key)]; + } + return []; + }, + }); + + let onSelectionChange = function (sm, selected) { + let me = this; + if (selected.length) { + if (me !== hardwareGrid) { + hardwareGrid.getSelectionModel().deselectAll(); + } + if (me !== diskGrid) { + diskGrid.getSelectionModel().deselectAll(); + } + if (me !== netGrid) { + netGrid.getSelectionModel().deselectAll(); + } + } + set_button_status(); + }; + + hardwareGrid.on('selectionchange', onSelectionChange); + diskGrid.on('selectionchange', onSelectionChange); + netGrid.on('selectionchange', onSelectionChange); let run_editor = function () { + let me = hardwareGrid; let rec = sm.getSelection()[0]; if (!rec || !rows[rec.data.key]?.editor) { return; @@ -435,6 +892,10 @@ Ext.define('PVE.qemu.HardwareView', { } }; + hardwareGrid.on('itemdblclick', run_editor); + diskGrid.on('itemdblclick', run_editor); + netGrid.on('itemdblclick', run_editor); + let edit_btn = new Proxmox.button.Button({ text: gettext('Edit'), selModel: sm, @@ -446,7 +907,6 @@ Ext.define('PVE.qemu.HardwareView', { 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) { @@ -469,7 +929,6 @@ Ext.define('PVE.qemu.HardwareView', { 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) { @@ -492,7 +951,6 @@ Ext.define('PVE.qemu.HardwareView', { let resize_menuitem = new Ext.menu.Item({ text: gettext('Resize'), iconCls: 'fa fa-plus', - selModel: sm, handler: () => { let rec = sm.getSelection()[0]; if (!rec) { @@ -531,7 +989,7 @@ Ext.define('PVE.qemu.HardwareView', { if (this.text === this.altText) { warn = gettext('Are you sure you want to detach entry {0}'); } - let rendered = me.renderKey(rec.data.key, {}, rec); + let rendered = hardwareGrid.renderKey(rec.data.key, {}, rec); let msg = Ext.String.format(warn, `'${rendered}'`); if (rows[rec.data.key].del_extra_msg) { @@ -540,7 +998,8 @@ Ext.define('PVE.qemu.HardwareView', { return msg; }, handler: function (btn, e, rec) { - let params = { delete: rec.data.key }; + let record = sm.getSelection()[0]; + let params = { delete: record.data.key }; if (btn.RESTMethod === 'POST') { params.background_delay = 5; } @@ -583,6 +1042,8 @@ Ext.define('PVE.qemu.HardwareView', { let revert_btn = new PVE.button.PendingRevert({ apiurl: '/api2/extjs/' + baseurl, + selModel: sm, + pendingGrid: hardwareGrid, }); let efidisk_menuitem = Ext.create('Ext.menu.Item', { @@ -590,8 +1051,7 @@ Ext.define('PVE.qemu.HardwareView', { iconCls: 'fa fa-fw fa-hdd-o black', disabled: !caps.vms['VM.Config.Disk'], handler: function () { - let { data: bios } = me.rstore.getData().map.bios || {}; - + let { data: bios } = hardwareGrid.rstore.getData().map.bios || {}; Ext.create('PVE.qemu.EFIDiskEdit', { autoShow: true, url: '/api2/extjs/' + baseurl, @@ -613,12 +1073,12 @@ Ext.define('PVE.qemu.HardwareView', { }; let set_button_status = function () { - let selection_model = me.getSelectionModel(); + let selection_model = sm; let rec = selection_model.getSelection()[0]; counts = {}; // en/disable hardwarebuttons let hasCloudInit = false; - me.rstore.getData().items.forEach(function ({ id, data }) { + hardwareGrid.rstore.getData().items.forEach(function ({ id, data }) { if (!hasCloudInit && (isCloudInitKey(data.value) || isCloudInitKey(data.pending))) { hasCloudInit = true; return; @@ -661,14 +1121,12 @@ Ext.define('PVE.qemu.HardwareView', { } const { key, value } = rec.data; const row = rows[key]; - const deleted = !!rec.data.delete; - const pending = deleted || me.hasPendingChanges(key); + const pending = deleted || hardwareGrid.hasPendingChanges(key); const isRunning = me.pveSelNode.data.running; const isCloudInit = isCloudInitKey(value); const isCDRom = value && !!value.toString().match(/media=cdrom/); - const isUnusedDisk = key.match(/^unused\d+/); const isUsedDisk = !isUnusedDisk && row.isOnStorageBus && !isCDRom; const isDisk = isUnusedDisk || isUsedDisk; @@ -720,10 +1178,6 @@ Ext.define('PVE.qemu.HardwareView', { }; Ext.apply(me, { - url: `/api2/json/nodes/${nodename}/qemu/${vmid}/pending`, - interval: 5000, - selModel: sm, - run_editor: run_editor, tbar: [ { text: gettext('Add'), @@ -825,19 +1279,32 @@ Ext.define('PVE.qemu.HardwareView', { diskaction_btn, revert_btn, ], - rows: rows, - sorterFn: sorterFn, - listeners: { - itemdblclick: run_editor, - selectionchange: set_button_status, - }, + items: [ + hardwareGrid, + // spacer with bottom border + { + xtype: 'panel', + border: true, + height: 1, + margin: '20 0 0 0', + }, + diskGrid, + // spacer with bottom border + { + xtype: 'panel', + border: true, + height: 1, + margin: '20 0 0 0', + }, + netGrid, + ], }); me.callParent(); - me.on('activate', me.rstore.startUpdate, me.rstore); - me.on('destroy', me.rstore.stopUpdate, me.rstore); + me.on('activate', () => hardwareGrid.rstore.startUpdate()); + me.on('destroy', () => hardwareGrid.rstore.stopUpdate()); - me.mon(me.getStore(), 'datachanged', set_button_status, me); + hardwareGrid.mon(hardwareGrid.getStore(), 'datachanged', set_button_status, me); }, }); -- 2.47.3