all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Thomas Lamprecht <t.lamprecht@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [PATCH datacenter-manager v5 10/10] subscription: add Check Subscription action
Date: Sun, 24 May 2026 00:58:17 +0200	[thread overview]
Message-ID: <20260523225835.3106077-11-t.lamprecht@proxmox.com> (raw)
In-Reply-To: <20260523225835.3106077-1-t.lamprecht@proxmox.com>

Wire a per-node Check Subscription action that drives the remote's
`update_subscription(force=true)` endpoint (POST on PVE / PBS) and
invalidates the PDM-side subscription cache so the next status read
reflects the fresh shop verdict instead of a 5-minute-stale snapshot.
Mirrors the per-product Check button on PVE and PBS, just driven
from the central registry view.

Useful when a node's live status has drifted to Invalid / Expired
because of a shop-side change and the operator wants to promote the
live verdict back to Active without waiting for the periodic check.

PVE and PBS use the canonical `UpdateSubscription` typed binding
(PVE via pve-api-types, PBS via proxmox-subscription).

NodeSubscriptionInfo and RemoteNodeStatus grow optional check_time
and next_due_date fields populated from the live SubscriptionInfo;
the Status column tooltip surfaces both, where the remote reports
them, so the operator can tell at a glance how fresh the last
check is and when the subscription will next come due.

Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---

Changes v4 -> 5:
* Node Status panel gains a real toolbar: per-node actions on the left,
  bulk / queue actions (Adopt All, Apply Pending, Discard Pending) on
  the right. Apply/Discard Pending and Auto-Assign move off the old
  standalone top toolbar into the two panels, each of which gets its
  own refresh (Dominik).

 cli/client/src/subscriptions.rs               |  23 ++
 docs/subscription-registry.rst                |   7 +
 lib/pdm-api-types/src/subscription.rs         |  14 +
 lib/pdm-client/src/lib.rs                     |  16 ++
 server/src/api/resources.rs                   |   4 +
 server/src/api/subscriptions/mod.rs           | 122 ++++++++-
 ui/src/configuration/subscription_keys.rs     |  90 ++++---
 ui/src/configuration/subscription_registry.rs | 243 +++++++++++-------
 8 files changed, 386 insertions(+), 133 deletions(-)

diff --git a/cli/client/src/subscriptions.rs b/cli/client/src/subscriptions.rs
index a83222ee..db1eacaa 100644
--- a/cli/client/src/subscriptions.rs
+++ b/cli/client/src/subscriptions.rs
@@ -53,6 +53,10 @@ pub fn cli() -> CommandLineInterface {
             CliCommand::new(&API_METHOD_ADOPT_KEY).arg_param(&["remote", "node"]),
         )
         .insert("adopt-all", CliCommand::new(&API_METHOD_ADOPT_ALL))
+        .insert(
+            "check",
+            CliCommand::new(&API_METHOD_CHECK_SUBSCRIPTION).arg_param(&["remote", "node"]),
+        )
         .into()
 }
 
@@ -373,6 +377,25 @@ async fn adopt_all(digest: Option<String>) -> Result<(), Error> {
     Ok(())
 }
 
+#[api(
+    input: {
+        properties: {
+            remote: { schema: REMOTE_ID_SCHEMA },
+            node: { schema: NODE_SCHEMA },
+        },
+    },
+)]
+/// Trigger a fresh shop-side subscription check on a remote node.
+///
+/// Equivalent to the per-product "Check" button: re-verifies the live subscription status
+/// against the shop. Useful for promoting a stale Invalid/Expired verdict to Active once the
+/// underlying issue is fixed at the shop, without waiting for the next periodic check.
+async fn check_subscription(remote: String, node: String) -> Result<(), Error> {
+    client()?.subscription_check(&remote, &node).await?;
+    println!("Re-checked subscription on {remote}/{node}.");
+    Ok(())
+}
+
 #[api(
     input: {
         properties: {
diff --git a/docs/subscription-registry.rst b/docs/subscription-registry.rst
index 6d599fe2..3d64c0bc 100644
--- a/docs/subscription-registry.rst
+++ b/docs/subscription-registry.rst
@@ -63,6 +63,13 @@ The proposed plan can be inspected before it is applied. Apply Pending walks the
 order; if any push or clear fails the remaining queue is kept intact for retry. Discard Pending
 drops the plan without touching any remote.
 
+The Check Subscription action triggers a fresh shop-side verification of the live subscription
+on the selected node, equivalent to the per-product "Check" button on PVE / PBS. Useful for
+promoting a stale ``Invalid`` or ``Expired`` verdict to ``Active`` once the underlying issue is
+fixed at the shop, without having to wait for the next periodic check. The Status column tooltip
+surfaces the last-checked timestamp and the next-due-date as reported by the remote, where
+available.
+
 Permissions
 -----------
 
diff --git a/lib/pdm-api-types/src/subscription.rs b/lib/pdm-api-types/src/subscription.rs
index 6073cec1..f6d5c1d8 100644
--- a/lib/pdm-api-types/src/subscription.rs
+++ b/lib/pdm-api-types/src/subscription.rs
@@ -120,6 +120,14 @@ pub struct NodeSubscriptionInfo {
     /// Serverid of the node, if accessible
     #[serde(skip_serializing)]
     pub serverid: Option<String>,
+
+    /// Epoch of the last successful subscription check on the node.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub check_time: Option<i64>,
+
+    /// Next due date of the subscription, as reported by the remote.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub next_due_date: Option<String>,
 }
 
 #[api(
@@ -555,6 +563,12 @@ pub struct RemoteNodeStatus {
     /// True when the pool has a clear queued for this node. Omitted on the wire when false.
     #[serde(default, skip_serializing_if = "std::ops::Not::not")]
     pub pending_clear: bool,
+    /// Epoch of the last successful subscription check on the node.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub check_time: Option<i64>,
+    /// Next due date of the subscription, as reported by the remote.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub next_due_date: Option<String>,
 }
 
 #[api]
diff --git a/lib/pdm-client/src/lib.rs b/lib/pdm-client/src/lib.rs
index 4eb6eb1c..00f83856 100644
--- a/lib/pdm-client/src/lib.rs
+++ b/lib/pdm-client/src/lib.rs
@@ -1397,6 +1397,22 @@ impl<T: HttpApiClient> PdmClient<T> {
             .nodata()
     }
 
+    /// Trigger a fresh shop-side subscription check on `remote`/`node`. Equivalent to the
+    /// per-product "Check" button: drives `update_subscription(force=true)` and invalidates the
+    /// remote's cached subscription state so the next `subscription_node_status` reflects the
+    /// new verdict.
+    pub async fn subscription_check(&self, remote: &str, node: &str) -> Result<(), Error> {
+        #[derive(Serialize)]
+        struct Args<'a> {
+            remote: &'a str,
+            node: &'a str,
+        }
+        self.0
+            .post("/api2/extjs/subscriptions/check", &Args { remote, node })
+            .await?
+            .nodata()
+    }
+
     /// Clear every pending assignment in one bulk transaction; returns the count of cleared
     /// entries.
     pub async fn subscription_clear_pending(
diff --git a/server/src/api/resources.rs b/server/src/api/resources.rs
index 38a8f47c..7b091087 100644
--- a/server/src/api/resources.rs
+++ b/server/src/api/resources.rs
@@ -954,6 +954,8 @@ async fn fetch_remote_subscription_info(
                                 .level
                                 .and_then(|level| level.parse().ok())
                                 .unwrap_or_default(),
+                            check_time: info.checktime,
+                            next_due_date: info.nextduedate,
                         }
                     }),
                 );
@@ -970,6 +972,8 @@ async fn fetch_remote_subscription_info(
                     key: info.key,
                     level,
                     serverid: info.serverid,
+                    check_time: info.checktime,
+                    next_due_date: info.nextduedate,
                 }
             });
 
diff --git a/server/src/api/subscriptions/mod.rs b/server/src/api/subscriptions/mod.rs
index 8e53811d..07cbfab6 100644
--- a/server/src/api/subscriptions/mod.rs
+++ b/server/src/api/subscriptions/mod.rs
@@ -47,6 +47,7 @@ const SUBDIRS: SubdirMap = &sorted!([
     ),
     ("auto-assign", &Router::new().post(&API_METHOD_AUTO_ASSIGN)),
     ("bulk-assign", &Router::new().post(&API_METHOD_BULK_ASSIGN)),
+    ("check", &Router::new().post(&API_METHOD_CHECK_SUBSCRIPTION)),
     (
         "clear-pending",
         &Router::new().post(&API_METHOD_CLEAR_PENDING)
@@ -815,6 +816,38 @@ async fn delete_subscription_on_remote(
     Ok(())
 }
 
+/// Trigger a fresh shop-side subscription check on `remote`/`node` and return once the remote
+/// has stored the result. Equivalent to the per-product "Check" button, just driven through PDM.
+async fn check_subscription_on_remote(
+    remote: &Remote,
+    product_type: ProductType,
+    node_name: &str,
+) -> Result<(), Error> {
+    match product_type {
+        ProductType::Pve => {
+            let client = crate::connection::make_pve_client(remote)?;
+            client
+                .update_subscription(
+                    node_name,
+                    pve_api_types::UpdateSubscription { force: Some(true) },
+                )
+                .await?;
+        }
+        ProductType::Pbs => {
+            let client = crate::connection::make_pbs_client(remote)?;
+            client
+                .check_subscription(proxmox_subscription::UpdateSubscription { force: Some(true) })
+                .await?;
+        }
+        ProductType::Pmg | ProductType::Pom => {
+            bail!("PDM cannot check '{product_type}' keys: no remote support yet");
+        }
+    }
+
+    info!("re-checked subscription on {}/{node_name}", remote.id);
+    Ok(())
+}
+
 #[api(
     input: {
         properties: {
@@ -974,6 +1007,63 @@ async fn revert_pending_clear(
     Ok(())
 }
 
+#[api(
+    input: {
+        properties: {
+            remote: { schema: REMOTE_ID_SCHEMA },
+            // NODE_SCHEMA rejects path-traversal input before it ends up interpolated into the
+            // remote URL `/api2/extjs/nodes/{node}/subscription`.
+            node: { schema: NODE_SCHEMA },
+        },
+    },
+    access: {
+        permission: &Permission::Privilege(&["system"], PRIV_SYS_MODIFY, false),
+    },
+)]
+/// Trigger a fresh shop-side subscription check on `remote`/`node`.
+///
+/// Mirrors the per-product "Check" button on PVE / PBS: drives the remote's
+/// `update_subscription(force=true)` endpoint so a status that went stale at the shop (Invalid,
+/// Expired) gets re-verified without waiting for the next periodic check. The cached
+/// subscription state for the remote is invalidated so the next node-status read reflects the
+/// fresh verdict instead of a 5-minute-stale snapshot.
+///
+/// Per-remote `PRIV_RESOURCE_MODIFY` is enforced inside the handler since the call costs an
+/// outbound HTTPS request to the shop.
+async fn check_subscription(
+    remote: String,
+    node: String,
+    rpcenv: &mut dyn RpcEnvironment,
+) -> Result<(), Error> {
+    let auth_id: Authid = rpcenv
+        .get_auth_id()
+        .context("no authid available")?
+        .parse()?;
+    let user_info = CachedUserInfo::new()?;
+    user_info.check_privs(
+        &auth_id,
+        &["resource", &remote],
+        PRIV_RESOURCE_MODIFY,
+        false,
+    )?;
+
+    let (remotes_config, _) = pdm_config::remotes::config()?;
+    let remote_entry = remotes_config
+        .get(&remote)
+        .ok_or_else(|| http_err!(NOT_FOUND, "remote '{remote}' not found"))?;
+
+    let product_type = match remote_entry.ty {
+        pdm_api_types::remotes::RemoteType::Pve => ProductType::Pve,
+        pdm_api_types::remotes::RemoteType::Pbs => ProductType::Pbs,
+    };
+
+    check_subscription_on_remote(remote_entry, product_type, &node)
+        .await
+        .map_err(|err| http_err!(BAD_REQUEST, "check failed on {remote}/{node}: {err}"))?;
+    invalidate_subscription_info_for_remote(&remote).await;
+    Ok(())
+}
+
 #[api(
     input: {
         properties: {
@@ -1356,13 +1446,22 @@ async fn collect_node_status(
         };
 
         for (node_name, node_info) in &node_infos {
-            let (status, level, sockets, current_key) = match node_info {
-                Some(info) => (info.status, info.level, info.sockets, info.key.clone()),
+            let (status, level, sockets, current_key, check_time, next_due_date) = match node_info {
+                Some(info) => (
+                    info.status,
+                    info.level,
+                    info.sockets,
+                    info.key.clone(),
+                    info.check_time,
+                    info.next_due_date.clone(),
+                ),
                 None => (
                     proxmox_subscription::SubscriptionStatus::NotFound,
                     SubscriptionLevel::None,
                     None,
                     None,
+                    None,
+                    None,
                 ),
             };
 
@@ -1385,6 +1484,8 @@ async fn collect_node_status(
                 assigned_key,
                 current_key,
                 pending_clear,
+                check_time,
+                next_due_date,
             });
         }
     }
@@ -2079,13 +2180,22 @@ async fn collect_status_uncached(
     for (remote_name, remote_ty, result) in results {
         let Ok(node_infos) = result else { continue };
         for (node_name, node_info) in &node_infos {
-            let (status, level, sockets, current_key) = match node_info {
-                Some(info) => (info.status, info.level, info.sockets, info.key.clone()),
+            let (status, level, sockets, current_key, check_time, next_due_date) = match node_info {
+                Some(info) => (
+                    info.status,
+                    info.level,
+                    info.sockets,
+                    info.key.clone(),
+                    info.check_time,
+                    info.next_due_date.clone(),
+                ),
                 None => (
                     proxmox_subscription::SubscriptionStatus::NotFound,
                     SubscriptionLevel::None,
                     None,
                     None,
+                    None,
+                    None,
                 ),
             };
             out.push(RemoteNodeStatus {
@@ -2098,6 +2208,8 @@ async fn collect_status_uncached(
                 assigned_key: None,
                 current_key,
                 pending_clear: false,
+                check_time,
+                next_due_date,
             });
         }
     }
@@ -2181,6 +2293,8 @@ mod tests {
             assigned_key: None,
             current_key: None,
             pending_clear: false,
+            check_time: None,
+            next_due_date: None,
         }
     }
 
diff --git a/ui/src/configuration/subscription_keys.rs b/ui/src/configuration/subscription_keys.rs
index f4651ec1..35c23461 100644
--- a/ui/src/configuration/subscription_keys.rs
+++ b/ui/src/configuration/subscription_keys.rs
@@ -261,48 +261,60 @@ impl LoadableComponent for SubscriptionKeyGridComp {
             .unwrap_or(false);
         let link = ctx.link();
 
-        Some(
-            Toolbar::new()
-                .border_bottom(true)
-                .with_child(
-                    Tooltip::new(
-                        Button::new(tr!("Add"))
-                            .icon_class("fa fa-plus")
-                            .on_activate(link.change_view_callback(|_| Some(ViewState::Add))),
-                    )
-                    .tip(tr!(
-                        "Add one or more subscription keys to the pool; the Assign step \
-                         happens later."
-                    )),
+        let mut toolbar = Toolbar::new()
+            .border_bottom(true)
+            .with_child(
+                Tooltip::new(
+                    Button::new(tr!("Add"))
+                        .icon_class("fa fa-plus")
+                        .on_activate(link.change_view_callback(|_| Some(ViewState::Add))),
                 )
-                .with_spacer()
-                .with_child(
-                    Tooltip::new(
-                        Button::new(tr!("Remove Key"))
-                            .icon_class("fa fa-trash-o")
-                            .disabled(!has_selection || synced_assignment)
-                            .on_activate(link.change_view_callback(|_| Some(ViewState::Remove))),
-                    )
-                    .tip(tr!(
-                        "Remove the selected key from the pool. Disabled while the key is \
-                         live on a remote node."
-                    )),
+                .tip(tr!(
+                    "Add one or more subscription keys to the pool; the Assign step \
+                     happens later."
+                )),
+            )
+            .with_spacer()
+            .with_child(
+                Tooltip::new(
+                    Button::new(tr!("Assign"))
+                        .icon_class("fa fa-link")
+                        .disabled(!has_selection || is_assigned || !assignable)
+                        .on_activate(link.change_view_callback(|_| Some(ViewState::Assign))),
                 )
-                .with_spacer()
-                .with_child(
-                    Tooltip::new(
-                        Button::new(tr!("Assign"))
-                            .icon_class("fa fa-link")
-                            .disabled(!has_selection || is_assigned || !assignable)
-                            .on_activate(link.change_view_callback(|_| Some(ViewState::Assign))),
-                    )
-                    .tip(tr!(
-                        "Pin the selected key to a remote node; Apply Pending pushes the \
-                         assignment to the remote."
-                    )),
+                .tip(tr!(
+                    "Pin the selected key to a remote node; Apply Pending pushes the \
+                     assignment to the remote."
+                )),
+            )
+            .with_child(
+                Tooltip::new(
+                    Button::new(tr!("Remove Key"))
+                        .icon_class("fa fa-trash-o")
+                        .disabled(!has_selection || synced_assignment)
+                        .on_activate(link.change_view_callback(|_| Some(ViewState::Remove))),
                 )
-                .into(),
-        )
+                .tip(tr!(
+                    "Remove the selected key from the pool. Disabled while the key is \
+                     live on a remote node."
+                )),
+            );
+
+        if let Some(cb) = ctx.props().on_auto_assign.clone() {
+            toolbar = toolbar.with_flex_spacer().with_child(
+                Tooltip::new(
+                    Button::new(tr!("Auto-Assign"))
+                        .icon_class("fa fa-magic")
+                        .on_activate(move |_| cb.emit(())),
+                )
+                .tip(tr!(
+                    "Propose a one-key-per-node assignment for nodes that have no active \
+                     subscription, then queue it pending Apply."
+                )),
+            );
+        }
+
+        Some(toolbar.into())
     }
 
     fn changed(
diff --git a/ui/src/configuration/subscription_registry.rs b/ui/src/configuration/subscription_registry.rs
index 4f1e8175..e8038363 100644
--- a/ui/src/configuration/subscription_registry.rs
+++ b/ui/src/configuration/subscription_registry.rs
@@ -7,6 +7,7 @@ use anyhow::Error;
 use yew::virtual_dom::{Key, VComp, VNode};
 
 use proxmox_yew_comp::percent_encoding::percent_encode_component;
+use proxmox_yew_comp::utils::render_epoch;
 use proxmox_yew_comp::{http_delete, http_get, http_get_full, http_post};
 use proxmox_yew_comp::{
     LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
@@ -65,6 +66,26 @@ fn subscription_status_label(status: proxmox_subscription::SubscriptionStatus) -
     }
 }
 
+/// Build a multi-line Status-column tooltip listing the last-check timestamp and the
+/// next-due-date when the remote provides them. Returns None if neither is set so the caller
+/// can skip wrapping the cell in a tooltip entirely.
+fn status_tooltip_lines(n: &RemoteNodeStatus) -> Option<String> {
+    let mut lines: Vec<String> = Vec::new();
+    if let Some(ts) = n.check_time {
+        lines.push(tr!("Last checked: {when}", when = render_epoch(ts)));
+    }
+    if let Some(due) = n.next_due_date.as_deref() {
+        if !due.is_empty() {
+            lines.push(tr!("Next due: {date}", date = due.to_string()));
+        }
+    }
+    if lines.is_empty() {
+        None
+    } else {
+        Some(lines.join("\n"))
+    }
+}
+
 fn pending_badge(push_count: u32, clear_count: u32) -> Row {
     let mut row = Row::new().class(AlignItems::Center).gap(3);
     if push_count > 0 {
@@ -249,6 +270,9 @@ pub enum Msg {
     AdoptKeyForSelectedNode,
     /// Open the confirmation dialog for adopting every foreign live subscription into the pool.
     AdoptAllPreview,
+    /// Re-check the subscription on the currently-selected node against the shop. Pure refresh
+    /// path; no confirmation dialog since the action is read-only from the pool's perspective.
+    CheckSubscriptionForSelectedNode,
 }
 
 #[derive(PartialEq)]
@@ -385,12 +409,16 @@ impl SubscriptionRegistryComp {
                     node_field_sorter(a, b, |n| subscription_status_label(n.status))
                 })
                 .render(|entry: &NodeTreeEntry| match entry {
-                    NodeTreeEntry::Node { data: n, .. } => Row::new()
-                        .class(AlignItems::Baseline)
-                        .gap(2)
-                        .with_child(subscription_status_icon(n.status))
-                        .with_child(subscription_status_label(n.status))
-                        .into(),
+                    NodeTreeEntry::Node { data: n, .. } => {
+                        let row = Row::new()
+                            .class(AlignItems::Baseline)
+                            .gap(2)
+                            .with_child(subscription_status_icon(n.status))
+                            .with_child(subscription_status_label(n.status));
+                        status_tooltip_lines(n)
+                            .map(|tip| Tooltip::new(row.clone()).tip(tip).into())
+                            .unwrap_or_else(|| row.into())
+                    }
                     NodeTreeEntry::Remote { active, total, .. } => {
                         let icon = if active == total {
                             Fa::new("check-circle").class(FontColor::Success)
@@ -732,84 +760,24 @@ impl LoadableComponent for SubscriptionRegistryComp {
                 ctx.link()
                     .change_view(Some(ViewState::ConfirmAdoptAll { candidates }));
             }
+            Msg::CheckSubscriptionForSelectedNode => {
+                let Some(n) = self.selected_node_status() else {
+                    return false;
+                };
+                let remote = n.remote.clone();
+                let node = n.node.clone();
+                let link = ctx.link().clone();
+                ctx.link().spawn(async move {
+                    if let Err(err) = crate::pdm_client().subscription_check(&remote, &node).await {
+                        link.show_error(tr!("Check Subscription"), err.to_string(), true);
+                    }
+                    link.send_reload();
+                });
+            }
         }
         true
     }
 
-    fn toolbar(&self, ctx: &LoadableComponentContext<Self>) -> Option<Html> {
-        let link = ctx.link();
-        let (push_count, clear_count) = self.pending_counts();
-        let adopt_all_count = self.adopt_all_candidates().len();
-        let mut toolbar = Toolbar::new()
-            .border_bottom(true)
-            .with_child(
-                Tooltip::new(
-                    Button::new(tr!("Auto-Assign"))
-                        .icon_class("fa fa-magic")
-                        .on_activate(link.callback(|_| Msg::AutoAssignPreview)),
-                )
-                .tip(tr!(
-                    "Propose a one-key-per-node assignment for nodes that have no active \
-                     subscription, then queue it pending Apply."
-                )),
-            )
-            .with_child(
-                Tooltip::new(
-                    Button::new(tr!("Adopt All"))
-                        .icon_class("fa fa-download")
-                        .disabled(adopt_all_count == 0)
-                        .on_activate(link.callback(|_| Msg::AdoptAllPreview)),
-                )
-                .tip(tr!(
-                    "Import every foreign live subscription that is not yet tracked by the \
-                     pool. The remote is not contacted; only the pool config is updated."
-                )),
-            )
-            .with_spacer()
-            .with_child(
-                Tooltip::new(
-                    Button::new(tr!("Apply Pending"))
-                        .icon_class("fa fa-play")
-                        .disabled(push_count + clear_count == 0)
-                        .on_activate(
-                            link.change_view_callback(|_| Some(ViewState::ConfirmApplyPending)),
-                        ),
-                )
-                .tip(tr!(
-                    "Push every queued assignment to its remote node and remove the \
-                     subscription from nodes pending clear."
-                )),
-            )
-            .with_child(
-                Tooltip::new(
-                    Button::new(tr!("Discard Pending"))
-                        .icon_class("fa fa-eraser")
-                        .disabled(push_count + clear_count == 0)
-                        .on_activate(
-                            link.change_view_callback(|_| Some(ViewState::ConfirmClearPending)),
-                        ),
-                )
-                .tip(tr!(
-                    "Discard queued assignments without touching the remote nodes."
-                )),
-            )
-            .with_flex_spacer();
-
-        if push_count + clear_count > 0 {
-            toolbar = toolbar.with_child(pending_badge(push_count, clear_count));
-        }
-
-        Some(
-            toolbar
-                .with_flex_spacer()
-                .with_child(Button::refresh(self.loading()).on_activate({
-                    let link = link.clone();
-                    move |_| link.send_reload()
-                }))
-                .into(),
-        )
-    }
-
     fn load(
         &self,
         ctx: &LoadableComponentContext<Self>,
@@ -1066,21 +1034,27 @@ impl LoadableComponent for SubscriptionRegistryComp {
 
 impl SubscriptionRegistryComp {
     fn render_key_pool_panel(&self, ctx: &LoadableComponentContext<Self>) -> Panel {
+        let statuses = Rc::new(self.last_node_data.clone());
         // Reload the right-side node tree whenever the left-side key pool mutates, so a fresh
         // assignment shows up as pending without forcing the operator to re-navigate.
-        let link = ctx.link().clone();
-        // Pass the current node-status snapshot into the grid so its Clear button can be
-        // disabled for synced bindings (orphan-prevention - mirrors the server-side refusal).
-        let statuses = Rc::new(self.last_node_data.clone());
+        let reload = ctx.link().clone();
+        // Both panels share one fetch, so this refresh reloads the whole view; each panel still
+        // carries its own so the control sits where the operator expects it.
+        let refresh = Button::refresh(self.loading()).on_activate({
+            let link = ctx.link().clone();
+            move |_| link.send_reload()
+        });
         Panel::new()
             .class(FlexFit)
             .border(true)
             .style("flex", "3 1 0")
             .min_width(300)
             .title(tr!("Key Pool"))
+            .with_tool(refresh)
             .with_child(
                 SubscriptionKeyGrid::new()
-                    .on_change(Callback::from(move |_| link.send_reload()))
+                    .on_change(Callback::from(move |_| reload.send_reload()))
+                    .on_auto_assign(ctx.link().callback(|_| Msg::AutoAssignPreview))
                     .node_status(statuses)
                     .pool_keys(self.pool_keys.clone())
                     .pool_digest(self.pool_digest.clone()),
@@ -1099,6 +1073,15 @@ impl SubscriptionRegistryComp {
         let can_revert = self.revert_target().is_some();
         let can_clear_key = self.selected_node_for_clear().is_some();
         let can_adopt_key = self.selected_node_for_adopt().is_some();
+        let adopt_all_count = self.adopt_all_candidates().len();
+        let (push_count, clear_count) = self.pending_counts();
+        let pending = push_count + clear_count;
+        // Check Subscription is a no-op on the remote when no key is installed (PVE / PBS
+        // `update_subscription` returns early without contacting the shop), so disable the
+        // button to keep the UI honest about what clicking it will do.
+        let can_check = self
+            .selected_node_status()
+            .is_some_and(|n| n.status != proxmox_subscription::SubscriptionStatus::NotFound);
         let assign_button = Tooltip::new(
             Button::new(tr!("Assign Key"))
                 .icon_class("fa fa-link")
@@ -1136,6 +1119,84 @@ impl SubscriptionRegistryComp {
         .tip(tr!(
             "Import the live subscription on the selected node into the pool."
         ));
+        let check_button = Tooltip::new(
+            Button::new(tr!("Check Subscription"))
+                .icon_class("fa fa-refresh")
+                .disabled(!can_check)
+                .on_activate(
+                    ctx.link()
+                        .callback(|_| Msg::CheckSubscriptionForSelectedNode),
+                ),
+        )
+        .tip(if can_check {
+            tr!("Re-verify the live subscription against the shop, refreshing the status.")
+        } else {
+            tr!("No subscription installed on the selected node; assign or adopt one first.")
+        });
+
+        let adopt_all_button = Tooltip::new(
+            Button::new(tr!("Adopt All"))
+                .icon_class("fa fa-download")
+                .disabled(adopt_all_count == 0)
+                .on_activate(ctx.link().callback(|_| Msg::AdoptAllPreview)),
+        )
+        .tip(tr!(
+            "Import every foreign live subscription that is not yet tracked by the \
+             pool. The remote is not contacted; only the pool config is updated."
+        ));
+        let apply_pending_button = Tooltip::new(
+            Button::new(tr!("Apply Pending"))
+                .icon_class("fa fa-play")
+                .disabled(pending == 0)
+                .on_activate(
+                    ctx.link()
+                        .change_view_callback(|_| Some(ViewState::ConfirmApplyPending)),
+                ),
+        )
+        .tip(tr!(
+            "Push every queued assignment to its remote node and remove the \
+             subscription from nodes pending clear."
+        ));
+        let discard_pending_button = Tooltip::new(
+            Button::new(tr!("Discard Pending"))
+                .icon_class("fa fa-eraser")
+                .disabled(pending == 0)
+                .on_activate(
+                    ctx.link()
+                        .change_view_callback(|_| Some(ViewState::ConfirmClearPending)),
+                ),
+        )
+        .tip(tr!(
+            "Discard queued assignments without touching the remote nodes."
+        ));
+
+        let refresh_button = Button::refresh(self.loading()).on_activate({
+            let link = ctx.link().clone();
+            move |_| link.send_reload()
+        });
+
+        // Left: per-node actions on the selected row, grouped add-key / undo-or-remove / verify.
+        // Right: bulk and queue actions over all nodes. The pending badge is fenced off from the
+        // queue verbs by its own rule so the verb cluster keeps its position when it is absent.
+        let mut toolbar = Toolbar::new()
+            .border_bottom(true)
+            .with_child(assign_button)
+            .with_child(adopt_key_button)
+            .with_spacer()
+            .with_child(revert_button)
+            .with_child(clear_key_button)
+            .with_spacer()
+            .with_child(check_button)
+            .with_flex_spacer();
+        if pending > 0 {
+            toolbar = toolbar
+                .with_child(pending_badge(push_count, clear_count))
+                .with_spacer();
+        }
+        toolbar = toolbar
+            .with_child(adopt_all_button)
+            .with_child(apply_pending_button)
+            .with_child(discard_pending_button);
 
         Panel::new()
             .class(FlexFit)
@@ -1143,11 +1204,13 @@ impl SubscriptionRegistryComp {
             .style("flex", "4 1 0")
             .min_width(400)
             .title(tr!("Node Subscription Status"))
-            .with_tool(assign_button)
-            .with_tool(adopt_key_button)
-            .with_tool(revert_button)
-            .with_tool(clear_key_button)
-            .with_child(table)
+            .with_tool(refresh_button)
+            .with_child(
+                Column::new()
+                    .class(FlexFit)
+                    .with_child(toolbar)
+                    .with_child(table),
+            )
     }
 
     /// Return `(pending pushes, pending clears)` mirroring the server's `compute_pending`
-- 
2.47.3





      parent reply	other threads:[~2026-05-23 22:58 UTC|newest]

Thread overview: 11+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-05-23 22:58 [PATCH datacenter-manager v5 00/10] subscription key pool registry Thomas Lamprecht
2026-05-23 22:58 ` [PATCH datacenter-manager v5 01/10] api types: subscription level: render full names Thomas Lamprecht
2026-05-23 22:58 ` [PATCH datacenter-manager v5 02/10] pdm-client: add wait_for_local_task helper Thomas Lamprecht
2026-05-23 22:58 ` [PATCH datacenter-manager v5 03/10] subscription: pool: add data model and config layer Thomas Lamprecht
2026-05-23 22:58 ` [PATCH datacenter-manager v5 04/10] subscription: api: add key pool and node status endpoints Thomas Lamprecht
2026-05-23 22:58 ` [PATCH datacenter-manager v5 05/10] ui: registry: add view with key pool and node status Thomas Lamprecht
2026-05-23 22:58 ` [PATCH datacenter-manager v5 06/10] cli: client: add subscription key pool management subcommands Thomas Lamprecht
2026-05-23 22:58 ` [PATCH datacenter-manager v5 07/10] docs: add subscription registry chapter Thomas Lamprecht
2026-05-23 22:58 ` [PATCH datacenter-manager v5 08/10] subscription: add Clear Key action and per-node revert Thomas Lamprecht
2026-05-23 22:58 ` [PATCH datacenter-manager v5 09/10] subscription: add Adopt Key action for foreign live subscriptions Thomas Lamprecht
2026-05-23 22:58 ` Thomas Lamprecht [this message]

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=20260523225835.3106077-11-t.lamprecht@proxmox.com \
    --to=t.lamprecht@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