* [pdm-devel] [PATCH datacenter-manager v3 00/21] prepare ui for customizable views
@ 2025-10-31 12:43 Dominik Csapak
  2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 01/21] ui: dashboard: refactor guest panel creation to its own module Dominik Csapak
                   ` (20 more replies)
  0 siblings, 21 replies; 22+ messages in thread
From: Dominik Csapak @ 2025-10-31 12:43 UTC (permalink / raw)
  To: pdm-devel
This is the first step to have customizable views in the PDM ui.
A few open points:
* The `LoadResult` struct should probably live in either pwt or
  yew-comp. I did not move it to pwt yet, but I'll do that for the next
  version.
* Not super sure if this kind of structure is the one we desire.
  The View is (for now) only a single layout (Row) that provides
  the same behavior as the current layout. The whole logic is in the
  View and every bit we want to add has to be handled there.
Note that we'll have to move the types for the View/ViewLayout/etc. to
the pdm-api-types,  since we'll also want to use them on the
backend. I'll do that too in a v4.
changes from v2:
* rebase on master (includes now the pbs datastore & node panel)
* integrated @shannon's feedback (just holler if i forgot something)
* added an edit mode
* restructured the `view` so that we can have a component per layout
  type (currently only `RowView`)
* changed the type of the laod results to use SharedState, so we
  can copy the Rc around instead of the actual data.
* moved the subscription info loading to the view
changes from v1:
* rebased on master
* added new patch to fix dashboard layout after change to views (patch 7)
Dominik Csapak (21):
  ui: dashboard: refactor guest panel creation to its own module
  ui: dashboard: refactor creating the node panel into its own module
  ui: dashboard: node panel: make remote type optional
  ui: dashboard: refactor remote panel creation into its own module
  ui: dashboard: remote panel: make wizard menu optional
  ui: dashboard: refactor sdn panel creation into its own module
  ui: dashboard: refactor task summary panel creation to its own module
  ui: dashboard: task summary: disable virtual scrolling
  ui: dashboard: refactor subscription panel creation to its own module
  ui: dashboard: refactor top entities panel creation to its own module
  ui: dashboard: refactor DashboardConfig editing/constants to their
    module
  ui: dashboard: factor out task parameter calculation
  ui: dashboard: pbs datastores panel: refactor creation into own module
  ui: dashboard: remove unused remote list
  ui: dashboard: status row: make loading less jarring
  ui: introduce `LoadResult` helper type
  ui: dashboard: implement 'View'
  ui: dashboard: use 'View' instead of the Dashboard
  ui: dashboard: subscription info: move subscription loading to view
  ui: dashboard: use SharedState for create_*_panel
  ui: dashboard: enable editing view
 ui/Cargo.toml                            |   2 +-
 ui/css/pdm.scss                          |   4 +
 ui/src/dashboard/guest_panel.rs          | 100 +++-
 ui/src/dashboard/mod.rs                  | 684 +----------------------
 ui/src/dashboard/node_status_panel.rs    | 100 +++-
 ui/src/dashboard/pbs_datastores_panel.rs |  38 +-
 ui/src/dashboard/refresh_config_edit.rs  | 107 ++++
 ui/src/dashboard/remote_panel.rs         |  59 +-
 ui/src/dashboard/sdn_zone_panel.rs       |  21 +-
 ui/src/dashboard/status_row.rs           |  40 +-
 ui/src/dashboard/subscription_info.rs    | 116 ++--
 ui/src/dashboard/tasks.rs                |  44 ++
 ui/src/dashboard/top_entities.rs         |  55 +-
 ui/src/dashboard/types.rs                |  79 +++
 ui/src/dashboard/view.rs                 | 539 ++++++++++++++++++
 ui/src/dashboard/view/row_element.rs     | 130 +++++
 ui/src/dashboard/view/row_view.rs        | 497 ++++++++++++++++
 ui/src/lib.rs                            |   5 +-
 ui/src/load_result.rs                    |  42 ++
 ui/src/main_menu.rs                      |   5 +-
 ui/src/pbs/remote.rs                     |  30 +-
 ui/src/pve/lxc/overview.rs               |  28 +-
 ui/src/pve/mod.rs                        |   4 +-
 ui/src/pve/node/overview.rs              |  29 +-
 ui/src/pve/qemu/overview.rs              |  28 +-
 ui/src/pve/storage.rs                    |  29 +-
 26 files changed, 1913 insertions(+), 902 deletions(-)
 create mode 100644 ui/src/dashboard/refresh_config_edit.rs
 create mode 100644 ui/src/dashboard/types.rs
 create mode 100644 ui/src/dashboard/view.rs
 create mode 100644 ui/src/dashboard/view/row_element.rs
 create mode 100644 ui/src/dashboard/view/row_view.rs
 create mode 100644 ui/src/load_result.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] 22+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v3 01/21] ui: dashboard: refactor guest panel creation to its own module
  2025-10-31 12:43 [pdm-devel] [PATCH datacenter-manager v3 00/21] prepare ui for customizable views Dominik Csapak
@ 2025-10-31 12:43 ` Dominik Csapak
  2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 02/21] ui: dashboard: refactor creating the node panel into " Dominik Csapak
                   ` (19 subsequent siblings)
  20 siblings, 0 replies; 22+ messages in thread
From: Dominik Csapak @ 2025-10-31 12:43 UTC (permalink / raw)
  To: pdm-devel
so we can more easily reuse it. For this, also make the 'create_title_with_icon'
a freestanding function that is public so we can reuse it outside the
dashboard struct.
Also add the functionality of not passing a guest_type, so that we can
create a panel that includes info for all guests, regardless of type.
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/dashboard/guest_panel.rs | 79 +++++++++++++++++++++++++--------
 ui/src/dashboard/mod.rs         | 69 +++++++++++++---------------
 2 files changed, 91 insertions(+), 57 deletions(-)
diff --git a/ui/src/dashboard/guest_panel.rs b/ui/src/dashboard/guest_panel.rs
index 814ecfa5..be7a7a2e 100644
--- a/ui/src/dashboard/guest_panel.rs
+++ b/ui/src/dashboard/guest_panel.rs
@@ -1,30 +1,34 @@
 use std::rc::Rc;
 
-use pdm_api_types::resource::{GuestStatusCount, ResourceType};
+use pdm_api_types::resource::{GuestStatusCount, ResourceType, ResourcesStatus};
 use pdm_search::{Search, SearchTerm};
 use proxmox_yew_comp::GuestState;
 use pwt::{
     css::{self, TextAlign},
     prelude::*,
-    widget::{Container, Fa, List, ListTile},
+    widget::{Container, Fa, List, ListTile, Panel},
 };
 use yew::{
     virtual_dom::{VComp, VNode},
     Properties,
 };
 
-use crate::{pve::GuestType, search_provider::get_search_provider};
+use crate::{
+    dashboard::create_title_with_icon, pve::GuestType, search_provider::get_search_provider,
+};
 
 use super::loading_column;
 
 #[derive(PartialEq, Clone, Properties)]
 pub struct GuestPanel {
-    guest_type: GuestType,
-    status: Option<GuestStatusCount>,
+    guest_type: Option<GuestType>,
+    status: Option<ResourcesStatus>,
 }
 
 impl GuestPanel {
-    pub fn new(guest_type: GuestType, status: Option<GuestStatusCount>) -> Self {
+    /// Creates a new guest panel. Setting `guest_type` to `None` means we
+    /// create one for all guests, regardless of type.
+    pub fn new(guest_type: Option<GuestType>, status: Option<ResourcesStatus>) -> Self {
         yew::props!(Self { guest_type, status })
     }
 }
@@ -63,7 +67,16 @@ impl yew::Component for PdmGuestPanel {
         let props = ctx.props();
         let guest_type = props.guest_type;
         let status = match &props.status {
-            Some(status) => status,
+            Some(status) => match guest_type {
+                Some(GuestType::Qemu) => status.qemu.clone(),
+                Some(GuestType::Lxc) => status.lxc.clone(),
+                None => GuestStatusCount {
+                    running: status.qemu.running + status.lxc.running,
+                    stopped: status.qemu.stopped + status.lxc.stopped,
+                    template: status.qemu.template + status.lxc.template,
+                    unknown: status.qemu.unknown + status.lxc.unknown,
+                },
+            },
             None => return loading_column().into(),
         };
 
@@ -93,7 +106,7 @@ impl yew::Component for PdmGuestPanel {
 
 fn create_list_tile(
     link: &html::Scope<PdmGuestPanel>,
-    guest_type: GuestType,
+    guest_type: Option<GuestType>,
     status_row: StatusRow,
 ) -> Option<ListTile> {
     let (icon, text, count, status, template) = match status_row {
@@ -129,7 +142,13 @@ fn create_list_tile(
                 None,
             ),
         },
-        StatusRow::All(count) => (Fa::from(guest_type), tr!("All"), count, None, None),
+        StatusRow::All(count) => (
+            Fa::from(guest_type.unwrap_or(GuestType::Qemu)),
+            tr!("All"),
+            count,
+            None,
+            None,
+        ),
     };
 
     Some(
@@ -158,19 +177,29 @@ fn create_list_tile(
 }
 
 fn create_guest_search_term(
-    guest_type: GuestType,
+    guest_type: Option<GuestType>,
     status: Option<&'static str>,
     template: Option<bool>,
 ) -> Search {
-    let resource_type: ResourceType = guest_type.into();
-    if status.is_none() && template.is_none() {
-        return Search::with_terms(vec![
-            SearchTerm::new(resource_type.as_str()).category(Some("type"))
-        ]);
+    let mut terms = Vec::new();
+    match guest_type {
+        Some(guest_type) => {
+            let resource_type: ResourceType = guest_type.into();
+            terms.push(SearchTerm::new(resource_type.as_str()).category(Some("type")));
+        }
+        None => {
+            terms.push(
+                SearchTerm::new(ResourceType::PveQemu.as_str())
+                    .category(Some("type"))
+                    .optional(true),
+            );
+            terms.push(
+                SearchTerm::new(ResourceType::PveLxc.as_str())
+                    .category(Some("type"))
+                    .optional(true),
+            );
+        }
     }
-
-    let mut terms = vec![SearchTerm::new(resource_type.as_str()).category(Some("type"))];
-
     if let Some(template) = template {
         terms.push(SearchTerm::new(template.to_string()).category(Some("template")));
     }
@@ -179,3 +208,17 @@ fn create_guest_search_term(
     }
     Search::with_terms(terms)
 }
+
+/// Creates a new guest panel. Setting `guest_type` to `None` means we
+/// create one for all guests, regardless of type.
+pub fn create_guest_panel(guest_type: Option<GuestType>, status: Option<ResourcesStatus>) -> Panel {
+    let (icon, title) = match guest_type {
+        Some(GuestType::Qemu) => ("desktop", tr!("Virtual Machines")),
+        Some(GuestType::Lxc) => ("cubes", tr!("Linux Container")),
+        None => ("desktop", tr!("Guests")),
+    };
+    Panel::new()
+        .title(create_title_with_icon(icon, title))
+        .border(true)
+        .with_child(GuestPanel::new(guest_type, status))
+}
diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs
index 07d5cd99..1fe149c3 100644
--- a/ui/src/dashboard/mod.rs
+++ b/ui/src/dashboard/mod.rs
@@ -41,7 +41,7 @@ mod remote_panel;
 use remote_panel::RemotePanel;
 
 mod guest_panel;
-use guest_panel::GuestPanel;
+pub use guest_panel::create_guest_panel;
 
 mod node_status_panel;
 use node_status_panel::NodeStatusPanel;
@@ -149,15 +149,6 @@ pub struct PdmDashboard {
 }
 
 impl PdmDashboard {
-    fn create_title_with_icon(&self, icon: &str, title: String) -> Html {
-        Row::new()
-            .class(AlignItems::Center)
-            .gap(2)
-            .with_child(Fa::new(icon))
-            .with_child(title)
-            .into()
-    }
-
     fn create_node_panel(&self, icon: &str, title: String, remote_type: RemoteType) -> Panel {
         let (nodes_status, failed_remotes) = match &self.status {
             Some(status) => {
@@ -178,7 +169,7 @@ impl PdmDashboard {
         Panel::new()
             .flex(1.0)
             .width(300)
-            .title(self.create_title_with_icon(icon, title))
+            .title(create_title_with_icon(icon, title))
             .border(true)
             .with_child(NodeStatusPanel::new(
                 remote_type,
@@ -187,34 +178,13 @@ impl PdmDashboard {
             ))
     }
 
-    fn create_guest_panel(&self, guest_type: GuestType) -> Panel {
-        let (icon, title, status) = match guest_type {
-            GuestType::Qemu => (
-                "desktop",
-                tr!("Virtual Machines"),
-                self.status.as_ref().map(|s| s.qemu.clone()),
-            ),
-            GuestType::Lxc => (
-                "cubes",
-                tr!("Linux Container"),
-                self.status.as_ref().map(|s| s.lxc.clone()),
-            ),
-        };
-        Panel::new()
-            .flex(1.0)
-            .width(300)
-            .title(self.create_title_with_icon(icon, title))
-            .border(true)
-            .with_child(GuestPanel::new(guest_type, status))
-    }
-
     fn create_sdn_panel(&self) -> Panel {
         let sdn_zones_status = self.status.as_ref().map(|status| status.sdn_zones.clone());
 
         Panel::new()
             .flex(1.0)
             .width(200)
-            .title(self.create_title_with_icon("sdn", tr!("SDN Zones")))
+            .title(create_title_with_icon("sdn", tr!("SDN Zones")))
             .border(true)
             .with_child(SdnZonePanel::new(
                 (!self.loading).then_some(sdn_zones_status).flatten(),
@@ -235,7 +205,7 @@ impl PdmDashboard {
             .flex(1.0)
             .width(500)
             .border(true)
-            .title(self.create_title_with_icon("list", title))
+            .title(create_title_with_icon("list", title))
             .with_child(
                 Container::new()
                     .class(FlexFit)
@@ -272,7 +242,7 @@ impl PdmDashboard {
             .width(500)
             .min_width(400)
             .border(true)
-            .title(self.create_title_with_icon(icon, title))
+            .title(create_title_with_icon(icon, title))
             .with_optional_child(
                 entities
                     .map(|entities| TopEntities::new(entities.clone(), metrics_title, threshold)),
@@ -294,7 +264,10 @@ impl PdmDashboard {
         Panel::new()
             .flex(1.0)
             .width(300)
-            .title(self.create_title_with_icon("database", tr!("Backup Server Datastores")))
+            .title(create_title_with_icon(
+                "database",
+                tr!("Backup Server Datastores"),
+            ))
             .border(true)
             .with_child(PbsDatastoresPanel::new(pbs_datastores))
     }
@@ -499,7 +472,7 @@ impl Component for PdmDashboard {
                     .padding_top(0)
                     .with_child(
                         Panel::new()
-                            .title(self.create_title_with_icon("server", tr!("Remotes")))
+                            .title(create_title_with_icon("server", tr!("Remotes")))
                             .flex(1.0)
                             //.border(true)
                             .width(300)
@@ -530,8 +503,16 @@ impl Component for PdmDashboard {
                         tr!("Virtual Environment Nodes"),
                         RemoteType::Pve,
                     ))
-                    .with_child(self.create_guest_panel(GuestType::Qemu))
-                    .with_child(self.create_guest_panel(GuestType::Lxc))
+                    .with_child(
+                        create_guest_panel(Some(GuestType::Qemu), self.status.clone())
+                            .flex(1.0)
+                            .width(300),
+                    )
+                    .with_child(
+                        create_guest_panel(Some(GuestType::Lxc), self.status.clone())
+                            .flex(1.0)
+                            .width(300),
+                    )
                     .with_child(self.create_node_panel(
                         "building-o",
                         tr!("Backup Server Nodes"),
@@ -682,3 +663,13 @@ fn loading_column() -> Column {
         .class(AlignItems::Center)
         .with_child(html! {<i class={"pwt-loading-icon"} />})
 }
+
+/// Create a consistent title component for the given title and icon
+pub fn create_title_with_icon(icon: &str, title: String) -> Html {
+    Row::new()
+        .class(AlignItems::Center)
+        .gap(2)
+        .with_child(Fa::new(icon))
+        .with_child(title)
+        .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] 22+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v3 02/21] ui: dashboard: refactor creating the node panel into its own module
  2025-10-31 12:43 [pdm-devel] [PATCH datacenter-manager v3 00/21] prepare ui for customizable views Dominik Csapak
  2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 01/21] ui: dashboard: refactor guest panel creation to its own module Dominik Csapak
@ 2025-10-31 12:43 ` Dominik Csapak
  2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 03/21] ui: dashboard: node panel: make remote type optional Dominik Csapak
                   ` (18 subsequent siblings)
  20 siblings, 0 replies; 22+ messages in thread
From: Dominik Csapak @ 2025-10-31 12:43 UTC (permalink / raw)
  To: pdm-devel
so we can easily reuse it outside the Dashboard struct.
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/dashboard/mod.rs               | 51 ++++++--------------------
 ui/src/dashboard/node_status_panel.rs | 52 +++++++++++++++++++++------
 2 files changed, 52 insertions(+), 51 deletions(-)
diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs
index 1fe149c3..3b5b3b50 100644
--- a/ui/src/dashboard/mod.rs
+++ b/ui/src/dashboard/mod.rs
@@ -44,7 +44,7 @@ mod guest_panel;
 pub use guest_panel::create_guest_panel;
 
 mod node_status_panel;
-use node_status_panel::NodeStatusPanel;
+use node_status_panel::create_node_panel;
 
 mod sdn_zone_panel;
 use sdn_zone_panel::SdnZonePanel;
@@ -149,35 +149,6 @@ pub struct PdmDashboard {
 }
 
 impl PdmDashboard {
-    fn create_node_panel(&self, icon: &str, title: String, remote_type: RemoteType) -> Panel {
-        let (nodes_status, failed_remotes) = match &self.status {
-            Some(status) => {
-                let nodes_status = match remote_type {
-                    RemoteType::Pve => Some(status.pve_nodes.clone()),
-                    RemoteType::Pbs => Some(status.pbs_nodes.clone()),
-                };
-                let failed_remotes = status
-                    .failed_remotes_list
-                    .iter()
-                    .filter(|item| item.remote_type == remote_type)
-                    .count();
-                (nodes_status, failed_remotes)
-            }
-            None => (None, 0),
-        };
-
-        Panel::new()
-            .flex(1.0)
-            .width(300)
-            .title(create_title_with_icon(icon, title))
-            .border(true)
-            .with_child(NodeStatusPanel::new(
-                remote_type,
-                nodes_status,
-                failed_remotes,
-            ))
-    }
-
     fn create_sdn_panel(&self) -> Panel {
         let sdn_zones_status = self.status.as_ref().map(|status| status.sdn_zones.clone());
 
@@ -498,11 +469,11 @@ impl Component for PdmDashboard {
                             )
                             .with_child(RemotePanel::new(self.status.clone())),
                     )
-                    .with_child(self.create_node_panel(
-                        "building",
-                        tr!("Virtual Environment Nodes"),
-                        RemoteType::Pve,
-                    ))
+                    .with_child(
+                        create_node_panel(RemoteType::Pve, self.status.clone())
+                            .flex(1.0)
+                            .width(300),
+                    )
                     .with_child(
                         create_guest_panel(Some(GuestType::Qemu), self.status.clone())
                             .flex(1.0)
@@ -513,11 +484,11 @@ impl Component for PdmDashboard {
                             .flex(1.0)
                             .width(300),
                     )
-                    .with_child(self.create_node_panel(
-                        "building-o",
-                        tr!("Backup Server Nodes"),
-                        RemoteType::Pbs,
-                    ))
+                    .with_child(
+                        create_node_panel(RemoteType::Pbs, self.status.clone())
+                            .flex(1.0)
+                            .width(300),
+                    )
                     .with_child(self.create_pbs_datastores_panel())
                     .with_child(SubscriptionInfo::new()),
             )
diff --git a/ui/src/dashboard/node_status_panel.rs b/ui/src/dashboard/node_status_panel.rs
index e9e8c6b2..c66d82e6 100644
--- a/ui/src/dashboard/node_status_panel.rs
+++ b/ui/src/dashboard/node_status_panel.rs
@@ -1,19 +1,17 @@
 use std::rc::Rc;
 
-use pdm_api_types::remotes::RemoteType;
-use pdm_api_types::resource::NodeStatusCount;
+use yew::virtual_dom::{VComp, VNode};
+
 use pdm_search::{Search, SearchTerm};
 use proxmox_yew_comp::Status;
-use pwt::{
-    css::{AlignItems, FlexFit, JustifyContent},
-    prelude::*,
-    widget::{Column, Fa},
-};
-use yew::{
-    virtual_dom::{VComp, VNode},
-    Properties,
-};
+use pwt::css::{AlignItems, FlexFit, JustifyContent};
+use pwt::prelude::*;
+use pwt::widget::{Column, Fa, Panel};
+
+use pdm_api_types::resource::NodeStatusCount;
+use pdm_api_types::{remotes::RemoteType, resource::ResourcesStatus};
 
+use crate::dashboard::create_title_with_icon;
 use crate::search_provider::get_search_provider;
 
 use super::loading_column;
@@ -142,3 +140,35 @@ fn map_status(
 
     (icon, status_msg, search_terms)
 }
+
+pub fn create_node_panel(remote_type: RemoteType, status: Option<ResourcesStatus>) -> Panel {
+    let (icon, title) = match remote_type {
+        RemoteType::Pve => ("building", tr!("Virtual Environment Nodes")),
+        RemoteType::Pbs => ("building-o", tr!("Backup Server Nodes")),
+    };
+
+    let (nodes_status, failed_remotes) = match status {
+        Some(status) => {
+            let nodes_status = match remote_type {
+                RemoteType::Pve => Some(status.pve_nodes.clone()),
+                RemoteType::Pbs => Some(status.pbs_nodes.clone()),
+            };
+            let failed_remotes = status
+                .failed_remotes_list
+                .iter()
+                .filter(|item| item.remote_type == remote_type)
+                .count();
+            (nodes_status, failed_remotes)
+        }
+        None => (None, 0),
+    };
+
+    Panel::new()
+        .title(create_title_with_icon(icon, title))
+        .border(true)
+        .with_child(NodeStatusPanel::new(
+            remote_type,
+            nodes_status,
+            failed_remotes,
+        ))
+}
-- 
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] 22+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v3 03/21] ui: dashboard: node panel: make remote type optional
  2025-10-31 12:43 [pdm-devel] [PATCH datacenter-manager v3 00/21] prepare ui for customizable views Dominik Csapak
  2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 01/21] ui: dashboard: refactor guest panel creation to its own module Dominik Csapak
  2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 02/21] ui: dashboard: refactor creating the node panel into " Dominik Csapak
@ 2025-10-31 12:43 ` Dominik Csapak
  2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 04/21] ui: dashboard: refactor remote panel creation into its own module Dominik Csapak
                   ` (17 subsequent siblings)
  20 siblings, 0 replies; 22+ messages in thread
From: Dominik Csapak @ 2025-10-31 12:43 UTC (permalink / raw)
  To: pdm-devel
and if it's `None` show a summary of all nodes, regardless of remote type.
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/dashboard/mod.rs               |  4 +--
 ui/src/dashboard/node_status_panel.rs | 49 +++++++++++++++++++--------
 2 files changed, 36 insertions(+), 17 deletions(-)
diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs
index 3b5b3b50..c9b1c014 100644
--- a/ui/src/dashboard/mod.rs
+++ b/ui/src/dashboard/mod.rs
@@ -470,7 +470,7 @@ impl Component for PdmDashboard {
                             .with_child(RemotePanel::new(self.status.clone())),
                     )
                     .with_child(
-                        create_node_panel(RemoteType::Pve, self.status.clone())
+                        create_node_panel(Some(RemoteType::Pve), self.status.clone())
                             .flex(1.0)
                             .width(300),
                     )
@@ -485,7 +485,7 @@ impl Component for PdmDashboard {
                             .width(300),
                     )
                     .with_child(
-                        create_node_panel(RemoteType::Pbs, self.status.clone())
+                        create_node_panel(Some(RemoteType::Pbs), self.status.clone())
                             .flex(1.0)
                             .width(300),
                     )
diff --git a/ui/src/dashboard/node_status_panel.rs b/ui/src/dashboard/node_status_panel.rs
index c66d82e6..0873635d 100644
--- a/ui/src/dashboard/node_status_panel.rs
+++ b/ui/src/dashboard/node_status_panel.rs
@@ -18,14 +18,17 @@ use super::loading_column;
 
 #[derive(PartialEq, Clone, Properties)]
 pub struct NodeStatusPanel {
-    remote_type: RemoteType,
+    remote_type: Option<RemoteType>,
     status: Option<NodeStatusCount>,
     failed_remotes: usize,
 }
 
 impl NodeStatusPanel {
+    /// Create a node status panel.
+    ///
+    /// Passing `None` to `remote_type` means creating a panel for all nodes, regardless of remote type.
     pub fn new(
-        remote_type: RemoteType,
+        remote_type: Option<RemoteType>,
         status: Option<NodeStatusCount>,
         failed_remotes: usize,
     ) -> Self {
@@ -95,13 +98,14 @@ impl yew::Component for NodeStatusPanelComponent {
 
 fn map_status(
     status: &NodeStatusCount,
-    remote_type: RemoteType,
+    remote_type: Option<RemoteType>,
     failed_remotes: usize,
 ) -> (Fa, String, Vec<SearchTerm>) {
-    let mut search_terms = vec![
-        SearchTerm::new("node").category(Some("type")),
-        SearchTerm::new(remote_type.to_string()).category(Some("remote-type")),
-    ];
+    let mut search_terms = vec![SearchTerm::new("node").category(Some("type"))];
+
+    if let Some(remote_type) = remote_type {
+        search_terms.push(SearchTerm::new(remote_type.to_string()).category(Some("remote-type")));
+    }
     let (icon, status_msg) = match status {
         NodeStatusCount {
             online,
@@ -126,11 +130,11 @@ fn map_status(
             )
         }
         NodeStatusCount { online, .. } if failed_remotes > 0 => match remote_type {
-            RemoteType::Pve => (
+            Some(RemoteType::Pve) | None => (
                 Status::Unknown.into(),
                 tr!("{0} of an unknown number of nodes online", online),
             ),
-            RemoteType::Pbs => (
+            Some(RemoteType::Pbs) => (
                 Status::Error.into(),
                 tr!("{0} remotes failed", failed_remotes),
             ),
@@ -141,22 +145,37 @@ fn map_status(
     (icon, status_msg, search_terms)
 }
 
-pub fn create_node_panel(remote_type: RemoteType, status: Option<ResourcesStatus>) -> Panel {
+/// Create a node status panel.
+///
+/// Passing `None` to `remote_type` means creating a panel for all nodes, regardless of remote type.
+pub fn create_node_panel(
+    remote_type: Option<RemoteType>,
+    status: Option<ResourcesStatus>,
+) -> Panel {
     let (icon, title) = match remote_type {
-        RemoteType::Pve => ("building", tr!("Virtual Environment Nodes")),
-        RemoteType::Pbs => ("building-o", tr!("Backup Server Nodes")),
+        Some(RemoteType::Pve) => ("building", tr!("Virtual Environment Nodes")),
+        Some(RemoteType::Pbs) => ("building-o", tr!("Backup Server Nodes")),
+        None => ("building", tr!("Nodes")),
     };
 
     let (nodes_status, failed_remotes) = match status {
         Some(status) => {
             let nodes_status = match remote_type {
-                RemoteType::Pve => Some(status.pve_nodes.clone()),
-                RemoteType::Pbs => Some(status.pbs_nodes.clone()),
+                Some(RemoteType::Pve) => Some(status.pve_nodes.clone()),
+                Some(RemoteType::Pbs) => Some(status.pbs_nodes.clone()),
+                None => Some(NodeStatusCount {
+                    online: status.pve_nodes.online + status.pbs_nodes.online,
+                    offline: status.pve_nodes.offline + status.pbs_nodes.offline,
+                    unknown: status.pve_nodes.unknown + status.pbs_nodes.unknown,
+                }),
             };
             let failed_remotes = status
                 .failed_remotes_list
                 .iter()
-                .filter(|item| item.remote_type == remote_type)
+                .filter(|item| match remote_type {
+                    Some(remote_type) => item.remote_type == remote_type,
+                    None => true,
+                })
                 .count();
             (nodes_status, failed_remotes)
         }
-- 
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] 22+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v3 04/21] ui: dashboard: refactor remote panel creation into its own module
  2025-10-31 12:43 [pdm-devel] [PATCH datacenter-manager v3 00/21] prepare ui for customizable views Dominik Csapak
                   ` (2 preceding siblings ...)
  2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 03/21] ui: dashboard: node panel: make remote type optional Dominik Csapak
@ 2025-10-31 12:43 ` Dominik Csapak
  2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 05/21] ui: dashboard: remote panel: make wizard menu optional Dominik Csapak
                   ` (16 subsequent siblings)
  20 siblings, 0 replies; 22+ messages in thread
From: Dominik Csapak @ 2025-10-31 12:43 UTC (permalink / raw)
  To: pdm-devel
so we can more easily reuse them outside the dashboard struct.
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/dashboard/mod.rs          | 39 ++++++++-------------------
 ui/src/dashboard/remote_panel.rs | 46 ++++++++++++++++++++++++--------
 2 files changed, 46 insertions(+), 39 deletions(-)
diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs
index c9b1c014..efd3e68c 100644
--- a/ui/src/dashboard/mod.rs
+++ b/ui/src/dashboard/mod.rs
@@ -19,7 +19,6 @@ use pwt::{
     widget::{
         error_message,
         form::{DisplayField, FormContext, Number},
-        menu::{Menu, MenuButton, MenuItem},
         Column, Container, Fa, InputPanel, Panel, Row,
     },
     AsyncPool,
@@ -38,7 +37,7 @@ mod subscription_info;
 pub use subscription_info::SubscriptionInfo;
 
 mod remote_panel;
-use remote_panel::RemotePanel;
+pub use remote_panel::create_remote_panel;
 
 mod guest_panel;
 pub use guest_panel::create_guest_panel;
@@ -442,32 +441,16 @@ impl Component for PdmDashboard {
                     .class(FlexWrap::Wrap)
                     .padding_top(0)
                     .with_child(
-                        Panel::new()
-                            .title(create_title_with_icon("server", tr!("Remotes")))
-                            .flex(1.0)
-                            //.border(true)
-                            .width(300)
-                            .min_height(175)
-                            .with_tool(
-                                MenuButton::new(tr!("Add")).show_arrow(true).menu(
-                                    Menu::new()
-                                        .with_item(
-                                            MenuItem::new("Proxmox VE")
-                                                .icon_class("fa fa-building")
-                                                .on_select(ctx.link().callback(|_| {
-                                                    Msg::CreateWizard(Some(RemoteType::Pve))
-                                                })),
-                                        )
-                                        .with_item(
-                                            MenuItem::new("Proxmox Backup Server")
-                                                .icon_class("fa fa-floppy-o")
-                                                .on_select(ctx.link().callback(|_| {
-                                                    Msg::CreateWizard(Some(RemoteType::Pbs))
-                                                })),
-                                        ),
-                                ),
-                            )
-                            .with_child(RemotePanel::new(self.status.clone())),
+                        create_remote_panel(
+                            self.status.clone(),
+                            ctx.link()
+                                .callback(|_| Msg::CreateWizard(Some(RemoteType::Pve))),
+                            ctx.link()
+                                .callback(|_| Msg::CreateWizard(Some(RemoteType::Pbs))),
+                        )
+                        .flex(1.0)
+                        .width(300)
+                        .min_height(175),
                     )
                     .with_child(
                         create_node_panel(Some(RemoteType::Pve), self.status.clone())
diff --git a/ui/src/dashboard/remote_panel.rs b/ui/src/dashboard/remote_panel.rs
index 75f772fb..27eebac2 100644
--- a/ui/src/dashboard/remote_panel.rs
+++ b/ui/src/dashboard/remote_panel.rs
@@ -1,21 +1,19 @@
 use std::rc::Rc;
 
+use yew::html::IntoEventCallback;
+use yew::virtual_dom::{VComp, VNode};
+
 use pdm_search::{Search, SearchTerm};
 use proxmox_yew_comp::Status;
-use pwt::{
-    css,
-    prelude::*,
-    props::{ContainerBuilder, WidgetBuilder},
-    widget::{Column, Container, Fa},
-};
-use yew::{
-    virtual_dom::{VComp, VNode},
-    Component, Properties,
-};
+use pwt::css;
+use pwt::prelude::*;
+use pwt::props::{ContainerBuilder, WidgetBuilder};
+use pwt::widget::menu::{Menu, MenuButton, MenuEvent, MenuItem};
+use pwt::widget::{Column, Container, Fa, Panel};
 
 use pdm_api_types::resource::ResourcesStatus;
 
-use crate::search_provider::get_search_provider;
+use crate::{dashboard::create_title_with_icon, search_provider::get_search_provider};
 
 #[derive(Properties, PartialEq)]
 /// A panel for showing the overall remotes status
@@ -116,3 +114,29 @@ fn create_search_term(failure: bool) -> Search {
         Search::with_terms(vec![SearchTerm::new("remote").category(Some("type"))])
     }
 }
+
+pub fn create_remote_panel(
+    status: Option<ResourcesStatus>,
+    on_pve_wizard: impl IntoEventCallback<MenuEvent>,
+    on_pbs_wizard: impl IntoEventCallback<MenuEvent>,
+) -> Panel {
+    Panel::new()
+        .title(create_title_with_icon("server", tr!("Remotes")))
+        .border(true)
+        .with_tool(
+            MenuButton::new(tr!("Add")).show_arrow(true).menu(
+                Menu::new()
+                    .with_item(
+                        MenuItem::new("Proxmox VE")
+                            .icon_class("fa fa-building")
+                            .on_select(on_pve_wizard),
+                    )
+                    .with_item(
+                        MenuItem::new("Proxmox Backup Server")
+                            .icon_class("fa fa-floppy-o")
+                            .on_select(on_pbs_wizard),
+                    ),
+            ),
+        )
+        .with_child(RemotePanel::new(status))
+}
-- 
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] 22+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v3 05/21] ui: dashboard: remote panel: make wizard menu optional
  2025-10-31 12:43 [pdm-devel] [PATCH datacenter-manager v3 00/21] prepare ui for customizable views Dominik Csapak
                   ` (3 preceding siblings ...)
  2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 04/21] ui: dashboard: refactor remote panel creation into its own module Dominik Csapak
@ 2025-10-31 12:43 ` Dominik Csapak
  2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 06/21] ui: dashboard: refactor sdn panel creation into its own module Dominik Csapak
                   ` (15 subsequent siblings)
  20 siblings, 0 replies; 22+ messages in thread
From: Dominik Csapak @ 2025-10-31 12:43 UTC (permalink / raw)
  To: pdm-devel
if the pve/pbs wizard callbacks are None, don't show the menu
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/dashboard/mod.rs          | 12 ++++++---
 ui/src/dashboard/remote_panel.rs | 43 ++++++++++++++++++--------------
 2 files changed, 32 insertions(+), 23 deletions(-)
diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs
index efd3e68c..8e124cdd 100644
--- a/ui/src/dashboard/mod.rs
+++ b/ui/src/dashboard/mod.rs
@@ -443,10 +443,14 @@ impl Component for PdmDashboard {
                     .with_child(
                         create_remote_panel(
                             self.status.clone(),
-                            ctx.link()
-                                .callback(|_| Msg::CreateWizard(Some(RemoteType::Pve))),
-                            ctx.link()
-                                .callback(|_| Msg::CreateWizard(Some(RemoteType::Pbs))),
+                            Some(
+                                ctx.link()
+                                    .callback(|_| Msg::CreateWizard(Some(RemoteType::Pve))),
+                            ),
+                            Some(
+                                ctx.link()
+                                    .callback(|_| Msg::CreateWizard(Some(RemoteType::Pbs))),
+                            ),
                         )
                         .flex(1.0)
                         .width(300)
diff --git a/ui/src/dashboard/remote_panel.rs b/ui/src/dashboard/remote_panel.rs
index 27eebac2..747f9b8d 100644
--- a/ui/src/dashboard/remote_panel.rs
+++ b/ui/src/dashboard/remote_panel.rs
@@ -117,26 +117,31 @@ fn create_search_term(failure: bool) -> Search {
 
 pub fn create_remote_panel(
     status: Option<ResourcesStatus>,
-    on_pve_wizard: impl IntoEventCallback<MenuEvent>,
-    on_pbs_wizard: impl IntoEventCallback<MenuEvent>,
+    on_pve_wizard: Option<impl IntoEventCallback<MenuEvent>>,
+    on_pbs_wizard: Option<impl IntoEventCallback<MenuEvent>>,
 ) -> Panel {
-    Panel::new()
+    let mut panel = Panel::new()
         .title(create_title_with_icon("server", tr!("Remotes")))
         .border(true)
-        .with_tool(
-            MenuButton::new(tr!("Add")).show_arrow(true).menu(
-                Menu::new()
-                    .with_item(
-                        MenuItem::new("Proxmox VE")
-                            .icon_class("fa fa-building")
-                            .on_select(on_pve_wizard),
-                    )
-                    .with_item(
-                        MenuItem::new("Proxmox Backup Server")
-                            .icon_class("fa fa-floppy-o")
-                            .on_select(on_pbs_wizard),
-                    ),
-            ),
-        )
-        .with_child(RemotePanel::new(status))
+        .with_child(RemotePanel::new(status));
+
+    if on_pve_wizard.is_some() || on_pbs_wizard.is_some() {
+        let mut menu = Menu::new();
+        if let Some(on_pve_wizard) = on_pve_wizard {
+            menu.add_item(
+                MenuItem::new("Proxmox VE")
+                    .icon_class("fa fa-building")
+                    .on_select(on_pve_wizard),
+            );
+        }
+        if let Some(on_pbs_wizard) = on_pbs_wizard {
+            menu.add_item(
+                MenuItem::new("Proxmox Backup Server")
+                    .icon_class("fa fa-floppy-o")
+                    .on_select(on_pbs_wizard),
+            );
+        }
+        panel.add_tool(MenuButton::new(tr!("Add")).show_arrow(true).menu(menu));
+    }
+    panel
 }
-- 
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] 22+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v3 06/21] ui: dashboard: refactor sdn panel creation into its own module
  2025-10-31 12:43 [pdm-devel] [PATCH datacenter-manager v3 00/21] prepare ui for customizable views Dominik Csapak
                   ` (4 preceding siblings ...)
  2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 05/21] ui: dashboard: remote panel: make wizard menu optional Dominik Csapak
@ 2025-10-31 12:43 ` Dominik Csapak
  2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 07/21] ui: dashboard: refactor task summary panel creation to " Dominik Csapak
                   ` (14 subsequent siblings)
  20 siblings, 0 replies; 22+ messages in thread
From: Dominik Csapak @ 2025-10-31 12:43 UTC (permalink / raw)
  To: pdm-devel
so we can more easily reuse that outside the dashboard struct.
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/dashboard/mod.rs            | 17 ++---------------
 ui/src/dashboard/sdn_zone_panel.rs | 15 ++++++++++++---
 2 files changed, 14 insertions(+), 18 deletions(-)
diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs
index 8e124cdd..9ec3f092 100644
--- a/ui/src/dashboard/mod.rs
+++ b/ui/src/dashboard/mod.rs
@@ -46,7 +46,7 @@ mod node_status_panel;
 use node_status_panel::create_node_panel;
 
 mod sdn_zone_panel;
-use sdn_zone_panel::SdnZonePanel;
+use sdn_zone_panel::create_sdn_panel;
 
 mod status_row;
 use status_row::DashboardStatusRow;
@@ -148,19 +148,6 @@ pub struct PdmDashboard {
 }
 
 impl PdmDashboard {
-    fn create_sdn_panel(&self) -> Panel {
-        let sdn_zones_status = self.status.as_ref().map(|status| status.sdn_zones.clone());
-
-        Panel::new()
-            .flex(1.0)
-            .width(200)
-            .title(create_title_with_icon("sdn", tr!("SDN Zones")))
-            .border(true)
-            .with_child(SdnZonePanel::new(
-                (!self.loading).then_some(sdn_zones_status).flatten(),
-            ))
-    }
-
     fn create_task_summary_panel(
         &self,
         statistics: &StatisticsOptions,
@@ -519,7 +506,7 @@ impl Component for PdmDashboard {
                     .class(FlexWrap::Wrap)
                     .with_child(self.create_task_summary_panel(&self.statistics, None))
                     .with_child(self.create_task_summary_panel(&self.statistics, Some(5)))
-                    .with_child(self.create_sdn_panel()),
+                    .with_child(create_sdn_panel(self.status.clone()).flex(1.0).width(200)),
             );
 
         Panel::new()
diff --git a/ui/src/dashboard/sdn_zone_panel.rs b/ui/src/dashboard/sdn_zone_panel.rs
index 0e26fa9c..611aadd1 100644
--- a/ui/src/dashboard/sdn_zone_panel.rs
+++ b/ui/src/dashboard/sdn_zone_panel.rs
@@ -1,18 +1,18 @@
 use std::rc::Rc;
 
-use pdm_api_types::resource::{ResourceType, SdnStatus, SdnZoneCount};
+use pdm_api_types::resource::{ResourceType, ResourcesStatus, SdnStatus, SdnZoneCount};
 use pdm_search::{Search, SearchTerm};
 use pwt::{
     css::{self, FontColor, TextAlign},
     prelude::*,
-    widget::{Container, Fa, List, ListTile},
+    widget::{Container, Fa, List, ListTile, Panel},
 };
 use yew::{
     virtual_dom::{VComp, VNode},
     Properties,
 };
 
-use crate::search_provider::get_search_provider;
+use crate::{dashboard::create_title_with_icon, search_provider::get_search_provider};
 
 use super::loading_column;
 
@@ -155,3 +155,12 @@ fn create_sdn_zone_search_term(status: Option<SdnStatus>) -> Search {
 
     Search::with_terms(terms)
 }
+
+pub fn create_sdn_panel(status: Option<ResourcesStatus>) -> Panel {
+    let sdn_zones_status = status.map(|status| status.sdn_zones);
+
+    Panel::new()
+        .title(create_title_with_icon("sdn", tr!("SDN Zones")))
+        .border(true)
+        .with_child(SdnZonePanel::new(sdn_zones_status))
+}
-- 
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] 22+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v3 07/21] ui: dashboard: refactor task summary panel creation to its own module
  2025-10-31 12:43 [pdm-devel] [PATCH datacenter-manager v3 00/21] prepare ui for customizable views Dominik Csapak
                   ` (5 preceding siblings ...)
  2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 06/21] ui: dashboard: refactor sdn panel creation into its own module Dominik Csapak
@ 2025-10-31 12:43 ` Dominik Csapak
  2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 08/21] ui: dashboard: task summary: disable virtual scrolling Dominik Csapak
                   ` (13 subsequent siblings)
  20 siblings, 0 replies; 22+ messages in thread
From: Dominik Csapak @ 2025-10-31 12:43 UTC (permalink / raw)
  To: pdm-devel
so we can easily reuse it outside the dashboard struct.
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/dashboard/mod.rs   | 65 +++++++++++++++------------------------
 ui/src/dashboard/tasks.rs | 30 ++++++++++++++++++
 2 files changed, 54 insertions(+), 41 deletions(-)
diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs
index 9ec3f092..e6ad2f0b 100644
--- a/ui/src/dashboard/mod.rs
+++ b/ui/src/dashboard/mod.rs
@@ -57,7 +57,7 @@ mod pbs_datastores_panel;
 use pbs_datastores_panel::PbsDatastoresPanel;
 
 mod tasks;
-use tasks::TaskSummary;
+use tasks::create_task_summary_panel;
 
 /// The initial 'max-age' parameter in seconds. The backend polls every 15 minutes, so to increase
 /// the chance of showing some data quickly use that as max age at the very first load.
@@ -148,44 +148,6 @@ pub struct PdmDashboard {
 }
 
 impl PdmDashboard {
-    fn create_task_summary_panel(
-        &self,
-        statistics: &StatisticsOptions,
-        remotes: Option<u32>,
-    ) -> Panel {
-        let (hours, since) = Self::get_task_options(&self.config);
-        let title = match remotes {
-            Some(_count) => tr!("Task Summary Sorted by Failed Tasks (Last {0}h)", hours),
-            None => tr!("Task Summary by Category (Last {0}h)", hours),
-        };
-        Panel::new()
-            .flex(1.0)
-            .width(500)
-            .border(true)
-            .title(create_title_with_icon("list", title))
-            .with_child(
-                Container::new()
-                    .class(FlexFit)
-                    .padding(2)
-                    .with_optional_child(
-                        statistics
-                            .data
-                            .clone()
-                            .map(|data| TaskSummary::new(data, since, remotes)),
-                    )
-                    .with_optional_child(
-                        (statistics.error.is_none() && statistics.data.is_none())
-                            .then_some(loading_column()),
-                    )
-                    .with_optional_child(
-                        statistics
-                            .error
-                            .as_ref()
-                            .map(|err| error_message(&err.to_string())),
-                    ),
-            )
-    }
-
     fn create_top_entities_panel(
         &self,
         icon: &str,
@@ -403,6 +365,7 @@ impl Component for PdmDashboard {
     }
 
     fn view(&self, ctx: &yew::Context<Self>) -> yew::Html {
+        let (hours, since) = Self::get_task_options(&self.config);
         let content = Column::new()
             .class(FlexFit)
             .with_child(
@@ -504,8 +467,28 @@ impl Component for PdmDashboard {
                     .style("padding-top", "0")
                     .class(pwt::css::Flex::Fill)
                     .class(FlexWrap::Wrap)
-                    .with_child(self.create_task_summary_panel(&self.statistics, None))
-                    .with_child(self.create_task_summary_panel(&self.statistics, Some(5)))
+                    .with_child(
+                        create_task_summary_panel(
+                            self.statistics.data.clone(),
+                            self.statistics.error.as_ref(),
+                            None,
+                            hours,
+                            since,
+                        )
+                        .flex(1.0)
+                        .width(500),
+                    )
+                    .with_child(
+                        create_task_summary_panel(
+                            self.statistics.data.clone(),
+                            self.statistics.error.as_ref(),
+                            Some(5),
+                            hours,
+                            since,
+                        )
+                        .flex(1.0)
+                        .width(500),
+                    )
                     .with_child(create_sdn_panel(self.status.clone()).flex(1.0).width(200)),
             );
 
diff --git a/ui/src/dashboard/tasks.rs b/ui/src/dashboard/tasks.rs
index d32cf378..cdb0d9c6 100644
--- a/ui/src/dashboard/tasks.rs
+++ b/ui/src/dashboard/tasks.rs
@@ -2,6 +2,7 @@ use std::collections::BTreeMap;
 use std::collections::HashMap;
 use std::rc::Rc;
 
+use anyhow::Error;
 use yew::html::Scope;
 use yew::virtual_dom::Key;
 
@@ -11,11 +12,15 @@ use pwt::prelude::*;
 use pwt::props::ExtractPrimaryKey;
 use pwt::state::Store;
 use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader};
+use pwt::widget::error_message;
+use pwt::widget::Panel;
 use pwt::widget::{ActionIcon, Container, Tooltip};
 use pwt_macros::{builder, widget};
 
 use pdm_api_types::TaskStatistics;
 
+use crate::dashboard::create_title_with_icon;
+use crate::dashboard::loading_column;
 use crate::tasks::TaskWorkerType;
 
 use super::filtered_tasks::FilteredTasks;
@@ -295,3 +300,28 @@ impl Component for ProxmoxTaskSummary {
             .into()
     }
 }
+
+pub fn create_task_summary_panel(
+    statistics: Option<TaskStatistics>,
+    error: Option<&Error>,
+    remotes: Option<u32>,
+    hours: u32,
+    since: i64,
+) -> Panel {
+    let title = match remotes {
+        Some(_count) => tr!("Task Summary Sorted by Failed Tasks (Last {0}h)", hours),
+        None => tr!("Task Summary by Category (Last {0}h)", hours),
+    };
+    let loading = error.is_none() && statistics.is_none();
+    Panel::new()
+        .border(true)
+        .title(create_title_with_icon("list", title))
+        .with_child(
+            Container::new()
+                .class(css::FlexFit)
+                .padding(2)
+                .with_optional_child(statistics.map(|data| TaskSummary::new(data, since, remotes)))
+                .with_optional_child((loading).then_some(loading_column()))
+                .with_optional_child(error.map(|err| error_message(&err.to_string()))),
+        )
+}
-- 
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] 22+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v3 08/21] ui: dashboard: task summary: disable virtual scrolling
  2025-10-31 12:43 [pdm-devel] [PATCH datacenter-manager v3 00/21] prepare ui for customizable views Dominik Csapak
                   ` (6 preceding siblings ...)
  2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 07/21] ui: dashboard: refactor task summary panel creation to " Dominik Csapak
@ 2025-10-31 12:43 ` Dominik Csapak
  2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 09/21] ui: dashboard: refactor subscription panel creation to its own module Dominik Csapak
                   ` (12 subsequent siblings)
  20 siblings, 0 replies; 22+ messages in thread
From: Dominik Csapak @ 2025-10-31 12:43 UTC (permalink / raw)
  To: pdm-devel
this, together with hiding the headers, changes the layout mechanism
internal to the datatable, which can be necessary in the dashboards flex
layout.
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/dashboard/tasks.rs | 3 +++
 1 file changed, 3 insertions(+)
diff --git a/ui/src/dashboard/tasks.rs b/ui/src/dashboard/tasks.rs
index cdb0d9c6..da447ecd 100644
--- a/ui/src/dashboard/tasks.rs
+++ b/ui/src/dashboard/tasks.rs
@@ -294,6 +294,9 @@ impl Component for ProxmoxTaskSummary {
                     .striped(false)
                     .borderless(true)
                     .hover(true)
+                    // these change the layout logic, which is necessary for the dashboards flex
+                    // layout
+                    .virtual_scroll(false)
                     .show_header(false),
             )
             .with_optional_child(tasks)
-- 
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] 22+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v3 09/21] ui: dashboard: refactor subscription panel creation to its own module
  2025-10-31 12:43 [pdm-devel] [PATCH datacenter-manager v3 00/21] prepare ui for customizable views Dominik Csapak
                   ` (7 preceding siblings ...)
  2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 08/21] ui: dashboard: task summary: disable virtual scrolling Dominik Csapak
@ 2025-10-31 12:43 ` Dominik Csapak
  2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 10/21] ui: dashboard: refactor top entities " Dominik Csapak
                   ` (11 subsequent siblings)
  20 siblings, 0 replies; 22+ messages in thread
From: Dominik Csapak @ 2025-10-31 12:43 UTC (permalink / raw)
  To: pdm-devel
so we can more easily reuse it outside the dashboard struct.
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/dashboard/mod.rs               |  9 ++++-
 ui/src/dashboard/subscription_info.rs | 54 +++++++++++++--------------
 2 files changed, 34 insertions(+), 29 deletions(-)
diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs
index e6ad2f0b..174700bf 100644
--- a/ui/src/dashboard/mod.rs
+++ b/ui/src/dashboard/mod.rs
@@ -34,7 +34,7 @@ mod top_entities;
 pub use top_entities::TopEntities;
 
 mod subscription_info;
-pub use subscription_info::SubscriptionInfo;
+pub use subscription_info::create_subscription_panel;
 
 mod remote_panel;
 pub use remote_panel::create_remote_panel;
@@ -427,7 +427,12 @@ impl Component for PdmDashboard {
                             .width(300),
                     )
                     .with_child(self.create_pbs_datastores_panel())
-                    .with_child(SubscriptionInfo::new()),
+                    .with_child(
+                        create_subscription_panel()
+                            .flex(1.0)
+                            .width(500)
+                            .min_height(150),
+                    ),
             )
             .with_child(
                 Container::new()
diff --git a/ui/src/dashboard/subscription_info.rs b/ui/src/dashboard/subscription_info.rs
index 08658d10..2a4d94b7 100644
--- a/ui/src/dashboard/subscription_info.rs
+++ b/ui/src/dashboard/subscription_info.rs
@@ -11,9 +11,7 @@ use proxmox_yew_comp::{http_get, Status};
 use pwt::{
     css::{AlignItems, FlexFit, JustifyContent, TextAlign},
     prelude::tr,
-    props::{
-        ContainerBuilder, CssBorderBuilder, CssPaddingBuilder, WidgetBuilder, WidgetStyleBuilder,
-    },
+    props::{ContainerBuilder, CssBorderBuilder, CssPaddingBuilder, WidgetBuilder},
     widget::{Column, Container, Fa, Panel, Row},
     AsyncPool,
 };
@@ -133,31 +131,19 @@ impl Component for PdmSubscriptionInfo {
     }
 
     fn view(&self, _ctx: &yew::Context<Self>) -> yew::Html {
-        let title: Html = Row::new()
+        Column::new()
+            .class(FlexFit)
+            .class(JustifyContent::Center)
             .class(AlignItems::Center)
-            .gap(2)
-            .with_child(Fa::new("ticket"))
-            .with_child(tr!("Subscription Status"))
-            .into();
-
-        Panel::new()
-            .flex(1.0)
-            .width(500)
-            .min_height(150)
-            .title(title)
-            .border(true)
-            .with_child(
-                Column::new()
-                    .class(FlexFit)
-                    .class(JustifyContent::Center)
-                    .class(AlignItems::Center)
-                    .with_optional_child(
-                        self.loading
-                            .then_some(html! {<i class={"pwt-loading-icon"} />}),
-                    )
-                    .with_optional_child(
-                        (!self.loading).then_some(render_subscription_status(&self.status)),
-                    ),
+            .with_optional_child(
+                self.loading.then_some(
+                    Container::new()
+                        .padding(4)
+                        .with_child(Container::from_tag("i").class("pwt-loading-icon")),
+                ),
+            )
+            .with_optional_child(
+                (!self.loading).then_some(render_subscription_status(&self.status)),
             )
             .into()
     }
@@ -169,3 +155,17 @@ impl From<SubscriptionInfo> for VNode {
         VNode::from(comp)
     }
 }
+
+pub fn create_subscription_panel() -> Panel {
+    let title: Html = Row::new()
+        .class(AlignItems::Center)
+        .gap(2)
+        .with_child(Fa::new("ticket"))
+        .with_child(tr!("Subscription Status"))
+        .into();
+
+    Panel::new()
+        .title(title)
+        .border(true)
+        .with_child(SubscriptionInfo::new())
+}
-- 
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] 22+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v3 10/21] ui: dashboard: refactor top entities panel creation to its own module
  2025-10-31 12:43 [pdm-devel] [PATCH datacenter-manager v3 00/21] prepare ui for customizable views Dominik Csapak
                   ` (8 preceding siblings ...)
  2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 09/21] ui: dashboard: refactor subscription panel creation to its own module Dominik Csapak
@ 2025-10-31 12:43 ` Dominik Csapak
  2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 11/21] ui: dashboard: refactor DashboardConfig editing/constants to their module Dominik Csapak
                   ` (10 subsequent siblings)
  20 siblings, 0 replies; 22+ messages in thread
From: Dominik Csapak @ 2025-10-31 12:43 UTC (permalink / raw)
  To: pdm-devel
so we can more easily reuse it outside the dashboard struct. Introduce
the 'LeaderboardType' enum to differentiate between the various types.
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/dashboard/mod.rs          | 83 +++++++++++++-------------------
 ui/src/dashboard/top_entities.rs | 45 +++++++++++++++--
 ui/src/dashboard/types.rs        |  9 ++++
 3 files changed, 82 insertions(+), 55 deletions(-)
 create mode 100644 ui/src/dashboard/types.rs
diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs
index 174700bf..e4c7a0cd 100644
--- a/ui/src/dashboard/mod.rs
+++ b/ui/src/dashboard/mod.rs
@@ -17,7 +17,6 @@ use pwt::{
     props::StorageLocation,
     state::PersistentState,
     widget::{
-        error_message,
         form::{DisplayField, FormContext, Number},
         Column, Container, Fa, InputPanel, Panel, Row,
     },
@@ -25,13 +24,12 @@ use pwt::{
 };
 
 use pdm_api_types::{remotes::RemoteType, resource::ResourcesStatus, TaskStatistics};
-use pdm_client::types::TopEntity;
 use proxmox_client::ApiResponseData;
 
 use crate::{pve::GuestType, remotes::AddWizard, RemoteList};
 
 mod top_entities;
-pub use top_entities::TopEntities;
+pub use top_entities::create_top_entities_panel;
 
 mod subscription_info;
 pub use subscription_info::create_subscription_panel;
@@ -59,6 +57,8 @@ use pbs_datastores_panel::PbsDatastoresPanel;
 mod tasks;
 use tasks::create_task_summary_panel;
 
+pub mod types;
+
 /// The initial 'max-age' parameter in seconds. The backend polls every 15 minutes, so to increase
 /// the chance of showing some data quickly use that as max age at the very first load.
 pub const INITIAL_MAX_AGE_S: u64 = 900;
@@ -148,32 +148,6 @@ pub struct PdmDashboard {
 }
 
 impl PdmDashboard {
-    fn create_top_entities_panel(
-        &self,
-        icon: &str,
-        title: String,
-        metrics_title: String,
-        entities: Option<&Vec<TopEntity>>,
-        threshold: f64,
-    ) -> Panel {
-        Panel::new()
-            .flex(1.0)
-            .width(500)
-            .min_width(400)
-            .border(true)
-            .title(create_title_with_icon(icon, title))
-            .with_optional_child(
-                entities
-                    .map(|entities| TopEntities::new(entities.clone(), metrics_title, threshold)),
-            )
-            .with_optional_child(self.top_entities.is_none().then_some(loading_column()))
-            .with_optional_child(
-                self.last_top_entities_error
-                    .as_ref()
-                    .map(|err| error_message(&err.to_string())),
-            )
-    }
-
     fn create_pbs_datastores_panel(&self) -> Panel {
         let pbs_datastores = self
             .status
@@ -442,27 +416,36 @@ impl Component for PdmDashboard {
                     .padding_top(0)
                     .class(FlexWrap::Wrap)
                     //.min_height(175)
-                    .with_child(self.create_top_entities_panel(
-                        "desktop",
-                        tr!("Guests With the Highest CPU Usage"),
-                        tr!("CPU usage"),
-                        self.top_entities.as_ref().map(|e| &e.guest_cpu),
-                        0.85,
-                    ))
-                    .with_child(self.create_top_entities_panel(
-                        "building",
-                        tr!("Nodes With the Highest CPU Usage"),
-                        tr!("CPU usage"),
-                        self.top_entities.as_ref().map(|e| &e.node_cpu),
-                        0.85,
-                    ))
-                    .with_child(self.create_top_entities_panel(
-                        "building",
-                        tr!("Nodes With the Highest Memory Usage"),
-                        tr!("Memory usage"),
-                        self.top_entities.as_ref().map(|e| &e.node_memory),
-                        0.95,
-                    )),
+                    .with_child(
+                        create_top_entities_panel(
+                            self.top_entities.as_ref().map(|e| e.guest_cpu.clone()),
+                            self.last_top_entities_error.as_ref(),
+                            types::LeaderboardType::GuestCpu,
+                        )
+                        .flex(1.0)
+                        .width(500)
+                        .min_width(400),
+                    )
+                    .with_child(
+                        create_top_entities_panel(
+                            self.top_entities.as_ref().map(|e| e.node_cpu.clone()),
+                            self.last_top_entities_error.as_ref(),
+                            types::LeaderboardType::NodeCpu,
+                        )
+                        .flex(1.0)
+                        .width(500)
+                        .min_width(400),
+                    )
+                    .with_child(
+                        create_top_entities_panel(
+                            self.top_entities.as_ref().map(|e| e.node_memory.clone()),
+                            self.last_top_entities_error.as_ref(),
+                            types::LeaderboardType::NodeCpu,
+                        )
+                        .flex(1.0)
+                        .width(500)
+                        .min_width(400),
+                    ),
             )
             .with_child(
                 Container::new()
diff --git a/ui/src/dashboard/top_entities.rs b/ui/src/dashboard/top_entities.rs
index c93ee252..dfe38692 100644
--- a/ui/src/dashboard/top_entities.rs
+++ b/ui/src/dashboard/top_entities.rs
@@ -1,12 +1,10 @@
 use std::rc::Rc;
 
 use web_sys::HtmlElement;
-use yew::{
-    virtual_dom::{VComp, VNode},
-    Component, NodeRef, PointerEvent, Properties, TargetCast,
-};
+use yew::virtual_dom::{VComp, VNode};
 
 use proxmox_yew_comp::utils::render_epoch;
+use pwt::prelude::*;
 use pwt::{
     css::{AlignItems, Display, FlexFit, JustifyContent},
     dom::align::{align_to, AlignOptions},
@@ -15,12 +13,13 @@ use pwt::{
         WidgetStyleBuilder,
     },
     tr,
-    widget::{ActionIcon, Column, Container, Row},
+    widget::{error_message, ActionIcon, Column, Container, Panel, Row},
 };
 
 use pdm_client::types::{Resource, TopEntity};
 
 use crate::{
+    dashboard::{create_title_with_icon, loading_column, types::LeaderboardType},
     get_deep_url, get_resource_node, navigate_to,
     renderer::{render_resource_icon, render_resource_name},
 };
@@ -326,3 +325,39 @@ fn graph_from_data(data: &Vec<Option<f64>>, threshold: f64) -> Container {
             ),
         )
 }
+
+pub fn create_top_entities_panel(
+    entities: Option<Vec<TopEntity>>,
+    error: Option<&proxmox_client::Error>,
+    leaderboard_type: LeaderboardType,
+) -> Panel {
+    let (icon, title, metrics_title, threshold) = match leaderboard_type {
+        LeaderboardType::GuestCpu => (
+            "desktop",
+            tr!("Guests With the Highest CPU Usage"),
+            tr!("CPU usage"),
+            0.85,
+        ),
+        LeaderboardType::NodeCpu => (
+            "building",
+            tr!("Nodes With the Highest CPU Usage"),
+            tr!("CPU usage"),
+            0.85,
+        ),
+        LeaderboardType::NodeMemory => (
+            "building",
+            tr!("Nodes With the Highest Memory Usage"),
+            tr!("Memory usage"),
+            0.95,
+        ),
+    };
+    let loading = entities.is_none() && error.is_none();
+    Panel::new()
+        .border(true)
+        .title(create_title_with_icon(icon, title))
+        .with_optional_child(
+            entities.map(|entities| TopEntities::new(entities, metrics_title, threshold)),
+        )
+        .with_optional_child(loading.then_some(loading_column()))
+        .with_optional_child(error.map(|err| error_message(&err.to_string())))
+}
diff --git a/ui/src/dashboard/types.rs b/ui/src/dashboard/types.rs
new file mode 100644
index 00000000..152d4f57
--- /dev/null
+++ b/ui/src/dashboard/types.rs
@@ -0,0 +1,9 @@
+use serde::{Deserialize, Serialize};
+
+#[derive(Serialize, Deserialize, PartialEq, Clone, Copy)]
+#[serde(rename_all = "kebab-case")]
+pub enum LeaderboardType {
+    GuestCpu,
+    NodeCpu,
+    NodeMemory,
+}
-- 
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] 22+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v3 11/21] ui: dashboard: refactor DashboardConfig editing/constants to their module
  2025-10-31 12:43 [pdm-devel] [PATCH datacenter-manager v3 00/21] prepare ui for customizable views Dominik Csapak
                   ` (9 preceding siblings ...)
  2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 10/21] ui: dashboard: refactor top entities " Dominik Csapak
@ 2025-10-31 12:43 ` Dominik Csapak
  2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 12/21] ui: dashboard: factor out task parameter calculation Dominik Csapak
                   ` (9 subsequent siblings)
  20 siblings, 0 replies; 22+ messages in thread
From: Dominik Csapak @ 2025-10-31 12:43 UTC (permalink / raw)
  To: pdm-devel
makes it easier to use outside the dashboard struct. Since it's not
really a 'dashboard configuration' but rather 'refresh configuration',
rename it as such.
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/dashboard/mod.rs                 | 129 +++++-------------------
 ui/src/dashboard/refresh_config_edit.rs | 107 ++++++++++++++++++++
 2 files changed, 130 insertions(+), 106 deletions(-)
 create mode 100644 ui/src/dashboard/refresh_config_edit.rs
diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs
index e4c7a0cd..e38da7ee 100644
--- a/ui/src/dashboard/mod.rs
+++ b/ui/src/dashboard/mod.rs
@@ -1,30 +1,25 @@
-use std::{collections::HashMap, rc::Rc};
+use std::rc::Rc;
 
 use anyhow::Error;
 use futures::join;
 use js_sys::Date;
-use serde::{Deserialize, Serialize};
 use serde_json::json;
 use yew::{
     virtual_dom::{VComp, VNode},
     Component,
 };
 
-use proxmox_yew_comp::{http_get, EditWindow};
+use proxmox_yew_comp::http_get;
 use pwt::{
     css::{AlignItems, FlexDirection, FlexFit, FlexWrap, JustifyContent},
     prelude::*,
     props::StorageLocation,
     state::PersistentState,
-    widget::{
-        form::{DisplayField, FormContext, Number},
-        Column, Container, Fa, InputPanel, Panel, Row,
-    },
+    widget::{form::FormContext, Column, Container, Fa, Panel, Row},
     AsyncPool,
 };
 
 use pdm_api_types::{remotes::RemoteType, resource::ResourcesStatus, TaskStatistics};
-use proxmox_client::ApiResponseData;
 
 use crate::{pve::GuestType, remotes::AddWizard, RemoteList};
 
@@ -59,28 +54,15 @@ use tasks::create_task_summary_panel;
 
 pub mod types;
 
-/// The initial 'max-age' parameter in seconds. The backend polls every 15 minutes, so to increase
-/// the chance of showing some data quickly use that as max age at the very first load.
-pub const INITIAL_MAX_AGE_S: u64 = 900;
 
-/// The 'max-age' parameter in seconds for when user forces a reload. Do not use 0 as the data will
-/// never be realtime anyway, with 5s we get very current data while avoiding that one or more
-/// "fidgety" users put unbounded load onto the remotes.
-pub const FORCE_RELOAD_MAX_AGE_S: u64 = 3;
-
-/// The default 'max-age' parameter in seconds. The backend polls every 15 minutes, but if a user
-/// has the dashboard active for a longer time it's beneficial to refresh a bit more often, forcing
-/// new data twice a minute is a good compromise.
-pub const DEFAULT_MAX_AGE_S: u64 = 30;
-
-/// The default refresh interval, we poll more frequently than the default max-age to quicker show
-/// any new data that was gathered either by the backend polling tasks or by a manual update
-/// triggered by another user.
-pub const DEFAULT_REFRESH_INTERVAL_S: u32 = 10;
-
-/// The default hours to show for task summaries. Use 2 days to ensure that all tasks from yesterday
-/// are included independent from the time a user checks the dashboard on the current day.
-pub const DEFAULT_TASK_SUMMARY_HOURS: u32 = 48;
+mod refresh_config_edit;
+pub use refresh_config_edit::{
+    create_refresh_config_edit_window, refresh_config_id, RefreshConfig,
+};
+use refresh_config_edit::{
+    DEFAULT_MAX_AGE_S, DEFAULT_REFRESH_INTERVAL_S, DEFAULT_TASK_SUMMARY_HOURS,
+    FORCE_RELOAD_MAX_AGE_S, INITIAL_MAX_AGE_S,
+};
 
 #[derive(Properties, PartialEq)]
 pub struct Dashboard {}
@@ -97,17 +79,6 @@ impl Default for Dashboard {
     }
 }
 
-#[derive(Serialize, Deserialize, Default, Debug)]
-#[serde(rename_all = "kebab-case")]
-pub struct DashboardConfig {
-    #[serde(skip_serializing_if = "Option::is_none")]
-    refresh_interval: Option<u32>,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    max_age: Option<u64>,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    task_last_hours: Option<u32>,
-}
-
 pub enum LoadingResult {
     Resources(Result<ResourcesStatus, Error>),
     TopEntities(Result<pdm_client::types::TopEntities, proxmox_client::Error>),
@@ -121,7 +92,7 @@ pub enum Msg {
     CreateWizard(Option<RemoteType>),
     Reload,
     ForceReload,
-    UpdateConfig(DashboardConfig),
+    UpdateConfig(RefreshConfig),
     ConfigWindow(bool),
 }
 
@@ -144,7 +115,7 @@ pub struct PdmDashboard {
     show_config_window: bool,
     _context_listener: ContextHandle<RemoteList>,
     async_pool: AsyncPool,
-    config: PersistentState<DashboardConfig>,
+    config: PersistentState<RefreshConfig>,
 }
 
 impl PdmDashboard {
@@ -217,7 +188,7 @@ impl PdmDashboard {
         });
     }
 
-    fn get_task_options(config: &PersistentState<DashboardConfig>) -> (u32, i64) {
+    fn get_task_options(config: &PersistentState<RefreshConfig>) -> (u32, i64) {
         let hours = config.task_last_hours.unwrap_or(DEFAULT_TASK_SUMMARY_HOURS);
         let since = (Date::now() / 1000.0) as i64 - (hours * 60 * 60) as i64;
         (hours, since)
@@ -229,8 +200,8 @@ impl Component for PdmDashboard {
     type Properties = Dashboard;
 
     fn create(ctx: &yew::Context<Self>) -> Self {
-        let config: PersistentState<DashboardConfig> =
-            PersistentState::new(StorageLocation::local("dashboard-config"));
+        let config: PersistentState<RefreshConfig> =
+            PersistentState::new(StorageLocation::local(refresh_config_id("dashboard")));
         let async_pool = AsyncPool::new();
 
         let (remote_list, _context_listener) = ctx
@@ -483,75 +454,21 @@ impl Component for PdmDashboard {
         Panel::new()
             .class(FlexFit)
             .with_child(content)
-            .with_optional_child(
-                self.show_wizard.map(|remote_type| {
-                    AddWizard::new(remote_type)
-                        .on_close(ctx.link().callback(|_| Msg::CreateWizard(None)))
-                        .on_submit(move |ctx| {
-                            crate::remotes::create_remote(ctx, remote_type)
-                        })
-                }),
-            )
+            .with_optional_child(self.show_wizard.map(|remote_type| {
+                AddWizard::new(remote_type)
+                    .on_close(ctx.link().callback(|_| Msg::CreateWizard(None)))
+                    .on_submit(move |ctx| crate::remotes::create_remote(ctx, remote_type))
+            }))
             .with_optional_child(
                 self.show_config_window.then_some(
-                    EditWindow::new(tr!("Dashboard Configuration"))
-                        .submit_text(tr!("Save"))
-                        .loader({
-                            || {
-                                let data: PersistentState<DashboardConfig> = PersistentState::new(
-                                    StorageLocation::local("dashboard-config"),
-                                );
-
-                                async move {
-                                    let data = serde_json::to_value(data.into_inner())?;
-                                    Ok(ApiResponseData {
-                                        attribs: HashMap::new(),
-                                        data,
-                                    })
-                                }
-                            }
-                        })
-                        .renderer(|_ctx: &FormContext| {
-                            InputPanel::new()
-                                .width(600)
-                                .padding(2)
-                                .with_field(
-                                    tr!("Refresh Interval (seconds)"),
-                                    Number::new()
-                                        .name("refresh-interval")
-                                        .min(5u64)
-                                        .step(5)
-                                        .placeholder(DEFAULT_REFRESH_INTERVAL_S.to_string()),
-                                )
-                                .with_field(
-                                    tr!("Max Age (seconds)"),
-                                    Number::new()
-                                        .name("max-age")
-                                        .min(0u64)
-                                        .step(5)
-                                        .placeholder(DEFAULT_MAX_AGE_S.to_string()),
-                                )
-                                .with_field(
-                                    "",
-                                    DisplayField::new()
-                                        .key("max-age-explanation")
-                                        .value(tr!("If a response from a remote is older than 'Max Age', it will be updated on the next refresh.")))
-                                .with_field(
-                                    tr!("Task Summary Time Range (last hours)"),
-                                    Number::new()
-                                        .name("task-last-hours")
-                                        .min(0u64)
-                                        .placeholder(DEFAULT_TASK_SUMMARY_HOURS.to_string()),
-                                )
-                                .into()
-                        })
+                    create_refresh_config_edit_window("dashboard")
                         .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: DashboardConfig =
+                                    let data: RefreshConfig =
                                         serde_json::from_value(ctx.get_submit_data())?;
                                     link.send_message(Msg::UpdateConfig(data));
                                     Ok(())
diff --git a/ui/src/dashboard/refresh_config_edit.rs b/ui/src/dashboard/refresh_config_edit.rs
new file mode 100644
index 00000000..673185d3
--- /dev/null
+++ b/ui/src/dashboard/refresh_config_edit.rs
@@ -0,0 +1,107 @@
+use std::collections::HashMap;
+
+use serde::{Deserialize, Serialize};
+
+use pwt::prelude::*;
+use pwt::props::StorageLocation;
+use pwt::state::PersistentState;
+use pwt::widget::form::{DisplayField, FormContext, Number};
+use pwt::widget::InputPanel;
+
+use proxmox_client::ApiResponseData;
+use proxmox_yew_comp::EditWindow;
+
+/// The initial 'max-age' parameter in seconds. The backend polls every 15 minutes, so to increase
+/// the chance of showing some data quickly use that as max age at the very first load.
+pub const INITIAL_MAX_AGE_S: u64 = 900;
+
+/// The 'max-age' parameter in seconds for when user forces a reload. Do not use 0 as the data will
+/// never be realtime anyway, with 5s we get very current data while avoiding that one or more
+/// "fidgety" users put unbounded load onto the remotes.
+pub const FORCE_RELOAD_MAX_AGE_S: u64 = 3;
+
+/// The default 'max-age' parameter in seconds. The backend polls every 15 minutes, but if a user
+/// has the dashboard active for a longer time it's beneficial to refresh a bit more often, forcing
+/// new data twice a minute is a good compromise.
+pub const DEFAULT_MAX_AGE_S: u64 = 30;
+
+/// The default refresh interval, we poll more frequently than the default max-age to quicker show
+/// any new data that was gathered either by the backend polling tasks or by a manual update
+/// triggered by another user.
+pub const DEFAULT_REFRESH_INTERVAL_S: u32 = 10;
+
+/// The default hours to show for task summaries. Use 2 days to ensure that all tasks from yesterday
+/// are included independent from the time a user checks the dashboard on the current day.
+pub const DEFAULT_TASK_SUMMARY_HOURS: u32 = 48;
+
+#[derive(Serialize, Deserialize, Default, Debug, Clone)]
+#[serde(rename_all = "kebab-case")]
+pub struct RefreshConfig {
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub refresh_interval: Option<u32>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub max_age: Option<u64>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub task_last_hours: Option<u32>,
+}
+
+/// Get a consistent id for use in a local storage
+pub fn refresh_config_id(id: &str) -> String {
+    format!("view-{id}-config")
+}
+
+pub fn create_refresh_config_edit_window(id: &str) -> EditWindow {
+    let id = refresh_config_id(id);
+    EditWindow::new(tr!("Refresh Configuration"))
+        .submit_text(tr!("Save"))
+        .loader({
+            move || {
+                let id = id.clone();
+                let data: PersistentState<RefreshConfig> = PersistentState::new(
+                    StorageLocation::local(id),
+                );
+
+                async move {
+                    let data = serde_json::to_value(data.into_inner())?;
+                    Ok(ApiResponseData {
+                        attribs: HashMap::new(),
+                        data,
+                    })
+                }
+            }
+        })
+    .renderer(|_ctx: &FormContext| {
+        InputPanel::new()
+            .width(600)
+            .padding(2)
+            .with_field(
+                tr!("Refresh Interval (seconds)"),
+                Number::new()
+                .name("refresh-interval")
+                .min(5u64)
+                .step(5)
+                .placeholder(DEFAULT_REFRESH_INTERVAL_S.to_string()),
+            )
+            .with_field(
+                tr!("Max Age (seconds)"),
+                Number::new()
+                .name("max-age")
+                .min(0u64)
+                .step(5)
+                .placeholder(DEFAULT_MAX_AGE_S.to_string()),
+            )
+            .with_field(
+                "",
+                DisplayField::new()
+                .key("max-age-explanation")
+                .value(tr!("If a response from a remote is older than 'Max Age', it will be updated on the next refresh.")))
+            .with_field(
+                tr!("Task Summary Time Range (last hours)"),
+                Number::new()
+                .name("task-last-hours")
+                .min(0u64)
+                .placeholder(DEFAULT_TASK_SUMMARY_HOURS.to_string()),
+            )
+            .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] 22+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v3 12/21] ui: dashboard: factor out task parameter calculation
  2025-10-31 12:43 [pdm-devel] [PATCH datacenter-manager v3 00/21] prepare ui for customizable views Dominik Csapak
                   ` (10 preceding siblings ...)
  2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 11/21] ui: dashboard: refactor DashboardConfig editing/constants to their module Dominik Csapak
@ 2025-10-31 12:43 ` Dominik Csapak
  2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 13/21] ui: dashboard: pbs datastores panel: refactor creation into own module Dominik Csapak
                   ` (8 subsequent siblings)
  20 siblings, 0 replies; 22+ messages in thread
From: Dominik Csapak @ 2025-10-31 12:43 UTC (permalink / raw)
  To: pdm-devel
into the dashboard::tasks module.
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/dashboard/mod.rs   | 19 ++++++-------------
 ui/src/dashboard/tasks.rs |  8 ++++++++
 2 files changed, 14 insertions(+), 13 deletions(-)
diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs
index e38da7ee..51341737 100644
--- a/ui/src/dashboard/mod.rs
+++ b/ui/src/dashboard/mod.rs
@@ -50,7 +50,7 @@ mod pbs_datastores_panel;
 use pbs_datastores_panel::PbsDatastoresPanel;
 
 mod tasks;
-use tasks::create_task_summary_panel;
+use tasks::{create_task_summary_panel, get_task_options};
 
 pub mod types;
 
@@ -60,8 +60,7 @@ pub use refresh_config_edit::{
     create_refresh_config_edit_window, refresh_config_id, RefreshConfig,
 };
 use refresh_config_edit::{
-    DEFAULT_MAX_AGE_S, DEFAULT_REFRESH_INTERVAL_S, DEFAULT_TASK_SUMMARY_HOURS,
-    FORCE_RELOAD_MAX_AGE_S, INITIAL_MAX_AGE_S,
+    DEFAULT_MAX_AGE_S, DEFAULT_REFRESH_INTERVAL_S, FORCE_RELOAD_MAX_AGE_S, INITIAL_MAX_AGE_S,
 };
 
 #[derive(Properties, PartialEq)]
@@ -147,7 +146,7 @@ impl PdmDashboard {
 
     fn do_reload(&mut self, ctx: &yew::Context<Self>, max_age: u64) {
         let link = ctx.link().clone();
-        let (_, since) = Self::get_task_options(&self.config);
+        let (_, since) = get_task_options(self.config.task_last_hours);
 
         self.load_finished_time = None;
         self.async_pool.spawn(async move {
@@ -187,12 +186,6 @@ impl PdmDashboard {
             link.send_message(Msg::LoadingFinished(LoadingResult::All));
         });
     }
-
-    fn get_task_options(config: &PersistentState<RefreshConfig>) -> (u32, i64) {
-        let hours = config.task_last_hours.unwrap_or(DEFAULT_TASK_SUMMARY_HOURS);
-        let since = (Date::now() / 1000.0) as i64 - (hours * 60 * 60) as i64;
-        (hours, since)
-    }
 }
 
 impl Component for PdmDashboard {
@@ -295,9 +288,9 @@ impl Component for PdmDashboard {
                 true
             }
             Msg::UpdateConfig(dashboard_config) => {
-                let (old_hours, _) = Self::get_task_options(&self.config);
+                let (old_hours, _) = get_task_options(self.config.task_last_hours);
                 self.config.update(dashboard_config);
-                let (new_hours, _) = Self::get_task_options(&self.config);
+                let (new_hours, _) = get_task_options(self.config.task_last_hours);
 
                 if old_hours != new_hours {
                     self.reload(ctx);
@@ -310,7 +303,7 @@ impl Component for PdmDashboard {
     }
 
     fn view(&self, ctx: &yew::Context<Self>) -> yew::Html {
-        let (hours, since) = Self::get_task_options(&self.config);
+        let (hours, since) = get_task_options(self.config.task_last_hours);
         let content = Column::new()
             .class(FlexFit)
             .with_child(
diff --git a/ui/src/dashboard/tasks.rs b/ui/src/dashboard/tasks.rs
index da447ecd..9989d4a9 100644
--- a/ui/src/dashboard/tasks.rs
+++ b/ui/src/dashboard/tasks.rs
@@ -3,6 +3,7 @@ use std::collections::HashMap;
 use std::rc::Rc;
 
 use anyhow::Error;
+use js_sys::Date;
 use yew::html::Scope;
 use yew::virtual_dom::Key;
 
@@ -21,6 +22,7 @@ use pdm_api_types::TaskStatistics;
 
 use crate::dashboard::create_title_with_icon;
 use crate::dashboard::loading_column;
+use crate::dashboard::refresh_config_edit::DEFAULT_TASK_SUMMARY_HOURS;
 use crate::tasks::TaskWorkerType;
 
 use super::filtered_tasks::FilteredTasks;
@@ -328,3 +330,9 @@ pub fn create_task_summary_panel(
                 .with_optional_child(error.map(|err| error_message(&err.to_string()))),
         )
 }
+
+pub fn get_task_options(last_hours: Option<u32>) -> (u32, i64) {
+    let hours = last_hours.unwrap_or(DEFAULT_TASK_SUMMARY_HOURS);
+    let since = (Date::now() / 1000.0) as i64 - (hours * 60 * 60) as i64;
+    (hours, since)
+}
-- 
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] 22+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v3 13/21] ui: dashboard: pbs datastores panel: refactor creation into own module
  2025-10-31 12:43 [pdm-devel] [PATCH datacenter-manager v3 00/21] prepare ui for customizable views Dominik Csapak
                   ` (11 preceding siblings ...)
  2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 12/21] ui: dashboard: factor out task parameter calculation Dominik Csapak
@ 2025-10-31 12:43 ` Dominik Csapak
  2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 14/21] ui: dashboard: remove unused remote list Dominik Csapak
                   ` (7 subsequent siblings)
  20 siblings, 0 replies; 22+ messages in thread
From: Dominik Csapak @ 2025-10-31 12:43 UTC (permalink / raw)
  To: pdm-devel
so we can use it more easily outside the `Dashboard` struct.
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/dashboard/mod.rs                  | 26 +++++----------------
 ui/src/dashboard/pbs_datastores_panel.rs | 29 ++++++++++++++++--------
 2 files changed, 25 insertions(+), 30 deletions(-)
diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs
index 51341737..ee40405b 100644
--- a/ui/src/dashboard/mod.rs
+++ b/ui/src/dashboard/mod.rs
@@ -47,14 +47,13 @@ use status_row::DashboardStatusRow;
 mod filtered_tasks;
 
 mod pbs_datastores_panel;
-use pbs_datastores_panel::PbsDatastoresPanel;
+pub use pbs_datastores_panel::create_pbs_datastores_panel;
 
 mod tasks;
 use tasks::{create_task_summary_panel, get_task_options};
 
 pub mod types;
 
-
 mod refresh_config_edit;
 pub use refresh_config_edit::{
     create_refresh_config_edit_window, refresh_config_id, RefreshConfig,
@@ -118,23 +117,6 @@ pub struct PdmDashboard {
 }
 
 impl PdmDashboard {
-    fn create_pbs_datastores_panel(&self) -> Panel {
-        let pbs_datastores = self
-            .status
-            .as_ref()
-            .map(|status| status.pbs_datastores.clone());
-
-        Panel::new()
-            .flex(1.0)
-            .width(300)
-            .title(create_title_with_icon(
-                "database",
-                tr!("Backup Server Datastores"),
-            ))
-            .border(true)
-            .with_child(PbsDatastoresPanel::new(pbs_datastores))
-    }
-
     fn reload(&mut self, ctx: &yew::Context<Self>) {
         let max_age = if self.loaded_once {
             self.config.max_age.unwrap_or(DEFAULT_MAX_AGE_S)
@@ -364,7 +346,11 @@ impl Component for PdmDashboard {
                             .flex(1.0)
                             .width(300),
                     )
-                    .with_child(self.create_pbs_datastores_panel())
+                    .with_child(
+                        create_pbs_datastores_panel(self.status.clone())
+                            .flex(1.0)
+                            .width(300),
+                    )
                     .with_child(
                         create_subscription_panel()
                             .flex(1.0)
diff --git a/ui/src/dashboard/pbs_datastores_panel.rs b/ui/src/dashboard/pbs_datastores_panel.rs
index a06acf7f..6542ac00 100644
--- a/ui/src/dashboard/pbs_datastores_panel.rs
+++ b/ui/src/dashboard/pbs_datastores_panel.rs
@@ -1,18 +1,15 @@
 use std::rc::Rc;
 
-use pdm_api_types::resource::{PbsDatastoreStatusCount, ResourceType};
+use yew::virtual_dom::{VComp, VNode};
+
+use pdm_api_types::resource::{PbsDatastoreStatusCount, ResourceType, ResourcesStatus};
 use pdm_search::{Search, SearchTerm};
 use proxmox_yew_comp::Status;
-use pwt::{
-    css::{self, TextAlign},
-    prelude::*,
-    widget::{Container, Fa, List, ListTile},
-};
-use yew::{
-    virtual_dom::{VComp, VNode},
-    Properties,
-};
+use pwt::css::{self, TextAlign};
+use pwt::prelude::*;
+use pwt::widget::{Container, Fa, List, ListTile, Panel};
 
+use crate::dashboard::create_title_with_icon;
 use crate::search_provider::get_search_provider;
 
 use super::loading_column;
@@ -157,3 +154,15 @@ fn create_pbs_datastores_status_search_term(search_term: Option<(&str, &str)>) -
     }
     Search::with_terms(terms)
 }
+
+pub fn create_pbs_datastores_panel(status: Option<ResourcesStatus>) -> Panel {
+    let pbs_datastores = status.map(|status| status.pbs_datastores.clone());
+
+    Panel::new()
+        .title(create_title_with_icon(
+            "database",
+            tr!("Backup Server Datastores"),
+        ))
+        .border(true)
+        .with_child(PbsDatastoresPanel::new(pbs_datastores))
+}
-- 
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] 22+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v3 14/21] ui: dashboard: remove unused remote list
  2025-10-31 12:43 [pdm-devel] [PATCH datacenter-manager v3 00/21] prepare ui for customizable views Dominik Csapak
                   ` (12 preceding siblings ...)
  2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 13/21] ui: dashboard: pbs datastores panel: refactor creation into own module Dominik Csapak
@ 2025-10-31 12:43 ` Dominik Csapak
  2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 15/21] ui: dashboard: status row: make loading less jarring Dominik Csapak
                   ` (6 subsequent siblings)
  20 siblings, 0 replies; 22+ messages in thread
From: Dominik Csapak @ 2025-10-31 12:43 UTC (permalink / raw)
  To: pdm-devel
this was introduced in commit
 9f2256c7 (ui: dashboard: make better use of the status api call)
but never actually used, so simply remove them and the corresponding
_context_listener.
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/dashboard/mod.rs | 17 +----------------
 1 file changed, 1 insertion(+), 16 deletions(-)
diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs
index ee40405b..91745ef9 100644
--- a/ui/src/dashboard/mod.rs
+++ b/ui/src/dashboard/mod.rs
@@ -21,7 +21,7 @@ use pwt::{
 
 use pdm_api_types::{remotes::RemoteType, resource::ResourcesStatus, TaskStatistics};
 
-use crate::{pve::GuestType, remotes::AddWizard, RemoteList};
+use crate::{pve::GuestType, remotes::AddWizard};
 
 mod top_entities;
 pub use top_entities::create_top_entities_panel;
@@ -86,7 +86,6 @@ pub enum LoadingResult {
 
 pub enum Msg {
     LoadingFinished(LoadingResult),
-    RemoteListChanged(RemoteList),
     CreateWizard(Option<RemoteType>),
     Reload,
     ForceReload,
@@ -108,10 +107,8 @@ pub struct PdmDashboard {
     loaded_once: bool,
     loading: bool,
     load_finished_time: Option<f64>,
-    remote_list: RemoteList,
     show_wizard: Option<RemoteType>,
     show_config_window: bool,
-    _context_listener: ContextHandle<RemoteList>,
     async_pool: AsyncPool,
     config: PersistentState<RefreshConfig>,
 }
@@ -179,11 +176,6 @@ impl Component for PdmDashboard {
             PersistentState::new(StorageLocation::local(refresh_config_id("dashboard")));
         let async_pool = AsyncPool::new();
 
-        let (remote_list, _context_listener) = ctx
-            .link()
-            .context(ctx.link().callback(Msg::RemoteListChanged))
-            .expect("No Remote list context provided");
-
         let mut this = Self {
             status: None,
             last_error: None,
@@ -196,10 +188,8 @@ impl Component for PdmDashboard {
             loaded_once: false,
             loading: true,
             load_finished_time: None,
-            remote_list,
             show_wizard: None,
             show_config_window: false,
-            _context_listener,
             async_pool,
             config,
         };
@@ -248,11 +238,6 @@ impl Component for PdmDashboard {
                 }
                 true
             }
-            Msg::RemoteListChanged(remote_list) => {
-                let changed = self.remote_list != remote_list;
-                self.remote_list = remote_list;
-                changed
-            }
             Msg::CreateWizard(remote_type) => {
                 self.show_wizard = remote_type;
                 true
-- 
2.47.3
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply	[flat|nested] 22+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v3 15/21] ui: dashboard: status row: make loading less jarring
  2025-10-31 12:43 [pdm-devel] [PATCH datacenter-manager v3 00/21] prepare ui for customizable views Dominik Csapak
                   ` (13 preceding siblings ...)
  2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 14/21] ui: dashboard: remove unused remote list Dominik Csapak
@ 2025-10-31 12:43 ` Dominik Csapak
  2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 16/21] ui: introduce `LoadResult` helper type Dominik Csapak
                   ` (5 subsequent siblings)
  20 siblings, 0 replies; 22+ messages in thread
From: Dominik Csapak @ 2025-10-31 12:43 UTC (permalink / raw)
  To: pdm-devel
by tracking the loading state not only via the last_refresh property,
but also from a reload message to a changing last_refresh value.
This means it now always shows the last refresh time if there was one
and only rotates the icon in that case.
This is much less jarring that a split second change of the text from
a date to 'now refreshing' back to a finished date.
While at it, remove the unused 'loading' property of the PdmDashboard
struct.
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/dashboard/mod.rs        | 11 ++---------
 ui/src/dashboard/status_row.rs | 11 +++++++++--
 2 files changed, 11 insertions(+), 11 deletions(-)
diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs
index 91745ef9..8857f82f 100644
--- a/ui/src/dashboard/mod.rs
+++ b/ui/src/dashboard/mod.rs
@@ -104,8 +104,6 @@ pub struct PdmDashboard {
     top_entities: Option<pdm_client::types::TopEntities>,
     last_top_entities_error: Option<proxmox_client::Error>,
     statistics: StatisticsOptions,
-    loaded_once: bool,
-    loading: bool,
     load_finished_time: Option<f64>,
     show_wizard: Option<RemoteType>,
     show_config_window: bool,
@@ -115,7 +113,7 @@ pub struct PdmDashboard {
 
 impl PdmDashboard {
     fn reload(&mut self, ctx: &yew::Context<Self>) {
-        let max_age = if self.loaded_once {
+        let max_age = if self.load_finished_time.is_some() {
             self.config.max_age.unwrap_or(DEFAULT_MAX_AGE_S)
         } else {
             INITIAL_MAX_AGE_S
@@ -127,7 +125,6 @@ impl PdmDashboard {
         let link = ctx.link().clone();
         let (_, since) = get_task_options(self.config.task_last_hours);
 
-        self.load_finished_time = None;
         self.async_pool.spawn(async move {
             let client = crate::pdm_client();
 
@@ -185,8 +182,6 @@ impl Component for PdmDashboard {
                 data: None,
                 error: None,
             },
-            loaded_once: false,
-            loading: true,
             load_finished_time: None,
             show_wizard: None,
             show_config_window: false,
@@ -226,9 +221,7 @@ impl Component for PdmDashboard {
                         Err(err) => self.statistics.error = Some(err),
                     },
                     LoadingResult::All => {
-                        self.loading = false;
-                        if !self.loaded_once {
-                            self.loaded_once = true;
+                        if self.load_finished_time.is_none() {
                             // immediately trigger a "normal" reload after the first load with the
                             // configured or default max-age to ensure users sees more current data.
                             ctx.link().send_message(Msg::Reload);
diff --git a/ui/src/dashboard/status_row.rs b/ui/src/dashboard/status_row.rs
index 5e377411..0855b123 100644
--- a/ui/src/dashboard/status_row.rs
+++ b/ui/src/dashboard/status_row.rs
@@ -45,6 +45,7 @@ pub enum Msg {
 #[doc(hidden)]
 pub struct PdmDashboardStatusRow {
     _interval: Interval,
+    loading: bool,
 }
 
 impl PdmDashboardStatusRow {
@@ -68,6 +69,7 @@ impl Component for PdmDashboardStatusRow {
     fn create(ctx: &yew::Context<Self>) -> Self {
         Self {
             _interval: Self::create_interval(ctx),
+            loading: false,
         }
     }
 
@@ -76,19 +78,24 @@ impl Component for PdmDashboardStatusRow {
         match msg {
             Msg::Reload(clicked) => {
                 props.on_reload.emit(clicked);
+                self.loading = true;
                 true
             }
         }
     }
 
-    fn changed(&mut self, ctx: &Context<Self>, _old_props: &Self::Properties) -> bool {
+    fn changed(&mut self, ctx: &Context<Self>, old_props: &Self::Properties) -> bool {
         self._interval = Self::create_interval(ctx);
+        let new_refresh = ctx.props().last_refresh;
+        if new_refresh.is_some() && old_props.last_refresh != new_refresh {
+            self.loading = false;
+        }
         true
     }
 
     fn view(&self, ctx: &yew::Context<Self>) -> yew::Html {
         let props = ctx.props();
-        let is_loading = props.last_refresh.is_none();
+        let is_loading = props.last_refresh.is_none() || self.loading;
         let on_settings_click = props.on_settings_click.clone();
         Row::new()
             .gap(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] 22+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v3 16/21] ui: introduce `LoadResult` helper type
  2025-10-31 12:43 [pdm-devel] [PATCH datacenter-manager v3 00/21] prepare ui for customizable views Dominik Csapak
                   ` (14 preceding siblings ...)
  2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 15/21] ui: dashboard: status row: make loading less jarring Dominik Csapak
@ 2025-10-31 12:43 ` Dominik Csapak
  2025-10-31 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 17/21] ui: dashboard: implement 'View' Dominik Csapak
                   ` (4 subsequent siblings)
  20 siblings, 0 replies; 22+ messages in thread
From: Dominik Csapak @ 2025-10-31 12:43 UTC (permalink / raw)
  To: pdm-devel
this factors out some common pattern when loading data, such as saving
the last valid data even when an error occurs, and a check if anything
has been set yet.
This saves a few lines when we use it vs duplicating that pattern
everywhere.
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/lib.rs               |  3 +++
 ui/src/load_result.rs       | 42 +++++++++++++++++++++++++++++++++++++
 ui/src/pbs/remote.rs        | 30 +++++++++-----------------
 ui/src/pve/lxc/overview.rs  | 28 +++++++------------------
 ui/src/pve/node/overview.rs | 29 +++++++++----------------
 ui/src/pve/qemu/overview.rs | 28 +++++++------------------
 ui/src/pve/storage.rs       | 29 +++++++------------------
 7 files changed, 89 insertions(+), 100 deletions(-)
 create mode 100644 ui/src/load_result.rs
diff --git a/ui/src/lib.rs b/ui/src/lib.rs
index a2b79b05..de76e1c0 100644
--- a/ui/src/lib.rs
+++ b/ui/src/lib.rs
@@ -39,6 +39,9 @@ pub mod sdn;
 
 pub mod renderer;
 
+mod load_result;
+pub use load_result::LoadResult;
+
 mod tasks;
 pub use tasks::register_pve_tasks;
 
diff --git a/ui/src/load_result.rs b/ui/src/load_result.rs
new file mode 100644
index 00000000..4f3e6d5a
--- /dev/null
+++ b/ui/src/load_result.rs
@@ -0,0 +1,42 @@
+/// Helper wrapper to factor out some common api loading behavior
+pub struct LoadResult<T, E> {
+    pub data: Option<T>,
+    pub error: Option<E>,
+}
+
+impl<T, E> LoadResult<T, E> {
+    /// Creates a new empty result that contains no data or error.
+    pub fn new() -> Self {
+        Self {
+            data: None,
+            error: None,
+        }
+    }
+
+    /// Update the current value with the given result
+    ///
+    /// On `Ok`, the previous error will be deleted.
+    /// On `Err`, the previous valid date is kept.
+    pub fn update(&mut self, result: Result<T, E>) {
+        match result {
+            Ok(data) => {
+                self.error = None;
+                self.data = Some(data);
+            }
+            Err(err) => {
+                self.error = Some(err);
+            }
+        }
+    }
+
+    /// If any of data or err has any value
+    pub fn has_data(&self) -> bool {
+        self.data.is_some() || self.error.is_some()
+    }
+}
+
+impl<T, E> Default for LoadResult<T, E> {
+    fn default() -> Self {
+        Self::new()
+    }
+}
diff --git a/ui/src/pbs/remote.rs b/ui/src/pbs/remote.rs
index 7cf7c7e2..67c9172f 100644
--- a/ui/src/pbs/remote.rs
+++ b/ui/src/pbs/remote.rs
@@ -17,7 +17,7 @@ use pwt::{
 use pbs_api_types::NodeStatus;
 use pdm_api_types::rrddata::PbsNodeDataPoint;
 
-use crate::renderer::separator;
+use crate::{renderer::separator, LoadResult};
 
 #[derive(Clone, Debug, Eq, PartialEq, Properties)]
 pub struct RemoteOverviewPanel {
@@ -59,12 +59,11 @@ pub struct RemoteOverviewPanelComp {
     load_data: Rc<Series>,
     mem_data: Rc<Series>,
     mem_total_data: Rc<Series>,
-    status: Option<NodeStatus>,
+    status: LoadResult<NodeStatus, proxmox_client::Error>,
 
     rrd_time_frame: RRDTimeframe,
 
     last_error: Option<proxmox_client::Error>,
-    last_status_error: Option<proxmox_client::Error>,
 
     async_pool: AsyncPool,
     _timeout: Option<gloo_timers::callback::Timeout>,
@@ -100,9 +99,8 @@ impl yew::Component for RemoteOverviewPanelComp {
             mem_data: Rc::new(Series::new("", Vec::new())),
             mem_total_data: Rc::new(Series::new("", Vec::new())),
             rrd_time_frame: RRDTimeframe::load(),
-            status: None,
+            status: LoadResult::new(),
             last_error: None,
-            last_status_error: None,
             async_pool: AsyncPool::new(),
             _timeout: None,
             _status_timeout: None,
@@ -160,15 +158,7 @@ impl yew::Component for RemoteOverviewPanelComp {
                 Err(err) => self.last_error = Some(err),
             },
             Msg::StatusLoadFinished(res) => {
-                match res {
-                    Ok(status) => {
-                        self.last_status_error = None;
-                        self.status = Some(status);
-                    }
-                    Err(err) => {
-                        self.last_status_error = Some(err);
-                    }
-                }
+                self.status.update(res);
                 let link = ctx.link().clone();
                 self._status_timeout = Some(gloo_timers::callback::Timeout::new(
                     ctx.props().status_interval,
@@ -188,7 +178,7 @@ impl yew::Component for RemoteOverviewPanelComp {
         let props = ctx.props();
 
         if props.remote != old_props.remote {
-            self.status = None;
+            self.status = LoadResult::new();
             self.last_error = None;
             self.time_data = Rc::new(Vec::new());
             self.cpu_data = Rc::new(Series::new("", Vec::new()));
@@ -205,9 +195,8 @@ impl yew::Component for RemoteOverviewPanelComp {
     }
 
     fn view(&self, ctx: &yew::Context<Self>) -> yew::Html {
-        let status_comp = node_info(self.status.as_ref().map(|s| s.into()));
+        let status_comp = node_info(self.status.data.as_ref().map(|s| s.into()));
 
-        let loading = self.status.is_none() && self.last_status_error.is_none();
         let title: Html = Row::new()
             .gap(2)
             .class(AlignItems::Baseline)
@@ -221,12 +210,13 @@ impl yew::Component for RemoteOverviewPanelComp {
             .with_child(
                 // FIXME: add some 'visible' or 'active' property to the progress
                 Progress::new()
-                    .value((!loading).then_some(0.0))
-                    .style("opacity", (!loading).then_some("0")),
+                    .value(self.status.has_data().then_some(0.0))
+                    .style("opacity", self.status.has_data().then_some("0")),
             )
             .with_child(status_comp)
             .with_optional_child(
-                self.last_status_error
+                self.status
+                    .error
                     .as_ref()
                     .map(|err| error_message(&err.to_string())),
             )
diff --git a/ui/src/pve/lxc/overview.rs b/ui/src/pve/lxc/overview.rs
index 41647275..5decbe6e 100644
--- a/ui/src/pve/lxc/overview.rs
+++ b/ui/src/pve/lxc/overview.rs
@@ -19,6 +19,7 @@ use pdm_api_types::{resource::PveLxcResource, rrddata::LxcDataPoint};
 use pdm_client::types::{IsRunning, LxcStatus};
 
 use crate::renderer::{separator, status_row};
+use crate::LoadResult;
 
 #[derive(Clone, Debug, Properties, PartialEq)]
 pub struct LxcOverviewPanel {
@@ -56,8 +57,7 @@ pub enum Msg {
 }
 
 pub struct LxcanelComp {
-    status: Option<LxcStatus>,
-    last_status_error: Option<proxmox_client::Error>,
+    status: LoadResult<LxcStatus, proxmox_client::Error>,
     last_rrd_error: Option<proxmox_client::Error>,
     _status_timeout: Option<Timeout>,
     _rrd_timeout: Option<Timeout>,
@@ -104,12 +104,11 @@ impl yew::Component for LxcanelComp {
         ctx.link()
             .send_message_batch(vec![Msg::ReloadStatus, Msg::ReloadRrd]);
         Self {
-            status: None,
+            status: LoadResult::new(),
             _status_timeout: None,
             _rrd_timeout: None,
             _async_pool: AsyncPool::new(),
             last_rrd_error: None,
-            last_status_error: None,
 
             rrd_time_frame: RRDTimeframe::load(),
 
@@ -144,15 +143,7 @@ impl yew::Component for LxcanelComp {
                 false
             }
             Msg::StatusResult(res) => {
-                match res {
-                    Ok(status) => {
-                        self.last_status_error = None;
-                        self.status = Some(status);
-                    }
-                    Err(err) => {
-                        self.last_status_error = Some(err);
-                    }
-                }
+                self.status.update(res);
 
                 self._status_timeout = Some(Timeout::new(props.status_interval, move || {
                     link.send_message(Msg::ReloadStatus)
@@ -211,8 +202,7 @@ impl yew::Component for LxcanelComp {
         let props = ctx.props();
 
         if props.remote != old_props.remote || props.info != old_props.info {
-            self.status = None;
-            self.last_status_error = None;
+            self.status = LoadResult::new();
             self.last_rrd_error = None;
 
             self.time = Rc::new(Vec::new());
@@ -236,7 +226,7 @@ impl yew::Component for LxcanelComp {
         let props = ctx.props();
 
         let mut status_comp = Column::new().gap(2).padding(4);
-        let status = match &self.status {
+        let status = match &self.status.data {
             Some(status) => status,
             None => &LxcStatus {
                 cpu: Some(props.info.cpu),
@@ -319,16 +309,14 @@ impl yew::Component for LxcanelComp {
             HumanByte::from(status.maxdisk.unwrap_or_default() as u64).to_string(),
         ));
 
-        let loading = self.status.is_none() && self.last_status_error.is_none();
-
         Panel::new()
             .class(FlexFit)
             .class(ColorScheme::Neutral)
             .with_child(
                 // FIXME: add some 'visible' or 'active' property to the progress
                 Progress::new()
-                    .value((!loading).then_some(0.0))
-                    .style("opacity", (!loading).then_some("0")),
+                    .value(self.status.has_data().then_some(0.0))
+                    .style("opacity", self.status.has_data().then_some("0")),
             )
             .with_child(status_comp)
             .with_child(separator().padding_x(4))
diff --git a/ui/src/pve/node/overview.rs b/ui/src/pve/node/overview.rs
index 1a98c004..c2f2958f 100644
--- a/ui/src/pve/node/overview.rs
+++ b/ui/src/pve/node/overview.rs
@@ -17,7 +17,7 @@ use pwt::{
 use pdm_api_types::rrddata::NodeDataPoint;
 use pdm_client::types::NodeStatus;
 
-use crate::renderer::separator;
+use crate::{renderer::separator, LoadResult};
 
 #[derive(Clone, Debug, Eq, PartialEq, Properties)]
 pub struct NodeOverviewPanel {
@@ -62,12 +62,11 @@ pub struct NodeOverviewPanelComp {
     load_data: Rc<Series>,
     mem_data: Rc<Series>,
     mem_total_data: Rc<Series>,
-    status: Option<NodeStatus>,
+    status: LoadResult<NodeStatus, proxmox_client::Error>,
 
     rrd_time_frame: RRDTimeframe,
 
     last_error: Option<proxmox_client::Error>,
-    last_status_error: Option<proxmox_client::Error>,
 
     async_pool: AsyncPool,
     _timeout: Option<gloo_timers::callback::Timeout>,
@@ -103,9 +102,8 @@ impl yew::Component for NodeOverviewPanelComp {
             mem_data: Rc::new(Series::new("", Vec::new())),
             mem_total_data: Rc::new(Series::new("", Vec::new())),
             rrd_time_frame: RRDTimeframe::load(),
-            status: None,
+            status: LoadResult::new(),
             last_error: None,
-            last_status_error: None,
             async_pool: AsyncPool::new(),
             _timeout: None,
             _status_timeout: None,
@@ -165,13 +163,7 @@ impl yew::Component for NodeOverviewPanelComp {
                 Err(err) => self.last_error = Some(err),
             },
             Msg::StatusLoadFinished(res) => {
-                match res {
-                    Ok(status) => {
-                        self.last_status_error = None;
-                        self.status = Some(status);
-                    }
-                    Err(err) => self.last_status_error = Some(err),
-                }
+                self.status.update(res);
                 let link = ctx.link().clone();
                 self._status_timeout = Some(gloo_timers::callback::Timeout::new(
                     ctx.props().status_interval,
@@ -191,8 +183,7 @@ impl yew::Component for NodeOverviewPanelComp {
         let props = ctx.props();
 
         if props.remote != old_props.remote || props.node != old_props.node {
-            self.status = None;
-            self.last_status_error = None;
+            self.status = LoadResult::new();
             self.last_error = None;
             self.time_data = Rc::new(Vec::new());
             self.cpu_data = Rc::new(Series::new("", Vec::new()));
@@ -209,20 +200,20 @@ impl yew::Component for NodeOverviewPanelComp {
     }
 
     fn view(&self, ctx: &yew::Context<Self>) -> yew::Html {
-        let status_comp = node_info(self.status.as_ref().map(|s| s.into()));
-        let loading = self.status.is_none() && self.last_status_error.is_none();
+        let status_comp = node_info(self.status.data.as_ref().map(|s| s.into()));
         Container::new()
             .class(FlexFit)
             .class(ColorScheme::Neutral)
             .with_child(
                 // FIXME: add some 'visible' or 'active' property to the progress
                 Progress::new()
-                    .value((!loading).then_some(0.0))
-                    .style("opacity", (!loading).then_some("0")),
+                    .value(self.status.has_data().then_some(0.0))
+                    .style("opacity", self.status.has_data().then_some("0")),
             )
             .with_child(status_comp)
             .with_optional_child(
-                self.last_status_error
+                self.status
+                    .error
                     .as_ref()
                     .map(|err| error_message(&err.to_string())),
             )
diff --git a/ui/src/pve/qemu/overview.rs b/ui/src/pve/qemu/overview.rs
index 3d715ebf..27b425b8 100644
--- a/ui/src/pve/qemu/overview.rs
+++ b/ui/src/pve/qemu/overview.rs
@@ -16,6 +16,7 @@ use pdm_api_types::{resource::PveQemuResource, rrddata::QemuDataPoint};
 use pdm_client::types::{IsRunning, QemuStatus};
 
 use crate::renderer::{separator, status_row};
+use crate::LoadResult;
 
 #[derive(Clone, Debug, Properties, PartialEq)]
 pub struct QemuOverviewPanel {
@@ -53,8 +54,7 @@ pub enum Msg {
 }
 
 pub struct QemuOverviewPanelComp {
-    status: Option<QemuStatus>,
-    last_status_error: Option<proxmox_client::Error>,
+    status: LoadResult<QemuStatus, proxmox_client::Error>,
     last_rrd_error: Option<proxmox_client::Error>,
     _status_timeout: Option<Timeout>,
     _rrd_timeout: Option<Timeout>,
@@ -101,12 +101,11 @@ impl yew::Component for QemuOverviewPanelComp {
         ctx.link()
             .send_message_batch(vec![Msg::ReloadStatus, Msg::ReloadRrd]);
         Self {
-            status: None,
+            status: LoadResult::new(),
             _status_timeout: None,
             _rrd_timeout: None,
             _async_pool: AsyncPool::new(),
             last_rrd_error: None,
-            last_status_error: None,
 
             rrd_time_frame: RRDTimeframe::load(),
 
@@ -141,16 +140,7 @@ impl yew::Component for QemuOverviewPanelComp {
                 false
             }
             Msg::StatusResult(res) => {
-                match res {
-                    Ok(status) => {
-                        self.last_status_error = None;
-                        self.status = Some(status);
-                    }
-                    Err(err) => {
-                        self.last_status_error = Some(err);
-                    }
-                }
-
+                self.status.update(res);
                 self._status_timeout = Some(Timeout::new(props.status_interval, move || {
                     link.send_message(Msg::ReloadStatus)
                 }));
@@ -210,8 +200,7 @@ impl yew::Component for QemuOverviewPanelComp {
         let props = ctx.props();
 
         if props.remote != old_props.remote || props.info != old_props.info {
-            self.status = None;
-            self.last_status_error = None;
+            self.status = LoadResult::new();
             self.last_rrd_error = None;
 
             self.time = Rc::new(Vec::new());
@@ -235,7 +224,7 @@ impl yew::Component for QemuOverviewPanelComp {
         let props = ctx.props();
         let mut status_comp = Column::new().gap(2).padding(4);
 
-        let status = match &self.status {
+        let status = match &self.status.data {
             Some(status) => status,
             None => &QemuStatus {
                 agent: None,
@@ -329,15 +318,14 @@ impl yew::Component for QemuOverviewPanelComp {
             HumanByte::from(status.maxdisk.unwrap_or_default() as u64).to_string(),
         ));
 
-        let loading = self.status.is_none() && self.last_status_error.is_none();
         Panel::new()
             .class(pwt::css::FlexFit)
             .class(pwt::css::ColorScheme::Neutral)
             .with_child(
                 // FIXME: add some 'visible' or 'active' property to the progress
                 Progress::new()
-                    .value((!loading).then_some(0.0))
-                    .style("opacity", (!loading).then_some("0")),
+                    .value(self.status.has_data().then_some(0.0))
+                    .style("opacity", self.status.has_data().then_some("0")),
             )
             .with_child(status_comp)
             .with_child(separator().padding_x(4))
diff --git a/ui/src/pve/storage.rs b/ui/src/pve/storage.rs
index 93a2fa29..9730d751 100644
--- a/ui/src/pve/storage.rs
+++ b/ui/src/pve/storage.rs
@@ -22,6 +22,7 @@ use pdm_client::types::PveStorageStatus;
 use crate::{
     pve::utils::{render_content_type, render_storage_type},
     renderer::{separator, status_row_right_icon},
+    LoadResult,
 };
 
 #[derive(Clone, Debug, Properties)]
@@ -74,8 +75,7 @@ pub enum Msg {
 }
 
 pub struct StoragePanelComp {
-    status: Option<PveStorageStatus>,
-    last_status_error: Option<proxmox_client::Error>,
+    status: LoadResult<PveStorageStatus, proxmox_client::Error>,
     last_rrd_error: Option<proxmox_client::Error>,
 
     /// internal guard for the periodic timeout callback to update status
@@ -131,12 +131,11 @@ impl yew::Component for StoragePanelComp {
         ctx.link()
             .send_message_batch(vec![Msg::ReloadStatus, Msg::ReloadRrd]);
         Self {
-            status: None,
+            status: LoadResult::new(),
             status_update_timeout_guard: None,
             rrd_update_timeout_guard: None,
             _async_pool: AsyncPool::new(),
             last_rrd_error: None,
-            last_status_error: None,
 
             rrd_time_frame: RRDTimeframe::load(),
 
@@ -167,16 +166,7 @@ impl yew::Component for StoragePanelComp {
                 false
             }
             Msg::StatusResult(res) => {
-                match res {
-                    Ok(status) => {
-                        self.last_status_error = None;
-                        self.status = Some(status);
-                    }
-                    Err(err) => {
-                        self.last_status_error = Some(err);
-                    }
-                }
-
+                self.status.update(res);
                 self.status_update_timeout_guard =
                     Some(Timeout::new(props.status_interval, move || {
                         link.send_message(Msg::ReloadStatus)
@@ -220,8 +210,7 @@ impl yew::Component for StoragePanelComp {
         let props = ctx.props();
 
         if props.remote != old_props.remote || props.info != old_props.info {
-            self.status = None;
-            self.last_status_error = None;
+            self.status = LoadResult::new();
             self.last_rrd_error = None;
 
             self.time = Rc::new(Vec::new());
@@ -246,7 +235,7 @@ impl yew::Component for StoragePanelComp {
             .into();
 
         let mut status_comp = Column::new().gap(2).padding(4);
-        let status = match &self.status {
+        let status = match &self.status.data {
             Some(status) => status,
             None => &PveStorageStatus {
                 active: None,
@@ -305,8 +294,6 @@ impl yew::Component for StoragePanelComp {
             .value(disk_usage as f32),
         );
 
-        let loading = self.status.is_none() && self.last_status_error.is_none();
-
         Panel::new()
             .class(FlexFit)
             .title(title)
@@ -314,8 +301,8 @@ impl yew::Component for StoragePanelComp {
             .with_child(
                 // FIXME: add some 'visible' or 'active' property to the progress
                 Progress::new()
-                    .value((!loading).then_some(0.0))
-                    .style("opacity", (!loading).then_some("0")),
+                    .value(self.status.has_data().then_some(0.0))
+                    .style("opacity", self.status.has_data().then_some("0")),
             )
             .with_child(status_comp)
             .with_child(separator().padding_x(4))
-- 
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] 22+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v3 17/21] ui: dashboard: implement 'View'
  2025-10-31 12:43 [pdm-devel] [PATCH datacenter-manager v3 00/21] prepare ui for customizable views Dominik Csapak
                   ` (15 preceding siblings ...)
  2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 16/21] ui: introduce `LoadResult` helper type Dominik Csapak
@ 2025-10-31 12:44 ` Dominik Csapak
  2025-10-31 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 18/21] ui: dashboard: use 'View' instead of the Dashboard Dominik Csapak
                   ` (3 subsequent siblings)
  20 siblings, 0 replies; 22+ messages in thread
From: Dominik Csapak @ 2025-10-31 12:44 UTC (permalink / raw)
  To: pdm-devel
this is a more generalized version of our `Dashboard`, which can be
configured with a `ViewTemplate` that is completely serializable, so
we're able to dynamically configure it (e.g. in the future over the api)
An example of such a configuration could be, e.g.:
{
    "layout": {
        "layout-type": "rows",
        "rows": [
            [
                {
                    "widget-type": "remotes",
                    "show-wizard": true
                }
            ],
            [
                {
                    "widget-type": "leaderboard",
                    "leaderboard-type": "guest-cpu"
                }
            ]
        ]
    }
}
Implemented are all widget our Dashboard holds, but this is easily
extendable.
We abstract the 'RowView' away in it's own module, so we can easily add
a new layout types.
To avoid some unnecessary cloning, use a 'SharedState' for the load
results, that way only Rc's will be cloned into the render callback.
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/dashboard/mod.rs           |   2 +
 ui/src/dashboard/types.rs         |  70 +++++
 ui/src/dashboard/view.rs          | 454 ++++++++++++++++++++++++++++++
 ui/src/dashboard/view/row_view.rs | 138 +++++++++
 ui/src/pve/mod.rs                 |   4 +-
 5 files changed, 667 insertions(+), 1 deletion(-)
 create mode 100644 ui/src/dashboard/view.rs
 create mode 100644 ui/src/dashboard/view/row_view.rs
diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs
index 8857f82f..e381c6f9 100644
--- a/ui/src/dashboard/mod.rs
+++ b/ui/src/dashboard/mod.rs
@@ -54,6 +54,8 @@ use tasks::{create_task_summary_panel, get_task_options};
 
 pub mod types;
 
+pub mod view;
+
 mod refresh_config_edit;
 pub use refresh_config_edit::{
     create_refresh_config_edit_window, refresh_config_id, RefreshConfig,
diff --git a/ui/src/dashboard/types.rs b/ui/src/dashboard/types.rs
index 152d4f57..c79c38ab 100644
--- a/ui/src/dashboard/types.rs
+++ b/ui/src/dashboard/types.rs
@@ -1,5 +1,68 @@
 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(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 {
@@ -7,3 +70,10 @@ pub enum LeaderboardType {
     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
new file mode 100644
index 00000000..aaccef0d
--- /dev/null
+++ b/ui/src/dashboard/view.rs
@@ -0,0 +1,454 @@
+use std::rc::Rc;
+
+use anyhow::Error;
+use futures::join;
+use js_sys::Date;
+use serde_json::json;
+use yew::virtual_dom::{VComp, VNode};
+
+use proxmox_yew_comp::http_get;
+use pwt::css;
+use pwt::prelude::*;
+use pwt::props::StorageLocation;
+use pwt::state::{PersistentState, SharedState};
+use pwt::widget::{error_message, form::FormContext, Column, Container, Progress, Row};
+use pwt::AsyncPool;
+
+use crate::dashboard::refresh_config_edit::{
+    refresh_config_id, RefreshConfig, DEFAULT_MAX_AGE_S, DEFAULT_REFRESH_INTERVAL_S,
+    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::{
+    LeaderboardType, 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,
+    create_subscription_panel, create_task_summary_panel, create_top_entities_panel,
+    DashboardStatusRow,
+};
+use crate::remotes::AddWizard;
+use crate::{pdm_client, LoadResult};
+
+use pdm_api_types::remotes::RemoteType;
+use pdm_api_types::resource::ResourcesStatus;
+use pdm_api_types::TaskStatistics;
+use pdm_client::types::TopEntities;
+
+mod row_view;
+pub use row_view::RowView;
+
+#[derive(Properties, PartialEq)]
+pub struct View {
+    view: AttrValue,
+}
+
+impl From<View> for VNode {
+    fn from(val: View) -> Self {
+        let comp = VComp::new::<ViewComp>(Rc::new(val), None);
+        VNode::from(comp)
+    }
+}
+
+impl View {
+    pub fn new(view: impl Into<AttrValue>) -> Self {
+        Self { view: view.into() }
+    }
+}
+
+pub enum LoadingResult {
+    Resources(Result<ResourcesStatus, Error>),
+    TopEntities(Result<pdm_client::types::TopEntities, proxmox_client::Error>),
+    TaskStatistics(Result<TaskStatistics, Error>),
+    All,
+}
+
+pub enum Msg {
+    ViewTemplateLoaded(Result<ViewTemplate, Error>),
+    LoadingResult(LoadingResult),
+    CreateWizard(Option<RemoteType>),
+    Reload(bool),       // force
+    ConfigWindow(bool), // show
+    UpdateConfig(RefreshConfig),
+}
+
+struct ViewComp {
+    template: LoadResult<ViewTemplate, Error>,
+
+    // various api call results
+    status: SharedState<LoadResult<ResourcesStatus, Error>>,
+    top_entities: SharedState<LoadResult<TopEntities, proxmox_client::Error>>,
+    statistics: SharedState<LoadResult<TaskStatistics, Error>>,
+
+    refresh_config: PersistentState<RefreshConfig>,
+
+    async_pool: AsyncPool,
+    loading: bool,
+    load_finished_time: Option<f64>,
+    show_config_window: bool,
+    show_create_wizard: Option<RemoteType>,
+}
+
+fn render_widget(
+    link: yew::html::Scope<ViewComp>,
+    item: &RowWidget,
+    status: SharedState<LoadResult<ResourcesStatus, Error>>,
+    top_entities: SharedState<LoadResult<TopEntities, proxmox_client::Error>>,
+    statistics: SharedState<LoadResult<TaskStatistics, Error>>,
+    refresh_config: RefreshConfig,
+) -> Html {
+    let status = status.read();
+    let top_entities = top_entities.read();
+    let statistics = statistics.read();
+
+    let mut widget = match &item.r#type {
+        WidgetType::Nodes { remote_type } => create_node_panel(*remote_type, status.data.clone()),
+        WidgetType::Guests { guest_type } => create_guest_panel(*guest_type, status.data.clone()),
+        WidgetType::Remotes { show_wizard } => create_remote_panel(
+            status.data.clone(),
+            show_wizard.then_some(link.callback(|_| Msg::CreateWizard(Some(RemoteType::Pve)))),
+            show_wizard.then_some(link.callback(|_| Msg::CreateWizard(Some(RemoteType::Pve)))),
+        ),
+        WidgetType::PbsDatastores => create_pbs_datastores_panel(status.data.clone()),
+        WidgetType::Subscription => create_subscription_panel(),
+        WidgetType::Sdn => create_sdn_panel(status.data.clone()),
+        WidgetType::Leaderboard { leaderboard_type } => {
+            let entities = match leaderboard_type {
+                LeaderboardType::GuestCpu => top_entities
+                    .data
+                    .as_ref()
+                    .map(|entities| entities.guest_cpu.clone()),
+                LeaderboardType::NodeCpu => top_entities
+                    .data
+                    .as_ref()
+                    .map(|entities| entities.node_cpu.clone()),
+                LeaderboardType::NodeMemory => top_entities
+                    .data
+                    .as_ref()
+                    .map(|entities| entities.node_memory.clone()),
+            };
+            create_top_entities_panel(entities, top_entities.error.as_ref(), *leaderboard_type)
+        }
+        WidgetType::TaskSummary { grouping } => {
+            let remotes = match grouping {
+                TaskSummaryGrouping::Category => None,
+                TaskSummaryGrouping::Remote => Some(5),
+            };
+            let (hours, since) = get_task_options(refresh_config.task_last_hours);
+            create_task_summary_panel(
+                statistics.data.clone(),
+                statistics.error.as_ref(),
+                remotes,
+                hours,
+                since,
+            )
+        }
+    };
+
+    if let Some(title) = &item.title {
+        widget.set_title(title.clone());
+    }
+
+    widget.border(false).class(css::FlexFit).into()
+}
+
+impl ViewComp {
+    fn reload(&mut self, ctx: &yew::Context<Self>) {
+        let max_age = if self.load_finished_time.is_some() {
+            self.refresh_config.max_age.unwrap_or(DEFAULT_MAX_AGE_S)
+        } else {
+            INITIAL_MAX_AGE_S
+        };
+        self.do_reload(ctx, max_age)
+    }
+
+    fn do_reload(&mut self, ctx: &yew::Context<Self>, max_age: u64) {
+        if let Some(data) = self.template.data.as_ref() {
+            let link = ctx.link().clone();
+            let (_, since) = get_task_options(self.refresh_config.task_last_hours);
+            let (status, top_entities, tasks) = required_api_calls(&data.layout);
+
+            self.loading = true;
+            self.async_pool.spawn(async move {
+                let status_future = async {
+                    if status {
+                        let res =
+                            http_get("/resources/status", Some(json!({"max-age": max_age}))).await;
+                        link.send_message(Msg::LoadingResult(LoadingResult::Resources(res)));
+                    }
+                };
+
+                let entities_future = async {
+                    if top_entities {
+                        let client: pdm_client::PdmClient<Rc<proxmox_yew_comp::HttpClientWasm>> =
+                            pdm_client();
+                        let res = client.get_top_entities().await;
+                        link.send_message(Msg::LoadingResult(LoadingResult::TopEntities(res)));
+                    }
+                };
+
+                let tasks_future = async {
+                    if tasks {
+                        let params = Some(json!({
+                            "since": since,
+                            "limit": 0,
+                        }));
+                        let res = http_get("/remote-tasks/statistics", params).await;
+                        link.send_message(Msg::LoadingResult(LoadingResult::TaskStatistics(res)));
+                    }
+                };
+
+                join!(status_future, entities_future, tasks_future);
+                link.send_message(Msg::LoadingResult(LoadingResult::All));
+            });
+        } else {
+            ctx.link()
+                .send_message(Msg::LoadingResult(LoadingResult::All));
+        }
+    }
+}
+
+// returns which api calls are required: status, top_entities, task statistics
+fn required_api_calls(layout: &ViewLayout) -> (bool, bool, bool) {
+    let mut status = false;
+    let mut top_entities = false;
+    let mut task_statistics = false;
+    match layout {
+        ViewLayout::Rows { rows } => {
+            for row in rows {
+                for item in row {
+                    match item.r#type {
+                        WidgetType::Nodes { .. }
+                        | WidgetType::Guests { .. }
+                        | WidgetType::Remotes { .. }
+                        | WidgetType::Sdn
+                        | WidgetType::PbsDatastores => {
+                            status = true;
+                        }
+                        WidgetType::Subscription => {
+                            // panel does it itself, it's always required anyway
+                        }
+                        WidgetType::Leaderboard { .. } => top_entities = true,
+                        WidgetType::TaskSummary { .. } => task_statistics = true,
+                    }
+                }
+            }
+        }
+    }
+
+    (status, top_entities, task_statistics)
+}
+
+fn has_sub_panel(layout: Option<&ViewTemplate>) -> bool {
+    match layout.map(|template| &template.layout) {
+        Some(ViewLayout::Rows { rows }) => {
+            for row in rows {
+                for item in row {
+                    if item.r#type == WidgetType::Subscription {
+                        return true;
+                    }
+                }
+            }
+        }
+        None => {}
+    }
+
+    false
+}
+
+impl Component for ViewComp {
+    type Message = Msg;
+    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 async_pool = AsyncPool::new();
+        async_pool.send_future(ctx.link().clone(), async move {
+            Msg::ViewTemplateLoaded(load_template().await)
+        });
+
+        Self {
+            template: LoadResult::new(),
+            async_pool,
+
+            status: SharedState::new(LoadResult::new()),
+            top_entities: SharedState::new(LoadResult::new()),
+            statistics: SharedState::new(LoadResult::new()),
+
+            refresh_config,
+            load_finished_time: None,
+            loading: true,
+            show_config_window: false,
+            show_create_wizard: None,
+        }
+    }
+
+    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
+        match msg {
+            Msg::ViewTemplateLoaded(view_template) => {
+                self.template.update(view_template);
+                self.reload(ctx);
+            }
+            Msg::LoadingResult(loading_result) => match loading_result {
+                LoadingResult::Resources(status) => self.status.write().update(status),
+                LoadingResult::TopEntities(top_entities) => {
+                    self.top_entities.write().update(top_entities)
+                }
+                LoadingResult::TaskStatistics(task_statistics) => {
+                    self.statistics.write().update(task_statistics)
+                }
+                LoadingResult::All => {
+                    self.loading = false;
+                    if self.load_finished_time.is_none() {
+                        // immediately trigger a "normal" reload after the first load with the
+                        // configured or default max-age to ensure users sees more current data.
+                        ctx.link().send_message(Msg::Reload(false));
+                    }
+                    self.load_finished_time = Some(Date::now() / 1000.0);
+                }
+            },
+            Msg::CreateWizard(remote_type) => {
+                self.show_create_wizard = remote_type;
+            }
+            Msg::Reload(force) => {
+                if force {
+                    self.do_reload(ctx, FORCE_RELOAD_MAX_AGE_S);
+                } else {
+                    self.reload(ctx);
+                }
+            }
+
+            Msg::ConfigWindow(show) => {
+                self.show_config_window = show;
+            }
+            Msg::UpdateConfig(dashboard_config) => {
+                let (old_hours, _) = get_task_options(self.refresh_config.task_last_hours);
+                self.refresh_config.update(dashboard_config);
+                let (new_hours, _) = get_task_options(self.refresh_config.task_last_hours);
+
+                if old_hours != new_hours {
+                    self.reload(ctx);
+                }
+
+                self.show_config_window = false;
+            }
+        }
+        true
+    }
+
+    fn changed(&mut self, ctx: &Context<Self>, _old_props: &Self::Properties) -> bool {
+        self.async_pool = AsyncPool::new();
+        self.load_finished_time = None;
+        self.async_pool.send_future(ctx.link().clone(), async move {
+            Msg::ViewTemplateLoaded(load_template().await)
+        });
+        true
+    }
+
+    fn view(&self, ctx: &yew::Context<Self>) -> yew::Html {
+        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")
+                .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)),
+                )),
+        );
+        if !has_sub_panel(self.template.data.as_ref()) {
+            view.add_child(
+                Row::new()
+                    .class("pwt-content-spacer")
+                    .with_child(create_subscription_panel()),
+            );
+        }
+        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 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(),
+                            top_entities.clone(),
+                            statistics.clone(),
+                            refresh_config.clone(),
+                        )
+                    }
+                }));
+            }
+            None => {}
+        }
+        // fill remaining space
+        view.add_child(
+            Container::new()
+                .class(css::Flex::Fill)
+                .class("pwt-content-spacer"),
+        );
+        view.add_optional_child(
+            self.template
+                .error
+                .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_create_wizard.map(|remote_type| {
+            AddWizard::new(remote_type)
+                .on_close(ctx.link().callback(|_| Msg::CreateWizard(None)))
+                .on_submit(move |ctx| crate::remotes::create_remote(ctx, remote_type))
+        }));
+        view.into()
+    }
+}
+
+async fn load_template() -> Result<ViewTemplate, Error> {
+    // FIXME: load template from api
+
+    let view_str = "
+        {
+          \"description\": \"some description\",
+          \"layout\": {
+            \"layout-type\": \"rows\",
+            \"rows\": []
+          }
+        }
+    ";
+
+    let template: ViewTemplate = serde_json::from_str(view_str)?;
+    Ok(template)
+}
diff --git a/ui/src/dashboard/view/row_view.rs b/ui/src/dashboard/view/row_view.rs
new file mode 100644
index 00000000..69300327
--- /dev/null
+++ b/ui/src/dashboard/view/row_view.rs
@@ -0,0 +1,138 @@
+use std::collections::HashMap;
+use std::rc::Rc;
+
+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_macros::builder;
+
+use crate::dashboard::types::RowWidget;
+
+#[derive(Properties, PartialEq)]
+#[builder]
+pub struct RowView {
+    rows: Vec<Vec<RowWidget>>,
+    widget_renderer: RenderFn<RowWidget>,
+}
+
+impl RowView {
+    /// Creates a new RowView
+    pub fn new(rows: Vec<Vec<RowWidget>>, widget_renderer: impl Into<RenderFn<RowWidget>>) -> Self {
+        let widget_renderer = widget_renderer.into();
+        yew::props! { Self {rows, widget_renderer }}
+    }
+}
+
+impl From<RowView> for VNode {
+    fn from(val: RowView) -> Self {
+        let comp = VComp::new::<RowViewComp>(Rc::new(val), None);
+        VNode::from(comp)
+    }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+/// Represents the position of a widget in a row view
+pub struct Position {
+    row: usize,
+    item: usize,
+}
+
+pub struct RowViewComp {
+    current_layout: Vec<Vec<(Position, RowWidget)>>,
+}
+
+fn extract_row_layout(rows: &Vec<Vec<RowWidget>>) -> Vec<Vec<(Position, RowWidget)>> {
+    rows.iter()
+        .enumerate()
+        .map(|(row_idx, row)| {
+            row.iter()
+                .enumerate()
+                .map(|(item_idx, item)| {
+                    (
+                        Position {
+                            row: row_idx,
+                            item: item_idx,
+                        },
+                        item.clone(),
+                    )
+                })
+                .collect()
+        })
+        .collect()
+}
+
+impl Component for RowViewComp {
+    type Message = ();
+    type Properties = RowView;
+
+    fn create(ctx: &Context<Self>) -> Self {
+        let current_layout = extract_row_layout(&ctx.props().rows);
+
+        let mut next_row_indices = HashMap::new();
+        for (row_idx, row) in current_layout.iter().enumerate() {
+            next_row_indices.insert(row_idx, row.len());
+        }
+        Self { current_layout }
+    }
+
+    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);
+        }
+
+        true
+    }
+
+    fn view(&self, ctx: &Context<Self>) -> Html {
+        let props = ctx.props();
+        let mut view = Column::new();
+        let layout = &self.current_layout;
+        let mut row = Row::new()
+            .padding_x(2)
+            .class("pwt-content-spacer-colors")
+            .class(css::FlexDirection::Row)
+            .class(css::FlexWrap::Wrap);
+        for (row_idx, items) in layout.iter().enumerate() {
+            let flex_sum: f32 = items
+                .iter()
+                .map(|(_, item)| 1.0f32.max(item.flex.unwrap_or(1.0)))
+                .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() {
+                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
+                let flex_style = format!(
+                    "{} {} calc({}% - calc({} * var(--pwt-spacer-4)))",
+                    flex, flex, flex_ratio, gaps_ratio
+                );
+
+                let widget = props.widget_renderer.apply(&item);
+                let row_element = Panel::new()
+                    .border(true)
+                    .margin_x(2)
+                    .margin_bottom(4)
+                    .key(format!("item-{}-{}", coords.row, coords.item))
+                    .style("flex", flex_style)
+                    .with_child(widget);
+
+                row.add_child(row_element);
+            }
+
+            row.add_child(
+                Container::new()
+                    .key(format!("spacer-{row_idx}"))
+                    .style("flex", "1 1 100%"),
+            );
+        }
+
+        view.add_child(row);
+        view.into()
+    }
+}
diff --git a/ui/src/pve/mod.rs b/ui/src/pve/mod.rs
index 058424a9..256744ea 100644
--- a/ui/src/pve/mod.rs
+++ b/ui/src/pve/mod.rs
@@ -3,6 +3,7 @@ use std::{fmt::Display, rc::Rc};
 use gloo_utils::window;
 use proxmox_client::Error;
 use proxmox_yew_comp::{LoadableComponent, LoadableComponentContext, LoadableComponentMaster};
+use serde::{Deserialize, Serialize};
 use yew::{
     prelude::Html,
     virtual_dom::{VComp, VNode},
@@ -67,7 +68,8 @@ impl std::fmt::Display for Action {
     }
 }
 
-#[derive(PartialEq, Clone, Copy)]
+#[derive(PartialEq, Clone, Copy, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
 pub enum GuestType {
     Qemu,
     Lxc,
-- 
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] 22+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v3 18/21] ui: dashboard: use 'View' instead of the Dashboard
  2025-10-31 12:43 [pdm-devel] [PATCH datacenter-manager v3 00/21] prepare ui for customizable views Dominik Csapak
                   ` (16 preceding siblings ...)
  2025-10-31 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 17/21] ui: dashboard: implement 'View' Dominik Csapak
@ 2025-10-31 12:44 ` Dominik Csapak
  2025-10-31 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 19/21] ui: dashboard: subscription info: move subscription loading to view Dominik Csapak
                   ` (2 subsequent siblings)
  20 siblings, 0 replies; 22+ messages in thread
From: Dominik Csapak @ 2025-10-31 12:44 UTC (permalink / raw)
  To: pdm-devel
this uses our new `View` with a (currently) static configuration to
replicate our Dashboard. Since all functionality of that is available
in the View, the `Dashboard` struct can be removed.
This should be functionally the same as before.
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/dashboard/mod.rs  | 433 ++-------------------------------------
 ui/src/dashboard/view.rs |  70 ++++++-
 ui/src/lib.rs            |   2 +-
 ui/src/main_menu.rs      |   5 +-
 4 files changed, 85 insertions(+), 425 deletions(-)
diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs
index e381c6f9..c56a3fa8 100644
--- a/ui/src/dashboard/mod.rs
+++ b/ui/src/dashboard/mod.rs
@@ -1,27 +1,6 @@
-use std::rc::Rc;
-
-use anyhow::Error;
-use futures::join;
-use js_sys::Date;
-use serde_json::json;
-use yew::{
-    virtual_dom::{VComp, VNode},
-    Component,
-};
-
-use proxmox_yew_comp::http_get;
-use pwt::{
-    css::{AlignItems, FlexDirection, FlexFit, FlexWrap, JustifyContent},
-    prelude::*,
-    props::StorageLocation,
-    state::PersistentState,
-    widget::{form::FormContext, Column, Container, Fa, Panel, Row},
-    AsyncPool,
-};
-
-use pdm_api_types::{remotes::RemoteType, resource::ResourcesStatus, TaskStatistics};
-
-use crate::{pve::GuestType, remotes::AddWizard};
+use pwt::css;
+use pwt::prelude::*;
+use pwt::widget::{Column, Fa, Row};
 
 mod top_entities;
 pub use top_entities::create_top_entities_panel;
@@ -39,10 +18,10 @@ mod node_status_panel;
 use node_status_panel::create_node_panel;
 
 mod sdn_zone_panel;
-use sdn_zone_panel::create_sdn_panel;
+pub use sdn_zone_panel::create_sdn_panel;
 
 mod status_row;
-use status_row::DashboardStatusRow;
+pub use status_row::DashboardStatusRow;
 
 mod filtered_tasks;
 
@@ -50,416 +29,28 @@ mod pbs_datastores_panel;
 pub use pbs_datastores_panel::create_pbs_datastores_panel;
 
 mod tasks;
-use tasks::{create_task_summary_panel, get_task_options};
+pub use tasks::create_task_summary_panel;
 
 pub mod types;
 
 pub mod view;
 
 mod refresh_config_edit;
-pub use refresh_config_edit::{
-    create_refresh_config_edit_window, refresh_config_id, RefreshConfig,
-};
-use refresh_config_edit::{
-    DEFAULT_MAX_AGE_S, DEFAULT_REFRESH_INTERVAL_S, FORCE_RELOAD_MAX_AGE_S, INITIAL_MAX_AGE_S,
-};
-
-#[derive(Properties, PartialEq)]
-pub struct Dashboard {}
-
-impl Dashboard {
-    pub fn new() -> Self {
-        yew::props!(Self {})
-    }
-}
-
-impl Default for Dashboard {
-    fn default() -> Self {
-        Self::new()
-    }
-}
-
-pub enum LoadingResult {
-    Resources(Result<ResourcesStatus, Error>),
-    TopEntities(Result<pdm_client::types::TopEntities, proxmox_client::Error>),
-    TaskStatistics(Result<TaskStatistics, Error>),
-    All,
-}
-
-pub enum Msg {
-    LoadingFinished(LoadingResult),
-    CreateWizard(Option<RemoteType>),
-    Reload,
-    ForceReload,
-    UpdateConfig(RefreshConfig),
-    ConfigWindow(bool),
-}
-
-struct StatisticsOptions {
-    data: Option<TaskStatistics>,
-    error: Option<Error>,
-}
-
-pub struct PdmDashboard {
-    status: Option<ResourcesStatus>,
-    last_error: Option<Error>,
-    top_entities: Option<pdm_client::types::TopEntities>,
-    last_top_entities_error: Option<proxmox_client::Error>,
-    statistics: StatisticsOptions,
-    load_finished_time: Option<f64>,
-    show_wizard: Option<RemoteType>,
-    show_config_window: bool,
-    async_pool: AsyncPool,
-    config: PersistentState<RefreshConfig>,
-}
-
-impl PdmDashboard {
-    fn reload(&mut self, ctx: &yew::Context<Self>) {
-        let max_age = if self.load_finished_time.is_some() {
-            self.config.max_age.unwrap_or(DEFAULT_MAX_AGE_S)
-        } else {
-            INITIAL_MAX_AGE_S
-        };
-        self.do_reload(ctx, max_age)
-    }
-
-    fn do_reload(&mut self, ctx: &yew::Context<Self>, max_age: u64) {
-        let link = ctx.link().clone();
-        let (_, since) = get_task_options(self.config.task_last_hours);
-
-        self.async_pool.spawn(async move {
-            let client = crate::pdm_client();
-
-            let top_entities_future = {
-                let link = link.clone();
-                async move {
-                    let res = client.get_top_entities().await;
-                    link.send_message(Msg::LoadingFinished(LoadingResult::TopEntities(res)));
-                }
-            };
-            let status_future = {
-                let link = link.clone();
-                async move {
-                    let res: Result<ResourcesStatus, _> =
-                        http_get("/resources/status", Some(json!({"max-age": max_age}))).await;
-                    link.send_message(Msg::LoadingFinished(LoadingResult::Resources(res)));
-                }
-            };
-
-            let params = Some(json!({
-                "since": since,
-                "limit": 0,
-            }));
-
-            // TODO replace with pdm client call
-            let statistics_future = {
-                let link = link.clone();
-                async move {
-                    let res: Result<TaskStatistics, _> =
-                        http_get("/remote-tasks/statistics", params).await;
-                    link.send_message(Msg::LoadingFinished(LoadingResult::TaskStatistics(res)));
-                }
-            };
-            join!(top_entities_future, status_future, statistics_future);
-            link.send_message(Msg::LoadingFinished(LoadingResult::All));
-        });
-    }
-}
-
-impl Component for PdmDashboard {
-    type Message = Msg;
-    type Properties = Dashboard;
-
-    fn create(ctx: &yew::Context<Self>) -> Self {
-        let config: PersistentState<RefreshConfig> =
-            PersistentState::new(StorageLocation::local(refresh_config_id("dashboard")));
-        let async_pool = AsyncPool::new();
-
-        let mut this = Self {
-            status: None,
-            last_error: None,
-            top_entities: None,
-            last_top_entities_error: None,
-            statistics: StatisticsOptions {
-                data: None,
-                error: None,
-            },
-            load_finished_time: None,
-            show_wizard: None,
-            show_config_window: false,
-            async_pool,
-            config,
-        };
-
-        this.reload(ctx);
-
-        this
-    }
-
-    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
-        match msg {
-            Msg::LoadingFinished(res) => {
-                match res {
-                    LoadingResult::Resources(resources_status) => match resources_status {
-                        Ok(status) => {
-                            self.last_error = None;
-                            self.status = Some(status);
-                        }
-                        Err(err) => self.last_error = Some(err),
-                    },
-                    LoadingResult::TopEntities(top_entities) => match top_entities {
-                        Ok(data) => {
-                            self.last_top_entities_error = None;
-                            self.top_entities = Some(data);
-                        }
-                        Err(err) => self.last_top_entities_error = Some(err),
-                    },
-
-                    LoadingResult::TaskStatistics(task_statistics) => match task_statistics {
-                        Ok(statistics) => {
-                            self.statistics.error = None;
-                            self.statistics.data = Some(statistics);
-                        }
-                        Err(err) => self.statistics.error = Some(err),
-                    },
-                    LoadingResult::All => {
-                        if self.load_finished_time.is_none() {
-                            // immediately trigger a "normal" reload after the first load with the
-                            // configured or default max-age to ensure users sees more current data.
-                            ctx.link().send_message(Msg::Reload);
-                        }
-                        self.load_finished_time = Some(Date::now() / 1000.0);
-                    }
-                }
-                true
-            }
-            Msg::CreateWizard(remote_type) => {
-                self.show_wizard = remote_type;
-                true
-            }
-            Msg::Reload => {
-                self.reload(ctx);
-                true
-            }
-            Msg::ForceReload => {
-                self.do_reload(ctx, FORCE_RELOAD_MAX_AGE_S);
-                true
-            }
-            Msg::ConfigWindow(show) => {
-                self.show_config_window = show;
-                true
-            }
-            Msg::UpdateConfig(dashboard_config) => {
-                let (old_hours, _) = get_task_options(self.config.task_last_hours);
-                self.config.update(dashboard_config);
-                let (new_hours, _) = get_task_options(self.config.task_last_hours);
-
-                if old_hours != new_hours {
-                    self.reload(ctx);
-                }
-
-                self.show_config_window = false;
-                true
-            }
-        }
-    }
-
-    fn view(&self, ctx: &yew::Context<Self>) -> yew::Html {
-        let (hours, since) = get_task_options(self.config.task_last_hours);
-        let content = Column::new()
-            .class(FlexFit)
-            .with_child(
-                Container::new()
-                    .class("pwt-content-spacer-padding")
-                    .class("pwt-content-spacer-colors")
-                    .style("color", "var(--pwt-color)")
-                    .style("background-color", "var(--pwt-color-background)")
-                    .with_child(DashboardStatusRow::new(
-                        self.load_finished_time,
-                        self.config
-                            .refresh_interval
-                            .unwrap_or(DEFAULT_REFRESH_INTERVAL_S),
-                        ctx.link()
-                            .callback(|force| if force { Msg::ForceReload } else { Msg::Reload }),
-                        ctx.link().callback(|_| Msg::ConfigWindow(true)),
-                    )),
-            )
-            .with_child(
-                Container::new()
-                    .class("pwt-content-spacer")
-                    .class(FlexDirection::Row)
-                    .class(FlexWrap::Wrap)
-                    .padding_top(0)
-                    .with_child(
-                        create_remote_panel(
-                            self.status.clone(),
-                            Some(
-                                ctx.link()
-                                    .callback(|_| Msg::CreateWizard(Some(RemoteType::Pve))),
-                            ),
-                            Some(
-                                ctx.link()
-                                    .callback(|_| Msg::CreateWizard(Some(RemoteType::Pbs))),
-                            ),
-                        )
-                        .flex(1.0)
-                        .width(300)
-                        .min_height(175),
-                    )
-                    .with_child(
-                        create_node_panel(Some(RemoteType::Pve), self.status.clone())
-                            .flex(1.0)
-                            .width(300),
-                    )
-                    .with_child(
-                        create_guest_panel(Some(GuestType::Qemu), self.status.clone())
-                            .flex(1.0)
-                            .width(300),
-                    )
-                    .with_child(
-                        create_guest_panel(Some(GuestType::Lxc), self.status.clone())
-                            .flex(1.0)
-                            .width(300),
-                    )
-                    .with_child(
-                        create_node_panel(Some(RemoteType::Pbs), self.status.clone())
-                            .flex(1.0)
-                            .width(300),
-                    )
-                    .with_child(
-                        create_pbs_datastores_panel(self.status.clone())
-                            .flex(1.0)
-                            .width(300),
-                    )
-                    .with_child(
-                        create_subscription_panel()
-                            .flex(1.0)
-                            .width(500)
-                            .min_height(150),
-                    ),
-            )
-            .with_child(
-                Container::new()
-                    .class("pwt-content-spacer")
-                    .class(FlexDirection::Row)
-                    .class("pwt-align-content-start")
-                    .padding_top(0)
-                    .class(FlexWrap::Wrap)
-                    //.min_height(175)
-                    .with_child(
-                        create_top_entities_panel(
-                            self.top_entities.as_ref().map(|e| e.guest_cpu.clone()),
-                            self.last_top_entities_error.as_ref(),
-                            types::LeaderboardType::GuestCpu,
-                        )
-                        .flex(1.0)
-                        .width(500)
-                        .min_width(400),
-                    )
-                    .with_child(
-                        create_top_entities_panel(
-                            self.top_entities.as_ref().map(|e| e.node_cpu.clone()),
-                            self.last_top_entities_error.as_ref(),
-                            types::LeaderboardType::NodeCpu,
-                        )
-                        .flex(1.0)
-                        .width(500)
-                        .min_width(400),
-                    )
-                    .with_child(
-                        create_top_entities_panel(
-                            self.top_entities.as_ref().map(|e| e.node_memory.clone()),
-                            self.last_top_entities_error.as_ref(),
-                            types::LeaderboardType::NodeCpu,
-                        )
-                        .flex(1.0)
-                        .width(500)
-                        .min_width(400),
-                    ),
-            )
-            .with_child(
-                Container::new()
-                    .class("pwt-content-spacer")
-                    .class(FlexDirection::Row)
-                    .class("pwt-align-content-start")
-                    .style("padding-top", "0")
-                    .class(pwt::css::Flex::Fill)
-                    .class(FlexWrap::Wrap)
-                    .with_child(
-                        create_task_summary_panel(
-                            self.statistics.data.clone(),
-                            self.statistics.error.as_ref(),
-                            None,
-                            hours,
-                            since,
-                        )
-                        .flex(1.0)
-                        .width(500),
-                    )
-                    .with_child(
-                        create_task_summary_panel(
-                            self.statistics.data.clone(),
-                            self.statistics.error.as_ref(),
-                            Some(5),
-                            hours,
-                            since,
-                        )
-                        .flex(1.0)
-                        .width(500),
-                    )
-                    .with_child(create_sdn_panel(self.status.clone()).flex(1.0).width(200)),
-            );
-
-        Panel::new()
-            .class(FlexFit)
-            .with_child(content)
-            .with_optional_child(self.show_wizard.map(|remote_type| {
-                AddWizard::new(remote_type)
-                    .on_close(ctx.link().callback(|_| Msg::CreateWizard(None)))
-                    .on_submit(move |ctx| crate::remotes::create_remote(ctx, remote_type))
-            }))
-            .with_optional_child(
-                self.show_config_window.then_some(
-                    create_refresh_config_edit_window("dashboard")
-                        .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(())
-                                }
-                            }
-                        }),
-                ),
-            )
-            .into()
-    }
-}
-
-impl From<Dashboard> for VNode {
-    fn from(val: Dashboard) -> Self {
-        let comp = VComp::new::<PdmDashboard>(Rc::new(val), None);
-        VNode::from(comp)
-    }
-}
+pub use refresh_config_edit::create_refresh_config_edit_window;
 
 fn loading_column() -> Column {
     Column::new()
         .padding(4)
-        .class(FlexFit)
-        .class(JustifyContent::Center)
-        .class(AlignItems::Center)
+        .class(css::FlexFit)
+        .class(css::JustifyContent::Center)
+        .class(css::AlignItems::Center)
         .with_child(html! {<i class={"pwt-loading-icon"} />})
 }
 
 /// Create a consistent title component for the given title and icon
-pub fn create_title_with_icon(icon: &str, title: String) -> Html {
+fn create_title_with_icon(icon: &str, title: String) -> Html {
     Row::new()
-        .class(AlignItems::Center)
+        .class(css::AlignItems::Center)
         .gap(2)
         .with_child(Fa::new(icon))
         .with_child(title)
diff --git a/ui/src/dashboard/view.rs b/ui/src/dashboard/view.rs
index aaccef0d..f3c8f0c8 100644
--- a/ui/src/dashboard/view.rs
+++ b/ui/src/dashboard/view.rs
@@ -444,7 +444,75 @@ async fn load_template() -> Result<ViewTemplate, Error> {
           \"description\": \"some description\",
           \"layout\": {
             \"layout-type\": \"rows\",
-            \"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\"
+                }
+              ]
+            ]
           }
         }
     ";
diff --git a/ui/src/lib.rs b/ui/src/lib.rs
index de76e1c0..f9af023d 100644
--- a/ui/src/lib.rs
+++ b/ui/src/lib.rs
@@ -27,7 +27,7 @@ mod search_provider;
 pub use search_provider::SearchProvider;
 
 mod dashboard;
-pub use dashboard::Dashboard;
+
 use yew_router::prelude::RouterScopeExt;
 
 mod widget;
diff --git a/ui/src/main_menu.rs b/ui/src/main_menu.rs
index 7650b63f..b5044169 100644
--- a/ui/src/main_menu.rs
+++ b/ui/src/main_menu.rs
@@ -13,11 +13,12 @@ use proxmox_yew_comp::{NotesView, XTermJs};
 
 use pdm_api_types::remotes::RemoteType;
 
+use crate::dashboard::view::View;
 use crate::remotes::RemotesPanel;
 use crate::sdn::evpn::EvpnPanel;
 use crate::sdn::ZoneTree;
 use crate::{
-    AccessControl, CertificatesPanel, Dashboard, RemoteListCacheEntry, ServerAdministration,
+    AccessControl, CertificatesPanel, RemoteListCacheEntry, ServerAdministration,
     SystemConfiguration,
 };
 
@@ -141,7 +142,7 @@ impl Component for PdmMainMenu {
             tr!("Dashboard"),
             "dashboard",
             Some("fa fa-tachometer"),
-            move |_| Dashboard::new().into(),
+            move |_| View::new("dashboard").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] 22+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v3 19/21] ui: dashboard: subscription info: move subscription loading to view
  2025-10-31 12:43 [pdm-devel] [PATCH datacenter-manager v3 00/21] prepare ui for customizable views Dominik Csapak
                   ` (17 preceding siblings ...)
  2025-10-31 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 18/21] ui: dashboard: use 'View' instead of the Dashboard Dominik Csapak
@ 2025-10-31 12:44 ` Dominik Csapak
  2025-10-31 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 20/21] ui: dashboard: use SharedState for create_*_panel Dominik Csapak
  2025-10-31 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 21/21] ui: dashboard: enable editing view Dominik Csapak
  20 siblings, 0 replies; 22+ messages in thread
From: Dominik Csapak @ 2025-10-31 12:44 UTC (permalink / raw)
  To: pdm-devel
so in case we have multiple subscription panels, the info from the view
can be reused.
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/dashboard/subscription_info.rs | 72 ++++++++++-----------------
 ui/src/dashboard/view.rs              | 21 ++++++--
 2 files changed, 44 insertions(+), 49 deletions(-)
diff --git a/ui/src/dashboard/subscription_info.rs b/ui/src/dashboard/subscription_info.rs
index 2a4d94b7..cf8d7260 100644
--- a/ui/src/dashboard/subscription_info.rs
+++ b/ui/src/dashboard/subscription_info.rs
@@ -7,31 +7,30 @@ use yew::{
     Component, Html, Properties,
 };
 
-use proxmox_yew_comp::{http_get, Status};
+use proxmox_yew_comp::Status;
+use pwt::prelude::*;
+use pwt::widget::{Column, Container, Fa, Panel, Row};
 use pwt::{
     css::{AlignItems, FlexFit, JustifyContent, TextAlign},
-    prelude::tr,
-    props::{ContainerBuilder, CssBorderBuilder, CssPaddingBuilder, WidgetBuilder},
-    widget::{Column, Container, Fa, Panel, Row},
-    AsyncPool,
+    state::SharedState,
 };
 
 use pdm_api_types::subscription::{RemoteSubscriptionState, RemoteSubscriptions};
 
+use crate::LoadResult;
+
 #[derive(Properties, PartialEq)]
-pub struct SubscriptionInfo {}
+pub struct SubscriptionInfo {
+    subs: Option<Vec<RemoteSubscriptions>>,
+}
 
 impl SubscriptionInfo {
-    pub fn new() -> Self {
-        Self {}
+    pub fn new(subs: Option<Vec<RemoteSubscriptions>>) -> Self {
+        Self { subs }
     }
 }
 
-struct PdmSubscriptionInfo {
-    status: Vec<RemoteSubscriptions>,
-    loading: bool,
-    _async_pool: AsyncPool,
-}
+struct PdmSubscriptionInfo {}
 
 fn render_subscription_status(subs: &[RemoteSubscriptions]) -> Row {
     let mut none = 0;
@@ -98,52 +97,31 @@ fn render_subscription_status(subs: &[RemoteSubscriptions]) -> Row {
 }
 
 impl Component for PdmSubscriptionInfo {
-    type Message = Result<Vec<RemoteSubscriptions>, Error>;
-
+    type Message = ();
     type Properties = SubscriptionInfo;
 
-    fn create(ctx: &yew::Context<Self>) -> Self {
-        let link = ctx.link().clone();
-        let mut _async_pool = AsyncPool::new();
-        _async_pool.spawn(async move {
-            let result = http_get("/resources/subscription", None).await;
-            link.send_message(result);
-        });
-
-        Self {
-            status: Vec::new(),
-            loading: true,
-            _async_pool,
-        }
-    }
-
-    fn update(&mut self, _ctx: &yew::Context<Self>, msg: Self::Message) -> bool {
-        match msg {
-            Ok(result) => {
-                self.status = result;
-            }
-            Err(_) => self.status = Vec::new(),
-        }
-
-        self.loading = false;
-
-        true
+    fn create(_ctx: &yew::Context<Self>) -> Self {
+        Self {}
     }
 
-    fn view(&self, _ctx: &yew::Context<Self>) -> yew::Html {
+    fn view(&self, ctx: &yew::Context<Self>) -> yew::Html {
+        let props = ctx.props();
         Column::new()
             .class(FlexFit)
             .class(JustifyContent::Center)
             .class(AlignItems::Center)
             .with_optional_child(
-                self.loading.then_some(
+                props.subs.is_none().then_some(
                     Container::new()
                         .padding(4)
                         .with_child(Container::from_tag("i").class("pwt-loading-icon")),
                 ),
             )
             .with_optional_child(
-                (!self.loading).then_some(render_subscription_status(&self.status)),
+                props
+                    .subs
+                    .as_ref()
+                    .map(|subs| render_subscription_status(subs)),
             )
             .into()
     }
@@ -156,7 +134,9 @@ impl From<SubscriptionInfo> for VNode {
     }
 }
 
-pub fn create_subscription_panel() -> Panel {
+pub fn create_subscription_panel(
+    subs: SharedState<LoadResult<Vec<RemoteSubscriptions>, Error>>,
+) -> Panel {
     let title: Html = Row::new()
         .class(AlignItems::Center)
         .gap(2)
@@ -167,5 +147,5 @@ pub fn create_subscription_panel() -> Panel {
     Panel::new()
         .title(title)
         .border(true)
-        .with_child(SubscriptionInfo::new())
+        .with_child(SubscriptionInfo::new(subs.read().data.clone()))
 }
diff --git a/ui/src/dashboard/view.rs b/ui/src/dashboard/view.rs
index f3c8f0c8..bcb277ed 100644
--- a/ui/src/dashboard/view.rs
+++ b/ui/src/dashboard/view.rs
@@ -34,6 +34,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::TaskStatistics;
 use pdm_client::types::TopEntities;
 
@@ -62,6 +63,7 @@ pub enum LoadingResult {
     Resources(Result<ResourcesStatus, Error>),
     TopEntities(Result<pdm_client::types::TopEntities, proxmox_client::Error>),
     TaskStatistics(Result<TaskStatistics, Error>),
+    SubscriptionInfo(Result<Vec<RemoteSubscriptions>, Error>),
     All,
 }
 
@@ -81,6 +83,7 @@ struct ViewComp {
     status: SharedState<LoadResult<ResourcesStatus, Error>>,
     top_entities: SharedState<LoadResult<TopEntities, proxmox_client::Error>>,
     statistics: SharedState<LoadResult<TaskStatistics, Error>>,
+    subscriptions: SharedState<LoadResult<Vec<RemoteSubscriptions>, Error>>,
 
     refresh_config: PersistentState<RefreshConfig>,
 
@@ -95,6 +98,7 @@ fn render_widget(
     link: yew::html::Scope<ViewComp>,
     item: &RowWidget,
     status: SharedState<LoadResult<ResourcesStatus, Error>>,
+    subscriptions: SharedState<LoadResult<Vec<RemoteSubscriptions>, Error>>,
     top_entities: SharedState<LoadResult<TopEntities, proxmox_client::Error>>,
     statistics: SharedState<LoadResult<TaskStatistics, Error>>,
     refresh_config: RefreshConfig,
@@ -112,7 +116,7 @@ fn render_widget(
             show_wizard.then_some(link.callback(|_| Msg::CreateWizard(Some(RemoteType::Pve)))),
         ),
         WidgetType::PbsDatastores => create_pbs_datastores_panel(status.data.clone()),
-        WidgetType::Subscription => create_subscription_panel(),
+        WidgetType::Subscription => create_subscription_panel(subscriptions),
         WidgetType::Sdn => create_sdn_panel(status.data.clone()),
         WidgetType::Leaderboard { leaderboard_type } => {
             let entities = match leaderboard_type {
@@ -200,7 +204,12 @@ impl ViewComp {
                     }
                 };
 
-                join!(status_future, entities_future, tasks_future);
+                let subs_future = async {
+                    let res = http_get("/resources/subscription", None).await;
+                    link.send_message(Msg::LoadingResult(LoadingResult::SubscriptionInfo(res)));
+                };
+
+                join!(status_future, entities_future, tasks_future, subs_future);
                 link.send_message(Msg::LoadingResult(LoadingResult::All));
             });
         } else {
@@ -279,6 +288,7 @@ impl Component for ViewComp {
             status: SharedState::new(LoadResult::new()),
             top_entities: SharedState::new(LoadResult::new()),
             statistics: SharedState::new(LoadResult::new()),
+            subscriptions: SharedState::new(LoadResult::new()),
 
             refresh_config,
             load_finished_time: None,
@@ -302,6 +312,9 @@ impl Component for ViewComp {
                 LoadingResult::TaskStatistics(task_statistics) => {
                     self.statistics.write().update(task_statistics)
                 }
+                LoadingResult::SubscriptionInfo(subscriptions) => {
+                    self.subscriptions.write().update(subscriptions);
+                }
                 LoadingResult::All => {
                     self.loading = false;
                     if self.load_finished_time.is_none() {
@@ -372,7 +385,7 @@ impl Component for ViewComp {
             view.add_child(
                 Row::new()
                     .class("pwt-content-spacer")
-                    .with_child(create_subscription_panel()),
+                    .with_child(create_subscription_panel(self.subscriptions.clone())),
             );
         }
         match self.template.data.as_ref().map(|template| &template.layout) {
@@ -380,6 +393,7 @@ impl Component for ViewComp {
                 view.add_child(RowView::new(rows.clone(), {
                     let link = ctx.link().clone();
                     let status = self.status.clone();
+                    let subscriptions = self.subscriptions.clone();
                     let top_entities = self.top_entities.clone();
                     let statistics = self.statistics.clone();
                     let refresh_config = self.refresh_config.clone();
@@ -388,6 +402,7 @@ impl Component for ViewComp {
                             link.clone(),
                             widget,
                             status.clone(),
+                            subscriptions.clone(),
                             top_entities.clone(),
                             statistics.clone(),
                             refresh_config.clone(),
-- 
2.47.3
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply	[flat|nested] 22+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v3 20/21] ui: dashboard: use SharedState for create_*_panel
  2025-10-31 12:43 [pdm-devel] [PATCH datacenter-manager v3 00/21] prepare ui for customizable views Dominik Csapak
                   ` (18 preceding siblings ...)
  2025-10-31 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 19/21] ui: dashboard: subscription info: move subscription loading to view Dominik Csapak
@ 2025-10-31 12:44 ` Dominik Csapak
  2025-10-31 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 21/21] ui: dashboard: enable editing view Dominik Csapak
  20 siblings, 0 replies; 22+ messages in thread
From: Dominik Csapak @ 2025-10-31 12:44 UTC (permalink / raw)
  To: pdm-devel
this avoids some unnecessary clones of the data.
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/dashboard/guest_panel.rs          | 29 ++++++++++++-----
 ui/src/dashboard/node_status_panel.rs    | 17 ++++++++--
 ui/src/dashboard/pbs_datastores_panel.rs | 13 ++++++--
 ui/src/dashboard/remote_panel.rs         | 18 ++++++++---
 ui/src/dashboard/sdn_zone_panel.rs       | 14 ++++++---
 ui/src/dashboard/tasks.rs                | 13 +++++---
 ui/src/dashboard/top_entities.rs         | 24 +++++++++-----
 ui/src/dashboard/view.rs                 | 40 +++++-------------------
 8 files changed, 102 insertions(+), 66 deletions(-)
diff --git a/ui/src/dashboard/guest_panel.rs b/ui/src/dashboard/guest_panel.rs
index be7a7a2e..03b86837 100644
--- a/ui/src/dashboard/guest_panel.rs
+++ b/ui/src/dashboard/guest_panel.rs
@@ -1,20 +1,24 @@
 use std::rc::Rc;
 
+use anyhow::Error;
+use yew::{
+    virtual_dom::{VComp, VNode},
+    Properties,
+};
+
 use pdm_api_types::resource::{GuestStatusCount, ResourceType, ResourcesStatus};
 use pdm_search::{Search, SearchTerm};
 use proxmox_yew_comp::GuestState;
 use pwt::{
     css::{self, TextAlign},
     prelude::*,
-    widget::{Container, Fa, List, ListTile, Panel},
-};
-use yew::{
-    virtual_dom::{VComp, VNode},
-    Properties,
+    state::SharedState,
+    widget::{error_message, Container, Fa, List, ListTile, Panel},
 };
 
 use crate::{
     dashboard::create_title_with_icon, pve::GuestType, search_provider::get_search_provider,
+    LoadResult,
 };
 
 use super::loading_column;
@@ -211,14 +215,23 @@ fn create_guest_search_term(
 
 /// Creates a new guest panel. Setting `guest_type` to `None` means we
 /// create one for all guests, regardless of type.
-pub fn create_guest_panel(guest_type: Option<GuestType>, status: Option<ResourcesStatus>) -> Panel {
+pub fn create_guest_panel(
+    guest_type: Option<GuestType>,
+    status: SharedState<LoadResult<ResourcesStatus, Error>>,
+) -> Panel {
     let (icon, title) = match guest_type {
         Some(GuestType::Qemu) => ("desktop", tr!("Virtual Machines")),
         Some(GuestType::Lxc) => ("cubes", tr!("Linux Container")),
         None => ("desktop", tr!("Guests")),
     };
+    let status = status.read();
     Panel::new()
         .title(create_title_with_icon(icon, title))
-        .border(true)
-        .with_child(GuestPanel::new(guest_type, status))
+        .with_child(GuestPanel::new(guest_type, status.data.clone()))
+        .with_optional_child(
+            status
+                .error
+                .as_ref()
+                .map(|err| error_message(&err.to_string())),
+        )
 }
diff --git a/ui/src/dashboard/node_status_panel.rs b/ui/src/dashboard/node_status_panel.rs
index 0873635d..60648ef0 100644
--- a/ui/src/dashboard/node_status_panel.rs
+++ b/ui/src/dashboard/node_status_panel.rs
@@ -1,18 +1,21 @@
 use std::rc::Rc;
 
+use anyhow::Error;
 use yew::virtual_dom::{VComp, VNode};
 
 use pdm_search::{Search, SearchTerm};
 use proxmox_yew_comp::Status;
 use pwt::css::{AlignItems, FlexFit, JustifyContent};
 use pwt::prelude::*;
-use pwt::widget::{Column, Fa, Panel};
+use pwt::state::SharedState;
+use pwt::widget::{error_message, Column, Fa, Panel};
 
 use pdm_api_types::resource::NodeStatusCount;
 use pdm_api_types::{remotes::RemoteType, resource::ResourcesStatus};
 
 use crate::dashboard::create_title_with_icon;
 use crate::search_provider::get_search_provider;
+use crate::LoadResult;
 
 use super::loading_column;
 
@@ -150,7 +153,7 @@ fn map_status(
 /// Passing `None` to `remote_type` means creating a panel for all nodes, regardless of remote type.
 pub fn create_node_panel(
     remote_type: Option<RemoteType>,
-    status: Option<ResourcesStatus>,
+    status: SharedState<LoadResult<ResourcesStatus, Error>>,
 ) -> Panel {
     let (icon, title) = match remote_type {
         Some(RemoteType::Pve) => ("building", tr!("Virtual Environment Nodes")),
@@ -158,7 +161,9 @@ pub fn create_node_panel(
         None => ("building", tr!("Nodes")),
     };
 
-    let (nodes_status, failed_remotes) = match status {
+    let status = status.read();
+
+    let (nodes_status, failed_remotes) = match &status.data {
         Some(status) => {
             let nodes_status = match remote_type {
                 Some(RemoteType::Pve) => Some(status.pve_nodes.clone()),
@@ -190,4 +195,10 @@ pub fn create_node_panel(
             nodes_status,
             failed_remotes,
         ))
+        .with_optional_child(
+            status
+                .error
+                .as_ref()
+                .map(|err| error_message(&err.to_string())),
+        )
 }
diff --git a/ui/src/dashboard/pbs_datastores_panel.rs b/ui/src/dashboard/pbs_datastores_panel.rs
index 6542ac00..b28647fa 100644
--- a/ui/src/dashboard/pbs_datastores_panel.rs
+++ b/ui/src/dashboard/pbs_datastores_panel.rs
@@ -1,5 +1,6 @@
 use std::rc::Rc;
 
+use anyhow::Error;
 use yew::virtual_dom::{VComp, VNode};
 
 use pdm_api_types::resource::{PbsDatastoreStatusCount, ResourceType, ResourcesStatus};
@@ -7,10 +8,12 @@ use pdm_search::{Search, SearchTerm};
 use proxmox_yew_comp::Status;
 use pwt::css::{self, TextAlign};
 use pwt::prelude::*;
+use pwt::state::SharedState;
 use pwt::widget::{Container, Fa, List, ListTile, Panel};
 
 use crate::dashboard::create_title_with_icon;
 use crate::search_provider::get_search_provider;
+use crate::LoadResult;
 
 use super::loading_column;
 
@@ -155,8 +158,14 @@ fn create_pbs_datastores_status_search_term(search_term: Option<(&str, &str)>) -
     Search::with_terms(terms)
 }
 
-pub fn create_pbs_datastores_panel(status: Option<ResourcesStatus>) -> Panel {
-    let pbs_datastores = status.map(|status| status.pbs_datastores.clone());
+pub fn create_pbs_datastores_panel(
+    status: SharedState<LoadResult<ResourcesStatus, Error>>,
+) -> Panel {
+    let pbs_datastores = status
+        .read()
+        .data
+        .as_ref()
+        .map(|status| status.pbs_datastores.clone());
 
     Panel::new()
         .title(create_title_with_icon(
diff --git a/ui/src/dashboard/remote_panel.rs b/ui/src/dashboard/remote_panel.rs
index 747f9b8d..a5682d7a 100644
--- a/ui/src/dashboard/remote_panel.rs
+++ b/ui/src/dashboard/remote_panel.rs
@@ -1,5 +1,6 @@
 use std::rc::Rc;
 
+use anyhow::Error;
 use yew::html::IntoEventCallback;
 use yew::virtual_dom::{VComp, VNode};
 
@@ -8,11 +9,13 @@ use proxmox_yew_comp::Status;
 use pwt::css;
 use pwt::prelude::*;
 use pwt::props::{ContainerBuilder, WidgetBuilder};
+use pwt::state::SharedState;
 use pwt::widget::menu::{Menu, MenuButton, MenuEvent, MenuItem};
-use pwt::widget::{Column, Container, Fa, Panel};
+use pwt::widget::{error_message, Column, Container, Fa, Panel};
 
 use pdm_api_types::resource::ResourcesStatus;
 
+use crate::LoadResult;
 use crate::{dashboard::create_title_with_icon, search_provider::get_search_provider};
 
 #[derive(Properties, PartialEq)]
@@ -116,14 +119,14 @@ fn create_search_term(failure: bool) -> Search {
 }
 
 pub fn create_remote_panel(
-    status: Option<ResourcesStatus>,
+    status: SharedState<LoadResult<ResourcesStatus, Error>>,
     on_pve_wizard: Option<impl IntoEventCallback<MenuEvent>>,
     on_pbs_wizard: Option<impl IntoEventCallback<MenuEvent>>,
 ) -> Panel {
+    let status = status.read();
     let mut panel = Panel::new()
         .title(create_title_with_icon("server", tr!("Remotes")))
-        .border(true)
-        .with_child(RemotePanel::new(status));
+        .with_child(RemotePanel::new(status.data.clone()));
 
     if on_pve_wizard.is_some() || on_pbs_wizard.is_some() {
         let mut menu = Menu::new();
@@ -143,5 +146,10 @@ pub fn create_remote_panel(
         }
         panel.add_tool(MenuButton::new(tr!("Add")).show_arrow(true).menu(menu));
     }
-    panel
+    panel.with_optional_child(
+        status
+            .error
+            .as_ref()
+            .map(|err| error_message(&err.to_string())),
+    )
 }
diff --git a/ui/src/dashboard/sdn_zone_panel.rs b/ui/src/dashboard/sdn_zone_panel.rs
index 611aadd1..8112550c 100644
--- a/ui/src/dashboard/sdn_zone_panel.rs
+++ b/ui/src/dashboard/sdn_zone_panel.rs
@@ -1,10 +1,13 @@
 use std::rc::Rc;
 
+use anyhow::Error;
+
 use pdm_api_types::resource::{ResourceType, ResourcesStatus, SdnStatus, SdnZoneCount};
 use pdm_search::{Search, SearchTerm};
 use pwt::{
     css::{self, FontColor, TextAlign},
     prelude::*,
+    state::SharedState,
     widget::{Container, Fa, List, ListTile, Panel},
 };
 use yew::{
@@ -12,7 +15,7 @@ use yew::{
     Properties,
 };
 
-use crate::{dashboard::create_title_with_icon, search_provider::get_search_provider};
+use crate::{dashboard::create_title_with_icon, search_provider::get_search_provider, LoadResult};
 
 use super::loading_column;
 
@@ -156,11 +159,14 @@ fn create_sdn_zone_search_term(status: Option<SdnStatus>) -> Search {
     Search::with_terms(terms)
 }
 
-pub fn create_sdn_panel(status: Option<ResourcesStatus>) -> Panel {
-    let sdn_zones_status = status.map(|status| status.sdn_zones);
+pub fn create_sdn_panel(status: SharedState<LoadResult<ResourcesStatus, Error>>) -> Panel {
+    let sdn_zones_status = status
+        .read()
+        .data
+        .as_ref()
+        .map(|status| status.sdn_zones.clone());
 
     Panel::new()
         .title(create_title_with_icon("sdn", tr!("SDN Zones")))
-        .border(true)
         .with_child(SdnZonePanel::new(sdn_zones_status))
 }
diff --git a/ui/src/dashboard/tasks.rs b/ui/src/dashboard/tasks.rs
index 9989d4a9..3903865c 100644
--- a/ui/src/dashboard/tasks.rs
+++ b/ui/src/dashboard/tasks.rs
@@ -4,6 +4,7 @@ use std::rc::Rc;
 
 use anyhow::Error;
 use js_sys::Date;
+use pwt::state::SharedState;
 use yew::html::Scope;
 use yew::virtual_dom::Key;
 
@@ -24,6 +25,7 @@ use crate::dashboard::create_title_with_icon;
 use crate::dashboard::loading_column;
 use crate::dashboard::refresh_config_edit::DEFAULT_TASK_SUMMARY_HOURS;
 use crate::tasks::TaskWorkerType;
+use crate::LoadResult;
 
 use super::filtered_tasks::FilteredTasks;
 
@@ -307,8 +309,7 @@ impl Component for ProxmoxTaskSummary {
 }
 
 pub fn create_task_summary_panel(
-    statistics: Option<TaskStatistics>,
-    error: Option<&Error>,
+    statistics: SharedState<LoadResult<TaskStatistics, Error>>,
     remotes: Option<u32>,
     hours: u32,
     since: i64,
@@ -317,15 +318,17 @@ pub fn create_task_summary_panel(
         Some(_count) => tr!("Task Summary Sorted by Failed Tasks (Last {0}h)", hours),
         None => tr!("Task Summary by Category (Last {0}h)", hours),
     };
-    let loading = error.is_none() && statistics.is_none();
+    let loading = !statistics.read().has_data();
+    let guard = statistics.read();
+    let data = guard.data.clone();
+    let error = guard.error.as_ref();
     Panel::new()
-        .border(true)
         .title(create_title_with_icon("list", title))
         .with_child(
             Container::new()
                 .class(css::FlexFit)
                 .padding(2)
-                .with_optional_child(statistics.map(|data| TaskSummary::new(data, since, remotes)))
+                .with_optional_child(data.map(|data| TaskSummary::new(data, since, remotes)))
                 .with_optional_child((loading).then_some(loading_column()))
                 .with_optional_child(error.map(|err| error_message(&err.to_string()))),
         )
diff --git a/ui/src/dashboard/top_entities.rs b/ui/src/dashboard/top_entities.rs
index dfe38692..e94c1b8c 100644
--- a/ui/src/dashboard/top_entities.rs
+++ b/ui/src/dashboard/top_entities.rs
@@ -1,5 +1,6 @@
 use std::rc::Rc;
 
+use pwt::state::SharedState;
 use web_sys::HtmlElement;
 use yew::virtual_dom::{VComp, VNode};
 
@@ -18,6 +19,7 @@ use pwt::{
 
 use pdm_client::types::{Resource, TopEntity};
 
+use crate::LoadResult;
 use crate::{
     dashboard::{create_title_with_icon, loading_column, types::LeaderboardType},
     get_deep_url, get_resource_node, navigate_to,
@@ -327,37 +329,45 @@ fn graph_from_data(data: &Vec<Option<f64>>, threshold: f64) -> Container {
 }
 
 pub fn create_top_entities_panel(
-    entities: Option<Vec<TopEntity>>,
-    error: Option<&proxmox_client::Error>,
+    top_entities: SharedState<
+        LoadResult<pdm_api_types::resource::TopEntities, proxmox_client::Error>,
+    >,
     leaderboard_type: LeaderboardType,
 ) -> Panel {
-    let (icon, title, metrics_title, threshold) = match leaderboard_type {
+    let top_entities = top_entities.read();
+    let (entities, icon, title, metrics_title, threshold) = match leaderboard_type {
         LeaderboardType::GuestCpu => (
+            top_entities.data.as_ref().map(|e| e.guest_cpu.clone()),
             "desktop",
             tr!("Guests With the Highest CPU Usage"),
             tr!("CPU usage"),
             0.85,
         ),
         LeaderboardType::NodeCpu => (
+            top_entities.data.as_ref().map(|e| e.node_cpu.clone()),
             "building",
             tr!("Nodes With the Highest CPU Usage"),
             tr!("CPU usage"),
             0.85,
         ),
         LeaderboardType::NodeMemory => (
+            top_entities.data.as_ref().map(|e| e.node_memory.clone()),
             "building",
             tr!("Nodes With the Highest Memory Usage"),
             tr!("Memory usage"),
             0.95,
         ),
     };
-    let loading = entities.is_none() && error.is_none();
     Panel::new()
-        .border(true)
         .title(create_title_with_icon(icon, title))
         .with_optional_child(
             entities.map(|entities| TopEntities::new(entities, metrics_title, threshold)),
         )
-        .with_optional_child(loading.then_some(loading_column()))
-        .with_optional_child(error.map(|err| error_message(&err.to_string())))
+        .with_optional_child((!top_entities.has_data()).then_some(loading_column()))
+        .with_optional_child(
+            top_entities
+                .error
+                .as_ref()
+                .map(|err| error_message(&err.to_string())),
+        )
 }
diff --git a/ui/src/dashboard/view.rs b/ui/src/dashboard/view.rs
index bcb277ed..fb46064c 100644
--- a/ui/src/dashboard/view.rs
+++ b/ui/src/dashboard/view.rs
@@ -103,37 +103,19 @@ fn render_widget(
     statistics: SharedState<LoadResult<TaskStatistics, Error>>,
     refresh_config: RefreshConfig,
 ) -> Html {
-    let status = status.read();
-    let top_entities = top_entities.read();
-    let statistics = statistics.read();
-
     let mut widget = match &item.r#type {
-        WidgetType::Nodes { remote_type } => create_node_panel(*remote_type, status.data.clone()),
-        WidgetType::Guests { guest_type } => create_guest_panel(*guest_type, status.data.clone()),
+        WidgetType::Nodes { remote_type } => create_node_panel(*remote_type, status),
+        WidgetType::Guests { guest_type } => create_guest_panel(*guest_type, status),
         WidgetType::Remotes { show_wizard } => create_remote_panel(
-            status.data.clone(),
-            show_wizard.then_some(link.callback(|_| Msg::CreateWizard(Some(RemoteType::Pve)))),
+            status,
             show_wizard.then_some(link.callback(|_| Msg::CreateWizard(Some(RemoteType::Pve)))),
+            show_wizard.then_some(link.callback(|_| Msg::CreateWizard(Some(RemoteType::Pbs)))),
         ),
-        WidgetType::PbsDatastores => create_pbs_datastores_panel(status.data.clone()),
+        WidgetType::PbsDatastores => create_pbs_datastores_panel(status),
         WidgetType::Subscription => create_subscription_panel(subscriptions),
-        WidgetType::Sdn => create_sdn_panel(status.data.clone()),
+        WidgetType::Sdn => create_sdn_panel(status),
         WidgetType::Leaderboard { leaderboard_type } => {
-            let entities = match leaderboard_type {
-                LeaderboardType::GuestCpu => top_entities
-                    .data
-                    .as_ref()
-                    .map(|entities| entities.guest_cpu.clone()),
-                LeaderboardType::NodeCpu => top_entities
-                    .data
-                    .as_ref()
-                    .map(|entities| entities.node_cpu.clone()),
-                LeaderboardType::NodeMemory => top_entities
-                    .data
-                    .as_ref()
-                    .map(|entities| entities.node_memory.clone()),
-            };
-            create_top_entities_panel(entities, top_entities.error.as_ref(), *leaderboard_type)
+            create_top_entities_panel(top_entities, *leaderboard_type)
         }
         WidgetType::TaskSummary { grouping } => {
             let remotes = match grouping {
@@ -141,13 +123,7 @@ fn render_widget(
                 TaskSummaryGrouping::Remote => Some(5),
             };
             let (hours, since) = get_task_options(refresh_config.task_last_hours);
-            create_task_summary_panel(
-                statistics.data.clone(),
-                statistics.error.as_ref(),
-                remotes,
-                hours,
-                since,
-            )
+            create_task_summary_panel(statistics, remotes, hours, since)
         }
     };
 
-- 
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] 22+ messages in thread
* [pdm-devel] [PATCH datacenter-manager v3 21/21] ui: dashboard: enable editing view
  2025-10-31 12:43 [pdm-devel] [PATCH datacenter-manager v3 00/21] prepare ui for customizable views Dominik Csapak
                   ` (19 preceding siblings ...)
  2025-10-31 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 20/21] ui: dashboard: use SharedState for create_*_panel Dominik Csapak
@ 2025-10-31 12:44 ` Dominik Csapak
  20 siblings, 0 replies; 22+ messages in thread
From: Dominik Csapak @ 2025-10-31 12:44 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.
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/status_row.rs       |  29 +-
 ui/src/dashboard/view.rs             |  92 ++++---
 ui/src/dashboard/view/row_element.rs | 130 +++++++++
 ui/src/dashboard/view/row_view.rs    | 389 +++++++++++++++++++++++++--
 6 files changed, 596 insertions(+), 50 deletions(-)
 create mode 100644 ui/src/dashboard/view/row_element.rs
diff --git a/ui/Cargo.toml b/ui/Cargo.toml
index cccb914e..b93ad79a 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/status_row.rs b/ui/src/dashboard/status_row.rs
index 0855b123..72ca195c 100644
--- a/ui/src/dashboard/status_row.rs
+++ b/ui/src/dashboard/status_row.rs
@@ -6,12 +6,13 @@ 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;
 
 #[widget(comp=PdmDashboardStatusRow)]
 #[derive(Properties, PartialEq, Clone)]
+#[builder]
 pub struct DashboardStatusRow {
     last_refresh: Option<f64>,
     reload_interval_s: u32,
@@ -19,6 +20,11 @@ pub struct DashboardStatusRow {
     on_reload: Callback<bool>,
 
     on_settings_click: Callback<()>,
+
+    #[builder_cb(Into, into, Option<Callback<bool>>)]
+    #[prop_or_default]
+    /// An optional callback to show and toggle an edit button
+    on_edit_toggle: Option<Callback<bool>>,
 }
 
 impl DashboardStatusRow {
@@ -40,12 +46,14 @@ impl DashboardStatusRow {
 pub enum Msg {
     /// The bool denotes if the reload comes from the click or the timer.
     Reload(bool),
+    Edit(bool),
 }
 
 #[doc(hidden)]
 pub struct PdmDashboardStatusRow {
     _interval: Interval,
     loading: bool,
+    edit: bool,
 }
 
 impl PdmDashboardStatusRow {
@@ -70,6 +78,7 @@ impl Component for PdmDashboardStatusRow {
         Self {
             _interval: Self::create_interval(ctx),
             loading: false,
+            edit: false,
         }
     }
 
@@ -81,6 +90,10 @@ impl Component for PdmDashboardStatusRow {
                 self.loading = true;
                 true
             }
+            Msg::Edit(edit) => {
+                self.edit = edit;
+                true
+            }
         }
     }
 
@@ -121,6 +134,20 @@ impl Component for PdmDashboardStatusRow {
                 None => tr!("Now refreshing"),
             }))
             .with_flex_spacer()
+            .with_optional_child(props.on_edit_toggle.clone().map(|on_edit_toggle| {
+                let (icon, tooltip, new_value) = if self.edit {
+                    ("fa fa-check", tr!("Finish Editing"), false)
+                } else {
+                    ("fa fa-pencil", tr!("Edit"), true)
+                };
+                Tooltip::new(ActionIcon::new(icon).tabindex(0).on_activate({
+                    ctx.link().callback(move |_| {
+                        on_edit_toggle.emit(new_value);
+                        Msg::Edit(new_value)
+                    })
+                }))
+                .tip(tooltip)
+            }))
             .with_child(
                 Tooltip::new(
                     ActionIcon::new("fa fa-cogs")
diff --git a/ui/src/dashboard/view.rs b/ui/src/dashboard/view.rs
index fb46064c..5239878d 100644
--- a/ui/src/dashboard/view.rs
+++ b/ui/src/dashboard/view.rs
@@ -20,9 +20,7 @@ use crate::dashboard::refresh_config_edit::{
 };
 use crate::dashboard::tasks::get_task_options;
 use crate::dashboard::types::RowWidget;
-use crate::dashboard::types::{
-    LeaderboardType, TaskSummaryGrouping, ViewLayout, ViewTemplate, WidgetType,
-};
+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,
@@ -41,6 +39,8 @@ use pdm_client::types::TopEntities;
 mod row_view;
 pub use row_view::RowView;
 
+mod row_element;
+
 #[derive(Properties, PartialEq)]
 pub struct View {
     view: AttrValue,
@@ -74,6 +74,8 @@ pub enum Msg {
     Reload(bool),       // force
     ConfigWindow(bool), // show
     UpdateConfig(RefreshConfig),
+    EditMode(bool),
+    LayoutUpdate(ViewLayout),
 }
 
 struct ViewComp {
@@ -92,6 +94,8 @@ struct ViewComp {
     load_finished_time: Option<f64>,
     show_config_window: bool,
     show_create_wizard: Option<RemoteType>,
+
+    edit_mode: bool,
 }
 
 fn render_widget(
@@ -271,6 +275,8 @@ impl Component for ViewComp {
             loading: true,
             show_config_window: false,
             show_create_wizard: None,
+
+            edit_mode: false,
         }
     }
 
@@ -326,6 +332,15 @@ impl Component for ViewComp {
 
                 self.show_config_window = false;
             }
+            Msg::EditMode(edit) => {
+                self.edit_mode = edit;
+            }
+            Msg::LayoutUpdate(view_layout) => {
+                // FIXME: update backend layout
+                if let Some(template) = &mut self.template.data {
+                    template.layout = view_layout;
+                }
+            }
         }
         true
     }
@@ -345,46 +360,56 @@ impl Component for ViewComp {
         }
         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)),
+                    )
+                    .on_edit_toggle(ctx.link().callback(Msg::EditMode)),
+                ),
         );
         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(),
+                            )
+                        }
+                    })
+                    .edit_mode(self.edit_mode)
+                    .on_update_layout(ctx.link().callback(Msg::LayoutUpdate)),
+                );
             }
             None => {}
         }
@@ -485,6 +510,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..adf7733f 100644
--- a/ui/src/dashboard/view/row_view.rs
+++ b/ui/src/dashboard/view/row_view.rs
@@ -1,21 +1,37 @@
 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;
 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::widget::menu::{Menu, MenuButton, MenuItem};
+use pwt::widget::{Button, Column, Container, Row};
 use pwt_macros::builder;
 
-use crate::dashboard::types::RowWidget;
+use crate::dashboard::types::{RowWidget, ViewLayout, WidgetType};
+use crate::dashboard::view::row_element::RowElement;
+
+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]
+    edit_mode: bool,
+
+    #[prop_or_default]
+    #[builder_cb(IntoEventCallback, into_event_callback, ViewLayout)]
+    on_update_layout: Option<Callback<ViewLayout>>,
 }
 
 impl RowView {
@@ -33,6 +49,21 @@ impl From<RowView> for VNode {
     }
 }
 
+pub enum DragMsg {
+    Start(Position),
+    End,
+    Enter(Position),
+    EnterDebounced(Position),
+}
+pub enum Msg {
+    DragEvent(DragMsg),
+    AddRow,
+    RemoveRow(usize), // idx
+    EditFlex(Position, u32),
+    AddWidget(Position, WidgetType),
+    RemoveWidget(Position),
+}
+
 #[derive(Clone, Copy, Debug, PartialEq)]
 /// Represents the position of a widget in a row view
 pub struct Position {
@@ -42,6 +73,12 @@ 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
 }
 
 fn extract_row_layout(rows: &Vec<Vec<RowWidget>>) -> Vec<Vec<(Position, RowWidget)>> {
@@ -65,7 +102,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 +112,109 @@ impl Component for RowViewComp {
         for (row_idx, row) in current_layout.iter().enumerate() {
             next_row_indices.insert(row_idx, row.len());
         }
-        Self { current_layout }
+        Self {
+            new_layout: None,
+            current_layout,
+            dragging: None,
+            dragging_target: None,
+            drag_timeout: None,
+            next_row_indices,
+        }
+    }
+
+    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::Enter(coords) => {
+                        let link = ctx.link().clone();
+                        self.drag_timeout = Some(Timeout::new(100, move || {
+                            link.send_message(Msg::DragEvent(DragMsg::EnterDebounced(coords)));
+                        }));
+                    }
+                    DragMsg::EnterDebounced(coords) => {
+                        // FIXME: only change when item is in correct place,
+                        // e.g. when the mouse position is such that it's inside the space
+                        // where the item would land
+                        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);
+            }
+        }
+        true
     }
 
     fn changed(&mut self, ctx: &Context<Self>, old_props: &Self::Properties) -> bool {
         let props = ctx.props();
-
+        if !props.edit_mode && old_props.edit_mode {
+            if let Some(on_update_layout) = &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 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;
+            }
+            if !props.edit_mode {
+                self.dragging = None;
+                self.dragging_target = None;
+                self.drag_timeout = None;
+            }
         }
 
         true
@@ -90,8 +222,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() && props.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 +239,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 +247,251 @@ 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(props.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", props.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(props.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)))
+                    // necessary for drop event to trigger
+                    .ondragover(|event: DragEvent| event.prevent_default())
+                    .ondragenter(
+                        ctx.link()
+                            .callback(move |_| Msg::DragEvent(DragMsg::Enter(current_coords))),
+                    )
+                    .onpointerenter(
+                        (self.dragging.is_some() && props.edit_mode).then_some(
+                            ctx.link()
+                                .callback(move |_| Msg::DragEvent(DragMsg::Enter(current_coords))),
+                        ),
+                    )
+                    .ondrop(ctx.link().callback(|event: DragEvent| {
+                        event.prevent_default();
+                        Msg::DragEvent(DragMsg::End)
+                    }));
                 row.add_child(row_element);
             }
 
+            if props.edit_mode {
+                let drop_coords = Position {
+                    row: row_idx,
+                    item: items.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() && props.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)),
+                                        ),
+                                ),
+                        ),
+                );
+            }
             row.add_child(
                 Container::new()
                     .key(format!("spacer-{row_idx}"))
                     .style("flex", "1 1 100%"),
             );
         }
-
+        if props.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()
     }
 }
+
+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] 22+ messages in thread
end of thread, other threads:[~2025-10-31 12:48 UTC | newest]
Thread overview: 22+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2025-10-31 12:43 [pdm-devel] [PATCH datacenter-manager v3 00/21] prepare ui for customizable views Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 01/21] ui: dashboard: refactor guest panel creation to its own module Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 02/21] ui: dashboard: refactor creating the node panel into " Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 03/21] ui: dashboard: node panel: make remote type optional Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 04/21] ui: dashboard: refactor remote panel creation into its own module Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 05/21] ui: dashboard: remote panel: make wizard menu optional Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 06/21] ui: dashboard: refactor sdn panel creation into its own module Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 07/21] ui: dashboard: refactor task summary panel creation to " Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 08/21] ui: dashboard: task summary: disable virtual scrolling Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 09/21] ui: dashboard: refactor subscription panel creation to its own module Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 10/21] ui: dashboard: refactor top entities " Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 11/21] ui: dashboard: refactor DashboardConfig editing/constants to their module Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 12/21] ui: dashboard: factor out task parameter calculation Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 13/21] ui: dashboard: pbs datastores panel: refactor creation into own module Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 14/21] ui: dashboard: remove unused remote list Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 15/21] ui: dashboard: status row: make loading less jarring Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 16/21] ui: introduce `LoadResult` helper type Dominik Csapak
2025-10-31 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 17/21] ui: dashboard: implement 'View' Dominik Csapak
2025-10-31 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 18/21] ui: dashboard: use 'View' instead of the Dashboard Dominik Csapak
2025-10-31 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 19/21] ui: dashboard: subscription info: move subscription loading to view Dominik Csapak
2025-10-31 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 20/21] ui: dashboard: use SharedState for create_*_panel Dominik Csapak
2025-10-31 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 21/21] ui: dashboard: enable editing view 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.