1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-23 15:29:42 +02:00

feat(app): add ingress to app service form [EE-5569] (#9106)

This commit is contained in:
Ali 2023-06-26 16:21:19 +12:00 committed by GitHub
parent 8c16fbb8aa
commit 89c1d0e337
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
47 changed files with 1929 additions and 1181 deletions

View file

@ -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>
);
}

View file

@ -0,0 +1,150 @@
import { Plus, RefreshCw } from 'lucide-react';
import { FormikErrors } from 'formik';
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
import { useCurrentUser } from '@/react/hooks/useUser';
import { useEnvironment } from '@/react/portainer/environments/queries';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { Card } from '@@/Card';
import { TextTip } from '@@/Tip/TextTip';
import { Button } from '@@/buttons';
import { FormError } from '@@/form-components/FormError';
import { Link } from '@@/Link';
import {
generateUniqueName,
newPort,
serviceFormDefaultValues,
} from '../utils';
import { ServiceFormValues, ServicePort } from '../types';
import { LoadBalancerServiceForm } from './LoadBalancerServiceForm';
interface Props {
services: ServiceFormValues[];
onChangeService: (services: ServiceFormValues[]) => void;
errors?: FormikErrors<ServiceFormValues[]>;
appName: string;
selector: Record<string, string>;
namespace?: string;
isEditMode?: boolean;
}
export function LoadBalancerServicesForm({
services,
onChangeService,
errors,
appName,
selector,
namespace,
isEditMode,
}: Props) {
const { isAdmin } = useCurrentUser();
const environmentId = useEnvironmentId();
const { data: loadBalancerEnabled, ...loadBalancerEnabledQuery } =
useEnvironment(
environmentId,
(environment) => environment?.Kubernetes.Configuration.UseLoadBalancer
);
const loadBalancerServiceCount = services.filter(
(service) =>
service.Type === KubernetesApplicationPublishingTypes.LOAD_BALANCER
).length;
return (
<Card className="pb-5">
<div className="flex flex-col gap-6">
<TextTip color="blue">
Allow access to traffic <b>external</b> to the cluster via a{' '}
<b>LoadBalancer service</b>. If running on a cloud platform, this auto
provisions a cloud load balancer.
</TextTip>
{!loadBalancerEnabled && loadBalancerEnabledQuery.isSuccess && (
<div className="flex flex-col">
<FormError>
{isAdmin ? (
<>
Load balancer use is not currently enabled in this cluster.
Configure via{' '}
<Link
to="kubernetes.cluster.setup"
target="_blank"
rel="noopener noreferrer"
>
Cluster Setup
</Link>{' '}
and then refresh this tab
</>
) : (
'Load balancer use is not currently enabled in this cluster, contact your administrator.'
)}
</FormError>
<div className="flex">
<Button
icon={RefreshCw}
color="default"
className="!ml-0"
onClick={() => loadBalancerEnabledQuery.refetch()}
>
Refresh
</Button>
</div>
</div>
)}
{loadBalancerServiceCount > 0 && (
<div className="flex w-full flex-col gap-4">
{services.map((service, index) =>
service.Type ===
KubernetesApplicationPublishingTypes.LOAD_BALANCER ? (
<LoadBalancerServiceForm
key={index}
serviceName={service.Name}
servicePorts={service.Ports}
errors={errors?.[index]?.Ports}
onChangePort={(servicePorts: ServicePort[]) => {
const newServices = [...services];
newServices[index].Ports = servicePorts;
onChangeService(newServices);
}}
services={services}
serviceIndex={index}
onChangeService={onChangeService}
namespace={namespace}
isEditMode={isEditMode}
/>
) : null
)}
</div>
)}
<div className="flex">
<Button
color="secondary"
className="!ml-0"
icon={Plus}
size="small"
onClick={() => {
// create a new service form value and add it to the list of services
const newService = structuredClone(serviceFormDefaultValues);
newService.Name = generateUniqueName(
appName,
services.length + 1,
services
);
newService.Type =
KubernetesApplicationPublishingTypes.LOAD_BALANCER;
const newServicePort = newPort(newService.Name);
newService.Ports = [newServicePort];
newService.Selector = selector;
onChangeService([...services, newService]);
}}
disabled={!loadBalancerEnabled}
data-cy="k8sAppCreate-createServiceButton"
>
Create service
</Button>
</div>
</div>
</Card>
);
}