diff --git a/app/assets/images/ingress-explanatory-diagram.png b/app/assets/images/ingress-explanatory-diagram.png new file mode 100644 index 000000000..20b5599e0 Binary files /dev/null and b/app/assets/images/ingress-explanatory-diagram.png differ 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 @@ -
-
Published URL
- - - {{ $ctrl.publishedUrl }} - -
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)
+
+
+
+ + + {{ url }} + +
+
+
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 +