From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id B71D41FF165 for ; Thu, 23 Oct 2025 13:19:26 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 3B5B17D33; Thu, 23 Oct 2025 13:19:54 +0200 (CEST) Mime-Version: 1.0 Date: Thu, 23 Oct 2025 13:19:40 +0200 Message-Id: To: "Dominik Csapak" X-Mailer: aerc 0.20.0 References: <20251023083253.1038119-1-d.csapak@proxmox.com> <20251023083253.1038119-16-d.csapak@proxmox.com> In-Reply-To: <20251023083253.1038119-16-d.csapak@proxmox.com> From: "Shannon Sterz" X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1761218372546 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.056 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: Re: [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 Cc: 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" On Thu Oct 23, 2025 at 10:28 AM CEST, Dominik Csapak wrote: > 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, this is fine, but i just had an idea, maybe this isn't too useful right now, but might be worth exploring: we could turn this into a HashMap with something like this: HashMap> then loading could become iterating over the keys and calling a function on them. with a wrapper type we could even implement a getter that transforms the ApiResponseData to a concrete type. might cut down on the loading logic below and make this more easily extensible in the future. the required_api_calls below could then just return such a hashmap with only the necessary keys. what do you think (note i haven't tested any of this)? > + 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") since this is used here quite extensively, might make sense to also give that a type in the `css` module, but that's unrelated to this series > + .class(css::FlexDirection::Row) just something i'm curious about, but is this necessary? shouldn't a `Row` already be `FlexDirection::Row`? or more accurately, isn't it by default? > + .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, _______________________________________________ pdm-devel mailing list pdm-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel