mirror of
https://github.com/portainer/portainer.git
synced 2025-07-24 15:59:41 +02:00
feat(app): add ingress to app service form [EE-5569] (#9106)
This commit is contained in:
parent
8c16fbb8aa
commit
89c1d0e337
47 changed files with 1929 additions and 1181 deletions
|
@ -1,167 +0,0 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
import { ChangeEvent } from 'react';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector';
|
||||
import { Button } from '@@/buttons';
|
||||
import { Card } from '@@/Card';
|
||||
import { Widget } from '@@/Widget';
|
||||
|
||||
import { isServicePortError, newPort } from './utils';
|
||||
import { ServiceFormValues, ServicePort } from './types';
|
||||
import { ServicePortInput } from './ServicePortInput';
|
||||
import { ContainerPortInput } from './ContainerPortInput';
|
||||
|
||||
interface Props {
|
||||
services: ServiceFormValues[];
|
||||
serviceIndex: number;
|
||||
onChangeService: (services: ServiceFormValues[]) => void;
|
||||
servicePorts: ServicePort[];
|
||||
onChangePort: (servicePorts: ServicePort[]) => void;
|
||||
serviceName?: string;
|
||||
errors?: string | string[] | FormikErrors<ServicePort>[];
|
||||
}
|
||||
|
||||
export function ClusterIpServiceForm({
|
||||
services,
|
||||
serviceIndex,
|
||||
onChangeService,
|
||||
servicePorts,
|
||||
onChangePort,
|
||||
errors,
|
||||
serviceName,
|
||||
}: Props) {
|
||||
const newClusterIpPort = newPort(serviceName);
|
||||
return (
|
||||
<Widget key={serviceIndex}>
|
||||
<Widget.Body>
|
||||
<div className="mb-4 flex justify-between">
|
||||
<div className="text-muted vertical-center">ClusterIP service</div>
|
||||
<Button
|
||||
icon={Trash2}
|
||||
color="dangerlight"
|
||||
className="!ml-0 flex-none"
|
||||
onClick={() => {
|
||||
// remove the service at index in an immutable way
|
||||
const newServices = [
|
||||
...services.slice(0, serviceIndex),
|
||||
...services.slice(serviceIndex + 1),
|
||||
];
|
||||
onChangeService(newServices);
|
||||
}}
|
||||
>
|
||||
Remove service
|
||||
</Button>
|
||||
</div>
|
||||
<div className="control-label !mb-2 !pt-0 text-left">Ports</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{servicePorts.map((servicePort, portIndex) => {
|
||||
const error = errors?.[portIndex];
|
||||
const servicePortError = isServicePortError<ServicePort>(error)
|
||||
? error
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={portIndex}
|
||||
className="flex flex-grow flex-wrap justify-between gap-x-4 gap-y-1"
|
||||
>
|
||||
<div className="inline-flex min-w-min flex-grow basis-3/4 flex-wrap gap-2">
|
||||
<div className="flex min-w-min basis-1/3 flex-col">
|
||||
<ContainerPortInput
|
||||
index={portIndex}
|
||||
value={servicePort.targetPort}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const newServicePorts = [...servicePorts];
|
||||
const newValue =
|
||||
e.target.value === ''
|
||||
? undefined
|
||||
: Number(e.target.value);
|
||||
newServicePorts[portIndex] = {
|
||||
...newServicePorts[portIndex],
|
||||
targetPort: newValue,
|
||||
port: newValue,
|
||||
};
|
||||
onChangePort(newServicePorts);
|
||||
}}
|
||||
/>
|
||||
{servicePortError?.targetPort && (
|
||||
<FormError>{servicePortError.targetPort}</FormError>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-min basis-1/3 flex-col">
|
||||
<ServicePortInput
|
||||
index={portIndex}
|
||||
value={servicePort.port}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const newServicePorts = [...servicePorts];
|
||||
newServicePorts[portIndex] = {
|
||||
...newServicePorts[portIndex],
|
||||
port:
|
||||
e.target.value === ''
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
};
|
||||
onChangePort(newServicePorts);
|
||||
}}
|
||||
/>
|
||||
{servicePortError?.port && (
|
||||
<FormError>{servicePortError.port}</FormError>
|
||||
)}
|
||||
</div>
|
||||
<ButtonSelector
|
||||
className="h-[30px]"
|
||||
onChange={(value) => {
|
||||
const newServicePorts = [...servicePorts];
|
||||
newServicePorts[portIndex] = {
|
||||
...newServicePorts[portIndex],
|
||||
protocol: value,
|
||||
};
|
||||
onChangePort(newServicePorts);
|
||||
}}
|
||||
value={servicePort.protocol || 'TCP'}
|
||||
options={[{ value: 'TCP' }, { value: 'UDP' }]}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
disabled={servicePorts.length === 1}
|
||||
size="small"
|
||||
className="!ml-0 h-[30px]"
|
||||
color="dangerlight"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// remove the port at the index in an immutable way
|
||||
const newServicePorts = [
|
||||
...servicePorts.slice(0, portIndex),
|
||||
...servicePorts.slice(portIndex + 1),
|
||||
];
|
||||
onChangePort(newServicePorts);
|
||||
}}
|
||||
data-cy={`k8sAppCreate-rmPortButton_${portIndex}`}
|
||||
icon={Trash2}
|
||||
>
|
||||
Remove port
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
<div className="flex">
|
||||
<Button
|
||||
icon={Plus}
|
||||
color="default"
|
||||
className="!ml-0"
|
||||
onClick={() => {
|
||||
const newServicesPorts = [...servicePorts, newClusterIpPort];
|
||||
onChangePort(newServicesPorts);
|
||||
}}
|
||||
>
|
||||
Add port
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Widget.Body>
|
||||
</Widget>
|
||||
);
|
||||
}
|
|
@ -1,23 +1,23 @@
|
|||
import { SchemaOf, array, boolean, mixed, number, object, string } from 'yup';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
|
||||
|
||||
import { Badge } from '@@/Badge';
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
|
||||
import {
|
||||
ServiceFormValues,
|
||||
ServicePort,
|
||||
ServiceTypeAngularEnum,
|
||||
ServiceTypeOption,
|
||||
ServiceTypeValue,
|
||||
} from './types';
|
||||
import { generateUniqueName } from './utils';
|
||||
import { ClusterIpServicesForm } from './ClusterIpServicesForm';
|
||||
import { ServiceTabs } from './ServiceTabs';
|
||||
import { NodePortServicesForm } from './NodePortServicesForm';
|
||||
import { LoadBalancerServicesForm } from './LoadBalancerServicesForm';
|
||||
import { ClusterIpServicesForm } from './cluster-ip/ClusterIpServicesForm';
|
||||
import { ServiceTabs } from './components/ServiceTabs';
|
||||
import { NodePortServicesForm } from './node-port/NodePortServicesForm';
|
||||
import { LoadBalancerServicesForm } from './load-balancer/LoadBalancerServicesForm';
|
||||
import { ServiceTabLabel } from './components/ServiceTabLabel';
|
||||
import { PublishingExplaination } from './PublishingExplaination';
|
||||
|
||||
const serviceTypeEnumsToValues: Record<
|
||||
ServiceTypeAngularEnum,
|
||||
|
@ -35,6 +35,7 @@ interface Props {
|
|||
appName: string;
|
||||
selector: Record<string, string>;
|
||||
isEditMode: boolean;
|
||||
namespace?: string;
|
||||
}
|
||||
|
||||
export function KubeServicesForm({
|
||||
|
@ -44,15 +45,19 @@ export function KubeServicesForm({
|
|||
appName,
|
||||
selector,
|
||||
isEditMode,
|
||||
namespace,
|
||||
}: Props) {
|
||||
const [selectedServiceType, setSelectedServiceType] =
|
||||
useState<ServiceTypeValue>('ClusterIP');
|
||||
|
||||
// when the appName changes, update the names for each service
|
||||
// and the serviceNames for each service port
|
||||
const newServiceNames = useMemo(
|
||||
() => getUniqNames(appName, services),
|
||||
[appName, services]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isEditMode) {
|
||||
const newServiceNames = getUniqNames(appName, services);
|
||||
const newServices = services.map((service, index) => {
|
||||
const newServiceName = newServiceNames[index];
|
||||
const newServicePorts = service.Ports.map((port) => ({
|
||||
|
@ -70,53 +75,49 @@ export function KubeServicesForm({
|
|||
() => getServiceTypeCounts(services),
|
||||
[services]
|
||||
);
|
||||
|
||||
const serviceTypeHasErrors = useMemo(
|
||||
() => getServiceTypeHasErrors(services, errors),
|
||||
[services, errors]
|
||||
);
|
||||
|
||||
const serviceTypeOptions: ServiceTypeOption[] = [
|
||||
{
|
||||
value: 'ClusterIP',
|
||||
label: (
|
||||
<div className="inline-flex items-center">
|
||||
ClusterIP services
|
||||
{serviceTypeCounts.ClusterIP && (
|
||||
<Badge className="ml-2 flex-none">
|
||||
{serviceTypeCounts.ClusterIP}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<ServiceTabLabel
|
||||
serviceTypeLabel="ClusterIP services"
|
||||
serviceTypeCount={serviceTypeCounts.ClusterIP}
|
||||
serviceTypeHasErrors={serviceTypeHasErrors.ClusterIP}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'NodePort',
|
||||
label: (
|
||||
<div className="inline-flex items-center">
|
||||
NodePort services
|
||||
{serviceTypeCounts.NodePort && (
|
||||
<Badge className="ml-2 flex-none">
|
||||
{serviceTypeCounts.NodePort}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<ServiceTabLabel
|
||||
serviceTypeLabel="NodePort services"
|
||||
serviceTypeCount={serviceTypeCounts.NodePort}
|
||||
serviceTypeHasErrors={serviceTypeHasErrors.NodePort}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'LoadBalancer',
|
||||
label: (
|
||||
<div className="inline-flex items-center">
|
||||
LoadBalancer services
|
||||
{serviceTypeCounts.LoadBalancer && (
|
||||
<Badge className="ml-2 flex-none">
|
||||
{serviceTypeCounts.LoadBalancer}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<ServiceTabLabel
|
||||
serviceTypeLabel="LoadBalancer services"
|
||||
serviceTypeCount={serviceTypeCounts.LoadBalancer}
|
||||
serviceTypeHasErrors={serviceTypeHasErrors.LoadBalancer}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="col-sm-12 form-section-title">
|
||||
Publishing the application
|
||||
</div>
|
||||
<FormSection title="Publishing the application" />
|
||||
<PublishingExplaination />
|
||||
<ServiceTabs
|
||||
serviceTypeOptions={serviceTypeOptions}
|
||||
selectedServiceType={selectedServiceType}
|
||||
|
@ -129,6 +130,8 @@ export function KubeServicesForm({
|
|||
errors={errors}
|
||||
appName={appName}
|
||||
selector={selector}
|
||||
namespace={namespace}
|
||||
isEditMode={isEditMode}
|
||||
/>
|
||||
)}
|
||||
{selectedServiceType === 'NodePort' && (
|
||||
|
@ -138,6 +141,8 @@ export function KubeServicesForm({
|
|||
errors={errors}
|
||||
appName={appName}
|
||||
selector={selector}
|
||||
namespace={namespace}
|
||||
isEditMode={isEditMode}
|
||||
/>
|
||||
)}
|
||||
{selectedServiceType === 'LoadBalancer' && (
|
||||
|
@ -147,6 +152,8 @@ export function KubeServicesForm({
|
|||
errors={errors}
|
||||
appName={appName}
|
||||
selector={selector}
|
||||
namespace={namespace}
|
||||
isEditMode={isEditMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
@ -189,222 +196,22 @@ function getServiceTypeCounts(
|
|||
}, {} as Record<ServiceTypeValue, number>);
|
||||
}
|
||||
|
||||
// values returned from the angular parent component (pascal case instead of camel case keys),
|
||||
// these should match the form values, but don't. Future tech debt work to update this would be nice
|
||||
// to make the converted values and formValues objects to be the same
|
||||
interface NodePortValues {
|
||||
Port: number;
|
||||
TargetPort: number;
|
||||
NodePort: number;
|
||||
Name?: string;
|
||||
Protocol?: string;
|
||||
Ingress?: string;
|
||||
}
|
||||
|
||||
type ServiceValues = {
|
||||
Type: number;
|
||||
Name: string;
|
||||
Ports: NodePortValues[];
|
||||
};
|
||||
|
||||
type NodePortValidationContext = {
|
||||
nodePortServices: ServiceValues[];
|
||||
formServices: ServiceFormValues[];
|
||||
};
|
||||
|
||||
export function kubeServicesValidation(
|
||||
validationData?: NodePortValidationContext
|
||||
): SchemaOf<ServiceFormValues[]> {
|
||||
return array(
|
||||
object({
|
||||
Headless: boolean().required(),
|
||||
Namespace: string(),
|
||||
Name: string(),
|
||||
StackName: string(),
|
||||
Type: mixed().oneOf([
|
||||
KubernetesApplicationPublishingTypes.CLUSTER_IP,
|
||||
KubernetesApplicationPublishingTypes.NODE_PORT,
|
||||
KubernetesApplicationPublishingTypes.LOAD_BALANCER,
|
||||
]),
|
||||
ClusterIP: string(),
|
||||
ApplicationName: string(),
|
||||
ApplicationOwner: string(),
|
||||
Note: string(),
|
||||
Ingress: boolean().required(),
|
||||
Selector: object(),
|
||||
Ports: array(
|
||||
object({
|
||||
port: number()
|
||||
.required('Service port number is required.')
|
||||
.min(1, 'Service port number must be inside the range 1-65535.')
|
||||
.max(65535, 'Service port number must be inside the range 1-65535.')
|
||||
.test(
|
||||
'service-port-is-unique',
|
||||
'Service port number must be unique.',
|
||||
(servicePort, context) => {
|
||||
// test for duplicate service ports within this service.
|
||||
// yup gives access to context.parent which gives one ServicePort object.
|
||||
// yup also gives access to all form values through this.options.context.
|
||||
// Unfortunately, it doesn't give direct access to all Ports within the current service.
|
||||
// To find all ports in the service for validation, I'm filtering the services by the service name,
|
||||
// that's stored in the ServicePort object, then getting all Ports in the service.
|
||||
if (servicePort === undefined || validationData === undefined) {
|
||||
return true;
|
||||
}
|
||||
const { formServices } = validationData;
|
||||
const matchingService = getServiceForPort(
|
||||
context.parent as ServicePort,
|
||||
formServices
|
||||
);
|
||||
if (matchingService === undefined) {
|
||||
return true;
|
||||
}
|
||||
const servicePorts = matchingService.Ports;
|
||||
const duplicateServicePortCount = servicePorts.filter(
|
||||
(port) => port.port === servicePort
|
||||
).length;
|
||||
return duplicateServicePortCount <= 1;
|
||||
}
|
||||
),
|
||||
targetPort: number()
|
||||
.required('Container port number is required.')
|
||||
.min(1, 'Container port number must be inside the range 1-65535.')
|
||||
.max(
|
||||
65535,
|
||||
'Container port number must be inside the range 1-65535.'
|
||||
),
|
||||
name: string(),
|
||||
serviceName: string().required(),
|
||||
protocol: string(),
|
||||
nodePort: number()
|
||||
.test(
|
||||
'node-port-is-unique-in-service',
|
||||
'Node port is already used in this service.',
|
||||
(nodePort, context) => {
|
||||
if (nodePort === undefined || validationData === undefined) {
|
||||
return true;
|
||||
}
|
||||
const { formServices } = validationData;
|
||||
const matchingService = getServiceForPort(
|
||||
context.parent as ServicePort,
|
||||
formServices
|
||||
);
|
||||
if (
|
||||
matchingService === undefined ||
|
||||
matchingService.Type !==
|
||||
KubernetesApplicationPublishingTypes.NODE_PORT // ignore validation unless the service is of type nodeport
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const servicePorts = matchingService.Ports;
|
||||
const duplicateNodePortCount = servicePorts.filter(
|
||||
(port) => port.nodePort === nodePort
|
||||
).length;
|
||||
return duplicateNodePortCount <= 1;
|
||||
}
|
||||
)
|
||||
.test(
|
||||
'node-port-is-unique-in-cluster',
|
||||
'Node port is already used.',
|
||||
(nodePort, context) => {
|
||||
if (nodePort === undefined || validationData === undefined) {
|
||||
return true;
|
||||
}
|
||||
const { formServices, nodePortServices } = validationData;
|
||||
const matchingService = getServiceForPort(
|
||||
context.parent as ServicePort,
|
||||
formServices
|
||||
);
|
||||
|
||||
if (
|
||||
matchingService === undefined ||
|
||||
matchingService.Type !==
|
||||
KubernetesApplicationPublishingTypes.NODE_PORT // ignore validation unless the service is of type nodeport
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// create a list of all the node ports (number[]) in the cluster, from services that aren't in the application form
|
||||
const formServiceNames = formServices.map(
|
||||
(formService) => formService.Name
|
||||
);
|
||||
const clusterNodePortsWithoutFormServices = nodePortServices
|
||||
.filter(
|
||||
(npService) => !formServiceNames.includes(npService.Name)
|
||||
)
|
||||
.flatMap((npService) => npService.Ports)
|
||||
.map((npServicePorts) => npServicePorts.NodePort);
|
||||
// node ports in the current form, excluding the current service
|
||||
const formNodePortsWithoutCurrentService = formServices
|
||||
.filter(
|
||||
(formService) =>
|
||||
formService.Type ===
|
||||
KubernetesApplicationPublishingTypes.NODE_PORT &&
|
||||
formService.Name !== matchingService.Name
|
||||
)
|
||||
.flatMap((formService) => formService.Ports)
|
||||
.map((formServicePorts) => formServicePorts.nodePort);
|
||||
return (
|
||||
!clusterNodePortsWithoutFormServices.includes(nodePort) && // node port is not in the cluster services that aren't in the application form
|
||||
!formNodePortsWithoutCurrentService.includes(nodePort) // and the node port is not in the current form, excluding the current service
|
||||
);
|
||||
}
|
||||
)
|
||||
.test(
|
||||
'node-port-minimum',
|
||||
'Nodeport number must be inside the range 30000-32767 or blank for system allocated.',
|
||||
(nodePort, context) => {
|
||||
if (nodePort === undefined || validationData === undefined) {
|
||||
return true;
|
||||
}
|
||||
const { formServices } = validationData;
|
||||
const matchingService = getServiceForPort(
|
||||
context.parent as ServicePort,
|
||||
formServices
|
||||
);
|
||||
if (
|
||||
!matchingService ||
|
||||
matchingService.Type !==
|
||||
KubernetesApplicationPublishingTypes.NODE_PORT
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return nodePort >= 30000;
|
||||
}
|
||||
)
|
||||
.test(
|
||||
'node-port-maximum',
|
||||
'Nodeport number must be inside the range 30000-32767 or blank for system allocated.',
|
||||
(nodePort, context) => {
|
||||
if (nodePort === undefined || validationData === undefined) {
|
||||
return true;
|
||||
}
|
||||
const { formServices } = validationData;
|
||||
const matchingService = getServiceForPort(
|
||||
context.parent as ServicePort,
|
||||
formServices
|
||||
);
|
||||
if (
|
||||
!matchingService ||
|
||||
matchingService.Type !==
|
||||
KubernetesApplicationPublishingTypes.NODE_PORT
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return nodePort <= 32767;
|
||||
}
|
||||
),
|
||||
ingress: object(),
|
||||
})
|
||||
),
|
||||
Annotations: array(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function getServiceForPort(
|
||||
servicePort: ServicePort,
|
||||
services: ServiceFormValues[]
|
||||
) {
|
||||
return services.find((service) => service.Name === servicePort.serviceName);
|
||||
/**
|
||||
* getServiceTypeHasErrors returns a map of service types to whether or not they have errors
|
||||
*/
|
||||
function getServiceTypeHasErrors(
|
||||
services: ServiceFormValues[],
|
||||
errors: FormikErrors<ServiceFormValues[] | undefined>
|
||||
): Record<ServiceTypeValue, boolean> {
|
||||
return services.reduce((acc, service, index) => {
|
||||
const type = serviceTypeEnumsToValues[service.Type];
|
||||
const serviceHasErrors = !!errors?.[index];
|
||||
// if the service type already has an error, don't overwrite it
|
||||
if (acc[type] === true) return acc;
|
||||
// otherwise, set the error to the value of serviceHasErrors
|
||||
return {
|
||||
...acc,
|
||||
[type]: serviceHasErrors,
|
||||
};
|
||||
}, {} as Record<ServiceTypeValue, boolean>);
|
||||
}
|
||||
|
|
|
@ -1,200 +0,0 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
import { ChangeEvent } from 'react';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector';
|
||||
import { Button } from '@@/buttons';
|
||||
import { Widget } from '@@/Widget';
|
||||
import { Card } from '@@/Card';
|
||||
import { InputGroup } from '@@/form-components/InputGroup';
|
||||
|
||||
import { isServicePortError, newPort } from './utils';
|
||||
import { ContainerPortInput } from './ContainerPortInput';
|
||||
import { ServicePortInput } from './ServicePortInput';
|
||||
import { ServiceFormValues, ServicePort } from './types';
|
||||
|
||||
interface Props {
|
||||
services: ServiceFormValues[];
|
||||
serviceIndex: number;
|
||||
onChangeService: (services: ServiceFormValues[]) => void;
|
||||
servicePorts: ServicePort[];
|
||||
onChangePort: (servicePorts: ServicePort[]) => void;
|
||||
serviceName?: string;
|
||||
errors?: string | string[] | FormikErrors<ServicePort>[];
|
||||
}
|
||||
|
||||
export function LoadBalancerServiceForm({
|
||||
services,
|
||||
serviceIndex,
|
||||
onChangeService,
|
||||
servicePorts,
|
||||
onChangePort,
|
||||
errors,
|
||||
serviceName,
|
||||
}: Props) {
|
||||
const newLoadBalancerPort = newPort(serviceName);
|
||||
return (
|
||||
<Widget key={serviceIndex}>
|
||||
<Widget.Body>
|
||||
<div className="mb-4 flex justify-between">
|
||||
<div className="text-muted vertical-center">LoadBalancer service</div>
|
||||
<Button
|
||||
icon={Trash2}
|
||||
color="dangerlight"
|
||||
className="!ml-0"
|
||||
onClick={() => {
|
||||
// remove the service at index in an immutable way
|
||||
const newServices = [
|
||||
...services.slice(0, serviceIndex),
|
||||
...services.slice(serviceIndex + 1),
|
||||
];
|
||||
onChangeService(newServices);
|
||||
}}
|
||||
>
|
||||
Remove service
|
||||
</Button>
|
||||
</div>
|
||||
<div className="control-label !mb-2 !pt-0 text-left">Ports</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{servicePorts.map((servicePort, portIndex) => {
|
||||
const error = errors?.[portIndex];
|
||||
const servicePortError = isServicePortError<ServicePort>(error)
|
||||
? error
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={portIndex}
|
||||
className="flex flex-grow flex-wrap justify-between gap-x-4 gap-y-1"
|
||||
>
|
||||
<div className="inline-flex min-w-min flex-grow basis-3/4 flex-wrap gap-2">
|
||||
<div className="flex min-w-min basis-1/4 flex-col">
|
||||
<ContainerPortInput
|
||||
index={portIndex}
|
||||
value={servicePort.targetPort}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const newServicePorts = [...servicePorts];
|
||||
const newValue =
|
||||
e.target.value === ''
|
||||
? undefined
|
||||
: Number(e.target.value);
|
||||
newServicePorts[portIndex] = {
|
||||
...newServicePorts[portIndex],
|
||||
targetPort: newValue,
|
||||
port: newValue,
|
||||
};
|
||||
onChangePort(newServicePorts);
|
||||
}}
|
||||
/>
|
||||
{servicePortError?.targetPort && (
|
||||
<FormError>{servicePortError.targetPort}</FormError>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-min basis-1/4 flex-col">
|
||||
<ServicePortInput
|
||||
index={portIndex}
|
||||
value={servicePort.port}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const newServicePorts = [...servicePorts];
|
||||
newServicePorts[portIndex] = {
|
||||
...newServicePorts[portIndex],
|
||||
port:
|
||||
e.target.value === ''
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
};
|
||||
onChangePort(newServicePorts);
|
||||
}}
|
||||
/>
|
||||
{servicePortError?.port && (
|
||||
<FormError>{servicePortError.port}</FormError>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-min basis-1/4 flex-col">
|
||||
<InputGroup size="small">
|
||||
<InputGroup.Addon required>
|
||||
Loadbalancer port
|
||||
</InputGroup.Addon>
|
||||
<InputGroup.Input
|
||||
type="number"
|
||||
className="form-control min-w-max"
|
||||
name={`loadbalancer_port_${portIndex}`}
|
||||
placeholder="80"
|
||||
min="1"
|
||||
max="65535"
|
||||
value={servicePort.port || ''}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const newServicePorts = [...servicePorts];
|
||||
newServicePorts[portIndex] = {
|
||||
...newServicePorts[portIndex],
|
||||
port:
|
||||
e.target.value === ''
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
};
|
||||
onChangePort(newServicePorts);
|
||||
}}
|
||||
required
|
||||
data-cy={`k8sAppCreate-loadbalancerPort_${portIndex}`}
|
||||
/>
|
||||
</InputGroup>
|
||||
{servicePortError?.nodePort && (
|
||||
<FormError>{servicePortError.nodePort}</FormError>
|
||||
)}
|
||||
</div>
|
||||
<ButtonSelector
|
||||
className="h-[30px]"
|
||||
onChange={(value) => {
|
||||
const newServicePorts = [...servicePorts];
|
||||
newServicePorts[portIndex] = {
|
||||
...newServicePorts[portIndex],
|
||||
protocol: value,
|
||||
};
|
||||
onChangePort(newServicePorts);
|
||||
}}
|
||||
value={servicePort.protocol || 'TCP'}
|
||||
options={[{ value: 'TCP' }, { value: 'UDP' }]}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
disabled={servicePorts.length === 1}
|
||||
size="small"
|
||||
className="!ml-0 h-[30px]"
|
||||
color="dangerlight"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// remove the port at the index in an immutable way
|
||||
const newServicePorts = [
|
||||
...servicePorts.slice(0, portIndex),
|
||||
...servicePorts.slice(portIndex + 1),
|
||||
];
|
||||
onChangePort(newServicePorts);
|
||||
}}
|
||||
data-cy={`k8sAppCreate-rmPortButton_${portIndex}`}
|
||||
icon={Trash2}
|
||||
>
|
||||
Remove port
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
<div className="flex">
|
||||
<Button
|
||||
icon={Plus}
|
||||
color="default"
|
||||
className="!ml-0"
|
||||
onClick={() => {
|
||||
const newServicesPorts = [...servicePorts, newLoadBalancerPort];
|
||||
onChangePort(newServicesPorts);
|
||||
}}
|
||||
>
|
||||
Add port
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Widget.Body>
|
||||
</Widget>
|
||||
);
|
||||
}
|
|
@ -1,197 +0,0 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
import { ChangeEvent } from 'react';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector';
|
||||
import { Button } from '@@/buttons';
|
||||
import { Widget } from '@@/Widget';
|
||||
import { Card } from '@@/Card';
|
||||
import { InputGroup } from '@@/form-components/InputGroup';
|
||||
|
||||
import { isServicePortError, newPort } from './utils';
|
||||
import { ContainerPortInput } from './ContainerPortInput';
|
||||
import { ServicePortInput } from './ServicePortInput';
|
||||
import { ServiceFormValues, ServicePort } from './types';
|
||||
|
||||
interface Props {
|
||||
services: ServiceFormValues[];
|
||||
serviceIndex: number;
|
||||
onChangeService: (services: ServiceFormValues[]) => void;
|
||||
servicePorts: ServicePort[];
|
||||
onChangePort: (servicePorts: ServicePort[]) => void;
|
||||
serviceName?: string;
|
||||
errors?: string | string[] | FormikErrors<ServicePort>[];
|
||||
}
|
||||
|
||||
export function NodePortServiceForm({
|
||||
services,
|
||||
serviceIndex,
|
||||
onChangeService,
|
||||
servicePorts,
|
||||
onChangePort,
|
||||
errors,
|
||||
serviceName,
|
||||
}: Props) {
|
||||
const newNodePortPort = newPort(serviceName);
|
||||
return (
|
||||
<Widget key={serviceIndex}>
|
||||
<Widget.Body>
|
||||
<div className="mb-4 flex justify-between">
|
||||
<div className="text-muted vertical-center">NodePort service</div>
|
||||
<Button
|
||||
icon={Trash2}
|
||||
color="dangerlight"
|
||||
className="!ml-0"
|
||||
onClick={() => {
|
||||
// remove the service at index in an immutable way
|
||||
const newServices = [
|
||||
...services.slice(0, serviceIndex),
|
||||
...services.slice(serviceIndex + 1),
|
||||
];
|
||||
onChangeService(newServices);
|
||||
}}
|
||||
>
|
||||
Remove service
|
||||
</Button>
|
||||
</div>
|
||||
<div className="control-label !mb-2 !pt-0 text-left">Ports</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{servicePorts.map((servicePort, portIndex) => {
|
||||
const error = errors?.[portIndex];
|
||||
const servicePortError = isServicePortError<ServicePort>(error)
|
||||
? error
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={portIndex}
|
||||
className="flex flex-grow flex-wrap justify-between gap-x-4 gap-y-1"
|
||||
>
|
||||
<div className="inline-flex min-w-min flex-grow basis-3/4 flex-wrap gap-2">
|
||||
<div className="flex min-w-min basis-1/4 flex-col">
|
||||
<ContainerPortInput
|
||||
index={portIndex}
|
||||
value={servicePort.targetPort}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const newServicePorts = [...servicePorts];
|
||||
const newValue =
|
||||
e.target.value === ''
|
||||
? undefined
|
||||
: Number(e.target.value);
|
||||
newServicePorts[portIndex] = {
|
||||
...newServicePorts[portIndex],
|
||||
targetPort: newValue,
|
||||
port: newValue,
|
||||
};
|
||||
onChangePort(newServicePorts);
|
||||
}}
|
||||
/>
|
||||
{servicePortError?.targetPort && (
|
||||
<FormError>{servicePortError.targetPort}</FormError>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-min basis-1/4 flex-col">
|
||||
<ServicePortInput
|
||||
index={portIndex}
|
||||
value={servicePort.port}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const newServicePorts = [...servicePorts];
|
||||
newServicePorts[portIndex] = {
|
||||
...newServicePorts[portIndex],
|
||||
port:
|
||||
e.target.value === ''
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
};
|
||||
onChangePort(newServicePorts);
|
||||
}}
|
||||
/>
|
||||
{servicePortError?.port && (
|
||||
<FormError>{servicePortError.port}</FormError>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-min basis-1/4 flex-col">
|
||||
<InputGroup size="small">
|
||||
<InputGroup.Addon>Nodeport</InputGroup.Addon>
|
||||
<InputGroup.Input
|
||||
type="number"
|
||||
className="form-control min-w-max"
|
||||
name={`node_port_${portIndex}`}
|
||||
placeholder="30080"
|
||||
min="30000"
|
||||
max="32767"
|
||||
value={servicePort.nodePort ?? ''}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const newServicePorts = [...servicePorts];
|
||||
newServicePorts[portIndex] = {
|
||||
...newServicePorts[portIndex],
|
||||
nodePort:
|
||||
e.target.value === ''
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
};
|
||||
onChangePort(newServicePorts);
|
||||
}}
|
||||
data-cy={`k8sAppCreate-nodePort_${portIndex}`}
|
||||
/>
|
||||
</InputGroup>
|
||||
{servicePortError?.nodePort && (
|
||||
<FormError>{servicePortError.nodePort}</FormError>
|
||||
)}
|
||||
</div>
|
||||
<ButtonSelector
|
||||
className="h-[30px]"
|
||||
onChange={(value) => {
|
||||
const newServicePorts = [...servicePorts];
|
||||
newServicePorts[portIndex] = {
|
||||
...newServicePorts[portIndex],
|
||||
protocol: value,
|
||||
};
|
||||
onChangePort(newServicePorts);
|
||||
}}
|
||||
value={servicePort.protocol || 'TCP'}
|
||||
options={[{ value: 'TCP' }, { value: 'UDP' }]}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
disabled={servicePorts.length === 1}
|
||||
size="small"
|
||||
className="!ml-0 h-[30px]"
|
||||
color="dangerlight"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// remove the port at the index in an immutable way
|
||||
const newServicePorts = [
|
||||
...servicePorts.slice(0, portIndex),
|
||||
...servicePorts.slice(portIndex + 1),
|
||||
];
|
||||
onChangePort(newServicePorts);
|
||||
}}
|
||||
data-cy={`k8sAppCreate-rmPortButton_${portIndex}`}
|
||||
icon={Trash2}
|
||||
>
|
||||
Remove port
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
<div className="flex">
|
||||
<Button
|
||||
icon={Plus}
|
||||
color="default"
|
||||
className="!ml-0"
|
||||
onClick={() => {
|
||||
const newServicesPorts = [...servicePorts, newNodePortPort];
|
||||
onChangePort(newServicesPorts);
|
||||
}}
|
||||
>
|
||||
Add port
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Widget.Body>
|
||||
</Widget>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
import ingressDiagram from '@/assets/images/ingress-explanatory-diagram.png';
|
||||
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
|
||||
export function PublishingExplaination() {
|
||||
return (
|
||||
<FormSection title="Explanation" isFoldable titleSize="sm">
|
||||
<div className="mb-4 flex flex-col items-start lg:flex-row">
|
||||
<img
|
||||
src={ingressDiagram}
|
||||
alt="ingress explaination"
|
||||
width={646}
|
||||
className="flex w-full max-w-2xl basis-1/2 flex-col object-contain lg:w-1/2"
|
||||
/>
|
||||
<div className="ml-8 basis-1/2">
|
||||
Expose the application workload via{' '}
|
||||
<a
|
||||
href="https://kubernetes.io/docs/concepts/services-networking/service/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
services
|
||||
</a>{' '}
|
||||
and{' '}
|
||||
<a
|
||||
href="https://kubernetes.io/docs/concepts/services-networking/ingress/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
ingresses
|
||||
</a>
|
||||
:
|
||||
<ul className="mt-3 ml-5 [&>li]:mb-3 [&>li>ul>li]:ml-5">
|
||||
<li>
|
||||
<b>Inside</b> the cluster{' '}
|
||||
<b>
|
||||
<i>only</i>
|
||||
</b>{' '}
|
||||
- via <b>ClusterIP</b> service
|
||||
<ul>
|
||||
<li>
|
||||
<i>The default service type.</i>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<b>Inside</b> the cluster via <b>ClusterIP</b> service and{' '}
|
||||
<b>outside</b> via <b>ingress</b>
|
||||
<ul>
|
||||
<li>
|
||||
<i>
|
||||
An ingress manages external access to (usually ClusterIP)
|
||||
services within the cluster, and allows defining of routing
|
||||
rules, SSL termination and other advanced features.
|
||||
</i>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<b>Inside</b> and <b>outside</b> the cluster via <b>NodePort</b>{' '}
|
||||
service
|
||||
<ul>
|
||||
<li>
|
||||
<i>
|
||||
This publishes the workload on a static port on each node,
|
||||
allowing external access via a nodes' IP address and
|
||||
port. Not generally recommended for Production use.
|
||||
</i>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<b>Inside</b> and <b>outside</b> the cluster via{' '}
|
||||
<b>LoadBalancer</b> service
|
||||
<ul>
|
||||
<li>
|
||||
<i>
|
||||
If running on a cloud platform, this auto provisions a cloud
|
||||
load balancer and assigns an external IP address or DNS to
|
||||
route traffic to the workload.
|
||||
</i>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,196 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
import { ChangeEvent } from 'react';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector';
|
||||
import { Button } from '@@/buttons';
|
||||
import { Card } from '@@/Card';
|
||||
import { Widget } from '@@/Widget';
|
||||
|
||||
import { isErrorType, newPort } from '../utils';
|
||||
import {
|
||||
ServiceFormValues,
|
||||
ServicePort,
|
||||
ServicePortIngressPath,
|
||||
} from '../types';
|
||||
import { ContainerPortInput } from '../components/ContainerPortInput';
|
||||
import { ServicePortInput } from '../components/ServicePortInput';
|
||||
import { AppIngressPathsForm } from '../ingress/AppIngressPathsForm';
|
||||
|
||||
interface Props {
|
||||
services: ServiceFormValues[];
|
||||
serviceIndex: number;
|
||||
onChangeService: (services: ServiceFormValues[]) => void;
|
||||
servicePorts: ServicePort[];
|
||||
onChangePort: (servicePorts: ServicePort[]) => void;
|
||||
serviceName?: string;
|
||||
errors?: string | string[] | FormikErrors<ServicePort>[];
|
||||
namespace?: string;
|
||||
isEditMode?: boolean;
|
||||
}
|
||||
|
||||
export function ClusterIpServiceForm({
|
||||
services,
|
||||
serviceIndex,
|
||||
onChangeService,
|
||||
servicePorts,
|
||||
onChangePort,
|
||||
errors,
|
||||
serviceName,
|
||||
namespace,
|
||||
isEditMode,
|
||||
}: Props) {
|
||||
const newClusterIpPort = newPort(serviceName);
|
||||
return (
|
||||
<Widget key={serviceIndex}>
|
||||
<Widget.Body>
|
||||
<div className="flex justify-between">
|
||||
<div className="text-muted vertical-center">ClusterIP</div>
|
||||
<Button
|
||||
icon={Trash2}
|
||||
color="dangerlight"
|
||||
className="!ml-0"
|
||||
onClick={() => {
|
||||
// remove the service at index in an immutable way
|
||||
const newServices = [
|
||||
...services.slice(0, serviceIndex),
|
||||
...services.slice(serviceIndex + 1),
|
||||
];
|
||||
onChangeService(newServices);
|
||||
}}
|
||||
>
|
||||
Remove service
|
||||
</Button>
|
||||
</div>
|
||||
<div className="control-label !mb-2 !pt-0 text-left">Ports</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{servicePorts.map((servicePort, portIndex) => {
|
||||
const error = errors?.[portIndex];
|
||||
const servicePortErrors = isErrorType<ServicePort>(error)
|
||||
? error
|
||||
: undefined;
|
||||
const ingressPathsErrors = isErrorType<ServicePortIngressPath[]>(
|
||||
servicePortErrors?.ingressPaths
|
||||
)
|
||||
? servicePortErrors?.ingressPaths
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Card key={portIndex} className="flex flex-col gap-y-3">
|
||||
<div className="flex flex-grow flex-wrap justify-between gap-x-4 gap-y-1">
|
||||
<div className="inline-flex min-w-min flex-grow basis-3/4 flex-wrap gap-2">
|
||||
<div className="flex min-w-min basis-1/3 flex-col">
|
||||
<ContainerPortInput
|
||||
serviceIndex={serviceIndex}
|
||||
portIndex={portIndex}
|
||||
value={servicePort.targetPort}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const newServicePorts = [...servicePorts];
|
||||
const newValue =
|
||||
e.target.value === ''
|
||||
? undefined
|
||||
: Number(e.target.value);
|
||||
newServicePorts[portIndex] = {
|
||||
...newServicePorts[portIndex],
|
||||
targetPort: newValue,
|
||||
port: newValue,
|
||||
};
|
||||
onChangePort(newServicePorts);
|
||||
}}
|
||||
/>
|
||||
{servicePortErrors?.targetPort && (
|
||||
<FormError>{servicePortErrors.targetPort}</FormError>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-min basis-1/3 flex-col">
|
||||
<ServicePortInput
|
||||
serviceIndex={serviceIndex}
|
||||
portIndex={portIndex}
|
||||
value={servicePort.port}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const newServicePorts = [...servicePorts];
|
||||
newServicePorts[portIndex] = {
|
||||
...newServicePorts[portIndex],
|
||||
port:
|
||||
e.target.value === ''
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
};
|
||||
onChangePort(newServicePorts);
|
||||
}}
|
||||
/>
|
||||
{servicePortErrors?.port && (
|
||||
<FormError>{servicePortErrors.port}</FormError>
|
||||
)}
|
||||
</div>
|
||||
<ButtonSelector
|
||||
className="h-[30px]"
|
||||
onChange={(value) => {
|
||||
const newServicePorts = [...servicePorts];
|
||||
newServicePorts[portIndex] = {
|
||||
...newServicePorts[portIndex],
|
||||
protocol: value,
|
||||
};
|
||||
onChangePort(newServicePorts);
|
||||
}}
|
||||
value={servicePort.protocol || 'TCP'}
|
||||
options={[{ value: 'TCP' }, { value: 'UDP' }]}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
disabled={servicePorts.length === 1}
|
||||
size="small"
|
||||
className="!ml-0 h-[30px]"
|
||||
color="dangerlight"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// remove the port at the index in an immutable way
|
||||
const newServicePorts = [
|
||||
...servicePorts.slice(0, portIndex),
|
||||
...servicePorts.slice(portIndex + 1),
|
||||
];
|
||||
onChangePort(newServicePorts);
|
||||
}}
|
||||
data-cy={`k8sAppCreate-rmPortButton-${serviceIndex}-${portIndex}`}
|
||||
icon={Trash2}
|
||||
>
|
||||
Remove port
|
||||
</Button>
|
||||
</div>
|
||||
<AppIngressPathsForm
|
||||
servicePortIngressPaths={servicePorts[portIndex].ingressPaths}
|
||||
onChangeIngressPaths={(
|
||||
ingressPaths?: ServicePortIngressPath[]
|
||||
) => {
|
||||
const newServicePorts = [...servicePorts];
|
||||
newServicePorts[portIndex].ingressPaths = ingressPaths;
|
||||
onChangePort(newServicePorts);
|
||||
}}
|
||||
namespace={namespace}
|
||||
ingressPathsErrors={ingressPathsErrors}
|
||||
serviceIndex={serviceIndex}
|
||||
portIndex={portIndex}
|
||||
isEditMode={isEditMode}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
<div className="flex">
|
||||
<Button
|
||||
icon={Plus}
|
||||
color="default"
|
||||
className="!ml-0"
|
||||
onClick={() => {
|
||||
const newServicesPorts = [...servicePorts, newClusterIpPort];
|
||||
onChangePort(newServicesPorts);
|
||||
}}
|
||||
>
|
||||
Add port
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Widget.Body>
|
||||
</Widget>
|
||||
);
|
||||
}
|
|
@ -7,8 +7,13 @@ import { Card } from '@@/Card';
|
|||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { Button } from '@@/buttons';
|
||||
|
||||
import { generateUniqueName, newPort, serviceFormDefaultValues } from './utils';
|
||||
import { ServiceFormValues, ServicePort } from './types';
|
||||
import {
|
||||
generateUniqueName,
|
||||
newPort,
|
||||
serviceFormDefaultValues,
|
||||
} from '../utils';
|
||||
import { ServiceFormValues, ServicePort } from '../types';
|
||||
|
||||
import { ClusterIpServiceForm } from './ClusterIpServiceForm';
|
||||
|
||||
interface Props {
|
||||
|
@ -17,6 +22,8 @@ interface Props {
|
|||
errors?: FormikErrors<ServiceFormValues[]>;
|
||||
appName: string;
|
||||
selector: Record<string, string>;
|
||||
namespace?: string;
|
||||
isEditMode?: boolean;
|
||||
}
|
||||
|
||||
export function ClusterIpServicesForm({
|
||||
|
@ -25,6 +32,8 @@ export function ClusterIpServicesForm({
|
|||
errors,
|
||||
appName,
|
||||
selector,
|
||||
namespace,
|
||||
isEditMode,
|
||||
}: Props) {
|
||||
const clusterIPServiceCount = services.filter(
|
||||
(service) =>
|
||||
|
@ -56,6 +65,8 @@ export function ClusterIpServicesForm({
|
|||
services={services}
|
||||
serviceIndex={index}
|
||||
onChangeService={onChangeService}
|
||||
namespace={namespace}
|
||||
isEditMode={isEditMode}
|
||||
/>
|
||||
) : null
|
||||
)}
|
|
@ -3,26 +3,32 @@ import { ChangeEvent } from 'react';
|
|||
import { InputGroup } from '@@/form-components/InputGroup';
|
||||
|
||||
type Props = {
|
||||
index: number;
|
||||
serviceIndex: number;
|
||||
portIndex: number;
|
||||
value?: number;
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
|
||||
export function ContainerPortInput({ index, value, onChange }: Props) {
|
||||
export function ContainerPortInput({
|
||||
serviceIndex,
|
||||
portIndex,
|
||||
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}`}
|
||||
name={`container_port_${portIndex}`}
|
||||
placeholder="80"
|
||||
min="1"
|
||||
max="65535"
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
required
|
||||
data-cy={`k8sAppCreate-containerPort_${index}`}
|
||||
data-cy={`k8sAppCreate-containerPort-${serviceIndex}-${portIndex}`}
|
||||
/>
|
||||
</InputGroup>
|
||||
);
|
|
@ -3,26 +3,32 @@ import { ChangeEvent } from 'react';
|
|||
import { InputGroup } from '@@/form-components/InputGroup';
|
||||
|
||||
type Props = {
|
||||
index: number;
|
||||
serviceIndex: number;
|
||||
portIndex: number;
|
||||
value?: number;
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
|
||||
export function ServicePortInput({ index, value, onChange }: Props) {
|
||||
export function ServicePortInput({
|
||||
serviceIndex,
|
||||
portIndex,
|
||||
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}`}
|
||||
name={`service_port_${portIndex}`}
|
||||
placeholder="80"
|
||||
min="1"
|
||||
max="65535"
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
required
|
||||
data-cy={`k8sAppCreate-servicePort_${index}`}
|
||||
data-cy={`k8sAppCreate-servicePort-${serviceIndex}-${portIndex}`}
|
||||
/>
|
||||
</InputGroup>
|
||||
);
|
|
@ -0,0 +1,33 @@
|
|||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@@/Badge';
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
type Props = {
|
||||
serviceTypeLabel: string;
|
||||
serviceTypeCount: number;
|
||||
serviceTypeHasErrors: boolean;
|
||||
};
|
||||
|
||||
export function ServiceTabLabel({
|
||||
serviceTypeLabel,
|
||||
serviceTypeCount,
|
||||
serviceTypeHasErrors,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="inline-flex items-center">
|
||||
{serviceTypeLabel}
|
||||
{serviceTypeCount && (
|
||||
<Badge
|
||||
className="ml-2 flex-none"
|
||||
type={serviceTypeHasErrors ? 'warn' : 'info'}
|
||||
>
|
||||
{serviceTypeHasErrors && (
|
||||
<Icon icon={AlertTriangle} className="!mr-1" />
|
||||
)}
|
||||
{serviceTypeCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import clsx from 'clsx';
|
||||
|
||||
import { ServiceTypeOption, ServiceTypeValue } from './types';
|
||||
import { ServiceTypeOption, ServiceTypeValue } from '../types';
|
||||
|
||||
type Props = {
|
||||
serviceTypeOptions: ServiceTypeOption[];
|
|
@ -0,0 +1,161 @@
|
|||
import { RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { Ingress } from '@/react/kubernetes/ingresses/types';
|
||||
|
||||
import { Select } from '@@/form-components/ReactSelect';
|
||||
import { Button } from '@@/buttons';
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
import { InputGroup } from '@@/form-components/InputGroup';
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import { IngressOption, ServicePortIngressPath } from '../types';
|
||||
|
||||
type Props = {
|
||||
ingressPath?: ServicePortIngressPath;
|
||||
ingressPathErrors?: FormikErrors<ServicePortIngressPath>;
|
||||
ingressHostOptions: IngressOption[];
|
||||
onChangeIngressPath: (ingressPath: ServicePortIngressPath) => void;
|
||||
onRemoveIngressPath: () => void;
|
||||
ingressesQuery: UseQueryResult<Ingress[], unknown>;
|
||||
namespace?: string;
|
||||
isEditMode?: boolean;
|
||||
};
|
||||
|
||||
export function AppIngressPathForm({
|
||||
ingressPath,
|
||||
ingressPathErrors,
|
||||
ingressHostOptions,
|
||||
onChangeIngressPath,
|
||||
onRemoveIngressPath,
|
||||
ingressesQuery,
|
||||
namespace,
|
||||
isEditMode,
|
||||
}: Props) {
|
||||
const [selectedIngress, setSelectedIngress] = useState<IngressOption | null>(
|
||||
ingressHostOptions[0] ?? null
|
||||
);
|
||||
|
||||
// if editing allow the current value as an option,
|
||||
// to handle the case where they disallow the ingress class after creating the path
|
||||
const ingressHostOptionsWithCurrentValue = useMemo(() => {
|
||||
if (
|
||||
ingressHostOptions.length === 0 &&
|
||||
ingressPath?.Host &&
|
||||
ingressPath?.IngressName &&
|
||||
isEditMode
|
||||
) {
|
||||
return [
|
||||
{
|
||||
value: ingressPath.Host,
|
||||
label: ingressPath.Host,
|
||||
ingressName: ingressPath.IngressName,
|
||||
},
|
||||
];
|
||||
}
|
||||
return ingressHostOptions;
|
||||
}, [
|
||||
ingressHostOptions,
|
||||
ingressPath?.Host,
|
||||
ingressPath?.IngressName,
|
||||
isEditMode,
|
||||
]);
|
||||
|
||||
// when the hostname options change (e.g. after a namespace change), update the selected ingress to the first available one
|
||||
useEffect(() => {
|
||||
if (ingressHostOptionsWithCurrentValue) {
|
||||
const newIngressPath = {
|
||||
...ingressPath,
|
||||
Host: ingressHostOptionsWithCurrentValue[0]?.value,
|
||||
IngressName: ingressHostOptionsWithCurrentValue[0]?.ingressName,
|
||||
};
|
||||
onChangeIngressPath(newIngressPath);
|
||||
setSelectedIngress(ingressHostOptionsWithCurrentValue[0] ?? null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ingressHostOptionsWithCurrentValue]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-wrap gap-x-4">
|
||||
<div className="flex min-w-[250px] basis-1/3 flex-col">
|
||||
<InputGroup size="small">
|
||||
<InputGroup.Addon>Hostname</InputGroup.Addon>
|
||||
<Select
|
||||
options={ingressHostOptions}
|
||||
value={selectedIngress}
|
||||
defaultValue={ingressHostOptions[0]}
|
||||
placeholder="Select a hostname..."
|
||||
theme={(theme) => ({
|
||||
...theme,
|
||||
borderRadius: 0,
|
||||
})}
|
||||
size="sm"
|
||||
onChange={(ingressOption) => {
|
||||
setSelectedIngress(ingressOption);
|
||||
const newIngressPath = {
|
||||
...ingressPath,
|
||||
Host: ingressOption?.value,
|
||||
IngressName: ingressOption?.ingressName,
|
||||
};
|
||||
onChangeIngressPath(newIngressPath);
|
||||
}}
|
||||
/>
|
||||
<InputGroup.ButtonWrapper>
|
||||
<Button
|
||||
icon={RefreshCw}
|
||||
color="default"
|
||||
onClick={() => ingressesQuery.refetch()}
|
||||
/>
|
||||
</InputGroup.ButtonWrapper>
|
||||
</InputGroup>
|
||||
{ingressHostOptions.length === 0 && !ingressPath?.Host && (
|
||||
<FormError>
|
||||
No ingress hostnames are available for the namespace '
|
||||
{namespace}'. Please update the namespace or{' '}
|
||||
<Link
|
||||
to="kubernetes.ingresses.create"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
create an ingress
|
||||
</Link>
|
||||
.
|
||||
</FormError>
|
||||
)}
|
||||
{ingressPathErrors?.Host && ingressHostOptions.length > 0 && (
|
||||
<FormError>{ingressPathErrors?.Host}</FormError>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-[250px] basis-1/3 flex-col">
|
||||
<InputGroup size="small">
|
||||
<InputGroup.Addon required>Path</InputGroup.Addon>
|
||||
<InputGroup.Input
|
||||
value={ingressPath?.Path ?? ''}
|
||||
placeholder="/example"
|
||||
onChange={(e) => {
|
||||
const newIngressPath = {
|
||||
...ingressPath,
|
||||
Path: e.target.value,
|
||||
};
|
||||
onChangeIngressPath(newIngressPath);
|
||||
}}
|
||||
/>
|
||||
</InputGroup>
|
||||
{ingressPathErrors?.Path && (
|
||||
<FormError>{ingressPathErrors?.Path}</FormError>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<Button
|
||||
icon={Trash2}
|
||||
color="dangerlight"
|
||||
size="medium"
|
||||
className="!ml-0"
|
||||
onClick={() => onRemoveIngressPath()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,171 @@
|
|||
import { Plus } from 'lucide-react';
|
||||
import { FormikErrors } from 'formik';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import {
|
||||
useIngressControllers,
|
||||
useIngresses,
|
||||
} from '@/react/kubernetes/ingresses/queries';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
import { Button } from '@@/buttons';
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import { ServicePortIngressPath } from '../types';
|
||||
|
||||
import { AppIngressPathForm } from './AppIngressPathForm';
|
||||
|
||||
type Props = {
|
||||
servicePortIngressPaths?: ServicePortIngressPath[];
|
||||
onChangeIngressPaths: (ingressPath: ServicePortIngressPath[]) => void;
|
||||
namespace?: string;
|
||||
ingressPathsErrors?: FormikErrors<ServicePortIngressPath[]>;
|
||||
serviceIndex: number;
|
||||
portIndex: number;
|
||||
isEditMode?: boolean;
|
||||
};
|
||||
|
||||
export function AppIngressPathsForm({
|
||||
servicePortIngressPaths,
|
||||
onChangeIngressPaths,
|
||||
namespace,
|
||||
ingressPathsErrors,
|
||||
serviceIndex,
|
||||
portIndex,
|
||||
isEditMode,
|
||||
}: Props) {
|
||||
const environmentId = useEnvironmentId();
|
||||
const ingressesQuery = useIngresses(
|
||||
environmentId,
|
||||
namespace ? [namespace] : undefined
|
||||
);
|
||||
const { data: ingresses } = ingressesQuery;
|
||||
const ingressControllersQuery = useIngressControllers(
|
||||
environmentId,
|
||||
namespace
|
||||
);
|
||||
const { data: ingressControllers } = ingressControllersQuery;
|
||||
|
||||
// if some ingress controllers are restricted by namespace, then filter the ingresses that use allowed ingress controllers
|
||||
const allowedIngressHostNameOptions = useMemo(() => {
|
||||
const allowedIngressClasses =
|
||||
ingressControllers
|
||||
?.filter((ic) => ic.Availability)
|
||||
.map((ic) => ic.ClassName) || [];
|
||||
const allowedIngresses =
|
||||
ingresses?.filter((ing) =>
|
||||
allowedIngressClasses.includes(ing.ClassName)
|
||||
) || [];
|
||||
return allowedIngresses.flatMap((ing) =>
|
||||
ing.Hosts?.length
|
||||
? ing.Hosts.map((host) => ({
|
||||
label: `${host} (${ing.Name})`,
|
||||
value: host,
|
||||
ingressName: ing.Name,
|
||||
}))
|
||||
: []
|
||||
);
|
||||
}, [ingressControllers, ingresses]);
|
||||
|
||||
if (ingressesQuery.isError || ingressControllersQuery.isError) {
|
||||
return <FormError>Unable to load ingresses.</FormError>;
|
||||
}
|
||||
|
||||
if (ingressesQuery.isLoading || ingressControllersQuery.isLoading) {
|
||||
return <p>Loading ingresses...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div className="!mb-0 flex w-full flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<SwitchField
|
||||
fieldClass="w-max gap-x-8"
|
||||
label="Expose via ingress"
|
||||
tooltip="Expose this ClusterIP service externally using an ingress. This will create a new ingress path for the selected ingress hostname."
|
||||
labelClass="w-max"
|
||||
name="publish-ingress"
|
||||
checked={!!servicePortIngressPaths?.length}
|
||||
onChange={(value) => {
|
||||
const newIngressPathsValue = value
|
||||
? [
|
||||
{
|
||||
Host: allowedIngressHostNameOptions[0]?.value ?? '',
|
||||
IngressName:
|
||||
allowedIngressHostNameOptions[0]?.ingressName ?? '',
|
||||
Path: '',
|
||||
},
|
||||
]
|
||||
: [];
|
||||
onChangeIngressPaths(newIngressPathsValue);
|
||||
}}
|
||||
data-cy={`applicationCreate-publishIngress-${serviceIndex}-${portIndex}`}
|
||||
/>
|
||||
{!!servicePortIngressPaths?.length && (
|
||||
<TextTip color="blue">
|
||||
Select from available ingresses below, or add new or edit existing
|
||||
ones via the{' '}
|
||||
<Link
|
||||
to="kubernetes.ingresses"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Ingresses screen
|
||||
</Link>{' '}
|
||||
and then reload the hostname dropdown.
|
||||
</TextTip>
|
||||
)}
|
||||
</div>
|
||||
{ingressesQuery.isSuccess && ingressControllersQuery.isSuccess
|
||||
? servicePortIngressPaths?.map((ingressPath, index) => (
|
||||
<AppIngressPathForm
|
||||
key={index}
|
||||
ingressPath={ingressPath}
|
||||
ingressPathErrors={ingressPathsErrors?.[index]}
|
||||
ingressHostOptions={allowedIngressHostNameOptions}
|
||||
onChangeIngressPath={(ingressPath: ServicePortIngressPath) => {
|
||||
const newIngressPaths = structuredClone(
|
||||
servicePortIngressPaths
|
||||
);
|
||||
newIngressPaths[index] = ingressPath;
|
||||
onChangeIngressPaths(newIngressPaths);
|
||||
}}
|
||||
onRemoveIngressPath={() => {
|
||||
const newIngressPaths = structuredClone(
|
||||
servicePortIngressPaths
|
||||
);
|
||||
newIngressPaths.splice(index, 1);
|
||||
onChangeIngressPaths(newIngressPaths);
|
||||
}}
|
||||
ingressesQuery={ingressesQuery}
|
||||
namespace={namespace}
|
||||
isEditMode={isEditMode}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
{!!servicePortIngressPaths?.length && (
|
||||
<div className="flex w-full flex-wrap gap-2">
|
||||
<Button
|
||||
icon={Plus}
|
||||
className="!ml-0"
|
||||
size="small"
|
||||
color="default"
|
||||
onClick={() => {
|
||||
const newIngressPaths = structuredClone(servicePortIngressPaths);
|
||||
newIngressPaths.push({
|
||||
Host: allowedIngressHostNameOptions[0]?.value,
|
||||
IngressName: allowedIngressHostNameOptions[0]?.ingressName,
|
||||
Path: '',
|
||||
});
|
||||
onChangeIngressPaths(newIngressPaths);
|
||||
}}
|
||||
>
|
||||
Add path
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,311 @@
|
|||
import { SchemaOf, array, object, boolean, string, mixed, number } from 'yup';
|
||||
|
||||
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
|
||||
|
||||
import { ServiceFormValues, ServicePort } from './types';
|
||||
import { prependWithSlash } from './utils';
|
||||
|
||||
// values returned from the angular parent component (pascal case instead of camel case keys),
|
||||
// these should match the form values, but don't. Future tech debt work to update this would be nice
|
||||
|
||||
// to make the converted values and formValues objects to be the same
|
||||
interface NodePortValues {
|
||||
Port: number;
|
||||
TargetPort: number;
|
||||
NodePort: number;
|
||||
Name?: string;
|
||||
Protocol?: string;
|
||||
Ingress?: string;
|
||||
}
|
||||
|
||||
type ServiceValues = {
|
||||
Type: number;
|
||||
Name: string;
|
||||
Ports: NodePortValues[];
|
||||
};
|
||||
|
||||
type AngularIngressPath = {
|
||||
IngressName: string;
|
||||
Host: string;
|
||||
Path: string;
|
||||
};
|
||||
|
||||
type AppServicesValidationData = {
|
||||
nodePortServices: ServiceValues[];
|
||||
formServices: ServiceFormValues[];
|
||||
ingressPaths?: AngularIngressPath[];
|
||||
originalIngressPaths?: AngularIngressPath[];
|
||||
};
|
||||
|
||||
export function kubeServicesValidation(
|
||||
validationData?: AppServicesValidationData
|
||||
): SchemaOf<ServiceFormValues[]> {
|
||||
return array(
|
||||
object({
|
||||
Headless: boolean().required(),
|
||||
Namespace: string(),
|
||||
Name: string(),
|
||||
StackName: string(),
|
||||
Type: mixed().oneOf([
|
||||
KubernetesApplicationPublishingTypes.CLUSTER_IP,
|
||||
KubernetesApplicationPublishingTypes.NODE_PORT,
|
||||
KubernetesApplicationPublishingTypes.LOAD_BALANCER,
|
||||
]),
|
||||
ClusterIP: string(),
|
||||
ApplicationName: string(),
|
||||
ApplicationOwner: string(),
|
||||
Note: string(),
|
||||
Ingress: boolean().required(),
|
||||
Selector: object(),
|
||||
Ports: array(
|
||||
object({
|
||||
port: number()
|
||||
.required('Service port number is required.')
|
||||
.min(1, 'Service port number must be inside the range 1-65535.')
|
||||
.max(65535, 'Service port number must be inside the range 1-65535.')
|
||||
.test(
|
||||
'service-port-is-unique',
|
||||
'Service port number must be unique.',
|
||||
(servicePort, context) => {
|
||||
// test for duplicate service ports within this service.
|
||||
// yup gives access to context.parent which gives one ServicePort object.
|
||||
// yup also gives access to all form values through this.options.context.
|
||||
// Unfortunately, it doesn't give direct access to all Ports within the current service.
|
||||
// To find all ports in the service for validation, I'm filtering the services by the service name,
|
||||
// that's stored in the ServicePort object, then getting all Ports in the service.
|
||||
if (servicePort === undefined || validationData === undefined) {
|
||||
return true;
|
||||
}
|
||||
const { formServices } = validationData;
|
||||
const matchingService = getServiceForPort(
|
||||
context.parent as ServicePort,
|
||||
formServices
|
||||
);
|
||||
if (matchingService === undefined) {
|
||||
return true;
|
||||
}
|
||||
const servicePorts = matchingService.Ports;
|
||||
const duplicateServicePortCount = servicePorts.filter(
|
||||
(port) => port.port === servicePort
|
||||
).length;
|
||||
return duplicateServicePortCount <= 1;
|
||||
}
|
||||
),
|
||||
targetPort: number()
|
||||
.required('Container port number is required.')
|
||||
.min(1, 'Container port number must be inside the range 1-65535.')
|
||||
.max(
|
||||
65535,
|
||||
'Container port number must be inside the range 1-65535.'
|
||||
),
|
||||
name: string(),
|
||||
serviceName: string(),
|
||||
protocol: string(),
|
||||
nodePort: number()
|
||||
.test(
|
||||
'node-port-is-unique-in-service',
|
||||
'Node port is already used in this service.',
|
||||
(nodePort, context) => {
|
||||
if (nodePort === undefined || validationData === undefined) {
|
||||
return true;
|
||||
}
|
||||
const { formServices } = validationData;
|
||||
const matchingService = getServiceForPort(
|
||||
context.parent as ServicePort,
|
||||
formServices
|
||||
);
|
||||
if (
|
||||
matchingService === undefined ||
|
||||
matchingService.Type !==
|
||||
KubernetesApplicationPublishingTypes.NODE_PORT // ignore validation unless the service is of type nodeport
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const servicePorts = matchingService.Ports;
|
||||
const duplicateNodePortCount = servicePorts.filter(
|
||||
(port) => port.nodePort === nodePort
|
||||
).length;
|
||||
return duplicateNodePortCount <= 1;
|
||||
}
|
||||
)
|
||||
.test(
|
||||
'node-port-is-unique-in-cluster',
|
||||
'Node port is already used.',
|
||||
(nodePort, context) => {
|
||||
if (nodePort === undefined || validationData === undefined) {
|
||||
return true;
|
||||
}
|
||||
const { formServices, nodePortServices } = validationData;
|
||||
const matchingService = getServiceForPort(
|
||||
context.parent as ServicePort,
|
||||
formServices
|
||||
);
|
||||
|
||||
if (
|
||||
matchingService === undefined ||
|
||||
matchingService.Type !==
|
||||
KubernetesApplicationPublishingTypes.NODE_PORT // ignore validation unless the service is of type nodeport
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// create a list of all the node ports (number[]) in the cluster, from services that aren't in the application form
|
||||
const formServiceNames = formServices.map(
|
||||
(formService) => formService.Name
|
||||
);
|
||||
const clusterNodePortsWithoutFormServices = nodePortServices
|
||||
.filter(
|
||||
(npService) => !formServiceNames.includes(npService.Name)
|
||||
)
|
||||
.flatMap((npService) => npService.Ports)
|
||||
.map((npServicePorts) => npServicePorts.NodePort);
|
||||
// node ports in the current form, excluding the current service
|
||||
const formNodePortsWithoutCurrentService = formServices
|
||||
.filter(
|
||||
(formService) =>
|
||||
formService.Type ===
|
||||
KubernetesApplicationPublishingTypes.NODE_PORT &&
|
||||
formService.Name !== matchingService.Name
|
||||
)
|
||||
.flatMap((formService) => formService.Ports)
|
||||
.map((formServicePorts) => formServicePorts.nodePort);
|
||||
return (
|
||||
!clusterNodePortsWithoutFormServices.includes(nodePort) && // node port is not in the cluster services that aren't in the application form
|
||||
!formNodePortsWithoutCurrentService.includes(nodePort) // and the node port is not in the current form, excluding the current service
|
||||
);
|
||||
}
|
||||
)
|
||||
.test(
|
||||
'node-port-minimum',
|
||||
'Nodeport number must be inside the range 30000-32767 or blank for system allocated.',
|
||||
(nodePort, context) => {
|
||||
if (nodePort === undefined || validationData === undefined) {
|
||||
return true;
|
||||
}
|
||||
const { formServices } = validationData;
|
||||
const matchingService = getServiceForPort(
|
||||
context.parent as ServicePort,
|
||||
formServices
|
||||
);
|
||||
if (
|
||||
!matchingService ||
|
||||
matchingService.Type !==
|
||||
KubernetesApplicationPublishingTypes.NODE_PORT
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return nodePort >= 30000;
|
||||
}
|
||||
)
|
||||
.test(
|
||||
'node-port-maximum',
|
||||
'Nodeport number must be inside the range 30000-32767 or blank for system allocated.',
|
||||
(nodePort, context) => {
|
||||
if (nodePort === undefined || validationData === undefined) {
|
||||
return true;
|
||||
}
|
||||
const { formServices } = validationData;
|
||||
const matchingService = getServiceForPort(
|
||||
context.parent as ServicePort,
|
||||
formServices
|
||||
);
|
||||
if (
|
||||
!matchingService ||
|
||||
matchingService.Type !==
|
||||
KubernetesApplicationPublishingTypes.NODE_PORT
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return nodePort <= 32767;
|
||||
}
|
||||
),
|
||||
ingressPaths: array(
|
||||
object({
|
||||
IngressName: string().required(),
|
||||
Host: string().required('Ingress hostname is required.'),
|
||||
Path: string()
|
||||
.required('Ingress path is required.')
|
||||
.test(
|
||||
'path-is-unique',
|
||||
'Ingress path is already in use for this hostname.',
|
||||
(path, context) => {
|
||||
if (path === undefined || validationData === undefined) {
|
||||
return true;
|
||||
}
|
||||
const ingressHostAndPath = `${
|
||||
context.parent.Host
|
||||
}${prependWithSlash(path)}`;
|
||||
const {
|
||||
ingressPaths: ingressPathsInNamespace,
|
||||
formServices,
|
||||
originalIngressPaths,
|
||||
} = validationData;
|
||||
|
||||
// get the count of the same ingressHostAndPath in the current form values
|
||||
const allFormServicePortIngresses = formServices.flatMap(
|
||||
(service) =>
|
||||
service.Ports.flatMap((port) => port.ingressPaths)
|
||||
);
|
||||
const formMatchingIngressHostPathCount =
|
||||
allFormServicePortIngresses
|
||||
.filter((ingress) => ingress?.Host !== '')
|
||||
.map(
|
||||
(ingress) =>
|
||||
`${ingress?.Host}${prependWithSlash(ingress?.Path)}`
|
||||
)
|
||||
.filter(
|
||||
(formIngressHostAndPath) =>
|
||||
formIngressHostAndPath === ingressHostAndPath
|
||||
).length;
|
||||
|
||||
// get the count of the same ingressHostAndPath in the namespace and subtract the count from the original form values
|
||||
const nsMatchingIngressHostPathCount = (
|
||||
ingressPathsInNamespace ?? []
|
||||
)
|
||||
.map(
|
||||
(ingressPath) =>
|
||||
`${ingressPath.Host}${ingressPath.Path}`
|
||||
)
|
||||
.filter(
|
||||
(nsIngressHostAndPath) =>
|
||||
nsIngressHostAndPath === ingressHostAndPath
|
||||
).length;
|
||||
|
||||
// get the count of the same ingressHostAndPath in the original form values
|
||||
const originalMatchingIngressHostPathCount = (
|
||||
originalIngressPaths ?? []
|
||||
)
|
||||
.map(
|
||||
(ingressPath) =>
|
||||
`${ingressPath.Host}${ingressPath.Path}`
|
||||
)
|
||||
.filter(
|
||||
(originalIngressHostAndPath) =>
|
||||
originalIngressHostAndPath === ingressHostAndPath
|
||||
).length;
|
||||
|
||||
// for the current ingressHostAndPath to be unique, nsMatchingIngressHostPathCount - originalMatchingIngressHostPathCount + formMatchingIngressHostPathCount must be 1 or less.
|
||||
const pathIsUnique =
|
||||
formMatchingIngressHostPathCount === 1 &&
|
||||
nsMatchingIngressHostPathCount -
|
||||
originalMatchingIngressHostPathCount +
|
||||
formMatchingIngressHostPathCount <=
|
||||
1;
|
||||
return pathIsUnique;
|
||||
}
|
||||
),
|
||||
})
|
||||
),
|
||||
})
|
||||
),
|
||||
Annotations: array(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function getServiceForPort(
|
||||
servicePort: ServicePort,
|
||||
services: ServiceFormValues[]
|
||||
) {
|
||||
return services.find((service) => service.Name === servicePort.serviceName);
|
||||
}
|
|
@ -0,0 +1,238 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
import { ChangeEvent, useRef } from 'react';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector';
|
||||
import { Button } from '@@/buttons';
|
||||
import { Widget } from '@@/Widget';
|
||||
import { Card } from '@@/Card';
|
||||
import { InputGroup } from '@@/form-components/InputGroup';
|
||||
|
||||
import { isErrorType, newPort } from '../utils';
|
||||
import { ContainerPortInput } from '../components/ContainerPortInput';
|
||||
import {
|
||||
ServiceFormValues,
|
||||
ServicePort,
|
||||
ServicePortIngressPath,
|
||||
} from '../types';
|
||||
import { ServicePortInput } from '../components/ServicePortInput';
|
||||
import { AppIngressPathsForm } from '../ingress/AppIngressPathsForm';
|
||||
|
||||
interface Props {
|
||||
services: ServiceFormValues[];
|
||||
serviceIndex: number;
|
||||
onChangeService: (services: ServiceFormValues[]) => void;
|
||||
servicePorts: ServicePort[];
|
||||
onChangePort: (servicePorts: ServicePort[]) => void;
|
||||
serviceName?: string;
|
||||
errors?: string | string[] | FormikErrors<ServicePort>[];
|
||||
namespace?: string;
|
||||
isEditMode?: boolean;
|
||||
}
|
||||
|
||||
export function LoadBalancerServiceForm({
|
||||
services,
|
||||
serviceIndex,
|
||||
onChangeService,
|
||||
servicePorts,
|
||||
onChangePort,
|
||||
errors,
|
||||
serviceName,
|
||||
namespace,
|
||||
isEditMode,
|
||||
}: Props) {
|
||||
const newLoadBalancerPort = newPort(serviceName); // If there's initially a ingress path for the service, allow editing it
|
||||
const { current: initiallyHasIngressPath } = useRef(
|
||||
services[serviceIndex].Ports.some((port) => port.ingressPaths?.length)
|
||||
);
|
||||
|
||||
return (
|
||||
<Widget key={serviceIndex}>
|
||||
<Widget.Body>
|
||||
<div className="flex justify-between">
|
||||
<div className="text-muted vertical-center">LoadBalancer</div>
|
||||
<Button
|
||||
icon={Trash2}
|
||||
color="dangerlight"
|
||||
className="!ml-0"
|
||||
onClick={() => {
|
||||
// remove the service at index in an immutable way
|
||||
const newServices = [
|
||||
...services.slice(0, serviceIndex),
|
||||
...services.slice(serviceIndex + 1),
|
||||
];
|
||||
onChangeService(newServices);
|
||||
}}
|
||||
>
|
||||
Remove service
|
||||
</Button>
|
||||
</div>
|
||||
<div className="control-label !mb-2 !pt-0 text-left">Ports</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{servicePorts.map((servicePort, portIndex) => {
|
||||
const error = errors?.[portIndex];
|
||||
const servicePortErrors = isErrorType<ServicePort>(error)
|
||||
? error
|
||||
: undefined;
|
||||
const ingressPathsErrors = isErrorType<ServicePortIngressPath[]>(
|
||||
servicePortErrors?.ingressPaths
|
||||
)
|
||||
? servicePortErrors?.ingressPaths
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Card key={portIndex} className="flex flex-col gap-y-3">
|
||||
<div className="flex flex-grow flex-wrap justify-between gap-x-4 gap-y-1">
|
||||
<div className="inline-flex min-w-min flex-grow basis-3/4 flex-wrap gap-2">
|
||||
<div className="flex min-w-min basis-1/4 flex-col">
|
||||
<ContainerPortInput
|
||||
serviceIndex={serviceIndex}
|
||||
portIndex={portIndex}
|
||||
value={servicePort.targetPort}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const newServicePorts = [...servicePorts];
|
||||
const newValue =
|
||||
e.target.value === ''
|
||||
? undefined
|
||||
: Number(e.target.value);
|
||||
newServicePorts[portIndex] = {
|
||||
...newServicePorts[portIndex],
|
||||
targetPort: newValue,
|
||||
port: newValue,
|
||||
};
|
||||
onChangePort(newServicePorts);
|
||||
}}
|
||||
/>
|
||||
{servicePortErrors?.targetPort && (
|
||||
<FormError>{servicePortErrors.targetPort}</FormError>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-min basis-1/4 flex-col">
|
||||
<ServicePortInput
|
||||
serviceIndex={serviceIndex}
|
||||
portIndex={portIndex}
|
||||
value={servicePort.port}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const newServicePorts = [...servicePorts];
|
||||
newServicePorts[portIndex] = {
|
||||
...newServicePorts[portIndex],
|
||||
port:
|
||||
e.target.value === ''
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
};
|
||||
onChangePort(newServicePorts);
|
||||
}}
|
||||
/>
|
||||
{servicePortErrors?.port && (
|
||||
<FormError>{servicePortErrors.port}</FormError>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-min basis-1/4 flex-col">
|
||||
<InputGroup size="small">
|
||||
<InputGroup.Addon required>
|
||||
Loadbalancer port
|
||||
</InputGroup.Addon>
|
||||
<InputGroup.Input
|
||||
type="number"
|
||||
className="form-control min-w-max"
|
||||
name={`loadbalancer_port_${portIndex}`}
|
||||
placeholder="80"
|
||||
min="1"
|
||||
max="65535"
|
||||
value={servicePort.port || ''}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const newServicePorts = [...servicePorts];
|
||||
newServicePorts[portIndex] = {
|
||||
...newServicePorts[portIndex],
|
||||
port:
|
||||
e.target.value === ''
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
};
|
||||
onChangePort(newServicePorts);
|
||||
}}
|
||||
required
|
||||
data-cy={`k8sAppCreate-loadbalancerPort_${portIndex}`}
|
||||
/>
|
||||
</InputGroup>
|
||||
{servicePortErrors?.nodePort && (
|
||||
<FormError>{servicePortErrors.nodePort}</FormError>
|
||||
)}
|
||||
</div>
|
||||
<ButtonSelector
|
||||
className="h-[30px]"
|
||||
onChange={(value) => {
|
||||
const newServicePorts = [...servicePorts];
|
||||
newServicePorts[portIndex] = {
|
||||
...newServicePorts[portIndex],
|
||||
protocol: value,
|
||||
};
|
||||
onChangePort(newServicePorts);
|
||||
}}
|
||||
value={servicePort.protocol || 'TCP'}
|
||||
options={[{ value: 'TCP' }, { value: 'UDP' }]}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
disabled={servicePorts.length === 1}
|
||||
size="small"
|
||||
className="!ml-0 h-[30px]"
|
||||
color="dangerlight"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// remove the port at the index in an immutable way
|
||||
const newServicePorts = [
|
||||
...servicePorts.slice(0, portIndex),
|
||||
...servicePorts.slice(portIndex + 1),
|
||||
];
|
||||
onChangePort(newServicePorts);
|
||||
}}
|
||||
data-cy={`k8sAppCreate-rmPortButton_${portIndex}`}
|
||||
icon={Trash2}
|
||||
>
|
||||
Remove port
|
||||
</Button>
|
||||
</div>
|
||||
{initiallyHasIngressPath && (
|
||||
<AppIngressPathsForm
|
||||
servicePortIngressPaths={
|
||||
servicePorts[portIndex].ingressPaths
|
||||
}
|
||||
onChangeIngressPaths={(
|
||||
ingressPaths?: ServicePortIngressPath[]
|
||||
) => {
|
||||
const newServicePorts = [...servicePorts];
|
||||
newServicePorts[portIndex].ingressPaths = ingressPaths;
|
||||
onChangePort(newServicePorts);
|
||||
}}
|
||||
namespace={namespace}
|
||||
ingressPathsErrors={ingressPathsErrors}
|
||||
serviceIndex={serviceIndex}
|
||||
portIndex={portIndex}
|
||||
isEditMode={isEditMode}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
<div className="flex">
|
||||
<Button
|
||||
icon={Plus}
|
||||
color="default"
|
||||
className="!ml-0"
|
||||
onClick={() => {
|
||||
const newServicesPorts = [...servicePorts, newLoadBalancerPort];
|
||||
onChangePort(newServicesPorts);
|
||||
}}
|
||||
>
|
||||
Add port
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Widget.Body>
|
||||
</Widget>
|
||||
);
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { Plus, RotateCw } from 'lucide-react';
|
||||
import { Plus, RefreshCw } from 'lucide-react';
|
||||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
|
||||
|
@ -12,8 +12,13 @@ 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 {
|
||||
generateUniqueName,
|
||||
newPort,
|
||||
serviceFormDefaultValues,
|
||||
} from '../utils';
|
||||
import { ServiceFormValues, ServicePort } from '../types';
|
||||
|
||||
import { LoadBalancerServiceForm } from './LoadBalancerServiceForm';
|
||||
|
||||
interface Props {
|
||||
|
@ -22,6 +27,8 @@ interface Props {
|
|||
errors?: FormikErrors<ServiceFormValues[]>;
|
||||
appName: string;
|
||||
selector: Record<string, string>;
|
||||
namespace?: string;
|
||||
isEditMode?: boolean;
|
||||
}
|
||||
|
||||
export function LoadBalancerServicesForm({
|
||||
|
@ -30,6 +37,8 @@ export function LoadBalancerServicesForm({
|
|||
errors,
|
||||
appName,
|
||||
selector,
|
||||
namespace,
|
||||
isEditMode,
|
||||
}: Props) {
|
||||
const { isAdmin } = useCurrentUser();
|
||||
const environmentId = useEnvironmentId();
|
||||
|
@ -73,7 +82,7 @@ export function LoadBalancerServicesForm({
|
|||
</FormError>
|
||||
<div className="flex">
|
||||
<Button
|
||||
icon={RotateCw}
|
||||
icon={RefreshCw}
|
||||
color="default"
|
||||
className="!ml-0"
|
||||
onClick={() => loadBalancerEnabledQuery.refetch()}
|
||||
|
@ -101,6 +110,8 @@ export function LoadBalancerServicesForm({
|
|||
services={services}
|
||||
serviceIndex={index}
|
||||
onChangeService={onChangeService}
|
||||
namespace={namespace}
|
||||
isEditMode={isEditMode}
|
||||
/>
|
||||
) : null
|
||||
)}
|
|
@ -0,0 +1,236 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
import { ChangeEvent, useRef } from 'react';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector';
|
||||
import { Button } from '@@/buttons';
|
||||
import { Widget } from '@@/Widget';
|
||||
import { Card } from '@@/Card';
|
||||
import { InputGroup } from '@@/form-components/InputGroup';
|
||||
|
||||
import { isErrorType, newPort } from '../utils';
|
||||
import { ContainerPortInput } from '../components/ContainerPortInput';
|
||||
import {
|
||||
ServiceFormValues,
|
||||
ServicePort,
|
||||
ServicePortIngressPath,
|
||||
} from '../types';
|
||||
import { ServicePortInput } from '../components/ServicePortInput';
|
||||
import { AppIngressPathsForm } from '../ingress/AppIngressPathsForm';
|
||||
|
||||
interface Props {
|
||||
services: ServiceFormValues[];
|
||||
serviceIndex: number;
|
||||
onChangeService: (services: ServiceFormValues[]) => void;
|
||||
servicePorts: ServicePort[];
|
||||
onChangePort: (servicePorts: ServicePort[]) => void;
|
||||
serviceName?: string;
|
||||
errors?: string | string[] | FormikErrors<ServicePort>[];
|
||||
namespace?: string;
|
||||
isEditMode?: boolean;
|
||||
}
|
||||
|
||||
export function NodePortServiceForm({
|
||||
services,
|
||||
serviceIndex,
|
||||
onChangeService,
|
||||
servicePorts,
|
||||
onChangePort,
|
||||
errors,
|
||||
serviceName,
|
||||
namespace,
|
||||
isEditMode,
|
||||
}: Props) {
|
||||
const newNodePortPort = newPort(serviceName);
|
||||
// If there's initially a ingress path for the service, allow editing it
|
||||
const { current: initiallyHasIngressPath } = useRef(
|
||||
services[serviceIndex].Ports.some((port) => port.ingressPaths?.length)
|
||||
);
|
||||
|
||||
return (
|
||||
<Widget key={serviceIndex}>
|
||||
<Widget.Body>
|
||||
<div className="flex justify-between">
|
||||
<div className="text-muted vertical-center">NodePort</div>
|
||||
<Button
|
||||
icon={Trash2}
|
||||
color="dangerlight"
|
||||
className="!ml-0"
|
||||
onClick={() => {
|
||||
// remove the service at index in an immutable way
|
||||
const newServices = [
|
||||
...services.slice(0, serviceIndex),
|
||||
...services.slice(serviceIndex + 1),
|
||||
];
|
||||
onChangeService(newServices);
|
||||
}}
|
||||
>
|
||||
Remove service
|
||||
</Button>
|
||||
</div>
|
||||
<div className="control-label !mb-2 !pt-0 text-left">Ports</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{servicePorts.map((servicePort, portIndex) => {
|
||||
const error = errors?.[portIndex];
|
||||
const servicePortErrors = isErrorType<ServicePort>(error)
|
||||
? error
|
||||
: undefined;
|
||||
const ingressPathsErrors = isErrorType<ServicePortIngressPath[]>(
|
||||
servicePortErrors?.ingressPaths
|
||||
)
|
||||
? servicePortErrors?.ingressPaths
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Card key={portIndex} className="flex flex-col gap-y-3">
|
||||
<div className="flex flex-grow flex-wrap justify-between gap-x-4 gap-y-1">
|
||||
<div className="inline-flex min-w-min flex-grow basis-3/4 flex-wrap gap-2">
|
||||
<div className="flex min-w-min basis-1/4 flex-col">
|
||||
<ContainerPortInput
|
||||
serviceIndex={serviceIndex}
|
||||
portIndex={portIndex}
|
||||
value={servicePort.targetPort}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const newServicePorts = [...servicePorts];
|
||||
const newValue =
|
||||
e.target.value === ''
|
||||
? undefined
|
||||
: Number(e.target.value);
|
||||
newServicePorts[portIndex] = {
|
||||
...newServicePorts[portIndex],
|
||||
targetPort: newValue,
|
||||
port: newValue,
|
||||
};
|
||||
onChangePort(newServicePorts);
|
||||
}}
|
||||
/>
|
||||
{servicePortErrors?.targetPort && (
|
||||
<FormError>{servicePortErrors.targetPort}</FormError>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-min basis-1/4 flex-col">
|
||||
<ServicePortInput
|
||||
serviceIndex={serviceIndex}
|
||||
portIndex={portIndex}
|
||||
value={servicePort.port}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const newServicePorts = [...servicePorts];
|
||||
newServicePorts[portIndex] = {
|
||||
...newServicePorts[portIndex],
|
||||
port:
|
||||
e.target.value === ''
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
};
|
||||
onChangePort(newServicePorts);
|
||||
}}
|
||||
/>
|
||||
{servicePortErrors?.port && (
|
||||
<FormError>{servicePortErrors.port}</FormError>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-min basis-1/4 flex-col">
|
||||
<InputGroup size="small">
|
||||
<InputGroup.Addon>Nodeport</InputGroup.Addon>
|
||||
<InputGroup.Input
|
||||
type="number"
|
||||
className="form-control min-w-max"
|
||||
name={`node_port_${portIndex}`}
|
||||
placeholder="30080"
|
||||
min="30000"
|
||||
max="32767"
|
||||
value={servicePort.nodePort ?? ''}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const newServicePorts = [...servicePorts];
|
||||
newServicePorts[portIndex] = {
|
||||
...newServicePorts[portIndex],
|
||||
nodePort:
|
||||
e.target.value === ''
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
};
|
||||
onChangePort(newServicePorts);
|
||||
}}
|
||||
data-cy={`k8sAppCreate-nodePort_${portIndex}`}
|
||||
/>
|
||||
</InputGroup>
|
||||
{servicePortErrors?.nodePort && (
|
||||
<FormError>{servicePortErrors.nodePort}</FormError>
|
||||
)}
|
||||
</div>
|
||||
<ButtonSelector
|
||||
className="h-[30px]"
|
||||
onChange={(value) => {
|
||||
const newServicePorts = [...servicePorts];
|
||||
newServicePorts[portIndex] = {
|
||||
...newServicePorts[portIndex],
|
||||
protocol: value,
|
||||
};
|
||||
onChangePort(newServicePorts);
|
||||
}}
|
||||
value={servicePort.protocol || 'TCP'}
|
||||
options={[{ value: 'TCP' }, { value: 'UDP' }]}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
disabled={servicePorts.length === 1}
|
||||
size="small"
|
||||
className="!ml-0 h-[30px]"
|
||||
color="dangerlight"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// remove the port at the index in an immutable way
|
||||
const newServicePorts = [
|
||||
...servicePorts.slice(0, portIndex),
|
||||
...servicePorts.slice(portIndex + 1),
|
||||
];
|
||||
onChangePort(newServicePorts);
|
||||
}}
|
||||
data-cy={`k8sAppCreate-rmPortButton_${portIndex}`}
|
||||
icon={Trash2}
|
||||
>
|
||||
Remove port
|
||||
</Button>
|
||||
</div>
|
||||
{initiallyHasIngressPath && (
|
||||
<AppIngressPathsForm
|
||||
servicePortIngressPaths={
|
||||
servicePorts[portIndex].ingressPaths
|
||||
}
|
||||
onChangeIngressPaths={(
|
||||
ingressPaths?: ServicePortIngressPath[]
|
||||
) => {
|
||||
const newServicePorts = [...servicePorts];
|
||||
newServicePorts[portIndex].ingressPaths = ingressPaths;
|
||||
onChangePort(newServicePorts);
|
||||
}}
|
||||
namespace={namespace}
|
||||
ingressPathsErrors={ingressPathsErrors}
|
||||
serviceIndex={serviceIndex}
|
||||
portIndex={portIndex}
|
||||
isEditMode={isEditMode}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
<div className="flex">
|
||||
<Button
|
||||
icon={Plus}
|
||||
color="default"
|
||||
className="!ml-0"
|
||||
onClick={() => {
|
||||
const newServicesPorts = [...servicePorts, newNodePortPort];
|
||||
onChangePort(newServicesPorts);
|
||||
}}
|
||||
>
|
||||
Add port
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Widget.Body>
|
||||
</Widget>
|
||||
);
|
||||
}
|
|
@ -7,8 +7,13 @@ import { Card } from '@@/Card';
|
|||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { Button } from '@@/buttons';
|
||||
|
||||
import { serviceFormDefaultValues, generateUniqueName, newPort } from './utils';
|
||||
import { ServiceFormValues, ServicePort } from './types';
|
||||
import {
|
||||
serviceFormDefaultValues,
|
||||
generateUniqueName,
|
||||
newPort,
|
||||
} from '../utils';
|
||||
import { ServiceFormValues, ServicePort } from '../types';
|
||||
|
||||
import { NodePortServiceForm } from './NodePortServiceForm';
|
||||
|
||||
interface Props {
|
||||
|
@ -17,6 +22,8 @@ interface Props {
|
|||
errors?: FormikErrors<ServiceFormValues[]>;
|
||||
appName: string;
|
||||
selector: Record<string, string>;
|
||||
namespace?: string;
|
||||
isEditMode?: boolean;
|
||||
}
|
||||
|
||||
export function NodePortServicesForm({
|
||||
|
@ -25,6 +32,8 @@ export function NodePortServicesForm({
|
|||
errors,
|
||||
appName,
|
||||
selector,
|
||||
namespace,
|
||||
isEditMode,
|
||||
}: Props) {
|
||||
const nodePortServiceCount = services.filter(
|
||||
(service) => service.Type === KubernetesApplicationPublishingTypes.NODE_PORT
|
||||
|
@ -54,6 +63,8 @@ export function NodePortServicesForm({
|
|||
services={services}
|
||||
serviceIndex={index}
|
||||
onChangeService={onChangeService}
|
||||
namespace={namespace}
|
||||
isEditMode={isEditMode}
|
||||
/>
|
||||
) : null
|
||||
)}
|
|
@ -9,12 +9,18 @@ export interface ServicePort {
|
|||
serviceName?: string;
|
||||
name?: string;
|
||||
protocol?: string;
|
||||
ingress?: object;
|
||||
ingressPaths?: ServicePortIngressPath[];
|
||||
}
|
||||
|
||||
export type ServiceTypeAngularEnum =
|
||||
(typeof KubernetesApplicationPublishingTypes)[keyof typeof KubernetesApplicationPublishingTypes];
|
||||
|
||||
export type ServicePortIngressPath = {
|
||||
IngressName?: string;
|
||||
Host?: string;
|
||||
Path?: string;
|
||||
};
|
||||
|
||||
export type ServiceFormValues = {
|
||||
Headless: boolean;
|
||||
Ports: ServicePort[];
|
||||
|
@ -35,3 +41,9 @@ export type ServiceTypeOption = {
|
|||
value: ServiceTypeValue;
|
||||
label: ReactNode;
|
||||
};
|
||||
|
||||
export type IngressOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
ingressName: string;
|
||||
};
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { ServiceFormValues } from './types';
|
||||
import { Ingress } from '@/react/kubernetes/ingresses/types';
|
||||
|
||||
export function isServicePortError<T>(
|
||||
import { ServiceFormValues, ServicePort } from './types';
|
||||
|
||||
export function isErrorType<T>(
|
||||
error: string | FormikErrors<T> | undefined
|
||||
): error is FormikErrors<T> {
|
||||
return error !== undefined && typeof error !== 'string';
|
||||
|
@ -24,7 +26,7 @@ function generateIndexedName(appName: string, index: number) {
|
|||
}
|
||||
|
||||
function isNameUnique(name: string, services: ServiceFormValues[]) {
|
||||
return services.findIndex((service) => service.Name === name) === -1;
|
||||
return !services.find((service) => service.Name === name);
|
||||
}
|
||||
|
||||
export function generateUniqueName(
|
||||
|
@ -57,3 +59,101 @@ export const serviceFormDefaultValues: ServiceFormValues = {
|
|||
Ingress: false,
|
||||
Selector: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates new Ingress objects from form path data
|
||||
* @param {Ingress[]} oldIngresses - The old Ingress objects
|
||||
* @param {ServicePort[]} newServicesPorts - The new ServicePort objects from the form
|
||||
* @param {ServicePort[]} oldServicesPorts - The old ServicePort objects
|
||||
* @returns {Ingress[]} The new Ingress objects
|
||||
*/
|
||||
export function generateNewIngressesFromFormPaths(
|
||||
oldIngresses?: Ingress[],
|
||||
newServicesPorts?: ServicePort[],
|
||||
oldServicesPorts?: ServicePort[]
|
||||
): Ingress[] {
|
||||
// filter the ports to only the ones that have an ingress
|
||||
const oldIngressPaths = oldServicesPorts
|
||||
?.flatMap((port) => port.ingressPaths)
|
||||
.filter((ingressPath) => ingressPath?.IngressName);
|
||||
const newPortsWithIngress = newServicesPorts?.filter(
|
||||
(port) => port.ingressPaths?.length
|
||||
);
|
||||
// return early if possible
|
||||
if (!oldIngresses && !newPortsWithIngress) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// remove the old paths from the newIngresses copy
|
||||
const newIngresses = structuredClone(oldIngresses) ?? [];
|
||||
oldIngressPaths?.forEach((oldIngressPath) => {
|
||||
if (!oldIngressPath?.Path) return;
|
||||
const newMatchingIng = newIngresses?.find(
|
||||
(ingress) => ingress.Name === oldIngressPath.IngressName
|
||||
);
|
||||
if (!newMatchingIng) return;
|
||||
|
||||
// remove the old path from the new ingress
|
||||
const oldPathIndex = newMatchingIng?.Paths?.findIndex(
|
||||
(path) =>
|
||||
path.Path === prependWithSlash(oldIngressPath.Path) &&
|
||||
path.Host === oldIngressPath.Host
|
||||
);
|
||||
if (oldPathIndex === -1 || oldPathIndex === undefined) return;
|
||||
if (newMatchingIng.Paths) {
|
||||
newMatchingIng.Paths = [
|
||||
...newMatchingIng.Paths.slice(0, oldPathIndex),
|
||||
...newMatchingIng.Paths.slice(oldPathIndex + 1),
|
||||
];
|
||||
}
|
||||
|
||||
// update the new ingresses with the newMatchingIng
|
||||
const newIngIndex = newIngresses.findIndex(
|
||||
(ingress) => ingress.Name === newMatchingIng.Name
|
||||
);
|
||||
newIngresses[newIngIndex] = newMatchingIng;
|
||||
});
|
||||
|
||||
// and add the new paths to return the updated ingresses
|
||||
newPortsWithIngress?.forEach(
|
||||
({ ingressPaths: newIngresspaths, ...servicePort }) => {
|
||||
newIngresspaths?.forEach((newIngressPath) => {
|
||||
if (!newIngressPath?.Path) return;
|
||||
const newMatchingIng = newIngresses.find(
|
||||
(ingress) => ingress.Name === newIngressPath?.IngressName
|
||||
);
|
||||
if (!newMatchingIng) return;
|
||||
|
||||
// add the new path to the new ingress
|
||||
if (
|
||||
newIngressPath.Host &&
|
||||
newIngressPath.IngressName &&
|
||||
servicePort.serviceName &&
|
||||
servicePort.port
|
||||
) {
|
||||
newMatchingIng.Paths = [
|
||||
...(newMatchingIng.Paths ?? []),
|
||||
{
|
||||
Path: prependWithSlash(newIngressPath.Path),
|
||||
Host: newIngressPath.Host,
|
||||
IngressName: newIngressPath.IngressName,
|
||||
ServiceName: servicePort.serviceName,
|
||||
Port: servicePort.port,
|
||||
PathType: 'Prefix',
|
||||
},
|
||||
];
|
||||
}
|
||||
// update the new ingresses with the newMatchingIng
|
||||
const newIngIndex = newIngresses.findIndex(
|
||||
(ingress) => ingress.Name === newMatchingIng.Name
|
||||
);
|
||||
newIngresses[newIngIndex] = newMatchingIng;
|
||||
});
|
||||
}
|
||||
);
|
||||
return newIngresses;
|
||||
}
|
||||
|
||||
export function prependWithSlash(path?: string) {
|
||||
return path?.startsWith('/') ? path : `/${path}`;
|
||||
}
|
||||
|
|
|
@ -41,7 +41,7 @@ export function ApplicationIngressesTable({
|
|||
<td className="w-[10%]">Path</td>
|
||||
<td className="w-[15%]">HTTP Route</td>
|
||||
</tr>
|
||||
{ingressPathsForAppServices.map((ingressPath, index) => (
|
||||
{ingressPathsForAppServices?.map((ingressPath, index) => (
|
||||
<tr key={index}>
|
||||
<td>
|
||||
<Authorized authorizations="K8sIngressesW">
|
||||
|
@ -94,7 +94,10 @@ function getIngressPathsForAppServices(
|
|||
}
|
||||
const matchingIngressesPaths = ingresses.flatMap((ingress) => {
|
||||
// for each ingress get an array of ingress paths that match the app services
|
||||
const matchingIngressPaths = ingress.Paths.filter((path) =>
|
||||
if (!ingress.Paths) {
|
||||
return [];
|
||||
}
|
||||
const matchingIngressPaths = ingress.Paths?.filter((path) =>
|
||||
services?.some((service) => {
|
||||
const servicePorts = service.spec?.ports?.map((port) => port.port);
|
||||
// include the ingress if the ingress path has a matching service name and port
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue