From: Dominik Csapak <d.csapak@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [pdm-devel] [PATCH datacenter-manager 14/15] ui: dashboard: implement 'View'
Date: Tue, 21 Oct 2025 16:03:30 +0200 [thread overview]
Message-ID: <20251021140801.3611022-15-d.csapak@proxmox.com> (raw)
In-Reply-To: <20251021140801.3611022-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.
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
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<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>,
+ },
+ #[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<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: LoadResult<ResourcesStatus, Error>,
+ top_entities: LoadResult<TopEntities, proxmox_client::Error>,
+ statistics: 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>,
+}
+
+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::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<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 => {
+ 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: 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<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.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<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 }) => {
+ for items in rows {
+ let mut row = Row::new()
+ .gap(4)
+ .padding_top(0)
+ .class("pwt-content-spacer")
+ .class(css::FlexDirection::Row)
+ .class(css::FlexWrap::Wrap);
+ let flex_sum: f32 = items.iter().map(|item| item.flex.unwrap_or(1.0)).sum();
+ let gaps_ratio = items.len().saturating_sub(1) as f32 / items.len() as f32;
+ for item in items {
+ let flex = item.flex.unwrap_or(1.0);
+ let flex_ratio = 100.0 * flex / flex_sum;
+ // we have to subtract the gaps too
+ let flex_style = format!(
+ "{} {} calc({}% - calc({} * var(--pwt-spacer-4)))",
+ flex, flex, flex_ratio, gaps_ratio
+ );
+ let mut widget = self
+ .create_widget(ctx, &item.r#type)
+ .style("flex", flex_style);
+ if let Some(title) = item.title.clone() {
+ widget.set_title(title);
+ }
+ row.add_child(widget);
+ }
+ view.add_child(row);
+ }
+ }
+ None => {}
+ }
+ // fill remaining space
+ view.add_child(
+ Container::new()
+ .class(css::Flex::Fill)
+ .class("pwt-content-spacer"),
+ );
+ view.add_optional_child(
+ self.template
+ .error
+ .as_ref()
+ .map(|err| error_message(&err.to_string())),
+ );
+ view.add_optional_child(
+ self.show_config_window.then_some(
+ create_refresh_config_edit_window(&ctx.props().view)
+ .on_close(ctx.link().callback(|_| Msg::ConfigWindow(false)))
+ .on_submit({
+ let link = ctx.link().clone();
+ move |ctx: FormContext| {
+ let link = link.clone();
+ async move {
+ let data: RefreshConfig =
+ serde_json::from_value(ctx.get_submit_data())?;
+ link.send_message(Msg::UpdateConfig(data));
+ Ok(())
+ }
+ }
+ }),
+ ),
+ );
+ view.add_optional_child(self.show_create_wizard.map(|remote_type| {
+ AddWizard::new(remote_type)
+ .on_close(ctx.link().callback(|_| Msg::CreateWizard(None)))
+ .on_submit(move |ctx| crate::remotes::create_remote(ctx, remote_type))
+ }));
+ view.into()
+ }
+}
+
+async fn load_template() -> Result<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/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
next prev parent reply other threads:[~2025-10-21 14:08 UTC|newest]
Thread overview: 17+ messages / expand[flat|nested] mbox.gz Atom feed top
2025-10-21 14:03 [pdm-devel] [PATCH datacenter-manager 00/15] prepare ui fore customizable views Dominik Csapak
2025-10-21 14:03 ` [pdm-devel] [PATCH datacenter-manager 01/15] ui: dashboard: refactor guest panel creation to its own module Dominik Csapak
2025-10-21 14:03 ` [pdm-devel] [PATCH datacenter-manager 02/15] ui: dashboard: refactor creating the node panel into " Dominik Csapak
2025-10-21 14:03 ` [pdm-devel] [PATCH datacenter-manager 03/15] ui: dashboard: refactor remote panel creation " Dominik Csapak
2025-10-21 14:03 ` [pdm-devel] [PATCH datacenter-manager 04/15] ui: dashboard: remote panel: make wizard menu optional Dominik Csapak
2025-10-21 14:03 ` [pdm-devel] [PATCH datacenter-manager 05/15] ui: dashboard: refactor sdn panel creation into its own module Dominik Csapak
2025-10-21 14:03 ` [pdm-devel] [PATCH datacenter-manager 06/15] ui: dashboard: refactor task summary panel creation to " Dominik Csapak
2025-10-21 14:03 ` [pdm-devel] [PATCH datacenter-manager 07/15] ui: dashboard: refactor subscription " Dominik Csapak
2025-10-21 14:03 ` [pdm-devel] [PATCH datacenter-manager 08/15] ui: dashboard: refactor top entities " Dominik Csapak
2025-10-21 14:03 ` [pdm-devel] [PATCH datacenter-manager 09/15] ui: dashboard: refactor DashboardConfig editing/constants to their module Dominik Csapak
2025-10-21 14:03 ` [pdm-devel] [PATCH datacenter-manager 10/15] ui: dashboard: factor out task parameter calculation Dominik Csapak
2025-10-21 14:03 ` [pdm-devel] [PATCH datacenter-manager 11/15] ui: dashboard: remove unused remote list Dominik Csapak
2025-10-21 14:03 ` [pdm-devel] [PATCH datacenter-manager 12/15] ui: dashboard: status row: make loading less jarring Dominik Csapak
2025-10-21 14:03 ` [pdm-devel] [PATCH datacenter-manager 13/15] ui: introduce `LoadResult` helper type Dominik Csapak
2025-10-21 14:03 ` Dominik Csapak [this message]
2025-10-21 14:03 ` [pdm-devel] [PATCH datacenter-manager 15/15] ui: dashboard: use 'View' instead of the Dashboard Dominik Csapak
2025-10-23 8:33 ` [pdm-devel] superseded: [PATCH datacenter-manager 00/15] prepare ui fore customizable views 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=20251021140801.3611022-15-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