Ext.define('PVE.qemu.HardwareView', { extend: 'Proxmox.grid.PendingObjectGrid', alias: ['widget.PVE.qemu.HardwareView'], onlineHelp: 'qm_virtual_machines_settings', renderKey: function(key, metaData, rec, rowIndex, colIndex, store) { var me = this; var rows = me.rows; var rowdef = rows[key] || {}; var iconCls = rowdef.iconCls; var icon = ''; var txt = rowdef.header || key; metaData.tdAttr = "valign=middle"; if (rowdef.isOnStorageBus) { var value = me.getObjectValue(key, '', false); if (value === '') { value = me.getObjectValue(key, '', true); } if (value.match(/vm-.*-cloudinit/)) { iconCls = 'cloud'; txt = rowdef.cloudheader; } else if (value.match(/media=cdrom/)) { metaData.tdCls = 'pve-itype-icon-cdrom'; return rowdef.cdheader; } } if (rowdef.tdCls) { metaData.tdCls = rowdef.tdCls; } else if (iconCls) { icon = ""; metaData.tdCls += " pve-itype-fa"; } // only return icons in grid but not remove dialog if (rowIndex !== undefined) { return icon + txt; } else { return txt; } }, initComponent: function() { var me = this; const { node: nodename, vmid } = me.pveSelNode.data; if (!nodename) { throw "no node name specified"; } else if (!vmid) { throw "no VM ID specified"; } const caps = Ext.state.Manager.get('GuiCap'); const diskCap = caps.vms['VM.Config.Disk']; const cdromCap = caps.vms['VM.Config.CDROM']; let isCloudInitKey = v => v && v.toString().match(/vm-.*-cloudinit/); const nodeInfo = PVE.data.ResourceStore.getNodes().find(node => node.node === nodename); let processorEditor = { xtype: 'pveQemuProcessorEdit', cgroupMode: nodeInfo['cgroup-mode'], }; let rows = { memory: { header: gettext('Memory'), editor: caps.vms['VM.Config.Memory'] ? 'PVE.qemu.MemoryEdit' : undefined, never_delete: true, defaultValue: '512', tdCls: 'pve-itype-icon-memory', group: 2, multiKey: ['memory', 'balloon', 'shares'], renderer: function(value, metaData, record, ri, ci, store, pending) { var res = ''; var max = me.getObjectValue('memory', 512, pending); var balloon = me.getObjectValue('balloon', undefined, pending); var shares = me.getObjectValue('shares', undefined, pending); res = Proxmox.Utils.format_size(max*1024*1024); if (balloon !== undefined && balloon > 0) { res = Proxmox.Utils.format_size(balloon*1024*1024) + "/" + res; if (shares) { res += ' [shares=' + shares +']'; } } else if (balloon === 0) { res += ' [balloon=0]'; } return res; }, }, sockets: { header: gettext('Processors'), never_delete: true, editor: caps.vms['VM.Config.CPU'] || caps.vms['VM.Config.HWType'] ? processorEditor : undefined, tdCls: 'pve-itype-icon-cpu', group: 3, defaultValue: '1', multiKey: ['sockets', 'cpu', 'cores', 'numa', 'vcpus', 'cpulimit', 'cpuunits'], renderer: function(value, metaData, record, rowIndex, colIndex, store, pending) { var sockets = me.getObjectValue('sockets', 1, pending); var model = me.getObjectValue('cpu', undefined, pending); var cores = me.getObjectValue('cores', 1, pending); var numa = me.getObjectValue('numa', undefined, pending); var vcpus = me.getObjectValue('vcpus', undefined, pending); var cpulimit = me.getObjectValue('cpulimit', undefined, pending); var cpuunits = me.getObjectValue('cpuunits', undefined, pending); let res = Ext.String.format( '{0} ({1} sockets, {2} cores)', sockets * cores, sockets, cores); if (model) { res += ' [' + model + ']'; } if (numa) { res += ' [numa=' + numa +']'; } if (vcpus) { res += ' [vcpus=' + vcpus +']'; } if (cpulimit) { res += ' [cpulimit=' + cpulimit +']'; } if (cpuunits) { res += ' [cpuunits=' + cpuunits +']'; } return res; }, }, bios: { header: 'BIOS', group: 4, never_delete: true, editor: caps.vms['VM.Config.Options'] ? 'PVE.qemu.BiosEdit' : undefined, defaultValue: '', iconCls: 'microchip', renderer: PVE.Utils.render_qemu_bios, }, vga: { header: gettext('Display'), editor: caps.vms['VM.Config.HWType'] ? 'PVE.qemu.DisplayEdit' : undefined, never_delete: true, iconCls: 'desktop', group: 5, defaultValue: '', renderer: PVE.Utils.render_kvm_vga_driver, }, machine: { header: gettext('Machine'), editor: caps.vms['VM.Config.HWType'] ? 'PVE.qemu.MachineEdit' : undefined, iconCls: 'cogs', never_delete: true, group: 6, defaultValue: '', renderer: function(value, metaData, record, rowIndex, colIndex, store, pending) { let ostype = me.getObjectValue('ostype', undefined, pending); if (PVE.Utils.is_windows(ostype) && (!value || value === 'pc' || value === 'q35')) { return value === 'q35' ? 'pc-q35-5.1' : 'pc-i440fx-5.1'; } return PVE.Utils.render_qemu_machine(value); }, }, scsihw: { header: gettext('SCSI Controller'), iconCls: 'database', editor: caps.vms['VM.Config.Options'] ? 'PVE.qemu.ScsiHwEdit' : undefined, renderer: PVE.Utils.render_scsihw, group: 7, never_delete: true, defaultValue: '', }, vmstate: { header: gettext('Hibernation VM State'), iconCls: 'download', del_extra_msg: gettext('The saved VM state will be permanently lost.'), group: 100, }, cores: { visible: false, }, cpu: { visible: false, }, numa: { visible: false, }, balloon: { visible: false, }, hotplug: { visible: false, }, vcpus: { visible: false, }, cpuunits: { visible: false, }, cpulimit: { visible: false, }, shares: { visible: false, }, ostype: { visible: false, }, }; PVE.Utils.forEachBus(undefined, function(type, id) { let confid = type + id; rows[confid] = { group: 10, iconCls: 'hdd-o', editor: 'PVE.qemu.HDEdit', isOnStorageBus: true, header: gettext('Hard Disk') + ' (' + confid +')', cdheader: gettext('CD/DVD Drive') + ' (' + confid +')', cloudheader: gettext('CloudInit Drive') + ' (' + confid + ')', }; }); for (let i = 0; i < PVE.Utils.hardware_counts.net; i++) { let confid = "net" + i.toString(); rows[confid] = { group: 15, order: i, iconCls: 'exchange', editor: caps.vms['VM.Config.Network'] ? 'PVE.qemu.NetworkEdit' : undefined, never_delete: !caps.vms['VM.Config.Network'], header: gettext('Network Device') + ' (' + confid +')', }; } rows.efidisk0 = { group: 20, iconCls: 'hdd-o', editor: null, never_delete: !caps.vms['VM.Config.Disk'], header: gettext('EFI Disk'), }; rows.tpmstate0 = { group: 22, iconCls: 'hdd-o', editor: null, never_delete: !caps.vms['VM.Config.Disk'], header: gettext('TPM State'), }; for (let i = 0; i < PVE.Utils.hardware_counts.usb; i++) { let confid = "usb" + i.toString(); rows[confid] = { group: 25, order: i, iconCls: 'usb', editor: caps.nodes['Sys.Console'] ? 'PVE.qemu.USBEdit' : undefined, never_delete: !caps.nodes['Sys.Console'], header: gettext('USB Device') + ' (' + confid + ')', }; } for (let i = 0; i < PVE.Utils.hardware_counts.hostpci; i++) { let confid = "hostpci" + i.toString(); rows[confid] = { group: 30, order: i, tdCls: 'pve-itype-icon-pci', never_delete: !caps.nodes['Sys.Console'], editor: caps.nodes['Sys.Console'] ? 'PVE.qemu.PCIEdit' : undefined, header: gettext('PCI Device') + ' (' + confid + ')', }; } for (let i = 0; i < PVE.Utils.hardware_counts.serial; i++) { let confid = "serial" + i.toString(); rows[confid] = { group: 35, order: i, tdCls: 'pve-itype-icon-serial', never_delete: !caps.nodes['Sys.Console'], header: gettext('Serial Port') + ' (' + confid + ')', }; } rows.audio0 = { group: 40, iconCls: 'volume-up', editor: caps.vms['VM.Config.HWType'] ? 'PVE.qemu.AudioEdit' : undefined, never_delete: !caps.vms['VM.Config.HWType'], header: gettext('Audio Device'), }; for (let i = 0; i < 256; i++) { rows["unused" + i.toString()] = { group: 99, order: i, iconCls: 'hdd-o', del_extra_msg: gettext('This will permanently erase all data.'), editor: caps.vms['VM.Config.Disk'] ? 'PVE.qemu.HDEdit' : undefined, header: gettext('Unused Disk') + ' ' + i.toString(), }; } rows.rng0 = { group: 45, tdCls: 'pve-itype-icon-die', editor: caps.nodes['Sys.Console'] ? 'PVE.qemu.RNGEdit' : undefined, never_delete: !caps.nodes['Sys.Console'], header: gettext("VirtIO RNG"), }; var sorterFn = function(rec1, rec2) { var v1 = rec1.data.key; var v2 = rec2.data.key; var g1 = rows[v1].group || 0; var g2 = rows[v2].group || 0; var order1 = rows[v1].order || 0; var order2 = rows[v2].order || 0; if (g1 - g2 !== 0) { return g1 - g2; } if (order1 - order2 !== 0) { return order1 - order2; } if (v1 > v2) { return 1; } else if (v1 < v2) { return -1; } else { return 0; } }; let baseurl = `nodes/${nodename}/qemu/${vmid}/config`; let sm = Ext.create('Ext.selection.RowModel', {}); let run_editor = function() { let rec = sm.getSelection()[0]; if (!rec || !rows[rec.data.key]?.editor) { return; } let rowdef = rows[rec.data.key]; let editor = rowdef.editor; if (rowdef.isOnStorageBus) { let value = me.getObjectValue(rec.data.key, '', true); if (isCloudInitKey(value)) { return; } else if (value.match(/media=cdrom/)) { editor = 'PVE.qemu.CDEdit'; } else if (!diskCap) { return; } } let commonOpts = { autoShow: true, pveSelNode: me.pveSelNode, confid: rec.data.key, url: `/api2/extjs/${baseurl}`, listeners: { destroy: () => me.reload(), }, }; if (Ext.isString(editor)) { Ext.create(editor, commonOpts); } else { let win = Ext.createWidget(rowdef.editor.xtype, Ext.apply(commonOpts, rowdef.editor)); win.load(); } }; let edit_btn = new Proxmox.button.Button({ text: gettext('Edit'), selModel: sm, disabled: true, handler: run_editor, }); 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(), }, }); }, }); let diskaction_btn = new Proxmox.button.Button({ text: gettext('Disk Action'), disabled: true, menu: { items: [ move_menuitem, reassign_menuitem, resize_menuitem, ], }, }); let remove_btn = new Proxmox.button.Button({ text: gettext('Remove'), defaultText: gettext('Remove'), altText: gettext('Detach'), selModel: sm, disabled: true, dangerous: true, RESTMethod: 'PUT', confirmMsg: function(rec) { let warn = gettext('Are you sure you want to remove entry {0}'); 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 msg = Ext.String.format(warn, `'${rendered}'`); if (rows[rec.data.key].del_extra_msg) { msg += '
' + rows[rec.data.key].del_extra_msg; } return msg; }, handler: function(btn, e, rec) { Proxmox.Utils.API2Request({ url: '/api2/extjs/' + baseurl, waitMsgTarget: me, method: btn.RESTMethod, params: { 'delete': rec.data.key, }, callback: () => me.reload(), failure: response => Ext.Msg.alert('Error', response.htmlStatus), success: function(response, options) { if (btn.RESTMethod === 'POST') { Ext.create('Proxmox.window.TaskProgress', { autoShow: true, upid: response.result.data, listeners: { destroy: () => me.reload(), }, }); } }, }); }, listeners: { render: function(btn) { // hack: calculate the max button width on first display to prevent the whole // toolbar to move when we switch between the "Remove" and "Detach" labels var def = btn.getSize().width; btn.setText(btn.altText); var alt = btn.getSize().width; btn.setText(btn.defaultText); var optimal = alt > def ? alt : def; btn.setSize({ width: optimal }); }, }, }); let revert_btn = new PVE.button.PendingRevert({ apiurl: '/api2/extjs/' + baseurl, }); let efidisk_menuitem = Ext.create('Ext.menu.Item', { text: gettext('EFI Disk'), iconCls: 'fa fa-fw fa-hdd-o black', disabled: !caps.vms['VM.Config.Disk'], handler: function() { let { data: bios } = me.rstore.getData().map.bios || {}; Ext.create('PVE.qemu.EFIDiskEdit', { autoShow: true, url: '/api2/extjs/' + baseurl, pveSelNode: me.pveSelNode, usesEFI: bios?.value === 'ovmf' || bios?.pending === 'ovmf', listeners: { destroy: () => me.reload(), }, }); }, }); let counts = {}; let isAtLimit = (type) => counts[type] >= PVE.Utils.hardware_counts[type]; let isAtUsbLimit = () => { let ostype = me.getObjectValue('ostype'); let machine = me.getObjectValue('machine'); return counts.usb >= PVE.Utils.get_max_usb_count(ostype, machine); }; let set_button_status = function() { let selection_model = me.getSelectionModel(); let rec = selection_model.getSelection()[0]; counts = {}; // en/disable hardwarebuttons let hasCloudInit = false; me.rstore.getData().items.forEach(function({ id, data }) { if (!hasCloudInit && (isCloudInitKey(data.value) || isCloudInitKey(data.pending))) { hasCloudInit = true; return; } let match = id.match(/^([^\d]+)\d+$/); if (match && PVE.Utils.hardware_counts[match[1]] !== undefined) { let type = match[1]; counts[type] = (counts[type] || 0) + 1; } }); // heuristic only for disabling some stuff, the backend has the final word. const noSysConsolePerm = !caps.nodes['Sys.Console']; const noVMConfigHWTypePerm = !caps.vms['VM.Config.HWType']; const noVMConfigNetPerm = !caps.vms['VM.Config.Network']; const noVMConfigDiskPerm = !caps.vms['VM.Config.Disk']; const noVMConfigCDROMPerm = !caps.vms['VM.Config.CDROM']; const noVMConfigCloudinitPerm = !caps.vms['VM.Config.Cloudinit']; me.down('#addUsb').setDisabled(noSysConsolePerm || isAtUsbLimit()); me.down('#addPci').setDisabled(noSysConsolePerm || isAtLimit('hostpci')); me.down('#addAudio').setDisabled(noVMConfigHWTypePerm || isAtLimit('audio')); me.down('#addSerial').setDisabled(noVMConfigHWTypePerm || isAtLimit('serial')); me.down('#addNet').setDisabled(noVMConfigNetPerm || isAtLimit('net')); me.down('#addRng').setDisabled(noSysConsolePerm || isAtLimit('rng')); efidisk_menuitem.setDisabled(noVMConfigDiskPerm || isAtLimit('efidisk')); me.down('#addTpmState').setDisabled(noSysConsolePerm || isAtLimit('tpmstate')); me.down('#addCloudinitDrive').setDisabled(noVMConfigCDROMPerm || noVMConfigCloudinitPerm || hasCloudInit); if (!rec) { remove_btn.disable(); edit_btn.disable(); diskaction_btn.disable(); revert_btn.disable(); return; } const { key, value } = rec.data; const row = rows[key]; const deleted = !!rec.data.delete; const pending = deleted || me.hasPendingChanges(key); 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; const isEfi = key === 'efidisk0'; const tpmMoveable = key === 'tpmstate0' && !me.pveSelNode.data.running; 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 ? 'POST':'PUT'; edit_btn.setDisabled( deleted || !row.editor || isCloudInit || (isCDRom && !cdromCap) || (isDisk && !diskCap)); diskaction_btn.setDisabled( pending || !diskCap || isCloudInit || !(isDisk || isEfi || tpmMoveable), ); move_menuitem.setDisabled(isUnusedDisk); reassign_menuitem.setDisabled(pending || (isEfi || tpmMoveable)); resize_menuitem.setDisabled(pending || !isUsedDisk); revert_btn.setDisabled(!pending); }; let editorFactory = (classPath, extraOptions) => { extraOptions = extraOptions || {}; return () => Ext.create(`PVE.qemu.${classPath}`, { autoShow: true, url: `/api2/extjs/${baseurl}`, pveSelNode: me.pveSelNode, listeners: { destroy: () => me.reload(), }, isAdd: true, isCreate: true, ...extraOptions, }); }; Ext.apply(me, { url: `/api2/json/nodes/${nodename}/qemu/${vmid}/pending`, interval: 5000, selModel: sm, run_editor: run_editor, tbar: [ { text: gettext('Add'), 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('CD/DVD Drive'), iconCls: 'pve-itype-icon-cdrom', disabled: !caps.vms['VM.Config.CDROM'], handler: editorFactory('CDEdit'), }, { text: gettext('Network Device'), itemId: 'addNet', iconCls: 'fa fa-fw fa-exchange black', 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', iconCls: 'fa fa-fw fa-usb black', disabled: !caps.nodes['Sys.Console'], handler: editorFactory('USBEdit'), }, { text: gettext('PCI Device'), itemId: 'addPci', iconCls: 'pve-itype-icon-pci', disabled: !caps.nodes['Sys.Console'], handler: editorFactory('PCIEdit'), }, { text: gettext('Serial Port'), itemId: 'addSerial', iconCls: 'pve-itype-icon-serial', disabled: !caps.vms['VM.Config.Options'], handler: editorFactory('SerialEdit'), }, { text: gettext('CloudInit Drive'), itemId: 'addCloudinitDrive', iconCls: 'fa fa-fw fa-cloud black', disabled: !caps.vms['VM.Config.CDROM'] || !caps.vms['VM.Config.Cloudinit'], handler: editorFactory('CIDriveEdit'), }, { text: gettext('Audio Device'), itemId: 'addAudio', iconCls: 'fa fa-fw fa-volume-up black', disabled: !caps.vms['VM.Config.HWType'], handler: editorFactory('AudioEdit'), }, { text: gettext("VirtIO RNG"), itemId: 'addRng', iconCls: 'pve-itype-icon-die', disabled: !caps.nodes['Sys.Console'], handler: editorFactory('RNGEdit'), }, ], }), }, remove_btn, edit_btn, diskaction_btn, revert_btn, ], rows: rows, sorterFn: sorterFn, listeners: { itemdblclick: run_editor, selectionchange: set_button_status, }, }); me.callParent(); me.on('activate', me.rstore.startUpdate, me.rstore); me.on('destroy', me.rstore.stopUpdate, me.rstore); me.mon(me.getStore(), 'datachanged', set_button_status, me); }, });