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 6E5BF1FF13E for ; Fri, 03 Apr 2026 18:57:12 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id A41C58E12; Fri, 3 Apr 2026 18:57:42 +0200 (CEST) From: Christoph Heiss To: pdm-devel@lists.proxmox.com Subject: [PATCH datacenter-manager v3 22/38] ui: auto-installer: add prepared answer configuration panel Date: Fri, 3 Apr 2026 18:53:54 +0200 Message-ID: <20260403165437.2166551-23-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: 1775235339591 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: CNHBWZGOCWLUBUFL44L462ZTIWUSA6AJ X-Message-ID-Hash: CNHBWZGOCWLUBUFL44L462ZTIWUSA6AJ 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: Adds a pretty typical CRUD panel, allowing users to add/edit/remove prepared answer file configurations for the auto-install server. Signed-off-by: Christoph Heiss --- Changes v2 -> v3: * filters are now proper key-value `DataTable`s instead of plain text areas * added new "Templating" and "Authentication" tabs * adapted as necessary to changed types from `proxmox-installer-types` * use new `PdmClient` methods instead of manual post/put * removed automatic `/api2` suffix from pdm base url * set _target="blank" for RFC 6901 link Changes v1 -> v2: * new patch ui/src/remotes/auto_installer/mod.rs | 19 + .../prepared_answer_add_wizard.rs | 173 ++++ .../prepared_answer_edit_window.rs | 165 ++++ .../auto_installer/prepared_answer_form.rs | 857 ++++++++++++++++++ .../auto_installer/prepared_answers_panel.rs | 248 +++++ 5 files changed, 1462 insertions(+) create mode 100644 ui/src/remotes/auto_installer/prepared_answer_add_wizard.rs create mode 100644 ui/src/remotes/auto_installer/prepared_answer_edit_window.rs create mode 100644 ui/src/remotes/auto_installer/prepared_answer_form.rs create mode 100644 ui/src/remotes/auto_installer/prepared_answers_panel.rs diff --git a/ui/src/remotes/auto_installer/mod.rs b/ui/src/remotes/auto_installer/mod.rs index 8155a9b..1a85978 100644 --- a/ui/src/remotes/auto_installer/mod.rs +++ b/ui/src/remotes/auto_installer/mod.rs @@ -1,6 +1,10 @@ //! Implements the UI for the proxmox-auto-installer integration. mod installations_panel; +mod prepared_answer_add_wizard; +mod prepared_answer_edit_window; +mod prepared_answer_form; +mod prepared_answers_panel; use std::rc::Rc; use yew::virtual_dom::{VComp, VNode}; @@ -39,15 +43,30 @@ impl Component for AutoInstallerPanelComponent { .with_child(tr!("Installations")) .into(); + let answers_title: Html = Row::new() + .gap(2) + .class(AlignItems::Baseline) + .with_child(Fa::new("files-o")) + .with_child(tr!("Prepared Answers")) + .into(); + Container::new() .class("pwt-content-spacer") .class(Fit) .class(css::Display::Grid) + .style("grid-template-columns", "repeat(2, 1fr)") + .style("grid-template-rows", "repeat(1, 1fr)") .with_child( Panel::new() + .style("grid-row", "span 2 / span 1") .title(installations_title) .with_child(installations_panel::InstallationsPanel::default()), ) + .with_child( + Panel::new() + .title(answers_title) + .with_child(prepared_answers_panel::PreparedAnswersPanel::default()), + ) .into() } } diff --git a/ui/src/remotes/auto_installer/prepared_answer_add_wizard.rs b/ui/src/remotes/auto_installer/prepared_answer_add_wizard.rs new file mode 100644 index 0000000..5d15a43 --- /dev/null +++ b/ui/src/remotes/auto_installer/prepared_answer_add_wizard.rs @@ -0,0 +1,173 @@ +//! Implements the configuration dialog UI for the auto-installer integration. + +use anyhow::Result; +use js_sys::Intl; +use proxmox_installer_types::answer; +use std::{collections::BTreeMap, future::Future, pin::Pin, rc::Rc}; +use wasm_bindgen::JsValue; +use yew::{ + html::IntoEventCallback, + virtual_dom::{VComp, VNode}, +}; + +use pdm_api_types::auto_installer::{DiskSelectionMode, PreparedInstallationConfig}; +use proxmox_yew_comp::{ + LoadableComponent, LoadableComponentContext, LoadableComponentMaster, LoadableComponentState, + Wizard, WizardPageRenderInfo, +}; +use pwt::{prelude::*, widget::TabBarItem}; +use pwt_macros::builder; + +use super::prepared_answer_form::*; +use crate::pdm_client; + +#[derive(Clone, PartialEq, Properties)] +#[builder] +pub struct AddAnswerWizardProperties { + /// Dialog close callback. + #[builder_cb(IntoEventCallback, into_event_callback, ())] + #[prop_or_default] + pub on_done: Option>, + + /// Auto-installer answer configuration. + config: PreparedInstallationConfig, +} + +impl AddAnswerWizardProperties { + pub fn new() -> Self { + let config = PreparedInstallationConfig { + id: String::new(), + authorized_tokens: Vec::new(), + // target filter + is_default: false, + target_filter: BTreeMap::new(), + // global options + country: "at".to_owned(), + fqdn: "host.example.com".to_owned(), + use_dhcp_fqdn: false, + keyboard: answer::KeyboardLayout::default(), + mailto: String::new(), + timezone: js_timezone().unwrap_or_else(|| "Etc/UTC".to_owned()), + root_password_hashed: None, + reboot_on_error: false, + reboot_mode: answer::RebootMode::default(), + root_ssh_keys: Vec::new(), + // network options + use_dhcp_network: true, + cidr: None, + gateway: None, + dns: None, + netdev_filter: BTreeMap::new(), + netif_name_pinning_enabled: true, + // disk options + filesystem: answer::FilesystemOptions::Ext4(answer::LvmOptions::default()), + disk_mode: DiskSelectionMode::default(), + disk_list: Vec::new(), + disk_filter: BTreeMap::new(), + disk_filter_match: None, + // post hook + post_hook_base_url: pdm_origin(), + post_hook_cert_fp: None, + // templating + template_counters: BTreeMap::new(), + }; + + yew::props!(Self { config }) + } + + pub fn with(config: PreparedInstallationConfig) -> Self { + yew::props!(Self { config }) + } +} + +impl From for VNode { + fn from(value: AddAnswerWizardProperties) -> Self { + let comp = + VComp::new::>(Rc::new(value), None); + VNode::from(comp) + } +} + +struct AddAnswerWizardComponent { + state: LoadableComponentState<()>, +} + +pwt::impl_deref_mut_property!(AddAnswerWizardComponent, state, LoadableComponentState<()>); + +impl LoadableComponent for AddAnswerWizardComponent { + type Properties = AddAnswerWizardProperties; + type Message = (); + type ViewState = (); + + fn create(_ctx: &LoadableComponentContext) -> Self { + Self { + state: LoadableComponentState::new(), + } + } + + fn load( + &self, + _ctx: &LoadableComponentContext, + ) -> Pin>>> { + Box::pin(async move { Ok(()) }) + } + + fn main_view(&self, ctx: &LoadableComponentContext) -> Html { + let props = ctx.props(); + + Wizard::new(tr!("Add Prepared Answer")) + .width(900) + .resizable(true) + .on_done(props.on_done.clone()) + .on_submit(|config: serde_json::Value| async move { submit(config).await }) + .with_page(TabBarItem::new().label(tr!("Global options")), { + let config = props.config.clone(); + move |_: &WizardPageRenderInfo| render_global_options_form(&config, true) + }) + .with_page(TabBarItem::new().label(tr!("Network options")), { + let config = props.config.clone(); + move |p: &WizardPageRenderInfo| render_network_options_form(&p.form_ctx, &config) + }) + .with_page(TabBarItem::new().label(tr!("Disk Setup")), { + let config = props.config.clone(); + move |p: &WizardPageRenderInfo| render_disk_setup_form(&p.form_ctx, &config) + }) + .with_page(TabBarItem::new().label(tr!("Target filter")), { + let config = props.config.clone(); + move |p: &WizardPageRenderInfo| render_target_filter_form(&p.form_ctx, &config) + }) + .with_page(TabBarItem::new().label(tr!("Templating")), { + let config = props.config.clone(); + move |_: &WizardPageRenderInfo| render_templating_form(&config) + }) + .with_page(TabBarItem::new().label(tr!("Authentication")), { + let config = props.config.clone(); + move |_: &WizardPageRenderInfo| render_auth_form(&config) + }) + .into() + } +} + +async fn submit(form_data: serde_json::Value) -> Result<()> { + let data = prepare_form_data(form_data)?; + + pdm_client() + .add_autoinst_prepared_answer(&serde_json::from_value(data)?) + .await?; + Ok(()) +} + +fn js_timezone() -> Option { + let datetime_options = Intl::DateTimeFormat::default().resolved_options(); + js_sys::Reflect::get(&datetime_options, &JsValue::from_str("timeZone")) + .ok() + .and_then(|v| v.as_string()) +} + +fn pdm_origin() -> Option { + gloo_utils::document() + .url() + .and_then(|s| web_sys::Url::new(&s)) + .map(|url| url.origin()) + .ok() +} diff --git a/ui/src/remotes/auto_installer/prepared_answer_edit_window.rs b/ui/src/remotes/auto_installer/prepared_answer_edit_window.rs new file mode 100644 index 0000000..3fb9766 --- /dev/null +++ b/ui/src/remotes/auto_installer/prepared_answer_edit_window.rs @@ -0,0 +1,165 @@ +//! Implements the configuration dialog UI for the auto-installer integration. + +use anyhow::Result; +use std::{future::Future, pin::Pin, rc::Rc}; +use yew::{ + html::IntoEventCallback, + virtual_dom::{VComp, VNode}, +}; + +use crate::pdm_client; +use pdm_api_types::auto_installer::{ + DeletablePreparedInstallationConfigProperty, PreparedInstallationConfig, +}; +use proxmox_yew_comp::{ + form::delete_empty_values, percent_encoding::percent_encode_component, EditWindow, + LoadableComponent, LoadableComponentContext, LoadableComponentMaster, LoadableComponentState, +}; +use pwt::{ + css::FlexFit, + prelude::*, + widget::{form::FormContext, TabBarItem, TabPanel}, +}; +use pwt_macros::builder; + +use super::prepared_answer_form::*; + +#[derive(Clone, PartialEq, Properties)] +#[builder] +pub struct EditAnswerWindowProperties { + /// Dialog close callback. + #[builder_cb(IntoEventCallback, into_event_callback, ())] + #[prop_or_default] + pub on_done: Option>, + + /// Auto-installer answer configuration. + config: PreparedInstallationConfig, +} + +impl EditAnswerWindowProperties { + pub fn new(config: PreparedInstallationConfig) -> Self { + yew::props!(Self { config }) + } +} + +impl From for VNode { + fn from(value: EditAnswerWindowProperties) -> Self { + let comp = + VComp::new::>(Rc::new(value), None); + VNode::from(comp) + } +} + +struct EditAnswerWindowComponent { + state: LoadableComponentState<()>, +} + +pwt::impl_deref_mut_property!(EditAnswerWindowComponent, state, LoadableComponentState<()>); + +impl LoadableComponent for EditAnswerWindowComponent { + type Properties = EditAnswerWindowProperties; + type Message = (); + type ViewState = (); + + fn create(_ctx: &LoadableComponentContext) -> Self { + Self { + state: LoadableComponentState::new(), + } + } + + fn load( + &self, + _ctx: &LoadableComponentContext, + ) -> Pin>>> { + Box::pin(async move { Ok(()) }) + } + + fn main_view(&self, ctx: &LoadableComponentContext) -> Html { + let props = ctx.props(); + + EditWindow::new(tr!("Edit Prepared Answer")) + .width(900) + .resizable(true) + .on_done(props.on_done.clone()) + .renderer({ + let props = props.clone(); + move |form_ctx: &FormContext| render_tabpanel(form_ctx, &props) + }) + .edit(true) + .submit_digest(true) + .on_submit({ + let id = props.config.id.clone(); + move |form_ctx: FormContext| { + let id = id.clone(); + let config = form_ctx.get_submit_data(); + async move { submit(&percent_encode_component(&id), config).await } + } + }) + .advanced_checkbox(true) + .into() + } +} + +async fn submit(id: &str, form_data: serde_json::Value) -> Result<()> { + let data = delete_empty_values( + &prepare_form_data(form_data)?, + &[ + "root-ssh-keys", + "post-hook-base-url", + "post-hook-cert-fp", + "disk-filter", + "netdev-filter", + ], + true, + ); + + let root_password = data["root-password"].as_str().map(ToOwned::to_owned); + let delete = data["delete"] + .as_array() + .cloned() + .unwrap_or_default() + .iter() + .flat_map(|s| s.as_str().and_then(|s| s.parse().ok())) + .collect::>(); + + pdm_client() + .update_autoinst_prepared_answer( + id, + &serde_json::from_value(data)?, + root_password.as_deref(), + &delete, + ) + .await?; + Ok(()) +} + +fn render_tabpanel(form_ctx: &FormContext, props: &EditAnswerWindowProperties) -> yew::Html { + TabPanel::new() + .class(FlexFit) + .force_render_all(true) + .with_item( + TabBarItem::new().label(tr!("Global options")), + render_global_options_form(&props.config, false), + ) + .with_item( + TabBarItem::new().label(tr!("Network options")), + render_network_options_form(form_ctx, &props.config), + ) + .with_item( + TabBarItem::new().label(tr!("Disk Setup")), + render_disk_setup_form(form_ctx, &props.config), + ) + .with_item( + TabBarItem::new().label(tr!("Target filter")), + render_target_filter_form(form_ctx, &props.config), + ) + .with_item( + TabBarItem::new().label(tr!("Templating")), + render_templating_form(&props.config), + ) + .with_item( + TabBarItem::new().label(tr!("Authentication")), + render_auth_form(&props.config), + ) + .into() +} diff --git a/ui/src/remotes/auto_installer/prepared_answer_form.rs b/ui/src/remotes/auto_installer/prepared_answer_form.rs new file mode 100644 index 0000000..29bc768 --- /dev/null +++ b/ui/src/remotes/auto_installer/prepared_answer_form.rs @@ -0,0 +1,857 @@ +//! Provides all shared components for the prepared answer create wizard and the corresponding +//! edit window, as well as some utility to collect and prepare the form data for submission. + +use anyhow::{anyhow, bail, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::{collections::BTreeMap, ops::Deref, rc::Rc, sync::LazyLock}; + +use pdm_api_types::auto_installer::{ + DiskSelectionMode, PreparedInstallationConfig, PREPARED_INSTALL_CONFIG_ID_SCHEMA, +}; +use proxmox_installer_types::{ + answer::{ + BtrfsCompressOption, BtrfsOptions, FilesystemOptions, FilesystemType, FilterMatch, + KeyboardLayout, LvmOptions, RebootMode, ZfsChecksumOption, ZfsCompressOption, ZfsOptions, + BTRFS_COMPRESS_OPTIONS, FILESYSTEM_TYPE_OPTIONS, ROOT_PASSWORD_SCHEMA, + ZFS_CHECKSUM_OPTIONS, ZFS_COMPRESS_OPTIONS, + }, + EMAIL_DEFAULT_PLACEHOLDER, +}; +use proxmox_schema::api_types::{CIDR_SCHEMA, IP_SCHEMA}; +use proxmox_yew_comp::SchemaValidation; +use pwt::{ + css::{Flex, FlexFit, Overflow}, + prelude::*, + widget::{ + form::{Checkbox, Combobox, DisplayField, Field, FormContext, InputType, Number, TextArea}, + Container, Fa, FieldPosition, InputPanel, KeyValueList, + }, +}; + +pub fn prepare_form_data(mut value: serde_json::Value) -> Result { + let obj = value + .as_object_mut() + .ok_or_else(|| anyhow!("form data must always be an object"))?; + + let fs_opts = collect_fs_options(obj); + let disk_list: Vec = obj + .remove("disk-list") + .and_then(|s| { + s.as_str() + .map(|s| s.split(',').map(|s| s.trim().to_owned()).collect()) + }) + .unwrap_or_default(); + + let root_ssh_keys = collect_lines_into_array(obj.remove("root-ssh-keys")); + + value["filesystem"] = json!(fs_opts); + value["disk-list"] = json!(disk_list); + value["root-ssh-keys"] = root_ssh_keys; + Ok(value) +} + +fn collect_fs_options(obj: &mut serde_json::Map) -> FilesystemOptions { + let fs_type = obj + .get("filesystem-type") + .and_then(|s| s.as_str()) + .and_then(|s| s.parse::().ok()) + .unwrap_or_default(); + + let lvm_options = LvmOptions { + hdsize: obj.remove("hdsize").and_then(|v| v.as_f64()), + swapsize: obj.remove("swapsize").and_then(|v| v.as_f64()), + maxroot: obj.remove("maxroot").and_then(|v| v.as_f64()), + maxvz: obj.remove("maxvz").and_then(|v| v.as_f64()), + minfree: obj.remove("minfree").and_then(|v| v.as_f64()), + }; + + match fs_type { + FilesystemType::Ext4 => FilesystemOptions::Ext4(lvm_options), + FilesystemType::Xfs => FilesystemOptions::Xfs(lvm_options), + FilesystemType::Zfs(level) => FilesystemOptions::Zfs(ZfsOptions { + raid: Some(level), + ashift: obj + .remove("ashift") + .and_then(|v| v.as_u64()) + .map(|v| v as u32), + arc_max: obj + .remove("ashift") + .and_then(|v| v.as_u64()) + .map(|v| v as u32), + checksum: obj + .remove("checksum") + .and_then(|v| v.as_str().map(ToOwned::to_owned)) + .and_then(|s| s.parse::().ok()), + compress: obj + .remove("checksum") + .and_then(|v| v.as_str().map(ToOwned::to_owned)) + .and_then(|s| s.parse::().ok()), + copies: obj + .remove("copies") + .and_then(|v| v.as_u64()) + .map(|v| v as u32), + hdsize: obj.remove("hdsize").and_then(|v| v.as_f64()), + }), + FilesystemType::Btrfs(level) => FilesystemOptions::Btrfs(BtrfsOptions { + raid: Some(level), + compress: obj + .remove("checksum") + .and_then(|v| v.as_str().map(ToOwned::to_owned)) + .and_then(|s| s.parse::().ok()), + hdsize: obj.remove("hdsize").and_then(|v| v.as_f64()), + }), + } +} + +fn collect_lines_into_array(value: Option) -> Value { + value + .and_then(|v| v.as_str().map(|s| s.to_owned())) + .map(|s| { + json!(s + .split('\n') + .filter(|s| !s.is_empty()) + .collect::>()) + }) + .unwrap_or(Value::Null) +} + +pub fn render_global_options_form( + config: &PreparedInstallationConfig, + is_create: bool, +) -> yew::Html { + let mut panel = InputPanel::new() + .class(Flex::Fill) + .class(Overflow::Auto) + .padding(4); + + if is_create { + panel.add_field( + tr!("Installation ID"), + Field::new() + .name("id") + .value(config.id.clone()) + .schema(&PREPARED_INSTALL_CONFIG_ID_SCHEMA) + .required(true), + ); + } else { + panel.add_field( + tr!("Installation ID"), + DisplayField::new().value(config.id.clone()), + ); + } + + panel + .with_field( + tr!("Country"), + Combobox::new() + .name("country") + .placeholder(tr!("Two-letter country code, e.g. at")) + .items(Rc::new( + COUNTRY_INFO + .deref() + .keys() + .map(|s| s.as_str().into()) + .collect(), + )) + .render_value(|v: &AttrValue| { + if let Some(s) = COUNTRY_INFO.deref().get(&v.to_string()) { + s.into() + } else { + v.into() + } + }) + .value(config.country.clone()) + .required(true), + ) + .with_field( + tr!("Timezone"), + Field::new() + .name("timezone") + .value(config.timezone.clone()) + .placeholder(tr!("Timezone name, e.g. Europe/Vienna")) + .required(true), + ) + .with_field( + tr!("Root password"), + Field::new() + .name("root-password") + .input_type(InputType::Password) + .schema(&ROOT_PASSWORD_SCHEMA) + .placeholder((!is_create).then(|| tr!("Keep current"))) + .required(is_create), + ) + .with_field( + tr!("Keyboard Layout"), + Combobox::new() + .name("keyboard") + .items(Rc::new( + KEYBOARD_LAYOUTS + .iter() + .map(|l| serde_variant_name(l).expect("valid variant").into()) + .collect(), + )) + .render_value(|v: &AttrValue| { + v.parse::() + .map(|v| v.human_name().to_owned()) + .unwrap_or_default() + .into() + }) + .value(serde_variant_name(config.keyboard)) + .required(true), + ) + .with_field( + tr!("Administrator email address"), + Field::new() + .name("mailto") + .placeholder(EMAIL_DEFAULT_PLACEHOLDER.to_owned()) + .input_type(InputType::Email) + .value(config.mailto.clone()) + .validate(|s: &String| { + if s.ends_with(".invalid") { + bail!(tr!("Invalid (default) email address")) + } else { + Ok(()) + } + }) + .required(true), + ) + .with_field( + tr!("Root SSH public keys"), + TextArea::new() + .name("root-ssh-keys") + .class("pwt-w-100") + .submit_empty(false) + .attribute("rows", "3") + .placeholder(tr!("One per line, usually begins with \"ssh-\", \"sk-ssh-\", \"ecdsa-\" or \"sk-ecdsa\"")) + .value(config.root_ssh_keys.join("\n")), + ) + .with_field( + tr!("Reboot on error"), + Checkbox::new().name("reboot-on-error"), + ) + .with_field( + tr!("Post-Installation action"), + Combobox::new() + .name("reboot-mode") + .items(Rc::new( + [RebootMode::Reboot, RebootMode::PowerOff] + .iter() + .map(|opt| serde_variant_name(opt).expect("valid variant").into()) + .collect(), + )) + .render_value(|v: &AttrValue| match v.parse::() { + Ok(RebootMode::Reboot) => tr!("Reboot").into(), + Ok(RebootMode::PowerOff) => tr!("Power off").into(), + _ => v.into(), + }) + .value(serde_variant_name(config.reboot_mode)) + .required(true), + ) + .into() +} + +pub fn render_network_options_form( + form_ctx: &FormContext, + config: &PreparedInstallationConfig, +) -> yew::Html { + let use_dhcp_network = form_ctx + .read() + .get_field_value("use-dhcp-network") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + + let use_dhcp_fqdn = form_ctx + .read() + .get_field_value("use-dhcp-fqdn") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + + InputPanel::new() + .class(Flex::Fill) + .class(Overflow::Auto) + .padding(4) + .show_advanced(form_ctx.get_show_advanced()) + .with_field( + tr!("Use DHCP"), + Checkbox::new().name("use-dhcp-network").default(true), + ) + .with_field( + tr!("IP address (CIDR)"), + Field::new() + .name("cidr") + .placeholder(tr!("E.g. 192.168.0.100/24")) + .schema(&CIDR_SCHEMA) + .disabled(use_dhcp_network) + .required(!use_dhcp_network), + ) + .with_field( + tr!("Gateway address"), + Field::new() + .name("gateway") + .placeholder(tr!("E.g. 192.168.0.1")) + .schema(&IP_SCHEMA) + .disabled(use_dhcp_network) + .required(!use_dhcp_network), + ) + .with_field( + tr!("DNS server address"), + Field::new() + .name("dns") + .placeholder(tr!("E.g. 192.168.0.254")) + .schema(&IP_SCHEMA) + .disabled(use_dhcp_network) + .required(!use_dhcp_network), + ) + .with_right_field( + tr!("FQDN from DHCP"), + Checkbox::new().name("use-dhcp-fqdn").default(false), + ) + .with_right_field( + tr!("Fully-qualified domain name (FQDN)"), + Field::new() + .name("fqdn") + .placeholder("{{product.product}}{{installation-nr}}.example.com") + .value(config.fqdn.to_string()) + .disabled(use_dhcp_fqdn) + .tip(tr!( + "Hostname and domain to set for the target installation. Allows templating." + )) + .required(!use_dhcp_fqdn), + ) + .with_right_field("", DisplayField::new()) + .with_right_field( + tr!("Pin network interfaces"), + Checkbox::new() + .name("netif-name-pinning-enabled") + .default(config.netif_name_pinning_enabled), + ) + .with_advanced_spacer() + .with_large_advanced_field( + tr!("Network device filters"), + KeyValueList::new() + .value( + config + .netdev_filter + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + ) + .key_label(tr!("Property name")) + .value_label(tr!("Value to match")) + .key_placeholder(tr!("udev property name")) + .value_placeholder(tr!("glob to match")) + .submit_validate(kv_list_to_map) + .submit_empty(false) + .name("netdev-filter") + .class(FlexFit) + .disabled(use_dhcp_fqdn), + ) + .into() +} + +pub fn render_disk_setup_form( + form_ctx: &FormContext, + config: &PreparedInstallationConfig, +) -> yew::Html { + let disk_mode = form_ctx + .read() + .get_field_value("disk-mode") + .and_then(|v| v.as_str().and_then(|s| s.parse::().ok())) + .unwrap_or_default(); + + let fs_type = form_ctx + .read() + .get_field_value("filesystem-type") + .and_then(|v| v.as_str().and_then(|s| s.parse::().ok())) + .unwrap_or_default(); + + let mut panel = InputPanel::new() + .class(Flex::Fill) + .class(Overflow::Auto) + .padding(4) + .show_advanced(form_ctx.get_show_advanced()) + .with_field( + tr!("Filesystem"), + Combobox::new() + .name("filesystem-type") + .items(Rc::new( + FILESYSTEM_TYPE_OPTIONS + .iter() + .map(|opt| serde_variant_name(opt).expect("valid variant").into()) + .collect(), + )) + .render_value(|v: &AttrValue| { + v.parse::() + .map(|v| v.to_string()) + .unwrap_or_default() + .into() + }) + .value(serde_variant_name(config.filesystem.to_type())) + .required(true) + .show_filter(false), + ) + .with_right_field( + tr!("Disk selection mode"), + Combobox::new() + .name("disk-mode") + .with_item("fixed") + .with_item("filter") + .default("fixed") + .render_value(|v: &AttrValue| match v.parse::() { + Ok(DiskSelectionMode::Fixed) => tr!("Fixed list of disk names").into(), + Ok(DiskSelectionMode::Filter) => tr!("Dynamically by udev filter").into(), + _ => v.into(), + }) + .required(true) + .value(serde_variant_name(config.disk_mode)), + ) + .with_field( + tr!("Disk names"), + Field::new() + .name("disk-list") + .placeholder("sda, sdb") + .value(config.disk_list.join(", ")) + .disabled(disk_mode != DiskSelectionMode::Fixed) + .required(disk_mode == DiskSelectionMode::Fixed), + ) + .with_spacer() + .with_field( + tr!("Disk udev filter mode"), + Combobox::new() + .name("disk-filter-match") + .items(Rc::new( + [FilterMatch::Any, FilterMatch::All] + .iter() + .map(|opt| serde_variant_name(opt).expect("valid variant").into()) + .collect(), + )) + .render_value(|v: &AttrValue| match v.parse::() { + Ok(FilterMatch::Any) => tr!("Match any filter").into(), + Ok(FilterMatch::All) => tr!("Match all filters").into(), + _ => v.into(), + }) + .default(serde_variant_name(FilterMatch::default())) + .value(config.disk_filter_match.and_then(serde_variant_name)) + .disabled(disk_mode != DiskSelectionMode::Filter), + ) + .with_large_field( + tr!("Disk udev filters"), + KeyValueList::new() + .value( + config + .disk_filter + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + ) + .key_label(tr!("Property name")) + .value_label(tr!("Value to match")) + .key_placeholder(tr!("udev property name")) + .value_placeholder(tr!("glob to match")) + .submit_validate(kv_list_to_map) + .submit_empty(false) + .name("disk-filter") + .class(FlexFit) + .disabled(disk_mode != DiskSelectionMode::Filter), + ); + + let warning = match fs_type { + FilesystemType::Zfs(_) => Some( + tr!("ZFS is not compatible with hardware RAID controllers, for details see the documentation.") + ), + FilesystemType::Btrfs(_) => Some(tr!( + "Btrfs integration is a technology preview and only available for Proxmox Virtual Environment installations." + )), + _ => None, + }; + + if let Some(text) = warning { + panel.add_large_custom_child( + Container::from_tag("span") + .class("pwt-color-warning pwt-mt-2 pwt-d-block") + .with_child(Fa::new("exclamation-circle").class("fa-fw")) + .with_child(text), + ); + } + + panel.add_spacer(true); + + add_fs_advanced_form_fields(&mut panel, &config.filesystem); + panel.into() +} + +fn add_fs_advanced_form_fields(panel: &mut InputPanel, fs_opts: &FilesystemOptions) { + match fs_opts { + FilesystemOptions::Ext4(opts) | FilesystemOptions::Xfs(opts) => { + add_lvm_advanced_form_fields(panel, opts) + } + FilesystemOptions::Zfs(opts) => add_zfs_advanced_form_fields(panel, opts), + FilesystemOptions::Btrfs(opts) => add_btrfs_advanced_form_fields(panel, opts), + } +} + +fn add_lvm_advanced_form_fields(panel: &mut InputPanel, fs_opts: &LvmOptions) { + panel.add_field_with_options( + FieldPosition::Left, + true, + false, + tr!("Harddisk size to use (GB)"), + Number::new() + .name("hdsize") + .min(4.) + .step(0.1) + .submit_empty(false) + .value(fs_opts.hdsize.map(|v| v.to_string())), + ); + + panel.add_field_with_options( + FieldPosition::Left, + true, + false, + tr!("Swap size (GB)"), + Number::new() + .name("swapsize") + .min(0.) + .max(fs_opts.hdsize.map(|v| v / 2.)) + .step(0.1) + .submit_empty(false) + .value(fs_opts.swapsize.map(|v| v.to_string())), + ); + panel.add_field_with_options( + FieldPosition::Right, + true, + false, + tr!("Maximum root volume size (GB)"), + Number::new() + .name("maxroot") + .min(0.) + .max(fs_opts.hdsize.map(|v| v / 2.)) + .step(0.1) + .submit_empty(false) + .value(fs_opts.maxroot.map(|v| v.to_string())), + ); + panel.add_field_with_options( + FieldPosition::Right, + true, + false, + tr!("Maximum data volume size (GB)"), + Number::new() + .name("maxvz") + .min(0.) + .max(fs_opts.hdsize.map(|v| v / 2.)) + .step(0.1) + .submit_empty(false) + .value(fs_opts.maxvz.map(|v| v.to_string())), + ); + panel.add_field_with_options( + FieldPosition::Right, + true, + false, + tr!("Minimum free space in LVM volume group (GB)"), + Number::new() + .name("minfree") + .min(0.) + .max(fs_opts.hdsize.map(|v| v / 2.)) + .step(0.1) + .submit_empty(false) + .value(fs_opts.minfree.map(|v| v.to_string())), + ); +} + +fn add_zfs_advanced_form_fields(panel: &mut InputPanel, fs_opts: &ZfsOptions) { + panel.add_field_with_options( + FieldPosition::Left, + true, + false, + "ashift", + Number::::new() + .name("ashift") + .min(9) + .max(16) + .step(1) + .submit_empty(false) + .value(fs_opts.ashift.map(|v| v.to_string())), + ); + panel.add_field_with_options( + FieldPosition::Left, + true, + false, + tr!("ARC maximum size (MiB)"), + Number::new() + .name("arc-max") + .min(64.) + .step(1.) + .submit_empty(false) + .value(fs_opts.arc_max.map(|v| v.to_string())), + ); + panel.add_field_with_options( + FieldPosition::Right, + true, + false, + tr!("Checksumming algorithm"), + Combobox::new() + .name("checksum") + .items(Rc::new( + ZFS_CHECKSUM_OPTIONS + .iter() + .map(|opt| serde_variant_name(opt).expect("valid variant").into()) + .collect(), + )) + .render_value(|v: &AttrValue| { + v.parse::() + .map(|v| v.to_string()) + .unwrap_or_default() + .into() + }) + .submit_empty(false) + .value(fs_opts.checksum.map(|v| v.to_string())), + ); + panel.add_field_with_options( + FieldPosition::Right, + true, + false, + tr!("Compression algorithm"), + Combobox::new() + .name("compress") + .items(Rc::new( + ZFS_COMPRESS_OPTIONS + .iter() + .map(|opt| serde_variant_name(opt).expect("valid variant").into()) + .collect(), + )) + .render_value(|v: &AttrValue| { + v.parse::() + .map(|v| v.to_string()) + .unwrap_or_default() + .into() + }) + .submit_empty(false) + .value(fs_opts.compress.map(|v| v.to_string())), + ); + panel.add_field_with_options( + FieldPosition::Right, + true, + false, + tr!("Copies"), + Number::::new() + .name("copies") + .min(1) + .max(3) + .step(1) + .submit_empty(false) + .value(fs_opts.copies.map(|v| v.to_string())), + ); +} + +fn add_btrfs_advanced_form_fields(panel: &mut InputPanel, fs_opts: &BtrfsOptions) { + panel.add_field_with_options( + FieldPosition::Right, + true, + false, + tr!("Compression algorithm"), + Combobox::new() + .name("compress") + .items(Rc::new( + BTRFS_COMPRESS_OPTIONS + .iter() + .map(|opt| serde_variant_name(opt).expect("valid variant").into()) + .collect(), + )) + .render_value(|v: &AttrValue| { + v.parse::() + .map(|v| v.to_string()) + .unwrap_or_default() + .into() + }) + .submit_empty(false) + .value(fs_opts.compress.map(|v| v.to_string())), + ); +} + +pub fn render_target_filter_form( + form_ctx: &FormContext, + config: &PreparedInstallationConfig, +) -> yew::Html { + let is_default = form_ctx + .read() + .get_field_value("is-default") + .and_then(|v| v.as_bool()) + .unwrap_or(config.is_default); + + let mut panel = InputPanel::new() + .class(Flex::Fill) + .class(Overflow::Auto) + .padding(4); + + if !is_default && config.target_filter.is_empty() { + panel.add_large_custom_child( + Container::from_tag("span") + .class("pwt-color-warning pwt-mb-2 pwt-d-block") + .with_child(Fa::new("exclamation-circle").class("fa-fw")) + .with_child(tr!( + "Not marked as default answer and target filter are empty, answer will never be matched." + )) + ); + } + + panel + .with_field( + tr!("Default answer"), + Checkbox::new() + .name("is-default") + .default(config.is_default), + ) + .with_spacer() + .with_large_field( + tr!("Target filters"), + KeyValueList::new() + .value( + config + .target_filter + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + ) + .key_label(tr!("JSON pointer")) + .value_label(tr!("Value to match")) + .key_placeholder("/json/pointer".into()) + .value_placeholder(tr!("glob to match")) + .submit_validate(kv_list_to_map) + .submit_empty(false) + .name("target-filter") + .class(FlexFit) + .disabled(is_default), + ) + .with_right_custom_child(Container::new().with_child(html! { + + {tr!("references RFC 6901" => "Target filter keys are JSON pointers according to")} + {" "} + {"RFC 6901"} + {"."} + + })) + .into() +} + +pub fn render_templating_form(config: &PreparedInstallationConfig) -> yew::Html { + InputPanel::new() + .class(Flex::Fill) + .class(Overflow::Auto) + .padding(4) + .with_large_custom_child( + Container::from_tag("span") + .class("pwt-mb-2 pwt-d-block") + .with_child(tr!( + "Numerical template counters can be used to provide unique values across installations." + )), + ) + .with_large_custom_child( + KeyValueList::new() + .value( + config + .template_counters + .iter() + .map(|(k, v)| (k.clone(), *v)) + .collect(), + ) + .value_label(tr!("Current value")) + .value_input_type(InputType::Number) + .submit_validate(kv_list_to_map) + .submit_empty(false) + .name("template-counters") + .class(FlexFit), + ) + .with_right_custom_child( + Container::from_tag("span") + .class("pwt-mt-2 pwt-d-block") + .style("float", "right") + .with_child(tr!( + "Counters are automatically incremented each time an answer is served." + )), + ) + .into() +} + +pub fn render_auth_form(config: &PreparedInstallationConfig) -> yew::Html { + InputPanel::new() + .class(Flex::Fill) + .class(Overflow::Auto) + .padding(4) + .with_large_custom_child( + Container::from_tag("span") + .class("pwt-mb-2 pwt-mt-2 pwt-d-block pwt-color-primary") + .with_child(Fa::new("info-circle").class("fa-fw")) + .with_child(tr!( + "Optional. If provided, status reporting will be enabled." + )), + ) + .with_large_field( + tr!("Proxmox Datacenter Manager base URL"), + Field::new() + .name("post-hook-base-url") + .tip(tr!( + "Base URL this PDM instance is reachable from the target host" + )) + .value(config.post_hook_base_url.clone()), + ) + .with_large_field( + tr!("SHA256 certificate fingerprint"), + Field::new() + .name("post-hook-cert-fp") + .tip(tr!("Optional certificate fingerprint")) + .value(config.post_hook_cert_fp.clone()), + ) + .into() +} + +#[allow(clippy::ptr_arg)] +fn kv_list_to_map(v: &Vec<(String, T)>) -> Result { + let map: BTreeMap = v.iter().cloned().collect(); + Ok(serde_json::to_value(map)?) +} + +fn serde_variant_name(ty: T) -> Option { + match serde_json::to_value(ty) { + Ok(Value::String(s)) => Some(s), + other => { + log::warn!( + "expected string of type {}, got {other:?}", + std::any::type_name::() + ); + None + } + } +} + +const KEYBOARD_LAYOUTS: &[KeyboardLayout] = { + use KeyboardLayout::*; + &[ + De, DeCh, Dk, EnGb, EnUs, Es, Fi, Fr, FrBe, FrCa, FrCh, Hu, Is, It, Jp, Lt, Mk, Nl, No, Pl, + Pt, PtBr, Se, Si, Tr, + ] +}; + +static COUNTRY_INFO: LazyLock> = LazyLock::new(|| { + #[derive(Deserialize)] + struct Iso3611CountryInfo { + alpha_2: String, + common_name: Option, + name: String, + } + + #[derive(Deserialize)] + struct Iso3611Info { + #[serde(rename = "3166-1")] + list: Vec, + } + + let raw: Iso3611Info = + serde_json::from_str(include_str!("/usr/share/iso-codes/json/iso_3166-1.json")) + .expect("valid country-info json"); + + raw.list + .into_iter() + .map(|c| (c.alpha_2.to_lowercase(), c.common_name.unwrap_or(c.name))) + .collect() +}); diff --git a/ui/src/remotes/auto_installer/prepared_answers_panel.rs b/ui/src/remotes/auto_installer/prepared_answers_panel.rs new file mode 100644 index 0000000..975cab9 --- /dev/null +++ b/ui/src/remotes/auto_installer/prepared_answers_panel.rs @@ -0,0 +1,248 @@ +//! Implements the UI for the auto-installer answer editing panel. + +use anyhow::Result; +use core::clone::Clone; +use std::{future::Future, pin::Pin, rc::Rc}; +use yew::{ + html, + virtual_dom::{Key, VComp, VNode}, + Properties, +}; + +use pdm_api_types::auto_installer::PreparedInstallationConfig; +use proxmox_yew_comp::{ + percent_encoding::percent_encode_component, ConfirmButton, LoadableComponent, + LoadableComponentContext, LoadableComponentMaster, LoadableComponentScopeExt, + LoadableComponentState, +}; +use pwt::{ + props::{ContainerBuilder, EventSubscriber, WidgetBuilder}, + state::{Selection, Store}, + tr, + widget::{ + data_table::{DataTable, DataTableColumn, DataTableHeader}, + Button, Fa, Toolbar, + }, +}; + +use super::{ + prepared_answer_add_wizard::AddAnswerWizardProperties, + prepared_answer_edit_window::EditAnswerWindowProperties, +}; +use crate::pdm_client; + +#[derive(Default, PartialEq, Properties)] +pub struct PreparedAnswersPanel {} + +impl From for VNode { + fn from(value: PreparedAnswersPanel) -> Self { + let comp = VComp::new::>( + Rc::new(value), + None, + ); + VNode::from(comp) + } +} + +#[derive(PartialEq)] +enum ViewState { + Create, + Copy, + Edit, +} + +#[derive(PartialEq)] +enum Message { + SelectionChange, + RemoveEntry, +} + +struct PreparedAnswersPanelComponent { + state: LoadableComponentState, + selection: Selection, + store: Store, + columns: Rc>>, +} + +pwt::impl_deref_mut_property!( + PreparedAnswersPanelComponent, + state, + LoadableComponentState +); + +impl LoadableComponent for PreparedAnswersPanelComponent { + type Properties = PreparedAnswersPanel; + type Message = Message; + type ViewState = ViewState; + + fn create(ctx: &LoadableComponentContext) -> Self { + let store = Store::with_extract_key(|record: &PreparedInstallationConfig| { + Key::from(record.id.to_string()) + }); + store.set_sorter( + |a: &PreparedInstallationConfig, b: &PreparedInstallationConfig| a.id.cmp(&b.id), + ); + + Self { + state: LoadableComponentState::new(), + selection: Selection::new() + .on_select(ctx.link().callback(|_| Message::SelectionChange)), + 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_prepared_answers().await?; + store.write().set_data(data); + Ok(()) + }) + } + + fn update(&mut self, ctx: &LoadableComponentContext, msg: Message) -> bool { + let link = ctx.link().clone(); + + match msg { + Message::SelectionChange => true, + Message::RemoveEntry => { + if let Some(key) = self.selection.selected_key() { + self.spawn(async move { + if let Err(err) = pdm_client() + .delete_autoinst_prepared_answer(&percent_encode_component( + &key.to_string(), + )) + .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().clone(); + + let toolbar = Toolbar::new() + .class("pwt-w-100") + .class(pwt::css::Overflow::Hidden) + .class("pwt-border-bottom") + .with_child( + Button::new(tr!("Add")) + .onclick(link.change_view_callback(|_| Some(ViewState::Create))), + ) + .with_spacer() + .with_child( + Button::new(tr!("Copy")) + .onclick(link.change_view_callback(|_| Some(ViewState::Copy))), + ) + .with_child( + Button::new(tr!("Edit")) + .disabled(self.selection.is_empty()) + .onclick(link.change_view_callback(|_| Some(ViewState::Edit))), + ) + .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)), + ); + + 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(pwt::css::FlexFit) + .selection(self.selection.clone()) + .on_row_dblclick(move |_: &mut _| link.change_view(Some(Self::ViewState::Edit))) + .into() + } + + fn dialog_view( + &self, + ctx: &LoadableComponentContext, + view_state: &Self::ViewState, + ) -> Option { + let on_done = ctx.link().clone().change_view_callback(|_| None); + + Some(match view_state { + Self::ViewState::Create => AddAnswerWizardProperties::new().on_done(on_done).into(), + Self::ViewState::Copy => { + let mut record = self + .store + .read() + .lookup_record(&self.selection.selected_key()?)? + .clone(); + + record.id += " (copy)"; + AddAnswerWizardProperties::with(record) + .on_done(on_done) + .into() + } + Self::ViewState::Edit => { + let record = self + .store + .read() + .lookup_record(&self.selection.selected_key()?)? + .clone(); + + EditAnswerWindowProperties::new(record) + .on_done(on_done) + .into() + } + }) + } +} + +fn columns() -> Vec> { + vec![ + DataTableColumn::new(tr!("ID")) + .width("320px") + .render(|item: &PreparedInstallationConfig| html! { &item.id }) + .sorter( + |a: &PreparedInstallationConfig, b: &PreparedInstallationConfig| a.id.cmp(&b.id), + ) + .sort_order(Some(true)) + .into(), + DataTableColumn::new(tr!("Default")) + .width("170px") + .render(|item: &PreparedInstallationConfig| { + if item.is_default { + Fa::new("check").into() + } else { + Fa::new("times").into() + } + }) + .into(), + DataTableColumn::new(tr!("Target filter")) + .flex(1) + .render(|item: &PreparedInstallationConfig| { + if item.target_filter.is_empty() { + "-".into() + } else { + item.target_filter + .iter() + .fold(String::new(), |acc, (k, v)| { + if acc.is_empty() { + format!("{k}={v}") + } else { + format!("{acc}, {k}={v}") + } + }) + .into() + } + }) + .into(), + ] +} -- 2.53.0