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 0F0721FF13A for ; Wed, 15 Apr 2026 10:48:05 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id E8331950F; Wed, 15 Apr 2026 10:48:04 +0200 (CEST) Mime-Version: 1.0 Content-Transfer-Encoding: quoted-printable Content-Type: text/plain; charset=UTF-8 Date: Wed, 15 Apr 2026 10:47:28 +0200 Message-Id: To: "Lukas Wagner" , Subject: Re: [PATCH datacenter-manager v3 10/11] ui: node status: add RRD graphs for PDM host metrics X-Mailer: aerc 0.20.0 References: <20260413085816.143591-1-l.wagner@proxmox.com> <20260413085816.143591-11-l.wagner@proxmox.com> In-Reply-To: <20260413085816.143591-11-l.wagner@proxmox.com> From: "Shannon Sterz" X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1776242770900 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.124 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: N7C2T4AOKCHSHU2ZNATQJEQKQMU2VCA3 X-Message-ID-Hash: N7C2T4AOKCHSHU2ZNATQJEQKQMU2VCA3 X-MailFrom: s.sterz@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: On Mon Apr 13, 2026 at 10:58 AM CEST, Lukas Wagner wrote: > 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 > Reviewed-by: Arthur Bied-Charreton > Tested-by: Arthur Bied-Charreton > --- > 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, RRDTim= eframe, > + 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 =3D crate::pdm_client() > + .get_pdm_node_rrddata(rrd_time_frame.mode, rrd_time_frame.ti= meframe) > + .await; > + > + Msg::RrdLoadFinished(res) > + } > + > fn change_power_state(&mut self, ctx: &yew::Context, command: = NodePowerCommand) { > let link =3D ctx.link().clone(); > self.abort_guard.replace(AsyncAbortGuard::spawn(async move { > @@ -184,8 +233,37 @@ impl Component for PdmNodeStatus { > type Message =3D Msg; > type Properties =3D 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 =3D show_package_versions; > true > } > + Msg::ReloadRrd =3D> { > + self._timeout =3D None; > + let timeframe =3D self.rrd_time_frame; > + self.async_pool.send_future(ctx.link().clone(), async mo= ve { > + Self::reload_rrd(timeframe).await > + }); > + true > + } > + Msg::RrdLoadFinished(res) =3D> match res { > + Ok(data_points) =3D> { > + self.error =3D None; > + let mut cpu_vec =3D Vec::with_capacity(data_points.l= en()); > + let mut cpu_pressure_some_vec =3D Vec::with_capacity= (data_points.len()); > + let mut iowait_vec =3D Vec::with_capacity(data_point= s.len()); > + let mut load_vec =3D Vec::with_capacity(data_points.= len()); > + let mut mem_vec =3D Vec::with_capacity(data_points.l= en()); > + let mut mem_total_vec =3D Vec::with_capacity(data_po= ints.len()); > + let mut swap_vec =3D Vec::with_capacity(data_points.= len()); > + let mut swap_total_vec =3D Vec::with_capacity(data_p= oints.len()); > + let mut mem_pressure_some_vec =3D Vec::with_capacity= (data_points.len()); > + let mut mem_pressure_full_vec =3D Vec::with_capacity= (data_points.len()); > + let mut io_pressure_some_vec =3D Vec::with_capacity(= data_points.len()); > + let mut io_pressure_full_vec =3D Vec::with_capacity(= data_points.len()); > + let mut time_vec =3D Vec::with_capacity(data_points.= len()); > + let mut net_in_vec =3D Vec::with_capacity(data_point= s.len()); > + let mut net_out_vec =3D Vec::with_capacity(data_poin= ts.len()); > + let mut disk_usage_vec =3D Vec::with_capacity(data_p= oints.len()); > + let mut disk_total_vec =3D Vec::with_capacity(data_p= oints.len()); > + let mut disk_transfer_read_vec =3D Vec::with_capacit= y(data_points.len()); > + let mut disk_transfer_write_vec =3D Vec::with_capaci= ty(data_points.len()); > + let mut disk_iops_read_vec =3D Vec::with_capacity(da= ta_points.len()); > + let mut disk_iops_write_vec =3D Vec::with_capacity(d= ata_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::N= AN)); > + 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(f6= 4::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(f6= 4::NAN)); > + disk_usage_vec.push(data.disk_used.unwrap_or(f64= ::NAN)); > + disk_transfer_read_vec.push(data.disk_read.unwra= p_or(f64::NAN)); > + disk_transfer_write_vec.push(data.disk_write.unw= rap_or(f64::NAN)); > + > + disk_iops_read_vec.push(data.disk_read_iops.unwr= ap_or(f64::NAN)); > + disk_iops_write_vec.push(data.disk_write_iops.un= wrap_or(f64::NAN)); > + > + time_vec.push(data.time as i64); > + } > + > + self.cpu_data =3D Rc::new(Series::new(tr!("CPU usage= "), cpu_vec)); > + self.iowait_data =3D Rc::new(Series::new(tr!("IO del= ay"), iowait_vec)); > + self.load_data =3D Rc::new(Series::new(tr!("Server L= oad"), load_vec)); > + self.cpu_pressure_some_data =3D > + Rc::new(Series::new(tr!("Some"), cpu_pressure_so= me_vec)); > + self.mem_data =3D Rc::new(Series::new(tr!("Used Memo= ry"), mem_vec)); > + self.mem_total_data =3D Rc::new(Series::new(tr!("Tot= al Memory"), mem_total_vec)); > + self.swap_data =3D Rc::new(Series::new(tr!("Used Swa= p"), swap_vec)); > + self.swap_total_data =3D Rc::new(Series::new(tr!("To= tal Swap"), swap_total_vec)); > + self.mem_pressure_some_data =3D > + Rc::new(Series::new(tr!("Some"), mem_pressure_so= me_vec)); > + self.mem_pressure_full_data =3D > + Rc::new(Series::new(tr!("Full"), mem_pressure_fu= ll_vec)); > + self.io_pressure_some_data =3D > + Rc::new(Series::new(tr!("Some"), io_pressure_som= e_vec)); > + self.io_pressure_full_data =3D > + Rc::new(Series::new(tr!("Full"), io_pressure_ful= l_vec)); > + > + self.net_in =3D Rc::new(Series::new(tr!("Incoming"),= net_in_vec)); > + self.net_out =3D Rc::new(Series::new(tr!("Outgoing")= , net_out_vec)); > + > + self.disk_usage_data =3D Rc::new(Series::new(tr!("Us= ed Disk"), disk_usage_vec)); > + self.disk_total_data =3D Rc::new(Series::new(tr!("To= tal Disk"), disk_total_vec)); > + self.disk_transfer_read_data =3D > + Rc::new(Series::new(tr!("Read"), disk_transfer_r= ead_vec)); > + self.disk_transfer_write_data =3D > + Rc::new(Series::new(tr!("Write"), disk_transfer_= write_vec)); > + self.disk_iops_read_data =3D > + Rc::new(Series::new(tr!("Read"), disk_iops_read_= vec)); > + self.disk_iops_write_data =3D > + Rc::new(Series::new(tr!("Write"), disk_iops_writ= e_vec)); > + > + self.time_data =3D Rc::new(time_vec); > + > + let link =3D ctx.link().clone(); > + self._timeout =3D Some(gloo_timers::callback::Timeou= t::new( > + ctx.props().rrd_interval, > + move || link.send_message(Msg::ReloadRrd), > + )); > + > + true > + } > + Err(err) =3D> { > + self.error =3D Some(err.into()); > + true > + } > + }, > + Msg::UpdateRrdTimeframe(rrd_time_frame) =3D> { > + self.rrd_time_frame =3D 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_messag= e(&err.to_string())), > + ) > + .with_child( > + Row::new() > + .padding_x(4) > + .padding_y(1) > + .class(JustifyContent::FlexEnd) > + .with_child( > + RRDTimeframeSelector::new().on_c= hange( > + ctx.link().callback(Msg::Upd= ateRrdTimeframe), > + ), > + ), > + ) > + .with_child( > + RRDGrid::new() > + .with_child( > + RRDGraph::new(self.time_data.clo= ne()) > + .title(tr!("CPU Usage")) > + .render_value(renderer::rrd_= value::render_cpu_usage) > + .serie0(Some(self.cpu_data.c= lone())) > + .serie1(Some(self.iowait_dat= a.clone())), > + ) > + .with_child( > + RRDGraph::new(self.time_data.clo= ne()) > + .title(tr!("Server Load")) > + .render_value(renderer::rrd_= value::render_load) > + .serie0(Some(self.load_data.= clone())), > + ) > + .with_child( > + RRDGraph::new(self.time_data.clo= ne()) > + .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.c= lone())), > + ) > + .with_child( > + RRDGraph::new(self.time_data.clo= ne()) > + .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.clo= ne()) > + .title(tr!("Network Traffic"= )) > + .binary(true) > + .render_value(renderer::rrd_= value::render_bandwidth) > + .serie0(Some(self.net_in.clo= ne())) > + .serie1(Some(self.net_out.cl= one())), > + ) > + .with_child( > + RRDGraph::new(self.time_data.clo= ne()) > + .title(tr!("CPU Pressure Sta= ll")) > + .render_value(renderer::rrd_= value::render_pressure) > + .serie0(Some(self.cpu_pressu= re_some_data.clone())), > + ) > + .with_child( > + RRDGraph::new(self.time_data.clo= ne()) > + .title(tr!("Memory Pressure = Stall")) > + .render_value(renderer::rrd_= value::render_pressure) > + .serie0(Some(self.mem_pressu= re_some_data.clone())) > + .serie1(Some(self.mem_pressu= re_full_data.clone())), > + ) > + .with_child( > + RRDGraph::new(self.time_data.clo= ne()) > + .title(tr!("IO Pressure Stal= l")) > + .render_value(renderer::rrd_= value::render_pressure) > + .serie0(Some(self.io_pressur= e_some_data.clone())) > + .serie1(Some(self.io_pressur= e_full_data.clone())), > + ) > + .with_child( > + RRDGraph::new(self.time_data.clo= ne()) > + .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.clo= ne()) > + .title(tr!("Root Disk Transf= er Rate")) > + .binary(true) > + .render_value(renderer::rrd_= value::render_bandwidth) > + .serie0(Some(self.disk_trans= fer_read_data.clone())) > + .serie1(Some(self.disk_trans= fer_write_data.clone())), > + ) > + .with_child( > + RRDGraph::new(self.time_data.clo= ne()) > + .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 { thanks for these helpers and the follow-up patch using them throughout pdm. i think they might make more sense in proxmox-yew-comp, though. we tend to implement more or less the same types of rrd charts across our products, so having them there is likely to come in handy again. what do you think? > + /// 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 =3D 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() > + } > + } > +}