public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [pdm-devel] [PATCH datacenter-manager v5 00/26] enable custom views on the UI
@ 2025-11-26 15:17 Dominik Csapak
  2025-11-26 15:17 ` [pdm-devel] [PATCH datacenter-manager v5 01/26] lib: pdm-config: views: add locking/saving methods Dominik Csapak
                   ` (27 more replies)
  0 siblings, 28 replies; 29+ messages in thread
From: Dominik Csapak @ 2025-11-26 15:17 UTC (permalink / raw)
  To: pdm-devel

With this series, users are able to add/edit/delete custom views.

NOTE: this series is based on lukas follow up series to the views[0]
but needs the change i mentioned in [1] to completely work.

A new 'Views' menu entry is added where the CRUD ui sits, and an entry
for each view below it.

The includes/excludes are editable from the CRUD grid, and the layout is
editable in the view itself.

For now i opted to make the layout part of the `ViewConfig` struct, but
as a simple json-string only, because we're not able to use the
api-macro (for now; the rust enum features i use are currently not
supported there). To verify it regardless, we try to deserialize
the layout in the add/update call, so that no wrong layout can enter
the config (via the api).

The patch 'ui: dashboard: prepare view for editint custom views'
is adapted from my last series, but all other changes are new,
especially the CRUD api.

There is still one part wrong, namely
* subscriptions panels are still added by default when none is in the
  layout itself (the plan is to move the subscription notice)

changes from v4:
* add `+view:<VIEW>` when clicking on a searchable item from within
  the view
* adapt to lukas' changes regarding filters (so include-all, etc.)
* add the ability to add a 'resource tree' to a custom view, which
  is basically the tree from the global search but limited for the
  specific view
* some general improvements to the resource tree

changelogs in the relevant patches,and patches 19-26 are new

chnages from v3:
* incorporated most of Shannon's feedback (Thanks!)
  see the individual patches for changelog
* rebase on master

changes from v2:
* fixed 'verbose' property for subscriptions api call
* adapted to PveSdnZone -> PveNetwork renaming
* added validators for filter fields
  (so that the user sees it's invalid)

changes from v1:
* rebase on master, lukas v5 was applied

0: https://lore.proxmox.com/pdm-devel/20251117141122.328559-1-l.wagner@proxmox.com/
1: https://lore.proxmox.com/pdm-devel/ae0e2d08-cc13-4c40-a1b3-802136a58ed4@proxmox.com/

Dominik Csapak (26):
  lib: pdm-config: views: add locking/saving methods
  lib: api-types: add 'layout' property to ViewConfig
  server: api: implement CRUD api for views
  server: api: resources: add 'view' category to search syntax
  ui: remote selector: allow forcing of value
  ui: dashboard types: add missing 'default's to de-serialization
  ui: dashboard: status row: add optional 'editing state'
  ui: dashboard: prepare view for editing custom views
  ui: views: implement view loading from api
  ui: views: make 'view' name property optional
  ui: views: add 'view' parameter to api calls
  ui: views: save updated layout to backend
  ui: add view list context
  ui: configuration: add view CRUD panels
  ui: main menu: add optional view_list property
  ui: load view list on page init
  lib/ui: move views types to pdm-api-types
  server: api: views: check layout string for validity
  ui: dashboard: add current view to search terms
  ui: resource tree: fix loading logic
  ui: resource tree: move error message into first column
  ui: resource tree: use `ViewContext` to limit the api calls to a view
  ui: resource tree: show guest tags
  api-types/ui: add ResourceTree variant for WidgetType
  ui: dashboard view: refactor widget rendering arguments into struct
  ui: resource tree/view: reload tree in a view on refresh

 lib/pdm-api-types/src/resource.rs        |   7 +
 lib/pdm-api-types/src/views.rs           |  91 +++-
 lib/pdm-config/src/views.rs              |  22 +-
 lib/pdm-search/src/lib.rs                |   9 +
 server/src/api/config/mod.rs             |   2 +
 server/src/api/config/views.rs           | 274 ++++++++++++
 server/src/api/resources.rs              |  19 +-
 server/src/views/mod.rs                  |   2 +-
 ui/Cargo.toml                            |   2 +-
 ui/css/pdm.scss                          |   4 +
 ui/src/configuration/mod.rs              |   2 +
 ui/src/configuration/views.rs            | 370 ++++++++++++++++
 ui/src/dashboard/guest_panel.rs          |   4 +-
 ui/src/dashboard/mod.rs                  |   5 +-
 ui/src/dashboard/node_status_panel.rs    |   4 +-
 ui/src/dashboard/pbs_datastores_panel.rs |   4 +-
 ui/src/dashboard/remote_panel.rs         |   4 +-
 ui/src/dashboard/resource_tree.rs        |  53 +++
 ui/src/dashboard/sdn_zone_panel.rs       |   8 +-
 ui/src/dashboard/status_row.rs           |  60 ++-
 ui/src/dashboard/top_entities.rs         |   3 +-
 ui/src/dashboard/types.rs                |  79 ----
 ui/src/dashboard/view.rs                 | 492 +++++++++++++--------
 ui/src/dashboard/view/row_element.rs     | 130 ++++++
 ui/src/dashboard/view/row_view.rs        | 524 ++++++++++++++++++++++-
 ui/src/lib.rs                            |   3 +
 ui/src/main.rs                           |  48 ++-
 ui/src/main_menu.rs                      |  57 ++-
 ui/src/pve/mod.rs                        |   9 +
 ui/src/view_list_context.rs              |  31 ++
 ui/src/widget/mod.rs                     |   8 +-
 ui/src/widget/remote_selector.rs         |   6 +
 ui/src/widget/resource_tree.rs           |  99 ++++-
 ui/src/widget/view_filter_selector.rs    | 421 ++++++++++++++++++
 ui/src/widget/view_selector.rs           |  55 +++
 35 files changed, 2604 insertions(+), 307 deletions(-)
 create mode 100644 server/src/api/config/views.rs
 create mode 100644 ui/src/configuration/views.rs
 create mode 100644 ui/src/dashboard/resource_tree.rs
 delete mode 100644 ui/src/dashboard/types.rs
 create mode 100644 ui/src/dashboard/view/row_element.rs
 create mode 100644 ui/src/view_list_context.rs
 create mode 100644 ui/src/widget/view_filter_selector.rs
 create mode 100644 ui/src/widget/view_selector.rs

-- 
2.47.3



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


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

* [pdm-devel] [PATCH datacenter-manager v5 01/26] lib: pdm-config: views: add locking/saving methods
  2025-11-26 15:17 [pdm-devel] [PATCH datacenter-manager v5 00/26] enable custom views on the UI Dominik Csapak
@ 2025-11-26 15:17 ` Dominik Csapak
  2025-11-26 15:17 ` [pdm-devel] [PATCH datacenter-manager v5 02/26] lib: api-types: add 'layout' property to ViewConfig Dominik Csapak
                   ` (26 subsequent siblings)
  27 siblings, 0 replies; 29+ messages in thread
From: Dominik Csapak @ 2025-11-26 15:17 UTC (permalink / raw)
  To: pdm-devel

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 lib/pdm-config/src/views.rs | 22 +++++++++++++++++++---
 server/src/views/mod.rs     |  2 +-
 2 files changed, 20 insertions(+), 4 deletions(-)

diff --git a/lib/pdm-config/src/views.rs b/lib/pdm-config/src/views.rs
index 59c02a66..5f523d07 100644
--- a/lib/pdm-config/src/views.rs
+++ b/lib/pdm-config/src/views.rs
@@ -1,17 +1,33 @@
 use anyhow::Error;
 
+use proxmox_product_config::{open_api_lockfile, replace_config, ApiLockGuard};
 use proxmox_section_config::typed::{ApiSectionDataEntry, SectionConfigData};
 
-use pdm_api_types::views::ViewConfigEntry;
+use pdm_api_types::{views::ViewConfigEntry, ConfigDigest};
 
 use pdm_buildcfg::configdir;
 
 const VIEW_CFG_FILENAME: &str = configdir!("/views.cfg");
+const VIEW_FILTER_CFG_LOCKFILE: &str = configdir!("/.views.lock");
 
 /// Get the `views.cfg` config file contents.
-pub fn config() -> Result<SectionConfigData<ViewConfigEntry>, Error> {
+pub fn config() -> Result<(SectionConfigData<ViewConfigEntry>, ConfigDigest), Error> {
     let content =
         proxmox_sys::fs::file_read_optional_string(VIEW_CFG_FILENAME)?.unwrap_or_default();
 
-    ViewConfigEntry::parse_section_config(VIEW_CFG_FILENAME, &content)
+    let digest = openssl::sha::sha256(content.as_bytes());
+
+    let data = ViewConfigEntry::parse_section_config(VIEW_CFG_FILENAME, &content)?;
+    Ok((data, digest.into()))
+}
+
+/// Get exclusive lock
+pub fn lock_config() -> Result<ApiLockGuard, Error> {
+    open_api_lockfile(VIEW_FILTER_CFG_LOCKFILE, None, true)
+}
+
+pub fn save_config(config: &SectionConfigData<ViewConfigEntry>) -> Result<(), Error> {
+    let raw = ViewConfigEntry::write_section_config(VIEW_CFG_FILENAME, config)?;
+    replace_config(VIEW_CFG_FILENAME, raw.as_bytes())?;
+    Ok(())
 }
diff --git a/server/src/views/mod.rs b/server/src/views/mod.rs
index 3669ace4..90354d0e 100644
--- a/server/src/views/mod.rs
+++ b/server/src/views/mod.rs
@@ -13,7 +13,7 @@ mod tests;
 /// Returns an error if the view configuration file could not be read, or
 /// if the view with the provided ID does not exist.
 pub fn get_view(view_id: &str) -> Result<View, Error> {
-    let config = pdm_config::views::config()?;
+    let (config, _) = pdm_config::views::config()?;
 
     let entry = config
         .get(view_id)
-- 
2.47.3



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


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

* [pdm-devel] [PATCH datacenter-manager v5 02/26] lib: api-types: add 'layout' property to ViewConfig
  2025-11-26 15:17 [pdm-devel] [PATCH datacenter-manager v5 00/26] enable custom views on the UI Dominik Csapak
  2025-11-26 15:17 ` [pdm-devel] [PATCH datacenter-manager v5 01/26] lib: pdm-config: views: add locking/saving methods Dominik Csapak
@ 2025-11-26 15:17 ` Dominik Csapak
  2025-11-26 15:17 ` [pdm-devel] [PATCH datacenter-manager v5 03/26] server: api: implement CRUD api for views Dominik Csapak
                   ` (25 subsequent siblings)
  27 siblings, 0 replies; 29+ messages in thread
From: Dominik Csapak @ 2025-11-26 15:17 UTC (permalink / raw)
  To: pdm-devel

this is a simple string that holds the layout json. We can't currently
add it as a normal property with 'correct' api types, since we want to
use enum features that are not available with the api macro.

Multiple reasons why we can't use the correct type here (currently):
* our api macro does not support enum variants with struct types
  (so e.g. `Foo { bar: usize }`) so we can't generate an api schema for
  it.
* using something generic like a `Value` does not work since that does
  not impl our UpdaterType and we can't here because of the orphan rule.
* we could try to use a wrapper type around `Value` and implement
  the `UpdaterType` and `ApiType` ourselves, but the section config
  won't parse more complex types like an `ObjectSchema`

So until these are possible, our best bet is to simply use a string
and be careful what we accept (like parsing into the wanted type
during parsing/updating).

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
changes from v4:
* adapted to lukas' changes: no need to adapt the tests anymore
 lib/pdm-api-types/src/views.rs | 12 +++++++++++-
 1 file changed, 11 insertions(+), 1 deletion(-)

diff --git a/lib/pdm-api-types/src/views.rs b/lib/pdm-api-types/src/views.rs
index 82ab8781..30da7476 100644
--- a/lib/pdm-api-types/src/views.rs
+++ b/lib/pdm-api-types/src/views.rs
@@ -47,7 +47,10 @@ pub const FILTER_RULE_LIST_SCHEMA: Schema =
         "exclude": {
             schema: FILTER_RULE_LIST_SCHEMA,
             optional: true,
-        }
+        },
+        layout: {
+            optional: true,
+        },
     }
 )]
 #[derive(Clone, Debug, Default, Deserialize, Serialize, Updater, PartialEq)]
@@ -70,6 +73,13 @@ pub struct ViewConfig {
     #[serde(default, skip_serializing_if = "Vec::is_empty")]
     #[updater(serde(skip_serializing_if = "Option::is_none"))]
     pub exclude: Vec<FilterRule>,
+
+    // we can't currently describe this with the 'api' macro so save
+    // it simply as a string and check it in the add/update call
+    /// The configured layout, encoded as json
+    #[serde(default, skip_serializing_if = "String::is_empty")]
+    #[updater(serde(skip_serializing_if = "Option::is_none"))]
+    pub layout: String,
 }
 
 #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
-- 
2.47.3



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


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

* [pdm-devel] [PATCH datacenter-manager v5 03/26] server: api: implement CRUD api for views
  2025-11-26 15:17 [pdm-devel] [PATCH datacenter-manager v5 00/26] enable custom views on the UI Dominik Csapak
  2025-11-26 15:17 ` [pdm-devel] [PATCH datacenter-manager v5 01/26] lib: pdm-config: views: add locking/saving methods Dominik Csapak
  2025-11-26 15:17 ` [pdm-devel] [PATCH datacenter-manager v5 02/26] lib: api-types: add 'layout' property to ViewConfig Dominik Csapak
@ 2025-11-26 15:17 ` Dominik Csapak
  2025-11-26 15:17 ` [pdm-devel] [PATCH datacenter-manager v5 04/26] server: api: resources: add 'view' category to search syntax Dominik Csapak
                   ` (24 subsequent siblings)
  27 siblings, 0 replies; 29+ messages in thread
From: Dominik Csapak @ 2025-11-26 15:17 UTC (permalink / raw)
  To: pdm-devel

namely list/read/update/delete api calls in `/config/views` api

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
changes from v4:
* adapt to lukas changes: add include-all (also to DeletableProperty)
 server/src/api/config/mod.rs   |   2 +
 server/src/api/config/views.rs | 263 +++++++++++++++++++++++++++++++++
 2 files changed, 265 insertions(+)
 create mode 100644 server/src/api/config/views.rs

diff --git a/server/src/api/config/mod.rs b/server/src/api/config/mod.rs
index 7b58c756..8f646c15 100644
--- a/server/src/api/config/mod.rs
+++ b/server/src/api/config/mod.rs
@@ -6,6 +6,7 @@ pub mod access;
 pub mod acme;
 pub mod certificate;
 pub mod notes;
+pub mod views;
 
 #[sortable]
 const SUBDIRS: SubdirMap = &sorted!([
@@ -13,6 +14,7 @@ const SUBDIRS: SubdirMap = &sorted!([
     ("acme", &acme::ROUTER),
     ("certificate", &certificate::ROUTER),
     ("notes", &notes::ROUTER),
+    ("views", &views::ROUTER)
 ]);
 
 pub const ROUTER: Router = Router::new()
diff --git a/server/src/api/config/views.rs b/server/src/api/config/views.rs
new file mode 100644
index 00000000..1401a0a0
--- /dev/null
+++ b/server/src/api/config/views.rs
@@ -0,0 +1,263 @@
+use anyhow::Error;
+use serde::{Deserialize, Serialize};
+
+use proxmox_access_control::CachedUserInfo;
+use proxmox_config_digest::ConfigDigest;
+use proxmox_router::{http_bail, http_err, Permission, Router, RpcEnvironment};
+use proxmox_schema::{api, param_bail};
+
+use pdm_api_types::{
+    views::{ViewConfig, ViewConfigEntry, ViewConfigUpdater},
+    PRIV_RESOURCE_AUDIT, PRIV_RESOURCE_MODIFY,
+};
+
+const VIEW_ROUTER: Router = Router::new()
+    .put(&API_METHOD_UPDATE_VIEW)
+    .delete(&API_METHOD_REMOVE_VIEW)
+    .get(&API_METHOD_READ_VIEW);
+
+pub const ROUTER: Router = Router::new()
+    .get(&API_METHOD_GET_VIEWS)
+    .post(&API_METHOD_ADD_VIEW)
+    .match_all("id", &VIEW_ROUTER);
+
+#[api(
+    protected: true,
+    access: {
+        permission: &Permission::Anybody,
+        description: "Returns the views the user has access to.",
+    },
+    returns: {
+        description: "List of views.",
+        type: Array,
+        items: {
+            type: String,
+            description: "The name of a view."
+        },
+    },
+)]
+/// List views.
+pub fn get_views(rpcenv: &mut dyn RpcEnvironment) -> Result<Vec<ViewConfig>, Error> {
+    let (config, _) = pdm_config::views::config()?;
+
+    let user_info = CachedUserInfo::new()?;
+    let auth_id = rpcenv.get_auth_id().unwrap().parse()?;
+    let top_level_allowed = user_info
+        .check_privs(&auth_id, &["view"], PRIV_RESOURCE_AUDIT, false)
+        .is_ok();
+
+    let views: Vec<ViewConfig> = config
+        .into_iter()
+        .filter_map(|(view, value)| {
+            if !top_level_allowed
+                && user_info
+                    .check_privs(&auth_id, &["view", &view], PRIV_RESOURCE_AUDIT, false)
+                    .is_err()
+            {
+                return None;
+            };
+            match value {
+                ViewConfigEntry::View(conf) => Some(conf),
+            }
+        })
+        .collect();
+
+    Ok(views)
+}
+
+#[api(
+    protected: true,
+    input: {
+        properties: {
+            view: {
+                flatten: true,
+                type: ViewConfig,
+            },
+            digest: {
+                type: ConfigDigest,
+                optional: true,
+            },
+        },
+    },
+    access: {
+        permission: &Permission::Privilege(&["view"], PRIV_RESOURCE_MODIFY, false),
+    },
+)]
+/// Add new view.
+pub fn add_view(view: ViewConfig, digest: Option<ConfigDigest>) -> Result<(), Error> {
+    let _lock = pdm_config::views::lock_config()?;
+
+    let (mut config, config_digest) = pdm_config::views::config()?;
+
+    config_digest.detect_modification(digest.as_ref())?;
+
+    let id = view.id.clone();
+
+    if let Some(ViewConfigEntry::View(_)) = config.insert(id.clone(), ViewConfigEntry::View(view)) {
+        param_bail!("id", "view '{}' already exists.", id)
+    }
+
+    pdm_config::views::save_config(&config)?;
+
+    Ok(())
+}
+
+#[api()]
+#[derive(Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+/// Deletable property name
+pub enum DeletableProperty {
+    /// Delete the include filters.
+    Include,
+    /// Delete the exclude filters.
+    Exclude,
+    /// Delete the layout.
+    Layout,
+    /// Delete include-all flag
+    IncludeAll,
+}
+
+#[api(
+    protected: true,
+    input: {
+        properties: {
+            id: {
+                type: String,
+                description: "",
+            },
+            view: {
+                flatten: true,
+                type: ViewConfigUpdater,
+            },
+            delete: {
+                description: "List of properties to delete.",
+                type: Array,
+                optional: true,
+                items: {
+                    type: DeletableProperty,
+                }
+            },
+            digest: {
+                type: ConfigDigest,
+                optional: true,
+            },
+        },
+    },
+    access: {
+        permission: &Permission::Privilege(&["view", "{id}"], PRIV_RESOURCE_MODIFY, false),
+    },
+)]
+/// Update View.
+pub fn update_view(
+    id: String,
+    view: ViewConfigUpdater,
+    delete: Option<Vec<DeletableProperty>>,
+    digest: Option<ConfigDigest>,
+) -> Result<(), Error> {
+    let _lock = pdm_config::views::lock_config()?;
+
+    let (mut config, config_digest) = pdm_config::views::config()?;
+
+    config_digest.detect_modification(digest.as_ref())?;
+
+    let entry = config
+        .get_mut(&id)
+        .ok_or_else(|| http_err!(NOT_FOUND, "no such remote {id}"))?;
+
+    let ViewConfigEntry::View(conf) = entry;
+
+    if let Some(delete) = delete {
+        for delete_prop in delete {
+            match delete_prop {
+                DeletableProperty::Include => conf.include = Vec::new(),
+                DeletableProperty::Exclude => conf.exclude = Vec::new(),
+                DeletableProperty::Layout => conf.layout = String::new(),
+                DeletableProperty::IncludeAll => conf.include_all = None,
+            }
+        }
+    }
+
+    if let Some(include) = view.include {
+        conf.include = include;
+    }
+
+    if let Some(exclude) = view.exclude {
+        conf.exclude = exclude;
+    }
+
+    if view.include_all.is_some() {
+        conf.include_all = view.include_all;
+    }
+
+    if let Some(layout) = view.layout {
+        conf.layout = layout;
+    }
+
+    pdm_config::views::save_config(&config)?;
+
+    Ok(())
+}
+
+#[api(
+    protected: true,
+    input: {
+        properties: {
+            id: {
+                type: String,
+                description: "",
+            },
+            digest: {
+                type: ConfigDigest,
+                optional: true,
+            },
+        },
+    },
+    access: {
+        permission: &Permission::Privilege(&["view"], PRIV_RESOURCE_MODIFY, false),
+    },
+)]
+/// Delete the view with the given id.
+pub fn remove_view(id: String, digest: Option<ConfigDigest>) -> Result<(), Error> {
+    let _lock = pdm_config::views::lock_config()?;
+
+    let (mut config, config_digest) = pdm_config::views::config()?;
+
+    config_digest.detect_modification(digest.as_ref())?;
+
+    match config.remove(&id) {
+        Some(ViewConfigEntry::View(_)) => {}
+        None => http_bail!(NOT_FOUND, "view '{id}' does not exist."),
+    }
+
+    pdm_config::views::save_config(&config)?;
+
+    Ok(())
+}
+
+#[api(
+    input: {
+        properties: {
+            id: {
+                type: String,
+                description: "",
+            },
+        },
+    },
+    access: {
+        permission: &Permission::Privilege(&["view", "{id}"], PRIV_RESOURCE_AUDIT, false),
+    },
+)]
+/// Get the config of a single view.
+pub fn read_view(id: String) -> Result<ViewConfig, Error> {
+    let (config, _) = pdm_config::views::config()?;
+
+    let view = config
+        .get(&id)
+        .ok_or_else(|| http_err!(NOT_FOUND, "no such view '{id}'"))?;
+
+    let view = match view {
+        ViewConfigEntry::View(view) => view.clone(),
+    };
+
+    Ok(view)
+}
-- 
2.47.3



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


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

* [pdm-devel] [PATCH datacenter-manager v5 04/26] server: api: resources: add 'view' category to search syntax
  2025-11-26 15:17 [pdm-devel] [PATCH datacenter-manager v5 00/26] enable custom views on the UI Dominik Csapak
                   ` (2 preceding siblings ...)
  2025-11-26 15:17 ` [pdm-devel] [PATCH datacenter-manager v5 03/26] server: api: implement CRUD api for views Dominik Csapak
@ 2025-11-26 15:17 ` Dominik Csapak
  2025-11-26 15:17 ` [pdm-devel] [PATCH datacenter-manager v5 05/26] ui: remote selector: allow forcing of value Dominik Csapak
                   ` (23 subsequent siblings)
  27 siblings, 0 replies; 29+ messages in thread
From: Dominik Csapak @ 2025-11-26 15:17 UTC (permalink / raw)
  To: pdm-devel

so that we can filter the results by view too.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 server/src/api/resources.rs | 19 ++++++++++++++++++-
 1 file changed, 18 insertions(+), 1 deletion(-)

diff --git a/server/src/api/resources.rs b/server/src/api/resources.rs
index da395975..15c2c479 100644
--- a/server/src/api/resources.rs
+++ b/server/src/api/resources.rs
@@ -62,6 +62,7 @@ enum MatchCategory {
     Remote,
     RemoteType,
     Property,
+    View,
 }
 
 impl std::str::FromStr for MatchCategory {
@@ -78,6 +79,7 @@ impl std::str::FromStr for MatchCategory {
             "remote" => MatchCategory::Remote,
             "remote-type" => MatchCategory::RemoteType,
             "property" => MatchCategory::Property,
+            "view" => MatchCategory::View,
             _ => bail!("invalid category"),
         };
         Ok(category)
@@ -108,6 +110,7 @@ impl MatchCategory {
                 .to_lowercase()
                 .split(",")
                 .any(|property| property == search_term.to_lowercase()),
+            MatchCategory::View => true,
         }
     }
 }
@@ -140,6 +143,7 @@ fn resource_matches_search_term(
                 }
                 _ => false,
             },
+            MatchCategory::View => return None,
         },
         Some(Err(_)) => false,
         None => {
@@ -171,6 +175,7 @@ fn remote_matches_search_term(
             MatchCategory::Template => false,
             MatchCategory::RemoteType => category.matches(&remote.ty.to_string(), &term.value),
             MatchCategory::NetworkType => false,
+            MatchCategory::View => true,
         },
         Some(Err(_)) => false,
         None => {
@@ -326,6 +331,18 @@ pub(crate) async fn get_resources_impl(
 
     let view = views::get_optional_view(view)?;
 
+    let mut view_filter_from_search = None;
+    filters.matches(|term| {
+        if let Some("view") = term.category.as_deref() {
+            view_filter_from_search = Some(term.value.to_string());
+        }
+        true
+    });
+
+    let view = view.or(views::get_optional_view(
+        view_filter_from_search.as_deref(),
+    )?);
+
     let remotes_only = is_remotes_only(&filters);
 
     for (remote_name, remote) in remotes_config {
@@ -1183,7 +1200,7 @@ pub(super) fn map_pve_network(
                 ClusterResourceNetworkType::UnknownEnumValue(variant) => {
                     log::debug!("ignoring unknown network type variant {variant}");
                     None
-                },
+                }
             }
         }
         _ => None,
-- 
2.47.3



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


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

* [pdm-devel] [PATCH datacenter-manager v5 05/26] ui: remote selector: allow forcing of value
  2025-11-26 15:17 [pdm-devel] [PATCH datacenter-manager v5 00/26] enable custom views on the UI Dominik Csapak
                   ` (3 preceding siblings ...)
  2025-11-26 15:17 ` [pdm-devel] [PATCH datacenter-manager v5 04/26] server: api: resources: add 'view' category to search syntax Dominik Csapak
@ 2025-11-26 15:17 ` Dominik Csapak
  2025-11-26 15:17 ` [pdm-devel] [PATCH datacenter-manager v5 06/26] ui: dashboard types: add missing 'default's to de-serialization Dominik Csapak
                   ` (22 subsequent siblings)
  27 siblings, 0 replies; 29+ messages in thread
From: Dominik Csapak @ 2025-11-26 15:17 UTC (permalink / raw)
  To: pdm-devel

by adding and passing through a 'value' property

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

diff --git a/ui/src/widget/remote_selector.rs b/ui/src/widget/remote_selector.rs
index 8fae9029..0cf0f400 100644
--- a/ui/src/widget/remote_selector.rs
+++ b/ui/src/widget/remote_selector.rs
@@ -19,6 +19,11 @@ use crate::RemoteList;
 #[derive(Clone, Properties, PartialEq)]
 #[builder]
 pub struct RemoteSelector {
+    /// Forced value
+    #[builder(IntoPropValue, into_prop_value)]
+    #[prop_or_default]
+    pub value: Option<AttrValue>,
+
     /// The default value
     #[builder(IntoPropValue, into_prop_value)]
     #[prop_or_default]
@@ -105,6 +110,7 @@ impl Component for PdmRemoteSelector {
             .with_input_props(&props.input_props)
             .on_change(props.on_change.clone())
             .default(props.default.clone())
+            .value(props.value.clone())
             .items(self.remotes.clone())
             .into()
     }
-- 
2.47.3



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


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

* [pdm-devel] [PATCH datacenter-manager v5 06/26] ui: dashboard types: add missing 'default's to de-serialization
  2025-11-26 15:17 [pdm-devel] [PATCH datacenter-manager v5 00/26] enable custom views on the UI Dominik Csapak
                   ` (4 preceding siblings ...)
  2025-11-26 15:17 ` [pdm-devel] [PATCH datacenter-manager v5 05/26] ui: remote selector: allow forcing of value Dominik Csapak
@ 2025-11-26 15:17 ` Dominik Csapak
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 07/26] ui: dashboard: status row: add optional 'editing state' Dominik Csapak
                   ` (21 subsequent siblings)
  27 siblings, 0 replies; 29+ messages in thread
From: Dominik Csapak @ 2025-11-26 15:17 UTC (permalink / raw)
  To: pdm-devel

otherwise a layout with no rows or empty description can be serialized,
but not deserialized.

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

diff --git a/ui/src/dashboard/types.rs b/ui/src/dashboard/types.rs
index c79c38ab..df4a3668 100644
--- a/ui/src/dashboard/types.rs
+++ b/ui/src/dashboard/types.rs
@@ -7,7 +7,7 @@ use crate::pve::GuestType;
 #[derive(Serialize, Deserialize, PartialEq, Clone)]
 #[serde(rename_all = "kebab-case")]
 pub struct ViewTemplate {
-    #[serde(skip_serializing_if = "String::is_empty")]
+    #[serde(default, skip_serializing_if = "String::is_empty")]
     pub description: String,
     pub layout: ViewLayout,
 }
@@ -17,7 +17,7 @@ pub struct ViewTemplate {
 #[serde(tag = "layout-type")]
 pub enum ViewLayout {
     Rows {
-        #[serde(skip_serializing_if = "Vec::is_empty")]
+        #[serde(default, skip_serializing_if = "Vec::is_empty")]
         rows: Vec<Vec<RowWidget>>,
     },
 }
-- 
2.47.3



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


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

* [pdm-devel] [PATCH datacenter-manager v5 07/26] ui: dashboard: status row: add optional 'editing state'
  2025-11-26 15:17 [pdm-devel] [PATCH datacenter-manager v5 00/26] enable custom views on the UI Dominik Csapak
                   ` (5 preceding siblings ...)
  2025-11-26 15:17 ` [pdm-devel] [PATCH datacenter-manager v5 06/26] ui: dashboard types: add missing 'default's to de-serialization Dominik Csapak
@ 2025-11-26 15:18 ` Dominik Csapak
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 08/26] ui: dashboard: prepare view for editing custom views Dominik Csapak
                   ` (20 subsequent siblings)
  27 siblings, 0 replies; 29+ messages in thread
From: Dominik Csapak @ 2025-11-26 15:18 UTC (permalink / raw)
  To: pdm-devel

If that is given, enable some editing buttons (start, finish, cancel) so
that a listener on that editing state can react to those actions.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/dashboard/status_row.rs | 60 +++++++++++++++++++++++++++++++++-
 ui/src/dashboard/view.rs       |  7 ++++
 2 files changed, 66 insertions(+), 1 deletion(-)

diff --git a/ui/src/dashboard/status_row.rs b/ui/src/dashboard/status_row.rs
index 0855b123..e1af6697 100644
--- a/ui/src/dashboard/status_row.rs
+++ b/ui/src/dashboard/status_row.rs
@@ -1,17 +1,23 @@
 use gloo_timers::callback::Interval;
+use yew::html::IntoPropValue;
 use yew::{Component, Properties};
 
+use pwt::css;
 use pwt::prelude::*;
+use pwt::state::SharedState;
 use pwt::{
     css::AlignItems,
     widget::{ActionIcon, Container, Row, Tooltip},
 };
-use pwt_macros::widget;
+use pwt_macros::{builder, widget};
 
 use proxmox_yew_comp::utils::render_epoch;
 
+use crate::dashboard::view::EditingMessage;
+
 #[widget(comp=PdmDashboardStatusRow)]
 #[derive(Properties, PartialEq, Clone)]
+#[builder]
 pub struct DashboardStatusRow {
     last_refresh: Option<f64>,
     reload_interval_s: u32,
@@ -19,6 +25,11 @@ pub struct DashboardStatusRow {
     on_reload: Callback<bool>,
 
     on_settings_click: Callback<()>,
+
+    #[builder(IntoPropValue, into_prop_value)]
+    #[prop_or_default]
+    /// If added, shows a edit/finish/cancel button
+    editing_state: Option<SharedState<Vec<EditingMessage>>>,
 }
 
 impl DashboardStatusRow {
@@ -40,12 +51,14 @@ impl DashboardStatusRow {
 pub enum Msg {
     /// The bool denotes if the reload comes from the click or the timer.
     Reload(bool),
+    Edit(EditingMessage),
 }
 
 #[doc(hidden)]
 pub struct PdmDashboardStatusRow {
     _interval: Interval,
     loading: bool,
+    edit: bool,
 }
 
 impl PdmDashboardStatusRow {
@@ -70,6 +83,7 @@ impl Component for PdmDashboardStatusRow {
         Self {
             _interval: Self::create_interval(ctx),
             loading: false,
+            edit: false,
         }
     }
 
@@ -81,6 +95,13 @@ impl Component for PdmDashboardStatusRow {
                 self.loading = true;
                 true
             }
+            Msg::Edit(editing) => {
+                self.edit = matches!(editing, EditingMessage::Start);
+                if let Some(state) = props.editing_state.as_ref() {
+                    state.write().push(editing);
+                }
+                true
+            }
         }
     }
 
@@ -121,6 +142,43 @@ impl Component for PdmDashboardStatusRow {
                 None => tr!("Now refreshing"),
             }))
             .with_flex_spacer()
+            .with_optional_child(props.editing_state.clone().and_then(|_| {
+                (!self.edit).then_some({
+                    Tooltip::new(ActionIcon::new("fa fa-pencil").tabindex(0).on_activate({
+                        ctx.link()
+                            .callback(move |_| Msg::Edit(EditingMessage::Start))
+                    }))
+                    .tip(tr!("Edit"))
+                })
+            }))
+            .with_optional_child(props.editing_state.clone().and_then(|_| {
+                self.edit.then_some({
+                    Tooltip::new(
+                        ActionIcon::new("fa fa-check")
+                            .class(css::ColorScheme::Success)
+                            .tabindex(0)
+                            .on_activate({
+                                ctx.link()
+                                    .callback(move |_| Msg::Edit(EditingMessage::Finish))
+                            }),
+                    )
+                    .tip(tr!("Finish Editing"))
+                })
+            }))
+            .with_optional_child(props.editing_state.clone().and_then(|_| {
+                self.edit.then_some({
+                    Tooltip::new(
+                        ActionIcon::new("fa fa-times")
+                            .class(css::ColorScheme::Error)
+                            .tabindex(0)
+                            .on_activate({
+                                ctx.link()
+                                    .callback(move |_| Msg::Edit(EditingMessage::Cancel))
+                            }),
+                    )
+                    .tip(tr!("Cancel Editing"))
+                })
+            }))
             .with_child(
                 Tooltip::new(
                     ActionIcon::new("fa fa-cogs")
diff --git a/ui/src/dashboard/view.rs b/ui/src/dashboard/view.rs
index 5ddf5ff3..a1c465e6 100644
--- a/ui/src/dashboard/view.rs
+++ b/ui/src/dashboard/view.rs
@@ -41,6 +41,13 @@ use pdm_client::types::TopEntities;
 mod row_view;
 pub use row_view::RowView;
 
+#[derive(Debug, Clone, PartialEq, Copy)]
+pub enum EditingMessage {
+    Start,
+    Cancel,
+    Finish,
+}
+
 #[derive(Properties, PartialEq)]
 pub struct View {
     view: AttrValue,
-- 
2.47.3



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


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

* [pdm-devel] [PATCH datacenter-manager v5 08/26] ui: dashboard: prepare view for editing custom views
  2025-11-26 15:17 [pdm-devel] [PATCH datacenter-manager v5 00/26] enable custom views on the UI Dominik Csapak
                   ` (6 preceding siblings ...)
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 07/26] ui: dashboard: status row: add optional 'editing state' Dominik Csapak
@ 2025-11-26 15:18 ` Dominik Csapak
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 09/26] ui: views: implement view loading from api Dominik Csapak
                   ` (19 subsequent siblings)
  27 siblings, 0 replies; 29+ messages in thread
From: Dominik Csapak @ 2025-11-26 15:18 UTC (permalink / raw)
  To: pdm-devel

This adds a mechanism to edit a view, namely it adds an edit button
(pencil) in the status row. When in 'edit mode' one can:
* drag the panels around
* delete panels
* add new panels
* set the 'flex' value of panels
* add a new row at the end
* delete a whole row

There is currently no mechanism to persistently save the result, but
that's only a case of wiring the 'on_update_layout' callback to e.g. a
backend api call.

Also the editing is only active when the view is not named 'dashboard'.

The drag&drop works with desktop and touchscreens, but on touchscreens,
there is no 'drag item' shown currently.

The menu structure for adding new items could probably be improved, but
that should not be a big issue.

For handling the 'editing overlay' of the panels, there is a new
'RowElement' component that just abstracts that away to have a less
code in the RowView component.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/Cargo.toml                        |   2 +-
 ui/css/pdm.scss                      |   4 +
 ui/src/dashboard/view.rs             | 110 ++++--
 ui/src/dashboard/view/row_element.rs | 130 +++++++
 ui/src/dashboard/view/row_view.rs    | 519 ++++++++++++++++++++++++++-
 5 files changed, 711 insertions(+), 54 deletions(-)
 create mode 100644 ui/src/dashboard/view/row_element.rs

diff --git a/ui/Cargo.toml b/ui/Cargo.toml
index d9acbe9e..04bb816d 100644
--- a/ui/Cargo.toml
+++ b/ui/Cargo.toml
@@ -23,7 +23,7 @@ serde_json = "1.0"
 wasm-bindgen = "0.2.92"
 wasm-bindgen-futures = "0.4"
 wasm-logger = "0.2"
-web-sys = { version = "0.3", features = ["Location"] }
+web-sys = { version = "0.3", features = ["Location", "DataTransfer"] }
 yew = { version = "0.21",  features = ["csr"] }
 yew-router = { version = "0.18" }
 
diff --git a/ui/css/pdm.scss b/ui/css/pdm.scss
index 92182a47..71cd4b05 100644
--- a/ui/css/pdm.scss
+++ b/ui/css/pdm.scss
@@ -120,3 +120,7 @@
         background-color: var(--pwt-color-background);
     }
 }
+
+.dragging-item {
+    opacity: 0.5;
+}
diff --git a/ui/src/dashboard/view.rs b/ui/src/dashboard/view.rs
index a1c465e6..772fa244 100644
--- a/ui/src/dashboard/view.rs
+++ b/ui/src/dashboard/view.rs
@@ -41,6 +41,8 @@ use pdm_client::types::TopEntities;
 mod row_view;
 pub use row_view::RowView;
 
+mod row_element;
+
 #[derive(Debug, Clone, PartialEq, Copy)]
 pub enum EditingMessage {
     Start,
@@ -82,6 +84,7 @@ pub enum Msg {
     ConfigWindow(bool), // show
     UpdateConfig(RefreshConfig),
     ShowSubscriptionsDialog(Option<Dialog>),
+    LayoutUpdate(ViewLayout),
 }
 
 struct ViewComp {
@@ -101,6 +104,8 @@ struct ViewComp {
     show_config_window: bool,
     show_create_wizard: Option<RemoteType>,
     subscriptions_dialog: Option<Dialog>,
+
+    editing_state: SharedState<Vec<EditingMessage>>,
 }
 
 fn render_widget(
@@ -290,6 +295,8 @@ impl Component for ViewComp {
             show_config_window: false,
             show_create_wizard: None,
             subscriptions_dialog: None,
+
+            editing_state: SharedState::new(Vec::new()),
         }
     }
 
@@ -348,6 +355,12 @@ impl Component for ViewComp {
             Msg::ShowSubscriptionsDialog(dialog) => {
                 self.subscriptions_dialog = dialog;
             }
+            Msg::LayoutUpdate(view_layout) => {
+                // FIXME: update backend layout
+                if let Some(template) = &mut self.template.data {
+                    template.layout = view_layout;
+                }
+            }
         }
         true
     }
@@ -362,58 +375,79 @@ impl Component for ViewComp {
     }
 
     fn view(&self, ctx: &yew::Context<Self>) -> yew::Html {
+        let props = ctx.props();
         if !self.template.has_data() {
             return Progress::new().into();
         }
         let mut view = Column::new().class(css::FlexFit).with_child(
             Container::new()
-                .class("pwt-content-spacer-padding")
+                .padding(4)
                 .class("pwt-content-spacer-colors")
                 .class("pwt-default-colors")
-                .with_child(DashboardStatusRow::new(
-                    self.load_finished_time,
-                    self.refresh_config
-                        .refresh_interval
-                        .unwrap_or(DEFAULT_REFRESH_INTERVAL_S),
-                    ctx.link().callback(Msg::Reload),
-                    ctx.link().callback(|_| Msg::ConfigWindow(true)),
-                )),
+                .with_child(
+                    DashboardStatusRow::new(
+                        self.load_finished_time,
+                        self.refresh_config
+                            .refresh_interval
+                            .unwrap_or(DEFAULT_REFRESH_INTERVAL_S),
+                        ctx.link().callback(Msg::Reload),
+                        ctx.link().callback(|_| Msg::ConfigWindow(true)),
+                    )
+                    .editing_state(
+                        (props.view != "dashboard").then_some(self.editing_state.clone()),
+                    ),
+                ),
         );
+
         if !has_sub_panel(self.template.data.as_ref()) {
             let subs = self.subscriptions.clone();
             let link = ctx.link().clone();
-            view.add_child(Row::new().class("pwt-content-spacer").with_child(
-                create_subscription_panel(
-                    subs.clone(),
-                    link.clone().callback(move |_| {
-                        let on_dialog_close = link.callback(|_| Msg::ShowSubscriptionsDialog(None));
-                        let dialog = create_subscriptions_dialog(subs.clone(), on_dialog_close);
-                        Msg::ShowSubscriptionsDialog(dialog)
-                    }),
-                ),
-            ));
+            view.add_child(
+                Row::new()
+                    .padding_x(4)
+                    .padding_bottom(4)
+                    .padding_top(0)
+                    .class("pwt-content-spacer-colors")
+                    .with_child(
+                        create_subscription_panel(
+                            subs.clone(),
+                            link.clone().callback(move |_| {
+                                let on_dialog_close =
+                                    link.callback(|_| Msg::ShowSubscriptionsDialog(None));
+                                let dialog =
+                                    create_subscriptions_dialog(subs.clone(), on_dialog_close);
+                                Msg::ShowSubscriptionsDialog(dialog)
+                            }),
+                        )
+                        .flex(1.0),
+                    ),
+            );
         }
         match self.template.data.as_ref().map(|template| &template.layout) {
             Some(ViewLayout::Rows { rows }) => {
-                view.add_child(RowView::new(rows.clone(), {
-                    let link = ctx.link().clone();
-                    let status = self.status.clone();
-                    let subscriptions = self.subscriptions.clone();
-                    let top_entities = self.top_entities.clone();
-                    let statistics = self.statistics.clone();
-                    let refresh_config = self.refresh_config.clone();
-                    move |widget: &RowWidget| {
-                        render_widget(
-                            link.clone(),
-                            widget,
-                            status.clone(),
-                            subscriptions.clone(),
-                            top_entities.clone(),
-                            statistics.clone(),
-                            refresh_config.clone(),
-                        )
-                    }
-                }));
+                view.add_child(
+                    RowView::new(rows.clone(), {
+                        let link = ctx.link().clone();
+                        let status = self.status.clone();
+                        let subscriptions = self.subscriptions.clone();
+                        let top_entities = self.top_entities.clone();
+                        let statistics = self.statistics.clone();
+                        let refresh_config = self.refresh_config.clone();
+                        move |widget: &RowWidget| {
+                            render_widget(
+                                link.clone(),
+                                widget,
+                                status.clone(),
+                                subscriptions.clone(),
+                                top_entities.clone(),
+                                statistics.clone(),
+                                refresh_config.clone(),
+                            )
+                        }
+                    })
+                    .editing_state(self.editing_state.clone())
+                    .on_update_layout(ctx.link().callback(Msg::LayoutUpdate)),
+                );
             }
             None => {}
         }
diff --git a/ui/src/dashboard/view/row_element.rs b/ui/src/dashboard/view/row_element.rs
new file mode 100644
index 00000000..d242195c
--- /dev/null
+++ b/ui/src/dashboard/view/row_element.rs
@@ -0,0 +1,130 @@
+use yew::html::IntoEventCallback;
+
+use pwt::css;
+use pwt::prelude::*;
+use pwt::props::RenderFn;
+use pwt::widget::{ActionIcon, Card, Fa, Panel, Row};
+use pwt_macros::{builder, widget};
+
+use crate::dashboard::types::RowWidget;
+
+#[widget(comp=RowElementComp, @element)]
+#[derive(PartialEq, Properties, Clone)]
+#[builder]
+pub struct RowElement {
+    item: RowWidget,
+    widget_renderer: RenderFn<RowWidget>,
+
+    #[builder]
+    #[prop_or_default]
+    edit_mode: bool,
+
+    #[builder]
+    #[prop_or_default]
+    is_dragging: bool,
+
+    #[builder_cb(IntoEventCallback, into_event_callback, ())]
+    #[prop_or_default]
+    on_remove: Option<Callback<()>>,
+
+    #[builder_cb(IntoEventCallback, into_event_callback, u32)]
+    #[prop_or_default]
+    on_flex_change: Option<Callback<u32>>,
+}
+
+impl RowElement {
+    pub fn new(item: RowWidget, widget_renderer: impl Into<RenderFn<RowWidget>>) -> Self {
+        let widget_renderer = widget_renderer.into();
+        yew::props!(Self {
+            item,
+            widget_renderer
+        })
+    }
+}
+
+pub enum Msg {
+    FlexReduce,
+    FlexIncrease,
+}
+
+pub struct RowElementComp {}
+
+impl Component for RowElementComp {
+    type Message = Msg;
+    type Properties = RowElement;
+
+    fn create(_ctx: &Context<Self>) -> Self {
+        Self {}
+    }
+
+    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
+        let props = ctx.props();
+        let flex = props.item.flex.unwrap_or(1.0) as u32;
+        match msg {
+            Msg::FlexReduce => {
+                if let Some(on_flex_change) = &props.on_flex_change {
+                    on_flex_change.emit(flex.saturating_sub(1))
+                }
+            }
+            Msg::FlexIncrease => {
+                if let Some(on_flex_change) = &props.on_flex_change {
+                    on_flex_change.emit(flex.saturating_add(1))
+                }
+            }
+        }
+
+        true
+    }
+
+    fn view(&self, ctx: &Context<Self>) -> Html {
+        let props = ctx.props();
+        let widget = props.widget_renderer.apply(&props.item);
+
+        let edit_overlay = Card::new()
+            .padding(2)
+            .style("z-index", "10")
+            .class(css::AlignItems::Center)
+            .with_child(Fa::new("bars").style("cursor", "grab").padding_end(1))
+            .with_child(tr!("Flex"))
+            .with_child(
+                ActionIcon::new("fa fa-minus")
+                    .on_activate(ctx.link().callback(|_| Msg::FlexReduce)),
+            )
+            .with_child(props.item.flex.unwrap_or(1.0) as u32)
+            .with_child(
+                ActionIcon::new("fa fa-plus")
+                    .on_activate(ctx.link().callback(|_| Msg::FlexIncrease)),
+            )
+            .with_child(ActionIcon::new("fa fa-times").on_activate({
+                let on_remove = props.on_remove.clone();
+                move |_| {
+                    if let Some(on_remove) = &on_remove {
+                        on_remove.emit(());
+                    }
+                }
+            }));
+
+        Panel::new()
+            .with_std_props(&props.std_props)
+            .listeners(&props.listeners)
+            .border(true)
+            .class(props.is_dragging.then_some("dragging-item"))
+            .attribute("draggable", if props.edit_mode { "true" } else { "false" })
+            .style("position", "relative")
+            .with_child(widget)
+            .with_optional_child(
+                props.edit_mode.then_some(
+                    Row::new()
+                        .gap(2)
+                        .class(css::Display::Flex)
+                        .class(css::AlignItems::Start)
+                        .class(css::JustifyContent::End)
+                        .key("overlay")
+                        .style("position", "absolute")
+                        .style("inset", "0")
+                        .with_child(edit_overlay),
+                ),
+            )
+            .into()
+    }
+}
diff --git a/ui/src/dashboard/view/row_view.rs b/ui/src/dashboard/view/row_view.rs
index 69300327..512e63e7 100644
--- a/ui/src/dashboard/view/row_view.rs
+++ b/ui/src/dashboard/view/row_view.rs
@@ -1,21 +1,42 @@
 use std::collections::HashMap;
 use std::rc::Rc;
 
+use gloo_timers::callback::Timeout;
+use wasm_bindgen::JsCast;
+use web_sys::Element;
+use yew::html::{IntoEventCallback, IntoPropValue};
 use yew::virtual_dom::{VComp, VNode};
 
 use pwt::css;
 use pwt::prelude::*;
 use pwt::props::RenderFn;
-use pwt::widget::{Column, Container, Panel, Row};
+use pwt::state::{SharedState, SharedStateObserver};
+use pwt::widget::menu::{Menu, MenuButton, MenuItem};
+use pwt::widget::{ActionIcon, Button, Column, Container, Row, Tooltip};
 use pwt_macros::builder;
 
-use crate::dashboard::types::RowWidget;
+use crate::dashboard::types::{RowWidget, ViewLayout, WidgetType};
+use crate::dashboard::view::row_element::RowElement;
+use crate::dashboard::view::EditingMessage;
+
+use pdm_api_types::remotes::RemoteType;
 
 #[derive(Properties, PartialEq)]
 #[builder]
 pub struct RowView {
     rows: Vec<Vec<RowWidget>>,
     widget_renderer: RenderFn<RowWidget>,
+
+    #[prop_or_default]
+    #[builder(IntoPropValue, into_prop_value)]
+    /// If set, enables/disables editing mode
+    editing_state: Option<SharedState<Vec<EditingMessage>>>,
+
+    #[prop_or_default]
+    #[builder_cb(IntoEventCallback, into_event_callback, ViewLayout)]
+    /// Will be called if there is an [`EditingController`] and the editing
+    /// is finished.
+    on_update_layout: Option<Callback<ViewLayout>>,
 }
 
 impl RowView {
@@ -33,6 +54,33 @@ impl From<RowView> for VNode {
     }
 }
 
+pub enum OverEvent {
+    Pointer(PointerEvent),
+    Drag(DragEvent),
+}
+
+pub enum DragMsg {
+    Start(Position),
+    End,
+    DragOver(OverEvent, Position),
+    Enter(Position),
+}
+
+pub enum MoveDirection {
+    Up,
+    Down,
+}
+pub enum Msg {
+    DragEvent(DragMsg),
+    AddRow,
+    RemoveRow(usize), // idx
+    EditFlex(Position, u32),
+    AddWidget(Position, WidgetType),
+    RemoveWidget(Position),
+    MoveRow(usize, MoveDirection), // idx
+    HandleEditMessages,
+}
+
 #[derive(Clone, Copy, Debug, PartialEq)]
 /// Represents the position of a widget in a row view
 pub struct Position {
@@ -42,6 +90,16 @@ pub struct Position {
 
 pub struct RowViewComp {
     current_layout: Vec<Vec<(Position, RowWidget)>>,
+    new_layout: Option<Vec<Vec<(Position, RowWidget)>>>,
+    dragging: Option<Position>,        // index of item
+    dragging_target: Option<Position>, // index of item
+    drag_timeout: Option<Timeout>,
+
+    next_row_indices: HashMap<usize, usize>, // for saving the max index for new widgets
+
+    node_ref: NodeRef,
+    edit_mode: bool,
+    _editing_observer: Option<SharedStateObserver<Vec<EditingMessage>>>,
 }
 
 fn extract_row_layout(rows: &Vec<Vec<RowWidget>>) -> Vec<Vec<(Position, RowWidget)>> {
@@ -65,7 +123,7 @@ fn extract_row_layout(rows: &Vec<Vec<RowWidget>>) -> Vec<Vec<(Position, RowWidge
 }
 
 impl Component for RowViewComp {
-    type Message = ();
+    type Message = Msg;
     type Properties = RowView;
 
     fn create(ctx: &Context<Self>) -> Self {
@@ -75,14 +133,189 @@ impl Component for RowViewComp {
         for (row_idx, row) in current_layout.iter().enumerate() {
             next_row_indices.insert(row_idx, row.len());
         }
-        Self { current_layout }
+
+        let _editing_observer = ctx
+            .props()
+            .editing_state
+            .as_ref()
+            .map(|state| state.add_listener(ctx.link().callback(|_| Msg::HandleEditMessages)));
+
+        Self {
+            new_layout: None,
+            current_layout,
+            dragging: None,
+            dragging_target: None,
+            drag_timeout: None,
+            next_row_indices,
+            node_ref: NodeRef::default(),
+            edit_mode: false,
+            _editing_observer,
+        }
+    }
+
+    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
+        match msg {
+            Msg::RemoveRow(idx) => {
+                self.current_layout.remove(idx);
+            }
+            Msg::AddRow => {
+                self.current_layout.push(Vec::new());
+            }
+            Msg::DragEvent(drag_msg) => match drag_msg {
+                DragMsg::Start(coords) => {
+                    self.dragging = Some(coords);
+                    self.dragging_target = Some(coords);
+                }
+                DragMsg::End => {
+                    self.dragging = None;
+                    self.dragging_target = None;
+                    if let Some(layout) = self.new_layout.take() {
+                        self.current_layout = layout;
+                    }
+                }
+                DragMsg::DragOver(event, position) => {
+                    // check if the pointer is at a position where the item can be dropped
+                    // without flickering, namely where it fits from it's dimensions
+                    let (target, pointer_pos) = match event {
+                        OverEvent::Pointer(event) => (
+                            event.target().and_then(|t| t.dyn_into::<Element>().ok()),
+                            (event.client_x(), event.client_y()),
+                        ),
+                        OverEvent::Drag(event) => (
+                            event.target().and_then(|t| t.dyn_into::<Element>().ok()),
+                            (event.client_x(), event.client_y()),
+                        ),
+                    };
+                    if let Some(el) = self.node_ref.cast::<Element>() {
+                        if let Ok(Some(dragging_el)) = el.query_selector(".dragging-item") {
+                            let dragging_rect = dragging_el.get_bounding_client_rect();
+
+                            if let Some(target) = target {
+                                let target_rect = target.get_bounding_client_rect();
+
+                                let x = pointer_pos.0 as f64;
+                                let x_min = target_rect.x();
+                                let x_max = target_rect.x() + dragging_rect.width();
+
+                                let y = pointer_pos.1 as f64;
+                                let y_min = target_rect.y();
+                                let y_max = target_rect.y() + dragging_rect.height();
+
+                                if x >= x_min && x <= x_max && y >= y_min && y <= y_max {
+                                    ctx.link()
+                                        .send_message(Msg::DragEvent(DragMsg::Enter(position)));
+                                }
+                            }
+                        }
+                    }
+                }
+                DragMsg::Enter(coords) => {
+                    if let Some(source_coords) = self.dragging {
+                        let mut new_layout = self.current_layout.clone();
+                        let item = new_layout[source_coords.row].remove(source_coords.item);
+                        let target_idx = new_layout[coords.row].len().min(coords.item);
+                        new_layout[coords.row].insert(target_idx, item);
+                        self.new_layout = Some(new_layout);
+                    }
+                    self.dragging_target = Some(coords);
+                }
+            },
+            Msg::EditFlex(coords, flex) => {
+                self.current_layout[coords.row][coords.item].1.flex = Some(flex as f32);
+            }
+            Msg::AddWidget(coords, widget_type) => {
+                let next_idx = *self.next_row_indices.get(&coords.row).unwrap_or(&0);
+                self.next_row_indices
+                    .insert(coords.row, next_idx.saturating_add(1));
+                self.current_layout[coords.row].insert(
+                    coords.item,
+                    (
+                        Position {
+                            row: coords.row,
+                            item: next_idx,
+                        },
+                        RowWidget {
+                            flex: None,
+                            title: None,
+                            r#type: widget_type,
+                        },
+                    ),
+                );
+            }
+            Msg::RemoveWidget(coords) => {
+                self.current_layout[coords.row].remove(coords.item);
+            }
+            Msg::MoveRow(old, direction) => {
+                let mut new_layout = self.current_layout.clone();
+                let row = new_layout.remove(old);
+                let new_idx = match direction {
+                    MoveDirection::Up => old.saturating_sub(1),
+                    MoveDirection::Down => old.saturating_add(1).min(new_layout.len()),
+                };
+                new_layout.insert(new_idx, row);
+                self.current_layout = new_layout;
+            }
+            Msg::HandleEditMessages => {
+                let props = ctx.props();
+                let state = match props.editing_state.clone() {
+                    Some(state) => state,
+                    None => return false,
+                };
+
+                if state.read().len() == 0 {
+                    return false;
+                } // Note: avoid endless loop
+
+                let list = state.write().split_off(0);
+                let mut editing = self.edit_mode;
+                let mut trigger_finish = false;
+                let mut cancel = false;
+                for msg in list {
+                    match msg {
+                        EditingMessage::Start => editing = true,
+                        EditingMessage::Cancel => {
+                            if editing {
+                                cancel = true;
+                            }
+                            editing = false;
+                        }
+                        EditingMessage::Finish => {
+                            if editing {
+                                trigger_finish = true;
+                            }
+                            editing = false;
+                        }
+                    }
+                }
+                if let (true, Some(on_update_layout)) = (trigger_finish, &props.on_update_layout) {
+                    let rows = self
+                        .current_layout
+                        .iter()
+                        .map(|row| row.iter().map(|(_, item)| item.clone()).collect())
+                        .collect();
+                    on_update_layout.emit(ViewLayout::Rows { rows });
+                }
+                if cancel {
+                    self.current_layout = extract_row_layout(&props.rows);
+                }
+                self.edit_mode = editing;
+                if !self.edit_mode {
+                    self.dragging = None;
+                    self.dragging_target = None;
+                    self.drag_timeout = None;
+                }
+            }
+        }
+        true
     }
 
     fn changed(&mut self, ctx: &Context<Self>, old_props: &Self::Properties) -> bool {
         let props = ctx.props();
-
         if props.rows != old_props.rows {
-            self.current_layout = extract_row_layout(&props.rows);
+            let new_layout = extract_row_layout(&props.rows);
+            if new_layout != self.current_layout {
+                self.current_layout = new_layout;
+            }
         }
 
         true
@@ -90,8 +323,11 @@ impl Component for RowViewComp {
 
     fn view(&self, ctx: &Context<Self>) -> Html {
         let props = ctx.props();
-        let mut view = Column::new();
-        let layout = &self.current_layout;
+        let mut view = Column::new().onpointerup(
+            (self.dragging.is_some() && self.edit_mode)
+                .then_some(ctx.link().callback(|_| Msg::DragEvent(DragMsg::End))),
+        );
+        let layout = self.new_layout.as_ref().unwrap_or(&self.current_layout);
         let mut row = Row::new()
             .padding_x(2)
             .class("pwt-content-spacer-colors")
@@ -104,7 +340,7 @@ impl Component for RowViewComp {
                 .sum();
             let gaps_ratio = 1.0; //items.len().saturating_sub(1) as f32 / items.len() as f32;
 
-            for (_item_idx, (coords, item)) in items.iter().enumerate() {
+            for (item_idx, (coords, item)) in items.iter().enumerate() {
                 let flex = item.flex.unwrap_or(1.0);
                 let flex_ratio = 95.0 * (flex.max(1.0)) / flex_sum;
                 // we have to subtract the gaps too
@@ -112,27 +348,280 @@ impl Component for RowViewComp {
                     "{} {} calc({}% - calc({} * var(--pwt-spacer-4)))",
                     flex, flex, flex_ratio, gaps_ratio
                 );
+                let current_coords = Position {
+                    row: row_idx,
+                    item: item_idx,
+                };
 
-                let widget = props.widget_renderer.apply(&item);
-                let row_element = Panel::new()
-                    .border(true)
+                let row_element = RowElement::new(item.clone(), props.widget_renderer.clone())
                     .margin_x(2)
                     .margin_bottom(4)
+                    .edit_mode(self.edit_mode)
+                    .is_dragging(self.dragging_target == Some(current_coords))
                     .key(format!("item-{}-{}", coords.row, coords.item))
                     .style("flex", flex_style)
-                    .with_child(widget);
+                    .style("touch-action", self.edit_mode.then_some("none"))
+                    .on_remove(
+                        ctx.link()
+                            .callback(move |_| Msg::RemoveWidget(current_coords)),
+                    )
+                    .on_flex_change(
+                        ctx.link()
+                            .callback(move |flex| Msg::EditFlex(current_coords, flex)),
+                    )
+                    .ondragstart(ctx.link().callback(move |event: DragEvent| {
+                        let data = event.data_transfer().unwrap();
+                        let _ = data.clear_data();
+                        let _ = data.set_data("", "");
+                        Msg::DragEvent(DragMsg::Start(current_coords))
+                    }))
+                    .onpointerdown(self.edit_mode.then_some(ctx.link().callback(
+                        move |event: PointerEvent| {
+                            // we need to release the pointer capture to trigger pointer events
+                            // on other elements
+                            if let Some(target) = event
+                                .target()
+                                .and_then(|target| target.dyn_into::<Element>().ok())
+                            {
+                                let _ = target.release_pointer_capture(event.pointer_id());
+                            }
+                            Msg::DragEvent(DragMsg::Start(current_coords))
+                        },
+                    )))
+                    .ondragend(ctx.link().callback(|_| Msg::DragEvent(DragMsg::End)))
+                    .onpointermove((self.dragging.is_some() && self.edit_mode).then_some(
+                        ctx.link().callback(move |event: PointerEvent| {
+                            Msg::DragEvent(DragMsg::DragOver(
+                                OverEvent::Pointer(event),
+                                current_coords,
+                            ))
+                        }),
+                    ))
+                    .ondragover((self.dragging.is_some() && self.edit_mode).then_some(
+                        ctx.link().callback(move |event: DragEvent| {
+                            Msg::DragEvent(DragMsg::DragOver(
+                                OverEvent::Drag(event),
+                                current_coords,
+                            ))
+                        }),
+                    ))
+                    .ondragover(|event: DragEvent| event.prevent_default())
+                    .ondrop(ctx.link().callback(|event: DragEvent| {
+                        event.prevent_default();
+                        Msg::DragEvent(DragMsg::End)
+                    }));
 
                 row.add_child(row_element);
             }
 
+            if self.edit_mode {
+                let drop_coords = Position {
+                    row: row_idx,
+                    item: items.len().saturating_sub(1),
+                };
+                let is_first_row = row_idx == 0;
+                let is_last_row = row_idx == (layout.len().saturating_sub(1));
+                row.add_child(
+                    Container::new()
+                        .key(format!("row-add-{}", row_idx))
+                        .style("flex", "1 1 100%")
+                        .margin_x(2)
+                        .margin_bottom(4)
+                        .padding_bottom(4)
+                        .border_bottom(true)
+                        .ondragenter(
+                            ctx.link()
+                                .callback(move |_| Msg::DragEvent(DragMsg::Enter(drop_coords))),
+                        )
+                        .onpointerenter(
+                            (self.dragging.is_some() && self.edit_mode)
+                                .then_some(ctx.link().callback(move |_| {
+                                    Msg::DragEvent(DragMsg::Enter(drop_coords))
+                                })),
+                        )
+                        // necessary for drop event to trigger
+                        .ondragover(|event: DragEvent| event.prevent_default())
+                        .ondrop(ctx.link().callback(|event: DragEvent| {
+                            event.prevent_default();
+                            Msg::DragEvent(DragMsg::End)
+                        }))
+                        .with_child(
+                            Row::new()
+                                .gap(2)
+                                .with_child(
+                                    MenuButton::new(tr!("Add Widget"))
+                                        .class(css::ColorScheme::Primary)
+                                        .show_arrow(true)
+                                        .icon_class("fa fa-plus-circle")
+                                        .menu(create_menu(
+                                            ctx,
+                                            Position {
+                                                row: row_idx,
+                                                item: items.len(),
+                                            },
+                                        )),
+                                )
+                                .with_child(
+                                    Button::new(tr!("Remove Row"))
+                                        .icon_class("fa fa-times")
+                                        .class(css::ColorScheme::Error)
+                                        .on_activate(
+                                            ctx.link().callback(move |_| Msg::RemoveRow(row_idx)),
+                                        ),
+                                )
+                                .with_flex_spacer()
+                                .with_child(
+                                    Tooltip::new(
+                                        ActionIcon::new("fa fa-arrow-down")
+                                            .on_activate(ctx.link().callback(move |_| {
+                                                Msg::MoveRow(row_idx, MoveDirection::Down)
+                                            }))
+                                            .disabled(is_last_row),
+                                    )
+                                    .tip(tr!("Move Row down")),
+                                )
+                                .with_child(
+                                    Tooltip::new(
+                                        ActionIcon::new("fa fa-arrow-up")
+                                            .on_activate(ctx.link().callback(move |_| {
+                                                Msg::MoveRow(row_idx, MoveDirection::Up)
+                                            }))
+                                            .disabled(is_first_row),
+                                    )
+                                    .tip(tr!("Move Row up")),
+                                ),
+                        ),
+                );
+            }
             row.add_child(
                 Container::new()
                     .key(format!("spacer-{row_idx}"))
                     .style("flex", "1 1 100%"),
             );
         }
-
+        if self.edit_mode {
+            row.add_child(
+                Container::new()
+                    .key("add-row")
+                    .padding_x(2)
+                    .style("flex", "1 1 100%")
+                    .with_child(
+                        Button::new(tr!("Add Row"))
+                            .class(css::ColorScheme::Secondary)
+                            .icon_class("fa fa-plus-circle")
+                            .on_activate(ctx.link().callback(|_| Msg::AddRow)),
+                    ),
+            );
+        }
         view.add_child(row);
-        view.into()
+        view.into_html_with_ref(self.node_ref.clone())
     }
 }
+
+fn create_menu(ctx: &yew::Context<RowViewComp>, new_coords: Position) -> Menu {
+    let create_callback = |widget: WidgetType| {
+        ctx.link()
+            .callback(move |_| Msg::AddWidget(new_coords, widget.clone()))
+    };
+    Menu::new()
+        .with_item(
+            MenuItem::new(tr!("Remote Panel"))
+                .on_select(create_callback(WidgetType::Remotes { show_wizard: true })),
+        )
+        .with_item(
+            MenuItem::new(tr!("Node Panels")).menu(
+                Menu::new()
+                    .with_item(
+                        MenuItem::new(tr!("All Nodes"))
+                            .on_select(create_callback(WidgetType::Nodes { remote_type: None })),
+                    )
+                    .with_item(MenuItem::new(tr!("PBS Nodes")).on_select(create_callback(
+                        WidgetType::Nodes {
+                            remote_type: Some(RemoteType::Pbs),
+                        },
+                    )))
+                    .with_item(MenuItem::new(tr!("PVE Nodes")).on_select(create_callback(
+                        WidgetType::Nodes {
+                            remote_type: Some(RemoteType::Pve),
+                        },
+                    ))),
+            ),
+        )
+        .with_item(
+            MenuItem::new(tr!("Guest Panels")).menu(
+                Menu::new()
+                    .with_item(
+                        MenuItem::new(tr!("All Guests"))
+                            .on_select(create_callback(WidgetType::Guests { guest_type: None })),
+                    )
+                    .with_item(
+                        MenuItem::new(tr!("Virtual Machines")).on_select(create_callback(
+                            WidgetType::Guests {
+                                guest_type: Some(crate::pve::GuestType::Qemu),
+                            },
+                        )),
+                    )
+                    .with_item(
+                        MenuItem::new(tr!("Linux Container")).on_select(create_callback(
+                            WidgetType::Guests {
+                                guest_type: Some(crate::pve::GuestType::Lxc),
+                            },
+                        )),
+                    ),
+            ),
+        )
+        .with_item(
+            MenuItem::new(tr!("Subscription Panel"))
+                .on_select(create_callback(WidgetType::Subscription)),
+        )
+        .with_item(
+            MenuItem::new(tr!("PBS Datastores"))
+                .on_select(create_callback(WidgetType::PbsDatastores)),
+        )
+        .with_item(
+            MenuItem::new(tr!("Leaderboards")).menu(
+                Menu::new()
+                    .with_item(
+                        MenuItem::new(tr!("Guests with Highest CPU Usage")).on_select(
+                            create_callback(WidgetType::Leaderboard {
+                                leaderboard_type:
+                                    crate::dashboard::types::LeaderboardType::GuestCpu,
+                            }),
+                        ),
+                    )
+                    .with_item(
+                        MenuItem::new(tr!("Nodes With the Hightest CPU Usagge)")).on_select(
+                            create_callback(WidgetType::Leaderboard {
+                                leaderboard_type: crate::dashboard::types::LeaderboardType::NodeCpu,
+                            }),
+                        ),
+                    )
+                    .with_item(
+                        MenuItem::new(tr!("Nodes With the Highest Memory Usage")).on_select(
+                            create_callback(WidgetType::Leaderboard {
+                                leaderboard_type:
+                                    crate::dashboard::types::LeaderboardType::NodeMemory,
+                            }),
+                        ),
+                    ),
+            ),
+        )
+        .with_item(
+            MenuItem::new(tr!("Task Summaries")).menu(
+                Menu::new()
+                    .with_item(MenuItem::new(tr!("Task Summary by Category")).on_select(
+                        create_callback(WidgetType::TaskSummary {
+                            grouping: crate::dashboard::types::TaskSummaryGrouping::Category,
+                        }),
+                    ))
+                    .with_item(
+                        MenuItem::new(tr!("Task Summary Sorted by Failed Tasks")).on_select(
+                            create_callback(WidgetType::TaskSummary {
+                                grouping: crate::dashboard::types::TaskSummaryGrouping::Remote,
+                            }),
+                        ),
+                    ),
+            ),
+        )
+        .with_item(MenuItem::new(tr!("SDN Panel")).on_select(create_callback(WidgetType::Sdn)))
+}
-- 
2.47.3



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


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

* [pdm-devel] [PATCH datacenter-manager v5 09/26] ui: views: implement view loading from api
  2025-11-26 15:17 [pdm-devel] [PATCH datacenter-manager v5 00/26] enable custom views on the UI Dominik Csapak
                   ` (7 preceding siblings ...)
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 08/26] ui: dashboard: prepare view for editing custom views Dominik Csapak
@ 2025-11-26 15:18 ` Dominik Csapak
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 10/26] ui: views: make 'view' name property optional Dominik Csapak
                   ` (18 subsequent siblings)
  27 siblings, 0 replies; 29+ messages in thread
From: Dominik Csapak @ 2025-11-26 15:18 UTC (permalink / raw)
  To: pdm-devel

by simply loading the config from the api. Also move the default
dashboard into a constant.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/dashboard/view.rs | 179 +++++++++++++++++++++------------------
 1 file changed, 97 insertions(+), 82 deletions(-)

diff --git a/ui/src/dashboard/view.rs b/ui/src/dashboard/view.rs
index 772fa244..d87c5a6d 100644
--- a/ui/src/dashboard/view.rs
+++ b/ui/src/dashboard/view.rs
@@ -8,6 +8,7 @@ use serde_json::json;
 use yew::virtual_dom::{VComp, VNode};
 
 use proxmox_yew_comp::http_get;
+use proxmox_yew_comp::percent_encoding::percent_encode_component;
 use pwt::css;
 use pwt::prelude::*;
 use pwt::props::StorageLocation;
@@ -35,6 +36,7 @@ use crate::{pdm_client, LoadResult};
 use pdm_api_types::remotes::RemoteType;
 use pdm_api_types::resource::ResourcesStatus;
 use pdm_api_types::subscription::RemoteSubscriptions;
+use pdm_api_types::views::ViewConfig;
 use pdm_api_types::TaskStatistics;
 use pdm_client::types::TopEntities;
 
@@ -276,8 +278,9 @@ impl Component for ViewComp {
         );
 
         let async_pool = AsyncPool::new();
+        let view = ctx.props().view.clone();
         async_pool.send_future(ctx.link().clone(), async move {
-            Msg::ViewTemplateLoaded(load_template().await)
+            Msg::ViewTemplateLoaded(load_template(Some(view)).await)
         });
 
         Self {
@@ -368,8 +371,9 @@ impl Component for ViewComp {
     fn changed(&mut self, ctx: &Context<Self>, _old_props: &Self::Properties) -> bool {
         self.async_pool = AsyncPool::new();
         self.load_finished_time = None;
+        let view = ctx.props().view.clone();
         self.async_pool.send_future(ctx.link().clone(), async move {
-            Msg::ViewTemplateLoaded(load_template().await)
+            Msg::ViewTemplateLoaded(load_template(Some(view)).await)
         });
         true
     }
@@ -493,87 +497,98 @@ impl Component for ViewComp {
     }
 }
 
-async fn load_template() -> Result<ViewTemplate, Error> {
-    // FIXME: load template from api
-
-    let view_str = "
-        {
-          \"description\": \"some description\",
-          \"layout\": {
-            \"layout-type\": \"rows\",
-            \"rows\": [
-              [
-                {
-                  \"flex\": 3.0,
-                  \"widget-type\": \"remotes\",
-                  \"show-wizard\": true
-                },
-                {
-                  \"flex\": 3.0,
-                  \"widget-type\": \"nodes\",
-                  \"remote-type\": \"pve\"
-                },
-                {
-                  \"flex\": 3.0,
-                  \"widget-type\": \"guests\",
-                  \"guest-type\": \"qemu\"
-                },
-                {
-                  \"flex\": 3.0,
-                  \"widget-type\": \"nodes\",
-                  \"remote-type\": \"pbs\"
-                },
-                {
-                  \"flex\": 3.0,
-                  \"widget-type\": \"guests\",
-                  \"guest-type\": \"lxc\"
-                },
-                {
-                  \"flex\": 3.0,
-                  \"widget-type\": \"pbs-datastores\"
-                },
-                {
-                  \"flex\": 5.0,
-                  \"widget-type\": \"subscription\"
-                }
-              ],
-              [
-                {
-                  \"widget-type\": \"leaderboard\",
-                  \"leaderboard-type\": \"guest-cpu\"
-                },
-                {
-                  \"widget-type\": \"leaderboard\",
-                  \"leaderboard-type\": \"node-cpu\"
-                },
-                {
-                  \"widget-type\": \"leaderboard\",
-                  \"leaderboard-type\": \"node-memory\"
-                }
-              ],
-              [
-                {
-                  \"flex\": 5.0,
-                  \"widget-type\": \"task-summary\",
-                  \"grouping\": \"category\",
-                  \"sorting\": \"default\"
-                },
-                {
-                  \"flex\": 5.0,
-                  \"widget-type\": \"task-summary\",
-                  \"grouping\": \"remote\",
-                  \"sorting\": \"failed-tasks\"
-                },
-                {
-                  \"flex\": 2.0,
-                  \"widget-type\": \"sdn\"
-                }
-              ]
-            ]
-          }
+const DEFAULT_DASHBOARD: &str = "
+    {
+      \"layout\": {
+        \"layout-type\": \"rows\",
+        \"rows\": [
+          [
+            {
+              \"flex\": 3.0,
+              \"widget-type\": \"remotes\",
+              \"show-wizard\": true
+            },
+            {
+              \"flex\": 3.0,
+              \"widget-type\": \"nodes\",
+              \"remote-type\": \"pve\"
+            },
+            {
+              \"flex\": 3.0,
+              \"widget-type\": \"guests\",
+              \"guest-type\": \"qemu\"
+            },
+            {
+              \"flex\": 3.0,
+              \"widget-type\": \"nodes\",
+              \"remote-type\": \"pbs\"
+            },
+            {
+              \"flex\": 3.0,
+              \"widget-type\": \"guests\",
+              \"guest-type\": \"lxc\"
+            },
+            {
+              \"flex\": 3.0,
+              \"widget-type\": \"pbs-datastores\"
+            },
+            {
+              \"flex\": 5.0,
+              \"widget-type\": \"subscription\"
+            }
+          ],
+          [
+            {
+              \"widget-type\": \"leaderboard\",
+              \"leaderboard-type\": \"guest-cpu\"
+            },
+            {
+              \"widget-type\": \"leaderboard\",
+              \"leaderboard-type\": \"node-cpu\"
+            },
+            {
+              \"widget-type\": \"leaderboard\",
+              \"leaderboard-type\": \"node-memory\"
+            }
+          ],
+          [
+            {
+              \"flex\": 5.0,
+              \"widget-type\": \"task-summary\",
+              \"grouping\": \"category\",
+              \"sorting\": \"default\"
+            },
+            {
+              \"flex\": 5.0,
+              \"widget-type\": \"task-summary\",
+              \"grouping\": \"remote\",
+              \"sorting\": \"failed-tasks\"
+            },
+            {
+              \"flex\": 2.0,
+              \"widget-type\": \"sdn\"
+            }
+          ]
+        ]
+      }
+    }
+";
+
+async fn load_template(view: Option<AttrValue>) -> Result<ViewTemplate, Error> {
+    let view_str = match view {
+        Some(view) => {
+            let view = percent_encode_component(view.as_str());
+            let config: ViewConfig = http_get(&format!("/config/views/{view}"), None).await?;
+            config.layout
         }
-    ";
+        None => String::new(),
+    };
+
+    let template: ViewTemplate = if view_str.is_empty() {
+        serde_json::from_str(DEFAULT_DASHBOARD)?
+    } else {
+        serde_json::from_str(&view_str)?
+    };
 
-    let template: ViewTemplate = serde_json::from_str(view_str)?;
     Ok(template)
 }
-- 
2.47.3



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


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

* [pdm-devel] [PATCH datacenter-manager v5 10/26] ui: views: make 'view' name property optional
  2025-11-26 15:17 [pdm-devel] [PATCH datacenter-manager v5 00/26] enable custom views on the UI Dominik Csapak
                   ` (8 preceding siblings ...)
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 09/26] ui: views: implement view loading from api Dominik Csapak
@ 2025-11-26 15:18 ` Dominik Csapak
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 11/26] ui: views: add 'view' parameter to api calls Dominik Csapak
                   ` (17 subsequent siblings)
  27 siblings, 0 replies; 29+ messages in thread
From: Dominik Csapak @ 2025-11-26 15:18 UTC (permalink / raw)
  To: pdm-devel

and make the default dashboard the default case

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/dashboard/view.rs | 59 +++++++++++++++++++++-------------------
 ui/src/main_menu.rs      |  2 +-
 2 files changed, 32 insertions(+), 29 deletions(-)

diff --git a/ui/src/dashboard/view.rs b/ui/src/dashboard/view.rs
index d87c5a6d..6c45e859 100644
--- a/ui/src/dashboard/view.rs
+++ b/ui/src/dashboard/view.rs
@@ -54,7 +54,7 @@ pub enum EditingMessage {
 
 #[derive(Properties, PartialEq)]
 pub struct View {
-    view: AttrValue,
+    view: Option<AttrValue>,
 }
 
 impl From<View> for VNode {
@@ -65,7 +65,7 @@ impl From<View> for VNode {
 }
 
 impl View {
-    pub fn new(view: impl Into<AttrValue>) -> Self {
+    pub fn new(view: impl Into<Option<AttrValue>>) -> Self {
         Self { view: view.into() }
     }
 }
@@ -273,14 +273,17 @@ impl Component for ViewComp {
     type Properties = View;
 
     fn create(ctx: &yew::Context<Self>) -> Self {
-        let refresh_config: PersistentState<RefreshConfig> = PersistentState::new(
-            StorageLocation::local(refresh_config_id(ctx.props().view.as_str())),
-        );
+        let view = ctx.props().view.clone();
+        let refresh_id = match view.as_ref() {
+            Some(view) => format!("view-{view}"),
+            None => "dashboard".to_string(),
+        };
+        let refresh_config: PersistentState<RefreshConfig> =
+            PersistentState::new(StorageLocation::local(refresh_config_id(&refresh_id)));
 
         let async_pool = AsyncPool::new();
-        let view = ctx.props().view.clone();
         async_pool.send_future(ctx.link().clone(), async move {
-            Msg::ViewTemplateLoaded(load_template(Some(view)).await)
+            Msg::ViewTemplateLoaded(load_template(view).await)
         });
 
         Self {
@@ -373,7 +376,7 @@ impl Component for ViewComp {
         self.load_finished_time = None;
         let view = ctx.props().view.clone();
         self.async_pool.send_future(ctx.link().clone(), async move {
-            Msg::ViewTemplateLoaded(load_template(Some(view)).await)
+            Msg::ViewTemplateLoaded(load_template(view).await)
         });
         true
     }
@@ -397,9 +400,7 @@ impl Component for ViewComp {
                         ctx.link().callback(Msg::Reload),
                         ctx.link().callback(|_| Msg::ConfigWindow(true)),
                     )
-                    .editing_state(
-                        (props.view != "dashboard").then_some(self.editing_state.clone()),
-                    ),
+                    .editing_state(props.view.is_some().then_some(self.editing_state.clone())),
                 ),
         );
 
@@ -467,24 +468,26 @@ impl Component for ViewComp {
                 .as_ref()
                 .map(|err| error_message(&err.to_string())),
         );
-        view.add_optional_child(
-            self.show_config_window.then_some(
-                create_refresh_config_edit_window(&ctx.props().view)
-                    .on_close(ctx.link().callback(|_| Msg::ConfigWindow(false)))
-                    .on_submit({
-                        let link = ctx.link().clone();
-                        move |ctx: FormContext| {
-                            let link = link.clone();
-                            async move {
-                                let data: RefreshConfig =
-                                    serde_json::from_value(ctx.get_submit_data())?;
-                                link.send_message(Msg::UpdateConfig(data));
-                                Ok(())
-                            }
+        view.add_optional_child(self.show_config_window.then_some({
+            let refresh_config_id = match &props.view {
+                Some(view) => format!("view-{view}"),
+                None => "dashboard".to_string(),
+            };
+            create_refresh_config_edit_window(&refresh_config_id)
+                .on_close(ctx.link().callback(|_| Msg::ConfigWindow(false)))
+                .on_submit({
+                    let link = ctx.link().clone();
+                    move |ctx: FormContext| {
+                        let link = link.clone();
+                        async move {
+                            let data: RefreshConfig =
+                                serde_json::from_value(ctx.get_submit_data())?;
+                            link.send_message(Msg::UpdateConfig(data));
+                            Ok(())
                         }
-                    }),
-            ),
-        );
+                    }
+                })
+        }));
         view.add_optional_child(self.show_create_wizard.map(|remote_type| {
             AddWizard::new(remote_type)
                 .on_close(ctx.link().callback(|_| Msg::CreateWizard(None)))
diff --git a/ui/src/main_menu.rs b/ui/src/main_menu.rs
index 474e0698..e50705dc 100644
--- a/ui/src/main_menu.rs
+++ b/ui/src/main_menu.rs
@@ -157,7 +157,7 @@ impl Component for PdmMainMenu {
             tr!("Dashboard"),
             "dashboard",
             Some("fa fa-tachometer"),
-            move |_| View::new("dashboard").into(),
+            move |_| View::new(None).into(),
         );
 
         if self.acl_context.check_privs(&["system"], PRIV_SYS_AUDIT) {
-- 
2.47.3



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


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

* [pdm-devel] [PATCH datacenter-manager v5 11/26] ui: views: add 'view' parameter to api calls
  2025-11-26 15:17 [pdm-devel] [PATCH datacenter-manager v5 00/26] enable custom views on the UI Dominik Csapak
                   ` (9 preceding siblings ...)
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 10/26] ui: views: make 'view' name property optional Dominik Csapak
@ 2025-11-26 15:18 ` Dominik Csapak
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 12/26] ui: views: save updated layout to backend Dominik Csapak
                   ` (16 subsequent siblings)
  27 siblings, 0 replies; 29+ messages in thread
From: Dominik Csapak @ 2025-11-26 15:18 UTC (permalink / raw)
  To: pdm-devel

if a 'view' name was given. This way the panels show the actual data
of the view.

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

diff --git a/ui/src/dashboard/view.rs b/ui/src/dashboard/view.rs
index 6c45e859..3a82cbc7 100644
--- a/ui/src/dashboard/view.rs
+++ b/ui/src/dashboard/view.rs
@@ -4,7 +4,7 @@ use anyhow::Error;
 use futures::join;
 use js_sys::Date;
 use pwt::widget::Dialog;
-use serde_json::json;
+use serde_json::{json, Value};
 use yew::virtual_dom::{VComp, VNode};
 
 use proxmox_yew_comp::http_get;
@@ -176,11 +176,20 @@ impl ViewComp {
             let (status, top_entities, tasks) = required_api_calls(&data.layout);
 
             self.loading = true;
+            let view = ctx.props().view.clone();
             self.async_pool.spawn(async move {
+                let add_view_filter = |params: &mut Value| {
+                    if let Some(view) = &view {
+                        params["view"] = view.to_string().into();
+                    }
+                };
                 let status_future = async {
                     if status {
-                        let res =
-                            http_get("/resources/status", Some(json!({"max-age": max_age}))).await;
+                        let mut params = json!({
+                            "max-age": max_age,
+                        });
+                        add_view_filter(&mut params);
+                        let res = http_get("/resources/status", Some(params)).await;
                         link.send_message(Msg::LoadingResult(LoadingResult::Resources(res)));
                     }
                 };
@@ -189,24 +198,31 @@ impl ViewComp {
                     if top_entities {
                         let client: pdm_client::PdmClient<Rc<proxmox_yew_comp::HttpClientWasm>> =
                             pdm_client();
-                        let res = client.get_top_entities(None).await;
+                        let res = client
+                            .get_top_entities(view.as_ref().map(|view| view.as_str()))
+                            .await;
                         link.send_message(Msg::LoadingResult(LoadingResult::TopEntities(res)));
                     }
                 };
 
                 let tasks_future = async {
                     if tasks {
-                        let params = Some(json!({
+                        let mut params = json!({
                             "since": since,
                             "limit": 0,
-                        }));
-                        let res = http_get("/remote-tasks/statistics", params).await;
+                        });
+                        add_view_filter(&mut params);
+                        let res = http_get("/remote-tasks/statistics", Some(params)).await;
                         link.send_message(Msg::LoadingResult(LoadingResult::TaskStatistics(res)));
                     }
                 };
 
                 let subs_future = async {
-                    let res = http_get("/resources/subscription?verbose=true", None).await;
+                    let mut params = json!({
+                        "verbose": true,
+                    });
+                    add_view_filter(&mut params);
+                    let res = http_get("/resources/subscription", Some(params)).await;
                     link.send_message(Msg::LoadingResult(LoadingResult::SubscriptionInfo(res)));
                 };
 
-- 
2.47.3



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


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

* [pdm-devel] [PATCH datacenter-manager v5 12/26] ui: views: save updated layout to backend
  2025-11-26 15:17 [pdm-devel] [PATCH datacenter-manager v5 00/26] enable custom views on the UI Dominik Csapak
                   ` (10 preceding siblings ...)
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 11/26] ui: views: add 'view' parameter to api calls Dominik Csapak
@ 2025-11-26 15:18 ` Dominik Csapak
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 13/26] ui: add view list context Dominik Csapak
                   ` (15 subsequent siblings)
  27 siblings, 0 replies; 29+ messages in thread
From: Dominik Csapak @ 2025-11-26 15:18 UTC (permalink / raw)
  To: pdm-devel

when we get a 'LayoutUpdate' message, update the view config with the
new layout.

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

diff --git a/ui/src/dashboard/view.rs b/ui/src/dashboard/view.rs
index 3a82cbc7..f1fafcd5 100644
--- a/ui/src/dashboard/view.rs
+++ b/ui/src/dashboard/view.rs
@@ -7,8 +7,8 @@ use pwt::widget::Dialog;
 use serde_json::{json, Value};
 use yew::virtual_dom::{VComp, VNode};
 
-use proxmox_yew_comp::http_get;
 use proxmox_yew_comp::percent_encoding::percent_encode_component;
+use proxmox_yew_comp::{http_get, http_put};
 use pwt::css;
 use pwt::prelude::*;
 use pwt::props::StorageLocation;
@@ -87,6 +87,7 @@ pub enum Msg {
     UpdateConfig(RefreshConfig),
     ShowSubscriptionsDialog(Option<Dialog>),
     LayoutUpdate(ViewLayout),
+    UpdateResult(Result<(), Error>),
 }
 
 struct ViewComp {
@@ -108,6 +109,7 @@ struct ViewComp {
     subscriptions_dialog: Option<Dialog>,
 
     editing_state: SharedState<Vec<EditingMessage>>,
+    update_result: LoadResult<(), Error>,
 }
 
 fn render_widget(
@@ -319,6 +321,7 @@ impl Component for ViewComp {
             subscriptions_dialog: None,
 
             editing_state: SharedState::new(Vec::new()),
+            update_result: LoadResult::new(),
         }
     }
 
@@ -378,11 +381,29 @@ impl Component for ViewComp {
                 self.subscriptions_dialog = dialog;
             }
             Msg::LayoutUpdate(view_layout) => {
-                // FIXME: update backend layout
+                let link = ctx.link().clone();
                 if let Some(template) = &mut self.template.data {
                     template.layout = view_layout;
+                    if let Some(view) = &ctx.props().view {
+                        let view = view.to_string();
+                        match serde_json::to_string(&template) {
+                            Ok(layout_str) => self.async_pool.spawn(async move {
+                                let params = json!({
+                                    "layout": layout_str,
+                                });
+
+                                let res =
+                                    http_put(format!("/config/views/{view}"), Some(params)).await;
+                                link.send_message(Msg::UpdateResult(res));
+                            }),
+                            Err(err) => self.template.update(Err(err.into())),
+                        };
+                    }
                 }
             }
+            Msg::UpdateResult(res) => {
+                self.update_result.update(res);
+            }
         }
         true
     }
@@ -484,6 +505,12 @@ impl Component for ViewComp {
                 .as_ref()
                 .map(|err| error_message(&err.to_string())),
         );
+        view.add_optional_child(
+            self.update_result
+                .error
+                .as_ref()
+                .map(|err| error_message(&err.to_string())),
+        );
         view.add_optional_child(self.show_config_window.then_some({
             let refresh_config_id = match &props.view {
                 Some(view) => format!("view-{view}"),
-- 
2.47.3



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


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

* [pdm-devel] [PATCH datacenter-manager v5 13/26] ui: add view list context
  2025-11-26 15:17 [pdm-devel] [PATCH datacenter-manager v5 00/26] enable custom views on the UI Dominik Csapak
                   ` (11 preceding siblings ...)
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 12/26] ui: views: save updated layout to backend Dominik Csapak
@ 2025-11-26 15:18 ` Dominik Csapak
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 14/26] ui: configuration: add view CRUD panels Dominik Csapak
                   ` (14 subsequent siblings)
  27 siblings, 0 replies; 29+ messages in thread
From: Dominik Csapak @ 2025-11-26 15:18 UTC (permalink / raw)
  To: pdm-devel

This wraps SharedState so that we can trigger a reload of the views
in 'main' from a component below without having to pass through a
callback.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/lib.rs               |  3 +++
 ui/src/view_list_context.rs | 31 +++++++++++++++++++++++++++++++
 2 files changed, 34 insertions(+)
 create mode 100644 ui/src/view_list_context.rs

diff --git a/ui/src/lib.rs b/ui/src/lib.rs
index 98761e00..27a51084 100644
--- a/ui/src/lib.rs
+++ b/ui/src/lib.rs
@@ -45,6 +45,9 @@ pub use load_result::LoadResult;
 mod tasks;
 pub use tasks::register_pve_tasks;
 
+mod view_list_context;
+pub use view_list_context::ViewListContext;
+
 pub fn pdm_client() -> pdm_client::PdmClient<std::rc::Rc<proxmox_yew_comp::HttpClientWasm>> {
     pdm_client::PdmClient(proxmox_yew_comp::CLIENT.with(|c| std::rc::Rc::clone(&c.borrow())))
 }
diff --git a/ui/src/view_list_context.rs b/ui/src/view_list_context.rs
new file mode 100644
index 00000000..c0e4a36e
--- /dev/null
+++ b/ui/src/view_list_context.rs
@@ -0,0 +1,31 @@
+use pwt::state::{SharedState, SharedStateObserver};
+use yew::Callback;
+
+#[derive(PartialEq, Clone)]
+/// Provides a context for updating and listening to changes of the list of views
+pub struct ViewListContext {
+    state: SharedState<usize>,
+}
+
+impl ViewListContext {
+    /// Create a new context
+    pub fn new() -> Self {
+        Self {
+            state: SharedState::new(0),
+        }
+    }
+
+    /// Add a listener to the view list context
+    pub fn add_listener(
+        &self,
+        cb: impl Into<Callback<SharedState<usize>>>,
+    ) -> SharedStateObserver<usize> {
+        self.state.add_listener(cb)
+    }
+
+    /// Triggers an update of the view list for the main menu
+    pub fn update_views(&self) {
+        let mut state = self.state.write();
+        **state = state.saturating_add(1);
+    }
+}
-- 
2.47.3



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


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

* [pdm-devel] [PATCH datacenter-manager v5 14/26] ui: configuration: add view CRUD panels
  2025-11-26 15:17 [pdm-devel] [PATCH datacenter-manager v5 00/26] enable custom views on the UI Dominik Csapak
                   ` (12 preceding siblings ...)
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 13/26] ui: add view list context Dominik Csapak
@ 2025-11-26 15:18 ` Dominik Csapak
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 15/26] ui: main menu: add optional view_list property Dominik Csapak
                   ` (13 subsequent siblings)
  27 siblings, 0 replies; 29+ messages in thread
From: Dominik Csapak @ 2025-11-26 15:18 UTC (permalink / raw)
  To: pdm-devel

it's a simple grid + edit window that enables adding/editing/removing
views.

We introduce two new widgets here too:
* ViewSelector: can select a view, used for selecting which view to copy
  the layout from.
* ViewFilterSelector: A grid for selecting the include/exclude filters,
  similar to e.g. the 'GroupFilters' in PBS are done.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
changes from v4:
* adapt to lukas changes (include-all, stringmatcher, etc.)

 ui/src/configuration/mod.rs           |   2 +
 ui/src/configuration/views.rs         | 370 ++++++++++++++++++++++
 ui/src/widget/mod.rs                  |   6 +
 ui/src/widget/view_filter_selector.rs | 421 ++++++++++++++++++++++++++
 ui/src/widget/view_selector.rs        |  55 ++++
 5 files changed, 854 insertions(+)
 create mode 100644 ui/src/configuration/views.rs
 create mode 100644 ui/src/widget/view_filter_selector.rs
 create mode 100644 ui/src/widget/view_selector.rs

diff --git a/ui/src/configuration/mod.rs b/ui/src/configuration/mod.rs
index 76d02cb9..35114336 100644
--- a/ui/src/configuration/mod.rs
+++ b/ui/src/configuration/mod.rs
@@ -13,6 +13,8 @@ mod permission_path_selector;
 mod webauthn;
 pub use webauthn::WebauthnPanel;
 
+pub mod views;
+
 #[function_component(SystemConfiguration)]
 pub fn system_configuration() -> Html {
     let panel = TabPanel::new()
diff --git a/ui/src/configuration/views.rs b/ui/src/configuration/views.rs
new file mode 100644
index 00000000..1e2a9984
--- /dev/null
+++ b/ui/src/configuration/views.rs
@@ -0,0 +1,370 @@
+use std::future::Future;
+use std::pin::Pin;
+use std::rc::Rc;
+
+use anyhow::{bail, Error};
+use proxmox_yew_comp::form::delete_empty_values;
+use proxmox_yew_comp::percent_encoding::percent_encode_component;
+use yew::virtual_dom::{Key, VComp, VNode};
+
+use proxmox_yew_comp::{
+    http_delete, http_get, http_post, http_put, EditWindow, LoadableComponent,
+    LoadableComponentContext, LoadableComponentMaster,
+};
+use pwt::prelude::*;
+use pwt::state::{Selection, Store};
+use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader};
+use pwt::widget::form::{Checkbox, DisplayField, Field, FormContext};
+use pwt::widget::{Button, ConfirmDialog, InputPanel, Toolbar};
+
+use pdm_api_types::views::{ViewConfig, ViewLayout, ViewTemplate};
+
+use crate::widget::{ViewFilterSelector, ViewSelector};
+use crate::ViewListContext;
+
+async fn create_view(
+    base_url: AttrValue,
+    store: Store<ViewConfig>,
+    form_ctx: FormContext,
+) -> Result<(), Error> {
+    let mut data = form_ctx.get_submit_data();
+    let layout = form_ctx.read().get_field_text("copy-from");
+    let layout = match layout.as_str() {
+        "" => Some(serde_json::to_string(&ViewTemplate {
+            description: String::new(),
+            layout: ViewLayout::Rows { rows: Vec::new() },
+        })?),
+        "__dashboard__" => None,
+        layout => {
+            let store = store.read();
+            if let Some(config) = store.lookup_record(&Key::from(layout)) {
+                Some(config.layout.clone())
+            } else {
+                bail!("Source View not found")
+            }
+        }
+    };
+
+    if let Some(layout) = layout {
+        data["layout"] = layout.into();
+    }
+
+    let config: ViewConfig = serde_json::from_value(data)?;
+
+    http_post(base_url.as_str(), Some(serde_json::to_value(config)?)).await
+}
+
+async fn update_view(base_url: AttrValue, form_ctx: FormContext) -> Result<(), Error> {
+    let data = form_ctx.get_submit_data();
+    let id = form_ctx.read().get_field_text("id");
+    let params = delete_empty_values(&data, &["include", "exclude", "include-all"], true);
+    let id = percent_encode_component(&id);
+
+    http_put(&format!("{base_url}/{id}"), Some(params)).await
+}
+
+#[derive(PartialEq, Clone, Properties)]
+pub struct ViewGrid {
+    #[prop_or("/config/views".into())]
+    base_url: AttrValue,
+}
+
+impl ViewGrid {
+    pub fn new() -> Self {
+        yew::props!(Self {})
+    }
+}
+
+impl Default for ViewGrid {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl From<ViewGrid> for VNode {
+    fn from(val: ViewGrid) -> Self {
+        VComp::new::<LoadableComponentMaster<ViewGridComp>>(Rc::new(val), None).into()
+    }
+}
+
+pub enum Msg {
+    SelectionChanged,
+    LoadFinished(Vec<ViewConfig>),
+    Remove(Key),
+    Reload,
+}
+
+#[derive(PartialEq)]
+pub enum ViewState {
+    Create,
+    Edit,
+    Remove,
+}
+
+#[doc(hidden)]
+pub struct ViewGridComp {
+    store: Store<ViewConfig>,
+    columns: Rc<Vec<DataTableHeader<ViewConfig>>>,
+    selection: Selection,
+}
+
+impl ViewGridComp {
+    fn columns() -> Rc<Vec<DataTableHeader<ViewConfig>>> {
+        let columns = vec![
+            DataTableColumn::new("ID")
+                .flex(5)
+                .get_property(|value: &ViewConfig| value.id.as_str())
+                .sort_order(true)
+                .into(),
+            DataTableColumn::new(tr!("# Included"))
+                .flex(1)
+                .sorter(|a: &ViewConfig, b: &ViewConfig| {
+                    let a = if a.include_all.unwrap_or_default() {
+                        usize::MAX
+                    } else {
+                        a.include.len()
+                    };
+                    let b = if b.include_all.unwrap_or_default() {
+                        usize::MAX
+                    } else {
+                        b.include.len()
+                    };
+                    a.cmp(&b)
+                })
+                .render(|value: &ViewConfig| {
+                    if value.include_all.unwrap_or_default() {
+                        tr!("All").into()
+                    } else {
+                        value.include.len().into()
+                    }
+                })
+                .into(),
+            DataTableColumn::new(tr!("# Excluded"))
+                .flex(1)
+                .get_property_owned(|value: &ViewConfig| value.exclude.len())
+                .into(),
+            DataTableColumn::new(tr!("Custom Layout"))
+                .flex(1)
+                .render(|value: &ViewConfig| {
+                    if value.layout.is_empty() {
+                        tr!("No").into()
+                    } else {
+                        tr!("Yes").into()
+                    }
+                })
+                .into(),
+        ];
+
+        Rc::new(columns)
+    }
+
+    fn create_add_dialog(&self, ctx: &LoadableComponentContext<Self>) -> Html {
+        let props = ctx.props();
+        let store = self.store.clone();
+        EditWindow::new(tr!("Add") + ": " + &tr!("View"))
+            .renderer(move |form_ctx: &FormContext| add_view_input_panel(form_ctx, store.clone()))
+            .on_submit({
+                let base_url = props.base_url.clone();
+                let store = self.store.clone();
+                move |form| create_view(base_url.clone(), store.clone(), form)
+            })
+            .on_done(ctx.link().clone().callback(|_| Msg::Reload))
+            .into()
+    }
+
+    fn create_edit_dialog(&self, selection: Key, ctx: &LoadableComponentContext<Self>) -> Html {
+        let props = ctx.props();
+        let id = selection.to_string();
+        EditWindow::new(tr!("Edit") + ": " + &tr!("View"))
+            .renderer(move |form_ctx| edit_view_input_panel(form_ctx, id.clone()))
+            .on_submit({
+                let base_url = props.base_url.clone();
+                move |form| update_view(base_url.clone(), form)
+            })
+            .loader(format!(
+                "{}/{}",
+                props.base_url,
+                percent_encode_component(&selection)
+            ))
+            .on_done(ctx.link().callback(|_| Msg::Reload))
+            .into()
+    }
+}
+
+impl LoadableComponent for ViewGridComp {
+    type Properties = ViewGrid;
+    type Message = Msg;
+    type ViewState = ViewState;
+
+    fn create(ctx: &proxmox_yew_comp::LoadableComponentContext<Self>) -> Self {
+        let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::SelectionChanged));
+        Self {
+            store: Store::with_extract_key(|config: &ViewConfig| config.id.as_str().into()),
+            columns: Self::columns(),
+            selection,
+        }
+    }
+
+    fn update(
+        &mut self,
+        ctx: &proxmox_yew_comp::LoadableComponentContext<Self>,
+        msg: Self::Message,
+    ) -> bool {
+        match msg {
+            Msg::LoadFinished(data) => self.store.set_data(data),
+            Msg::Remove(key) => {
+                if let Some(rec) = self.store.read().lookup_record(&key) {
+                    let id = rec.id.clone();
+                    let link = ctx.link().clone();
+                    let base_url = ctx.props().base_url.clone();
+                    ctx.link().spawn(async move {
+                        match http_delete(format!("{base_url}/{id}"), None).await {
+                            Ok(()) => {}
+                            Err(err) => {
+                                link.show_error(
+                                    tr!("Error"),
+                                    tr!("Could not delete '{0}': '{1}'", id, err),
+                                    true,
+                                );
+                            }
+                        }
+                        link.send_message(Msg::Reload);
+                    });
+                }
+            }
+            Msg::SelectionChanged => {}
+            Msg::Reload => {
+                ctx.link().change_view(None);
+                ctx.link().send_reload();
+                if let Some((context, _)) = ctx
+                    .link()
+                    .yew_link()
+                    .context::<ViewListContext>(Callback::from(|_| {}))
+                {
+                    context.update_views();
+                }
+            }
+        }
+        true
+    }
+
+    fn toolbar(&self, ctx: &proxmox_yew_comp::LoadableComponentContext<Self>) -> Option<Html> {
+        let selection = self.selection.selected_key();
+        let link = ctx.link();
+        Some(
+            Toolbar::new()
+                .border_bottom(true)
+                .with_child(
+                    Button::new(tr!("Add"))
+                        .on_activate(link.change_view_callback(|_| Some(ViewState::Create))),
+                )
+                .with_child(
+                    Button::new(tr!("Edit"))
+                        .disabled(selection.is_none())
+                        .on_activate(link.change_view_callback(move |_| Some(ViewState::Edit))),
+                )
+                .with_child(
+                    Button::new(tr!("Remove"))
+                        .disabled(selection.is_none())
+                        .on_activate(link.change_view_callback(move |_| Some(ViewState::Remove))),
+                )
+                .into(),
+        )
+    }
+
+    fn load(
+        &self,
+        ctx: &proxmox_yew_comp::LoadableComponentContext<Self>,
+    ) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
+        let base_url = ctx.props().base_url.clone();
+        let link = ctx.link().clone();
+        Box::pin(async move {
+            let data: Vec<ViewConfig> = http_get(base_url.as_str(), None).await?;
+            link.send_message(Msg::LoadFinished(data));
+            Ok(())
+        })
+    }
+
+    fn main_view(&self, ctx: &proxmox_yew_comp::LoadableComponentContext<Self>) -> Html {
+        let link = ctx.link();
+        DataTable::new(self.columns.clone(), self.store.clone())
+            .on_row_dblclick(move |_: &mut _| link.change_view(Some(ViewState::Edit)))
+            .selection(self.selection.clone())
+            .into()
+    }
+
+    fn dialog_view(
+        &self,
+        ctx: &proxmox_yew_comp::LoadableComponentContext<Self>,
+        view_state: &Self::ViewState,
+    ) -> Option<Html> {
+        match view_state {
+            ViewState::Create => Some(self.create_add_dialog(ctx)),
+            ViewState::Edit => self
+                .selection
+                .selected_key()
+                .map(|key| self.create_edit_dialog(key, ctx)),
+            ViewState::Remove => self.selection.selected_key().map(|key| {
+                ConfirmDialog::new(
+                    tr!("Confirm"),
+                    tr!("Are you sure you want to remove '{0}'", key.to_string()),
+                )
+                .on_confirm({
+                    let link = ctx.link().clone();
+                    let key = key.clone();
+                    move |_| {
+                        link.send_message(Msg::Remove(key.clone()));
+                    }
+                })
+                .into()
+            }),
+        }
+    }
+}
+
+fn add_view_input_panel(form_ctx: &FormContext, store: Store<ViewConfig>) -> Html {
+    let include_all = form_ctx.read().get_field_checked("include-all");
+    InputPanel::new()
+        .padding(4)
+        .with_field(tr!("Name"), Field::new().name("id").required(true))
+        .with_right_field(
+            tr!("Copy Layout from"),
+            ViewSelector::new(store)
+                .placeholder(tr!("None"))
+                .name("copy-from"),
+        )
+        .with_field(
+            tr!("Include All"),
+            Checkbox::new().name("include-all").default(true),
+        )
+        .with_field_and_options(
+            pwt::widget::FieldPosition::Large,
+            false,
+            include_all,
+            tr!("Include"),
+            ViewFilterSelector::new()
+                .name("include")
+                .disabled(include_all),
+        )
+        .with_large_field(tr!("Exclude"), ViewFilterSelector::new().name("exclude"))
+        .into()
+}
+
+fn edit_view_input_panel(form_ctx: &FormContext, id: String) -> Html {
+    let include_all = form_ctx.read().get_field_checked("include-all");
+    InputPanel::new()
+        .padding(4)
+        .with_field(tr!("Name"), DisplayField::new().name("id").value(id))
+        .with_field(tr!("Include All"), Checkbox::new().name("include-all"))
+        .with_field_and_options(
+            pwt::widget::FieldPosition::Large,
+            false,
+            include_all,
+            tr!("Include"),
+            ViewFilterSelector::new()
+                .name("include")
+                .disabled(include_all),
+        )
+        .with_large_field(tr!("Exclude"), ViewFilterSelector::new().name("exclude"))
+        .into()
+}
diff --git a/ui/src/widget/mod.rs b/ui/src/widget/mod.rs
index 9d7840c1..97e7e472 100644
--- a/ui/src/widget/mod.rs
+++ b/ui/src/widget/mod.rs
@@ -26,3 +26,9 @@ mod remote_selector;
 pub use remote_selector::RemoteSelector;
 
 mod remote_endpoint_selector;
+
+mod view_selector;
+pub use view_selector::ViewSelector;
+
+mod view_filter_selector;
+pub use view_filter_selector::ViewFilterSelector;
diff --git a/ui/src/widget/view_filter_selector.rs b/ui/src/widget/view_filter_selector.rs
new file mode 100644
index 00000000..18393752
--- /dev/null
+++ b/ui/src/widget/view_filter_selector.rs
@@ -0,0 +1,421 @@
+use std::rc::Rc;
+use std::str::FromStr;
+
+use anyhow::{bail, Error};
+use pdm_api_types::resource::ResourceType;
+use pwt::css;
+use pwt::widget::{ActionIcon, Button, Column, Row};
+use serde_json::Value;
+use yew::virtual_dom::Key;
+
+use pwt::prelude::*;
+use pwt::state::Store;
+use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader};
+use pwt::widget::form::{
+    Combobox, Field, ManagedField, ManagedFieldContext, ManagedFieldMaster, ManagedFieldState,
+};
+use pwt_macros::widget;
+
+use pdm_api_types::views::{
+    EnumMatcher, FilterRule, StringMatcher, FILTER_RULE_LIST_SCHEMA, FILTER_RULE_SCHEMA,
+};
+
+use crate::widget::RemoteSelector;
+
+#[derive(PartialEq, Clone)]
+struct FilterRuleEntry {
+    index: usize,
+    filter: Option<FilterRule>,
+}
+
+#[derive(PartialEq, Clone, Copy)]
+enum FilterRuleType {
+    ResourceType,
+    ResourcePool,
+    ResourceId,
+    Tag,
+    Remote,
+}
+
+impl FromStr for FilterRuleType {
+    type Err = Error;
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        Ok(match s {
+            "resource-type" => FilterRuleType::ResourceType,
+            "resource-pool" => FilterRuleType::ResourcePool,
+            "resource-id" => FilterRuleType::ResourceId,
+            "tag" => FilterRuleType::Tag,
+            "remote" => FilterRuleType::Remote,
+            _ => bail!("unknown filter type"),
+        })
+    }
+}
+
+impl From<FilterRuleType> for AttrValue {
+    fn from(value: FilterRuleType) -> Self {
+        match value {
+            FilterRuleType::ResourceType => "resource-type".into(),
+            FilterRuleType::ResourcePool => "resource-pool".into(),
+            FilterRuleType::ResourceId => "resource-id".into(),
+            FilterRuleType::Tag => "tag".into(),
+            FilterRuleType::Remote => "remote".into(),
+        }
+    }
+}
+
+impl From<&FilterRule> for FilterRuleType {
+    fn from(value: &FilterRule) -> Self {
+        match value {
+            FilterRule::ResourceType(_) => FilterRuleType::ResourceType,
+            FilterRule::ResourcePool(_) => FilterRuleType::ResourcePool,
+            FilterRule::ResourceId(_) => FilterRuleType::ResourceId,
+            FilterRule::Tag(_) => FilterRuleType::Tag,
+            FilterRule::Remote(_) => FilterRuleType::Remote,
+        }
+    }
+}
+
+#[widget(comp=ManagedFieldMaster<ViewFilterSelectorComp>, @input)]
+#[derive(PartialEq, Clone, Properties)]
+pub struct ViewFilterSelector {}
+
+impl ViewFilterSelector {
+    pub fn new() -> Self {
+        yew::props!(Self {})
+    }
+}
+
+pub struct ViewFilterSelectorComp {
+    store: Store<FilterRuleEntry>,
+}
+
+impl ViewFilterSelectorComp {
+    fn update_value(&self, ctx: &ManagedFieldContext<Self>) {
+        let store = self.store.read();
+        let value: Vec<_> = store
+            .iter()
+            .map(|entry| entry.filter.as_ref().map(|filter| filter.to_string()))
+            .collect();
+
+        ctx.link().update_value(value);
+    }
+}
+
+pub enum Msg {
+    Add,
+    Remove(usize),                   // index
+    ChangeFilter(FilterRule, usize), // index
+}
+
+impl ManagedField for ViewFilterSelectorComp {
+    type Properties = ViewFilterSelector;
+    type Message = Msg;
+    type ValidateClosure = bool;
+
+    fn validation_args(props: &Self::Properties) -> Self::ValidateClosure {
+        props.input_props.required
+    }
+
+    fn update(
+        &mut self,
+        ctx: &pwt::widget::form::ManagedFieldContext<Self>,
+        msg: Self::Message,
+    ) -> bool {
+        match msg {
+            Msg::Add => {
+                let mut store = self.store.write();
+                let index = store.len();
+                store.push(FilterRuleEntry {
+                    index,
+                    filter: None,
+                });
+                drop(store);
+                self.update_value(ctx);
+            }
+            Msg::Remove(index) => {
+                let data: Vec<FilterRuleEntry> = self
+                    .store
+                    .read()
+                    .iter()
+                    .filter(move |&item| item.index != index)
+                    .cloned()
+                    .enumerate()
+                    .map(|(index, mut old)| {
+                        old.index = index;
+                        old
+                    })
+                    .collect();
+                self.store.set_data(data);
+                self.update_value(ctx);
+            }
+            Msg::ChangeFilter(filter_rule, index) => {
+                let mut store = self.store.write();
+                if let Some(rec) = store.lookup_record_mut(&Key::from(index)) {
+                    rec.filter = Some(filter_rule);
+                }
+                drop(store);
+                self.update_value(ctx);
+            }
+        }
+
+        true
+    }
+
+    fn setup(_props: &Self::Properties) -> pwt::widget::form::ManagedFieldState {
+        ManagedFieldState::new(Value::Array(Vec::new()), Value::Array(Vec::new()))
+    }
+
+    fn validator(required: &Self::ValidateClosure, value: &Value) -> Result<Value, anyhow::Error> {
+        FILTER_RULE_LIST_SCHEMA.verify_json(value)?;
+
+        if value.is_null() && *required {
+            bail!("value required");
+        }
+
+        Ok(value.clone())
+    }
+
+    fn create(_ctx: &pwt::widget::form::ManagedFieldContext<Self>) -> Self {
+        let store = Store::with_extract_key(|rule: &FilterRuleEntry| Key::from(rule.index));
+
+        Self { store }
+    }
+
+    fn value_changed(&mut self, ctx: &ManagedFieldContext<Self>) {
+        if let Ok(data) = serde_json::from_value::<Vec<FilterRule>>(ctx.state().value.clone()) {
+            self.store.set_data(
+                data.into_iter()
+                    .enumerate()
+                    .map(|(index, filter)| FilterRuleEntry {
+                        index,
+                        filter: Some(filter),
+                    })
+                    .collect(),
+            );
+        }
+    }
+
+    fn view(&self, ctx: &pwt::widget::form::ManagedFieldContext<Self>) -> Html {
+        let toolbar = Row::new().with_child(
+            Button::new(tr!("Add"))
+                .class(css::ColorScheme::Primary)
+                .icon_class("fa fa-plus-circle")
+                .on_activate(ctx.link().callback(|_| Msg::Add)),
+        );
+        Column::new()
+            .gap(2)
+            .with_child(
+                DataTable::new(columns(ctx), self.store.clone())
+                    .border(true)
+                    .height(200),
+            )
+            .with_child(toolbar)
+            .into()
+    }
+}
+
+fn columns(
+    ctx: &ManagedFieldContext<ViewFilterSelectorComp>,
+) -> Rc<Vec<DataTableHeader<FilterRuleEntry>>> {
+    let link = ctx.link().clone();
+    let columns = vec![
+        DataTableColumn::new(tr!("Type"))
+            .render({
+                let link = link.clone();
+                move |entry: &FilterRuleEntry| {
+                    let index = entry.index;
+                    let filter_type = entry.filter.as_ref().map(FilterRuleType::from);
+                    Combobox::new()
+                        .placeholder(tr!("Select"))
+                        .required(true)
+                        .default(filter_type.map(AttrValue::from))
+                        .on_change({
+                            let link = link.clone();
+                            move |value: String| {
+                                let filter = match FilterRuleType::from_str(value.as_str()) {
+                                    Ok(FilterRuleType::ResourceType) => {
+                                        FilterRule::ResourceType(EnumMatcher(ResourceType::Node))
+                                    }
+                                    Ok(FilterRuleType::ResourcePool) => FilterRule::ResourcePool(
+                                        StringMatcher::Exact(String::new()),
+                                    ),
+                                    Ok(FilterRuleType::ResourceId) => {
+                                        FilterRule::ResourceId(StringMatcher::Exact(String::new()))
+                                    }
+                                    Ok(FilterRuleType::Tag) => {
+                                        FilterRule::Tag(StringMatcher::Exact(String::new()))
+                                    }
+                                    Ok(FilterRuleType::Remote) => {
+                                        FilterRule::Remote(StringMatcher::Exact(String::new()))
+                                    }
+                                    Err(_) => return,
+                                };
+
+                                link.send_message(Msg::ChangeFilter(filter, index));
+                            }
+                        })
+                        .items(Rc::new(vec![
+                            FilterRuleType::ResourceType.into(),
+                            FilterRuleType::ResourcePool.into(),
+                            FilterRuleType::ResourceId.into(),
+                            FilterRuleType::Tag.into(),
+                            FilterRuleType::Remote.into(),
+                        ]))
+                        .render_value(|value: &AttrValue| {
+                            if value.as_str().is_empty() {
+                                return "".into();
+                            }
+                            match FilterRuleType::from_str(value.as_str()) {
+                                Ok(FilterRuleType::ResourceType) => tr!("Resource Type"),
+                                Ok(FilterRuleType::ResourcePool) => tr!("Resource Pool"),
+                                Ok(FilterRuleType::ResourceId) => tr!("Resource ID"),
+                                Ok(FilterRuleType::Tag) => tr!("Tag"),
+                                Ok(FilterRuleType::Remote) => tr!("Remote"),
+                                Err(err) => tr!("invalid type: {0}", err.to_string()),
+                            }
+                            .into()
+                        })
+                        .into()
+                }
+            })
+            .into(),
+        DataTableColumn::new(tr!("Value"))
+            .render({
+                let link = link.clone();
+                move |entry: &FilterRuleEntry| {
+                    let index = entry.index;
+
+                    let send_change = {
+                        let link = link.clone();
+                        move |rule: FilterRule| {
+                            link.send_message(Msg::ChangeFilter(rule, index));
+                        }
+                    };
+                    match entry.filter.as_ref() {
+                        Some(FilterRule::ResourceType(resource_type)) => Combobox::new()
+                            .required(true)
+                            .value(resource_type.0.to_string())
+                            .items(Rc::new(vec![
+                                ResourceType::Node.to_string().into(),
+                                ResourceType::PveQemu.to_string().into(),
+                                ResourceType::PveLxc.to_string().into(),
+                                ResourceType::PveStorage.to_string().into(),
+                                ResourceType::PveNetwork.to_string().into(),
+                                ResourceType::PbsDatastore.to_string().into(),
+                            ]))
+                            .render_value(|value: &AttrValue| {
+                                if value.as_str().is_empty() {
+                                    return "".into();
+                                }
+                                match ResourceType::from_str(value.as_str()) {
+                                    Ok(ResourceType::Node) => tr!("Node"),
+                                    Ok(ResourceType::PveQemu) => tr!("Virtual Machine"),
+                                    Ok(ResourceType::PveLxc) => tr!("Container"),
+                                    Ok(ResourceType::PveStorage) => tr!("PVE Storage"),
+                                    Ok(ResourceType::PveNetwork) => tr!("PVE Network"),
+                                    Ok(ResourceType::PbsDatastore) => tr!("PBS Datastore"),
+                                    Err(err) => tr!("invalid type: {0}", err.to_string()),
+                                }
+                                .into()
+                            })
+                            .on_change({
+                                move |value: String| {
+                                    if let Ok(resource_type) =
+                                        ResourceType::from_str(value.as_str())
+                                    {
+                                        send_change(FilterRule::ResourceType(EnumMatcher(
+                                            resource_type,
+                                        )));
+                                    }
+                                }
+                            })
+                            .into(),
+                        Some(FilterRule::ResourceId(id)) => Field::new()
+                            .value(match id {
+                                StringMatcher::Exact(id) => id.clone(),
+                            })
+                            .required(true)
+                            .validate(|value: &String| {
+                                let value =
+                                    FilterRule::ResourceId(StringMatcher::Exact(value.to_owned()))
+                                        .to_string();
+                                FILTER_RULE_SCHEMA.parse_simple_value(&value)?;
+                                Ok(())
+                            })
+                            .on_change({
+                                move |value: String| {
+                                    send_change(FilterRule::ResourceId(StringMatcher::Exact(
+                                        value,
+                                    )));
+                                }
+                            })
+                            .into(),
+                        Some(FilterRule::ResourcePool(pool)) => Field::new()
+                            .value(match pool {
+                                StringMatcher::Exact(pool) => pool.clone(),
+                            })
+                            .required(true)
+                            .validate(|value: &String| {
+                                let value = FilterRule::ResourcePool(StringMatcher::Exact(
+                                    value.to_owned(),
+                                ))
+                                .to_string();
+                                FILTER_RULE_SCHEMA.parse_simple_value(&value)?;
+                                Ok(())
+                            })
+                            .on_change({
+                                move |value: String| {
+                                    send_change(FilterRule::ResourcePool(StringMatcher::Exact(
+                                        value,
+                                    )));
+                                }
+                            })
+                            .into(),
+                        Some(FilterRule::Tag(tag)) => Field::new()
+                            .value(match tag {
+                                StringMatcher::Exact(tag) => tag.clone(),
+                            })
+                            .required(true)
+                            .validate(|value: &String| {
+                                let value = FilterRule::Tag(StringMatcher::Exact(value.to_owned()))
+                                    .to_string();
+                                FILTER_RULE_SCHEMA.parse_simple_value(&value)?;
+                                Ok(())
+                            })
+                            .on_change({
+                                move |value: String| {
+                                    send_change(FilterRule::Tag(StringMatcher::Exact(value)));
+                                }
+                            })
+                            .into(),
+                        Some(FilterRule::Remote(remote)) => RemoteSelector::new()
+                            .value(match remote {
+                                StringMatcher::Exact(remote) => remote.clone(),
+                            })
+                            .required(true)
+                            .on_change(move |value| {
+                                send_change(FilterRule::Remote(StringMatcher::Exact(value)))
+                            })
+                            .into(),
+                        None => Field::new()
+                            .placeholder(tr!("Select Type first"))
+                            .disabled(true)
+                            .into(),
+                    }
+                }
+            })
+            .into(),
+        DataTableColumn::new("")
+            .width("50px")
+            .render(move |entry: &FilterRuleEntry| {
+                let index = entry.index;
+                ActionIcon::new("fa fa-lg fa-trash-o")
+                    .tabindex(0)
+                    .on_activate(link.callback(move |_| Msg::Remove(index)))
+                    .into()
+            })
+            .into(),
+    ];
+
+    Rc::new(columns)
+}
diff --git a/ui/src/widget/view_selector.rs b/ui/src/widget/view_selector.rs
new file mode 100644
index 00000000..b48ef4f7
--- /dev/null
+++ b/ui/src/widget/view_selector.rs
@@ -0,0 +1,55 @@
+use std::rc::Rc;
+
+use pwt::prelude::*;
+use pwt::state::Store;
+use pwt::widget::form::Combobox;
+use pwt_macros::{builder, widget};
+
+use pdm_api_types::views::ViewConfig;
+
+#[widget(comp=ViewSelectorComp, @input)]
+#[derive(Clone, Properties, PartialEq)]
+#[builder]
+pub struct ViewSelector {
+    store: Store<ViewConfig>,
+}
+
+impl ViewSelector {
+    pub fn new(store: Store<ViewConfig>) -> Self {
+        yew::props!(Self { store })
+    }
+}
+
+#[doc(hidden)]
+pub struct ViewSelectorComp {}
+
+impl Component for ViewSelectorComp {
+    type Message = ();
+    type Properties = ViewSelector;
+
+    fn create(_ctx: &Context<Self>) -> Self {
+        Self {}
+    }
+
+    fn view(&self, ctx: &Context<Self>) -> Html {
+        let mut list = vec!["__dashboard__".into()];
+        let store = &ctx.props().store;
+        for item in store.read().data().iter() {
+            list.push(item.id.clone().into());
+        }
+        Combobox::new()
+            .items(Rc::new(list))
+            .with_input_props(&ctx.props().input_props)
+            .on_change(|_| {})
+            .render_value({
+                move |value: &AttrValue| {
+                    if value == "__dashboard__" {
+                        html! {{tr!("Dashboard")}}
+                    } else {
+                        html! {{value}}
+                    }
+                }
+            })
+            .into()
+    }
+}
-- 
2.47.3



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


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

* [pdm-devel] [PATCH datacenter-manager v5 15/26] ui: main menu: add optional view_list property
  2025-11-26 15:17 [pdm-devel] [PATCH datacenter-manager v5 00/26] enable custom views on the UI Dominik Csapak
                   ` (13 preceding siblings ...)
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 14/26] ui: configuration: add view CRUD panels Dominik Csapak
@ 2025-11-26 15:18 ` Dominik Csapak
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 16/26] ui: load view list on page init Dominik Csapak
                   ` (12 subsequent siblings)
  27 siblings, 0 replies; 29+ messages in thread
From: Dominik Csapak @ 2025-11-26 15:18 UTC (permalink / raw)
  To: pdm-devel

to show them in the navigation. The main 'View' entry point leads to the
CRUD grid for editing, and the views are listed below as separate
entries.

If an entry is selected but we did not get any list (e.g. it's not
loaded yet), simply add a dummy entry for that view. This prevent issues
when the page is reloaded on a view.

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

diff --git a/ui/src/main_menu.rs b/ui/src/main_menu.rs
index e50705dc..a9827c73 100644
--- a/ui/src/main_menu.rs
+++ b/ui/src/main_menu.rs
@@ -5,7 +5,7 @@ use yew::virtual_dom::{Key, VComp, VNode};
 
 use pwt::css::{self, Display, FlexFit};
 use pwt::prelude::*;
-use pwt::state::Selection;
+use pwt::state::{NavigationContextExt, Selection};
 use pwt::widget::nav::{Menu, MenuItem, NavigationDrawer};
 use pwt::widget::{Container, Row, SelectionView, SelectionViewRenderInfo};
 
@@ -14,6 +14,7 @@ use proxmox_yew_comp::{AclContext, NotesView, XTermJs};
 use pdm_api_types::remotes::RemoteType;
 use pdm_api_types::{PRIV_SYS_AUDIT, PRIV_SYS_MODIFY};
 
+use crate::configuration::views::ViewGrid;
 use crate::dashboard::view::View;
 use crate::remotes::RemotesPanel;
 use crate::sdn::evpn::EvpnPanel;
@@ -54,6 +55,10 @@ pub struct MainMenu {
     #[builder]
     #[prop_or_default]
     pub remote_list: Vec<RemoteListCacheEntry>,
+
+    #[builder]
+    #[prop_or_default]
+    pub view_list: Vec<String>,
 }
 
 impl MainMenu {
@@ -145,6 +150,14 @@ impl Component for PdmMainMenu {
         let scope = ctx.link().clone();
         let props = ctx.props();
 
+        let route_view = match ctx.link().nav_context() {
+            Some(nav) => match nav.path().split_once("-") {
+                Some(("view", view)) => Some(view.to_string()),
+                _ => None,
+            },
+            None => None,
+        };
+
         let mut content = SelectionView::new()
             .class(FlexFit)
             .selection(self.menu_selection.clone());
@@ -160,6 +173,46 @@ impl Component for PdmMainMenu {
             move |_| View::new(None).into(),
         );
 
+        let mut views = Menu::new();
+
+        let mut found = false;
+
+        for view in &props.view_list {
+            let view = view.to_string();
+            if route_view.as_ref() == Some(&view) {
+                found = true;
+            }
+            register_view(
+                &mut views,
+                &mut content,
+                view.clone(),
+                &format!("view-{view}"),
+                Some("fa fa-eye"),
+                move |_| View::new(Some(view.clone().into())).into(),
+            );
+        }
+
+        if let (false, Some(view)) = (found, route_view) {
+            register_view(
+                &mut views,
+                &mut content,
+                view.clone(),
+                &format!("view-{view}"),
+                Some("fa fa-tachometer"),
+                move |_| View::new(Some(view.clone().into())).into(),
+            );
+        }
+
+        register_submenu(
+            &mut menu,
+            &mut content,
+            tr!("Views"),
+            "views",
+            Some("fa fa-tachometer"),
+            move |_| ViewGrid::new().into(),
+            views,
+        );
+
         if self.acl_context.check_privs(&["system"], PRIV_SYS_AUDIT) {
             let allow_editing = self
                 .acl_context
-- 
2.47.3



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


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

* [pdm-devel] [PATCH datacenter-manager v5 16/26] ui: load view list on page init
  2025-11-26 15:17 [pdm-devel] [PATCH datacenter-manager v5 00/26] enable custom views on the UI Dominik Csapak
                   ` (14 preceding siblings ...)
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 15/26] ui: main menu: add optional view_list property Dominik Csapak
@ 2025-11-26 15:18 ` Dominik Csapak
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 17/26] lib/ui: move views types to pdm-api-types Dominik Csapak
                   ` (11 subsequent siblings)
  27 siblings, 0 replies; 29+ messages in thread
From: Dominik Csapak @ 2025-11-26 15:18 UTC (permalink / raw)
  To: pdm-devel

and provide a ViewListContext for the components below, so we can
refresh the list when it's requested.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/main.rs | 48 +++++++++++++++++++++++++++++++++++++++++++++---
 1 file changed, 45 insertions(+), 3 deletions(-)

diff --git a/ui/src/main.rs b/ui/src/main.rs
index c586d981..2cbc7a9b 100644
--- a/ui/src/main.rs
+++ b/ui/src/main.rs
@@ -10,7 +10,7 @@ use yew::prelude::*;
 
 use pwt::prelude::*;
 use pwt::props::TextRenderFn;
-use pwt::state::{Loader, PersistentState};
+use pwt::state::{Loader, PersistentState, SharedStateObserver};
 use pwt::widget::{Column, DesktopApp, Dialog, Mask};
 
 use pbs_api_types::TaskListItem;
@@ -23,11 +23,14 @@ use proxmox_yew_comp::{
 
 //use pbs::MainMenu;
 use pdm_api_types::subscription::{RemoteSubscriptionState, RemoteSubscriptions};
+use pdm_api_types::views::ViewConfig;
 use pdm_ui::{
     register_pve_tasks, MainMenu, RemoteList, RemoteListCacheEntry, SearchProvider, TopNavBar,
+    ViewListContext,
 };
 
 type MsgRemoteList = Result<RemoteList, Error>;
+type MsgViewList = Result<Vec<String>, Error>;
 
 enum Msg {
     ConfirmSubscription,
@@ -37,6 +40,8 @@ enum Msg {
     Logout,
     TaskChanged,
     RemoteList(MsgRemoteList),
+    ViewList(MsgViewList),
+    UpdateViewList,
 }
 
 struct DatacenterManagerApp {
@@ -51,6 +56,10 @@ struct DatacenterManagerApp {
     remote_list_error: Option<String>,
     remote_list_timeout: Option<Timeout>,
     search_provider: SearchProvider,
+
+    view_list: Vec<String>,
+    view_list_context: ViewListContext,
+    _view_list_observer: SharedStateObserver<usize>,
 }
 
 async fn check_subscription() -> Msg {
@@ -95,9 +104,24 @@ impl DatacenterManagerApp {
             //ctx.link().send_future_batch(get_fingerprint());
             //
             self.remote_list_timeout = Self::poll_remote_list(ctx, true);
+            self.update_views(ctx);
         }
     }
 
+    fn update_views(&mut self, ctx: &Context<Self>) {
+        ctx.link().send_future(async move {
+            let res = http_get("/config/views", None)
+                .await
+                .map(|list: Vec<ViewConfig>| {
+                    let mut list: Vec<_> = list.into_iter().map(|config| config.id).collect();
+                    list.sort();
+                    list
+                });
+
+            Msg::ViewList(res)
+        });
+    }
+
     fn update_remotes(&mut self, ctx: &Context<Self>, result: MsgRemoteList) -> bool {
         self.remote_list_timeout = Self::poll_remote_list(ctx, false);
         let mut changed = false;
@@ -183,6 +207,10 @@ impl Component for DatacenterManagerApp {
 
         let login_info = authentication_from_cookie(&proxmox_yew_comp::ExistingProduct::PDM);
 
+        let view_list_context = ViewListContext::new();
+        let _view_list_observer =
+            view_list_context.add_listener(ctx.link().callback(|_| Msg::UpdateViewList));
+
         let mut this = Self {
             _auth_observer,
             login_info,
@@ -195,6 +223,9 @@ impl Component for DatacenterManagerApp {
             remote_list_error: None,
             remote_list_timeout: None,
             search_provider: SearchProvider::new(),
+            view_list: Vec::new(),
+            view_list_context,
+            _view_list_observer,
         };
 
         this.on_login(ctx, false);
@@ -253,7 +284,14 @@ impl Component for DatacenterManagerApp {
                 if self.login_info.is_some() {
                     return self.update_remotes(ctx, remotes);
                 }
-
+                false
+            }
+            Msg::ViewList(views) => {
+                self.view_list = views.ok().unwrap_or_default();
+                true
+            }
+            Msg::UpdateViewList => {
+                self.update_views(ctx);
                 false
             }
         }
@@ -280,6 +318,7 @@ impl Component for DatacenterManagerApp {
             .with_child({
                 let main_view: Html = if self.login_info.is_some() && !loading {
                     MainMenu::new()
+                        .view_list(self.view_list.clone())
                         .username(username.clone())
                         .remote_list(self.remote_list_cache.clone())
                         .remote_list_loading(self.remote_list_error.is_some())
@@ -297,12 +336,15 @@ impl Component for DatacenterManagerApp {
 
         let context = self.remote_list.clone();
         let search_context = self.search_provider.clone();
+        let view_list_context = self.view_list_context.clone();
 
         DesktopApp::new(html! {
             <ContextProvider<SearchProvider> context={search_context}>
                 <ContextProvider<RemoteList> {context}>
                     <AclContextProvider>
-                        {body}
+                        <ContextProvider<ViewListContext> context={view_list_context}>
+                            {body}
+                        </ContextProvider<ViewListContext>>
                     </AclContextProvider>
                 </ContextProvider<RemoteList>>
             </ContextProvider<SearchProvider>>
-- 
2.47.3



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


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

* [pdm-devel] [PATCH datacenter-manager v5 17/26] lib/ui: move views types to pdm-api-types
  2025-11-26 15:17 [pdm-devel] [PATCH datacenter-manager v5 00/26] enable custom views on the UI Dominik Csapak
                   ` (15 preceding siblings ...)
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 16/26] ui: load view list on page init Dominik Csapak
@ 2025-11-26 15:18 ` Dominik Csapak
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 18/26] server: api: views: check layout string for validity Dominik Csapak
                   ` (10 subsequent siblings)
  27 siblings, 0 replies; 29+ messages in thread
From: Dominik Csapak @ 2025-11-26 15:18 UTC (permalink / raw)
  To: pdm-devel

so that we can reuse these in the backend too. For the `GuestType`, we
have to redefine it in the pdm-api-types crate, since the ui depends on
some impls there that we cannot move (e.g. From<GuestType> for Fa), but
a From impl is provided so we can easily convert between the 'backend'
type and the 'ui' type where necessary (it's just two enum variants that
are copy and should not make much issues)

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 lib/pdm-api-types/src/resource.rs    |  7 +++
 lib/pdm-api-types/src/views.rs       | 78 ++++++++++++++++++++++++++-
 ui/src/dashboard/mod.rs              |  2 -
 ui/src/dashboard/top_entities.rs     |  3 +-
 ui/src/dashboard/types.rs            | 79 ----------------------------
 ui/src/dashboard/view.rs             | 10 ++--
 ui/src/dashboard/view/row_element.rs |  2 +-
 ui/src/dashboard/view/row_view.rs    | 21 ++++----
 ui/src/pve/mod.rs                    |  9 ++++
 9 files changed, 113 insertions(+), 98 deletions(-)
 delete mode 100644 ui/src/dashboard/types.rs

diff --git a/lib/pdm-api-types/src/resource.rs b/lib/pdm-api-types/src/resource.rs
index d89b45d7..4106a7ea 100644
--- a/lib/pdm-api-types/src/resource.rs
+++ b/lib/pdm-api-types/src/resource.rs
@@ -775,3 +775,10 @@ pub struct TopEntities {
     /// The top entries for Node Memory
     pub node_memory: Vec<TopEntity>,
 }
+
+#[derive(PartialEq, Clone, Copy, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum GuestType {
+    Qemu,
+    Lxc,
+}
diff --git a/lib/pdm-api-types/src/views.rs b/lib/pdm-api-types/src/views.rs
index 30da7476..c9864718 100644
--- a/lib/pdm-api-types/src/views.rs
+++ b/lib/pdm-api-types/src/views.rs
@@ -11,7 +11,9 @@ use proxmox_schema::{
 use proxmox_section_config::{typed::ApiSectionDataEntry, SectionConfig, SectionConfigPlugin};
 
 use crate::{
-    remotes::REMOTE_ID_SCHEMA, resource::ResourceType, PROXMOX_SAFE_ID_REGEX, VIEW_ID_SCHEMA,
+    remotes::{RemoteType, REMOTE_ID_SCHEMA},
+    resource::{GuestType, ResourceType},
+    PROXMOX_SAFE_ID_REGEX, VIEW_ID_SCHEMA,
 };
 
 const_regex! {
@@ -232,6 +234,80 @@ fn verify_filter_rule(input: &str) -> Result<(), anyhow::Error> {
     FilterRule::from_str(input).map(|_| ())
 }
 
+#[derive(Serialize, Deserialize, PartialEq, Clone)]
+#[serde(rename_all = "kebab-case")]
+pub struct ViewTemplate {
+    #[serde(default, skip_serializing_if = "String::is_empty")]
+    pub description: String,
+    pub layout: ViewLayout,
+}
+
+#[derive(Serialize, Deserialize, PartialEq, Clone)]
+#[serde(rename_all = "kebab-case")]
+#[serde(tag = "layout-type")]
+pub enum ViewLayout {
+    Rows {
+        #[serde(default, skip_serializing_if = "Vec::is_empty")]
+        rows: Vec<Vec<RowWidget>>,
+    },
+}
+
+#[derive(Serialize, Deserialize, PartialEq, Clone)]
+#[serde(rename_all = "kebab-case")]
+pub struct RowWidget {
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub flex: Option<f32>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub title: Option<String>,
+    #[serde(flatten)]
+    pub r#type: WidgetType,
+}
+
+#[derive(Serialize, Deserialize, PartialEq, Clone)]
+#[serde(rename_all = "kebab-case")]
+#[serde(tag = "widget-type")]
+pub enum WidgetType {
+    #[serde(rename_all = "kebab-case")]
+    Nodes {
+        #[serde(skip_serializing_if = "Option::is_none")]
+        remote_type: Option<RemoteType>,
+    },
+    #[serde(rename_all = "kebab-case")]
+    Guests {
+        #[serde(skip_serializing_if = "Option::is_none")]
+        guest_type: Option<GuestType>,
+    },
+    PbsDatastores,
+    #[serde(rename_all = "kebab-case")]
+    Remotes {
+        show_wizard: bool,
+    },
+    Subscription,
+    Sdn,
+    #[serde(rename_all = "kebab-case")]
+    Leaderboard {
+        leaderboard_type: LeaderboardType,
+    },
+    TaskSummary {
+        grouping: TaskSummaryGrouping,
+    },
+}
+
+#[derive(Serialize, Deserialize, PartialEq, Clone, Copy)]
+#[serde(rename_all = "kebab-case")]
+pub enum LeaderboardType {
+    GuestCpu,
+    NodeCpu,
+    NodeMemory,
+}
+
+#[derive(Serialize, Deserialize, PartialEq, Clone, Copy)]
+#[serde(rename_all = "kebab-case")]
+pub enum TaskSummaryGrouping {
+    Category,
+    Remote,
+}
+
 #[cfg(test)]
 mod test {
     use anyhow::Error;
diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs
index 31581c12..8800102f 100644
--- a/ui/src/dashboard/mod.rs
+++ b/ui/src/dashboard/mod.rs
@@ -34,8 +34,6 @@ pub use pbs_datastores_panel::create_pbs_datastores_panel;
 mod tasks;
 pub use tasks::create_task_summary_panel;
 
-pub mod types;
-
 pub mod view;
 
 mod refresh_config_edit;
diff --git a/ui/src/dashboard/top_entities.rs b/ui/src/dashboard/top_entities.rs
index e94c1b8c..ab8c703d 100644
--- a/ui/src/dashboard/top_entities.rs
+++ b/ui/src/dashboard/top_entities.rs
@@ -17,11 +17,12 @@ use pwt::{
     widget::{error_message, ActionIcon, Column, Container, Panel, Row},
 };
 
+use pdm_api_types::views::LeaderboardType;
 use pdm_client::types::{Resource, TopEntity};
 
 use crate::LoadResult;
 use crate::{
-    dashboard::{create_title_with_icon, loading_column, types::LeaderboardType},
+    dashboard::{create_title_with_icon, loading_column},
     get_deep_url, get_resource_node, navigate_to,
     renderer::{render_resource_icon, render_resource_name},
 };
diff --git a/ui/src/dashboard/types.rs b/ui/src/dashboard/types.rs
deleted file mode 100644
index df4a3668..00000000
--- a/ui/src/dashboard/types.rs
+++ /dev/null
@@ -1,79 +0,0 @@
-use serde::{Deserialize, Serialize};
-
-use pdm_api_types::remotes::RemoteType;
-
-use crate::pve::GuestType;
-
-#[derive(Serialize, Deserialize, PartialEq, Clone)]
-#[serde(rename_all = "kebab-case")]
-pub struct ViewTemplate {
-    #[serde(default, skip_serializing_if = "String::is_empty")]
-    pub description: String,
-    pub layout: ViewLayout,
-}
-
-#[derive(Serialize, Deserialize, PartialEq, Clone)]
-#[serde(rename_all = "kebab-case")]
-#[serde(tag = "layout-type")]
-pub enum ViewLayout {
-    Rows {
-        #[serde(default, skip_serializing_if = "Vec::is_empty")]
-        rows: Vec<Vec<RowWidget>>,
-    },
-}
-
-#[derive(Serialize, Deserialize, PartialEq, Clone)]
-#[serde(rename_all = "kebab-case")]
-pub struct RowWidget {
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub flex: Option<f32>,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub title: Option<String>,
-    #[serde(flatten)]
-    pub r#type: WidgetType,
-}
-
-#[derive(Serialize, Deserialize, PartialEq, Clone)]
-#[serde(rename_all = "kebab-case")]
-#[serde(tag = "widget-type")]
-pub enum WidgetType {
-    #[serde(rename_all = "kebab-case")]
-    Nodes {
-        #[serde(skip_serializing_if = "Option::is_none")]
-        remote_type: Option<RemoteType>,
-    },
-    #[serde(rename_all = "kebab-case")]
-    Guests {
-        #[serde(skip_serializing_if = "Option::is_none")]
-        guest_type: Option<GuestType>,
-    },
-    PbsDatastores,
-    #[serde(rename_all = "kebab-case")]
-    Remotes {
-        show_wizard: bool,
-    },
-    Subscription,
-    Sdn,
-    #[serde(rename_all = "kebab-case")]
-    Leaderboard {
-        leaderboard_type: LeaderboardType,
-    },
-    TaskSummary {
-        grouping: TaskSummaryGrouping,
-    },
-}
-
-#[derive(Serialize, Deserialize, PartialEq, Clone, Copy)]
-#[serde(rename_all = "kebab-case")]
-pub enum LeaderboardType {
-    GuestCpu,
-    NodeCpu,
-    NodeMemory,
-}
-
-#[derive(Serialize, Deserialize, PartialEq, Clone, Copy)]
-#[serde(rename_all = "kebab-case")]
-pub enum TaskSummaryGrouping {
-    Category,
-    Remote,
-}
diff --git a/ui/src/dashboard/view.rs b/ui/src/dashboard/view.rs
index f1fafcd5..962afb9b 100644
--- a/ui/src/dashboard/view.rs
+++ b/ui/src/dashboard/view.rs
@@ -22,8 +22,6 @@ use crate::dashboard::refresh_config_edit::{
 };
 use crate::dashboard::subscription_info::create_subscriptions_dialog;
 use crate::dashboard::tasks::get_task_options;
-use crate::dashboard::types::RowWidget;
-use crate::dashboard::types::{TaskSummaryGrouping, ViewLayout, ViewTemplate, WidgetType};
 use crate::dashboard::{
     create_guest_panel, create_node_panel, create_pbs_datastores_panel,
     create_refresh_config_edit_window, create_remote_panel, create_sdn_panel,
@@ -36,7 +34,9 @@ use crate::{pdm_client, LoadResult};
 use pdm_api_types::remotes::RemoteType;
 use pdm_api_types::resource::ResourcesStatus;
 use pdm_api_types::subscription::RemoteSubscriptions;
-use pdm_api_types::views::ViewConfig;
+use pdm_api_types::views::{
+    RowWidget, TaskSummaryGrouping, ViewConfig, ViewLayout, ViewTemplate, WidgetType,
+};
 use pdm_api_types::TaskStatistics;
 use pdm_client::types::TopEntities;
 
@@ -123,7 +123,9 @@ fn render_widget(
 ) -> Html {
     let mut widget = match &item.r#type {
         WidgetType::Nodes { remote_type } => create_node_panel(*remote_type, status),
-        WidgetType::Guests { guest_type } => create_guest_panel(*guest_type, status),
+        WidgetType::Guests { guest_type } => {
+            create_guest_panel(guest_type.map(|g| g.into()), status)
+        }
         WidgetType::Remotes { show_wizard } => create_remote_panel(
             status,
             show_wizard.then_some(link.callback(|_| Msg::CreateWizard(Some(RemoteType::Pve)))),
diff --git a/ui/src/dashboard/view/row_element.rs b/ui/src/dashboard/view/row_element.rs
index d242195c..01d868e6 100644
--- a/ui/src/dashboard/view/row_element.rs
+++ b/ui/src/dashboard/view/row_element.rs
@@ -6,7 +6,7 @@ use pwt::props::RenderFn;
 use pwt::widget::{ActionIcon, Card, Fa, Panel, Row};
 use pwt_macros::{builder, widget};
 
-use crate::dashboard::types::RowWidget;
+use pdm_api_types::views::RowWidget;
 
 #[widget(comp=RowElementComp, @element)]
 #[derive(PartialEq, Properties, Clone)]
diff --git a/ui/src/dashboard/view/row_view.rs b/ui/src/dashboard/view/row_view.rs
index 512e63e7..7ecac200 100644
--- a/ui/src/dashboard/view/row_view.rs
+++ b/ui/src/dashboard/view/row_view.rs
@@ -2,6 +2,7 @@ use std::collections::HashMap;
 use std::rc::Rc;
 
 use gloo_timers::callback::Timeout;
+use pdm_api_types::resource::GuestType;
 use wasm_bindgen::JsCast;
 use web_sys::Element;
 use yew::html::{IntoEventCallback, IntoPropValue};
@@ -15,11 +16,13 @@ use pwt::widget::menu::{Menu, MenuButton, MenuItem};
 use pwt::widget::{ActionIcon, Button, Column, Container, Row, Tooltip};
 use pwt_macros::builder;
 
-use crate::dashboard::types::{RowWidget, ViewLayout, WidgetType};
 use crate::dashboard::view::row_element::RowElement;
 use crate::dashboard::view::EditingMessage;
 
 use pdm_api_types::remotes::RemoteType;
+use pdm_api_types::views::{
+    LeaderboardType, RowWidget, TaskSummaryGrouping, ViewLayout, WidgetType,
+};
 
 #[derive(Properties, PartialEq)]
 #[builder]
@@ -557,14 +560,14 @@ fn create_menu(ctx: &yew::Context<RowViewComp>, new_coords: Position) -> Menu {
                     .with_item(
                         MenuItem::new(tr!("Virtual Machines")).on_select(create_callback(
                             WidgetType::Guests {
-                                guest_type: Some(crate::pve::GuestType::Qemu),
+                                guest_type: Some(GuestType::Qemu),
                             },
                         )),
                     )
                     .with_item(
                         MenuItem::new(tr!("Linux Container")).on_select(create_callback(
                             WidgetType::Guests {
-                                guest_type: Some(crate::pve::GuestType::Lxc),
+                                guest_type: Some(GuestType::Lxc),
                             },
                         )),
                     ),
@@ -584,23 +587,21 @@ fn create_menu(ctx: &yew::Context<RowViewComp>, new_coords: Position) -> Menu {
                     .with_item(
                         MenuItem::new(tr!("Guests with Highest CPU Usage")).on_select(
                             create_callback(WidgetType::Leaderboard {
-                                leaderboard_type:
-                                    crate::dashboard::types::LeaderboardType::GuestCpu,
+                                leaderboard_type: LeaderboardType::GuestCpu,
                             }),
                         ),
                     )
                     .with_item(
                         MenuItem::new(tr!("Nodes With the Hightest CPU Usagge)")).on_select(
                             create_callback(WidgetType::Leaderboard {
-                                leaderboard_type: crate::dashboard::types::LeaderboardType::NodeCpu,
+                                leaderboard_type: LeaderboardType::NodeCpu,
                             }),
                         ),
                     )
                     .with_item(
                         MenuItem::new(tr!("Nodes With the Highest Memory Usage")).on_select(
                             create_callback(WidgetType::Leaderboard {
-                                leaderboard_type:
-                                    crate::dashboard::types::LeaderboardType::NodeMemory,
+                                leaderboard_type: LeaderboardType::NodeMemory,
                             }),
                         ),
                     ),
@@ -611,13 +612,13 @@ fn create_menu(ctx: &yew::Context<RowViewComp>, new_coords: Position) -> Menu {
                 Menu::new()
                     .with_item(MenuItem::new(tr!("Task Summary by Category")).on_select(
                         create_callback(WidgetType::TaskSummary {
-                            grouping: crate::dashboard::types::TaskSummaryGrouping::Category,
+                            grouping: TaskSummaryGrouping::Category,
                         }),
                     ))
                     .with_item(
                         MenuItem::new(tr!("Task Summary Sorted by Failed Tasks")).on_select(
                             create_callback(WidgetType::TaskSummary {
-                                grouping: crate::dashboard::types::TaskSummaryGrouping::Remote,
+                                grouping: TaskSummaryGrouping::Remote,
                             }),
                         ),
                     ),
diff --git a/ui/src/pve/mod.rs b/ui/src/pve/mod.rs
index a3448ae1..4629077e 100644
--- a/ui/src/pve/mod.rs
+++ b/ui/src/pve/mod.rs
@@ -75,6 +75,15 @@ pub enum GuestType {
     Lxc,
 }
 
+impl From<pdm_api_types::resource::GuestType> for GuestType {
+    fn from(value: pdm_api_types::resource::GuestType) -> Self {
+        match value {
+            pdm_api_types::resource::GuestType::Qemu => GuestType::Qemu,
+            pdm_api_types::resource::GuestType::Lxc => GuestType::Lxc,
+        }
+    }
+}
+
 impl Display for GuestType {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         match self {
-- 
2.47.3



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


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

* [pdm-devel] [PATCH datacenter-manager v5 18/26] server: api: views: check layout string for validity
  2025-11-26 15:17 [pdm-devel] [PATCH datacenter-manager v5 00/26] enable custom views on the UI Dominik Csapak
                   ` (16 preceding siblings ...)
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 17/26] lib/ui: move views types to pdm-api-types Dominik Csapak
@ 2025-11-26 15:18 ` Dominik Csapak
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 19/26] ui: dashboard: add current view to search terms Dominik Csapak
                   ` (9 subsequent siblings)
  27 siblings, 0 replies; 29+ messages in thread
From: Dominik Csapak @ 2025-11-26 15:18 UTC (permalink / raw)
  To: pdm-devel

check the validity by trying to serialize into a ViewTemplate, since
that is what the ui expects. Fail the api call if that does not work.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 server/src/api/config/views.rs | 13 ++++++++++++-
 1 file changed, 12 insertions(+), 1 deletion(-)

diff --git a/server/src/api/config/views.rs b/server/src/api/config/views.rs
index 1401a0a0..79bc6f19 100644
--- a/server/src/api/config/views.rs
+++ b/server/src/api/config/views.rs
@@ -7,7 +7,7 @@ use proxmox_router::{http_bail, http_err, Permission, Router, RpcEnvironment};
 use proxmox_schema::{api, param_bail};
 
 use pdm_api_types::{
-    views::{ViewConfig, ViewConfigEntry, ViewConfigUpdater},
+    views::{ViewConfig, ViewConfigEntry, ViewConfigUpdater, ViewTemplate},
     PRIV_RESOURCE_AUDIT, PRIV_RESOURCE_MODIFY,
 };
 
@@ -93,6 +93,12 @@ pub fn add_view(view: ViewConfig, digest: Option<ConfigDigest>) -> Result<(), Er
 
     let id = view.id.clone();
 
+    if !view.layout.is_empty() {
+        if let Err(err) = serde_json::from_str::<ViewTemplate>(&view.layout) {
+            param_bail!("layout", "layout is not valid: '{}'", err)
+        }
+    }
+
     if let Some(ViewConfigEntry::View(_)) = config.insert(id.clone(), ViewConfigEntry::View(view)) {
         param_bail!("id", "view '{}' already exists.", id)
     }
@@ -190,6 +196,11 @@ pub fn update_view(
     }
 
     if let Some(layout) = view.layout {
+        if !layout.is_empty() {
+            if let Err(err) = serde_json::from_str::<ViewTemplate>(&layout) {
+                param_bail!("layout", "layout is not valid: '{}'", err)
+            }
+        }
         conf.layout = layout;
     }
 
-- 
2.47.3



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


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

* [pdm-devel] [PATCH datacenter-manager v5 19/26] ui: dashboard: add current view to search terms
  2025-11-26 15:17 [pdm-devel] [PATCH datacenter-manager v5 00/26] enable custom views on the UI Dominik Csapak
                   ` (17 preceding siblings ...)
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 18/26] server: api: views: check layout string for validity Dominik Csapak
@ 2025-11-26 15:18 ` Dominik Csapak
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 20/26] ui: resource tree: fix loading logic Dominik Csapak
                   ` (8 subsequent siblings)
  27 siblings, 0 replies; 29+ messages in thread
From: Dominik Csapak @ 2025-11-26 15:18 UTC (permalink / raw)
  To: pdm-devel

by providing a `ViewContext` that contains the name of the current view
and utilizing that in the dashboard widget when searching for some term.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 lib/pdm-search/src/lib.rs                |  9 +++++++
 ui/src/dashboard/guest_panel.rs          |  4 +++-
 ui/src/dashboard/node_status_panel.rs    |  4 +++-
 ui/src/dashboard/pbs_datastores_panel.rs |  4 +++-
 ui/src/dashboard/remote_panel.rs         |  4 +++-
 ui/src/dashboard/sdn_zone_panel.rs       |  8 +++++--
 ui/src/dashboard/view.rs                 | 30 +++++++++++++++++++++++-
 7 files changed, 56 insertions(+), 7 deletions(-)

diff --git a/lib/pdm-search/src/lib.rs b/lib/pdm-search/src/lib.rs
index fa484cf4..cb9119f6 100644
--- a/lib/pdm-search/src/lib.rs
+++ b/lib/pdm-search/src/lib.rs
@@ -68,6 +68,15 @@ impl Search {
 
         true
     }
+
+    /// Add a term to the search
+    pub fn add_term(&mut self, term: SearchTerm) {
+        if term.is_optional() {
+            self.optional_terms.push(term);
+        } else {
+            self.required_terms.push(term);
+        }
+    }
 }
 
 impl fmt::Display for Search {
diff --git a/ui/src/dashboard/guest_panel.rs b/ui/src/dashboard/guest_panel.rs
index 8b97fa2b..32c41c22 100644
--- a/ui/src/dashboard/guest_panel.rs
+++ b/ui/src/dashboard/guest_panel.rs
@@ -16,6 +16,7 @@ use pdm_api_types::resource::{GuestStatusCount, ResourceType, ResourcesStatus};
 use pdm_search::{Search, SearchTerm};
 
 use crate::dashboard::create_title_with_icon;
+use crate::dashboard::view::add_current_view_to_search;
 use crate::pve::GuestType;
 use crate::search_provider::get_search_provider;
 use crate::LoadResult;
@@ -59,8 +60,9 @@ impl yew::Component for PdmGuestPanel {
         Self {}
     }
 
-    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
+    fn update(&mut self, ctx: &Context<Self>, mut msg: Self::Message) -> bool {
         if let Some(provider) = get_search_provider(ctx) {
+            add_current_view_to_search(ctx, &mut msg);
             provider.search(msg);
         }
         false
diff --git a/ui/src/dashboard/node_status_panel.rs b/ui/src/dashboard/node_status_panel.rs
index 60648ef0..032719f2 100644
--- a/ui/src/dashboard/node_status_panel.rs
+++ b/ui/src/dashboard/node_status_panel.rs
@@ -14,6 +14,7 @@ use pdm_api_types::resource::NodeStatusCount;
 use pdm_api_types::{remotes::RemoteType, resource::ResourcesStatus};
 
 use crate::dashboard::create_title_with_icon;
+use crate::dashboard::view::add_current_view_to_search;
 use crate::search_provider::get_search_provider;
 use crate::LoadResult;
 
@@ -60,8 +61,9 @@ impl yew::Component for NodeStatusPanelComponent {
         Self {}
     }
 
-    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
+    fn update(&mut self, ctx: &Context<Self>, mut msg: Self::Message) -> bool {
         if let Some(provider) = get_search_provider(ctx) {
+            add_current_view_to_search(ctx, &mut msg);
             provider.search(msg);
         }
         false
diff --git a/ui/src/dashboard/pbs_datastores_panel.rs b/ui/src/dashboard/pbs_datastores_panel.rs
index 32193ae4..afc83b30 100644
--- a/ui/src/dashboard/pbs_datastores_panel.rs
+++ b/ui/src/dashboard/pbs_datastores_panel.rs
@@ -12,6 +12,7 @@ use pwt::state::SharedState;
 use pwt::widget::{Container, Fa, List, ListTile, Panel};
 
 use crate::dashboard::create_title_with_icon;
+use crate::dashboard::view::add_current_view_to_search;
 use crate::search_provider::get_search_provider;
 use crate::LoadResult;
 
@@ -55,8 +56,9 @@ impl yew::Component for PbsDatastoresPanelComponent {
         Self {}
     }
 
-    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
+    fn update(&mut self, ctx: &Context<Self>, mut msg: Self::Message) -> bool {
         if let Some(provider) = get_search_provider(ctx) {
+            add_current_view_to_search(ctx, &mut msg);
             provider.search(msg);
         }
 
diff --git a/ui/src/dashboard/remote_panel.rs b/ui/src/dashboard/remote_panel.rs
index a5682d7a..16125a9b 100644
--- a/ui/src/dashboard/remote_panel.rs
+++ b/ui/src/dashboard/remote_panel.rs
@@ -15,6 +15,7 @@ use pwt::widget::{error_message, Column, Container, Fa, Panel};
 
 use pdm_api_types::resource::ResourcesStatus;
 
+use crate::dashboard::view::add_current_view_to_search;
 use crate::LoadResult;
 use crate::{dashboard::create_title_with_icon, search_provider::get_search_provider};
 
@@ -49,8 +50,9 @@ impl Component for PdmRemotePanel {
         Self {}
     }
 
-    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
+    fn update(&mut self, ctx: &Context<Self>, mut msg: Self::Message) -> bool {
         if let Some(search) = get_search_provider(ctx) {
+            add_current_view_to_search(ctx, &mut msg);
             search.search(msg);
         }
         false
diff --git a/ui/src/dashboard/sdn_zone_panel.rs b/ui/src/dashboard/sdn_zone_panel.rs
index ed734f6f..e5aa8c1a 100644
--- a/ui/src/dashboard/sdn_zone_panel.rs
+++ b/ui/src/dashboard/sdn_zone_panel.rs
@@ -15,7 +15,10 @@ use yew::{
     Properties,
 };
 
-use crate::{dashboard::create_title_with_icon, search_provider::get_search_provider, LoadResult};
+use crate::dashboard::create_title_with_icon;
+use crate::dashboard::view::add_current_view_to_search;
+use crate::search_provider::get_search_provider;
+use crate::LoadResult;
 
 use super::loading_column;
 
@@ -72,8 +75,9 @@ impl yew::Component for SdnZonePanelComponent {
         Self {}
     }
 
-    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
+    fn update(&mut self, ctx: &Context<Self>, mut msg: Self::Message) -> bool {
         if let Some(provider) = get_search_provider(ctx) {
+            add_current_view_to_search(ctx, &mut msg);
             provider.search(msg);
         }
 
diff --git a/ui/src/dashboard/view.rs b/ui/src/dashboard/view.rs
index 962afb9b..ff287db8 100644
--- a/ui/src/dashboard/view.rs
+++ b/ui/src/dashboard/view.rs
@@ -39,6 +39,7 @@ use pdm_api_types::views::{
 };
 use pdm_api_types::TaskStatistics;
 use pdm_client::types::TopEntities;
+use pdm_search::{Search, SearchTerm};
 
 mod row_view;
 pub use row_view::RowView;
@@ -70,6 +71,12 @@ impl View {
     }
 }
 
+#[derive(PartialEq, Clone)]
+/// Used to provide the current view name via a [`ContextProvider`]
+pub struct ViewContext {
+    pub name: Option<AttrValue>,
+}
+
 pub enum LoadingResult {
     Resources(Result<ResourcesStatus, Error>),
     TopEntities(Result<pdm_client::types::TopEntities, proxmox_client::Error>),
@@ -541,7 +548,15 @@ impl Component for ViewComp {
 
         view.add_optional_child(self.subscriptions_dialog.clone());
 
-        view.into()
+        let view_context = ViewContext {
+            name: props.view.clone(),
+        };
+
+        html! {
+            <ContextProvider<ViewContext> context={view_context}>
+                {view}
+            </ContextProvider<ViewContext>>
+        }
     }
 }
 
@@ -640,3 +655,16 @@ async fn load_template(view: Option<AttrValue>) -> Result<ViewTemplate, Error> {
 
     Ok(template)
 }
+
+/// This adds the current view from the context to the given [`Search`] if any
+pub fn add_current_view_to_search<T: yew::Component>(ctx: &yew::Context<T>, search: &mut Search) {
+    if let Some((context, _)) = ctx.link().context::<ViewContext>(Callback::from(|_| {})) {
+        if let Some(name) = context.name {
+            search.add_term(
+                SearchTerm::new(name.to_string())
+                    .category(Some("view"))
+                    .optional(false),
+            );
+        }
+    }
+}
-- 
2.47.3



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


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

* [pdm-devel] [PATCH datacenter-manager v5 20/26] ui: resource tree: fix loading logic
  2025-11-26 15:17 [pdm-devel] [PATCH datacenter-manager v5 00/26] enable custom views on the UI Dominik Csapak
                   ` (18 preceding siblings ...)
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 19/26] ui: dashboard: add current view to search terms Dominik Csapak
@ 2025-11-26 15:18 ` Dominik Csapak
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 21/26] ui: resource tree: move error message into first column Dominik Csapak
                   ` (7 subsequent siblings)
  27 siblings, 0 replies; 29+ messages in thread
From: Dominik Csapak @ 2025-11-26 15:18 UTC (permalink / raw)
  To: pdm-devel

we want to load when we either have a search term, or 'search_only' is
deactivated.

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

diff --git a/ui/src/widget/resource_tree.rs b/ui/src/widget/resource_tree.rs
index 708df65c..8ad2ba7e 100644
--- a/ui/src/widget/resource_tree.rs
+++ b/ui/src/widget/resource_tree.rs
@@ -146,7 +146,7 @@ impl Component for PdmResourceTree {
                 let props = ctx.props();
                 let link = ctx.link().clone();
                 let search_term = props.search_term.clone();
-                if props.search_only && !search_term.is_empty() {
+                if !props.search_only || !search_term.is_empty() {
                     self._load_timeout = Some(Timeout::new(INPUT_BUFFER_MS, move || {
                         link.send_future(async move {
                             Msg::LoadResult(load_resources(search_term).await)
-- 
2.47.3



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


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

* [pdm-devel] [PATCH datacenter-manager v5 21/26] ui: resource tree: move error message into first column
  2025-11-26 15:17 [pdm-devel] [PATCH datacenter-manager v5 00/26] enable custom views on the UI Dominik Csapak
                   ` (19 preceding siblings ...)
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 20/26] ui: resource tree: fix loading logic Dominik Csapak
@ 2025-11-26 15:18 ` Dominik Csapak
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 22/26] ui: resource tree: use `ViewContext` to limit the api calls to a view Dominik Csapak
                   ` (6 subsequent siblings)
  27 siblings, 0 replies; 29+ messages in thread
From: Dominik Csapak @ 2025-11-26 15:18 UTC (permalink / raw)
  To: pdm-devel

this makes the 'node' column much more predictable, so we
can set a fixed width that should be enough for most nodenames instead
of using up much space (double than the 'id' one).

To properly display the error, we can use the 'colspan' attribute when
we use `render_cell` in the first column.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/widget/resource_tree.rs | 26 ++++++++++++++++++--------
 1 file changed, 18 insertions(+), 8 deletions(-)

diff --git a/ui/src/widget/resource_tree.rs b/ui/src/widget/resource_tree.rs
index 8ad2ba7e..795e9f49 100644
--- a/ui/src/widget/resource_tree.rs
+++ b/ui/src/widget/resource_tree.rs
@@ -13,8 +13,8 @@ use pwt::{
     state::{Selection, TreeStore},
     widget::{
         data_table::{
-            DataTable, DataTableColumn, DataTableHeader, DataTableKeyboardEvent,
-            DataTableMouseEvent,
+            DataTable, DataTableCellRenderArgs, DataTableColumn, DataTableHeader,
+            DataTableKeyboardEvent, DataTableMouseEvent,
         },
         ActionIcon, Column, Container, Fa, Panel, Progress, Row, Tooltip,
     },
@@ -312,8 +312,10 @@ fn columns(
         DataTableColumn::new(tr!("ID"))
             .tree_column(store)
             .flex(1)
-            .render(|item: &PdmTreeEntry| {
-                let (icon, text, tooltip) = match item {
+            .render_cell(|args: &mut DataTableCellRenderArgs<PdmTreeEntry>| {
+                let item = args.record();
+                let mut colspan = false;
+                let (icon, text, tooltip) = match &item {
                     PdmTreeEntry::Root => (
                         Container::new().with_child(Fa::new("server").fixed_width()),
                         String::from("root"),
@@ -331,24 +333,32 @@ fn columns(
                             .with_optional_child(err.is_some().then_some(
                                 Fa::from(Status::Error).fixed_width().class("status-icon"),
                             )),
-                        remote.clone(),
+                        match err {
+                            Some(err) => {
+                                colspan = true;
+                                format!("{remote} - {err}").into()
+                            }
+                            None => remote.into(),
+                        },
                         err.as_ref().map(|err| err.to_string()),
                     ),
                 };
+                if colspan {
+                    args.set_attribute("colspan", "2");
+                }
                 Tooltip::new(Row::new().gap(4).with_child(icon).with_child(text))
                     .tip(tooltip)
                     .into()
             })
             .into(),
-        DataTableColumn::new(tr!("Node/Error"))
-            .flex(2)
+        DataTableColumn::new("Node")
+            .width("150px")
             .render(|item: &PdmTreeEntry| {
                 match item {
                     PdmTreeEntry::Root => "",
                     PdmTreeEntry::Resource(_, resource) => {
                         get_resource_node(resource).unwrap_or("")
                     }
-                    PdmTreeEntry::Remote(_, Some(err)) => err,
                     PdmTreeEntry::Remote(_, _) => "",
                 }
                 .into()
-- 
2.47.3



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


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

* [pdm-devel] [PATCH datacenter-manager v5 22/26] ui: resource tree: use `ViewContext` to limit the api calls to a view
  2025-11-26 15:17 [pdm-devel] [PATCH datacenter-manager v5 00/26] enable custom views on the UI Dominik Csapak
                   ` (20 preceding siblings ...)
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 21/26] ui: resource tree: move error message into first column Dominik Csapak
@ 2025-11-26 15:18 ` Dominik Csapak
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 23/26] ui: resource tree: show guest tags Dominik Csapak
                   ` (5 subsequent siblings)
  27 siblings, 0 replies; 29+ messages in thread
From: Dominik Csapak @ 2025-11-26 15:18 UTC (permalink / raw)
  To: pdm-devel

this automatically limits the resource tree to a view when the context
exist, such as in a 'view'.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/widget/resource_tree.rs | 17 +++++++++++++++--
 1 file changed, 15 insertions(+), 2 deletions(-)

diff --git a/ui/src/widget/resource_tree.rs b/ui/src/widget/resource_tree.rs
index 795e9f49..0fa0e340 100644
--- a/ui/src/widget/resource_tree.rs
+++ b/ui/src/widget/resource_tree.rs
@@ -26,6 +26,7 @@ use proxmox_yew_comp::{http_get, Status};
 use pdm_api_types::resource::{RemoteResources, Resource};
 
 use crate::{
+    dashboard::view::ViewContext,
     get_deep_url, get_resource_node,
     renderer::{render_resource_name, render_status_icon},
     RemoteList,
@@ -84,11 +85,17 @@ impl ExtractPrimaryKey for PdmTreeEntry {
     }
 }
 
-async fn load_resources(search_term: String) -> Result<Vec<RemoteResources>, Error> {
+async fn load_resources(
+    search_term: String,
+    view: Option<AttrValue>,
+) -> Result<Vec<RemoteResources>, Error> {
     let mut params = json!({ "max-age": REFRESH_TIME_S });
     if !search_term.is_empty() {
         params["search"] = search_term.into();
     }
+    if let Some(view) = view {
+        params["view"] = view.to_string().into();
+    }
     http_get("/resources/list", Some(params)).await
 }
 
@@ -146,10 +153,16 @@ impl Component for PdmResourceTree {
                 let props = ctx.props();
                 let link = ctx.link().clone();
                 let search_term = props.search_term.clone();
+
+                let view = ctx
+                    .link()
+                    .context::<ViewContext>(Callback::from(|_| {}))
+                    .and_then(|(context, _)| context.name);
+
                 if !props.search_only || !search_term.is_empty() {
                     self._load_timeout = Some(Timeout::new(INPUT_BUFFER_MS, move || {
                         link.send_future(async move {
-                            Msg::LoadResult(load_resources(search_term).await)
+                            Msg::LoadResult(load_resources(search_term, view).await)
                         });
                     }));
                     self.loading = true;
-- 
2.47.3



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


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

* [pdm-devel] [PATCH datacenter-manager v5 23/26] ui: resource tree: show guest tags
  2025-11-26 15:17 [pdm-devel] [PATCH datacenter-manager v5 00/26] enable custom views on the UI Dominik Csapak
                   ` (21 preceding siblings ...)
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 22/26] ui: resource tree: use `ViewContext` to limit the api calls to a view Dominik Csapak
@ 2025-11-26 15:18 ` Dominik Csapak
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 24/26] api-types/ui: add ResourceTree variant for WidgetType Dominik Csapak
                   ` (4 subsequent siblings)
  27 siblings, 0 replies; 29+ messages in thread
From: Dominik Csapak @ 2025-11-26 15:18 UTC (permalink / raw)
  To: pdm-devel

simply add them after the name

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

diff --git a/ui/src/widget/resource_tree.rs b/ui/src/widget/resource_tree.rs
index 0fa0e340..25d26d65 100644
--- a/ui/src/widget/resource_tree.rs
+++ b/ui/src/widget/resource_tree.rs
@@ -28,6 +28,7 @@ use pdm_api_types::resource::{RemoteResources, Resource};
 use crate::{
     dashboard::view::ViewContext,
     get_deep_url, get_resource_node,
+    pve::utils::render_guest_tags,
     renderer::{render_resource_name, render_status_icon},
     RemoteList,
 };
@@ -331,12 +332,20 @@ fn columns(
                 let (icon, text, tooltip) = match &item {
                     PdmTreeEntry::Root => (
                         Container::new().with_child(Fa::new("server").fixed_width()),
-                        String::from("root"),
+                        html! {{"root"}},
                         None,
                     ),
                     PdmTreeEntry::Resource(_, resource) => (
                         render_status_icon(resource),
-                        render_resource_name(resource, true),
+                        Row::new()
+                            .gap(1)
+                            .with_child(render_resource_name(resource, true))
+                            .with_child(render_guest_tags(match resource {
+                                Resource::PveQemu(pve_qemu_resource) => &pve_qemu_resource.tags[..],
+                                Resource::PveLxc(pve_lxc_resource) => &pve_lxc_resource.tags[..],
+                                _ => &[],
+                            }))
+                            .into(),
                         None,
                     ),
                     PdmTreeEntry::Remote(remote, err) => (
-- 
2.47.3



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


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

* [pdm-devel] [PATCH datacenter-manager v5 24/26] api-types/ui: add ResourceTree variant for WidgetType
  2025-11-26 15:17 [pdm-devel] [PATCH datacenter-manager v5 00/26] enable custom views on the UI Dominik Csapak
                   ` (22 preceding siblings ...)
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 23/26] ui: resource tree: show guest tags Dominik Csapak
@ 2025-11-26 15:18 ` Dominik Csapak
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 25/26] ui: dashboard view: refactor widget rendering arguments into struct Dominik Csapak
                   ` (3 subsequent siblings)
  27 siblings, 0 replies; 29+ messages in thread
From: Dominik Csapak @ 2025-11-26 15:18 UTC (permalink / raw)
  To: pdm-devel

this is the resource tree from the search, but contained within a panel
and with search field in a toolbar, for use in the custom views.

This can come in handy, since it gives an overview of the filtered
resources.

It does not refresh together with the other panels in the view
currently, only once at the beginning and when the search term changes.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 lib/pdm-api-types/src/views.rs    |  1 +
 ui/src/dashboard/mod.rs           |  3 ++
 ui/src/dashboard/resource_tree.rs | 46 +++++++++++++++++++++++++++++++
 ui/src/dashboard/view.rs          |  6 +++-
 ui/src/dashboard/view/row_view.rs |  4 +++
 5 files changed, 59 insertions(+), 1 deletion(-)
 create mode 100644 ui/src/dashboard/resource_tree.rs

diff --git a/lib/pdm-api-types/src/views.rs b/lib/pdm-api-types/src/views.rs
index c9864718..1bcde477 100644
--- a/lib/pdm-api-types/src/views.rs
+++ b/lib/pdm-api-types/src/views.rs
@@ -291,6 +291,7 @@ pub enum WidgetType {
     TaskSummary {
         grouping: TaskSummaryGrouping,
     },
+    ResourceTree,
 }
 
 #[derive(Serialize, Deserialize, PartialEq, Clone, Copy)]
diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs
index 8800102f..17e5ccd3 100644
--- a/ui/src/dashboard/mod.rs
+++ b/ui/src/dashboard/mod.rs
@@ -39,6 +39,9 @@ pub mod view;
 mod refresh_config_edit;
 pub use refresh_config_edit::create_refresh_config_edit_window;
 
+mod resource_tree;
+pub use resource_tree::create_resource_tree;
+
 fn loading_column() -> Column {
     Column::new()
         .padding(4)
diff --git a/ui/src/dashboard/resource_tree.rs b/ui/src/dashboard/resource_tree.rs
new file mode 100644
index 00000000..cd7a1702
--- /dev/null
+++ b/ui/src/dashboard/resource_tree.rs
@@ -0,0 +1,46 @@
+use pwt::css;
+use pwt::prelude::*;
+use pwt::props::{ContainerBuilder, WidgetBuilder, WidgetStyleBuilder};
+use pwt::widget::form::Field;
+use pwt::widget::Column;
+use pwt::widget::Panel;
+use pwt::widget::Row;
+use pwt::widget::Toolbar;
+
+use crate::widget::ResourceTree;
+
+#[function_component]
+fn ResourceTreeWithSearch() -> Html {
+    let search = use_state(String::new);
+
+    Column::new()
+        .class(css::FlexFit)
+        .with_child(
+            Toolbar::new()
+                .with_child(tr!("Search"))
+                .with_child(Field::new().on_change({
+                    let search = search.clone();
+                    move |value| search.set(value)
+                })),
+        )
+        .with_child(
+            // use another flex layout with base width to work around the data tables dynamic
+            // column size that does not decrease
+            Row::new().class(css::FlexFit).with_child(
+                ResourceTree::new()
+                    .search_term(search.to_string())
+                    .flex(1.0)
+                    .width(250)
+                    .height(500)
+                    .class(css::FlexFit),
+            ),
+        )
+        .into()
+}
+
+pub fn create_resource_tree() -> Panel {
+    Panel::new()
+        .class(css::FlexFit)
+        .title(tr!("Resources"))
+        .with_child(html! {<ResourceTreeWithSearch />})
+}
diff --git a/ui/src/dashboard/view.rs b/ui/src/dashboard/view.rs
index ff287db8..e6a86bc9 100644
--- a/ui/src/dashboard/view.rs
+++ b/ui/src/dashboard/view.rs
@@ -24,7 +24,7 @@ use crate::dashboard::subscription_info::create_subscriptions_dialog;
 use crate::dashboard::tasks::get_task_options;
 use crate::dashboard::{
     create_guest_panel, create_node_panel, create_pbs_datastores_panel,
-    create_refresh_config_edit_window, create_remote_panel, create_sdn_panel,
+    create_refresh_config_edit_window, create_remote_panel, create_resource_tree, create_sdn_panel,
     create_subscription_panel, create_task_summary_panel, create_top_entities_panel,
     DashboardStatusRow,
 };
@@ -161,6 +161,7 @@ fn render_widget(
             let (hours, since) = get_task_options(refresh_config.task_last_hours);
             create_task_summary_panel(statistics, remotes, hours, since)
         }
+        WidgetType::ResourceTree => create_resource_tree(),
     };
 
     if let Some(title) = &item.title {
@@ -269,6 +270,9 @@ fn required_api_calls(layout: &ViewLayout) -> (bool, bool, bool) {
                         }
                         WidgetType::Leaderboard { .. } => top_entities = true,
                         WidgetType::TaskSummary { .. } => task_statistics = true,
+                        WidgetType::ResourceTree => {
+                            // each list must do it itself
+                        }
                     }
                 }
             }
diff --git a/ui/src/dashboard/view/row_view.rs b/ui/src/dashboard/view/row_view.rs
index 7ecac200..df5552c3 100644
--- a/ui/src/dashboard/view/row_view.rs
+++ b/ui/src/dashboard/view/row_view.rs
@@ -625,4 +625,8 @@ fn create_menu(ctx: &yew::Context<RowViewComp>, new_coords: Position) -> Menu {
             ),
         )
         .with_item(MenuItem::new(tr!("SDN Panel")).on_select(create_callback(WidgetType::Sdn)))
+        .with_item(
+            MenuItem::new(tr!("Resource Tree"))
+                .on_select(create_callback(WidgetType::ResourceTree)),
+        )
 }
-- 
2.47.3



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


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

* [pdm-devel] [PATCH datacenter-manager v5 25/26] ui: dashboard view: refactor widget rendering arguments into struct
  2025-11-26 15:17 [pdm-devel] [PATCH datacenter-manager v5 00/26] enable custom views on the UI Dominik Csapak
                   ` (23 preceding siblings ...)
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 24/26] api-types/ui: add ResourceTree variant for WidgetType Dominik Csapak
@ 2025-11-26 15:18 ` Dominik Csapak
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 26/26] ui: resource tree/view: reload tree in a view on refresh Dominik Csapak
                   ` (2 subsequent siblings)
  27 siblings, 0 replies; 29+ messages in thread
From: Dominik Csapak @ 2025-11-26 15:18 UTC (permalink / raw)
  To: pdm-devel

This way, adding a new parameter is easier and we don't have to write
them all out twice.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/dashboard/view.rs | 57 +++++++++++++++++++++-------------------
 1 file changed, 30 insertions(+), 27 deletions(-)

diff --git a/ui/src/dashboard/view.rs b/ui/src/dashboard/view.rs
index e6a86bc9..6b3baf3a 100644
--- a/ui/src/dashboard/view.rs
+++ b/ui/src/dashboard/view.rs
@@ -100,12 +100,7 @@ pub enum Msg {
 struct ViewComp {
     template: LoadResult<ViewTemplate, Error>,
 
-    // various api call results
-    status: SharedState<LoadResult<ResourcesStatus, Error>>,
-    top_entities: SharedState<LoadResult<TopEntities, proxmox_client::Error>>,
-    statistics: SharedState<LoadResult<TaskStatistics, Error>>,
-    subscriptions: SharedState<LoadResult<Vec<RemoteSubscriptions>, Error>>,
-
+    render_args: WidgetRenderArgs,
     refresh_config: PersistentState<RefreshConfig>,
 
     async_pool: AsyncPool,
@@ -119,15 +114,27 @@ struct ViewComp {
     update_result: LoadResult<(), Error>,
 }
 
-fn render_widget(
-    link: yew::html::Scope<ViewComp>,
-    item: &RowWidget,
+#[derive(Clone)]
+struct WidgetRenderArgs {
     status: SharedState<LoadResult<ResourcesStatus, Error>>,
     subscriptions: SharedState<LoadResult<Vec<RemoteSubscriptions>, Error>>,
     top_entities: SharedState<LoadResult<TopEntities, proxmox_client::Error>>,
     statistics: SharedState<LoadResult<TaskStatistics, Error>>,
+}
+
+fn render_widget(
+    link: yew::html::Scope<ViewComp>,
+    item: &RowWidget,
+    render_args: WidgetRenderArgs,
     refresh_config: RefreshConfig,
 ) -> Html {
+    let WidgetRenderArgs {
+        status,
+        subscriptions,
+        top_entities,
+        statistics,
+    } = render_args;
+
     let mut widget = match &item.r#type {
         WidgetType::Nodes { remote_type } => create_node_panel(*remote_type, status),
         WidgetType::Guests { guest_type } => {
@@ -321,11 +328,6 @@ impl Component for ViewComp {
             template: LoadResult::new(),
             async_pool,
 
-            status: SharedState::new(LoadResult::new()),
-            top_entities: SharedState::new(LoadResult::new()),
-            statistics: SharedState::new(LoadResult::new()),
-            subscriptions: SharedState::new(LoadResult::new()),
-
             refresh_config,
             load_finished_time: None,
             loading: true,
@@ -335,6 +337,13 @@ impl Component for ViewComp {
 
             editing_state: SharedState::new(Vec::new()),
             update_result: LoadResult::new(),
+
+            render_args: WidgetRenderArgs {
+                status: SharedState::new(LoadResult::new()),
+                top_entities: SharedState::new(LoadResult::new()),
+                statistics: SharedState::new(LoadResult::new()),
+                subscriptions: SharedState::new(LoadResult::new()),
+            },
         }
     }
 
@@ -345,15 +354,15 @@ impl Component for ViewComp {
                 self.reload(ctx);
             }
             Msg::LoadingResult(loading_result) => match loading_result {
-                LoadingResult::Resources(status) => self.status.write().update(status),
+                LoadingResult::Resources(status) => self.render_args.status.write().update(status),
                 LoadingResult::TopEntities(top_entities) => {
-                    self.top_entities.write().update(top_entities)
+                    self.render_args.top_entities.write().update(top_entities)
                 }
                 LoadingResult::TaskStatistics(task_statistics) => {
-                    self.statistics.write().update(task_statistics)
+                    self.render_args.statistics.write().update(task_statistics)
                 }
                 LoadingResult::SubscriptionInfo(subscriptions) => {
-                    self.subscriptions.write().update(subscriptions);
+                    self.render_args.subscriptions.write().update(subscriptions);
                 }
                 LoadingResult::All => {
                     self.loading = false;
@@ -455,7 +464,7 @@ impl Component for ViewComp {
         );
 
         if !has_sub_panel(self.template.data.as_ref()) {
-            let subs = self.subscriptions.clone();
+            let subs = self.render_args.subscriptions.clone();
             let link = ctx.link().clone();
             view.add_child(
                 Row::new()
@@ -483,19 +492,13 @@ impl Component for ViewComp {
                 view.add_child(
                     RowView::new(rows.clone(), {
                         let link = ctx.link().clone();
-                        let status = self.status.clone();
-                        let subscriptions = self.subscriptions.clone();
-                        let top_entities = self.top_entities.clone();
-                        let statistics = self.statistics.clone();
+                        let args = self.render_args.clone();
                         let refresh_config = self.refresh_config.clone();
                         move |widget: &RowWidget| {
                             render_widget(
                                 link.clone(),
                                 widget,
-                                status.clone(),
-                                subscriptions.clone(),
-                                top_entities.clone(),
-                                statistics.clone(),
+                                args.clone(),
                                 refresh_config.clone(),
                             )
                         }
-- 
2.47.3



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


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

* [pdm-devel] [PATCH datacenter-manager v5 26/26] ui: resource tree/view: reload tree in a view on refresh
  2025-11-26 15:17 [pdm-devel] [PATCH datacenter-manager v5 00/26] enable custom views on the UI Dominik Csapak
                   ` (24 preceding siblings ...)
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 25/26] ui: dashboard view: refactor widget rendering arguments into struct Dominik Csapak
@ 2025-11-26 15:18 ` Dominik Csapak
  2025-11-26 21:15 ` [pdm-devel] applied: [PATCH datacenter-manager v5 00/26] enable custom views on the UI Thomas Lamprecht
  2025-11-26 21:17 ` [pdm-devel] " Thomas Lamprecht
  27 siblings, 0 replies; 29+ messages in thread
From: Dominik Csapak @ 2025-11-26 15:18 UTC (permalink / raw)
  To: pdm-devel

by using a simple usize counter that acts as a `RedrawController`.
By using this controller as a property, and updating that property on
every view, we can trigger a reload from outside the component.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/dashboard/resource_tree.rs | 13 +++++++---
 ui/src/dashboard/view.rs          |  7 +++++-
 ui/src/widget/mod.rs              |  2 +-
 ui/src/widget/resource_tree.rs    | 41 +++++++++++++++++++++++++++++--
 4 files changed, 56 insertions(+), 7 deletions(-)

diff --git a/ui/src/dashboard/resource_tree.rs b/ui/src/dashboard/resource_tree.rs
index cd7a1702..f1887e75 100644
--- a/ui/src/dashboard/resource_tree.rs
+++ b/ui/src/dashboard/resource_tree.rs
@@ -7,10 +7,16 @@ use pwt::widget::Panel;
 use pwt::widget::Row;
 use pwt::widget::Toolbar;
 
+use crate::widget::RedrawController;
 use crate::widget::ResourceTree;
 
+#[derive(Properties, Clone, PartialEq)]
+struct ResourceTreeWithSearchProps {
+    redraw_controller: RedrawController,
+}
+
 #[function_component]
-fn ResourceTreeWithSearch() -> Html {
+fn ResourceTreeWithSearch(props: &ResourceTreeWithSearchProps) -> Html {
     let search = use_state(String::new);
 
     Column::new()
@@ -28,6 +34,7 @@ fn ResourceTreeWithSearch() -> Html {
             // column size that does not decrease
             Row::new().class(css::FlexFit).with_child(
                 ResourceTree::new()
+                    .redraw_controller(props.redraw_controller.clone())
                     .search_term(search.to_string())
                     .flex(1.0)
                     .width(250)
@@ -38,9 +45,9 @@ fn ResourceTreeWithSearch() -> Html {
         .into()
 }
 
-pub fn create_resource_tree() -> Panel {
+pub fn create_resource_tree(redraw_controller: RedrawController) -> Panel {
     Panel::new()
         .class(css::FlexFit)
         .title(tr!("Resources"))
-        .with_child(html! {<ResourceTreeWithSearch />})
+        .with_child(html! {<ResourceTreeWithSearch {redraw_controller} />})
 }
diff --git a/ui/src/dashboard/view.rs b/ui/src/dashboard/view.rs
index 6b3baf3a..65697d38 100644
--- a/ui/src/dashboard/view.rs
+++ b/ui/src/dashboard/view.rs
@@ -29,6 +29,7 @@ use crate::dashboard::{
     DashboardStatusRow,
 };
 use crate::remotes::AddWizard;
+use crate::widget::RedrawController;
 use crate::{pdm_client, LoadResult};
 
 use pdm_api_types::remotes::RemoteType;
@@ -120,6 +121,7 @@ struct WidgetRenderArgs {
     subscriptions: SharedState<LoadResult<Vec<RemoteSubscriptions>, Error>>,
     top_entities: SharedState<LoadResult<TopEntities, proxmox_client::Error>>,
     statistics: SharedState<LoadResult<TaskStatistics, Error>>,
+    redraw_controller: RedrawController,
 }
 
 fn render_widget(
@@ -133,6 +135,7 @@ fn render_widget(
         subscriptions,
         top_entities,
         statistics,
+        redraw_controller,
     } = render_args;
 
     let mut widget = match &item.r#type {
@@ -168,7 +171,7 @@ fn render_widget(
             let (hours, since) = get_task_options(refresh_config.task_last_hours);
             create_task_summary_panel(statistics, remotes, hours, since)
         }
-        WidgetType::ResourceTree => create_resource_tree(),
+        WidgetType::ResourceTree => create_resource_tree(redraw_controller),
     };
 
     if let Some(title) = &item.title {
@@ -189,6 +192,7 @@ impl ViewComp {
     }
 
     fn do_reload(&mut self, ctx: &yew::Context<Self>, max_age: u64) {
+        self.render_args.redraw_controller.redraw_request();
         if let Some(data) = self.template.data.as_ref() {
             let link = ctx.link().clone();
             let (_, since) = get_task_options(self.refresh_config.task_last_hours);
@@ -343,6 +347,7 @@ impl Component for ViewComp {
                 top_entities: SharedState::new(LoadResult::new()),
                 statistics: SharedState::new(LoadResult::new()),
                 subscriptions: SharedState::new(LoadResult::new()),
+                redraw_controller: RedrawController::new(),
             },
         }
     }
diff --git a/ui/src/widget/mod.rs b/ui/src/widget/mod.rs
index 97e7e472..7d789c39 100644
--- a/ui/src/widget/mod.rs
+++ b/ui/src/widget/mod.rs
@@ -17,7 +17,7 @@ mod pve_realm_selector;
 pub use pve_realm_selector::PveRealmSelector;
 
 mod resource_tree;
-pub use resource_tree::ResourceTree;
+pub use resource_tree::{RedrawController, ResourceTree};
 
 mod search_box;
 pub use search_box::SearchBox;
diff --git a/ui/src/widget/resource_tree.rs b/ui/src/widget/resource_tree.rs
index 25d26d65..9f17634c 100644
--- a/ui/src/widget/resource_tree.rs
+++ b/ui/src/widget/resource_tree.rs
@@ -4,7 +4,11 @@ use anyhow::Error;
 use gloo_timers::callback::Timeout;
 use serde_json::json;
 use web_sys::window;
-use yew::{html::IntoEventCallback, virtual_dom::Key, Component};
+use yew::{
+    html::{IntoEventCallback, IntoPropValue},
+    virtual_dom::Key,
+    Component,
+};
 
 use pwt::{
     css::{FlexFit, FontColor},
@@ -36,6 +40,25 @@ use crate::{
 const REFRESH_TIME_S: u32 = 60;
 const INPUT_BUFFER_MS: u32 = 500;
 
+/// A simple counter that, when used as a property, can be used to force refresh a child component.
+#[derive(Clone, PartialEq)]
+pub struct RedrawController(pub usize);
+
+impl RedrawController {
+    /// increase the counter, triggering a property change
+    ///
+    /// After this, the child component has to be rerendered in the `view` method with a new clone
+    /// of this controller.
+    pub fn redraw_request(&mut self) {
+        self.0 = self.0.wrapping_add(1);
+    }
+
+    /// Returns a new RedrawController
+    pub fn new() -> Self {
+        Self(0)
+    }
+}
+
 #[widget(comp=PdmResourceTree, @element)]
 #[derive(Properties, Clone, PartialEq)]
 #[builder]
@@ -55,6 +78,11 @@ pub struct ResourceTree {
     #[builder_cb(IntoEventCallback, into_event_callback, ())]
     /// Triggered after the user navigated to an entry by clicking or using the keyboard
     pub on_navigate: Option<Callback<()>>,
+
+    #[prop_or_default]
+    #[builder(IntoPropValue, into_prop_value)]
+    /// If set, will trigger a reload on a redraw request.
+    pub redraw_controller: Option<RedrawController>,
 }
 
 impl ResourceTree {
@@ -244,7 +272,16 @@ impl Component for PdmResourceTree {
 
     fn changed(&mut self, ctx: &Context<Self>, old_props: &Self::Properties) -> bool {
         let props = ctx.props();
-        if props.search_term != old_props.search_term {
+
+        let needs_reload = props.search_term != old_props.search_term;
+        let mut redraw_request = false;
+        if let Some(redraw_controller) = props.redraw_controller.clone() {
+            redraw_request = match &old_props.redraw_controller {
+                Some(controller) => controller.0 != redraw_controller.0,
+                None => true,
+            };
+        }
+        if needs_reload || redraw_request {
             if !props.search_only || !props.search_term.is_empty() {
                 ctx.link().clone().send_message(Msg::Load);
             } else if props.search_term.is_empty() {
-- 
2.47.3



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


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

* [pdm-devel] applied: [PATCH datacenter-manager v5 00/26] enable custom views on the UI
  2025-11-26 15:17 [pdm-devel] [PATCH datacenter-manager v5 00/26] enable custom views on the UI Dominik Csapak
                   ` (25 preceding siblings ...)
  2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 26/26] ui: resource tree/view: reload tree in a view on refresh Dominik Csapak
@ 2025-11-26 21:15 ` Thomas Lamprecht
  2025-11-26 21:17 ` [pdm-devel] " Thomas Lamprecht
  27 siblings, 0 replies; 29+ messages in thread
From: Thomas Lamprecht @ 2025-11-26 21:15 UTC (permalink / raw)
  To: pdm-devel, Dominik Csapak

On Wed, 26 Nov 2025 16:17:53 +0100, Dominik Csapak wrote:
> With this series, users are able to add/edit/delete custom views.
> 
> NOTE: this series is based on lukas follow up series to the views[0]
> but needs the change i mentioned in [1] to completely work.
> 
> A new 'Views' menu entry is added where the CRUD ui sits, and an entry
> for each view below it.
> 
> [...]

Applied, thanks!

[01/26] lib: pdm-config: views: add locking/saving methods
        commit: 5fbce9ac65b92748dc557c5230970484ac50142e
[02/26] lib: api-types: add 'layout' property to ViewConfig
        commit: 5e423bebce3039158741b9e4b619006129e9a4cd
[03/26] server: api: implement CRUD api for views
        commit: 16256fb8043ed554e66ee4f6ffc4f3f448f06826
[04/26] server: api: resources: add 'view' category to search syntax
        commit: aa369f3db878f4f25ca349b018ed1dd1656fbe88
[05/26] ui: remote selector: allow forcing of value
        commit: 813d6da27463bd2e1e8437079f95eb94ef644027
[06/26] ui: dashboard types: add missing 'default's to de-serialization
        commit: ebb791fbcdc87196dfef419e6439c31bc42cad5a
[07/26] ui: dashboard: status row: add optional 'editing state'
        commit: 8773f798342d88abda53d3c55312b58f2f9b6700
[08/26] ui: dashboard: prepare view for editing custom views
        commit: 07fddd49f7358d6975e7ceadd84afe91b8d66693
[09/26] ui: views: implement view loading from api
        commit: 0d734cb64969073a3b45442f4d7e6e9c79ceb539
[10/26] ui: views: make 'view' name property optional
        commit: acf78251fc145aa52900d8b22b9fe416ea9a3d55
[11/26] ui: views: add 'view' parameter to api calls
        commit: 589408f6329e9f5d38aa0a45359dca94638835dc
[12/26] ui: views: save updated layout to backend
        commit: 25fcacb0148516e180213fd4266ffff45ebfe524
[13/26] ui: add view list context
        commit: 77fc9914353e04ff261aabdfd99fab8f3a7d32d0
[14/26] ui: configuration: add view CRUD panels
        commit: 34738a6afde00d1bcd573830ef420b26b08ba8d3
[15/26] ui: main menu: add optional view_list property
        commit: e6bfbb88622d21e624d8c1ccb270d27a1eac440f
[16/26] ui: load view list on page init
        commit: 7b11a68448b1ca736936584b06697adaaa8fe184
[17/26] lib/ui: move views types to pdm-api-types
        commit: 5633ae438d823792a2d7408531aab63c09eb7cac
[18/26] server: api: views: check layout string for validity
        commit: b311f4dec557ab220f193290511c7bbe4d456363
[19/26] ui: dashboard: add current view to search terms
        commit: 97ae738191c1d0517377d31fd87ebfedf7bc6b97
[20/26] ui: resource tree: fix loading logic
        commit: e10706d821be23ac3ffd80d2937eb0187cfb39b5
[21/26] ui: resource tree: move error message into first column
        commit: 0baee620cd6e87cb0fa64884bd4d465a6e198072
[22/26] ui: resource tree: use `ViewContext` to limit the api calls to a view
        commit: 3767103e4f80bc3ef6a962d393c8a36357b8e069
[23/26] ui: resource tree: show guest tags
        commit: 4e62b65354ec597b90c0a8b8b804014207613a87
[24/26] api-types/ui: add ResourceTree variant for WidgetType
        commit: 67f9f607efe216019f628b9b90e44c6998919a71
[25/26] ui: dashboard view: refactor widget rendering arguments into struct
        commit: c9468952e1bc986b363b9493c1f301c36fd720b1
[26/26] ui: resource tree/view: reload tree in a view on refresh
        commit: 8e483a733f0376af7d36d7668c4b4ee8b8d63a01


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


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

* Re: [pdm-devel] [PATCH datacenter-manager v5 00/26] enable custom views on the UI
  2025-11-26 15:17 [pdm-devel] [PATCH datacenter-manager v5 00/26] enable custom views on the UI Dominik Csapak
                   ` (26 preceding siblings ...)
  2025-11-26 21:15 ` [pdm-devel] applied: [PATCH datacenter-manager v5 00/26] enable custom views on the UI Thomas Lamprecht
@ 2025-11-26 21:17 ` Thomas Lamprecht
  27 siblings, 0 replies; 29+ messages in thread
From: Thomas Lamprecht @ 2025-11-26 21:17 UTC (permalink / raw)
  To: Proxmox Datacenter Manager development discussion, Dominik Csapak

Am 26.11.25 um 16:19 schrieb Dominik Csapak:
> With this series, users are able to add/edit/delete custom views.
> 
> NOTE: this series is based on lukas follow up series to the views[0]
> but needs the change i mentioned in [1] to completely work.
> 
> A new 'Views' menu entry is added where the CRUD ui sits, and an entry
> for each view below it.
> 
> The includes/excludes are editable from the CRUD grid, and the layout is
> editable in the view itself.
> 
> For now i opted to make the layout part of the `ViewConfig` struct, but
> as a simple json-string only, because we're not able to use the
> api-macro (for now; the rust enum features i use are currently not
> supported there). To verify it regardless, we try to deserialize
> the layout in the add/update call, so that no wrong layout can enter
> the config (via the api).
> 
> The patch 'ui: dashboard: prepare view for editint custom views'
> is adapted from my last series, but all other changes are new,
> especially the CRUD api.
> 
> There is still one part wrong, namely
> * subscriptions panels are still added by default when none is in the
>   layout itself (the plan is to move the subscription notice)
> 

Smaller things I noticed:

- when adding a view there is missing frontend validation for the name, one
  can e.g. enter "Virtual Guests" there and then only gets an error on submit.
  FWIW, we could split name and ID here (can be done any time later too though)

- as you already write, having the subscription state of all remotes summarized
  in  the top panel would be nice, and that should be probably computed in the
  backend so that it can work for views that do not have any node included.

- I get where you come from with the eye icon, but maybe use "fa-plus-square-o"
  for now to simbolize a view with it's grid-like (well flex, but for users
  without CSS knowledge that distinction probably doesn't matter) arrangement
  of cards?
  https://fontawesome.com/v4/icon/plus-square-o
  The top-level entry should also use the same icon (in the midterm ideally same
  spirit but plural).

- export/import would be nice and potentially cheap to add FWICT.

- /view/... ACL paths are not yet visible in the ACL object path selector, e.g.
  when adding a new permission.

- some generic resource stats counter might be nice and cheap.

Might need to re-check this out with a bit fresher mind, but I already did once
before and given that I saw nothing completely off for my taste after playing
around with it for half an hour I see no reason to delay this any further now,
especially given our current time horizon.

Actually: Real nice work overall with this joint effort from you and Lukas!


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


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

end of thread, other threads:[~2025-11-26 21:17 UTC | newest]

Thread overview: 29+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2025-11-26 15:17 [pdm-devel] [PATCH datacenter-manager v5 00/26] enable custom views on the UI Dominik Csapak
2025-11-26 15:17 ` [pdm-devel] [PATCH datacenter-manager v5 01/26] lib: pdm-config: views: add locking/saving methods Dominik Csapak
2025-11-26 15:17 ` [pdm-devel] [PATCH datacenter-manager v5 02/26] lib: api-types: add 'layout' property to ViewConfig Dominik Csapak
2025-11-26 15:17 ` [pdm-devel] [PATCH datacenter-manager v5 03/26] server: api: implement CRUD api for views Dominik Csapak
2025-11-26 15:17 ` [pdm-devel] [PATCH datacenter-manager v5 04/26] server: api: resources: add 'view' category to search syntax Dominik Csapak
2025-11-26 15:17 ` [pdm-devel] [PATCH datacenter-manager v5 05/26] ui: remote selector: allow forcing of value Dominik Csapak
2025-11-26 15:17 ` [pdm-devel] [PATCH datacenter-manager v5 06/26] ui: dashboard types: add missing 'default's to de-serialization Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 07/26] ui: dashboard: status row: add optional 'editing state' Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 08/26] ui: dashboard: prepare view for editing custom views Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 09/26] ui: views: implement view loading from api Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 10/26] ui: views: make 'view' name property optional Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 11/26] ui: views: add 'view' parameter to api calls Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 12/26] ui: views: save updated layout to backend Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 13/26] ui: add view list context Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 14/26] ui: configuration: add view CRUD panels Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 15/26] ui: main menu: add optional view_list property Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 16/26] ui: load view list on page init Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 17/26] lib/ui: move views types to pdm-api-types Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 18/26] server: api: views: check layout string for validity Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 19/26] ui: dashboard: add current view to search terms Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 20/26] ui: resource tree: fix loading logic Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 21/26] ui: resource tree: move error message into first column Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 22/26] ui: resource tree: use `ViewContext` to limit the api calls to a view Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 23/26] ui: resource tree: show guest tags Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 24/26] api-types/ui: add ResourceTree variant for WidgetType Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 25/26] ui: dashboard view: refactor widget rendering arguments into struct Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 26/26] ui: resource tree/view: reload tree in a view on refresh Dominik Csapak
2025-11-26 21:15 ` [pdm-devel] applied: [PATCH datacenter-manager v5 00/26] enable custom views on the UI Thomas Lamprecht
2025-11-26 21:17 ` [pdm-devel] " Thomas Lamprecht

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal