fix(ingress): loading and ui fixes [EE-5132] (#9959)

pull/9969/head
Ali 2023-08-01 19:31:35 +12:00 committed by GitHub
parent e400c4dfc6
commit d0ecf6c16b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 309 additions and 174 deletions

View File

@ -0,0 +1,19 @@
import { Story, Meta } from '@storybook/react';
import { PropsWithChildren } from 'react';
import { InlineLoader, Props } from './InlineLoader';
export default {
title: 'Components/InlineLoader',
component: InlineLoader,
} as Meta;
function Template({ className, children }: PropsWithChildren<Props>) {
return <InlineLoader className={className}>{children}</InlineLoader>;
}
export const Primary: Story<PropsWithChildren<Props>> = Template.bind({});
Primary.args = {
className: 'test-class',
children: 'Loading...',
};

View File

@ -0,0 +1,23 @@
import { Loader2 } from 'lucide-react';
import { PropsWithChildren } from 'react';
import clsx from 'clsx';
import { Icon } from '@@/Icon';
export type Props = {
className: string;
};
export function InlineLoader({
children,
className,
}: PropsWithChildren<Props>) {
return (
<div
className={clsx('text-muted flex items-center gap-2 text-sm', className)}
>
<Icon icon={Loader2} className="animate-spin-slow" />
{children}
</div>
);
}

View File

@ -0,0 +1 @@
export { InlineLoader } from './InlineLoader';

View File

@ -17,7 +17,7 @@ interface WidgetProps {
}
const meta: Meta<WidgetProps> = {
title: 'Widget',
title: 'Components/Widget',
component: Widget,
args: {
loading: false,

View File

@ -31,6 +31,22 @@
display: none;
}
.portainer-selector-root .portainer-selector__group-heading {
text-transform: none !important;
font-size: 85% !important;
}
.input-group .portainer-selector-root:last-child .portainer-selector__control {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
}
.input-group .portainer-selector-root:not(:first-child):not(:last-child) .portainer-selector__control {
border-radius: 0;
}
/* input style */
.portainer-selector-root .portainer-selector__control {
border-color: var(--border-form-control-color);

View File

@ -111,10 +111,6 @@ export function AppIngressPathForm({
value={selectedIngress}
defaultValue={ingressHostOptions[0]}
placeholder="Select a hostname..."
theme={(theme) => ({
...theme,
borderRadius: 0,
})}
size="sm"
onChange={(ingressOption) => {
setSelectedIngress(ingressOption);

View File

@ -7,7 +7,7 @@ import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useConfigurations } from '@/react/kubernetes/configs/queries';
import { useNamespaces } from '@/react/kubernetes/namespaces/queries';
import { useServices } from '@/react/kubernetes/networks/services/queries';
import { notifySuccess, notifyError } from '@/portainer/services/notifications';
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
import { useAuthorizations } from '@/react/hooks/useUser';
import { Link } from '@@/Link';
@ -23,8 +23,7 @@ import {
useIngressControllers,
} from '../queries';
import { Annotation } from './Annotations/types';
import { Rule, Path, Host } from './types';
import { Rule, Path, Host, GroupedServiceOptions } from './types';
import { IngressForm } from './IngressForm';
import {
prepareTLS,
@ -33,6 +32,7 @@ import {
prepareRuleFromIngress,
checkIfPathExistsWithHost,
} from './utils';
import { Annotation } from './Annotations/types';
export function CreateIngressView() {
const environmentId = useEnvironmentId();
@ -58,31 +58,22 @@ export function CreateIngressView() {
{} as Record<string, string>
);
const namespacesResults = useNamespaces(environmentId);
const { data: namespaces, ...namespacesQuery } = useNamespaces(environmentId);
const servicesResults = useServices(environmentId, namespace);
const { data: allServices } = useServices(environmentId, namespace);
const configResults = useConfigurations(environmentId, namespace);
const ingressesResults = useIngresses(
environmentId,
namespacesResults.data ? Object.keys(namespacesResults?.data || {}) : []
namespaces ? Object.keys(namespaces || {}) : []
);
const ingressControllersResults = useIngressControllers(
const ingressControllersQuery = useIngressControllers(
environmentId,
namespace,
0
namespace
);
const createIngressMutation = useCreateIngress();
const updateIngressMutation = useUpdateIngress();
const isLoading =
(servicesResults.isLoading &&
configResults.isLoading &&
namespacesResults.isLoading &&
ingressesResults.isLoading &&
ingressControllersResults.isLoading) ||
(isEdit && !ingressRule.IngressName);
const [ingressNames, ingresses, ruleCounterByNamespace, hostWithTLS] =
useMemo((): [
string[],
@ -122,40 +113,51 @@ export function CreateIngressView() {
];
}, [ingressesResults.data, namespace]);
const namespacesOptions: Option<string>[] = [
{ label: 'Select a namespace', value: '' },
];
Object.entries(namespacesResults?.data || {}).forEach(([ns, val]) => {
if (!val.IsSystem) {
namespacesOptions.push({
label: ns,
value: ns,
});
}
});
const clusterIpServices = useMemo(
() => servicesResults.data?.filter((s) => s.Type === 'ClusterIP'),
[servicesResults.data]
);
const servicesOptions = useMemo(
const namespaceOptions = useMemo(
() =>
clusterIpServices?.map((service) => ({
label: service.Name,
value: service.Name,
Object.entries(namespaces || {})
.filter(([, nsValue]) => !nsValue.IsSystem)
.map(([nsKey]) => ({
label: nsKey,
value: nsKey,
})),
[clusterIpServices]
[namespaces]
);
const serviceOptions = [
{ label: 'Select a service', value: '' },
...(servicesOptions || []),
];
const serviceOptions: GroupedServiceOptions = useMemo(() => {
const groupedOptions: GroupedServiceOptions = (
allServices?.reduce<GroupedServiceOptions>(
(groupedOptions, service) => {
// add a new option to the group that matches the service type
const newGroupedOptions = groupedOptions.map((group) => {
if (group.label === service.Type) {
return {
...group,
options: [
...group.options,
{ label: service.Name, value: service.Name },
],
};
}
return group;
});
return newGroupedOptions;
},
[
{ label: 'ClusterIP', options: [] },
{ label: 'NodePort', options: [] },
{ label: 'LoadBalancer', options: [] },
] as GroupedServiceOptions
) || []
).filter((group) => group.options.length > 0);
return groupedOptions;
}, [allServices]);
const servicePorts = useMemo(
() =>
clusterIpServices
allServices
? Object.fromEntries(
clusterIpServices?.map((service) => [
allServices?.map((service) => [
service.Name,
service.Ports.map((port) => ({
label: String(port.Port),
@ -164,33 +166,35 @@ export function CreateIngressView() {
])
)
: {},
[clusterIpServices]
[allServices]
);
const existingIngressClass = useMemo(
() =>
ingressControllersResults.data?.find(
ingressControllersQuery.data?.find(
(i) =>
i.ClassName === ingressRule.IngressClassName ||
(i.Type === 'custom' && ingressRule.IngressClassName === '')
),
[ingressControllersResults.data, ingressRule.IngressClassName]
[ingressControllersQuery.data, ingressRule.IngressClassName]
);
const ingressClassOptions: Option<string>[] = [
{ label: 'Select an ingress class', value: '' },
...(ingressControllersResults.data
const ingressClassOptions: Option<string>[] = useMemo(
() =>
ingressControllersQuery.data
?.filter((cls) => cls.Availability)
.map((cls) => ({
label: cls.ClassName,
value: cls.ClassName,
})) || []),
];
})) || [],
[ingressControllersQuery.data]
);
if (
(!existingIngressClass ||
(existingIngressClass && !existingIngressClass.Availability)) &&
ingressRule.IngressClassName &&
!ingressControllersResults.isLoading
!ingressControllersQuery.isLoading
) {
const optionLabel = !ingressRule.IngressType
? `${ingressRule.IngressClassName} - NOT FOUND`
@ -222,15 +226,15 @@ export function CreateIngressView() {
!!params.name &&
ingressesResults.data &&
!ingressRule.IngressName &&
!ingressControllersResults.isLoading &&
!ingressControllersResults.isLoading
!ingressControllersQuery.isLoading &&
!ingressControllersQuery.isLoading
) {
// if it is an edit screen, prepare the rule from the ingress
const ing = ingressesResults.data?.find(
(ing) => ing.Name === params.name && ing.Namespace === params.namespace
);
if (ing) {
const type = ingressControllersResults.data?.find(
const type = ingressControllersQuery.data?.find(
(c) =>
c.ClassName === ing.ClassName ||
(c.Type === 'custom' && !ing.ClassName)
@ -244,7 +248,7 @@ export function CreateIngressView() {
}, [
params.name,
ingressesResults.data,
ingressControllersResults.data,
ingressControllersQuery.data,
ingressRule.IngressName,
params.namespace,
]);
@ -292,7 +296,7 @@ export function CreateIngressView() {
(
ingressRule: Rule,
ingressNames: string[],
serviceOptions: Option<string>[],
groupedServiceOptions: GroupedServiceOptions,
existingIngressClass?: IngressController
) => {
const errors: Record<string, ReactNode> = {};
@ -314,7 +318,7 @@ export function CreateIngressView() {
errors.ingressName = 'Ingress name already exists';
}
if (!rule.IngressClassName) {
if (!ingressClassOptions.length && ingressControllersQuery.isSuccess) {
errors.className = 'Ingress class is required';
}
}
@ -398,10 +402,14 @@ export function CreateIngressView() {
'Service name is required';
}
const availableServiceNames = groupedServiceOptions.flatMap(
(optionGroup) => optionGroup.options.map((option) => option.value)
);
if (
isEdit &&
path.ServiceName &&
!serviceOptions.find((s) => s.value === path.ServiceName)
!availableServiceNames.find((sn) => sn === path.ServiceName)
) {
errors[`hosts[${hi}].paths[${pi}].servicename`] = (
<span>
@ -456,26 +464,32 @@ export function CreateIngressView() {
}
return true;
},
[ingresses, environmentId, isEdit, params.name]
[
isEdit,
ingressClassOptions,
ingressControllersQuery.isSuccess,
environmentId,
ingresses,
params.name,
]
);
const debouncedValidate = useMemo(() => debounce(validate, 300), [validate]);
const debouncedValidate = useMemo(() => debounce(validate, 500), [validate]);
useEffect(() => {
if (namespace.length > 0) {
debouncedValidate(
ingressRule,
ingressNames || [],
servicesOptions || [],
serviceOptions || [],
existingIngressClass
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
ingressRule,
namespace,
ingressNames,
servicesOptions,
serviceOptions,
existingIngressClass,
debouncedValidate,
]);
@ -498,10 +512,10 @@ export function CreateIngressView() {
<div className="col-sm-12">
<IngressForm
environmentID={environmentId}
isLoading={isLoading}
isEdit={isEdit}
rule={ingressRule}
ingressClassOptions={ingressClassOptions}
isIngressClassOptionsLoading={ingressControllersQuery.isLoading}
errors={errors}
servicePorts={servicePorts}
tlsOptions={tlsOptions}
@ -520,10 +534,11 @@ export function CreateIngressView() {
handleAnnotationChange={handleAnnotationChange}
namespace={namespace}
handleNamespaceChange={handleNamespaceChange}
namespacesOptions={namespacesOptions}
namespacesOptions={namespaceOptions}
isNamespaceOptionsLoading={namespacesQuery.isLoading}
/>
</div>
{namespace && !isLoading && (
{namespace && (
<div className="col-sm-12">
<Button
onClick={() => handleCreateIngressRules()}
@ -548,7 +563,7 @@ export function CreateIngressView() {
setIngressRule((prevRules) => {
const rule = { ...prevRules, [key]: val };
if (key === 'IngressClassName') {
rule.IngressType = ingressControllersResults.data?.find(
rule.IngressType = ingressControllersQuery.data?.find(
(c) => c.ClassName === val
)?.Type;
}
@ -637,7 +652,7 @@ export function CreateIngressView() {
Key: uuidv4(),
Namespace: namespace,
IngressName: newKey,
IngressClassName: '',
IngressClassName: ingressRule.IngressClassName || '',
Hosts: [host],
};

View File

@ -1,19 +1,23 @@
import { ChangeEvent, ReactNode } from 'react';
import { ChangeEvent, ReactNode, useEffect } from 'react';
import { Plus, RefreshCw, Trash2 } from 'lucide-react';
import Route from '@/assets/ico/route.svg?c';
import { Link } from '@@/Link';
import { Select, Option } from '@@/form-components/Input/Select';
import { Option } from '@@/form-components/Input/Select';
import { FormError } from '@@/form-components/FormError';
import { Widget, WidgetBody, WidgetTitle } from '@@/Widget';
import { Tooltip } from '@@/Tip/Tooltip';
import { Button } from '@@/buttons';
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
import { TextTip } from '@@/Tip/TextTip';
import { Card } from '@@/Card';
import { InputGroup } from '@@/form-components/InputGroup';
import { InlineLoader } from '@@/InlineLoader';
import { Select } from '@@/form-components/ReactSelect';
import { Annotations } from './Annotations';
import { Rule, ServicePorts } from './types';
import { GroupedServiceOptions, Rule, ServicePorts } from './types';
import '../style.css';
@ -33,15 +37,16 @@ interface Props {
rule: Rule;
errors: Record<string, ReactNode>;
isLoading: boolean;
isEdit: boolean;
namespace: string;
servicePorts: ServicePorts;
ingressClassOptions: Option<string>[];
serviceOptions: Option<string>[];
isIngressClassOptionsLoading: boolean;
serviceOptions: GroupedServiceOptions;
tlsOptions: Option<string>[];
namespacesOptions: Option<string>[];
isNamespaceOptionsLoading: boolean;
removeIngressRoute: (hostIndex: number, pathIndex: number) => void;
removeIngressHost: (hostIndex: number) => void;
@ -76,7 +81,6 @@ interface Props {
export function IngressForm({
environmentID,
rule,
isLoading,
isEdit,
servicePorts,
tlsOptions,
@ -94,20 +98,38 @@ export function IngressForm({
reloadTLSCerts,
handleAnnotationChange,
ingressClassOptions,
isIngressClassOptionsLoading,
errors,
namespacesOptions,
isNamespaceOptionsLoading,
handleNamespaceChange,
namespace,
}: Props) {
if (isLoading) {
return <div>Loading...</div>;
}
const hasNoHostRule = rule.Hosts?.some((host) => host.NoHost);
const placeholderAnnotation =
PlaceholderAnnotations[rule.IngressType || 'other'] ||
PlaceholderAnnotations.other;
const pathTypes = PathTypes[rule.IngressType || 'other'] || PathTypes.other;
// when the namespace options update the value to an available one
useEffect(() => {
const namespaces = namespacesOptions.map((option) => option.value);
if (!namespaces.includes(namespace) && namespaces.length > 0) {
handleNamespaceChange(namespaces[0]);
}
}, [namespacesOptions, namespace, handleNamespaceChange]);
// when the ingress class options update update the value to an available one
useEffect(() => {
const ingressClasses = ingressClassOptions.map((option) => option.value);
if (
!ingressClasses.includes(rule.IngressClassName) &&
ingressClasses.length > 0
) {
handleIngressChange('IngressClassName', ingressClasses[0]);
}
}, [ingressClassOptions, rule.IngressClassName, handleIngressChange]);
return (
<Widget>
<WidgetTitle icon={Route} title="Ingress" />
@ -121,19 +143,30 @@ export function IngressForm({
>
Namespace
</label>
{isNamespaceOptionsLoading && (
<div className="col-sm-4">
<InlineLoader className="pt-2">
Loading namespaces...
</InlineLoader>
</div>
)}
{!isNamespaceOptionsLoading && (
<div className={`col-sm-4 ${isEdit && 'control-label'}`}>
{isEdit ? (
namespace
) : (
<Select
name="namespaces"
options={namespacesOptions || []}
onChange={(e) => handleNamespaceChange(e.target.value)}
defaultValue={namespace}
disabled={isEdit}
value={{ value: namespace, label: namespace }}
isDisabled={isEdit}
onChange={(val) =>
handleNamespaceChange(val?.value || '')
}
/>
)}
</div>
)}
</div>
</div>
</div>
@ -180,21 +213,40 @@ export function IngressForm({
Ingress class
</label>
<div className="col-sm-4">
{isIngressClassOptionsLoading && (
<InlineLoader className="pt-2">
Loading ingress classes...
</InlineLoader>
)}
{!isIngressClassOptionsLoading && (
<>
<Select
name="ingress_class"
className="form-control"
placeholder="Ingress name"
defaultValue={rule.IngressClassName}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
handleIngressChange('IngressClassName', e.target.value)
}
options={ingressClassOptions}
value={{
label: rule.IngressClassName,
value: rule.IngressClassName,
}}
onChange={(ingressClassOption) =>
handleIngressChange(
'IngressClassName',
ingressClassOption?.value || ''
)
}
/>
{errors.className && (
<FormError className="error-inline mt-1">
{errors.className}
</FormError>
)}
</>
)}
{errors.className && (
<FormError className="error-inline mt-1">
{errors.className}
</FormError>
)}
</div>
</div>
</div>
@ -300,9 +352,9 @@ export function IngressForm({
{namespace &&
rule?.Hosts?.map((host, hostIndex) => (
<div className="row rule bordered mb-5" key={host.Key}>
<div className="col-sm-12">
<div className="row rule-actions mt-5">
<Card key={host.Key} className="mb-5">
<div className="flex flex-col">
<div className="row rule-actions">
<div className="col-sm-3 p-0">
{!host.NoHost ? 'Rule' : 'Fallback rule'}
</div>
@ -323,11 +375,9 @@ export function IngressForm({
{!host.NoHost && (
<div className="row">
<div className="form-group col-sm-6 col-lg-4 !pl-0 !pr-2">
<div className="input-group input-group-sm">
<span className="input-group-addon required">
Hostname
</span>
<input
<InputGroup size="small">
<InputGroup.Addon required>Hostname</InputGroup.Addon>
<InputGroup.Input
name={`ingress_host_${hostIndex}`}
type="text"
className="form-control form-control-sm"
@ -337,7 +387,7 @@ export function IngressForm({
handleHostChange(hostIndex, e.target.value)
}
/>
</div>
</InputGroup>
{errors[`hosts[${hostIndex}].host`] && (
<FormError className="mt-1 !mb-0">
{errors[`hosts[${hostIndex}].host`]}
@ -346,17 +396,19 @@ export function IngressForm({
</div>
<div className="form-group col-sm-6 col-lg-4 !pr-0 !pl-2">
<div className="input-group input-group-sm">
<span className="input-group-addon">TLS secret</span>
<InputGroup size="small">
<InputGroup.Addon>TLS secret</InputGroup.Addon>
<Select
key={tlsOptions.toString() + host.Secret}
name={`ingress_tls_${hostIndex}`}
options={tlsOptions}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
handleTLSChange(hostIndex, e.target.value)
value={{
value: rule.Hosts[hostIndex].Secret,
label: rule.Hosts[hostIndex].Secret || 'No TLS',
}}
onChange={(TLSOption) =>
handleTLSChange(hostIndex, TLSOption?.value || '')
}
defaultValue={host.Secret}
className="!rounded-r-none"
size="sm"
/>
{!host.NoHost && (
<div className="input-group-btn">
@ -367,7 +419,7 @@ export function IngressForm({
/>
</div>
)}
</div>
</InputGroup>
</div>
<div className="col-sm-12 col-lg-4 flex h-[30px] items-center pl-2">
@ -414,25 +466,27 @@ export function IngressForm({
key={`path_${path.Key}}`}
>
<div className="form-group col-sm-3 col-xl-2 !m-0 !pl-0">
<div className="input-group input-group-sm">
<span className="input-group-addon required">
Service
</span>
<InputGroup size="small">
<InputGroup.Addon required>Service</InputGroup.Addon>
<Select
key={serviceOptions.toString() + path.ServiceName}
name={`ingress_service_${hostIndex}_${pathIndex}`}
options={serviceOptions}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
value={{
value: path.ServiceName,
label: path.ServiceName || 'Select a service',
}}
onChange={(serviceOption) =>
handlePathChange(
hostIndex,
pathIndex,
'ServiceName',
e.target.value
serviceOption?.value || ''
)
}
defaultValue={path.ServiceName}
size="sm"
/>
</div>
</InputGroup>
{errors[
`hosts[${hostIndex}].paths[${pathIndex}].servicename`
] && (
@ -449,35 +503,41 @@ export function IngressForm({
<div className="form-group col-sm-2 col-xl-2 !m-0 !pl-0">
{servicePorts && (
<>
<div className="input-group input-group-sm">
<span className="input-group-addon required">
<InputGroup size="small">
<InputGroup.Addon required>
Service port
</span>
</InputGroup.Addon>
<Select
key={servicePorts.toString() + path.ServicePort}
name={`ingress_servicePort_${hostIndex}_${pathIndex}`}
options={
path.ServiceName &&
servicePorts[path.ServiceName]
? servicePorts[path.ServiceName]
: [
{
label: 'Select port',
value: '',
},
]
servicePorts[path.ServiceName]?.map(
(portOption) => ({
...portOption,
value: portOption.value.toString(),
})
) || []
}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
onChange={(option) =>
handlePathChange(
hostIndex,
pathIndex,
'ServicePort',
e.target.value
option?.value || ''
)
}
defaultValue={path.ServicePort}
value={{
label: (
path.ServicePort || 'Select a port'
).toString(),
value:
rule.Hosts[hostIndex].Paths[
pathIndex
].ServicePort.toString(),
}}
size="sm"
/>
</div>
</InputGroup>
{errors[
`hosts[${hostIndex}].paths[${pathIndex}].serviceport`
] && (
@ -494,30 +554,32 @@ export function IngressForm({
</div>
<div className="form-group col-sm-3 col-xl-2 !m-0 !pl-0">
<div className="input-group input-group-sm">
<span className="input-group-addon">Path type</span>
<Select
<InputGroup size="small">
<InputGroup.Addon>Path type</InputGroup.Addon>
<Select<Option<string>>
key={servicePorts.toString() + path.PathType}
name={`ingress_pathType_${hostIndex}_${pathIndex}`}
options={
pathTypes
? pathTypes.map((type) => ({
pathTypes?.map((type) => ({
label: type,
value: type,
}))
: []
})) || []
}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
onChange={(option) =>
handlePathChange(
hostIndex,
pathIndex,
'PathType',
e.target.value
option?.value || ''
)
}
defaultValue={path.PathType}
value={{
label: path.PathType || 'Select a path type',
value: path.PathType || '',
}}
size="sm"
/>
</div>
</InputGroup>
{errors[
`hosts[${hostIndex}].paths[${pathIndex}].pathType`
] && (
@ -532,9 +594,9 @@ export function IngressForm({
</div>
<div className="form-group col-sm-3 col-xl-3 !m-0 !pl-0">
<div className="input-group input-group-sm">
<span className="input-group-addon required">Path</span>
<input
<InputGroup size="small">
<InputGroup.Addon required>Path</InputGroup.Addon>
<InputGroup.Input
className="form-control"
name={`ingress_route_${hostIndex}-${pathIndex}`}
placeholder="/example"
@ -550,7 +612,7 @@ export function IngressForm({
)
}
/>
</div>
</InputGroup>
{errors[
`hosts[${hostIndex}].paths[${pathIndex}].path`
] && (
@ -592,7 +654,7 @@ export function IngressForm({
</Button>
</div>
</div>
</div>
</Card>
))}
{namespace && (

View File

@ -31,3 +31,8 @@ export interface Rule {
export interface ServicePorts {
[serviceName: string]: Option<string>[];
}
export type GroupedServiceOptions = {
label: string;
options: Option<string>[];
}[];

View File

@ -177,8 +177,7 @@ export function useDeleteIngresses() {
*/
export function useIngressControllers(
environmentId: EnvironmentId,
namespace?: string,
cacheTime?: number
namespace?: string
) {
return useQuery(
[
@ -193,7 +192,6 @@ export function useIngressControllers(
namespace ? getIngressControllers(environmentId, namespace) : [],
{
enabled: !!namespace,
cacheTime,
...withError('Unable to get ingress controllers'),
}
);