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: Thu, 23 Oct 2025 13:19:40 +0200 [thread overview]
Message-ID: <DDPNT6DP4TSQ.25NHTZX5MTZZJ@proxmox.com> (raw)
In-Reply-To: <20251023083253.1038119-16-d.csapak@proxmox.com>
On Thu Oct 23, 2025 at 10:28 AM CEST, Dominik Csapak wrote:
> this is a more generalized version of our `Dashboard`, which can be
> configured with a `ViewTemplate` that is completely serializable, so
> we're able to dynamically configure it (e.g. in the future over the api)
>
> An example of such a configuration could be, e.g.:
>
> {
> "layout": {
> "layout-type": "rows",
> "rows": [
> [
> {
> "widget-type": "remotes",
> "show-wizard": true
> }
> ],
> [
> {
> "widget-type": "leaderboard",
> "leaderboard-type": "guest-cpu"
> }
> ]
> ]
> }
> }
>
> Implemented are all widget our Dashboard holds, but this is easily
> extendable.
>
> Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
> ---
> changes from v1:
> * correctly mark as RFC
>
> ui/src/dashboard/mod.rs | 1 +
> ui/src/dashboard/types.rs | 69 ++++++
> ui/src/dashboard/view.rs | 456 ++++++++++++++++++++++++++++++++++++++
> ui/src/pve/mod.rs | 4 +-
> 4 files changed, 529 insertions(+), 1 deletion(-)
> create mode 100644 ui/src/dashboard/view.rs
>
> diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs
> index a394ea81..8a0bdad0 100644
> --- a/ui/src/dashboard/mod.rs
> +++ b/ui/src/dashboard/mod.rs
> @@ -51,6 +51,7 @@ use tasks::{create_task_summary_panel, get_task_options};
>
> pub mod types;
>
> +pub mod view;
>
> mod refresh_config_edit;
> pub use refresh_config_edit::{
> diff --git a/ui/src/dashboard/types.rs b/ui/src/dashboard/types.rs
> index 152d4f57..8899246e 100644
> --- a/ui/src/dashboard/types.rs
> +++ b/ui/src/dashboard/types.rs
> @@ -1,5 +1,67 @@
> use serde::{Deserialize, Serialize};
>
> +use pdm_api_types::remotes::RemoteType;
> +
> +use crate::pve::GuestType;
> +
> +#[derive(Serialize, Deserialize, PartialEq, Clone)]
> +#[serde(rename_all = "kebab-case")]
> +pub struct ViewTemplate {
> + #[serde(skip_serializing_if = "String::is_empty")]
> + pub description: String,
> + pub layout: ViewLayout,
> +}
> +
> +#[derive(Serialize, Deserialize, PartialEq, Clone)]
> +#[serde(rename_all = "kebab-case")]
> +#[serde(tag = "layout-type")]
> +pub enum ViewLayout {
> + Rows {
> + #[serde(skip_serializing_if = "Vec::is_empty")]
> + rows: Vec<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>,
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)?
> + 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")
since this is used here quite extensively, might make sense to also give
that a type in the `css` module, but that's unrelated to this series
> + .class(css::FlexDirection::Row)
just something i'm curious about, but is this necessary? shouldn't a
`Row` already be `FlexDirection::Row`? or more accurately, isn't it by
default?
> + .class(css::FlexWrap::Wrap);
> + let flex_sum: f32 = items.iter().map(|item| item.flex.unwrap_or(1.0)).sum();
> + let gaps_ratio = items.len().saturating_sub(1) as f32 / items.len() as f32;
> + for item in items {
> + let flex = item.flex.unwrap_or(1.0);
> + let flex_ratio = 100.0 * flex / flex_sum;
> + // we have to subtract the gaps too
> + let flex_style = format!(
> + "{} {} calc({}% - calc({} * var(--pwt-spacer-4)))",
> + flex, flex, flex_ratio, gaps_ratio
> + );
> + let mut widget = self
> + .create_widget(ctx, &item.r#type)
> + .style("flex", flex_style);
> + if let Some(title) = item.title.clone() {
> + widget.set_title(title);
> + }
> + row.add_child(widget);
> + }
> + view.add_child(row);
> + }
> + }
> + None => {}
> + }
> + // fill remaining space
> + view.add_child(
> + Container::new()
> + .class(css::Flex::Fill)
> + .class("pwt-content-spacer"),
> + );
> + view.add_optional_child(
> + self.template
> + .error
> + .as_ref()
> + .map(|err| error_message(&err.to_string())),
> + );
> + view.add_optional_child(
> + self.show_config_window.then_some(
> + create_refresh_config_edit_window(&ctx.props().view)
> + .on_close(ctx.link().callback(|_| Msg::ConfigWindow(false)))
> + .on_submit({
> + let link = ctx.link().clone();
> + move |ctx: FormContext| {
> + let link = link.clone();
> + async move {
> + let data: RefreshConfig =
> + serde_json::from_value(ctx.get_submit_data())?;
> + link.send_message(Msg::UpdateConfig(data));
> + Ok(())
> + }
> + }
> + }),
> + ),
> + );
> + view.add_optional_child(self.show_create_wizard.map(|remote_type| {
> + AddWizard::new(remote_type)
> + .on_close(ctx.link().callback(|_| Msg::CreateWizard(None)))
> + .on_submit(move |ctx| crate::remotes::create_remote(ctx, remote_type))
> + }));
> + view.into()
> + }
> +}
> +
> +async fn load_template() -> Result<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,
_______________________________________________
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-23 11:19 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 [this message]
2025-10-23 11:44 ` Dominik Csapak
2025-10-23 11:48 ` Dominik Csapak
2025-10-24 10:17 ` Shannon Sterz
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=DDPNT6DP4TSQ.25NHTZX5MTZZJ@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