public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
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.


> +    }
> +}
> +






  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
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal