public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Dominik Csapak <d.csapak@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [pdm-devel] [PATCH datacenter-manager v3 17/21] ui: dashboard: implement 'View'
Date: Fri, 31 Oct 2025 13:44:00 +0100	[thread overview]
Message-ID: <20251031124822.2739685-18-d.csapak@proxmox.com> (raw)
In-Reply-To: <20251031124822.2739685-1-d.csapak@proxmox.com>

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.

We abstract the 'RowView' away in it's own module, so we can easily add
a new layout types.

To avoid some unnecessary cloning, use a 'SharedState' for the load
results, that way only Rc's will be cloned into the render callback.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/dashboard/mod.rs           |   2 +
 ui/src/dashboard/types.rs         |  70 +++++
 ui/src/dashboard/view.rs          | 454 ++++++++++++++++++++++++++++++
 ui/src/dashboard/view/row_view.rs | 138 +++++++++
 ui/src/pve/mod.rs                 |   4 +-
 5 files changed, 667 insertions(+), 1 deletion(-)
 create mode 100644 ui/src/dashboard/view.rs
 create mode 100644 ui/src/dashboard/view/row_view.rs

diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs
index 8857f82f..e381c6f9 100644
--- a/ui/src/dashboard/mod.rs
+++ b/ui/src/dashboard/mod.rs
@@ -54,6 +54,8 @@ use tasks::{create_task_summary_panel, get_task_options};
 
 pub mod types;
 
+pub mod view;
+
 mod refresh_config_edit;
 pub use refresh_config_edit::{
     create_refresh_config_edit_window, refresh_config_id, RefreshConfig,
diff --git a/ui/src/dashboard/types.rs b/ui/src/dashboard/types.rs
index 152d4f57..c79c38ab 100644
--- a/ui/src/dashboard/types.rs
+++ b/ui/src/dashboard/types.rs
@@ -1,5 +1,68 @@
 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<Vec<RowWidget>>,
+    },
+}
+
+#[derive(Serialize, Deserialize, PartialEq, Clone)]
+#[serde(rename_all = "kebab-case")]
+pub struct RowWidget {
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub flex: Option<f32>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub title: Option<String>,
+    #[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<RemoteType>,
+    },
+    #[serde(rename_all = "kebab-case")]
+    Guests {
+        #[serde(skip_serializing_if = "Option::is_none")]
+        guest_type: Option<GuestType>,
+    },
+    PbsDatastores,
+    #[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 +70,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..aaccef0d
--- /dev/null
+++ b/ui/src/dashboard/view.rs
@@ -0,0 +1,454 @@
+use std::rc::Rc;
+
+use anyhow::Error;
+use futures::join;
+use js_sys::Date;
+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, SharedState};
+use pwt::widget::{error_message, form::FormContext, Column, Container, 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::RowWidget;
+use crate::dashboard::types::{
+    LeaderboardType, TaskSummaryGrouping, ViewLayout, ViewTemplate, WidgetType,
+};
+use crate::dashboard::{
+    create_guest_panel, create_node_panel, create_pbs_datastores_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;
+
+mod row_view;
+pub use row_view::RowView;
+
+#[derive(Properties, PartialEq)]
+pub struct View {
+    view: AttrValue,
+}
+
+impl From<View> for VNode {
+    fn from(val: View) -> Self {
+        let comp = VComp::new::<ViewComp>(Rc::new(val), None);
+        VNode::from(comp)
+    }
+}
+
+impl View {
+    pub fn new(view: impl Into<AttrValue>) -> Self {
+        Self { view: view.into() }
+    }
+}
+
+pub enum LoadingResult {
+    Resources(Result<ResourcesStatus, Error>),
+    TopEntities(Result<pdm_client::types::TopEntities, proxmox_client::Error>),
+    TaskStatistics(Result<TaskStatistics, Error>),
+    All,
+}
+
+pub enum Msg {
+    ViewTemplateLoaded(Result<ViewTemplate, Error>),
+    LoadingResult(LoadingResult),
+    CreateWizard(Option<RemoteType>),
+    Reload(bool),       // force
+    ConfigWindow(bool), // show
+    UpdateConfig(RefreshConfig),
+}
+
+struct ViewComp {
+    template: LoadResult<ViewTemplate, Error>,
+
+    // various api call results
+    status: SharedState<LoadResult<ResourcesStatus, Error>>,
+    top_entities: SharedState<LoadResult<TopEntities, proxmox_client::Error>>,
+    statistics: SharedState<LoadResult<TaskStatistics, Error>>,
+
+    refresh_config: PersistentState<RefreshConfig>,
+
+    async_pool: AsyncPool,
+    loading: bool,
+    load_finished_time: Option<f64>,
+    show_config_window: bool,
+    show_create_wizard: Option<RemoteType>,
+}
+
+fn render_widget(
+    link: yew::html::Scope<ViewComp>,
+    item: &RowWidget,
+    status: SharedState<LoadResult<ResourcesStatus, Error>>,
+    top_entities: SharedState<LoadResult<TopEntities, proxmox_client::Error>>,
+    statistics: SharedState<LoadResult<TaskStatistics, Error>>,
+    refresh_config: RefreshConfig,
+) -> Html {
+    let status = status.read();
+    let top_entities = top_entities.read();
+    let statistics = statistics.read();
+
+    let mut widget = match &item.r#type {
+        WidgetType::Nodes { remote_type } => create_node_panel(*remote_type, status.data.clone()),
+        WidgetType::Guests { guest_type } => create_guest_panel(*guest_type, status.data.clone()),
+        WidgetType::Remotes { show_wizard } => create_remote_panel(
+            status.data.clone(),
+            show_wizard.then_some(link.callback(|_| Msg::CreateWizard(Some(RemoteType::Pve)))),
+            show_wizard.then_some(link.callback(|_| Msg::CreateWizard(Some(RemoteType::Pve)))),
+        ),
+        WidgetType::PbsDatastores => create_pbs_datastores_panel(status.data.clone()),
+        WidgetType::Subscription => create_subscription_panel(),
+        WidgetType::Sdn => create_sdn_panel(status.data.clone()),
+        WidgetType::Leaderboard { leaderboard_type } => {
+            let entities = match leaderboard_type {
+                LeaderboardType::GuestCpu => top_entities
+                    .data
+                    .as_ref()
+                    .map(|entities| entities.guest_cpu.clone()),
+                LeaderboardType::NodeCpu => top_entities
+                    .data
+                    .as_ref()
+                    .map(|entities| entities.node_cpu.clone()),
+                LeaderboardType::NodeMemory => top_entities
+                    .data
+                    .as_ref()
+                    .map(|entities| entities.node_memory.clone()),
+            };
+            create_top_entities_panel(entities, 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(refresh_config.task_last_hours);
+            create_task_summary_panel(
+                statistics.data.clone(),
+                statistics.error.as_ref(),
+                remotes,
+                hours,
+                since,
+            )
+        }
+    };
+
+    if let Some(title) = &item.title {
+        widget.set_title(title.clone());
+    }
+
+    widget.border(false).class(css::FlexFit).into()
+}
+
+impl ViewComp {
+    fn reload(&mut self, ctx: &yew::Context<Self>) {
+        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<Self>, 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<Rc<proxmox_yew_comp::HttpClientWasm>> =
+                            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
+                        | WidgetType::PbsDatastores => {
+                            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>) -> Self {
+        let refresh_config: PersistentState<RefreshConfig> = 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: SharedState::new(LoadResult::new()),
+            top_entities: SharedState::new(LoadResult::new()),
+            statistics: SharedState::new(LoadResult::new()),
+
+            refresh_config,
+            load_finished_time: None,
+            loading: true,
+            show_config_window: false,
+            show_create_wizard: None,
+        }
+    }
+
+    fn update(&mut self, ctx: &Context<Self>, 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.write().update(status),
+                LoadingResult::TopEntities(top_entities) => {
+                    self.top_entities.write().update(top_entities)
+                }
+                LoadingResult::TaskStatistics(task_statistics) => {
+                    self.statistics.write().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<Self>, _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<Self>) -> 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 }) => {
+                view.add_child(RowView::new(rows.clone(), {
+                    let link = ctx.link().clone();
+                    let status = self.status.clone();
+                    let top_entities = self.top_entities.clone();
+                    let statistics = self.statistics.clone();
+                    let refresh_config = self.refresh_config.clone();
+                    move |widget: &RowWidget| {
+                        render_widget(
+                            link.clone(),
+                            widget,
+                            status.clone(),
+                            top_entities.clone(),
+                            statistics.clone(),
+                            refresh_config.clone(),
+                        )
+                    }
+                }));
+            }
+            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<ViewTemplate, Error> {
+    // 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/dashboard/view/row_view.rs b/ui/src/dashboard/view/row_view.rs
new file mode 100644
index 00000000..69300327
--- /dev/null
+++ b/ui/src/dashboard/view/row_view.rs
@@ -0,0 +1,138 @@
+use std::collections::HashMap;
+use std::rc::Rc;
+
+use yew::virtual_dom::{VComp, VNode};
+
+use pwt::css;
+use pwt::prelude::*;
+use pwt::props::RenderFn;
+use pwt::widget::{Column, Container, Panel, Row};
+use pwt_macros::builder;
+
+use crate::dashboard::types::RowWidget;
+
+#[derive(Properties, PartialEq)]
+#[builder]
+pub struct RowView {
+    rows: Vec<Vec<RowWidget>>,
+    widget_renderer: RenderFn<RowWidget>,
+}
+
+impl RowView {
+    /// Creates a new RowView
+    pub fn new(rows: Vec<Vec<RowWidget>>, widget_renderer: impl Into<RenderFn<RowWidget>>) -> Self {
+        let widget_renderer = widget_renderer.into();
+        yew::props! { Self {rows, widget_renderer }}
+    }
+}
+
+impl From<RowView> for VNode {
+    fn from(val: RowView) -> Self {
+        let comp = VComp::new::<RowViewComp>(Rc::new(val), None);
+        VNode::from(comp)
+    }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+/// Represents the position of a widget in a row view
+pub struct Position {
+    row: usize,
+    item: usize,
+}
+
+pub struct RowViewComp {
+    current_layout: Vec<Vec<(Position, RowWidget)>>,
+}
+
+fn extract_row_layout(rows: &Vec<Vec<RowWidget>>) -> Vec<Vec<(Position, RowWidget)>> {
+    rows.iter()
+        .enumerate()
+        .map(|(row_idx, row)| {
+            row.iter()
+                .enumerate()
+                .map(|(item_idx, item)| {
+                    (
+                        Position {
+                            row: row_idx,
+                            item: item_idx,
+                        },
+                        item.clone(),
+                    )
+                })
+                .collect()
+        })
+        .collect()
+}
+
+impl Component for RowViewComp {
+    type Message = ();
+    type Properties = RowView;
+
+    fn create(ctx: &Context<Self>) -> Self {
+        let current_layout = extract_row_layout(&ctx.props().rows);
+
+        let mut next_row_indices = HashMap::new();
+        for (row_idx, row) in current_layout.iter().enumerate() {
+            next_row_indices.insert(row_idx, row.len());
+        }
+        Self { current_layout }
+    }
+
+    fn changed(&mut self, ctx: &Context<Self>, old_props: &Self::Properties) -> bool {
+        let props = ctx.props();
+
+        if props.rows != old_props.rows {
+            self.current_layout = extract_row_layout(&props.rows);
+        }
+
+        true
+    }
+
+    fn view(&self, ctx: &Context<Self>) -> Html {
+        let props = ctx.props();
+        let mut view = Column::new();
+        let layout = &self.current_layout;
+        let mut row = Row::new()
+            .padding_x(2)
+            .class("pwt-content-spacer-colors")
+            .class(css::FlexDirection::Row)
+            .class(css::FlexWrap::Wrap);
+        for (row_idx, items) in layout.iter().enumerate() {
+            let flex_sum: f32 = items
+                .iter()
+                .map(|(_, item)| 1.0f32.max(item.flex.unwrap_or(1.0)))
+                .sum();
+            let gaps_ratio = 1.0; //items.len().saturating_sub(1) as f32 / items.len() as f32;
+
+            for (_item_idx, (coords, item)) in items.iter().enumerate() {
+                let flex = item.flex.unwrap_or(1.0);
+                let flex_ratio = 95.0 * (flex.max(1.0)) / 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 widget = props.widget_renderer.apply(&item);
+                let row_element = Panel::new()
+                    .border(true)
+                    .margin_x(2)
+                    .margin_bottom(4)
+                    .key(format!("item-{}-{}", coords.row, coords.item))
+                    .style("flex", flex_style)
+                    .with_child(widget);
+
+                row.add_child(row_element);
+            }
+
+            row.add_child(
+                Container::new()
+                    .key(format!("spacer-{row_idx}"))
+                    .style("flex", "1 1 100%"),
+            );
+        }
+
+        view.add_child(row);
+        view.into()
+    }
+}
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


  parent reply	other threads:[~2025-10-31 12:48 UTC|newest]

Thread overview: 22+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2025-10-31 12:43 [pdm-devel] [PATCH datacenter-manager v3 00/21] prepare ui for customizable views Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 01/21] ui: dashboard: refactor guest panel creation to its own module Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 02/21] ui: dashboard: refactor creating the node panel into " Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 03/21] ui: dashboard: node panel: make remote type optional Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 04/21] ui: dashboard: refactor remote panel creation into its own module Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 05/21] ui: dashboard: remote panel: make wizard menu optional Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 06/21] ui: dashboard: refactor sdn panel creation into its own module Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 07/21] ui: dashboard: refactor task summary panel creation to " Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 08/21] ui: dashboard: task summary: disable virtual scrolling Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 09/21] ui: dashboard: refactor subscription panel creation to its own module Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 10/21] ui: dashboard: refactor top entities " Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 11/21] ui: dashboard: refactor DashboardConfig editing/constants to their module Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 12/21] ui: dashboard: factor out task parameter calculation Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 13/21] ui: dashboard: pbs datastores panel: refactor creation into own module Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 14/21] ui: dashboard: remove unused remote list Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 15/21] ui: dashboard: status row: make loading less jarring Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 16/21] ui: introduce `LoadResult` helper type Dominik Csapak
2025-10-31 12:44 ` Dominik Csapak [this message]
2025-10-31 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 18/21] ui: dashboard: use 'View' instead of the Dashboard Dominik Csapak
2025-10-31 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 19/21] ui: dashboard: subscription info: move subscription loading to view Dominik Csapak
2025-10-31 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 20/21] ui: dashboard: use SharedState for create_*_panel Dominik Csapak
2025-10-31 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 21/21] ui: dashboard: enable editing view Dominik Csapak

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20251031124822.2739685-18-d.csapak@proxmox.com \
    --to=d.csapak@proxmox.com \
    --cc=pdm-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal