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:
parent
8c16fbb8aa
commit
89c1d0e337
47 changed files with 1929 additions and 1181 deletions
|
@ -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>
|
||||
);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue