From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id 55FB41FF14F for ; Fri, 08 May 2026 15:41:22 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 23C4C19FD9; Fri, 8 May 2026 15:41:22 +0200 (CEST) From: Dominik Csapak To: pve-devel@lists.proxmox.com Subject: [PATCH manager v2 3/3] ui: qemu hardware view: split out disks and nics into grids Date: Fri, 8 May 2026 15:38:06 +0200 Message-ID: <20260508134110.4001168-4-d.csapak@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260508134110.4001168-1-d.csapak@proxmox.com> References: <20260508134110.4001168-1-d.csapak@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL 0.050 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 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: LLH3NGG5X6IRAB7RA45CIKQUQR2BSVCC X-Message-ID-Hash: LLH3NGG5X6IRAB7RA45CIKQUQR2BSVCC 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 property 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. Signed-off-by: Dominik Csapak --- changes in v2: * refactor the disk regex * also consider newly pending disks and nics * correct use of the boolean values (have to parseBoolean first) www/manager6/button/Revert.js | 2 +- www/manager6/qemu/HardwareView.js | 551 ++++++++++++++++++++++++++++-- 2 files changed, 526 insertions(+), 27 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 471e3a8f..86da04c7 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; @@ -403,9 +425,475 @@ 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, + }); + + let keyForDiskGrid = (key) => key.match(/^(ide|sata|scsi|virtio|unused)\d+$/); + + hardwareGrid.getStore().addFilter({ + filterFn: function (rec) { + let val = rec.get('value') || rec.get('pending'); + let key = rec.get('key'); + if (keyForDiskGrid(key)) { + return PVE.Utils.diskIsCdrom(val) || isCloudInitKey(val); + } else if (PVE.Utils.keyIsNic(key)) { + return false; + } + return true; + }, + }); + + let pendingStore = Ext.create('Ext.data.Store'); + let pendingRenderer = function (originalRenderer) { + originalRenderer ??= Ext.htmlEncode; + return function (val, metaData, rec, rowIdx, colIdx, store, view) { + let txt = originalRenderer(val, metaData, rec, rowIdx, colIdx, store, view); + let pendingTxt; + + let pending = pendingStore.getById(rec.get('id')); + if (pending) { + let dataIndex = view.up().getColumns()[colIdx].dataIndex; + let value = pending.get(dataIndex); + pendingTxt = originalRenderer( + value, + metaData, + rec, + rowIdx, + colIdx, + store, + view, + ); + } + + if (pendingTxt && txt && pendingTxt !== txt) { + txt += '
'; + txt += `${pendingTxt}`; + } + if (pending && pendingTxt && (!txt || !rec.data.raw)) { + txt = `${pendingTxt}`; + } + if (pending && !pendingTxt && txt) { + txt = `
${txt}
`; + } + if (rec.data.delete && txt) { + txt = `
${txt}
`; + } + + return txt; + }; + }; + + // boolean values parsed from property string have to be parsed to real boolean values + let booleanRenderer = function (value) { + if (Ext.isString(value)) { + value = PVE.Parser.parseBoolean(value); + } + return Proxmox.Utils.format_boolean(value); + }; + + 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, + minWidth: 200, + renderer: pendingRenderer(), + }, + { + header: gettext('Size'), + dataIndex: 'size', + width: 100, + 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(booleanRenderer), + }, + { + header: gettext('IO thread'), + dataIndex: 'iothread', + width: 100, + renderer: pendingRenderer(booleanRenderer), + }, + { + header: gettext('SSD emulation'), + dataIndex: 'ssd', + width: 120, + renderer: pendingRenderer(booleanRenderer), + }, + { + header: gettext('Backup'), + dataIndex: 'backup', + width: 80, + renderer: pendingRenderer((v) => booleanRenderer(v ?? true)), + }, + { + header: gettext('Read-Only'), + dataIndex: 'ro', + width: 100, + renderer: pendingRenderer(booleanRenderer), + }, + { + header: gettext('Replicate'), + dataIndex: 'replicate', + width: 100, + renderer: pendingRenderer(booleanRenderer), + }, + { + 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(booleanRenderer), + }, + { + 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(booleanRenderer), + }, + { + 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', + flex: 1, + minWidth: 80, + 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(booleanRenderer), + }, + { + 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(booleanRenderer), + }, + { + 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'); + let deleted = rec.get('delete'); + if (keyForDiskGrid(key)) { + let isDisk = (value) => !value.match(/media=cdrom/) && !isCloudInitKey(value); + + let parsed = PVE.Parser.parseQemuDrive(key, val); + if (parsed && val && isDisk(val)) { + parsed.id = key; + parsed.raw = val; + parsed.deleted = deleted; + diskData.push(parsed); + } + + if (pending && isDisk(pending)) { + if (!parsed) { + diskData.push({ + id: key, + raw: '', + }); + } + parsed = PVE.Parser.parseQemuDrive(key, pending); + parsed.id = key; + parsed.raw = pending; + pendingData.push(parsed); + } + } else if (PVE.Utils.keyIsNic(key)) { + let parsed = PVE.Parser.parseQemuNetwork(key, val); + if (parsed) { + parsed.id = key; + parsed.raw = val; + parsed.deleted = deleted; + nicData.push(parsed); + } + if (pending) { + if (!parsed) { + nicData.push({ + id: key, + raw: '', + }); + } + parsed = PVE.Parser.parseQemuNetwork(key, pending); + parsed.id = key; + parsed.raw = pending; + 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 expected by tools + 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 expected by tools + 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; @@ -449,6 +937,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, @@ -492,7 +984,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) { @@ -515,7 +1006,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) { @@ -538,7 +1028,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) { @@ -621,7 +1110,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) { @@ -630,7 +1119,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; } @@ -673,6 +1163,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', { @@ -680,8 +1172,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, @@ -703,12 +1194,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; @@ -751,9 +1242,8 @@ 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); @@ -821,10 +1311,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'), @@ -926,19 +1412,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