From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by lists.proxmox.com (Postfix) with ESMTPS id CC3BB9FB26 for ; Tue, 7 Nov 2023 11:20:02 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id ACC6430453 for ; Tue, 7 Nov 2023 11:19:13 +0100 (CET) Received: from proxmox-new.maurer-it.com (proxmox-new.maurer-it.com [94.136.29.106]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by firstgate.proxmox.com (Proxmox) with ESMTPS for ; Tue, 7 Nov 2023 11:19:10 +0100 (CET) Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1]) by proxmox-new.maurer-it.com (Proxmox) with ESMTP id C629246985 for ; Tue, 7 Nov 2023 11:19:08 +0100 (CET) From: Lukas Wagner To: pve-devel@lists.proxmox.com Date: Tue, 7 Nov 2023 11:18:25 +0100 Message-Id: <20231107101827.340100-26-l.wagner@proxmox.com> X-Mailer: git-send-email 2.39.2 In-Reply-To: <20231107101827.340100-1-l.wagner@proxmox.com> References: <20231107101827.340100-1-l.wagner@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -0.016 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 T_SCC_BODY_TEXT_LINE -0.01 - Subject: [pve-devel] [PATCH proxmox-widget-toolkit 25/27] notification: matcher: add UI for matcher editing X-BeenThere: pve-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox VE development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , X-List-Received-Date: Tue, 07 Nov 2023 10:20:02 -0000 This modifies the old filter edit window in the following ways: - Split content into multiple panels - Name and comment in the first tab - Match rules in a tree-structure in the second tab - Targets to notify in the third tab Signed-off-by: Lukas Wagner --- Notes: The code binding the match rule tree structure to the editable fields could definitely be a bit cleaner. I think this is the first time that we have used such a pattern, so there there was much experimentation needed to get this working. I plan to revisit it and clean up a bit later, I wanted to get the notification system changes on the list ASAP. src/window/NotificationMatcherEdit.js | 867 ++++++++++++++++++++++++-- 1 file changed, 820 insertions(+), 47 deletions(-) diff --git a/src/window/NotificationMatcherEdit.js b/src/window/NotificationMatcherEdit.js index a014f3e..c6f0726 100644 --- a/src/window/NotificationMatcherEdit.js +++ b/src/window/NotificationMatcherEdit.js @@ -1,6 +1,6 @@ -Ext.define('Proxmox.panel.NotificationMatcherEditPanel', { +Ext.define('Proxmox.panel.NotificationMatcherGeneralPanel', { extend: 'Proxmox.panel.InputPanel', - xtype: 'pmxNotificationMatcherEditPanel', + xtype: 'pmxNotificationMatcherGeneralPanel', mixins: ['Proxmox.Mixin.CBind'], items: [ @@ -15,53 +15,27 @@ Ext.define('Proxmox.panel.NotificationMatcherEditPanel', { allowBlank: false, }, { - xtype: 'proxmoxKVComboBox', - name: 'min-severity', - fieldLabel: gettext('Minimum Severity'), - value: null, + xtype: 'proxmoxtextfield', + name: 'comment', + fieldLabel: gettext('Comment'), cbind: { deleteEmpty: '{!isCreate}', }, - comboItems: [ - ['info', 'info'], - ['notice', 'notice'], - ['warning', 'warning'], - ['error', 'error'], - ], - triggers: { - clear: { - cls: 'pmx-clear-trigger', - weight: -1, - hidden: false, - handler: function() { - this.setValue(''); - }, - }, - }, - }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Invert match'), - name: 'invert-match', - uncheckedValue: 0, - defaultValue: 0, - cbind: { - deleteDefaultValue: '{!isCreate}', - }, }, + ], +}); + +Ext.define('Proxmox.panel.NotificationMatcherTargetPanel', { + extend: 'Proxmox.panel.InputPanel', + xtype: 'pmxNotificationMatcherTargetPanel', + mixins: ['Proxmox.Mixin.CBind'], + + items: [ { xtype: 'pmxNotificationTargetSelector', name: 'target', allowBlank: false, }, - { - xtype: 'proxmoxtextfield', - name: 'comment', - fieldLabel: gettext('Comment'), - cbind: { - deleteEmpty: '{!isCreate}', - }, - }, ], }); @@ -74,7 +48,7 @@ Ext.define('Proxmox.window.NotificationMatcherEdit', { labelWidth: 120, }, - width: 500, + width: 700, initComponent: function() { let me = this; @@ -97,12 +71,38 @@ Ext.define('Proxmox.window.NotificationMatcherEdit', { me.subject = gettext('Notification Matcher'); Ext.apply(me, { - items: [{ - name: me.name, - xtype: 'pmxNotificationMatcherEditPanel', - isCreate: me.isCreate, - baseUrl: me.baseUrl, - }], + bodyPadding: 0, + items: [ + { + xtype: 'tabpanel', + region: 'center', + layout: 'fit', + bodyPadding: 10, + items: [ + { + name: me.name, + title: gettext('General'), + xtype: 'pmxNotificationMatcherGeneralPanel', + isCreate: me.isCreate, + baseUrl: me.baseUrl, + }, + { + name: me.name, + title: gettext('Match Rules'), + xtype: 'pmxNotificationMatchRulesEditPanel', + isCreate: me.isCreate, + baseUrl: me.baseUrl, + }, + { + name: me.name, + title: gettext('Targets to notify'), + xtype: 'pmxNotificationMatcherTargetPanel', + isCreate: me.isCreate, + baseUrl: me.baseUrl, + }, + ], + }, + ], }); me.callParent(); @@ -252,3 +252,776 @@ Ext.define('Proxmox.form.NotificationTargetSelector', { }, }); + +Ext.define('Proxmox.panel.NotificationRulesEditPanel', { + extend: 'Proxmox.panel.InputPanel', + xtype: 'pmxNotificationMatchRulesEditPanel', + mixins: ['Proxmox.Mixin.CBind'], + + viewModel: { + data: { + selectedRecord: null, + matchFieldType: 'exact', + matchFieldField: '', + matchFieldValue: '', + rootMode: 'all', + }, + + formulas: { + nodeType: { + get: function(get) { + let record = get('selectedRecord'); + return record?.get('type'); + }, + set: function(value) { + let me = this; + let record = me.get('selectedRecord'); + + let data; + + switch (value) { + case 'match-severity': + data = { + value: ['info', 'notice', 'warning', 'error'], + }; + break; + case 'match-field': + data = { + type: 'exact', + field: '', + value: '', + }; + break; + case 'match-calendar': + data = { + value: '', + }; + break; + } + + let node = { + type: value, + data, + }; + record.set(node); + }, + }, + showMatchingMode: function(get) { + let record = get('selectedRecord'); + if (!record) { + return false; + } + return record.isRoot(); + }, + showMatcherType: function(get) { + let record = get('selectedRecord'); + if (!record) { + return false; + } + return !record.isRoot(); + }, + typeIsMatchField: { + bind: { + bindTo: '{selectedRecord}', + deep: true, + }, + get: function(record) { + return record?.get('type') === 'match-field'; + }, + }, + typeIsMatchSeverity: { + bind: { + bindTo: '{selectedRecord}', + deep: true, + }, + get: function(record) { + return record?.get('type') === 'match-severity'; + }, + }, + typeIsMatchCalendar: { + bind: { + bindTo: '{selectedRecord}', + deep: true, + }, + get: function(record) { + return record?.get('type') === 'match-calendar'; + }, + }, + matchFieldType: { + bind: { + bindTo: '{selectedRecord}', + deep: true, + }, + set: function(value) { + let me = this; + let record = me.get('selectedRecord'); + let currentData = record.get('data'); + record.set({ + data: { + ...currentData, + type: value, + }, + }); + }, + get: function(record) { + return record?.get('data')?.type; + }, + }, + matchFieldField: { + bind: { + bindTo: '{selectedRecord}', + deep: true, + }, + set: function(value) { + let me = this; + let record = me.get('selectedRecord'); + let currentData = record.get('data'); + + record.set({ + data: { + ...currentData, + field: value, + }, + }); + }, + get: function(record) { + return record?.get('data')?.field; + }, + }, + matchFieldValue: { + bind: { + bindTo: '{selectedRecord}', + deep: true, + }, + set: function(value) { + let me = this; + let record = me.get('selectedRecord'); + let currentData = record.get('data'); + record.set({ + data: { + ...currentData, + value: value, + }, + }); + }, + get: function(record) { + return record?.get('data')?.value; + }, + }, + matchSeverityValue: { + bind: { + bindTo: '{selectedRecord}', + deep: true, + }, + set: function(value) { + let me = this; + let record = me.get('selectedRecord'); + let currentData = record.get('data'); + record.set({ + data: { + ...currentData, + value: value, + }, + }); + }, + get: function(record) { + return record?.get('data')?.value; + }, + }, + matchCalendarValue: { + bind: { + bindTo: '{selectedRecord}', + deep: true, + }, + set: function(value) { + let me = this; + let record = me.get('selectedRecord'); + let currentData = record.get('data'); + record.set({ + data: { + ...currentData, + value: value, + }, + }); + }, + get: function(record) { + return record?.get('data')?.value; + }, + }, + rootMode: { + bind: { + bindTo: '{selectedRecord}', + deep: true, + }, + set: function(value) { + let me = this; + let record = me.get('selectedRecord'); + let currentData = record.get('data'); + record.set({ + data: { + ...currentData, + value, + }, + }); + }, + get: function(record) { + return record?.get('data')?.value; + }, + }, + invertMatch: { + bind: { + bindTo: '{selectedRecord}', + deep: true, + }, + set: function(value) { + let me = this; + let record = me.get('selectedRecord'); + let currentData = record.get('data'); + record.set({ + data: { + ...currentData, + invert: value, + }, + }); + }, + get: function(record) { + return record?.get('data')?.invert; + }, + }, + }, + }, + + column1: [ + { + xtype: 'pmxNotificationMatchRuleTree', + cbind: { + isCreate: '{isCreate}', + }, + }, + ], + column2: [ + { + xtype: 'pmxNotificationMatchRuleSettings', + }, + + ], + + onGetValues: function(values) { + let me = this; + + let deleteArrayIfEmtpy = (field) => { + if (Ext.isArray(values[field])) { + if (values[field].length === 0) { + delete values[field]; + if (!me.isCreate) { + Proxmox.Utils.assemble_field_data(values, { 'delete': field }); + } + } + } + }; + deleteArrayIfEmtpy('match-field'); + deleteArrayIfEmtpy('match-severity'); + deleteArrayIfEmtpy('match-calendar'); + + return values; + }, +}); + +Ext.define('Proxmox.panel.NotificationMatchRuleTree', { + extend: 'Ext.panel.Panel', + xtype: 'pmxNotificationMatchRuleTree', + mixins: ['Proxmox.Mixin.CBind'], + border: false, + + getNodeTextAndIcon: function(type, data) { + let text; + let iconCls; + + switch (type) { + case 'match-severity': { + let v = data.value.join(', '); + text = Ext.String.format(gettext("Match severity: {0}"), v); + iconCls = 'fa fa-exclamation'; + } break; + case 'match-field': { + let field = data.field; + let value = data.value; + text = Ext.String.format(gettext("Match field: {0}={1}"), field, value); + iconCls = 'fa fa-cube'; + } break; + case 'match-calendar': { + let v = data.value; + text = Ext.String.format(gettext("Match calendar: {0}"), v); + iconCls = 'fa fa-calendar-o'; + } break; + case 'mode': + if (data.value === 'all') { + text = gettext("All"); + } else if (data.value === 'any') { + text = gettext("Any"); + } + if (data.invert) { + text = `!${text}`; + } + iconCls = 'fa fa-filter'; + + break; + } + + return [text, iconCls]; + }, + + initComponent: function() { + let me = this; + + let treeStore = Ext.create('Ext.data.TreeStore', { + root: { + expanded: true, + expandable: false, + text: '', + type: 'mode', + data: { + value: 'all', + invert: false, + }, + children: [], + iconCls: 'fa fa-filter', + }, + }); + + let realMatchFields = Ext.create({ + xtype: 'hiddenfield', + setValue: function(value) { + this.value = value; + this.checkChange(); + }, + getValue: function() { + return this.value; + }, + getSubmitValue: function() { + let value = this.value; + if (!value) { + value = []; + } + return value; + }, + name: 'match-field', + }); + + let realMatchSeverity = Ext.create({ + xtype: 'hiddenfield', + setValue: function(value) { + this.value = value; + this.checkChange(); + }, + getValue: function() { + return this.value; + }, + getSubmitValue: function() { + let value = this.value; + if (!value) { + value = []; + } + return value; + }, + name: 'match-severity', + }); + + let realMode = Ext.create({ + xtype: 'hiddenfield', + name: 'mode', + setValue: function(value) { + this.value = value; + this.checkChange(); + }, + getValue: function() { + return this.value; + }, + getSubmitValue: function() { + let value = this.value; + return value; + }, + }); + + let realMatchCalendar = Ext.create({ + xtype: 'hiddenfield', + name: 'match-calendar', + + setValue: function(value) { + this.value = value; + this.checkChange(); + }, + getValue: function() { + return this.value; + }, + getSubmitValue: function() { + let value = this.value; + return value; + }, + }); + + let realInvertMatch = Ext.create({ + xtype: 'proxmoxcheckbox', + name: 'invert-match', + hidden: true, + deleteEmpty: !me.isCreate, + }); + + let storeChanged = function(store) { + store.suspendEvent('datachanged'); + + let matchFieldStmts = []; + let matchSeverityStmts = []; + let matchCalendarStmts = []; + let modeStmt = 'all'; + let invertMatchStmt = false; + + store.each(function(model) { + let type = model.get('type'); + let data = model.get('data'); + + switch (type) { + case 'match-field': + matchFieldStmts.push(`${data.type}:${data.field}=${data.value}`); + break; + case 'match-severity': + matchSeverityStmts.push(data.value.join(',')); + break; + case 'match-calendar': + matchCalendarStmts.push(data.value); + break; + case 'mode': + modeStmt = data.value; + invertMatchStmt = data.invert; + break; + } + + let [text, iconCls] = me.getNodeTextAndIcon(type, data); + model.set({ + text, + iconCls, + }); + }); + + realMatchFields.suspendEvent('change'); + realMatchFields.setValue(matchFieldStmts); + realMatchFields.resumeEvent('change'); + + realMatchCalendar.suspendEvent('change'); + realMatchCalendar.setValue(matchCalendarStmts); + realMatchCalendar.resumeEvent('change'); + + realMode.suspendEvent('change'); + realMode.setValue(modeStmt); + realMode.resumeEvent('change'); + + realInvertMatch.suspendEvent('change'); + realInvertMatch.setValue(invertMatchStmt); + realInvertMatch.resumeEvent('change'); + + realMatchSeverity.suspendEvent('change'); + realMatchSeverity.setValue(matchSeverityStmts); + realMatchSeverity.resumeEvent('change'); + + store.resumeEvent('datachanged'); + }; + + realMatchFields.addListener('change', function(field, value) { + let parseMatchField = function(filter) { + let [, type, matchedField, matchedValue] = + filter.match(/^(?:(regex|exact):)?([A-Za-z0-9_][A-Za-z0-9._-]*)=(.+)$/); + if (type === undefined) { + type = "exact"; + } + return { + type: 'match-field', + data: { + type, + field: matchedField, + value: matchedValue, + }, + leaf: true, + }; + }; + + for (let node of treeStore.queryBy( + record => record.get('type') === 'match-field', + ).getRange()) { + node.remove(true); + } + + let records = value.map(parseMatchField); + + let rootNode = treeStore.getRootNode(); + + for (let record of records) { + rootNode.appendChild(record); + } + }); + + realMatchSeverity.addListener('change', function(field, value) { + let parseSeverity = function(severities) { + return { + type: 'match-severity', + data: { + value: severities.split(','), + }, + leaf: true, + }; + }; + + for (let node of treeStore.queryBy( + record => record.get('type') === 'match-severity').getRange()) { + node.remove(true); + } + + let records = value.map(parseSeverity); + let rootNode = treeStore.getRootNode(); + + for (let record of records) { + rootNode.appendChild(record); + } + }); + + realMatchCalendar.addListener('change', function(field, value) { + let parseCalendar = function(timespan) { + return { + type: 'match-calendar', + data: { + value: timespan, + }, + leaf: true, + }; + }; + + for (let node of treeStore.queryBy( + record => record.get('type') === 'match-calendar').getRange()) { + node.remove(true); + } + + let records = value.map(parseCalendar); + let rootNode = treeStore.getRootNode(); + + for (let record of records) { + rootNode.appendChild(record); + } + }); + + realMode.addListener('change', function(field, value) { + let data = treeStore.getRootNode().get('data'); + treeStore.getRootNode().set('data', { + ...data, + value, + }); + }); + + realInvertMatch.addListener('change', function(field, value) { + let data = treeStore.getRootNode().get('data'); + treeStore.getRootNode().set('data', { + ...data, + invert: value, + }); + }); + + treeStore.addListener('datachanged', storeChanged); + + let treePanel = Ext.create({ + xtype: 'treepanel', + store: treeStore, + minHeight: 300, + maxHeight: 300, + scrollable: true, + + bind: { + selection: '{selectedRecord}', + }, + }); + + let addNode = function() { + let node = { + type: 'match-field', + data: { + type: 'exact', + field: '', + value: '', + }, + leaf: true, + }; + treeStore.getRootNode().appendChild(node); + treePanel.setSelection(treeStore.getRootNode().lastChild); + }; + + let deleteNode = function() { + let selection = treePanel.getSelection(); + for (let selected of selection) { + if (!selected.isRoot()) { + selected.remove(true); + } + } + }; + + Ext.apply(me, { + items: [ + realMatchFields, + realMode, + realMatchSeverity, + realInvertMatch, + realMatchCalendar, + treePanel, + { + xtype: 'button', + margin: '5 5 5 0', + text: gettext('Add'), + iconCls: 'fa fa-plus-circle', + handler: addNode, + }, + { + xtype: 'button', + margin: '5 5 5 0', + text: gettext('Remove'), + iconCls: 'fa fa-minus-circle', + handler: deleteNode, + }, + ], + }); + me.callParent(); + }, +}); + +Ext.define('Proxmox.panel.NotificationMatchRuleSettings', { + extend: 'Ext.panel.Panel', + xtype: 'pmxNotificationMatchRuleSettings', + border: false, + + items: [ + { + xtype: 'proxmoxKVComboBox', + name: 'mode', + fieldLabel: gettext('Match if'), + allowBlank: false, + isFormField: false, + + comboItems: [ + ['all', gettext('All rules match')], + ['any', gettext('Any rule matches')], + ], + bind: { + hidden: '{!showMatchingMode}', + disabled: '{!showMatchingMode}', + value: '{rootMode}', + }, + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Invert match'), + isFormField: false, + uncheckedValue: 0, + defaultValue: 0, + bind: { + hidden: '{!showMatchingMode}', + disabled: '{!showMatchingMode}', + value: '{invertMatch}', + }, + + }, + { + xtype: 'proxmoxKVComboBox', + fieldLabel: gettext('Node type'), + isFormField: false, + allowBlank: false, + + bind: { + value: '{nodeType}', + hidden: '{!showMatcherType}', + disabled: '{!showMatcherType}', + }, + + comboItems: [ + ['match-field', gettext('Match Field')], + ['match-severity', gettext('Match Severity')], + ['match-calendar', gettext('Match Calendar')], + ], + }, + { + fieldLabel: 'Match Type', + xtype: 'proxmoxKVComboBox', + reference: 'type', + isFormField: false, + allowBlank: false, + submitValue: false, + + bind: { + hidden: '{!typeIsMatchField}', + disabled: '{!typeIsMatchField}', + value: '{matchFieldType}', + }, + + comboItems: [ + ['exact', gettext('Exact')], + ['regex', gettext('Regex')], + ], + }, + { + fieldLabel: gettext('Field'), + xtype: 'textfield', + isFormField: false, + submitValue: false, + bind: { + hidden: '{!typeIsMatchField}', + disabled: '{!typeIsMatchField}', + value: '{matchFieldField}', + }, + }, + { + fieldLabel: gettext('Value'), + xtype: 'textfield', + isFormField: false, + submitValue: false, + allowBlank: false, + bind: { + hidden: '{!typeIsMatchField}', + disabled: '{!typeIsMatchField}', + value: '{matchFieldValue}', + }, + }, + { + xtype: 'proxmoxKVComboBox', + fieldLabel: gettext('Severities to match'), + isFormField: false, + allowBlank: true, + multiSelect: true, + + bind: { + value: '{matchSeverityValue}', + hidden: '{!typeIsMatchSeverity}', + disabled: '{!typeIsMatchSeverity}', + }, + + comboItems: [ + ['info', gettext('Info')], + ['notice', gettext('Notice')], + ['warning', gettext('Warning')], + ['error', gettext('Error')], + ], + }, + { + xtype: 'proxmoxKVComboBox', + fieldLabel: gettext('Timespan to match'), + isFormField: false, + allowBlank: false, + editable: true, + displayField: 'key', + + bind: { + value: '{matchCalendarValue}', + hidden: '{!typeIsMatchCalendar}', + disabled: '{!typeIsMatchCalender}', + }, + + comboItems: [ + ['mon 8-12', ''], + ['tue..fri,sun 0:00-23:59', ''], + ], + }, + ], +}); -- 2.39.2