From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id E98701FF13F for ; Thu, 07 May 2026 09:25:03 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 8D795C612; Thu, 7 May 2026 09:25:03 +0200 (CEST) From: Thomas Lamprecht To: pdm-devel@lists.proxmox.com Subject: [PATCH 5/8] ui: add subscription registry with key pool and node status Date: Thu, 7 May 2026 09:17:28 +0200 Message-ID: <20260507072436.2649563-6-t.lamprecht@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260507072436.2649563-1-t.lamprecht@proxmox.com> References: <20260507072436.2649563-1-t.lamprecht@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1778138578292 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.997 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment KAM_MAILER 2 Automated Mailer Tag Left in Email SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: EHO2ADS4KASBVJBKAEJLUC25BXWGJ6TJ X-Message-ID-Hash: EHO2ADS4KASBVJBKAEJLUC25BXWGJ6TJ X-MailFrom: t.lamprecht@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: Add a top-level Subscription Registry view with a Key Pool panel next to a Node Status tree. The Add dialog takes a textarea so an operator can paste several keys at once, newline or comma separated; the backend validates the whole batch atomically. The Assign dialog filters the remote selector by the key's compatible remote type and pulls a node selector for the chosen remote. PMG and POM keys leave Assign disabled since PDM cannot push them to a remote yet. Pending assignments show up in the Node Status panel with a clock icon. Selecting a node there exposes Clear Assignment and Remove actions: an operator often thinks in terms of "this node is wrong" rather than tracking down the key entry on the left side. The Key Pool panel notifies the parent on every successful pool mutation so the Node Status tree reloads in lockstep, otherwise the right side keeps showing the pre-mutation view until the operator navigates away and back. Apply Pending shows an info toast on the no-op reply instead of opening a task dialog. Clear Pending hits the bulk backend endpoint rather than issuing per-key PUTs from the UI. Signed-off-by: Thomas Lamprecht --- ui/src/configuration/mod.rs | 2 + ui/src/configuration/subscription_keys.rs | 449 +++++++++++ ui/src/configuration/subscription_registry.rs | 713 ++++++++++++++++++ ui/src/main_menu.rs | 10 + 4 files changed, 1174 insertions(+) create mode 100644 ui/src/configuration/subscription_keys.rs create mode 100644 ui/src/configuration/subscription_registry.rs diff --git a/ui/src/configuration/mod.rs b/ui/src/configuration/mod.rs index 6ffb64b..4180111 100644 --- a/ui/src/configuration/mod.rs +++ b/ui/src/configuration/mod.rs @@ -13,7 +13,9 @@ mod permission_path_selector; mod webauthn; pub use webauthn::WebauthnPanel; +pub mod subscription_keys; pub mod subscription_panel; +pub mod subscription_registry; pub mod views; diff --git a/ui/src/configuration/subscription_keys.rs b/ui/src/configuration/subscription_keys.rs new file mode 100644 index 0000000..c535e94 --- /dev/null +++ b/ui/src/configuration/subscription_keys.rs @@ -0,0 +1,449 @@ +use std::future::Future; +use std::pin::Pin; +use std::rc::Rc; + +use anyhow::Error; + +use pdm_api_types::remotes::RemoteType; +use pdm_api_types::subscription::{ProductType, RemoteNodeStatus, SubscriptionKeyEntry}; +use yew::virtual_dom::{Key, VComp, VNode}; + +use proxmox_yew_comp::percent_encoding::percent_encode_component; +use proxmox_yew_comp::{http_delete, http_get, http_post, http_put, EditWindow}; +use proxmox_yew_comp::{ + LoadableComponent, LoadableComponentContext, LoadableComponentMaster, + LoadableComponentScopeExt, LoadableComponentState, +}; + +use pwt::css::FontStyle; +use pwt::prelude::*; +use pwt::state::{Selection, Store}; +use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader}; +use pwt::widget::form::{DisplayField, FormContext, TextArea}; +use pwt::widget::{Button, ConfirmDialog, Container, InputPanel, Toolbar}; + +use crate::widget::{PveNodeSelector, RemoteSelector}; + +const BASE_URL: &str = "/subscriptions/keys"; + +#[derive(Properties, PartialEq, Clone)] +pub struct SubscriptionKeyGrid { + /// Called after every successful pool mutation (add, assign, clear, remove). Lets the parent + /// view (the Subscription Registry) reload its own data so the Node Status side stays in + /// sync with the Key Pool side. + #[prop_or_default] + pub on_change: Option>, + + /// 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>, +} + +impl SubscriptionKeyGrid { + pub fn new() -> Self { + yew::props!(Self {}) + } + + pub fn on_change(mut self, cb: impl Into>>) -> Self { + self.on_change = cb.into(); + self + } + + pub fn node_status(mut self, statuses: Rc>) -> Self { + self.node_status = statuses; + self + } +} + +impl Default for SubscriptionKeyGrid { + fn default() -> Self { + Self::new() + } +} + +impl From for VNode { + fn from(val: SubscriptionKeyGrid) -> Self { + VComp::new::>(Rc::new(val), None).into() + } +} + +pub enum Msg { + LoadFinished(Vec), + Remove(Key), + Reload, +} + +#[derive(PartialEq)] +pub enum ViewState { + Add, + Assign, + Remove, +} + +#[doc(hidden)] +pub struct SubscriptionKeyGridComp { + state: LoadableComponentState, + store: Store, + columns: Rc>>, + selection: Selection, +} + +pwt::impl_deref_mut_property!( + SubscriptionKeyGridComp, + state, + LoadableComponentState +); + +impl SubscriptionKeyGridComp { + fn columns() -> Rc>> { + Rc::new(vec![ + DataTableColumn::new(tr!("Key")) + .flex(2) + .get_property(|entry: &SubscriptionKeyEntry| entry.key.as_str()) + .sort_order(true) + .into(), + DataTableColumn::new(tr!("Product")) + .width("80px") + .render(|entry: &SubscriptionKeyEntry| entry.product_type.to_string().into()) + .into(), + DataTableColumn::new(tr!("Level")) + .width("90px") + .render(|entry: &SubscriptionKeyEntry| entry.level.to_string().into()) + .into(), + DataTableColumn::new(tr!("Assignment")) + .flex(2) + .render( + |entry: &SubscriptionKeyEntry| match (&entry.remote, &entry.node) { + (Some(remote), Some(node)) => format!("{remote} / {node}").into(), + _ => Html::default(), + }, + ) + .into(), + ]) + } + + fn selected_entry(&self) -> Option { + let key = self.selection.selected_key()?; + self.store.read().lookup_record(&key).cloned() + } + + fn create_add_dialog(&self, ctx: &LoadableComponentContext) -> Html { + EditWindow::new(tr!("Add Subscription Keys")) + .renderer(|_form_ctx| add_input_panel()) + .on_submit(submit_add_keys) + .on_done(ctx.link().clone().callback(|_| Msg::Reload)) + .into() + } + + fn create_assign_dialog( + &self, + entry: &SubscriptionKeyEntry, + ctx: &LoadableComponentContext, + ) -> Html { + let key = entry.key.clone(); + let product_type = entry.product_type; + EditWindow::new(tr!("Assign Key to Remote")) + .renderer({ + let key = key.clone(); + move |form_ctx| assign_input_panel(&key, product_type, form_ctx) + }) + .on_submit({ + let key = key.clone(); + move |form| submit_assign(key.clone(), form) + }) + .on_done(ctx.link().clone().callback(|_| Msg::Reload)) + .into() + } +} + +impl LoadableComponent for SubscriptionKeyGridComp { + type Properties = SubscriptionKeyGrid; + type Message = Msg; + type ViewState = ViewState; + + fn create(ctx: &LoadableComponentContext) -> Self { + let selection = Selection::new().on_select({ + let link = ctx.link().clone(); + move |_| link.send_redraw() + }); + Self { + state: LoadableComponentState::new(), + store: Store::with_extract_key(|entry: &SubscriptionKeyEntry| { + entry.key.as_str().into() + }), + columns: Self::columns(), + selection, + } + } + + fn update(&mut self, ctx: &LoadableComponentContext, msg: Self::Message) -> bool { + match msg { + Msg::LoadFinished(data) => self.store.set_data(data), + Msg::Remove(key) => { + let id = key.to_string(); + let link = ctx.link().clone(); + ctx.link().spawn(async move { + let url = format!("{BASE_URL}/{}", percent_encode_component(&id)); + if let Err(err) = http_delete(&url, None).await { + link.show_error( + tr!("Error"), + tr!("Could not remove {id}: {err}", id = id, err = err), + true, + ); + } + link.send_message(Msg::Reload); + }); + } + Msg::Reload => { + ctx.link().change_view(None); + ctx.link().send_reload(); + if let Some(cb) = &ctx.props().on_change { + cb.emit(()); + } + } + } + true + } + + fn toolbar(&self, ctx: &LoadableComponentContext) -> Option { + let entry = self.selected_entry(); + let has_selection = entry.is_some(); + let is_assigned = entry.as_ref().map(|e| e.remote.is_some()).unwrap_or(false); + let synced_assignment = entry + .as_ref() + .map(|e| is_synced_assignment(e, &ctx.props().node_status)) + .unwrap_or(false); + let assignable = entry + .as_ref() + .map(|e| { + e.product_type.matches_remote_type(RemoteType::Pve) + || e.product_type.matches_remote_type(RemoteType::Pbs) + }) + .unwrap_or(false); + let link = ctx.link(); + + Some( + Toolbar::new() + .border_bottom(true) + .with_child( + Button::new(tr!("Add")) + .icon_class("fa fa-plus") + .on_activate(link.change_view_callback(|_| Some(ViewState::Add))), + ) + .with_spacer() + .with_child( + Button::new(tr!("Remove Key")) + .icon_class("fa fa-trash-o") + .disabled(!has_selection || synced_assignment) + .on_activate(link.change_view_callback(|_| Some(ViewState::Remove))), + ) + .with_spacer() + .with_child( + Button::new(tr!("Assign")) + .icon_class("fa fa-link") + .disabled(!has_selection || is_assigned || !assignable) + .on_activate(link.change_view_callback(|_| Some(ViewState::Assign))), + ) + .into(), + ) + } + + fn load( + &self, + ctx: &LoadableComponentContext, + ) -> Pin>>> { + let link = ctx.link().clone(); + Box::pin(async move { + let data: Vec = http_get(BASE_URL, None).await?; + link.send_message(Msg::LoadFinished(data)); + Ok(()) + }) + } + + fn main_view(&self, _ctx: &LoadableComponentContext) -> Html { + DataTable::new(self.columns.clone(), self.store.clone()) + .selection(self.selection.clone()) + .into() + } + + fn dialog_view( + &self, + ctx: &LoadableComponentContext, + view_state: &Self::ViewState, + ) -> Option { + match view_state { + ViewState::Add => Some(self.create_add_dialog(ctx)), + ViewState::Assign => self + .selected_entry() + .map(|entry| self.create_assign_dialog(&entry, ctx)), + ViewState::Remove => self.selection.selected_key().map(|key| { + ConfirmDialog::new( + tr!("Remove Key"), + tr!( + "Remove {key} from the key pool? This does not revoke the subscription.", + key = key.to_string(), + ), + ) + .on_confirm({ + let link = ctx.link().clone(); + let key = key.clone(); + move |_| link.send_message(Msg::Remove(key.clone())) + }) + .into() + }), + } + } +} + +/// Returns true when the pool entry's binding currently runs the same key on the remote and is +/// Active - meaning a clear-assignment would orphan the live subscription. Mirrors the +/// server-side gate; the operator should use Reissue Key in that state. +fn is_synced_assignment(entry: &SubscriptionKeyEntry, statuses: &[RemoteNodeStatus]) -> bool { + let (Some(remote), Some(node)) = (entry.remote.as_deref(), entry.node.as_deref()) else { + return false; + }; + statuses + .iter() + .find(|n| n.remote == remote && n.node == node) + .map(|n| { + n.status == proxmox_subscription::SubscriptionStatus::Active + && n.current_key.as_deref() == Some(entry.key.as_str()) + }) + .unwrap_or(false) +} + +fn add_input_panel() -> Html { + let hint = Container::new() + .class(FontStyle::TitleSmall) + .class(pwt::css::Opacity::Quarter) + .padding_top(2) + .with_child(tr!( + "One key per line, or comma-separated. Only Proxmox VE and Proxmox Backup Server keys are accepted." + )); + + // The textarea opts into `width: 100%` so it fills the InputPanel's grid cell instead of + // shrinking to browser-default cols. + InputPanel::new() + .padding(4) + .min_width(500) + .with_large_custom_child( + TextArea::new() + .name("keys") + .submit_empty(false) + .required(true) + .attribute("rows", "8") + .attribute("placeholder", tr!("Subscription key(s)")) + .style("width", "100%") + .style("box-sizing", "border-box"), + ) + .with_large_custom_child(hint) + .into() +} + +async fn submit_add_keys(form_ctx: FormContext) -> Result<(), Error> { + let raw = form_ctx.read().get_field_text("keys"); + let keys: Vec = raw + .split(|c: char| c.is_whitespace() || c == ',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(); + + if keys.is_empty() { + anyhow::bail!(tr!("no keys provided")); + } + + http_post(BASE_URL, Some(serde_json::json!({ "keys": keys }))).await +} + +/// Map a subscription product type to the remote type its keys can drive. +fn remote_type_for(product_type: ProductType) -> Option { + if product_type.matches_remote_type(RemoteType::Pve) { + Some(RemoteType::Pve) + } else if product_type.matches_remote_type(RemoteType::Pbs) { + Some(RemoteType::Pbs) + } else { + None + } +} + +fn assign_input_panel(key: &str, product_type: ProductType, form_ctx: &FormContext) -> Html { + let mut panel = InputPanel::new().padding(4).min_width(500).with_field( + tr!("Key"), + DisplayField::new() + .name("key") + .value(key.to_string()) + .key("key-display"), + ); + + let Some(remote_type) = remote_type_for(product_type) else { + // Defensive: the toolbar disables Assign for these product types. + return panel + .with_large_custom_child( + Container::new() + .class(FontStyle::TitleSmall) + .class(pwt::css::Opacity::Quarter) + .with_child(tr!( + "PDM cannot manage {product} remotes yet; this key is parked in the pool.", + product = product_type.to_string(), + )), + ) + .into(); + }; + + panel = panel.with_field( + tr!("Remote"), + RemoteSelector::new() + .name("remote") + .remote_type(remote_type) + .required(true), + ); + + match remote_type { + RemoteType::Pve => { + let selected_remote = form_ctx.read().get_field_text("remote"); + if selected_remote.is_empty() { + panel + .with_field( + tr!("Node"), + DisplayField::new() + .name("node") + .key("node-no-remote") + .value(AttrValue::from(tr!("Select a remote first."))), + ) + .into() + } else { + // `PveNodeSelector` fetches its node list in `create` and does not re-fetch on + // prop change, so a per-remote `key` forces a fresh component when the operator + // picks a target. + panel + .with_field( + tr!("Node"), + PveNodeSelector::new(selected_remote.clone()) + .name("node") + .key(format!("node-selector-{selected_remote}")) + .required(true), + ) + .into() + } + } + RemoteType::Pbs => panel + .with_field( + tr!("Node"), + DisplayField::new() + .name("node") + .value(AttrValue::from("localhost")) + .key("node-localhost"), + ) + .into(), + } +} + +async fn submit_assign(key: String, form_ctx: FormContext) -> Result<(), Error> { + let data = form_ctx.get_submit_data(); + let url = format!("{BASE_URL}/{}", percent_encode_component(&key)); + http_put(&url, Some(data)).await +} diff --git a/ui/src/configuration/subscription_registry.rs b/ui/src/configuration/subscription_registry.rs new file mode 100644 index 0000000..7ed96e6 --- /dev/null +++ b/ui/src/configuration/subscription_registry.rs @@ -0,0 +1,713 @@ +use std::future::Future; +use std::pin::Pin; +use std::rc::Rc; + +use anyhow::Error; + +use yew::virtual_dom::{Key, VComp, VNode}; + +use proxmox_yew_comp::percent_encoding::percent_encode_component; +use proxmox_yew_comp::{http_delete, http_get, http_post, http_put}; +use proxmox_yew_comp::{ + LoadableComponent, LoadableComponentContext, LoadableComponentMaster, + LoadableComponentScopeExt, LoadableComponentState, +}; + +use pwt::css::{AlignItems, Flex, FlexDirection, FlexFit, FontColor, JustifyContent, Overflow}; +use pwt::prelude::*; +use pwt::props::{ContainerBuilder, ExtractPrimaryKey, WidgetBuilder}; +use pwt::state::{Selection, SlabTree, Store, TreeStore}; +use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader}; +use pwt::widget::{Button, Column, Container, Fa, Panel, Row, Toolbar, Tooltip}; + +use pdm_api_types::subscription::{ProposedAssignment, RemoteNodeStatus, SubscriptionLevel}; + +use super::subscription_keys::SubscriptionKeyGrid; + +const NODE_STATUS_URL: &str = "/subscriptions/node-status"; +const AUTO_ASSIGN_URL: &str = "/subscriptions/auto-assign"; +const APPLY_PENDING_URL: &str = "/subscriptions/apply-pending"; +const CLEAR_PENDING_URL: &str = "/subscriptions/clear-pending"; + +/// Map a [`SubscriptionStatus`] to the icon shown in subscription panels. +/// +/// Public so the dashboard subscriptions panel can render the same icon for the same state +/// without redefining the mapping. The 4-variant `proxmox_yew_comp::Status` does not cover +/// every subscription state (New, Expired, Suspended need their own icons), hence the dedicated +/// helper. +pub fn subscription_status_icon(status: proxmox_subscription::SubscriptionStatus) -> Fa { + use proxmox_subscription::SubscriptionStatus as S; + match status { + S::Active => Fa::new("check-circle").class(FontColor::Success), + S::New => Fa::new("clock-o").class(FontColor::Primary), + S::NotFound => Fa::new("exclamation-circle").class(FontColor::Error), + S::Invalid => Fa::new("times-circle").class(FontColor::Warning), + S::Expired => Fa::new("clock-o").class(FontColor::Warning), + S::Suspended => Fa::new("ban").class(FontColor::Error), + } +} + +#[derive(Clone, Debug, PartialEq)] +enum NodeTreeEntry { + Root, + Remote { + name: String, + ty: pdm_api_types::remotes::RemoteType, + active: u32, + total: u32, + }, + Node { + data: RemoteNodeStatus, + /// If true, this is the only node in its remote and is shown at the top level under the + /// remote name instead of nested. + standalone: bool, + }, +} + +impl NodeTreeEntry { + fn name(&self) -> &str { + match self { + Self::Root => "", + Self::Remote { name, .. } => name, + Self::Node { data, standalone } => { + if *standalone { + &data.remote + } else { + &data.node + } + } + } + } +} + +impl ExtractPrimaryKey for NodeTreeEntry { + fn extract_key(&self) -> Key { + Key::from(match self { + NodeTreeEntry::Root => "/".to_string(), + NodeTreeEntry::Remote { name, .. } => format!("/{name}"), + NodeTreeEntry::Node { data, .. } => format!("/{}/{}", data.remote, data.node), + }) + } +} + +fn build_tree(nodes: Vec) -> SlabTree { + use std::collections::BTreeMap; + + let mut by_remote: BTreeMap> = 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 for VNode { + fn from(val: SubscriptionRegistryProps) -> Self { + VComp::new::>(Rc::new(val), None).into() + } +} + +pub enum Msg { + LoadFinished(Vec), + AutoAssignPreview, + AutoAssignApply, + ApplyPending, + ClearPending, + /// Clear the pool's pin on the currently-selected node by un-assigning its key. + ClearSelectedNode, + /// Remove the pool entry currently pinned to the selected node. + RemoveSelectedNodeKey, +} + +#[derive(PartialEq)] +pub enum ViewState { + ConfirmAutoAssign(Vec), + ConfirmClearPending, + ConfirmRemoveSelectedNodeKey(String), +} + +#[doc(hidden)] +pub struct SubscriptionRegistryComp { + state: LoadableComponentState, + tree_store: TreeStore, + tree_columns: Rc>>, + proposal_columns: Rc>>, + node_selection: Selection, + last_node_data: Vec, +} + +pwt::impl_deref_mut_property!( + SubscriptionRegistryComp, + state, + LoadableComponentState +); + +fn tree_sorter(a: &NodeTreeEntry, b: &NodeTreeEntry) -> std::cmp::Ordering { + a.name().cmp(b.name()) +} + +impl SubscriptionRegistryComp { + fn tree_columns(store: TreeStore) -> Rc>> { + Rc::new(vec![ + DataTableColumn::new(tr!("Name")) + .tree_column(store) + .flex(3) + .render(|entry: &NodeTreeEntry| { + let (icon, name) = match entry { + NodeTreeEntry::Root => return Html::default(), + NodeTreeEntry::Remote { name, ty, .. } => { + let icon = if *ty == pdm_api_types::remotes::RemoteType::Pbs { + "building-o" + } else { + "server" + }; + (icon, name.as_str()) + } + NodeTreeEntry::Node { + data: n, + standalone, + } => { + let icon = if n.ty == pdm_api_types::remotes::RemoteType::Pbs { + "building-o" + } else { + "building" + }; + let label = if *standalone { &n.remote } else { &n.node }; + (icon, label.as_str()) + } + }; + Row::new() + .class(AlignItems::Baseline) + .gap(2) + .with_child(Fa::new(icon)) + .with_child(name) + .into() + }) + .sorter(tree_sorter) + .into(), + DataTableColumn::new(tr!("Sockets")) + .width("70px") + .render(|entry: &NodeTreeEntry| match entry { + NodeTreeEntry::Node { data: n, .. } => { + n.sockets.map(|s| s.to_string()).unwrap_or_default().into() + } + _ => Html::default(), + }) + .into(), + DataTableColumn::new(tr!("Status")) + .width("120px") + .render(|entry: &NodeTreeEntry| match entry { + NodeTreeEntry::Node { data: n, .. } => Row::new() + .class(AlignItems::Baseline) + .gap(2) + .with_child(subscription_status_icon(n.status)) + .with_child(n.status.to_string()) + .into(), + NodeTreeEntry::Remote { active, total, .. } => { + let icon = if active == total { + Fa::new("check-circle").class(FontColor::Success) + } else if *active == 0 { + Fa::new("exclamation-circle").class(FontColor::Error) + } else { + Fa::new("exclamation-triangle").class(FontColor::Warning) + }; + Tooltip::new( + Row::new() + .class(AlignItems::Baseline) + .gap(2) + .with_child(icon) + .with_child(format!("{active}/{total}")), + ) + .tip(tr!( + "{active} of {total} nodes subscribed", + active = active, + total = total, + )) + .into() + } + _ => Html::default(), + }) + .into(), + DataTableColumn::new(tr!("Level")) + .width("90px") + .render(|entry: &NodeTreeEntry| match entry { + NodeTreeEntry::Node { data: n, .. } if n.level != SubscriptionLevel::None => { + n.level.to_string().into() + } + _ => Html::default(), + }) + .into(), + DataTableColumn::new(tr!("Key")) + .flex(2) + .render(|entry: &NodeTreeEntry| match entry { + NodeTreeEntry::Node { data: n, .. } => key_cell(n), + _ => Html::default(), + }) + .into(), + ]) + } + + fn proposal_columns() -> Rc>> { + Rc::new(vec![ + DataTableColumn::new(tr!("Remote / Node")) + .flex(2) + .render(|p: &ProposedAssignment| format!("{} / {}", p.remote, p.node).into()) + .into(), + DataTableColumn::new(tr!("Key")) + .flex(2) + .render(|p: &ProposedAssignment| { + Container::from_tag("span") + .class(pwt::css::FontStyle::LabelMedium) + .with_child(p.key.clone()) + .into() + }) + .into(), + DataTableColumn::new(tr!("Sockets (node / key)")) + .width("160px") + .render(|p: &ProposedAssignment| { + let label = match (p.node_sockets, p.key_sockets) { + (Some(ns), Some(ks)) => format!("{ns} / {ks}"), + (Some(ns), None) => format!("{ns} / -"), + (None, Some(ks)) => format!("- / {ks}"), + _ => String::new(), + }; + label.into() + }) + .into(), + ]) + } +} + +fn key_cell(n: &RemoteNodeStatus) -> Html { + let assigned = n.assigned_key.as_deref(); + let current = n.current_key.as_deref(); + + // Pending = pool key assigned but the node doesn't have an active subscription yet (the key + // still needs to be pushed). + let pending = + assigned.is_some() && n.status != proxmox_subscription::SubscriptionStatus::Active; + + match (assigned, current) { + (Some(a), Some(c)) if a != c => Row::new() + .class(AlignItems::Baseline) + .gap(2) + .with_child(Fa::new("clock-o").class(FontColor::Warning)) + .with_child(format!("{a} \u{2192} {c}")) + .into(), + _ => { + let text = current.or(assigned).unwrap_or(""); + if pending { + Row::new() + .class(AlignItems::Baseline) + .gap(2) + .with_child(Fa::new("clock-o").class(FontColor::Warning)) + .with_child(text) + .into() + } else { + text.into() + } + } + } +} + +impl LoadableComponent for SubscriptionRegistryComp { + type Properties = SubscriptionRegistryProps; + type Message = Msg; + type ViewState = ViewState; + + fn create(ctx: &LoadableComponentContext) -> Self { + let store = TreeStore::new().view_root(false); + store.set_sorter(tree_sorter); + + let node_selection = Selection::new().on_select({ + let link = ctx.link().clone(); + move |_| link.send_redraw() + }); + + Self { + state: LoadableComponentState::new(), + tree_store: store.clone(), + tree_columns: Self::tree_columns(store), + proposal_columns: Self::proposal_columns(), + node_selection, + last_node_data: Vec::new(), + } + } + + fn update(&mut self, ctx: &LoadableComponentContext, msg: Self::Message) -> bool { + match msg { + Msg::LoadFinished(data) => { + self.last_node_data = data.clone(); + let tree = build_tree(data); + self.tree_store.write().update_root_tree(tree); + } + Msg::AutoAssignPreview => { + let link = ctx.link().clone(); + ctx.link().spawn(async move { + match http_post::>(AUTO_ASSIGN_URL, None).await { + Ok(proposals) if proposals.is_empty() => { + link.show_error( + tr!("Auto-Assign"), + tr!("No suitable unassigned keys for the remaining nodes."), + false, + ); + } + Ok(proposals) => { + link.change_view(Some(ViewState::ConfirmAutoAssign(proposals))); + } + Err(err) => link.show_error(tr!("Auto-Assign"), err.to_string(), true), + } + }); + } + Msg::AutoAssignApply => { + let link = ctx.link().clone(); + ctx.link().spawn(async move { + let url = format!("{AUTO_ASSIGN_URL}?apply=1"); + match http_post::>(&url, None).await { + Ok(_) => { + link.change_view(None); + link.send_reload(); + } + Err(err) => link.show_error(tr!("Auto-Assign"), err.to_string(), true), + } + }); + } + Msg::ApplyPending => { + let link = ctx.link().clone(); + ctx.link().spawn(async move { + match http_post::>(APPLY_PENDING_URL, None).await { + Ok(None) => link.show_error( + tr!("Apply Pending"), + tr!("No pending assignments. Every assigned key is already active on its remote node."), + false, + ), + Ok(Some(upid)) => link.show_task_progres(upid), + Err(err) => link.show_error(tr!("Apply"), err.to_string(), true), + } + }); + } + Msg::ClearPending => { + let link = ctx.link().clone(); + ctx.link().spawn(async move { + match http_post::(CLEAR_PENDING_URL, None).await { + Ok(_) => { + link.change_view(None); + link.send_reload(); + } + Err(err) => link.show_error(tr!("Clear Pending"), err.to_string(), true), + } + }); + } + Msg::ClearSelectedNode => { + let Some(key) = self.selected_assigned_key() else { + return false; + }; + let link = ctx.link().clone(); + ctx.link().spawn(async move { + let url = format!("/subscriptions/keys/{}", percent_encode_component(&key),); + if let Err(err) = http_put::<()>(&url, Some(serde_json::json!({}))).await { + link.show_error(tr!("Clear Assignment"), err.to_string(), true); + } + link.send_reload(); + }); + } + Msg::RemoveSelectedNodeKey => { + let Some(key) = self.selected_assigned_key() else { + return false; + }; + ctx.link() + .change_view(Some(ViewState::ConfirmRemoveSelectedNodeKey(key))); + } + } + true + } + + fn toolbar(&self, ctx: &LoadableComponentContext) -> Option { + let link = ctx.link(); + Some( + Toolbar::new() + .border_bottom(true) + .with_child( + Button::new(tr!("Auto-Assign")) + .icon_class("fa fa-magic") + .on_activate(link.callback(|_| Msg::AutoAssignPreview)), + ) + .with_spacer() + .with_child( + Button::new(tr!("Apply Pending")) + .icon_class("fa fa-play") + .on_activate(link.callback(|_| Msg::ApplyPending)), + ) + .with_child( + Button::new(tr!("Clear Pending")) + .icon_class("fa fa-eraser") + .on_activate( + link.change_view_callback(|_| Some(ViewState::ConfirmClearPending)), + ), + ) + .with_flex_spacer() + .with_child( + Button::refresh(ctx.loading()).on_activate({ + let link = link.clone(); + move |_| link.send_reload() + }), + ) + .into(), + ) + } + + fn load( + &self, + ctx: &LoadableComponentContext, + ) -> Pin>>> { + let link = ctx.link().clone(); + Box::pin(async move { + let data: Vec = http_get(NODE_STATUS_URL, None).await?; + link.send_message(Msg::LoadFinished(data)); + Ok(()) + }) + } + + fn main_view(&self, ctx: &LoadableComponentContext) -> 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, + view_state: &Self::ViewState, + ) -> Option { + match view_state { + ViewState::ConfirmClearPending => { + use pwt::widget::ConfirmDialog; + Some( + ConfirmDialog::new( + tr!("Clear Pending Assignments"), + tr!("Remove all assignments that have not yet been applied to the remote nodes?"), + ) + .on_confirm({ + let link = ctx.link().clone(); + move |_| link.send_message(Msg::ClearPending) + }) + .into(), + ) + } + ViewState::ConfirmAutoAssign(proposals) => { + Some(self.render_auto_assign_dialog(ctx, proposals)) + } + ViewState::ConfirmRemoveSelectedNodeKey(key) => { + use pwt::widget::ConfirmDialog; + let link = ctx.link().clone(); + let key_for_callback = key.clone(); + Some( + ConfirmDialog::new( + tr!("Remove Key"), + tr!( + "Remove {key} from the key pool? This does not revoke the subscription on the remote node.", + key = key.clone(), + ), + ) + .on_confirm(move |_| { + let link = link.clone(); + let key = key_for_callback.clone(); + link.clone().spawn(async move { + let url = format!( + "/subscriptions/keys/{}", + percent_encode_component(&key), + ); + if let Err(err) = http_delete(&url, None).await { + link.show_error(tr!("Remove Key"), err.to_string(), true); + } + link.change_view(None); + link.send_reload(); + }); + }) + .into(), + ) + } + } + } +} + +impl SubscriptionRegistryComp { + fn render_key_pool_panel(&self, ctx: &LoadableComponentContext) -> Panel { + // Reload the right-side node tree whenever the left-side key pool mutates, so a fresh + // assignment shows up as pending without forcing the operator to re-navigate. + let link = ctx.link().clone(); + // Pass the current node-status snapshot into the grid so its Clear button can be + // disabled for synced bindings (orphan-prevention - mirrors the server-side refusal). + let statuses = Rc::new(self.last_node_data.clone()); + Panel::new() + .class(FlexFit) + .border(true) + .style("flex", "3 1 0") + .min_width(300) + .title(tr!("Key Pool")) + .with_child( + SubscriptionKeyGrid::new() + .on_change(Callback::from(move |_| link.send_reload())) + .node_status(statuses), + ) + } + + fn render_node_tree_panel(&self, ctx: &LoadableComponentContext) -> Panel { + let table = DataTable::new(self.tree_columns.clone(), self.tree_store.clone()) + .selection(self.node_selection.clone()) + .striped(false) + .borderless(true) + .show_header(true) + .class(FlexFit); + + let has_assigned = self.selected_assigned_key().is_some(); + let clear_button = Button::new(tr!("Clear Assignment")) + .icon_class("fa fa-unlink") + .disabled(!has_assigned) + .on_activate(ctx.link().callback(|_| Msg::ClearSelectedNode)); + let remove_button = Button::new(tr!("Remove")) + .icon_class("fa fa-trash-o") + .disabled(!has_assigned) + .on_activate(ctx.link().callback(|_| Msg::RemoveSelectedNodeKey)); + + Panel::new() + .class(FlexFit) + .border(true) + .style("flex", "4 1 0") + .min_width(400) + .title(tr!("Node Status")) + .with_tool(clear_button) + .with_tool(remove_button) + .with_child(table) + } + + /// Pool key currently assigned to whatever node the operator selected in the tree. + /// + /// Returns None when no node row is selected, the selected entry is a remote-level + /// aggregate, or the node has no pool assignment. + fn selected_assigned_key(&self) -> Option { + let key = self.node_selection.selected_key()?; + let raw = key.to_string(); + let mut parts = raw.trim_start_matches('/').splitn(2, '/'); + let remote = parts.next()?; + let node = parts.next()?; + self.last_node_data + .iter() + .find(|n| n.remote == remote && n.node == node) + .and_then(|n| n.assigned_key.clone()) + } + + fn render_auto_assign_dialog( + &self, + ctx: &LoadableComponentContext, + proposals: &[ProposedAssignment], + ) -> Html { + use pwt::widget::Dialog; + + let store: Store = Store::with_extract_key(|p: &ProposedAssignment| { + format!("{}/{}", p.remote, p.node).into() + }); + store.set_data(proposals.to_vec()); + + let link_close = ctx.link().clone(); + let link_apply = ctx.link().clone(); + let body = Column::new() + .class(Flex::Fill) + .class(Overflow::Hidden) + .min_height(0) + .padding(2) + .gap(2) + .min_width(600) + .with_child(Container::from_tag("p").with_child(tr!( + "The following {n} assignments are proposed. Click Apply to confirm.", + n = proposals.len(), + ))) + .with_child( + DataTable::new(self.proposal_columns.clone(), store) + .striped(true) + .class(FlexFit) + .min_height(140), + ) + .with_child( + Row::new() + .class(JustifyContent::FlexEnd) + .gap(2) + .padding_top(2) + .with_child( + Button::new(tr!("Cancel")) + .on_activate(move |_| link_close.change_view(None)), + ) + .with_child( + Button::new(tr!("Apply")) + .on_activate(move |_| link_apply.send_message(Msg::AutoAssignApply)), + ), + ); + + Dialog::new(tr!("Auto-Assign Proposal")) + .resizable(true) + .width(700) + .min_width(500) + .min_height(300) + .max_height("80vh") + .on_close({ + let link = ctx.link().clone(); + move |_| link.change_view(None) + }) + .with_child(body) + .into() + } +} diff --git a/ui/src/main_menu.rs b/ui/src/main_menu.rs index 18988ea..eba02d5 100644 --- a/ui/src/main_menu.rs +++ b/ui/src/main_menu.rs @@ -15,6 +15,7 @@ use pdm_api_types::remotes::RemoteType; use pdm_api_types::{PRIV_SYS_AUDIT, PRIV_SYS_MODIFY}; use crate::configuration::subscription_panel::SubscriptionPanel; +use crate::configuration::subscription_registry::SubscriptionRegistryProps; use crate::configuration::views::ViewGrid; use crate::dashboard::view::View; use crate::remotes::RemotesPanel; @@ -292,6 +293,15 @@ impl Component for PdmMainMenu { config_submenu, ); + register_view( + &mut menu, + &mut content, + tr!("Subscription Registry"), + "subscription-registry", + Some("fa fa-id-card"), + |_| SubscriptionRegistryProps::new().into(), + ); + let mut admin_submenu = Menu::new(); register_view( -- 2.47.3