diff --git a/libs/locales/lib/en/translation.json b/libs/locales/lib/en/translation.json index 46a686d971..df70d29feb 100644 --- a/libs/locales/lib/en/translation.json +++ b/libs/locales/lib/en/translation.json @@ -183,6 +183,7 @@ "ai:Cluster installation is still in-progress.": "Cluster installation is still in-progress.", "ai:Cluster installation process": "Cluster installation process", "ai:Cluster installation was cancelled": "Cluster installation was cancelled.", + "ai:Cluster must be created before configuring infrastructure environment": "Cluster must be created before configuring infrastructure environment", "ai:Cluster must have at least 3 hosts.": "Cluster must have at least 3 hosts.", "ai:Cluster name": "Cluster name", "ai:Cluster network CIDR": "Cluster network CIDR", @@ -214,6 +215,7 @@ "ai:Configure environment": "Configure environment", "ai:Configure host inventory settings": "Configure host inventory settings", "ai:Configure load balancer on Amazon Web Services for me.": "Configure load balancer on Amazon Web Services for me.", + "ai:Configure proxy settings": "Configure proxy settings", "ai:Configure the SSH key and proxy settings after the modal appears (optional).": "Configure the SSH key and proxy settings after the modal appears (optional).", "ai:Configure your own NTP sources to sychronize the time between the hosts that will be added to this infrastructure environment.": "Configure your own NTP sources to sychronize the time between the hosts that will be added to this infrastructure environment.", "ai:Configure your own NTP sources to synchronize the time between the hosts that will be added to this infrastructure environment.": "Configure your own NTP sources to synchronize the time between the hosts that will be added to this infrastructure environment.", @@ -346,9 +348,11 @@ "ai:Failed to add hosts to the cluster": "Failed to add hosts to the cluster", "ai:Failed to configure provisioning to enable registering hosts via BMC.": "Failed to configure provisioning to enable registering hosts via BMC.", "ai:Failed to create AgentServiceConfig": "Failed to create AgentServiceConfig", + "ai:Failed to create infrastructure environment": "Failed to create infrastructure environment", "ai:Failed to create IngressController": "Failed to create IngressController", "ai:Failed to delete host": "Failed to delete host", "ai:Failed to download the discovery Image": "Failed to download the discovery Image", + "ai:Failed to fetch cluster": "Failed to fetch cluster", "ai:Failed to fetch cluster credentials.": "Failed to fetch cluster credentials.", "ai:Failed to get Provisioning Configuration": "Failed to get Provisioning Configuration", "ai:Failed to load Credentials Download step": "Failed to load Credentials Download step", @@ -358,6 +362,7 @@ "ai:Failed to save configuration": "Failed to save configuration", "ai:Failed to save host selection.": "Failed to save host selection.", "ai:Failed to update host": "Failed to update host", + "ai:Failed to update infrastructure environment": "Failed to update infrastructure environment", "ai:Failed validations:": "Failed validations:", "ai:Failing infrastructure environment": "Failing infrastructure environment", "ai:Fence Agents Remediation requirements": "Fence Agents Remediation requirements", @@ -562,6 +567,7 @@ "ai:Minimum Memory": "Minimum Memory", "ai:Minimum memory for selected role": "Minimum memory for selected role", "ai:Minimum number of hosts": "Minimum number of hosts", + "ai:Missing cluster": "Missing cluster", "ai:Model": "Model", "ai:Modify your platform configuration to access your platform's features directly in OpenShift.": "Modify your platform configuration to access your platform's features directly in OpenShift.", "ai:More info for configure storage sizes": "More information for configure storage sizes", @@ -670,6 +676,7 @@ "ai:Operators": "Operators", "ai:Option 1: Add the following records to your DNS server (recommended)": "Option 1: Add the following records to your DNS server (recommended)", "ai:Option 2: Update your local /etc/hosts or /etc/resolv.conf files": "Option 2: Update your local /etc/hosts or /etc/resolv.conf files", + "ai:Optional configurations": "Optional configurations", "ai:Otherwise, the VMs will not be able to reboot during the installation process.": "Otherwise, the VMs will not be able to reboot during the installation process.", "ai:OVN separates the physical network topology from the logical one and is recommended if you're using new or telco features.": "OVN separates the physical network topology from the logical one and is recommended if you are using new or telco features.", "ai:Packet loss": "Packet loss", @@ -739,6 +746,7 @@ "ai:Removing {{name}} will remove the association with {{count}} host. These hosts will become available for other nodepools._plural": "Removing {{name}} will remove the association with {{count}} hosts. These hosts will become available for other nodepools.", "ai:Removing from cluster": "Removing from cluster", "ai:Rename hostnames using the custom template:": "Rename hostnames using the custom template:", + "ai:Rendezvous IP": "Rendezvous IP", "ai:Report a bug": "Report a bug", "ai:Required field": "Required field", "ai:Requirements for Two Node control plane OpenShift": "Requirements for Two Node control plane OpenShift", @@ -840,6 +848,7 @@ "ai:The hosts you selected are using different proxy settings. Configure a proxy that will be applied for these hosts. Configure at least one of the proxy settings below.": "The hosts you selected are using different proxy settings. Configure a proxy that will be applied for these hosts. Configure at least one of the following proxy settings.", "ai:The HTTP proxy URL that agents should use to access the discovery service.": "The HTTP proxy URL that agents should use to access the discovery service.", "ai:The IP address pool to use for service IP addresses. You can enter only one IP address pool. If you need to access the services from an external network, configure load balancers and routers to manage the traffic.": "The IP address pool to use for service IP addresses. You can enter only one IP address pool. If you need to access the services from an external network, configure load balancers and routers to manage the traffic.", + "ai:The IP address that hosts will use to communicate with the bootstrap node during installation.": "The IP address that hosts will use to communicate with the bootstrap node during installation.", "ai:The MAC address of the host's network connected NIC that will be used to provision the host.": "The MAC address of the host's network connected NIC that will be used to provision the host.", "ai:The output displays the following:": "The output displays the following:", "ai:The resource has been saved and the YAML is now read only.": "The resource has been saved and the YAML is now read only.", diff --git a/libs/types/assisted-installer-service.d.ts b/libs/types/assisted-installer-service.d.ts index 39e585e2cb..e1b70348f5 100644 --- a/libs/types/assisted-installer-service.d.ts +++ b/libs/types/assisted-installer-service.d.ts @@ -1734,7 +1734,7 @@ export interface ImageInfo { staticNetworkConfig?: string; type?: ImageType; } -export type ImageType = 'full-iso' | 'minimal-iso'; +export type ImageType = 'full-iso' | 'minimal-iso' | 'disconnected-iso'; export interface ImportClusterParams { /** * OpenShift cluster name. @@ -1830,6 +1830,14 @@ export interface InfraEnv { * certificates in this bundle. */ additionalTrustBundle?: string; + /** + * The IP address that hosts will use to communicate with the bootstrap node during installation. + */ + rendezvousIp?: string; + /** + * The type of network configuration for hosts: 'dhcp' for DHCP only, 'static' for static IP configuration. + */ + hostsNetworkConfigurationType?: 'dhcp' | 'static'; } export interface InfraEnvCreateParams { /** @@ -1875,6 +1883,14 @@ export interface InfraEnvCreateParams { * certificates in this bundle. */ additionalTrustBundle?: string; + /** + * The IP address that hosts will use to communicate with the bootstrap node during installation. + */ + rendezvousIp?: string; + /** + * The type of network configuration for hosts: 'dhcp' for DHCP only, 'static' for static IP configuration. + */ + hostsNetworkConfigurationType?: 'dhcp' | 'static'; } export type InfraEnvList = InfraEnv[]; export interface InfraEnvUpdateParams { @@ -1906,6 +1922,14 @@ export interface InfraEnvUpdateParams { * Version of the OS image */ openshiftVersion?: string; + /** + * The IP address that hosts will use to communicate with the bootstrap node during installation. + */ + rendezvousIp?: string; + /** + * The type of network configuration for hosts: 'dhcp' for DHCP only, 'static' for static IP configuration. + */ + hostsNetworkConfigurationType?: 'dhcp' | 'static'; } export interface InfraError { /** diff --git a/libs/ui-lib/lib/ocm/components/clusterConfiguration/DiscoveryImageTypeDropdown.tsx b/libs/ui-lib/lib/ocm/components/clusterConfiguration/DiscoveryImageTypeDropdown.tsx index 3ac9cd21b7..fa7e2f1ff0 100644 --- a/libs/ui-lib/lib/ocm/components/clusterConfiguration/DiscoveryImageTypeDropdown.tsx +++ b/libs/ui-lib/lib/ocm/components/clusterConfiguration/DiscoveryImageTypeDropdown.tsx @@ -20,6 +20,7 @@ export const discoveryImageTypes: Record = { 'minimal-iso': 'Minimal image file - Download an ISO that fetches content on boot', 'full-iso': 'Full image file - Download a self-contained ISO', 'discovery-image-ipxe': 'iPXE - Provision from your network server', + 'disconnected-iso': 'Disconnected ISO - Provision from a local file', }; type DiscoveryImageTypeDropdownProps = { diff --git a/libs/ui-lib/lib/ocm/components/clusterConfiguration/OcmDiscoveryImageConfigForm.tsx b/libs/ui-lib/lib/ocm/components/clusterConfiguration/OcmDiscoveryImageConfigForm.tsx index 7775c681a8..a29be2fdf3 100644 --- a/libs/ui-lib/lib/ocm/components/clusterConfiguration/OcmDiscoveryImageConfigForm.tsx +++ b/libs/ui-lib/lib/ocm/components/clusterConfiguration/OcmDiscoveryImageConfigForm.tsx @@ -15,7 +15,6 @@ import { import { Formik, FormikHelpers } from 'formik'; import { HostStaticNetworkConfig, - ImageType, InfraEnv, Proxy, } from '@openshift-assisted/types/assisted-installer-service'; @@ -70,7 +69,7 @@ type OcmDiscoveryImageConfigFormProps = Proxy & { formikActions: FormikHelpers, ) => Promise; sshPublicKey?: string; - imageType?: ImageType; + imageType?: DiscoveryImageType; isIpxeSelected?: boolean; enableCertificate?: boolean; trustBundle?: InfraEnv['additionalTrustBundle']; @@ -104,7 +103,7 @@ export const OcmDiscoveryImageConfigForm = ({ httpsProxy: httpsProxy || '', noProxy: noProxy || '', enableProxy: !!(httpProxy || httpsProxy || noProxy), - imageType: imageTypeValue as ImageType, + imageType: imageType, enableCertificate: enableCertificate || false, trustBundle: trustBundle || '', }; diff --git a/libs/ui-lib/lib/ocm/components/clusterWizard/ClusterWizardContextProvider.tsx b/libs/ui-lib/lib/ocm/components/clusterWizard/ClusterWizardContextProvider.tsx index 40c3c83991..86863ed601 100644 --- a/libs/ui-lib/lib/ocm/components/clusterWizard/ClusterWizardContextProvider.tsx +++ b/libs/ui-lib/lib/ocm/components/clusterWizard/ClusterWizardContextProvider.tsx @@ -76,6 +76,33 @@ const getWizardStepIds = ( return stepsCopy; }; +// Same logic as getWizardStepIds but for disconnected flow +// Adds static IP steps after 'disconnected-optional-configurations' instead of 'cluster-details' +const getDisconnectedWizardStepIds = ( + wizardStepIds: ClusterWizardStepsType[] | undefined, + staticIpView?: StaticIpView | 'dhcp-selected', +): ClusterWizardStepsType[] => { + let stepsCopy = wizardStepIds ? [...wizardStepIds] : [...disconnectedSteps]; + if (staticIpView === StaticIpView.YAML) { + stepsCopy = removeStepFromClusterWizard(stepsCopy, 'static-ip-network-wide-configurations', 2); + stepsCopy = addStepToClusterWizard(stepsCopy, 'disconnected-optional-configurations', [ + 'static-ip-yaml-view', + ]); + } else if (staticIpView === StaticIpView.FORM) { + stepsCopy = removeStepFromClusterWizard(stepsCopy, 'static-ip-yaml-view', 1); + stepsCopy = addStepToClusterWizard( + stepsCopy, + 'disconnected-optional-configurations', + staticIpFormViewSubSteps, + ); + } else if (staticIpView === 'dhcp-selected') { + stepsCopy = removeStepFromClusterWizard(stepsCopy, 'static-ip-network-wide-configurations', 2); + stepsCopy = removeStepFromClusterWizard(stepsCopy, 'static-ip-yaml-view', 1); + } + + return stepsCopy; +}; + const ClusterWizardContextProvider = ({ children, cluster, @@ -89,6 +116,8 @@ const ClusterWizardContextProvider = ({ const isSingleClusterFeatureEnabled = useFeature('ASSISTED_INSTALLER_SINGLE_CLUSTER_FEATURE'); const [currentStepId, setCurrentStepId] = React.useState(); const [connectedWizardStepIds, setWizardStepIds] = React.useState(); + const [disconnectedWizardStepIds, setDisconnectedWizardStepIds] = + React.useState(disconnectedSteps); const [wizardPerPage, setWizardPerPage] = React.useState(10); const [customManifestsStep, setCustomManifestsStep] = React.useState(false); const [installDisconnected, setInstallDisconnected] = React.useState(false); @@ -106,7 +135,7 @@ const ClusterWizardContextProvider = ({ const { clearAlerts, addAlert, alerts } = useAlerts(); const setClusterPermissions = useSetClusterPermissions(); - const wizardStepIds = installDisconnected ? disconnectedSteps : connectedWizardStepIds; + const wizardStepIds = installDisconnected ? disconnectedWizardStepIds : connectedWizardStepIds; React.useEffect(() => { if (!UISettingsLoading) { @@ -165,17 +194,29 @@ const ClusterWizardContextProvider = ({ const handleMoveFromStaticIp = () => { //if static ip view change wasn't persisted, moving from static ip step should change the wizard steps to match the view in the infra env - const staticIpInfo = infraEnv ? getStaticIpInfo(infraEnv) : undefined; + const currentInfraEnv = installDisconnected ? disconnectedInfraEnv : infraEnv; + const staticIpInfo = currentInfraEnv ? getStaticIpInfo(currentInfraEnv) : undefined; if (!staticIpInfo) { throw `Wizard step is currently ${currentStepId}, but no static ip info is defined`; } - const newStepIds = getWizardStepIds( - wizardStepIds, - staticIpInfo.view, - customManifestsStep, - isSingleClusterFeatureEnabled, - ); - setWizardStepIds(newStepIds); + + if (installDisconnected) { + // For disconnected wizard, update wizard steps directly (same pattern as connected) + const newStepIds = getDisconnectedWizardStepIds( + disconnectedWizardStepIds, + staticIpInfo.view, + ); + setDisconnectedWizardStepIds(newStepIds); + } else { + // For connected wizard, update wizard steps directly + const newStepIds = getWizardStepIds( + wizardStepIds, + staticIpInfo.view, + customManifestsStep, + isSingleClusterFeatureEnabled, + ); + setWizardStepIds(newStepIds); + } }; const onSetCurrentStepId = (stepId: ClusterWizardStepsType) => { @@ -219,31 +260,57 @@ const ClusterWizardContextProvider = ({ } else { setCurrentStepId('static-ip-network-wide-configurations'); } - setWizardStepIds( - getWizardStepIds(wizardStepIds, view, customManifestsStep, isSingleClusterFeatureEnabled), - ); - }, - onUpdateHostNetworkConfigType(type: HostsNetworkConfigurationType): void { - if (type === HostsNetworkConfigurationType.STATIC) { - setWizardStepIds( - getWizardStepIds( - wizardStepIds, - StaticIpView.FORM, - customManifestsStep, - isSingleClusterFeatureEnabled, - ), + if (installDisconnected) { + // For disconnected wizard, update wizard steps (same pattern as connected) + setDisconnectedWizardStepIds( + getDisconnectedWizardStepIds(disconnectedWizardStepIds, view), ); } else { setWizardStepIds( getWizardStepIds( wizardStepIds, - 'dhcp-selected', + view, customManifestsStep, isSingleClusterFeatureEnabled, ), ); } }, + onUpdateHostNetworkConfigType(type: HostsNetworkConfigurationType): void { + if (installDisconnected) { + // For disconnected wizard, update wizard steps (same pattern as connected) + if (type === HostsNetworkConfigurationType.STATIC) { + setDisconnectedWizardStepIds( + getDisconnectedWizardStepIds(disconnectedWizardStepIds, StaticIpView.FORM), + ); + } else { + setDisconnectedWizardStepIds( + getDisconnectedWizardStepIds(disconnectedWizardStepIds, 'dhcp-selected'), + ); + } + } else { + // For connected wizard, update wizard steps directly + if (type === HostsNetworkConfigurationType.STATIC) { + setWizardStepIds( + getWizardStepIds( + wizardStepIds, + StaticIpView.FORM, + customManifestsStep, + isSingleClusterFeatureEnabled, + ), + ); + } else { + setWizardStepIds( + getWizardStepIds( + wizardStepIds, + 'dhcp-selected', + customManifestsStep, + isSingleClusterFeatureEnabled, + ), + ); + } + } + }, wizardStepIds: wizardStepIds, currentStepId, setCurrentStepId: onSetCurrentStepId, @@ -276,8 +343,8 @@ const ClusterWizardContextProvider = ({ uiSettings, updateUISettings, installDisconnected, - setInstallDisconnected, connectedWizardStepIds, + disconnectedWizardStepIds, disconnectedInfraEnv, ]); diff --git a/libs/ui-lib/lib/ocm/components/clusterWizard/NewClusterWizard.tsx b/libs/ui-lib/lib/ocm/components/clusterWizard/NewClusterWizard.tsx index bb657e9dd3..7891e2edc5 100644 --- a/libs/ui-lib/lib/ocm/components/clusterWizard/NewClusterWizard.tsx +++ b/libs/ui-lib/lib/ocm/components/clusterWizard/NewClusterWizard.tsx @@ -5,7 +5,9 @@ import { useClusterWizardContext } from './ClusterWizardContext'; import ReviewStep from './disconnected/ReviewStep'; import BasicStep from './disconnected/BasicStep'; import OptionalConfigurationsStep from './disconnected/OptionalConfigurationsStep'; +import DisconnectedStaticIp from './disconnected/DisconnectedStaticIp'; import { ClusterWizardStepsType } from './wizardTransition'; +import { ModalDialogsContextProvider } from '../hosts/ModalDialogsContext'; const getCurrentStep = (currentStepId: ClusterWizardStepsType) => { switch (currentStepId) { @@ -15,6 +17,10 @@ const getCurrentStep = (currentStepId: ClusterWizardStepsType) => { return ; case 'disconnected-optional-configurations': return ; + case 'static-ip-yaml-view': + case 'static-ip-network-wide-configurations': + case 'static-ip-host-configurations': + return ; default: return ; } @@ -23,9 +29,11 @@ const getCurrentStep = (currentStepId: ClusterWizardStepsType) => { const NewClusterWizard: React.FC = () => { const { currentStepId } = useClusterWizardContext(); return ( -
- {getCurrentStep(currentStepId)} -
+ +
+ {getCurrentStep(currentStepId)} +
+
); }; diff --git a/libs/ui-lib/lib/ocm/components/clusterWizard/disconnected/BasicStep.tsx b/libs/ui-lib/lib/ocm/components/clusterWizard/disconnected/BasicStep.tsx index 184134c343..a4fc8a7556 100644 --- a/libs/ui-lib/lib/ocm/components/clusterWizard/disconnected/BasicStep.tsx +++ b/libs/ui-lib/lib/ocm/components/clusterWizard/disconnected/BasicStep.tsx @@ -43,7 +43,7 @@ const BasicStep = () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-call await ClustersService.registerDisconnected({ name: 'disconnected-cluster', - openshiftVersion: '4.20', + openshiftVersion: '4.22', }); disconnectedClusterId = disconnectedCluster.id; navigate(`${currentPath}/${disconnectedClusterId}`, { @@ -98,7 +98,7 @@ const BasicStep = () => {
- + {t('ai:Learn more about OpenShift releases')} diff --git a/libs/ui-lib/lib/ocm/components/clusterWizard/disconnected/DisconnectedStaticIp.tsx b/libs/ui-lib/lib/ocm/components/clusterWizard/disconnected/DisconnectedStaticIp.tsx new file mode 100644 index 0000000000..86e4e1068b --- /dev/null +++ b/libs/ui-lib/lib/ocm/components/clusterWizard/disconnected/DisconnectedStaticIp.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { ClusterWizardStep, getFormikErrorFields, useAlerts } from '../../../../common'; +import { useClusterWizardContext } from '../ClusterWizardContext'; +import ClusterWizardFooter from '../ClusterWizardFooter'; +import ClusterWizardNavigation from '../ClusterWizardNavigation'; +import { StaticIpFormState } from '../../clusterConfiguration/staticIp/components/propTypes'; +import { StaticIpPage } from '../../clusterConfiguration/staticIp/components/StaticIpPage'; +import { WithErrorBoundary } from '../../../../common/components/ErrorHandling/WithErrorBoundary'; +import { InfraEnvsAPI } from '../../../services/apis'; +import { InfraEnvUpdateParams } from '@openshift-assisted/types/assisted-installer-service'; + +const getInitialFormStateProps = (): StaticIpFormState => { + return { + isValid: true, + isSubmitting: false, + isAutoSaveRunning: false, + errors: {}, + touched: {}, + isEmpty: true, + }; +}; + +const DisconnectedStaticIp: React.FC = () => { + const { moveNext, moveBack, disconnectedInfraEnv, setDisconnectedInfraEnv } = + useClusterWizardContext(); + const { alerts } = useAlerts(); + const [formState, setFormStateProps] = React.useState( + getInitialFormStateProps(), + ); + + const onFormStateChange = (formState: StaticIpFormState) => { + setFormStateProps(formState); + }; + + const updateInfraEnv = async (params: InfraEnvUpdateParams) => { + if (!disconnectedInfraEnv?.id) { + throw new Error('No disconnected infraEnv available'); + } + const { data: updatedInfraEnv } = await InfraEnvsAPI.update(disconnectedInfraEnv.id, params); + setDisconnectedInfraEnv(updatedInfraEnv); + return updatedInfraEnv; + }; + + const isNextDisabled = + formState.isAutoSaveRunning || !formState.isValid || !!alerts.length || formState.isSubmitting; + const errorFields = getFormikErrorFields(formState.errors, formState.touched); + + const footer = ( + moveNext()} + onBack={() => moveBack()} + isNextDisabled={isNextDisabled} + isBackDisabled={formState.isSubmitting || formState.isAutoSaveRunning} + /> + ); + + if (!disconnectedInfraEnv) { + return null; + } + + return ( + } footer={footer}> + + + + + ); +}; + +export default DisconnectedStaticIp; diff --git a/libs/ui-lib/lib/ocm/components/clusterWizard/disconnected/OptionalConfigurationsStep.tsx b/libs/ui-lib/lib/ocm/components/clusterWizard/disconnected/OptionalConfigurationsStep.tsx index be3f90fe6c..d87c26509c 100644 --- a/libs/ui-lib/lib/ocm/components/clusterWizard/disconnected/OptionalConfigurationsStep.tsx +++ b/libs/ui-lib/lib/ocm/components/clusterWizard/disconnected/OptionalConfigurationsStep.tsx @@ -1,13 +1,20 @@ import * as React from 'react'; -import { Formik, useFormikContext } from 'formik'; +import { Formik, useFormikContext, yupToFormErrors } from 'formik'; import * as Yup from 'yup'; import { ClusterWizardStep, TechnologyPreview, - getRichTextValidation, sshPublicKeyValidationSchema, pullSecretValidationSchema, getFormikErrorFields, + httpProxyValidationSchema, + noProxyValidationSchema, + ntpSourceValidationSchema, + ipValidationSchema, + InputField, + CheckboxField, + AdditionalNTPSourcesField, + ProxyFieldsType, } from '../../../../common'; import { Split, SplitItem, Grid, GridItem, Form, Content, Checkbox } from '@patternfly/react-core'; import { useClusterWizardContext } from '../ClusterWizardContext'; @@ -16,6 +23,7 @@ import ClusterWizardNavigation from '../ClusterWizardNavigation'; import { WithErrorBoundary } from '../../../../common/components/ErrorHandling/WithErrorBoundary'; import UploadSSH from '../../../../common/components/clusterConfiguration/UploadSSH'; import PullSecretField from '../../../../common/components/ui/formik/PullSecretField'; +import { ProxyInputFields } from '../../../../common/components/clusterConfiguration/ProxyFields'; import { isInOcm, handleApiError, getApiErrorMessage } from '../../../../common/api'; import { useAlerts } from '../../../../common/components/AlertsContextProvider'; import { AlertVariant } from '@patternfly/react-core'; @@ -24,11 +32,93 @@ import { InfraEnvsAPI } from '../../../services/apis'; import usePullSecret from '../../../hooks/usePullSecret'; import { useParams } from 'react-router-dom-v5-compat'; import ClustersService from '../../../services/ClustersService'; -import { Cluster } from '@openshift-assisted/types/assisted-installer-service'; +import { + Cluster, + InfraEnvCreateParams, + InfraEnvUpdateParams, +} from '@openshift-assisted/types/assisted-installer-service'; +import { HostsNetworkConfigurationControlGroup } from '../../clusterConfiguration/HostsNetworkConfigurationControlGroup'; +import { HostsNetworkConfigurationType } from '../../../services/types'; +import { useTranslation } from '../../../../common/hooks/use-translation-wrapper'; +import { getDummyInfraEnvField } from '../../clusterConfiguration/staticIp/data/dummyData'; + +const DEFAULT_CPU_ARCHITECTURE = 'x86_64' as const; +const DISCONNECTED_IMAGE_TYPE = 'disconnected-iso' as const; -type OptionalConfigurationsFormValues = { +type OptionalConfigurationsFormValues = ProxyFieldsType & { sshPublicKey?: string; - pullSecret?: string; + pullSecret: string; + enableNtpSources: boolean; + additionalNtpSources?: string; + hostsNetworkConfigurationType: HostsNetworkConfigurationType; + rendezvousIp?: string; +}; + +/** + * Builds common infrastructure environment params from form values + */ +const buildInfraEnvParams = (values: OptionalConfigurationsFormValues) => { + // Build proxy object - only include fields that have values + const proxy = { + ...(values.httpProxy && { httpProxy: values.httpProxy }), + ...(values.httpsProxy && { httpsProxy: values.httpsProxy }), + ...(values.noProxy && { noProxy: values.noProxy }), + }; + const hasProxy = Object.keys(proxy).length > 0; + + return { + pullSecret: values.pullSecret, + ...(values.sshPublicKey && { sshAuthorizedKey: values.sshPublicKey }), + ...(hasProxy && { proxy }), + ...(values.additionalNtpSources && { + additionalNtpSources: values.additionalNtpSources, + }), + ...(values.rendezvousIp && { rendezvousIp: values.rendezvousIp }), + // Initialize with dummy static network config when static IP is selected + // This is required for the StaticIpPage to render properly + ...(values.hostsNetworkConfigurationType === HostsNetworkConfigurationType.STATIC && { + staticNetworkConfig: getDummyInfraEnvField(), + }), + }; +}; + +const getValidationSchema = (values: OptionalConfigurationsFormValues) => + Yup.object().shape({ + sshPublicKey: sshPublicKeyValidationSchema, + pullSecret: pullSecretValidationSchema.required('Pull secret is required'), + enableProxy: Yup.boolean().required(), + httpProxy: httpProxyValidationSchema({ + values, + pairValueName: 'httpsProxy', + allowEmpty: true, + }), + httpsProxy: httpProxyValidationSchema({ + values, + pairValueName: 'httpProxy', + allowEmpty: true, + }), + noProxy: noProxyValidationSchema, + enableNtpSources: Yup.boolean().required(), + additionalNtpSources: ntpSourceValidationSchema, + hostsNetworkConfigurationType: Yup.string() + .oneOf(Object.values(HostsNetworkConfigurationType)) + .required(), + rendezvousIp: Yup.string() + .max(45, 'IP address must be at most 45 characters') + .test( + 'ip-validation', + 'Not a valid IP address', + (value) => !value || ipValidationSchema.isValidSync(value), + ), + }); + +const validate = (values: OptionalConfigurationsFormValues) => { + try { + getValidationSchema(values).validateSync(values, { abortEarly: false }); + return {}; + } catch (error) { + return yupToFormErrors(error); + } }; const PullSecretSync: React.FC<{ pullSecret?: string }> = ({ pullSecret }) => { @@ -47,11 +137,13 @@ const OptionalConfigurationsStep = () => { const pullSecret = usePullSecret() || ''; const { clusterId } = useParams<{ clusterId: string }>(); const [cluster, setCluster] = React.useState(null); + const { t } = useTranslation(); const { moveNext, moveBack, setDisconnectedInfraEnv, disconnectedInfraEnv } = useClusterWizardContext(); const { addAlert } = useAlerts(); - const [editPullSecret, setEditPullSecret] = React.useState(false); + // If no pull secret is available, default the checkbox to checked so the field is expanded + const [editPullSecret, setEditPullSecret] = React.useState(!pullSecret); React.useEffect(() => { const fetchCluster = async () => { @@ -64,7 +156,7 @@ const OptionalConfigurationsStep = () => { } catch (error) { handleApiError(error, () => { addAlert({ - title: 'Failed to fetch cluster', + title: t('ai:Failed to fetch cluster'), message: getApiErrorMessage(error), variant: AlertVariant.danger, }); @@ -72,71 +164,70 @@ const OptionalConfigurationsStep = () => { } }; void fetchCluster(); - }, [clusterId, addAlert]); - - const validationSchema = React.useMemo( - () => - Yup.object().shape({ - sshPublicKey: sshPublicKeyValidationSchema, - pullSecret: pullSecretValidationSchema, - }), - [], - ); + }, [clusterId, addAlert, t]); const initialValues: OptionalConfigurationsFormValues = { sshPublicKey: '', pullSecret: pullSecret, + enableProxy: false, + httpProxy: '', + httpsProxy: '', + noProxy: '', + enableNtpSources: false, + additionalNtpSources: '', + hostsNetworkConfigurationType: HostsNetworkConfigurationType.DHCP, + rendezvousIp: '', }; return ( (validationSchema)} + validateOnMount + validate={validate} onSubmit={async (values) => { - if (!cluster || !cluster.id) { + if (!cluster?.id) { addAlert({ - title: 'Missing cluster', - message: 'Cluster must be created before configuring infrastructure environment', + title: t('ai:Missing cluster'), + message: t('ai:Cluster must be created before configuring infrastructure environment'), variant: AlertVariant.danger, }); return; } + const commonParams = buildInfraEnvParams(values); + try { - // Check if infraEnv already exists - if (disconnectedInfraEnv && disconnectedInfraEnv.id) { + if (disconnectedInfraEnv?.id) { // Update existing infraEnv - const updateParams = { - pullSecret: values.pullSecret || pullSecret, - ...(values.sshPublicKey && { sshAuthorizedKey: values.sshPublicKey }), + const updateParams: InfraEnvUpdateParams = { + ...commonParams, + imageType: DISCONNECTED_IMAGE_TYPE, }; const { data: updatedInfraEnv } = await InfraEnvsAPI.update( disconnectedInfraEnv.id, updateParams, ); setDisconnectedInfraEnv(updatedInfraEnv); - moveNext(); } else { - // Create infraEnv with all params - const createParams = { - name: `disconnected-cluster_infra-env`, - pullSecret: values.pullSecret || pullSecret, + // Create new infraEnv + const createParams: InfraEnvCreateParams = { + name: InfraEnvsService.makeInfraEnvName(DEFAULT_CPU_ARCHITECTURE, cluster.name), clusterId: cluster.id, openshiftVersion: cluster.openshiftVersion, - cpuArchitecture: 'x86_64' as const, - ...(values.sshPublicKey && { sshAuthorizedKey: values.sshPublicKey }), + cpuArchitecture: DEFAULT_CPU_ARCHITECTURE, + imageType: DISCONNECTED_IMAGE_TYPE, + ...commonParams, }; const createdInfraEnv = await InfraEnvsService.create(createParams); setDisconnectedInfraEnv(createdInfraEnv); - moveNext(); } + moveNext(); } catch (error) { handleApiError(error, () => { addAlert({ - title: - disconnectedInfraEnv && disconnectedInfraEnv.id - ? 'Failed to update infrastructure environment' - : 'Failed to create infrastructure environment', + title: disconnectedInfraEnv?.id + ? t('ai:Failed to update infrastructure environment') + : t('ai:Failed to create infrastructure environment'), message: getApiErrorMessage(error), variant: AlertVariant.danger, }); @@ -144,7 +235,7 @@ const OptionalConfigurationsStep = () => { } }} > - {({ submitForm, isValid, errors, touched, isSubmitting }) => { + {({ submitForm, isValid, errors, touched, isSubmitting, values }) => { const errorFields = getFormikErrorFields(errors, touched); const handleNext = () => { void submitForm(); // This will trigger onSubmit @@ -170,7 +261,7 @@ const OptionalConfigurationsStep = () => { - Optional configurations + {t('ai:Optional configurations')} @@ -179,14 +270,70 @@ const OptionalConfigurationsStep = () => { + {/* Rendezvous IP */} + + setEditPullSecret(checked)} id="edit-pull-secret-checkbox" /> {(editPullSecret || !pullSecret) && } + + {/* Proxy Settings */} + + {t( + 'ai:If hosts are behind a firewall that requires the use of a proxy, provide additional information about the proxy.', + )} +

+ } + body={values.enableProxy && } + /> + + {/* NTP Configuration */} + + {t( + 'ai:Configure your own NTP sources to synchronize the time between the hosts that will be added to this infrastructure environment.', + )} +

+ } + body={ + values.enableNtpSources && ( + + + + ) + } + /> + + {/* Network Configuration */} +
diff --git a/libs/ui-lib/lib/ocm/components/clusterWizard/disconnected/ReviewStep.tsx b/libs/ui-lib/lib/ocm/components/clusterWizard/disconnected/ReviewStep.tsx index 8bc7035e35..5cb806632d 100644 --- a/libs/ui-lib/lib/ocm/components/clusterWizard/disconnected/ReviewStep.tsx +++ b/libs/ui-lib/lib/ocm/components/clusterWizard/disconnected/ReviewStep.tsx @@ -25,7 +25,6 @@ import { Content, } from '@patternfly/react-core'; import { Formik } from 'formik'; -import { saveAs } from 'file-saver'; import { useNavigate, useParams } from 'react-router-dom-v5-compat'; import { getOperatorSpecs } from '../../../../common/components/operators/operatorSpecs'; @@ -55,7 +54,26 @@ const ReviewStep = () => { onNext={() => { void (async () => { if (disconnectedInfraEnv?.downloadUrl) { - saveAs(disconnectedInfraEnv.downloadUrl); + // Open download in new tab - we need the window reference to detect when download starts + const downloadWindow = window.open(disconnectedInfraEnv.downloadUrl, '_blank'); + + // Wait for the download tab to close (indicates download has started) + // The tab closes automatically when browser initiates the file download + if (downloadWindow) { + await new Promise((resolve) => { + const checkClosed = setInterval(() => { + if (downloadWindow.closed) { + clearInterval(checkClosed); + resolve(); + } + }, 200); + // Fallback timeout in case the window doesn't close (e.g., popup blocker) + setTimeout(() => { + clearInterval(checkClosed); + resolve(); + }, 10000); + }); + } } if (clusterId) { try { @@ -128,9 +146,17 @@ const ReviewStep = () => { + {disconnectedInfraEnv?.rendezvousIp && ( + + Controller Ip + + {disconnectedInfraEnv?.rendezvousIp} + + + )} OpenShift version - 4.20 + 4.22 CPU architecture