From: Lukas Wagner <l.wagner@proxmox.com>
To: pbs-devel@lists.proxmox.com, pve-devel@lists.proxmox.com
Subject: [PATCH proxmox-widget-toolkit v2 20/34] notifications: matcher: add better calendar editor
Date: Thu, 1 Oct 2026 10:36:52 +0200 [thread overview]
Message-ID: <20261001083706.144246-21-l.wagner@proxmox.com> (raw)
In-Reply-To: <20261001083706.144246-1-l.wagner@proxmox.com>
This new editor allows one to enter the start time, end time and tick
the matched week-days, instead of having to enter the appropriate string
representation of the time range (e.g. 'mon..tue 08:00-12:00')
The general approach was copied from PBS's traffic rule edit panel, but
it is too different to generalize this into a new, reusable component.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
Notes:
Changes since v1:
- don't allow users to type anything but digits and : into the
timefields
- Show error message/icon in new match expression edit panel if
- start time is after end time
- no day is selected
.../NotificationMatchExpressionEditPanel.js | 26 +-
src/window/NotificationMatcherEdit.js | 335 +++++++++++++++++-
2 files changed, 347 insertions(+), 14 deletions(-)
diff --git a/src/panel/NotificationMatchExpressionEditPanel.js b/src/panel/NotificationMatchExpressionEditPanel.js
index 773fe81..78a99cc 100644
--- a/src/panel/NotificationMatchExpressionEditPanel.js
+++ b/src/panel/NotificationMatchExpressionEditPanel.js
@@ -87,7 +87,7 @@ Ext.define('Proxmox.panel.NotificationMatchExpressionEditPanel', {
break;
case 'match-calendar':
data = {
- value: '',
+ value: '00:00-23:59',
};
leaf = true;
break;
@@ -169,10 +169,19 @@ Ext.define('Proxmox.panel.NotificationMatchRuleTreeExpression', {
mixins: ['Proxmox.Mixin.CBind'],
border: false,
+ // Converts a 'HH:MM' time into minutes since midnight, or NaN if it cannot
+ // be parsed.
+ parseTime: function (time) {
+ let [hours, minutes] = time.split(':');
+ return Number(hours) * 60 + Number(minutes ?? '0');
+ },
+
// Returns a message describing why a node is incomplete, or undefined if
// the node is fully configured. Incomplete nodes are marked in the tree and
// block submitting the form.
getNodeError: function (type, data, hasChildren) {
+ let me = this;
+
switch (type) {
case 'match-severity': {
let v = Ext.isArray(data.value) ? data.value.join(', ') : data.value;
@@ -192,7 +201,15 @@ Ext.define('Proxmox.panel.NotificationMatchRuleTreeExpression', {
case 'match-calendar': {
let v = data.value;
if (!v || !v.length) {
- return gettext('Calendar rules must have a schedule.');
+ return gettext('Calendar rules must match at least one day.');
+ }
+
+ // Only schedules which the editor itself can produce are
+ // checked, any other one is left to the backend. Unparsable
+ // times compare as NaN and are thus not reported either.
+ let [, start, end] = /^(?:\S*\s+)?([0-9:]+)-([0-9:]+)$/.exec(v) ?? [];
+ if (start !== undefined && me.parseTime(start) >= me.parseTime(end)) {
+ return gettext('A calendar rule\'s start time cannot be after the end time.');
}
break;
}
@@ -330,7 +347,10 @@ Ext.define('Proxmox.panel.NotificationMatchRuleTreeExpression', {
}
}
case 'match-calendar': {
- let value = data.value ? data.value : '?';
+ // A schedule which is not fully specified, for instance because
+ // no weekday is selected, renders as blank, so show it as unset
+ // instead.
+ let value = Ext.isString(data.value) && data.value.trim() ? data.value : '?';
value = `<code>${Ext.String.htmlEncode(value)}</code>`;
if (invert) {
diff --git a/src/window/NotificationMatcherEdit.js b/src/window/NotificationMatcherEdit.js
index 893c3e3..7c3decf 100644
--- a/src/window/NotificationMatcherEdit.js
+++ b/src/window/NotificationMatcherEdit.js
@@ -361,7 +361,7 @@ Ext.define('Proxmox.panel.NotificationRulesEditPanel', {
break;
case 'match-calendar':
data = {
- value: '',
+ value: '00:00-23:59',
};
break;
}
@@ -977,6 +977,11 @@ Ext.define('Proxmox.panel.MatchCalendarSettings', {
},
set: function (value) {
let me = this;
+
+ if (!me.get('typeIsMatchCalendar')) {
+ return;
+ }
+
let record = me.get('selectedRecord');
let currentData = record.get('data');
record.set({
@@ -992,23 +997,331 @@ Ext.define('Proxmox.panel.MatchCalendarSettings', {
},
},
},
+ controller: {
+ xclass: 'Ext.app.ViewController',
+ control: {
+ 'grid checkbox': {
+ change: 'dowChanged',
+ },
+ timefield: {
+ change: 'timeChanged',
+ },
+ 'field[reference=timeframe]': {
+ change: 'setGridData',
+ },
+ },
+
+ weekdays: ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'],
+
+ setGridData: function (field, value) {
+ let me = this;
+
+ let record = me.parseTimeframe(value);
+
+ me.lookup('weekdayGrid').getStore().setData([record]);
+ me.lookup('timeStart').setValue(record.start);
+ me.lookup('timeEnd').setValue(record.end);
+ },
+
+ parseTimeframe: function (timeframe) {
+ let me = this;
+ let matches = /^(?:(\S*)\s+)?([0-9:]+)-([0-9:]+)$/.exec(timeframe);
+
+ // Schedules which the editor cannot represent, an empty one in
+ // particular, start out without any selected day and are thus
+ // shown as invalid.
+ if (!matches) {
+ return {
+ start: '00:00',
+ end: '23:59',
+ };
+ }
+
+ let [, days, start, end] = matches;
+
+ if (start === '0') {
+ start = '00:00';
+ }
+
+ let record = {
+ start,
+ end,
+ };
+
+ if (!days) {
+ days = 'mon..sun';
+ }
+
+ days = days.split(',');
+ days.forEach((day) => {
+ if (record[day]) {
+ return;
+ }
+
+ if (me.weekdays.indexOf(day) !== -1) {
+ record[day] = true;
+ } else {
+ // we have a range 'xxx..yyy'
+ let [startDay, endDay] = day.split('..');
+ let startIdx = me.weekdays.indexOf(startDay);
+ let endIdx = me.weekdays.indexOf(endDay);
+
+ if (endIdx < startIdx) {
+ endIdx += me.weekdays.length;
+ }
+
+ for (let dayIdx = startIdx; dayIdx <= endIdx; dayIdx++) {
+ let curDay = me.weekdays[dayIdx % me.weekdays.length];
+ if (!record[curDay]) {
+ record[curDay] = true;
+ }
+ }
+ }
+ });
+
+ return record;
+ },
+
+ dowChanged: function (field, value) {
+ let me = this;
+ let record = field.getWidgetRecord();
+ if (record === undefined) {
+ // this is sometimes called before a record/column is initialized
+ return;
+ }
+ let col = field.getWidgetColumn();
+ record.set(col.dataIndex, value);
+ record.commit();
+
+ let startField = me.lookup('timeStart');
+ let endField = me.lookup('timeEnd');
+
+ me.updateTimeframeField(startField, endField);
+ },
+
+ timeChanged: function (field, value) {
+ let me = this;
+
+ let startField = me.lookup('timeStart');
+ let endField = me.lookup('timeEnd');
+
+ let start = startField.getValue();
+ let end = endField.getValue();
+
+ let valid = !(start && end && start >= end);
+
+ if (!valid) {
+ startField.markInvalid(gettext('Start time must be before end time'));
+ endField.markInvalid(gettext('End time must be after start time'));
+ } else {
+ startField.clearInvalid();
+ endField.clearInvalid();
+ }
+
+ me.updateTimeframeField(startField, endField);
+ },
+
+ updateTimeframeField: function (startField, endField) {
+ let me = this;
+
+ let data = me.lookup('weekdayGrid').getStore().getData().getAt(0);
+
+ let days = me.formatSelectedDays(data.data);
+
+ let start = me.formatTime(startField);
+ let end = me.formatTime(endField);
+
+ // An empty selection cannot be expressed, as a schedule without
+ // weekdays already matches every day. Store an empty schedule
+ // instead, which is reported as invalid by the rule tree.
+ let timeframe = '';
+
+ if (days !== undefined) {
+ timeframe = days ? `${days} ${start}-${end}` : `${start}-${end}`;
+ }
+
+ let field = me.lookup('timeframe');
+ field.suspendEvent('change');
+ field.setValue(timeframe);
+
+ me.getViewModel().set('matchCalendarValue', timeframe);
+
+ field.resumeEvent('change');
+ },
+
+ // Returns the weekday part of a schedule, which is empty if every day is
+ // selected, or `undefined` if no day is selected at all.
+ formatSelectedDays: function (days) {
+ let me = this;
+ let selected = me.weekdays.filter((day) => days[day]);
+
+ if (selected.length === 0) {
+ return undefined;
+ }
+ if (selected.length === me.weekdays.length) {
+ return '';
+ }
+ if (selected.length === 1) {
+ return selected[0];
+ }
+
+ // Check if selected days are a contiguous block in weekday order
+ let indices = selected.map((day) => me.weekdays.indexOf(day));
+ let isContiguous = indices.every((idx, i) => i === 0 || idx === indices[i - 1] + 1);
+
+ if (isContiguous) {
+ return `${selected[0]}..${selected[selected.length - 1]}`;
+ }
+
+ return selected.join(',');
+ },
+
+ formatTime: function (timefield) {
+ let value = timefield.getValue();
+
+ if (!value) {
+ return '';
+ }
+
+ let hours = value.getHours().toString().padStart(2, '0');
+ let minutes = value.getMinutes().toString().padStart(2, '0');
+ return `${hours}:${minutes}`;
+ },
+ },
items: [
{
- xtype: 'proxmoxKVComboBox',
- fieldLabel: gettext('Timespan to match'),
+ xtype: 'hidden',
+ reference: 'timeframe',
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', ''],
+ },
+ {
+ xtype: 'timefield',
+ reference: 'timeStart',
+ fieldLabel: gettext('Time Start'),
+ isFormField: false,
+ format: 'H:i',
+ formatText: 'HH:MM',
+ maskRe: /[\d:]/,
+ stripCharsRe: /[^\d:]+/g,
+ allowBlank: false,
+ },
+ {
+ xtype: 'timefield',
+ reference: 'timeEnd',
+ fieldLabel: gettext('Time End'),
+ isFormField: false,
+ format: 'H:i',
+ formatText: 'HH:MM',
+ maxValue: '23:59',
+ maskRe: /[\d:]/,
+ stripCharsRe: /[^\d:]+/g,
+ allowBlank: false,
+ },
+ {
+ xtype: 'fieldcontainer',
+ items: [
+ {
+ xtype: 'grid',
+ margin: '10 0 0 0',
+ reference: 'weekdayGrid',
+ store: {
+ fields: ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'],
+ data: [
+ {
+ mon: true,
+ tue: true,
+ wed: true,
+ thu: true,
+ fri: true,
+ sat: true,
+ sun: true,
+ },
+ ],
+ },
+ enableColumnMove: false,
+ columns: {
+ defaults: {
+ menuDisabled: true,
+ resizable: false,
+ },
+ items: [
+ {
+ text: gettext('Mon'),
+ xtype: 'widgetcolumn',
+ dataIndex: 'mon',
+ flex: 1,
+ widget: {
+ xtype: 'checkbox',
+ isFormField: false,
+ },
+ },
+ {
+ text: gettext('Tue'),
+ xtype: 'widgetcolumn',
+ dataIndex: 'tue',
+ flex: 1,
+ widget: {
+ xtype: 'checkbox',
+ isFormField: false,
+ },
+ },
+ {
+ text: gettext('Wed'),
+ xtype: 'widgetcolumn',
+ dataIndex: 'wed',
+ flex: 1,
+ widget: {
+ xtype: 'checkbox',
+ isFormField: false,
+ },
+ },
+ {
+ text: gettext('Thu'),
+ xtype: 'widgetcolumn',
+ dataIndex: 'thu',
+ flex: 1,
+ widget: {
+ xtype: 'checkbox',
+ isFormField: false,
+ },
+ },
+ {
+ text: gettext('Fri'),
+ xtype: 'widgetcolumn',
+ dataIndex: 'fri',
+ flex: 1,
+ widget: {
+ xtype: 'checkbox',
+ isFormField: false,
+ },
+ },
+ {
+ text: gettext('Sat'),
+ xtype: 'widgetcolumn',
+ dataIndex: 'sat',
+ flex: 1,
+ widget: {
+ xtype: 'checkbox',
+ isFormField: false,
+ },
+ },
+ {
+ text: gettext('Sun'),
+ xtype: 'widgetcolumn',
+ dataIndex: 'sun',
+ flex: 1,
+ widget: {
+ xtype: 'checkbox',
+ isFormField: false,
+ },
+ },
+ ],
+ },
+ },
],
},
],
--
2.47.3
next prev parent reply other threads:[~2026-10-01 8:43 UTC|newest]
Thread overview: 35+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-10-01 8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 01/34] add new proxmox-match-expression crate Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 02/34] notify: log which matchers matched on a notification Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 03/34] notify: promote matcher to dir-style module Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 04/34] notify: fix doc comment Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 05/34] notify: matcher: break out severity matcher into submodule Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 06/34] notify: matcher: break out field " Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 07/34] notify: matcher: break out calendar " Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 08/34] notify: matcher: calendar: add basic unit test Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 09/34] notify: matcher: add InlineSeverityMatcher Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 10/34] notify: matcher: add InlineFieldMatcher Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 11/34] notify: matcher: add InlineCalendarMatcher Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 12/34] notify: matcher: add expression support Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 13/34] notify: api: support new expression parameter Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 14/34] notify: api: add `get_matcher_as_expression` Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 15/34] notify: migrate PBS's and PVE's default matcher to expression syntax Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 16/34] notify: move legacy matcher keys behind feature flag Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox v2 17/34] notify: matcher: don't allow empty field/severity matchers Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox-widget-toolkit v2 18/34] notifications: increase matcher window width Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox-widget-toolkit v2 19/34] notifications: matcher: add support for match expressions Lukas Wagner
2026-10-01 8:36 ` Lukas Wagner [this message]
2026-10-01 8:36 ` [PATCH proxmox-widget-toolkit v2 21/34] notifications: matcher: consistently use title case for UI elements Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox-backup v2 22/34] notification: opt into 'legacy-matchers' feature in proxmox-notify Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox-backup v2 23/34] api: notification: add 'migrate-to-expression' parameter to get_matcher Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox-backup v2 24/34] ui: notification: enable new matcher UI Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox-perl-rs v2 25/34] notify: move proxmox-notify bindings from common to pve-rs Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox-perl-rs v2 26/34] notify: matcher: pass matcher config / updater directly Lukas Wagner
2026-10-01 8:36 ` [PATCH proxmox-perl-rs v2 27/34] notify: opt into 'legacy-matchers' feature in proxmox-notify Lukas Wagner
2026-10-01 8:37 ` [PATCH proxmox-perl-rs v2 28/34] notify: add 'migrate_to_expression' parameter for get_matcher Lukas Wagner
2026-10-01 8:37 ` [PATCH pve-cluster v2 29/34] notify: adapt to changed module prefix for PVE::RS::Notify Lukas Wagner
2026-10-01 8:37 ` [PATCH manager v2 30/34] vzdump: " Lukas Wagner
2026-10-01 8:37 ` [PATCH manager v2 31/34] api: notification: pass config/updater directly to rust bindings Lukas Wagner
2026-10-01 8:37 ` [PATCH manager v2 32/34] api: notification: get_matcher: add 'migrate-to-expression' parameter Lukas Wagner
2026-10-01 8:37 ` [PATCH manager v2 33/34] api: notification: add 'expression' to matcher parameter schema Lukas Wagner
2026-10-01 8:37 ` [PATCH manager v2 34/34] ui: notification: enable new matcher UI Lukas Wagner
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20261001083706.144246-21-l.wagner@proxmox.com \
--to=l.wagner@proxmox.com \
--cc=pbs-devel@lists.proxmox.com \
--cc=pve-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox