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 v2 5/8] ui: add subscription registry with key pool and node status
Date: Thu,  7 May 2026 10:26:46 +0200	[thread overview]
Message-ID: <20260507082943.2749725-6-t.lamprecht@proxmox.com> (raw)
In-Reply-To: <20260507082943.2749725-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, newline or comma separated; the backend validates the
whole batch atomically. The Assign dialog filters the remote
selector by the key's compatible remote type and pulls a node
selector for the chosen remote. PMG and POM keys leave Assign
disabled since PDM cannot push them to a remote yet.

Pending assignments show up in the Node Status panel with a clock
icon. Selecting a node there exposes Clear Assignment and Remove
actions: an operator often thinks in terms of "this node is wrong"
rather than tracking down the key entry on the left side.

The Key Pool panel notifies the parent on every successful pool
mutation so the Node Status tree reloads in lockstep, otherwise the
right side keeps showing the pre-mutation view until the operator
navigates away and back.

Apply Pending shows an info toast on the no-op reply instead of
opening a task dialog. Clear Pending hits the bulk backend endpoint
rather than issuing per-key PUTs from the UI.

Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
 ui/src/configuration/mod.rs                   |   2 +
 ui/src/configuration/subscription_keys.rs     | 449 +++++++++++
 ui/src/configuration/subscription_registry.rs | 713 ++++++++++++++++++
 ui/src/main_menu.rs                           |  10 +
 4 files changed, 1174 insertions(+)
 create mode 100644 ui/src/configuration/subscription_keys.rs
 create mode 100644 ui/src/configuration/subscription_registry.rs

diff --git a/ui/src/configuration/mod.rs b/ui/src/configuration/mod.rs
index 6ffb64b..4180111 100644
--- a/ui/src/configuration/mod.rs
+++ b/ui/src/configuration/mod.rs
@@ -13,7 +13,9 @@ mod permission_path_selector;
 mod webauthn;
 pub use webauthn::WebauthnPanel;
 
+pub mod subscription_keys;
 pub mod subscription_panel;
+pub mod subscription_registry;
 
 pub mod views;
 
diff --git a/ui/src/configuration/subscription_keys.rs b/ui/src/configuration/subscription_keys.rs
new file mode 100644
index 0000000..c535e94
--- /dev/null
+++ b/ui/src/configuration/subscription_keys.rs
@@ -0,0 +1,449 @@
+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_get, http_post, http_put, 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};
+
+use crate::widget::{PveNodeSelector, RemoteSelector};
+
+const BASE_URL: &str = "/subscriptions/keys";
+
+#[derive(Properties, PartialEq, Clone)]
+pub struct SubscriptionKeyGrid {
+    /// 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
+    }
+}
+
+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 {
+    LoadFinished(Vec<SubscriptionKeyEntry>),
+    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")
+                .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(),
+                        _ => 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 {
+                        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?;
+            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
+}
+
+/// 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
+}
diff --git a/ui/src/configuration/subscription_registry.rs b/ui/src/configuration/subscription_registry.rs
new file mode 100644
index 0000000..7ed96e6
--- /dev/null
+++ b/ui/src/configuration/subscription_registry.rs
@@ -0,0 +1,713 @@
+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_post, http_put};
+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::{ProposedAssignment, RemoteNodeStatus, SubscriptionLevel};
+
+use super::subscription_keys::SubscriptionKeyGrid;
+
+const NODE_STATUS_URL: &str = "/subscriptions/node-status";
+const AUTO_ASSIGN_URL: &str = "/subscriptions/auto-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),
+    }
+}
+
+#[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(Vec<RemoteNodeStatus>),
+    AutoAssignPreview,
+    AutoAssignApply,
+    ApplyPending,
+    ClearPending,
+    /// Clear the pool's pin on the currently-selected node by un-assigning its key.
+    ClearSelectedNode,
+    /// Remove the pool entry currently pinned to the selected node.
+    RemoveSelectedNodeKey,
+}
+
+#[derive(PartialEq)]
+pub enum ViewState {
+    ConfirmAutoAssign(Vec<ProposedAssignment>),
+    ConfirmClearPending,
+    ConfirmRemoveSelectedNodeKey(String),
+}
+
+#[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>,
+}
+
+pwt::impl_deref_mut_property!(
+    SubscriptionRegistryComp,
+    state,
+    LoadableComponentState<ViewState>
+);
+
+fn tree_sorter(a: &NodeTreeEntry, b: &NodeTreeEntry) -> std::cmp::Ordering {
+    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")
+                .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("120px")
+                .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(n.status.to_string())
+                        .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")
+                .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)
+                .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();
+
+    // Pending = pool key assigned but the node doesn't have an active subscription yet (the key
+    // still needs to be pushed).
+    let pending =
+        assigned.is_some() && n.status != proxmox_subscription::SubscriptionStatus::Active;
+
+    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(),
+        }
+    }
+
+    fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
+        match msg {
+            Msg::LoadFinished(data) => {
+                self.last_node_data = data.clone();
+                let tree = build_tree(data);
+                self.tree_store.write().update_root_tree(tree);
+            }
+            Msg::AutoAssignPreview => {
+                let link = ctx.link().clone();
+                ctx.link().spawn(async move {
+                    match http_post::<Vec<ProposedAssignment>>(AUTO_ASSIGN_URL, None).await {
+                        Ok(proposals) if proposals.is_empty() => {
+                            link.show_error(
+                                tr!("Auto-Assign"),
+                                tr!("No suitable unassigned keys for the remaining nodes."),
+                                false,
+                            );
+                        }
+                        Ok(proposals) => {
+                            link.change_view(Some(ViewState::ConfirmAutoAssign(proposals)));
+                        }
+                        Err(err) => link.show_error(tr!("Auto-Assign"), err.to_string(), true),
+                    }
+                });
+            }
+            Msg::AutoAssignApply => {
+                let link = ctx.link().clone();
+                ctx.link().spawn(async move {
+                    let url = format!("{AUTO_ASSIGN_URL}?apply=1");
+                    match http_post::<Vec<ProposedAssignment>>(&url, None).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();
+                ctx.link().spawn(async move {
+                    match http_post::<Option<String>>(APPLY_PENDING_URL, None).await {
+                        Ok(None) => link.show_error(
+                            tr!("Apply Pending"),
+                            tr!("No pending assignments. Every assigned key is already active on its remote node."),
+                            false,
+                        ),
+                        Ok(Some(upid)) => link.show_task_progres(upid),
+                        Err(err) => link.show_error(tr!("Apply"), err.to_string(), true),
+                    }
+                });
+            }
+            Msg::ClearPending => {
+                let link = ctx.link().clone();
+                ctx.link().spawn(async move {
+                    match http_post::<serde_json::Value>(CLEAR_PENDING_URL, None).await {
+                        Ok(_) => {
+                            link.change_view(None);
+                            link.send_reload();
+                        }
+                        Err(err) => link.show_error(tr!("Clear Pending"), err.to_string(), true),
+                    }
+                });
+            }
+            Msg::ClearSelectedNode => {
+                let Some(key) = self.selected_assigned_key() else {
+                    return false;
+                };
+                let link = ctx.link().clone();
+                ctx.link().spawn(async move {
+                    let url = format!("/subscriptions/keys/{}", percent_encode_component(&key),);
+                    if let Err(err) = http_put::<()>(&url, Some(serde_json::json!({}))).await {
+                        link.show_error(tr!("Clear Assignment"), err.to_string(), true);
+                    }
+                    link.send_reload();
+                });
+            }
+            Msg::RemoveSelectedNodeKey => {
+                let Some(key) = self.selected_assigned_key() else {
+                    return false;
+                };
+                ctx.link()
+                    .change_view(Some(ViewState::ConfirmRemoveSelectedNodeKey(key)));
+            }
+        }
+        true
+    }
+
+    fn toolbar(&self, ctx: &LoadableComponentContext<Self>) -> Option<Html> {
+        let link = ctx.link();
+        Some(
+            Toolbar::new()
+                .border_bottom(true)
+                .with_child(
+                    Button::new(tr!("Auto-Assign"))
+                        .icon_class("fa fa-magic")
+                        .on_activate(link.callback(|_| Msg::AutoAssignPreview)),
+                )
+                .with_spacer()
+                .with_child(
+                    Button::new(tr!("Apply Pending"))
+                        .icon_class("fa fa-play")
+                        .on_activate(link.callback(|_| Msg::ApplyPending)),
+                )
+                .with_child(
+                    Button::new(tr!("Clear Pending"))
+                        .icon_class("fa fa-eraser")
+                        .on_activate(
+                            link.change_view_callback(|_| Some(ViewState::ConfirmClearPending)),
+                        ),
+                )
+                .with_flex_spacer()
+                .with_child(
+                    Button::refresh(ctx.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 {
+            let data: Vec<RemoteNodeStatus> = http_get(NODE_STATUS_URL, None).await?;
+            link.send_message(Msg::LoadFinished(data));
+            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::ConfirmClearPending => {
+                use pwt::widget::ConfirmDialog;
+                Some(
+                    ConfirmDialog::new(
+                        tr!("Clear Pending Assignments"),
+                        tr!("Remove all assignments that have not yet been applied to the remote nodes?"),
+                    )
+                    .on_confirm({
+                        let link = ctx.link().clone();
+                        move |_| link.send_message(Msg::ClearPending)
+                    })
+                    .into(),
+                )
+            }
+            ViewState::ConfirmAutoAssign(proposals) => {
+                Some(self.render_auto_assign_dialog(ctx, proposals))
+            }
+            ViewState::ConfirmRemoveSelectedNodeKey(key) => {
+                use pwt::widget::ConfirmDialog;
+                let link = ctx.link().clone();
+                let key_for_callback = key.clone();
+                Some(
+                    ConfirmDialog::new(
+                        tr!("Remove Key"),
+                        tr!(
+                            "Remove {key} from the key pool? This does not revoke the subscription on the remote node.",
+                            key = key.clone(),
+                        ),
+                    )
+                    .on_confirm(move |_| {
+                        let link = link.clone();
+                        let key = key_for_callback.clone();
+                        link.clone().spawn(async move {
+                            let url = format!(
+                                "/subscriptions/keys/{}",
+                                percent_encode_component(&key),
+                            );
+                            if let Err(err) = http_delete(&url, None).await {
+                                link.show_error(tr!("Remove Key"), err.to_string(), true);
+                            }
+                            link.change_view(None);
+                            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),
+            )
+    }
+
+    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 has_assigned = self.selected_assigned_key().is_some();
+        let clear_button = Button::new(tr!("Clear Assignment"))
+            .icon_class("fa fa-unlink")
+            .disabled(!has_assigned)
+            .on_activate(ctx.link().callback(|_| Msg::ClearSelectedNode));
+        let remove_button = Button::new(tr!("Remove"))
+            .icon_class("fa fa-trash-o")
+            .disabled(!has_assigned)
+            .on_activate(ctx.link().callback(|_| Msg::RemoveSelectedNodeKey));
+
+        Panel::new()
+            .class(FlexFit)
+            .border(true)
+            .style("flex", "4 1 0")
+            .min_width(400)
+            .title(tr!("Node Status"))
+            .with_tool(clear_button)
+            .with_tool(remove_button)
+            .with_child(table)
+    }
+
+    /// Pool key currently assigned to whatever node the operator selected in the tree.
+    ///
+    /// Returns None when no node row is selected, the selected entry is a remote-level
+    /// aggregate, or the node has no pool assignment.
+    fn selected_assigned_key(&self) -> Option<String> {
+        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)
+            .and_then(|n| n.assigned_key.clone())
+    }
+
+    fn render_auto_assign_dialog(
+        &self,
+        ctx: &LoadableComponentContext<Self>,
+        proposals: &[ProposedAssignment],
+    ) -> Html {
+        use pwt::widget::Dialog;
+
+        let store: Store<ProposedAssignment> = Store::with_extract_key(|p: &ProposedAssignment| {
+            format!("{}/{}", p.remote, p.node).into()
+        });
+        store.set_data(proposals.to_vec());
+
+        let link_close = ctx.link().clone();
+        let link_apply = ctx.link().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 Apply to confirm.",
+                n = proposals.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!("Apply"))
+                            .on_activate(move |_| link_apply.send_message(Msg::AutoAssignApply)),
+                    ),
+            );
+
+        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 18988ea..eba02d5 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(
-- 
2.47.3





  parent reply	other threads:[~2026-05-07  8:30 UTC|newest]

Thread overview: 15+ 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-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-07  8:26 ` Thomas Lamprecht [this message]
2026-05-07  8:26 ` [PATCH datacenter-manager v2 6/8] cli: add subscription key pool management subcommands Thomas Lamprecht
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-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

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=20260507082943.2749725-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