public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
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


  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
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal