mirror of https://github.com/portainer/portainer
refactor(app): app service form to react [EE-5415] (#8994)
parent
2d05103fed
commit
69776b4863
|
@ -1,95 +0,0 @@
|
|||
import _ from 'lodash-es';
|
||||
import { KubernetesServicePort } from 'Kubernetes/models/service/models';
|
||||
import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues';
|
||||
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
|
||||
import { KubernetesApplicationPublishingTypes } from 'Kubernetes/models/application/models/constants';
|
||||
|
||||
export default class KubeServicesItemViewController {
|
||||
/* @ngInject */
|
||||
constructor(EndpointProvider, Authentication) {
|
||||
this.EndpointProvider = EndpointProvider;
|
||||
this.Authentication = Authentication;
|
||||
this.KubernetesApplicationPublishingTypes = KubernetesApplicationPublishingTypes;
|
||||
}
|
||||
|
||||
addPort() {
|
||||
const port = new KubernetesServicePort();
|
||||
port.nodePort = '';
|
||||
port.port = '';
|
||||
port.targetPort = '';
|
||||
port.protocol = 'TCP';
|
||||
this.service.Ports.push(port);
|
||||
}
|
||||
|
||||
removePort(index) {
|
||||
this.service.Ports.splice(index, 1);
|
||||
}
|
||||
|
||||
servicePort(index) {
|
||||
const targetPort = this.service.Ports[index].targetPort;
|
||||
this.service.Ports[index].port = targetPort;
|
||||
this.onChangeServicePort();
|
||||
}
|
||||
|
||||
isAdmin() {
|
||||
return this.Authentication.isAdmin();
|
||||
}
|
||||
|
||||
onChangeContainerPort() {
|
||||
const state = this.state.duplicates.targetPort;
|
||||
const source = _.map(this.service.Ports, (sp) => sp.targetPort);
|
||||
const duplicates = KubernetesFormValidationHelper.getDuplicates(source);
|
||||
state.refs = duplicates;
|
||||
state.hasRefs = Object.keys(duplicates).length > 0;
|
||||
}
|
||||
|
||||
onChangeServicePort() {
|
||||
const state = this.state.duplicates.servicePort;
|
||||
const source = _.map(this.service.Ports, (sp) => sp.port);
|
||||
const duplicates = KubernetesFormValidationHelper.getDuplicates(source);
|
||||
state.refs = duplicates;
|
||||
state.hasRefs = Object.keys(duplicates).length > 0;
|
||||
|
||||
this.service.servicePortError = state.hasRefs;
|
||||
}
|
||||
|
||||
onChangeNodePort() {
|
||||
const state = this.state.duplicates.nodePort;
|
||||
|
||||
// create a list of all the node ports (number[]) in the cluster and current form
|
||||
const clusterNodePortsWithoutCurrentService = this.nodePortServices
|
||||
.filter((npService) => npService.Name !== this.service.Name)
|
||||
.map((npService) => npService.Ports)
|
||||
.flat()
|
||||
.map((npServicePorts) => npServicePorts.NodePort);
|
||||
const formNodePortsWithoutCurrentService = this.formServices
|
||||
.filter((formService) => formService.Type === KubernetesApplicationPublishingTypes.NODE_PORT && formService.Name !== this.service.Name)
|
||||
.map((formService) => formService.Ports)
|
||||
.flat()
|
||||
.map((formServicePorts) => formServicePorts.nodePort);
|
||||
const serviceNodePorts = this.service.Ports.map((sp) => sp.nodePort);
|
||||
// getDuplicates cares about the index, so put the serviceNodePorts at the start
|
||||
const allNodePortsWithoutCurrentService = [...clusterNodePortsWithoutCurrentService, ...formNodePortsWithoutCurrentService];
|
||||
|
||||
const duplicates = KubernetesFormValidationHelper.getDuplicateNodePorts(serviceNodePorts, allNodePortsWithoutCurrentService);
|
||||
state.refs = duplicates;
|
||||
state.hasRefs = Object.keys(duplicates).length > 0;
|
||||
|
||||
this.service.nodePortError = state.hasRefs;
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
if (this.service.Ports.length === 0) {
|
||||
this.addPort();
|
||||
}
|
||||
|
||||
this.state = {
|
||||
duplicates: {
|
||||
targetPort: new KubernetesFormValidationReferences(),
|
||||
servicePort: new KubernetesFormValidationReferences(),
|
||||
nodePort: new KubernetesFormValidationReferences(),
|
||||
},
|
||||
endpointId: this.EndpointProvider.endpointID(),
|
||||
};
|
||||
}
|
||||
}
|
|
@ -1,188 +0,0 @@
|
|||
<ng-form name="serviceForm">
|
||||
<div ng-if="$ctrl.isAdmin()" class="small" ng-show="$ctrl.service.Type === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && !$ctrl.loadbalancerEnabled">
|
||||
<p class="text-warning vertical-center pt-2">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> No Load balancer is available in this cluster, click
|
||||
<a class="hyperlink" ui-sref="kubernetes.cluster.setup">here</a> to configure load balancer.
|
||||
</p>
|
||||
</div>
|
||||
<div ng-if="!$ctrl.isAdmin()" class="small" ng-show="$ctrl.service.Type === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && !$ctrl.loadbalancerEnabled">
|
||||
<p class="text-warning vertical-center pt-2">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> No Load balancer is available in this cluster, contact your administrator.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ng-if="
|
||||
($ctrl.service.Type === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && $ctrl.loadbalancerEnabled) ||
|
||||
$ctrl.service.Type === $ctrl.KubernetesApplicationPublishingTypes.CLUSTER_IP ||
|
||||
$ctrl.service.Type === $ctrl.KubernetesApplicationPublishingTypes.NODE_PORT
|
||||
"
|
||||
>
|
||||
<div ng-show="!$ctrl.multiItemDisable" class="vertical-center mt-5 mb-5">
|
||||
<label class="control-label !pt-0 text-left">Published ports</label>
|
||||
</div>
|
||||
<div ng-repeat="servicePort in $ctrl.service.Ports" class="service-form row mt-5">
|
||||
<div class="form-group col-sm-3 !mx-0 !pl-0">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-addon required">Container port</span>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control"
|
||||
name="container_port_{{ $index }}"
|
||||
ng-model="servicePort.targetPort"
|
||||
placeholder="80"
|
||||
ng-min="1"
|
||||
ng-max="65535"
|
||||
min="1"
|
||||
max="65535"
|
||||
ng-change="$ctrl.servicePort($index)"
|
||||
required
|
||||
ng-disabled="$ctrl.originalIngresses.length === 0 || ($ctrl.service.Type === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && !$ctrl.loadbalancerEnabled)"
|
||||
ng-change="$ctrl.onChangeContainerPort()"
|
||||
data-cy="k8sAppCreate-containerPort_{{ $index }}"
|
||||
/>
|
||||
</div>
|
||||
<span>
|
||||
<div class="small text-warning mt-1" ng-if="$ctrl.state.duplicates.targetPort.refs[$index] !== undefined">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> This container port is already used.
|
||||
</div>
|
||||
<div class="small text-warning mt-1" ng-messages="serviceForm['container_port_'+$index].$error">
|
||||
<p class="vertical-center" ng-message="required"><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Container port number is required.</p>
|
||||
<p class="vertical-center" ng-message="min"><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Container port number must be inside the range 1-65535.</p>
|
||||
<p class="vertical-center" ng-message="max"><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Container port number must be inside the range 1-65535.</p>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group col-sm-3 !mx-0 !pl-0">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-addon required">Service port</span>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control"
|
||||
name="service_port_{{ $index }}"
|
||||
ng-model="servicePort.port"
|
||||
placeholder="80"
|
||||
ng-min="1"
|
||||
ng-max="65535"
|
||||
min="1"
|
||||
max="65535"
|
||||
required
|
||||
ng-disabled="$ctrl.originalIngresses.length === 0 || ($ctrl.service.Type === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && !$ctrl.loadbalancerEnabled)"
|
||||
ng-change="$ctrl.onChangeServicePort()"
|
||||
data-cy="k8sAppCreate-servicePort_{{ $index }}"
|
||||
/>
|
||||
</div>
|
||||
<span>
|
||||
<div class="small text-warning mt-1" ng-if="$ctrl.state.duplicates.servicePort.refs[$index] !== undefined">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> This service port is already used.
|
||||
</div>
|
||||
<div class="small text-warning mt-1">
|
||||
<div ng-messages="serviceForm['service_port_'+$index].$error">
|
||||
<p class="vertical-center" ng-message="required"><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Service port number is required.</p>
|
||||
<p class="vertical-center" ng-message="min"><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Service port number must be inside the range 1-65535.</p>
|
||||
<p class="vertical-center" ng-message="max"><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Service port number must be inside the range 1-65535.</p>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group col-sm-3 !mx-0 !pl-0" ng-if="$ctrl.service.Type === $ctrl.KubernetesApplicationPublishingTypes.NODE_PORT">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-addon required">Nodeport</span>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control"
|
||||
name="node_port_{{ $index }}"
|
||||
ng-model="servicePort.nodePort"
|
||||
placeholder="30080"
|
||||
ng-min="30000"
|
||||
ng-max="32767"
|
||||
min="30000"
|
||||
max="32767"
|
||||
required
|
||||
ng-change="$ctrl.onChangeNodePort()"
|
||||
data-cy="k8sAppCreate-nodeportPort_{{ $index }}"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<span>
|
||||
<div class="small text-warning mt-1">
|
||||
<div ng-messages="serviceForm['node_port_'+$index].$error">
|
||||
<p class="vertical-center" ng-message="required"><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Nodeport is required.</p>
|
||||
<p class="vertical-center" ng-message="min"
|
||||
><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Nodeport number must be inside the range 30000-32767 or blank for system allocated.</p
|
||||
>
|
||||
<p class="vertical-center" ng-message="max"
|
||||
><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Nodeport number must be inside the range 30000-32767 or blank for system allocated.</p
|
||||
>
|
||||
<div class="text-warning mt-1" ng-if="$ctrl.state.duplicates.nodePort.refs[$index] !== undefined">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> This node port is already used.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-sm-3 !mx-0 !pl-0" ng-if="$ctrl.service.Type === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-addon">Loadbalancer port</span>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control"
|
||||
name="loadbalancer_port_{{ $index }}"
|
||||
ng-model="servicePort.port"
|
||||
placeholder="80"
|
||||
ng-min="1"
|
||||
ng-max="65535"
|
||||
min="1"
|
||||
max="65535"
|
||||
required
|
||||
ng-disabled="$ctrl.service.Type === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && !$ctrl.loadbalancerEnabled"
|
||||
data-cy="k8sAppCreate-loadbalancerPort_{{ $index }}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group col-sm-3 !mx-0 !pl-0">
|
||||
<div class="input-group input-group-sm">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<label
|
||||
class="btn btn-light"
|
||||
ng-model="servicePort.protocol"
|
||||
uib-btn-radio="'TCP'"
|
||||
ng-change="ctrl.onChangePortProtocol($index)"
|
||||
ng-disabled="ctrl.isProtocolOptionDisabled($index, 'TCP')"
|
||||
data-cy="k8sAppCreate-TCPButton_{{ $index }}"
|
||||
>TCP</label
|
||||
>
|
||||
<label
|
||||
class="btn btn-light"
|
||||
ng-model="servicePort.protocol"
|
||||
uib-btn-radio="'UDP'"
|
||||
ng-change="ctrl.onChangePortProtocol($index)"
|
||||
ng-disabled="ctrl.isProtocolOptionDisabled($index, 'UDP')"
|
||||
data-cy="k8sAppCreate-UDPButton_{{ $index }}"
|
||||
>UDP</label
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
ng-disabled="$ctrl.service.Ports.length === 1"
|
||||
ng-show="!$ctrl.multiItemDisable"
|
||||
class="btn btn-sm btn-dangerlight btn-only-icon"
|
||||
type="button"
|
||||
ng-click="$ctrl.removePort($index)"
|
||||
data-cy="k8sAppCreate-rmPortButton_{{ $index }}"
|
||||
>
|
||||
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<span class="btn btn-primary btn-sm btn btn-sm btn-light mb-2 !ml-0" ng-click="$ctrl.addPort()" data-cy="k8sAppCreate-addNewPortButton">
|
||||
<pr-icon icon="'plus'" size="'sm'"></pr-icon> Publish a new port
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ng-form>
|
|
@ -1,14 +0,0 @@
|
|||
import angular from 'angular';
|
||||
import controller from './kube-services-item.controller';
|
||||
|
||||
angular.module('portainer.kubernetes').component('kubeServicesItemView', {
|
||||
templateUrl: './kube-services-item.html',
|
||||
controller,
|
||||
bindings: {
|
||||
nodePortServices: '<',
|
||||
formServices: '<',
|
||||
service: '=',
|
||||
isEdit: '<',
|
||||
loadbalancerEnabled: '<',
|
||||
},
|
||||
});
|
|
@ -1,105 +0,0 @@
|
|||
import { KubernetesService, KubernetesServicePort, KubernetesServiceTypes } from '@/kubernetes/models/service/models';
|
||||
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models/constants';
|
||||
import { notifyError } from '@/portainer/services/notifications';
|
||||
import { getServices } from '@/react/kubernetes/networks/services/service';
|
||||
|
||||
export default class KubeServicesViewController {
|
||||
/* @ngInject */
|
||||
constructor($async, EndpointProvider, Authentication) {
|
||||
this.$async = $async;
|
||||
this.EndpointProvider = EndpointProvider;
|
||||
this.Authentication = Authentication;
|
||||
this.asyncOnInit = this.asyncOnInit.bind(this);
|
||||
}
|
||||
|
||||
addEntry(service) {
|
||||
const p = new KubernetesService();
|
||||
p.Type = service;
|
||||
|
||||
p.Selector = this.formValues.Selector;
|
||||
|
||||
p.Name = this.getUniqName();
|
||||
this.formValues.Services.push(p);
|
||||
}
|
||||
|
||||
getUniqName() {
|
||||
//services name will follow thia patten: service, service-2, service-3...
|
||||
let nameIndex = 2;
|
||||
let UniqName = this.formValues.Name;
|
||||
const services = this.formValues.Services;
|
||||
|
||||
const sortServices = services.sort((a, b) => {
|
||||
return a.Name.localeCompare(b.Name);
|
||||
});
|
||||
|
||||
if (sortServices.length !== 0) {
|
||||
sortServices.forEach((service) => {
|
||||
if (service.Name === UniqName) {
|
||||
UniqName = this.formValues.Name + '-' + nameIndex;
|
||||
nameIndex += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
return UniqName;
|
||||
}
|
||||
|
||||
deleteService(index) {
|
||||
this.formValues.Services.splice(index, 1);
|
||||
}
|
||||
|
||||
addPort(index) {
|
||||
const p = new KubernetesServicePort();
|
||||
this.formValues.Services[index].Ports.push(p);
|
||||
}
|
||||
|
||||
serviceType(type) {
|
||||
switch (type) {
|
||||
case KubernetesApplicationPublishingTypes.CLUSTER_IP:
|
||||
return KubernetesServiceTypes.CLUSTER_IP;
|
||||
case KubernetesApplicationPublishingTypes.NODE_PORT:
|
||||
return KubernetesServiceTypes.NODE_PORT;
|
||||
case KubernetesApplicationPublishingTypes.LOAD_BALANCER:
|
||||
return KubernetesServiceTypes.LOAD_BALANCER;
|
||||
}
|
||||
}
|
||||
|
||||
isAdmin() {
|
||||
return this.Authentication.isAdmin();
|
||||
}
|
||||
|
||||
async asyncOnInit() {
|
||||
try {
|
||||
// get all nodeport services in the cluster, to validate unique nodeports in the form
|
||||
const allSettledServices = await Promise.allSettled(this.namespaces.map((namespace) => getServices(this.state.endpointId, namespace)));
|
||||
const allServices = allSettledServices
|
||||
.filter((settledService) => settledService.status === 'fulfilled' && settledService.value)
|
||||
.map((fulfilledService) => fulfilledService.value)
|
||||
.flat();
|
||||
this.nodePortServices = allServices.filter((service) => service.Type === 'NodePort');
|
||||
} catch (error) {
|
||||
notifyError('Failure', error, 'Failed getting services');
|
||||
}
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
this.state = {
|
||||
serviceType: [
|
||||
{
|
||||
typeName: KubernetesServiceTypes.CLUSTER_IP,
|
||||
typeValue: KubernetesApplicationPublishingTypes.CLUSTER_IP,
|
||||
},
|
||||
{
|
||||
typeName: KubernetesServiceTypes.NODE_PORT,
|
||||
typeValue: KubernetesApplicationPublishingTypes.NODE_PORT,
|
||||
},
|
||||
{
|
||||
typeName: KubernetesServiceTypes.LOAD_BALANCER,
|
||||
typeValue: KubernetesApplicationPublishingTypes.LOAD_BALANCER,
|
||||
},
|
||||
],
|
||||
selected: KubernetesApplicationPublishingTypes.CLUSTER_IP,
|
||||
endpointId: this.EndpointProvider.endpointID(),
|
||||
};
|
||||
return this.$async(this.asyncOnInit);
|
||||
}
|
||||
}
|
|
@ -1,91 +0,0 @@
|
|||
<div class="col-sm-12 form-section-title"> Publishing the application </div>
|
||||
<div class="col-sm-12 !p-0">
|
||||
<div class="small">
|
||||
<p class="text-muted vertical-center">
|
||||
<pr-icon icon="'alert-circle'" mode="'primary'"></pr-icon>
|
||||
<span>Publish your application by creating a ClusterIP service for it, which you may then expose via <a target="_blank" ui-sref="kubernetes.ingresses">an ingress</a>.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 form-inline">
|
||||
<div class="col-sm-6" style="padding-left: 0px">
|
||||
<select
|
||||
class="form-control"
|
||||
ng-model="$ctrl.state.selected"
|
||||
ng-options="item.typeValue as item.typeName for item in $ctrl.state.serviceType"
|
||||
data-cy="k8sAppCreate-publishingModeDropdown"
|
||||
></select>
|
||||
<button type="button" class="btn btn-md btn-default vertical-center !ml-0" ng-click="$ctrl.addEntry( $ctrl.state.selected )" data-cy="k8sAppCreate-createServiceButton">
|
||||
<span
|
||||
class="interactive vertical-center"
|
||||
tooltip-append-to-body="true"
|
||||
tooltip-placement="top"
|
||||
tooltip-class="portainer-tooltip"
|
||||
uib-tooltip="Different service types expose the deployment in alternate ways.
|
||||
ClusterIP exposes it within the cluster (for internal access only).
|
||||
NodePort exposes it (on a high port) across all nodes.
|
||||
LoadBalancer exposes it via an external load balancer."
|
||||
>
|
||||
<pr-icon icon="'plus'" size="'sm'"></pr-icon> Create service
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 form-inline" style="margin-top: 20px" ng-repeat="service in $ctrl.formValues.Services">
|
||||
<div>
|
||||
<div class="text-muted vertical-center">
|
||||
<pr-icon ng-if="$ctrl.serviceType(service.Type) === 'ClusterIP'" icon="'list'"></pr-icon>
|
||||
<pr-icon ng-if="$ctrl.serviceType(service.Type) === 'LoadBalancer'" icon="'svg-dataflow'"></pr-icon>
|
||||
<pr-icon ng-if="$ctrl.serviceType(service.Type) === 'NodePort'" icon="'list'"></pr-icon>
|
||||
{{ $ctrl.serviceType(service.Type) }}
|
||||
</div>
|
||||
<kube-services-item-view
|
||||
node-port-services="$ctrl.nodePortServices"
|
||||
form-services="$ctrl.formValues.Services"
|
||||
service="$ctrl.formValues.Services[$index]"
|
||||
is-edit="$ctrl.isEdit"
|
||||
loadbalancer-enabled="$ctrl.loadbalancerEnabled"
|
||||
></kube-services-item-view>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-dangerlight space-right vertical-center"
|
||||
style="margin-left: 0; margin-top: 10px"
|
||||
ng-click="$ctrl.deleteService( $index )"
|
||||
data-cy="k8sConfigCreate-removeButton"
|
||||
>
|
||||
<pr-icon icon="'trash-2'" size="'md'"></pr-icon> Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ng-if="$ctrl.formValues.Services[$index].Ingress && $ctrl.formValues.OriginalIngresses.length === 0">
|
||||
<div class="text-muted">
|
||||
<pr-icon icon="'svg-route'" class-name="'mr-0.5'"></pr-icon>
|
||||
Ingress
|
||||
</div>
|
||||
<div ng-if="$ctrl.isAdmin()" class="small">
|
||||
<p class="text-warning vertical-center pt-2">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Ingress is not configured in this namespace, select another namespace or click
|
||||
<a ui-sref="kubernetes.cluster.setup">here</a> to configure ingress.
|
||||
</p>
|
||||
</div>
|
||||
<div ng-if="!$ctrl.isAdmin()" class="small">
|
||||
<p class="text-warning vertical-center pt-2">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Ingress is not configured in this namespace, select another namespace or contact your administrator.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-dangerlight space-right vertical-center"
|
||||
style="margin-left: 0; margin-top: 10px"
|
||||
ng-click="$ctrl.deleteService( $index )"
|
||||
data-cy="k8sConfigCreate-removeButton"
|
||||
>
|
||||
<pr-icon icon="'trash-2'" size="'md'"></pr-icon> Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -1,13 +0,0 @@
|
|||
import angular from 'angular';
|
||||
import controller from './kube-services.controller';
|
||||
|
||||
angular.module('portainer.kubernetes').component('kubeServicesView', {
|
||||
templateUrl: './kube-services.html',
|
||||
controller,
|
||||
bindings: {
|
||||
formValues: '=',
|
||||
isEdit: '<',
|
||||
namespaces: '<',
|
||||
loadbalancerEnabled: '<',
|
||||
},
|
||||
});
|
|
@ -303,9 +303,11 @@ class KubernetesApplicationHelper {
|
|||
const svcport = new KubernetesServicePort();
|
||||
svcport.name = port.name;
|
||||
svcport.port = port.port;
|
||||
svcport.nodePort = port.nodePort;
|
||||
svcport.nodePort = port.nodePort || 0;
|
||||
svcport.protocol = port.protocol;
|
||||
svcport.targetPort = port.targetPort;
|
||||
svcport.serviceName = service.metadata.name;
|
||||
svcport.ingress = {};
|
||||
|
||||
app.Ingresses.value.forEach((ingress) => {
|
||||
const ingressNameMatched = ingress.Paths.find((ingPath) => ingPath.ServiceName === service.metadata.name);
|
||||
|
|
|
@ -127,7 +127,7 @@ export class KubernetesIngressConverter {
|
|||
static newApplicationFormValuesToIngresses(formValues, serviceName, servicePorts) {
|
||||
const ingresses = angular.copy(formValues.OriginalIngresses);
|
||||
servicePorts.forEach((port) => {
|
||||
const ingress = _.find(ingresses, { Name: port.ingress.IngressName });
|
||||
const ingress = port.ingress && _.find(ingresses, { Name: port.ingress.IngressName });
|
||||
if (ingress) {
|
||||
const rule = new KubernetesIngressRule();
|
||||
rule.ServiceName = serviceName;
|
||||
|
|
|
@ -7,6 +7,10 @@ import { StorageAccessModeSelector } from '@/react/kubernetes/cluster/ConfigureV
|
|||
import { NamespaceAccessUsersSelector } from '@/react/kubernetes/namespaces/AccessView/NamespaceAccessUsersSelector';
|
||||
import { CreateNamespaceRegistriesSelector } from '@/react/kubernetes/namespaces/CreateView/CreateNamespaceRegistriesSelector';
|
||||
import { KubeApplicationAccessPolicySelector } from '@/react/kubernetes/applications/CreateView/KubeApplicationAccessPolicySelector';
|
||||
import {
|
||||
KubeServicesForm,
|
||||
kubeServicesValidation,
|
||||
} from '@/react/kubernetes/applications/CreateView/application-services/KubeServicesForm';
|
||||
import { KubeApplicationDeploymentTypeSelector } from '@/react/kubernetes/applications/CreateView/KubeApplicationDeploymentTypeSelector';
|
||||
import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||
|
@ -15,8 +19,10 @@ import {
|
|||
ApplicationDetailsWidget,
|
||||
} from '@/react/kubernetes/applications/DetailsView';
|
||||
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||
import { withFormValidation } from '@/react-tools/withFormValidation';
|
||||
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||
|
||||
export const componentsModule = angular
|
||||
export const ngModule = angular
|
||||
.module('portainer.kubernetes.react.components', [])
|
||||
.component(
|
||||
'ingressClassDatatable',
|
||||
|
@ -93,7 +99,7 @@ export const componentsModule = angular
|
|||
.component(
|
||||
'applicationSummaryWidget',
|
||||
r2a(
|
||||
withUIRouter(withReactQuery(withUserProvider(ApplicationSummaryWidget))),
|
||||
withUIRouter(withReactQuery(withCurrentUser(ApplicationSummaryWidget))),
|
||||
[]
|
||||
)
|
||||
)
|
||||
|
@ -103,4 +109,21 @@ export const componentsModule = angular
|
|||
withUIRouter(withReactQuery(withUserProvider(ApplicationDetailsWidget))),
|
||||
[]
|
||||
)
|
||||
).name;
|
||||
);
|
||||
|
||||
export const componentsModule = ngModule.name;
|
||||
|
||||
withFormValidation(
|
||||
ngModule,
|
||||
withUIRouter(withCurrentUser(withReactQuery(KubeServicesForm))),
|
||||
'kubeServicesForm',
|
||||
[
|
||||
'values',
|
||||
'onChange',
|
||||
'loadBalancerEnabled',
|
||||
'appName',
|
||||
'selector',
|
||||
'isEditMode',
|
||||
],
|
||||
kubeServicesValidation
|
||||
);
|
||||
|
|
|
@ -1144,13 +1144,15 @@
|
|||
</div>
|
||||
|
||||
<!-- kubernetes services options -->
|
||||
<kube-services-view
|
||||
form-values="ctrl.formValues"
|
||||
is-edit="ctrl.state.isEdit"
|
||||
namespaces="ctrl.allNamespaces"
|
||||
configurations="ctrl.configurations"
|
||||
loadbalancer-enabled="ctrl.publishViaLoadBalancerEnabled()"
|
||||
></kube-services-view>
|
||||
<kube-services-form
|
||||
on-change="(ctrl.onServicesChange)"
|
||||
values="ctrl.formValues.Services"
|
||||
load-balancer-enabled="ctrl.publishViaLoadBalancerEnabled()"
|
||||
app-name="ctrl.formValues.Name"
|
||||
selector="ctrl.formValues.Selector"
|
||||
validation-context="{nodePortServices: ctrl.state.nodePortServices}"
|
||||
is-edit-mode="ctrl.state.isEdit"
|
||||
></kube-services-form>
|
||||
<!-- kubernetes services options -->
|
||||
|
||||
<!-- summary -->
|
||||
|
@ -1192,19 +1194,22 @@
|
|||
</div>
|
||||
</div>
|
||||
<!-- kubernetes services options -->
|
||||
<kube-services-view
|
||||
namespaces="ctrl.allNamespaces"
|
||||
form-values="ctrl.formValues"
|
||||
is-edit="ctrl.state.isEdit"
|
||||
loadbalancer-enabled="ctrl.publishViaLoadBalancerEnabled()"
|
||||
></kube-services-view>
|
||||
<kube-services-form
|
||||
on-change="(ctrl.onServicesChange)"
|
||||
values="ctrl.formValues.Services"
|
||||
load-balancer-enabled="ctrl.publishViaLoadBalancerEnabled()"
|
||||
app-name="ctrl.formValues.Name"
|
||||
selector="ctrl.formValues.Selector"
|
||||
validation-context="{nodePortServices: ctrl.state.nodePortServices}"
|
||||
is-edit-mode="ctrl.state.isEdit"
|
||||
></kube-services-form>
|
||||
<!-- kubernetes services options -->
|
||||
</div>
|
||||
|
||||
<!-- kubernetes summary for external application -->
|
||||
<kubernetes-summary-view ng-if="ctrl.isExternalApplication()" form-values="ctrl.formValues" old-form-values="ctrl.savedFormValues"></kubernetes-summary-view>
|
||||
<!-- kubernetes summary for external application -->
|
||||
<div class="col-sm-12 form-section-title" ng-if="ctrl.state.appType !== ctrl.KubernetesDeploymentTypes.GIT" ng-hide="ctrl.stack.IsComposeFormat"> Actions </div>
|
||||
<div class="col-sm-12 form-section-title !mt-6" ng-if="ctrl.state.appType !== ctrl.KubernetesDeploymentTypes.GIT" ng-hide="ctrl.stack.IsComposeFormat"> Actions </div>
|
||||
<!-- #region ACTIONS -->
|
||||
<div class="form-group" ng-hide="ctrl.stack.IsComposeFormat">
|
||||
<div class="col-sm-12">
|
||||
|
|
|
@ -3,6 +3,7 @@ import _ from 'lodash-es';
|
|||
import filesizeParser from 'filesize-parser';
|
||||
import * as JsonPatch from 'fast-json-patch';
|
||||
import { RegistryTypes } from '@/portainer/models/registryTypes';
|
||||
import { getServices } from '@/react/kubernetes/networks/services/service';
|
||||
|
||||
import {
|
||||
KubernetesApplicationDataAccessPolicies,
|
||||
|
@ -133,6 +134,7 @@ class KubernetesCreateApplicationController {
|
|||
isEdit: this.$state.params.namespace && this.$state.params.name,
|
||||
persistedFoldersUseExistingVolumes: false,
|
||||
pullImageValidity: false,
|
||||
nodePortServices: [],
|
||||
};
|
||||
|
||||
this.isAdmin = this.Authentication.isAdmin();
|
||||
|
@ -156,6 +158,7 @@ class KubernetesCreateApplicationController {
|
|||
this.onChangeDeploymentType = this.onChangeDeploymentType.bind(this);
|
||||
this.supportGlobalDeployment = this.supportGlobalDeployment.bind(this);
|
||||
this.onChangePlacementType = this.onChangePlacementType.bind(this);
|
||||
this.onServicesChange = this.onServicesChange.bind(this);
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
|
@ -453,6 +456,12 @@ class KubernetesCreateApplicationController {
|
|||
/* #endregion */
|
||||
|
||||
/* #region PUBLISHED PORTS UI MANAGEMENT */
|
||||
onServicesChange(services) {
|
||||
return this.$async(async () => {
|
||||
this.formValues.Services = services;
|
||||
});
|
||||
}
|
||||
|
||||
onServicePublishChange() {
|
||||
// enable publishing with no previous ports exposed
|
||||
if (this.formValues.IsPublishingService && !this.formValues.PublishedPorts.length) {
|
||||
|
@ -1295,6 +1304,14 @@ class KubernetesCreateApplicationController {
|
|||
} finally {
|
||||
this.state.viewReady = true;
|
||||
}
|
||||
// get all nodeport services in the cluster, to validate unique nodeports in the form
|
||||
// this is below the try catch, to not block the page rendering
|
||||
const allSettledServices = await Promise.allSettled(this.resourcePools.map((namespace) => getServices(this.endpoint.Id, namespace.Namespace.Name)));
|
||||
const allServices = allSettledServices
|
||||
.filter((settledService) => settledService.status === 'fulfilled' && settledService.value)
|
||||
.map((fulfilledService) => fulfilledService.value)
|
||||
.flat();
|
||||
this.state.nodePortServices = allServices.filter((service) => service.Type === 'NodePort');
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ interface FormFieldProps<TValue> {
|
|||
onChange(values: TValue): void;
|
||||
values: TValue;
|
||||
errors?: FormikErrors<TValue> | ArrayError<TValue>;
|
||||
validationContext?: object; // optional context to pass to yup validation, for example, external data
|
||||
}
|
||||
|
||||
type WithFormFieldProps<TProps, TValue> = TProps & FormFieldProps<TValue>;
|
||||
|
@ -37,7 +38,13 @@ export function withFormValidation<TProps, TValue, TData = never>(
|
|||
ngModule
|
||||
.component(
|
||||
reactComponentName,
|
||||
r2a(Component, ['errors', 'onChange', 'values', ...propNames])
|
||||
r2a(Component, [
|
||||
'errors',
|
||||
'onChange',
|
||||
'values',
|
||||
'validationContext',
|
||||
...propNames,
|
||||
])
|
||||
)
|
||||
.component(
|
||||
componentName,
|
||||
|
@ -68,7 +75,12 @@ export function createFormValidationComponent<TFormModel, TData = never>(
|
|||
</ng-form>`,
|
||||
controller: createFormValidatorController(schemaBuilder),
|
||||
bindings: Object.fromEntries(
|
||||
[...propsWithErrors, 'validationData', 'onChange'].map((p) => [p, '<'])
|
||||
[
|
||||
...propsWithErrors,
|
||||
'validationData',
|
||||
'onChange',
|
||||
'validationContext',
|
||||
].map((p) => [p, '<'])
|
||||
),
|
||||
};
|
||||
}
|
||||
|
@ -87,6 +99,8 @@ export function createFormValidatorController<TFormModel, TData = never>(
|
|||
|
||||
validationData?: TData;
|
||||
|
||||
validationContext?: object;
|
||||
|
||||
onChange?: (value: TFormModel) => void;
|
||||
|
||||
/* @ngInject */
|
||||
|
@ -100,17 +114,18 @@ export function createFormValidatorController<TFormModel, TData = never>(
|
|||
async handleChange(newValues: TFormModel) {
|
||||
return this.$async(async () => {
|
||||
this.onChange?.(newValues);
|
||||
await this.runValidation(newValues);
|
||||
await this.runValidation(newValues, this.validationContext);
|
||||
});
|
||||
}
|
||||
|
||||
async runValidation(value: TFormModel) {
|
||||
async runValidation(value: TFormModel, validationContext?: object) {
|
||||
return this.$async(async () => {
|
||||
this.form?.$setValidity('form', true, this.form);
|
||||
|
||||
this.errors = await validateForm<TFormModel>(
|
||||
() => schemaBuilder(this.validationData),
|
||||
value
|
||||
value,
|
||||
validationContext
|
||||
);
|
||||
|
||||
if (this.errors && Object.keys(this.errors).length > 0) {
|
||||
|
@ -121,7 +136,10 @@ export function createFormValidatorController<TFormModel, TData = never>(
|
|||
|
||||
async $onChanges(changes: { values?: { currentValue: TFormModel } }) {
|
||||
if (changes.values) {
|
||||
await this.runValidation(changes.values.currentValue);
|
||||
await this.runValidation(
|
||||
changes.values.currentValue,
|
||||
this.validationContext
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -4,6 +4,7 @@ import { UISref, UISrefProps } from '@uirouter/react';
|
|||
interface Props {
|
||||
title?: string;
|
||||
target?: AnchorHTMLAttributes<HTMLAnchorElement>['target'];
|
||||
rel?: AnchorHTMLAttributes<HTMLAnchorElement>['rel'];
|
||||
}
|
||||
|
||||
export function Link({
|
||||
|
@ -16,7 +17,7 @@ export function Link({
|
|||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<UISref className={className} {...props}>
|
||||
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
|
||||
<a title={title} target={props.target}>
|
||||
<a title={title} target={props.target} rel={props.rel}>
|
||||
{children}
|
||||
</a>
|
||||
</UISref>
|
||||
|
|
|
@ -18,6 +18,7 @@ interface Props<T> {
|
|||
size?: Size;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ButtonSelector<T extends string | number>({
|
||||
|
@ -27,9 +28,10 @@ export function ButtonSelector<T extends string | number>({
|
|||
options,
|
||||
disabled,
|
||||
readOnly,
|
||||
className,
|
||||
}: Props<T>) {
|
||||
return (
|
||||
<ButtonGroup size={size} className={styles.group}>
|
||||
<ButtonGroup size={size} className={clsx(styles.group, className)}>
|
||||
{options.map((option) => (
|
||||
<OptionItem
|
||||
key={option.value}
|
||||
|
|
|
@ -13,7 +13,7 @@ export function FormError({ children, className }: PropsWithChildren<Props>) {
|
|||
<p
|
||||
className={clsx(`text-muted small vertical-center help-block`, className)}
|
||||
>
|
||||
<Icon icon={AlertTriangle} className="icon-warning" />
|
||||
<Icon icon={AlertTriangle} className="icon-warning shrink-0" />
|
||||
<span className="text-warning">{children}</span>
|
||||
</p>
|
||||
);
|
||||
|
|
|
@ -1,18 +1,28 @@
|
|||
import { ComponentType, PropsWithChildren } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { useInputGroupContext } from './InputGroup';
|
||||
|
||||
type BaseProps<TProps> = {
|
||||
as?: ComponentType<TProps> | string;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
export function InputGroupAddon<TProps>({
|
||||
children,
|
||||
as = 'span',
|
||||
required,
|
||||
...props
|
||||
}: PropsWithChildren<{ as?: ComponentType<TProps> | string } & TProps>) {
|
||||
}: PropsWithChildren<BaseProps<TProps> & TProps>) {
|
||||
useInputGroupContext();
|
||||
const Component = as as 'span';
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<Component className="input-group-addon" {...props}>
|
||||
<Component
|
||||
className={clsx('input-group-addon', required && 'required')}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
|
|
|
@ -3,7 +3,8 @@ import { SchemaOf } from 'yup';
|
|||
|
||||
export async function validateForm<T>(
|
||||
schemaBuilder: () => SchemaOf<T>,
|
||||
formValues: T
|
||||
formValues: T,
|
||||
validationContext?: object
|
||||
) {
|
||||
const validationSchema = schemaBuilder();
|
||||
|
||||
|
@ -11,6 +12,8 @@ export async function validateForm<T>(
|
|||
await validationSchema.validate(formValues, {
|
||||
strict: true,
|
||||
abortEarly: false,
|
||||
// workaround to access all parents for nested fields. See clusterIpFormValidation for a use case.
|
||||
context: { formValues, validationContext },
|
||||
});
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
|
|
|
@ -0,0 +1,132 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
import { ChangeEvent } from 'react';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector';
|
||||
import { Button } from '@@/buttons';
|
||||
|
||||
import { isServicePortError, newPort } from './utils';
|
||||
import { ServicePort } from './types';
|
||||
import { ServicePortInput } from './ServicePortInput';
|
||||
import { ContainerPortInput } from './ContainerPortInput';
|
||||
|
||||
interface Props {
|
||||
values: ServicePort[];
|
||||
onChange: (servicePorts: ServicePort[]) => void;
|
||||
serviceName?: string;
|
||||
errors?: string | string[] | FormikErrors<ServicePort>[];
|
||||
}
|
||||
|
||||
export function ClusterIpForm({
|
||||
values: servicePorts,
|
||||
onChange,
|
||||
errors,
|
||||
serviceName,
|
||||
}: Props) {
|
||||
const newClusterIpPort = newPort(serviceName);
|
||||
return (
|
||||
<>
|
||||
<div className="control-label !mb-2 !pt-0 text-left">Published ports</div>
|
||||
<div className="mb-2 flex flex-col gap-4">
|
||||
{servicePorts.map((servicePort, index) => {
|
||||
const error = errors?.[index];
|
||||
const servicePortError = isServicePortError<ServicePort>(error)
|
||||
? error
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div key={index} className="flex flex-grow flex-wrap gap-2">
|
||||
<div className="flex w-1/3 min-w-min flex-col">
|
||||
<ContainerPortInput
|
||||
index={index}
|
||||
value={servicePort.targetPort}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const newServicePorts = [...servicePorts];
|
||||
const newValue =
|
||||
e.target.value === ''
|
||||
? undefined
|
||||
: Number(e.target.value);
|
||||
newServicePorts[index] = {
|
||||
...newServicePorts[index],
|
||||
targetPort: newValue,
|
||||
port: newValue,
|
||||
};
|
||||
onChange(newServicePorts);
|
||||
}}
|
||||
/>
|
||||
{servicePortError?.targetPort && (
|
||||
<FormError>{servicePortError.targetPort}</FormError>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex w-1/3 min-w-min flex-col">
|
||||
<ServicePortInput
|
||||
index={index}
|
||||
value={servicePort.port}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const newServicePorts = [...servicePorts];
|
||||
newServicePorts[index] = {
|
||||
...newServicePorts[index],
|
||||
port:
|
||||
e.target.value === ''
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
};
|
||||
onChange(newServicePorts);
|
||||
}}
|
||||
/>
|
||||
{servicePortError?.port && (
|
||||
<FormError>{servicePortError.port}</FormError>
|
||||
)}
|
||||
</div>
|
||||
<ButtonSelector
|
||||
className="h-[30px]"
|
||||
onChange={(value) => {
|
||||
const newServicePorts = [...servicePorts];
|
||||
newServicePorts[index] = {
|
||||
...newServicePorts[index],
|
||||
protocol: value,
|
||||
};
|
||||
onChange(newServicePorts);
|
||||
}}
|
||||
value={servicePort.protocol || 'TCP'}
|
||||
options={[{ value: 'TCP' }, { value: 'UDP' }]}
|
||||
/>
|
||||
<Button
|
||||
disabled={servicePorts.length === 1}
|
||||
size="small"
|
||||
className="!ml-0 h-[30px]"
|
||||
color="danger"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// remove the port at the index in an immutable way
|
||||
const newServicePorts = [
|
||||
...servicePorts.slice(0, index),
|
||||
...servicePorts.slice(index + 1),
|
||||
];
|
||||
onChange(newServicePorts);
|
||||
}}
|
||||
data-cy={`k8sAppCreate-rmPortButton_${index}`}
|
||||
icon={Trash2}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="flex">
|
||||
<Button
|
||||
icon={Plus}
|
||||
color="default"
|
||||
className="!ml-0"
|
||||
onClick={() => {
|
||||
const newServicesPorts = [...servicePorts, newClusterIpPort];
|
||||
onChange(newServicesPorts);
|
||||
}}
|
||||
>
|
||||
Publish a new port
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
import { ChangeEvent } from 'react';
|
||||
|
||||
import { InputGroup } from '@@/form-components/InputGroup';
|
||||
|
||||
type Props = {
|
||||
index: number;
|
||||
value?: number;
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
|
||||
export function ContainerPortInput({ index, value, onChange }: Props) {
|
||||
return (
|
||||
<InputGroup size="small">
|
||||
<InputGroup.Addon required>Container port</InputGroup.Addon>
|
||||
<InputGroup.Input
|
||||
type="number"
|
||||
className="form-control min-w-max"
|
||||
name={`container_port_${index}`}
|
||||
placeholder="80"
|
||||
min="1"
|
||||
max="65535"
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
required
|
||||
data-cy={`k8sAppCreate-containerPort_${index}`}
|
||||
/>
|
||||
</InputGroup>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,539 @@
|
|||
import { SchemaOf, array, boolean, mixed, number, object, string } from 'yup';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { SingleValue } from 'react-select';
|
||||
import { List, Plus, Trash2 } from 'lucide-react';
|
||||
import { FormikErrors } from 'formik';
|
||||
|
||||
import DataFlow from '@/assets/ico/dataflow-1.svg?c';
|
||||
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
|
||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { Select } from '@@/form-components/ReactSelect';
|
||||
import { Button } from '@@/buttons';
|
||||
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
|
||||
import { Icon } from '@@/Icon';
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
|
||||
import { ServiceFormValues, ServicePort, ServiceTypeValue } from './types';
|
||||
import { LoadBalancerForm } from './LoadBalancerForm';
|
||||
import { ClusterIpForm } from './ClusterIpForm';
|
||||
import { NodePortForm } from './NodePortForm';
|
||||
import { newPort } from './utils';
|
||||
|
||||
type ServiceTypeLabel = 'ClusterIP' | 'NodePort' | 'LoadBalancer';
|
||||
type ServiceTypeOption = { value: ServiceTypeValue; label: ServiceTypeLabel };
|
||||
const serviceTypeOptions: ServiceTypeOption[] = [
|
||||
{
|
||||
value: KubernetesApplicationPublishingTypes.CLUSTER_IP,
|
||||
label: 'ClusterIP',
|
||||
},
|
||||
{ value: KubernetesApplicationPublishingTypes.NODE_PORT, label: 'NodePort' },
|
||||
{
|
||||
value: KubernetesApplicationPublishingTypes.LOAD_BALANCER,
|
||||
label: 'LoadBalancer',
|
||||
},
|
||||
];
|
||||
|
||||
const serviceFormDefaultValues: ServiceFormValues = {
|
||||
Headless: false,
|
||||
Namespace: '',
|
||||
Name: '',
|
||||
StackName: '',
|
||||
Ports: [],
|
||||
Type: 1, // clusterip type as default
|
||||
ClusterIP: '',
|
||||
ApplicationName: '',
|
||||
ApplicationOwner: '',
|
||||
Note: '',
|
||||
Ingress: false,
|
||||
Selector: {},
|
||||
};
|
||||
|
||||
interface Props {
|
||||
values: ServiceFormValues[];
|
||||
onChange: (loadBalancerPorts: ServiceFormValues[]) => void;
|
||||
errors?: FormikErrors<ServiceFormValues[]>;
|
||||
loadBalancerEnabled: boolean;
|
||||
appName: string;
|
||||
selector: Record<string, string>;
|
||||
isEditMode: boolean;
|
||||
}
|
||||
|
||||
export function KubeServicesForm({
|
||||
values: services,
|
||||
onChange,
|
||||
errors,
|
||||
loadBalancerEnabled,
|
||||
appName,
|
||||
selector,
|
||||
isEditMode,
|
||||
}: Props) {
|
||||
const { isAdmin } = useCurrentUser();
|
||||
const [selectedServiceTypeOption, setSelectedServiceTypeOption] = useState<
|
||||
SingleValue<ServiceTypeOption>
|
||||
>(serviceTypeOptions[0]); // ClusterIP is the default value
|
||||
|
||||
// when the appName changes, update the names for each service
|
||||
// and the serviceNames for each service port
|
||||
useEffect(() => {
|
||||
if (!isEditMode) {
|
||||
const newServiceNames = getUniqNames(appName, services);
|
||||
const newServices = services.map((service, index) => {
|
||||
const newServiceName = newServiceNames[index];
|
||||
const newServicePorts = service.Ports.map((port) => ({
|
||||
...port,
|
||||
serviceName: newServiceName,
|
||||
}));
|
||||
return { ...service, Name: newServiceName, Ports: newServicePorts };
|
||||
});
|
||||
onChange(newServices);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appName]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="col-sm-12 form-section-title">
|
||||
Publishing the application
|
||||
</div>
|
||||
<div className="col-sm-12 !p-0">
|
||||
<div className="row">
|
||||
<TextTip color="blue">
|
||||
Publish your application by creating a ClusterIP service for it,
|
||||
which you may then expose via{' '}
|
||||
<Link
|
||||
target="_blank"
|
||||
to="kubernetes.ingresses"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
an ingress
|
||||
</Link>
|
||||
.
|
||||
</TextTip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full">
|
||||
<Select<ServiceTypeOption>
|
||||
options={serviceTypeOptions}
|
||||
value={selectedServiceTypeOption}
|
||||
className="w-1/4"
|
||||
data-cy="k8sAppCreate-publishingModeDropdown"
|
||||
onChange={(val) => {
|
||||
setSelectedServiceTypeOption(val);
|
||||
}}
|
||||
/>
|
||||
<TooltipWithChildren
|
||||
position="top"
|
||||
className="portainer-tooltip"
|
||||
message="Different service types expose the application in alternate ways.
|
||||
ClusterIP exposes it within the cluster (for internal access only).
|
||||
NodePort exposes it (on a high port) across all nodes.
|
||||
LoadBalancer exposes it via an external load balancer."
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
color="default"
|
||||
icon={Plus}
|
||||
size="medium"
|
||||
disabled={
|
||||
selectedServiceTypeOption?.value ===
|
||||
KubernetesApplicationPublishingTypes.LOAD_BALANCER &&
|
||||
!loadBalancerEnabled
|
||||
}
|
||||
onClick={() => {
|
||||
// create a new service form value and add it to the list of services
|
||||
const newService = structuredClone(serviceFormDefaultValues);
|
||||
newService.Name = generateUniqueName(
|
||||
appName,
|
||||
services.length + 1,
|
||||
services
|
||||
);
|
||||
newService.Type =
|
||||
selectedServiceTypeOption?.value ||
|
||||
KubernetesApplicationPublishingTypes.CLUSTER_IP;
|
||||
const newServicePort = newPort(newService.Name);
|
||||
newService.Ports = [newServicePort];
|
||||
newService.Selector = selector;
|
||||
onChange([...services, newService]);
|
||||
}}
|
||||
data-cy="k8sAppCreate-createServiceButton"
|
||||
>
|
||||
Create service
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipWithChildren>
|
||||
</div>
|
||||
<div className="flex w-full flex-col">
|
||||
{selectedServiceTypeOption?.value ===
|
||||
KubernetesApplicationPublishingTypes.LOAD_BALANCER &&
|
||||
isAdmin &&
|
||||
!loadBalancerEnabled && (
|
||||
<FormError className="mt-2">
|
||||
No Load balancer is available in this cluster, click{' '}
|
||||
<Link
|
||||
to="kubernetes.cluster.setup"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
here
|
||||
</Link>{' '}
|
||||
to configure load balancer.
|
||||
</FormError>
|
||||
)}
|
||||
{selectedServiceTypeOption?.value ===
|
||||
KubernetesApplicationPublishingTypes.LOAD_BALANCER &&
|
||||
!isAdmin &&
|
||||
!loadBalancerEnabled && (
|
||||
<FormError className="mt-2">
|
||||
No Load balancer is available in this cluster, contact your
|
||||
administrator.
|
||||
</FormError>
|
||||
)}
|
||||
{services.map((service, index) => (
|
||||
<div key={index} className="border-bottom py-6">
|
||||
{service.Type ===
|
||||
KubernetesApplicationPublishingTypes.CLUSTER_IP && (
|
||||
<>
|
||||
<div className="text-muted vertical-center w-full">
|
||||
<Icon icon={List} />
|
||||
ClusterIP
|
||||
</div>
|
||||
<ClusterIpForm
|
||||
serviceName={service.Name}
|
||||
values={service.Ports}
|
||||
errors={errors?.[index]?.Ports}
|
||||
onChange={(servicePorts: ServicePort[]) => {
|
||||
const newServices = [...services];
|
||||
newServices[index].Ports = servicePorts;
|
||||
onChange(newServices);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{service.Type ===
|
||||
KubernetesApplicationPublishingTypes.NODE_PORT && (
|
||||
<>
|
||||
<div className="text-muted vertical-center w-full">
|
||||
<Icon icon={List} />
|
||||
NodePort
|
||||
</div>
|
||||
<NodePortForm
|
||||
serviceName={service.Name}
|
||||
values={service.Ports}
|
||||
errors={errors?.[index]?.Ports}
|
||||
onChange={(servicePorts: ServicePort[]) => {
|
||||
const newServices = [...services];
|
||||
newServices[index].Ports = servicePorts;
|
||||
onChange(newServices);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{service.Type ===
|
||||
KubernetesApplicationPublishingTypes.LOAD_BALANCER && (
|
||||
<>
|
||||
<div className="text-muted vertical-center w-full">
|
||||
<Icon icon={DataFlow} />
|
||||
LoadBalancer
|
||||
</div>
|
||||
<LoadBalancerForm
|
||||
serviceName={service.Name}
|
||||
values={service.Ports}
|
||||
errors={errors?.[index]?.Ports}
|
||||
onChange={(servicePorts: ServicePort[]) => {
|
||||
const newServices = [...services];
|
||||
newServices[index].Ports = servicePorts;
|
||||
onChange(newServices);
|
||||
}}
|
||||
loadBalancerEnabled={loadBalancerEnabled}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
icon={Trash2}
|
||||
color="danger"
|
||||
className="!ml-0 mt-2"
|
||||
onClick={() => {
|
||||
// remove the service at index in an immutable way
|
||||
const newServices = [
|
||||
...services.slice(0, index),
|
||||
...services.slice(index + 1),
|
||||
];
|
||||
onChange(newServices);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function generateIndexedName(appName: string, index: number) {
|
||||
return index === 0 ? appName : `${appName}-${index}`;
|
||||
}
|
||||
|
||||
function isNameUnique(name: string, services: ServiceFormValues[]) {
|
||||
return services.findIndex((service) => service.Name === name) === -1;
|
||||
}
|
||||
|
||||
function generateUniqueName(
|
||||
appName: string,
|
||||
index: number,
|
||||
services: ServiceFormValues[]
|
||||
) {
|
||||
let initialIndex = index;
|
||||
let uniqueName = appName;
|
||||
|
||||
while (!isNameUnique(uniqueName, services)) {
|
||||
uniqueName = generateIndexedName(appName, initialIndex);
|
||||
initialIndex++;
|
||||
}
|
||||
|
||||
return uniqueName;
|
||||
}
|
||||
|
||||
function getUniqNames(appName: string, services: ServiceFormValues[]) {
|
||||
const sortedServices = [...services].sort((a, b) =>
|
||||
a.Name && b.Name ? a.Name.localeCompare(b.Name) : 0
|
||||
);
|
||||
|
||||
const uniqueNames = sortedServices.reduce(
|
||||
(acc: string[]) => {
|
||||
const newIndex =
|
||||
acc.findIndex((existingName) => existingName === appName) + 1;
|
||||
const uniqName = acc.includes(appName)
|
||||
? generateUniqueName(appName, newIndex, services)
|
||||
: appName;
|
||||
return [...acc, uniqName];
|
||||
},
|
||||
[appName]
|
||||
);
|
||||
|
||||
return uniqueNames;
|
||||
}
|
||||
|
||||
// values returned from the angular parent component (pascal case instead of camel case keys),
|
||||
// these should match the form values, but don't. Future tech debt work to update this would be nice
|
||||
// to make the converted values and formValues objects to be the same
|
||||
interface NodePortValues {
|
||||
Port: number;
|
||||
TargetPort: number;
|
||||
NodePort: number;
|
||||
Name?: string;
|
||||
Protocol?: string;
|
||||
Ingress?: string;
|
||||
}
|
||||
|
||||
type ServiceValues = {
|
||||
Type: number;
|
||||
Name: string;
|
||||
Ports: NodePortValues[];
|
||||
};
|
||||
|
||||
type NodePortValidationContext = {
|
||||
nodePortServices: ServiceValues[];
|
||||
formServices: ServiceFormValues[];
|
||||
};
|
||||
|
||||
export function kubeServicesValidation(): SchemaOf<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.',
|
||||
// eslint-disable-next-line func-names
|
||||
function (servicePort, context) {
|
||||
// test for duplicate service ports within this service.
|
||||
// yup gives access to context.parent which gives one ServicePort object.
|
||||
// yup also gives access to all form values through this.options.context.
|
||||
// Unfortunately, it doesn't give direct access to all Ports within the current service.
|
||||
// To find all ports in the service for validation, I'm filtering the services by the service name,
|
||||
// that's stored in the ServicePort object, then getting all Ports in the service.
|
||||
if (servicePort === undefined) {
|
||||
return true;
|
||||
}
|
||||
const matchingService = getServiceForPort(
|
||||
context.parent as ServicePort,
|
||||
this.options.context?.formValues as ServiceFormValues[]
|
||||
);
|
||||
if (matchingService === undefined) {
|
||||
return true;
|
||||
}
|
||||
const servicePorts = matchingService.Ports;
|
||||
const duplicateServicePortCount = servicePorts.filter(
|
||||
(port) => port.port === servicePort
|
||||
).length;
|
||||
return duplicateServicePortCount <= 1;
|
||||
}
|
||||
),
|
||||
targetPort: number()
|
||||
.required('Container port number is required.')
|
||||
.min(1, 'Container port number must be inside the range 1-65535.')
|
||||
.max(
|
||||
65535,
|
||||
'Container port number must be inside the range 1-65535.'
|
||||
),
|
||||
name: string(),
|
||||
serviceName: string().required(),
|
||||
protocol: string(),
|
||||
nodePort: number()
|
||||
.test(
|
||||
'node-port-is-unique-in-service',
|
||||
'Node port is already used in this service.',
|
||||
// eslint-disable-next-line func-names
|
||||
function (nodePort, context) {
|
||||
if (nodePort === undefined) {
|
||||
return true;
|
||||
}
|
||||
const matchingService = getServiceForPort(
|
||||
context.parent as ServicePort,
|
||||
this.options.context?.formValues as ServiceFormValues[]
|
||||
);
|
||||
if (
|
||||
matchingService === undefined ||
|
||||
matchingService.Type !==
|
||||
KubernetesApplicationPublishingTypes.NODE_PORT // ignore validation unless the service is of type nodeport
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const servicePorts = matchingService.Ports;
|
||||
const duplicateNodePortCount = servicePorts.filter(
|
||||
(port) => port.nodePort === nodePort
|
||||
).length;
|
||||
return duplicateNodePortCount <= 1;
|
||||
}
|
||||
)
|
||||
.test(
|
||||
'node-port-is-unique-in-cluster',
|
||||
'Node port is already used.',
|
||||
// eslint-disable-next-line func-names
|
||||
function (nodePort, context) {
|
||||
if (nodePort === undefined) {
|
||||
return true;
|
||||
}
|
||||
const { nodePortServices } = this.options.context
|
||||
?.validationContext as NodePortValidationContext;
|
||||
const formServices = this.options.context
|
||||
?.formValues as ServiceFormValues[];
|
||||
const matchingService = getServiceForPort(
|
||||
context.parent as ServicePort,
|
||||
formServices
|
||||
);
|
||||
|
||||
if (
|
||||
matchingService === undefined ||
|
||||
matchingService.Type !==
|
||||
KubernetesApplicationPublishingTypes.NODE_PORT // ignore validation unless the service is of type nodeport
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// create a list of all the node ports (number[]) in the cluster, from services that aren't in the application form
|
||||
const formServiceNames = formServices.map(
|
||||
(formService) => formService.Name
|
||||
);
|
||||
const clusterNodePortsWithoutFormServices = nodePortServices
|
||||
.filter(
|
||||
(npService) => !formServiceNames.includes(npService.Name)
|
||||
)
|
||||
.flatMap((npService) => npService.Ports)
|
||||
.map((npServicePorts) => npServicePorts.NodePort);
|
||||
// node ports in the current form, excluding the current service
|
||||
const formNodePortsWithoutCurrentService = formServices
|
||||
.filter(
|
||||
(formService) =>
|
||||
formService.Type ===
|
||||
KubernetesApplicationPublishingTypes.NODE_PORT &&
|
||||
formService.Name !== matchingService.Name
|
||||
)
|
||||
.flatMap((formService) => formService.Ports)
|
||||
.map((formServicePorts) => formServicePorts.nodePort);
|
||||
return (
|
||||
!clusterNodePortsWithoutFormServices.includes(nodePort) && // node port is not in the cluster services that aren't in the application form
|
||||
!formNodePortsWithoutCurrentService.includes(nodePort) // node port is not in the current form, excluding the current service
|
||||
);
|
||||
}
|
||||
)
|
||||
.test(
|
||||
'node-port-minimum',
|
||||
'Nodeport number must be inside the range 30000-32767 or blank for system allocated.',
|
||||
// eslint-disable-next-line func-names
|
||||
function (nodePort, context) {
|
||||
if (nodePort === undefined) {
|
||||
return true;
|
||||
}
|
||||
const matchingService = getServiceForPort(
|
||||
context.parent as ServicePort,
|
||||
this.options.context?.formValues as ServiceFormValues[]
|
||||
);
|
||||
if (
|
||||
!matchingService ||
|
||||
matchingService.Type !==
|
||||
KubernetesApplicationPublishingTypes.NODE_PORT
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return nodePort >= 30000;
|
||||
}
|
||||
)
|
||||
.test(
|
||||
'node-port-maximum',
|
||||
'Nodeport number must be inside the range 30000-32767 or blank for system allocated.',
|
||||
// eslint-disable-next-line func-names
|
||||
function (nodePort, context) {
|
||||
if (nodePort === undefined) {
|
||||
return true;
|
||||
}
|
||||
const matchingService = getServiceForPort(
|
||||
context.parent as ServicePort,
|
||||
this.options.context?.formValues as ServiceFormValues[]
|
||||
);
|
||||
if (
|
||||
!matchingService ||
|
||||
matchingService.Type !==
|
||||
KubernetesApplicationPublishingTypes.NODE_PORT
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return nodePort <= 32767;
|
||||
}
|
||||
),
|
||||
ingress: object(),
|
||||
})
|
||||
),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function getServiceForPort(
|
||||
servicePort: ServicePort,
|
||||
services: ServiceFormValues[]
|
||||
) {
|
||||
return services.find((service) => service.Name === servicePort.serviceName);
|
||||
}
|
|
@ -0,0 +1,176 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
import { ChangeEvent } from 'react';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
import { InputGroup } from '@@/form-components/InputGroup';
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector';
|
||||
import { Button } from '@@/buttons';
|
||||
|
||||
import { isServicePortError, newPort } from './utils';
|
||||
import { ContainerPortInput } from './ContainerPortInput';
|
||||
import { ServicePortInput } from './ServicePortInput';
|
||||
import { ServicePort } from './types';
|
||||
|
||||
interface Props {
|
||||
values: ServicePort[];
|
||||
onChange: (loadBalancerPorts: ServicePort[]) => void;
|
||||
loadBalancerEnabled: boolean;
|
||||
serviceName?: string;
|
||||
errors?: string | string[] | FormikErrors<ServicePort>[];
|
||||
}
|
||||
|
||||
export function LoadBalancerForm({
|
||||
values: loadBalancerPorts,
|
||||
onChange,
|
||||
loadBalancerEnabled,
|
||||
serviceName,
|
||||
errors,
|
||||
}: Props) {
|
||||
const newLoadBalancerPort = newPort(serviceName);
|
||||
return (
|
||||
<>
|
||||
{loadBalancerEnabled && (
|
||||
<>
|
||||
<div className="control-label !mb-2 !pt-0 text-left">
|
||||
Published ports
|
||||
</div>
|
||||
<div className="mb-2 flex flex-col gap-4">
|
||||
{loadBalancerPorts.map((lbPort, index) => {
|
||||
const error = errors?.[index];
|
||||
const servicePortError = isServicePortError<ServicePort>(error)
|
||||
? error
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div key={index} className="flex flex-grow flex-wrap gap-2">
|
||||
<div className="flex w-1/4 min-w-min flex-col">
|
||||
<ContainerPortInput
|
||||
index={index}
|
||||
value={lbPort.targetPort}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const newServicePorts = [...loadBalancerPorts];
|
||||
const newValue =
|
||||
e.target.value === ''
|
||||
? undefined
|
||||
: Number(e.target.value);
|
||||
newServicePorts[index] = {
|
||||
...newServicePorts[index],
|
||||
targetPort: newValue,
|
||||
port: newValue,
|
||||
};
|
||||
onChange(newServicePorts);
|
||||
}}
|
||||
/>
|
||||
{servicePortError?.targetPort && (
|
||||
<FormError>{servicePortError.targetPort}</FormError>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-1/4 min-w-min flex-col">
|
||||
<ServicePortInput
|
||||
index={index}
|
||||
value={lbPort.port}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const newServicePorts = [...loadBalancerPorts];
|
||||
newServicePorts[index] = {
|
||||
...newServicePorts[index],
|
||||
port:
|
||||
e.target.value === ''
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
};
|
||||
onChange(newServicePorts);
|
||||
}}
|
||||
/>
|
||||
{servicePortError?.port && (
|
||||
<FormError>{servicePortError.port}</FormError>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-1/4 min-w-min 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_${index}`}
|
||||
placeholder="80"
|
||||
min="1"
|
||||
max="65535"
|
||||
value={lbPort.port || ''}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const newServicePorts = [...loadBalancerPorts];
|
||||
newServicePorts[index] = {
|
||||
...newServicePorts[index],
|
||||
port:
|
||||
e.target.value === ''
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
};
|
||||
onChange(newServicePorts);
|
||||
}}
|
||||
required
|
||||
data-cy={`k8sAppCreate-loadbalancerPort_${index}`}
|
||||
/>
|
||||
</InputGroup>
|
||||
{servicePortError?.nodePort && (
|
||||
<FormError>{servicePortError.nodePort}</FormError>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ButtonSelector
|
||||
className="h-[30px]"
|
||||
onChange={(value) => {
|
||||
const newServicePorts = [...loadBalancerPorts];
|
||||
newServicePorts[index] = {
|
||||
...newServicePorts[index],
|
||||
protocol: value,
|
||||
};
|
||||
onChange(newServicePorts);
|
||||
}}
|
||||
value={lbPort.protocol || 'TCP'}
|
||||
options={[{ value: 'TCP' }, { value: 'UDP' }]}
|
||||
/>
|
||||
<Button
|
||||
disabled={loadBalancerPorts.length === 1}
|
||||
size="small"
|
||||
className="!ml-0 h-[30px]"
|
||||
color="danger"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// remove the port at the index in an immutable way
|
||||
const newServicePorts = [
|
||||
...loadBalancerPorts.slice(0, index),
|
||||
...loadBalancerPorts.slice(index + 1),
|
||||
];
|
||||
onChange(newServicePorts);
|
||||
}}
|
||||
data-cy={`k8sAppCreate-rmPortButton_${index}`}
|
||||
icon={Trash2}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="flex">
|
||||
<Button
|
||||
icon={Plus}
|
||||
color="default"
|
||||
className="!ml-0"
|
||||
onClick={() => {
|
||||
const newServicesPorts = [
|
||||
...loadBalancerPorts,
|
||||
newLoadBalancerPort,
|
||||
];
|
||||
onChange(newServicesPorts);
|
||||
}}
|
||||
>
|
||||
Publish a new port
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,162 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
import { ChangeEvent } from 'react';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
import { InputGroup } from '@@/form-components/InputGroup';
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector';
|
||||
import { Button } from '@@/buttons';
|
||||
|
||||
import { isServicePortError, newPort } from './utils';
|
||||
import { ContainerPortInput } from './ContainerPortInput';
|
||||
import { ServicePortInput } from './ServicePortInput';
|
||||
import { ServicePort } from './types';
|
||||
|
||||
interface Props {
|
||||
values: ServicePort[];
|
||||
onChange: (nodePorts: ServicePort[]) => void;
|
||||
serviceName?: string;
|
||||
errors?: string | string[] | FormikErrors<ServicePort>[];
|
||||
}
|
||||
|
||||
export function NodePortForm({
|
||||
values: nodePorts,
|
||||
onChange,
|
||||
errors,
|
||||
serviceName,
|
||||
}: Props) {
|
||||
const newNodePortPort = newPort(serviceName);
|
||||
return (
|
||||
<>
|
||||
<div className="control-label !mb-2 !pt-0 text-left">Published ports</div>
|
||||
<div className="mb-2 flex flex-col gap-4">
|
||||
{nodePorts.map((nodePort, index) => {
|
||||
const error = errors?.[index];
|
||||
const servicePortError = isServicePortError<ServicePort>(error)
|
||||
? error
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div key={index} className="flex flex-grow flex-wrap gap-2">
|
||||
<div className="flex w-1/4 min-w-min flex-col">
|
||||
<ContainerPortInput
|
||||
index={index}
|
||||
value={nodePort.targetPort}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const newServicePorts = [...nodePorts];
|
||||
const newValue =
|
||||
e.target.value === ''
|
||||
? undefined
|
||||
: Number(e.target.value);
|
||||
newServicePorts[index] = {
|
||||
...newServicePorts[index],
|
||||
targetPort: newValue,
|
||||
port: newValue,
|
||||
};
|
||||
onChange(newServicePorts);
|
||||
}}
|
||||
/>
|
||||
{servicePortError?.targetPort && (
|
||||
<FormError>{servicePortError.targetPort}</FormError>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-1/4 min-w-min flex-col">
|
||||
<ServicePortInput
|
||||
index={index}
|
||||
value={nodePort.port}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const newServicePorts = [...nodePorts];
|
||||
newServicePorts[index] = {
|
||||
...newServicePorts[index],
|
||||
port:
|
||||
e.target.value === ''
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
};
|
||||
onChange(newServicePorts);
|
||||
}}
|
||||
/>
|
||||
{servicePortError?.port && (
|
||||
<FormError>{servicePortError.port}</FormError>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-1/4 min-w-min flex-col">
|
||||
<InputGroup size="small">
|
||||
<InputGroup.Addon>Nodeport</InputGroup.Addon>
|
||||
<InputGroup.Input
|
||||
type="number"
|
||||
className="form-control min-w-max"
|
||||
name={`node_port_${index}`}
|
||||
placeholder="30080"
|
||||
min="30000"
|
||||
max="32767"
|
||||
value={nodePort.nodePort ?? ''}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const newServicePorts = [...nodePorts];
|
||||
newServicePorts[index] = {
|
||||
...newServicePorts[index],
|
||||
nodePort:
|
||||
e.target.value === ''
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
};
|
||||
onChange(newServicePorts);
|
||||
}}
|
||||
data-cy={`k8sAppCreate-nodePort_${index}`}
|
||||
/>
|
||||
</InputGroup>
|
||||
{servicePortError?.nodePort && (
|
||||
<FormError>{servicePortError.nodePort}</FormError>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ButtonSelector
|
||||
className="h-[30px]"
|
||||
onChange={(value) => {
|
||||
const newServicePorts = [...nodePorts];
|
||||
newServicePorts[index] = {
|
||||
...newServicePorts[index],
|
||||
protocol: value,
|
||||
};
|
||||
onChange(newServicePorts);
|
||||
}}
|
||||
value={nodePort.protocol || 'TCP'}
|
||||
options={[{ value: 'TCP' }, { value: 'UDP' }]}
|
||||
/>
|
||||
<Button
|
||||
disabled={nodePorts.length === 1}
|
||||
size="small"
|
||||
className="!ml-0 h-[30px]"
|
||||
color="danger"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// remove the port at the index in an immutable way
|
||||
const newServicePorts = [
|
||||
...nodePorts.slice(0, index),
|
||||
...nodePorts.slice(index + 1),
|
||||
];
|
||||
onChange(newServicePorts);
|
||||
}}
|
||||
data-cy={`k8sAppCreate-rmPortButton_${index}`}
|
||||
icon={Trash2}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="flex">
|
||||
<Button
|
||||
icon={Plus}
|
||||
color="default"
|
||||
className="!ml-0"
|
||||
onClick={() => {
|
||||
const newServicesPorts = [...nodePorts, newNodePortPort];
|
||||
onChange(newServicesPorts);
|
||||
}}
|
||||
>
|
||||
Publish a new port
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
import { ChangeEvent } from 'react';
|
||||
|
||||
import { InputGroup } from '@@/form-components/InputGroup';
|
||||
|
||||
type Props = {
|
||||
index: number;
|
||||
value?: number;
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
|
||||
export function ServicePortInput({ index, value, onChange }: Props) {
|
||||
return (
|
||||
<InputGroup size="small">
|
||||
<InputGroup.Addon required>Service port</InputGroup.Addon>
|
||||
<InputGroup.Input
|
||||
type="number"
|
||||
className="form-control min-w-max"
|
||||
name={`service_port_${index}`}
|
||||
placeholder="80"
|
||||
min="1"
|
||||
max="65535"
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
required
|
||||
data-cy={`k8sAppCreate-servicePort_${index}`}
|
||||
/>
|
||||
</InputGroup>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
export interface ServicePort {
|
||||
port?: number;
|
||||
targetPort?: number;
|
||||
nodePort?: number;
|
||||
serviceName?: string;
|
||||
name?: string;
|
||||
protocol?: string;
|
||||
ingress?: object;
|
||||
}
|
||||
|
||||
export type ServiceTypeValue = 1 | 2 | 3;
|
||||
|
||||
export type ServiceFormValues = {
|
||||
Headless: boolean;
|
||||
Ports: ServicePort[];
|
||||
Type: ServiceTypeValue;
|
||||
Ingress: boolean;
|
||||
ClusterIP?: string;
|
||||
ApplicationName?: string;
|
||||
ApplicationOwner?: string;
|
||||
Note?: string;
|
||||
Name?: string;
|
||||
StackName?: string;
|
||||
Selector?: Record<string, string>;
|
||||
Namespace?: string;
|
||||
};
|
|
@ -0,0 +1,18 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
|
||||
export function isServicePortError<T>(
|
||||
error: string | FormikErrors<T> | undefined
|
||||
): error is FormikErrors<T> {
|
||||
return error !== undefined && typeof error !== 'string';
|
||||
}
|
||||
|
||||
export function newPort(serviceName?: string) {
|
||||
return {
|
||||
port: undefined,
|
||||
targetPort: undefined,
|
||||
name: '',
|
||||
protocol: 'TCP',
|
||||
nodePort: undefined,
|
||||
serviceName,
|
||||
};
|
||||
}
|
Loading…
Reference in New Issue