diff --git a/app/kubernetes/components/kube-services/kube-services-item/kube-services-item.controller.js b/app/kubernetes/components/kube-services/kube-services-item/kube-services-item.controller.js deleted file mode 100644 index c3d58176f..000000000 --- a/app/kubernetes/components/kube-services/kube-services-item/kube-services-item.controller.js +++ /dev/null @@ -1,95 +0,0 @@ -import _ from 'lodash-es'; -import { KubernetesServicePort } from 'Kubernetes/models/service/models'; -import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues'; -import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper'; -import { KubernetesApplicationPublishingTypes } from 'Kubernetes/models/application/models/constants'; - -export default class KubeServicesItemViewController { - /* @ngInject */ - constructor(EndpointProvider, Authentication) { - this.EndpointProvider = EndpointProvider; - this.Authentication = Authentication; - this.KubernetesApplicationPublishingTypes = KubernetesApplicationPublishingTypes; - } - - addPort() { - const port = new KubernetesServicePort(); - port.nodePort = ''; - port.port = ''; - port.targetPort = ''; - port.protocol = 'TCP'; - this.service.Ports.push(port); - } - - removePort(index) { - this.service.Ports.splice(index, 1); - } - - servicePort(index) { - const targetPort = this.service.Ports[index].targetPort; - this.service.Ports[index].port = targetPort; - this.onChangeServicePort(); - } - - isAdmin() { - return this.Authentication.isAdmin(); - } - - onChangeContainerPort() { - const state = this.state.duplicates.targetPort; - const source = _.map(this.service.Ports, (sp) => sp.targetPort); - const duplicates = KubernetesFormValidationHelper.getDuplicates(source); - state.refs = duplicates; - state.hasRefs = Object.keys(duplicates).length > 0; - } - - onChangeServicePort() { - const state = this.state.duplicates.servicePort; - const source = _.map(this.service.Ports, (sp) => sp.port); - const duplicates = KubernetesFormValidationHelper.getDuplicates(source); - state.refs = duplicates; - state.hasRefs = Object.keys(duplicates).length > 0; - - this.service.servicePortError = state.hasRefs; - } - - onChangeNodePort() { - const state = this.state.duplicates.nodePort; - - // create a list of all the node ports (number[]) in the cluster and current form - const clusterNodePortsWithoutCurrentService = this.nodePortServices - .filter((npService) => npService.Name !== this.service.Name) - .map((npService) => npService.Ports) - .flat() - .map((npServicePorts) => npServicePorts.NodePort); - const formNodePortsWithoutCurrentService = this.formServices - .filter((formService) => formService.Type === KubernetesApplicationPublishingTypes.NODE_PORT && formService.Name !== this.service.Name) - .map((formService) => formService.Ports) - .flat() - .map((formServicePorts) => formServicePorts.nodePort); - const serviceNodePorts = this.service.Ports.map((sp) => sp.nodePort); - // getDuplicates cares about the index, so put the serviceNodePorts at the start - const allNodePortsWithoutCurrentService = [...clusterNodePortsWithoutCurrentService, ...formNodePortsWithoutCurrentService]; - - const duplicates = KubernetesFormValidationHelper.getDuplicateNodePorts(serviceNodePorts, allNodePortsWithoutCurrentService); - state.refs = duplicates; - state.hasRefs = Object.keys(duplicates).length > 0; - - this.service.nodePortError = state.hasRefs; - } - - $onInit() { - if (this.service.Ports.length === 0) { - this.addPort(); - } - - this.state = { - duplicates: { - targetPort: new KubernetesFormValidationReferences(), - servicePort: new KubernetesFormValidationReferences(), - nodePort: new KubernetesFormValidationReferences(), - }, - endpointId: this.EndpointProvider.endpointID(), - }; - } -} diff --git a/app/kubernetes/components/kube-services/kube-services-item/kube-services-item.html b/app/kubernetes/components/kube-services/kube-services-item/kube-services-item.html deleted file mode 100644 index 813b0ca1b..000000000 --- a/app/kubernetes/components/kube-services/kube-services-item/kube-services-item.html +++ /dev/null @@ -1,188 +0,0 @@ - -
-

- No Load balancer is available in this cluster, click - here to configure load balancer. -

-
-
-

- No Load balancer is available in this cluster, contact your administrator. -

-
- -
-
- -
-
-
-
- Container port - -
- -
- This container port is already used. -
-
-

Container port number is required.

-

Container port number must be inside the range 1-65535.

-

Container port number must be inside the range 1-65535.

-
-
-
- -
-
- Service port - -
- -
- This service port is already used. -
-
-
-

Service port number is required.

-

Service port number must be inside the range 1-65535.

-

Service port number must be inside the range 1-65535.

-
-
-
-
- -
-
- Nodeport - -
-
- -
-
-

Nodeport is required.

-

Nodeport number must be inside the range 30000-32767 or blank for system allocated.

-

Nodeport number must be inside the range 30000-32767 or blank for system allocated.

-
- This node port is already used. -
-
-
-
-
-
-
-
- Loadbalancer port - -
-
- -
-
-
- - -
- -
-
-
-
- - Publish a new port - -
-
-
diff --git a/app/kubernetes/components/kube-services/kube-services-item/kube-services-item.js b/app/kubernetes/components/kube-services/kube-services-item/kube-services-item.js deleted file mode 100644 index d9c192dcd..000000000 --- a/app/kubernetes/components/kube-services/kube-services-item/kube-services-item.js +++ /dev/null @@ -1,14 +0,0 @@ -import angular from 'angular'; -import controller from './kube-services-item.controller'; - -angular.module('portainer.kubernetes').component('kubeServicesItemView', { - templateUrl: './kube-services-item.html', - controller, - bindings: { - nodePortServices: '<', - formServices: '<', - service: '=', - isEdit: '<', - loadbalancerEnabled: '<', - }, -}); diff --git a/app/kubernetes/components/kube-services/kube-services.controller.js b/app/kubernetes/components/kube-services/kube-services.controller.js deleted file mode 100644 index bb6b4c087..000000000 --- a/app/kubernetes/components/kube-services/kube-services.controller.js +++ /dev/null @@ -1,105 +0,0 @@ -import { KubernetesService, KubernetesServicePort, KubernetesServiceTypes } from '@/kubernetes/models/service/models'; -import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models/constants'; -import { notifyError } from '@/portainer/services/notifications'; -import { getServices } from '@/react/kubernetes/networks/services/service'; - -export default class KubeServicesViewController { - /* @ngInject */ - constructor($async, EndpointProvider, Authentication) { - this.$async = $async; - this.EndpointProvider = EndpointProvider; - this.Authentication = Authentication; - this.asyncOnInit = this.asyncOnInit.bind(this); - } - - addEntry(service) { - const p = new KubernetesService(); - p.Type = service; - - p.Selector = this.formValues.Selector; - - p.Name = this.getUniqName(); - this.formValues.Services.push(p); - } - - getUniqName() { - //services name will follow thia patten: service, service-2, service-3... - let nameIndex = 2; - let UniqName = this.formValues.Name; - const services = this.formValues.Services; - - const sortServices = services.sort((a, b) => { - return a.Name.localeCompare(b.Name); - }); - - if (sortServices.length !== 0) { - sortServices.forEach((service) => { - if (service.Name === UniqName) { - UniqName = this.formValues.Name + '-' + nameIndex; - nameIndex += 1; - } - }); - } - return UniqName; - } - - deleteService(index) { - this.formValues.Services.splice(index, 1); - } - - addPort(index) { - const p = new KubernetesServicePort(); - this.formValues.Services[index].Ports.push(p); - } - - serviceType(type) { - switch (type) { - case KubernetesApplicationPublishingTypes.CLUSTER_IP: - return KubernetesServiceTypes.CLUSTER_IP; - case KubernetesApplicationPublishingTypes.NODE_PORT: - return KubernetesServiceTypes.NODE_PORT; - case KubernetesApplicationPublishingTypes.LOAD_BALANCER: - return KubernetesServiceTypes.LOAD_BALANCER; - } - } - - isAdmin() { - return this.Authentication.isAdmin(); - } - - async asyncOnInit() { - try { - // get all nodeport services in the cluster, to validate unique nodeports in the form - const allSettledServices = await Promise.allSettled(this.namespaces.map((namespace) => getServices(this.state.endpointId, namespace))); - const allServices = allSettledServices - .filter((settledService) => settledService.status === 'fulfilled' && settledService.value) - .map((fulfilledService) => fulfilledService.value) - .flat(); - this.nodePortServices = allServices.filter((service) => service.Type === 'NodePort'); - } catch (error) { - notifyError('Failure', error, 'Failed getting services'); - } - } - - $onInit() { - this.state = { - serviceType: [ - { - typeName: KubernetesServiceTypes.CLUSTER_IP, - typeValue: KubernetesApplicationPublishingTypes.CLUSTER_IP, - }, - { - typeName: KubernetesServiceTypes.NODE_PORT, - typeValue: KubernetesApplicationPublishingTypes.NODE_PORT, - }, - { - typeName: KubernetesServiceTypes.LOAD_BALANCER, - typeValue: KubernetesApplicationPublishingTypes.LOAD_BALANCER, - }, - ], - selected: KubernetesApplicationPublishingTypes.CLUSTER_IP, - endpointId: this.EndpointProvider.endpointID(), - }; - return this.$async(this.asyncOnInit); - } -} diff --git a/app/kubernetes/components/kube-services/kube-services.html b/app/kubernetes/components/kube-services/kube-services.html deleted file mode 100644 index fc872e63d..000000000 --- a/app/kubernetes/components/kube-services/kube-services.html +++ /dev/null @@ -1,91 +0,0 @@ -
Publishing the application
-
-
-

- - Publish your application by creating a ClusterIP service for it, which you may then expose via an ingress. -

-
-
-
-
-
- - -
-
-
- -
-
-
-
- - - - {{ $ctrl.serviceType(service.Type) }} -
- - -
- -
-
- - Ingress -
-
-

- Ingress is not configured in this namespace, select another namespace or click - here to configure ingress. -

-
-
-

- Ingress is not configured in this namespace, select another namespace or contact your administrator. -

-
- -
-
-
diff --git a/app/kubernetes/components/kube-services/kube-services.js b/app/kubernetes/components/kube-services/kube-services.js deleted file mode 100644 index 1d3610f7d..000000000 --- a/app/kubernetes/components/kube-services/kube-services.js +++ /dev/null @@ -1,13 +0,0 @@ -import angular from 'angular'; -import controller from './kube-services.controller'; - -angular.module('portainer.kubernetes').component('kubeServicesView', { - templateUrl: './kube-services.html', - controller, - bindings: { - formValues: '=', - isEdit: '<', - namespaces: '<', - loadbalancerEnabled: '<', - }, -}); diff --git a/app/kubernetes/helpers/application/index.js b/app/kubernetes/helpers/application/index.js index f068868db..b640db2b4 100644 --- a/app/kubernetes/helpers/application/index.js +++ b/app/kubernetes/helpers/application/index.js @@ -303,9 +303,11 @@ class KubernetesApplicationHelper { const svcport = new KubernetesServicePort(); svcport.name = port.name; svcport.port = port.port; - svcport.nodePort = port.nodePort; + svcport.nodePort = port.nodePort || 0; svcport.protocol = port.protocol; svcport.targetPort = port.targetPort; + svcport.serviceName = service.metadata.name; + svcport.ingress = {}; app.Ingresses.value.forEach((ingress) => { const ingressNameMatched = ingress.Paths.find((ingPath) => ingPath.ServiceName === service.metadata.name); diff --git a/app/kubernetes/ingress/converter.js b/app/kubernetes/ingress/converter.js index 67fdc5bed..86f87e634 100644 --- a/app/kubernetes/ingress/converter.js +++ b/app/kubernetes/ingress/converter.js @@ -127,7 +127,7 @@ export class KubernetesIngressConverter { static newApplicationFormValuesToIngresses(formValues, serviceName, servicePorts) { const ingresses = angular.copy(formValues.OriginalIngresses); servicePorts.forEach((port) => { - const ingress = _.find(ingresses, { Name: port.ingress.IngressName }); + const ingress = port.ingress && _.find(ingresses, { Name: port.ingress.IngressName }); if (ingress) { const rule = new KubernetesIngressRule(); rule.ServiceName = serviceName; diff --git a/app/kubernetes/react/components/index.ts b/app/kubernetes/react/components/index.ts index 536ae597f..2c4574286 100644 --- a/app/kubernetes/react/components/index.ts +++ b/app/kubernetes/react/components/index.ts @@ -7,6 +7,10 @@ import { StorageAccessModeSelector } from '@/react/kubernetes/cluster/ConfigureV import { NamespaceAccessUsersSelector } from '@/react/kubernetes/namespaces/AccessView/NamespaceAccessUsersSelector'; import { CreateNamespaceRegistriesSelector } from '@/react/kubernetes/namespaces/CreateView/CreateNamespaceRegistriesSelector'; import { KubeApplicationAccessPolicySelector } from '@/react/kubernetes/applications/CreateView/KubeApplicationAccessPolicySelector'; +import { + KubeServicesForm, + kubeServicesValidation, +} from '@/react/kubernetes/applications/CreateView/application-services/KubeServicesForm'; import { KubeApplicationDeploymentTypeSelector } from '@/react/kubernetes/applications/CreateView/KubeApplicationDeploymentTypeSelector'; import { withReactQuery } from '@/react-tools/withReactQuery'; import { withUIRouter } from '@/react-tools/withUIRouter'; @@ -15,8 +19,10 @@ import { ApplicationDetailsWidget, } from '@/react/kubernetes/applications/DetailsView'; import { withUserProvider } from '@/react/test-utils/withUserProvider'; +import { withFormValidation } from '@/react-tools/withFormValidation'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; -export const componentsModule = angular +export const ngModule = angular .module('portainer.kubernetes.react.components', []) .component( 'ingressClassDatatable', @@ -93,7 +99,7 @@ export const componentsModule = angular .component( 'applicationSummaryWidget', r2a( - withUIRouter(withReactQuery(withUserProvider(ApplicationSummaryWidget))), + withUIRouter(withReactQuery(withCurrentUser(ApplicationSummaryWidget))), [] ) ) @@ -103,4 +109,21 @@ export const componentsModule = angular withUIRouter(withReactQuery(withUserProvider(ApplicationDetailsWidget))), [] ) - ).name; + ); + +export const componentsModule = ngModule.name; + +withFormValidation( + ngModule, + withUIRouter(withCurrentUser(withReactQuery(KubeServicesForm))), + 'kubeServicesForm', + [ + 'values', + 'onChange', + 'loadBalancerEnabled', + 'appName', + 'selector', + 'isEditMode', + ], + kubeServicesValidation +); diff --git a/app/kubernetes/views/applications/create/createApplication.html b/app/kubernetes/views/applications/create/createApplication.html index 8436350d4..ca9546c3f 100644 --- a/app/kubernetes/views/applications/create/createApplication.html +++ b/app/kubernetes/views/applications/create/createApplication.html @@ -1144,13 +1144,15 @@ - + @@ -1192,19 +1194,22 @@ - + -
Actions
+
Actions
diff --git a/app/kubernetes/views/applications/create/createApplicationController.js b/app/kubernetes/views/applications/create/createApplicationController.js index 9046348d7..21f3c0870 100644 --- a/app/kubernetes/views/applications/create/createApplicationController.js +++ b/app/kubernetes/views/applications/create/createApplicationController.js @@ -3,6 +3,7 @@ import _ from 'lodash-es'; import filesizeParser from 'filesize-parser'; import * as JsonPatch from 'fast-json-patch'; import { RegistryTypes } from '@/portainer/models/registryTypes'; +import { getServices } from '@/react/kubernetes/networks/services/service'; import { KubernetesApplicationDataAccessPolicies, @@ -133,6 +134,7 @@ class KubernetesCreateApplicationController { isEdit: this.$state.params.namespace && this.$state.params.name, persistedFoldersUseExistingVolumes: false, pullImageValidity: false, + nodePortServices: [], }; this.isAdmin = this.Authentication.isAdmin(); @@ -156,6 +158,7 @@ class KubernetesCreateApplicationController { this.onChangeDeploymentType = this.onChangeDeploymentType.bind(this); this.supportGlobalDeployment = this.supportGlobalDeployment.bind(this); this.onChangePlacementType = this.onChangePlacementType.bind(this); + this.onServicesChange = this.onServicesChange.bind(this); } /* #endregion */ @@ -453,6 +456,12 @@ class KubernetesCreateApplicationController { /* #endregion */ /* #region PUBLISHED PORTS UI MANAGEMENT */ + onServicesChange(services) { + return this.$async(async () => { + this.formValues.Services = services; + }); + } + onServicePublishChange() { // enable publishing with no previous ports exposed if (this.formValues.IsPublishingService && !this.formValues.PublishedPorts.length) { @@ -1295,6 +1304,14 @@ class KubernetesCreateApplicationController { } finally { this.state.viewReady = true; } + // get all nodeport services in the cluster, to validate unique nodeports in the form + // this is below the try catch, to not block the page rendering + const allSettledServices = await Promise.allSettled(this.resourcePools.map((namespace) => getServices(this.endpoint.Id, namespace.Namespace.Name))); + const allServices = allSettledServices + .filter((settledService) => settledService.status === 'fulfilled' && settledService.value) + .map((fulfilledService) => fulfilledService.value) + .flat(); + this.state.nodePortServices = allServices.filter((service) => service.Type === 'NodePort'); }); } diff --git a/app/react-tools/withFormValidation.ts b/app/react-tools/withFormValidation.ts index ed105dc7a..07d57ad1e 100644 --- a/app/react-tools/withFormValidation.ts +++ b/app/react-tools/withFormValidation.ts @@ -13,6 +13,7 @@ interface FormFieldProps { onChange(values: TValue): void; values: TValue; errors?: FormikErrors | ArrayError; + validationContext?: object; // optional context to pass to yup validation, for example, external data } type WithFormFieldProps = TProps & FormFieldProps; @@ -37,7 +38,13 @@ export function withFormValidation( ngModule .component( reactComponentName, - r2a(Component, ['errors', 'onChange', 'values', ...propNames]) + r2a(Component, [ + 'errors', + 'onChange', + 'values', + 'validationContext', + ...propNames, + ]) ) .component( componentName, @@ -68,7 +75,12 @@ export function createFormValidationComponent( `, controller: createFormValidatorController(schemaBuilder), bindings: Object.fromEntries( - [...propsWithErrors, 'validationData', 'onChange'].map((p) => [p, '<']) + [ + ...propsWithErrors, + 'validationData', + 'onChange', + 'validationContext', + ].map((p) => [p, '<']) ), }; } @@ -87,6 +99,8 @@ export function createFormValidatorController( validationData?: TData; + validationContext?: object; + onChange?: (value: TFormModel) => void; /* @ngInject */ @@ -100,17 +114,18 @@ export function createFormValidatorController( async handleChange(newValues: TFormModel) { return this.$async(async () => { this.onChange?.(newValues); - await this.runValidation(newValues); + await this.runValidation(newValues, this.validationContext); }); } - async runValidation(value: TFormModel) { + async runValidation(value: TFormModel, validationContext?: object) { return this.$async(async () => { this.form?.$setValidity('form', true, this.form); this.errors = await validateForm( () => schemaBuilder(this.validationData), - value + value, + validationContext ); if (this.errors && Object.keys(this.errors).length > 0) { @@ -121,7 +136,10 @@ export function createFormValidatorController( async $onChanges(changes: { values?: { currentValue: TFormModel } }) { if (changes.values) { - await this.runValidation(changes.values.currentValue); + await this.runValidation( + changes.values.currentValue, + this.validationContext + ); } } }; diff --git a/app/react/components/Link.tsx b/app/react/components/Link.tsx index ef82951b9..90571009f 100644 --- a/app/react/components/Link.tsx +++ b/app/react/components/Link.tsx @@ -4,6 +4,7 @@ import { UISref, UISrefProps } from '@uirouter/react'; interface Props { title?: string; target?: AnchorHTMLAttributes['target']; + rel?: AnchorHTMLAttributes['rel']; } export function Link({ @@ -16,7 +17,7 @@ export function Link({ // eslint-disable-next-line react/jsx-props-no-spreading {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} - + {children} diff --git a/app/react/components/form-components/ButtonSelector/ButtonSelector.tsx b/app/react/components/form-components/ButtonSelector/ButtonSelector.tsx index 7eb2cd97c..989d4b61a 100644 --- a/app/react/components/form-components/ButtonSelector/ButtonSelector.tsx +++ b/app/react/components/form-components/ButtonSelector/ButtonSelector.tsx @@ -18,6 +18,7 @@ interface Props { size?: Size; disabled?: boolean; readOnly?: boolean; + className?: string; } export function ButtonSelector({ @@ -27,9 +28,10 @@ export function ButtonSelector({ options, disabled, readOnly, + className, }: Props) { return ( - + {options.map((option) => ( ) {

- + {children}

); diff --git a/app/react/components/form-components/InputGroup/InputGroupAddon.tsx b/app/react/components/form-components/InputGroup/InputGroupAddon.tsx index 09cef2c29..4ba446c7e 100644 --- a/app/react/components/form-components/InputGroup/InputGroupAddon.tsx +++ b/app/react/components/form-components/InputGroup/InputGroupAddon.tsx @@ -1,18 +1,28 @@ import { ComponentType, PropsWithChildren } from 'react'; +import clsx from 'clsx'; import { useInputGroupContext } from './InputGroup'; +type BaseProps = { + as?: ComponentType | string; + required?: boolean; +}; + export function InputGroupAddon({ children, as = 'span', + required, ...props -}: PropsWithChildren<{ as?: ComponentType | string } & TProps>) { +}: PropsWithChildren & TProps>) { useInputGroupContext(); const Component = as as 'span'; return ( - // eslint-disable-next-line react/jsx-props-no-spreading - + {children} ); diff --git a/app/react/components/form-components/validate-form.ts b/app/react/components/form-components/validate-form.ts index 0d3e54450..3d65d2bee 100644 --- a/app/react/components/form-components/validate-form.ts +++ b/app/react/components/form-components/validate-form.ts @@ -3,7 +3,8 @@ import { SchemaOf } from 'yup'; export async function validateForm( schemaBuilder: () => SchemaOf, - formValues: T + formValues: T, + validationContext?: object ) { const validationSchema = schemaBuilder(); @@ -11,6 +12,8 @@ export async function validateForm( await validationSchema.validate(formValues, { strict: true, abortEarly: false, + // workaround to access all parents for nested fields. See clusterIpFormValidation for a use case. + context: { formValues, validationContext }, }); return undefined; } catch (error) { diff --git a/app/react/kubernetes/applications/CreateView/.keep b/app/react/kubernetes/applications/CreateView/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/kubernetes/applications/CreateView/application-services/ClusterIpForm.tsx b/app/react/kubernetes/applications/CreateView/application-services/ClusterIpForm.tsx new file mode 100644 index 000000000..159a9d87c --- /dev/null +++ b/app/react/kubernetes/applications/CreateView/application-services/ClusterIpForm.tsx @@ -0,0 +1,132 @@ +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/ContainerPortInput.tsx b/app/react/kubernetes/applications/CreateView/application-services/ContainerPortInput.tsx new file mode 100644 index 000000000..7ddb56903 --- /dev/null +++ b/app/react/kubernetes/applications/CreateView/application-services/ContainerPortInput.tsx @@ -0,0 +1,29 @@ +import { ChangeEvent } from 'react'; + +import { InputGroup } from '@@/form-components/InputGroup'; + +type Props = { + index: number; + value?: number; + onChange: (e: ChangeEvent) => void; +}; + +export function ContainerPortInput({ index, value, onChange }: Props) { + return ( + + Container port + + + ); +} diff --git a/app/react/kubernetes/applications/CreateView/application-services/KubeServicesForm.tsx b/app/react/kubernetes/applications/CreateView/application-services/KubeServicesForm.tsx new file mode 100644 index 000000000..b78a46795 --- /dev/null +++ b/app/react/kubernetes/applications/CreateView/application-services/KubeServicesForm.tsx @@ -0,0 +1,539 @@ +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 { 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 { ServiceFormValues, ServicePort, ServiceTypeValue } from './types'; +import { LoadBalancerForm } from './LoadBalancerForm'; +import { ClusterIpForm } from './ClusterIpForm'; +import { NodePortForm } from './NodePortForm'; +import { newPort } from './utils'; + +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: {}, +}; + +interface Props { + values: ServiceFormValues[]; + onChange: (loadBalancerPorts: ServiceFormValues[]) => void; + errors?: FormikErrors; + loadBalancerEnabled: boolean; + appName: string; + selector: Record; + isEditMode: boolean; +} + +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 + + // when the appName changes, update the names for each service + // and the serviceNames for each service port + useEffect(() => { + if (!isEditMode) { + const newServiceNames = getUniqNames(appName, services); + const newServices = services.map((service, index) => { + const newServiceName = newServiceNames[index]; + const newServicePorts = service.Ports.map((port) => ({ + ...port, + serviceName: newServiceName, + })); + return { ...service, Name: newServiceName, Ports: newServicePorts }; + }); + onChange(newServices); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [appName]); + + 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); + }} + /> + + + + + +
+
+ {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} + /> + + )} + +
+ ))} +
+ + ); +} + +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 + ); + + const uniqueNames = sortedServices.reduce( + (acc: string[]) => { + const newIndex = + acc.findIndex((existingName) => existingName === appName) + 1; + const uniqName = acc.includes(appName) + ? generateUniqueName(appName, newIndex, services) + : appName; + return [...acc, uniqName]; + }, + [appName] + ); + + return uniqueNames; +} + +// 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 +interface NodePortValues { + Port: number; + TargetPort: number; + NodePort: number; + Name?: string; + Protocol?: string; + Ingress?: string; +} + +type ServiceValues = { + Type: number; + Name: string; + Ports: NodePortValues[]; +}; + +type NodePortValidationContext = { + nodePortServices: ServiceValues[]; + formServices: ServiceFormValues[]; +}; + +export function kubeServicesValidation(): SchemaOf { + return array( + object({ + Headless: boolean().required(), + Namespace: string(), + Name: string(), + StackName: string(), + Type: mixed().oneOf([ + KubernetesApplicationPublishingTypes.CLUSTER_IP, + KubernetesApplicationPublishingTypes.NODE_PORT, + KubernetesApplicationPublishingTypes.LOAD_BALANCER, + ]), + ClusterIP: string(), + ApplicationName: string(), + ApplicationOwner: string(), + Note: string(), + Ingress: boolean().required(), + Selector: object(), + Ports: array( + object({ + port: number() + .required('Service port number is required.') + .min(1, 'Service port number must be inside the range 1-65535.') + .max(65535, 'Service port number must be inside the range 1-65535.') + .test( + 'service-port-is-unique', + 'Service port number must be unique.', + // eslint-disable-next-line func-names + function (servicePort, context) { + // test for duplicate service ports within this service. + // yup gives access to context.parent which gives one ServicePort object. + // yup also gives access to all form values through this.options.context. + // Unfortunately, it doesn't give direct access to all Ports within the current service. + // To find all ports in the service for validation, I'm filtering the services by the service name, + // that's stored in the ServicePort object, then getting all Ports in the service. + if (servicePort === undefined) { + return true; + } + const matchingService = getServiceForPort( + context.parent as ServicePort, + this.options.context?.formValues as ServiceFormValues[] + ); + if (matchingService === undefined) { + return true; + } + const servicePorts = matchingService.Ports; + const duplicateServicePortCount = servicePorts.filter( + (port) => port.port === servicePort + ).length; + return duplicateServicePortCount <= 1; + } + ), + targetPort: number() + .required('Container port number is required.') + .min(1, 'Container port number must be inside the range 1-65535.') + .max( + 65535, + 'Container port number must be inside the range 1-65535.' + ), + name: string(), + serviceName: string().required(), + protocol: string(), + nodePort: number() + .test( + 'node-port-is-unique-in-service', + 'Node port is already used in this service.', + // eslint-disable-next-line func-names + function (nodePort, context) { + if (nodePort === undefined) { + return true; + } + const matchingService = getServiceForPort( + context.parent as ServicePort, + this.options.context?.formValues as ServiceFormValues[] + ); + if ( + matchingService === undefined || + matchingService.Type !== + KubernetesApplicationPublishingTypes.NODE_PORT // ignore validation unless the service is of type nodeport + ) { + return true; + } + const servicePorts = matchingService.Ports; + const duplicateNodePortCount = servicePorts.filter( + (port) => port.nodePort === nodePort + ).length; + return duplicateNodePortCount <= 1; + } + ) + .test( + 'node-port-is-unique-in-cluster', + 'Node port is already used.', + // eslint-disable-next-line func-names + function (nodePort, context) { + if (nodePort === undefined) { + return true; + } + const { nodePortServices } = this.options.context + ?.validationContext as NodePortValidationContext; + const formServices = this.options.context + ?.formValues as ServiceFormValues[]; + const matchingService = getServiceForPort( + context.parent as ServicePort, + formServices + ); + + if ( + matchingService === undefined || + matchingService.Type !== + KubernetesApplicationPublishingTypes.NODE_PORT // ignore validation unless the service is of type nodeport + ) { + return true; + } + + // create a list of all the node ports (number[]) in the cluster, from services that aren't in the application form + const formServiceNames = formServices.map( + (formService) => formService.Name + ); + const clusterNodePortsWithoutFormServices = nodePortServices + .filter( + (npService) => !formServiceNames.includes(npService.Name) + ) + .flatMap((npService) => npService.Ports) + .map((npServicePorts) => npServicePorts.NodePort); + // node ports in the current form, excluding the current service + const formNodePortsWithoutCurrentService = formServices + .filter( + (formService) => + formService.Type === + KubernetesApplicationPublishingTypes.NODE_PORT && + formService.Name !== matchingService.Name + ) + .flatMap((formService) => formService.Ports) + .map((formServicePorts) => formServicePorts.nodePort); + return ( + !clusterNodePortsWithoutFormServices.includes(nodePort) && // node port is not in the cluster services that aren't in the application form + !formNodePortsWithoutCurrentService.includes(nodePort) // node port is not in the current form, excluding the current service + ); + } + ) + .test( + 'node-port-minimum', + 'Nodeport number must be inside the range 30000-32767 or blank for system allocated.', + // eslint-disable-next-line func-names + function (nodePort, context) { + if (nodePort === undefined) { + return true; + } + const matchingService = getServiceForPort( + context.parent as ServicePort, + this.options.context?.formValues as ServiceFormValues[] + ); + if ( + !matchingService || + matchingService.Type !== + KubernetesApplicationPublishingTypes.NODE_PORT + ) { + return true; + } + return nodePort >= 30000; + } + ) + .test( + 'node-port-maximum', + 'Nodeport number must be inside the range 30000-32767 or blank for system allocated.', + // eslint-disable-next-line func-names + function (nodePort, context) { + if (nodePort === undefined) { + return true; + } + const matchingService = getServiceForPort( + context.parent as ServicePort, + this.options.context?.formValues as ServiceFormValues[] + ); + if ( + !matchingService || + matchingService.Type !== + KubernetesApplicationPublishingTypes.NODE_PORT + ) { + return true; + } + return nodePort <= 32767; + } + ), + ingress: object(), + }) + ), + }) + ); +} + +function getServiceForPort( + servicePort: ServicePort, + services: ServiceFormValues[] +) { + return services.find((service) => service.Name === servicePort.serviceName); +} diff --git a/app/react/kubernetes/applications/CreateView/application-services/LoadBalancerForm.tsx b/app/react/kubernetes/applications/CreateView/application-services/LoadBalancerForm.tsx new file mode 100644 index 000000000..a241e8f1c --- /dev/null +++ b/app/react/kubernetes/applications/CreateView/application-services/LoadBalancerForm.tsx @@ -0,0 +1,176 @@ +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/NodePortForm.tsx b/app/react/kubernetes/applications/CreateView/application-services/NodePortForm.tsx new file mode 100644 index 000000000..96becf7e0 --- /dev/null +++ b/app/react/kubernetes/applications/CreateView/application-services/NodePortForm.tsx @@ -0,0 +1,162 @@ +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/ServicePortInput.tsx b/app/react/kubernetes/applications/CreateView/application-services/ServicePortInput.tsx new file mode 100644 index 000000000..dd40ea991 --- /dev/null +++ b/app/react/kubernetes/applications/CreateView/application-services/ServicePortInput.tsx @@ -0,0 +1,29 @@ +import { ChangeEvent } from 'react'; + +import { InputGroup } from '@@/form-components/InputGroup'; + +type Props = { + index: number; + value?: number; + onChange: (e: ChangeEvent) => void; +}; + +export function ServicePortInput({ index, value, onChange }: Props) { + return ( + + Service port + + + ); +} diff --git a/app/react/kubernetes/applications/CreateView/application-services/types.ts b/app/react/kubernetes/applications/CreateView/application-services/types.ts new file mode 100644 index 000000000..6bfdec2e9 --- /dev/null +++ b/app/react/kubernetes/applications/CreateView/application-services/types.ts @@ -0,0 +1,26 @@ +export interface ServicePort { + port?: number; + targetPort?: number; + nodePort?: number; + serviceName?: string; + name?: string; + protocol?: string; + ingress?: object; +} + +export type ServiceTypeValue = 1 | 2 | 3; + +export type ServiceFormValues = { + Headless: boolean; + Ports: ServicePort[]; + Type: ServiceTypeValue; + Ingress: boolean; + ClusterIP?: string; + ApplicationName?: string; + ApplicationOwner?: string; + Note?: string; + Name?: string; + StackName?: string; + Selector?: Record; + Namespace?: string; +}; diff --git a/app/react/kubernetes/applications/CreateView/application-services/utils.ts b/app/react/kubernetes/applications/CreateView/application-services/utils.ts new file mode 100644 index 000000000..10b806888 --- /dev/null +++ b/app/react/kubernetes/applications/CreateView/application-services/utils.ts @@ -0,0 +1,18 @@ +import { FormikErrors } from 'formik'; + +export function isServicePortError( + error: string | FormikErrors | undefined +): error is FormikErrors { + return error !== undefined && typeof error !== 'string'; +} + +export function newPort(serviceName?: string) { + return { + port: undefined, + targetPort: undefined, + name: '', + protocol: 'TCP', + nodePort: undefined, + serviceName, + }; +}