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 v4 22/40] ui: auto-installer: add prepared answer configuration panel
Date: Thu, 30 Apr 2026 14:46:51 +0200	[thread overview]
Message-ID: <20260430124712.1614305-23-c.heiss@proxmox.com> (raw)
In-Reply-To: <20260430124712.1614305-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 v3 -> v4:
  * set key on all custom fields, as required by `InputPanel`
  * add validation for udev filters and template counter keys
  * display token secret if new one was automatically created
  * add "Authorized tokens" column to panel view
  * moved token selector here
  * provide default PDM base url as placeholder only
  * adapt to `AnswerAuthToken` -> `AnswerToken` rename
  * renamed "Post Hook" to "Authentication" panel
  * invoke `on_submit_result` callback after creating/updating an answer
  * add email and disk-list schema on respective form fields
  * move status reporting hint to bottom of panel
  * allow empty answer token selection

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

Changes v3 -> v4:
  * use enum newtype struct for token_panel ViewState::DisplaySecret
  * AnswerAuthToken -> AnswerToken rename
  * move show_secret_dialog render to common module
  * fix form dirty check in TokenSelector

Changes v2 -> v3:
  * new patch

 ui/src/remotes/auto_installer/mod.rs          |   20 +
 .../prepared_answer_add_wizard.rs             |  219 ++++
 .../prepared_answer_edit_window.rs            |  217 ++++
 .../auto_installer/prepared_answer_form.rs    | 1089 +++++++++++++++++
 .../auto_installer/prepared_answers_panel.rs  |  311 +++++
 .../remotes/auto_installer/token_selector.rs  |  159 +++
 6 files changed, 2015 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
 create mode 100644 ui/src/remotes/auto_installer/token_selector.rs

diff --git a/ui/src/remotes/auto_installer/mod.rs b/ui/src/remotes/auto_installer/mod.rs
index 8155a9b..cd1f668 100644
--- a/ui/src/remotes/auto_installer/mod.rs
+++ b/ui/src/remotes/auto_installer/mod.rs
@@ -1,6 +1,11 @@
 //! 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;
+mod token_selector;
 
 use std::rc::Rc;
 use yew::virtual_dom::{VComp, VNode};
@@ -39,15 +44,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..deb069a
--- /dev/null
+++ b/ui/src/remotes/auto_installer/prepared_answer_add_wizard.rs
@@ -0,0 +1,219 @@
+//! 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::{Key, VComp, VNode},
+};
+
+use pdm_api_types::auto_installer::{
+    AnswerToken, AnswerTokenCreateResult, DiskSelectionMode, PreparedInstallationConfig,
+    PreparedInstallationConfigCreateResult,
+};
+use proxmox_yew_comp::{
+    LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
+    LoadableComponentScopeExt, LoadableComponentState, Wizard, WizardPageRenderInfo,
+};
+use pwt::{prelude::*, state::Store, 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_close: Option<Callback<()>>,
+
+    /// Dialog submit results callback.
+    #[builder_cb(IntoEventCallback, into_event_callback, (PreparedInstallationConfig, Option<AnswerTokenCreateResult>))]
+    #[prop_or_default]
+    pub on_submit_result:
+        Option<Callback<(PreparedInstallationConfig, Option<AnswerTokenCreateResult>)>>,
+
+    /// Auto-installer answer configuration.
+    config: PreparedInstallationConfig,
+}
+
+impl AddAnswerWizardProperties {
+    pub fn new() -> Self {
+        let mut template_counters = BTreeMap::new();
+        template_counters.insert("installation_nr".to_owned(), 0i32);
+
+        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: None,
+            post_hook_cert_fp: None,
+            // templating
+            template_counters,
+        };
+
+        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<()>,
+    token_store: Store<AnswerToken>,
+}
+
+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 {
+        let store = Store::with_extract_key(|record: &AnswerToken| Key::from(record.id.to_owned()));
+        store.set_sorter(|a: &AnswerToken, b: &AnswerToken| a.id.cmp(&b.id));
+
+        Self {
+            state: LoadableComponentState::new(),
+            token_store: store,
+        }
+    }
+
+    fn load(
+        &self,
+        _ctx: &LoadableComponentContext<Self>,
+    ) -> Pin<Box<dyn Future<Output = Result<()>>>> {
+        let store = self.token_store.clone();
+        Box::pin(async move {
+            let data = pdm_client()
+                .get_autoinst_tokens()
+                .await?
+                .into_iter()
+                .collect();
+
+            store.write().set_data(data);
+            Ok(())
+        })
+    }
+
+    fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
+        let props = ctx.props();
+        let link = ctx.link().clone();
+
+        Wizard::new(tr!("Add Prepared Answer"))
+            .width(900)
+            .resizable(true)
+            .on_submit({
+                let on_submit_result = props.on_submit_result.clone();
+                move |config: serde_json::Value| {
+                    let link = link.clone();
+                    let on_submit_result = on_submit_result.clone();
+                    async move {
+                        match submit(config).await {
+                            Ok(PreparedInstallationConfigCreateResult { config, token }) => {
+                                if let Some(on_submit_result) = on_submit_result {
+                                    on_submit_result.emit((config, token));
+                                }
+                            }
+                            Err(err) => link.show_error(
+                                tr!("Failed to create installation configuration"),
+                                err,
+                                true,
+                            ),
+                        }
+                        Ok(())
+                    }
+                }
+            })
+            .on_close(props.on_close.clone())
+            .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!("Post Hook")), {
+                let config = props.config.clone();
+                let secrets = self.token_store.clone();
+                move |p: &WizardPageRenderInfo| {
+                    render_auth_form(&p.form_ctx, &config, secrets.clone())
+                }
+            })
+            .into()
+    }
+}
+
+impl AddAnswerWizardComponent {}
+
+async fn submit(form_data: serde_json::Value) -> Result<PreparedInstallationConfigCreateResult> {
+    let data = prepare_form_data(form_data)?;
+    let root_password = data["root-password"].as_str().map(ToOwned::to_owned);
+
+    Ok(pdm_client()
+        .add_autoinst_prepared_answer(&serde_json::from_value(data)?, root_password.as_deref())
+        .await?)
+}
+
+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())
+}
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..4bdbab1
--- /dev/null
+++ b/ui/src/remotes/auto_installer/prepared_answer_edit_window.rs
@@ -0,0 +1,217 @@
+//! 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::{Key, VComp, VNode},
+};
+
+use crate::pdm_client;
+use pdm_api_types::auto_installer::{
+    AnswerToken, AnswerTokenCreateResult, DeletablePreparedInstallationConfigProperty,
+    PreparedInstallationConfig, PreparedInstallationConfigUpdateResult,
+};
+use proxmox_yew_comp::{
+    form::delete_empty_values, percent_encoding::percent_encode_component, EditWindow,
+    LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
+    LoadableComponentScopeExt, LoadableComponentState,
+};
+use pwt::{
+    css::FlexFit,
+    prelude::*,
+    state::Store,
+    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_close: Option<Callback<()>>,
+
+    /// Dialog submit results callback.
+    #[builder_cb(IntoEventCallback, into_event_callback, (PreparedInstallationConfig, Option<AnswerTokenCreateResult>))]
+    #[prop_or_default]
+    pub on_submit_result:
+        Option<Callback<(PreparedInstallationConfig, Option<AnswerTokenCreateResult>)>>,
+
+    /// 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<()>,
+    token_store: Store<AnswerToken>,
+}
+
+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 {
+        let token_store =
+            Store::with_extract_key(|record: &AnswerToken| Key::from(record.id.to_owned()));
+        token_store.set_sorter(|a: &AnswerToken, b: &AnswerToken| a.id.cmp(&b.id));
+
+        Self {
+            state: LoadableComponentState::new(),
+            token_store,
+        }
+    }
+
+    fn load(
+        &self,
+        _ctx: &LoadableComponentContext<Self>,
+    ) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
+        let store = self.token_store.clone();
+        Box::pin(async move {
+            let data = pdm_client()
+                .get_autoinst_tokens()
+                .await?
+                .into_iter()
+                .collect();
+
+            store.write().set_data(data);
+            Ok(())
+        })
+    }
+
+    fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
+        let props = ctx.props();
+        let link = ctx.link().clone();
+
+        EditWindow::new(tr!("Edit Prepared Answer"))
+            .width(900)
+            .resizable(true)
+            .renderer({
+                let props = props.clone();
+                let token_store = self.token_store.clone();
+                move |form_ctx: &FormContext| render_tabpanel(form_ctx, &props, token_store.clone())
+            })
+            .edit(true)
+            .submit_digest(true)
+            .on_submit({
+                let id = props.config.id.clone();
+                let on_submit_result = props.on_submit_result.clone();
+                move |form_ctx: FormContext| {
+                    let id = id.clone();
+                    let link = link.clone();
+                    let on_submit_result = on_submit_result.clone();
+                    let config = form_ctx.get_submit_data();
+
+                    async move {
+                        match submit(&percent_encode_component(&id), config).await {
+                            Ok(PreparedInstallationConfigUpdateResult { config, token }) => {
+                                if let Some(on_submit_result) = on_submit_result {
+                                    on_submit_result.emit((config, token));
+                                }
+                            }
+                            Err(err) => link.show_error(
+                                tr!("Failed to update installation configuration"),
+                                err,
+                                true,
+                            ),
+                        }
+                        Ok(())
+                    }
+                }
+            })
+            .on_close(props.on_close.clone())
+            .advanced_checkbox(true)
+            .into()
+    }
+}
+
+async fn submit(
+    id: &str,
+    form_data: serde_json::Value,
+) -> Result<PreparedInstallationConfigUpdateResult> {
+    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",
+            "target-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>>();
+
+    Ok(pdm_client()
+        .update_autoinst_prepared_answer(
+            id,
+            &serde_json::from_value(data)?,
+            root_password.as_deref(),
+            &delete,
+        )
+        .await?)
+}
+
+fn render_tabpanel(
+    form_ctx: &FormContext,
+    props: &EditAnswerWindowProperties,
+    tokens: Store<AnswerToken>,
+) -> 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(form_ctx, &props.config, tokens),
+        )
+        .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..d6120d0
--- /dev/null
+++ b/ui/src/remotes/auto_installer/prepared_answer_form.rs
@@ -0,0 +1,1089 @@
+//! 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::{
+        AnswerToken, DiskSelectionMode, PreparedInstallationConfig,
+        PREPARED_INSTALL_CONFIG_ID_SCHEMA, TEMPLATE_COUNTER_NAME_REGEX, UDEV_FILTER_KEY_REGEX,
+    },
+    DISK_LIST_SCHEMA, EMAIL_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::{utils::copy_text_to_clipboard, KeyValueList, SchemaValidation};
+use pwt::{
+    css::{ColorScheme, Flex, FlexFit, Overflow},
+    prelude::*,
+    props::FieldStdProps,
+    state::Store,
+    widget::{
+        form::{Checkbox, Combobox, DisplayField, Field, FormContext, InputType, Number, TextArea},
+        Button, Column, Container, Dialog, Fa, FieldLabel, FieldPosition, InputPanel, Row, Tooltip,
+    },
+};
+
+use crate::remotes::auto_installer::token_selector::TokenSelector;
+
+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::Array(Vec::new()))
+}
+
+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().name("id").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())
+                .schema(&EMAIL_SCHEMA)
+                .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().key("dummy"))
+        .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(), Value::String(v.clone())))
+                        .collect(),
+                )
+                .key_label(tr!("Property name"))
+                .value_label(tr!("Value to match"))
+                .key_placeholder(tr!("udev property name, e.g. ID_NET_DRIVER"))
+                .value_renderer(render_udev_filter_value.into())
+                .submit_validate(kv_list_to_udev_filter_map_validate)
+                .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(", "))
+                .schema(&DISK_LIST_SCHEMA)
+                .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(), Value::String(v.clone())))
+                        .collect(),
+                )
+                .key_label(tr!("Property name"))
+                .value_label(tr!("Value to match"))
+                .key_placeholder(tr!("udev property name, e.g. ID_MODEL"))
+                .value_renderer(render_udev_filter_value.into())
+                .submit_validate(kv_list_to_udev_filter_map_validate)
+                .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")
+                .key("fs-warning")
+                .class("pwt-mt-2 pwt-d-block pwt-color-warning")
+                .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 has_target_filters = form_ctx
+        .read()
+        .get_field_value("target-filter")
+        .and_then(|v| v.as_array().map(|vec| !vec.is_empty()))
+        .unwrap_or(false);
+
+    let mut panel = InputPanel::new()
+        .class(Flex::Fill)
+        .class(Overflow::Auto)
+        .padding(4);
+
+    if !is_default && !has_target_filters {
+        panel.add_large_custom_child(
+            Container::from_tag("span")
+                .key("unmatchable-answer-warning")
+                .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")
+                .tip(tr!(
+                    "If selected, this configuration will be used if no other matches."
+                ))
+                .default(config.is_default),
+        )
+        .with_spacer()
+        .with_large_field(
+            tr!("Target filters"),
+            KeyValueList::new()
+                .value(
+                    config
+                        .target_filter
+                        .iter()
+                        .map(|(k, v)| (k.clone(), Value::String(v.clone())))
+                        .collect(),
+                )
+                .key_label(tr!("JSON pointer"))
+                .value_label(tr!("Value to match"))
+                .key_placeholder("/json/pointer")
+                .submit_validate(|v: &Vec<(String, Value)>| {
+                    let map: BTreeMap<String, String> = v
+                        .iter()
+                        .map(|(k, v)| {
+                            (
+                                k.clone(),
+                                v.as_str().map(ToOwned::to_owned).unwrap_or_default(),
+                            )
+                        })
+                        .collect();
+                    Ok(serde_json::to_value(map)?)
+                })
+                .submit_empty(true)
+                .name("target-filter")
+                .class(FlexFit)
+                .disabled(is_default),
+        )
+        .with_right_custom_child(Container::new().key("rfc-6901-hint").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("p")
+                .key("counter-info")
+                .with_child(tr!(
+                    "Numerical template counters can be used to provide unique values across installations."
+                )),
+        )
+        .with_large_custom_child(
+            Container::from_tag("p")
+                .key("counters-hint")
+                .class("pwt-mb-2")
+                .with_child(tr!(
+                    "Counters are automatically incremented each time an answer is served."
+                )),
+        )
+        .with_large_custom_child(
+            KeyValueList::new()
+                .value(
+                    config
+                        .template_counters
+                        .iter()
+                        .map(|(k, v)| (k.clone(), Value::Number((*v).into())))
+                        .collect(),
+                )
+                .value_label(tr!("Current value"))
+                .value_renderer(render_template_counter_value.into())
+                .submit_validate(kv_list_to_template_counter_map_validate)
+                .submit_empty(false)
+                .name("template-counters")
+                .class(FlexFit),
+        )
+        .into()
+}
+
+pub fn render_auth_form(
+    form_ctx: &FormContext,
+    config: &PreparedInstallationConfig,
+    tokens: Store<AnswerToken>,
+) -> yew::Html {
+    let has_tokens_selected = form_ctx
+        .read()
+        .get_field_value("authorized-tokens")
+        .and_then(|v| v.as_array().map(|vec| !vec.is_empty()))
+        .unwrap_or(false);
+
+    let mut panel = InputPanel::new()
+        .class(Flex::Fill)
+        .class(Overflow::Auto)
+        .padding(4)
+        .with_custom_child(
+            Container::from_tag("span")
+                .key("authorized-tokens-title")
+                .class("pwt-font-title-medium")
+                .with_child(tr!("Authorized tokens")),
+        )
+        .with_large_custom_child(
+            TokenSelector::new(tokens)
+                .selected_keys(config.authorized_tokens.clone())
+                .required(false)
+                .submit_empty(true)
+                .name("authorized-tokens"),
+        );
+
+    if !has_tokens_selected {
+        panel.add_large_custom_child(
+            Container::from_tag("p")
+                .key("auth-token-auto-create")
+                .class("pwt-color-warning")
+                .with_child(Fa::new("exclamation-circle").class("fa-fw"))
+                .with_child(tr!(
+                    "No existing authorization token selected. A new one will be automatically created."
+                ))
+        );
+    }
+
+    panel
+        .with_spacer()
+        .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"
+                ))
+                .tip(pdm_origin())
+                .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()),
+        )
+        .with_large_custom_child(
+            Container::from_tag("p")
+                .key("post-hook-hint")
+                .class("pwt-mt-2 pwt-color-primary")
+                .with_child(Fa::new("info-circle").class("fa-fw"))
+                .with_child(tr!(
+                    "Optional. If provided, status reporting will be enabled."
+                )),
+        )
+        .into()
+}
+
+pub fn render_show_secret_dialog(
+    config_id: Option<&str>,
+    token: &AnswerToken,
+    secret: &str,
+    on_close: Callback<()>,
+) -> Option<yew::Html> {
+    let token = format!("{}:{secret}", token.id);
+
+    let copy_token_view = Container::new()
+        .class("pwt-form-grid-col4")
+        .with_child(FieldLabel::new(tr!("Token")))
+        .with_child(
+            Row::new()
+                .class("pwt-fill-grid-row")
+                .gap(2)
+                .with_child(
+                    Field::new()
+                        .input_type(InputType::Password)
+                        .class(FlexFit)
+                        .value(token.to_owned())
+                        .read_only(true),
+                )
+                .with_child(
+                    Tooltip::new(
+                        Button::new_icon("fa fa-clipboard")
+                            .class(ColorScheme::Primary)
+                            .on_activate({
+                                let token = token.to_owned();
+                                move |_| copy_text_to_clipboard(&token)
+                            }),
+                    )
+                    .tip(tr!("Copy answer token to clipboard.")),
+                ),
+        );
+
+    let commandline = format!(
+        "proxmox-auto-install-assistant prepare-iso --fetch-from http --url {} --answer-auth-token {token}",
+        pdm_origin().unwrap_or_else(|| "https://pdm.example.com:8443".to_owned()),
+    );
+
+    let copy_commandline_view = Container::new()
+        .class("pwt-form-grid-col4")
+        .with_child(FieldLabel::new(tr!("Command line")))
+        .with_child(
+            Row::new()
+                .class("pwt-fill-grid-row")
+                .gap(2)
+                .with_child(
+                    Field::new()
+                        .input_type(InputType::Password)
+                        .class(FlexFit)
+                        .style("height", "2em")
+                        .value(commandline.to_owned())
+                        .read_only(true),
+                )
+                .with_child(
+                    Tooltip::new(
+                        Button::new_icon("fa fa-clipboard")
+                            .class(ColorScheme::Primary)
+                            .on_activate({
+                                let commandline = commandline.to_owned();
+                                move |_| copy_text_to_clipboard(&commandline)
+                            }),
+                    )
+                    .tip(tr!("Copy command line to clipboard.")),
+                ),
+        );
+
+    let mut panel = InputPanel::new().padding(4);
+
+    if let Some(id) = config_id {
+        panel.add_large_field(
+            false,
+            false,
+            tr!("Configuration ID"),
+            DisplayField::new().value(id.to_owned()).read_only(true),
+        );
+    }
+
+    panel.add_large_custom_child(copy_token_view);
+    panel.add_large_custom_child(copy_commandline_view);
+
+    let dialog = Dialog::new(tr!("New Answer Token")).on_close(on_close).with_child(Column::new().with_child(panel))
+        .with_child(
+            Container::new()
+                .padding(4)
+                .class(FlexFit)
+                .class(ColorScheme::WarningContainer)
+                .class("pwt-default-colors")
+                .with_child(tr!(
+                    "Please record the configuration token or ISO preparation command line - it will only be displayed once."
+                )),
+        );
+
+    Some(dialog.into())
+}
+
+fn render_udev_filter_value(
+    (_key, value, props, on_change): &(String, Value, FieldStdProps, Callback<String>),
+) -> yew::Html {
+    Field::new()
+        .placeholder(tr!("glob to match"))
+        .disabled(props.disabled)
+        .value(value.as_str().map(|s| s.to_owned()).unwrap_or_default())
+        .on_change(on_change)
+        .into()
+}
+
+fn render_template_counter_value(
+    (_key, value, props, on_change): &(String, Value, FieldStdProps, Callback<String>),
+) -> yew::Html {
+    Number::<i32>::new()
+        .value(value.as_i64().unwrap_or_default().to_string())
+        .disabled(props.disabled)
+        .on_change({
+            let on_change = on_change.clone();
+            move |v: Option<Result<i32, String>>| {
+                if let Some(Ok(v)) = v {
+                    on_change.emit(v.to_string());
+                }
+            }
+        })
+        .into()
+}
+
+#[allow(clippy::ptr_arg)]
+fn kv_list_to_udev_filter_map_validate(v: &Vec<(String, Value)>) -> Result<Value> {
+    let mut map = BTreeMap::<String, String>::new();
+    for (k, v) in v {
+        if UDEV_FILTER_KEY_REGEX.is_match(k) {
+            map.insert(k.clone(), v.as_str().unwrap_or_default().to_owned());
+        } else {
+            bail!("udev property names must only consist of uppercase characters and underscores: {k}");
+        }
+    }
+
+    Ok(serde_json::to_value(map)?)
+}
+
+#[allow(clippy::ptr_arg)]
+fn kv_list_to_template_counter_map_validate(v: &Vec<(String, Value)>) -> Result<Value> {
+    let mut map = BTreeMap::<String, i32>::new();
+    for (k, v) in v {
+        if TEMPLATE_COUNTER_NAME_REGEX.is_match(k) {
+            match v.as_i64().and_then(|v| v.try_into().ok()) {
+                Some(v) => {
+                    map.insert(k.clone(), v);
+                }
+                None => bail!("invalid value: {v}"),
+            }
+        } else {
+            bail!("must be a valid minijinja identifier: {k}");
+        }
+    }
+
+    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
+        }
+    }
+}
+
+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/remotes/auto_installer/prepared_answers_panel.rs b/ui/src/remotes/auto_installer/prepared_answers_panel.rs
new file mode 100644
index 0000000..0fa6970
--- /dev/null
+++ b/ui/src/remotes/auto_installer/prepared_answers_panel.rs
@@ -0,0 +1,311 @@
+//! 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::{
+    virtual_dom::{Key, VComp, VNode},
+    Properties,
+};
+
+use pdm_api_types::auto_installer::{
+    AnswerToken, AnswerTokenCreateResult, 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, remotes::auto_installer::prepared_answer_form::render_show_secret_dialog};
+
+#[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,
+    DisplaySecret {
+        config_id: String,
+        token: AnswerToken,
+        secret: String,
+    },
+}
+
+#[derive(PartialEq)]
+enum Message {
+    SelectionChange,
+    RemoveEntry,
+    DisplaySecret {
+        config_id: String,
+        token: AnswerToken,
+        secret: String,
+    },
+}
+
+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
+            }
+            Message::DisplaySecret {
+                config_id,
+                token,
+                secret,
+            } => {
+                link.change_view(Some(Self::ViewState::DisplaySecret {
+                    config_id,
+                    token,
+                    secret,
+                }));
+                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 link = ctx.link().clone();
+        let on_submit_result = ctx.link().callback(
+            move |(config, new_token): (
+                PreparedInstallationConfig,
+                Option<AnswerTokenCreateResult>,
+            )| {
+                if let Some(token) = new_token {
+                    Self::Message::DisplaySecret {
+                        config_id: config.id,
+                        token: token.token,
+                        secret: token.secret,
+                    }
+                } else {
+                    link.change_view(None);
+                    link.send_reload();
+                    Self::Message::SelectionChange
+                }
+            },
+        );
+
+        let on_close = ctx.link().change_view_callback(|_| None);
+
+        match view_state {
+            Self::ViewState::Create => Some(
+                AddAnswerWizardProperties::new()
+                    .on_submit_result(on_submit_result)
+                    .on_close(on_close)
+                    .into(),
+            ),
+            Self::ViewState::Copy => {
+                let mut record = self
+                    .store
+                    .read()
+                    .lookup_record(&self.selection.selected_key()?)?
+                    .clone();
+
+                record.id += " (copy)";
+                Some(
+                    AddAnswerWizardProperties::with(record)
+                        .on_submit_result(on_submit_result)
+                        .on_close(on_close)
+                        .into(),
+                )
+            }
+            Self::ViewState::Edit => {
+                let record = self
+                    .store
+                    .read()
+                    .lookup_record(&self.selection.selected_key()?)?
+                    .clone();
+
+                Some(
+                    EditAnswerWindowProperties::new(record)
+                        .on_submit_result(on_submit_result)
+                        .on_close(on_close)
+                        .into(),
+                )
+            }
+            Self::ViewState::DisplaySecret {
+                config_id,
+                token,
+                secret,
+            } => render_show_secret_dialog(Some(config_id), token, secret, on_close),
+        }
+    }
+}
+
+fn columns() -> Vec<DataTableHeader<PreparedInstallationConfig>> {
+    vec![
+        DataTableColumn::new(tr!("ID"))
+            .width("320px")
+            .render(|item: &PreparedInstallationConfig| item.id.as_str().into())
+            .sorter(
+                |a: &PreparedInstallationConfig, b: &PreparedInstallationConfig| a.id.cmp(&b.id),
+            )
+            .sort_order(Some(true))
+            .into(),
+        DataTableColumn::new(tr!("Default"))
+            .width("80px")
+            .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(),
+        DataTableColumn::new(tr!("Authorized tokens"))
+            .flex(1)
+            .render(|item: &PreparedInstallationConfig| item.authorized_tokens.join(", ").into())
+            .into(),
+    ]
+}
diff --git a/ui/src/remotes/auto_installer/token_selector.rs b/ui/src/remotes/auto_installer/token_selector.rs
new file mode 100644
index 0000000..ec34515
--- /dev/null
+++ b/ui/src/remotes/auto_installer/token_selector.rs
@@ -0,0 +1,159 @@
+//! A [`GridPicker`]-based selector for access tokens for the automated installer.
+
+use serde_json::Value;
+use std::{collections::HashSet, rc::Rc};
+use yew::{html, virtual_dom::Key, Properties};
+
+use pdm_api_types::auto_installer::AnswerToken;
+use pwt::{
+    css::FlexFit,
+    prelude::*,
+    state::{Selection, Store},
+    widget::{
+        data_table::{DataTable, DataTableColumn, DataTableHeader, MultiSelectMode},
+        form::{
+            ManagedField, ManagedFieldContext, ManagedFieldMaster, ManagedFieldScopeExt,
+            ManagedFieldState,
+        },
+        GridPicker,
+    },
+};
+use pwt_macros::{builder, widget};
+
+#[widget(comp = ManagedFieldMaster<TokenSelectorField>, @input)]
+#[derive(Clone, PartialEq, Properties)]
+#[builder]
+pub struct TokenSelector {
+    /// All available tokens to select.
+    store: Store<AnswerToken>,
+
+    #[builder]
+    #[prop_or_default]
+    /// Keys of entries to pre-select.
+    pub selected_keys: Vec<String>,
+}
+
+impl TokenSelector {
+    pub fn new(store: Store<AnswerToken>) -> Self {
+        yew::props!(Self { store })
+    }
+}
+
+pub struct TokenSelectorField {
+    state: ManagedFieldState,
+    store: Store<AnswerToken>,
+    selection: Selection,
+    columns: Rc<Vec<DataTableHeader<AnswerToken>>>,
+}
+
+pwt::impl_deref_mut_property!(TokenSelectorField, state, ManagedFieldState);
+
+pub enum Message {
+    UpdateSelection,
+}
+
+impl ManagedField for TokenSelectorField {
+    type Message = Message;
+    type Properties = TokenSelector;
+    type ValidateClosure = ();
+
+    fn create(ctx: &ManagedFieldContext<Self>) -> Self {
+        let selection = Selection::new()
+            .multiselect(true)
+            .on_select(ctx.link().callback(|_| Message::UpdateSelection));
+
+        let mut selected = ctx.props().selected_keys.clone();
+        selected.sort();
+
+        let store = ctx.props().store.clone().on_change(ctx.link().callback({
+            // re-apply selection when store changes
+            let selection = selection.clone();
+            let selected = selected
+                .iter()
+                .cloned()
+                .map(Key::from)
+                .collect::<HashSet<Key>>();
+            move |_| {
+                selection.bulk_select(selected.clone());
+                Message::UpdateSelection
+            }
+        }));
+
+        let default = serde_json::to_value(selected).unwrap_or_default();
+
+        Self {
+            state: ManagedFieldState::new(default.clone(), default),
+            store,
+            selection,
+            columns: Self::columns(),
+        }
+    }
+
+    fn validation_args(_props: &Self::Properties) -> Self::ValidateClosure {}
+
+    fn validator(_props: &Self::ValidateClosure, value: &Value) -> Result<Value, anyhow::Error> {
+        Ok(value.clone())
+    }
+
+    fn update(&mut self, ctx: &ManagedFieldContext<Self>, msg: Self::Message) -> bool {
+        match msg {
+            Self::Message::UpdateSelection => {
+                let mut selected = self
+                    .selection
+                    .selected_keys()
+                    .iter()
+                    .map(|k| k.to_string())
+                    .collect::<Vec<_>>();
+                selected.sort();
+                ctx.link().update_value(selected);
+                true
+            }
+        }
+    }
+
+    fn changed(&mut self, ctx: &ManagedFieldContext<Self>, old_props: &Self::Properties) -> bool {
+        let props = ctx.props();
+
+        if old_props.selected_keys != props.selected_keys {
+            let mut selected = props
+                .selected_keys
+                .iter()
+                .map(|k| k.to_string())
+                .collect::<Vec<_>>();
+            selected.sort();
+
+            ctx.link().update_default(selected);
+        }
+
+        true
+    }
+
+    fn view(&self, _ctx: &ManagedFieldContext<Self>) -> Html {
+        GridPicker::new(
+            DataTable::new(self.columns.clone(), self.store.clone())
+                .multiselect_mode(MultiSelectMode::Simple)
+                .border(true)
+                .class(FlexFit),
+        )
+        .selection(self.selection.clone())
+        .into()
+    }
+}
+
+impl TokenSelectorField {
+    fn columns() -> Rc<Vec<DataTableHeader<AnswerToken>>> {
+        Rc::new(vec![
+            DataTableColumn::selection_indicator().into(),
+            DataTableColumn::new(tr!("Token"))
+                .flex(1)
+                .render(|item: &AnswerToken| html! { &item.id })
+                .sorter(|a: &AnswerToken, b: &AnswerToken| a.id.cmp(&b.id))
+                .sort_order(true)
+                .into(),
+            DataTableColumn::new(tr!("Comment"))
+                .flex(1)
+                .render(|item: &AnswerToken| html! { item.comment.as_deref().unwrap_or("") })
+                .into(),
+        ])
+    }
+}
-- 
2.53.0





  parent reply	other threads:[~2026-04-30 12:50 UTC|newest]

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