From: Lukas Wagner <l.wagner@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [pdm-devel] [PATCH datacenter-manager v2 11/13] ui: add remote update view
Date: Fri, 17 Oct 2025 14:10:07 +0200 [thread overview]
Message-ID: <20251017121009.212499-12-l.wagner@proxmox.com> (raw)
In-Reply-To: <20251017121009.212499-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>
Reviewed-by: Shannon Sterz <s.sterz@proxmox.com>
Tested-by: Shannon Sterz <s.sterz@proxmox.com>
---
Notes:
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..6a7ecfb4
--- /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, 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),
+ })
+ }
+}
+
+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)
+ .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()
+ }
+}
+
+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/{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 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()
+}
--
2.47.3
_______________________________________________
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 12:10 UTC|newest]
Thread overview: 18+ messages / expand[flat|nested] mbox.gz Atom feed top
2025-10-17 12:09 [pdm-devel] [PATCH datacenter-manager v2 00/13] add global " Lukas Wagner
2025-10-17 12:09 ` [pdm-devel] [PATCH datacenter-manager v2 01/13] metric collection task: tests: add missing parameter for cluster_metric_export Lukas Wagner
2025-10-21 19:24 ` [pdm-devel] applied: " Thomas Lamprecht
2025-10-17 12:09 ` [pdm-devel] [PATCH datacenter-manager v2 02/13] pdm-api-types: add types for remote upgrade summary Lukas Wagner
2025-10-17 12:09 ` [pdm-devel] [PATCH datacenter-manager v2 03/13] remote updates: add cache for remote update availability Lukas Wagner
2025-10-17 12:10 ` [pdm-devel] [PATCH datacenter-manager v2 04/13] api: add API for retrieving/refreshing the remote update summary Lukas Wagner
2025-10-17 12:10 ` [pdm-devel] [PATCH datacenter-manager v2 05/13] unprivileged api daemon: tasks: add remote update refresh task Lukas Wagner
2025-10-17 12:10 ` [pdm-devel] [PATCH datacenter-manager v2 06/13] pdm-client: add API methods for remote update summaries Lukas Wagner
2025-10-17 12:10 ` [pdm-devel] [PATCH datacenter-manager v2 07/13] pbs-client: add bindings for APT-related API calls Lukas Wagner
2025-10-17 12:10 ` [pdm-devel] [PATCH datacenter-manager v2 08/13] task cache: use separate functions for tracking PVE and PBS tasks Lukas Wagner
2025-10-17 12:10 ` [pdm-devel] [PATCH datacenter-manager v2 09/13] remote updates: add support for PBS remotes Lukas Wagner
2025-10-17 12:10 ` [pdm-devel] [PATCH datacenter-manager v2 10/13] api: add APT endpoints " Lukas Wagner
2025-10-17 12:10 ` Lukas Wagner [this message]
2025-10-21 19:18 ` [pdm-devel] [PATCH datacenter-manager v2 11/13] ui: add remote update view Thomas Lamprecht
2025-10-22 10:22 ` Lukas Wagner
2025-10-23 8:36 ` Thomas Lamprecht
2025-10-17 12:10 ` [pdm-devel] [PATCH datacenter-manager v2 12/13] ui: show new remote update view in the 'Remotes' section Lukas Wagner
2025-10-17 12:10 ` [pdm-devel] [PATCH datacenter-manager v2 13/13] remote updates: avoid unnecessary clone 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=20251017121009.212499-12-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.