[pve-devel] [PATCH manager v2 5/5] ui: ha: add ha rules components and menu entry
Daniel Kral
d.kral at proxmox.com
Fri Jun 20 16:31:48 CEST 2025
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.kral at 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
More information about the pve-devel
mailing list