From: Dominik Csapak <d.csapak@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [pdm-devel] [PATCH datacenter-manager v4 7/8] ui: dashboard: add task summaries
Date: Tue, 26 Aug 2025 12:15:17 +0200 [thread overview]
Message-ID: <20250826102229.2271453-8-d.csapak@proxmox.com> (raw)
In-Reply-To: <20250826102229.2271453-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>
---
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<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..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<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<(TaskWorkerType, TaskStatusClass)>), // task_tyope
+}
+
+#[derive(PartialEq)]
+pub enum ViewState {}
+
+#[doc(hidden)]
+pub struct ProxmoxTaskSummary {
+ store: Store<TaskSummaryItem>,
+ task_filters: Option<(TaskWorkerType, TaskStatusClass)>,
+}
+
+fn extract_task_summary(data: &TaskStatistics) -> Vec<TaskSummaryItem> {
+ let mut map: BTreeMap<TaskWorkerType, TaskSummaryItem> = 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<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(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<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: 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<Self>,
+ ) -> Rc<Vec<DataTableHeader<TaskSummaryItem>>> {
+ 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<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)| {
+ 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
next prev parent reply other threads:[~2025-08-26 10:22 UTC|newest]
Thread overview: 11+ messages / expand[flat|nested] mbox.gz Atom feed top
2025-08-26 10:15 [pdm-devel] [PATCH datacenter-manager v4 0/8] add task summary panels in dashboard Dominik Csapak
2025-08-26 10:15 ` [pdm-devel] [PATCH datacenter-manager v4 1/8] server: task cache: treat a limit of 0 as unbounded Dominik Csapak
2025-08-26 10:15 ` [pdm-devel] [PATCH datacenter-manager v4 2/8] server: api: remote tasks: add 'remote' filter option Dominik Csapak
2025-08-26 10:15 ` [pdm-devel] [PATCH datacenter-manager v4 3/8] server: api: add remote-tasks statistics Dominik Csapak
2025-08-26 10:15 ` [pdm-devel] [PATCH datacenter-manager v4 4/8] ui: refactor remote upid formatter Dominik Csapak
2025-08-26 10:15 ` [pdm-devel] [PATCH datacenter-manager v4 5/8] ui: tasks: add helper to summarize task categories Dominik Csapak
2025-08-26 10:15 ` [pdm-devel] [PATCH datacenter-manager v4 6/8] ui: add dialog to show filtered tasks Dominik Csapak
2025-08-26 10:15 ` Dominik Csapak [this message]
2025-08-26 10:15 ` [pdm-devel] [PATCH datacenter-manager v4 8/8] ui: dashboard: make task summary time range configurable Dominik Csapak
2025-08-26 12:41 ` [pdm-devel] [PATCH datacenter-manager v4 0/8] add task summary panels in dashboard Stefan Hanreich
2025-09-04 19:21 ` [pdm-devel] applied-series: " Thomas Lamprecht
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=20250826102229.2271453-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