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 110651FF16B for ; Tue, 26 Aug 2025 12:22:33 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 497E82F127; Tue, 26 Aug 2025 12:22:35 +0200 (CEST) From: Dominik Csapak To: pdm-devel@lists.proxmox.com Date: Tue, 26 Aug 2025 12:15:17 +0200 Message-ID: <20250826102229.2271453-8-d.csapak@proxmox.com> X-Mailer: git-send-email 2.47.2 In-Reply-To: <20250826102229.2271453-1-d.csapak@proxmox.com> References: <20250826102229.2271453-1-d.csapak@proxmox.com> MIME-Version: 1.0 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.022 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: [pdm-devel] [PATCH datacenter-manager v4 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" 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 --- changes from v3: * use BTreeMap instead of Hashmap for sorting * use TaskWorkerType instead of helper functions ui/src/dashboard/mod.rs | 108 +++++++++++++- ui/src/dashboard/tasks.rs | 301 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 403 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..fc38aea --- /dev/null +++ b/ui/src/dashboard/tasks.rs @@ -0,0 +1,301 @@ +use std::collections::BTreeMap; +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::TaskWorkerType; + +use super::filtered_tasks::FilteredTasks; + +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd)] +struct TaskSummaryItem { + group: TaskWorkerType, + error_count: u64, + warning_count: u64, + ok_count: u64, + unknown_count: u64, +} + +impl TaskSummaryItem { + fn new(group: TaskWorkerType) -> Self { + TaskSummaryItem { + group, + 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<(TaskWorkerType, TaskStatusClass)>), // task_tyope +} + +#[derive(PartialEq)] +pub enum ViewState {} + +#[doc(hidden)] +pub struct ProxmoxTaskSummary { + store: Store, + task_filters: Option<(TaskWorkerType, TaskStatusClass)>, +} + +fn extract_task_summary(data: &TaskStatistics) -> Vec { + let mut map: BTreeMap = BTreeMap::new(); + + let mut insert_type = |task_type: TaskWorkerType| { + map.insert(task_type.clone(), TaskSummaryItem::new(task_type)); + }; + + insert_type(TaskWorkerType::Migrate); + insert_type(TaskWorkerType::Qemu); + insert_type(TaskWorkerType::Lxc); + insert_type(TaskWorkerType::Ceph); + insert_type(TaskWorkerType::Backup); + insert_type(TaskWorkerType::Ha); + + for (worker_type, count) in data.by_type.iter() { + let task_type = TaskWorkerType::new_from_str(&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; + } + + map.into_values().collect() +} + +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(TaskWorkerType::Remote(remote.to_string()))); + + 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: TaskWorkerType, + 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}", task_type.to_title(), 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_owned(|item: &TaskSummaryItem| item.group.to_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)| { + FilteredTasks::new(props.since, task_type.clone(), *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() + } +} -- 2.47.2 _______________________________________________ pdm-devel mailing list pdm-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel