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 5792C1FF142 for ; Fri, 22 May 2026 15:16:45 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 4F7FDB57F; Fri, 22 May 2026 15:16:44 +0200 (CEST) Message-ID: Date: Fri, 22 May 2026 15:16:32 +0200 MIME-Version: 1.0 User-Agent: Mozilla Thunderbird Beta Subject: Re: [PATCH datacenter-manager v4 05/10] ui: registry: add view with key pool and node status To: Thomas Lamprecht , pdm-devel@lists.proxmox.com References: <20260522085128.2678090-1-t.lamprecht@proxmox.com> <20260522085128.2678090-6-t.lamprecht@proxmox.com> Content-Language: en-US From: Dominik Csapak In-Reply-To: <20260522085128.2678090-6-t.lamprecht@proxmox.com> Content-Type: text/plain; charset=UTF-8; format=flowed Content-Transfer-Encoding: 7bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1779455774361 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.951 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 URIBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to URIBL was blocked. See http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more information. [mod.rs] Message-ID-Hash: GF63LJ2WDUN7KKXIUATUUQX7ERAOPMHZ X-Message-ID-Hash: GF63LJ2WDUN7KKXIUATUUQX7ERAOPMHZ X-MailFrom: d.csapak@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: works good & looks nice, none of my comments are blockers but polishing work. high level comments: we have 3 types of toolbars here on a single page, which seems a bit inconsistent: * the top level toolbar without a title * the left side has a titlebar and a toolbar below * the right side has a ttitlebar and the buttons inline with the title the elements from the top bar could probably be moved to the left hand side, removing the toolbar as a whole? and the adopt-all fits more on the right side (so most actions 'pdm -> remotes' would be on the left side and most actions 'pdm <- remotes' would be on the right side; except assign on the right and check subscription) i'd also opt for either style but consistent (a separate toolbar is probably what we use most and fits the rest of pdm) i already mentioned the 'weak' indicator of the reload and general loading/waiting indication. If a window submit calls the api that can block, we should imho either * mask the window until the submit is done (what we do most of the time) * disable the button + add a loading indicator some comments (mostly nits inline) On 5/22/26 10:52 AM, Thomas Lamprecht wrote: > 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 v3 -> 4: > * `build_tree` iterates `by_remote` by value, dropping three > `.clone()`s (Wolfgang). > * Add Subscription Keys helper text: Opacity::Quarter -> ThreeQuarters > for readable contrast in both themes (Lukas). > * New `excluded_remotes` prop on `RemoteSelector`; Assign-Key uses it > to hide fully-subscribed remotes (Lukas). > * New `show_memory(bool)` builder on `PveNodeSelector` (default true); > Assign-Key Node dropdown sets it to false (Lukas). > * Auto-Assign Proposal Key column drops the > `FontStyle::LabelMedium` span wrapper that forced 12px (Lukas). > * Per-node Assign dialog body: `.padding(2)` -> > `.padding_x(2).padding_top(2)` so the footer sits flush with the > bottom edge (Lukas). > > ui/src/configuration/mod.rs | 3 + > ui/src/configuration/subscription_assign.rs | 336 ++++++ > ui/src/configuration/subscription_keys.rs | 568 +++++++++ > ui/src/configuration/subscription_registry.rs | 1014 +++++++++++++++++ > ui/src/main_menu.rs | 10 + > ui/src/widget/pve_node_selector.rs | 91 +- > ui/src/widget/remote_selector.rs | 28 +- > 7 files changed, 2024 insertions(+), 26 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/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..58154aa2 > --- /dev/null > +++ b/ui/src/configuration/subscription_assign.rs > @@ -0,0 +1,336 @@ > +//! 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"; > + nit: this is unused here, but see below > +/// 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") nit: this is not enough to show e.g. "Community" probably 100px should be fine. > + .render(|e: &SubscriptionKeyEntry| e.level.to_string().into()) > + .into(), > + DataTableColumn::new(tr!("Sockets")) > + .width("70px") with this, the header is not fully readable ("Sock..."), 80px should be enough here > + .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", should probably use KEYS_URL > + 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) This should probably be dropped since there is already a gap(2) between elements here > + .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() > + // No `.padding(2)` here: a uniform 10px around would also pad below the footer, > + // leaving the Cancel / Assign buttons floating away from the dialog's bottom edge. > + // The footer carries its own `padding_top(2)` for the gap above the buttons. having the padding consistent around the dialog is what we usually do (e.g. in an edit window) so I'd do a normal padding here and also this columns should get a 'FlexFit' otherwise it won't resize together with the dialog on user resize and the buttons don't stay at the bottom of the window > + .padding_x(2) > + .padding_top(2) > + .gap(2) > + .min_width(640) this removed together with the flexfit means the constraints should live in the dialog, so that can resize and the body just adapts. otherwise we get a scrollbar in the dialog since the body has a bigger min_width than the dialog > + .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..ed5ac7f0 > --- /dev/null > +++ b/ui/src/configuration/subscription_keys.rs > @@ -0,0 +1,568 @@ > +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>, > +} not sure where to put this comment but here it is: would probably be nice to be able to remove multiple keys at once, e.g. i could imagine a user decomissioning a whole cluster. and removing a large number of keys could be cumbersome when done individually e.g. a checkbox column to select multiple keys and then only enabling the 'remove' button could work > + > +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 > + } you could use the 'builder' macro from pwt-macros here to generate them from the properties. one advantage is that the documentation will be added to or linked by the builder methods too (helpful when using things like rust-analyzer) > +} > + > +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") same comment regarding space as above > + .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::ThreeQuarters) > + .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(); > + }; > + > + // Hide remotes of this type whose every node already has a pool key assigned; they offer no > + // free target for this key. > + let excluded_remotes: Rc> = { > + use std::collections::BTreeSet; > + let mut all_of_type: BTreeSet<&str> = BTreeSet::new(); > + let mut assignable: BTreeSet<&str> = BTreeSet::new(); > + for n in node_status.iter().filter(|n| n.ty == remote_type) { > + all_of_type.insert(n.remote.as_str()); > + if n.assigned_key.is_none() { > + assignable.insert(n.remote.as_str()); > + } > + } > + Rc::new( > + all_of_type > + .difference(&assignable) > + .map(|r| AttrValue::from(r.to_string())) > + .collect(), > + ) > + }; > + > + panel = panel.with_field( > + tr!("Remote"), > + RemoteSelector::new() > + .name("remote") > + .remote_type(remote_type) > + .excluded_remotes(excluded_remotes) > + .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)) > + .show_memory(false) > + .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..2d4853b5 > --- /dev/null > +++ b/ui/src/configuration/subscription_registry.rs > @@ -0,0 +1,1014 @@ > +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}; super tiny nit. I think the preferred way now is to just 'use pwt::css' and use 'css::AlignItems' 'css::Flex' and so on, but this surely is a personal preference > +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"), > + } > +} i have the feeling this should probably live somewhere more global like proxmox-yew-comp > + > +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, mut 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.remove(0), > + standalone: true, > + }); > + } else { > + let mut remote_entry = root.append(NodeTreeEntry::Remote { > + name: remote_name, > + ty, > + active, > + total, > + }); > + remote_entry.set_expanded(true); > + for n in remote_nodes { > + remote_entry.append(NodeTreeEntry::Node { > + data: n, > + 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") same problem with size as the other two level columns > + .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| 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)); > + } is it intended that this sits between two flex_spacers? it seems a bit unbalanced: | button | button | button | button | --- flex --- ---flex--- | button | it's not really centered but also not left or right aligned... maybe just having it directly after the buttons would be better? > + > + 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") not really a comment on your code, but more a reminder to myself: i don't like that we have to do this that often, maybe we could abstract away a 'flex type' that implements from for a single number that results in 'flex: x x auto' a tuple of two number result in 'flex: x y auto' and a tuple of three number that result in 'flex: x y z' that way one could use here .flex((3, 1, 0)) or having a struct would also be better: .flex(Flex { grow: 3, shrink: 1, basis: 0 }) or something like that.... > + .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. this could probably be a simple helper struct struct AssignTarget { remote, node, ty, sockets } that can be passed around. makes it easier to read and pass and there can be no confusion about which String is which > + 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..79936309 100644 > --- a/ui/src/widget/pve_node_selector.rs > +++ b/ui/src/widget/pve_node_selector.rs > @@ -43,6 +43,17 @@ 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>, > + > + /// Whether to show the "Memory Usage" column. Callers picking a node for a context where > + /// memory is irrelevant (e.g. subscription assignment) can hide it. > + #[builder] > + #[prop_or(true)] > + pub show_memory: bool, > } > > impl PveNodeSelector { > @@ -51,6 +62,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 +76,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 +88,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 +116,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,9 +137,17 @@ 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(); > + let show_memory = props.show_memory; > let on_change = { > let on_change = props.on_change.clone(); > let store = self.store.clone(); > @@ -128,7 +172,7 @@ impl Component for PveNodeSelectorComp { > .into(); > } > GridPicker::new( > - DataTable::new(columns(), args.store.clone()) > + DataTable::new(columns(show_memory), args.store.clone()) > .min_width(300) > .header_focusable(false) > .class(FlexFit), > @@ -148,22 +192,27 @@ impl Component for PveNodeSelectorComp { > } > } > > -fn columns() -> Rc>> { > - Rc::new(vec![ > - DataTableColumn::new(tr!("Node")) > - .get_property(|entry: &ClusterNodeIndexResponse| &entry.node) > - .sort_order(true) > - .into(), > - DataTableColumn::new(tr!("Memory Usage")) > - .render( > - |entry: &ClusterNodeIndexResponse| match (entry.mem, entry.maxmem) { > - (Some(mem), Some(maxmem)) => { > - html! {format!("{:.2}%", 100.0 * mem as f64 / maxmem as f64)} > - } > - _ => html! {}, > - }, > - ) > - .sorter(|a: &ClusterNodeIndexResponse, b: &ClusterNodeIndexResponse| a.mem.cmp(&b.mem)) > - .into(), > - ]) > +fn columns(show_memory: bool) -> Rc>> { > + let mut columns = vec![DataTableColumn::new(tr!("Node")) > + .get_property(|entry: &ClusterNodeIndexResponse| &entry.node) > + .sort_order(true) > + .into()]; > + if show_memory { > + columns.push( > + DataTableColumn::new(tr!("Memory Usage")) > + .render( > + |entry: &ClusterNodeIndexResponse| match (entry.mem, entry.maxmem) { > + (Some(mem), Some(maxmem)) => { > + html! {format!("{:.2}%", 100.0 * mem as f64 / maxmem as f64)} > + } > + _ => html! {}, > + }, > + ) > + .sorter(|a: &ClusterNodeIndexResponse, b: &ClusterNodeIndexResponse| { > + a.mem.cmp(&b.mem) > + }) > + .into(), > + ); > + } > + Rc::new(columns) > } > diff --git a/ui/src/widget/remote_selector.rs b/ui/src/widget/remote_selector.rs > index 0cf0f400..69732aab 100644 > --- a/ui/src/widget/remote_selector.rs > +++ b/ui/src/widget/remote_selector.rs > @@ -38,12 +38,21 @@ pub struct RemoteSelector { > #[builder(IntoPropValue, into_prop_value)] > #[prop_or_default] > pub remote_type: Option, > + > + /// Remote IDs to drop from the list (e.g. remotes with no node left to assign a key to). > + #[prop_or_default] > + pub excluded_remotes: Rc>, > } > > impl RemoteSelector { > pub fn new() -> Self { > yew::props!(Self {}) > } > + > + pub fn excluded_remotes(mut self, remotes: Rc>) -> Self { > + self.excluded_remotes = remotes; > + self > + } > } > > pub struct PdmRemoteSelector { > @@ -64,12 +73,19 @@ impl PdmRemoteSelector { > > fn set_remote_list(&mut self, ctx: &yew::Context, remotes: RemoteList) { > let ty = ctx.props().remote_type; > + let excluded = ctx.props().excluded_remotes.clone(); > let remotes = remotes > .iter() > - .filter_map(move |remote| match (ty, remote.ty) { > - (Some(a), b) if a == b => Some(remote.id.clone().into()), > - (None, _) => Some(remote.id.clone().into()), > - _ => None, > + .filter_map(move |remote| { > + let id: AttrValue = remote.id.clone().into(); > + if excluded.contains(&id) { > + return None; > + } > + match (ty, remote.ty) { > + (Some(a), b) if a == b => Some(id), > + (None, _) => Some(id), > + _ => None, > + } > }) > .collect(); > > @@ -97,7 +113,9 @@ impl Component for PdmRemoteSelector { > } > > fn changed(&mut self, ctx: &yew::Context, _old_props: &Self::Properties) -> bool { > - if ctx.props().remote_type != _old_props.remote_type { > + if ctx.props().remote_type != _old_props.remote_type > + || ctx.props().excluded_remotes != _old_props.excluded_remotes > + { > self.update_remote_list(ctx); > } > true