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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.