From: "Shannon Sterz" <s.sterz@proxmox.com>
To: "Lukas Wagner" <l.wagner@proxmox.com>
Cc: Proxmox Datacenter Manager development discussion
<pdm-devel@lists.proxmox.com>
Subject: Re: [pdm-devel] [PATCH proxmox-datacenter-manager 11/12] ui: add remote update view
Date: Fri, 17 Oct 2025 12:15:35 +0200 [thread overview]
Message-ID: <DDKIOUIS30IF.G0T72O8I7AKV@proxmox.com> (raw)
In-Reply-To: <20251015124711.312943-12-l.wagner@proxmox.com>
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 <l.wagner@proxmox.com>
> ---
> 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<UpdateTree> for VNode {
> + fn from(value: UpdateTree) -> Self {
> + let comp = VComp::new::<LoadableComponentMaster<UpdateTreeComponent>>(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<Key>),
> + 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<UpdateTreeEntry>,
> + selection: Selection,
> + selected_entry: Option<UpdateTreeEntry>,
> +}
> +
> +fn default_sorter(a: &UpdateTreeEntry, b: &UpdateTreeEntry) -> Ordering {
> + a.name().cmp(b.name())
> +}
> +
> +impl UpdateTreeComponent {
> + fn columns(
> + _ctx: &LoadableComponentContext<Self>,
> + store: TreeStore<UpdateTreeEntry>,
> + ) -> Rc<Vec<DataTableHeader<UpdateTreeEntry>>> {
> + 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<UpdateTreeEntry>| {
> + 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<UpdateTreeEntry> {
> + 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>) -> 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<Self>,
> + ) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
> + 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<Self>, 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<Self>) -> 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<Self>) -> 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<Self>) -> 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! {<h1 class="pwt-font-headline-medium">{header}</h1>})
> + .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
next prev parent reply other threads:[~2025-10-17 10:15 UTC|newest]
Thread overview: 22+ messages / expand[flat|nested] mbox.gz Atom feed top
2025-10-15 12:46 [pdm-devel] [PATCH proxmox-datacenter-manager 00/12] add global " Lukas Wagner
2025-10-15 12:47 ` [pdm-devel] [PATCH proxmox-datacenter-manager 01/12] metric collection task: tests: add missing parameter for cluster_metric_export Lukas Wagner
2025-10-15 12:47 ` [pdm-devel] [PATCH proxmox-datacenter-manager 02/12] pdm-api-types: add types for remote upgrade summary Lukas Wagner
2025-10-17 10:15 ` Shannon Sterz
2025-10-17 11:12 ` Lukas Wagner
2025-10-17 11:52 ` Lukas Wagner
2025-10-15 12:47 ` [pdm-devel] [PATCH proxmox-datacenter-manager 03/12] remote updates: add cache for remote update availability Lukas Wagner
2025-10-15 12:47 ` [pdm-devel] [PATCH proxmox-datacenter-manager 04/12] api: add API for retrieving/refreshing the remote update summary Lukas Wagner
2025-10-17 7:44 ` Lukas Wagner
2025-10-17 10:15 ` Shannon Sterz
2025-10-17 11:00 ` Lukas Wagner
2025-10-15 12:47 ` [pdm-devel] [PATCH proxmox-datacenter-manager 05/12] unprivileged api daemon: tasks: add remote update refresh task Lukas Wagner
2025-10-15 12:47 ` [pdm-devel] [PATCH proxmox-datacenter-manager 06/12] pdm-client: add API methods for remote update summaries Lukas Wagner
2025-10-15 12:47 ` [pdm-devel] [PATCH proxmox-datacenter-manager 07/12] pbs-client: add bindings for APT-related API calls Lukas Wagner
2025-10-15 12:47 ` [pdm-devel] [PATCH proxmox-datacenter-manager 08/12] task cache: use separate functions for tracking PVE and PBS tasks Lukas Wagner
2025-10-15 12:47 ` [pdm-devel] [PATCH proxmox-datacenter-manager 09/12] remote updates: add support for PBS remotes Lukas Wagner
2025-10-15 12:47 ` [pdm-devel] [PATCH proxmox-datacenter-manager 10/12] api: add APT endpoints " Lukas Wagner
2025-10-15 12:47 ` [pdm-devel] [PATCH proxmox-datacenter-manager 11/12] ui: add remote update view Lukas Wagner
2025-10-17 10:15 ` Shannon Sterz [this message]
2025-10-15 12:47 ` [pdm-devel] [PATCH proxmox-datacenter-manager 12/12] ui: show new remote update view in the 'Remotes' section Lukas Wagner
2025-10-17 10:15 ` [pdm-devel] [PATCH proxmox-datacenter-manager 00/12] add global remote update view Shannon Sterz
2025-10-17 12:14 ` [pdm-devel] superseded: " Lukas Wagner
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=DDKIOUIS30IF.G0T72O8I7AKV@proxmox.com \
--to=s.sterz@proxmox.com \
--cc=l.wagner@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