From: Christoph Heiss <c.heiss@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [pdm-devel] [PATCH datacenter-manager 13/13] ui: auto-installer: add prepared answer configuration panel
Date: Thu, 4 Dec 2025 13:51:22 +0100 [thread overview]
Message-ID: <20251204125122.945961-14-c.heiss@proxmox.com> (raw)
In-Reply-To: <20251204125122.945961-1-c.heiss@proxmox.com>
Adds a pretty typical CRUD panel, allowing users to add/edit/remove
prepared answer file configurations for the auto-install server.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
ui/Cargo.toml | 4 +
ui/src/auto_installer/add_wizard.rs | 142 +++
ui/src/auto_installer/answer_form.rs | 849 ++++++++++++++++++
ui/src/auto_installer/edit_window.rs | 105 +++
ui/src/auto_installer/mod.rs | 11 +
.../auto_installer/prepared_answers_panel.rs | 233 +++++
ui/src/main_menu.rs | 11 +-
7 files changed, 1354 insertions(+), 1 deletion(-)
create mode 100644 ui/src/auto_installer/add_wizard.rs
create mode 100644 ui/src/auto_installer/answer_form.rs
create mode 100644 ui/src/auto_installer/edit_window.rs
create mode 100644 ui/src/auto_installer/prepared_answers_panel.rs
diff --git a/ui/Cargo.toml b/ui/Cargo.toml
index 2b4713c..4ba38c5 100644
--- a/ui/Cargo.toml
+++ b/ui/Cargo.toml
@@ -42,6 +42,8 @@ proxmox-schema = "5"
proxmox-subscription = { version = "1.0.1", features = ["api-types"], default-features = false }
proxmox-rrd-api-types = "1"
proxmox-node-status = "1"
+proxmox-network-types = "0.1"
+proxmox-installer-types = "0.1"
pbs-api-types = { version = "1.0.3", features = [ "enum-fallback" ] }
pdm-api-types = { version = "1.0", path = "../lib/pdm-api-types" }
@@ -54,6 +56,8 @@ pdm-search = { version = "0.2", path = "../lib/pdm-search" }
[patch.crates-io]
# proxmox-client = { path = "../../proxmox/proxmox-client" }
# proxmox-human-byte = { path = "../../proxmox/proxmox-human-byte" }
+# proxmox-network-types = { path = "../proxmox/proxmox-network-types" }
+# proxmox-installer-types = { path = "../proxmox/proxmox-installer-types" }
# proxmox-login = { path = "../../proxmox/proxmox-login" }
# proxmox-rrd-api-types = { path = "../../proxmox/proxmox-rrd-api-types" }
# proxmox-schema = { path = "../../proxmox/proxmox-schema" }
diff --git a/ui/src/auto_installer/add_wizard.rs b/ui/src/auto_installer/add_wizard.rs
new file mode 100644
index 0000000..5e392a5
--- /dev/null
+++ b/ui/src/auto_installer/add_wizard.rs
@@ -0,0 +1,142 @@
+//! Implements the configuration dialog UI for the auto-installer integration.
+
+use js_sys::Intl;
+use proxmox_installer_types::answer;
+use proxmox_network_types::fqdn::Fqdn;
+use proxmox_schema::property_string::PropertyString;
+use std::rc::Rc;
+use wasm_bindgen::JsValue;
+use yew::{
+ html::IntoEventCallback,
+ virtual_dom::{VComp, VNode},
+};
+
+use crate::auto_installer::answer_form::*;
+use pdm_api_types::auto_installer::{DiskSelectionMode, PreparedInstallationConfig};
+use proxmox_yew_comp::{Wizard, WizardPageRenderInfo};
+use pwt::prelude::*;
+use pwt::widget::TabBarItem;
+use pwt_macros::builder;
+
+#[derive(Clone, PartialEq, Properties)]
+#[builder]
+pub struct AddAnswerWizardProperties {
+ /// Dialog close callback.
+ #[builder_cb(IntoEventCallback, into_event_callback, ())]
+ #[prop_or_default]
+ pub on_done: Option<Callback<()>>,
+
+ /// Auto-installer answer configuration.
+ config: PreparedInstallationConfig,
+}
+
+impl AddAnswerWizardProperties {
+ pub fn with(config: PreparedInstallationConfig) -> Self {
+ yew::props!(Self { config })
+ }
+}
+
+impl Default for AddAnswerWizardProperties {
+ fn default() -> Self {
+ let config = PreparedInstallationConfig {
+ id: String::new(),
+ // target filter
+ is_default: false,
+ target_filter: Vec::new(),
+ // global options
+ country: "at".to_owned(),
+ fqdn: "host.example.com"
+ .parse::<Fqdn>()
+ .expect("known valid fqdn"),
+ use_dhcp_fqdn: false,
+ keyboard: answer::KeyboardLayout::default(),
+ mailto: "root@example.invalid".to_owned(),
+ timezone: get_js_timezone().unwrap_or_else(|| "Etc/UTC".to_owned()),
+ root_password_hashed: None,
+ reboot_on_error: false,
+ reboot_mode: answer::RebootMode::default(),
+ root_ssh_keys: Vec::new(),
+ // network options
+ use_dhcp_network: true,
+ cidr: None,
+ gateway: None,
+ dns: None,
+ netdev_filter: Vec::new(),
+ netif_name_pinning_enabled: true,
+ // disk options
+ filesystem_type: answer::FilesystemType::default(),
+ filesystem_options: PropertyString::new(answer::FilesystemOptions::Lvm(
+ answer::LvmOptions::default(),
+ )),
+ disk_mode: DiskSelectionMode::default(),
+ disk_list: None,
+ disk_filter: Vec::new(),
+ disk_filter_match: None,
+ post_hook_base_url: None,
+ post_hook_cert_fp: None,
+ };
+
+ yew::props!(Self { config })
+ }
+}
+
+impl From<AddAnswerWizardProperties> for VNode {
+ fn from(value: AddAnswerWizardProperties) -> Self {
+ let comp = VComp::new::<AddAnswerWizardComponent>(Rc::new(value), None);
+ VNode::from(comp)
+ }
+}
+
+pub struct AddAnswerWizardComponent {}
+
+impl Component for AddAnswerWizardComponent {
+ type Message = ();
+ type Properties = AddAnswerWizardProperties;
+
+ fn create(_ctx: &Context<Self>) -> Self {
+ Self {}
+ }
+
+ fn view(&self, ctx: &Context<Self>) -> Html {
+ let props = ctx.props();
+ let url = "/auto-install/prepared";
+
+ Wizard::new(tr!("Add Prepared Answer"))
+ .width(900)
+ .on_done(props.on_done.clone())
+ .on_submit({
+ move |config: serde_json::Value| async move { submit(url, None, config).await }
+ })
+ .with_page(
+ TabBarItem::new().key("global").label(tr!("Global options")),
+ {
+ let config = props.config.clone();
+ move |_: &WizardPageRenderInfo| render_global_options_form(&config, true)
+ },
+ )
+ .with_page(TabBarItem::new().label(tr!("Network options")), {
+ let config = props.config.clone();
+ move |p: &WizardPageRenderInfo| render_network_options_form(&p.form_ctx, &config)
+ })
+ .with_page(TabBarItem::new().label(tr!("Disk Setup")), {
+ let config = props.config.clone();
+ move |p: &WizardPageRenderInfo| render_disk_setup_form(&p.form_ctx, &config)
+ })
+ .with_page(TabBarItem::new().label(tr!("Target filter")), {
+ let config = props.config.clone();
+ move |p: &WizardPageRenderInfo| render_target_filter_form(&p.form_ctx, &config)
+ })
+ .with_page(TabBarItem::new().label(tr!("Post-installation")), {
+ let config = props.config.clone();
+ move |_: &WizardPageRenderInfo| render_post_hook_form(&config)
+ })
+ .into()
+ }
+}
+
+fn get_js_timezone() -> Option<String> {
+ let datetime_options = Intl::DateTimeFormat::default().resolved_options();
+ js_sys::Reflect::get(&datetime_options, &JsValue::from_str("timeZone"))
+ .ok()
+ .and_then(|v| v.as_string())
+}
diff --git a/ui/src/auto_installer/answer_form.rs b/ui/src/auto_installer/answer_form.rs
new file mode 100644
index 0000000..44a3ade
--- /dev/null
+++ b/ui/src/auto_installer/answer_form.rs
@@ -0,0 +1,849 @@
+use anyhow::{anyhow, bail, Result};
+use proxmox_network_types::fqdn::Fqdn;
+use serde::{Deserialize, Serialize};
+use serde_json::{json, Value};
+use std::{collections::BTreeMap, ops::Deref, rc::Rc, sync::LazyLock};
+
+use pdm_api_types::auto_installer::{
+ DiskSelectionMode, PreparedInstallationConfig, PREPARED_INSTALL_CONFIG_ID_SCHEMA,
+};
+use proxmox_installer_types::{
+ answer::{
+ BtrfsCompressOption, BtrfsOptions, FilesystemOptions, FilesystemType, FilterMatch,
+ KeyboardLayout, LvmOptions, RebootMode, ZfsChecksumOption, ZfsCompressOption, ZfsOptions,
+ BTRFS_COMPRESS_OPTIONS, FILESYSTEM_TYPE_OPTIONS, ROOT_PASSWORD_SCHEMA,
+ ZFS_CHECKSUM_OPTIONS, ZFS_COMPRESS_OPTIONS,
+ },
+ ProxmoxProduct, EMAIL_DEFAULT_PLACEHOLDER,
+};
+use proxmox_schema::{
+ api_types::{CIDR_SCHEMA, IP_SCHEMA},
+ property_string::PropertyString,
+};
+use proxmox_yew_comp::{form::delete_empty_values, SchemaValidation};
+use pwt::widget::{
+ form::{Checkbox, Combobox, DisplayField, Field, FormContext, InputType},
+ Container, FieldPosition, InputPanel,
+};
+use pwt::{
+ css::{Flex, Overflow},
+ prelude::*,
+ widget::form::{Number, TextArea},
+};
+
+pub async fn submit(
+ url: &str,
+ existing_id: Option<&str>,
+ mut config: serde_json::Value,
+) -> Result<()> {
+ let obj = config.as_object_mut().expect("always an object");
+
+ let fs_opts = collect_fs_options_into_propstring(obj);
+ obj.insert("filesystem-options".to_owned(), json!(fs_opts));
+
+ let root_ssh_keys = collect_lines_into_array(obj.remove("root-ssh-keys"));
+ let target_filter = collect_lines_into_array(obj.remove("target-filter"));
+ let disk_filter = collect_lines_into_array(obj.remove("disk-filter-text"));
+ let netdev_filter = collect_lines_into_array(obj.remove("netdev-filter-text"));
+
+ config["root-ssh-keys"] = root_ssh_keys;
+ config["target-filter"] = target_filter;
+ config["disk-filter"] = disk_filter;
+ config["netdev-filter"] = netdev_filter;
+
+ if let Some(id) = existing_id {
+ config["id"] = json!(id);
+ let data = delete_empty_values(
+ &config,
+ &[
+ "root-ssh-keys",
+ "post-hook-base-url",
+ "post-hook-cert-fp",
+ "disk-filter",
+ "netdev-filter",
+ ],
+ true,
+ );
+ proxmox_yew_comp::http_put(url, Some(data)).await
+ } else {
+ proxmox_yew_comp::http_post(url, Some(config)).await
+ }
+}
+
+fn collect_fs_options_into_propstring(
+ obj: &mut serde_json::Map<String, Value>,
+) -> PropertyString<FilesystemOptions> {
+ let fs_type = obj
+ .get("filesystem-type")
+ .and_then(|s| s.as_str())
+ .and_then(|s| s.parse::<FilesystemType>().ok())
+ .unwrap_or_default();
+
+ match fs_type {
+ FilesystemType::Ext4 | FilesystemType::Xfs => {
+ PropertyString::new(FilesystemOptions::Lvm(LvmOptions {
+ hdsize: obj.remove("hdsize").and_then(|v| v.as_f64()),
+ swapsize: obj.remove("swapsize").and_then(|v| v.as_f64()),
+ maxroot: obj.remove("maxroot").and_then(|v| v.as_f64()),
+ maxvz: obj.remove("maxvz").and_then(|v| v.as_f64()),
+ minfree: obj.remove("minfree").and_then(|v| v.as_f64()),
+ }))
+ }
+ FilesystemType::Zfs(level) => PropertyString::new(FilesystemOptions::Zfs(ZfsOptions {
+ raid: Some(level),
+ ashift: obj
+ .remove("ashift")
+ .and_then(|v| v.as_u64())
+ .map(|v| v as u32),
+ arc_max: obj
+ .remove("ashift")
+ .and_then(|v| v.as_u64())
+ .map(|v| v as u32),
+ checksum: obj
+ .remove("checksum")
+ .and_then(|v| v.as_str().map(ToOwned::to_owned))
+ .and_then(|s| s.parse::<ZfsChecksumOption>().ok()),
+ compress: obj
+ .remove("checksum")
+ .and_then(|v| v.as_str().map(ToOwned::to_owned))
+ .and_then(|s| s.parse::<ZfsCompressOption>().ok()),
+ copies: obj
+ .remove("copies")
+ .and_then(|v| v.as_u64())
+ .map(|v| v as u32),
+ hdsize: obj.remove("hdsize").and_then(|v| v.as_f64()),
+ })),
+ FilesystemType::Btrfs(level) => {
+ PropertyString::new(FilesystemOptions::Btrfs(BtrfsOptions {
+ raid: Some(level),
+ compress: obj
+ .remove("checksum")
+ .and_then(|v| v.as_str().map(ToOwned::to_owned))
+ .and_then(|s| s.parse::<BtrfsCompressOption>().ok()),
+ hdsize: obj.remove("hdsize").and_then(|v| v.as_f64()),
+ }))
+ }
+ }
+}
+
+fn collect_lines_into_array(value: Option<Value>) -> Value {
+ value
+ .and_then(|v| v.as_str().map(|s| s.to_owned()))
+ .map(|s| json!(s.split('\n').collect::<Vec<&str>>()))
+ .unwrap_or_else(|| json!([]))
+}
+
+pub fn render_global_options_form(
+ config: &PreparedInstallationConfig,
+ is_create: bool,
+) -> yew::Html {
+ let mut panel = InputPanel::new()
+ .class(Flex::Fill)
+ .class(Overflow::Auto)
+ .padding(4);
+
+ if is_create {
+ panel.add_field(
+ tr!("Installation ID"),
+ Field::new()
+ .name("id")
+ .value(config.id.clone())
+ .schema(&PREPARED_INSTALL_CONFIG_ID_SCHEMA)
+ .required(true),
+ );
+ } else {
+ panel.add_field(
+ tr!("Installation ID"),
+ DisplayField::new().value(config.id.clone()),
+ );
+ }
+
+ panel
+ .with_field(
+ tr!("Country"),
+ Combobox::new()
+ .name("country")
+ .placeholder(tr!("Two-letter country code, e.g. at"))
+ .items(Rc::new(
+ COUNTRY_INFO
+ .deref()
+ .keys()
+ .map(|s| s.as_str().into())
+ .collect(),
+ ))
+ .render_value(|v: &AttrValue| {
+ if let Some(s) = COUNTRY_INFO.deref().get(&v.to_string()) {
+ s.into()
+ } else {
+ v.into()
+ }
+ })
+ .value(config.country.clone())
+ .required(true),
+ )
+ .with_field(
+ tr!("Timezone"),
+ Field::new()
+ .name("timezone")
+ .value(config.timezone.clone())
+ .placeholder(tr!("Timezone name, e.g. Europe/Vienna"))
+ .required(true),
+ )
+ .with_field(
+ tr!("Root password"),
+ Field::new()
+ .name("root-password")
+ .input_type(InputType::Password)
+ .schema(&ROOT_PASSWORD_SCHEMA)
+ .placeholder((!is_create).then(|| tr!("Keep current")))
+ .required(is_create),
+ )
+ .with_field(
+ tr!("Keyboard Layout"),
+ Combobox::new()
+ .name("keyboard")
+ .items(Rc::new(
+ KEYBOARD_LAYOUTS
+ .iter()
+ .map(|l| serde_variant_name(l).expect("valid variant").into())
+ .collect(),
+ ))
+ .render_value(|v: &AttrValue| {
+ v.parse::<KeyboardLayout>()
+ .map(|v| v.to_string())
+ .unwrap_or_default()
+ .into()
+ })
+ .value(serde_variant_name(config.keyboard))
+ .required(true),
+ )
+ .with_field(
+ tr!("Administrator email address"),
+ Field::new()
+ .name("mailto")
+ .placeholder(EMAIL_DEFAULT_PLACEHOLDER.to_owned())
+ .input_type(InputType::Email)
+ .value(config.mailto.clone())
+ .validate(|s: &String| {
+ if s.ends_with(".invalid") {
+ bail!(tr!("Invalid (default) email address"))
+ } else {
+ Ok(())
+ }
+ })
+ .required(true),
+ )
+ .with_field(
+ tr!("Root SSH public keys"),
+ TextArea::new()
+ .name("root-ssh-keys")
+ .class("pwt-w-100")
+ .submit_empty(false)
+ .attribute("rows", "3")
+ .placeholder(tr!("One per line, usually begins with \"ssh-\", \"sk-ssh-\", \"ecdsa-\" or \"sk-ecdsa\""))
+ .value(config.root_ssh_keys.join("\n")),
+ )
+ .with_field(
+ tr!("Reboot on error"),
+ Checkbox::new().name("reboot-on-error"),
+ )
+ .with_field(
+ tr!("Post-Installation action"),
+ Combobox::new()
+ .name("reboot-mode")
+ .items(Rc::new(
+ [RebootMode::Reboot, RebootMode::PowerOff]
+ .iter()
+ .map(|opt| serde_variant_name(opt).expect("valid variant").into())
+ .collect(),
+ ))
+ .render_value(|v: &AttrValue| match v.parse::<RebootMode>() {
+ Ok(RebootMode::Reboot) => tr!("Reboot").into(),
+ Ok(RebootMode::PowerOff) => tr!("Power off").into(),
+ _ => v.into(),
+ })
+ .value(serde_variant_name(config.reboot_mode))
+ .required(true),
+ )
+ .into()
+}
+
+pub fn render_network_options_form(
+ form_ctx: &FormContext,
+ config: &PreparedInstallationConfig,
+) -> yew::Html {
+ let use_dhcp_network = form_ctx
+ .read()
+ .get_field_value("use-dhcp-network")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(true);
+
+ let use_dhcp_fqdn = form_ctx
+ .read()
+ .get_field_value("use-dhcp-fqdn")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(true);
+
+ InputPanel::new()
+ .class(Flex::Fill)
+ .class(Overflow::Auto)
+ .padding(4)
+ .show_advanced(form_ctx.get_show_advanced())
+ .with_field(
+ tr!("Use DHCP network information"),
+ Checkbox::new().name("use-dhcp-network").default(true),
+ )
+ .with_field(
+ tr!("IP address (CIDR)"),
+ Field::new()
+ .name("cidr")
+ .placeholder(tr!("E.g. 192.168.0.100/24"))
+ .schema(&CIDR_SCHEMA)
+ .disabled(use_dhcp_network)
+ .required(!use_dhcp_network),
+ )
+ .with_field(
+ tr!("Gateway address"),
+ Field::new()
+ .name("gateway")
+ .placeholder(tr!("E.g. 192.168.0.1"))
+ .schema(&IP_SCHEMA)
+ .disabled(use_dhcp_network)
+ .required(!use_dhcp_network),
+ )
+ .with_field(
+ tr!("DNS server address"),
+ Field::new()
+ .name("dns")
+ .placeholder(tr!("E.g. 192.168.0.254"))
+ .schema(&IP_SCHEMA)
+ .disabled(use_dhcp_network)
+ .required(!use_dhcp_network),
+ )
+ .with_right_field(
+ tr!("Use FQDN from DHCP"),
+ Checkbox::new().name("use-dhcp-fqdn").default(false),
+ )
+ .with_right_field(
+ tr!("Fully-qualified domain name (FQDN)"),
+ Field::new()
+ .name("fqdn")
+ .placeholder("machine.example.com")
+ .value(config.fqdn.to_string())
+ .disabled(use_dhcp_fqdn)
+ .validate(|s: &String| {
+ s.parse::<Fqdn>()
+ .map_err(|err| anyhow!("{err}"))
+ .map(|_| ())
+ })
+ .required(!use_dhcp_fqdn),
+ )
+ .with_right_field("", DisplayField::new()) // dummy
+ .with_right_field(
+ tr!("Enable network interface name pinning"),
+ Checkbox::new()
+ .name("netif-name-pinning-enabled")
+ .default(config.netif_name_pinning_enabled),
+ )
+ .with_spacer()
+ .with_large_field(
+ tr!("Network device udev filters"),
+ TextArea::new()
+ .name("netdev-filter-text")
+ .class("pwt-w-100")
+ .style("resize", "vertical")
+ .submit_empty(false)
+ .attribute("rows", "3")
+ .placeholder(tr!("UDEV_PROP=glob*, e.g. ID_NET_DRIVER=foo, one per line"))
+ .value(config.netdev_filter.iter().fold(String::new(), |acc, s| {
+ if acc.is_empty() {
+ s.to_string()
+ } else {
+ format!("{acc}\n{s}")
+ }
+ }))
+ .disabled(use_dhcp_fqdn),
+ )
+ .into()
+}
+
+pub fn render_disk_setup_form(
+ form_ctx: &FormContext,
+ config: &PreparedInstallationConfig,
+) -> yew::Html {
+ let disk_mode = form_ctx
+ .read()
+ .get_field_value("disk-mode")
+ .and_then(|v| v.as_str().and_then(|s| s.parse::<DiskSelectionMode>().ok()))
+ .unwrap_or_default();
+
+ let fs_type = form_ctx
+ .read()
+ .get_field_value("filesystem-type")
+ .and_then(|v| v.as_str().and_then(|s| s.parse::<FilesystemType>().ok()))
+ .unwrap_or_default();
+
+ let product_filter = form_ctx
+ .read()
+ .get_field_value("target-filter-product")
+ .and_then(|v| v.as_str().and_then(|s| s.parse::<ProxmoxProduct>().ok()));
+
+ // Btrfs is only enabled for PVE installations
+ let filter_btrfs = |fstype: &&FilesystemType| -> bool {
+ product_filter == Some(ProxmoxProduct::PVE) || !fstype.is_btrfs()
+ };
+
+ let mut panel = InputPanel::new()
+ .class(Flex::Fill)
+ .class(Overflow::Auto)
+ .padding(4)
+ .show_advanced(form_ctx.get_show_advanced())
+ .with_field(
+ tr!("Filesystem"),
+ Combobox::new()
+ .name("filesystem-type")
+ .items(Rc::new(
+ FILESYSTEM_TYPE_OPTIONS
+ .iter()
+ .filter(filter_btrfs)
+ .map(|opt| serde_variant_name(opt).expect("valid variant").into())
+ .collect(),
+ ))
+ .render_value(|v: &AttrValue| {
+ v.parse::<FilesystemType>()
+ .map(|v| v.to_string())
+ .unwrap_or_default()
+ .into()
+ })
+ .value(serde_variant_name(config.filesystem_type))
+ .required(true)
+ .show_filter(false),
+ )
+ .with_right_field(
+ tr!("Disk selection mode"),
+ Combobox::new()
+ .name("disk-mode")
+ .with_item("fixed")
+ .with_item("filter")
+ .default("fixed")
+ .render_value(|v: &AttrValue| match v.parse::<DiskSelectionMode>() {
+ Ok(DiskSelectionMode::Fixed) => tr!("Fixed list of disk names").into(),
+ Ok(DiskSelectionMode::Filter) => tr!("Dynamically by udev filter").into(),
+ _ => v.into(),
+ })
+ .required(true)
+ .value(serde_variant_name(config.disk_mode)),
+ )
+ .with_spacer()
+ .with_field(
+ tr!("Disk names"),
+ Field::new()
+ .name("disk-list")
+ .placeholder(tr!("E.g.") + " sda, sdb")
+ .value(
+ config
+ .disk_list
+ .as_ref()
+ .map(|s| s.to_owned())
+ .unwrap_or_default(),
+ )
+ .disabled(disk_mode != DiskSelectionMode::Fixed)
+ .required(disk_mode == DiskSelectionMode::Fixed),
+ )
+ .with_field(
+ tr!("Disk udev filter mode"),
+ Combobox::new()
+ .name("disk-filter-match")
+ .items(Rc::new(
+ [FilterMatch::Any, FilterMatch::All]
+ .iter()
+ .map(|opt| serde_variant_name(opt).expect("valid variant").into())
+ .collect(),
+ ))
+ .render_value(|v: &AttrValue| match v.parse::<FilterMatch>() {
+ Ok(FilterMatch::Any) => tr!("Match any filter").into(),
+ Ok(FilterMatch::All) => tr!("Match all filters").into(),
+ _ => v.into(),
+ })
+ .default(serde_variant_name(FilterMatch::default()))
+ .value(config.disk_filter_match.and_then(serde_variant_name))
+ .disabled(disk_mode != DiskSelectionMode::Filter),
+ )
+ .with_large_field(
+ tr!("Disk udev filters"),
+ TextArea::new()
+ .name("disk-filter-text")
+ .class("pwt-w-100")
+ .style("resize", "vertical")
+ .submit_empty(false)
+ .attribute("rows", "3")
+ .placeholder(tr!(
+ "UDEV_PROP=glob*, e.g. ID_MODEL=VENDORFOO_SSD0, one per line"
+ ))
+ .value(config.disk_filter.iter().fold(String::new(), |acc, s| {
+ if acc.is_empty() {
+ s.to_string()
+ } else {
+ format!("{acc}\n{s}")
+ }
+ }))
+ .disabled(disk_mode != DiskSelectionMode::Filter),
+ );
+
+ let warning = match fs_type {
+ FilesystemType::Zfs(_) => Some(
+ tr!("ZFS is not compatible with hardware RAID controllers, for details see the documentation.")
+ ),
+ FilesystemType::Btrfs(_) => Some(tr!("Btrfs integration is a technology preview.")),
+ _ => None,
+ };
+
+ if let Some(text) = warning {
+ panel.add_large_custom_child(html! {
+ <span class="pwt-color-warning pwt-mt-2 pwt-d-block">
+ <i class="fa fa-fw fa-exclamation-circle"/>
+ {text}
+ </span>
+ });
+ }
+
+ panel.add_spacer(true);
+
+ add_fs_advanced_form_fields(&mut panel, &config.filesystem_options);
+ panel.into()
+}
+
+fn add_fs_advanced_form_fields(panel: &mut InputPanel, fs_opts: &FilesystemOptions) {
+ match fs_opts {
+ FilesystemOptions::Lvm(opts) => add_lvm_advanced_form_fields(panel, opts),
+ FilesystemOptions::Zfs(opts) => add_zfs_advanced_form_fields(panel, opts),
+ FilesystemOptions::Btrfs(opts) => add_btrfs_advanced_form_fields(panel, opts),
+ }
+}
+
+fn add_lvm_advanced_form_fields(panel: &mut InputPanel, fs_opts: &LvmOptions) {
+ panel.add_field_with_options(
+ FieldPosition::Left,
+ true,
+ false,
+ tr!("Harddisk size to use (GB)"),
+ Field::new()
+ .name("hdsize")
+ .number(4., None, 0.1)
+ .submit_empty(false)
+ .value(fs_opts.hdsize.map(|v| v.to_string())),
+ );
+
+ panel.add_field_with_options(
+ FieldPosition::Left,
+ true,
+ false,
+ tr!("Swap size (GB)"),
+ Field::new()
+ .name("swapsize")
+ .number(0., fs_opts.hdsize.map(|v| v / 2.), 1.)
+ .submit_empty(false)
+ .value(fs_opts.swapsize.map(|v| v.to_string())),
+ );
+ panel.add_field_with_options(
+ FieldPosition::Right,
+ true,
+ false,
+ tr!("Maximum root volume size (GB)"),
+ Field::new()
+ .name("maxroot")
+ .number(0., fs_opts.hdsize.map(|v| v / 2.), 1.)
+ .submit_empty(false)
+ .value(fs_opts.maxroot.map(|v| v.to_string())),
+ );
+ panel.add_field_with_options(
+ FieldPosition::Right,
+ true,
+ false,
+ tr!("Maximum data volume size (GB)"),
+ Field::new()
+ .name("maxvz")
+ .number(0., fs_opts.hdsize.map(|v| v / 2.), 1.)
+ .submit_empty(false)
+ .value(fs_opts.maxvz.map(|v| v.to_string())),
+ );
+ panel.add_field_with_options(
+ FieldPosition::Right,
+ true,
+ false,
+ tr!("Minimum free space in LVM volume group (GB)"),
+ Field::new()
+ .name("minfree")
+ .number(0., fs_opts.hdsize.map(|v| v / 2.), 1.)
+ .submit_empty(false)
+ .value(fs_opts.minfree.map(|v| v.to_string())),
+ );
+}
+
+fn add_zfs_advanced_form_fields(panel: &mut InputPanel, fs_opts: &ZfsOptions) {
+ panel.add_field_with_options(
+ FieldPosition::Left,
+ true,
+ false,
+ "ashift",
+ Number::<u64>::new()
+ .name("ashift")
+ .min(9)
+ .max(16)
+ .step(1)
+ .submit_empty(false)
+ .value(fs_opts.ashift.map(|v| v.to_string())),
+ );
+ panel.add_field_with_options(
+ FieldPosition::Left,
+ true,
+ false,
+ tr!("ARC maximum size (MiB)"),
+ Field::new()
+ .name("arc-max")
+ .number(64., None, 1.)
+ .submit_empty(false)
+ .value(fs_opts.arc_max.map(|v| v.to_string())),
+ );
+ panel.add_field_with_options(
+ FieldPosition::Right,
+ true,
+ false,
+ tr!("Checksumming algorithm"),
+ Combobox::new()
+ .name("checksum")
+ .items(Rc::new(
+ ZFS_CHECKSUM_OPTIONS
+ .iter()
+ .map(|opt| serde_variant_name(opt).expect("valid variant").into())
+ .collect(),
+ ))
+ .render_value(|v: &AttrValue| {
+ v.parse::<ZfsChecksumOption>()
+ .map(|v| v.to_string())
+ .unwrap_or_default()
+ .into()
+ })
+ .submit_empty(false)
+ .value(fs_opts.checksum.map(|v| v.to_string())),
+ );
+ panel.add_field_with_options(
+ FieldPosition::Right,
+ true,
+ false,
+ tr!("Compression algorithm"),
+ Combobox::new()
+ .name("compress")
+ .items(Rc::new(
+ ZFS_COMPRESS_OPTIONS
+ .iter()
+ .map(|opt| serde_variant_name(opt).expect("valid variant").into())
+ .collect(),
+ ))
+ .render_value(|v: &AttrValue| {
+ v.parse::<ZfsCompressOption>()
+ .map(|v| v.to_string())
+ .unwrap_or_default()
+ .into()
+ })
+ .submit_empty(false)
+ .value(fs_opts.compress.map(|v| v.to_string())),
+ );
+ panel.add_field_with_options(
+ FieldPosition::Right,
+ true,
+ false,
+ tr!("Copies"),
+ Number::<u32>::new()
+ .name("copies")
+ .min(1)
+ .max(3)
+ .step(1)
+ .submit_empty(false)
+ .value(fs_opts.copies.map(|v| v.to_string())),
+ );
+}
+
+fn add_btrfs_advanced_form_fields(panel: &mut InputPanel, fs_opts: &BtrfsOptions) {
+ panel.add_field_with_options(
+ FieldPosition::Right,
+ true,
+ false,
+ tr!("Compression algorithm"),
+ Combobox::new()
+ .name("compress")
+ .items(Rc::new(
+ BTRFS_COMPRESS_OPTIONS
+ .iter()
+ .map(|opt| serde_variant_name(opt).expect("valid variant").into())
+ .collect(),
+ ))
+ .render_value(|v: &AttrValue| {
+ v.parse::<BtrfsCompressOption>()
+ .map(|v| v.to_string())
+ .unwrap_or_default()
+ .into()
+ })
+ .submit_empty(false)
+ .value(fs_opts.compress.map(|v| v.to_string())),
+ );
+}
+
+pub fn render_target_filter_form(
+ form_ctx: &FormContext,
+ config: &PreparedInstallationConfig,
+) -> yew::Html {
+ let is_default = form_ctx
+ .read()
+ .get_field_value("is-default")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(config.is_default);
+
+ let target_filter = form_ctx
+ .read()
+ .get_field_value("target-filter")
+ .and_then(|v| v.as_str().map(|s| s.to_string()))
+ .unwrap_or_else(|| {
+ config.target_filter.iter().fold(String::new(), |acc, s| {
+ if acc.is_empty() {
+ s.to_string()
+ } else {
+ format!("{acc}\n{s}")
+ }
+ })
+ });
+
+ let mut panel = InputPanel::new()
+ .class(Flex::Fill)
+ .class(Overflow::Auto)
+ .padding(4);
+
+ if !is_default && target_filter.is_empty() {
+ panel.add_large_custom_child(html! {
+ <span class="pwt-color-warning pwt-mb-2 pwt-d-block">
+ <i class="fa fa-fw fa-exclamation-circle"/>
+ {tr!("Not marked as default answer and target filter are empty, answer will never be matched.")}
+ </span>
+ });
+ }
+
+ panel
+ .with_field(
+ tr!("Default answer"),
+ Checkbox::new()
+ .name("is-default")
+ .default(config.is_default),
+ )
+ .with_spacer()
+ .with_large_field(
+ tr!("Target filters"),
+ TextArea::new()
+ .name("target-filter")
+ .class("pwt-w-100")
+ .style("resize", "vertical")
+ .submit_empty(false)
+ .attribute("rows", "4")
+ .placeholder(tr!(
+ "/json/pointer=value, one per line, for example: /product/product=pve"
+ ))
+ .default(target_filter)
+ .disabled(is_default),
+ )
+ .with_right_custom_child(Container::new().with_child(html! {
+ <span>
+ {tr!("Target filter keys are JSON pointers according to")}
+ {" "}
+ <a href="https://www.rfc-editor.org/rfc/rfc6901">{"RFC 6906"}</a>
+ {"."}
+ </span>
+ }))
+ .into()
+}
+
+pub fn render_post_hook_form(config: &PreparedInstallationConfig) -> yew::Html {
+ InputPanel::new()
+ .class(Flex::Fill)
+ .class(Overflow::Auto)
+ .padding(4)
+ .with_large_custom_child(html! {
+ <span class="pwt-mb-2 pwt-d-block">
+ <i class="fa fa-fw fa-info-circle"/>
+ {tr!("Optional. If provided, progress reporting is enabled.")}
+ </span>
+ })
+ .with_field(
+ tr!("PDM API base URL"),
+ Field::new()
+ .name("post-hook-base-url")
+ .tip(tr!(
+ "Base URL this PDM instance is reachable from the target host"
+ ))
+ .value(
+ config
+ .post_hook_base_url
+ .clone()
+ .or_else(|| pdm_origin().map(|s| format!("{s}/api2"))),
+ ),
+ )
+ .with_field(
+ tr!("SHA256 certificate fingerprint"),
+ Field::new()
+ .name("post-hook-cert-fp")
+ .tip(tr!("Optional certificate fingerprint"))
+ .value(config.post_hook_cert_fp.clone()),
+ )
+ .into()
+}
+
+fn serde_variant_name<T: Serialize>(ty: T) -> Option<String> {
+ match serde_json::to_value(ty) {
+ Ok(Value::String(s)) => Some(s),
+ other => {
+ log::warn!(
+ "expected string of type {}, got {other:?}",
+ std::any::type_name::<T>()
+ );
+ None
+ }
+ }
+}
+
+fn pdm_origin() -> Option<String> {
+ gloo_utils::document()
+ .url()
+ .and_then(|s| web_sys::Url::new(&s))
+ .map(|url| url.origin())
+ .ok()
+}
+
+const KEYBOARD_LAYOUTS: &[KeyboardLayout] = {
+ use KeyboardLayout::*;
+ &[
+ De, DeCh, Dk, EnGb, EnUs, Es, Fi, Fr, FrBe, FrCa, FrCh, Hu, Is, It, Jp, Lt, Mk, Nl, No, Pl,
+ Pt, PtBr, Se, Si, Tr,
+ ]
+};
+
+static COUNTRY_INFO: LazyLock<BTreeMap<String, String>> = LazyLock::new(|| {
+ #[derive(Deserialize)]
+ struct Iso3611CountryInfo {
+ alpha_2: String,
+ common_name: Option<String>,
+ name: String,
+ }
+
+ #[derive(Deserialize)]
+ struct Iso3611Info {
+ #[serde(rename = "3166-1")]
+ list: Vec<Iso3611CountryInfo>,
+ }
+
+ let raw: Iso3611Info =
+ serde_json::from_str(include_str!("/usr/share/iso-codes/json/iso_3166-1.json"))
+ .expect("valid country-info json");
+
+ raw.list
+ .into_iter()
+ .map(|c| (c.alpha_2.to_lowercase(), c.common_name.unwrap_or(c.name)))
+ .collect()
+});
diff --git a/ui/src/auto_installer/edit_window.rs b/ui/src/auto_installer/edit_window.rs
new file mode 100644
index 0000000..7054eea
--- /dev/null
+++ b/ui/src/auto_installer/edit_window.rs
@@ -0,0 +1,105 @@
+//! Implements the configuration dialog UI for the auto-installer integration.
+
+use std::rc::Rc;
+use yew::{
+ html::IntoEventCallback,
+ virtual_dom::{VComp, VNode},
+};
+
+use crate::auto_installer::answer_form::*;
+use pdm_api_types::auto_installer::PreparedInstallationConfig;
+use proxmox_yew_comp::{percent_encoding::percent_encode_component, EditWindow};
+use pwt::prelude::*;
+use pwt::widget::{form::FormContext, TabBarItem, TabPanel};
+use pwt_macros::builder;
+
+#[derive(Clone, PartialEq, Properties)]
+#[builder]
+pub struct EditAnswerWindowProperties {
+ /// Dialog close callback.
+ #[builder_cb(IntoEventCallback, into_event_callback, ())]
+ #[prop_or_default]
+ pub on_done: Option<Callback<()>>,
+
+ /// Auto-installer answer configuration.
+ config: PreparedInstallationConfig,
+}
+
+impl EditAnswerWindowProperties {
+ pub fn new(config: PreparedInstallationConfig) -> Self {
+ yew::props!(Self { config })
+ }
+}
+
+impl From<EditAnswerWindowProperties> for VNode {
+ fn from(value: EditAnswerWindowProperties) -> Self {
+ let comp = VComp::new::<EditAnswerWindowComponent>(Rc::new(value), None);
+ VNode::from(comp)
+ }
+}
+
+pub struct EditAnswerWindowComponent {}
+
+impl Component for EditAnswerWindowComponent {
+ type Message = ();
+ type Properties = EditAnswerWindowProperties;
+
+ fn create(_ctx: &Context<Self>) -> Self {
+ Self {}
+ }
+
+ fn view(&self, ctx: &Context<Self>) -> Html {
+ let props = ctx.props();
+ let url = format!(
+ "/auto-install/prepared/{}",
+ percent_encode_component(&props.config.id)
+ );
+
+ EditWindow::new(tr!("Edit Prepared Answer"))
+ .width(900)
+ .on_done(props.on_done.clone())
+ .renderer({
+ let props = props.clone();
+ move |form_ctx: &FormContext| render_tabpanel(form_ctx, &props)
+ })
+ .edit(true)
+ .submit_digest(true)
+ .on_submit({
+ let url = url.clone();
+ let id = props.config.id.clone();
+ move |form_ctx: FormContext| {
+ let url = url.clone();
+ let id = id.clone();
+ let config = form_ctx.get_submit_data();
+ async move { submit(&url, Some(&id), config).await }
+ }
+ })
+ .advanced_checkbox(true)
+ .into()
+ }
+}
+
+fn render_tabpanel(form_ctx: &FormContext, props: &EditAnswerWindowProperties) -> yew::Html {
+ TabPanel::new()
+ .with_item(
+ TabBarItem::new().key("global").label(tr!("Global options")),
+ render_global_options_form(&props.config, false),
+ )
+ .with_item(
+ TabBarItem::new().label(tr!("Network options")),
+ render_network_options_form(form_ctx, &props.config),
+ )
+ .with_item(
+ TabBarItem::new().label(tr!("Disk Setup")),
+ render_disk_setup_form(form_ctx, &props.config),
+ )
+ .with_item(
+ TabBarItem::new().label(tr!("Target filter")),
+ render_target_filter_form(form_ctx, &props.config),
+ )
+ .with_item(
+ TabBarItem::new().label(tr!("Post-installation")),
+ render_post_hook_form(&props.config),
+ )
+ .into()
+}
diff --git a/ui/src/auto_installer/mod.rs b/ui/src/auto_installer/mod.rs
index 810eade..1702e68 100644
--- a/ui/src/auto_installer/mod.rs
+++ b/ui/src/auto_installer/mod.rs
@@ -1,4 +1,15 @@
//! Implements the UI for the proxmox-auto-installer integration.
+mod answer_form;
+
+mod add_wizard;
+pub use add_wizard::*;
+
+mod edit_window;
+pub use edit_window::*;
+
+mod prepared_answers_panel;
+pub use prepared_answers_panel::*;
+
mod installations_panel;
pub use installations_panel::*;
diff --git a/ui/src/auto_installer/prepared_answers_panel.rs b/ui/src/auto_installer/prepared_answers_panel.rs
new file mode 100644
index 0000000..32de81a
--- /dev/null
+++ b/ui/src/auto_installer/prepared_answers_panel.rs
@@ -0,0 +1,233 @@
+//! Implements the UI for the auto-installer answer editing panel.
+
+use anyhow::Result;
+use std::{future::Future, pin::Pin, rc::Rc};
+use yew::{
+ html,
+ virtual_dom::{Key, VComp, VNode},
+ Properties,
+};
+
+use pdm_api_types::auto_installer::PreparedInstallationConfig;
+use proxmox_yew_comp::{
+ percent_encoding::percent_encode_component, ConfirmButton, LoadableComponent,
+ LoadableComponentContext, LoadableComponentMaster,
+};
+use pwt::{
+ props::{ContainerBuilder, EventSubscriber, WidgetBuilder},
+ state::{Selection, Store},
+ tr,
+ widget::{
+ data_table::{DataTable, DataTableColumn, DataTableHeader, DataTableMouseEvent},
+ Button, Fa, Toolbar,
+ },
+};
+
+#[derive(Default, PartialEq, Properties)]
+pub struct AutoInstallerPreparedAnswersPanel {}
+
+impl From<AutoInstallerPreparedAnswersPanel> for VNode {
+ fn from(value: AutoInstallerPreparedAnswersPanel) -> Self {
+ let comp = VComp::new::<LoadableComponentMaster<AutoInstallerPreparedAnswersPanelComponent>>(
+ Rc::new(value),
+ None,
+ );
+ VNode::from(comp)
+ }
+}
+
+#[derive(PartialEq)]
+pub enum ViewState {
+ Create,
+ Copy,
+ Edit,
+}
+
+#[derive(PartialEq)]
+pub enum Message {
+ SelectionChange,
+ RemoveEntry,
+}
+
+#[derive(PartialEq, Properties)]
+pub struct AutoInstallerPreparedAnswersPanelComponent {
+ selection: Selection,
+ store: Store<PreparedInstallationConfig>,
+ columns: Rc<Vec<DataTableHeader<PreparedInstallationConfig>>>,
+}
+
+impl LoadableComponent for AutoInstallerPreparedAnswersPanelComponent {
+ type Properties = AutoInstallerPreparedAnswersPanel;
+ type Message = Message;
+ type ViewState = ViewState;
+
+ fn create(ctx: &LoadableComponentContext<Self>) -> Self {
+ let store = Store::with_extract_key(|record: &PreparedInstallationConfig| {
+ Key::from(record.id.to_string())
+ });
+ store.set_sorter(
+ |a: &PreparedInstallationConfig, b: &PreparedInstallationConfig| a.id.cmp(&b.id),
+ );
+
+ Self {
+ selection: Selection::new()
+ .on_select(ctx.link().callback(|_| Message::SelectionChange)),
+ store,
+ columns: Rc::new(columns()),
+ }
+ }
+
+ fn load(
+ &self,
+ _ctx: &LoadableComponentContext<Self>,
+ ) -> Pin<Box<dyn Future<Output = Result<()>>>> {
+ let path = "/auto-install/prepared".to_string();
+ let store = self.store.clone();
+ Box::pin(async move {
+ let data = proxmox_yew_comp::http_get(&path, None).await?;
+ store.write().set_data(data);
+ Ok(())
+ })
+ }
+
+ fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Message) -> bool {
+ match msg {
+ Message::SelectionChange => true,
+ Message::RemoveEntry => {
+ if let Some(key) = self.selection.selected_key() {
+ let link = ctx.link();
+ link.clone().spawn(async move {
+ if let Err(err) = delete_entry(key).await {
+ link.show_error(tr!("Unable to delete entry"), err, true);
+ }
+ link.send_reload();
+ })
+ }
+ false
+ }
+ }
+ }
+
+ fn toolbar(&self, ctx: &LoadableComponentContext<Self>) -> Option<yew::Html> {
+ let link = ctx.link();
+
+ let toolbar = Toolbar::new()
+ .class("pwt-w-100")
+ .class(pwt::css::Overflow::Hidden)
+ .class("pwt-border-bottom")
+ .with_child(Button::new(tr!("Add")).onclick({
+ let link = ctx.link();
+ move |_| {
+ link.change_view(Some(ViewState::Create));
+ }
+ }))
+ .with_spacer()
+ .with_child(Button::new(tr!("Copy")).onclick({
+ let link = ctx.link();
+ move |_| {
+ link.change_view(Some(ViewState::Copy));
+ }
+ }))
+ .with_child(
+ Button::new(tr!("Edit"))
+ .disabled(self.selection.is_empty())
+ .onclick(link.change_view_callback(|_| Some(ViewState::Edit))),
+ )
+ .with_child(
+ ConfirmButton::new(tr!("Remove"))
+ .confirm_message(tr!("Are you sure you want to remove this entry?"))
+ .disabled(self.selection.is_empty())
+ .on_activate(link.callback(|_| Message::RemoveEntry)),
+ );
+
+ Some(toolbar.into())
+ }
+
+ fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> yew::Html {
+ DataTable::new(self.columns.clone(), self.store.clone())
+ .class(pwt::css::FlexFit)
+ .selection(self.selection.clone())
+ .on_row_dblclick({
+ let link = ctx.link();
+ move |_: &mut DataTableMouseEvent| {
+ link.change_view(Some(Self::ViewState::Edit));
+ }
+ })
+ .into()
+ }
+
+ fn dialog_view(
+ &self,
+ ctx: &LoadableComponentContext<Self>,
+ view_state: &Self::ViewState,
+ ) -> Option<yew::Html> {
+ let on_done = ctx.link().clone().change_view_callback(|_| None);
+
+ Some(match view_state {
+ Self::ViewState::Create => super::AddAnswerWizardProperties::default()
+ .on_done(on_done)
+ .into(),
+ Self::ViewState::Copy => {
+ let mut record = self
+ .store
+ .read()
+ .lookup_record(&self.selection.selected_key()?)?
+ .clone();
+
+ record.id += " (copy)";
+ super::AddAnswerWizardProperties::with(record)
+ .on_done(on_done)
+ .into()
+ }
+ Self::ViewState::Edit => {
+ let record = self
+ .store
+ .read()
+ .lookup_record(&self.selection.selected_key()?)?
+ .clone();
+
+ super::EditAnswerWindowProperties::new(record)
+ .on_done(on_done)
+ .into()
+ }
+ })
+ }
+}
+
+async fn delete_entry(key: Key) -> Result<()> {
+ let url = format!(
+ "/auto-install/prepared/{}",
+ percent_encode_component(&key.to_string())
+ );
+ proxmox_yew_comp::http_delete(&url, None).await
+}
+
+fn columns() -> Vec<DataTableHeader<PreparedInstallationConfig>> {
+ vec![
+ DataTableColumn::new(tr!("ID"))
+ .width("320px")
+ .render(|item: &PreparedInstallationConfig| html! { &item.id })
+ .into(),
+ DataTableColumn::new(tr!("Default"))
+ .width("170px")
+ .render(|item: &PreparedInstallationConfig| {
+ if item.is_default {
+ Fa::new("check").into()
+ } else {
+ Fa::new("times").into()
+ }
+ })
+ .into(),
+ DataTableColumn::new(tr!("Target filter"))
+ .flex(1)
+ .render(|item: &PreparedInstallationConfig| {
+ item.target_filter
+ .iter()
+ .map(|s| s.to_string())
+ .reduce(|acc, s| format!("{acc}, {s}"))
+ .unwrap_or_else(|| "-".to_owned())
+ .into()
+ })
+ .into(),
+ ]
+}
diff --git a/ui/src/main_menu.rs b/ui/src/main_menu.rs
index 073b84d..531174b 100644
--- a/ui/src/main_menu.rs
+++ b/ui/src/main_menu.rs
@@ -14,7 +14,7 @@ use proxmox_yew_comp::{AclContext, NotesView, XTermJs};
use pdm_api_types::remotes::RemoteType;
use pdm_api_types::{PRIV_SYS_AUDIT, PRIV_SYS_MODIFY};
-use crate::auto_installer::AutoInstallerPanel;
+use crate::auto_installer::{AutoInstallerPanel, AutoInstallerPreparedAnswersPanel};
use crate::configuration::subscription_panel::SubscriptionPanel;
use crate::configuration::views::ViewGrid;
use crate::dashboard::view::View;
@@ -381,6 +381,15 @@ impl Component for PdmMainMenu {
let mut autoinstaller_submenu = Menu::new();
+ register_view(
+ &mut autoinstaller_submenu,
+ &mut content,
+ tr!("Prepared Answers"),
+ "auto-installer-prepared",
+ Some("fa fa-files-o"),
+ |_| AutoInstallerPreparedAnswersPanel::default().into(),
+ );
+
register_submenu(
&mut menu,
&mut content,
--
2.51.2
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
next prev parent reply other threads:[~2025-12-04 12:52 UTC|newest]
Thread overview: 17+ messages / expand[flat|nested] mbox.gz Atom feed top
2025-12-04 12:51 [pdm-devel] [PATCH proxmox/datacenter-manager 00/13] initial auto-installer integration Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 01/13] api-macro: allow $ in identifier name Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 02/13] network-types: move `Fqdn` type from proxmox-installer-common Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 03/13] network-types: implement api type for Fqdn Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 04/13] network-types: add api wrapper type for std::net::IpAddr Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 05/13] installer-types: add common types used by the installer Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 06/13] installer-types: add types used by the auto-installer Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 07/13] installer-types: implement api type for all externally-used types Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH datacenter-manager 08/13] api-types: add api types for auto-installer integration Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH datacenter-manager 09/13] config: add auto-installer configuration module Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH datacenter-manager 10/13] acl: wire up new /system/auto-installation acl path Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH datacenter-manager 11/13] server: api: add auto-installer integration module Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH datacenter-manager 12/13] ui: auto-installer: add installations overview panel Christoph Heiss
2025-12-04 12:51 ` Christoph Heiss [this message]
2025-12-04 14:17 ` [pdm-devel] [PATCH proxmox/datacenter-manager 00/13] initial auto-installer integration Lukas Wagner
2025-12-04 15:06 ` Christoph Heiss
2025-12-05 11:26 ` Christoph Heiss
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20251204125122.945961-14-c.heiss@proxmox.com \
--to=c.heiss@proxmox.com \
--cc=pdm-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.