Add components for basic CRUD operations on the HA rules and viewing potentially errors of contradictory HA rules, which are currently only possible by manually editing the file right now.
The feature flag 'use-location-rules' controls whether location rules can be created from the web interface. Location rules are not removed if the flag is unset as the API is expected to remove these entries. Signed-off-by: Daniel Kral <d.k...@proxmox.com> --- changes since v1: - NEW! www/manager6/Makefile | 7 + www/manager6/dc/Config.js | 23 +- www/manager6/ha/RuleEdit.js | 149 +++++++++++++ www/manager6/ha/RuleErrorsModal.js | 50 +++++ www/manager6/ha/Rules.js | 228 ++++++++++++++++++++ www/manager6/ha/rules/ColocationRuleEdit.js | 24 +++ www/manager6/ha/rules/ColocationRules.js | 31 +++ www/manager6/ha/rules/LocationRuleEdit.js | 145 +++++++++++++ www/manager6/ha/rules/LocationRules.js | 36 ++++ 9 files changed, 686 insertions(+), 7 deletions(-) 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/ColocationRuleEdit.js create mode 100644 www/manager6/ha/rules/ColocationRules.js create mode 100644 www/manager6/ha/rules/LocationRuleEdit.js create mode 100644 www/manager6/ha/rules/LocationRules.js diff --git a/www/manager6/Makefile b/www/manager6/Makefile index ca641e34..636d8edb 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -147,8 +147,15 @@ 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/ColocationRuleEdit.js \ + ha/rules/ColocationRules.js \ + ha/rules/LocationRuleEdit.js \ + ha/rules/LocationRules.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 7e39c85f..690213fb 100644 --- a/www/manager6/dc/Config.js +++ b/www/manager6/dc/Config.js @@ -181,13 +181,22 @@ Ext.define('PVE.dc.Config', { }); } - me.items.push({ - title: gettext('Fencing'), - groups: ['ha'], - iconCls: 'fa fa-bolt', - xtype: 'pveFencingView', - itemId: 'ha-fencing', - }); + me.items.push( + { + title: gettext('Rules'), + groups: ['ha'], + xtype: 'pveHARulesView', + iconCls: 'fa fa-gears', + itemId: 'ha-rules', + }, + { + title: gettext('Fencing'), + groups: ['ha'], + iconCls: 'fa fa-bolt', + xtype: 'pveFencingView', + itemId: 'ha-fencing', + }, + ); // always show on initial load, will be hiddea later if the SDN API calls don't exist, // else it won't be shown at first if the user initially loads with DC selected if (PVE.SDNInfo || PVE.SDNInfo === undefined) { diff --git a/www/manager6/ha/RuleEdit.js b/www/manager6/ha/RuleEdit.js new file mode 100644 index 00000000..a6c2a7d2 --- /dev/null +++ b/www/manager6/ha/RuleEdit.js @@ -0,0 +1,149 @@ +Ext.define('PVE.ha.RuleInputPanel', { + extend: 'Proxmox.panel.InputPanel', + + onlineHelp: 'ha_manager_rules', + + formatServiceListString: function (services) { + let me = this; + + return services.map((vmid) => { + if (me.servicesStore.getById(`qemu/${vmid}`)) { + return `vm:${vmid}`; + } else if (me.servicesStore.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; + } + + if (!values.enabled) { + values.state = 'disabled'; + } else { + values.state = 'enabled'; + } + delete values.enabled; + + values.services = me.formatServiceListString(values.services); + + return values; + }, + + initComponent: function () { + let me = this; + + let servicesStore = 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, { + servicesStore: servicesStore, + }); + + me.column1.unshift( + { + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'rule', + value: me.ruleId || '', + fieldLabel: 'ID', + allowBlank: false, + }, + { + xtype: 'vmComboSelector', + name: 'services', + fieldLabel: gettext('Services'), + store: me.servicesStore, + 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.services = values.services + .split(',') + .map((service) => service.split(':')[1]); + + values.enabled = values.state === 'enabled'; + + inputPanel.setValues(values); + }, + }); + } + }, +}); diff --git a/www/manager6/ha/RuleErrorsModal.js b/www/manager6/ha/RuleErrorsModal.js new file mode 100644 index 00000000..aac1ef87 --- /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 00000000..d69aa3b2 --- /dev/null +++ b/www/manager6/ha/Rules.js @@ -0,0 +1,228 @@ +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('State'), + xtype: 'actioncolumn', + width: 25, + align: 'center', + dataIndex: 'state', + items: [ + { + isActionDisabled: (table, rowIndex, colIndex, item, { data }) => + data.state !== 'contradictory', + handler: (table, rowIndex, colIndex, item, event, { data }) => { + Ext.create('PVE.ha.RuleErrorsModal', { + autoShow: true, + errors: data.errors ?? {}, + }); + }, + getTip: (value) => { + switch (value) { + case 'contradictory': + return gettext('Errors'); + case 'disabled': + return gettext('Disabled'); + default: + return gettext('Enabled'); + } + }, + getClass: (value) => { + let iconName = 'check'; + + if (value === 'contradictory') { + iconName = 'exclamation-triangle'; + } else if (value === 'disabled') { + 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', + }, + + viewModel: { + data: { + isHALocationEnabled: false, + }, + formulas: { + showHALocation: (get) => get('isHALocationEnabled'), + }, + }, + + items: [ + { + title: gettext('HA Location'), + xtype: 'pveHALocationRulesView', + flex: 1, + border: 0, + bind: { + hidden: '{!isHALocationEnabled}', + }, + }, + { + xtype: 'splitter', + collapsible: false, + performCollapse: false, + }, + { + title: gettext('HA Colocation'), + xtype: 'pveHAColocationRulesView', + flex: 1, + border: 0, + }, + ], + + initComponent: function () { + let me = this; + + let viewModel = me.getViewModel(); + + PVE.Utils.getHALocationFeatureStatus().then((isHALocationEnabled) => { + viewModel.set('isHALocationEnabled', isHALocationEnabled); + }); + + me.callParent(); + }, + }, + function () { + Ext.define('pve-ha-rules', { + extend: 'Ext.data.Model', + fields: [ + 'rule', + 'type', + 'nodes', + 'state', + 'digest', + 'comment', + 'affinity', + 'services', + 'conflicts', + { + name: 'strict', + type: 'boolean', + }, + ], + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/ha/rules', + }, + idProperty: 'rule', + }); + }, +); diff --git a/www/manager6/ha/rules/ColocationRuleEdit.js b/www/manager6/ha/rules/ColocationRuleEdit.js new file mode 100644 index 00000000..d8c5223c --- /dev/null +++ b/www/manager6/ha/rules/ColocationRuleEdit.js @@ -0,0 +1,24 @@ +Ext.define('PVE.ha.rules.ColocationInputPanel', { + extend: 'PVE.ha.RuleInputPanel', + + initComponent: function () { + let me = this; + + me.column1 = []; + + me.column2 = [ + { + xtype: 'proxmoxKVComboBox', + name: 'affinity', + fieldLabel: gettext('Affinity'), + allowBlank: false, + comboItems: [ + ['separate', gettext('Keep separate')], + ['together', gettext('Keep together')], + ], + }, + ]; + + me.callParent(); + }, +}); diff --git a/www/manager6/ha/rules/ColocationRules.js b/www/manager6/ha/rules/ColocationRules.js new file mode 100644 index 00000000..f8c410de --- /dev/null +++ b/www/manager6/ha/rules/ColocationRules.js @@ -0,0 +1,31 @@ +Ext.define('PVE.ha.ColocationRulesView', { + extend: 'PVE.ha.RulesBaseView', + alias: 'widget.pveHAColocationRulesView', + + title: gettext('HA Colocation'), + ruleType: 'colocation', + inputPanel: 'ColocationInputPanel', + faIcon: 'link', + + stateful: true, + stateId: 'grid-ha-colocation-rules', + + initComponent: function () { + let me = this; + + me.columns = [ + { + header: gettext('Affinity'), + flex: 1, + dataIndex: 'affinity', + }, + { + header: gettext('Services'), + flex: 1, + dataIndex: 'services', + }, + ]; + + me.callParent(); + }, +}); diff --git a/www/manager6/ha/rules/LocationRuleEdit.js b/www/manager6/ha/rules/LocationRuleEdit.js new file mode 100644 index 00000000..cd540a18 --- /dev/null +++ b/www/manager6/ha/rules/LocationRuleEdit.js @@ -0,0 +1,145 @@ +Ext.define('PVE.ha.rules.LocationInputPanel', { + 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 services must be restricted to the nodes.'), + }, + uncheckedValue: 0, + defaultValue: 0, + }, + ]; + + /* 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/LocationRules.js b/www/manager6/ha/rules/LocationRules.js new file mode 100644 index 00000000..6201a5bf --- /dev/null +++ b/www/manager6/ha/rules/LocationRules.js @@ -0,0 +1,36 @@ +Ext.define('PVE.ha.LocationRulesView', { + extend: 'PVE.ha.RulesBaseView', + alias: 'widget.pveHALocationRulesView', + + ruleType: 'location', + ruleTitle: gettext('HA Location'), + inputPanel: 'LocationInputPanel', + faIcon: 'map-pin', + + stateful: true, + stateId: 'grid-ha-location-rules', + + initComponent: function () { + let me = this; + + me.columns = [ + { + header: gettext('Strict'), + width: 50, + dataIndex: 'strict', + }, + { + header: gettext('Services'), + flex: 1, + dataIndex: 'services', + }, + { + header: gettext('Nodes'), + flex: 1, + dataIndex: 'nodes', + }, + ]; + + me.callParent(); + }, +}); -- 2.39.5 _______________________________________________ pve-devel mailing list pve-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel