public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Lukas Wagner <l.wagner@proxmox.com>
To: pbs-devel@lists.proxmox.com, pve-devel@lists.proxmox.com
Subject: [PATCH proxmox-widget-toolkit v3 21/39] notifications: matcher: add better calendar editor
Date: Fri,  9 Oct 2026 14:56:28 +0200	[thread overview]
Message-ID: <20261009125646.284673-22-l.wagner@proxmox.com> (raw)
In-Reply-To: <20261009125646.284673-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
    
    Changes since v2:
      - In the time range selectors, set the altFormats property.
        This allows users to enter time in varying formats and the
        field will automatically normalize the format:
          0:00 -> 00:00
          730 -> 07:30
          5  -> 05:00
    
      - Accept and normalize existing valid non-standard time durations,
        e.g.
    
          - Mon..Fri 12:00-14:00
          - mon..fri 12:00 - 14:00

 .../NotificationMatchExpressionEditPanel.js   |  26 +-
 src/window/NotificationMatcherEdit.js         | 339 +++++++++++++++++-
 2 files changed, 351 insertions(+), 14 deletions(-)

diff --git a/src/panel/NotificationMatchExpressionEditPanel.js b/src/panel/NotificationMatchExpressionEditPanel.js
index d88e139..176bfaf 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..9397125 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,335 @@ 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:]+)\s*-\s*([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.toLowerCase();
+
+            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',
+            altFormats: 'G:i|Gi|G',
+            formatText: 'HH:MM',
+            maskRe: /[\d:]/,
+            stripCharsRe: /[^\d:]+/g,
+            allowBlank: false,
+        },
+        {
+            xtype: 'timefield',
+            reference: 'timeEnd',
+            fieldLabel: gettext('Time End'),
+            isFormField: false,
+            format: 'H:i',
+            altFormats: 'G:i|Gi|G',
+            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





  parent reply	other threads:[~2026-10-09 13:03 UTC|newest]

Thread overview: 40+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-10-09 12:56 [PATCH many v3 00/39] notifications: add nested match expressions Lukas Wagner
2026-10-09 12:56 ` [PATCH proxmox v3 01/39] add new proxmox-match-expression crate Lukas Wagner
2026-10-09 12:56 ` [PATCH proxmox v3 02/39] notify: log which matchers matched on a notification Lukas Wagner
2026-10-09 12:56 ` [PATCH proxmox v3 03/39] notify: promote matcher to dir-style module Lukas Wagner
2026-10-09 12:56 ` [PATCH proxmox v3 04/39] notify: fix doc comment Lukas Wagner
2026-10-09 12:56 ` [PATCH proxmox v3 05/39] notify: matcher: break out severity matcher into submodule Lukas Wagner
2026-10-09 12:56 ` [PATCH proxmox v3 06/39] notify: matcher: break out field " Lukas Wagner
2026-10-09 12:56 ` [PATCH proxmox v3 07/39] notify: matcher: break out calendar " Lukas Wagner
2026-10-09 12:56 ` [PATCH proxmox v3 08/39] notify: matcher: calendar: add basic unit test Lukas Wagner
2026-10-09 12:56 ` [PATCH proxmox v3 09/39] notify: matcher: add InlineSeverityMatcher Lukas Wagner
2026-10-09 12:56 ` [PATCH proxmox v3 10/39] notify: matcher: add InlineFieldMatcher Lukas Wagner
2026-10-09 12:56 ` [PATCH proxmox v3 11/39] notify: matcher: add InlineCalendarMatcher Lukas Wagner
2026-10-09 12:56 ` [PATCH proxmox v3 12/39] notify: matcher: add expression support Lukas Wagner
2026-10-09 12:56 ` [PATCH proxmox v3 13/39] notify: api: support new expression parameter Lukas Wagner
2026-10-09 12:56 ` [PATCH proxmox v3 14/39] notify: api: add `get_matcher_as_expression` Lukas Wagner
2026-10-09 12:56 ` [PATCH proxmox v3 15/39] notify: migrate PBS's and PVE's default matcher to expression syntax Lukas Wagner
2026-10-09 12:56 ` [PATCH proxmox v3 16/39] notify: move legacy matcher keys behind feature flag Lukas Wagner
2026-10-09 12:56 ` [PATCH proxmox v3 17/39] notify: matcher: don't allow empty field/severity matchers Lukas Wagner
2026-10-09 12:56 ` [PATCH proxmox v3 18/39] notify: matcher: re-serialize expression on create/update Lukas Wagner
2026-10-09 12:56 ` [PATCH proxmox-widget-toolkit v3 19/39] notifications: increase matcher window width Lukas Wagner
2026-10-09 12:56 ` [PATCH proxmox-widget-toolkit v3 20/39] notifications: matcher: add support for match expressions Lukas Wagner
2026-10-09 12:56 ` Lukas Wagner [this message]
2026-10-09 12:56 ` [PATCH proxmox-widget-toolkit v3 22/39] notifications: matcher: consistently use title case for UI elements Lukas Wagner
2026-10-09 12:56 ` [PATCH proxmox-backup v3 23/39] notification: opt into 'legacy-matchers' feature in proxmox-notify Lukas Wagner
2026-10-09 12:56 ` [PATCH proxmox-backup v3 24/39] api: notification: add 'migrate-to-expression' parameter to get_matcher Lukas Wagner
2026-10-09 12:56 ` [PATCH proxmox-backup v3 25/39] ui: notification: enable new matcher UI Lukas Wagner
2026-10-09 12:56 ` [PATCH proxmox-perl-rs v3 26/39] notify: move proxmox-notify bindings from common to pve-rs Lukas Wagner
2026-10-09 12:56 ` [PATCH proxmox-perl-rs v3 27/39] notify: matcher: pass matcher config / updater directly Lukas Wagner
2026-10-09 12:56 ` [PATCH proxmox-perl-rs v3 28/39] notify: opt into 'legacy-matchers' feature in proxmox-notify Lukas Wagner
2026-10-09 12:56 ` [PATCH proxmox-perl-rs v3 29/39] notify: add 'migrate_to_expression' parameter for get_matcher Lukas Wagner
2026-10-09 12:56 ` [PATCH pve-cluster v3 30/39] notify: adapt to changed module prefix for PVE::RS::Notify Lukas Wagner
2026-10-09 12:56 ` [PATCH pve-cluster v3 31/39] cluster: move pvecfg node version helpers from qemu-server Lukas Wagner
2026-10-09 12:56 ` [PATCH common v3 32/39] tools: move version_cmp() helper " Lukas Wagner
2026-10-09 12:56 ` [PATCH manager v3 33/39] vzdump: adapt to changed module prefix for PVE::RS::Notify Lukas Wagner
2026-10-09 12:56 ` [PATCH manager v3 34/39] api: notification: pass config/updater directly to rust bindings Lukas Wagner
2026-10-09 12:56 ` [PATCH manager v3 35/39] api: notification: get_matcher: add 'migrate-to-expression' parameter Lukas Wagner
2026-10-09 12:56 ` [PATCH manager v3 36/39] api: notification: add 'expression' to matcher parameter schema Lukas Wagner
2026-10-09 12:56 ` [PATCH manager v3 37/39] ui: notification: enable new matcher UI Lukas Wagner
2026-10-09 12:56 ` [PATCH manager v3 38/39] api: notification: reject matchers with expression if not supported by all nodes Lukas Wagner
2026-10-09 12:56 ` [PATCH proxmox-mail-forward v3 39/39] cargo: opt into legacy-matchers feature 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=20261009125646.284673-22-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
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal