From: "Lukas Wagner" <l.wagner@proxmox.com>
To: "Proxmox Datacenter Manager development discussion"
<pdm-devel@lists.proxmox.com>,
"Christoph Heiss" <c.heiss@proxmox.com>
Subject: Re: [pdm-devel] [PATCH datacenter-manager v2 13/14] ui: auto-installer: add prepared answer configuration panel
Date: Tue, 09 Dec 2025 14:01:18 +0100 [thread overview]
Message-ID: <DETPELECLMGM.1TWRKAIUWYCZF@proxmox.com> (raw)
In-Reply-To: <20251205112528.373387-14-c.heiss@proxmox.com>
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 <c.heiss@proxmox.com>
> ---
> 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<Callback<()>>,
> +
> + /// 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::<Fqdn>()
> + .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<AddAnswerWizardProperties> for VNode {
> + fn from(value: AddAnswerWizardProperties) -> Self {
> + let comp = VComp::new::<AddAnswerWizardComponent>(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 {
> + Self {}
> + }
> +
> + fn view(&self, ctx: &Context<Self>) -> 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<String> {
> + 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<String, Value>,
> +) -> PropertyString<FilesystemOptions> {
> + let fs_type = obj
> + .get("filesystem-type")
> + .and_then(|s| s.as_str())
> + .and_then(|s| s.parse::<FilesystemType>().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::<ZfsChecksumOption>().ok()),
> + compress: obj
> + .remove("checksum")
> + .and_then(|v| v.as_str().map(ToOwned::to_owned))
> + .and_then(|s| s.parse::<ZfsCompressOption>().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::<BtrfsCompressOption>().ok()),
> + hdsize: obj.remove("hdsize").and_then(|v| v.as_f64()),
> + }))
> + }
> + }
> +}
> +
> +fn collect_lines_into_array(value: Option<Value>) -> Value {
> + value
> + .and_then(|v| v.as_str().map(|s| s.to_owned()))
> + .map(|s| json!(s.split('\n').collect::<Vec<&str>>()))
> + .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::<KeyboardLayout>()
> + .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::<RebootMode>() {
> + 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::<Fqdn>()
> + .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::<DiskSelectionMode>().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::<FilesystemType>().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::<ProxmoxProduct>().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::<FilesystemType>()
> + .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::<DiskSelectionMode>() {
> + 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::<FilterMatch>() {
> + 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! {
> + <span class="pwt-color-warning pwt-mt-2 pwt-d-block">
> + <i class="fa fa-fw fa-exclamation-circle"/>
> + {text}
> + </span>
> + });
> + }
> +
> + 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::<u64>::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::<ZfsChecksumOption>()
> + .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::<ZfsCompressOption>()
> + .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::<u32>::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::<BtrfsCompressOption>()
> + .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! {
> + <span class="pwt-color-warning pwt-mb-2 pwt-d-block">
> + <i class="fa fa-fw fa-exclamation-circle"/>
> + {tr!("Not marked as default answer and target filter are empty, answer will never be matched.")}
> + </span>
> + });
> + }
> +
> + 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! {
> + <span>
> + {tr!("Target filter keys are JSON pointers according to")}
> + {" "}
> + <a href="https://www.rfc-editor.org/rfc/rfc6901">{"RFC 6906"}</a>
> + {"."}
> + </span>
> + }))
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! {
> + <span class="pwt-mb-2 pwt-d-block">
> + <i class="fa fa-fw fa-info-circle"/>
> + {tr!("Optional. If provided, progress reporting is enabled.")}
> + </span>
> + })
> + .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<T: Serialize>(ty: T) -> Option<String> {
> + 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::<T>()
> + );
> + None
> + }
> + }
> +}
> +
> +fn pdm_origin() -> Option<String> {
> + 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<BTreeMap<String, String>> = LazyLock::new(|| {
> + #[derive(Deserialize)]
> + struct Iso3611CountryInfo {
> + alpha_2: String,
> + common_name: Option<String>,
> + name: String,
> + }
> +
> + #[derive(Deserialize)]
> + struct Iso3611Info {
> + #[serde(rename = "3166-1")]
> + list: Vec<Iso3611CountryInfo>,
> + }
> +
> + 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<Callback<()>>,
> +
> + /// Auto-installer answer configuration.
> + config: PreparedInstallationConfig,
> +}
> +
> +impl EditAnswerWindowProperties {
> + pub fn new(config: PreparedInstallationConfig) -> Self {
> + yew::props!(Self { config })
> + }
> +}
> +
> +impl From<EditAnswerWindowProperties> for VNode {
> + fn from(value: EditAnswerWindowProperties) -> Self {
> + let comp = VComp::new::<EditAnswerWindowComponent>(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 {
> + Self {}
> + }
> +
> + fn view(&self, ctx: &Context<Self>) -> 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<AutoInstallerPreparedAnswersPanel> for VNode {
> + fn from(value: AutoInstallerPreparedAnswersPanel) -> Self {
> + let comp = VComp::new::<LoadableComponentMaster<AutoInstallerPreparedAnswersPanelComponent>>(
> + 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<PreparedInstallationConfig>,
> + columns: Rc<Vec<DataTableHeader<PreparedInstallationConfig>>>,
> +}
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>) -> 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<Self>,
> + ) -> Pin<Box<dyn Future<Output = Result<()>>>> {
> + 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<Self>, 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<Self>) -> Option<yew::Html> {
> + 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<Self>) -> 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<Self>,
> + view_state: &Self::ViewState,
> + ) -> Option<yew::Html> {
> + 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<DataTableHeader<PreparedInstallationConfig>> {
> + 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
next prev parent reply other threads:[~2025-12-09 13:00 UTC|newest]
Thread overview: 35+ messages / expand[flat|nested] mbox.gz Atom feed top
2025-12-05 11:25 [pdm-devel] [PATCH proxmox/datacenter-manager v2 00/14] initial auto-installer integration Christoph Heiss
2025-12-05 11:25 ` [pdm-devel] [PATCH proxmox v2 01/14] api-macro: allow $ in identifier name Christoph Heiss
2025-12-05 11:25 ` [pdm-devel] [PATCH proxmox v2 02/14] network-types: move `Fqdn` type from proxmox-installer-common Christoph Heiss
2025-12-09 9:13 ` Lukas Wagner
2025-12-09 12:26 ` Christoph Heiss
2025-12-05 11:25 ` [pdm-devel] [PATCH proxmox v2 03/14] network-types: implement api type for Fqdn Christoph Heiss
2025-12-09 9:13 ` Lukas Wagner
2025-12-05 11:25 ` [pdm-devel] [PATCH proxmox v2 04/14] network-types: add api wrapper type for std::net::IpAddr Christoph Heiss
2025-12-09 9:16 ` Lukas Wagner
2025-12-05 11:25 ` [pdm-devel] [PATCH proxmox v2 05/14] installer-types: add common types used by the installer Christoph Heiss
2025-12-09 9:35 ` Lukas Wagner
2025-12-09 12:17 ` Christoph Heiss
2025-12-05 11:25 ` [pdm-devel] [PATCH proxmox v2 06/14] installer-types: add types used by the auto-installer Christoph Heiss
2025-12-09 9:44 ` Lukas Wagner
2025-12-05 11:25 ` [pdm-devel] [PATCH proxmox v2 07/14] installer-types: implement api type for all externally-used types Christoph Heiss
2025-12-09 9:52 ` Lukas Wagner
2025-12-05 11:25 ` [pdm-devel] [PATCH datacenter-manager v2 08/14] api-types: add api types for auto-installer integration Christoph Heiss
2025-12-09 10:03 ` Lukas Wagner
2025-12-05 11:25 ` [pdm-devel] [PATCH datacenter-manager v2 09/14] config: add auto-installer configuration module Christoph Heiss
2025-12-09 10:22 ` Lukas Wagner
2025-12-09 12:10 ` Christoph Heiss
2025-12-05 11:25 ` [pdm-devel] [PATCH datacenter-manager v2 10/14] acl: wire up new /system/auto-installation acl path Christoph Heiss
2025-12-09 10:23 ` Lukas Wagner
2025-12-05 11:25 ` [pdm-devel] [PATCH datacenter-manager v2 11/14] server: api: add auto-installer integration module Christoph Heiss
2025-12-09 11:01 ` Lukas Wagner
2025-12-05 11:25 ` [pdm-devel] [PATCH datacenter-manager v2 12/14] ui: auto-installer: add installations overview panel Christoph Heiss
2025-12-09 12:35 ` Lukas Wagner
2025-12-05 11:25 ` [pdm-devel] [PATCH datacenter-manager v2 13/14] ui: auto-installer: add prepared answer configuration panel Christoph Heiss
2025-12-09 13:01 ` Lukas Wagner [this message]
2025-12-05 11:25 ` [pdm-devel] [PATCH datacenter-manager v2 14/14] docs: add documentation for auto-installer integration Christoph Heiss
2025-12-09 13:12 ` Lukas Wagner
2025-12-05 11:53 ` [pdm-devel] [PATCH proxmox/datacenter-manager v2 00/14] initial " Thomas Lamprecht
2025-12-05 15:50 ` Christoph Heiss
2025-12-05 15:57 ` Thomas Lamprecht
2025-12-09 13:38 ` Lukas Wagner
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=DETPELECLMGM.1TWRKAIUWYCZF@proxmox.com \
--to=l.wagner@proxmox.com \
--cc=c.heiss@proxmox.com \
--cc=pdm-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox