From mboxrd@z Thu Jan  1 00:00:00 1970
Return-Path: <pve-devel-bounces@lists.proxmox.com>
Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9])
	by lore.proxmox.com (Postfix) with ESMTPS id E32AC1FF164
	for <inbox@lore.proxmox.com>; Fri, 20 Jun 2025 16:36:09 +0200 (CEST)
Received: from firstgate.proxmox.com (localhost [127.0.0.1])
	by firstgate.proxmox.com (Proxmox) with ESMTP id A58DEEC85;
	Fri, 20 Jun 2025 16:33:05 +0200 (CEST)
From: Daniel Kral <d.kral@proxmox.com>
To: pve-devel@lists.proxmox.com
Date: Fri, 20 Jun 2025 16:31:48 +0200
Message-Id: <20250620143148.218469-41-d.kral@proxmox.com>
X-Mailer: git-send-email 2.39.5
In-Reply-To: <20250620143148.218469-1-d.kral@proxmox.com>
References: <20250620143148.218469-1-d.kral@proxmox.com>
MIME-Version: 1.0
X-SPAM-LEVEL: Spam detection results:  0
 AWL 0.012 Adjusted score from AWL reputation of From: address
 BAYES_00                 -1.9 Bayes spam probability is 0 to 1%
 DMARC_MISSING             0.1 Missing DMARC policy
 KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment
 SPF_HELO_NONE           0.001 SPF: HELO does not publish an SPF Record
 SPF_PASS               -0.001 SPF: sender matches SPF record
Subject: [pve-devel] [PATCH manager v2 5/5] ui: ha: add ha rules components
 and menu entry
X-BeenThere: pve-devel@lists.proxmox.com
X-Mailman-Version: 2.1.29
Precedence: list
List-Id: Proxmox VE development discussion <pve-devel.lists.proxmox.com>
List-Unsubscribe: <https://lists.proxmox.com/cgi-bin/mailman/options/pve-devel>, 
 <mailto:pve-devel-request@lists.proxmox.com?subject=unsubscribe>
List-Archive: <http://lists.proxmox.com/pipermail/pve-devel/>
List-Post: <mailto:pve-devel@lists.proxmox.com>
List-Help: <mailto:pve-devel-request@lists.proxmox.com?subject=help>
List-Subscribe: <https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel>, 
 <mailto:pve-devel-request@lists.proxmox.com?subject=subscribe>
Reply-To: Proxmox VE development discussion <pve-devel@lists.proxmox.com>
Content-Type: text/plain; charset="us-ascii"
Content-Transfer-Encoding: 7bit
Errors-To: pve-devel-bounces@lists.proxmox.com
Sender: "pve-devel" <pve-devel-bounces@lists.proxmox.com>

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@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