* Re: [PATCH datacenter-manager] ui: tree-wide: fix spelling of show-task_progress() function
2026-06-17 12:41 14% [PATCH datacenter-manager] ui: tree-wide: fix spelling of show-task_progress() function Shannon Sterz
@ 2026-06-17 13:46 0% ` Dominik Csapak
0 siblings, 0 replies; 19+ results
From: Dominik Csapak @ 2026-06-17 13:46 UTC (permalink / raw)
To: Shannon Sterz, pdm-devel
needed that for testing building with yew-comp from master
so consider this (not applying since it's not bumped yet..)
thanks!
Reviewed-by: Dominik Csapak <d.csapak@proxmox.com>
On 6/17/26 2:40 PM, Shannon Sterz wrote:
> the commit 51a9489dd418 (loadable component: fix show_task_progress
> method name spelling) fixed the name of this function. adapt to it so
> pdm ui will build against yew-comp once this change is bumped.
>
> Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
> ---
>
> Notes:
> i know the change this is adapting to isn't bumped yet, so applying it
> before that will break the build. however, i could not find anything
> like this on list [1] so i thought i'll send a fix for this before
> it's accidentally missed.
>
> [1]: https://lore.proxmox.com/all/?q=show_task_progres
>
> ui/src/configuration/subscription_registry.rs | 2 +-
> ui/src/guests.rs | 2 +-
> ui/src/pve/tree.rs | 6 +++---
> ui/src/remotes/updates.rs | 2 +-
> ui/src/widget/snapshot_window.rs | 2 +-
> 5 files changed, 7 insertions(+), 7 deletions(-)
>
> diff --git a/ui/src/configuration/subscription_registry.rs b/ui/src/configuration/subscription_registry.rs
> index 470789bc..32343c15 100644
> --- a/ui/src/configuration/subscription_registry.rs
> +++ b/ui/src/configuration/subscription_registry.rs
> @@ -885,7 +885,7 @@ impl LoadableComponent for SubscriptionRegistryComp {
> Ok(None) => link.change_view(None),
> Ok(Some(upid)) => {
> link.change_view(None);
> - link.show_task_progres(upid);
> + link.show_task_progress(upid);
> }
> Err(err) => link.show_error(tr!("Apply Pending"), err.to_string(), true),
> }
> diff --git a/ui/src/guests.rs b/ui/src/guests.rs
> index f11f0de5..ef670a00 100644
> --- a/ui/src/guests.rs
> +++ b/ui/src/guests.rs
> @@ -340,7 +340,7 @@ impl LoadableComponent for GuestPanelComp {
> // another remote that ran concurrently cannot point the task
> // viewer at the wrong remote
> self.set_task_base_url(format!("/pve/remotes/{}/tasks", upid.remote()).into());
> - ctx.link().show_task_progres(upid.to_string());
> + ctx.link().show_task_progress(upid.to_string());
> }
> Msg::GuestAction(action, key) => {
> let Some(entry) = self.store.read().lookup_record(&key).cloned() else {
> diff --git a/ui/src/pve/tree.rs b/ui/src/pve/tree.rs
> index 7a14a05b..0042331c 100644
> --- a/ui/src/pve/tree.rs
> +++ b/ui/src/pve/tree.rs
> @@ -362,7 +362,7 @@ impl LoadableComponent for PveTreeComp {
> };
>
> match res {
> - Ok(upid) => link.show_task_progres(upid.to_string()),
> + Ok(upid) => link.show_task_progress(upid.to_string()),
> Err(err) => link.show_error(tr!("Error"), err.to_string(), true),
> }
> }),
> @@ -386,7 +386,7 @@ impl LoadableComponent for PveTreeComp {
> };
>
> match res {
> - Ok(upid) => link.show_task_progres(upid.to_string()),
> + Ok(upid) => link.show_task_progress(upid.to_string()),
> Err(err) => link.show_error(tr!("Error"), err.to_string(), true),
> }
> }),
> @@ -566,7 +566,7 @@ impl LoadableComponent for PveTreeComp {
> .on_close(ctx.link().change_view_callback(|_| None))
> .on_submit({
> let link = ctx.link().clone();
> - move |upid: RemoteUpid| link.show_task_progres(upid.to_string())
> + move |upid: RemoteUpid| link.show_task_progress(upid.to_string())
> })
> .into(),
> ),
> diff --git a/ui/src/remotes/updates.rs b/ui/src/remotes/updates.rs
> index dbb9f700..0aca00e6 100644
> --- a/ui/src/remotes/updates.rs
> +++ b/ui/src/remotes/updates.rs
> @@ -428,7 +428,7 @@ impl LoadableComponent for UpdateTreeComponent {
>
> match client.refresh_remote_update_summary().await {
> Ok(upid) => {
> - link.show_task_progres(upid.to_string());
> + link.show_task_progress(upid.to_string());
> }
> Err(err) => {
> link.show_error(tr!("Could not refresh update status."), err, false);
> diff --git a/ui/src/widget/snapshot_window.rs b/ui/src/widget/snapshot_window.rs
> index b14b912f..71b0bc01 100644
> --- a/ui/src/widget/snapshot_window.rs
> +++ b/ui/src/widget/snapshot_window.rs
> @@ -228,7 +228,7 @@ impl PdmSnapshotWindow {
> self.set_task_base_url(
> format!("/{}/remotes/{}/tasks", upid.remote_type(), upid.remote()).into(),
> );
> - ctx.link().show_task_progres(upid.to_string());
> + ctx.link().show_task_progress(upid.to_string());
> }
>
> fn create_input_panel(guest_type: GuestType) -> InputPanel {
^ permalink raw reply [relevance 0%]
* [PATCH datacenter-manager] ui: tree-wide: fix spelling of show-task_progress() function
@ 2026-06-17 12:41 14% Shannon Sterz
2026-06-17 13:46 0% ` Dominik Csapak
0 siblings, 1 reply; 19+ results
From: Shannon Sterz @ 2026-06-17 12:41 UTC (permalink / raw)
To: pdm-devel
the commit 51a9489dd418 (loadable component: fix show_task_progress
method name spelling) fixed the name of this function. adapt to it so
pdm ui will build against yew-comp once this change is bumped.
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
Notes:
i know the change this is adapting to isn't bumped yet, so applying it
before that will break the build. however, i could not find anything
like this on list [1] so i thought i'll send a fix for this before
it's accidentally missed.
[1]: https://lore.proxmox.com/all/?q=show_task_progres
ui/src/configuration/subscription_registry.rs | 2 +-
ui/src/guests.rs | 2 +-
ui/src/pve/tree.rs | 6 +++---
ui/src/remotes/updates.rs | 2 +-
ui/src/widget/snapshot_window.rs | 2 +-
5 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/ui/src/configuration/subscription_registry.rs b/ui/src/configuration/subscription_registry.rs
index 470789bc..32343c15 100644
--- a/ui/src/configuration/subscription_registry.rs
+++ b/ui/src/configuration/subscription_registry.rs
@@ -885,7 +885,7 @@ impl LoadableComponent for SubscriptionRegistryComp {
Ok(None) => link.change_view(None),
Ok(Some(upid)) => {
link.change_view(None);
- link.show_task_progres(upid);
+ link.show_task_progress(upid);
}
Err(err) => link.show_error(tr!("Apply Pending"), err.to_string(), true),
}
diff --git a/ui/src/guests.rs b/ui/src/guests.rs
index f11f0de5..ef670a00 100644
--- a/ui/src/guests.rs
+++ b/ui/src/guests.rs
@@ -340,7 +340,7 @@ impl LoadableComponent for GuestPanelComp {
// another remote that ran concurrently cannot point the task
// viewer at the wrong remote
self.set_task_base_url(format!("/pve/remotes/{}/tasks", upid.remote()).into());
- ctx.link().show_task_progres(upid.to_string());
+ ctx.link().show_task_progress(upid.to_string());
}
Msg::GuestAction(action, key) => {
let Some(entry) = self.store.read().lookup_record(&key).cloned() else {
diff --git a/ui/src/pve/tree.rs b/ui/src/pve/tree.rs
index 7a14a05b..0042331c 100644
--- a/ui/src/pve/tree.rs
+++ b/ui/src/pve/tree.rs
@@ -362,7 +362,7 @@ impl LoadableComponent for PveTreeComp {
};
match res {
- Ok(upid) => link.show_task_progres(upid.to_string()),
+ Ok(upid) => link.show_task_progress(upid.to_string()),
Err(err) => link.show_error(tr!("Error"), err.to_string(), true),
}
}),
@@ -386,7 +386,7 @@ impl LoadableComponent for PveTreeComp {
};
match res {
- Ok(upid) => link.show_task_progres(upid.to_string()),
+ Ok(upid) => link.show_task_progress(upid.to_string()),
Err(err) => link.show_error(tr!("Error"), err.to_string(), true),
}
}),
@@ -566,7 +566,7 @@ impl LoadableComponent for PveTreeComp {
.on_close(ctx.link().change_view_callback(|_| None))
.on_submit({
let link = ctx.link().clone();
- move |upid: RemoteUpid| link.show_task_progres(upid.to_string())
+ move |upid: RemoteUpid| link.show_task_progress(upid.to_string())
})
.into(),
),
diff --git a/ui/src/remotes/updates.rs b/ui/src/remotes/updates.rs
index dbb9f700..0aca00e6 100644
--- a/ui/src/remotes/updates.rs
+++ b/ui/src/remotes/updates.rs
@@ -428,7 +428,7 @@ impl LoadableComponent for UpdateTreeComponent {
match client.refresh_remote_update_summary().await {
Ok(upid) => {
- link.show_task_progres(upid.to_string());
+ link.show_task_progress(upid.to_string());
}
Err(err) => {
link.show_error(tr!("Could not refresh update status."), err, false);
diff --git a/ui/src/widget/snapshot_window.rs b/ui/src/widget/snapshot_window.rs
index b14b912f..71b0bc01 100644
--- a/ui/src/widget/snapshot_window.rs
+++ b/ui/src/widget/snapshot_window.rs
@@ -228,7 +228,7 @@ impl PdmSnapshotWindow {
self.set_task_base_url(
format!("/{}/remotes/{}/tasks", upid.remote_type(), upid.remote()).into(),
);
- ctx.link().show_task_progres(upid.to_string());
+ ctx.link().show_task_progress(upid.to_string());
}
fn create_input_panel(guest_type: GuestType) -> InputPanel {
--
2.47.3
^ permalink raw reply related [relevance 14%]
* [PATCH datacenter-manager v5 05/10] ui: registry: add view with key pool and node status
@ 2026-05-23 22:58 1% ` Thomas Lamprecht
0 siblings, 0 replies; 19+ results
From: Thomas Lamprecht @ 2026-05-23 22:58 UTC (permalink / raw)
To: pdm-devel
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 v4 -> 5:
* Auto-Assign opens a "computing" dialog immediately and the panel masks
while refreshing, so a slow remote no longer looks like a no-op
(Dominik).
* Add Subscription Keys dedups pasted keys before submitting (Dominik).
* Auto-Assign proposal grid is non-interactive (header menu and column
resize off) (Dominik).
* SubscriptionKeyGrid uses the #[builder] macro; CSS types imported for
bare use; assorted column-width and dialog padding / FlexFit nits.
* AssignTarget struct replaces the loose (remote, node, ty, sockets)
tuple on the node-first Assign path.
ui/src/configuration/mod.rs | 3 +
ui/src/configuration/subscription_assign.rs | 328 ++++++
ui/src/configuration/subscription_keys.rs | 563 +++++++++
ui/src/configuration/subscription_registry.rs | 1035 +++++++++++++++++
ui/src/main_menu.rs | 10 +
ui/src/widget/pve_node_selector.rs | 91 +-
ui/src/widget/remote_selector.rs | 28 +-
7 files changed, 2032 insertions(+), 26 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/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..4b7db9dd
--- /dev/null
+++ b/ui/src/configuration/subscription_assign.rs
@@ -0,0 +1,328 @@
+//! 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, FontColor, JustifyContent};
+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, Mask, 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";
+
+/// The node a key is about to be assigned to. Bundles the four target attributes so they travel
+/// together instead of as loose positional arguments where a `String` could be swapped for
+/// another.
+#[derive(Clone, PartialEq)]
+pub struct AssignTarget {
+ pub remote: String,
+ pub node: String,
+ pub ty: RemoteType,
+ pub sockets: Option<i64>,
+}
+
+/// 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("100px")
+ .render(|e: &SubscriptionKeyEntry| e.level.to_string().into())
+ .into(),
+ DataTableColumn::new(tr!("Sockets"))
+ .width("80px")
+ .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!("{KEYS_URL}/{}/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 target: AssignTarget,
+ 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(target: AssignTarget, pool_keys: Rc<Vec<SubscriptionKeyEntry>>) -> Self {
+ Self {
+ target,
+ 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.target.ty);
+ let default = default_candidate(&candidates, props.target.ty, props.target.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().target.remote.clone();
+ let node = ctx.props().target.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.target.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()
+ .gap(2)
+ .class(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(FontColor::Error)
+ .with_child(err.clone()),
+ );
+ }
+
+ // FlexFit keeps the footer flush on resize; size constraints live on the Dialog so the
+ // body never out-grows it.
+ let mut body = Column::new().class(FlexFit).padding(2).gap(2).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.target.remote.clone(),
+ node = props.target.node.clone()
+ ))
+ .resizable(true)
+ .min_width(640)
+ .min_height(300)
+ .max_height("80vh")
+ .on_close({
+ let cb = props.on_done.clone();
+ move |_| {
+ if let Some(cb) = &cb {
+ cb.emit(());
+ }
+ }
+ })
+ .with_child(
+ Mask::new(body)
+ .visible(self.submitting)
+ .text(tr!("Assigning...")),
+ )
+ .into()
+ }
+}
diff --git a/ui/src/configuration/subscription_keys.rs b/ui/src/configuration/subscription_keys.rs
new file mode 100644
index 00000000..b94aeaf0
--- /dev/null
+++ b/ui/src/configuration/subscription_keys.rs
@@ -0,0 +1,563 @@
+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::{
+ AddKeysResult, 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, Opacity};
+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 pwt_macros::builder;
+use yew::html::IntoEventCallback;
+
+use crate::widget::{PveNodeSelector, RemoteSelector};
+
+const BASE_URL: &str = "/subscriptions/keys";
+
+#[derive(Properties, PartialEq, Clone)]
+#[builder]
+pub struct SubscriptionKeyGrid {
+ /// Pool keys, owned by the parent registry so both panels see the same snapshot.
+ #[builder]
+ #[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.
+ #[builder]
+ #[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.
+ #[builder_cb(IntoEventCallback, into_event_callback, ())]
+ #[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.
+ #[builder]
+ #[prop_or_default]
+ pub node_status: Rc<Vec<RemoteNodeStatus>>,
+
+ /// When set, a right-aligned Auto-Assign button in the toolbar drives this callback.
+ #[builder_cb(IntoEventCallback, into_event_callback, ())]
+ #[prop_or_default]
+ pub on_auto_assign: Option<Callback<()>>,
+}
+
+impl SubscriptionKeyGrid {
+ pub fn new() -> Self {
+ yew::props!(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("100px")
+ .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(Opacity::ThreeQuarters)
+ .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");
+ // Dedup before submit so a pasted-twice key is a no-op, not a server rejection.
+ let mut seen = std::collections::HashSet::new();
+ let keys: Vec<String> = raw
+ .split(|c: char| c.is_whitespace() || c == ',')
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .filter(|s| seen.insert(s.to_string()))
+ .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::<AddKeysResult>(BASE_URL, Some(body)).await?;
+ Ok(())
+}
+
+/// 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(Opacity::Quarter)
+ .with_child(tr!(
+ "PDM cannot manage {product} remotes yet; this key is parked in the pool.",
+ product = product_type.to_string(),
+ )),
+ )
+ .into();
+ };
+
+ // Hide remotes of this type whose every node already has a pool key assigned; they offer no
+ // free target for this key.
+ let excluded_remotes: Rc<Vec<AttrValue>> = {
+ use std::collections::BTreeSet;
+ let mut all_of_type: BTreeSet<&str> = BTreeSet::new();
+ let mut assignable: BTreeSet<&str> = BTreeSet::new();
+ for n in node_status.iter().filter(|n| n.ty == remote_type) {
+ all_of_type.insert(n.remote.as_str());
+ if n.assigned_key.is_none() {
+ assignable.insert(n.remote.as_str());
+ }
+ }
+ Rc::new(
+ all_of_type
+ .difference(&assignable)
+ .map(|r| AttrValue::from(r.to_string()))
+ .collect(),
+ )
+ };
+
+ panel = panel.with_field(
+ tr!("Remote"),
+ RemoteSelector::new()
+ .name("remote")
+ .remote_type(remote_type)
+ .excluded_remotes(excluded_remotes)
+ .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))
+ .show_memory(false)
+ .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..20faae9b
--- /dev/null
+++ b/ui/src/configuration/subscription_registry.rs
@@ -0,0 +1,1035 @@
+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, Mask, Panel, Row, Toolbar, Tooltip};
+
+use pdm_api_types::subscription::{
+ AutoAssignProposal, ProposedAssignment, RemoteNodeStatus, SubscriptionKeyEntry,
+ SubscriptionLevel,
+};
+
+use super::subscription_assign::{AssignKeyToNodeDialog, AssignTarget};
+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, mut 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.remove(0),
+ standalone: true,
+ });
+ } else {
+ let mut remote_entry = root.append(NodeTreeEntry::Remote {
+ name: remote_name,
+ ty,
+ active,
+ total,
+ });
+ remote_entry.set_expanded(true);
+ for n in remote_nodes {
+ remote_entry.append(NodeTreeEntry::Node {
+ data: n,
+ 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 {
+ /// Auto-Assign proposal is being computed on the server (fans out to every remote, so it can
+ /// take seconds on a slow fleet). Shown immediately on click so the operator sees the request
+ /// landed instead of staring at an unchanged panel.
+ AutoAssignComputing,
+ ConfirmAutoAssign(AutoAssignProposal),
+ ConfirmApplyPending,
+ ConfirmClearPending,
+ /// Assign a pool key to the given node. Opens from the right panel's Assign Key button.
+ AssignKeyToNode(AssignTarget),
+}
+
+#[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("80px")
+ .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("100px")
+ .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(),
+ ])
+ }
+
+ // Read-only preview grid: menu and resize are independent flags, disable both.
+ fn proposal_columns() -> Rc<Vec<DataTableHeader<ProposedAssignment>>> {
+ Rc::new(vec![
+ DataTableColumn::new(tr!("Remote / Node"))
+ .flex(2)
+ .show_menu(false)
+ .resizable(false)
+ .render(|p: &ProposedAssignment| format!("{} / {}", p.remote, p.node).into())
+ .into(),
+ DataTableColumn::new(tr!("Key"))
+ .flex(2)
+ .show_menu(false)
+ .resizable(false)
+ .render(|p: &ProposedAssignment| p.key.clone().into())
+ .into(),
+ DataTableColumn::new(tr!("Sockets (node / key)"))
+ .width("160px")
+ .show_menu(false)
+ .resizable(false)
+ .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 => {
+ // Switch to the computing view first so the click registers instantly; the POST
+ // below fans out to every remote and may take seconds.
+ ctx.link().change_view(Some(ViewState::AutoAssignComputing));
+ 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(target) = self.assign_target_for_selected_node() else {
+ return false;
+ };
+ ctx.link()
+ .change_view(Some(ViewState::AssignKeyToNode(target)));
+ }
+ }
+ 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 {
+ // Both panels are populated by one shared fetch, so a reload leaves both equally stale;
+ // mask the whole content together rather than per-panel. This also covers the initial
+ // load and every action that ends in `send_reload`, giving the prominent busy indicator
+ // the bare refresh-button spinner lacked.
+ // FlexFit on the Mask too, else its wrapper shrinks to content and the panels stop short
+ // of the viewport bottom.
+ Mask::new(
+ 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)),
+ )
+ .class(FlexFit)
+ .visible(self.loading())
+ .text(tr!("Loading subscription status..."))
+ .into()
+ }
+
+ fn dialog_view(
+ &self,
+ ctx: &LoadableComponentContext<Self>,
+ view_state: &Self::ViewState,
+ ) -> Option<Html> {
+ match view_state {
+ ViewState::AutoAssignComputing => {
+ use pwt::widget::Dialog;
+ Some(
+ Dialog::new(tr!("Auto-Assign"))
+ .min_width(400)
+ .on_close({
+ let link = ctx.link().clone();
+ move |_| link.change_view(None)
+ })
+ .with_child(
+ Mask::new(Container::new().min_width(360).min_height(80))
+ .visible(true)
+ .text(tr!("Computing proposal...")),
+ )
+ .into(),
+ )
+ }
+ 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(target) => {
+ let close_link = ctx.link().clone();
+ Some(
+ AssignKeyToNodeDialog::new(target.clone(), 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 the [`AssignTarget`] 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<AssignTarget> {
+ let n = self.selected_node_status()?;
+ if n.assigned_key.is_some() {
+ return None;
+ }
+ if n.status == proxmox_subscription::SubscriptionStatus::Active {
+ return None;
+ }
+ Some(AssignTarget {
+ remote: n.remote.clone(),
+ node: n.node.clone(),
+ ty: n.ty,
+ sockets: 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..95bc2b48 100644
--- a/ui/src/widget/pve_node_selector.rs
+++ b/ui/src/widget/pve_node_selector.rs
@@ -43,6 +43,17 @@ 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>>,
+
+ /// Whether to show the "Memory Usage" column. Callers picking a node for a context where
+ /// memory is irrelevant (e.g. subscription assignment) can hide it.
+ #[builder]
+ #[prop_or(true)]
+ pub show_memory: bool,
}
impl PveNodeSelector {
@@ -51,6 +62,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 +76,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 +88,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 +116,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,9 +137,17 @@ 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();
+ let show_memory = props.show_memory;
let on_change = {
let on_change = props.on_change.clone();
let store = self.store.clone();
@@ -128,7 +172,7 @@ impl Component for PveNodeSelectorComp {
.into();
}
GridPicker::new(
- DataTable::new(columns(), args.store.clone())
+ DataTable::new(columns(show_memory), args.store.clone())
.min_width(300)
.header_focusable(false)
.class(FlexFit),
@@ -148,22 +192,27 @@ impl Component for PveNodeSelectorComp {
}
}
-fn columns() -> Rc<Vec<DataTableHeader<ClusterNodeIndexResponse>>> {
- Rc::new(vec![
- DataTableColumn::new(tr!("Node"))
- .get_property(|entry: &ClusterNodeIndexResponse| &entry.node)
- .sort_order(true)
- .into(),
- DataTableColumn::new(tr!("Memory Usage"))
- .render(
- |entry: &ClusterNodeIndexResponse| match (entry.mem, entry.maxmem) {
- (Some(mem), Some(maxmem)) => {
- html! {format!("{:.2}%", 100.0 * mem as f64 / maxmem as f64)}
- }
- _ => html! {},
- },
- )
- .sorter(|a: &ClusterNodeIndexResponse, b: &ClusterNodeIndexResponse| a.mem.cmp(&b.mem))
- .into(),
- ])
+fn columns(show_memory: bool) -> Rc<Vec<DataTableHeader<ClusterNodeIndexResponse>>> {
+ let mut columns = vec![DataTableColumn::new(tr!("Node"))
+ .get_property(|entry: &ClusterNodeIndexResponse| &entry.node)
+ .sort_order(true)
+ .into()];
+ if show_memory {
+ columns.push(
+ DataTableColumn::new(tr!("Memory Usage"))
+ .render(
+ |entry: &ClusterNodeIndexResponse| match (entry.mem, entry.maxmem) {
+ (Some(mem), Some(maxmem)) => {
+ html! {format!("{:.2}%", 100.0 * mem as f64 / maxmem as f64)}
+ }
+ _ => html! {},
+ },
+ )
+ .sorter(
+ |a: &ClusterNodeIndexResponse, b: &ClusterNodeIndexResponse| a.mem.cmp(&b.mem),
+ )
+ .into(),
+ );
+ }
+ Rc::new(columns)
}
diff --git a/ui/src/widget/remote_selector.rs b/ui/src/widget/remote_selector.rs
index 0cf0f400..69732aab 100644
--- a/ui/src/widget/remote_selector.rs
+++ b/ui/src/widget/remote_selector.rs
@@ -38,12 +38,21 @@ pub struct RemoteSelector {
#[builder(IntoPropValue, into_prop_value)]
#[prop_or_default]
pub remote_type: Option<RemoteType>,
+
+ /// Remote IDs to drop from the list (e.g. remotes with no node left to assign a key to).
+ #[prop_or_default]
+ pub excluded_remotes: Rc<Vec<AttrValue>>,
}
impl RemoteSelector {
pub fn new() -> Self {
yew::props!(Self {})
}
+
+ pub fn excluded_remotes(mut self, remotes: Rc<Vec<AttrValue>>) -> Self {
+ self.excluded_remotes = remotes;
+ self
+ }
}
pub struct PdmRemoteSelector {
@@ -64,12 +73,19 @@ impl PdmRemoteSelector {
fn set_remote_list(&mut self, ctx: &yew::Context<Self>, remotes: RemoteList) {
let ty = ctx.props().remote_type;
+ let excluded = ctx.props().excluded_remotes.clone();
let remotes = remotes
.iter()
- .filter_map(move |remote| match (ty, remote.ty) {
- (Some(a), b) if a == b => Some(remote.id.clone().into()),
- (None, _) => Some(remote.id.clone().into()),
- _ => None,
+ .filter_map(move |remote| {
+ let id: AttrValue = remote.id.clone().into();
+ if excluded.contains(&id) {
+ return None;
+ }
+ match (ty, remote.ty) {
+ (Some(a), b) if a == b => Some(id),
+ (None, _) => Some(id),
+ _ => None,
+ }
})
.collect();
@@ -97,7 +113,9 @@ impl Component for PdmRemoteSelector {
}
fn changed(&mut self, ctx: &yew::Context<Self>, _old_props: &Self::Properties) -> bool {
- if ctx.props().remote_type != _old_props.remote_type {
+ if ctx.props().remote_type != _old_props.remote_type
+ || ctx.props().excluded_remotes != _old_props.excluded_remotes
+ {
self.update_remote_list(ctx);
}
true
--
2.47.3
^ permalink raw reply related [relevance 1%]
* Re: [PATCH datacenter-manager v4 05/10] ui: registry: add view with key pool and node status
2026-05-21 19:20 1% ` [PATCH datacenter-manager v4 05/10] ui: registry: add view with key pool and node status Thomas Lamprecht
@ 2026-05-22 13:16 0% ` Dominik Csapak
0 siblings, 0 replies; 19+ results
From: Dominik Csapak @ 2026-05-22 13:16 UTC (permalink / raw)
To: Thomas Lamprecht, pdm-devel
works good & looks nice, none of my comments are blockers
but polishing work.
high level comments:
we have 3 types of toolbars here on a single page, which seems
a bit inconsistent:
* the top level toolbar without a title
* the left side has a titlebar and a toolbar below
* the right side has a ttitlebar and the buttons inline with the title
the elements from the top bar could probably be moved to the left
hand side, removing the toolbar as a whole? and the
adopt-all fits more on the right side
(so most actions 'pdm -> remotes' would be on the left side
and most actions 'pdm <- remotes' would be on the right side;
except assign on the right and check subscription)
i'd also opt for either style but consistent (a separate toolbar
is probably what we use most and fits the rest of pdm)
i already mentioned the 'weak' indicator of the reload and
general loading/waiting indication.
If a window submit calls the api that can block, we should
imho either
* mask the window until the submit is done (what we do most of the time)
* disable the button + add a loading indicator
some comments (mostly nits inline)
On 5/22/26 10:52 AM, Thomas Lamprecht wrote:
> 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 v3 -> 4:
> * `build_tree` iterates `by_remote` by value, dropping three
> `.clone()`s (Wolfgang).
> * Add Subscription Keys helper text: Opacity::Quarter -> ThreeQuarters
> for readable contrast in both themes (Lukas).
> * New `excluded_remotes` prop on `RemoteSelector`; Assign-Key uses it
> to hide fully-subscribed remotes (Lukas).
> * New `show_memory(bool)` builder on `PveNodeSelector` (default true);
> Assign-Key Node dropdown sets it to false (Lukas).
> * Auto-Assign Proposal Key column drops the
> `FontStyle::LabelMedium` span wrapper that forced 12px (Lukas).
> * Per-node Assign dialog body: `.padding(2)` ->
> `.padding_x(2).padding_top(2)` so the footer sits flush with the
> bottom edge (Lukas).
>
> ui/src/configuration/mod.rs | 3 +
> ui/src/configuration/subscription_assign.rs | 336 ++++++
> ui/src/configuration/subscription_keys.rs | 568 +++++++++
> ui/src/configuration/subscription_registry.rs | 1014 +++++++++++++++++
> ui/src/main_menu.rs | 10 +
> ui/src/widget/pve_node_selector.rs | 91 +-
> ui/src/widget/remote_selector.rs | 28 +-
> 7 files changed, 2024 insertions(+), 26 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/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..58154aa2
> --- /dev/null
> +++ b/ui/src/configuration/subscription_assign.rs
> @@ -0,0 +1,336 @@
> +//! 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";
> +
nit: this is unused here, but see below
> +/// 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")
nit: this is not enough to show e.g. "Community" probably 100px should
be fine.
> + .render(|e: &SubscriptionKeyEntry| e.level.to_string().into())
> + .into(),
> + DataTableColumn::new(tr!("Sockets"))
> + .width("70px")
with this, the header is not fully readable ("Sock..."),
80px should be enough here
> + .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",
should probably use KEYS_URL
> + 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)
This should probably be dropped since there is already a gap(2) between
elements here
> + .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()
> + // No `.padding(2)` here: a uniform 10px around would also pad below the footer,
> + // leaving the Cancel / Assign buttons floating away from the dialog's bottom edge.
> + // The footer carries its own `padding_top(2)` for the gap above the buttons.
having the padding consistent around the dialog is what we usually do
(e.g. in an edit window) so I'd do a normal padding here and
also this columns should get a 'FlexFit' otherwise it won't resize
together with the dialog on user resize and the buttons don't stay
at the bottom of the window
> + .padding_x(2)
> + .padding_top(2)
> + .gap(2)
> + .min_width(640)
this removed together with the flexfit means the constraints should live
in the dialog, so that can resize and the body just adapts.
otherwise we get a scrollbar in the dialog since the body has a bigger
min_width than the dialog
> + .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..ed5ac7f0
> --- /dev/null
> +++ b/ui/src/configuration/subscription_keys.rs
> @@ -0,0 +1,568 @@
> +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>>,
> +}
not sure where to put this comment but here it is:
would probably be nice to be able to remove multiple keys at once, e.g.
i could imagine a user decomissioning a whole cluster. and removing a
large number of keys could be cumbersome when done individually
e.g. a checkbox column to select multiple keys and then only enabling
the 'remove' button could work
> +
> +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
> + }
you could use the 'builder' macro from pwt-macros here to generate them
from the properties.
one advantage is that the documentation will be added to or linked by
the builder methods too (helpful when using things like rust-analyzer)
> +}
> +
> +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")
same comment regarding space as above
> + .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::ThreeQuarters)
> + .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();
> + };
> +
> + // Hide remotes of this type whose every node already has a pool key assigned; they offer no
> + // free target for this key.
> + let excluded_remotes: Rc<Vec<AttrValue>> = {
> + use std::collections::BTreeSet;
> + let mut all_of_type: BTreeSet<&str> = BTreeSet::new();
> + let mut assignable: BTreeSet<&str> = BTreeSet::new();
> + for n in node_status.iter().filter(|n| n.ty == remote_type) {
> + all_of_type.insert(n.remote.as_str());
> + if n.assigned_key.is_none() {
> + assignable.insert(n.remote.as_str());
> + }
> + }
> + Rc::new(
> + all_of_type
> + .difference(&assignable)
> + .map(|r| AttrValue::from(r.to_string()))
> + .collect(),
> + )
> + };
> +
> + panel = panel.with_field(
> + tr!("Remote"),
> + RemoteSelector::new()
> + .name("remote")
> + .remote_type(remote_type)
> + .excluded_remotes(excluded_remotes)
> + .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))
> + .show_memory(false)
> + .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..2d4853b5
> --- /dev/null
> +++ b/ui/src/configuration/subscription_registry.rs
> @@ -0,0 +1,1014 @@
> +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};
super tiny nit. I think the preferred way now is to just 'use pwt::css'
and use 'css::AlignItems' 'css::Flex' and so on, but this surely is a
personal preference
> +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"),
> + }
> +}
i have the feeling this should probably live somewhere more global like
proxmox-yew-comp
> +
> +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, mut 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.remove(0),
> + standalone: true,
> + });
> + } else {
> + let mut remote_entry = root.append(NodeTreeEntry::Remote {
> + name: remote_name,
> + ty,
> + active,
> + total,
> + });
> + remote_entry.set_expanded(true);
> + for n in remote_nodes {
> + remote_entry.append(NodeTreeEntry::Node {
> + data: n,
> + 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")
same problem with size as the other two level columns
> + .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| 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));
> + }
is it intended that this sits between two flex_spacers?
it seems a bit unbalanced:
| button | button | button | button | --- flex --- <pending indicator>
---flex--- | button |
it's not really centered but also not left or right aligned...
maybe just having it directly after the buttons would be better?
> +
> + 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")
not really a comment on your code, but more a reminder to myself:
i don't like that we have to do this that often, maybe we could abstract
away a 'flex type' that implements from for a single number
that results in 'flex: x x auto' a tuple of two number result in
'flex: x y auto' and a tuple of three number that result in
'flex: x y z'
that way one could use here
.flex((3, 1, 0))
or having a struct would also be better:
.flex(Flex {
grow: 3,
shrink: 1,
basis: 0
})
or something like that....
> + .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.
this could probably be a simple helper struct
struct AssignTarget {
remote,
node,
ty,
sockets
}
that can be passed around.
makes it easier to read and pass and there can be no confusion about
which String is which
> + 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..79936309 100644
> --- a/ui/src/widget/pve_node_selector.rs
> +++ b/ui/src/widget/pve_node_selector.rs
> @@ -43,6 +43,17 @@ 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>>,
> +
> + /// Whether to show the "Memory Usage" column. Callers picking a node for a context where
> + /// memory is irrelevant (e.g. subscription assignment) can hide it.
> + #[builder]
> + #[prop_or(true)]
> + pub show_memory: bool,
> }
>
> impl PveNodeSelector {
> @@ -51,6 +62,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 +76,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 +88,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 +116,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,9 +137,17 @@ 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();
> + let show_memory = props.show_memory;
> let on_change = {
> let on_change = props.on_change.clone();
> let store = self.store.clone();
> @@ -128,7 +172,7 @@ impl Component for PveNodeSelectorComp {
> .into();
> }
> GridPicker::new(
> - DataTable::new(columns(), args.store.clone())
> + DataTable::new(columns(show_memory), args.store.clone())
> .min_width(300)
> .header_focusable(false)
> .class(FlexFit),
> @@ -148,22 +192,27 @@ impl Component for PveNodeSelectorComp {
> }
> }
>
> -fn columns() -> Rc<Vec<DataTableHeader<ClusterNodeIndexResponse>>> {
> - Rc::new(vec![
> - DataTableColumn::new(tr!("Node"))
> - .get_property(|entry: &ClusterNodeIndexResponse| &entry.node)
> - .sort_order(true)
> - .into(),
> - DataTableColumn::new(tr!("Memory Usage"))
> - .render(
> - |entry: &ClusterNodeIndexResponse| match (entry.mem, entry.maxmem) {
> - (Some(mem), Some(maxmem)) => {
> - html! {format!("{:.2}%", 100.0 * mem as f64 / maxmem as f64)}
> - }
> - _ => html! {},
> - },
> - )
> - .sorter(|a: &ClusterNodeIndexResponse, b: &ClusterNodeIndexResponse| a.mem.cmp(&b.mem))
> - .into(),
> - ])
> +fn columns(show_memory: bool) -> Rc<Vec<DataTableHeader<ClusterNodeIndexResponse>>> {
> + let mut columns = vec![DataTableColumn::new(tr!("Node"))
> + .get_property(|entry: &ClusterNodeIndexResponse| &entry.node)
> + .sort_order(true)
> + .into()];
> + if show_memory {
> + columns.push(
> + DataTableColumn::new(tr!("Memory Usage"))
> + .render(
> + |entry: &ClusterNodeIndexResponse| match (entry.mem, entry.maxmem) {
> + (Some(mem), Some(maxmem)) => {
> + html! {format!("{:.2}%", 100.0 * mem as f64 / maxmem as f64)}
> + }
> + _ => html! {},
> + },
> + )
> + .sorter(|a: &ClusterNodeIndexResponse, b: &ClusterNodeIndexResponse| {
> + a.mem.cmp(&b.mem)
> + })
> + .into(),
> + );
> + }
> + Rc::new(columns)
> }
> diff --git a/ui/src/widget/remote_selector.rs b/ui/src/widget/remote_selector.rs
> index 0cf0f400..69732aab 100644
> --- a/ui/src/widget/remote_selector.rs
> +++ b/ui/src/widget/remote_selector.rs
> @@ -38,12 +38,21 @@ pub struct RemoteSelector {
> #[builder(IntoPropValue, into_prop_value)]
> #[prop_or_default]
> pub remote_type: Option<RemoteType>,
> +
> + /// Remote IDs to drop from the list (e.g. remotes with no node left to assign a key to).
> + #[prop_or_default]
> + pub excluded_remotes: Rc<Vec<AttrValue>>,
> }
>
> impl RemoteSelector {
> pub fn new() -> Self {
> yew::props!(Self {})
> }
> +
> + pub fn excluded_remotes(mut self, remotes: Rc<Vec<AttrValue>>) -> Self {
> + self.excluded_remotes = remotes;
> + self
> + }
> }
>
> pub struct PdmRemoteSelector {
> @@ -64,12 +73,19 @@ impl PdmRemoteSelector {
>
> fn set_remote_list(&mut self, ctx: &yew::Context<Self>, remotes: RemoteList) {
> let ty = ctx.props().remote_type;
> + let excluded = ctx.props().excluded_remotes.clone();
> let remotes = remotes
> .iter()
> - .filter_map(move |remote| match (ty, remote.ty) {
> - (Some(a), b) if a == b => Some(remote.id.clone().into()),
> - (None, _) => Some(remote.id.clone().into()),
> - _ => None,
> + .filter_map(move |remote| {
> + let id: AttrValue = remote.id.clone().into();
> + if excluded.contains(&id) {
> + return None;
> + }
> + match (ty, remote.ty) {
> + (Some(a), b) if a == b => Some(id),
> + (None, _) => Some(id),
> + _ => None,
> + }
> })
> .collect();
>
> @@ -97,7 +113,9 @@ impl Component for PdmRemoteSelector {
> }
>
> fn changed(&mut self, ctx: &yew::Context<Self>, _old_props: &Self::Properties) -> bool {
> - if ctx.props().remote_type != _old_props.remote_type {
> + if ctx.props().remote_type != _old_props.remote_type
> + || ctx.props().excluded_remotes != _old_props.excluded_remotes
> + {
> self.update_remote_list(ctx);
> }
> true
^ permalink raw reply [relevance 0%]
* [PATCH datacenter-manager v4 05/10] ui: registry: add view with key pool and node status
@ 2026-05-21 19:20 1% ` Thomas Lamprecht
2026-05-22 13:16 0% ` Dominik Csapak
0 siblings, 1 reply; 19+ results
From: Thomas Lamprecht @ 2026-05-21 19:20 UTC (permalink / raw)
To: pdm-devel
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 v3 -> 4:
* `build_tree` iterates `by_remote` by value, dropping three
`.clone()`s (Wolfgang).
* Add Subscription Keys helper text: Opacity::Quarter -> ThreeQuarters
for readable contrast in both themes (Lukas).
* New `excluded_remotes` prop on `RemoteSelector`; Assign-Key uses it
to hide fully-subscribed remotes (Lukas).
* New `show_memory(bool)` builder on `PveNodeSelector` (default true);
Assign-Key Node dropdown sets it to false (Lukas).
* Auto-Assign Proposal Key column drops the
`FontStyle::LabelMedium` span wrapper that forced 12px (Lukas).
* Per-node Assign dialog body: `.padding(2)` ->
`.padding_x(2).padding_top(2)` so the footer sits flush with the
bottom edge (Lukas).
ui/src/configuration/mod.rs | 3 +
ui/src/configuration/subscription_assign.rs | 336 ++++++
ui/src/configuration/subscription_keys.rs | 568 +++++++++
ui/src/configuration/subscription_registry.rs | 1014 +++++++++++++++++
ui/src/main_menu.rs | 10 +
ui/src/widget/pve_node_selector.rs | 91 +-
ui/src/widget/remote_selector.rs | 28 +-
7 files changed, 2024 insertions(+), 26 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/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..58154aa2
--- /dev/null
+++ b/ui/src/configuration/subscription_assign.rs
@@ -0,0 +1,336 @@
+//! 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()
+ // No `.padding(2)` here: a uniform 10px around would also pad below the footer,
+ // leaving the Cancel / Assign buttons floating away from the dialog's bottom edge.
+ // The footer carries its own `padding_top(2)` for the gap above the buttons.
+ .padding_x(2)
+ .padding_top(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..ed5ac7f0
--- /dev/null
+++ b/ui/src/configuration/subscription_keys.rs
@@ -0,0 +1,568 @@
+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::ThreeQuarters)
+ .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();
+ };
+
+ // Hide remotes of this type whose every node already has a pool key assigned; they offer no
+ // free target for this key.
+ let excluded_remotes: Rc<Vec<AttrValue>> = {
+ use std::collections::BTreeSet;
+ let mut all_of_type: BTreeSet<&str> = BTreeSet::new();
+ let mut assignable: BTreeSet<&str> = BTreeSet::new();
+ for n in node_status.iter().filter(|n| n.ty == remote_type) {
+ all_of_type.insert(n.remote.as_str());
+ if n.assigned_key.is_none() {
+ assignable.insert(n.remote.as_str());
+ }
+ }
+ Rc::new(
+ all_of_type
+ .difference(&assignable)
+ .map(|r| AttrValue::from(r.to_string()))
+ .collect(),
+ )
+ };
+
+ panel = panel.with_field(
+ tr!("Remote"),
+ RemoteSelector::new()
+ .name("remote")
+ .remote_type(remote_type)
+ .excluded_remotes(excluded_remotes)
+ .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))
+ .show_memory(false)
+ .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..2d4853b5
--- /dev/null
+++ b/ui/src/configuration/subscription_registry.rs
@@ -0,0 +1,1014 @@
+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, mut 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.remove(0),
+ standalone: true,
+ });
+ } else {
+ let mut remote_entry = root.append(NodeTreeEntry::Remote {
+ name: remote_name,
+ ty,
+ active,
+ total,
+ });
+ remote_entry.set_expanded(true);
+ for n in remote_nodes {
+ remote_entry.append(NodeTreeEntry::Node {
+ data: n,
+ 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| 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..79936309 100644
--- a/ui/src/widget/pve_node_selector.rs
+++ b/ui/src/widget/pve_node_selector.rs
@@ -43,6 +43,17 @@ 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>>,
+
+ /// Whether to show the "Memory Usage" column. Callers picking a node for a context where
+ /// memory is irrelevant (e.g. subscription assignment) can hide it.
+ #[builder]
+ #[prop_or(true)]
+ pub show_memory: bool,
}
impl PveNodeSelector {
@@ -51,6 +62,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 +76,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 +88,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 +116,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,9 +137,17 @@ 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();
+ let show_memory = props.show_memory;
let on_change = {
let on_change = props.on_change.clone();
let store = self.store.clone();
@@ -128,7 +172,7 @@ impl Component for PveNodeSelectorComp {
.into();
}
GridPicker::new(
- DataTable::new(columns(), args.store.clone())
+ DataTable::new(columns(show_memory), args.store.clone())
.min_width(300)
.header_focusable(false)
.class(FlexFit),
@@ -148,22 +192,27 @@ impl Component for PveNodeSelectorComp {
}
}
-fn columns() -> Rc<Vec<DataTableHeader<ClusterNodeIndexResponse>>> {
- Rc::new(vec![
- DataTableColumn::new(tr!("Node"))
- .get_property(|entry: &ClusterNodeIndexResponse| &entry.node)
- .sort_order(true)
- .into(),
- DataTableColumn::new(tr!("Memory Usage"))
- .render(
- |entry: &ClusterNodeIndexResponse| match (entry.mem, entry.maxmem) {
- (Some(mem), Some(maxmem)) => {
- html! {format!("{:.2}%", 100.0 * mem as f64 / maxmem as f64)}
- }
- _ => html! {},
- },
- )
- .sorter(|a: &ClusterNodeIndexResponse, b: &ClusterNodeIndexResponse| a.mem.cmp(&b.mem))
- .into(),
- ])
+fn columns(show_memory: bool) -> Rc<Vec<DataTableHeader<ClusterNodeIndexResponse>>> {
+ let mut columns = vec![DataTableColumn::new(tr!("Node"))
+ .get_property(|entry: &ClusterNodeIndexResponse| &entry.node)
+ .sort_order(true)
+ .into()];
+ if show_memory {
+ columns.push(
+ DataTableColumn::new(tr!("Memory Usage"))
+ .render(
+ |entry: &ClusterNodeIndexResponse| match (entry.mem, entry.maxmem) {
+ (Some(mem), Some(maxmem)) => {
+ html! {format!("{:.2}%", 100.0 * mem as f64 / maxmem as f64)}
+ }
+ _ => html! {},
+ },
+ )
+ .sorter(|a: &ClusterNodeIndexResponse, b: &ClusterNodeIndexResponse| {
+ a.mem.cmp(&b.mem)
+ })
+ .into(),
+ );
+ }
+ Rc::new(columns)
}
diff --git a/ui/src/widget/remote_selector.rs b/ui/src/widget/remote_selector.rs
index 0cf0f400..69732aab 100644
--- a/ui/src/widget/remote_selector.rs
+++ b/ui/src/widget/remote_selector.rs
@@ -38,12 +38,21 @@ pub struct RemoteSelector {
#[builder(IntoPropValue, into_prop_value)]
#[prop_or_default]
pub remote_type: Option<RemoteType>,
+
+ /// Remote IDs to drop from the list (e.g. remotes with no node left to assign a key to).
+ #[prop_or_default]
+ pub excluded_remotes: Rc<Vec<AttrValue>>,
}
impl RemoteSelector {
pub fn new() -> Self {
yew::props!(Self {})
}
+
+ pub fn excluded_remotes(mut self, remotes: Rc<Vec<AttrValue>>) -> Self {
+ self.excluded_remotes = remotes;
+ self
+ }
}
pub struct PdmRemoteSelector {
@@ -64,12 +73,19 @@ impl PdmRemoteSelector {
fn set_remote_list(&mut self, ctx: &yew::Context<Self>, remotes: RemoteList) {
let ty = ctx.props().remote_type;
+ let excluded = ctx.props().excluded_remotes.clone();
let remotes = remotes
.iter()
- .filter_map(move |remote| match (ty, remote.ty) {
- (Some(a), b) if a == b => Some(remote.id.clone().into()),
- (None, _) => Some(remote.id.clone().into()),
- _ => None,
+ .filter_map(move |remote| {
+ let id: AttrValue = remote.id.clone().into();
+ if excluded.contains(&id) {
+ return None;
+ }
+ match (ty, remote.ty) {
+ (Some(a), b) if a == b => Some(id),
+ (None, _) => Some(id),
+ _ => None,
+ }
})
.collect();
@@ -97,7 +113,9 @@ impl Component for PdmRemoteSelector {
}
fn changed(&mut self, ctx: &yew::Context<Self>, _old_props: &Self::Properties) -> bool {
- if ctx.props().remote_type != _old_props.remote_type {
+ if ctx.props().remote_type != _old_props.remote_type
+ || ctx.props().excluded_remotes != _old_props.excluded_remotes
+ {
self.update_remote_list(ctx);
}
true
--
2.47.3
^ permalink raw reply related [relevance 1%]
* [PATCH datacenter-manager v3 05/12] ui: registry: add view with key pool and node status
@ 2026-05-15 7:43 1% ` Thomas Lamprecht
0 siblings, 0 replies; 19+ results
From: Thomas Lamprecht @ 2026-05-15 7:43 UTC (permalink / raw)
To: pdm-devel
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
^ permalink raw reply related [relevance 1%]
* [PATCH datacenter-manager v2 5/8] ui: add subscription registry with key pool and node status
@ 2026-05-07 8:26 1% ` Thomas Lamprecht
0 siblings, 0 replies; 19+ results
From: Thomas Lamprecht @ 2026-05-07 8:26 UTC (permalink / raw)
To: pdm-devel
Add a top-level Subscription Registry view with a Key Pool panel
next to a Node Status tree.
The Add dialog takes a textarea so an operator can paste several
keys at once, newline or comma separated; the backend validates the
whole batch atomically. The Assign dialog filters the remote
selector by the key's compatible remote type and pulls a node
selector for the chosen remote. PMG and POM keys leave Assign
disabled since PDM cannot push them to a remote yet.
Pending assignments show up in the Node Status panel with a clock
icon. Selecting a node there exposes Clear Assignment and Remove
actions: an operator often thinks in terms of "this node is wrong"
rather than tracking down the key entry on the left side.
The Key Pool panel notifies the parent on every successful pool
mutation so the Node Status tree reloads in lockstep, otherwise the
right side keeps showing the pre-mutation view until the operator
navigates away and back.
Apply Pending shows an info toast on the no-op reply instead of
opening a task dialog. Clear Pending hits the bulk backend endpoint
rather than issuing per-key PUTs from the UI.
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
ui/src/configuration/mod.rs | 2 +
ui/src/configuration/subscription_keys.rs | 449 +++++++++++
ui/src/configuration/subscription_registry.rs | 713 ++++++++++++++++++
ui/src/main_menu.rs | 10 +
4 files changed, 1174 insertions(+)
create mode 100644 ui/src/configuration/subscription_keys.rs
create mode 100644 ui/src/configuration/subscription_registry.rs
diff --git a/ui/src/configuration/mod.rs b/ui/src/configuration/mod.rs
index 6ffb64b..4180111 100644
--- a/ui/src/configuration/mod.rs
+++ b/ui/src/configuration/mod.rs
@@ -13,7 +13,9 @@ mod permission_path_selector;
mod webauthn;
pub use webauthn::WebauthnPanel;
+pub mod subscription_keys;
pub mod subscription_panel;
+pub mod subscription_registry;
pub mod views;
diff --git a/ui/src/configuration/subscription_keys.rs b/ui/src/configuration/subscription_keys.rs
new file mode 100644
index 0000000..c535e94
--- /dev/null
+++ b/ui/src/configuration/subscription_keys.rs
@@ -0,0 +1,449 @@
+use std::future::Future;
+use std::pin::Pin;
+use std::rc::Rc;
+
+use anyhow::Error;
+
+use pdm_api_types::remotes::RemoteType;
+use pdm_api_types::subscription::{ProductType, RemoteNodeStatus, SubscriptionKeyEntry};
+use yew::virtual_dom::{Key, VComp, VNode};
+
+use proxmox_yew_comp::percent_encoding::percent_encode_component;
+use proxmox_yew_comp::{http_delete, http_get, http_post, http_put, EditWindow};
+use proxmox_yew_comp::{
+ LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
+ LoadableComponentScopeExt, LoadableComponentState,
+};
+
+use pwt::css::FontStyle;
+use pwt::prelude::*;
+use pwt::state::{Selection, Store};
+use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader};
+use pwt::widget::form::{DisplayField, FormContext, TextArea};
+use pwt::widget::{Button, ConfirmDialog, Container, InputPanel, Toolbar};
+
+use crate::widget::{PveNodeSelector, RemoteSelector};
+
+const BASE_URL: &str = "/subscriptions/keys";
+
+#[derive(Properties, PartialEq, Clone)]
+pub struct SubscriptionKeyGrid {
+ /// Called after every successful pool mutation (add, assign, clear, remove). Lets the parent
+ /// view (the Subscription Registry) reload its own data so the Node Status side stays in
+ /// sync with the Key Pool side.
+ #[prop_or_default]
+ pub on_change: Option<Callback<()>>,
+
+ /// Latest live node-status snapshot from the parent view. Used to disable the Clear button
+ /// when the selected entry's binding is currently synced (the assigned key is the live
+ /// active key on its remote), since unassigning then would orphan the live subscription.
+ /// The server enforces the same gate; this prop just turns it into a UI affordance.
+ #[prop_or_default]
+ pub node_status: Rc<Vec<RemoteNodeStatus>>,
+}
+
+impl SubscriptionKeyGrid {
+ pub fn new() -> Self {
+ yew::props!(Self {})
+ }
+
+ pub fn on_change(mut self, cb: impl Into<Option<Callback<()>>>) -> Self {
+ self.on_change = cb.into();
+ self
+ }
+
+ pub fn node_status(mut self, statuses: Rc<Vec<RemoteNodeStatus>>) -> Self {
+ self.node_status = statuses;
+ self
+ }
+}
+
+impl Default for SubscriptionKeyGrid {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl From<SubscriptionKeyGrid> for VNode {
+ fn from(val: SubscriptionKeyGrid) -> Self {
+ VComp::new::<LoadableComponentMaster<SubscriptionKeyGridComp>>(Rc::new(val), None).into()
+ }
+}
+
+pub enum Msg {
+ LoadFinished(Vec<SubscriptionKeyEntry>),
+ Remove(Key),
+ Reload,
+}
+
+#[derive(PartialEq)]
+pub enum ViewState {
+ Add,
+ Assign,
+ Remove,
+}
+
+#[doc(hidden)]
+pub struct SubscriptionKeyGridComp {
+ state: LoadableComponentState<ViewState>,
+ store: Store<SubscriptionKeyEntry>,
+ columns: Rc<Vec<DataTableHeader<SubscriptionKeyEntry>>>,
+ selection: Selection,
+}
+
+pwt::impl_deref_mut_property!(
+ SubscriptionKeyGridComp,
+ state,
+ LoadableComponentState<ViewState>
+);
+
+impl SubscriptionKeyGridComp {
+ fn columns() -> Rc<Vec<DataTableHeader<SubscriptionKeyEntry>>> {
+ Rc::new(vec![
+ DataTableColumn::new(tr!("Key"))
+ .flex(2)
+ .get_property(|entry: &SubscriptionKeyEntry| entry.key.as_str())
+ .sort_order(true)
+ .into(),
+ DataTableColumn::new(tr!("Product"))
+ .width("80px")
+ .render(|entry: &SubscriptionKeyEntry| entry.product_type.to_string().into())
+ .into(),
+ DataTableColumn::new(tr!("Level"))
+ .width("90px")
+ .render(|entry: &SubscriptionKeyEntry| entry.level.to_string().into())
+ .into(),
+ DataTableColumn::new(tr!("Assignment"))
+ .flex(2)
+ .render(
+ |entry: &SubscriptionKeyEntry| match (&entry.remote, &entry.node) {
+ (Some(remote), Some(node)) => format!("{remote} / {node}").into(),
+ _ => Html::default(),
+ },
+ )
+ .into(),
+ ])
+ }
+
+ fn selected_entry(&self) -> Option<SubscriptionKeyEntry> {
+ let key = self.selection.selected_key()?;
+ self.store.read().lookup_record(&key).cloned()
+ }
+
+ fn create_add_dialog(&self, ctx: &LoadableComponentContext<Self>) -> Html {
+ EditWindow::new(tr!("Add Subscription Keys"))
+ .renderer(|_form_ctx| add_input_panel())
+ .on_submit(submit_add_keys)
+ .on_done(ctx.link().clone().callback(|_| Msg::Reload))
+ .into()
+ }
+
+ fn create_assign_dialog(
+ &self,
+ entry: &SubscriptionKeyEntry,
+ ctx: &LoadableComponentContext<Self>,
+ ) -> Html {
+ let key = entry.key.clone();
+ let product_type = entry.product_type;
+ EditWindow::new(tr!("Assign Key to Remote"))
+ .renderer({
+ let key = key.clone();
+ move |form_ctx| assign_input_panel(&key, product_type, form_ctx)
+ })
+ .on_submit({
+ let key = key.clone();
+ move |form| submit_assign(key.clone(), form)
+ })
+ .on_done(ctx.link().clone().callback(|_| Msg::Reload))
+ .into()
+ }
+}
+
+impl LoadableComponent for SubscriptionKeyGridComp {
+ type Properties = SubscriptionKeyGrid;
+ type Message = Msg;
+ type ViewState = ViewState;
+
+ fn create(ctx: &LoadableComponentContext<Self>) -> Self {
+ let selection = Selection::new().on_select({
+ let link = ctx.link().clone();
+ move |_| link.send_redraw()
+ });
+ Self {
+ state: LoadableComponentState::new(),
+ store: Store::with_extract_key(|entry: &SubscriptionKeyEntry| {
+ entry.key.as_str().into()
+ }),
+ columns: Self::columns(),
+ selection,
+ }
+ }
+
+ fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
+ match msg {
+ Msg::LoadFinished(data) => self.store.set_data(data),
+ Msg::Remove(key) => {
+ let id = key.to_string();
+ let link = ctx.link().clone();
+ ctx.link().spawn(async move {
+ let url = format!("{BASE_URL}/{}", percent_encode_component(&id));
+ if let Err(err) = http_delete(&url, None).await {
+ link.show_error(
+ tr!("Error"),
+ tr!("Could not remove {id}: {err}", id = id, err = err),
+ true,
+ );
+ }
+ link.send_message(Msg::Reload);
+ });
+ }
+ Msg::Reload => {
+ ctx.link().change_view(None);
+ ctx.link().send_reload();
+ if let Some(cb) = &ctx.props().on_change {
+ cb.emit(());
+ }
+ }
+ }
+ true
+ }
+
+ fn toolbar(&self, ctx: &LoadableComponentContext<Self>) -> Option<Html> {
+ let entry = self.selected_entry();
+ let has_selection = entry.is_some();
+ let is_assigned = entry.as_ref().map(|e| e.remote.is_some()).unwrap_or(false);
+ let synced_assignment = entry
+ .as_ref()
+ .map(|e| is_synced_assignment(e, &ctx.props().node_status))
+ .unwrap_or(false);
+ let assignable = entry
+ .as_ref()
+ .map(|e| {
+ e.product_type.matches_remote_type(RemoteType::Pve)
+ || e.product_type.matches_remote_type(RemoteType::Pbs)
+ })
+ .unwrap_or(false);
+ let link = ctx.link();
+
+ Some(
+ Toolbar::new()
+ .border_bottom(true)
+ .with_child(
+ Button::new(tr!("Add"))
+ .icon_class("fa fa-plus")
+ .on_activate(link.change_view_callback(|_| Some(ViewState::Add))),
+ )
+ .with_spacer()
+ .with_child(
+ Button::new(tr!("Remove Key"))
+ .icon_class("fa fa-trash-o")
+ .disabled(!has_selection || synced_assignment)
+ .on_activate(link.change_view_callback(|_| Some(ViewState::Remove))),
+ )
+ .with_spacer()
+ .with_child(
+ Button::new(tr!("Assign"))
+ .icon_class("fa fa-link")
+ .disabled(!has_selection || is_assigned || !assignable)
+ .on_activate(link.change_view_callback(|_| Some(ViewState::Assign))),
+ )
+ .into(),
+ )
+ }
+
+ fn load(
+ &self,
+ ctx: &LoadableComponentContext<Self>,
+ ) -> Pin<Box<dyn Future<Output = Result<(), Error>>>> {
+ let link = ctx.link().clone();
+ Box::pin(async move {
+ let data: Vec<SubscriptionKeyEntry> = http_get(BASE_URL, None).await?;
+ link.send_message(Msg::LoadFinished(data));
+ Ok(())
+ })
+ }
+
+ fn main_view(&self, _ctx: &LoadableComponentContext<Self>) -> Html {
+ DataTable::new(self.columns.clone(), self.store.clone())
+ .selection(self.selection.clone())
+ .into()
+ }
+
+ fn dialog_view(
+ &self,
+ ctx: &LoadableComponentContext<Self>,
+ view_state: &Self::ViewState,
+ ) -> Option<Html> {
+ match view_state {
+ ViewState::Add => Some(self.create_add_dialog(ctx)),
+ ViewState::Assign => self
+ .selected_entry()
+ .map(|entry| self.create_assign_dialog(&entry, ctx)),
+ ViewState::Remove => self.selection.selected_key().map(|key| {
+ ConfirmDialog::new(
+ tr!("Remove Key"),
+ tr!(
+ "Remove {key} from the key pool? This does not revoke the subscription.",
+ key = key.to_string(),
+ ),
+ )
+ .on_confirm({
+ let link = ctx.link().clone();
+ let key = key.clone();
+ move |_| link.send_message(Msg::Remove(key.clone()))
+ })
+ .into()
+ }),
+ }
+ }
+}
+
+/// Returns true when the pool entry's binding currently runs the same key on the remote and is
+/// Active - meaning a clear-assignment would orphan the live subscription. Mirrors the
+/// server-side gate; the operator should use Reissue Key in that state.
+fn is_synced_assignment(entry: &SubscriptionKeyEntry, statuses: &[RemoteNodeStatus]) -> bool {
+ let (Some(remote), Some(node)) = (entry.remote.as_deref(), entry.node.as_deref()) else {
+ return false;
+ };
+ statuses
+ .iter()
+ .find(|n| n.remote == remote && n.node == node)
+ .map(|n| {
+ n.status == proxmox_subscription::SubscriptionStatus::Active
+ && n.current_key.as_deref() == Some(entry.key.as_str())
+ })
+ .unwrap_or(false)
+}
+
+fn add_input_panel() -> Html {
+ let hint = Container::new()
+ .class(FontStyle::TitleSmall)
+ .class(pwt::css::Opacity::Quarter)
+ .padding_top(2)
+ .with_child(tr!(
+ "One key per line, or comma-separated. Only Proxmox VE and Proxmox Backup Server keys are accepted."
+ ));
+
+ // The textarea opts into `width: 100%` so it fills the InputPanel's grid cell instead of
+ // shrinking to browser-default cols.
+ InputPanel::new()
+ .padding(4)
+ .min_width(500)
+ .with_large_custom_child(
+ TextArea::new()
+ .name("keys")
+ .submit_empty(false)
+ .required(true)
+ .attribute("rows", "8")
+ .attribute("placeholder", tr!("Subscription key(s)"))
+ .style("width", "100%")
+ .style("box-sizing", "border-box"),
+ )
+ .with_large_custom_child(hint)
+ .into()
+}
+
+async fn submit_add_keys(form_ctx: FormContext) -> Result<(), Error> {
+ let raw = form_ctx.read().get_field_text("keys");
+ let keys: Vec<String> = raw
+ .split(|c: char| c.is_whitespace() || c == ',')
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .map(str::to_string)
+ .collect();
+
+ if keys.is_empty() {
+ anyhow::bail!(tr!("no keys provided"));
+ }
+
+ http_post(BASE_URL, Some(serde_json::json!({ "keys": keys }))).await
+}
+
+/// Map a subscription product type to the remote type its keys can drive.
+fn remote_type_for(product_type: ProductType) -> Option<RemoteType> {
+ if product_type.matches_remote_type(RemoteType::Pve) {
+ Some(RemoteType::Pve)
+ } else if product_type.matches_remote_type(RemoteType::Pbs) {
+ Some(RemoteType::Pbs)
+ } else {
+ None
+ }
+}
+
+fn assign_input_panel(key: &str, product_type: ProductType, form_ctx: &FormContext) -> Html {
+ let mut panel = InputPanel::new().padding(4).min_width(500).with_field(
+ tr!("Key"),
+ DisplayField::new()
+ .name("key")
+ .value(key.to_string())
+ .key("key-display"),
+ );
+
+ let Some(remote_type) = remote_type_for(product_type) else {
+ // Defensive: the toolbar disables Assign for these product types.
+ return panel
+ .with_large_custom_child(
+ Container::new()
+ .class(FontStyle::TitleSmall)
+ .class(pwt::css::Opacity::Quarter)
+ .with_child(tr!(
+ "PDM cannot manage {product} remotes yet; this key is parked in the pool.",
+ product = product_type.to_string(),
+ )),
+ )
+ .into();
+ };
+
+ panel = panel.with_field(
+ tr!("Remote"),
+ RemoteSelector::new()
+ .name("remote")
+ .remote_type(remote_type)
+ .required(true),
+ );
+
+ match remote_type {
+ RemoteType::Pve => {
+ let selected_remote = form_ctx.read().get_field_text("remote");
+ if selected_remote.is_empty() {
+ panel
+ .with_field(
+ tr!("Node"),
+ DisplayField::new()
+ .name("node")
+ .key("node-no-remote")
+ .value(AttrValue::from(tr!("Select a remote first."))),
+ )
+ .into()
+ } else {
+ // `PveNodeSelector` fetches its node list in `create` and does not re-fetch on
+ // prop change, so a per-remote `key` forces a fresh component when the operator
+ // picks a target.
+ panel
+ .with_field(
+ tr!("Node"),
+ PveNodeSelector::new(selected_remote.clone())
+ .name("node")
+ .key(format!("node-selector-{selected_remote}"))
+ .required(true),
+ )
+ .into()
+ }
+ }
+ RemoteType::Pbs => panel
+ .with_field(
+ tr!("Node"),
+ DisplayField::new()
+ .name("node")
+ .value(AttrValue::from("localhost"))
+ .key("node-localhost"),
+ )
+ .into(),
+ }
+}
+
+async fn submit_assign(key: String, form_ctx: FormContext) -> Result<(), Error> {
+ let data = form_ctx.get_submit_data();
+ let url = format!("{BASE_URL}/{}", percent_encode_component(&key));
+ http_put(&url, Some(data)).await
+}
diff --git a/ui/src/configuration/subscription_registry.rs b/ui/src/configuration/subscription_registry.rs
new file mode 100644
index 0000000..7ed96e6
--- /dev/null
+++ b/ui/src/configuration/subscription_registry.rs
@@ -0,0 +1,713 @@
+use std::future::Future;
+use std::pin::Pin;
+use std::rc::Rc;
+
+use anyhow::Error;
+
+use yew::virtual_dom::{Key, VComp, VNode};
+
+use proxmox_yew_comp::percent_encoding::percent_encode_component;
+use proxmox_yew_comp::{http_delete, http_get, http_post, http_put};
+use proxmox_yew_comp::{
+ LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
+ LoadableComponentScopeExt, LoadableComponentState,
+};
+
+use pwt::css::{AlignItems, Flex, FlexDirection, FlexFit, FontColor, JustifyContent, Overflow};
+use pwt::prelude::*;
+use pwt::props::{ContainerBuilder, ExtractPrimaryKey, WidgetBuilder};
+use pwt::state::{Selection, SlabTree, Store, TreeStore};
+use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader};
+use pwt::widget::{Button, Column, Container, Fa, Panel, Row, Toolbar, Tooltip};
+
+use pdm_api_types::subscription::{ProposedAssignment, RemoteNodeStatus, SubscriptionLevel};
+
+use super::subscription_keys::SubscriptionKeyGrid;
+
+const NODE_STATUS_URL: &str = "/subscriptions/node-status";
+const AUTO_ASSIGN_URL: &str = "/subscriptions/auto-assign";
+const APPLY_PENDING_URL: &str = "/subscriptions/apply-pending";
+const CLEAR_PENDING_URL: &str = "/subscriptions/clear-pending";
+
+/// Map a [`SubscriptionStatus`] to the icon shown in subscription panels.
+///
+/// Public so the dashboard subscriptions panel can render the same icon for the same state
+/// without redefining the mapping. The 4-variant `proxmox_yew_comp::Status` does not cover
+/// every subscription state (New, Expired, Suspended need their own icons), hence the dedicated
+/// helper.
+pub fn subscription_status_icon(status: proxmox_subscription::SubscriptionStatus) -> Fa {
+ use proxmox_subscription::SubscriptionStatus as S;
+ match status {
+ S::Active => Fa::new("check-circle").class(FontColor::Success),
+ S::New => Fa::new("clock-o").class(FontColor::Primary),
+ S::NotFound => Fa::new("exclamation-circle").class(FontColor::Error),
+ S::Invalid => Fa::new("times-circle").class(FontColor::Warning),
+ S::Expired => Fa::new("clock-o").class(FontColor::Warning),
+ S::Suspended => Fa::new("ban").class(FontColor::Error),
+ }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+enum NodeTreeEntry {
+ Root,
+ Remote {
+ name: String,
+ ty: pdm_api_types::remotes::RemoteType,
+ active: u32,
+ total: u32,
+ },
+ Node {
+ data: RemoteNodeStatus,
+ /// If true, this is the only node in its remote and is shown at the top level under the
+ /// remote name instead of nested.
+ standalone: bool,
+ },
+}
+
+impl NodeTreeEntry {
+ fn name(&self) -> &str {
+ match self {
+ Self::Root => "",
+ Self::Remote { name, .. } => name,
+ Self::Node { data, standalone } => {
+ if *standalone {
+ &data.remote
+ } else {
+ &data.node
+ }
+ }
+ }
+ }
+}
+
+impl ExtractPrimaryKey for NodeTreeEntry {
+ fn extract_key(&self) -> Key {
+ Key::from(match self {
+ NodeTreeEntry::Root => "/".to_string(),
+ NodeTreeEntry::Remote { name, .. } => format!("/{name}"),
+ NodeTreeEntry::Node { data, .. } => format!("/{}/{}", data.remote, data.node),
+ })
+ }
+}
+
+fn build_tree(nodes: Vec<RemoteNodeStatus>) -> SlabTree<NodeTreeEntry> {
+ use std::collections::BTreeMap;
+
+ let mut by_remote: BTreeMap<String, Vec<RemoteNodeStatus>> = BTreeMap::new();
+ for n in nodes {
+ by_remote.entry(n.remote.clone()).or_default().push(n);
+ }
+
+ let mut tree = SlabTree::new();
+ let mut root = tree.set_root(NodeTreeEntry::Root);
+ root.set_expanded(true);
+
+ for (remote_name, remote_nodes) in &by_remote {
+ let total = remote_nodes.len() as u32;
+ let active = remote_nodes
+ .iter()
+ .filter(|n| n.status == proxmox_subscription::SubscriptionStatus::Active)
+ .count() as u32;
+
+ let ty = remote_nodes.first().map(|n| n.ty).unwrap_or_default();
+
+ if remote_nodes.len() == 1 {
+ root.append(NodeTreeEntry::Node {
+ data: remote_nodes[0].clone(),
+ standalone: true,
+ });
+ } else {
+ let mut remote_entry = root.append(NodeTreeEntry::Remote {
+ name: remote_name.clone(),
+ ty,
+ active,
+ total,
+ });
+ remote_entry.set_expanded(true);
+ for n in remote_nodes {
+ remote_entry.append(NodeTreeEntry::Node {
+ data: n.clone(),
+ standalone: false,
+ });
+ }
+ }
+ }
+
+ tree
+}
+
+#[derive(Properties, PartialEq, Clone)]
+pub struct SubscriptionRegistryProps {}
+
+impl SubscriptionRegistryProps {
+ pub fn new() -> Self {
+ yew::props!(Self {})
+ }
+}
+
+impl Default for SubscriptionRegistryProps {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl From<SubscriptionRegistryProps> for VNode {
+ fn from(val: SubscriptionRegistryProps) -> Self {
+ VComp::new::<LoadableComponentMaster<SubscriptionRegistryComp>>(Rc::new(val), None).into()
+ }
+}
+
+pub enum Msg {
+ LoadFinished(Vec<RemoteNodeStatus>),
+ AutoAssignPreview,
+ AutoAssignApply,
+ ApplyPending,
+ ClearPending,
+ /// Clear the pool's pin on the currently-selected node by un-assigning its key.
+ ClearSelectedNode,
+ /// Remove the pool entry currently pinned to the selected node.
+ RemoveSelectedNodeKey,
+}
+
+#[derive(PartialEq)]
+pub enum ViewState {
+ ConfirmAutoAssign(Vec<ProposedAssignment>),
+ ConfirmClearPending,
+ ConfirmRemoveSelectedNodeKey(String),
+}
+
+#[doc(hidden)]
+pub struct SubscriptionRegistryComp {
+ state: LoadableComponentState<ViewState>,
+ tree_store: TreeStore<NodeTreeEntry>,
+ tree_columns: Rc<Vec<DataTableHeader<NodeTreeEntry>>>,
+ proposal_columns: Rc<Vec<DataTableHeader<ProposedAssignment>>>,
+ node_selection: Selection,
+ last_node_data: Vec<RemoteNodeStatus>,
+}
+
+pwt::impl_deref_mut_property!(
+ SubscriptionRegistryComp,
+ state,
+ LoadableComponentState<ViewState>
+);
+
+fn tree_sorter(a: &NodeTreeEntry, b: &NodeTreeEntry) -> std::cmp::Ordering {
+ a.name().cmp(b.name())
+}
+
+impl SubscriptionRegistryComp {
+ fn tree_columns(store: TreeStore<NodeTreeEntry>) -> Rc<Vec<DataTableHeader<NodeTreeEntry>>> {
+ Rc::new(vec![
+ DataTableColumn::new(tr!("Name"))
+ .tree_column(store)
+ .flex(3)
+ .render(|entry: &NodeTreeEntry| {
+ let (icon, name) = match entry {
+ NodeTreeEntry::Root => return Html::default(),
+ NodeTreeEntry::Remote { name, ty, .. } => {
+ let icon = if *ty == pdm_api_types::remotes::RemoteType::Pbs {
+ "building-o"
+ } else {
+ "server"
+ };
+ (icon, name.as_str())
+ }
+ NodeTreeEntry::Node {
+ data: n,
+ standalone,
+ } => {
+ let icon = if n.ty == pdm_api_types::remotes::RemoteType::Pbs {
+ "building-o"
+ } else {
+ "building"
+ };
+ let label = if *standalone { &n.remote } else { &n.node };
+ (icon, label.as_str())
+ }
+ };
+ Row::new()
+ .class(AlignItems::Baseline)
+ .gap(2)
+ .with_child(Fa::new(icon))
+ .with_child(name)
+ .into()
+ })
+ .sorter(tree_sorter)
+ .into(),
+ DataTableColumn::new(tr!("Sockets"))
+ .width("70px")
+ .render(|entry: &NodeTreeEntry| match entry {
+ NodeTreeEntry::Node { data: n, .. } => {
+ n.sockets.map(|s| s.to_string()).unwrap_or_default().into()
+ }
+ _ => Html::default(),
+ })
+ .into(),
+ DataTableColumn::new(tr!("Status"))
+ .width("120px")
+ .render(|entry: &NodeTreeEntry| match entry {
+ NodeTreeEntry::Node { data: n, .. } => Row::new()
+ .class(AlignItems::Baseline)
+ .gap(2)
+ .with_child(subscription_status_icon(n.status))
+ .with_child(n.status.to_string())
+ .into(),
+ NodeTreeEntry::Remote { active, total, .. } => {
+ let icon = if active == total {
+ Fa::new("check-circle").class(FontColor::Success)
+ } else if *active == 0 {
+ Fa::new("exclamation-circle").class(FontColor::Error)
+ } else {
+ Fa::new("exclamation-triangle").class(FontColor::Warning)
+ };
+ Tooltip::new(
+ Row::new()
+ .class(AlignItems::Baseline)
+ .gap(2)
+ .with_child(icon)
+ .with_child(format!("{active}/{total}")),
+ )
+ .tip(tr!(
+ "{active} of {total} nodes subscribed",
+ active = active,
+ total = total,
+ ))
+ .into()
+ }
+ _ => Html::default(),
+ })
+ .into(),
+ DataTableColumn::new(tr!("Level"))
+ .width("90px")
+ .render(|entry: &NodeTreeEntry| match entry {
+ NodeTreeEntry::Node { data: n, .. } if n.level != SubscriptionLevel::None => {
+ n.level.to_string().into()
+ }
+ _ => Html::default(),
+ })
+ .into(),
+ DataTableColumn::new(tr!("Key"))
+ .flex(2)
+ .render(|entry: &NodeTreeEntry| match entry {
+ NodeTreeEntry::Node { data: n, .. } => key_cell(n),
+ _ => Html::default(),
+ })
+ .into(),
+ ])
+ }
+
+ fn proposal_columns() -> Rc<Vec<DataTableHeader<ProposedAssignment>>> {
+ Rc::new(vec![
+ DataTableColumn::new(tr!("Remote / Node"))
+ .flex(2)
+ .render(|p: &ProposedAssignment| format!("{} / {}", p.remote, p.node).into())
+ .into(),
+ DataTableColumn::new(tr!("Key"))
+ .flex(2)
+ .render(|p: &ProposedAssignment| {
+ Container::from_tag("span")
+ .class(pwt::css::FontStyle::LabelMedium)
+ .with_child(p.key.clone())
+ .into()
+ })
+ .into(),
+ DataTableColumn::new(tr!("Sockets (node / key)"))
+ .width("160px")
+ .render(|p: &ProposedAssignment| {
+ let label = match (p.node_sockets, p.key_sockets) {
+ (Some(ns), Some(ks)) => format!("{ns} / {ks}"),
+ (Some(ns), None) => format!("{ns} / -"),
+ (None, Some(ks)) => format!("- / {ks}"),
+ _ => String::new(),
+ };
+ label.into()
+ })
+ .into(),
+ ])
+ }
+}
+
+fn key_cell(n: &RemoteNodeStatus) -> Html {
+ let assigned = n.assigned_key.as_deref();
+ let current = n.current_key.as_deref();
+
+ // Pending = pool key assigned but the node doesn't have an active subscription yet (the key
+ // still needs to be pushed).
+ let pending =
+ assigned.is_some() && n.status != proxmox_subscription::SubscriptionStatus::Active;
+
+ match (assigned, current) {
+ (Some(a), Some(c)) if a != c => Row::new()
+ .class(AlignItems::Baseline)
+ .gap(2)
+ .with_child(Fa::new("clock-o").class(FontColor::Warning))
+ .with_child(format!("{a} \u{2192} {c}"))
+ .into(),
+ _ => {
+ let text = current.or(assigned).unwrap_or("");
+ if pending {
+ Row::new()
+ .class(AlignItems::Baseline)
+ .gap(2)
+ .with_child(Fa::new("clock-o").class(FontColor::Warning))
+ .with_child(text)
+ .into()
+ } else {
+ text.into()
+ }
+ }
+ }
+}
+
+impl LoadableComponent for SubscriptionRegistryComp {
+ type Properties = SubscriptionRegistryProps;
+ type Message = Msg;
+ type ViewState = ViewState;
+
+ fn create(ctx: &LoadableComponentContext<Self>) -> Self {
+ let store = TreeStore::new().view_root(false);
+ store.set_sorter(tree_sorter);
+
+ let node_selection = Selection::new().on_select({
+ let link = ctx.link().clone();
+ move |_| link.send_redraw()
+ });
+
+ Self {
+ state: LoadableComponentState::new(),
+ tree_store: store.clone(),
+ tree_columns: Self::tree_columns(store),
+ proposal_columns: Self::proposal_columns(),
+ node_selection,
+ last_node_data: Vec::new(),
+ }
+ }
+
+ fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
+ match msg {
+ Msg::LoadFinished(data) => {
+ self.last_node_data = data.clone();
+ let tree = build_tree(data);
+ self.tree_store.write().update_root_tree(tree);
+ }
+ Msg::AutoAssignPreview => {
+ let link = ctx.link().clone();
+ ctx.link().spawn(async move {
+ match http_post::<Vec<ProposedAssignment>>(AUTO_ASSIGN_URL, None).await {
+ Ok(proposals) if proposals.is_empty() => {
+ link.show_error(
+ tr!("Auto-Assign"),
+ tr!("No suitable unassigned keys for the remaining nodes."),
+ false,
+ );
+ }
+ Ok(proposals) => {
+ link.change_view(Some(ViewState::ConfirmAutoAssign(proposals)));
+ }
+ Err(err) => link.show_error(tr!("Auto-Assign"), err.to_string(), true),
+ }
+ });
+ }
+ Msg::AutoAssignApply => {
+ let link = ctx.link().clone();
+ ctx.link().spawn(async move {
+ let url = format!("{AUTO_ASSIGN_URL}?apply=1");
+ match http_post::<Vec<ProposedAssignment>>(&url, None).await {
+ Ok(_) => {
+ link.change_view(None);
+ link.send_reload();
+ }
+ Err(err) => link.show_error(tr!("Auto-Assign"), err.to_string(), true),
+ }
+ });
+ }
+ Msg::ApplyPending => {
+ let link = ctx.link().clone();
+ ctx.link().spawn(async move {
+ match http_post::<Option<String>>(APPLY_PENDING_URL, None).await {
+ Ok(None) => link.show_error(
+ tr!("Apply Pending"),
+ tr!("No pending assignments. Every assigned key is already active on its remote node."),
+ false,
+ ),
+ Ok(Some(upid)) => link.show_task_progres(upid),
+ Err(err) => link.show_error(tr!("Apply"), err.to_string(), true),
+ }
+ });
+ }
+ Msg::ClearPending => {
+ let link = ctx.link().clone();
+ ctx.link().spawn(async move {
+ match http_post::<serde_json::Value>(CLEAR_PENDING_URL, None).await {
+ Ok(_) => {
+ link.change_view(None);
+ link.send_reload();
+ }
+ Err(err) => link.show_error(tr!("Clear Pending"), err.to_string(), true),
+ }
+ });
+ }
+ Msg::ClearSelectedNode => {
+ let Some(key) = self.selected_assigned_key() else {
+ return false;
+ };
+ let link = ctx.link().clone();
+ ctx.link().spawn(async move {
+ let url = format!("/subscriptions/keys/{}", percent_encode_component(&key),);
+ if let Err(err) = http_put::<()>(&url, Some(serde_json::json!({}))).await {
+ link.show_error(tr!("Clear Assignment"), err.to_string(), true);
+ }
+ link.send_reload();
+ });
+ }
+ Msg::RemoveSelectedNodeKey => {
+ let Some(key) = self.selected_assigned_key() else {
+ return false;
+ };
+ ctx.link()
+ .change_view(Some(ViewState::ConfirmRemoveSelectedNodeKey(key)));
+ }
+ }
+ true
+ }
+
+ fn toolbar(&self, ctx: &LoadableComponentContext<Self>) -> Option<Html> {
+ let link = ctx.link();
+ Some(
+ Toolbar::new()
+ .border_bottom(true)
+ .with_child(
+ Button::new(tr!("Auto-Assign"))
+ .icon_class("fa fa-magic")
+ .on_activate(link.callback(|_| Msg::AutoAssignPreview)),
+ )
+ .with_spacer()
+ .with_child(
+ Button::new(tr!("Apply Pending"))
+ .icon_class("fa fa-play")
+ .on_activate(link.callback(|_| Msg::ApplyPending)),
+ )
+ .with_child(
+ Button::new(tr!("Clear Pending"))
+ .icon_class("fa fa-eraser")
+ .on_activate(
+ link.change_view_callback(|_| Some(ViewState::ConfirmClearPending)),
+ ),
+ )
+ .with_flex_spacer()
+ .with_child(
+ Button::refresh(ctx.loading()).on_activate({
+ let link = link.clone();
+ move |_| link.send_reload()
+ }),
+ )
+ .into(),
+ )
+ }
+
+ fn load(
+ &self,
+ ctx: &LoadableComponentContext<Self>,
+ ) -> Pin<Box<dyn Future<Output = Result<(), Error>>>> {
+ let link = ctx.link().clone();
+ Box::pin(async move {
+ let data: Vec<RemoteNodeStatus> = http_get(NODE_STATUS_URL, None).await?;
+ link.send_message(Msg::LoadFinished(data));
+ Ok(())
+ })
+ }
+
+ fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
+ Container::new()
+ .class("pwt-content-spacer")
+ .class(FlexFit)
+ .class(FlexDirection::Row)
+ .with_child(self.render_key_pool_panel(ctx))
+ .with_child(self.render_node_tree_panel(ctx))
+ .into()
+ }
+
+ fn dialog_view(
+ &self,
+ ctx: &LoadableComponentContext<Self>,
+ view_state: &Self::ViewState,
+ ) -> Option<Html> {
+ match view_state {
+ ViewState::ConfirmClearPending => {
+ use pwt::widget::ConfirmDialog;
+ Some(
+ ConfirmDialog::new(
+ tr!("Clear Pending Assignments"),
+ tr!("Remove all assignments that have not yet been applied to the remote nodes?"),
+ )
+ .on_confirm({
+ let link = ctx.link().clone();
+ move |_| link.send_message(Msg::ClearPending)
+ })
+ .into(),
+ )
+ }
+ ViewState::ConfirmAutoAssign(proposals) => {
+ Some(self.render_auto_assign_dialog(ctx, proposals))
+ }
+ ViewState::ConfirmRemoveSelectedNodeKey(key) => {
+ use pwt::widget::ConfirmDialog;
+ let link = ctx.link().clone();
+ let key_for_callback = key.clone();
+ Some(
+ ConfirmDialog::new(
+ tr!("Remove Key"),
+ tr!(
+ "Remove {key} from the key pool? This does not revoke the subscription on the remote node.",
+ key = key.clone(),
+ ),
+ )
+ .on_confirm(move |_| {
+ let link = link.clone();
+ let key = key_for_callback.clone();
+ link.clone().spawn(async move {
+ let url = format!(
+ "/subscriptions/keys/{}",
+ percent_encode_component(&key),
+ );
+ if let Err(err) = http_delete(&url, None).await {
+ link.show_error(tr!("Remove Key"), err.to_string(), true);
+ }
+ link.change_view(None);
+ link.send_reload();
+ });
+ })
+ .into(),
+ )
+ }
+ }
+ }
+}
+
+impl SubscriptionRegistryComp {
+ fn render_key_pool_panel(&self, ctx: &LoadableComponentContext<Self>) -> Panel {
+ // Reload the right-side node tree whenever the left-side key pool mutates, so a fresh
+ // assignment shows up as pending without forcing the operator to re-navigate.
+ let link = ctx.link().clone();
+ // Pass the current node-status snapshot into the grid so its Clear button can be
+ // disabled for synced bindings (orphan-prevention - mirrors the server-side refusal).
+ let statuses = Rc::new(self.last_node_data.clone());
+ Panel::new()
+ .class(FlexFit)
+ .border(true)
+ .style("flex", "3 1 0")
+ .min_width(300)
+ .title(tr!("Key Pool"))
+ .with_child(
+ SubscriptionKeyGrid::new()
+ .on_change(Callback::from(move |_| link.send_reload()))
+ .node_status(statuses),
+ )
+ }
+
+ fn render_node_tree_panel(&self, ctx: &LoadableComponentContext<Self>) -> Panel {
+ let table = DataTable::new(self.tree_columns.clone(), self.tree_store.clone())
+ .selection(self.node_selection.clone())
+ .striped(false)
+ .borderless(true)
+ .show_header(true)
+ .class(FlexFit);
+
+ let has_assigned = self.selected_assigned_key().is_some();
+ let clear_button = Button::new(tr!("Clear Assignment"))
+ .icon_class("fa fa-unlink")
+ .disabled(!has_assigned)
+ .on_activate(ctx.link().callback(|_| Msg::ClearSelectedNode));
+ let remove_button = Button::new(tr!("Remove"))
+ .icon_class("fa fa-trash-o")
+ .disabled(!has_assigned)
+ .on_activate(ctx.link().callback(|_| Msg::RemoveSelectedNodeKey));
+
+ Panel::new()
+ .class(FlexFit)
+ .border(true)
+ .style("flex", "4 1 0")
+ .min_width(400)
+ .title(tr!("Node Status"))
+ .with_tool(clear_button)
+ .with_tool(remove_button)
+ .with_child(table)
+ }
+
+ /// Pool key currently assigned to whatever node the operator selected in the tree.
+ ///
+ /// Returns None when no node row is selected, the selected entry is a remote-level
+ /// aggregate, or the node has no pool assignment.
+ fn selected_assigned_key(&self) -> Option<String> {
+ let key = self.node_selection.selected_key()?;
+ let raw = key.to_string();
+ let mut parts = raw.trim_start_matches('/').splitn(2, '/');
+ let remote = parts.next()?;
+ let node = parts.next()?;
+ self.last_node_data
+ .iter()
+ .find(|n| n.remote == remote && n.node == node)
+ .and_then(|n| n.assigned_key.clone())
+ }
+
+ fn render_auto_assign_dialog(
+ &self,
+ ctx: &LoadableComponentContext<Self>,
+ proposals: &[ProposedAssignment],
+ ) -> Html {
+ use pwt::widget::Dialog;
+
+ let store: Store<ProposedAssignment> = Store::with_extract_key(|p: &ProposedAssignment| {
+ format!("{}/{}", p.remote, p.node).into()
+ });
+ store.set_data(proposals.to_vec());
+
+ let link_close = ctx.link().clone();
+ let link_apply = ctx.link().clone();
+ let body = Column::new()
+ .class(Flex::Fill)
+ .class(Overflow::Hidden)
+ .min_height(0)
+ .padding(2)
+ .gap(2)
+ .min_width(600)
+ .with_child(Container::from_tag("p").with_child(tr!(
+ "The following {n} assignments are proposed. Click Apply to confirm.",
+ n = proposals.len(),
+ )))
+ .with_child(
+ DataTable::new(self.proposal_columns.clone(), store)
+ .striped(true)
+ .class(FlexFit)
+ .min_height(140),
+ )
+ .with_child(
+ Row::new()
+ .class(JustifyContent::FlexEnd)
+ .gap(2)
+ .padding_top(2)
+ .with_child(
+ Button::new(tr!("Cancel"))
+ .on_activate(move |_| link_close.change_view(None)),
+ )
+ .with_child(
+ Button::new(tr!("Apply"))
+ .on_activate(move |_| link_apply.send_message(Msg::AutoAssignApply)),
+ ),
+ );
+
+ Dialog::new(tr!("Auto-Assign Proposal"))
+ .resizable(true)
+ .width(700)
+ .min_width(500)
+ .min_height(300)
+ .max_height("80vh")
+ .on_close({
+ let link = ctx.link().clone();
+ move |_| link.change_view(None)
+ })
+ .with_child(body)
+ .into()
+ }
+}
diff --git a/ui/src/main_menu.rs b/ui/src/main_menu.rs
index 18988ea..eba02d5 100644
--- a/ui/src/main_menu.rs
+++ b/ui/src/main_menu.rs
@@ -15,6 +15,7 @@ use pdm_api_types::remotes::RemoteType;
use pdm_api_types::{PRIV_SYS_AUDIT, PRIV_SYS_MODIFY};
use crate::configuration::subscription_panel::SubscriptionPanel;
+use crate::configuration::subscription_registry::SubscriptionRegistryProps;
use crate::configuration::views::ViewGrid;
use crate::dashboard::view::View;
use crate::remotes::RemotesPanel;
@@ -292,6 +293,15 @@ impl Component for PdmMainMenu {
config_submenu,
);
+ register_view(
+ &mut menu,
+ &mut content,
+ tr!("Subscription Registry"),
+ "subscription-registry",
+ Some("fa fa-id-card"),
+ |_| SubscriptionRegistryProps::new().into(),
+ );
+
let mut admin_submenu = Menu::new();
register_view(
--
2.47.3
^ permalink raw reply related [relevance 1%]
* [PATCH 5/8] ui: add subscription registry with key pool and node status
@ 2026-05-07 7:17 1% ` Thomas Lamprecht
0 siblings, 0 replies; 19+ results
From: Thomas Lamprecht @ 2026-05-07 7:17 UTC (permalink / raw)
To: pdm-devel
Add a top-level Subscription Registry view with a Key Pool panel
next to a Node Status tree.
The Add dialog takes a textarea so an operator can paste several
keys at once, newline or comma separated; the backend validates the
whole batch atomically. The Assign dialog filters the remote
selector by the key's compatible remote type and pulls a node
selector for the chosen remote. PMG and POM keys leave Assign
disabled since PDM cannot push them to a remote yet.
Pending assignments show up in the Node Status panel with a clock
icon. Selecting a node there exposes Clear Assignment and Remove
actions: an operator often thinks in terms of "this node is wrong"
rather than tracking down the key entry on the left side.
The Key Pool panel notifies the parent on every successful pool
mutation so the Node Status tree reloads in lockstep, otherwise the
right side keeps showing the pre-mutation view until the operator
navigates away and back.
Apply Pending shows an info toast on the no-op reply instead of
opening a task dialog. Clear Pending hits the bulk backend endpoint
rather than issuing per-key PUTs from the UI.
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
ui/src/configuration/mod.rs | 2 +
ui/src/configuration/subscription_keys.rs | 449 +++++++++++
ui/src/configuration/subscription_registry.rs | 713 ++++++++++++++++++
ui/src/main_menu.rs | 10 +
4 files changed, 1174 insertions(+)
create mode 100644 ui/src/configuration/subscription_keys.rs
create mode 100644 ui/src/configuration/subscription_registry.rs
diff --git a/ui/src/configuration/mod.rs b/ui/src/configuration/mod.rs
index 6ffb64b..4180111 100644
--- a/ui/src/configuration/mod.rs
+++ b/ui/src/configuration/mod.rs
@@ -13,7 +13,9 @@ mod permission_path_selector;
mod webauthn;
pub use webauthn::WebauthnPanel;
+pub mod subscription_keys;
pub mod subscription_panel;
+pub mod subscription_registry;
pub mod views;
diff --git a/ui/src/configuration/subscription_keys.rs b/ui/src/configuration/subscription_keys.rs
new file mode 100644
index 0000000..c535e94
--- /dev/null
+++ b/ui/src/configuration/subscription_keys.rs
@@ -0,0 +1,449 @@
+use std::future::Future;
+use std::pin::Pin;
+use std::rc::Rc;
+
+use anyhow::Error;
+
+use pdm_api_types::remotes::RemoteType;
+use pdm_api_types::subscription::{ProductType, RemoteNodeStatus, SubscriptionKeyEntry};
+use yew::virtual_dom::{Key, VComp, VNode};
+
+use proxmox_yew_comp::percent_encoding::percent_encode_component;
+use proxmox_yew_comp::{http_delete, http_get, http_post, http_put, EditWindow};
+use proxmox_yew_comp::{
+ LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
+ LoadableComponentScopeExt, LoadableComponentState,
+};
+
+use pwt::css::FontStyle;
+use pwt::prelude::*;
+use pwt::state::{Selection, Store};
+use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader};
+use pwt::widget::form::{DisplayField, FormContext, TextArea};
+use pwt::widget::{Button, ConfirmDialog, Container, InputPanel, Toolbar};
+
+use crate::widget::{PveNodeSelector, RemoteSelector};
+
+const BASE_URL: &str = "/subscriptions/keys";
+
+#[derive(Properties, PartialEq, Clone)]
+pub struct SubscriptionKeyGrid {
+ /// Called after every successful pool mutation (add, assign, clear, remove). Lets the parent
+ /// view (the Subscription Registry) reload its own data so the Node Status side stays in
+ /// sync with the Key Pool side.
+ #[prop_or_default]
+ pub on_change: Option<Callback<()>>,
+
+ /// Latest live node-status snapshot from the parent view. Used to disable the Clear button
+ /// when the selected entry's binding is currently synced (the assigned key is the live
+ /// active key on its remote), since unassigning then would orphan the live subscription.
+ /// The server enforces the same gate; this prop just turns it into a UI affordance.
+ #[prop_or_default]
+ pub node_status: Rc<Vec<RemoteNodeStatus>>,
+}
+
+impl SubscriptionKeyGrid {
+ pub fn new() -> Self {
+ yew::props!(Self {})
+ }
+
+ pub fn on_change(mut self, cb: impl Into<Option<Callback<()>>>) -> Self {
+ self.on_change = cb.into();
+ self
+ }
+
+ pub fn node_status(mut self, statuses: Rc<Vec<RemoteNodeStatus>>) -> Self {
+ self.node_status = statuses;
+ self
+ }
+}
+
+impl Default for SubscriptionKeyGrid {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl From<SubscriptionKeyGrid> for VNode {
+ fn from(val: SubscriptionKeyGrid) -> Self {
+ VComp::new::<LoadableComponentMaster<SubscriptionKeyGridComp>>(Rc::new(val), None).into()
+ }
+}
+
+pub enum Msg {
+ LoadFinished(Vec<SubscriptionKeyEntry>),
+ Remove(Key),
+ Reload,
+}
+
+#[derive(PartialEq)]
+pub enum ViewState {
+ Add,
+ Assign,
+ Remove,
+}
+
+#[doc(hidden)]
+pub struct SubscriptionKeyGridComp {
+ state: LoadableComponentState<ViewState>,
+ store: Store<SubscriptionKeyEntry>,
+ columns: Rc<Vec<DataTableHeader<SubscriptionKeyEntry>>>,
+ selection: Selection,
+}
+
+pwt::impl_deref_mut_property!(
+ SubscriptionKeyGridComp,
+ state,
+ LoadableComponentState<ViewState>
+);
+
+impl SubscriptionKeyGridComp {
+ fn columns() -> Rc<Vec<DataTableHeader<SubscriptionKeyEntry>>> {
+ Rc::new(vec![
+ DataTableColumn::new(tr!("Key"))
+ .flex(2)
+ .get_property(|entry: &SubscriptionKeyEntry| entry.key.as_str())
+ .sort_order(true)
+ .into(),
+ DataTableColumn::new(tr!("Product"))
+ .width("80px")
+ .render(|entry: &SubscriptionKeyEntry| entry.product_type.to_string().into())
+ .into(),
+ DataTableColumn::new(tr!("Level"))
+ .width("90px")
+ .render(|entry: &SubscriptionKeyEntry| entry.level.to_string().into())
+ .into(),
+ DataTableColumn::new(tr!("Assignment"))
+ .flex(2)
+ .render(
+ |entry: &SubscriptionKeyEntry| match (&entry.remote, &entry.node) {
+ (Some(remote), Some(node)) => format!("{remote} / {node}").into(),
+ _ => Html::default(),
+ },
+ )
+ .into(),
+ ])
+ }
+
+ fn selected_entry(&self) -> Option<SubscriptionKeyEntry> {
+ let key = self.selection.selected_key()?;
+ self.store.read().lookup_record(&key).cloned()
+ }
+
+ fn create_add_dialog(&self, ctx: &LoadableComponentContext<Self>) -> Html {
+ EditWindow::new(tr!("Add Subscription Keys"))
+ .renderer(|_form_ctx| add_input_panel())
+ .on_submit(submit_add_keys)
+ .on_done(ctx.link().clone().callback(|_| Msg::Reload))
+ .into()
+ }
+
+ fn create_assign_dialog(
+ &self,
+ entry: &SubscriptionKeyEntry,
+ ctx: &LoadableComponentContext<Self>,
+ ) -> Html {
+ let key = entry.key.clone();
+ let product_type = entry.product_type;
+ EditWindow::new(tr!("Assign Key to Remote"))
+ .renderer({
+ let key = key.clone();
+ move |form_ctx| assign_input_panel(&key, product_type, form_ctx)
+ })
+ .on_submit({
+ let key = key.clone();
+ move |form| submit_assign(key.clone(), form)
+ })
+ .on_done(ctx.link().clone().callback(|_| Msg::Reload))
+ .into()
+ }
+}
+
+impl LoadableComponent for SubscriptionKeyGridComp {
+ type Properties = SubscriptionKeyGrid;
+ type Message = Msg;
+ type ViewState = ViewState;
+
+ fn create(ctx: &LoadableComponentContext<Self>) -> Self {
+ let selection = Selection::new().on_select({
+ let link = ctx.link().clone();
+ move |_| link.send_redraw()
+ });
+ Self {
+ state: LoadableComponentState::new(),
+ store: Store::with_extract_key(|entry: &SubscriptionKeyEntry| {
+ entry.key.as_str().into()
+ }),
+ columns: Self::columns(),
+ selection,
+ }
+ }
+
+ fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
+ match msg {
+ Msg::LoadFinished(data) => self.store.set_data(data),
+ Msg::Remove(key) => {
+ let id = key.to_string();
+ let link = ctx.link().clone();
+ ctx.link().spawn(async move {
+ let url = format!("{BASE_URL}/{}", percent_encode_component(&id));
+ if let Err(err) = http_delete(&url, None).await {
+ link.show_error(
+ tr!("Error"),
+ tr!("Could not remove {id}: {err}", id = id, err = err),
+ true,
+ );
+ }
+ link.send_message(Msg::Reload);
+ });
+ }
+ Msg::Reload => {
+ ctx.link().change_view(None);
+ ctx.link().send_reload();
+ if let Some(cb) = &ctx.props().on_change {
+ cb.emit(());
+ }
+ }
+ }
+ true
+ }
+
+ fn toolbar(&self, ctx: &LoadableComponentContext<Self>) -> Option<Html> {
+ let entry = self.selected_entry();
+ let has_selection = entry.is_some();
+ let is_assigned = entry.as_ref().map(|e| e.remote.is_some()).unwrap_or(false);
+ let synced_assignment = entry
+ .as_ref()
+ .map(|e| is_synced_assignment(e, &ctx.props().node_status))
+ .unwrap_or(false);
+ let assignable = entry
+ .as_ref()
+ .map(|e| {
+ e.product_type.matches_remote_type(RemoteType::Pve)
+ || e.product_type.matches_remote_type(RemoteType::Pbs)
+ })
+ .unwrap_or(false);
+ let link = ctx.link();
+
+ Some(
+ Toolbar::new()
+ .border_bottom(true)
+ .with_child(
+ Button::new(tr!("Add"))
+ .icon_class("fa fa-plus")
+ .on_activate(link.change_view_callback(|_| Some(ViewState::Add))),
+ )
+ .with_spacer()
+ .with_child(
+ Button::new(tr!("Remove Key"))
+ .icon_class("fa fa-trash-o")
+ .disabled(!has_selection || synced_assignment)
+ .on_activate(link.change_view_callback(|_| Some(ViewState::Remove))),
+ )
+ .with_spacer()
+ .with_child(
+ Button::new(tr!("Assign"))
+ .icon_class("fa fa-link")
+ .disabled(!has_selection || is_assigned || !assignable)
+ .on_activate(link.change_view_callback(|_| Some(ViewState::Assign))),
+ )
+ .into(),
+ )
+ }
+
+ fn load(
+ &self,
+ ctx: &LoadableComponentContext<Self>,
+ ) -> Pin<Box<dyn Future<Output = Result<(), Error>>>> {
+ let link = ctx.link().clone();
+ Box::pin(async move {
+ let data: Vec<SubscriptionKeyEntry> = http_get(BASE_URL, None).await?;
+ link.send_message(Msg::LoadFinished(data));
+ Ok(())
+ })
+ }
+
+ fn main_view(&self, _ctx: &LoadableComponentContext<Self>) -> Html {
+ DataTable::new(self.columns.clone(), self.store.clone())
+ .selection(self.selection.clone())
+ .into()
+ }
+
+ fn dialog_view(
+ &self,
+ ctx: &LoadableComponentContext<Self>,
+ view_state: &Self::ViewState,
+ ) -> Option<Html> {
+ match view_state {
+ ViewState::Add => Some(self.create_add_dialog(ctx)),
+ ViewState::Assign => self
+ .selected_entry()
+ .map(|entry| self.create_assign_dialog(&entry, ctx)),
+ ViewState::Remove => self.selection.selected_key().map(|key| {
+ ConfirmDialog::new(
+ tr!("Remove Key"),
+ tr!(
+ "Remove {key} from the key pool? This does not revoke the subscription.",
+ key = key.to_string(),
+ ),
+ )
+ .on_confirm({
+ let link = ctx.link().clone();
+ let key = key.clone();
+ move |_| link.send_message(Msg::Remove(key.clone()))
+ })
+ .into()
+ }),
+ }
+ }
+}
+
+/// Returns true when the pool entry's binding currently runs the same key on the remote and is
+/// Active - meaning a clear-assignment would orphan the live subscription. Mirrors the
+/// server-side gate; the operator should use Reissue Key in that state.
+fn is_synced_assignment(entry: &SubscriptionKeyEntry, statuses: &[RemoteNodeStatus]) -> bool {
+ let (Some(remote), Some(node)) = (entry.remote.as_deref(), entry.node.as_deref()) else {
+ return false;
+ };
+ statuses
+ .iter()
+ .find(|n| n.remote == remote && n.node == node)
+ .map(|n| {
+ n.status == proxmox_subscription::SubscriptionStatus::Active
+ && n.current_key.as_deref() == Some(entry.key.as_str())
+ })
+ .unwrap_or(false)
+}
+
+fn add_input_panel() -> Html {
+ let hint = Container::new()
+ .class(FontStyle::TitleSmall)
+ .class(pwt::css::Opacity::Quarter)
+ .padding_top(2)
+ .with_child(tr!(
+ "One key per line, or comma-separated. Only Proxmox VE and Proxmox Backup Server keys are accepted."
+ ));
+
+ // The textarea opts into `width: 100%` so it fills the InputPanel's grid cell instead of
+ // shrinking to browser-default cols.
+ InputPanel::new()
+ .padding(4)
+ .min_width(500)
+ .with_large_custom_child(
+ TextArea::new()
+ .name("keys")
+ .submit_empty(false)
+ .required(true)
+ .attribute("rows", "8")
+ .attribute("placeholder", tr!("Subscription key(s)"))
+ .style("width", "100%")
+ .style("box-sizing", "border-box"),
+ )
+ .with_large_custom_child(hint)
+ .into()
+}
+
+async fn submit_add_keys(form_ctx: FormContext) -> Result<(), Error> {
+ let raw = form_ctx.read().get_field_text("keys");
+ let keys: Vec<String> = raw
+ .split(|c: char| c.is_whitespace() || c == ',')
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .map(str::to_string)
+ .collect();
+
+ if keys.is_empty() {
+ anyhow::bail!(tr!("no keys provided"));
+ }
+
+ http_post(BASE_URL, Some(serde_json::json!({ "keys": keys }))).await
+}
+
+/// Map a subscription product type to the remote type its keys can drive.
+fn remote_type_for(product_type: ProductType) -> Option<RemoteType> {
+ if product_type.matches_remote_type(RemoteType::Pve) {
+ Some(RemoteType::Pve)
+ } else if product_type.matches_remote_type(RemoteType::Pbs) {
+ Some(RemoteType::Pbs)
+ } else {
+ None
+ }
+}
+
+fn assign_input_panel(key: &str, product_type: ProductType, form_ctx: &FormContext) -> Html {
+ let mut panel = InputPanel::new().padding(4).min_width(500).with_field(
+ tr!("Key"),
+ DisplayField::new()
+ .name("key")
+ .value(key.to_string())
+ .key("key-display"),
+ );
+
+ let Some(remote_type) = remote_type_for(product_type) else {
+ // Defensive: the toolbar disables Assign for these product types.
+ return panel
+ .with_large_custom_child(
+ Container::new()
+ .class(FontStyle::TitleSmall)
+ .class(pwt::css::Opacity::Quarter)
+ .with_child(tr!(
+ "PDM cannot manage {product} remotes yet; this key is parked in the pool.",
+ product = product_type.to_string(),
+ )),
+ )
+ .into();
+ };
+
+ panel = panel.with_field(
+ tr!("Remote"),
+ RemoteSelector::new()
+ .name("remote")
+ .remote_type(remote_type)
+ .required(true),
+ );
+
+ match remote_type {
+ RemoteType::Pve => {
+ let selected_remote = form_ctx.read().get_field_text("remote");
+ if selected_remote.is_empty() {
+ panel
+ .with_field(
+ tr!("Node"),
+ DisplayField::new()
+ .name("node")
+ .key("node-no-remote")
+ .value(AttrValue::from(tr!("Select a remote first."))),
+ )
+ .into()
+ } else {
+ // `PveNodeSelector` fetches its node list in `create` and does not re-fetch on
+ // prop change, so a per-remote `key` forces a fresh component when the operator
+ // picks a target.
+ panel
+ .with_field(
+ tr!("Node"),
+ PveNodeSelector::new(selected_remote.clone())
+ .name("node")
+ .key(format!("node-selector-{selected_remote}"))
+ .required(true),
+ )
+ .into()
+ }
+ }
+ RemoteType::Pbs => panel
+ .with_field(
+ tr!("Node"),
+ DisplayField::new()
+ .name("node")
+ .value(AttrValue::from("localhost"))
+ .key("node-localhost"),
+ )
+ .into(),
+ }
+}
+
+async fn submit_assign(key: String, form_ctx: FormContext) -> Result<(), Error> {
+ let data = form_ctx.get_submit_data();
+ let url = format!("{BASE_URL}/{}", percent_encode_component(&key));
+ http_put(&url, Some(data)).await
+}
diff --git a/ui/src/configuration/subscription_registry.rs b/ui/src/configuration/subscription_registry.rs
new file mode 100644
index 0000000..7ed96e6
--- /dev/null
+++ b/ui/src/configuration/subscription_registry.rs
@@ -0,0 +1,713 @@
+use std::future::Future;
+use std::pin::Pin;
+use std::rc::Rc;
+
+use anyhow::Error;
+
+use yew::virtual_dom::{Key, VComp, VNode};
+
+use proxmox_yew_comp::percent_encoding::percent_encode_component;
+use proxmox_yew_comp::{http_delete, http_get, http_post, http_put};
+use proxmox_yew_comp::{
+ LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
+ LoadableComponentScopeExt, LoadableComponentState,
+};
+
+use pwt::css::{AlignItems, Flex, FlexDirection, FlexFit, FontColor, JustifyContent, Overflow};
+use pwt::prelude::*;
+use pwt::props::{ContainerBuilder, ExtractPrimaryKey, WidgetBuilder};
+use pwt::state::{Selection, SlabTree, Store, TreeStore};
+use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader};
+use pwt::widget::{Button, Column, Container, Fa, Panel, Row, Toolbar, Tooltip};
+
+use pdm_api_types::subscription::{ProposedAssignment, RemoteNodeStatus, SubscriptionLevel};
+
+use super::subscription_keys::SubscriptionKeyGrid;
+
+const NODE_STATUS_URL: &str = "/subscriptions/node-status";
+const AUTO_ASSIGN_URL: &str = "/subscriptions/auto-assign";
+const APPLY_PENDING_URL: &str = "/subscriptions/apply-pending";
+const CLEAR_PENDING_URL: &str = "/subscriptions/clear-pending";
+
+/// Map a [`SubscriptionStatus`] to the icon shown in subscription panels.
+///
+/// Public so the dashboard subscriptions panel can render the same icon for the same state
+/// without redefining the mapping. The 4-variant `proxmox_yew_comp::Status` does not cover
+/// every subscription state (New, Expired, Suspended need their own icons), hence the dedicated
+/// helper.
+pub fn subscription_status_icon(status: proxmox_subscription::SubscriptionStatus) -> Fa {
+ use proxmox_subscription::SubscriptionStatus as S;
+ match status {
+ S::Active => Fa::new("check-circle").class(FontColor::Success),
+ S::New => Fa::new("clock-o").class(FontColor::Primary),
+ S::NotFound => Fa::new("exclamation-circle").class(FontColor::Error),
+ S::Invalid => Fa::new("times-circle").class(FontColor::Warning),
+ S::Expired => Fa::new("clock-o").class(FontColor::Warning),
+ S::Suspended => Fa::new("ban").class(FontColor::Error),
+ }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+enum NodeTreeEntry {
+ Root,
+ Remote {
+ name: String,
+ ty: pdm_api_types::remotes::RemoteType,
+ active: u32,
+ total: u32,
+ },
+ Node {
+ data: RemoteNodeStatus,
+ /// If true, this is the only node in its remote and is shown at the top level under the
+ /// remote name instead of nested.
+ standalone: bool,
+ },
+}
+
+impl NodeTreeEntry {
+ fn name(&self) -> &str {
+ match self {
+ Self::Root => "",
+ Self::Remote { name, .. } => name,
+ Self::Node { data, standalone } => {
+ if *standalone {
+ &data.remote
+ } else {
+ &data.node
+ }
+ }
+ }
+ }
+}
+
+impl ExtractPrimaryKey for NodeTreeEntry {
+ fn extract_key(&self) -> Key {
+ Key::from(match self {
+ NodeTreeEntry::Root => "/".to_string(),
+ NodeTreeEntry::Remote { name, .. } => format!("/{name}"),
+ NodeTreeEntry::Node { data, .. } => format!("/{}/{}", data.remote, data.node),
+ })
+ }
+}
+
+fn build_tree(nodes: Vec<RemoteNodeStatus>) -> SlabTree<NodeTreeEntry> {
+ use std::collections::BTreeMap;
+
+ let mut by_remote: BTreeMap<String, Vec<RemoteNodeStatus>> = BTreeMap::new();
+ for n in nodes {
+ by_remote.entry(n.remote.clone()).or_default().push(n);
+ }
+
+ let mut tree = SlabTree::new();
+ let mut root = tree.set_root(NodeTreeEntry::Root);
+ root.set_expanded(true);
+
+ for (remote_name, remote_nodes) in &by_remote {
+ let total = remote_nodes.len() as u32;
+ let active = remote_nodes
+ .iter()
+ .filter(|n| n.status == proxmox_subscription::SubscriptionStatus::Active)
+ .count() as u32;
+
+ let ty = remote_nodes.first().map(|n| n.ty).unwrap_or_default();
+
+ if remote_nodes.len() == 1 {
+ root.append(NodeTreeEntry::Node {
+ data: remote_nodes[0].clone(),
+ standalone: true,
+ });
+ } else {
+ let mut remote_entry = root.append(NodeTreeEntry::Remote {
+ name: remote_name.clone(),
+ ty,
+ active,
+ total,
+ });
+ remote_entry.set_expanded(true);
+ for n in remote_nodes {
+ remote_entry.append(NodeTreeEntry::Node {
+ data: n.clone(),
+ standalone: false,
+ });
+ }
+ }
+ }
+
+ tree
+}
+
+#[derive(Properties, PartialEq, Clone)]
+pub struct SubscriptionRegistryProps {}
+
+impl SubscriptionRegistryProps {
+ pub fn new() -> Self {
+ yew::props!(Self {})
+ }
+}
+
+impl Default for SubscriptionRegistryProps {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl From<SubscriptionRegistryProps> for VNode {
+ fn from(val: SubscriptionRegistryProps) -> Self {
+ VComp::new::<LoadableComponentMaster<SubscriptionRegistryComp>>(Rc::new(val), None).into()
+ }
+}
+
+pub enum Msg {
+ LoadFinished(Vec<RemoteNodeStatus>),
+ AutoAssignPreview,
+ AutoAssignApply,
+ ApplyPending,
+ ClearPending,
+ /// Clear the pool's pin on the currently-selected node by un-assigning its key.
+ ClearSelectedNode,
+ /// Remove the pool entry currently pinned to the selected node.
+ RemoveSelectedNodeKey,
+}
+
+#[derive(PartialEq)]
+pub enum ViewState {
+ ConfirmAutoAssign(Vec<ProposedAssignment>),
+ ConfirmClearPending,
+ ConfirmRemoveSelectedNodeKey(String),
+}
+
+#[doc(hidden)]
+pub struct SubscriptionRegistryComp {
+ state: LoadableComponentState<ViewState>,
+ tree_store: TreeStore<NodeTreeEntry>,
+ tree_columns: Rc<Vec<DataTableHeader<NodeTreeEntry>>>,
+ proposal_columns: Rc<Vec<DataTableHeader<ProposedAssignment>>>,
+ node_selection: Selection,
+ last_node_data: Vec<RemoteNodeStatus>,
+}
+
+pwt::impl_deref_mut_property!(
+ SubscriptionRegistryComp,
+ state,
+ LoadableComponentState<ViewState>
+);
+
+fn tree_sorter(a: &NodeTreeEntry, b: &NodeTreeEntry) -> std::cmp::Ordering {
+ a.name().cmp(b.name())
+}
+
+impl SubscriptionRegistryComp {
+ fn tree_columns(store: TreeStore<NodeTreeEntry>) -> Rc<Vec<DataTableHeader<NodeTreeEntry>>> {
+ Rc::new(vec![
+ DataTableColumn::new(tr!("Name"))
+ .tree_column(store)
+ .flex(3)
+ .render(|entry: &NodeTreeEntry| {
+ let (icon, name) = match entry {
+ NodeTreeEntry::Root => return Html::default(),
+ NodeTreeEntry::Remote { name, ty, .. } => {
+ let icon = if *ty == pdm_api_types::remotes::RemoteType::Pbs {
+ "building-o"
+ } else {
+ "server"
+ };
+ (icon, name.as_str())
+ }
+ NodeTreeEntry::Node {
+ data: n,
+ standalone,
+ } => {
+ let icon = if n.ty == pdm_api_types::remotes::RemoteType::Pbs {
+ "building-o"
+ } else {
+ "building"
+ };
+ let label = if *standalone { &n.remote } else { &n.node };
+ (icon, label.as_str())
+ }
+ };
+ Row::new()
+ .class(AlignItems::Baseline)
+ .gap(2)
+ .with_child(Fa::new(icon))
+ .with_child(name)
+ .into()
+ })
+ .sorter(tree_sorter)
+ .into(),
+ DataTableColumn::new(tr!("Sockets"))
+ .width("70px")
+ .render(|entry: &NodeTreeEntry| match entry {
+ NodeTreeEntry::Node { data: n, .. } => {
+ n.sockets.map(|s| s.to_string()).unwrap_or_default().into()
+ }
+ _ => Html::default(),
+ })
+ .into(),
+ DataTableColumn::new(tr!("Status"))
+ .width("120px")
+ .render(|entry: &NodeTreeEntry| match entry {
+ NodeTreeEntry::Node { data: n, .. } => Row::new()
+ .class(AlignItems::Baseline)
+ .gap(2)
+ .with_child(subscription_status_icon(n.status))
+ .with_child(n.status.to_string())
+ .into(),
+ NodeTreeEntry::Remote { active, total, .. } => {
+ let icon = if active == total {
+ Fa::new("check-circle").class(FontColor::Success)
+ } else if *active == 0 {
+ Fa::new("exclamation-circle").class(FontColor::Error)
+ } else {
+ Fa::new("exclamation-triangle").class(FontColor::Warning)
+ };
+ Tooltip::new(
+ Row::new()
+ .class(AlignItems::Baseline)
+ .gap(2)
+ .with_child(icon)
+ .with_child(format!("{active}/{total}")),
+ )
+ .tip(tr!(
+ "{active} of {total} nodes subscribed",
+ active = active,
+ total = total,
+ ))
+ .into()
+ }
+ _ => Html::default(),
+ })
+ .into(),
+ DataTableColumn::new(tr!("Level"))
+ .width("90px")
+ .render(|entry: &NodeTreeEntry| match entry {
+ NodeTreeEntry::Node { data: n, .. } if n.level != SubscriptionLevel::None => {
+ n.level.to_string().into()
+ }
+ _ => Html::default(),
+ })
+ .into(),
+ DataTableColumn::new(tr!("Key"))
+ .flex(2)
+ .render(|entry: &NodeTreeEntry| match entry {
+ NodeTreeEntry::Node { data: n, .. } => key_cell(n),
+ _ => Html::default(),
+ })
+ .into(),
+ ])
+ }
+
+ fn proposal_columns() -> Rc<Vec<DataTableHeader<ProposedAssignment>>> {
+ Rc::new(vec![
+ DataTableColumn::new(tr!("Remote / Node"))
+ .flex(2)
+ .render(|p: &ProposedAssignment| format!("{} / {}", p.remote, p.node).into())
+ .into(),
+ DataTableColumn::new(tr!("Key"))
+ .flex(2)
+ .render(|p: &ProposedAssignment| {
+ Container::from_tag("span")
+ .class(pwt::css::FontStyle::LabelMedium)
+ .with_child(p.key.clone())
+ .into()
+ })
+ .into(),
+ DataTableColumn::new(tr!("Sockets (node / key)"))
+ .width("160px")
+ .render(|p: &ProposedAssignment| {
+ let label = match (p.node_sockets, p.key_sockets) {
+ (Some(ns), Some(ks)) => format!("{ns} / {ks}"),
+ (Some(ns), None) => format!("{ns} / -"),
+ (None, Some(ks)) => format!("- / {ks}"),
+ _ => String::new(),
+ };
+ label.into()
+ })
+ .into(),
+ ])
+ }
+}
+
+fn key_cell(n: &RemoteNodeStatus) -> Html {
+ let assigned = n.assigned_key.as_deref();
+ let current = n.current_key.as_deref();
+
+ // Pending = pool key assigned but the node doesn't have an active subscription yet (the key
+ // still needs to be pushed).
+ let pending =
+ assigned.is_some() && n.status != proxmox_subscription::SubscriptionStatus::Active;
+
+ match (assigned, current) {
+ (Some(a), Some(c)) if a != c => Row::new()
+ .class(AlignItems::Baseline)
+ .gap(2)
+ .with_child(Fa::new("clock-o").class(FontColor::Warning))
+ .with_child(format!("{a} \u{2192} {c}"))
+ .into(),
+ _ => {
+ let text = current.or(assigned).unwrap_or("");
+ if pending {
+ Row::new()
+ .class(AlignItems::Baseline)
+ .gap(2)
+ .with_child(Fa::new("clock-o").class(FontColor::Warning))
+ .with_child(text)
+ .into()
+ } else {
+ text.into()
+ }
+ }
+ }
+}
+
+impl LoadableComponent for SubscriptionRegistryComp {
+ type Properties = SubscriptionRegistryProps;
+ type Message = Msg;
+ type ViewState = ViewState;
+
+ fn create(ctx: &LoadableComponentContext<Self>) -> Self {
+ let store = TreeStore::new().view_root(false);
+ store.set_sorter(tree_sorter);
+
+ let node_selection = Selection::new().on_select({
+ let link = ctx.link().clone();
+ move |_| link.send_redraw()
+ });
+
+ Self {
+ state: LoadableComponentState::new(),
+ tree_store: store.clone(),
+ tree_columns: Self::tree_columns(store),
+ proposal_columns: Self::proposal_columns(),
+ node_selection,
+ last_node_data: Vec::new(),
+ }
+ }
+
+ fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
+ match msg {
+ Msg::LoadFinished(data) => {
+ self.last_node_data = data.clone();
+ let tree = build_tree(data);
+ self.tree_store.write().update_root_tree(tree);
+ }
+ Msg::AutoAssignPreview => {
+ let link = ctx.link().clone();
+ ctx.link().spawn(async move {
+ match http_post::<Vec<ProposedAssignment>>(AUTO_ASSIGN_URL, None).await {
+ Ok(proposals) if proposals.is_empty() => {
+ link.show_error(
+ tr!("Auto-Assign"),
+ tr!("No suitable unassigned keys for the remaining nodes."),
+ false,
+ );
+ }
+ Ok(proposals) => {
+ link.change_view(Some(ViewState::ConfirmAutoAssign(proposals)));
+ }
+ Err(err) => link.show_error(tr!("Auto-Assign"), err.to_string(), true),
+ }
+ });
+ }
+ Msg::AutoAssignApply => {
+ let link = ctx.link().clone();
+ ctx.link().spawn(async move {
+ let url = format!("{AUTO_ASSIGN_URL}?apply=1");
+ match http_post::<Vec<ProposedAssignment>>(&url, None).await {
+ Ok(_) => {
+ link.change_view(None);
+ link.send_reload();
+ }
+ Err(err) => link.show_error(tr!("Auto-Assign"), err.to_string(), true),
+ }
+ });
+ }
+ Msg::ApplyPending => {
+ let link = ctx.link().clone();
+ ctx.link().spawn(async move {
+ match http_post::<Option<String>>(APPLY_PENDING_URL, None).await {
+ Ok(None) => link.show_error(
+ tr!("Apply Pending"),
+ tr!("No pending assignments. Every assigned key is already active on its remote node."),
+ false,
+ ),
+ Ok(Some(upid)) => link.show_task_progres(upid),
+ Err(err) => link.show_error(tr!("Apply"), err.to_string(), true),
+ }
+ });
+ }
+ Msg::ClearPending => {
+ let link = ctx.link().clone();
+ ctx.link().spawn(async move {
+ match http_post::<serde_json::Value>(CLEAR_PENDING_URL, None).await {
+ Ok(_) => {
+ link.change_view(None);
+ link.send_reload();
+ }
+ Err(err) => link.show_error(tr!("Clear Pending"), err.to_string(), true),
+ }
+ });
+ }
+ Msg::ClearSelectedNode => {
+ let Some(key) = self.selected_assigned_key() else {
+ return false;
+ };
+ let link = ctx.link().clone();
+ ctx.link().spawn(async move {
+ let url = format!("/subscriptions/keys/{}", percent_encode_component(&key),);
+ if let Err(err) = http_put::<()>(&url, Some(serde_json::json!({}))).await {
+ link.show_error(tr!("Clear Assignment"), err.to_string(), true);
+ }
+ link.send_reload();
+ });
+ }
+ Msg::RemoveSelectedNodeKey => {
+ let Some(key) = self.selected_assigned_key() else {
+ return false;
+ };
+ ctx.link()
+ .change_view(Some(ViewState::ConfirmRemoveSelectedNodeKey(key)));
+ }
+ }
+ true
+ }
+
+ fn toolbar(&self, ctx: &LoadableComponentContext<Self>) -> Option<Html> {
+ let link = ctx.link();
+ Some(
+ Toolbar::new()
+ .border_bottom(true)
+ .with_child(
+ Button::new(tr!("Auto-Assign"))
+ .icon_class("fa fa-magic")
+ .on_activate(link.callback(|_| Msg::AutoAssignPreview)),
+ )
+ .with_spacer()
+ .with_child(
+ Button::new(tr!("Apply Pending"))
+ .icon_class("fa fa-play")
+ .on_activate(link.callback(|_| Msg::ApplyPending)),
+ )
+ .with_child(
+ Button::new(tr!("Clear Pending"))
+ .icon_class("fa fa-eraser")
+ .on_activate(
+ link.change_view_callback(|_| Some(ViewState::ConfirmClearPending)),
+ ),
+ )
+ .with_flex_spacer()
+ .with_child(
+ Button::refresh(ctx.loading()).on_activate({
+ let link = link.clone();
+ move |_| link.send_reload()
+ }),
+ )
+ .into(),
+ )
+ }
+
+ fn load(
+ &self,
+ ctx: &LoadableComponentContext<Self>,
+ ) -> Pin<Box<dyn Future<Output = Result<(), Error>>>> {
+ let link = ctx.link().clone();
+ Box::pin(async move {
+ let data: Vec<RemoteNodeStatus> = http_get(NODE_STATUS_URL, None).await?;
+ link.send_message(Msg::LoadFinished(data));
+ Ok(())
+ })
+ }
+
+ fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
+ Container::new()
+ .class("pwt-content-spacer")
+ .class(FlexFit)
+ .class(FlexDirection::Row)
+ .with_child(self.render_key_pool_panel(ctx))
+ .with_child(self.render_node_tree_panel(ctx))
+ .into()
+ }
+
+ fn dialog_view(
+ &self,
+ ctx: &LoadableComponentContext<Self>,
+ view_state: &Self::ViewState,
+ ) -> Option<Html> {
+ match view_state {
+ ViewState::ConfirmClearPending => {
+ use pwt::widget::ConfirmDialog;
+ Some(
+ ConfirmDialog::new(
+ tr!("Clear Pending Assignments"),
+ tr!("Remove all assignments that have not yet been applied to the remote nodes?"),
+ )
+ .on_confirm({
+ let link = ctx.link().clone();
+ move |_| link.send_message(Msg::ClearPending)
+ })
+ .into(),
+ )
+ }
+ ViewState::ConfirmAutoAssign(proposals) => {
+ Some(self.render_auto_assign_dialog(ctx, proposals))
+ }
+ ViewState::ConfirmRemoveSelectedNodeKey(key) => {
+ use pwt::widget::ConfirmDialog;
+ let link = ctx.link().clone();
+ let key_for_callback = key.clone();
+ Some(
+ ConfirmDialog::new(
+ tr!("Remove Key"),
+ tr!(
+ "Remove {key} from the key pool? This does not revoke the subscription on the remote node.",
+ key = key.clone(),
+ ),
+ )
+ .on_confirm(move |_| {
+ let link = link.clone();
+ let key = key_for_callback.clone();
+ link.clone().spawn(async move {
+ let url = format!(
+ "/subscriptions/keys/{}",
+ percent_encode_component(&key),
+ );
+ if let Err(err) = http_delete(&url, None).await {
+ link.show_error(tr!("Remove Key"), err.to_string(), true);
+ }
+ link.change_view(None);
+ link.send_reload();
+ });
+ })
+ .into(),
+ )
+ }
+ }
+ }
+}
+
+impl SubscriptionRegistryComp {
+ fn render_key_pool_panel(&self, ctx: &LoadableComponentContext<Self>) -> Panel {
+ // Reload the right-side node tree whenever the left-side key pool mutates, so a fresh
+ // assignment shows up as pending without forcing the operator to re-navigate.
+ let link = ctx.link().clone();
+ // Pass the current node-status snapshot into the grid so its Clear button can be
+ // disabled for synced bindings (orphan-prevention - mirrors the server-side refusal).
+ let statuses = Rc::new(self.last_node_data.clone());
+ Panel::new()
+ .class(FlexFit)
+ .border(true)
+ .style("flex", "3 1 0")
+ .min_width(300)
+ .title(tr!("Key Pool"))
+ .with_child(
+ SubscriptionKeyGrid::new()
+ .on_change(Callback::from(move |_| link.send_reload()))
+ .node_status(statuses),
+ )
+ }
+
+ fn render_node_tree_panel(&self, ctx: &LoadableComponentContext<Self>) -> Panel {
+ let table = DataTable::new(self.tree_columns.clone(), self.tree_store.clone())
+ .selection(self.node_selection.clone())
+ .striped(false)
+ .borderless(true)
+ .show_header(true)
+ .class(FlexFit);
+
+ let has_assigned = self.selected_assigned_key().is_some();
+ let clear_button = Button::new(tr!("Clear Assignment"))
+ .icon_class("fa fa-unlink")
+ .disabled(!has_assigned)
+ .on_activate(ctx.link().callback(|_| Msg::ClearSelectedNode));
+ let remove_button = Button::new(tr!("Remove"))
+ .icon_class("fa fa-trash-o")
+ .disabled(!has_assigned)
+ .on_activate(ctx.link().callback(|_| Msg::RemoveSelectedNodeKey));
+
+ Panel::new()
+ .class(FlexFit)
+ .border(true)
+ .style("flex", "4 1 0")
+ .min_width(400)
+ .title(tr!("Node Status"))
+ .with_tool(clear_button)
+ .with_tool(remove_button)
+ .with_child(table)
+ }
+
+ /// Pool key currently assigned to whatever node the operator selected in the tree.
+ ///
+ /// Returns None when no node row is selected, the selected entry is a remote-level
+ /// aggregate, or the node has no pool assignment.
+ fn selected_assigned_key(&self) -> Option<String> {
+ let key = self.node_selection.selected_key()?;
+ let raw = key.to_string();
+ let mut parts = raw.trim_start_matches('/').splitn(2, '/');
+ let remote = parts.next()?;
+ let node = parts.next()?;
+ self.last_node_data
+ .iter()
+ .find(|n| n.remote == remote && n.node == node)
+ .and_then(|n| n.assigned_key.clone())
+ }
+
+ fn render_auto_assign_dialog(
+ &self,
+ ctx: &LoadableComponentContext<Self>,
+ proposals: &[ProposedAssignment],
+ ) -> Html {
+ use pwt::widget::Dialog;
+
+ let store: Store<ProposedAssignment> = Store::with_extract_key(|p: &ProposedAssignment| {
+ format!("{}/{}", p.remote, p.node).into()
+ });
+ store.set_data(proposals.to_vec());
+
+ let link_close = ctx.link().clone();
+ let link_apply = ctx.link().clone();
+ let body = Column::new()
+ .class(Flex::Fill)
+ .class(Overflow::Hidden)
+ .min_height(0)
+ .padding(2)
+ .gap(2)
+ .min_width(600)
+ .with_child(Container::from_tag("p").with_child(tr!(
+ "The following {n} assignments are proposed. Click Apply to confirm.",
+ n = proposals.len(),
+ )))
+ .with_child(
+ DataTable::new(self.proposal_columns.clone(), store)
+ .striped(true)
+ .class(FlexFit)
+ .min_height(140),
+ )
+ .with_child(
+ Row::new()
+ .class(JustifyContent::FlexEnd)
+ .gap(2)
+ .padding_top(2)
+ .with_child(
+ Button::new(tr!("Cancel"))
+ .on_activate(move |_| link_close.change_view(None)),
+ )
+ .with_child(
+ Button::new(tr!("Apply"))
+ .on_activate(move |_| link_apply.send_message(Msg::AutoAssignApply)),
+ ),
+ );
+
+ Dialog::new(tr!("Auto-Assign Proposal"))
+ .resizable(true)
+ .width(700)
+ .min_width(500)
+ .min_height(300)
+ .max_height("80vh")
+ .on_close({
+ let link = ctx.link().clone();
+ move |_| link.change_view(None)
+ })
+ .with_child(body)
+ .into()
+ }
+}
diff --git a/ui/src/main_menu.rs b/ui/src/main_menu.rs
index 18988ea..eba02d5 100644
--- a/ui/src/main_menu.rs
+++ b/ui/src/main_menu.rs
@@ -15,6 +15,7 @@ use pdm_api_types::remotes::RemoteType;
use pdm_api_types::{PRIV_SYS_AUDIT, PRIV_SYS_MODIFY};
use crate::configuration::subscription_panel::SubscriptionPanel;
+use crate::configuration::subscription_registry::SubscriptionRegistryProps;
use crate::configuration::views::ViewGrid;
use crate::dashboard::view::View;
use crate::remotes::RemotesPanel;
@@ -292,6 +293,15 @@ impl Component for PdmMainMenu {
config_submenu,
);
+ register_view(
+ &mut menu,
+ &mut content,
+ tr!("Subscription Registry"),
+ "subscription-registry",
+ Some("fa fa-id-card"),
+ |_| SubscriptionRegistryProps::new().into(),
+ );
+
let mut admin_submenu = Menu::new();
register_view(
--
2.47.3
^ permalink raw reply related [relevance 1%]
* [PATCH proxmox-yew-comp v6 2/2] loadable component: don't trigger reload when starting a task
@ 2026-03-09 8:36 5% ` Lukas Wagner
0 siblings, 0 replies; 19+ results
From: Lukas Wagner @ 2026-03-09 8:36 UTC (permalink / raw)
To: pdm-devel
The view will be reloaded anyways when the task viewer closes.
Usually, one wants to start a new task to produce/refresh some data
which should later be displayed in the component, so it makes little
sense to reload before the task actually finishes.
This avoids an issue with the previous commit. The double-load would
reset 'fresh_load' after the first load, leading to the new data not
being correctly displayed after the second load.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
Reviewed-by: Shannon Sterz <s.sterz@proxmox.com>
---
src/loadable_component.rs | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/loadable_component.rs b/src/loadable_component.rs
index 0235f17..2ec2ad9 100644
--- a/src/loadable_component.rs
+++ b/src/loadable_component.rs
@@ -332,7 +332,6 @@ impl<M, V: PartialEq, T: 'static + LoadableComponent<Message = M, ViewState = V>
self.send_message(Msg::Spawn(Box::pin(async move {
match command_future.await {
Ok(task_id) => {
- link.send_reload();
if short {
link.show_task_progres(task_id);
} else {
--
2.47.3
^ permalink raw reply related [relevance 5%]
* [PATCH proxmox-yew-comp v5 2/2] loadable component: don't trigger reload when starting a task
@ 2026-03-03 8:18 5% ` Lukas Wagner
0 siblings, 0 replies; 19+ results
From: Lukas Wagner @ 2026-03-03 8:18 UTC (permalink / raw)
To: pdm-devel
The view will be reloaded anyways when the task viewer closes.
Usually, one wants to start a new task to produce/refresh some data
which should later be displayed in the component, so it makes little
sense to reload before the task actually finishes.
This avoids an issue with the previous commit. The double-load would
reset 'fresh_load' after the first load, leading to the new data not
being correctly displayed after the second load.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
Reviewed-by: Shannon Sterz <s.sterz@proxmox.com>
---
src/loadable_component.rs | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/loadable_component.rs b/src/loadable_component.rs
index 0235f17..2ec2ad9 100644
--- a/src/loadable_component.rs
+++ b/src/loadable_component.rs
@@ -332,7 +332,6 @@ impl<M, V: PartialEq, T: 'static + LoadableComponent<Message = M, ViewState = V>
self.send_message(Msg::Spawn(Box::pin(async move {
match command_future.await {
Ok(task_id) => {
- link.send_reload();
if short {
link.show_task_progres(task_id);
} else {
--
2.47.3
^ permalink raw reply related [relevance 5%]
* [PATCH proxmox-yew-comp v4 2/2] loadable component: don't trigger reload when starting a task
@ 2026-02-06 13:44 5% ` Lukas Wagner
0 siblings, 0 replies; 19+ results
From: Lukas Wagner @ 2026-02-06 13:44 UTC (permalink / raw)
To: pdm-devel
The view will be reloaded anyways when the task viewer closes.
Usually, one wants to start a new task to produce/refresh some data
which should later be displayed in the component, so it makes little
sense to reload before the task actually finishes.
This avoids an issue with the previous commit. The double-load would
reset 'fresh_load' after the first load, leading to the new data not
being correctly displayed after the second load.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
Reviewed-by: Shannon Sterz <s.sterz@proxmox.com>
---
src/loadable_component.rs | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/loadable_component.rs b/src/loadable_component.rs
index 0235f17..2ec2ad9 100644
--- a/src/loadable_component.rs
+++ b/src/loadable_component.rs
@@ -332,7 +332,6 @@ impl<M, V: PartialEq, T: 'static + LoadableComponent<Message = M, ViewState = V>
self.send_message(Msg::Spawn(Box::pin(async move {
match command_future.await {
Ok(task_id) => {
- link.send_reload();
if short {
link.show_task_progres(task_id);
} else {
--
2.47.3
^ permalink raw reply related [relevance 5%]
* [pdm-devel] [PATCH proxmox-yew-comp v3 2/2] loadable component: don't trigger reload when starting a task
@ 2025-12-29 15:30 5% ` Lukas Wagner
0 siblings, 0 replies; 19+ results
From: Lukas Wagner @ 2025-12-29 15:30 UTC (permalink / raw)
To: pdm-devel
The view will be reloaded anyways when the task viewer closes.
Usually, one wants to start a new task to produce/refresh some data
which should later be displayed in the component, so it makes little
sense to reload before the task actually finishes.
This avoids an issue with the previous commit. The double-load would
reset 'fresh_load' after the first load, leading to the new data not
being correctly displayed after the second load.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
Reviewed-by: Shannon Sterz <s.sterz@proxmox.com>
---
src/loadable_component.rs | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/loadable_component.rs b/src/loadable_component.rs
index 389ae28..7e26b24 100644
--- a/src/loadable_component.rs
+++ b/src/loadable_component.rs
@@ -333,7 +333,6 @@ impl<M, V: PartialEq, T: 'static + LoadableComponent<Message = M, ViewState = V>
self.send_message(Msg::Spawn(Box::pin(async move {
match command_future.await {
Ok(task_id) => {
- link.send_reload();
if short {
link.show_task_progres(task_id);
} else {
--
2.47.3
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply related [relevance 5%]
* [pdm-devel] [PATCH proxmox-yew-comp v2 2/2] loadable component: don't trigger reload when starting a task
@ 2025-12-18 14:20 5% ` Lukas Wagner
0 siblings, 0 replies; 19+ results
From: Lukas Wagner @ 2025-12-18 14:20 UTC (permalink / raw)
To: pdm-devel
The view will be reloaded anyways when the task viewer closes.
Usually, one wants to start a new task to produce/refresh some data
which should later be displayed in the component, so it makes little
sense to reload before the task actually finishes.
This avoids an issue with the previous commit. The double-load would
reset 'fresh_load' after the first load, leading to the new data not
being correctly displayed after the second load.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
src/loadable_component.rs | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/loadable_component.rs b/src/loadable_component.rs
index 389ae28..7e26b24 100644
--- a/src/loadable_component.rs
+++ b/src/loadable_component.rs
@@ -333,7 +333,6 @@ impl<M, V: PartialEq, T: 'static + LoadableComponent<Message = M, ViewState = V>
self.send_message(Msg::Spawn(Box::pin(async move {
match command_future.await {
Ok(task_id) => {
- link.send_reload();
if short {
link.show_task_progres(task_id);
} else {
--
2.47.3
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply related [relevance 5%]
* [yew-devel] [RFC yew-comp V2] refactor: move LoadableComponent state into component implementations
@ 2025-12-10 9:28 2% Dietmar Maurer
0 siblings, 0 replies; 19+ results
From: Dietmar Maurer @ 2025-12-10 9:28 UTC (permalink / raw)
To: yew-devel
Major Refactoring of the `LoadableComponent` system.
Encapsulate component state (loading, error, view_state) into a new
`LoadableComponentState` struct.
Instead of `LoadableComponentMaster` managing the state externally,
the concrete components now own their `LoadableComponentState`.
- Use `impl_deref_mut_property`LoadableComponentState` struct. to implement
Deref/DerefMut for components, allowing `LoadableComponentMaster` to
access the state transparently.
- `LoadableComponentContext` is now a normal yew Scope (removed custom implementation)
- Migrate all `LoadableComponent` implementations (ACL, ACME, APT, Network,
User/Token/TFA views, etc.) to the new pattern.
- avoid useless Redraw/Datachange/Refresh messages, because `LoadableComponentMaster`
already implements that.
Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
---
Changes in V2:
- fix typos found by Thomas
- avoid send_custom_message() and custom_callback()
src/acl/acl_view.rs | 24 +-
src/acme/acme_accounts.rs | 28 +-
src/acme/acme_domains.rs | 31 +-
src/acme/acme_plugins.rs | 40 +-
src/acme/certificate_list.rs | 36 +-
src/apt_package_manager.rs | 43 +-
src/apt_repositories.rs | 40 +-
src/auth_view.rs | 28 +-
src/configuration/network_view.rs | 32 +-
src/configuration/pve/lxc_network_panel.rs | 26 +-
src/lib.rs | 33 +-
src/loadable_component.rs | 689 +++++++++++----------
src/node_status_panel.rs | 27 +-
src/notes_view.rs | 14 +-
src/object_grid.rs | 26 +-
src/permission_panel.rs | 17 +-
src/subscription_panel.rs | 33 +-
src/tasks.rs | 24 +-
src/tfa/tfa_view.rs | 30 +-
src/token_panel.rs | 20 +-
src/user_panel.rs | 29 +-
21 files changed, 760 insertions(+), 510 deletions(-)
diff --git a/src/acl/acl_view.rs b/src/acl/acl_view.rs
index 58da3fd..ee197b0 100644
--- a/src/acl/acl_view.rs
+++ b/src/acl/acl_view.rs
@@ -24,7 +24,9 @@ use proxmox_access_control::types::{AclListItem, AclUgidType};
use crate::percent_encoding::percent_encode_component;
use crate::utils::render_boolean;
use crate::{
- ConfirmButton, EditWindow, LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
+ impl_deref_mut_property, ConfirmButton, EditWindow, LoadableComponent,
+ LoadableComponentContext, LoadableComponentMaster, LoadableComponentScopeExt,
+ LoadableComponentState,
};
use super::acl_edit::AclEditWindow;
@@ -95,15 +97,17 @@ enum ViewState {
}
enum Msg {
- Reload,
Remove,
}
struct ProxmoxAclView {
+ state: LoadableComponentState<ViewState>,
selection: Selection,
store: Store<AclListItem>,
}
+impl_deref_mut_property!(ProxmoxAclView, state, LoadableComponentState<ViewState>);
+
impl ProxmoxAclView {
fn colmuns() -> Rc<Vec<DataTableHeader<AclListItem>>> {
Rc::new(vec![
@@ -141,14 +145,21 @@ impl LoadableComponent for ProxmoxAclView {
let link = ctx.link();
link.repeated_load(5000);
- let selection = Selection::new().on_select(link.callback(|_| Msg::Reload));
+ let selection = Selection::new().on_select({
+ let link = ctx.link().clone();
+ move |_| link.send_redraw()
+ });
let store = Store::with_extract_key(|record: &AclListItem| {
let acl_id = format!("{} for {} - {}", record.path, record.ugid, record.roleid);
Key::from(acl_id)
});
- Self { selection, store }
+ Self {
+ state: LoadableComponentState::new(),
+ selection,
+ store,
+ }
}
fn load(
@@ -211,14 +222,13 @@ impl LoadableComponent for ProxmoxAclView {
fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
match msg {
- Msg::Reload => true,
Msg::Remove => {
if let Some(key) = self.selection.selected_key() {
if let Some(record) = self.store.read().lookup_record(&key).cloned() {
- let link = ctx.link();
+ let link = ctx.link().clone();
let url = ctx.props().acl_api_endpoint.to_owned();
- link.clone().spawn(async move {
+ self.spawn(async move {
let data = match record.ugid_type {
AclUgidType::User => json!({
"delete": true,
diff --git a/src/acme/acme_accounts.rs b/src/acme/acme_accounts.rs
index 19bf48b..4c4db1a 100644
--- a/src/acme/acme_accounts.rs
+++ b/src/acme/acme_accounts.rs
@@ -15,8 +15,9 @@ use crate::common_api_types::AcmeAccountInfo;
use crate::percent_encoding::percent_encode_component;
use crate::utils::render_url;
use crate::{
- ConfirmButton, DataViewWindow, LoadableComponent, LoadableComponentContext,
- LoadableComponentMaster,
+ impl_deref_mut_property, ConfirmButton, DataViewWindow, LoadableComponent,
+ LoadableComponentContext, LoadableComponentMaster, LoadableComponentScopeExt,
+ LoadableComponentState,
};
use super::AcmeRegisterAccount;
@@ -44,14 +45,17 @@ impl AcmeAccountsPanel {
#[doc(hidden)]
pub struct ProxmoxAcmeAccountsPanel {
+ state: LoadableComponentState<ViewState>,
selection: Selection,
store: Store<AcmeAccountEntry>,
columns: Rc<Vec<DataTableHeader<AcmeAccountEntry>>>,
}
-pub enum Msg {
- Redraw,
-}
+impl_deref_mut_property!(
+ ProxmoxAcmeAccountsPanel,
+ state,
+ LoadableComponentState<ViewState>
+);
#[derive(PartialEq)]
pub enum ViewState {
@@ -61,11 +65,14 @@ pub enum ViewState {
impl LoadableComponent for ProxmoxAcmeAccountsPanel {
type Properties = AcmeAccountsPanel;
- type Message = Msg;
+ type Message = ();
type ViewState = ViewState;
fn create(ctx: &LoadableComponentContext<Self>) -> Self {
- let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::Redraw));
+ let selection = Selection::new().on_select({
+ let link = ctx.link().clone();
+ move |_| link.send_redraw()
+ });
let store =
Store::with_extract_key(|record: &AcmeAccountEntry| Key::from(record.name.clone()));
@@ -77,6 +84,7 @@ impl LoadableComponent for ProxmoxAcmeAccountsPanel {
.into()]);
Self {
+ state: LoadableComponentState::new(),
selection,
store,
columns,
@@ -110,7 +118,7 @@ impl LoadableComponent for ProxmoxAcmeAccountsPanel {
Button::new(tr!("View"))
.disabled(selected_key.is_none())
.onclick({
- let link = ctx.link();
+ let link = ctx.link().clone();
let selected_key = selected_key.clone();
move |_| {
if let Some(selected_key) = &selected_key {
@@ -123,7 +131,7 @@ impl LoadableComponent for ProxmoxAcmeAccountsPanel {
ConfirmButton::remove_entry(selected_key.as_deref().unwrap_or("").to_string())
.disabled(selected_key.is_none())
.on_activate({
- let link = ctx.link();
+ let link = ctx.link().clone();
let selected_key = selected_key.clone();
move |_| {
@@ -159,7 +167,7 @@ impl LoadableComponent for ProxmoxAcmeAccountsPanel {
.selection(self.selection.clone())
.on_row_dblclick({
let selection = self.selection.clone();
- let link = ctx.link();
+ let link = ctx.link().clone();
move |_: &mut _| {
if let Some(selected_key) = selection.selected_key() {
link.change_view(Some(ViewState::View(selected_key.clone())));
diff --git a/src/acme/acme_domains.rs b/src/acme/acme_domains.rs
index 6ab924d..2d1ec5c 100644
--- a/src/acme/acme_domains.rs
+++ b/src/acme/acme_domains.rs
@@ -19,8 +19,10 @@ use pwt_macros::builder;
use crate::common_api_types::{create_acme_config_string, parse_acme_config_string, AcmeConfig};
use crate::common_api_types::{create_acme_domain_string, parse_acme_domain_string, AcmeDomain};
use crate::percent_encoding::percent_encode_component;
-use crate::{ConfirmButton, EditWindow};
-use crate::{LoadableComponent, LoadableComponentContext, LoadableComponentMaster};
+use crate::{impl_deref_mut_property, ConfirmButton, EditWindow, LoadableComponentState};
+use crate::{
+ LoadableComponent, LoadableComponentContext, LoadableComponentMaster, LoadableComponentScopeExt,
+};
use super::{AcmeAccountSelector, AcmeChallengeTypeSelector, AcmePluginSelector};
@@ -54,14 +56,20 @@ impl AcmeDomainsPanel {
#[doc(hidden)]
pub struct ProxmoxAcmeDomainsPanel {
+ state: LoadableComponentState<ViewState>,
selection: Selection,
store: Store<AcmeDomainEntry>,
columns: Rc<Vec<DataTableHeader<AcmeDomainEntry>>>,
acme_account: Option<AcmeConfig>,
}
+impl_deref_mut_property!(
+ ProxmoxAcmeDomainsPanel,
+ state,
+ LoadableComponentState<ViewState>
+);
+
pub enum Msg {
- Redraw,
AcmeAccount(Option<AcmeConfig>),
}
@@ -78,7 +86,10 @@ impl LoadableComponent for ProxmoxAcmeDomainsPanel {
type ViewState = ViewState;
fn create(ctx: &LoadableComponentContext<Self>) -> Self {
- let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::Redraw));
+ let selection = Selection::new().on_select({
+ let link = ctx.link().clone();
+ move |_| link.send_redraw()
+ });
let store = Store::with_extract_key(|record: &AcmeDomainEntry| {
Key::from(record.config_key.clone())
});
@@ -109,6 +120,7 @@ impl LoadableComponent for ProxmoxAcmeDomainsPanel {
ctx.link().repeated_load(3000);
Self {
+ state: LoadableComponentState::new(),
selection,
store,
columns,
@@ -122,7 +134,7 @@ impl LoadableComponent for ProxmoxAcmeDomainsPanel {
) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
let store = self.store.clone();
let url = ctx.props().url.clone();
- let link = ctx.link();
+ let link = ctx.link().clone();
Box::pin(async move {
let data: Value = crate::http_get(&*url, None).await?;
@@ -157,7 +169,6 @@ impl LoadableComponent for ProxmoxAcmeDomainsPanel {
fn update(&mut self, _ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
match msg {
- Msg::Redraw => true,
Msg::AcmeAccount(acme_account) => {
self.acme_account = acme_account;
true
@@ -179,7 +190,7 @@ impl LoadableComponent for ProxmoxAcmeDomainsPanel {
Button::new(tr!("Edit"))
.disabled(selected_key.is_none())
.onclick({
- let link = ctx.link();
+ let link = ctx.link().clone();
let selected_key = selected_key.clone();
move |_| {
if let Some(selected_key) = &selected_key {
@@ -192,7 +203,7 @@ impl LoadableComponent for ProxmoxAcmeDomainsPanel {
ConfirmButton::remove_entry(selected_key.as_deref().unwrap_or("").to_string())
.disabled(selected_key.is_none())
.on_activate({
- let link = ctx.link();
+ let link = ctx.link().clone();
let url = ctx.props().url.clone();
let selected_key = selected_key.clone();
move |_| {
@@ -238,7 +249,7 @@ impl LoadableComponent for ProxmoxAcmeDomainsPanel {
</div>}
})
.with_child(Button::new(tr!("Order Certificate Now")).onclick({
- let link = ctx.link();
+ let link = ctx.link().clone();
move |_| {
let command_path = "/nodes/localhost/certificates/acme/certificate";
link.start_task(command_path, None, false);
@@ -253,7 +264,7 @@ impl LoadableComponent for ProxmoxAcmeDomainsPanel {
.class("pwt-flex-fit")
.selection(self.selection.clone())
.on_row_dblclick({
- let link = ctx.link();
+ let link = ctx.link().clone();
move |event: &mut DataTableMouseEvent| {
let key = &event.record_key;
link.change_view(Some(ViewState::Edit(key.clone())));
diff --git a/src/acme/acme_plugins.rs b/src/acme/acme_plugins.rs
index f0b1e69..f5545b9 100644
--- a/src/acme/acme_plugins.rs
+++ b/src/acme/acme_plugins.rs
@@ -9,19 +9,20 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use yew::virtual_dom::{Key, VComp, VNode};
+use pwt::prelude::*;
use pwt::state::{Selection, Store};
use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader, DataTableMouseEvent};
use pwt::widget::form::{DisplayField, Field, FormContext, Number, TextArea};
use pwt::widget::{Button, InputPanel, Toolbar};
-use pwt::{prelude::*, AsyncPool};
use pwt_macros::builder;
use crate::form::delete_empty_values;
use crate::percent_encoding::percent_encode_component;
use crate::{
- http_get, ConfirmButton, EditWindow, LoadableComponent, LoadableComponentContext,
- LoadableComponentLink, LoadableComponentMaster,
+ http_get, impl_deref_mut_property, ConfirmButton, EditWindow, LoadableComponent,
+ LoadableComponentContext, LoadableComponentMaster, LoadableComponentScope,
+ LoadableComponentScopeExt, LoadableComponentState,
};
use super::{AcmeChallengeSchemaItem, AcmeChallengeSelector};
@@ -81,14 +82,20 @@ struct ChallengeSchemaInfo {
#[doc(hidden)]
pub struct ProxmoxAcmePluginsPanel {
+ state: LoadableComponentState<ViewState>,
selection: Selection,
store: Store<PluginConfig>,
columns: Rc<Vec<DataTableHeader<PluginConfig>>>,
challenge_schema: Option<AcmeChallengeSchemaItem>,
schema_info: ChallengeSchemaInfo,
- async_pool: AsyncPool,
}
+impl_deref_mut_property!(
+ ProxmoxAcmePluginsPanel,
+ state,
+ LoadableComponentState<ViewState>
+);
+
#[derive(PartialEq)]
pub enum ViewState {
Add,
@@ -96,7 +103,6 @@ pub enum ViewState {
}
pub enum Msg {
- Redraw,
CloseDialog,
Add,
Edit(Key),
@@ -126,7 +132,10 @@ impl LoadableComponent for ProxmoxAcmePluginsPanel {
type ViewState = ViewState;
fn create(ctx: &LoadableComponentContext<Self>) -> Self {
- let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::Redraw));
+ let selection = Selection::new().on_select({
+ let link = ctx.link().clone();
+ move |_| link.send_redraw()
+ });
let store =
Store::with_extract_key(|record: &PluginConfig| Key::from(record.plugin.clone()));
@@ -136,6 +145,7 @@ impl LoadableComponent for ProxmoxAcmePluginsPanel {
ctx.link().send_message(Msg::LoadChallengeSchemaList);
Self {
+ state: LoadableComponentState::new(),
selection,
store,
columns,
@@ -144,7 +154,6 @@ impl LoadableComponent for ProxmoxAcmePluginsPanel {
schema_name_map,
store: Store::new(),
},
- async_pool: AsyncPool::new(),
}
}
@@ -164,7 +173,6 @@ impl LoadableComponent for ProxmoxAcmePluginsPanel {
fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
match msg {
- Msg::Redraw => true,
Msg::Add => {
self.challenge_schema = None;
ctx.link().change_view(Some(ViewState::Add));
@@ -217,8 +225,8 @@ impl LoadableComponent for ProxmoxAcmePluginsPanel {
}
Msg::LoadChallengeSchemaList => {
let url = ctx.props().challenge_shema_url.clone();
- let link = ctx.link();
- self.async_pool.spawn(async move {
+ let link = ctx.link().clone();
+ self.spawn(async move {
let result = http_get(&*url, None).await;
link.send_message(Msg::UpdateChallengeSchemaList(result));
});
@@ -241,7 +249,7 @@ impl LoadableComponent for ProxmoxAcmePluginsPanel {
);
let command_future = crate::http_delete(command_path, None);
let link = ctx.link().clone();
- self.async_pool.spawn(async move {
+ self.spawn(async move {
match command_future.await {
Ok(()) => {
link.send_reload();
@@ -270,7 +278,7 @@ impl LoadableComponent for ProxmoxAcmePluginsPanel {
Button::new(tr!("Edit"))
.disabled(selected_key.is_none())
.onclick({
- let link = ctx.link();
+ let link = ctx.link().clone();
let selected_key = selected_key.clone();
move |_| {
if let Some(selected_key) = &selected_key {
@@ -297,7 +305,7 @@ impl LoadableComponent for ProxmoxAcmePluginsPanel {
.selection(self.selection.clone())
.on_row_dblclick({
let store = self.store.clone();
- let link = ctx.link();
+ let link = ctx.link().clone();
move |event: &mut DataTableMouseEvent| {
let key = &event.record_key;
if store.read().lookup_record(key).is_some() {
@@ -372,7 +380,7 @@ impl ProxmoxAcmePluginsPanel {
}
fn dns_plugin_input_panel(
- link: &LoadableComponentLink<Self>,
+ link: &LoadableComponentScope<Self>,
form_ctx: &FormContext,
id: Option<&str>,
challenge_schema: Option<&AcmeChallengeSchemaItem>,
@@ -498,7 +506,7 @@ impl ProxmoxAcmePluginsPanel {
.on_done(ctx.link().callback(|_| Msg::CloseDialog))
.renderer({
let id = id.to_owned();
- let link = ctx.link();
+ let link = ctx.link().clone();
let challenge_schema = self.challenge_schema.clone();
let challenge_store = self.schema_info.store.clone();
move |form_ctx: &FormContext| {
@@ -534,7 +542,7 @@ impl ProxmoxAcmePluginsPanel {
EditWindow::new(tr!("Add") + ": " + &tr!("ACME DNS Plugin"))
.on_done(ctx.link().callback(|_| Msg::CloseDialog))
.renderer({
- let link = ctx.link();
+ let link = ctx.link().clone();
let challenge_schema = self.challenge_schema.clone();
let challenge_store = self.schema_info.store.clone();
move |form_ctx: &FormContext| {
diff --git a/src/acme/certificate_list.rs b/src/acme/certificate_list.rs
index d9ce92c..7ac20e6 100644
--- a/src/acme/certificate_list.rs
+++ b/src/acme/certificate_list.rs
@@ -18,8 +18,9 @@ use pwt::widget::{Button, Container, Dialog, FileButton, MessageBox, Toolbar};
use crate::common_api_types::CertificateInfo;
use crate::utils::render_epoch;
use crate::{
- ConfirmButton, EditWindow, KVGrid, KVGridRow, LoadableComponent, LoadableComponentContext,
- LoadableComponentMaster,
+ impl_deref_mut_property, ConfirmButton, EditWindow, KVGrid, KVGridRow, LoadableComponent,
+ LoadableComponentContext, LoadableComponentMaster, LoadableComponentScopeExt,
+ LoadableComponentState,
};
async fn upload_custom_certificate(form_ctx: FormContext) -> Result<(), Error> {
@@ -46,10 +47,6 @@ impl CertificateList {
}
}
-pub enum Msg {
- Redraw,
-}
-
#[derive(PartialEq)]
pub enum ViewState {
CertificateView(Rc<Value>),
@@ -59,24 +56,35 @@ pub enum ViewState {
#[doc(hidden)]
pub struct ProxmoxCertificateList {
+ state: LoadableComponentState<ViewState>,
selection: Selection,
store: Store<CertificateInfo>,
columns: Rc<Vec<DataTableHeader<CertificateInfo>>>,
rows: Rc<Vec<KVGridRow>>,
}
+impl_deref_mut_property!(
+ ProxmoxCertificateList,
+ state,
+ LoadableComponentState<ViewState>
+);
+
impl LoadableComponent for ProxmoxCertificateList {
type Properties = CertificateList;
- type Message = Msg;
+ type Message = ();
type ViewState = ViewState;
fn create(ctx: &LoadableComponentContext<Self>) -> Self {
- let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::Redraw));
+ let selection = Selection::new().on_select({
+ let link = ctx.link().clone();
+ move |_| link.send_redraw()
+ });
let store =
Store::with_extract_key(|record: &CertificateInfo| Key::from(record.filename.clone()));
let columns = Rc::new(columns());
let rows = Rc::new(rows());
Self {
+ state: LoadableComponentState::new(),
selection,
store,
columns,
@@ -121,7 +129,7 @@ impl LoadableComponent for ProxmoxCertificateList {
"proxy.pem"
))
.on_activate({
- let link = ctx.link();
+ let link = ctx.link().clone();
move |_| {
let link = link.clone();
let command_path = "/nodes/localhost/certificates/custom".to_string();
@@ -145,7 +153,7 @@ impl LoadableComponent for ProxmoxCertificateList {
.disabled(selected_cert.is_none())
.onclick({
let selected_cert = selected_cert.clone();
- let link = ctx.link();
+ let link = ctx.link().clone();
move |_| {
if let Some(selected_cert) = &selected_cert {
let cert_data: Value =
@@ -167,7 +175,7 @@ impl LoadableComponent for ProxmoxCertificateList {
.selection(self.selection.clone())
.on_row_dblclick({
let store = self.store.clone();
- let link = ctx.link();
+ let link = ctx.link().clone();
move |event: &mut DataTableMouseEvent| {
let key = &event.record_key;
if let Some(selected_cert) = store.read().lookup_record(key).cloned() {
@@ -226,10 +234,10 @@ async fn update_field_from_file(
impl ProxmoxCertificateList {
fn create_upload_custom_certificate(&self, ctx: &LoadableComponentContext<Self>) -> Html {
- let link = ctx.link();
+ let link = ctx.link().clone();
EditWindow::new(tr!("Upload Custom Certificate"))
.width(600)
- .on_close(ctx.link().change_view_callback(|_| None))
+ .on_close(link.change_view_callback(|_| None))
.submit_text(tr!("Upload"))
.renderer(move |form_ctx: &FormContext| {
Form::new()
@@ -287,7 +295,7 @@ impl ProxmoxCertificateList {
.into()
})
.on_submit({
- let link = ctx.link();
+ let link = ctx.link().clone();
move |form_ctx: FormContext| {
let link = link.clone();
async move {
diff --git a/src/apt_package_manager.rs b/src/apt_package_manager.rs
index 82d536f..7c77aa4 100644
--- a/src/apt_package_manager.rs
+++ b/src/apt_package_manager.rs
@@ -18,13 +18,14 @@ use pwt::widget::data_table::{
DataTable, DataTableCellRenderArgs, DataTableColumn, DataTableHeader, DataTableHeaderGroup,
};
use pwt::widget::{AlertDialog, Button, Container, Toolbar, Tooltip};
-use pwt::AsyncPool;
use crate::percent_encoding::percent_encode_component;
use crate::subscription_alert::subscription_is_active;
+use crate::LoadableComponentState;
use crate::SubscriptionAlert;
use crate::{
- DataViewWindow, LoadableComponent, LoadableComponentContext, LoadableComponentMaster, XTermJs,
+ DataViewWindow, LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
+ LoadableComponentScopeExt, XTermJs,
};
use proxmox_apt_api_types::APTUpdateInfo;
@@ -163,31 +164,43 @@ pub enum ViewState {
/// Messages for [`ProxmoxAptManager::update`]
pub enum Msg {
CheckSubscription,
- SelectionChange,
}
pub struct ProxmoxAptPackageManager {
+ state: LoadableComponentState<ViewState>,
tree_store: TreeStore<TreeEntry>,
selection: Selection,
columns: Rc<Vec<DataTableHeader<TreeEntry>>>,
- async_pool: AsyncPool,
}
+crate::impl_deref_mut_property!(
+ ProxmoxAptPackageManager,
+ state,
+ LoadableComponentState<ViewState>
+);
+
impl LoadableComponent for ProxmoxAptPackageManager {
type Properties = AptPackageManager;
type Message = Msg;
type ViewState = ViewState;
fn create(ctx: &LoadableComponentContext<Self>) -> Self {
+ let props = ctx.props();
let tree_store = TreeStore::new().view_root(false);
let columns = Self::columns(ctx, tree_store.clone());
- let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::SelectionChange));
+ let selection = Selection::new().on_select({
+ let link = ctx.link().clone();
+ move |_| link.send_redraw()
+ });
+
+ let mut state = LoadableComponentState::new();
+ state.set_task_base_url(props.task_base_url.clone());
Self {
+ state,
tree_store,
selection,
columns,
- async_pool: AsyncPool::new(),
}
}
@@ -200,14 +213,12 @@ impl LoadableComponent for ProxmoxAptPackageManager {
.clone()
.subscription_url
.unwrap_or("/nodes/localhost/subscription".into());
- let task_base_url = props.task_base_url.clone();
let command = format!("{}/update", props.base_url);
- self.async_pool.spawn(async move {
+ self.spawn(async move {
let data = crate::http_get::<Value>(url.as_str(), None).await;
let is_active = subscription_is_active(Some(&data));
if is_active {
- link.task_base_url(task_base_url);
link.start_task(&command, None, false);
} else {
link.change_view(Some(ViewState::ShowSubscriptionPopup));
@@ -215,7 +226,6 @@ impl LoadableComponent for ProxmoxAptPackageManager {
});
true
}
- Msg::SelectionChange => true,
}
}
@@ -264,9 +274,8 @@ impl LoadableComponent for ProxmoxAptPackageManager {
.class("pwt-overflow-hidden")
.class("pwt-border-bottom")
.with_child(Button::new(tr!("Refresh")).on_activate({
- let link = ctx.link();
+ let link = ctx.link().clone();
let sub_check = props.subscription_url.is_some();
- link.task_base_url(props.task_base_url.clone());
let command = format!("{}/update", props.base_url);
@@ -287,7 +296,7 @@ impl LoadableComponent for ProxmoxAptPackageManager {
Button::new(tr!("Changelog"))
.disabled(selected_package.is_none())
.onclick({
- let link = ctx.link();
+ let link = ctx.link().clone();
let view = selected_package
.as_ref()
.map(|p| ViewState::ShowChangelog(p.clone()));
@@ -296,8 +305,8 @@ impl LoadableComponent for ProxmoxAptPackageManager {
)
.with_flex_spacer()
.with_child({
- let loading = ctx.loading();
- let link = ctx.link();
+ let loading = self.loading();
+ let link = ctx.link().clone();
Button::refresh(loading).onclick(move |_| link.send_reload())
});
@@ -325,11 +334,9 @@ impl LoadableComponent for ProxmoxAptPackageManager {
ViewState::ShowSubscriptionPopup => {
let link = ctx.link().clone();
let props = ctx.props();
- let task_base_url = props.task_base_url.clone();
let command = format!("{}/update", props.base_url);
let on_close = move |_| {
link.change_view(None);
- link.task_base_url(task_base_url.clone());
link.start_task(&command, None, false);
};
Some(if let Some(msg) = props.subscription_message.clone() {
@@ -351,6 +358,8 @@ impl LoadableComponent for ProxmoxAptPackageManager {
) -> bool {
let props = ctx.props();
+ self.set_task_base_url(props.task_base_url.clone());
+
if props.base_url != old_props.base_url || props.task_base_url != old_props.task_base_url {
ctx.link().send_reload();
true
diff --git a/src/apt_repositories.rs b/src/apt_repositories.rs
index 7157c0c..5e8eee6 100644
--- a/src/apt_repositories.rs
+++ b/src/apt_repositories.rs
@@ -22,7 +22,8 @@ use pwt::widget::{Button, Column, Container, Fa, Row, Toolbar, Tooltip};
use crate::subscription_alert::subscription_is_active;
use crate::{
EditWindow, ExistingProduct, LoadableComponent, LoadableComponentContext,
- LoadableComponentMaster, ProjectInfo, SubscriptionAlert,
+ LoadableComponentMaster, LoadableComponentScopeExt, LoadableComponentState, ProjectInfo,
+ SubscriptionAlert,
};
use pwt_macros::builder;
@@ -406,7 +407,6 @@ fn apt_configuration_to_tree(config: &APTRepositoriesResult) -> SlabTree<TreeEnt
}
pub enum Msg {
- Refresh,
ToggleEnable,
UpdateStatus(APTRepositoriesResult),
SubscriptionInfo(Result<Value, Error>),
@@ -419,6 +419,7 @@ pub enum ViewState {
}
pub struct ProxmoxAptRepositories {
+ state: LoadableComponentState<ViewState>,
tree_store: TreeStore<TreeEntry>,
selection: Selection,
columns: Rc<Vec<DataTableHeader<TreeEntry>>>,
@@ -430,6 +431,12 @@ pub struct ProxmoxAptRepositories {
status_columns: Rc<Vec<DataTableHeader<StatusLine>>>,
}
+crate::impl_deref_mut_property!(
+ ProxmoxAptRepositories,
+ state,
+ LoadableComponentState<ViewState>
+);
+
impl LoadableComponent for ProxmoxAptRepositories {
type Properties = AptRepositories;
type Message = Msg;
@@ -438,19 +445,27 @@ impl LoadableComponent for ProxmoxAptRepositories {
fn create(ctx: &LoadableComponentContext<Self>) -> Self {
let tree_store = TreeStore::new().view_root(false);
let columns = Self::columns(ctx, tree_store.clone());
- let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::Refresh));
+ let selection = Selection::new().on_select({
+ let link = ctx.link().clone();
+ move |_| link.send_redraw()
+ });
let status_columns = Self::status_columns(ctx);
let subscription_url = ctx.props().subscription_url.clone();
- let link = ctx.link();
- link.send_future(async move {
- // TODO: also reload this in load, not only create?!
- let data = crate::http_get(subscription_url.to_string(), None).await;
- Msg::SubscriptionInfo(data)
+ let state = LoadableComponentState::new();
+
+ state.spawn({
+ let link = ctx.link().clone();
+ async move {
+ // TODO: also reload this in load, not only create?!
+ let data = crate::http_get(subscription_url.to_string(), None).await;
+ link.send_message(Msg::SubscriptionInfo(data));
+ }
});
Self {
+ state,
tree_store,
selection,
columns,
@@ -470,7 +485,7 @@ impl LoadableComponent for ProxmoxAptRepositories {
let props = ctx.props();
let base_url = props.base_url.clone();
let tree_store = self.tree_store.clone();
- let link = ctx.link();
+ let link = ctx.link().clone();
Box::pin(async move {
let config = apt_configuration(base_url.clone()).await?;
@@ -484,7 +499,6 @@ impl LoadableComponent for ProxmoxAptRepositories {
fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
let props = ctx.props();
match msg {
- Msg::Refresh => true,
Msg::SubscriptionInfo(status) => {
self.subscription_status = Some(status);
if let Some(config) = &self.config {
@@ -547,7 +561,7 @@ impl LoadableComponent for ProxmoxAptRepositories {
});
// fixme: add digest to protect against concurrent changes
let url = format!("{}/repositories", props.base_url);
- let link = ctx.link();
+ let link = ctx.link().clone();
link.clone().spawn(async move {
match crate::http_post(url, Some(param)).await {
Ok(()) => {
@@ -610,8 +624,8 @@ impl LoadableComponent for ProxmoxAptRepositories {
})
.with_flex_spacer()
.with_child({
- let loading = ctx.loading();
- let link = ctx.link();
+ let loading = self.loading();
+ let link = ctx.link().clone();
Button::refresh(loading).onclick(move |_| link.send_reload())
});
diff --git a/src/auth_view.rs b/src/auth_view.rs
index d6e5528..82f6041 100644
--- a/src/auth_view.rs
+++ b/src/auth_view.rs
@@ -21,7 +21,8 @@ use pwt_macros::builder;
use crate::{
AuthEditLDAP, AuthEditOpenID, EditWindow, LoadableComponent, LoadableComponentContext,
- LoadableComponentLink, LoadableComponentMaster,
+ LoadableComponentMaster, LoadableComponentScope, LoadableComponentScopeExt,
+ LoadableComponentState,
};
use crate::common_api_types::BasicRealmInfo;
@@ -76,17 +77,20 @@ pub enum ViewState {
}
pub enum Msg {
- Redraw,
Edit,
Remove,
Sync,
}
+
#[doc(hidden)]
pub struct ProxmoxAuthView {
+ state: LoadableComponentState<ViewState>,
selection: Selection,
store: Store<BasicRealmInfo>,
}
+crate::impl_deref_mut_property!(ProxmoxAuthView, state, LoadableComponentState<ViewState>);
+
async fn delete_item(base_url: AttrValue, realm: AttrValue) -> Result<(), Error> {
let url = format!("{base_url}/{}", percent_encode_component(&realm));
crate::http_delete(&url, None).await?;
@@ -95,7 +99,7 @@ async fn delete_item(base_url: AttrValue, realm: AttrValue) -> Result<(), Error>
async fn sync_realm(
form_ctx: FormContext,
- link: LoadableComponentLink<ProxmoxAuthView>,
+ link: LoadableComponentScope<ProxmoxAuthView>,
url: impl Into<String>,
) -> Result<(), Error> {
let mut data = form_ctx.get_submit_data();
@@ -178,8 +182,15 @@ impl LoadableComponent for ProxmoxAuthView {
fn create(ctx: &LoadableComponentContext<Self>) -> Self {
let store = Store::new();
- let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::Redraw));
- Self { store, selection }
+ let selection = Selection::new().on_select({
+ let link = ctx.link().clone();
+ move |_| link.send_redraw()
+ });
+ Self {
+ state: LoadableComponentState::new(),
+ store,
+ selection,
+ }
}
fn load(
@@ -201,7 +212,6 @@ impl LoadableComponent for ProxmoxAuthView {
let props = ctx.props();
match msg {
- Msg::Redraw => true,
Msg::Remove => {
let Some(info) = self.get_selected_record() else {
return true;
@@ -218,7 +228,7 @@ impl LoadableComponent for ProxmoxAuthView {
return true;
};
- let link = ctx.link();
+ let link = ctx.link().clone();
link.clone().spawn(async move {
if let Err(err) = delete_item(base_url, info.realm.into()).await {
link.show_error(tr!("Unable to delete item"), err, true);
@@ -341,7 +351,7 @@ impl LoadableComponent for ProxmoxAuthView {
.selection(self.selection.clone())
.class("pwt-flex-fit")
.on_row_dblclick({
- let link = ctx.link();
+ let link = ctx.link().clone();
move |_: &mut _| link.send_message(Msg::Edit)
})
.into()
@@ -398,7 +408,7 @@ impl LoadableComponent for ProxmoxAuthView {
.into(),
),
ViewState::Sync(realm) => {
- let link = ctx.link();
+ let link = ctx.link().clone();
let url = format!(
"{}/{}/sync",
ctx.props().base_url,
diff --git a/src/configuration/network_view.rs b/src/configuration/network_view.rs
index d7d519c..213de53 100644
--- a/src/configuration/network_view.rs
+++ b/src/configuration/network_view.rs
@@ -12,7 +12,10 @@ use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader};
use pwt::widget::menu::{Menu, MenuButton, MenuItem};
use pwt::widget::{Button, Column, Container, SplitPane, Toolbar};
-use crate::{LoadableComponent, LoadableComponentContext, LoadableComponentMaster, TaskProgress};
+use crate::{
+ LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
+ LoadableComponentScopeExt, LoadableComponentState, TaskProgress,
+};
use proxmox_client::ApiResponseData;
use crate::percent_encoding::percent_encode_component;
@@ -66,12 +69,15 @@ impl NetworkView {
#[doc(hidden)]
pub struct ProxmoxNetworkView {
+ state: LoadableComponentState<ViewState>,
columns: Rc<Vec<DataTableHeader<Interface>>>,
store: Store<Interface>,
changes: String,
selection: Selection,
}
+crate::impl_deref_mut_property!(ProxmoxNetworkView, state, LoadableComponentState<ViewState>);
+
#[derive(PartialEq)]
pub enum ViewState {
AddBridge,
@@ -81,7 +87,6 @@ pub enum ViewState {
}
pub enum Msg {
- SelectionChange,
RemoveItem,
Changes(String),
RevertChanges,
@@ -119,7 +124,7 @@ impl LoadableComponent for ProxmoxNetworkView {
ctx: &LoadableComponentContext<Self>,
) -> Pin<Box<dyn Future<Output = Result<(), Error>>>> {
let store = self.store.clone();
- let link = ctx.link();
+ let link = ctx.link().clone();
Box::pin(async move {
let (data, changes) = load_interfaces().await?;
store.write().set_data(data);
@@ -130,8 +135,12 @@ impl LoadableComponent for ProxmoxNetworkView {
fn create(ctx: &LoadableComponentContext<Self>) -> Self {
let store = Store::with_extract_key(|record: &Interface| Key::from(record.name.as_str()));
- let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::SelectionChange));
+ let selection = Selection::new().on_select({
+ let link = ctx.link().clone();
+ move |_| link.send_redraw()
+ });
Self {
+ state: LoadableComponentState::new(),
store,
selection,
changes: String::new(),
@@ -141,10 +150,9 @@ impl LoadableComponent for ProxmoxNetworkView {
fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
match msg {
- Msg::SelectionChange => true,
Msg::RemoveItem => {
if let Some(key) = self.selection.selected_key() {
- let link = ctx.link();
+ let link = ctx.link().clone();
link.clone().spawn(async move {
if let Err(err) = delete_interface(key).await {
link.show_error(tr!("Unable to delete item"), err, true);
@@ -159,8 +167,8 @@ impl LoadableComponent for ProxmoxNetworkView {
true
}
Msg::RevertChanges => {
- let link = ctx.link();
- link.clone().spawn(async move {
+ let link = ctx.link().clone();
+ self.spawn(async move {
if let Err(err) = revert_changes().await {
link.show_error(tr!("Unable to revert changes"), err, true);
}
@@ -169,7 +177,7 @@ impl LoadableComponent for ProxmoxNetworkView {
false
}
Msg::ApplyChanges => {
- let link = ctx.link();
+ let link = ctx.link().clone();
link.clone().spawn(async move {
match apply_changes().await {
Err(err) => {
@@ -239,8 +247,8 @@ impl LoadableComponent for ProxmoxNetworkView {
)
.with_flex_spacer()
.with_child({
- let loading = ctx.loading();
- let link = ctx.link();
+ let loading = self.loading();
+ let link = ctx.link().clone();
Button::refresh(loading).onclick(move |_| link.send_reload())
});
@@ -248,7 +256,7 @@ impl LoadableComponent for ProxmoxNetworkView {
}
fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
- let link = ctx.link();
+ let link = ctx.link().clone();
let table = DataTable::new(Rc::clone(&self.columns), self.store.clone())
.class("pwt-flex-fit")
diff --git a/src/configuration/pve/lxc_network_panel.rs b/src/configuration/pve/lxc_network_panel.rs
index 953edba..807b145 100644
--- a/src/configuration/pve/lxc_network_panel.rs
+++ b/src/configuration/pve/lxc_network_panel.rs
@@ -23,9 +23,12 @@ use crate::form::pve::lxc_network_property;
use crate::form::typed_load;
use crate::{
configuration::guest_config_url, form::pve::PveGuestType, LoadableComponent,
- LoadableComponentContext,
+ LoadableComponentContext, LoadableComponentScopeExt,
+};
+use crate::{
+ http_put, impl_deref_mut_property, ConfirmButton, LoadableComponentMaster,
+ LoadableComponentState, PropertyEditDialog,
};
-use crate::{http_put, ConfirmButton, LoadableComponentMaster, PropertyEditDialog};
#[derive(Clone, PartialEq, Properties)]
#[builder]
@@ -82,21 +85,23 @@ pub enum ViewState {
}
pub enum Msg {
- Redraw,
SelectionChange,
Remove(Key),
}
pub struct LxcNetworkComp {
+ state: LoadableComponentState<ViewState>,
columns: Rc<Vec<DataTableHeader<NetworkEntry>>>,
store: Store<NetworkEntry>,
selection: Selection,
}
+impl_deref_mut_property!(LxcNetworkComp, state, LoadableComponentState<ViewState>);
+
impl LxcNetworkComp {
fn edit_dialog(&self, ctx: &LoadableComponentContext<Self>, name: Option<String>) -> Html {
let props = ctx.props();
- let link = ctx.link();
+ let link = ctx.link().clone();
let property = lxc_network_property(
Some(props.node.clone()),
@@ -157,11 +162,15 @@ impl LoadableComponent for LxcNetworkComp {
fn create(ctx: &LoadableComponentContext<Self>) -> Self {
let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::SelectionChange));
- let store = Store::new().on_change(ctx.link().callback(|_| Msg::Redraw));
+ let store = Store::new().on_change({
+ let link = ctx.link().clone();
+ move |_| link.send_redraw()
+ });
ctx.link().repeated_load(3000);
Self {
+ state: LoadableComponentState::new(),
store,
selection,
columns: columns(),
@@ -170,15 +179,14 @@ impl LoadableComponent for LxcNetworkComp {
fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
let props = ctx.props();
- let link = ctx.link();
+ let link = ctx.link().clone();
match msg {
Msg::SelectionChange => true,
- Msg::Redraw => true,
Msg::Remove(key) => {
let url =
guest_config_url(props.vmid, &props.node, &props.remote, PveGuestType::Lxc);
- link.clone().spawn(async move {
+ self.spawn(async move {
let param = json!({ "delete": [ key.to_string() ]});
let result: Result<(), _> = crate::http_put(&url, Some(param)).await;
if let Err(err) = result {
@@ -259,7 +267,7 @@ impl LoadableComponent for LxcNetworkComp {
fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
let props = ctx.props();
let readonly = props.readonly;
- let link = ctx.link();
+ let link = ctx.link().clone();
if props.mobile {
let mut tiles = Vec::new();
diff --git a/src/lib.rs b/src/lib.rs
index 6df8a32..f4761a2 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -88,7 +88,8 @@ pub mod layout;
mod loadable_component;
pub use loadable_component::{
- LoadableComponent, LoadableComponentContext, LoadableComponentLink, LoadableComponentMaster,
+ LoadableComponent, LoadableComponentContext, LoadableComponentMaster, LoadableComponentScope,
+ LoadableComponentScopeExt, LoadableComponentState,
};
mod node_info;
@@ -336,3 +337,33 @@ pub fn available_language_list() -> Vec<LanguageInfo> {
),
]
}
+
+#[cfg(doc)]
+use std::ops::{Deref, DerefMut};
+
+/// Implement [Deref] to a structure member.
+#[macro_export]
+macro_rules! impl_deref_property {
+ ($ty:ty, $property_name:ident, $property_type:ty) => {
+ impl std::ops::Deref for $ty {
+ type Target = $property_type;
+
+ fn deref(&self) -> &Self::Target {
+ &self.$property_name
+ }
+ }
+ };
+}
+
+/// Implement [DerefMut] to a structure member.
+#[macro_export]
+macro_rules! impl_deref_mut_property {
+ ($ty:ty, $property_name:ident, $property_type:ty) => {
+ $crate::impl_deref_property!($ty, $property_name, $property_type);
+ impl std::ops::DerefMut for $ty {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ &mut self.$property_name
+ }
+ }
+ };
+}
diff --git a/src/loadable_component.rs b/src/loadable_component.rs
index f0e28a9..7680000 100644
--- a/src/loadable_component.rs
+++ b/src/loadable_component.rs
@@ -1,163 +1,296 @@
-use anyhow::Error;
-use serde_json::Value;
use std::future::Future;
+use std::ops::DerefMut;
use std::pin::Pin;
-use yew_router::scope_ext::RouterScopeExt;
+use anyhow::Error;
use gloo_timers::callback::Timeout;
+use serde_json::Value;
use yew::html::Scope;
use pwt::dom::DomVisibilityObserver;
use pwt::prelude::*;
-use pwt::state::NavigationContextExt;
use pwt::widget::{AlertDialog, Column};
use pwt::AsyncPool;
+#[cfg(doc)]
+use crate::impl_deref_mut_property;
+#[cfg(doc)]
+use pwt::widget::Dialog;
+
use crate::{TaskProgress, TaskViewer};
-pub struct LoadableComponentState {
- loading: usize,
- last_load_error: Option<String>,
- repeat_timespan: u32, /* 0 => no repeated loading */
- task_base_url: Option<AttrValue>,
-}
+pub type LoadableComponentContext<L> = Context<LoadableComponentMaster<L>>;
+pub type LoadableComponentScope<L> = Scope<LoadableComponentMaster<L>>;
+
+/// Loadable Components
+///
+/// - Load data using an async function [LoadableComponent::load]
+/// - repeated load possible
+/// - pause repeated load when component is not visible (uses [DomVisibilityObserver])
+/// - display the loaded data [LoadableComponent::main_view]
+/// - display an optional toolbar [LoadableComponent::toolbar]
+/// - display any errors from failed load.
+/// - display additional dialogs depening on [LoadableComponent::ViewState]
+///
+/// The [LoadableComponentScopeExt] defines available control function on the scope.
+///
+/// The [LoadableComponentState] provides acces to load status informations and add the ability
+/// to spawn tasks.
+///
+/// ```
+/// use proxmox_yew_comp::{LoadableComponent, LoadableComponentState, LoadableComponentContext};
+/// // include the scope extension for (for `change_view`, `send_custom_message`, ...)
+/// use proxmox_yew_comp::LoadableComponentScopeExt;
+/// # use std::pin::Pin;
+/// # use std::rc::Rc;
+/// # use std::future::Future;
+/// # use pwt::prelude::*;
+/// # use proxmox_yew_comp::http_get;
+/// # use yew::virtual_dom::{VComp, VNode, Key};
+///
+/// // define the component properties
+/// #[derive(Clone, PartialEq, Properties)]
+/// pub struct MyComponent {
+/// key: Option<Key>,
+/// /* add whatever you need */
+/// };
+///
+/// // define your view states
+/// #[derive(PartialEq)]
+/// pub enum ViewState { Add, Edit }
+///
+/// // define the component message type
+/// pub enum Msg { UpdateData(String) }
+///
+/// // define the component state
+/// pub struct MyComponentState {
+/// // you need to incluce a LoadableComponentState
+/// state: LoadableComponentState<ViewState>,
+/// // Add any other data you need
+/// loaded_data: Option<String>,
+/// }
+///
+/// // implement DerefMut
+/// proxmox_yew_comp::impl_deref_mut_property!(
+/// MyComponentState,
+/// state,
+/// LoadableComponentState<ViewState>
+/// );
+///
+/// impl LoadableComponent for MyComponentState {
+/// type Properties = MyComponent;
+/// type Message = Msg; // component message type
+/// type ViewState = ViewState;
+///
+/// fn create(ctx: &LoadableComponentContext<Self>) -> Self {
+/// Self {
+/// state: LoadableComponentState::new(),
+/// loaded_data: None,
+/// }
+/// }
+///
+/// fn load(
+/// &self,
+/// ctx: &LoadableComponentContext<Self>,
+/// ) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
+/// let link = ctx.link().clone();
+/// Box::pin(async move {
+/// let data = http_get("/something", None).await?; // load something here
+/// link.send_custom_message(Msg::UpdateData(data));
+/// Ok(())
+/// })
+/// }
+///
+/// fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
+/// match msg {
+/// Msg::UpdateData(data) => self.loaded_data = Some(data),
+/// }
+/// true
+/// }
+///
+/// fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
+/// let text: String = if let Some(data) = &self.loaded_data {
+/// data.clone()
+/// } else {
+/// "no data".into()
+/// };
+/// html!{text}
+/// }
+/// }
+///
+/// // add ability to generate the yew Component (provided by [LoadableComponentMaster])
+/// use proxmox_yew_comp::LoadableComponentMaster;
+/// impl From<MyComponent> for VNode {
+/// fn from(props: MyComponent) -> VNode {
+/// let key = props.key.clone();
+/// let comp = VComp::new::<LoadableComponentMaster<MyComponentState>>(Rc::new(props), key);
+/// VNode::from(comp)
+/// }
+/// }
+///
+/// ```
+pub trait LoadableComponent:
+ Sized + DerefMut<Target = LoadableComponentState<Self::ViewState>> + 'static
+{
+ /// The yew component properties.
+ type Properties: Properties;
+ /// The yew component message type.
+ type Message: 'static;
+ /// The view state
+ ///
+ /// The view state of the component can be changed with [LoadableComponentScopeExt::change_view].
+ /// The value is then passed to the [LoadableComponent::dialog_view] function which can render
+ /// different dialogs.
+ type ViewState: 'static + PartialEq;
-pub struct LoadableComponentContext<'a, L: LoadableComponent + Sized + 'static> {
- ctx: &'a Context<LoadableComponentMaster<L>>,
- comp_state: &'a LoadableComponentState,
-}
+ /// Create a new instance
+ fn create(ctx: &LoadableComponentContext<Self>) -> Self;
-impl<L: LoadableComponent + Sized> LoadableComponentContext<'_, L> {
- pub fn props(&self) -> &L::Properties {
- self.ctx.props()
- }
- pub fn link(&self) -> LoadableComponentLink<L> {
- LoadableComponentLink {
- link: self.ctx.link().clone(),
- }
- }
- pub fn loading(&self) -> bool {
- self.comp_state.loading > 0
- }
+ /// Async Load
+ fn load(
+ &self,
+ ctx: &LoadableComponentContext<Self>,
+ ) -> Pin<Box<dyn Future<Output = Result<(), Error>>>>;
- pub fn last_load_errors(&self) -> Option<&str> {
- self.comp_state.last_load_error.as_deref()
+ /// Yew component update function (see [Component::update])
+ #[allow(unused_variables)]
+ fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
+ true
}
-}
-pub struct LoadableComponentLink<L: LoadableComponent + Sized + 'static> {
- link: Scope<LoadableComponentMaster<L>>,
-}
-
-impl<L: LoadableComponent + Sized> Clone for LoadableComponentLink<L> {
- fn clone(&self) -> Self {
- Self {
- link: self.link.clone(),
- }
+ /// Yew component changed function (see [Component::changed])
+ #[allow(unused_variables)]
+ fn changed(
+ &mut self,
+ ctx: &LoadableComponentContext<Self>,
+ _old_props: &Self::Properties,
+ ) -> bool {
+ true
}
-}
-impl<L: LoadableComponent + Sized> LoadableComponentLink<L> {
- pub fn send_message(&self, msg: impl Into<L::Message>) {
- let msg = msg.into();
- self.link.send_message(Msg::ChildMessage(msg));
+ /// Optional toolbar
+ #[allow(unused_variables)]
+ fn toolbar(&self, ctx: &LoadableComponentContext<Self>) -> Option<Html> {
+ None
}
- pub fn callback<F, IN, M>(&self, function: F) -> Callback<IN>
- where
- M: Into<L::Message>,
- F: Fn(IN) -> M + 'static,
- {
- self.link.callback(move |p: IN| {
- let msg: L::Message = function(p).into();
- Msg::ChildMessage(msg)
- })
- }
+ /// Main view (see [Component::view])
+ ///
+ /// The difference is that we render the result into a [Column], with an optional
+ /// toolbar on the top.
+ fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html;
- /// Spawn a future using the [AsyncPool] from the component.
- pub fn spawn<Fut>(&self, future: Fut)
- where
- Fut: Future<Output = ()> + 'static,
- {
- self.link.send_message(Msg::Spawn(Box::pin(future)));
+ /// ViewState dependent dialogs
+ ///
+ /// The result is rendered below the main view. Usually some kind of [Dialog] window.
+ ///
+ /// The view state can be changed with `link.change_view(..)` and `link.change_view_callback(...)`.
+ #[allow(unused_variables)]
+ fn dialog_view(
+ &self,
+ ctx: &LoadableComponentContext<Self>,
+ view_state: &Self::ViewState,
+ ) -> Option<Html> {
+ None
}
- pub fn send_future<Fut, M>(&self, future: Fut)
- where
- M: Into<L::Message>,
- Fut: Future<Output = M> + 'static,
- {
- let link = self.link.clone();
- self.link.send_message(Msg::Spawn(Box::pin(async move {
- let message: L::Message = future.await.into();
- link.send_message(Msg::ChildMessage(message));
- })));
- }
+ /// Yew component rendered function (see [Component::rendered])
+ #[allow(unused_variables)]
+ fn rendered(&mut self, ctx: &LoadableComponentContext<Self>, first_render: bool) {}
+}
- pub fn callback_future<F, Fut, IN, M>(&self, function: F) -> Callback<IN>
- where
- M: Into<L::Message>,
- Fut: Future<Output = M> + 'static,
- F: Fn(IN) -> Fut + 'static,
- {
- let link = self.clone();
+#[derive(Clone, PartialEq)]
+pub enum ViewState<V: PartialEq> {
+ Main,
+ /// Show the dialog returned by dialog_view
+ Dialog(V),
+ /// Show proxmox api task status
+ TaskProgress(String),
+ /// Show proxmox api task log
+ TaskLog(String, Option<i64>),
+ /// Show an error message dialog
+ Error(String, String, /* reload_on_close */ bool),
+}
- let closure = move |input: IN| {
- link.send_future(function(input));
- };
+pub enum Msg<M, V: PartialEq> {
+ DataChange,
+ Load,
+ RepeatedLoad(u32 /* repeat time in miliseconds */),
+ LoadResult(Result<(), Error>),
+ ChangeView(/*reload*/ bool, ViewState<V>),
+ ChildMessage(M),
+ Visible(bool),
+ Spawn(Pin<Box<dyn Future<Output = ()>>>),
+}
- closure.into()
+impl<M, V: PartialEq> From<M> for Msg<M, V> {
+ fn from(value: M) -> Self {
+ Msg::ChildMessage(value)
}
+}
- pub fn send_reload(&self) {
- self.link.send_message(Msg::Load)
- }
+pub trait LoadableComponentScopeExt<M, V: PartialEq> {
+ fn send_reload(&self);
+ fn send_redraw(&self);
+ fn repeated_load(&self, miliseconds: u32);
- pub fn repeated_load(&self, miliseconds: u32) {
- self.link.send_message(Msg::RepeatedLoad(miliseconds));
- }
+ fn change_view(&self, child_view_state: Option<V>);
+ fn change_view_callback<C, F, IN>(&self, function: F) -> Callback<IN>
+ where
+ C: Into<Option<V>>,
+ F: Fn(IN) -> C + 'static;
- pub fn task_base_url(&self, base_url: impl Into<AttrValue>) {
- self.link.send_message(Msg::TaskBaseUrl(base_url.into()));
- }
+ /// Spawn a future using the [AsyncPool] from the component.
+ fn spawn<Fut>(&self, future: Fut)
+ where
+ Fut: Future<Output = ()> + 'static;
- pub fn show_error(
+ fn show_error(
&self,
title: impl Into<String>,
msg: impl std::fmt::Display,
reload_on_close: bool,
- ) {
- let view_state = ViewState::Error(title.into(), msg.to_string(), reload_on_close);
- self.link.send_message(Msg::ChangeView(false, view_state));
+ );
+
+ fn show_task_progres(&self, task_id: impl Into<String>);
+
+ fn show_task_log(&self, task_id: impl Into<String>, endtime: Option<i64>);
+
+ fn start_task(&self, command_path: impl Into<String>, data: Option<Value>, short: bool);
+}
+
+impl<M, V: PartialEq, T: 'static + LoadableComponent<Message = M, ViewState = V>>
+ LoadableComponentScopeExt<M, V> for Scope<LoadableComponentMaster<T>>
+{
+ fn send_reload(&self) {
+ self.send_message(Msg::Load);
}
- pub fn show_task_progres(&self, task_id: impl Into<String>) {
- let view_state = ViewState::TaskProgress(task_id.into());
- self.link.send_message(Msg::ChangeView(false, view_state));
+ fn send_redraw(&self) {
+ self.send_message(Msg::DataChange);
}
- pub fn show_task_log(&self, task_id: impl Into<String>, endtime: Option<i64>) {
- let view_state = ViewState::TaskLog(task_id.into(), endtime);
- self.link.send_message(Msg::ChangeView(false, view_state));
+ fn repeated_load(&self, miliseconds: u32) {
+ self.send_message(Msg::RepeatedLoad(miliseconds));
}
- pub fn change_view(&self, child_view_state: Option<L::ViewState>) {
+ fn change_view(&self, child_view_state: Option<V>) {
let view_state = if let Some(child_view_state) = child_view_state {
ViewState::Dialog(child_view_state)
} else {
ViewState::Main
};
- self.link.send_message(Msg::ChangeView(false, view_state));
+ self.send_message(Msg::ChangeView(false, view_state));
}
- pub fn change_view_callback<F, IN, M>(&self, function: F) -> Callback<IN>
+ fn change_view_callback<C, F, IN>(&self, function: F) -> Callback<IN>
where
- M: Into<Option<L::ViewState>>,
- F: Fn(IN) -> M + 'static,
+ C: Into<Option<V>>,
+ F: Fn(IN) -> C + 'static,
{
- self.link.callback(move |p: IN| {
- let state: Option<L::ViewState> = function(p).into();
+ self.callback(move |p: IN| {
+ let state: Option<V> = function(p).into();
if let Some(state) = state {
Msg::ChangeView(true, ViewState::Dialog(state))
} else {
@@ -166,11 +299,38 @@ impl<L: LoadableComponent + Sized> LoadableComponentLink<L> {
})
}
- pub fn start_task(&self, command_path: impl Into<String>, data: Option<Value>, short: bool) {
+ fn spawn<Fut>(&self, future: Fut)
+ where
+ Fut: Future<Output = ()> + 'static,
+ {
+ self.send_message(Msg::Spawn(Box::pin(future)));
+ }
+
+ fn show_error(
+ &self,
+ title: impl Into<String>,
+ msg: impl std::fmt::Display,
+ reload_on_close: bool,
+ ) {
+ let view_state = ViewState::Error(title.into(), msg.to_string(), reload_on_close);
+ self.send_message(Msg::ChangeView(false, view_state));
+ }
+
+ fn show_task_progres(&self, task_id: impl Into<String>) {
+ let view_state = ViewState::TaskProgress(task_id.into());
+ self.send_message(Msg::ChangeView(false, view_state));
+ }
+
+ fn show_task_log(&self, task_id: impl Into<String>, endtime: Option<i64>) {
+ let view_state = ViewState::TaskLog(task_id.into(), endtime);
+ self.send_message(Msg::ChangeView(false, view_state));
+ }
+
+ fn start_task(&self, command_path: impl Into<String>, data: Option<Value>, short: bool) {
let command_path: String = command_path.into();
let link = self.clone();
let command_future = crate::http_post::<String>(command_path, data);
- self.link.send_message(Msg::Spawn(Box::pin(async move {
+ self.send_message(Msg::Spawn(Box::pin(async move {
match command_future.await {
Ok(task_id) => {
link.send_reload();
@@ -187,140 +347,86 @@ impl<L: LoadableComponent + Sized> LoadableComponentLink<L> {
}
})));
}
-
- /// Returns the original [`yew::html::Scope`] of the master component.
- ///
- /// This is useful when e.g. trying to get an higher level context
- pub fn yew_link(&self) -> &Scope<LoadableComponentMaster<L>> {
- &self.link
- }
}
-impl<L: LoadableComponent + Sized> RouterScopeExt for LoadableComponentLink<L> {
- fn navigator(&self) -> Option<yew_router::prelude::Navigator> {
- self.link.navigator()
- }
-
- fn location(&self) -> Option<yew_router::prelude::Location> {
- self.link.location()
- }
-
- fn route<R>(&self) -> Option<R>
- where
- R: yew_router::Routable + 'static,
- {
- self.link.route()
- }
-
- fn add_location_listener(
- &self,
- cb: Callback<yew_router::prelude::Location>,
- ) -> Option<yew_router::prelude::LocationHandle> {
- self.link.add_location_listener(cb)
- }
-
- fn add_navigator_listener(
- &self,
- cb: Callback<yew_router::prelude::Navigator>,
- ) -> Option<yew_router::prelude::NavigatorHandle> {
- self.link.add_navigator_listener(cb)
- }
+/// Base state for [LoadableComponent] implementations.
+///
+/// The struct provides the following features:
+///
+/// - access to load status informations
+/// - setup task base url
+/// - spawn tasks: includes an [AsyncPool], so that any [LoadableComponent] can spawn
+/// task via this pool.
+///
+/// The [LoadableComponent] trait requires access to this struct via [DerefMut]. The
+/// macro [impl_deref_mut_property] provides an easy way to
+/// implement that.
+///
+/// ```
+/// use proxmox_yew_comp::LoadableComponentState;
+/// # #[derive(PartialEq)]
+/// # pub enum ViewState { Add, Edit }
+/// pub struct MyComponentState {
+/// state: LoadableComponentState<ViewState>,
+/// // Add any other data you need
+/// other_data: String,
+/// }
+/// // implement DerefMut
+/// proxmox_yew_comp::impl_deref_mut_property!(MyComponentState, state, LoadableComponentState<ViewState>);
+/// ```
+pub struct LoadableComponentState<V: PartialEq> {
+ loading: usize,
+ last_load_error: Option<String>,
+ repeat_timespan: u32, /* 0 => no repeated loading */
+ task_base_url: Option<AttrValue>,
+ view_state: ViewState<V>,
+ reload_timeout: Option<Timeout>,
+ visible: bool,
+ visibitlity_observer: Option<DomVisibilityObserver>,
+ node_ref: NodeRef,
+ async_pool: AsyncPool,
}
-impl<L: LoadableComponent + Sized> NavigationContextExt for LoadableComponentLink<L> {
- fn nav_context(&self) -> Option<pwt::state::NavigationContext> {
- self.link.nav_context()
- }
-
- fn full_path(&self) -> Option<String> {
- self.link.full_path()
- }
-
- fn push_relative_route(&self, path: &str) {
- self.link.push_relative_route(path)
+impl<V: PartialEq> LoadableComponentState<V> {
+ pub fn new() -> Self {
+ Self {
+ loading: 0,
+ last_load_error: None,
+ repeat_timespan: 0,
+ task_base_url: None,
+ view_state: ViewState::Main,
+ reload_timeout: None,
+ visible: true,
+ visibitlity_observer: None,
+ node_ref: NodeRef::default(),
+ async_pool: AsyncPool::new(),
+ }
}
-}
-
-pub trait LoadableComponent: Sized {
- type Properties: Properties;
- type Message: 'static;
- type ViewState: 'static + PartialEq;
- fn create(ctx: &LoadableComponentContext<Self>) -> Self;
-
- fn load(
- &self,
- ctx: &LoadableComponentContext<Self>,
- ) -> Pin<Box<dyn Future<Output = Result<(), Error>>>>;
-
- #[allow(unused_variables)]
- fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
- true
+ pub fn loading(&self) -> bool {
+ self.loading > 0
}
- #[allow(unused_variables)]
- fn changed(
- &mut self,
- ctx: &LoadableComponentContext<Self>,
- _old_props: &Self::Properties,
- ) -> bool {
- true
+ pub fn last_load_errors(&self) -> Option<&str> {
+ self.last_load_error.as_deref()
}
- #[allow(unused_variables)]
- fn toolbar(&self, ctx: &LoadableComponentContext<Self>) -> Option<Html> {
- None
+ pub fn set_task_base_url(&mut self, base_url: AttrValue) {
+ self.task_base_url = Some(base_url);
}
- fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html;
-
- #[allow(unused_variables)]
- fn dialog_view(
- &self,
- ctx: &LoadableComponentContext<Self>,
- view_state: &Self::ViewState,
- ) -> Option<Html> {
- None
+ /// Spawn a future using the [AsyncPool] from the component.
+ pub fn spawn<Fut>(&self, future: Fut)
+ where
+ Fut: Future<Output = ()> + 'static,
+ {
+ self.async_pool.spawn(future);
}
-
- #[allow(unused_variables)]
- fn rendered(&mut self, ctx: &LoadableComponentContext<Self>, first_render: bool) {}
-}
-
-#[derive(Clone, PartialEq)]
-pub enum ViewState<V: PartialEq> {
- Main,
- /// Show the dialog returned by dialog_view
- Dialog(V),
- /// Show proxmox api task status
- TaskProgress(String),
- /// Show proxmox api task log
- TaskLog(String, Option<i64>),
- /// Show an error message dialog
- Error(String, String, /* reload_on_close */ bool),
-}
-
-pub enum Msg<M, V: PartialEq> {
- DataChange,
- Load,
- RepeatedLoad(u32 /* repeat time in miliseconds */),
- LoadResult(Result<(), Error>),
- ChangeView(/*reload*/ bool, ViewState<V>),
- ChildMessage(M),
- TaskBaseUrl(AttrValue),
- Visible(bool),
- Spawn(Pin<Box<dyn Future<Output = ()>>>),
}
+#[doc(hidden)]
pub struct LoadableComponentMaster<L: LoadableComponent> {
state: L,
- comp_state: LoadableComponentState,
- view_state: ViewState<L::ViewState>,
- reload_timeout: Option<Timeout>,
- visible: bool,
- visibitlity_observer: Option<DomVisibilityObserver>,
- node_ref: NodeRef,
- async_pool: AsyncPool,
}
impl<L: LoadableComponent + 'static> Component for LoadableComponentMaster<L> {
@@ -328,91 +434,65 @@ impl<L: LoadableComponent + 'static> Component for LoadableComponentMaster<L> {
type Properties = L::Properties;
fn create(ctx: &Context<Self>) -> Self {
- let loading = 0;
-
- let comp_state = LoadableComponentState {
- loading,
- last_load_error: None,
- repeat_timespan: 0,
- task_base_url: None,
- };
-
- let sub_context = LoadableComponentContext {
- ctx,
- comp_state: &comp_state,
- };
-
// Send Msg::Load first (before any Msg::RepeatedLoad in create), so that we
// can avoid multiple loads at startup
ctx.link().send_message(Msg::Load);
- let state = L::create(&sub_context);
+ let mut state = L::create(ctx);
+ state.visible = true;
- Self {
- state,
- comp_state,
- view_state: ViewState::Main,
- reload_timeout: None,
- visible: true,
- visibitlity_observer: None,
- node_ref: NodeRef::default(),
- async_pool: AsyncPool::new(),
- }
+ Self { state }
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Msg::Spawn(future) => {
- self.async_pool.spawn(future);
+ self.state.async_pool.spawn(future);
false
}
Msg::DataChange => true,
Msg::Load => {
- self.comp_state.loading += 1;
+ let load_future = self.state.load(ctx);
+ self.state.loading += 1;
let link = ctx.link().clone();
- let sub_context = LoadableComponentContext {
- ctx,
- comp_state: &self.comp_state,
- };
- let load_future = self.state.load(&sub_context);
- self.async_pool.spawn(async move {
+ self.state.async_pool.spawn(async move {
let data = load_future.await;
link.send_message(Msg::LoadResult(data));
});
true
}
Msg::RepeatedLoad(timespan) => {
- self.comp_state.repeat_timespan = timespan;
- self.reload_timeout = None;
- if self.comp_state.loading == 0 {
+ self.state.repeat_timespan = timespan;
+ self.state.reload_timeout = None;
+ if self.state.loading == 0 {
<Self as yew::Component>::update(self, ctx, Msg::Load);
}
false
}
Msg::LoadResult(data) => {
- self.comp_state.loading -= 1;
+ self.state.loading -= 1;
match data {
Ok(()) => {
- self.comp_state.last_load_error = None;
+ self.state.last_load_error = None;
}
Err(err) => {
- let this_is_the_first_error = self.comp_state.last_load_error.is_none();
- self.comp_state.last_load_error = Some(err.to_string());
+ let this_is_the_first_error = self.state.last_load_error.is_none();
+ self.state.last_load_error = Some(err.to_string());
if this_is_the_first_error {
- self.view_state =
+ self.state.view_state =
ViewState::Error(tr!("Load failed"), err.to_string(), false);
}
}
}
- self.reload_timeout = None;
- if self.comp_state.loading == 0 {
+ self.state.reload_timeout = None;
+ if self.state.loading == 0 {
/* no outstanding loads */
- if self.comp_state.repeat_timespan > 0 {
+ if self.state.repeat_timespan > 0 {
let link = ctx.link().clone();
- if self.visible {
- self.reload_timeout =
- Some(Timeout::new(self.comp_state.repeat_timespan, move || {
+ if self.state.visible {
+ self.state.reload_timeout =
+ Some(Timeout::new(self.state.repeat_timespan, move || {
link.send_message(Msg::Load);
}));
}
@@ -421,7 +501,7 @@ impl<L: LoadableComponent + 'static> Component for LoadableComponentMaster<L> {
true
}
Msg::ChangeView(reload_data, view_state) => {
- if self.view_state == view_state {
+ if self.state.view_state == view_state {
return false;
}
@@ -429,29 +509,21 @@ impl<L: LoadableComponent + 'static> Component for LoadableComponentMaster<L> {
ctx.link().send_message(Msg::Load);
}
- self.view_state = view_state;
+ self.state.view_state = view_state;
true
}
Msg::ChildMessage(child_msg) => {
- let sub_context = LoadableComponentContext {
- ctx,
- comp_state: &self.comp_state,
- };
- self.state.update(&sub_context, child_msg);
+ self.state.update(ctx, child_msg);
true
}
- Msg::TaskBaseUrl(base_url) => {
- self.comp_state.task_base_url = Some(base_url);
- false
- }
Msg::Visible(visible) => {
- if self.visible == visible {
+ if self.state.visible == visible {
return false;
}
- self.visible = visible;
- if self.comp_state.loading == 0 && self.visible {
+ self.state.visible = visible;
+ if self.state.loading == 0 && self.state.visible {
/* no outstanding loads */
- if self.comp_state.loading == 0 {
+ if self.state.loading == 0 {
<Self as yew::Component>::update(self, ctx, Msg::Load);
}
}
@@ -461,26 +533,16 @@ impl<L: LoadableComponent + 'static> Component for LoadableComponentMaster<L> {
}
fn changed(&mut self, ctx: &Context<Self>, _old_props: &Self::Properties) -> bool {
- let sub_context = LoadableComponentContext {
- ctx,
- comp_state: &self.comp_state,
- };
-
- self.state.changed(&sub_context, _old_props)
+ self.state.changed(ctx, _old_props)
}
fn view(&self, ctx: &Context<Self>) -> Html {
- let sub_context = LoadableComponentContext {
- ctx,
- comp_state: &self.comp_state,
- };
-
- let main_view = self.state.main_view(&sub_context);
+ let main_view = self.state.main_view(ctx);
let dialog: Option<Html> =
- match &self.view_state {
+ match &self.state.view_state {
ViewState::Main => None,
- ViewState::Dialog(view_state) => self.state.dialog_view(&sub_context, view_state),
+ ViewState::Dialog(view_state) => self.state.dialog_view(ctx, view_state),
ViewState::Error(title, msg, reload_on_close) => {
let reload_on_close = *reload_on_close;
Some(
@@ -498,7 +560,7 @@ impl<L: LoadableComponent + 'static> Component for LoadableComponentMaster<L> {
.callback(move |_| Msg::ChangeView(true, ViewState::Main)),
);
- if let Some(base_url) = &self.comp_state.task_base_url {
+ if let Some(base_url) = &self.state.task_base_url {
task_progress.set_base_url(base_url);
}
@@ -510,19 +572,19 @@ impl<L: LoadableComponent + 'static> Component for LoadableComponentMaster<L> {
.callback(move |_| Msg::ChangeView(true, ViewState::Main)),
);
- if let Some(base_url) = &self.comp_state.task_base_url {
+ if let Some(base_url) = &self.state.task_base_url {
task_viewer.set_base_url(base_url);
}
Some(task_viewer.into())
}
};
- let toolbar = self.state.toolbar(&sub_context);
+ let toolbar = self.state.toolbar(ctx);
let mut alert_msg = None;
if dialog.is_none() {
- if let Some(msg) = &self.comp_state.last_load_error {
+ if let Some(msg) = &self.state.last_load_error {
alert_msg = Some(pwt::widget::error_message(msg).class("pwt-border-top"));
}
}
@@ -533,23 +595,18 @@ impl<L: LoadableComponent + 'static> Component for LoadableComponentMaster<L> {
.with_child(main_view)
.with_optional_child(alert_msg)
.with_optional_child(dialog)
- .into_html_with_ref(self.node_ref.clone())
+ .into_html_with_ref(self.state.node_ref.clone())
}
fn rendered(&mut self, ctx: &Context<Self>, first_render: bool) {
- if self.visibitlity_observer.is_none() && self.reload_timeout.is_some() {
- if let Some(el) = self.node_ref.cast::<web_sys::Element>() {
- self.visibitlity_observer = Some(DomVisibilityObserver::new(
+ if self.state.visibitlity_observer.is_none() && self.state.reload_timeout.is_some() {
+ if let Some(el) = self.state.node_ref.cast::<web_sys::Element>() {
+ self.state.visibitlity_observer = Some(DomVisibilityObserver::new(
&el,
ctx.link().callback(Msg::Visible),
))
}
}
- let sub_context = LoadableComponentContext {
- ctx,
- comp_state: &self.comp_state,
- };
-
- self.state.rendered(&sub_context, first_render);
+ self.state.rendered(ctx, first_render);
}
}
diff --git a/src/node_status_panel.rs b/src/node_status_panel.rs
index 3f8946d..f7d4810 100644
--- a/src/node_status_panel.rs
+++ b/src/node_status_panel.rs
@@ -17,7 +17,7 @@ use proxmox_node_status::{NodePowerCommand, NodeStatus};
use crate::utils::copy_text_to_clipboard;
use crate::{
http_get, http_post, node_info, ConfirmButton, LoadableComponent, LoadableComponentContext,
- LoadableComponentMaster,
+ LoadableComponentMaster, LoadableComponentScopeExt, LoadableComponentState,
};
#[derive(Properties, Clone, PartialEq)]
@@ -53,7 +53,6 @@ enum Msg {
Error(Error),
Loaded(Rc<NodeStatus>),
RebootOrShutdown(NodePowerCommand),
- Reload,
}
#[derive(PartialEq)]
@@ -62,10 +61,17 @@ enum ViewState {
}
struct ProxmoxNodeStatusPanel {
+ state: LoadableComponentState<ViewState>,
node_status: Option<Rc<NodeStatus>>,
error: Option<Error>,
}
+crate::impl_deref_mut_property!(
+ ProxmoxNodeStatusPanel,
+ state,
+ LoadableComponentState<ViewState>
+);
+
impl ProxmoxNodeStatusPanel {
fn change_power_state(&self, ctx: &LoadableComponentContext<Self>, command: NodePowerCommand) {
let Some(url) = ctx.props().status_base_url.clone() else {
@@ -79,7 +85,7 @@ impl ProxmoxNodeStatusPanel {
}));
match http_post(url.as_str(), data).await {
- Ok(()) => link.send_message(Msg::Reload),
+ Ok(()) => link.send_redraw(),
Err(err) => link.send_message(Msg::Error(err)),
}
});
@@ -90,8 +96,8 @@ impl ProxmoxNodeStatusPanel {
ctx: &LoadableComponentContext<Self>,
fingerprint: &str,
) -> Dialog {
- let link = ctx.link();
- let link_button = ctx.link();
+ let link = ctx.link().clone();
+ let link_button = ctx.link().clone();
let fingerprint = fingerprint.to_owned();
Dialog::new(tr!("Fingerprint"))
@@ -135,10 +141,10 @@ impl LoadableComponent for ProxmoxNodeStatusPanel {
type ViewState = ViewState;
type Properties = NodeStatusPanel;
- fn create(ctx: &crate::LoadableComponentContext<Self>) -> Self {
+ fn create(ctx: &LoadableComponentContext<Self>) -> Self {
ctx.link().repeated_load(5000);
-
Self {
+ state: LoadableComponentState::new(),
node_status: None,
error: None,
}
@@ -146,7 +152,7 @@ impl LoadableComponent for ProxmoxNodeStatusPanel {
fn load(
&self,
- ctx: &crate::LoadableComponentContext<Self>,
+ ctx: &LoadableComponentContext<Self>,
) -> std::pin::Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
let url = ctx.props().status_base_url.clone();
let link = ctx.link().clone();
@@ -162,7 +168,7 @@ impl LoadableComponent for ProxmoxNodeStatusPanel {
})
}
- fn update(&mut self, ctx: &crate::LoadableComponentContext<Self>, msg: Self::Message) -> bool {
+ fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
match msg {
Msg::Error(err) => {
self.error = Some(err);
@@ -177,7 +183,6 @@ impl LoadableComponent for ProxmoxNodeStatusPanel {
self.change_power_state(ctx, command);
false
}
- Msg::Reload => true,
}
}
@@ -197,7 +202,7 @@ impl LoadableComponent for ProxmoxNodeStatusPanel {
None
}
- fn main_view(&self, ctx: &crate::LoadableComponentContext<Self>) -> Html {
+ fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
let status = self
.node_status
.as_ref()
diff --git a/src/notes_view.rs b/src/notes_view.rs
index 6b0a379..5a96408 100644
--- a/src/notes_view.rs
+++ b/src/notes_view.rs
@@ -17,7 +17,7 @@ use proxmox_client::ApiResponseData;
use crate::{
ApiLoadCallback, EditWindow, LoadableComponent, LoadableComponentContext,
- LoadableComponentMaster, Markdown,
+ LoadableComponentMaster, LoadableComponentScopeExt, LoadableComponentState, Markdown,
};
#[derive(Serialize, Deserialize, Clone, PartialEq)]
@@ -107,10 +107,13 @@ pub enum Msg {
#[doc(hidden)]
pub struct ProxmoxNotesView {
+ state: LoadableComponentState<ViewState>,
data: NotesWithDigest,
edit_window_loader: ApiLoadCallback<Value>,
}
+crate::impl_deref_mut_property!(ProxmoxNotesView, state, LoadableComponentState<ViewState>);
+
impl LoadableComponent for ProxmoxNotesView {
type Properties = NotesView;
type Message = Msg;
@@ -130,6 +133,7 @@ impl LoadableComponent for ProxmoxNotesView {
}
});
Self {
+ state: LoadableComponentState::new(),
data: NotesWithDigest {
notes: String::new(),
digest: None,
@@ -143,7 +147,7 @@ impl LoadableComponent for ProxmoxNotesView {
ctx: &LoadableComponentContext<Self>,
) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
let loader = ctx.props().loader.clone();
- let link = ctx.link();
+ let link = ctx.link().clone();
Box::pin(async move {
let resp = loader.apply().await?;
let notes = resp.data;
@@ -169,10 +173,8 @@ impl LoadableComponent for ProxmoxNotesView {
.class("pwt-overflow-hidden")
.class("pwt-border-bottom")
.with_child(
- Button::new(tr!("Edit")).on_activate(
- ctx.link()
- .change_view_callback(|_| Some(ViewState::EditNotes)),
- ),
+ Button::new(tr!("Edit"))
+ .on_activate(ctx.link().change_view_callback(|_| ViewState::EditNotes)),
)
.into(),
)
diff --git a/src/object_grid.rs b/src/object_grid.rs
index a6313d0..40214a3 100644
--- a/src/object_grid.rs
+++ b/src/object_grid.rs
@@ -8,7 +8,7 @@ use indexmap::IndexMap;
use proxmox_client::ApiResponseData;
use serde_json::Value;
-use yew::html::IntoPropValue;
+use yew::html::{IntoPropValue, Scope};
use yew::prelude::*;
use yew::virtual_dom::{Key, VComp, VNode};
@@ -20,8 +20,11 @@ use pwt::widget::form::FormContext;
use pwt::widget::{Button, Toolbar};
use crate::{ApiLoadCallback, IntoApiLoadCallback};
-use crate::{EditWindow, KVGrid, KVGridRow, LoadableComponentLink};
-use crate::{LoadableComponent, LoadableComponentContext, LoadableComponentMaster};
+use crate::{EditWindow, KVGrid, KVGridRow};
+use crate::{
+ LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
+ LoadableComponentScopeExt, LoadableComponentState,
+};
use pwt_macros::builder;
@@ -257,6 +260,8 @@ pub enum ViewState {
#[doc(hidden)]
pub struct PwtObjectGrid {
+ state: LoadableComponentState<ViewState>,
+
selection: Option<Key>,
data: Rc<Value>,
@@ -266,6 +271,8 @@ pub struct PwtObjectGrid {
controller_observer: Option<SharedStateObserver<Vec<ObjectGridCommand>>>,
}
+crate::impl_deref_mut_property!(PwtObjectGrid, state, LoadableComponentState<ViewState>);
+
impl PwtObjectGrid {
fn update_rows(&mut self, props: &ObjectGrid) {
let mut rows = Vec::new();
@@ -280,7 +287,11 @@ impl PwtObjectGrid {
self.rows = Rc::new(rows);
}
- fn update_controller(&mut self, props: &ObjectGrid, link: LoadableComponentLink<Self>) {
+ fn update_controller(
+ &mut self,
+ props: &ObjectGrid,
+ link: Scope<LoadableComponentMaster<Self>>,
+ ) {
match &props.controller {
None => self.controller_observer = None,
Some(controller) => {
@@ -335,6 +346,7 @@ impl LoadableComponent for PwtObjectGrid {
ctx.link().repeated_load(3000);
let mut me = Self {
+ state: LoadableComponentState::new(),
data: Rc::new(Value::Null),
rows: Rc::new(Vec::new()),
editors: IndexMap::new(),
@@ -342,7 +354,7 @@ impl LoadableComponent for PwtObjectGrid {
controller_observer: None,
};
me.update_rows(props);
- me.update_controller(props, ctx.link());
+ me.update_controller(props, ctx.link().clone());
me
}
@@ -352,7 +364,7 @@ impl LoadableComponent for PwtObjectGrid {
) -> Pin<Box<dyn Future<Output = Result<(), Error>>>> {
let props = ctx.props();
let loader = props.loader.clone();
- let link = ctx.link();
+ let link = ctx.link().clone();
Box::pin(async move {
if let Some(loader) = &loader {
@@ -418,7 +430,7 @@ impl LoadableComponent for PwtObjectGrid {
let mut toolbar = Toolbar::new()
.border_bottom(true)
.with_child(Button::new("Edit").disabled(disable_edit).onclick({
- let link = ctx.link();
+ let link = ctx.link().clone();
move |_| {
link.change_view(Some(ViewState::EditObject));
}
diff --git a/src/permission_panel.rs b/src/permission_panel.rs
index 3ed07b1..e7d2c27 100644
--- a/src/permission_panel.rs
+++ b/src/permission_panel.rs
@@ -18,7 +18,7 @@ use pwt::widget::data_table::{
use pwt_macros::builder;
-use crate::{http_get, LoadableComponent, LoadableComponentMaster};
+use crate::{http_get, LoadableComponent, LoadableComponentMaster, LoadableComponentState};
#[derive(Clone, PartialEq, Properties)]
#[builder]
@@ -46,10 +46,13 @@ impl PermissionPanel {
}
}
pub struct ProxmoxPermissionPanel {
+ state: LoadableComponentState<()>,
store: TreeStore<PermissionInfo>,
columns: Rc<Vec<DataTableHeader<PermissionInfo>>>,
}
+crate::impl_deref_mut_property!(ProxmoxPermissionPanel, state, LoadableComponentState<()>);
+
#[derive(Debug, Clone, Ord, PartialOrd, Eq, PartialEq)]
enum PermissionInfo {
Permission(String, String, bool),
@@ -113,15 +116,19 @@ impl LoadableComponent for ProxmoxPermissionPanel {
type Message = ();
type ViewState = ();
- fn create(_ctx: &crate::LoadableComponentContext<Self>) -> Self {
+ fn create(_ctx: &Context<LoadableComponentMaster<Self>>) -> Self {
let store = TreeStore::new();
let columns = Rc::new(columns(&store));
- Self { store, columns }
+ Self {
+ state: LoadableComponentState::new(),
+ store,
+ columns,
+ }
}
fn load(
&self,
- ctx: &crate::LoadableComponentContext<Self>,
+ ctx: &Context<LoadableComponentMaster<Self>>,
) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
let props = ctx.props();
let base_url = props.base_url.clone();
@@ -151,7 +158,7 @@ impl LoadableComponent for ProxmoxPermissionPanel {
})
}
- fn main_view(&self, _ctx: &crate::LoadableComponentContext<Self>) -> Html {
+ fn main_view(&self, _ctx: &Context<LoadableComponentMaster<Self>>) -> Html {
DataTable::new(Rc::clone(&self.columns), self.store.clone())
.class("pwt-flex-fit")
.into()
diff --git a/src/subscription_panel.rs b/src/subscription_panel.rs
index 9f8e65e..1b6a8e4 100644
--- a/src/subscription_panel.rs
+++ b/src/subscription_panel.rs
@@ -12,8 +12,13 @@ use pwt::widget::form::{Field, FormContext};
use pwt::widget::{Button, Container, InputPanel, Toolbar};
use crate::utils::render_epoch;
-use crate::{ConfirmButton, DataViewWindow, EditWindow, KVGrid, KVGridRow, ProjectInfo};
-use crate::{LoadableComponent, LoadableComponentContext, LoadableComponentMaster};
+use crate::{
+ ConfirmButton, DataViewWindow, EditWindow, KVGrid, KVGridRow, LoadableComponentState,
+ ProjectInfo,
+};
+use crate::{
+ LoadableComponent, LoadableComponentContext, LoadableComponentMaster, LoadableComponentScopeExt,
+};
#[derive(Properties, PartialEq, Clone)]
pub struct SubscriptionPanel {
@@ -38,20 +43,26 @@ pub enum ViewState {
SystemReport,
}
-pub enum Msg {}
-
pub struct ProxmoxSubscriptionPanel {
+ state: LoadableComponentState<ViewState>,
rows: Rc<Vec<KVGridRow>>,
data: Rc<RefCell<Rc<Value>>>,
}
+crate::impl_deref_mut_property!(
+ ProxmoxSubscriptionPanel,
+ state,
+ LoadableComponentState<ViewState>
+);
+
impl LoadableComponent for ProxmoxSubscriptionPanel {
- type Message = Msg;
+ type Message = ();
type Properties = SubscriptionPanel;
type ViewState = ViewState;
fn create(_ctx: &LoadableComponentContext<Self>) -> Self {
Self {
+ state: LoadableComponentState::new(),
rows: Rc::new(rows()),
data: Rc::new(RefCell::new(Rc::new(Value::Null))),
}
@@ -59,7 +70,7 @@ impl LoadableComponent for ProxmoxSubscriptionPanel {
fn load(
&self,
- ctx: &crate::LoadableComponentContext<Self>,
+ ctx: &Context<LoadableComponentMaster<Self>>,
) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
let data = self.data.clone();
let base_url = ctx.props().base_url.to_string();
@@ -70,7 +81,7 @@ impl LoadableComponent for ProxmoxSubscriptionPanel {
})
}
- fn toolbar(&self, ctx: &LoadableComponentContext<Self>) -> Option<Html> {
+ fn toolbar(&self, ctx: &Context<LoadableComponentMaster<Self>>) -> Option<Html> {
let toolbar = Toolbar::new()
.class("pwt-overflow-hidden")
.with_child(
@@ -85,7 +96,7 @@ impl LoadableComponent for ProxmoxSubscriptionPanel {
Button::new(tr!("Check"))
.icon_class("fa fa-check-square-o")
.onclick({
- let link = ctx.link();
+ let link = ctx.link().clone();
let base_url = ctx.props().base_url.to_string();
move |_| {
link.spawn({
@@ -112,7 +123,7 @@ impl LoadableComponent for ProxmoxSubscriptionPanel {
html! {tr!("Are you sure you want to remove the subscription key?")},
)
.on_activate({
- let link = ctx.link();
+ let link = ctx.link().clone();
let base_url = ctx.props().base_url.to_string();
move |_| {
link.spawn({
@@ -141,8 +152,8 @@ impl LoadableComponent for ProxmoxSubscriptionPanel {
)
.with_flex_spacer()
.with_child({
- let loading = ctx.loading();
- let link = ctx.link();
+ let loading = self.loading();
+ let link = ctx.link().clone();
Button::refresh(loading).onclick(move |_| link.send_reload())
});
diff --git a/src/tasks.rs b/src/tasks.rs
index d3e814f..8dec24f 100644
--- a/src/tasks.rs
+++ b/src/tasks.rs
@@ -26,7 +26,10 @@ use pbs_api_types::TaskListItem;
use pwt_macros::builder;
-use crate::{LoadableComponent, LoadableComponentContext, LoadableComponentMaster, TaskViewer};
+use crate::{
+ LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
+ LoadableComponentScopeExt, LoadableComponentState, TaskViewer,
+};
use super::{TaskStatusSelector, TaskTypeSelector};
@@ -98,7 +101,6 @@ pub enum ViewDialog {
}
pub enum Msg {
- Redraw,
ToggleFilter,
LoadBatch(bool), // fresh load
LoadFinished,
@@ -106,6 +108,7 @@ pub enum Msg {
ShowTask,
}
pub struct ProxmoxTasks {
+ state: LoadableComponentState<ViewDialog>,
selection: Selection,
store: Store<TaskListItem>,
show_filter: PersistentState<bool>,
@@ -117,6 +120,8 @@ pub struct ProxmoxTasks {
columns: Rc<Vec<DataTableHeader<TaskListItem>>>,
}
+crate::impl_deref_mut_property!(ProxmoxTasks, state, LoadableComponentState<ViewDialog>);
+
impl ProxmoxTasks {
fn columns(ctx: &LoadableComponentContext<Self>) -> Rc<Vec<DataTableHeader<TaskListItem>>> {
if let Some(columns) = ctx.props().columns.clone() {
@@ -169,7 +174,10 @@ impl LoadableComponent for ProxmoxTasks {
fn create(ctx: &LoadableComponentContext<Self>) -> Self {
let link = ctx.link();
- let selection = Selection::new().on_select(link.callback(|_| Msg::Redraw));
+ let selection = Selection::new().on_select({
+ let link = ctx.link().clone();
+ move |_| link.send_redraw()
+ });
let store = Store::with_extract_key(|item: &TaskListItem| Key::from(item.upid.clone()));
let filter_form_context =
@@ -194,6 +202,7 @@ impl LoadableComponent for ProxmoxTasks {
});
Self {
+ state: LoadableComponentState::new(),
selection,
store,
show_filter: PersistentState::new("ProxmoxTasksShowFilter"),
@@ -293,7 +302,6 @@ impl LoadableComponent for ProxmoxTasks {
fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
match msg {
- Msg::Redraw => true,
Msg::ToggleFilter => {
self.show_filter.update(!*self.show_filter);
true
@@ -304,7 +312,7 @@ impl LoadableComponent for ProxmoxTasks {
return false;
}
let filter_params = form_context.get_submit_data();
- if ctx.loading() && self.last_filter == filter_params {
+ if self.loading() && self.last_filter == filter_params {
return false;
}
@@ -384,8 +392,8 @@ impl LoadableComponent for ProxmoxTasks {
.onclick(ctx.link().callback(|_| Msg::ToggleFilter)),
)
.with_child({
- let loading = ctx.loading();
- let link = ctx.link();
+ let loading = self.loading();
+ let link = ctx.link().clone();
Button::refresh(loading).onclick(move |_| link.send_message(Msg::LoadBatch(true)))
});
@@ -441,7 +449,7 @@ impl LoadableComponent for ProxmoxTasks {
fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
let columns = self.columns.clone();
- let link = ctx.link();
+ let link = ctx.link().clone();
DataTable::new(columns, self.store.clone())
.class("pwt-flex-fit")
diff --git a/src/tfa/tfa_view.rs b/src/tfa/tfa_view.rs
index c4c1a43..bba9692 100644
--- a/src/tfa/tfa_view.rs
+++ b/src/tfa/tfa_view.rs
@@ -16,7 +16,10 @@ use pwt::widget::{Button, Mask, Toolbar};
use pwt_macros::builder;
-use crate::{LoadableComponent, LoadableComponentContext, LoadableComponentMaster};
+use crate::{
+ impl_deref_mut_property, LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
+ LoadableComponentScopeExt, LoadableComponentState,
+};
use proxmox_tfa::{TfaType, TfaUser};
@@ -87,7 +90,6 @@ impl TfaView {
}
pub enum Msg {
- Redraw,
Edit,
Remove(Option<String>),
RemoveResult(Result<(), Error>),
@@ -104,11 +106,14 @@ pub enum ViewState {
#[doc(hidden)]
pub struct ProxmoxTfaView {
+ state: LoadableComponentState<ViewState>,
selection: Selection,
store: Store<TfaEntry>,
removing: bool,
}
+impl_deref_mut_property!(ProxmoxTfaView, state, LoadableComponentState<ViewState>);
+
impl ProxmoxTfaView {
fn get_selected_record(&self) -> Option<TfaEntry> {
let selected_key = self.selection.selected_key();
@@ -127,8 +132,12 @@ impl LoadableComponent for ProxmoxTfaView {
fn create(ctx: &LoadableComponentContext<Self>) -> Self {
let store = Store::new();
- let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::Redraw));
+ let selection = Selection::new().on_select({
+ let link = ctx.link().clone();
+ move |_| link.send_redraw()
+ });
Self {
+ state: LoadableComponentState::new(),
store,
selection,
removing: false,
@@ -177,7 +186,6 @@ impl LoadableComponent for ProxmoxTfaView {
fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
let props = ctx.props();
match msg {
- Msg::Redraw => true,
Msg::Edit => {
let info = match self.get_selected_record() {
Some(info) => info,
@@ -204,8 +212,8 @@ impl LoadableComponent for ProxmoxTfaView {
// fixme: ask use if he really wants to remove
let link = ctx.link().clone();
let base_url = props.base_url.clone();
- link.send_future(async move {
- Msg::RemoveResult(
+ self.spawn(async move {
+ link.send_message(Msg::RemoveResult(
delete_item(
base_url,
info.user_id.clone(),
@@ -213,7 +221,7 @@ impl LoadableComponent for ProxmoxTfaView {
password,
)
.await,
- )
+ ))
});
false
@@ -282,8 +290,8 @@ impl LoadableComponent for ProxmoxTfaView {
)
.with_flex_spacer()
.with_child({
- let loading = ctx.loading();
- let link = ctx.link();
+ let loading = self.loading();
+ let link = ctx.link().clone();
Button::refresh(loading).onclick(move |_| link.send_reload())
});
@@ -296,7 +304,7 @@ impl LoadableComponent for ProxmoxTfaView {
.selection(self.selection.clone())
.class("pwt-flex-fit")
.on_row_dblclick({
- let link = ctx.link();
+ let link = ctx.link().clone();
move |_: &mut _| link.send_message(Msg::Edit)
});
Mask::new(view).visible(self.removing).into()
@@ -314,7 +322,7 @@ impl LoadableComponent for ProxmoxTfaView {
TfaConfirmRemove::new(info)
.on_close(ctx.link().change_view_callback(|_| None))
.on_confirm({
- let link = ctx.link();
+ let link = ctx.link().clone();
move |password| link.send_message(Msg::Remove(password))
})
.into()
diff --git a/src/token_panel.rs b/src/token_panel.rs
index 031d54f..5291620 100644
--- a/src/token_panel.rs
+++ b/src/token_panel.rs
@@ -25,7 +25,8 @@ use crate::utils::{
};
use crate::{
AuthidSelector, ConfirmButton, EditWindow, LoadableComponent, LoadableComponentContext,
- LoadableComponentLink, LoadableComponentMaster, PermissionPanel,
+ LoadableComponentMaster, LoadableComponentScope, LoadableComponentScopeExt,
+ LoadableComponentState, PermissionPanel,
};
async fn load_api_tokens() -> Result<Vec<ApiToken>, Error> {
@@ -37,7 +38,7 @@ async fn load_api_tokens() -> Result<Vec<ApiToken>, Error> {
async fn create_token(
form_ctx: FormContext,
- link: LoadableComponentLink<ProxmoxTokenView>,
+ link: LoadableComponentScope<ProxmoxTokenView>,
) -> Result<(), Error> {
let mut data = form_ctx.get_submit_data();
@@ -118,17 +119,19 @@ enum ViewState {
}
enum Msg {
- Refresh,
Remove,
Regenerate,
}
struct ProxmoxTokenView {
+ state: LoadableComponentState<ViewState>,
selection: Selection,
store: Store<ApiToken>,
columns: Rc<Vec<DataTableHeader<ApiToken>>>,
}
+crate::impl_deref_mut_property!(ProxmoxTokenView, state, LoadableComponentState<ViewState>);
+
fn token_api_url(user: &str, tokenname: &str) -> String {
format!(
"/access/users/{}/token/{}",
@@ -146,11 +149,15 @@ impl LoadableComponent for ProxmoxTokenView {
let link = ctx.link();
link.repeated_load(5000);
- let selection = Selection::new().on_select(link.callback(|_| Msg::Refresh));
+ let selection = Selection::new().on_select({
+ let link = ctx.link().clone();
+ move |_| link.send_redraw()
+ });
let store =
Store::with_extract_key(|record: &ApiToken| Key::from(record.tokenid.to_string()));
Self {
+ state: LoadableComponentState::new(),
selection,
store,
columns: columns(),
@@ -217,7 +224,6 @@ impl LoadableComponent for ProxmoxTokenView {
fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
match msg {
- Msg::Refresh => true,
Msg::Remove => {
let Some(record) = self.store.selected_record(&self.selection) else {
return false;
@@ -230,7 +236,7 @@ impl LoadableComponent for ProxmoxTokenView {
};
let url = token_api_url(&user, tokenname.as_str());
- let link = ctx.link();
+ let link = ctx.link().clone();
link.clone().spawn(async move {
match crate::http_delete(url, None).await {
Ok(()) => {
@@ -271,7 +277,7 @@ impl LoadableComponent for ProxmoxTokenView {
}
fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
- let link = ctx.link();
+ let link = ctx.link().clone();
DataTable::new(self.columns.clone(), self.store.clone())
.class("pwt-flex-fit")
diff --git a/src/user_panel.rs b/src/user_panel.rs
index d364df8..f7af395 100644
--- a/src/user_panel.rs
+++ b/src/user_panel.rs
@@ -23,8 +23,9 @@ use crate::form::delete_empty_values;
use crate::percent_encoding::percent_encode_component;
use crate::utils::{epoch_to_input_value, render_epoch_short};
use crate::{
- EditWindow, LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
- PermissionPanel, RealmSelector, SchemaValidation,
+ impl_deref_mut_property, EditWindow, LoadableComponent, LoadableComponentContext,
+ LoadableComponentMaster, LoadableComponentScopeExt, LoadableComponentState, PermissionPanel,
+ RealmSelector, SchemaValidation,
};
async fn load_user_list() -> Result<Vec<UserWithTokens>, Error> {
@@ -111,15 +112,17 @@ pub enum ViewState {
}
pub enum Msg {
- SelectionChange,
RemoveItem,
}
pub struct ProxmoxUserPanel {
+ state: LoadableComponentState<ViewState>,
store: Store<UserWithTokens>,
selection: Selection,
}
+impl_deref_mut_property!(ProxmoxUserPanel, state, LoadableComponentState<ViewState>);
+
impl LoadableComponent for ProxmoxUserPanel {
type Message = Msg;
type Properties = UserPanel;
@@ -142,17 +145,23 @@ impl LoadableComponent for ProxmoxUserPanel {
Key::from(record.user.userid.as_str())
});
- let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::SelectionChange));
+ let selection = Selection::new().on_select({
+ let link = ctx.link().clone();
+ move |_| link.send_redraw()
+ });
- Self { store, selection }
+ Self {
+ state: LoadableComponentState::new(),
+ store,
+ selection,
+ }
}
fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
match msg {
- Msg::SelectionChange => true,
Msg::RemoveItem => {
if let Some(key) = self.selection.selected_key() {
- let link = ctx.link();
+ let link = ctx.link().clone();
link.clone().spawn(async move {
if let Err(err) = delete_user(key).await {
link.show_error(tr!("Unable to delete user"), err, true);
@@ -206,8 +215,8 @@ impl LoadableComponent for ProxmoxUserPanel {
)
.with_flex_spacer()
.with_child({
- let loading = ctx.loading();
- let link = ctx.link();
+ let loading = self.loading();
+ let link = ctx.link().clone();
Button::refresh(loading).onclick(move |_| link.send_reload())
});
@@ -215,7 +224,7 @@ impl LoadableComponent for ProxmoxUserPanel {
}
fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
- let link = ctx.link();
+ let link = ctx.link().clone();
DataTable::new(columns(), self.store.clone())
.class("pwt-flex-fill pwt-overflow-auto")
.selection(self.selection.clone())
--
2.47.3
_______________________________________________
yew-devel mailing list
yew-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/yew-devel
^ permalink raw reply related [relevance 2%]
* [yew-devel] [RFC yew-comp] refactor: move LoadableComponent state into component implementations
@ 2025-12-09 13:11 1% Dietmar Maurer
0 siblings, 0 replies; 19+ results
From: Dietmar Maurer @ 2025-12-09 13:11 UTC (permalink / raw)
To: yew-devel
Major Refactoring of the `LoadableComponent` system.
Encapsulate component state (loading, error, view_state) into a new
`LoadableComponentState` struct.
Instead of `LoadableComponentMaster` managing the state externally,
the concrete components now own their `LoadableComponentState`.
- Use `impl_deref_mut_property`LoadableComponentState` struct. to implement
Deref/DerefMut for components, allowing `LoadableComponentMaster` to
access the state transparently.
- `LoadableComponentContext` is now a normal yew Scope (removed custom implementation)
- Migrate all `LoadableComponent` implementations (ACL, ACME, APT, Network,
User/Token/TFA views, etc.) to the new pattern.
- Use `link.custom_callback` and `link.send_custom_message` for internal
messaging (rename is necessaray because of naming conflict with standard
Scope function).
- avoid useless Redraw/Datachange/Refresh messages, because `LoadableComponentMaster`
already implements that.
Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
---
src/acl/acl_view.rs | 26 +-
src/acme/acme_accounts.rs | 28 +-
src/acme/acme_domains.rs | 35 +-
src/acme/acme_plugins.rs | 60 +-
src/acme/certificate_list.rs | 36 +-
src/apt_package_manager.rs | 45 +-
src/apt_repositories.rs | 44 +-
src/auth_view.rs | 36 +-
src/configuration/network_view.rs | 40 +-
src/configuration/pve/lxc_network_panel.rs | 31 +-
src/lib.rs | 33 +-
src/loadable_component.rs | 698 ++++++++++++---------
src/node_status_panel.rs | 37 +-
src/notes_view.rs | 16 +-
src/object_grid.rs | 36 +-
src/permission_panel.rs | 17 +-
src/subscription_panel.rs | 33 +-
src/tasks.rs | 39 +-
src/tfa/tfa_view.rs | 36 +-
src/token_panel.rs | 24 +-
src/user_panel.rs | 31 +-
21 files changed, 825 insertions(+), 556 deletions(-)
diff --git a/src/acl/acl_view.rs b/src/acl/acl_view.rs
index 58da3fd..9347d72 100644
--- a/src/acl/acl_view.rs
+++ b/src/acl/acl_view.rs
@@ -24,7 +24,9 @@ use proxmox_access_control::types::{AclListItem, AclUgidType};
use crate::percent_encoding::percent_encode_component;
use crate::utils::render_boolean;
use crate::{
- ConfirmButton, EditWindow, LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
+ impl_deref_mut_property, ConfirmButton, EditWindow, LoadableComponent,
+ LoadableComponentContext, LoadableComponentMaster, LoadableComponentScopeExt,
+ LoadableComponentState,
};
use super::acl_edit::AclEditWindow;
@@ -95,15 +97,17 @@ enum ViewState {
}
enum Msg {
- Reload,
Remove,
}
struct ProxmoxAclView {
+ state: LoadableComponentState<ViewState>,
selection: Selection,
store: Store<AclListItem>,
}
+impl_deref_mut_property!(ProxmoxAclView, state, LoadableComponentState<ViewState>);
+
impl ProxmoxAclView {
fn colmuns() -> Rc<Vec<DataTableHeader<AclListItem>>> {
Rc::new(vec![
@@ -141,14 +145,21 @@ impl LoadableComponent for ProxmoxAclView {
let link = ctx.link();
link.repeated_load(5000);
- let selection = Selection::new().on_select(link.callback(|_| Msg::Reload));
+ let selection = Selection::new().on_select({
+ let link = ctx.link().clone();
+ move |_| link.send_redraw()
+ });
let store = Store::with_extract_key(|record: &AclListItem| {
let acl_id = format!("{} for {} - {}", record.path, record.ugid, record.roleid);
Key::from(acl_id)
});
- Self { selection, store }
+ Self {
+ state: LoadableComponentState::new(),
+ selection,
+ store,
+ }
}
fn load(
@@ -203,7 +214,7 @@ impl LoadableComponent for ProxmoxAclView {
ConfirmButton::new(tr!("Remove ACL Entry"))
.confirm_message(tr!("Are you sure you want to remove this ACL entry?"))
.disabled(disabled)
- .on_activate(ctx.link().callback(|_| Msg::Remove)),
+ .on_activate(ctx.link().custom_callback(|_| Msg::Remove)),
);
Some(toolbar.into())
@@ -211,14 +222,13 @@ impl LoadableComponent for ProxmoxAclView {
fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
match msg {
- Msg::Reload => true,
Msg::Remove => {
if let Some(key) = self.selection.selected_key() {
if let Some(record) = self.store.read().lookup_record(&key).cloned() {
- let link = ctx.link();
+ let link = ctx.link().clone();
let url = ctx.props().acl_api_endpoint.to_owned();
- link.clone().spawn(async move {
+ self.spawn(async move {
let data = match record.ugid_type {
AclUgidType::User => json!({
"delete": true,
diff --git a/src/acme/acme_accounts.rs b/src/acme/acme_accounts.rs
index 19bf48b..4c4db1a 100644
--- a/src/acme/acme_accounts.rs
+++ b/src/acme/acme_accounts.rs
@@ -15,8 +15,9 @@ use crate::common_api_types::AcmeAccountInfo;
use crate::percent_encoding::percent_encode_component;
use crate::utils::render_url;
use crate::{
- ConfirmButton, DataViewWindow, LoadableComponent, LoadableComponentContext,
- LoadableComponentMaster,
+ impl_deref_mut_property, ConfirmButton, DataViewWindow, LoadableComponent,
+ LoadableComponentContext, LoadableComponentMaster, LoadableComponentScopeExt,
+ LoadableComponentState,
};
use super::AcmeRegisterAccount;
@@ -44,14 +45,17 @@ impl AcmeAccountsPanel {
#[doc(hidden)]
pub struct ProxmoxAcmeAccountsPanel {
+ state: LoadableComponentState<ViewState>,
selection: Selection,
store: Store<AcmeAccountEntry>,
columns: Rc<Vec<DataTableHeader<AcmeAccountEntry>>>,
}
-pub enum Msg {
- Redraw,
-}
+impl_deref_mut_property!(
+ ProxmoxAcmeAccountsPanel,
+ state,
+ LoadableComponentState<ViewState>
+);
#[derive(PartialEq)]
pub enum ViewState {
@@ -61,11 +65,14 @@ pub enum ViewState {
impl LoadableComponent for ProxmoxAcmeAccountsPanel {
type Properties = AcmeAccountsPanel;
- type Message = Msg;
+ type Message = ();
type ViewState = ViewState;
fn create(ctx: &LoadableComponentContext<Self>) -> Self {
- let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::Redraw));
+ let selection = Selection::new().on_select({
+ let link = ctx.link().clone();
+ move |_| link.send_redraw()
+ });
let store =
Store::with_extract_key(|record: &AcmeAccountEntry| Key::from(record.name.clone()));
@@ -77,6 +84,7 @@ impl LoadableComponent for ProxmoxAcmeAccountsPanel {
.into()]);
Self {
+ state: LoadableComponentState::new(),
selection,
store,
columns,
@@ -110,7 +118,7 @@ impl LoadableComponent for ProxmoxAcmeAccountsPanel {
Button::new(tr!("View"))
.disabled(selected_key.is_none())
.onclick({
- let link = ctx.link();
+ let link = ctx.link().clone();
let selected_key = selected_key.clone();
move |_| {
if let Some(selected_key) = &selected_key {
@@ -123,7 +131,7 @@ impl LoadableComponent for ProxmoxAcmeAccountsPanel {
ConfirmButton::remove_entry(selected_key.as_deref().unwrap_or("").to_string())
.disabled(selected_key.is_none())
.on_activate({
- let link = ctx.link();
+ let link = ctx.link().clone();
let selected_key = selected_key.clone();
move |_| {
@@ -159,7 +167,7 @@ impl LoadableComponent for ProxmoxAcmeAccountsPanel {
.selection(self.selection.clone())
.on_row_dblclick({
let selection = self.selection.clone();
- let link = ctx.link();
+ let link = ctx.link().clone();
move |_: &mut _| {
if let Some(selected_key) = selection.selected_key() {
link.change_view(Some(ViewState::View(selected_key.clone())));
diff --git a/src/acme/acme_domains.rs b/src/acme/acme_domains.rs
index 6ab924d..32003d8 100644
--- a/src/acme/acme_domains.rs
+++ b/src/acme/acme_domains.rs
@@ -19,8 +19,10 @@ use pwt_macros::builder;
use crate::common_api_types::{create_acme_config_string, parse_acme_config_string, AcmeConfig};
use crate::common_api_types::{create_acme_domain_string, parse_acme_domain_string, AcmeDomain};
use crate::percent_encoding::percent_encode_component;
-use crate::{ConfirmButton, EditWindow};
-use crate::{LoadableComponent, LoadableComponentContext, LoadableComponentMaster};
+use crate::{impl_deref_mut_property, ConfirmButton, EditWindow, LoadableComponentState};
+use crate::{
+ LoadableComponent, LoadableComponentContext, LoadableComponentMaster, LoadableComponentScopeExt,
+};
use super::{AcmeAccountSelector, AcmeChallengeTypeSelector, AcmePluginSelector};
@@ -54,14 +56,20 @@ impl AcmeDomainsPanel {
#[doc(hidden)]
pub struct ProxmoxAcmeDomainsPanel {
+ state: LoadableComponentState<ViewState>,
selection: Selection,
store: Store<AcmeDomainEntry>,
columns: Rc<Vec<DataTableHeader<AcmeDomainEntry>>>,
acme_account: Option<AcmeConfig>,
}
+impl_deref_mut_property!(
+ ProxmoxAcmeDomainsPanel,
+ state,
+ LoadableComponentState<ViewState>
+);
+
pub enum Msg {
- Redraw,
AcmeAccount(Option<AcmeConfig>),
}
@@ -78,7 +86,10 @@ impl LoadableComponent for ProxmoxAcmeDomainsPanel {
type ViewState = ViewState;
fn create(ctx: &LoadableComponentContext<Self>) -> Self {
- let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::Redraw));
+ let selection = Selection::new().on_select({
+ let link = ctx.link().clone();
+ move |_| link.send_redraw()
+ });
let store = Store::with_extract_key(|record: &AcmeDomainEntry| {
Key::from(record.config_key.clone())
});
@@ -109,6 +120,7 @@ impl LoadableComponent for ProxmoxAcmeDomainsPanel {
ctx.link().repeated_load(3000);
Self {
+ state: LoadableComponentState::new(),
selection,
store,
columns,
@@ -122,7 +134,7 @@ impl LoadableComponent for ProxmoxAcmeDomainsPanel {
) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
let store = self.store.clone();
let url = ctx.props().url.clone();
- let link = ctx.link();
+ let link = ctx.link().clone();
Box::pin(async move {
let data: Value = crate::http_get(&*url, None).await?;
@@ -147,9 +159,9 @@ impl LoadableComponent for ProxmoxAcmeDomainsPanel {
if let Some(Value::String(acme_account)) = data.get("acme") {
let acme_account = parse_acme_config_string(acme_account)?;
- link.send_message(Msg::AcmeAccount(Some(acme_account)));
+ link.send_custom_message(Msg::AcmeAccount(Some(acme_account)));
} else {
- link.send_message(Msg::AcmeAccount(None));
+ link.send_custom_message(Msg::AcmeAccount(None));
}
Ok(())
})
@@ -157,7 +169,6 @@ impl LoadableComponent for ProxmoxAcmeDomainsPanel {
fn update(&mut self, _ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
match msg {
- Msg::Redraw => true,
Msg::AcmeAccount(acme_account) => {
self.acme_account = acme_account;
true
@@ -179,7 +190,7 @@ impl LoadableComponent for ProxmoxAcmeDomainsPanel {
Button::new(tr!("Edit"))
.disabled(selected_key.is_none())
.onclick({
- let link = ctx.link();
+ let link = ctx.link().clone();
let selected_key = selected_key.clone();
move |_| {
if let Some(selected_key) = &selected_key {
@@ -192,7 +203,7 @@ impl LoadableComponent for ProxmoxAcmeDomainsPanel {
ConfirmButton::remove_entry(selected_key.as_deref().unwrap_or("").to_string())
.disabled(selected_key.is_none())
.on_activate({
- let link = ctx.link();
+ let link = ctx.link().clone();
let url = ctx.props().url.clone();
let selected_key = selected_key.clone();
move |_| {
@@ -238,7 +249,7 @@ impl LoadableComponent for ProxmoxAcmeDomainsPanel {
</div>}
})
.with_child(Button::new(tr!("Order Certificate Now")).onclick({
- let link = ctx.link();
+ let link = ctx.link().clone();
move |_| {
let command_path = "/nodes/localhost/certificates/acme/certificate";
link.start_task(command_path, None, false);
@@ -253,7 +264,7 @@ impl LoadableComponent for ProxmoxAcmeDomainsPanel {
.class("pwt-flex-fit")
.selection(self.selection.clone())
.on_row_dblclick({
- let link = ctx.link();
+ let link = ctx.link().clone();
move |event: &mut DataTableMouseEvent| {
let key = &event.record_key;
link.change_view(Some(ViewState::Edit(key.clone())));
diff --git a/src/acme/acme_plugins.rs b/src/acme/acme_plugins.rs
index f0b1e69..66baa5f 100644
--- a/src/acme/acme_plugins.rs
+++ b/src/acme/acme_plugins.rs
@@ -9,19 +9,20 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use yew::virtual_dom::{Key, VComp, VNode};
+use pwt::prelude::*;
use pwt::state::{Selection, Store};
use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader, DataTableMouseEvent};
use pwt::widget::form::{DisplayField, Field, FormContext, Number, TextArea};
use pwt::widget::{Button, InputPanel, Toolbar};
-use pwt::{prelude::*, AsyncPool};
use pwt_macros::builder;
use crate::form::delete_empty_values;
use crate::percent_encoding::percent_encode_component;
use crate::{
- http_get, ConfirmButton, EditWindow, LoadableComponent, LoadableComponentContext,
- LoadableComponentLink, LoadableComponentMaster,
+ http_get, impl_deref_mut_property, ConfirmButton, EditWindow, LoadableComponent,
+ LoadableComponentContext, LoadableComponentMaster, LoadableComponentScope,
+ LoadableComponentScopeExt, LoadableComponentState,
};
use super::{AcmeChallengeSchemaItem, AcmeChallengeSelector};
@@ -81,14 +82,20 @@ struct ChallengeSchemaInfo {
#[doc(hidden)]
pub struct ProxmoxAcmePluginsPanel {
+ state: LoadableComponentState<ViewState>,
selection: Selection,
store: Store<PluginConfig>,
columns: Rc<Vec<DataTableHeader<PluginConfig>>>,
challenge_schema: Option<AcmeChallengeSchemaItem>,
schema_info: ChallengeSchemaInfo,
- async_pool: AsyncPool,
}
+impl_deref_mut_property!(
+ ProxmoxAcmePluginsPanel,
+ state,
+ LoadableComponentState<ViewState>
+);
+
#[derive(PartialEq)]
pub enum ViewState {
Add,
@@ -96,7 +103,6 @@ pub enum ViewState {
}
pub enum Msg {
- Redraw,
CloseDialog,
Add,
Edit(Key),
@@ -126,16 +132,20 @@ impl LoadableComponent for ProxmoxAcmePluginsPanel {
type ViewState = ViewState;
fn create(ctx: &LoadableComponentContext<Self>) -> Self {
- let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::Redraw));
+ let selection = Selection::new().on_select({
+ let link = ctx.link().clone();
+ move |_| link.send_redraw()
+ });
let store =
Store::with_extract_key(|record: &PluginConfig| Key::from(record.plugin.clone()));
let schema_name_map = Rc::new(HashMap::new());
let columns = columns(schema_name_map.clone());
- ctx.link().send_message(Msg::LoadChallengeSchemaList);
+ ctx.link().send_custom_message(Msg::LoadChallengeSchemaList);
Self {
+ state: LoadableComponentState::new(),
selection,
store,
columns,
@@ -144,7 +154,6 @@ impl LoadableComponent for ProxmoxAcmePluginsPanel {
schema_name_map,
store: Store::new(),
},
- async_pool: AsyncPool::new(),
}
}
@@ -164,7 +173,6 @@ impl LoadableComponent for ProxmoxAcmePluginsPanel {
fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
match msg {
- Msg::Redraw => true,
Msg::Add => {
self.challenge_schema = None;
ctx.link().change_view(Some(ViewState::Add));
@@ -217,10 +225,10 @@ impl LoadableComponent for ProxmoxAcmePluginsPanel {
}
Msg::LoadChallengeSchemaList => {
let url = ctx.props().challenge_shema_url.clone();
- let link = ctx.link();
- self.async_pool.spawn(async move {
+ let link = ctx.link().clone();
+ self.spawn(async move {
let result = http_get(&*url, None).await;
- link.send_message(Msg::UpdateChallengeSchemaList(result));
+ link.send_custom_message(Msg::UpdateChallengeSchemaList(result));
});
false
}
@@ -241,7 +249,7 @@ impl LoadableComponent for ProxmoxAcmePluginsPanel {
);
let command_future = crate::http_delete(command_path, None);
let link = ctx.link().clone();
- self.async_pool.spawn(async move {
+ self.spawn(async move {
match command_future.await {
Ok(()) => {
link.send_reload();
@@ -265,16 +273,16 @@ impl LoadableComponent for ProxmoxAcmePluginsPanel {
.class("pwt-w-100")
.class("pwt-overflow-hidden")
.class("pwt-border-bottom")
- .with_child(Button::new(tr!("Add")).onclick(ctx.link().callback(|_| Msg::Add)))
+ .with_child(Button::new(tr!("Add")).onclick(ctx.link().custom_callback(|_| Msg::Add)))
.with_child(
Button::new(tr!("Edit"))
.disabled(selected_key.is_none())
.onclick({
- let link = ctx.link();
+ let link = ctx.link().clone();
let selected_key = selected_key.clone();
move |_| {
if let Some(selected_key) = &selected_key {
- link.send_message(Msg::Edit(selected_key.clone()));
+ link.send_custom_message(Msg::Edit(selected_key.clone()));
}
}
}),
@@ -282,7 +290,7 @@ impl LoadableComponent for ProxmoxAcmePluginsPanel {
.with_child(
ConfirmButton::remove_entry(selected_key.as_deref().unwrap_or("").to_string())
.disabled(selected_key.is_none())
- .on_activate(ctx.link().callback({
+ .on_activate(ctx.link().custom_callback({
let selected_key = selected_key.clone();
move |_| Msg::Delete(selected_key.clone())
})),
@@ -297,11 +305,11 @@ impl LoadableComponent for ProxmoxAcmePluginsPanel {
.selection(self.selection.clone())
.on_row_dblclick({
let store = self.store.clone();
- let link = ctx.link();
+ let link = ctx.link().clone();
move |event: &mut DataTableMouseEvent| {
let key = &event.record_key;
if store.read().lookup_record(key).is_some() {
- link.send_message(Msg::Edit(key.clone()));
+ link.send_custom_message(Msg::Edit(key.clone()));
};
}
})
@@ -372,7 +380,7 @@ impl ProxmoxAcmePluginsPanel {
}
fn dns_plugin_input_panel(
- link: &LoadableComponentLink<Self>,
+ link: &LoadableComponentScope<Self>,
form_ctx: &FormContext,
id: Option<&str>,
challenge_schema: Option<&AcmeChallengeSchemaItem>,
@@ -411,7 +419,7 @@ impl ProxmoxAcmePluginsPanel {
AcmeChallengeSelector::with_store(challenge_store)
.name("api")
.required(true)
- .on_change(link.callback(Msg::ChallengeSchema)),
+ .on_change(link.custom_callback(Msg::ChallengeSchema)),
);
if let Some(description) =
@@ -463,7 +471,7 @@ impl ProxmoxAcmePluginsPanel {
.on_change({
let field_name = field_name.clone();
let form_ctx = form_ctx.clone();
- link.callback(move |v| {
+ link.custom_callback(move |v| {
Msg::ApiDataChange(form_ctx.clone(), field_name.clone(), v)
})
}),
@@ -495,10 +503,10 @@ impl ProxmoxAcmePluginsPanel {
move |url: AttrValue| crate::http_get_full(url.to_string(), None),
url.clone(),
))
- .on_done(ctx.link().callback(|_| Msg::CloseDialog))
+ .on_done(ctx.link().custom_callback(|_| Msg::CloseDialog))
.renderer({
let id = id.to_owned();
- let link = ctx.link();
+ let link = ctx.link().clone();
let challenge_schema = self.challenge_schema.clone();
let challenge_store = self.schema_info.store.clone();
move |form_ctx: &FormContext| {
@@ -532,9 +540,9 @@ impl ProxmoxAcmePluginsPanel {
fn create_add_dns_plugin_dialog(&self, ctx: &crate::LoadableComponentContext<Self>) -> Html {
EditWindow::new(tr!("Add") + ": " + &tr!("ACME DNS Plugin"))
- .on_done(ctx.link().callback(|_| Msg::CloseDialog))
+ .on_done(ctx.link().custom_callback(|_| Msg::CloseDialog))
.renderer({
- let link = ctx.link();
+ let link = ctx.link().clone();
let challenge_schema = self.challenge_schema.clone();
let challenge_store = self.schema_info.store.clone();
move |form_ctx: &FormContext| {
diff --git a/src/acme/certificate_list.rs b/src/acme/certificate_list.rs
index d9ce92c..7ac20e6 100644
--- a/src/acme/certificate_list.rs
+++ b/src/acme/certificate_list.rs
@@ -18,8 +18,9 @@ use pwt::widget::{Button, Container, Dialog, FileButton, MessageBox, Toolbar};
use crate::common_api_types::CertificateInfo;
use crate::utils::render_epoch;
use crate::{
- ConfirmButton, EditWindow, KVGrid, KVGridRow, LoadableComponent, LoadableComponentContext,
- LoadableComponentMaster,
+ impl_deref_mut_property, ConfirmButton, EditWindow, KVGrid, KVGridRow, LoadableComponent,
+ LoadableComponentContext, LoadableComponentMaster, LoadableComponentScopeExt,
+ LoadableComponentState,
};
async fn upload_custom_certificate(form_ctx: FormContext) -> Result<(), Error> {
@@ -46,10 +47,6 @@ impl CertificateList {
}
}
-pub enum Msg {
- Redraw,
-}
-
#[derive(PartialEq)]
pub enum ViewState {
CertificateView(Rc<Value>),
@@ -59,24 +56,35 @@ pub enum ViewState {
#[doc(hidden)]
pub struct ProxmoxCertificateList {
+ state: LoadableComponentState<ViewState>,
selection: Selection,
store: Store<CertificateInfo>,
columns: Rc<Vec<DataTableHeader<CertificateInfo>>>,
rows: Rc<Vec<KVGridRow>>,
}
+impl_deref_mut_property!(
+ ProxmoxCertificateList,
+ state,
+ LoadableComponentState<ViewState>
+);
+
impl LoadableComponent for ProxmoxCertificateList {
type Properties = CertificateList;
- type Message = Msg;
+ type Message = ();
type ViewState = ViewState;
fn create(ctx: &LoadableComponentContext<Self>) -> Self {
- let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::Redraw));
+ let selection = Selection::new().on_select({
+ let link = ctx.link().clone();
+ move |_| link.send_redraw()
+ });
let store =
Store::with_extract_key(|record: &CertificateInfo| Key::from(record.filename.clone()));
let columns = Rc::new(columns());
let rows = Rc::new(rows());
Self {
+ state: LoadableComponentState::new(),
selection,
store,
columns,
@@ -121,7 +129,7 @@ impl LoadableComponent for ProxmoxCertificateList {
"proxy.pem"
))
.on_activate({
- let link = ctx.link();
+ let link = ctx.link().clone();
move |_| {
let link = link.clone();
let command_path = "/nodes/localhost/certificates/custom".to_string();
@@ -145,7 +153,7 @@ impl LoadableComponent for ProxmoxCertificateList {
.disabled(selected_cert.is_none())
.onclick({
let selected_cert = selected_cert.clone();
- let link = ctx.link();
+ let link = ctx.link().clone();
move |_| {
if let Some(selected_cert) = &selected_cert {
let cert_data: Value =
@@ -167,7 +175,7 @@ impl LoadableComponent for ProxmoxCertificateList {
.selection(self.selection.clone())
.on_row_dblclick({
let store = self.store.clone();
- let link = ctx.link();
+ let link = ctx.link().clone();
move |event: &mut DataTableMouseEvent| {
let key = &event.record_key;
if let Some(selected_cert) = store.read().lookup_record(key).cloned() {
@@ -226,10 +234,10 @@ async fn update_field_from_file(
impl ProxmoxCertificateList {
fn create_upload_custom_certificate(&self, ctx: &LoadableComponentContext<Self>) -> Html {
- let link = ctx.link();
+ let link = ctx.link().clone();
EditWindow::new(tr!("Upload Custom Certificate"))
.width(600)
- .on_close(ctx.link().change_view_callback(|_| None))
+ .on_close(link.change_view_callback(|_| None))
.submit_text(tr!("Upload"))
.renderer(move |form_ctx: &FormContext| {
Form::new()
@@ -287,7 +295,7 @@ impl ProxmoxCertificateList {
.into()
})
.on_submit({
- let link = ctx.link();
+ let link = ctx.link().clone();
move |form_ctx: FormContext| {
let link = link.clone();
async move {
diff --git a/src/apt_package_manager.rs b/src/apt_package_manager.rs
index 82d536f..7264dae 100644
--- a/src/apt_package_manager.rs
+++ b/src/apt_package_manager.rs
@@ -18,13 +18,14 @@ use pwt::widget::data_table::{
DataTable, DataTableCellRenderArgs, DataTableColumn, DataTableHeader, DataTableHeaderGroup,
};
use pwt::widget::{AlertDialog, Button, Container, Toolbar, Tooltip};
-use pwt::AsyncPool;
use crate::percent_encoding::percent_encode_component;
use crate::subscription_alert::subscription_is_active;
+use crate::LoadableComponentState;
use crate::SubscriptionAlert;
use crate::{
- DataViewWindow, LoadableComponent, LoadableComponentContext, LoadableComponentMaster, XTermJs,
+ DataViewWindow, LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
+ LoadableComponentScopeExt, XTermJs,
};
use proxmox_apt_api_types::APTUpdateInfo;
@@ -163,31 +164,43 @@ pub enum ViewState {
/// Messages for [`ProxmoxAptManager::update`]
pub enum Msg {
CheckSubscription,
- SelectionChange,
}
pub struct ProxmoxAptPackageManager {
+ state: LoadableComponentState<ViewState>,
tree_store: TreeStore<TreeEntry>,
selection: Selection,
columns: Rc<Vec<DataTableHeader<TreeEntry>>>,
- async_pool: AsyncPool,
}
+crate::impl_deref_mut_property!(
+ ProxmoxAptPackageManager,
+ state,
+ LoadableComponentState<ViewState>
+);
+
impl LoadableComponent for ProxmoxAptPackageManager {
type Properties = AptPackageManager;
type Message = Msg;
type ViewState = ViewState;
fn create(ctx: &LoadableComponentContext<Self>) -> Self {
+ let props = ctx.props();
let tree_store = TreeStore::new().view_root(false);
let columns = Self::columns(ctx, tree_store.clone());
- let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::SelectionChange));
+ let selection = Selection::new().on_select({
+ let link = ctx.link().clone();
+ move |_| link.send_redraw()
+ });
+
+ let mut state = LoadableComponentState::new();
+ state.set_task_base_url(props.task_base_url.clone());
Self {
+ state,
tree_store,
selection,
columns,
- async_pool: AsyncPool::new(),
}
}
@@ -200,14 +213,12 @@ impl LoadableComponent for ProxmoxAptPackageManager {
.clone()
.subscription_url
.unwrap_or("/nodes/localhost/subscription".into());
- let task_base_url = props.task_base_url.clone();
let command = format!("{}/update", props.base_url);
- self.async_pool.spawn(async move {
+ self.spawn(async move {
let data = crate::http_get::<Value>(url.as_str(), None).await;
let is_active = subscription_is_active(Some(&data));
if is_active {
- link.task_base_url(task_base_url);
link.start_task(&command, None, false);
} else {
link.change_view(Some(ViewState::ShowSubscriptionPopup));
@@ -215,7 +226,6 @@ impl LoadableComponent for ProxmoxAptPackageManager {
});
true
}
- Msg::SelectionChange => true,
}
}
@@ -264,15 +274,14 @@ impl LoadableComponent for ProxmoxAptPackageManager {
.class("pwt-overflow-hidden")
.class("pwt-border-bottom")
.with_child(Button::new(tr!("Refresh")).on_activate({
- let link = ctx.link();
+ let link = ctx.link().clone();
let sub_check = props.subscription_url.is_some();
- link.task_base_url(props.task_base_url.clone());
let command = format!("{}/update", props.base_url);
move |_| {
if sub_check {
- link.send_message(Msg::CheckSubscription);
+ link.send_custom_message(Msg::CheckSubscription);
} else {
link.start_task(&command, None, false);
}
@@ -287,7 +296,7 @@ impl LoadableComponent for ProxmoxAptPackageManager {
Button::new(tr!("Changelog"))
.disabled(selected_package.is_none())
.onclick({
- let link = ctx.link();
+ let link = ctx.link().clone();
let view = selected_package
.as_ref()
.map(|p| ViewState::ShowChangelog(p.clone()));
@@ -296,8 +305,8 @@ impl LoadableComponent for ProxmoxAptPackageManager {
)
.with_flex_spacer()
.with_child({
- let loading = ctx.loading();
- let link = ctx.link();
+ let loading = self.loading();
+ let link = ctx.link().clone();
Button::refresh(loading).onclick(move |_| link.send_reload())
});
@@ -325,11 +334,9 @@ impl LoadableComponent for ProxmoxAptPackageManager {
ViewState::ShowSubscriptionPopup => {
let link = ctx.link().clone();
let props = ctx.props();
- let task_base_url = props.task_base_url.clone();
let command = format!("{}/update", props.base_url);
let on_close = move |_| {
link.change_view(None);
- link.task_base_url(task_base_url.clone());
link.start_task(&command, None, false);
};
Some(if let Some(msg) = props.subscription_message.clone() {
@@ -351,6 +358,8 @@ impl LoadableComponent for ProxmoxAptPackageManager {
) -> bool {
let props = ctx.props();
+ self.set_task_base_url(props.task_base_url.clone());
+
if props.base_url != old_props.base_url || props.task_base_url != old_props.task_base_url {
ctx.link().send_reload();
true
diff --git a/src/apt_repositories.rs b/src/apt_repositories.rs
index 7157c0c..904c052 100644
--- a/src/apt_repositories.rs
+++ b/src/apt_repositories.rs
@@ -22,7 +22,8 @@ use pwt::widget::{Button, Column, Container, Fa, Row, Toolbar, Tooltip};
use crate::subscription_alert::subscription_is_active;
use crate::{
EditWindow, ExistingProduct, LoadableComponent, LoadableComponentContext,
- LoadableComponentMaster, ProjectInfo, SubscriptionAlert,
+ LoadableComponentMaster, LoadableComponentScopeExt, LoadableComponentState, ProjectInfo,
+ SubscriptionAlert,
};
use pwt_macros::builder;
@@ -406,7 +407,6 @@ fn apt_configuration_to_tree(config: &APTRepositoriesResult) -> SlabTree<TreeEnt
}
pub enum Msg {
- Refresh,
ToggleEnable,
UpdateStatus(APTRepositoriesResult),
SubscriptionInfo(Result<Value, Error>),
@@ -419,6 +419,7 @@ pub enum ViewState {
}
pub struct ProxmoxAptRepositories {
+ state: LoadableComponentState<ViewState>,
tree_store: TreeStore<TreeEntry>,
selection: Selection,
columns: Rc<Vec<DataTableHeader<TreeEntry>>>,
@@ -430,6 +431,12 @@ pub struct ProxmoxAptRepositories {
status_columns: Rc<Vec<DataTableHeader<StatusLine>>>,
}
+crate::impl_deref_mut_property!(
+ ProxmoxAptRepositories,
+ state,
+ LoadableComponentState<ViewState>
+);
+
impl LoadableComponent for ProxmoxAptRepositories {
type Properties = AptRepositories;
type Message = Msg;
@@ -438,19 +445,27 @@ impl LoadableComponent for ProxmoxAptRepositories {
fn create(ctx: &LoadableComponentContext<Self>) -> Self {
let tree_store = TreeStore::new().view_root(false);
let columns = Self::columns(ctx, tree_store.clone());
- let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::Refresh));
+ let selection = Selection::new().on_select({
+ let link = ctx.link().clone();
+ move |_| link.send_redraw()
+ });
let status_columns = Self::status_columns(ctx);
let subscription_url = ctx.props().subscription_url.clone();
- let link = ctx.link();
- link.send_future(async move {
- // TODO: also reload this in load, not only create?!
- let data = crate::http_get(subscription_url.to_string(), None).await;
- Msg::SubscriptionInfo(data)
+ let state = LoadableComponentState::new();
+
+ state.spawn({
+ let link = ctx.link().clone();
+ async move {
+ // TODO: also reload this in load, not only create?!
+ let data = crate::http_get(subscription_url.to_string(), None).await;
+ link.send_custom_message(Msg::SubscriptionInfo(data));
+ }
});
Self {
+ state,
tree_store,
selection,
columns,
@@ -470,13 +485,13 @@ impl LoadableComponent for ProxmoxAptRepositories {
let props = ctx.props();
let base_url = props.base_url.clone();
let tree_store = self.tree_store.clone();
- let link = ctx.link();
+ let link = ctx.link().clone();
Box::pin(async move {
let config = apt_configuration(base_url.clone()).await?;
let tree = apt_configuration_to_tree(&config);
tree_store.write().update_root_tree(tree);
- link.send_message(Msg::UpdateStatus(config));
+ link.send_custom_message(Msg::UpdateStatus(config));
Ok(())
})
}
@@ -484,7 +499,6 @@ impl LoadableComponent for ProxmoxAptRepositories {
fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
let props = ctx.props();
match msg {
- Msg::Refresh => true,
Msg::SubscriptionInfo(status) => {
self.subscription_status = Some(status);
if let Some(config) = &self.config {
@@ -547,7 +561,7 @@ impl LoadableComponent for ProxmoxAptRepositories {
});
// fixme: add digest to protect against concurrent changes
let url = format!("{}/repositories", props.base_url);
- let link = ctx.link();
+ let link = ctx.link().clone();
link.clone().spawn(async move {
match crate::http_post(url, Some(param)).await {
Ok(()) => {
@@ -606,12 +620,12 @@ impl LoadableComponent for ProxmoxAptRepositories {
tr!("Enable")
})
.disabled(enabled.is_none())
- .onclick(ctx.link().callback(|_| Msg::ToggleEnable))
+ .onclick(ctx.link().custom_callback(|_| Msg::ToggleEnable))
})
.with_flex_spacer()
.with_child({
- let loading = ctx.loading();
- let link = ctx.link();
+ let loading = self.loading();
+ let link = ctx.link().clone();
Button::refresh(loading).onclick(move |_| link.send_reload())
});
diff --git a/src/auth_view.rs b/src/auth_view.rs
index d6e5528..bb3960b 100644
--- a/src/auth_view.rs
+++ b/src/auth_view.rs
@@ -21,7 +21,8 @@ use pwt_macros::builder;
use crate::{
AuthEditLDAP, AuthEditOpenID, EditWindow, LoadableComponent, LoadableComponentContext,
- LoadableComponentLink, LoadableComponentMaster,
+ LoadableComponentMaster, LoadableComponentScope, LoadableComponentScopeExt,
+ LoadableComponentState,
};
use crate::common_api_types::BasicRealmInfo;
@@ -76,17 +77,20 @@ pub enum ViewState {
}
pub enum Msg {
- Redraw,
Edit,
Remove,
Sync,
}
+
#[doc(hidden)]
pub struct ProxmoxAuthView {
+ state: LoadableComponentState<ViewState>,
selection: Selection,
store: Store<BasicRealmInfo>,
}
+crate::impl_deref_mut_property!(ProxmoxAuthView, state, LoadableComponentState<ViewState>);
+
async fn delete_item(base_url: AttrValue, realm: AttrValue) -> Result<(), Error> {
let url = format!("{base_url}/{}", percent_encode_component(&realm));
crate::http_delete(&url, None).await?;
@@ -95,7 +99,7 @@ async fn delete_item(base_url: AttrValue, realm: AttrValue) -> Result<(), Error>
async fn sync_realm(
form_ctx: FormContext,
- link: LoadableComponentLink<ProxmoxAuthView>,
+ link: LoadableComponentScope<ProxmoxAuthView>,
url: impl Into<String>,
) -> Result<(), Error> {
let mut data = form_ctx.get_submit_data();
@@ -178,8 +182,15 @@ impl LoadableComponent for ProxmoxAuthView {
fn create(ctx: &LoadableComponentContext<Self>) -> Self {
let store = Store::new();
- let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::Redraw));
- Self { store, selection }
+ let selection = Selection::new().on_select({
+ let link = ctx.link().clone();
+ move |_| link.send_redraw()
+ });
+ Self {
+ state: LoadableComponentState::new(),
+ store,
+ selection,
+ }
}
fn load(
@@ -201,7 +212,6 @@ impl LoadableComponent for ProxmoxAuthView {
let props = ctx.props();
match msg {
- Msg::Redraw => true,
Msg::Remove => {
let Some(info) = self.get_selected_record() else {
return true;
@@ -218,7 +228,7 @@ impl LoadableComponent for ProxmoxAuthView {
return true;
};
- let link = ctx.link();
+ let link = ctx.link().clone();
link.clone().spawn(async move {
if let Err(err) = delete_item(base_url, info.realm.into()).await {
link.show_error(tr!("Unable to delete item"), err, true);
@@ -319,17 +329,17 @@ impl LoadableComponent for ProxmoxAuthView {
.with_child(
Button::new(tr!("Edit"))
.disabled(edit_disabled)
- .onclick(ctx.link().callback(|_| Msg::Edit)),
+ .onclick(ctx.link().custom_callback(|_| Msg::Edit)),
)
.with_child(
Button::new(tr!("Remove"))
.disabled(remove_disabled)
- .onclick(ctx.link().callback(|_| Msg::Remove)),
+ .onclick(ctx.link().custom_callback(|_| Msg::Remove)),
)
.with_child(
Button::new(tr!("Sync"))
.disabled(sync_disabled)
- .onclick(ctx.link().callback(|_| Msg::Sync)),
+ .onclick(ctx.link().custom_callback(|_| Msg::Sync)),
);
Some(toolbar.into())
@@ -341,8 +351,8 @@ impl LoadableComponent for ProxmoxAuthView {
.selection(self.selection.clone())
.class("pwt-flex-fit")
.on_row_dblclick({
- let link = ctx.link();
- move |_: &mut _| link.send_message(Msg::Edit)
+ let link = ctx.link().clone();
+ move |_: &mut _| link.send_custom_message(Msg::Edit)
})
.into()
}
@@ -398,7 +408,7 @@ impl LoadableComponent for ProxmoxAuthView {
.into(),
),
ViewState::Sync(realm) => {
- let link = ctx.link();
+ let link = ctx.link().clone();
let url = format!(
"{}/{}/sync",
ctx.props().base_url,
diff --git a/src/configuration/network_view.rs b/src/configuration/network_view.rs
index d7d519c..62b5e2e 100644
--- a/src/configuration/network_view.rs
+++ b/src/configuration/network_view.rs
@@ -12,7 +12,10 @@ use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader};
use pwt::widget::menu::{Menu, MenuButton, MenuItem};
use pwt::widget::{Button, Column, Container, SplitPane, Toolbar};
-use crate::{LoadableComponent, LoadableComponentContext, LoadableComponentMaster, TaskProgress};
+use crate::{
+ LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
+ LoadableComponentScopeExt, LoadableComponentState, TaskProgress,
+};
use proxmox_client::ApiResponseData;
use crate::percent_encoding::percent_encode_component;
@@ -66,12 +69,15 @@ impl NetworkView {
#[doc(hidden)]
pub struct ProxmoxNetworkView {
+ state: LoadableComponentState<ViewState>,
columns: Rc<Vec<DataTableHeader<Interface>>>,
store: Store<Interface>,
changes: String,
selection: Selection,
}
+crate::impl_deref_mut_property!(ProxmoxNetworkView, state, LoadableComponentState<ViewState>);
+
#[derive(PartialEq)]
pub enum ViewState {
AddBridge,
@@ -81,7 +87,6 @@ pub enum ViewState {
}
pub enum Msg {
- SelectionChange,
RemoveItem,
Changes(String),
RevertChanges,
@@ -119,19 +124,23 @@ impl LoadableComponent for ProxmoxNetworkView {
ctx: &LoadableComponentContext<Self>,
) -> Pin<Box<dyn Future<Output = Result<(), Error>>>> {
let store = self.store.clone();
- let link = ctx.link();
+ let link = ctx.link().clone();
Box::pin(async move {
let (data, changes) = load_interfaces().await?;
store.write().set_data(data);
- link.send_message(Msg::Changes(changes));
+ link.send_custom_message(Msg::Changes(changes));
Ok(())
})
}
fn create(ctx: &LoadableComponentContext<Self>) -> Self {
let store = Store::with_extract_key(|record: &Interface| Key::from(record.name.as_str()));
- let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::SelectionChange));
+ let selection = Selection::new().on_select({
+ let link = ctx.link().clone();
+ move |_| link.send_redraw()
+ });
Self {
+ state: LoadableComponentState::new(),
store,
selection,
changes: String::new(),
@@ -141,10 +150,9 @@ impl LoadableComponent for ProxmoxNetworkView {
fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
match msg {
- Msg::SelectionChange => true,
Msg::RemoveItem => {
if let Some(key) = self.selection.selected_key() {
- let link = ctx.link();
+ let link = ctx.link().clone();
link.clone().spawn(async move {
if let Err(err) = delete_interface(key).await {
link.show_error(tr!("Unable to delete item"), err, true);
@@ -159,8 +167,8 @@ impl LoadableComponent for ProxmoxNetworkView {
true
}
Msg::RevertChanges => {
- let link = ctx.link();
- link.clone().spawn(async move {
+ let link = ctx.link().clone();
+ self.spawn(async move {
if let Err(err) = revert_changes().await {
link.show_error(tr!("Unable to revert changes"), err, true);
}
@@ -169,7 +177,7 @@ impl LoadableComponent for ProxmoxNetworkView {
false
}
Msg::ApplyChanges => {
- let link = ctx.link();
+ let link = ctx.link().clone();
link.clone().spawn(async move {
match apply_changes().await {
Err(err) => {
@@ -219,7 +227,7 @@ impl LoadableComponent for ProxmoxNetworkView {
.with_child(
Button::new(tr!("Revert"))
.disabled(no_changes)
- .onclick(link.callback(|_| Msg::RevertChanges)),
+ .onclick(link.custom_callback(|_| Msg::RevertChanges)),
)
.with_child(
Button::new(tr!("Edit"))
@@ -229,18 +237,18 @@ impl LoadableComponent for ProxmoxNetworkView {
.with_child(
Button::new(tr!("Remove"))
.disabled(disabled)
- .onclick(link.callback(|_| Msg::RemoveItem)),
+ .onclick(link.custom_callback(|_| Msg::RemoveItem)),
)
.with_spacer()
.with_child(
Button::new(tr!("Apply Configuration"))
.disabled(no_changes)
- .onclick(link.callback(|_| Msg::ApplyChanges)),
+ .onclick(link.custom_callback(|_| Msg::ApplyChanges)),
)
.with_flex_spacer()
.with_child({
- let loading = ctx.loading();
- let link = ctx.link();
+ let loading = self.loading();
+ let link = ctx.link().clone();
Button::refresh(loading).onclick(move |_| link.send_reload())
});
@@ -248,7 +256,7 @@ impl LoadableComponent for ProxmoxNetworkView {
}
fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
- let link = ctx.link();
+ let link = ctx.link().clone();
let table = DataTable::new(Rc::clone(&self.columns), self.store.clone())
.class("pwt-flex-fit")
diff --git a/src/configuration/pve/lxc_network_panel.rs b/src/configuration/pve/lxc_network_panel.rs
index 953edba..3527b1e 100644
--- a/src/configuration/pve/lxc_network_panel.rs
+++ b/src/configuration/pve/lxc_network_panel.rs
@@ -23,9 +23,12 @@ use crate::form::pve::lxc_network_property;
use crate::form::typed_load;
use crate::{
configuration::guest_config_url, form::pve::PveGuestType, LoadableComponent,
- LoadableComponentContext,
+ LoadableComponentContext, LoadableComponentScopeExt,
+};
+use crate::{
+ http_put, impl_deref_mut_property, ConfirmButton, LoadableComponentMaster,
+ LoadableComponentState, PropertyEditDialog,
};
-use crate::{http_put, ConfirmButton, LoadableComponentMaster, PropertyEditDialog};
#[derive(Clone, PartialEq, Properties)]
#[builder]
@@ -82,21 +85,23 @@ pub enum ViewState {
}
pub enum Msg {
- Redraw,
SelectionChange,
Remove(Key),
}
pub struct LxcNetworkComp {
+ state: LoadableComponentState<ViewState>,
columns: Rc<Vec<DataTableHeader<NetworkEntry>>>,
store: Store<NetworkEntry>,
selection: Selection,
}
+impl_deref_mut_property!(LxcNetworkComp, state, LoadableComponentState<ViewState>);
+
impl LxcNetworkComp {
fn edit_dialog(&self, ctx: &LoadableComponentContext<Self>, name: Option<String>) -> Html {
let props = ctx.props();
- let link = ctx.link();
+ let link = ctx.link().clone();
let property = lxc_network_property(
Some(props.node.clone()),
@@ -156,12 +161,17 @@ impl LoadableComponent for LxcNetworkComp {
}
fn create(ctx: &LoadableComponentContext<Self>) -> Self {
- let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::SelectionChange));
- let store = Store::new().on_change(ctx.link().callback(|_| Msg::Redraw));
+ let selection =
+ Selection::new().on_select(ctx.link().custom_callback(|_| Msg::SelectionChange));
+ let store = Store::new().on_change({
+ let link = ctx.link().clone();
+ move |_| link.send_redraw()
+ });
ctx.link().repeated_load(3000);
Self {
+ state: LoadableComponentState::new(),
store,
selection,
columns: columns(),
@@ -170,15 +180,14 @@ impl LoadableComponent for LxcNetworkComp {
fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
let props = ctx.props();
- let link = ctx.link();
+ let link = ctx.link().clone();
match msg {
Msg::SelectionChange => true,
- Msg::Redraw => true,
Msg::Remove(key) => {
let url =
guest_config_url(props.vmid, &props.node, &props.remote, PveGuestType::Lxc);
- link.clone().spawn(async move {
+ self.spawn(async move {
let param = json!({ "delete": [ key.to_string() ]});
let result: Result<(), _> = crate::http_put(&url, Some(param)).await;
if let Err(err) = result {
@@ -238,7 +247,7 @@ impl LoadableComponent for LxcNetworkComp {
let key = selected_key.clone();
move |_| {
if let Some(key) = &key {
- link.send_message(Msg::Remove(key.clone()))
+ link.send_custom_message(Msg::Remove(key.clone()))
}
}
})
@@ -259,7 +268,7 @@ impl LoadableComponent for LxcNetworkComp {
fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
let props = ctx.props();
let readonly = props.readonly;
- let link = ctx.link();
+ let link = ctx.link().clone();
if props.mobile {
let mut tiles = Vec::new();
diff --git a/src/lib.rs b/src/lib.rs
index 6df8a32..f4761a2 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -88,7 +88,8 @@ pub mod layout;
mod loadable_component;
pub use loadable_component::{
- LoadableComponent, LoadableComponentContext, LoadableComponentLink, LoadableComponentMaster,
+ LoadableComponent, LoadableComponentContext, LoadableComponentMaster, LoadableComponentScope,
+ LoadableComponentScopeExt, LoadableComponentState,
};
mod node_info;
@@ -336,3 +337,33 @@ pub fn available_language_list() -> Vec<LanguageInfo> {
),
]
}
+
+#[cfg(doc)]
+use std::ops::{Deref, DerefMut};
+
+/// Implement [Deref] to a structure member.
+#[macro_export]
+macro_rules! impl_deref_property {
+ ($ty:ty, $property_name:ident, $property_type:ty) => {
+ impl std::ops::Deref for $ty {
+ type Target = $property_type;
+
+ fn deref(&self) -> &Self::Target {
+ &self.$property_name
+ }
+ }
+ };
+}
+
+/// Implement [DerefMut] to a structure member.
+#[macro_export]
+macro_rules! impl_deref_mut_property {
+ ($ty:ty, $property_name:ident, $property_type:ty) => {
+ $crate::impl_deref_property!($ty, $property_name, $property_type);
+ impl std::ops::DerefMut for $ty {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ &mut self.$property_name
+ }
+ }
+ };
+}
diff --git a/src/loadable_component.rs b/src/loadable_component.rs
index f0e28a9..43cb7a9 100644
--- a/src/loadable_component.rs
+++ b/src/loadable_component.rs
@@ -1,163 +1,313 @@
-use anyhow::Error;
-use serde_json::Value;
use std::future::Future;
+use std::ops::DerefMut;
use std::pin::Pin;
-use yew_router::scope_ext::RouterScopeExt;
+use anyhow::Error;
use gloo_timers::callback::Timeout;
+use serde_json::Value;
use yew::html::Scope;
use pwt::dom::DomVisibilityObserver;
use pwt::prelude::*;
-use pwt::state::NavigationContextExt;
use pwt::widget::{AlertDialog, Column};
use pwt::AsyncPool;
+#[cfg(doc)]
+use crate::impl_deref_mut_property;
+#[cfg(doc)]
+use pwt::widget::Dialog;
+
use crate::{TaskProgress, TaskViewer};
-pub struct LoadableComponentState {
- loading: usize,
- last_load_error: Option<String>,
- repeat_timespan: u32, /* 0 => no repeated loading */
- task_base_url: Option<AttrValue>,
-}
+pub type LoadableComponentContext<L> = Context<LoadableComponentMaster<L>>;
+pub type LoadableComponentScope<L> = Scope<LoadableComponentMaster<L>>;
+
+/// Loadable Components
+///
+/// - Load data using an async function [LoadableComponent::load]
+/// - repeated load possible
+/// - pause repeated load when component is not visible (uses [DomVisibilityObserver])
+/// - display the loaded data [LoadableComponent::main_view]
+/// - display an optional toolbar [LoadableComponent::toolbar]
+/// - display any errors from failed load.
+/// - display additional dialogs depening on [LoadableComponent::ViewState]
+///
+/// The [LoadableComponentScopeExt] defines available control function on the scope.
+///
+/// The [LoadableComponentState] provides acces to load status informations and add the ability
+/// to spawn tasks.
+///
+/// ```
+/// use proxmox_yew_comp::{LoadableComponent, LoadableComponentState, LoadableComponentContext};
+/// // include the scope extension for (for `change_view`, `send_custom_message`, ...)
+/// use proxmox_yew_comp::LoadableComponentScopeExt;
+/// # use std::pin::Pin;
+/// # use std::rc::Rc;
+/// # use std::future::Future;
+/// # use pwt::prelude::*;
+/// # use proxmox_yew_comp::http_get;
+/// # use yew::virtual_dom::{VComp, VNode, Key};
+///
+/// // define the component properties
+/// #[derive(Clone, PartialEq, Properties)]
+/// pub struct MyComponent {
+/// key: Option<Key>,
+/// /* add whatever you need */
+/// };
+///
+/// // define your view states
+/// #[derive(PartialEq)]
+/// pub enum ViewState { Add, Edit }
+///
+/// // define the component message type
+/// pub enum Msg { UpdateData(String) }
+///
+/// // define the component state
+/// pub struct MyComponentState {
+/// // you need to inlucde a LoadableComponentState
+/// state: LoadableComponentState<ViewState>,
+/// // Add any other data you need
+/// loaded_data: Option<String>,
+/// }
+///
+/// // implement DerefMut
+/// proxmox_yew_comp::impl_deref_mut_property!(
+/// MyComponentState,
+/// state,
+/// LoadableComponentState<ViewState>
+/// );
+///
+/// impl LoadableComponent for MyComponentState {
+/// type Properties = MyComponent;
+/// type Message = Msg; // component message type
+/// type ViewState = ViewState;
+///
+/// fn create(ctx: &LoadableComponentContext<Self>) -> Self {
+/// Self {
+/// state: LoadableComponentState::new(),
+/// loaded_data: None,
+/// }
+/// }
+///
+/// fn load(
+/// &self,
+/// ctx: &LoadableComponentContext<Self>,
+/// ) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
+/// let link = ctx.link().clone();
+/// Box::pin(async move {
+/// let data = http_get("/something", None).await?; // load something here
+/// link.send_custom_message(Msg::UpdateData(data));
+/// Ok(())
+/// })
+/// }
+///
+/// fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
+/// match msg {
+/// Msg::UpdateData(data) => self.loaded_data = Some(data),
+/// }
+/// true
+/// }
+///
+/// fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
+/// let text: String = if let Some(data) = &self.loaded_data {
+/// data.clone()
+/// } else {
+/// "no data".into()
+/// };
+/// html!{text}
+/// }
+/// }
+///
+/// // add ability to generate the yew Component (provided by [LoadableComponentMaster])
+/// use proxmox_yew_comp::LoadableComponentMaster;
+/// impl From<MyComponent> for VNode {
+/// fn from(props: MyComponent) -> VNode {
+/// let key = props.key.clone();
+/// let comp = VComp::new::<LoadableComponentMaster<MyComponentState>>(Rc::new(props), key);
+/// VNode::from(comp)
+/// }
+/// }
+///
+/// ```
+pub trait LoadableComponent:
+ Sized + DerefMut<Target = LoadableComponentState<Self::ViewState>> + 'static
+{
+ /// The yew component properties.
+ type Properties: Properties;
+ /// The yew component message type.
+ type Message: 'static;
+ /// The view state
+ ///
+ /// The view state of the component can be changed with [LoadableComponentScopeExt::change_view].
+ /// The value is then passed to the [LoadableComponent::dialog_view] function which can render
+ /// different dialogs.
+ type ViewState: 'static + PartialEq;
-pub struct LoadableComponentContext<'a, L: LoadableComponent + Sized + 'static> {
- ctx: &'a Context<LoadableComponentMaster<L>>,
- comp_state: &'a LoadableComponentState,
-}
+ /// Create a new instance
+ fn create(ctx: &LoadableComponentContext<Self>) -> Self;
+
+ /// Async Load
+ fn load(
+ &self,
+ ctx: &LoadableComponentContext<Self>,
+ ) -> Pin<Box<dyn Future<Output = Result<(), Error>>>>;
-impl<L: LoadableComponent + Sized> LoadableComponentContext<'_, L> {
- pub fn props(&self) -> &L::Properties {
- self.ctx.props()
+ /// Yew component update function (see [Component::update])
+ #[allow(unused_variables)]
+ fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
+ true
}
- pub fn link(&self) -> LoadableComponentLink<L> {
- LoadableComponentLink {
- link: self.ctx.link().clone(),
- }
+
+ /// Yew component changed function (see [Component::changed])
+ #[allow(unused_variables)]
+ fn changed(
+ &mut self,
+ ctx: &LoadableComponentContext<Self>,
+ _old_props: &Self::Properties,
+ ) -> bool {
+ true
}
- pub fn loading(&self) -> bool {
- self.comp_state.loading > 0
+
+ /// Optional toolbar
+ #[allow(unused_variables)]
+ fn toolbar(&self, ctx: &LoadableComponentContext<Self>) -> Option<Html> {
+ None
}
- pub fn last_load_errors(&self) -> Option<&str> {
- self.comp_state.last_load_error.as_deref()
+ /// Main view (see [Component::view])
+ ///
+ /// The difference is that we render the result into a [Column], with an optional
+ /// toolbar on the top.
+ fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html;
+
+ /// ViewState dependent dialogs
+ ///
+ /// The result is rendered below the main view. Usually some kind of [Dialog] window.
+ ///
+ /// The view state can be changed with `link.change_view(..)` and `link.change_view_callback(...)`.
+ #[allow(unused_variables)]
+ fn dialog_view(
+ &self,
+ ctx: &LoadableComponentContext<Self>,
+ view_state: &Self::ViewState,
+ ) -> Option<Html> {
+ None
}
+
+ /// Yew component rendered function (see [Component::rendered])
+ #[allow(unused_variables)]
+ fn rendered(&mut self, ctx: &LoadableComponentContext<Self>, first_render: bool) {}
}
-pub struct LoadableComponentLink<L: LoadableComponent + Sized + 'static> {
- link: Scope<LoadableComponentMaster<L>>,
+#[derive(Clone, PartialEq)]
+pub enum ViewState<V: PartialEq> {
+ Main,
+ /// Show the dialog returned by dialog_view
+ Dialog(V),
+ /// Show proxmox api task status
+ TaskProgress(String),
+ /// Show proxmox api task log
+ TaskLog(String, Option<i64>),
+ /// Show an error message dialog
+ Error(String, String, /* reload_on_close */ bool),
}
-impl<L: LoadableComponent + Sized> Clone for LoadableComponentLink<L> {
- fn clone(&self) -> Self {
- Self {
- link: self.link.clone(),
- }
- }
+pub enum Msg<M, V: PartialEq> {
+ DataChange,
+ Load,
+ RepeatedLoad(u32 /* repeat time in miliseconds */),
+ LoadResult(Result<(), Error>),
+ ChangeView(/*reload*/ bool, ViewState<V>),
+ ChildMessage(M),
+ Visible(bool),
+ Spawn(Pin<Box<dyn Future<Output = ()>>>),
}
-impl<L: LoadableComponent + Sized> LoadableComponentLink<L> {
- pub fn send_message(&self, msg: impl Into<L::Message>) {
- let msg = msg.into();
- self.link.send_message(Msg::ChildMessage(msg));
- }
+pub trait LoadableComponentScopeExt<M, V: PartialEq> {
+ fn send_custom_message(&self, msg: M);
+ fn send_reload(&self);
+ fn send_redraw(&self);
+ fn repeated_load(&self, miliseconds: u32);
- pub fn callback<F, IN, M>(&self, function: F) -> Callback<IN>
+ fn change_view(&self, child_view_state: Option<V>);
+ fn custom_callback<F, IN>(&self, function: F) -> Callback<IN>
where
- M: Into<L::Message>,
- F: Fn(IN) -> M + 'static,
- {
- self.link.callback(move |p: IN| {
- let msg: L::Message = function(p).into();
- Msg::ChildMessage(msg)
- })
- }
+ M: Into<M>,
+ F: Fn(IN) -> M + 'static;
- /// Spawn a future using the [AsyncPool] from the component.
- pub fn spawn<Fut>(&self, future: Fut)
+ fn change_view_callback<C, F, IN>(&self, function: F) -> Callback<IN>
where
- Fut: Future<Output = ()> + 'static,
- {
- self.link.send_message(Msg::Spawn(Box::pin(future)));
- }
+ C: Into<Option<V>>,
+ F: Fn(IN) -> C + 'static;
- pub fn send_future<Fut, M>(&self, future: Fut)
+ /// Spawn a future using the [AsyncPool] from the component.
+ fn spawn<Fut>(&self, future: Fut)
where
- M: Into<L::Message>,
- Fut: Future<Output = M> + 'static,
- {
- let link = self.link.clone();
- self.link.send_message(Msg::Spawn(Box::pin(async move {
- let message: L::Message = future.await.into();
- link.send_message(Msg::ChildMessage(message));
- })));
- }
+ Fut: Future<Output = ()> + 'static;
- pub fn callback_future<F, Fut, IN, M>(&self, function: F) -> Callback<IN>
- where
- M: Into<L::Message>,
- Fut: Future<Output = M> + 'static,
- F: Fn(IN) -> Fut + 'static,
- {
- let link = self.clone();
+ fn show_error(
+ &self,
+ title: impl Into<String>,
+ msg: impl std::fmt::Display,
+ reload_on_close: bool,
+ );
- let closure = move |input: IN| {
- link.send_future(function(input));
- };
+ fn show_task_progres(&self, task_id: impl Into<String>);
- closure.into()
- }
+ fn show_task_log(&self, task_id: impl Into<String>, endtime: Option<i64>);
- pub fn send_reload(&self) {
- self.link.send_message(Msg::Load)
- }
+ fn start_task(&self, command_path: impl Into<String>, data: Option<Value>, short: bool);
+}
- pub fn repeated_load(&self, miliseconds: u32) {
- self.link.send_message(Msg::RepeatedLoad(miliseconds));
+impl<M, V: PartialEq, T: 'static + LoadableComponent<Message = M, ViewState = V>>
+ LoadableComponentScopeExt<M, V> for Scope<LoadableComponentMaster<T>>
+{
+ fn send_custom_message(&self, msg: M) {
+ self.send_message(Msg::ChildMessage(msg));
}
- pub fn task_base_url(&self, base_url: impl Into<AttrValue>) {
- self.link.send_message(Msg::TaskBaseUrl(base_url.into()));
+ fn send_reload(&self) {
+ self.send_message(Msg::Load);
}
- pub fn show_error(
- &self,
- title: impl Into<String>,
- msg: impl std::fmt::Display,
- reload_on_close: bool,
- ) {
- let view_state = ViewState::Error(title.into(), msg.to_string(), reload_on_close);
- self.link.send_message(Msg::ChangeView(false, view_state));
+ fn send_redraw(&self) {
+ self.send_message(Msg::DataChange);
}
- pub fn show_task_progres(&self, task_id: impl Into<String>) {
- let view_state = ViewState::TaskProgress(task_id.into());
- self.link.send_message(Msg::ChangeView(false, view_state));
+ fn repeated_load(&self, miliseconds: u32) {
+ self.send_message(Msg::RepeatedLoad(miliseconds));
}
- pub fn show_task_log(&self, task_id: impl Into<String>, endtime: Option<i64>) {
- let view_state = ViewState::TaskLog(task_id.into(), endtime);
- self.link.send_message(Msg::ChangeView(false, view_state));
+ fn custom_callback<F, IN>(&self, function: F) -> Callback<IN>
+ where
+ M: Into<M>,
+ F: Fn(IN) -> M + 'static,
+ {
+ let scope = self.clone();
+ let closure = move |input| {
+ let output = function(input);
+ scope.send_custom_message(output);
+ };
+ Callback::from(closure)
}
- pub fn change_view(&self, child_view_state: Option<L::ViewState>) {
+ fn change_view(&self, child_view_state: Option<V>) {
let view_state = if let Some(child_view_state) = child_view_state {
ViewState::Dialog(child_view_state)
} else {
ViewState::Main
};
- self.link.send_message(Msg::ChangeView(false, view_state));
+ self.send_message(Msg::ChangeView(false, view_state));
}
- pub fn change_view_callback<F, IN, M>(&self, function: F) -> Callback<IN>
+ fn change_view_callback<C, F, IN>(&self, function: F) -> Callback<IN>
where
- M: Into<Option<L::ViewState>>,
- F: Fn(IN) -> M + 'static,
+ C: Into<Option<V>>,
+ F: Fn(IN) -> C + 'static,
{
- self.link.callback(move |p: IN| {
- let state: Option<L::ViewState> = function(p).into();
+ self.callback(move |p: IN| {
+ let state: Option<V> = function(p).into();
if let Some(state) = state {
Msg::ChangeView(true, ViewState::Dialog(state))
} else {
@@ -166,11 +316,38 @@ impl<L: LoadableComponent + Sized> LoadableComponentLink<L> {
})
}
- pub fn start_task(&self, command_path: impl Into<String>, data: Option<Value>, short: bool) {
+ fn spawn<Fut>(&self, future: Fut)
+ where
+ Fut: Future<Output = ()> + 'static,
+ {
+ self.send_message(Msg::Spawn(Box::pin(future)));
+ }
+
+ fn show_error(
+ &self,
+ title: impl Into<String>,
+ msg: impl std::fmt::Display,
+ reload_on_close: bool,
+ ) {
+ let view_state = ViewState::Error(title.into(), msg.to_string(), reload_on_close);
+ self.send_message(Msg::ChangeView(false, view_state));
+ }
+
+ fn show_task_progres(&self, task_id: impl Into<String>) {
+ let view_state = ViewState::TaskProgress(task_id.into());
+ self.send_message(Msg::ChangeView(false, view_state));
+ }
+
+ fn show_task_log(&self, task_id: impl Into<String>, endtime: Option<i64>) {
+ let view_state = ViewState::TaskLog(task_id.into(), endtime);
+ self.send_message(Msg::ChangeView(false, view_state));
+ }
+
+ fn start_task(&self, command_path: impl Into<String>, data: Option<Value>, short: bool) {
let command_path: String = command_path.into();
let link = self.clone();
let command_future = crate::http_post::<String>(command_path, data);
- self.link.send_message(Msg::Spawn(Box::pin(async move {
+ self.send_message(Msg::Spawn(Box::pin(async move {
match command_future.await {
Ok(task_id) => {
link.send_reload();
@@ -187,140 +364,86 @@ impl<L: LoadableComponent + Sized> LoadableComponentLink<L> {
}
})));
}
-
- /// Returns the original [`yew::html::Scope`] of the master component.
- ///
- /// This is useful when e.g. trying to get an higher level context
- pub fn yew_link(&self) -> &Scope<LoadableComponentMaster<L>> {
- &self.link
- }
}
-impl<L: LoadableComponent + Sized> RouterScopeExt for LoadableComponentLink<L> {
- fn navigator(&self) -> Option<yew_router::prelude::Navigator> {
- self.link.navigator()
- }
-
- fn location(&self) -> Option<yew_router::prelude::Location> {
- self.link.location()
- }
-
- fn route<R>(&self) -> Option<R>
- where
- R: yew_router::Routable + 'static,
- {
- self.link.route()
- }
-
- fn add_location_listener(
- &self,
- cb: Callback<yew_router::prelude::Location>,
- ) -> Option<yew_router::prelude::LocationHandle> {
- self.link.add_location_listener(cb)
- }
-
- fn add_navigator_listener(
- &self,
- cb: Callback<yew_router::prelude::Navigator>,
- ) -> Option<yew_router::prelude::NavigatorHandle> {
- self.link.add_navigator_listener(cb)
- }
+/// Base state for [LoadableComponent] implementations.
+///
+/// The struct provides the following features:
+///
+/// - access to load status informations
+/// - setup task base url
+/// - spawn tasks: includes an [AsyncPool], so that any [LoadableComponent] can spawn
+/// task via this pool.
+///
+/// The [LoadableComponent] trait requires access to this struct via [DerefMut]. The
+/// macro [impl_deref_mut_property] provides an easy way to
+/// implement that.
+///
+/// ```
+/// use proxmox_yew_comp::LoadableComponentState;
+/// # #[derive(PartialEq)]
+/// # pub enum ViewState { Add, Edit }
+/// pub struct MyComponentState {
+/// state: LoadableComponentState<ViewState>,
+/// // Add any other data you need
+/// other_data: String,
+/// }
+/// // implement DerefMut
+/// proxmox_yew_comp::impl_deref_mut_property!(MyComponentState, state, LoadableComponentState<ViewState>);
+/// ```
+pub struct LoadableComponentState<V: PartialEq> {
+ loading: usize,
+ last_load_error: Option<String>,
+ repeat_timespan: u32, /* 0 => no repeated loading */
+ task_base_url: Option<AttrValue>,
+ view_state: ViewState<V>,
+ reload_timeout: Option<Timeout>,
+ visible: bool,
+ visibitlity_observer: Option<DomVisibilityObserver>,
+ node_ref: NodeRef,
+ async_pool: AsyncPool,
}
-impl<L: LoadableComponent + Sized> NavigationContextExt for LoadableComponentLink<L> {
- fn nav_context(&self) -> Option<pwt::state::NavigationContext> {
- self.link.nav_context()
- }
-
- fn full_path(&self) -> Option<String> {
- self.link.full_path()
- }
-
- fn push_relative_route(&self, path: &str) {
- self.link.push_relative_route(path)
+impl<V: PartialEq> LoadableComponentState<V> {
+ pub fn new() -> Self {
+ Self {
+ loading: 0,
+ last_load_error: None,
+ repeat_timespan: 0,
+ task_base_url: None,
+ view_state: ViewState::Main,
+ reload_timeout: None,
+ visible: true,
+ visibitlity_observer: None,
+ node_ref: NodeRef::default(),
+ async_pool: AsyncPool::new(),
+ }
}
-}
-
-pub trait LoadableComponent: Sized {
- type Properties: Properties;
- type Message: 'static;
- type ViewState: 'static + PartialEq;
- fn create(ctx: &LoadableComponentContext<Self>) -> Self;
-
- fn load(
- &self,
- ctx: &LoadableComponentContext<Self>,
- ) -> Pin<Box<dyn Future<Output = Result<(), Error>>>>;
-
- #[allow(unused_variables)]
- fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
- true
+ pub fn loading(&self) -> bool {
+ self.loading > 0
}
- #[allow(unused_variables)]
- fn changed(
- &mut self,
- ctx: &LoadableComponentContext<Self>,
- _old_props: &Self::Properties,
- ) -> bool {
- true
+ pub fn last_load_errors(&self) -> Option<&str> {
+ self.last_load_error.as_deref()
}
- #[allow(unused_variables)]
- fn toolbar(&self, ctx: &LoadableComponentContext<Self>) -> Option<Html> {
- None
+ pub fn set_task_base_url(&mut self, base_url: AttrValue) {
+ self.task_base_url = Some(base_url);
}
- fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html;
-
- #[allow(unused_variables)]
- fn dialog_view(
- &self,
- ctx: &LoadableComponentContext<Self>,
- view_state: &Self::ViewState,
- ) -> Option<Html> {
- None
+ /// Spawn a future using the [AsyncPool] from the component.
+ pub fn spawn<Fut>(&self, future: Fut)
+ where
+ Fut: Future<Output = ()> + 'static,
+ {
+ self.async_pool.spawn(future);
}
-
- #[allow(unused_variables)]
- fn rendered(&mut self, ctx: &LoadableComponentContext<Self>, first_render: bool) {}
-}
-
-#[derive(Clone, PartialEq)]
-pub enum ViewState<V: PartialEq> {
- Main,
- /// Show the dialog returned by dialog_view
- Dialog(V),
- /// Show proxmox api task status
- TaskProgress(String),
- /// Show proxmox api task log
- TaskLog(String, Option<i64>),
- /// Show an error message dialog
- Error(String, String, /* reload_on_close */ bool),
-}
-
-pub enum Msg<M, V: PartialEq> {
- DataChange,
- Load,
- RepeatedLoad(u32 /* repeat time in miliseconds */),
- LoadResult(Result<(), Error>),
- ChangeView(/*reload*/ bool, ViewState<V>),
- ChildMessage(M),
- TaskBaseUrl(AttrValue),
- Visible(bool),
- Spawn(Pin<Box<dyn Future<Output = ()>>>),
}
+#[doc(hidden)]
pub struct LoadableComponentMaster<L: LoadableComponent> {
state: L,
- comp_state: LoadableComponentState,
- view_state: ViewState<L::ViewState>,
- reload_timeout: Option<Timeout>,
- visible: bool,
- visibitlity_observer: Option<DomVisibilityObserver>,
- node_ref: NodeRef,
- async_pool: AsyncPool,
}
impl<L: LoadableComponent + 'static> Component for LoadableComponentMaster<L> {
@@ -328,91 +451,65 @@ impl<L: LoadableComponent + 'static> Component for LoadableComponentMaster<L> {
type Properties = L::Properties;
fn create(ctx: &Context<Self>) -> Self {
- let loading = 0;
-
- let comp_state = LoadableComponentState {
- loading,
- last_load_error: None,
- repeat_timespan: 0,
- task_base_url: None,
- };
-
- let sub_context = LoadableComponentContext {
- ctx,
- comp_state: &comp_state,
- };
-
// Send Msg::Load first (before any Msg::RepeatedLoad in create), so that we
// can avoid multiple loads at startup
ctx.link().send_message(Msg::Load);
- let state = L::create(&sub_context);
+ let mut state = L::create(ctx);
+ state.visible = true;
- Self {
- state,
- comp_state,
- view_state: ViewState::Main,
- reload_timeout: None,
- visible: true,
- visibitlity_observer: None,
- node_ref: NodeRef::default(),
- async_pool: AsyncPool::new(),
- }
+ Self { state }
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Msg::Spawn(future) => {
- self.async_pool.spawn(future);
+ self.state.async_pool.spawn(future);
false
}
Msg::DataChange => true,
Msg::Load => {
- self.comp_state.loading += 1;
+ let load_future = self.state.load(ctx);
+ self.state.loading += 1;
let link = ctx.link().clone();
- let sub_context = LoadableComponentContext {
- ctx,
- comp_state: &self.comp_state,
- };
- let load_future = self.state.load(&sub_context);
- self.async_pool.spawn(async move {
+ self.state.async_pool.spawn(async move {
let data = load_future.await;
link.send_message(Msg::LoadResult(data));
});
true
}
Msg::RepeatedLoad(timespan) => {
- self.comp_state.repeat_timespan = timespan;
- self.reload_timeout = None;
- if self.comp_state.loading == 0 {
+ self.state.repeat_timespan = timespan;
+ self.state.reload_timeout = None;
+ if self.state.loading == 0 {
<Self as yew::Component>::update(self, ctx, Msg::Load);
}
false
}
Msg::LoadResult(data) => {
- self.comp_state.loading -= 1;
+ self.state.loading -= 1;
match data {
Ok(()) => {
- self.comp_state.last_load_error = None;
+ self.state.last_load_error = None;
}
Err(err) => {
- let this_is_the_first_error = self.comp_state.last_load_error.is_none();
- self.comp_state.last_load_error = Some(err.to_string());
+ let this_is_the_first_error = self.state.last_load_error.is_none();
+ self.state.last_load_error = Some(err.to_string());
if this_is_the_first_error {
- self.view_state =
+ self.state.view_state =
ViewState::Error(tr!("Load failed"), err.to_string(), false);
}
}
}
- self.reload_timeout = None;
- if self.comp_state.loading == 0 {
+ self.state.reload_timeout = None;
+ if self.state.loading == 0 {
/* no outstanding loads */
- if self.comp_state.repeat_timespan > 0 {
+ if self.state.repeat_timespan > 0 {
let link = ctx.link().clone();
- if self.visible {
- self.reload_timeout =
- Some(Timeout::new(self.comp_state.repeat_timespan, move || {
+ if self.state.visible {
+ self.state.reload_timeout =
+ Some(Timeout::new(self.state.repeat_timespan, move || {
link.send_message(Msg::Load);
}));
}
@@ -421,7 +518,7 @@ impl<L: LoadableComponent + 'static> Component for LoadableComponentMaster<L> {
true
}
Msg::ChangeView(reload_data, view_state) => {
- if self.view_state == view_state {
+ if self.state.view_state == view_state {
return false;
}
@@ -429,29 +526,21 @@ impl<L: LoadableComponent + 'static> Component for LoadableComponentMaster<L> {
ctx.link().send_message(Msg::Load);
}
- self.view_state = view_state;
+ self.state.view_state = view_state;
true
}
Msg::ChildMessage(child_msg) => {
- let sub_context = LoadableComponentContext {
- ctx,
- comp_state: &self.comp_state,
- };
- self.state.update(&sub_context, child_msg);
+ self.state.update(ctx, child_msg);
true
}
- Msg::TaskBaseUrl(base_url) => {
- self.comp_state.task_base_url = Some(base_url);
- false
- }
Msg::Visible(visible) => {
- if self.visible == visible {
+ if self.state.visible == visible {
return false;
}
- self.visible = visible;
- if self.comp_state.loading == 0 && self.visible {
+ self.state.visible = visible;
+ if self.state.loading == 0 && self.state.visible {
/* no outstanding loads */
- if self.comp_state.loading == 0 {
+ if self.state.loading == 0 {
<Self as yew::Component>::update(self, ctx, Msg::Load);
}
}
@@ -461,26 +550,16 @@ impl<L: LoadableComponent + 'static> Component for LoadableComponentMaster<L> {
}
fn changed(&mut self, ctx: &Context<Self>, _old_props: &Self::Properties) -> bool {
- let sub_context = LoadableComponentContext {
- ctx,
- comp_state: &self.comp_state,
- };
-
- self.state.changed(&sub_context, _old_props)
+ self.state.changed(ctx, _old_props)
}
fn view(&self, ctx: &Context<Self>) -> Html {
- let sub_context = LoadableComponentContext {
- ctx,
- comp_state: &self.comp_state,
- };
-
- let main_view = self.state.main_view(&sub_context);
+ let main_view = self.state.main_view(ctx);
let dialog: Option<Html> =
- match &self.view_state {
+ match &self.state.view_state {
ViewState::Main => None,
- ViewState::Dialog(view_state) => self.state.dialog_view(&sub_context, view_state),
+ ViewState::Dialog(view_state) => self.state.dialog_view(ctx, view_state),
ViewState::Error(title, msg, reload_on_close) => {
let reload_on_close = *reload_on_close;
Some(
@@ -498,7 +577,7 @@ impl<L: LoadableComponent + 'static> Component for LoadableComponentMaster<L> {
.callback(move |_| Msg::ChangeView(true, ViewState::Main)),
);
- if let Some(base_url) = &self.comp_state.task_base_url {
+ if let Some(base_url) = &self.state.task_base_url {
task_progress.set_base_url(base_url);
}
@@ -510,19 +589,19 @@ impl<L: LoadableComponent + 'static> Component for LoadableComponentMaster<L> {
.callback(move |_| Msg::ChangeView(true, ViewState::Main)),
);
- if let Some(base_url) = &self.comp_state.task_base_url {
+ if let Some(base_url) = &self.state.task_base_url {
task_viewer.set_base_url(base_url);
}
Some(task_viewer.into())
}
};
- let toolbar = self.state.toolbar(&sub_context);
+ let toolbar = self.state.toolbar(ctx);
let mut alert_msg = None;
if dialog.is_none() {
- if let Some(msg) = &self.comp_state.last_load_error {
+ if let Some(msg) = &self.state.last_load_error {
alert_msg = Some(pwt::widget::error_message(msg).class("pwt-border-top"));
}
}
@@ -533,23 +612,18 @@ impl<L: LoadableComponent + 'static> Component for LoadableComponentMaster<L> {
.with_child(main_view)
.with_optional_child(alert_msg)
.with_optional_child(dialog)
- .into_html_with_ref(self.node_ref.clone())
+ .into_html_with_ref(self.state.node_ref.clone())
}
fn rendered(&mut self, ctx: &Context<Self>, first_render: bool) {
- if self.visibitlity_observer.is_none() && self.reload_timeout.is_some() {
- if let Some(el) = self.node_ref.cast::<web_sys::Element>() {
- self.visibitlity_observer = Some(DomVisibilityObserver::new(
+ if self.state.visibitlity_observer.is_none() && self.state.reload_timeout.is_some() {
+ if let Some(el) = self.state.node_ref.cast::<web_sys::Element>() {
+ self.state.visibitlity_observer = Some(DomVisibilityObserver::new(
&el,
ctx.link().callback(Msg::Visible),
))
}
}
- let sub_context = LoadableComponentContext {
- ctx,
- comp_state: &self.comp_state,
- };
-
- self.state.rendered(&sub_context, first_render);
+ self.state.rendered(ctx, first_render);
}
}
diff --git a/src/node_status_panel.rs b/src/node_status_panel.rs
index 3f8946d..9249c01 100644
--- a/src/node_status_panel.rs
+++ b/src/node_status_panel.rs
@@ -17,7 +17,7 @@ use proxmox_node_status::{NodePowerCommand, NodeStatus};
use crate::utils::copy_text_to_clipboard;
use crate::{
http_get, http_post, node_info, ConfirmButton, LoadableComponent, LoadableComponentContext,
- LoadableComponentMaster,
+ LoadableComponentMaster, LoadableComponentScopeExt, LoadableComponentState,
};
#[derive(Properties, Clone, PartialEq)]
@@ -53,7 +53,6 @@ enum Msg {
Error(Error),
Loaded(Rc<NodeStatus>),
RebootOrShutdown(NodePowerCommand),
- Reload,
}
#[derive(PartialEq)]
@@ -62,10 +61,17 @@ enum ViewState {
}
struct ProxmoxNodeStatusPanel {
+ state: LoadableComponentState<ViewState>,
node_status: Option<Rc<NodeStatus>>,
error: Option<Error>,
}
+crate::impl_deref_mut_property!(
+ ProxmoxNodeStatusPanel,
+ state,
+ LoadableComponentState<ViewState>
+);
+
impl ProxmoxNodeStatusPanel {
fn change_power_state(&self, ctx: &LoadableComponentContext<Self>, command: NodePowerCommand) {
let Some(url) = ctx.props().status_base_url.clone() else {
@@ -79,8 +85,8 @@ impl ProxmoxNodeStatusPanel {
}));
match http_post(url.as_str(), data).await {
- Ok(()) => link.send_message(Msg::Reload),
- Err(err) => link.send_message(Msg::Error(err)),
+ Ok(()) => link.send_redraw(),
+ Err(err) => link.send_custom_message(Msg::Error(err)),
}
});
}
@@ -90,8 +96,8 @@ impl ProxmoxNodeStatusPanel {
ctx: &LoadableComponentContext<Self>,
fingerprint: &str,
) -> Dialog {
- let link = ctx.link();
- let link_button = ctx.link();
+ let link = ctx.link().clone();
+ let link_button = ctx.link().clone();
let fingerprint = fingerprint.to_owned();
Dialog::new(tr!("Fingerprint"))
@@ -135,10 +141,10 @@ impl LoadableComponent for ProxmoxNodeStatusPanel {
type ViewState = ViewState;
type Properties = NodeStatusPanel;
- fn create(ctx: &crate::LoadableComponentContext<Self>) -> Self {
+ fn create(ctx: &LoadableComponentContext<Self>) -> Self {
ctx.link().repeated_load(5000);
-
Self {
+ state: LoadableComponentState::new(),
node_status: None,
error: None,
}
@@ -146,7 +152,7 @@ impl LoadableComponent for ProxmoxNodeStatusPanel {
fn load(
&self,
- ctx: &crate::LoadableComponentContext<Self>,
+ ctx: &LoadableComponentContext<Self>,
) -> std::pin::Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
let url = ctx.props().status_base_url.clone();
let link = ctx.link().clone();
@@ -154,15 +160,15 @@ impl LoadableComponent for ProxmoxNodeStatusPanel {
Box::pin(async move {
if let Some(url) = url {
match http_get(url.as_str(), None).await {
- Ok(res) => link.send_message(Msg::Loaded(Rc::new(res))),
- Err(err) => link.send_message(Msg::Error(err)),
+ Ok(res) => link.send_custom_message(Msg::Loaded(Rc::new(res))),
+ Err(err) => link.send_custom_message(Msg::Error(err)),
}
}
Ok(())
})
}
- fn update(&mut self, ctx: &crate::LoadableComponentContext<Self>, msg: Self::Message) -> bool {
+ fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
match msg {
Msg::Error(err) => {
self.error = Some(err);
@@ -177,7 +183,6 @@ impl LoadableComponent for ProxmoxNodeStatusPanel {
self.change_power_state(ctx, command);
false
}
- Msg::Reload => true,
}
}
@@ -197,7 +202,7 @@ impl LoadableComponent for ProxmoxNodeStatusPanel {
None
}
- fn main_view(&self, ctx: &crate::LoadableComponentContext<Self>) -> Html {
+ fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
let status = self
.node_status
.as_ref()
@@ -223,7 +228,7 @@ impl LoadableComponent for ProxmoxNodeStatusPanel {
.confirm_message(tr!("Are you sure you want to reboot the node?"))
.on_activate(
ctx.link()
- .callback(|_| Msg::RebootOrShutdown(NodePowerCommand::Reboot)),
+ .custom_callback(|_| Msg::RebootOrShutdown(NodePowerCommand::Reboot)),
)
.icon_class("fa fa-undo"),
);
@@ -232,7 +237,7 @@ impl LoadableComponent for ProxmoxNodeStatusPanel {
.confirm_message(tr!("Are you sure you want to shut down the node?"))
.on_activate(
ctx.link()
- .callback(|_| Msg::RebootOrShutdown(NodePowerCommand::Shutdown)),
+ .custom_callback(|_| Msg::RebootOrShutdown(NodePowerCommand::Shutdown)),
)
.icon_class("fa fa-power-off"),
);
diff --git a/src/notes_view.rs b/src/notes_view.rs
index 6b0a379..9c10c00 100644
--- a/src/notes_view.rs
+++ b/src/notes_view.rs
@@ -17,7 +17,7 @@ use proxmox_client::ApiResponseData;
use crate::{
ApiLoadCallback, EditWindow, LoadableComponent, LoadableComponentContext,
- LoadableComponentMaster, Markdown,
+ LoadableComponentMaster, LoadableComponentScopeExt, LoadableComponentState, Markdown,
};
#[derive(Serialize, Deserialize, Clone, PartialEq)]
@@ -107,10 +107,13 @@ pub enum Msg {
#[doc(hidden)]
pub struct ProxmoxNotesView {
+ state: LoadableComponentState<ViewState>,
data: NotesWithDigest,
edit_window_loader: ApiLoadCallback<Value>,
}
+crate::impl_deref_mut_property!(ProxmoxNotesView, state, LoadableComponentState<ViewState>);
+
impl LoadableComponent for ProxmoxNotesView {
type Properties = NotesView;
type Message = Msg;
@@ -130,6 +133,7 @@ impl LoadableComponent for ProxmoxNotesView {
}
});
Self {
+ state: LoadableComponentState::new(),
data: NotesWithDigest {
notes: String::new(),
digest: None,
@@ -143,12 +147,12 @@ impl LoadableComponent for ProxmoxNotesView {
ctx: &LoadableComponentContext<Self>,
) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
let loader = ctx.props().loader.clone();
- let link = ctx.link();
+ let link = ctx.link().clone();
Box::pin(async move {
let resp = loader.apply().await?;
let notes = resp.data;
let digest = resp.attribs.get("digest").cloned();
- link.send_message(Msg::Load(NotesWithDigest { notes, digest }));
+ link.send_custom_message(Msg::Load(NotesWithDigest { notes, digest }));
Ok(())
})
}
@@ -169,10 +173,8 @@ impl LoadableComponent for ProxmoxNotesView {
.class("pwt-overflow-hidden")
.class("pwt-border-bottom")
.with_child(
- Button::new(tr!("Edit")).on_activate(
- ctx.link()
- .change_view_callback(|_| Some(ViewState::EditNotes)),
- ),
+ Button::new(tr!("Edit"))
+ .on_activate(ctx.link().change_view_callback(|_| ViewState::EditNotes)),
)
.into(),
)
diff --git a/src/object_grid.rs b/src/object_grid.rs
index a6313d0..e2321d0 100644
--- a/src/object_grid.rs
+++ b/src/object_grid.rs
@@ -8,7 +8,7 @@ use indexmap::IndexMap;
use proxmox_client::ApiResponseData;
use serde_json::Value;
-use yew::html::IntoPropValue;
+use yew::html::{IntoPropValue, Scope};
use yew::prelude::*;
use yew::virtual_dom::{Key, VComp, VNode};
@@ -20,8 +20,11 @@ use pwt::widget::form::FormContext;
use pwt::widget::{Button, Toolbar};
use crate::{ApiLoadCallback, IntoApiLoadCallback};
-use crate::{EditWindow, KVGrid, KVGridRow, LoadableComponentLink};
-use crate::{LoadableComponent, LoadableComponentContext, LoadableComponentMaster};
+use crate::{EditWindow, KVGrid, KVGridRow};
+use crate::{
+ LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
+ LoadableComponentScopeExt, LoadableComponentState,
+};
use pwt_macros::builder;
@@ -257,6 +260,8 @@ pub enum ViewState {
#[doc(hidden)]
pub struct PwtObjectGrid {
+ state: LoadableComponentState<ViewState>,
+
selection: Option<Key>,
data: Rc<Value>,
@@ -266,6 +271,8 @@ pub struct PwtObjectGrid {
controller_observer: Option<SharedStateObserver<Vec<ObjectGridCommand>>>,
}
+crate::impl_deref_mut_property!(PwtObjectGrid, state, LoadableComponentState<ViewState>);
+
impl PwtObjectGrid {
fn update_rows(&mut self, props: &ObjectGrid) {
let mut rows = Vec::new();
@@ -280,7 +287,11 @@ impl PwtObjectGrid {
self.rows = Rc::new(rows);
}
- fn update_controller(&mut self, props: &ObjectGrid, link: LoadableComponentLink<Self>) {
+ fn update_controller(
+ &mut self,
+ props: &ObjectGrid,
+ link: Scope<LoadableComponentMaster<Self>>,
+ ) {
match &props.controller {
None => self.controller_observer = None,
Some(controller) => {
@@ -292,7 +303,7 @@ impl PwtObjectGrid {
guard.split_off(0)
};
for command in commands {
- link.send_message(Msg::ControllerCommand(command));
+ link.send_custom_message(Msg::ControllerCommand(command));
}
},
));
@@ -335,6 +346,7 @@ impl LoadableComponent for PwtObjectGrid {
ctx.link().repeated_load(3000);
let mut me = Self {
+ state: LoadableComponentState::new(),
data: Rc::new(Value::Null),
rows: Rc::new(Vec::new()),
editors: IndexMap::new(),
@@ -342,7 +354,7 @@ impl LoadableComponent for PwtObjectGrid {
controller_observer: None,
};
me.update_rows(props);
- me.update_controller(props, ctx.link());
+ me.update_controller(props, ctx.link().clone());
me
}
@@ -352,12 +364,12 @@ impl LoadableComponent for PwtObjectGrid {
) -> Pin<Box<dyn Future<Output = Result<(), Error>>>> {
let props = ctx.props();
let loader = props.loader.clone();
- let link = ctx.link();
+ let link = ctx.link().clone();
Box::pin(async move {
if let Some(loader) = &loader {
let api_resp: ApiResponseData<Value> = loader.apply().await?;
- link.send_message(Msg::DataChange(api_resp));
+ link.send_custom_message(Msg::DataChange(api_resp));
}
Ok(())
})
@@ -418,7 +430,7 @@ impl LoadableComponent for PwtObjectGrid {
let mut toolbar = Toolbar::new()
.border_bottom(true)
.with_child(Button::new("Edit").disabled(disable_edit).onclick({
- let link = ctx.link();
+ let link = ctx.link().clone();
move |_| {
link.change_view(Some(ViewState::EditObject));
}
@@ -437,11 +449,11 @@ impl LoadableComponent for PwtObjectGrid {
.class("pwt-flex-fit")
.rows(Rc::clone(&self.rows))
.data(self.data.clone())
- .on_select(ctx.link().callback(Msg::Select))
+ .on_select(ctx.link().custom_callback(Msg::Select))
.on_row_dblclick({
let link = ctx.link().clone();
move |event: &mut DataTableMouseEvent| {
- link.send_message(Msg::Select(Some(event.record_key.clone())));
+ link.send_custom_message(Msg::Select(Some(event.record_key.clone())));
link.change_view(Some(ViewState::EditObject));
}
})
@@ -449,7 +461,7 @@ impl LoadableComponent for PwtObjectGrid {
let link = ctx.link().clone();
move |event: &mut DataTableKeyboardEvent| {
if event.key() == " " {
- link.send_message(Msg::Select(Some(event.record_key.clone())));
+ link.send_custom_message(Msg::Select(Some(event.record_key.clone())));
link.change_view(Some(ViewState::EditObject));
}
}
diff --git a/src/permission_panel.rs b/src/permission_panel.rs
index 3ed07b1..e7d2c27 100644
--- a/src/permission_panel.rs
+++ b/src/permission_panel.rs
@@ -18,7 +18,7 @@ use pwt::widget::data_table::{
use pwt_macros::builder;
-use crate::{http_get, LoadableComponent, LoadableComponentMaster};
+use crate::{http_get, LoadableComponent, LoadableComponentMaster, LoadableComponentState};
#[derive(Clone, PartialEq, Properties)]
#[builder]
@@ -46,10 +46,13 @@ impl PermissionPanel {
}
}
pub struct ProxmoxPermissionPanel {
+ state: LoadableComponentState<()>,
store: TreeStore<PermissionInfo>,
columns: Rc<Vec<DataTableHeader<PermissionInfo>>>,
}
+crate::impl_deref_mut_property!(ProxmoxPermissionPanel, state, LoadableComponentState<()>);
+
#[derive(Debug, Clone, Ord, PartialOrd, Eq, PartialEq)]
enum PermissionInfo {
Permission(String, String, bool),
@@ -113,15 +116,19 @@ impl LoadableComponent for ProxmoxPermissionPanel {
type Message = ();
type ViewState = ();
- fn create(_ctx: &crate::LoadableComponentContext<Self>) -> Self {
+ fn create(_ctx: &Context<LoadableComponentMaster<Self>>) -> Self {
let store = TreeStore::new();
let columns = Rc::new(columns(&store));
- Self { store, columns }
+ Self {
+ state: LoadableComponentState::new(),
+ store,
+ columns,
+ }
}
fn load(
&self,
- ctx: &crate::LoadableComponentContext<Self>,
+ ctx: &Context<LoadableComponentMaster<Self>>,
) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
let props = ctx.props();
let base_url = props.base_url.clone();
@@ -151,7 +158,7 @@ impl LoadableComponent for ProxmoxPermissionPanel {
})
}
- fn main_view(&self, _ctx: &crate::LoadableComponentContext<Self>) -> Html {
+ fn main_view(&self, _ctx: &Context<LoadableComponentMaster<Self>>) -> Html {
DataTable::new(Rc::clone(&self.columns), self.store.clone())
.class("pwt-flex-fit")
.into()
diff --git a/src/subscription_panel.rs b/src/subscription_panel.rs
index 9f8e65e..1b6a8e4 100644
--- a/src/subscription_panel.rs
+++ b/src/subscription_panel.rs
@@ -12,8 +12,13 @@ use pwt::widget::form::{Field, FormContext};
use pwt::widget::{Button, Container, InputPanel, Toolbar};
use crate::utils::render_epoch;
-use crate::{ConfirmButton, DataViewWindow, EditWindow, KVGrid, KVGridRow, ProjectInfo};
-use crate::{LoadableComponent, LoadableComponentContext, LoadableComponentMaster};
+use crate::{
+ ConfirmButton, DataViewWindow, EditWindow, KVGrid, KVGridRow, LoadableComponentState,
+ ProjectInfo,
+};
+use crate::{
+ LoadableComponent, LoadableComponentContext, LoadableComponentMaster, LoadableComponentScopeExt,
+};
#[derive(Properties, PartialEq, Clone)]
pub struct SubscriptionPanel {
@@ -38,20 +43,26 @@ pub enum ViewState {
SystemReport,
}
-pub enum Msg {}
-
pub struct ProxmoxSubscriptionPanel {
+ state: LoadableComponentState<ViewState>,
rows: Rc<Vec<KVGridRow>>,
data: Rc<RefCell<Rc<Value>>>,
}
+crate::impl_deref_mut_property!(
+ ProxmoxSubscriptionPanel,
+ state,
+ LoadableComponentState<ViewState>
+);
+
impl LoadableComponent for ProxmoxSubscriptionPanel {
- type Message = Msg;
+ type Message = ();
type Properties = SubscriptionPanel;
type ViewState = ViewState;
fn create(_ctx: &LoadableComponentContext<Self>) -> Self {
Self {
+ state: LoadableComponentState::new(),
rows: Rc::new(rows()),
data: Rc::new(RefCell::new(Rc::new(Value::Null))),
}
@@ -59,7 +70,7 @@ impl LoadableComponent for ProxmoxSubscriptionPanel {
fn load(
&self,
- ctx: &crate::LoadableComponentContext<Self>,
+ ctx: &Context<LoadableComponentMaster<Self>>,
) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
let data = self.data.clone();
let base_url = ctx.props().base_url.to_string();
@@ -70,7 +81,7 @@ impl LoadableComponent for ProxmoxSubscriptionPanel {
})
}
- fn toolbar(&self, ctx: &LoadableComponentContext<Self>) -> Option<Html> {
+ fn toolbar(&self, ctx: &Context<LoadableComponentMaster<Self>>) -> Option<Html> {
let toolbar = Toolbar::new()
.class("pwt-overflow-hidden")
.with_child(
@@ -85,7 +96,7 @@ impl LoadableComponent for ProxmoxSubscriptionPanel {
Button::new(tr!("Check"))
.icon_class("fa fa-check-square-o")
.onclick({
- let link = ctx.link();
+ let link = ctx.link().clone();
let base_url = ctx.props().base_url.to_string();
move |_| {
link.spawn({
@@ -112,7 +123,7 @@ impl LoadableComponent for ProxmoxSubscriptionPanel {
html! {tr!("Are you sure you want to remove the subscription key?")},
)
.on_activate({
- let link = ctx.link();
+ let link = ctx.link().clone();
let base_url = ctx.props().base_url.to_string();
move |_| {
link.spawn({
@@ -141,8 +152,8 @@ impl LoadableComponent for ProxmoxSubscriptionPanel {
)
.with_flex_spacer()
.with_child({
- let loading = ctx.loading();
- let link = ctx.link();
+ let loading = self.loading();
+ let link = ctx.link().clone();
Button::refresh(loading).onclick(move |_| link.send_reload())
});
diff --git a/src/tasks.rs b/src/tasks.rs
index d3e814f..6e1118e 100644
--- a/src/tasks.rs
+++ b/src/tasks.rs
@@ -26,7 +26,10 @@ use pbs_api_types::TaskListItem;
use pwt_macros::builder;
-use crate::{LoadableComponent, LoadableComponentContext, LoadableComponentMaster, TaskViewer};
+use crate::{
+ LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
+ LoadableComponentScopeExt, LoadableComponentState, TaskViewer,
+};
use super::{TaskStatusSelector, TaskTypeSelector};
@@ -98,7 +101,6 @@ pub enum ViewDialog {
}
pub enum Msg {
- Redraw,
ToggleFilter,
LoadBatch(bool), // fresh load
LoadFinished,
@@ -106,6 +108,7 @@ pub enum Msg {
ShowTask,
}
pub struct ProxmoxTasks {
+ state: LoadableComponentState<ViewDialog>,
selection: Selection,
store: Store<TaskListItem>,
show_filter: PersistentState<bool>,
@@ -117,6 +120,8 @@ pub struct ProxmoxTasks {
columns: Rc<Vec<DataTableHeader<TaskListItem>>>,
}
+crate::impl_deref_mut_property!(ProxmoxTasks, state, LoadableComponentState<ViewDialog>);
+
impl ProxmoxTasks {
fn columns(ctx: &LoadableComponentContext<Self>) -> Rc<Vec<DataTableHeader<TaskListItem>>> {
if let Some(columns) = ctx.props().columns.clone() {
@@ -169,18 +174,21 @@ impl LoadableComponent for ProxmoxTasks {
fn create(ctx: &LoadableComponentContext<Self>) -> Self {
let link = ctx.link();
- let selection = Selection::new().on_select(link.callback(|_| Msg::Redraw));
+ let selection = Selection::new().on_select({
+ let link = ctx.link().clone();
+ move |_| link.send_redraw()
+ });
let store = Store::with_extract_key(|item: &TaskListItem| Key::from(item.upid.clone()));
let filter_form_context =
- FormContext::new().on_change(ctx.link().callback(|_| Msg::UpdateFilter));
+ FormContext::new().on_change(ctx.link().custom_callback(|_| Msg::UpdateFilter));
let row_render_callback = DataTableRowRenderCallback::new({
let store = store.clone();
let link = link.clone();
move |args: &mut _| {
if args.row_index() > store.data_len().saturating_sub(LOAD_BUFFER_ROWS) {
- link.send_message(Msg::LoadBatch(false));
+ link.send_custom_message(Msg::LoadBatch(false));
}
let record: &TaskListItem = args.record();
match record.status.as_deref() {
@@ -194,6 +202,7 @@ impl LoadableComponent for ProxmoxTasks {
});
Self {
+ state: LoadableComponentState::new(),
selection,
store,
show_filter: PersistentState::new("ProxmoxTasksShowFilter"),
@@ -286,14 +295,13 @@ impl LoadableComponent for ProxmoxTasks {
}
store.append(&mut data);
}
- link.send_message(Msg::LoadFinished);
+ link.send_custom_message(Msg::LoadFinished);
Ok(())
})
}
fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
match msg {
- Msg::Redraw => true,
Msg::ToggleFilter => {
self.show_filter.update(!*self.show_filter);
true
@@ -304,7 +312,7 @@ impl LoadableComponent for ProxmoxTasks {
return false;
}
let filter_params = form_context.get_submit_data();
- if ctx.loading() && self.last_filter == filter_params {
+ if self.loading() && self.last_filter == filter_params {
return false;
}
@@ -369,7 +377,7 @@ impl LoadableComponent for ProxmoxTasks {
.with_child(
Button::new(tr!("View"))
.disabled(disabled)
- .onclick(ctx.link().callback(|_| Msg::ShowTask)),
+ .onclick(ctx.link().custom_callback(|_| Msg::ShowTask)),
)
.with_flex_spacer()
.with_child({
@@ -381,12 +389,13 @@ impl LoadableComponent for ProxmoxTasks {
.with_child(
Button::new("Filter")
.icon_class(filter_icon_class)
- .onclick(ctx.link().callback(|_| Msg::ToggleFilter)),
+ .onclick(ctx.link().custom_callback(|_| Msg::ToggleFilter)),
)
.with_child({
- let loading = ctx.loading();
- let link = ctx.link();
- Button::refresh(loading).onclick(move |_| link.send_message(Msg::LoadBatch(true)))
+ let loading = self.loading();
+ let link = ctx.link().clone();
+ Button::refresh(loading)
+ .onclick(move |_| link.send_custom_message(Msg::LoadBatch(true)))
});
let filter_classes = classes!(
@@ -441,13 +450,13 @@ impl LoadableComponent for ProxmoxTasks {
fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
let columns = self.columns.clone();
- let link = ctx.link();
+ let link = ctx.link().clone();
DataTable::new(columns, self.store.clone())
.class("pwt-flex-fit")
.selection(self.selection.clone())
.on_row_dblclick(move |_: &mut _| {
- link.send_message(Msg::ShowTask);
+ link.send_custom_message(Msg::ShowTask);
})
.row_render_callback(self.row_render_callback.clone())
.into()
diff --git a/src/tfa/tfa_view.rs b/src/tfa/tfa_view.rs
index c4c1a43..540c98b 100644
--- a/src/tfa/tfa_view.rs
+++ b/src/tfa/tfa_view.rs
@@ -16,7 +16,10 @@ use pwt::widget::{Button, Mask, Toolbar};
use pwt_macros::builder;
-use crate::{LoadableComponent, LoadableComponentContext, LoadableComponentMaster};
+use crate::{
+ impl_deref_mut_property, LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
+ LoadableComponentScopeExt, LoadableComponentState,
+};
use proxmox_tfa::{TfaType, TfaUser};
@@ -87,7 +90,6 @@ impl TfaView {
}
pub enum Msg {
- Redraw,
Edit,
Remove(Option<String>),
RemoveResult(Result<(), Error>),
@@ -104,11 +106,14 @@ pub enum ViewState {
#[doc(hidden)]
pub struct ProxmoxTfaView {
+ state: LoadableComponentState<ViewState>,
selection: Selection,
store: Store<TfaEntry>,
removing: bool,
}
+impl_deref_mut_property!(ProxmoxTfaView, state, LoadableComponentState<ViewState>);
+
impl ProxmoxTfaView {
fn get_selected_record(&self) -> Option<TfaEntry> {
let selected_key = self.selection.selected_key();
@@ -127,8 +132,12 @@ impl LoadableComponent for ProxmoxTfaView {
fn create(ctx: &LoadableComponentContext<Self>) -> Self {
let store = Store::new();
- let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::Redraw));
+ let selection = Selection::new().on_select({
+ let link = ctx.link().clone();
+ move |_| link.send_redraw()
+ });
Self {
+ state: LoadableComponentState::new(),
store,
selection,
removing: false,
@@ -177,7 +186,6 @@ impl LoadableComponent for ProxmoxTfaView {
fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
let props = ctx.props();
match msg {
- Msg::Redraw => true,
Msg::Edit => {
let info = match self.get_selected_record() {
Some(info) => info,
@@ -204,8 +212,8 @@ impl LoadableComponent for ProxmoxTfaView {
// fixme: ask use if he really wants to remove
let link = ctx.link().clone();
let base_url = props.base_url.clone();
- link.send_future(async move {
- Msg::RemoveResult(
+ self.spawn(async move {
+ link.send_custom_message(Msg::RemoveResult(
delete_item(
base_url,
info.user_id.clone(),
@@ -213,7 +221,7 @@ impl LoadableComponent for ProxmoxTfaView {
password,
)
.await,
- )
+ ))
});
false
@@ -273,7 +281,7 @@ impl LoadableComponent for ProxmoxTfaView {
.with_child(
Button::new(tr!("Edit"))
.disabled(edit_disabled)
- .onclick(ctx.link().callback(|_| Msg::Edit)),
+ .onclick(ctx.link().custom_callback(|_| Msg::Edit)),
)
.with_child(
Button::new(tr!("Remove"))
@@ -282,8 +290,8 @@ impl LoadableComponent for ProxmoxTfaView {
)
.with_flex_spacer()
.with_child({
- let loading = ctx.loading();
- let link = ctx.link();
+ let loading = self.loading();
+ let link = ctx.link().clone();
Button::refresh(loading).onclick(move |_| link.send_reload())
});
@@ -296,8 +304,8 @@ impl LoadableComponent for ProxmoxTfaView {
.selection(self.selection.clone())
.class("pwt-flex-fit")
.on_row_dblclick({
- let link = ctx.link();
- move |_: &mut _| link.send_message(Msg::Edit)
+ let link = ctx.link().clone();
+ move |_: &mut _| link.send_custom_message(Msg::Edit)
});
Mask::new(view).visible(self.removing).into()
}
@@ -314,8 +322,8 @@ impl LoadableComponent for ProxmoxTfaView {
TfaConfirmRemove::new(info)
.on_close(ctx.link().change_view_callback(|_| None))
.on_confirm({
- let link = ctx.link();
- move |password| link.send_message(Msg::Remove(password))
+ let link = ctx.link().clone();
+ move |password| link.send_custom_message(Msg::Remove(password))
})
.into()
}),
diff --git a/src/token_panel.rs b/src/token_panel.rs
index 031d54f..66c25db 100644
--- a/src/token_panel.rs
+++ b/src/token_panel.rs
@@ -25,7 +25,8 @@ use crate::utils::{
};
use crate::{
AuthidSelector, ConfirmButton, EditWindow, LoadableComponent, LoadableComponentContext,
- LoadableComponentLink, LoadableComponentMaster, PermissionPanel,
+ LoadableComponentMaster, LoadableComponentScope, LoadableComponentScopeExt,
+ LoadableComponentState, PermissionPanel,
};
async fn load_api_tokens() -> Result<Vec<ApiToken>, Error> {
@@ -37,7 +38,7 @@ async fn load_api_tokens() -> Result<Vec<ApiToken>, Error> {
async fn create_token(
form_ctx: FormContext,
- link: LoadableComponentLink<ProxmoxTokenView>,
+ link: LoadableComponentScope<ProxmoxTokenView>,
) -> Result<(), Error> {
let mut data = form_ctx.get_submit_data();
@@ -118,17 +119,19 @@ enum ViewState {
}
enum Msg {
- Refresh,
Remove,
Regenerate,
}
struct ProxmoxTokenView {
+ state: LoadableComponentState<ViewState>,
selection: Selection,
store: Store<ApiToken>,
columns: Rc<Vec<DataTableHeader<ApiToken>>>,
}
+crate::impl_deref_mut_property!(ProxmoxTokenView, state, LoadableComponentState<ViewState>);
+
fn token_api_url(user: &str, tokenname: &str) -> String {
format!(
"/access/users/{}/token/{}",
@@ -146,11 +149,15 @@ impl LoadableComponent for ProxmoxTokenView {
let link = ctx.link();
link.repeated_load(5000);
- let selection = Selection::new().on_select(link.callback(|_| Msg::Refresh));
+ let selection = Selection::new().on_select({
+ let link = ctx.link().clone();
+ move |_| link.send_redraw()
+ });
let store =
Store::with_extract_key(|record: &ApiToken| Key::from(record.tokenid.to_string()));
Self {
+ state: LoadableComponentState::new(),
selection,
store,
columns: columns(),
@@ -193,7 +200,7 @@ impl LoadableComponent for ProxmoxTokenView {
.confirm_message(tr!("Are you sure you want to remove the API token? \
All existing users of the token will lose access!"))
.disabled(disabled)
- .on_activate(link.callback(|_| Msg::Remove)),
+ .on_activate(link.custom_callback(|_| Msg::Remove)),
)
.with_spacer()
.with_child(
@@ -203,7 +210,7 @@ impl LoadableComponent for ProxmoxTokenView {
All existing users of the token will lose access!"
))
.disabled(disabled)
- .on_activate(link.callback(|_| Msg::Regenerate)),
+ .on_activate(link.custom_callback(|_| Msg::Regenerate)),
)
.with_spacer()
.with_child(
@@ -217,7 +224,6 @@ impl LoadableComponent for ProxmoxTokenView {
fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
match msg {
- Msg::Refresh => true,
Msg::Remove => {
let Some(record) = self.store.selected_record(&self.selection) else {
return false;
@@ -230,7 +236,7 @@ impl LoadableComponent for ProxmoxTokenView {
};
let url = token_api_url(&user, tokenname.as_str());
- let link = ctx.link();
+ let link = ctx.link().clone();
link.clone().spawn(async move {
match crate::http_delete(url, None).await {
Ok(()) => {
@@ -271,7 +277,7 @@ impl LoadableComponent for ProxmoxTokenView {
}
fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
- let link = ctx.link();
+ let link = ctx.link().clone();
DataTable::new(self.columns.clone(), self.store.clone())
.class("pwt-flex-fit")
diff --git a/src/user_panel.rs b/src/user_panel.rs
index d364df8..cbc6b49 100644
--- a/src/user_panel.rs
+++ b/src/user_panel.rs
@@ -23,8 +23,9 @@ use crate::form::delete_empty_values;
use crate::percent_encoding::percent_encode_component;
use crate::utils::{epoch_to_input_value, render_epoch_short};
use crate::{
- EditWindow, LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
- PermissionPanel, RealmSelector, SchemaValidation,
+ impl_deref_mut_property, EditWindow, LoadableComponent, LoadableComponentContext,
+ LoadableComponentMaster, LoadableComponentScopeExt, LoadableComponentState, PermissionPanel,
+ RealmSelector, SchemaValidation,
};
async fn load_user_list() -> Result<Vec<UserWithTokens>, Error> {
@@ -111,15 +112,17 @@ pub enum ViewState {
}
pub enum Msg {
- SelectionChange,
RemoveItem,
}
pub struct ProxmoxUserPanel {
+ state: LoadableComponentState<ViewState>,
store: Store<UserWithTokens>,
selection: Selection,
}
+impl_deref_mut_property!(ProxmoxUserPanel, state, LoadableComponentState<ViewState>);
+
impl LoadableComponent for ProxmoxUserPanel {
type Message = Msg;
type Properties = UserPanel;
@@ -142,17 +145,23 @@ impl LoadableComponent for ProxmoxUserPanel {
Key::from(record.user.userid.as_str())
});
- let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::SelectionChange));
+ let selection = Selection::new().on_select({
+ let link = ctx.link().clone();
+ move |_| link.send_redraw()
+ });
- Self { store, selection }
+ Self {
+ state: LoadableComponentState::new(),
+ store,
+ selection,
+ }
}
fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
match msg {
- Msg::SelectionChange => true,
Msg::RemoveItem => {
if let Some(key) = self.selection.selected_key() {
- let link = ctx.link();
+ let link = ctx.link().clone();
link.clone().spawn(async move {
if let Err(err) = delete_user(key).await {
link.show_error(tr!("Unable to delete user"), err, true);
@@ -191,7 +200,7 @@ impl LoadableComponent for ProxmoxUserPanel {
.with_child(
Button::new(tr!("Remove"))
.disabled(no_selection)
- .onclick(link.callback(|_| Msg::RemoveItem)),
+ .onclick(link.custom_callback(|_| Msg::RemoveItem)),
)
.with_spacer()
.with_child(
@@ -206,8 +215,8 @@ impl LoadableComponent for ProxmoxUserPanel {
)
.with_flex_spacer()
.with_child({
- let loading = ctx.loading();
- let link = ctx.link();
+ let loading = self.loading();
+ let link = ctx.link().clone();
Button::refresh(loading).onclick(move |_| link.send_reload())
});
@@ -215,7 +224,7 @@ impl LoadableComponent for ProxmoxUserPanel {
}
fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
- let link = ctx.link();
+ let link = ctx.link().clone();
DataTable::new(columns(), self.store.clone())
.class("pwt-flex-fill pwt-overflow-auto")
.selection(self.selection.clone())
--
2.47.3
_______________________________________________
yew-devel mailing list
yew-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/yew-devel
^ permalink raw reply related [relevance 1%]
* [pdm-devel] [PATCH datacenter-manager v3 10/12] ui: add remote update view
@ 2025-10-23 12:44 2% ` Lukas Wagner
0 siblings, 0 replies; 19+ results
From: Lukas Wagner @ 2025-10-23 12:44 UTC (permalink / raw)
To: pdm-devel
This commit adds a new view for showing a global overview about
available updates on managed remotes. The view is split in the middle.
On the left side, we display a tree view showing all remotes and nodes,
on the right side we show a list of available updates for any node
selected in the tree.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
Notes:
Chances since v2:
- Show text for collapsed remote tree item
- "All nodes up-to-date"
- "Some nodes nodes have pending updates"
- "Some nodes have pending updates, some nodes unavailable"
- ...
- Collapse single-node PVE remotes and PBS remotes into a single item
to reduce clicks needed
- Show remote and node in the header of the right-hand update list
- Some refactoring to make the code a tiny bit nicer
Changes since v1:
- made RemoteUpdateTreeMsg and UpdateTreeComp private
- format!(...) var inlining
- remove unneeded borrows
- use gloo_utils::window()
ui/src/remotes/mod.rs | 3 +
ui/src/remotes/updates.rs | 530 ++++++++++++++++++++++++++++++++++++++
2 files changed, 533 insertions(+)
create mode 100644 ui/src/remotes/updates.rs
diff --git a/ui/src/remotes/mod.rs b/ui/src/remotes/mod.rs
index 83b3331b..cce21563 100644
--- a/ui/src/remotes/mod.rs
+++ b/ui/src/remotes/mod.rs
@@ -24,6 +24,9 @@ pub use config::{create_remote, RemoteConfigPanel};
mod tasks;
pub use tasks::RemoteTaskList;
+mod updates;
+pub use updates::UpdateTree;
+
use yew::{function_component, Html};
use pwt::prelude::*;
diff --git a/ui/src/remotes/updates.rs b/ui/src/remotes/updates.rs
new file mode 100644
index 00000000..8294795a
--- /dev/null
+++ b/ui/src/remotes/updates.rs
@@ -0,0 +1,530 @@
+use std::cmp::Ordering;
+use std::ops::Deref;
+use std::pin::Pin;
+use std::rc::Rc;
+
+use futures::Future;
+use yew::virtual_dom::{Key, VComp, VNode};
+use yew::{html, Html, Properties};
+
+use pdm_api_types::remote_updates::{
+ NodeUpdateStatus, NodeUpdateSummary, RemoteUpdateStatus, UpdateSummary,
+};
+use pdm_api_types::remotes::RemoteType;
+use pwt::css::{AlignItems, FlexFit, TextAlign};
+use pwt::widget::data_table::{DataTableCellRenderArgs, DataTableCellRenderer};
+
+use proxmox_yew_comp::{
+ AptPackageManager, LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
+};
+use pwt::props::{CssBorderBuilder, CssPaddingBuilder, WidgetStyleBuilder};
+use pwt::widget::{Button, Container, Panel};
+use pwt::{
+ css,
+ css::FontColor,
+ props::{ContainerBuilder, ExtractPrimaryKey, WidgetBuilder},
+ state::{Selection, SlabTree, TreeStore},
+ tr,
+ widget::{
+ data_table::{DataTable, DataTableColumn, DataTableHeader},
+ Column, Fa, Row,
+ },
+};
+
+use crate::{get_deep_url, get_deep_url_low_level, pdm_client};
+
+#[derive(PartialEq, Properties)]
+pub struct UpdateTree {}
+
+impl UpdateTree {
+ pub fn new() -> Self {
+ yew::props!(Self {})
+ }
+}
+
+impl From<UpdateTree> for VNode {
+ fn from(value: UpdateTree) -> Self {
+ let comp = VComp::new::<LoadableComponentMaster<UpdateTreeComponent>>(Rc::new(value), None);
+ VNode::from(comp)
+ }
+}
+
+#[derive(Clone, PartialEq, Debug)]
+struct RemoteEntry {
+ remote: String,
+ ty: RemoteType,
+ number_of_failed_nodes: u32,
+ number_of_nodes: u32,
+ number_of_updatable_nodes: u32,
+ poll_status: RemoteUpdateStatus,
+}
+
+#[derive(Clone, PartialEq, Debug)]
+struct NodeEntry {
+ remote: String,
+ node: String,
+ ty: RemoteType,
+ summary: NodeUpdateSummary,
+ flat: bool,
+}
+
+#[derive(Clone, PartialEq, Debug)]
+enum UpdateTreeEntry {
+ Root,
+ Remote(RemoteEntry),
+ Node(NodeEntry),
+}
+
+impl UpdateTreeEntry {
+ fn name(&self) -> &str {
+ match &self {
+ Self::Root => "",
+ Self::Remote(data) => &data.remote,
+ Self::Node(data) => {
+ if data.flat {
+ &data.remote
+ } else {
+ &data.node
+ }
+ }
+ }
+ }
+}
+
+impl ExtractPrimaryKey for UpdateTreeEntry {
+ fn extract_key(&self) -> yew::virtual_dom::Key {
+ Key::from(match self {
+ UpdateTreeEntry::Root => "/".to_string(),
+ UpdateTreeEntry::Remote(data) => format!("/{}", data.remote),
+ UpdateTreeEntry::Node(data) => format!("/{}/{}", data.remote, data.node),
+ })
+ }
+}
+
+enum RemoteUpdateTreeMsg {
+ LoadFinished(UpdateSummary),
+ KeySelected(Option<Key>),
+ RefreshAll,
+}
+
+struct UpdateTreeComponent {
+ store: TreeStore<UpdateTreeEntry>,
+ selection: Selection,
+ selected_entry: Option<UpdateTreeEntry>,
+}
+
+fn default_sorter(a: &UpdateTreeEntry, b: &UpdateTreeEntry) -> Ordering {
+ a.name().cmp(b.name())
+}
+
+impl UpdateTreeComponent {
+ fn columns(
+ _ctx: &LoadableComponentContext<Self>,
+ store: TreeStore<UpdateTreeEntry>,
+ ) -> Rc<Vec<DataTableHeader<UpdateTreeEntry>>> {
+ Rc::new(vec![
+ DataTableColumn::new(tr!("Name"))
+ .tree_column(store)
+ .flex(1)
+ .render(|entry: &UpdateTreeEntry| {
+ let icon = match entry {
+ UpdateTreeEntry::Remote(_) => Some("server"),
+ UpdateTreeEntry::Node(_) => Some("building"),
+ _ => None,
+ };
+
+ Row::new()
+ .class(css::AlignItems::Baseline)
+ .gap(2)
+ .with_optional_child(icon.map(|icon| Fa::new(icon)))
+ .with_child(entry.name())
+ .into()
+ })
+ .sorter(default_sorter)
+ .into(),
+ DataTableColumn::new(tr!("Status"))
+ .flex(3)
+ .render_cell(DataTableCellRenderer::new(
+ move |args: &mut DataTableCellRenderArgs<UpdateTreeEntry>| match args.record() {
+ UpdateTreeEntry::Root => {
+ html!()
+ }
+ UpdateTreeEntry::Remote(remote_info) => {
+ render_remote_summary(remote_info, args.is_expanded()).into()
+ }
+ UpdateTreeEntry::Node(info) => render_node_info(info).into(),
+ },
+ ))
+ .into(),
+ ])
+ }
+}
+
+fn build_store_from_response(update_summary: UpdateSummary) -> SlabTree<UpdateTreeEntry> {
+ let mut tree = SlabTree::new();
+
+ let mut root = tree.set_root(UpdateTreeEntry::Root);
+ root.set_expanded(true);
+
+ for (remote_name, remote_summary) in update_summary.remotes.deref() {
+ if remote_summary.nodes.len() == 1 {
+ if let Some((node_name, node_summary)) = remote_summary.nodes.iter().take(1).next() {
+ root.append(UpdateTreeEntry::Node(NodeEntry {
+ remote: remote_name.clone(),
+ node: node_name.clone(),
+ ty: remote_summary.remote_type,
+ summary: node_summary.clone(),
+ flat: true,
+ }));
+
+ continue;
+ }
+ }
+
+ let mut remote_entry = root.append(UpdateTreeEntry::Remote(RemoteEntry {
+ remote: remote_name.clone(),
+ ty: remote_summary.remote_type,
+ number_of_nodes: 0,
+ number_of_updatable_nodes: 0,
+ number_of_failed_nodes: 0,
+ poll_status: remote_summary.status.clone(),
+ }));
+ remote_entry.set_expanded(false);
+
+ let number_of_nodes = remote_summary.nodes.len();
+ let mut number_of_updatable_nodes = 0;
+ let mut number_of_failed_nodes = 0;
+
+ for (node_name, node_summary) in remote_summary.nodes.deref() {
+ match node_summary.status {
+ NodeUpdateStatus::Success => {
+ if node_summary.number_of_updates > 0 {
+ number_of_updatable_nodes += 1;
+ }
+ }
+ NodeUpdateStatus::Error => {
+ number_of_failed_nodes += 1;
+ }
+ }
+
+ remote_entry.append(UpdateTreeEntry::Node(NodeEntry {
+ remote: remote_name.clone(),
+ node: node_name.clone(),
+ ty: remote_summary.remote_type,
+ summary: node_summary.clone(),
+ flat: false,
+ }));
+ }
+
+ if let UpdateTreeEntry::Remote(info) = remote_entry.record_mut() {
+ info.number_of_updatable_nodes = number_of_updatable_nodes;
+ info.number_of_nodes = number_of_nodes as u32;
+ info.number_of_failed_nodes = number_of_failed_nodes as u32;
+ }
+ }
+
+ tree
+}
+
+impl LoadableComponent for UpdateTreeComponent {
+ type Properties = UpdateTree;
+ type Message = RemoteUpdateTreeMsg;
+ type ViewState = ();
+
+ fn create(ctx: &LoadableComponentContext<Self>) -> Self {
+ let link = ctx.link();
+
+ let store = TreeStore::new().view_root(false);
+ store.set_sorter(default_sorter);
+
+ link.repeated_load(5000);
+
+ let selection = Selection::new().on_select(link.callback(|selection: Selection| {
+ RemoteUpdateTreeMsg::KeySelected(selection.selected_key())
+ }));
+
+ Self {
+ store: store.clone(),
+ selection,
+ selected_entry: None,
+ }
+ }
+
+ fn load(
+ &self,
+ ctx: &LoadableComponentContext<Self>,
+ ) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
+ let link = ctx.link().clone();
+
+ Box::pin(async move {
+ let client = pdm_client();
+
+ let updates = client.remote_update_summary().await?;
+ link.send_message(Self::Message::LoadFinished(updates));
+
+ Ok(())
+ })
+ }
+
+ fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
+ match msg {
+ Self::Message::LoadFinished(updates) => {
+ let data = build_store_from_response(updates);
+ self.store.write().update_root_tree(data);
+ self.store.set_sorter(default_sorter);
+
+ return true;
+ }
+ Self::Message::KeySelected(key) => {
+ if let Some(key) = key {
+ let read_guard = self.store.read();
+ let node_ref = read_guard.lookup_node(&key).unwrap();
+ let record = node_ref.record();
+
+ self.selected_entry = Some(record.clone());
+
+ return true;
+ }
+ }
+ Self::Message::RefreshAll => {
+ let link = ctx.link();
+
+ link.clone().spawn(async move {
+ let client = pdm_client();
+
+ match client.refresh_remote_update_summary().await {
+ Ok(upid) => {
+ link.show_task_progres(upid.to_string());
+ }
+ Err(err) => {
+ link.show_error(tr!("Could not refresh update status."), err, false);
+ }
+ }
+ });
+ }
+ }
+
+ false
+ }
+
+ fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> yew::Html {
+ Container::new()
+ .class("pwt-content-spacer")
+ .class(FlexFit)
+ .class("pwt-flex-direction-row")
+ .with_child(self.render_update_tree_panel(ctx))
+ .with_child(self.render_update_list_panel(ctx))
+ .into()
+ }
+}
+
+impl UpdateTreeComponent {
+ fn render_update_tree_panel(&self, ctx: &LoadableComponentContext<Self>) -> Panel {
+ let table = DataTable::new(Self::columns(ctx, self.store.clone()), self.store.clone())
+ .selection(self.selection.clone())
+ .striped(false)
+ .borderless(true)
+ .show_header(false)
+ .class(css::FlexFit);
+
+ let refresh_all_button = Button::new(tr!("Refresh all")).on_activate({
+ let link = ctx.link().clone();
+ move |_| {
+ link.send_message(RemoteUpdateTreeMsg::RefreshAll);
+ }
+ });
+
+ let title: Html = Row::new()
+ .gap(2)
+ .class(AlignItems::Baseline)
+ .with_child(Fa::new("refresh"))
+ .with_child(tr!("Remote System Updates"))
+ .into();
+
+ Panel::new()
+ .min_width(500)
+ .title(title)
+ .with_tool(refresh_all_button)
+ .style("flex", "1 1 0")
+ .class(FlexFit)
+ .border(true)
+ .with_child(table)
+ }
+
+ fn render_update_list_panel(&self, ctx: &LoadableComponentContext<Self>) -> Panel {
+ match &self.selected_entry {
+ Some(UpdateTreeEntry::Node(NodeEntry {
+ remote, node, ty, ..
+ })) => {
+ let title: Html = Row::new()
+ .gap(2)
+ .class(AlignItems::Baseline)
+ .with_child(Fa::new("list"))
+ .with_child(tr!("Update List - {} ({})", remote, node))
+ .into();
+
+ let base_url = format!("/{ty}/remotes/{remote}/nodes/{node}/apt",);
+ let task_base_url = format!("/{ty}/remotes/{remote}/tasks");
+
+ let apt = AptPackageManager::new()
+ .base_url(base_url)
+ .task_base_url(task_base_url)
+ .enable_upgrade(true)
+ .on_upgrade({
+ let remote = remote.clone();
+ let link = ctx.link().clone();
+ let remote = remote.clone();
+ let node = node.clone();
+ let ty = *ty;
+
+ move |_| match ty {
+ RemoteType::Pve => {
+ let id = format!("node/{node}::apt");
+ if let Some(url) = get_deep_url(link.yew_link(), &remote, None, &id)
+ {
+ let _ = gloo_utils::window().open_with_url(&url.href());
+ }
+ }
+ RemoteType::Pbs => {
+ let hash = "#pbsServerAdministration:updates";
+ if let Some(url) =
+ get_deep_url_low_level(link.yew_link(), &remote, None, &hash)
+ {
+ let _ = gloo_utils::window().open_with_url(&url.href());
+ }
+ }
+ }
+ });
+
+ Panel::new()
+ .class(FlexFit)
+ .title(title)
+ .border(true)
+ .min_width(500)
+ .with_child(apt)
+ .style("flex", "1 1 0")
+ }
+ _ => {
+ let title: Html = Row::new()
+ .gap(2)
+ .class(AlignItems::Baseline)
+ .with_child(Fa::new("list"))
+ .with_child(tr!("Update List"))
+ .into();
+
+ let header = tr!("No node selected");
+ let msg = tr!("Select a node to show available updates.");
+
+ let select_node_msg = Column::new()
+ .class(FlexFit)
+ .padding(2)
+ .class(AlignItems::Center)
+ .class(TextAlign::Center)
+ .with_child(html! {<h1 class="pwt-font-headline-medium">{header}</h1>})
+ .with_child(Container::new().with_child(msg));
+
+ Panel::new()
+ .class(FlexFit)
+ .title(title)
+ .border(true)
+ .min_width(500)
+ .with_child(select_node_msg)
+ .style("flex", "1 1 0")
+ }
+ }
+ }
+}
+
+fn render_remote_summary(entry: &RemoteEntry, expanded: bool) -> Row {
+ let mut row = Row::new().class(css::AlignItems::Baseline).gap(2);
+ match entry.poll_status {
+ RemoteUpdateStatus::Success => {
+ if !expanded {
+ let up_to_date_nodes = entry.number_of_nodes
+ - entry.number_of_updatable_nodes
+ - entry.number_of_failed_nodes;
+
+ let text = if entry.number_of_nodes == up_to_date_nodes {
+ row = row.with_child(render_remote_summary_icon(RemoteSummaryIcon::UpToDate));
+ tr!("All nodes up-to-date")
+ } else if entry.number_of_updatable_nodes > 0 {
+ row = row.with_child(render_remote_summary_icon(RemoteSummaryIcon::Updatable));
+
+ if entry.number_of_failed_nodes > 0 {
+ row = row.with_child(render_remote_summary_icon(RemoteSummaryIcon::Error));
+ // NOTE: This 'summary' line is only shown for remotes with multiple nodes,
+ // so we don't really have to consider the singular form of 'x out of y
+ // nodes'
+ tr!("Some nodes have pending updates, some nodes unavailable")
+ } else {
+ tr!("Some nodes have pending updates")
+ }
+ } else if entry.number_of_failed_nodes > 0 {
+ row = row.with_child(render_remote_summary_icon(RemoteSummaryIcon::Error));
+ tr!("Some nodes unavailable")
+ } else {
+ String::new()
+ };
+
+ row = row.with_child(text);
+ }
+ }
+ RemoteUpdateStatus::Error => {
+ row = row.with_child(render_remote_summary_icon(RemoteSummaryIcon::Error));
+ row = row.with_child(tr!("Could not connect to remote"));
+ }
+ RemoteUpdateStatus::Unknown => {
+ row = row.with_child(render_remote_summary_icon(RemoteSummaryIcon::Unknown));
+ row = row.with_child(tr!("Update status unknown"));
+ }
+ }
+
+ row
+}
+
+fn render_node_info(entry: &NodeEntry) -> Row {
+ let (icon, text) = if entry.summary.status == NodeUpdateStatus::Error {
+ let icon = render_remote_summary_icon(RemoteSummaryIcon::Error);
+ let text = if let Some(status) = &entry.summary.status_message {
+ tr!("Failed to retrieve update status: {}", status)
+ } else {
+ tr!("Unknown error")
+ };
+
+ (icon, text)
+ } else if entry.summary.number_of_updates > 0 {
+ (
+ render_remote_summary_icon(RemoteSummaryIcon::Updatable),
+ tr!("One update pending" | "{n} updates pending" % entry.summary.number_of_updates),
+ )
+ } else {
+ (
+ render_remote_summary_icon(RemoteSummaryIcon::UpToDate),
+ tr!("Up-to-date"),
+ )
+ };
+
+ Row::new()
+ .class(css::AlignItems::Baseline)
+ .gap(2)
+ .with_child(icon)
+ .with_child(text)
+}
+
+enum RemoteSummaryIcon {
+ UpToDate,
+ Updatable,
+ Error,
+ Unknown,
+}
+
+fn render_remote_summary_icon(icon: RemoteSummaryIcon) -> Fa {
+ let (icon_class, icon_scheme) = match icon {
+ RemoteSummaryIcon::UpToDate => ("check", FontColor::Success),
+ RemoteSummaryIcon::Error => ("times-circle", FontColor::Error),
+ RemoteSummaryIcon::Updatable => ("refresh", FontColor::Primary),
+ RemoteSummaryIcon::Unknown => ("question-circle-o", FontColor::Primary),
+ };
+
+ Fa::new(icon_class).class(icon_scheme)
+}
--
2.47.3
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply related [relevance 2%]
* [pdm-devel] [PATCH datacenter-manager v2 11/13] ui: add remote update view
@ 2025-10-17 12:10 2% ` Lukas Wagner
0 siblings, 0 replies; 19+ results
From: Lukas Wagner @ 2025-10-17 12:10 UTC (permalink / raw)
To: pdm-devel
This commit adds a new view for showing a global overview about
available updates on managed remotes. The view is split in the middle.
On the left side, we display a tree view showing all remotes and nodes,
on the right side we show a list of available updates for any node
selected in the tree.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
Reviewed-by: Shannon Sterz <s.sterz@proxmox.com>
Tested-by: Shannon Sterz <s.sterz@proxmox.com>
---
Notes:
Changes since v1:
- made RemoteUpdateTreeMsg and UpdateTreeComp private
- format!(...) var inlining
- remove unneeded borrows
- use gloo_utils::window()
ui/src/remotes/mod.rs | 3 +
ui/src/remotes/updates.rs | 530 ++++++++++++++++++++++++++++++++++++++
2 files changed, 533 insertions(+)
create mode 100644 ui/src/remotes/updates.rs
diff --git a/ui/src/remotes/mod.rs b/ui/src/remotes/mod.rs
index 83b3331b..cce21563 100644
--- a/ui/src/remotes/mod.rs
+++ b/ui/src/remotes/mod.rs
@@ -24,6 +24,9 @@ pub use config::{create_remote, RemoteConfigPanel};
mod tasks;
pub use tasks::RemoteTaskList;
+mod updates;
+pub use updates::UpdateTree;
+
use yew::{function_component, Html};
use pwt::prelude::*;
diff --git a/ui/src/remotes/updates.rs b/ui/src/remotes/updates.rs
new file mode 100644
index 00000000..6a7ecfb4
--- /dev/null
+++ b/ui/src/remotes/updates.rs
@@ -0,0 +1,530 @@
+use std::cmp::Ordering;
+use std::ops::Deref;
+use std::pin::Pin;
+use std::rc::Rc;
+
+use futures::Future;
+use yew::virtual_dom::{Key, VComp, VNode};
+use yew::{html, Html, Properties};
+
+use pdm_api_types::remote_updates::{
+ NodeUpdateStatus, NodeUpdateSummary, RemoteUpdateStatus, UpdateSummary,
+};
+use pdm_api_types::remotes::RemoteType;
+use pwt::css::{AlignItems, FlexFit, TextAlign};
+use pwt::widget::data_table::{DataTableCellRenderArgs, DataTableCellRenderer};
+
+use proxmox_yew_comp::{
+ AptPackageManager, LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
+};
+use pwt::props::{CssBorderBuilder, CssMarginBuilder, CssPaddingBuilder, WidgetStyleBuilder};
+use pwt::widget::{Button, Container, Panel, Tooltip};
+use pwt::{
+ css,
+ css::FontColor,
+ props::{ContainerBuilder, ExtractPrimaryKey, WidgetBuilder},
+ state::{Selection, SlabTree, TreeStore},
+ tr,
+ widget::{
+ data_table::{DataTable, DataTableColumn, DataTableHeader},
+ Column, Fa, Row,
+ },
+};
+
+use crate::{get_deep_url, get_deep_url_low_level, pdm_client};
+
+#[derive(PartialEq, Properties)]
+pub struct UpdateTree {}
+
+impl UpdateTree {
+ pub fn new() -> Self {
+ yew::props!(Self {})
+ }
+}
+
+impl From<UpdateTree> for VNode {
+ fn from(value: UpdateTree) -> Self {
+ let comp = VComp::new::<LoadableComponentMaster<UpdateTreeComponent>>(Rc::new(value), None);
+ VNode::from(comp)
+ }
+}
+
+#[derive(Clone, PartialEq, Debug)]
+struct RemoteEntry {
+ remote: String,
+ ty: RemoteType,
+ number_of_failed_nodes: u32,
+ number_of_nodes: u32,
+ number_of_updatable_nodes: u32,
+ poll_status: RemoteUpdateStatus,
+}
+
+#[derive(Clone, PartialEq, Debug)]
+struct NodeEntry {
+ remote: String,
+ node: String,
+ ty: RemoteType,
+ summary: NodeUpdateSummary,
+}
+
+#[derive(Clone, PartialEq, Debug)]
+enum UpdateTreeEntry {
+ Root,
+ Remote(RemoteEntry),
+ Node(NodeEntry),
+}
+
+impl UpdateTreeEntry {
+ fn name(&self) -> &str {
+ match &self {
+ Self::Root => "",
+ Self::Remote(data) => &data.remote,
+ Self::Node(data) => &data.node,
+ }
+ }
+}
+
+impl ExtractPrimaryKey for UpdateTreeEntry {
+ fn extract_key(&self) -> yew::virtual_dom::Key {
+ Key::from(match self {
+ UpdateTreeEntry::Root => "/".to_string(),
+ UpdateTreeEntry::Remote(data) => format!("/{}", data.remote),
+ UpdateTreeEntry::Node(data) => format!("/{}/{}", data.remote, data.node),
+ })
+ }
+}
+
+enum RemoteUpdateTreeMsg {
+ LoadFinished(UpdateSummary),
+ KeySelected(Option<Key>),
+ RefreshAll,
+}
+
+struct UpdateTreeComponent {
+ store: TreeStore<UpdateTreeEntry>,
+ selection: Selection,
+ selected_entry: Option<UpdateTreeEntry>,
+}
+
+fn default_sorter(a: &UpdateTreeEntry, b: &UpdateTreeEntry) -> Ordering {
+ a.name().cmp(b.name())
+}
+
+impl UpdateTreeComponent {
+ fn columns(
+ _ctx: &LoadableComponentContext<Self>,
+ store: TreeStore<UpdateTreeEntry>,
+ ) -> Rc<Vec<DataTableHeader<UpdateTreeEntry>>> {
+ Rc::new(vec![
+ DataTableColumn::new(tr!("Name"))
+ .tree_column(store)
+ .width("200px")
+ .render(|entry: &UpdateTreeEntry| {
+ let icon = match entry {
+ UpdateTreeEntry::Remote(_) => Some("server"),
+ UpdateTreeEntry::Node(_) => Some("building"),
+ _ => None,
+ };
+
+ Row::new()
+ .class(css::AlignItems::Baseline)
+ .gap(2)
+ .with_optional_child(icon.map(|icon| Fa::new(icon)))
+ .with_child(entry.name())
+ .into()
+ })
+ .sorter(default_sorter)
+ .into(),
+ DataTableColumn::new(tr!("Status"))
+ .render_cell(DataTableCellRenderer::new(
+ move |args: &mut DataTableCellRenderArgs<UpdateTreeEntry>| {
+ let mut row = Row::new().class(css::AlignItems::Baseline).gap(2);
+
+ match args.record() {
+ UpdateTreeEntry::Remote(remote_info) => match remote_info.poll_status {
+ RemoteUpdateStatus::Unknown => {
+ row = row.with_child(render_remote_status_icon(
+ RemoteUpdateStatus::Unknown,
+ ));
+ }
+ RemoteUpdateStatus::Success => {
+ if !args.is_expanded() {
+ let up_to_date_nodes = remote_info.number_of_nodes
+ - remote_info.number_of_updatable_nodes
+ - remote_info.number_of_failed_nodes;
+
+ if up_to_date_nodes > 0 {
+ row = row.with_child(render_remote_summary_counter(
+ up_to_date_nodes,
+ RemoteSummaryIcon::UpToDate,
+ ));
+ }
+
+ if remote_info.number_of_updatable_nodes > 0 {
+ row = row.with_child(render_remote_summary_counter(
+ remote_info.number_of_updatable_nodes,
+ RemoteSummaryIcon::Updatable,
+ ));
+ }
+
+ if remote_info.number_of_failed_nodes > 0 {
+ row = row.with_child(render_remote_summary_counter(
+ remote_info.number_of_failed_nodes,
+ RemoteSummaryIcon::Error,
+ ));
+ }
+ }
+ }
+ RemoteUpdateStatus::Error => {
+ row = row.with_child(render_remote_status_icon(
+ RemoteUpdateStatus::Error,
+ ));
+ }
+ },
+ UpdateTreeEntry::Node(info) => {
+ if info.summary.status == NodeUpdateStatus::Error {
+ row = row.with_child(
+ Fa::new("times-circle").class(FontColor::Error),
+ );
+ row = row.with_child(tr!("Could not get update info"));
+ } else if info.summary.number_of_updates > 0 {
+ row = row
+ .with_child(Fa::new("refresh").class(FontColor::Primary));
+ row = row.with_child(tr!(
+ "{0} updates are available",
+ info.summary.number_of_updates
+ ));
+ } else {
+ row =
+ row.with_child(Fa::new("check").class(FontColor::Success));
+ row = row.with_child(tr!("Up-to-date"));
+ }
+ }
+ _ => {}
+ }
+
+ row.into()
+ },
+ ))
+ .into(),
+ ])
+ }
+}
+
+fn build_store_from_response(update_summary: UpdateSummary) -> SlabTree<UpdateTreeEntry> {
+ let mut tree = SlabTree::new();
+
+ let mut root = tree.set_root(UpdateTreeEntry::Root);
+ root.set_expanded(true);
+
+ for (remote_name, remote_summary) in update_summary.remotes.deref() {
+ let mut remote_entry = root.append(UpdateTreeEntry::Remote(RemoteEntry {
+ remote: remote_name.clone(),
+ ty: remote_summary.remote_type,
+ number_of_nodes: 0,
+ number_of_updatable_nodes: 0,
+ number_of_failed_nodes: 0,
+ poll_status: remote_summary.status.clone(),
+ }));
+ remote_entry.set_expanded(false);
+
+ let number_of_nodes = remote_summary.nodes.len();
+ let mut number_of_updatable_nodes = 0;
+ let mut number_of_failed_nodes = 0;
+
+ for (node_name, node_summary) in remote_summary.nodes.deref() {
+ match node_summary.status {
+ NodeUpdateStatus::Success => {
+ if node_summary.number_of_updates > 0 {
+ number_of_updatable_nodes += 1;
+ }
+ }
+ NodeUpdateStatus::Error => {
+ number_of_failed_nodes += 1;
+ }
+ }
+
+ remote_entry.append(UpdateTreeEntry::Node(NodeEntry {
+ remote: remote_name.clone(),
+ node: node_name.clone(),
+ ty: remote_summary.remote_type,
+ summary: node_summary.clone(),
+ }));
+ }
+
+ if let UpdateTreeEntry::Remote(info) = remote_entry.record_mut() {
+ info.number_of_updatable_nodes = number_of_updatable_nodes;
+ info.number_of_nodes = number_of_nodes as u32;
+ info.number_of_failed_nodes = number_of_failed_nodes as u32;
+ }
+ }
+
+ tree
+}
+
+impl LoadableComponent for UpdateTreeComponent {
+ type Properties = UpdateTree;
+ type Message = RemoteUpdateTreeMsg;
+ type ViewState = ();
+
+ fn create(ctx: &LoadableComponentContext<Self>) -> Self {
+ let link = ctx.link();
+
+ let store = TreeStore::new().view_root(false);
+ store.set_sorter(default_sorter);
+
+ link.repeated_load(5000);
+
+ let selection = Selection::new().on_select(link.callback(|selection: Selection| {
+ RemoteUpdateTreeMsg::KeySelected(selection.selected_key())
+ }));
+
+ Self {
+ store: store.clone(),
+ selection,
+ selected_entry: None,
+ }
+ }
+
+ fn load(
+ &self,
+ ctx: &LoadableComponentContext<Self>,
+ ) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
+ let link = ctx.link().clone();
+
+ Box::pin(async move {
+ let client = pdm_client();
+
+ let updates = client.remote_update_summary().await?;
+ link.send_message(Self::Message::LoadFinished(updates));
+
+ Ok(())
+ })
+ }
+
+ fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
+ match msg {
+ Self::Message::LoadFinished(updates) => {
+ let data = build_store_from_response(updates);
+ self.store.write().update_root_tree(data);
+ self.store.set_sorter(default_sorter);
+
+ return true;
+ }
+ Self::Message::KeySelected(key) => {
+ if let Some(key) = key {
+ let read_guard = self.store.read();
+ let node_ref = read_guard.lookup_node(&key).unwrap();
+ let record = node_ref.record();
+
+ self.selected_entry = Some(record.clone());
+
+ return true;
+ }
+ }
+ Self::Message::RefreshAll => {
+ let link = ctx.link();
+
+ link.clone().spawn(async move {
+ let client = pdm_client();
+
+ match client.refresh_remote_update_summary().await {
+ Ok(upid) => {
+ link.show_task_progres(upid.to_string());
+ }
+ Err(err) => {
+ link.show_error(tr!("Could not refresh update status."), err, false);
+ }
+ }
+ });
+ }
+ }
+
+ false
+ }
+
+ fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> yew::Html {
+ Container::new()
+ .class("pwt-content-spacer")
+ .class(FlexFit)
+ .class("pwt-flex-direction-row")
+ .with_child(self.render_update_tree_panel(ctx))
+ .with_child(self.render_update_list_panel(ctx))
+ .into()
+ }
+}
+
+impl UpdateTreeComponent {
+ fn render_update_tree_panel(&self, ctx: &LoadableComponentContext<Self>) -> Panel {
+ let table = DataTable::new(Self::columns(ctx, self.store.clone()), self.store.clone())
+ .selection(self.selection.clone())
+ .striped(false)
+ .borderless(true)
+ .show_header(false)
+ .class(css::FlexFit);
+
+ let refresh_all_button = Button::new(tr!("Refresh all")).on_activate({
+ let link = ctx.link().clone();
+ move |_| {
+ link.send_message(RemoteUpdateTreeMsg::RefreshAll);
+ }
+ });
+
+ let title: Html = Row::new()
+ .gap(2)
+ .class(AlignItems::Baseline)
+ .with_child(Fa::new("refresh"))
+ .with_child(tr!("Remote System Updates"))
+ .into();
+
+ Panel::new()
+ .min_width(500)
+ .title(title)
+ .with_tool(refresh_all_button)
+ .style("flex", "1 1 0")
+ .class(FlexFit)
+ .border(true)
+ .with_child(table)
+ }
+
+ fn render_update_list_panel(&self, ctx: &LoadableComponentContext<Self>) -> Panel {
+ let title: Html = Row::new()
+ .gap(2)
+ .class(AlignItems::Baseline)
+ .with_child(Fa::new("list"))
+ .with_child(tr!("Update List"))
+ .into();
+
+ match &self.selected_entry {
+ // (Some(remote), Some(remote_type), Some(node)) => {
+ Some(UpdateTreeEntry::Node(NodeEntry {
+ remote, node, ty, ..
+ })) => {
+ let base_url = format!("/{ty}/remotes/{remote}/nodes/{node}/apt",);
+ let task_base_url = format!("/{ty}/remotes/{remote}/tasks");
+
+ let apt = AptPackageManager::new()
+ .base_url(base_url)
+ .task_base_url(task_base_url)
+ .enable_upgrade(true)
+ .on_upgrade({
+ let remote = remote.clone();
+ let link = ctx.link().clone();
+ let remote = remote.clone();
+ let node = node.clone();
+ let ty = *ty;
+
+ move |_| match ty {
+ RemoteType::Pve => {
+ let id = format!("node/{node}::apt");
+ if let Some(url) = get_deep_url(link.yew_link(), &remote, None, &id)
+ {
+ let _ = gloo_utils::window().open_with_url(&url.href());
+ }
+ }
+ RemoteType::Pbs => {
+ let hash = "#pbsServerAdministration:updates";
+ if let Some(url) =
+ get_deep_url_low_level(link.yew_link(), &remote, None, &hash)
+ {
+ let _ = gloo_utils::window().open_with_url(&url.href());
+ }
+ }
+ }
+ });
+
+ Panel::new()
+ .class(FlexFit)
+ .title(title)
+ .border(true)
+ .min_width(500)
+ .with_child(apt)
+ .style("flex", "1 1 0")
+ }
+ _ => {
+ let header = tr!("No node selected");
+ let msg = tr!("Select a node to show available updates.");
+
+ let select_node_msg = Column::new()
+ .class(FlexFit)
+ .padding(2)
+ .class(AlignItems::Center)
+ .class(TextAlign::Center)
+ .with_child(html! {<h1 class="pwt-font-headline-medium">{header}</h1>})
+ .with_child(Container::new().with_child(msg));
+
+ Panel::new()
+ .class(FlexFit)
+ .title(title)
+ .border(true)
+ .min_width(500)
+ .with_child(select_node_msg)
+ .style("flex", "1 1 0")
+ }
+ }
+ }
+}
+
+enum RemoteSummaryIcon {
+ UpToDate,
+ Updatable,
+ Error,
+}
+
+fn render_remote_summary_counter(count: u32, task_class: RemoteSummaryIcon) -> Html {
+ let (icon_class, icon_scheme, state_text) = match task_class {
+ RemoteSummaryIcon::UpToDate => (
+ "check",
+ FontColor::Success,
+ tr!("One node is up-to-date." | "{n} nodes are up-to-date." % count),
+ ),
+ RemoteSummaryIcon::Error => (
+ "times-circle",
+ FontColor::Error,
+ tr!("Failed to retrieve update info for one node."
+ | "Failed to retrieve update info for {n} nodes." % count),
+ ),
+ RemoteSummaryIcon::Updatable => (
+ "refresh",
+ FontColor::Primary,
+ tr!("One node has updates available." | "{n} nodes have updates available." % count),
+ ),
+ };
+
+ let icon = Fa::new(icon_class).margin_end(3).class(icon_scheme);
+
+ Tooltip::new(
+ Container::from_tag("span")
+ .with_child(icon)
+ .with_child(count)
+ .margin_end(5),
+ )
+ .tip(state_text)
+ .into()
+}
+
+fn render_remote_status_icon(task_class: RemoteUpdateStatus) -> Html {
+ let (icon_class, icon_scheme, state_text) = match task_class {
+ RemoteUpdateStatus::Success => (
+ "check",
+ FontColor::Success,
+ tr!("All nodes of this remote are up-to-date."),
+ ),
+ RemoteUpdateStatus::Error => (
+ "times-circle",
+ FontColor::Error,
+ tr!("Could not retrieve update info for remote."),
+ ),
+ RemoteUpdateStatus::Unknown => (
+ "question-circle-o",
+ FontColor::Primary,
+ tr!("The update status is not known."),
+ ),
+ };
+
+ let icon = Fa::new(icon_class).margin_end(3).class(icon_scheme);
+
+ Tooltip::new(Container::from_tag("span").with_child(icon).margin_end(5))
+ .tip(state_text)
+ .into()
+}
--
2.47.3
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply related [relevance 2%]
* Re: [pdm-devel] [PATCH proxmox-datacenter-manager 11/12] ui: add remote update view
2025-10-15 12:47 2% ` [pdm-devel] [PATCH proxmox-datacenter-manager 11/12] ui: add " Lukas Wagner
@ 2025-10-17 10:15 0% ` Shannon Sterz
0 siblings, 0 replies; 19+ results
From: Shannon Sterz @ 2025-10-17 10:15 UTC (permalink / raw)
To: Lukas Wagner; +Cc: Proxmox Datacenter Manager development discussion
On Wed Oct 15, 2025 at 2:47 PM CEST, Lukas Wagner wrote:
> This commit adds a new view for showing a global overview about
> available updates on managed remotes. Thew view is split in the middle.
nit: typo here, should be "The" not "Thew"
> On the left side, we display a tree view showing all remotes and nodes,
> on the right side we show a list of available updates for any node
> selected in the tree.
>
> Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
> ---
> ui/src/remotes/mod.rs | 3 +
> ui/src/remotes/updates.rs | 531 ++++++++++++++++++++++++++++++++++++++
> 2 files changed, 534 insertions(+)
> create mode 100644 ui/src/remotes/updates.rs
>
> diff --git a/ui/src/remotes/mod.rs b/ui/src/remotes/mod.rs
> index 83b3331b..cce21563 100644
> --- a/ui/src/remotes/mod.rs
> +++ b/ui/src/remotes/mod.rs
> @@ -24,6 +24,9 @@ pub use config::{create_remote, RemoteConfigPanel};
> mod tasks;
> pub use tasks::RemoteTaskList;
>
> +mod updates;
> +pub use updates::UpdateTree;
> +
> use yew::{function_component, Html};
>
> use pwt::prelude::*;
> diff --git a/ui/src/remotes/updates.rs b/ui/src/remotes/updates.rs
> new file mode 100644
> index 00000000..1a2e9e25
> --- /dev/null
> +++ b/ui/src/remotes/updates.rs
> @@ -0,0 +1,531 @@
> +use std::cmp::Ordering;
> +use std::ops::Deref;
> +use std::pin::Pin;
> +use std::rc::Rc;
> +
> +use futures::Future;
> +use yew::virtual_dom::{Key, VComp, VNode};
> +use yew::{html, Html, Properties};
> +
> +use pdm_api_types::remote_updates::{
> + NodeUpdateStatus, NodeUpdateSummary, RemoteUpdateStatus, UpdateSummary,
> +};
> +use pdm_api_types::remotes::RemoteType;
> +use pwt::css::{AlignItems, FlexFit, TextAlign};
> +use pwt::widget::data_table::{DataTableCellRenderArgs, DataTableCellRenderer};
> +
> +use proxmox_yew_comp::{
> + AptPackageManager, LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
> +};
> +use pwt::props::{CssBorderBuilder, CssMarginBuilder, CssPaddingBuilder, WidgetStyleBuilder};
> +use pwt::widget::{Button, Container, Panel, Tooltip};
> +use pwt::{
> + css,
> + css::FontColor,
> + props::{ContainerBuilder, ExtractPrimaryKey, WidgetBuilder},
> + state::{Selection, SlabTree, TreeStore},
> + tr,
> + widget::{
> + data_table::{DataTable, DataTableColumn, DataTableHeader},
> + Column, Fa, Row,
> + },
> +};
> +
> +use crate::{get_deep_url, get_deep_url_low_level, pdm_client};
> +
> +#[derive(PartialEq, Properties)]
> +pub struct UpdateTree {}
> +
> +impl UpdateTree {
> + pub fn new() -> Self {
> + yew::props!(Self {})
> + }
> +}
> +
> +impl From<UpdateTree> for VNode {
> + fn from(value: UpdateTree) -> Self {
> + let comp = VComp::new::<LoadableComponentMaster<UpdateTreeComponent>>(Rc::new(value), None);
> + VNode::from(comp)
> + }
> +}
> +
> +#[derive(Clone, PartialEq, Debug)]
> +struct RemoteEntry {
> + remote: String,
> + ty: RemoteType,
> + number_of_failed_nodes: u32,
> + number_of_nodes: u32,
> + number_of_updatable_nodes: u32,
> + poll_status: RemoteUpdateStatus,
> +}
> +
> +#[derive(Clone, PartialEq, Debug)]
> +struct NodeEntry {
> + remote: String,
> + node: String,
> + ty: RemoteType,
> + summary: NodeUpdateSummary,
> +}
> +
> +#[derive(Clone, PartialEq, Debug)]
> +enum UpdateTreeEntry {
> + Root,
> + Remote(RemoteEntry),
> + Node(NodeEntry),
> +}
> +
> +impl UpdateTreeEntry {
> + fn name(&self) -> &str {
> + match &self {
> + Self::Root => "",
> + Self::Remote(data) => &data.remote,
> + Self::Node(data) => &data.node,
> + }
> + }
> +}
> +
> +impl ExtractPrimaryKey for UpdateTreeEntry {
> + fn extract_key(&self) -> yew::virtual_dom::Key {
> + Key::from(match self {
> + UpdateTreeEntry::Root => "/".to_string(),
> + UpdateTreeEntry::Remote(data) => format!("/{}", data.remote),
> + UpdateTreeEntry::Node(data) => format!("/{}/{}", data.remote, data.node),
> + })
> + }
> +}
> +
> +pub enum RemoteUpdateTreeMsg {
> + LoadFinished(UpdateSummary),
> + KeySelected(Option<Key>),
> + RefreshAll,
> +}
i know this is pre-existing and also a theme throughout the yew stuff,
but i'd call making message enums public an anti-pattern. there isn't
really a point to having them outside of the component where they
matter. imo, the `pub` here should be dropped.
> +
> +pub struct UpdateTreeComponent {
> + store: TreeStore<UpdateTreeEntry>,
> + selection: Selection,
> + selected_entry: Option<UpdateTreeEntry>,
> +}
> +
> +fn default_sorter(a: &UpdateTreeEntry, b: &UpdateTreeEntry) -> Ordering {
> + a.name().cmp(b.name())
> +}
> +
> +impl UpdateTreeComponent {
> + fn columns(
> + _ctx: &LoadableComponentContext<Self>,
> + store: TreeStore<UpdateTreeEntry>,
> + ) -> Rc<Vec<DataTableHeader<UpdateTreeEntry>>> {
> + Rc::new(vec![
> + DataTableColumn::new(tr!("Name"))
> + .tree_column(store)
> + .width("200px")
> + .render(|entry: &UpdateTreeEntry| {
> + let icon = match entry {
> + UpdateTreeEntry::Remote(_) => Some("server"),
> + UpdateTreeEntry::Node(_) => Some("building"),
> + _ => None,
> + };
> +
> + Row::new()
> + .class(css::AlignItems::Baseline)
> + .gap(2)
> + .with_optional_child(icon.map(|icon| Fa::new(icon)))
> + .with_child(entry.name())
> + .into()
> + })
> + .sorter(default_sorter)
> + .into(),
> + DataTableColumn::new(tr!("Status"))
> + .render_cell(DataTableCellRenderer::new(
> + move |args: &mut DataTableCellRenderArgs<UpdateTreeEntry>| {
> + let mut row = Row::new().class(css::AlignItems::Baseline).gap(2);
> +
> + match args.record() {
> + UpdateTreeEntry::Remote(remote_info) => match remote_info.poll_status {
> + RemoteUpdateStatus::Unknown => {
> + row = row.with_child(render_remote_status_icon(
> + RemoteUpdateStatus::Unknown,
> + ));
> + }
> + RemoteUpdateStatus::Success => {
> + if !args.is_expanded() {
> + let up_to_date_nodes = remote_info.number_of_nodes
> + - remote_info.number_of_updatable_nodes
> + - remote_info.number_of_failed_nodes;
> +
> + if up_to_date_nodes > 0 {
> + row = row.with_child(render_remote_summary_counter(
> + up_to_date_nodes,
> + RemoteSummaryIcon::UpToDate,
> + ));
> + }
> +
> + if remote_info.number_of_updatable_nodes > 0 {
> + row = row.with_child(render_remote_summary_counter(
> + remote_info.number_of_updatable_nodes,
> + RemoteSummaryIcon::Updatable,
> + ));
> + }
> +
> + if remote_info.number_of_failed_nodes > 0 {
> + row = row.with_child(render_remote_summary_counter(
> + remote_info.number_of_failed_nodes,
> + RemoteSummaryIcon::Error,
> + ));
> + }
> + }
> + }
> + RemoteUpdateStatus::Error => {
> + row = row.with_child(render_remote_status_icon(
> + RemoteUpdateStatus::Error,
> + ));
> + }
> + },
> + UpdateTreeEntry::Node(info) => {
> + if info.summary.status == NodeUpdateStatus::Error {
> + row = row.with_child(
> + Fa::new("times-circle").class(FontColor::Error),
> + );
> + row = row.with_child(tr!("Could not get update info"));
> + } else if info.summary.number_of_updates > 0 {
> + row = row
> + .with_child(Fa::new("refresh").class(FontColor::Primary));
> + row = row.with_child(tr!(
> + "{0} updates are available",
> + info.summary.number_of_updates
> + ));
> + } else {
> + row =
> + row.with_child(Fa::new("check").class(FontColor::Success));
> + row = row.with_child(tr!("Up-to-date"));
> + }
> + }
> + _ => {}
> + }
> +
> + row.into()
> + },
> + ))
> + .into(),
> + ])
> + }
> +}
> +
> +fn build_store_from_response(update_summary: UpdateSummary) -> SlabTree<UpdateTreeEntry> {
> + let mut tree = SlabTree::new();
> +
> + let mut root = tree.set_root(UpdateTreeEntry::Root);
> + root.set_expanded(true);
> +
> + for (remote_name, remote_summary) in update_summary.remotes.deref() {
> + let mut remote_entry = root.append(UpdateTreeEntry::Remote(RemoteEntry {
> + remote: remote_name.clone(),
> + ty: remote_summary.remote_type,
> + number_of_nodes: 0,
> + number_of_updatable_nodes: 0,
> + number_of_failed_nodes: 0,
> + poll_status: remote_summary.status.clone(),
> + }));
> + remote_entry.set_expanded(false);
> +
> + let number_of_nodes = remote_summary.nodes.len();
> + let mut number_of_updatable_nodes = 0;
> + let mut number_of_failed_nodes = 0;
> +
> + for (node_name, node_summary) in remote_summary.nodes.deref() {
> + match node_summary.status {
> + NodeUpdateStatus::Success => {
> + if node_summary.number_of_updates > 0 {
> + number_of_updatable_nodes += 1;
> + }
> + }
> + NodeUpdateStatus::Error => {
> + number_of_failed_nodes += 1;
> + }
> + }
> +
> + remote_entry.append(UpdateTreeEntry::Node(NodeEntry {
> + remote: remote_name.clone(),
> + node: node_name.clone(),
> + ty: remote_summary.remote_type,
> + summary: node_summary.clone(),
> + }));
> + }
> +
> + if let UpdateTreeEntry::Remote(info) = remote_entry.record_mut() {
> + info.number_of_updatable_nodes = number_of_updatable_nodes;
> + info.number_of_nodes = number_of_nodes as u32;
> + info.number_of_failed_nodes = number_of_failed_nodes as u32;
> + }
> + }
> +
> + tree
> +}
> +
> +impl LoadableComponent for UpdateTreeComponent {
> + type Properties = UpdateTree;
> + type Message = RemoteUpdateTreeMsg;
> + type ViewState = ();
> +
> + fn create(ctx: &LoadableComponentContext<Self>) -> Self {
> + let link = ctx.link();
> +
> + let store = TreeStore::new().view_root(false);
> + store.set_sorter(default_sorter);
> +
> + link.repeated_load(5000);
> +
> + let selection = Selection::new().on_select(link.callback(|selection: Selection| {
> + RemoteUpdateTreeMsg::KeySelected(selection.selected_key())
> + }));
> +
> + Self {
> + store: store.clone(),
> + selection,
> + selected_entry: None,
> + }
> + }
> +
> + fn load(
> + &self,
> + ctx: &LoadableComponentContext<Self>,
> + ) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
> + let link = ctx.link().clone();
> +
> + Box::pin(async move {
> + let client = pdm_client();
> +
> + let updates = client.remote_update_summary().await?;
> + link.send_message(Self::Message::LoadFinished(updates));
> +
> + Ok(())
> + })
> + }
> +
> + fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
> + match msg {
> + Self::Message::LoadFinished(updates) => {
> + let data = build_store_from_response(updates);
> + self.store.write().update_root_tree(data);
> + self.store.set_sorter(default_sorter);
> +
> + return true;
> + }
> + Self::Message::KeySelected(key) => {
> + if let Some(key) = key {
> + let read_guard = self.store.read();
> + let node_ref = read_guard.lookup_node(&key).unwrap();
> + let record = node_ref.record();
> +
> + self.selected_entry = Some(record.clone());
> +
> + return true;
> + }
> + }
> + Self::Message::RefreshAll => {
> + let link = ctx.link();
> +
> + link.clone().spawn(async move {
> + let client = pdm_client();
> +
> + match client.refresh_remote_update_summary().await {
> + Ok(upid) => {
> + link.show_task_progres(upid.to_string());
> + }
> + Err(err) => {
> + link.show_error(tr!("Could not refresh update status."), err, false);
> + }
> + }
> + });
> + }
> + }
> +
> + false
> + }
> +
> + fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> yew::Html {
> + Container::new()
> + .class("pwt-content-spacer")
> + .class(FlexFit)
> + .class("pwt-flex-direction-row")
> + .with_child(self.render_update_tree_panel(ctx))
> + .with_child(self.render_update_list_panel(ctx))
> + .into()
haven't tested this, but why not just use a Row here?
> + }
> +}
> +
> +impl UpdateTreeComponent {
> + fn render_update_tree_panel(&self, ctx: &LoadableComponentContext<Self>) -> Panel {
> + let table = DataTable::new(Self::columns(ctx, self.store.clone()), self.store.clone())
> + .selection(self.selection.clone())
> + .striped(false)
> + .borderless(true)
> + .show_header(false)
> + .class(css::FlexFit);
> +
> + let refresh_all_button = Button::new(tr!("Refresh all")).on_activate({
> + let link = ctx.link().clone();
> + move |_| {
> + link.send_message(RemoteUpdateTreeMsg::RefreshAll);
> + }
> + });
> +
> + let title: Html = Row::new()
> + .gap(2)
> + .class(AlignItems::Baseline)
> + .with_child(Fa::new("refresh"))
> + .with_child(tr!("Remote System Updates"))
> + .into();
> +
> + Panel::new()
> + .min_width(500)
> + .title(title)
> + .with_tool(refresh_all_button)
> + .style("flex", "1 1 0")
> + .class(FlexFit)
> + .border(true)
> + .with_child(table)
> + }
> +
> + fn render_update_list_panel(&self, ctx: &LoadableComponentContext<Self>) -> Panel {
> + let title: Html = Row::new()
> + .gap(2)
> + .class(AlignItems::Baseline)
> + .with_child(Fa::new("list"))
> + .with_child(tr!("Update List"))
> + .into();
> +
> + match &self.selected_entry {
> + // (Some(remote), Some(remote_type), Some(node)) => {
> + Some(UpdateTreeEntry::Node(NodeEntry {
> + remote, node, ty, ..
> + })) => {
> + let base_url = format!("/{ty}/remotes/{remote}/nodes/{node}/apt",);
> + let task_base_url = format!("/{ty}/remotes/{}/tasks", remote);
nit: remote should be inlined here
> +
> + let apt = AptPackageManager::new()
> + .base_url(base_url)
> + .task_base_url(task_base_url)
> + .enable_upgrade(true)
> + .on_upgrade({
> + let remote = remote.clone();
> + let link = ctx.link().clone();
> + let remote = remote.clone();
> + let node = node.clone();
> + let ty = *ty;
> +
> + move |_| match ty {
> + RemoteType::Pve => {
> + let id = format!("node/{}::apt", node);
same for node here
> + if let Some(url) =
> + get_deep_url(&link.yew_link(), &remote, None, &id)
you don't need references here for the first parameter, `yew_link()
already returns a reference
> + {
> + let _ = web_sys::window().unwrap().open_with_url(&url.href());
gloo_utils::window() will safe you an unwrap here :)
> + }
> + }
> + RemoteType::Pbs => {
> + let hash = "#pbsServerAdministration:updates";
> + if let Some(url) =
> + get_deep_url_low_level(&link.yew_link(), &remote, None, &hash)
see comment about yew_link() above, and you can drop the extra reference
for hash here too
> + {
> + let _ = web_sys::window().unwrap().open_with_url(&url.href());
see comment about gloo_utils above
> + }
> + }
> + }
> + });
> +
> + Panel::new()
> + .class(FlexFit)
> + .title(title)
> + .border(true)
> + .min_width(500)
> + .with_child(apt)
> + .style("flex", "1 1 0")
> + }
> + _ => {
> + let header = tr!("No node selected");
> + let msg = tr!("Select a node to show available updates.");
> +
> + let select_node_msg = Column::new()
> + .class(FlexFit)
> + .padding(2)
> + .class(AlignItems::Center)
> + .class(TextAlign::Center)
> + .with_child(html! {<h1 class="pwt-font-headline-medium">{header}</h1>})
> + .with_child(Container::new().with_child(msg));
> +
> + Panel::new()
> + .class(FlexFit)
> + .title(title)
> + .border(true)
> + .min_width(500)
> + .with_child(select_node_msg)
> + .style("flex", "1 1 0")
> + }
> + }
> + }
> +}
> +
> +enum RemoteSummaryIcon {
> + UpToDate,
> + Updatable,
> + Error,
> +}
> +
> +fn render_remote_summary_counter(count: u32, task_class: RemoteSummaryIcon) -> Html {
> + let (icon_class, icon_scheme, state_text) = match task_class {
> + RemoteSummaryIcon::UpToDate => (
> + "check",
> + FontColor::Success,
> + tr!("One node is up-to-date." | "{n} nodes are up-to-date." % count),
> + ),
> + RemoteSummaryIcon::Error => (
> + "times-circle",
> + FontColor::Error,
> + tr!("Failed to retrieve update info for one node."
> + | "Failed to retrieve update info for {n} nodes." % count),
> + ),
> + RemoteSummaryIcon::Updatable => (
> + "refresh",
> + FontColor::Primary,
> + tr!("One node has updates available." | "{n} nodes have updates available." % count),
> + ),
> + };
> +
> + let icon = Fa::new(icon_class).margin_end(3).class(icon_scheme);
> +
> + Tooltip::new(
> + Container::from_tag("span")
> + .with_child(icon)
> + .with_child(count)
> + .margin_end(5),
> + )
> + .tip(state_text)
> + .into()
> +}
> +
> +fn render_remote_status_icon(task_class: RemoteUpdateStatus) -> Html {
> + let (icon_class, icon_scheme, state_text) = match task_class {
> + RemoteUpdateStatus::Success => (
> + "check",
> + FontColor::Success,
> + tr!("All nodes of this remote are up-to-date."),
> + ),
> + RemoteUpdateStatus::Error => (
> + "times-circle",
> + FontColor::Error,
> + tr!("Could not retrieve update info for remote."),
> + ),
> + RemoteUpdateStatus::Unknown => (
> + "question-circle-o",
> + FontColor::Primary,
> + tr!("The update status is not known."),
> + ),
> + };
> +
> + let icon = Fa::new(icon_class).margin_end(3).class(icon_scheme);
> +
> + Tooltip::new(Container::from_tag("span").with_child(icon).margin_end(5))
> + .tip(state_text)
> + .into()
> +}
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply [relevance 0%]
* [pdm-devel] [PATCH proxmox-datacenter-manager 11/12] ui: add remote update view
@ 2025-10-15 12:47 2% ` Lukas Wagner
2025-10-17 10:15 0% ` Shannon Sterz
0 siblings, 1 reply; 19+ results
From: Lukas Wagner @ 2025-10-15 12:47 UTC (permalink / raw)
To: pdm-devel
This commit adds a new view for showing a global overview about
available updates on managed remotes. Thew view is split in the middle.
On the left side, we display a tree view showing all remotes and nodes,
on the right side we show a list of available updates for any node
selected in the tree.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
ui/src/remotes/mod.rs | 3 +
ui/src/remotes/updates.rs | 531 ++++++++++++++++++++++++++++++++++++++
2 files changed, 534 insertions(+)
create mode 100644 ui/src/remotes/updates.rs
diff --git a/ui/src/remotes/mod.rs b/ui/src/remotes/mod.rs
index 83b3331b..cce21563 100644
--- a/ui/src/remotes/mod.rs
+++ b/ui/src/remotes/mod.rs
@@ -24,6 +24,9 @@ pub use config::{create_remote, RemoteConfigPanel};
mod tasks;
pub use tasks::RemoteTaskList;
+mod updates;
+pub use updates::UpdateTree;
+
use yew::{function_component, Html};
use pwt::prelude::*;
diff --git a/ui/src/remotes/updates.rs b/ui/src/remotes/updates.rs
new file mode 100644
index 00000000..1a2e9e25
--- /dev/null
+++ b/ui/src/remotes/updates.rs
@@ -0,0 +1,531 @@
+use std::cmp::Ordering;
+use std::ops::Deref;
+use std::pin::Pin;
+use std::rc::Rc;
+
+use futures::Future;
+use yew::virtual_dom::{Key, VComp, VNode};
+use yew::{html, Html, Properties};
+
+use pdm_api_types::remote_updates::{
+ NodeUpdateStatus, NodeUpdateSummary, RemoteUpdateStatus, UpdateSummary,
+};
+use pdm_api_types::remotes::RemoteType;
+use pwt::css::{AlignItems, FlexFit, TextAlign};
+use pwt::widget::data_table::{DataTableCellRenderArgs, DataTableCellRenderer};
+
+use proxmox_yew_comp::{
+ AptPackageManager, LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
+};
+use pwt::props::{CssBorderBuilder, CssMarginBuilder, CssPaddingBuilder, WidgetStyleBuilder};
+use pwt::widget::{Button, Container, Panel, Tooltip};
+use pwt::{
+ css,
+ css::FontColor,
+ props::{ContainerBuilder, ExtractPrimaryKey, WidgetBuilder},
+ state::{Selection, SlabTree, TreeStore},
+ tr,
+ widget::{
+ data_table::{DataTable, DataTableColumn, DataTableHeader},
+ Column, Fa, Row,
+ },
+};
+
+use crate::{get_deep_url, get_deep_url_low_level, pdm_client};
+
+#[derive(PartialEq, Properties)]
+pub struct UpdateTree {}
+
+impl UpdateTree {
+ pub fn new() -> Self {
+ yew::props!(Self {})
+ }
+}
+
+impl From<UpdateTree> for VNode {
+ fn from(value: UpdateTree) -> Self {
+ let comp = VComp::new::<LoadableComponentMaster<UpdateTreeComponent>>(Rc::new(value), None);
+ VNode::from(comp)
+ }
+}
+
+#[derive(Clone, PartialEq, Debug)]
+struct RemoteEntry {
+ remote: String,
+ ty: RemoteType,
+ number_of_failed_nodes: u32,
+ number_of_nodes: u32,
+ number_of_updatable_nodes: u32,
+ poll_status: RemoteUpdateStatus,
+}
+
+#[derive(Clone, PartialEq, Debug)]
+struct NodeEntry {
+ remote: String,
+ node: String,
+ ty: RemoteType,
+ summary: NodeUpdateSummary,
+}
+
+#[derive(Clone, PartialEq, Debug)]
+enum UpdateTreeEntry {
+ Root,
+ Remote(RemoteEntry),
+ Node(NodeEntry),
+}
+
+impl UpdateTreeEntry {
+ fn name(&self) -> &str {
+ match &self {
+ Self::Root => "",
+ Self::Remote(data) => &data.remote,
+ Self::Node(data) => &data.node,
+ }
+ }
+}
+
+impl ExtractPrimaryKey for UpdateTreeEntry {
+ fn extract_key(&self) -> yew::virtual_dom::Key {
+ Key::from(match self {
+ UpdateTreeEntry::Root => "/".to_string(),
+ UpdateTreeEntry::Remote(data) => format!("/{}", data.remote),
+ UpdateTreeEntry::Node(data) => format!("/{}/{}", data.remote, data.node),
+ })
+ }
+}
+
+pub enum RemoteUpdateTreeMsg {
+ LoadFinished(UpdateSummary),
+ KeySelected(Option<Key>),
+ RefreshAll,
+}
+
+pub struct UpdateTreeComponent {
+ store: TreeStore<UpdateTreeEntry>,
+ selection: Selection,
+ selected_entry: Option<UpdateTreeEntry>,
+}
+
+fn default_sorter(a: &UpdateTreeEntry, b: &UpdateTreeEntry) -> Ordering {
+ a.name().cmp(b.name())
+}
+
+impl UpdateTreeComponent {
+ fn columns(
+ _ctx: &LoadableComponentContext<Self>,
+ store: TreeStore<UpdateTreeEntry>,
+ ) -> Rc<Vec<DataTableHeader<UpdateTreeEntry>>> {
+ Rc::new(vec![
+ DataTableColumn::new(tr!("Name"))
+ .tree_column(store)
+ .width("200px")
+ .render(|entry: &UpdateTreeEntry| {
+ let icon = match entry {
+ UpdateTreeEntry::Remote(_) => Some("server"),
+ UpdateTreeEntry::Node(_) => Some("building"),
+ _ => None,
+ };
+
+ Row::new()
+ .class(css::AlignItems::Baseline)
+ .gap(2)
+ .with_optional_child(icon.map(|icon| Fa::new(icon)))
+ .with_child(entry.name())
+ .into()
+ })
+ .sorter(default_sorter)
+ .into(),
+ DataTableColumn::new(tr!("Status"))
+ .render_cell(DataTableCellRenderer::new(
+ move |args: &mut DataTableCellRenderArgs<UpdateTreeEntry>| {
+ let mut row = Row::new().class(css::AlignItems::Baseline).gap(2);
+
+ match args.record() {
+ UpdateTreeEntry::Remote(remote_info) => match remote_info.poll_status {
+ RemoteUpdateStatus::Unknown => {
+ row = row.with_child(render_remote_status_icon(
+ RemoteUpdateStatus::Unknown,
+ ));
+ }
+ RemoteUpdateStatus::Success => {
+ if !args.is_expanded() {
+ let up_to_date_nodes = remote_info.number_of_nodes
+ - remote_info.number_of_updatable_nodes
+ - remote_info.number_of_failed_nodes;
+
+ if up_to_date_nodes > 0 {
+ row = row.with_child(render_remote_summary_counter(
+ up_to_date_nodes,
+ RemoteSummaryIcon::UpToDate,
+ ));
+ }
+
+ if remote_info.number_of_updatable_nodes > 0 {
+ row = row.with_child(render_remote_summary_counter(
+ remote_info.number_of_updatable_nodes,
+ RemoteSummaryIcon::Updatable,
+ ));
+ }
+
+ if remote_info.number_of_failed_nodes > 0 {
+ row = row.with_child(render_remote_summary_counter(
+ remote_info.number_of_failed_nodes,
+ RemoteSummaryIcon::Error,
+ ));
+ }
+ }
+ }
+ RemoteUpdateStatus::Error => {
+ row = row.with_child(render_remote_status_icon(
+ RemoteUpdateStatus::Error,
+ ));
+ }
+ },
+ UpdateTreeEntry::Node(info) => {
+ if info.summary.status == NodeUpdateStatus::Error {
+ row = row.with_child(
+ Fa::new("times-circle").class(FontColor::Error),
+ );
+ row = row.with_child(tr!("Could not get update info"));
+ } else if info.summary.number_of_updates > 0 {
+ row = row
+ .with_child(Fa::new("refresh").class(FontColor::Primary));
+ row = row.with_child(tr!(
+ "{0} updates are available",
+ info.summary.number_of_updates
+ ));
+ } else {
+ row =
+ row.with_child(Fa::new("check").class(FontColor::Success));
+ row = row.with_child(tr!("Up-to-date"));
+ }
+ }
+ _ => {}
+ }
+
+ row.into()
+ },
+ ))
+ .into(),
+ ])
+ }
+}
+
+fn build_store_from_response(update_summary: UpdateSummary) -> SlabTree<UpdateTreeEntry> {
+ let mut tree = SlabTree::new();
+
+ let mut root = tree.set_root(UpdateTreeEntry::Root);
+ root.set_expanded(true);
+
+ for (remote_name, remote_summary) in update_summary.remotes.deref() {
+ let mut remote_entry = root.append(UpdateTreeEntry::Remote(RemoteEntry {
+ remote: remote_name.clone(),
+ ty: remote_summary.remote_type,
+ number_of_nodes: 0,
+ number_of_updatable_nodes: 0,
+ number_of_failed_nodes: 0,
+ poll_status: remote_summary.status.clone(),
+ }));
+ remote_entry.set_expanded(false);
+
+ let number_of_nodes = remote_summary.nodes.len();
+ let mut number_of_updatable_nodes = 0;
+ let mut number_of_failed_nodes = 0;
+
+ for (node_name, node_summary) in remote_summary.nodes.deref() {
+ match node_summary.status {
+ NodeUpdateStatus::Success => {
+ if node_summary.number_of_updates > 0 {
+ number_of_updatable_nodes += 1;
+ }
+ }
+ NodeUpdateStatus::Error => {
+ number_of_failed_nodes += 1;
+ }
+ }
+
+ remote_entry.append(UpdateTreeEntry::Node(NodeEntry {
+ remote: remote_name.clone(),
+ node: node_name.clone(),
+ ty: remote_summary.remote_type,
+ summary: node_summary.clone(),
+ }));
+ }
+
+ if let UpdateTreeEntry::Remote(info) = remote_entry.record_mut() {
+ info.number_of_updatable_nodes = number_of_updatable_nodes;
+ info.number_of_nodes = number_of_nodes as u32;
+ info.number_of_failed_nodes = number_of_failed_nodes as u32;
+ }
+ }
+
+ tree
+}
+
+impl LoadableComponent for UpdateTreeComponent {
+ type Properties = UpdateTree;
+ type Message = RemoteUpdateTreeMsg;
+ type ViewState = ();
+
+ fn create(ctx: &LoadableComponentContext<Self>) -> Self {
+ let link = ctx.link();
+
+ let store = TreeStore::new().view_root(false);
+ store.set_sorter(default_sorter);
+
+ link.repeated_load(5000);
+
+ let selection = Selection::new().on_select(link.callback(|selection: Selection| {
+ RemoteUpdateTreeMsg::KeySelected(selection.selected_key())
+ }));
+
+ Self {
+ store: store.clone(),
+ selection,
+ selected_entry: None,
+ }
+ }
+
+ fn load(
+ &self,
+ ctx: &LoadableComponentContext<Self>,
+ ) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
+ let link = ctx.link().clone();
+
+ Box::pin(async move {
+ let client = pdm_client();
+
+ let updates = client.remote_update_summary().await?;
+ link.send_message(Self::Message::LoadFinished(updates));
+
+ Ok(())
+ })
+ }
+
+ fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
+ match msg {
+ Self::Message::LoadFinished(updates) => {
+ let data = build_store_from_response(updates);
+ self.store.write().update_root_tree(data);
+ self.store.set_sorter(default_sorter);
+
+ return true;
+ }
+ Self::Message::KeySelected(key) => {
+ if let Some(key) = key {
+ let read_guard = self.store.read();
+ let node_ref = read_guard.lookup_node(&key).unwrap();
+ let record = node_ref.record();
+
+ self.selected_entry = Some(record.clone());
+
+ return true;
+ }
+ }
+ Self::Message::RefreshAll => {
+ let link = ctx.link();
+
+ link.clone().spawn(async move {
+ let client = pdm_client();
+
+ match client.refresh_remote_update_summary().await {
+ Ok(upid) => {
+ link.show_task_progres(upid.to_string());
+ }
+ Err(err) => {
+ link.show_error(tr!("Could not refresh update status."), err, false);
+ }
+ }
+ });
+ }
+ }
+
+ false
+ }
+
+ fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> yew::Html {
+ Container::new()
+ .class("pwt-content-spacer")
+ .class(FlexFit)
+ .class("pwt-flex-direction-row")
+ .with_child(self.render_update_tree_panel(ctx))
+ .with_child(self.render_update_list_panel(ctx))
+ .into()
+ }
+}
+
+impl UpdateTreeComponent {
+ fn render_update_tree_panel(&self, ctx: &LoadableComponentContext<Self>) -> Panel {
+ let table = DataTable::new(Self::columns(ctx, self.store.clone()), self.store.clone())
+ .selection(self.selection.clone())
+ .striped(false)
+ .borderless(true)
+ .show_header(false)
+ .class(css::FlexFit);
+
+ let refresh_all_button = Button::new(tr!("Refresh all")).on_activate({
+ let link = ctx.link().clone();
+ move |_| {
+ link.send_message(RemoteUpdateTreeMsg::RefreshAll);
+ }
+ });
+
+ let title: Html = Row::new()
+ .gap(2)
+ .class(AlignItems::Baseline)
+ .with_child(Fa::new("refresh"))
+ .with_child(tr!("Remote System Updates"))
+ .into();
+
+ Panel::new()
+ .min_width(500)
+ .title(title)
+ .with_tool(refresh_all_button)
+ .style("flex", "1 1 0")
+ .class(FlexFit)
+ .border(true)
+ .with_child(table)
+ }
+
+ fn render_update_list_panel(&self, ctx: &LoadableComponentContext<Self>) -> Panel {
+ let title: Html = Row::new()
+ .gap(2)
+ .class(AlignItems::Baseline)
+ .with_child(Fa::new("list"))
+ .with_child(tr!("Update List"))
+ .into();
+
+ match &self.selected_entry {
+ // (Some(remote), Some(remote_type), Some(node)) => {
+ Some(UpdateTreeEntry::Node(NodeEntry {
+ remote, node, ty, ..
+ })) => {
+ let base_url = format!("/{ty}/remotes/{remote}/nodes/{node}/apt",);
+ let task_base_url = format!("/{ty}/remotes/{}/tasks", remote);
+
+ let apt = AptPackageManager::new()
+ .base_url(base_url)
+ .task_base_url(task_base_url)
+ .enable_upgrade(true)
+ .on_upgrade({
+ let remote = remote.clone();
+ let link = ctx.link().clone();
+ let remote = remote.clone();
+ let node = node.clone();
+ let ty = *ty;
+
+ move |_| match ty {
+ RemoteType::Pve => {
+ let id = format!("node/{}::apt", node);
+ if let Some(url) =
+ get_deep_url(&link.yew_link(), &remote, None, &id)
+ {
+ let _ = web_sys::window().unwrap().open_with_url(&url.href());
+ }
+ }
+ RemoteType::Pbs => {
+ let hash = "#pbsServerAdministration:updates";
+ if let Some(url) =
+ get_deep_url_low_level(&link.yew_link(), &remote, None, &hash)
+ {
+ let _ = web_sys::window().unwrap().open_with_url(&url.href());
+ }
+ }
+ }
+ });
+
+ Panel::new()
+ .class(FlexFit)
+ .title(title)
+ .border(true)
+ .min_width(500)
+ .with_child(apt)
+ .style("flex", "1 1 0")
+ }
+ _ => {
+ let header = tr!("No node selected");
+ let msg = tr!("Select a node to show available updates.");
+
+ let select_node_msg = Column::new()
+ .class(FlexFit)
+ .padding(2)
+ .class(AlignItems::Center)
+ .class(TextAlign::Center)
+ .with_child(html! {<h1 class="pwt-font-headline-medium">{header}</h1>})
+ .with_child(Container::new().with_child(msg));
+
+ Panel::new()
+ .class(FlexFit)
+ .title(title)
+ .border(true)
+ .min_width(500)
+ .with_child(select_node_msg)
+ .style("flex", "1 1 0")
+ }
+ }
+ }
+}
+
+enum RemoteSummaryIcon {
+ UpToDate,
+ Updatable,
+ Error,
+}
+
+fn render_remote_summary_counter(count: u32, task_class: RemoteSummaryIcon) -> Html {
+ let (icon_class, icon_scheme, state_text) = match task_class {
+ RemoteSummaryIcon::UpToDate => (
+ "check",
+ FontColor::Success,
+ tr!("One node is up-to-date." | "{n} nodes are up-to-date." % count),
+ ),
+ RemoteSummaryIcon::Error => (
+ "times-circle",
+ FontColor::Error,
+ tr!("Failed to retrieve update info for one node."
+ | "Failed to retrieve update info for {n} nodes." % count),
+ ),
+ RemoteSummaryIcon::Updatable => (
+ "refresh",
+ FontColor::Primary,
+ tr!("One node has updates available." | "{n} nodes have updates available." % count),
+ ),
+ };
+
+ let icon = Fa::new(icon_class).margin_end(3).class(icon_scheme);
+
+ Tooltip::new(
+ Container::from_tag("span")
+ .with_child(icon)
+ .with_child(count)
+ .margin_end(5),
+ )
+ .tip(state_text)
+ .into()
+}
+
+fn render_remote_status_icon(task_class: RemoteUpdateStatus) -> Html {
+ let (icon_class, icon_scheme, state_text) = match task_class {
+ RemoteUpdateStatus::Success => (
+ "check",
+ FontColor::Success,
+ tr!("All nodes of this remote are up-to-date."),
+ ),
+ RemoteUpdateStatus::Error => (
+ "times-circle",
+ FontColor::Error,
+ tr!("Could not retrieve update info for remote."),
+ ),
+ RemoteUpdateStatus::Unknown => (
+ "question-circle-o",
+ FontColor::Primary,
+ tr!("The update status is not known."),
+ ),
+ };
+
+ let icon = Fa::new(icon_class).margin_end(3).class(icon_scheme);
+
+ Tooltip::new(Container::from_tag("span").with_child(icon).margin_end(5))
+ .tip(state_text)
+ .into()
+}
--
2.47.3
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply related [relevance 2%]
Results 1-19 of 19 | reverse | options above
-- pct% links below jump to the message on this page, permalinks otherwise --
2025-10-15 12:46 [pdm-devel] [PATCH proxmox-datacenter-manager 00/12] add global remote update view Lukas Wagner
2025-10-15 12:47 2% ` [pdm-devel] [PATCH proxmox-datacenter-manager 11/12] ui: add " Lukas Wagner
2025-10-17 10:15 0% ` Shannon Sterz
2025-10-17 12:09 [pdm-devel] [PATCH datacenter-manager v2 00/13] add global " Lukas Wagner
2025-10-17 12:10 2% ` [pdm-devel] [PATCH datacenter-manager v2 11/13] ui: add " Lukas Wagner
2025-10-23 12:44 [pdm-devel] [PATCH datacenter-manager v3 00/12] add global " Lukas Wagner
2025-10-23 12:44 2% ` [pdm-devel] [PATCH datacenter-manager v3 10/12] ui: add " Lukas Wagner
2025-12-09 13:11 1% [yew-devel] [RFC yew-comp] refactor: move LoadableComponent state into component implementations Dietmar Maurer
2025-12-10 9:28 2% [yew-devel] [RFC yew-comp V2] " Dietmar Maurer
2025-12-18 14:20 [pdm-devel] [PATCH datacenter-manager/proxmox-yew-comp v2 0/6] manual refresh button for remote task cache Lukas Wagner
2025-12-18 14:20 5% ` [pdm-devel] [PATCH proxmox-yew-comp v2 2/2] loadable component: don't trigger reload when starting a task Lukas Wagner
2025-12-29 15:29 [pdm-devel] [PATCH datacenter-manager/proxmox-yew-comp v3 0/6] manual refresh button for remote task cache Lukas Wagner
2025-12-29 15:30 5% ` [pdm-devel] [PATCH proxmox-yew-comp v3 2/2] loadable component: don't trigger reload when starting a task Lukas Wagner
2026-02-06 13:44 [PATCH datacenter-manager/proxmox-yew-comp v4 0/8] manual refresh button for remote task cache Lukas Wagner
2026-02-06 13:44 5% ` [PATCH proxmox-yew-comp v4 2/2] loadable component: don't trigger reload when starting a task Lukas Wagner
2026-03-03 8:18 [PATCH datacenter-manager/proxmox-yew-comp v5 0/8] manual refresh button for remote task cache Lukas Wagner
2026-03-03 8:18 5% ` [PATCH proxmox-yew-comp v5 2/2] loadable component: don't trigger reload when starting a task Lukas Wagner
2026-03-09 8:36 [PATCH datacenter-manager/proxmox-yew-comp v6 0/7] manual refresh button for remote task cache Lukas Wagner
2026-03-09 8:36 5% ` [PATCH proxmox-yew-comp v6 2/2] loadable component: don't trigger reload when starting a task Lukas Wagner
2026-05-07 7:17 [PATCH 0/8] subscription: add central key pool registry with reissue support Thomas Lamprecht
2026-05-07 7:17 1% ` [PATCH 5/8] ui: add subscription registry with key pool and node status Thomas Lamprecht
2026-05-07 8:26 [PATCH datacenter-manager v2 0/8] subscription: add central key pool registry with reissue support Thomas Lamprecht
2026-05-07 8:26 1% ` [PATCH datacenter-manager v2 5/8] ui: add subscription registry with key pool and node status Thomas Lamprecht
2026-05-15 7:43 [PATCH datacenter-manager v3 00/12] subscription key pool registry Thomas Lamprecht
2026-05-15 7:43 1% ` [PATCH datacenter-manager v3 05/12] ui: registry: add view with key pool and node status Thomas Lamprecht
2026-05-21 19:20 [PATCH datacenter-manager v4 00/10] subscription key pool registry Thomas Lamprecht
2026-05-21 19:20 1% ` [PATCH datacenter-manager v4 05/10] ui: registry: add view with key pool and node status Thomas Lamprecht
2026-05-22 13:16 0% ` Dominik Csapak
2026-05-23 22:58 [PATCH datacenter-manager v5 00/10] subscription key pool registry Thomas Lamprecht
2026-05-23 22:58 1% ` [PATCH datacenter-manager v5 05/10] ui: registry: add view with key pool and node status Thomas Lamprecht
2026-06-17 12:41 14% [PATCH datacenter-manager] ui: tree-wide: fix spelling of show-task_progress() function Shannon Sterz
2026-06-17 13:46 0% ` Dominik Csapak
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.