public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: "Michael Köppl" <m.koeppl@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [pve-devel] [PATCH FOLLOW-UP manager v2 1/1] ui: add overview and edit components for node affinity rules
Date: Wed, 23 Jul 2025 17:35:24 +0200	[thread overview]
Message-ID: <20250723153524.288508-6-m.koeppl@proxmox.com> (raw)
In-Reply-To: <20250723153524.288508-1-m.koeppl@proxmox.com>

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@proxmox.com>
Originally-by: Daniel Kral <d.kral@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



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel

      parent reply	other threads:[~2025-07-23 15:34 UTC|newest]

Thread overview: 6+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2025-07-23 15:35 [pve-devel] [PATCH FOLLOW-UP ha-manager/manager v2 0/5] fix rule migration errors and add UI components Michael Köppl
2025-07-23 15:35 ` [pve-devel] [PATCH FOLLOW-UP ha-manager v2 1/4] config: use entire path to groups.cfg path in delete_group_config Michael Köppl
2025-07-23 15:35 ` [pve-devel] [PATCH FOLLOW-UP ha-manager v2 2/4] ha: decode JSON string with version info returned by get_node_kv Michael Köppl
2025-07-23 15:35 ` [pve-devel] [PATCH FOLLOW-UP ha-manager v2 3/4] api: return disable field for rules endpoint Michael Köppl
2025-07-23 15:35 ` [pve-devel] [PATCH FOLLOW-UP ha-manager v2 4/4] ha: check for actual disable value during rule checks Michael Köppl
2025-07-23 15:35 ` Michael Köppl [this message]

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20250723153524.288508-6-m.koeppl@proxmox.com \
    --to=m.koeppl@proxmox.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal