feat(app): rearrange app form services [EE-5566] (#9056)

pull/9066/head
Ali 1 year ago committed by GitHub
parent d7fc2046d7
commit 2d69e93efa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -28,7 +28,6 @@ export const KubernetesApplicationPublishingTypes = Object.freeze({
CLUSTER_IP: 1,
NODE_PORT: 2,
LOAD_BALANCER: 3,
INGRESS: 4,
});
export const KubernetesApplicationPlacementTypes = Object.freeze({

@ -117,13 +117,6 @@ withFormValidation(
ngModule,
withUIRouter(withCurrentUser(withReactQuery(KubeServicesForm))),
'kubeServicesForm',
[
'values',
'onChange',
'loadBalancerEnabled',
'appName',
'selector',
'isEditMode',
],
['values', 'onChange', 'appName', 'selector', 'isEditMode'],
kubeServicesValidation
);

@ -1339,7 +1339,6 @@
<kube-services-form
on-change="(ctrl.onServicesChange)"
values="ctrl.formValues.Services"
load-balancer-enabled="ctrl.publishViaLoadBalancerEnabled()"
app-name="ctrl.formValues.Name"
selector="ctrl.formValues.Selector"
validation-data="{nodePortServices: ctrl.state.nodePortServices, formServices: ctrl.formValues.Services}"

@ -823,10 +823,6 @@ class KubernetesCreateApplicationController {
return this.nodesLimits.overflowForReplica(cpu, memory, instances);
}
publishViaLoadBalancerEnabled() {
return this.state.useLoadBalancer && this.state.maxLoadBalancersQuota !== 0;
}
publishViaIngressEnabled() {
return this.ingresses.length;
}

@ -30,12 +30,7 @@ export function TextTip({
inline ? 'inline-flex' : 'flex'
)}
>
<Icon
icon={icon}
mode={getMode(color)}
size="sm"
className="!mt-0.5 flex-none"
/>
<Icon icon={icon} mode={getMode(color)} className="!mt-0.5 flex-none" />
<span className={childrenWrapperClassName}>{children}</span>
</div>

@ -1,132 +0,0 @@
import { FormikErrors } from 'formik';
import { ChangeEvent } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { FormError } from '@@/form-components/FormError';
import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector';
import { Button } from '@@/buttons';
import { isServicePortError, newPort } from './utils';
import { ServicePort } from './types';
import { ServicePortInput } from './ServicePortInput';
import { ContainerPortInput } from './ContainerPortInput';
interface Props {
values: ServicePort[];
onChange: (servicePorts: ServicePort[]) => void;
serviceName?: string;
errors?: string | string[] | FormikErrors<ServicePort>[];
}
export function ClusterIpForm({
values: servicePorts,
onChange,
errors,
serviceName,
}: Props) {
const newClusterIpPort = newPort(serviceName);
return (
<>
<div className="control-label !mb-2 !pt-0 text-left">Published ports</div>
<div className="mb-2 flex flex-col gap-4">
{servicePorts.map((servicePort, index) => {
const error = errors?.[index];
const servicePortError = isServicePortError<ServicePort>(error)
? error
: undefined;
return (
<div key={index} className="flex flex-grow flex-wrap gap-2">
<div className="flex w-1/3 min-w-min flex-col">
<ContainerPortInput
index={index}
value={servicePort.targetPort}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const newServicePorts = [...servicePorts];
const newValue =
e.target.value === ''
? undefined
: Number(e.target.value);
newServicePorts[index] = {
...newServicePorts[index],
targetPort: newValue,
port: newValue,
};
onChange(newServicePorts);
}}
/>
{servicePortError?.targetPort && (
<FormError>{servicePortError.targetPort}</FormError>
)}
</div>
<div className="flex w-1/3 min-w-min flex-col">
<ServicePortInput
index={index}
value={servicePort.port}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const newServicePorts = [...servicePorts];
newServicePorts[index] = {
...newServicePorts[index],
port:
e.target.value === ''
? undefined
: Number(e.target.value),
};
onChange(newServicePorts);
}}
/>
{servicePortError?.port && (
<FormError>{servicePortError.port}</FormError>
)}
</div>
<ButtonSelector
className="h-[30px]"
onChange={(value) => {
const newServicePorts = [...servicePorts];
newServicePorts[index] = {
...newServicePorts[index],
protocol: value,
};
onChange(newServicePorts);
}}
value={servicePort.protocol || 'TCP'}
options={[{ value: 'TCP' }, { value: 'UDP' }]}
/>
<Button
disabled={servicePorts.length === 1}
size="small"
className="!ml-0 h-[30px]"
color="danger"
type="button"
onClick={() => {
// remove the port at the index in an immutable way
const newServicePorts = [
...servicePorts.slice(0, index),
...servicePorts.slice(index + 1),
];
onChange(newServicePorts);
}}
data-cy={`k8sAppCreate-rmPortButton_${index}`}
icon={Trash2}
/>
</div>
);
})}
<div className="flex">
<Button
icon={Plus}
color="default"
className="!ml-0"
onClick={() => {
const newServicesPorts = [...servicePorts, newClusterIpPort];
onChange(newServicesPorts);
}}
>
Publish a new port
</Button>
</div>
</div>
</>
);
}

@ -0,0 +1,167 @@
import { FormikErrors } from 'formik';
import { ChangeEvent } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { FormError } from '@@/form-components/FormError';
import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector';
import { Button } from '@@/buttons';
import { Card } from '@@/Card';
import { Widget } from '@@/Widget';
import { isServicePortError, newPort } from './utils';
import { ServiceFormValues, ServicePort } from './types';
import { ServicePortInput } from './ServicePortInput';
import { ContainerPortInput } from './ContainerPortInput';
interface Props {
services: ServiceFormValues[];
serviceIndex: number;
onChangeService: (services: ServiceFormValues[]) => void;
servicePorts: ServicePort[];
onChangePort: (servicePorts: ServicePort[]) => void;
serviceName?: string;
errors?: string | string[] | FormikErrors<ServicePort>[];
}
export function ClusterIpServiceForm({
services,
serviceIndex,
onChangeService,
servicePorts,
onChangePort,
errors,
serviceName,
}: Props) {
const newClusterIpPort = newPort(serviceName);
return (
<Widget key={serviceIndex}>
<Widget.Body>
<div className="mb-4 flex justify-between">
<div className="text-muted vertical-center">ClusterIP service</div>
<Button
icon={Trash2}
color="dangerlight"
className="!ml-0 flex-none"
onClick={() => {
// remove the service at index in an immutable way
const newServices = [
...services.slice(0, serviceIndex),
...services.slice(serviceIndex + 1),
];
onChangeService(newServices);
}}
>
Remove service
</Button>
</div>
<div className="control-label !mb-2 !pt-0 text-left">Ports</div>
<div className="flex flex-col gap-3">
{servicePorts.map((servicePort, portIndex) => {
const error = errors?.[portIndex];
const servicePortError = isServicePortError<ServicePort>(error)
? error
: undefined;
return (
<Card
key={portIndex}
className="flex flex-grow flex-wrap justify-between gap-x-4 gap-y-1"
>
<div className="inline-flex min-w-min flex-grow basis-3/4 flex-wrap gap-2">
<div className="flex min-w-min basis-1/3 flex-col">
<ContainerPortInput
index={portIndex}
value={servicePort.targetPort}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const newServicePorts = [...servicePorts];
const newValue =
e.target.value === ''
? undefined
: Number(e.target.value);
newServicePorts[portIndex] = {
...newServicePorts[portIndex],
targetPort: newValue,
port: newValue,
};
onChangePort(newServicePorts);
}}
/>
{servicePortError?.targetPort && (
<FormError>{servicePortError.targetPort}</FormError>
)}
</div>
<div className="flex min-w-min basis-1/3 flex-col">
<ServicePortInput
index={portIndex}
value={servicePort.port}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const newServicePorts = [...servicePorts];
newServicePorts[portIndex] = {
...newServicePorts[portIndex],
port:
e.target.value === ''
? undefined
: Number(e.target.value),
};
onChangePort(newServicePorts);
}}
/>
{servicePortError?.port && (
<FormError>{servicePortError.port}</FormError>
)}
</div>
<ButtonSelector
className="h-[30px]"
onChange={(value) => {
const newServicePorts = [...servicePorts];
newServicePorts[portIndex] = {
...newServicePorts[portIndex],
protocol: value,
};
onChangePort(newServicePorts);
}}
value={servicePort.protocol || 'TCP'}
options={[{ value: 'TCP' }, { value: 'UDP' }]}
/>
</div>
<Button
disabled={servicePorts.length === 1}
size="small"
className="!ml-0 h-[30px]"
color="dangerlight"
type="button"
onClick={() => {
// remove the port at the index in an immutable way
const newServicePorts = [
...servicePorts.slice(0, portIndex),
...servicePorts.slice(portIndex + 1),
];
onChangePort(newServicePorts);
}}
data-cy={`k8sAppCreate-rmPortButton_${portIndex}`}
icon={Trash2}
>
Remove port
</Button>
</Card>
);
})}
<div className="flex">
<Button
icon={Plus}
color="default"
className="!ml-0"
onClick={() => {
const newServicesPorts = [...servicePorts, newClusterIpPort];
onChangePort(newServicesPorts);
}}
>
Add port
</Button>
</div>
</div>
</Widget.Body>
</Widget>
);
}

@ -0,0 +1,92 @@
import { Plus } from 'lucide-react';
import { FormikErrors } from 'formik';
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
import { Card } from '@@/Card';
import { TextTip } from '@@/Tip/TextTip';
import { Button } from '@@/buttons';
import { generateUniqueName, newPort, serviceFormDefaultValues } from './utils';
import { ServiceFormValues, ServicePort } from './types';
import { ClusterIpServiceForm } from './ClusterIpServiceForm';
interface Props {
services: ServiceFormValues[];
onChangeService: (services: ServiceFormValues[]) => void;
errors?: FormikErrors<ServiceFormValues[]>;
appName: string;
selector: Record<string, string>;
}
export function ClusterIpServicesForm({
services,
onChangeService,
errors,
appName,
selector,
}: Props) {
const clusterIPServiceCount = services.filter(
(service) =>
service.Type === KubernetesApplicationPublishingTypes.CLUSTER_IP
).length;
return (
<Card className="pb-5">
<div className="flex flex-col gap-6">
<TextTip color="blue">
Publish <b>internally</b> in the cluster via a{' '}
<b>ClusterIP service</b>, optionally exposing <b>externally</b> to the
outside world via an <b>ingress</b>.
</TextTip>
{clusterIPServiceCount > 0 && (
<div className="flex w-full flex-col gap-4">
{services.map((service, index) =>
service.Type ===
KubernetesApplicationPublishingTypes.CLUSTER_IP ? (
<ClusterIpServiceForm
key={index}
serviceName={service.Name}
servicePorts={service.Ports}
errors={errors?.[index]?.Ports}
onChangePort={(servicePorts: ServicePort[]) => {
const newServices = [...services];
newServices[index].Ports = servicePorts;
onChangeService(newServices);
}}
services={services}
serviceIndex={index}
onChangeService={onChangeService}
/>
) : null
)}
</div>
)}
<div className="flex">
<Button
color="secondary"
className="!ml-0"
icon={Plus}
size="small"
onClick={() => {
// create a new service form value and add it to the list of services
const newService = structuredClone(serviceFormDefaultValues);
newService.Name = generateUniqueName(
appName,
services.length + 1,
services
);
newService.Type = KubernetesApplicationPublishingTypes.CLUSTER_IP;
const newServicePort = newPort(newService.Name);
newService.Ports = [newServicePort];
newService.Selector = selector;
onChangeService([...services, newService]);
}}
data-cy="k8sAppCreate-createServiceButton"
>
Create service
</Button>
</div>
</div>
</Card>
);
}

@ -1,61 +1,37 @@
import { SchemaOf, array, boolean, mixed, number, object, string } from 'yup';
import { useEffect, useState } from 'react';
import { SingleValue } from 'react-select';
import { List, Plus, Trash2 } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { FormikErrors } from 'formik';
import DataFlow from '@/assets/ico/dataflow-1.svg?c';
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
import { useCurrentUser } from '@/react/hooks/useUser';
import { Link } from '@@/Link';
import { TextTip } from '@@/Tip/TextTip';
import { Select } from '@@/form-components/ReactSelect';
import { Button } from '@@/buttons';
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
import { Icon } from '@@/Icon';
import { FormError } from '@@/form-components/FormError';
import { Badge } from '@@/Badge';
import { ServiceFormValues, ServicePort, ServiceTypeValue } from './types';
import { LoadBalancerForm } from './LoadBalancerForm';
import { ClusterIpForm } from './ClusterIpForm';
import { NodePortForm } from './NodePortForm';
import { newPort } from './utils';
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';
type ServiceTypeLabel = 'ClusterIP' | 'NodePort' | 'LoadBalancer';
type ServiceTypeOption = { value: ServiceTypeValue; label: ServiceTypeLabel };
const serviceTypeOptions: ServiceTypeOption[] = [
{
value: KubernetesApplicationPublishingTypes.CLUSTER_IP,
label: 'ClusterIP',
},
{ value: KubernetesApplicationPublishingTypes.NODE_PORT, label: 'NodePort' },
{
value: KubernetesApplicationPublishingTypes.LOAD_BALANCER,
label: 'LoadBalancer',
},
];
const serviceFormDefaultValues: ServiceFormValues = {
Headless: false,
Namespace: '',
Name: '',
StackName: '',
Ports: [],
Type: 1, // clusterip type as default
ClusterIP: '',
ApplicationName: '',
ApplicationOwner: '',
Note: '',
Ingress: false,
Selector: {},
const serviceTypeEnumsToValues: Record<
ServiceTypeAngularEnum,
ServiceTypeValue
> = {
[KubernetesApplicationPublishingTypes.CLUSTER_IP]: 'ClusterIP',
[KubernetesApplicationPublishingTypes.NODE_PORT]: 'NodePort',
[KubernetesApplicationPublishingTypes.LOAD_BALANCER]: 'LoadBalancer',
};
interface Props {
values: ServiceFormValues[];
onChange: (loadBalancerPorts: ServiceFormValues[]) => void;
onChange: (services: ServiceFormValues[]) => void;
errors?: FormikErrors<ServiceFormValues[]>;
loadBalancerEnabled: boolean;
appName: string;
selector: Record<string, string>;
isEditMode: boolean;
@ -65,15 +41,12 @@ export function KubeServicesForm({
values: services,
onChange,
errors,
loadBalancerEnabled,
appName,
selector,
isEditMode,
}: Props) {
const { isAdmin } = useCurrentUser();
const [selectedServiceTypeOption, setSelectedServiceTypeOption] = useState<
SingleValue<ServiceTypeOption>
>(serviceTypeOptions[0]); // ClusterIP is the default value
const [selectedServiceType, setSelectedServiceType] =
useState<ServiceTypeValue>('ClusterIP');
// when the appName changes, update the names for each service
// and the serviceNames for each service port
@ -93,210 +66,93 @@ export function KubeServicesForm({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appName]);
const serviceTypeCounts = useMemo(
() => getServiceTypeCounts(services),
[services]
);
const serviceTypeOptions: ServiceTypeOption[] = [
{
value: 'ClusterIP',
label: (
<div className="inline-flex items-center">
ClusterIP services
{serviceTypeCounts.ClusterIP && (
<Badge className="ml-2 flex-none">
{serviceTypeCounts.ClusterIP}
</Badge>
)}
</div>
),
},
{
value: 'NodePort',
label: (
<div className="inline-flex items-center">
NodePort services
{serviceTypeCounts.NodePort && (
<Badge className="ml-2 flex-none">
{serviceTypeCounts.NodePort}
</Badge>
)}
</div>
),
},
{
value: 'LoadBalancer',
label: (
<div className="inline-flex items-center">
LoadBalancer services
{serviceTypeCounts.LoadBalancer && (
<Badge className="ml-2 flex-none">
{serviceTypeCounts.LoadBalancer}
</Badge>
)}
</div>
),
},
];
return (
<>
<div className="flex flex-col">
<div className="col-sm-12 form-section-title">
Publishing the application
</div>
<div className="col-sm-12 !p-0">
<div className="row">
<TextTip color="blue">
Publish your application by creating a ClusterIP service for it,
which you may then expose via{' '}
<Link
target="_blank"
to="kubernetes.ingresses"
rel="noopener noreferrer"
>
an ingress
</Link>
.
</TextTip>
</div>
</div>
<div className="flex w-full">
<Select<ServiceTypeOption>
options={serviceTypeOptions}
value={selectedServiceTypeOption}
className="w-1/4"
data-cy="k8sAppCreate-publishingModeDropdown"
onChange={(val) => {
setSelectedServiceTypeOption(val);
}}
<ServiceTabs
serviceTypeOptions={serviceTypeOptions}
selectedServiceType={selectedServiceType}
setSelectedServiceType={setSelectedServiceType}
/>
{selectedServiceType === 'ClusterIP' && (
<ClusterIpServicesForm
services={services}
onChangeService={onChange}
errors={errors}
appName={appName}
selector={selector}
/>
<TooltipWithChildren
position="top"
className="portainer-tooltip"
message="Different service types expose the application in alternate ways.
ClusterIP exposes it within the cluster (for internal access only).
NodePort exposes it (on a high port) across all nodes.
LoadBalancer exposes it via an external load balancer."
>
<span>
<Button
color="default"
icon={Plus}
size="medium"
disabled={
selectedServiceTypeOption?.value ===
KubernetesApplicationPublishingTypes.LOAD_BALANCER &&
!loadBalancerEnabled
}
onClick={() => {
// create a new service form value and add it to the list of services
const newService = structuredClone(serviceFormDefaultValues);
newService.Name = generateUniqueName(
appName,
services.length + 1,
services
);
newService.Type =
selectedServiceTypeOption?.value ||
KubernetesApplicationPublishingTypes.CLUSTER_IP;
const newServicePort = newPort(newService.Name);
newService.Ports = [newServicePort];
newService.Selector = selector;
onChange([...services, newService]);
}}
data-cy="k8sAppCreate-createServiceButton"
>
Create service
</Button>
</span>
</TooltipWithChildren>
</div>
<div className="flex w-full flex-col">
{selectedServiceTypeOption?.value ===
KubernetesApplicationPublishingTypes.LOAD_BALANCER &&
isAdmin &&
!loadBalancerEnabled && (
<FormError className="mt-2">
No Load balancer is available in this cluster, click{' '}
<Link
to="kubernetes.cluster.setup"
target="_blank"
rel="noopener noreferrer"
>
here
</Link>{' '}
to configure load balancer.
</FormError>
)}
{selectedServiceTypeOption?.value ===
KubernetesApplicationPublishingTypes.LOAD_BALANCER &&
!isAdmin &&
!loadBalancerEnabled && (
<FormError className="mt-2">
No Load balancer is available in this cluster, contact your
administrator.
</FormError>
)}
{services.map((service, index) => (
<div key={index} className="border-bottom py-6">
{service.Type ===
KubernetesApplicationPublishingTypes.CLUSTER_IP && (
<>
<div className="text-muted vertical-center w-full">
<Icon icon={List} />
ClusterIP
</div>
<ClusterIpForm
serviceName={service.Name}
values={service.Ports}
errors={errors?.[index]?.Ports}
onChange={(servicePorts: ServicePort[]) => {
const newServices = [...services];
newServices[index].Ports = servicePorts;
onChange(newServices);
}}
/>
</>
)}
{service.Type ===
KubernetesApplicationPublishingTypes.NODE_PORT && (
<>
<div className="text-muted vertical-center w-full">
<Icon icon={List} />
NodePort
</div>
<NodePortForm
serviceName={service.Name}
values={service.Ports}
errors={errors?.[index]?.Ports}
onChange={(servicePorts: ServicePort[]) => {
const newServices = [...services];
newServices[index].Ports = servicePorts;
onChange(newServices);
}}
/>
</>
)}
{service.Type ===
KubernetesApplicationPublishingTypes.LOAD_BALANCER && (
<>
<div className="text-muted vertical-center w-full">
<Icon icon={DataFlow} />
LoadBalancer
</div>
<LoadBalancerForm
serviceName={service.Name}
values={service.Ports}
errors={errors?.[index]?.Ports}
onChange={(servicePorts: ServicePort[]) => {
const newServices = [...services];
newServices[index].Ports = servicePorts;
onChange(newServices);
}}
loadBalancerEnabled={loadBalancerEnabled}
/>
</>
)}
<Button
icon={Trash2}
color="danger"
className="!ml-0 mt-2"
onClick={() => {
// remove the service at index in an immutable way
const newServices = [
...services.slice(0, index),
...services.slice(index + 1),
];
onChange(newServices);
}}
>
Remove
</Button>
</div>
))}
</div>
</>
)}
{selectedServiceType === 'NodePort' && (
<NodePortServicesForm
services={services}
onChangeService={onChange}
errors={errors}
appName={appName}
selector={selector}
/>
)}
{selectedServiceType === 'LoadBalancer' && (
<LoadBalancerServicesForm
services={services}
onChangeService={onChange}
errors={errors}
appName={appName}
selector={selector}
/>
)}
</div>
);
}
function generateIndexedName(appName: string, index: number) {
return index === 0 ? appName : `${appName}-${index}`;
}
function isNameUnique(name: string, services: ServiceFormValues[]) {
return services.findIndex((service) => service.Name === name) === -1;
}
function generateUniqueName(
appName: string,
index: number,
services: ServiceFormValues[]
) {
let initialIndex = index;
let uniqueName = appName;
while (!isNameUnique(uniqueName, services)) {
uniqueName = generateIndexedName(appName, initialIndex);
initialIndex++;
}
return uniqueName;
}
function getUniqNames(appName: string, services: ServiceFormValues[]) {
const sortedServices = [...services].sort((a, b) =>
a.Name && b.Name ? a.Name.localeCompare(b.Name) : 0
@ -317,6 +173,22 @@ function getUniqNames(appName: string, services: ServiceFormValues[]) {
return uniqueNames;
}
/**
* getServiceTypeCounts returns a map of service types to the number of services of that type
*/
function getServiceTypeCounts(
services: ServiceFormValues[]
): Record<ServiceTypeValue, number> {
return services.reduce((acc, service) => {
const type = serviceTypeEnumsToValues[service.Type];
const count = acc[type];
return {
...acc,
[type]: count ? count + 1 : 1,
};
}, {} as Record<ServiceTypeValue, number>);
}
// values returned from the angular parent component (pascal case instead of camel case keys),
// 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
@ -525,6 +397,7 @@ export function kubeServicesValidation(
ingress: object(),
})
),
Annotations: array(),
})
);
}

@ -1,176 +0,0 @@
import { FormikErrors } from 'formik';
import { ChangeEvent } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { InputGroup } from '@@/form-components/InputGroup';
import { FormError } from '@@/form-components/FormError';
import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector';
import { Button } from '@@/buttons';
import { isServicePortError, newPort } from './utils';
import { ContainerPortInput } from './ContainerPortInput';
import { ServicePortInput } from './ServicePortInput';
import { ServicePort } from './types';
interface Props {
values: ServicePort[];
onChange: (loadBalancerPorts: ServicePort[]) => void;
loadBalancerEnabled: boolean;
serviceName?: string;
errors?: string | string[] | FormikErrors<ServicePort>[];
}
export function LoadBalancerForm({
values: loadBalancerPorts,
onChange,
loadBalancerEnabled,
serviceName,
errors,
}: Props) {
const newLoadBalancerPort = newPort(serviceName);
return (
<>
{loadBalancerEnabled && (
<>
<div className="control-label !mb-2 !pt-0 text-left">
Published ports
</div>
<div className="mb-2 flex flex-col gap-4">
{loadBalancerPorts.map((lbPort, index) => {
const error = errors?.[index];
const servicePortError = isServicePortError<ServicePort>(error)
? error
: undefined;
return (
<div key={index} className="flex flex-grow flex-wrap gap-2">
<div className="flex w-1/4 min-w-min flex-col">
<ContainerPortInput
index={index}
value={lbPort.targetPort}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const newServicePorts = [...loadBalancerPorts];
const newValue =
e.target.value === ''
? undefined
: Number(e.target.value);
newServicePorts[index] = {
...newServicePorts[index],
targetPort: newValue,
port: newValue,
};
onChange(newServicePorts);
}}
/>
{servicePortError?.targetPort && (
<FormError>{servicePortError.targetPort}</FormError>
)}
</div>
<div className="flex w-1/4 min-w-min flex-col">
<ServicePortInput
index={index}
value={lbPort.port}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const newServicePorts = [...loadBalancerPorts];
newServicePorts[index] = {
...newServicePorts[index],
port:
e.target.value === ''
? undefined
: Number(e.target.value),
};
onChange(newServicePorts);
}}
/>
{servicePortError?.port && (
<FormError>{servicePortError.port}</FormError>
)}
</div>
<div className="flex w-1/4 min-w-min flex-col">
<InputGroup size="small">
<InputGroup.Addon required>
Loadbalancer port
</InputGroup.Addon>
<InputGroup.Input
type="number"
className="form-control min-w-max"
name={`loadbalancer_port_${index}`}
placeholder="80"
min="1"
max="65535"
value={lbPort.port || ''}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const newServicePorts = [...loadBalancerPorts];
newServicePorts[index] = {
...newServicePorts[index],
port:
e.target.value === ''
? undefined
: Number(e.target.value),
};
onChange(newServicePorts);
}}
required
data-cy={`k8sAppCreate-loadbalancerPort_${index}`}
/>
</InputGroup>
{servicePortError?.nodePort && (
<FormError>{servicePortError.nodePort}</FormError>
)}
</div>
<ButtonSelector
className="h-[30px]"
onChange={(value) => {
const newServicePorts = [...loadBalancerPorts];
newServicePorts[index] = {
...newServicePorts[index],
protocol: value,
};
onChange(newServicePorts);
}}
value={lbPort.protocol || 'TCP'}
options={[{ value: 'TCP' }, { value: 'UDP' }]}
/>
<Button
disabled={loadBalancerPorts.length === 1}
size="small"
className="!ml-0 h-[30px]"
color="danger"
type="button"
onClick={() => {
// remove the port at the index in an immutable way
const newServicePorts = [
...loadBalancerPorts.slice(0, index),
...loadBalancerPorts.slice(index + 1),
];
onChange(newServicePorts);
}}
data-cy={`k8sAppCreate-rmPortButton_${index}`}
icon={Trash2}
/>
</div>
);
})}
<div className="flex">
<Button
icon={Plus}
color="default"
className="!ml-0"
onClick={() => {
const newServicesPorts = [
...loadBalancerPorts,
newLoadBalancerPort,
];
onChange(newServicesPorts);
}}
>
Publish a new port
</Button>
</div>
</div>
</>
)}
</>
);
}

@ -0,0 +1,200 @@
import { FormikErrors } from 'formik';
import { ChangeEvent } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { FormError } from '@@/form-components/FormError';
import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector';
import { Button } from '@@/buttons';
import { Widget } from '@@/Widget';
import { Card } from '@@/Card';
import { InputGroup } from '@@/form-components/InputGroup';
import { isServicePortError, newPort } from './utils';
import { ContainerPortInput } from './ContainerPortInput';
import { ServicePortInput } from './ServicePortInput';
import { ServiceFormValues, ServicePort } from './types';
interface Props {
services: ServiceFormValues[];
serviceIndex: number;
onChangeService: (services: ServiceFormValues[]) => void;
servicePorts: ServicePort[];
onChangePort: (servicePorts: ServicePort[]) => void;
serviceName?: string;
errors?: string | string[] | FormikErrors<ServicePort>[];
}
export function LoadBalancerServiceForm({
services,
serviceIndex,
onChangeService,
servicePorts,
onChangePort,
errors,
serviceName,
}: Props) {
const newLoadBalancerPort = newPort(serviceName);
return (
<Widget key={serviceIndex}>
<Widget.Body>
<div className="mb-4 flex justify-between">
<div className="text-muted vertical-center">LoadBalancer service</div>
<Button
icon={Trash2}
color="dangerlight"
className="!ml-0"
onClick={() => {
// remove the service at index in an immutable way
const newServices = [
...services.slice(0, serviceIndex),
...services.slice(serviceIndex + 1),
];
onChangeService(newServices);
}}
>
Remove service
</Button>
</div>
<div className="control-label !mb-2 !pt-0 text-left">Ports</div>
<div className="flex flex-col gap-3">
{servicePorts.map((servicePort, portIndex) => {
const error = errors?.[portIndex];
const servicePortError = isServicePortError<ServicePort>(error)
? error
: undefined;
return (
<Card
key={portIndex}
className="flex flex-grow flex-wrap justify-between gap-x-4 gap-y-1"
>
<div className="inline-flex min-w-min flex-grow basis-3/4 flex-wrap gap-2">
<div className="flex min-w-min basis-1/4 flex-col">
<ContainerPortInput
index={portIndex}
value={servicePort.targetPort}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const newServicePorts = [...servicePorts];
const newValue =
e.target.value === ''
? undefined
: Number(e.target.value);
newServicePorts[portIndex] = {
...newServicePorts[portIndex],
targetPort: newValue,
port: newValue,
};
onChangePort(newServicePorts);
}}
/>
{servicePortError?.targetPort && (
<FormError>{servicePortError.targetPort}</FormError>
)}
</div>
<div className="flex min-w-min basis-1/4 flex-col">
<ServicePortInput
index={portIndex}
value={servicePort.port}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const newServicePorts = [...servicePorts];
newServicePorts[portIndex] = {
...newServicePorts[portIndex],
port:
e.target.value === ''
? undefined
: Number(e.target.value),
};
onChangePort(newServicePorts);
}}
/>
{servicePortError?.port && (
<FormError>{servicePortError.port}</FormError>
)}
</div>
<div className="flex min-w-min basis-1/4 flex-col">
<InputGroup size="small">
<InputGroup.Addon required>
Loadbalancer port
</InputGroup.Addon>
<InputGroup.Input
type="number"
className="form-control min-w-max"
name={`loadbalancer_port_${portIndex}`}
placeholder="80"
min="1"
max="65535"
value={servicePort.port || ''}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const newServicePorts = [...servicePorts];
newServicePorts[portIndex] = {
...newServicePorts[portIndex],
port:
e.target.value === ''
? undefined
: Number(e.target.value),
};
onChangePort(newServicePorts);
}}
required
data-cy={`k8sAppCreate-loadbalancerPort_${portIndex}`}
/>
</InputGroup>
{servicePortError?.nodePort && (
<FormError>{servicePortError.nodePort}</FormError>
)}
</div>
<ButtonSelector
className="h-[30px]"
onChange={(value) => {
const newServicePorts = [...servicePorts];
newServicePorts[portIndex] = {
...newServicePorts[portIndex],
protocol: value,
};
onChangePort(newServicePorts);
}}
value={servicePort.protocol || 'TCP'}
options={[{ value: 'TCP' }, { value: 'UDP' }]}
/>
</div>
<Button
disabled={servicePorts.length === 1}
size="small"
className="!ml-0 h-[30px]"
color="dangerlight"
type="button"
onClick={() => {
// remove the port at the index in an immutable way
const newServicePorts = [
...servicePorts.slice(0, portIndex),
...servicePorts.slice(portIndex + 1),
];
onChangePort(newServicePorts);
}}
data-cy={`k8sAppCreate-rmPortButton_${portIndex}`}
icon={Trash2}
>
Remove port
</Button>
</Card>
);
})}
<div className="flex">
<Button
icon={Plus}
color="default"
className="!ml-0"
onClick={() => {
const newServicesPorts = [...servicePorts, newLoadBalancerPort];
onChangePort(newServicesPorts);
}}
>
Add port
</Button>
</div>
</div>
</Widget.Body>
</Widget>
);
}

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

@ -1,162 +0,0 @@
import { FormikErrors } from 'formik';
import { ChangeEvent } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { InputGroup } from '@@/form-components/InputGroup';
import { FormError } from '@@/form-components/FormError';
import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector';
import { Button } from '@@/buttons';
import { isServicePortError, newPort } from './utils';
import { ContainerPortInput } from './ContainerPortInput';
import { ServicePortInput } from './ServicePortInput';
import { ServicePort } from './types';
interface Props {
values: ServicePort[];
onChange: (nodePorts: ServicePort[]) => void;
serviceName?: string;
errors?: string | string[] | FormikErrors<ServicePort>[];
}
export function NodePortForm({
values: nodePorts,
onChange,
errors,
serviceName,
}: Props) {
const newNodePortPort = newPort(serviceName);
return (
<>
<div className="control-label !mb-2 !pt-0 text-left">Published ports</div>
<div className="mb-2 flex flex-col gap-4">
{nodePorts.map((nodePort, index) => {
const error = errors?.[index];
const servicePortError = isServicePortError<ServicePort>(error)
? error
: undefined;
return (
<div key={index} className="flex flex-grow flex-wrap gap-2">
<div className="flex w-1/4 min-w-min flex-col">
<ContainerPortInput
index={index}
value={nodePort.targetPort}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const newServicePorts = [...nodePorts];
const newValue =
e.target.value === ''
? undefined
: Number(e.target.value);
newServicePorts[index] = {
...newServicePorts[index],
targetPort: newValue,
port: newValue,
};
onChange(newServicePorts);
}}
/>
{servicePortError?.targetPort && (
<FormError>{servicePortError.targetPort}</FormError>
)}
</div>
<div className="flex w-1/4 min-w-min flex-col">
<ServicePortInput
index={index}
value={nodePort.port}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const newServicePorts = [...nodePorts];
newServicePorts[index] = {
...newServicePorts[index],
port:
e.target.value === ''
? undefined
: Number(e.target.value),
};
onChange(newServicePorts);
}}
/>
{servicePortError?.port && (
<FormError>{servicePortError.port}</FormError>
)}
</div>
<div className="flex w-1/4 min-w-min flex-col">
<InputGroup size="small">
<InputGroup.Addon>Nodeport</InputGroup.Addon>
<InputGroup.Input
type="number"
className="form-control min-w-max"
name={`node_port_${index}`}
placeholder="30080"
min="30000"
max="32767"
value={nodePort.nodePort ?? ''}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const newServicePorts = [...nodePorts];
newServicePorts[index] = {
...newServicePorts[index],
nodePort:
e.target.value === ''
? undefined
: Number(e.target.value),
};
onChange(newServicePorts);
}}
data-cy={`k8sAppCreate-nodePort_${index}`}
/>
</InputGroup>
{servicePortError?.nodePort && (
<FormError>{servicePortError.nodePort}</FormError>
)}
</div>
<ButtonSelector
className="h-[30px]"
onChange={(value) => {
const newServicePorts = [...nodePorts];
newServicePorts[index] = {
...newServicePorts[index],
protocol: value,
};
onChange(newServicePorts);
}}
value={nodePort.protocol || 'TCP'}
options={[{ value: 'TCP' }, { value: 'UDP' }]}
/>
<Button
disabled={nodePorts.length === 1}
size="small"
className="!ml-0 h-[30px]"
color="danger"
type="button"
onClick={() => {
// remove the port at the index in an immutable way
const newServicePorts = [
...nodePorts.slice(0, index),
...nodePorts.slice(index + 1),
];
onChange(newServicePorts);
}}
data-cy={`k8sAppCreate-rmPortButton_${index}`}
icon={Trash2}
/>
</div>
);
})}
<div className="flex">
<Button
icon={Plus}
color="default"
className="!ml-0"
onClick={() => {
const newServicesPorts = [...nodePorts, newNodePortPort];
onChange(newServicesPorts);
}}
>
Publish a new port
</Button>
</div>
</div>
</>
);
}

@ -0,0 +1,197 @@
import { FormikErrors } from 'formik';
import { ChangeEvent } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { FormError } from '@@/form-components/FormError';
import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector';
import { Button } from '@@/buttons';
import { Widget } from '@@/Widget';
import { Card } from '@@/Card';
import { InputGroup } from '@@/form-components/InputGroup';
import { isServicePortError, newPort } from './utils';
import { ContainerPortInput } from './ContainerPortInput';
import { ServicePortInput } from './ServicePortInput';
import { ServiceFormValues, ServicePort } from './types';
interface Props {
services: ServiceFormValues[];
serviceIndex: number;
onChangeService: (services: ServiceFormValues[]) => void;
servicePorts: ServicePort[];
onChangePort: (servicePorts: ServicePort[]) => void;
serviceName?: string;
errors?: string | string[] | FormikErrors<ServicePort>[];
}
export function NodePortServiceForm({
services,
serviceIndex,
onChangeService,
servicePorts,
onChangePort,
errors,
serviceName,
}: Props) {
const newNodePortPort = newPort(serviceName);
return (
<Widget key={serviceIndex}>
<Widget.Body>
<div className="mb-4 flex justify-between">
<div className="text-muted vertical-center">NodePort service</div>
<Button
icon={Trash2}
color="dangerlight"
className="!ml-0"
onClick={() => {
// remove the service at index in an immutable way
const newServices = [
...services.slice(0, serviceIndex),
...services.slice(serviceIndex + 1),
];
onChangeService(newServices);
}}
>
Remove service
</Button>
</div>
<div className="control-label !mb-2 !pt-0 text-left">Ports</div>
<div className="flex flex-col gap-3">
{servicePorts.map((servicePort, portIndex) => {
const error = errors?.[portIndex];
const servicePortError = isServicePortError<ServicePort>(error)
? error
: undefined;
return (
<Card
key={portIndex}
className="flex flex-grow flex-wrap justify-between gap-x-4 gap-y-1"
>
<div className="inline-flex min-w-min flex-grow basis-3/4 flex-wrap gap-2">
<div className="flex min-w-min basis-1/4 flex-col">
<ContainerPortInput
index={portIndex}
value={servicePort.targetPort}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const newServicePorts = [...servicePorts];
const newValue =
e.target.value === ''
? undefined
: Number(e.target.value);
newServicePorts[portIndex] = {
...newServicePorts[portIndex],
targetPort: newValue,
port: newValue,
};
onChangePort(newServicePorts);
}}
/>
{servicePortError?.targetPort && (
<FormError>{servicePortError.targetPort}</FormError>
)}
</div>
<div className="flex min-w-min basis-1/4 flex-col">
<ServicePortInput
index={portIndex}
value={servicePort.port}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const newServicePorts = [...servicePorts];
newServicePorts[portIndex] = {
...newServicePorts[portIndex],
port:
e.target.value === ''
? undefined
: Number(e.target.value),
};
onChangePort(newServicePorts);
}}
/>
{servicePortError?.port && (
<FormError>{servicePortError.port}</FormError>
)}
</div>
<div className="flex min-w-min basis-1/4 flex-col">
<InputGroup size="small">
<InputGroup.Addon>Nodeport</InputGroup.Addon>
<InputGroup.Input
type="number"
className="form-control min-w-max"
name={`node_port_${portIndex}`}
placeholder="30080"
min="30000"
max="32767"
value={servicePort.nodePort ?? ''}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const newServicePorts = [...servicePorts];
newServicePorts[portIndex] = {
...newServicePorts[portIndex],
nodePort:
e.target.value === ''
? undefined
: Number(e.target.value),
};
onChangePort(newServicePorts);
}}
data-cy={`k8sAppCreate-nodePort_${portIndex}`}
/>
</InputGroup>
{servicePortError?.nodePort && (
<FormError>{servicePortError.nodePort}</FormError>
)}
</div>
<ButtonSelector
className="h-[30px]"
onChange={(value) => {
const newServicePorts = [...servicePorts];
newServicePorts[portIndex] = {
...newServicePorts[portIndex],
protocol: value,
};
onChangePort(newServicePorts);
}}
value={servicePort.protocol || 'TCP'}
options={[{ value: 'TCP' }, { value: 'UDP' }]}
/>
</div>
<Button
disabled={servicePorts.length === 1}
size="small"
className="!ml-0 h-[30px]"
color="dangerlight"
type="button"
onClick={() => {
// remove the port at the index in an immutable way
const newServicePorts = [
...servicePorts.slice(0, portIndex),
...servicePorts.slice(portIndex + 1),
];
onChangePort(newServicePorts);
}}
data-cy={`k8sAppCreate-rmPortButton_${portIndex}`}
icon={Trash2}
>
Remove port
</Button>
</Card>
);
})}
<div className="flex">
<Button
icon={Plus}
color="default"
className="!ml-0"
onClick={() => {
const newServicesPorts = [...servicePorts, newNodePortPort];
onChangePort(newServicesPorts);
}}
>
Add port
</Button>
</div>
</div>
</Widget.Body>
</Widget>
);
}

@ -0,0 +1,90 @@
import { FormikErrors } from 'formik';
import { Plus } from 'lucide-react';
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
import { Card } from '@@/Card';
import { TextTip } from '@@/Tip/TextTip';
import { Button } from '@@/buttons';
import { serviceFormDefaultValues, generateUniqueName, newPort } from './utils';
import { ServiceFormValues, ServicePort } from './types';
import { NodePortServiceForm } from './NodePortServiceForm';
interface Props {
services: ServiceFormValues[];
onChangeService: (services: ServiceFormValues[]) => void;
errors?: FormikErrors<ServiceFormValues[]>;
appName: string;
selector: Record<string, string>;
}
export function NodePortServicesForm({
services,
onChangeService,
errors,
appName,
selector,
}: Props) {
const nodePortServiceCount = services.filter(
(service) => service.Type === KubernetesApplicationPublishingTypes.NODE_PORT
).length;
return (
<Card className="pb-5">
<div className="flex flex-col gap-6">
<TextTip color="blue">
Allow access to traffic <b>external</b> to the cluster via a{' '}
<b>NodePort service</b>. Not generally recommended for Production use.
</TextTip>
{nodePortServiceCount > 0 && (
<div className="flex w-full flex-col gap-4">
{services.map((service, index) =>
service.Type ===
KubernetesApplicationPublishingTypes.NODE_PORT ? (
<NodePortServiceForm
key={index}
serviceName={service.Name}
servicePorts={service.Ports}
errors={errors?.[index]?.Ports}
onChangePort={(servicePorts: ServicePort[]) => {
const newServices = [...services];
newServices[index].Ports = servicePorts;
onChangeService(newServices);
}}
services={services}
serviceIndex={index}
onChangeService={onChangeService}
/>
) : null
)}
</div>
)}
<div className="flex">
<Button
color="secondary"
className="!ml-0"
icon={Plus}
size="small"
onClick={() => {
// create a new service form value and add it to the list of services
const newService = structuredClone(serviceFormDefaultValues);
newService.Name = generateUniqueName(
appName,
services.length + 1,
services
);
newService.Type = KubernetesApplicationPublishingTypes.NODE_PORT;
const newServicePort = newPort(newService.Name);
newService.Ports = [newServicePort];
newService.Selector = selector;
onChangeService([...services, newService]);
}}
data-cy="k8sAppCreate-createServiceButton"
>
Create service
</Button>
</div>
</div>
</Card>
);
}

@ -0,0 +1,43 @@
import clsx from 'clsx';
import { ServiceTypeOption, ServiceTypeValue } from './types';
type Props = {
serviceTypeOptions: ServiceTypeOption[];
selectedServiceType: ServiceTypeValue;
setSelectedServiceType: (serviceTypeValue: ServiceTypeValue) => void;
};
export function ServiceTabs({
serviceTypeOptions,
selectedServiceType,
setSelectedServiceType,
}: Props) {
return (
<div className="flex pl-2">
{serviceTypeOptions.map(({ label }, index) => (
<label
key={index}
className={clsx(
'!mb-0 inline-flex cursor-pointer items-center gap-2 border-0 border-b-2 border-solid bg-transparent px-4 py-2 font-medium',
selectedServiceType === serviceTypeOptions[index].value
? 'border-blue-8 text-blue-8 th-highcontrast:border-blue-6 th-highcontrast:text-blue-6 th-dark:border-blue-6 th-dark:text-blue-6'
: 'border-transparent'
)}
>
<input
type="radio"
name="widget-tabs"
className="hidden"
value={serviceTypeOptions[index].value}
checked={selectedServiceType === serviceTypeOptions[index].value}
onChange={(e) =>
setSelectedServiceType(e.target.value as ServiceTypeValue)
}
/>
{label}
</label>
))}
</div>
);
}

@ -1,3 +1,7 @@
import { ReactNode } from 'react';
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
export interface ServicePort {
port?: number;
targetPort?: number;
@ -8,12 +12,13 @@ export interface ServicePort {
ingress?: object;
}
export type ServiceTypeValue = 1 | 2 | 3;
export type ServiceTypeAngularEnum =
(typeof KubernetesApplicationPublishingTypes)[keyof typeof KubernetesApplicationPublishingTypes];
export type ServiceFormValues = {
Headless: boolean;
Ports: ServicePort[];
Type: ServiceTypeValue;
Type: ServiceTypeAngularEnum;
Ingress: boolean;
ClusterIP?: string;
ApplicationName?: string;
@ -24,3 +29,9 @@ export type ServiceFormValues = {
Selector?: Record<string, string>;
Namespace?: string;
};
export type ServiceTypeValue = 'ClusterIP' | 'NodePort' | 'LoadBalancer';
export type ServiceTypeOption = {
value: ServiceTypeValue;
label: ReactNode;
};

@ -1,5 +1,7 @@
import { FormikErrors } from 'formik';
import { ServiceFormValues } from './types';
export function isServicePortError<T>(
error: string | FormikErrors<T> | undefined
): error is FormikErrors<T> {
@ -16,3 +18,42 @@ export function newPort(serviceName?: string) {
serviceName,
};
}
function generateIndexedName(appName: string, index: number) {
return index === 0 ? appName : `${appName}-${index}`;
}
function isNameUnique(name: string, services: ServiceFormValues[]) {
return services.findIndex((service) => service.Name === name) === -1;
}
export function generateUniqueName(
appName: string,
index: number,
services: ServiceFormValues[]
) {
let initialIndex = index;
let uniqueName = appName;
while (!isNameUnique(uniqueName, services)) {
uniqueName = generateIndexedName(appName, initialIndex);
initialIndex++;
}
return uniqueName;
}
export const serviceFormDefaultValues: ServiceFormValues = {
Headless: false,
Namespace: '',
Name: '',
StackName: '',
Ports: [],
Type: 1, // clusterip type as default
ClusterIP: '',
ApplicationName: '',
ApplicationOwner: '',
Note: '',
Ingress: false,
Selector: {},
};

Loading…
Cancel
Save