diff --git a/app/kubernetes/models/application/models/constants.js b/app/kubernetes/models/application/models/constants.js index 735a634b4..dead6e7f3 100644 --- a/app/kubernetes/models/application/models/constants.js +++ b/app/kubernetes/models/application/models/constants.js @@ -28,7 +28,6 @@ export const KubernetesApplicationPublishingTypes = Object.freeze({ CLUSTER_IP: 1, NODE_PORT: 2, LOAD_BALANCER: 3, - INGRESS: 4, }); export const KubernetesApplicationPlacementTypes = Object.freeze({ diff --git a/app/kubernetes/react/components/index.ts b/app/kubernetes/react/components/index.ts index 2c4574286..74650b880 100644 --- a/app/kubernetes/react/components/index.ts +++ b/app/kubernetes/react/components/index.ts @@ -117,13 +117,6 @@ withFormValidation( ngModule, withUIRouter(withCurrentUser(withReactQuery(KubeServicesForm))), 'kubeServicesForm', - [ - 'values', - 'onChange', - 'loadBalancerEnabled', - 'appName', - 'selector', - 'isEditMode', - ], + ['values', 'onChange', 'appName', 'selector', 'isEditMode'], kubeServicesValidation ); diff --git a/app/kubernetes/views/applications/create/createApplication.html b/app/kubernetes/views/applications/create/createApplication.html index b643556be..2c7606b49 100644 --- a/app/kubernetes/views/applications/create/createApplication.html +++ b/app/kubernetes/views/applications/create/createApplication.html @@ -1339,7 +1339,6 @@ - + {children} diff --git a/app/react/kubernetes/applications/CreateView/application-services/ClusterIpForm.tsx b/app/react/kubernetes/applications/CreateView/application-services/ClusterIpForm.tsx deleted file mode 100644 index 159a9d87c..000000000 --- a/app/react/kubernetes/applications/CreateView/application-services/ClusterIpForm.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { FormikErrors } from 'formik'; -import { ChangeEvent } from 'react'; -import { Plus, Trash2 } from 'lucide-react'; - -import { FormError } from '@@/form-components/FormError'; -import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector'; -import { Button } from '@@/buttons'; - -import { isServicePortError, newPort } from './utils'; -import { ServicePort } from './types'; -import { ServicePortInput } from './ServicePortInput'; -import { ContainerPortInput } from './ContainerPortInput'; - -interface Props { - values: ServicePort[]; - onChange: (servicePorts: ServicePort[]) => void; - serviceName?: string; - errors?: string | string[] | FormikErrors[]; -} - -export function ClusterIpForm({ - values: servicePorts, - onChange, - errors, - serviceName, -}: Props) { - const newClusterIpPort = newPort(serviceName); - return ( - <> -
Published ports
-
- {servicePorts.map((servicePort, index) => { - const error = errors?.[index]; - const servicePortError = isServicePortError(error) - ? error - : undefined; - - return ( -
-
- ) => { - const newServicePorts = [...servicePorts]; - const newValue = - e.target.value === '' - ? undefined - : Number(e.target.value); - newServicePorts[index] = { - ...newServicePorts[index], - targetPort: newValue, - port: newValue, - }; - onChange(newServicePorts); - }} - /> - {servicePortError?.targetPort && ( - {servicePortError.targetPort} - )} -
- -
- ) => { - const newServicePorts = [...servicePorts]; - newServicePorts[index] = { - ...newServicePorts[index], - port: - e.target.value === '' - ? undefined - : Number(e.target.value), - }; - onChange(newServicePorts); - }} - /> - {servicePortError?.port && ( - {servicePortError.port} - )} -
- { - const newServicePorts = [...servicePorts]; - newServicePorts[index] = { - ...newServicePorts[index], - protocol: value, - }; - onChange(newServicePorts); - }} - value={servicePort.protocol || 'TCP'} - options={[{ value: 'TCP' }, { value: 'UDP' }]} - /> -
- ); - })} -
- -
-
- - ); -} diff --git a/app/react/kubernetes/applications/CreateView/application-services/ClusterIpServiceForm.tsx b/app/react/kubernetes/applications/CreateView/application-services/ClusterIpServiceForm.tsx new file mode 100644 index 000000000..307261558 --- /dev/null +++ b/app/react/kubernetes/applications/CreateView/application-services/ClusterIpServiceForm.tsx @@ -0,0 +1,167 @@ +import { FormikErrors } from 'formik'; +import { ChangeEvent } from 'react'; +import { Plus, Trash2 } from 'lucide-react'; + +import { FormError } from '@@/form-components/FormError'; +import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector'; +import { Button } from '@@/buttons'; +import { Card } from '@@/Card'; +import { Widget } from '@@/Widget'; + +import { isServicePortError, newPort } from './utils'; +import { ServiceFormValues, ServicePort } from './types'; +import { ServicePortInput } from './ServicePortInput'; +import { ContainerPortInput } from './ContainerPortInput'; + +interface Props { + services: ServiceFormValues[]; + serviceIndex: number; + onChangeService: (services: ServiceFormValues[]) => void; + servicePorts: ServicePort[]; + onChangePort: (servicePorts: ServicePort[]) => void; + serviceName?: string; + errors?: string | string[] | FormikErrors[]; +} + +export function ClusterIpServiceForm({ + services, + serviceIndex, + onChangeService, + servicePorts, + onChangePort, + errors, + serviceName, +}: Props) { + const newClusterIpPort = newPort(serviceName); + return ( + + +
+
ClusterIP service
+ +
+
Ports
+
+ {servicePorts.map((servicePort, portIndex) => { + const error = errors?.[portIndex]; + const servicePortError = isServicePortError(error) + ? error + : undefined; + + return ( + +
+
+ ) => { + const newServicePorts = [...servicePorts]; + const newValue = + e.target.value === '' + ? undefined + : Number(e.target.value); + newServicePorts[portIndex] = { + ...newServicePorts[portIndex], + targetPort: newValue, + port: newValue, + }; + onChangePort(newServicePorts); + }} + /> + {servicePortError?.targetPort && ( + {servicePortError.targetPort} + )} +
+ +
+ ) => { + const newServicePorts = [...servicePorts]; + newServicePorts[portIndex] = { + ...newServicePorts[portIndex], + port: + e.target.value === '' + ? undefined + : Number(e.target.value), + }; + onChangePort(newServicePorts); + }} + /> + {servicePortError?.port && ( + {servicePortError.port} + )} +
+ { + const newServicePorts = [...servicePorts]; + newServicePorts[portIndex] = { + ...newServicePorts[portIndex], + protocol: value, + }; + onChangePort(newServicePorts); + }} + value={servicePort.protocol || 'TCP'} + options={[{ value: 'TCP' }, { value: 'UDP' }]} + /> +
+ +
+ ); + })} +
+ +
+
+
+
+ ); +} diff --git a/app/react/kubernetes/applications/CreateView/application-services/ClusterIpServicesForm.tsx b/app/react/kubernetes/applications/CreateView/application-services/ClusterIpServicesForm.tsx new file mode 100644 index 000000000..8eba13e2c --- /dev/null +++ b/app/react/kubernetes/applications/CreateView/application-services/ClusterIpServicesForm.tsx @@ -0,0 +1,92 @@ +import { Plus } from 'lucide-react'; +import { FormikErrors } from 'formik'; + +import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models'; + +import { Card } from '@@/Card'; +import { TextTip } from '@@/Tip/TextTip'; +import { Button } from '@@/buttons'; + +import { generateUniqueName, newPort, serviceFormDefaultValues } from './utils'; +import { ServiceFormValues, ServicePort } from './types'; +import { ClusterIpServiceForm } from './ClusterIpServiceForm'; + +interface Props { + services: ServiceFormValues[]; + onChangeService: (services: ServiceFormValues[]) => void; + errors?: FormikErrors; + appName: string; + selector: Record; +} + +export function ClusterIpServicesForm({ + services, + onChangeService, + errors, + appName, + selector, +}: Props) { + const clusterIPServiceCount = services.filter( + (service) => + service.Type === KubernetesApplicationPublishingTypes.CLUSTER_IP + ).length; + return ( + +
+ + Publish internally in the cluster via a{' '} + ClusterIP service, optionally exposing externally to the + outside world via an ingress. + + {clusterIPServiceCount > 0 && ( +
+ {services.map((service, index) => + service.Type === + KubernetesApplicationPublishingTypes.CLUSTER_IP ? ( + { + const newServices = [...services]; + newServices[index].Ports = servicePorts; + onChangeService(newServices); + }} + services={services} + serviceIndex={index} + onChangeService={onChangeService} + /> + ) : null + )} +
+ )} +
+ +
+
+
+ ); +} diff --git a/app/react/kubernetes/applications/CreateView/application-services/KubeServicesForm.tsx b/app/react/kubernetes/applications/CreateView/application-services/KubeServicesForm.tsx index 7b43113cb..37a29c3cf 100644 --- a/app/react/kubernetes/applications/CreateView/application-services/KubeServicesForm.tsx +++ b/app/react/kubernetes/applications/CreateView/application-services/KubeServicesForm.tsx @@ -1,61 +1,37 @@ import { SchemaOf, array, boolean, mixed, number, object, string } from 'yup'; -import { useEffect, useState } from 'react'; -import { SingleValue } from 'react-select'; -import { List, Plus, Trash2 } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; import { FormikErrors } from 'formik'; -import DataFlow from '@/assets/ico/dataflow-1.svg?c'; import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models'; -import { useCurrentUser } from '@/react/hooks/useUser'; -import { Link } from '@@/Link'; -import { TextTip } from '@@/Tip/TextTip'; -import { Select } from '@@/form-components/ReactSelect'; -import { Button } from '@@/buttons'; -import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren'; -import { Icon } from '@@/Icon'; -import { FormError } from '@@/form-components/FormError'; +import { Badge } from '@@/Badge'; -import { ServiceFormValues, ServicePort, ServiceTypeValue } from './types'; -import { LoadBalancerForm } from './LoadBalancerForm'; -import { ClusterIpForm } from './ClusterIpForm'; -import { NodePortForm } from './NodePortForm'; -import { newPort } from './utils'; +import { + ServiceFormValues, + ServicePort, + ServiceTypeAngularEnum, + ServiceTypeOption, + ServiceTypeValue, +} from './types'; +import { generateUniqueName } from './utils'; +import { ClusterIpServicesForm } from './ClusterIpServicesForm'; +import { ServiceTabs } from './ServiceTabs'; +import { NodePortServicesForm } from './NodePortServicesForm'; +import { LoadBalancerServicesForm } from './LoadBalancerServicesForm'; -type ServiceTypeLabel = 'ClusterIP' | 'NodePort' | 'LoadBalancer'; -type ServiceTypeOption = { value: ServiceTypeValue; label: ServiceTypeLabel }; -const serviceTypeOptions: ServiceTypeOption[] = [ - { - value: KubernetesApplicationPublishingTypes.CLUSTER_IP, - label: 'ClusterIP', - }, - { value: KubernetesApplicationPublishingTypes.NODE_PORT, label: 'NodePort' }, - { - value: KubernetesApplicationPublishingTypes.LOAD_BALANCER, - label: 'LoadBalancer', - }, -]; - -const serviceFormDefaultValues: ServiceFormValues = { - Headless: false, - Namespace: '', - Name: '', - StackName: '', - Ports: [], - Type: 1, // clusterip type as default - ClusterIP: '', - ApplicationName: '', - ApplicationOwner: '', - Note: '', - Ingress: false, - Selector: {}, +const serviceTypeEnumsToValues: Record< + ServiceTypeAngularEnum, + ServiceTypeValue +> = { + [KubernetesApplicationPublishingTypes.CLUSTER_IP]: 'ClusterIP', + [KubernetesApplicationPublishingTypes.NODE_PORT]: 'NodePort', + [KubernetesApplicationPublishingTypes.LOAD_BALANCER]: 'LoadBalancer', }; interface Props { values: ServiceFormValues[]; - onChange: (loadBalancerPorts: ServiceFormValues[]) => void; + onChange: (services: ServiceFormValues[]) => void; errors?: FormikErrors; - loadBalancerEnabled: boolean; appName: string; selector: Record; isEditMode: boolean; @@ -65,15 +41,12 @@ export function KubeServicesForm({ values: services, onChange, errors, - loadBalancerEnabled, appName, selector, isEditMode, }: Props) { - const { isAdmin } = useCurrentUser(); - const [selectedServiceTypeOption, setSelectedServiceTypeOption] = useState< - SingleValue - >(serviceTypeOptions[0]); // ClusterIP is the default value + const [selectedServiceType, setSelectedServiceType] = + useState('ClusterIP'); // when the appName changes, update the names for each service // and the serviceNames for each service port @@ -93,210 +66,93 @@ export function KubeServicesForm({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [appName]); + const serviceTypeCounts = useMemo( + () => getServiceTypeCounts(services), + [services] + ); + const serviceTypeOptions: ServiceTypeOption[] = [ + { + value: 'ClusterIP', + label: ( +
+ ClusterIP services + {serviceTypeCounts.ClusterIP && ( + + {serviceTypeCounts.ClusterIP} + + )} +
+ ), + }, + { + value: 'NodePort', + label: ( +
+ NodePort services + {serviceTypeCounts.NodePort && ( + + {serviceTypeCounts.NodePort} + + )} +
+ ), + }, + { + value: 'LoadBalancer', + label: ( +
+ LoadBalancer services + {serviceTypeCounts.LoadBalancer && ( + + {serviceTypeCounts.LoadBalancer} + + )} +
+ ), + }, + ]; + return ( - <> +
Publishing the application
-
-
- - Publish your application by creating a ClusterIP service for it, - which you may then expose via{' '} - - an ingress - - . - -
-
-
- - options={serviceTypeOptions} - value={selectedServiceTypeOption} - className="w-1/4" - data-cy="k8sAppCreate-publishingModeDropdown" - onChange={(val) => { - setSelectedServiceTypeOption(val); - }} + + {selectedServiceType === 'ClusterIP' && ( + - - - - - -
-
- {selectedServiceTypeOption?.value === - KubernetesApplicationPublishingTypes.LOAD_BALANCER && - isAdmin && - !loadBalancerEnabled && ( - - No Load balancer is available in this cluster, click{' '} - - here - {' '} - to configure load balancer. - - )} - {selectedServiceTypeOption?.value === - KubernetesApplicationPublishingTypes.LOAD_BALANCER && - !isAdmin && - !loadBalancerEnabled && ( - - No Load balancer is available in this cluster, contact your - administrator. - - )} - {services.map((service, index) => ( -
- {service.Type === - KubernetesApplicationPublishingTypes.CLUSTER_IP && ( - <> -
- - ClusterIP -
- { - const newServices = [...services]; - newServices[index].Ports = servicePorts; - onChange(newServices); - }} - /> - - )} - {service.Type === - KubernetesApplicationPublishingTypes.NODE_PORT && ( - <> -
- - NodePort -
- { - const newServices = [...services]; - newServices[index].Ports = servicePorts; - onChange(newServices); - }} - /> - - )} - {service.Type === - KubernetesApplicationPublishingTypes.LOAD_BALANCER && ( - <> -
- - LoadBalancer -
- { - const newServices = [...services]; - newServices[index].Ports = servicePorts; - onChange(newServices); - }} - loadBalancerEnabled={loadBalancerEnabled} - /> - - )} - -
- ))} -
- + )} + {selectedServiceType === 'NodePort' && ( + + )} + {selectedServiceType === 'LoadBalancer' && ( + + )} +
); } -function generateIndexedName(appName: string, index: number) { - return index === 0 ? appName : `${appName}-${index}`; -} - -function isNameUnique(name: string, services: ServiceFormValues[]) { - return services.findIndex((service) => service.Name === name) === -1; -} - -function generateUniqueName( - appName: string, - index: number, - services: ServiceFormValues[] -) { - let initialIndex = index; - let uniqueName = appName; - - while (!isNameUnique(uniqueName, services)) { - uniqueName = generateIndexedName(appName, initialIndex); - initialIndex++; - } - - return uniqueName; -} - function getUniqNames(appName: string, services: ServiceFormValues[]) { const sortedServices = [...services].sort((a, b) => a.Name && b.Name ? a.Name.localeCompare(b.Name) : 0 @@ -317,6 +173,22 @@ function getUniqNames(appName: string, services: ServiceFormValues[]) { return uniqueNames; } +/** + * getServiceTypeCounts returns a map of service types to the number of services of that type + */ +function getServiceTypeCounts( + services: ServiceFormValues[] +): Record { + return services.reduce((acc, service) => { + const type = serviceTypeEnumsToValues[service.Type]; + const count = acc[type]; + return { + ...acc, + [type]: count ? count + 1 : 1, + }; + }, {} as Record); +} + // values returned from the angular parent component (pascal case instead of camel case keys), // these should match the form values, but don't. Future tech debt work to update this would be nice // to make the converted values and formValues objects to be the same @@ -525,6 +397,7 @@ export function kubeServicesValidation( ingress: object(), }) ), + Annotations: array(), }) ); } diff --git a/app/react/kubernetes/applications/CreateView/application-services/LoadBalancerForm.tsx b/app/react/kubernetes/applications/CreateView/application-services/LoadBalancerForm.tsx deleted file mode 100644 index a241e8f1c..000000000 --- a/app/react/kubernetes/applications/CreateView/application-services/LoadBalancerForm.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import { FormikErrors } from 'formik'; -import { ChangeEvent } from 'react'; -import { Plus, Trash2 } from 'lucide-react'; - -import { InputGroup } from '@@/form-components/InputGroup'; -import { FormError } from '@@/form-components/FormError'; -import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector'; -import { Button } from '@@/buttons'; - -import { isServicePortError, newPort } from './utils'; -import { ContainerPortInput } from './ContainerPortInput'; -import { ServicePortInput } from './ServicePortInput'; -import { ServicePort } from './types'; - -interface Props { - values: ServicePort[]; - onChange: (loadBalancerPorts: ServicePort[]) => void; - loadBalancerEnabled: boolean; - serviceName?: string; - errors?: string | string[] | FormikErrors[]; -} - -export function LoadBalancerForm({ - values: loadBalancerPorts, - onChange, - loadBalancerEnabled, - serviceName, - errors, -}: Props) { - const newLoadBalancerPort = newPort(serviceName); - return ( - <> - {loadBalancerEnabled && ( - <> -
- Published ports -
-
- {loadBalancerPorts.map((lbPort, index) => { - const error = errors?.[index]; - const servicePortError = isServicePortError(error) - ? error - : undefined; - - return ( -
-
- ) => { - const newServicePorts = [...loadBalancerPorts]; - const newValue = - e.target.value === '' - ? undefined - : Number(e.target.value); - newServicePorts[index] = { - ...newServicePorts[index], - targetPort: newValue, - port: newValue, - }; - onChange(newServicePorts); - }} - /> - {servicePortError?.targetPort && ( - {servicePortError.targetPort} - )} -
-
- ) => { - const newServicePorts = [...loadBalancerPorts]; - newServicePorts[index] = { - ...newServicePorts[index], - port: - e.target.value === '' - ? undefined - : Number(e.target.value), - }; - onChange(newServicePorts); - }} - /> - {servicePortError?.port && ( - {servicePortError.port} - )} -
-
- - - Loadbalancer port - - ) => { - const newServicePorts = [...loadBalancerPorts]; - newServicePorts[index] = { - ...newServicePorts[index], - port: - e.target.value === '' - ? undefined - : Number(e.target.value), - }; - onChange(newServicePorts); - }} - required - data-cy={`k8sAppCreate-loadbalancerPort_${index}`} - /> - - {servicePortError?.nodePort && ( - {servicePortError.nodePort} - )} -
- - { - const newServicePorts = [...loadBalancerPorts]; - newServicePorts[index] = { - ...newServicePorts[index], - protocol: value, - }; - onChange(newServicePorts); - }} - value={lbPort.protocol || 'TCP'} - options={[{ value: 'TCP' }, { value: 'UDP' }]} - /> -
- ); - })} -
- -
-
- - )} - - ); -} diff --git a/app/react/kubernetes/applications/CreateView/application-services/LoadBalancerServiceForm.tsx b/app/react/kubernetes/applications/CreateView/application-services/LoadBalancerServiceForm.tsx new file mode 100644 index 000000000..44f8ad0b8 --- /dev/null +++ b/app/react/kubernetes/applications/CreateView/application-services/LoadBalancerServiceForm.tsx @@ -0,0 +1,200 @@ +import { FormikErrors } from 'formik'; +import { ChangeEvent } from 'react'; +import { Plus, Trash2 } from 'lucide-react'; + +import { FormError } from '@@/form-components/FormError'; +import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector'; +import { Button } from '@@/buttons'; +import { Widget } from '@@/Widget'; +import { Card } from '@@/Card'; +import { InputGroup } from '@@/form-components/InputGroup'; + +import { isServicePortError, newPort } from './utils'; +import { ContainerPortInput } from './ContainerPortInput'; +import { ServicePortInput } from './ServicePortInput'; +import { ServiceFormValues, ServicePort } from './types'; + +interface Props { + services: ServiceFormValues[]; + serviceIndex: number; + onChangeService: (services: ServiceFormValues[]) => void; + servicePorts: ServicePort[]; + onChangePort: (servicePorts: ServicePort[]) => void; + serviceName?: string; + errors?: string | string[] | FormikErrors[]; +} + +export function LoadBalancerServiceForm({ + services, + serviceIndex, + onChangeService, + servicePorts, + onChangePort, + errors, + serviceName, +}: Props) { + const newLoadBalancerPort = newPort(serviceName); + return ( + + +
+
LoadBalancer service
+ +
+
Ports
+
+ {servicePorts.map((servicePort, portIndex) => { + const error = errors?.[portIndex]; + const servicePortError = isServicePortError(error) + ? error + : undefined; + + return ( + +
+
+ ) => { + const newServicePorts = [...servicePorts]; + const newValue = + e.target.value === '' + ? undefined + : Number(e.target.value); + newServicePorts[portIndex] = { + ...newServicePorts[portIndex], + targetPort: newValue, + port: newValue, + }; + onChangePort(newServicePorts); + }} + /> + {servicePortError?.targetPort && ( + {servicePortError.targetPort} + )} +
+ +
+ ) => { + const newServicePorts = [...servicePorts]; + newServicePorts[portIndex] = { + ...newServicePorts[portIndex], + port: + e.target.value === '' + ? undefined + : Number(e.target.value), + }; + onChangePort(newServicePorts); + }} + /> + {servicePortError?.port && ( + {servicePortError.port} + )} +
+
+ + + Loadbalancer port + + ) => { + const newServicePorts = [...servicePorts]; + newServicePorts[portIndex] = { + ...newServicePorts[portIndex], + port: + e.target.value === '' + ? undefined + : Number(e.target.value), + }; + onChangePort(newServicePorts); + }} + required + data-cy={`k8sAppCreate-loadbalancerPort_${portIndex}`} + /> + + {servicePortError?.nodePort && ( + {servicePortError.nodePort} + )} +
+ { + const newServicePorts = [...servicePorts]; + newServicePorts[portIndex] = { + ...newServicePorts[portIndex], + protocol: value, + }; + onChangePort(newServicePorts); + }} + value={servicePort.protocol || 'TCP'} + options={[{ value: 'TCP' }, { value: 'UDP' }]} + /> +
+ +
+ ); + })} +
+ +
+
+
+
+ ); +} diff --git a/app/react/kubernetes/applications/CreateView/application-services/LoadBalancerServicesForm.tsx b/app/react/kubernetes/applications/CreateView/application-services/LoadBalancerServicesForm.tsx new file mode 100644 index 000000000..37135521e --- /dev/null +++ b/app/react/kubernetes/applications/CreateView/application-services/LoadBalancerServicesForm.tsx @@ -0,0 +1,139 @@ +import { Plus, RotateCw } from 'lucide-react'; +import { FormikErrors } from 'formik'; + +import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models'; +import { useCurrentUser } from '@/react/hooks/useUser'; +import { useEnvironment } from '@/react/portainer/environments/queries'; +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; + +import { Card } from '@@/Card'; +import { TextTip } from '@@/Tip/TextTip'; +import { Button } from '@@/buttons'; +import { FormError } from '@@/form-components/FormError'; +import { Link } from '@@/Link'; + +import { generateUniqueName, newPort, serviceFormDefaultValues } from './utils'; +import { ServiceFormValues, ServicePort } from './types'; +import { LoadBalancerServiceForm } from './LoadBalancerServiceForm'; + +interface Props { + services: ServiceFormValues[]; + onChangeService: (services: ServiceFormValues[]) => void; + errors?: FormikErrors; + appName: string; + selector: Record; +} + +export function LoadBalancerServicesForm({ + services, + onChangeService, + errors, + appName, + selector, +}: Props) { + const { isAdmin } = useCurrentUser(); + const environmentId = useEnvironmentId(); + const { data: loadBalancerEnabled, ...loadBalancerEnabledQuery } = + useEnvironment( + environmentId, + (environment) => environment?.Kubernetes.Configuration.UseLoadBalancer + ); + + const loadBalancerServiceCount = services.filter( + (service) => + service.Type === KubernetesApplicationPublishingTypes.LOAD_BALANCER + ).length; + return ( + +
+ + Allow access to traffic external to the cluster via a{' '} + LoadBalancer service. If running on a cloud platform, this auto + provisions a cloud load balancer. + + {!loadBalancerEnabled && loadBalancerEnabledQuery.isSuccess && ( +
+ + {isAdmin ? ( + <> + Load balancer use is not currently enabled in this cluster. + Configure via{' '} + + Cluster Setup + {' '} + and then refresh this tab + + ) : ( + 'Load balancer use is not currently enabled in this cluster, contact your administrator.' + )} + +
+ +
+
+ )} + {loadBalancerServiceCount > 0 && ( +
+ {services.map((service, index) => + service.Type === + KubernetesApplicationPublishingTypes.LOAD_BALANCER ? ( + { + const newServices = [...services]; + newServices[index].Ports = servicePorts; + onChangeService(newServices); + }} + services={services} + serviceIndex={index} + onChangeService={onChangeService} + /> + ) : null + )} +
+ )} +
+ +
+
+
+ ); +} diff --git a/app/react/kubernetes/applications/CreateView/application-services/NodePortForm.tsx b/app/react/kubernetes/applications/CreateView/application-services/NodePortForm.tsx deleted file mode 100644 index 96becf7e0..000000000 --- a/app/react/kubernetes/applications/CreateView/application-services/NodePortForm.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { FormikErrors } from 'formik'; -import { ChangeEvent } from 'react'; -import { Plus, Trash2 } from 'lucide-react'; - -import { InputGroup } from '@@/form-components/InputGroup'; -import { FormError } from '@@/form-components/FormError'; -import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector'; -import { Button } from '@@/buttons'; - -import { isServicePortError, newPort } from './utils'; -import { ContainerPortInput } from './ContainerPortInput'; -import { ServicePortInput } from './ServicePortInput'; -import { ServicePort } from './types'; - -interface Props { - values: ServicePort[]; - onChange: (nodePorts: ServicePort[]) => void; - serviceName?: string; - errors?: string | string[] | FormikErrors[]; -} - -export function NodePortForm({ - values: nodePorts, - onChange, - errors, - serviceName, -}: Props) { - const newNodePortPort = newPort(serviceName); - return ( - <> -
Published ports
-
- {nodePorts.map((nodePort, index) => { - const error = errors?.[index]; - const servicePortError = isServicePortError(error) - ? error - : undefined; - - return ( -
-
- ) => { - const newServicePorts = [...nodePorts]; - const newValue = - e.target.value === '' - ? undefined - : Number(e.target.value); - newServicePorts[index] = { - ...newServicePorts[index], - targetPort: newValue, - port: newValue, - }; - onChange(newServicePorts); - }} - /> - {servicePortError?.targetPort && ( - {servicePortError.targetPort} - )} -
-
- ) => { - const newServicePorts = [...nodePorts]; - newServicePorts[index] = { - ...newServicePorts[index], - port: - e.target.value === '' - ? undefined - : Number(e.target.value), - }; - onChange(newServicePorts); - }} - /> - {servicePortError?.port && ( - {servicePortError.port} - )} -
-
- - Nodeport - ) => { - const newServicePorts = [...nodePorts]; - newServicePorts[index] = { - ...newServicePorts[index], - nodePort: - e.target.value === '' - ? undefined - : Number(e.target.value), - }; - onChange(newServicePorts); - }} - data-cy={`k8sAppCreate-nodePort_${index}`} - /> - - {servicePortError?.nodePort && ( - {servicePortError.nodePort} - )} -
- - { - const newServicePorts = [...nodePorts]; - newServicePorts[index] = { - ...newServicePorts[index], - protocol: value, - }; - onChange(newServicePorts); - }} - value={nodePort.protocol || 'TCP'} - options={[{ value: 'TCP' }, { value: 'UDP' }]} - /> -
- ); - })} -
- -
-
- - ); -} diff --git a/app/react/kubernetes/applications/CreateView/application-services/NodePortServiceForm.tsx b/app/react/kubernetes/applications/CreateView/application-services/NodePortServiceForm.tsx new file mode 100644 index 000000000..73a3ac98c --- /dev/null +++ b/app/react/kubernetes/applications/CreateView/application-services/NodePortServiceForm.tsx @@ -0,0 +1,197 @@ +import { FormikErrors } from 'formik'; +import { ChangeEvent } from 'react'; +import { Plus, Trash2 } from 'lucide-react'; + +import { FormError } from '@@/form-components/FormError'; +import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector'; +import { Button } from '@@/buttons'; +import { Widget } from '@@/Widget'; +import { Card } from '@@/Card'; +import { InputGroup } from '@@/form-components/InputGroup'; + +import { isServicePortError, newPort } from './utils'; +import { ContainerPortInput } from './ContainerPortInput'; +import { ServicePortInput } from './ServicePortInput'; +import { ServiceFormValues, ServicePort } from './types'; + +interface Props { + services: ServiceFormValues[]; + serviceIndex: number; + onChangeService: (services: ServiceFormValues[]) => void; + servicePorts: ServicePort[]; + onChangePort: (servicePorts: ServicePort[]) => void; + serviceName?: string; + errors?: string | string[] | FormikErrors[]; +} + +export function NodePortServiceForm({ + services, + serviceIndex, + onChangeService, + servicePorts, + onChangePort, + errors, + serviceName, +}: Props) { + const newNodePortPort = newPort(serviceName); + return ( + + +
+
NodePort service
+ +
+
Ports
+
+ {servicePorts.map((servicePort, portIndex) => { + const error = errors?.[portIndex]; + const servicePortError = isServicePortError(error) + ? error + : undefined; + + return ( + +
+
+ ) => { + const newServicePorts = [...servicePorts]; + const newValue = + e.target.value === '' + ? undefined + : Number(e.target.value); + newServicePorts[portIndex] = { + ...newServicePorts[portIndex], + targetPort: newValue, + port: newValue, + }; + onChangePort(newServicePorts); + }} + /> + {servicePortError?.targetPort && ( + {servicePortError.targetPort} + )} +
+ +
+ ) => { + const newServicePorts = [...servicePorts]; + newServicePorts[portIndex] = { + ...newServicePorts[portIndex], + port: + e.target.value === '' + ? undefined + : Number(e.target.value), + }; + onChangePort(newServicePorts); + }} + /> + {servicePortError?.port && ( + {servicePortError.port} + )} +
+
+ + Nodeport + ) => { + const newServicePorts = [...servicePorts]; + newServicePorts[portIndex] = { + ...newServicePorts[portIndex], + nodePort: + e.target.value === '' + ? undefined + : Number(e.target.value), + }; + onChangePort(newServicePorts); + }} + data-cy={`k8sAppCreate-nodePort_${portIndex}`} + /> + + {servicePortError?.nodePort && ( + {servicePortError.nodePort} + )} +
+ { + const newServicePorts = [...servicePorts]; + newServicePorts[portIndex] = { + ...newServicePorts[portIndex], + protocol: value, + }; + onChangePort(newServicePorts); + }} + value={servicePort.protocol || 'TCP'} + options={[{ value: 'TCP' }, { value: 'UDP' }]} + /> +
+ +
+ ); + })} +
+ +
+
+
+
+ ); +} diff --git a/app/react/kubernetes/applications/CreateView/application-services/NodePortServicesForm.tsx b/app/react/kubernetes/applications/CreateView/application-services/NodePortServicesForm.tsx new file mode 100644 index 000000000..3d26fdc89 --- /dev/null +++ b/app/react/kubernetes/applications/CreateView/application-services/NodePortServicesForm.tsx @@ -0,0 +1,90 @@ +import { FormikErrors } from 'formik'; +import { Plus } from 'lucide-react'; + +import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models'; + +import { Card } from '@@/Card'; +import { TextTip } from '@@/Tip/TextTip'; +import { Button } from '@@/buttons'; + +import { serviceFormDefaultValues, generateUniqueName, newPort } from './utils'; +import { ServiceFormValues, ServicePort } from './types'; +import { NodePortServiceForm } from './NodePortServiceForm'; + +interface Props { + services: ServiceFormValues[]; + onChangeService: (services: ServiceFormValues[]) => void; + errors?: FormikErrors; + appName: string; + selector: Record; +} + +export function NodePortServicesForm({ + services, + onChangeService, + errors, + appName, + selector, +}: Props) { + const nodePortServiceCount = services.filter( + (service) => service.Type === KubernetesApplicationPublishingTypes.NODE_PORT + ).length; + return ( + +
+ + Allow access to traffic external to the cluster via a{' '} + NodePort service. Not generally recommended for Production use. + + {nodePortServiceCount > 0 && ( +
+ {services.map((service, index) => + service.Type === + KubernetesApplicationPublishingTypes.NODE_PORT ? ( + { + const newServices = [...services]; + newServices[index].Ports = servicePorts; + onChangeService(newServices); + }} + services={services} + serviceIndex={index} + onChangeService={onChangeService} + /> + ) : null + )} +
+ )} +
+ +
+
+
+ ); +} diff --git a/app/react/kubernetes/applications/CreateView/application-services/ServiceTabs.tsx b/app/react/kubernetes/applications/CreateView/application-services/ServiceTabs.tsx new file mode 100644 index 000000000..dee3e829d --- /dev/null +++ b/app/react/kubernetes/applications/CreateView/application-services/ServiceTabs.tsx @@ -0,0 +1,43 @@ +import clsx from 'clsx'; + +import { ServiceTypeOption, ServiceTypeValue } from './types'; + +type Props = { + serviceTypeOptions: ServiceTypeOption[]; + selectedServiceType: ServiceTypeValue; + setSelectedServiceType: (serviceTypeValue: ServiceTypeValue) => void; +}; + +export function ServiceTabs({ + serviceTypeOptions, + selectedServiceType, + setSelectedServiceType, +}: Props) { + return ( +
+ {serviceTypeOptions.map(({ label }, index) => ( + + ))} +
+ ); +} diff --git a/app/react/kubernetes/applications/CreateView/application-services/types.ts b/app/react/kubernetes/applications/CreateView/application-services/types.ts index 6bfdec2e9..cb4c6135c 100644 --- a/app/react/kubernetes/applications/CreateView/application-services/types.ts +++ b/app/react/kubernetes/applications/CreateView/application-services/types.ts @@ -1,3 +1,7 @@ +import { ReactNode } from 'react'; + +import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models'; + export interface ServicePort { port?: number; targetPort?: number; @@ -8,12 +12,13 @@ export interface ServicePort { ingress?: object; } -export type ServiceTypeValue = 1 | 2 | 3; +export type ServiceTypeAngularEnum = + (typeof KubernetesApplicationPublishingTypes)[keyof typeof KubernetesApplicationPublishingTypes]; export type ServiceFormValues = { Headless: boolean; Ports: ServicePort[]; - Type: ServiceTypeValue; + Type: ServiceTypeAngularEnum; Ingress: boolean; ClusterIP?: string; ApplicationName?: string; @@ -24,3 +29,9 @@ export type ServiceFormValues = { Selector?: Record; Namespace?: string; }; + +export type ServiceTypeValue = 'ClusterIP' | 'NodePort' | 'LoadBalancer'; +export type ServiceTypeOption = { + value: ServiceTypeValue; + label: ReactNode; +}; diff --git a/app/react/kubernetes/applications/CreateView/application-services/utils.ts b/app/react/kubernetes/applications/CreateView/application-services/utils.ts index 10b806888..0b6885e4b 100644 --- a/app/react/kubernetes/applications/CreateView/application-services/utils.ts +++ b/app/react/kubernetes/applications/CreateView/application-services/utils.ts @@ -1,5 +1,7 @@ import { FormikErrors } from 'formik'; +import { ServiceFormValues } from './types'; + export function isServicePortError( error: string | FormikErrors | undefined ): error is FormikErrors { @@ -16,3 +18,42 @@ export function newPort(serviceName?: string) { serviceName, }; } + +function generateIndexedName(appName: string, index: number) { + return index === 0 ? appName : `${appName}-${index}`; +} + +function isNameUnique(name: string, services: ServiceFormValues[]) { + return services.findIndex((service) => service.Name === name) === -1; +} + +export function generateUniqueName( + appName: string, + index: number, + services: ServiceFormValues[] +) { + let initialIndex = index; + let uniqueName = appName; + + while (!isNameUnique(uniqueName, services)) { + uniqueName = generateIndexedName(appName, initialIndex); + initialIndex++; + } + + return uniqueName; +} + +export const serviceFormDefaultValues: ServiceFormValues = { + Headless: false, + Namespace: '', + Name: '', + StackName: '', + Ports: [], + Type: 1, // clusterip type as default + ClusterIP: '', + ApplicationName: '', + ApplicationOwner: '', + Note: '', + Ingress: false, + Selector: {}, +};