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 [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id 24DD91FF16B for <inbox@lore.proxmox.com>; Thu, 23 Jan 2025 16:10:23 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 8880AF120; Thu, 23 Jan 2025 16:10:18 +0100 (CET) From: Dominik Csapak <d.csapak@proxmox.com> To: pdm-devel@lists.proxmox.com Date: Thu, 23 Jan 2025 16:10:12 +0100 Message-Id: <20250123151012.4047891-1-d.csapak@proxmox.com> X-Mailer: git-send-email 2.39.5 MIME-Version: 1.0 X-SPAM-LEVEL: Spam detection results: 0 AWL -1.231 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_VALIDITY_CERTIFIED_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. RCVD_IN_VALIDITY_RPBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. RCVD_IN_VALIDITY_SAFE_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. 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] ui: dashboard: add task summary 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 have in PBS, show some categories of tasks with their status counts. When clicking on a count, a filtered view opens that only shows those tasks. This also refactors the option remote task rendering from the running tasks list. It's already prepared that one can provide a given timeframe, currently it's hardcoded to 24 hours (like the stastic panels too) Signed-off-by: Dominik Csapak <d.csapak@proxmox.com> --- ui/src/dashboard/mod.rs | 22 +- ui/src/dashboard/tasks.rs | 423 ++++++++++++++++++++++++++++++++++++++ ui/src/tasks.rs | 19 +- ui/src/top_nav_bar.rs | 20 +- 4 files changed, 462 insertions(+), 22 deletions(-) create mode 100644 ui/src/dashboard/tasks.rs diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs index ea0cf5e..754f4af 100644 --- a/ui/src/dashboard/mod.rs +++ b/ui/src/dashboard/mod.rs @@ -26,6 +26,9 @@ pub use top_entities::TopEntities; mod subscription_info; pub use subscription_info::SubscriptionInfo; +mod tasks; +use tasks::TaskSummary; + #[derive(Properties, PartialEq)] pub struct Dashboard { #[prop_or(60)] @@ -260,7 +263,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, _) => ( @@ -290,7 +293,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() @@ -392,7 +395,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) @@ -414,6 +416,18 @@ 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(TaskSummary::new()) + // invisible for the content-spacer + .with_child(Container::new().style("display", "none")), ); Panel::new() @@ -423,7 +437,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..90f86c6 --- /dev/null +++ b/ui/src/dashboard/tasks.rs @@ -0,0 +1,423 @@ +use std::collections::HashMap; +use std::rc::Rc; + +use js_sys::Date; +use serde_json::json; +use yew::virtual_dom::{Key, VComp, VNode}; + +use proxmox_yew_comp::common_api_types::{TaskListItem, TaskStatusClass}; +use proxmox_yew_comp::utils::{format_duration_human, render_epoch}; +use proxmox_yew_comp::{ + http_get, LoadableComponent, LoadableComponentContext, LoadableComponentLink, + LoadableComponentMaster, Status, TaskViewer, +}; +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, Dialog, Fa, Panel, Row, Tooltip}; +use pwt_macros::builder; + +use pdm_api_types::RemoteUpid; + +use crate::tasks::format_optional_remote_upid; + +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd)] +struct TaskSummaryItem { + prio: usize, // sort order + task_type: String, + title: String, + error_count: u64, + warning_count: u64, + ok_count: u64, +} + +impl TaskSummaryItem { + fn new(task_type: impl Into<String>, title: impl Into<String>, prio: usize) -> Self { + TaskSummaryItem { + task_type: task_type.into(), + title: title.into(), + prio, + error_count: 0, + warning_count: 0, + ok_count: 0, + } + } +} + +impl ExtractPrimaryKey for TaskSummaryItem { + fn extract_key(&self) -> Key { + Key::from(self.task_type.clone()) + } +} + +#[derive(Clone, PartialEq, Properties)] +#[builder] +pub struct TaskSummary { + #[builder] + /// How much + #[prop_or(24)] + amount_hours: u64, +} + +impl TaskSummary { + pub fn new() -> Self { + yew::props!(Self {}) + } +} + +pub enum Msg { + DataChange(Vec<TaskListItem>), +} + +#[derive(PartialEq)] +pub enum ViewState { + ShowFilteredTasks((String, TaskStatusClass)), + ShowTask((RemoteUpid, Option<i64>)), // endtime +} + +#[doc(hidden)] +pub struct ProxmoxTaskSummary { + store: Store<TaskSummaryItem>, + task_store: Store<TaskListItem>, +} + +fn map_worker_type(worker_type: &str) -> &str { + match worker_type { + task_type if task_type.contains("migrate") => "migrate", + task_type if task_type.starts_with("qm") => "qm", + task_type if task_type.starts_with("vz") && task_type != "vzdump" => "vz", + task_type if task_type.starts_with("ceph") => "ceph", + task_type if task_type.starts_with("ha") => "ha", + other => other, + } +} + +fn get_type_title(task_type: &str) -> String { + match task_type { + "migrate" => tr!("Guest Migrations"), + "qm" => tr!("Virtual Machine related Tasks"), + "vz" => tr!("Container related Tasks"), + "ceph" => tr!("Ceph related Tasks"), + "vzdump" => tr!("Backup Tasks"), + "ha" => tr!("HA related Tasks"), + other => tr!("Worker Type: {0}", other), + } +} + +fn extract_task_summary(data: &[TaskListItem]) -> 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 task in data { + let status: TaskStatusClass = match &task.status { + Some(status) => status.into(), + None => continue, + }; + + let task_type = map_worker_type(&task.worker_type); + + let entry = match map.get_mut(task_type) { + Some(entry) => entry, + None => continue, + }; + + match status { + TaskStatusClass::Ok => entry.ok_count += 1, + TaskStatusClass::Warning => entry.warning_count += 1, + TaskStatusClass::Error => entry.error_count += 1, + } + } + + let mut list: Vec<TaskSummaryItem> = map.into_values().collect(); + list.sort(); + list +} + +fn render_counter( + link: LoadableComponentLink<ProxmoxTaskSummary>, + count: u64, + task_type: String, + task_class: TaskStatusClass, +) -> Html { + let (icon_class, icon_scheme) = match task_class { + TaskStatusClass::Ok => ("fa-check", css::ColorScheme::Success), + TaskStatusClass::Warning => ("fa-exclamation-triangle", css::ColorScheme::Warning), + TaskStatusClass::Error => ("fa-times-circle", css::ColorScheme::Error), + }; + let action = ActionIcon::new(classes!("fa", icon_class)) + .margin_end(1) + .class((count > 0).then_some(icon_scheme)) + .disabled(count == 0) + .on_activate(link.change_view_callback(move |_| { + ViewState::ShowFilteredTasks((task_type.clone(), task_class)) + })); + + Container::from_tag("span") + .with_child(action) + .with_child(count) + .into() +} + +impl ProxmoxTaskSummary { + fn task_summary_columns( + &self, + ctx: &LoadableComponentContext<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.task_type.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.task_type.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.task_type.clone(), + TaskStatusClass::Ok, + ) + } + }) + .into(), + ]) + } +} + +impl LoadableComponent for ProxmoxTaskSummary { + type Properties = TaskSummary; + type Message = Msg; + type ViewState = ViewState; + + fn create(_ctx: &LoadableComponentContext<Self>) -> Self { + let store = Store::new(); + store.set_data(extract_task_summary(&[])); + Self { + store, + task_store: Store::new(), + } + } + + fn load( + &self, + ctx: &LoadableComponentContext<Self>, + ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), anyhow::Error>>>> { + let link = ctx.link(); + let amount_hours = ctx.props().amount_hours; + Box::pin(async move { + let since = (Date::now() / 1000.0) as u64 - (amount_hours * 60 * 60); + + // TODO replace with pdm client call + let params = Some(json!({ + "since": since, + })); + + let res: Vec<_> = http_get("/remote-tasks/list", params).await?; + link.send_message(Msg::DataChange(res)); + Ok(()) + }) + } + + fn update(&mut self, _ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool { + match msg { + Msg::DataChange(data) => { + self.store.set_data(extract_task_summary(&data)); + self.task_store.set_data(data); + true + } + } + } + + fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html { + let columns = self.task_summary_columns(ctx); + let grid = DataTable::new(columns, self.store.clone()) + .class(pwt::css::FlexFit) + .striped(false) + .borderless(true) + .hover(true) + .show_header(false); + + let title: Html = Row::new() + .class(css::AlignItems::Center) + .gap(2) + .with_child(Fa::new("list-alt")) + .with_child(tr!("Task Summary")) + .into(); + + Panel::new() + .class(css::FlexFit) + .title(title) + .with_child(Container::new().padding(2).with_child(grid)) + .into() + } + + fn dialog_view( + &self, + ctx: &LoadableComponentContext<Self>, + view_state: &Self::ViewState, + ) -> Option<Html> { + match view_state { + ViewState::ShowFilteredTasks((task_type, task_status)) => { + let task_type = task_type.clone(); + let task_status = *task_status; + let status_text = match task_status { + TaskStatusClass::Ok => tr!("OK"), + TaskStatusClass::Warning => tr!("Warning"), + TaskStatusClass::Error => tr!("Error"), + }; + let title = format!("{} - {}", get_type_title(&task_type), status_text); + self.task_store.set_filter(move |task: &TaskListItem| { + let status: TaskStatusClass = match &task.status { + Some(status) => status.into(), + None => return false, + }; + + let worker_type = map_worker_type(&task.worker_type); + if task_type != worker_type { + return false; + } + + match (task_status, status) { + (TaskStatusClass::Ok, TaskStatusClass::Ok) => {} + (TaskStatusClass::Warning, TaskStatusClass::Warning) => {} + (TaskStatusClass::Error, TaskStatusClass::Error) => {} + _ => return false, + } + + true + }); + Some( + Dialog::new(title) + .min_width(800) + .resizable(true) + .on_close(ctx.link().change_view_callback(|_| None)) + .with_child( + DataTable::new(filtered_tasks_columns(ctx), self.task_store.clone()) + .min_height(600) + .class(pwt::css::FlexFit), + ) + .into(), + ) + } + ViewState::ShowTask((remote_upid, endtime)) => { + // TODO PBS + let base_url = format!("/pve/remotes/{}/tasks", remote_upid.remote()); + Some( + TaskViewer::new(remote_upid.to_string()) + .endtime(endtime) + .base_url(base_url) + .on_close(ctx.link().change_view_callback(|_| None)) + .into(), + ) + } + } + } +} + +fn filtered_tasks_columns( + ctx: &LoadableComponentContext<ProxmoxTaskSummary>, +) -> Rc<Vec<DataTableHeader<TaskListItem>>> { + Rc::new(vec![ + DataTableColumn::new(tr!("Task")) + .flex(1) + .render(|item: &TaskListItem| format_optional_remote_upid(&item.upid).into()) + .into(), + DataTableColumn::new(tr!("Start Time")) + .width("200px") + .render(|item: &TaskListItem| render_epoch(item.starttime).into()) + .into(), + DataTableColumn::new(tr!("Duration")) + .render(|item: &TaskListItem| { + let duration = match item.endtime { + Some(endtime) => endtime - item.starttime, + None => return html! {"-"}, + }; + html! {format_duration_human(duration as f64)} + }) + .into(), + DataTableColumn::new(tr!("Status")) + .justify("center") + .render(|item: &TaskListItem| { + let text = item.status.as_deref().unwrap_or(""); + let icon = match text.into() { + TaskStatusClass::Ok => Status::Success, + TaskStatusClass::Warning => Status::Warning, + TaskStatusClass::Error => Status::Error, + }; + icon.to_fa_icon().into() + }) + .into(), + DataTableColumn::new(tr!("Action")) + .justify("center") + .render({ + let link = ctx.link().clone(); + move |item: &TaskListItem| { + let upid = item.upid.clone(); + let endtime = item.endtime; + let icon = ActionIcon::new("fa fa-chevron-right").on_activate( + link.change_view_callback(move |_| { + upid.parse() + .map(|upid| ViewState::ShowTask((upid, endtime))) + .ok() + }), + ); + Tooltip::new(icon).tip(tr!("Open Task")).into() + } + }) + .into(), + ]) +} + +impl From<TaskSummary> for VNode { + fn from(val: TaskSummary) -> Self { + let comp = VComp::new::<LoadableComponentMaster<ProxmoxTaskSummary>>(Rc::new(val), None); + VNode::from(comp) + } +} diff --git a/ui/src/tasks.rs b/ui/src/tasks.rs index 6aa202a..a02a7f4 100644 --- a/ui/src/tasks.rs +++ b/ui/src/tasks.rs @@ -1,6 +1,9 @@ -use proxmox_yew_comp::utils::register_task_description; +use proxmox_yew_comp::utils::{format_task_description, format_upid, register_task_description}; use pwt::tr; +use pdm_api_types::RemoteUpid; +use pdm_client::types::PveUpid; + pub fn register_pve_tasks() { register_task_description("qmstart", ("VM", tr!("Start"))); register_task_description("acmedeactivate", ("ACME Account", tr!("Deactivate"))); @@ -99,3 +102,17 @@ pub fn register_pve_tasks() { register_task_description("zfscreate", (tr!("ZFS Storage"), tr!("Create"))); register_task_description("zfsremove", ("ZFS Pool", tr!("Remove"))); } + +/// Format a UPID that is either [`RemoteUpid`] or a [`UPID`] +/// If it's a [`RemoteUpid`], prefixes it with the remote name +pub fn format_optional_remote_upid(upid: &str) -> String { + if let Ok(remote_upid) = upid.parse::<RemoteUpid>() { + let description = match remote_upid.upid.parse::<PveUpid>() { + Ok(upid) => format_task_description(&upid.worker_type, upid.worker_id.as_deref()), + Err(_) => format_upid(&remote_upid.upid), + }; + format!("{} - {}", remote_upid.remote(), description) + } else { + format_upid(&upid) + } +} diff --git a/ui/src/top_nav_bar.rs b/ui/src/top_nav_bar.rs index 07b3b23..38ab503 100644 --- a/ui/src/top_nav_bar.rs +++ b/ui/src/top_nav_bar.rs @@ -13,15 +13,15 @@ use pwt::state::{Loader, Theme, ThemeObserver}; use pwt::widget::{Button, Container, Row, ThemeModeSelector, Tooltip}; use proxmox_yew_comp::common_api_types::TaskListItem; -use proxmox_yew_comp::utils::{format_task_description, format_upid, set_location_href}; +use proxmox_yew_comp::utils::set_location_href; use proxmox_yew_comp::RunningTasksButton; use proxmox_yew_comp::{http_get, HelpButton, LanguageDialog, TaskViewer, ThemeDialog}; use pwt_macros::builder; use pdm_api_types::RemoteUpid; -use pdm_client::types::PveUpid; +use crate::tasks::format_optional_remote_upid; use crate::widget::SearchBox; #[derive(Deserialize)] @@ -206,21 +206,7 @@ impl Component for PdmTopNavBar { set_location_href("#/remotes/tasks"); }), ]) - .render(|item: &TaskListItem| { - if let Ok(remote_upid) = (&item.upid).parse::<RemoteUpid>() { - let description = match remote_upid.upid.parse::<PveUpid>() { - Ok(upid) => format_task_description( - &upid.worker_type, - upid.worker_id.as_deref(), - ), - Err(_) => format_upid(&remote_upid.upid), - }; - format!("{} - {}", remote_upid.remote(), description) - } else { - format_upid(&item.upid) - } - .into() - }), + .render(|item: &TaskListItem| format_optional_remote_upid(&item.upid).into()), ); button_group.add_child( -- 2.39.5 _______________________________________________ pdm-devel mailing list pdm-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel