public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: "Shannon Sterz" <s.sterz@proxmox.com>
To: "Dominik Csapak" <d.csapak@proxmox.com>
Cc: Proxmox Datacenter Manager development discussion
	<pdm-devel@lists.proxmox.com>
Subject: Re: [pdm-devel] [RFC PATCH datacenter-manager v2 15/16] ui: dashboard: implement 'View'
Date: Fri, 24 Oct 2025 12:17:25 +0200	[thread overview]
Message-ID: <DDQH4208RAP8.28R3YO54Z8UX2@proxmox.com> (raw)
In-Reply-To: <336b436e-10f4-4ad4-ad28-1b470fdecb56@proxmox.com>


[-- Attachment #1.1: Type: text/plain, Size: 1986 bytes --]

On 10/23/25 1:44 PM, Dominik Csapak wrote:
> On 10/23/25 1:19 PM, Shannon Sterz wrote:
>> On Thu Oct 23, 2025 at 10:28 AM CEST, Dominik Csapak wrote:
> [snip]
>>> +
>>> +struct ViewComp {
>>> +    template: LoadResult<ViewTemplate, Error>,
>>> +
>>> +    // various api call results
>>> +    status: LoadResult<ResourcesStatus, Error>,
>>> +    top_entities: LoadResult<TopEntities, proxmox_client::Error>,
>>> +    statistics: LoadResult<TaskStatistics, Error>,
>>
>> 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<ToQuery, LoadResult<ApiResponseData, Error>>
>>
>> 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)?
>
> i don't think this will work, since ApiResponseData itself takes a
> generic parameter too, and we can't use different ones for different
> values of the same hashmap AFAIK
>
> but yeah, we should think about how we could generalize this
> instead of just adding on new members...
>

sorry seems I dropped the list in my last response... so sending this
again.

thought about this a bit. we can leverage DeserializeOwned and Value
here quite a bit to make it work. i did a bit of messing around and came
up with the attached patch. this certainly isn't perfect or polished
(e.g. we could use wrapper types to move the get_entities function
somewhere more sensible; safe a clone here or there etc.), but the
general gist is there. it also works so yeah :)


[-- Attachment #2: 0001-wip-ui-view-refactor-loading-logic-to-use-hashmap.patch --]
[-- Type: text/x-patch, Size: 16051 bytes --]

From e266584fc8efa4504135e955c4e64ae7abda27b4 Mon Sep 17 00:00:00 2001
From: Shannon Sterz <s.sterz@proxmox.com>
Date: Fri, 24 Oct 2025 12:10:41 +0200
Subject: [PATCH] wip: ui: view: refactor loading logic to use hashmap

this is mostly just a poc that we could use a hashmap of loadable
entities instead of having to specify a field for each separately.
---
 server/src/metric_collection/top_entities.rs |   2 +-
 ui/src/dashboard/top_entities.rs             |   2 +-
 ui/src/dashboard/view.rs                     | 222 +++++++++++--------
 3 files changed, 128 insertions(+), 98 deletions(-)

diff --git a/server/src/metric_collection/top_entities.rs b/server/src/metric_collection/top_entities.rs
index ea121ee..73a3e63 100644
--- a/server/src/metric_collection/top_entities.rs
+++ b/server/src/metric_collection/top_entities.rs
@@ -36,7 +36,7 @@ pub fn calculate_top(
     remotes: &HashMap<String, pdm_api_types::remotes::Remote>,
     timeframe: proxmox_rrd_api_types::RrdTimeframe,
     num: usize,
-    check_remote_privs: impl Fn(&str) -> bool
+    check_remote_privs: impl Fn(&str) -> bool,
 ) -> TopEntities {
     let mut guest_cpu = Vec::new();
     let mut node_cpu = Vec::new();
diff --git a/ui/src/dashboard/top_entities.rs b/ui/src/dashboard/top_entities.rs
index dfe3869..25e62c4 100644
--- a/ui/src/dashboard/top_entities.rs
+++ b/ui/src/dashboard/top_entities.rs
@@ -328,7 +328,7 @@ fn graph_from_data(data: &Vec<Option<f64>>, threshold: f64) -> Container {
 
 pub fn create_top_entities_panel(
     entities: Option<Vec<TopEntity>>,
-    error: Option<&proxmox_client::Error>,
+    error: Option<&anyhow::Error>,
     leaderboard_type: LeaderboardType,
 ) -> Panel {
     let (icon, title, metrics_title, threshold) = match leaderboard_type {
diff --git a/ui/src/dashboard/view.rs b/ui/src/dashboard/view.rs
index 6cea9f8..80d1c55 100644
--- a/ui/src/dashboard/view.rs
+++ b/ui/src/dashboard/view.rs
@@ -1,10 +1,14 @@
+use std::collections::HashMap;
+use std::hash::Hash;
 use std::rc::Rc;
 
-use anyhow::Error;
-use futures::join;
+use anyhow::{format_err, Error};
+use futures::future::select_all;
+use html::Scope;
 use js_sys::Date;
-use pwt::widget::form::FormContext;
+use serde::de::DeserializeOwned;
 use serde_json::json;
+use serde_json::Value;
 use yew::virtual_dom::{VComp, VNode};
 
 use proxmox_yew_comp::http_get;
@@ -12,6 +16,7 @@ use pwt::css;
 use pwt::prelude::*;
 use pwt::props::StorageLocation;
 use pwt::state::PersistentState;
+use pwt::widget::form::FormContext;
 use pwt::widget::{error_message, Column, Container, Panel, Progress, Row};
 use pwt::AsyncPool;
 
@@ -32,8 +37,6 @@ 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)]
@@ -54,29 +57,67 @@ impl View {
     }
 }
 
-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),
+    LoadingResult((LoadableEntities, Result<Value, Error>)),
     CreateWizard(Option<RemoteType>),
     Reload(bool),       // force
     ConfigWindow(bool), // show
     UpdateConfig(RefreshConfig),
+    LoadingDone,
+}
+
+#[derive(Hash, PartialEq, Eq, Debug, Clone, Copy)]
+pub enum LoadableEntities {
+    Status,
+    TopEntities,
+    Statistics,
+}
+
+impl LoadableEntities {
+    async fn load(&self, max_age: u64, since: i64, link: &Scope<ViewComp>) {
+        let res = match self {
+            LoadableEntities::Status => {
+                http_get("/resources/status", Some(json!({"max-age": max_age}))).await
+            }
+            LoadableEntities::TopEntities => {
+                let client: pdm_client::PdmClient<Rc<proxmox_yew_comp::HttpClientWasm>> =
+                    pdm_client();
+                client
+                    .get_top_entities()
+                    .await
+                    .map(|r| serde_json::to_value(r).unwrap())
+                    .map_err(|e| format_err!("could not load top entities").context(e))
+            }
+            LoadableEntities::Statistics => {
+                let params = Some(json!({
+                    "since": since,
+                    "limit": 0,
+                }));
+                http_get("/remote-tasks/statistics", params).await
+            }
+        };
+
+        link.send_message(Msg::LoadingResult((*self, res)));
+    }
+}
+
+fn get_entity<T: DeserializeOwned>(
+    map: &HashMap<LoadableEntities, LoadResult<Value, Error>>,
+    key: &LoadableEntities,
+) -> Option<T> {
+    map.get(key).and_then(|d| {
+        d.data
+            .as_ref()
+            .and_then(|d| serde_json::from_value(d.clone()).ok())
+    })
 }
 
 struct ViewComp {
     template: LoadResult<ViewTemplate, Error>,
 
     // various api call results
-    status: LoadResult<ResourcesStatus, Error>,
-    top_entities: LoadResult<TopEntities, proxmox_client::Error>,
-    statistics: LoadResult<TaskStatistics, Error>,
+    load_results: HashMap<LoadableEntities, LoadResult<Value, Error>>,
 
     refresh_config: PersistentState<RefreshConfig>,
 
@@ -90,14 +131,16 @@ struct ViewComp {
 impl ViewComp {
     fn create_widget(&self, ctx: &yew::Context<Self>, 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::Nodes { remote_type } => create_node_panel(
+                *remote_type,
+                get_entity(&self.load_results, &LoadableEntities::Status),
+            ),
+            WidgetType::Guests { guest_type } => create_guest_panel(
+                *guest_type,
+                get_entity(&self.load_results, &LoadableEntities::Status),
+            ),
             WidgetType::Remotes { show_wizard } => create_remote_panel(
-                self.status.data.clone(),
+                get_entity(&self.load_results, &LoadableEntities::Status),
                 show_wizard.then_some(
                     ctx.link()
                         .callback(|_| Msg::CreateWizard(Some(RemoteType::Pve))),
@@ -108,28 +151,29 @@ impl ViewComp {
                 ),
             ),
             WidgetType::Subscription => create_subscription_panel(),
-            WidgetType::Sdn => create_sdn_panel(self.status.data.clone()),
+            WidgetType::Sdn => {
+                create_sdn_panel(get_entity(&self.load_results, &LoadableEntities::Status))
+            }
             WidgetType::Leaderboard { leaderboard_type } => {
+                let top_entities: Option<TopEntities> =
+                    get_entity(&self.load_results, &LoadableEntities::TopEntities);
+
                 let entities = match leaderboard_type {
-                    LeaderboardType::GuestCpu => self
-                        .top_entities
-                        .data
+                    LeaderboardType::GuestCpu => top_entities
                         .as_ref()
                         .map(|entities| entities.guest_cpu.clone()),
-                    LeaderboardType::NodeCpu => self
-                        .top_entities
-                        .data
+                    LeaderboardType::NodeCpu => top_entities
                         .as_ref()
                         .map(|entities| entities.node_cpu.clone()),
-                    LeaderboardType::NodeMemory => self
-                        .top_entities
-                        .data
+                    LeaderboardType::NodeMemory => top_entities
                         .as_ref()
                         .map(|entities| entities.node_memory.clone()),
                 };
                 create_top_entities_panel(
                     entities,
-                    self.top_entities.error.as_ref(),
+                    self.load_results
+                        .get(&LoadableEntities::TopEntities)
+                        .and_then(|t| t.error.as_ref()),
                     *leaderboard_type,
                 )
             }
@@ -140,8 +184,10 @@ impl ViewComp {
                 };
                 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(),
+                    get_entity(&self.load_results, &LoadableEntities::Statistics),
+                    self.load_results
+                        .get(&LoadableEntities::Statistics)
+                        .and_then(|t| t.error.as_ref()),
                     remotes,
                     hours,
                     since,
@@ -160,56 +206,41 @@ impl ViewComp {
     }
 
     fn do_reload(&mut self, ctx: &yew::Context<Self>, max_age: u64) {
-        if let Some(data) = self.template.data.as_ref() {
+        if self.template.data.as_ref().is_some() {
             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);
+            let keys = self
+                .load_results
+                .keys()
+                .cloned()
+                .collect::<Vec<LoadableEntities>>();
 
             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 mut futures = Vec::new();
 
-                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)));
-                    }
-                };
+                for key in keys {
+                    let key = key.clone();
+                    let link = link.clone();
+                    let future = Box::pin(async move { key.load(max_age, since, &link).await });
+                    futures.push(future);
+                }
 
-                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));
+                select_all(futures).await;
+                link.send_message(Msg::LoadingDone);
             });
         } else {
-            ctx.link()
-                .send_message(Msg::LoadingResult(LoadingResult::All));
+            ctx.link().send_message(Msg::LoadingDone);
         }
     }
 }
 
 // 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;
+fn required_api_calls(
+    map: &mut HashMap<LoadableEntities, LoadResult<Value, Error>>,
+    layout: &ViewLayout,
+) {
     match layout {
         ViewLayout::Rows { rows } => {
             for row in rows {
@@ -219,20 +250,22 @@ fn required_api_calls(layout: &ViewLayout) -> (bool, bool, bool) {
                         | WidgetType::Guests { .. }
                         | WidgetType::Remotes { .. }
                         | WidgetType::Sdn => {
-                            status = true;
+                            map.insert(LoadableEntities::Status, LoadResult::new());
                         }
                         WidgetType::Subscription => {
                             // panel does it itself, it's always required anyway
                         }
-                        WidgetType::Leaderboard { .. } => top_entities = true,
-                        WidgetType::TaskSummary { .. } => task_statistics = true,
+                        WidgetType::Leaderboard { .. } => {
+                            map.insert(LoadableEntities::TopEntities, LoadResult::new());
+                        }
+                        WidgetType::TaskSummary { .. } => {
+                            map.insert(LoadableEntities::Statistics, LoadResult::new());
+                        }
                     }
                 }
             }
         }
     }
-
-    (status, top_entities, task_statistics)
 }
 
 fn has_sub_panel(layout: Option<&ViewTemplate>) -> bool {
@@ -269,10 +302,7 @@ impl Component for ViewComp {
         Self {
             template: LoadResult::new(),
             async_pool,
-
-            status: LoadResult::new(),
-            top_entities: LoadResult::new(),
-            statistics: LoadResult::new(),
+            load_results: HashMap::new(),
 
             refresh_config,
             load_finished_time: None,
@@ -286,24 +316,24 @@ impl Component for ViewComp {
         match msg {
             Msg::ViewTemplateLoaded(view_template) => {
                 self.template.update(view_template);
+                if let Some(template) = self.template.data.as_ref() {
+                    required_api_calls(&mut self.load_results, &template.layout);
+                }
                 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)
+            Msg::LoadingResult((entity, result)) => {
+                self.load_results.get_mut(&entity).unwrap().update(result)
+            }
+            Msg::LoadingDone => {
+                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));
                 }
-                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);
-                }
-            },
+                self.load_finished_time = Some(Date::now() / 1000.0);
+            }
+
             Msg::CreateWizard(remote_type) => {
                 self.show_create_wizard = remote_type;
             }
-- 
2.47.3


[-- Attachment #3: Type: text/plain, Size: 160 bytes --]

_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel

  reply	other threads:[~2025-10-24 10:17 UTC|newest]

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

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=DDQH4208RAP8.28R3YO54Z8UX2@proxmox.com \
    --to=s.sterz@proxmox.com \
    --cc=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