From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id A77631FF13C for ; Thu, 30 Apr 2026 14:50:15 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 8922B7A2B; Thu, 30 Apr 2026 14:50:15 +0200 (CEST) From: Christoph Heiss 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 Message-ID: <20260430124712.1614305-23-c.heiss@proxmox.com> X-Mailer: git-send-email 2.53.0 In-Reply-To: <20260430124712.1614305-1-c.heiss@proxmox.com> References: <20260430124712.1614305-1-c.heiss@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1777553256146 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.025 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment PROLO_LEO1 0.1 Meta Catches all Leo drug variations so far SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: Y42N2CZVPZVTGGNDGXZVW7UIR6T5DMM4 X-Message-ID-Hash: Y42N2CZVPZVTGGNDGXZVW7UIR6T5DMM4 X-MailFrom: c.heiss@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: 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 --- 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>, + + /// Dialog submit results callback. + #[builder_cb(IntoEventCallback, into_event_callback, (PreparedInstallationConfig, Option))] + #[prop_or_default] + pub on_submit_result: + Option)>>, + + /// 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 for VNode { + fn from(value: AddAnswerWizardProperties) -> Self { + let comp = + VComp::new::>(Rc::new(value), None); + VNode::from(comp) + } +} + +struct AddAnswerWizardComponent { + state: LoadableComponentState<()>, + token_store: Store, +} + +pwt::impl_deref_mut_property!(AddAnswerWizardComponent, state, LoadableComponentState<()>); + +impl LoadableComponent for AddAnswerWizardComponent { + type Properties = AddAnswerWizardProperties; + type Message = (); + type ViewState = (); + + fn create(_ctx: &LoadableComponentContext) -> 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, + ) -> Pin>>> { + 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) -> 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 { + 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 { + 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>, + + /// Dialog submit results callback. + #[builder_cb(IntoEventCallback, into_event_callback, (PreparedInstallationConfig, Option))] + #[prop_or_default] + pub on_submit_result: + Option)>>, + + /// Auto-installer answer configuration. + config: PreparedInstallationConfig, +} + +impl EditAnswerWindowProperties { + pub fn new(config: PreparedInstallationConfig) -> Self { + yew::props!(Self { config }) + } +} + +impl From for VNode { + fn from(value: EditAnswerWindowProperties) -> Self { + let comp = + VComp::new::>(Rc::new(value), None); + VNode::from(comp) + } +} + +struct EditAnswerWindowComponent { + state: LoadableComponentState<()>, + token_store: Store, +} + +pwt::impl_deref_mut_property!(EditAnswerWindowComponent, state, LoadableComponentState<()>); + +impl LoadableComponent for EditAnswerWindowComponent { + type Properties = EditAnswerWindowProperties; + type Message = (); + type ViewState = (); + + fn create(_ctx: &LoadableComponentContext) -> 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, + ) -> Pin>>> { + 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) -> 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 { + 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::>(); + + 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, +) -> 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 { + 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 = 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) -> FilesystemOptions { + let fs_type = obj + .get("filesystem-type") + .and_then(|s| s.as_str()) + .and_then(|s| s.parse::().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::().ok()), + compress: obj + .remove("checksum") + .and_then(|v| v.as_str().map(ToOwned::to_owned)) + .and_then(|s| s.parse::().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::().ok()), + hdsize: obj.remove("hdsize").and_then(|v| v.as_f64()), + }), + } +} + +fn collect_lines_into_array(value: Option) -> 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::>()) + }) + .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::() + .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::() { + 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::().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::().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::() + .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::() { + 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::() { + 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::::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::() + .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::() + .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::::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::() + .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 = 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! { + + {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("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, +) -> 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 { + 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), +) -> 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), +) -> yew::Html { + Number::::new() + .value(value.as_i64().unwrap_or_default().to_string()) + .disabled(props.disabled) + .on_change({ + let on_change = on_change.clone(); + move |v: Option>| { + 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 { + let mut map = BTreeMap::::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 { + let mut map = BTreeMap::::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(ty: T) -> Option { + 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::() + ); + None + } + } +} + +fn pdm_origin() -> Option { + 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> = LazyLock::new(|| { + #[derive(Deserialize)] + struct Iso3611CountryInfo { + alpha_2: String, + common_name: Option, + name: String, + } + + #[derive(Deserialize)] + struct Iso3611Info { + #[serde(rename = "3166-1")] + list: Vec, + } + + 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 for VNode { + fn from(value: PreparedAnswersPanel) -> Self { + let comp = VComp::new::>( + 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, + selection: Selection, + store: Store, + columns: Rc>>, +} + +pwt::impl_deref_mut_property!( + PreparedAnswersPanelComponent, + state, + LoadableComponentState +); + +impl LoadableComponent for PreparedAnswersPanelComponent { + type Properties = PreparedAnswersPanel; + type Message = Message; + type ViewState = ViewState; + + fn create(ctx: &LoadableComponentContext) -> 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, + ) -> Pin>>> { + 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, 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) -> Option { + 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) -> 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, + view_state: &Self::ViewState, + ) -> Option { + let link = ctx.link().clone(); + let on_submit_result = ctx.link().callback( + move |(config, new_token): ( + PreparedInstallationConfig, + Option, + )| { + 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> { + 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, @input)] +#[derive(Clone, PartialEq, Properties)] +#[builder] +pub struct TokenSelector { + /// All available tokens to select. + store: Store, + + #[builder] + #[prop_or_default] + /// Keys of entries to pre-select. + pub selected_keys: Vec, +} + +impl TokenSelector { + pub fn new(store: Store) -> Self { + yew::props!(Self { store }) + } +} + +pub struct TokenSelectorField { + state: ManagedFieldState, + store: Store, + selection: Selection, + columns: Rc>>, +} + +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 { + 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::>(); + 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 { + Ok(value.clone()) + } + + fn update(&mut self, ctx: &ManagedFieldContext, msg: Self::Message) -> bool { + match msg { + Self::Message::UpdateSelection => { + let mut selected = self + .selection + .selected_keys() + .iter() + .map(|k| k.to_string()) + .collect::>(); + selected.sort(); + ctx.link().update_value(selected); + true + } + } + } + + fn changed(&mut self, ctx: &ManagedFieldContext, 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::>(); + selected.sort(); + + ctx.link().update_default(selected); + } + + true + } + + fn view(&self, _ctx: &ManagedFieldContext) -> 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>> { + 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