]> git.proxmox.com Git - proxmox-backup.git/blobdiff - www/window/NotificationMatcherOverride.js
ui: notifications: pull in UX improvements for match rules creation
[proxmox-backup.git] / www / window / NotificationMatcherOverride.js
diff --git a/www/window/NotificationMatcherOverride.js b/www/window/NotificationMatcherOverride.js
new file mode 100644 (file)
index 0000000..8636653
--- /dev/null
@@ -0,0 +1,1105 @@
+// Override some components from widget toolkit.
+// This was done so that we can already use the improved UI for editing
+// match rules without waiting for the needed API calls in PVE to be merged
+//
+// This can and *should* be removed once these changes have landed in
+// widget toolkit:
+// https://lists.proxmox.com/pipermail/pve-devel/2024-April/063539.html
+
+
+Ext.define('pbs-notification-fields', {
+    extend: 'Ext.data.Model',
+    fields: ['name', 'description'],
+    idProperty: 'name',
+});
+
+Ext.define('pbs-notification-field-values', {
+    extend: 'Ext.data.Model',
+    fields: ['value', 'comment', 'field'],
+    idProperty: 'value',
+});
+
+Ext.define('PBS.panel.NotificationRulesEditPanel', {
+    override: 'Proxmox.panel.NotificationRulesEditPanel',
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pmxNotificationMatchRulesEditPanel',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    controller: {
+       xclass: 'Ext.app.ViewController',
+
+       // we want to also set the empty value, but 'bind' does not do that so
+       // we have to set it then (and only then) to get the correct value in
+       // the tree
+       control: {
+           'field': {
+               change: function(cmp) {
+                   let me = this;
+                   let vm = me.getViewModel();
+                   if (cmp.field) {
+                       let record = vm.get('selectedRecord');
+                       if (!record) {
+                           return;
+                       }
+                       let data = Ext.apply({}, record.get('data'));
+                       let value = cmp.getValue();
+                       // only update if the value is empty (or empty array)
+                       if (!value || !value.length) {
+                           data[cmp.field] = value;
+                           record.set({ data });
+                       }
+                   }
+               },
+           },
+       },
+    },
+
+    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', 'unknown'],
+                           };
+                           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();
+           },
+
+           rootMode: {
+               bind: {
+                   bindTo: '{selectedRecord}',
+                   deep: true,
+               },
+               set: function(value) {
+                   let me = this;
+                   let record = me.get('selectedRecord');
+                   let currentData = record.get('data');
+                   let invert = false;
+                   if (value.startsWith('not')) {
+                       value = value.substring(3);
+                       invert = true;
+                   }
+                   record.set({
+                       data: {
+                           ...currentData,
+                           value,
+                           invert,
+                       },
+                   });
+               },
+               get: function(record) {
+                   let prefix = record?.get('data').invert ? 'not' : '';
+                   return prefix + record?.get('data')?.value;
+               },
+           },
+       },
+    },
+
+    column1: [
+       {
+           xtype: 'pbsNotificationMatchRuleTree',
+           cbind: {
+               isCreate: '{isCreate}',
+           },
+       },
+    ],
+    column2: [
+       {
+           xtype: 'pbsNotificationMatchRuleSettings',
+           cbind: {
+               baseUrl: '{baseUrl}',
+           },
+       },
+
+    ],
+
+    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('PBS.panel.NotificationMatchRuleTree', {
+    extend: 'Ext.panel.Panel',
+    xtype: 'pbsNotificationMatchRuleTree',
+    mixins: ['Proxmox.Mixin.CBind'],
+    border: false,
+
+    getNodeTextAndIcon: function(type, data) {
+       let text;
+       let iconCls;
+
+       switch (type) {
+           case 'match-severity': {
+               let v = data.value;
+               if (Ext.isArray(data.value)) {
+                   v = data.value.join(', ');
+               }
+               text = Ext.String.format(gettext("Match severity: {0}"), v);
+               iconCls = 'fa fa-exclamation';
+               if (!v) {
+                   iconCls += ' internal-error';
+               }
+           } 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-square-o';
+               if (!field || !value || (Ext.isArray(value) && !value.length)) {
+                   iconCls += ' internal-error';
+               }
+           } break;
+           case 'match-calendar': {
+               let v = data.value;
+               text = Ext.String.format(gettext("Match calendar: {0}"), v);
+               iconCls = 'fa fa-calendar-o';
+               if (!v || !v.length) {
+                   iconCls += ' internal-error';
+               }
+           } 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;
+           },
+           getErrors: function() {
+               for (const matcher of this.value ?? []) {
+                   let matches = matcher.match(/^([^:]+):([^=]+)=(.+)$/);
+                   if (!matches) {
+                       return [""]; // fake error for validation
+                   }
+               }
+               return [];
+           },
+           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;
+           },
+           getErrors: function() {
+               for (const severities of this.value ?? []) {
+                   if (!severities) {
+                       return [""]; // fake error for validation
+                   }
+               }
+               return [];
+           },
+           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;
+           },
+           getErrors: function() {
+               for (const timespan of this.value ?? []) {
+                   if (!timespan) {
+                       return [""]; // fake error for validation
+                   }
+               }
+               return [];
+           },
+           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':
+                       if (Ext.isArray(data.value)) {
+                           matchSeverityStmts.push(data.value.join(','));
+                       } else {
+                           matchSeverityStmts.push(data.value);
+                       }
+                       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";
+               }
+
+               if (type === 'exact') {
+                   matchedValue = matchedValue.split(',');
+               }
+
+               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);
+           }
+
+           if (!value) {
+               return;
+           }
+           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('PBS.panel.NotificationMatchRuleSettings', {
+    extend: 'Ext.panel.Panel',
+    xtype: 'pbsNotificationMatchRuleSettings',
+    mixins: ['Proxmox.Mixin.CBind'],
+    border: false,
+    layout: 'anchor',
+
+    items: [
+       {
+           xtype: 'proxmoxKVComboBox',
+           name: 'mode',
+           fieldLabel: gettext('Match if'),
+           allowBlank: false,
+           isFormField: false,
+
+           matchFieldWidth: false,
+
+           comboItems: [
+               ['all', gettext('All rules match')],
+               ['any', gettext('Any rule matches')],
+               ['notall', gettext('At least one rule does not match')],
+               ['notany', gettext('No rule matches')],
+           ],
+           // Hide initially to avoid glitches when opening the window
+           hidden: true,
+           bind: {
+               hidden: '{!showMatchingMode}',
+               disabled: '{!showMatchingMode}',
+               value: '{rootMode}',
+           },
+       },
+       {
+           xtype: 'proxmoxKVComboBox',
+           fieldLabel: gettext('Node type'),
+           isFormField: false,
+           allowBlank: false,
+           // Hide initially to avoid glitches when opening the window
+           hidden: true,
+           bind: {
+               value: '{nodeType}',
+               hidden: '{!showMatcherType}',
+               disabled: '{!showMatcherType}',
+           },
+
+           comboItems: [
+               ['match-field', gettext('Match Field')],
+               ['match-severity', gettext('Match Severity')],
+               ['match-calendar', gettext('Match Calendar')],
+           ],
+       },
+       {
+           xtype: 'pbsNotificationMatchFieldSettings',
+           cbind: {
+               baseUrl: '{baseUrl}',
+           },
+       },
+       {
+           xtype: 'pbsNotificationMatchSeveritySettings',
+       },
+       {
+           xtype: 'pbsNotificationMatchCalendarSettings',
+       },
+    ],
+});
+
+Ext.define('PBS.panel.MatchCalendarSettings', {
+    extend: 'Ext.panel.Panel',
+    xtype: 'pbsNotificationMatchCalendarSettings',
+    border: false,
+    layout: 'anchor',
+    // Hide initially to avoid glitches when opening the window
+    hidden: true,
+    bind: {
+       hidden: '{!typeIsMatchCalendar}',
+    },
+    viewModel: {
+       // parent is set in `initComponents`
+       formulas: {
+           typeIsMatchCalendar: {
+               bind: {
+                   bindTo: '{selectedRecord}',
+                   deep: true,
+               },
+               get: function(record) {
+                   return record?.get('type') === 'match-calendar';
+               },
+           },
+
+           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;
+               },
+           },
+       },
+    },
+    items: [
+       {
+           xtype: 'proxmoxKVComboBox',
+           fieldLabel: gettext('Timespan to match'),
+           isFormField: false,
+           allowBlank: false,
+           editable: true,
+           displayField: 'key',
+           field: 'value',
+           bind: {
+               value: '{matchCalendarValue}',
+               disabled: '{!typeIsMatchCalender}',
+           },
+
+           comboItems: [
+               ['mon 8-12', ''],
+               ['tue..fri,sun 0:00-23:59', ''],
+           ],
+       },
+    ],
+
+    initComponent: function() {
+       let me = this;
+       Ext.apply(me.viewModel, {
+           parent: me.up('pmxNotificationMatchRulesEditPanel').getViewModel(),
+       });
+       me.callParent();
+    },
+});
+
+Ext.define('PBS.panel.MatchSeveritySettings', {
+    extend: 'Ext.panel.Panel',
+    xtype: 'pbsNotificationMatchSeveritySettings',
+    border: false,
+    layout: 'anchor',
+    // Hide initially to avoid glitches when opening the window
+    hidden: true,
+    bind: {
+       hidden: '{!typeIsMatchSeverity}',
+    },
+    viewModel: {
+       // parent is set in `initComponents`
+       formulas: {
+           typeIsMatchSeverity: {
+               bind: {
+                   bindTo: '{selectedRecord}',
+                   deep: true,
+               },
+               get: function(record) {
+                   return record?.get('type') === 'match-severity';
+               },
+           },
+           matchSeverityValue: {
+               bind: {
+                   bindTo: '{selectedRecord}',
+                   deep: true,
+               },
+               set: function(value) {
+                   let record = this.get('selectedRecord');
+                   let currentData = record.get('data');
+                   record.set({
+                       data: {
+                           ...currentData,
+                           value: value,
+                       },
+                   });
+               },
+               get: function(record) {
+                   return record?.get('data')?.value;
+               },
+           },
+       },
+    },
+    items: [
+       {
+           xtype: 'proxmoxKVComboBox',
+           fieldLabel: gettext('Severities to match'),
+           isFormField: false,
+           allowBlank: true,
+           multiSelect: true,
+           field: 'value',
+           // Hide initially to avoid glitches when opening the window
+           hidden: true,
+           bind: {
+               value: '{matchSeverityValue}',
+               hidden: '{!typeIsMatchSeverity}',
+               disabled: '{!typeIsMatchSeverity}',
+           },
+
+           comboItems: [
+               ['info', gettext('Info')],
+               ['notice', gettext('Notice')],
+               ['warning', gettext('Warning')],
+               ['error', gettext('Error')],
+               ['unknown', gettext('Unknown')],
+           ],
+       },
+    ],
+
+    initComponent: function() {
+       let me = this;
+       Ext.apply(me.viewModel, {
+           parent: me.up('pmxNotificationMatchRulesEditPanel').getViewModel(),
+       });
+       me.callParent();
+    },
+});
+
+Ext.define('PBS.panel.MatchFieldSettings', {
+    extend: 'Ext.panel.Panel',
+    xtype: 'pbsNotificationMatchFieldSettings',
+    border: false,
+    layout: 'anchor',
+    // Hide initially to avoid glitches when opening the window
+    hidden: true,
+    bind: {
+       hidden: '{!typeIsMatchField}',
+    },
+    controller: {
+       xclass: 'Ext.app.ViewController',
+
+       control: {
+           'field[reference=fieldSelector]': {
+               change: function(field) {
+                   let view = this.getView();
+                   let valueField = view.down('field[reference=valueSelector]');
+                   let store = valueField.getStore();
+                   let val = field.getValue();
+
+                   if (val) {
+                       store.setFilters([
+                           {
+                               property: 'field',
+                               value: val,
+                           },
+                       ]);
+                   }
+               },
+           },
+       },
+    },
+    viewModel: {
+       // parent is set in `initComponents`
+       formulas: {
+           typeIsMatchField: {
+               bind: {
+                   bindTo: '{selectedRecord}',
+                   deep: true,
+               },
+               get: function(record) {
+                   return record?.get('type') === 'match-field';
+               },
+           },
+           isRegex: function(get) {
+               return get('matchFieldType') === 'regex';
+           },
+           matchFieldType: {
+               bind: {
+                   bindTo: '{selectedRecord}',
+                   deep: true,
+               },
+               set: function(value) {
+                   let record = this.get('selectedRecord');
+                   let currentData = record.get('data');
+
+                   let newValue = [];
+
+                   // Build equivalent regular expression if switching
+                   // to 'regex' mode
+                   if (value === 'regex') {
+                       let regexVal = "^";
+                       if (currentData.value) {
+                           regexVal += `(${currentData.value.join('|')})`;
+                       }
+                       regexVal += "$";
+                       newValue.push(regexVal);
+                   }
+
+                   record.set({
+                       data: {
+                           ...currentData,
+                           type: value,
+                           value: newValue,
+                       },
+                   });
+               },
+               get: function(record) {
+                   return record?.get('data')?.type;
+               },
+           },
+           matchFieldField: {
+               bind: {
+                   bindTo: '{selectedRecord}',
+                   deep: true,
+               },
+               set: function(value) {
+                   let record = this.get('selectedRecord');
+                   let currentData = record.get('data');
+
+                   record.set({
+                       data: {
+                           ...currentData,
+                           field: value,
+                           // Reset value if field changes
+                           value: [],
+                       },
+                   });
+               },
+               get: function(record) {
+                   return record?.get('data')?.field;
+               },
+           },
+           matchFieldValue: {
+               bind: {
+                   bindTo: '{selectedRecord}',
+                   deep: true,
+               },
+               set: function(value) {
+                   let record = this.get('selectedRecord');
+                   let currentData = record.get('data');
+                   record.set({
+                       data: {
+                           ...currentData,
+                           value: value,
+                       },
+                   });
+               },
+               get: function(record) {
+                   return record?.get('data')?.value;
+               },
+           },
+       },
+    },
+
+    initComponent: function() {
+       let me = this;
+
+       let store = Ext.create('Ext.data.Store', {
+           model: 'pbs-notification-fields',
+           autoLoad: true,
+           proxy: {
+               type: 'proxmox',
+               url: `/api2/json/${me.baseUrl}/matcher-fields`,
+           },
+           listeners: {
+               'load': function() {
+                   this.each(function(record) {
+                       record.set({
+                           description:
+                               Proxmox.Utils.formatNotificationFieldName(
+                                   record.get('name'),
+                               ),
+                       });
+                   });
+
+                   // Commit changes so that the description field is not marked
+                   // as dirty
+                   this.commitChanges();
+               },
+           },
+       });
+
+       let valueStore = Ext.create('Ext.data.Store', {
+           model: 'pbs-notification-field-values',
+           autoLoad: true,
+           proxy: {
+               type: 'proxmox',
+
+               url: `/api2/json/${me.baseUrl}/matcher-field-values`,
+           },
+           listeners: {
+               'load': function() {
+                   this.each(function(record) {
+                       if (record.get('field') === 'type') {
+                           record.set({
+                               comment:
+                                   Proxmox.Utils.formatNotificationFieldValue(
+                                       record.get('value'),
+                                   ),
+                           });
+                       }
+                   }, this, true);
+
+                   // Commit changes so that the description field is not marked
+                   // as dirty
+                   this.commitChanges();
+               },
+           },
+       });
+
+       Ext.apply(me.viewModel, {
+           parent: me.up('pmxNotificationMatchRulesEditPanel').getViewModel(),
+       });
+       Ext.apply(me, {
+           items: [
+               {
+                   fieldLabel: gettext('Match Type'),
+                   xtype: 'proxmoxKVComboBox',
+                   reference: 'type',
+                   isFormField: false,
+                   allowBlank: false,
+                   submitValue: false,
+                   field: 'type',
+
+                   bind: {
+                       value: '{matchFieldType}',
+                   },
+
+                   comboItems: [
+                       ['exact', gettext('Exact')],
+                       ['regex', gettext('Regex')],
+                   ],
+               },
+               {
+                   fieldLabel: gettext('Field'),
+                   reference: 'fieldSelector',
+                   xtype: 'proxmoxComboGrid',
+                   isFormField: false,
+                   submitValue: false,
+                   allowBlank: false,
+                   editable: false,
+                   store: store,
+                   queryMode: 'local',
+                   valueField: 'name',
+                   displayField: 'description',
+                   field: 'field',
+                   bind: {
+                       value: '{matchFieldField}',
+                   },
+                   listConfig: {
+                       columns: [
+                           {
+                               header: gettext('Description'),
+                               dataIndex: 'description',
+                               flex: 2,
+                           },
+                           {
+                               header: gettext('Field Name'),
+                               dataIndex: 'name',
+                               flex: 1,
+                           },
+                       ],
+                   },
+               },
+               {
+                   fieldLabel: gettext('Value'),
+                   reference: 'valueSelector',
+                   xtype: 'proxmoxComboGrid',
+                   autoSelect: false,
+                   editable: false,
+                   isFormField: false,
+                   submitValue: false,
+                   allowBlank: false,
+                   showClearTrigger: true,
+                   field: 'value',
+                   store: valueStore,
+                   valueField: 'value',
+                   displayField: 'value',
+                   notFoundIsValid: false,
+                   multiSelect: true,
+                   bind: {
+                       value: '{matchFieldValue}',
+                       hidden: '{isRegex}',
+                   },
+                   listConfig: {
+                       columns: [
+                           {
+                               header: gettext('Value'),
+                               dataIndex: 'value',
+                               flex: 1,
+                           },
+                           {
+                               header: gettext('Comment'),
+                               dataIndex: 'comment',
+                               flex: 2,
+                           },
+                       ],
+                   },
+               },
+               {
+                   fieldLabel: gettext('Regex'),
+                   xtype: 'proxmoxtextfield',
+                   editable: true,
+                   isFormField: false,
+                   submitValue: false,
+                   allowBlank: false,
+                   field: 'value',
+                   bind: {
+                       value: '{matchFieldValue}',
+                       hidden: '{!isRegex}',
+                   },
+               },
+           ],
+       });
+       me.callParent();
+    },
+});