Add the rules overview that replaces the groups overview and displays node affinity rules. In addition, the edit dialogs for node affinity rules are added, allowing both creation and editing of node affinity rules.
Signed-off-by: Michael Köppl <[email protected]> Originally-by: Daniel Kral <[email protected]> --- This is based on the v2 version of the series [0], but I incorporated the changes suggested in the cover letter of v3 and the review comments on v2. I added this since this patch was mentioned in manager 2/3 [1] of v3, but it seems it was accidentally left out during splitting up the series. Based on this, the rules can be seen and manipulated after successful migration from groups to rules. The first patch of part 2 of the v3 series makes changes to the files added in this patch and should apply cleanly. www/manager6/Makefile | 5 + www/manager6/dc/Config.js | 7 + www/manager6/ha/Groups.js | 4 +- www/manager6/ha/RuleEdit.js | 145 +++++++++++++ www/manager6/ha/RuleErrorsModal.js | 50 +++++ www/manager6/ha/Rules.js | 193 ++++++++++++++++++ www/manager6/ha/rules/NodeAffinityRuleEdit.js | 151 ++++++++++++++ www/manager6/ha/rules/NodeAffinityRules.js | 36 ++++ 8 files changed, 590 insertions(+), 1 deletion(-) create mode 100644 www/manager6/ha/RuleEdit.js create mode 100644 www/manager6/ha/RuleErrorsModal.js create mode 100644 www/manager6/ha/Rules.js create mode 100644 www/manager6/ha/rules/NodeAffinityRuleEdit.js create mode 100644 www/manager6/ha/rules/NodeAffinityRules.js diff --git a/www/manager6/Makefile b/www/manager6/Makefile index 84a8b4d00..dc0291c54 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -148,8 +148,13 @@ JSSRC= \ ha/Groups.js \ ha/ResourceEdit.js \ ha/Resources.js \ + ha/RuleEdit.js \ + ha/RuleErrorsModal.js \ + ha/Rules.js \ ha/Status.js \ ha/StatusView.js \ + ha/rules/NodeAffinityRuleEdit.js \ + ha/rules/NodeAffinityRules.js \ dc/ACLView.js \ dc/ACMEClusterView.js \ dc/AuthEditBase.js \ diff --git a/www/manager6/dc/Config.js b/www/manager6/dc/Config.js index 76c9a6ca1..5140efec6 100644 --- a/www/manager6/dc/Config.js +++ b/www/manager6/dc/Config.js @@ -176,6 +176,13 @@ Ext.define('PVE.dc.Config', { iconCls: 'fa fa-object-group', itemId: 'ha-groups', }, + { + title: gettext('Rules'), + groups: ['ha'], + xtype: 'pveHARulesView', + iconCls: 'fa fa-gears', + itemId: 'ha-rules', + }, { title: gettext('Fencing'), groups: ['ha'], diff --git a/www/manager6/ha/Groups.js b/www/manager6/ha/Groups.js index 6b4958f01..6c46a40fa 100644 --- a/www/manager6/ha/Groups.js +++ b/www/manager6/ha/Groups.js @@ -58,7 +58,7 @@ Ext.define('PVE.ha.GroupsView', { tbar: [ { text: gettext('Create'), - disabled: !caps.nodes['Sys.Console'], + disabled: true, handler: function () { Ext.create('PVE.ha.GroupEdit', { listeners: { @@ -112,6 +112,8 @@ Ext.define('PVE.ha.GroupsView', { }, }); + me.emptyText = gettext('HA Node Affinity rules are used instead of HA Groups'); + me.callParent(); }, }); diff --git a/www/manager6/ha/RuleEdit.js b/www/manager6/ha/RuleEdit.js new file mode 100644 index 000000000..5bfe042ef --- /dev/null +++ b/www/manager6/ha/RuleEdit.js @@ -0,0 +1,145 @@ +Ext.define('PVE.ha.RuleInputPanel', { + extend: 'Proxmox.panel.InputPanel', + + onlineHelp: 'ha_manager_rules', + + formatServiceListString: function (resources) { + let me = this; + + return resources.map((vmid) => { + if (me.resourcesStore.getById(`qemu/${vmid}`)) { + return `vm:${vmid}`; + } else if (me.resourcesStore.getById(`lxc/${vmid}`)) { + return `ct:${vmid}`; + } else { + Ext.Msg.alert(gettext('Error'), `Could not find resource type for ${vmid}`); + throw `Unknown resource type: ${vmid}`; + } + }); + }, + + onGetValues: function (values) { + let me = this; + + values.type = me.ruleType; + + if (!me.isCreate) { + delete values.rule; + } + + values.disable = 1 - values.enabled; + delete values.enabled; + + values.resources = me.formatServiceListString(values.resources); + + return values; + }, + + initComponent: function () { + let me = this; + + let resourcesStore = Ext.create('Ext.data.Store', { + model: 'PVEResources', + autoLoad: true, + sorters: 'vmid', + filters: [ + { + property: 'type', + value: /lxc|qemu/, + }, + { + property: 'hastate', + operator: '!=', + value: 'unmanaged', + }, + ], + }); + + Ext.apply(me, { + resourcesStore: resourcesStore, + }); + + me.column1.unshift( + { + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'rule', + value: me.ruleId || '', + fieldLabel: 'ID', + allowBlank: false, + }, + { + xtype: 'vmComboSelector', + name: 'resources', + fieldLabel: gettext('Resources'), + store: me.resourcesStore, + allowBlank: false, + autoSelect: false, + multiSelect: true, + validateExists: true, + }, + ); + + me.column2 = me.column2 ?? []; + + me.column2.unshift({ + xtype: 'proxmoxcheckbox', + name: 'enabled', + fieldLabel: gettext('Enable'), + uncheckedValue: 0, + defaultValue: 1, + checked: true, + }); + + me.callParent(); + }, +}); + +Ext.define('PVE.ha.RuleEdit', { + extend: 'Proxmox.window.Edit', + + defaultFocus: undefined, // prevent the vmComboSelector to be expanded when focusing the window + + initComponent: function () { + let me = this; + + me.isCreate = !me.ruleId; + + if (me.isCreate) { + me.url = '/api2/extjs/cluster/ha/rules'; + me.method = 'POST'; + } else { + me.url = `/api2/extjs/cluster/ha/rules/${me.ruleId}`; + me.method = 'PUT'; + } + + let inputPanel = Ext.create(me.panelType, { + ruleId: me.ruleId, + ruleType: me.ruleType, + isCreate: me.isCreate, + }); + + Ext.apply(me, { + subject: me.panelName, + isAdd: true, + items: [inputPanel], + }); + + me.callParent(); + + if (!me.isCreate) { + me.load({ + success: (response, options) => { + let values = response.result.data; + + values.resources = values.resources + .split(',') + .map((service) => service.split(':')[1]); + + values.enabled = !values.disable; + + inputPanel.setValues(values); + }, + }); + } + }, +}); diff --git a/www/manager6/ha/RuleErrorsModal.js b/www/manager6/ha/RuleErrorsModal.js new file mode 100644 index 000000000..aac1ef873 --- /dev/null +++ b/www/manager6/ha/RuleErrorsModal.js @@ -0,0 +1,50 @@ +Ext.define('PVE.ha.RuleErrorsModal', { + extend: 'Ext.window.Window', + alias: ['widget.pveHARulesErrorsModal'], + mixins: ['Proxmox.Mixin.CBind'], + + modal: true, + scrollable: true, + resizable: false, + + title: gettext('Rule errors'), + + initComponent: function () { + let me = this; + + let renderHARuleErrors = (errors) => { + if (!errors) { + return gettext('HA Rule has no errors.'); + } + + let errorListItemsHtml = ''; + + for (let [opt, messages] of Object.entries(errors)) { + errorListItemsHtml += messages + .map((message) => `<li>${Ext.htmlEncode(`${opt}: ${message}`)}</li>`) + .join(''); + } + + return `<div> + <p>${gettext('The HA rule has the following errors:')}</p> + <ul>${errorListItemsHtml}</ul> + </div>`; + }; + + Ext.apply(me, { + modal: true, + border: false, + layout: 'fit', + items: [ + { + xtype: 'displayfield', + padding: 20, + scrollable: true, + value: renderHARuleErrors(me.errors), + }, + ], + }); + + me.callParent(); + }, +}); diff --git a/www/manager6/ha/Rules.js b/www/manager6/ha/Rules.js new file mode 100644 index 000000000..ef861a3ff --- /dev/null +++ b/www/manager6/ha/Rules.js @@ -0,0 +1,193 @@ +Ext.define('PVE.ha.RulesBaseView', { + extend: 'Ext.grid.GridPanel', + + initComponent: function () { + let me = this; + + if (!me.ruleType) { + throw 'no rule type given'; + } + + let store = new Ext.data.Store({ + model: 'pve-ha-rules', + autoLoad: true, + filters: [ + { + property: 'type', + value: me.ruleType, + }, + ], + }); + + let reloadStore = () => store.load(); + + let sm = Ext.create('Ext.selection.RowModel', {}); + + let createRuleEditWindow = (ruleId) => { + if (!me.inputPanel) { + throw `no editor registered for ha rule type: ${me.ruleType}`; + } + + Ext.create('PVE.ha.RuleEdit', { + panelType: `PVE.ha.rules.${me.inputPanel}`, + panelName: me.ruleTitle, + ruleType: me.ruleType, + ruleId: ruleId, + autoShow: true, + listeners: { + destroy: reloadStore, + }, + }); + }; + + let runEditor = () => { + let rec = sm.getSelection()[0]; + if (!rec) { + return; + } + let { rule } = rec.data; + createRuleEditWindow(rule); + }; + + let editButton = Ext.create('Proxmox.button.Button', { + text: gettext('Edit'), + disabled: true, + selModel: sm, + handler: runEditor, + }); + + let removeButton = Ext.create('Proxmox.button.StdRemoveButton', { + selModel: sm, + baseurl: '/cluster/ha/rules/', + callback: reloadStore, + }); + + Ext.apply(me, { + store: store, + selModel: sm, + viewConfig: { + trackOver: false, + }, + emptyText: Ext.String.format(gettext('No {0} rules configured.'), me.ruleTitle), + tbar: [ + { + text: gettext('Add'), + handler: () => createRuleEditWindow(), + }, + editButton, + removeButton, + ], + listeners: { + activate: reloadStore, + itemdblclick: runEditor, + }, + }); + + me.columns.unshift( + { + header: gettext('Enabled'), + xtype: 'actioncolumn', + width: 65, + align: 'center', + dataIndex: 'disable', + items: [ + { + isActionDisabled: (table, rowIndex, colIndex, item, { data }) => + !data.errors, + handler: (table, rowIndex, colIndex, item, event, { data }) => { + Ext.create('PVE.ha.RuleErrorsModal', { + autoShow: true, + errors: data.errors ?? {}, + }); + }, + getTip: (value, _m, { data }) => { + if (data.errors) { + return gettext('Errors'); + } + + if (!value) { + return gettext('Enabled'); + } else { + return gettext('Disabled'); + } + }, + getClass: (value, _m, { data }) => { + let iconName = 'check'; + + if (data.errors) { + iconName = 'exclamation-triangle'; + } else if (value) { + iconName = 'minus'; + } + + return `fa fa-${iconName}`; + }, + }, + ], + }, + { + header: gettext('Rule'), + width: 200, + dataIndex: 'rule', + }, + ); + + me.columns.push({ + header: gettext('Comment'), + flex: 1, + renderer: Ext.String.htmlEncode, + dataIndex: 'comment', + }); + + me.callParent(); + }, +}); + +Ext.define( + 'PVE.ha.RulesView', + { + extend: 'Ext.panel.Panel', + alias: 'widget.pveHARulesView', + mixins: ['Proxmox.Mixin.CBind'], + + onlineHelp: 'ha_manager_rules', + + layout: { + type: 'vbox', + align: 'stretch', + }, + + items: [ + { + title: gettext('HA Node Affinity'), + xtype: 'pveHANodeAffinityRulesView', + flex: 1, + border: 0, + }, + ], + }, + function () { + Ext.define('pve-ha-rules', { + extend: 'Ext.data.Model', + fields: [ + 'rule', + 'type', + 'nodes', + 'errors', + 'disable', + 'comment', + 'resources', + { + name: 'strict', + type: 'boolean', + }, + 'digest', + ], + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/ha/rules', + }, + idProperty: 'rule', + }); + }, +); diff --git a/www/manager6/ha/rules/NodeAffinityRuleEdit.js b/www/manager6/ha/rules/NodeAffinityRuleEdit.js new file mode 100644 index 000000000..497831f7b --- /dev/null +++ b/www/manager6/ha/rules/NodeAffinityRuleEdit.js @@ -0,0 +1,151 @@ +Ext.define('PVE.ha.rules.NodeAffinityInputPanel', { + extend: 'PVE.ha.RuleInputPanel', + + initComponent: function () { + let me = this; + + me.column1 = [ + { + xtype: 'proxmoxcheckbox', + name: 'strict', + fieldLabel: gettext('Strict'), + autoEl: { + tag: 'div', + 'data-qtip': gettext('Enable if the resources must be restricted to the nodes.'), + }, + uncheckedValue: 0, + defaultValue: 0, + }, + { + xtype: 'textfield', + fieldLabel: gettext('Comment'), + name: 'comment', + allowBlank: true, + }, + ]; + + /* TODO Code copied from GroupEdit, should be factored out in component */ + let update_nodefield, update_node_selection; + + let sm = Ext.create('Ext.selection.CheckboxModel', { + mode: 'SIMPLE', + listeners: { + selectionchange: function (model, selected) { + update_nodefield(selected); + }, + }, + }); + + let store = Ext.create('Ext.data.Store', { + fields: ['node', 'mem', 'cpu', 'priority'], + data: PVE.data.ResourceStore.getNodes(), // use already cached data to avoid an API call + proxy: { + type: 'memory', + reader: { type: 'json' }, + }, + sorters: [ + { + property: 'node', + direction: 'ASC', + }, + ], + }); + + var nodegrid = Ext.createWidget('grid', { + store: store, + border: true, + height: 300, + selModel: sm, + columns: [ + { + header: gettext('Node'), + flex: 1, + dataIndex: 'node', + }, + { + header: gettext('Memory usage') + ' %', + renderer: PVE.Utils.render_mem_usage_percent, + sortable: true, + width: 150, + dataIndex: 'mem', + }, + { + header: gettext('CPU usage'), + renderer: Proxmox.Utils.render_cpu, + sortable: true, + width: 150, + dataIndex: 'cpu', + }, + { + header: gettext('Priority'), + xtype: 'widgetcolumn', + dataIndex: 'priority', + sortable: true, + stopSelection: true, + widget: { + xtype: 'proxmoxintegerfield', + minValue: 0, + maxValue: 1000, + isFormField: false, + listeners: { + change: function (numberfield, value, old_value) { + let record = numberfield.getWidgetRecord(); + record.set('priority', value); + update_nodefield(sm.getSelection()); + record.commit(); + }, + }, + }, + }, + ], + }); + + let nodefield = Ext.create('Ext.form.field.Hidden', { + name: 'nodes', + value: '', + listeners: { + change: function (field, value) { + update_node_selection(value); + }, + }, + isValid: function () { + let value = this.getValue(); + return value && value.length !== 0; + }, + }); + + update_node_selection = function (string) { + sm.deselectAll(true); + + string.split(',').forEach(function (e, idx, array) { + let [node, priority] = e.split(':'); + store.each(function (record) { + if (record.get('node') === node) { + sm.select(record, true); + record.set('priority', priority); + record.commit(); + } + }); + }); + nodegrid.reconfigure(store); + }; + + update_nodefield = function (selected) { + let nodes = selected + .map(({ data }) => data.node + (data.priority ? `:${data.priority}` : '')) + .join(','); + + // nodefield change listener calls us again, which results in a + // endless recursion, suspend the event temporary to avoid this + nodefield.suspendEvent('change'); + nodefield.setValue(nodes); + nodefield.resumeEvent('change'); + }; + + me.column2 = [nodefield]; + + me.columnB = [nodegrid]; + + me.callParent(); + }, +}); diff --git a/www/manager6/ha/rules/NodeAffinityRules.js b/www/manager6/ha/rules/NodeAffinityRules.js new file mode 100644 index 000000000..6bac4d7d9 --- /dev/null +++ b/www/manager6/ha/rules/NodeAffinityRules.js @@ -0,0 +1,36 @@ +Ext.define('PVE.ha.NodeAffinityRulesView', { + extend: 'PVE.ha.RulesBaseView', + alias: 'widget.pveHANodeAffinityRulesView', + + ruleType: 'node-affinity', + ruleTitle: gettext('HA Node Affinity'), + inputPanel: 'NodeAffinityInputPanel', + faIcon: 'map-pin', + + stateful: true, + stateId: 'grid-ha-node-affinity-rules', + + initComponent: function () { + let me = this; + + me.columns = [ + { + header: gettext('Strict'), + width: 50, + dataIndex: 'strict', + }, + { + header: gettext('Resources'), + flex: 1, + dataIndex: 'resources', + }, + { + header: gettext('Nodes'), + flex: 1, + dataIndex: 'nodes', + }, + ]; + + me.callParent(); + }, +}); -- 2.47.2 _______________________________________________ pve-devel mailing list [email protected] https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel
