all lists on lists.proxmox.com
 help / color / mirror / Atom feed
* [pdm-devel] [PATCH datacenter-manager v2 0/9] implement more complex search syntax
@ 2025-08-25  8:58 Dominik Csapak
  2025-08-25  8:58 ` [pdm-devel] [PATCH datacenter-manager v2 1/9] pdm-api-types: resources: add helper methods for fields Dominik Csapak
                   ` (10 more replies)
  0 siblings, 11 replies; 15+ messages in thread
From: Dominik Csapak @ 2025-08-25  8:58 UTC (permalink / raw)
  To: pdm-devel

this introduces a more complex search syntax for the resources api call
and uses that with the dashboard to show relevant resources, e.g.
when clicking on the remotes panel when there are failed remotes, it
adds a search to the box that filters for offline remotes. Same
for clicking on the running vm count, etc.

The syntax is a first draft of mine, we can still tweak and change it
as we see fit, but it's a start.

a 'normal' search term gets filtered to id/name so that does not change
as before, but you can now specify 'categories' with `category:value`
e.g. it's now possible to search for `type:remote` or `status:offline`

it also adds the possibility to mark terms as required like this:

+someterm

required terms have to exist in the resulting resource, while optional
ones are OR'd (so at least one optional match must exist)

Not implemented yet are (but can be done afterwards):
* GUI for filtering
* Include subscription status

changes from v1:
* added commit to improve text width
* added commit to add clear trigger to search box
* rebased on master

Dominik Csapak (9):
  pdm-api-types: resources: add helper methods for fields
  lib: add pdm-search crate
  server: api: resources: add more complex filter syntax
  ui: add possibility to insert into search box
  ui: dashboard: remotes panel: open search on click
  ui: dashboard: guest panel: search for guest states when clicking on
    them
  ui: dashboard: search for nodes when clicking on the nodes panel
  ui: search box: add clear trigger
  ui: dashboard: guest panel: improve column widths

 Cargo.toml                        |   2 +
 lib/pdm-api-types/src/resource.rs |  27 ++++
 lib/pdm-search/Cargo.toml         |  12 ++
 lib/pdm-search/src/lib.rs         | 259 ++++++++++++++++++++++++++++++
 server/Cargo.toml                 |   1 +
 server/src/api/resources.rs       |  84 ++++++++--
 ui/Cargo.toml                     |   1 +
 ui/src/dashboard/guest_panel.rs   |  86 +++++++++-
 ui/src/dashboard/mod.rs           |  46 +++++-
 ui/src/dashboard/remote_panel.rs  |  32 +++-
 ui/src/lib.rs                     |   3 +
 ui/src/main.rs                    |  17 +-
 ui/src/search_provider.rs         |  35 ++++
 ui/src/widget/search_box.rs       |  40 ++++-
 14 files changed, 612 insertions(+), 33 deletions(-)
 create mode 100644 lib/pdm-search/Cargo.toml
 create mode 100644 lib/pdm-search/src/lib.rs
 create mode 100644 ui/src/search_provider.rs

-- 
2.47.2



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


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

* [pdm-devel] [PATCH datacenter-manager v2 1/9] pdm-api-types: resources: add helper methods for fields
  2025-08-25  8:58 [pdm-devel] [PATCH datacenter-manager v2 0/9] implement more complex search syntax Dominik Csapak
@ 2025-08-25  8:58 ` Dominik Csapak
  2025-08-25  8:58 ` [pdm-devel] [PATCH datacenter-manager v2 2/9] lib: add pdm-search crate Dominik Csapak
                   ` (9 subsequent siblings)
  10 siblings, 0 replies; 15+ messages in thread
From: Dominik Csapak @ 2025-08-25  8:58 UTC (permalink / raw)
  To: pdm-devel

namely 'resource_type' and 'status'. All resources have those fields
in one way or another, so adding a helper that one does not have to
use `match` on every call site makes code there shorter.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 lib/pdm-api-types/src/resource.rs | 27 +++++++++++++++++++++++++++
 1 file changed, 27 insertions(+)

diff --git a/lib/pdm-api-types/src/resource.rs b/lib/pdm-api-types/src/resource.rs
index 6227855..0dfadd5 100644
--- a/lib/pdm-api-types/src/resource.rs
+++ b/lib/pdm-api-types/src/resource.rs
@@ -62,6 +62,33 @@ impl Resource {
             Resource::PbsDatastore(r) => r.name.as_str(),
         }
     }
+
+    pub fn resource_type(&self) -> &str {
+        match self {
+            Resource::PveStorage(_) => "storage",
+            Resource::PveQemu(_) => "qemu",
+            Resource::PveLxc(_) => "lxc",
+            Resource::PveNode(_) | Resource::PbsNode(_) => "node",
+            Resource::PbsDatastore(_) => "datastore",
+        }
+    }
+
+    pub fn status(&self) -> &str {
+        match self {
+            Resource::PveStorage(r) => r.status.as_str(),
+            Resource::PveQemu(r) => r.status.as_str(),
+            Resource::PveLxc(r) => r.status.as_str(),
+            Resource::PveNode(r) => r.status.as_str(),
+            Resource::PbsNode(r) => {
+                if r.uptime > 0 {
+                    "online"
+                } else {
+                    "offline"
+                }
+            }
+            Resource::PbsDatastore(_) => "online",
+        }
+    }
 }
 
 #[api(
-- 
2.47.2



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


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

* [pdm-devel] [PATCH datacenter-manager v2 2/9] lib: add pdm-search crate
  2025-08-25  8:58 [pdm-devel] [PATCH datacenter-manager v2 0/9] implement more complex search syntax Dominik Csapak
  2025-08-25  8:58 ` [pdm-devel] [PATCH datacenter-manager v2 1/9] pdm-api-types: resources: add helper methods for fields Dominik Csapak
@ 2025-08-25  8:58 ` Dominik Csapak
  2025-08-25 13:14   ` Stefan Hanreich
  2025-08-25  8:58 ` [pdm-devel] [PATCH datacenter-manager v2 3/9] server: api: resources: add more complex filter syntax Dominik Csapak
                   ` (8 subsequent siblings)
  10 siblings, 1 reply; 15+ messages in thread
From: Dominik Csapak @ 2025-08-25  8:58 UTC (permalink / raw)
  To: pdm-devel

Introduce a new create for search & filter related code. It currently
includes basic parsing & testing of search terms. Intended to be used on
some API calls that allow for more complex filters, such as the
resources API.

Contains a `SearchTerm` and a `Search` struct. The former represents
a single term to search for, with an optional category and if it's
optional or not. The latter represents a full search with multiple
terms.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 Cargo.toml                |   2 +
 lib/pdm-search/Cargo.toml |  12 ++
 lib/pdm-search/src/lib.rs | 259 ++++++++++++++++++++++++++++++++++++++
 3 files changed, 273 insertions(+)
 create mode 100644 lib/pdm-search/Cargo.toml
 create mode 100644 lib/pdm-search/src/lib.rs

diff --git a/Cargo.toml b/Cargo.toml
index 08b9373..236f00b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -19,6 +19,7 @@ members = [
     "lib/pdm-api-types",
     "lib/pdm-client",
     "lib/pdm-config",
+    "lib/pdm-search",
     "lib/pdm-ui-shared",
 
     "cli/client",
@@ -86,6 +87,7 @@ pdm-api-types = { path = "lib/pdm-api-types" }
 pdm-buildcfg = { path = "lib/pdm-buildcfg" }
 pdm-config = { path = "lib/pdm-config" }
 pdm-client = { version = "0.2", path = "lib/pdm-client" }
+pdm-search = { version = "0.2", path = "lib/pdm-search" }
 pdm-ui-shared = { version = "0.2", path = "lib/pdm-ui-shared" }
 proxmox-fido2 = { path = "cli/proxmox-fido2" }
 
diff --git a/lib/pdm-search/Cargo.toml b/lib/pdm-search/Cargo.toml
new file mode 100644
index 0000000..5f51e75
--- /dev/null
+++ b/lib/pdm-search/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "pdm-search"
+description = "Proxmox Datacenter Manager shared ui modules"
+homepage = "https://www.proxmox.com"
+
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+anyhow.workspace = true
diff --git a/lib/pdm-search/src/lib.rs b/lib/pdm-search/src/lib.rs
new file mode 100644
index 0000000..8d6cca3
--- /dev/null
+++ b/lib/pdm-search/src/lib.rs
@@ -0,0 +1,259 @@
+//! Abstraction over a [`Search`] that contains multiple [`SearchTerm`]s.
+//!
+//! Provides methods to filter an item over a combination of such terms and
+//! construct them from text, and serialize them back to text.
+use std::{fmt::Display, str::FromStr};
+
+use anyhow::bail;
+
+#[derive(Clone)]
+pub struct Search {
+    required_terms: Vec<SearchTerm>,
+    optional_terms: Vec<SearchTerm>,
+}
+
+impl<S: AsRef<str>> From<S> for Search {
+    fn from(value: S) -> Self {
+        let mut optional_terms = Vec::new();
+        let mut required_terms = Vec::new();
+        for term in value.as_ref().split_whitespace() {
+            match term.parse::<SearchTerm>() {
+                Ok(term) => {
+                    if term.optional {
+                        optional_terms.push(term)
+                    } else {
+                        required_terms.push(term)
+                    }
+                }
+                Err(_) => {} // ignore invalid search terms
+            }
+        }
+
+        Self {
+            required_terms,
+            optional_terms,
+        }
+    }
+}
+
+impl Search {
+    pub fn new() -> Self {
+        Self::with_terms(Vec::new())
+    }
+
+    pub fn is_empty(&self) -> bool {
+        self.required_terms.is_empty() && self.optional_terms.is_empty()
+    }
+
+    pub fn with_terms(terms: Vec<SearchTerm>) -> Self {
+        let mut optional_terms = Vec::new();
+        let mut required_terms = Vec::new();
+
+        for term in terms {
+            if term.optional {
+                optional_terms.push(term);
+            } else {
+                required_terms.push(term);
+            }
+        }
+
+        Self {
+            optional_terms,
+            required_terms,
+        }
+    }
+
+    /// Test if the given `Fn(&SearchTerm) -> bool` for all [`SearchTerm`] configured matches
+    ///
+    /// Returns true if it matches considering the constraints:
+    /// if there are no filters, returns true
+    pub fn matches<F: Fn(&SearchTerm) -> bool>(&self, matches: F) -> bool {
+        if self.is_empty() {
+            return true;
+        }
+
+        let optional_matches: Vec<bool> = self.optional_terms.iter().map(&matches).collect();
+        let required_matches: Vec<bool> = self.required_terms.iter().map(&matches).collect();
+
+        if !required_matches.is_empty() && required_matches.iter().any(|f| !f) {
+            return false;
+        }
+
+        if !optional_matches.is_empty() && optional_matches.iter().all(|f| !f) {
+            return false;
+        }
+
+        true
+    }
+
+    /// Returns true if the combination of [`SearchTerm`]s require that this category value must be
+    /// true. Useful to find out if some condition is required (e.g. type == 'remote')
+    pub fn category_value_required(&self, category: &str, value: &str) -> bool {
+        for term in &self.required_terms {
+            if term.category.as_deref() == Some(category) && value.contains(&term.value) {
+                return true;
+            }
+        }
+
+        let mut optional_count = 0;
+
+        for term in &self.optional_terms {
+            if term.category.as_deref() == Some(category) && term.value == value {
+                optional_count += 1;
+            }
+        }
+
+        self.required_terms.is_empty()
+            && self.optional_terms.len() == optional_count
+            && optional_count > 0
+    }
+}
+
+impl Default for Search {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl std::fmt::Display for Search {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        for (count, term) in self.required_terms.iter().enumerate() {
+            if count != 0 {
+                write!(f, " ")?;
+            }
+
+            write!(f, "{term}")?;
+        }
+
+        if !self.required_terms.is_empty() && !self.optional_terms.is_empty() {
+            write!(f, " ")?;
+        }
+
+        for (count, term) in self.optional_terms.iter().enumerate() {
+            if count != 0 {
+                write!(f, " ")?;
+            }
+
+            write!(f, "{term}")?;
+        }
+
+        Ok(())
+    }
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct SearchTerm {
+    optional: bool,
+    pub value: String,
+    pub category: Option<String>,
+}
+
+impl SearchTerm {
+    /// Creates a new [`SearchTerm`].
+    pub fn new<S: Into<String>>(term: S) -> Self {
+        Self {
+            value: term.into(),
+            optional: false,
+            category: None,
+        }
+    }
+
+    pub fn category<S: Into<String>>(mut self, category: Option<S>) -> Self {
+        self.category = category.map(|s| s.into());
+        self
+    }
+
+    pub fn optional(mut self, optional: bool) -> Self {
+        self.optional = optional;
+        self
+    }
+}
+
+impl FromStr for SearchTerm {
+    type Err = anyhow::Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let mut optional = true;
+        let mut term: String = s.into();
+        if term.starts_with("+") {
+            optional = false;
+            term.remove(0);
+        }
+
+        let (term, category) = if let Some(idx) = term.find(":") {
+            let mut real_term = term.split_off(idx);
+            real_term.remove(0); // remove ':'
+            (real_term, Some(term))
+        } else {
+            (term, None)
+        };
+
+        if term.is_empty() {
+            bail!("term cannot be empty");
+        }
+
+        if category == Some("".into()) {
+            bail!("category cannot be empty");
+        }
+
+        Ok(SearchTerm::new(term).optional(optional).category(category))
+    }
+}
+
+impl std::fmt::Display for SearchTerm {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        if !self.optional {
+            f.write_str("+")?;
+        }
+
+        if let Some(cat) = &self.category {
+            f.write_str(cat)?;
+            f.write_str(":")?;
+        }
+
+        f.write_str(&self.value)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::SearchTerm;
+
+    #[test]
+    fn parse_test_simple_filter() {
+        assert_eq!(
+            "foo".parse::<SearchTerm>().unwrap(),
+            SearchTerm::new("foo").optional(true),
+        );
+    }
+
+    #[test]
+    fn parse_test_requires_filter() {
+        assert_eq!(
+            "+foo".parse::<SearchTerm>().unwrap(),
+            SearchTerm::new("foo"),
+        );
+    }
+
+    #[test]
+    fn parse_test_category_filter() {
+        assert_eq!(
+            "foo:bar".parse::<SearchTerm>().unwrap(),
+            SearchTerm::new("bar")
+                .optional(true)
+                .category(Some("foo".into()))
+        );
+        assert_eq!(
+            "+foo:bar".parse::<SearchTerm>().unwrap(),
+            SearchTerm::new("bar").category(Some("foo".into()))
+        );
+    }
+
+    #[test]
+    fn parse_test_invalid_filter() {
+        assert!(":bar".parse::<SearchTerm>().is_err());
+        assert!("+cat:".parse::<SearchTerm>().is_err());
+        assert!("+".parse::<SearchTerm>().is_err());
+        assert!(":".parse::<SearchTerm>().is_err());
+    }
+}
-- 
2.47.2



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


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

* [pdm-devel] [PATCH datacenter-manager v2 3/9] server: api: resources: add more complex filter syntax
  2025-08-25  8:58 [pdm-devel] [PATCH datacenter-manager v2 0/9] implement more complex search syntax Dominik Csapak
  2025-08-25  8:58 ` [pdm-devel] [PATCH datacenter-manager v2 1/9] pdm-api-types: resources: add helper methods for fields Dominik Csapak
  2025-08-25  8:58 ` [pdm-devel] [PATCH datacenter-manager v2 2/9] lib: add pdm-search crate Dominik Csapak
@ 2025-08-25  8:58 ` Dominik Csapak
  2025-08-25 13:14   ` Stefan Hanreich
  2025-08-25  8:58 ` [pdm-devel] [PATCH datacenter-manager v2 4/9] ui: add possibility to insert into search box Dominik Csapak
                   ` (7 subsequent siblings)
  10 siblings, 1 reply; 15+ messages in thread
From: Dominik Csapak @ 2025-08-25  8:58 UTC (permalink / raw)
  To: pdm-devel

by using the new pdm-search crate for the resources api call.

We have to do 3 filter passes:
* one fast pass for remotes if the filter are constructed in a way that
  must filter to 'remote' (in this case we don't have to look at/return
  the resources at all, and can skip remotes that don't match)
* a pass for the resources
* a second pass for the remotes that check if they match for
  remote/non-remote mixed results

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 server/Cargo.toml           |  1 +
 server/src/api/resources.rs | 84 +++++++++++++++++++++++++++++++------
 2 files changed, 73 insertions(+), 12 deletions(-)

diff --git a/server/Cargo.toml b/server/Cargo.toml
index 24a2e40..ada7c80 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -74,6 +74,7 @@ proxmox-acme-api = { workspace = true, features = [ "impl" ] }
 pdm-api-types.workspace = true
 pdm-buildcfg.workspace = true
 pdm-config.workspace = true
+pdm-search.workspace = true
 
 pve-api-types = { workspace = true, features = [ "client" ] }
 pbs-api-types.workspace = true
diff --git a/server/src/api/resources.rs b/server/src/api/resources.rs
index 6a8c8ef..027298a 100644
--- a/server/src/api/resources.rs
+++ b/server/src/api/resources.rs
@@ -15,12 +15,13 @@ use pdm_api_types::subscription::{
     NodeSubscriptionInfo, RemoteSubscriptionState, RemoteSubscriptions, SubscriptionLevel,
 };
 use pdm_api_types::{Authid, PRIV_RESOURCE_AUDIT};
+use pdm_search::{Search, SearchTerm};
 use proxmox_access_control::CachedUserInfo;
 use proxmox_router::{
     http_bail, list_subdirs_api_method, Permission, Router, RpcEnvironment, SubdirMap,
 };
 use proxmox_rrd_api_types::RrdTimeframe;
-use proxmox_schema::api;
+use proxmox_schema::{api, parse_boolean};
 use proxmox_sortable_macro::sortable;
 use proxmox_subscription::SubscriptionStatus;
 use pve_api_types::{ClusterResource, ClusterResourceType};
@@ -45,6 +46,44 @@ const SUBDIRS: SubdirMap = &sorted!([
     ),
 ]);
 
+fn resource_matches_search_term(resource: &Resource, term: &SearchTerm) -> bool {
+    match &term.category {
+        Some(category) => match category.as_str() {
+            "type" => resource.resource_type().contains(&term.value),
+            "name" => resource.name().contains(&term.value),
+            "id" => resource.id().contains(&term.value),
+            "status" => resource.status().contains(&term.value),
+            "template" => match resource {
+                Resource::PveQemu(PveQemuResource { template, .. }, ..)
+                | Resource::PveLxc(PveLxcResource { template, .. }) => {
+                    match parse_boolean(&term.value) {
+                        Ok(boolean) => boolean == *template,
+                        Err(_) => false,
+                    }
+                }
+                _ => false,
+            },
+            "remote" => true, // this has to be checked beforehand
+            _ => false,
+        },
+        None => resource.name().contains(&term.value) || resource.id().contains(&term.value),
+    }
+}
+
+fn remote_matches_search_term(remote_name: &str, online: Option<bool>, term: &SearchTerm) -> bool {
+    match term.category.as_deref() {
+        Some("remote" | "name" | "id") => remote_name.contains(&term.value),
+        Some("type") => "remote".contains(&term.value),
+        Some("status") => match online {
+            None => true,
+            Some(true) => "online".contains(&term.value),
+            Some(false) => "offline".contains(&term.value),
+        },
+        None => remote_name.contains(&term.value) || "remote".contains(&term.value),
+        Some(_) => false,
+    }
+}
+
 #[api(
     // FIXME:: see list-like API calls in resource routers, we probably want more fine-grained
     // checks..
@@ -104,6 +143,14 @@ pub(crate) async fn get_resources_impl(
     let (remotes_config, _) = pdm_config::remotes::config()?;
     let mut join_handles = Vec::new();
 
+    let mut filters = Search::new();
+
+    if let Some(search) = &search {
+        filters = Search::from(search);
+    }
+
+    let remotes_only = filters.category_value_required("type", "remote");
+
     for (remote_name, remote) in remotes_config {
         if let Some(ref auth_id) = opt_auth_id {
             let remote_privs = user_info.lookup_privs(auth_id, &["resource", &remote_name]);
@@ -111,12 +158,27 @@ pub(crate) async fn get_resources_impl(
                 continue;
             }
         }
+
+        if remotes_only
+            && !filters.matches(|term| remote_matches_search_term(&remote_name, None, term))
+        {
+            continue;
+        }
+        let filter = filters.clone();
         let handle = tokio::spawn(async move {
-            let (resources, error) = match get_resources_for_remote(remote, max_age).await {
+            let (mut resources, error) = match get_resources_for_remote(remote, max_age).await {
                 Ok(resources) => (resources, None),
                 Err(error) => (Vec::new(), Some(error.to_string())),
             };
 
+            if remotes_only {
+                resources.clear();
+            } else if !filter.is_empty() {
+                resources.retain(|resource| {
+                    filter.matches(|filter| resource_matches_search_term(resource, filter))
+                });
+            }
+
             RemoteResources {
                 remote: remote_name,
                 resources,
@@ -132,17 +194,15 @@ pub(crate) async fn get_resources_impl(
         remote_resources.push(handle.await?);
     }
 
-    if let Some(search) = search {
-        // FIXME implement more complex filter syntax
-        remote_resources.retain_mut(|res| {
-            if res.remote.contains(&search) {
-                true
-            } else {
-                res.resources
-                    .retain(|res| res.id().contains(&search) || res.name().contains(&search));
-                !res.resources.is_empty()
+    if !filters.is_empty() {
+        remote_resources.retain(|res| {
+            if !res.resources.is_empty() {
+                return true;
             }
-        });
+            filters.matches(|filter| {
+                remote_matches_search_term(&res.remote, Some(res.error.is_none()), filter)
+            })
+        })
     }
 
     Ok(remote_resources)
-- 
2.47.2



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


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

* [pdm-devel] [PATCH datacenter-manager v2 4/9] ui: add possibility to insert into search box
  2025-08-25  8:58 [pdm-devel] [PATCH datacenter-manager v2 0/9] implement more complex search syntax Dominik Csapak
                   ` (2 preceding siblings ...)
  2025-08-25  8:58 ` [pdm-devel] [PATCH datacenter-manager v2 3/9] server: api: resources: add more complex filter syntax Dominik Csapak
@ 2025-08-25  8:58 ` Dominik Csapak
  2025-08-25  8:58 ` [pdm-devel] [PATCH datacenter-manager v2 5/9] ui: dashboard: remotes panel: open search on click Dominik Csapak
                   ` (6 subsequent siblings)
  10 siblings, 0 replies; 15+ messages in thread
From: Dominik Csapak @ 2025-08-25  8:58 UTC (permalink / raw)
  To: pdm-devel

by implementing a 'SearchProvider' context. This enables us to
insert a search term from everywhere. This can be helpful e.g. if we
want to prefill the search box with a specific pattern

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/Cargo.toml               |  1 +
 ui/src/lib.rs               |  3 +++
 ui/src/main.rs              | 17 ++++++++++++-----
 ui/src/search_provider.rs   | 35 +++++++++++++++++++++++++++++++++++
 ui/src/widget/search_box.rs | 26 +++++++++++++++++++++-----
 5 files changed, 72 insertions(+), 10 deletions(-)
 create mode 100644 ui/src/search_provider.rs

diff --git a/ui/Cargo.toml b/ui/Cargo.toml
index ef66020..4c48502 100644
--- a/ui/Cargo.toml
+++ b/ui/Cargo.toml
@@ -43,6 +43,7 @@ pbs-api-types = "1.0.3"
 pdm-api-types = { version = "0.2", path = "../lib/pdm-api-types" }
 pdm-ui-shared = { version = "0.2", path = "../lib/pdm-ui-shared" }
 pdm-client = { version = "0.2", path = "../lib/pdm-client" }
+pdm-search = { version = "0.2", path = "../lib/pdm-search" }
 
 [patch.crates-io]
 # proxmox-client = { path = "../../proxmox/proxmox-client" }
diff --git a/ui/src/lib.rs b/ui/src/lib.rs
index e3755ec..edb50f9 100644
--- a/ui/src/lib.rs
+++ b/ui/src/lib.rs
@@ -21,6 +21,9 @@ pub use remotes::RemoteConfigPanel;
 mod top_nav_bar;
 pub use top_nav_bar::TopNavBar;
 
+mod search_provider;
+pub use search_provider::SearchProvider;
+
 mod dashboard;
 pub use dashboard::Dashboard;
 use yew_router::prelude::RouterScopeExt;
diff --git a/ui/src/main.rs b/ui/src/main.rs
index 6e2c9b2..be0c10c 100644
--- a/ui/src/main.rs
+++ b/ui/src/main.rs
@@ -22,7 +22,7 @@ use proxmox_yew_comp::{
 
 //use pbs::MainMenu;
 use pdm_api_types::subscription::{RemoteSubscriptionState, RemoteSubscriptions};
-use pdm_ui::{register_pve_tasks, MainMenu, RemoteList, TopNavBar};
+use pdm_ui::{register_pve_tasks, MainMenu, RemoteList, SearchProvider, TopNavBar};
 
 type MsgRemoteList = Result<RemoteList, Error>;
 
@@ -46,6 +46,7 @@ struct DatacenterManagerApp {
     remote_list: RemoteList,
     remote_list_error: Option<String>,
     remote_list_timeout: Option<Timeout>,
+    search_provider: SearchProvider,
 }
 
 async fn check_subscription() -> Msg {
@@ -166,6 +167,7 @@ impl Component for DatacenterManagerApp {
             remote_list: Vec::new().into(),
             remote_list_error: None,
             remote_list_timeout: None,
+            search_provider: SearchProvider::new(),
         };
 
         this.on_login(ctx, false);
@@ -258,10 +260,15 @@ impl Component for DatacenterManagerApp {
             .with_optional_child(subscription_alert);
 
         let context = self.remote_list.clone();
-
-        DesktopApp::new(
-            html! {<ContextProvider<RemoteList> {context}>{body}</ContextProvider<RemoteList>>},
-        )
+        let search_context = self.search_provider.clone();
+
+        DesktopApp::new(html! {
+            <ContextProvider<SearchProvider> context={search_context}>
+                <ContextProvider<RemoteList> {context}>
+                    {body}
+                </ContextProvider<RemoteList>>
+            </ContextProvider<SearchProvider>>
+        })
         .into()
     }
 }
diff --git a/ui/src/search_provider.rs b/ui/src/search_provider.rs
new file mode 100644
index 0000000..441cc2b
--- /dev/null
+++ b/ui/src/search_provider.rs
@@ -0,0 +1,35 @@
+use yew::Callback;
+
+use pwt::state::{SharedState, SharedStateObserver};
+
+use pdm_search::Search;
+
+#[derive(Clone, PartialEq)]
+pub struct SearchProvider {
+    state: SharedState<String>,
+}
+
+impl SearchProvider {
+    pub fn new() -> Self {
+        Self {
+            state: SharedState::new("".into()),
+        }
+    }
+
+    pub fn add_listener(
+        &self,
+        cb: impl Into<Callback<SharedState<String>>>,
+    ) -> SharedStateObserver<String> {
+        self.state.add_listener(cb)
+    }
+
+    pub fn search(&self, search_term: Search) {
+        **self.state.write() = search_term.to_string();
+    }
+}
+
+pub fn get_search_provider<T: yew::Component>(ctx: &yew::Context<T>) -> Option<SearchProvider> {
+    let (provider, _context_listener) = ctx.link().context(Callback::from(|_| {}))?;
+
+    Some(provider)
+}
diff --git a/ui/src/widget/search_box.rs b/ui/src/widget/search_box.rs
index 0aeedb7..6b2478f 100644
--- a/ui/src/widget/search_box.rs
+++ b/ui/src/widget/search_box.rs
@@ -9,13 +9,15 @@ use yew::{
 };
 
 use pwt::{
-    dom::focus::FocusTracker,
-    dom::IntoHtmlElement,
+    dom::{focus::FocusTracker, IntoHtmlElement},
     prelude::*,
     props::CssLength,
+    state::{SharedState, SharedStateObserver},
     widget::{form::Field, Container},
 };
 
+use crate::search_provider::get_search_provider;
+
 use super::ResourceTree;
 
 #[derive(Properties, PartialEq)]
@@ -35,7 +37,7 @@ impl From<SearchBox> for VNode {
 }
 
 pub enum Msg {
-    ChangeTerm(String),
+    ChangeTerm(String, bool), // force value
     FocusChange(bool),
     ToggleFocus,
 }
@@ -48,6 +50,8 @@ pub struct PdmSearchBox {
     focus: bool,
     global_shortcut_listener: Closure<dyn Fn(KeyboardEvent)>,
     toggle_focus: bool,
+    _observer: Option<SharedStateObserver<String>>,
+    force_value: bool,
 }
 
 impl Component for PdmSearchBox {
@@ -57,6 +61,14 @@ impl Component for PdmSearchBox {
 
     fn create(ctx: &yew::Context<Self>) -> Self {
         let link = ctx.link().clone();
+        let _observer = get_search_provider(ctx).map(|search| {
+            search.add_listener(ctx.link().batch_callback(|value: SharedState<String>| {
+                vec![
+                    Msg::ToggleFocus,
+                    Msg::ChangeTerm(value.read().clone(), true),
+                ]
+            }))
+        });
         Self {
             search_field_ref: Default::default(),
             search_box_ref: Default::default(),
@@ -72,13 +84,16 @@ impl Component for PdmSearchBox {
                     _ => {}
                 }
             })),
+            _observer,
+            force_value: false,
         }
     }
 
     fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
         match msg {
-            Msg::ChangeTerm(term) => {
+            Msg::ChangeTerm(term, force_value) => {
                 self.search_term = term;
+                self.force_value = force_value;
                 true
             }
             Msg::FocusChange(focus) => {
@@ -122,7 +137,8 @@ impl Component for PdmSearchBox {
                 Field::new()
                     .placeholder(tr!("Search (Ctrl+Space / Ctrl+Shift+F)"))
                     .node_ref(self.search_field_ref.clone())
-                    .on_input(ctx.link().callback(Msg::ChangeTerm)),
+                    .value(self.force_value.then_some(self.search_term.clone()))
+                    .on_input(ctx.link().callback(|term| Msg::ChangeTerm(term, false))),
             )
             .with_child(search_result)
             .into()
-- 
2.47.2



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


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

* [pdm-devel] [PATCH datacenter-manager v2 5/9] ui: dashboard: remotes panel: open search on click
  2025-08-25  8:58 [pdm-devel] [PATCH datacenter-manager v2 0/9] implement more complex search syntax Dominik Csapak
                   ` (3 preceding siblings ...)
  2025-08-25  8:58 ` [pdm-devel] [PATCH datacenter-manager v2 4/9] ui: add possibility to insert into search box Dominik Csapak
@ 2025-08-25  8:58 ` Dominik Csapak
  2025-08-25  8:58 ` [pdm-devel] [PATCH datacenter-manager v2 6/9] ui: dashboard: guest panel: search for guest states when clicking on them Dominik Csapak
                   ` (5 subsequent siblings)
  10 siblings, 0 replies; 15+ messages in thread
From: Dominik Csapak @ 2025-08-25  8:58 UTC (permalink / raw)
  To: pdm-devel

using the SearchProvider, insert a search term into the search box to
find and show either all remotes (when healthy) or the failed ones.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/dashboard/remote_panel.rs | 32 +++++++++++++++++++++++++++++++-
 1 file changed, 31 insertions(+), 1 deletion(-)

diff --git a/ui/src/dashboard/remote_panel.rs b/ui/src/dashboard/remote_panel.rs
index 7471fb6..849b4d3 100644
--- a/ui/src/dashboard/remote_panel.rs
+++ b/ui/src/dashboard/remote_panel.rs
@@ -1,5 +1,6 @@
 use std::rc::Rc;
 
+use pdm_search::{Search, SearchTerm};
 use proxmox_yew_comp::Status;
 use pwt::{
     css,
@@ -14,6 +15,8 @@ use yew::{
 
 use pdm_api_types::resource::ResourcesStatus;
 
+use crate::search_provider::get_search_provider;
+
 #[derive(Properties, PartialEq)]
 /// A panel for showing the overall remotes status
 pub struct RemotePanel {
@@ -38,13 +41,20 @@ impl From<RemotePanel> for VNode {
 struct PdmRemotePanel {}
 
 impl Component for PdmRemotePanel {
-    type Message = &'static str;
+    type Message = Search;
     type Properties = RemotePanel;
 
     fn create(_ctx: &yew::Context<Self>) -> Self {
         Self {}
     }
 
+    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
+        if let Some(search) = get_search_provider(ctx) {
+            search.search(msg);
+        }
+        false
+    }
+
     fn view(&self, ctx: &yew::Context<Self>) -> yew::Html {
         let props = ctx.props();
         if props.status.is_none() {
@@ -77,6 +87,13 @@ impl Component for PdmRemotePanel {
         };
         Column::new()
             .tabindex(if failure { 0 } else { -1 })
+            .onclick(ctx.link().callback(move |_| create_search_term(failure)))
+            .onkeydown(ctx.link().batch_callback(move |event: KeyboardEvent| {
+                match event.key().as_str() {
+                    "Enter" | " " => Some(create_search_term(failure)),
+                    _ => None,
+                }
+            }))
             .padding(4)
             .class(css::FlexFit)
             .class(css::AlignItems::Center)
@@ -88,3 +105,16 @@ impl Component for PdmRemotePanel {
             .into()
     }
 }
+
+fn create_search_term(failure: bool) -> Search {
+    if failure {
+        Search::with_terms(vec![
+            SearchTerm::new("remote").category(Some("type")),
+            SearchTerm::new("offline").category(Some("status")),
+        ])
+    } else {
+        Search::with_terms(vec![SearchTerm::new("remote")
+            .optional(true)
+            .category(Some("type"))])
+    }
+}
-- 
2.47.2



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


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

* [pdm-devel] [PATCH datacenter-manager v2 6/9] ui: dashboard: guest panel: search for guest states when clicking on them
  2025-08-25  8:58 [pdm-devel] [PATCH datacenter-manager v2 0/9] implement more complex search syntax Dominik Csapak
                   ` (4 preceding siblings ...)
  2025-08-25  8:58 ` [pdm-devel] [PATCH datacenter-manager v2 5/9] ui: dashboard: remotes panel: open search on click Dominik Csapak
@ 2025-08-25  8:58 ` Dominik Csapak
  2025-08-25  8:58 ` [pdm-devel] [PATCH datacenter-manager v2 7/9] ui: dashboard: search for nodes when clicking on the nodes panel Dominik Csapak
                   ` (4 subsequent siblings)
  10 siblings, 0 replies; 15+ messages in thread
From: Dominik Csapak @ 2025-08-25  8:58 UTC (permalink / raw)
  To: pdm-devel

by using the SearchProvider, we create a fitting search term for the
clicked/selected type of guest.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/dashboard/guest_panel.rs | 84 +++++++++++++++++++++++++++++++--
 1 file changed, 80 insertions(+), 4 deletions(-)

diff --git a/ui/src/dashboard/guest_panel.rs b/ui/src/dashboard/guest_panel.rs
index 8d130c3..605aa5a 100644
--- a/ui/src/dashboard/guest_panel.rs
+++ b/ui/src/dashboard/guest_panel.rs
@@ -1,19 +1,26 @@
 use std::rc::Rc;
 
 use pdm_api_types::resource::GuestStatusCount;
+use pdm_search::{Search, SearchTerm};
 use proxmox_yew_comp::GuestState;
 use pwt::{
     prelude::*,
     props::ExtractPrimaryKey,
     state::Store,
     widget::{
-        data_table::{DataTable, DataTableColumn, DataTableHeader},
+        data_table::{
+            DataTable, DataTableColumn, DataTableHeader, DataTableKeyboardEvent,
+            DataTableMouseEvent, DataTableRowRenderArgs,
+        },
         Fa,
     },
 };
-use yew::virtual_dom::{VComp, VNode};
+use yew::{
+    virtual_dom::{Key, VComp, VNode},
+    Properties,
+};
 
-use crate::pve::GuestType;
+use crate::{pve::GuestType, search_provider::get_search_provider};
 
 use super::loading_column;
 
@@ -101,13 +108,20 @@ fn columns(guest_type: GuestType) -> Rc<Vec<DataTableHeader<StatusRow>>> {
 pub struct PdmGuestPanel {}
 
 impl yew::Component for PdmGuestPanel {
-    type Message = String;
+    type Message = Search;
     type Properties = GuestPanel;
 
     fn create(_ctx: &yew::Context<Self>) -> Self {
         Self {}
     }
 
+    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
+        if let Some(provider) = get_search_provider(ctx) {
+            provider.search(msg);
+        }
+        false
+    }
+
     fn view(&self, ctx: &yew::Context<Self>) -> yew::Html {
         let props = ctx.props();
         if props.status.is_none() {
@@ -137,7 +151,69 @@ impl yew::Component for PdmGuestPanel {
             .striped(false)
             .borderless(true)
             .bordered(false)
+            .row_render_callback(|renderer: &mut DataTableRowRenderArgs<StatusRow>| {
+                renderer.class.push("pwt-pointer");
+            })
+            .on_row_keydown({
+                let store = store.clone();
+                let link = ctx.link().clone();
+                move |event: &mut DataTableKeyboardEvent| match event.key().as_str() {
+                    " " | "Enter" => search_callback(&link, &store, guest_type, &event.record_key),
+                    _ => {}
+                }
+            })
+            .on_row_click({
+                let store = store.clone();
+                let link = ctx.link().clone();
+                move |event: &mut DataTableMouseEvent| {
+                    search_callback(&link, &store, guest_type, &event.record_key);
+                }
+            })
             .show_header(false)
             .into()
     }
 }
+
+fn search_callback(
+    link: &html::Scope<PdmGuestPanel>,
+    store: &Store<StatusRow>,
+    guest_type: GuestType,
+    key: &Key,
+) {
+    if let Some((_, record)) = store.filtered_data().find(|(_, rec)| rec.key() == *key) {
+        let (status, template) = match &*record.record() {
+            StatusRow::State(guest_state, _) => match guest_state {
+                GuestState::Running => (Some("running"), Some(false)),
+                GuestState::Paused => (Some("paused"), Some(false)),
+                GuestState::Stopped => (Some("stopped"), Some(false)),
+                GuestState::Template => (None, Some(true)),
+                GuestState::Unknown => (Some("unknown"), None),
+            },
+            StatusRow::All(_) => (None, None),
+        };
+
+        link.send_message(create_guest_search_term(guest_type, status, template));
+    }
+}
+
+fn create_guest_search_term(
+    guest_type: GuestType,
+    status: Option<&'static str>,
+    template: Option<bool>,
+) -> Search {
+    if status.is_none() && template.is_none() {
+        return Search::with_terms(vec![SearchTerm::new(guest_type.to_string())
+            .optional(true)
+            .category(Some("type"))]);
+    }
+
+    let mut terms = vec![SearchTerm::new(guest_type.to_string()).category(Some("type"))];
+
+    if let Some(template) = template {
+        terms.push(SearchTerm::new(template.to_string()).category(Some("template")));
+    }
+    if let Some(status) = status {
+        terms.push(SearchTerm::new(status).category(Some("status")));
+    }
+    Search::with_terms(terms)
+}
-- 
2.47.2



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


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

* [pdm-devel] [PATCH datacenter-manager v2 7/9] ui: dashboard: search for nodes when clicking on the nodes panel
  2025-08-25  8:58 [pdm-devel] [PATCH datacenter-manager v2 0/9] implement more complex search syntax Dominik Csapak
                   ` (5 preceding siblings ...)
  2025-08-25  8:58 ` [pdm-devel] [PATCH datacenter-manager v2 6/9] ui: dashboard: guest panel: search for guest states when clicking on them Dominik Csapak
@ 2025-08-25  8:58 ` Dominik Csapak
  2025-08-25  8:58 ` [pdm-devel] [PATCH datacenter-manager v2 8/9] ui: search box: add clear trigger Dominik Csapak
                   ` (3 subsequent siblings)
  10 siblings, 0 replies; 15+ messages in thread
From: Dominik Csapak @ 2025-08-25  8:58 UTC (permalink / raw)
  To: pdm-devel

similar to what we do for the remote panel

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/dashboard/mod.rs | 46 +++++++++++++++++++++++++++++++++++++----
 1 file changed, 42 insertions(+), 4 deletions(-)

diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs
index 84efb1b..1ac683b 100644
--- a/ui/src/dashboard/mod.rs
+++ b/ui/src/dashboard/mod.rs
@@ -26,9 +26,10 @@ use pwt::{
 
 use pdm_api_types::resource::{GuestStatusCount, NodeStatusCount, ResourcesStatus};
 use pdm_client::types::TopEntity;
+use pdm_search::{Search, SearchTerm};
 use proxmox_client::ApiResponseData;
 
-use crate::{pve::GuestType, remotes::AddWizard, RemoteList};
+use crate::{pve::GuestType, remotes::AddWizard, search_provider::get_search_provider, RemoteList};
 
 mod top_entities;
 pub use top_entities::TopEntities;
@@ -87,6 +88,7 @@ pub enum Msg {
     Reload,
     UpdateConfig(DashboardConfig),
     ConfigWindow(bool),
+    Search(Search),
 }
 
 pub struct PdmDashboard {
@@ -114,13 +116,25 @@ impl PdmDashboard {
             .into()
     }
 
-    fn create_node_panel(&self, icon: &str, title: String, status: &NodeStatusCount) -> Panel {
+    fn create_node_panel(
+        &self,
+        ctx: &yew::Context<Self>,
+        icon: &str,
+        title: String,
+        status: &NodeStatusCount,
+    ) -> Panel {
         let (status_icon, text): (Fa, String) = match status {
             NodeStatusCount {
-                online, offline, ..
+                online,
+                offline,
+                unknown,
             } if *offline > 0 => (
                 Status::Error.into(),
-                tr!("{0} of {1} nodes are offline", offline, online),
+                tr!(
+                    "{0} of {1} nodes are offline",
+                    offline,
+                    online + offline + unknown,
+                ),
             ),
             NodeStatusCount { unknown, .. } if *unknown > 0 => (
                 Status::Warning.into(),
@@ -143,6 +157,23 @@ impl PdmDashboard {
             .with_child(
                 Column::new()
                     .padding(4)
+                    .class("pwt-pointer")
+                    .onclick(ctx.link().callback(move |_| {
+                        Msg::Search(Search::with_terms(vec![
+                            SearchTerm::new("node").category(Some("type"))
+                        ]))
+                    }))
+                    .onkeydown(ctx.link().batch_callback(move |event: KeyboardEvent| {
+                        match event.key().as_str() {
+                            "Enter" | " " => {
+                                Some(Msg::Search(Search::with_terms(vec![SearchTerm::new(
+                                    "node",
+                                )
+                                .category(Some("type"))])))
+                            }
+                            _ => None,
+                        }
+                    }))
                     .class(FlexFit)
                     .class(AlignItems::Center)
                     .class(JustifyContent::Center)
@@ -290,6 +321,12 @@ impl Component for PdmDashboard {
                 self.show_config_window = false;
                 true
             }
+            Msg::Search(search_term) => {
+                if let Some(provider) = get_search_provider(ctx) {
+                    provider.search(search_term.into());
+                }
+                false
+            }
         }
     }
 
@@ -334,6 +371,7 @@ impl Component for PdmDashboard {
                             )),
                     )
                     .with_child(self.create_node_panel(
+                        ctx,
                         "building",
                         tr!("Virtual Environment Nodes"),
                         &self.status.pve_nodes,
-- 
2.47.2



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


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

* [pdm-devel] [PATCH datacenter-manager v2 8/9] ui: search box: add clear trigger
  2025-08-25  8:58 [pdm-devel] [PATCH datacenter-manager v2 0/9] implement more complex search syntax Dominik Csapak
                   ` (6 preceding siblings ...)
  2025-08-25  8:58 ` [pdm-devel] [PATCH datacenter-manager v2 7/9] ui: dashboard: search for nodes when clicking on the nodes panel Dominik Csapak
@ 2025-08-25  8:58 ` Dominik Csapak
  2025-08-25  8:58 ` [pdm-devel] [PATCH datacenter-manager v2 9/9] ui: dashboard: guest panel: improve column widths Dominik Csapak
                   ` (2 subsequent siblings)
  10 siblings, 0 replies; 15+ messages in thread
From: Dominik Csapak @ 2025-08-25  8:58 UTC (permalink / raw)
  To: pdm-devel

that removes the search term on click. We also need to set the
flex-basis for now, as otherwise the field 'wobbles' when showing or
hiding the trigger.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/widget/search_box.rs | 14 +++++++++++++-
 1 file changed, 13 insertions(+), 1 deletion(-)

diff --git a/ui/src/widget/search_box.rs b/ui/src/widget/search_box.rs
index 6b2478f..52bb156 100644
--- a/ui/src/widget/search_box.rs
+++ b/ui/src/widget/search_box.rs
@@ -13,7 +13,7 @@ use pwt::{
     prelude::*,
     props::CssLength,
     state::{SharedState, SharedStateObserver},
-    widget::{form::Field, Container},
+    widget::{form::Field, Container, Trigger},
 };
 
 use crate::search_provider::get_search_provider;
@@ -128,16 +128,28 @@ impl Component for PdmSearchBox {
             .height(400)
             .class("pwt-shadow2");
 
+        let clear_trigger_icon = if self.search_term.is_empty() {
+            ""
+        } else {
+            "fa fa-times"
+        };
+
         Container::new()
             .onfocusin(self.focus_tracker.get_focus_callback(true))
             .onfocusout(self.focus_tracker.get_focus_callback(false))
             .flex(2.0)
+            .style("flex-basis", "230px") // to avoid changing size with trigger
             .min_width(230) // placeholder text
             .with_child(
                 Field::new()
                     .placeholder(tr!("Search (Ctrl+Space / Ctrl+Shift+F)"))
                     .node_ref(self.search_field_ref.clone())
                     .value(self.force_value.then_some(self.search_term.clone()))
+                    .with_trigger(
+                        Trigger::new(clear_trigger_icon)
+                            .onclick(ctx.link().callback(|_| Msg::ChangeTerm("".into(), true))),
+                        true,
+                    )
                     .on_input(ctx.link().callback(|term| Msg::ChangeTerm(term, false))),
             )
             .with_child(search_result)
-- 
2.47.2



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


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

* [pdm-devel] [PATCH datacenter-manager v2 9/9] ui: dashboard: guest panel: improve column widths
  2025-08-25  8:58 [pdm-devel] [PATCH datacenter-manager v2 0/9] implement more complex search syntax Dominik Csapak
                   ` (7 preceding siblings ...)
  2025-08-25  8:58 ` [pdm-devel] [PATCH datacenter-manager v2 8/9] ui: search box: add clear trigger Dominik Csapak
@ 2025-08-25  8:58 ` Dominik Csapak
  2025-08-25 13:48 ` [pdm-devel] [PATCH datacenter-manager v2 0/9] implement more complex search syntax Stefan Hanreich
  2025-08-26 12:36 ` [pdm-devel] superseded: " Dominik Csapak
  10 siblings, 0 replies; 15+ messages in thread
From: Dominik Csapak @ 2025-08-25  8:58 UTC (permalink / raw)
  To: pdm-devel

Giving the text much more space than the numbers is a bit counter
productive here, since the text are fixed size, but the values can get
much wider if they're large enough. With both having the same space
(flex 1), it still is perfectly visible with the minimum width of the
component.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/dashboard/guest_panel.rs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/ui/src/dashboard/guest_panel.rs b/ui/src/dashboard/guest_panel.rs
index 605aa5a..a52a1c6 100644
--- a/ui/src/dashboard/guest_panel.rs
+++ b/ui/src/dashboard/guest_panel.rs
@@ -81,7 +81,7 @@ fn columns(guest_type: GuestType) -> Rc<Vec<DataTableHeader<StatusRow>>> {
             })
             .into(),
         DataTableColumn::new("text")
-            .flex(5)
+            .flex(1)
             .render(|item: &StatusRow| {
                 match item {
                     StatusRow::State(GuestState::Running, _) => tr!("running"),
-- 
2.47.2



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


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

* Re: [pdm-devel] [PATCH datacenter-manager v2 2/9] lib: add pdm-search crate
  2025-08-25  8:58 ` [pdm-devel] [PATCH datacenter-manager v2 2/9] lib: add pdm-search crate Dominik Csapak
@ 2025-08-25 13:14   ` Stefan Hanreich
  2025-08-26 12:23     ` Dominik Csapak
  0 siblings, 1 reply; 15+ messages in thread
From: Stefan Hanreich @ 2025-08-25 13:14 UTC (permalink / raw)
  To: Proxmox Datacenter Manager development discussion, Dominik Csapak

some comments inline, I think the FromIterator<Item = SearchTerm> would
be a nice improvement, the rest is just potential for future / follow-ups.

On 8/25/25 11:01 AM, Dominik Csapak wrote:
> Introduce a new create for search & filter related code. It currently
> includes basic parsing & testing of search terms. Intended to be used on
> some API calls that allow for more complex filters, such as the
> resources API.
> 
> Contains a `SearchTerm` and a `Search` struct. The former represents
> a single term to search for, with an optional category and if it's
> optional or not. The latter represents a full search with multiple
> terms.
> 
> Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
> ---
>  Cargo.toml                |   2 +
>  lib/pdm-search/Cargo.toml |  12 ++
>  lib/pdm-search/src/lib.rs | 259 ++++++++++++++++++++++++++++++++++++++
>  3 files changed, 273 insertions(+)
>  create mode 100644 lib/pdm-search/Cargo.toml
>  create mode 100644 lib/pdm-search/src/lib.rs
> 
> diff --git a/Cargo.toml b/Cargo.toml
> index 08b9373..236f00b 100644
> --- a/Cargo.toml
> +++ b/Cargo.toml
> @@ -19,6 +19,7 @@ members = [
>      "lib/pdm-api-types",
>      "lib/pdm-client",
>      "lib/pdm-config",
> +    "lib/pdm-search",
>      "lib/pdm-ui-shared",
>  
>      "cli/client",
> @@ -86,6 +87,7 @@ pdm-api-types = { path = "lib/pdm-api-types" }
>  pdm-buildcfg = { path = "lib/pdm-buildcfg" }
>  pdm-config = { path = "lib/pdm-config" }
>  pdm-client = { version = "0.2", path = "lib/pdm-client" }
> +pdm-search = { version = "0.2", path = "lib/pdm-search" }
>  pdm-ui-shared = { version = "0.2", path = "lib/pdm-ui-shared" }
>  proxmox-fido2 = { path = "cli/proxmox-fido2" }
>  
> diff --git a/lib/pdm-search/Cargo.toml b/lib/pdm-search/Cargo.toml
> new file mode 100644
> index 0000000..5f51e75
> --- /dev/null
> +++ b/lib/pdm-search/Cargo.toml
> @@ -0,0 +1,12 @@
> +[package]
> +name = "pdm-search"
> +description = "Proxmox Datacenter Manager shared ui modules"
> +homepage = "https://www.proxmox.com"
> +
> +version.workspace = true
> +edition.workspace = true
> +license.workspace = true
> +repository.workspace = true
> +
> +[dependencies]
> +anyhow.workspace = true
> diff --git a/lib/pdm-search/src/lib.rs b/lib/pdm-search/src/lib.rs
> new file mode 100644
> index 0000000..8d6cca3
> --- /dev/null
> +++ b/lib/pdm-search/src/lib.rs
> @@ -0,0 +1,259 @@
> +//! Abstraction over a [`Search`] that contains multiple [`SearchTerm`]s.
> +//!
> +//! Provides methods to filter an item over a combination of such terms and
> +//! construct them from text, and serialize them back to text.
> +use std::{fmt::Display, str::FromStr};
> +
> +use anyhow::bail;
> +
> +#[derive(Clone)]

implement Default as well?

> +pub struct Search {
> +    required_terms: Vec<SearchTerm>,
> +    optional_terms: Vec<SearchTerm>,
> +}
> +
> +impl<S: AsRef<str>> From<S> for Search {
> +    fn from(value: S) -> Self {
> +        let mut optional_terms = Vec::new();
> +        let mut required_terms = Vec::new();
> +        for term in value.as_ref().split_whitespace() {
> +            match term.parse::<SearchTerm>() {
> +                Ok(term) => {
> +                    if term.optional {
> +                        optional_terms.push(term)
> +                    } else {
> +                        required_terms.push(term)
> +                    }
> +                }
> +                Err(_) => {} // ignore invalid search terms
> +            }> +        }
> +
> +        Self {
> +            required_terms,
> +            optional_terms,
> +        }
> +    }
> +}

this implementation could use a potential FromIterator implementation
for Search, avoiding the duplicated code shared w/ with_terms?

i.e.

value.split_whitespace().filter_map(|term| term.parse().ok()).collect()

> +
> +impl Search {
> +    pub fn new() -> Self {
> +        Self::with_terms(Vec::new())
> +    }

use Default::default() here then? (see next mail for further reasoning).

> +
> +    pub fn is_empty(&self) -> bool {
> +        self.required_terms.is_empty() && self.optional_terms.is_empty()
> +    }
> +
> +    pub fn with_terms(terms: Vec<SearchTerm>) -> Self {
> +        let mut optional_terms = Vec::new();
> +        let mut required_terms = Vec::new();
> +
> +        for term in terms {
> +            if term.optional {
> +                optional_terms.push(term);
> +            } else {
> +                required_terms.push(term);
> +            }
> +        }

nit: partition could be used, although in the future there might be more
than required / optional.

> +
> +        Self {
> +            optional_terms,
> +            required_terms,
> +        }
> +    }

potentially moving this into a FromIterator<Item = SearchTerm>
implementation would be more appropriate?

Potentially a FromIterator<Item = (String, String)> or FromIterator<Item
= (String, Option<String>)> might also be interesting (with respective
From implementations for SearchTerm)?

From<SearchTerm> might also be interesting? or even a From<T: impl
Into<SearchTerm>>

> +
> +    /// Test if the given `Fn(&SearchTerm) -> bool` for all [`SearchTerm`] configured matches
> +    ///
> +    /// Returns true if it matches considering the constraints:
> +    /// if there are no filters, returns true
> +    pub fn matches<F: Fn(&SearchTerm) -> bool>(&self, matches: F) -> bool {
> +        if self.is_empty() {
> +            return true;
> +        }
> +
> +        let optional_matches: Vec<bool> = self.optional_terms.iter().map(&matches).collect();
> +        let required_matches: Vec<bool> = self.required_terms.iter().map(&matches).collect();
> +
> +        if !required_matches.is_empty() && required_matches.iter().any(|f| !f) {
> +            return false;
> +        }
> +
> +        if !optional_matches.is_empty() && optional_matches.iter().all(|f| !f) {
> +            return false;
> +        }
> +
> +        true
> +    }

nit: we could avoid collecting altogether and shortcircuit, removing the
need for calling is_empty twice as well?

if !self.optional_matches.is_empty() &&
optional_matches.iter().map(&matches).all(..)

for required_matches we don't even need the empty check since any is
always false for empty collections

> +
> +    /// Returns true if the combination of [`SearchTerm`]s require that this category value must be
> +    /// true. Useful to find out if some condition is required (e.g. type == 'remote')
> +    pub fn category_value_required(&self, category: &str, value: &str) -> bool {
> +        for term in &self.required_terms {
> +            if term.category.as_deref() == Some(category) && value.contains(&term.value) {
> +                return true;
> +            }
> +        }
> +
> +        let mut optional_count = 0;
> +
> +        for term in &self.optional_terms {
> +            if term.category.as_deref() == Some(category) && term.value == value {
> +                optional_count += 1;
> +            }
> +        }
> +
> +        self.required_terms.is_empty()
> +            && self.optional_terms.len() == optional_count
> +            && optional_count > 0
> +    }
> +}
> +
> +impl Default for Search {
> +    fn default() -> Self {
> +        Self::new()
> +    }
> +}
> +
> +impl std::fmt::Display for Search {
> +    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
> +        for (count, term) in self.required_terms.iter().enumerate() {
> +            if count != 0 {
> +                write!(f, " ")?;
> +            }
> +
> +            write!(f, "{term}")?;
> +        }
> +
> +        if !self.required_terms.is_empty() && !self.optional_terms.is_empty() {
> +            write!(f, " ")?;
> +        }
> +
> +        for (count, term) in self.optional_terms.iter().enumerate() {
> +            if count != 0 {
> +                write!(f, " ")?;
> +            }
> +
> +            write!(f, "{term}")?;
> +        }
> +
> +        Ok(())
> +    }
> +}
> +
> +#[derive(Debug, Clone, PartialEq)]
> +pub struct SearchTerm {
> +    optional: bool,
> +    pub value: String,
> +    pub category: Option<String>,
> +}
> +
> +impl SearchTerm {
> +    /// Creates a new [`SearchTerm`].
> +    pub fn new<S: Into<String>>(term: S) -> Self {

are spaces potentially a problem here?

> +        Self {
> +            value: term.into(),
> +            optional: false,
> +            category: None,
> +        }
> +    }
> +
> +    pub fn category<S: Into<String>>(mut self, category: Option<S>) -> Self {

same remark w.r.t. spaces

Is ToString potentially better as trait bound here, since I often see
.to_string() at the call sites?

I usually prefer impl Into<Option<>> for functions taking optional
values, since it avoids having to type out Some(_) everywhere, but in
this case with Into<String> this breaks type inference when passing None.

> +        self.category = category.map(|s| s.into());
> +        self
> +    }
> +
> +    pub fn optional(mut self, optional: bool) -> Self {
> +        self.optional = optional;
> +        self
> +    }
> +}
> +
> +impl FromStr for SearchTerm {
> +    type Err = anyhow::Error;
> +
> +    fn from_str(s: &str) -> Result<Self, Self::Err> {
> +        let mut optional = true;
> +        let mut term: String = s.into();
> +        if term.starts_with("+") {
> +            optional = false;
> +            term.remove(0);
> +        }
> +
> +        let (term, category) = if let Some(idx) = term.find(":") {
> +            let mut real_term = term.split_off(idx);
> +            real_term.remove(0); // remove ':'
> +            (real_term, Some(term))
> +        } else {
> +            (term, None)
> +        };
> +
> +        if term.is_empty() {
> +            bail!("term cannot be empty");
> +        }
> +
> +        if category == Some("".into()) {
> +            bail!("category cannot be empty");
> +        }
> +
> +        Ok(SearchTerm::new(term).optional(optional).category(category))
> +    }
> +}
> +
> +impl std::fmt::Display for SearchTerm {
> +    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
> +        if !self.optional {
> +            f.write_str("+")?;
> +        }
> +
> +        if let Some(cat) = &self.category {
> +            f.write_str(cat)?;
> +            f.write_str(":")?;
> +        }
> +
> +        f.write_str(&self.value)
> +    }
> +}
> +
> +#[cfg(test)]
> +mod tests {
> +    use crate::SearchTerm;
> +
> +    #[test]
> +    fn parse_test_simple_filter() {
> +        assert_eq!(
> +            "foo".parse::<SearchTerm>().unwrap(),
> +            SearchTerm::new("foo").optional(true),
> +        );
> +    }
> +
> +    #[test]
> +    fn parse_test_requires_filter() {
> +        assert_eq!(
> +            "+foo".parse::<SearchTerm>().unwrap(),
> +            SearchTerm::new("foo"),
> +        );
> +    }
> +
> +    #[test]
> +    fn parse_test_category_filter() {
> +        assert_eq!(
> +            "foo:bar".parse::<SearchTerm>().unwrap(),
> +            SearchTerm::new("bar")
> +                .optional(true)
> +                .category(Some("foo".into()))
> +        );
> +        assert_eq!(
> +            "+foo:bar".parse::<SearchTerm>().unwrap(),
> +            SearchTerm::new("bar").category(Some("foo".into()))
> +        );
> +    }
> +
> +    #[test]
> +    fn parse_test_invalid_filter() {
> +        assert!(":bar".parse::<SearchTerm>().is_err());
> +        assert!("+cat:".parse::<SearchTerm>().is_err());
> +        assert!("+".parse::<SearchTerm>().is_err());
> +        assert!(":".parse::<SearchTerm>().is_err());
> +    }
> +}



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


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

* Re: [pdm-devel] [PATCH datacenter-manager v2 3/9] server: api: resources: add more complex filter syntax
  2025-08-25  8:58 ` [pdm-devel] [PATCH datacenter-manager v2 3/9] server: api: resources: add more complex filter syntax Dominik Csapak
@ 2025-08-25 13:14   ` Stefan Hanreich
  0 siblings, 0 replies; 15+ messages in thread
From: Stefan Hanreich @ 2025-08-25 13:14 UTC (permalink / raw)
  To: Proxmox Datacenter Manager development discussion, Dominik Csapak

comments inline

On 8/25/25 11:00 AM, Dominik Csapak wrote:
> by using the new pdm-search crate for the resources api call.
> 
> We have to do 3 filter passes:
> * one fast pass for remotes if the filter are constructed in a way that
>   must filter to 'remote' (in this case we don't have to look at/return
>   the resources at all, and can skip remotes that don't match)
> * a pass for the resources
> * a second pass for the remotes that check if they match for
>   remote/non-remote mixed results
> 
> Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
> ---
>  server/Cargo.toml           |  1 +
>  server/src/api/resources.rs | 84 +++++++++++++++++++++++++++++++------
>  2 files changed, 73 insertions(+), 12 deletions(-)
> 
> diff --git a/server/Cargo.toml b/server/Cargo.toml
> index 24a2e40..ada7c80 100644
> --- a/server/Cargo.toml
> +++ b/server/Cargo.toml
> @@ -74,6 +74,7 @@ proxmox-acme-api = { workspace = true, features = [ "impl" ] }
>  pdm-api-types.workspace = true
>  pdm-buildcfg.workspace = true
>  pdm-config.workspace = true
> +pdm-search.workspace = true
>  
>  pve-api-types = { workspace = true, features = [ "client" ] }
>  pbs-api-types.workspace = true
> diff --git a/server/src/api/resources.rs b/server/src/api/resources.rs
> index 6a8c8ef..027298a 100644
> --- a/server/src/api/resources.rs
> +++ b/server/src/api/resources.rs
> @@ -15,12 +15,13 @@ use pdm_api_types::subscription::{
>      NodeSubscriptionInfo, RemoteSubscriptionState, RemoteSubscriptions, SubscriptionLevel,
>  };
>  use pdm_api_types::{Authid, PRIV_RESOURCE_AUDIT};
> +use pdm_search::{Search, SearchTerm};
>  use proxmox_access_control::CachedUserInfo;
>  use proxmox_router::{
>      http_bail, list_subdirs_api_method, Permission, Router, RpcEnvironment, SubdirMap,
>  };
>  use proxmox_rrd_api_types::RrdTimeframe;
> -use proxmox_schema::api;
> +use proxmox_schema::{api, parse_boolean};
>  use proxmox_sortable_macro::sortable;
>  use proxmox_subscription::SubscriptionStatus;
>  use pve_api_types::{ClusterResource, ClusterResourceType};
> @@ -45,6 +46,44 @@ const SUBDIRS: SubdirMap = &sorted!([
>      ),
>  ]);
>  
> +fn resource_matches_search_term(resource: &Resource, term: &SearchTerm) -> bool {
> +    match &term.category {
> +        Some(category) => match category.as_str() {
> +            "type" => resource.resource_type().contains(&term.value),
> +            "name" => resource.name().contains(&term.value),
> +            "id" => resource.id().contains(&term.value),
> +            "status" => resource.status().contains(&term.value),
> +            "template" => match resource {
> +                Resource::PveQemu(PveQemuResource { template, .. }, ..)
> +                | Resource::PveLxc(PveLxcResource { template, .. }) => {
> +                    match parse_boolean(&term.value) {
> +                        Ok(boolean) => boolean == *template,
> +                        Err(_) => false,
> +                    }
> +                }
> +                _ => false,
> +            },
> +            "remote" => true, // this has to be checked beforehand
> +            _ => false,
> +        },
> +        None => resource.name().contains(&term.value) || resource.id().contains(&term.value),
> +    }
> +}
> +
> +fn remote_matches_search_term(remote_name: &str, online: Option<bool>, term: &SearchTerm) -> bool {
> +    match term.category.as_deref() {
> +        Some("remote" | "name" | "id") => remote_name.contains(&term.value),
> +        Some("type") => "remote".contains(&term.value),
> +        Some("status") => match online {
> +            None => true,
> +            Some(true) => "online".contains(&term.value),
> +            Some(false) => "offline".contains(&term.value),
> +        },
> +        None => remote_name.contains(&term.value) || "remote".contains(&term.value),
> +        Some(_) => false,
> +    }
> +}
> +

I wonder, if in the future it would make sense to make SearchTerm
generic over SearchTerm<Category, Value> with String as default for
both. But currently that's probably a YAGNI.


>  #[api(
>      // FIXME:: see list-like API calls in resource routers, we probably want more fine-grained
>      // checks..
> @@ -104,6 +143,14 @@ pub(crate) async fn get_resources_impl(
>      let (remotes_config, _) = pdm_config::remotes::config()?;
>      let mut join_handles = Vec::new();
>  
> +    let mut filters = Search::new();
> +
> +    if let Some(search) = &search {
> +        filters = Search::from(search);
> +    }

nit: if we had a Default implementation we could do

let mut filters = search.map(Search::from).unwrap_or_default();

> +
> +    let remotes_only = filters.category_value_required("type", "remote");
> +
>      for (remote_name, remote) in remotes_config {
>          if let Some(ref auth_id) = opt_auth_id {
>              let remote_privs = user_info.lookup_privs(auth_id, &["resource", &remote_name]);
> @@ -111,12 +158,27 @@ pub(crate) async fn get_resources_impl(
>                  continue;
>              }
>          }
> +
> +        if remotes_only
> +            && !filters.matches(|term| remote_matches_search_term(&remote_name, None, term))
> +        {
> +            continue;
> +        }
> +        let filter = filters.clone();
>          let handle = tokio::spawn(async move {
> -            let (resources, error) = match get_resources_for_remote(remote, max_age).await {
> +            let (mut resources, error) = match get_resources_for_remote(remote, max_age).await {
>                  Ok(resources) => (resources, None),
>                  Err(error) => (Vec::new(), Some(error.to_string())),
>              };
>  
> +            if remotes_only {
> +                resources.clear();
> +            } else if !filter.is_empty() {
> +                resources.retain(|resource| {
> +                    filter.matches(|filter| resource_matches_search_term(resource, filter))
> +                });
> +            }
> +
>              RemoteResources {
>                  remote: remote_name,
>                  resources,
> @@ -132,17 +194,15 @@ pub(crate) async fn get_resources_impl(
>          remote_resources.push(handle.await?);
>      }
>  
> -    if let Some(search) = search {
> -        // FIXME implement more complex filter syntax
> -        remote_resources.retain_mut(|res| {
> -            if res.remote.contains(&search) {
> -                true
> -            } else {
> -                res.resources
> -                    .retain(|res| res.id().contains(&search) || res.name().contains(&search));
> -                !res.resources.is_empty()
> +    if !filters.is_empty() {
> +        remote_resources.retain(|res| {
> +            if !res.resources.is_empty() {
> +                return true;
>              }
> -        });
> +            filters.matches(|filter| {
> +                remote_matches_search_term(&res.remote, Some(res.error.is_none()), filter)
> +            })
> +        })
>      }
>  
>      Ok(remote_resources)



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


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

* Re: [pdm-devel] [PATCH datacenter-manager v2 0/9] implement more complex search syntax
  2025-08-25  8:58 [pdm-devel] [PATCH datacenter-manager v2 0/9] implement more complex search syntax Dominik Csapak
                   ` (8 preceding siblings ...)
  2025-08-25  8:58 ` [pdm-devel] [PATCH datacenter-manager v2 9/9] ui: dashboard: guest panel: improve column widths Dominik Csapak
@ 2025-08-25 13:48 ` Stefan Hanreich
  2025-08-26 12:36 ` [pdm-devel] superseded: " Dominik Csapak
  10 siblings, 0 replies; 15+ messages in thread
From: Stefan Hanreich @ 2025-08-25 13:48 UTC (permalink / raw)
  To: Proxmox Datacenter Manager development discussion, Dominik Csapak

Some comments as replies w.r.t. pdm-search public API. I think the
syntax for the search itself is fine. Maybe document somewhere that this
is (at least partly) inspired by [1] and therefore in turn [2]?


Consider the patches:

Reviewed-by: Stefan Hanreich <s.hanreich@proxmox.com>


I also gave this a quick spin on my PDM instance and tried some
potential edge-cases as well.

One minor thing:

If I e.g. click on "All" in the Virtual Machines panel (or Container or
Remotes), only one search term is inserted without the required flag.
If I wanted to drill it down further (e.g. by name), it might be
convenient to make this required as well, so it doesn't turn into an OR
search then.

Tested-by: Stefan Hanreich <s.hanreich@proxmox.com>


[1] https://docs.gitlab.com/user/search/advanced_search/
[2]
https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-simple-query-string-query

On 8/25/25 11:00 AM, Dominik Csapak wrote:
> this introduces a more complex search syntax for the resources api call
> and uses that with the dashboard to show relevant resources, e.g.
> when clicking on the remotes panel when there are failed remotes, it
> adds a search to the box that filters for offline remotes. Same
> for clicking on the running vm count, etc.
> 
> The syntax is a first draft of mine, we can still tweak and change it
> as we see fit, but it's a start.
> 
> a 'normal' search term gets filtered to id/name so that does not change
> as before, but you can now specify 'categories' with `category:value`
> e.g. it's now possible to search for `type:remote` or `status:offline`
> 
> it also adds the possibility to mark terms as required like this:
> 
> +someterm
> 
> required terms have to exist in the resulting resource, while optional
> ones are OR'd (so at least one optional match must exist)
> 
> Not implemented yet are (but can be done afterwards):
> * GUI for filtering
> * Include subscription status
> 
> changes from v1:
> * added commit to improve text width
> * added commit to add clear trigger to search box
> * rebased on master
> 
> Dominik Csapak (9):
>   pdm-api-types: resources: add helper methods for fields
>   lib: add pdm-search crate
>   server: api: resources: add more complex filter syntax
>   ui: add possibility to insert into search box
>   ui: dashboard: remotes panel: open search on click
>   ui: dashboard: guest panel: search for guest states when clicking on
>     them
>   ui: dashboard: search for nodes when clicking on the nodes panel
>   ui: search box: add clear trigger
>   ui: dashboard: guest panel: improve column widths
> 
>  Cargo.toml                        |   2 +
>  lib/pdm-api-types/src/resource.rs |  27 ++++
>  lib/pdm-search/Cargo.toml         |  12 ++
>  lib/pdm-search/src/lib.rs         | 259 ++++++++++++++++++++++++++++++
>  server/Cargo.toml                 |   1 +
>  server/src/api/resources.rs       |  84 ++++++++--
>  ui/Cargo.toml                     |   1 +
>  ui/src/dashboard/guest_panel.rs   |  86 +++++++++-
>  ui/src/dashboard/mod.rs           |  46 +++++-
>  ui/src/dashboard/remote_panel.rs  |  32 +++-
>  ui/src/lib.rs                     |   3 +
>  ui/src/main.rs                    |  17 +-
>  ui/src/search_provider.rs         |  35 ++++
>  ui/src/widget/search_box.rs       |  40 ++++-
>  14 files changed, 612 insertions(+), 33 deletions(-)
>  create mode 100644 lib/pdm-search/Cargo.toml
>  create mode 100644 lib/pdm-search/src/lib.rs
>  create mode 100644 ui/src/search_provider.rs
> 



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


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

* Re: [pdm-devel] [PATCH datacenter-manager v2 2/9] lib: add pdm-search crate
  2025-08-25 13:14   ` Stefan Hanreich
@ 2025-08-26 12:23     ` Dominik Csapak
  0 siblings, 0 replies; 15+ messages in thread
From: Dominik Csapak @ 2025-08-26 12:23 UTC (permalink / raw)
  To: Stefan Hanreich, Proxmox Datacenter Manager development discussion



On 8/25/25 3:14 PM, Stefan Hanreich wrote:
> some comments inline, I think the FromIterator<Item = SearchTerm> would
> be a nice improvement, the rest is just potential for future / follow-ups.
> 
> On 8/25/25 11:01 AM, Dominik Csapak wrote:
>> Introduce a new create for search & filter related code. It currently
>> includes basic parsing & testing of search terms. Intended to be used on
>> some API calls that allow for more complex filters, such as the
>> resources API.
>>
>> Contains a `SearchTerm` and a `Search` struct. The former represents
>> a single term to search for, with an optional category and if it's
>> optional or not. The latter represents a full search with multiple
>> terms.
>>
>> Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
>> ---
>>   Cargo.toml                |   2 +
>>   lib/pdm-search/Cargo.toml |  12 ++
>>   lib/pdm-search/src/lib.rs | 259 ++++++++++++++++++++++++++++++++++++++
>>   3 files changed, 273 insertions(+)
>>   create mode 100644 lib/pdm-search/Cargo.toml
>>   create mode 100644 lib/pdm-search/src/lib.rs
>>
>> diff --git a/Cargo.toml b/Cargo.toml
>> index 08b9373..236f00b 100644
>> --- a/Cargo.toml
>> +++ b/Cargo.toml
>> @@ -19,6 +19,7 @@ members = [
>>       "lib/pdm-api-types",
>>       "lib/pdm-client",
>>       "lib/pdm-config",
>> +    "lib/pdm-search",
>>       "lib/pdm-ui-shared",
>>   
>>       "cli/client",
>> @@ -86,6 +87,7 @@ pdm-api-types = { path = "lib/pdm-api-types" }
>>   pdm-buildcfg = { path = "lib/pdm-buildcfg" }
>>   pdm-config = { path = "lib/pdm-config" }
>>   pdm-client = { version = "0.2", path = "lib/pdm-client" }
>> +pdm-search = { version = "0.2", path = "lib/pdm-search" }
>>   pdm-ui-shared = { version = "0.2", path = "lib/pdm-ui-shared" }
>>   proxmox-fido2 = { path = "cli/proxmox-fido2" }
>>   
>> diff --git a/lib/pdm-search/Cargo.toml b/lib/pdm-search/Cargo.toml
>> new file mode 100644
>> index 0000000..5f51e75
>> --- /dev/null
>> +++ b/lib/pdm-search/Cargo.toml
>> @@ -0,0 +1,12 @@
>> +[package]
>> +name = "pdm-search"
>> +description = "Proxmox Datacenter Manager shared ui modules"
>> +homepage = "https://www.proxmox.com"
>> +
>> +version.workspace = true
>> +edition.workspace = true
>> +license.workspace = true
>> +repository.workspace = true
>> +
>> +[dependencies]
>> +anyhow.workspace = true
>> diff --git a/lib/pdm-search/src/lib.rs b/lib/pdm-search/src/lib.rs
>> new file mode 100644
>> index 0000000..8d6cca3
>> --- /dev/null
>> +++ b/lib/pdm-search/src/lib.rs
>> @@ -0,0 +1,259 @@
>> +//! Abstraction over a [`Search`] that contains multiple [`SearchTerm`]s.
>> +//!
>> +//! Provides methods to filter an item over a combination of such terms and
>> +//! construct them from text, and serialize them back to text.
>> +use std::{fmt::Display, str::FromStr};
>> +
>> +use anyhow::bail;
>> +
>> +#[derive(Clone)]
> 
> implement Default as well?
> 
>> +pub struct Search {
>> +    required_terms: Vec<SearchTerm>,
>> +    optional_terms: Vec<SearchTerm>,
>> +}
>> +
>> +impl<S: AsRef<str>> From<S> for Search {
>> +    fn from(value: S) -> Self {
>> +        let mut optional_terms = Vec::new();
>> +        let mut required_terms = Vec::new();
>> +        for term in value.as_ref().split_whitespace() {
>> +            match term.parse::<SearchTerm>() {
>> +                Ok(term) => {
>> +                    if term.optional {
>> +                        optional_terms.push(term)
>> +                    } else {
>> +                        required_terms.push(term)
>> +                    }
>> +                }
>> +                Err(_) => {} // ignore invalid search terms
>> +            }> +        }
>> +
>> +        Self {
>> +            required_terms,
>> +            optional_terms,
>> +        }
>> +    }
>> +}
> 
> this implementation could use a potential FromIterator implementation
> for Search, avoiding the duplicated code shared w/ with_terms?
> 
> i.e.
> 
> value.split_whitespace().filter_map(|term| term.parse().ok()).collect()
> 
>> +
>> +impl Search {
>> +    pub fn new() -> Self {
>> +        Self::with_terms(Vec::new())
>> +    }
> 
> use Default::default() here then? (see next mail for further reasoning).
> 
>> +
>> +    pub fn is_empty(&self) -> bool {
>> +        self.required_terms.is_empty() && self.optional_terms.is_empty()
>> +    }
>> +
>> +    pub fn with_terms(terms: Vec<SearchTerm>) -> Self {
>> +        let mut optional_terms = Vec::new();
>> +        let mut required_terms = Vec::new();
>> +
>> +        for term in terms {
>> +            if term.optional {
>> +                optional_terms.push(term);
>> +            } else {
>> +                required_terms.push(term);
>> +            }
>> +        }
> 
> nit: partition could be used, although in the future there might be more
> than required / optional.
> 
>> +
>> +        Self {
>> +            optional_terms,
>> +            required_terms,
>> +        }
>> +    }
> 
> potentially moving this into a FromIterator<Item = SearchTerm>
> implementation would be more appropriate?
> 
> Potentially a FromIterator<Item = (String, String)> or FromIterator<Item
> = (String, Option<String>)> might also be interesting (with respective
>  From implementations for SearchTerm)?
> 
> From<SearchTerm> might also be interesting? or even a From<T: impl
> Into<SearchTerm>>
> 
>> +
>> +    /// Test if the given `Fn(&SearchTerm) -> bool` for all [`SearchTerm`] configured matches
>> +    ///
>> +    /// Returns true if it matches considering the constraints:
>> +    /// if there are no filters, returns true
>> +    pub fn matches<F: Fn(&SearchTerm) -> bool>(&self, matches: F) -> bool {
>> +        if self.is_empty() {
>> +            return true;
>> +        }
>> +
>> +        let optional_matches: Vec<bool> = self.optional_terms.iter().map(&matches).collect();
>> +        let required_matches: Vec<bool> = self.required_terms.iter().map(&matches).collect();
>> +
>> +        if !required_matches.is_empty() && required_matches.iter().any(|f| !f) {
>> +            return false;
>> +        }
>> +
>> +        if !optional_matches.is_empty() && optional_matches.iter().all(|f| !f) {
>> +            return false;
>> +        }
>> +
>> +        true
>> +    }
> 
> nit: we could avoid collecting altogether and shortcircuit, removing the
> need for calling is_empty twice as well?
> 
> if !self.optional_matches.is_empty() &&
> optional_matches.iter().map(&matches).all(..)
> 
> for required_matches we don't even need the empty check since any is
> always false for empty collections
> 
>> +
>> +    /// Returns true if the combination of [`SearchTerm`]s require that this category value must be
>> +    /// true. Useful to find out if some condition is required (e.g. type == 'remote')
>> +    pub fn category_value_required(&self, category: &str, value: &str) -> bool {
>> +        for term in &self.required_terms {
>> +            if term.category.as_deref() == Some(category) && value.contains(&term.value) {
>> +                return true;
>> +            }
>> +        }
>> +
>> +        let mut optional_count = 0;
>> +
>> +        for term in &self.optional_terms {
>> +            if term.category.as_deref() == Some(category) && term.value == value {
>> +                optional_count += 1;
>> +            }
>> +        }
>> +
>> +        self.required_terms.is_empty()
>> +            && self.optional_terms.len() == optional_count
>> +            && optional_count > 0
>> +    }
>> +}
>> +
>> +impl Default for Search {
>> +    fn default() -> Self {
>> +        Self::new()
>> +    }
>> +}
>> +
>> +impl std::fmt::Display for Search {
>> +    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
>> +        for (count, term) in self.required_terms.iter().enumerate() {
>> +            if count != 0 {
>> +                write!(f, " ")?;
>> +            }
>> +
>> +            write!(f, "{term}")?;
>> +        }
>> +
>> +        if !self.required_terms.is_empty() && !self.optional_terms.is_empty() {
>> +            write!(f, " ")?;
>> +        }
>> +
>> +        for (count, term) in self.optional_terms.iter().enumerate() {
>> +            if count != 0 {
>> +                write!(f, " ")?;
>> +            }
>> +
>> +            write!(f, "{term}")?;
>> +        }
>> +
>> +        Ok(())
>> +    }
>> +}
>> +
>> +#[derive(Debug, Clone, PartialEq)]
>> +pub struct SearchTerm {
>> +    optional: bool,
>> +    pub value: String,
>> +    pub category: Option<String>,
>> +}
>> +
>> +impl SearchTerm {
>> +    /// Creates a new [`SearchTerm`].
>> +    pub fn new<S: Into<String>>(term: S) -> Self {
> 
> are spaces potentially a problem here?
> 

since we use 'split_whitespace' when we convert from the search
string to the terms, it shouldn't be?

isn't be able to generate a SearchTerm directly, so I don't
see how it's currently a problem

>> +        Self {
>> +            value: term.into(),
>> +            optional: false,
>> +            category: None,
>> +        }
>> +    }
>> +
>> +    pub fn category<S: Into<String>>(mut self, category: Option<S>) -> Self {
> 
> same remark w.r.t. spaces
> 
> Is ToString potentially better as trait bound here, since I often see
> .to_string() at the call sites?
> 
> I usually prefer impl Into<Option<>> for functions taking optional
> values, since it avoids having to type out Some(_) everywhere, but in
> this case with Into<String> this breaks type inference when passing None.

yeah Into<Option> does not work for a trait type (without manually 
specifying the type when using none

but yeah, i can try ToString, i wanted to avoid the extra copying in 
case we can convert the object into a string

> 
>> +        self.category = category.map(|s| s.into());
>> +        self
>> +    }
>> +
>> +    pub fn optional(mut self, optional: bool) -> Self {
>> +        self.optional = optional;
>> +        self
>> +    }
>> +}
>> +
>> +impl FromStr for SearchTerm {
>> +    type Err = anyhow::Error;
>> +
>> +    fn from_str(s: &str) -> Result<Self, Self::Err> {
>> +        let mut optional = true;
>> +        let mut term: String = s.into();
>> +        if term.starts_with("+") {
>> +            optional = false;
>> +            term.remove(0);
>> +        }
>> +
>> +        let (term, category) = if let Some(idx) = term.find(":") {
>> +            let mut real_term = term.split_off(idx);
>> +            real_term.remove(0); // remove ':'
>> +            (real_term, Some(term))
>> +        } else {
>> +            (term, None)
>> +        };
>> +
>> +        if term.is_empty() {
>> +            bail!("term cannot be empty");
>> +        }
>> +
>> +        if category == Some("".into()) {
>> +            bail!("category cannot be empty");
>> +        }
>> +
>> +        Ok(SearchTerm::new(term).optional(optional).category(category))
>> +    }
>> +}
>> +
>> +impl std::fmt::Display for SearchTerm {
>> +    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
>> +        if !self.optional {
>> +            f.write_str("+")?;
>> +        }
>> +
>> +        if let Some(cat) = &self.category {
>> +            f.write_str(cat)?;
>> +            f.write_str(":")?;
>> +        }
>> +
>> +        f.write_str(&self.value)
>> +    }
>> +}
>> +
>> +#[cfg(test)]
>> +mod tests {
>> +    use crate::SearchTerm;
>> +
>> +    #[test]
>> +    fn parse_test_simple_filter() {
>> +        assert_eq!(
>> +            "foo".parse::<SearchTerm>().unwrap(),
>> +            SearchTerm::new("foo").optional(true),
>> +        );
>> +    }
>> +
>> +    #[test]
>> +    fn parse_test_requires_filter() {
>> +        assert_eq!(
>> +            "+foo".parse::<SearchTerm>().unwrap(),
>> +            SearchTerm::new("foo"),
>> +        );
>> +    }
>> +
>> +    #[test]
>> +    fn parse_test_category_filter() {
>> +        assert_eq!(
>> +            "foo:bar".parse::<SearchTerm>().unwrap(),
>> +            SearchTerm::new("bar")
>> +                .optional(true)
>> +                .category(Some("foo".into()))
>> +        );
>> +        assert_eq!(
>> +            "+foo:bar".parse::<SearchTerm>().unwrap(),
>> +            SearchTerm::new("bar").category(Some("foo".into()))
>> +        );
>> +    }
>> +
>> +    #[test]
>> +    fn parse_test_invalid_filter() {
>> +        assert!(":bar".parse::<SearchTerm>().is_err());
>> +        assert!("+cat:".parse::<SearchTerm>().is_err());
>> +        assert!("+".parse::<SearchTerm>().is_err());
>> +        assert!(":".parse::<SearchTerm>().is_err());
>> +    }
>> +}
> 
> 



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


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

* [pdm-devel] superseded: [PATCH datacenter-manager v2 0/9] implement more complex search syntax
  2025-08-25  8:58 [pdm-devel] [PATCH datacenter-manager v2 0/9] implement more complex search syntax Dominik Csapak
                   ` (9 preceding siblings ...)
  2025-08-25 13:48 ` [pdm-devel] [PATCH datacenter-manager v2 0/9] implement more complex search syntax Stefan Hanreich
@ 2025-08-26 12:36 ` Dominik Csapak
  10 siblings, 0 replies; 15+ messages in thread
From: Dominik Csapak @ 2025-08-26 12:36 UTC (permalink / raw)
  To: pdm-devel

superseded by v3:
https://lore.proxmox.com/pdm-devel/20250826123336.3970108-1-d.csapak@proxmox.com/


_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


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

end of thread, other threads:[~2025-08-26 12:36 UTC | newest]

Thread overview: 15+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2025-08-25  8:58 [pdm-devel] [PATCH datacenter-manager v2 0/9] implement more complex search syntax Dominik Csapak
2025-08-25  8:58 ` [pdm-devel] [PATCH datacenter-manager v2 1/9] pdm-api-types: resources: add helper methods for fields Dominik Csapak
2025-08-25  8:58 ` [pdm-devel] [PATCH datacenter-manager v2 2/9] lib: add pdm-search crate Dominik Csapak
2025-08-25 13:14   ` Stefan Hanreich
2025-08-26 12:23     ` Dominik Csapak
2025-08-25  8:58 ` [pdm-devel] [PATCH datacenter-manager v2 3/9] server: api: resources: add more complex filter syntax Dominik Csapak
2025-08-25 13:14   ` Stefan Hanreich
2025-08-25  8:58 ` [pdm-devel] [PATCH datacenter-manager v2 4/9] ui: add possibility to insert into search box Dominik Csapak
2025-08-25  8:58 ` [pdm-devel] [PATCH datacenter-manager v2 5/9] ui: dashboard: remotes panel: open search on click Dominik Csapak
2025-08-25  8:58 ` [pdm-devel] [PATCH datacenter-manager v2 6/9] ui: dashboard: guest panel: search for guest states when clicking on them Dominik Csapak
2025-08-25  8:58 ` [pdm-devel] [PATCH datacenter-manager v2 7/9] ui: dashboard: search for nodes when clicking on the nodes panel Dominik Csapak
2025-08-25  8:58 ` [pdm-devel] [PATCH datacenter-manager v2 8/9] ui: search box: add clear trigger Dominik Csapak
2025-08-25  8:58 ` [pdm-devel] [PATCH datacenter-manager v2 9/9] ui: dashboard: guest panel: improve column widths Dominik Csapak
2025-08-25 13:48 ` [pdm-devel] [PATCH datacenter-manager v2 0/9] implement more complex search syntax Stefan Hanreich
2025-08-26 12:36 ` [pdm-devel] superseded: " Dominik Csapak

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