From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id 738551FF165 for ; Thu, 23 Oct 2025 10:33:12 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id CFF903A07; Thu, 23 Oct 2025 10:33:39 +0200 (CEST) From: Dominik Csapak To: pdm-devel@lists.proxmox.com Date: Thu, 23 Oct 2025 10:28:19 +0200 Message-ID: <20251023083253.1038119-16-d.csapak@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20251023083253.1038119-1-d.csapak@proxmox.com> References: <20251023083253.1038119-1-d.csapak@proxmox.com> MIME-Version: 1.0 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.028 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Subject: [pdm-devel] [RFC PATCH datacenter-manager v2 15/16] ui: dashboard: implement 'View' X-BeenThere: pdm-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Reply-To: Proxmox Datacenter Manager development discussion Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: pdm-devel-bounces@lists.proxmox.com Sender: "pdm-devel" this is a more generalized version of our `Dashboard`, which can be configured with a `ViewTemplate` that is completely serializable, so we're able to dynamically configure it (e.g. in the future over the api) An example of such a configuration could be, e.g.: { "layout": { "layout-type": "rows", "rows": [ [ { "widget-type": "remotes", "show-wizard": true } ], [ { "widget-type": "leaderboard", "leaderboard-type": "guest-cpu" } ] ] } } Implemented are all widget our Dashboard holds, but this is easily extendable. Signed-off-by: Dominik Csapak --- changes from v1: * correctly mark as RFC ui/src/dashboard/mod.rs | 1 + ui/src/dashboard/types.rs | 69 ++++++ ui/src/dashboard/view.rs | 456 ++++++++++++++++++++++++++++++++++++++ ui/src/pve/mod.rs | 4 +- 4 files changed, 529 insertions(+), 1 deletion(-) create mode 100644 ui/src/dashboard/view.rs diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs index a394ea81..8a0bdad0 100644 --- a/ui/src/dashboard/mod.rs +++ b/ui/src/dashboard/mod.rs @@ -51,6 +51,7 @@ use tasks::{create_task_summary_panel, get_task_options}; pub mod types; +pub mod view; mod refresh_config_edit; pub use refresh_config_edit::{ diff --git a/ui/src/dashboard/types.rs b/ui/src/dashboard/types.rs index 152d4f57..8899246e 100644 --- a/ui/src/dashboard/types.rs +++ b/ui/src/dashboard/types.rs @@ -1,5 +1,67 @@ use serde::{Deserialize, Serialize}; +use pdm_api_types::remotes::RemoteType; + +use crate::pve::GuestType; + +#[derive(Serialize, Deserialize, PartialEq, Clone)] +#[serde(rename_all = "kebab-case")] +pub struct ViewTemplate { + #[serde(skip_serializing_if = "String::is_empty")] + pub description: String, + pub layout: ViewLayout, +} + +#[derive(Serialize, Deserialize, PartialEq, Clone)] +#[serde(rename_all = "kebab-case")] +#[serde(tag = "layout-type")] +pub enum ViewLayout { + Rows { + #[serde(skip_serializing_if = "Vec::is_empty")] + rows: Vec>, + }, +} + +#[derive(Serialize, Deserialize, PartialEq, Clone)] +#[serde(rename_all = "kebab-case")] +pub struct RowWidget { + #[serde(skip_serializing_if = "Option::is_none")] + pub flex: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(flatten)] + pub r#type: WidgetType, +} + +#[derive(Serialize, Deserialize, PartialEq, Clone)] +#[serde(rename_all = "kebab-case")] +#[serde(tag = "widget-type")] +pub enum WidgetType { + #[serde(rename_all = "kebab-case")] + Nodes { + #[serde(skip_serializing_if = "Option::is_none")] + remote_type: Option, + }, + #[serde(rename_all = "kebab-case")] + Guests { + #[serde(skip_serializing_if = "Option::is_none")] + guest_type: Option, + }, + #[serde(rename_all = "kebab-case")] + Remotes { + show_wizard: bool, + }, + Subscription, + Sdn, + #[serde(rename_all = "kebab-case")] + Leaderboard { + leaderboard_type: LeaderboardType, + }, + TaskSummary { + grouping: TaskSummaryGrouping, + }, +} + #[derive(Serialize, Deserialize, PartialEq, Clone, Copy)] #[serde(rename_all = "kebab-case")] pub enum LeaderboardType { @@ -7,3 +69,10 @@ pub enum LeaderboardType { NodeCpu, NodeMemory, } + +#[derive(Serialize, Deserialize, PartialEq, Clone, Copy)] +#[serde(rename_all = "kebab-case")] +pub enum TaskSummaryGrouping { + Category, + Remote, +} diff --git a/ui/src/dashboard/view.rs b/ui/src/dashboard/view.rs new file mode 100644 index 00000000..7159d4e8 --- /dev/null +++ b/ui/src/dashboard/view.rs @@ -0,0 +1,456 @@ +use std::rc::Rc; + +use anyhow::Error; +use futures::join; +use js_sys::Date; +use pwt::widget::form::FormContext; +use serde_json::json; +use yew::virtual_dom::{VComp, VNode}; + +use proxmox_yew_comp::http_get; +use pwt::css; +use pwt::prelude::*; +use pwt::props::StorageLocation; +use pwt::state::PersistentState; +use pwt::widget::{error_message, Column, Container, Panel, Progress, Row}; +use pwt::AsyncPool; + +use crate::dashboard::refresh_config_edit::{ + refresh_config_id, RefreshConfig, DEFAULT_MAX_AGE_S, DEFAULT_REFRESH_INTERVAL_S, + FORCE_RELOAD_MAX_AGE_S, INITIAL_MAX_AGE_S, +}; +use crate::dashboard::tasks::get_task_options; +use crate::dashboard::types::{ + LeaderboardType, TaskSummaryGrouping, ViewLayout, ViewTemplate, WidgetType, +}; +use crate::dashboard::{ + create_guest_panel, create_node_panel, create_refresh_config_edit_window, create_remote_panel, + create_sdn_panel, create_subscription_panel, create_task_summary_panel, + create_top_entities_panel, DashboardStatusRow, +}; +use crate::remotes::AddWizard; +use crate::{pdm_client, LoadResult}; + +use pdm_api_types::remotes::RemoteType; +use pdm_api_types::resource::ResourcesStatus; +use pdm_api_types::TaskStatistics; +use pdm_client::types::TopEntities; + +#[derive(Properties, PartialEq)] +pub struct View { + view: AttrValue, +} + +impl From for VNode { + fn from(val: View) -> Self { + let comp = VComp::new::(Rc::new(val), None); + VNode::from(comp) + } +} + +impl View { + pub fn new(view: impl Into) -> Self { + Self { view: view.into() } + } +} + +pub enum LoadingResult { + Resources(Result), + TopEntities(Result), + TaskStatistics(Result), + All, +} + +pub enum Msg { + ViewTemplateLoaded(Result), + LoadingResult(LoadingResult), + CreateWizard(Option), + Reload(bool), // force + ConfigWindow(bool), // show + UpdateConfig(RefreshConfig), +} + +struct ViewComp { + template: LoadResult, + + // various api call results + status: LoadResult, + top_entities: LoadResult, + statistics: LoadResult, + + refresh_config: PersistentState, + + async_pool: AsyncPool, + loading: bool, + load_finished_time: Option, + show_config_window: bool, + show_create_wizard: Option, +} + +impl ViewComp { + fn create_widget(&self, ctx: &yew::Context, widget: &WidgetType) -> Panel { + match widget { + WidgetType::Nodes { remote_type } => { + create_node_panel(*remote_type, self.status.data.clone()) + } + WidgetType::Guests { guest_type } => { + create_guest_panel(*guest_type, self.status.data.clone()) + } + WidgetType::Remotes { show_wizard } => create_remote_panel( + self.status.data.clone(), + show_wizard.then_some( + ctx.link() + .callback(|_| Msg::CreateWizard(Some(RemoteType::Pve))), + ), + show_wizard.then_some( + ctx.link() + .callback(|_| Msg::CreateWizard(Some(RemoteType::Pve))), + ), + ), + WidgetType::Subscription => create_subscription_panel(), + WidgetType::Sdn => create_sdn_panel(self.status.data.clone()), + WidgetType::Leaderboard { leaderboard_type } => { + let entities = match leaderboard_type { + LeaderboardType::GuestCpu => self + .top_entities + .data + .as_ref() + .map(|entities| entities.guest_cpu.clone()), + LeaderboardType::NodeCpu => self + .top_entities + .data + .as_ref() + .map(|entities| entities.node_cpu.clone()), + LeaderboardType::NodeMemory => self + .top_entities + .data + .as_ref() + .map(|entities| entities.node_memory.clone()), + }; + create_top_entities_panel( + entities, + self.top_entities.error.as_ref(), + *leaderboard_type, + ) + } + WidgetType::TaskSummary { grouping } => { + let remotes = match grouping { + TaskSummaryGrouping::Category => None, + TaskSummaryGrouping::Remote => Some(5), + }; + let (hours, since) = get_task_options(self.refresh_config.task_last_hours); + create_task_summary_panel( + self.statistics.data.clone(), + self.statistics.error.as_ref(), + remotes, + hours, + since, + ) + } + } + } + + fn reload(&mut self, ctx: &yew::Context) { + let max_age = if self.load_finished_time.is_some() { + self.refresh_config.max_age.unwrap_or(DEFAULT_MAX_AGE_S) + } else { + INITIAL_MAX_AGE_S + }; + self.do_reload(ctx, max_age) + } + + fn do_reload(&mut self, ctx: &yew::Context, max_age: u64) { + if let Some(data) = self.template.data.as_ref() { + let link = ctx.link().clone(); + let (_, since) = get_task_options(self.refresh_config.task_last_hours); + let (status, top_entities, tasks) = required_api_calls(&data.layout); + + self.loading = true; + self.async_pool.spawn(async move { + let status_future = async { + if status { + let res = + http_get("/resources/status", Some(json!({"max-age": max_age}))).await; + link.send_message(Msg::LoadingResult(LoadingResult::Resources(res))); + } + }; + + let entities_future = async { + if top_entities { + let client: pdm_client::PdmClient> = + pdm_client(); + let res = client.get_top_entities().await; + link.send_message(Msg::LoadingResult(LoadingResult::TopEntities(res))); + } + }; + + let tasks_future = async { + if tasks { + let params = Some(json!({ + "since": since, + "limit": 0, + })); + let res = http_get("/remote-tasks/statistics", params).await; + link.send_message(Msg::LoadingResult(LoadingResult::TaskStatistics(res))); + } + }; + + join!(status_future, entities_future, tasks_future); + link.send_message(Msg::LoadingResult(LoadingResult::All)); + }); + } else { + ctx.link() + .send_message(Msg::LoadingResult(LoadingResult::All)); + } + } +} + +// returns which api calls are required: status, top_entities, task statistics +fn required_api_calls(layout: &ViewLayout) -> (bool, bool, bool) { + let mut status = false; + let mut top_entities = false; + let mut task_statistics = false; + match layout { + ViewLayout::Rows { rows } => { + for row in rows { + for item in row { + match item.r#type { + WidgetType::Nodes { .. } + | WidgetType::Guests { .. } + | WidgetType::Remotes { .. } + | WidgetType::Sdn => { + status = true; + } + WidgetType::Subscription => { + // panel does it itself, it's always required anyway + } + WidgetType::Leaderboard { .. } => top_entities = true, + WidgetType::TaskSummary { .. } => task_statistics = true, + } + } + } + } + } + + (status, top_entities, task_statistics) +} + +fn has_sub_panel(layout: Option<&ViewTemplate>) -> bool { + match layout.map(|template| &template.layout) { + Some(ViewLayout::Rows { rows }) => { + for row in rows { + for item in row { + if item.r#type == WidgetType::Subscription { + return true; + } + } + } + } + None => {} + } + + false +} + +impl Component for ViewComp { + type Message = Msg; + type Properties = View; + + fn create(ctx: &yew::Context) -> Self { + let refresh_config: PersistentState = PersistentState::new( + StorageLocation::local(refresh_config_id(ctx.props().view.as_str())), + ); + + let async_pool = AsyncPool::new(); + async_pool.send_future(ctx.link().clone(), async move { + Msg::ViewTemplateLoaded(load_template().await) + }); + + Self { + template: LoadResult::new(), + async_pool, + + status: LoadResult::new(), + top_entities: LoadResult::new(), + statistics: LoadResult::new(), + + refresh_config, + load_finished_time: None, + loading: true, + show_config_window: false, + show_create_wizard: None, + } + } + + fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { + match msg { + Msg::ViewTemplateLoaded(view_template) => { + self.template.update(view_template); + self.reload(ctx); + } + Msg::LoadingResult(loading_result) => match loading_result { + LoadingResult::Resources(status) => self.status.update(status), + LoadingResult::TopEntities(top_entities) => self.top_entities.update(top_entities), + LoadingResult::TaskStatistics(task_statistics) => { + self.statistics.update(task_statistics) + } + LoadingResult::All => { + self.loading = false; + if self.load_finished_time.is_none() { + // immediately trigger a "normal" reload after the first load with the + // configured or default max-age to ensure users sees more current data. + ctx.link().send_message(Msg::Reload(false)); + } + self.load_finished_time = Some(Date::now() / 1000.0); + } + }, + Msg::CreateWizard(remote_type) => { + self.show_create_wizard = remote_type; + } + Msg::Reload(force) => { + if force { + self.do_reload(ctx, FORCE_RELOAD_MAX_AGE_S); + } else { + self.reload(ctx); + } + } + + Msg::ConfigWindow(show) => { + self.show_config_window = show; + } + Msg::UpdateConfig(dashboard_config) => { + let (old_hours, _) = get_task_options(self.refresh_config.task_last_hours); + self.refresh_config.update(dashboard_config); + let (new_hours, _) = get_task_options(self.refresh_config.task_last_hours); + + if old_hours != new_hours { + self.reload(ctx); + } + + self.show_config_window = false; + } + } + true + } + + fn changed(&mut self, ctx: &Context, _old_props: &Self::Properties) -> bool { + self.async_pool = AsyncPool::new(); + self.load_finished_time = None; + self.async_pool.send_future(ctx.link().clone(), async move { + Msg::ViewTemplateLoaded(load_template().await) + }); + true + } + + fn view(&self, ctx: &yew::Context) -> yew::Html { + if !self.template.has_data() { + return Progress::new().into(); + } + let mut view = Column::new().class(css::FlexFit).with_child( + Container::new() + .class("pwt-content-spacer-padding") + .class("pwt-content-spacer-colors") + .class("pwt-default-colors") + .with_child(DashboardStatusRow::new( + self.load_finished_time, + self.refresh_config + .refresh_interval + .unwrap_or(DEFAULT_REFRESH_INTERVAL_S), + ctx.link().callback(Msg::Reload), + ctx.link().callback(|_| Msg::ConfigWindow(true)), + )), + ); + if !has_sub_panel(self.template.data.as_ref()) { + view.add_child( + Row::new() + .class("pwt-content-spacer") + .with_child(create_subscription_panel()), + ); + } + match self.template.data.as_ref().map(|template| &template.layout) { + Some(ViewLayout::Rows { rows }) => { + for items in rows { + let mut row = Row::new() + .gap(4) + .padding_top(0) + .class("pwt-content-spacer") + .class(css::FlexDirection::Row) + .class(css::FlexWrap::Wrap); + let flex_sum: f32 = items.iter().map(|item| item.flex.unwrap_or(1.0)).sum(); + let gaps_ratio = items.len().saturating_sub(1) as f32 / items.len() as f32; + for item in items { + let flex = item.flex.unwrap_or(1.0); + let flex_ratio = 100.0 * flex / flex_sum; + // we have to subtract the gaps too + let flex_style = format!( + "{} {} calc({}% - calc({} * var(--pwt-spacer-4)))", + flex, flex, flex_ratio, gaps_ratio + ); + let mut widget = self + .create_widget(ctx, &item.r#type) + .style("flex", flex_style); + if let Some(title) = item.title.clone() { + widget.set_title(title); + } + row.add_child(widget); + } + view.add_child(row); + } + } + None => {} + } + // fill remaining space + view.add_child( + Container::new() + .class(css::Flex::Fill) + .class("pwt-content-spacer"), + ); + view.add_optional_child( + self.template + .error + .as_ref() + .map(|err| error_message(&err.to_string())), + ); + view.add_optional_child( + self.show_config_window.then_some( + create_refresh_config_edit_window(&ctx.props().view) + .on_close(ctx.link().callback(|_| Msg::ConfigWindow(false))) + .on_submit({ + let link = ctx.link().clone(); + move |ctx: FormContext| { + let link = link.clone(); + async move { + let data: RefreshConfig = + serde_json::from_value(ctx.get_submit_data())?; + link.send_message(Msg::UpdateConfig(data)); + Ok(()) + } + } + }), + ), + ); + view.add_optional_child(self.show_create_wizard.map(|remote_type| { + AddWizard::new(remote_type) + .on_close(ctx.link().callback(|_| Msg::CreateWizard(None))) + .on_submit(move |ctx| crate::remotes::create_remote(ctx, remote_type)) + })); + view.into() + } +} + +async fn load_template() -> Result { + // FIXME: load template from api + + let view_str = " + { + \"description\": \"some description\", + \"layout\": { + \"layout-type\": \"rows\", + \"rows\": [] + } + } + "; + + let template: ViewTemplate = serde_json::from_str(view_str)?; + Ok(template) +} diff --git a/ui/src/pve/mod.rs b/ui/src/pve/mod.rs index 058424a9..256744ea 100644 --- a/ui/src/pve/mod.rs +++ b/ui/src/pve/mod.rs @@ -3,6 +3,7 @@ use std::{fmt::Display, rc::Rc}; use gloo_utils::window; use proxmox_client::Error; use proxmox_yew_comp::{LoadableComponent, LoadableComponentContext, LoadableComponentMaster}; +use serde::{Deserialize, Serialize}; use yew::{ prelude::Html, virtual_dom::{VComp, VNode}, @@ -67,7 +68,8 @@ impl std::fmt::Display for Action { } } -#[derive(PartialEq, Clone, Copy)] +#[derive(PartialEq, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] pub enum GuestType { Qemu, Lxc, -- 2.47.3 _______________________________________________ pdm-devel mailing list pdm-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel