public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Thomas Lamprecht <t.lamprecht@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [PATCH datacenter-manager v3 05/12] ui: registry: add view with key pool and node status
Date: Fri, 15 May 2026 09:43:15 +0200	[thread overview]
Message-ID: <20260515074623.766766-6-t.lamprecht@proxmox.com> (raw)
In-Reply-To: <20260515074623.766766-1-t.lamprecht@proxmox.com>

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 <t.lamprecht@proxmox.com>
---

Changes v2 -> 3:
* ESC dismisses every ConfirmDialog on the registry view; the v2 view
  got stuck in the dialog state on ESC.
* Pool grid columns are sortable.
* New hidden-by-default Source column for the Adopted entries
  introduced in v3-0009.
* Pending counts use the same predicate the server uses for
  compute_pending, so client and server stay in sync.
* Invalid keys land with a clear error instead of staying queued
  with a misleading pending badge.
* "Clear Pending" button renamed to "Discard Pending"; the action
  also cancels queued clears once the Clear Key flow lands in v3-0008.
* "Clear Assignment" action on the Node Status panel renamed to
  "Revert"; its sibling "Remove" button there is dropped (Remove
  Key on the pool grid covers it).

 ui/Cargo.toml                                 |    2 +-
 ui/src/configuration/mod.rs                   |    3 +
 ui/src/configuration/subscription_assign.rs   |  332 ++++++
 ui/src/configuration/subscription_keys.rs     |  546 +++++++++
 ui/src/configuration/subscription_registry.rs | 1019 +++++++++++++++++
 ui/src/main_menu.rs                           |   10 +
 ui/src/widget/pve_node_selector.rs            |   41 +-
 7 files changed, 1950 insertions(+), 3 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/Cargo.toml b/ui/Cargo.toml
index 4e1f772f..3d578022 100644
--- a/ui/Cargo.toml
+++ b/ui/Cargo.toml
@@ -30,7 +30,7 @@ yew-router = { version = "0.18" }
 pwt = "0.8.0"
 pwt-macros = "0.5"
 
-proxmox-yew-comp = { version = "0.8.7", features = ["apt", "dns", "network", "rrd"] }
+proxmox-yew-comp = { version = "0.8.8", features = ["apt", "dns", "network", "rrd"] }
 
 proxmox-access-control = { version = "1.1", features = []}
 proxmox-acme-api = "1"
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..16936b7f
--- /dev/null
+++ b/ui/src/configuration/subscription_assign.rs
@@ -0,0 +1,332 @@
+//! 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";
+
+/// 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<SubscriptionKeyEntry> {
+    let mut out: Vec<SubscriptionKeyEntry> = 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<i64>,
+) -> Option<String> {
+    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<Vec<DataTableHeader<SubscriptionKeyEntry>>> {
+    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")
+            .render(|e: &SubscriptionKeyEntry| e.level.to_string().into())
+            .into(),
+        DataTableColumn::new(tr!("Sockets"))
+            .width("70px")
+            .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",
+        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 <remote>/<node>" dialog.
+#[derive(Properties, Clone, PartialEq)]
+pub struct AssignKeyToNodeDialog {
+    pub remote: String,
+    pub node: String,
+    pub ty: RemoteType,
+    pub node_sockets: Option<i64>,
+    pub pool_keys: Rc<Vec<SubscriptionKeyEntry>>,
+
+    #[prop_or_default]
+    pub pool_digest: Option<String>,
+
+    #[prop_or_default]
+    pub on_done: Option<Callback<()>>,
+}
+
+impl AssignKeyToNodeDialog {
+    pub fn new(
+        remote: impl Into<String>,
+        node: impl Into<String>,
+        ty: RemoteType,
+        node_sockets: Option<i64>,
+        pool_keys: Rc<Vec<SubscriptionKeyEntry>>,
+    ) -> 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<String>) -> 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<AssignKeyToNodeDialog> for VNode {
+    fn from(val: AssignKeyToNodeDialog) -> Self {
+        VComp::new::<AssignKeyToNodeComp>(Rc::new(val), None).into()
+    }
+}
+
+pub enum AssignMsg {
+    SelectionChanged,
+    Submit,
+    SubmitDone(Result<(), Error>),
+}
+
+pub struct AssignKeyToNodeComp {
+    store: Store<SubscriptionKeyEntry>,
+    columns: Rc<Vec<DataTableHeader<SubscriptionKeyEntry>>>,
+    selection: Selection,
+    last_error: Option<String>,
+    submitting: bool,
+}
+
+impl yew::Component for AssignKeyToNodeComp {
+    type Message = AssignMsg;
+    type Properties = AssignKeyToNodeDialog;
+
+    fn create(ctx: &yew::Context<Self>) -> 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<Self>, 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<Self>) -> 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<Html> = 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)
+            .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()
+            .padding(2)
+            .gap(2)
+            .min_width(640)
+            .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..e43543ae
--- /dev/null
+++ b/ui/src/configuration/subscription_keys.rs
@@ -0,0 +1,546 @@
+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<Vec<SubscriptionKeyEntry>>,
+
+    /// 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<String>,
+
+    /// 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<Callback<()>>,
+
+    /// 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<Vec<RemoteNodeStatus>>,
+}
+
+impl SubscriptionKeyGrid {
+    pub fn new() -> Self {
+        yew::props!(Self {})
+    }
+
+    pub fn on_change(mut self, cb: impl Into<Option<Callback<()>>>) -> Self {
+        self.on_change = cb.into();
+        self
+    }
+
+    pub fn node_status(mut self, statuses: Rc<Vec<RemoteNodeStatus>>) -> Self {
+        self.node_status = statuses;
+        self
+    }
+
+    pub fn pool_keys(mut self, keys: Rc<Vec<SubscriptionKeyEntry>>) -> Self {
+        self.pool_keys = keys;
+        self
+    }
+
+    pub fn pool_digest(mut self, digest: Option<String>) -> Self {
+        self.pool_digest = digest;
+        self
+    }
+}
+
+impl Default for SubscriptionKeyGrid {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl From<SubscriptionKeyGrid> for VNode {
+    fn from(val: SubscriptionKeyGrid) -> Self {
+        VComp::new::<LoadableComponentMaster<SubscriptionKeyGridComp>>(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<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")
+                .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")
+                .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<SubscriptionKeyEntry> {
+        let key = self.selection.selected_key()?;
+        self.store.read().lookup_record(&key).cloned()
+    }
+
+    fn create_add_dialog(&self, ctx: &LoadableComponentContext<Self>) -> 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<Self>,
+    ) -> 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>) -> 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<Self>, 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<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(
+                    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<Self>,
+        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<Self>,
+    ) -> Pin<Box<dyn Future<Output = Result<(), Error>>>> {
+        // 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<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| {
+                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::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, digest: Option<String>) -> 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"));
+    }
+
+    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<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,
+    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();
+    };
+
+    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 {
+                let excluded: Vec<String> = 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))
+                            .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<String>,
+) -> 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..513fa3a0
--- /dev/null
+++ b/ui/src/configuration/subscription_registry.rs
@@ -0,0 +1,1019 @@
+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};
+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"),
+    }
+}
+
+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<RemoteNodeStatus>) -> SlabTree<NodeTreeEntry> {
+    use std::collections::BTreeMap;
+
+    let mut by_remote: BTreeMap<String, Vec<RemoteNodeStatus>> = 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, 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[0].clone(),
+                standalone: true,
+            });
+        } else {
+            let mut remote_entry = root.append(NodeTreeEntry::Remote {
+                name: remote_name.clone(),
+                ty,
+                active,
+                total,
+            });
+            remote_entry.set_expanded(true);
+            for n in remote_nodes {
+                remote_entry.append(NodeTreeEntry::Node {
+                    data: n.clone(),
+                    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<SubscriptionRegistryProps> for VNode {
+    fn from(val: SubscriptionRegistryProps) -> Self {
+        VComp::new::<LoadableComponentMaster<SubscriptionRegistryComp>>(Rc::new(val), None).into()
+    }
+}
+
+pub enum Msg {
+    LoadFinished {
+        nodes: Vec<RemoteNodeStatus>,
+        keys: Vec<SubscriptionKeyEntry>,
+        digest: Option<String>,
+    },
+    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<i64>,
+    },
+}
+
+#[doc(hidden)]
+pub struct SubscriptionRegistryComp {
+    state: LoadableComponentState<ViewState>,
+    tree_store: TreeStore<NodeTreeEntry>,
+    tree_columns: Rc<Vec<DataTableHeader<NodeTreeEntry>>>,
+    proposal_columns: Rc<Vec<DataTableHeader<ProposedAssignment>>>,
+    node_selection: Selection,
+    last_node_data: Vec<RemoteNodeStatus>,
+    /// 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<Vec<SubscriptionKeyEntry>>,
+    /// 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<String>,
+}
+
+pwt::impl_deref_mut_property!(
+    SubscriptionRegistryComp,
+    state,
+    LoadableComponentState<ViewState>
+);
+
+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<K: Ord>(
+    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<NodeTreeEntry>) -> Rc<Vec<DataTableHeader<NodeTreeEntry>>> {
+        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")
+                .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<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(),
+        ])
+    }
+}
+
+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>) -> 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<Self>, 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::<AutoAssignProposal>(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::<Vec<ProposedAssignment>>(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::<Option<String>>(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::<serde_json::Value>(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<Self>) -> Option<Html> {
+        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));
+        }
+
+        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<Self>,
+    ) -> Pin<Box<dyn Future<Output = Result<(), Error>>>> {
+        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::<Vec<RemoteNodeStatus>>(NODE_STATUS_URL, None);
+            let keys_fut = http_get_full::<Vec<SubscriptionKeyEntry>>(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<Self>) -> 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<Self>,
+        view_state: &Self::ViewState,
+    ) -> Option<Html> {
+        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<Self>) -> 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")
+            .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<Self>) -> 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<String> {
+        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.
+    fn assign_target_for_selected_node(
+        &self,
+    ) -> Option<(String, String, pdm_api_types::remotes::RemoteType, Option<i64>)> {
+        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<Self>,
+        proposal: &AutoAssignProposal,
+    ) -> Html {
+        use pwt::widget::Dialog;
+
+        let store: Store<ProposedAssignment> = 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..94d37bbd 100644
--- a/ui/src/widget/pve_node_selector.rs
+++ b/ui/src/widget/pve_node_selector.rs
@@ -43,6 +43,11 @@ 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<Vec<String>>,
 }
 
 impl PveNodeSelector {
@@ -51,6 +56,11 @@ impl PveNodeSelector {
             remote: remote.into_prop_value()
         })
     }
+
+    pub fn excluded_nodes(mut self, nodes: Rc<Vec<String>>) -> Self {
+        self.excluded_nodes = nodes;
+        self
+    }
 }
 
 pub enum Msg {
@@ -60,6 +70,9 @@ pub enum Msg {
 pub struct PveNodeSelectorComp {
     _async_pool: AsyncPool,
     store: Store<ClusterNodeIndexResponse>,
+    /// 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<ClusterNodeIndexResponse>,
     last_err: Option<AttrValue>,
 }
 
@@ -69,6 +82,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<ClusterNodeIndexResponse> = 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 +110,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<Self>, msg: Self::Message) -> bool {
+    fn update(&mut self, ctx: &yew::Context<Self>, 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,6 +131,13 @@ impl Component for PveNodeSelectorComp {
         true
     }
 
+    fn changed(&mut self, ctx: &yew::Context<Self>, 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<Self>) -> yew::Html {
         let props = ctx.props();
         let err = self.last_err.clone();
-- 
2.47.3





  parent reply	other threads:[~2026-05-15  7:47 UTC|newest]

Thread overview: 16+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-05-15  7:43 [PATCH datacenter-manager v3 00/12] subscription key pool registry Thomas Lamprecht
2026-05-15  7:43 ` [PATCH datacenter-manager v3 01/12] api types: subscription level: render full names Thomas Lamprecht
2026-05-15  7:43 ` [PATCH datacenter-manager v3 02/12] pdm-client: add wait_for_local_task helper Thomas Lamprecht
2026-05-15 15:21   ` Wolfgang Bumiller
2026-05-15  7:43 ` [PATCH datacenter-manager v3 03/12] subscription: pool: add data model and config layer Thomas Lamprecht
2026-05-15 15:21   ` Wolfgang Bumiller
2026-05-15  7:43 ` [PATCH datacenter-manager v3 04/12] subscription: api: add key pool and node status endpoints Thomas Lamprecht
2026-05-15 15:21   ` Wolfgang Bumiller
2026-05-15  7:43 ` Thomas Lamprecht [this message]
2026-05-15  7:43 ` [PATCH datacenter-manager v3 06/12] cli: client: add subscription key pool management subcommands Thomas Lamprecht
2026-05-15  7:43 ` [PATCH datacenter-manager v3 07/12] docs: add subscription registry chapter Thomas Lamprecht
2026-05-15  7:43 ` [PATCH datacenter-manager v3 08/12] subscription: add Clear Key action and per-node revert Thomas Lamprecht
2026-05-15  7:43 ` [PATCH datacenter-manager v3 09/12] subscription: add Adopt Key action for foreign live subscriptions Thomas Lamprecht
2026-05-15  7:43 ` [PATCH datacenter-manager v3 10/12] subscription: add Adopt All bulk action Thomas Lamprecht
2026-05-15  7:43 ` [PATCH datacenter-manager v3 11/12] subscription: add Check Subscription action Thomas Lamprecht
2026-05-15  7:43 ` [RFC PATCH datacenter-manager v3 12/12] ui: registry: add Add-and-Assign wizard from Assign Key dialog 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=20260515074623.766766-6-t.lamprecht@proxmox.com \
    --to=t.lamprecht@proxmox.com \
    --cc=pdm-devel@lists.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