[pve-devel] [PATCH v3 manager 2/3] gui/cluster: add CorosyncLinkEdit component to support up to 8 links

Stefan Reiter s.reiter at proxmox.com
Mon Mar 23 13:41:13 CET 2020


CorosyncLinkEdit is a Panel that contains between one and 8
CorosyncLinkSelectors. These can be added or removed with according
buttons.

Values submitted to the API are calculated by each
ProxmoxNetworkSelector itself. This works because ExtJS searches
recursively through all child components for ones with a value to be
submitted, i.e. the CorosyncLinkEdit and CorosyncLinkSelector components
are not part of data submission at all.

Change ClusterEdit.js to use the new component for cluster join and
create. To make space in layout, move 'password' field to the side
(where the network-selector previously was) and use 'hbox' panel for
horizontal layouting to avoid spacing issues with languages where the
fieldLabel doesn't fit on one line.

Signed-off-by: Stefan Reiter <s.reiter at proxmox.com>
---
 www/manager6/Makefile               |   1 +
 www/manager6/dc/ClusterEdit.js      | 187 +++++++------
 www/manager6/dc/CorosyncLinkEdit.js | 411 ++++++++++++++++++++++++++++
 3 files changed, 505 insertions(+), 94 deletions(-)
 create mode 100644 www/manager6/dc/CorosyncLinkEdit.js

diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index 41615430..0f2224af 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -224,6 +224,7 @@ JSSRC= 				                 	\
 	dc/Cluster.js					\
 	dc/ClusterEdit.js				\
 	dc/PermissionView.js				\
+	dc/CorosyncLinkEdit.js				\
 	Workspace.js
 
 lint: ${JSSRC}
diff --git a/www/manager6/dc/ClusterEdit.js b/www/manager6/dc/ClusterEdit.js
index 7542104a..c18b546b 100644
--- a/www/manager6/dc/ClusterEdit.js
+++ b/www/manager6/dc/ClusterEdit.js
@@ -25,24 +25,24 @@ Ext.define('PVE.ClusterCreateWindow', {
 	    name: 'clustername'
 	},
 	{
-	    xtype: 'proxmoxNetworkSelector',
-	    fieldLabel: Ext.String.format(gettext('Link {0}'), 0),
-	    emptyText: gettext("Optional, defaults to IP resolved by node's hostname"),
-	    name: 'link0',
-	    autoSelect: false,
-	    valueField: 'address',
-	    displayField: 'address',
-	    skipEmptyText: true
-	}],
-	advancedItems: [{
-	    xtype: 'proxmoxNetworkSelector',
-	    fieldLabel: Ext.String.format(gettext('Link {0}'), 1),
-	    emptyText: gettext("Optional second link for redundancy"),
-	    name: 'link1',
-	    autoSelect: false,
-	    valueField: 'address',
-	    displayField: 'address',
-	    skipEmptyText: true
+	    xtype: 'fieldcontainer',
+	    fieldLabel: gettext("Cluster Links"),
+	    style: {
+		'padding-top': '5px',
+	    },
+	    items: [
+		{
+		    xtype: 'pveCorosyncLinkEditor',
+		    style: {
+			'padding-bottom': '5px',
+		    },
+		    name: 'links'
+		},
+		{
+		    xtype: 'label',
+		    text: gettext("Multiple links are used as failover, lower numbers have higher priority.")
+		}
+	    ]
 	}]
     }
 });
@@ -149,20 +149,10 @@ Ext.define('PVE.ClusterJoinNodeWindow', {
 	    info: {
 		fp: '',
 		ip: '',
-		clusterName: '',
-		ring0Needed: false,
-		ring1Possible: false,
-		ring1Needed: false
+		clusterName: ''
 	    }
 	},
 	formulas: {
-	    ring0EmptyText: function(get) {
-		if (get('info.ring0Needed')) {
-		    return gettext("Cannot use default address safely");
-		} else {
-		    return gettext("Default: IP resolved by node's hostname");
-		}
-	    },
 	    submittxt: function(get) {
 		let cn = get('info.clusterName');
 		if (cn) {
@@ -188,9 +178,6 @@ Ext.define('PVE.ClusterJoinNodeWindow', {
 		change: 'recomputeSerializedInfo',
 		enable: 'resetField'
 	    },
-	    'proxmoxtextfield[name=ring1_addr]': {
-		enable: 'ring1Needed'
-	    },
 	    'textfield': {
 		disable: 'resetField'
 	    }
@@ -198,47 +185,67 @@ Ext.define('PVE.ClusterJoinNodeWindow', {
 	resetField: function(field) {
 	    field.reset();
 	},
-	ring1Needed: function(f) {
-	    var vm = this.getViewModel();
-	    f.allowBlank = !vm.get('info.ring1Needed');
-	},
 	onInputTypeChange: function(field, assistedInput) {
-	    var vm = this.getViewModel();
+	    let linkEditor = this.lookup('linkEditor');
+
+	    // this also clears all links
+	    linkEditor.setAllowNumberEdit(!assistedInput);
+
 	    if (!assistedInput) {
-		vm.set('info.ring1Possible', true);
+		linkEditor.setInfoText();
+		linkEditor.setDefaultLinks();
 	    }
 	},
 	recomputeSerializedInfo: function(field, value) {
-	    var vm = this.getViewModel();
-	    var jsons = Ext.util.Base64.decode(value);
-	    var joinInfo = Ext.JSON.decode(jsons, true);
+	    let vm = this.getViewModel();
+
+	    let assistedEntryBox = this.lookup('assistedEntry');
+	    if (!assistedEntryBox.getValue()) {
+		// not in assisted entry mode, nothing to do
+		return;
+	    }
+
+	    let linkEditor = this.lookup('linkEditor');
+
+	    let jsons = Ext.util.Base64.decode(value);
+	    let joinInfo = Ext.JSON.decode(jsons, true);
 
-	    var info = {
+	    let info = {
 		fp: '',
-		ring1Needed: false,
-		ring1Possible: false,
 		ip: '',
 		clusterName: ''
 	    };
 
-	    var totem = {};
 	    if (!(joinInfo && joinInfo.totem)) {
 		field.valid = false;
+		linkEditor.setLinks([]);
+		linkEditor.setInfoText();
 	    } else {
-		var ring0Needed = false;
-		if (joinInfo.ring_addr !== undefined) {
-		    ring0Needed = joinInfo.ring_addr[0] !== joinInfo.ipAddress;
+		let interfaces = joinInfo.totem.interface;
+		let links = Object.values(interfaces).map(iface => {
+		    return {
+			number: iface.linknumber,
+			value: '',
+			text: '',
+			allowBlank: false
+		    };
+		});
+
+		linkEditor.setInfoText();
+		if (links.length == 1 && joinInfo.ring_addr !== undefined &&
+		    joinInfo.ring_addr[0] === joinInfo.ipAddress) {
+
+		    links[0].allowBlank = true;
+		    linkEditor.setInfoText(gettext("Leave empty to use IP resolved by node's hostname"));
 		}
 
+		linkEditor.setLinks(links);
+
 		info = {
 		    ip: joinInfo.ipAddress,
 		    fp: joinInfo.fingerprint,
-		    ring0Needed: ring0Needed,
-		    ring1Possible: !!joinInfo.totem['interface']['1'],
-		    ring1Needed: !!joinInfo.totem['interface']['1'],
-		    clusterName: joinInfo.totem['cluster_name']
+		    clusterName: joinInfo.totem.cluster_name
 		};
-		totem = joinInfo.totem;
 		field.valid = true;
 	    }
 
@@ -275,6 +282,7 @@ Ext.define('PVE.ClusterJoinNodeWindow', {
 	xtype: 'proxmoxcheckbox',
 	reference: 'assistedEntry',
 	name: 'assistedEntry',
+	itemId: 'assistedEntry',
 	submitValue: false,
 	value: true,
 	autoEl: {
@@ -301,10 +309,17 @@ Ext.define('PVE.ClusterJoinNodeWindow', {
 	value: ''
     },
     {
-	xtype: 'inputpanel',
-	column1: [
+	xtype: 'panel',
+	width: 776,
+	layout: {
+	    type: 'hbox',
+	    align: 'center'
+	},
+	items: [
 	    {
 		xtype: 'textfield',
+		flex: 1,
+		margin: '0 5px 0 0',
 		fieldLabel: gettext('Peer Address'),
 		allowBlank: false,
 		bind: {
@@ -315,52 +330,36 @@ Ext.define('PVE.ClusterJoinNodeWindow', {
 	    },
 	    {
 		xtype: 'textfield',
+		flex: 1,
+		margin: '0 0 10px 5px',
 		inputType: 'password',
 		emptyText: gettext("Peer's root password"),
 		fieldLabel: gettext('Password'),
 		allowBlank: false,
 		name: 'password'
-	    }
-	],
-	column2: [
-	    {
-		xtype: 'proxmoxNetworkSelector',
-		fieldLabel: Ext.String.format(gettext('Link {0}'), 0),
-		bind: {
-		    emptyText: '{ring0EmptyText}',
-		    allowBlank: '{!info.ring0Needed}'
-		},
-		skipEmptyText: true,
-		autoSelect: false,
-		valueField: 'address',
-		displayField: 'address',
-		name: 'link0'
 	    },
+	]
+    },
+    {
+	xtype: 'textfield',
+	fieldLabel: gettext('Fingerprint'),
+	allowBlank: false,
+	bind: {
+	    value: '{info.fp}',
+	    readOnly: '{assistedEntry.checked}'
+	},
+	name: 'fingerprint'
+    },
+    {
+	xtype: 'fieldcontainer',
+	fieldLabel: gettext("Cluster Links"),
+	items: [
 	    {
-		xtype: 'proxmoxNetworkSelector',
-		fieldLabel: Ext.String.format(gettext('Link {0}'), 1),
-		skipEmptyText: true,
-		autoSelect: false,
-		valueField: 'address',
-		displayField: 'address',
-		bind: {
-		    disabled: '{!info.ring1Possible}',
-		    allowBlank: '{!info.ring1Needed}',
-		},
-		name: 'link1'
-	    }
-	],
-	columnB: [
-	    {
-		xtype: 'textfield',
-		fieldLabel: gettext('Fingerprint'),
-		allowBlank: false,
-		bind: {
-		    value: '{info.fp}',
-		    readOnly: '{assistedEntry.checked}'
-		},
-		name: 'fingerprint'
-	    }
+		xtype: 'pveCorosyncLinkEditor',
+		itemId: 'linkEditor',
+		reference: 'linkEditor',
+		allowNumberEdit: false
+	    },
 	]
     }]
 });
diff --git a/www/manager6/dc/CorosyncLinkEdit.js b/www/manager6/dc/CorosyncLinkEdit.js
new file mode 100644
index 00000000..e14ee85f
--- /dev/null
+++ b/www/manager6/dc/CorosyncLinkEdit.js
@@ -0,0 +1,411 @@
+Ext.define('PVE.form.CorosyncLinkEditorController', {
+    extend: 'Ext.app.ViewController',
+    alias: 'controller.pveCorosyncLinkEditorController',
+
+    addLinkIfEmpty: function() {
+	let view = this.getView();
+	if (view.items || view.items.length == 0) {
+	    this.addLink();
+	}
+    },
+
+    addEmptyLink: function() {
+	// discard parameters to allow being called from 'handler'
+	this.addLink();
+    },
+
+    addLink: function(number, value, allowBlank) {
+	let me = this;
+	let view = me.getView();
+	let vm = view.getViewModel();
+
+	let linkCount = vm.get('linkCount');
+	if (linkCount >= vm.get('maxLinkCount')) {
+	    return;
+	}
+
+	if (number === undefined) {
+	    number = me.getNextFreeNumber();
+	}
+	if (value === undefined) {
+	    value = me.getNextFreeNetwork();
+	}
+
+	let linkSelector = Ext.create('PVE.form.CorosyncLinkSelector', {
+	    maxLinkNumber: vm.get('maxLinkCount') - 1,
+	    allowNumberEdit: vm.get('allowNumberEdit'),
+	    allowBlankNetwork: allowBlank,
+	    initNumber: number,
+	    initNetwork: value,
+
+	    // needs to be set here, because we need to update the viewmodel
+	    removeBtnHandler: function() {
+		let curLinkCount = vm.get('linkCount');
+
+		if (curLinkCount <= 1) {
+		    return;
+		}
+
+		vm.set('linkCount', curLinkCount - 1);
+
+		// 'this' is the linkSelector here
+		view.remove(this);
+
+		me.updateDeleteButtonState();
+	    }
+	});
+
+	view.add(linkSelector);
+
+	linkCount++;
+	vm.set('linkCount', linkCount);
+
+	me.updateDeleteButtonState();
+    },
+
+    // ExtJS trips on binding this for some reason, so do it manually
+    updateDeleteButtonState: function() {
+	let view = this.getView();
+	let vm = view.getViewModel();
+
+	let disabled = vm.get('linkCount') <= 1;
+
+	let deleteButtons = view.query('button[cls=removeLinkBtn]');
+	Ext.Array.each(deleteButtons, btn => {
+	    btn.setDisabled(disabled);
+	})
+    },
+
+    getNextFreeNetwork: function() {
+	let view = this.getView();
+	let vm = view.getViewModel();
+	let netsInUse = Ext.Array.map(
+	    view.query('proxmoxNetworkSelector'), selector => selector.value);
+
+	// default to empty field, user has to set up link manually
+	let retval = undefined;
+
+	let nets = vm.get('networks');
+	Ext.Array.each(nets, net => {
+	    if (!Ext.Array.contains(netsInUse, net)) {
+		retval = net;
+		return false; // break
+	    }
+	});
+
+	return retval;
+    },
+
+    getNextFreeNumber: function() {
+	let view = this.getView();
+	let vm = view.getViewModel();
+	let numbersInUse = Ext.Array.map(
+	    view.query('numberfield'), field => field.value);
+
+	for (let i = 0; i < vm.get('maxLinkCount'); i++) {
+	    if (!Ext.Array.contains(numbersInUse, i)) {
+		return i;
+	    }
+	}
+
+	// all numbers in use, this should never happen since add button is
+	// disabled automatically
+	return 0;
+    }
+});
+
+Ext.define('PVE.form.CorosyncLinkSelector', {
+    extend: 'Ext.panel.Panel',
+    xtype: 'pveCorosyncLinkSelector',
+
+    mixins: ['Proxmox.Mixin.CBind' ],
+    cbindData: [],
+
+    // config
+    maxLinkNumber: 7,
+    allowNumberEdit: true,
+    allowBlankNetwork: false,
+    removeBtnHandler: undefined,
+
+    // values
+    initNumber: 0,
+    initNetwork: '',
+
+    layout: 'hbox',
+    bodyPadding: 5,
+    border: 0,
+
+    items: [
+	{
+	    xtype: 'numberfield',
+	    cbind: {
+		maxValue: '{maxLinkNumber}',
+		readOnly: '{!allowNumberEdit}',
+		value: '{initNumber}'
+	    },
+
+	    minValue: 0,
+	    allowBlank: false,
+	    width: 80,
+	    labelWidth: 30,
+	    fieldLabel: gettext('Link'),
+
+	    // see getSubmitValue of network selector
+	    submitValue: false
+	},
+	{
+	    xtype: 'proxmoxNetworkSelector',
+	    cbind: {
+		allowBlank: '{allowBlankNetwork}',
+		value: '{initNetwork}'
+	    },
+
+	    autoSelect: false,
+	    valueField: 'address',
+	    displayField: 'address',
+	    margin: '0 5px 0 5px',
+	    getSubmitValue: function() {
+		// link number is encoded into key, so we need to set
+		// field name before value retrieval
+		let me = this;
+		let numSelect = me.prev('numberfield');
+		let linkNumber = numSelect.getValue();
+		me.name = 'link' + linkNumber;
+		return me.getValue();
+	    }
+	},
+	{
+	    xtype: 'button',
+	    iconCls: 'fa fa-trash-o',
+	    cls: 'removeLinkBtn',
+
+	    cbind: {
+		hidden: '{!allowNumberEdit}'
+	    },
+
+	    handler: function() {
+		let me = this;
+		let parent = me.up('pveCorosyncLinkSelector');
+		if (parent.removeBtnHandler !== undefined) {
+		    parent.removeBtnHandler();
+		}
+	    }
+	}
+    ],
+
+    initComponent: function() {
+	let me = this;
+
+	me.callParent();
+
+	let numSelect = me.down('numberfield');
+	let netSelect = me.down('proxmoxNetworkSelector');
+
+	numSelect.validator = this.createNoDuplicatesValidator(
+		'numberfield',
+		gettext("Duplicate link number not allowed.")
+	);
+
+	netSelect.validator = this.createNoDuplicatesValidator(
+		'proxmoxNetworkSelector',
+		gettext("Duplicate link address not allowed.")
+	);
+    },
+
+    createNoDuplicatesValidator: function(queryString, errorMsg) {
+	// linkSelector
+	let me = this;
+
+	return function(val) {
+	    let curField = this;
+	    let form = me.up('form');
+	    let linkEditor = me.up('pveCorosyncLinkEditor');
+
+	    if (!form.validating) {
+		// avoid recursion/double validation by setting temporary states
+		curField.validating = true;
+		form.validating = true;
+
+		// validate all other fields as well, to always mark both
+		// parties involved in a 'duplicate' error
+		form.isValid();
+
+		form.validating = false;
+		curField.validating = false;
+	    } else if (curField.validating) {
+		// we'll be validated by the original call in the other
+		// if-branch, avoid double work
+		return true;
+	    }
+
+	    if (val === undefined || (val instanceof String && val.length === 0)) {
+		// let this be caught by allowBlank, if at all
+		return true;
+	    }
+
+	    let allFields = linkEditor.query(queryString);
+	    let err = undefined;
+	    Ext.Array.each(allFields, field => {
+		if (field != curField && field.getValue() == val) {
+		    err = errorMsg;
+		    return false; // break
+		}
+	    });
+
+	    return err || true;
+	};
+    }
+});
+
+Ext.define('PVE.form.CorosyncLinkEditor', {
+    extend: 'Ext.panel.Panel',
+    xtype: 'pveCorosyncLinkEditor',
+
+    controller: 'pveCorosyncLinkEditorController',
+
+    // only initial config, use setter otherwise
+    allowNumberEdit: true,
+
+    viewModel: {
+	data: {
+	    linkCount: 0,
+	    maxLinkCount: 8,
+	    networks: null,
+	    allowNumberEdit: true,
+	    infoText: ''
+	},
+	formulas: {
+	    addDisabled: function(get) {
+		return !get('allowNumberEdit') ||
+		    get('linkCount') >= get('maxLinkCount');
+	    },
+	    dockHidden: function(get) {
+		return !(get('allowNumberEdit') || get('infoText'));
+	    }
+	}
+    },
+
+    dockedItems: [{
+	xtype: 'toolbar',
+	dock: 'bottom',
+	defaultButtonUI : 'default',
+	bind: {
+	    hidden: '{dockHidden}'
+	},
+	items: [
+	    {
+		xtype: 'button',
+		text: gettext('Add'),
+		bind: {
+		    disabled: '{addDisabled}',
+		    hidden: '{!allowNumberEdit}'
+		},
+		handler: 'addEmptyLink'
+	    },
+	    {
+		xtype: 'label',
+		bind: {
+		    text: '{infoText}'
+		}
+	    }
+	]
+    }],
+
+    setInfoText: function(text) {
+	let me = this;
+	let vm = me.getViewModel();
+
+	vm.set('infoText', text || '');
+    },
+
+    setLinks: function(links) {
+	let me = this;
+	let controller = me.getController();
+	let vm = me.getViewModel();
+
+	me.removeAll();
+	vm.set('linkCount', 0);
+
+	Ext.Array.each(links, link => {
+	    controller.addLink(link['number'], link['value'], link['allowBlank']);
+	});
+    },
+
+    setDefaultLinks: function() {
+	let me = this;
+	let controller = me.getController();
+	let vm = me.getViewModel();
+
+	me.removeAll();
+	vm.set('linkCount', 0);
+	controller.addLink();
+    },
+
+    // clears all links
+    setAllowNumberEdit: function(allow) {
+	let me = this;
+	let vm = me.getViewModel();
+	vm.set('allowNumberEdit', allow);
+	me.removeAll();
+	vm.set('linkCount', 0);
+    },
+
+    items: [{
+	// No links is never a valid scenario, but can occur during a slow load
+	xtype: 'hiddenfield',
+	submitValue: false,
+	isValid: function() {
+	    let me = this;
+	    let vm = me.up('pveCorosyncLinkEditor').getViewModel();
+	    return vm.get('linkCount') > 0;
+	}
+    }],
+
+    initComponent: function() {
+	let me = this;
+	let vm = me.getViewModel();
+	let controller = me.getController();
+
+	vm.set('allowNumberEdit', me.allowNumberEdit);
+
+	me.callParent();
+
+	// Request local node networks to pre-populate first link.
+	Proxmox.Utils.API2Request({
+	    url: '/nodes/localhost/network',
+	    method: 'GET',
+	    waitMsgTarget: me,
+	    success: response => {
+		let data = response.result.data;
+		if (data.length > 0) {
+		    data.sort((a, b) => a.iface.localeCompare(b.iface));
+		    let addresses = [];
+		    for (let net of data) {
+			if (net.address) {
+			    addresses.push(net.address);
+			}
+			if (net.address6) {
+			    addresses.push(net.address6);
+			}
+		    }
+
+		    vm.set('networks', addresses);
+		}
+
+		// Always have at least one link, but account for delay in API,
+		// someone might have called 'setLinks' in the meantime -
+		// except if 'allowNumberEdit' is false, in which case we're
+		// probably waiting for the user to input the join info
+		if (vm.get('allowNumberEdit')) {
+		    controller.addLinkIfEmpty();
+		}
+	    },
+	    failure: () => {
+		if (vm.get('allowNumberEdit')) {
+		    controller.addLinkIfEmpty();
+		}
+	    }
+	});
+    }
+});
+
-- 
2.25.2





More information about the pve-devel mailing list