* [pdm-devel] [PATCH datacenter-manager v2 00/18] enable custom views on the UI
@ 2025-11-14 12:11 Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 01/18] lib: pdm-config: views: add locking/saving methods Dominik Csapak
` (18 more replies)
0 siblings, 19 replies; 20+ messages in thread
From: Dominik Csapak @ 2025-11-14 12:11 UTC (permalink / raw)
To: pdm-devel
With this series, users are able to add/edit/delete custom views.
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 are still a few parts missing/wrong, namely
* while the api is prepared, clicking on guest status items does not
yet populate the search field with a '+view:foo' term
* subscriptions panels are still added by default when none is in the
layout itself (the plan is to move the subscription notice)
* i'm still working on making the 'resource tree' a full widget that
users can add to their views.
changes from v1:
* rebase on master, lukas v5 was applied
0: https://lore.proxmox.com/pdm-devel/20251112103510.158127-1-l.wagner@proxmox.com/T/#t
Dominik Csapak (18):
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' 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
lib/pdm-api-types/src/resource.rs | 7 +
lib/pdm-api-types/src/views.rs | 90 ++++-
lib/pdm-config/src/views.rs | 22 +-
server/src/api/config/mod.rs | 2 +
server/src/api/config/views.rs | 265 +++++++++++++
server/src/api/resources.rs | 19 +-
server/src/views/mod.rs | 2 +-
server/src/views/tests.rs | 15 +
ui/Cargo.toml | 2 +-
ui/css/pdm.scss | 4 +
ui/src/configuration/mod.rs | 2 +
ui/src/configuration/view_edit.rs | 4 +
ui/src/configuration/views.rs | 320 ++++++++++++++++
ui/src/dashboard/mod.rs | 2 -
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 | 389 +++++++++++--------
ui/src/dashboard/view/row_element.rs | 130 +++++++
ui/src/dashboard/view/row_view.rs | 520 +++++++++++++++++++++++++-
ui/src/lib.rs | 3 +
ui/src/main.rs | 47 ++-
ui/src/main_menu.rs | 57 ++-
ui/src/pve/mod.rs | 9 +
ui/src/view_list_context.rs | 31 ++
ui/src/widget/mod.rs | 6 +
ui/src/widget/remote_selector.rs | 6 +
ui/src/widget/view_filter_selector.rs | 378 +++++++++++++++++++
ui/src/widget/view_selector.rs | 55 +++
29 files changed, 2273 insertions(+), 256 deletions(-)
create mode 100644 server/src/api/config/views.rs
create mode 100644 ui/src/configuration/view_edit.rs
create mode 100644 ui/src/configuration/views.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] 20+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v2 01/18] lib: pdm-config: views: add locking/saving methods
2025-11-14 12:11 [pdm-devel] [PATCH datacenter-manager v2 00/18] enable custom views on the UI Dominik Csapak
@ 2025-11-14 12:11 ` Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 02/18] lib: api-types: add 'layout' property to ViewConfig Dominik Csapak
` (17 subsequent siblings)
18 siblings, 0 replies; 20+ messages in thread
From: Dominik Csapak @ 2025-11-14 12:11 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 80f8425c..04d39620 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] 20+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v2 02/18] lib: api-types: add 'layout' property to ViewConfig
2025-11-14 12:11 [pdm-devel] [PATCH datacenter-manager v2 00/18] enable custom views on the UI Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 01/18] lib: pdm-config: views: add locking/saving methods Dominik Csapak
@ 2025-11-14 12:11 ` Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 03/18] server: api: implement CRUD api for views Dominik Csapak
` (16 subsequent siblings)
18 siblings, 0 replies; 20+ messages in thread
From: Dominik Csapak @ 2025-11-14 12:11 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.
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
lib/pdm-api-types/src/views.rs | 12 +++++++++++-
server/src/views/tests.rs | 15 +++++++++++++++
2 files changed, 26 insertions(+), 1 deletion(-)
diff --git a/lib/pdm-api-types/src/views.rs b/lib/pdm-api-types/src/views.rs
index ef39cc62..4b837384 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)]
@@ -67,6 +70,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)]
diff --git a/server/src/views/tests.rs b/server/src/views/tests.rs
index 030b7994..c015acd2 100644
--- a/server/src/views/tests.rs
+++ b/server/src/views/tests.rs
@@ -184,6 +184,7 @@ fn include_exclude_remotes() {
FilterRule::Remote("remote-b".into()),
FilterRule::Remote("remote-c".into()),
],
+ layout: String::new(),
};
run_test(
config.clone(),
@@ -327,6 +328,7 @@ fn include_exclude_type() {
id: "exclude-resource-type".into(),
include: vec![FilterRule::ResourceType(ResourceType::PveQemu)],
exclude: vec![FilterRule::ResourceType(ResourceType::PveStorage)],
+ layout: String::new(),
},
&[
(
@@ -355,6 +357,7 @@ fn include_exclude_tags() {
FilterRule::Tag("tag2".to_string()),
],
exclude: vec![FilterRule::Tag("tag3".to_string())],
+ layout: String::new(),
},
&[
(
@@ -400,6 +403,7 @@ fn include_exclude_resource_pool() {
FilterRule::ResourcePool("pool2".to_string()),
],
exclude: vec![FilterRule::ResourcePool("pool2".to_string())],
+ layout: String::new(),
},
&[
(
@@ -449,6 +453,7 @@ fn include_exclude_resource_id() {
FilterRule::ResourceId("remote/otherremote/guest/101".to_string()),
FilterRule::ResourceId(format!("remote/{REMOTE}/storage/{NODE}/otherstorage")),
],
+ layout: String::new(),
},
&[
(
@@ -495,6 +500,7 @@ fn node_included() {
FilterRule::ResourceId("remote/someremote/node/test".to_string()),
],
exclude: vec![FilterRule::Remote("remote-b".to_string())],
+ layout: String::new(),
});
assert!(view.is_node_included("remote-a", "somenode"));
@@ -512,6 +518,7 @@ fn can_skip_remote_if_excluded() {
id: "abc".into(),
include: vec![],
exclude: vec![FilterRule::Remote("remote-b".to_string())],
+ layout: String::new(),
});
assert!(!view.can_skip_remote("remote-a"));
@@ -524,6 +531,7 @@ fn can_skip_remote_if_included() {
id: "abc".into(),
include: vec![FilterRule::Remote("remote-b".to_string())],
exclude: vec![],
+ layout: String::new(),
});
assert!(!view.can_skip_remote("remote-b"));
@@ -539,6 +547,7 @@ fn can_skip_remote_cannot_skip_if_any_other_include() {
FilterRule::ResourceId("resource/remote-a/guest/100".to_string()),
],
exclude: vec![],
+ layout: String::new(),
});
assert!(!view.can_skip_remote("remote-b"));
@@ -553,6 +562,7 @@ fn can_skip_remote_explicit_remote_exclude() {
"resource/remote-a/guest/100".to_string(),
)],
exclude: vec![FilterRule::Remote("remote-a".to_string())],
+ layout: String::new(),
});
assert!(view.can_skip_remote("remote-a"));
@@ -564,6 +574,7 @@ fn can_skip_remote_with_empty_config() {
id: "abc".into(),
include: vec![],
exclude: vec![],
+ layout: String::new(),
});
assert!(!view.can_skip_remote("remote-a"));
@@ -578,6 +589,7 @@ fn can_skip_remote_with_no_remote_includes() {
"resource/remote-a/guest/100".to_string(),
)],
exclude: vec![],
+ layout: String::new(),
});
assert!(!view.can_skip_remote("remote-a"));
@@ -590,6 +602,7 @@ fn explicitly_included_remote() {
id: "abc".into(),
include: vec![FilterRule::Remote("remote-b".to_string())],
exclude: vec![],
+ layout: String::new(),
});
assert!(view.is_remote_explicitly_included("remote-b"));
@@ -601,6 +614,7 @@ fn included_and_excluded_same_remote() {
id: "abc".into(),
include: vec![FilterRule::Remote("remote-b".to_string())],
exclude: vec![FilterRule::Remote("remote-b".to_string())],
+ layout: String::new(),
});
assert!(!view.is_remote_explicitly_included("remote-b"));
@@ -612,6 +626,7 @@ fn not_explicitly_included_remote() {
id: "abc".into(),
include: vec![],
exclude: vec![],
+ layout: String::new(),
});
// Assert that is not *explicitly* included
--
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] 20+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v2 03/18] server: api: implement CRUD api for views
2025-11-14 12:11 [pdm-devel] [PATCH datacenter-manager v2 00/18] enable custom views on the UI Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 01/18] lib: pdm-config: views: add locking/saving methods Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 02/18] lib: api-types: add 'layout' property to ViewConfig Dominik Csapak
@ 2025-11-14 12:11 ` Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 04/18] server: api: resources: add 'view' category to search syntax Dominik Csapak
` (15 subsequent siblings)
18 siblings, 0 replies; 20+ messages in thread
From: Dominik Csapak @ 2025-11-14 12:11 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>
---
server/src/api/config/mod.rs | 2 +
server/src/api/config/views.rs | 254 +++++++++++++++++++++++++++++++++
2 files changed, 256 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", ¬es::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..8c5a1d29
--- /dev/null
+++ b/server/src/api/config/views.rs
@@ -0,0 +1,254 @@
+use anyhow::Error;
+
+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,
+};
+use serde::{Deserialize, Serialize};
+
+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 = 0 != user_info.lookup_privs(&auth_id, &["view"]);
+
+ 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,
+}
+
+#[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(),
+ }
+ }
+ }
+
+ if let Some(include) = view.include {
+ conf.include = include;
+ }
+
+ if let Some(exclude) = view.exclude {
+ conf.exclude = exclude;
+ }
+
+ 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] 20+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v2 04/18] server: api: resources: add 'view' category to search syntax
2025-11-14 12:11 [pdm-devel] [PATCH datacenter-manager v2 00/18] enable custom views on the UI Dominik Csapak
` (2 preceding siblings ...)
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 03/18] server: api: implement CRUD api for views Dominik Csapak
@ 2025-11-14 12:11 ` Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 05/18] ui: remote selector: allow forcing of value Dominik Csapak
` (14 subsequent siblings)
18 siblings, 0 replies; 20+ messages in thread
From: Dominik Csapak @ 2025-11-14 12:11 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] 20+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v2 05/18] ui: remote selector: allow forcing of value
2025-11-14 12:11 [pdm-devel] [PATCH datacenter-manager v2 00/18] enable custom views on the UI Dominik Csapak
` (3 preceding siblings ...)
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 04/18] server: api: resources: add 'view' category to search syntax Dominik Csapak
@ 2025-11-14 12:11 ` Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 06/18] ui: dashboard types: add missing 'default' to de-serialization Dominik Csapak
` (13 subsequent siblings)
18 siblings, 0 replies; 20+ messages in thread
From: Dominik Csapak @ 2025-11-14 12:11 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] 20+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v2 06/18] ui: dashboard types: add missing 'default' to de-serialization
2025-11-14 12:11 [pdm-devel] [PATCH datacenter-manager v2 00/18] enable custom views on the UI Dominik Csapak
` (4 preceding siblings ...)
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 05/18] ui: remote selector: allow forcing of value Dominik Csapak
@ 2025-11-14 12:11 ` Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 07/18] ui: dashboard: status row: add optional 'editing state' Dominik Csapak
` (12 subsequent siblings)
18 siblings, 0 replies; 20+ messages in thread
From: Dominik Csapak @ 2025-11-14 12:11 UTC (permalink / raw)
To: pdm-devel
otherwise a layout with now rows can be serialized, but not
deserialized.
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
ui/src/dashboard/types.rs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/ui/src/dashboard/types.rs b/ui/src/dashboard/types.rs
index c79c38ab..3b018b6e 100644
--- a/ui/src/dashboard/types.rs
+++ b/ui/src/dashboard/types.rs
@@ -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] 20+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v2 07/18] ui: dashboard: status row: add optional 'editing state'
2025-11-14 12:11 [pdm-devel] [PATCH datacenter-manager v2 00/18] enable custom views on the UI Dominik Csapak
` (5 preceding siblings ...)
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 06/18] ui: dashboard types: add missing 'default' to de-serialization Dominik Csapak
@ 2025-11-14 12:11 ` Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 08/18] ui: dashboard: prepare view for editing custom views Dominik Csapak
` (11 subsequent siblings)
18 siblings, 0 replies; 20+ messages in thread
From: Dominik Csapak @ 2025-11-14 12:11 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 28fb0158..a39f8f58 100644
--- a/ui/src/dashboard/view.rs
+++ b/ui/src/dashboard/view.rs
@@ -39,6 +39,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] 20+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v2 08/18] ui: dashboard: prepare view for editing custom views
2025-11-14 12:11 [pdm-devel] [PATCH datacenter-manager v2 00/18] enable custom views on the UI Dominik Csapak
` (6 preceding siblings ...)
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 07/18] ui: dashboard: status row: add optional 'editing state' Dominik Csapak
@ 2025-11-14 12:11 ` Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 09/18] ui: views: implement view loading from api Dominik Csapak
` (10 subsequent siblings)
18 siblings, 0 replies; 20+ messages in thread
From: Dominik Csapak @ 2025-11-14 12:11 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 | 88 +++--
ui/src/dashboard/view/row_element.rs | 130 +++++++
ui/src/dashboard/view/row_view.rs | 519 ++++++++++++++++++++++++++-
5 files changed, 697 insertions(+), 46 deletions(-)
create mode 100644 ui/src/dashboard/view/row_element.rs
diff --git a/ui/Cargo.toml b/ui/Cargo.toml
index 8da9351a..9f9b594f 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 a39f8f58..1d317b0b 100644
--- a/ui/src/dashboard/view.rs
+++ b/ui/src/dashboard/view.rs
@@ -39,6 +39,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,
@@ -79,6 +81,7 @@ pub enum Msg {
Reload(bool), // force
ConfigWindow(bool), // show
UpdateConfig(RefreshConfig),
+ LayoutUpdate(ViewLayout),
}
struct ViewComp {
@@ -97,6 +100,8 @@ struct ViewComp {
load_finished_time: Option<f64>,
show_config_window: bool,
show_create_wizard: Option<RemoteType>,
+
+ editing_state: SharedState<Vec<EditingMessage>>,
}
fn render_widget(
@@ -276,6 +281,8 @@ impl Component for ViewComp {
loading: true,
show_config_window: false,
show_create_wizard: None,
+
+ editing_state: SharedState::new(Vec::new()),
}
}
@@ -331,6 +338,12 @@ impl Component for ViewComp {
self.show_config_window = false;
}
+ Msg::LayoutUpdate(view_layout) => {
+ // FIXME: update backend layout
+ if let Some(template) = &mut self.template.data {
+ template.layout = view_layout;
+ }
+ }
}
true
}
@@ -345,51 +358,65 @@ 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()) {
view.add_child(
Row::new()
- .class("pwt-content-spacer")
- .with_child(create_subscription_panel(self.subscriptions.clone())),
+ .padding_x(4)
+ .padding_bottom(4)
+ .padding_top(0)
+ .class("pwt-content-spacer-colors")
+ .with_child(create_subscription_panel(self.subscriptions.clone()).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 => {}
}
@@ -490,6 +517,7 @@ async fn load_template() -> Result<ViewTemplate, Error> {
\"leaderboard-type\": \"node-memory\"
}
],
+ [],
[
{
\"flex\": 5.0,
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] 20+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v2 09/18] ui: views: implement view loading from api
2025-11-14 12:11 [pdm-devel] [PATCH datacenter-manager v2 00/18] enable custom views on the UI Dominik Csapak
` (7 preceding siblings ...)
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 08/18] ui: dashboard: prepare view for editing custom views Dominik Csapak
@ 2025-11-14 12:11 ` Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 10/18] ui: views: make 'view' name property optional Dominik Csapak
` (9 subsequent siblings)
18 siblings, 0 replies; 20+ messages in thread
From: Dominik Csapak @ 2025-11-14 12:11 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, 96 insertions(+), 83 deletions(-)
diff --git a/ui/src/dashboard/view.rs b/ui/src/dashboard/view.rs
index 1d317b0b..4191ce90 100644
--- a/ui/src/dashboard/view.rs
+++ b/ui/src/dashboard/view.rs
@@ -33,6 +33,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;
@@ -263,8 +264,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 {
@@ -351,8 +353,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
}
@@ -459,88 +462,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 = "
+ {
+ \"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\"
+ }
+ ]
+ ]
+ }
+ }
+";
+
+async fn load_template(view: Option<AttrValue>) -> Result<ViewTemplate, Error> {
+ let view_str = match view {
+ Some(view) => {
+ 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] 20+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v2 10/18] ui: views: make 'view' name property optional
2025-11-14 12:11 [pdm-devel] [PATCH datacenter-manager v2 00/18] enable custom views on the UI Dominik Csapak
` (8 preceding siblings ...)
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 09/18] ui: views: implement view loading from api Dominik Csapak
@ 2025-11-14 12:11 ` Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 11/18] ui: views: add 'view' parameter to api calls Dominik Csapak
` (8 subsequent siblings)
18 siblings, 0 replies; 20+ messages in thread
From: Dominik Csapak @ 2025-11-14 12:11 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 | 61 +++++++++++++++++++++-------------------
ui/src/main_menu.rs | 2 +-
2 files changed, 33 insertions(+), 30 deletions(-)
diff --git a/ui/src/dashboard/view.rs b/ui/src/dashboard/view.rs
index 4191ce90..ad38db26 100644
--- a/ui/src/dashboard/view.rs
+++ b/ui/src/dashboard/view.rs
@@ -51,7 +51,7 @@ pub enum EditingMessage {
#[derive(Properties, PartialEq)]
pub struct View {
- view: AttrValue,
+ view: Option<AttrValue>,
}
impl From<View> for VNode {
@@ -62,7 +62,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() }
}
}
@@ -259,14 +259,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)
+ async_pool.send_future(ctx.link().clone(), {
+ async move { Msg::ViewTemplateLoaded(load_template(view).await) }
});
Self {
@@ -355,7 +358,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
}
@@ -379,9 +382,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())),
),
);
@@ -435,24 +436,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 43cc2923..883c482a 100644
--- a/ui/src/main_menu.rs
+++ b/ui/src/main_menu.rs
@@ -142,7 +142,7 @@ impl Component for PdmMainMenu {
tr!("Dashboard"),
"dashboard",
Some("fa fa-tachometer"),
- move |_| View::new("dashboard").into(),
+ move |_| View::new(None).into(),
);
register_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] 20+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v2 11/18] ui: views: add 'view' parameter to api calls
2025-11-14 12:11 [pdm-devel] [PATCH datacenter-manager v2 00/18] enable custom views on the UI Dominik Csapak
` (9 preceding siblings ...)
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 10/18] ui: views: make 'view' name property optional Dominik Csapak
@ 2025-11-14 12:11 ` Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 12/18] ui: views: save updated layout to backend Dominik Csapak
` (7 subsequent siblings)
18 siblings, 0 replies; 20+ messages in thread
From: Dominik Csapak @ 2025-11-14 12:11 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 ad38db26..79271983 100644
--- a/ui/src/dashboard/view.rs
+++ b/ui/src/dashboard/view.rs
@@ -3,7 +3,7 @@ use std::rc::Rc;
use anyhow::Error;
use futures::join;
use js_sys::Date;
-use serde_json::json;
+use serde_json::{json, Value};
use yew::virtual_dom::{VComp, VNode};
use proxmox_yew_comp::http_get;
@@ -162,11 +162,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)));
}
};
@@ -175,24 +184,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.into()
+ });
+ 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] 20+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v2 12/18] ui: views: save updated layout to backend
2025-11-14 12:11 [pdm-devel] [PATCH datacenter-manager v2 00/18] enable custom views on the UI Dominik Csapak
` (10 preceding siblings ...)
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 11/18] ui: views: add 'view' parameter to api calls Dominik Csapak
@ 2025-11-14 12:11 ` Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 13/18] ui: add view list context Dominik Csapak
` (6 subsequent siblings)
18 siblings, 0 replies; 20+ messages in thread
From: Dominik Csapak @ 2025-11-14 12:11 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 | 32 ++++++++++++++++++++++++++++++--
1 file changed, 30 insertions(+), 2 deletions(-)
diff --git a/ui/src/dashboard/view.rs b/ui/src/dashboard/view.rs
index 79271983..8ee59073 100644
--- a/ui/src/dashboard/view.rs
+++ b/ui/src/dashboard/view.rs
@@ -6,7 +6,7 @@ use js_sys::Date;
use serde_json::{json, Value};
use yew::virtual_dom::{VComp, VNode};
-use proxmox_yew_comp::http_get;
+use proxmox_yew_comp::{http_get, http_put};
use pwt::css;
use pwt::prelude::*;
use pwt::props::StorageLocation;
@@ -83,6 +83,7 @@ pub enum Msg {
ConfigWindow(bool), // show
UpdateConfig(RefreshConfig),
LayoutUpdate(ViewLayout),
+ UpdateResult(Result<(), Error>),
}
struct ViewComp {
@@ -103,6 +104,7 @@ struct ViewComp {
show_create_wizard: Option<RemoteType>,
editing_state: SharedState<Vec<EditingMessage>>,
+ update_result: LoadResult<(), Error>,
}
fn render_widget(
@@ -304,6 +306,7 @@ impl Component for ViewComp {
show_create_wizard: None,
editing_state: SharedState::new(Vec::new()),
+ update_result: LoadResult::new(),
}
}
@@ -360,11 +363,30 @@ impl Component for ViewComp {
self.show_config_window = false;
}
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
}
@@ -452,6 +474,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] 20+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v2 13/18] ui: add view list context
2025-11-14 12:11 [pdm-devel] [PATCH datacenter-manager v2 00/18] enable custom views on the UI Dominik Csapak
` (11 preceding siblings ...)
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 12/18] ui: views: save updated layout to backend Dominik Csapak
@ 2025-11-14 12:11 ` Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 14/18] ui: configuration: add view CRUD panels Dominik Csapak
` (5 subsequent siblings)
18 siblings, 0 replies; 20+ messages in thread
From: Dominik Csapak @ 2025-11-14 12:11 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] 20+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v2 14/18] ui: configuration: add view CRUD panels
2025-11-14 12:11 [pdm-devel] [PATCH datacenter-manager v2 00/18] enable custom views on the UI Dominik Csapak
` (12 preceding siblings ...)
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 13/18] ui: add view list context Dominik Csapak
@ 2025-11-14 12:11 ` Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 15/18] ui: main menu: add optional view_list property Dominik Csapak
` (4 subsequent siblings)
18 siblings, 0 replies; 20+ messages in thread
From: Dominik Csapak @ 2025-11-14 12:11 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>
---
ui/src/configuration/mod.rs | 2 +
ui/src/configuration/view_edit.rs | 4 +
ui/src/configuration/views.rs | 320 ++++++++++++++++++++++
ui/src/widget/mod.rs | 6 +
ui/src/widget/view_filter_selector.rs | 378 ++++++++++++++++++++++++++
ui/src/widget/view_selector.rs | 55 ++++
6 files changed, 765 insertions(+)
create mode 100644 ui/src/configuration/view_edit.rs
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/view_edit.rs b/ui/src/configuration/view_edit.rs
new file mode 100644
index 00000000..9ca36f0f
--- /dev/null
+++ b/ui/src/configuration/view_edit.rs
@@ -0,0 +1,4 @@
+use pwt::prelude::*;
+
+#[derive(Properties, PartialEq, Clone)]
+pub struct ViewEdit {}
diff --git a/ui/src/configuration/views.rs b/ui/src/configuration/views.rs
new file mode 100644
index 00000000..35eb9fc1
--- /dev/null
+++ b/ui/src/configuration/views.rs
@@ -0,0 +1,320 @@
+use std::rc::Rc;
+
+use anyhow::{bail, Error};
+use proxmox_yew_comp::form::delete_empty_values;
+use serde_json::json;
+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::{DisplayField, Field, FormContext};
+use pwt::widget::{Button, ConfirmDialog, InputPanel, Toolbar};
+
+use pdm_api_types::views::ViewConfig;
+
+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 = if layout == "__dashboard__" {
+ None
+ } else {
+ let store = store.read();
+ if let Some(config) = store.lookup_record(&Key::from(layout)) {
+ Some(config.layout.clone())
+ } else {
+ bail!("Source View not found")
+ }
+ };
+
+ let mut params = json!({
+ "id": data["id"].take(),
+ });
+ if let Some(layout) = layout {
+ params["layout"] = layout.into();
+ }
+ if data["include"].is_array() {
+ params["include"] = data["include"].take();
+ }
+ if data["exclude"].is_array() {
+ params["exclude"] = data["exclude"].take();
+ }
+ http_post(base_url.as_str(), Some(params)).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"], true);
+ 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)
+ .get_property_owned(|value: &ViewConfig| value.include.len())
+ .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 |_| add_view_input_panel(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 |_| edit_view_input_panel(id.clone()))
+ .on_submit({
+ let base_url = props.base_url.clone();
+ move |form| update_view(base_url.clone(), form)
+ })
+ .loader(format!("{}/{}", props.base_url, 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>,
+ ) -> std::pin::Pin<Box<dyn std::prelude::rust_2024::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(store: Store<ViewConfig>) -> Html {
+ InputPanel::new()
+ .padding(4)
+ .with_field(tr!("Name"), Field::new().name("id").required(true))
+ .with_right_field(
+ tr!("Copy Layout from"),
+ ViewSelector::new(store).name("copy-from").required(true),
+ )
+ .with_large_field(tr!("Include"), ViewFilterSelector::new().name("include"))
+ .with_large_field(tr!("Exclude"), ViewFilterSelector::new().name("exclude"))
+ .into()
+}
+
+fn edit_view_input_panel(id: String) -> Html {
+ InputPanel::new()
+ .padding(4)
+ .with_field(tr!("Name"), DisplayField::new().name("id").value(id))
+ .with_large_field(tr!("Include"), ViewFilterSelector::new().name("include"))
+ .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..3776ad39
--- /dev/null
+++ b/ui/src/widget/view_filter_selector.rs
@@ -0,0 +1,378 @@
+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::{FilterRule, FILTER_RULE_LIST_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(ResourceType::Node)
+ }
+ Ok(FilterRuleType::ResourcePool) => {
+ FilterRule::ResourcePool(String::new())
+ }
+ Ok(FilterRuleType::ResourceId) => {
+ FilterRule::ResourceId(String::new())
+ }
+ Ok(FilterRuleType::Tag) => FilterRule::Tag(String::new()),
+ Ok(FilterRuleType::Remote) => FilterRule::Remote(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.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::PveSdnZone.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::PveSdnZone) => tr!("PVE SDN Zone"),
+ 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(resource_type));
+ }
+ }
+ })
+ .into(),
+ Some(FilterRule::ResourceId(id)) => Field::new()
+ .value(id.clone())
+ .required(true)
+ .on_change({
+ move |value: String| {
+ send_change(FilterRule::ResourceId(value));
+ }
+ })
+ .into(),
+ Some(FilterRule::ResourcePool(pool)) => Field::new()
+ .value(pool.clone())
+ .required(true)
+ .on_change({
+ move |value: String| {
+ send_change(FilterRule::ResourcePool(value));
+ }
+ })
+ .into(),
+ Some(FilterRule::Tag(tag)) => Field::new()
+ .value(tag.clone())
+ .required(true)
+ .on_change({
+ move |value: String| {
+ send_change(FilterRule::Tag(value));
+ }
+ })
+ .into(),
+ Some(FilterRule::Remote(remote)) => RemoteSelector::new()
+ .value(remote.clone())
+ .required(true)
+ .on_change(move |value| send_change(FilterRule::Remote(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] 20+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v2 15/18] ui: main menu: add optional view_list property
2025-11-14 12:11 [pdm-devel] [PATCH datacenter-manager v2 00/18] enable custom views on the UI Dominik Csapak
` (13 preceding siblings ...)
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 14/18] ui: configuration: add view CRUD panels Dominik Csapak
@ 2025-11-14 12:11 ` Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 16/18] ui: load view list on page init Dominik Csapak
` (3 subsequent siblings)
18 siblings, 0 replies; 20+ messages in thread
From: Dominik Csapak @ 2025-11-14 12:11 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 883c482a..fd64e330 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};
@@ -13,6 +13,7 @@ use proxmox_yew_comp::{NotesView, XTermJs};
use pdm_api_types::remotes::RemoteType;
+use crate::configuration::views::ViewGrid;
use crate::dashboard::view::View;
use crate::remotes::RemotesPanel;
use crate::sdn::evpn::EvpnPanel;
@@ -53,6 +54,10 @@ pub struct MainMenu {
#[builder]
#[prop_or_default]
pub remote_list: Vec<RemoteListCacheEntry>,
+
+ #[builder]
+ #[prop_or_default]
+ pub view_list: Vec<String>,
}
impl MainMenu {
@@ -130,6 +135,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());
@@ -145,6 +158,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-tachometer"),
+ 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,
+ );
+
register_view(
&mut menu,
&mut content,
--
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] 20+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v2 16/18] ui: load view list on page init
2025-11-14 12:11 [pdm-devel] [PATCH datacenter-manager v2 00/18] enable custom views on the UI Dominik Csapak
` (14 preceding siblings ...)
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 15/18] ui: main menu: add optional view_list property Dominik Csapak
@ 2025-11-14 12:11 ` Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 17/18] lib/ui: move views types to pdm-api-types Dominik Csapak
` (2 subsequent siblings)
18 siblings, 0 replies; 20+ messages in thread
From: Dominik Csapak @ 2025-11-14 12:11 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 | 47 +++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 45 insertions(+), 2 deletions(-)
diff --git a/ui/src/main.rs b/ui/src/main.rs
index f8a44f57..ce87a93e 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);
@@ -248,6 +279,14 @@ impl Component for DatacenterManagerApp {
}
*/
Msg::RemoteList(remotes) => self.update_remotes(ctx, remotes),
+ Msg::ViewList(views) => {
+ self.view_list = views.ok().unwrap_or_default();
+ true
+ }
+ Msg::UpdateViewList => {
+ self.update_views(ctx);
+ false
+ }
}
}
@@ -272,6 +311,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())
@@ -289,11 +329,14 @@ 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}>
- {body}
+ <ContextProvider<ViewListContext> context={view_list_context}>
+ {body}
+ </ContextProvider<ViewListContext>>
</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] 20+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v2 17/18] lib/ui: move views types to pdm-api-types
2025-11-14 12:11 [pdm-devel] [PATCH datacenter-manager v2 00/18] enable custom views on the UI Dominik Csapak
` (15 preceding siblings ...)
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 16/18] ui: load view list on page init Dominik Csapak
@ 2025-11-14 12:11 ` Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 18/18] server: api: views: check layout string for validity Dominik Csapak
2025-11-14 12:22 ` [pdm-devel] [PATCH datacenter-manager v2 00/18] enable custom views on the UI Dominik Csapak
18 siblings, 0 replies; 20+ messages in thread
From: Dominik Csapak @ 2025-11-14 12:11 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 4b837384..3464833d 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! {
@@ -182,6 +184,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(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 3b018b6e..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(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 8ee59073..7edf5106 100644
--- a/ui/src/dashboard/view.rs
+++ b/ui/src/dashboard/view.rs
@@ -19,8 +19,6 @@ use crate::dashboard::refresh_config_edit::{
FORCE_RELOAD_MAX_AGE_S, INITIAL_MAX_AGE_S,
};
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,
@@ -33,7 +31,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;
@@ -118,7 +118,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 c0759f18..c1c62230 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] 20+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v2 18/18] server: api: views: check layout string for validity
2025-11-14 12:11 [pdm-devel] [PATCH datacenter-manager v2 00/18] enable custom views on the UI Dominik Csapak
` (16 preceding siblings ...)
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 17/18] lib/ui: move views types to pdm-api-types Dominik Csapak
@ 2025-11-14 12:11 ` Dominik Csapak
2025-11-14 12:22 ` [pdm-devel] [PATCH datacenter-manager v2 00/18] enable custom views on the UI Dominik Csapak
18 siblings, 0 replies; 20+ messages in thread
From: Dominik Csapak @ 2025-11-14 12:11 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 8c5a1d29..8c6744e2 100644
--- a/server/src/api/config/views.rs
+++ b/server/src/api/config/views.rs
@@ -6,7 +6,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,
};
use serde::{Deserialize, Serialize};
@@ -91,6 +91,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)
}
@@ -181,6 +187,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] 20+ messages in thread
* Re: [pdm-devel] [PATCH datacenter-manager v2 00/18] enable custom views on the UI
2025-11-14 12:11 [pdm-devel] [PATCH datacenter-manager v2 00/18] enable custom views on the UI Dominik Csapak
` (17 preceding siblings ...)
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 18/18] server: api: views: check layout string for validity Dominik Csapak
@ 2025-11-14 12:22 ` Dominik Csapak
18 siblings, 0 replies; 20+ messages in thread
From: Dominik Csapak @ 2025-11-14 12:22 UTC (permalink / raw)
To: pdm-devel
disregard this, i forgot to add a hunk and the gui does not compile :(
v3 incoming..
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply [flat|nested] 20+ messages in thread
end of thread, other threads:[~2025-11-14 12:22 UTC | newest]
Thread overview: 20+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2025-11-14 12:11 [pdm-devel] [PATCH datacenter-manager v2 00/18] enable custom views on the UI Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 01/18] lib: pdm-config: views: add locking/saving methods Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 02/18] lib: api-types: add 'layout' property to ViewConfig Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 03/18] server: api: implement CRUD api for views Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 04/18] server: api: resources: add 'view' category to search syntax Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 05/18] ui: remote selector: allow forcing of value Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 06/18] ui: dashboard types: add missing 'default' to de-serialization Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 07/18] ui: dashboard: status row: add optional 'editing state' Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 08/18] ui: dashboard: prepare view for editing custom views Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 09/18] ui: views: implement view loading from api Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 10/18] ui: views: make 'view' name property optional Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 11/18] ui: views: add 'view' parameter to api calls Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 12/18] ui: views: save updated layout to backend Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 13/18] ui: add view list context Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 14/18] ui: configuration: add view CRUD panels Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 15/18] ui: main menu: add optional view_list property Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 16/18] ui: load view list on page init Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 17/18] lib/ui: move views types to pdm-api-types Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 18/18] server: api: views: check layout string for validity Dominik Csapak
2025-11-14 12:22 ` [pdm-devel] [PATCH datacenter-manager v2 00/18] enable custom views on the UI Dominik Csapak
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.