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 4956E1FF13F for ; Thu, 12 Mar 2026 14:53:47 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id ADBEB17CF4; Thu, 12 Mar 2026 14:53:42 +0100 (CET) From: Lukas Wagner To: pdm-devel@lists.proxmox.com Subject: [PATCH datacenter-manager 25/26] ui: node status: add RRD graphs for PDM host metrics Date: Thu, 12 Mar 2026 14:52:26 +0100 Message-ID: <20260312135229.420729-26-l.wagner@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260312135229.420729-1-l.wagner@proxmox.com> References: <20260312135229.420729-1-l.wagner@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1773323525300 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.047 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 RCVD_IN_MSPIKE_H2 0.001 Average reputation (+2) 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: 2TQAKXP2FHM4A4N5JWUWHABMCUYGODT5 X-Message-ID-Hash: 2TQAKXP2FHM4A4N5JWUWHABMCUYGODT5 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: This adds RRD graphs in the existing node status panel. We add graphs for - CPU/IOWait - Load-Avg - Memory usage - Network utilization - Pressure (CPU, memory, IO) Signed-off-by: Lukas Wagner --- ui/src/administration/node_status.rs | 312 ++++++++++++++++++++++++++- ui/src/renderer.rs | 49 +++++ 2 files changed, 354 insertions(+), 7 deletions(-) diff --git a/ui/src/administration/node_status.rs b/ui/src/administration/node_status.rs index a61f25a2..62684338 100644 --- a/ui/src/administration/node_status.rs +++ b/ui/src/administration/node_status.rs @@ -7,17 +7,28 @@ use proxmox_node_status::NodePowerCommand; use proxmox_time::epoch_i64; use proxmox_yew_comp::percent_encoding::percent_encode_component; use proxmox_yew_comp::utils::{copy_text_to_clipboard, render_epoch}; -use proxmox_yew_comp::{http_post, ConfirmButton, NodeStatusPanel}; -use pwt::prelude::*; +use proxmox_yew_comp::{ + http_post, ConfirmButton, NodeStatusPanel, RRDGraph, RRDGrid, RRDTimeframe, + RRDTimeframeSelector, Series, +}; +use pwt::css::JustifyContent; use pwt::widget::{Button, Column, Container, Row}; use pwt::AsyncAbortGuard; +use pwt::{prelude::*, AsyncPool}; -use crate::get_nodename; +use pdm_api_types::rrddata::PdmNodeDatapoint; + +use crate::{get_nodename, renderer}; #[derive(Properties, Clone, PartialEq)] -pub(crate) struct NodeStatus {} +pub(crate) struct NodeStatus { + #[prop_or(60_000)] + /// The interval for refreshing the rrd data + pub rrd_interval: u32, +} impl NodeStatus { + /// Create new [`NodeStatus`] panel. pub(crate) fn new() -> Self { yew::props!(Self {}) } @@ -31,20 +42,58 @@ impl From for VNode { enum Msg { Reload, + ReloadRrd, + UpdateRrdTimeframe(RRDTimeframe), Error(Error), RebootOrShutdown(NodePowerCommand), ShowSystemReport(bool), ShowPackageVersions(bool), + RrdLoadFinished(Result, proxmox_client::Error>), } struct PdmNodeStatus { + time_data: Rc>, + + cpu_data: Rc, + iowait_data: Rc, + load_data: Rc, + mem_data: Rc, + mem_total_data: Rc, + swap_data: Rc, + swap_total_data: Rc, + disk_usage_data: Rc, + disk_total_data: Rc, + disk_transfer_read_data: Rc, + disk_transfer_write_data: Rc, + disk_iops_read_data: Rc, + disk_iops_write_data: Rc, + cpu_pressure_some_data: Rc, + mem_pressure_some_data: Rc, + mem_pressure_full_data: Rc, + io_pressure_some_data: Rc, + io_pressure_full_data: Rc, + net_in: Rc, + net_out: Rc, + + rrd_time_frame: RRDTimeframe, error: Option, abort_guard: Option, show_system_report: bool, show_package_versions: bool, + + async_pool: AsyncPool, + _timeout: Option, } impl PdmNodeStatus { + async fn reload_rrd(rrd_time_frame: RRDTimeframe) -> Msg { + let res = crate::pdm_client() + .get_pdm_node_rrddata(rrd_time_frame.mode, rrd_time_frame.timeframe) + .await; + + Msg::RrdLoadFinished(res) + } + fn change_power_state(&mut self, ctx: &yew::Context, command: NodePowerCommand) { let link = ctx.link().clone(); self.abort_guard.replace(AsyncAbortGuard::spawn(async move { @@ -184,8 +233,37 @@ impl Component for PdmNodeStatus { type Message = Msg; type Properties = NodeStatus; - fn create(_ctx: &yew::Context) -> Self { + fn create(ctx: &yew::Context) -> Self { + ctx.link().send_message(Msg::ReloadRrd); + Self { + time_data: Rc::new(Vec::new()), + + cpu_data: empty_series(), + cpu_pressure_some_data: empty_series(), + mem_pressure_some_data: empty_series(), + mem_pressure_full_data: empty_series(), + io_pressure_some_data: empty_series(), + io_pressure_full_data: empty_series(), + iowait_data: empty_series(), + load_data: empty_series(), + mem_data: empty_series(), + mem_total_data: empty_series(), + swap_data: empty_series(), + swap_total_data: empty_series(), + net_in: empty_series(), + net_out: empty_series(), + disk_usage_data: empty_series(), + disk_total_data: empty_series(), + disk_transfer_read_data: empty_series(), + disk_transfer_write_data: empty_series(), + disk_iops_read_data: empty_series(), + disk_iops_write_data: empty_series(), + + async_pool: AsyncPool::new(), + _timeout: None, + + rrd_time_frame: RRDTimeframe::load(), error: None, abort_guard: None, show_system_report: false, @@ -212,6 +290,121 @@ impl Component for PdmNodeStatus { self.show_package_versions = show_package_versions; true } + Msg::ReloadRrd => { + self._timeout = None; + let timeframe = self.rrd_time_frame; + self.async_pool.send_future(ctx.link().clone(), async move { + Self::reload_rrd(timeframe).await + }); + true + } + Msg::RrdLoadFinished(res) => match res { + Ok(data_points) => { + self.error = None; + let mut cpu_vec = Vec::with_capacity(data_points.len()); + let mut cpu_pressure_some_vec = Vec::with_capacity(data_points.len()); + let mut iowait_vec = Vec::with_capacity(data_points.len()); + let mut load_vec = Vec::with_capacity(data_points.len()); + let mut mem_vec = Vec::with_capacity(data_points.len()); + let mut mem_total_vec = Vec::with_capacity(data_points.len()); + let mut swap_vec = Vec::with_capacity(data_points.len()); + let mut swap_total_vec = Vec::with_capacity(data_points.len()); + let mut mem_pressure_some_vec = Vec::with_capacity(data_points.len()); + let mut mem_pressure_full_vec = Vec::with_capacity(data_points.len()); + let mut io_pressure_some_vec = Vec::with_capacity(data_points.len()); + let mut io_pressure_full_vec = Vec::with_capacity(data_points.len()); + let mut time_vec = Vec::with_capacity(data_points.len()); + let mut net_in_vec = Vec::with_capacity(data_points.len()); + let mut net_out_vec = Vec::with_capacity(data_points.len()); + let mut disk_usage_vec = Vec::with_capacity(data_points.len()); + let mut disk_total_vec = Vec::with_capacity(data_points.len()); + let mut disk_transfer_read_vec = Vec::with_capacity(data_points.len()); + let mut disk_transfer_write_vec = Vec::with_capacity(data_points.len()); + let mut disk_iops_read_vec = Vec::with_capacity(data_points.len()); + let mut disk_iops_write_vec = Vec::with_capacity(data_points.len()); + + for data in data_points { + cpu_vec.push(data.cpu_current.unwrap_or(f64::NAN)); + iowait_vec.push(data.cpu_iowait.unwrap_or(f64::NAN)); + load_vec.push(data.cpu_avg1.unwrap_or(f64::NAN)); + cpu_pressure_some_vec + .push(data.cpu_pressure_some_avg10.unwrap_or(f64::NAN)); + mem_vec.push(data.mem_used.unwrap_or(f64::NAN)); + mem_total_vec.push(data.mem_total.unwrap_or(f64::NAN)); + swap_vec.push(data.swap_used.unwrap_or(f64::NAN)); + swap_total_vec.push(data.swap_total.unwrap_or(f64::NAN)); + mem_pressure_some_vec + .push(data.mem_pressure_some_avg10.unwrap_or(f64::NAN)); + mem_pressure_full_vec + .push(data.mem_pressure_full_avg10.unwrap_or(f64::NAN)); + net_in_vec.push(data.net_in.unwrap_or(f64::NAN)); + net_out_vec.push(data.net_out.unwrap_or(f64::NAN)); + io_pressure_some_vec.push(data.io_pressure_some_avg10.unwrap_or(f64::NAN)); + io_pressure_full_vec.push(data.io_pressure_full_avg10.unwrap_or(f64::NAN)); + + disk_total_vec.push(data.disk_total.unwrap_or(f64::NAN)); + disk_usage_vec.push(data.disk_used.unwrap_or(f64::NAN)); + disk_transfer_read_vec.push(data.disk_read.unwrap_or(f64::NAN)); + disk_transfer_write_vec.push(data.disk_write.unwrap_or(f64::NAN)); + + disk_iops_read_vec.push(data.disk_read_iops.unwrap_or(f64::NAN)); + disk_iops_write_vec.push(data.disk_write_iops.unwrap_or(f64::NAN)); + + time_vec.push(data.time as i64); + } + + self.cpu_data = Rc::new(Series::new(tr!("CPU usage"), cpu_vec)); + self.iowait_data = Rc::new(Series::new(tr!("IO delay"), iowait_vec)); + self.load_data = Rc::new(Series::new(tr!("Server Load"), load_vec)); + self.cpu_pressure_some_data = + Rc::new(Series::new(tr!("Some"), cpu_pressure_some_vec)); + self.mem_data = Rc::new(Series::new(tr!("Used Memory"), mem_vec)); + self.mem_total_data = Rc::new(Series::new(tr!("Total Memory"), mem_total_vec)); + self.swap_data = Rc::new(Series::new(tr!("Used Swap"), swap_vec)); + self.swap_total_data = Rc::new(Series::new(tr!("Total Swap"), swap_total_vec)); + self.mem_pressure_some_data = + Rc::new(Series::new(tr!("Some"), mem_pressure_some_vec)); + self.mem_pressure_full_data = + Rc::new(Series::new(tr!("Full"), mem_pressure_full_vec)); + self.io_pressure_some_data = + Rc::new(Series::new(tr!("Some"), io_pressure_some_vec)); + self.io_pressure_full_data = + Rc::new(Series::new(tr!("Full"), io_pressure_full_vec)); + + self.net_in = Rc::new(Series::new(tr!("Incoming"), net_in_vec)); + self.net_out = Rc::new(Series::new(tr!("Outgoing"), net_out_vec)); + + self.disk_usage_data = Rc::new(Series::new(tr!("Used Disk"), disk_usage_vec)); + self.disk_total_data = Rc::new(Series::new(tr!("Total Disk"), disk_total_vec)); + self.disk_transfer_read_data = + Rc::new(Series::new(tr!("Read"), disk_transfer_read_vec)); + self.disk_transfer_write_data = + Rc::new(Series::new(tr!("Write"), disk_transfer_write_vec)); + self.disk_iops_read_data = + Rc::new(Series::new(tr!("Read"), disk_iops_read_vec)); + self.disk_iops_write_data = + Rc::new(Series::new(tr!("Write"), disk_iops_write_vec)); + + self.time_data = Rc::new(time_vec); + + let link = ctx.link().clone(); + self._timeout = Some(gloo_timers::callback::Timeout::new( + ctx.props().rrd_interval, + move || link.send_message(Msg::ReloadRrd), + )); + + true + } + Err(err) => { + self.error = Some(err.into()); + true + } + }, + Msg::UpdateRrdTimeframe(rrd_time_frame) => { + self.rrd_time_frame = rrd_time_frame; + ctx.link().send_message(Msg::ReloadRrd); + false + } } } @@ -267,12 +460,113 @@ impl Component for PdmNodeStatus { ), ) .with_child( - Row::new() + Column::new() .class("pwt-content-spacer-padding") .class("pwt-content-spacer-colors") .class("pwt-default-colors") .class(pwt::css::FlexFit) - .with_child(NodeStatusPanel::new().status_base_url("/nodes/localhost/status")), + .with_child( + NodeStatusPanel::new() + .status_base_url("/nodes/localhost/status") + .with_child(renderer::separator().padding_x(4)) + .with_optional_child( + self.error + .as_ref() + .map(|err| pwt::widget::error_message(&err.to_string())), + ) + .with_child( + Row::new() + .padding_x(4) + .padding_y(1) + .class(JustifyContent::FlexEnd) + .with_child( + RRDTimeframeSelector::new().on_change( + ctx.link().callback(Msg::UpdateRrdTimeframe), + ), + ), + ) + .with_child( + RRDGrid::new() + .with_child( + RRDGraph::new(self.time_data.clone()) + .title(tr!("CPU Usage")) + .render_value(renderer::rrd_value::render_cpu_usage) + .serie0(Some(self.cpu_data.clone())) + .serie1(Some(self.iowait_data.clone())), + ) + .with_child( + RRDGraph::new(self.time_data.clone()) + .title(tr!("Server Load")) + .render_value(renderer::rrd_value::render_load) + .serie0(Some(self.load_data.clone())), + ) + .with_child( + RRDGraph::new(self.time_data.clone()) + .title(tr!("Memory Usage")) + .binary(true) + .render_value(renderer::rrd_value::render_bytes) + .serie0(Some(self.mem_total_data.clone())) + .serie1(Some(self.mem_data.clone())), + ) + .with_child( + RRDGraph::new(self.time_data.clone()) + .title(tr!("Swap Usage")) + .binary(true) + .render_value(renderer::rrd_value::render_bytes) + .serie0(Some(self.swap_total_data.clone())) + .serie1(Some(self.swap_data.clone())), + ) + .with_child( + RRDGraph::new(self.time_data.clone()) + .title(tr!("Network Traffic")) + .binary(true) + .render_value(renderer::rrd_value::render_bandwidth) + .serie0(Some(self.net_in.clone())) + .serie1(Some(self.net_out.clone())), + ) + .with_child( + RRDGraph::new(self.time_data.clone()) + .title(tr!("CPU Pressure Stall")) + .render_value(renderer::rrd_value::render_pressure) + .serie0(Some(self.cpu_pressure_some_data.clone())), + ) + .with_child( + RRDGraph::new(self.time_data.clone()) + .title(tr!("Memory Pressure Stall")) + .render_value(renderer::rrd_value::render_pressure) + .serie0(Some(self.mem_pressure_some_data.clone())) + .serie1(Some(self.mem_pressure_full_data.clone())), + ) + .with_child( + RRDGraph::new(self.time_data.clone()) + .title(tr!("IO Pressure Stall")) + .render_value(renderer::rrd_value::render_pressure) + .serie0(Some(self.io_pressure_some_data.clone())) + .serie1(Some(self.io_pressure_full_data.clone())), + ) + .with_child( + RRDGraph::new(self.time_data.clone()) + .title(tr!("Root Disk Usage")) + .render_value(renderer::rrd_value::render_bytes) + .serie0(Some(self.disk_usage_data.clone())) + .serie1(Some(self.disk_total_data.clone())), + ) + .with_child( + RRDGraph::new(self.time_data.clone()) + .title(tr!("Root Disk Transfer Rate")) + .binary(true) + .render_value(renderer::rrd_value::render_bandwidth) + .serie0(Some(self.disk_transfer_read_data.clone())) + .serie1(Some(self.disk_transfer_write_data.clone())), + ) + .with_child( + RRDGraph::new(self.time_data.clone()) + .title(tr!("Root Disk IOPS")) + .serie0(Some(self.disk_iops_read_data.clone())) + .serie1(Some(self.disk_iops_write_data.clone())), + ), + ), + ), ) .with_optional_child( self.show_system_report @@ -285,3 +579,7 @@ impl Component for PdmNodeStatus { .into() } } + +fn empty_series() -> Rc { + Rc::new(Series::new("", Vec::new())) +} diff --git a/ui/src/renderer.rs b/ui/src/renderer.rs index 00c0720e..bfc059b3 100644 --- a/ui/src/renderer.rs +++ b/ui/src/renderer.rs @@ -111,3 +111,52 @@ pub(crate) fn render_title_row(title: String, icon: &str) -> Row { .with_child(Fa::new(icon)) .with_child(title) } + +/// Helpers for rendering values in RRD graphs. +pub mod rrd_value { + /// Render CPU usage in percent. `v` is multiplied by 100 to get the percent value. + pub fn render_cpu_usage(v: &f64) -> String { + if v.is_finite() { + format!("{:.1}%", v * 100.0) + } else { + v.to_string() + } + } + + /// Render server load value. + pub fn render_load(v: &f64) -> String { + if v.is_finite() { + format!("{:.2}", v) + } else { + v.to_string() + } + } + + /// Render a byte value. + pub fn render_bytes(v: &f64) -> String { + if v.is_finite() { + proxmox_human_byte::HumanByte::from(*v as u64).to_string() + } else { + v.to_string() + } + } + + /// Render bandwidth. + pub fn render_bandwidth(v: &f64) -> String { + if v.is_finite() { + let bytes = proxmox_human_byte::HumanByte::from(*v as u64); + format!("{bytes}/s") + } else { + v.to_string() + } + } + + /// Render pressure stall value. + pub fn render_pressure(v: &f64) -> String { + if v.is_finite() { + format!("{:.1}%", v) + } else { + v.to_string() + } + } +} -- 2.47.3