From: Stefan Hanreich <s.hanreich@proxmox.com>
To: Proxmox Datacenter Manager development discussion
<pdm-devel@lists.proxmox.com>,
Dominik Csapak <d.csapak@proxmox.com>
Subject: Re: [pdm-devel] [PATCH datacenter-manager v3 7/8] ui: dashboard: add task summaries
Date: Mon, 25 Aug 2025 12:52:28 +0200 [thread overview]
Message-ID: <46f65a30-925b-4741-9b4a-b13083f31221@proxmox.com> (raw)
In-Reply-To: <20250825081042.797559-8-d.csapak@proxmox.com>
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 <d.csapak@proxmox.com>
> ---
> 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<ResourcesStatus, Error>,
> Result<pdm_client::types::TopEntities, proxmox_client::Error>,
> + Result<TaskStatistics, Error>,
> );
>
> pub enum Msg {
> @@ -91,11 +98,19 @@ pub enum Msg {
> ConfigWindow(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,
> load_finished_time: Option<f64>,
> remote_list: RemoteList,
> @@ -174,6 +189,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,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<Self>, 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<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
> +}
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<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(),
> + ])
> + }
> +
> + fn update_task_statistics(&mut self, ctx: &Context<Self>) {
> + 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>) -> Self {
> + let mut this = Self {
> + store: Store::new(),
> + task_filters: None,
> + };
> +
> + this.update_task_statistics(ctx);
> + this
> + }
> +
> + fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
> + match msg {
> + Msg::ShowFilteredTasks(filters) => {
> + self.task_filters = filters;
> + }
> + }
> + true
> + }
> +
> + fn changed(&mut self, ctx: &Context<Self>, old_props: &Self::Properties) -> bool {
> + if old_props.statistics != ctx.props().statistics {
> + self.update_task_statistics(ctx);
> + }
> +
> + 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()
> + }
> +}
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
next prev parent reply other threads:[~2025-08-25 10:52 UTC|newest]
Thread overview: 19+ messages / expand[flat|nested] mbox.gz Atom feed top
2025-08-25 8:08 [pdm-devel] [PATCH datacenter-manager v3 0/8] add task summary panels in dashboard Dominik Csapak
2025-08-25 8:08 ` [pdm-devel] [PATCH datacenter-manager v3 1/8] server: task cache: treat a limit of 0 as unbounded Dominik Csapak
2025-08-25 9:55 ` Stefan Hanreich
2025-08-25 10:49 ` Dominik Csapak
2025-08-25 13:40 ` Stefan Hanreich
2025-08-25 8:08 ` [pdm-devel] [PATCH datacenter-manager v3 2/8] server: api: remote tasks: add 'remote' filter option Dominik Csapak
2025-08-25 8:08 ` [pdm-devel] [PATCH datacenter-manager v3 3/8] server: api: add remote-tasks statistics Dominik Csapak
2025-08-25 8:08 ` [pdm-devel] [PATCH datacenter-manager v3 4/8] ui: refactor remote upid formatter Dominik Csapak
2025-08-25 10:56 ` Stefan Hanreich
2025-08-26 9:48 ` Dominik Csapak
2025-08-25 8:08 ` [pdm-devel] [PATCH datacenter-manager v3 5/8] ui: tasks: add helper to summarize task categories Dominik Csapak
2025-08-25 9:54 ` Stefan Hanreich
2025-08-25 8:08 ` [pdm-devel] [PATCH datacenter-manager v3 6/8] ui: add dialog to show filtered tasks Dominik Csapak
2025-08-25 8:08 ` [pdm-devel] [PATCH datacenter-manager v3 7/8] ui: dashboard: add task summaries Dominik Csapak
2025-08-25 10:52 ` Stefan Hanreich [this message]
2025-08-25 8:08 ` [pdm-devel] [PATCH datacenter-manager v3 8/8] ui: dashboard: make task summary time range configurable Dominik Csapak
2025-08-25 9:54 ` Stefan Hanreich
2025-08-25 13:17 ` [pdm-devel] [PATCH datacenter-manager v3 0/8] add task summary panels in dashboard Stefan Hanreich
2025-08-26 11:22 ` [pdm-devel] superseded: " Dominik Csapak
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=46f65a30-925b-4741-9b4a-b13083f31221@proxmox.com \
--to=s.hanreich@proxmox.com \
--cc=d.csapak@proxmox.com \
--cc=pdm-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox