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,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 &apos;
{namespace}&apos;. 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>
);
}

View file

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