all lists on lists.proxmox.com
 help / color / mirror / Atom feed
* [pdm-devel] [PATCH datacenter-manager] ui: dashboard: add task summary
@ 2025-01-23 15:10 Dominik Csapak
  2025-01-29 18:18 ` Thomas Lamprecht
  0 siblings, 1 reply; 2+ messages in thread
From: Dominik Csapak @ 2025-01-23 15:10 UTC (permalink / raw)
  To: pdm-devel

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


^ permalink raw reply	[flat|nested] 2+ messages in thread

end of thread, other threads:[~2025-01-29 18:19 UTC | newest]

Thread overview: 2+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2025-01-23 15:10 [pdm-devel] [PATCH datacenter-manager] ui: dashboard: add task summary Dominik Csapak
2025-01-29 18:18 ` Thomas Lamprecht

This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal