public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Dominik Csapak <d.csapak@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [pdm-devel] [PATCH datacenter-manager v3 7/8] ui: dashboard: add task summaries
Date: Mon, 25 Aug 2025 10:08:43 +0200	[thread overview]
Message-ID: <20250825081042.797559-8-d.csapak@proxmox.com> (raw)
In-Reply-To: <20250825081042.797559-1-d.csapak@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   | 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
+}
+
+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()
+    }
+}
-- 
2.47.2



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


  parent reply	other threads:[~2025-08-25  8:10 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 ` Dominik Csapak [this message]
2025-08-25 10:52   ` [pdm-devel] [PATCH datacenter-manager v3 7/8] ui: dashboard: add task summaries Stefan Hanreich
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=20250825081042.797559-8-d.csapak@proxmox.com \
    --to=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
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal