all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Lukas Wagner <l.wagner@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [pdm-devel] [PATCH datacenter-manager v3 10/12] ui: add remote update view
Date: Thu, 23 Oct 2025 14:44:18 +0200	[thread overview]
Message-ID: <20251023124420.244585-11-l.wagner@proxmox.com> (raw)
In-Reply-To: <20251023124420.244585-1-l.wagner@proxmox.com>

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 <l.wagner@proxmox.com>
---

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<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,
+    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<Key>),
+    RefreshAll,
+}
+
+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)
+                .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<UpdateTreeEntry>| 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<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() {
+        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>) -> 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()
+    }
+}
+
+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 {
+        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! {<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")
+            }
+        }
+    }
+}
+
+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


  parent reply	other threads:[~2025-10-23 12:44 UTC|newest]

Thread overview: 14+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2025-10-23 12:44 [pdm-devel] [PATCH datacenter-manager v3 00/12] add global " Lukas Wagner
2025-10-23 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 01/12] pdm-api-types: add types for remote upgrade summary Lukas Wagner
2025-10-23 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 02/12] remote updates: add cache for remote update availability Lukas Wagner
2025-10-23 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 03/12] api: add API for retrieving/refreshing the remote update summary Lukas Wagner
2025-10-23 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 04/12] unprivileged api daemon: tasks: add remote update refresh task Lukas Wagner
2025-10-23 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 05/12] pdm-client: add API methods for remote update summaries Lukas Wagner
2025-10-23 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 06/12] pbs-client: add bindings for APT-related API calls Lukas Wagner
2025-10-23 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 07/12] task cache: use separate functions for tracking PVE and PBS tasks Lukas Wagner
2025-10-23 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 08/12] remote updates: add support for PBS remotes Lukas Wagner
2025-10-23 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 09/12] api: add APT endpoints " Lukas Wagner
2025-10-23 12:44 ` Lukas Wagner [this message]
2025-10-23 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 11/12] ui: show new remote update view in the 'Remotes' section Lukas Wagner
2025-10-23 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 12/12] remote updates: avoid unnecessary clone Lukas Wagner
2025-10-23 20:30 ` [pdm-devel] applied-series: [PATCH datacenter-manager v3 00/12] add global remote update 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=20251023124420.244585-11-l.wagner@proxmox.com \
    --to=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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal