[pve-devel] [PATCH FOLLOW-UP manager v2 1/1] ui: add overview and edit components for node affinity rules
Michael Köppl
m.koeppl at proxmox.com
Wed Jul 23 17:35:24 CEST 2025
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 <m.koeppl at proxmox.com>
Originally-by: Daniel Kral <d.kral at proxmox.com>
---
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
More information about the pve-devel
mailing list