* [pdm-devel] [PATCH datacenter-manager 1/7] pdm-api-types: resources: add helper methods for fields
2025-04-16 11:49 [pdm-devel] [PATCH datacenter-manager 0/7] implement more complex search syntax Dominik Csapak
@ 2025-04-16 11:49 ` Dominik Csapak
2025-04-16 11:49 ` [pdm-devel] [PATCH datacenter-manager 2/7] lib: add pdm-search crate Dominik Csapak
` (5 subsequent siblings)
6 siblings, 0 replies; 8+ messages in thread
From: Dominik Csapak @ 2025-04-16 11:49 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.39.5
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply [flat|nested] 8+ messages in thread
* [pdm-devel] [PATCH datacenter-manager 2/7] lib: add pdm-search crate
2025-04-16 11:49 [pdm-devel] [PATCH datacenter-manager 0/7] implement more complex search syntax Dominik Csapak
2025-04-16 11:49 ` [pdm-devel] [PATCH datacenter-manager 1/7] pdm-api-types: resources: add helper methods for fields Dominik Csapak
@ 2025-04-16 11:49 ` Dominik Csapak
2025-04-16 11:49 ` [pdm-devel] [PATCH datacenter-manager 3/7] server: api: resources: add more complex filter syntax Dominik Csapak
` (4 subsequent siblings)
6 siblings, 0 replies; 8+ messages in thread
From: Dominik Csapak @ 2025-04-16 11:49 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 6e16831..2f544fa 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",
@@ -85,6 +86,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.1", path = "lib/pdm-client" }
+pdm-search = { version = "0.1", path = "lib/pdm-search" }
pdm-ui-shared = { version = "0.1", 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.39.5
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply [flat|nested] 8+ messages in thread
* [pdm-devel] [PATCH datacenter-manager 3/7] server: api: resources: add more complex filter syntax
2025-04-16 11:49 [pdm-devel] [PATCH datacenter-manager 0/7] implement more complex search syntax Dominik Csapak
2025-04-16 11:49 ` [pdm-devel] [PATCH datacenter-manager 1/7] pdm-api-types: resources: add helper methods for fields Dominik Csapak
2025-04-16 11:49 ` [pdm-devel] [PATCH datacenter-manager 2/7] lib: add pdm-search crate Dominik Csapak
@ 2025-04-16 11:49 ` Dominik Csapak
2025-04-16 11:49 ` [pdm-devel] [PATCH datacenter-manager 4/7] ui: add possibility to insert into search box Dominik Csapak
` (3 subsequent siblings)
6 siblings, 0 replies; 8+ messages in thread
From: Dominik Csapak @ 2025-04-16 11:49 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 7b0058e..59d68c7 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -71,6 +71,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 ddcee7e..f3d9175 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.39.5
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply [flat|nested] 8+ messages in thread
* [pdm-devel] [PATCH datacenter-manager 4/7] ui: add possibility to insert into search box
2025-04-16 11:49 [pdm-devel] [PATCH datacenter-manager 0/7] implement more complex search syntax Dominik Csapak
` (2 preceding siblings ...)
2025-04-16 11:49 ` [pdm-devel] [PATCH datacenter-manager 3/7] server: api: resources: add more complex filter syntax Dominik Csapak
@ 2025-04-16 11:49 ` Dominik Csapak
2025-04-16 11:49 ` [pdm-devel] [PATCH datacenter-manager 5/7] ui: dashboard: remotes panel: open search on click Dominik Csapak
` (2 subsequent siblings)
6 siblings, 0 replies; 8+ messages in thread
From: Dominik Csapak @ 2025-04-16 11:49 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 10345a2..96fd07e 100644
--- a/ui/Cargo.toml
+++ b/ui/Cargo.toml
@@ -42,6 +42,7 @@ pbs-api-types = "0.2.0"
pdm-api-types = { version = "0.1", path = "../lib/pdm-api-types" }
pdm-ui-shared = { version = "0.1", path = "../lib/pdm-ui-shared" }
pdm-client = { version = "0.1", path = "../lib/pdm-client" }
+pdm-search = { version = "0.1", 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.39.5
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply [flat|nested] 8+ messages in thread
* [pdm-devel] [PATCH datacenter-manager 5/7] ui: dashboard: remotes panel: open search on click
2025-04-16 11:49 [pdm-devel] [PATCH datacenter-manager 0/7] implement more complex search syntax Dominik Csapak
` (3 preceding siblings ...)
2025-04-16 11:49 ` [pdm-devel] [PATCH datacenter-manager 4/7] ui: add possibility to insert into search box Dominik Csapak
@ 2025-04-16 11:49 ` Dominik Csapak
2025-04-16 11:49 ` [pdm-devel] [PATCH datacenter-manager 6/7] ui: dashboard: guest panel: search for guest states when clicking on them Dominik Csapak
2025-04-16 11:49 ` [pdm-devel] [PATCH datacenter-manager 7/7] ui: dashboard: search for nodes when clicking on the nodes panel Dominik Csapak
6 siblings, 0 replies; 8+ messages in thread
From: Dominik Csapak @ 2025-04-16 11:49 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 2c1dd75..05119db 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.39.5
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply [flat|nested] 8+ messages in thread
* [pdm-devel] [PATCH datacenter-manager 6/7] ui: dashboard: guest panel: search for guest states when clicking on them
2025-04-16 11:49 [pdm-devel] [PATCH datacenter-manager 0/7] implement more complex search syntax Dominik Csapak
` (4 preceding siblings ...)
2025-04-16 11:49 ` [pdm-devel] [PATCH datacenter-manager 5/7] ui: dashboard: remotes panel: open search on click Dominik Csapak
@ 2025-04-16 11:49 ` Dominik Csapak
2025-04-16 11:49 ` [pdm-devel] [PATCH datacenter-manager 7/7] ui: dashboard: search for nodes when clicking on the nodes panel Dominik Csapak
6 siblings, 0 replies; 8+ messages in thread
From: Dominik Csapak @ 2025-04-16 11:49 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 d885056..9f58da4 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.39.5
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply [flat|nested] 8+ messages in thread
* [pdm-devel] [PATCH datacenter-manager 7/7] ui: dashboard: search for nodes when clicking on the nodes panel
2025-04-16 11:49 [pdm-devel] [PATCH datacenter-manager 0/7] implement more complex search syntax Dominik Csapak
` (5 preceding siblings ...)
2025-04-16 11:49 ` [pdm-devel] [PATCH datacenter-manager 6/7] ui: dashboard: guest panel: search for guest states when clicking on them Dominik Csapak
@ 2025-04-16 11:49 ` Dominik Csapak
6 siblings, 0 replies; 8+ messages in thread
From: Dominik Csapak @ 2025-04-16 11:49 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 | 48 ++++++++++++++++++++++++++++++++++++-----
1 file changed, 43 insertions(+), 5 deletions(-)
diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs
index 7b7ec81..8e964b5 100644
--- a/ui/src/dashboard/mod.rs
+++ b/ui/src/dashboard/mod.rs
@@ -17,8 +17,9 @@ use pwt::{
use pdm_api_types::resource::{GuestStatusCount, NodeStatusCount, ResourcesStatus};
use pdm_client::types::TopEntity;
+use pdm_search::{Search, SearchTerm};
-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;
@@ -56,6 +57,7 @@ pub enum Msg {
TopEntitiesLoadResult(Result<pdm_client::types::TopEntities, proxmox_client::Error>),
RemoteListChanged(RemoteList),
CreateWizard(bool),
+ Search(Search),
}
pub struct PdmDashboard {
@@ -80,13 +82,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) = match status {
NodeStatusCount {
- online, offline, ..
+ online,
+ offline,
+ unknown,
} if *offline > 0 => (
Status::Error.to_fa_icon(),
- 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.to_fa_icon(),
@@ -110,6 +124,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)
@@ -203,7 +234,7 @@ impl Component for PdmDashboard {
}
}
- fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
+ fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Msg::LoadingFinished(resources_status) => {
match resources_status {
@@ -235,6 +266,12 @@ impl Component for PdmDashboard {
self.show_wizard = show;
true
}
+ Msg::Search(search_term) => {
+ if let Some(provider) = get_search_provider(ctx) {
+ provider.search(search_term.into());
+ }
+ false
+ }
}
}
@@ -263,6 +300,7 @@ impl Component for PdmDashboard {
)),
)
.with_child(self.create_node_panel(
+ ctx,
"building",
tr!("Virtual Environment Nodes"),
&self.status.pve_nodes,
--
2.39.5
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply [flat|nested] 8+ messages in thread