ui: improve boot order editor

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@proxmox.com>
This commit is contained in:
Stefan Reiter 2020-10-06 15:32:18 +02:00 committed by Thomas Lamprecht
parent 659a335026
commit 1cc7c672b7
3 changed files with 257 additions and 134 deletions

View File

@ -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;

View File

@ -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();
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,
},
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));
{
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 {
item.store.loadData(me.list);
return "<span class='faded'>" + idx + "</span>";
}
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();
});
},
},
{
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'),
}
},
listeners: {
drop: function() {
// doesn't fire automatically on reorder
this.getStore().fireEvent("update");
}
},
},
{
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);
}
// 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() {
var me = this;
let 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
}
}),
Ext.createWidget('proxmoxKVComboBox', {
fieldLabel: gettext('Boot device') + " 2",
labelWidth: 120,
name: 'bd2',
allowBlank: false,
listeners: {
change: me.recomputeSelection
}
}),
Ext.createWidget('proxmoxKVComboBox', {
fieldLabel: gettext('Boot device') + " 3",
labelWidth: 120,
name: 'bd3',
allowBlank: false,
listeners: {
change: me.recomputeSelection
}
})
];
Ext.apply(me, { items: me.comboboxes });
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) {

View File

@ -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;
}