mirror of https://github.com/portainer/portainer
feat(app): add ingress to app service form [EE-5569] (#9106)
parent
8c16fbb8aa
commit
89c1d0e337
Binary file not shown.
After Width: | Height: | Size: 67 KiB |
|
@ -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;
|
|
||||||
}
|
|
|
@ -1,7 +0,0 @@
|
||||||
<div class="published-url-container">
|
|
||||||
<div class="text-muted"> Published URL </div>
|
|
||||||
<a ng-href="{{ $ctrl.publishedUrl }}" target="_blank" class="publish-url-link vertical-center">
|
|
||||||
<pr-icon icon="'external-link'"></pr-icon>
|
|
||||||
{{ $ctrl.publishedUrl }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
|
@ -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: '@',
|
|
||||||
},
|
|
||||||
});
|
|
|
@ -9,3 +9,14 @@
|
||||||
.datatable-wide {
|
.datatable-wide {
|
||||||
width: 55px;
|
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;
|
||||||
|
}
|
||||||
|
|
|
@ -328,7 +328,19 @@
|
||||||
</kubernetes-applications-datatable>
|
</kubernetes-applications-datatable>
|
||||||
</span>
|
</span>
|
||||||
<span ng-if="!item.KubernetesApplications">
|
<span ng-if="!item.KubernetesApplications">
|
||||||
<kubernetes-applications-datatable-url ng-if="$ctrl.getPublishedUrl(item)" published-url="{{ $ctrl.getPublishedUrl(item) }}"></kubernetes-applications-datatable-url>
|
<div class="published-url-container">
|
||||||
|
<div>
|
||||||
|
<div class="text-muted"> Published URL(s) </div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div ng-repeat="url in $ctrl.getPublishedUrls(item)">
|
||||||
|
<a ng-href="{{ url }}" target="_blank" class="publish-url-link vertical-center">
|
||||||
|
<pr-icon icon="'external-link'"></pr-icon>
|
||||||
|
{{ url }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<kubernetes-applications-datatable-details
|
<kubernetes-applications-datatable-details
|
||||||
ng-if="$ctrl.hasConfigurationSecrets(item)"
|
ng-if="$ctrl.hasConfigurationSecrets(item)"
|
||||||
configurations="item.Configurations"
|
configurations="item.Configurations"
|
||||||
|
|
|
@ -50,7 +50,7 @@ angular.module('portainer.docker').controller('KubernetesApplicationsDatatableCo
|
||||||
};
|
};
|
||||||
|
|
||||||
this.isExpandable = function (item) {
|
this.isExpandable = function (item) {
|
||||||
return item.KubernetesApplications || this.hasConfigurationSecrets(item) || !!this.getPublishedUrl(item).length;
|
return item.KubernetesApplications || this.hasConfigurationSecrets(item) || !!this.getPublishedUrls(item).length;
|
||||||
};
|
};
|
||||||
|
|
||||||
this.expandItem = function (item, expanded) {
|
this.expandItem = function (item, expanded) {
|
||||||
|
@ -100,7 +100,7 @@ angular.module('portainer.docker').controller('KubernetesApplicationsDatatableCo
|
||||||
return !ctrl.isSystemNamespace(item) || ctrl.settings.showSystem;
|
return !ctrl.isSystemNamespace(item) || ctrl.settings.showSystem;
|
||||||
};
|
};
|
||||||
|
|
||||||
this.getPublishedUrl = function (item) {
|
this.getPublishedUrls = function (item) {
|
||||||
// Map all ingress rules in published ports to their respective URLs
|
// Map all ingress rules in published ports to their respective URLs
|
||||||
const ingressUrls = item.PublishedPorts.flatMap((pp) => pp.IngressRules)
|
const ingressUrls = item.PublishedPorts.flatMap((pp) => pp.IngressRules)
|
||||||
.filter(({ Host, IP }) => Host || IP)
|
.filter(({ Host, IP }) => Host || IP)
|
||||||
|
@ -119,7 +119,7 @@ angular.module('portainer.docker').controller('KubernetesApplicationsDatatableCo
|
||||||
const publishedUrls = [...ingressUrls, ...loadBalancerURLs];
|
const publishedUrls = [...ingressUrls, ...loadBalancerURLs];
|
||||||
|
|
||||||
// Return the first URL - priority given to ingress urls, then services (load balancers)
|
// 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) {
|
this.hasConfigurationSecrets = function (item) {
|
||||||
|
|
|
@ -307,22 +307,18 @@ class KubernetesApplicationHelper {
|
||||||
svcport.protocol = port.protocol;
|
svcport.protocol = port.protocol;
|
||||||
svcport.targetPort = port.targetPort;
|
svcport.targetPort = port.targetPort;
|
||||||
svcport.serviceName = service.metadata.name;
|
svcport.serviceName = service.metadata.name;
|
||||||
svcport.ingress = {};
|
svcport.ingressPaths = [];
|
||||||
|
|
||||||
app.Ingresses.value.forEach((ingress) => {
|
app.Ingresses.value.forEach((ingress) => {
|
||||||
const ingressNameMatched = ingress.Paths.find((ingPath) => ingPath.ServiceName === service.metadata.name);
|
const matchingIngressPaths = ingress.Paths.filter((ingPath) => ingPath.ServiceName === service.metadata.name && ingPath.Port === port.port);
|
||||||
const ingressPortMatched = ingress.Paths.find((ingPath) => ingPath.Port === port.port);
|
// only add ingress info to the port if the ingress serviceport and name matches
|
||||||
// only add ingress info to the port if the ingress serviceport matches the port in the service
|
const newPaths = matchingIngressPaths.map((ingPath) => ({
|
||||||
if (ingressPortMatched) {
|
IngressName: ingPath.IngressName,
|
||||||
svcport.ingress = {
|
Host: ingPath.Host,
|
||||||
IngressName: ingressPortMatched.IngressName,
|
Path: ingPath.Path,
|
||||||
Host: ingressPortMatched.Host,
|
}));
|
||||||
Path: ingressPortMatched.Path,
|
svcport.ingressPaths = [...svcport.ingressPaths, ...newPaths];
|
||||||
};
|
svc.Ingress = matchingIngressPaths.length > 0;
|
||||||
}
|
|
||||||
if (ingressNameMatched) {
|
|
||||||
svc.Ingress = true;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ports.push(svcport);
|
ports.push(svcport);
|
||||||
|
|
|
@ -82,10 +82,8 @@ export class KubernetesIngressConverter {
|
||||||
const ingresses = angular.copy(formValues.OriginalIngresses);
|
const ingresses = angular.copy(formValues.OriginalIngresses);
|
||||||
application.Services.forEach((service) => {
|
application.Services.forEach((service) => {
|
||||||
ingresses.forEach((ingress) => {
|
ingresses.forEach((ingress) => {
|
||||||
const path = _.find(ingress.Paths, { ServiceName: service.metadata.name });
|
const paths = _.filter(ingress.Paths, { ServiceName: service.metadata.name });
|
||||||
if (path) {
|
paths.forEach((path) => _.remove(ingress.Paths, path));
|
||||||
_.remove(ingress.Paths, path);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return ingresses;
|
return ingresses;
|
||||||
|
|
|
@ -28,7 +28,6 @@ export function KubernetesApplicationFormValues() {
|
||||||
this.PlacementType = KubernetesApplicationPlacementTypes.PREFERRED;
|
this.PlacementType = KubernetesApplicationPlacementTypes.PREFERRED;
|
||||||
this.Placements = []; // KubernetesApplicationPlacementFormValue lis;
|
this.Placements = []; // KubernetesApplicationPlacementFormValue lis;
|
||||||
this.OriginalIngresses = undefined;
|
this.OriginalIngresses = undefined;
|
||||||
this.IsPublishingService = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const KubernetesApplicationConfigurationFormValueOverridenKeyTypes = Object.freeze({
|
export const KubernetesApplicationConfigurationFormValueOverridenKeyTypes = Object.freeze({
|
||||||
|
|
|
@ -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
|
* KubernetesServicePort Model
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -7,10 +7,8 @@ import { StorageAccessModeSelector } from '@/react/kubernetes/cluster/ConfigureV
|
||||||
import { NamespaceAccessUsersSelector } from '@/react/kubernetes/namespaces/AccessView/NamespaceAccessUsersSelector';
|
import { NamespaceAccessUsersSelector } from '@/react/kubernetes/namespaces/AccessView/NamespaceAccessUsersSelector';
|
||||||
import { CreateNamespaceRegistriesSelector } from '@/react/kubernetes/namespaces/CreateView/CreateNamespaceRegistriesSelector';
|
import { CreateNamespaceRegistriesSelector } from '@/react/kubernetes/namespaces/CreateView/CreateNamespaceRegistriesSelector';
|
||||||
import { KubeApplicationAccessPolicySelector } from '@/react/kubernetes/applications/CreateView/KubeApplicationAccessPolicySelector';
|
import { KubeApplicationAccessPolicySelector } from '@/react/kubernetes/applications/CreateView/KubeApplicationAccessPolicySelector';
|
||||||
import {
|
import { KubeServicesForm } from '@/react/kubernetes/applications/CreateView/application-services/KubeServicesForm';
|
||||||
KubeServicesForm,
|
import { kubeServicesValidation } from '@/react/kubernetes/applications/CreateView/application-services/kubeServicesValidation';
|
||||||
kubeServicesValidation,
|
|
||||||
} from '@/react/kubernetes/applications/CreateView/application-services/KubeServicesForm';
|
|
||||||
import { KubeApplicationDeploymentTypeSelector } from '@/react/kubernetes/applications/CreateView/KubeApplicationDeploymentTypeSelector';
|
import { KubeApplicationDeploymentTypeSelector } from '@/react/kubernetes/applications/CreateView/KubeApplicationDeploymentTypeSelector';
|
||||||
import { withReactQuery } from '@/react-tools/withReactQuery';
|
import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||||
|
@ -117,6 +115,6 @@ withFormValidation(
|
||||||
ngModule,
|
ngModule,
|
||||||
withUIRouter(withCurrentUser(withReactQuery(KubeServicesForm))),
|
withUIRouter(withCurrentUser(withReactQuery(KubeServicesForm))),
|
||||||
'kubeServicesForm',
|
'kubeServicesForm',
|
||||||
['values', 'onChange', 'appName', 'selector', 'isEditMode'],
|
['values', 'onChange', 'appName', 'selector', 'isEditMode', 'namespace'],
|
||||||
kubeServicesValidation
|
kubeServicesValidation
|
||||||
);
|
);
|
||||||
|
|
|
@ -13,6 +13,8 @@ import { KubernetesHorizontalPodAutoScalerHelper } from 'Kubernetes/horizontal-p
|
||||||
import { KubernetesHorizontalPodAutoScalerConverter } from 'Kubernetes/horizontal-pod-auto-scaler/converter';
|
import { KubernetesHorizontalPodAutoScalerConverter } from 'Kubernetes/horizontal-pod-auto-scaler/converter';
|
||||||
import KubernetesPodConverter from 'Kubernetes/pod/converter';
|
import KubernetesPodConverter from 'Kubernetes/pod/converter';
|
||||||
import { notifyError } from '@/portainer/services/notifications';
|
import { notifyError } from '@/portainer/services/notifications';
|
||||||
|
import { KubernetesIngressConverter } from 'Kubernetes/ingress/converter';
|
||||||
|
import { generateNewIngressesFromFormPaths } from '@/react/kubernetes/applications/CreateView/application-services/utils';
|
||||||
|
|
||||||
class KubernetesApplicationService {
|
class KubernetesApplicationService {
|
||||||
/* #region CONSTRUCTOR */
|
/* #region CONSTRUCTOR */
|
||||||
|
@ -70,6 +72,12 @@ class KubernetesApplicationService {
|
||||||
return apiService;
|
return apiService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_generateIngressPatchPromises(oldIngresses, newIngresses) {
|
||||||
|
return _.map(newIngresses, (newIng) => {
|
||||||
|
const oldIng = _.find(oldIngresses, { Name: newIng.Name });
|
||||||
|
return this.KubernetesIngressService.patch(oldIng, newIng);
|
||||||
|
});
|
||||||
|
}
|
||||||
/* #endregion */
|
/* #endregion */
|
||||||
|
|
||||||
/* #region GET */
|
/* #region GET */
|
||||||
|
@ -214,6 +222,18 @@ class KubernetesApplicationService {
|
||||||
notifyError('Unable to create service', error);
|
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);
|
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
|
* 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)
|
* 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 [oldApp, oldHeadlessService, oldServices, , oldClaims] = KubernetesApplicationConverter.applicationFormValuesToApplication(oldFormValues);
|
||||||
const [newApp, newHeadlessService, newServices, , newClaims] = KubernetesApplicationConverter.applicationFormValuesToApplication(newFormValues);
|
const [newApp, newHeadlessService, newServices, , newClaims] = KubernetesApplicationConverter.applicationFormValuesToApplication(newFormValues);
|
||||||
const oldApiService = this._getApplicationApiService(oldApp);
|
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 newKind = KubernetesHorizontalPodAutoScalerHelper.getApplicationTypeString(newApp);
|
||||||
const newAutoScaler = KubernetesHorizontalPodAutoScalerConverter.applicationFormValuesToModel(newFormValues, newKind);
|
const newAutoScaler = KubernetesHorizontalPodAutoScalerConverter.applicationFormValuesToModel(newFormValues, newKind);
|
||||||
if (!oldFormValues.AutoScaler.IsUsed) {
|
if (!oldFormValues.AutoScaler.IsUsed) {
|
||||||
|
@ -384,11 +419,11 @@ class KubernetesApplicationService {
|
||||||
//
|
//
|
||||||
// patch(oldValues: KubernetesApplication, newValues: KubernetesApplication, partial: (undefined | false)): Promise<unknown>
|
// patch(oldValues: KubernetesApplication, newValues: KubernetesApplication, partial: (undefined | false)): Promise<unknown>
|
||||||
// patch(oldValues: KubernetesApplicationFormValues, newValues: KubernetesApplicationFormValues, partial: true): Promise<unknown>
|
// patch(oldValues: KubernetesApplicationFormValues, newValues: KubernetesApplicationFormValues, partial: true): Promise<unknown>
|
||||||
patch(oldValues, newValues, partial = false) {
|
patch(oldValues, newValues, partial = false, originalServicePorts) {
|
||||||
if (partial) {
|
if (partial) {
|
||||||
return this.$async(this.patchPartialAsync, oldValues, newValues);
|
return this.$async(this.patchPartialAsync, oldValues, newValues);
|
||||||
}
|
}
|
||||||
return this.$async(this.patchAsync, oldValues, newValues);
|
return this.$async(this.patchAsync, oldValues, newValues, originalServicePorts);
|
||||||
}
|
}
|
||||||
/* #endregion */
|
/* #endregion */
|
||||||
|
|
||||||
|
@ -412,6 +447,16 @@ class KubernetesApplicationService {
|
||||||
if (application.ServiceType) {
|
if (application.ServiceType) {
|
||||||
// delete headless service && non-headless service
|
// delete headless service && non-headless service
|
||||||
await this.KubernetesServiceService.delete(application.Services);
|
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)) {
|
if (!_.isEmpty(application.AutoScaler)) {
|
||||||
await this.KubernetesHorizontalPodAutoScalerService.delete(application.AutoScaler);
|
await this.KubernetesHorizontalPodAutoScalerService.delete(application.AutoScaler);
|
||||||
|
|
|
@ -1306,8 +1306,9 @@
|
||||||
load-balancer-enabled="ctrl.publishViaLoadBalancerEnabled()"
|
load-balancer-enabled="ctrl.publishViaLoadBalancerEnabled()"
|
||||||
app-name="ctrl.formValues.Name"
|
app-name="ctrl.formValues.Name"
|
||||||
selector="ctrl.formValues.Selector"
|
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"
|
is-edit-mode="ctrl.state.isEdit"
|
||||||
|
namespace="ctrl.formValues.ResourcePool.Namespace.Name"
|
||||||
></kube-services-form>
|
></kube-services-form>
|
||||||
<!-- kubernetes services options -->
|
<!-- kubernetes services options -->
|
||||||
|
|
||||||
|
@ -1355,8 +1356,9 @@
|
||||||
values="ctrl.formValues.Services"
|
values="ctrl.formValues.Services"
|
||||||
app-name="ctrl.formValues.Name"
|
app-name="ctrl.formValues.Name"
|
||||||
selector="ctrl.formValues.Selector"
|
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"
|
is-edit-mode="ctrl.state.isEdit"
|
||||||
|
namespace="ctrl.formValues.ResourcePool.Namespace.Name"
|
||||||
></kube-services-form>
|
></kube-services-form>
|
||||||
<!-- kubernetes services options -->
|
<!-- kubernetes services options -->
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -22,7 +22,6 @@ import {
|
||||||
KubernetesApplicationEnvironmentVariableFormValue,
|
KubernetesApplicationEnvironmentVariableFormValue,
|
||||||
KubernetesApplicationFormValues,
|
KubernetesApplicationFormValues,
|
||||||
KubernetesApplicationPersistedFolderFormValue,
|
KubernetesApplicationPersistedFolderFormValue,
|
||||||
KubernetesApplicationPublishedPortFormValue,
|
|
||||||
KubernetesApplicationPlacementFormValue,
|
KubernetesApplicationPlacementFormValue,
|
||||||
KubernetesFormValidationReferences,
|
KubernetesFormValidationReferences,
|
||||||
} from 'Kubernetes/models/application/formValues';
|
} from 'Kubernetes/models/application/formValues';
|
||||||
|
@ -125,12 +124,6 @@ class KubernetesCreateApplicationController {
|
||||||
configMapPaths: new KubernetesFormValidationReferences(),
|
configMapPaths: new KubernetesFormValidationReferences(),
|
||||||
secretPaths: new KubernetesFormValidationReferences(),
|
secretPaths: new KubernetesFormValidationReferences(),
|
||||||
existingVolumes: new KubernetesFormValidationReferences(),
|
existingVolumes: new KubernetesFormValidationReferences(),
|
||||||
publishedPorts: {
|
|
||||||
containerPorts: new KubernetesFormValidationReferences(),
|
|
||||||
nodePorts: new KubernetesFormValidationReferences(),
|
|
||||||
ingressRoutes: new KubernetesFormValidationReferences(),
|
|
||||||
loadBalancerPorts: new KubernetesFormValidationReferences(),
|
|
||||||
},
|
|
||||||
placements: new KubernetesFormValidationReferences(),
|
placements: new KubernetesFormValidationReferences(),
|
||||||
},
|
},
|
||||||
isEdit: this.$state.params.namespace && this.$state.params.name,
|
isEdit: this.$state.params.namespace && this.$state.params.name,
|
||||||
|
@ -153,7 +146,6 @@ class KubernetesCreateApplicationController {
|
||||||
this.deployApplicationAsync = this.deployApplicationAsync.bind(this);
|
this.deployApplicationAsync = this.deployApplicationAsync.bind(this);
|
||||||
this.setPullImageValidity = this.setPullImageValidity.bind(this);
|
this.setPullImageValidity = this.setPullImageValidity.bind(this);
|
||||||
this.onChangeFileContent = this.onChangeFileContent.bind(this);
|
this.onChangeFileContent = this.onChangeFileContent.bind(this);
|
||||||
this.onServicePublishChange = this.onServicePublishChange.bind(this);
|
|
||||||
this.checkIngressesToUpdate = this.checkIngressesToUpdate.bind(this);
|
this.checkIngressesToUpdate = this.checkIngressesToUpdate.bind(this);
|
||||||
this.confirmUpdateApplicationAsync = this.confirmUpdateApplicationAsync.bind(this);
|
this.confirmUpdateApplicationAsync = this.confirmUpdateApplicationAsync.bind(this);
|
||||||
this.onDataAccessPolicyChange = this.onDataAccessPolicyChange.bind(this);
|
this.onDataAccessPolicyChange = this.onDataAccessPolicyChange.bind(this);
|
||||||
|
@ -517,154 +509,12 @@ class KubernetesCreateApplicationController {
|
||||||
|
|
||||||
/* #endregion */
|
/* #endregion */
|
||||||
|
|
||||||
/* #region PUBLISHED PORTS UI MANAGEMENT */
|
/* #region SERVICES UI MANAGEMENT */
|
||||||
onServicesChange(services) {
|
onServicesChange(services) {
|
||||||
return this.$async(async () => {
|
return this.$async(async () => {
|
||||||
this.formValues.Services = services;
|
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 */
|
/* #endregion */
|
||||||
|
|
||||||
/* #region STATE VALIDATION FUNCTIONS */
|
/* #region STATE VALIDATION FUNCTIONS */
|
||||||
|
@ -675,11 +525,7 @@ class KubernetesCreateApplicationController {
|
||||||
!this.state.duplicates.persistedFolders.hasRefs &&
|
!this.state.duplicates.persistedFolders.hasRefs &&
|
||||||
!this.state.duplicates.configMapPaths.hasRefs &&
|
!this.state.duplicates.configMapPaths.hasRefs &&
|
||||||
!this.state.duplicates.secretPaths.hasRefs &&
|
!this.state.duplicates.secretPaths.hasRefs &&
|
||||||
!this.state.duplicates.existingVolumes.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
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -860,22 +706,10 @@ class KubernetesCreateApplicationController {
|
||||||
}
|
}
|
||||||
/* #endregion */
|
/* #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) {
|
isEditAndNotNewPlacement(index) {
|
||||||
return this.state.isEdit && !this.formValues.Placements[index].IsNew;
|
return this.state.isEdit && !this.formValues.Placements[index].IsNew;
|
||||||
}
|
}
|
||||||
|
|
||||||
isNewAndNotFirst(index) {
|
|
||||||
return !this.state.isEdit && index !== 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
showPlacementPolicySection() {
|
showPlacementPolicySection() {
|
||||||
const placements = _.filter(this.formValues.Placements, { NeedsDeletion: false });
|
const placements = _.filter(this.formValues.Placements, { NeedsDeletion: false });
|
||||||
return placements.length !== 0;
|
return placements.length !== 0;
|
||||||
|
@ -897,8 +731,7 @@ class KubernetesCreateApplicationController {
|
||||||
const invalid = !this.isValid();
|
const invalid = !this.isValid();
|
||||||
const hasNoChanges = this.isEditAndNoChangesMade();
|
const hasNoChanges = this.isEditAndNoChangesMade();
|
||||||
const nonScalable = this.isNonScalable();
|
const nonScalable = this.isNonScalable();
|
||||||
const isPublishingWithoutPorts = this.formValues.IsPublishingService && this.hasNoPublishedPorts();
|
return overflow || autoScalerOverflow || inProgress || invalid || hasNoChanges || nonScalable;
|
||||||
return overflow || autoScalerOverflow || inProgress || invalid || hasNoChanges || nonScalable || isPublishingWithoutPorts;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isExternalApplication() {
|
isExternalApplication() {
|
||||||
|
@ -908,33 +741,6 @@ class KubernetesCreateApplicationController {
|
||||||
return false;
|
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 */
|
/* #endregion */
|
||||||
|
|
||||||
/* #region DATA AUTO REFRESH */
|
/* #region DATA AUTO REFRESH */
|
||||||
|
@ -1061,6 +867,7 @@ class KubernetesCreateApplicationController {
|
||||||
return this.$async(async () => {
|
return this.$async(async () => {
|
||||||
try {
|
try {
|
||||||
this.ingresses = await this.KubernetesIngressService.get(namespace);
|
this.ingresses = await this.KubernetesIngressService.get(namespace);
|
||||||
|
this.ingressPaths = this.ingresses.flatMap((ingress) => ingress.Paths);
|
||||||
this.ingressHostnames = this.ingresses.length ? this.ingresses[0].Hosts : [];
|
this.ingressHostnames = this.ingresses.length ? this.ingresses[0].Hosts : [];
|
||||||
if (!this.publishViaIngressEnabled()) {
|
if (!this.publishViaIngressEnabled()) {
|
||||||
if (this.savedFormValues) {
|
if (this.savedFormValues) {
|
||||||
|
@ -1093,7 +900,6 @@ class KubernetesCreateApplicationController {
|
||||||
this.clearConfigMaps();
|
this.clearConfigMaps();
|
||||||
this.clearSecrets();
|
this.clearSecrets();
|
||||||
this.resetPersistedFolders();
|
this.resetPersistedFolders();
|
||||||
this.resetPublishedPorts();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onResourcePoolSelectionChange() {
|
onResourcePoolSelectionChange() {
|
||||||
|
@ -1115,7 +921,7 @@ class KubernetesCreateApplicationController {
|
||||||
this.formValues.ApplicationOwner = this.Authentication.getUserDetails().username;
|
this.formValues.ApplicationOwner = this.Authentication.getUserDetails().username;
|
||||||
// combine the secrets and configmap form values when submitting the form
|
// combine the secrets and configmap form values when submitting the form
|
||||||
_.remove(this.formValues.Configurations, (item) => item.SelectedConfiguration === undefined);
|
_.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.Notifications.success('Request to deploy application successfully submitted', this.formValues.Name);
|
||||||
this.$state.go('kubernetes.applications');
|
this.$state.go('kubernetes.applications');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -1137,7 +943,7 @@ class KubernetesCreateApplicationController {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.state.actionInProgress = true;
|
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.Notifications.success('Success', 'Request to update application successfully submitted');
|
||||||
this.$state.go('kubernetes.applications.application', { name: this.application.Name, namespace: this.application.ResourcePool });
|
this.$state.go('kubernetes.applications.application', { name: this.application.Name, namespace: this.application.ResourcePool });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -1191,7 +997,7 @@ class KubernetesCreateApplicationController {
|
||||||
});
|
});
|
||||||
ingressesForService.forEach((ingressForService) => {
|
ingressesForService.forEach((ingressForService) => {
|
||||||
updatedOldPorts.forEach((servicePort, pIndex) => {
|
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
|
// 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);
|
const doesIngressPathMatchServicePort = ingressForService.Paths.find((ingPath) => ingPath.ServiceName === updatedService.Name && ingPath.Port === servicePort.port);
|
||||||
if (!doesIngressPathMatchServicePort) {
|
if (!doesIngressPathMatchServicePort) {
|
||||||
|
@ -1322,6 +1128,9 @@ class KubernetesCreateApplicationController {
|
||||||
this.ingresses
|
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) {
|
if (this.application.ApplicationKind) {
|
||||||
this.state.appType = KubernetesDeploymentTypes[this.application.ApplicationKind.toUpperCase()];
|
this.state.appType = KubernetesDeploymentTypes[this.application.ApplicationKind.toUpperCase()];
|
||||||
if (this.application.ApplicationKind === KubernetesDeploymentTypes.URL) {
|
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.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);
|
this.oldFormValues = angular.copy(this.formValues);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.Notifications.error('Failure', err, 'Unable to load view data');
|
this.Notifications.error('Failure', err, 'Unable to load view data');
|
||||||
|
|
|
@ -5,18 +5,14 @@ import { KubernetesDeployment } from 'Kubernetes/models/deployment/models';
|
||||||
import { KubernetesStatefulSet } from 'Kubernetes/models/stateful-set/models';
|
import { KubernetesStatefulSet } from 'Kubernetes/models/stateful-set/models';
|
||||||
import { KubernetesDaemonSet } from 'Kubernetes/models/daemon-set/models';
|
import { KubernetesDaemonSet } from 'Kubernetes/models/daemon-set/models';
|
||||||
import { KubernetesService, KubernetesServiceTypes } from 'Kubernetes/models/service/models';
|
import { KubernetesService, KubernetesServiceTypes } from 'Kubernetes/models/service/models';
|
||||||
import {
|
import { KubernetesApplication, KubernetesApplicationDeploymentTypes, KubernetesApplicationTypes } from 'Kubernetes/models/application/models';
|
||||||
KubernetesApplication,
|
|
||||||
KubernetesApplicationDeploymentTypes,
|
|
||||||
KubernetesApplicationPublishingTypes,
|
|
||||||
KubernetesApplicationTypes,
|
|
||||||
} from 'Kubernetes/models/application/models';
|
|
||||||
import { KubernetesHorizontalPodAutoScalerHelper } from 'Kubernetes/horizontal-pod-auto-scaler/helper';
|
import { KubernetesHorizontalPodAutoScalerHelper } from 'Kubernetes/horizontal-pod-auto-scaler/helper';
|
||||||
import { KubernetesHorizontalPodAutoScalerConverter } from 'Kubernetes/horizontal-pod-auto-scaler/converter';
|
import { KubernetesHorizontalPodAutoScalerConverter } from 'Kubernetes/horizontal-pod-auto-scaler/converter';
|
||||||
import KubernetesApplicationConverter from 'Kubernetes/converters/application';
|
import KubernetesApplicationConverter from 'Kubernetes/converters/application';
|
||||||
import KubernetesServiceConverter from 'Kubernetes/converters/service';
|
import KubernetesServiceConverter from 'Kubernetes/converters/service';
|
||||||
import { KubernetesIngressConverter } from 'Kubernetes/ingress/converter';
|
import { KubernetesIngressConverter } from 'Kubernetes/ingress/converter';
|
||||||
import KubernetesPersistentVolumeClaimConverter from 'Kubernetes/converters/persistentVolumeClaim';
|
import KubernetesPersistentVolumeClaimConverter from 'Kubernetes/converters/persistentVolumeClaim';
|
||||||
|
import { generateNewIngressesFromFormPaths } from '@/react/kubernetes/applications/CreateView/application-services/utils';
|
||||||
|
|
||||||
const { CREATE, UPDATE, DELETE } = KubernetesResourceActions;
|
const { CREATE, UPDATE, DELETE } = KubernetesResourceActions;
|
||||||
|
|
||||||
|
@ -45,21 +41,16 @@ function getCreatedApplicationResources(formValues) {
|
||||||
if (services) {
|
if (services) {
|
||||||
services.forEach((service) => {
|
services.forEach((service) => {
|
||||||
resources.push({ action: CREATE, kind: KubernetesResourceTypes.SERVICE, name: service.Name, type: service.Type || KubernetesServiceTypes.CLUSTER_IP });
|
resources.push({ action: CREATE, kind: KubernetesResourceTypes.SERVICE, name: service.Name, type: service.Type || KubernetesServiceTypes.CLUSTER_IP });
|
||||||
if (formValues.OriginalIngresses.length !== 0) {
|
// Ingress
|
||||||
const ingresses = KubernetesIngressConverter.newApplicationFormValuesToIngresses(formValues, service.Name, service.Ports);
|
const newServicePorts = formValues.Services.flatMap((service) => service.Ports);
|
||||||
resources.push(...getIngressUpdateSummary(formValues.OriginalIngresses, ingresses));
|
const newIngresses = generateNewIngressesFromFormPaths(formValues.OriginalIngresses, newServicePorts);
|
||||||
}
|
resources.push(...getIngressUpdateSummary(formValues.OriginalIngresses, newIngresses));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (service) {
|
if (service) {
|
||||||
// Service
|
// Service
|
||||||
resources.push({ action: CREATE, kind: KubernetesResourceTypes.SERVICE, name: service.Name, type: service.Type || KubernetesServiceTypes.CLUSTER_IP });
|
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) {
|
if (app instanceof KubernetesStatefulSet) {
|
||||||
|
@ -147,28 +138,18 @@ function getUpdatedApplicationResources(oldFormValues, newFormValues) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newFormValues.PublishingType === KubernetesApplicationPublishingTypes.INGRESS || oldFormValues.PublishingType === KubernetesApplicationPublishingTypes.INGRESS) {
|
// Ingress
|
||||||
// Ingress
|
const oldIngresses = KubernetesIngressConverter.applicationFormValuesToIngresses(oldFormValues, oldService.Name);
|
||||||
const oldIngresses = KubernetesIngressConverter.applicationFormValuesToIngresses(oldFormValues, oldService.Name);
|
const newServicePorts = newFormValues.Services.flatMap((service) => service.Ports);
|
||||||
const newIngresses = KubernetesIngressConverter.applicationFormValuesToIngresses(newFormValues, newService.Name);
|
const oldServicePorts = oldFormValues.Services.flatMap((service) => service.Ports);
|
||||||
resources.push(...getIngressUpdateSummary(oldIngresses, newIngresses));
|
const newIngresses = generateNewIngressesFromFormPaths(newFormValues.OriginalIngresses, newServicePorts, oldServicePorts);
|
||||||
}
|
resources.push(...getIngressUpdateSummary(oldIngresses, newIngresses));
|
||||||
} else if (!oldService && newService) {
|
} else if (!oldService && newService) {
|
||||||
// Service
|
// Service
|
||||||
resources.push({ action: CREATE, kind: KubernetesResourceTypes.SERVICE, name: newService.Name, type: newService.Type || KubernetesServiceTypes.CLUSTER_IP });
|
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) {
|
} else if (oldService && !newService) {
|
||||||
// Service
|
// Service
|
||||||
resources.push({ action: DELETE, kind: KubernetesResourceTypes.SERVICE, name: oldService.Name, type: oldService.Type || KubernetesServiceTypes.CLUSTER_IP });
|
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);
|
const newKind = KubernetesHorizontalPodAutoScalerHelper.getApplicationTypeString(newApp);
|
||||||
|
|
|
@ -7,11 +7,13 @@ import { FormSectionTitle } from '../FormSectionTitle';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: ReactNode;
|
title: ReactNode;
|
||||||
|
titleSize?: 'sm' | 'md' | 'lg';
|
||||||
isFoldable?: boolean;
|
isFoldable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormSection({
|
export function FormSection({
|
||||||
title,
|
title,
|
||||||
|
titleSize = 'md',
|
||||||
children,
|
children,
|
||||||
isFoldable = false,
|
isFoldable = false,
|
||||||
}: PropsWithChildren<Props>) {
|
}: PropsWithChildren<Props>) {
|
||||||
|
@ -19,7 +21,10 @@ export function FormSection({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FormSectionTitle htmlFor={isFoldable ? `foldingButton${title}` : ''}>
|
<FormSectionTitle
|
||||||
|
htmlFor={isFoldable ? `foldingButton${title}` : ''}
|
||||||
|
titleSize={titleSize}
|
||||||
|
>
|
||||||
{isFoldable && (
|
{isFoldable && (
|
||||||
<button
|
<button
|
||||||
id={`foldingButton${title}`}
|
id={`foldingButton${title}`}
|
||||||
|
|
|
@ -1,22 +1,43 @@
|
||||||
|
import clsx from 'clsx';
|
||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
htmlFor?: string;
|
htmlFor?: string;
|
||||||
|
titleSize?: 'sm' | 'md' | 'lg';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tailwindTitleSize = {
|
||||||
|
sm: 'text-sm',
|
||||||
|
md: 'text-base',
|
||||||
|
lg: 'text-lg',
|
||||||
|
};
|
||||||
|
|
||||||
export function FormSectionTitle({
|
export function FormSectionTitle({
|
||||||
children,
|
children,
|
||||||
htmlFor,
|
htmlFor,
|
||||||
|
titleSize = 'md',
|
||||||
}: PropsWithChildren<Props>) {
|
}: PropsWithChildren<Props>) {
|
||||||
if (htmlFor) {
|
if (htmlFor) {
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
htmlFor={htmlFor}
|
htmlFor={htmlFor}
|
||||||
className="col-sm-12 form-section-title flex cursor-pointer items-center"
|
className={clsx(
|
||||||
|
'col-sm-12 mt-1 mb-2 flex cursor-pointer items-center pl-0 font-medium',
|
||||||
|
tailwindTitleSize[titleSize]
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <div className="col-sm-12 form-section-title">{children}</div>;
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'col-sm-12 mt-1 mb-2 pl-0 font-medium',
|
||||||
|
tailwindTitleSize[titleSize]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
import { useInputGroupContext } from './InputGroup';
|
import { useInputGroupContext } from './InputGroup';
|
||||||
|
|
||||||
|
@ -7,5 +8,16 @@ export function InputGroupButtonWrapper({
|
||||||
}: PropsWithChildren<unknown>) {
|
}: PropsWithChildren<unknown>) {
|
||||||
useInputGroupContext();
|
useInputGroupContext();
|
||||||
|
|
||||||
return <span className="input-group-btn">{children}</span>;
|
return (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'input-group-btn [&>button]:!ml-0',
|
||||||
|
// the button should be rounded at the end (right) if it's the last child and start (left) if it's the first child
|
||||||
|
// if the button is in the middle of the group, it shouldn't be rounded
|
||||||
|
'[&>button]:!rounded-none [&:last-child>button]:!rounded-r-[5px] [&:first-child>button]:!rounded-l-[5px]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,6 +19,14 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.portainer-selector-root.sm .portainer-selector__placeholder {
|
||||||
|
@apply text-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portainer-selector-root.md .portainer-selector__placeholder {
|
||||||
|
@apply text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
.portainer-selector__indicator-separator {
|
.portainer-selector__indicator-separator {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
@ -27,6 +35,29 @@
|
||||||
.portainer-selector-root .portainer-selector__control {
|
.portainer-selector-root .portainer-selector__control {
|
||||||
border-color: var(--border-form-control-color);
|
border-color: var(--border-form-control-color);
|
||||||
background-color: var(--bg-inputbox);
|
background-color: var(--bg-inputbox);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Match the same box shadow styles as the other portainer inputs, which are inherited from the bootstrap form-control class */
|
||||||
|
.portainer-selector-root .portainer-selector__control--is-focused {
|
||||||
|
border-color: #66afe9;
|
||||||
|
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(102, 175, 233, 0.6);
|
||||||
|
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(102, 175, 233, 0.6);
|
||||||
|
transition: border-color ease-in-out 0.15s, -webkit-box-shadow ease-in-out 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portainer-selector-root .portainer-selector__control--is-focused:hover {
|
||||||
|
border-color: #66afe9;
|
||||||
|
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(102, 175, 233, 0.6);
|
||||||
|
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(102, 175, 233, 0.6);
|
||||||
|
transition: border-color ease-in-out 0.15s, -webkit-box-shadow ease-in-out 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portainer-selector-root.sm .portainer-selector__control {
|
||||||
|
min-height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portainer-selector-root.md .portainer-selector__control {
|
||||||
min-height: 34px;
|
min-height: 34px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -38,6 +69,11 @@
|
||||||
color: var(--text-form-control-color);
|
color: var(--text-form-control-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.portainer-selector-root.sm .portainer-selector__input-container {
|
||||||
|
height: 20px;
|
||||||
|
@apply text-xs;
|
||||||
|
}
|
||||||
|
|
||||||
.portainer-selector-root .portainer-selector__dropdown-indicator {
|
.portainer-selector-root .portainer-selector__dropdown-indicator {
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
}
|
}
|
||||||
|
@ -56,6 +92,14 @@
|
||||||
color: var(--single-value-option-text-color);
|
color: var(--single-value-option-text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.portainer-selector-root.sm .portainer-selector__single-value {
|
||||||
|
@apply text-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portainer-selector-root.md .portainer-selector__single-value {
|
||||||
|
@apply text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
/* Menu colors */
|
/* Menu colors */
|
||||||
.portainer-selector__menu {
|
.portainer-selector__menu {
|
||||||
--bg-multiselect-color: var(--white-color);
|
--bg-multiselect-color: var(--white-color);
|
||||||
|
@ -100,6 +144,10 @@
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.portainer-selector-root.sm .portainer-selector__menu .portainer-selector__option {
|
||||||
|
@apply text-xs;
|
||||||
|
}
|
||||||
|
|
||||||
.portainer-selector__menu-portal .portainer-selector__menu .portainer-selector__option:active,
|
.portainer-selector__menu-portal .portainer-selector__menu .portainer-selector__option:active,
|
||||||
.portainer-selector__menu-portal .portainer-selector__menu .portainer-selector__option--is-focused,
|
.portainer-selector__menu-portal .portainer-selector__menu .portainer-selector__option--is-focused,
|
||||||
.portainer-selector-root .portainer-selector__menu .portainer-selector__option:active,
|
.portainer-selector-root .portainer-selector__menu .portainer-selector__option:active,
|
||||||
|
|
|
@ -20,14 +20,22 @@ type RegularProps<
|
||||||
Option = DefaultOption,
|
Option = DefaultOption,
|
||||||
IsMulti extends boolean = false,
|
IsMulti extends boolean = false,
|
||||||
Group extends GroupBase<Option> = GroupBase<Option>
|
Group extends GroupBase<Option> = GroupBase<Option>
|
||||||
> = { isCreatable?: false } & ReactSelectProps<Option, IsMulti, Group> &
|
> = { isCreatable?: false; size?: 'sm' | 'md' } & ReactSelectProps<
|
||||||
|
Option,
|
||||||
|
IsMulti,
|
||||||
|
Group
|
||||||
|
> &
|
||||||
RefAttributes<ReactSelectType<Option, IsMulti, Group>>;
|
RefAttributes<ReactSelectType<Option, IsMulti, Group>>;
|
||||||
|
|
||||||
type CreatableProps<
|
type CreatableProps<
|
||||||
Option = DefaultOption,
|
Option = DefaultOption,
|
||||||
IsMulti extends boolean = false,
|
IsMulti extends boolean = false,
|
||||||
Group extends GroupBase<Option> = GroupBase<Option>
|
Group extends GroupBase<Option> = GroupBase<Option>
|
||||||
> = { isCreatable: true } & ReactSelectCreatableProps<Option, IsMulti, Group>;
|
> = { isCreatable: true; size?: 'sm' | 'md' } & ReactSelectCreatableProps<
|
||||||
|
Option,
|
||||||
|
IsMulti,
|
||||||
|
Group
|
||||||
|
>;
|
||||||
|
|
||||||
type Props<
|
type Props<
|
||||||
Option = DefaultOption,
|
Option = DefaultOption,
|
||||||
|
@ -41,12 +49,17 @@ export function Select<
|
||||||
Option = DefaultOption,
|
Option = DefaultOption,
|
||||||
IsMulti extends boolean = false,
|
IsMulti extends boolean = false,
|
||||||
Group extends GroupBase<Option> = GroupBase<Option>
|
Group extends GroupBase<Option> = GroupBase<Option>
|
||||||
>({ className, isCreatable = false, ...props }: Props<Option, IsMulti, Group>) {
|
>({
|
||||||
|
className,
|
||||||
|
isCreatable = false,
|
||||||
|
size = 'md',
|
||||||
|
...props
|
||||||
|
}: Props<Option, IsMulti, Group>) {
|
||||||
const Component = isCreatable ? ReactSelectCreatable : ReactSelect;
|
const Component = isCreatable ? ReactSelectCreatable : ReactSelect;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Component
|
<Component
|
||||||
className={clsx(className, 'portainer-selector-root')}
|
className={clsx(className, 'portainer-selector-root', size)}
|
||||||
classNamePrefix="portainer-selector"
|
classNamePrefix="portainer-selector"
|
||||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
@ -1,167 +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 { Card } from '@@/Card';
|
|
||||||
import { Widget } from '@@/Widget';
|
|
||||||
|
|
||||||
import { isServicePortError, newPort } from './utils';
|
|
||||||
import { ServiceFormValues, ServicePort } from './types';
|
|
||||||
import { ServicePortInput } from './ServicePortInput';
|
|
||||||
import { ContainerPortInput } from './ContainerPortInput';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
services: ServiceFormValues[];
|
|
||||||
serviceIndex: number;
|
|
||||||
onChangeService: (services: ServiceFormValues[]) => void;
|
|
||||||
servicePorts: ServicePort[];
|
|
||||||
onChangePort: (servicePorts: ServicePort[]) => void;
|
|
||||||
serviceName?: string;
|
|
||||||
errors?: string | string[] | FormikErrors<ServicePort>[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ClusterIpServiceForm({
|
|
||||||
services,
|
|
||||||
serviceIndex,
|
|
||||||
onChangeService,
|
|
||||||
servicePorts,
|
|
||||||
onChangePort,
|
|
||||||
errors,
|
|
||||||
serviceName,
|
|
||||||
}: Props) {
|
|
||||||
const newClusterIpPort = newPort(serviceName);
|
|
||||||
return (
|
|
||||||
<Widget key={serviceIndex}>
|
|
||||||
<Widget.Body>
|
|
||||||
<div className="mb-4 flex justify-between">
|
|
||||||
<div className="text-muted vertical-center">ClusterIP service</div>
|
|
||||||
<Button
|
|
||||||
icon={Trash2}
|
|
||||||
color="dangerlight"
|
|
||||||
className="!ml-0 flex-none"
|
|
||||||
onClick={() => {
|
|
||||||
// remove the service at index in an immutable way
|
|
||||||
const newServices = [
|
|
||||||
...services.slice(0, serviceIndex),
|
|
||||||
...services.slice(serviceIndex + 1),
|
|
||||||
];
|
|
||||||
onChangeService(newServices);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Remove service
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="control-label !mb-2 !pt-0 text-left">Ports</div>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{servicePorts.map((servicePort, portIndex) => {
|
|
||||||
const error = errors?.[portIndex];
|
|
||||||
const servicePortError = isServicePortError<ServicePort>(error)
|
|
||||||
? error
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={portIndex}
|
|
||||||
className="flex flex-grow flex-wrap justify-between gap-x-4 gap-y-1"
|
|
||||||
>
|
|
||||||
<div className="inline-flex min-w-min flex-grow basis-3/4 flex-wrap gap-2">
|
|
||||||
<div className="flex min-w-min basis-1/3 flex-col">
|
|
||||||
<ContainerPortInput
|
|
||||||
index={portIndex}
|
|
||||||
value={servicePort.targetPort}
|
|
||||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
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 && (
|
|
||||||
<FormError>{servicePortError.targetPort}</FormError>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex min-w-min basis-1/3 flex-col">
|
|
||||||
<ServicePortInput
|
|
||||||
index={portIndex}
|
|
||||||
value={servicePort.port}
|
|
||||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const newServicePorts = [...servicePorts];
|
|
||||||
newServicePorts[portIndex] = {
|
|
||||||
...newServicePorts[portIndex],
|
|
||||||
port:
|
|
||||||
e.target.value === ''
|
|
||||||
? undefined
|
|
||||||
: Number(e.target.value),
|
|
||||||
};
|
|
||||||
onChangePort(newServicePorts);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{servicePortError?.port && (
|
|
||||||
<FormError>{servicePortError.port}</FormError>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ButtonSelector
|
|
||||||
className="h-[30px]"
|
|
||||||
onChange={(value) => {
|
|
||||||
const newServicePorts = [...servicePorts];
|
|
||||||
newServicePorts[portIndex] = {
|
|
||||||
...newServicePorts[portIndex],
|
|
||||||
protocol: value,
|
|
||||||
};
|
|
||||||
onChangePort(newServicePorts);
|
|
||||||
}}
|
|
||||||
value={servicePort.protocol || 'TCP'}
|
|
||||||
options={[{ value: 'TCP' }, { value: 'UDP' }]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
disabled={servicePorts.length === 1}
|
|
||||||
size="small"
|
|
||||||
className="!ml-0 h-[30px]"
|
|
||||||
color="dangerlight"
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
// remove the port at the index in an immutable way
|
|
||||||
const newServicePorts = [
|
|
||||||
...servicePorts.slice(0, portIndex),
|
|
||||||
...servicePorts.slice(portIndex + 1),
|
|
||||||
];
|
|
||||||
onChangePort(newServicePorts);
|
|
||||||
}}
|
|
||||||
data-cy={`k8sAppCreate-rmPortButton_${portIndex}`}
|
|
||||||
icon={Trash2}
|
|
||||||
>
|
|
||||||
Remove port
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<div className="flex">
|
|
||||||
<Button
|
|
||||||
icon={Plus}
|
|
||||||
color="default"
|
|
||||||
className="!ml-0"
|
|
||||||
onClick={() => {
|
|
||||||
const newServicesPorts = [...servicePorts, newClusterIpPort];
|
|
||||||
onChangePort(newServicesPorts);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add port
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Widget.Body>
|
|
||||||
</Widget>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,23 +1,23 @@
|
||||||
import { SchemaOf, array, boolean, mixed, number, object, string } from 'yup';
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { FormikErrors } from 'formik';
|
import { FormikErrors } from 'formik';
|
||||||
|
|
||||||
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
|
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
|
||||||
|
|
||||||
import { Badge } from '@@/Badge';
|
import { FormSection } from '@@/form-components/FormSection';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ServiceFormValues,
|
ServiceFormValues,
|
||||||
ServicePort,
|
|
||||||
ServiceTypeAngularEnum,
|
ServiceTypeAngularEnum,
|
||||||
ServiceTypeOption,
|
ServiceTypeOption,
|
||||||
ServiceTypeValue,
|
ServiceTypeValue,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { generateUniqueName } from './utils';
|
import { generateUniqueName } from './utils';
|
||||||
import { ClusterIpServicesForm } from './ClusterIpServicesForm';
|
import { ClusterIpServicesForm } from './cluster-ip/ClusterIpServicesForm';
|
||||||
import { ServiceTabs } from './ServiceTabs';
|
import { ServiceTabs } from './components/ServiceTabs';
|
||||||
import { NodePortServicesForm } from './NodePortServicesForm';
|
import { NodePortServicesForm } from './node-port/NodePortServicesForm';
|
||||||
import { LoadBalancerServicesForm } from './LoadBalancerServicesForm';
|
import { LoadBalancerServicesForm } from './load-balancer/LoadBalancerServicesForm';
|
||||||
|
import { ServiceTabLabel } from './components/ServiceTabLabel';
|
||||||
|
import { PublishingExplaination } from './PublishingExplaination';
|
||||||
|
|
||||||
const serviceTypeEnumsToValues: Record<
|
const serviceTypeEnumsToValues: Record<
|
||||||
ServiceTypeAngularEnum,
|
ServiceTypeAngularEnum,
|
||||||
|
@ -35,6 +35,7 @@ interface Props {
|
||||||
appName: string;
|
appName: string;
|
||||||
selector: Record<string, string>;
|
selector: Record<string, string>;
|
||||||
isEditMode: boolean;
|
isEditMode: boolean;
|
||||||
|
namespace?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KubeServicesForm({
|
export function KubeServicesForm({
|
||||||
|
@ -44,15 +45,19 @@ export function KubeServicesForm({
|
||||||
appName,
|
appName,
|
||||||
selector,
|
selector,
|
||||||
isEditMode,
|
isEditMode,
|
||||||
|
namespace,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [selectedServiceType, setSelectedServiceType] =
|
const [selectedServiceType, setSelectedServiceType] =
|
||||||
useState<ServiceTypeValue>('ClusterIP');
|
useState<ServiceTypeValue>('ClusterIP');
|
||||||
|
|
||||||
// when the appName changes, update the names for each service
|
// when the appName changes, update the names for each service
|
||||||
// and the serviceNames for each service port
|
// and the serviceNames for each service port
|
||||||
|
const newServiceNames = useMemo(
|
||||||
|
() => getUniqNames(appName, services),
|
||||||
|
[appName, services]
|
||||||
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEditMode) {
|
if (!isEditMode) {
|
||||||
const newServiceNames = getUniqNames(appName, services);
|
|
||||||
const newServices = services.map((service, index) => {
|
const newServices = services.map((service, index) => {
|
||||||
const newServiceName = newServiceNames[index];
|
const newServiceName = newServiceNames[index];
|
||||||
const newServicePorts = service.Ports.map((port) => ({
|
const newServicePorts = service.Ports.map((port) => ({
|
||||||
|
@ -70,53 +75,49 @@ export function KubeServicesForm({
|
||||||
() => getServiceTypeCounts(services),
|
() => getServiceTypeCounts(services),
|
||||||
[services]
|
[services]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const serviceTypeHasErrors = useMemo(
|
||||||
|
() => getServiceTypeHasErrors(services, errors),
|
||||||
|
[services, errors]
|
||||||
|
);
|
||||||
|
|
||||||
const serviceTypeOptions: ServiceTypeOption[] = [
|
const serviceTypeOptions: ServiceTypeOption[] = [
|
||||||
{
|
{
|
||||||
value: 'ClusterIP',
|
value: 'ClusterIP',
|
||||||
label: (
|
label: (
|
||||||
<div className="inline-flex items-center">
|
<ServiceTabLabel
|
||||||
ClusterIP services
|
serviceTypeLabel="ClusterIP services"
|
||||||
{serviceTypeCounts.ClusterIP && (
|
serviceTypeCount={serviceTypeCounts.ClusterIP}
|
||||||
<Badge className="ml-2 flex-none">
|
serviceTypeHasErrors={serviceTypeHasErrors.ClusterIP}
|
||||||
{serviceTypeCounts.ClusterIP}
|
/>
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'NodePort',
|
value: 'NodePort',
|
||||||
label: (
|
label: (
|
||||||
<div className="inline-flex items-center">
|
<ServiceTabLabel
|
||||||
NodePort services
|
serviceTypeLabel="NodePort services"
|
||||||
{serviceTypeCounts.NodePort && (
|
serviceTypeCount={serviceTypeCounts.NodePort}
|
||||||
<Badge className="ml-2 flex-none">
|
serviceTypeHasErrors={serviceTypeHasErrors.NodePort}
|
||||||
{serviceTypeCounts.NodePort}
|
/>
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'LoadBalancer',
|
value: 'LoadBalancer',
|
||||||
label: (
|
label: (
|
||||||
<div className="inline-flex items-center">
|
<ServiceTabLabel
|
||||||
LoadBalancer services
|
serviceTypeLabel="LoadBalancer services"
|
||||||
{serviceTypeCounts.LoadBalancer && (
|
serviceTypeCount={serviceTypeCounts.LoadBalancer}
|
||||||
<Badge className="ml-2 flex-none">
|
serviceTypeHasErrors={serviceTypeHasErrors.LoadBalancer}
|
||||||
{serviceTypeCounts.LoadBalancer}
|
/>
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="col-sm-12 form-section-title">
|
<FormSection title="Publishing the application" />
|
||||||
Publishing the application
|
<PublishingExplaination />
|
||||||
</div>
|
|
||||||
<ServiceTabs
|
<ServiceTabs
|
||||||
serviceTypeOptions={serviceTypeOptions}
|
serviceTypeOptions={serviceTypeOptions}
|
||||||
selectedServiceType={selectedServiceType}
|
selectedServiceType={selectedServiceType}
|
||||||
|
@ -129,6 +130,8 @@ export function KubeServicesForm({
|
||||||
errors={errors}
|
errors={errors}
|
||||||
appName={appName}
|
appName={appName}
|
||||||
selector={selector}
|
selector={selector}
|
||||||
|
namespace={namespace}
|
||||||
|
isEditMode={isEditMode}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{selectedServiceType === 'NodePort' && (
|
{selectedServiceType === 'NodePort' && (
|
||||||
|
@ -138,6 +141,8 @@ export function KubeServicesForm({
|
||||||
errors={errors}
|
errors={errors}
|
||||||
appName={appName}
|
appName={appName}
|
||||||
selector={selector}
|
selector={selector}
|
||||||
|
namespace={namespace}
|
||||||
|
isEditMode={isEditMode}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{selectedServiceType === 'LoadBalancer' && (
|
{selectedServiceType === 'LoadBalancer' && (
|
||||||
|
@ -147,6 +152,8 @@ export function KubeServicesForm({
|
||||||
errors={errors}
|
errors={errors}
|
||||||
appName={appName}
|
appName={appName}
|
||||||
selector={selector}
|
selector={selector}
|
||||||
|
namespace={namespace}
|
||||||
|
isEditMode={isEditMode}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -189,222 +196,22 @@ function getServiceTypeCounts(
|
||||||
}, {} as Record<ServiceTypeValue, number>);
|
}, {} as Record<ServiceTypeValue, number>);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
* getServiceTypeHasErrors returns a map of service types to whether or not they have errors
|
||||||
// to make the converted values and formValues objects to be the same
|
*/
|
||||||
interface NodePortValues {
|
function getServiceTypeHasErrors(
|
||||||
Port: number;
|
services: ServiceFormValues[],
|
||||||
TargetPort: number;
|
errors: FormikErrors<ServiceFormValues[] | undefined>
|
||||||
NodePort: number;
|
): Record<ServiceTypeValue, boolean> {
|
||||||
Name?: string;
|
return services.reduce((acc, service, index) => {
|
||||||
Protocol?: string;
|
const type = serviceTypeEnumsToValues[service.Type];
|
||||||
Ingress?: string;
|
const serviceHasErrors = !!errors?.[index];
|
||||||
}
|
// if the service type already has an error, don't overwrite it
|
||||||
|
if (acc[type] === true) return acc;
|
||||||
type ServiceValues = {
|
// otherwise, set the error to the value of serviceHasErrors
|
||||||
Type: number;
|
return {
|
||||||
Name: string;
|
...acc,
|
||||||
Ports: NodePortValues[];
|
[type]: serviceHasErrors,
|
||||||
};
|
};
|
||||||
|
}, {} as Record<ServiceTypeValue, boolean>);
|
||||||
type NodePortValidationContext = {
|
|
||||||
nodePortServices: ServiceValues[];
|
|
||||||
formServices: ServiceFormValues[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function kubeServicesValidation(
|
|
||||||
validationData?: NodePortValidationContext
|
|
||||||
): SchemaOf<ServiceFormValues[]> {
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<ServicePort>[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LoadBalancerServiceForm({
|
|
||||||
services,
|
|
||||||
serviceIndex,
|
|
||||||
onChangeService,
|
|
||||||
servicePorts,
|
|
||||||
onChangePort,
|
|
||||||
errors,
|
|
||||||
serviceName,
|
|
||||||
}: Props) {
|
|
||||||
const newLoadBalancerPort = newPort(serviceName);
|
|
||||||
return (
|
|
||||||
<Widget key={serviceIndex}>
|
|
||||||
<Widget.Body>
|
|
||||||
<div className="mb-4 flex justify-between">
|
|
||||||
<div className="text-muted vertical-center">LoadBalancer service</div>
|
|
||||||
<Button
|
|
||||||
icon={Trash2}
|
|
||||||
color="dangerlight"
|
|
||||||
className="!ml-0"
|
|
||||||
onClick={() => {
|
|
||||||
// remove the service at index in an immutable way
|
|
||||||
const newServices = [
|
|
||||||
...services.slice(0, serviceIndex),
|
|
||||||
...services.slice(serviceIndex + 1),
|
|
||||||
];
|
|
||||||
onChangeService(newServices);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Remove service
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="control-label !mb-2 !pt-0 text-left">Ports</div>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{servicePorts.map((servicePort, portIndex) => {
|
|
||||||
const error = errors?.[portIndex];
|
|
||||||
const servicePortError = isServicePortError<ServicePort>(error)
|
|
||||||
? error
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={portIndex}
|
|
||||||
className="flex flex-grow flex-wrap justify-between gap-x-4 gap-y-1"
|
|
||||||
>
|
|
||||||
<div className="inline-flex min-w-min flex-grow basis-3/4 flex-wrap gap-2">
|
|
||||||
<div className="flex min-w-min basis-1/4 flex-col">
|
|
||||||
<ContainerPortInput
|
|
||||||
index={portIndex}
|
|
||||||
value={servicePort.targetPort}
|
|
||||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
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 && (
|
|
||||||
<FormError>{servicePortError.targetPort}</FormError>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex min-w-min basis-1/4 flex-col">
|
|
||||||
<ServicePortInput
|
|
||||||
index={portIndex}
|
|
||||||
value={servicePort.port}
|
|
||||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const newServicePorts = [...servicePorts];
|
|
||||||
newServicePorts[portIndex] = {
|
|
||||||
...newServicePorts[portIndex],
|
|
||||||
port:
|
|
||||||
e.target.value === ''
|
|
||||||
? undefined
|
|
||||||
: Number(e.target.value),
|
|
||||||
};
|
|
||||||
onChangePort(newServicePorts);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{servicePortError?.port && (
|
|
||||||
<FormError>{servicePortError.port}</FormError>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex min-w-min basis-1/4 flex-col">
|
|
||||||
<InputGroup size="small">
|
|
||||||
<InputGroup.Addon required>
|
|
||||||
Loadbalancer port
|
|
||||||
</InputGroup.Addon>
|
|
||||||
<InputGroup.Input
|
|
||||||
type="number"
|
|
||||||
className="form-control min-w-max"
|
|
||||||
name={`loadbalancer_port_${portIndex}`}
|
|
||||||
placeholder="80"
|
|
||||||
min="1"
|
|
||||||
max="65535"
|
|
||||||
value={servicePort.port || ''}
|
|
||||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const newServicePorts = [...servicePorts];
|
|
||||||
newServicePorts[portIndex] = {
|
|
||||||
...newServicePorts[portIndex],
|
|
||||||
port:
|
|
||||||
e.target.value === ''
|
|
||||||
? undefined
|
|
||||||
: Number(e.target.value),
|
|
||||||
};
|
|
||||||
onChangePort(newServicePorts);
|
|
||||||
}}
|
|
||||||
required
|
|
||||||
data-cy={`k8sAppCreate-loadbalancerPort_${portIndex}`}
|
|
||||||
/>
|
|
||||||
</InputGroup>
|
|
||||||
{servicePortError?.nodePort && (
|
|
||||||
<FormError>{servicePortError.nodePort}</FormError>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ButtonSelector
|
|
||||||
className="h-[30px]"
|
|
||||||
onChange={(value) => {
|
|
||||||
const newServicePorts = [...servicePorts];
|
|
||||||
newServicePorts[portIndex] = {
|
|
||||||
...newServicePorts[portIndex],
|
|
||||||
protocol: value,
|
|
||||||
};
|
|
||||||
onChangePort(newServicePorts);
|
|
||||||
}}
|
|
||||||
value={servicePort.protocol || 'TCP'}
|
|
||||||
options={[{ value: 'TCP' }, { value: 'UDP' }]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
disabled={servicePorts.length === 1}
|
|
||||||
size="small"
|
|
||||||
className="!ml-0 h-[30px]"
|
|
||||||
color="dangerlight"
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
// remove the port at the index in an immutable way
|
|
||||||
const newServicePorts = [
|
|
||||||
...servicePorts.slice(0, portIndex),
|
|
||||||
...servicePorts.slice(portIndex + 1),
|
|
||||||
];
|
|
||||||
onChangePort(newServicePorts);
|
|
||||||
}}
|
|
||||||
data-cy={`k8sAppCreate-rmPortButton_${portIndex}`}
|
|
||||||
icon={Trash2}
|
|
||||||
>
|
|
||||||
Remove port
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<div className="flex">
|
|
||||||
<Button
|
|
||||||
icon={Plus}
|
|
||||||
color="default"
|
|
||||||
className="!ml-0"
|
|
||||||
onClick={() => {
|
|
||||||
const newServicesPorts = [...servicePorts, newLoadBalancerPort];
|
|
||||||
onChangePort(newServicesPorts);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add port
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Widget.Body>
|
|
||||||
</Widget>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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<ServicePort>[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NodePortServiceForm({
|
|
||||||
services,
|
|
||||||
serviceIndex,
|
|
||||||
onChangeService,
|
|
||||||
servicePorts,
|
|
||||||
onChangePort,
|
|
||||||
errors,
|
|
||||||
serviceName,
|
|
||||||
}: Props) {
|
|
||||||
const newNodePortPort = newPort(serviceName);
|
|
||||||
return (
|
|
||||||
<Widget key={serviceIndex}>
|
|
||||||
<Widget.Body>
|
|
||||||
<div className="mb-4 flex justify-between">
|
|
||||||
<div className="text-muted vertical-center">NodePort service</div>
|
|
||||||
<Button
|
|
||||||
icon={Trash2}
|
|
||||||
color="dangerlight"
|
|
||||||
className="!ml-0"
|
|
||||||
onClick={() => {
|
|
||||||
// remove the service at index in an immutable way
|
|
||||||
const newServices = [
|
|
||||||
...services.slice(0, serviceIndex),
|
|
||||||
...services.slice(serviceIndex + 1),
|
|
||||||
];
|
|
||||||
onChangeService(newServices);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Remove service
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="control-label !mb-2 !pt-0 text-left">Ports</div>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{servicePorts.map((servicePort, portIndex) => {
|
|
||||||
const error = errors?.[portIndex];
|
|
||||||
const servicePortError = isServicePortError<ServicePort>(error)
|
|
||||||
? error
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={portIndex}
|
|
||||||
className="flex flex-grow flex-wrap justify-between gap-x-4 gap-y-1"
|
|
||||||
>
|
|
||||||
<div className="inline-flex min-w-min flex-grow basis-3/4 flex-wrap gap-2">
|
|
||||||
<div className="flex min-w-min basis-1/4 flex-col">
|
|
||||||
<ContainerPortInput
|
|
||||||
index={portIndex}
|
|
||||||
value={servicePort.targetPort}
|
|
||||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
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 && (
|
|
||||||
<FormError>{servicePortError.targetPort}</FormError>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex min-w-min basis-1/4 flex-col">
|
|
||||||
<ServicePortInput
|
|
||||||
index={portIndex}
|
|
||||||
value={servicePort.port}
|
|
||||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const newServicePorts = [...servicePorts];
|
|
||||||
newServicePorts[portIndex] = {
|
|
||||||
...newServicePorts[portIndex],
|
|
||||||
port:
|
|
||||||
e.target.value === ''
|
|
||||||
? undefined
|
|
||||||
: Number(e.target.value),
|
|
||||||
};
|
|
||||||
onChangePort(newServicePorts);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{servicePortError?.port && (
|
|
||||||
<FormError>{servicePortError.port}</FormError>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex min-w-min basis-1/4 flex-col">
|
|
||||||
<InputGroup size="small">
|
|
||||||
<InputGroup.Addon>Nodeport</InputGroup.Addon>
|
|
||||||
<InputGroup.Input
|
|
||||||
type="number"
|
|
||||||
className="form-control min-w-max"
|
|
||||||
name={`node_port_${portIndex}`}
|
|
||||||
placeholder="30080"
|
|
||||||
min="30000"
|
|
||||||
max="32767"
|
|
||||||
value={servicePort.nodePort ?? ''}
|
|
||||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const newServicePorts = [...servicePorts];
|
|
||||||
newServicePorts[portIndex] = {
|
|
||||||
...newServicePorts[portIndex],
|
|
||||||
nodePort:
|
|
||||||
e.target.value === ''
|
|
||||||
? undefined
|
|
||||||
: Number(e.target.value),
|
|
||||||
};
|
|
||||||
onChangePort(newServicePorts);
|
|
||||||
}}
|
|
||||||
data-cy={`k8sAppCreate-nodePort_${portIndex}`}
|
|
||||||
/>
|
|
||||||
</InputGroup>
|
|
||||||
{servicePortError?.nodePort && (
|
|
||||||
<FormError>{servicePortError.nodePort}</FormError>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ButtonSelector
|
|
||||||
className="h-[30px]"
|
|
||||||
onChange={(value) => {
|
|
||||||
const newServicePorts = [...servicePorts];
|
|
||||||
newServicePorts[portIndex] = {
|
|
||||||
...newServicePorts[portIndex],
|
|
||||||
protocol: value,
|
|
||||||
};
|
|
||||||
onChangePort(newServicePorts);
|
|
||||||
}}
|
|
||||||
value={servicePort.protocol || 'TCP'}
|
|
||||||
options={[{ value: 'TCP' }, { value: 'UDP' }]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
disabled={servicePorts.length === 1}
|
|
||||||
size="small"
|
|
||||||
className="!ml-0 h-[30px]"
|
|
||||||
color="dangerlight"
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
// remove the port at the index in an immutable way
|
|
||||||
const newServicePorts = [
|
|
||||||
...servicePorts.slice(0, portIndex),
|
|
||||||
...servicePorts.slice(portIndex + 1),
|
|
||||||
];
|
|
||||||
onChangePort(newServicePorts);
|
|
||||||
}}
|
|
||||||
data-cy={`k8sAppCreate-rmPortButton_${portIndex}`}
|
|
||||||
icon={Trash2}
|
|
||||||
>
|
|
||||||
Remove port
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<div className="flex">
|
|
||||||
<Button
|
|
||||||
icon={Plus}
|
|
||||||
color="default"
|
|
||||||
className="!ml-0"
|
|
||||||
onClick={() => {
|
|
||||||
const newServicesPorts = [...servicePorts, newNodePortPort];
|
|
||||||
onChangePort(newServicesPorts);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add port
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Widget.Body>
|
|
||||||
</Widget>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -0,0 +1,90 @@
|
||||||
|
import ingressDiagram from '@/assets/images/ingress-explanatory-diagram.png';
|
||||||
|
|
||||||
|
import { FormSection } from '@@/form-components/FormSection';
|
||||||
|
|
||||||
|
export function PublishingExplaination() {
|
||||||
|
return (
|
||||||
|
<FormSection title="Explanation" isFoldable titleSize="sm">
|
||||||
|
<div className="mb-4 flex flex-col items-start lg:flex-row">
|
||||||
|
<img
|
||||||
|
src={ingressDiagram}
|
||||||
|
alt="ingress explaination"
|
||||||
|
width={646}
|
||||||
|
className="flex w-full max-w-2xl basis-1/2 flex-col object-contain lg:w-1/2"
|
||||||
|
/>
|
||||||
|
<div className="ml-8 basis-1/2">
|
||||||
|
Expose the application workload via{' '}
|
||||||
|
<a
|
||||||
|
href="https://kubernetes.io/docs/concepts/services-networking/service/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
services
|
||||||
|
</a>{' '}
|
||||||
|
and{' '}
|
||||||
|
<a
|
||||||
|
href="https://kubernetes.io/docs/concepts/services-networking/ingress/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
ingresses
|
||||||
|
</a>
|
||||||
|
:
|
||||||
|
<ul className="mt-3 ml-5 [&>li]:mb-3 [&>li>ul>li]:ml-5">
|
||||||
|
<li>
|
||||||
|
<b>Inside</b> the cluster{' '}
|
||||||
|
<b>
|
||||||
|
<i>only</i>
|
||||||
|
</b>{' '}
|
||||||
|
- via <b>ClusterIP</b> service
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<i>The default service type.</i>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<b>Inside</b> the cluster via <b>ClusterIP</b> service and{' '}
|
||||||
|
<b>outside</b> via <b>ingress</b>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<i>
|
||||||
|
An ingress manages external access to (usually ClusterIP)
|
||||||
|
services within the cluster, and allows defining of routing
|
||||||
|
rules, SSL termination and other advanced features.
|
||||||
|
</i>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<b>Inside</b> and <b>outside</b> the cluster via <b>NodePort</b>{' '}
|
||||||
|
service
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<i>
|
||||||
|
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.
|
||||||
|
</i>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<b>Inside</b> and <b>outside</b> the cluster via{' '}
|
||||||
|
<b>LoadBalancer</b> service
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<i>
|
||||||
|
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.
|
||||||
|
</i>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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<ServicePort>[];
|
||||||
|
namespace?: string;
|
||||||
|
isEditMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClusterIpServiceForm({
|
||||||
|
services,
|
||||||
|
serviceIndex,
|
||||||
|
onChangeService,
|
||||||
|
servicePorts,
|
||||||
|
onChangePort,
|
||||||
|
errors,
|
||||||
|
serviceName,
|
||||||
|
namespace,
|
||||||
|
isEditMode,
|
||||||
|
}: Props) {
|
||||||
|
const newClusterIpPort = newPort(serviceName);
|
||||||
|
return (
|
||||||
|
<Widget key={serviceIndex}>
|
||||||
|
<Widget.Body>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<div className="text-muted vertical-center">ClusterIP</div>
|
||||||
|
<Button
|
||||||
|
icon={Trash2}
|
||||||
|
color="dangerlight"
|
||||||
|
className="!ml-0"
|
||||||
|
onClick={() => {
|
||||||
|
// remove the service at index in an immutable way
|
||||||
|
const newServices = [
|
||||||
|
...services.slice(0, serviceIndex),
|
||||||
|
...services.slice(serviceIndex + 1),
|
||||||
|
];
|
||||||
|
onChangeService(newServices);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove service
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="control-label !mb-2 !pt-0 text-left">Ports</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{servicePorts.map((servicePort, portIndex) => {
|
||||||
|
const error = errors?.[portIndex];
|
||||||
|
const servicePortErrors = isErrorType<ServicePort>(error)
|
||||||
|
? error
|
||||||
|
: undefined;
|
||||||
|
const ingressPathsErrors = isErrorType<ServicePortIngressPath[]>(
|
||||||
|
servicePortErrors?.ingressPaths
|
||||||
|
)
|
||||||
|
? servicePortErrors?.ingressPaths
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={portIndex} className="flex flex-col gap-y-3">
|
||||||
|
<div className="flex flex-grow flex-wrap justify-between gap-x-4 gap-y-1">
|
||||||
|
<div className="inline-flex min-w-min flex-grow basis-3/4 flex-wrap gap-2">
|
||||||
|
<div className="flex min-w-min basis-1/3 flex-col">
|
||||||
|
<ContainerPortInput
|
||||||
|
serviceIndex={serviceIndex}
|
||||||
|
portIndex={portIndex}
|
||||||
|
value={servicePort.targetPort}
|
||||||
|
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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 && (
|
||||||
|
<FormError>{servicePortErrors.targetPort}</FormError>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-min basis-1/3 flex-col">
|
||||||
|
<ServicePortInput
|
||||||
|
serviceIndex={serviceIndex}
|
||||||
|
portIndex={portIndex}
|
||||||
|
value={servicePort.port}
|
||||||
|
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newServicePorts = [...servicePorts];
|
||||||
|
newServicePorts[portIndex] = {
|
||||||
|
...newServicePorts[portIndex],
|
||||||
|
port:
|
||||||
|
e.target.value === ''
|
||||||
|
? undefined
|
||||||
|
: Number(e.target.value),
|
||||||
|
};
|
||||||
|
onChangePort(newServicePorts);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{servicePortErrors?.port && (
|
||||||
|
<FormError>{servicePortErrors.port}</FormError>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ButtonSelector
|
||||||
|
className="h-[30px]"
|
||||||
|
onChange={(value) => {
|
||||||
|
const newServicePorts = [...servicePorts];
|
||||||
|
newServicePorts[portIndex] = {
|
||||||
|
...newServicePorts[portIndex],
|
||||||
|
protocol: value,
|
||||||
|
};
|
||||||
|
onChangePort(newServicePorts);
|
||||||
|
}}
|
||||||
|
value={servicePort.protocol || 'TCP'}
|
||||||
|
options={[{ value: 'TCP' }, { value: 'UDP' }]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
disabled={servicePorts.length === 1}
|
||||||
|
size="small"
|
||||||
|
className="!ml-0 h-[30px]"
|
||||||
|
color="dangerlight"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
// remove the port at the index in an immutable way
|
||||||
|
const newServicePorts = [
|
||||||
|
...servicePorts.slice(0, portIndex),
|
||||||
|
...servicePorts.slice(portIndex + 1),
|
||||||
|
];
|
||||||
|
onChangePort(newServicePorts);
|
||||||
|
}}
|
||||||
|
data-cy={`k8sAppCreate-rmPortButton-${serviceIndex}-${portIndex}`}
|
||||||
|
icon={Trash2}
|
||||||
|
>
|
||||||
|
Remove port
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<AppIngressPathsForm
|
||||||
|
servicePortIngressPaths={servicePorts[portIndex].ingressPaths}
|
||||||
|
onChangeIngressPaths={(
|
||||||
|
ingressPaths?: ServicePortIngressPath[]
|
||||||
|
) => {
|
||||||
|
const newServicePorts = [...servicePorts];
|
||||||
|
newServicePorts[portIndex].ingressPaths = ingressPaths;
|
||||||
|
onChangePort(newServicePorts);
|
||||||
|
}}
|
||||||
|
namespace={namespace}
|
||||||
|
ingressPathsErrors={ingressPathsErrors}
|
||||||
|
serviceIndex={serviceIndex}
|
||||||
|
portIndex={portIndex}
|
||||||
|
isEditMode={isEditMode}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="flex">
|
||||||
|
<Button
|
||||||
|
icon={Plus}
|
||||||
|
color="default"
|
||||||
|
className="!ml-0"
|
||||||
|
onClick={() => {
|
||||||
|
const newServicesPorts = [...servicePorts, newClusterIpPort];
|
||||||
|
onChangePort(newServicesPorts);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add port
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Widget.Body>
|
||||||
|
</Widget>
|
||||||
|
);
|
||||||
|
}
|
|
@ -7,8 +7,13 @@ import { Card } from '@@/Card';
|
||||||
import { TextTip } from '@@/Tip/TextTip';
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
import { Button } from '@@/buttons';
|
import { Button } from '@@/buttons';
|
||||||
|
|
||||||
import { generateUniqueName, newPort, serviceFormDefaultValues } from './utils';
|
import {
|
||||||
import { ServiceFormValues, ServicePort } from './types';
|
generateUniqueName,
|
||||||
|
newPort,
|
||||||
|
serviceFormDefaultValues,
|
||||||
|
} from '../utils';
|
||||||
|
import { ServiceFormValues, ServicePort } from '../types';
|
||||||
|
|
||||||
import { ClusterIpServiceForm } from './ClusterIpServiceForm';
|
import { ClusterIpServiceForm } from './ClusterIpServiceForm';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
@ -17,6 +22,8 @@ interface Props {
|
||||||
errors?: FormikErrors<ServiceFormValues[]>;
|
errors?: FormikErrors<ServiceFormValues[]>;
|
||||||
appName: string;
|
appName: string;
|
||||||
selector: Record<string, string>;
|
selector: Record<string, string>;
|
||||||
|
namespace?: string;
|
||||||
|
isEditMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClusterIpServicesForm({
|
export function ClusterIpServicesForm({
|
||||||
|
@ -25,6 +32,8 @@ export function ClusterIpServicesForm({
|
||||||
errors,
|
errors,
|
||||||
appName,
|
appName,
|
||||||
selector,
|
selector,
|
||||||
|
namespace,
|
||||||
|
isEditMode,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const clusterIPServiceCount = services.filter(
|
const clusterIPServiceCount = services.filter(
|
||||||
(service) =>
|
(service) =>
|
||||||
|
@ -56,6 +65,8 @@ export function ClusterIpServicesForm({
|
||||||
services={services}
|
services={services}
|
||||||
serviceIndex={index}
|
serviceIndex={index}
|
||||||
onChangeService={onChangeService}
|
onChangeService={onChangeService}
|
||||||
|
namespace={namespace}
|
||||||
|
isEditMode={isEditMode}
|
||||||
/>
|
/>
|
||||||
) : null
|
) : null
|
||||||
)}
|
)}
|
|
@ -3,26 +3,32 @@ import { ChangeEvent } from 'react';
|
||||||
import { InputGroup } from '@@/form-components/InputGroup';
|
import { InputGroup } from '@@/form-components/InputGroup';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
index: number;
|
serviceIndex: number;
|
||||||
|
portIndex: number;
|
||||||
value?: number;
|
value?: number;
|
||||||
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
|
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ContainerPortInput({ index, value, onChange }: Props) {
|
export function ContainerPortInput({
|
||||||
|
serviceIndex,
|
||||||
|
portIndex,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<InputGroup size="small">
|
<InputGroup size="small">
|
||||||
<InputGroup.Addon required>Container port</InputGroup.Addon>
|
<InputGroup.Addon required>Container port</InputGroup.Addon>
|
||||||
<InputGroup.Input
|
<InputGroup.Input
|
||||||
type="number"
|
type="number"
|
||||||
className="form-control min-w-max"
|
className="form-control min-w-max"
|
||||||
name={`container_port_${index}`}
|
name={`container_port_${portIndex}`}
|
||||||
placeholder="80"
|
placeholder="80"
|
||||||
min="1"
|
min="1"
|
||||||
max="65535"
|
max="65535"
|
||||||
value={value ?? ''}
|
value={value ?? ''}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
required
|
required
|
||||||
data-cy={`k8sAppCreate-containerPort_${index}`}
|
data-cy={`k8sAppCreate-containerPort-${serviceIndex}-${portIndex}`}
|
||||||
/>
|
/>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
);
|
);
|
|
@ -3,26 +3,32 @@ import { ChangeEvent } from 'react';
|
||||||
import { InputGroup } from '@@/form-components/InputGroup';
|
import { InputGroup } from '@@/form-components/InputGroup';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
index: number;
|
serviceIndex: number;
|
||||||
|
portIndex: number;
|
||||||
value?: number;
|
value?: number;
|
||||||
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
|
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ServicePortInput({ index, value, onChange }: Props) {
|
export function ServicePortInput({
|
||||||
|
serviceIndex,
|
||||||
|
portIndex,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<InputGroup size="small">
|
<InputGroup size="small">
|
||||||
<InputGroup.Addon required>Service port</InputGroup.Addon>
|
<InputGroup.Addon required>Service port</InputGroup.Addon>
|
||||||
<InputGroup.Input
|
<InputGroup.Input
|
||||||
type="number"
|
type="number"
|
||||||
className="form-control min-w-max"
|
className="form-control min-w-max"
|
||||||
name={`service_port_${index}`}
|
name={`service_port_${portIndex}`}
|
||||||
placeholder="80"
|
placeholder="80"
|
||||||
min="1"
|
min="1"
|
||||||
max="65535"
|
max="65535"
|
||||||
value={value ?? ''}
|
value={value ?? ''}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
required
|
required
|
||||||
data-cy={`k8sAppCreate-servicePort_${index}`}
|
data-cy={`k8sAppCreate-servicePort-${serviceIndex}-${portIndex}`}
|
||||||
/>
|
/>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
);
|
);
|
|
@ -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 (
|
||||||
|
<div className="inline-flex items-center">
|
||||||
|
{serviceTypeLabel}
|
||||||
|
{serviceTypeCount && (
|
||||||
|
<Badge
|
||||||
|
className="ml-2 flex-none"
|
||||||
|
type={serviceTypeHasErrors ? 'warn' : 'info'}
|
||||||
|
>
|
||||||
|
{serviceTypeHasErrors && (
|
||||||
|
<Icon icon={AlertTriangle} className="!mr-1" />
|
||||||
|
)}
|
||||||
|
{serviceTypeCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
import { ServiceTypeOption, ServiceTypeValue } from './types';
|
import { ServiceTypeOption, ServiceTypeValue } from '../types';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
serviceTypeOptions: ServiceTypeOption[];
|
serviceTypeOptions: ServiceTypeOption[];
|
|
@ -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<ServicePortIngressPath>;
|
||||||
|
ingressHostOptions: IngressOption[];
|
||||||
|
onChangeIngressPath: (ingressPath: ServicePortIngressPath) => void;
|
||||||
|
onRemoveIngressPath: () => void;
|
||||||
|
ingressesQuery: UseQueryResult<Ingress[], unknown>;
|
||||||
|
namespace?: string;
|
||||||
|
isEditMode?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AppIngressPathForm({
|
||||||
|
ingressPath,
|
||||||
|
ingressPathErrors,
|
||||||
|
ingressHostOptions,
|
||||||
|
onChangeIngressPath,
|
||||||
|
onRemoveIngressPath,
|
||||||
|
ingressesQuery,
|
||||||
|
namespace,
|
||||||
|
isEditMode,
|
||||||
|
}: Props) {
|
||||||
|
const [selectedIngress, setSelectedIngress] = useState<IngressOption | null>(
|
||||||
|
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 (
|
||||||
|
<div className="flex w-full flex-wrap gap-x-4">
|
||||||
|
<div className="flex min-w-[250px] basis-1/3 flex-col">
|
||||||
|
<InputGroup size="small">
|
||||||
|
<InputGroup.Addon>Hostname</InputGroup.Addon>
|
||||||
|
<Select
|
||||||
|
options={ingressHostOptions}
|
||||||
|
value={selectedIngress}
|
||||||
|
defaultValue={ingressHostOptions[0]}
|
||||||
|
placeholder="Select a hostname..."
|
||||||
|
theme={(theme) => ({
|
||||||
|
...theme,
|
||||||
|
borderRadius: 0,
|
||||||
|
})}
|
||||||
|
size="sm"
|
||||||
|
onChange={(ingressOption) => {
|
||||||
|
setSelectedIngress(ingressOption);
|
||||||
|
const newIngressPath = {
|
||||||
|
...ingressPath,
|
||||||
|
Host: ingressOption?.value,
|
||||||
|
IngressName: ingressOption?.ingressName,
|
||||||
|
};
|
||||||
|
onChangeIngressPath(newIngressPath);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<InputGroup.ButtonWrapper>
|
||||||
|
<Button
|
||||||
|
icon={RefreshCw}
|
||||||
|
color="default"
|
||||||
|
onClick={() => ingressesQuery.refetch()}
|
||||||
|
/>
|
||||||
|
</InputGroup.ButtonWrapper>
|
||||||
|
</InputGroup>
|
||||||
|
{ingressHostOptions.length === 0 && !ingressPath?.Host && (
|
||||||
|
<FormError>
|
||||||
|
No ingress hostnames are available for the namespace '
|
||||||
|
{namespace}'. Please update the namespace or{' '}
|
||||||
|
<Link
|
||||||
|
to="kubernetes.ingresses.create"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
create an ingress
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</FormError>
|
||||||
|
)}
|
||||||
|
{ingressPathErrors?.Host && ingressHostOptions.length > 0 && (
|
||||||
|
<FormError>{ingressPathErrors?.Host}</FormError>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-[250px] basis-1/3 flex-col">
|
||||||
|
<InputGroup size="small">
|
||||||
|
<InputGroup.Addon required>Path</InputGroup.Addon>
|
||||||
|
<InputGroup.Input
|
||||||
|
value={ingressPath?.Path ?? ''}
|
||||||
|
placeholder="/example"
|
||||||
|
onChange={(e) => {
|
||||||
|
const newIngressPath = {
|
||||||
|
...ingressPath,
|
||||||
|
Path: e.target.value,
|
||||||
|
};
|
||||||
|
onChangeIngressPath(newIngressPath);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
{ingressPathErrors?.Path && (
|
||||||
|
<FormError>{ingressPathErrors?.Path}</FormError>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Button
|
||||||
|
icon={Trash2}
|
||||||
|
color="dangerlight"
|
||||||
|
size="medium"
|
||||||
|
className="!ml-0"
|
||||||
|
onClick={() => onRemoveIngressPath()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,171 @@
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
import { FormikErrors } from 'formik';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
useIngressControllers,
|
||||||
|
useIngresses,
|
||||||
|
} from '@/react/kubernetes/ingresses/queries';
|
||||||
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
|
||||||
|
import { FormError } from '@@/form-components/FormError';
|
||||||
|
import { Button } from '@@/buttons';
|
||||||
|
import { SwitchField } from '@@/form-components/SwitchField';
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
import { Link } from '@@/Link';
|
||||||
|
|
||||||
|
import { ServicePortIngressPath } from '../types';
|
||||||
|
|
||||||
|
import { AppIngressPathForm } from './AppIngressPathForm';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
servicePortIngressPaths?: ServicePortIngressPath[];
|
||||||
|
onChangeIngressPaths: (ingressPath: ServicePortIngressPath[]) => void;
|
||||||
|
namespace?: string;
|
||||||
|
ingressPathsErrors?: FormikErrors<ServicePortIngressPath[]>;
|
||||||
|
serviceIndex: number;
|
||||||
|
portIndex: number;
|
||||||
|
isEditMode?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AppIngressPathsForm({
|
||||||
|
servicePortIngressPaths,
|
||||||
|
onChangeIngressPaths,
|
||||||
|
namespace,
|
||||||
|
ingressPathsErrors,
|
||||||
|
serviceIndex,
|
||||||
|
portIndex,
|
||||||
|
isEditMode,
|
||||||
|
}: Props) {
|
||||||
|
const environmentId = useEnvironmentId();
|
||||||
|
const ingressesQuery = useIngresses(
|
||||||
|
environmentId,
|
||||||
|
namespace ? [namespace] : undefined
|
||||||
|
);
|
||||||
|
const { data: ingresses } = ingressesQuery;
|
||||||
|
const ingressControllersQuery = useIngressControllers(
|
||||||
|
environmentId,
|
||||||
|
namespace
|
||||||
|
);
|
||||||
|
const { data: ingressControllers } = ingressControllersQuery;
|
||||||
|
|
||||||
|
// if some ingress controllers are restricted by namespace, then filter the ingresses that use allowed ingress controllers
|
||||||
|
const allowedIngressHostNameOptions = useMemo(() => {
|
||||||
|
const allowedIngressClasses =
|
||||||
|
ingressControllers
|
||||||
|
?.filter((ic) => ic.Availability)
|
||||||
|
.map((ic) => ic.ClassName) || [];
|
||||||
|
const allowedIngresses =
|
||||||
|
ingresses?.filter((ing) =>
|
||||||
|
allowedIngressClasses.includes(ing.ClassName)
|
||||||
|
) || [];
|
||||||
|
return allowedIngresses.flatMap((ing) =>
|
||||||
|
ing.Hosts?.length
|
||||||
|
? ing.Hosts.map((host) => ({
|
||||||
|
label: `${host} (${ing.Name})`,
|
||||||
|
value: host,
|
||||||
|
ingressName: ing.Name,
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
}, [ingressControllers, ingresses]);
|
||||||
|
|
||||||
|
if (ingressesQuery.isError || ingressControllersQuery.isError) {
|
||||||
|
return <FormError>Unable to load ingresses.</FormError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ingressesQuery.isLoading || ingressControllersQuery.isLoading) {
|
||||||
|
return <p>Loading ingresses...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-y-4">
|
||||||
|
<div className="!mb-0 flex w-full flex-wrap items-center gap-x-4 gap-y-2">
|
||||||
|
<SwitchField
|
||||||
|
fieldClass="w-max gap-x-8"
|
||||||
|
label="Expose via ingress"
|
||||||
|
tooltip="Expose this ClusterIP service externally using an ingress. This will create a new ingress path for the selected ingress hostname."
|
||||||
|
labelClass="w-max"
|
||||||
|
name="publish-ingress"
|
||||||
|
checked={!!servicePortIngressPaths?.length}
|
||||||
|
onChange={(value) => {
|
||||||
|
const newIngressPathsValue = value
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
Host: allowedIngressHostNameOptions[0]?.value ?? '',
|
||||||
|
IngressName:
|
||||||
|
allowedIngressHostNameOptions[0]?.ingressName ?? '',
|
||||||
|
Path: '',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
onChangeIngressPaths(newIngressPathsValue);
|
||||||
|
}}
|
||||||
|
data-cy={`applicationCreate-publishIngress-${serviceIndex}-${portIndex}`}
|
||||||
|
/>
|
||||||
|
{!!servicePortIngressPaths?.length && (
|
||||||
|
<TextTip color="blue">
|
||||||
|
Select from available ingresses below, or add new or edit existing
|
||||||
|
ones via the{' '}
|
||||||
|
<Link
|
||||||
|
to="kubernetes.ingresses"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Ingresses screen
|
||||||
|
</Link>{' '}
|
||||||
|
and then reload the hostname dropdown.
|
||||||
|
</TextTip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{ingressesQuery.isSuccess && ingressControllersQuery.isSuccess
|
||||||
|
? servicePortIngressPaths?.map((ingressPath, index) => (
|
||||||
|
<AppIngressPathForm
|
||||||
|
key={index}
|
||||||
|
ingressPath={ingressPath}
|
||||||
|
ingressPathErrors={ingressPathsErrors?.[index]}
|
||||||
|
ingressHostOptions={allowedIngressHostNameOptions}
|
||||||
|
onChangeIngressPath={(ingressPath: ServicePortIngressPath) => {
|
||||||
|
const newIngressPaths = structuredClone(
|
||||||
|
servicePortIngressPaths
|
||||||
|
);
|
||||||
|
newIngressPaths[index] = ingressPath;
|
||||||
|
onChangeIngressPaths(newIngressPaths);
|
||||||
|
}}
|
||||||
|
onRemoveIngressPath={() => {
|
||||||
|
const newIngressPaths = structuredClone(
|
||||||
|
servicePortIngressPaths
|
||||||
|
);
|
||||||
|
newIngressPaths.splice(index, 1);
|
||||||
|
onChangeIngressPaths(newIngressPaths);
|
||||||
|
}}
|
||||||
|
ingressesQuery={ingressesQuery}
|
||||||
|
namespace={namespace}
|
||||||
|
isEditMode={isEditMode}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
{!!servicePortIngressPaths?.length && (
|
||||||
|
<div className="flex w-full flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
icon={Plus}
|
||||||
|
className="!ml-0"
|
||||||
|
size="small"
|
||||||
|
color="default"
|
||||||
|
onClick={() => {
|
||||||
|
const newIngressPaths = structuredClone(servicePortIngressPaths);
|
||||||
|
newIngressPaths.push({
|
||||||
|
Host: allowedIngressHostNameOptions[0]?.value,
|
||||||
|
IngressName: allowedIngressHostNameOptions[0]?.ingressName,
|
||||||
|
Path: '',
|
||||||
|
});
|
||||||
|
onChangeIngressPaths(newIngressPaths);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add path
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,311 @@
|
||||||
|
import { SchemaOf, array, object, boolean, string, mixed, number } from 'yup';
|
||||||
|
|
||||||
|
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
|
||||||
|
|
||||||
|
import { ServiceFormValues, ServicePort } from './types';
|
||||||
|
import { prependWithSlash } from './utils';
|
||||||
|
|
||||||
|
// 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 AngularIngressPath = {
|
||||||
|
IngressName: string;
|
||||||
|
Host: string;
|
||||||
|
Path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AppServicesValidationData = {
|
||||||
|
nodePortServices: ServiceValues[];
|
||||||
|
formServices: ServiceFormValues[];
|
||||||
|
ingressPaths?: AngularIngressPath[];
|
||||||
|
originalIngressPaths?: AngularIngressPath[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function kubeServicesValidation(
|
||||||
|
validationData?: AppServicesValidationData
|
||||||
|
): SchemaOf<ServiceFormValues[]> {
|
||||||
|
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(),
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
),
|
||||||
|
ingressPaths: array(
|
||||||
|
object({
|
||||||
|
IngressName: string().required(),
|
||||||
|
Host: string().required('Ingress hostname is required.'),
|
||||||
|
Path: string()
|
||||||
|
.required('Ingress path is required.')
|
||||||
|
.test(
|
||||||
|
'path-is-unique',
|
||||||
|
'Ingress path is already in use for this hostname.',
|
||||||
|
(path, context) => {
|
||||||
|
if (path === undefined || validationData === undefined) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const ingressHostAndPath = `${
|
||||||
|
context.parent.Host
|
||||||
|
}${prependWithSlash(path)}`;
|
||||||
|
const {
|
||||||
|
ingressPaths: ingressPathsInNamespace,
|
||||||
|
formServices,
|
||||||
|
originalIngressPaths,
|
||||||
|
} = validationData;
|
||||||
|
|
||||||
|
// get the count of the same ingressHostAndPath in the current form values
|
||||||
|
const allFormServicePortIngresses = formServices.flatMap(
|
||||||
|
(service) =>
|
||||||
|
service.Ports.flatMap((port) => port.ingressPaths)
|
||||||
|
);
|
||||||
|
const formMatchingIngressHostPathCount =
|
||||||
|
allFormServicePortIngresses
|
||||||
|
.filter((ingress) => ingress?.Host !== '')
|
||||||
|
.map(
|
||||||
|
(ingress) =>
|
||||||
|
`${ingress?.Host}${prependWithSlash(ingress?.Path)}`
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
(formIngressHostAndPath) =>
|
||||||
|
formIngressHostAndPath === ingressHostAndPath
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// get the count of the same ingressHostAndPath in the namespace and subtract the count from the original form values
|
||||||
|
const nsMatchingIngressHostPathCount = (
|
||||||
|
ingressPathsInNamespace ?? []
|
||||||
|
)
|
||||||
|
.map(
|
||||||
|
(ingressPath) =>
|
||||||
|
`${ingressPath.Host}${ingressPath.Path}`
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
(nsIngressHostAndPath) =>
|
||||||
|
nsIngressHostAndPath === ingressHostAndPath
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// get the count of the same ingressHostAndPath in the original form values
|
||||||
|
const originalMatchingIngressHostPathCount = (
|
||||||
|
originalIngressPaths ?? []
|
||||||
|
)
|
||||||
|
.map(
|
||||||
|
(ingressPath) =>
|
||||||
|
`${ingressPath.Host}${ingressPath.Path}`
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
(originalIngressHostAndPath) =>
|
||||||
|
originalIngressHostAndPath === ingressHostAndPath
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// for the current ingressHostAndPath to be unique, nsMatchingIngressHostPathCount - originalMatchingIngressHostPathCount + formMatchingIngressHostPathCount must be 1 or less.
|
||||||
|
const pathIsUnique =
|
||||||
|
formMatchingIngressHostPathCount === 1 &&
|
||||||
|
nsMatchingIngressHostPathCount -
|
||||||
|
originalMatchingIngressHostPathCount +
|
||||||
|
formMatchingIngressHostPathCount <=
|
||||||
|
1;
|
||||||
|
return pathIsUnique;
|
||||||
|
}
|
||||||
|
),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
Annotations: array(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServiceForPort(
|
||||||
|
servicePort: ServicePort,
|
||||||
|
services: ServiceFormValues[]
|
||||||
|
) {
|
||||||
|
return services.find((service) => service.Name === servicePort.serviceName);
|
||||||
|
}
|
|
@ -0,0 +1,238 @@
|
||||||
|
import { FormikErrors } from 'formik';
|
||||||
|
import { ChangeEvent, useRef } 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 { isErrorType, newPort } from '../utils';
|
||||||
|
import { ContainerPortInput } from '../components/ContainerPortInput';
|
||||||
|
import {
|
||||||
|
ServiceFormValues,
|
||||||
|
ServicePort,
|
||||||
|
ServicePortIngressPath,
|
||||||
|
} from '../types';
|
||||||
|
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<ServicePort>[];
|
||||||
|
namespace?: string;
|
||||||
|
isEditMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadBalancerServiceForm({
|
||||||
|
services,
|
||||||
|
serviceIndex,
|
||||||
|
onChangeService,
|
||||||
|
servicePorts,
|
||||||
|
onChangePort,
|
||||||
|
errors,
|
||||||
|
serviceName,
|
||||||
|
namespace,
|
||||||
|
isEditMode,
|
||||||
|
}: Props) {
|
||||||
|
const newLoadBalancerPort = newPort(serviceName); // If there's initially a ingress path for the service, allow editing it
|
||||||
|
const { current: initiallyHasIngressPath } = useRef(
|
||||||
|
services[serviceIndex].Ports.some((port) => port.ingressPaths?.length)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Widget key={serviceIndex}>
|
||||||
|
<Widget.Body>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<div className="text-muted vertical-center">LoadBalancer</div>
|
||||||
|
<Button
|
||||||
|
icon={Trash2}
|
||||||
|
color="dangerlight"
|
||||||
|
className="!ml-0"
|
||||||
|
onClick={() => {
|
||||||
|
// remove the service at index in an immutable way
|
||||||
|
const newServices = [
|
||||||
|
...services.slice(0, serviceIndex),
|
||||||
|
...services.slice(serviceIndex + 1),
|
||||||
|
];
|
||||||
|
onChangeService(newServices);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove service
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="control-label !mb-2 !pt-0 text-left">Ports</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{servicePorts.map((servicePort, portIndex) => {
|
||||||
|
const error = errors?.[portIndex];
|
||||||
|
const servicePortErrors = isErrorType<ServicePort>(error)
|
||||||
|
? error
|
||||||
|
: undefined;
|
||||||
|
const ingressPathsErrors = isErrorType<ServicePortIngressPath[]>(
|
||||||
|
servicePortErrors?.ingressPaths
|
||||||
|
)
|
||||||
|
? servicePortErrors?.ingressPaths
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={portIndex} className="flex flex-col gap-y-3">
|
||||||
|
<div className="flex flex-grow flex-wrap justify-between gap-x-4 gap-y-1">
|
||||||
|
<div className="inline-flex min-w-min flex-grow basis-3/4 flex-wrap gap-2">
|
||||||
|
<div className="flex min-w-min basis-1/4 flex-col">
|
||||||
|
<ContainerPortInput
|
||||||
|
serviceIndex={serviceIndex}
|
||||||
|
portIndex={portIndex}
|
||||||
|
value={servicePort.targetPort}
|
||||||
|
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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 && (
|
||||||
|
<FormError>{servicePortErrors.targetPort}</FormError>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-min basis-1/4 flex-col">
|
||||||
|
<ServicePortInput
|
||||||
|
serviceIndex={serviceIndex}
|
||||||
|
portIndex={portIndex}
|
||||||
|
value={servicePort.port}
|
||||||
|
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newServicePorts = [...servicePorts];
|
||||||
|
newServicePorts[portIndex] = {
|
||||||
|
...newServicePorts[portIndex],
|
||||||
|
port:
|
||||||
|
e.target.value === ''
|
||||||
|
? undefined
|
||||||
|
: Number(e.target.value),
|
||||||
|
};
|
||||||
|
onChangePort(newServicePorts);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{servicePortErrors?.port && (
|
||||||
|
<FormError>{servicePortErrors.port}</FormError>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-min basis-1/4 flex-col">
|
||||||
|
<InputGroup size="small">
|
||||||
|
<InputGroup.Addon required>
|
||||||
|
Loadbalancer port
|
||||||
|
</InputGroup.Addon>
|
||||||
|
<InputGroup.Input
|
||||||
|
type="number"
|
||||||
|
className="form-control min-w-max"
|
||||||
|
name={`loadbalancer_port_${portIndex}`}
|
||||||
|
placeholder="80"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
value={servicePort.port || ''}
|
||||||
|
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newServicePorts = [...servicePorts];
|
||||||
|
newServicePorts[portIndex] = {
|
||||||
|
...newServicePorts[portIndex],
|
||||||
|
port:
|
||||||
|
e.target.value === ''
|
||||||
|
? undefined
|
||||||
|
: Number(e.target.value),
|
||||||
|
};
|
||||||
|
onChangePort(newServicePorts);
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
data-cy={`k8sAppCreate-loadbalancerPort_${portIndex}`}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
{servicePortErrors?.nodePort && (
|
||||||
|
<FormError>{servicePortErrors.nodePort}</FormError>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ButtonSelector
|
||||||
|
className="h-[30px]"
|
||||||
|
onChange={(value) => {
|
||||||
|
const newServicePorts = [...servicePorts];
|
||||||
|
newServicePorts[portIndex] = {
|
||||||
|
...newServicePorts[portIndex],
|
||||||
|
protocol: value,
|
||||||
|
};
|
||||||
|
onChangePort(newServicePorts);
|
||||||
|
}}
|
||||||
|
value={servicePort.protocol || 'TCP'}
|
||||||
|
options={[{ value: 'TCP' }, { value: 'UDP' }]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
disabled={servicePorts.length === 1}
|
||||||
|
size="small"
|
||||||
|
className="!ml-0 h-[30px]"
|
||||||
|
color="dangerlight"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
// remove the port at the index in an immutable way
|
||||||
|
const newServicePorts = [
|
||||||
|
...servicePorts.slice(0, portIndex),
|
||||||
|
...servicePorts.slice(portIndex + 1),
|
||||||
|
];
|
||||||
|
onChangePort(newServicePorts);
|
||||||
|
}}
|
||||||
|
data-cy={`k8sAppCreate-rmPortButton_${portIndex}`}
|
||||||
|
icon={Trash2}
|
||||||
|
>
|
||||||
|
Remove port
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{initiallyHasIngressPath && (
|
||||||
|
<AppIngressPathsForm
|
||||||
|
servicePortIngressPaths={
|
||||||
|
servicePorts[portIndex].ingressPaths
|
||||||
|
}
|
||||||
|
onChangeIngressPaths={(
|
||||||
|
ingressPaths?: ServicePortIngressPath[]
|
||||||
|
) => {
|
||||||
|
const newServicePorts = [...servicePorts];
|
||||||
|
newServicePorts[portIndex].ingressPaths = ingressPaths;
|
||||||
|
onChangePort(newServicePorts);
|
||||||
|
}}
|
||||||
|
namespace={namespace}
|
||||||
|
ingressPathsErrors={ingressPathsErrors}
|
||||||
|
serviceIndex={serviceIndex}
|
||||||
|
portIndex={portIndex}
|
||||||
|
isEditMode={isEditMode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="flex">
|
||||||
|
<Button
|
||||||
|
icon={Plus}
|
||||||
|
color="default"
|
||||||
|
className="!ml-0"
|
||||||
|
onClick={() => {
|
||||||
|
const newServicesPorts = [...servicePorts, newLoadBalancerPort];
|
||||||
|
onChangePort(newServicesPorts);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add port
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Widget.Body>
|
||||||
|
</Widget>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
import { Plus, RotateCw } from 'lucide-react';
|
import { Plus, RefreshCw } from 'lucide-react';
|
||||||
import { FormikErrors } from 'formik';
|
import { FormikErrors } from 'formik';
|
||||||
|
|
||||||
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
|
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
|
||||||
|
@ -12,8 +12,13 @@ import { Button } from '@@/buttons';
|
||||||
import { FormError } from '@@/form-components/FormError';
|
import { FormError } from '@@/form-components/FormError';
|
||||||
import { Link } from '@@/Link';
|
import { Link } from '@@/Link';
|
||||||
|
|
||||||
import { generateUniqueName, newPort, serviceFormDefaultValues } from './utils';
|
import {
|
||||||
import { ServiceFormValues, ServicePort } from './types';
|
generateUniqueName,
|
||||||
|
newPort,
|
||||||
|
serviceFormDefaultValues,
|
||||||
|
} from '../utils';
|
||||||
|
import { ServiceFormValues, ServicePort } from '../types';
|
||||||
|
|
||||||
import { LoadBalancerServiceForm } from './LoadBalancerServiceForm';
|
import { LoadBalancerServiceForm } from './LoadBalancerServiceForm';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
@ -22,6 +27,8 @@ interface Props {
|
||||||
errors?: FormikErrors<ServiceFormValues[]>;
|
errors?: FormikErrors<ServiceFormValues[]>;
|
||||||
appName: string;
|
appName: string;
|
||||||
selector: Record<string, string>;
|
selector: Record<string, string>;
|
||||||
|
namespace?: string;
|
||||||
|
isEditMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LoadBalancerServicesForm({
|
export function LoadBalancerServicesForm({
|
||||||
|
@ -30,6 +37,8 @@ export function LoadBalancerServicesForm({
|
||||||
errors,
|
errors,
|
||||||
appName,
|
appName,
|
||||||
selector,
|
selector,
|
||||||
|
namespace,
|
||||||
|
isEditMode,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { isAdmin } = useCurrentUser();
|
const { isAdmin } = useCurrentUser();
|
||||||
const environmentId = useEnvironmentId();
|
const environmentId = useEnvironmentId();
|
||||||
|
@ -73,7 +82,7 @@ export function LoadBalancerServicesForm({
|
||||||
</FormError>
|
</FormError>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<Button
|
<Button
|
||||||
icon={RotateCw}
|
icon={RefreshCw}
|
||||||
color="default"
|
color="default"
|
||||||
className="!ml-0"
|
className="!ml-0"
|
||||||
onClick={() => loadBalancerEnabledQuery.refetch()}
|
onClick={() => loadBalancerEnabledQuery.refetch()}
|
||||||
|
@ -101,6 +110,8 @@ export function LoadBalancerServicesForm({
|
||||||
services={services}
|
services={services}
|
||||||
serviceIndex={index}
|
serviceIndex={index}
|
||||||
onChangeService={onChangeService}
|
onChangeService={onChangeService}
|
||||||
|
namespace={namespace}
|
||||||
|
isEditMode={isEditMode}
|
||||||
/>
|
/>
|
||||||
) : null
|
) : null
|
||||||
)}
|
)}
|
|
@ -0,0 +1,236 @@
|
||||||
|
import { FormikErrors } from 'formik';
|
||||||
|
import { ChangeEvent, useRef } 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 { isErrorType, newPort } from '../utils';
|
||||||
|
import { ContainerPortInput } from '../components/ContainerPortInput';
|
||||||
|
import {
|
||||||
|
ServiceFormValues,
|
||||||
|
ServicePort,
|
||||||
|
ServicePortIngressPath,
|
||||||
|
} from '../types';
|
||||||
|
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<ServicePort>[];
|
||||||
|
namespace?: string;
|
||||||
|
isEditMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NodePortServiceForm({
|
||||||
|
services,
|
||||||
|
serviceIndex,
|
||||||
|
onChangeService,
|
||||||
|
servicePorts,
|
||||||
|
onChangePort,
|
||||||
|
errors,
|
||||||
|
serviceName,
|
||||||
|
namespace,
|
||||||
|
isEditMode,
|
||||||
|
}: Props) {
|
||||||
|
const newNodePortPort = newPort(serviceName);
|
||||||
|
// If there's initially a ingress path for the service, allow editing it
|
||||||
|
const { current: initiallyHasIngressPath } = useRef(
|
||||||
|
services[serviceIndex].Ports.some((port) => port.ingressPaths?.length)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Widget key={serviceIndex}>
|
||||||
|
<Widget.Body>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<div className="text-muted vertical-center">NodePort</div>
|
||||||
|
<Button
|
||||||
|
icon={Trash2}
|
||||||
|
color="dangerlight"
|
||||||
|
className="!ml-0"
|
||||||
|
onClick={() => {
|
||||||
|
// remove the service at index in an immutable way
|
||||||
|
const newServices = [
|
||||||
|
...services.slice(0, serviceIndex),
|
||||||
|
...services.slice(serviceIndex + 1),
|
||||||
|
];
|
||||||
|
onChangeService(newServices);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove service
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="control-label !mb-2 !pt-0 text-left">Ports</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{servicePorts.map((servicePort, portIndex) => {
|
||||||
|
const error = errors?.[portIndex];
|
||||||
|
const servicePortErrors = isErrorType<ServicePort>(error)
|
||||||
|
? error
|
||||||
|
: undefined;
|
||||||
|
const ingressPathsErrors = isErrorType<ServicePortIngressPath[]>(
|
||||||
|
servicePortErrors?.ingressPaths
|
||||||
|
)
|
||||||
|
? servicePortErrors?.ingressPaths
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={portIndex} className="flex flex-col gap-y-3">
|
||||||
|
<div className="flex flex-grow flex-wrap justify-between gap-x-4 gap-y-1">
|
||||||
|
<div className="inline-flex min-w-min flex-grow basis-3/4 flex-wrap gap-2">
|
||||||
|
<div className="flex min-w-min basis-1/4 flex-col">
|
||||||
|
<ContainerPortInput
|
||||||
|
serviceIndex={serviceIndex}
|
||||||
|
portIndex={portIndex}
|
||||||
|
value={servicePort.targetPort}
|
||||||
|
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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 && (
|
||||||
|
<FormError>{servicePortErrors.targetPort}</FormError>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-min basis-1/4 flex-col">
|
||||||
|
<ServicePortInput
|
||||||
|
serviceIndex={serviceIndex}
|
||||||
|
portIndex={portIndex}
|
||||||
|
value={servicePort.port}
|
||||||
|
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newServicePorts = [...servicePorts];
|
||||||
|
newServicePorts[portIndex] = {
|
||||||
|
...newServicePorts[portIndex],
|
||||||
|
port:
|
||||||
|
e.target.value === ''
|
||||||
|
? undefined
|
||||||
|
: Number(e.target.value),
|
||||||
|
};
|
||||||
|
onChangePort(newServicePorts);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{servicePortErrors?.port && (
|
||||||
|
<FormError>{servicePortErrors.port}</FormError>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-min basis-1/4 flex-col">
|
||||||
|
<InputGroup size="small">
|
||||||
|
<InputGroup.Addon>Nodeport</InputGroup.Addon>
|
||||||
|
<InputGroup.Input
|
||||||
|
type="number"
|
||||||
|
className="form-control min-w-max"
|
||||||
|
name={`node_port_${portIndex}`}
|
||||||
|
placeholder="30080"
|
||||||
|
min="30000"
|
||||||
|
max="32767"
|
||||||
|
value={servicePort.nodePort ?? ''}
|
||||||
|
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newServicePorts = [...servicePorts];
|
||||||
|
newServicePorts[portIndex] = {
|
||||||
|
...newServicePorts[portIndex],
|
||||||
|
nodePort:
|
||||||
|
e.target.value === ''
|
||||||
|
? undefined
|
||||||
|
: Number(e.target.value),
|
||||||
|
};
|
||||||
|
onChangePort(newServicePorts);
|
||||||
|
}}
|
||||||
|
data-cy={`k8sAppCreate-nodePort_${portIndex}`}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
{servicePortErrors?.nodePort && (
|
||||||
|
<FormError>{servicePortErrors.nodePort}</FormError>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ButtonSelector
|
||||||
|
className="h-[30px]"
|
||||||
|
onChange={(value) => {
|
||||||
|
const newServicePorts = [...servicePorts];
|
||||||
|
newServicePorts[portIndex] = {
|
||||||
|
...newServicePorts[portIndex],
|
||||||
|
protocol: value,
|
||||||
|
};
|
||||||
|
onChangePort(newServicePorts);
|
||||||
|
}}
|
||||||
|
value={servicePort.protocol || 'TCP'}
|
||||||
|
options={[{ value: 'TCP' }, { value: 'UDP' }]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
disabled={servicePorts.length === 1}
|
||||||
|
size="small"
|
||||||
|
className="!ml-0 h-[30px]"
|
||||||
|
color="dangerlight"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
// remove the port at the index in an immutable way
|
||||||
|
const newServicePorts = [
|
||||||
|
...servicePorts.slice(0, portIndex),
|
||||||
|
...servicePorts.slice(portIndex + 1),
|
||||||
|
];
|
||||||
|
onChangePort(newServicePorts);
|
||||||
|
}}
|
||||||
|
data-cy={`k8sAppCreate-rmPortButton_${portIndex}`}
|
||||||
|
icon={Trash2}
|
||||||
|
>
|
||||||
|
Remove port
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{initiallyHasIngressPath && (
|
||||||
|
<AppIngressPathsForm
|
||||||
|
servicePortIngressPaths={
|
||||||
|
servicePorts[portIndex].ingressPaths
|
||||||
|
}
|
||||||
|
onChangeIngressPaths={(
|
||||||
|
ingressPaths?: ServicePortIngressPath[]
|
||||||
|
) => {
|
||||||
|
const newServicePorts = [...servicePorts];
|
||||||
|
newServicePorts[portIndex].ingressPaths = ingressPaths;
|
||||||
|
onChangePort(newServicePorts);
|
||||||
|
}}
|
||||||
|
namespace={namespace}
|
||||||
|
ingressPathsErrors={ingressPathsErrors}
|
||||||
|
serviceIndex={serviceIndex}
|
||||||
|
portIndex={portIndex}
|
||||||
|
isEditMode={isEditMode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="flex">
|
||||||
|
<Button
|
||||||
|
icon={Plus}
|
||||||
|
color="default"
|
||||||
|
className="!ml-0"
|
||||||
|
onClick={() => {
|
||||||
|
const newServicesPorts = [...servicePorts, newNodePortPort];
|
||||||
|
onChangePort(newServicesPorts);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add port
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Widget.Body>
|
||||||
|
</Widget>
|
||||||
|
);
|
||||||
|
}
|
|
@ -7,8 +7,13 @@ import { Card } from '@@/Card';
|
||||||
import { TextTip } from '@@/Tip/TextTip';
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
import { Button } from '@@/buttons';
|
import { Button } from '@@/buttons';
|
||||||
|
|
||||||
import { serviceFormDefaultValues, generateUniqueName, newPort } from './utils';
|
import {
|
||||||
import { ServiceFormValues, ServicePort } from './types';
|
serviceFormDefaultValues,
|
||||||
|
generateUniqueName,
|
||||||
|
newPort,
|
||||||
|
} from '../utils';
|
||||||
|
import { ServiceFormValues, ServicePort } from '../types';
|
||||||
|
|
||||||
import { NodePortServiceForm } from './NodePortServiceForm';
|
import { NodePortServiceForm } from './NodePortServiceForm';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
@ -17,6 +22,8 @@ interface Props {
|
||||||
errors?: FormikErrors<ServiceFormValues[]>;
|
errors?: FormikErrors<ServiceFormValues[]>;
|
||||||
appName: string;
|
appName: string;
|
||||||
selector: Record<string, string>;
|
selector: Record<string, string>;
|
||||||
|
namespace?: string;
|
||||||
|
isEditMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NodePortServicesForm({
|
export function NodePortServicesForm({
|
||||||
|
@ -25,6 +32,8 @@ export function NodePortServicesForm({
|
||||||
errors,
|
errors,
|
||||||
appName,
|
appName,
|
||||||
selector,
|
selector,
|
||||||
|
namespace,
|
||||||
|
isEditMode,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const nodePortServiceCount = services.filter(
|
const nodePortServiceCount = services.filter(
|
||||||
(service) => service.Type === KubernetesApplicationPublishingTypes.NODE_PORT
|
(service) => service.Type === KubernetesApplicationPublishingTypes.NODE_PORT
|
||||||
|
@ -54,6 +63,8 @@ export function NodePortServicesForm({
|
||||||
services={services}
|
services={services}
|
||||||
serviceIndex={index}
|
serviceIndex={index}
|
||||||
onChangeService={onChangeService}
|
onChangeService={onChangeService}
|
||||||
|
namespace={namespace}
|
||||||
|
isEditMode={isEditMode}
|
||||||
/>
|
/>
|
||||||
) : null
|
) : null
|
||||||
)}
|
)}
|
|
@ -9,12 +9,18 @@ export interface ServicePort {
|
||||||
serviceName?: string;
|
serviceName?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
protocol?: string;
|
protocol?: string;
|
||||||
ingress?: object;
|
ingressPaths?: ServicePortIngressPath[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServiceTypeAngularEnum =
|
export type ServiceTypeAngularEnum =
|
||||||
(typeof KubernetesApplicationPublishingTypes)[keyof typeof KubernetesApplicationPublishingTypes];
|
(typeof KubernetesApplicationPublishingTypes)[keyof typeof KubernetesApplicationPublishingTypes];
|
||||||
|
|
||||||
|
export type ServicePortIngressPath = {
|
||||||
|
IngressName?: string;
|
||||||
|
Host?: string;
|
||||||
|
Path?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ServiceFormValues = {
|
export type ServiceFormValues = {
|
||||||
Headless: boolean;
|
Headless: boolean;
|
||||||
Ports: ServicePort[];
|
Ports: ServicePort[];
|
||||||
|
@ -35,3 +41,9 @@ export type ServiceTypeOption = {
|
||||||
value: ServiceTypeValue;
|
value: ServiceTypeValue;
|
||||||
label: ReactNode;
|
label: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type IngressOption = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
ingressName: string;
|
||||||
|
};
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
import { FormikErrors } from 'formik';
|
import { FormikErrors } from 'formik';
|
||||||
|
|
||||||
import { ServiceFormValues } from './types';
|
import { Ingress } from '@/react/kubernetes/ingresses/types';
|
||||||
|
|
||||||
export function isServicePortError<T>(
|
import { ServiceFormValues, ServicePort } from './types';
|
||||||
|
|
||||||
|
export function isErrorType<T>(
|
||||||
error: string | FormikErrors<T> | undefined
|
error: string | FormikErrors<T> | undefined
|
||||||
): error is FormikErrors<T> {
|
): error is FormikErrors<T> {
|
||||||
return error !== undefined && typeof error !== 'string';
|
return error !== undefined && typeof error !== 'string';
|
||||||
|
@ -24,7 +26,7 @@ function generateIndexedName(appName: string, index: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function isNameUnique(name: string, services: ServiceFormValues[]) {
|
function isNameUnique(name: string, services: ServiceFormValues[]) {
|
||||||
return services.findIndex((service) => service.Name === name) === -1;
|
return !services.find((service) => service.Name === name);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateUniqueName(
|
export function generateUniqueName(
|
||||||
|
@ -57,3 +59,101 @@ export const serviceFormDefaultValues: ServiceFormValues = {
|
||||||
Ingress: false,
|
Ingress: false,
|
||||||
Selector: {},
|
Selector: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates new Ingress objects from form path data
|
||||||
|
* @param {Ingress[]} oldIngresses - The old Ingress objects
|
||||||
|
* @param {ServicePort[]} newServicesPorts - The new ServicePort objects from the form
|
||||||
|
* @param {ServicePort[]} oldServicesPorts - The old ServicePort objects
|
||||||
|
* @returns {Ingress[]} The new Ingress objects
|
||||||
|
*/
|
||||||
|
export function generateNewIngressesFromFormPaths(
|
||||||
|
oldIngresses?: Ingress[],
|
||||||
|
newServicesPorts?: ServicePort[],
|
||||||
|
oldServicesPorts?: ServicePort[]
|
||||||
|
): Ingress[] {
|
||||||
|
// filter the ports to only the ones that have an ingress
|
||||||
|
const oldIngressPaths = oldServicesPorts
|
||||||
|
?.flatMap((port) => port.ingressPaths)
|
||||||
|
.filter((ingressPath) => ingressPath?.IngressName);
|
||||||
|
const newPortsWithIngress = newServicesPorts?.filter(
|
||||||
|
(port) => port.ingressPaths?.length
|
||||||
|
);
|
||||||
|
// return early if possible
|
||||||
|
if (!oldIngresses && !newPortsWithIngress) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove the old paths from the newIngresses copy
|
||||||
|
const newIngresses = structuredClone(oldIngresses) ?? [];
|
||||||
|
oldIngressPaths?.forEach((oldIngressPath) => {
|
||||||
|
if (!oldIngressPath?.Path) return;
|
||||||
|
const newMatchingIng = newIngresses?.find(
|
||||||
|
(ingress) => ingress.Name === oldIngressPath.IngressName
|
||||||
|
);
|
||||||
|
if (!newMatchingIng) return;
|
||||||
|
|
||||||
|
// remove the old path from the new ingress
|
||||||
|
const oldPathIndex = newMatchingIng?.Paths?.findIndex(
|
||||||
|
(path) =>
|
||||||
|
path.Path === prependWithSlash(oldIngressPath.Path) &&
|
||||||
|
path.Host === oldIngressPath.Host
|
||||||
|
);
|
||||||
|
if (oldPathIndex === -1 || oldPathIndex === undefined) return;
|
||||||
|
if (newMatchingIng.Paths) {
|
||||||
|
newMatchingIng.Paths = [
|
||||||
|
...newMatchingIng.Paths.slice(0, oldPathIndex),
|
||||||
|
...newMatchingIng.Paths.slice(oldPathIndex + 1),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// update the new ingresses with the newMatchingIng
|
||||||
|
const newIngIndex = newIngresses.findIndex(
|
||||||
|
(ingress) => ingress.Name === newMatchingIng.Name
|
||||||
|
);
|
||||||
|
newIngresses[newIngIndex] = newMatchingIng;
|
||||||
|
});
|
||||||
|
|
||||||
|
// and add the new paths to return the updated ingresses
|
||||||
|
newPortsWithIngress?.forEach(
|
||||||
|
({ ingressPaths: newIngresspaths, ...servicePort }) => {
|
||||||
|
newIngresspaths?.forEach((newIngressPath) => {
|
||||||
|
if (!newIngressPath?.Path) return;
|
||||||
|
const newMatchingIng = newIngresses.find(
|
||||||
|
(ingress) => ingress.Name === newIngressPath?.IngressName
|
||||||
|
);
|
||||||
|
if (!newMatchingIng) return;
|
||||||
|
|
||||||
|
// add the new path to the new ingress
|
||||||
|
if (
|
||||||
|
newIngressPath.Host &&
|
||||||
|
newIngressPath.IngressName &&
|
||||||
|
servicePort.serviceName &&
|
||||||
|
servicePort.port
|
||||||
|
) {
|
||||||
|
newMatchingIng.Paths = [
|
||||||
|
...(newMatchingIng.Paths ?? []),
|
||||||
|
{
|
||||||
|
Path: prependWithSlash(newIngressPath.Path),
|
||||||
|
Host: newIngressPath.Host,
|
||||||
|
IngressName: newIngressPath.IngressName,
|
||||||
|
ServiceName: servicePort.serviceName,
|
||||||
|
Port: servicePort.port,
|
||||||
|
PathType: 'Prefix',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
// update the new ingresses with the newMatchingIng
|
||||||
|
const newIngIndex = newIngresses.findIndex(
|
||||||
|
(ingress) => ingress.Name === newMatchingIng.Name
|
||||||
|
);
|
||||||
|
newIngresses[newIngIndex] = newMatchingIng;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return newIngresses;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prependWithSlash(path?: string) {
|
||||||
|
return path?.startsWith('/') ? path : `/${path}`;
|
||||||
|
}
|
||||||
|
|
|
@ -41,7 +41,7 @@ export function ApplicationIngressesTable({
|
||||||
<td className="w-[10%]">Path</td>
|
<td className="w-[10%]">Path</td>
|
||||||
<td className="w-[15%]">HTTP Route</td>
|
<td className="w-[15%]">HTTP Route</td>
|
||||||
</tr>
|
</tr>
|
||||||
{ingressPathsForAppServices.map((ingressPath, index) => (
|
{ingressPathsForAppServices?.map((ingressPath, index) => (
|
||||||
<tr key={index}>
|
<tr key={index}>
|
||||||
<td>
|
<td>
|
||||||
<Authorized authorizations="K8sIngressesW">
|
<Authorized authorizations="K8sIngressesW">
|
||||||
|
@ -94,7 +94,10 @@ function getIngressPathsForAppServices(
|
||||||
}
|
}
|
||||||
const matchingIngressesPaths = ingresses.flatMap((ingress) => {
|
const matchingIngressesPaths = ingresses.flatMap((ingress) => {
|
||||||
// for each ingress get an array of ingress paths that match the app services
|
// for each ingress get an array of ingress paths that match the app services
|
||||||
const matchingIngressPaths = ingress.Paths.filter((path) =>
|
if (!ingress.Paths) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const matchingIngressPaths = ingress.Paths?.filter((path) =>
|
||||||
services?.some((service) => {
|
services?.some((service) => {
|
||||||
const servicePorts = service.spec?.ports?.map((port) => port.port);
|
const servicePorts = service.spec?.ports?.map((port) => port.port);
|
||||||
// include the ingress if the ingress path has a matching service name and port
|
// include the ingress if the ingress path has a matching service name and port
|
||||||
|
|
|
@ -11,7 +11,7 @@ import { columnHelper } from './helper';
|
||||||
export const ingressRules = columnHelper.accessor(
|
export const ingressRules = columnHelper.accessor(
|
||||||
({ Paths, TLS }) =>
|
({ Paths, TLS }) =>
|
||||||
// return an accessor function with all the useful text to search for
|
// return an accessor function with all the useful text to search for
|
||||||
Paths.map((path) => {
|
Paths?.map((path) => {
|
||||||
const isHttp = isHTTP(TLS || [], path.Host);
|
const isHttp = isHTTP(TLS || [], path.Host);
|
||||||
return `${isHttp ? 'http' : 'https'}://${path.Host}${path.Path}${
|
return `${isHttp ? 'http' : 'https'}://${path.Host}${path.Path}${
|
||||||
path.ServiceName
|
path.ServiceName
|
||||||
|
|
|
@ -92,25 +92,27 @@ export function useIngresses(
|
||||||
.flat();
|
.flat();
|
||||||
|
|
||||||
// check if each ingress path service has a service that still exists
|
// check if each ingress path service has a service that still exists
|
||||||
filteredIngresses.forEach((ing, iIndex) => {
|
const updatedFilteredIngresses: Ingress[] = filteredIngresses.map(
|
||||||
const servicesInNamespace = services?.filter(
|
(ing) => {
|
||||||
(service) => service?.Namespace === ing?.Namespace
|
const servicesInNamespace = services?.filter(
|
||||||
);
|
(service) => service?.Namespace === ing?.Namespace
|
||||||
const serviceNamesInNamespace = servicesInNamespace?.map(
|
);
|
||||||
(service) => service.Name
|
const serviceNamesInNamespace = servicesInNamespace?.map(
|
||||||
);
|
(service) => service.Name
|
||||||
ing.Paths?.forEach((path, pIndex) => {
|
);
|
||||||
if (
|
|
||||||
!serviceNamesInNamespace?.includes(path.ServiceName) &&
|
const updatedPaths =
|
||||||
filteredIngresses[iIndex].Paths
|
ing.Paths?.map((path) => {
|
||||||
) {
|
const hasService = serviceNamesInNamespace?.includes(
|
||||||
filteredIngresses[iIndex].Paths[pIndex].HasService = false;
|
path.ServiceName
|
||||||
} else {
|
);
|
||||||
filteredIngresses[iIndex].Paths[pIndex].HasService = true;
|
return { ...path, HasService: hasService };
|
||||||
}
|
}) || null;
|
||||||
});
|
|
||||||
});
|
return { ...ing, Paths: updatedPaths };
|
||||||
return filteredIngresses;
|
}
|
||||||
|
);
|
||||||
|
return updatedFilteredIngresses;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!namespaces?.length,
|
enabled: !!namespaces?.length,
|
||||||
|
@ -175,7 +177,7 @@ export function useDeleteIngresses() {
|
||||||
*/
|
*/
|
||||||
export function useIngressControllers(
|
export function useIngressControllers(
|
||||||
environmentId: EnvironmentId,
|
environmentId: EnvironmentId,
|
||||||
namespace: string
|
namespace?: string
|
||||||
) {
|
) {
|
||||||
return useQuery(
|
return useQuery(
|
||||||
[
|
[
|
||||||
|
@ -186,10 +188,8 @@ export function useIngressControllers(
|
||||||
namespace,
|
namespace,
|
||||||
'ingresscontrollers',
|
'ingresscontrollers',
|
||||||
],
|
],
|
||||||
async () => {
|
async () =>
|
||||||
const ing = await getIngressControllers(environmentId, namespace);
|
namespace ? getIngressControllers(environmentId, namespace) : [],
|
||||||
return ing;
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
enabled: !!namespace,
|
enabled: !!namespace,
|
||||||
cacheTime: 0,
|
cacheTime: 0,
|
||||||
|
|
|
@ -30,7 +30,7 @@ export type Ingress = {
|
||||||
ClassName: string;
|
ClassName: string;
|
||||||
Annotations?: Record<string, string>;
|
Annotations?: Record<string, string>;
|
||||||
Hosts?: string[];
|
Hosts?: string[];
|
||||||
Paths: Path[];
|
Paths: Path[] | null;
|
||||||
TLS?: TLS[];
|
TLS?: TLS[];
|
||||||
Type?: string;
|
Type?: string;
|
||||||
Labels?: Record<string, string>;
|
Labels?: Record<string, string>;
|
||||||
|
|
|
@ -64,6 +64,7 @@ export interface KubernetesConfiguration {
|
||||||
EnableResourceOverCommit?: boolean;
|
EnableResourceOverCommit?: boolean;
|
||||||
ResourceOverCommitPercentage?: number;
|
ResourceOverCommitPercentage?: number;
|
||||||
RestrictDefaultNamespace?: boolean;
|
RestrictDefaultNamespace?: boolean;
|
||||||
|
RestrictStandardUserIngressW?: boolean;
|
||||||
IngressClasses: IngressClass[];
|
IngressClasses: IngressClass[];
|
||||||
IngressAvailabilityPerNamespace: boolean;
|
IngressAvailabilityPerNamespace: boolean;
|
||||||
AllowNoneIngressClass: boolean;
|
AllowNoneIngressClass: boolean;
|
||||||
|
|
Loading…
Reference in New Issue