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
next prev parent 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