all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Shan Shaji <s.shaji@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [pdm-devel] [PATCH datacenter-manager] fix #6797: add dialog to view remote subscriptions
Date: Wed, 22 Oct 2025 13:46:55 +0200	[thread overview]
Message-ID: <20251022114655.237125-1-s.shaji@proxmox.com> (raw)

When clicking on the subscription status panel, no details appeared that
will help the user to see the subscription details of remote/nodes. For
example if a remote had a "mixed" subscription state, users couldn't see
which remote had that state.

Fixed the issue by adding a subscription dialog. Now, when the users
click the subscription status panel, a dialog opens showing the remote
subscription state and the individual subscriptions for each node.

Signed-off-by: Shan Shaji <s.shaji@proxmox.com>
---
 ui/src/dashboard/mod.rs                |   3 +
 ui/src/dashboard/subscription_info.rs  |  81 ++++++++--
 ui/src/dashboard/subscriptions_list.rs | 206 +++++++++++++++++++++++++
 3 files changed, 275 insertions(+), 15 deletions(-)
 create mode 100644 ui/src/dashboard/subscriptions_list.rs

diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs
index f54c509..ec5df9b 100644
--- a/ui/src/dashboard/mod.rs
+++ b/ui/src/dashboard/mod.rs
@@ -42,6 +42,9 @@ pub use top_entities::TopEntities;
 mod subscription_info;
 pub use subscription_info::SubscriptionInfo;
 
+mod subscriptions_list;
+pub use subscriptions_list::SubscriptionsList;
+
 mod remote_panel;
 use remote_panel::RemotePanel;
 
diff --git a/ui/src/dashboard/subscription_info.rs b/ui/src/dashboard/subscription_info.rs
index 08658d1..a1d2305 100644
--- a/ui/src/dashboard/subscription_info.rs
+++ b/ui/src/dashboard/subscription_info.rs
@@ -1,3 +1,8 @@
+use core::{
+    clone::Clone,
+    convert::{From, Into},
+    option::Option::None,
+};
 use std::rc::Rc;
 
 use anyhow::Error;
@@ -12,14 +17,16 @@ use pwt::{
     css::{AlignItems, FlexFit, JustifyContent, TextAlign},
     prelude::tr,
     props::{
-        ContainerBuilder, CssBorderBuilder, CssPaddingBuilder, WidgetBuilder, WidgetStyleBuilder,
+        ContainerBuilder, CssBorderBuilder, CssPaddingBuilder, EventSubscriber, WidgetBuilder,
+        WidgetStyleBuilder,
     },
-    widget::{Column, Container, Fa, Panel, Row},
+    widget::{Column, Container, Dialog, Fa, Panel, Row},
     AsyncPool,
 };
 
 use pdm_api_types::subscription::{RemoteSubscriptionState, RemoteSubscriptions};
 
+use super::SubscriptionsList;
 #[derive(Properties, PartialEq)]
 pub struct SubscriptionInfo {}
 
@@ -29,10 +36,17 @@ impl SubscriptionInfo {
     }
 }
 
+enum Msg {
+    LoadResults(Result<Vec<RemoteSubscriptions>, Error>),
+    OpenSubscriptionsDialog,
+    CloseSubscriptionsDialog,
+}
+
 struct PdmSubscriptionInfo {
     status: Vec<RemoteSubscriptions>,
     loading: bool,
     _async_pool: AsyncPool,
+    dialog_open: bool,
 }
 
 fn render_subscription_status(subs: &[RemoteSubscriptions]) -> Row {
@@ -100,7 +114,7 @@ fn render_subscription_status(subs: &[RemoteSubscriptions]) -> Row {
 }
 
 impl Component for PdmSubscriptionInfo {
-    type Message = Result<Vec<RemoteSubscriptions>, Error>;
+    type Message = Msg;
 
     type Properties = SubscriptionInfo;
 
@@ -108,31 +122,40 @@ impl Component for PdmSubscriptionInfo {
         let link = ctx.link().clone();
         let mut _async_pool = AsyncPool::new();
         _async_pool.spawn(async move {
-            let result = http_get("/resources/subscription", None).await;
-            link.send_message(result);
+            let result = http_get("/resources/subscription?verbose=true", None).await;
+            link.send_message(Msg::LoadResults(result));
         });
 
         Self {
             status: Vec::new(),
             loading: true,
             _async_pool,
+            dialog_open: false,
         }
     }
 
     fn update(&mut self, _ctx: &yew::Context<Self>, msg: Self::Message) -> bool {
         match msg {
-            Ok(result) => {
-                self.status = result;
+            Msg::LoadResults(result) => {
+                self.status = match result {
+                    Ok(result) => result,
+                    Err(_) => Vec::new(),
+                };
+                self.loading = false;
+                true
+            }
+            Msg::OpenSubscriptionsDialog => {
+                self.dialog_open = true;
+                true
+            }
+            Msg::CloseSubscriptionsDialog => {
+                self.dialog_open = false;
+                true
             }
-            Err(_) => self.status = Vec::new(),
         }
-
-        self.loading = false;
-
-        true
     }
 
-    fn view(&self, _ctx: &yew::Context<Self>) -> yew::Html {
+    fn view(&self, ctx: &yew::Context<Self>) -> yew::Html {
         let title: Html = Row::new()
             .class(AlignItems::Center)
             .gap(2)
@@ -140,12 +163,23 @@ impl Component for PdmSubscriptionInfo {
             .with_child(tr!("Subscription Status"))
             .into();
 
-        Panel::new()
+        let panel = Panel::new()
             .flex(1.0)
             .width(500)
             .min_height(150)
             .title(title)
-            .border(true)
+            .border(true);
+
+        let is_panel_clickable = !self.loading && !self.status.is_empty();
+        let panel = if is_panel_clickable {
+            panel
+                .onclick(ctx.link().callback(|_| Msg::OpenSubscriptionsDialog))
+                .style("cursor", "pointer")
+        } else {
+            panel
+        };
+
+        panel
             .with_child(
                 Column::new()
                     .class(FlexFit)
@@ -157,12 +191,29 @@ impl Component for PdmSubscriptionInfo {
                     )
                     .with_optional_child(
                         (!self.loading).then_some(render_subscription_status(&self.status)),
+                    )
+                    .with_optional_child(
+                        (self.dialog_open && is_panel_clickable).then_some(self.dialog_view(ctx)),
                     ),
             )
             .into()
     }
 }
 
+impl PdmSubscriptionInfo {
+    fn dialog_view(&self, ctx: &yew::Context<Self>) -> Html {
+        Dialog::new(tr!("Your Subscriptions"))
+            .resizable(true)
+            .width(500)
+            .height(400)
+            .min_width(200)
+            .min_height(50)
+            .with_child(SubscriptionsList::new(self.status.clone()))
+            .on_close(ctx.link().callback(|_| Msg::CloseSubscriptionsDialog))
+            .into()
+    }
+}
+
 impl From<SubscriptionInfo> for VNode {
     fn from(val: SubscriptionInfo) -> Self {
         let comp = VComp::new::<PdmSubscriptionInfo>(Rc::new(val), None);
diff --git a/ui/src/dashboard/subscriptions_list.rs b/ui/src/dashboard/subscriptions_list.rs
new file mode 100644
index 0000000..f7af974
--- /dev/null
+++ b/ui/src/dashboard/subscriptions_list.rs
@@ -0,0 +1,206 @@
+use pdm_api_types::subscription::{
+    RemoteSubscriptionState, RemoteSubscriptions, SubscriptionLevel,
+};
+use proxmox_yew_comp::Status;
+use pwt::{
+    css::{AlignItems, Overflow},
+    props::{ContainerBuilder, ExtractPrimaryKey, WidgetBuilder},
+    state::{KeyedSlabTree, TreeStore},
+    tr,
+    widget::{
+        data_table::{DataTable, DataTableColumn, DataTableHeader},
+        Fa, Row,
+    },
+};
+use yew::{
+    html,
+    virtual_dom::{Key, VComp, VNode},
+    Html,
+};
+
+use core::{clone::Clone, cmp::Ord};
+use std::rc::Rc;
+use yew::{Component, Properties};
+
+#[derive(Properties, PartialEq)]
+pub struct SubscriptionsList {
+    subscriptions: Vec<RemoteSubscriptions>,
+}
+
+impl SubscriptionsList {
+    pub fn new(subscriptions: Vec<RemoteSubscriptions>) -> Self {
+        yew::props!(Self { subscriptions })
+    }
+}
+
+pub struct PdmSubscriptionsList {
+    store: TreeStore<SubscriptionTreeEntry>,
+}
+
+#[derive(Clone, PartialEq)]
+struct RemoteEntry {
+    name: String,
+    state: RemoteSubscriptionState,
+    error: Option<String>,
+}
+
+#[derive(Clone, PartialEq)]
+struct NodeEntry {
+    remote: String,
+    name: String,
+    level: SubscriptionLevel,
+}
+
+#[derive(Clone, PartialEq)]
+enum SubscriptionTreeEntry {
+    Root,
+    Remote(RemoteEntry),
+    Node(NodeEntry),
+}
+
+impl SubscriptionTreeEntry {
+    fn name(&self) -> &str {
+        match self {
+            SubscriptionTreeEntry::Root => "",
+            SubscriptionTreeEntry::Remote(remote_entry) => &remote_entry.name,
+            SubscriptionTreeEntry::Node(node_entry) => &node_entry.name,
+        }
+    }
+}
+
+impl ExtractPrimaryKey for SubscriptionTreeEntry {
+    fn extract_key(&self) -> Key {
+        match self {
+            SubscriptionTreeEntry::Root => Key::from("root"),
+            SubscriptionTreeEntry::Remote(remote) => Key::from(format!("{}", remote.name)),
+            SubscriptionTreeEntry::Node(node) => {
+                Key::from(format!("{}/{}", node.remote, node.name))
+            }
+        }
+    }
+}
+
+impl Component for PdmSubscriptionsList {
+    type Message = ();
+    type Properties = SubscriptionsList;
+
+    fn create(ctx: &yew::Context<Self>) -> Self {
+        let subscriptions = sort_subscriptions(&ctx.props().subscriptions);
+
+        let store = TreeStore::new().view_root(false);
+        let mut tree = KeyedSlabTree::new();
+        let mut root = tree.set_root(SubscriptionTreeEntry::Root);
+        root.set_expanded(true);
+
+        for remote in subscriptions {
+            let mut remote_node = root.append(SubscriptionTreeEntry::Remote(RemoteEntry {
+                name: remote.remote.clone(),
+                state: remote.state.clone(),
+                error: remote.error.clone(),
+            }));
+
+            if let Some(node_status) = remote.node_status.as_ref() {
+                if node_status.is_empty() {
+                    continue;
+                }
+
+                for (node_name, info) in node_status {
+                    if let Some(info) = info {
+                        remote_node.append(SubscriptionTreeEntry::Node(NodeEntry {
+                            remote: remote.remote.clone(),
+                            name: node_name.clone(),
+                            level: info.level.clone(),
+                        }));
+                    }
+                }
+            }
+        }
+
+        store.write().update_root_tree(tree);
+        Self { store }
+    }
+
+    fn view(&self, _ctx: &yew::Context<Self>) -> Html {
+        DataTable::new(columns(self.store.clone()), self.store.clone())
+            .class(Overflow::Auto)
+            .into()
+    }
+}
+
+fn columns(
+    store: TreeStore<SubscriptionTreeEntry>,
+) -> Rc<Vec<DataTableHeader<SubscriptionTreeEntry>>> {
+    let tree_column = DataTableColumn::new(tr!("Remote"))
+        .tree_column(store)
+        .render(|entry: &SubscriptionTreeEntry| {
+            let row = Row::new().class(AlignItems::Center).gap(2);
+            match entry {
+                SubscriptionTreeEntry::Remote(remote) => row.with_child(remote.name.clone()).into(),
+                SubscriptionTreeEntry::Node(node) => row
+                    .with_child(Fa::new("server"))
+                    .with_child(node.name.clone())
+                    .into(),
+                SubscriptionTreeEntry::Root => row.into(),
+            }
+        })
+        .sorter(|a: &SubscriptionTreeEntry, b: &SubscriptionTreeEntry| a.name().cmp(b.name()))
+        .into();
+
+    let subscription_column = DataTableColumn::new(tr!("Subcription"))
+        .render(|entry: &SubscriptionTreeEntry| match entry {
+            SubscriptionTreeEntry::Node(node) => {
+                let text = match node.level {
+                    SubscriptionLevel::None => "None",
+                    SubscriptionLevel::Basic => "Basic",
+                    SubscriptionLevel::Community => "Community",
+                    SubscriptionLevel::Premium => "Premium",
+                    SubscriptionLevel::Standard => "Standard",
+                    SubscriptionLevel::Unknown => "Unknown",
+                };
+                text.into()
+            }
+            SubscriptionTreeEntry::Remote(remote) => {
+                if let Some(error) = &remote.error {
+                    html! { <span class="pwt-font-label-small">{error}</span> }.into()
+                } else {
+                    let icon = match remote.state {
+                        RemoteSubscriptionState::Mixed => Fa::from(Status::Warning),
+                        RemoteSubscriptionState::Active => Fa::from(Status::Success),
+                        _ => Fa::from(Status::Unknown),
+                    };
+
+                    let text = match remote.state {
+                        RemoteSubscriptionState::None => "None",
+                        RemoteSubscriptionState::Unknown => "Unknown",
+                        RemoteSubscriptionState::Mixed => "Mixed",
+                        RemoteSubscriptionState::Active => "Active",
+                    };
+
+                    Row::new().gap(2).with_child(icon).with_child(text).into()
+                }
+            }
+            SubscriptionTreeEntry::Root => "".into(),
+        })
+        .into();
+
+    Rc::new(vec![tree_column, subscription_column])
+}
+
+fn sort_subscriptions(subs: &[RemoteSubscriptions]) -> Vec<RemoteSubscriptions> {
+    let mut subscriptions = subs.to_vec();
+    subscriptions.sort_by_key(|rs| match rs.state {
+        RemoteSubscriptionState::None => 0,
+        RemoteSubscriptionState::Unknown => 1,
+        RemoteSubscriptionState::Mixed => 2,
+        RemoteSubscriptionState::Active => 3,
+    });
+    subscriptions
+}
+
+
+impl From<SubscriptionsList> for VNode {
+    fn from(val: SubscriptionsList) -> Self {
+        let comp = VComp::new::<PdmSubscriptionsList>(Rc::new(val), None);
+        VNode::from(comp)
+    }
+}
-- 
2.47.3



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


             reply	other threads:[~2025-10-22 11:46 UTC|newest]

Thread overview: 4+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2025-10-22 11:46 Shan Shaji [this message]
2025-10-22 12:25 ` Shannon Sterz
2025-10-22 13:18   ` Shan Shaji
2025-10-22 14:33     ` Shan Shaji

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=20251022114655.237125-1-s.shaji@proxmox.com \
    --to=s.shaji@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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal