From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: <pdm-devel-bounces@lists.proxmox.com> Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id 6DB661FF15C for <inbox@lore.proxmox.com>; Wed, 19 Feb 2025 13:29:04 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id A6391287B6; Wed, 19 Feb 2025 13:29:00 +0100 (CET) From: Dominik Csapak <d.csapak@proxmox.com> To: pdm-devel@lists.proxmox.com Date: Wed, 19 Feb 2025 13:28:24 +0100 Message-Id: <20250219122824.2043990-8-d.csapak@proxmox.com> X-Mailer: git-send-email 2.39.5 In-Reply-To: <20250219122824.2043990-1-d.csapak@proxmox.com> References: <20250219122824.2043990-1-d.csapak@proxmox.com> MIME-Version: 1.0 X-SPAM-LEVEL: Spam detection results: 0 AWL -1.228 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 URIBL_DBL_SPAM 2.5 Contains a spam URL listed in the Spamhaus DBL blocklist [tasks.rs] Subject: [pdm-devel] [PATCH datacenter-manager v2 7/7] 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 <pdm-devel.lists.proxmox.com> List-Unsubscribe: <https://lists.proxmox.com/cgi-bin/mailman/options/pdm-devel>, <mailto:pdm-devel-request@lists.proxmox.com?subject=unsubscribe> List-Archive: <http://lists.proxmox.com/pipermail/pdm-devel/> List-Post: <mailto:pdm-devel@lists.proxmox.com> List-Help: <mailto:pdm-devel-request@lists.proxmox.com?subject=help> List-Subscribe: <https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel>, <mailto:pdm-devel-request@lists.proxmox.com?subject=subscribe> Reply-To: Proxmox Datacenter Manager development discussion <pdm-devel@lists.proxmox.com> Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: pdm-devel-bounces@lists.proxmox.com Sender: "pdm-devel" <pdm-devel-bounces@lists.proxmox.com> 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 <d.csapak@proxmox.com> --- ui/src/dashboard/mod.rs | 110 +++++++++++++- ui/src/dashboard/tasks.rs | 302 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 407 insertions(+), 5 deletions(-) create mode 100644 ui/src/dashboard/tasks.rs diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs index 9d79cd3..62237b6 100644 --- a/ui/src/dashboard/mod.rs +++ b/ui/src/dashboard/mod.rs @@ -1,6 +1,7 @@ use std::rc::Rc; use anyhow::Error; +use js_sys::Date; use serde_json::json; use yew::{ virtual_dom::{VComp, VNode}, @@ -15,7 +16,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 crate::{remotes::AddWizard, RemoteList}; @@ -28,6 +32,9 @@ pub use subscription_info::SubscriptionInfo; mod filtered_tasks; +mod tasks; +use tasks::TaskSummary; + #[derive(Properties, PartialEq)] pub struct Dashboard { #[prop_or(60)] @@ -50,15 +57,24 @@ impl Default for Dashboard { pub enum Msg { LoadingFinished(Result<ResourcesStatus, Error>), TopEntitiesLoadResult(Result<pdm_client::types::TopEntities, proxmox_client::Error>), + StatisticsLoadResult(Result<TaskStatistics, Error>), RemoteListChanged(RemoteList), CreateWizard(bool), } +struct StatisticsOptions { + hours: u32, + since: i64, + data: Option<TaskStatistics>, + error: Option<Error>, +} + pub struct PdmDashboard { status: ResourcesStatus, last_error: Option<Error>, top_entities: Option<pdm_client::types::TopEntities>, last_top_entities_error: Option<proxmox_client::Error>, + statistics: StatisticsOptions, loading: bool, remote_list: RemoteList, show_wizard: bool, @@ -163,6 +179,47 @@ impl PdmDashboard { }) } + fn create_task_summary_panel( + &self, + statistics: &StatisticsOptions, + remotes: Option<u32>, + ) -> 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,16 +266,39 @@ impl Component for PdmDashboard { link.send_message(Msg::TopEntitiesLoadResult(result)); } }); + let hours = 24; + let since = (Date::now() / 1000.0) as i64 - (hours as i64 * 60 * 60); + async_pool.spawn({ + let link = ctx.link().clone(); + async move { + // TODO replace with pdm client call + let params = Some(json!({ + "since": since, + "limit": 0, + })); + + let res = http_get("/remote-tasks/statistics", params).await; + link.send_message(Msg::StatisticsLoadResult(res)); + } + }); let (remote_list, _context_listener) = ctx .link() .context(ctx.link().callback(Msg::RemoteListChanged)) .expect("No Remote list context provided"); + let statistics = StatisticsOptions { + hours, + since, + data: None, + error: None, + }; + Self { status: ResourcesStatus::default(), last_error: None, top_entities: None, last_top_entities_error: None, + statistics, loading: true, remote_list, show_wizard: false, @@ -250,6 +330,16 @@ impl Component for PdmDashboard { } true } + Msg::StatisticsLoadResult(res) => { + match res { + Ok(statistics) => { + self.statistics.error = None; + self.statistics.data = Some(statistics); + } + Err(err) => self.statistics.error = Some(err), + } + true + } Msg::RemoteListChanged(remote_list) => { let changed = self.remote_list != remote_list; self.remote_list = remote_list; @@ -262,7 +352,7 @@ impl Component for PdmDashboard { } } - fn view(&self, _ctx: &yew::Context<Self>) -> yew::Html { + fn view(&self, ctx: &yew::Context<Self>) -> yew::Html { let (remote_icon, remote_text) = match (self.status.failed_remotes, self.status.remotes) { (0, 0) => (Status::Warning.to_fa_icon(), tr!("No remotes configured.")), (0, _) => ( @@ -292,7 +382,7 @@ impl Component for PdmDashboard { .with_tool( Button::new(tr!("Add")) .icon_class("fa fa-plus-circle") - .onclick(_ctx.link().callback(|_| Msg::CreateWizard(true))), + .onclick(ctx.link().callback(|_| Msg::CreateWizard(true))), ) .with_child( Column::new() @@ -394,7 +484,6 @@ impl Component for PdmDashboard { .class("pwt-content-spacer") .class("pwt-flex-direction-row") .class("pwt-align-content-start") - .class(pwt::css::Flex::Fill) .style("padding-top", "0") .class(FlexWrap::Wrap) //.min_height(175) @@ -416,6 +505,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("pwt-flex-direction-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() @@ -425,7 +525,7 @@ impl Component for PdmDashboard { .with_optional_child( self.show_wizard.then_some( AddWizard::new(pdm_api_types::remotes::RemoteType::Pve) - .on_close(_ctx.link().callback(|_| Msg::CreateWizard(false))) + .on_close(ctx.link().callback(|_| Msg::CreateWizard(false))) .on_submit(move |ctx| { crate::remotes::create_remote( ctx, diff --git a/ui/src/dashboard/tasks.rs b/ui/src/dashboard/tasks.rs new file mode 100644 index 0000000..a41b38a --- /dev/null +++ b/ui/src/dashboard/tasks.rs @@ -0,0 +1,302 @@ +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<String>, title: impl Into<String>, 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<u32>, +} + +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<u32>) -> 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<TaskSummaryItem>, + task_filters: Option<(String, TaskStatusClass)>, +} + +fn extract_task_summary(data: &TaskStatistics) -> Vec<TaskSummaryItem> { + let mut map: HashMap<String, TaskSummaryItem> = 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<TaskSummaryItem> = map.into_values().collect(); + list.sort(); + list +} + +fn extract_task_summary_remote(data: &TaskStatistics, limit: u32) -> Vec<TaskSummaryItem> { + let mut map: HashMap<String, TaskSummaryItem> = 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<TaskSummaryItem> = 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<ProxmoxTaskSummary>, + 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<Self>, + ) -> Rc<Vec<DataTableHeader<TaskSummaryItem>>> { + 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(), + ]) + } +} + +impl Component for ProxmoxTaskSummary { + type Properties = TaskSummary; + type Message = Msg; + + fn create(ctx: &Context<Self>) -> Self { + let store = Store::new(); + 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)); + } + + Self { + store, + task_filters: None, + } + } + + fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool { + match msg { + Msg::ShowFilteredTasks(filters) => { + self.task_filters = filters; + } + } + true + } + + fn view(&self, ctx: &Context<Self>) -> 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() + } +} -- 2.39.5 _______________________________________________ pdm-devel mailing list pdm-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel