all lists on lists.proxmox.com
 help / color / mirror / Atom feed
* [PATCH many 00/29] notifications: add nested match expressions
@ 2026-07-09 11:56 Lukas Wagner
  2026-07-09 11:56 ` [PATCH proxmox 01/29] add new proxmox-match-expression crate Lukas Wagner
                   ` (28 more replies)
  0 siblings, 29 replies; 30+ messages in thread
From: Lukas Wagner @ 2026-07-09 11:56 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.

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
match. 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-manager needs 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

Also, Arthur's XOAUTH2 patch series should probably be applied in one go with
my efforts. He probably needs to rebase on top of my changes anyway.


proxmox:

Lukas Wagner (15):
  add new proxmox-match-expression crate
  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

 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           | 511 +++++++++++++++++
 proxmox-notify/Cargo.toml                     |   3 +
 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        | 150 +++++
 proxmox-notify/src/matcher/expression.rs      |  84 +++
 proxmox-notify/src/matcher/field.rs           | 233 ++++++++
 proxmox-notify/src/matcher/mod.rs             | 401 +++++++++++++
 proxmox-notify/src/matcher/severity.rs        | 105 ++++
 20 files changed, 1953 insertions(+), 596 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):
  notification: increase matcher window width
  notifications: matcher: add support for match expressions
  notification: matcher: add better calendar editor
  notifications: matcher: consistently use title case for UI elements

 src/Makefile                                  |   1 +
 src/Schema.js                                 |  10 +
 src/css/ext6-pmx.css                          |  27 +
 .../NotificationMatchExpressionEditPanel.js   | 786 ++++++++++++++++++
 src/proxmox-dark/scss/extjs/_treepanel.scss   |   5 +
 src/proxmox-dark/scss/proxmox/_general.scss   |   4 +
 src/window/NotificationMatcherEdit.js         | 319 ++++++-
 7 files changed, 1133 insertions(+), 19 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 (3):
  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/src/bindings/notify.rs | 207 ++++++----------------------------
 pve-rs/Cargo.toml             |   2 +-
 2 files changed, 33 insertions(+), 176 deletions(-)


pve-manager:

Lukas Wagner (4):
  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 ++++++++----------------------
 www/manager6/Utils.js             |   2 +
 2 files changed, 43 insertions(+), 124 deletions(-)


Summary over all repositories:
  34 files changed, 3182 insertions(+), 918 deletions(-)

-- 
Generated by murpp 0.12.0




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

* [PATCH proxmox 01/29] add new proxmox-match-expression crate
  2026-07-09 11:56 [PATCH many 00/29] notifications: add nested match expressions Lukas Wagner
@ 2026-07-09 11:56 ` Lukas Wagner
  2026-07-09 11:56 ` [PATCH proxmox 02/29] notify: promote matcher to dir-style module Lukas Wagner
                   ` (27 subsequent siblings)
  28 siblings, 0 replies; 30+ messages in thread
From: Lukas Wagner @ 2026-07-09 11:56 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>
---
 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           | 511 ++++++++++++++++++
 7 files changed, 596 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 ef407166..fcb06704 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -31,6 +31,7 @@ members = [
     "proxmox-log",
     "proxmox-login",
     "proxmox-metrics",
+    "proxmox-match-expression",
     "proxmox-network-api",
     "proxmox-network-types",
     "proxmox-node-status",
@@ -177,6 +178,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..0b9b0208
--- /dev/null
+++ b/proxmox-match-expression/src/lib.rs
@@ -0,0 +1,511 @@
+//! 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 a notification matcher matched a notification 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 notification.
+    matches: bool,
+}
+
+impl<M> EvaluatedExpressionWithResult<M> {
+    /// Return whether the expression matched the notification.
+    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, 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) => {
+                if expressions.is_empty() {
+                    // 'all-of' without any sub-expressions is *not* a match
+                    EvaluatedExpressionWithResult {
+                        expression: EvaluatedExpression::AllOf(Vec::new()),
+                        matches: false,
+                    }
+                } else {
+                    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)
+    }
+
+    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 {
+        CustomFailure,
+        CustomTrue,
+        CustomFalse,
+    }
+
+    impl MatchExpression for TestMatcher {
+        type Data = ();
+        type Error = ();
+
+        fn evaluate(&self, _data: &Self::Data) -> Result<bool, ()> {
+            match self {
+                TestMatcher::CustomFailure => Err(()),
+                TestMatcher::CustomTrue => Ok(true),
+                TestMatcher::CustomFalse => 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::CustomFailure)];
+        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::CustomFailure)];
+        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::CustomFailure)];
+        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] 30+ messages in thread

* [PATCH proxmox 02/29] notify: promote matcher to dir-style module
  2026-07-09 11:56 [PATCH many 00/29] notifications: add nested match expressions Lukas Wagner
  2026-07-09 11:56 ` [PATCH proxmox 01/29] add new proxmox-match-expression crate Lukas Wagner
@ 2026-07-09 11:56 ` Lukas Wagner
  2026-07-09 11:56 ` [PATCH proxmox 03/29] notify: fix doc comment Lukas Wagner
                   ` (26 subsequent siblings)
  28 siblings, 0 replies; 30+ messages in thread
From: Lukas Wagner @ 2026-07-09 11:56 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] 30+ messages in thread

* [PATCH proxmox 03/29] notify: fix doc comment
  2026-07-09 11:56 [PATCH many 00/29] notifications: add nested match expressions Lukas Wagner
  2026-07-09 11:56 ` [PATCH proxmox 01/29] add new proxmox-match-expression crate Lukas Wagner
  2026-07-09 11:56 ` [PATCH proxmox 02/29] notify: promote matcher to dir-style module Lukas Wagner
@ 2026-07-09 11:56 ` Lukas Wagner
  2026-07-09 11:56 ` [PATCH proxmox 04/29] notify: matcher: break out severity matcher into submodule Lukas Wagner
                   ` (25 subsequent siblings)
  28 siblings, 0 replies; 30+ messages in thread
From: Lukas Wagner @ 2026-07-09 11:56 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 9964e4bf..720be1c4 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] 30+ messages in thread

* [PATCH proxmox 04/29] notify: matcher: break out severity matcher into submodule
  2026-07-09 11:56 [PATCH many 00/29] notifications: add nested match expressions Lukas Wagner
                   ` (2 preceding siblings ...)
  2026-07-09 11:56 ` [PATCH proxmox 03/29] notify: fix doc comment Lukas Wagner
@ 2026-07-09 11:56 ` Lukas Wagner
  2026-07-09 11:56 ` [PATCH proxmox 05/29] notify: matcher: break out field " Lukas Wagner
                   ` (24 subsequent siblings)
  28 siblings, 0 replies; 30+ messages in thread
From: Lukas Wagner @ 2026-07-09 11:56 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 720be1c4..d27ce465 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 {
@@ -464,6 +428,8 @@ pub fn check_matches<'a>(
 
 #[cfg(test)]
 mod tests {
+    use crate::Severity;
+
     use super::*;
     use serde_json::Value;
     use std::collections::HashMap;
@@ -505,14 +471,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] 30+ messages in thread

* [PATCH proxmox 05/29] notify: matcher: break out field matcher into submodule
  2026-07-09 11:56 [PATCH many 00/29] notifications: add nested match expressions Lukas Wagner
                   ` (3 preceding siblings ...)
  2026-07-09 11:56 ` [PATCH proxmox 04/29] notify: matcher: break out severity matcher into submodule Lukas Wagner
@ 2026-07-09 11:56 ` Lukas Wagner
  2026-07-09 11:56 ` [PATCH proxmox 06/29] notify: matcher: break out calendar " Lukas Wagner
                   ` (23 subsequent siblings)
  28 siblings, 0 replies; 30+ messages in thread
From: Lukas Wagner @ 2026-07-09 11:56 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 d27ce465..dce9ec5c 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();
@@ -428,49 +290,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] 30+ messages in thread

* [PATCH proxmox 06/29] notify: matcher: break out calendar matcher into submodule
  2026-07-09 11:56 [PATCH many 00/29] notifications: add nested match expressions Lukas Wagner
                   ` (4 preceding siblings ...)
  2026-07-09 11:56 ` [PATCH proxmox 05/29] notify: matcher: break out field " Lukas Wagner
@ 2026-07-09 11:56 ` Lukas Wagner
  2026-07-09 11:56 ` [PATCH proxmox 07/29] notify: matcher: calendar: add basic unit test Lukas Wagner
                   ` (22 subsequent siblings)
  28 siblings, 0 replies; 30+ messages in thread
From: Lukas Wagner @ 2026-07-09 11:56 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 dce9ec5c..8c9e5505 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] 30+ messages in thread

* [PATCH proxmox 07/29] notify: matcher: calendar: add basic unit test
  2026-07-09 11:56 [PATCH many 00/29] notifications: add nested match expressions Lukas Wagner
                   ` (5 preceding siblings ...)
  2026-07-09 11:56 ` [PATCH proxmox 06/29] notify: matcher: break out calendar " Lukas Wagner
@ 2026-07-09 11:56 ` Lukas Wagner
  2026-07-09 11:56 ` [PATCH proxmox 08/29] notify: matcher: add InlineSeverityMatcher Lukas Wagner
                   ` (21 subsequent siblings)
  28 siblings, 0 replies; 30+ messages in thread
From: Lukas Wagner @ 2026-07-09 11:56 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] 30+ messages in thread

* [PATCH proxmox 08/29] notify: matcher: add InlineSeverityMatcher
  2026-07-09 11:56 [PATCH many 00/29] notifications: add nested match expressions Lukas Wagner
                   ` (6 preceding siblings ...)
  2026-07-09 11:56 ` [PATCH proxmox 07/29] notify: matcher: calendar: add basic unit test Lukas Wagner
@ 2026-07-09 11:56 ` Lukas Wagner
  2026-07-09 11:56 ` [PATCH proxmox 09/29] notify: matcher: add InlineFieldMatcher Lukas Wagner
                   ` (20 subsequent siblings)
  28 siblings, 0 replies; 30+ messages in thread
From: Lukas Wagner @ 2026-07-09 11:56 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 8c9e5505..bf2efceb 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] 30+ messages in thread

* [PATCH proxmox 09/29] notify: matcher: add InlineFieldMatcher
  2026-07-09 11:56 [PATCH many 00/29] notifications: add nested match expressions Lukas Wagner
                   ` (7 preceding siblings ...)
  2026-07-09 11:56 ` [PATCH proxmox 08/29] notify: matcher: add InlineSeverityMatcher Lukas Wagner
@ 2026-07-09 11:56 ` Lukas Wagner
  2026-07-09 11:56 ` [PATCH proxmox 10/29] notify: matcher: add InlineCalendarMatcher Lukas Wagner
                   ` (19 subsequent siblings)
  28 siblings, 0 replies; 30+ messages in thread
From: Lukas Wagner @ 2026-07-09 11:56 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 bf2efceb..0267a2b9 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] 30+ messages in thread

* [PATCH proxmox 10/29] notify: matcher: add InlineCalendarMatcher
  2026-07-09 11:56 [PATCH many 00/29] notifications: add nested match expressions Lukas Wagner
                   ` (8 preceding siblings ...)
  2026-07-09 11:56 ` [PATCH proxmox 09/29] notify: matcher: add InlineFieldMatcher Lukas Wagner
@ 2026-07-09 11:56 ` Lukas Wagner
  2026-07-09 11:56 ` [PATCH proxmox 11/29] notify: matcher: add expression support Lukas Wagner
                   ` (18 subsequent siblings)
  28 siblings, 0 replies; 30+ messages in thread
From: Lukas Wagner @ 2026-07-09 11:56 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 0267a2b9..a5d53d8a 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] 30+ messages in thread

* [PATCH proxmox 11/29] notify: matcher: add expression support
  2026-07-09 11:56 [PATCH many 00/29] notifications: add nested match expressions Lukas Wagner
                   ` (9 preceding siblings ...)
  2026-07-09 11:56 ` [PATCH proxmox 10/29] notify: matcher: add InlineCalendarMatcher Lukas Wagner
@ 2026-07-09 11:56 ` Lukas Wagner
  2026-07-09 11:56 ` [PATCH proxmox 12/29] notify: api: support new expression parameter Lukas Wagner
                   ` (17 subsequent siblings)
  28 siblings, 0 replies; 30+ messages in thread
From: Lukas Wagner @ 2026-07-09 11:56 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        | 142 +++++++++++++----------
 proxmox-notify/src/matcher/severity.rs   |   6 +-
 6 files changed, 185 insertions(+), 81 deletions(-)
 create mode 100644 proxmox-notify/src/matcher/expression.rs

diff --git a/Cargo.toml b/Cargo.toml
index fcb06704..690f3436 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -137,6 +137,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..308987ad
--- /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 s: 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 a5d53d8a..429692a4 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,76 @@ trait MatchDirective {
 
 impl MatcherConfig {
     pub fn matches(&self, notification: &Notification) -> Result<Option<&[String]>, Error> {
-        let mode = self.mode.unwrap_or_default();
+        let expression = if let Some(expression_str) = &self.expression {
+            self.warn_about_ignored_properties();
 
-        let mut is_match = mode.neutral_element();
-        // If there are no matching directives, the matcher will always match
-        let mut no_matchers = true;
-
-        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_ignored_properties(&self) {
+        if !self.match_severity.is_empty()
+            || !self.match_calendar.is_empty()
+            || !self.match_field.is_empty()
+            || self.mode.is_some()
+            || self.invert_match.is_some()
+        {
+            tracing::warn!(
+                "matcher '{}' has 'expression' set - 'match-*', 'invert-match' and 'mode' are ignored",
+                self.name
+            );
+        }
     }
 }
 
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] 30+ messages in thread

* [PATCH proxmox 12/29] notify: api: support new expression parameter
  2026-07-09 11:56 [PATCH many 00/29] notifications: add nested match expressions Lukas Wagner
                   ` (10 preceding siblings ...)
  2026-07-09 11:56 ` [PATCH proxmox 11/29] notify: matcher: add expression support Lukas Wagner
@ 2026-07-09 11:56 ` Lukas Wagner
  2026-07-09 11:57 ` [PATCH proxmox 13/29] notify: api: add `get_matcher_as_expression` Lukas Wagner
                   ` (16 subsequent siblings)
  28 siblings, 0 replies; 30+ messages in thread
From: Lukas Wagner @ 2026-07-09 11:56 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 |  43 ++++++++++-
 2 files changed, 161 insertions(+), 1 deletion(-)

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 429692a4..60725e00 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 {
@@ -216,6 +216,45 @@ impl MatcherConfig {
             );
         }
     }
+
+    /// Ensure the validity of this matcher.
+    pub(crate) fn ensure_valid(&self) -> Result<(), Error> {
+        if let Some(expression) = &self.expression {
+            if self.invert_match.is_some() {
+                return Err(Error::Generic(
+                    "'expression' and 'invert-match' are mutually exclusive properties".into(),
+                ));
+            }
+            if self.mode.is_some() {
+                return Err(Error::Generic(
+                    "'expression' and 'mode' are mutually exclusive properties".into(),
+                ));
+            }
+            if !self.match_field.is_empty() {
+                return Err(Error::Generic(
+                    "'expression' and 'match-field' are mutually exclusive properties".into(),
+                ));
+            }
+            if !self.match_severity.is_empty() {
+                return Err(Error::Generic(
+                    "'expression' and 'match-severity' are mutually exclusive properties".into(),
+                ));
+            }
+            if !self.match_calendar.is_empty() {
+                return Err(Error::Generic(
+                    "'expression' and 'match-calendar' are mutually exclusive properties".into(),
+                ));
+            }
+
+            if let Err(err) = serde_json::from_str::<Expression<NotificationMatcher>>(expression) {
+                return Err(Error::Generic(format!(
+                    "'expression' is not valid: {err:#}"
+                )));
+            }
+        }
+
+        Ok(())
+    }
 }
 
 #[api]
@@ -237,6 +276,8 @@ pub enum DeleteableMatcherProperty {
     MatchSeverity,
     /// Delete `mode`
     Mode,
+    /// Delete `expression`
+    Expression,
     /// Delete `target`
     Target,
 }
-- 
2.47.3





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

* [PATCH proxmox 13/29] notify: api: add `get_matcher_as_expression`
  2026-07-09 11:56 [PATCH many 00/29] notifications: add nested match expressions Lukas Wagner
                   ` (11 preceding siblings ...)
  2026-07-09 11:56 ` [PATCH proxmox 12/29] notify: api: support new expression parameter Lukas Wagner
@ 2026-07-09 11:57 ` Lukas Wagner
  2026-07-09 11:57 ` [PATCH proxmox 14/29] notify: migrate PBS's and PVE's default matcher to expression syntax Lukas Wagner
                   ` (15 subsequent siblings)
  28 siblings, 0 replies; 30+ messages in thread
From: Lukas Wagner @ 2026-07-09 11:57 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] 30+ messages in thread

* [PATCH proxmox 14/29] notify: migrate PBS's and PVE's default matcher to expression syntax
  2026-07-09 11:56 [PATCH many 00/29] notifications: add nested match expressions Lukas Wagner
                   ` (12 preceding siblings ...)
  2026-07-09 11:57 ` [PATCH proxmox 13/29] notify: api: add `get_matcher_as_expression` Lukas Wagner
@ 2026-07-09 11:57 ` Lukas Wagner
  2026-07-09 11:57 ` [PATCH proxmox 15/29] notify: move legacy matcher keys behind feature flag Lukas Wagner
                   ` (14 subsequent siblings)
  28 siblings, 0 replies; 30+ messages in thread
From: Lukas Wagner @ 2026-07-09 11:57 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/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 ++-
 8 files changed, 157 insertions(+), 46 deletions(-)

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..b2561ad1 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],
+                    }.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] 30+ messages in thread

* [PATCH proxmox 15/29] notify: move legacy matcher keys behind feature flag
  2026-07-09 11:56 [PATCH many 00/29] notifications: add nested match expressions Lukas Wagner
                   ` (13 preceding siblings ...)
  2026-07-09 11:57 ` [PATCH proxmox 14/29] notify: migrate PBS's and PVE's default matcher to expression syntax Lukas Wagner
@ 2026-07-09 11:57 ` Lukas Wagner
  2026-07-09 11:57 ` [PATCH proxmox-widget-toolkit 16/29] notification: increase matcher window width Lukas Wagner
                   ` (13 subsequent siblings)
  28 siblings, 0 replies; 30+ messages in thread
From: Lukas Wagner @ 2026-07-09 11:57 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      | 106 +++++++--
 proxmox-notify/src/matcher/severity.rs | 132 ++++++-----
 7 files changed, 469 insertions(+), 297 deletions(-)

diff --git a/proxmox-notify/Cargo.toml b/proxmox-notify/Cargo.toml
index ae82f649..cf0e8fc4 100644
--- a/proxmox-notify/Cargo.toml
+++ b/proxmox-notify/Cargo.toml
@@ -48,3 +48,4 @@ pve-context = ["dep:proxmox-sys"]
 pbs-context = ["dep:proxmox-sys"]
 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 60725e00..08235f05 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> {
-        let expression = if let Some(expression_str) = &self.expression {
-            self.warn_about_ignored_properties();
+        let expression: Expression<NotificationMatcher> = {
+            if let Some(expression_str) = &self.expression {
+                #[cfg(feature = "legacy-matchers")]
+                self.warn_about_ignored_properties();
 
-            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()
+                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()
@@ -203,6 +260,7 @@ impl MatcherConfig {
         }
     }
 
+    #[cfg(feature = "legacy-matchers")]
     fn warn_about_ignored_properties(&self) {
         if !self.match_severity.is_empty()
             || !self.match_calendar.is_empty()
@@ -220,26 +278,31 @@ impl MatcherConfig {
     /// Ensure the validity of this matcher.
     pub(crate) fn ensure_valid(&self) -> Result<(), Error> {
         if let Some(expression) = &self.expression {
+            #[cfg(feature = "legacy-matchers")]
             if self.invert_match.is_some() {
                 return Err(Error::Generic(
                     "'expression' and 'invert-match' are mutually exclusive properties".into(),
                 ));
             }
+            #[cfg(feature = "legacy-matchers")]
             if self.mode.is_some() {
                 return Err(Error::Generic(
                     "'expression' and 'mode' are mutually exclusive properties".into(),
                 ));
             }
+            #[cfg(feature = "legacy-matchers")]
             if !self.match_field.is_empty() {
                 return Err(Error::Generic(
                     "'expression' and 'match-field' are mutually exclusive properties".into(),
                 ));
             }
+            #[cfg(feature = "legacy-matchers")]
             if !self.match_severity.is_empty() {
                 return Err(Error::Generic(
                     "'expression' and 'match-severity' are mutually exclusive properties".into(),
                 ));
             }
+            #[cfg(feature = "legacy-matchers")]
             if !self.match_calendar.is_empty() {
                 return Err(Error::Generic(
                     "'expression' and 'match-calendar' are mutually exclusive properties".into(),
@@ -267,14 +330,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,
@@ -309,14 +377,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] 30+ messages in thread

* [PATCH proxmox-widget-toolkit 16/29] notification: increase matcher window width
  2026-07-09 11:56 [PATCH many 00/29] notifications: add nested match expressions Lukas Wagner
                   ` (14 preceding siblings ...)
  2026-07-09 11:57 ` [PATCH proxmox 15/29] notify: move legacy matcher keys behind feature flag Lukas Wagner
@ 2026-07-09 11:57 ` Lukas Wagner
  2026-07-09 11:57 ` [PATCH proxmox-widget-toolkit 17/29] notifications: matcher: add support for match expressions Lukas Wagner
                   ` (12 subsequent siblings)
  28 siblings, 0 replies; 30+ messages in thread
From: Lukas Wagner @ 2026-07-09 11:57 UTC (permalink / raw)
  To: pbs-devel, pve-devel

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] 30+ messages in thread

* [PATCH proxmox-widget-toolkit 17/29] notifications: matcher: add support for match expressions
  2026-07-09 11:56 [PATCH many 00/29] notifications: add nested match expressions Lukas Wagner
                   ` (15 preceding siblings ...)
  2026-07-09 11:57 ` [PATCH proxmox-widget-toolkit 16/29] notification: increase matcher window width Lukas Wagner
@ 2026-07-09 11:57 ` Lukas Wagner
  2026-07-09 11:57 ` [PATCH proxmox-widget-toolkit 18/29] notification: matcher: add better calendar editor Lukas Wagner
                   ` (11 subsequent siblings)
  28 siblings, 0 replies; 30+ messages in thread
From: Lukas Wagner @ 2026-07-09 11:57 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>
---
 src/Makefile                                  |   1 +
 src/Schema.js                                 |  10 +
 src/css/ext6-pmx.css                          |  27 +
 .../NotificationMatchExpressionEditPanel.js   | 786 ++++++++++++++++++
 src/proxmox-dark/scss/extjs/_treepanel.scss   |   5 +
 src/proxmox-dark/scss/proxmox/_general.scss   |   4 +
 src/window/NotificationMatcherEdit.js         |  13 +-
 7 files changed, 841 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..bf4ecd3 100644
--- a/src/css/ext6-pmx.css
+++ b/src/css/ext6-pmx.css
@@ -248,6 +248,29 @@ 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 {
+    background-color: #f5f5f5;
+    border-radius: 4px;
+    padding: 4px;
+    font-size: 11px;
+}
+
 /* fix padding for legend in header */
 .x-legend-inner {
     padding: 0;
@@ -431,3 +454,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..6d1f93e
--- /dev/null
+++ b/src/panel/NotificationMatchExpressionEditPanel.js
@@ -0,0 +1,786 @@
+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,
+
+    getNodeIcon: function (type, data, invert) {
+        let iconCls = 'fa';
+
+        switch (type) {
+            case 'match-severity':
+                {
+                    let v = data.value;
+                    if (Ext.isArray(data.value)) {
+                        v = data.value.join(', ');
+                    }
+                    iconCls += ' fa-exclamation';
+                    if (!v) {
+                        iconCls += ' internal-error';
+                    }
+                }
+                break;
+            case 'match-field':
+                {
+                    let field = data.field;
+                    let value = data.value;
+                    iconCls += ' fa-square-o';
+                    if (!field || !value || (Ext.isArray(value) && !value.length)) {
+                        iconCls += ' internal-error';
+                    }
+                }
+                break;
+            case 'match-calendar':
+                {
+                    let v = data.value;
+                    iconCls += ' fa-calendar-o';
+                    if (!v || !v.length) {
+                        iconCls += ' internal-error';
+                    }
+                }
+                break;
+            case 'any-of':
+                iconCls += ' fa-filter';
+
+                break;
+            case 'all-of':
+                iconCls += ' fa-filter';
+
+                break;
+            case 'one-of':
+                iconCls = 'fa fa-filter';
+                break;
+
+            case 'match-always':
+                if (invert) {
+                    iconCls += ' fa-ban';
+                } else {
+                    iconCls += ' fa-check-circle';
+                }
+        }
+
+        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 rule matches');
+                } else {
+                    return gettext('At least one rule matches');
+                }
+
+            case 'all-of':
+                if (invert) {
+                    return gettext('At least one rule does not match');
+                } else {
+                    return gettext('All rules match');
+                }
+
+            case 'one-of':
+                if (invert) {
+                    return gettext('Not: Exactly one rule matches');
+                } else {
+                    return gettext('Exactly one 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',
+            },
+        });
+
+        let realExpression = Ext.create({
+            xtype: 'hiddenfield',
+            name: 'expression',
+            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.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');
+
+            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.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,
+            minHeight: 300,
+            maxHeight: 300,
+            scrollable: true,
+
+            // 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,
+                },
+                {
+                    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 child node'), 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 node'), v),
+                            getClass: (v, m, { data }) => 'fa fa-trash-o',
+                            isActionDisabled: (v, r, c, i, rec) => rec.isRoot(),
+                        },
+                    ],
+                },
+            ],
+        });
+
+        Ext.apply(me, {
+            items: [realExpression, treePanel],
+        });
+        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 rules match')],
+                ['any-of', gettext('At least one rule matches')],
+                ['one-of', gettext('Exactly one node 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] 30+ messages in thread

* [PATCH proxmox-widget-toolkit 18/29] notification: matcher: add better calendar editor
  2026-07-09 11:56 [PATCH many 00/29] notifications: add nested match expressions Lukas Wagner
                   ` (16 preceding siblings ...)
  2026-07-09 11:57 ` [PATCH proxmox-widget-toolkit 17/29] notifications: matcher: add support for match expressions Lukas Wagner
@ 2026-07-09 11:57 ` Lukas Wagner
  2026-07-09 11:57 ` [PATCH proxmox-widget-toolkit 19/29] notifications: matcher: consistently use title case for UI elements Lukas Wagner
                   ` (10 subsequent siblings)
  28 siblings, 0 replies; 30+ messages in thread
From: Lukas Wagner @ 2026-07-09 11:57 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>
---
 .../NotificationMatchExpressionEditPanel.js   |   2 +-
 src/window/NotificationMatcherEdit.js         | 300 +++++++++++++++++-
 2 files changed, 290 insertions(+), 12 deletions(-)

diff --git a/src/panel/NotificationMatchExpressionEditPanel.js b/src/panel/NotificationMatchExpressionEditPanel.js
index 6d1f93e..e117ea5 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;
diff --git a/src/window/NotificationMatcherEdit.js b/src/window/NotificationMatcherEdit.js
index 893c3e3..433f86c 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,296 @@ 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 [, days, start, end] = /^(?:(\S*)\s+)?([0-9:]+)-([0-9:]+)$/.exec(timeframe) || [];
+
+            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 timeframe = me.formatSelectedDays(data.data);
+
+            let start = me.formatTime(startField);
+            let end = me.formatTime(endField);
+
+            timeframe += ` ${start}-${end}`;
+
+            let field = me.lookup('timeframe');
+            field.suspendEvent('change');
+            field.setValue(timeframe);
+
+            me.getViewModel().set('matchCalendarValue', timeframe);
+
+            field.resumeEvent('change');
+        },
+
+        formatSelectedDays: function (days) {
+            let me = this;
+            let selected = me.weekdays.filter((day) => days[day]);
+
+            if (selected.length === 0) {
+                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',
+            allowBlank: false,
+        },
+        {
+            xtype: 'timefield',
+            reference: 'timeEnd',
+            fieldLabel: gettext('Time End'),
+            isFormField: false,
+            format: 'H:i',
+            formatText: 'HH:MM',
+            maxValue: '23:59',
+            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,
+                            },
+                        ],
+                    },
+                    columns: [
+                        {
+                            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] 30+ messages in thread

* [PATCH proxmox-widget-toolkit 19/29] notifications: matcher: consistently use title case for UI elements
  2026-07-09 11:56 [PATCH many 00/29] notifications: add nested match expressions Lukas Wagner
                   ` (17 preceding siblings ...)
  2026-07-09 11:57 ` [PATCH proxmox-widget-toolkit 18/29] notification: matcher: add better calendar editor Lukas Wagner
@ 2026-07-09 11:57 ` Lukas Wagner
  2026-07-09 11:57 ` [PATCH proxmox-backup 20/29] notification: opt into 'legacy-matchers' feature in proxmox-notify Lukas Wagner
                   ` (9 subsequent siblings)
  28 siblings, 0 replies; 30+ messages in thread
From: Lukas Wagner @ 2026-07-09 11:57 UTC (permalink / raw)
  To: pbs-devel, pve-devel

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

diff --git a/src/window/NotificationMatcherEdit.js b/src/window/NotificationMatcherEdit.js
index 433f86c..b46fda3 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,
@@ -1346,7 +1346,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] 30+ messages in thread

* [PATCH proxmox-backup 20/29] notification: opt into 'legacy-matchers' feature in proxmox-notify
  2026-07-09 11:56 [PATCH many 00/29] notifications: add nested match expressions Lukas Wagner
                   ` (18 preceding siblings ...)
  2026-07-09 11:57 ` [PATCH proxmox-widget-toolkit 19/29] notifications: matcher: consistently use title case for UI elements Lukas Wagner
@ 2026-07-09 11:57 ` Lukas Wagner
  2026-07-09 11:57 ` [PATCH proxmox-backup 21/29] api: notification: add 'migrate-to-expression' parameter to get_matcher Lukas Wagner
                   ` (8 subsequent siblings)
  28 siblings, 0 replies; 30+ messages in thread
From: Lukas Wagner @ 2026-07-09 11:57 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 a625370cf..f8b17aa54 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] 30+ messages in thread

* [PATCH proxmox-backup 21/29] api: notification: add 'migrate-to-expression' parameter to get_matcher
  2026-07-09 11:56 [PATCH many 00/29] notifications: add nested match expressions Lukas Wagner
                   ` (19 preceding siblings ...)
  2026-07-09 11:57 ` [PATCH proxmox-backup 20/29] notification: opt into 'legacy-matchers' feature in proxmox-notify Lukas Wagner
@ 2026-07-09 11:57 ` Lukas Wagner
  2026-07-09 11:57 ` [PATCH proxmox-backup 22/29] ui: notification: enable new matcher UI Lukas Wagner
                   ` (7 subsequent siblings)
  28 siblings, 0 replies; 30+ messages in thread
From: Lukas Wagner @ 2026-07-09 11:57 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] 30+ messages in thread

* [PATCH proxmox-backup 22/29] ui: notification: enable new matcher UI
  2026-07-09 11:56 [PATCH many 00/29] notifications: add nested match expressions Lukas Wagner
                   ` (20 preceding siblings ...)
  2026-07-09 11:57 ` [PATCH proxmox-backup 21/29] api: notification: add 'migrate-to-expression' parameter to get_matcher Lukas Wagner
@ 2026-07-09 11:57 ` Lukas Wagner
  2026-07-09 11:57 ` [PATCH proxmox-perl-rs 23/29] notify: matcher: pass matcher config / updater directly Lukas Wagner
                   ` (6 subsequent siblings)
  28 siblings, 0 replies; 30+ messages in thread
From: Lukas Wagner @ 2026-07-09 11:57 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] 30+ messages in thread

* [PATCH proxmox-perl-rs 23/29] notify: matcher: pass matcher config / updater directly
  2026-07-09 11:56 [PATCH many 00/29] notifications: add nested match expressions Lukas Wagner
                   ` (21 preceding siblings ...)
  2026-07-09 11:57 ` [PATCH proxmox-backup 22/29] ui: notification: enable new matcher UI Lukas Wagner
@ 2026-07-09 11:57 ` Lukas Wagner
  2026-07-09 11:57 ` [PATCH proxmox-perl-rs 24/29] notify: opt into 'legacy-matchers' feature in proxmox-notify Lukas Wagner
                   ` (5 subsequent siblings)
  28 siblings, 0 replies; 30+ messages in thread
From: Lukas Wagner @ 2026-07-09 11:57 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>
---
 common/src/bindings/notify.rs | 200 +++++-----------------------------
 1 file changed, 26 insertions(+), 174 deletions(-)

diff --git a/common/src/bindings/notify.rs b/common/src/bindings/notify.rs
index 409270a..80ccf39 100644
--- a/common/src/bindings/notify.rs
+++ b/common/src/bindings/notify.rs
@@ -12,7 +12,7 @@ pub mod proxmox_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 proxmox_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 proxmox_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 proxmox_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 proxmox_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 proxmox_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 proxmox_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 proxmox_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 proxmox_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 proxmox_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 proxmox_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 proxmox_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] 30+ messages in thread

* [PATCH proxmox-perl-rs 24/29] notify: opt into 'legacy-matchers' feature in proxmox-notify
  2026-07-09 11:56 [PATCH many 00/29] notifications: add nested match expressions Lukas Wagner
                   ` (22 preceding siblings ...)
  2026-07-09 11:57 ` [PATCH proxmox-perl-rs 23/29] notify: matcher: pass matcher config / updater directly Lukas Wagner
@ 2026-07-09 11:57 ` Lukas Wagner
  2026-07-09 11:57 ` [PATCH proxmox-perl-rs 25/29] notify: add 'migrate_to_expression' parameter for get_matcher Lukas Wagner
                   ` (4 subsequent siblings)
  28 siblings, 0 replies; 30+ messages in thread
From: Lukas Wagner @ 2026-07-09 11:57 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] 30+ messages in thread

* [PATCH proxmox-perl-rs 25/29] notify: add 'migrate_to_expression' parameter for get_matcher
  2026-07-09 11:56 [PATCH many 00/29] notifications: add nested match expressions Lukas Wagner
                   ` (23 preceding siblings ...)
  2026-07-09 11:57 ` [PATCH proxmox-perl-rs 24/29] notify: opt into 'legacy-matchers' feature in proxmox-notify Lukas Wagner
@ 2026-07-09 11:57 ` Lukas Wagner
  2026-07-09 11:57 ` [PATCH manager 26/29] api: notification: pass config/updater directly to rust bindings Lukas Wagner
                   ` (3 subsequent siblings)
  28 siblings, 0 replies; 30+ messages in thread
From: Lukas Wagner @ 2026-07-09 11:57 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>
---
 common/src/bindings/notify.rs | 7 ++++++-
 1 file changed, 6 insertions(+), 1 deletion(-)

diff --git a/common/src/bindings/notify.rs b/common/src/bindings/notify.rs
index 80ccf39..3b9c613 100644
--- a/common/src/bindings/notify.rs
+++ b/common/src/bindings/notify.rs
@@ -464,9 +464,14 @@ pub mod proxmox_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] 30+ messages in thread

* [PATCH manager 26/29] api: notification: pass config/updater directly to rust bindings
  2026-07-09 11:56 [PATCH many 00/29] notifications: add nested match expressions Lukas Wagner
                   ` (24 preceding siblings ...)
  2026-07-09 11:57 ` [PATCH proxmox-perl-rs 25/29] notify: add 'migrate_to_expression' parameter for get_matcher Lukas Wagner
@ 2026-07-09 11:57 ` Lukas Wagner
  2026-07-09 11:57 ` [PATCH manager 27/29] api: notification: get_matcher: add 'migrate-to-expression' parameter Lukas Wagner
                   ` (2 subsequent siblings)
  28 siblings, 0 replies; 30+ messages in thread
From: Lukas Wagner @ 2026-07-09 11:57 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 +++++-------------------------
 1 file changed, 24 insertions(+), 123 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);
-- 
2.47.3





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

* [PATCH manager 27/29] api: notification: get_matcher: add 'migrate-to-expression' parameter
  2026-07-09 11:56 [PATCH many 00/29] notifications: add nested match expressions Lukas Wagner
                   ` (25 preceding siblings ...)
  2026-07-09 11:57 ` [PATCH manager 26/29] api: notification: pass config/updater directly to rust bindings Lukas Wagner
@ 2026-07-09 11:57 ` Lukas Wagner
  2026-07-09 11:57 ` [PATCH manager 28/29] api: notification: add 'expression' to matcher parameter schema Lukas Wagner
  2026-07-09 11:57 ` [PATCH manager 29/29] ui: notification: enable new matcher UI Lukas Wagner
  28 siblings, 0 replies; 30+ messages in thread
From: Lukas Wagner @ 2026-07-09 11:57 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] 30+ messages in thread

* [PATCH manager 28/29] api: notification: add 'expression' to matcher parameter schema
  2026-07-09 11:56 [PATCH many 00/29] notifications: add nested match expressions Lukas Wagner
                   ` (26 preceding siblings ...)
  2026-07-09 11:57 ` [PATCH manager 27/29] api: notification: get_matcher: add 'migrate-to-expression' parameter Lukas Wagner
@ 2026-07-09 11:57 ` Lukas Wagner
  2026-07-09 11:57 ` [PATCH manager 29/29] ui: notification: enable new matcher UI Lukas Wagner
  28 siblings, 0 replies; 30+ messages in thread
From: Lukas Wagner @ 2026-07-09 11:57 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] 30+ messages in thread

* [PATCH manager 29/29] ui: notification: enable new matcher UI
  2026-07-09 11:56 [PATCH many 00/29] notifications: add nested match expressions Lukas Wagner
                   ` (27 preceding siblings ...)
  2026-07-09 11:57 ` [PATCH manager 28/29] api: notification: add 'expression' to matcher parameter schema Lukas Wagner
@ 2026-07-09 11:57 ` Lukas Wagner
  28 siblings, 0 replies; 30+ messages in thread
From: Lukas Wagner @ 2026-07-09 11:57 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 040b5ae0..f5387175 100644
--- a/www/manager6/Utils.js
+++ b/www/manager6/Utils.js
@@ -2257,5 +2257,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] 30+ messages in thread

end of thread, other threads:[~2026-07-09 12:02 UTC | newest]

Thread overview: 30+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-07-09 11:56 [PATCH many 00/29] notifications: add nested match expressions Lukas Wagner
2026-07-09 11:56 ` [PATCH proxmox 01/29] add new proxmox-match-expression crate Lukas Wagner
2026-07-09 11:56 ` [PATCH proxmox 02/29] notify: promote matcher to dir-style module Lukas Wagner
2026-07-09 11:56 ` [PATCH proxmox 03/29] notify: fix doc comment Lukas Wagner
2026-07-09 11:56 ` [PATCH proxmox 04/29] notify: matcher: break out severity matcher into submodule Lukas Wagner
2026-07-09 11:56 ` [PATCH proxmox 05/29] notify: matcher: break out field " Lukas Wagner
2026-07-09 11:56 ` [PATCH proxmox 06/29] notify: matcher: break out calendar " Lukas Wagner
2026-07-09 11:56 ` [PATCH proxmox 07/29] notify: matcher: calendar: add basic unit test Lukas Wagner
2026-07-09 11:56 ` [PATCH proxmox 08/29] notify: matcher: add InlineSeverityMatcher Lukas Wagner
2026-07-09 11:56 ` [PATCH proxmox 09/29] notify: matcher: add InlineFieldMatcher Lukas Wagner
2026-07-09 11:56 ` [PATCH proxmox 10/29] notify: matcher: add InlineCalendarMatcher Lukas Wagner
2026-07-09 11:56 ` [PATCH proxmox 11/29] notify: matcher: add expression support Lukas Wagner
2026-07-09 11:56 ` [PATCH proxmox 12/29] notify: api: support new expression parameter Lukas Wagner
2026-07-09 11:57 ` [PATCH proxmox 13/29] notify: api: add `get_matcher_as_expression` Lukas Wagner
2026-07-09 11:57 ` [PATCH proxmox 14/29] notify: migrate PBS's and PVE's default matcher to expression syntax Lukas Wagner
2026-07-09 11:57 ` [PATCH proxmox 15/29] notify: move legacy matcher keys behind feature flag Lukas Wagner
2026-07-09 11:57 ` [PATCH proxmox-widget-toolkit 16/29] notification: increase matcher window width Lukas Wagner
2026-07-09 11:57 ` [PATCH proxmox-widget-toolkit 17/29] notifications: matcher: add support for match expressions Lukas Wagner
2026-07-09 11:57 ` [PATCH proxmox-widget-toolkit 18/29] notification: matcher: add better calendar editor Lukas Wagner
2026-07-09 11:57 ` [PATCH proxmox-widget-toolkit 19/29] notifications: matcher: consistently use title case for UI elements Lukas Wagner
2026-07-09 11:57 ` [PATCH proxmox-backup 20/29] notification: opt into 'legacy-matchers' feature in proxmox-notify Lukas Wagner
2026-07-09 11:57 ` [PATCH proxmox-backup 21/29] api: notification: add 'migrate-to-expression' parameter to get_matcher Lukas Wagner
2026-07-09 11:57 ` [PATCH proxmox-backup 22/29] ui: notification: enable new matcher UI Lukas Wagner
2026-07-09 11:57 ` [PATCH proxmox-perl-rs 23/29] notify: matcher: pass matcher config / updater directly Lukas Wagner
2026-07-09 11:57 ` [PATCH proxmox-perl-rs 24/29] notify: opt into 'legacy-matchers' feature in proxmox-notify Lukas Wagner
2026-07-09 11:57 ` [PATCH proxmox-perl-rs 25/29] notify: add 'migrate_to_expression' parameter for get_matcher Lukas Wagner
2026-07-09 11:57 ` [PATCH manager 26/29] api: notification: pass config/updater directly to rust bindings Lukas Wagner
2026-07-09 11:57 ` [PATCH manager 27/29] api: notification: get_matcher: add 'migrate-to-expression' parameter Lukas Wagner
2026-07-09 11:57 ` [PATCH manager 28/29] api: notification: add 'expression' to matcher parameter schema Lukas Wagner
2026-07-09 11:57 ` [PATCH manager 29/29] ui: notification: enable new matcher UI Lukas Wagner

This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal