From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id 733D11FF136 for ; Mon, 23 Mar 2026 12:07:22 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id B05D612B6A; Mon, 23 Mar 2026 12:07:41 +0100 (CET) From: Dominik Csapak To: pdm-devel@lists.proxmox.com Subject: [PATCH datacenter-manager 3/4] ui: dashboard: add new gauge panels widget type Date: Mon, 23 Mar 2026 12:03:40 +0100 Message-ID: <20260323110728.1500528-4-d.csapak@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260323110728.1500528-1-d.csapak@proxmox.com> References: <20260323110728.1500528-1-d.csapak@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL 0.041 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: WRC3DJHJBGG26BUCXLBX522G3SRFJY4R X-Message-ID-Hash: WRC3DJHJBGG26BUCXLBX522G3SRFJY4R X-MailFrom: d.csapak@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: 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 | 156 ++++++++++++++++++++++++++++++ ui/src/dashboard/mod.rs | 3 + ui/src/dashboard/view.rs | 9 +- ui/src/dashboard/view/row_view.rs | 43 +++++++- 5 files changed, 223 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, } +#[derive(Serialize, Deserialize, PartialEq, Clone, Copy)] +#[serde(rename_all = "kebab-case")] +pub enum NodeResourceType { + Cpu, + Memory, + Storage, +} + #[derive(Serialize, Deserialize, PartialEq, Clone)] #[serde(rename_all = "kebab-case")] #[serde(tag = "widget-type")] @@ -295,6 +303,13 @@ pub enum WidgetType { grouping: TaskSummaryGrouping, }, ResourceTree, + #[serde(rename_all = "kebab-case")] + NodeResourceGauge { + #[serde(skip_serializing_if = "Option::is_none")] + resource: Option, + #[serde(skip_serializing_if = "Option::is_none")] + remote_type: Option, + }, } #[derive(Serialize, Deserialize, PartialEq, Clone, Copy)] diff --git a/ui/src/dashboard/gauge_panel.rs b/ui/src/dashboard/gauge_panel.rs new file mode 100644 index 00000000..89094f65 --- /dev/null +++ b/ui/src/dashboard/gauge_panel.rs @@ -0,0 +1,156 @@ +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; + +pub fn create_gauge_panel( + resource_type: Option, + remote_type: Option, + status: SharedState>, +) -> Panel { + let status = status.read(); + let (show_cpu, show_mem, show_storage, title, subtitle, icon) = match resource_type { + Some(NodeResourceType::Cpu) => (true, false, false, tr!("CPU Usage"), false, "cpu"), + Some(NodeResourceType::Memory) => { + (false, true, false, tr!("Memory Usage"), false, "memory") + } + Some(NodeResourceType::Storage) => { + (false, false, true, tr!("Storage Usage"), false, "database") + } + None => (true, true, true, tr!("Resource Usage"), true, "tachometer"), + }; + + let suffix = match remote_type { + Some(RemoteType::Pve) => " - PVE", + Some(RemoteType::Pbs) => " - PBS", + None => "", + }; + + let is_loading = !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) = match remote_type { + Some(RemoteType::Pve) => ( + show_cpu.then_some((data.pve_cpu_stats.used, data.pve_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.pve_storage_stats.total)), + ), + Some(RemoteType::Pbs) => ( + show_cpu.then_some((data.pbs_cpu_stats.used, data.pbs_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.pbs_storage_stats.total)), + ), + None => ( + 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(( + data.pve_memory_stats.used + data.pbs_memory_stats.used, + data.pve_memory_stats.total + data.pbs_memory_stats.total, + )), + show_storage.then_some(( + data.pve_storage_stats.used + data.pbs_storage_stats.used, + data.pve_storage_stats.total + data.pbs_storage_stats.total, + )), + ), + }; + + let chart = |percentage: f64, icon: Fa, title: String, extra_text: String| -> Column { + Column::new() + .flex(1.0) + .width("0") // correct flex base size for calculation + .max_height(250) + .with_optional_child( + subtitle.then_some( + Row::new() + .gap(1) + .class(css::AlignItems::Center) + .class(css::JustifyContent::Center) + .with_child(icon) + .with_child(&title), + ), + ) + .with_child( + PieChart::new(title, percentage) + .class(css::Overflow::Auto) + .text(format!("{:.2}%", percentage * 100.)) + .angle_start(45.0) + .angle_end(315.0) + .show_tooltip(false), + ) + .with_child( + Container::new() + .class(css::TextAlign::Center) + .with_child(extra_text), + ) + }; + + Row::new() + .padding(4) + .with_optional_child(cpu.map(|(used, total)| { + let pct = if total == 0.0 { 0.0 } else { used / total }; + let extra_text = match remote_type { + Some(RemoteType::Pve) => { + tr!( + "{0} of {1} ({2} allocated)", + format!("{used:.2}"), + format!("{total:.0}"), + format!("{:.0}", data.pve_cpu_stats.allocated.unwrap_or(0.0)), + ) + } + _ => { + tr!("{0} of {1}", 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 == 0 { + 0.0 + } else { + used as f64 / total as f64 + }, + Fa::new("memory"), + tr!("Memory"), + tr!("{0} of {1}", HumanByte::from(used), HumanByte::from(total)), + ) + })) + .with_optional_child(storage.map(|(used, total)| { + chart( + if total == 0 { + 0.0 + } else { + used as f64 / total as f64 + }, + Fa::new("database"), + tr!("Storage"), + tr!("{0} of {1}", HumanByte::from(used), HumanByte::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; +mod gauge_panel; +pub use gauge_panel::create_gauge_panel; + mod guest_panel; pub use guest_panel::create_guest_panel; 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_pbs_datastores_panel, create_refresh_config_edit_window, create_remote_panel, create_resource_tree, create_sdn_panel, create_subscription_panel, create_task_summary_panel, create_top_entities_panel, DashboardStatusRow, @@ -167,6 +167,10 @@ fn render_widget( create_task_summary_panel(statistics, remotes, hours, since) } WidgetType::ResourceTree => create_resource_tree(redraw_controller), + WidgetType::NodeResourceGauge { + resource, + remote_type, + } => create_gauge_panel(*resource, *remote_type, status), }; if let Some(title) = &item.title { @@ -268,7 +272,8 @@ fn required_api_calls(layout: &ViewLayout) -> (bool, bool, bool) { | WidgetType::Guests { .. } | WidgetType::Remotes { .. } | WidgetType::Sdn - | WidgetType::PbsDatastores => { + | WidgetType::PbsDatastores + | WidgetType::NodeResourceGauge { .. } => { status = true; } WidgetType::Subscription => { diff --git a/ui/src/dashboard/view/row_view.rs b/ui/src/dashboard/view/row_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; use pdm_api_types::remotes::RemoteType; use pdm_api_types::views::{ - LeaderboardType, RowWidget, TaskSummaryGrouping, ViewLayout, WidgetType, + LeaderboardType, NodeResourceType, RowWidget, TaskSummaryGrouping, ViewLayout, WidgetType, }; #[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 = |remote_type: Option| -> Menu { + Menu::new() + .with_item( + MenuItem::new(tr!("All Resources")).on_select(create_callback( + WidgetType::NodeResourceGauge { + resource: None, + remote_type, + }, + )), + ) + .with_item(MenuItem::new(tr!("CPU")).on_select(create_callback( + WidgetType::NodeResourceGauge { + resource: Some(NodeResourceType::Cpu), + remote_type, + }, + ))) + .with_item(MenuItem::new(tr!("Memory")).on_select(create_callback( + WidgetType::NodeResourceGauge { + resource: Some(NodeResourceType::Memory), + remote_type, + }, + ))) + .with_item(MenuItem::new(tr!("Storage")).on_select(create_callback( + 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_gauge_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))), + ), + ), + ) } -- 2.47.3