From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id 724981FF179 for ; Wed, 15 Oct 2025 14:47:13 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id C45241B158; Wed, 15 Oct 2025 14:47:28 +0200 (CEST) From: Lukas Wagner To: pdm-devel@lists.proxmox.com Date: Wed, 15 Oct 2025 14:47:10 +0200 Message-ID: <20251015124711.312943-12-l.wagner@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20251015124711.312943-1-l.wagner@proxmox.com> References: <20251015124711.312943-1-l.wagner@proxmox.com> MIME-Version: 1.0 X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1760532438078 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 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 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. Thew 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 --- 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, +} + +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() + } +} + +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); + + 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); + if let Some(url) = + get_deep_url(&link.yew_link(), &remote, None, &id) + { + let _ = web_sys::window().unwrap().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 _ = web_sys::window().unwrap().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 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() +} -- 2.47.3 _______________________________________________ pdm-devel mailing list pdm-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel