From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id 4E2B81FF15C for ; Fri, 17 Oct 2025 12:15:50 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id CF6B9259E1; Fri, 17 Oct 2025 12:16:11 +0200 (CEST) Mime-Version: 1.0 Date: Fri, 17 Oct 2025 12:15:35 +0200 Message-Id: To: "Lukas Wagner" X-Mailer: aerc 0.20.0 References: <20251015124711.312943-1-l.wagner@proxmox.com> <20251015124711.312943-12-l.wagner@proxmox.com> In-Reply-To: <20251015124711.312943-12-l.wagner@proxmox.com> From: "Shannon Sterz" X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1760696132503 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.056 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Subject: Re: [pdm-devel] [PATCH proxmox-datacenter-manager 11/12] ui: add remote update view X-BeenThere: pdm-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Reply-To: Proxmox Datacenter Manager development discussion Cc: Proxmox Datacenter Manager development discussion Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: pdm-devel-bounces@lists.proxmox.com Sender: "pdm-devel" On Wed Oct 15, 2025 at 2:47 PM CEST, Lukas Wagner wrote: > This commit adds a new view for showing a global overview about > available updates on managed remotes. Thew view is split in the middle. nit: typo here, should be "The" not "Thew" > On the left side, we display a tree view showing all remotes and nodes, > on the right side we show a list of available updates for any node > selected in the tree. > > Signed-off-by: Lukas Wagner > --- > ui/src/remotes/mod.rs | 3 + > ui/src/remotes/updates.rs | 531 ++++++++++++++++++++++++++++++++++++++ > 2 files changed, 534 insertions(+) > create mode 100644 ui/src/remotes/updates.rs > > diff --git a/ui/src/remotes/mod.rs b/ui/src/remotes/mod.rs > index 83b3331b..cce21563 100644 > --- a/ui/src/remotes/mod.rs > +++ b/ui/src/remotes/mod.rs > @@ -24,6 +24,9 @@ pub use config::{create_remote, RemoteConfigPanel}; > mod tasks; > pub use tasks::RemoteTaskList; > > +mod updates; > +pub use updates::UpdateTree; > + > use yew::{function_component, Html}; > > use pwt::prelude::*; > diff --git a/ui/src/remotes/updates.rs b/ui/src/remotes/updates.rs > new file mode 100644 > index 00000000..1a2e9e25 > --- /dev/null > +++ b/ui/src/remotes/updates.rs > @@ -0,0 +1,531 @@ > +use std::cmp::Ordering; > +use std::ops::Deref; > +use std::pin::Pin; > +use std::rc::Rc; > + > +use futures::Future; > +use yew::virtual_dom::{Key, VComp, VNode}; > +use yew::{html, Html, Properties}; > + > +use pdm_api_types::remote_updates::{ > + NodeUpdateStatus, NodeUpdateSummary, RemoteUpdateStatus, UpdateSummary, > +}; > +use pdm_api_types::remotes::RemoteType; > +use pwt::css::{AlignItems, FlexFit, TextAlign}; > +use pwt::widget::data_table::{DataTableCellRenderArgs, DataTableCellRenderer}; > + > +use proxmox_yew_comp::{ > + AptPackageManager, LoadableComponent, LoadableComponentContext, LoadableComponentMaster, > +}; > +use pwt::props::{CssBorderBuilder, CssMarginBuilder, CssPaddingBuilder, WidgetStyleBuilder}; > +use pwt::widget::{Button, Container, Panel, Tooltip}; > +use pwt::{ > + css, > + css::FontColor, > + props::{ContainerBuilder, ExtractPrimaryKey, WidgetBuilder}, > + state::{Selection, SlabTree, TreeStore}, > + tr, > + widget::{ > + data_table::{DataTable, DataTableColumn, DataTableHeader}, > + Column, Fa, Row, > + }, > +}; > + > +use crate::{get_deep_url, get_deep_url_low_level, pdm_client}; > + > +#[derive(PartialEq, Properties)] > +pub struct UpdateTree {} > + > +impl UpdateTree { > + pub fn new() -> Self { > + yew::props!(Self {}) > + } > +} > + > +impl From for VNode { > + fn from(value: UpdateTree) -> Self { > + let comp = VComp::new::>(Rc::new(value), None); > + VNode::from(comp) > + } > +} > + > +#[derive(Clone, PartialEq, Debug)] > +struct RemoteEntry { > + remote: String, > + ty: RemoteType, > + number_of_failed_nodes: u32, > + number_of_nodes: u32, > + number_of_updatable_nodes: u32, > + poll_status: RemoteUpdateStatus, > +} > + > +#[derive(Clone, PartialEq, Debug)] > +struct NodeEntry { > + remote: String, > + node: String, > + ty: RemoteType, > + summary: NodeUpdateSummary, > +} > + > +#[derive(Clone, PartialEq, Debug)] > +enum UpdateTreeEntry { > + Root, > + Remote(RemoteEntry), > + Node(NodeEntry), > +} > + > +impl UpdateTreeEntry { > + fn name(&self) -> &str { > + match &self { > + Self::Root => "", > + Self::Remote(data) => &data.remote, > + Self::Node(data) => &data.node, > + } > + } > +} > + > +impl ExtractPrimaryKey for UpdateTreeEntry { > + fn extract_key(&self) -> yew::virtual_dom::Key { > + Key::from(match self { > + UpdateTreeEntry::Root => "/".to_string(), > + UpdateTreeEntry::Remote(data) => format!("/{}", data.remote), > + UpdateTreeEntry::Node(data) => format!("/{}/{}", data.remote, data.node), > + }) > + } > +} > + > +pub enum RemoteUpdateTreeMsg { > + LoadFinished(UpdateSummary), > + KeySelected(Option), > + RefreshAll, > +} i know this is pre-existing and also a theme throughout the yew stuff, but i'd call making message enums public an anti-pattern. there isn't really a point to having them outside of the component where they matter. imo, the `pub` here should be dropped. > + > +pub struct UpdateTreeComponent { > + store: TreeStore, > + selection: Selection, > + selected_entry: Option, > +} > + > +fn default_sorter(a: &UpdateTreeEntry, b: &UpdateTreeEntry) -> Ordering { > + a.name().cmp(b.name()) > +} > + > +impl UpdateTreeComponent { > + fn columns( > + _ctx: &LoadableComponentContext, > + store: TreeStore, > + ) -> Rc>> { > + Rc::new(vec![ > + DataTableColumn::new(tr!("Name")) > + .tree_column(store) > + .width("200px") > + .render(|entry: &UpdateTreeEntry| { > + let icon = match entry { > + UpdateTreeEntry::Remote(_) => Some("server"), > + UpdateTreeEntry::Node(_) => Some("building"), > + _ => None, > + }; > + > + Row::new() > + .class(css::AlignItems::Baseline) > + .gap(2) > + .with_optional_child(icon.map(|icon| Fa::new(icon))) > + .with_child(entry.name()) > + .into() > + }) > + .sorter(default_sorter) > + .into(), > + DataTableColumn::new(tr!("Status")) > + .render_cell(DataTableCellRenderer::new( > + move |args: &mut DataTableCellRenderArgs| { > + let mut row = Row::new().class(css::AlignItems::Baseline).gap(2); > + > + match args.record() { > + UpdateTreeEntry::Remote(remote_info) => match remote_info.poll_status { > + RemoteUpdateStatus::Unknown => { > + row = row.with_child(render_remote_status_icon( > + RemoteUpdateStatus::Unknown, > + )); > + } > + RemoteUpdateStatus::Success => { > + if !args.is_expanded() { > + let up_to_date_nodes = remote_info.number_of_nodes > + - remote_info.number_of_updatable_nodes > + - remote_info.number_of_failed_nodes; > + > + if up_to_date_nodes > 0 { > + row = row.with_child(render_remote_summary_counter( > + up_to_date_nodes, > + RemoteSummaryIcon::UpToDate, > + )); > + } > + > + if remote_info.number_of_updatable_nodes > 0 { > + row = row.with_child(render_remote_summary_counter( > + remote_info.number_of_updatable_nodes, > + RemoteSummaryIcon::Updatable, > + )); > + } > + > + if remote_info.number_of_failed_nodes > 0 { > + row = row.with_child(render_remote_summary_counter( > + remote_info.number_of_failed_nodes, > + RemoteSummaryIcon::Error, > + )); > + } > + } > + } > + RemoteUpdateStatus::Error => { > + row = row.with_child(render_remote_status_icon( > + RemoteUpdateStatus::Error, > + )); > + } > + }, > + UpdateTreeEntry::Node(info) => { > + if info.summary.status == NodeUpdateStatus::Error { > + row = row.with_child( > + Fa::new("times-circle").class(FontColor::Error), > + ); > + row = row.with_child(tr!("Could not get update info")); > + } else if info.summary.number_of_updates > 0 { > + row = row > + .with_child(Fa::new("refresh").class(FontColor::Primary)); > + row = row.with_child(tr!( > + "{0} updates are available", > + info.summary.number_of_updates > + )); > + } else { > + row = > + row.with_child(Fa::new("check").class(FontColor::Success)); > + row = row.with_child(tr!("Up-to-date")); > + } > + } > + _ => {} > + } > + > + row.into() > + }, > + )) > + .into(), > + ]) > + } > +} > + > +fn build_store_from_response(update_summary: UpdateSummary) -> SlabTree { > + let mut tree = SlabTree::new(); > + > + let mut root = tree.set_root(UpdateTreeEntry::Root); > + root.set_expanded(true); > + > + for (remote_name, remote_summary) in update_summary.remotes.deref() { > + let mut remote_entry = root.append(UpdateTreeEntry::Remote(RemoteEntry { > + remote: remote_name.clone(), > + ty: remote_summary.remote_type, > + number_of_nodes: 0, > + number_of_updatable_nodes: 0, > + number_of_failed_nodes: 0, > + poll_status: remote_summary.status.clone(), > + })); > + remote_entry.set_expanded(false); > + > + let number_of_nodes = remote_summary.nodes.len(); > + let mut number_of_updatable_nodes = 0; > + let mut number_of_failed_nodes = 0; > + > + for (node_name, node_summary) in remote_summary.nodes.deref() { > + match node_summary.status { > + NodeUpdateStatus::Success => { > + if node_summary.number_of_updates > 0 { > + number_of_updatable_nodes += 1; > + } > + } > + NodeUpdateStatus::Error => { > + number_of_failed_nodes += 1; > + } > + } > + > + remote_entry.append(UpdateTreeEntry::Node(NodeEntry { > + remote: remote_name.clone(), > + node: node_name.clone(), > + ty: remote_summary.remote_type, > + summary: node_summary.clone(), > + })); > + } > + > + if let UpdateTreeEntry::Remote(info) = remote_entry.record_mut() { > + info.number_of_updatable_nodes = number_of_updatable_nodes; > + info.number_of_nodes = number_of_nodes as u32; > + info.number_of_failed_nodes = number_of_failed_nodes as u32; > + } > + } > + > + tree > +} > + > +impl LoadableComponent for UpdateTreeComponent { > + type Properties = UpdateTree; > + type Message = RemoteUpdateTreeMsg; > + type ViewState = (); > + > + fn create(ctx: &LoadableComponentContext) -> Self { > + let link = ctx.link(); > + > + let store = TreeStore::new().view_root(false); > + store.set_sorter(default_sorter); > + > + link.repeated_load(5000); > + > + let selection = Selection::new().on_select(link.callback(|selection: Selection| { > + RemoteUpdateTreeMsg::KeySelected(selection.selected_key()) > + })); > + > + Self { > + store: store.clone(), > + selection, > + selected_entry: None, > + } > + } > + > + fn load( > + &self, > + ctx: &LoadableComponentContext, > + ) -> Pin>>> { > + let link = ctx.link().clone(); > + > + Box::pin(async move { > + let client = pdm_client(); > + > + let updates = client.remote_update_summary().await?; > + link.send_message(Self::Message::LoadFinished(updates)); > + > + Ok(()) > + }) > + } > + > + fn update(&mut self, ctx: &LoadableComponentContext, msg: Self::Message) -> bool { > + match msg { > + Self::Message::LoadFinished(updates) => { > + let data = build_store_from_response(updates); > + self.store.write().update_root_tree(data); > + self.store.set_sorter(default_sorter); > + > + return true; > + } > + Self::Message::KeySelected(key) => { > + if let Some(key) = key { > + let read_guard = self.store.read(); > + let node_ref = read_guard.lookup_node(&key).unwrap(); > + let record = node_ref.record(); > + > + self.selected_entry = Some(record.clone()); > + > + return true; > + } > + } > + Self::Message::RefreshAll => { > + let link = ctx.link(); > + > + link.clone().spawn(async move { > + let client = pdm_client(); > + > + match client.refresh_remote_update_summary().await { > + Ok(upid) => { > + link.show_task_progres(upid.to_string()); > + } > + Err(err) => { > + link.show_error(tr!("Could not refresh update status."), err, false); > + } > + } > + }); > + } > + } > + > + false > + } > + > + fn main_view(&self, ctx: &LoadableComponentContext) -> yew::Html { > + Container::new() > + .class("pwt-content-spacer") > + .class(FlexFit) > + .class("pwt-flex-direction-row") > + .with_child(self.render_update_tree_panel(ctx)) > + .with_child(self.render_update_list_panel(ctx)) > + .into() haven't tested this, but why not just use a Row here? > + } > +} > + > +impl UpdateTreeComponent { > + fn render_update_tree_panel(&self, ctx: &LoadableComponentContext) -> Panel { > + let table = DataTable::new(Self::columns(ctx, self.store.clone()), self.store.clone()) > + .selection(self.selection.clone()) > + .striped(false) > + .borderless(true) > + .show_header(false) > + .class(css::FlexFit); > + > + let refresh_all_button = Button::new(tr!("Refresh all")).on_activate({ > + let link = ctx.link().clone(); > + move |_| { > + link.send_message(RemoteUpdateTreeMsg::RefreshAll); > + } > + }); > + > + let title: Html = Row::new() > + .gap(2) > + .class(AlignItems::Baseline) > + .with_child(Fa::new("refresh")) > + .with_child(tr!("Remote System Updates")) > + .into(); > + > + Panel::new() > + .min_width(500) > + .title(title) > + .with_tool(refresh_all_button) > + .style("flex", "1 1 0") > + .class(FlexFit) > + .border(true) > + .with_child(table) > + } > + > + fn render_update_list_panel(&self, ctx: &LoadableComponentContext) -> Panel { > + let title: Html = Row::new() > + .gap(2) > + .class(AlignItems::Baseline) > + .with_child(Fa::new("list")) > + .with_child(tr!("Update List")) > + .into(); > + > + match &self.selected_entry { > + // (Some(remote), Some(remote_type), Some(node)) => { > + Some(UpdateTreeEntry::Node(NodeEntry { > + remote, node, ty, .. > + })) => { > + let base_url = format!("/{ty}/remotes/{remote}/nodes/{node}/apt",); > + let task_base_url = format!("/{ty}/remotes/{}/tasks", remote); nit: remote should be inlined here > + > + let apt = AptPackageManager::new() > + .base_url(base_url) > + .task_base_url(task_base_url) > + .enable_upgrade(true) > + .on_upgrade({ > + let remote = remote.clone(); > + let link = ctx.link().clone(); > + let remote = remote.clone(); > + let node = node.clone(); > + let ty = *ty; > + > + move |_| match ty { > + RemoteType::Pve => { > + let id = format!("node/{}::apt", node); same for node here > + if let Some(url) = > + get_deep_url(&link.yew_link(), &remote, None, &id) you don't need references here for the first parameter, `yew_link() already returns a reference > + { > + let _ = web_sys::window().unwrap().open_with_url(&url.href()); gloo_utils::window() will safe you an unwrap here :) > + } > + } > + RemoteType::Pbs => { > + let hash = "#pbsServerAdministration:updates"; > + if let Some(url) = > + get_deep_url_low_level(&link.yew_link(), &remote, None, &hash) see comment about yew_link() above, and you can drop the extra reference for hash here too > + { > + let _ = web_sys::window().unwrap().open_with_url(&url.href()); see comment about gloo_utils above > + } > + } > + } > + }); > + > + Panel::new() > + .class(FlexFit) > + .title(title) > + .border(true) > + .min_width(500) > + .with_child(apt) > + .style("flex", "1 1 0") > + } > + _ => { > + let header = tr!("No node selected"); > + let msg = tr!("Select a node to show available updates."); > + > + let select_node_msg = Column::new() > + .class(FlexFit) > + .padding(2) > + .class(AlignItems::Center) > + .class(TextAlign::Center) > + .with_child(html! {

{header}

}) > + .with_child(Container::new().with_child(msg)); > + > + Panel::new() > + .class(FlexFit) > + .title(title) > + .border(true) > + .min_width(500) > + .with_child(select_node_msg) > + .style("flex", "1 1 0") > + } > + } > + } > +} > + > +enum RemoteSummaryIcon { > + UpToDate, > + Updatable, > + Error, > +} > + > +fn render_remote_summary_counter(count: u32, task_class: RemoteSummaryIcon) -> Html { > + let (icon_class, icon_scheme, state_text) = match task_class { > + RemoteSummaryIcon::UpToDate => ( > + "check", > + FontColor::Success, > + tr!("One node is up-to-date." | "{n} nodes are up-to-date." % count), > + ), > + RemoteSummaryIcon::Error => ( > + "times-circle", > + FontColor::Error, > + tr!("Failed to retrieve update info for one node." > + | "Failed to retrieve update info for {n} nodes." % count), > + ), > + RemoteSummaryIcon::Updatable => ( > + "refresh", > + FontColor::Primary, > + tr!("One node has updates available." | "{n} nodes have updates available." % count), > + ), > + }; > + > + let icon = Fa::new(icon_class).margin_end(3).class(icon_scheme); > + > + Tooltip::new( > + Container::from_tag("span") > + .with_child(icon) > + .with_child(count) > + .margin_end(5), > + ) > + .tip(state_text) > + .into() > +} > + > +fn render_remote_status_icon(task_class: RemoteUpdateStatus) -> Html { > + let (icon_class, icon_scheme, state_text) = match task_class { > + RemoteUpdateStatus::Success => ( > + "check", > + FontColor::Success, > + tr!("All nodes of this remote are up-to-date."), > + ), > + RemoteUpdateStatus::Error => ( > + "times-circle", > + FontColor::Error, > + tr!("Could not retrieve update info for remote."), > + ), > + RemoteUpdateStatus::Unknown => ( > + "question-circle-o", > + FontColor::Primary, > + tr!("The update status is not known."), > + ), > + }; > + > + let icon = Fa::new(icon_class).margin_end(3).class(icon_scheme); > + > + Tooltip::new(Container::from_tag("span").with_child(icon).margin_end(5)) > + .tip(state_text) > + .into() > +} _______________________________________________ pdm-devel mailing list pdm-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel