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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.