public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Christoph Heiss <c.heiss@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [pdm-devel] [PATCH datacenter-manager 13/13] ui: auto-installer: add prepared answer configuration panel
Date: Thu,  4 Dec 2025 13:51:22 +0100	[thread overview]
Message-ID: <20251204125122.945961-14-c.heiss@proxmox.com> (raw)
In-Reply-To: <20251204125122.945961-1-c.heiss@proxmox.com>

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>
---
 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 {}
+
+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
+    }
+}
+
+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>
+        }))
+        .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"))),
+                ),
+        )
+        .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, ())]
+    #[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 {}
+
+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>>>,
+}
+
+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
+}
+
+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,
-- 
2.51.2



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


  parent reply	other threads:[~2025-12-04 12:52 UTC|newest]

Thread overview: 17+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2025-12-04 12:51 [pdm-devel] [PATCH proxmox/datacenter-manager 00/13] initial auto-installer integration Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 01/13] api-macro: allow $ in identifier name Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 02/13] network-types: move `Fqdn` type from proxmox-installer-common Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 03/13] network-types: implement api type for Fqdn Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 04/13] network-types: add api wrapper type for std::net::IpAddr Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 05/13] installer-types: add common types used by the installer Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 06/13] installer-types: add types used by the auto-installer Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 07/13] installer-types: implement api type for all externally-used types Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH datacenter-manager 08/13] api-types: add api types for auto-installer integration Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH datacenter-manager 09/13] config: add auto-installer configuration module Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH datacenter-manager 10/13] acl: wire up new /system/auto-installation acl path Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH datacenter-manager 11/13] server: api: add auto-installer integration module Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH datacenter-manager 12/13] ui: auto-installer: add installations overview panel Christoph Heiss
2025-12-04 12:51 ` Christoph Heiss [this message]
2025-12-04 14:17 ` [pdm-devel] [PATCH proxmox/datacenter-manager 00/13] initial auto-installer integration Lukas Wagner
2025-12-04 15:06   ` Christoph Heiss
2025-12-05 11:26 ` Christoph Heiss

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=20251204125122.945961-14-c.heiss@proxmox.com \
    --to=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