From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id CC4A11FF14C for ; Fri, 15 May 2026 09:47:35 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 97B09ECAC; Fri, 15 May 2026 09:47:33 +0200 (CEST) From: Thomas Lamprecht To: pdm-devel@lists.proxmox.com Subject: [PATCH datacenter-manager v3 05/12] ui: registry: add view with key pool and node status Date: Fri, 15 May 2026 09:43:15 +0200 Message-ID: <20260515074623.766766-6-t.lamprecht@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260515074623.766766-1-t.lamprecht@proxmox.com> References: <20260515074623.766766-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: 1778831192548 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.997 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 KAM_MAILER 2 Automated Mailer Tag Left in Email 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: IDHCLELCGQVMOOHAYDL7UGRSNAMVMXA7 X-Message-ID-Hash: IDHCLELCGQVMOOHAYDL7UGRSNAMVMXA7 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: Add a top-level Subscription Registry view with a Key Pool panel next to a Node Status tree. The Add dialog takes a textarea so an operator can paste several keys at once. The Assign dialog filters the remote selector by the key's compatible product type; PMG and POM keys leave Assign disabled since PDM cannot push them to a remote yet. Pending assignments show in the Node Status panel with a clock icon; the toolbar carries a counts badge driven by the same predicate the server uses for compute_pending. Selecting a node exposes a Revert action that drops the entry's pending change. Signed-off-by: Thomas Lamprecht --- Changes v2 -> 3: * ESC dismisses every ConfirmDialog on the registry view; the v2 view got stuck in the dialog state on ESC. * Pool grid columns are sortable. * New hidden-by-default Source column for the Adopted entries introduced in v3-0009. * Pending counts use the same predicate the server uses for compute_pending, so client and server stay in sync. * Invalid keys land with a clear error instead of staying queued with a misleading pending badge. * "Clear Pending" button renamed to "Discard Pending"; the action also cancels queued clears once the Clear Key flow lands in v3-0008. * "Clear Assignment" action on the Node Status panel renamed to "Revert"; its sibling "Remove" button there is dropped (Remove Key on the pool grid covers it). ui/Cargo.toml | 2 +- ui/src/configuration/mod.rs | 3 + ui/src/configuration/subscription_assign.rs | 332 ++++++ ui/src/configuration/subscription_keys.rs | 546 +++++++++ ui/src/configuration/subscription_registry.rs | 1019 +++++++++++++++++ ui/src/main_menu.rs | 10 + ui/src/widget/pve_node_selector.rs | 41 +- 7 files changed, 1950 insertions(+), 3 deletions(-) create mode 100644 ui/src/configuration/subscription_assign.rs create mode 100644 ui/src/configuration/subscription_keys.rs create mode 100644 ui/src/configuration/subscription_registry.rs diff --git a/ui/Cargo.toml b/ui/Cargo.toml index 4e1f772f..3d578022 100644 --- a/ui/Cargo.toml +++ b/ui/Cargo.toml @@ -30,7 +30,7 @@ yew-router = { version = "0.18" } pwt = "0.8.0" pwt-macros = "0.5" -proxmox-yew-comp = { version = "0.8.7", features = ["apt", "dns", "network", "rrd"] } +proxmox-yew-comp = { version = "0.8.8", features = ["apt", "dns", "network", "rrd"] } proxmox-access-control = { version = "1.1", features = []} proxmox-acme-api = "1" diff --git a/ui/src/configuration/mod.rs b/ui/src/configuration/mod.rs index 6ffb64be..b3eff105 100644 --- a/ui/src/configuration/mod.rs +++ b/ui/src/configuration/mod.rs @@ -13,7 +13,10 @@ mod permission_path_selector; mod webauthn; pub use webauthn::WebauthnPanel; +pub mod subscription_assign; +pub mod subscription_keys; pub mod subscription_panel; +pub mod subscription_registry; pub mod views; diff --git a/ui/src/configuration/subscription_assign.rs b/ui/src/configuration/subscription_assign.rs new file mode 100644 index 00000000..16936b7f --- /dev/null +++ b/ui/src/configuration/subscription_assign.rs @@ -0,0 +1,332 @@ +//! Node-first Assign Key dialog opened from the Subscription Registry's node tree panel. + +use std::rc::Rc; + +use anyhow::Error; +use serde_json::json; + +use yew::html::IntoEventCallback; +use yew::virtual_dom::{Key, VComp, VNode}; + +use pwt::css::FlexFit; +use pwt::prelude::*; +use pwt::props::{ContainerBuilder, WidgetBuilder}; +use pwt::state::{Selection, Store}; +use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader}; +use pwt::widget::{Button, Column, Container, Dialog, Row}; + +use proxmox_yew_comp::http_post; +use proxmox_yew_comp::percent_encoding::percent_encode_component; + +use pdm_api_types::remotes::RemoteType; +use pdm_api_types::subscription::{ + pick_best_pve_socket_key, socket_count_from_key, SubscriptionKeyEntry, +}; + +const KEYS_URL: &str = "/subscriptions/keys"; + +/// Filter the pool to keys that can land on a `remote_type` node and are not yet bound. +fn candidates_for( + pool_keys: &[SubscriptionKeyEntry], + remote_type: RemoteType, +) -> Vec { + let mut out: Vec = pool_keys + .iter() + .filter(|e| e.remote.is_none() && e.product_type.matches_remote_type(remote_type)) + .cloned() + .collect(); + // PVE: smallest covering socket count first so the default selection is the cheapest fit + // that still works. PBS keys have no socket count, fall back to key string. + out.sort_by(|a, b| { + let sa = socket_count_from_key(&a.key); + let sb = socket_count_from_key(&b.key); + sa.cmp(&sb).then_with(|| a.key.cmp(&b.key)) + }); + out +} + +/// Pick a sensible default key for the dialog. For PVE, the smallest covering socket-count; +/// for PBS, the first candidate. +fn default_candidate( + candidates: &[SubscriptionKeyEntry], + remote_type: RemoteType, + node_sockets: Option, +) -> Option { + if candidates.is_empty() { + return None; + } + if remote_type == RemoteType::Pve { + let needed = node_sockets.unwrap_or(1).max(1) as u32; + if let Some(picked) = pick_best_pve_socket_key( + needed, + candidates.iter().map(|e| (e.key.clone(), e.key.as_str())), + ) { + return Some(picked); + } + } + candidates.first().map(|e| e.key.clone()) +} + +fn key_columns() -> Rc>> { + Rc::new(vec![ + DataTableColumn::new(tr!("Key")) + .flex(2) + .get_property(|e: &SubscriptionKeyEntry| e.key.as_str()) + .into(), + DataTableColumn::new(tr!("Product")) + .width("80px") + .render(|e: &SubscriptionKeyEntry| e.product_type.to_string().into()) + .into(), + DataTableColumn::new(tr!("Level")) + .width("90px") + .render(|e: &SubscriptionKeyEntry| e.level.to_string().into()) + .into(), + DataTableColumn::new(tr!("Sockets")) + .width("70px") + .render(|e: &SubscriptionKeyEntry| { + socket_count_from_key(&e.key) + .map(|s| s.to_string()) + .unwrap_or_default() + .into() + }) + .into(), + ]) +} + +async fn submit_assignment( + key: &str, + remote: &str, + node: &str, + digest: Option<&str>, +) -> Result<(), Error> { + let url = format!( + "/subscriptions/keys/{}/assignment", + percent_encode_component(key), + ); + let mut body = json!({ "remote": remote, "node": node }); + if let Some(d) = digest { + body["digest"] = d.into(); + } + http_post::<()>(&url, Some(body)).await +} + +/// Simple "Assign Key to /" dialog. +#[derive(Properties, Clone, PartialEq)] +pub struct AssignKeyToNodeDialog { + pub remote: String, + pub node: String, + pub ty: RemoteType, + pub node_sockets: Option, + pub pool_keys: Rc>, + + #[prop_or_default] + pub pool_digest: Option, + + #[prop_or_default] + pub on_done: Option>, +} + +impl AssignKeyToNodeDialog { + pub fn new( + remote: impl Into, + node: impl Into, + ty: RemoteType, + node_sockets: Option, + pool_keys: Rc>, + ) -> Self { + Self { + remote: remote.into(), + node: node.into(), + ty, + node_sockets, + pool_keys, + pool_digest: None, + on_done: None, + } + } + + pub fn pool_digest(mut self, digest: Option) -> Self { + self.pool_digest = digest; + self + } + + pub fn on_done(mut self, cb: impl IntoEventCallback<()>) -> Self { + self.on_done = cb.into_event_callback(); + self + } +} + +impl From for VNode { + fn from(val: AssignKeyToNodeDialog) -> Self { + VComp::new::(Rc::new(val), None).into() + } +} + +pub enum AssignMsg { + SelectionChanged, + Submit, + SubmitDone(Result<(), Error>), +} + +pub struct AssignKeyToNodeComp { + store: Store, + columns: Rc>>, + selection: Selection, + last_error: Option, + submitting: bool, +} + +impl yew::Component for AssignKeyToNodeComp { + type Message = AssignMsg; + type Properties = AssignKeyToNodeDialog; + + fn create(ctx: &yew::Context) -> Self { + let props = ctx.props(); + let candidates = candidates_for(&props.pool_keys, props.ty); + let default = default_candidate(&candidates, props.ty, props.node_sockets); + + let store = Store::with_extract_key(|e: &SubscriptionKeyEntry| Key::from(e.key.as_str())); + store.set_data(candidates); + + let selection = Selection::new().on_select({ + let link = ctx.link().clone(); + move |_| link.send_message(AssignMsg::SelectionChanged) + }); + if let Some(key) = default { + selection.select(Key::from(key)); + } + + Self { + store, + columns: key_columns(), + selection, + last_error: None, + submitting: false, + } + } + + fn update(&mut self, ctx: &yew::Context, msg: Self::Message) -> bool { + match msg { + AssignMsg::SelectionChanged => true, + AssignMsg::Submit => { + let Some(picked) = self.selection.selected_key() else { + self.last_error = Some(tr!("Select a key first.")); + return true; + }; + let key = picked.to_string(); + let remote = ctx.props().remote.clone(); + let node = ctx.props().node.clone(); + let digest = ctx.props().pool_digest.clone(); + self.submitting = true; + self.last_error = None; + ctx.link().send_future(async move { + let res = submit_assignment(&key, &remote, &node, digest.as_deref()).await; + AssignMsg::SubmitDone(res) + }); + true + } + AssignMsg::SubmitDone(Ok(())) => { + self.submitting = false; + if let Some(cb) = &ctx.props().on_done { + cb.emit(()); + } + false + } + AssignMsg::SubmitDone(Err(err)) => { + self.submitting = false; + self.last_error = Some(err.to_string()); + true + } + } + } + + fn view(&self, ctx: &yew::Context) -> Html { + let props = ctx.props(); + let no_candidates = self.store.read().len() == 0; + + // The dialog title already carries `{remote}/{node}`; render only the sockets line here + // so the body adds context the title cannot fit. Without sockets there is nothing to add. + let header: Option = props.node_sockets.map(|s| { + Row::new() + .gap(2) + .with_child(Container::new().with_child(tr!("Node sockets:"))) + .with_child(Container::new().with_child(s.to_string())) + .into() + }); + + let body_keys: Html = if no_candidates { + Container::new() + .padding(2) + .with_child(tr!( + "No matching free keys in the pool. Add one via the Key Pool panel first." + )) + .into() + } else { + DataTable::new(self.columns.clone(), self.store.clone()) + .selection(self.selection.clone()) + .striped(true) + .min_height(140) + .class(FlexFit) + .into() + }; + + let mut footer = Row::new() + .padding_top(2) + .gap(2) + .class(pwt::css::JustifyContent::FlexEnd) + .with_flex_spacer() + .with_child(Button::new(tr!("Cancel")).on_activate({ + let cb = props.on_done.clone(); + move |_| { + if let Some(cb) = &cb { + cb.emit(()); + } + } + })) + .with_child( + Button::new(tr!("Assign")) + .disabled(no_candidates || self.submitting) + .on_activate(ctx.link().callback(|_| AssignMsg::Submit)), + ); + + if let Some(err) = &self.last_error { + footer = footer.with_child( + Container::new() + .padding_x(2) + .class(pwt::css::FontColor::Error) + .with_child(err.clone()), + ); + } + + let mut body = Column::new() + .padding(2) + .gap(2) + .min_width(640) + .min_height(0); + if let Some(h) = header { + body = body.with_child(h); + } + let body = body.with_child(body_keys).with_child(footer); + + Dialog::new(tr!( + "Assign Key to {remote}/{node}", + remote = props.remote.clone(), + node = props.node.clone() + )) + .resizable(true) + .min_width(500) + .min_height(300) + .max_height("80vh") + .on_close({ + let cb = props.on_done.clone(); + move |_| { + if let Some(cb) = &cb { + cb.emit(()); + } + } + }) + .with_child(body) + .into() + } +} + diff --git a/ui/src/configuration/subscription_keys.rs b/ui/src/configuration/subscription_keys.rs new file mode 100644 index 00000000..e43543ae --- /dev/null +++ b/ui/src/configuration/subscription_keys.rs @@ -0,0 +1,546 @@ +use std::future::Future; +use std::pin::Pin; +use std::rc::Rc; + +use anyhow::Error; + +use pdm_api_types::remotes::RemoteType; +use pdm_api_types::subscription::{ProductType, RemoteNodeStatus, SubscriptionKeyEntry}; +use yew::virtual_dom::{Key, VComp, VNode}; + +use proxmox_yew_comp::percent_encoding::percent_encode_component; +use proxmox_yew_comp::{http_delete, http_post, EditWindow}; +use proxmox_yew_comp::{ + LoadableComponent, LoadableComponentContext, LoadableComponentMaster, + LoadableComponentScopeExt, LoadableComponentState, +}; + +use pwt::css::FontStyle; +use pwt::prelude::*; +use pwt::state::{Selection, Store}; +use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader}; +use pwt::widget::form::{DisplayField, FormContext, TextArea}; +use pwt::widget::{Button, ConfirmDialog, Container, InputPanel, Toolbar, Tooltip}; + +use crate::widget::{PveNodeSelector, RemoteSelector}; + +const BASE_URL: &str = "/subscriptions/keys"; + +#[derive(Properties, PartialEq, Clone)] +pub struct SubscriptionKeyGrid { + /// Pool keys, owned by the parent registry so both panels see the same snapshot. + #[prop_or_default] + pub pool_keys: Rc>, + + /// Pool-config digest captured by the parent registry on its last `/subscriptions/keys` + /// fetch. Passed through to every mutation so the server can reject (409) a call made + /// against a stale view rather than silently overwriting a parallel admin's edits. + #[prop_or_default] + pub pool_digest: Option, + + /// Called after every successful pool mutation (add, assign, clear, remove). Lets the parent + /// view (the Subscription Registry) reload its own data so the Node Status side stays in + /// sync with the Key Pool side. + #[prop_or_default] + pub on_change: Option>, + + /// Latest live node-status snapshot from the parent view. Used to disable the Clear button + /// when the selected entry's binding is currently synced (the assigned key is the live + /// active key on its remote), since unassigning then would orphan the live subscription. + /// The server enforces the same gate; this prop just turns it into a UI affordance. + #[prop_or_default] + pub node_status: Rc>, +} + +impl SubscriptionKeyGrid { + pub fn new() -> Self { + yew::props!(Self {}) + } + + pub fn on_change(mut self, cb: impl Into>>) -> Self { + self.on_change = cb.into(); + self + } + + pub fn node_status(mut self, statuses: Rc>) -> Self { + self.node_status = statuses; + self + } + + pub fn pool_keys(mut self, keys: Rc>) -> Self { + self.pool_keys = keys; + self + } + + pub fn pool_digest(mut self, digest: Option) -> Self { + self.pool_digest = digest; + self + } +} + +impl Default for SubscriptionKeyGrid { + fn default() -> Self { + Self::new() + } +} + +impl From for VNode { + fn from(val: SubscriptionKeyGrid) -> Self { + VComp::new::>(Rc::new(val), None).into() + } +} + +pub enum Msg { + Remove(Key), + Reload, +} + +#[derive(PartialEq)] +pub enum ViewState { + Add, + Assign, + Remove, +} + +#[doc(hidden)] +pub struct SubscriptionKeyGridComp { + state: LoadableComponentState, + store: Store, + columns: Rc>>, + selection: Selection, +} + +pwt::impl_deref_mut_property!( + SubscriptionKeyGridComp, + state, + LoadableComponentState +); + +impl SubscriptionKeyGridComp { + fn columns() -> Rc>> { + Rc::new(vec![ + DataTableColumn::new(tr!("Key")) + .flex(2) + .get_property(|entry: &SubscriptionKeyEntry| entry.key.as_str()) + .sort_order(true) + .into(), + DataTableColumn::new(tr!("Product")) + .width("80px") + .sorter(|a: &SubscriptionKeyEntry, b: &SubscriptionKeyEntry| { + a.product_type + .to_string() + .cmp(&b.product_type.to_string()) + }) + .render(|entry: &SubscriptionKeyEntry| entry.product_type.to_string().into()) + .into(), + DataTableColumn::new(tr!("Level")) + .width("90px") + .sorter(|a: &SubscriptionKeyEntry, b: &SubscriptionKeyEntry| a.level.cmp(&b.level)) + .render(|entry: &SubscriptionKeyEntry| entry.level.to_string().into()) + .into(), + DataTableColumn::new(tr!("Assignment")) + .flex(2) + .sorter(|a: &SubscriptionKeyEntry, b: &SubscriptionKeyEntry| { + (&a.remote, &a.node).cmp(&(&b.remote, &b.node)) + }) + .render( + |entry: &SubscriptionKeyEntry| match (&entry.remote, &entry.node) { + (Some(remote), Some(node)) => format!("{remote} / {node}").into(), + _ => Html::default(), + }, + ) + .into(), + ]) + } + + fn selected_entry(&self) -> Option { + let key = self.selection.selected_key()?; + self.store.read().lookup_record(&key).cloned() + } + + fn create_add_dialog(&self, ctx: &LoadableComponentContext) -> Html { + let digest = ctx.props().pool_digest.clone(); + EditWindow::new(tr!("Add Subscription Keys")) + .renderer(|_form_ctx| add_input_panel()) + .on_submit(move |form| submit_add_keys(form, digest.clone())) + .on_done(ctx.link().clone().callback(|_| Msg::Reload)) + .into() + } + + fn create_assign_dialog( + &self, + entry: &SubscriptionKeyEntry, + ctx: &LoadableComponentContext, + ) -> Html { + let key = entry.key.clone(); + let product_type = entry.product_type; + let node_status = ctx.props().node_status.clone(); + let digest = ctx.props().pool_digest.clone(); + EditWindow::new(tr!("Assign Key to Remote")) + .renderer({ + let key = key.clone(); + move |form_ctx| assign_input_panel(&key, product_type, form_ctx, &node_status) + }) + .on_submit({ + let key = key.clone(); + move |form| submit_assign(key.clone(), form, digest.clone()) + }) + .on_done(ctx.link().clone().callback(|_| Msg::Reload)) + .into() + } +} + +impl LoadableComponent for SubscriptionKeyGridComp { + type Properties = SubscriptionKeyGrid; + type Message = Msg; + type ViewState = ViewState; + + fn create(ctx: &LoadableComponentContext) -> Self { + let selection = Selection::new().on_select({ + let link = ctx.link().clone(); + move |_| link.send_redraw() + }); + let store = Store::with_extract_key(|entry: &SubscriptionKeyEntry| { + entry.key.as_str().into() + }); + store.set_data((*ctx.props().pool_keys).clone()); + Self { + state: LoadableComponentState::new(), + store, + columns: Self::columns(), + selection, + } + } + + fn update(&mut self, ctx: &LoadableComponentContext, msg: Self::Message) -> bool { + match msg { + Msg::Remove(key) => { + let id = key.to_string(); + let link = ctx.link().clone(); + let digest = ctx.props().pool_digest.clone(); + ctx.link().spawn(async move { + let url = format!("{BASE_URL}/{}", percent_encode_component(&id)); + let query = digest.map(|d| serde_json::json!({ "digest": d })); + if let Err(err) = http_delete(&url, query).await { + link.show_error( + tr!("Error"), + tr!("Could not remove {id}: {err}", id = id, err = err), + true, + ); + } + link.send_message(Msg::Reload); + }); + } + Msg::Reload => { + ctx.link().change_view(None); + if let Some(cb) = &ctx.props().on_change { + cb.emit(()); + } + } + } + true + } + + fn toolbar(&self, ctx: &LoadableComponentContext) -> Option { + let entry = self.selected_entry(); + let has_selection = entry.is_some(); + let is_assigned = entry.as_ref().map(|e| e.remote.is_some()).unwrap_or(false); + let synced_assignment = entry + .as_ref() + .map(|e| is_synced_assignment(e, &ctx.props().node_status)) + .unwrap_or(false); + let assignable = entry + .as_ref() + .map(|e| { + e.product_type.matches_remote_type(RemoteType::Pve) + || e.product_type.matches_remote_type(RemoteType::Pbs) + }) + .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." + )), + ) + .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." + )), + ) + .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." + )), + ) + .into(), + ) + } + + fn changed( + &mut self, + ctx: &LoadableComponentContext, + old_props: &Self::Properties, + ) -> bool { + if !Rc::ptr_eq(&old_props.pool_keys, &ctx.props().pool_keys) { + self.store.set_data((*ctx.props().pool_keys).clone()); + } + true + } + + fn load( + &self, + _ctx: &LoadableComponentContext, + ) -> Pin>>> { + // Pool data flows in via the `pool_keys` prop owned by the parent registry; the grid + // does not fetch on its own. Resolve immediately so the LoadableComponent harness does + // not show its mask. + Box::pin(async { Ok(()) }) + } + + fn main_view(&self, _ctx: &LoadableComponentContext) -> Html { + DataTable::new(self.columns.clone(), self.store.clone()) + .selection(self.selection.clone()) + .into() + } + + fn dialog_view( + &self, + ctx: &LoadableComponentContext, + view_state: &Self::ViewState, + ) -> Option { + match view_state { + ViewState::Add => Some(self.create_add_dialog(ctx)), + ViewState::Assign => self + .selected_entry() + .map(|entry| self.create_assign_dialog(&entry, ctx)), + ViewState::Remove => self.selection.selected_key().map(|key| { + let assignment = self.selected_entry().and_then(|e| { + Some((e.remote.clone()?, e.node.clone()?)) + }); + let body = match assignment { + Some((remote, node)) => tr!( + "Remove {key} from the key pool? It is still assigned to {remote}/{node}; the assignment is released without removing the subscription on the remote.", + key = key.to_string(), + remote = remote, + node = node, + ), + None => tr!( + "Remove {key} from the key pool? This does not revoke the subscription.", + key = key.to_string(), + ), + }; + ConfirmDialog::new(tr!("Remove Key"), body) + .on_confirm({ + let link = ctx.link().clone(); + let key = key.clone(); + move |_| link.send_message(Msg::Remove(key.clone())) + }) + .on_close({ + let link = ctx.link().clone(); + move |_| link.change_view(None) + }) + .into() + }), + } + } +} + +/// Returns true when the pool entry's binding currently runs the same key on the remote and is +/// Active - meaning a clear-assignment would orphan the live subscription. Mirrors the +/// server-side gate; the operator must release the live subscription on the remote first. +fn is_synced_assignment(entry: &SubscriptionKeyEntry, statuses: &[RemoteNodeStatus]) -> bool { + let (Some(remote), Some(node)) = (entry.remote.as_deref(), entry.node.as_deref()) else { + return false; + }; + statuses + .iter() + .find(|n| n.remote == remote && n.node == node) + .map(|n| { + n.status == proxmox_subscription::SubscriptionStatus::Active + && n.current_key.as_deref() == Some(entry.key.as_str()) + }) + .unwrap_or(false) +} + +fn add_input_panel() -> Html { + let hint = Container::new() + .class(FontStyle::TitleSmall) + .class(pwt::css::Opacity::Quarter) + .padding_top(2) + .with_child(tr!( + "One key per line, or comma-separated. Only Proxmox VE and Proxmox Backup Server keys are accepted." + )); + + // The textarea opts into `width: 100%` so it fills the InputPanel's grid cell instead of + // shrinking to browser-default cols. + InputPanel::new() + .padding(4) + .min_width(500) + .with_large_custom_child( + TextArea::new() + .name("keys") + .submit_empty(false) + .required(true) + .attribute("rows", "8") + .attribute("placeholder", tr!("Subscription key(s)")) + .style("width", "100%") + .style("box-sizing", "border-box"), + ) + .with_large_custom_child(hint) + .into() +} + +async fn submit_add_keys(form_ctx: FormContext, digest: Option) -> Result<(), Error> { + let raw = form_ctx.read().get_field_text("keys"); + let keys: Vec = raw + .split(|c: char| c.is_whitespace() || c == ',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(); + + if keys.is_empty() { + anyhow::bail!(tr!("no keys provided")); + } + + let mut body = serde_json::json!({ "keys": keys }); + if let Some(d) = digest { + body["digest"] = d.into(); + } + http_post(BASE_URL, Some(body)).await +} + +/// Map a subscription product type to the remote type its keys can drive. +fn remote_type_for(product_type: ProductType) -> Option { + if product_type.matches_remote_type(RemoteType::Pve) { + Some(RemoteType::Pve) + } else if product_type.matches_remote_type(RemoteType::Pbs) { + Some(RemoteType::Pbs) + } else { + None + } +} + +fn assign_input_panel( + key: &str, + product_type: ProductType, + form_ctx: &FormContext, + node_status: &[RemoteNodeStatus], +) -> Html { + let mut panel = InputPanel::new().padding(4).min_width(500).with_field( + tr!("Key"), + DisplayField::new() + .name("key") + .value(key.to_string()) + .key("key-display"), + ); + + let Some(remote_type) = remote_type_for(product_type) else { + // Defensive: the toolbar disables Assign for these product types. + return panel + .with_large_custom_child( + Container::new() + .class(FontStyle::TitleSmall) + .class(pwt::css::Opacity::Quarter) + .with_child(tr!( + "PDM cannot manage {product} remotes yet; this key is parked in the pool.", + product = product_type.to_string(), + )), + ) + .into(); + }; + + panel = panel.with_field( + tr!("Remote"), + RemoteSelector::new() + .name("remote") + .remote_type(remote_type) + .required(true), + ); + + match remote_type { + RemoteType::Pve => { + let selected_remote = form_ctx.read().get_field_text("remote"); + if selected_remote.is_empty() { + panel + .with_field( + tr!("Node"), + DisplayField::new() + .name("node") + .key("node-no-remote") + .value(AttrValue::from(tr!("Select a remote first."))), + ) + .into() + } else { + let excluded: Vec = node_status + .iter() + .filter(|n| n.remote == selected_remote && n.assigned_key.is_some()) + .map(|n| n.node.clone()) + .collect(); + // `PveNodeSelector` fetches its node list in `create` and does not re-fetch on + // prop change, so a per-remote `key` forces a fresh component when the operator + // picks a target. + panel + .with_field( + tr!("Node"), + PveNodeSelector::new(selected_remote.clone()) + .name("node") + .key(format!("node-selector-{selected_remote}")) + .excluded_nodes(Rc::new(excluded)) + .required(true), + ) + .into() + } + } + RemoteType::Pbs => panel + .with_field( + tr!("Node"), + DisplayField::new() + .name("node") + .value(AttrValue::from("localhost")) + .key("node-localhost"), + ) + .into(), + } +} + +async fn submit_assign( + key: String, + form_ctx: FormContext, + digest: Option, +) -> Result<(), Error> { + let mut data = form_ctx.get_submit_data(); + if let Some(d) = digest { + if let Some(obj) = data.as_object_mut() { + obj.insert("digest".to_string(), d.into()); + } + } + let url = format!("{BASE_URL}/{}/assignment", percent_encode_component(&key)); + http_post(&url, Some(data)).await +} diff --git a/ui/src/configuration/subscription_registry.rs b/ui/src/configuration/subscription_registry.rs new file mode 100644 index 00000000..513fa3a0 --- /dev/null +++ b/ui/src/configuration/subscription_registry.rs @@ -0,0 +1,1019 @@ +use std::future::Future; +use std::pin::Pin; +use std::rc::Rc; + +use anyhow::Error; + +use yew::virtual_dom::{Key, VComp, VNode}; + +use proxmox_yew_comp::percent_encoding::percent_encode_component; +use proxmox_yew_comp::{http_delete, http_get, http_get_full, http_post}; +use proxmox_yew_comp::{ + LoadableComponent, LoadableComponentContext, LoadableComponentMaster, + LoadableComponentScopeExt, LoadableComponentState, +}; + +use pwt::css::{AlignItems, Flex, FlexDirection, FlexFit, FontColor, JustifyContent, Overflow}; +use pwt::prelude::*; +use pwt::props::{ContainerBuilder, ExtractPrimaryKey, WidgetBuilder}; +use pwt::state::{Selection, SlabTree, Store, TreeStore}; +use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader}; +use pwt::widget::{Button, Column, Container, Fa, Panel, Row, Toolbar, Tooltip}; + +use pdm_api_types::subscription::{ + AutoAssignProposal, ProposedAssignment, RemoteNodeStatus, SubscriptionKeyEntry, + SubscriptionLevel, +}; + +use super::subscription_keys::SubscriptionKeyGrid; + +const NODE_STATUS_URL: &str = "/subscriptions/node-status"; +const KEYS_URL: &str = "/subscriptions/keys"; +const AUTO_ASSIGN_URL: &str = "/subscriptions/auto-assign"; +const BULK_ASSIGN_URL: &str = "/subscriptions/bulk-assign"; +const APPLY_PENDING_URL: &str = "/subscriptions/apply-pending"; +const CLEAR_PENDING_URL: &str = "/subscriptions/clear-pending"; + +/// Map a [`SubscriptionStatus`] to the icon shown in subscription panels. +/// +/// Public so the dashboard subscriptions panel can render the same icon for the same state +/// without redefining the mapping. The 4-variant `proxmox_yew_comp::Status` does not cover +/// every subscription state (New, Expired, Suspended need their own icons), hence the dedicated +/// helper. +pub fn subscription_status_icon(status: proxmox_subscription::SubscriptionStatus) -> Fa { + use proxmox_subscription::SubscriptionStatus as S; + match status { + S::Active => Fa::new("check-circle").class(FontColor::Success), + S::New => Fa::new("clock-o").class(FontColor::Primary), + S::NotFound => Fa::new("exclamation-circle").class(FontColor::Error), + S::Invalid => Fa::new("times-circle").class(FontColor::Warning), + S::Expired => Fa::new("clock-o").class(FontColor::Warning), + S::Suspended => Fa::new("ban").class(FontColor::Error), + } +} + +fn subscription_status_label(status: proxmox_subscription::SubscriptionStatus) -> String { + use proxmox_subscription::SubscriptionStatus as S; + match status { + S::Active => tr!("Active"), + S::New => tr!("New"), + S::NotFound => tr!("No subscription"), + S::Invalid => tr!("Invalid"), + S::Expired => tr!("Expired"), + S::Suspended => tr!("Suspended"), + } +} + +fn pending_badge(push_count: u32, clear_count: u32) -> Row { + let mut row = Row::new().class(AlignItems::Center).gap(3); + if push_count > 0 { + row = row.with_child( + Tooltip::new( + Row::new() + .class(AlignItems::Baseline) + .gap(1) + .with_child(Fa::new("clock-o").class(FontColor::Warning)) + .with_child(tr!("{n} pending push(es)", n = push_count)), + ) + .tip(tr!( + "{n} pool key(s) queued for push; Apply Pending will install them on the remote.", + n = push_count, + )), + ); + } + if clear_count > 0 { + row = row.with_child( + Tooltip::new( + Row::new() + .class(AlignItems::Baseline) + .gap(1) + .with_child(Fa::new("recycle").class(FontColor::Warning)) + .with_child(tr!("{n} pending clear(s)", n = clear_count)), + ) + .tip(tr!( + "{n} live subscription(s) queued for removal; Apply Pending will free them.", + n = clear_count, + )), + ); + } + row +} + +#[derive(Clone, Debug, PartialEq)] +enum NodeTreeEntry { + Root, + Remote { + name: String, + ty: pdm_api_types::remotes::RemoteType, + active: u32, + total: u32, + }, + Node { + data: RemoteNodeStatus, + /// If true, this is the only node in its remote and is shown at the top level under the + /// remote name instead of nested. + standalone: bool, + }, +} + +impl NodeTreeEntry { + fn name(&self) -> &str { + match self { + Self::Root => "", + Self::Remote { name, .. } => name, + Self::Node { data, standalone } => { + if *standalone { + &data.remote + } else { + &data.node + } + } + } + } +} + +impl ExtractPrimaryKey for NodeTreeEntry { + fn extract_key(&self) -> Key { + Key::from(match self { + NodeTreeEntry::Root => "/".to_string(), + NodeTreeEntry::Remote { name, .. } => format!("/{name}"), + NodeTreeEntry::Node { data, .. } => format!("/{}/{}", data.remote, data.node), + }) + } +} + +fn build_tree(nodes: Vec) -> SlabTree { + use std::collections::BTreeMap; + + let mut by_remote: BTreeMap> = BTreeMap::new(); + for n in nodes { + by_remote.entry(n.remote.clone()).or_default().push(n); + } + + let mut tree = SlabTree::new(); + let mut root = tree.set_root(NodeTreeEntry::Root); + root.set_expanded(true); + + for (remote_name, remote_nodes) in &by_remote { + let total = remote_nodes.len() as u32; + let active = remote_nodes + .iter() + .filter(|n| n.status == proxmox_subscription::SubscriptionStatus::Active) + .count() as u32; + + let ty = remote_nodes.first().map(|n| n.ty).unwrap_or_default(); + + if remote_nodes.len() == 1 { + root.append(NodeTreeEntry::Node { + data: remote_nodes[0].clone(), + standalone: true, + }); + } else { + let mut remote_entry = root.append(NodeTreeEntry::Remote { + name: remote_name.clone(), + ty, + active, + total, + }); + remote_entry.set_expanded(true); + for n in remote_nodes { + remote_entry.append(NodeTreeEntry::Node { + data: n.clone(), + standalone: false, + }); + } + } + } + + tree +} + +#[derive(Properties, PartialEq, Clone)] +pub struct SubscriptionRegistryProps {} + +impl SubscriptionRegistryProps { + pub fn new() -> Self { + yew::props!(Self {}) + } +} + +impl Default for SubscriptionRegistryProps { + fn default() -> Self { + Self::new() + } +} + +impl From for VNode { + fn from(val: SubscriptionRegistryProps) -> Self { + VComp::new::>(Rc::new(val), None).into() + } +} + +pub enum Msg { + LoadFinished { + nodes: Vec, + keys: Vec, + digest: Option, + }, + AutoAssignPreview, + /// Commit a previously-fetched proposal via the bulk-assign endpoint. + BulkAssignApply(AutoAssignProposal), + ApplyPending, + ClearPending, + /// Revert the pending change on the currently-selected node: drop the unpushed pool + /// assignment without touching the remote. + RevertSelectedNode, + /// Open the Assign Key dialog for the currently-selected node. + AssignKeyToSelectedNode, +} + +#[derive(PartialEq)] +pub enum ViewState { + ConfirmAutoAssign(AutoAssignProposal), + ConfirmApplyPending, + ConfirmClearPending, + /// Assign a pool key to the given node. Opens from the right panel's Assign Key button. + AssignKeyToNode { + remote: String, + node: String, + ty: pdm_api_types::remotes::RemoteType, + node_sockets: Option, + }, +} + +#[doc(hidden)] +pub struct SubscriptionRegistryComp { + state: LoadableComponentState, + tree_store: TreeStore, + tree_columns: Rc>>, + proposal_columns: Rc>>, + node_selection: Selection, + last_node_data: Vec, + /// Canonical pool snapshot. Passed down to the key grid (display) and shared with the + /// node-first Assign dialog and the Add+Assign wizard (selector source-of-truth). + pool_keys: Rc>, + /// Pool-config digest captured alongside `pool_keys`. Forwarded to every pool mutation so + /// the server rejects stale-view writes with 409 instead of silently overwriting a parallel + /// admin's edits. + pool_digest: Option, +} + +pwt::impl_deref_mut_property!( + SubscriptionRegistryComp, + state, + LoadableComponentState +); + +fn tree_sorter(a: &NodeTreeEntry, b: &NodeTreeEntry) -> std::cmp::Ordering { + a.name().cmp(b.name()) +} + +/// Sort helper that compares two Node entries on a derived key and falls back to name comparison +/// for any Root/Remote variant; tree columns surface this so parent rows do not reshuffle when +/// sorting by a Node-only attribute. +fn node_field_sorter( + a: &NodeTreeEntry, + b: &NodeTreeEntry, + f: impl Fn(&RemoteNodeStatus) -> K, +) -> std::cmp::Ordering { + match (a, b) { + (NodeTreeEntry::Node { data: na, .. }, NodeTreeEntry::Node { data: nb, .. }) => { + f(na).cmp(&f(nb)) + } + _ => a.name().cmp(b.name()), + } +} + +impl SubscriptionRegistryComp { + fn tree_columns(store: TreeStore) -> Rc>> { + Rc::new(vec![ + DataTableColumn::new(tr!("Name")) + .tree_column(store) + .flex(3) + .render(|entry: &NodeTreeEntry| { + let (icon, name) = match entry { + NodeTreeEntry::Root => return Html::default(), + NodeTreeEntry::Remote { name, ty, .. } => { + let icon = if *ty == pdm_api_types::remotes::RemoteType::Pbs { + "building-o" + } else { + "server" + }; + (icon, name.as_str()) + } + NodeTreeEntry::Node { + data: n, + standalone, + } => { + let icon = if n.ty == pdm_api_types::remotes::RemoteType::Pbs { + "building-o" + } else { + "building" + }; + let label = if *standalone { &n.remote } else { &n.node }; + (icon, label.as_str()) + } + }; + Row::new() + .class(AlignItems::Baseline) + .gap(2) + .with_child(Fa::new(icon)) + .with_child(name) + .into() + }) + .sorter(tree_sorter) + .into(), + DataTableColumn::new(tr!("Sockets")) + .width("70px") + .sorter(|a: &NodeTreeEntry, b: &NodeTreeEntry| { + node_field_sorter(a, b, |n| n.sockets) + }) + .render(|entry: &NodeTreeEntry| match entry { + NodeTreeEntry::Node { data: n, .. } => { + n.sockets.map(|s| s.to_string()).unwrap_or_default().into() + } + _ => Html::default(), + }) + .into(), + DataTableColumn::new(tr!("Status")) + .width("150px") + .sorter(|a: &NodeTreeEntry, b: &NodeTreeEntry| { + 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::Remote { active, total, .. } => { + let icon = if active == total { + Fa::new("check-circle").class(FontColor::Success) + } else if *active == 0 { + Fa::new("exclamation-circle").class(FontColor::Error) + } else { + Fa::new("exclamation-triangle").class(FontColor::Warning) + }; + Tooltip::new( + Row::new() + .class(AlignItems::Baseline) + .gap(2) + .with_child(icon) + .with_child(format!("{active}/{total}")), + ) + .tip(tr!( + "{active} of {total} nodes subscribed", + active = active, + total = total, + )) + .into() + } + _ => Html::default(), + }) + .into(), + DataTableColumn::new(tr!("Level")) + .width("90px") + .sorter(|a: &NodeTreeEntry, b: &NodeTreeEntry| { + node_field_sorter(a, b, |n| n.level) + }) + .render(|entry: &NodeTreeEntry| match entry { + NodeTreeEntry::Node { data: n, .. } if n.level != SubscriptionLevel::None => { + n.level.to_string().into() + } + _ => Html::default(), + }) + .into(), + DataTableColumn::new(tr!("Key")) + .flex(2) + .sorter(|a: &NodeTreeEntry, b: &NodeTreeEntry| { + node_field_sorter(a, b, |n| { + n.assigned_key + .clone() + .or_else(|| n.current_key.clone()) + .unwrap_or_default() + }) + }) + .render(|entry: &NodeTreeEntry| match entry { + NodeTreeEntry::Node { data: n, .. } => key_cell(n), + _ => Html::default(), + }) + .into(), + ]) + } + + fn proposal_columns() -> Rc>> { + Rc::new(vec![ + DataTableColumn::new(tr!("Remote / Node")) + .flex(2) + .render(|p: &ProposedAssignment| format!("{} / {}", p.remote, p.node).into()) + .into(), + DataTableColumn::new(tr!("Key")) + .flex(2) + .render(|p: &ProposedAssignment| { + Container::from_tag("span") + .class(pwt::css::FontStyle::LabelMedium) + .with_child(p.key.clone()) + .into() + }) + .into(), + DataTableColumn::new(tr!("Sockets (node / key)")) + .width("160px") + .render(|p: &ProposedAssignment| { + let label = match (p.node_sockets, p.key_sockets) { + (Some(ns), Some(ks)) => format!("{ns} / {ks}"), + (Some(ns), None) => format!("{ns} / -"), + (None, Some(ks)) => format!("- / {ks}"), + _ => String::new(), + }; + label.into() + }) + .into(), + ]) + } +} + +fn key_cell(n: &RemoteNodeStatus) -> Html { + let assigned = n.assigned_key.as_deref(); + let current = n.current_key.as_deref(); + + if n.pending_clear { + // Clear queued: surface the live key the operator is about to free, with a recycle + // icon in the warning colour so the row stands out next to ordinary pending pushes. + let text = current.or(assigned).unwrap_or(""); + return Tooltip::new( + Row::new() + .class(AlignItems::Baseline) + .gap(2) + .with_child(Fa::new("recycle").class(FontColor::Warning)) + .with_child(text), + ) + .tip(tr!( + "Pending Clear - 'Apply Pending' will remove this subscription from the node." + )) + .into(); + } + + // Pending push = pool has a key assigned that the live state has not yet picked up. Drive + // this off the keys themselves, not off the subscription status: a key that is on the node + // but reports `Invalid`/`Expired`/etc. is *applied* (the push went through), just unhealthy. + // The Status column surfaces the health axis; the clock icon here is reserved for the + // "queued operation has not landed yet" axis. + let pending = assigned.is_some() && current != assigned; + + match (assigned, current) { + (Some(a), Some(c)) if a != c => Row::new() + .class(AlignItems::Baseline) + .gap(2) + .with_child(Fa::new("clock-o").class(FontColor::Warning)) + .with_child(format!("{a} \u{2192} {c}")) + .into(), + _ => { + let text = current.or(assigned).unwrap_or(""); + if pending { + Row::new() + .class(AlignItems::Baseline) + .gap(2) + .with_child(Fa::new("clock-o").class(FontColor::Warning)) + .with_child(text) + .into() + } else { + text.into() + } + } + } +} + +impl LoadableComponent for SubscriptionRegistryComp { + type Properties = SubscriptionRegistryProps; + type Message = Msg; + type ViewState = ViewState; + + fn create(ctx: &LoadableComponentContext) -> Self { + let store = TreeStore::new().view_root(false); + store.set_sorter(tree_sorter); + + let node_selection = Selection::new().on_select({ + let link = ctx.link().clone(); + move |_| link.send_redraw() + }); + + Self { + state: LoadableComponentState::new(), + tree_store: store.clone(), + tree_columns: Self::tree_columns(store), + proposal_columns: Self::proposal_columns(), + node_selection, + last_node_data: Vec::new(), + pool_keys: Rc::new(Vec::new()), + pool_digest: None, + } + } + + fn update(&mut self, ctx: &LoadableComponentContext, msg: Self::Message) -> bool { + match msg { + Msg::LoadFinished { + nodes, + keys, + digest, + } => { + self.last_node_data = nodes.clone(); + let tree = build_tree(nodes); + self.tree_store.write().update_root_tree(tree); + self.pool_keys = Rc::new(keys); + self.pool_digest = digest; + } + Msg::AutoAssignPreview => { + let link = ctx.link().clone(); + ctx.link().spawn(async move { + match http_post::(AUTO_ASSIGN_URL, None).await { + Ok(proposal) if proposal.assignments.is_empty() => { + link.show_error( + tr!("Auto-Assign"), + tr!("No suitable unassigned keys for the remaining nodes."), + false, + ); + } + Ok(proposal) => { + link.change_view(Some(ViewState::ConfirmAutoAssign(proposal))); + } + Err(err) => link.show_error(tr!("Auto-Assign"), err.to_string(), true), + } + }); + } + Msg::BulkAssignApply(proposal) => { + let link = ctx.link().clone(); + ctx.link().spawn(async move { + let body = serde_json::json!({ "proposal": proposal }); + match http_post::>(BULK_ASSIGN_URL, Some(body)).await { + Ok(_) => { + link.change_view(None); + link.send_reload(); + } + Err(err) => link.show_error(tr!("Auto-Assign"), err.to_string(), true), + } + }); + } + Msg::ApplyPending => { + let link = ctx.link().clone(); + let body = self + .pool_digest + .clone() + .map(|d| serde_json::json!({ "digest": d })); + ctx.link().spawn(async move { + match http_post::>(APPLY_PENDING_URL, body).await { + // Button gated on pending != 0; None only fires on a clearing race. + Ok(None) => link.change_view(None), + Ok(Some(upid)) => { + link.change_view(None); + link.show_task_progres(upid); + } + Err(err) => link.show_error(tr!("Apply Pending"), err.to_string(), true), + } + link.send_reload(); + }); + } + Msg::ClearPending => { + let link = ctx.link().clone(); + let body = self + .pool_digest + .clone() + .map(|d| serde_json::json!({ "digest": d })); + ctx.link().spawn(async move { + match http_post::(CLEAR_PENDING_URL, body).await { + Ok(_) => { + link.change_view(None); + link.send_reload(); + } + Err(err) => link.show_error(tr!("Discard Pending"), err.to_string(), true), + } + }); + } + Msg::RevertSelectedNode => { + let Some(key) = self.clear_assignment_target_key() else { + return false; + }; + let link = ctx.link().clone(); + let digest = self.pool_digest.clone(); + ctx.link().spawn(async move { + let url = format!( + "/subscriptions/keys/{}/assignment", + percent_encode_component(&key), + ); + let query = digest.map(|d| serde_json::json!({ "digest": d })); + if let Err(err) = http_delete(&url, query).await { + link.show_error(tr!("Revert"), err.to_string(), true); + } + link.send_reload(); + }); + } + Msg::AssignKeyToSelectedNode => { + let Some((remote, node, ty, node_sockets)) = + self.assign_target_for_selected_node() + else { + return false; + }; + ctx.link().change_view(Some(ViewState::AssignKeyToNode { + remote, + node, + ty, + node_sockets, + })); + } + } + true + } + + fn toolbar(&self, ctx: &LoadableComponentContext) -> Option { + let link = ctx.link(); + let (push_count, clear_count) = self.pending_counts(); + 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_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, + ) -> Pin>>> { + let link = ctx.link().clone(); + Box::pin(async move { + // Both panels share one snapshot. Fetching in parallel keeps the latency one + // round-trip; serial would compound on slow remotes. Use `http_get_full` for the + // pool fetch so the digest comes back alongside the entries - every mutation later + // round-trips that digest so a stale view fails with 409 instead of overwriting a + // parallel admin's edit. + let nodes_fut = http_get::>(NODE_STATUS_URL, None); + let keys_fut = http_get_full::>(KEYS_URL, None); + let (nodes, keys) = futures::future::join(nodes_fut, keys_fut).await; + let keys = keys?; + let digest = keys + .attribs + .get("digest") + .and_then(|v| v.as_str()) + .map(str::to_string); + link.send_message(Msg::LoadFinished { + nodes: nodes?, + keys: keys.data, + digest, + }); + Ok(()) + }) + } + + fn main_view(&self, ctx: &LoadableComponentContext) -> Html { + Container::new() + .class("pwt-content-spacer") + .class(FlexFit) + .class(FlexDirection::Row) + .with_child(self.render_key_pool_panel(ctx)) + .with_child(self.render_node_tree_panel(ctx)) + .into() + } + + fn dialog_view( + &self, + ctx: &LoadableComponentContext, + view_state: &Self::ViewState, + ) -> Option { + match view_state { + ViewState::ConfirmApplyPending => { + use pwt::widget::ConfirmDialog; + let (push_count, clear_count) = self.pending_counts(); + let body = match (push_count, clear_count) { + (p, 0) => tr!( + "Push {n} queued assignment(s) to the remote nodes?", + n = p, + ), + (0, c) => tr!( + "Remove {n} live subscription(s) from the remote nodes?", + n = c, + ), + (p, c) => tr!( + "Push {p} queued assignment(s) and remove {c} live subscription(s) on the remote nodes?", + p = p, + c = c, + ), + }; + Some( + ConfirmDialog::new(tr!("Apply Pending Changes"), body) + .icon_class("fa fa-question-circle") + .on_confirm({ + let link = ctx.link().clone(); + move |_| link.send_message(Msg::ApplyPending) + }) + // ESC / X / No must reset the LoadableComponent's view_state too, or + // the dialog closes visually while the parent keeps thinking we are + // still on the confirm view - subsequent clicks land on a stale state. + .on_close({ + let link = ctx.link().clone(); + move |_| link.change_view(None) + }) + .into(), + ) + } + ViewState::ConfirmClearPending => { + use pwt::widget::ConfirmDialog; + Some( + ConfirmDialog::new( + tr!("Discard Pending Changes"), + tr!("Discard all assignments that have not yet been applied to the remote nodes?"), + ) + .icon_class("fa fa-question-circle") + .on_confirm({ + let link = ctx.link().clone(); + move |_| link.send_message(Msg::ClearPending) + }) + .on_close({ + let link = ctx.link().clone(); + move |_| link.change_view(None) + }) + .into(), + ) + } + ViewState::ConfirmAutoAssign(proposal) => { + Some(self.render_auto_assign_dialog(ctx, proposal)) + } + ViewState::AssignKeyToNode { + remote, + node, + ty, + node_sockets, + } => { + use super::subscription_assign::AssignKeyToNodeDialog; + let close_link = ctx.link().clone(); + Some( + AssignKeyToNodeDialog::new( + remote.clone(), + node.clone(), + *ty, + *node_sockets, + self.pool_keys.clone(), + ) + .pool_digest(self.pool_digest.clone()) + .on_done(Callback::from(move |_| { + close_link.change_view(None); + close_link.send_reload(); + })) + .into(), + ) + } + } + } +} + +impl SubscriptionRegistryComp { + fn render_key_pool_panel(&self, ctx: &LoadableComponentContext) -> Panel { + // 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()); + Panel::new() + .class(FlexFit) + .border(true) + .style("flex", "3 1 0") + .min_width(300) + .title(tr!("Key Pool")) + .with_child( + SubscriptionKeyGrid::new() + .on_change(Callback::from(move |_| link.send_reload())) + .node_status(statuses) + .pool_keys(self.pool_keys.clone()) + .pool_digest(self.pool_digest.clone()), + ) + } + + fn render_node_tree_panel(&self, ctx: &LoadableComponentContext) -> Panel { + let table = DataTable::new(self.tree_columns.clone(), self.tree_store.clone()) + .selection(self.node_selection.clone()) + .striped(false) + .borderless(true) + .show_header(true) + .class(FlexFit); + + let can_assign_key = self.assign_target_for_selected_node().is_some(); + let can_revert = self.clear_assignment_target_key().is_some(); + let assign_button = Tooltip::new( + Button::new(tr!("Assign Key")) + .icon_class("fa fa-link") + .disabled(!can_assign_key) + .on_activate(ctx.link().callback(|_| Msg::AssignKeyToSelectedNode)), + ) + .tip(tr!( + "Bind a pool key to the selected node. Available for nodes without an active \ + subscription that have no pool assignment yet." + )); + let revert_button = Tooltip::new( + Button::new(tr!("Revert")) + .icon_class("fa fa-undo") + .disabled(!can_revert) + .on_activate(ctx.link().callback(|_| Msg::RevertSelectedNode)), + ) + .tip(tr!( + "Revert the pending change on the selected node: drop an unpushed pool \ + assignment without touching the remote." + )); + + Panel::new() + .class(FlexFit) + .border(true) + .style("flex", "4 1 0") + .min_width(400) + .title(tr!("Node Subscription Status")) + .with_tool(assign_button) + .with_tool(revert_button) + .with_child(table) + } + + /// Return `(pending pushes, pending clears)` mirroring the server's `compute_pending` + /// predicate. Iterates the pool (not the node-status list) so a pool entry bound to a + /// vanished node still counts as pending - matching what Apply Pending would actually try. + fn pending_counts(&self) -> (u32, u32) { + let mut push = 0; + let mut clear = 0; + for entry in self.pool_keys.iter() { + let (Some(remote), Some(node)) = (entry.remote.as_deref(), entry.node.as_deref()) + else { + continue; + }; + if entry.pending_clear { + clear += 1; + continue; + } + // Pending push = the live current key on the node does not match the assigned pool + // key. Subscription health (Invalid, Expired, ...) is a separate axis surfaced via + // the Status column; re-pushing the same key would not change the shop's verdict + // and the badge must not double-count health issues as queued operations. + let is_pending = match self + .last_node_data + .iter() + .find(|n| n.remote == remote && n.node == node) + { + Some(n) => n.current_key.as_deref() != Some(entry.key.as_str()), + None => true, + }; + if is_pending { + push += 1; + } + } + (push, clear) + } + + /// Resolve the selected tree row to its `RemoteNodeStatus`, if any. + fn selected_node_status(&self) -> Option<&RemoteNodeStatus> { + let key = self.node_selection.selected_key()?; + let raw = key.to_string(); + let mut parts = raw.trim_start_matches('/').splitn(2, '/'); + let remote = parts.next()?; + let node = parts.next()?; + self.last_node_data + .iter() + .find(|n| n.remote == remote && n.node == node) + } + + /// Returns the assigned key when Revert is appropriate: there is a binding AND it has not + /// yet been pushed (different from current_key, or the node is not Active). For an + /// already-synced assignment, clearing would orphan the live subscription on the remote, + /// so the operator must take a different path (introduced later in the series). + fn clear_assignment_target_key(&self) -> Option { + let n = self.selected_node_status()?; + let assigned = n.assigned_key.as_ref()?; + let synced = n.status == proxmox_subscription::SubscriptionStatus::Active + && n.current_key.as_deref() == Some(assigned.as_str()); + if synced { + return None; + } + Some(assigned.clone()) + } + + /// Returns `(remote, node, type, node_sockets)` for the right-panel Assign button: + /// selected row is a node, no assigned key in the pool yet, and no live active subscription. + /// Refusing earlier than the server keeps the button-disable affordance honest. + fn assign_target_for_selected_node( + &self, + ) -> Option<(String, String, pdm_api_types::remotes::RemoteType, Option)> { + let n = self.selected_node_status()?; + if n.assigned_key.is_some() { + return None; + } + if n.status == proxmox_subscription::SubscriptionStatus::Active { + return None; + } + Some((n.remote.clone(), n.node.clone(), n.ty, n.sockets)) + } + + fn render_auto_assign_dialog( + &self, + ctx: &LoadableComponentContext, + proposal: &AutoAssignProposal, + ) -> Html { + use pwt::widget::Dialog; + + let store: Store = Store::with_extract_key(|p: &ProposedAssignment| { + format!("{}/{}", p.remote, p.node).into() + }); + store.set_data(proposal.assignments.clone()); + + let link_close = ctx.link().clone(); + let link_apply = ctx.link().clone(); + let proposal_for_apply = proposal.clone(); + let body = Column::new() + .class(Flex::Fill) + .class(Overflow::Hidden) + .min_height(0) + .padding(2) + .gap(2) + .min_width(600) + .with_child(Container::from_tag("p").with_child(tr!( + "The following {n} assignments are proposed. Click Assign to confirm.", + n = proposal.assignments.len(), + ))) + .with_child( + DataTable::new(self.proposal_columns.clone(), store) + .striped(true) + .class(FlexFit) + .min_height(140), + ) + .with_child( + Row::new() + .class(JustifyContent::FlexEnd) + .gap(2) + .padding_top(2) + .with_child( + Button::new(tr!("Cancel")) + .on_activate(move |_| link_close.change_view(None)), + ) + .with_child(Button::new(tr!("Assign")).on_activate(move |_| { + link_apply.send_message(Msg::BulkAssignApply(proposal_for_apply.clone())) + })), + ); + + Dialog::new(tr!("Auto-Assign Proposal")) + .resizable(true) + .width(700) + .min_width(500) + .min_height(300) + .max_height("80vh") + .on_close({ + let link = ctx.link().clone(); + move |_| link.change_view(None) + }) + .with_child(body) + .into() + } +} diff --git a/ui/src/main_menu.rs b/ui/src/main_menu.rs index 18988eaf..eba02d5f 100644 --- a/ui/src/main_menu.rs +++ b/ui/src/main_menu.rs @@ -15,6 +15,7 @@ use pdm_api_types::remotes::RemoteType; use pdm_api_types::{PRIV_SYS_AUDIT, PRIV_SYS_MODIFY}; use crate::configuration::subscription_panel::SubscriptionPanel; +use crate::configuration::subscription_registry::SubscriptionRegistryProps; use crate::configuration::views::ViewGrid; use crate::dashboard::view::View; use crate::remotes::RemotesPanel; @@ -292,6 +293,15 @@ impl Component for PdmMainMenu { config_submenu, ); + register_view( + &mut menu, + &mut content, + tr!("Subscription Registry"), + "subscription-registry", + Some("fa fa-id-card"), + |_| SubscriptionRegistryProps::new().into(), + ); + let mut admin_submenu = Menu::new(); register_view( diff --git a/ui/src/widget/pve_node_selector.rs b/ui/src/widget/pve_node_selector.rs index ca78514b..94d37bbd 100644 --- a/ui/src/widget/pve_node_selector.rs +++ b/ui/src/widget/pve_node_selector.rs @@ -43,6 +43,11 @@ pub struct PveNodeSelector { #[builder(IntoPropValue, into_prop_value)] #[prop_or_default] pub remote: AttrValue, + + /// Node names that should not appear in the selector (e.g. nodes that already have a + /// subscription key assigned in the pool). + #[prop_or_default] + pub excluded_nodes: Rc>, } impl PveNodeSelector { @@ -51,6 +56,11 @@ impl PveNodeSelector { remote: remote.into_prop_value() }) } + + pub fn excluded_nodes(mut self, nodes: Rc>) -> Self { + self.excluded_nodes = nodes; + self + } } pub enum Msg { @@ -60,6 +70,9 @@ pub enum Msg { pub struct PveNodeSelectorComp { _async_pool: AsyncPool, store: Store, + /// Unfiltered node list as fetched from the remote, kept so a prop change to `excluded_nodes` + /// can re-filter without round-tripping the remote again. + raw_nodes: Vec, last_err: Option, } @@ -69,6 +82,19 @@ impl PveNodeSelectorComp { nodes.sort_by(|a, b| a.node.cmp(&b.node)); Ok(nodes) } + + fn apply_filter(&mut self, excluded: &[String]) { + let filtered: Vec = if excluded.is_empty() { + self.raw_nodes.clone() + } else { + self.raw_nodes + .iter() + .filter(|n| !excluded.iter().any(|e| e == &n.node)) + .cloned() + .collect() + }; + self.store.set_data(filtered); + } } impl Component for PveNodeSelectorComp { @@ -84,16 +110,20 @@ impl Component for PveNodeSelectorComp { Self { _async_pool, last_err: None, + raw_nodes: Vec::new(), store: Store::with_extract_key(|node: &ClusterNodeIndexResponse| { Key::from(node.node.as_str()) }), } } - fn update(&mut self, _ctx: &yew::Context, msg: Self::Message) -> bool { + fn update(&mut self, ctx: &yew::Context, msg: Self::Message) -> bool { match msg { Msg::UpdateNodeList(res) => match res { - Ok(result) => self.store.set_data(result), + Ok(result) => { + self.raw_nodes = result; + self.apply_filter(&ctx.props().excluded_nodes); + } Err(err) => self.last_err = Some(err.to_string().into()), }, } @@ -101,6 +131,13 @@ impl Component for PveNodeSelectorComp { true } + fn changed(&mut self, ctx: &yew::Context, old_props: &Self::Properties) -> bool { + if old_props.excluded_nodes != ctx.props().excluded_nodes { + self.apply_filter(&ctx.props().excluded_nodes); + } + true + } + fn view(&self, ctx: &yew::Context) -> yew::Html { let props = ctx.props(); let err = self.last_err.clone(); -- 2.47.3