From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id D6E1D1FF13E for ; Fri, 03 Apr 2026 18:56:13 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id A02C984A0; Fri, 3 Apr 2026 18:56:44 +0200 (CEST) From: Christoph Heiss To: pdm-devel@lists.proxmox.com Subject: [PATCH datacenter-manager v3 21/38] ui: auto-installer: add installations overview panel Date: Fri, 3 Apr 2026 18:53:53 +0200 Message-ID: <20260403165437.2166551-22-c.heiss@proxmox.com> X-Mailer: git-send-email 2.53.0 In-Reply-To: <20260403165437.2166551-1-c.heiss@proxmox.com> References: <20260403165437.2166551-1-c.heiss@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1775235334403 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.067 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: JJ7NZMZKRJHQOOTCMDOJ3AN5HFJAQ5XP X-Message-ID-Hash: JJ7NZMZKRJHQOOTCMDOJ3AN5HFJAQ5XP X-MailFrom: c.heiss@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: A simple overview panel with a list of in-progress and on-going installations. Signed-off-by: Christoph Heiss --- Changes v2 -> v3: * the panel now lives under the "Remotes" menu Changes v1 -> v2: * no changes ui/Cargo.toml | 2 + .../auto_installer/installations_panel.rs | 305 ++++++++++++++++++ ui/src/remotes/auto_installer/mod.rs | 53 +++ ui/src/remotes/mod.rs | 10 + 4 files changed, 370 insertions(+) create mode 100644 ui/src/remotes/auto_installer/installations_panel.rs create mode 100644 ui/src/remotes/auto_installer/mod.rs diff --git a/ui/Cargo.toml b/ui/Cargo.toml index a0215c1..7d00133 100644 --- a/ui/Cargo.toml +++ b/ui/Cargo.toml @@ -37,6 +37,7 @@ proxmox-acme-api = "1" proxmox-deb-version = "0.1" proxmox-client = "1" proxmox-human-byte = "1" +proxmox-installer-types = "0.1" proxmox-login = "1" proxmox-schema = "5" proxmox-subscription = { version = "1.0.1", features = ["api-types"], default-features = false } @@ -55,6 +56,7 @@ pdm-search = { version = "0.2", path = "../lib/pdm-search" } [patch.crates-io] # proxmox-client = { path = "../../proxmox/proxmox-client" } # proxmox-human-byte = { path = "../../proxmox/proxmox-human-byte" } +# proxmox-installer-types = { path = "../proxmox/proxmox-installer-types" } # proxmox-login = { path = "../../proxmox/proxmox-login" } # proxmox-rrd-api-types = { path = "../../proxmox/proxmox-rrd-api-types" } # proxmox-schema = { path = "../../proxmox/proxmox-schema" } diff --git a/ui/src/remotes/auto_installer/installations_panel.rs b/ui/src/remotes/auto_installer/installations_panel.rs new file mode 100644 index 0000000..07b61a5 --- /dev/null +++ b/ui/src/remotes/auto_installer/installations_panel.rs @@ -0,0 +1,305 @@ +//! Implements the UI components for displaying an overview view of all finished/in-progress +//! installations. + +use anyhow::{anyhow, Result}; +use core::clone::Clone; +use std::{future::Future, pin::Pin, rc::Rc}; + +use pdm_api_types::auto_installer::{Installation, InstallationStatus}; +use proxmox_installer_types::{post_hook::PostHookInfo, SystemInfo}; +use proxmox_yew_comp::{ + percent_encoding::percent_encode_component, ConfirmButton, DataViewWindow, LoadableComponent, + LoadableComponentContext, LoadableComponentMaster, LoadableComponentScopeExt, + LoadableComponentState, +}; +use pwt::{ + css::{Flex, FlexFit, Overflow}, + props::{ + ContainerBuilder, CssPaddingBuilder, EventSubscriber, FieldBuilder, WidgetBuilder, + WidgetStyleBuilder, + }, + state::{Selection, Store}, + tr, + widget::{ + data_table::{DataTable, DataTableColumn, DataTableHeader}, + form::TextArea, + Button, Toolbar, + }, +}; +use yew::{ + virtual_dom::{Key, VComp, VNode}, + Properties, +}; + +use crate::pdm_client; + +#[derive(Default, PartialEq, Properties)] +pub struct InstallationsPanel {} + +impl From for VNode { + fn from(value: InstallationsPanel) -> Self { + let comp = VComp::new::>( + Rc::new(value), + None, + ); + VNode::from(comp) + } +} + +enum Message { + Refresh, + SelectionChange, + RemoveEntry, +} + +#[derive(PartialEq)] +enum ViewState { + ShowRawSystemInfo, + ShowRawPostHookData, +} + +struct InstallationsPanelComponent { + state: LoadableComponentState, + selection: Selection, + store: Store, + columns: Rc>>, +} + +pwt::impl_deref_mut_property!( + InstallationsPanelComponent, + state, + LoadableComponentState +); + +impl LoadableComponent for InstallationsPanelComponent { + type Properties = InstallationsPanel; + type Message = Message; + type ViewState = ViewState; + + fn create(ctx: &LoadableComponentContext) -> Self { + let selection = + Selection::new().on_select(ctx.link().callback(|_| Message::SelectionChange)); + + let store = + Store::with_extract_key(|record: &Installation| Key::from(record.uuid.to_string())); + store.set_sorter(|a: &Installation, b: &Installation| a.received_at.cmp(&b.received_at)); + + Self { + state: LoadableComponentState::new(), + selection, + store, + columns: Rc::new(columns()), + } + } + + fn load( + &self, + _ctx: &LoadableComponentContext, + ) -> Pin>>> { + let store = self.store.clone(); + Box::pin(async move { + let data = pdm_client().get_autoinst_installations().await?; + store.write().set_data(data); + Ok(()) + }) + } + + fn update(&mut self, ctx: &LoadableComponentContext, msg: Self::Message) -> bool { + match msg { + Self::Message::Refresh => { + ctx.link().send_reload(); + false + } + Self::Message::SelectionChange => true, + Self::Message::RemoveEntry => { + if let Some(key) = self.selection.selected_key() { + let link = ctx.link().clone(); + self.spawn(async move { + if let Err(err) = delete_entry(key).await { + link.show_error(tr!("Unable to delete entry"), err, true); + } + link.send_reload(); + }) + } + false + } + } + } + + fn toolbar(&self, ctx: &LoadableComponentContext) -> Option { + let link = ctx.link(); + + let selection_has_post_hook_data = self + .selection + .selected_key() + .and_then(|key| { + self.store + .read() + .lookup_record(&key) + .map(|data| data.post_hook_data.is_some()) + }) + .unwrap_or(false); + + let toolbar = Toolbar::new() + .class("pwt-w-100") + .class(Overflow::Hidden) + .class("pwt-border-bottom") + .with_child( + Button::new(tr!("Raw system information")) + .disabled(self.selection.is_empty()) + .onclick(link.change_view_callback(|_| Some(ViewState::ShowRawSystemInfo))), + ) + .with_child( + Button::new(tr!("Post-installation webhook data")) + .disabled(self.selection.is_empty() || !selection_has_post_hook_data) + .onclick(link.change_view_callback(|_| Some(ViewState::ShowRawPostHookData))), + ) + .with_spacer() + .with_child( + ConfirmButton::new(tr!("Remove")) + .confirm_message(tr!("Are you sure you want to remove this entry?")) + .disabled(self.selection.is_empty()) + .on_activate(link.callback(|_| Message::RemoveEntry)), + ) + .with_flex_spacer() + .with_child( + Button::refresh(self.loading()).onclick(ctx.link().callback(|_| Message::Refresh)), + ); + + Some(toolbar.into()) + } + + fn main_view(&self, ctx: &LoadableComponentContext) -> yew::Html { + let link = ctx.link().clone(); + + DataTable::new(self.columns.clone(), self.store.clone()) + .class(FlexFit) + .selection(self.selection.clone()) + .on_row_dblclick({ + move |_: &mut _| { + link.change_view(Some(Self::ViewState::ShowRawSystemInfo)); + } + }) + .into() + } + + fn dialog_view( + &self, + ctx: &LoadableComponentContext, + view_state: &Self::ViewState, + ) -> Option { + let on_done = ctx.link().clone().change_view_callback(|_| None); + + let record = self + .store + .read() + .lookup_record(&self.selection.selected_key()?)? + .clone(); + + Some(match view_state { + Self::ViewState::ShowRawSystemInfo => { + DataViewWindow::new(tr!("Raw system information")) + .on_done(on_done) + .loader({ + move || { + let info = record.info.clone(); + async move { Ok(info) } + } + }) + .renderer(|data: &SystemInfo| -> yew::Html { + let value = serde_json::to_string_pretty(data) + .unwrap_or_else(|_| "".to_owned()); + render_raw_info_container(value) + }) + .resizable(true) + .into() + } + Self::ViewState::ShowRawPostHookData => { + DataViewWindow::new(tr!("Raw post-installation webhook data")) + .on_done(on_done) + .loader({ + move || { + let data = record.post_hook_data.clone(); + async move { + data.ok_or_else(|| anyhow!("no post-installation webhook data")) + } + } + }) + .renderer(|data: &PostHookInfo| -> yew::Html { + let value = serde_json::to_string_pretty(data) + .unwrap_or_else(|_| "".to_owned()); + render_raw_info_container(value) + }) + .resizable(true) + .into() + } + }) + } +} + +async fn delete_entry(key: Key) -> Result<()> { + let id = percent_encode_component(&key.to_string()); + Ok(pdm_client().delete_autoinst_installation(&id).await?) +} + +fn render_raw_info_container(value: String) -> yew::Html { + pwt::widget::Container::new() + .class(Flex::Fill) + .class(Overflow::Auto) + .padding(4) + .with_child( + TextArea::new() + .width("800px") + .read_only(true) + .attribute("rows", "40") + .value(value), + ) + .into() +} + +fn columns() -> Vec> { + vec![ + DataTableColumn::new(tr!("Received")) + .width("170px") + .render(|item: &Installation| { + proxmox_yew_comp::utils::render_epoch(item.received_at).into() + }) + .sorter(|a: &Installation, b: &Installation| a.received_at.cmp(&b.received_at)) + .sort_order(Some(false)) + .into(), + DataTableColumn::new(tr!("Product")) + .width("300px") + .render(|item: &Installation| { + format!( + "{} {}-{}", + item.info.product.fullname, item.info.iso.release, item.info.iso.isorelease + ) + .into() + }) + .sorter(|a: &Installation, b: &Installation| { + a.info.product.product.cmp(&b.info.product.product) + }) + .into(), + DataTableColumn::new(tr!("Status")) + .width("200px") + .render(|item: &Installation| { + match item.status { + InstallationStatus::AnswerSent => tr!("Answer sent"), + InstallationStatus::NoAnswerFound => tr!("No matching answer found"), + InstallationStatus::InProgress => tr!("In Progress"), + InstallationStatus::Finished => tr!("Finished"), + } + .into() + }) + .sorter(|a: &Installation, b: &Installation| a.status.cmp(&b.status)) + .into(), + DataTableColumn::new(tr!("Matched answer")) + .flex(1) + .render(|item: &Installation| match &item.answer_id { + Some(s) => s.into(), + None => "-".into(), + }) + .sorter(|a: &Installation, b: &Installation| a.answer_id.cmp(&b.answer_id)) + .into(), + ] +} diff --git a/ui/src/remotes/auto_installer/mod.rs b/ui/src/remotes/auto_installer/mod.rs new file mode 100644 index 0000000..8155a9b --- /dev/null +++ b/ui/src/remotes/auto_installer/mod.rs @@ -0,0 +1,53 @@ +//! Implements the UI for the proxmox-auto-installer integration. + +mod installations_panel; + +use std::rc::Rc; +use yew::virtual_dom::{VComp, VNode}; + +use pwt::{ + css::{self, AlignItems, Fit}, + prelude::*, + props::{ContainerBuilder, WidgetBuilder}, + widget::{Container, Fa, Panel, Row}, +}; + +#[derive(Default, PartialEq, Properties)] +pub struct AutoInstallerPanel {} + +impl From for VNode { + fn from(value: AutoInstallerPanel) -> Self { + VComp::new::(Rc::new(value), None).into() + } +} + +pub struct AutoInstallerPanelComponent {} + +impl Component for AutoInstallerPanelComponent { + type Message = (); + type Properties = AutoInstallerPanel; + + fn create(_: &Context) -> Self { + Self {} + } + + fn view(&self, _: &Context) -> Html { + let installations_title: Html = Row::new() + .gap(2) + .class(AlignItems::Baseline) + .with_child(Fa::new("cubes")) + .with_child(tr!("Installations")) + .into(); + + Container::new() + .class("pwt-content-spacer") + .class(Fit) + .class(css::Display::Grid) + .with_child( + Panel::new() + .title(installations_title) + .with_child(installations_panel::InstallationsPanel::default()), + ) + .into() + } +} diff --git a/ui/src/remotes/mod.rs b/ui/src/remotes/mod.rs index bfe9dc0..14b2dd0 100644 --- a/ui/src/remotes/mod.rs +++ b/ui/src/remotes/mod.rs @@ -32,6 +32,9 @@ mod remove_remote; mod firewall; pub use firewall::FirewallTree; +mod auto_installer; +use auto_installer::AutoInstallerPanel; + use yew::{function_component, Html}; use pwt::prelude::*; @@ -75,6 +78,13 @@ pub fn system_configuration() -> Html { .label(tr!("Firewall")) .icon_class("fa fa-shield"), |_| FirewallTree::new().into(), + ) + .with_item_builder( + TabBarItem::new() + .key("auto-installer") + .label(tr!("Automated Installations")) + .icon_class("fa fa-cubes"), + |_| AutoInstallerPanel::default().into(), ); NavigationContainer::new().with_child(panel).into() -- 2.53.0