public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [PATCH many v2 00/34] notifications: add nested match expressions
@ 2026-10-01  8:36 Lukas Wagner
  2026-10-01  8:36 ` [PATCH proxmox v2 01/34] add new proxmox-match-expression crate Lukas Wagner
                   ` (33 more replies)
  0 siblings, 34 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:36 UTC (permalink / raw)
  To: pbs-devel, pve-devel

NOTE: I have a couple of other patches planned for the notification stack (**);
so I guess it would make sense wait a bit before applying this -- just to avoid
too many version bumps and too much maintainer churn. I have a patch series
already in the works that builds on top of this one, so I would prefer to
perform any non-trivial changes to this feature as follow-ups; rebasing the new
series I'm working on right now is a huge pain, as it moves a lot of code
around in proxmox-notify (preparations for proxmox-notify v2, essentially)

This resolves a long-standing limitation of notification matchers. Up until
now, notification match rules could only be one level deep, so one top-level
combinator (all/any) and an arbitrary number of match rules as direct children.
This severely limits which kind of matching behaviors can be represented.

The notification stack was always designed with the possibility of arbitrarily
nested match rules, however, due to limitations in our API and config formats
(lacking support for nested data structures), this was not implemented yet.

A prior attempt to resolve the limitations imposed by the lack of nesting was
to add 'sub-matchers', where one matcher could evaluate the result of another
matcher. After some consideration, I decided not to pursue this approach further,
due to concerns about the UX of that approach [1].

This new series chooses a different approach. Nested match rules can now be
represented by a single matcher in the config. The lack of support for nested
data structures in the API was circumvented by storing a serialized form of the
'rule tree' as a JSON blob in a single new configuration parameter
'expression'. If this new key is set, the old 'match-*', 'invert-match' and
'mode' keys are ignored; 'expression' is intended as a replacement for these
keys. I have not yet marked the old keys as deprecated. Theoretically these can
stay supported for now, however when editing a matcher via the UI, the old keys
will always be translated into an equivalent 'expression'. For any new products
receiving the notification stack, we can drop the support for the old keys by
compiling proxmox-notify without the 'legacy-matchers' flag.

The expression evaluation logic was implemented in a new generic crate
'proxmox-match-expression'. 'proxmox-notify' is the first user of this crate,
however it is generic enough to be potentially useful in other contexts as
well.

Bumps:
  - pve-rs needs proxmox-notify bumped
  - pve-cluster (libpve-notify-perl) needs pve-rs bumped
  - pve-manager needs pve-cluster (libpve-notify-perl), pve-rs and widget-toolkit bumped
  - proxmox-backup needs widget-toolkit and proxmox-notify bumped
  - proxmox-notify needs a first release of proxmox-match-expression
  
  - proxmox-mail-forward needs to be rebuilt against bumped proxmox-notify (unless
    it has already been changed to not call into proxmox-notify anymore, see below)

[1] https://lore.proxmox.com/pbs-devel/20250521142309.264719-1-l.wagner@proxmox.com/


(**): Some of the things I want to improve/implement:
   - Migrate PVE to use a worker-based approach, same as in PBS
     -> once that is done, we can fully migrate proxmox-mail-forward
        to use the worker/spool dir approach, instead of using proxmox-notify
        directly
   - Introduce a notification history
   - Refactor error types in proxmox-notify a bit

Changes since v1:
  - Move Proxmox::RS::Notify module to PVE::RS::Notify, since it is only used
    by PVE
  - Fix legacy-sendmail mode, the calling code was not adapted to the new API
    of Proxmox::RS::Notify in v1
  - proxmox-match-expression: all-of([]) now evaluates to true, similar to
    Iterator::all or all([]) in python
  - proxmox-notify: Don't allow empty all-of/any-of/one-of rules (in both UI and API,
    proxmox-match-expression still allow them)
  - Also don't allow empty severity/field matchers (empty arrays of matched values)
  - Fix slight inconsistency with the default notification matcher for PBS;
    now prune notifications with severity 'notice' are included once more
  - Other minor review comments
  - Overall polish of the new matcher UI
    - Add hint about nodes being drag&drop'able
    - Add error field if a node is invalid
    - Fixed erroreous caldendar string generated by the UI under
      certain circumenstances

Thanks go to Wolfgang and Arthur for their feedback of v1.


proxmox:

Lukas Wagner (17):
  add new proxmox-match-expression crate
  notify: log which matchers matched on a notification
  notify: promote matcher to dir-style module
  notify: fix doc comment
  notify: matcher: break out severity matcher into submodule
  notify: matcher: break out field matcher into submodule
  notify: matcher: break out calendar matcher into submodule
  notify: matcher: calendar: add basic unit test
  notify: matcher: add InlineSeverityMatcher
  notify: matcher: add InlineFieldMatcher
  notify: matcher: add InlineCalendarMatcher
  notify: matcher: add expression support
  notify: api: support new expression parameter
  notify: api: add `get_matcher_as_expression`
  notify: migrate PBS's and PVE's default matcher to expression syntax
  notify: move legacy matcher keys behind feature flag
  notify: matcher: don't allow empty field/severity matchers

 Cargo.toml                                    |   3 +
 proxmox-match-expression/Cargo.toml           |  18 +
 proxmox-match-expression/debian/changelog     |   6 +
 proxmox-match-expression/debian/control       |  34 ++
 proxmox-match-expression/debian/copyright     |  18 +
 proxmox-match-expression/debian/debcargo.toml |   7 +
 proxmox-match-expression/src/lib.rs           | 524 +++++++++++++++++
 proxmox-notify/Cargo.toml                     |   7 +-
 proxmox-notify/src/api/matcher.rs             | 248 +++++++-
 proxmox-notify/src/context/mod.rs             |   4 +-
 proxmox-notify/src/context/pbs.rs             |  87 ++-
 proxmox-notify/src/context/pve.rs             |  67 ++-
 proxmox-notify/src/context/test.rs            |   9 +-
 proxmox-notify/src/lib.rs                     |  29 +-
 proxmox-notify/src/matcher.rs                 | 532 ------------------
 proxmox-notify/src/matcher/calendar.rs        | 158 ++++++
 proxmox-notify/src/matcher/expression.rs      |  97 ++++
 proxmox-notify/src/matcher/field.rs           | 249 ++++++++
 proxmox-notify/src/matcher/mod.rs             | 531 +++++++++++++++++
 proxmox-notify/src/matcher/severity.rs        | 119 ++++
 20 files changed, 2149 insertions(+), 598 deletions(-)
 create mode 100644 proxmox-match-expression/Cargo.toml
 create mode 100644 proxmox-match-expression/debian/changelog
 create mode 100644 proxmox-match-expression/debian/control
 create mode 100644 proxmox-match-expression/debian/copyright
 create mode 100644 proxmox-match-expression/debian/debcargo.toml
 create mode 100644 proxmox-match-expression/src/lib.rs
 delete mode 100644 proxmox-notify/src/matcher.rs
 create mode 100644 proxmox-notify/src/matcher/calendar.rs
 create mode 100644 proxmox-notify/src/matcher/expression.rs
 create mode 100644 proxmox-notify/src/matcher/field.rs
 create mode 100644 proxmox-notify/src/matcher/mod.rs
 create mode 100644 proxmox-notify/src/matcher/severity.rs


proxmox-widget-toolkit:

Lukas Wagner (4):
  notifications: increase matcher window width
  notifications: matcher: add support for match expressions
  notifications: matcher: add better calendar editor
  notifications: matcher: consistently use title case for UI elements

 src/Makefile                                  |   1 +
 src/Schema.js                                 |  10 +
 src/Utils.js                                  |   2 +-
 src/css/ext6-pmx.css                          |  51 +
 .../NotificationMatchExpressionEditPanel.js   | 886 ++++++++++++++++++
 src/proxmox-dark/scss/extjs/_treepanel.scss   |   5 +
 src/proxmox-dark/scss/proxmox/_general.scss   |   4 +
 src/window/NotificationMatcherEdit.js         | 354 ++++++-
 8 files changed, 1293 insertions(+), 20 deletions(-)
 create mode 100644 src/panel/NotificationMatchExpressionEditPanel.js


proxmox-backup:

Lukas Wagner (3):
  notification: opt into 'legacy-matchers' feature in proxmox-notify
  api: notification: add 'migrate-to-expression' parameter to
    get_matcher
  ui: notification: enable new matcher UI

 Cargo.toml                                |  2 +-
 src/api2/config/notifications/matchers.rs | 18 ++++++++++++++++--
 www/Utils.js                              |  3 +++
 3 files changed, 20 insertions(+), 3 deletions(-)


proxmox-perl-rs:

Lukas Wagner (4):
  notify: move proxmox-notify bindings from common to pve-rs
  notify: matcher: pass matcher config / updater directly
  notify: opt into 'legacy-matchers' feature in proxmox-notify
  notify: add 'migrate_to_expression' parameter for get_matcher

 common/pkg/Makefile                       |   1 -
 common/src/bindings/mod.rs                |   3 -
 pmg-rs/Cargo.toml                         |   1 -
 pve-rs/Cargo.toml                         |   2 +-
 pve-rs/Makefile                           |   1 +
 pve-rs/src/bindings/mod.rs                |   3 +
 {common => pve-rs}/src/bindings/notify.rs | 212 ++++------------------
 7 files changed, 40 insertions(+), 183 deletions(-)
 rename {common => pve-rs}/src/bindings/notify.rs (73%)


pve-cluster:

Lukas Wagner (1):
  notify: adapt to changed module prefix for PVE::RS::Notify

 src/PVE/Notify.pm | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)


pve-manager:

Lukas Wagner (5):
  vzdump: adapt to changed module prefix for PVE::RS::Notify
  api: notification: pass config/updater directly to rust bindings
  api: notification: get_matcher: add 'migrate-to-expression' parameter
  api: notification: add 'expression' to matcher parameter schema
  ui: notification: enable new matcher UI

 PVE/API2/Cluster/Notifications.pm | 165 ++++++++----------------------
 PVE/VZDump.pm                     |  21 ++--
 www/manager6/Utils.js             |   2 +
 3 files changed, 53 insertions(+), 135 deletions(-)


Summary over all repositories:
  42 files changed, 3557 insertions(+), 941 deletions(-)

-- 
Generated by murpp 0.12.1




^ permalink raw reply	[flat|nested] 35+ messages in thread

* [PATCH proxmox v2 01/34] add new proxmox-match-expression crate
  2026-10-01  8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
@ 2026-10-01  8:36 ` Lukas Wagner
  2026-10-01  8:36 ` [PATCH proxmox v2 02/34] notify: log which matchers matched on a notification Lukas Wagner
                   ` (32 subsequent siblings)
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:36 UTC (permalink / raw)
  To: pbs-devel, pve-devel

This new crate is a generic implementation of a serializable expression
language. It supports basic combinators, such as AnyOf, OneOf and AllOf,
constants, Not, as well as custom matcher leave nodes that are injected
via a generic type parameter.

The first user of this implementation will be the notification stack.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---

Notes:
    Changes since v1:
    
       - all_of([]) evaluates now to true
         This conforms to Rust's Iterator::nay or Python's all(...)
    
         The first and only user of this crate, proxmox-notify explicitly
         disallows empty combinators/groups, since they do not make
         much sense from a UX perspective
    
       - implement From<EvaluatedExpressionWithResult> for
         EvaluatedExpression
    
       - Expression now also has a `children` method, allowing
        to easily iterate over child nodes

 Cargo.toml                                    |   2 +
 proxmox-match-expression/Cargo.toml           |  18 +
 proxmox-match-expression/debian/changelog     |   6 +
 proxmox-match-expression/debian/control       |  34 ++
 proxmox-match-expression/debian/copyright     |  18 +
 proxmox-match-expression/debian/debcargo.toml |   7 +
 proxmox-match-expression/src/lib.rs           | 524 ++++++++++++++++++
 7 files changed, 609 insertions(+)
 create mode 100644 proxmox-match-expression/Cargo.toml
 create mode 100644 proxmox-match-expression/debian/changelog
 create mode 100644 proxmox-match-expression/debian/control
 create mode 100644 proxmox-match-expression/debian/copyright
 create mode 100644 proxmox-match-expression/debian/debcargo.toml
 create mode 100644 proxmox-match-expression/src/lib.rs

diff --git a/Cargo.toml b/Cargo.toml
index ddefaaac..2e07077f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -32,6 +32,7 @@ members = [
     "proxmox-log",
     "proxmox-login",
     "proxmox-metrics",
+    "proxmox-match-expression",
     "proxmox-network-api",
     "proxmox-network-types",
     "proxmox-node-status",
@@ -179,6 +180,7 @@ proxmox-io = { version = "1.2.1", path = "proxmox-io" }
 proxmox-lang = { version = "1.5", path = "proxmox-lang" }
 proxmox-log = { version = "1.0.0", path = "proxmox-log" }
 proxmox-login = { version = "1.0.0", path = "proxmox-login" }
+proxmox-match-expression = { version = "0.1", path = "proxmox-match-expression" }
 proxmox-network-types = { version = "1.0.2", path = "proxmox-network-types" }
 proxmox-parallel-handler = { version = "1.0.0", path = "proxmox-parallel-handler" }
 proxmox-pgp = { version = "1.0.0", path = "proxmox-pgp" }
diff --git a/proxmox-match-expression/Cargo.toml b/proxmox-match-expression/Cargo.toml
new file mode 100644
index 00000000..294537bc
--- /dev/null
+++ b/proxmox-match-expression/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "proxmox-match-expression"
+description = "evaluation logic for nested match expressions"
+version = "0.1.0"
+
+authors.workspace = true
+edition.workspace = true
+exclude.workspace = true
+homepage.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+serde = { workspace = true, features = ["derive"] }
+
+[dev-dependencies]
+serde_json.workspace = true
+pretty_assertions.workspace = true
diff --git a/proxmox-match-expression/debian/changelog b/proxmox-match-expression/debian/changelog
new file mode 100644
index 00000000..44b20a10
--- /dev/null
+++ b/proxmox-match-expression/debian/changelog
@@ -0,0 +1,6 @@
+rust-proxmox-match-expression (0.1.0-1) unstable; urgency=medium
+
+  * initial version.
+
+ -- Proxmox Support Team <support@proxmox.com>  Thu, 18 Jun 2026 14:02:03 +0200
+
diff --git a/proxmox-match-expression/debian/control b/proxmox-match-expression/debian/control
new file mode 100644
index 00000000..14bc8999
--- /dev/null
+++ b/proxmox-match-expression/debian/control
@@ -0,0 +1,34 @@
+Source: rust-proxmox-match-expression
+Section: rust
+Priority: optional
+Build-Depends: debhelper-compat (= 13),
+ dh-sequence-cargo
+Build-Depends-Arch: cargo:native <!nocheck>,
+ rustc:native <!nocheck>,
+ libstd-rust-dev <!nocheck>,
+ librust-serde-1+default-dev <!nocheck>,
+ librust-serde-1+derive-dev <!nocheck>
+Maintainer: Proxmox Support Team <support@proxmox.com>
+Standards-Version: 4.7.2
+Vcs-Git: git://git.proxmox.com/git/proxmox.git
+Vcs-Browser: https://git.proxmox.com/?p=proxmox.git
+Homepage: https://proxmox.com
+X-Cargo-Crate: proxmox-match-expression
+
+Package: librust-proxmox-match-expression-dev
+Architecture: any
+Multi-Arch: same
+Depends:
+ ${misc:Depends},
+ librust-serde-1+default-dev,
+ librust-serde-1+derive-dev
+Provides:
+ librust-proxmox-match-expression+default-dev (= ${binary:Version}),
+ librust-proxmox-match-expression-0-dev (= ${binary:Version}),
+ librust-proxmox-match-expression-0+default-dev (= ${binary:Version}),
+ librust-proxmox-match-expression-0.1-dev (= ${binary:Version}),
+ librust-proxmox-match-expression-0.1+default-dev (= ${binary:Version}),
+ librust-proxmox-match-expression-0.1.0-dev (= ${binary:Version}),
+ librust-proxmox-match-expression-0.1.0+default-dev (= ${binary:Version})
+Description: Evaluation logic for nested match expressions - Rust source code
+ Source code for Debianized Rust crate "proxmox-match-expression"
diff --git a/proxmox-match-expression/debian/copyright b/proxmox-match-expression/debian/copyright
new file mode 100644
index 00000000..279c2689
--- /dev/null
+++ b/proxmox-match-expression/debian/copyright
@@ -0,0 +1,18 @@
+Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+
+Files:
+ *
+Copyright: 2024 - 2026 Proxmox Server Solutions GmbH <support@proxmox.com>
+License: AGPL-3.0-or-later
+ This program is free software: you can redistribute it and/or modify it under
+ the terms of the GNU Affero General Public License as published by the Free
+ Software Foundation, either version 3 of the License, or (at your option) any
+ later version.
+ .
+ This program is distributed in the hope that it will be useful, but WITHOUT
+ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+ details.
+ .
+ You should have received a copy of the GNU Affero General Public License along
+ with this program. If not, see <https://www.gnu.org/licenses/>.
diff --git a/proxmox-match-expression/debian/debcargo.toml b/proxmox-match-expression/debian/debcargo.toml
new file mode 100644
index 00000000..b7864cdb
--- /dev/null
+++ b/proxmox-match-expression/debian/debcargo.toml
@@ -0,0 +1,7 @@
+overlay = "."
+crate_src_path = ".."
+maintainer = "Proxmox Support Team <support@proxmox.com>"
+
+[source]
+vcs_git = "git://git.proxmox.com/git/proxmox.git"
+vcs_browser = "https://git.proxmox.com/?p=proxmox.git"
diff --git a/proxmox-match-expression/src/lib.rs b/proxmox-match-expression/src/lib.rs
new file mode 100644
index 00000000..da07e1da
--- /dev/null
+++ b/proxmox-match-expression/src/lib.rs
@@ -0,0 +1,524 @@
+//! De/serializable expression evaluation with custom leaf-nodes.
+//!
+//! ## Example:
+//!```
+//! use serde::{Serialize, Deserialize};
+//! use pretty_assertions::{assert_eq, assert_str_eq};
+//!
+//! use proxmox_match_expression::{any_of, Expression, MatchExpression};
+//!
+//! #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
+//! #[serde(rename_all = "kebab-case", tag = "type")]
+//! enum StringMatcher {
+//!     AllUppercase,
+//!     Content { value: String },
+//! }
+//!
+//! impl MatchExpression for StringMatcher {
+//!     type Data = String;
+//!     type Error = ();
+//!
+//!    fn evaluate(&self, data: &Self::Data) -> Result<bool, ()> {
+//!         match self {
+//!             StringMatcher::AllUppercase => Ok(&data.to_uppercase() == data),
+//!             StringMatcher::Content { value } => Ok(value == data),
+//!         }
+//!     }
+//! }
+//!
+//! impl From<StringMatcher> for Expression<StringMatcher> {
+//!     fn from(matcher: StringMatcher) -> Self {
+//!         Expression::Match(matcher)
+//!     }
+//! }
+//!
+//! // You can use macros to conveniently construct expressions in code.
+//! let expr = any_of![
+//!     StringMatcher::Content {
+//!         value: "foo".to_string(),
+//!     }
+//!     .into(),
+//!     StringMatcher::AllUppercase.into(),
+//! ];
+//!
+//! // Expressions implement `[Serialize]` and `[Deserialize]`
+//! let expected_serialized_expr = r#"{
+//!  "any-of": [
+//!    {
+//!      "match": {
+//!        "type": "content",
+//!        "value": "foo"
+//!      }
+//!    },
+//!    {
+//!      "match": {
+//!        "type": "all-uppercase"
+//!      }
+//!    }
+//!  ]
+//!}"#;
+//!
+//! assert_eq!(serde_json::to_string_pretty(&expr).unwrap(), expected_serialized_expr);
+//!
+//! assert!(expr.evaluate(&"foo".to_string()).unwrap().is_match());
+//! assert!(!expr.evaluate(&"fo".to_string()).unwrap().is_match());
+//! assert!(!expr.evaluate(&"fooo".to_string()).unwrap().is_match());
+//!
+//! let evaluated_expression = expr.evaluate(&"AAAAA".to_string()).unwrap();
+//! assert!(evaluated_expression.is_match());
+//!
+//! // `[Expression::evaluate]` returns [`EvaluatedExpression`], which contains a full trace
+//! // of all expressions that were evaluated.
+//!
+//! let serialized = serde_json::to_string_pretty(&evaluated_expression).unwrap();
+//! let expected = r#"{
+//!  "any-of": [
+//!    {
+//!      "match": {
+//!        "type": "content",
+//!        "value": "foo"
+//!      },
+//!      "matches": false
+//!    },
+//!    {
+//!      "match": {
+//!        "type": "all-uppercase"
+//!      },
+//!      "matches": true
+//!    }
+//!  ],
+//!  "matches": true
+//!}"#;
+//!
+//! assert_str_eq!(serialized, expected);
+//!
+//! assert!(!evaluated_expression.children()[0].is_match());
+//! assert!(evaluated_expression.children()[1].is_match());
+//!
+//! assert_eq!(evaluated_expression.children()[1].children(), &[]);
+//!
+//! ```
+
+use serde::{Deserialize, Serialize};
+
+/// Trait for custom expression leaf-nodes.
+pub trait MatchExpression {
+    /// Use this type to specify the type of the data the expression is evaluated against.
+    type Data;
+
+    /// Error type returned when evaluating this custom expression.
+    type Error;
+
+    /// Evaluate this expression.
+    ///
+    /// If there are no errors, this should return `Ok(true)` or `Ok(false)`, depending on
+    /// whether this leaf-node expression matched the provided data or not.
+    fn evaluate(&self, data: &Self::Data) -> Result<bool, Self::Error>;
+}
+
+#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
+#[serde(rename_all = "kebab-case")]
+/// Match expression.
+pub enum Expression<M> {
+    /// Match if all of the sub-expression match.
+    AllOf(Vec<Expression<M>>),
+    /// Match if any of the sub-expressions matches.
+    AnyOf(Vec<Expression<M>>),
+    /// Match if exactly one of the sub-expressions matches.
+    OneOf(Vec<Expression<M>>),
+    /// Match if the sub-expression does not match.
+    Not(Box<Expression<M>>),
+    /// Constant value (true or false).
+    Constant(bool),
+    /// Custom matcher leaf-nodes.
+    Match(M),
+}
+
+#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
+#[serde(rename_all = "kebab-case")]
+/// Evaluated match expression.
+pub enum EvaluatedExpression<M> {
+    /// Match if all of the sub-expressions match.
+    AllOf(Vec<EvaluatedExpressionWithResult<M>>),
+    /// Match if any of the sub-expressions matches.
+    AnyOf(Vec<EvaluatedExpressionWithResult<M>>),
+    /// Match if exactly one of the sub-expressions matches.
+    OneOf(Vec<EvaluatedExpressionWithResult<M>>),
+    /// Match if the sub-expression does not match.
+    Not(Box<EvaluatedExpressionWithResult<M>>),
+    /// Constant value (true or false).
+    Constant(bool),
+    /// Custom matcher leaf-nodes.
+    Match(M),
+}
+
+/// Evaluated expression resulting from [`Expression::evaluate`].
+///
+/// This type contains the original expression, augmented with per-node
+/// results. This is useful for recording 'traces', e.g. for recording
+/// exactly why an expression matched on the data or not.
+#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
+pub struct EvaluatedExpressionWithResult<M> {
+    /// The actual expression.
+    #[serde(flatten)]
+    expression: EvaluatedExpression<M>,
+    /// The evaluated expression matched the provided data.
+    matches: bool,
+}
+
+impl<M> EvaluatedExpressionWithResult<M> {
+    /// Return whether the expression matched the provided data.
+    pub fn is_match(&self) -> bool {
+        self.matches
+    }
+
+    /// Borrow the contained [`EvaluatedExpression`].
+    pub fn expression(&self) -> &EvaluatedExpression<M> {
+        &self.expression
+    }
+
+    /// Return the contained [`EvaluatedExpression`], consuming `self`.
+    pub fn into_expression(self) -> EvaluatedExpression<M> {
+        self.expression
+    }
+
+    /// The direct child nodes, uniform across all combinator kinds.
+    ///
+    /// 'not' yields its single operand as a one-element slice; leaf nodes
+    /// ('constant', 'match') yield an empty slice. This lets a generic tree
+    /// walker recurse without special-casing each variant.
+    pub fn children(&self) -> &[EvaluatedExpressionWithResult<M>] {
+        match &self.expression {
+            EvaluatedExpression::AllOf(expressions)
+            | EvaluatedExpression::AnyOf(expressions)
+            | EvaluatedExpression::OneOf(expressions) => expressions,
+            EvaluatedExpression::Not(expression) => std::slice::from_ref(&**expression),
+            EvaluatedExpression::Constant(_) | EvaluatedExpression::Match(_) => &[],
+        }
+    }
+}
+
+impl<M> From<EvaluatedExpressionWithResult<M>> for EvaluatedExpression<M> {
+    fn from(value: EvaluatedExpressionWithResult<M>) -> Self {
+        value.into_expression()
+    }
+}
+
+impl<M, D, E> Expression<M>
+where
+    M: Clone + MatchExpression<Data = D, Error = E>,
+{
+    /// Evaluate this expression against a provided variable.
+    ///
+    /// This function returns an `EvaluatedExpression`, replicating the exact structure
+    /// of `self`, but annotated with a per-subexpression trace to record which of
+    /// the subexpressions matched or not.
+    ///
+    /// # Note
+    /// This evaluates the expression and *all* sub-expressions fully and does not return
+    /// early if the final result is already determined by the already evaluated sub-expressions
+    /// (for instance, [`Expression::AnyOf`] and the first sub-expression matched).
+    /// This is done so that we can return the full trace via the [`EvaluatedExpressionWithResult`]
+    /// type.
+    ///
+    /// If you need fast evaluation and do not care about the trace, feel free to add a
+    /// `evaluate_fast` (or similar), which does early returns as appropriate and simply returns
+    /// `bool`.
+    pub fn evaluate(&self, data: &D) -> Result<EvaluatedExpressionWithResult<M>, E> {
+        let evaluated_expression = match self {
+            Expression::AllOf(expressions) => {
+                let (matches, evaluated_expressions) =
+                    Self::evaluate_subexpressions(data, expressions, true, |a, b| a && b)?;
+
+                EvaluatedExpressionWithResult {
+                    expression: EvaluatedExpression::AllOf(evaluated_expressions),
+                    matches,
+                }
+            }
+            Expression::AnyOf(expressions) => {
+                let (matches, evaluated_expressions) =
+                    Self::evaluate_subexpressions(data, expressions, false, |a, b| a || b)?;
+
+                EvaluatedExpressionWithResult {
+                    expression: EvaluatedExpression::AnyOf(evaluated_expressions),
+                    matches,
+                }
+            }
+            Expression::OneOf(expressions) => {
+                let mut matched_count = 0;
+                let mut evaluated_expressions = Vec::new();
+
+                for expression in expressions {
+                    let evaluated_expression = expression.evaluate(data)?;
+                    if evaluated_expression.is_match() {
+                        matched_count += 1;
+                    }
+                    evaluated_expressions.push(evaluated_expression)
+                }
+
+                EvaluatedExpressionWithResult {
+                    expression: EvaluatedExpression::OneOf(evaluated_expressions),
+                    matches: matched_count == 1,
+                }
+            }
+            Expression::Not(expression) => {
+                let evaluated_expression = expression.evaluate(data)?;
+                let matches = !evaluated_expression.is_match();
+                EvaluatedExpressionWithResult {
+                    expression: EvaluatedExpression::Not(Box::new(evaluated_expression)),
+                    matches,
+                }
+            }
+            Expression::Constant(value) => EvaluatedExpressionWithResult {
+                expression: EvaluatedExpression::Constant(*value),
+                matches: *value,
+            },
+            Expression::Match(inner) => {
+                let matches = inner.evaluate(data)?;
+                EvaluatedExpressionWithResult {
+                    expression: EvaluatedExpression::Match(inner.clone()),
+                    matches,
+                }
+            }
+        };
+
+        Ok(evaluated_expression)
+    }
+
+    /// The direct child nodes, uniform across all combinator kinds.
+    ///
+    /// 'not' yields its single operand as a one-element slice; leaf nodes
+    /// ('constant', 'match') yield an empty slice. This lets a generic tree
+    /// walker recurse without special-casing each variant.
+    pub fn children(&self) -> &[Expression<M>] {
+        match &self {
+            Expression::AllOf(expressions)
+            | Expression::AnyOf(expressions)
+            | Expression::OneOf(expressions) => expressions,
+            Expression::Not(expression) => std::slice::from_ref(&**expression),
+            Expression::Constant(_) | Expression::Match(_) => &[],
+        }
+    }
+
+    fn evaluate_subexpressions(
+        data: &D,
+        expressions: &[Expression<M>],
+        initial: bool,
+        op: impl Fn(bool, bool) -> bool,
+    ) -> Result<(bool, Vec<EvaluatedExpressionWithResult<M>>), E> {
+        let mut matches = initial;
+
+        let mut evaluated_expressions = Vec::new();
+
+        for expression in expressions {
+            let evaluated_expression = expression.evaluate(data)?;
+            matches = op(matches, evaluated_expression.is_match());
+            evaluated_expressions.push(evaluated_expression);
+        }
+
+        Ok((matches, evaluated_expressions))
+    }
+}
+
+/// Convenience macro that can be used to create an 'AnyOf' expression.
+///
+/// ```
+/// use proxmox_match_expression::{any_of, Expression};
+///
+/// let x: Expression<()> = any_of![
+///     Expression::Constant(true),
+/// ];
+/// assert_eq!(x, Expression::AnyOf(vec![Expression::Constant(true)]));
+/// ```
+///
+#[macro_export]
+macro_rules! any_of {
+    ($($x:expr),* $(,)?) => (
+        $crate::Expression::AnyOf(vec![$($x),*])
+    );
+}
+
+/// Convenience macro that can be used to create an 'AllOf' expression.
+///
+/// ```
+/// use proxmox_match_expression::{all_of, Expression};
+///
+/// let x: Expression<()> = all_of![
+///     Expression::Constant(true),
+/// ];
+/// assert_eq!(x, Expression::AllOf(vec![Expression::Constant(true)]));
+/// ```
+#[macro_export]
+macro_rules! all_of {
+    ($($x:expr),* $(,)?) => (
+        $crate::Expression::AllOf(vec![$($x),*])
+    );
+}
+
+/// Convenience macro that can be used to create an 'OneOf' expression.
+///
+/// ```
+/// use proxmox_match_expression::{one_of, Expression};
+///
+/// let x: Expression<()> = one_of![
+///     Expression::Constant(true),
+/// ];
+/// assert_eq!(x, Expression::OneOf(vec![Expression::Constant(true)]));
+/// ```
+#[macro_export]
+macro_rules! one_of {
+    ($($x:expr),* $(,)?) => (
+        $crate::Expression::OneOf(vec![$($x),*])
+    );
+}
+
+/// Convenience macro that can be used to create a 'Not' expression;
+///
+/// ```
+/// use proxmox_match_expression::{not, Expression};
+///
+/// let x: Expression<()> = not!(Expression::Constant(true));
+/// assert_eq!(x, Expression::Not(Box::new(Expression::Constant(true))));
+/// ```
+#[macro_export]
+macro_rules! not {
+    ($e:expr) => {
+        $crate::Expression::Not(Box::new($e))
+    };
+}
+
+#[cfg(test)]
+mod test {
+    use super::*;
+
+    #[derive(Serialize, Deserialize, Debug, Clone)]
+    #[serde(rename_all = "kebab-case", tag = "type")]
+    pub enum TestMatcher {
+        Failure,
+        True,
+        False,
+    }
+
+    impl MatchExpression for TestMatcher {
+        type Data = ();
+        type Error = ();
+
+        fn evaluate(&self, _data: &Self::Data) -> Result<bool, ()> {
+            match self {
+                TestMatcher::Failure => Err(()),
+                TestMatcher::True => Ok(true),
+                TestMatcher::False => Ok(false),
+            }
+        }
+    }
+
+    fn eval(expr: Expression<TestMatcher>) -> Result<bool, ()> {
+        let e = expr.evaluate(&())?;
+
+        Ok(e.is_match())
+    }
+
+    #[test]
+    fn test_all_of() {
+        let expr = all_of![Expression::Constant(true), Expression::Constant(false)];
+        assert!(!eval(expr).unwrap());
+
+        let expr = all_of![Expression::Constant(true), Expression::Constant(true)];
+        assert!(eval(expr).unwrap());
+
+        let expr = all_of![Expression::Constant(true)];
+        assert!(eval(expr).unwrap());
+
+        let expr: Expression<TestMatcher> = all_of![];
+        assert!(eval(expr).unwrap());
+
+        let expr = all_of![Expression::Match(TestMatcher::Failure)];
+        assert!(eval(expr).is_err());
+    }
+
+    #[test]
+    fn test_any_of() {
+        let expr = any_of![Expression::Constant(true), Expression::Constant(false)];
+        assert!(eval(expr).unwrap());
+
+        let expr = any_of![Expression::Constant(false), Expression::Constant(false)];
+        assert!(!eval(expr).unwrap());
+
+        let expr = any_of![Expression::Constant(true)];
+        assert!(eval(expr).unwrap());
+
+        let expr: Expression<TestMatcher> = any_of![];
+        assert!(!eval(expr).unwrap());
+
+        let expr = any_of![Expression::Match(TestMatcher::Failure)];
+        assert!(eval(expr).is_err());
+    }
+
+    #[test]
+    fn test_one_of() {
+        let expr = one_of![Expression::Constant(true), Expression::Constant(false)];
+        assert!(eval(expr).unwrap());
+
+        let expr = one_of![Expression::Constant(false)];
+        assert!(!eval(expr).unwrap());
+
+        let expr = one_of![Expression::Constant(true)];
+        assert!(eval(expr).unwrap());
+
+        let expr: Expression<TestMatcher> = one_of![];
+        assert!(!eval(expr).unwrap());
+
+        let expr = one_of![Expression::Match(TestMatcher::Failure)];
+        assert!(eval(expr).is_err());
+
+        // Exactly one of three sub-expressions matches.
+        let expr = one_of![
+            Expression::Constant(false),
+            Expression::Constant(true),
+            Expression::Constant(false),
+        ];
+        assert!(eval(expr).unwrap());
+
+        // Two of three match. This must *not* match: it distinguishes
+        // 'exactly one' from XOR-parity, which would report a match for
+        // any odd number of matching sub-expressions.
+        let expr = one_of![
+            Expression::Constant(true),
+            Expression::Constant(true),
+            Expression::Constant(false),
+        ];
+        assert!(!eval(expr).unwrap());
+
+        // Three of three match. XOR-parity would (incorrectly) report a
+        // match here, since three is odd.
+        let expr = one_of![
+            Expression::Constant(true),
+            Expression::Constant(true),
+            Expression::Constant(true),
+        ];
+        assert!(!eval(expr).unwrap());
+
+        // None of three match.
+        let expr = one_of![
+            Expression::Constant(false),
+            Expression::Constant(false),
+            Expression::Constant(false),
+        ];
+        assert!(!eval(expr).unwrap());
+    }
+
+    #[test]
+    fn test_not() {
+        let expr = not!(Expression::Constant(false));
+        assert!(eval(expr).unwrap());
+    }
+
+    #[test]
+    fn test_constant() {
+        let expr: Expression<TestMatcher> = Expression::Constant(true);
+        assert!(eval(expr).unwrap());
+
+        let expr: Expression<TestMatcher> = Expression::Constant(false);
+        assert!(!eval(expr).unwrap());
+    }
+}
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 35+ messages in thread

* [PATCH proxmox v2 02/34] notify: log which matchers matched on a notification
  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 ` Lukas Wagner
  2026-10-01  8:36 ` [PATCH proxmox v2 03/34] notify: promote matcher to dir-style module Lukas Wagner
                   ` (31 subsequent siblings)
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:36 UTC (permalink / raw)
  To: pbs-devel, pve-devel

This should make debugging notification issues much easier. Eventually,
this information shall also be recorded in a notification history, but
adding the log output is a valuable addition until then.

Suggested-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 proxmox-notify/src/matcher.rs | 18 ++++++++++++++++--
 1 file changed, 16 insertions(+), 2 deletions(-)

diff --git a/proxmox-notify/src/matcher.rs b/proxmox-notify/src/matcher.rs
index 9964e4bf..cce86b6c 100644
--- a/proxmox-notify/src/matcher.rs
+++ b/proxmox-notify/src/matcher.rs
@@ -442,6 +442,7 @@ pub fn check_matches<'a>(
     notification: &Notification,
 ) -> HashSet<&'a str> {
     let mut targets = HashSet::new();
+    let mut matching_matchers = Vec::new();
 
     for matcher in matchers {
         if matcher.disable.unwrap_or_default() {
@@ -451,14 +452,27 @@ pub fn check_matches<'a>(
         }
 
         match matcher.matches(notification) {
-            Ok(t) => {
-                let t = t.unwrap_or_default();
+            Ok(Some(t)) => {
+                matching_matchers.push(matcher.name.clone());
                 targets.extend(t.iter().map(|s| s.as_str()));
             }
+            Ok(None) => {}
             Err(err) => error!("matcher '{matcher}' failed: {err}", matcher = matcher.name),
         }
     }
 
+    if !matching_matchers.is_empty() {
+        let target_list = Vec::from_iter(targets.clone());
+
+        info!(
+            "notification matched the following matchers: {}. The following targets will be notified: {}",
+            matching_matchers.join(", "),
+            target_list.join(", ")
+        );
+    } else {
+        info!("notification was not matched by any matcher");
+    }
+
     targets
 }
 
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 35+ messages in thread

* [PATCH proxmox v2 03/34] notify: promote matcher to dir-style module
  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 ` Lukas Wagner
  2026-10-01  8:36 ` [PATCH proxmox v2 04/34] notify: fix doc comment Lukas Wagner
                   ` (30 subsequent siblings)
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:36 UTC (permalink / raw)
  To: pbs-devel, pve-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 proxmox-notify/src/{matcher.rs => matcher/mod.rs} | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 rename proxmox-notify/src/{matcher.rs => matcher/mod.rs} (100%)

diff --git a/proxmox-notify/src/matcher.rs b/proxmox-notify/src/matcher/mod.rs
similarity index 100%
rename from proxmox-notify/src/matcher.rs
rename to proxmox-notify/src/matcher/mod.rs
-- 
2.47.3





^ permalink raw reply	[flat|nested] 35+ messages in thread

* [PATCH proxmox v2 04/34] notify: fix doc comment
  2026-10-01  8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
                   ` (2 preceding siblings ...)
  2026-10-01  8:36 ` [PATCH proxmox v2 03/34] notify: promote matcher to dir-style module Lukas Wagner
@ 2026-10-01  8:36 ` Lukas Wagner
  2026-10-01  8:36 ` [PATCH proxmox v2 05/34] notify: matcher: break out severity matcher into submodule Lukas Wagner
                   ` (29 subsequent siblings)
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:36 UTC (permalink / raw)
  To: pbs-devel, pve-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 proxmox-notify/src/matcher/mod.rs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/proxmox-notify/src/matcher/mod.rs b/proxmox-notify/src/matcher/mod.rs
index cce86b6c..9d9d5d42 100644
--- a/proxmox-notify/src/matcher/mod.rs
+++ b/proxmox-notify/src/matcher/mod.rs
@@ -108,7 +108,7 @@ pub const MATCH_FIELD_ENTRY_SCHEMA: Schema = StringSchema::new("Match metadata f
     })]
 #[derive(Debug, Serialize, Deserialize, Updater, Default)]
 #[serde(rename_all = "kebab-case")]
-/// Config for Sendmail notification endpoints
+/// Config for notification matchers.
 pub struct MatcherConfig {
     /// Name of the matcher.
     #[updater(skip)]
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 35+ messages in thread

* [PATCH proxmox v2 05/34] notify: matcher: break out severity matcher into submodule
  2026-10-01  8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
                   ` (3 preceding siblings ...)
  2026-10-01  8:36 ` [PATCH proxmox v2 04/34] notify: fix doc comment Lukas Wagner
@ 2026-10-01  8:36 ` Lukas Wagner
  2026-10-01  8:36 ` [PATCH proxmox v2 06/34] notify: matcher: break out field " Lukas Wagner
                   ` (28 subsequent siblings)
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:36 UTC (permalink / raw)
  To: pbs-devel, pve-devel

No functional changes.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 proxmox-notify/src/matcher/mod.rs      | 56 +++-------------------
 proxmox-notify/src/matcher/severity.rs | 66 ++++++++++++++++++++++++++
 2 files changed, 73 insertions(+), 49 deletions(-)
 create mode 100644 proxmox-notify/src/matcher/severity.rs

diff --git a/proxmox-notify/src/matcher/mod.rs b/proxmox-notify/src/matcher/mod.rs
index 9d9d5d42..b27f72c0 100644
--- a/proxmox-notify/src/matcher/mod.rs
+++ b/proxmox-notify/src/matcher/mod.rs
@@ -13,7 +13,11 @@ use proxmox_schema::{ApiStringFormat, Schema, StringSchema, Updater, api, const_
 use proxmox_time::{DailyDuration, parse_daily_duration};
 
 use crate::schema::ENTITY_NAME_SCHEMA;
-use crate::{Error, Notification, Origin, Severity};
+use crate::{Error, Notification, Origin};
+
+pub mod severity;
+
+use severity::SeverityMatcher;
 
 pub const MATCHER_TYPENAME: &str = "matcher";
 
@@ -337,46 +341,6 @@ impl MatcherConfig {
     }
 }
 
-/// Match severity of the notification.
-#[derive(Clone, Debug)]
-pub struct SeverityMatcher {
-    severities: Vec<Severity>,
-}
-
-proxmox_serde::forward_deserialize_to_from_str!(SeverityMatcher);
-proxmox_serde::forward_serialize_to_display!(SeverityMatcher);
-
-/// Common trait implemented by all matching directives
-impl MatchDirective for SeverityMatcher {
-    /// Check if this directive matches a given notification
-    fn matches(&self, notification: &Notification) -> Result<bool, Error> {
-        Ok(self.severities.contains(&notification.metadata.severity))
-    }
-}
-
-impl fmt::Display for SeverityMatcher {
-    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
-        let severities: Vec<String> = self.severities.iter().map(|s| format!("{s}")).collect();
-        f.write_str(&severities.join(","))
-    }
-}
-
-impl FromStr for SeverityMatcher {
-    type Err = Error;
-    fn from_str(s: &str) -> Result<Self, Error> {
-        let mut severities = Vec::new();
-
-        for element in s.split(',') {
-            let element = element.trim();
-            let severity: Severity = element.parse()?;
-
-            severities.push(severity)
-        }
-
-        Ok(Self { severities })
-    }
-}
-
 /// Match timestamp of the notification.
 #[derive(Clone, Debug)]
 pub struct CalendarMatcher {
@@ -478,6 +442,8 @@ pub fn check_matches<'a>(
 
 #[cfg(test)]
 mod tests {
+    use crate::Severity;
+
     use super::*;
     use serde_json::Value;
     use std::collections::HashMap;
@@ -519,14 +485,6 @@ mod tests {
         assert!("regex:'3=b.*".parse::<FieldMatcher>().is_err());
         assert!("invalid:'bar=b.*".parse::<FieldMatcher>().is_err());
     }
-    #[test]
-    fn test_severities() {
-        let notification =
-            Notification::from_template(Severity::Notice, "test", Value::Null, Default::default());
-
-        let matcher: SeverityMatcher = "info,notice,warning,error".parse().unwrap();
-        assert!(matcher.matches(&notification).unwrap());
-    }
 
     #[test]
     fn test_empty_matcher_matches_always() {
diff --git a/proxmox-notify/src/matcher/severity.rs b/proxmox-notify/src/matcher/severity.rs
new file mode 100644
index 00000000..4b126d85
--- /dev/null
+++ b/proxmox-notify/src/matcher/severity.rs
@@ -0,0 +1,66 @@
+use std::fmt;
+use std::str::FromStr;
+
+use crate::{Error, Notification, Severity};
+
+use super::MatchDirective;
+
+/// Match severity of the notification.
+#[derive(Clone, Debug)]
+pub struct SeverityMatcher {
+    severities: Vec<Severity>,
+}
+
+/// Common trait implemented by all matching directives
+impl MatchDirective for SeverityMatcher {
+    /// Check if this directive matches a given notification
+    fn matches(&self, notification: &Notification) -> Result<bool, Error> {
+        Ok(self.severities.contains(&notification.metadata.severity))
+    }
+}
+
+impl fmt::Display for SeverityMatcher {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        let severities: Vec<String> = self.severities.iter().map(|s| format!("{s}")).collect();
+        f.write_str(&severities.join(","))
+    }
+}
+
+impl FromStr for SeverityMatcher {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Error> {
+        let mut severities = Vec::new();
+
+        for element in s.split(',') {
+            let element = element.trim();
+            let severity: Severity = element.parse()?;
+
+            severities.push(severity)
+        }
+
+        Ok(Self { severities })
+    }
+}
+
+proxmox_serde::forward_deserialize_to_from_str!(SeverityMatcher);
+proxmox_serde::forward_serialize_to_display!(SeverityMatcher);
+
+#[cfg(test)]
+mod test {
+    use serde_json::Value;
+
+    use crate::{
+        Notification, Severity,
+        matcher::{MatchDirective as _, severity::SeverityMatcher},
+    };
+
+    #[test]
+    fn test_severities() {
+        let notification =
+            Notification::from_template(Severity::Notice, "test", Value::Null, Default::default());
+
+        let matcher: SeverityMatcher = "info,notice,warning,error".parse().unwrap();
+        assert!(matcher.matches(&notification).unwrap());
+    }
+}
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 35+ messages in thread

* [PATCH proxmox v2 06/34] notify: matcher: break out field matcher into submodule
  2026-10-01  8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
                   ` (4 preceding siblings ...)
  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 ` Lukas Wagner
  2026-10-01  8:36 ` [PATCH proxmox v2 07/34] notify: matcher: break out calendar " Lukas Wagner
                   ` (27 subsequent siblings)
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:36 UTC (permalink / raw)
  To: pbs-devel, pve-devel

No functional changes.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 proxmox-notify/src/matcher/field.rs | 201 ++++++++++++++++++++++++++++
 proxmox-notify/src/matcher/mod.rs   | 184 +------------------------
 2 files changed, 205 insertions(+), 180 deletions(-)
 create mode 100644 proxmox-notify/src/matcher/field.rs

diff --git a/proxmox-notify/src/matcher/field.rs b/proxmox-notify/src/matcher/field.rs
new file mode 100644
index 00000000..8165b1f4
--- /dev/null
+++ b/proxmox-notify/src/matcher/field.rs
@@ -0,0 +1,201 @@
+use std::{fmt, str::FromStr};
+
+use const_format::concatcp;
+use regex::Regex;
+
+use proxmox_schema::{
+    ApiStringFormat, Schema, StringSchema, api_types::SAFE_ID_REGEX_STR, const_regex,
+};
+
+use crate::{Error, Notification};
+
+use super::MatchDirective;
+
+const_regex! {
+    pub MATCH_FIELD_ENTRY_REGEX = concatcp!(r"^(?:(exact|regex):)?(", SAFE_ID_REGEX_STR, r")=(.*)$");
+}
+
+pub const MATCH_FIELD_ENTRY_FORMAT: ApiStringFormat =
+    ApiStringFormat::VerifyFn(verify_field_matcher);
+
+fn verify_field_matcher(s: &str) -> Result<(), anyhow::Error> {
+    let _: FieldMatcher = s.parse()?;
+    Ok(())
+}
+
+pub const MATCH_FIELD_ENTRY_SCHEMA: Schema = StringSchema::new("Match metadata field.")
+    .format(&MATCH_FIELD_ENTRY_FORMAT)
+    .min_length(1)
+    .max_length(1024)
+    .schema();
+
+/// Check if the notification metadata fields match
+#[derive(Clone, Debug)]
+pub enum FieldMatcher {
+    Exact {
+        field: String,
+        matched_values: Vec<String>,
+    },
+    Regex {
+        field: String,
+        matched_regex: Regex,
+    },
+}
+
+impl MatchDirective for FieldMatcher {
+    fn matches(&self, notification: &Notification) -> Result<bool, Error> {
+        Ok(match self {
+            FieldMatcher::Exact {
+                field,
+                matched_values,
+            } => {
+                let value = notification.metadata.additional_fields.get(field);
+
+                if let Some(value) = value {
+                    matched_values.contains(value)
+                } else {
+                    // Metadata field does not exist, so we do not match
+                    false
+                }
+            }
+            FieldMatcher::Regex {
+                field,
+                matched_regex,
+            } => {
+                let value = notification.metadata.additional_fields.get(field);
+
+                if let Some(value) = value {
+                    matched_regex.is_match(value)
+                } else {
+                    // Metadata field does not exist, so we do not match
+                    false
+                }
+            }
+        })
+    }
+}
+
+impl fmt::Display for FieldMatcher {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        // Attention, Display is used to implement Serialize, do not
+        // change the format.
+
+        match self {
+            FieldMatcher::Exact {
+                field,
+                matched_values,
+            } => {
+                let values = matched_values.join(",");
+                write!(f, "exact:{field}={values}")
+            }
+            FieldMatcher::Regex {
+                field,
+                matched_regex,
+            } => {
+                let re = matched_regex.as_str();
+                write!(f, "regex:{field}={re}")
+            }
+        }
+    }
+}
+
+impl FromStr for FieldMatcher {
+    type Err = Error;
+    fn from_str(s: &str) -> Result<Self, Error> {
+        if !MATCH_FIELD_ENTRY_REGEX.is_match(s) {
+            return Err(Error::FilterFailed(format!(
+                "invalid match-field statement: {s}"
+            )));
+        }
+
+        if let Some(remaining) = s.strip_prefix("regex:") {
+            match remaining.split_once('=') {
+                None => Err(Error::FilterFailed(format!(
+                    "invalid match-field statement: {s}"
+                ))),
+                Some((field, expected_value_regex)) => {
+                    let regex = Regex::new(expected_value_regex)
+                        .map_err(|err| Error::FilterFailed(format!("invalid regex: {err}")))?;
+
+                    Ok(Self::Regex {
+                        field: field.into(),
+                        matched_regex: regex,
+                    })
+                }
+            }
+        } else if let Some(remaining) = s.strip_prefix("exact:") {
+            match remaining.split_once('=') {
+                None => Err(Error::FilterFailed(format!(
+                    "invalid match-field statement: {s}"
+                ))),
+                Some((field, expected_values)) => {
+                    let values: Vec<String> = expected_values
+                        .split(',')
+                        .map(str::trim)
+                        .map(String::from)
+                        .collect();
+                    Ok(Self::Exact {
+                        field: field.into(),
+                        matched_values: values,
+                    })
+                }
+            }
+        } else {
+            Err(Error::FilterFailed(format!(
+                "invalid match-field statement: {s}"
+            )))
+        }
+    }
+}
+
+proxmox_serde::forward_deserialize_to_from_str!(FieldMatcher);
+proxmox_serde::forward_serialize_to_display!(FieldMatcher);
+
+#[cfg(test)]
+mod tests {
+    use std::collections::HashMap;
+
+    use serde_json::Value;
+
+    use crate::Severity;
+
+    use super::*;
+
+    #[test]
+    fn test_matching() {
+        let mut fields = HashMap::new();
+        fields.insert("foo".into(), "bar".into());
+
+        let notification =
+            Notification::from_template(Severity::Notice, "test", Value::Null, fields);
+
+        let matcher: FieldMatcher = "exact:foo=bar".parse().unwrap();
+        assert!(matcher.matches(&notification).unwrap());
+
+        let matcher: FieldMatcher = "regex:foo=b.*".parse().unwrap();
+        assert!(matcher.matches(&notification).unwrap());
+
+        let matcher: FieldMatcher = "regex:notthere=b.*".parse().unwrap();
+        assert!(!matcher.matches(&notification).unwrap());
+
+        let matcher: FieldMatcher = "exact:foo=bar,test".parse().unwrap();
+        assert!(matcher.matches(&notification).unwrap());
+
+        let mut fields = HashMap::new();
+        fields.insert("foo".into(), "test".into());
+
+        let notification =
+            Notification::from_template(Severity::Notice, "test", Value::Null, fields);
+        assert!(matcher.matches(&notification).unwrap());
+
+        let mut fields = HashMap::new();
+        fields.insert("foo".into(), "notthere".into());
+
+        let notification =
+            Notification::from_template(Severity::Notice, "test", Value::Null, fields);
+        assert!(!matcher.matches(&notification).unwrap());
+
+        assert!("regex:'3=b.*".parse::<FieldMatcher>().is_err());
+        assert!("invalid:'bar=b.*".parse::<FieldMatcher>().is_err());
+    }
+}
diff --git a/proxmox-notify/src/matcher/mod.rs b/proxmox-notify/src/matcher/mod.rs
index b27f72c0..dd0e25b5 100644
--- a/proxmox-notify/src/matcher/mod.rs
+++ b/proxmox-notify/src/matcher/mod.rs
@@ -15,8 +15,10 @@ use proxmox_time::{DailyDuration, parse_daily_duration};
 use crate::schema::ENTITY_NAME_SCHEMA;
 use crate::{Error, Notification, Origin};
 
+pub mod field;
 pub mod severity;
 
+use field::FieldMatcher;
 use severity::SeverityMatcher;
 
 pub const MATCHER_TYPENAME: &str = "matcher";
@@ -51,24 +53,6 @@ impl MatchModeOperator {
     }
 }
 
-const_regex! {
-    pub MATCH_FIELD_ENTRY_REGEX = concatcp!(r"^(?:(exact|regex):)?(", SAFE_ID_REGEX_STR, r")=(.*)$");
-}
-
-pub const MATCH_FIELD_ENTRY_FORMAT: ApiStringFormat =
-    ApiStringFormat::VerifyFn(verify_field_matcher);
-
-fn verify_field_matcher(s: &str) -> Result<(), anyhow::Error> {
-    let _: FieldMatcher = s.parse()?;
-    Ok(())
-}
-
-pub const MATCH_FIELD_ENTRY_SCHEMA: Schema = StringSchema::new("Match metadata field.")
-    .format(&MATCH_FIELD_ENTRY_FORMAT)
-    .min_length(1)
-    .max_length(1024)
-    .schema();
-
 #[api(
     properties: {
         name: {
@@ -163,128 +147,6 @@ trait MatchDirective {
     fn matches(&self, notification: &Notification) -> Result<bool, Error>;
 }
 
-/// Check if the notification metadata fields match
-#[derive(Clone, Debug)]
-pub enum FieldMatcher {
-    Exact {
-        field: String,
-        matched_values: Vec<String>,
-    },
-    Regex {
-        field: String,
-        matched_regex: Regex,
-    },
-}
-
-proxmox_serde::forward_deserialize_to_from_str!(FieldMatcher);
-proxmox_serde::forward_serialize_to_display!(FieldMatcher);
-
-impl MatchDirective for FieldMatcher {
-    fn matches(&self, notification: &Notification) -> Result<bool, Error> {
-        Ok(match self {
-            FieldMatcher::Exact {
-                field,
-                matched_values,
-            } => {
-                let value = notification.metadata.additional_fields.get(field);
-
-                if let Some(value) = value {
-                    matched_values.contains(value)
-                } else {
-                    // Metadata field does not exist, so we do not match
-                    false
-                }
-            }
-            FieldMatcher::Regex {
-                field,
-                matched_regex,
-            } => {
-                let value = notification.metadata.additional_fields.get(field);
-
-                if let Some(value) = value {
-                    matched_regex.is_match(value)
-                } else {
-                    // Metadata field does not exist, so we do not match
-                    false
-                }
-            }
-        })
-    }
-}
-
-impl fmt::Display for FieldMatcher {
-    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
-        // Attention, Display is used to implement Serialize, do not
-        // change the format.
-
-        match self {
-            FieldMatcher::Exact {
-                field,
-                matched_values,
-            } => {
-                let values = matched_values.join(",");
-                write!(f, "exact:{field}={values}")
-            }
-            FieldMatcher::Regex {
-                field,
-                matched_regex,
-            } => {
-                let re = matched_regex.as_str();
-                write!(f, "regex:{field}={re}")
-            }
-        }
-    }
-}
-
-impl FromStr for FieldMatcher {
-    type Err = Error;
-    fn from_str(s: &str) -> Result<Self, Error> {
-        if !MATCH_FIELD_ENTRY_REGEX.is_match(s) {
-            return Err(Error::FilterFailed(format!(
-                "invalid match-field statement: {s}"
-            )));
-        }
-
-        if let Some(remaining) = s.strip_prefix("regex:") {
-            match remaining.split_once('=') {
-                None => Err(Error::FilterFailed(format!(
-                    "invalid match-field statement: {s}"
-                ))),
-                Some((field, expected_value_regex)) => {
-                    let regex = Regex::new(expected_value_regex)
-                        .map_err(|err| Error::FilterFailed(format!("invalid regex: {err}")))?;
-
-                    Ok(Self::Regex {
-                        field: field.into(),
-                        matched_regex: regex,
-                    })
-                }
-            }
-        } else if let Some(remaining) = s.strip_prefix("exact:") {
-            match remaining.split_once('=') {
-                None => Err(Error::FilterFailed(format!(
-                    "invalid match-field statement: {s}"
-                ))),
-                Some((field, expected_values)) => {
-                    let values: Vec<String> = expected_values
-                        .split(',')
-                        .map(str::trim)
-                        .map(String::from)
-                        .collect();
-                    Ok(Self::Exact {
-                        field: field.into(),
-                        matched_values: values,
-                    })
-                }
-            }
-        } else {
-            Err(Error::FilterFailed(format!(
-                "invalid match-field statement: {s}"
-            )))
-        }
-    }
-}
-
 impl MatcherConfig {
     pub fn matches(&self, notification: &Notification) -> Result<Option<&[String]>, Error> {
         let mode = self.mode.unwrap_or_default();
@@ -442,49 +304,11 @@ pub fn check_matches<'a>(
 
 #[cfg(test)]
 mod tests {
+    use serde_json::Value;
+
     use crate::Severity;
 
     use super::*;
-    use serde_json::Value;
-    use std::collections::HashMap;
-
-    #[test]
-    fn test_matching() {
-        let mut fields = HashMap::new();
-        fields.insert("foo".into(), "bar".into());
-
-        let notification =
-            Notification::from_template(Severity::Notice, "test", Value::Null, fields);
-
-        let matcher: FieldMatcher = "exact:foo=bar".parse().unwrap();
-        assert!(matcher.matches(&notification).unwrap());
-
-        let matcher: FieldMatcher = "regex:foo=b.*".parse().unwrap();
-        assert!(matcher.matches(&notification).unwrap());
-
-        let matcher: FieldMatcher = "regex:notthere=b.*".parse().unwrap();
-        assert!(!matcher.matches(&notification).unwrap());
-
-        let matcher: FieldMatcher = "exact:foo=bar,test".parse().unwrap();
-        assert!(matcher.matches(&notification).unwrap());
-
-        let mut fields = HashMap::new();
-        fields.insert("foo".into(), "test".into());
-
-        let notification =
-            Notification::from_template(Severity::Notice, "test", Value::Null, fields);
-        assert!(matcher.matches(&notification).unwrap());
-
-        let mut fields = HashMap::new();
-        fields.insert("foo".into(), "notthere".into());
-
-        let notification =
-            Notification::from_template(Severity::Notice, "test", Value::Null, fields);
-        assert!(!matcher.matches(&notification).unwrap());
-
-        assert!("regex:'3=b.*".parse::<FieldMatcher>().is_err());
-        assert!("invalid:'bar=b.*".parse::<FieldMatcher>().is_err());
-    }
 
     #[test]
     fn test_empty_matcher_matches_always() {
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 35+ messages in thread

* [PATCH proxmox v2 07/34] notify: matcher: break out calendar matcher into submodule
  2026-10-01  8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
                   ` (5 preceding siblings ...)
  2026-10-01  8:36 ` [PATCH proxmox v2 06/34] notify: matcher: break out field " Lukas Wagner
@ 2026-10-01  8:36 ` Lukas Wagner
  2026-10-01  8:36 ` [PATCH proxmox v2 08/34] notify: matcher: calendar: add basic unit test Lukas Wagner
                   ` (26 subsequent siblings)
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:36 UTC (permalink / raw)
  To: pbs-devel, pve-devel

No functional changes.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 proxmox-notify/src/matcher/calendar.rs | 46 ++++++++++++++++++++++++
 proxmox-notify/src/matcher/mod.rs      | 48 +++-----------------------
 2 files changed, 50 insertions(+), 44 deletions(-)
 create mode 100644 proxmox-notify/src/matcher/calendar.rs

diff --git a/proxmox-notify/src/matcher/calendar.rs b/proxmox-notify/src/matcher/calendar.rs
new file mode 100644
index 00000000..5deab13e
--- /dev/null
+++ b/proxmox-notify/src/matcher/calendar.rs
@@ -0,0 +1,46 @@
+use std::fmt;
+use std::str::FromStr;
+
+use proxmox_time::DailyDuration;
+
+use crate::{Error, Notification};
+
+use super::MatchDirective;
+
+/// Match timestamp of the notification.
+#[derive(Clone, Debug)]
+pub struct CalendarMatcher {
+    schedule: DailyDuration,
+    original: String,
+}
+
+impl MatchDirective for CalendarMatcher {
+    fn matches(&self, notification: &Notification) -> Result<bool, Error> {
+        self.schedule
+            .time_match(notification.metadata.timestamp, false)
+            .map_err(|err| Error::Generic(format!("could not match timestamp: {err}")))
+    }
+}
+
+impl fmt::Display for CalendarMatcher {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        f.write_str(&self.original)
+    }
+}
+
+impl FromStr for CalendarMatcher {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Error> {
+        let schedule = proxmox_time::parse_daily_duration(s)
+            .map_err(|e| Error::Generic(format!("could not parse schedule: {e}")))?;
+
+        Ok(Self {
+            schedule,
+            original: s.to_string(),
+        })
+    }
+}
+
+proxmox_serde::forward_deserialize_to_from_str!(CalendarMatcher);
+proxmox_serde::forward_serialize_to_display!(CalendarMatcher);
diff --git a/proxmox-notify/src/matcher/mod.rs b/proxmox-notify/src/matcher/mod.rs
index dd0e25b5..48aaa351 100644
--- a/proxmox-notify/src/matcher/mod.rs
+++ b/proxmox-notify/src/matcher/mod.rs
@@ -1,23 +1,20 @@
 use std::collections::HashSet;
-use std::fmt;
 use std::fmt::Debug;
-use std::str::FromStr;
 
-use const_format::concatcp;
-use regex::Regex;
 use serde::{Deserialize, Serialize};
 use tracing::{error, info};
 
-use proxmox_schema::api_types::{COMMENT_SCHEMA, SAFE_ID_REGEX_STR};
-use proxmox_schema::{ApiStringFormat, Schema, StringSchema, Updater, api, const_regex};
-use proxmox_time::{DailyDuration, parse_daily_duration};
+use proxmox_schema::api_types::COMMENT_SCHEMA;
+use proxmox_schema::{Updater, api};
 
 use crate::schema::ENTITY_NAME_SCHEMA;
 use crate::{Error, Notification, Origin};
 
+pub mod calendar;
 pub mod field;
 pub mod severity;
 
+use calendar::CalendarMatcher;
 use field::FieldMatcher;
 use severity::SeverityMatcher;
 
@@ -203,43 +200,6 @@ impl MatcherConfig {
     }
 }
 
-/// Match timestamp of the notification.
-#[derive(Clone, Debug)]
-pub struct CalendarMatcher {
-    schedule: DailyDuration,
-    original: String,
-}
-
-proxmox_serde::forward_deserialize_to_from_str!(CalendarMatcher);
-proxmox_serde::forward_serialize_to_display!(CalendarMatcher);
-
-impl MatchDirective for CalendarMatcher {
-    fn matches(&self, notification: &Notification) -> Result<bool, Error> {
-        self.schedule
-            .time_match(notification.metadata.timestamp, false)
-            .map_err(|err| Error::Generic(format!("could not match timestamp: {err}")))
-    }
-}
-
-impl fmt::Display for CalendarMatcher {
-    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
-        f.write_str(&self.original)
-    }
-}
-
-impl FromStr for CalendarMatcher {
-    type Err = Error;
-    fn from_str(s: &str) -> Result<Self, Error> {
-        let schedule = parse_daily_duration(s)
-            .map_err(|e| Error::Generic(format!("could not parse schedule: {e}")))?;
-
-        Ok(Self {
-            schedule,
-            original: s.to_string(),
-        })
-    }
-}
-
 #[api]
 #[derive(Serialize, Deserialize)]
 #[serde(rename_all = "kebab-case")]
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 35+ messages in thread

* [PATCH proxmox v2 08/34] notify: matcher: calendar: add basic unit test
  2026-10-01  8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
                   ` (6 preceding siblings ...)
  2026-10-01  8:36 ` [PATCH proxmox v2 07/34] notify: matcher: break out calendar " Lukas Wagner
@ 2026-10-01  8:36 ` Lukas Wagner
  2026-10-01  8:36 ` [PATCH proxmox v2 09/34] notify: matcher: add InlineSeverityMatcher Lukas Wagner
                   ` (25 subsequent siblings)
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:36 UTC (permalink / raw)
  To: pbs-devel, pve-devel

Just a basic smoke test to ensure that the matcher can be constructed
from from a calendar expression.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 proxmox-notify/src/matcher/calendar.rs | 23 +++++++++++++++++++++++
 1 file changed, 23 insertions(+)

diff --git a/proxmox-notify/src/matcher/calendar.rs b/proxmox-notify/src/matcher/calendar.rs
index 5deab13e..970801c5 100644
--- a/proxmox-notify/src/matcher/calendar.rs
+++ b/proxmox-notify/src/matcher/calendar.rs
@@ -44,3 +44,26 @@ impl FromStr for CalendarMatcher {
 
 proxmox_serde::forward_deserialize_to_from_str!(CalendarMatcher);
 proxmox_serde::forward_serialize_to_display!(CalendarMatcher);
+
+#[cfg(test)]
+mod test {
+    use serde_json::Value;
+
+    use crate::{Notification, Severity, matcher::MatchDirective as _};
+
+    use super::*;
+
+    #[test]
+    fn test_calendar_matcher() {
+        let mut notification =
+            Notification::from_template(Severity::Notice, "test", Value::Null, Default::default());
+
+        // Fr 22 Mai 2026 12:45:00 CEST
+        notification.metadata.timestamp = 1779446700;
+
+        // Match on a wide rage to avoid issues when running this test case
+        // in a different time zone.
+        let matcher: CalendarMatcher = "thu..sat 0-23".parse().unwrap();
+        assert!(matcher.matches(&notification).unwrap());
+    }
+}
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 35+ messages in thread

* [PATCH proxmox v2 09/34] notify: matcher: add InlineSeverityMatcher
  2026-10-01  8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
                   ` (7 preceding siblings ...)
  2026-10-01  8:36 ` [PATCH proxmox v2 08/34] notify: matcher: calendar: add basic unit test Lukas Wagner
@ 2026-10-01  8:36 ` Lukas Wagner
  2026-10-01  8:36 ` [PATCH proxmox v2 10/34] notify: matcher: add InlineFieldMatcher Lukas Wagner
                   ` (24 subsequent siblings)
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:36 UTC (permalink / raw)
  To: pbs-devel, pve-devel

This move the inline serialization format into a new-type wrapper around
SeverityMatcher.

This allows us to keep the existing single-line serialization format for
the old configuration keys (match-calendar), while deriving a regular
Serializer for SeverityMatcher that will be used in the new expression
based matcher.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 proxmox-notify/src/matcher/mod.rs      |  4 +--
 proxmox-notify/src/matcher/severity.rs | 41 ++++++++++++++++++--------
 2 files changed, 31 insertions(+), 14 deletions(-)

diff --git a/proxmox-notify/src/matcher/mod.rs b/proxmox-notify/src/matcher/mod.rs
index 48aaa351..2aef6949 100644
--- a/proxmox-notify/src/matcher/mod.rs
+++ b/proxmox-notify/src/matcher/mod.rs
@@ -16,7 +16,7 @@ pub mod severity;
 
 use calendar::CalendarMatcher;
 use field::FieldMatcher;
-use severity::SeverityMatcher;
+use severity::InlineSeverityMatcher;
 
 pub const MATCHER_TYPENAME: &str = "matcher";
 
@@ -107,7 +107,7 @@ pub struct MatcherConfig {
     /// List of matched severity levels.
     #[serde(default, skip_serializing_if = "Vec::is_empty")]
     #[updater(serde(skip_serializing_if = "Option::is_none"))]
-    pub match_severity: Vec<SeverityMatcher>,
+    pub match_severity: Vec<InlineSeverityMatcher>,
 
     /// List of matched severity levels.
     #[serde(default, skip_serializing_if = "Vec::is_empty")]
diff --git a/proxmox-notify/src/matcher/severity.rs b/proxmox-notify/src/matcher/severity.rs
index 4b126d85..e12f2260 100644
--- a/proxmox-notify/src/matcher/severity.rs
+++ b/proxmox-notify/src/matcher/severity.rs
@@ -11,7 +11,6 @@ pub struct SeverityMatcher {
     severities: Vec<Severity>,
 }
 
-/// Common trait implemented by all matching directives
 impl MatchDirective for SeverityMatcher {
     /// Check if this directive matches a given notification
     fn matches(&self, notification: &Notification) -> Result<bool, Error> {
@@ -19,14 +18,29 @@ impl MatchDirective for SeverityMatcher {
     }
 }
 
-impl fmt::Display for SeverityMatcher {
+/// Match severity of the notification.
+///
+/// This is a wrapper that serializes into an inline format that can be used
+/// in a section config key-value pair.
+#[derive(Clone, Debug)]
+pub struct InlineSeverityMatcher(SeverityMatcher);
+
+/// Common trait implemented by all matching directives
+impl MatchDirective for InlineSeverityMatcher {
+    /// Check if this directive matches a given notification
+    fn matches(&self, notification: &Notification) -> Result<bool, Error> {
+        self.0.matches(notification)
+    }
+}
+
+impl fmt::Display for InlineSeverityMatcher {
     fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
-        let severities: Vec<String> = self.severities.iter().map(|s| format!("{s}")).collect();
+        let severities: Vec<String> = self.0.severities.iter().map(|s| format!("{s}")).collect();
         f.write_str(&severities.join(","))
     }
 }
 
-impl FromStr for SeverityMatcher {
+impl FromStr for InlineSeverityMatcher {
     type Err = Error;
 
     fn from_str(s: &str) -> Result<Self, Error> {
@@ -39,28 +53,31 @@ impl FromStr for SeverityMatcher {
             severities.push(severity)
         }
 
-        Ok(Self { severities })
+        Ok(Self(SeverityMatcher { severities }))
     }
 }
 
-proxmox_serde::forward_deserialize_to_from_str!(SeverityMatcher);
-proxmox_serde::forward_serialize_to_display!(SeverityMatcher);
+impl InlineSeverityMatcher {
+    pub fn into_inner(self) -> SeverityMatcher {
+        self.0
+    }
+}
+
+proxmox_serde::forward_deserialize_to_from_str!(InlineSeverityMatcher);
+proxmox_serde::forward_serialize_to_display!(InlineSeverityMatcher);
 
 #[cfg(test)]
 mod test {
     use serde_json::Value;
 
-    use crate::{
-        Notification, Severity,
-        matcher::{MatchDirective as _, severity::SeverityMatcher},
-    };
+    use super::*;
 
     #[test]
     fn test_severities() {
         let notification =
             Notification::from_template(Severity::Notice, "test", Value::Null, Default::default());
 
-        let matcher: SeverityMatcher = "info,notice,warning,error".parse().unwrap();
+        let matcher: InlineSeverityMatcher = "info,notice,warning,error".parse().unwrap();
         assert!(matcher.matches(&notification).unwrap());
     }
 }
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 35+ messages in thread

* [PATCH proxmox v2 10/34] notify: matcher: add InlineFieldMatcher
  2026-10-01  8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
                   ` (8 preceding siblings ...)
  2026-10-01  8:36 ` [PATCH proxmox v2 09/34] notify: matcher: add InlineSeverityMatcher Lukas Wagner
@ 2026-10-01  8:36 ` Lukas Wagner
  2026-10-01  8:36 ` [PATCH proxmox v2 11/34] notify: matcher: add InlineCalendarMatcher Lukas Wagner
                   ` (23 subsequent siblings)
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:36 UTC (permalink / raw)
  To: pbs-devel, pve-devel

This move the inline serialization format into a new-type wrapper around
FieldMatcher.

This allows us to keep the existing single-line serialization format for
the old configuration keys (match-field), while deriving a regular
Serializer for FieldMatcher that will be used in the new expression
based matcher.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 proxmox-notify/src/matcher/field.rs | 55 +++++++++++++++++++----------
 proxmox-notify/src/matcher/mod.rs   |  4 +--
 2 files changed, 39 insertions(+), 20 deletions(-)

diff --git a/proxmox-notify/src/matcher/field.rs b/proxmox-notify/src/matcher/field.rs
index 8165b1f4..ad04b388 100644
--- a/proxmox-notify/src/matcher/field.rs
+++ b/proxmox-notify/src/matcher/field.rs
@@ -16,10 +16,10 @@ const_regex! {
 }
 
 pub const MATCH_FIELD_ENTRY_FORMAT: ApiStringFormat =
-    ApiStringFormat::VerifyFn(verify_field_matcher);
+    ApiStringFormat::VerifyFn(verify_inline_field_matcher);
 
-fn verify_field_matcher(s: &str) -> Result<(), anyhow::Error> {
-    let _: FieldMatcher = s.parse()?;
+fn verify_inline_field_matcher(s: &str) -> Result<(), anyhow::Error> {
+    let _: InlineFieldMatcher = s.parse()?;
     Ok(())
 }
 
@@ -75,12 +75,25 @@ impl MatchDirective for FieldMatcher {
     }
 }
 
-impl fmt::Display for FieldMatcher {
+/// Check if the notification metadata fields match
+///
+/// This is a wrapper that serializes into an inline format that can be used
+/// in a section config key-value pair.
+#[derive(Clone, Debug)]
+pub struct InlineFieldMatcher(FieldMatcher);
+
+impl MatchDirective for InlineFieldMatcher {
+    fn matches(&self, notification: &Notification) -> Result<bool, Error> {
+        self.0.matches(notification)
+    }
+}
+
+impl fmt::Display for InlineFieldMatcher {
     fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
         // Attention, Display is used to implement Serialize, do not
         // change the format.
 
-        match self {
+        match &self.0 {
             FieldMatcher::Exact {
                 field,
                 matched_values,
@@ -99,7 +112,7 @@ impl fmt::Display for FieldMatcher {
     }
 }
 
-impl FromStr for FieldMatcher {
+impl FromStr for InlineFieldMatcher {
     type Err = Error;
     fn from_str(s: &str) -> Result<Self, Error> {
         if !MATCH_FIELD_ENTRY_REGEX.is_match(s) {
@@ -117,10 +130,10 @@ impl FromStr for FieldMatcher {
                     let regex = Regex::new(expected_value_regex)
                         .map_err(|err| Error::FilterFailed(format!("invalid regex: {err}")))?;
 
-                    Ok(Self::Regex {
+                    Ok(Self(FieldMatcher::Regex {
                         field: field.into(),
                         matched_regex: regex,
-                    })
+                    }))
                 }
             }
         } else if let Some(remaining) = s.strip_prefix("exact:") {
@@ -134,10 +147,10 @@ impl FromStr for FieldMatcher {
                         .map(str::trim)
                         .map(String::from)
                         .collect();
-                    Ok(Self::Exact {
+                    Ok(Self(FieldMatcher::Exact {
                         field: field.into(),
                         matched_values: values,
-                    })
+                    }))
                 }
             }
         } else {
@@ -148,8 +161,14 @@ impl FromStr for FieldMatcher {
     }
 }
 
-proxmox_serde::forward_deserialize_to_from_str!(FieldMatcher);
-proxmox_serde::forward_serialize_to_display!(FieldMatcher);
+impl InlineFieldMatcher {
+    pub fn into_inner(self) -> FieldMatcher {
+        self.0
+    }
+}
+
+proxmox_serde::forward_deserialize_to_from_str!(InlineFieldMatcher);
+proxmox_serde::forward_serialize_to_display!(InlineFieldMatcher);
 
 #[cfg(test)]
 mod tests {
@@ -169,16 +188,16 @@ mod tests {
         let notification =
             Notification::from_template(Severity::Notice, "test", Value::Null, fields);
 
-        let matcher: FieldMatcher = "exact:foo=bar".parse().unwrap();
+        let matcher: InlineFieldMatcher = "exact:foo=bar".parse().unwrap();
         assert!(matcher.matches(&notification).unwrap());
 
-        let matcher: FieldMatcher = "regex:foo=b.*".parse().unwrap();
+        let matcher: InlineFieldMatcher = "regex:foo=b.*".parse().unwrap();
         assert!(matcher.matches(&notification).unwrap());
 
-        let matcher: FieldMatcher = "regex:notthere=b.*".parse().unwrap();
+        let matcher: InlineFieldMatcher = "regex:notthere=b.*".parse().unwrap();
         assert!(!matcher.matches(&notification).unwrap());
 
-        let matcher: FieldMatcher = "exact:foo=bar,test".parse().unwrap();
+        let matcher: InlineFieldMatcher = "exact:foo=bar,test".parse().unwrap();
         assert!(matcher.matches(&notification).unwrap());
 
         let mut fields = HashMap::new();
@@ -195,7 +214,7 @@ mod tests {
             Notification::from_template(Severity::Notice, "test", Value::Null, fields);
         assert!(!matcher.matches(&notification).unwrap());
 
-        assert!("regex:'3=b.*".parse::<FieldMatcher>().is_err());
-        assert!("invalid:'bar=b.*".parse::<FieldMatcher>().is_err());
+        assert!("regex:'3=b.*".parse::<InlineFieldMatcher>().is_err());
+        assert!("invalid:'bar=b.*".parse::<InlineFieldMatcher>().is_err());
     }
 }
diff --git a/proxmox-notify/src/matcher/mod.rs b/proxmox-notify/src/matcher/mod.rs
index 2aef6949..32ed9fbf 100644
--- a/proxmox-notify/src/matcher/mod.rs
+++ b/proxmox-notify/src/matcher/mod.rs
@@ -15,7 +15,7 @@ pub mod field;
 pub mod severity;
 
 use calendar::CalendarMatcher;
-use field::FieldMatcher;
+use field::InlineFieldMatcher;
 use severity::InlineSeverityMatcher;
 
 pub const MATCHER_TYPENAME: &str = "matcher";
@@ -102,7 +102,7 @@ pub struct MatcherConfig {
     /// List of matched metadata fields.
     #[serde(default, skip_serializing_if = "Vec::is_empty")]
     #[updater(serde(skip_serializing_if = "Option::is_none"))]
-    pub match_field: Vec<FieldMatcher>,
+    pub match_field: Vec<InlineFieldMatcher>,
 
     /// List of matched severity levels.
     #[serde(default, skip_serializing_if = "Vec::is_empty")]
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 35+ messages in thread

* [PATCH proxmox v2 11/34] notify: matcher: add InlineCalendarMatcher
  2026-10-01  8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
                   ` (9 preceding siblings ...)
  2026-10-01  8:36 ` [PATCH proxmox v2 10/34] notify: matcher: add InlineFieldMatcher Lukas Wagner
@ 2026-10-01  8:36 ` Lukas Wagner
  2026-10-01  8:36 ` [PATCH proxmox v2 12/34] notify: matcher: add expression support Lukas Wagner
                   ` (22 subsequent siblings)
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:36 UTC (permalink / raw)
  To: pbs-devel, pve-devel

This move the inline serialization format into a new-type wrapper around
CalendarMatcher.

This allows us to keep the existing single-line serialization format for
the old configuration keys (match-calendar), while deriving a regular
Serializer for CalendarMatcher that will be used in the new expression
based matcher.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 proxmox-notify/src/matcher/calendar.rs | 103 +++++++++++++++++++++----
 proxmox-notify/src/matcher/mod.rs      |   4 +-
 2 files changed, 88 insertions(+), 19 deletions(-)

diff --git a/proxmox-notify/src/matcher/calendar.rs b/proxmox-notify/src/matcher/calendar.rs
index 970801c5..c6263eb5 100644
--- a/proxmox-notify/src/matcher/calendar.rs
+++ b/proxmox-notify/src/matcher/calendar.rs
@@ -1,49 +1,104 @@
 use std::fmt;
 use std::str::FromStr;
 
+use serde::{Deserialize, Serialize};
+
 use proxmox_time::DailyDuration;
 
 use crate::{Error, Notification};
 
 use super::MatchDirective;
 
-/// Match timestamp of the notification.
+/// Convenience wrapper around [`DailyDuration`] that implements [`Serialize`] and
+/// [`Deserialize`].
 #[derive(Clone, Debug)]
+struct DailyDurationWrapper {
+    schedule: String,
+    daily_duration: DailyDuration,
+}
+
+impl fmt::Display for DailyDurationWrapper {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        f.write_str(self.as_str())
+    }
+}
+
+impl FromStr for DailyDurationWrapper {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Error> {
+        let daily_duration = proxmox_time::parse_daily_duration(s)
+            .map_err(|e| Error::Generic(format!("could not parse schedule: {e}")))?;
+
+        Ok(Self {
+            daily_duration,
+            schedule: s.to_string(),
+        })
+    }
+}
+
+impl DailyDurationWrapper {
+    fn as_str(&self) -> &str {
+        &self.schedule
+    }
+}
+
+proxmox_serde::forward_deserialize_to_from_str!(DailyDurationWrapper);
+proxmox_serde::forward_serialize_to_display!(DailyDurationWrapper);
+
+/// Match timestamp of the notification.
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
 pub struct CalendarMatcher {
-    schedule: DailyDuration,
-    original: String,
+    schedule: DailyDurationWrapper,
 }
 
 impl MatchDirective for CalendarMatcher {
     fn matches(&self, notification: &Notification) -> Result<bool, Error> {
         self.schedule
+            .daily_duration
             .time_match(notification.metadata.timestamp, false)
             .map_err(|err| Error::Generic(format!("could not match timestamp: {err}")))
     }
 }
 
-impl fmt::Display for CalendarMatcher {
-    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
-        f.write_str(&self.original)
+/// Match the timestamp of a notification.
+///
+/// This is a wrapper that serializes into an inline format that can be used
+/// in a section config key-value pair.
+#[derive(Clone, Debug)]
+pub struct InlineCalendarMatcher(CalendarMatcher);
+
+impl MatchDirective for InlineCalendarMatcher {
+    fn matches(&self, notification: &Notification) -> Result<bool, Error> {
+        self.0.matches(notification)
     }
 }
 
-impl FromStr for CalendarMatcher {
+impl fmt::Display for InlineCalendarMatcher {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        f.write_str(self.0.schedule.as_str())
+    }
+}
+
+impl FromStr for InlineCalendarMatcher {
     type Err = Error;
 
     fn from_str(s: &str) -> Result<Self, Error> {
-        let schedule = proxmox_time::parse_daily_duration(s)
-            .map_err(|e| Error::Generic(format!("could not parse schedule: {e}")))?;
-
-        Ok(Self {
-            schedule,
-            original: s.to_string(),
-        })
+        Ok(Self(CalendarMatcher {
+            schedule: s.parse()?,
+        }))
     }
 }
 
-proxmox_serde::forward_deserialize_to_from_str!(CalendarMatcher);
-proxmox_serde::forward_serialize_to_display!(CalendarMatcher);
+impl InlineCalendarMatcher {
+    pub fn into_inner(self) -> CalendarMatcher {
+        self.0
+    }
+}
+
+proxmox_serde::forward_deserialize_to_from_str!(InlineCalendarMatcher);
+proxmox_serde::forward_serialize_to_display!(InlineCalendarMatcher);
 
 #[cfg(test)]
 mod test {
@@ -63,7 +118,21 @@ mod test {
 
         // Match on a wide rage to avoid issues when running this test case
         // in a different time zone.
-        let matcher: CalendarMatcher = "thu..sat 0-23".parse().unwrap();
+        let matcher: InlineCalendarMatcher = "thu..sat 0-23".parse().unwrap();
         assert!(matcher.matches(&notification).unwrap());
     }
+
+    #[test]
+    fn test_calendar_matcher_de_ser_roundtrip() {
+        let calendar_matcher = "{ \"schedule\": \"thu..sat 0-23\" }";
+
+        let calendar_matcher: CalendarMatcher = serde_json::from_str(calendar_matcher).unwrap();
+
+        let s = serde_json::to_string(&calendar_matcher).unwrap();
+        let m: CalendarMatcher = serde_json::from_str(&s).unwrap();
+
+        let a = InlineCalendarMatcher(m);
+
+        assert_eq!(a.to_string(), "thu..sat 0-23");
+    }
 }
diff --git a/proxmox-notify/src/matcher/mod.rs b/proxmox-notify/src/matcher/mod.rs
index 32ed9fbf..24186cda 100644
--- a/proxmox-notify/src/matcher/mod.rs
+++ b/proxmox-notify/src/matcher/mod.rs
@@ -14,7 +14,7 @@ pub mod calendar;
 pub mod field;
 pub mod severity;
 
-use calendar::CalendarMatcher;
+use calendar::InlineCalendarMatcher;
 use field::InlineFieldMatcher;
 use severity::InlineSeverityMatcher;
 
@@ -112,7 +112,7 @@ pub struct MatcherConfig {
     /// List of matched severity levels.
     #[serde(default, skip_serializing_if = "Vec::is_empty")]
     #[updater(serde(skip_serializing_if = "Option::is_none"))]
-    pub match_calendar: Vec<CalendarMatcher>,
+    pub match_calendar: Vec<InlineCalendarMatcher>,
     /// Decide if 'all' or 'any' match statements must match.
     #[serde(skip_serializing_if = "Option::is_none")]
     pub mode: Option<MatchModeOperator>,
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 35+ messages in thread

* [PATCH proxmox v2 12/34] notify: matcher: add expression support
  2026-10-01  8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
                   ` (10 preceding siblings ...)
  2026-10-01  8:36 ` [PATCH proxmox v2 11/34] notify: matcher: add InlineCalendarMatcher Lukas Wagner
@ 2026-10-01  8:36 ` Lukas Wagner
  2026-10-01  8:36 ` [PATCH proxmox v2 13/34] notify: api: support new expression parameter Lukas Wagner
                   ` (21 subsequent siblings)
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:36 UTC (permalink / raw)
  To: pbs-devel, pve-devel

This commit adds support for the new 'expression' parameter in the
matcher configuration. It contains a JSON-serialized match expression
(based on the new proxmox-match-expression crate). It is intended as a
replacement for the existing match-field, match-calendar,
match-severity, mode and invert-match keys.

The match expression implements a strict superset of the existing
configuration keys, any existing configuration can be converted into
an expression.

Expressions lift some of limitations of the old approach, namely they
allow arbitrary nesting of match rules and combinators.

The following expressions are supported
  - all-of (corresponds to the former mode 'all')
  - any-of (corresponds to the former mode 'any')
  - one-of (new)
  - not    (corresponds to former 'invert-match')
  - match-calendar (unchanged)
  - match-field    (unchanged)
  - match-severity (unchanged)

The combinators all-of, any-of and one-of support an arbitrary number of
child expressions (where each child can be a combinator, not, or a
match-* expression).

Internally, existing, 'old-style' matchers are converted on the fly to
expressions, meaning there is only a single code path for the matching
logic. If any matcher has any of the old configuration keys and the new
expression set, the old keys will be ignored and a warning will be
logged.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 Cargo.toml                               |   1 +
 proxmox-notify/Cargo.toml                |   2 +
 proxmox-notify/src/matcher/expression.rs |  84 ++++++++++++
 proxmox-notify/src/matcher/field.rs      |  31 ++---
 proxmox-notify/src/matcher/mod.rs        | 163 ++++++++++++++---------
 proxmox-notify/src/matcher/severity.rs   |   6 +-
 6 files changed, 207 insertions(+), 80 deletions(-)
 create mode 100644 proxmox-notify/src/matcher/expression.rs

diff --git a/Cargo.toml b/Cargo.toml
index 2e07077f..7b0e80c5 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -138,6 +138,7 @@ serde-xml-rs = "0.5"
 serde_cbor = "0.11.1"
 serde_json = "1.0"
 serde_plain = "1.0"
+serde_regex = "1.1"
 syn = { version = "2", features = [ "full", "visit-mut" ] }
 sync_wrapper = "1"
 tar = "0.4"
diff --git a/proxmox-notify/Cargo.toml b/proxmox-notify/Cargo.toml
index bc63e19d..ae82f649 100644
--- a/proxmox-notify/Cargo.toml
+++ b/proxmox-notify/Cargo.toml
@@ -24,11 +24,13 @@ percent-encoding = { workspace = true, optional = true }
 regex.workspace = true
 serde = { workspace = true, features = ["derive"] }
 serde_json.workspace = true
+serde_regex.workspace = true
 
 proxmox-base64 = { workspace = true, optional = true }
 proxmox-http = { workspace = true, features = ["client-sync"], optional = true }
 proxmox-http-error.workspace = true
 proxmox-human-byte.workspace = true
+proxmox-match-expression.workspace = true
 proxmox-schema = { workspace = true, features = ["api-macro", "api-types"] }
 proxmox-section-config = { workspace = true }
 proxmox-serde.workspace = true
diff --git a/proxmox-notify/src/matcher/expression.rs b/proxmox-notify/src/matcher/expression.rs
new file mode 100644
index 00000000..9bb12f74
--- /dev/null
+++ b/proxmox-notify/src/matcher/expression.rs
@@ -0,0 +1,84 @@
+use serde::{Deserialize, Serialize};
+
+use proxmox_match_expression::MatchExpression;
+
+use crate::{Error, Notification};
+
+use super::{calendar::CalendarMatcher, field::FieldMatcher, severity::SeverityMatcher};
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+#[serde(rename_all = "kebab-case", tag = "type")]
+pub enum NotificationMatcher {
+    /// Match a notification's metadata field.
+    Field(FieldMatcher),
+    /// Match a notification's timestamp.
+    Calendar(CalendarMatcher),
+    /// Match the severity of a notification.
+    Severity(SeverityMatcher),
+}
+
+impl MatchExpression for NotificationMatcher {
+    type Data = Notification;
+    type Error = Error;
+
+    fn evaluate(&self, data: &Notification) -> Result<bool, Error> {
+        use super::MatchDirective;
+
+        match self {
+            NotificationMatcher::Field(field_matcher) => field_matcher.matches(data),
+            NotificationMatcher::Calendar(calendar_matcher) => calendar_matcher.matches(data),
+            NotificationMatcher::Severity(severity_matcher) => severity_matcher.matches(data),
+        }
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use proxmox_match_expression::Expression;
+
+    use super::*;
+
+    #[test]
+    fn test_matcher_deser() {
+        let e = r#"
+            {
+              "any-of": [
+                {
+                  "match": {
+                    "type": "severity",
+                    "severities": [
+                      "info",
+                      "notice"
+                    ]
+                  }
+                },
+                {
+                  "match": {
+                    "type": "field",
+                    "field": "something",
+                    "regex": "^abc$"
+                    }
+                },
+                {
+                  "match": {
+                    "type": "field",
+                    "field": "something",
+                    "values": [
+                      "a",
+                      "b",
+                      "c"
+                    ]
+                  }
+                },
+                {
+                  "match": {
+                    "type": "calendar",
+                    "schedule": "sat,sun 10-14"
+                  }
+                }
+              ]
+            }"#;
+
+        let _: Expression<NotificationMatcher> = serde_json::from_str(e).unwrap();
+    }
+}
diff --git a/proxmox-notify/src/matcher/field.rs b/proxmox-notify/src/matcher/field.rs
index ad04b388..fd25939c 100644
--- a/proxmox-notify/src/matcher/field.rs
+++ b/proxmox-notify/src/matcher/field.rs
@@ -6,6 +6,7 @@ use regex::Regex;
 use proxmox_schema::{
     ApiStringFormat, Schema, StringSchema, api_types::SAFE_ID_REGEX_STR, const_regex,
 };
+use serde::{Deserialize, Serialize};
 
 use crate::{Error, Notification};
 
@@ -30,42 +31,38 @@ pub const MATCH_FIELD_ENTRY_SCHEMA: Schema = StringSchema::new("Match metadata f
     .schema();
 
 /// Check if the notification metadata fields match
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case", untagged)]
 pub enum FieldMatcher {
     Exact {
         field: String,
-        matched_values: Vec<String>,
+        values: Vec<String>,
     },
     Regex {
         field: String,
-        matched_regex: Regex,
+        #[serde(with = "serde_regex")]
+        regex: Regex,
     },
 }
 
 impl MatchDirective for FieldMatcher {
     fn matches(&self, notification: &Notification) -> Result<bool, Error> {
         Ok(match self {
-            FieldMatcher::Exact {
-                field,
-                matched_values,
-            } => {
+            FieldMatcher::Exact { field, values } => {
                 let value = notification.metadata.additional_fields.get(field);
 
                 if let Some(value) = value {
-                    matched_values.contains(value)
+                    values.contains(value)
                 } else {
                     // Metadata field does not exist, so we do not match
                     false
                 }
             }
-            FieldMatcher::Regex {
-                field,
-                matched_regex,
-            } => {
+            FieldMatcher::Regex { field, regex } => {
                 let value = notification.metadata.additional_fields.get(field);
 
                 if let Some(value) = value {
-                    matched_regex.is_match(value)
+                    regex.is_match(value)
                 } else {
                     // Metadata field does not exist, so we do not match
                     false
@@ -96,14 +93,14 @@ impl fmt::Display for InlineFieldMatcher {
         match &self.0 {
             FieldMatcher::Exact {
                 field,
-                matched_values,
+                values: matched_values,
             } => {
                 let values = matched_values.join(",");
                 write!(f, "exact:{field}={values}")
             }
             FieldMatcher::Regex {
                 field,
-                matched_regex,
+                regex: matched_regex,
             } => {
                 let re = matched_regex.as_str();
                 write!(f, "regex:{field}={re}")
@@ -132,7 +129,7 @@ impl FromStr for InlineFieldMatcher {
 
                     Ok(Self(FieldMatcher::Regex {
                         field: field.into(),
-                        matched_regex: regex,
+                        regex,
                     }))
                 }
             }
@@ -149,7 +146,7 @@ impl FromStr for InlineFieldMatcher {
                         .collect();
                     Ok(Self(FieldMatcher::Exact {
                         field: field.into(),
-                        matched_values: values,
+                        values,
                     }))
                 }
             }
diff --git a/proxmox-notify/src/matcher/mod.rs b/proxmox-notify/src/matcher/mod.rs
index 24186cda..4a1beecf 100644
--- a/proxmox-notify/src/matcher/mod.rs
+++ b/proxmox-notify/src/matcher/mod.rs
@@ -1,16 +1,19 @@
 use std::collections::HashSet;
 use std::fmt::Debug;
 
+use proxmox_match_expression::Expression;
 use serde::{Deserialize, Serialize};
 use tracing::{error, info};
 
 use proxmox_schema::api_types::COMMENT_SCHEMA;
 use proxmox_schema::{Updater, api};
 
+use crate::matcher::expression::NotificationMatcher;
 use crate::schema::ENTITY_NAME_SCHEMA;
 use crate::{Error, Notification, Origin};
 
 pub mod calendar;
+pub mod expression;
 pub mod field;
 pub mod severity;
 
@@ -32,24 +35,6 @@ pub enum MatchModeOperator {
     Any,
 }
 
-impl MatchModeOperator {
-    /// Apply the mode operator to two bools, lhs and rhs
-    fn apply(&self, lhs: bool, rhs: bool) -> bool {
-        match self {
-            MatchModeOperator::All => lhs && rhs,
-            MatchModeOperator::Any => lhs || rhs,
-        }
-    }
-
-    // https://en.wikipedia.org/wiki/Identity_element
-    fn neutral_element(&self) -> bool {
-        match self {
-            MatchModeOperator::All => true,
-            MatchModeOperator::Any => false,
-        }
-    }
-}
-
 #[api(
     properties: {
         name: {
@@ -121,6 +106,20 @@ pub struct MatcherConfig {
     #[serde(skip_serializing_if = "Option::is_none")]
     pub invert_match: Option<bool>,
 
+    /// Match expression as inline JSON. This option is mutually exclusive with
+    /// the following options.
+    ///
+    ///   - match-field
+    ///   - match-calendar
+    ///   - match-severity
+    ///   - invert-match
+    ///   - mode
+    ///
+    /// All of the above can be represented as (sub)-expressions of this
+    /// expression.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub expression: Option<String>,
+
     /// Targets to notify.
     #[serde(default, skip_serializing_if = "Vec::is_empty")]
     #[updater(serde(skip_serializing_if = "Option::is_none"))]
@@ -146,57 +145,99 @@ trait MatchDirective {
 
 impl MatcherConfig {
     pub fn matches(&self, notification: &Notification) -> Result<Option<&[String]>, Error> {
-        let mode = self.mode.unwrap_or_default();
+        self.warn_about_invalid_configuration();
 
-        let mut is_match = mode.neutral_element();
-        // If there are no matching directives, the matcher will always match
-        let mut no_matchers = true;
+        let expression = if let Some(expression_str) = &self.expression {
 
-        if !self.match_severity.is_empty() {
-            no_matchers = false;
-            is_match = mode.apply(
-                is_match,
-                self.check_matches(notification, &self.match_severity)?,
-            );
-        }
-        if !self.match_field.is_empty() {
-            no_matchers = false;
-            is_match = mode.apply(
-                is_match,
-                self.check_matches(notification, &self.match_field)?,
-            );
-        }
-        if !self.match_calendar.is_empty() {
-            no_matchers = false;
-            is_match = mode.apply(
-                is_match,
-                self.check_matches(notification, &self.match_calendar)?,
-            );
-        }
-
-        let invert_match = self.invert_match.unwrap_or_default();
-
-        Ok(if is_match != invert_match || no_matchers {
-            Some(&self.target)
+            serde_json::from_str(expression_str).map_err(|err| {
+                Error::FilterFailed(format!("could not deserialize filter expression: {err:#}"))
+            })?
         } else {
-            None
-        })
+            self.generate_expression_from_legacy_config()
+        };
+
+        // Later, once we have a notification history, we can also save the evaluated expression.
+        // This gives the user a trace of which rules matched and which did not.
+        let evaluated_expression = expression.evaluate(notification)?;
+
+        Ok(evaluated_expression
+            .is_match()
+            .then_some(self.target.as_slice()))
     }
 
-    /// Check if given `MatchDirectives` match a notification.
-    fn check_matches(
-        &self,
-        notification: &Notification,
-        matchers: &[impl MatchDirective],
-    ) -> Result<bool, Error> {
-        let mode = self.mode.unwrap_or_default();
-        let mut is_match = mode.neutral_element();
+    pub(crate) fn generate_expression_from_legacy_config(&self) -> Expression<NotificationMatcher> {
+        if self.match_severity.is_empty()
+            && self.match_field.is_empty()
+            && self.match_calendar.is_empty()
+        {
+            // No match clauses means that the matcher always matches.
+            // Also, 'invert-match' does not have any effect then.
+            return Expression::Constant(true);
+        }
+        let mut expressions = Vec::new();
 
-        for field_matcher in matchers {
-            is_match = mode.apply(is_match, field_matcher.matches(notification)?);
+        for m in self.match_calendar.clone() {
+            expressions.push(Expression::Match(NotificationMatcher::Calendar(
+                m.into_inner(),
+            )));
+        }
+        for m in self.match_field.clone() {
+            expressions.push(Expression::Match(NotificationMatcher::Field(
+                m.into_inner(),
+            )));
+        }
+        for m in self.match_severity.clone() {
+            expressions.push(Expression::Match(NotificationMatcher::Severity(
+                m.into_inner(),
+            )));
         }
 
-        Ok(is_match)
+        let root = match self.mode.unwrap_or_default() {
+            MatchModeOperator::All => Expression::AllOf(expressions),
+            MatchModeOperator::Any => Expression::AnyOf(expressions),
+        };
+
+        if self.invert_match.unwrap_or_default() {
+            Expression::Not(Box::new(root))
+        } else {
+            root
+        }
+    }
+
+    fn warn_about_invalid_configuration(&self) {
+        if self.expression.is_some() {
+            let conflicting_properties = self.properties_conflicting_with_expression();
+
+            if !conflicting_properties.is_empty() {
+                let joined = conflicting_properties.join(", ");
+                tracing::warn!(
+                    "matcher '{}' has 'expression' set, the following properties are ignored: {joined}",
+                    self.name
+                );
+            }
+        }
+    }
+
+    fn properties_conflicting_with_expression(&self) -> Vec<&'static str> {
+        let mut conflicting_properties = Vec::new();
+
+        if self.invert_match.is_some() {
+            conflicting_properties.push("invert-match");
+        }
+        if self.mode.is_some() {
+            conflicting_properties.push("mode");
+        }
+        if !self.match_field.is_empty() {
+            conflicting_properties.push("match-field");
+        }
+        if !self.match_severity.is_empty() {
+            conflicting_properties.push("match-severity");
+        }
+        if !self.match_calendar.is_empty() {
+            conflicting_properties.push("match-calendar");
+        }
+
+        conflicting_properties
     }
 }
 
diff --git a/proxmox-notify/src/matcher/severity.rs b/proxmox-notify/src/matcher/severity.rs
index e12f2260..64472fab 100644
--- a/proxmox-notify/src/matcher/severity.rs
+++ b/proxmox-notify/src/matcher/severity.rs
@@ -1,14 +1,16 @@
 use std::fmt;
 use std::str::FromStr;
 
+use serde::{Deserialize, Serialize};
+
 use crate::{Error, Notification, Severity};
 
 use super::MatchDirective;
 
 /// Match severity of the notification.
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, Serialize, Deserialize)]
 pub struct SeverityMatcher {
-    severities: Vec<Severity>,
+    pub(crate) severities: Vec<Severity>,
 }
 
 impl MatchDirective for SeverityMatcher {
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 35+ messages in thread

* [PATCH proxmox v2 13/34] notify: api: support new expression parameter
  2026-10-01  8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
                   ` (11 preceding siblings ...)
  2026-10-01  8:36 ` [PATCH proxmox v2 12/34] notify: matcher: add expression support Lukas Wagner
@ 2026-10-01  8:36 ` Lukas Wagner
  2026-10-01  8:36 ` [PATCH proxmox v2 14/34] notify: api: add `get_matcher_as_expression` Lukas Wagner
                   ` (20 subsequent siblings)
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:36 UTC (permalink / raw)
  To: pbs-devel, pve-devel

Add support for the new 'expression' parameter to the add_matcher and
updater_matcher API functions. Also add some validation that won't allow
to create a configuration with both old properties and new the
'expression'.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 proxmox-notify/src/api/matcher.rs | 119 ++++++++++++++++++++++++++++++
 proxmox-notify/src/matcher/mod.rs |  85 ++++++++++++++++++++-
 2 files changed, 202 insertions(+), 2 deletions(-)

diff --git a/proxmox-notify/src/api/matcher.rs b/proxmox-notify/src/api/matcher.rs
index b2bf15ae..3713dfef 100644
--- a/proxmox-notify/src/api/matcher.rs
+++ b/proxmox-notify/src/api/matcher.rs
@@ -40,6 +40,10 @@ pub fn add_matcher(config: &mut Config, matcher_config: MatcherConfig) -> Result
     super::ensure_unique(config, &matcher_config.name)?;
     super::ensure_endpoints_exist(config, &matcher_config.target)?;
 
+    matcher_config
+        .ensure_valid()
+        .map_err(|err| http_err!(BAD_REQUEST, "invalid matcher config: {err}"))?;
+
     config
         .config
         .set_data(&matcher_config.name, MATCHER_TYPENAME, &matcher_config)
@@ -83,6 +87,7 @@ pub fn update_matcher(
                 DeleteableMatcherProperty::InvertMatch => matcher.invert_match = None,
                 DeleteableMatcherProperty::Comment => matcher.comment = None,
                 DeleteableMatcherProperty::Disable => matcher.disable = None,
+                DeleteableMatcherProperty::Expression => matcher.expression = None,
             }
         }
     }
@@ -115,11 +120,19 @@ pub fn update_matcher(
         matcher.disable = Some(disable);
     }
 
+    if let Some(expression) = matcher_updater.expression {
+        matcher.expression = Some(expression);
+    }
+
     if let Some(target) = matcher_updater.target {
         super::ensure_endpoints_exist(config, target.as_slice())?;
         matcher.target = target;
     }
 
+    matcher
+        .ensure_valid()
+        .map_err(|err| http_err!(BAD_REQUEST, "invalid matcher config: {err}"))?;
+
     config
         .config
         .set_data(name, MATCHER_TYPENAME, &matcher)
@@ -150,8 +163,12 @@ pub fn delete_matcher(config: &mut Config, name: &str) -> Result<(), HttpError>
 
 #[cfg(all(test, feature = "sendmail"))]
 mod tests {
+    use proxmox_match_expression::Expression;
+
     use super::*;
+
     use crate::matcher::MatchModeOperator;
+    use crate::matcher::expression::NotificationMatcher;
 
     fn empty_config() -> Config {
         Config::new("", "").unwrap()
@@ -172,6 +189,11 @@ matcher: matcher2
         .unwrap()
     }
 
+    fn valid_expression_string() -> String {
+        let expr = Expression::<NotificationMatcher>::Constant(true);
+        serde_json::to_string(&expr).unwrap()
+    }
+
     #[test]
     fn test_update_not_existing_returns_error() -> Result<(), HttpError> {
         let mut config = empty_config();
@@ -261,4 +283,101 @@ matcher: matcher2
 
         Ok(())
     }
+
+    #[test]
+    fn test_update_matcher_mutually_exclusive_with_expression() -> Result<(), HttpError> {
+        let mut config = config_with_two_matchers();
+        let digest = config.digest;
+
+        let proto = MatcherConfigUpdater {
+            expression: Some(valid_expression_string()),
+            ..Default::default()
+        };
+
+        let mut updater = proto.clone();
+        updater.match_field = Some(vec!["exact:foo=bar".parse().unwrap()]);
+
+        assert!(update_matcher(&mut config, "matcher1", updater, None, Some(&digest),).is_err());
+
+        let mut updater = proto.clone();
+        updater.match_calendar = Some(vec!["mon..sun 12-13".parse().unwrap()]);
+
+        assert!(update_matcher(&mut config, "matcher1", updater, None, Some(&digest),).is_err());
+
+        let mut updater = proto.clone();
+        updater.match_severity = Some(vec!["info,warning".parse().unwrap()]);
+
+        assert!(update_matcher(&mut config, "matcher1", updater, None, Some(&digest),).is_err());
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_update_invalid_expression() -> Result<(), HttpError> {
+        let mut config = config_with_two_matchers();
+        let digest = config.digest;
+
+        assert!(
+            update_matcher(
+                &mut config,
+                "matcher1",
+                MatcherConfigUpdater {
+                    expression: Some("invalid".into()),
+                    ..Default::default()
+                },
+                None,
+                Some(&digest),
+            )
+            .is_err()
+        );
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_add_mutually_exclusive_with_expression() -> Result<(), HttpError> {
+        let mut config = empty_config();
+
+        let proto = MatcherConfig {
+            name: "matcher3".into(),
+            expression: Some(valid_expression_string()),
+            ..Default::default()
+        };
+
+        let mut entity = proto.clone();
+        entity.match_field = vec!["exact:foo=bar".parse().unwrap()];
+
+        assert!(add_matcher(&mut config, entity).is_err());
+
+        let mut entity = proto.clone();
+        entity.match_severity = vec!["info,warning".parse().unwrap()];
+
+        assert!(add_matcher(&mut config, entity).is_err());
+
+        let mut entity = proto.clone();
+        entity.match_calendar = vec!["mon..sun 12-13".parse().unwrap()];
+
+        assert!(add_matcher(&mut config, entity).is_err());
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_add_invalid_expression() -> Result<(), HttpError> {
+        let mut config = empty_config();
+
+        assert!(
+            add_matcher(
+                &mut config,
+                MatcherConfig {
+                    name: "matcher2".into(),
+                    expression: Some("invalid".into()),
+                    ..Default::default()
+                }
+            )
+            .is_err()
+        );
+
+        Ok(())
+    }
 }
diff --git a/proxmox-notify/src/matcher/mod.rs b/proxmox-notify/src/matcher/mod.rs
index 4a1beecf..12110e95 100644
--- a/proxmox-notify/src/matcher/mod.rs
+++ b/proxmox-notify/src/matcher/mod.rs
@@ -76,7 +76,7 @@ pub enum MatchModeOperator {
             optional: true,
         },
     })]
-#[derive(Debug, Serialize, Deserialize, Updater, Default)]
+#[derive(Clone, Debug, Serialize, Deserialize, Updater, Default)]
 #[serde(rename_all = "kebab-case")]
 /// Config for notification matchers.
 pub struct MatcherConfig {
@@ -148,7 +148,6 @@ impl MatcherConfig {
         self.warn_about_invalid_configuration();
 
         let expression = if let Some(expression_str) = &self.expression {
-
             serde_json::from_str(expression_str).map_err(|err| {
                 Error::FilterFailed(format!("could not deserialize filter expression: {err:#}"))
             })?
@@ -204,6 +203,86 @@ impl MatcherConfig {
         }
     }
 
+    /// Ensure the validity of this matcher.
+    pub(crate) fn ensure_valid(&self) -> Result<(), Error> {
+        self.ensure_no_ignored_properties()?;
+        self.ensure_valid_expression()?;
+
+        Ok(())
+    }
+
+    fn ensure_no_ignored_properties(&self) -> Result<(), Error> {
+        if self.expression.is_some() {
+            let conflicting_properties = self.properties_conflicting_with_expression();
+
+            if !conflicting_properties.is_empty() {
+                let joined = conflicting_properties.join(", ");
+                return Err(Error::Generic(format!(
+                    "'expression' property is mutually exclusive with: {joined}"
+                )));
+            }
+        }
+
+        Ok(())
+    }
+
+    fn ensure_valid_expression(&self) -> Result<(), Error> {
+        if let Some(expression) = &self.expression {
+            match serde_json::from_str::<Expression<NotificationMatcher>>(expression) {
+                Ok(expr) => {
+                    Self::verify_match_expression_constraints(&expr)?;
+                }
+                Err(err) => {
+                    return Err(Error::Generic(format!(
+                        "'expression' is not valid: {err:#}"
+                    )));
+                }
+            }
+        }
+
+        Ok(())
+    }
+
+    /// Recursively walk expression nodes and verify any additional constraints on them:
+    ///
+    ///   - all-of/any-of/one-of *must* have child nodes:
+    ///     The generic proxmox-match-expression crates handles empty combinators just fine,
+    ///     however, from a UX perspective, it makes sense to require them to have child nodes.
+    fn verify_match_expression_constraints(
+        expr: &Expression<NotificationMatcher>,
+    ) -> Result<(), Error> {
+        match expr {
+            Expression::AllOf(expressions) => {
+                if expressions.is_empty() {
+                    return Err(Error::Generic(
+                        "'expression' may not contain empty 'all-of' rule".to_string(),
+                    ));
+                }
+            }
+            Expression::AnyOf(expressions) => {
+                if expressions.is_empty() {
+                    return Err(Error::Generic(
+                        "'expression' may not contain empty 'any-of' rule".to_string(),
+                    ));
+                }
+            }
+            Expression::OneOf(expressions) => {
+                if expressions.is_empty() {
+                    return Err(Error::Generic(
+                        "'expression' may not contain empty 'one-of' rule".to_string(),
+                    ));
+                }
+            }
+            _ => {}
+        }
+
+        for child_expression in expr.children() {
+            Self::verify_match_expression_constraints(child_expression)?;
+        }
+
+        Ok(())
+    }
+
     fn warn_about_invalid_configuration(&self) {
         if self.expression.is_some() {
             let conflicting_properties = self.properties_conflicting_with_expression();
@@ -260,6 +339,8 @@ pub enum DeleteableMatcherProperty {
     MatchSeverity,
     /// Delete `mode`
     Mode,
+    /// Delete `expression`
+    Expression,
     /// Delete `target`
     Target,
 }
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 35+ messages in thread

* [PATCH proxmox v2 14/34] notify: api: add `get_matcher_as_expression`
  2026-10-01  8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
                   ` (12 preceding siblings ...)
  2026-10-01  8:36 ` [PATCH proxmox v2 13/34] notify: api: support new expression parameter Lukas Wagner
@ 2026-10-01  8:36 ` 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
                   ` (19 subsequent siblings)
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:36 UTC (permalink / raw)
  To: pbs-devel, pve-devel

This one translates an existing matcher into an equivalent
expression-based matcher on the fly. It allows us to keep the migration
logic entirely in the backend; the GUI simply requests any existing
matcher via this new getter and always receives one where the entire
logic is encoded in the 'expression' parameter.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 proxmox-notify/src/api/matcher.rs | 31 +++++++++++++++++++++++++++++++
 1 file changed, 31 insertions(+)

diff --git a/proxmox-notify/src/api/matcher.rs b/proxmox-notify/src/api/matcher.rs
index 3713dfef..be2d9c0e 100644
--- a/proxmox-notify/src/api/matcher.rs
+++ b/proxmox-notify/src/api/matcher.rs
@@ -29,6 +29,37 @@ pub fn get_matcher(config: &Config, name: &str) -> Result<MatcherConfig, HttpErr
         .map_err(|_| http_err!(NOT_FOUND, "matcher '{name}' not found"))
 }
 
+/// Get matcher with given `name`, translating any existing 'match-*' keys into an
+/// equivalent 'expression'.
+///
+/// The caller is responsible for any needed permission checks.
+/// Returns the endpoint or a `HttpError` if the matcher was not found (`404 Not found`).
+pub fn get_matcher_as_expression(config: &Config, name: &str) -> Result<MatcherConfig, HttpError> {
+    let mut matcher: MatcherConfig = config
+        .config
+        .lookup(MATCHER_TYPENAME, name)
+        .map_err(|_| http_err!(NOT_FOUND, "matcher '{name}' not found"))?;
+
+    if matcher.expression.is_none() {
+        let expression = matcher.generate_expression_from_legacy_config();
+        let expression_json = serde_json::to_string(&expression).map_err(|err| {
+            http_err!(
+                INTERNAL_SERVER_ERROR,
+                "could not serialize matcher expression: {err}"
+            )
+        })?;
+        matcher.expression = Some(expression_json);
+    }
+
+    matcher.match_calendar.clear();
+    matcher.match_severity.clear();
+    matcher.match_field.clear();
+    matcher.mode = None;
+    matcher.invert_match = None;
+
+    Ok(matcher)
+}
+
 /// Add new notification matcher.
 ///
 /// The caller is responsible for any needed permission checks.
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 35+ messages in thread

* [PATCH proxmox v2 15/34] notify: migrate PBS's and PVE's default matcher to expression syntax
  2026-10-01  8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
                   ` (13 preceding siblings ...)
  2026-10-01  8:36 ` [PATCH proxmox v2 14/34] notify: api: add `get_matcher_as_expression` Lukas Wagner
@ 2026-10-01  8:36 ` Lukas Wagner
  2026-10-01  8:36 ` [PATCH proxmox v2 16/34] notify: move legacy matcher keys behind feature flag Lukas Wagner
                   ` (18 subsequent siblings)
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:36 UTC (permalink / raw)
  To: pbs-devel, pve-devel

Instead of providing the default matcher expression as a JSON blob, we
programmatically construct the appropricate SectionConfigData entity.
This required a change of the Context trait, now the default_config
method returns &'static SectionConfigData instead of &'static str.

The actual SectionConfigData entity is only constructed once and then
stored in a OnceLock.

A nice side-effect of this change is that the config-parsing code path
is more efficient, since we don't have to deserialize the default config
over and over again.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 proxmox-notify/Cargo.toml              |  4 +-
 proxmox-notify/src/context/mod.rs      |  4 +-
 proxmox-notify/src/context/pbs.rs      | 87 ++++++++++++++++++++------
 proxmox-notify/src/context/pve.rs      | 67 +++++++++++++++-----
 proxmox-notify/src/context/test.rs     |  9 ++-
 proxmox-notify/src/lib.rs              |  6 +-
 proxmox-notify/src/matcher/calendar.rs |  9 ++-
 proxmox-notify/src/matcher/field.rs    | 11 +++-
 proxmox-notify/src/matcher/severity.rs | 10 ++-
 9 files changed, 159 insertions(+), 48 deletions(-)

diff --git a/proxmox-notify/Cargo.toml b/proxmox-notify/Cargo.toml
index ae82f649..9cbfc89f 100644
--- a/proxmox-notify/Cargo.toml
+++ b/proxmox-notify/Cargo.toml
@@ -44,7 +44,7 @@ default = ["sendmail", "gotify", "smtp", "webhook"]
 mail-forwarder = ["dep:mail-parser", "dep:proxmox-sys", "proxmox-sendmail/mail-forwarder"]
 sendmail = ["dep:proxmox-sys", "dep:proxmox-sendmail"]
 gotify = ["dep:proxmox-http", "dep:http"]
-pve-context = ["dep:proxmox-sys"]
-pbs-context = ["dep:proxmox-sys"]
+pve-context = ["dep:proxmox-sys", "sendmail"]
+pbs-context = ["dep:proxmox-sys", "sendmail"]
 smtp = ["dep:lettre"]
 webhook = ["dep:http", "dep:percent-encoding", "dep:proxmox-base64", "dep:proxmox-http"]
diff --git a/proxmox-notify/src/context/mod.rs b/proxmox-notify/src/context/mod.rs
index 87a2a716..70c3a567 100644
--- a/proxmox-notify/src/context/mod.rs
+++ b/proxmox-notify/src/context/mod.rs
@@ -1,6 +1,8 @@
 use std::fmt::Debug;
 use std::sync::Mutex;
 
+use proxmox_section_config::SectionConfigData;
+
 use crate::Error;
 use crate::renderer::TemplateSource;
 
@@ -24,7 +26,7 @@ pub trait Context: Send + Sync + Debug {
     /// Proxy configuration for the current node
     fn http_proxy_config(&self) -> Option<String>;
     /// Return default config for built-in targets/matchers.
-    fn default_config(&self) -> &'static str;
+    fn default_config(&self) -> &'static SectionConfigData;
     /// Return the path of `filename` from `source` and a certain (optional) `namespace`
     fn lookup_template(
         &self,
diff --git a/proxmox-notify/src/context/pbs.rs b/proxmox-notify/src/context/pbs.rs
index a9121548..e40774da 100644
--- a/proxmox-notify/src/context/pbs.rs
+++ b/proxmox-notify/src/context/pbs.rs
@@ -1,14 +1,19 @@
 use std::path::Path;
+use std::sync::OnceLock;
 
 use serde::Deserialize;
 use tracing::error;
 
 use proxmox_schema::{ObjectSchema, Schema, StringSchema};
-use proxmox_section_config::{SectionConfig, SectionConfigPlugin};
+use proxmox_section_config::{SectionConfig, SectionConfigData, SectionConfigPlugin};
 
-use crate::Error;
 use crate::context::{Context, common};
+use crate::endpoints::sendmail::{SENDMAIL_TYPENAME, SendmailConfig};
+use crate::matcher::field::FieldMatcher;
+use crate::matcher::severity::SeverityMatcher;
+use crate::matcher::{MATCHER_TYPENAME, MatcherConfig};
 use crate::renderer::TemplateSource;
+use crate::{Error, Severity};
 
 const PBS_USER_CFG_FILENAME: &str = "/etc/proxmox-backup/user.cfg";
 const PBS_NODE_CFG_FILENAME: &str = "/etc/proxmox-backup/node.cfg";
@@ -60,21 +65,6 @@ fn lookup_mail_address(content: &str, username: &str) -> Option<String> {
     }
 }
 
-const DEFAULT_CONFIG: &str = "\
-sendmail: mail-to-root
-    comment Send mails to root@pam's email address
-    mailto-user root@pam
-
-
-matcher: default-matcher
-    mode all
-    invert-match true
-    match-field exact:type=prune
-    match-severity info
-    target mail-to-root
-    comment Route everything but successful prune job notifications to mail-to-root
-";
-
 #[derive(Debug)]
 pub struct PBSContext;
 
@@ -102,8 +92,67 @@ impl Context for PBSContext {
         content.and_then(|content| common::lookup_datacenter_config_key(&content, "http-proxy"))
     }
 
-    fn default_config(&self) -> &'static str {
-        DEFAULT_CONFIG
+    fn default_config(&self) -> &'static SectionConfigData {
+        static DEFAULT_CONFIG: OnceLock<SectionConfigData> = OnceLock::new();
+        DEFAULT_CONFIG.get_or_init(|| {
+            // FIXME: Long-term we want to move the trait implementation to the product,
+            // maybe add some nice builder to construct the default config.
+            // Moving this as-is to the product would expose a lot of internals
+            // to the product.
+
+            let mut config = SectionConfigData::default();
+            config
+                .set_data(
+                    "mail-to-root",
+                    SENDMAIL_TYPENAME,
+                    SendmailConfig {
+                        name: "mail-to-root".into(),
+                        mailto_user: vec!["root@pam".into()],
+                        comment: Some("Send mails to root@pam's email address".into()),
+                        ..Default::default()
+                    },
+                )
+                .expect("failed to set 'mail-to-root' in default config");
+
+            use proxmox_match_expression::{not, any_of, all_of};
+
+            let expression = any_of![
+                not!(
+                    FieldMatcher::Exact {
+                        field: "type".into(),
+                        values: vec!["prune".into()],
+                    }.into()
+                ),
+                all_of![
+                    FieldMatcher::Exact {
+                        field: "type".into(),
+                        values: vec!["prune".into()],
+                    }.into(),
+                    SeverityMatcher {
+                        severities: vec![Severity::Error, Severity::Warning, Severity::Notice],
+                    }.into(),
+                ],
+            ];
+
+            let expression = serde_json::to_string(&expression)
+                .expect("failed serialize expression for 'default-matcher'");
+
+            config
+                .set_data(
+                    "default-matcher",
+                    MATCHER_TYPENAME,
+                    MatcherConfig {
+                        name: "default-matcher".into(),
+                        expression: Some(expression),
+                        target: vec!["mail-to-root".into()],
+                        comment: Some("Route everything but successful prune job notifications to mail-to-root".into()),
+                        ..Default::default()
+                    },
+                )
+                .expect("failed to set 'default-matcher' in default config");
+
+            config
+        })
     }
 
     fn lookup_template(
diff --git a/proxmox-notify/src/context/pve.rs b/proxmox-notify/src/context/pve.rs
index 3d9ff92e..00fab322 100644
--- a/proxmox-notify/src/context/pve.rs
+++ b/proxmox-notify/src/context/pve.rs
@@ -1,7 +1,15 @@
+use std::path::Path;
+use std::sync::OnceLock;
+
+use proxmox_match_expression::Expression;
+use proxmox_section_config::SectionConfigData;
+
 use crate::Error;
 use crate::context::{Context, common};
+use crate::endpoints::sendmail::{SENDMAIL_TYPENAME, SendmailConfig};
+use crate::matcher::expression::NotificationMatcher;
+use crate::matcher::{MATCHER_TYPENAME, MatcherConfig};
 use crate::renderer::TemplateSource;
-use std::path::Path;
 
 fn lookup_mail_address(content: &str, user: &str) -> Option<String> {
     common::normalize_for_return(content.lines().find_map(|line| {
@@ -14,18 +22,6 @@ fn lookup_mail_address(content: &str, user: &str) -> Option<String> {
     }))
 }
 
-const DEFAULT_CONFIG: &str = "\
-sendmail: mail-to-root
-	comment Send mails to root@pam's email address
-	mailto-user root@pam
-
-
-matcher: default-matcher
-    mode all
-    target mail-to-root
-    comment Route all notifications to mail-to-root
-";
-
 #[derive(Debug)]
 pub struct PVEContext;
 
@@ -51,8 +47,49 @@ impl Context for PVEContext {
         content.and_then(|content| common::lookup_datacenter_config_key(&content, "http_proxy"))
     }
 
-    fn default_config(&self) -> &'static str {
-        DEFAULT_CONFIG
+    fn default_config(&self) -> &'static SectionConfigData {
+        static DEFAULT_CONFIG: OnceLock<SectionConfigData> = OnceLock::new();
+        DEFAULT_CONFIG.get_or_init(|| {
+            // FIXME: Long-term we want to move the trait implementation to the product,
+            // maybe add some nice builder to construct the default config.
+            // Moving this as-is to the product would expose a lot of internals
+            // to the product.
+
+            let mut config = SectionConfigData::default();
+            config
+                .set_data(
+                    "mail-to-root",
+                    SENDMAIL_TYPENAME,
+                    SendmailConfig {
+                        name: "mail-to-root".into(),
+                        mailto_user: vec!["root@pam".into()],
+                        comment: Some("Send mails to root@pam's email address".into()),
+                        ..Default::default()
+                    },
+                )
+                .expect("failed to set 'mail-to-root' in default config");
+
+            let expr: Expression<NotificationMatcher> = Expression::Constant(true);
+
+            let expr_str = serde_json::to_string(&expr)
+                .expect("failed serialize expression for 'default-matcher'");
+
+            config
+                .set_data(
+                    "default-matcher",
+                    MATCHER_TYPENAME,
+                    MatcherConfig {
+                        name: "default-matcher".into(),
+                        expression: Some(expr_str),
+                        target: vec!["mail-to-root".into()],
+                        comment: Some("Route all notifications to mail-to-root".into()),
+                        ..Default::default()
+                    },
+                )
+                .expect("failed to set 'default-matcher' in default config");
+
+            config
+        })
     }
 
     fn lookup_template(
diff --git a/proxmox-notify/src/context/test.rs b/proxmox-notify/src/context/test.rs
index 22da38d3..ad8ef696 100644
--- a/proxmox-notify/src/context/test.rs
+++ b/proxmox-notify/src/context/test.rs
@@ -1,3 +1,7 @@
+use std::sync::OnceLock;
+
+use proxmox_section_config::SectionConfigData;
+
 use crate::Error;
 use crate::context::Context;
 use crate::renderer::TemplateSource;
@@ -28,8 +32,9 @@ impl Context for TestContext {
         None
     }
 
-    fn default_config(&self) -> &'static str {
-        ""
+    fn default_config(&self) -> &'static SectionConfigData {
+        static DEFAULT_CONFIG: OnceLock<SectionConfigData> = OnceLock::new();
+        DEFAULT_CONFIG.get_or_init(|| SectionConfigData::default())
     }
 
     fn lookup_template(
diff --git a/proxmox-notify/src/lib.rs b/proxmox-notify/src/lib.rs
index 12d21edc..1d1c03cc 100644
--- a/proxmox-notify/src/lib.rs
+++ b/proxmox-notify/src/lib.rs
@@ -285,11 +285,7 @@ impl Config {
         let (mut config, digest) = config::config(raw_config)?;
         let (private_config, _) = config::private_config(raw_private_config)?;
 
-        let default_config = context().default_config();
-
-        let builtin_config = config::config_parser()
-            .parse("<builtin>", default_config)
-            .map_err(|err| Error::ConfigDeserialization(err.into()))?;
+        let builtin_config = context().default_config();
 
         for (key, (builtin_typename, builtin_value)) in &builtin_config.sections {
             if let Some((typename, value)) = config.sections.get_mut(key) {
diff --git a/proxmox-notify/src/matcher/calendar.rs b/proxmox-notify/src/matcher/calendar.rs
index c6263eb5..43805840 100644
--- a/proxmox-notify/src/matcher/calendar.rs
+++ b/proxmox-notify/src/matcher/calendar.rs
@@ -3,9 +3,10 @@ use std::str::FromStr;
 
 use serde::{Deserialize, Serialize};
 
+use proxmox_match_expression::Expression;
 use proxmox_time::DailyDuration;
 
-use crate::{Error, Notification};
+use crate::{Error, Notification, matcher::expression::NotificationMatcher};
 
 use super::MatchDirective;
 
@@ -53,6 +54,12 @@ pub struct CalendarMatcher {
     schedule: DailyDurationWrapper,
 }
 
+impl From<CalendarMatcher> for Expression<NotificationMatcher> {
+    fn from(value: CalendarMatcher) -> Self {
+        Expression::Match(NotificationMatcher::Calendar(value))
+    }
+}
+
 impl MatchDirective for CalendarMatcher {
     fn matches(&self, notification: &Notification) -> Result<bool, Error> {
         self.schedule
diff --git a/proxmox-notify/src/matcher/field.rs b/proxmox-notify/src/matcher/field.rs
index fd25939c..a21b00d3 100644
--- a/proxmox-notify/src/matcher/field.rs
+++ b/proxmox-notify/src/matcher/field.rs
@@ -2,13 +2,14 @@ use std::{fmt, str::FromStr};
 
 use const_format::concatcp;
 use regex::Regex;
+use serde::{Deserialize, Serialize};
 
+use proxmox_match_expression::Expression;
 use proxmox_schema::{
     ApiStringFormat, Schema, StringSchema, api_types::SAFE_ID_REGEX_STR, const_regex,
 };
-use serde::{Deserialize, Serialize};
 
-use crate::{Error, Notification};
+use crate::{Error, Notification, matcher::expression::NotificationMatcher};
 
 use super::MatchDirective;
 
@@ -45,6 +46,12 @@ pub enum FieldMatcher {
     },
 }
 
+impl From<FieldMatcher> for Expression<NotificationMatcher> {
+    fn from(value: FieldMatcher) -> Self {
+        Expression::Match(NotificationMatcher::Field(value))
+    }
+}
+
 impl MatchDirective for FieldMatcher {
     fn matches(&self, notification: &Notification) -> Result<bool, Error> {
         Ok(match self {
diff --git a/proxmox-notify/src/matcher/severity.rs b/proxmox-notify/src/matcher/severity.rs
index 64472fab..a2538bcc 100644
--- a/proxmox-notify/src/matcher/severity.rs
+++ b/proxmox-notify/src/matcher/severity.rs
@@ -3,7 +3,9 @@ use std::str::FromStr;
 
 use serde::{Deserialize, Serialize};
 
-use crate::{Error, Notification, Severity};
+use proxmox_match_expression::Expression;
+
+use crate::{Error, Notification, Severity, matcher::expression::NotificationMatcher};
 
 use super::MatchDirective;
 
@@ -13,6 +15,12 @@ pub struct SeverityMatcher {
     pub(crate) severities: Vec<Severity>,
 }
 
+impl From<SeverityMatcher> for Expression<NotificationMatcher> {
+    fn from(value: SeverityMatcher) -> Self {
+        Expression::Match(NotificationMatcher::Severity(value))
+    }
+}
+
 impl MatchDirective for SeverityMatcher {
     /// Check if this directive matches a given notification
     fn matches(&self, notification: &Notification) -> Result<bool, Error> {
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 35+ messages in thread

* [PATCH proxmox v2 16/34] notify: move legacy matcher keys behind feature flag
  2026-10-01  8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
                   ` (14 preceding siblings ...)
  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 ` Lukas Wagner
  2026-10-01  8:36 ` [PATCH proxmox v2 17/34] notify: matcher: don't allow empty field/severity matchers Lukas Wagner
                   ` (17 subsequent siblings)
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:36 UTC (permalink / raw)
  To: pbs-devel, pve-devel

This allows us to only support 'expression' matchers if we want to, e.g.
when introducing the notification stack to PDM.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 proxmox-notify/Cargo.toml              |   1 +
 proxmox-notify/src/api/matcher.rs      | 110 ++++++---
 proxmox-notify/src/lib.rs              |  23 +-
 proxmox-notify/src/matcher/calendar.rs |  85 +++----
 proxmox-notify/src/matcher/field.rs    | 309 +++++++++++++------------
 proxmox-notify/src/matcher/mod.rs      | 102 ++++++--
 proxmox-notify/src/matcher/severity.rs | 132 ++++++-----
 7 files changed, 466 insertions(+), 296 deletions(-)

diff --git a/proxmox-notify/Cargo.toml b/proxmox-notify/Cargo.toml
index 9cbfc89f..9ff3c523 100644
--- a/proxmox-notify/Cargo.toml
+++ b/proxmox-notify/Cargo.toml
@@ -48,3 +48,4 @@ pve-context = ["dep:proxmox-sys", "sendmail"]
 pbs-context = ["dep:proxmox-sys", "sendmail"]
 smtp = ["dep:lettre"]
 webhook = ["dep:http", "dep:percent-encoding", "dep:proxmox-base64", "dep:proxmox-http"]
+legacy-matchers = []
diff --git a/proxmox-notify/src/api/matcher.rs b/proxmox-notify/src/api/matcher.rs
index be2d9c0e..77b2e1ce 100644
--- a/proxmox-notify/src/api/matcher.rs
+++ b/proxmox-notify/src/api/matcher.rs
@@ -34,6 +34,7 @@ pub fn get_matcher(config: &Config, name: &str) -> Result<MatcherConfig, HttpErr
 ///
 /// The caller is responsible for any needed permission checks.
 /// Returns the endpoint or a `HttpError` if the matcher was not found (`404 Not found`).
+#[cfg(feature = "legacy-matchers")]
 pub fn get_matcher_as_expression(config: &Config, name: &str) -> Result<MatcherConfig, HttpError> {
     let mut matcher: MatcherConfig = config
         .config
@@ -110,37 +111,49 @@ pub fn update_matcher(
     if let Some(delete) = delete {
         for deletable_property in delete {
             match deletable_property {
+                #[cfg(feature = "legacy-matchers")]
                 DeleteableMatcherProperty::MatchSeverity => matcher.match_severity.clear(),
+                #[cfg(feature = "legacy-matchers")]
                 DeleteableMatcherProperty::MatchField => matcher.match_field.clear(),
+                #[cfg(feature = "legacy-matchers")]
                 DeleteableMatcherProperty::MatchCalendar => matcher.match_calendar.clear(),
-                DeleteableMatcherProperty::Target => matcher.target.clear(),
-                DeleteableMatcherProperty::Mode => matcher.mode = None,
+                #[cfg(feature = "legacy-matchers")]
                 DeleteableMatcherProperty::InvertMatch => matcher.invert_match = None,
-                DeleteableMatcherProperty::Comment => matcher.comment = None,
-                DeleteableMatcherProperty::Disable => matcher.disable = None,
+                #[cfg(feature = "legacy-matchers")]
+                DeleteableMatcherProperty::Mode => matcher.mode = None,
                 DeleteableMatcherProperty::Expression => matcher.expression = None,
+                DeleteableMatcherProperty::Comment => matcher.comment = None,
+                DeleteableMatcherProperty::Target => matcher.target.clear(),
+                DeleteableMatcherProperty::Disable => matcher.disable = None,
             }
         }
     }
 
-    if let Some(match_severity) = matcher_updater.match_severity {
-        matcher.match_severity = match_severity;
+    #[cfg(feature = "legacy-matchers")]
+    {
+        if let Some(match_severity) = matcher_updater.match_severity {
+            matcher.match_severity = match_severity;
+        }
+
+        if let Some(match_field) = matcher_updater.match_field {
+            matcher.match_field = match_field;
+        }
+
+        if let Some(match_calendar) = matcher_updater.match_calendar {
+            matcher.match_calendar = match_calendar;
+        }
+
+        if let Some(mode) = matcher_updater.mode {
+            matcher.mode = Some(mode);
+        }
+
+        if let Some(invert_match) = matcher_updater.invert_match {
+            matcher.invert_match = Some(invert_match);
+        }
     }
 
-    if let Some(match_field) = matcher_updater.match_field {
-        matcher.match_field = match_field;
-    }
-
-    if let Some(match_calendar) = matcher_updater.match_calendar {
-        matcher.match_calendar = match_calendar;
-    }
-
-    if let Some(mode) = matcher_updater.mode {
-        matcher.mode = Some(mode);
-    }
-
-    if let Some(invert_match) = matcher_updater.invert_match {
-        matcher.invert_match = Some(invert_match);
+    if let Some(expression) = matcher_updater.expression {
+        matcher.expression = Some(expression);
     }
 
     if let Some(comment) = matcher_updater.comment {
@@ -150,11 +163,6 @@ pub fn update_matcher(
     if let Some(disable) = matcher_updater.disable {
         matcher.disable = Some(disable);
     }
-
-    if let Some(expression) = matcher_updater.expression {
-        matcher.expression = Some(expression);
-    }
-
     if let Some(target) = matcher_updater.target {
         super::ensure_endpoints_exist(config, target.as_slice())?;
         matcher.target = target;
@@ -198,7 +206,6 @@ mod tests {
 
     use super::*;
 
-    use crate::matcher::MatchModeOperator;
     use crate::matcher::expression::NotificationMatcher;
 
     fn empty_config() -> Config {
@@ -250,7 +257,10 @@ matcher: matcher2
     }
 
     #[test]
+    #[cfg(feature = "legacy-matchers")]
     fn test_matcher_update() -> Result<(), HttpError> {
+        use crate::matcher::MatchModeOperator;
+
         let mut config = config_with_two_matchers();
 
         let digest = config.digest;
@@ -305,6 +315,52 @@ matcher: matcher2
         Ok(())
     }
 
+    #[test]
+    #[cfg(not(feature = "legacy-matchers"))]
+    fn test_matcher_update() -> Result<(), HttpError> {
+        let mut config = config_with_two_matchers();
+
+        let digest = config.digest;
+
+        update_matcher(
+            &mut config,
+            "matcher1",
+            MatcherConfigUpdater {
+                expression: Some(valid_expression_string()),
+                target: Some(vec!["foo".into()]),
+                comment: Some("new comment".into()),
+                ..Default::default()
+            },
+            None,
+            Some(&digest),
+        )?;
+
+        let matcher = get_matcher(&config, "matcher1")?;
+
+        assert_eq!(matcher.comment, Some("new comment".into()));
+
+        // Test property deletion
+        update_matcher(
+            &mut config,
+            "matcher1",
+            Default::default(),
+            Some(&[
+                DeleteableMatcherProperty::Target,
+                DeleteableMatcherProperty::Comment,
+                DeleteableMatcherProperty::Expression,
+            ]),
+            Some(&digest),
+        )?;
+
+        let matcher = get_matcher(&config, "matcher1")?;
+
+        assert!(matcher.target.is_empty());
+        assert!(matcher.expression.is_none());
+        assert_eq!(matcher.comment, None);
+
+        Ok(())
+    }
+
     #[test]
     fn test_matcher_delete() -> Result<(), HttpError> {
         let mut config = config_with_two_matchers();
@@ -316,6 +372,7 @@ matcher: matcher2
     }
 
     #[test]
+    #[cfg(feature = "legacy-matchers")]
     fn test_update_matcher_mutually_exclusive_with_expression() -> Result<(), HttpError> {
         let mut config = config_with_two_matchers();
         let digest = config.digest;
@@ -366,6 +423,7 @@ matcher: matcher2
     }
 
     #[test]
+    #[cfg(feature = "legacy-matchers")]
     fn test_add_mutually_exclusive_with_expression() -> Result<(), HttpError> {
         let mut config = empty_config();
 
diff --git a/proxmox-notify/src/lib.rs b/proxmox-notify/src/lib.rs
index 1d1c03cc..8895796b 100644
--- a/proxmox-notify/src/lib.rs
+++ b/proxmox-notify/src/lib.rs
@@ -595,6 +595,10 @@ impl Bus {
 mod tests {
     use std::{cell::RefCell, rc::Rc};
 
+    use proxmox_match_expression::Expression;
+
+    use crate::matcher::{expression::NotificationMatcher, severity::SeverityMatcher};
+
     use super::*;
 
     #[derive(Default, Clone)]
@@ -634,6 +638,10 @@ mod tests {
         }
     }
 
+    fn expression_to_string(expression: Expression<NotificationMatcher>) -> String {
+        serde_json::to_string(&expression).unwrap()
+    }
+
     #[test]
     fn test_add_mock_endpoint() -> Result<(), Error> {
         let mock = MockEndpoint::new("endpoint");
@@ -643,6 +651,7 @@ mod tests {
 
         let matcher = MatcherConfig {
             target: vec!["endpoint".into()],
+            expression: Some(expression_to_string(Expression::Constant(true))),
             ..Default::default()
         };
 
@@ -673,14 +682,24 @@ mod tests {
 
         bus.add_matcher(MatcherConfig {
             name: "matcher1".into(),
-            match_severity: vec!["warning,error".parse()?],
+            expression: Some(expression_to_string(
+                SeverityMatcher {
+                    severities: vec![Severity::Error, Severity::Warning],
+                }
+                .into(),
+            )),
             target: vec!["mock1".into()],
             ..Default::default()
         });
 
         bus.add_matcher(MatcherConfig {
             name: "matcher2".into(),
-            match_severity: vec!["error".parse()?],
+            expression: Some(expression_to_string(
+                SeverityMatcher {
+                    severities: vec![Severity::Error],
+                }
+                .into(),
+            )),
             target: vec!["mock2".into()],
             ..Default::default()
         });
diff --git a/proxmox-notify/src/matcher/calendar.rs b/proxmox-notify/src/matcher/calendar.rs
index 43805840..ceb4db76 100644
--- a/proxmox-notify/src/matcher/calendar.rs
+++ b/proxmox-notify/src/matcher/calendar.rs
@@ -69,43 +69,51 @@ impl MatchDirective for CalendarMatcher {
     }
 }
 
-/// Match the timestamp of a notification.
-///
-/// This is a wrapper that serializes into an inline format that can be used
-/// in a section config key-value pair.
-#[derive(Clone, Debug)]
-pub struct InlineCalendarMatcher(CalendarMatcher);
+#[cfg(feature = "legacy-matchers")]
+pub mod inline {
+    use super::*;
 
-impl MatchDirective for InlineCalendarMatcher {
-    fn matches(&self, notification: &Notification) -> Result<bool, Error> {
-        self.0.matches(notification)
+    /// Match the timestamp of a notification.
+    ///
+    /// This is a wrapper that serializes into an inline format that can be used
+    /// in a section config key-value pair.
+    #[derive(Clone, Debug)]
+    pub struct InlineCalendarMatcher(CalendarMatcher);
+
+    impl MatchDirective for InlineCalendarMatcher {
+        fn matches(&self, notification: &Notification) -> Result<bool, Error> {
+            self.0.matches(notification)
+        }
     }
+
+    impl fmt::Display for InlineCalendarMatcher {
+        fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+            f.write_str(self.0.schedule.as_str())
+        }
+    }
+
+    impl FromStr for InlineCalendarMatcher {
+        type Err = Error;
+
+        fn from_str(s: &str) -> Result<Self, Error> {
+            Ok(Self(CalendarMatcher {
+                schedule: s.parse()?,
+            }))
+        }
+    }
+
+    impl InlineCalendarMatcher {
+        pub fn into_inner(self) -> CalendarMatcher {
+            self.0
+        }
+    }
+
+    proxmox_serde::forward_deserialize_to_from_str!(InlineCalendarMatcher);
+    proxmox_serde::forward_serialize_to_display!(InlineCalendarMatcher);
 }
 
-impl fmt::Display for InlineCalendarMatcher {
-    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
-        f.write_str(self.0.schedule.as_str())
-    }
-}
-
-impl FromStr for InlineCalendarMatcher {
-    type Err = Error;
-
-    fn from_str(s: &str) -> Result<Self, Error> {
-        Ok(Self(CalendarMatcher {
-            schedule: s.parse()?,
-        }))
-    }
-}
-
-impl InlineCalendarMatcher {
-    pub fn into_inner(self) -> CalendarMatcher {
-        self.0
-    }
-}
-
-proxmox_serde::forward_deserialize_to_from_str!(InlineCalendarMatcher);
-proxmox_serde::forward_serialize_to_display!(InlineCalendarMatcher);
+#[cfg(feature = "legacy-matchers")]
+pub use inline::*;
 
 #[cfg(test)]
 mod test {
@@ -125,7 +133,9 @@ mod test {
 
         // Match on a wide rage to avoid issues when running this test case
         // in a different time zone.
-        let matcher: InlineCalendarMatcher = "thu..sat 0-23".parse().unwrap();
+        let matcher = CalendarMatcher {
+            schedule: "thu..sat 0-23".parse().unwrap(),
+        };
         assert!(matcher.matches(&notification).unwrap());
     }
 
@@ -135,11 +145,6 @@ mod test {
 
         let calendar_matcher: CalendarMatcher = serde_json::from_str(calendar_matcher).unwrap();
 
-        let s = serde_json::to_string(&calendar_matcher).unwrap();
-        let m: CalendarMatcher = serde_json::from_str(&s).unwrap();
-
-        let a = InlineCalendarMatcher(m);
-
-        assert_eq!(a.to_string(), "thu..sat 0-23");
+        let _s = serde_json::to_string(&calendar_matcher).unwrap();
     }
 }
diff --git a/proxmox-notify/src/matcher/field.rs b/proxmox-notify/src/matcher/field.rs
index a21b00d3..4a4b2588 100644
--- a/proxmox-notify/src/matcher/field.rs
+++ b/proxmox-notify/src/matcher/field.rs
@@ -1,36 +1,12 @@
-use std::{fmt, str::FromStr};
-
-use const_format::concatcp;
 use regex::Regex;
 use serde::{Deserialize, Serialize};
 
 use proxmox_match_expression::Expression;
-use proxmox_schema::{
-    ApiStringFormat, Schema, StringSchema, api_types::SAFE_ID_REGEX_STR, const_regex,
-};
 
 use crate::{Error, Notification, matcher::expression::NotificationMatcher};
 
 use super::MatchDirective;
 
-const_regex! {
-    pub MATCH_FIELD_ENTRY_REGEX = concatcp!(r"^(?:(exact|regex):)?(", SAFE_ID_REGEX_STR, r")=(.*)$");
-}
-
-pub const MATCH_FIELD_ENTRY_FORMAT: ApiStringFormat =
-    ApiStringFormat::VerifyFn(verify_inline_field_matcher);
-
-fn verify_inline_field_matcher(s: &str) -> Result<(), anyhow::Error> {
-    let _: InlineFieldMatcher = s.parse()?;
-    Ok(())
-}
-
-pub const MATCH_FIELD_ENTRY_SCHEMA: Schema = StringSchema::new("Match metadata field.")
-    .format(&MATCH_FIELD_ENTRY_FORMAT)
-    .min_length(1)
-    .max_length(1024)
-    .schema();
-
 /// Check if the notification metadata fields match
 #[derive(Clone, Debug, Serialize, Deserialize)]
 #[serde(rename_all = "kebab-case", untagged)]
@@ -79,146 +55,179 @@ impl MatchDirective for FieldMatcher {
     }
 }
 
-/// Check if the notification metadata fields match
-///
-/// This is a wrapper that serializes into an inline format that can be used
-/// in a section config key-value pair.
-#[derive(Clone, Debug)]
-pub struct InlineFieldMatcher(FieldMatcher);
+#[cfg(feature = "legacy-matchers")]
+pub mod inline {
+    use std::{fmt, str::FromStr};
 
-impl MatchDirective for InlineFieldMatcher {
-    fn matches(&self, notification: &Notification) -> Result<bool, Error> {
-        self.0.matches(notification)
-    }
-}
+    use const_format::concatcp;
 
-impl fmt::Display for InlineFieldMatcher {
-    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
-        // Attention, Display is used to implement Serialize, do not
-        // change the format.
-
-        match &self.0 {
-            FieldMatcher::Exact {
-                field,
-                values: matched_values,
-            } => {
-                let values = matched_values.join(",");
-                write!(f, "exact:{field}={values}")
-            }
-            FieldMatcher::Regex {
-                field,
-                regex: matched_regex,
-            } => {
-                let re = matched_regex.as_str();
-                write!(f, "regex:{field}={re}")
-            }
-        }
-    }
-}
-
-impl FromStr for InlineFieldMatcher {
-    type Err = Error;
-    fn from_str(s: &str) -> Result<Self, Error> {
-        if !MATCH_FIELD_ENTRY_REGEX.is_match(s) {
-            return Err(Error::FilterFailed(format!(
-                "invalid match-field statement: {s}"
-            )));
-        }
-
-        if let Some(remaining) = s.strip_prefix("regex:") {
-            match remaining.split_once('=') {
-                None => Err(Error::FilterFailed(format!(
-                    "invalid match-field statement: {s}"
-                ))),
-                Some((field, expected_value_regex)) => {
-                    let regex = Regex::new(expected_value_regex)
-                        .map_err(|err| Error::FilterFailed(format!("invalid regex: {err}")))?;
-
-                    Ok(Self(FieldMatcher::Regex {
-                        field: field.into(),
-                        regex,
-                    }))
-                }
-            }
-        } else if let Some(remaining) = s.strip_prefix("exact:") {
-            match remaining.split_once('=') {
-                None => Err(Error::FilterFailed(format!(
-                    "invalid match-field statement: {s}"
-                ))),
-                Some((field, expected_values)) => {
-                    let values: Vec<String> = expected_values
-                        .split(',')
-                        .map(str::trim)
-                        .map(String::from)
-                        .collect();
-                    Ok(Self(FieldMatcher::Exact {
-                        field: field.into(),
-                        values,
-                    }))
-                }
-            }
-        } else {
-            Err(Error::FilterFailed(format!(
-                "invalid match-field statement: {s}"
-            )))
-        }
-    }
-}
-
-impl InlineFieldMatcher {
-    pub fn into_inner(self) -> FieldMatcher {
-        self.0
-    }
-}
-
-proxmox_serde::forward_deserialize_to_from_str!(InlineFieldMatcher);
-proxmox_serde::forward_serialize_to_display!(InlineFieldMatcher);
-
-#[cfg(test)]
-mod tests {
-    use std::collections::HashMap;
-
-    use serde_json::Value;
-
-    use crate::Severity;
+    use proxmox_schema::{
+        ApiStringFormat, Schema, StringSchema, api_types::SAFE_ID_REGEX_STR, const_regex,
+    };
 
     use super::*;
 
-    #[test]
-    fn test_matching() {
-        let mut fields = HashMap::new();
-        fields.insert("foo".into(), "bar".into());
+    const_regex! {
+        pub MATCH_FIELD_ENTRY_REGEX = concatcp!(r"^(?:(exact|regex):)?(", SAFE_ID_REGEX_STR, r")=(.*)$");
+    }
 
-        let notification =
-            Notification::from_template(Severity::Notice, "test", Value::Null, fields);
+    pub const MATCH_FIELD_ENTRY_FORMAT: ApiStringFormat =
+        ApiStringFormat::VerifyFn(verify_inline_field_matcher);
 
-        let matcher: InlineFieldMatcher = "exact:foo=bar".parse().unwrap();
-        assert!(matcher.matches(&notification).unwrap());
+    fn verify_inline_field_matcher(s: &str) -> Result<(), anyhow::Error> {
+        let _: InlineFieldMatcher = s.parse()?;
+        Ok(())
+    }
 
-        let matcher: InlineFieldMatcher = "regex:foo=b.*".parse().unwrap();
-        assert!(matcher.matches(&notification).unwrap());
+    pub const MATCH_FIELD_ENTRY_SCHEMA: Schema = StringSchema::new("Match metadata field.")
+        .format(&MATCH_FIELD_ENTRY_FORMAT)
+        .min_length(1)
+        .max_length(1024)
+        .schema();
 
-        let matcher: InlineFieldMatcher = "regex:notthere=b.*".parse().unwrap();
-        assert!(!matcher.matches(&notification).unwrap());
+    /// Check if the notification metadata fields match
+    /// This is a wrapper that serializes into an inline format that can be used
+    /// in a section config key-value pair.
+    #[derive(Clone, Debug)]
+    pub struct InlineFieldMatcher(FieldMatcher);
 
-        let matcher: InlineFieldMatcher = "exact:foo=bar,test".parse().unwrap();
-        assert!(matcher.matches(&notification).unwrap());
+    impl MatchDirective for InlineFieldMatcher {
+        fn matches(&self, notification: &Notification) -> Result<bool, Error> {
+            self.0.matches(notification)
+        }
+    }
 
-        let mut fields = HashMap::new();
-        fields.insert("foo".into(), "test".into());
+    impl fmt::Display for InlineFieldMatcher {
+        fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+            // Attention, Display is used to implement Serialize, do not
+            // change the format.
 
-        let notification =
-            Notification::from_template(Severity::Notice, "test", Value::Null, fields);
-        assert!(matcher.matches(&notification).unwrap());
+            match &self.0 {
+                FieldMatcher::Exact {
+                    field,
+                    values: matched_values,
+                } => {
+                    let values = matched_values.join(",");
+                    write!(f, "exact:{field}={values}")
+                }
+                FieldMatcher::Regex {
+                    field,
+                    regex: matched_regex,
+                } => {
+                    let re = matched_regex.as_str();
+                    write!(f, "regex:{field}={re}")
+                }
+            }
+        }
+    }
 
-        let mut fields = HashMap::new();
-        fields.insert("foo".into(), "notthere".into());
+    impl FromStr for InlineFieldMatcher {
+        type Err = Error;
+        fn from_str(s: &str) -> Result<Self, Error> {
+            if !MATCH_FIELD_ENTRY_REGEX.is_match(s) {
+                return Err(Error::FilterFailed(format!(
+                    "invalid match-field statement: {s}"
+                )));
+            }
 
-        let notification =
-            Notification::from_template(Severity::Notice, "test", Value::Null, fields);
-        assert!(!matcher.matches(&notification).unwrap());
+            if let Some(remaining) = s.strip_prefix("regex:") {
+                match remaining.split_once('=') {
+                    None => Err(Error::FilterFailed(format!(
+                        "invalid match-field statement: {s}"
+                    ))),
+                    Some((field, expected_value_regex)) => {
+                        let regex = Regex::new(expected_value_regex)
+                            .map_err(|err| Error::FilterFailed(format!("invalid regex: {err}")))?;
 
-        assert!("regex:'3=b.*".parse::<InlineFieldMatcher>().is_err());
-        assert!("invalid:'bar=b.*".parse::<InlineFieldMatcher>().is_err());
+                        Ok(Self(FieldMatcher::Regex {
+                            field: field.into(),
+                            regex,
+                        }))
+                    }
+                }
+            } else if let Some(remaining) = s.strip_prefix("exact:") {
+                match remaining.split_once('=') {
+                    None => Err(Error::FilterFailed(format!(
+                        "invalid match-field statement: {s}"
+                    ))),
+                    Some((field, expected_values)) => {
+                        let values: Vec<String> = expected_values
+                            .split(',')
+                            .map(str::trim)
+                            .map(String::from)
+                            .collect();
+                        Ok(Self(FieldMatcher::Exact {
+                            field: field.into(),
+                            values,
+                        }))
+                    }
+                }
+            } else {
+                Err(Error::FilterFailed(format!(
+                    "invalid match-field statement: {s}"
+                )))
+            }
+        }
+    }
+
+    impl InlineFieldMatcher {
+        pub fn into_inner(self) -> FieldMatcher {
+            self.0
+        }
+    }
+
+    proxmox_serde::forward_deserialize_to_from_str!(InlineFieldMatcher);
+    proxmox_serde::forward_serialize_to_display!(InlineFieldMatcher);
+
+    #[cfg(test)]
+    mod tests {
+        use std::collections::HashMap;
+
+        use serde_json::Value;
+
+        use crate::Severity;
+
+        use super::*;
+
+        #[test]
+        fn test_matching() {
+            let mut fields = HashMap::new();
+            fields.insert("foo".into(), "bar".into());
+
+            let notification =
+                Notification::from_template(Severity::Notice, "test", Value::Null, fields);
+
+            let matcher: InlineFieldMatcher = "exact:foo=bar".parse().unwrap();
+            assert!(matcher.matches(&notification).unwrap());
+
+            let matcher: InlineFieldMatcher = "regex:foo=b.*".parse().unwrap();
+            assert!(matcher.matches(&notification).unwrap());
+
+            let matcher: InlineFieldMatcher = "regex:notthere=b.*".parse().unwrap();
+            assert!(!matcher.matches(&notification).unwrap());
+
+            let matcher: InlineFieldMatcher = "exact:foo=bar,test".parse().unwrap();
+            assert!(matcher.matches(&notification).unwrap());
+
+            let mut fields = HashMap::new();
+            fields.insert("foo".into(), "test".into());
+
+            let notification =
+                Notification::from_template(Severity::Notice, "test", Value::Null, fields);
+            assert!(matcher.matches(&notification).unwrap());
+
+            let mut fields = HashMap::new();
+            fields.insert("foo".into(), "notthere".into());
+
+            let notification =
+                Notification::from_template(Severity::Notice, "test", Value::Null, fields);
+            assert!(!matcher.matches(&notification).unwrap());
+
+            assert!("regex:'3=b.*".parse::<InlineFieldMatcher>().is_err());
+            assert!("invalid:'bar=b.*".parse::<InlineFieldMatcher>().is_err());
+        }
     }
 }
+
+#[cfg(feature = "legacy-matchers")]
+pub use inline::*;
diff --git a/proxmox-notify/src/matcher/mod.rs b/proxmox-notify/src/matcher/mod.rs
index 12110e95..7279ef25 100644
--- a/proxmox-notify/src/matcher/mod.rs
+++ b/proxmox-notify/src/matcher/mod.rs
@@ -17,10 +17,6 @@ pub mod expression;
 pub mod field;
 pub mod severity;
 
-use calendar::InlineCalendarMatcher;
-use field::InlineFieldMatcher;
-use severity::InlineSeverityMatcher;
-
 pub const MATCHER_TYPENAME: &str = "matcher";
 
 #[api]
@@ -35,6 +31,7 @@ pub enum MatchModeOperator {
     Any,
 }
 
+#[cfg(feature = "legacy-matchers")]
 #[api(
     properties: {
         name: {
@@ -87,17 +84,17 @@ pub struct MatcherConfig {
     /// List of matched metadata fields.
     #[serde(default, skip_serializing_if = "Vec::is_empty")]
     #[updater(serde(skip_serializing_if = "Option::is_none"))]
-    pub match_field: Vec<InlineFieldMatcher>,
+    pub match_field: Vec<field::InlineFieldMatcher>,
 
     /// List of matched severity levels.
     #[serde(default, skip_serializing_if = "Vec::is_empty")]
     #[updater(serde(skip_serializing_if = "Option::is_none"))]
-    pub match_severity: Vec<InlineSeverityMatcher>,
+    pub match_severity: Vec<severity::InlineSeverityMatcher>,
 
     /// List of matched severity levels.
     #[serde(default, skip_serializing_if = "Vec::is_empty")]
     #[updater(serde(skip_serializing_if = "Option::is_none"))]
-    pub match_calendar: Vec<InlineCalendarMatcher>,
+    pub match_calendar: Vec<calendar::InlineCalendarMatcher>,
     /// Decide if 'all' or 'any' match statements must match.
     #[serde(skip_serializing_if = "Option::is_none")]
     pub mode: Option<MatchModeOperator>,
@@ -139,20 +136,79 @@ pub struct MatcherConfig {
     pub origin: Option<Origin>,
 }
 
+#[cfg(not(feature = "legacy-matchers"))]
+#[api(
+    properties: {
+        name: {
+            schema: ENTITY_NAME_SCHEMA,
+        },
+        comment: {
+            optional: true,
+            schema: COMMENT_SCHEMA,
+        },
+        "target": {
+            type: Array,
+            items: {
+                schema: ENTITY_NAME_SCHEMA,
+            },
+            optional: true,
+        },
+    })]
+#[derive(Clone, Debug, Serialize, Deserialize, Updater, Default)]
+#[serde(rename_all = "kebab-case")]
+/// Config for notification matchers.
+pub struct MatcherConfig {
+    /// Name of the matcher.
+    #[updater(skip)]
+    pub name: String,
+
+    /// Match expression as inline JSON.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub expression: Option<String>,
+
+    /// Targets to notify.
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    #[updater(serde(skip_serializing_if = "Option::is_none"))]
+    pub target: Vec<String>,
+
+    /// Comment.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub comment: Option<String>,
+
+    /// Disable this matcher.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub disable: Option<bool>,
+
+    /// Origin of this config entry.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    #[updater(skip)]
+    pub origin: Option<Origin>,
+}
+
 trait MatchDirective {
     fn matches(&self, notification: &Notification) -> Result<bool, Error>;
 }
 
 impl MatcherConfig {
     pub fn matches(&self, notification: &Notification) -> Result<Option<&[String]>, Error> {
+        #[cfg(feature = "legacy-matchers")]
         self.warn_about_invalid_configuration();
 
-        let expression = if let Some(expression_str) = &self.expression {
-            serde_json::from_str(expression_str).map_err(|err| {
-                Error::FilterFailed(format!("could not deserialize filter expression: {err:#}"))
-            })?
-        } else {
-            self.generate_expression_from_legacy_config()
+        let expression: Expression<NotificationMatcher> = {
+            if let Some(expression_str) = &self.expression {
+                serde_json::from_str(expression_str).map_err(|err| {
+                    Error::FilterFailed(format!("could not deserialize filter expression: {err:#}"))
+                })?
+            } else {
+                #[cfg(feature = "legacy-matchers")]
+                {
+                    self.generate_expression_from_legacy_config()
+                }
+                #[cfg(not(feature = "legacy-matchers"))]
+                {
+                    Expression::Constant(true)
+                }
+            }
         };
 
         // Later, once we have a notification history, we can also save the evaluated expression.
@@ -164,6 +220,7 @@ impl MatcherConfig {
             .then_some(self.target.as_slice()))
     }
 
+    #[cfg(feature = "legacy-matchers")]
     pub(crate) fn generate_expression_from_legacy_config(&self) -> Expression<NotificationMatcher> {
         if self.match_severity.is_empty()
             && self.match_field.is_empty()
@@ -205,12 +262,14 @@ impl MatcherConfig {
 
     /// Ensure the validity of this matcher.
     pub(crate) fn ensure_valid(&self) -> Result<(), Error> {
+        #[cfg(feature = "legacy-matchers")]
         self.ensure_no_ignored_properties()?;
         self.ensure_valid_expression()?;
 
         Ok(())
     }
 
+    #[cfg(feature = "legacy-matchers")]
     fn ensure_no_ignored_properties(&self) -> Result<(), Error> {
         if self.expression.is_some() {
             let conflicting_properties = self.properties_conflicting_with_expression();
@@ -283,6 +342,7 @@ impl MatcherConfig {
         Ok(())
     }
 
+    #[cfg(feature = "legacy-matchers")]
     fn warn_about_invalid_configuration(&self) {
         if self.expression.is_some() {
             let conflicting_properties = self.properties_conflicting_with_expression();
@@ -297,6 +357,7 @@ impl MatcherConfig {
         }
     }
 
+    #[cfg(feature = "legacy-matchers")]
     fn properties_conflicting_with_expression(&self) -> Vec<&'static str> {
         let mut conflicting_properties = Vec::new();
 
@@ -330,14 +391,19 @@ pub enum DeleteableMatcherProperty {
     /// Delete `disable`
     Disable,
     /// Delete `invert-match`
+    #[cfg(feature = "legacy-matchers")]
     InvertMatch,
     /// Delete `match-calendar`
+    #[cfg(feature = "legacy-matchers")]
     MatchCalendar,
     /// Delete `match-field`
+    #[cfg(feature = "legacy-matchers")]
     MatchField,
     /// Delete `match-severity`
+    #[cfg(feature = "legacy-matchers")]
     MatchSeverity,
     /// Delete `mode`
+    #[cfg(feature = "legacy-matchers")]
     Mode,
     /// Delete `expression`
     Expression,
@@ -386,14 +452,14 @@ pub fn check_matches<'a>(
 
 #[cfg(test)]
 mod tests {
-    use serde_json::Value;
-
-    use crate::Severity;
-
-    use super::*;
 
     #[test]
+    #[cfg(feature = "legacy-matchers")]
     fn test_empty_matcher_matches_always() {
+        use super::*;
+        use crate::Severity;
+        use serde_json::Value;
+
         let notification =
             Notification::from_template(Severity::Notice, "test", Value::Null, Default::default());
 
diff --git a/proxmox-notify/src/matcher/severity.rs b/proxmox-notify/src/matcher/severity.rs
index a2538bcc..2088ca74 100644
--- a/proxmox-notify/src/matcher/severity.rs
+++ b/proxmox-notify/src/matcher/severity.rs
@@ -1,6 +1,3 @@
-use std::fmt;
-use std::str::FromStr;
-
 use serde::{Deserialize, Serialize};
 
 use proxmox_match_expression::Expression;
@@ -28,66 +25,81 @@ impl MatchDirective for SeverityMatcher {
     }
 }
 
-/// Match severity of the notification.
-///
-/// This is a wrapper that serializes into an inline format that can be used
-/// in a section config key-value pair.
-#[derive(Clone, Debug)]
-pub struct InlineSeverityMatcher(SeverityMatcher);
-
-/// Common trait implemented by all matching directives
-impl MatchDirective for InlineSeverityMatcher {
-    /// Check if this directive matches a given notification
-    fn matches(&self, notification: &Notification) -> Result<bool, Error> {
-        self.0.matches(notification)
-    }
-}
-
-impl fmt::Display for InlineSeverityMatcher {
-    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
-        let severities: Vec<String> = self.0.severities.iter().map(|s| format!("{s}")).collect();
-        f.write_str(&severities.join(","))
-    }
-}
-
-impl FromStr for InlineSeverityMatcher {
-    type Err = Error;
-
-    fn from_str(s: &str) -> Result<Self, Error> {
-        let mut severities = Vec::new();
-
-        for element in s.split(',') {
-            let element = element.trim();
-            let severity: Severity = element.parse()?;
-
-            severities.push(severity)
-        }
-
-        Ok(Self(SeverityMatcher { severities }))
-    }
-}
-
-impl InlineSeverityMatcher {
-    pub fn into_inner(self) -> SeverityMatcher {
-        self.0
-    }
-}
-
-proxmox_serde::forward_deserialize_to_from_str!(InlineSeverityMatcher);
-proxmox_serde::forward_serialize_to_display!(InlineSeverityMatcher);
-
-#[cfg(test)]
-mod test {
-    use serde_json::Value;
+#[cfg(feature = "legacy-matchers")]
+pub mod inline {
+    use std::{fmt, str::FromStr};
 
     use super::*;
 
-    #[test]
-    fn test_severities() {
-        let notification =
-            Notification::from_template(Severity::Notice, "test", Value::Null, Default::default());
+    /// Match severity of the notification.
+    ///
+    /// This is a wrapper that serializes into an inline format that can be used
+    /// in a section config key-value pair.
+    #[derive(Clone, Debug)]
+    pub struct InlineSeverityMatcher(SeverityMatcher);
 
-        let matcher: InlineSeverityMatcher = "info,notice,warning,error".parse().unwrap();
-        assert!(matcher.matches(&notification).unwrap());
+    /// Common trait implemented by all matching directives
+    impl MatchDirective for InlineSeverityMatcher {
+        /// Check if this directive matches a given notification
+        fn matches(&self, notification: &Notification) -> Result<bool, Error> {
+            self.0.matches(notification)
+        }
+    }
+
+    impl fmt::Display for InlineSeverityMatcher {
+        fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+            let severities: Vec<String> =
+                self.0.severities.iter().map(|s| format!("{s}")).collect();
+            f.write_str(&severities.join(","))
+        }
+    }
+
+    impl FromStr for InlineSeverityMatcher {
+        type Err = Error;
+
+        fn from_str(s: &str) -> Result<Self, Error> {
+            let mut severities = Vec::new();
+
+            for element in s.split(',') {
+                let element = element.trim();
+                let severity: Severity = element.parse()?;
+
+                severities.push(severity)
+            }
+
+            Ok(Self(SeverityMatcher { severities }))
+        }
+    }
+
+    impl InlineSeverityMatcher {
+        pub fn into_inner(self) -> SeverityMatcher {
+            self.0
+        }
+    }
+
+    proxmox_serde::forward_deserialize_to_from_str!(InlineSeverityMatcher);
+    proxmox_serde::forward_serialize_to_display!(InlineSeverityMatcher);
+
+    #[cfg(test)]
+    mod test {
+        use serde_json::Value;
+
+        use super::*;
+
+        #[test]
+        fn test_severities() {
+            let notification = Notification::from_template(
+                Severity::Notice,
+                "test",
+                Value::Null,
+                Default::default(),
+            );
+
+            let matcher: InlineSeverityMatcher = "info,notice,warning,error".parse().unwrap();
+            assert!(matcher.matches(&notification).unwrap());
+        }
     }
 }
+
+#[cfg(feature = "legacy-matchers")]
+pub use inline::*;
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 35+ messages in thread

* [PATCH proxmox v2 17/34] notify: matcher: don't allow empty field/severity matchers
  2026-10-01  8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
                   ` (15 preceding siblings ...)
  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 ` Lukas Wagner
  2026-10-01  8:36 ` [PATCH proxmox-widget-toolkit v2 18/34] notifications: increase matcher window width Lukas Wagner
                   ` (16 subsequent siblings)
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:36 UTC (permalink / raw)
  To: pbs-devel, pve-devel

Severity and field matcher in 'exact' mode allow matching on multiple
values, which is equivalent to an 'any-of' of multiple matchers matching
on a single value. Since we don't allow empty 'any-of' groups, it makes
sense to reject empty/severity matchers as well.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---

Notes:
    new in v2

 proxmox-notify/src/matcher/calendar.rs   |  8 ++++
 proxmox-notify/src/matcher/expression.rs | 13 ++++++
 proxmox-notify/src/matcher/field.rs      | 16 +++++++
 proxmox-notify/src/matcher/mod.rs        | 55 ++++++++++++++++++++++++
 proxmox-notify/src/matcher/severity.rs   | 14 ++++++
 5 files changed, 106 insertions(+)

diff --git a/proxmox-notify/src/matcher/calendar.rs b/proxmox-notify/src/matcher/calendar.rs
index ceb4db76..7fd18291 100644
--- a/proxmox-notify/src/matcher/calendar.rs
+++ b/proxmox-notify/src/matcher/calendar.rs
@@ -67,6 +67,10 @@ impl MatchDirective for CalendarMatcher {
             .time_match(notification.metadata.timestamp, false)
             .map_err(|err| Error::Generic(format!("could not match timestamp: {err}")))
     }
+
+    fn validate(&self) -> Result<(), Error> {
+        Ok(())
+    }
 }
 
 #[cfg(feature = "legacy-matchers")]
@@ -84,6 +88,10 @@ pub mod inline {
         fn matches(&self, notification: &Notification) -> Result<bool, Error> {
             self.0.matches(notification)
         }
+
+        fn validate(&self) -> Result<(), Error> {
+            self.0.validate()
+        }
     }
 
     impl fmt::Display for InlineCalendarMatcher {
diff --git a/proxmox-notify/src/matcher/expression.rs b/proxmox-notify/src/matcher/expression.rs
index 9bb12f74..55c71cc4 100644
--- a/proxmox-notify/src/matcher/expression.rs
+++ b/proxmox-notify/src/matcher/expression.rs
@@ -32,6 +32,19 @@ impl MatchExpression for NotificationMatcher {
     }
 }
 
+impl NotificationMatcher {
+    /// Validate any additional constraints that cannot be expressed in the type system.
+    pub(crate) fn validate(&self) -> Result<(), Error> {
+        use crate::matcher::MatchDirective;
+
+        match self {
+            NotificationMatcher::Field(field_matcher) => field_matcher.validate(),
+            NotificationMatcher::Calendar(calendar_matcher) => calendar_matcher.validate(),
+            NotificationMatcher::Severity(severity_matcher) => severity_matcher.validate(),
+        }
+    }
+}
+
 #[cfg(test)]
 mod test {
     use proxmox_match_expression::Expression;
diff --git a/proxmox-notify/src/matcher/field.rs b/proxmox-notify/src/matcher/field.rs
index 4a4b2588..cd23ff9c 100644
--- a/proxmox-notify/src/matcher/field.rs
+++ b/proxmox-notify/src/matcher/field.rs
@@ -53,6 +53,18 @@ impl MatchDirective for FieldMatcher {
             }
         })
     }
+
+    fn validate(&self) -> Result<(), Error> {
+        if let FieldMatcher::Exact { values, .. } = self {
+            if values.is_empty() {
+                return Err(Error::Generic(
+                    "field matcher must contain values to match".to_string(),
+                ));
+            }
+        }
+
+        Ok(())
+    }
 }
 
 #[cfg(feature = "legacy-matchers")]
@@ -95,6 +107,10 @@ pub mod inline {
         fn matches(&self, notification: &Notification) -> Result<bool, Error> {
             self.0.matches(notification)
         }
+
+        fn validate(&self) -> Result<(), Error> {
+            self.0.validate()
+        }
     }
 
     impl fmt::Display for InlineFieldMatcher {
diff --git a/proxmox-notify/src/matcher/mod.rs b/proxmox-notify/src/matcher/mod.rs
index 7279ef25..0c995819 100644
--- a/proxmox-notify/src/matcher/mod.rs
+++ b/proxmox-notify/src/matcher/mod.rs
@@ -187,6 +187,8 @@ pub struct MatcherConfig {
 
 trait MatchDirective {
     fn matches(&self, notification: &Notification) -> Result<bool, Error>;
+    /// Validate any additional constraints that cannot be expressed in the type system.
+    fn validate(&self) -> Result<(), Error>;
 }
 
 impl MatcherConfig {
@@ -332,6 +334,9 @@ impl MatcherConfig {
                     ));
                 }
             }
+            Expression::Match(matcher) => {
+                matcher.validate()?;
+            }
             _ => {}
         }
 
@@ -452,6 +457,7 @@ pub fn check_matches<'a>(
 
 #[cfg(test)]
 mod tests {
+    use super::*;
 
     #[test]
     #[cfg(feature = "legacy-matchers")]
@@ -473,4 +479,53 @@ mod tests {
             assert!(config.matches(&notification).unwrap().is_some())
         }
     }
+
+    #[test]
+    fn test_expression_constraints() {
+        let empty_any_of = r#"
+            {
+              "any-of": []
+            }"#;
+        let empty_all_of = r#"
+            {
+              "all-of": []
+            }"#;
+        let empty_one_of = r#"
+            {
+              "one-of": []
+            }"#;
+
+        let empty_field_matcher = r#"
+            {
+              "match": {
+                "type": "field",
+                "field": "something",
+                "values": []
+              }
+            }"#;
+
+        let empty_severity_matcher = r#"
+            {
+              "match": {
+                "type": "severity",
+                "severities": [],
+              }
+            }"#;
+
+        for expr in [
+            empty_one_of,
+            empty_all_of,
+            empty_any_of,
+            empty_field_matcher,
+            empty_severity_matcher,
+        ] {
+            let config = MatcherConfig {
+                name: "matcher".to_string(),
+                expression: Some(expr.into()),
+                ..Default::default()
+            };
+
+            assert!(config.ensure_valid().is_err());
+        }
+    }
 }
diff --git a/proxmox-notify/src/matcher/severity.rs b/proxmox-notify/src/matcher/severity.rs
index 2088ca74..2360dfca 100644
--- a/proxmox-notify/src/matcher/severity.rs
+++ b/proxmox-notify/src/matcher/severity.rs
@@ -23,6 +23,16 @@ impl MatchDirective for SeverityMatcher {
     fn matches(&self, notification: &Notification) -> Result<bool, Error> {
         Ok(self.severities.contains(&notification.metadata.severity))
     }
+
+    fn validate(&self) -> Result<(), Error> {
+        if self.severities.is_empty() {
+            return Err(Error::Generic(
+                "severity matcher must contain severities to match".to_string(),
+            ));
+        }
+
+        Ok(())
+    }
 }
 
 #[cfg(feature = "legacy-matchers")]
@@ -44,6 +54,10 @@ pub mod inline {
         fn matches(&self, notification: &Notification) -> Result<bool, Error> {
             self.0.matches(notification)
         }
+
+        fn validate(&self) -> Result<(), Error> {
+            self.0.validate()
+        }
     }
 
     impl fmt::Display for InlineSeverityMatcher {
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 35+ messages in thread

* [PATCH proxmox-widget-toolkit v2 18/34] notifications: increase matcher window width
  2026-10-01  8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
                   ` (16 preceding siblings ...)
  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 ` Lukas Wagner
  2026-10-01  8:36 ` [PATCH proxmox-widget-toolkit v2 19/34] notifications: matcher: add support for match expressions Lukas Wagner
                   ` (15 subsequent siblings)
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:36 UTC (permalink / raw)
  To: pbs-devel, pve-devel

The two-column layout of the dialog benefits from a bit more space.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 src/window/NotificationMatcherEdit.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/window/NotificationMatcherEdit.js b/src/window/NotificationMatcherEdit.js
index a1d173c..1999e20 100644
--- a/src/window/NotificationMatcherEdit.js
+++ b/src/window/NotificationMatcherEdit.js
@@ -78,7 +78,7 @@ Ext.define('Proxmox.window.NotificationMatcherEdit', {
         labelWidth: 120,
     },
 
-    width: 800,
+    width: 1000,
 
     initComponent: function () {
         let me = this;
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 35+ messages in thread

* [PATCH proxmox-widget-toolkit v2 19/34] notifications: matcher: add support for match expressions
  2026-10-01  8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
                   ` (17 preceding siblings ...)
  2026-10-01  8:36 ` [PATCH proxmox-widget-toolkit v2 18/34] notifications: increase matcher window width Lukas Wagner
@ 2026-10-01  8:36 ` Lukas Wagner
  2026-10-01  8:36 ` [PATCH proxmox-widget-toolkit v2 20/34] notifications: matcher: add better calendar editor Lukas Wagner
                   ` (14 subsequent siblings)
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:36 UTC (permalink / raw)
  To: pbs-devel, pve-devel

This patches changes to 'matcher edit window' to support the new
'expression' paramter in the matcher config. Instead of of flat
'match-*' configuration keys, 'expression' contains the match rules as a
serialized JSON blob. This allows us to create arbitrarily nested match
formulas.

Support for match expressions has to be enabled via a feature flag. This
avoids awkward versioned breaks between the widget toolkit package and
the main product.

The entire match rule panel has changed so much that copying the old
component and using that as a base was deemed more useful than modifying
the existing panel in-place. The old component can be removed once we
have migrated all products to this new system.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---

Notes:
    Changes since v1:
      - Don't allow empty all-of/any-of/one-of rules, show an
        error below the tree then

 src/Makefile                                  |   1 +
 src/Schema.js                                 |  10 +
 src/css/ext6-pmx.css                          |  51 ++
 .../NotificationMatchExpressionEditPanel.js   | 866 ++++++++++++++++++
 src/proxmox-dark/scss/extjs/_treepanel.scss   |   5 +
 src/proxmox-dark/scss/proxmox/_general.scss   |   4 +
 src/window/NotificationMatcherEdit.js         |  13 +-
 7 files changed, 945 insertions(+), 5 deletions(-)
 create mode 100644 src/panel/NotificationMatchExpressionEditPanel.js

diff --git a/src/Makefile b/src/Makefile
index 9c52339..f11716a 100644
--- a/src/Makefile
+++ b/src/Makefile
@@ -64,6 +64,7 @@ JSSRC=					\
 	panel/LogView.js		\
 	panel/NodeInfoRepoStatus.js	\
 	panel/NotificationConfigView.js	\
+	panel/NotificationMatchExpressionEditPanel.js	\
 	panel/JournalView.js		\
 	panel/PermissionView.js		\
 	panel/PruneKeepPanel.js		\
diff --git a/src/Schema.js b/src/Schema.js
index 5de3f53..e72d301 100644
--- a/src/Schema.js
+++ b/src/Schema.js
@@ -86,6 +86,16 @@ Ext.define('Proxmox.Schema', {
         }
     },
 
+    notificationMatcherExpressions: false,
+
+    notificationMatcherPanelType: function () {
+        if (Proxmox.Schema.notificationMatcherExpressions) {
+            return 'pmxNotificationMatchExpressionEditPanel';
+        } else {
+            return 'pmxNotificationMatchRulesEditPanel';
+        }
+    },
+
     pxarFileTypes: {
         b: { icon: 'cube', label: gettext('Block Device') },
         c: { icon: 'tty', label: gettext('Character Device') },
diff --git a/src/css/ext6-pmx.css b/src/css/ext6-pmx.css
index 878dfa0..93010e6 100644
--- a/src/css/ext6-pmx.css
+++ b/src/css/ext6-pmx.css
@@ -248,6 +248,53 @@ div.right-aligned {
     background-color: #f5f5f5;
 }
 
+/* the small icons */
+.x-tree-icon-custom:after {
+    position: relative;
+    left: -5px;
+    top: 1px;
+    font-size: 0.75em;
+    text-shadow: -1px 0px 2px #fff;
+    content: "\ ";
+}
+
+/* yellow ! triangle */
+.x-tree-icon-custom.internal-error:after {
+    content: "\f071";
+    color: #ffcc00;
+}
+
+.x-tree-node-text > code {
+    display: inline-block;
+    background-color: #f5f5f5;
+    border-radius: 4px;
+    padding: 1px 4px;
+    margin: 1px 0;
+    font-size: 11px;
+    /* a single long value, e.g. a regex, has to break instead of overflowing */
+    overflow-wrap: anywhere;
+}
+
+/* The notification matcher rule tree wraps its labels, see 'cellWrap'. Laying
+ * the cell out as a flex row keeps the wrapped lines aligned with the first
+ * one, instead of restarting at the left edge under the indentation. */
+.pmx-match-rule-tree .x-grid-cell-inner-treecolumn {
+    display: flex;
+    align-items: flex-start;
+}
+
+/* indentation, elbows and the node icon have a fixed size and must not shrink */
+.pmx-match-rule-tree .x-tree-elbow-img,
+.pmx-match-rule-tree .x-tree-icon {
+    flex: none;
+}
+
+/* 'min-width' lets the label get narrower than its longest value */
+.pmx-match-rule-tree .x-tree-node-text {
+    flex: 1;
+    min-width: 0;
+}
+
 /* fix padding for legend in header */
 .x-legend-inner {
     padding: 0;
@@ -431,3 +478,7 @@ div.right-aligned {
     font-style: italic;
 }
 /* journal log level coloring end */
+
+.pmx-horizontal-separator {
+    border-top: 1px solid #cfcfcf;
+}
diff --git a/src/panel/NotificationMatchExpressionEditPanel.js b/src/panel/NotificationMatchExpressionEditPanel.js
new file mode 100644
index 0000000..773fe81
--- /dev/null
+++ b/src/panel/NotificationMatchExpressionEditPanel.js
@@ -0,0 +1,866 @@
+Ext.define('Proxmox.panel.NotificationMatchExpressionEditPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pmxNotificationMatchExpressionEditPanel',
+    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: {
+            invertMatch: false,
+            selectedRecord: null,
+            matchFieldType: 'exact',
+            matchFieldField: '',
+            matchFieldValue: '',
+        },
+
+        formulas: {
+            showMatcherType: (get) => get('selectedRecord') !== null,
+
+            invertMatch: {
+                bind: {
+                    bindTo: '{selectedRecord}',
+                    deep: true,
+                },
+                get: function (record) {
+                    return record?.get('invert');
+                },
+                set: function (value) {
+                    let me = this;
+                    let record = me.get('selectedRecord');
+                    record.set('invert', value);
+                },
+            },
+            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;
+                    let leaf;
+
+                    switch (value) {
+                        case 'match-severity':
+                            data = {
+                                value: ['info', 'notice', 'warning', 'error', 'unknown'],
+                            };
+                            leaf = true;
+                            break;
+                        case 'match-field':
+                            data = {
+                                type: 'exact',
+                                field: '',
+                                value: '',
+                            };
+                            leaf = true;
+                            break;
+                        case 'match-calendar':
+                            data = {
+                                value: '',
+                            };
+                            leaf = true;
+                            break;
+                        case 'any-of':
+                            data = {};
+                            leaf = false;
+                            break;
+                        case 'all-of':
+                            data = {};
+                            leaf = false;
+                            break;
+                        case 'one-of':
+                            data = {};
+                            leaf = false;
+                            break;
+                        case 'match-always':
+                            value = 'match-always';
+                            data = {};
+                            leaf = true;
+                            break;
+                    }
+
+                    let node = {
+                        type: value,
+                        invert: false,
+                        data,
+                        leaf,
+                    };
+
+                    if (!leaf) {
+                        node.expanded = true;
+                        node.expandable = false;
+                    } else {
+                        record.removeAll();
+                    }
+
+                    record.set(node);
+                },
+            },
+        },
+    },
+
+    column1: [
+        {
+            xtype: 'pmxNotificationMatchRuleTreeExpression',
+            cbind: {
+                baseUrl: '{baseUrl}',
+            },
+        },
+    ],
+
+    column2: [
+        {
+            xtype: 'pmxNotificationMatchExpressionNodePanel',
+            cbind: {
+                baseUrl: '{baseUrl}',
+            },
+        },
+    ],
+
+    onGetValues: function (values) {
+        let me = this;
+
+        if (!me.isCreate) {
+            Proxmox.Utils.assemble_field_data(values, { delete: 'match-field' });
+            Proxmox.Utils.assemble_field_data(values, { delete: 'match-severity' });
+            Proxmox.Utils.assemble_field_data(values, { delete: 'match-calendar' });
+            Proxmox.Utils.assemble_field_data(values, { delete: 'mode' });
+            Proxmox.Utils.assemble_field_data(values, { delete: 'invert-match' });
+        }
+
+        return values;
+    },
+});
+
+Ext.define('Proxmox.panel.NotificationMatchRuleTreeExpression', {
+    extend: 'Ext.panel.Panel',
+    xtype: 'pmxNotificationMatchRuleTreeExpression',
+    mixins: ['Proxmox.Mixin.CBind'],
+    border: false,
+
+    // 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) {
+        switch (type) {
+            case 'match-severity': {
+                let v = Ext.isArray(data.value) ? data.value.join(', ') : data.value;
+                if (!v) {
+                    return gettext('Severity rules must match at least one severity.');
+                }
+                break;
+            }
+            case 'match-field': {
+                let field = data.field;
+                let value = data.value;
+                if (!field || !value || (Ext.isArray(value) && !value.length)) {
+                    return gettext('Field rules must have a field and a value.');
+                }
+                break;
+            }
+            case 'match-calendar': {
+                let v = data.value;
+                if (!v || !v.length) {
+                    return gettext('Calendar rules must have a schedule.');
+                }
+                break;
+            }
+            case 'any-of':
+            case 'all-of':
+            case 'one-of':
+                if (!hasChildren) {
+                    return gettext('Rule groups must contain at least one sub-rule.');
+                }
+                break;
+        }
+
+        return undefined;
+    },
+
+    getNodeIcon: function (type, data, invert, hasChildren) {
+        let me = this;
+        let iconCls = 'fa';
+
+        switch (type) {
+            case 'match-severity':
+                iconCls += ' fa-exclamation';
+                break;
+            case 'match-field':
+                iconCls += ' fa-square-o';
+                break;
+            case 'match-calendar':
+                iconCls += ' fa-calendar-o';
+                break;
+            case 'any-of':
+            case 'all-of':
+            case 'one-of':
+                iconCls += ' fa-filter';
+                break;
+            case 'match-always':
+                if (invert) {
+                    iconCls += ' fa-ban';
+                } else {
+                    iconCls += ' fa-check-circle';
+                }
+                break;
+        }
+
+        if (me.getNodeError(type, data, hasChildren)) {
+            iconCls += ' internal-error';
+        }
+
+        return iconCls;
+    },
+
+    renderTreeLabel: function (_value, meta, record) {
+        let data = record.get('data');
+        let invert = record.get('invert');
+        let type = record.get('type');
+
+        switch (type) {
+            case 'match-severity': {
+                let v;
+
+                if (data.value) {
+                    v = data.value.map((x) => `<code>${Ext.String.htmlEncode(x)}</code>`).join(' ');
+                } else {
+                    v = '<code>?</code>';
+                }
+
+                if (invert) {
+                    return Ext.String.format(gettext('Severity: all except {0}'), v);
+                } else {
+                    return Ext.String.format(gettext('Severity: {0}'), v);
+                }
+            }
+            case 'match-field': {
+                let valueIsArray = Ext.isArray(data.value);
+
+                let field = data.field ? data.field : '?';
+                let value =
+                    (!valueIsArray && data.value) || (valueIsArray && data.value.length)
+                        ? data.value
+                        : '?';
+                let matchType = data.type;
+
+                let fieldHtml = `<code>${Ext.String.htmlEncode(field)}</code>`;
+                let valueHtml = `<code>${Ext.String.htmlEncode(value)}</code>`;
+
+                if (matchType === 'exact') {
+                    if (Ext.isArray(value) && value.length > 1) {
+                        valueHtml = value
+                            .map((x) => `<code>${Ext.String.htmlEncode(x)}</code>`)
+                            .join(' ');
+
+                        if (invert) {
+                            // inverted field matcher, matching multiple values
+                            return Ext.String.format(
+                                gettext('Field: {0} none of {1}'),
+                                fieldHtml,
+                                valueHtml,
+                            );
+                        } else {
+                            // non-inverted field matcher, matching multiple values
+                            return Ext.String.format(
+                                gettext('Field: {0} any of {1}'),
+                                fieldHtml,
+                                valueHtml,
+                            );
+                        }
+                    } else if (invert) {
+                        // inverted field matcher, matching a single value
+                        return Ext.String.format(
+                            gettext('Field: {0} is not {1}'),
+                            fieldHtml,
+                            valueHtml,
+                        );
+                    } else {
+                        // non-inverted field matcher, matching a single value
+                        return Ext.String.format(
+                            gettext('Field: {0} is {1}'),
+                            fieldHtml,
+                            valueHtml,
+                        );
+                    }
+                } else if (invert) {
+                    // inverted regex-based field matcher
+                    return Ext.String.format(
+                        gettext('Field: {0} does not match {1}'),
+                        fieldHtml,
+                        valueHtml,
+                    );
+                } else {
+                    // non-inverted regex-based field matcher
+                    return Ext.String.format(
+                        gettext('Field: {0} matches {1}'),
+                        fieldHtml,
+                        valueHtml,
+                    );
+                }
+            }
+            case 'match-calendar': {
+                let value = data.value ? data.value : '?';
+                value = `<code>${Ext.String.htmlEncode(value)}</code>`;
+
+                if (invert) {
+                    return Ext.String.format(gettext('Calendar: outside {0}'), value);
+                } else {
+                    return Ext.String.format(gettext('Calendar: within {0}'), value);
+                }
+            }
+            case 'any-of':
+                if (invert) {
+                    return gettext('No sub-rule matches');
+                } else {
+                    return gettext('At least one sub-rule matches');
+                }
+
+            case 'all-of':
+                if (invert) {
+                    return gettext('At least one sub-rule does not match');
+                } else {
+                    return gettext('All sub-rules match');
+                }
+
+            case 'one-of':
+                if (invert) {
+                    return gettext('Not: Exactly one sub-rule matches');
+                } else {
+                    return gettext('Exactly one sub-rule matches');
+                }
+
+            case 'match-always':
+                if (invert) {
+                    return gettext('Never match');
+                } else {
+                    return gettext('Always match');
+                }
+        }
+    },
+
+    initComponent: function () {
+        let me = this;
+
+        let treeStore = Ext.create('Ext.data.TreeStore', {
+            root: {
+                expanded: true,
+                expandable: false,
+                text: '',
+                type: 'match-always',
+                data: {},
+                invert: false,
+                leaf: true,
+                children: [],
+                iconCls: 'fa fa-filter',
+            },
+        });
+
+        // The field holding the expression is hidden, so render its validation
+        // errors below the tree instead.
+        let errorLabel = Ext.create({
+            xtype: 'component',
+            hidden: true,
+            cls: 'x-form-invalid-under-default',
+        });
+
+        let getExpressionErrors = function () {
+            let errors = [];
+
+            treeStore.getRoot().cascade(function (node) {
+                let error = me.getNodeError(
+                    node.get('type'),
+                    node.get('data'),
+                    node.hasChildNodes(),
+                );
+
+                if (error && !errors.includes(error)) {
+                    errors.push(error);
+                }
+            });
+
+            return errors;
+        };
+
+        let realExpression = Ext.create({
+            xtype: 'hiddenfield',
+            name: 'expression',
+            getErrors: function () {
+                let errors = getExpressionErrors();
+
+                errorLabel.setHtml(
+                    errors.map((error) => Ext.String.htmlEncode(error)).join('<br>'),
+                );
+                errorLabel.setHidden(!errors.length);
+
+                return errors;
+            },
+            setValue: function (value) {
+                this.value = value;
+                this.checkChange();
+            },
+            getValue: function () {
+                return this.value;
+            },
+            getSubmitValue: function () {
+                let value = this.value;
+                return value;
+            },
+        });
+
+        let storeChanged = function (store) {
+            store.suspendEvent('datachanged');
+
+            store.each(function (model) {
+                let type = model.get('type');
+                let data = model.get('data');
+                let invert = model.get('invert');
+
+                let iconCls = me.getNodeIcon(type, data, invert, model.hasChildNodes());
+
+                model.set({
+                    iconCls,
+                });
+            });
+
+            let root = store.getRoot();
+            let stack = [[]];
+
+            root.cascade({
+                before: function (node) {
+                    let type = node.get('type');
+                    let data = node.get('data');
+                    let invert = node.get('invert');
+
+                    switch (type) {
+                        case 'all-of':
+                        case 'any-of':
+                        case 'one-of':
+                            stack.push([]);
+                            break;
+                        case 'match-calendar':
+                            {
+                                let obj = {
+                                    match: {
+                                        type: 'calendar',
+                                        schedule: data.value,
+                                    },
+                                };
+
+                                if (invert) {
+                                    obj = {
+                                        not: obj,
+                                    };
+                                }
+
+                                stack.at(-1).push(obj);
+                            }
+
+                            break;
+                        case 'match-field':
+                            {
+                                let obj = {
+                                    match: {
+                                        type: 'field',
+                                        field: data.field,
+                                    },
+                                };
+
+                                if (data.type === 'exact') {
+                                    obj.match.values = data.value;
+                                } else {
+                                    obj.match.regex = data.value;
+                                }
+
+                                if (invert) {
+                                    obj = {
+                                        not: obj,
+                                    };
+                                }
+
+                                stack.at(-1).push(obj);
+                            }
+                            break;
+                        case 'match-severity':
+                            {
+                                let obj = {
+                                    match: {
+                                        type: 'severity',
+                                        severities: data.value,
+                                    },
+                                };
+
+                                if (invert) {
+                                    obj = {
+                                        not: obj,
+                                    };
+                                }
+
+                                stack.at(-1).push(obj);
+                            }
+                            break;
+                        case 'match-always':
+                            stack.at(-1).push({
+                                constant: !invert,
+                            });
+                            break;
+                    }
+
+                    // Visit this node
+                    return true;
+                },
+                after: function (node) {
+                    let type = node.get('type');
+                    let invert = node.get('invert');
+
+                    switch (type) {
+                        case 'all-of':
+                        case 'any-of':
+                        case 'one-of':
+                            {
+                                let children = stack.pop();
+
+                                let a = {};
+                                a[type] = children;
+
+                                if (invert) {
+                                    a = {
+                                        not: a,
+                                    };
+                                }
+
+                                stack.at(-1).push(a);
+                            }
+                            break;
+                    }
+                },
+            });
+
+            let topLevelNodes = stack.pop();
+            let obj = topLevelNodes[0];
+
+            realExpression.suspendEvent('change');
+            realExpression.setValue(JSON.stringify(obj));
+            realExpression.resumeEvent('change');
+
+            // the expression can stay the same while the tree became (in)valid,
+            // e.g. when the last child of a group is removed
+            realExpression.validate();
+
+            store.resumeEvent('datachanged');
+        };
+
+        realExpression.addListener('change', function (field, value) {
+            let obj;
+
+            try {
+                obj = JSON.parse(value);
+            } catch (e) {
+                Ext.Msg.alert(gettext('Invalid matcher expression'), e);
+                return;
+            }
+
+            function visit(obj) {
+                for (const [key, value] of Object.entries(obj)) {
+                    let ret;
+
+                    switch (key) {
+                        case 'match':
+                            {
+                                switch (value.type) {
+                                    case 'calendar':
+                                        {
+                                            ret = {
+                                                type: 'match-calendar',
+                                                data: {
+                                                    value: value.schedule,
+                                                },
+                                                leaf: true,
+                                            };
+                                        }
+                                        break;
+                                    case 'field':
+                                        {
+                                            ret = {
+                                                type: 'match-field',
+                                                data: {
+                                                    field: value.field,
+                                                },
+                                                leaf: true,
+                                            };
+
+                                            if (value.regex) {
+                                                ret.data.type = 'regex';
+                                                ret.data.value = value.regex;
+                                            } else {
+                                                ret.data.type = 'exact';
+                                                ret.data.value = value.values;
+                                            }
+                                        }
+                                        break;
+                                    case 'severity':
+                                        {
+                                            ret = {
+                                                type: 'match-severity',
+                                                data: {
+                                                    value: value.severities,
+                                                },
+                                                leaf: true,
+                                            };
+                                        }
+                                        break;
+                                }
+                            }
+                            break;
+                        case 'all-of':
+                        case 'any-of':
+                        case 'one-of':
+                            {
+                                let children = [];
+                                for (let child of value) {
+                                    children.push(visit(child));
+                                }
+                                ret = {
+                                    type: key,
+                                    data: {},
+                                    children,
+                                    leaf: false,
+                                };
+                            }
+                            break;
+                        case 'not':
+                            {
+                                let child = visit(value);
+                                ret = {
+                                    ...child,
+                                    invert: true,
+                                };
+                            }
+                            break;
+                        case 'constant': {
+                            ret = {
+                                type: 'match-always',
+                                data: {},
+                                invert: !value,
+                                leaf: true,
+                            };
+                        }
+                    }
+
+                    let iconCls = me.getNodeIcon(
+                        ret.type,
+                        ret.data,
+                        ret.invert,
+                        !!ret.children?.length,
+                    );
+
+                    ret.iconCls = iconCls;
+                    if (!ret.leaf) {
+                        ret.expanded = true;
+                        ret.expandable = false;
+                    }
+
+                    return ret;
+                }
+            }
+
+            let rootNode = visit(obj);
+
+            treeStore.setRootNode(rootNode);
+        });
+
+        treeStore.addListener('datachanged', storeChanged);
+
+        let treePanel = Ext.create({
+            xtype: 'treepanel',
+            store: treeStore,
+            // scope for the label wrapping rules in ext6-pmx.css
+            cls: 'pmx-match-rule-tree',
+            minHeight: 400,
+            maxHeight: 400,
+            scrollable: true,
+            // wrapped labels have no predictable height, so render all rows
+            // instead of estimating the scroll range from a sample row
+            bufferedRenderer: false,
+
+            // Allow drag&drop for tree nodes
+            viewConfig: {
+                plugins: {
+                    ptype: 'treeviewdragdrop',
+                    containerScroll: true,
+                },
+            },
+
+            bind: {
+                selection: '{selectedRecord}',
+            },
+
+            columns: [
+                {
+                    xtype: 'treecolumn',
+                    dataIndex: 'text',
+                    renderer: me.renderTreeLabel,
+                    flex: 1,
+                    // rules can list many values, wrap them so that all of
+                    // them stay visible; this implies variableRowHeight
+                    cellWrap: true,
+                },
+                {
+                    xtype: 'actioncolumn',
+                    width: 60,
+                    items: [
+                        {
+                            handler: (view, rI, cI, item, e, record) => {
+                                let node = record.appendChild({
+                                    type: 'match-field',
+                                    data: {
+                                        type: 'exact',
+                                        field: '',
+                                        value: '',
+                                    },
+                                    leaf: true,
+                                });
+
+                                view.setSelection(node);
+                            },
+                            getTip: (v, m, rec) =>
+                                Ext.String.format(gettext('Add new sub-rule'), v),
+                            getClass: (v, m, { data }) => (data.leaf ? '' : 'fa fa-plus-circle'),
+                            isActionDisabled: (v, r, c, i, rec) => rec.data.leaf,
+                        },
+                        {
+                            handler: function (view, rI, cI, item, e, record) {
+                                if (record.hasChildNodes()) {
+                                    Ext.Msg.confirm(
+                                        gettext('Remove rule?'),
+                                        gettext('Remove rule and all of its sub-rules?'),
+                                        (decision) => {
+                                            if (decision === 'yes') {
+                                                record.remove(true);
+                                            }
+                                        },
+                                    );
+                                } else {
+                                    record.remove(true);
+                                }
+                            },
+                            getTip: (v, m, rec) =>
+                                Ext.String.format(gettext('Remove this rule'), v),
+                            getClass: (v, m, { data }) => 'fa fa-trash-o',
+                            isActionDisabled: (v, r, c, i, rec) => rec.isRoot(),
+                        },
+                    ],
+                },
+            ],
+        });
+
+        Ext.apply(me, {
+            items: [
+                realExpression,
+                treePanel,
+                {
+                    // nothing in the tree hints at the nodes being movable
+                    xtype: 'container',
+                    margin: '5 0 0 0',
+                    html: gettext('Rules can be reordered and moved between groups with drag and drop.'),
+                },
+                errorLabel,
+            ],
+        });
+        me.callParent();
+    },
+});
+
+Ext.define('Proxmox.panel.NotificationMatchExpressionNodePanel', {
+    extend: 'Ext.panel.Panel',
+    xtype: 'pmxNotificationMatchExpressionNodePanel',
+    mixins: ['Proxmox.Mixin.CBind'],
+    border: false,
+    layout: 'anchor',
+
+    items: [
+        {
+            xtype: 'proxmoxKVComboBox',
+            fieldLabel: gettext('Rule Type'),
+            isFormField: false,
+            allowBlank: false,
+            // Hide initially to avoid glitches when opening the window
+            hidden: true,
+            bind: {
+                value: '{nodeType}',
+                hidden: '{!showMatcherType}',
+            },
+
+            comboItems: [
+                ['match-always', gettext('Always match')],
+                ['all-of', gettext('All sub-rules match')],
+                ['any-of', gettext('At least one sub-rule matches')],
+                ['one-of', gettext('Exactly one sub-rule matches')],
+                ['match-field', gettext('Match Field')],
+                ['match-severity', gettext('Match Severity')],
+                ['match-calendar', gettext('Match Calendar')],
+            ],
+        },
+        {
+            xtype: 'proxmoxcheckbox',
+            isFormField: false,
+            fieldLabel: gettext('Invert Rule'),
+            allowBlank: false,
+            bind: {
+                value: '{invertMatch}',
+                hidden: '{!showMatcherType}',
+            },
+        },
+        {
+            xtype: 'component',
+            height: 1,
+            margin: '20 0 20 0',
+            cls: 'pmx-horizontal-separator',
+            bind: {
+                hidden: '{!showMatcherType}',
+            },
+        },
+        {
+            xtype: 'pmxNotificationMatchFieldSettings',
+            cbind: {
+                baseUrl: '{baseUrl}',
+            },
+        },
+        {
+            xtype: 'pmxNotificationMatchSeveritySettings',
+        },
+        {
+            xtype: 'pmxNotificationMatchCalendarSettings',
+        },
+    ],
+});
diff --git a/src/proxmox-dark/scss/extjs/_treepanel.scss b/src/proxmox-dark/scss/extjs/_treepanel.scss
index 0480371..bf92f0b 100644
--- a/src/proxmox-dark/scss/extjs/_treepanel.scss
+++ b/src/proxmox-dark/scss/extjs/_treepanel.scss
@@ -22,3 +22,8 @@
   background-color: $background-darker;
   border-color: $border-color;
 }
+
+.x-tree-node-text > code {
+    background-color: $background-darkest;
+}
+
diff --git a/src/proxmox-dark/scss/proxmox/_general.scss b/src/proxmox-dark/scss/proxmox/_general.scss
index dcbf049..36ff65c 100644
--- a/src/proxmox-dark/scss/proxmox/_general.scss
+++ b/src/proxmox-dark/scss/proxmox/_general.scss
@@ -51,3 +51,7 @@ div.eol-notice + div[id^="panel-"] > div[id^="panel-"][id$="-bodyWrap"] > div {
 .pmx-unclickable {
   pointer-events: none;
 }
+
+.pmx-horizontal-separator {
+    border-top: 1px solid $border-color-alt;
+}
diff --git a/src/window/NotificationMatcherEdit.js b/src/window/NotificationMatcherEdit.js
index 1999e20..893c3e3 100644
--- a/src/window/NotificationMatcherEdit.js
+++ b/src/window/NotificationMatcherEdit.js
@@ -95,6 +95,9 @@ Ext.define('Proxmox.window.NotificationMatcherEdit', {
             me.method = 'POST';
         } else {
             me.url += `/${me.name}`;
+            if (Proxmox.Schema.notificationMatcherExpressions) {
+                me.loadUrl = me.url + '?migrate-to-expression=1';
+            }
             me.method = 'PUT';
         }
 
@@ -119,7 +122,7 @@ Ext.define('Proxmox.window.NotificationMatcherEdit', {
                         {
                             name: me.name,
                             title: gettext('Match Rules'),
-                            xtype: 'pmxNotificationMatchRulesEditPanel',
+                            xtype: Proxmox.Schema.notificationMatcherPanelType(),
                             isCreate: me.isCreate,
                             baseUrl: me.baseUrl,
                         },
@@ -1013,7 +1016,7 @@ Ext.define('Proxmox.panel.MatchCalendarSettings', {
     initComponent: function () {
         let me = this;
         Ext.apply(me.viewModel, {
-            parent: me.up('pmxNotificationMatchRulesEditPanel').getViewModel(),
+            parent: me.up(Proxmox.Schema.notificationMatcherPanelType()).getViewModel(),
         });
         me.callParent();
     },
@@ -1091,7 +1094,7 @@ Ext.define('Proxmox.panel.MatchSeveritySettings', {
     initComponent: function () {
         let me = this;
         Ext.apply(me.viewModel, {
-            parent: me.up('pmxNotificationMatchRulesEditPanel').getViewModel(),
+            parent: me.up(Proxmox.Schema.notificationMatcherPanelType()).getViewModel(),
         });
         me.callParent();
     },
@@ -1164,7 +1167,7 @@ Ext.define('Proxmox.panel.MatchFieldSettings', {
                             regexVal += `(${currentData.value.join('|')})`;
                         }
                         regexVal += '$';
-                        newValue.push(regexVal);
+                        newValue = regexVal;
                     }
 
                     record.set({
@@ -1282,7 +1285,7 @@ Ext.define('Proxmox.panel.MatchFieldSettings', {
         });
 
         Ext.apply(me.viewModel, {
-            parent: me.up('pmxNotificationMatchRulesEditPanel').getViewModel(),
+            parent: me.up(Proxmox.Schema.notificationMatcherPanelType()).getViewModel(),
         });
         Ext.apply(me, {
             items: [
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 35+ messages in thread

* [PATCH proxmox-widget-toolkit v2 20/34] notifications: matcher: add better calendar editor
  2026-10-01  8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
                   ` (18 preceding siblings ...)
  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
  2026-10-01  8:36 ` [PATCH proxmox-widget-toolkit v2 21/34] notifications: matcher: consistently use title case for UI elements Lukas Wagner
                   ` (13 subsequent siblings)
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:36 UTC (permalink / raw)
  To: pbs-devel, pve-devel

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





^ permalink raw reply related	[flat|nested] 35+ messages in thread

* [PATCH proxmox-widget-toolkit v2 21/34] notifications: matcher: consistently use title case for UI elements
  2026-10-01  8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
                   ` (19 preceding siblings ...)
  2026-10-01  8:36 ` [PATCH proxmox-widget-toolkit v2 20/34] notifications: matcher: add better calendar editor Lukas Wagner
@ 2026-10-01  8:36 ` Lukas Wagner
  2026-10-01  8:36 ` [PATCH proxmox-backup v2 22/34] notification: opt into 'legacy-matchers' feature in proxmox-notify Lukas Wagner
                   ` (12 subsequent siblings)
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:36 UTC (permalink / raw)
  To: pbs-devel, pve-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 src/Utils.js                          | 2 +-
 src/window/NotificationMatcherEdit.js | 4 ++--
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/src/Utils.js b/src/Utils.js
index a2c4269..ca40d8c 100644
--- a/src/Utils.js
+++ b/src/Utils.js
@@ -728,7 +728,7 @@ Ext.define('Proxmox.Utils', {
 
         // Only add product-agnostic fields here!
         notificationFieldName: {
-            type: gettext('Notification type'),
+            type: gettext('Notification Type'),
             hostname: gettext('Hostname'),
         },
 
diff --git a/src/window/NotificationMatcherEdit.js b/src/window/NotificationMatcherEdit.js
index 7c3decf..3beb5fb 100644
--- a/src/window/NotificationMatcherEdit.js
+++ b/src/window/NotificationMatcherEdit.js
@@ -128,7 +128,7 @@ Ext.define('Proxmox.window.NotificationMatcherEdit', {
                         },
                         {
                             name: me.name,
-                            title: gettext('Targets to notify'),
+                            title: gettext('Targets to Notify'),
                             xtype: 'pmxNotificationMatcherTargetPanel',
                             isCreate: me.isCreate,
                             baseUrl: me.baseUrl,
@@ -1381,7 +1381,7 @@ Ext.define('Proxmox.panel.MatchSeveritySettings', {
     items: [
         {
             xtype: 'proxmoxKVComboBox',
-            fieldLabel: gettext('Severities to match'),
+            fieldLabel: gettext('Severities to Match'),
             isFormField: false,
             allowBlank: true,
             multiSelect: true,
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 35+ messages in thread

* [PATCH proxmox-backup v2 22/34] notification: opt into 'legacy-matchers' feature in proxmox-notify
  2026-10-01  8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
                   ` (20 preceding siblings ...)
  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 ` 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
                   ` (11 subsequent siblings)
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:36 UTC (permalink / raw)
  To: pbs-devel, pve-devel

This allows us to continue to support the older 'match-*', 'mode' and
'invert-match' configuration keys in notifications.cfg.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 Cargo.toml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/Cargo.toml b/Cargo.toml
index 9443f9163..d79f29f3a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -235,7 +235,7 @@ proxmox-ldap.workspace = true
 proxmox-metrics.workspace = true
 proxmox-network-api = { workspace = true, features = [ "impl" ] }
 proxmox-network-types.workspace = true
-proxmox-notify = { workspace = true, features = [ "pbs-context" ] }
+proxmox-notify = { workspace = true, features = [ "pbs-context", "legacy-matchers" ] }
 proxmox-openid.workspace = true
 proxmox-product-config.workspace = true
 proxmox-parallel-handler.workspace = true
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 35+ messages in thread

* [PATCH proxmox-backup v2 23/34] api: notification: add 'migrate-to-expression' parameter to get_matcher
  2026-10-01  8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
                   ` (21 preceding siblings ...)
  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 ` Lukas Wagner
  2026-10-01  8:36 ` [PATCH proxmox-backup v2 24/34] ui: notification: enable new matcher UI Lukas Wagner
                   ` (10 subsequent siblings)
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:36 UTC (permalink / raw)
  To: pbs-devel, pve-devel

If set, we return the matcher with any occurrences of the 'old' match-*,
invert-match and mode keys to an equivalent expression.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 src/api2/config/notifications/matchers.rs | 18 ++++++++++++++++--
 1 file changed, 16 insertions(+), 2 deletions(-)

diff --git a/src/api2/config/notifications/matchers.rs b/src/api2/config/notifications/matchers.rs
index fba1859cf..504d26d40 100644
--- a/src/api2/config/notifications/matchers.rs
+++ b/src/api2/config/notifications/matchers.rs
@@ -40,6 +40,11 @@ pub fn list_matchers(
         properties: {
             name: {
                 schema: ENTITY_NAME_SCHEMA,
+            },
+            "migrate-to-expression": {
+                description: "Automatically translate existing 'match-*', 'invert-match' and 'mode' to an equivalent matching 'expression'",
+                optional: true,
+                default: false,
             }
         },
     },
@@ -49,9 +54,18 @@ pub fn list_matchers(
     },
 )]
 /// Get a notification matcher.
-pub fn get_matcher(name: String, rpcenv: &mut dyn RpcEnvironment) -> Result<MatcherConfig, Error> {
+pub fn get_matcher(
+    name: String,
+    migrate_to_expression: bool,
+    rpcenv: &mut dyn RpcEnvironment,
+) -> Result<MatcherConfig, Error> {
     let config = pbs_config::notifications::config()?;
-    let matcher = proxmox_notify::api::matcher::get_matcher(&config, &name)?;
+
+    let matcher = if migrate_to_expression {
+        proxmox_notify::api::matcher::get_matcher_as_expression(&config, &name)?
+    } else {
+        proxmox_notify::api::matcher::get_matcher(&config, &name)?
+    };
 
     rpcenv["digest"] = hex::encode(config.digest()).into();
 
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 35+ messages in thread

* [PATCH proxmox-backup v2 24/34] ui: notification: enable new matcher UI
  2026-10-01  8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
                   ` (22 preceding siblings ...)
  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 ` 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
                   ` (9 subsequent siblings)
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:36 UTC (permalink / raw)
  To: pbs-devel, pve-devel

By setting this feature flag, we enable the new improved match rule edit
window. The feature flag allows us to bump the widget toolkit
independently of the rest rest.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 www/Utils.js | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/www/Utils.js b/www/Utils.js
index d6bfd459e..6d027e377 100644
--- a/www/Utils.js
+++ b/www/Utils.js
@@ -533,6 +533,9 @@ Ext.define('PBS.Utils', {
                 iconCls: 'fa-bell-o',
             },
         };
+
+        // Opt into the new matcher expressions
+        Proxmox.Schema.notificationMatcherExpressions = true;
     },
 
     // Convert an ArrayBuffer to a base64url encoded string.
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 35+ messages in thread

* [PATCH proxmox-perl-rs v2 25/34] notify: move proxmox-notify bindings from common to pve-rs
  2026-10-01  8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
                   ` (23 preceding siblings ...)
  2026-10-01  8:36 ` [PATCH proxmox-backup v2 24/34] ui: notification: enable new matcher UI Lukas Wagner
@ 2026-10-01  8:36 ` Lukas Wagner
  2026-10-01  8:36 ` [PATCH proxmox-perl-rs v2 26/34] notify: matcher: pass matcher config / updater directly Lukas Wagner
                   ` (8 subsequent siblings)
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:36 UTC (permalink / raw)
  To: pbs-devel, pve-devel

PMG neither uses the notification stack, nor is it planned in the near
future. Shipping the bindings in 'common' is awkward, since it then
requires us to use the same feature flags in proxmox-notify for both,
PVE and PMG.

This reduces the number of dependencies for pmg-rs from 297 to 269, all
of the removed ones were unused before.

The module prefix changes from Proxmox::RS::Notify to PVE::RS::Notify,
which is a breaking change that requires changes in the callers.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 common/pkg/Makefile                       | 1 -
 common/src/bindings/mod.rs                | 3 ---
 pmg-rs/Cargo.toml                         | 1 -
 pve-rs/Makefile                           | 1 +
 pve-rs/src/bindings/mod.rs                | 3 +++
 {common => pve-rs}/src/bindings/notify.rs | 8 ++++----
 6 files changed, 8 insertions(+), 9 deletions(-)
 rename {common => pve-rs}/src/bindings/notify.rs (99%)

diff --git a/common/pkg/Makefile b/common/pkg/Makefile
index 5a537f9..1fce22f 100644
--- a/common/pkg/Makefile
+++ b/common/pkg/Makefile
@@ -23,7 +23,6 @@ PERLMOD_GENPACKAGE := /usr/lib/perlmod/genpackage.pl \
 PERLMOD_PACKAGES := \
 	  Proxmox::RS::APT::Repositories \
 	  Proxmox::RS::CalendarEvent \
-	  Proxmox::RS::Notify \
 	  Proxmox::RS::OIDC \
 	  Proxmox::RS::SharedCache \
 	  Proxmox::RS::Subscription
diff --git a/common/src/bindings/mod.rs b/common/src/bindings/mod.rs
index e0d1a3b..f122b9a 100644
--- a/common/src/bindings/mod.rs
+++ b/common/src/bindings/mod.rs
@@ -8,9 +8,6 @@ pub use apt_repositories::proxmox_rs_apt_repositories;
 mod calendar_event;
 pub use calendar_event::proxmox_rs_calendar_event;
 
-mod notify;
-pub use notify::proxmox_rs_notify;
-
 mod oidc;
 pub use oidc::proxmox_rs_oidc;
 
diff --git a/pmg-rs/Cargo.toml b/pmg-rs/Cargo.toml
index 23be254..f88a204 100644
--- a/pmg-rs/Cargo.toml
+++ b/pmg-rs/Cargo.toml
@@ -35,7 +35,6 @@ proxmox-config-digest = "1"
 proxmox-http = { version = "1", features = ["client-sync", "client-trait"] }
 proxmox-http-error = "1"
 proxmox-log = "1"
-proxmox-notify = "1"
 proxmox-openid =  "1"
 proxmox-shared-cache = "1"
 proxmox-subscription = "1.0.2"
diff --git a/pve-rs/Makefile b/pve-rs/Makefile
index bb1cd2d..4c183c8 100644
--- a/pve-rs/Makefile
+++ b/pve-rs/Makefile
@@ -27,6 +27,7 @@ PERLMOD_GENPACKAGE := /usr/lib/perlmod/genpackage.pl \
 
 PERLMOD_PACKAGES := \
 	  PVE::RS::Firewall::SDN \
+	  PVE::RS::Notify \
 	  PVE::RS::NVML \
 	  PVE::RS::OCI \
 	  PVE::RS::OpenId \
diff --git a/pve-rs/src/bindings/mod.rs b/pve-rs/src/bindings/mod.rs
index f922982..143a62b 100644
--- a/pve-rs/src/bindings/mod.rs
+++ b/pve-rs/src/bindings/mod.rs
@@ -19,6 +19,9 @@ pub mod firewall;
 mod sdn;
 pub use sdn::fabrics::pve_rs_sdn_fabrics;
 
+mod notify;
+pub use notify::pve_rs_notify;
+
 #[allow(unused_imports)]
 pub use crate::common::bindings::*;
 
diff --git a/common/src/bindings/notify.rs b/pve-rs/src/bindings/notify.rs
similarity index 99%
rename from common/src/bindings/notify.rs
rename to pve-rs/src/bindings/notify.rs
index 409270a..ba713e3 100644
--- a/common/src/bindings/notify.rs
+++ b/pve-rs/src/bindings/notify.rs
@@ -1,6 +1,6 @@
-#[perlmod::package(name = "Proxmox::RS::Notify")]
-pub mod proxmox_rs_notify {
-    //! The `Proxmox::RS::Notify` package.
+#[perlmod::package(name = "PVE::RS::Notify")]
+pub mod pve_rs_notify {
+    //! The `PVE::RS::Notify` package.
     //!
     //! This implements the new notification API and support code.
     //!
@@ -45,7 +45,7 @@ pub mod proxmox_rs_notify {
         config: Mutex<Config>,
     }
 
-    perlmod::declare_magic!(Box<NotificationConfig> : &NotificationConfig as "Proxmox::RS::Notify");
+    perlmod::declare_magic!(Box<NotificationConfig> : &NotificationConfig as "PVE::RS::Notify");
 
     /// Method: Support `dclone` so this can be put into the `ccache` of `PVE::Cluster`.
     #[export(name = "STORABLE_freeze", raw_return)]
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 35+ messages in thread

* [PATCH proxmox-perl-rs v2 26/34] notify: matcher: pass matcher config / updater directly
  2026-10-01  8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
                   ` (24 preceding siblings ...)
  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 ` 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
                   ` (7 subsequent siblings)
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:36 UTC (permalink / raw)
  To: pbs-devel, pve-devel

Instead of enumerating all config properties individually as paramters,
we now pass the config struct or updater as a parameter. This allows us
to simplify the callers and also we don't have to change the binding
code if new parameters are added -- a simple rebuild against
proxmox-notify suffices.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 pve-rs/src/bindings/notify.rs | 200 +++++-----------------------------
 1 file changed, 26 insertions(+), 174 deletions(-)

diff --git a/pve-rs/src/bindings/notify.rs b/pve-rs/src/bindings/notify.rs
index ba713e3..820b8c0 100644
--- a/pve-rs/src/bindings/notify.rs
+++ b/pve-rs/src/bindings/notify.rs
@@ -12,7 +12,7 @@ pub mod pve_rs_notify {
     use std::collections::HashMap;
     use std::sync::Mutex;
 
-    use anyhow::{Error, bail};
+    use anyhow::{bail, Error};
     use serde_json::Value as JSONValue;
 
     use perlmod::Value;
@@ -26,17 +26,14 @@ pub mod pve_rs_notify {
         DeleteableSendmailProperty, SendmailConfig, SendmailConfigUpdater,
     };
     use proxmox_notify::endpoints::smtp::{
-        DeleteableSmtpProperty, SmtpConfig, SmtpConfigUpdater, SmtpMode, SmtpPrivateConfig,
+        DeleteableSmtpProperty, SmtpConfig, SmtpConfigUpdater, SmtpPrivateConfig,
         SmtpPrivateConfigUpdater,
     };
     use proxmox_notify::endpoints::webhook::{
         DeleteableWebhookProperty, WebhookConfig, WebhookConfigUpdater,
     };
-    use proxmox_notify::matcher::{
-        CalendarMatcher, DeleteableMatcherProperty, FieldMatcher, MatchModeOperator, MatcherConfig,
-        MatcherConfigUpdater, SeverityMatcher,
-    };
-    use proxmox_notify::{Config, Notification, Severity, api};
+    use proxmox_notify::matcher::{DeleteableMatcherProperty, MatcherConfig, MatcherConfigUpdater};
+    use proxmox_notify::{api, Config, Notification, Severity};
 
     /// A notification catalog instance.
     ///
@@ -189,49 +186,23 @@ pub mod pve_rs_notify {
     ///
     /// See [`api::sendmail::add_endpoint`].
     #[export(serialize_error)]
-    #[allow(clippy::too_many_arguments)]
     pub fn add_sendmail_endpoint(
         #[try_from_ref] this: &NotificationConfig,
-        name: String,
-        mailto: Option<Vec<String>>,
-        mailto_user: Option<Vec<String>>,
-        from_address: Option<String>,
-        author: Option<String>,
-        comment: Option<String>,
-        disable: Option<bool>,
+        sendmail_config: SendmailConfig,
     ) -> Result<(), HttpError> {
         let mut config = this.config.lock().unwrap();
 
-        api::sendmail::add_endpoint(
-            &mut config,
-            SendmailConfig {
-                name,
-                mailto: mailto.unwrap_or_default(),
-                mailto_user: mailto_user.unwrap_or_default(),
-                from_address,
-                author,
-                comment,
-                disable,
-                filter: None,
-                origin: None,
-            },
-        )
+        api::sendmail::add_endpoint(&mut config, sendmail_config)
     }
 
     /// Method: Update a sendmail endpoint.
     ///
     /// See [`api::sendmail::update_endpoint`].
     #[export(serialize_error)]
-    #[allow(clippy::too_many_arguments)]
     pub fn update_sendmail_endpoint(
         #[try_from_ref] this: &NotificationConfig,
         name: &str,
-        mailto: Option<Vec<String>>,
-        mailto_user: Option<Vec<String>>,
-        from_address: Option<String>,
-        author: Option<String>,
-        comment: Option<String>,
-        disable: Option<bool>,
+        updater: SendmailConfigUpdater,
         delete: Option<Vec<DeleteableSendmailProperty>>,
         digest: Option<&str>,
     ) -> Result<(), HttpError> {
@@ -241,14 +212,7 @@ pub mod pve_rs_notify {
         api::sendmail::update_endpoint(
             &mut config,
             name,
-            SendmailConfigUpdater {
-                mailto,
-                mailto_user,
-                from_address,
-                author,
-                comment,
-                disable,
-            },
+            updater,
             delete.as_deref(),
             digest.as_deref(),
         )
@@ -295,39 +259,22 @@ pub mod pve_rs_notify {
     #[export(serialize_error)]
     pub fn add_gotify_endpoint(
         #[try_from_ref] this: &NotificationConfig,
-        name: String,
-        server: String,
-        token: String,
-        comment: Option<String>,
-        disable: Option<bool>,
+        gotify_config: GotifyConfig,
+        gotify_private_config: GotifyPrivateConfig,
     ) -> Result<(), HttpError> {
         let mut config = this.config.lock().unwrap();
-        api::gotify::add_endpoint(
-            &mut config,
-            GotifyConfig {
-                name: name.clone(),
-                server,
-                comment,
-                disable,
-                filter: None,
-                origin: None,
-            },
-            GotifyPrivateConfig { name, token },
-        )
+        api::gotify::add_endpoint(&mut config, gotify_config, gotify_private_config)
     }
 
     /// Method: Update a 'gotify' endpoint.
     ///
     /// See [`api::gotify::update_endpoint`].
     #[export(serialize_error)]
-    #[allow(clippy::too_many_arguments)]
     pub fn update_gotify_endpoint(
         #[try_from_ref] this: &NotificationConfig,
         name: &str,
-        server: Option<String>,
-        token: Option<String>,
-        comment: Option<String>,
-        disable: Option<bool>,
+        updater: GotifyConfigUpdater,
+        private_updater: GotifyPrivateConfigUpdater,
         delete: Option<Vec<DeleteableGotifyProperty>>,
         digest: Option<&str>,
     ) -> Result<(), HttpError> {
@@ -337,12 +284,8 @@ pub mod pve_rs_notify {
         api::gotify::update_endpoint(
             &mut config,
             name,
-            GotifyConfigUpdater {
-                server,
-                comment,
-                disable,
-            },
-            GotifyPrivateConfigUpdater { token },
+            updater,
+            private_updater,
             delete.as_deref(),
             digest.as_deref(),
         )
@@ -387,62 +330,24 @@ pub mod pve_rs_notify {
     ///
     /// See [`api::smtp::add_endpoint`].
     #[export(serialize_error)]
-    #[allow(clippy::too_many_arguments)]
     pub fn add_smtp_endpoint(
         #[try_from_ref] this: &NotificationConfig,
-        name: String,
-        server: String,
-        port: Option<u16>,
-        mode: Option<SmtpMode>,
-        username: Option<String>,
-        password: Option<String>,
-        mailto: Option<Vec<String>>,
-        mailto_user: Option<Vec<String>>,
-        from_address: String,
-        author: Option<String>,
-        comment: Option<String>,
-        disable: Option<bool>,
+        smtp_config: SmtpConfig,
+        smtp_private_config: SmtpPrivateConfig,
     ) -> Result<(), HttpError> {
         let mut config = this.config.lock().unwrap();
-        api::smtp::add_endpoint(
-            &mut config,
-            SmtpConfig {
-                name: name.clone(),
-                server,
-                port,
-                mode,
-                username,
-                mailto: mailto.unwrap_or_default(),
-                mailto_user: mailto_user.unwrap_or_default(),
-                from_address,
-                author,
-                comment,
-                disable,
-                origin: None,
-            },
-            SmtpPrivateConfig { name, password },
-        )
+        api::smtp::add_endpoint(&mut config, smtp_config, smtp_private_config)
     }
 
     /// Method: Update an SMTP endpoint.
     ///
     /// See [`api::smtp::update_endpoint`].
     #[export(serialize_error)]
-    #[allow(clippy::too_many_arguments)]
     pub fn update_smtp_endpoint(
         #[try_from_ref] this: &NotificationConfig,
         name: &str,
-        server: Option<String>,
-        port: Option<u16>,
-        mode: Option<SmtpMode>,
-        username: Option<String>,
-        password: Option<String>,
-        mailto: Option<Vec<String>>,
-        mailto_user: Option<Vec<String>>,
-        from_address: Option<String>,
-        author: Option<String>,
-        comment: Option<String>,
-        disable: Option<bool>,
+        updater: SmtpConfigUpdater,
+        private_updater: SmtpPrivateConfigUpdater,
         delete: Option<Vec<DeleteableSmtpProperty>>,
         digest: Option<&str>,
     ) -> Result<(), HttpError> {
@@ -452,19 +357,8 @@ pub mod pve_rs_notify {
         api::smtp::update_endpoint(
             &mut config,
             name,
-            SmtpConfigUpdater {
-                server,
-                port,
-                mode,
-                username,
-                mailto,
-                mailto_user,
-                from_address,
-                author,
-                comment,
-                disable,
-            },
-            SmtpPrivateConfigUpdater { password },
+            updater,
+            private_updater,
             delete.as_deref(),
             digest.as_deref(),
         )
@@ -509,7 +403,6 @@ pub mod pve_rs_notify {
     ///
     /// See [`api::webhook::add_endpoint`].
     #[export(serialize_error)]
-    #[allow(clippy::too_many_arguments)]
     pub fn add_webhook_endpoint(
         #[try_from_ref] this: &NotificationConfig,
         endpoint_config: WebhookConfig,
@@ -522,7 +415,6 @@ pub mod pve_rs_notify {
     ///
     /// See [`api::webhook::update_endpoint`].
     #[export(serialize_error)]
-    #[allow(clippy::too_many_arguments)]
     pub fn update_webhook_endpoint(
         #[try_from_ref] this: &NotificationConfig,
         name: &str,
@@ -581,53 +473,22 @@ pub mod pve_rs_notify {
     ///
     /// See [`api::matcher::add_matcher`].
     #[export(serialize_error)]
-    #[allow(clippy::too_many_arguments)]
     pub fn add_matcher(
         #[try_from_ref] this: &NotificationConfig,
-        name: String,
-        target: Option<Vec<String>>,
-        match_severity: Option<Vec<SeverityMatcher>>,
-        match_field: Option<Vec<FieldMatcher>>,
-        match_calendar: Option<Vec<CalendarMatcher>>,
-        mode: Option<MatchModeOperator>,
-        invert_match: Option<bool>,
-        comment: Option<String>,
-        disable: Option<bool>,
+        matcher_config: MatcherConfig,
     ) -> Result<(), HttpError> {
         let mut config = this.config.lock().unwrap();
-        api::matcher::add_matcher(
-            &mut config,
-            MatcherConfig {
-                name,
-                match_severity: match_severity.unwrap_or_default(),
-                match_field: match_field.unwrap_or_default(),
-                match_calendar: match_calendar.unwrap_or_default(),
-                target: target.unwrap_or_default(),
-                mode,
-                invert_match,
-                comment,
-                disable,
-                origin: None,
-            },
-        )
+        api::matcher::add_matcher(&mut config, matcher_config)
     }
 
     /// Method: Update a matcher.
     ///
     /// See [`api::matcher::update_matcher`].
     #[export(serialize_error)]
-    #[allow(clippy::too_many_arguments)]
     pub fn update_matcher(
         #[try_from_ref] this: &NotificationConfig,
         name: &str,
-        target: Option<Vec<String>>,
-        match_severity: Option<Vec<SeverityMatcher>>,
-        match_field: Option<Vec<FieldMatcher>>,
-        match_calendar: Option<Vec<CalendarMatcher>>,
-        mode: Option<MatchModeOperator>,
-        invert_match: Option<bool>,
-        comment: Option<String>,
-        disable: Option<bool>,
+        updater: MatcherConfigUpdater,
         delete: Option<Vec<DeleteableMatcherProperty>>,
         digest: Option<&str>,
     ) -> Result<(), HttpError> {
@@ -637,16 +498,7 @@ pub mod pve_rs_notify {
         api::matcher::update_matcher(
             &mut config,
             name,
-            MatcherConfigUpdater {
-                match_severity,
-                match_field,
-                match_calendar,
-                target,
-                mode,
-                invert_match,
-                comment,
-                disable,
-            },
+            updater,
             delete.as_deref(),
             digest.as_deref(),
         )
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 35+ messages in thread

* [PATCH proxmox-perl-rs v2 27/34] notify: opt into 'legacy-matchers' feature in proxmox-notify
  2026-10-01  8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
                   ` (25 preceding siblings ...)
  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 ` 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
                   ` (6 subsequent siblings)
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:36 UTC (permalink / raw)
  To: pbs-devel, pve-devel

This allows us to continue to support the older 'match-*', 'mode' and
'invert-match' configuration keys in notifications.cfg.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 pve-rs/Cargo.toml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/pve-rs/Cargo.toml b/pve-rs/Cargo.toml
index 5ae9082..ab95f29 100644
--- a/pve-rs/Cargo.toml
+++ b/pve-rs/Cargo.toml
@@ -39,7 +39,7 @@ proxmox-http = { version = "1.0.2", features = ["client-sync", "client-trait"] }
 proxmox-http-error = "1"
 proxmox-log = "1"
 proxmox-network-types = "1.1.2"
-proxmox-notify = { version = "1", features = ["pve-context"] }
+proxmox-notify = { version = "1", features = ["pve-context", "legacy-matchers"] }
 proxmox-oci = "0.2.1"
 proxmox-openid = "1.0.2"
 proxmox-resource-scheduling = "2"
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 35+ messages in thread

* [PATCH proxmox-perl-rs v2 28/34] notify: add 'migrate_to_expression' parameter for get_matcher
  2026-10-01  8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
                   ` (26 preceding siblings ...)
  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 ` 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
                   ` (5 subsequent siblings)
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:37 UTC (permalink / raw)
  To: pbs-devel, pve-devel

If set, we return the matcher with any occurrences of the 'old' match-*,
invert-match and mode keys to an equivalent expression.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 pve-rs/src/bindings/notify.rs | 12 +++++++++---
 1 file changed, 9 insertions(+), 3 deletions(-)

diff --git a/pve-rs/src/bindings/notify.rs b/pve-rs/src/bindings/notify.rs
index 820b8c0..da3c056 100644
--- a/pve-rs/src/bindings/notify.rs
+++ b/pve-rs/src/bindings/notify.rs
@@ -12,7 +12,7 @@ pub mod pve_rs_notify {
     use std::collections::HashMap;
     use std::sync::Mutex;
 
-    use anyhow::{bail, Error};
+    use anyhow::{Error, bail};
     use serde_json::Value as JSONValue;
 
     use perlmod::Value;
@@ -33,7 +33,7 @@ pub mod pve_rs_notify {
         DeleteableWebhookProperty, WebhookConfig, WebhookConfigUpdater,
     };
     use proxmox_notify::matcher::{DeleteableMatcherProperty, MatcherConfig, MatcherConfigUpdater};
-    use proxmox_notify::{api, Config, Notification, Severity};
+    use proxmox_notify::{Config, Notification, Severity, api};
 
     /// A notification catalog instance.
     ///
@@ -464,9 +464,15 @@ pub mod pve_rs_notify {
     pub fn get_matcher(
         #[try_from_ref] this: &NotificationConfig,
         id: &str,
+        migrate_to_expression: bool,
     ) -> Result<MatcherConfig, HttpError> {
         let config = this.config.lock().unwrap();
-        api::matcher::get_matcher(&config, id)
+
+        if migrate_to_expression {
+            api::matcher::get_matcher_as_expression(&config, id)
+        } else {
+            api::matcher::get_matcher(&config, id)
+        }
     }
 
     /// Method: Add a matcher.
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 35+ messages in thread

* [PATCH pve-cluster v2 29/34] notify: adapt to changed module prefix for PVE::RS::Notify
  2026-10-01  8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
                   ` (27 preceding siblings ...)
  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 ` Lukas Wagner
  2026-10-01  8:37 ` [PATCH manager v2 30/34] vzdump: " Lukas Wagner
                   ` (4 subsequent siblings)
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:37 UTC (permalink / raw)
  To: pbs-devel, pve-devel

The binding code was moved from the 'common' module (which is shared
with PMG) to pve-rs itself, which caused the module prefix to change.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 src/PVE/Notify.pm | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/PVE/Notify.pm b/src/PVE/Notify.pm
index b44b5b3..89be6bc 100644
--- a/src/PVE/Notify.pm
+++ b/src/PVE/Notify.pm
@@ -7,7 +7,7 @@ use PVE::Cluster qw(cfs_register_file cfs_read_file cfs_lock_file cfs_write_file
 use PVE::INotify;
 use PVE::Tools;
 
-use Proxmox::RS::Notify;
+use PVE::RS::Notify;
 
 cfs_register_file(
     'notifications.cfg', \&parse_notification_config, \&write_notification_config,
@@ -49,7 +49,7 @@ sub read_config {
     my $config = cfs_read_file('notifications.cfg');
     my $priv_config = cfs_read_file('priv/notifications.cfg');
 
-    my $notification_config = Proxmox::RS::Notify->parse_config($config, $priv_config);
+    my $notification_config = PVE::RS::Notify->parse_config($config, $priv_config);
 
     return $notification_config;
 }
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 35+ messages in thread

* [PATCH manager v2 30/34] vzdump: adapt to changed module prefix for PVE::RS::Notify
  2026-10-01  8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
                   ` (28 preceding siblings ...)
  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 ` Lukas Wagner
  2026-10-01  8:37 ` [PATCH manager v2 31/34] api: notification: pass config/updater directly to rust bindings Lukas Wagner
                   ` (3 subsequent siblings)
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:37 UTC (permalink / raw)
  To: pbs-devel, pve-devel

The binding code was moved from the 'common' module (which is shared
with PMG) to pve-rs itself, which caused the module prefix to change.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 PVE/VZDump.pm | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/PVE/VZDump.pm b/PVE/VZDump.pm
index defe9e0a..11c5089f 100644
--- a/PVE/VZDump.pm
+++ b/PVE/VZDump.pm
@@ -545,7 +545,7 @@ sub send_notification {
         if ($email_configured && ($policy eq "always" || ($policy eq "failure" && $failed))) {
             # Start out with an empty config. Might still contain
             # built-ins, so we need to disable/remove them.
-            my $notification_config = Proxmox::RS::Notify->parse_config('', '');
+            my $notification_config = PVE::RS::Notify->parse_config('', '');
 
             # Remove built-in matchers, since we only want to send an
             # email to the specified recipients and nobody else.
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 35+ messages in thread

* [PATCH manager v2 31/34] api: notification: pass config/updater directly to rust bindings
  2026-10-01  8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
                   ` (29 preceding siblings ...)
  2026-10-01  8:37 ` [PATCH manager v2 30/34] vzdump: " Lukas Wagner
@ 2026-10-01  8:37 ` Lukas Wagner
  2026-10-01  8:37 ` [PATCH manager v2 32/34] api: notification: get_matcher: add 'migrate-to-expression' parameter Lukas Wagner
                   ` (2 subsequent siblings)
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:37 UTC (permalink / raw)
  To: pbs-devel, pve-devel

Instead of having to enumerate function parameters individually, pass
the entire parameter hash to the rust bindings. This reduces potential
for future errors and reduces churn when adding new parameters.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 PVE/API2/Cluster/Notifications.pm | 147 +++++-------------------------
 PVE/VZDump.pm                     |  19 ++--
 2 files changed, 33 insertions(+), 133 deletions(-)

diff --git a/PVE/API2/Cluster/Notifications.pm b/PVE/API2/Cluster/Notifications.pm
index 8b455227..3f99de08 100644
--- a/PVE/API2/Cluster/Notifications.pm
+++ b/PVE/API2/Cluster/Notifications.pm
@@ -518,21 +518,11 @@ __PACKAGE__->register_method({
     code => sub {
         my ($param) = @_;
 
-        my $name = extract_param($param, 'name');
-        my $mailto = extract_param($param, 'mailto');
-        my $mailto_user = extract_param($param, 'mailto-user');
-        my $from_address = extract_param($param, 'from-address');
-        my $author = extract_param($param, 'author');
-        my $comment = extract_param($param, 'comment');
-        my $disable = extract_param($param, 'disable');
-
         eval {
             PVE::Notify::lock_config(sub {
                 my $config = PVE::Notify::read_config();
 
-                $config->add_sendmail_endpoint(
-                    $name, $mailto, $mailto_user, $from_address, $author, $comment, $disable,
-                );
+                $config->add_sendmail_endpoint($param);
 
                 PVE::Notify::write_config($config);
             });
@@ -582,13 +572,6 @@ __PACKAGE__->register_method({
         my ($param) = @_;
 
         my $name = extract_param($param, 'name');
-        my $mailto = extract_param($param, 'mailto');
-        my $mailto_user = extract_param($param, 'mailto-user');
-        my $from_address = extract_param($param, 'from-address');
-        my $author = extract_param($param, 'author');
-        my $comment = extract_param($param, 'comment');
-        my $disable = extract_param($param, 'disable');
-
         my $delete = extract_param($param, 'delete');
         my $digest = extract_param($param, 'digest');
 
@@ -597,15 +580,7 @@ __PACKAGE__->register_method({
                 my $config = PVE::Notify::read_config();
 
                 $config->update_sendmail_endpoint(
-                    $name,
-                    $mailto,
-                    $mailto_user,
-                    $from_address,
-                    $author,
-                    $comment,
-                    $disable,
-                    $delete,
-                    $digest,
+                    $name, $param, $delete, $digest,
                 );
 
                 PVE::Notify::write_config($config);
@@ -789,18 +764,19 @@ __PACKAGE__->register_method({
     code => sub {
         my ($param) = @_;
 
-        my $name = extract_param($param, 'name');
-        my $server = extract_param($param, 'server');
         my $token = extract_param($param, 'token');
-        my $comment = extract_param($param, 'comment');
-        my $disable = extract_param($param, 'disable');
+        my $name = $param->{name};
 
         eval {
             PVE::Notify::lock_config(sub {
                 my $config = PVE::Notify::read_config();
 
                 $config->add_gotify_endpoint(
-                    $name, $server, $token, $comment, $disable,
+                    $param,
+                    {
+                        'name' => $name,
+                        'token' => $token,
+                    },
                 );
 
                 PVE::Notify::write_config($config);
@@ -850,10 +826,7 @@ __PACKAGE__->register_method({
         my ($param) = @_;
 
         my $name = extract_param($param, 'name');
-        my $server = extract_param($param, 'server');
         my $token = extract_param($param, 'token');
-        my $comment = extract_param($param, 'comment');
-        my $disable = extract_param($param, 'disable');
 
         my $delete = extract_param($param, 'delete');
         my $digest = extract_param($param, 'digest');
@@ -864,10 +837,10 @@ __PACKAGE__->register_method({
 
                 $config->update_gotify_endpoint(
                     $name,
-                    $server,
-                    $token,
-                    $comment,
-                    $disable,
+                    $param,
+                    {
+                        token => $token,
+                    },
                     $delete,
                     $digest,
                 );
@@ -1102,36 +1075,19 @@ __PACKAGE__->register_method({
     code => sub {
         my ($param) = @_;
 
-        my $name = extract_param($param, 'name');
-        my $server = extract_param($param, 'server');
-        my $port = extract_param($param, 'port');
-        my $mode = extract_param($param, 'mode');
-        my $username = extract_param($param, 'username');
+        my $name = $param->{name};
         my $password = extract_param($param, 'password');
-        my $mailto = extract_param($param, 'mailto');
-        my $mailto_user = extract_param($param, 'mailto-user');
-        my $from_address = extract_param($param, 'from-address');
-        my $author = extract_param($param, 'author');
-        my $comment = extract_param($param, 'comment');
-        my $disable = extract_param($param, 'disable');
 
         eval {
             PVE::Notify::lock_config(sub {
                 my $config = PVE::Notify::read_config();
 
                 $config->add_smtp_endpoint(
-                    $name,
-                    $server,
-                    $port,
-                    $mode,
-                    $username,
-                    $password,
-                    $mailto,
-                    $mailto_user,
-                    $from_address,
-                    $author,
-                    $comment,
-                    $disable,
+                    $param,
+                    {
+                        name => $name,
+                        password => $password,
+                    },
                 );
 
                 PVE::Notify::write_config($config);
@@ -1182,17 +1138,7 @@ __PACKAGE__->register_method({
         my ($param) = @_;
 
         my $name = extract_param($param, 'name');
-        my $server = extract_param($param, 'server');
-        my $port = extract_param($param, 'port');
-        my $mode = extract_param($param, 'mode');
-        my $username = extract_param($param, 'username');
         my $password = extract_param($param, 'password');
-        my $mailto = extract_param($param, 'mailto');
-        my $mailto_user = extract_param($param, 'mailto-user');
-        my $from_address = extract_param($param, 'from-address');
-        my $author = extract_param($param, 'author');
-        my $comment = extract_param($param, 'comment');
-        my $disable = extract_param($param, 'disable');
 
         my $delete = extract_param($param, 'delete');
         my $digest = extract_param($param, 'digest');
@@ -1203,17 +1149,10 @@ __PACKAGE__->register_method({
 
                 $config->update_smtp_endpoint(
                     $name,
-                    $server,
-                    $port,
-                    $mode,
-                    $username,
-                    $password,
-                    $mailto,
-                    $mailto_user,
-                    $from_address,
-                    $author,
-                    $comment,
-                    $disable,
+                    $param,
+                    {
+                        password => $password,
+                    },
                     $delete,
                     $digest,
                 );
@@ -1705,31 +1644,11 @@ __PACKAGE__->register_method({
     code => sub {
         my ($param) = @_;
 
-        my $name = extract_param($param, 'name');
-        my $match_severity = extract_param($param, 'match-severity');
-        my $match_field = extract_param($param, 'match-field');
-        my $match_calendar = extract_param($param, 'match-calendar');
-        my $target = extract_param($param, 'target');
-        my $mode = extract_param($param, 'mode');
-        my $invert_match = extract_param($param, 'invert-match');
-        my $comment = extract_param($param, 'comment');
-        my $disable = extract_param($param, 'disable');
-
         eval {
             PVE::Notify::lock_config(sub {
                 my $config = PVE::Notify::read_config();
 
-                $config->add_matcher(
-                    $name,
-                    $target,
-                    $match_severity,
-                    $match_field,
-                    $match_calendar,
-                    $mode,
-                    $invert_match,
-                    $comment,
-                    $disable,
-                );
+                $config->add_matcher($param);
 
                 PVE::Notify::write_config($config);
             });
@@ -1770,14 +1689,6 @@ __PACKAGE__->register_method({
         my ($param) = @_;
 
         my $name = extract_param($param, 'name');
-        my $match_severity = extract_param($param, 'match-severity');
-        my $match_field = extract_param($param, 'match-field');
-        my $match_calendar = extract_param($param, 'match-calendar');
-        my $target = extract_param($param, 'target');
-        my $mode = extract_param($param, 'mode');
-        my $invert_match = extract_param($param, 'invert-match');
-        my $comment = extract_param($param, 'comment');
-        my $disable = extract_param($param, 'disable');
         my $digest = extract_param($param, 'digest');
         my $delete = extract_param($param, 'delete');
 
@@ -1786,17 +1697,7 @@ __PACKAGE__->register_method({
                 my $config = PVE::Notify::read_config();
 
                 $config->update_matcher(
-                    $name,
-                    $target,
-                    $match_severity,
-                    $match_field,
-                    $match_calendar,
-                    $mode,
-                    $invert_match,
-                    $comment,
-                    $disable,
-                    $delete,
-                    $digest,
+                    $name, $param, $delete, $digest,
                 );
 
                 PVE::Notify::write_config($config);
diff --git a/PVE/VZDump.pm b/PVE/VZDump.pm
index 11c5089f..01c8064d 100644
--- a/PVE/VZDump.pm
+++ b/PVE/VZDump.pm
@@ -557,21 +557,20 @@ sub send_notification {
             # verified once the config is serialized. That means that
             # we can rely on that fact that no other endpoint with this name exists.
             my $endpoint_name = "<" . join(",", @$mailto) . ">";
-            $notification_config->add_sendmail_endpoint(
-                $endpoint_name,
-                $mailto,
-                undef,
-                undef,
-                "vzdump backup tool",
-            );
+            $notification_config->add_sendmail_endpoint({
+                name => $endpoint_name,
+                mailto => $mailto,
+                author => "vzdump backup tool",
+            });
 
             my $endpoints = [$endpoint_name];
 
             # Add a matcher that matches all notifications, set our
             # newly created target as a target.
-            $notification_config->add_matcher(
-                "<matcher-$endpoint_name>", $endpoints,
-            );
+            $notification_config->add_matcher({
+                name => "<matcher-$endpoint_name>",
+                target => $endpoints,
+            });
 
             PVE::Notify::notify(
                 $severity, "vzdump", $template_data, $fields, $notification_config,
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 35+ messages in thread

* [PATCH manager v2 32/34] api: notification: get_matcher: add 'migrate-to-expression' parameter
  2026-10-01  8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
                   ` (30 preceding siblings ...)
  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 ` 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
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:37 UTC (permalink / raw)
  To: pbs-devel, pve-devel

If set, any existing matchers will be transformed so that any 'match-*',
'invert-match' or 'mode' parameter is translated into an equivalent
'expression'.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 PVE/API2/Cluster/Notifications.pm | 11 ++++++++++-
 1 file changed, 10 insertions(+), 1 deletion(-)

diff --git a/PVE/API2/Cluster/Notifications.pm b/PVE/API2/Cluster/Notifications.pm
index 3f99de08..80884af1 100644
--- a/PVE/API2/Cluster/Notifications.pm
+++ b/PVE/API2/Cluster/Notifications.pm
@@ -1603,6 +1603,14 @@ __PACKAGE__->register_method({
                 type => 'string',
                 format => 'pve-configid',
             },
+            'migrate-to-expression' => {
+                type => 'boolean',
+                default => 0,
+                description =>
+                    "Automatically translate existing 'match-*', 'invert-match' and 'mode' to"
+                    . " an equivalent matching 'expression'.",
+                optional => 1,
+            },
         },
     },
     returns => {
@@ -1614,10 +1622,11 @@ __PACKAGE__->register_method({
     code => sub {
         my ($param) = @_;
         my $name = extract_param($param, 'name');
+        my $migrate_to_expression = extract_param($param, 'migrate-to-expression') // 0;
 
         my $config = PVE::Notify::read_config();
 
-        my $matcher = eval { $config->get_matcher($name) };
+        my $matcher = eval { $config->get_matcher($name, $migrate_to_expression) };
 
         raise_api_error($@) if $@;
         $matcher->{digest} = $config->digest();
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 35+ messages in thread

* [PATCH manager v2 33/34] api: notification: add 'expression' to matcher parameter schema
  2026-10-01  8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
                   ` (31 preceding siblings ...)
  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 ` Lukas Wagner
  2026-10-01  8:37 ` [PATCH manager v2 34/34] ui: notification: enable new matcher UI Lukas Wagner
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:37 UTC (permalink / raw)
  To: pbs-devel, pve-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 PVE/API2/Cluster/Notifications.pm | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/PVE/API2/Cluster/Notifications.pm b/PVE/API2/Cluster/Notifications.pm
index 80884af1..bd3c86cc 100644
--- a/PVE/API2/Cluster/Notifications.pm
+++ b/PVE/API2/Cluster/Notifications.pm
@@ -1480,6 +1480,13 @@ my $matcher_properties = {
         type => 'string',
         format => 'pve-configid',
     },
+    'expression' => {
+        type => 'string',
+        description => 'Match expression as inline JSON. This option is mutually exclusive with'
+            . ' the following options: match-field, match-calendar, match-severity, invert-match'
+            . ' and mode. All of these can be represented as (sub)-expressions of this expression',
+        optional => 1,
+    },
     'match-field' => {
         type => 'array',
         items => {
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 35+ messages in thread

* [PATCH manager v2 34/34] ui: notification: enable new matcher UI
  2026-10-01  8:36 [PATCH many v2 00/34] notifications: add nested match expressions Lukas Wagner
                   ` (32 preceding siblings ...)
  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 ` Lukas Wagner
  33 siblings, 0 replies; 35+ messages in thread
From: Lukas Wagner @ 2026-10-01  8:37 UTC (permalink / raw)
  To: pbs-devel, pve-devel

By setting this feature flag, we enable the new improved match rule edit
window. The feature flag allows us to bump the widget toolkit
independently of the rest rest.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 www/manager6/Utils.js | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js
index 8b99371d..8c2bb3c4 100644
--- a/www/manager6/Utils.js
+++ b/www/manager6/Utils.js
@@ -2272,5 +2272,7 @@ Ext.define('PVE.Utils', {
             replication: gettext('Replication job notifications'),
             fencing: gettext('Node fencing notifications'),
         });
+
+        Proxmox.Schema.notificationMatcherExpressions = true;
     },
 });
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 35+ messages in thread

end of thread, other threads:[~2026-10-01  8:44 UTC | newest]

Thread overview: 35+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
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 ` [PATCH proxmox-widget-toolkit v2 20/34] notifications: matcher: add better calendar editor Lukas Wagner
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

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