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 290C71FF17A for ; Tue, 9 Dec 2025 14:00:50 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 11846508; Tue, 9 Dec 2025 14:01:29 +0100 (CET) Mime-Version: 1.0 Date: Tue, 09 Dec 2025 14:01:18 +0100 Message-Id: From: "Lukas Wagner" To: "Proxmox Datacenter Manager development discussion" , "Christoph Heiss" X-Mailer: aerc 0.21.0-0-g5549850facc2-dirty References: <20251205112528.373387-1-c.heiss@proxmox.com> <20251205112528.373387-14-c.heiss@proxmox.com> In-Reply-To: <20251205112528.373387-14-c.heiss@proxmox.com> X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1765285272513 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.118 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 POISEN_SPAM_PILL_3 0.1 random spam to be learned in bayes RCVD_IN_VALIDITY_CERTIFIED_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. RCVD_IN_VALIDITY_RPBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. RCVD_IN_VALIDITY_SAFE_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record URIBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to URIBL was blocked. See http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more information. [rfc-editor.org, mod.rs] Subject: Re: [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" A couple of notes inline. Nice work overall! On Fri Dec 5, 2025 at 12:25 PM CET, Christoph Heiss wrote: > 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 {} This type can also be private :) > + > +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 > + } > +} In general, I think it would be better to add bindings for these new APIs in the pdm_client crate and then use the actual PDM client here, as we do in most other places in the GUI. > + > +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"} > + {"."} > + > + })) Would be great if this link would open in a new brower tab - otherwise you lose the state of the wizard when clicking the link (happened to me when trying this out - twice) > + .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"))), I think this is lacking a `/json` at the end - anyways, the /api2/json part could probably be added automatically, right? So that the user just needs to provide the base URL? (e.g. https://somehost:8443) > + ), > + ) > + .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, ())]u > + #[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 {} This also could be private, I think. > + > +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>>, > +} ViewState, Message and the struct above could be made private. > + > +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 > +} Here as well, I think it would be nicer to implement this in the pdm_client crate. > + > +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, _______________________________________________ pdm-devel mailing list pdm-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel