From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id 0A7361FF184 for ; Thu, 4 Dec 2025 13:51:57 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 30E9F2664F; Thu, 4 Dec 2025 13:52:24 +0100 (CET) From: Christoph Heiss To: pdm-devel@lists.proxmox.com Date: Thu, 4 Dec 2025 13:51:21 +0100 Message-ID: <20251204125122.945961-13-c.heiss@proxmox.com> X-Mailer: git-send-email 2.51.2 In-Reply-To: <20251204125122.945961-1-c.heiss@proxmox.com> References: <20251204125122.945961-1-c.heiss@proxmox.com> MIME-Version: 1.0 X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1764852692642 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.050 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 Subject: [pdm-devel] [PATCH datacenter-manager 12/13] ui: auto-installer: add installations overview panel X-BeenThere: pdm-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Reply-To: Proxmox Datacenter Manager development discussion Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: pdm-devel-bounces@lists.proxmox.com Sender: "pdm-devel" A simple overview panel with a list of in-progress and on-going installations. Signed-off-by: Christoph Heiss --- lib/pdm-api-types/src/lib.rs | 2 +- ui/src/auto_installer/installations_panel.rs | 289 +++++++++++++++++++ ui/src/auto_installer/mod.rs | 4 + ui/src/lib.rs | 2 + ui/src/main_menu.rs | 13 + 5 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 ui/src/auto_installer/installations_panel.rs create mode 100644 ui/src/auto_installer/mod.rs diff --git a/lib/pdm-api-types/src/lib.rs b/lib/pdm-api-types/src/lib.rs index 98139f0..84c38ce 100644 --- a/lib/pdm-api-types/src/lib.rs +++ b/lib/pdm-api-types/src/lib.rs @@ -114,8 +114,8 @@ pub mod subscription; pub mod sdn; -pub mod views; pub mod auto_installer; +pub mod views; const_regex! { // just a rough check - dummy acceptor is used before persisting diff --git a/ui/src/auto_installer/installations_panel.rs b/ui/src/auto_installer/installations_panel.rs new file mode 100644 index 0000000..9c57bad --- /dev/null +++ b/ui/src/auto_installer/installations_panel.rs @@ -0,0 +1,289 @@ +//! Implements the UI components for displaying an overview view of all finished/in-progress +//! installations. + +use anyhow::{anyhow, Result}; +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, +}; +use pwt::{ + css::{Flex, Overflow}, + props::{ + ContainerBuilder, CssPaddingBuilder, EventSubscriber, FieldBuilder, WidgetBuilder, + WidgetStyleBuilder, + }, + state::{Selection, Store}, + tr, + widget::{ + data_table::{DataTable, DataTableColumn, DataTableHeader, DataTableMouseEvent}, + form::TextArea, + Button, Toolbar, + }, +}; +use yew::{ + virtual_dom::{Key, VComp, VNode}, + Properties, +}; + +#[derive(Default, PartialEq, Properties)] +pub struct AutoInstallerPanel {} + +impl From for VNode { + fn from(value: AutoInstallerPanel) -> Self { + let comp = VComp::new::>( + Rc::new(value), + None, + ); + VNode::from(comp) + } +} + +pub enum Message { + Refresh, + SelectionChange, + RemoveEntry, +} + +#[derive(PartialEq)] +pub enum ViewState { + ShowRawSystemInfo, + ShowRawPostHookData, +} + +#[derive(PartialEq, Properties)] +pub struct AutoInstallerPanelComponent { + selection: Selection, + store: Store, + columns: Rc>>, +} + +impl LoadableComponent for AutoInstallerPanelComponent { + type Properties = AutoInstallerPanel; + 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 { + selection, + store, + columns: Rc::new(columns()), + } + } + + fn load( + &self, + _ctx: &LoadableComponentContext, + ) -> Pin>>> { + let store = self.store.clone(); + Box::pin(async move { + let data = proxmox_yew_comp::http_get("/auto-install/installations", None).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(); + link.clone().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(pwt::css::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(ctx.loading()).onclick(ctx.link().callback(|_| Message::Refresh)), + ); + + Some(toolbar.into()) + } + + fn main_view(&self, ctx: &LoadableComponentContext) -> yew::Html { + DataTable::new(self.columns.clone(), self.store.clone()) + .class(pwt::css::FlexFit) + .selection(self.selection.clone()) + .on_row_dblclick({ + let link = ctx.link(); + move |_: &mut DataTableMouseEvent| { + 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 url = format!( + "/auto-install/installations/{}", + percent_encode_component(&key.to_string()) + ); + proxmox_yew_comp::http_delete(&url, None).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() + }) + .into(), + DataTableColumn::new(tr!("Product")) + .width("300px") + .render(|item: &Installation| { + format!( + "{} {}-{}", + item.info.product.fullname, item.info.iso.release, item.info.iso.isorelease + ) + .into() + }) + .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() + }) + .into(), + DataTableColumn::new(tr!("Matched answer")) + .flex(1) + .render(|item: &Installation| match &item.answer_id { + Some(s) => s.into(), + None => "-".into(), + }) + .into(), + ] +} diff --git a/ui/src/auto_installer/mod.rs b/ui/src/auto_installer/mod.rs new file mode 100644 index 0000000..810eade --- /dev/null +++ b/ui/src/auto_installer/mod.rs @@ -0,0 +1,4 @@ +//! Implements the UI for the proxmox-auto-installer integration. + +mod installations_panel; +pub use installations_panel::*; diff --git a/ui/src/lib.rs b/ui/src/lib.rs index 1aac757..e5a8826 100644 --- a/ui/src/lib.rs +++ b/ui/src/lib.rs @@ -59,6 +59,8 @@ pub use tasks::register_pve_tasks; mod view_list_context; pub use view_list_context::ViewListContext; +mod auto_installer; + pub fn pdm_client() -> pdm_client::PdmClient> { pdm_client::PdmClient(proxmox_yew_comp::CLIENT.with(|c| std::rc::Rc::clone(&c.borrow()))) } diff --git a/ui/src/main_menu.rs b/ui/src/main_menu.rs index 18988ea..073b84d 100644 --- a/ui/src/main_menu.rs +++ b/ui/src/main_menu.rs @@ -14,6 +14,7 @@ use proxmox_yew_comp::{AclContext, NotesView, XTermJs}; use pdm_api_types::remotes::RemoteType; use pdm_api_types::{PRIV_SYS_AUDIT, PRIV_SYS_MODIFY}; +use crate::auto_installer::AutoInstallerPanel; use crate::configuration::subscription_panel::SubscriptionPanel; use crate::configuration::views::ViewGrid; use crate::dashboard::view::View; @@ -378,6 +379,18 @@ impl Component for PdmMainMenu { remote_submenu, ); + let mut autoinstaller_submenu = Menu::new(); + + register_submenu( + &mut menu, + &mut content, + tr!("Automated Installations"), + "auto-installer", + Some("fa fa-cubes"), + |_| AutoInstallerPanel::default().into(), + autoinstaller_submenu, + ); + let drawer = NavigationDrawer::new(menu) .aria_label("Datacenter Manager") .class("pwt-border-end") -- 2.51.2 _______________________________________________ pdm-devel mailing list pdm-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel