diff --git a/www/manager6/Makefile b/www/manager6/Makefile index ff93b224..694876f9 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -244,6 +244,7 @@ JSSRC= \ dc/NodeView.js \ dc/Cluster.js \ dc/ClusterEdit.js \ + dc/CorosyncLinkEdit.js \ dc/PermissionView.js \ dc/TokenView.js \ dc/TokenEdit.js \ diff --git a/www/manager6/dc/ClusterEdit.js b/www/manager6/dc/ClusterEdit.js index bf63c588..53cd0efc 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(); - var info = { + 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); + + 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(); + } + } + }); + } +}); +