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