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 4DCB11FF187 for ; Mon, 25 Aug 2025 12:52:30 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id AECF31016E; Mon, 25 Aug 2025 12:52:34 +0200 (CEST) Message-ID: <46f65a30-925b-4741-9b4a-b13083f31221@proxmox.com> Date: Mon, 25 Aug 2025 12:52:28 +0200 MIME-Version: 1.0 User-Agent: Mozilla Thunderbird To: Proxmox Datacenter Manager development discussion , Dominik Csapak References: <20250825081042.797559-1-d.csapak@proxmox.com> <20250825081042.797559-8-d.csapak@proxmox.com> Content-Language: en-US From: Stefan Hanreich In-Reply-To: <20250825081042.797559-8-d.csapak@proxmox.com> X-SPAM-LEVEL: Spam detection results: 0 AWL 0.710 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 Subject: Re: [pdm-devel] [PATCH datacenter-manager v3 7/8] ui: dashboard: add task summaries X-BeenThere: pdm-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Reply-To: Proxmox Datacenter Manager development discussion Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: pdm-devel-bounces@lists.proxmox.com Sender: "pdm-devel" one comment inline On 8/25/25 10:10 AM, Dominik Csapak wrote: > similar to what we show in PBS. > We add two panels: > > * one for summarizing by category (e.g. guest migration) > * one by the 5 remotes with the most tasks > > Each shows a list of categories/remotes with a count per state > (success,warning,error). A click on such a count opens a filtered > view that shows only those tasks. > > Signed-off-by: Dominik Csapak > --- > ui/src/dashboard/mod.rs | 108 ++++++++++++- > ui/src/dashboard/tasks.rs | 316 ++++++++++++++++++++++++++++++++++++++ > 2 files changed, 418 insertions(+), 6 deletions(-) > create mode 100644 ui/src/dashboard/tasks.rs > > diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs > index 0cdd26c..502332a 100644 > --- a/ui/src/dashboard/mod.rs > +++ b/ui/src/dashboard/mod.rs > @@ -1,7 +1,7 @@ > use std::{collections::HashMap, rc::Rc}; > > use anyhow::Error; > -use futures::future::join; > +use futures::join; > use js_sys::Date; > use serde::{Deserialize, Serialize}; > use serde_json::json; > @@ -24,7 +24,10 @@ use pwt::{ > AsyncPool, > }; > > -use pdm_api_types::resource::{GuestStatusCount, NodeStatusCount, ResourcesStatus}; > +use pdm_api_types::{ > + resource::{GuestStatusCount, NodeStatusCount, ResourcesStatus}, > + TaskStatistics, > +}; > use pdm_client::types::TopEntity; > use proxmox_client::ApiResponseData; > > @@ -47,6 +50,9 @@ use status_row::DashboardStatusRow; > > mod filtered_tasks; > > +mod tasks; > +use tasks::TaskSummary; > + > /// The default 'max-age' parameter in seconds. > pub const DEFAULT_MAX_AGE_S: u64 = 60; > > @@ -80,6 +86,7 @@ pub struct DashboardConfig { > pub type LoadingResult = ( > Result, > Result, > + Result, > ); > > pub enum Msg { > @@ -91,11 +98,19 @@ pub enum Msg { > ConfigWindow(bool), > } > > +struct StatisticsOptions { > + hours: u32, > + since: i64, > + data: Option, > + error: Option, > +} > + > pub struct PdmDashboard { > status: ResourcesStatus, > last_error: Option, > top_entities: Option, > last_top_entities_error: Option, > + statistics: StatisticsOptions, > loading: bool, > load_finished_time: Option, > remote_list: RemoteList, > @@ -174,6 +189,47 @@ impl PdmDashboard { > )) > } > > + fn create_task_summary_panel( > + &self, > + statistics: &StatisticsOptions, > + remotes: Option, > + ) -> Panel { > + let title = match remotes { > + Some(count) => tr!( > + "Task Summary for Top {0} Remotes (Last {1}h)", > + count, > + statistics.hours > + ), > + None => tr!("Task Summary by Category (Last {0}h)", statistics.hours), > + }; > + Panel::new() > + .flex(1.0) > + .width(500) > + .border(true) > + .title(self.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, statistics.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, > @@ -209,9 +265,23 @@ impl PdmDashboard { > let top_entities_future = client.get_top_entities(); > let status_future = http_get("/resources/status", Some(json!({"max-age": max_age}))); > > - let (top_entities_res, status_res) = join(top_entities_future, status_future).await; > + let since = (Date::now() / 1000.0) as i64 - (24 * 60 * 60); > + let params = Some(json!({ > + "since": since, > + "limit": 0, > + })); > + > + // TODO replace with pdm client call > + let statistics_future = http_get("/remote-tasks/statistics", params); > > - link.send_message(Msg::LoadingFinished((status_res, top_entities_res))); > + let (top_entities_res, status_res, statistics_res) = > + join!(top_entities_future, status_future, statistics_future); > + > + link.send_message(Msg::LoadingFinished(( > + status_res, > + top_entities_res, > + statistics_res, > + ))); > }); > } > } > @@ -229,11 +299,20 @@ impl Component for PdmDashboard { > .context(ctx.link().callback(Msg::RemoteListChanged)) > .expect("No Remote list context provided"); > > + let since = (Date::now() / 1000.0) as i64 - (24 * 60 * 60); > + let statistics = StatisticsOptions { > + hours: 24, > + since, > + data: None, > + error: None, > + }; > + > let mut this = Self { > status: ResourcesStatus::default(), > last_error: None, > top_entities: None, > last_top_entities_error: None, > + statistics, > loading: true, > load_finished_time: None, > remote_list, > @@ -251,7 +330,7 @@ impl Component for PdmDashboard { > > fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { > match msg { > - Msg::LoadingFinished((resources_status, top_entities)) => { > + Msg::LoadingFinished((resources_status, top_entities, task_statistics)) => { > match resources_status { > Ok(status) => { > self.last_error = None; > @@ -266,6 +345,13 @@ impl Component for PdmDashboard { > } > Err(err) => self.last_top_entities_error = Some(err), > } > + match task_statistics { > + Ok(statistics) => { > + self.statistics.error = None; > + self.statistics.data = Some(statistics); > + } > + Err(err) => self.statistics.error = Some(err), > + } > self.loading = false; > self.load_finished_time = Some(Date::now() / 1000.0); > true > @@ -412,7 +498,6 @@ impl Component for PdmDashboard { > .class("pwt-content-spacer") > .class(FlexDirection::Row) > .class("pwt-align-content-start") > - .class(pwt::css::Flex::Fill) > .padding_top(0) > .class(FlexWrap::Wrap) > //.min_height(175) > @@ -434,6 +519,17 @@ impl Component for PdmDashboard { > tr!("Memory usage"), > self.top_entities.as_ref().map(|e| &e.node_memory), > )), > + ) > + .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(self.create_task_summary_panel(&self.statistics, None)) > + .with_child(self.create_task_summary_panel(&self.statistics, Some(5))), > ); > > Panel::new() > diff --git a/ui/src/dashboard/tasks.rs b/ui/src/dashboard/tasks.rs > new file mode 100644 > index 0000000..efd43d2 > --- /dev/null > +++ b/ui/src/dashboard/tasks.rs > @@ -0,0 +1,316 @@ > +use std::collections::HashMap; > +use std::rc::Rc; > + > +use yew::html::Scope; > +use yew::virtual_dom::Key; > + > +use proxmox_yew_comp::common_api_types::TaskStatusClass; > +use pwt::css; > +use pwt::prelude::*; > +use pwt::props::ExtractPrimaryKey; > +use pwt::state::Store; > +use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader}; > +use pwt::widget::{ActionIcon, Container, Tooltip}; > +use pwt_macros::{builder, widget}; > + > +use pdm_api_types::TaskStatistics; > + > +use crate::tasks::{get_type_title, map_worker_type}; > + > +use super::filtered_tasks::FilteredTasks; > + > +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd)] > +struct TaskSummaryItem { > + prio: usize, // sort order > + group: String, > + title: String, > + error_count: u64, > + warning_count: u64, > + ok_count: u64, > + unknown_count: u64, > +} > + > +impl TaskSummaryItem { > + fn new(group: impl Into, title: impl Into, prio: usize) -> Self { > + TaskSummaryItem { > + group: group.into(), > + title: title.into(), > + prio, > + error_count: 0, > + warning_count: 0, > + ok_count: 0, > + unknown_count: 0, > + } > + } > +} > + > +impl ExtractPrimaryKey for TaskSummaryItem { > + fn extract_key(&self) -> Key { > + Key::from(self.group.clone()) > + } > +} > + > +#[widget(comp=ProxmoxTaskSummary, @element)] > +#[derive(Clone, PartialEq, Properties)] > +#[builder] > +pub struct TaskSummary { > + statistics: TaskStatistics, > + > + since: i64, > + > + top_remotes: Option, > +} > + > +impl TaskSummary { > + /// New Task Summary, if `top_remotes` is `Some`, group > + /// by that much remotes instead of predefined groups. > + pub fn new(statistics: TaskStatistics, since: i64, top_remotes: Option) -> Self { > + yew::props!(Self { > + statistics, > + since, > + top_remotes > + }) > + } > +} > + > +pub enum Msg { > + ShowFilteredTasks(Option<(String, TaskStatusClass)>), // task_tyope > +} > + > +#[derive(PartialEq)] > +pub enum ViewState {} > + > +#[doc(hidden)] > +pub struct ProxmoxTaskSummary { > + store: Store, > + task_filters: Option<(String, TaskStatusClass)>, > +} > + > +fn extract_task_summary(data: &TaskStatistics) -> Vec { > + let mut map: HashMap = HashMap::new(); > + > + let mut prio = 0; > + let mut insert_type = |task_type: &str| { > + prio += 1; > + map.insert( > + task_type.to_string(), > + TaskSummaryItem::new(task_type, get_type_title(task_type), prio), > + ); > + }; > + > + insert_type("migrate"); > + insert_type("qm"); > + insert_type("vz"); > + insert_type("ceph"); > + insert_type("vzdump"); > + insert_type("ha"); > + > + for (worker_type, count) in data.by_type.iter() { > + let task_type = map_worker_type(worker_type); > + > + let entry = match map.get_mut(task_type) { > + Some(entry) => entry, > + None => continue, > + }; > + > + entry.ok_count += count.ok; > + entry.warning_count += count.warning; > + entry.error_count += count.error; > + entry.unknown_count += count.unknown; > + } > + > + let mut list: Vec = map.into_values().collect(); > + list.sort(); > + list > +} If we move the type into an enum, as suggested previously, then we could just use a BTreeMap and get sorting/prio for free? Could also just insert entries into the map 'on demand' unless we want to show all headings in the UI? > + > +fn extract_task_summary_remote(data: &TaskStatistics, limit: u32) -> Vec { > + let mut map: HashMap = HashMap::new(); > + > + for (remote, count) in data.by_remote.iter() { > + let entry = map > + .entry(remote.to_string()) > + .or_insert_with(|| TaskSummaryItem::new(remote, remote, 0)); > + > + entry.ok_count += count.ok; > + entry.warning_count += count.warning; > + entry.error_count += count.error; > + entry.unknown_count += count.unknown; > + } > + > + let mut list: Vec = map.into_values().collect(); > + list.sort_by(|a, b| { > + let a_count = a.error_count + a.warning_count + a.ok_count; > + let b_count = b.error_count + b.warning_count + b.ok_count; > + b_count.cmp(&a_count) > + }); > + > + list.into_iter().take(limit as usize).collect() > +} > + > +fn render_counter( > + link: Scope, > + count: u64, > + task_type: String, > + task_class: TaskStatusClass, > +) -> Html { > + let (icon_class, icon_scheme, state_text) = match task_class { > + TaskStatusClass::Ok => ("fa-check", css::ColorScheme::Success, tr!("OK")), > + TaskStatusClass::Warning => ( > + "fa-exclamation-triangle", > + css::ColorScheme::Warning, > + tr!("Warning"), > + ), > + TaskStatusClass::Error => ("fa-times-circle", css::ColorScheme::Error, tr!("Error")), > + }; > + let tip = tr!("Show: {0} - {1}", get_type_title(&task_type), state_text); > + > + let has_count = count > 0; > + let action = ActionIcon::new(classes!("fa", icon_class)) > + .margin_end(1) > + .class(has_count.then_some(icon_scheme)) > + .disabled(!has_count) > + .on_activate(move |_| { > + link.send_message(Msg::ShowFilteredTasks(Some(( > + task_type.clone(), > + task_class, > + )))) > + }); > + > + Tooltip::new( > + Container::from_tag("span") > + .with_child(action) > + .with_child(count), > + ) > + .tip(has_count.then_some(tip)) > + .into() > +} > + > +impl ProxmoxTaskSummary { > + fn task_summary_columns( > + &self, > + ctx: &Context, > + ) -> Rc>> { > + Rc::new(vec![ > + DataTableColumn::new("") > + .flex(1) > + .get_property(|item: &TaskSummaryItem| &item.title) > + .into(), > + DataTableColumn::new("") > + .width("100px") > + .render({ > + let link = ctx.link().clone(); > + move |item: &TaskSummaryItem| { > + render_counter( > + link.clone(), > + item.error_count, > + item.group.clone(), > + TaskStatusClass::Error, > + ) > + } > + }) > + .into(), > + DataTableColumn::new("") > + .width("100px") > + .render({ > + let link = ctx.link().clone(); > + move |item: &TaskSummaryItem| { > + render_counter( > + link.clone(), > + item.warning_count, > + item.group.clone(), > + TaskStatusClass::Warning, > + ) > + } > + }) > + .into(), > + DataTableColumn::new("") > + .width("100px") > + .render({ > + let link = ctx.link().clone(); > + move |item: &TaskSummaryItem| { > + render_counter( > + link.clone(), > + item.ok_count, > + item.group.clone(), > + TaskStatusClass::Ok, > + ) > + } > + }) > + .into(), > + ]) > + } > + > + fn update_task_statistics(&mut self, ctx: &Context) { > + let store = &self.store; > + > + if let Some(top_remotes) = ctx.props().top_remotes { > + store.set_data(extract_task_summary_remote( > + &ctx.props().statistics, > + top_remotes, > + )); > + } else { > + store.set_data(extract_task_summary(&ctx.props().statistics)); > + } > + } > +} > + > +impl Component for ProxmoxTaskSummary { > + type Properties = TaskSummary; > + type Message = Msg; > + > + fn create(ctx: &Context) -> Self { > + let mut this = Self { > + store: Store::new(), > + task_filters: None, > + }; > + > + this.update_task_statistics(ctx); > + this > + } > + > + fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool { > + match msg { > + Msg::ShowFilteredTasks(filters) => { > + self.task_filters = filters; > + } > + } > + true > + } > + > + fn changed(&mut self, ctx: &Context, old_props: &Self::Properties) -> bool { > + if old_props.statistics != ctx.props().statistics { > + self.update_task_statistics(ctx); > + } > + > + true > + } > + > + fn view(&self, ctx: &Context) -> Html { > + let props = ctx.props(); > + let tasks = self.task_filters.as_ref().map(|(task_type, task_status)| { > + let group = if props.top_remotes.is_some() { > + super::filtered_tasks::TaskGroup::Remote(task_type.clone()) > + } else { > + super::filtered_tasks::TaskGroup::Type(task_type.clone()) > + }; > + FilteredTasks::new(props.since, group, *task_status).on_close({ > + let link = ctx.link().clone(); > + move |_| link.send_message(Msg::ShowFilteredTasks(None)) > + }) > + }); > + > + Container::new() > + .class(css::FlexFit) > + .with_child( > + DataTable::new(self.task_summary_columns(ctx), self.store.clone()) > + .class(pwt::css::FlexFit) > + .striped(false) > + .borderless(true) > + .hover(true) > + .show_header(false), > + ) > + .with_optional_child(tasks) > + .into() > + } > +} _______________________________________________ pdm-devel mailing list pdm-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel