mirror of https://github.com/portainer/portainer
feat(app): rearrange app form services [EE-5566] (#9056)
parent
d7fc2046d7
commit
2d69e93efa
|
@ -28,7 +28,6 @@ export const KubernetesApplicationPublishingTypes = Object.freeze({
|
||||||
CLUSTER_IP: 1,
|
CLUSTER_IP: 1,
|
||||||
NODE_PORT: 2,
|
NODE_PORT: 2,
|
||||||
LOAD_BALANCER: 3,
|
LOAD_BALANCER: 3,
|
||||||
INGRESS: 4,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const KubernetesApplicationPlacementTypes = Object.freeze({
|
export const KubernetesApplicationPlacementTypes = Object.freeze({
|
||||||
|
|
|
@ -117,13 +117,6 @@ withFormValidation(
|
||||||
ngModule,
|
ngModule,
|
||||||
withUIRouter(withCurrentUser(withReactQuery(KubeServicesForm))),
|
withUIRouter(withCurrentUser(withReactQuery(KubeServicesForm))),
|
||||||
'kubeServicesForm',
|
'kubeServicesForm',
|
||||||
[
|
['values', 'onChange', 'appName', 'selector', 'isEditMode'],
|
||||||
'values',
|
|
||||||
'onChange',
|
|
||||||
'loadBalancerEnabled',
|
|
||||||
'appName',
|
|
||||||
'selector',
|
|
||||||
'isEditMode',
|
|
||||||
],
|
|
||||||
kubeServicesValidation
|
kubeServicesValidation
|
||||||
);
|
);
|
||||||
|
|
|
@ -1339,7 +1339,6 @@
|
||||||
<kube-services-form
|
<kube-services-form
|
||||||
on-change="(ctrl.onServicesChange)"
|
on-change="(ctrl.onServicesChange)"
|
||||||
values="ctrl.formValues.Services"
|
values="ctrl.formValues.Services"
|
||||||
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}"
|
||||||
|
|
|
@ -823,10 +823,6 @@ class KubernetesCreateApplicationController {
|
||||||
return this.nodesLimits.overflowForReplica(cpu, memory, instances);
|
return this.nodesLimits.overflowForReplica(cpu, memory, instances);
|
||||||
}
|
}
|
||||||
|
|
||||||
publishViaLoadBalancerEnabled() {
|
|
||||||
return this.state.useLoadBalancer && this.state.maxLoadBalancersQuota !== 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
publishViaIngressEnabled() {
|
publishViaIngressEnabled() {
|
||||||
return this.ingresses.length;
|
return this.ingresses.length;
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,12 +30,7 @@ export function TextTip({
|
||||||
inline ? 'inline-flex' : 'flex'
|
inline ? 'inline-flex' : 'flex'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon icon={icon} mode={getMode(color)} className="!mt-0.5 flex-none" />
|
||||||
icon={icon}
|
|
||||||
mode={getMode(color)}
|
|
||||||
size="sm"
|
|
||||||
className="!mt-0.5 flex-none"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<span className={childrenWrapperClassName}>{children}</span>
|
<span className={childrenWrapperClassName}>{children}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,132 +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 { 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,167 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
import { FormikErrors } from 'formik';
|
||||||
|
|
||||||
|
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
|
||||||
|
|
||||||
|
import { Card } from '@@/Card';
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
import { Button } from '@@/buttons';
|
||||||
|
|
||||||
|
import { generateUniqueName, newPort, serviceFormDefaultValues } from './utils';
|
||||||
|
import { ServiceFormValues, ServicePort } from './types';
|
||||||
|
import { ClusterIpServiceForm } from './ClusterIpServiceForm';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
services: ServiceFormValues[];
|
||||||
|
onChangeService: (services: ServiceFormValues[]) => void;
|
||||||
|
errors?: FormikErrors<ServiceFormValues[]>;
|
||||||
|
appName: string;
|
||||||
|
selector: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClusterIpServicesForm({
|
||||||
|
services,
|
||||||
|
onChangeService,
|
||||||
|
errors,
|
||||||
|
appName,
|
||||||
|
selector,
|
||||||
|
}: Props) {
|
||||||
|
const clusterIPServiceCount = services.filter(
|
||||||
|
(service) =>
|
||||||
|
service.Type === KubernetesApplicationPublishingTypes.CLUSTER_IP
|
||||||
|
).length;
|
||||||
|
return (
|
||||||
|
<Card className="pb-5">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<TextTip color="blue">
|
||||||
|
Publish <b>internally</b> in the cluster via a{' '}
|
||||||
|
<b>ClusterIP service</b>, optionally exposing <b>externally</b> to the
|
||||||
|
outside world via an <b>ingress</b>.
|
||||||
|
</TextTip>
|
||||||
|
{clusterIPServiceCount > 0 && (
|
||||||
|
<div className="flex w-full flex-col gap-4">
|
||||||
|
{services.map((service, index) =>
|
||||||
|
service.Type ===
|
||||||
|
KubernetesApplicationPublishingTypes.CLUSTER_IP ? (
|
||||||
|
<ClusterIpServiceForm
|
||||||
|
key={index}
|
||||||
|
serviceName={service.Name}
|
||||||
|
servicePorts={service.Ports}
|
||||||
|
errors={errors?.[index]?.Ports}
|
||||||
|
onChangePort={(servicePorts: ServicePort[]) => {
|
||||||
|
const newServices = [...services];
|
||||||
|
newServices[index].Ports = servicePorts;
|
||||||
|
onChangeService(newServices);
|
||||||
|
}}
|
||||||
|
services={services}
|
||||||
|
serviceIndex={index}
|
||||||
|
onChangeService={onChangeService}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex">
|
||||||
|
<Button
|
||||||
|
color="secondary"
|
||||||
|
className="!ml-0"
|
||||||
|
icon={Plus}
|
||||||
|
size="small"
|
||||||
|
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 = KubernetesApplicationPublishingTypes.CLUSTER_IP;
|
||||||
|
const newServicePort = newPort(newService.Name);
|
||||||
|
newService.Ports = [newServicePort];
|
||||||
|
newService.Selector = selector;
|
||||||
|
onChangeService([...services, newService]);
|
||||||
|
}}
|
||||||
|
data-cy="k8sAppCreate-createServiceButton"
|
||||||
|
>
|
||||||
|
Create service
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,61 +1,37 @@
|
||||||
import { SchemaOf, array, boolean, mixed, number, object, string } from 'yup';
|
import { SchemaOf, array, boolean, mixed, number, object, string } from 'yup';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { SingleValue } from 'react-select';
|
|
||||||
import { List, Plus, Trash2 } from 'lucide-react';
|
|
||||||
import { FormikErrors } from 'formik';
|
import { FormikErrors } from 'formik';
|
||||||
|
|
||||||
import DataFlow from '@/assets/ico/dataflow-1.svg?c';
|
|
||||||
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
|
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
|
||||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
|
||||||
|
|
||||||
import { Link } from '@@/Link';
|
import { Badge } from '@@/Badge';
|
||||||
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 {
|
||||||
import { LoadBalancerForm } from './LoadBalancerForm';
|
ServiceFormValues,
|
||||||
import { ClusterIpForm } from './ClusterIpForm';
|
ServicePort,
|
||||||
import { NodePortForm } from './NodePortForm';
|
ServiceTypeAngularEnum,
|
||||||
import { newPort } from './utils';
|
ServiceTypeOption,
|
||||||
|
ServiceTypeValue,
|
||||||
|
} from './types';
|
||||||
|
import { generateUniqueName } from './utils';
|
||||||
|
import { ClusterIpServicesForm } from './ClusterIpServicesForm';
|
||||||
|
import { ServiceTabs } from './ServiceTabs';
|
||||||
|
import { NodePortServicesForm } from './NodePortServicesForm';
|
||||||
|
import { LoadBalancerServicesForm } from './LoadBalancerServicesForm';
|
||||||
|
|
||||||
type ServiceTypeLabel = 'ClusterIP' | 'NodePort' | 'LoadBalancer';
|
const serviceTypeEnumsToValues: Record<
|
||||||
type ServiceTypeOption = { value: ServiceTypeValue; label: ServiceTypeLabel };
|
ServiceTypeAngularEnum,
|
||||||
const serviceTypeOptions: ServiceTypeOption[] = [
|
ServiceTypeValue
|
||||||
{
|
> = {
|
||||||
value: KubernetesApplicationPublishingTypes.CLUSTER_IP,
|
[KubernetesApplicationPublishingTypes.CLUSTER_IP]: 'ClusterIP',
|
||||||
label: 'ClusterIP',
|
[KubernetesApplicationPublishingTypes.NODE_PORT]: 'NodePort',
|
||||||
},
|
[KubernetesApplicationPublishingTypes.LOAD_BALANCER]: 'LoadBalancer',
|
||||||
{ 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 {
|
interface Props {
|
||||||
values: ServiceFormValues[];
|
values: ServiceFormValues[];
|
||||||
onChange: (loadBalancerPorts: ServiceFormValues[]) => void;
|
onChange: (services: ServiceFormValues[]) => void;
|
||||||
errors?: FormikErrors<ServiceFormValues[]>;
|
errors?: FormikErrors<ServiceFormValues[]>;
|
||||||
loadBalancerEnabled: boolean;
|
|
||||||
appName: string;
|
appName: string;
|
||||||
selector: Record<string, string>;
|
selector: Record<string, string>;
|
||||||
isEditMode: boolean;
|
isEditMode: boolean;
|
||||||
|
@ -65,15 +41,12 @@ export function KubeServicesForm({
|
||||||
values: services,
|
values: services,
|
||||||
onChange,
|
onChange,
|
||||||
errors,
|
errors,
|
||||||
loadBalancerEnabled,
|
|
||||||
appName,
|
appName,
|
||||||
selector,
|
selector,
|
||||||
isEditMode,
|
isEditMode,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { isAdmin } = useCurrentUser();
|
const [selectedServiceType, setSelectedServiceType] =
|
||||||
const [selectedServiceTypeOption, setSelectedServiceTypeOption] = useState<
|
useState<ServiceTypeValue>('ClusterIP');
|
||||||
SingleValue<ServiceTypeOption>
|
|
||||||
>(serviceTypeOptions[0]); // ClusterIP is the default value
|
|
||||||
|
|
||||||
// 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
|
||||||
|
@ -93,210 +66,93 @@ export function KubeServicesForm({
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [appName]);
|
}, [appName]);
|
||||||
|
|
||||||
|
const serviceTypeCounts = useMemo(
|
||||||
|
() => getServiceTypeCounts(services),
|
||||||
|
[services]
|
||||||
|
);
|
||||||
|
const serviceTypeOptions: ServiceTypeOption[] = [
|
||||||
|
{
|
||||||
|
value: 'ClusterIP',
|
||||||
|
label: (
|
||||||
|
<div className="inline-flex items-center">
|
||||||
|
ClusterIP services
|
||||||
|
{serviceTypeCounts.ClusterIP && (
|
||||||
|
<Badge className="ml-2 flex-none">
|
||||||
|
{serviceTypeCounts.ClusterIP}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'NodePort',
|
||||||
|
label: (
|
||||||
|
<div className="inline-flex items-center">
|
||||||
|
NodePort services
|
||||||
|
{serviceTypeCounts.NodePort && (
|
||||||
|
<Badge className="ml-2 flex-none">
|
||||||
|
{serviceTypeCounts.NodePort}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'LoadBalancer',
|
||||||
|
label: (
|
||||||
|
<div className="inline-flex items-center">
|
||||||
|
LoadBalancer services
|
||||||
|
{serviceTypeCounts.LoadBalancer && (
|
||||||
|
<Badge className="ml-2 flex-none">
|
||||||
|
{serviceTypeCounts.LoadBalancer}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col">
|
||||||
<div className="col-sm-12 form-section-title">
|
<div className="col-sm-12 form-section-title">
|
||||||
Publishing the application
|
Publishing the application
|
||||||
</div>
|
</div>
|
||||||
<div className="col-sm-12 !p-0">
|
<ServiceTabs
|
||||||
<div className="row">
|
serviceTypeOptions={serviceTypeOptions}
|
||||||
<TextTip color="blue">
|
selectedServiceType={selectedServiceType}
|
||||||
Publish your application by creating a ClusterIP service for it,
|
setSelectedServiceType={setSelectedServiceType}
|
||||||
which you may then expose via{' '}
|
/>
|
||||||
<Link
|
{selectedServiceType === 'ClusterIP' && (
|
||||||
target="_blank"
|
<ClusterIpServicesForm
|
||||||
to="kubernetes.ingresses"
|
services={services}
|
||||||
rel="noopener noreferrer"
|
onChangeService={onChange}
|
||||||
>
|
errors={errors}
|
||||||
an ingress
|
appName={appName}
|
||||||
</Link>
|
selector={selector}
|
||||||
.
|
|
||||||
</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"
|
{selectedServiceType === 'NodePort' && (
|
||||||
className="portainer-tooltip"
|
<NodePortServicesForm
|
||||||
message="Different service types expose the application in alternate ways.
|
services={services}
|
||||||
ClusterIP exposes it within the cluster (for internal access only).
|
onChangeService={onChange}
|
||||||
NodePort exposes it (on a high port) across all nodes.
|
errors={errors}
|
||||||
LoadBalancer exposes it via an external load balancer."
|
appName={appName}
|
||||||
>
|
selector={selector}
|
||||||
<span>
|
/>
|
||||||
<Button
|
)}
|
||||||
color="default"
|
{selectedServiceType === 'LoadBalancer' && (
|
||||||
icon={Plus}
|
<LoadBalancerServicesForm
|
||||||
size="medium"
|
services={services}
|
||||||
disabled={
|
onChangeService={onChange}
|
||||||
selectedServiceTypeOption?.value ===
|
errors={errors}
|
||||||
KubernetesApplicationPublishingTypes.LOAD_BALANCER &&
|
appName={appName}
|
||||||
!loadBalancerEnabled
|
selector={selector}
|
||||||
}
|
/>
|
||||||
onClick={() => {
|
)}
|
||||||
// create a new service form value and add it to the list of services
|
</div>
|
||||||
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[]) {
|
function getUniqNames(appName: string, services: ServiceFormValues[]) {
|
||||||
const sortedServices = [...services].sort((a, b) =>
|
const sortedServices = [...services].sort((a, b) =>
|
||||||
a.Name && b.Name ? a.Name.localeCompare(b.Name) : 0
|
a.Name && b.Name ? a.Name.localeCompare(b.Name) : 0
|
||||||
|
@ -317,6 +173,22 @@ function getUniqNames(appName: string, services: ServiceFormValues[]) {
|
||||||
return uniqueNames;
|
return uniqueNames;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* getServiceTypeCounts returns a map of service types to the number of services of that type
|
||||||
|
*/
|
||||||
|
function getServiceTypeCounts(
|
||||||
|
services: ServiceFormValues[]
|
||||||
|
): Record<ServiceTypeValue, number> {
|
||||||
|
return services.reduce((acc, service) => {
|
||||||
|
const type = serviceTypeEnumsToValues[service.Type];
|
||||||
|
const count = acc[type];
|
||||||
|
return {
|
||||||
|
...acc,
|
||||||
|
[type]: count ? count + 1 : 1,
|
||||||
|
};
|
||||||
|
}, {} as Record<ServiceTypeValue, number>);
|
||||||
|
}
|
||||||
|
|
||||||
// values returned from the angular parent component (pascal case instead of camel case keys),
|
// 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
|
// 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
|
// to make the converted values and formValues objects to be the same
|
||||||
|
@ -525,6 +397,7 @@ export function kubeServicesValidation(
|
||||||
ingress: object(),
|
ingress: object(),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
|
Annotations: array(),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,176 +0,0 @@
|
||||||
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,200 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { Plus, RotateCw } from 'lucide-react';
|
||||||
|
import { FormikErrors } from 'formik';
|
||||||
|
|
||||||
|
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
|
||||||
|
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||||
|
import { useEnvironment } from '@/react/portainer/environments/queries';
|
||||||
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
|
||||||
|
import { Card } from '@@/Card';
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
import { Button } from '@@/buttons';
|
||||||
|
import { FormError } from '@@/form-components/FormError';
|
||||||
|
import { Link } from '@@/Link';
|
||||||
|
|
||||||
|
import { generateUniqueName, newPort, serviceFormDefaultValues } from './utils';
|
||||||
|
import { ServiceFormValues, ServicePort } from './types';
|
||||||
|
import { LoadBalancerServiceForm } from './LoadBalancerServiceForm';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
services: ServiceFormValues[];
|
||||||
|
onChangeService: (services: ServiceFormValues[]) => void;
|
||||||
|
errors?: FormikErrors<ServiceFormValues[]>;
|
||||||
|
appName: string;
|
||||||
|
selector: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadBalancerServicesForm({
|
||||||
|
services,
|
||||||
|
onChangeService,
|
||||||
|
errors,
|
||||||
|
appName,
|
||||||
|
selector,
|
||||||
|
}: Props) {
|
||||||
|
const { isAdmin } = useCurrentUser();
|
||||||
|
const environmentId = useEnvironmentId();
|
||||||
|
const { data: loadBalancerEnabled, ...loadBalancerEnabledQuery } =
|
||||||
|
useEnvironment(
|
||||||
|
environmentId,
|
||||||
|
(environment) => environment?.Kubernetes.Configuration.UseLoadBalancer
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadBalancerServiceCount = services.filter(
|
||||||
|
(service) =>
|
||||||
|
service.Type === KubernetesApplicationPublishingTypes.LOAD_BALANCER
|
||||||
|
).length;
|
||||||
|
return (
|
||||||
|
<Card className="pb-5">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<TextTip color="blue">
|
||||||
|
Allow access to traffic <b>external</b> to the cluster via a{' '}
|
||||||
|
<b>LoadBalancer service</b>. If running on a cloud platform, this auto
|
||||||
|
provisions a cloud load balancer.
|
||||||
|
</TextTip>
|
||||||
|
{!loadBalancerEnabled && loadBalancerEnabledQuery.isSuccess && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<FormError>
|
||||||
|
{isAdmin ? (
|
||||||
|
<>
|
||||||
|
Load balancer use is not currently enabled in this cluster.
|
||||||
|
Configure via{' '}
|
||||||
|
<Link
|
||||||
|
to="kubernetes.cluster.setup"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Cluster Setup
|
||||||
|
</Link>{' '}
|
||||||
|
and then refresh this tab
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Load balancer use is not currently enabled in this cluster, contact your administrator.'
|
||||||
|
)}
|
||||||
|
</FormError>
|
||||||
|
<div className="flex">
|
||||||
|
<Button
|
||||||
|
icon={RotateCw}
|
||||||
|
color="default"
|
||||||
|
className="!ml-0"
|
||||||
|
onClick={() => loadBalancerEnabledQuery.refetch()}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loadBalancerServiceCount > 0 && (
|
||||||
|
<div className="flex w-full flex-col gap-4">
|
||||||
|
{services.map((service, index) =>
|
||||||
|
service.Type ===
|
||||||
|
KubernetesApplicationPublishingTypes.LOAD_BALANCER ? (
|
||||||
|
<LoadBalancerServiceForm
|
||||||
|
key={index}
|
||||||
|
serviceName={service.Name}
|
||||||
|
servicePorts={service.Ports}
|
||||||
|
errors={errors?.[index]?.Ports}
|
||||||
|
onChangePort={(servicePorts: ServicePort[]) => {
|
||||||
|
const newServices = [...services];
|
||||||
|
newServices[index].Ports = servicePorts;
|
||||||
|
onChangeService(newServices);
|
||||||
|
}}
|
||||||
|
services={services}
|
||||||
|
serviceIndex={index}
|
||||||
|
onChangeService={onChangeService}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex">
|
||||||
|
<Button
|
||||||
|
color="secondary"
|
||||||
|
className="!ml-0"
|
||||||
|
icon={Plus}
|
||||||
|
size="small"
|
||||||
|
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 =
|
||||||
|
KubernetesApplicationPublishingTypes.LOAD_BALANCER;
|
||||||
|
const newServicePort = newPort(newService.Name);
|
||||||
|
newService.Ports = [newServicePort];
|
||||||
|
newService.Selector = selector;
|
||||||
|
onChangeService([...services, newService]);
|
||||||
|
}}
|
||||||
|
disabled={!loadBalancerEnabled}
|
||||||
|
data-cy="k8sAppCreate-createServiceButton"
|
||||||
|
>
|
||||||
|
Create service
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,162 +0,0 @@
|
||||||
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,197 @@
|
||||||
|
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 { FormikErrors } from 'formik';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
|
||||||
|
|
||||||
|
import { Card } from '@@/Card';
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
import { Button } from '@@/buttons';
|
||||||
|
|
||||||
|
import { serviceFormDefaultValues, generateUniqueName, newPort } from './utils';
|
||||||
|
import { ServiceFormValues, ServicePort } from './types';
|
||||||
|
import { NodePortServiceForm } from './NodePortServiceForm';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
services: ServiceFormValues[];
|
||||||
|
onChangeService: (services: ServiceFormValues[]) => void;
|
||||||
|
errors?: FormikErrors<ServiceFormValues[]>;
|
||||||
|
appName: string;
|
||||||
|
selector: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NodePortServicesForm({
|
||||||
|
services,
|
||||||
|
onChangeService,
|
||||||
|
errors,
|
||||||
|
appName,
|
||||||
|
selector,
|
||||||
|
}: Props) {
|
||||||
|
const nodePortServiceCount = services.filter(
|
||||||
|
(service) => service.Type === KubernetesApplicationPublishingTypes.NODE_PORT
|
||||||
|
).length;
|
||||||
|
return (
|
||||||
|
<Card className="pb-5">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<TextTip color="blue">
|
||||||
|
Allow access to traffic <b>external</b> to the cluster via a{' '}
|
||||||
|
<b>NodePort service</b>. Not generally recommended for Production use.
|
||||||
|
</TextTip>
|
||||||
|
{nodePortServiceCount > 0 && (
|
||||||
|
<div className="flex w-full flex-col gap-4">
|
||||||
|
{services.map((service, index) =>
|
||||||
|
service.Type ===
|
||||||
|
KubernetesApplicationPublishingTypes.NODE_PORT ? (
|
||||||
|
<NodePortServiceForm
|
||||||
|
key={index}
|
||||||
|
serviceName={service.Name}
|
||||||
|
servicePorts={service.Ports}
|
||||||
|
errors={errors?.[index]?.Ports}
|
||||||
|
onChangePort={(servicePorts: ServicePort[]) => {
|
||||||
|
const newServices = [...services];
|
||||||
|
newServices[index].Ports = servicePorts;
|
||||||
|
onChangeService(newServices);
|
||||||
|
}}
|
||||||
|
services={services}
|
||||||
|
serviceIndex={index}
|
||||||
|
onChangeService={onChangeService}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex">
|
||||||
|
<Button
|
||||||
|
color="secondary"
|
||||||
|
className="!ml-0"
|
||||||
|
icon={Plus}
|
||||||
|
size="small"
|
||||||
|
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 = KubernetesApplicationPublishingTypes.NODE_PORT;
|
||||||
|
const newServicePort = newPort(newService.Name);
|
||||||
|
newService.Ports = [newServicePort];
|
||||||
|
newService.Selector = selector;
|
||||||
|
onChangeService([...services, newService]);
|
||||||
|
}}
|
||||||
|
data-cy="k8sAppCreate-createServiceButton"
|
||||||
|
>
|
||||||
|
Create service
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
import { ServiceTypeOption, ServiceTypeValue } from './types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
serviceTypeOptions: ServiceTypeOption[];
|
||||||
|
selectedServiceType: ServiceTypeValue;
|
||||||
|
setSelectedServiceType: (serviceTypeValue: ServiceTypeValue) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ServiceTabs({
|
||||||
|
serviceTypeOptions,
|
||||||
|
selectedServiceType,
|
||||||
|
setSelectedServiceType,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex pl-2">
|
||||||
|
{serviceTypeOptions.map(({ label }, index) => (
|
||||||
|
<label
|
||||||
|
key={index}
|
||||||
|
className={clsx(
|
||||||
|
'!mb-0 inline-flex cursor-pointer items-center gap-2 border-0 border-b-2 border-solid bg-transparent px-4 py-2 font-medium',
|
||||||
|
selectedServiceType === serviceTypeOptions[index].value
|
||||||
|
? 'border-blue-8 text-blue-8 th-highcontrast:border-blue-6 th-highcontrast:text-blue-6 th-dark:border-blue-6 th-dark:text-blue-6'
|
||||||
|
: 'border-transparent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="widget-tabs"
|
||||||
|
className="hidden"
|
||||||
|
value={serviceTypeOptions[index].value}
|
||||||
|
checked={selectedServiceType === serviceTypeOptions[index].value}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSelectedServiceType(e.target.value as ServiceTypeValue)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,3 +1,7 @@
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
|
||||||
|
|
||||||
export interface ServicePort {
|
export interface ServicePort {
|
||||||
port?: number;
|
port?: number;
|
||||||
targetPort?: number;
|
targetPort?: number;
|
||||||
|
@ -8,12 +12,13 @@ export interface ServicePort {
|
||||||
ingress?: object;
|
ingress?: object;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServiceTypeValue = 1 | 2 | 3;
|
export type ServiceTypeAngularEnum =
|
||||||
|
(typeof KubernetesApplicationPublishingTypes)[keyof typeof KubernetesApplicationPublishingTypes];
|
||||||
|
|
||||||
export type ServiceFormValues = {
|
export type ServiceFormValues = {
|
||||||
Headless: boolean;
|
Headless: boolean;
|
||||||
Ports: ServicePort[];
|
Ports: ServicePort[];
|
||||||
Type: ServiceTypeValue;
|
Type: ServiceTypeAngularEnum;
|
||||||
Ingress: boolean;
|
Ingress: boolean;
|
||||||
ClusterIP?: string;
|
ClusterIP?: string;
|
||||||
ApplicationName?: string;
|
ApplicationName?: string;
|
||||||
|
@ -24,3 +29,9 @@ export type ServiceFormValues = {
|
||||||
Selector?: Record<string, string>;
|
Selector?: Record<string, string>;
|
||||||
Namespace?: string;
|
Namespace?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ServiceTypeValue = 'ClusterIP' | 'NodePort' | 'LoadBalancer';
|
||||||
|
export type ServiceTypeOption = {
|
||||||
|
value: ServiceTypeValue;
|
||||||
|
label: ReactNode;
|
||||||
|
};
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import { FormikErrors } from 'formik';
|
import { FormikErrors } from 'formik';
|
||||||
|
|
||||||
|
import { ServiceFormValues } from './types';
|
||||||
|
|
||||||
export function isServicePortError<T>(
|
export function isServicePortError<T>(
|
||||||
error: string | FormikErrors<T> | undefined
|
error: string | FormikErrors<T> | undefined
|
||||||
): error is FormikErrors<T> {
|
): error is FormikErrors<T> {
|
||||||
|
@ -16,3 +18,42 @@ export function newPort(serviceName?: string) {
|
||||||
serviceName,
|
serviceName,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const serviceFormDefaultValues: ServiceFormValues = {
|
||||||
|
Headless: false,
|
||||||
|
Namespace: '',
|
||||||
|
Name: '',
|
||||||
|
StackName: '',
|
||||||
|
Ports: [],
|
||||||
|
Type: 1, // clusterip type as default
|
||||||
|
ClusterIP: '',
|
||||||
|
ApplicationName: '',
|
||||||
|
ApplicationOwner: '',
|
||||||
|
Note: '',
|
||||||
|
Ingress: false,
|
||||||
|
Selector: {},
|
||||||
|
};
|
||||||
|
|
Loading…
Reference in New Issue