From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id 0DA7B1FF13A for ; Wed, 01 Apr 2026 13:34:26 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 842D618BC9; Wed, 1 Apr 2026 13:34:54 +0200 (CEST) Content-Type: text/plain; charset=UTF-8 Date: Wed, 01 Apr 2026 13:34:41 +0200 Message-Id: Subject: Re: [PATCH datacenter-manager v2 3/4] ui: dashboard: add new gauge panels widget type From: "Lukas Wagner" To: "Dominik Csapak" , Mime-Version: 1.0 Content-Transfer-Encoding: quoted-printable X-Mailer: aerc 0.21.0-0-g5549850facc2-dirty References: <20260330131044.693709-1-d.csapak@proxmox.com> <20260330131044.693709-4-d.csapak@proxmox.com> In-Reply-To: <20260330131044.693709-4-d.csapak@proxmox.com> X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1775043224133 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.051 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: 7V62DCZMCYJBKYIUVYZ7YFDAOOPIYW5L X-Message-ID-Hash: 7V62DCZMCYJBKYIUVYZ7YFDAOOPIYW5L X-MailFrom: l.wagner@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: Hi Dominik, thanks for the patch! Some notes inline, along with a proposed patch that you can squash in if you like at the end. On Mon Mar 30, 2026 at 3:07 PM CEST, Dominik Csapak wrote: > This adds gauge chart panels for cpu/memory/storage. Either individually > or combined all three. For either PVE hosts, PBS hosts, or combined. > > We use the new pie chart component for this. > > Signed-off-by: Dominik Csapak > --- > lib/pdm-api-types/src/views.rs | 15 +++ > ui/src/dashboard/gauge_panel.rs | 160 ++++++++++++++++++++++++++++++ > ui/src/dashboard/mod.rs | 3 + > ui/src/dashboard/view.rs | 9 +- > ui/src/dashboard/view/row_view.rs | 43 +++++++- > 5 files changed, 227 insertions(+), 3 deletions(-) > create mode 100644 ui/src/dashboard/gauge_panel.rs > > diff --git a/lib/pdm-api-types/src/views.rs b/lib/pdm-api-types/src/views= .rs > index 5e9b4b31..50181e72 100644 > --- a/lib/pdm-api-types/src/views.rs > +++ b/lib/pdm-api-types/src/views.rs > @@ -266,6 +266,14 @@ pub struct RowWidget { > pub r#type: WidgetType, > } > =20 > +#[derive(Serialize, Deserialize, PartialEq, Clone, Copy)] > +#[serde(rename_all =3D "kebab-case")] > +pub enum NodeResourceType { > + Cpu, > + Memory, > + Storage, > +} nit: missing doc comments for a public type. > + > #[derive(Serialize, Deserialize, PartialEq, Clone)] > #[serde(rename_all =3D "kebab-case")] > #[serde(tag =3D "widget-type")] > @@ -295,6 +303,13 @@ pub enum WidgetType { > grouping: TaskSummaryGrouping, > }, > ResourceTree, > + #[serde(rename_all =3D "kebab-case")] nit: missing doc-comments for this public enum variant > + NodeResourceGauge { > + #[serde(skip_serializing_if =3D "Option::is_none")] > + resource: Option, > + #[serde(skip_serializing_if =3D "Option::is_none")] > + remote_type: Option, > + }, > } > =20 > #[derive(Serialize, Deserialize, PartialEq, Clone, Copy)] > diff --git a/ui/src/dashboard/gauge_panel.rs b/ui/src/dashboard/gauge_pan= el.rs > new file mode 100644 > index 00000000..8b30eb0f > --- /dev/null > +++ b/ui/src/dashboard/gauge_panel.rs > @@ -0,0 +1,160 @@ > +use anyhow::Error; > + > +use proxmox_human_byte::HumanByte; > +use pwt::css; > +use pwt::prelude::*; > +use pwt::state::SharedState; > +use pwt::widget::Fa; > +use pwt::widget::{charts::PieChart, Panel}; > +use pwt::widget::{error_message, Column, Container, Row}; > + > +use pdm_api_types::remotes::RemoteType; > +use pdm_api_types::{resource::ResourcesStatus, views::NodeResourceType}; > + > +use crate::dashboard::{create_title_with_icon, loading_column}; > +use crate::LoadResult; > + This is a pub fn and should therefore have at least some basic documentatio= n. > +pub fn create_gauge_panel( > + resource_type: Option, > + remote_type: Option, > + status: SharedState>, > +) -> Panel { > + let status =3D status.read(); > + let (show_cpu, show_mem, show_storage, title, subtitle, icon) =3D ma= tch resource_type { > + Some(NodeResourceType::Cpu) =3D> (true, false, false, tr!("CPU U= sage"), false, "cpu"), > + Some(NodeResourceType::Memory) =3D> { > + (false, true, false, tr!("Memory Usage"), false, "memory") > + } > + Some(NodeResourceType::Storage) =3D> { > + (false, false, true, tr!("Storage Usage"), false, "database"= ) > + } > + None =3D> (true, true, true, tr!("Resource Usage"), true, "tacho= meter"), > + }; I find this tuple-based approach a bit hard to read, one has jump around between the 'let (....)' and the returned tuple to see what is going on. Having 4 different booleans in the assignment could also lead to subtle logic bugs if one is not careful when changing this code. I think it could be a nice idea to move these local variables into a struct= . See the end of this message for a full patch. It's quite a bit more verbose, but personally I think its nicer than tuple destructuring with such a large amount of parameters - plus it makes the actual create_gauge_panel function a bit shorter. Feel free to squash it in if you agree. > + > + let suffix =3D match remote_type { > + Some(RemoteType::Pve) =3D> " - Virtual Environment", > + Some(RemoteType::Pbs) =3D> " - Backup Server", > + None =3D> "", > + }; > + > + let is_loading =3D !status.has_data(); > + > + Panel::new() > + .title(create_title_with_icon(icon, format!("{title}{suffix}"))) > + .border(true) > + .with_optional_child(status.data.as_ref().map(|data| { > + let (cpu, mem, storage) =3D match remote_type { > + Some(RemoteType::Pve) =3D> ( > + show_cpu.then_some((data.pve_cpu_stats.used, data.pv= e_cpu_stats.max)), > + show_mem.then_some((data.pve_memory_stats.used, data= .pve_memory_stats.total)), > + show_storage > + .then_some((data.pve_storage_stats.used, data.pv= e_storage_stats.total)), > + ), > + Some(RemoteType::Pbs) =3D> ( > + show_cpu.then_some((data.pbs_cpu_stats.used, data.pb= s_cpu_stats.max)), > + show_mem.then_some((data.pbs_memory_stats.used, data= .pbs_memory_stats.total)), > + show_storage > + .then_some((data.pbs_storage_stats.used, data.pb= s_storage_stats.total)), > + ), > + None =3D> ( > + show_cpu.then_some(( > + data.pve_cpu_stats.used + data.pbs_cpu_stats.use= d, > + data.pve_cpu_stats.max + data.pbs_cpu_stats.max, > + )), > + show_mem.then_some(( > + data.pve_memory_stats.used + data.pbs_memory_sta= ts.used, > + data.pve_memory_stats.total + data.pbs_memory_st= ats.total, > + )), > + show_storage.then_some(( > + data.pve_storage_stats.used + data.pbs_storage_s= tats.used, > + data.pve_storage_stats.total + data.pbs_storage_= stats.total, > + )), > + ), > + }; > + > + let chart =3D |percentage: f64, icon: Fa, title: String, ext= ra_text: String| -> Column { > + let subtitle =3D subtitle.then_some( > + Row::new() > + .gap(1) > + .class(css::AlignItems::Center) > + .class(css::JustifyContent::Center) > + .with_child(icon) > + .with_child(&title), > + ); > + Column::new() > + .flex(1.0) > + .width("0") // correct flex base size for calculatio= n > + .max_height(250) > + .with_child( > + PieChart::new(title, percentage) > + .class(css::Overflow::Auto) > + .text(format!("{:.0}%", percentage * 100.)) > + .angle_start(75.0) > + .angle_end(285.0) > + .show_tooltip(false), > + ) > + .with_optional_child(subtitle) > + .with_child( > + Container::new() > + .padding_top(1) > + .class(css::TextAlign::Center) > + .with_child(extra_text), > + ) > + }; > + > + Row::new() > + .padding(4) > + .with_optional_child(cpu.map(|(used, total)| { > + let pct =3D if total =3D=3D 0.0 { 0.0 } else { used = / total }; > + let extra_text =3D match remote_type { > + Some(RemoteType::Pve) =3D> { > + tr!( > + "{0} of {1} cores ({2} allocated)", > + format!("{used:.2}"), > + format!("{total:.0}"), > + format!("{:.0}", data.pve_cpu_stats.allo= cated.unwrap_or(0.0)), > + ) > + } > + _ =3D> { > + tr!( > + "{0} of {1} cores", > + format!("{used:.2}"), > + format!("{total:.0}") > + ) > + } > + }; > + chart(pct, Fa::new("cpu"), tr!("CPU"), extra_text) > + })) > + .with_optional_child(mem.map(|(used, total)| { > + chart( > + if total =3D=3D 0 { > + 0.0 > + } else { > + used as f64 / total as f64 > + }, > + Fa::new("memory"), > + tr!("Memory"), > + tr!("{0} of {1}", HumanByte::from(used), HumanBy= te::from(total)), > + ) > + })) > + .with_optional_child(storage.map(|(used, total)| { > + chart( > + if total =3D=3D 0 { > + 0.0 > + } else { > + used as f64 / total as f64 > + }, > + Fa::new("database"), > + tr!("Storage"), > + tr!("{0} of {1}", HumanByte::from(used), HumanBy= te::from(total)), > + ) > + })) > + })) > + .with_optional_child(is_loading.then_some(loading_column())) > + .with_optional_child( > + status > + .error > + .as_ref() > + .map(|err| error_message(&err.to_string())), > + ) > +} > diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs > index 17e5ccd3..194000c2 100644 > --- a/ui/src/dashboard/mod.rs > +++ b/ui/src/dashboard/mod.rs > @@ -14,6 +14,9 @@ pub use subscriptions_list::SubscriptionsList; > mod remote_panel; > pub use remote_panel::create_remote_panel; > =20 > +mod gauge_panel; > +pub use gauge_panel::create_gauge_panel; > + > mod guest_panel; > pub use guest_panel::create_guest_panel; > =20 > diff --git a/ui/src/dashboard/view.rs b/ui/src/dashboard/view.rs > index 4a0400ac..eb1a348e 100644 > --- a/ui/src/dashboard/view.rs > +++ b/ui/src/dashboard/view.rs > @@ -22,7 +22,7 @@ use crate::dashboard::refresh_config_edit::{ > use crate::dashboard::subscription_info::create_subscriptions_dialog; > use crate::dashboard::tasks::get_task_options; > use crate::dashboard::{ > - create_guest_panel, create_node_panel, create_pbs_datastores_panel, > + create_gauge_panel, create_guest_panel, create_node_panel, create_pb= s_datastores_panel, > create_refresh_config_edit_window, create_remote_panel, create_resou= rce_tree, create_sdn_panel, > create_subscription_panel, create_task_summary_panel, create_top_ent= ities_panel, > DashboardStatusRow, > @@ -167,6 +167,10 @@ fn render_widget( > create_task_summary_panel(statistics, remotes, hours, since) > } > WidgetType::ResourceTree =3D> create_resource_tree(redraw_contro= ller), > + WidgetType::NodeResourceGauge { > + resource, > + remote_type, > + } =3D> create_gauge_panel(*resource, *remote_type, status), > }; > =20 > if let Some(title) =3D &item.title { > @@ -268,7 +272,8 @@ fn required_api_calls(layout: &ViewLayout) -> (bool, = bool, bool) { > | WidgetType::Guests { .. } > | WidgetType::Remotes { .. } > | WidgetType::Sdn > - | WidgetType::PbsDatastores =3D> { > + | WidgetType::PbsDatastores > + | WidgetType::NodeResourceGauge { .. } =3D> { > status =3D true; > } > WidgetType::Subscription =3D> { > diff --git a/ui/src/dashboard/view/row_view.rs b/ui/src/dashboard/view/ro= w_view.rs > index 673b4627..f220f954 100644 > --- a/ui/src/dashboard/view/row_view.rs > +++ b/ui/src/dashboard/view/row_view.rs > @@ -21,7 +21,7 @@ use crate::dashboard::view::EditingMessage; > =20 > use pdm_api_types::remotes::RemoteType; > use pdm_api_types::views::{ > - LeaderboardType, RowWidget, TaskSummaryGrouping, ViewLayout, WidgetT= ype, > + LeaderboardType, NodeResourceType, RowWidget, TaskSummaryGrouping, V= iewLayout, WidgetType, > }; > =20 > #[derive(Properties, PartialEq)] > @@ -539,6 +539,35 @@ fn create_menu(ctx: &yew::Context, new_= coords: Position) -> Menu { > ctx.link() > .callback(move |_| Msg::AddWidget(new_coords, widget.clone()= )) > }; > + let create_gauge_menu =3D |remote_type: Option| -> Menu = { > + Menu::new() > + .with_item( > + MenuItem::new(tr!("All Resources")).on_select(create_cal= lback( > + WidgetType::NodeResourceGauge { > + resource: None, > + remote_type, > + }, > + )), > + ) > + .with_item(MenuItem::new(tr!("CPU")).on_select(create_callba= ck( > + WidgetType::NodeResourceGauge { > + resource: Some(NodeResourceType::Cpu), > + remote_type, > + }, > + ))) > + .with_item(MenuItem::new(tr!("Memory")).on_select(create_cal= lback( > + WidgetType::NodeResourceGauge { > + resource: Some(NodeResourceType::Memory), > + remote_type, > + }, > + ))) > + .with_item(MenuItem::new(tr!("Storage")).on_select(create_ca= llback( > + WidgetType::NodeResourceGauge { > + resource: Some(NodeResourceType::Storage), > + remote_type, > + }, > + ))) > + }; > Menu::new() > .with_item( > MenuItem::new(tr!("Remote Panel")) > @@ -642,4 +671,16 @@ fn create_menu(ctx: &yew::Context, new_= coords: Position) -> Menu { > MenuItem::new(tr!("Resource Tree")) > .on_select(create_callback(WidgetType::ResourceTree)), > ) > + .with_item( > + MenuItem::new(tr!("Resource Usage")).menu( > + Menu::new() > + .with_item(MenuItem::new(tr!("All")).menu(create_gau= ge_menu(None))) > + .with_item( > + MenuItem::new(tr!("PVE")).menu(create_gauge_menu= (Some(RemoteType::Pve))), > + ) > + .with_item( > + MenuItem::new(tr!("PBS")).menu(create_gauge_menu= (Some(RemoteType::Pbs))), > + ), > + ), > + ) > } >>From f8e9e10c8a12dfd2cc03450c1ccb6cf6d978d94e Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Wed, 1 Apr 2026 13:12:15 +0200 Subject: [PATCH datacenter-manager] extract local vars to struct Signed-off-by: Lukas Wagner --- ui/src/dashboard/gauge_panel.rs | 94 +++++++++++++++++++++++++-------- 1 file changed, 73 insertions(+), 21 deletions(-) diff --git a/ui/src/dashboard/gauge_panel.rs b/ui/src/dashboard/gauge_panel= .rs index 8b30eb0f..a88f56cb 100644 --- a/ui/src/dashboard/gauge_panel.rs +++ b/ui/src/dashboard/gauge_panel.rs @@ -14,22 +14,61 @@ use pdm_api_types::{resource::ResourcesStatus, views::N= odeResourceType}; use crate::dashboard::{create_title_with_icon, loading_column}; use crate::LoadResult; =20 +struct PanelConfig { + show_cpu: bool, + show_mem: bool, + show_storage: bool, + title: String, + subtitle: bool, + icon: &'static str, +} + +impl PanelConfig { + fn new(resource_type: Option) -> Self { + match resource_type { + Some(NodeResourceType::Cpu) =3D> PanelConfig { + show_cpu: true, + show_mem: false, + show_storage: false, + title: tr!("CPU Usage"), + subtitle: false, + icon: "cpu", + }, + Some(NodeResourceType::Memory) =3D> PanelConfig { + show_cpu: false, + show_mem: true, + show_storage: false, + title: tr!("Memory Usage"), + subtitle: false, + icon: "memory", + }, + Some(NodeResourceType::Storage) =3D> PanelConfig { + show_cpu: false, + show_mem: false, + show_storage: true, + title: tr!("Storage Usage"), + subtitle: false, + icon: "database", + }, + None =3D> PanelConfig { + show_cpu: true, + show_mem: true, + show_storage: true, + title: tr!("Resource Usage"), + subtitle: true, + icon: "tachometer", + }, + } + } +} + pub fn create_gauge_panel( resource_type: Option, remote_type: Option, status: SharedState>, ) -> Panel { let status =3D status.read(); - let (show_cpu, show_mem, show_storage, title, subtitle, icon) =3D matc= h resource_type { - Some(NodeResourceType::Cpu) =3D> (true, false, false, tr!("CPU Usa= ge"), false, "cpu"), - Some(NodeResourceType::Memory) =3D> { - (false, true, false, tr!("Memory Usage"), false, "memory") - } - Some(NodeResourceType::Storage) =3D> { - (false, false, true, tr!("Storage Usage"), false, "database") - } - None =3D> (true, true, true, tr!("Resource Usage"), true, "tachome= ter"), - }; + let panel_config =3D PanelConfig::new(resource_type); =20 let suffix =3D match remote_type { Some(RemoteType::Pve) =3D> " - Virtual Environment", @@ -40,32 +79,45 @@ pub fn create_gauge_panel( let is_loading =3D !status.has_data(); =20 Panel::new() - .title(create_title_with_icon(icon, format!("{title}{suffix}"))) + .title(create_title_with_icon( + panel_config.icon, + format!("{}{suffix}", panel_config.title), + )) .border(true) .with_optional_child(status.data.as_ref().map(|data| { let (cpu, mem, storage) =3D match remote_type { Some(RemoteType::Pve) =3D> ( - show_cpu.then_some((data.pve_cpu_stats.used, data.pve_= cpu_stats.max)), - show_mem.then_some((data.pve_memory_stats.used, data.p= ve_memory_stats.total)), - show_storage + panel_config + .show_cpu + .then_some((data.pve_cpu_stats.used, data.pve_cpu_= stats.max)), + panel_config + .show_mem + .then_some((data.pve_memory_stats.used, data.pve_m= emory_stats.total)), + panel_config + .show_storage .then_some((data.pve_storage_stats.used, data.pve_= storage_stats.total)), ), Some(RemoteType::Pbs) =3D> ( - show_cpu.then_some((data.pbs_cpu_stats.used, data.pbs_= cpu_stats.max)), - show_mem.then_some((data.pbs_memory_stats.used, data.p= bs_memory_stats.total)), - show_storage + panel_config + .show_cpu + .then_some((data.pbs_cpu_stats.used, data.pbs_cpu_= stats.max)), + panel_config + .show_mem + .then_some((data.pbs_memory_stats.used, data.pbs_m= emory_stats.total)), + panel_config + .show_storage .then_some((data.pbs_storage_stats.used, data.pbs_= storage_stats.total)), ), None =3D> ( - show_cpu.then_some(( + panel_config.show_cpu.then_some(( data.pve_cpu_stats.used + data.pbs_cpu_stats.used, data.pve_cpu_stats.max + data.pbs_cpu_stats.max, )), - show_mem.then_some(( + panel_config.show_mem.then_some(( data.pve_memory_stats.used + data.pbs_memory_stats= .used, data.pve_memory_stats.total + data.pbs_memory_stat= s.total, )), - show_storage.then_some(( + panel_config.show_storage.then_some(( data.pve_storage_stats.used + data.pbs_storage_sta= ts.used, data.pve_storage_stats.total + data.pbs_storage_st= ats.total, )), @@ -73,7 +125,7 @@ pub fn create_gauge_panel( }; =20 let chart =3D |percentage: f64, icon: Fa, title: String, extra= _text: String| -> Column { - let subtitle =3D subtitle.then_some( + let subtitle =3D panel_config.subtitle.then_some( Row::new() .gap(1) .class(css::AlignItems::Center) --=20 2.47.3