From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id 0F3661FF145 for ; Sun, 24 May 2026 00:58:59 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 04AA21CFD4; Sun, 24 May 2026 00:58:58 +0200 (CEST) From: Thomas Lamprecht 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 Message-ID: <20260523225835.3106077-11-t.lamprecht@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260523225835.3106077-1-t.lamprecht@proxmox.com> References: <20260523225835.3106077-1-t.lamprecht@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1779577100658 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.005 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: BEEMCKBV3FGFCYDQUGWISUY25PXUIP3U X-Message-ID-Hash: BEEMCKBV3FGFCYDQUGWISUY25PXUIP3U X-MailFrom: t.lamprecht@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: 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 --- 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) -> 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, + + /// Epoch of the last successful subscription check on the node. + #[serde(skip_serializing_if = "Option::is_none")] + pub check_time: Option, + + /// Next due date of the subscription, as reported by the remote. + #[serde(skip_serializing_if = "Option::is_none")] + pub next_due_date: Option, } #[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, + /// Next due date of the subscription, as reported by the remote. + #[serde(skip_serializing_if = "Option::is_none")] + pub next_due_date: Option, } #[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 PdmClient { .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 { + let mut lines: Vec = 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) -> Option { - 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, @@ -1066,21 +1034,27 @@ impl LoadableComponent for SubscriptionRegistryComp { impl SubscriptionRegistryComp { fn render_key_pool_panel(&self, ctx: &LoadableComponentContext) -> 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