[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