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
next prev 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