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 3D6901FF1A6 for ; Fri, 5 Dec 2025 12:26:19 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 467021575B; Fri, 5 Dec 2025 12:26:47 +0100 (CET) From: Christoph Heiss To: pdm-devel@lists.proxmox.com Date: Fri, 5 Dec 2025 12:25:15 +0100 Message-ID: <20251205112528.373387-14-c.heiss@proxmox.com> X-Mailer: git-send-email 2.51.2 In-Reply-To: <20251205112528.373387-1-c.heiss@proxmox.com> References: <20251205112528.373387-1-c.heiss@proxmox.com> MIME-Version: 1.0 X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1764933948415 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.047 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 POISEN_SPAM_PILL 0.1 Meta: its spam POISEN_SPAM_PILL_1 0.1 random spam to be learned in bayes 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 v2 13/14] ui: auto-installer: add prepared answer configuration 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" 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 v1 -> v2: * no changes ui/Cargo.toml | 4 + ui/src/auto_installer/add_wizard.rs | 142 +++ ui/src/auto_installer/answer_form.rs | 849 ++++++++++++++++++ ui/src/auto_installer/edit_window.rs | 105 +++ ui/src/auto_installer/mod.rs | 11 + .../auto_installer/prepared_answers_panel.rs | 233 +++++ ui/src/main_menu.rs | 11 +- 7 files changed, 1354 insertions(+), 1 deletion(-) create mode 100644 ui/src/auto_installer/add_wizard.rs create mode 100644 ui/src/auto_installer/answer_form.rs create mode 100644 ui/src/auto_installer/edit_window.rs create mode 100644 ui/src/auto_installer/prepared_answers_panel.rs diff --git a/ui/Cargo.toml b/ui/Cargo.toml index 2b4713c..4ba38c5 100644 --- a/ui/Cargo.toml +++ b/ui/Cargo.toml @@ -42,6 +42,8 @@ proxmox-schema = "5" proxmox-subscription = { version = "1.0.1", features = ["api-types"], default-features = false } proxmox-rrd-api-types = "1" proxmox-node-status = "1" +proxmox-network-types = "0.1" +proxmox-installer-types = "0.1" pbs-api-types = { version = "1.0.3", features = [ "enum-fallback" ] } pdm-api-types = { version = "1.0", path = "../lib/pdm-api-types" } @@ -54,6 +56,8 @@ 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-network-types = { path = "../proxmox/proxmox-network-types" } +# 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/auto_installer/add_wizard.rs b/ui/src/auto_installer/add_wizard.rs new file mode 100644 index 0000000..5e392a5 --- /dev/null +++ b/ui/src/auto_installer/add_wizard.rs @@ -0,0 +1,142 @@ +//! Implements the configuration dialog UI for the auto-installer integration. + +use js_sys::Intl; +use proxmox_installer_types::answer; +use proxmox_network_types::fqdn::Fqdn; +use proxmox_schema::property_string::PropertyString; +use std::rc::Rc; +use wasm_bindgen::JsValue; +use yew::{ + html::IntoEventCallback, + virtual_dom::{VComp, VNode}, +}; + +use crate::auto_installer::answer_form::*; +use pdm_api_types::auto_installer::{DiskSelectionMode, PreparedInstallationConfig}; +use proxmox_yew_comp::{Wizard, WizardPageRenderInfo}; +use pwt::prelude::*; +use pwt::widget::TabBarItem; +use pwt_macros::builder; + +#[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 with(config: PreparedInstallationConfig) -> Self { + yew::props!(Self { config }) + } +} + +impl Default for AddAnswerWizardProperties { + fn default() -> Self { + let config = PreparedInstallationConfig { + id: String::new(), + // target filter + is_default: false, + target_filter: Vec::new(), + // global options + country: "at".to_owned(), + fqdn: "host.example.com" + .parse::() + .expect("known valid fqdn"), + use_dhcp_fqdn: false, + keyboard: answer::KeyboardLayout::default(), + mailto: "root@example.invalid".to_owned(), + timezone: get_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: Vec::new(), + netif_name_pinning_enabled: true, + // disk options + filesystem_type: answer::FilesystemType::default(), + filesystem_options: PropertyString::new(answer::FilesystemOptions::Lvm( + answer::LvmOptions::default(), + )), + disk_mode: DiskSelectionMode::default(), + disk_list: None, + disk_filter: Vec::new(), + disk_filter_match: None, + post_hook_base_url: None, + post_hook_cert_fp: None, + }; + + yew::props!(Self { config }) + } +} + +impl From for VNode { + fn from(value: AddAnswerWizardProperties) -> Self { + let comp = VComp::new::(Rc::new(value), None); + VNode::from(comp) + } +} + +pub struct AddAnswerWizardComponent {} + +impl Component for AddAnswerWizardComponent { + type Message = (); + type Properties = AddAnswerWizardProperties; + + fn create(_ctx: &Context) -> Self { + Self {} + } + + fn view(&self, ctx: &Context) -> Html { + let props = ctx.props(); + let url = "/auto-install/prepared"; + + Wizard::new(tr!("Add Prepared Answer")) + .width(900) + .on_done(props.on_done.clone()) + .on_submit({ + move |config: serde_json::Value| async move { submit(url, None, config).await } + }) + .with_page( + TabBarItem::new().key("global").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!("Post-installation")), { + let config = props.config.clone(); + move |_: &WizardPageRenderInfo| render_post_hook_form(&config) + }) + .into() + } +} + +fn get_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()) +} diff --git a/ui/src/auto_installer/answer_form.rs b/ui/src/auto_installer/answer_form.rs new file mode 100644 index 0000000..44a3ade --- /dev/null +++ b/ui/src/auto_installer/answer_form.rs @@ -0,0 +1,849 @@ +use anyhow::{anyhow, bail, Result}; +use proxmox_network_types::fqdn::Fqdn; +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, + }, + ProxmoxProduct, EMAIL_DEFAULT_PLACEHOLDER, +}; +use proxmox_schema::{ + api_types::{CIDR_SCHEMA, IP_SCHEMA}, + property_string::PropertyString, +}; +use proxmox_yew_comp::{form::delete_empty_values, SchemaValidation}; +use pwt::widget::{ + form::{Checkbox, Combobox, DisplayField, Field, FormContext, InputType}, + Container, FieldPosition, InputPanel, +}; +use pwt::{ + css::{Flex, Overflow}, + prelude::*, + widget::form::{Number, TextArea}, +}; + +pub async fn submit( + url: &str, + existing_id: Option<&str>, + mut config: serde_json::Value, +) -> Result<()> { + let obj = config.as_object_mut().expect("always an object"); + + let fs_opts = collect_fs_options_into_propstring(obj); + obj.insert("filesystem-options".to_owned(), json!(fs_opts)); + + let root_ssh_keys = collect_lines_into_array(obj.remove("root-ssh-keys")); + let target_filter = collect_lines_into_array(obj.remove("target-filter")); + let disk_filter = collect_lines_into_array(obj.remove("disk-filter-text")); + let netdev_filter = collect_lines_into_array(obj.remove("netdev-filter-text")); + + config["root-ssh-keys"] = root_ssh_keys; + config["target-filter"] = target_filter; + config["disk-filter"] = disk_filter; + config["netdev-filter"] = netdev_filter; + + if let Some(id) = existing_id { + config["id"] = json!(id); + let data = delete_empty_values( + &config, + &[ + "root-ssh-keys", + "post-hook-base-url", + "post-hook-cert-fp", + "disk-filter", + "netdev-filter", + ], + true, + ); + proxmox_yew_comp::http_put(url, Some(data)).await + } else { + proxmox_yew_comp::http_post(url, Some(config)).await + } +} + +fn collect_fs_options_into_propstring( + obj: &mut serde_json::Map, +) -> PropertyString { + let fs_type = obj + .get("filesystem-type") + .and_then(|s| s.as_str()) + .and_then(|s| s.parse::().ok()) + .unwrap_or_default(); + + match fs_type { + FilesystemType::Ext4 | FilesystemType::Xfs => { + PropertyString::new(FilesystemOptions::Lvm(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()), + })) + } + FilesystemType::Zfs(level) => PropertyString::new(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) => { + PropertyString::new(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').collect::>())) + .unwrap_or_else(|| json!([])) +} + +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.to_string()) + .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 network information"), + 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!("Use 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("machine.example.com") + .value(config.fqdn.to_string()) + .disabled(use_dhcp_fqdn) + .validate(|s: &String| { + s.parse::() + .map_err(|err| anyhow!("{err}")) + .map(|_| ()) + }) + .required(!use_dhcp_fqdn), + ) + .with_right_field("", DisplayField::new()) // dummy + .with_right_field( + tr!("Enable network interface name pinning"), + Checkbox::new() + .name("netif-name-pinning-enabled") + .default(config.netif_name_pinning_enabled), + ) + .with_spacer() + .with_large_field( + tr!("Network device udev filters"), + TextArea::new() + .name("netdev-filter-text") + .class("pwt-w-100") + .style("resize", "vertical") + .submit_empty(false) + .attribute("rows", "3") + .placeholder(tr!("UDEV_PROP=glob*, e.g. ID_NET_DRIVER=foo, one per line")) + .value(config.netdev_filter.iter().fold(String::new(), |acc, s| { + if acc.is_empty() { + s.to_string() + } else { + format!("{acc}\n{s}") + } + })) + .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 product_filter = form_ctx + .read() + .get_field_value("target-filter-product") + .and_then(|v| v.as_str().and_then(|s| s.parse::().ok())); + + // Btrfs is only enabled for PVE installations + let filter_btrfs = |fstype: &&FilesystemType| -> bool { + product_filter == Some(ProxmoxProduct::PVE) || !fstype.is_btrfs() + }; + + 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() + .filter(filter_btrfs) + .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_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_spacer() + .with_field( + tr!("Disk names"), + Field::new() + .name("disk-list") + .placeholder(tr!("E.g.") + " sda, sdb") + .value( + config + .disk_list + .as_ref() + .map(|s| s.to_owned()) + .unwrap_or_default(), + ) + .disabled(disk_mode != DiskSelectionMode::Fixed) + .required(disk_mode == DiskSelectionMode::Fixed), + ) + .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"), + TextArea::new() + .name("disk-filter-text") + .class("pwt-w-100") + .style("resize", "vertical") + .submit_empty(false) + .attribute("rows", "3") + .placeholder(tr!( + "UDEV_PROP=glob*, e.g. ID_MODEL=VENDORFOO_SSD0, one per line" + )) + .value(config.disk_filter.iter().fold(String::new(), |acc, s| { + if acc.is_empty() { + s.to_string() + } else { + format!("{acc}\n{s}") + } + })) + .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.")), + _ => None, + }; + + if let Some(text) = warning { + panel.add_large_custom_child(html! { + + + {text} + + }); + } + + panel.add_spacer(true); + + add_fs_advanced_form_fields(&mut panel, &config.filesystem_options); + panel.into() +} + +fn add_fs_advanced_form_fields(panel: &mut InputPanel, fs_opts: &FilesystemOptions) { + match fs_opts { + FilesystemOptions::Lvm(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)"), + Field::new() + .name("hdsize") + .number(4., None, 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)"), + Field::new() + .name("swapsize") + .number(0., fs_opts.hdsize.map(|v| v / 2.), 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)"), + Field::new() + .name("maxroot") + .number(0., fs_opts.hdsize.map(|v| v / 2.), 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)"), + Field::new() + .name("maxvz") + .number(0., fs_opts.hdsize.map(|v| v / 2.), 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)"), + Field::new() + .name("minfree") + .number(0., fs_opts.hdsize.map(|v| v / 2.), 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)"), + Field::new() + .name("arc-max") + .number(64., None, 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 target_filter = form_ctx + .read() + .get_field_value("target-filter") + .and_then(|v| v.as_str().map(|s| s.to_string())) + .unwrap_or_else(|| { + config.target_filter.iter().fold(String::new(), |acc, s| { + if acc.is_empty() { + s.to_string() + } else { + format!("{acc}\n{s}") + } + }) + }); + + let mut panel = InputPanel::new() + .class(Flex::Fill) + .class(Overflow::Auto) + .padding(4); + + if !is_default && target_filter.is_empty() { + panel.add_large_custom_child(html! { + + + {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"), + TextArea::new() + .name("target-filter") + .class("pwt-w-100") + .style("resize", "vertical") + .submit_empty(false) + .attribute("rows", "4") + .placeholder(tr!( + "/json/pointer=value, one per line, for example: /product/product=pve" + )) + .default(target_filter) + .disabled(is_default), + ) + .with_right_custom_child(Container::new().with_child(html! { + + {tr!("Target filter keys are JSON pointers according to")} + {" "} + {"RFC 6906"} + {"."} + + })) + .into() +} + +pub fn render_post_hook_form(config: &PreparedInstallationConfig) -> yew::Html { + InputPanel::new() + .class(Flex::Fill) + .class(Overflow::Auto) + .padding(4) + .with_large_custom_child(html! { + + + {tr!("Optional. If provided, progress reporting is enabled.")} + + }) + .with_field( + tr!("PDM API 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() + .or_else(|| pdm_origin().map(|s| format!("{s}/api2"))), + ), + ) + .with_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() +} + +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 + } + } +} + +fn pdm_origin() -> Option { + gloo_utils::document() + .url() + .and_then(|s| web_sys::Url::new(&s)) + .map(|url| url.origin()) + .ok() +} + +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/auto_installer/edit_window.rs b/ui/src/auto_installer/edit_window.rs new file mode 100644 index 0000000..7054eea --- /dev/null +++ b/ui/src/auto_installer/edit_window.rs @@ -0,0 +1,105 @@ +//! Implements the configuration dialog UI for the auto-installer integration. + +use std::rc::Rc; +use yew::{ + html::IntoEventCallback, + virtual_dom::{VComp, VNode}, +}; + +use crate::auto_installer::answer_form::*; +use pdm_api_types::auto_installer::PreparedInstallationConfig; +use proxmox_yew_comp::{percent_encoding::percent_encode_component, EditWindow}; +use pwt::prelude::*; +use pwt::widget::{form::FormContext, TabBarItem, TabPanel}; +use pwt_macros::builder; + +#[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) + } +} + +pub struct EditAnswerWindowComponent {} + +impl Component for EditAnswerWindowComponent { + type Message = (); + type Properties = EditAnswerWindowProperties; + + fn create(_ctx: &Context) -> Self { + Self {} + } + + fn view(&self, ctx: &Context) -> Html { + let props = ctx.props(); + let url = format!( + "/auto-install/prepared/{}", + percent_encode_component(&props.config.id) + ); + + EditWindow::new(tr!("Edit Prepared Answer")) + .width(900) + .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 url = url.clone(); + let id = props.config.id.clone(); + move |form_ctx: FormContext| { + let url = url.clone(); + let id = id.clone(); + let config = form_ctx.get_submit_data(); + async move { submit(&url, Some(&id), config).await } + } + }) + .advanced_checkbox(true) + .into() + } +} + +fn render_tabpanel(form_ctx: &FormContext, props: &EditAnswerWindowProperties) -> yew::Html { + TabPanel::new() + .with_item( + TabBarItem::new().key("global").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!("Post-installation")), + render_post_hook_form(&props.config), + ) + .into() +} diff --git a/ui/src/auto_installer/mod.rs b/ui/src/auto_installer/mod.rs index 810eade..1702e68 100644 --- a/ui/src/auto_installer/mod.rs +++ b/ui/src/auto_installer/mod.rs @@ -1,4 +1,15 @@ //! Implements the UI for the proxmox-auto-installer integration. +mod answer_form; + +mod add_wizard; +pub use add_wizard::*; + +mod edit_window; +pub use edit_window::*; + +mod prepared_answers_panel; +pub use prepared_answers_panel::*; + mod installations_panel; pub use installations_panel::*; diff --git a/ui/src/auto_installer/prepared_answers_panel.rs b/ui/src/auto_installer/prepared_answers_panel.rs new file mode 100644 index 0000000..32de81a --- /dev/null +++ b/ui/src/auto_installer/prepared_answers_panel.rs @@ -0,0 +1,233 @@ +//! Implements the UI for the auto-installer answer editing panel. + +use anyhow::Result; +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, +}; +use pwt::{ + props::{ContainerBuilder, EventSubscriber, WidgetBuilder}, + state::{Selection, Store}, + tr, + widget::{ + data_table::{DataTable, DataTableColumn, DataTableHeader, DataTableMouseEvent}, + Button, Fa, Toolbar, + }, +}; + +#[derive(Default, PartialEq, Properties)] +pub struct AutoInstallerPreparedAnswersPanel {} + +impl From for VNode { + fn from(value: AutoInstallerPreparedAnswersPanel) -> Self { + let comp = VComp::new::>( + Rc::new(value), + None, + ); + VNode::from(comp) + } +} + +#[derive(PartialEq)] +pub enum ViewState { + Create, + Copy, + Edit, +} + +#[derive(PartialEq)] +pub enum Message { + SelectionChange, + RemoveEntry, +} + +#[derive(PartialEq, Properties)] +pub struct AutoInstallerPreparedAnswersPanelComponent { + selection: Selection, + store: Store, + columns: Rc>>, +} + +impl LoadableComponent for AutoInstallerPreparedAnswersPanelComponent { + type Properties = AutoInstallerPreparedAnswersPanel; + 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 { + selection: Selection::new() + .on_select(ctx.link().callback(|_| Message::SelectionChange)), + store, + columns: Rc::new(columns()), + } + } + + fn load( + &self, + _ctx: &LoadableComponentContext, + ) -> Pin>>> { + let path = "/auto-install/prepared".to_string(); + let store = self.store.clone(); + Box::pin(async move { + let data = proxmox_yew_comp::http_get(&path, None).await?; + store.write().set_data(data); + Ok(()) + }) + } + + fn update(&mut self, ctx: &LoadableComponentContext, msg: Message) -> bool { + match msg { + Message::SelectionChange => true, + 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 toolbar = Toolbar::new() + .class("pwt-w-100") + .class(pwt::css::Overflow::Hidden) + .class("pwt-border-bottom") + .with_child(Button::new(tr!("Add")).onclick({ + let link = ctx.link(); + move |_| { + link.change_view(Some(ViewState::Create)); + } + })) + .with_spacer() + .with_child(Button::new(tr!("Copy")).onclick({ + let link = ctx.link(); + move |_| { + link.change_view(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 { + 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::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 => super::AddAnswerWizardProperties::default() + .on_done(on_done) + .into(), + Self::ViewState::Copy => { + let mut record = self + .store + .read() + .lookup_record(&self.selection.selected_key()?)? + .clone(); + + record.id += " (copy)"; + super::AddAnswerWizardProperties::with(record) + .on_done(on_done) + .into() + } + Self::ViewState::Edit => { + let record = self + .store + .read() + .lookup_record(&self.selection.selected_key()?)? + .clone(); + + super::EditAnswerWindowProperties::new(record) + .on_done(on_done) + .into() + } + }) + } +} + +async fn delete_entry(key: Key) -> Result<()> { + let url = format!( + "/auto-install/prepared/{}", + percent_encode_component(&key.to_string()) + ); + proxmox_yew_comp::http_delete(&url, None).await +} + +fn columns() -> Vec> { + vec![ + DataTableColumn::new(tr!("ID")) + .width("320px") + .render(|item: &PreparedInstallationConfig| html! { &item.id }) + .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| { + item.target_filter + .iter() + .map(|s| s.to_string()) + .reduce(|acc, s| format!("{acc}, {s}")) + .unwrap_or_else(|| "-".to_owned()) + .into() + }) + .into(), + ] +} diff --git a/ui/src/main_menu.rs b/ui/src/main_menu.rs index 073b84d..531174b 100644 --- a/ui/src/main_menu.rs +++ b/ui/src/main_menu.rs @@ -14,7 +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::auto_installer::{AutoInstallerPanel, AutoInstallerPreparedAnswersPanel}; use crate::configuration::subscription_panel::SubscriptionPanel; use crate::configuration::views::ViewGrid; use crate::dashboard::view::View; @@ -381,6 +381,15 @@ impl Component for PdmMainMenu { let mut autoinstaller_submenu = Menu::new(); + register_view( + &mut autoinstaller_submenu, + &mut content, + tr!("Prepared Answers"), + "auto-installer-prepared", + Some("fa fa-files-o"), + |_| AutoInstallerPreparedAnswersPanel::default().into(), + ); + register_submenu( &mut menu, &mut content, -- 2.51.2 _______________________________________________ pdm-devel mailing list pdm-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel