[pve-devel] [PATCH proxmox-widget-toolkit 25/27] notification: matcher: add UI for matcher editing

Lukas Wagner l.wagner at proxmox.com
Tue Nov 7 11:18:25 CET 2023


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 <l.wagner at proxmox.com>
---

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






More information about the pve-devel mailing list