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 74C6A1FF146 for ; Tue, 12 May 2026 16:45:48 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 9C4E61843E; Tue, 12 May 2026 16:45:47 +0200 (CEST) Mime-Version: 1.0 Content-Transfer-Encoding: quoted-printable Content-Type: text/plain; charset=UTF-8 Date: Tue, 12 May 2026 16:45:42 +0200 Message-Id: Subject: Re: [PATCH datacenter-manager v2 5/8] ui: add subscription registry with key pool and node status From: "Lukas Wagner" To: "Thomas Lamprecht" , X-Mailer: aerc 0.21.0-0-g5549850facc2-dirty References: <20260507082943.2749725-1-t.lamprecht@proxmox.com> <20260507082943.2749725-6-t.lamprecht@proxmox.com> In-Reply-To: <20260507082943.2749725-6-t.lamprecht@proxmox.com> X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1778597030707 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.946 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: CS2RTMWH52LFBLGSDBTNUSQGR353ZJIG X-Message-ID-Hash: CS2RTMWH52LFBLGSDBTNUSQGR353ZJIG X-MailFrom: l.wagner@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: On Thu May 7, 2026 at 10:26 AM CEST, Thomas Lamprecht wrote: [...] > +#[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.a= s_str()) > + .sort_order(true) > + .into(), > + DataTableColumn::new(tr!("Product")) > + .width("80px") > + .render(|entry: &SubscriptionKeyEntry| entry.product_typ= e.to_string().into()) > + .into(), > + DataTableColumn::new(tr!("Level")) > + .width("90px") > + .render(|entry: &SubscriptionKeyEntry| entry.level.to_st= ring().into()) > + .into(), > + DataTableColumn::new(tr!("Assignment")) > + .flex(2) > + .render( > + |entry: &SubscriptionKeyEntry| match (&entry.remote,= &entry.node) { > + (Some(remote), Some(node)) =3D> format!("{remote= } / {node}").into(), You could check the product type so that PBS can leave out '{node}' part -- it's always 'localhost' anyways :) > + _ =3D> Html::default(), > + }, > + ) > + .into(), > + ]) > + } > + > + fn selected_entry(&self) -> Option { > + let key =3D self.selection.selected_key()?; > + self.store.read().lookup_record(&key).cloned() > + } > + > + fn create_add_dialog(&self, ctx: &LoadableComponentContext) ->= Html { > + EditWindow::new(tr!("Add Subscription Keys")) > + .renderer(|_form_ctx| add_input_panel()) > + .on_submit(submit_add_keys) > + .on_done(ctx.link().clone().callback(|_| Msg::Reload)) > + .into() > + } > + > + fn create_assign_dialog( > + &self, > + entry: &SubscriptionKeyEntry, > + ctx: &LoadableComponentContext, > + ) -> Html { > + let key =3D entry.key.clone(); > + let product_type =3D entry.product_type; > + EditWindow::new(tr!("Assign Key to Remote")) > + .renderer({ > + let key =3D key.clone(); > + move |form_ctx| assign_input_panel(&key, product_type, f= orm_ctx) > + }) > + .on_submit({ > + let key =3D key.clone(); > + move |form| submit_assign(key.clone(), form) > + }) > + .on_done(ctx.link().clone().callback(|_| Msg::Reload)) > + .into() > + } > +} > + > +impl LoadableComponent for SubscriptionKeyGridComp { > + type Properties =3D SubscriptionKeyGrid; > + type Message =3D Msg; > + type ViewState =3D ViewState; > + > + fn create(ctx: &LoadableComponentContext) -> Self { > + let selection =3D Selection::new().on_select({ > + let link =3D ctx.link().clone(); > + move |_| link.send_redraw() > + }); > + Self { > + state: LoadableComponentState::new(), > + store: Store::with_extract_key(|entry: &SubscriptionKeyEntry= | { > + entry.key.as_str().into() > + }), > + columns: Self::columns(), > + selection, > + } > + } > + > + fn update(&mut self, ctx: &LoadableComponentContext, msg: Self= ::Message) -> bool { > + match msg { > + Msg::LoadFinished(data) =3D> self.store.set_data(data), > + Msg::Remove(key) =3D> { > + let id =3D key.to_string(); > + let link =3D ctx.link().clone(); > + ctx.link().spawn(async move { > + let url =3D format!("{BASE_URL}/{}", percent_encode_= component(&id)); > + if let Err(err) =3D http_delete(&url, None).await { As far as I could tell, the next patch adds fitting bindings for the pdm-client crate. You could use them here via crate::pdm_client > + link.show_error( > + tr!("Error"), > + tr!("Could not remove {id}: {err}", id =3D i= d, err =3D err), > + true, > + ); > + } > + link.send_message(Msg::Reload); > + }); > + } > + Msg::Reload =3D> { > + ctx.link().change_view(None); > + ctx.link().send_reload(); > + if let Some(cb) =3D &ctx.props().on_change { > + cb.emit(()); > + } > + } > + } > + true > + } > + > + fn toolbar(&self, ctx: &LoadableComponentContext) -> Option { > + let entry =3D self.selected_entry(); > + let has_selection =3D entry.is_some(); > + let is_assigned =3D entry.as_ref().map(|e| e.remote.is_some()).u= nwrap_or(false); > + let synced_assignment =3D entry > + .as_ref() > + .map(|e| is_synced_assignment(e, &ctx.props().node_status)) > + .unwrap_or(false); > + let assignable =3D entry > + .as_ref() > + .map(|e| { > + e.product_type.matches_remote_type(RemoteType::Pve) > + || e.product_type.matches_remote_type(RemoteType::Pb= s) > + }) > + .unwrap_or(false); > + let link =3D ctx.link(); > + > + Some( > + Toolbar::new() > + .border_bottom(true) > + .with_child( > + Button::new(tr!("Add")) > + .icon_class("fa fa-plus") > + .on_activate(link.change_view_callback(|_| Some(= ViewState::Add))), > + ) > + .with_spacer() > + .with_child( > + 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))), > + ) > + .with_spacer() > + .with_child( > + Button::new(tr!("Assign")) > + .icon_class("fa fa-link") > + .disabled(!has_selection || is_assigned || !assi= gnable) > + .on_activate(link.change_view_callback(|_| Some(= ViewState::Assign))), > + ) > + .into(), > + ) > + } > + > + fn load( > + &self, > + ctx: &LoadableComponentContext, > + ) -> Pin>>> { > + let link =3D ctx.link().clone(); > + Box::pin(async move { > + let data: Vec =3D http_get(BASE_URL, N= one).await?; Same thing here regarding the usage of the pdm-client crate > + link.send_message(Msg::LoadFinished(data)); > + 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 =3D> Some(self.create_add_dialog(ctx)), > + ViewState::Assign =3D> self > + .selected_entry() > + .map(|entry| self.create_assign_dialog(&entry, ctx)), > + ViewState::Remove =3D> self.selection.selected_key().map(|ke= y| { > + ConfirmDialog::new( > + tr!("Remove Key"), > + tr!( > + "Remove {key} from the key pool? This does not r= evoke the subscription.", > + key =3D key.to_string(), > + ), > + ) > + .on_confirm({ > + let link =3D ctx.link().clone(); > + let key =3D key.clone(); > + move |_| link.send_message(Msg::Remove(key.clone())) > + }) > + .into() > + }), > + } > + } > +} > + > +/// Returns true when the pool entry's binding currently runs the same k= ey on the remote and is > +/// Active - meaning a clear-assignment would orphan the live subscripti= on. Mirrors the > +/// server-side gate; the operator should use Reissue Key in that state. > +fn is_synced_assignment(entry: &SubscriptionKeyEntry, statuses: &[Remote= NodeStatus]) -> bool { > + let (Some(remote), Some(node)) =3D (entry.remote.as_deref(), entry.n= ode.as_deref()) else { > + return false; > + }; > + statuses > + .iter() > + .find(|n| n.remote =3D=3D remote && n.node =3D=3D node) > + .map(|n| { > + n.status =3D=3D proxmox_subscription::SubscriptionStatus::Ac= tive > + && n.current_key.as_deref() =3D=3D Some(entry.key.as_str= ()) > + }) > + .unwrap_or(false) > +} > + > +fn add_input_panel() -> Html { > + let hint =3D 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 P= roxmox 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) -> Result<(), Error> { > + let raw =3D form_ctx.read().get_field_text("keys"); > + let keys: Vec =3D raw > + .split(|c: char| c.is_whitespace() || c =3D=3D ',') > + .map(str::trim) > + .filter(|s| !s.is_empty()) > + .map(str::to_string) > + .collect(); > + > + if keys.is_empty() { > + anyhow::bail!(tr!("no keys provided")); > + } > + > + http_post(BASE_URL, Some(serde_json::json!({ "keys": keys }))).await Here as well > +} > + > +/// Map a subscription product type to the remote type its keys can driv= e. > +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: &F= ormContext) -> Html { > + let mut panel =3D InputPanel::new().padding(4).min_width(500).with_f= ield( > + tr!("Key"), > + DisplayField::new() > + .name("key") > + .value(key.to_string()) > + .key("key-display"), > + ); > + > + let Some(remote_type) =3D remote_type_for(product_type) else { > + // Defensive: the toolbar disables Assign for these product type= s. > + 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 k= ey is parked in the pool.", > + product =3D product_type.to_string(), > + )), > + ) > + .into(); > + }; > + > + panel =3D panel.with_field( > + tr!("Remote"), > + RemoteSelector::new() > + .name("remote") > + .remote_type(remote_type) > + .required(true), > + ); > + > + match remote_type { > + RemoteType::Pve =3D> { > + let selected_remote =3D form_ctx.read().get_field_text("remo= te"); > + 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 { > + // `PveNodeSelector` fetches its node list in `create` a= nd does not re-fetch on > + // prop change, so a per-remote `key` forces a fresh com= ponent when the operator > + // picks a target. > + panel > + .with_field( > + tr!("Node"), > + PveNodeSelector::new(selected_remote.clone()) > + .name("node") > + .key(format!("node-selector-{selected_remote= }")) > + .required(true), > + ) > + .into() > + } > + } > + RemoteType::Pbs =3D> 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) -> Result<(),= Error> { > + let data =3D form_ctx.get_submit_data(); > + let url =3D format!("{BASE_URL}/{}", percent_encode_component(&key))= ; > + http_put(&url, Some(data)).await > +} Here as well. [...] > + > + fn proposal_columns() -> Rc>= > { > + Rc::new(vec![ > + DataTableColumn::new(tr!("Remote / Node")) > + .flex(2) > + .render(|p: &ProposedAssignment| format!("{} / {}", p.re= mote, 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 =3D match (p.node_sockets, p.key_sockets) = { > + (Some(ns), Some(ks)) =3D> format!("{ns} / {ks}")= , > + (Some(ns), None) =3D> format!("{ns} / -"), > + (None, Some(ks)) =3D> format!("- / {ks}"), > + _ =3D> String::new(), > + }; > + label.into() > + }) > + .into(), > + ]) I think a 'Product Type' column here would be nice as well. > + } > +} > +