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 a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox