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 6772A1FF13F for ; Thu, 12 Mar 2026 09:41:15 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 4D8A09957; Thu, 12 Mar 2026 09:41:00 +0100 (CET) From: Arthur Bied-Charreton To: pve-devel@lists.proxmox.com Subject: [PATCH pve-manager 4/8] ui: Add basic custom CPU model editor Date: Thu, 12 Mar 2026 09:40:17 +0100 Message-ID: <20260312084021.124465-5-a.bied-charreton@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260312084021.124465-1-a.bied-charreton@proxmox.com> References: <20260312084021.124465-1-a.bied-charreton@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -0.103 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 KAM_LAZY_DOMAIN_SECURITY 1 Sending domain does not have any anti-forgery methods RDNS_NONE 0.793 Delivered to internal network by a host with no rDNS SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_NONE 0.001 SPF: sender does not publish an SPF Record Message-ID-Hash: IDNU4YVAXEYMDMM7AAT76ARKH32RVZUB X-Message-ID-Hash: IDNU4YVAXEYMDMM7AAT76ARKH32RVZUB X-MailFrom: abied-charreton@jett.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 CC: Stefan Reiter X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: Add components for creating/updating custom CPU models, allowing to set the values that would normally have to be set manually in `/etc/pve/virtual-guest/cpu-models.conf` [0]. Original patch: https://lore.proxmox.com/pve-devel/20211028114150.3245864-9-s.reiter@proxmox.com/ [0] https://pve.proxmox.com/wiki/Manual:_cpu-models.conf Originally-by: Stefan Reiter Signed-off-by: Arthur Bied-Charreton --- www/manager6/Makefile | 3 + www/manager6/dc/CPUTypeEdit.js | 90 +++++++++++++++ www/manager6/dc/CPUTypeView.js | 139 +++++++++++++++++++++++ www/manager6/dc/Config.js | 6 + www/manager6/form/PhysBitsSelector.js | 153 ++++++++++++++++++++++++++ 5 files changed, 391 insertions(+) create mode 100644 www/manager6/dc/CPUTypeEdit.js create mode 100644 www/manager6/dc/CPUTypeView.js create mode 100644 www/manager6/form/PhysBitsSelector.js diff --git a/www/manager6/Makefile b/www/manager6/Makefile index 4558d53e..fbde2327 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -58,6 +58,7 @@ JSSRC= \ form/PCISelector.js \ form/PCIMapSelector.js \ form/PermPathSelector.js \ + form/PhysBitsSelector.js \ form/PoolSelector.js \ form/PreallocationSelector.js \ form/PrivilegesSelector.js \ @@ -170,6 +171,8 @@ JSSRC= \ dc/CmdMenu.js \ dc/Config.js \ dc/CorosyncLinkEdit.js \ + dc/CPUTypeEdit.js \ + dc/CPUTypeView.js \ dc/GroupEdit.js \ dc/GroupView.js \ dc/Guests.js \ diff --git a/www/manager6/dc/CPUTypeEdit.js b/www/manager6/dc/CPUTypeEdit.js new file mode 100644 index 00000000..8cf508b4 --- /dev/null +++ b/www/manager6/dc/CPUTypeEdit.js @@ -0,0 +1,90 @@ +Ext.define('PVE.dc.CPUTypeEdit', { + extend: 'Proxmox.window.Edit', + alias: ['widget.pveCpuTypeEdit'], + mixins: ['Proxmox.Mixin.CBind'], + + subject: gettext('CPU Type'), + + // Avoid default-focusing the reported model dropdown while still + // focusing the name textfield if it is editable + defaultFocus: 'textfield', + + height: 600, + width: 800, + + cbindData: { + cputype: '', + isCreate: (cfg) => !cfg.cputype, + }, + + cbind: { + autoLoad: (get) => !get('isCreate'), + url: (get) => `/api2/extjs/nodes/localhost/capabilities/qemu/cpu/model/${get('cputype')}`, + method: (get) => (get('isCreate') ? 'POST' : 'PUT'), + isCreate: (get) => get('isCreate'), + }, + + getValues: function () { + let me = this; + let values = me.callParent(); + + PVE.Utils.delete_if_default(values, 'reported-model', '', me.isCreate); + PVE.Utils.delete_if_default(values, 'hv-vendor-id', '', me.isCreate); + PVE.Utils.delete_if_default(values, 'phys-bits', '', me.isCreate); + PVE.Utils.delete_if_default(values, 'flags', '', me.isCreate); + + if (me.isCreate && !values.cputype.match(/^custom-/)) { + values.cputype = 'custom-' + values.cputype; + } + + return values; + }, + + items: [ + { + xtype: 'inputpanel', + column1: [ + { + xtype: 'pmxDisplayEditField', + fieldLabel: gettext('Name'), + cbind: { + editable: '{isCreate}', + value: '{cputype}', + }, + name: 'cputype', + renderer: (val) => val.replace(/^custom-/, ''), + allowBlank: false, + }, + { + xtype: 'CPUModelSelector', + fieldLabel: gettext('Reported Model'), + allowCustom: false, + name: 'reported-model', + }, + { + xtype: 'textfield', + fieldLabel: gettext('Hyper-V Vendor'), + name: 'hv-vendor-id', + allowBlank: true, + emptyText: gettext('None'), + maxLength: 12, + }, + ], + column2: [ + { + xtype: 'checkbox', + fieldLabel: gettext('Hidden'), + name: 'hidden', + inputValue: 1, + uncheckedValue: 0, + }, + { + xtype: 'PhysBitsSelector', + fieldLabel: gettext('Phys-Bits'), + name: 'phys-bits', + }, + ], + + }, + ], +}); diff --git a/www/manager6/dc/CPUTypeView.js b/www/manager6/dc/CPUTypeView.js new file mode 100644 index 00000000..c79ce690 --- /dev/null +++ b/www/manager6/dc/CPUTypeView.js @@ -0,0 +1,139 @@ +Ext.define('pve-custom-cpu-type', { + extend: 'Ext.data.Model', + fields: [ + 'cputype', + 'reported-model', + 'hv-vendor-id', + 'flags', + 'phys-bits', + { name: 'hidden', type: 'boolean' }, + ], +}); + +Ext.define('PVE.dc.CPUTypeView', { + extend: 'Ext.grid.GridPanel', + alias: ['widget.pveCPUTypeView'], + + onlineHelp: 'qm_cpu', + + store: { + model: 'pve-custom-cpu-type', + proxy: { + type: 'proxmox', + url: '/api2/json/nodes/localhost/capabilities/qemu/cpu/model', + }, + autoLoad: true, + sorters: ['cputype'], + }, + + controller: { + xclass: 'Ext.app.ViewController', + + getSelection: function () { + let me = this; + let grid = me.getView(); + let selection = grid.getSelection(); + if (selection.length === 1) { + return selection[0].data; + } + return null; + }, + + showEditor: function (cputype) { + let me = this; + let param = cputype ? { cputype } : {}; + let win = Ext.create('PVE.dc.CPUTypeEdit', param); + win.on('destroy', () => me.reload()); + win.show(); + }, + + onAdd: function () { + let me = this; + me.showEditor(); + }, + + onEdit: function () { + let me = this; + let selection = me.getSelection(); + me.showEditor(selection.cputype); + }, + + reload: function () { + let me = this; + me.getView().getStore().reload(); + }, + }, + + columns: [ + { + header: gettext('Name'), + flex: 1, + dataIndex: 'cputype', + renderer: (val) => val.replace(/^custom-/, ''), + }, + { + header: gettext('Reported Model'), + flex: 1, + dataIndex: 'reported-model', + }, + { + header: gettext('Phys-Bits'), + flex: 1, + dataIndex: 'phys-bits', + }, + { + header: gettext('Hidden'), + flex: 1, + dataIndex: 'hidden', + }, + { + header: gettext('HyperV-Vendor'), + flex: 1, + dataIndex: 'hv-vendor-id', + }, + { + header: gettext('Flags'), + flex: 2, + dataIndex: 'flags', + }, + ], + + tbar: [ + { + text: gettext('Add'), + handler: 'onAdd', + }, + '-', + { + xtype: 'proxmoxStdRemoveButton', + baseurl: '/api2/extjs/nodes/localhost/capabilities/qemu/cpu/model/', + getRecordName: (rec) => rec.data.cputype, + getUrl: function (rec) { + let me = this; + return me.baseurl + rec.data.cputype; + }, + callback: 'reload', + }, + { + text: gettext('Edit'), + handler: 'onEdit', + }, + ], + + selModel: { + xtype: 'rowmodel', + }, + + listeners: { + itemdblclick: function (_, rec) { + let me = this; + me.getController().showEditor(rec.data.cputype); + }, + }, + + initComponent: function () { + let me = this; + me.callParent(); + Proxmox.Utils.monStoreErrors(me, me.store); + }, +}); diff --git a/www/manager6/dc/Config.js b/www/manager6/dc/Config.js index b5e27a21..629e4fc8 100644 --- a/www/manager6/dc/Config.js +++ b/www/manager6/dc/Config.js @@ -146,6 +146,12 @@ Ext.define('PVE.dc.Config', { title: gettext('Replication'), itemId: 'replication', }, + { + xtype: 'pveCPUTypeView', + iconCls: 'fa fa-microchip', + title: gettext('Custom CPU models'), + itemId: 'cputypes', + }, { xtype: 'pveACLView', title: gettext('Permissions'), diff --git a/www/manager6/form/PhysBitsSelector.js b/www/manager6/form/PhysBitsSelector.js new file mode 100644 index 00000000..78f932a6 --- /dev/null +++ b/www/manager6/form/PhysBitsSelector.js @@ -0,0 +1,153 @@ +Ext.define('PVE.form.PhysBitsSelector', { + extend: 'Ext.form.FieldContainer', + alias: 'widget.PhysBitsSelector', + mixins: ['Ext.form.field.Field'], + + layout: 'vbox', + originalValue: '', + + controller: { + xclass: 'Ext.app.ViewController', + + onRadioChange: function (radio, value) { + let me = this; + if (value === undefined) { + return; + } + + ['modeDefault', 'modeHost', 'modeCustom'].forEach(function (ref) { + let r = me.lookupReference(ref); + if (r !== radio) { + r.suspendEvents(); + r.setValue(false); + r.resumeEvents(); + } + }); + + me.updateNumberField(); + }, + + updateNumberField: function () { + let me = this; + let modeCustom = me.lookupReference('modeCustom'); + let customNum = me.lookupReference('customNum'); + + customNum.setDisabled(!modeCustom.getValue()); + me.getView().validate(); + }, + + listen: { + component: { + '*': { + change: function () { + let me = this; + me.getView().checkChange(); + }, + }, + }, + }, + }, + + getValue: function () { + let me = this; + let ctrl = me.getController(); + if (ctrl.lookupReference('modeDefault').getValue()) { + return ''; + } else if (ctrl.lookupReference('modeHost').getValue()) { + return 'host'; + } else if (ctrl.lookupReference('modeCustom').getValue()) { + return ctrl.lookupReference('customNum').getValue(); + } + return ''; // shouldn't happen + }, + + setValue: function (value) { + let me = this; + let ctrl = me.getController(); + let modeField; + + if (!value) { + modeField = ctrl.lookupReference('modeDefault'); + } else if (value === 'host') { + modeField = ctrl.lookupReference('modeHost'); + } else { + let customNum = ctrl.lookupReference('customNum'); + customNum.setValue(value); + modeField = ctrl.lookupReference('modeCustom'); + } + + modeField.setValue(true); + me.checkChange(); + + return value; + }, + + getErrors: function () { + let me = this; + let ctrl = me.getController(); + if (ctrl.lookupReference('modeCustom').getValue()) { + return ctrl.lookupReference('customNum').getErrors(); + } + return []; + }, + + isValid: function () { + let me = this; + let ctrl = me.getController(); + if (ctrl.lookupReference('modeCustom').getValue()) { + return ctrl.lookupReference('customNum').isValid(); + } + return true; + }, + + items: [ + { + xtype: 'radiofield', + boxLabel: gettext('Default'), + inputValue: 'default', + checked: true, + reference: 'modeDefault', + listeners: { + change: 'onRadioChange', + }, + isFormField: false, + }, + { + xtype: 'radiofield', + boxLabel: gettext('Host'), + inputValue: 'host', + reference: 'modeHost', + listeners: { + change: 'onRadioChange', + }, + isFormField: false, + }, + { + xtype: 'fieldcontainer', + layout: 'hbox', + items: [ + { + xtype: 'radiofield', + boxLabel: gettext('Custom'), + inputValue: 'custom', + listeners: { + change: 'onRadioChange', + }, + reference: 'modeCustom', + isFormField: false, + }, + { + xtype: 'numberfield', + width: 60, + margin: '0 0 0 10px', + minValue: 8, + maxValue: 64, + reference: 'customNum', + allowBlank: false, + isFormField: false, + disabled: true, + }, + ], + }, + ], +}); -- 2.47.3