From 89c1d0e3370773b26d1cd263b56e48b8dec862f5 Mon Sep 17 00:00:00 2001 From: Ali <83188384+testA113@users.noreply.github.com> Date: Mon, 26 Jun 2023 16:21:19 +1200 Subject: [PATCH] feat(app): add ingress to app service form [EE-5569] (#9106) --- .../images/ingress-explanatory-diagram.png | Bin 0 -> 68585 bytes .../applications-datatable-url.css | 10 - .../applications-datatable-url.html | 7 - .../applications-datatable-url.js | 9 - .../applicationsDatatable.css | 11 + .../applicationsDatatable.html | 14 +- .../applicationsDatatableController.js | 6 +- app/kubernetes/helpers/application/index.js | 24 +- app/kubernetes/ingress/converter.js | 6 +- .../models/application/formValues.js | 1 - app/kubernetes/models/service/models.js | 14 - app/kubernetes/react/components/index.ts | 8 +- app/kubernetes/services/applicationService.js | 51 ++- .../create/createApplication.html | 6 +- .../create/createApplicationController.js | 213 +----------- .../summary/resources/applicationResources.js | 43 +-- .../FormSection/FormSection.tsx | 7 +- .../FormSectionTitle/FormSectionTitle.tsx | 25 +- .../InputGroup/InputGroupButtonWrapper.tsx | 14 +- .../form-components/ReactSelect.css | 48 +++ .../form-components/ReactSelect.tsx | 21 +- .../ClusterIpServiceForm.tsx | 167 ---------- .../application-services/KubeServicesForm.tsx | 313 ++++-------------- .../LoadBalancerServiceForm.tsx | 200 ----------- .../NodePortServiceForm.tsx | 197 ----------- .../PublishingExplaination.tsx | 90 +++++ .../cluster-ip/ClusterIpServiceForm.tsx | 196 +++++++++++ .../ClusterIpServicesForm.tsx | 15 +- .../{ => components}/ContainerPortInput.tsx | 14 +- .../{ => components}/ServicePortInput.tsx | 14 +- .../components/ServiceTabLabel.tsx | 33 ++ .../{ => components}/ServiceTabs.tsx | 2 +- .../ingress/AppIngressPathForm.tsx | 161 +++++++++ .../ingress/AppIngressPathsForm.tsx | 171 ++++++++++ .../kubeServicesValidation.ts | 311 +++++++++++++++++ .../load-balancer/LoadBalancerServiceForm.tsx | 238 +++++++++++++ .../LoadBalancerServicesForm.tsx | 19 +- .../node-port/NodePortServiceForm.tsx | 236 +++++++++++++ .../{ => node-port}/NodePortServicesForm.tsx | 15 +- .../CreateView/application-services/types.ts | 14 +- .../CreateView/application-services/utils.ts | 106 +++++- .../ApplicationIngressesTable.tsx | 7 +- .../IngressDatatable/columns/ingressRules.tsx | 2 +- app/react/kubernetes/ingresses/queries.ts | 48 +-- app/react/kubernetes/ingresses/types.ts | 2 +- app/react/portainer/environments/.keep | 0 app/react/portainer/environments/types.ts | 1 + 47 files changed, 1929 insertions(+), 1181 deletions(-) create mode 100644 app/assets/images/ingress-explanatory-diagram.png delete mode 100644 app/kubernetes/components/datatables/applications-datatable-url/applications-datatable-url.css delete mode 100644 app/kubernetes/components/datatables/applications-datatable-url/applications-datatable-url.html delete mode 100644 app/kubernetes/components/datatables/applications-datatable-url/applications-datatable-url.js delete mode 100644 app/react/kubernetes/applications/CreateView/application-services/ClusterIpServiceForm.tsx delete mode 100644 app/react/kubernetes/applications/CreateView/application-services/LoadBalancerServiceForm.tsx delete mode 100644 app/react/kubernetes/applications/CreateView/application-services/NodePortServiceForm.tsx create mode 100644 app/react/kubernetes/applications/CreateView/application-services/PublishingExplaination.tsx create mode 100644 app/react/kubernetes/applications/CreateView/application-services/cluster-ip/ClusterIpServiceForm.tsx rename app/react/kubernetes/applications/CreateView/application-services/{ => cluster-ip}/ClusterIpServicesForm.tsx (90%) rename app/react/kubernetes/applications/CreateView/application-services/{ => components}/ContainerPortInput.tsx (67%) rename app/react/kubernetes/applications/CreateView/application-services/{ => components}/ServicePortInput.tsx (68%) create mode 100644 app/react/kubernetes/applications/CreateView/application-services/components/ServiceTabLabel.tsx rename app/react/kubernetes/applications/CreateView/application-services/{ => components}/ServiceTabs.tsx (95%) create mode 100644 app/react/kubernetes/applications/CreateView/application-services/ingress/AppIngressPathForm.tsx create mode 100644 app/react/kubernetes/applications/CreateView/application-services/ingress/AppIngressPathsForm.tsx create mode 100644 app/react/kubernetes/applications/CreateView/application-services/kubeServicesValidation.ts create mode 100644 app/react/kubernetes/applications/CreateView/application-services/load-balancer/LoadBalancerServiceForm.tsx rename app/react/kubernetes/applications/CreateView/application-services/{ => load-balancer}/LoadBalancerServicesForm.tsx (92%) create mode 100644 app/react/kubernetes/applications/CreateView/application-services/node-port/NodePortServiceForm.tsx rename app/react/kubernetes/applications/CreateView/application-services/{ => node-port}/NodePortServicesForm.tsx (90%) delete mode 100644 app/react/portainer/environments/.keep diff --git a/app/assets/images/ingress-explanatory-diagram.png b/app/assets/images/ingress-explanatory-diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..20b5599e00210414897a13d859faf878e70eab9f GIT binary patch literal 68585 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

3UX%_zLsYEi01vE+$;Fgemf37g>__k~(1! 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 ZiIp)a{PF9_kK}t(zo-3gp|Vxz{{SUs@A3cu literal 0 HcmV?d00001 diff --git a/app/kubernetes/components/datatables/applications-datatable-url/applications-datatable-url.css b/app/kubernetes/components/datatables/applications-datatable-url/applications-datatable-url.css deleted file mode 100644 index 6e9a817e6..000000000 --- a/app/kubernetes/components/datatables/applications-datatable-url/applications-datatable-url.css +++ /dev/null @@ -1,10 +0,0 @@ -.published-url-container { - display: grid; - grid-template-columns: 1fr 1fr 3fr; - padding-top: 10px; - padding-bottom: 5px; -} - -.publish-url-link { - width: min-content; -} diff --git a/app/kubernetes/components/datatables/applications-datatable-url/applications-datatable-url.html b/app/kubernetes/components/datatables/applications-datatable-url/applications-datatable-url.html deleted file mode 100644 index 9755ce0d5..000000000 --- a/app/kubernetes/components/datatables/applications-datatable-url/applications-datatable-url.html +++ /dev/null @@ -1,7 +0,0 @@ -

diff --git a/app/kubernetes/components/datatables/applications-datatable-url/applications-datatable-url.js b/app/kubernetes/components/datatables/applications-datatable-url/applications-datatable-url.js deleted file mode 100644 index 7177b851b..000000000 --- a/app/kubernetes/components/datatables/applications-datatable-url/applications-datatable-url.js +++ /dev/null @@ -1,9 +0,0 @@ -import angular from 'angular'; -import './applications-datatable-url.css'; - -angular.module('portainer.kubernetes').component('kubernetesApplicationsDatatableUrl', { - templateUrl: './applications-datatable-url.html', - bindings: { - publishedUrl: '@', - }, -}); diff --git a/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.css b/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.css index 93daf1350..89cda522c 100644 --- a/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.css +++ b/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.css @@ -9,3 +9,14 @@ .datatable-wide { width: 55px; } + +.published-url-container { + display: grid; + grid-template-columns: 1fr 1fr 3fr; + padding-top: 10px; + padding-bottom: 5px; +} + +.publish-url-link { + width: min-content; +} diff --git a/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.html b/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.html index 1aa610f59..41f424e4b 100644 --- a/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.html +++ b/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.html @@ -328,7 +328,19 @@ - +
+
+
Published URL(s)
+
+ +
pp.IngressRules) .filter(({ Host, IP }) => Host || IP) @@ -119,7 +119,7 @@ angular.module('portainer.docker').controller('KubernetesApplicationsDatatableCo const publishedUrls = [...ingressUrls, ...loadBalancerURLs]; // Return the first URL - priority given to ingress urls, then services (load balancers) - return publishedUrls.length > 0 ? publishedUrls[0] : ''; + return publishedUrls.length > 0 ? publishedUrls : ''; }; this.hasConfigurationSecrets = function (item) { diff --git a/app/kubernetes/helpers/application/index.js b/app/kubernetes/helpers/application/index.js index fec22a199..6add57116 100644 --- a/app/kubernetes/helpers/application/index.js +++ b/app/kubernetes/helpers/application/index.js @@ -307,22 +307,18 @@ class KubernetesApplicationHelper { svcport.protocol = port.protocol; svcport.targetPort = port.targetPort; svcport.serviceName = service.metadata.name; - svcport.ingress = {}; + svcport.ingressPaths = []; app.Ingresses.value.forEach((ingress) => { - const ingressNameMatched = ingress.Paths.find((ingPath) => ingPath.ServiceName === service.metadata.name); - const ingressPortMatched = ingress.Paths.find((ingPath) => ingPath.Port === port.port); - // only add ingress info to the port if the ingress serviceport matches the port in the service - if (ingressPortMatched) { - svcport.ingress = { - IngressName: ingressPortMatched.IngressName, - Host: ingressPortMatched.Host, - Path: ingressPortMatched.Path, - }; - } - if (ingressNameMatched) { - svc.Ingress = true; - } + const matchingIngressPaths = ingress.Paths.filter((ingPath) => ingPath.ServiceName === service.metadata.name && ingPath.Port === port.port); + // only add ingress info to the port if the ingress serviceport and name matches + const newPaths = matchingIngressPaths.map((ingPath) => ({ + IngressName: ingPath.IngressName, + Host: ingPath.Host, + Path: ingPath.Path, + })); + svcport.ingressPaths = [...svcport.ingressPaths, ...newPaths]; + svc.Ingress = matchingIngressPaths.length > 0; }); ports.push(svcport); diff --git a/app/kubernetes/ingress/converter.js b/app/kubernetes/ingress/converter.js index 86f87e634..df04d7e06 100644 --- a/app/kubernetes/ingress/converter.js +++ b/app/kubernetes/ingress/converter.js @@ -82,10 +82,8 @@ export class KubernetesIngressConverter { const ingresses = angular.copy(formValues.OriginalIngresses); application.Services.forEach((service) => { ingresses.forEach((ingress) => { - const path = _.find(ingress.Paths, { ServiceName: service.metadata.name }); - if (path) { - _.remove(ingress.Paths, path); - } + const paths = _.filter(ingress.Paths, { ServiceName: service.metadata.name }); + paths.forEach((path) => _.remove(ingress.Paths, path)); }); }); return ingresses; diff --git a/app/kubernetes/models/application/formValues.js b/app/kubernetes/models/application/formValues.js index 692bc84ef..ce3415711 100644 --- a/app/kubernetes/models/application/formValues.js +++ b/app/kubernetes/models/application/formValues.js @@ -28,7 +28,6 @@ export function KubernetesApplicationFormValues() { this.PlacementType = KubernetesApplicationPlacementTypes.PREFERRED; this.Placements = []; // KubernetesApplicationPlacementFormValue lis; this.OriginalIngresses = undefined; - this.IsPublishingService = false; } export const KubernetesApplicationConfigurationFormValueOverridenKeyTypes = Object.freeze({ diff --git a/app/kubernetes/models/service/models.js b/app/kubernetes/models/service/models.js index 5d0bea48d..06dfb563e 100644 --- a/app/kubernetes/models/service/models.js +++ b/app/kubernetes/models/service/models.js @@ -54,20 +54,6 @@ export class KubernetesIngressService { } } -const _KubernetesIngressServiceRoute = Object.freeze({ - Host: '', - IngressName: '', - Path: '', - ServiceName: '', - TLSCert: '', -}); - -export class KubernetesIngressServiceRoute { - constructor() { - Object.assign(this, JSON.parse(JSON.stringify(_KubernetesIngressServiceRoute))); - } -} - /** * KubernetesServicePort Model */ diff --git a/app/kubernetes/react/components/index.ts b/app/kubernetes/react/components/index.ts index 74650b880..c45e66eb1 100644 --- a/app/kubernetes/react/components/index.ts +++ b/app/kubernetes/react/components/index.ts @@ -7,10 +7,8 @@ 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 { KubeServicesForm } from '@/react/kubernetes/applications/CreateView/application-services/KubeServicesForm'; +import { kubeServicesValidation } from '@/react/kubernetes/applications/CreateView/application-services/kubeServicesValidation'; import { KubeApplicationDeploymentTypeSelector } from '@/react/kubernetes/applications/CreateView/KubeApplicationDeploymentTypeSelector'; import { withReactQuery } from '@/react-tools/withReactQuery'; import { withUIRouter } from '@/react-tools/withUIRouter'; @@ -117,6 +115,6 @@ withFormValidation( ngModule, withUIRouter(withCurrentUser(withReactQuery(KubeServicesForm))), 'kubeServicesForm', - ['values', 'onChange', 'appName', 'selector', 'isEditMode'], + ['values', 'onChange', 'appName', 'selector', 'isEditMode', 'namespace'], kubeServicesValidation ); diff --git a/app/kubernetes/services/applicationService.js b/app/kubernetes/services/applicationService.js index 8ce012bef..79a306c32 100644 --- a/app/kubernetes/services/applicationService.js +++ b/app/kubernetes/services/applicationService.js @@ -13,6 +13,8 @@ import { KubernetesHorizontalPodAutoScalerHelper } from 'Kubernetes/horizontal-p import { KubernetesHorizontalPodAutoScalerConverter } from 'Kubernetes/horizontal-pod-auto-scaler/converter'; import KubernetesPodConverter from 'Kubernetes/pod/converter'; import { notifyError } from '@/portainer/services/notifications'; +import { KubernetesIngressConverter } from 'Kubernetes/ingress/converter'; +import { generateNewIngressesFromFormPaths } from '@/react/kubernetes/applications/CreateView/application-services/utils'; class KubernetesApplicationService { /* #region CONSTRUCTOR */ @@ -70,6 +72,12 @@ class KubernetesApplicationService { return apiService; } + _generateIngressPatchPromises(oldIngresses, newIngresses) { + return _.map(newIngresses, (newIng) => { + const oldIng = _.find(oldIngresses, { Name: newIng.Name }); + return this.KubernetesIngressService.patch(oldIng, newIng); + }); + } /* #endregion */ /* #region GET */ @@ -214,6 +222,18 @@ class KubernetesApplicationService { notifyError('Unable to create service', error); } }); + + try { + //Generate all ingresses from current form by passing services object + const newServicePorts = formValues.Services.flatMap((service) => service.Ports); + const newIngresses = generateNewIngressesFromFormPaths(formValues.OriginalIngresses, newServicePorts); + if (newIngresses) { + //Update original ingress with current ingress + await Promise.all(this._generateIngressPatchPromises(formValues.OriginalIngresses, newIngresses)); + } + } catch (error) { + notifyError('Unable to update service', error); + } } const apiService = this._getApplicationApiService(app); @@ -256,7 +276,7 @@ class KubernetesApplicationService { * To synchronise with kubernetes resource creation, update and delete summary output, any new resources created * in this method should also be displayed in the summary output (getUpdatedApplicationResources) */ - async patchAsync(oldFormValues, newFormValues) { + async patchAsync(oldFormValues, newFormValues, originalServicePorts) { const [oldApp, oldHeadlessService, oldServices, , oldClaims] = KubernetesApplicationConverter.applicationFormValuesToApplication(oldFormValues); const [newApp, newHeadlessService, newServices, , newClaims] = KubernetesApplicationConverter.applicationFormValuesToApplication(newFormValues); const oldApiService = this._getApplicationApiService(oldApp); @@ -341,6 +361,21 @@ class KubernetesApplicationService { }); } + // Update ingresses + if (newServices) { + try { + //Generate all ingresses from current form by passing services object + const newServicePorts = newFormValues.Services.flatMap((service) => service.Ports); + const newIngresses = generateNewIngressesFromFormPaths(newFormValues.OriginalIngresses, newServicePorts, originalServicePorts); + if (newIngresses) { + //Update original ingress with current ingress + await Promise.all(this._generateIngressPatchPromises(newFormValues.OriginalIngresses, newIngresses)); + } + } catch (error) { + notifyError('Unable to update service', error); + } + } + const newKind = KubernetesHorizontalPodAutoScalerHelper.getApplicationTypeString(newApp); const newAutoScaler = KubernetesHorizontalPodAutoScalerConverter.applicationFormValuesToModel(newFormValues, newKind); if (!oldFormValues.AutoScaler.IsUsed) { @@ -384,11 +419,11 @@ class KubernetesApplicationService { // // patch(oldValues: KubernetesApplication, newValues: KubernetesApplication, partial: (undefined | false)): Promise // patch(oldValues: KubernetesApplicationFormValues, newValues: KubernetesApplicationFormValues, partial: true): Promise - patch(oldValues, newValues, partial = false) { + patch(oldValues, newValues, partial = false, originalServicePorts) { if (partial) { return this.$async(this.patchPartialAsync, oldValues, newValues); } - return this.$async(this.patchAsync, oldValues, newValues); + return this.$async(this.patchAsync, oldValues, newValues, originalServicePorts); } /* #endregion */ @@ -412,6 +447,16 @@ class KubernetesApplicationService { if (application.ServiceType) { // delete headless service && non-headless service await this.KubernetesServiceService.delete(application.Services); + if (application.Ingresses.length) { + const originalIngresses = await this.KubernetesIngressService.get(payload.Namespace); + const formValues = { + OriginalIngresses: originalIngresses, + PublishedPorts: KubernetesApplicationHelper.generatePublishedPortsFormValuesFromPublishedPorts(application.ServiceType, application.PublishedPorts), + }; + const ingresses = KubernetesIngressConverter.applicationFormValuesToDeleteIngresses(formValues, application); + + await Promise.all(this._generateIngressPatchPromises(formValues.OriginalIngresses, ingresses)); + } } if (!_.isEmpty(application.AutoScaler)) { await this.KubernetesHorizontalPodAutoScalerService.delete(application.AutoScaler); diff --git a/app/kubernetes/views/applications/create/createApplication.html b/app/kubernetes/views/applications/create/createApplication.html index 1764293d0..d9859c05f 100644 --- a/app/kubernetes/views/applications/create/createApplication.html +++ b/app/kubernetes/views/applications/create/createApplication.html @@ -1306,8 +1306,9 @@ load-balancer-enabled="ctrl.publishViaLoadBalancerEnabled()" app-name="ctrl.formValues.Name" selector="ctrl.formValues.Selector" - validation-data="{nodePortServices: ctrl.state.nodePortServices, formServices: ctrl.formValues.Services}" + validation-data="{nodePortServices: ctrl.state.nodePortServices, formServices: ctrl.formValues.Services, ingressPaths: ctrl.ingressPaths, originalIngressPaths: ctrl.originalIngressPaths}" is-edit-mode="ctrl.state.isEdit" + namespace="ctrl.formValues.ResourcePool.Namespace.Name" > @@ -1355,8 +1356,9 @@ values="ctrl.formValues.Services" app-name="ctrl.formValues.Name" selector="ctrl.formValues.Selector" - validation-data="{nodePortServices: ctrl.state.nodePortServices, formServices: ctrl.formValues.Services}" + validation-data="{nodePortServices: ctrl.state.nodePortServices, formServices: ctrl.formValues.Services, ingressPaths: ctrl.ingressPaths, originalIngressPaths: ctrl.originalIngressPaths}" is-edit-mode="ctrl.state.isEdit" + namespace="ctrl.formValues.ResourcePool.Namespace.Name" > diff --git a/app/kubernetes/views/applications/create/createApplicationController.js b/app/kubernetes/views/applications/create/createApplicationController.js index 3176711eb..fcfff49ed 100644 --- a/app/kubernetes/views/applications/create/createApplicationController.js +++ b/app/kubernetes/views/applications/create/createApplicationController.js @@ -22,7 +22,6 @@ import { KubernetesApplicationEnvironmentVariableFormValue, KubernetesApplicationFormValues, KubernetesApplicationPersistedFolderFormValue, - KubernetesApplicationPublishedPortFormValue, KubernetesApplicationPlacementFormValue, KubernetesFormValidationReferences, } from 'Kubernetes/models/application/formValues'; @@ -125,12 +124,6 @@ class KubernetesCreateApplicationController { configMapPaths: new KubernetesFormValidationReferences(), secretPaths: new KubernetesFormValidationReferences(), existingVolumes: new KubernetesFormValidationReferences(), - publishedPorts: { - containerPorts: new KubernetesFormValidationReferences(), - nodePorts: new KubernetesFormValidationReferences(), - ingressRoutes: new KubernetesFormValidationReferences(), - loadBalancerPorts: new KubernetesFormValidationReferences(), - }, placements: new KubernetesFormValidationReferences(), }, isEdit: this.$state.params.namespace && this.$state.params.name, @@ -153,7 +146,6 @@ class KubernetesCreateApplicationController { this.deployApplicationAsync = this.deployApplicationAsync.bind(this); this.setPullImageValidity = this.setPullImageValidity.bind(this); this.onChangeFileContent = this.onChangeFileContent.bind(this); - this.onServicePublishChange = this.onServicePublishChange.bind(this); this.checkIngressesToUpdate = this.checkIngressesToUpdate.bind(this); this.confirmUpdateApplicationAsync = this.confirmUpdateApplicationAsync.bind(this); this.onDataAccessPolicyChange = this.onDataAccessPolicyChange.bind(this); @@ -517,154 +509,12 @@ class KubernetesCreateApplicationController { /* #endregion */ - /* #region PUBLISHED PORTS UI MANAGEMENT */ + /* #region SERVICES 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) { - this.addPublishedPort(); - return; - } - - // service update - if (this.formValues.IsPublishingService) { - this.formValues.PublishedPorts.forEach((port) => (port.NeedsDeletion = false)); - } else { - // delete new ports, mark old ports to be deleted - this.formValues.PublishedPorts = this.formValues.PublishedPorts.filter((port) => !port.IsNew).map((port) => ({ ...port, NeedsDeletion: true })); - } - } - - addPublishedPort() { - const p = new KubernetesApplicationPublishedPortFormValue(); - const ingresses = this.ingresses; - if (this.formValues.PublishingType === KubernetesApplicationPublishingTypes.INGRESS) { - p.IngressName = ingresses && ingresses.length ? ingresses[0].Name : undefined; - p.IngressHost = ingresses && ingresses.length ? ingresses[0].Hosts[0] : undefined; - p.IngressHosts = ingresses && ingresses.length ? ingresses[0].Hosts : undefined; - } - if (this.formValues.PublishedPorts.length) { - p.Protocol = this.formValues.PublishedPorts[0].Protocol; - } - this.formValues.PublishedPorts.push(p); - } - - resetPublishedPorts() { - const ingresses = this.ingresses; - _.forEach(this.formValues.PublishedPorts, (p) => { - p.IngressName = ingresses && ingresses.length ? ingresses[0].Name : undefined; - p.IngressHost = ingresses && ingresses.length ? ingresses[0].Hosts[0] : undefined; - }); - } - - restorePublishedPort(index) { - this.formValues.PublishedPorts[index].NeedsDeletion = false; - this.onChangePublishedPorts(); - } - - removePublishedPort(index) { - if (this.state.isEdit && !this.formValues.PublishedPorts[index].IsNew) { - this.formValues.PublishedPorts[index].NeedsDeletion = true; - } else { - this.formValues.PublishedPorts.splice(index, 1); - } - this.onChangePublishedPorts(); - } - /* #endregion */ - - /* #region PUBLISHED PORTS ON CHANGE VALIDATION */ - onChangePublishedPorts() { - this.onChangePortMappingContainerPort(); - this.onChangePortMappingNodePort(); - this.onChangePortMappingIngressRoute(); - this.onChangePortMappingLoadBalancer(); - this.onChangePortProtocol(); - } - - onChangePortMappingContainerPort() { - const state = this.state.duplicates.publishedPorts.containerPorts; - if (this.formValues.PublishingType !== KubernetesApplicationPublishingTypes.INGRESS) { - const source = _.map(this.formValues.PublishedPorts, (p) => (p.NeedsDeletion ? undefined : p.ContainerPort + p.Protocol)); - const duplicates = KubernetesFormValidationHelper.getDuplicates(source); - state.refs = duplicates; - state.hasRefs = Object.keys(duplicates).length > 0; - } else { - state.refs = {}; - state.hasRefs = false; - } - } - - onChangePortMappingNodePort() { - const state = this.state.duplicates.publishedPorts.nodePorts; - if (this.formValues.PublishingType === KubernetesApplicationPublishingTypes.NODE_PORT) { - const source = _.map(this.formValues.PublishedPorts, (p) => (p.NeedsDeletion ? undefined : p.NodePort)); - const duplicates = KubernetesFormValidationHelper.getDuplicates(source); - state.refs = duplicates; - state.hasRefs = Object.keys(duplicates).length > 0; - } else { - state.refs = {}; - state.hasRefs = false; - } - } - - onChangePortMappingIngress(index) { - const publishedPort = this.formValues.PublishedPorts[index]; - const ingress = _.find(this.ingresses, { Name: publishedPort.IngressName }); - publishedPort.IngressHosts = ingress.Hosts; - this.ingressHostnames = ingress.Hosts; - publishedPort.IngressHost = this.ingressHostnames.length ? this.ingressHostnames[0] : []; - this.onChangePublishedPorts(); - } - - onChangePortMappingIngressRoute() { - const state = this.state.duplicates.publishedPorts.ingressRoutes; - - if (this.formValues.PublishingType === KubernetesApplicationPublishingTypes.INGRESS) { - const newRoutes = _.map(this.formValues.PublishedPorts, (p) => (p.IsNew && p.IngressRoute ? `${p.IngressHost || p.IngressName}${p.IngressRoute}` : undefined)); - const toDelRoutes = _.map(this.formValues.PublishedPorts, (p) => (p.NeedsDeletion && p.IngressRoute ? `${p.IngressHost || p.IngressName}${p.IngressRoute}` : undefined)); - const allRoutes = _.flatMap(this.ingresses, (i) => _.map(i.Paths, (p) => `${p.Host || i.Name}${p.Path}`)); - const duplicates = KubernetesFormValidationHelper.getDuplicates(newRoutes); - _.forEach(newRoutes, (route, idx) => { - if (_.includes(allRoutes, route) && !_.includes(toDelRoutes, route)) { - duplicates[idx] = route; - } - }); - state.refs = duplicates; - state.hasRefs = Object.keys(duplicates).length > 0; - } else { - state.refs = {}; - state.hasRefs = false; - } - } - - onChangePortMappingLoadBalancer() { - const state = this.state.duplicates.publishedPorts.loadBalancerPorts; - if (this.formValues.PublishingType === KubernetesApplicationPublishingTypes.LOAD_BALANCER) { - const source = _.map(this.formValues.PublishedPorts, (p) => (p.NeedsDeletion ? undefined : p.LoadBalancerPort)); - const duplicates = KubernetesFormValidationHelper.getDuplicates(source); - state.refs = duplicates; - state.hasRefs = Object.keys(duplicates).length > 0; - } else { - state.refs = {}; - state.hasRefs = false; - } - } - - onChangePortProtocol(index) { - if (this.formValues.PublishingType === KubernetesApplicationPublishingTypes.LOAD_BALANCER) { - const newPorts = _.filter(this.formValues.PublishedPorts, { IsNew: true }); - _.forEach(newPorts, (port) => { - port.Protocol = index ? this.formValues.PublishedPorts[index].Protocol : newPorts[0].Protocol; - }); - this.onChangePortMappingLoadBalancer(); - } - this.onChangePortMappingContainerPort(); - } /* #endregion */ /* #region STATE VALIDATION FUNCTIONS */ @@ -675,11 +525,7 @@ class KubernetesCreateApplicationController { !this.state.duplicates.persistedFolders.hasRefs && !this.state.duplicates.configMapPaths.hasRefs && !this.state.duplicates.secretPaths.hasRefs && - !this.state.duplicates.existingVolumes.hasRefs && - !this.state.duplicates.publishedPorts.containerPorts.hasRefs && - !this.state.duplicates.publishedPorts.nodePorts.hasRefs && - !this.state.duplicates.publishedPorts.ingressRoutes.hasRefs && - !this.state.duplicates.publishedPorts.loadBalancerPorts.hasRefs + !this.state.duplicates.existingVolumes.hasRefs ); } @@ -860,22 +706,10 @@ class KubernetesCreateApplicationController { } /* #endregion */ - isEditAndNotNewPublishedPort(index) { - return this.state.isEdit && !this.formValues.PublishedPorts[index].IsNew; - } - - hasNoPublishedPorts() { - return this.formValues.PublishedPorts.filter((port) => !port.NeedsDeletion).length === 0; - } - isEditAndNotNewPlacement(index) { return this.state.isEdit && !this.formValues.Placements[index].IsNew; } - isNewAndNotFirst(index) { - return !this.state.isEdit && index !== 0; - } - showPlacementPolicySection() { const placements = _.filter(this.formValues.Placements, { NeedsDeletion: false }); return placements.length !== 0; @@ -897,8 +731,7 @@ class KubernetesCreateApplicationController { const invalid = !this.isValid(); const hasNoChanges = this.isEditAndNoChangesMade(); const nonScalable = this.isNonScalable(); - const isPublishingWithoutPorts = this.formValues.IsPublishingService && this.hasNoPublishedPorts(); - return overflow || autoScalerOverflow || inProgress || invalid || hasNoChanges || nonScalable || isPublishingWithoutPorts; + return overflow || autoScalerOverflow || inProgress || invalid || hasNoChanges || nonScalable; } isExternalApplication() { @@ -908,33 +741,6 @@ class KubernetesCreateApplicationController { return false; } } - - disableLoadBalancerEdit() { - return ( - this.state.isEdit && - this.application.ServiceType === this.ServiceTypes.LOAD_BALANCER && - !this.application.LoadBalancerIPAddress && - this.formValues.PublishingType === this.ApplicationPublishingTypes.LOAD_BALANCER - ); - } - - isPublishingTypeEditDisabled() { - const ports = _.filter(this.formValues.PublishedPorts, { IsNew: false, NeedsDeletion: false }); - return this.state.isEdit && this.formValues.PublishedPorts.length > 0 && ports.length > 0; - } - - isEditLBWithPorts() { - return this.formValues.PublishingType === KubernetesApplicationPublishingTypes.LOAD_BALANCER && _.filter(this.formValues.PublishedPorts, { IsNew: false }).length === 0; - } - - isProtocolOptionDisabled(index, protocol) { - return ( - this.disableLoadBalancerEdit() || - (this.isEditAndNotNewPublishedPort(index) && this.formValues.PublishedPorts[index].Protocol !== protocol) || - (this.isEditLBWithPorts() && this.formValues.PublishedPorts[index].Protocol !== protocol && this.isNewAndNotFirst(index)) - ); - } - /* #endregion */ /* #region DATA AUTO REFRESH */ @@ -1061,6 +867,7 @@ class KubernetesCreateApplicationController { return this.$async(async () => { try { this.ingresses = await this.KubernetesIngressService.get(namespace); + this.ingressPaths = this.ingresses.flatMap((ingress) => ingress.Paths); this.ingressHostnames = this.ingresses.length ? this.ingresses[0].Hosts : []; if (!this.publishViaIngressEnabled()) { if (this.savedFormValues) { @@ -1093,7 +900,6 @@ class KubernetesCreateApplicationController { this.clearConfigMaps(); this.clearSecrets(); this.resetPersistedFolders(); - this.resetPublishedPorts(); } onResourcePoolSelectionChange() { @@ -1115,7 +921,7 @@ class KubernetesCreateApplicationController { this.formValues.ApplicationOwner = this.Authentication.getUserDetails().username; // combine the secrets and configmap form values when submitting the form _.remove(this.formValues.Configurations, (item) => item.SelectedConfiguration === undefined); - await this.KubernetesApplicationService.create(this.formValues); + await this.KubernetesApplicationService.create(this.formValues, this.originalServicePorts); this.Notifications.success('Request to deploy application successfully submitted', this.formValues.Name); this.$state.go('kubernetes.applications'); } catch (err) { @@ -1137,7 +943,7 @@ class KubernetesCreateApplicationController { try { this.state.actionInProgress = true; - await this.KubernetesApplicationService.patch(this.savedFormValues, this.formValues); + await this.KubernetesApplicationService.patch(this.savedFormValues, this.formValues, false, this.originalServicePorts); this.Notifications.success('Success', 'Request to update application successfully submitted'); this.$state.go('kubernetes.applications.application', { name: this.application.Name, namespace: this.application.ResourcePool }); } catch (err) { @@ -1191,7 +997,7 @@ class KubernetesCreateApplicationController { }); ingressesForService.forEach((ingressForService) => { updatedOldPorts.forEach((servicePort, pIndex) => { - if (servicePort.ingress) { + if (servicePort.ingressPaths) { // if there isn't a ingress path that has a matching service name and port const doesIngressPathMatchServicePort = ingressForService.Paths.find((ingPath) => ingPath.ServiceName === updatedService.Name && ingPath.Port === servicePort.port); if (!doesIngressPathMatchServicePort) { @@ -1322,6 +1128,9 @@ class KubernetesCreateApplicationController { this.ingresses ); + this.originalServicePorts = structuredClone(this.formValues.Services.flatMap((service) => service.Ports)); + this.originalIngressPaths = structuredClone(this.originalServicePorts.flatMap((port) => port.ingressPaths).filter((ingressPath) => ingressPath.Host)); + if (this.application.ApplicationKind) { this.state.appType = KubernetesDeploymentTypes[this.application.ApplicationKind.toUpperCase()]; if (this.application.ApplicationKind === KubernetesDeploymentTypes.URL) { @@ -1359,8 +1168,6 @@ class KubernetesCreateApplicationController { this.nodesLimits.excludesPods(this.application.Pods, this.formValues.CpuLimit, KubernetesResourceReservationHelper.bytesValue(this.formValues.MemoryLimit)); } - this.formValues.IsPublishingService = this.formValues.PublishedPorts.length > 0; - this.oldFormValues = angular.copy(this.formValues); } catch (err) { this.Notifications.error('Failure', err, 'Unable to load view data'); diff --git a/app/kubernetes/views/summary/resources/applicationResources.js b/app/kubernetes/views/summary/resources/applicationResources.js index 42acb432c..287e10475 100644 --- a/app/kubernetes/views/summary/resources/applicationResources.js +++ b/app/kubernetes/views/summary/resources/applicationResources.js @@ -5,18 +5,14 @@ import { KubernetesDeployment } from 'Kubernetes/models/deployment/models'; import { KubernetesStatefulSet } from 'Kubernetes/models/stateful-set/models'; import { KubernetesDaemonSet } from 'Kubernetes/models/daemon-set/models'; import { KubernetesService, KubernetesServiceTypes } from 'Kubernetes/models/service/models'; -import { - KubernetesApplication, - KubernetesApplicationDeploymentTypes, - KubernetesApplicationPublishingTypes, - KubernetesApplicationTypes, -} from 'Kubernetes/models/application/models'; +import { KubernetesApplication, KubernetesApplicationDeploymentTypes, KubernetesApplicationTypes } from 'Kubernetes/models/application/models'; import { KubernetesHorizontalPodAutoScalerHelper } from 'Kubernetes/horizontal-pod-auto-scaler/helper'; import { KubernetesHorizontalPodAutoScalerConverter } from 'Kubernetes/horizontal-pod-auto-scaler/converter'; import KubernetesApplicationConverter from 'Kubernetes/converters/application'; import KubernetesServiceConverter from 'Kubernetes/converters/service'; import { KubernetesIngressConverter } from 'Kubernetes/ingress/converter'; import KubernetesPersistentVolumeClaimConverter from 'Kubernetes/converters/persistentVolumeClaim'; +import { generateNewIngressesFromFormPaths } from '@/react/kubernetes/applications/CreateView/application-services/utils'; const { CREATE, UPDATE, DELETE } = KubernetesResourceActions; @@ -45,21 +41,16 @@ function getCreatedApplicationResources(formValues) { if (services) { services.forEach((service) => { resources.push({ action: CREATE, kind: KubernetesResourceTypes.SERVICE, name: service.Name, type: service.Type || KubernetesServiceTypes.CLUSTER_IP }); - if (formValues.OriginalIngresses.length !== 0) { - const ingresses = KubernetesIngressConverter.newApplicationFormValuesToIngresses(formValues, service.Name, service.Ports); - resources.push(...getIngressUpdateSummary(formValues.OriginalIngresses, ingresses)); - } + // Ingress + const newServicePorts = formValues.Services.flatMap((service) => service.Ports); + const newIngresses = generateNewIngressesFromFormPaths(formValues.OriginalIngresses, newServicePorts); + resources.push(...getIngressUpdateSummary(formValues.OriginalIngresses, newIngresses)); }); } if (service) { // Service resources.push({ action: CREATE, kind: KubernetesResourceTypes.SERVICE, name: service.Name, type: service.Type || KubernetesServiceTypes.CLUSTER_IP }); - if (formValues.PublishingType === KubernetesApplicationPublishingTypes.INGRESS) { - // Ingress - const ingresses = KubernetesIngressConverter.applicationFormValuesToIngresses(formValues, service.Name); - resources.push(...getIngressUpdateSummary(formValues.OriginalIngresses, ingresses)); - } } if (app instanceof KubernetesStatefulSet) { @@ -147,28 +138,18 @@ function getUpdatedApplicationResources(oldFormValues, newFormValues) { }); } - if (newFormValues.PublishingType === KubernetesApplicationPublishingTypes.INGRESS || oldFormValues.PublishingType === KubernetesApplicationPublishingTypes.INGRESS) { - // Ingress - const oldIngresses = KubernetesIngressConverter.applicationFormValuesToIngresses(oldFormValues, oldService.Name); - const newIngresses = KubernetesIngressConverter.applicationFormValuesToIngresses(newFormValues, newService.Name); - resources.push(...getIngressUpdateSummary(oldIngresses, newIngresses)); - } + // Ingress + const oldIngresses = KubernetesIngressConverter.applicationFormValuesToIngresses(oldFormValues, oldService.Name); + const newServicePorts = newFormValues.Services.flatMap((service) => service.Ports); + const oldServicePorts = oldFormValues.Services.flatMap((service) => service.Ports); + const newIngresses = generateNewIngressesFromFormPaths(newFormValues.OriginalIngresses, newServicePorts, oldServicePorts); + resources.push(...getIngressUpdateSummary(oldIngresses, newIngresses)); } else if (!oldService && newService) { // Service resources.push({ action: CREATE, kind: KubernetesResourceTypes.SERVICE, name: newService.Name, type: newService.Type || KubernetesServiceTypes.CLUSTER_IP }); - if (newFormValues.PublishingType === KubernetesApplicationPublishingTypes.INGRESS) { - // Ingress - const ingresses = KubernetesIngressConverter.applicationFormValuesToIngresses(newFormValues, newService.Name); - resources.push(...getIngressUpdateSummary(newFormValues.OriginalIngresses, ingresses)); - } } else if (oldService && !newService) { // Service resources.push({ action: DELETE, kind: KubernetesResourceTypes.SERVICE, name: oldService.Name, type: oldService.Type || KubernetesServiceTypes.CLUSTER_IP }); - if (oldFormValues.PublishingType === KubernetesApplicationPublishingTypes.INGRESS) { - // Ingress - const ingresses = KubernetesIngressConverter.applicationFormValuesToIngresses(newFormValues, oldService.Name); - resources.push(...getIngressUpdateSummary(oldFormValues.OriginalIngresses, ingresses)); - } } const newKind = KubernetesHorizontalPodAutoScalerHelper.getApplicationTypeString(newApp); diff --git a/app/react/components/form-components/FormSection/FormSection.tsx b/app/react/components/form-components/FormSection/FormSection.tsx index 2b7f65016..6bcee02d4 100644 --- a/app/react/components/form-components/FormSection/FormSection.tsx +++ b/app/react/components/form-components/FormSection/FormSection.tsx @@ -7,11 +7,13 @@ import { FormSectionTitle } from '../FormSectionTitle'; interface Props { title: ReactNode; + titleSize?: 'sm' | 'md' | 'lg'; isFoldable?: boolean; } export function FormSection({ title, + titleSize = 'md', children, isFoldable = false, }: PropsWithChildren) { @@ -19,7 +21,10 @@ export function FormSection({ return ( <> - + {isFoldable && ( - -
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/KubeServicesForm.tsx b/app/react/kubernetes/applications/CreateView/application-services/KubeServicesForm.tsx index 37a29c3cf..248e782ae 100644 --- a/app/react/kubernetes/applications/CreateView/application-services/KubeServicesForm.tsx +++ b/app/react/kubernetes/applications/CreateView/application-services/KubeServicesForm.tsx @@ -1,23 +1,23 @@ -import { SchemaOf, array, boolean, mixed, number, object, string } from 'yup'; import { useEffect, useMemo, useState } from 'react'; import { FormikErrors } from 'formik'; import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models'; -import { Badge } from '@@/Badge'; +import { FormSection } from '@@/form-components/FormSection'; 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'; +import { ClusterIpServicesForm } from './cluster-ip/ClusterIpServicesForm'; +import { ServiceTabs } from './components/ServiceTabs'; +import { NodePortServicesForm } from './node-port/NodePortServicesForm'; +import { LoadBalancerServicesForm } from './load-balancer/LoadBalancerServicesForm'; +import { ServiceTabLabel } from './components/ServiceTabLabel'; +import { PublishingExplaination } from './PublishingExplaination'; const serviceTypeEnumsToValues: Record< ServiceTypeAngularEnum, @@ -35,6 +35,7 @@ interface Props { appName: string; selector: Record; isEditMode: boolean; + namespace?: string; } export function KubeServicesForm({ @@ -44,15 +45,19 @@ export function KubeServicesForm({ appName, selector, isEditMode, + namespace, }: Props) { const [selectedServiceType, setSelectedServiceType] = useState('ClusterIP'); // when the appName changes, update the names for each service // and the serviceNames for each service port + const newServiceNames = useMemo( + () => getUniqNames(appName, services), + [appName, services] + ); useEffect(() => { if (!isEditMode) { - const newServiceNames = getUniqNames(appName, services); const newServices = services.map((service, index) => { const newServiceName = newServiceNames[index]; const newServicePorts = service.Ports.map((port) => ({ @@ -70,53 +75,49 @@ export function KubeServicesForm({ () => getServiceTypeCounts(services), [services] ); + + const serviceTypeHasErrors = useMemo( + () => getServiceTypeHasErrors(services, errors), + [services, errors] + ); + 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 -
+ + )} {selectedServiceType === 'NodePort' && ( @@ -138,6 +141,8 @@ export function KubeServicesForm({ errors={errors} appName={appName} selector={selector} + namespace={namespace} + isEditMode={isEditMode} /> )} {selectedServiceType === 'LoadBalancer' && ( @@ -147,6 +152,8 @@ export function KubeServicesForm({ errors={errors} appName={appName} selector={selector} + namespace={namespace} + isEditMode={isEditMode} /> )}
@@ -189,222 +196,22 @@ function getServiceTypeCounts( }, {} 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 -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( - validationData?: NodePortValidationContext -): 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.', - (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 || validationData === undefined) { - return true; - } - const { formServices } = validationData; - const matchingService = getServiceForPort( - context.parent as ServicePort, - formServices - ); - 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.', - (nodePort, context) => { - if (nodePort === undefined || validationData === undefined) { - return true; - } - const { formServices } = validationData; - 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; - } - 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.', - (nodePort, context) => { - if (nodePort === undefined || validationData === undefined) { - return true; - } - const { formServices, nodePortServices } = validationData; - 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) // and the 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.', - (nodePort, context) => { - if (nodePort === undefined || validationData === undefined) { - return true; - } - const { formServices } = validationData; - const matchingService = getServiceForPort( - context.parent as ServicePort, - formServices - ); - 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.', - (nodePort, context) => { - if (nodePort === undefined || validationData === undefined) { - return true; - } - const { formServices } = validationData; - const matchingService = getServiceForPort( - context.parent as ServicePort, - formServices - ); - if ( - !matchingService || - matchingService.Type !== - KubernetesApplicationPublishingTypes.NODE_PORT - ) { - return true; - } - return nodePort <= 32767; - } - ), - ingress: object(), - }) - ), - Annotations: array(), - }) - ); -} - -function getServiceForPort( - servicePort: ServicePort, - services: ServiceFormValues[] -) { - return services.find((service) => service.Name === servicePort.serviceName); +/** + * getServiceTypeHasErrors returns a map of service types to whether or not they have errors + */ +function getServiceTypeHasErrors( + services: ServiceFormValues[], + errors: FormikErrors +): Record { + return services.reduce((acc, service, index) => { + const type = serviceTypeEnumsToValues[service.Type]; + const serviceHasErrors = !!errors?.[index]; + // if the service type already has an error, don't overwrite it + if (acc[type] === true) return acc; + // otherwise, set the error to the value of serviceHasErrors + return { + ...acc, + [type]: serviceHasErrors, + }; + }, {} as Record); } diff --git a/app/react/kubernetes/applications/CreateView/application-services/LoadBalancerServiceForm.tsx b/app/react/kubernetes/applications/CreateView/application-services/LoadBalancerServiceForm.tsx deleted file mode 100644 index 44f8ad0b8..000000000 --- a/app/react/kubernetes/applications/CreateView/application-services/LoadBalancerServiceForm.tsx +++ /dev/null @@ -1,200 +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 { 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/NodePortServiceForm.tsx b/app/react/kubernetes/applications/CreateView/application-services/NodePortServiceForm.tsx deleted file mode 100644 index 73a3ac98c..000000000 --- a/app/react/kubernetes/applications/CreateView/application-services/NodePortServiceForm.tsx +++ /dev/null @@ -1,197 +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 { 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/PublishingExplaination.tsx b/app/react/kubernetes/applications/CreateView/application-services/PublishingExplaination.tsx new file mode 100644 index 000000000..1dd9db746 --- /dev/null +++ b/app/react/kubernetes/applications/CreateView/application-services/PublishingExplaination.tsx @@ -0,0 +1,90 @@ +import ingressDiagram from '@/assets/images/ingress-explanatory-diagram.png'; + +import { FormSection } from '@@/form-components/FormSection'; + +export function PublishingExplaination() { + return ( + +
+ ingress explaination +
+ Expose the application workload via{' '} + + services + {' '} + and{' '} + + ingresses + + : +
    +
  • + Inside the cluster{' '} + + only + {' '} + - via ClusterIP service +
      +
    • + The default service type. +
    • +
    +
  • +
  • + Inside the cluster via ClusterIP service and{' '} + outside via ingress +
      +
    • + + An ingress manages external access to (usually ClusterIP) + services within the cluster, and allows defining of routing + rules, SSL termination and other advanced features. + +
    • +
    +
  • +
  • + Inside and outside the cluster via NodePort{' '} + service +
      +
    • + + This publishes the workload on a static port on each node, + allowing external access via a nodes' IP address and + port. Not generally recommended for Production use. + +
    • +
    +
  • +
  • + Inside and outside the cluster via{' '} + LoadBalancer service +
      +
    • + + If running on a cloud platform, this auto provisions a cloud + load balancer and assigns an external IP address or DNS to + route traffic to the workload. + +
    • +
    +
  • +
+
+
+
+ ); +} diff --git a/app/react/kubernetes/applications/CreateView/application-services/cluster-ip/ClusterIpServiceForm.tsx b/app/react/kubernetes/applications/CreateView/application-services/cluster-ip/ClusterIpServiceForm.tsx new file mode 100644 index 000000000..56ee950c9 --- /dev/null +++ b/app/react/kubernetes/applications/CreateView/application-services/cluster-ip/ClusterIpServiceForm.tsx @@ -0,0 +1,196 @@ +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 { isErrorType, newPort } from '../utils'; +import { + ServiceFormValues, + ServicePort, + ServicePortIngressPath, +} from '../types'; +import { ContainerPortInput } from '../components/ContainerPortInput'; +import { ServicePortInput } from '../components/ServicePortInput'; +import { AppIngressPathsForm } from '../ingress/AppIngressPathsForm'; + +interface Props { + services: ServiceFormValues[]; + serviceIndex: number; + onChangeService: (services: ServiceFormValues[]) => void; + servicePorts: ServicePort[]; + onChangePort: (servicePorts: ServicePort[]) => void; + serviceName?: string; + errors?: string | string[] | FormikErrors[]; + namespace?: string; + isEditMode?: boolean; +} + +export function ClusterIpServiceForm({ + services, + serviceIndex, + onChangeService, + servicePorts, + onChangePort, + errors, + serviceName, + namespace, + isEditMode, +}: Props) { + const newClusterIpPort = newPort(serviceName); + return ( + + +
+
ClusterIP
+ +
+
Ports
+
+ {servicePorts.map((servicePort, portIndex) => { + const error = errors?.[portIndex]; + const servicePortErrors = isErrorType(error) + ? error + : undefined; + const ingressPathsErrors = isErrorType( + servicePortErrors?.ingressPaths + ) + ? servicePortErrors?.ingressPaths + : 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); + }} + /> + {servicePortErrors?.targetPort && ( + {servicePortErrors.targetPort} + )} +
+
+ ) => { + const newServicePorts = [...servicePorts]; + newServicePorts[portIndex] = { + ...newServicePorts[portIndex], + port: + e.target.value === '' + ? undefined + : Number(e.target.value), + }; + onChangePort(newServicePorts); + }} + /> + {servicePortErrors?.port && ( + {servicePortErrors.port} + )} +
+ { + const newServicePorts = [...servicePorts]; + newServicePorts[portIndex] = { + ...newServicePorts[portIndex], + protocol: value, + }; + onChangePort(newServicePorts); + }} + value={servicePort.protocol || 'TCP'} + options={[{ value: 'TCP' }, { value: 'UDP' }]} + /> +
+ +
+ { + const newServicePorts = [...servicePorts]; + newServicePorts[portIndex].ingressPaths = ingressPaths; + onChangePort(newServicePorts); + }} + namespace={namespace} + ingressPathsErrors={ingressPathsErrors} + serviceIndex={serviceIndex} + portIndex={portIndex} + isEditMode={isEditMode} + /> +
+ ); + })} +
+ +
+
+
+
+ ); +} diff --git a/app/react/kubernetes/applications/CreateView/application-services/ClusterIpServicesForm.tsx b/app/react/kubernetes/applications/CreateView/application-services/cluster-ip/ClusterIpServicesForm.tsx similarity index 90% rename from app/react/kubernetes/applications/CreateView/application-services/ClusterIpServicesForm.tsx rename to app/react/kubernetes/applications/CreateView/application-services/cluster-ip/ClusterIpServicesForm.tsx index 8eba13e2c..f95a2315b 100644 --- a/app/react/kubernetes/applications/CreateView/application-services/ClusterIpServicesForm.tsx +++ b/app/react/kubernetes/applications/CreateView/application-services/cluster-ip/ClusterIpServicesForm.tsx @@ -7,8 +7,13 @@ 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 { + generateUniqueName, + newPort, + serviceFormDefaultValues, +} from '../utils'; +import { ServiceFormValues, ServicePort } from '../types'; + import { ClusterIpServiceForm } from './ClusterIpServiceForm'; interface Props { @@ -17,6 +22,8 @@ interface Props { errors?: FormikErrors; appName: string; selector: Record; + namespace?: string; + isEditMode?: boolean; } export function ClusterIpServicesForm({ @@ -25,6 +32,8 @@ export function ClusterIpServicesForm({ errors, appName, selector, + namespace, + isEditMode, }: Props) { const clusterIPServiceCount = services.filter( (service) => @@ -56,6 +65,8 @@ export function ClusterIpServicesForm({ services={services} serviceIndex={index} onChangeService={onChangeService} + namespace={namespace} + isEditMode={isEditMode} /> ) : null )} diff --git a/app/react/kubernetes/applications/CreateView/application-services/ContainerPortInput.tsx b/app/react/kubernetes/applications/CreateView/application-services/components/ContainerPortInput.tsx similarity index 67% rename from app/react/kubernetes/applications/CreateView/application-services/ContainerPortInput.tsx rename to app/react/kubernetes/applications/CreateView/application-services/components/ContainerPortInput.tsx index 7ddb56903..f4eff429b 100644 --- a/app/react/kubernetes/applications/CreateView/application-services/ContainerPortInput.tsx +++ b/app/react/kubernetes/applications/CreateView/application-services/components/ContainerPortInput.tsx @@ -3,26 +3,32 @@ import { ChangeEvent } from 'react'; import { InputGroup } from '@@/form-components/InputGroup'; type Props = { - index: number; + serviceIndex: number; + portIndex: number; value?: number; onChange: (e: ChangeEvent) => void; }; -export function ContainerPortInput({ index, value, onChange }: Props) { +export function ContainerPortInput({ + serviceIndex, + portIndex, + value, + onChange, +}: Props) { return ( Container port ); diff --git a/app/react/kubernetes/applications/CreateView/application-services/ServicePortInput.tsx b/app/react/kubernetes/applications/CreateView/application-services/components/ServicePortInput.tsx similarity index 68% rename from app/react/kubernetes/applications/CreateView/application-services/ServicePortInput.tsx rename to app/react/kubernetes/applications/CreateView/application-services/components/ServicePortInput.tsx index dd40ea991..b98366789 100644 --- a/app/react/kubernetes/applications/CreateView/application-services/ServicePortInput.tsx +++ b/app/react/kubernetes/applications/CreateView/application-services/components/ServicePortInput.tsx @@ -3,26 +3,32 @@ import { ChangeEvent } from 'react'; import { InputGroup } from '@@/form-components/InputGroup'; type Props = { - index: number; + serviceIndex: number; + portIndex: number; value?: number; onChange: (e: ChangeEvent) => void; }; -export function ServicePortInput({ index, value, onChange }: Props) { +export function ServicePortInput({ + serviceIndex, + portIndex, + value, + onChange, +}: Props) { return ( Service port ); diff --git a/app/react/kubernetes/applications/CreateView/application-services/components/ServiceTabLabel.tsx b/app/react/kubernetes/applications/CreateView/application-services/components/ServiceTabLabel.tsx new file mode 100644 index 000000000..090787bb5 --- /dev/null +++ b/app/react/kubernetes/applications/CreateView/application-services/components/ServiceTabLabel.tsx @@ -0,0 +1,33 @@ +import { AlertTriangle } from 'lucide-react'; + +import { Badge } from '@@/Badge'; +import { Icon } from '@@/Icon'; + +type Props = { + serviceTypeLabel: string; + serviceTypeCount: number; + serviceTypeHasErrors: boolean; +}; + +export function ServiceTabLabel({ + serviceTypeLabel, + serviceTypeCount, + serviceTypeHasErrors, +}: Props) { + return ( +
+ {serviceTypeLabel} + {serviceTypeCount && ( + + {serviceTypeHasErrors && ( + + )} + {serviceTypeCount} + + )} +
+ ); +} diff --git a/app/react/kubernetes/applications/CreateView/application-services/ServiceTabs.tsx b/app/react/kubernetes/applications/CreateView/application-services/components/ServiceTabs.tsx similarity index 95% rename from app/react/kubernetes/applications/CreateView/application-services/ServiceTabs.tsx rename to app/react/kubernetes/applications/CreateView/application-services/components/ServiceTabs.tsx index dee3e829d..45c9d776f 100644 --- a/app/react/kubernetes/applications/CreateView/application-services/ServiceTabs.tsx +++ b/app/react/kubernetes/applications/CreateView/application-services/components/ServiceTabs.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx'; -import { ServiceTypeOption, ServiceTypeValue } from './types'; +import { ServiceTypeOption, ServiceTypeValue } from '../types'; type Props = { serviceTypeOptions: ServiceTypeOption[]; diff --git a/app/react/kubernetes/applications/CreateView/application-services/ingress/AppIngressPathForm.tsx b/app/react/kubernetes/applications/CreateView/application-services/ingress/AppIngressPathForm.tsx new file mode 100644 index 000000000..c9f8498ba --- /dev/null +++ b/app/react/kubernetes/applications/CreateView/application-services/ingress/AppIngressPathForm.tsx @@ -0,0 +1,161 @@ +import { RefreshCw, Trash2 } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { UseQueryResult } from 'react-query'; +import { FormikErrors } from 'formik'; + +import { Ingress } from '@/react/kubernetes/ingresses/types'; + +import { Select } from '@@/form-components/ReactSelect'; +import { Button } from '@@/buttons'; +import { FormError } from '@@/form-components/FormError'; +import { InputGroup } from '@@/form-components/InputGroup'; +import { Link } from '@@/Link'; + +import { IngressOption, ServicePortIngressPath } from '../types'; + +type Props = { + ingressPath?: ServicePortIngressPath; + ingressPathErrors?: FormikErrors; + ingressHostOptions: IngressOption[]; + onChangeIngressPath: (ingressPath: ServicePortIngressPath) => void; + onRemoveIngressPath: () => void; + ingressesQuery: UseQueryResult; + namespace?: string; + isEditMode?: boolean; +}; + +export function AppIngressPathForm({ + ingressPath, + ingressPathErrors, + ingressHostOptions, + onChangeIngressPath, + onRemoveIngressPath, + ingressesQuery, + namespace, + isEditMode, +}: Props) { + const [selectedIngress, setSelectedIngress] = useState( + ingressHostOptions[0] ?? null + ); + + // if editing allow the current value as an option, + // to handle the case where they disallow the ingress class after creating the path + const ingressHostOptionsWithCurrentValue = useMemo(() => { + if ( + ingressHostOptions.length === 0 && + ingressPath?.Host && + ingressPath?.IngressName && + isEditMode + ) { + return [ + { + value: ingressPath.Host, + label: ingressPath.Host, + ingressName: ingressPath.IngressName, + }, + ]; + } + return ingressHostOptions; + }, [ + ingressHostOptions, + ingressPath?.Host, + ingressPath?.IngressName, + isEditMode, + ]); + + // when the hostname options change (e.g. after a namespace change), update the selected ingress to the first available one + useEffect(() => { + if (ingressHostOptionsWithCurrentValue) { + const newIngressPath = { + ...ingressPath, + Host: ingressHostOptionsWithCurrentValue[0]?.value, + IngressName: ingressHostOptionsWithCurrentValue[0]?.ingressName, + }; + onChangeIngressPath(newIngressPath); + setSelectedIngress(ingressHostOptionsWithCurrentValue[0] ?? null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ingressHostOptionsWithCurrentValue]); + + return ( +
+
+ + Hostname +