[PATCH datacenter-manager v3 22/38] ui: auto-installer: add prepared answer configuration panel

Christoph Heiss
2026-04-03T16:57:12Z

Adds a pretty typical CRUD panel, allowing users to add/edit/remove
prepared answer file configurations for the auto-install server.

Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* filters are now proper key-value `DataTable`s instead of plain text
areas
* added new "Templating" and "Authentication" tabs
* adapted as necessary to changed types from `proxmox-installer-types`
* use new `PdmClient` methods instead of manual post/put
* removed automatic `/api2` suffix from pdm base url
* set _target="blank" for RFC 6901 link

Changes v1 -> v2:
* new patch

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

diff --git a/ui/src/remotes/auto_installer/mod.rs b/ui/src/remotes/auto_installer/mod.rs
index 8155a9b..1a85978 100644
--- a/ui/src/remotes/auto_installer/mod.rs
+++ b/ui/src/remotes/auto_installer/mod.rs
@@ -1,6 +1,10 @@
//! Implements the UI for the proxmox-auto-installer integration.

mod installations_panel;
+mod prepared_answer_add_wizard;
+mod prepared_answer_edit_window;
+mod prepared_answer_form;
+mod prepared_answers_panel;

use std::rc::Rc;
use yew::virtual_dom::{VComp, VNode};
@@ -39,15 +43,30 @@ impl Component for AutoInstallerPanelComponent {
.with_child(tr!("Installations"))
.into();

+ let answers_title: Html = Row::new()
+ .gap(2)
+ .class(AlignItems::Baseline)
+ .with_child(Fa::new("files-o"))
+ .with_child(tr!("Prepared Answers"))
+ .into();
+
Container::new()
.class("pwt-content-spacer")
.class(Fit)
.class(css::Display::Grid)
+ .style("grid-template-columns", "repeat(2, 1fr)")
+ .style("grid-template-rows", "repeat(1, 1fr)")
.with_child(
Panel::new()
+ .style("grid-row", "span 2 / span 1")
.title(installations_title)
.with_child(installations_panel::InstallationsPanel::default()),
)
+ .with_child(
+ Panel::new()
+ .title(answers_title)
+ .with_child(prepared_answers_panel::PreparedAnswersPanel::default()),
+ )
.into()
}
}
diff --git a/ui/src/remotes/auto_installer/prepared_answer_add_wizard.rs b/ui/src/remotes/auto_installer/prepared_answer_add_wizard.rs
new file mode 100644
index 0000000..5d15a43
--- /dev/null
+++ b/ui/src/remotes/auto_installer/prepared_answer_add_wizard.rs
@@ -0,0 +1,173 @@
+//! Implements the configuration dialog UI for the auto-installer integration.
+
+use anyhow::Result;
+use js_sys::Intl;
+use proxmox_installer_types::answer;
+use std::{collections::BTreeMap, future::Future, pin::Pin, rc::Rc};
+use wasm_bindgen::JsValue;
+use yew::{
+ html::IntoEventCallback,
+ virtual_dom::{VComp, VNode},
+};
+
+use pdm_api_types::auto_installer::{DiskSelectionMode, PreparedInstallationConfig};
+use proxmox_yew_comp::{
+ LoadableComponent, LoadableComponentContext, LoadableComponentMaster, LoadableComponentState,
+ Wizard, WizardPageRenderInfo,
+};
+use pwt::{prelude::*, widget::TabBarItem};
+use pwt_macros::builder;
+
+use super::prepared_answer_form::*;
+use crate::pdm_client;
+
+#[derive(Clone, PartialEq, Properties)]
+#[builder]
+pub struct AddAnswerWizardProperties {
+ /// Dialog close callback.
+ #[builder_cb(IntoEventCallback, into_event_callback, ())]
+ #[prop_or_default]
+ pub on_done: Option<Callback<()>>,
+
+ /// Auto-installer answer configuration.
+ config: PreparedInstallationConfig,
+}
+
+impl AddAnswerWizardProperties {
+ pub fn new() -> Self {
+ let config = PreparedInstallationConfig {
+ id: String::new(),
+ authorized_tokens: Vec::new(),
+ // target filter
+ is_default: false,
+ target_filter: BTreeMap::new(),
+ // global options
+ country: "at".to_owned(),
+ fqdn: "host.example.com".to_owned(),
+ use_dhcp_fqdn: false,
+ keyboard: answer::KeyboardLayout::default(),
+ mailto: String::new(),
+ timezone: js_timezone().unwrap_or_else(|| "Etc/UTC".to_owned()),
+ root_password_hashed: None,
+ reboot_on_error: false,
+ reboot_mode: answer::RebootMode::default(),
+ root_ssh_keys: Vec::new(),
+ // network options
+ use_dhcp_network: true,
+ cidr: None,
+ gateway: None,
+ dns: None,
+ netdev_filter: BTreeMap::new(),
+ netif_name_pinning_enabled: true,
+ // disk options
+ filesystem: answer::FilesystemOptions::Ext4(answer::LvmOptions::default()),
+ disk_mode: DiskSelectionMode::default(),
+ disk_list: Vec::new(),
+ disk_filter: BTreeMap::new(),
+ disk_filter_match: None,
+ // post hook
+ post_hook_base_url: pdm_origin(),
+ post_hook_cert_fp: None,
+ // templating
+ template_counters: BTreeMap::new(),
+ };
+
+ yew::props!(Self { config })
+ }
+
+ pub fn with(config: PreparedInstallationConfig) -> Self {
+ yew::props!(Self { config })
+ }
+}
+
+impl From<AddAnswerWizardProperties> for VNode {
+ fn from(value: AddAnswerWizardProperties) -> Self {
+ let comp =
+ VComp::new::<LoadableComponentMaster<AddAnswerWizardComponent>>(Rc::new(value), None);
+ VNode::from(comp)
+ }
+}
+
+struct AddAnswerWizardComponent {
+ state: LoadableComponentState<()>,
+}
+
+pwt::impl_deref_mut_property!(AddAnswerWizardComponent, state, LoadableComponentState<()>);
+
+impl LoadableComponent for AddAnswerWizardComponent {
+ type Properties = AddAnswerWizardProperties;
+ type Message = ();
+ type ViewState = ();
+
+ fn create(_ctx: &LoadableComponentContext<Self>) -> Self {
+ Self {
+ state: LoadableComponentState::new(),
+ }
+ }
+
+ fn load(
+ &self,
+ _ctx: &LoadableComponentContext<Self>,
+ ) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
+ Box::pin(async move { Ok(()) })
+ }
+
+ fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
+ let props = ctx.props();
+
+ Wizard::new(tr!("Add Prepared Answer"))
+ .width(900)
+ .resizable(true)
+ .on_done(props.on_done.clone())
+ .on_submit(|config: serde_json::Value| async move { submit(config).await })
+ .with_page(TabBarItem::new().label(tr!("Global options")), {
+ let config = props.config.clone();
+ move |_: &WizardPageRenderInfo| render_global_options_form(&config, true)
+ })
+ .with_page(TabBarItem::new().label(tr!("Network options")), {
+ let config = props.config.clone();
+ move |p: &WizardPageRenderInfo| render_network_options_form(&p.form_ctx, &config)
+ })
+ .with_page(TabBarItem::new().label(tr!("Disk Setup")), {
+ let config = props.config.clone();
+ move |p: &WizardPageRenderInfo| render_disk_setup_form(&p.form_ctx, &config)
+ })
+ .with_page(TabBarItem::new().label(tr!("Target filter")), {
+ let config = props.config.clone();
+ move |p: &WizardPageRenderInfo| render_target_filter_form(&p.form_ctx, &config)
+ })
+ .with_page(TabBarItem::new().label(tr!("Templating")), {
+ let config = props.config.clone();
+ move |_: &WizardPageRenderInfo| render_templating_form(&config)
+ })
+ .with_page(TabBarItem::new().label(tr!("Authentication")), {
+ let config = props.config.clone();
+ move |_: &WizardPageRenderInfo| render_auth_form(&config)
+ })
+ .into()
+ }
+}
+
+async fn submit(form_data: serde_json::Value) -> Result<()> {
+ let data = prepare_form_data(form_data)?;
+
+ pdm_client()
+ .add_autoinst_prepared_answer(&serde_json::from_value(data)?)
+ .await?;
+ Ok(())
+}
+
+fn js_timezone() -> Option<String> {
+ let datetime_options = Intl::DateTimeFormat::default().resolved_options();
+ js_sys::Reflect::get(&datetime_options, &JsValue::from_str("timeZone"))
+ .ok()
+ .and_then(|v| v.as_string())
+}
+
+fn pdm_origin() -> Option<String> {
+ gloo_utils::document()
+ .url()
+ .and_then(|s| web_sys::Url::new(&s))
+ .map(|url| url.origin())
+ .ok()
+}
diff --git a/ui/src/remotes/auto_installer/prepared_answer_edit_window.rs b/ui/src/remotes/auto_installer/prepared_answer_edit_window.rs
new file mode 100644
index 0000000..3fb9766
--- /dev/null
+++ b/ui/src/remotes/auto_installer/prepared_answer_edit_window.rs
@@ -0,0 +1,165 @@
+//! Implements the configuration dialog UI for the auto-installer integration.
+
+use anyhow::Result;
+use std::{future::Future, pin::Pin, rc::Rc};
+use yew::{
+ html::IntoEventCallback,
+ virtual_dom::{VComp, VNode},
+};
+
+use crate::pdm_client;
+use pdm_api_types::auto_installer::{
+ DeletablePreparedInstallationConfigProperty, PreparedInstallationConfig,
+};
+use proxmox_yew_comp::{
+ form::delete_empty_values, percent_encoding::percent_encode_component, EditWindow,
+ LoadableComponent, LoadableComponentContext, LoadableComponentMaster, LoadableComponentState,
+};
+use pwt::{
+ css::FlexFit,
+ prelude::*,
+ widget::{form::FormContext, TabBarItem, TabPanel},
+};
+use pwt_macros::builder;
+
+use super::prepared_answer_form::*;
+
+#[derive(Clone, PartialEq, Properties)]
+#[builder]
+pub struct EditAnswerWindowProperties {
+ /// Dialog close callback.
+ #[builder_cb(IntoEventCallback, into_event_callback, ())]
+ #[prop_or_default]
+ pub on_done: Option<Callback<()>>,
+
+ /// Auto-installer answer configuration.
+ config: PreparedInstallationConfig,
+}
+
+impl EditAnswerWindowProperties {
+ pub fn new(config: PreparedInstallationConfig) -> Self {
+ yew::props!(Self { config })
+ }
+}
+
+impl From<EditAnswerWindowProperties> for VNode {
+ fn from(value: EditAnswerWindowProperties) -> Self {
+ let comp =
+ VComp::new::<LoadableComponentMaster<EditAnswerWindowComponent>>(Rc::new(value), None);
+ VNode::from(comp)
+ }
+}
+
+struct EditAnswerWindowComponent {
+ state: LoadableComponentState<()>,
+}
+
+pwt::impl_deref_mut_property!(EditAnswerWindowComponent, state, LoadableComponentState<()>);
+
+impl LoadableComponent for EditAnswerWindowComponent {
+ type Properties = EditAnswerWindowProperties;
+ type Message = ();
+ type ViewState = ();
+
+ fn create(_ctx: &LoadableComponentContext<Self>) -> Self {
+ Self {
+ state: LoadableComponentState::new(),
+ }
+ }
+
+ fn load(
+ &self,
+ _ctx: &LoadableComponentContext<Self>,
+ ) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
+ Box::pin(async move { Ok(()) })
+ }
+
+ fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
+ let props = ctx.props();
+
+ EditWindow::new(tr!("Edit Prepared Answer"))
+ .width(900)
+ .resizable(true)
+ .on_done(props.on_done.clone())
+ .renderer({
+ let props = props.clone();
+ move |form_ctx: &FormContext| render_tabpanel(form_ctx, &props)
+ })
+ .edit(true)
+ .submit_digest(true)
+ .on_submit({
+ let id = props.config.id.clone();
+ move |form_ctx: FormContext| {
+ let id = id.clone();
+ let config = form_ctx.get_submit_data();
+ async move { submit(&percent_encode_component(&id), config).await }
+ }
+ })
+ .advanced_checkbox(true)
+ .into()
+ }
+}
+
+async fn submit(id: &str, form_data: serde_json::Value) -> Result<()> {
+ let data = delete_empty_values(
+ &prepare_form_data(form_data)?,
+ &[
+ "root-ssh-keys",
+ "post-hook-base-url",
+ "post-hook-cert-fp",
+ "disk-filter",
+ "netdev-filter",
+ ],
+ true,
+ );
+
+ let root_password = data["root-password"].as_str().map(ToOwned::to_owned);
+ let delete = data["delete"]
+ .as_array()
+ .cloned()
+ .unwrap_or_default()
+ .iter()
+ .flat_map(|s| s.as_str().and_then(|s| s.parse().ok()))
+ .collect::<Vec<DeletablePreparedInstallationConfigProperty>>();
+
+ pdm_client()
+ .update_autoinst_prepared_answer(
+ id,
+ &serde_json::from_value(data)?,
+ root_password.as_deref(),
+ &delete,
+ )
+ .await?;
+ Ok(())
+}
+
+fn render_tabpanel(form_ctx: &FormContext, props: &EditAnswerWindowProperties) -> yew::Html {
+ TabPanel::new()
+ .class(FlexFit)
+ .force_render_all(true)
+ .with_item(
+ TabBarItem::new().label(tr!("Global options")),
+ render_global_options_form(&props.config, false),
+ )
+ .with_item(
+ TabBarItem::new().label(tr!("Network options")),
+ render_network_options_form(form_ctx, &props.config),
+ )
+ .with_item(
+ TabBarItem::new().label(tr!("Disk Setup")),
+ render_disk_setup_form(form_ctx, &props.config),
+ )
+ .with_item(
+ TabBarItem::new().label(tr!("Target filter")),
+ render_target_filter_form(form_ctx, &props.config),
+ )
+ .with_item(
+ TabBarItem::new().label(tr!("Templating")),
+ render_templating_form(&props.config),
+ )
+ .with_item(
+ TabBarItem::new().label(tr!("Authentication")),
+ render_auth_form(&props.config),
+ )
+ .into()
+}
diff --git a/ui/src/remotes/auto_installer/prepared_answer_form.rs b/ui/src/remotes/auto_installer/prepared_answer_form.rs
new file mode 100644
index 0000000..29bc768
--- /dev/null
+++ b/ui/src/remotes/auto_installer/prepared_answer_form.rs
@@ -0,0 +1,857 @@
+//! Provides all shared components for the prepared answer create wizard and the corresponding
+//! edit window, as well as some utility to collect and prepare the form data for submission.
+
+use anyhow::{anyhow, bail, Result};
+use serde::{Deserialize, Serialize};
+use serde_json::{json, Value};
+use std::{collections::BTreeMap, ops::Deref, rc::Rc, sync::LazyLock};
+
+use pdm_api_types::auto_installer::{
+ DiskSelectionMode, PreparedInstallationConfig, PREPARED_INSTALL_CONFIG_ID_SCHEMA,
+};
+use proxmox_installer_types::{
+ answer::{
+ BtrfsCompressOption, BtrfsOptions, FilesystemOptions, FilesystemType, FilterMatch,
+ KeyboardLayout, LvmOptions, RebootMode, ZfsChecksumOption, ZfsCompressOption, ZfsOptions,
+ BTRFS_COMPRESS_OPTIONS, FILESYSTEM_TYPE_OPTIONS, ROOT_PASSWORD_SCHEMA,
+ ZFS_CHECKSUM_OPTIONS, ZFS_COMPRESS_OPTIONS,
+ },
+ EMAIL_DEFAULT_PLACEHOLDER,
+};
+use proxmox_schema::api_types::{CIDR_SCHEMA, IP_SCHEMA};
+use proxmox_yew_comp::SchemaValidation;
+use pwt::{
+ css::{Flex, FlexFit, Overflow},
+ prelude::*,
+ widget::{
+ form::{Checkbox, Combobox, DisplayField, Field, FormContext, InputType, Number, TextArea},
+ Container, Fa, FieldPosition, InputPanel, KeyValueList,
+ },
+};
+
+pub fn prepare_form_data(mut value: serde_json::Value) -> Result<serde_json::Value> {
+ let obj = value
+ .as_object_mut()
+ .ok_or_else(|| anyhow!("form data must always be an object"))?;
+
+ let fs_opts = collect_fs_options(obj);
+ let disk_list: Vec<String> = obj
+ .remove("disk-list")
+ .and_then(|s| {
+ s.as_str()
+ .map(|s| s.split(',').map(|s| s.trim().to_owned()).collect())
+ })
+ .unwrap_or_default();
+
+ let root_ssh_keys = collect_lines_into_array(obj.remove("root-ssh-keys"));
+
+ value["filesystem"] = json!(fs_opts);
+ value["disk-list"] = json!(disk_list);
+ value["root-ssh-keys"] = root_ssh_keys;
+ Ok(value)
+}
+
+fn collect_fs_options(obj: &mut serde_json::Map<String, Value>) -> FilesystemOptions {
+ let fs_type = obj
+ .get("filesystem-type")
+ .and_then(|s| s.as_str())
+ .and_then(|s| s.parse::<FilesystemType>().ok())
+ .unwrap_or_default();
+
+ let lvm_options = LvmOptions {
+ hdsize: obj.remove("hdsize").and_then(|v| v.as_f64()),
+ swapsize: obj.remove("swapsize").and_then(|v| v.as_f64()),
+ maxroot: obj.remove("maxroot").and_then(|v| v.as_f64()),
+ maxvz: obj.remove("maxvz").and_then(|v| v.as_f64()),
+ minfree: obj.remove("minfree").and_then(|v| v.as_f64()),
+ };
+
+ match fs_type {
+ FilesystemType::Ext4 => FilesystemOptions::Ext4(lvm_options),
+ FilesystemType::Xfs => FilesystemOptions::Xfs(lvm_options),
+ FilesystemType::Zfs(level) => FilesystemOptions::Zfs(ZfsOptions {
+ raid: Some(level),
+ ashift: obj
+ .remove("ashift")
+ .and_then(|v| v.as_u64())
+ .map(|v| v as u32),
+ arc_max: obj
+ .remove("ashift")
+ .and_then(|v| v.as_u64())
+ .map(|v| v as u32),
+ checksum: obj
+ .remove("checksum")
+ .and_then(|v| v.as_str().map(ToOwned::to_owned))
+ .and_then(|s| s.parse::<ZfsChecksumOption>().ok()),
+ compress: obj
+ .remove("checksum")
+ .and_then(|v| v.as_str().map(ToOwned::to_owned))
+ .and_then(|s| s.parse::<ZfsCompressOption>().ok()),
+ copies: obj
+ .remove("copies")
+ .and_then(|v| v.as_u64())
+ .map(|v| v as u32),
+ hdsize: obj.remove("hdsize").and_then(|v| v.as_f64()),
+ }),
+ FilesystemType::Btrfs(level) => FilesystemOptions::Btrfs(BtrfsOptions {
+ raid: Some(level),
+ compress: obj
+ .remove("checksum")
+ .and_then(|v| v.as_str().map(ToOwned::to_owned))
+ .and_then(|s| s.parse::<BtrfsCompressOption>().ok()),
+ hdsize: obj.remove("hdsize").and_then(|v| v.as_f64()),
+ }),
+ }
+}
+
+fn collect_lines_into_array(value: Option<Value>) -> Value {
+ value
+ .and_then(|v| v.as_str().map(|s| s.to_owned()))
+ .map(|s| {
+ json!(s
+ .split('\n')
+ .filter(|s| !s.is_empty())
+ .collect::<Vec<&str>>())
+ })
+ .unwrap_or(Value::Null)
+}
+
+pub fn render_global_options_form(
+ config: &PreparedInstallationConfig,
+ is_create: bool,
+) -> yew::Html {
+ let mut panel = InputPanel::new()
+ .class(Flex::Fill)
+ .class(Overflow::Auto)
+ .padding(4);
+
+ if is_create {
+ panel.add_field(
+ tr!("Installation ID"),
+ Field::new()
+ .name("id")
+ .value(config.id.clone())
+ .schema(&PREPARED_INSTALL_CONFIG_ID_SCHEMA)
+ .required(true),
+ );
+ } else {
+ panel.add_field(
+ tr!("Installation ID"),
+ DisplayField::new().value(config.id.clone()),
+ );
+ }
+
+ panel
+ .with_field(
+ tr!("Country"),
+ Combobox::new()
+ .name("country")
+ .placeholder(tr!("Two-letter country code, e.g. at"))
+ .items(Rc::new(
+ COUNTRY_INFO
+ .deref()
+ .keys()
+ .map(|s| s.as_str().into())
+ .collect(),
+ ))
+ .render_value(|v: &AttrValue| {
+ if let Some(s) = COUNTRY_INFO.deref().get(&v.to_string()) {
+ s.into()
+ } else {
+ v.into()
+ }
+ })
+ .value(config.country.clone())
+ .required(true),
+ )
+ .with_field(
+ tr!("Timezone"),
+ Field::new()
+ .name("timezone")
+ .value(config.timezone.clone())
+ .placeholder(tr!("Timezone name, e.g. Europe/Vienna"))
+ .required(true),
+ )
+ .with_field(
+ tr!("Root password"),
+ Field::new()
+ .name("root-password")
+ .input_type(InputType::Password)
+ .schema(&ROOT_PASSWORD_SCHEMA)
+ .placeholder((!is_create).then(|| tr!("Keep current")))
+ .required(is_create),
+ )
+ .with_field(
+ tr!("Keyboard Layout"),
+ Combobox::new()
+ .name("keyboard")
+ .items(Rc::new(
+ KEYBOARD_LAYOUTS
+ .iter()
+ .map(|l| serde_variant_name(l).expect("valid variant").into())
+ .collect(),
+ ))
+ .render_value(|v: &AttrValue| {
+ v.parse::<KeyboardLayout>()
+ .map(|v| v.human_name().to_owned())
+ .unwrap_or_default()
+ .into()
+ })
+ .value(serde_variant_name(config.keyboard))
+ .required(true),
+ )
+ .with_field(
+ tr!("Administrator email address"),
+ Field::new()
+ .name("mailto")
+ .placeholder(EMAIL_DEFAULT_PLACEHOLDER.to_owned())
+ .input_type(InputType::Email)
+ .value(config.mailto.clone())
+ .validate(|s: &String| {
+ if s.ends_with(".invalid") {
+ bail!(tr!("Invalid (default) email address"))
+ } else {
+ Ok(())
+ }
+ })
+ .required(true),
+ )
+ .with_field(
+ tr!("Root SSH public keys"),
+ TextArea::new()
+ .name("root-ssh-keys")
+ .class("pwt-w-100")
+ .submit_empty(false)
+ .attribute("rows", "3")
+ .placeholder(tr!("One per line, usually begins with \"ssh-\", \"sk-ssh-\", \"ecdsa-\" or \"sk-ecdsa\""))
+ .value(config.root_ssh_keys.join("\n")),
+ )
+ .with_field(
+ tr!("Reboot on error"),
+ Checkbox::new().name("reboot-on-error"),
+ )
+ .with_field(
+ tr!("Post-Installation action"),
+ Combobox::new()
+ .name("reboot-mode")
+ .items(Rc::new(
+ [RebootMode::Reboot, RebootMode::PowerOff]
+ .iter()
+ .map(|opt| serde_variant_name(opt).expect("valid variant").into())
+ .collect(),
+ ))
+ .render_value(|v: &AttrValue| match v.parse::<RebootMode>() {
+ Ok(RebootMode::Reboot) => tr!("Reboot").into(),
+ Ok(RebootMode::PowerOff) => tr!("Power off").into(),
+ _ => v.into(),
+ })
+ .value(serde_variant_name(config.reboot_mode))
+ .required(true),
+ )
+ .into()
+}
+
+pub fn render_network_options_form(
+ form_ctx: &FormContext,
+ config: &PreparedInstallationConfig,
+) -> yew::Html {
+ let use_dhcp_network = form_ctx
+ .read()
+ .get_field_value("use-dhcp-network")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(true);
+
+ let use_dhcp_fqdn = form_ctx
+ .read()
+ .get_field_value("use-dhcp-fqdn")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(true);
+
+ InputPanel::new()
+ .class(Flex::Fill)
+ .class(Overflow::Auto)
+ .padding(4)
+ .show_advanced(form_ctx.get_show_advanced())
+ .with_field(
+ tr!("Use DHCP"),
+ Checkbox::new().name("use-dhcp-network").default(true),
+ )
+ .with_field(
+ tr!("IP address (CIDR)"),
+ Field::new()
+ .name("cidr")
+ .placeholder(tr!("E.g. 192.168.0.100/24"))
+ .schema(&CIDR_SCHEMA)
+ .disabled(use_dhcp_network)
+ .required(!use_dhcp_network),
+ )
+ .with_field(
+ tr!("Gateway address"),
+ Field::new()
+ .name("gateway")
+ .placeholder(tr!("E.g. 192.168.0.1"))
+ .schema(&IP_SCHEMA)
+ .disabled(use_dhcp_network)
+ .required(!use_dhcp_network),
+ )
+ .with_field(
+ tr!("DNS server address"),
+ Field::new()
+ .name("dns")
+ .placeholder(tr!("E.g. 192.168.0.254"))
+ .schema(&IP_SCHEMA)
+ .disabled(use_dhcp_network)
+ .required(!use_dhcp_network),
+ )
+ .with_right_field(
+ tr!("FQDN from DHCP"),
+ Checkbox::new().name("use-dhcp-fqdn").default(false),
+ )
+ .with_right_field(
+ tr!("Fully-qualified domain name (FQDN)"),
+ Field::new()
+ .name("fqdn")
+ .placeholder("{{product.product}}{{installation-nr}}.example.com")
+ .value(config.fqdn.to_string())
+ .disabled(use_dhcp_fqdn)
+ .tip(tr!(
+ "Hostname and domain to set for the target installation. Allows templating."
+ ))
+ .required(!use_dhcp_fqdn),
+ )
+ .with_right_field("", DisplayField::new())
+ .with_right_field(
+ tr!("Pin network interfaces"),
+ Checkbox::new()
+ .name("netif-name-pinning-enabled")
+ .default(config.netif_name_pinning_enabled),
+ )
+ .with_advanced_spacer()
+ .with_large_advanced_field(
+ tr!("Network device filters"),
+ KeyValueList::new()
+ .value(
+ config
+ .netdev_filter
+ .iter()
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect(),
+ )
+ .key_label(tr!("Property name"))
+ .value_label(tr!("Value to match"))
+ .key_placeholder(tr!("udev property name"))
+ .value_placeholder(tr!("glob to match"))
+ .submit_validate(kv_list_to_map)
+ .submit_empty(false)
+ .name("netdev-filter")
+ .class(FlexFit)
+ .disabled(use_dhcp_fqdn),
+ )
+ .into()
+}
+
+pub fn render_disk_setup_form(
+ form_ctx: &FormContext,
+ config: &PreparedInstallationConfig,
+) -> yew::Html {
+ let disk_mode = form_ctx
+ .read()
+ .get_field_value("disk-mode")
+ .and_then(|v| v.as_str().and_then(|s| s.parse::<DiskSelectionMode>().ok()))
+ .unwrap_or_default();
+
+ let fs_type = form_ctx
+ .read()
+ .get_field_value("filesystem-type")
+ .and_then(|v| v.as_str().and_then(|s| s.parse::<FilesystemType>().ok()))
+ .unwrap_or_default();
+
+ let mut panel = InputPanel::new()
+ .class(Flex::Fill)
+ .class(Overflow::Auto)
+ .padding(4)
+ .show_advanced(form_ctx.get_show_advanced())
+ .with_field(
+ tr!("Filesystem"),
+ Combobox::new()
+ .name("filesystem-type")
+ .items(Rc::new(
+ FILESYSTEM_TYPE_OPTIONS
+ .iter()
+ .map(|opt| serde_variant_name(opt).expect("valid variant").into())
+ .collect(),
+ ))
+ .render_value(|v: &AttrValue| {
+ v.parse::<FilesystemType>()
+ .map(|v| v.to_string())
+ .unwrap_or_default()
+ .into()
+ })
+ .value(serde_variant_name(config.filesystem.to_type()))
+ .required(true)
+ .show_filter(false),
+ )
+ .with_right_field(
+ tr!("Disk selection mode"),
+ Combobox::new()
+ .name("disk-mode")
+ .with_item("fixed")
+ .with_item("filter")
+ .default("fixed")
+ .render_value(|v: &AttrValue| match v.parse::<DiskSelectionMode>() {
+ Ok(DiskSelectionMode::Fixed) => tr!("Fixed list of disk names").into(),
+ Ok(DiskSelectionMode::Filter) => tr!("Dynamically by udev filter").into(),
+ _ => v.into(),
+ })
+ .required(true)
+ .value(serde_variant_name(config.disk_mode)),
+ )
+ .with_field(
+ tr!("Disk names"),
+ Field::new()
+ .name("disk-list")
+ .placeholder("sda, sdb")
+ .value(config.disk_list.join(", "))
+ .disabled(disk_mode != DiskSelectionMode::Fixed)
+ .required(disk_mode == DiskSelectionMode::Fixed),
+ )
+ .with_spacer()
+ .with_field(
+ tr!("Disk udev filter mode"),
+ Combobox::new()
+ .name("disk-filter-match")
+ .items(Rc::new(
+ [FilterMatch::Any, FilterMatch::All]
+ .iter()
+ .map(|opt| serde_variant_name(opt).expect("valid variant").into())
+ .collect(),
+ ))
+ .render_value(|v: &AttrValue| match v.parse::<FilterMatch>() {
+ Ok(FilterMatch::Any) => tr!("Match any filter").into(),
+ Ok(FilterMatch::All) => tr!("Match all filters").into(),
+ _ => v.into(),
+ })
+ .default(serde_variant_name(FilterMatch::default()))
+ .value(config.disk_filter_match.and_then(serde_variant_name))
+ .disabled(disk_mode != DiskSelectionMode::Filter),
+ )
+ .with_large_field(
+ tr!("Disk udev filters"),
+ KeyValueList::new()
+ .value(
+ config
+ .disk_filter
+ .iter()
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect(),
+ )
+ .key_label(tr!("Property name"))
+ .value_label(tr!("Value to match"))
+ .key_placeholder(tr!("udev property name"))
+ .value_placeholder(tr!("glob to match"))
+ .submit_validate(kv_list_to_map)
+ .submit_empty(false)
+ .name("disk-filter")
+ .class(FlexFit)
+ .disabled(disk_mode != DiskSelectionMode::Filter),
+ );
+
+ let warning = match fs_type {
+ FilesystemType::Zfs(_) => Some(
+ tr!("ZFS is not compatible with hardware RAID controllers, for details see the documentation.")
+ ),
+ FilesystemType::Btrfs(_) => Some(tr!(
+ "Btrfs integration is a technology preview and only available for Proxmox Virtual Environment installations."
+ )),
+ _ => None,
+ };
+
+ if let Some(text) = warning {
+ panel.add_large_custom_child(
+ Container::from_tag("span")
+ .class("pwt-color-warning pwt-mt-2 pwt-d-block")
+ .with_child(Fa::new("exclamation-circle").class("fa-fw"))
+ .with_child(text),
+ );
+ }
+
+ panel.add_spacer(true);
+
+ add_fs_advanced_form_fields(&mut panel, &config.filesystem);
+ panel.into()
+}
+
+fn add_fs_advanced_form_fields(panel: &mut InputPanel, fs_opts: &FilesystemOptions) {
+ match fs_opts {
+ FilesystemOptions::Ext4(opts) | FilesystemOptions::Xfs(opts) => {
+ add_lvm_advanced_form_fields(panel, opts)
+ }
+ FilesystemOptions::Zfs(opts) => add_zfs_advanced_form_fields(panel, opts),
+ FilesystemOptions::Btrfs(opts) => add_btrfs_advanced_form_fields(panel, opts),
+ }
+}
+
+fn add_lvm_advanced_form_fields(panel: &mut InputPanel, fs_opts: &LvmOptions) {
+ panel.add_field_with_options(
+ FieldPosition::Left,
+ true,
+ false,
+ tr!("Harddisk size to use (GB)"),
+ Number::new()
+ .name("hdsize")
+ .min(4.)
+ .step(0.1)
+ .submit_empty(false)
+ .value(fs_opts.hdsize.map(|v| v.to_string())),
+ );
+
+ panel.add_field_with_options(
+ FieldPosition::Left,
+ true,
+ false,
+ tr!("Swap size (GB)"),
+ Number::new()
+ .name("swapsize")
+ .min(0.)
+ .max(fs_opts.hdsize.map(|v| v / 2.))
+ .step(0.1)
+ .submit_empty(false)
+ .value(fs_opts.swapsize.map(|v| v.to_string())),
+ );
+ panel.add_field_with_options(
+ FieldPosition::Right,
+ true,
+ false,
+ tr!("Maximum root volume size (GB)"),
+ Number::new()
+ .name("maxroot")
+ .min(0.)
+ .max(fs_opts.hdsize.map(|v| v / 2.))
+ .step(0.1)
+ .submit_empty(false)
+ .value(fs_opts.maxroot.map(|v| v.to_string())),
+ );
+ panel.add_field_with_options(
+ FieldPosition::Right,
+ true,
+ false,
+ tr!("Maximum data volume size (GB)"),
+ Number::new()
+ .name("maxvz")
+ .min(0.)
+ .max(fs_opts.hdsize.map(|v| v / 2.))
+ .step(0.1)
+ .submit_empty(false)
+ .value(fs_opts.maxvz.map(|v| v.to_string())),
+ );
+ panel.add_field_with_options(
+ FieldPosition::Right,
+ true,
+ false,
+ tr!("Minimum free space in LVM volume group (GB)"),
+ Number::new()
+ .name("minfree")
+ .min(0.)
+ .max(fs_opts.hdsize.map(|v| v / 2.))
+ .step(0.1)
+ .submit_empty(false)
+ .value(fs_opts.minfree.map(|v| v.to_string())),
+ );
+}
+
+fn add_zfs_advanced_form_fields(panel: &mut InputPanel, fs_opts: &ZfsOptions) {
+ panel.add_field_with_options(
+ FieldPosition::Left,
+ true,
+ false,
+ "ashift",
+ Number::<u64>::new()
+ .name("ashift")
+ .min(9)
+ .max(16)
+ .step(1)
+ .submit_empty(false)
+ .value(fs_opts.ashift.map(|v| v.to_string())),
+ );
+ panel.add_field_with_options(
+ FieldPosition::Left,
+ true,
+ false,
+ tr!("ARC maximum size (MiB)"),
+ Number::new()
+ .name("arc-max")
+ .min(64.)
+ .step(1.)
+ .submit_empty(false)
+ .value(fs_opts.arc_max.map(|v| v.to_string())),
+ );
+ panel.add_field_with_options(
+ FieldPosition::Right,
+ true,
+ false,
+ tr!("Checksumming algorithm"),
+ Combobox::new()
+ .name("checksum")
+ .items(Rc::new(
+ ZFS_CHECKSUM_OPTIONS
+ .iter()
+ .map(|opt| serde_variant_name(opt).expect("valid variant").into())
+ .collect(),
+ ))
+ .render_value(|v: &AttrValue| {
+ v.parse::<ZfsChecksumOption>()
+ .map(|v| v.to_string())
+ .unwrap_or_default()
+ .into()
+ })
+ .submit_empty(false)
+ .value(fs_opts.checksum.map(|v| v.to_string())),
+ );
+ panel.add_field_with_options(
+ FieldPosition::Right,
+ true,
+ false,
+ tr!("Compression algorithm"),
+ Combobox::new()
+ .name("compress")
+ .items(Rc::new(
+ ZFS_COMPRESS_OPTIONS
+ .iter()
+ .map(|opt| serde_variant_name(opt).expect("valid variant").into())
+ .collect(),
+ ))
+ .render_value(|v: &AttrValue| {
+ v.parse::<ZfsCompressOption>()
+ .map(|v| v.to_string())
+ .unwrap_or_default()
+ .into()
+ })
+ .submit_empty(false)
+ .value(fs_opts.compress.map(|v| v.to_string())),
+ );
+ panel.add_field_with_options(
+ FieldPosition::Right,
+ true,
+ false,
+ tr!("Copies"),
+ Number::<u32>::new()
+ .name("copies")
+ .min(1)
+ .max(3)
+ .step(1)
+ .submit_empty(false)
+ .value(fs_opts.copies.map(|v| v.to_string())),
+ );
+}
+
+fn add_btrfs_advanced_form_fields(panel: &mut InputPanel, fs_opts: &BtrfsOptions) {
+ panel.add_field_with_options(
+ FieldPosition::Right,
+ true,
+ false,
+ tr!("Compression algorithm"),
+ Combobox::new()
+ .name("compress")
+ .items(Rc::new(
+ BTRFS_COMPRESS_OPTIONS
+ .iter()
+ .map(|opt| serde_variant_name(opt).expect("valid variant").into())
+ .collect(),
+ ))
+ .render_value(|v: &AttrValue| {
+ v.parse::<BtrfsCompressOption>()
+ .map(|v| v.to_string())
+ .unwrap_or_default()
+ .into()
+ })
+ .submit_empty(false)
+ .value(fs_opts.compress.map(|v| v.to_string())),
+ );
+}
+
+pub fn render_target_filter_form(
+ form_ctx: &FormContext,
+ config: &PreparedInstallationConfig,
+) -> yew::Html {
+ let is_default = form_ctx
+ .read()
+ .get_field_value("is-default")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(config.is_default);
+
+ let mut panel = InputPanel::new()
+ .class(Flex::Fill)
+ .class(Overflow::Auto)
+ .padding(4);
+
+ if !is_default && config.target_filter.is_empty() {
+ panel.add_large_custom_child(
+ Container::from_tag("span")
+ .class("pwt-color-warning pwt-mb-2 pwt-d-block")
+ .with_child(Fa::new("exclamation-circle").class("fa-fw"))
+ .with_child(tr!(
+ "Not marked as default answer and target filter are empty, answer will never be matched."
+ ))
+ );
+ }
+
+ panel
+ .with_field(
+ tr!("Default answer"),
+ Checkbox::new()
+ .name("is-default")
+ .default(config.is_default),
+ )
+ .with_spacer()
+ .with_large_field(
+ tr!("Target filters"),
+ KeyValueList::new()
+ .value(
+ config
+ .target_filter
+ .iter()
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect(),
+ )
+ .key_label(tr!("JSON pointer"))
+ .value_label(tr!("Value to match"))
+ .key_placeholder("/json/pointer".into())
+ .value_placeholder(tr!("glob to match"))
+ .submit_validate(kv_list_to_map)
+ .submit_empty(false)
+ .name("target-filter")
+ .class(FlexFit)
+ .disabled(is_default),
+ )
+ .with_right_custom_child(Container::new().with_child(html! {
+
+ {tr!("references RFC 6901" => "Target filter keys are JSON pointers according to")}
+ {" "}
+ {"RFC 6901"}
+ {"."}
+
+ }))
+ .into()
+}
+
+pub fn render_templating_form(config: &PreparedInstallationConfig) -> yew::Html {
+ InputPanel::new()
+ .class(Flex::Fill)
+ .class(Overflow::Auto)
+ .padding(4)
+ .with_large_custom_child(
+ Container::from_tag("span")
+ .class("pwt-mb-2 pwt-d-block")
+ .with_child(tr!(
+ "Numerical template counters can be used to provide unique values across installations."
+ )),
+ )
+ .with_large_custom_child(
+ KeyValueList::new()
+ .value(
+ config
+ .template_counters
+ .iter()
+ .map(|(k, v)| (k.clone(), *v))
+ .collect(),
+ )
+ .value_label(tr!("Current value"))
+ .value_input_type(InputType::Number)
+ .submit_validate(kv_list_to_map)
+ .submit_empty(false)
+ .name("template-counters")
+ .class(FlexFit),
+ )
+ .with_right_custom_child(
+ Container::from_tag("span")
+ .class("pwt-mt-2 pwt-d-block")
+ .style("float", "right")
+ .with_child(tr!(
+ "Counters are automatically incremented each time an answer is served."
+ )),
+ )
+ .into()
+}
+
+pub fn render_auth_form(config: &PreparedInstallationConfig) -> yew::Html {
+ InputPanel::new()
+ .class(Flex::Fill)
+ .class(Overflow::Auto)
+ .padding(4)
+ .with_large_custom_child(
+ Container::from_tag("span")
+ .class("pwt-mb-2 pwt-mt-2 pwt-d-block pwt-color-primary")
+ .with_child(Fa::new("info-circle").class("fa-fw"))
+ .with_child(tr!(
+ "Optional. If provided, status reporting will be enabled."
+ )),
+ )
+ .with_large_field(
+ tr!("Proxmox Datacenter Manager base URL"),
+ Field::new()
+ .name("post-hook-base-url")
+ .tip(tr!(
+ "Base URL this PDM instance is reachable from the target host"
+ ))
+ .value(config.post_hook_base_url.clone()),
+ )
+ .with_large_field(
+ tr!("SHA256 certificate fingerprint"),
+ Field::new()
+ .name("post-hook-cert-fp")
+ .tip(tr!("Optional certificate fingerprint"))
+ .value(config.post_hook_cert_fp.clone()),
+ )
+ .into()
+}
+
+#[allow(clippy::ptr_arg)]
+fn kv_list_to_map<T: Clone + Serialize>(v: &Vec<(String, T)>) -> Result<Value> {
+ let map: BTreeMap<String, T> = v.iter().cloned().collect();
+ Ok(serde_json::to_value(map)?)
+}
+
+fn serde_variant_name<T: Serialize>(ty: T) -> Option<String> {
+ match serde_json::to_value(ty) {
+ Ok(Value::String(s)) => Some(s),
+ other => {
+ log::warn!(
+ "expected string of type {}, got {other:?}",
+ std::any::type_name::<T>()
+ );
+ None
+ }
+ }
+}
+
+const KEYBOARD_LAYOUTS: &[KeyboardLayout] = {
+ use KeyboardLayout::*;
+ &[
+ De, DeCh, Dk, EnGb, EnUs, Es, Fi, Fr, FrBe, FrCa, FrCh, Hu, Is, It, Jp, Lt, Mk, Nl, No, Pl,
+ Pt, PtBr, Se, Si, Tr,
+ ]
+};
+
+static COUNTRY_INFO: LazyLock<BTreeMap<String, String>> = LazyLock::new(|| {
+ #[derive(Deserialize)]
+ struct Iso3611CountryInfo {
+ alpha_2: String,
+ common_name: Option<String>,
+ name: String,
+ }
+
+ #[derive(Deserialize)]
+ struct Iso3611Info {
+ #[serde(rename = "3166-1")]
+ list: Vec<Iso3611CountryInfo>,
+ }
+
+ let raw: Iso3611Info =
+ serde_json::from_str(include_str!("/usr/share/iso-codes/json/iso_3166-1.json"))
+ .expect("valid country-info json");
+
+ raw.list
+ .into_iter()
+ .map(|c| (c.alpha_2.to_lowercase(), c.common_name.unwrap_or(c.name)))
+ .collect()
+});
diff --git a/ui/src/remotes/auto_installer/prepared_answers_panel.rs b/ui/src/remotes/auto_installer/prepared_answers_panel.rs
new file mode 100644
index 0000000..975cab9
--- /dev/null
+++ b/ui/src/remotes/auto_installer/prepared_answers_panel.rs
@@ -0,0 +1,248 @@
+//! Implements the UI for the auto-installer answer editing panel.
+
+use anyhow::Result;
+use core::clone::Clone;
+use std::{future::Future, pin::Pin, rc::Rc};
+use yew::{
+ html,
+ virtual_dom::{Key, VComp, VNode},
+ Properties,
+};
+
+use pdm_api_types::auto_installer::PreparedInstallationConfig;
+use proxmox_yew_comp::{
+ percent_encoding::percent_encode_component, ConfirmButton, LoadableComponent,
+ LoadableComponentContext, LoadableComponentMaster, LoadableComponentScopeExt,
+ LoadableComponentState,
+};
+use pwt::{
+ props::{ContainerBuilder, EventSubscriber, WidgetBuilder},
+ state::{Selection, Store},
+ tr,
+ widget::{
+ data_table::{DataTable, DataTableColumn, DataTableHeader},
+ Button, Fa, Toolbar,
+ },
+};
+
+use super::{
+ prepared_answer_add_wizard::AddAnswerWizardProperties,
+ prepared_answer_edit_window::EditAnswerWindowProperties,
+};
+use crate::pdm_client;
+
+#[derive(Default, PartialEq, Properties)]
+pub struct PreparedAnswersPanel {}
+
+impl From<PreparedAnswersPanel> for VNode {
+ fn from(value: PreparedAnswersPanel) -> Self {
+ let comp = VComp::new::<LoadableComponentMaster<PreparedAnswersPanelComponent>>(
+ Rc::new(value),
+ None,
+ );
+ VNode::from(comp)
+ }
+}
+
+#[derive(PartialEq)]
+enum ViewState {
+ Create,
+ Copy,
+ Edit,
+}
+
+#[derive(PartialEq)]
+enum Message {
+ SelectionChange,
+ RemoveEntry,
+}
+
+struct PreparedAnswersPanelComponent {
+ state: LoadableComponentState<ViewState>,
+ selection: Selection,
+ store: Store<PreparedInstallationConfig>,
+ columns: Rc<Vec<DataTableHeader<PreparedInstallationConfig>>>,
+}
+
+pwt::impl_deref_mut_property!(
+ PreparedAnswersPanelComponent,
+ state,
+ LoadableComponentState<ViewState>
+);
+
+impl LoadableComponent for PreparedAnswersPanelComponent {
+ type Properties = PreparedAnswersPanel;
+ type Message = Message;
+ type ViewState = ViewState;
+
+ fn create(ctx: &LoadableComponentContext<Self>) -> Self {
+ let store = Store::with_extract_key(|record: &PreparedInstallationConfig| {
+ Key::from(record.id.to_string())
+ });
+ store.set_sorter(
+ |a: &PreparedInstallationConfig, b: &PreparedInstallationConfig| a.id.cmp(&b.id),
+ );
+
+ Self {
+ state: LoadableComponentState::new(),
+ selection: Selection::new()
+ .on_select(ctx.link().callback(|_| Message::SelectionChange)),
+ store,
+ columns: Rc::new(columns()),
+ }
+ }
+
+ fn load(
+ &self,
+ _ctx: &LoadableComponentContext<Self>,
+ ) -> Pin<Box<dyn Future<Output = Result<()>>>> {
+ let store = self.store.clone();
+ Box::pin(async move {
+ let data = pdm_client().get_autoinst_prepared_answers().await?;
+ store.write().set_data(data);
+ Ok(())
+ })
+ }
+
+ fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Message) -> bool {
+ let link = ctx.link().clone();
+
+ match msg {
+ Message::SelectionChange => true,
+ Message::RemoveEntry => {
+ if let Some(key) = self.selection.selected_key() {
+ self.spawn(async move {
+ if let Err(err) = pdm_client()
+ .delete_autoinst_prepared_answer(&percent_encode_component(
+ &key.to_string(),
+ ))
+ .await
+ {
+ link.show_error(tr!("Unable to delete entry"), err, true);
+ }
+ link.send_reload();
+ })
+ }
+ false
+ }
+ }
+ }
+
+ fn toolbar(&self, ctx: &LoadableComponentContext<Self>) -> Option<yew::Html> {
+ let link = ctx.link().clone();
+
+ let toolbar = Toolbar::new()
+ .class("pwt-w-100")
+ .class(pwt::css::Overflow::Hidden)
+ .class("pwt-border-bottom")
+ .with_child(
+ Button::new(tr!("Add"))
+ .onclick(link.change_view_callback(|_| Some(ViewState::Create))),
+ )
+ .with_spacer()
+ .with_child(
+ Button::new(tr!("Copy"))
+ .onclick(link.change_view_callback(|_| Some(ViewState::Copy))),
+ )
+ .with_child(
+ Button::new(tr!("Edit"))
+ .disabled(self.selection.is_empty())
+ .onclick(link.change_view_callback(|_| Some(ViewState::Edit))),
+ )
+ .with_child(
+ ConfirmButton::new(tr!("Remove"))
+ .confirm_message(tr!("Are you sure you want to remove this entry?"))
+ .disabled(self.selection.is_empty())
+ .on_activate(link.callback(|_| Message::RemoveEntry)),
+ );
+
+ Some(toolbar.into())
+ }
+
+ fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> yew::Html {
+ let link = ctx.link().clone();
+
+ DataTable::new(self.columns.clone(), self.store.clone())
+ .class(pwt::css::FlexFit)
+ .selection(self.selection.clone())
+ .on_row_dblclick(move |_: &mut _| link.change_view(Some(Self::ViewState::Edit)))
+ .into()
+ }
+
+ fn dialog_view(
+ &self,
+ ctx: &LoadableComponentContext<Self>,
+ view_state: &Self::ViewState,
+ ) -> Option<yew::Html> {
+ let on_done = ctx.link().clone().change_view_callback(|_| None);
+
+ Some(match view_state {
+ Self::ViewState::Create => AddAnswerWizardProperties::new().on_done(on_done).into(),
+ Self::ViewState::Copy => {
+ let mut record = self
+ .store
+ .read()
+ .lookup_record(&self.selection.selected_key()?)?
+ .clone();
+
+ record.id += " (copy)";
+ AddAnswerWizardProperties::with(record)
+ .on_done(on_done)
+ .into()
+ }
+ Self::ViewState::Edit => {
+ let record = self
+ .store
+ .read()
+ .lookup_record(&self.selection.selected_key()?)?
+ .clone();
+
+ EditAnswerWindowProperties::new(record)
+ .on_done(on_done)
+ .into()
+ }
+ })
+ }
+}
+
+fn columns() -> Vec<DataTableHeader<PreparedInstallationConfig>> {
+ vec![
+ DataTableColumn::new(tr!("ID"))
+ .width("320px")
+ .render(|item: &PreparedInstallationConfig| html! { &item.id })
+ .sorter(
+ |a: &PreparedInstallationConfig, b: &PreparedInstallationConfig| a.id.cmp(&b.id),
+ )
+ .sort_order(Some(true))
+ .into(),
+ DataTableColumn::new(tr!("Default"))
+ .width("170px")
+ .render(|item: &PreparedInstallationConfig| {
+ if item.is_default {
+ Fa::new("check").into()
+ } else {
+ Fa::new("times").into()
+ }
+ })
+ .into(),
+ DataTableColumn::new(tr!("Target filter"))
+ .flex(1)
+ .render(|item: &PreparedInstallationConfig| {
+ if item.target_filter.is_empty() {
+ "-".into()
+ } else {
+ item.target_filter
+ .iter()
+ .fold(String::new(), |acc, (k, v)| {
+ if acc.is_empty() {
+ format!("{k}={v}")
+ } else {
+ format!("{acc}, {k}={v}")
+ }
+ })
+ .into()
+ }
+ })
+ .into(),
+ ]
+}
--
2.53.0