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 B2AB51FF133 for ; Mon, 11 May 2026 15:03:03 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 5136416547; Mon, 11 May 2026 15:03:01 +0200 (CEST) Message-ID: Date: Mon, 11 May 2026 15:02:50 +0200 MIME-Version: 1.0 User-Agent: Mozilla Thunderbird Subject: Re: [PATCH manager] ui: qemu hardware view: split out disks and nics into grids To: Dominik Csapak , pve-devel@lists.proxmox.com References: <20260227091158.715596-1-d.csapak@proxmox.com> <3e2705c9-09a4-42b7-80a3-aee28e13ed12@proxmox.com> <84642f51-675c-453a-a063-2983ccf65b3e@proxmox.com> Content-Language: en-US From: David Riley In-Reply-To: <84642f51-675c-453a-a063-2983ccf65b3e@proxmox.com> Content-Type: text/plain; charset=UTF-8; format=flowed Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1778504459228 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.207 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 POISEN_SPAM_PILL 0.1 Meta: its spam POISEN_SPAM_PILL_1 0.1 random spam to be learned in bayes POISEN_SPAM_PILL_3 0.1 random spam to be learned in bayes SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record URIBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to URIBL was blocked. See http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more information. [parsed.id,parsed.media] Message-ID-Hash: RG5NBYLHTJMEXD63M7R3H4SFGIZ2ZTZY X-Message-ID-Hash: RG5NBYLHTJMEXD63M7R3H4SFGIZ2ZTZY X-MailFrom: d.riley@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: On 5/8/26 3:40 PM, Dominik Csapak wrote: > > > On 5/6/26 10:46 AM, David Riley wrote: >> Thanks for sending this patch. >> I checked it out and it works as expected and visually looks good. >> >> The only small thing I noticed in the UI is the horizontal scrollbar that >> is overlapping with the contents of the last row. Making it cumbersome >> to read while scrolling horizontal. This is only the case if there is more >> than 1 element present in the table. Not sure why the padding differs >> if there is more than 1 element, because it looks fine if there is only >> one element. > > could only observe that in firefox, in chromium it works as intended. > Not sure if we can fix this easily since it seems like an issue > with how extjs calculates the column widths and how firefox > renders the scrollbar. if the column widths are small enough > the scrollbar vanished and its not an issue. > > i think we'd have the same issue in every grid that has a horizontal > scrollbar? > You are right this seems to be a general issue, but thanks for taking the time to look into it. >> >> Some smaller nitpicks inline, but overall it looks good to me. >> >> On 5/5/26 10:39 AM, Dominik Csapak wrote: >>> ping, >>> >>> would be nice to have something like this included IMHO >>> was just waiting on feedback before sending for containers too >>> >>> On 2/27/26 10:11 AM, Dominik Csapak wrote: >>>> 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+$/)) { >> >> nit: might make sense to have a single source of truth for these regex patterns. >> As these are used more than once. >> Same for: >> * /^(ide|sata|scsi|virtio|unused)\d+$/ >> * /^net\d+$/ >> * /media=cdrom/ >> >>>> +                    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) { >> nit: It might be more readable to use: metaData, rowIndex, colIndex >> took me a second to figure it out. >>>> +                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); >>>>       }, >>>>   }); >>> >>> >>> >>> >>> >