[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