From: "Lukas Wagner" <l.wagner@proxmox.com>
To: "Thomas Lamprecht" <t.lamprecht@proxmox.com>,
<pdm-devel@lists.proxmox.com>
Subject: Re: [PATCH datacenter-manager v2 5/8] ui: add subscription registry with key pool and node status
Date: Tue, 12 May 2026 16:45:42 +0200 [thread overview]
Message-ID: <DIGS2FK5RU5F.1P73QUIB9EVZV@proxmox.com> (raw)
In-Reply-To: <20260507082943.2749725-6-t.lamprecht@proxmox.com>
On Thu May 7, 2026 at 10:26 AM CEST, Thomas Lamprecht wrote:
[...]
> +#[doc(hidden)]
> +pub struct SubscriptionKeyGridComp {
> + state: LoadableComponentState<ViewState>,
> + store: Store<SubscriptionKeyEntry>,
> + columns: Rc<Vec<DataTableHeader<SubscriptionKeyEntry>>>,
> + selection: Selection,
> +}
> +
> +pwt::impl_deref_mut_property!(
> + SubscriptionKeyGridComp,
> + state,
> + LoadableComponentState<ViewState>
> +);
> +
> +impl SubscriptionKeyGridComp {
> + fn columns() -> Rc<Vec<DataTableHeader<SubscriptionKeyEntry>>> {
> + 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")
> + .render(|entry: &SubscriptionKeyEntry| entry.product_type.to_string().into())
> + .into(),
> + DataTableColumn::new(tr!("Level"))
> + .width("90px")
> + .render(|entry: &SubscriptionKeyEntry| entry.level.to_string().into())
> + .into(),
> + DataTableColumn::new(tr!("Assignment"))
> + .flex(2)
> + .render(
> + |entry: &SubscriptionKeyEntry| match (&entry.remote, &entry.node) {
> + (Some(remote), Some(node)) => format!("{remote} / {node}").into(),
You could check the product type so that PBS can leave out '{node}'
part -- it's always 'localhost' anyways :)
> + _ => Html::default(),
> + },
> + )
> + .into(),
> + ])
> + }
> +
> + fn selected_entry(&self) -> Option<SubscriptionKeyEntry> {
> + let key = self.selection.selected_key()?;
> + self.store.read().lookup_record(&key).cloned()
> + }
> +
> + fn create_add_dialog(&self, ctx: &LoadableComponentContext<Self>) -> 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<Self>,
> + ) -> Html {
> + let key = entry.key.clone();
> + let product_type = entry.product_type;
> + EditWindow::new(tr!("Assign Key to Remote"))
> + .renderer({
> + let key = key.clone();
> + move |form_ctx| assign_input_panel(&key, product_type, form_ctx)
> + })
> + .on_submit({
> + let key = key.clone();
> + move |form| submit_assign(key.clone(), form)
> + })
> + .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>) -> Self {
> + let selection = Selection::new().on_select({
> + let link = 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<Self>, msg: Self::Message) -> bool {
> + match msg {
> + Msg::LoadFinished(data) => self.store.set_data(data),
> + Msg::Remove(key) => {
> + let id = key.to_string();
> + let link = ctx.link().clone();
> + ctx.link().spawn(async move {
> + let url = format!("{BASE_URL}/{}", percent_encode_component(&id));
> + if let Err(err) = 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 = id, err = err),
> + true,
> + );
> + }
> + link.send_message(Msg::Reload);
> + });
> + }
> + Msg::Reload => {
> + ctx.link().change_view(None);
> + ctx.link().send_reload();
> + if let Some(cb) = &ctx.props().on_change {
> + cb.emit(());
> + }
> + }
> + }
> + true
> + }
> +
> + fn toolbar(&self, ctx: &LoadableComponentContext<Self>) -> Option<Html> {
> + 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(
> + 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 || !assignable)
> + .on_activate(link.change_view_callback(|_| Some(ViewState::Assign))),
> + )
> + .into(),
> + )
> + }
> +
> + fn load(
> + &self,
> + ctx: &LoadableComponentContext<Self>,
> + ) -> Pin<Box<dyn Future<Output = Result<(), Error>>>> {
> + let link = ctx.link().clone();
> + Box::pin(async move {
> + let data: Vec<SubscriptionKeyEntry> = http_get(BASE_URL, None).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<Self>) -> Html {
> + DataTable::new(self.columns.clone(), self.store.clone())
> + .selection(self.selection.clone())
> + .into()
> + }
> +
> + fn dialog_view(
> + &self,
> + ctx: &LoadableComponentContext<Self>,
> + view_state: &Self::ViewState,
> + ) -> Option<Html> {
> + 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| {
> + ConfirmDialog::new(
> + tr!("Remove Key"),
> + tr!(
> + "Remove {key} from the key pool? This does not revoke the subscription.",
> + key = key.to_string(),
> + ),
> + )
> + .on_confirm({
> + let link = ctx.link().clone();
> + let key = key.clone();
> + move |_| link.send_message(Msg::Remove(key.clone()))
> + })
> + .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 should use Reissue Key in that state.
> +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) -> Result<(), Error> {
> + let raw = form_ctx.read().get_field_text("keys");
> + let keys: Vec<String> = 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"));
> + }
> +
> + 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 drive.
> +fn remote_type_for(product_type: ProductType) -> Option<RemoteType> {
> + 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) -> 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 {
> + // `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}"))
> + .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) -> Result<(), Error> {
> + let data = form_ctx.get_submit_data();
> + let url = format!("{BASE_URL}/{}", percent_encode_component(&key));
> + http_put(&url, Some(data)).await
> +}
Here as well.
[...]
> +
> + fn proposal_columns() -> Rc<Vec<DataTableHeader<ProposedAssignment>>> {
> + 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(),
> + ])
I think a 'Product Type' column here would be nice as well.
> + }
> +}
> +
next prev parent reply other threads:[~2026-05-12 14:45 UTC|newest]
Thread overview: 21+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-05-07 8:26 [PATCH datacenter-manager v2 0/8] subscription: add central key pool registry with reissue support Thomas Lamprecht
2026-05-07 8:26 ` [PATCH datacenter-manager v2 1/8] api: subscription cache: ensure max_age=0 forces a fresh fetch Thomas Lamprecht
2026-05-07 13:23 ` Lukas Wagner
2026-05-08 12:43 ` applied: " Lukas Wagner
2026-05-07 8:26 ` [PATCH datacenter-manager v2 2/8] api types: subscription level: render full names Thomas Lamprecht
2026-05-07 13:23 ` Lukas Wagner
2026-05-07 8:26 ` [PATCH datacenter-manager v2 3/8] subscription: add key pool data model and config layer Thomas Lamprecht
2026-05-12 9:51 ` Lukas Wagner
2026-05-07 8:26 ` [PATCH datacenter-manager v2 4/8] subscription: add key pool and node status API endpoints Thomas Lamprecht
2026-05-07 13:23 ` Lukas Wagner
2026-05-12 12:21 ` Lukas Wagner
2026-05-07 8:26 ` [PATCH datacenter-manager v2 5/8] ui: add subscription registry with key pool and node status Thomas Lamprecht
2026-05-12 14:45 ` Lukas Wagner [this message]
2026-05-07 8:26 ` [PATCH datacenter-manager v2 6/8] cli: add subscription key pool management subcommands Thomas Lamprecht
2026-05-12 12:56 ` Lukas Wagner
2026-05-07 8:26 ` [PATCH datacenter-manager v2 7/8] docs: add subscription registry chapter Thomas Lamprecht
2026-05-07 8:26 ` [PATCH datacenter-manager v2 8/8] subscription: add Reissue Key action with pending-reissue queue Thomas Lamprecht
2026-05-12 13:57 ` Lukas Wagner
2026-05-07 8:34 ` [PATCH datacenter-manager v2 9/9] fixup! ui: add subscription registry with key pool and node status Thomas Lamprecht
2026-05-07 13:23 ` [PATCH datacenter-manager v2 0/8] subscription: add central key pool registry with reissue support Lukas Wagner
2026-05-15 7:48 ` superseded: " Thomas Lamprecht
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=DIGS2FK5RU5F.1P73QUIB9EVZV@proxmox.com \
--to=l.wagner@proxmox.com \
--cc=pdm-devel@lists.proxmox.com \
--cc=t.lamprecht@proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.