[pdm-devel] [PATCH datacenter-manager] ui: dashboard: add task summary

Dominik Csapak d.csapak at proxmox.com
Thu Jan 23 16:10:12 CET 2025


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 at 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





More information about the pdm-devel mailing list