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: [PATCH datacenter-manager v3 22/38] ui: auto-installer: add prepared answer configuration panel
Date: Fri,  3 Apr 2026 18:53:54 +0200	[thread overview]
Message-ID: <20260403165437.2166551-23-c.heiss@proxmox.com> (raw)
In-Reply-To: <20260403165437.2166551-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>
---
Changes v2 -> v3:
  * filters are now proper key-value `DataTable`s instead of plain text
    areas
  * added new "Templating" and "Authentication" tabs
  * adapted as necessary to changed types from `proxmox-installer-types`
  * use new `PdmClient` methods instead of manual post/put
  * removed automatic `/api2` suffix from pdm base url
  * set _target="blank" for RFC 6901 link

Changes v1 -> v2:
  * new patch

 ui/src/remotes/auto_installer/mod.rs          |  19 +
 .../prepared_answer_add_wizard.rs             | 173 ++++
 .../prepared_answer_edit_window.rs            | 165 ++++
 .../auto_installer/prepared_answer_form.rs    | 857 ++++++++++++++++++
 .../auto_installer/prepared_answers_panel.rs  | 248 +++++
 5 files changed, 1462 insertions(+)
 create mode 100644 ui/src/remotes/auto_installer/prepared_answer_add_wizard.rs
 create mode 100644 ui/src/remotes/auto_installer/prepared_answer_edit_window.rs
 create mode 100644 ui/src/remotes/auto_installer/prepared_answer_form.rs
 create mode 100644 ui/src/remotes/auto_installer/prepared_answers_panel.rs

diff --git a/ui/src/remotes/auto_installer/mod.rs b/ui/src/remotes/auto_installer/mod.rs
index 8155a9b..1a85978 100644
--- a/ui/src/remotes/auto_installer/mod.rs
+++ b/ui/src/remotes/auto_installer/mod.rs
@@ -1,6 +1,10 @@
 //! Implements the UI for the proxmox-auto-installer integration.
 
 mod installations_panel;
+mod prepared_answer_add_wizard;
+mod prepared_answer_edit_window;
+mod prepared_answer_form;
+mod prepared_answers_panel;
 
 use std::rc::Rc;
 use yew::virtual_dom::{VComp, VNode};
@@ -39,15 +43,30 @@ impl Component for AutoInstallerPanelComponent {
             .with_child(tr!("Installations"))
             .into();
 
+        let answers_title: Html = Row::new()
+            .gap(2)
+            .class(AlignItems::Baseline)
+            .with_child(Fa::new("files-o"))
+            .with_child(tr!("Prepared Answers"))
+            .into();
+
         Container::new()
             .class("pwt-content-spacer")
             .class(Fit)
             .class(css::Display::Grid)
+            .style("grid-template-columns", "repeat(2, 1fr)")
+            .style("grid-template-rows", "repeat(1, 1fr)")
             .with_child(
                 Panel::new()
+                    .style("grid-row", "span 2 / span 1")
                     .title(installations_title)
                     .with_child(installations_panel::InstallationsPanel::default()),
             )
+            .with_child(
+                Panel::new()
+                    .title(answers_title)
+                    .with_child(prepared_answers_panel::PreparedAnswersPanel::default()),
+            )
             .into()
     }
 }
diff --git a/ui/src/remotes/auto_installer/prepared_answer_add_wizard.rs b/ui/src/remotes/auto_installer/prepared_answer_add_wizard.rs
new file mode 100644
index 0000000..5d15a43
--- /dev/null
+++ b/ui/src/remotes/auto_installer/prepared_answer_add_wizard.rs
@@ -0,0 +1,173 @@
+//! Implements the configuration dialog UI for the auto-installer integration.
+
+use anyhow::Result;
+use js_sys::Intl;
+use proxmox_installer_types::answer;
+use std::{collections::BTreeMap, future::Future, pin::Pin, rc::Rc};
+use wasm_bindgen::JsValue;
+use yew::{
+    html::IntoEventCallback,
+    virtual_dom::{VComp, VNode},
+};
+
+use pdm_api_types::auto_installer::{DiskSelectionMode, PreparedInstallationConfig};
+use proxmox_yew_comp::{
+    LoadableComponent, LoadableComponentContext, LoadableComponentMaster, LoadableComponentState,
+    Wizard, WizardPageRenderInfo,
+};
+use pwt::{prelude::*, widget::TabBarItem};
+use pwt_macros::builder;
+
+use super::prepared_answer_form::*;
+use crate::pdm_client;
+
+#[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 new() -> Self {
+        let config = PreparedInstallationConfig {
+            id: String::new(),
+            authorized_tokens: Vec::new(),
+            // target filter
+            is_default: false,
+            target_filter: BTreeMap::new(),
+            // global options
+            country: "at".to_owned(),
+            fqdn: "host.example.com".to_owned(),
+            use_dhcp_fqdn: false,
+            keyboard: answer::KeyboardLayout::default(),
+            mailto: String::new(),
+            timezone: 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: BTreeMap::new(),
+            netif_name_pinning_enabled: true,
+            // disk options
+            filesystem: answer::FilesystemOptions::Ext4(answer::LvmOptions::default()),
+            disk_mode: DiskSelectionMode::default(),
+            disk_list: Vec::new(),
+            disk_filter: BTreeMap::new(),
+            disk_filter_match: None,
+            // post hook
+            post_hook_base_url: pdm_origin(),
+            post_hook_cert_fp: None,
+            // templating
+            template_counters: BTreeMap::new(),
+        };
+
+        yew::props!(Self { config })
+    }
+
+    pub fn with(config: PreparedInstallationConfig) -> Self {
+        yew::props!(Self { config })
+    }
+}
+
+impl From<AddAnswerWizardProperties> for VNode {
+    fn from(value: AddAnswerWizardProperties) -> Self {
+        let comp =
+            VComp::new::<LoadableComponentMaster<AddAnswerWizardComponent>>(Rc::new(value), None);
+        VNode::from(comp)
+    }
+}
+
+struct AddAnswerWizardComponent {
+    state: LoadableComponentState<()>,
+}
+
+pwt::impl_deref_mut_property!(AddAnswerWizardComponent, state, LoadableComponentState<()>);
+
+impl LoadableComponent for AddAnswerWizardComponent {
+    type Properties = AddAnswerWizardProperties;
+    type Message = ();
+    type ViewState = ();
+
+    fn create(_ctx: &LoadableComponentContext<Self>) -> Self {
+        Self {
+            state: LoadableComponentState::new(),
+        }
+    }
+
+    fn load(
+        &self,
+        _ctx: &LoadableComponentContext<Self>,
+    ) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
+        Box::pin(async move { Ok(()) })
+    }
+
+    fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
+        let props = ctx.props();
+
+        Wizard::new(tr!("Add Prepared Answer"))
+            .width(900)
+            .resizable(true)
+            .on_done(props.on_done.clone())
+            .on_submit(|config: serde_json::Value| async move { submit(config).await })
+            .with_page(TabBarItem::new().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!("Templating")), {
+                let config = props.config.clone();
+                move |_: &WizardPageRenderInfo| render_templating_form(&config)
+            })
+            .with_page(TabBarItem::new().label(tr!("Authentication")), {
+                let config = props.config.clone();
+                move |_: &WizardPageRenderInfo| render_auth_form(&config)
+            })
+            .into()
+    }
+}
+
+async fn submit(form_data: serde_json::Value) -> Result<()> {
+    let data = prepare_form_data(form_data)?;
+
+    pdm_client()
+        .add_autoinst_prepared_answer(&serde_json::from_value(data)?)
+        .await?;
+    Ok(())
+}
+
+fn 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())
+}
+
+fn pdm_origin() -> Option<String> {
+    gloo_utils::document()
+        .url()
+        .and_then(|s| web_sys::Url::new(&s))
+        .map(|url| url.origin())
+        .ok()
+}
diff --git a/ui/src/remotes/auto_installer/prepared_answer_edit_window.rs b/ui/src/remotes/auto_installer/prepared_answer_edit_window.rs
new file mode 100644
index 0000000..3fb9766
--- /dev/null
+++ b/ui/src/remotes/auto_installer/prepared_answer_edit_window.rs
@@ -0,0 +1,165 @@
+//! Implements the configuration dialog UI for the auto-installer integration.
+
+use anyhow::Result;
+use std::{future::Future, pin::Pin, rc::Rc};
+use yew::{
+    html::IntoEventCallback,
+    virtual_dom::{VComp, VNode},
+};
+
+use crate::pdm_client;
+use pdm_api_types::auto_installer::{
+    DeletablePreparedInstallationConfigProperty, PreparedInstallationConfig,
+};
+use proxmox_yew_comp::{
+    form::delete_empty_values, percent_encoding::percent_encode_component, EditWindow,
+    LoadableComponent, LoadableComponentContext, LoadableComponentMaster, LoadableComponentState,
+};
+use pwt::{
+    css::FlexFit,
+    prelude::*,
+    widget::{form::FormContext, TabBarItem, TabPanel},
+};
+use pwt_macros::builder;
+
+use super::prepared_answer_form::*;
+
+#[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::<LoadableComponentMaster<EditAnswerWindowComponent>>(Rc::new(value), None);
+        VNode::from(comp)
+    }
+}
+
+struct EditAnswerWindowComponent {
+    state: LoadableComponentState<()>,
+}
+
+pwt::impl_deref_mut_property!(EditAnswerWindowComponent, state, LoadableComponentState<()>);
+
+impl LoadableComponent for EditAnswerWindowComponent {
+    type Properties = EditAnswerWindowProperties;
+    type Message = ();
+    type ViewState = ();
+
+    fn create(_ctx: &LoadableComponentContext<Self>) -> Self {
+        Self {
+            state: LoadableComponentState::new(),
+        }
+    }
+
+    fn load(
+        &self,
+        _ctx: &LoadableComponentContext<Self>,
+    ) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
+        Box::pin(async move { Ok(()) })
+    }
+
+    fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
+        let props = ctx.props();
+
+        EditWindow::new(tr!("Edit Prepared Answer"))
+            .width(900)
+            .resizable(true)
+            .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 id = props.config.id.clone();
+                move |form_ctx: FormContext| {
+                    let id = id.clone();
+                    let config = form_ctx.get_submit_data();
+                    async move { submit(&percent_encode_component(&id), config).await }
+                }
+            })
+            .advanced_checkbox(true)
+            .into()
+    }
+}
+
+async fn submit(id: &str, form_data: serde_json::Value) -> Result<()> {
+    let data = delete_empty_values(
+        &prepare_form_data(form_data)?,
+        &[
+            "root-ssh-keys",
+            "post-hook-base-url",
+            "post-hook-cert-fp",
+            "disk-filter",
+            "netdev-filter",
+        ],
+        true,
+    );
+
+    let root_password = data["root-password"].as_str().map(ToOwned::to_owned);
+    let delete = data["delete"]
+        .as_array()
+        .cloned()
+        .unwrap_or_default()
+        .iter()
+        .flat_map(|s| s.as_str().and_then(|s| s.parse().ok()))
+        .collect::<Vec<DeletablePreparedInstallationConfigProperty>>();
+
+    pdm_client()
+        .update_autoinst_prepared_answer(
+            id,
+            &serde_json::from_value(data)?,
+            root_password.as_deref(),
+            &delete,
+        )
+        .await?;
+    Ok(())
+}
+
+fn render_tabpanel(form_ctx: &FormContext, props: &EditAnswerWindowProperties) -> yew::Html {
+    TabPanel::new()
+        .class(FlexFit)
+        .force_render_all(true)
+        .with_item(
+            TabBarItem::new().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!("Templating")),
+            render_templating_form(&props.config),
+        )
+        .with_item(
+            TabBarItem::new().label(tr!("Authentication")),
+            render_auth_form(&props.config),
+        )
+        .into()
+}
diff --git a/ui/src/remotes/auto_installer/prepared_answer_form.rs b/ui/src/remotes/auto_installer/prepared_answer_form.rs
new file mode 100644
index 0000000..29bc768
--- /dev/null
+++ b/ui/src/remotes/auto_installer/prepared_answer_form.rs
@@ -0,0 +1,857 @@
+//! Provides all shared components for the prepared answer create wizard and the corresponding
+//! edit window, as well as some utility to collect and prepare the form data for submission.
+
+use anyhow::{anyhow, bail, Result};
+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,
+    },
+    EMAIL_DEFAULT_PLACEHOLDER,
+};
+use proxmox_schema::api_types::{CIDR_SCHEMA, IP_SCHEMA};
+use proxmox_yew_comp::SchemaValidation;
+use pwt::{
+    css::{Flex, FlexFit, Overflow},
+    prelude::*,
+    widget::{
+        form::{Checkbox, Combobox, DisplayField, Field, FormContext, InputType, Number, TextArea},
+        Container, Fa, FieldPosition, InputPanel, KeyValueList,
+    },
+};
+
+pub fn prepare_form_data(mut value: serde_json::Value) -> Result<serde_json::Value> {
+    let obj = value
+        .as_object_mut()
+        .ok_or_else(|| anyhow!("form data must always be an object"))?;
+
+    let fs_opts = collect_fs_options(obj);
+    let disk_list: Vec<String> = obj
+        .remove("disk-list")
+        .and_then(|s| {
+            s.as_str()
+                .map(|s| s.split(',').map(|s| s.trim().to_owned()).collect())
+        })
+        .unwrap_or_default();
+
+    let root_ssh_keys = collect_lines_into_array(obj.remove("root-ssh-keys"));
+
+    value["filesystem"] = json!(fs_opts);
+    value["disk-list"] = json!(disk_list);
+    value["root-ssh-keys"] = root_ssh_keys;
+    Ok(value)
+}
+
+fn collect_fs_options(obj: &mut serde_json::Map<String, Value>) -> FilesystemOptions {
+    let fs_type = obj
+        .get("filesystem-type")
+        .and_then(|s| s.as_str())
+        .and_then(|s| s.parse::<FilesystemType>().ok())
+        .unwrap_or_default();
+
+    let lvm_options = 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()),
+    };
+
+    match fs_type {
+        FilesystemType::Ext4 => FilesystemOptions::Ext4(lvm_options),
+        FilesystemType::Xfs => FilesystemOptions::Xfs(lvm_options),
+        FilesystemType::Zfs(level) => 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) => 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')
+                .filter(|s| !s.is_empty())
+                .collect::<Vec<&str>>())
+        })
+        .unwrap_or(Value::Null)
+}
+
+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.human_name().to_owned())
+                        .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"),
+            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!("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("{{product.product}}{{installation-nr}}.example.com")
+                .value(config.fqdn.to_string())
+                .disabled(use_dhcp_fqdn)
+                .tip(tr!(
+                    "Hostname and domain to set for the target installation. Allows templating."
+                ))
+                .required(!use_dhcp_fqdn),
+        )
+        .with_right_field("", DisplayField::new())
+        .with_right_field(
+            tr!("Pin network interfaces"),
+            Checkbox::new()
+                .name("netif-name-pinning-enabled")
+                .default(config.netif_name_pinning_enabled),
+        )
+        .with_advanced_spacer()
+        .with_large_advanced_field(
+            tr!("Network device filters"),
+            KeyValueList::new()
+                .value(
+                    config
+                        .netdev_filter
+                        .iter()
+                        .map(|(k, v)| (k.clone(), v.clone()))
+                        .collect(),
+                )
+                .key_label(tr!("Property name"))
+                .value_label(tr!("Value to match"))
+                .key_placeholder(tr!("udev property name"))
+                .value_placeholder(tr!("glob to match"))
+                .submit_validate(kv_list_to_map)
+                .submit_empty(false)
+                .name("netdev-filter")
+                .class(FlexFit)
+                .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 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()
+                        .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.to_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_field(
+            tr!("Disk names"),
+            Field::new()
+                .name("disk-list")
+                .placeholder("sda, sdb")
+                .value(config.disk_list.join(", "))
+                .disabled(disk_mode != DiskSelectionMode::Fixed)
+                .required(disk_mode == DiskSelectionMode::Fixed),
+        )
+        .with_spacer()
+        .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"),
+            KeyValueList::new()
+                .value(
+                    config
+                        .disk_filter
+                        .iter()
+                        .map(|(k, v)| (k.clone(), v.clone()))
+                        .collect(),
+                )
+                .key_label(tr!("Property name"))
+                .value_label(tr!("Value to match"))
+                .key_placeholder(tr!("udev property name"))
+                .value_placeholder(tr!("glob to match"))
+                .submit_validate(kv_list_to_map)
+                .submit_empty(false)
+                .name("disk-filter")
+                .class(FlexFit)
+                .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 and only available for Proxmox Virtual Environment installations."
+        )),
+        _ => None,
+    };
+
+    if let Some(text) = warning {
+        panel.add_large_custom_child(
+            Container::from_tag("span")
+                .class("pwt-color-warning pwt-mt-2 pwt-d-block")
+                .with_child(Fa::new("exclamation-circle").class("fa-fw"))
+                .with_child(text),
+        );
+    }
+
+    panel.add_spacer(true);
+
+    add_fs_advanced_form_fields(&mut panel, &config.filesystem);
+    panel.into()
+}
+
+fn add_fs_advanced_form_fields(panel: &mut InputPanel, fs_opts: &FilesystemOptions) {
+    match fs_opts {
+        FilesystemOptions::Ext4(opts) | FilesystemOptions::Xfs(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)"),
+        Number::new()
+            .name("hdsize")
+            .min(4.)
+            .step(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)"),
+        Number::new()
+            .name("swapsize")
+            .min(0.)
+            .max(fs_opts.hdsize.map(|v| v / 2.))
+            .step(0.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)"),
+        Number::new()
+            .name("maxroot")
+            .min(0.)
+            .max(fs_opts.hdsize.map(|v| v / 2.))
+            .step(0.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)"),
+        Number::new()
+            .name("maxvz")
+            .min(0.)
+            .max(fs_opts.hdsize.map(|v| v / 2.))
+            .step(0.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)"),
+        Number::new()
+            .name("minfree")
+            .min(0.)
+            .max(fs_opts.hdsize.map(|v| v / 2.))
+            .step(0.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)"),
+        Number::new()
+            .name("arc-max")
+            .min(64.)
+            .step(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 mut panel = InputPanel::new()
+        .class(Flex::Fill)
+        .class(Overflow::Auto)
+        .padding(4);
+
+    if !is_default && config.target_filter.is_empty() {
+        panel.add_large_custom_child(
+            Container::from_tag("span")
+                .class("pwt-color-warning pwt-mb-2 pwt-d-block")
+                .with_child(Fa::new("exclamation-circle").class("fa-fw"))
+                .with_child(tr!(
+                    "Not marked as default answer and target filter are empty, answer will never be matched."
+                ))
+        );
+    }
+
+    panel
+        .with_field(
+            tr!("Default answer"),
+            Checkbox::new()
+                .name("is-default")
+                .default(config.is_default),
+        )
+        .with_spacer()
+        .with_large_field(
+            tr!("Target filters"),
+            KeyValueList::new()
+                .value(
+                    config
+                        .target_filter
+                        .iter()
+                        .map(|(k, v)| (k.clone(), v.clone()))
+                        .collect(),
+                )
+                .key_label(tr!("JSON pointer"))
+                .value_label(tr!("Value to match"))
+                .key_placeholder("/json/pointer".into())
+                .value_placeholder(tr!("glob to match"))
+                .submit_validate(kv_list_to_map)
+                .submit_empty(false)
+                .name("target-filter")
+                .class(FlexFit)
+                .disabled(is_default),
+        )
+        .with_right_custom_child(Container::new().with_child(html! {
+            <span style="float: right;">
+                {tr!("references RFC 6901" => "Target filter keys are JSON pointers according to")}
+                {" "}
+                <a href="https://www.rfc-editor.org/rfc/rfc6901" target="_blank">{"RFC 6901"}</a>
+                {"."}
+            </span>
+        }))
+        .into()
+}
+
+pub fn render_templating_form(config: &PreparedInstallationConfig) -> yew::Html {
+    InputPanel::new()
+        .class(Flex::Fill)
+        .class(Overflow::Auto)
+        .padding(4)
+        .with_large_custom_child(
+            Container::from_tag("span")
+                .class("pwt-mb-2 pwt-d-block")
+                .with_child(tr!(
+                    "Numerical template counters can be used to provide unique values across installations."
+                )),
+        )
+        .with_large_custom_child(
+            KeyValueList::new()
+                .value(
+                    config
+                        .template_counters
+                        .iter()
+                        .map(|(k, v)| (k.clone(), *v))
+                        .collect(),
+                )
+                .value_label(tr!("Current value"))
+                .value_input_type(InputType::Number)
+                .submit_validate(kv_list_to_map)
+                .submit_empty(false)
+                .name("template-counters")
+                .class(FlexFit),
+        )
+        .with_right_custom_child(
+            Container::from_tag("span")
+                .class("pwt-mt-2 pwt-d-block")
+                .style("float", "right")
+                .with_child(tr!(
+                    "Counters are automatically incremented each time an answer is served."
+                )),
+        )
+        .into()
+}
+
+pub fn render_auth_form(config: &PreparedInstallationConfig) -> yew::Html {
+    InputPanel::new()
+        .class(Flex::Fill)
+        .class(Overflow::Auto)
+        .padding(4)
+        .with_large_custom_child(
+            Container::from_tag("span")
+                .class("pwt-mb-2 pwt-mt-2 pwt-d-block pwt-color-primary")
+                .with_child(Fa::new("info-circle").class("fa-fw"))
+                .with_child(tr!(
+                    "Optional. If provided, status reporting will be enabled."
+                )),
+        )
+        .with_large_field(
+            tr!("Proxmox Datacenter Manager 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()),
+        )
+        .with_large_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()
+}
+
+#[allow(clippy::ptr_arg)]
+fn kv_list_to_map<T: Clone + Serialize>(v: &Vec<(String, T)>) -> Result<Value> {
+    let map: BTreeMap<String, T> = v.iter().cloned().collect();
+    Ok(serde_json::to_value(map)?)
+}
+
+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
+        }
+    }
+}
+
+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/remotes/auto_installer/prepared_answers_panel.rs b/ui/src/remotes/auto_installer/prepared_answers_panel.rs
new file mode 100644
index 0000000..975cab9
--- /dev/null
+++ b/ui/src/remotes/auto_installer/prepared_answers_panel.rs
@@ -0,0 +1,248 @@
+//! Implements the UI for the auto-installer answer editing panel.
+
+use anyhow::Result;
+use core::clone::Clone;
+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, LoadableComponentScopeExt,
+    LoadableComponentState,
+};
+use pwt::{
+    props::{ContainerBuilder, EventSubscriber, WidgetBuilder},
+    state::{Selection, Store},
+    tr,
+    widget::{
+        data_table::{DataTable, DataTableColumn, DataTableHeader},
+        Button, Fa, Toolbar,
+    },
+};
+
+use super::{
+    prepared_answer_add_wizard::AddAnswerWizardProperties,
+    prepared_answer_edit_window::EditAnswerWindowProperties,
+};
+use crate::pdm_client;
+
+#[derive(Default, PartialEq, Properties)]
+pub struct PreparedAnswersPanel {}
+
+impl From<PreparedAnswersPanel> for VNode {
+    fn from(value: PreparedAnswersPanel) -> Self {
+        let comp = VComp::new::<LoadableComponentMaster<PreparedAnswersPanelComponent>>(
+            Rc::new(value),
+            None,
+        );
+        VNode::from(comp)
+    }
+}
+
+#[derive(PartialEq)]
+enum ViewState {
+    Create,
+    Copy,
+    Edit,
+}
+
+#[derive(PartialEq)]
+enum Message {
+    SelectionChange,
+    RemoveEntry,
+}
+
+struct PreparedAnswersPanelComponent {
+    state: LoadableComponentState<ViewState>,
+    selection: Selection,
+    store: Store<PreparedInstallationConfig>,
+    columns: Rc<Vec<DataTableHeader<PreparedInstallationConfig>>>,
+}
+
+pwt::impl_deref_mut_property!(
+    PreparedAnswersPanelComponent,
+    state,
+    LoadableComponentState<ViewState>
+);
+
+impl LoadableComponent for PreparedAnswersPanelComponent {
+    type Properties = PreparedAnswersPanel;
+    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 {
+            state: LoadableComponentState::new(),
+            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 store = self.store.clone();
+        Box::pin(async move {
+            let data = pdm_client().get_autoinst_prepared_answers().await?;
+            store.write().set_data(data);
+            Ok(())
+        })
+    }
+
+    fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Message) -> bool {
+        let link = ctx.link().clone();
+
+        match msg {
+            Message::SelectionChange => true,
+            Message::RemoveEntry => {
+                if let Some(key) = self.selection.selected_key() {
+                    self.spawn(async move {
+                        if let Err(err) = pdm_client()
+                            .delete_autoinst_prepared_answer(&percent_encode_component(
+                                &key.to_string(),
+                            ))
+                            .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().clone();
+
+        let toolbar = Toolbar::new()
+            .class("pwt-w-100")
+            .class(pwt::css::Overflow::Hidden)
+            .class("pwt-border-bottom")
+            .with_child(
+                Button::new(tr!("Add"))
+                    .onclick(link.change_view_callback(|_| Some(ViewState::Create))),
+            )
+            .with_spacer()
+            .with_child(
+                Button::new(tr!("Copy"))
+                    .onclick(link.change_view_callback(|_| 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 {
+        let link = ctx.link().clone();
+
+        DataTable::new(self.columns.clone(), self.store.clone())
+            .class(pwt::css::FlexFit)
+            .selection(self.selection.clone())
+            .on_row_dblclick(move |_: &mut _| 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 => AddAnswerWizardProperties::new().on_done(on_done).into(),
+            Self::ViewState::Copy => {
+                let mut record = self
+                    .store
+                    .read()
+                    .lookup_record(&self.selection.selected_key()?)?
+                    .clone();
+
+                record.id += " (copy)";
+                AddAnswerWizardProperties::with(record)
+                    .on_done(on_done)
+                    .into()
+            }
+            Self::ViewState::Edit => {
+                let record = self
+                    .store
+                    .read()
+                    .lookup_record(&self.selection.selected_key()?)?
+                    .clone();
+
+                EditAnswerWindowProperties::new(record)
+                    .on_done(on_done)
+                    .into()
+            }
+        })
+    }
+}
+
+fn columns() -> Vec<DataTableHeader<PreparedInstallationConfig>> {
+    vec![
+        DataTableColumn::new(tr!("ID"))
+            .width("320px")
+            .render(|item: &PreparedInstallationConfig| html! { &item.id })
+            .sorter(
+                |a: &PreparedInstallationConfig, b: &PreparedInstallationConfig| a.id.cmp(&b.id),
+            )
+            .sort_order(Some(true))
+            .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| {
+                if item.target_filter.is_empty() {
+                    "-".into()
+                } else {
+                    item.target_filter
+                        .iter()
+                        .fold(String::new(), |acc, (k, v)| {
+                            if acc.is_empty() {
+                                format!("{k}={v}")
+                            } else {
+                                format!("{acc}, {k}={v}")
+                            }
+                        })
+                        .into()
+                }
+            })
+            .into(),
+    ]
+}
-- 
2.53.0





  parent reply	other threads:[~2026-04-03 16:57 UTC|newest]

Thread overview: 39+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 01/38] api-macro: allow $ in identifier name Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 02/38] schema: oneOf: allow single string variant Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 03/38] schema: implement UpdaterType for HashMap and BTreeMap Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 04/38] network-types: move `Fqdn` type from proxmox-installer-common Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 05/38] network-types: implement api type for Fqdn Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 06/38] network-types: add api wrapper type for std::net::IpAddr Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 07/38] network-types: cidr: implement generic `IpAddr::new` constructor Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 08/38] network-types: fqdn: implement standard library Error for Fqdn Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 09/38] node-status: make KernelVersionInformation Clone + PartialEq Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 10/38] installer-types: add common types used by the installer Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 11/38] installer-types: add types used by the auto-installer Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 12/38] installer-types: implement api type for all externally-used types Christoph Heiss
2026-04-03 16:53 ` [PATCH yew-widget-toolkit v3 13/38] widget: kvlist: add widget for user-modifiable data tables Christoph Heiss
2026-04-03 16:53 ` [PATCH datacenter-manager v3 14/38] api-types, cli: use ReturnType::new() instead of constructing it manually Christoph Heiss
2026-04-03 16:53 ` [PATCH datacenter-manager v3 15/38] api-types: add api types for auto-installer integration Christoph Heiss
2026-04-03 16:53 ` [PATCH datacenter-manager v3 16/38] config: add auto-installer configuration module Christoph Heiss
2026-04-03 16:53 ` [PATCH datacenter-manager v3 17/38] acl: wire up new /system/auto-installation acl path Christoph Heiss
2026-04-03 16:53 ` [PATCH datacenter-manager v3 18/38] server: api: add auto-installer integration module Christoph Heiss
2026-04-03 16:53 ` [PATCH datacenter-manager v3 19/38] server: api: auto-installer: add access token management endpoints Christoph Heiss
2026-04-03 16:53 ` [PATCH datacenter-manager v3 20/38] client: add bindings for auto-installer endpoints Christoph Heiss
2026-04-03 16:53 ` [PATCH datacenter-manager v3 21/38] ui: auto-installer: add installations overview panel Christoph Heiss
2026-04-03 16:53 ` Christoph Heiss [this message]
2026-04-03 16:53 ` [PATCH datacenter-manager v3 23/38] ui: auto-installer: add access token configuration panel Christoph Heiss
2026-04-03 16:53 ` [PATCH datacenter-manager v3 24/38] docs: add documentation for auto-installer integration Christoph Heiss
2026-04-03 16:53 ` [PATCH installer v3 25/38] install: iso env: use JSON boolean literals for product config Christoph Heiss
2026-04-03 16:53 ` [PATCH installer v3 26/38] common: http: allow passing custom headers to post() Christoph Heiss
2026-04-03 16:53 ` [PATCH installer v3 27/38] common: options: move regex construction out of loop Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 28/38] assistant: support adding an authorization token for HTTP-based answers Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 29/38] tree-wide: used moved `Fqdn` type to proxmox-network-types Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 30/38] tree-wide: use `Cidr` type from proxmox-network-types Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 31/38] tree-wide: switch to filesystem types from proxmox-installer-types Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 32/38] post-hook: switch to types in proxmox-installer-types Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 33/38] auto: sysinfo: switch to types from proxmox-installer-types Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 34/38] fetch-answer: " Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 35/38] fetch-answer: http: prefer json over toml for answer format Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 36/38] fetch-answer: send auto-installer HTTP authorization token if set Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 37/38] tree-wide: switch out `Answer` -> `AutoInstallerConfig` types Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 38/38] auto: drop now-dead answer file definitions 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=20260403165437.2166551-23-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