[pve-devel] [PATCH v2 manager 7/7] ui: improve boot order editor

Stefan Reiter s.reiter at proxmox.com
Tue Oct 6 15:32:18 CEST 2020


The new boot order property can express many more scenarios than the old
one. Update the editor so it can handle it.

Features a grid with all supported boot devices which can be reordered
using drag-and-drop, as well as toggled on and off with an inline
checkbox.

Support for configs still using the old format is given, with the first
write automatically updating the VM config to use the new one.

The renderer for the Options panel is updated with support for the new
format.

Note that it is very well possible to disable all boot devices, in which
case an empty 'boot: ' will be stored to the config file. I'm not sure
what that would be useful for, but there's no reason to forbid it
either, just warn the user that it's probably not what they want.

Signed-off-by: Stefan Reiter <s.reiter at proxmox.com>
---

Depends on updated qemu-server for API support.

v2:
* improve GUI with two new columns
* make more schematic, putting less in initComponent
* use existing format for renderer in Options panel, instead of changing it
  (I opted to just use the existing one instead of changing it at all now,
  just updated to support the new format)
* update for new 'empty' (no bootdevs) behaviour
* update legacy conversion code to deal with 4 characters, ignoring 'a' (floppy)
  (this appears to be a never-triggered bug in the old implementation as well?)

 www/css/ext6-pve.css               |   4 +
 www/manager6/qemu/BootOrderEdit.js | 355 ++++++++++++++++++-----------
 www/manager6/qemu/Options.js       |  32 ++-
 3 files changed, 257 insertions(+), 134 deletions(-)

diff --git a/www/css/ext6-pve.css b/www/css/ext6-pve.css
index a91f1aaf..6430ffc4 100644
--- a/www/css/ext6-pve.css
+++ b/www/css/ext6-pve.css
@@ -583,6 +583,10 @@ table.osds td:first-of-type {
     cursor: pointer;
 }
 
+.cursor-move {
+    cursor: move;
+}
+
 .x-grid-filters-filtered-column {
     font-style: italic;
     font-weight: bold;
diff --git a/www/manager6/qemu/BootOrderEdit.js b/www/manager6/qemu/BootOrderEdit.js
index 19d5d50a..c5726e96 100644
--- a/www/manager6/qemu/BootOrderEdit.js
+++ b/www/manager6/qemu/BootOrderEdit.js
@@ -1,150 +1,250 @@
+Ext.define('pve-boot-order-entry', {
+    extend: 'Ext.data.Model',
+    fields: [
+	{name: 'name', type: 'string'},
+	{name: 'enabled', type: 'bool'},
+	{name: 'desc', type: 'string'},
+    ]
+});
+
 Ext.define('PVE.qemu.BootOrderPanel', {
     extend: 'Proxmox.panel.InputPanel',
     alias: 'widget.pveQemuBootOrderPanel',
+
     vmconfig: {}, // store loaded vm config
+    store: undefined,
 
-    bootdisk: undefined,
-    selection: [],
-    list: [],
-    comboboxes: [],
+    inUpdate: false,
+    controller: {
+	xclass: 'Ext.app.ViewController',
+    },
 
-    isBootDisk: function(value) {
+    isDisk: function(value) {
 	return PVE.Utils.bus_match.test(value);
     },
 
-    setVMConfig: function(vmconfig) {
-	var me = this;
-	me.vmconfig = vmconfig;
-	var order = me.vmconfig.boot || 'cdn';
-	me.bootdisk = me.vmconfig.bootdisk || undefined;
-
-	// get the first 3 characters
-	// ignore the rest (there should never be more than 3)
-	me.selection = order.split('').slice(0,3);
-
-	// build bootdev list
-	me.list = [];
-	Ext.Object.each(me.vmconfig, function(key, value) {
-	    if (me.isBootDisk(key) &&
-		!(/media=cdrom/).test(value)) {
-		me.list.push([key, "Disk '" + key + "'"]);
-	    }
-	});
-
-	me.list.push(['d', 'CD-ROM']);
-	me.list.push(['n', gettext('Network')]);
-	me.list.push(['__none__', Proxmox.Utils.noneText]);
-
-	me.recomputeList();
-
-	me.comboboxes.forEach(function(box) {
-	    box.resetOriginalValue();
-	});
+    isBootdev: function(dev, value) {
+	return this.isDisk(dev) ||
+	    (/^net\d+/).test(dev) ||
+	    (/^hostpci\d+/).test(dev) ||
+	    ((/^usb\d+/).test(dev) && !(/spice/).test(value));
     },
 
-    onGetValues: function(values) {
-	var me = this;
-	var order = me.selection.join('');
-	var res = { boot: order };
+    setVMConfig: function(vmconfig) {
+	let me = this;
+	me.vmconfig = vmconfig;
 
-	if  (me.bootdisk && order.indexOf('c') !== -1) {
-	    res.bootdisk = me.bootdisk;
-	} else {
-	    res['delete'] = 'bootdisk';
+	me.store.removeAll();
+
+	let boot = PVE.Parser.parsePropertyString(me.vmconfig.boot, "legacy");
+
+	let bootorder = [];
+	if (boot.order) {
+	    bootorder = boot.order.split(';').map(dev => ({name: dev, enabled: true}));
+	} else if (!(/^\s*$/).test(me.vmconfig.boot)) {
+	    // legacy style, transform to new bootorder
+	    let order = boot.legacy || 'cdn';
+	    let bootdisk = me.vmconfig.bootdisk || undefined;
+
+	    // get the first 4 characters (acdn)
+	    // ignore the rest (there should never be more than 4)
+	    let orderList = order.split('').slice(0,4);
+
+	    // build bootdev list
+	    for (let i = 0; i < orderList.length; i++) {
+		let list = [];
+		if (orderList[i] === 'c') {
+		    if (bootdisk !== undefined && me.vmconfig[bootdisk]) {
+			list.push(bootdisk);
+		    }
+		} else if (orderList[i] === 'd') {
+		    Ext.Object.each(me.vmconfig, function(key, value) {
+			if (me.isDisk(key) && (/media=cdrom/).test(value)) {
+			    list.push(key);
+			}
+		    });
+		} else if (orderList[i] === 'n') {
+		    Ext.Object.each(me.vmconfig, function(key, value) {
+			if ((/^net\d+/).test(key)) {
+			    list.push(key);
+			}
+		    });
+		}
+
+		// Object.each iterates in random order, sort alphabetically
+		list.sort();
+		list.forEach(dev => bootorder.push({name: dev, enabled: true}));
+	    }
 	}
 
+	// add disabled devices as well
+	let disabled = [];
+	Ext.Object.each(me.vmconfig, function(key, value) {
+	    if (me.isBootdev(key, value) &&
+		!Ext.Array.some(bootorder, x => x.name === key))
+	    {
+		disabled.push(key);
+	    }
+	});
+	disabled.sort();
+	disabled.forEach(dev => bootorder.push({name: dev, enabled: false}));
+
+	// add descriptions
+	bootorder.forEach(entry => {
+	    entry.desc = me.vmconfig[entry.name];
+	});
+
+	me.store.insert(0, bootorder);
+	me.store.fireEvent("update");
+    },
+
+    calculateValue: function() {
+	let me = this;
+	return me.store.getData().items
+	    .filter(x => x.data.enabled)
+	    .map(x => x.data.name)
+	    .join(';');
+    },
+
+    onGetValues: function() {
+	let me = this;
+	// Note: we allow an empty value, so no 'delete' option
+	let val = { order: me.calculateValue() };
+	let res = { boot: PVE.Parser.printPropertyString(val) };
 	return res;
     },
 
-    recomputeSelection: function(combobox, newVal, oldVal) {
-	var me = this.up('#inputpanel');
-	me.selection = [];
-	me.comboboxes.forEach(function(item) {
-	    var val = item.getValue();
-
-	    // when selecting an already selected item,
-	    // switch it around
-	    if ((val === newVal || (me.isBootDisk(val) && me.isBootDisk(newVal))) &&
-		item.name !== combobox.name &&
-		newVal !== '__none__') {
-		// swap items
-		val = oldVal;
-	    }
-
-	    // push 'c','d' or 'n' in the array
-	    if (me.isBootDisk(val)) {
-		me.selection.push('c');
-		me.bootdisk = val;
-	    } else if (val === 'd' ||
-		       val === 'n') {
-		me.selection.push(val);
-	    }
-	});
-
-	me.recomputeList();
-    },
-
-    recomputeList: function(){
-	var me = this;
-	// set the correct values in the kvcomboboxes
-	var cnt = 0;
-	me.comboboxes.forEach(function(item) {
-	    if (cnt === 0) {
-		// never show 'none' on first combobox
-		item.store.loadData(me.list.slice(0, me.list.length-1));
-	    } else {
-		item.store.loadData(me.list);
-	    }
-	    item.suspendEvent('change');
-	    if (cnt < me.selection.length) {
-		item.setValue((me.selection[cnt] !== 'c')?me.selection[cnt]:me.bootdisk);
-	    } else if (cnt === 0){
-		item.setValue('');
-	    } else {
-		item.setValue('__none__');
-	    }
-	    cnt++;
-	    item.resumeEvent('change');
-	    item.validate();
-	});
-    },
-
-    initComponent : function() {
-	var me = this;
-
-	// this has to be done here, because of
-	// the way our inputPanel class handles items
-	me.comboboxes = [
-		Ext.createWidget('proxmoxKVComboBox', {
-		fieldLabel: gettext('Boot device') + " 1",
-		labelWidth: 120,
-		name: 'bd1',
-		allowBlank: false,
-		listeners: {
-		    change: me.recomputeSelection
+    items: [
+	{
+	    xtype: 'grid',
+	    reference: 'grid',
+	    margin: '0 0 5 0',
+	    columns: [
+		{
+		    header: '',
+		    renderer: () => "<i class='fa fa-reorder cursor-move'></i>",
+		    width: 30,
+		    sortable: false,
+		    hideable: false,
+		    draggable: false,
+		},
+		{
+		    header: '#',
+		    width: 30,
+		    sortable: false,
+		    hideable: false,
+		    draggable: false,
+		    renderer: (value, metaData, record, rowIndex) => {
+			let idx = (rowIndex + 1).toString();
+			if (record.get('enabled')) {
+			    return idx;
+			} else {
+			    return "<span class='faded'>" + idx + "</span>";
+			}
+		    },
+		},
+		{
+		    xtype: 'checkcolumn',
+		    header: gettext('Enabled'),
+		    dataIndex: 'enabled',
+		    width: 70,
+		    sortable: false,
+		    hideable: false,
+		    draggable: false,
+		},
+		{
+		    header: gettext('Device'),
+		    dataIndex: 'name',
+		    width: 70,
+		    sortable: false,
+		    hideable: false,
+		    draggable: false,
+		},
+		{
+		    header: gettext('Description'),
+		    dataIndex: 'desc',
+		    flex: true,
+		    sortable: false,
+		    hideable: false,
+		    draggable: false,
+		},
+	    ],
+	    viewConfig: {
+		plugins: {
+		    ptype: 'gridviewdragdrop',
+		    dragText: gettext('Drag and drop to reorder'),
 		}
-	    }),
-		Ext.createWidget('proxmoxKVComboBox', {
-		fieldLabel: gettext('Boot device') + " 2",
-		labelWidth: 120,
-		name: 'bd2',
-		allowBlank: false,
-		listeners: {
-		    change: me.recomputeSelection
+	    },
+	    listeners: {
+		drop: function() {
+		    // doesn't fire automatically on reorder
+		    this.getStore().fireEvent("update");
 		}
-	    }),
-		Ext.createWidget('proxmoxKVComboBox', {
-		fieldLabel: gettext('Boot device') + " 3",
-		labelWidth: 120,
-		name: 'bd3',
-		allowBlank: false,
-		listeners: {
-		    change: me.recomputeSelection
+	    },
+	},
+	{
+	    xtype: 'component',
+	    html: gettext('Drag and drop to reorder'),
+	},
+	{
+	    xtype: 'displayfield',
+	    reference: 'emptyWarning',
+	    userCls: 'pmx-hint',
+	    value: gettext('Warning: No devices selected, the VM will probably not boot!'),
+	},
+	{
+	    // for dirty marking and 'reset' function
+	    xtype: 'field',
+	    reference: 'marker',
+	    hidden: true,
+	    setValue: function(val) {
+		let me = this;
+		let panel = me.up('pveQemuBootOrderPanel');
+
+		// on form reset, go back to original state
+		if (!panel.inUpdate) {
+		    panel.setVMConfig(panel.vmconfig);
 		}
-	    })
-	];
-	Ext.apply(me, { items: me.comboboxes });
+
+		// not a subclass, so no callParent; just do it manually
+		me.setRawValue(me.valueToRaw(val));
+		return me.mixins.field.setValue.call(me, val);
+	    }
+	},
+    ],
+
+    initComponent: function() {
+	let me = this;
+
 	me.callParent();
+
+	let controller = me.getController();
+
+	let grid = controller.lookup('grid');
+	let marker = controller.lookup('marker');
+	let emptyWarning = controller.lookup('emptyWarning');
+
+	marker.originalValue = undefined;
+
+	me.store = Ext.create('Ext.data.Store', {
+	    model: 'pve-boot-order-entry',
+	    listeners: {
+		update: function() {
+		    this.commitChanges();
+		    let val = me.calculateValue();
+		    if (marker.originalValue === undefined) {
+			marker.originalValue = val;
+		    }
+		    me.inUpdate = true;
+		    marker.setValue(val);
+		    me.inUpdate = false;
+		    marker.checkDirty();
+		    emptyWarning.setHidden(val !== '');
+		    grid.getView().refresh();
+		}
+	    }
+	});
+	grid.setStore(me.store);
     }
 });
 
@@ -157,9 +257,10 @@ Ext.define('PVE.qemu.BootOrderEdit', {
     }],
 
     subject: gettext('Boot Order'),
+    width: 600,
 
     initComponent : function() {
-	var me = this;
+	let me = this;
 	me.callParent();
 	me.load({
 	    success: function(response, options) {
diff --git a/www/manager6/qemu/Options.js b/www/manager6/qemu/Options.js
index 20f6ffbb..1f07d81a 100644
--- a/www/manager6/qemu/Options.js
+++ b/www/manager6/qemu/Options.js
@@ -92,27 +92,45 @@ Ext.define('PVE.qemu.Options', {
 		editor: caps.vms['VM.Config.Disk'] ? 'PVE.qemu.BootOrderEdit' : undefined,
 		multiKey: ['boot', 'bootdisk'],
 		renderer: function(order, metaData, record, rowIndex, colIndex, store, pending) {
+		    if (/^\s*$/.test(order)) {
+			return gettext('(No boot device selected)');
+		    }
+		    let boot = PVE.Parser.parsePropertyString(order, "legacy");
+		    if (boot.order) {
+			let list = boot.order.split(';');
+			let ret = '';
+			let i = 1;
+			list.forEach(dev => {
+			    if (ret) {
+				ret += ', ';
+			    }
+			    ret += dev;
+			});
+			return ret;
+		    }
+
+		    // legacy style and fallback
 		    var i;
 		    var text = '';
 		    var bootdisk = me.getObjectValue('bootdisk', undefined, pending);
-		    order = order || 'cdn';
+		    order = boot.legacy || 'cdn';
 		    for (i = 0; i < order.length; i++) {
-			var sel = order.substring(i, i + 1);
 			if (text) {
 			    text += ', ';
 			}
+			var sel = order.substring(i, i + 1);
 			if (sel === 'c') {
 			    if (bootdisk) {
-				text += "Disk '" + bootdisk + "'";
+				text += bootdisk;
 			    } else {
-				text += "Disk";
+				text += gettext('(no bootdisk)');
 			    }
 			} else if (sel === 'n') {
-			    text += 'Network';
+			    text += gettext('any net');
 			} else if (sel === 'a') {
-			    text += 'Floppy';
+			    text += gettext('Floppy');
 			} else if (sel === 'd') {
-			    text += 'CD-ROM';
+			    text += gettext('any CD-ROM');
 			} else {
 			    text += sel;
 			}
-- 
2.20.1






More information about the pve-devel mailing list