public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Dominik Csapak <d.csapak@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [pdm-devel] [PATCH datacenter-manager 07/11] ui: pbs: add datastore tree
Date: Fri, 26 Sep 2025 09:20:27 +0200	[thread overview]
Message-ID: <20250926072749.560801-8-d.csapak@proxmox.com> (raw)
In-Reply-To: <20250926072749.560801-1-d.csapak@proxmox.com>

Similar to how we have a tree for pve, add a tree for pbs.
The root is the Remote (node), and the datastores are below it.

Since we lack a 'datastore status' api call at the moment, use the
datastore configs to show them (for now).

Not yet used

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/pbs/tree.rs | 324 +++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 324 insertions(+)
 create mode 100644 ui/src/pbs/tree.rs

diff --git a/ui/src/pbs/tree.rs b/ui/src/pbs/tree.rs
new file mode 100644
index 00000000..f804665b
--- /dev/null
+++ b/ui/src/pbs/tree.rs
@@ -0,0 +1,324 @@
+use std::cmp::Ordering;
+use std::rc::Rc;
+
+use gloo_utils::window;
+use yew::html::Scope;
+use yew::virtual_dom::{Key, VComp, VNode};
+use yew::{Component, Properties};
+
+use pwt::css::{AlignItems, FlexFit, FontStyle};
+use pwt::prelude::*;
+use pwt::props::ExtractPrimaryKey;
+use pwt::state::{KeyedSlabTree, NavigationContext, NavigationContextExt, Selection, TreeStore};
+use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader};
+use pwt::widget::form::Field;
+use pwt::widget::{ActionIcon, Button, Column, Container, Fa, Row, Toolbar, Tooltip, Trigger};
+
+use pbs_api_types::DataStoreConfig;
+
+use crate::get_deep_url;
+use crate::renderer::render_tree_column;
+
+#[derive(Clone, PartialEq)]
+#[allow(clippy::large_enum_variant)]
+pub enum PbsTreeNode {
+    Root,
+    Datastore(DataStoreConfig),
+}
+
+impl ExtractPrimaryKey for PbsTreeNode {
+    fn extract_key(&self) -> yew::virtual_dom::Key {
+        match self {
+            PbsTreeNode::Root => Key::from("__root__"),
+            PbsTreeNode::Datastore(datastore) => Key::from(datastore.name.as_str()),
+        }
+    }
+}
+
+#[derive(PartialEq, Properties)]
+pub struct PbsTree {
+    remote: String,
+    resources: Rc<Vec<DataStoreConfig>>,
+    loading: bool,
+    on_select: Callback<PbsTreeNode>,
+    on_reload_click: Callback<()>,
+}
+
+impl PbsTree {
+    pub fn new(
+        remote: String,
+        resources: Rc<Vec<DataStoreConfig>>,
+        loading: bool,
+        on_select: impl Into<Callback<PbsTreeNode>>,
+        on_reload_click: impl Into<Callback<()>>,
+    ) -> Self {
+        Self {
+            remote,
+            resources,
+            loading,
+            on_select: on_select.into(),
+            on_reload_click: on_reload_click.into(),
+        }
+    }
+}
+
+impl From<PbsTree> for VNode {
+    fn from(val: PbsTree) -> Self {
+        VComp::new::<PbsTreeComp>(Rc::new(val), None).into()
+    }
+}
+
+pub enum Msg {
+    Filter(String),
+    KeySelected(Option<Key>),
+    RouteChanged(String),
+}
+
+#[doc(hidden)]
+pub struct PbsTreeComp {
+    columns: Rc<Vec<DataTableHeader<PbsTreeNode>>>,
+    filter: String,
+    store: TreeStore<PbsTreeNode>,
+    view_selection: Selection,
+    loaded: bool,
+    _nav_handle: ContextHandle<NavigationContext>,
+}
+
+impl PbsTreeComp {
+    fn load_tree(&mut self, ctx: &yew::Context<Self>) {
+        let mut tree = KeyedSlabTree::new();
+        let mut root = tree.set_root(PbsTreeNode::Root);
+
+        for datastore in ctx.props().resources.iter() {
+            root.append(PbsTreeNode::Datastore(datastore.clone()));
+        }
+
+        root.set_expanded(true);
+        root.sort_by(true, |a, b| match (a, b) {
+            (PbsTreeNode::Root, PbsTreeNode::Root) => Ordering::Equal,
+            (PbsTreeNode::Root, _) => Ordering::Less,
+            (_, PbsTreeNode::Root) => Ordering::Greater,
+            (PbsTreeNode::Datastore(a), PbsTreeNode::Datastore(b)) => a.name.cmp(&b.name),
+        });
+
+        if !self.loaded {
+            let select_key = self
+                .view_selection
+                .selected_key()
+                .unwrap_or(Key::from("__root__"));
+            if let Some(node) = tree.lookup_node(&select_key) {
+                self.view_selection.select(select_key);
+                ctx.props().on_select.emit(node.record().clone());
+            }
+        }
+
+        self.store.write().update_root_tree(tree);
+        self.loaded = true;
+    }
+}
+
+impl Component for PbsTreeComp {
+    type Message = Msg;
+    type Properties = PbsTree;
+
+    fn create(ctx: &yew::Context<Self>) -> Self {
+        let mut tree = KeyedSlabTree::new();
+        tree.set_root(PbsTreeNode::Root);
+        let store = TreeStore::new();
+        store.write().update_root_tree(tree);
+
+        let props = ctx.props();
+        let view_selection = Selection::new().on_select(
+            ctx.link()
+                .callback(|selection: Selection| Msg::KeySelected(selection.selected_key())),
+        );
+
+        let (_nav_ctx, _nav_handle) = ctx
+            .link()
+            .context::<NavigationContext>(Callback::from({
+                let link = ctx.link().clone();
+                move |nav_ctx: NavigationContext| {
+                    let path = nav_ctx.path();
+                    link.send_message(Msg::RouteChanged(path));
+                }
+            }))
+            .unwrap();
+
+        let path = _nav_ctx.path();
+        ctx.link().send_message(Msg::RouteChanged(path));
+        Self {
+            columns: columns(
+                ctx.link().clone(),
+                store.clone(),
+                props.remote.clone(),
+                props.loading,
+            ),
+            filter: String::new(),
+            store,
+            view_selection,
+            loaded: false,
+            _nav_handle,
+        }
+    }
+
+    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
+        match msg {
+            Msg::Filter(filter) => {
+                let changed = self.filter != filter;
+                self.filter = filter;
+                if self.filter.is_empty() {
+                    self.store.set_filter(None);
+                } else {
+                    let text = self.filter.to_lowercase();
+                    self.store
+                        .set_filter(move |entry: &PbsTreeNode| match entry {
+                            PbsTreeNode::Root => true,
+                            PbsTreeNode::Datastore(datastore) => {
+                                datastore.name.to_lowercase().contains(&text)
+                            }
+                        });
+                }
+                changed
+            }
+            Msg::KeySelected(key) => {
+                let key = key.unwrap_or_else(|| Key::from("__root__"));
+                let store = self.store.read();
+                let root = store.root().unwrap();
+
+                if let Some(node) = root.find_node_by_key(&key) {
+                    let record = node.record().clone();
+                    let route = match &record {
+                        PbsTreeNode::Root => String::new(),
+                        PbsTreeNode::Datastore(datastore) => datastore.name.to_string(),
+                    };
+                    ctx.link().push_relative_route(&route);
+                    ctx.props().on_select.emit(record);
+                }
+                true
+            }
+            Msg::RouteChanged(path) => {
+                let key = if path == "_" || path.is_empty() {
+                    Key::from("__root__")
+                } else {
+                    Key::from(path)
+                };
+                self.view_selection.select(key);
+                true
+            }
+        }
+    }
+
+    fn changed(&mut self, ctx: &Context<Self>, old_props: &Self::Properties) -> bool {
+        if ctx.props().resources != old_props.resources {
+            self.load_tree(ctx);
+        }
+        true
+    }
+
+    fn view(&self, ctx: &yew::Context<Self>) -> yew::Html {
+        let link = ctx.link();
+
+        let nav = DataTable::new(Rc::clone(&self.columns), self.store.clone())
+            .selection(self.view_selection.clone())
+            .striped(false)
+            .borderless(true)
+            .hover(true)
+            .class(FlexFit)
+            .show_header(false);
+        Column::new()
+            .class(FlexFit)
+            .with_child(
+                Toolbar::new()
+                    .border_bottom(true)
+                    .with_child(
+                        Row::new()
+                            .class(AlignItems::Baseline)
+                            .class(FontStyle::TitleMedium)
+                            .gap(2)
+                            .with_child(Fa::new("database"))
+                            .with_child(tr!("Datastores")),
+                    )
+                    .with_child(
+                        Field::new()
+                            .value(self.filter.clone())
+                            .with_trigger(
+                                // FIXME: add `with_optional_trigger` ?
+                                Trigger::new(if !self.filter.is_empty() {
+                                    "fa fa-times"
+                                } else {
+                                    ""
+                                })
+                                .on_activate(link.callback(|_| Msg::Filter(String::new()))),
+                                true,
+                            )
+                            .placeholder(tr!("Filter"))
+                            .on_input(link.callback(Msg::Filter)),
+                    )
+                    .with_child(Button::refresh(ctx.props().loading).on_activate({
+                        let on_reload_click = ctx.props().on_reload_click.clone();
+                        move |_| {
+                            on_reload_click.emit(());
+                        }
+                    })),
+            )
+            .with_child(nav)
+            .into()
+    }
+}
+
+fn columns(
+    link: Scope<PbsTreeComp>,
+    store: TreeStore<PbsTreeNode>,
+    remote: String,
+    loading: bool,
+) -> Rc<Vec<DataTableHeader<PbsTreeNode>>> {
+    let loading = match store.read().root() {
+        Some(root) => loading && root.children_count() == 0,
+        None => loading,
+    };
+    let remote_name = remote.clone();
+    let tree_column = DataTableColumn::new("Type/ID")
+        .flex(1)
+        .tree_column(store)
+        .render(move |entry: &PbsTreeNode| {
+            let (icon, text) = match entry {
+                PbsTreeNode::Root if loading => (
+                    Container::from_tag("i").class("pwt-loading-icon"),
+                    tr!("Querying Remote..."),
+                ),
+                PbsTreeNode::Root => (
+                    Container::new().with_child(Fa::new("server")),
+                    remote_name.clone(),
+                ),
+                PbsTreeNode::Datastore(datastore) => (
+                    Container::new().with_child(Fa::new("database")),
+                    datastore.name.clone(),
+                ),
+            };
+            render_tree_column(icon.into(), text).into()
+        })
+        .into();
+
+    let link_column = DataTableColumn::new("link")
+        .render(move |entry: &PbsTreeNode| {
+            let local_id = match entry {
+                PbsTreeNode::Root => String::new(),
+                PbsTreeNode::Datastore(datastore) => datastore.name.clone(),
+            };
+            Tooltip::new(ActionIcon::new("fa fa-external-link").on_activate({
+                let link = link.clone();
+                let remote = remote.clone();
+                move |_| {
+                    // there must be a remote with a connections config if were already here
+                    if let Some(url) = get_deep_url(&link, &remote, None, &local_id) {
+                        let _ = window().open_with_url(&url.href());
+                    }
+                }
+            }))
+            .tip(tr!("Open in PBS UI"))
+            .into()
+        })
+        .into();
+
+    Rc::new(vec![tree_column, link_column])
+}
-- 
2.47.3



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


  parent reply	other threads:[~2025-09-26  7:27 UTC|newest]

Thread overview: 13+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2025-09-26  7:20 [pdm-devel] [PATCH datacenter-manager 00/11] improve PBS view Dominik Csapak
2025-09-26  7:20 ` [pdm-devel] [PATCH datacenter-manager 01/11] server: api: return list of pve/pbs remotes Dominik Csapak
2025-09-26  7:20 ` [pdm-devel] [PATCH datacenter-manager 02/11] server: api: pbs: add status api call Dominik Csapak
2025-09-26  7:20 ` [pdm-devel] [PATCH datacenter-manager 03/11] server: pbs-client: use `json` formatter for the snapshot list streaming api Dominik Csapak
2025-09-26  7:20 ` [pdm-devel] [PATCH datacenter-manager 04/11] pdm-client: add pbs node status helper Dominik Csapak
2025-09-26  7:20 ` [pdm-devel] [PATCH datacenter-manager 05/11] ui: pve: tree: refactor rendering the tree column Dominik Csapak
2025-09-26  7:20 ` [pdm-devel] [PATCH datacenter-manager 06/11] ui: add helper to compare two strings with `localeCompare` Dominik Csapak
2025-09-26  7:20 ` Dominik Csapak [this message]
2025-09-26  7:20 ` [pdm-devel] [PATCH datacenter-manager 08/11] ui: pbs: convert snapshot list to a snapshot tree Dominik Csapak
2025-09-26  7:20 ` [pdm-devel] [PATCH datacenter-manager 09/11] ui: pbs: add datastore panel component Dominik Csapak
2025-09-26  7:20 ` [pdm-devel] [PATCH datacenter-manager 10/11] ui: pbs: add remote overview panel Dominik Csapak
2025-09-26  7:20 ` [pdm-devel] [PATCH datacenter-manager 11/11] ui: pbs: use new pbs panels/overview Dominik Csapak
2025-09-26 13:45 ` [pdm-devel] applied: [PATCH datacenter-manager 00/11] improve PBS view Thomas Lamprecht

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=20250926072749.560801-8-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
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal