* [PATCH manager] ui: qemu hardware view: split out disks and nics into grids
@ 2026-02-27 9:05 Dominik Csapak
0 siblings, 0 replies; only message in thread
From: Dominik Csapak @ 2026-02-27 9:05 UTC (permalink / raw)
To: pve-devel
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 <d.csapak@proxmox.com>
---
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 += '<br />';
+ txt += `<span style="color: darkorange">${pendingTxt}</span>`;
+ } else {
+ txt = `<div style="color: darkorange; text-decoration: line-through;">${txt}</div>`;
+ }
+ } else {
+ txt = `<span style="color: orange">${pendingTxt}</span>`;
+ }
+ }
+ } else {
+ let hwRec = hardwareGrid.getStore().getById(rec.get('id'));
+ if (hwRec.data.delete && txt) {
+ txt = `<div style="color: darkorange; text-decoration: line-through;">${txt}</div>`;
+ }
+ }
+
+ 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
^ permalink raw reply [flat|nested] only message in thread
only message in thread, other threads:[~2026-02-27 9:11 UTC | newest]
Thread overview: (only message) (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2026-02-27 9:05 [PATCH manager] ui: qemu hardware view: split out disks and nics into grids Dominik Csapak
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox