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 BE7C91FF165 for ; Thu, 23 Oct 2025 14:44:46 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id C7F3F9FAA; Thu, 23 Oct 2025 14:45:07 +0200 (CEST) From: Lukas Wagner To: pdm-devel@lists.proxmox.com Date: Thu, 23 Oct 2025 14:44:18 +0200 Message-ID: <20251023124420.244585-11-l.wagner@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20251023124420.244585-1-l.wagner@proxmox.com> References: <20251023124420.244585-1-l.wagner@proxmox.com> MIME-Version: 1.0 X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1761223463459 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.027 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: [pdm-devel] [PATCH datacenter-manager v3 10/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 Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: pdm-devel-bounces@lists.proxmox.com Sender: "pdm-devel" This commit adds a new view for showing a global overview about available updates on managed remotes. The view is split in the middle. 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 --- Notes: Chances since v2: - Show text for collapsed remote tree item - "All nodes up-to-date" - "Some nodes nodes have pending updates" - "Some nodes have pending updates, some nodes unavailable" - ... - Collapse single-node PVE remotes and PBS remotes into a single item to reduce clicks needed - Show remote and node in the header of the right-hand update list - Some refactoring to make the code a tiny bit nicer Changes since v1: - made RemoteUpdateTreeMsg and UpdateTreeComp private - format!(...) var inlining - remove unneeded borrows - use gloo_utils::window() ui/src/remotes/mod.rs | 3 + ui/src/remotes/updates.rs | 530 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 533 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..8294795a --- /dev/null +++ b/ui/src/remotes/updates.rs @@ -0,0 +1,530 @@ +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, CssPaddingBuilder, WidgetStyleBuilder}; +use pwt::widget::{Button, Container, Panel}; +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, + flat: bool, +} + +#[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) => { + if data.flat { + &data.remote + } else { + &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), + }) + } +} + +enum RemoteUpdateTreeMsg { + LoadFinished(UpdateSummary), + KeySelected(Option), + RefreshAll, +} + +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) + .flex(1) + .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")) + .flex(3) + .render_cell(DataTableCellRenderer::new( + move |args: &mut DataTableCellRenderArgs| match args.record() { + UpdateTreeEntry::Root => { + html!() + } + UpdateTreeEntry::Remote(remote_info) => { + render_remote_summary(remote_info, args.is_expanded()).into() + } + UpdateTreeEntry::Node(info) => render_node_info(info).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() { + if remote_summary.nodes.len() == 1 { + if let Some((node_name, node_summary)) = remote_summary.nodes.iter().take(1).next() { + root.append(UpdateTreeEntry::Node(NodeEntry { + remote: remote_name.clone(), + node: node_name.clone(), + ty: remote_summary.remote_type, + summary: node_summary.clone(), + flat: true, + })); + + continue; + } + } + + 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(), + flat: false, + })); + } + + 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() + } +} + +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 { + match &self.selected_entry { + Some(UpdateTreeEntry::Node(NodeEntry { + remote, node, ty, .. + })) => { + let title: Html = Row::new() + .gap(2) + .class(AlignItems::Baseline) + .with_child(Fa::new("list")) + .with_child(tr!("Update List - {} ({})", remote, node)) + .into(); + + let base_url = format!("/{ty}/remotes/{remote}/nodes/{node}/apt",); + let task_base_url = format!("/{ty}/remotes/{remote}/tasks"); + + 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/{node}::apt"); + if let Some(url) = get_deep_url(link.yew_link(), &remote, None, &id) + { + let _ = gloo_utils::window().open_with_url(&url.href()); + } + } + RemoteType::Pbs => { + let hash = "#pbsServerAdministration:updates"; + if let Some(url) = + get_deep_url_low_level(link.yew_link(), &remote, None, &hash) + { + let _ = gloo_utils::window().open_with_url(&url.href()); + } + } + } + }); + + Panel::new() + .class(FlexFit) + .title(title) + .border(true) + .min_width(500) + .with_child(apt) + .style("flex", "1 1 0") + } + _ => { + let title: Html = Row::new() + .gap(2) + .class(AlignItems::Baseline) + .with_child(Fa::new("list")) + .with_child(tr!("Update List")) + .into(); + + 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") + } + } + } +} + +fn render_remote_summary(entry: &RemoteEntry, expanded: bool) -> Row { + let mut row = Row::new().class(css::AlignItems::Baseline).gap(2); + match entry.poll_status { + RemoteUpdateStatus::Success => { + if !expanded { + let up_to_date_nodes = entry.number_of_nodes + - entry.number_of_updatable_nodes + - entry.number_of_failed_nodes; + + let text = if entry.number_of_nodes == up_to_date_nodes { + row = row.with_child(render_remote_summary_icon(RemoteSummaryIcon::UpToDate)); + tr!("All nodes up-to-date") + } else if entry.number_of_updatable_nodes > 0 { + row = row.with_child(render_remote_summary_icon(RemoteSummaryIcon::Updatable)); + + if entry.number_of_failed_nodes > 0 { + row = row.with_child(render_remote_summary_icon(RemoteSummaryIcon::Error)); + // NOTE: This 'summary' line is only shown for remotes with multiple nodes, + // so we don't really have to consider the singular form of 'x out of y + // nodes' + tr!("Some nodes have pending updates, some nodes unavailable") + } else { + tr!("Some nodes have pending updates") + } + } else if entry.number_of_failed_nodes > 0 { + row = row.with_child(render_remote_summary_icon(RemoteSummaryIcon::Error)); + tr!("Some nodes unavailable") + } else { + String::new() + }; + + row = row.with_child(text); + } + } + RemoteUpdateStatus::Error => { + row = row.with_child(render_remote_summary_icon(RemoteSummaryIcon::Error)); + row = row.with_child(tr!("Could not connect to remote")); + } + RemoteUpdateStatus::Unknown => { + row = row.with_child(render_remote_summary_icon(RemoteSummaryIcon::Unknown)); + row = row.with_child(tr!("Update status unknown")); + } + } + + row +} + +fn render_node_info(entry: &NodeEntry) -> Row { + let (icon, text) = if entry.summary.status == NodeUpdateStatus::Error { + let icon = render_remote_summary_icon(RemoteSummaryIcon::Error); + let text = if let Some(status) = &entry.summary.status_message { + tr!("Failed to retrieve update status: {}", status) + } else { + tr!("Unknown error") + }; + + (icon, text) + } else if entry.summary.number_of_updates > 0 { + ( + render_remote_summary_icon(RemoteSummaryIcon::Updatable), + tr!("One update pending" | "{n} updates pending" % entry.summary.number_of_updates), + ) + } else { + ( + render_remote_summary_icon(RemoteSummaryIcon::UpToDate), + tr!("Up-to-date"), + ) + }; + + Row::new() + .class(css::AlignItems::Baseline) + .gap(2) + .with_child(icon) + .with_child(text) +} + +enum RemoteSummaryIcon { + UpToDate, + Updatable, + Error, + Unknown, +} + +fn render_remote_summary_icon(icon: RemoteSummaryIcon) -> Fa { + let (icon_class, icon_scheme) = match icon { + RemoteSummaryIcon::UpToDate => ("check", FontColor::Success), + RemoteSummaryIcon::Error => ("times-circle", FontColor::Error), + RemoteSummaryIcon::Updatable => ("refresh", FontColor::Primary), + RemoteSummaryIcon::Unknown => ("question-circle-o", FontColor::Primary), + }; + + Fa::new(icon_class).class(icon_scheme) +} -- 2.47.3 _______________________________________________ pdm-devel mailing list pdm-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel