From: Christoph Heiss <c.heiss@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [PATCH datacenter-manager v4 22/40] ui: auto-installer: add prepared answer configuration panel
Date: Thu, 30 Apr 2026 14:46:51 +0200 [thread overview]
Message-ID: <20260430124712.1614305-23-c.heiss@proxmox.com> (raw)
In-Reply-To: <20260430124712.1614305-1-c.heiss@proxmox.com>
Adds a pretty typical CRUD panel, allowing users to add/edit/remove
prepared answer file configurations for the auto-install server.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v3 -> v4:
* set key on all custom fields, as required by `InputPanel`
* add validation for udev filters and template counter keys
* display token secret if new one was automatically created
* add "Authorized tokens" column to panel view
* moved token selector here
* provide default PDM base url as placeholder only
* adapt to `AnswerAuthToken` -> `AnswerToken` rename
* renamed "Post Hook" to "Authentication" panel
* invoke `on_submit_result` callback after creating/updating an answer
* add email and disk-list schema on respective form fields
* move status reporting hint to bottom of panel
* allow empty answer token selection
Changes v2 -> v3:
* filters are now proper key-value `DataTable`s instead of plain text
areas
* added new "Templating" and "Authentication" tabs
* adapted as necessary to changed types from `proxmox-installer-types`
* use new `PdmClient` methods instead of manual post/put
* removed automatic `/api2` suffix from pdm base url
* set _target="blank" for RFC 6901 link
Changes v1 -> v2:
* new patch
Changes v3 -> v4:
* use enum newtype struct for token_panel ViewState::DisplaySecret
* AnswerAuthToken -> AnswerToken rename
* move show_secret_dialog render to common module
* fix form dirty check in TokenSelector
Changes v2 -> v3:
* new patch
ui/src/remotes/auto_installer/mod.rs | 20 +
.../prepared_answer_add_wizard.rs | 219 ++++
.../prepared_answer_edit_window.rs | 217 ++++
.../auto_installer/prepared_answer_form.rs | 1089 +++++++++++++++++
.../auto_installer/prepared_answers_panel.rs | 311 +++++
.../remotes/auto_installer/token_selector.rs | 159 +++
6 files changed, 2015 insertions(+)
create mode 100644 ui/src/remotes/auto_installer/prepared_answer_add_wizard.rs
create mode 100644 ui/src/remotes/auto_installer/prepared_answer_edit_window.rs
create mode 100644 ui/src/remotes/auto_installer/prepared_answer_form.rs
create mode 100644 ui/src/remotes/auto_installer/prepared_answers_panel.rs
create mode 100644 ui/src/remotes/auto_installer/token_selector.rs
diff --git a/ui/src/remotes/auto_installer/mod.rs b/ui/src/remotes/auto_installer/mod.rs
index 8155a9b..cd1f668 100644
--- a/ui/src/remotes/auto_installer/mod.rs
+++ b/ui/src/remotes/auto_installer/mod.rs
@@ -1,6 +1,11 @@
//! Implements the UI for the proxmox-auto-installer integration.
mod installations_panel;
+mod prepared_answer_add_wizard;
+mod prepared_answer_edit_window;
+mod prepared_answer_form;
+mod prepared_answers_panel;
+mod token_selector;
use std::rc::Rc;
use yew::virtual_dom::{VComp, VNode};
@@ -39,15 +44,30 @@ impl Component for AutoInstallerPanelComponent {
.with_child(tr!("Installations"))
.into();
+ let answers_title: Html = Row::new()
+ .gap(2)
+ .class(AlignItems::Baseline)
+ .with_child(Fa::new("files-o"))
+ .with_child(tr!("Prepared Answers"))
+ .into();
+
Container::new()
.class("pwt-content-spacer")
.class(Fit)
.class(css::Display::Grid)
+ .style("grid-template-columns", "repeat(2, 1fr)")
+ .style("grid-template-rows", "repeat(1, 1fr)")
.with_child(
Panel::new()
+ .style("grid-row", "span 2 / span 1")
.title(installations_title)
.with_child(installations_panel::InstallationsPanel::default()),
)
+ .with_child(
+ Panel::new()
+ .title(answers_title)
+ .with_child(prepared_answers_panel::PreparedAnswersPanel::default()),
+ )
.into()
}
}
diff --git a/ui/src/remotes/auto_installer/prepared_answer_add_wizard.rs b/ui/src/remotes/auto_installer/prepared_answer_add_wizard.rs
new file mode 100644
index 0000000..deb069a
--- /dev/null
+++ b/ui/src/remotes/auto_installer/prepared_answer_add_wizard.rs
@@ -0,0 +1,219 @@
+//! Implements the configuration dialog UI for the auto-installer integration.
+
+use anyhow::Result;
+use js_sys::Intl;
+use proxmox_installer_types::answer;
+use std::{collections::BTreeMap, future::Future, pin::Pin, rc::Rc};
+use wasm_bindgen::JsValue;
+use yew::{
+ html::IntoEventCallback,
+ virtual_dom::{Key, VComp, VNode},
+};
+
+use pdm_api_types::auto_installer::{
+ AnswerToken, AnswerTokenCreateResult, DiskSelectionMode, PreparedInstallationConfig,
+ PreparedInstallationConfigCreateResult,
+};
+use proxmox_yew_comp::{
+ LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
+ LoadableComponentScopeExt, LoadableComponentState, Wizard, WizardPageRenderInfo,
+};
+use pwt::{prelude::*, state::Store, widget::TabBarItem};
+use pwt_macros::builder;
+
+use super::prepared_answer_form::*;
+use crate::pdm_client;
+
+#[derive(Clone, PartialEq, Properties)]
+#[builder]
+pub struct AddAnswerWizardProperties {
+ /// Dialog close callback.
+ #[builder_cb(IntoEventCallback, into_event_callback, ())]
+ #[prop_or_default]
+ pub on_close: Option<Callback<()>>,
+
+ /// Dialog submit results callback.
+ #[builder_cb(IntoEventCallback, into_event_callback, (PreparedInstallationConfig, Option<AnswerTokenCreateResult>))]
+ #[prop_or_default]
+ pub on_submit_result:
+ Option<Callback<(PreparedInstallationConfig, Option<AnswerTokenCreateResult>)>>,
+
+ /// Auto-installer answer configuration.
+ config: PreparedInstallationConfig,
+}
+
+impl AddAnswerWizardProperties {
+ pub fn new() -> Self {
+ let mut template_counters = BTreeMap::new();
+ template_counters.insert("installation_nr".to_owned(), 0i32);
+
+ let config = PreparedInstallationConfig {
+ id: String::new(),
+ authorized_tokens: Vec::new(),
+ // target filter
+ is_default: false,
+ target_filter: BTreeMap::new(),
+ // global options
+ country: "at".to_owned(),
+ fqdn: "host.example.com".to_owned(),
+ use_dhcp_fqdn: false,
+ keyboard: answer::KeyboardLayout::default(),
+ mailto: String::new(),
+ timezone: js_timezone().unwrap_or_else(|| "Etc/UTC".to_owned()),
+ root_password_hashed: None,
+ reboot_on_error: false,
+ reboot_mode: answer::RebootMode::default(),
+ root_ssh_keys: Vec::new(),
+ // network options
+ use_dhcp_network: true,
+ cidr: None,
+ gateway: None,
+ dns: None,
+ netdev_filter: BTreeMap::new(),
+ netif_name_pinning_enabled: true,
+ // disk options
+ filesystem: answer::FilesystemOptions::Ext4(answer::LvmOptions::default()),
+ disk_mode: DiskSelectionMode::default(),
+ disk_list: Vec::new(),
+ disk_filter: BTreeMap::new(),
+ disk_filter_match: None,
+ // post hook
+ post_hook_base_url: None,
+ post_hook_cert_fp: None,
+ // templating
+ template_counters,
+ };
+
+ yew::props!(Self { config })
+ }
+
+ pub fn with(config: PreparedInstallationConfig) -> Self {
+ yew::props!(Self { config })
+ }
+}
+
+impl From<AddAnswerWizardProperties> for VNode {
+ fn from(value: AddAnswerWizardProperties) -> Self {
+ let comp =
+ VComp::new::<LoadableComponentMaster<AddAnswerWizardComponent>>(Rc::new(value), None);
+ VNode::from(comp)
+ }
+}
+
+struct AddAnswerWizardComponent {
+ state: LoadableComponentState<()>,
+ token_store: Store<AnswerToken>,
+}
+
+pwt::impl_deref_mut_property!(AddAnswerWizardComponent, state, LoadableComponentState<()>);
+
+impl LoadableComponent for AddAnswerWizardComponent {
+ type Properties = AddAnswerWizardProperties;
+ type Message = ();
+ type ViewState = ();
+
+ fn create(_ctx: &LoadableComponentContext<Self>) -> Self {
+ let store = Store::with_extract_key(|record: &AnswerToken| Key::from(record.id.to_owned()));
+ store.set_sorter(|a: &AnswerToken, b: &AnswerToken| a.id.cmp(&b.id));
+
+ Self {
+ state: LoadableComponentState::new(),
+ token_store: store,
+ }
+ }
+
+ fn load(
+ &self,
+ _ctx: &LoadableComponentContext<Self>,
+ ) -> Pin<Box<dyn Future<Output = Result<()>>>> {
+ let store = self.token_store.clone();
+ Box::pin(async move {
+ let data = pdm_client()
+ .get_autoinst_tokens()
+ .await?
+ .into_iter()
+ .collect();
+
+ store.write().set_data(data);
+ Ok(())
+ })
+ }
+
+ fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
+ let props = ctx.props();
+ let link = ctx.link().clone();
+
+ Wizard::new(tr!("Add Prepared Answer"))
+ .width(900)
+ .resizable(true)
+ .on_submit({
+ let on_submit_result = props.on_submit_result.clone();
+ move |config: serde_json::Value| {
+ let link = link.clone();
+ let on_submit_result = on_submit_result.clone();
+ async move {
+ match submit(config).await {
+ Ok(PreparedInstallationConfigCreateResult { config, token }) => {
+ if let Some(on_submit_result) = on_submit_result {
+ on_submit_result.emit((config, token));
+ }
+ }
+ Err(err) => link.show_error(
+ tr!("Failed to create installation configuration"),
+ err,
+ true,
+ ),
+ }
+ Ok(())
+ }
+ }
+ })
+ .on_close(props.on_close.clone())
+ .with_page(TabBarItem::new().label(tr!("Global options")), {
+ let config = props.config.clone();
+ move |_: &WizardPageRenderInfo| render_global_options_form(&config, true)
+ })
+ .with_page(TabBarItem::new().label(tr!("Network options")), {
+ let config = props.config.clone();
+ move |p: &WizardPageRenderInfo| render_network_options_form(&p.form_ctx, &config)
+ })
+ .with_page(TabBarItem::new().label(tr!("Disk Setup")), {
+ let config = props.config.clone();
+ move |p: &WizardPageRenderInfo| render_disk_setup_form(&p.form_ctx, &config)
+ })
+ .with_page(TabBarItem::new().label(tr!("Target filter")), {
+ let config = props.config.clone();
+ move |p: &WizardPageRenderInfo| render_target_filter_form(&p.form_ctx, &config)
+ })
+ .with_page(TabBarItem::new().label(tr!("Templating")), {
+ let config = props.config.clone();
+ move |_: &WizardPageRenderInfo| render_templating_form(&config)
+ })
+ .with_page(TabBarItem::new().label(tr!("Post Hook")), {
+ let config = props.config.clone();
+ let secrets = self.token_store.clone();
+ move |p: &WizardPageRenderInfo| {
+ render_auth_form(&p.form_ctx, &config, secrets.clone())
+ }
+ })
+ .into()
+ }
+}
+
+impl AddAnswerWizardComponent {}
+
+async fn submit(form_data: serde_json::Value) -> Result<PreparedInstallationConfigCreateResult> {
+ let data = prepare_form_data(form_data)?;
+ let root_password = data["root-password"].as_str().map(ToOwned::to_owned);
+
+ Ok(pdm_client()
+ .add_autoinst_prepared_answer(&serde_json::from_value(data)?, root_password.as_deref())
+ .await?)
+}
+
+fn js_timezone() -> Option<String> {
+ let datetime_options = Intl::DateTimeFormat::default().resolved_options();
+ js_sys::Reflect::get(&datetime_options, &JsValue::from_str("timeZone"))
+ .ok()
+ .and_then(|v| v.as_string())
+}
diff --git a/ui/src/remotes/auto_installer/prepared_answer_edit_window.rs b/ui/src/remotes/auto_installer/prepared_answer_edit_window.rs
new file mode 100644
index 0000000..4bdbab1
--- /dev/null
+++ b/ui/src/remotes/auto_installer/prepared_answer_edit_window.rs
@@ -0,0 +1,217 @@
+//! Implements the configuration dialog UI for the auto-installer integration.
+
+use anyhow::Result;
+use std::{future::Future, pin::Pin, rc::Rc};
+use yew::{
+ html::IntoEventCallback,
+ virtual_dom::{Key, VComp, VNode},
+};
+
+use crate::pdm_client;
+use pdm_api_types::auto_installer::{
+ AnswerToken, AnswerTokenCreateResult, DeletablePreparedInstallationConfigProperty,
+ PreparedInstallationConfig, PreparedInstallationConfigUpdateResult,
+};
+use proxmox_yew_comp::{
+ form::delete_empty_values, percent_encoding::percent_encode_component, EditWindow,
+ LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
+ LoadableComponentScopeExt, LoadableComponentState,
+};
+use pwt::{
+ css::FlexFit,
+ prelude::*,
+ state::Store,
+ widget::{form::FormContext, TabBarItem, TabPanel},
+};
+use pwt_macros::builder;
+
+use super::prepared_answer_form::*;
+
+#[derive(Clone, PartialEq, Properties)]
+#[builder]
+pub struct EditAnswerWindowProperties {
+ /// Dialog close callback.
+ #[builder_cb(IntoEventCallback, into_event_callback, ())]
+ #[prop_or_default]
+ pub on_close: Option<Callback<()>>,
+
+ /// Dialog submit results callback.
+ #[builder_cb(IntoEventCallback, into_event_callback, (PreparedInstallationConfig, Option<AnswerTokenCreateResult>))]
+ #[prop_or_default]
+ pub on_submit_result:
+ Option<Callback<(PreparedInstallationConfig, Option<AnswerTokenCreateResult>)>>,
+
+ /// Auto-installer answer configuration.
+ config: PreparedInstallationConfig,
+}
+
+impl EditAnswerWindowProperties {
+ pub fn new(config: PreparedInstallationConfig) -> Self {
+ yew::props!(Self { config })
+ }
+}
+
+impl From<EditAnswerWindowProperties> for VNode {
+ fn from(value: EditAnswerWindowProperties) -> Self {
+ let comp =
+ VComp::new::<LoadableComponentMaster<EditAnswerWindowComponent>>(Rc::new(value), None);
+ VNode::from(comp)
+ }
+}
+
+struct EditAnswerWindowComponent {
+ state: LoadableComponentState<()>,
+ token_store: Store<AnswerToken>,
+}
+
+pwt::impl_deref_mut_property!(EditAnswerWindowComponent, state, LoadableComponentState<()>);
+
+impl LoadableComponent for EditAnswerWindowComponent {
+ type Properties = EditAnswerWindowProperties;
+ type Message = ();
+ type ViewState = ();
+
+ fn create(_ctx: &LoadableComponentContext<Self>) -> Self {
+ let token_store =
+ Store::with_extract_key(|record: &AnswerToken| Key::from(record.id.to_owned()));
+ token_store.set_sorter(|a: &AnswerToken, b: &AnswerToken| a.id.cmp(&b.id));
+
+ Self {
+ state: LoadableComponentState::new(),
+ token_store,
+ }
+ }
+
+ fn load(
+ &self,
+ _ctx: &LoadableComponentContext<Self>,
+ ) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
+ let store = self.token_store.clone();
+ Box::pin(async move {
+ let data = pdm_client()
+ .get_autoinst_tokens()
+ .await?
+ .into_iter()
+ .collect();
+
+ store.write().set_data(data);
+ Ok(())
+ })
+ }
+
+ fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
+ let props = ctx.props();
+ let link = ctx.link().clone();
+
+ EditWindow::new(tr!("Edit Prepared Answer"))
+ .width(900)
+ .resizable(true)
+ .renderer({
+ let props = props.clone();
+ let token_store = self.token_store.clone();
+ move |form_ctx: &FormContext| render_tabpanel(form_ctx, &props, token_store.clone())
+ })
+ .edit(true)
+ .submit_digest(true)
+ .on_submit({
+ let id = props.config.id.clone();
+ let on_submit_result = props.on_submit_result.clone();
+ move |form_ctx: FormContext| {
+ let id = id.clone();
+ let link = link.clone();
+ let on_submit_result = on_submit_result.clone();
+ let config = form_ctx.get_submit_data();
+
+ async move {
+ match submit(&percent_encode_component(&id), config).await {
+ Ok(PreparedInstallationConfigUpdateResult { config, token }) => {
+ if let Some(on_submit_result) = on_submit_result {
+ on_submit_result.emit((config, token));
+ }
+ }
+ Err(err) => link.show_error(
+ tr!("Failed to update installation configuration"),
+ err,
+ true,
+ ),
+ }
+ Ok(())
+ }
+ }
+ })
+ .on_close(props.on_close.clone())
+ .advanced_checkbox(true)
+ .into()
+ }
+}
+
+async fn submit(
+ id: &str,
+ form_data: serde_json::Value,
+) -> Result<PreparedInstallationConfigUpdateResult> {
+ let data = delete_empty_values(
+ &prepare_form_data(form_data)?,
+ &[
+ "root-ssh-keys",
+ "post-hook-base-url",
+ "post-hook-cert-fp",
+ "disk-filter",
+ "netdev-filter",
+ "target-filter",
+ ],
+ true,
+ );
+
+ let root_password = data["root-password"].as_str().map(ToOwned::to_owned);
+ let delete = data["delete"]
+ .as_array()
+ .cloned()
+ .unwrap_or_default()
+ .iter()
+ .flat_map(|s| s.as_str().and_then(|s| s.parse().ok()))
+ .collect::<Vec<DeletablePreparedInstallationConfigProperty>>();
+
+ Ok(pdm_client()
+ .update_autoinst_prepared_answer(
+ id,
+ &serde_json::from_value(data)?,
+ root_password.as_deref(),
+ &delete,
+ )
+ .await?)
+}
+
+fn render_tabpanel(
+ form_ctx: &FormContext,
+ props: &EditAnswerWindowProperties,
+ tokens: Store<AnswerToken>,
+) -> yew::Html {
+ TabPanel::new()
+ .class(FlexFit)
+ .force_render_all(true)
+ .with_item(
+ TabBarItem::new().label(tr!("Global options")),
+ render_global_options_form(&props.config, false),
+ )
+ .with_item(
+ TabBarItem::new().label(tr!("Network options")),
+ render_network_options_form(form_ctx, &props.config),
+ )
+ .with_item(
+ TabBarItem::new().label(tr!("Disk Setup")),
+ render_disk_setup_form(form_ctx, &props.config),
+ )
+ .with_item(
+ TabBarItem::new().label(tr!("Target filter")),
+ render_target_filter_form(form_ctx, &props.config),
+ )
+ .with_item(
+ TabBarItem::new().label(tr!("Templating")),
+ render_templating_form(&props.config),
+ )
+ .with_item(
+ TabBarItem::new().label(tr!("Authentication")),
+ render_auth_form(form_ctx, &props.config, tokens),
+ )
+ .into()
+}
diff --git a/ui/src/remotes/auto_installer/prepared_answer_form.rs b/ui/src/remotes/auto_installer/prepared_answer_form.rs
new file mode 100644
index 0000000..d6120d0
--- /dev/null
+++ b/ui/src/remotes/auto_installer/prepared_answer_form.rs
@@ -0,0 +1,1089 @@
+//! Provides all shared components for the prepared answer create wizard and the corresponding
+//! edit window, as well as some utility to collect and prepare the form data for submission.
+
+use anyhow::{anyhow, bail, Result};
+use serde::{Deserialize, Serialize};
+use serde_json::{json, Value};
+use std::{collections::BTreeMap, ops::Deref, rc::Rc, sync::LazyLock};
+
+use pdm_api_types::{
+ auto_installer::{
+ AnswerToken, DiskSelectionMode, PreparedInstallationConfig,
+ PREPARED_INSTALL_CONFIG_ID_SCHEMA, TEMPLATE_COUNTER_NAME_REGEX, UDEV_FILTER_KEY_REGEX,
+ },
+ DISK_LIST_SCHEMA, EMAIL_SCHEMA,
+};
+use proxmox_installer_types::{
+ answer::{
+ BtrfsCompressOption, BtrfsOptions, FilesystemOptions, FilesystemType, FilterMatch,
+ KeyboardLayout, LvmOptions, RebootMode, ZfsChecksumOption, ZfsCompressOption, ZfsOptions,
+ BTRFS_COMPRESS_OPTIONS, FILESYSTEM_TYPE_OPTIONS, ROOT_PASSWORD_SCHEMA,
+ ZFS_CHECKSUM_OPTIONS, ZFS_COMPRESS_OPTIONS,
+ },
+ EMAIL_DEFAULT_PLACEHOLDER,
+};
+use proxmox_schema::api_types::{CIDR_SCHEMA, IP_SCHEMA};
+use proxmox_yew_comp::{utils::copy_text_to_clipboard, KeyValueList, SchemaValidation};
+use pwt::{
+ css::{ColorScheme, Flex, FlexFit, Overflow},
+ prelude::*,
+ props::FieldStdProps,
+ state::Store,
+ widget::{
+ form::{Checkbox, Combobox, DisplayField, Field, FormContext, InputType, Number, TextArea},
+ Button, Column, Container, Dialog, Fa, FieldLabel, FieldPosition, InputPanel, Row, Tooltip,
+ },
+};
+
+use crate::remotes::auto_installer::token_selector::TokenSelector;
+
+pub fn prepare_form_data(mut value: serde_json::Value) -> Result<serde_json::Value> {
+ let obj = value
+ .as_object_mut()
+ .ok_or_else(|| anyhow!("form data must always be an object"))?;
+
+ let fs_opts = collect_fs_options(obj);
+ let disk_list: Vec<String> = obj
+ .remove("disk-list")
+ .and_then(|s| {
+ s.as_str()
+ .map(|s| s.split(',').map(|s| s.trim().to_owned()).collect())
+ })
+ .unwrap_or_default();
+
+ let root_ssh_keys = collect_lines_into_array(obj.remove("root-ssh-keys"));
+
+ value["filesystem"] = json!(fs_opts);
+ value["disk-list"] = json!(disk_list);
+ value["root-ssh-keys"] = root_ssh_keys;
+
+ Ok(value)
+}
+
+fn collect_fs_options(obj: &mut serde_json::Map<String, Value>) -> FilesystemOptions {
+ let fs_type = obj
+ .get("filesystem-type")
+ .and_then(|s| s.as_str())
+ .and_then(|s| s.parse::<FilesystemType>().ok())
+ .unwrap_or_default();
+
+ let lvm_options = LvmOptions {
+ hdsize: obj.remove("hdsize").and_then(|v| v.as_f64()),
+ swapsize: obj.remove("swapsize").and_then(|v| v.as_f64()),
+ maxroot: obj.remove("maxroot").and_then(|v| v.as_f64()),
+ maxvz: obj.remove("maxvz").and_then(|v| v.as_f64()),
+ minfree: obj.remove("minfree").and_then(|v| v.as_f64()),
+ };
+
+ match fs_type {
+ FilesystemType::Ext4 => FilesystemOptions::Ext4(lvm_options),
+ FilesystemType::Xfs => FilesystemOptions::Xfs(lvm_options),
+ FilesystemType::Zfs(level) => FilesystemOptions::Zfs(ZfsOptions {
+ raid: Some(level),
+ ashift: obj
+ .remove("ashift")
+ .and_then(|v| v.as_u64())
+ .map(|v| v as u32),
+ arc_max: obj
+ .remove("ashift")
+ .and_then(|v| v.as_u64())
+ .map(|v| v as u32),
+ checksum: obj
+ .remove("checksum")
+ .and_then(|v| v.as_str().map(ToOwned::to_owned))
+ .and_then(|s| s.parse::<ZfsChecksumOption>().ok()),
+ compress: obj
+ .remove("checksum")
+ .and_then(|v| v.as_str().map(ToOwned::to_owned))
+ .and_then(|s| s.parse::<ZfsCompressOption>().ok()),
+ copies: obj
+ .remove("copies")
+ .and_then(|v| v.as_u64())
+ .map(|v| v as u32),
+ hdsize: obj.remove("hdsize").and_then(|v| v.as_f64()),
+ }),
+ FilesystemType::Btrfs(level) => FilesystemOptions::Btrfs(BtrfsOptions {
+ raid: Some(level),
+ compress: obj
+ .remove("checksum")
+ .and_then(|v| v.as_str().map(ToOwned::to_owned))
+ .and_then(|s| s.parse::<BtrfsCompressOption>().ok()),
+ hdsize: obj.remove("hdsize").and_then(|v| v.as_f64()),
+ }),
+ }
+}
+
+fn collect_lines_into_array(value: Option<Value>) -> Value {
+ value
+ .and_then(|v| v.as_str().map(|s| s.to_owned()))
+ .map(|s| {
+ json!(s
+ .split('\n')
+ .filter(|s| !s.is_empty())
+ .collect::<Vec<&str>>())
+ })
+ .unwrap_or(Value::Array(Vec::new()))
+}
+
+pub fn render_global_options_form(
+ config: &PreparedInstallationConfig,
+ is_create: bool,
+) -> yew::Html {
+ let mut panel = InputPanel::new()
+ .class(Flex::Fill)
+ .class(Overflow::Auto)
+ .padding(4);
+
+ if is_create {
+ panel.add_field(
+ tr!("Installation ID"),
+ Field::new()
+ .name("id")
+ .value(config.id.clone())
+ .schema(&PREPARED_INSTALL_CONFIG_ID_SCHEMA)
+ .required(true),
+ );
+ } else {
+ panel.add_field(
+ tr!("Installation ID"),
+ DisplayField::new().name("id").value(config.id.clone()),
+ );
+ }
+
+ panel
+ .with_field(
+ tr!("Country"),
+ Combobox::new()
+ .name("country")
+ .placeholder(tr!("Two-letter country code, e.g. at"))
+ .items(Rc::new(
+ COUNTRY_INFO
+ .deref()
+ .keys()
+ .map(|s| s.as_str().into())
+ .collect(),
+ ))
+ .render_value(|v: &AttrValue| {
+ if let Some(s) = COUNTRY_INFO.deref().get(&v.to_string()) {
+ s.into()
+ } else {
+ v.into()
+ }
+ })
+ .value(config.country.clone())
+ .required(true),
+ )
+ .with_field(
+ tr!("Timezone"),
+ Field::new()
+ .name("timezone")
+ .value(config.timezone.clone())
+ .placeholder(tr!("Timezone name, e.g. Europe/Vienna"))
+ .required(true),
+ )
+ .with_field(
+ tr!("Root password"),
+ Field::new()
+ .name("root-password")
+ .input_type(InputType::Password)
+ .schema(&ROOT_PASSWORD_SCHEMA)
+ .placeholder((!is_create).then(|| tr!("Keep current")))
+ .required(is_create),
+ )
+ .with_field(
+ tr!("Keyboard Layout"),
+ Combobox::new()
+ .name("keyboard")
+ .items(Rc::new(
+ KEYBOARD_LAYOUTS
+ .iter()
+ .map(|l| serde_variant_name(l).expect("valid variant").into())
+ .collect(),
+ ))
+ .render_value(|v: &AttrValue| {
+ v.parse::<KeyboardLayout>()
+ .map(|v| v.human_name().to_owned())
+ .unwrap_or_default()
+ .into()
+ })
+ .value(serde_variant_name(config.keyboard))
+ .required(true),
+ )
+ .with_field(
+ tr!("Administrator email address"),
+ Field::new()
+ .name("mailto")
+ .placeholder(EMAIL_DEFAULT_PLACEHOLDER.to_owned())
+ .input_type(InputType::Email)
+ .value(config.mailto.clone())
+ .schema(&EMAIL_SCHEMA)
+ .validate(|s: &String| {
+ if s.ends_with(".invalid") {
+ bail!(tr!("Invalid (default) email address"))
+ } else {
+ Ok(())
+ }
+ })
+ .required(true),
+ )
+ .with_field(
+ tr!("Root SSH public keys"),
+ TextArea::new()
+ .name("root-ssh-keys")
+ .class("pwt-w-100")
+ .submit_empty(false)
+ .attribute("rows", "3")
+ .placeholder(tr!("One per line, usually begins with \"ssh-\", \"sk-ssh-\", \"ecdsa-\" or \"sk-ecdsa\""))
+ .value(config.root_ssh_keys.join("\n")),
+ )
+ .with_field(
+ tr!("Reboot on error"),
+ Checkbox::new().name("reboot-on-error"),
+ )
+ .with_field(
+ tr!("Post-Installation action"),
+ Combobox::new()
+ .name("reboot-mode")
+ .items(Rc::new(
+ [RebootMode::Reboot, RebootMode::PowerOff]
+ .iter()
+ .map(|opt| serde_variant_name(opt).expect("valid variant").into())
+ .collect(),
+ ))
+ .render_value(|v: &AttrValue| match v.parse::<RebootMode>() {
+ Ok(RebootMode::Reboot) => tr!("Reboot").into(),
+ Ok(RebootMode::PowerOff) => tr!("Power off").into(),
+ _ => v.into(),
+ })
+ .value(serde_variant_name(config.reboot_mode))
+ .required(true),
+ )
+ .into()
+}
+
+pub fn render_network_options_form(
+ form_ctx: &FormContext,
+ config: &PreparedInstallationConfig,
+) -> yew::Html {
+ let use_dhcp_network = form_ctx
+ .read()
+ .get_field_value("use-dhcp-network")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(true);
+
+ let use_dhcp_fqdn = form_ctx
+ .read()
+ .get_field_value("use-dhcp-fqdn")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(true);
+
+ InputPanel::new()
+ .class(Flex::Fill)
+ .class(Overflow::Auto)
+ .padding(4)
+ .show_advanced(form_ctx.get_show_advanced())
+ .with_field(
+ tr!("Use DHCP"),
+ Checkbox::new().name("use-dhcp-network").default(true),
+ )
+ .with_field(
+ tr!("IP address (CIDR)"),
+ Field::new()
+ .name("cidr")
+ .placeholder(tr!("E.g. 192.168.0.100/24"))
+ .schema(&CIDR_SCHEMA)
+ .disabled(use_dhcp_network)
+ .required(!use_dhcp_network),
+ )
+ .with_field(
+ tr!("Gateway address"),
+ Field::new()
+ .name("gateway")
+ .placeholder(tr!("E.g. 192.168.0.1"))
+ .schema(&IP_SCHEMA)
+ .disabled(use_dhcp_network)
+ .required(!use_dhcp_network),
+ )
+ .with_field(
+ tr!("DNS server address"),
+ Field::new()
+ .name("dns")
+ .placeholder(tr!("E.g. 192.168.0.254"))
+ .schema(&IP_SCHEMA)
+ .disabled(use_dhcp_network)
+ .required(!use_dhcp_network),
+ )
+ .with_right_field(
+ tr!("FQDN from DHCP"),
+ Checkbox::new().name("use-dhcp-fqdn").default(false),
+ )
+ .with_right_field(
+ tr!("Fully-qualified domain name (FQDN)"),
+ Field::new()
+ .name("fqdn")
+ .placeholder("{{product.product}}{{installation_nr}}.example.com")
+ .value(config.fqdn.to_string())
+ .disabled(use_dhcp_fqdn)
+ .tip(tr!(
+ "Hostname and domain to set for the target installation. Allows templating."
+ ))
+ .required(!use_dhcp_fqdn),
+ )
+ .with_right_field("", DisplayField::new().key("dummy"))
+ .with_right_field(
+ tr!("Pin network interfaces"),
+ Checkbox::new()
+ .name("netif-name-pinning-enabled")
+ .default(config.netif_name_pinning_enabled),
+ )
+ .with_advanced_spacer()
+ .with_large_advanced_field(
+ tr!("Network device filters"),
+ KeyValueList::new()
+ .value(
+ config
+ .netdev_filter
+ .iter()
+ .map(|(k, v)| (k.clone(), Value::String(v.clone())))
+ .collect(),
+ )
+ .key_label(tr!("Property name"))
+ .value_label(tr!("Value to match"))
+ .key_placeholder(tr!("udev property name, e.g. ID_NET_DRIVER"))
+ .value_renderer(render_udev_filter_value.into())
+ .submit_validate(kv_list_to_udev_filter_map_validate)
+ .submit_empty(false)
+ .name("netdev-filter")
+ .class(FlexFit)
+ .disabled(use_dhcp_fqdn),
+ )
+ .into()
+}
+
+pub fn render_disk_setup_form(
+ form_ctx: &FormContext,
+ config: &PreparedInstallationConfig,
+) -> yew::Html {
+ let disk_mode = form_ctx
+ .read()
+ .get_field_value("disk-mode")
+ .and_then(|v| v.as_str().and_then(|s| s.parse::<DiskSelectionMode>().ok()))
+ .unwrap_or_default();
+
+ let fs_type = form_ctx
+ .read()
+ .get_field_value("filesystem-type")
+ .and_then(|v| v.as_str().and_then(|s| s.parse::<FilesystemType>().ok()))
+ .unwrap_or_default();
+
+ let mut panel = InputPanel::new()
+ .class(Flex::Fill)
+ .class(Overflow::Auto)
+ .padding(4)
+ .show_advanced(form_ctx.get_show_advanced())
+ .with_field(
+ tr!("Filesystem"),
+ Combobox::new()
+ .name("filesystem-type")
+ .items(Rc::new(
+ FILESYSTEM_TYPE_OPTIONS
+ .iter()
+ .map(|opt| serde_variant_name(opt).expect("valid variant").into())
+ .collect(),
+ ))
+ .render_value(|v: &AttrValue| {
+ v.parse::<FilesystemType>()
+ .map(|v| v.to_string())
+ .unwrap_or_default()
+ .into()
+ })
+ .value(serde_variant_name(config.filesystem.to_type()))
+ .required(true)
+ .show_filter(false),
+ )
+ .with_right_field(
+ tr!("Disk selection mode"),
+ Combobox::new()
+ .name("disk-mode")
+ .with_item("fixed")
+ .with_item("filter")
+ .default("fixed")
+ .render_value(|v: &AttrValue| match v.parse::<DiskSelectionMode>() {
+ Ok(DiskSelectionMode::Fixed) => tr!("Fixed list of disk names").into(),
+ Ok(DiskSelectionMode::Filter) => tr!("Dynamically by udev filter").into(),
+ _ => v.into(),
+ })
+ .required(true)
+ .value(serde_variant_name(config.disk_mode)),
+ )
+ .with_field(
+ tr!("Disk names"),
+ Field::new()
+ .name("disk-list")
+ .placeholder("sda, sdb")
+ .value(config.disk_list.join(", "))
+ .schema(&DISK_LIST_SCHEMA)
+ .disabled(disk_mode != DiskSelectionMode::Fixed)
+ .required(disk_mode == DiskSelectionMode::Fixed),
+ )
+ .with_spacer()
+ .with_field(
+ tr!("Disk udev filter mode"),
+ Combobox::new()
+ .name("disk-filter-match")
+ .items(Rc::new(
+ [FilterMatch::Any, FilterMatch::All]
+ .iter()
+ .map(|opt| serde_variant_name(opt).expect("valid variant").into())
+ .collect(),
+ ))
+ .render_value(|v: &AttrValue| match v.parse::<FilterMatch>() {
+ Ok(FilterMatch::Any) => tr!("Match any filter").into(),
+ Ok(FilterMatch::All) => tr!("Match all filters").into(),
+ _ => v.into(),
+ })
+ .default(serde_variant_name(FilterMatch::default()))
+ .value(config.disk_filter_match.and_then(serde_variant_name))
+ .disabled(disk_mode != DiskSelectionMode::Filter),
+ )
+ .with_large_field(
+ tr!("Disk udev filters"),
+ KeyValueList::new()
+ .value(
+ config
+ .disk_filter
+ .iter()
+ .map(|(k, v)| (k.clone(), Value::String(v.clone())))
+ .collect(),
+ )
+ .key_label(tr!("Property name"))
+ .value_label(tr!("Value to match"))
+ .key_placeholder(tr!("udev property name, e.g. ID_MODEL"))
+ .value_renderer(render_udev_filter_value.into())
+ .submit_validate(kv_list_to_udev_filter_map_validate)
+ .submit_empty(false)
+ .name("disk-filter")
+ .class(FlexFit)
+ .disabled(disk_mode != DiskSelectionMode::Filter),
+ );
+
+ let warning = match fs_type {
+ FilesystemType::Zfs(_) => Some(
+ tr!("ZFS is not compatible with hardware RAID controllers, for details see the documentation.")
+ ),
+ FilesystemType::Btrfs(_) => Some(tr!(
+ "Btrfs integration is a technology preview and only available for Proxmox Virtual Environment installations."
+ )),
+ _ => None,
+ };
+
+ if let Some(text) = warning {
+ panel.add_large_custom_child(
+ Container::from_tag("span")
+ .key("fs-warning")
+ .class("pwt-mt-2 pwt-d-block pwt-color-warning")
+ .with_child(Fa::new("exclamation-circle").class("fa-fw"))
+ .with_child(text),
+ );
+ }
+
+ panel.add_spacer(true);
+
+ add_fs_advanced_form_fields(&mut panel, &config.filesystem);
+ panel.into()
+}
+
+fn add_fs_advanced_form_fields(panel: &mut InputPanel, fs_opts: &FilesystemOptions) {
+ match fs_opts {
+ FilesystemOptions::Ext4(opts) | FilesystemOptions::Xfs(opts) => {
+ add_lvm_advanced_form_fields(panel, opts)
+ }
+ FilesystemOptions::Zfs(opts) => add_zfs_advanced_form_fields(panel, opts),
+ FilesystemOptions::Btrfs(opts) => add_btrfs_advanced_form_fields(panel, opts),
+ }
+}
+
+fn add_lvm_advanced_form_fields(panel: &mut InputPanel, fs_opts: &LvmOptions) {
+ panel.add_field_with_options(
+ FieldPosition::Left,
+ true,
+ false,
+ tr!("Harddisk size to use (GB)"),
+ Number::new()
+ .name("hdsize")
+ .min(4.)
+ .step(0.1)
+ .submit_empty(false)
+ .value(fs_opts.hdsize.map(|v| v.to_string())),
+ );
+
+ panel.add_field_with_options(
+ FieldPosition::Left,
+ true,
+ false,
+ tr!("Swap size (GB)"),
+ Number::new()
+ .name("swapsize")
+ .min(0.)
+ .max(fs_opts.hdsize.map(|v| v / 2.))
+ .step(0.1)
+ .submit_empty(false)
+ .value(fs_opts.swapsize.map(|v| v.to_string())),
+ );
+ panel.add_field_with_options(
+ FieldPosition::Right,
+ true,
+ false,
+ tr!("Maximum root volume size (GB)"),
+ Number::new()
+ .name("maxroot")
+ .min(0.)
+ .max(fs_opts.hdsize.map(|v| v / 2.))
+ .step(0.1)
+ .submit_empty(false)
+ .value(fs_opts.maxroot.map(|v| v.to_string())),
+ );
+ panel.add_field_with_options(
+ FieldPosition::Right,
+ true,
+ false,
+ tr!("Maximum data volume size (GB)"),
+ Number::new()
+ .name("maxvz")
+ .min(0.)
+ .max(fs_opts.hdsize.map(|v| v / 2.))
+ .step(0.1)
+ .submit_empty(false)
+ .value(fs_opts.maxvz.map(|v| v.to_string())),
+ );
+ panel.add_field_with_options(
+ FieldPosition::Right,
+ true,
+ false,
+ tr!("Minimum free space in LVM volume group (GB)"),
+ Number::new()
+ .name("minfree")
+ .min(0.)
+ .max(fs_opts.hdsize.map(|v| v / 2.))
+ .step(0.1)
+ .submit_empty(false)
+ .value(fs_opts.minfree.map(|v| v.to_string())),
+ );
+}
+
+fn add_zfs_advanced_form_fields(panel: &mut InputPanel, fs_opts: &ZfsOptions) {
+ panel.add_field_with_options(
+ FieldPosition::Left,
+ true,
+ false,
+ "ashift",
+ Number::<u64>::new()
+ .name("ashift")
+ .min(9)
+ .max(16)
+ .step(1)
+ .submit_empty(false)
+ .value(fs_opts.ashift.map(|v| v.to_string())),
+ );
+ panel.add_field_with_options(
+ FieldPosition::Left,
+ true,
+ false,
+ tr!("ARC maximum size (MiB)"),
+ Number::new()
+ .name("arc-max")
+ .min(64.)
+ .step(1.)
+ .submit_empty(false)
+ .value(fs_opts.arc_max.map(|v| v.to_string())),
+ );
+ panel.add_field_with_options(
+ FieldPosition::Right,
+ true,
+ false,
+ tr!("Checksumming algorithm"),
+ Combobox::new()
+ .name("checksum")
+ .items(Rc::new(
+ ZFS_CHECKSUM_OPTIONS
+ .iter()
+ .map(|opt| serde_variant_name(opt).expect("valid variant").into())
+ .collect(),
+ ))
+ .render_value(|v: &AttrValue| {
+ v.parse::<ZfsChecksumOption>()
+ .map(|v| v.to_string())
+ .unwrap_or_default()
+ .into()
+ })
+ .submit_empty(false)
+ .value(fs_opts.checksum.map(|v| v.to_string())),
+ );
+ panel.add_field_with_options(
+ FieldPosition::Right,
+ true,
+ false,
+ tr!("Compression algorithm"),
+ Combobox::new()
+ .name("compress")
+ .items(Rc::new(
+ ZFS_COMPRESS_OPTIONS
+ .iter()
+ .map(|opt| serde_variant_name(opt).expect("valid variant").into())
+ .collect(),
+ ))
+ .render_value(|v: &AttrValue| {
+ v.parse::<ZfsCompressOption>()
+ .map(|v| v.to_string())
+ .unwrap_or_default()
+ .into()
+ })
+ .submit_empty(false)
+ .value(fs_opts.compress.map(|v| v.to_string())),
+ );
+ panel.add_field_with_options(
+ FieldPosition::Right,
+ true,
+ false,
+ tr!("Copies"),
+ Number::<u32>::new()
+ .name("copies")
+ .min(1)
+ .max(3)
+ .step(1)
+ .submit_empty(false)
+ .value(fs_opts.copies.map(|v| v.to_string())),
+ );
+}
+
+fn add_btrfs_advanced_form_fields(panel: &mut InputPanel, fs_opts: &BtrfsOptions) {
+ panel.add_field_with_options(
+ FieldPosition::Right,
+ true,
+ false,
+ tr!("Compression algorithm"),
+ Combobox::new()
+ .name("compress")
+ .items(Rc::new(
+ BTRFS_COMPRESS_OPTIONS
+ .iter()
+ .map(|opt| serde_variant_name(opt).expect("valid variant").into())
+ .collect(),
+ ))
+ .render_value(|v: &AttrValue| {
+ v.parse::<BtrfsCompressOption>()
+ .map(|v| v.to_string())
+ .unwrap_or_default()
+ .into()
+ })
+ .submit_empty(false)
+ .value(fs_opts.compress.map(|v| v.to_string())),
+ );
+}
+
+pub fn render_target_filter_form(
+ form_ctx: &FormContext,
+ config: &PreparedInstallationConfig,
+) -> yew::Html {
+ let is_default = form_ctx
+ .read()
+ .get_field_value("is-default")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(config.is_default);
+
+ let has_target_filters = form_ctx
+ .read()
+ .get_field_value("target-filter")
+ .and_then(|v| v.as_array().map(|vec| !vec.is_empty()))
+ .unwrap_or(false);
+
+ let mut panel = InputPanel::new()
+ .class(Flex::Fill)
+ .class(Overflow::Auto)
+ .padding(4);
+
+ if !is_default && !has_target_filters {
+ panel.add_large_custom_child(
+ Container::from_tag("span")
+ .key("unmatchable-answer-warning")
+ .class("pwt-color-warning pwt-mb-2 pwt-d-block")
+ .with_child(Fa::new("exclamation-circle").class("fa-fw"))
+ .with_child(tr!(
+ "Not marked as default answer and target filter are empty, answer will never be matched."
+ ))
+ );
+ }
+
+ panel
+ .with_field(
+ tr!("Default answer"),
+ Checkbox::new()
+ .name("is-default")
+ .tip(tr!(
+ "If selected, this configuration will be used if no other matches."
+ ))
+ .default(config.is_default),
+ )
+ .with_spacer()
+ .with_large_field(
+ tr!("Target filters"),
+ KeyValueList::new()
+ .value(
+ config
+ .target_filter
+ .iter()
+ .map(|(k, v)| (k.clone(), Value::String(v.clone())))
+ .collect(),
+ )
+ .key_label(tr!("JSON pointer"))
+ .value_label(tr!("Value to match"))
+ .key_placeholder("/json/pointer")
+ .submit_validate(|v: &Vec<(String, Value)>| {
+ let map: BTreeMap<String, String> = v
+ .iter()
+ .map(|(k, v)| {
+ (
+ k.clone(),
+ v.as_str().map(ToOwned::to_owned).unwrap_or_default(),
+ )
+ })
+ .collect();
+ Ok(serde_json::to_value(map)?)
+ })
+ .submit_empty(true)
+ .name("target-filter")
+ .class(FlexFit)
+ .disabled(is_default),
+ )
+ .with_right_custom_child(Container::new().key("rfc-6901-hint").with_child(html! {
+ <span style="float: right;">
+ {tr!("references RFC 6901" => "Target filter keys are JSON pointers according to")}
+ {" "}
+ <a href="https://www.rfc-editor.org/rfc/rfc6901" target="_blank">{"RFC 6901"}</a>
+ {"."}
+ </span>
+ }))
+ .into()
+}
+
+pub fn render_templating_form(config: &PreparedInstallationConfig) -> yew::Html {
+ InputPanel::new()
+ .class(Flex::Fill)
+ .class(Overflow::Auto)
+ .padding(4)
+ .with_large_custom_child(
+ Container::from_tag("p")
+ .key("counter-info")
+ .with_child(tr!(
+ "Numerical template counters can be used to provide unique values across installations."
+ )),
+ )
+ .with_large_custom_child(
+ Container::from_tag("p")
+ .key("counters-hint")
+ .class("pwt-mb-2")
+ .with_child(tr!(
+ "Counters are automatically incremented each time an answer is served."
+ )),
+ )
+ .with_large_custom_child(
+ KeyValueList::new()
+ .value(
+ config
+ .template_counters
+ .iter()
+ .map(|(k, v)| (k.clone(), Value::Number((*v).into())))
+ .collect(),
+ )
+ .value_label(tr!("Current value"))
+ .value_renderer(render_template_counter_value.into())
+ .submit_validate(kv_list_to_template_counter_map_validate)
+ .submit_empty(false)
+ .name("template-counters")
+ .class(FlexFit),
+ )
+ .into()
+}
+
+pub fn render_auth_form(
+ form_ctx: &FormContext,
+ config: &PreparedInstallationConfig,
+ tokens: Store<AnswerToken>,
+) -> yew::Html {
+ let has_tokens_selected = form_ctx
+ .read()
+ .get_field_value("authorized-tokens")
+ .and_then(|v| v.as_array().map(|vec| !vec.is_empty()))
+ .unwrap_or(false);
+
+ let mut panel = InputPanel::new()
+ .class(Flex::Fill)
+ .class(Overflow::Auto)
+ .padding(4)
+ .with_custom_child(
+ Container::from_tag("span")
+ .key("authorized-tokens-title")
+ .class("pwt-font-title-medium")
+ .with_child(tr!("Authorized tokens")),
+ )
+ .with_large_custom_child(
+ TokenSelector::new(tokens)
+ .selected_keys(config.authorized_tokens.clone())
+ .required(false)
+ .submit_empty(true)
+ .name("authorized-tokens"),
+ );
+
+ if !has_tokens_selected {
+ panel.add_large_custom_child(
+ Container::from_tag("p")
+ .key("auth-token-auto-create")
+ .class("pwt-color-warning")
+ .with_child(Fa::new("exclamation-circle").class("fa-fw"))
+ .with_child(tr!(
+ "No existing authorization token selected. A new one will be automatically created."
+ ))
+ );
+ }
+
+ panel
+ .with_spacer()
+ .with_large_field(
+ tr!("Proxmox Datacenter Manager base URL"),
+ Field::new()
+ .name("post-hook-base-url")
+ .tip(tr!(
+ "Base URL this PDM instance is reachable from the target host"
+ ))
+ .tip(pdm_origin())
+ .value(config.post_hook_base_url.clone()),
+ )
+ .with_large_field(
+ tr!("SHA256 certificate fingerprint"),
+ Field::new()
+ .name("post-hook-cert-fp")
+ .tip(tr!("Optional certificate fingerprint"))
+ .value(config.post_hook_cert_fp.clone()),
+ )
+ .with_large_custom_child(
+ Container::from_tag("p")
+ .key("post-hook-hint")
+ .class("pwt-mt-2 pwt-color-primary")
+ .with_child(Fa::new("info-circle").class("fa-fw"))
+ .with_child(tr!(
+ "Optional. If provided, status reporting will be enabled."
+ )),
+ )
+ .into()
+}
+
+pub fn render_show_secret_dialog(
+ config_id: Option<&str>,
+ token: &AnswerToken,
+ secret: &str,
+ on_close: Callback<()>,
+) -> Option<yew::Html> {
+ let token = format!("{}:{secret}", token.id);
+
+ let copy_token_view = Container::new()
+ .class("pwt-form-grid-col4")
+ .with_child(FieldLabel::new(tr!("Token")))
+ .with_child(
+ Row::new()
+ .class("pwt-fill-grid-row")
+ .gap(2)
+ .with_child(
+ Field::new()
+ .input_type(InputType::Password)
+ .class(FlexFit)
+ .value(token.to_owned())
+ .read_only(true),
+ )
+ .with_child(
+ Tooltip::new(
+ Button::new_icon("fa fa-clipboard")
+ .class(ColorScheme::Primary)
+ .on_activate({
+ let token = token.to_owned();
+ move |_| copy_text_to_clipboard(&token)
+ }),
+ )
+ .tip(tr!("Copy answer token to clipboard.")),
+ ),
+ );
+
+ let commandline = format!(
+ "proxmox-auto-install-assistant prepare-iso --fetch-from http --url {} --answer-auth-token {token}",
+ pdm_origin().unwrap_or_else(|| "https://pdm.example.com:8443".to_owned()),
+ );
+
+ let copy_commandline_view = Container::new()
+ .class("pwt-form-grid-col4")
+ .with_child(FieldLabel::new(tr!("Command line")))
+ .with_child(
+ Row::new()
+ .class("pwt-fill-grid-row")
+ .gap(2)
+ .with_child(
+ Field::new()
+ .input_type(InputType::Password)
+ .class(FlexFit)
+ .style("height", "2em")
+ .value(commandline.to_owned())
+ .read_only(true),
+ )
+ .with_child(
+ Tooltip::new(
+ Button::new_icon("fa fa-clipboard")
+ .class(ColorScheme::Primary)
+ .on_activate({
+ let commandline = commandline.to_owned();
+ move |_| copy_text_to_clipboard(&commandline)
+ }),
+ )
+ .tip(tr!("Copy command line to clipboard.")),
+ ),
+ );
+
+ let mut panel = InputPanel::new().padding(4);
+
+ if let Some(id) = config_id {
+ panel.add_large_field(
+ false,
+ false,
+ tr!("Configuration ID"),
+ DisplayField::new().value(id.to_owned()).read_only(true),
+ );
+ }
+
+ panel.add_large_custom_child(copy_token_view);
+ panel.add_large_custom_child(copy_commandline_view);
+
+ let dialog = Dialog::new(tr!("New Answer Token")).on_close(on_close).with_child(Column::new().with_child(panel))
+ .with_child(
+ Container::new()
+ .padding(4)
+ .class(FlexFit)
+ .class(ColorScheme::WarningContainer)
+ .class("pwt-default-colors")
+ .with_child(tr!(
+ "Please record the configuration token or ISO preparation command line - it will only be displayed once."
+ )),
+ );
+
+ Some(dialog.into())
+}
+
+fn render_udev_filter_value(
+ (_key, value, props, on_change): &(String, Value, FieldStdProps, Callback<String>),
+) -> yew::Html {
+ Field::new()
+ .placeholder(tr!("glob to match"))
+ .disabled(props.disabled)
+ .value(value.as_str().map(|s| s.to_owned()).unwrap_or_default())
+ .on_change(on_change)
+ .into()
+}
+
+fn render_template_counter_value(
+ (_key, value, props, on_change): &(String, Value, FieldStdProps, Callback<String>),
+) -> yew::Html {
+ Number::<i32>::new()
+ .value(value.as_i64().unwrap_or_default().to_string())
+ .disabled(props.disabled)
+ .on_change({
+ let on_change = on_change.clone();
+ move |v: Option<Result<i32, String>>| {
+ if let Some(Ok(v)) = v {
+ on_change.emit(v.to_string());
+ }
+ }
+ })
+ .into()
+}
+
+#[allow(clippy::ptr_arg)]
+fn kv_list_to_udev_filter_map_validate(v: &Vec<(String, Value)>) -> Result<Value> {
+ let mut map = BTreeMap::<String, String>::new();
+ for (k, v) in v {
+ if UDEV_FILTER_KEY_REGEX.is_match(k) {
+ map.insert(k.clone(), v.as_str().unwrap_or_default().to_owned());
+ } else {
+ bail!("udev property names must only consist of uppercase characters and underscores: {k}");
+ }
+ }
+
+ Ok(serde_json::to_value(map)?)
+}
+
+#[allow(clippy::ptr_arg)]
+fn kv_list_to_template_counter_map_validate(v: &Vec<(String, Value)>) -> Result<Value> {
+ let mut map = BTreeMap::<String, i32>::new();
+ for (k, v) in v {
+ if TEMPLATE_COUNTER_NAME_REGEX.is_match(k) {
+ match v.as_i64().and_then(|v| v.try_into().ok()) {
+ Some(v) => {
+ map.insert(k.clone(), v);
+ }
+ None => bail!("invalid value: {v}"),
+ }
+ } else {
+ bail!("must be a valid minijinja identifier: {k}");
+ }
+ }
+
+ Ok(serde_json::to_value(map)?)
+}
+
+fn serde_variant_name<T: Serialize>(ty: T) -> Option<String> {
+ match serde_json::to_value(ty) {
+ Ok(Value::String(s)) => Some(s),
+ other => {
+ log::warn!(
+ "expected string of type {}, got {other:?}",
+ std::any::type_name::<T>()
+ );
+ None
+ }
+ }
+}
+
+fn pdm_origin() -> Option<String> {
+ gloo_utils::document()
+ .url()
+ .and_then(|s| web_sys::Url::new(&s))
+ .map(|url| url.origin())
+ .ok()
+}
+
+const KEYBOARD_LAYOUTS: &[KeyboardLayout] = {
+ use KeyboardLayout::*;
+ &[
+ De, DeCh, Dk, EnGb, EnUs, Es, Fi, Fr, FrBe, FrCa, FrCh, Hu, Is, It, Jp, Lt, Mk, Nl, No, Pl,
+ Pt, PtBr, Se, Si, Tr,
+ ]
+};
+
+static COUNTRY_INFO: LazyLock<BTreeMap<String, String>> = LazyLock::new(|| {
+ #[derive(Deserialize)]
+ struct Iso3611CountryInfo {
+ alpha_2: String,
+ common_name: Option<String>,
+ name: String,
+ }
+
+ #[derive(Deserialize)]
+ struct Iso3611Info {
+ #[serde(rename = "3166-1")]
+ list: Vec<Iso3611CountryInfo>,
+ }
+
+ let raw: Iso3611Info =
+ serde_json::from_str(include_str!("/usr/share/iso-codes/json/iso_3166-1.json"))
+ .expect("valid country-info json");
+
+ raw.list
+ .into_iter()
+ .map(|c| (c.alpha_2.to_lowercase(), c.common_name.unwrap_or(c.name)))
+ .collect()
+});
diff --git a/ui/src/remotes/auto_installer/prepared_answers_panel.rs b/ui/src/remotes/auto_installer/prepared_answers_panel.rs
new file mode 100644
index 0000000..0fa6970
--- /dev/null
+++ b/ui/src/remotes/auto_installer/prepared_answers_panel.rs
@@ -0,0 +1,311 @@
+//! Implements the UI for the auto-installer answer editing panel.
+
+use anyhow::Result;
+use core::clone::Clone;
+use std::{future::Future, pin::Pin, rc::Rc};
+use yew::{
+ virtual_dom::{Key, VComp, VNode},
+ Properties,
+};
+
+use pdm_api_types::auto_installer::{
+ AnswerToken, AnswerTokenCreateResult, PreparedInstallationConfig,
+};
+use proxmox_yew_comp::{
+ percent_encoding::percent_encode_component, ConfirmButton, LoadableComponent,
+ LoadableComponentContext, LoadableComponentMaster, LoadableComponentScopeExt,
+ LoadableComponentState,
+};
+use pwt::{
+ props::{ContainerBuilder, EventSubscriber, WidgetBuilder},
+ state::{Selection, Store},
+ tr,
+ widget::{
+ data_table::{DataTable, DataTableColumn, DataTableHeader},
+ Button, Fa, Toolbar,
+ },
+};
+
+use super::{
+ prepared_answer_add_wizard::AddAnswerWizardProperties,
+ prepared_answer_edit_window::EditAnswerWindowProperties,
+};
+use crate::{pdm_client, remotes::auto_installer::prepared_answer_form::render_show_secret_dialog};
+
+#[derive(Default, PartialEq, Properties)]
+pub struct PreparedAnswersPanel {}
+
+impl From<PreparedAnswersPanel> for VNode {
+ fn from(value: PreparedAnswersPanel) -> Self {
+ let comp = VComp::new::<LoadableComponentMaster<PreparedAnswersPanelComponent>>(
+ Rc::new(value),
+ None,
+ );
+ VNode::from(comp)
+ }
+}
+
+#[derive(PartialEq)]
+enum ViewState {
+ Create,
+ Copy,
+ Edit,
+ DisplaySecret {
+ config_id: String,
+ token: AnswerToken,
+ secret: String,
+ },
+}
+
+#[derive(PartialEq)]
+enum Message {
+ SelectionChange,
+ RemoveEntry,
+ DisplaySecret {
+ config_id: String,
+ token: AnswerToken,
+ secret: String,
+ },
+}
+
+struct PreparedAnswersPanelComponent {
+ state: LoadableComponentState<ViewState>,
+ selection: Selection,
+ store: Store<PreparedInstallationConfig>,
+ columns: Rc<Vec<DataTableHeader<PreparedInstallationConfig>>>,
+}
+
+pwt::impl_deref_mut_property!(
+ PreparedAnswersPanelComponent,
+ state,
+ LoadableComponentState<ViewState>
+);
+
+impl LoadableComponent for PreparedAnswersPanelComponent {
+ type Properties = PreparedAnswersPanel;
+ type Message = Message;
+ type ViewState = ViewState;
+
+ fn create(ctx: &LoadableComponentContext<Self>) -> Self {
+ let store = Store::with_extract_key(|record: &PreparedInstallationConfig| {
+ Key::from(record.id.to_string())
+ });
+ store.set_sorter(
+ |a: &PreparedInstallationConfig, b: &PreparedInstallationConfig| a.id.cmp(&b.id),
+ );
+
+ Self {
+ state: LoadableComponentState::new(),
+ selection: Selection::new()
+ .on_select(ctx.link().callback(|_| Message::SelectionChange)),
+ store,
+ columns: Rc::new(columns()),
+ }
+ }
+
+ fn load(
+ &self,
+ _ctx: &LoadableComponentContext<Self>,
+ ) -> Pin<Box<dyn Future<Output = Result<()>>>> {
+ let store = self.store.clone();
+ Box::pin(async move {
+ let data = pdm_client().get_autoinst_prepared_answers().await?;
+ store.write().set_data(data);
+ Ok(())
+ })
+ }
+
+ fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Message) -> bool {
+ let link = ctx.link().clone();
+
+ match msg {
+ Message::SelectionChange => true,
+ Message::RemoveEntry => {
+ if let Some(key) = self.selection.selected_key() {
+ self.spawn(async move {
+ if let Err(err) = pdm_client()
+ .delete_autoinst_prepared_answer(&percent_encode_component(
+ &key.to_string(),
+ ))
+ .await
+ {
+ link.show_error(tr!("Unable to delete entry"), err, true);
+ }
+ link.send_reload();
+ })
+ }
+ false
+ }
+ Message::DisplaySecret {
+ config_id,
+ token,
+ secret,
+ } => {
+ link.change_view(Some(Self::ViewState::DisplaySecret {
+ config_id,
+ token,
+ secret,
+ }));
+ false
+ }
+ }
+ }
+
+ fn toolbar(&self, ctx: &LoadableComponentContext<Self>) -> Option<yew::Html> {
+ let link = ctx.link().clone();
+
+ let toolbar = Toolbar::new()
+ .class("pwt-w-100")
+ .class(pwt::css::Overflow::Hidden)
+ .class("pwt-border-bottom")
+ .with_child(
+ Button::new(tr!("Add"))
+ .onclick(link.change_view_callback(|_| Some(ViewState::Create))),
+ )
+ .with_spacer()
+ .with_child(
+ Button::new(tr!("Copy"))
+ .onclick(link.change_view_callback(|_| Some(ViewState::Copy))),
+ )
+ .with_child(
+ Button::new(tr!("Edit"))
+ .disabled(self.selection.is_empty())
+ .onclick(link.change_view_callback(|_| Some(ViewState::Edit))),
+ )
+ .with_child(
+ ConfirmButton::new(tr!("Remove"))
+ .confirm_message(tr!("Are you sure you want to remove this entry?"))
+ .disabled(self.selection.is_empty())
+ .on_activate(link.callback(|_| Message::RemoveEntry)),
+ );
+
+ Some(toolbar.into())
+ }
+
+ fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> yew::Html {
+ let link = ctx.link().clone();
+
+ DataTable::new(self.columns.clone(), self.store.clone())
+ .class(pwt::css::FlexFit)
+ .selection(self.selection.clone())
+ .on_row_dblclick(move |_: &mut _| link.change_view(Some(Self::ViewState::Edit)))
+ .into()
+ }
+
+ fn dialog_view(
+ &self,
+ ctx: &LoadableComponentContext<Self>,
+ view_state: &Self::ViewState,
+ ) -> Option<yew::Html> {
+ let link = ctx.link().clone();
+ let on_submit_result = ctx.link().callback(
+ move |(config, new_token): (
+ PreparedInstallationConfig,
+ Option<AnswerTokenCreateResult>,
+ )| {
+ if let Some(token) = new_token {
+ Self::Message::DisplaySecret {
+ config_id: config.id,
+ token: token.token,
+ secret: token.secret,
+ }
+ } else {
+ link.change_view(None);
+ link.send_reload();
+ Self::Message::SelectionChange
+ }
+ },
+ );
+
+ let on_close = ctx.link().change_view_callback(|_| None);
+
+ match view_state {
+ Self::ViewState::Create => Some(
+ AddAnswerWizardProperties::new()
+ .on_submit_result(on_submit_result)
+ .on_close(on_close)
+ .into(),
+ ),
+ Self::ViewState::Copy => {
+ let mut record = self
+ .store
+ .read()
+ .lookup_record(&self.selection.selected_key()?)?
+ .clone();
+
+ record.id += " (copy)";
+ Some(
+ AddAnswerWizardProperties::with(record)
+ .on_submit_result(on_submit_result)
+ .on_close(on_close)
+ .into(),
+ )
+ }
+ Self::ViewState::Edit => {
+ let record = self
+ .store
+ .read()
+ .lookup_record(&self.selection.selected_key()?)?
+ .clone();
+
+ Some(
+ EditAnswerWindowProperties::new(record)
+ .on_submit_result(on_submit_result)
+ .on_close(on_close)
+ .into(),
+ )
+ }
+ Self::ViewState::DisplaySecret {
+ config_id,
+ token,
+ secret,
+ } => render_show_secret_dialog(Some(config_id), token, secret, on_close),
+ }
+ }
+}
+
+fn columns() -> Vec<DataTableHeader<PreparedInstallationConfig>> {
+ vec![
+ DataTableColumn::new(tr!("ID"))
+ .width("320px")
+ .render(|item: &PreparedInstallationConfig| item.id.as_str().into())
+ .sorter(
+ |a: &PreparedInstallationConfig, b: &PreparedInstallationConfig| a.id.cmp(&b.id),
+ )
+ .sort_order(Some(true))
+ .into(),
+ DataTableColumn::new(tr!("Default"))
+ .width("80px")
+ .render(|item: &PreparedInstallationConfig| {
+ if item.is_default {
+ Fa::new("check").into()
+ } else {
+ Fa::new("times").into()
+ }
+ })
+ .into(),
+ DataTableColumn::new(tr!("Target filter"))
+ .flex(1)
+ .render(|item: &PreparedInstallationConfig| {
+ if item.target_filter.is_empty() {
+ "-".into()
+ } else {
+ item.target_filter
+ .iter()
+ .fold(String::new(), |acc, (k, v)| {
+ if acc.is_empty() {
+ format!("{k}={v}")
+ } else {
+ format!("{acc}, {k}={v}")
+ }
+ })
+ .into()
+ }
+ })
+ .into(),
+ DataTableColumn::new(tr!("Authorized tokens"))
+ .flex(1)
+ .render(|item: &PreparedInstallationConfig| item.authorized_tokens.join(", ").into())
+ .into(),
+ ]
+}
diff --git a/ui/src/remotes/auto_installer/token_selector.rs b/ui/src/remotes/auto_installer/token_selector.rs
new file mode 100644
index 0000000..ec34515
--- /dev/null
+++ b/ui/src/remotes/auto_installer/token_selector.rs
@@ -0,0 +1,159 @@
+//! A [`GridPicker`]-based selector for access tokens for the automated installer.
+
+use serde_json::Value;
+use std::{collections::HashSet, rc::Rc};
+use yew::{html, virtual_dom::Key, Properties};
+
+use pdm_api_types::auto_installer::AnswerToken;
+use pwt::{
+ css::FlexFit,
+ prelude::*,
+ state::{Selection, Store},
+ widget::{
+ data_table::{DataTable, DataTableColumn, DataTableHeader, MultiSelectMode},
+ form::{
+ ManagedField, ManagedFieldContext, ManagedFieldMaster, ManagedFieldScopeExt,
+ ManagedFieldState,
+ },
+ GridPicker,
+ },
+};
+use pwt_macros::{builder, widget};
+
+#[widget(comp = ManagedFieldMaster<TokenSelectorField>, @input)]
+#[derive(Clone, PartialEq, Properties)]
+#[builder]
+pub struct TokenSelector {
+ /// All available tokens to select.
+ store: Store<AnswerToken>,
+
+ #[builder]
+ #[prop_or_default]
+ /// Keys of entries to pre-select.
+ pub selected_keys: Vec<String>,
+}
+
+impl TokenSelector {
+ pub fn new(store: Store<AnswerToken>) -> Self {
+ yew::props!(Self { store })
+ }
+}
+
+pub struct TokenSelectorField {
+ state: ManagedFieldState,
+ store: Store<AnswerToken>,
+ selection: Selection,
+ columns: Rc<Vec<DataTableHeader<AnswerToken>>>,
+}
+
+pwt::impl_deref_mut_property!(TokenSelectorField, state, ManagedFieldState);
+
+pub enum Message {
+ UpdateSelection,
+}
+
+impl ManagedField for TokenSelectorField {
+ type Message = Message;
+ type Properties = TokenSelector;
+ type ValidateClosure = ();
+
+ fn create(ctx: &ManagedFieldContext<Self>) -> Self {
+ let selection = Selection::new()
+ .multiselect(true)
+ .on_select(ctx.link().callback(|_| Message::UpdateSelection));
+
+ let mut selected = ctx.props().selected_keys.clone();
+ selected.sort();
+
+ let store = ctx.props().store.clone().on_change(ctx.link().callback({
+ // re-apply selection when store changes
+ let selection = selection.clone();
+ let selected = selected
+ .iter()
+ .cloned()
+ .map(Key::from)
+ .collect::<HashSet<Key>>();
+ move |_| {
+ selection.bulk_select(selected.clone());
+ Message::UpdateSelection
+ }
+ }));
+
+ let default = serde_json::to_value(selected).unwrap_or_default();
+
+ Self {
+ state: ManagedFieldState::new(default.clone(), default),
+ store,
+ selection,
+ columns: Self::columns(),
+ }
+ }
+
+ fn validation_args(_props: &Self::Properties) -> Self::ValidateClosure {}
+
+ fn validator(_props: &Self::ValidateClosure, value: &Value) -> Result<Value, anyhow::Error> {
+ Ok(value.clone())
+ }
+
+ fn update(&mut self, ctx: &ManagedFieldContext<Self>, msg: Self::Message) -> bool {
+ match msg {
+ Self::Message::UpdateSelection => {
+ let mut selected = self
+ .selection
+ .selected_keys()
+ .iter()
+ .map(|k| k.to_string())
+ .collect::<Vec<_>>();
+ selected.sort();
+ ctx.link().update_value(selected);
+ true
+ }
+ }
+ }
+
+ fn changed(&mut self, ctx: &ManagedFieldContext<Self>, old_props: &Self::Properties) -> bool {
+ let props = ctx.props();
+
+ if old_props.selected_keys != props.selected_keys {
+ let mut selected = props
+ .selected_keys
+ .iter()
+ .map(|k| k.to_string())
+ .collect::<Vec<_>>();
+ selected.sort();
+
+ ctx.link().update_default(selected);
+ }
+
+ true
+ }
+
+ fn view(&self, _ctx: &ManagedFieldContext<Self>) -> Html {
+ GridPicker::new(
+ DataTable::new(self.columns.clone(), self.store.clone())
+ .multiselect_mode(MultiSelectMode::Simple)
+ .border(true)
+ .class(FlexFit),
+ )
+ .selection(self.selection.clone())
+ .into()
+ }
+}
+
+impl TokenSelectorField {
+ fn columns() -> Rc<Vec<DataTableHeader<AnswerToken>>> {
+ Rc::new(vec![
+ DataTableColumn::selection_indicator().into(),
+ DataTableColumn::new(tr!("Token"))
+ .flex(1)
+ .render(|item: &AnswerToken| html! { &item.id })
+ .sorter(|a: &AnswerToken, b: &AnswerToken| a.id.cmp(&b.id))
+ .sort_order(true)
+ .into(),
+ DataTableColumn::new(tr!("Comment"))
+ .flex(1)
+ .render(|item: &AnswerToken| html! { item.comment.as_deref().unwrap_or("") })
+ .into(),
+ ])
+ }
+}
--
2.53.0
next prev parent reply other threads:[~2026-04-30 12:50 UTC|newest]
Thread overview: 41+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-04-30 12:46 [PATCH datacenter-manager/installer/proxmox/yew-comp v4 00/40] add auto-installer integration Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 01/40] api-macro: allow $ in identifier name Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 02/40] schema: oneOf: allow single string variant Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 03/40] schema: implement UpdaterType for HashMap and BTreeMap Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 04/40] network-types: move `Fqdn` type from proxmox-installer-common Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 05/40] network-types: implement api type for Fqdn Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 06/40] network-types: add api wrapper type for std::net::IpAddr Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 07/40] network-types: cidr: implement generic `IpAddr::new` constructor Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 08/40] network-types: fqdn: implement standard library Error for Fqdn Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 09/40] node-status: make KernelVersionInformation Clone + PartialEq Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 10/40] installer-types: add common types used by the installer Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 11/40] installer-types: add types used by the auto-installer Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 12/40] installer-types: implement api type for all externally-used types Christoph Heiss
2026-04-30 12:46 ` [PATCH yew-comp v4 13/40] widget: kvlist: add widget for user-modifiable data tables Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 14/40] api-types, cli: use ReturnType::new() instead of constructing it manually Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 15/40] api-types: add api types for auto-installer integration Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 16/40] config: add auto-installer configuration module Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 17/40] acl: wire up new /system/auto-installation acl path Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 18/40] server: api: add auto-installer integration module Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 19/40] server: api: auto-installer: add access token management endpoints Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 20/40] client: add bindings for auto-installer endpoints Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 21/40] ui: auto-installer: add installations overview panel Christoph Heiss
2026-04-30 12:46 ` Christoph Heiss [this message]
2026-04-30 12:46 ` [PATCH datacenter-manager v4 23/40] ui: auto-installer: add access token configuration panel Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 24/40] docs: add documentation for auto-installer integration Christoph Heiss
2026-04-30 12:46 ` [PATCH installer v4 25/40] install: iso env: use JSON boolean literals for product config Christoph Heiss
2026-04-30 12:46 ` [PATCH installer v4 26/40] common: http: allow passing custom headers to post() Christoph Heiss
2026-04-30 12:46 ` [PATCH installer v4 27/40] common: http: retrieve error message from body on post() Christoph Heiss
2026-04-30 12:46 ` [PATCH installer v4 28/40] common: options: move regex construction out of loop Christoph Heiss
2026-04-30 12:46 ` [PATCH installer v4 29/40] assistant: support adding an authorization token for HTTP-based answers Christoph Heiss
2026-04-30 12:46 ` [PATCH installer v4 30/40] post-hook: run cargo fmt Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 31/40] tree-wide: used moved `Fqdn` type to proxmox-network-types Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 32/40] tree-wide: use `Cidr` type from proxmox-network-types Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 33/40] tree-wide: switch to filesystem types from proxmox-installer-types Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 34/40] auto: sysinfo: switch to " Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 35/40] fetch-answer: " Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 36/40] fetch-answer: http: prefer json over toml for answer format Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 37/40] fetch-answer: send auto-installer HTTP authorization token if set Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 38/40] fetch-answer: print full error messages when fetching failed Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 39/40] tree-wide: switch out `Answer` -> `AutoInstallerConfig` types Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 40/40] auto: drop now-dead answer file definitions Christoph Heiss
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260430124712.1614305-23-c.heiss@proxmox.com \
--to=c.heiss@proxmox.com \
--cc=pdm-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox