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> = { const meta: Meta<WidgetProps> = {
title: 'Widget', title: 'Components/Widget',
component: Widget, component: Widget,
args: { args: {
loading: false, loading: false,

View File

@ -31,6 +31,22 @@
display: none; 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 */ /* input style */
.portainer-selector-root .portainer-selector__control { .portainer-selector-root .portainer-selector__control {
border-color: var(--border-form-control-color); border-color: var(--border-form-control-color);

View File

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

View File

@ -7,7 +7,7 @@ import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useConfigurations } from '@/react/kubernetes/configs/queries'; import { useConfigurations } from '@/react/kubernetes/configs/queries';
import { useNamespaces } from '@/react/kubernetes/namespaces/queries'; import { useNamespaces } from '@/react/kubernetes/namespaces/queries';
import { useServices } from '@/react/kubernetes/networks/services/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 { useAuthorizations } from '@/react/hooks/useUser';
import { Link } from '@@/Link'; import { Link } from '@@/Link';
@ -23,8 +23,7 @@ import {
useIngressControllers, useIngressControllers,
} from '../queries'; } from '../queries';
import { Annotation } from './Annotations/types'; import { Rule, Path, Host, GroupedServiceOptions } from './types';
import { Rule, Path, Host } from './types';
import { IngressForm } from './IngressForm'; import { IngressForm } from './IngressForm';
import { import {
prepareTLS, prepareTLS,
@ -33,6 +32,7 @@ import {
prepareRuleFromIngress, prepareRuleFromIngress,
checkIfPathExistsWithHost, checkIfPathExistsWithHost,
} from './utils'; } from './utils';
import { Annotation } from './Annotations/types';
export function CreateIngressView() { export function CreateIngressView() {
const environmentId = useEnvironmentId(); const environmentId = useEnvironmentId();
@ -58,31 +58,22 @@ export function CreateIngressView() {
{} as Record<string, string> {} 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 configResults = useConfigurations(environmentId, namespace);
const ingressesResults = useIngresses( const ingressesResults = useIngresses(
environmentId, environmentId,
namespacesResults.data ? Object.keys(namespacesResults?.data || {}) : [] namespaces ? Object.keys(namespaces || {}) : []
); );
const ingressControllersResults = useIngressControllers( const ingressControllersQuery = useIngressControllers(
environmentId, environmentId,
namespace, namespace
0
); );
const createIngressMutation = useCreateIngress(); const createIngressMutation = useCreateIngress();
const updateIngressMutation = useUpdateIngress(); const updateIngressMutation = useUpdateIngress();
const isLoading =
(servicesResults.isLoading &&
configResults.isLoading &&
namespacesResults.isLoading &&
ingressesResults.isLoading &&
ingressControllersResults.isLoading) ||
(isEdit && !ingressRule.IngressName);
const [ingressNames, ingresses, ruleCounterByNamespace, hostWithTLS] = const [ingressNames, ingresses, ruleCounterByNamespace, hostWithTLS] =
useMemo((): [ useMemo((): [
string[], string[],
@ -122,40 +113,51 @@ export function CreateIngressView() {
]; ];
}, [ingressesResults.data, namespace]); }, [ingressesResults.data, namespace]);
const namespacesOptions: Option<string>[] = [ const namespaceOptions = useMemo(
{ 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(
() => () =>
clusterIpServices?.map((service) => ({ Object.entries(namespaces || {})
label: service.Name, .filter(([, nsValue]) => !nsValue.IsSystem)
value: service.Name, .map(([nsKey]) => ({
label: nsKey,
value: nsKey,
})), })),
[clusterIpServices] [namespaces]
); );
const serviceOptions = [ const serviceOptions: GroupedServiceOptions = useMemo(() => {
{ label: 'Select a service', value: '' }, const groupedOptions: GroupedServiceOptions = (
...(servicesOptions || []), 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( const servicePorts = useMemo(
() => () =>
clusterIpServices allServices
? Object.fromEntries( ? Object.fromEntries(
clusterIpServices?.map((service) => [ allServices?.map((service) => [
service.Name, service.Name,
service.Ports.map((port) => ({ service.Ports.map((port) => ({
label: String(port.Port), label: String(port.Port),
@ -164,33 +166,35 @@ export function CreateIngressView() {
]) ])
) )
: {}, : {},
[clusterIpServices] [allServices]
); );
const existingIngressClass = useMemo( const existingIngressClass = useMemo(
() => () =>
ingressControllersResults.data?.find( ingressControllersQuery.data?.find(
(i) => (i) =>
i.ClassName === ingressRule.IngressClassName || i.ClassName === ingressRule.IngressClassName ||
(i.Type === 'custom' && 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: '' }, const ingressClassOptions: Option<string>[] = useMemo(
...(ingressControllersResults.data () =>
ingressControllersQuery.data
?.filter((cls) => cls.Availability) ?.filter((cls) => cls.Availability)
.map((cls) => ({ .map((cls) => ({
label: cls.ClassName, label: cls.ClassName,
value: cls.ClassName, value: cls.ClassName,
})) || []), })) || [],
]; [ingressControllersQuery.data]
);
if ( if (
(!existingIngressClass || (!existingIngressClass ||
(existingIngressClass && !existingIngressClass.Availability)) && (existingIngressClass && !existingIngressClass.Availability)) &&
ingressRule.IngressClassName && ingressRule.IngressClassName &&
!ingressControllersResults.isLoading !ingressControllersQuery.isLoading
) { ) {
const optionLabel = !ingressRule.IngressType const optionLabel = !ingressRule.IngressType
? `${ingressRule.IngressClassName} - NOT FOUND` ? `${ingressRule.IngressClassName} - NOT FOUND`
@ -222,15 +226,15 @@ export function CreateIngressView() {
!!params.name && !!params.name &&
ingressesResults.data && ingressesResults.data &&
!ingressRule.IngressName && !ingressRule.IngressName &&
!ingressControllersResults.isLoading && !ingressControllersQuery.isLoading &&
!ingressControllersResults.isLoading !ingressControllersQuery.isLoading
) { ) {
// if it is an edit screen, prepare the rule from the ingress // if it is an edit screen, prepare the rule from the ingress
const ing = ingressesResults.data?.find( const ing = ingressesResults.data?.find(
(ing) => ing.Name === params.name && ing.Namespace === params.namespace (ing) => ing.Name === params.name && ing.Namespace === params.namespace
); );
if (ing) { if (ing) {
const type = ingressControllersResults.data?.find( const type = ingressControllersQuery.data?.find(
(c) => (c) =>
c.ClassName === ing.ClassName || c.ClassName === ing.ClassName ||
(c.Type === 'custom' && !ing.ClassName) (c.Type === 'custom' && !ing.ClassName)
@ -244,7 +248,7 @@ export function CreateIngressView() {
}, [ }, [
params.name, params.name,
ingressesResults.data, ingressesResults.data,
ingressControllersResults.data, ingressControllersQuery.data,
ingressRule.IngressName, ingressRule.IngressName,
params.namespace, params.namespace,
]); ]);
@ -292,7 +296,7 @@ export function CreateIngressView() {
( (
ingressRule: Rule, ingressRule: Rule,
ingressNames: string[], ingressNames: string[],
serviceOptions: Option<string>[], groupedServiceOptions: GroupedServiceOptions,
existingIngressClass?: IngressController existingIngressClass?: IngressController
) => { ) => {
const errors: Record<string, ReactNode> = {}; const errors: Record<string, ReactNode> = {};
@ -314,7 +318,7 @@ export function CreateIngressView() {
errors.ingressName = 'Ingress name already exists'; errors.ingressName = 'Ingress name already exists';
} }
if (!rule.IngressClassName) { if (!ingressClassOptions.length && ingressControllersQuery.isSuccess) {
errors.className = 'Ingress class is required'; errors.className = 'Ingress class is required';
} }
} }
@ -398,10 +402,14 @@ export function CreateIngressView() {
'Service name is required'; 'Service name is required';
} }
const availableServiceNames = groupedServiceOptions.flatMap(
(optionGroup) => optionGroup.options.map((option) => option.value)
);
if ( if (
isEdit && isEdit &&
path.ServiceName && path.ServiceName &&
!serviceOptions.find((s) => s.value === path.ServiceName) !availableServiceNames.find((sn) => sn === path.ServiceName)
) { ) {
errors[`hosts[${hi}].paths[${pi}].servicename`] = ( errors[`hosts[${hi}].paths[${pi}].servicename`] = (
<span> <span>
@ -456,26 +464,32 @@ export function CreateIngressView() {
} }
return true; 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(() => { useEffect(() => {
if (namespace.length > 0) { if (namespace.length > 0) {
debouncedValidate( debouncedValidate(
ingressRule, ingressRule,
ingressNames || [], ingressNames || [],
servicesOptions || [], serviceOptions || [],
existingIngressClass existingIngressClass
); );
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ }, [
ingressRule, ingressRule,
namespace, namespace,
ingressNames, ingressNames,
servicesOptions, serviceOptions,
existingIngressClass, existingIngressClass,
debouncedValidate, debouncedValidate,
]); ]);
@ -498,10 +512,10 @@ export function CreateIngressView() {
<div className="col-sm-12"> <div className="col-sm-12">
<IngressForm <IngressForm
environmentID={environmentId} environmentID={environmentId}
isLoading={isLoading}
isEdit={isEdit} isEdit={isEdit}
rule={ingressRule} rule={ingressRule}
ingressClassOptions={ingressClassOptions} ingressClassOptions={ingressClassOptions}
isIngressClassOptionsLoading={ingressControllersQuery.isLoading}
errors={errors} errors={errors}
servicePorts={servicePorts} servicePorts={servicePorts}
tlsOptions={tlsOptions} tlsOptions={tlsOptions}
@ -520,10 +534,11 @@ export function CreateIngressView() {
handleAnnotationChange={handleAnnotationChange} handleAnnotationChange={handleAnnotationChange}
namespace={namespace} namespace={namespace}
handleNamespaceChange={handleNamespaceChange} handleNamespaceChange={handleNamespaceChange}
namespacesOptions={namespacesOptions} namespacesOptions={namespaceOptions}
isNamespaceOptionsLoading={namespacesQuery.isLoading}
/> />
</div> </div>
{namespace && !isLoading && ( {namespace && (
<div className="col-sm-12"> <div className="col-sm-12">
<Button <Button
onClick={() => handleCreateIngressRules()} onClick={() => handleCreateIngressRules()}
@ -548,7 +563,7 @@ export function CreateIngressView() {
setIngressRule((prevRules) => { setIngressRule((prevRules) => {
const rule = { ...prevRules, [key]: val }; const rule = { ...prevRules, [key]: val };
if (key === 'IngressClassName') { if (key === 'IngressClassName') {
rule.IngressType = ingressControllersResults.data?.find( rule.IngressType = ingressControllersQuery.data?.find(
(c) => c.ClassName === val (c) => c.ClassName === val
)?.Type; )?.Type;
} }
@ -637,7 +652,7 @@ export function CreateIngressView() {
Key: uuidv4(), Key: uuidv4(),
Namespace: namespace, Namespace: namespace,
IngressName: newKey, IngressName: newKey,
IngressClassName: '', IngressClassName: ingressRule.IngressClassName || '',
Hosts: [host], 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 { Plus, RefreshCw, Trash2 } from 'lucide-react';
import Route from '@/assets/ico/route.svg?c'; import Route from '@/assets/ico/route.svg?c';
import { Link } from '@@/Link'; 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 { FormError } from '@@/form-components/FormError';
import { Widget, WidgetBody, WidgetTitle } from '@@/Widget'; import { Widget, WidgetBody, WidgetTitle } from '@@/Widget';
import { Tooltip } from '@@/Tip/Tooltip'; import { Tooltip } from '@@/Tip/Tooltip';
import { Button } from '@@/buttons'; import { Button } from '@@/buttons';
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren'; import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
import { TextTip } from '@@/Tip/TextTip'; 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 { Annotations } from './Annotations';
import { Rule, ServicePorts } from './types'; import { GroupedServiceOptions, Rule, ServicePorts } from './types';
import '../style.css'; import '../style.css';
@ -33,15 +37,16 @@ interface Props {
rule: Rule; rule: Rule;
errors: Record<string, ReactNode>; errors: Record<string, ReactNode>;
isLoading: boolean;
isEdit: boolean; isEdit: boolean;
namespace: string; namespace: string;
servicePorts: ServicePorts; servicePorts: ServicePorts;
ingressClassOptions: Option<string>[]; ingressClassOptions: Option<string>[];
serviceOptions: Option<string>[]; isIngressClassOptionsLoading: boolean;
serviceOptions: GroupedServiceOptions;
tlsOptions: Option<string>[]; tlsOptions: Option<string>[];
namespacesOptions: Option<string>[]; namespacesOptions: Option<string>[];
isNamespaceOptionsLoading: boolean;
removeIngressRoute: (hostIndex: number, pathIndex: number) => void; removeIngressRoute: (hostIndex: number, pathIndex: number) => void;
removeIngressHost: (hostIndex: number) => void; removeIngressHost: (hostIndex: number) => void;
@ -76,7 +81,6 @@ interface Props {
export function IngressForm({ export function IngressForm({
environmentID, environmentID,
rule, rule,
isLoading,
isEdit, isEdit,
servicePorts, servicePorts,
tlsOptions, tlsOptions,
@ -94,20 +98,38 @@ export function IngressForm({
reloadTLSCerts, reloadTLSCerts,
handleAnnotationChange, handleAnnotationChange,
ingressClassOptions, ingressClassOptions,
isIngressClassOptionsLoading,
errors, errors,
namespacesOptions, namespacesOptions,
isNamespaceOptionsLoading,
handleNamespaceChange, handleNamespaceChange,
namespace, namespace,
}: Props) { }: Props) {
if (isLoading) {
return <div>Loading...</div>;
}
const hasNoHostRule = rule.Hosts?.some((host) => host.NoHost); const hasNoHostRule = rule.Hosts?.some((host) => host.NoHost);
const placeholderAnnotation = const placeholderAnnotation =
PlaceholderAnnotations[rule.IngressType || 'other'] || PlaceholderAnnotations[rule.IngressType || 'other'] ||
PlaceholderAnnotations.other; PlaceholderAnnotations.other;
const pathTypes = PathTypes[rule.IngressType || 'other'] || PathTypes.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 ( return (
<Widget> <Widget>
<WidgetTitle icon={Route} title="Ingress" /> <WidgetTitle icon={Route} title="Ingress" />
@ -121,19 +143,30 @@ export function IngressForm({
> >
Namespace Namespace
</label> </label>
{isNamespaceOptionsLoading && (
<div className="col-sm-4"> <div className="col-sm-4">
<InlineLoader className="pt-2">
Loading namespaces...
</InlineLoader>
</div>
)}
{!isNamespaceOptionsLoading && (
<div className={`col-sm-4 ${isEdit && 'control-label'}`}>
{isEdit ? ( {isEdit ? (
namespace namespace
) : ( ) : (
<Select <Select
name="namespaces" name="namespaces"
options={namespacesOptions || []} options={namespacesOptions || []}
onChange={(e) => handleNamespaceChange(e.target.value)} value={{ value: namespace, label: namespace }}
defaultValue={namespace} isDisabled={isEdit}
disabled={isEdit} onChange={(val) =>
handleNamespaceChange(val?.value || '')
}
/> />
)} )}
</div> </div>
)}
</div> </div>
</div> </div>
</div> </div>
@ -180,21 +213,40 @@ export function IngressForm({
Ingress class Ingress class
</label> </label>
<div className="col-sm-4"> <div className="col-sm-4">
{isIngressClassOptionsLoading && (
<InlineLoader className="pt-2">
Loading ingress classes...
</InlineLoader>
)}
{!isIngressClassOptionsLoading && (
<>
<Select <Select
name="ingress_class" name="ingress_class"
className="form-control"
placeholder="Ingress name" placeholder="Ingress name"
defaultValue={rule.IngressClassName}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
handleIngressChange('IngressClassName', e.target.value)
}
options={ingressClassOptions} options={ingressClassOptions}
value={{
label: rule.IngressClassName,
value: rule.IngressClassName,
}}
onChange={(ingressClassOption) =>
handleIngressChange(
'IngressClassName',
ingressClassOption?.value || ''
)
}
/> />
{errors.className && ( {errors.className && (
<FormError className="error-inline mt-1"> <FormError className="error-inline mt-1">
{errors.className} {errors.className}
</FormError> </FormError>
)} )}
</>
)}
{errors.className && (
<FormError className="error-inline mt-1">
{errors.className}
</FormError>
)}
</div> </div>
</div> </div>
</div> </div>
@ -300,9 +352,9 @@ export function IngressForm({
{namespace && {namespace &&
rule?.Hosts?.map((host, hostIndex) => ( rule?.Hosts?.map((host, hostIndex) => (
<div className="row rule bordered mb-5" key={host.Key}> <Card key={host.Key} className="mb-5">
<div className="col-sm-12"> <div className="flex flex-col">
<div className="row rule-actions mt-5"> <div className="row rule-actions">
<div className="col-sm-3 p-0"> <div className="col-sm-3 p-0">
{!host.NoHost ? 'Rule' : 'Fallback rule'} {!host.NoHost ? 'Rule' : 'Fallback rule'}
</div> </div>
@ -323,11 +375,9 @@ export function IngressForm({
{!host.NoHost && ( {!host.NoHost && (
<div className="row"> <div className="row">
<div className="form-group col-sm-6 col-lg-4 !pl-0 !pr-2"> <div className="form-group col-sm-6 col-lg-4 !pl-0 !pr-2">
<div className="input-group input-group-sm"> <InputGroup size="small">
<span className="input-group-addon required"> <InputGroup.Addon required>Hostname</InputGroup.Addon>
Hostname <InputGroup.Input
</span>
<input
name={`ingress_host_${hostIndex}`} name={`ingress_host_${hostIndex}`}
type="text" type="text"
className="form-control form-control-sm" className="form-control form-control-sm"
@ -337,7 +387,7 @@ export function IngressForm({
handleHostChange(hostIndex, e.target.value) handleHostChange(hostIndex, e.target.value)
} }
/> />
</div> </InputGroup>
{errors[`hosts[${hostIndex}].host`] && ( {errors[`hosts[${hostIndex}].host`] && (
<FormError className="mt-1 !mb-0"> <FormError className="mt-1 !mb-0">
{errors[`hosts[${hostIndex}].host`]} {errors[`hosts[${hostIndex}].host`]}
@ -346,17 +396,19 @@ export function IngressForm({
</div> </div>
<div className="form-group col-sm-6 col-lg-4 !pr-0 !pl-2"> <div className="form-group col-sm-6 col-lg-4 !pr-0 !pl-2">
<div className="input-group input-group-sm"> <InputGroup size="small">
<span className="input-group-addon">TLS secret</span> <InputGroup.Addon>TLS secret</InputGroup.Addon>
<Select <Select
key={tlsOptions.toString() + host.Secret} key={tlsOptions.toString() + host.Secret}
name={`ingress_tls_${hostIndex}`} name={`ingress_tls_${hostIndex}`}
options={tlsOptions} value={{
onChange={(e: ChangeEvent<HTMLSelectElement>) => value: rule.Hosts[hostIndex].Secret,
handleTLSChange(hostIndex, e.target.value) label: rule.Hosts[hostIndex].Secret || 'No TLS',
}}
onChange={(TLSOption) =>
handleTLSChange(hostIndex, TLSOption?.value || '')
} }
defaultValue={host.Secret} size="sm"
className="!rounded-r-none"
/> />
{!host.NoHost && ( {!host.NoHost && (
<div className="input-group-btn"> <div className="input-group-btn">
@ -367,7 +419,7 @@ export function IngressForm({
/> />
</div> </div>
)} )}
</div> </InputGroup>
</div> </div>
<div className="col-sm-12 col-lg-4 flex h-[30px] items-center pl-2"> <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}}`} key={`path_${path.Key}}`}
> >
<div className="form-group col-sm-3 col-xl-2 !m-0 !pl-0"> <div className="form-group col-sm-3 col-xl-2 !m-0 !pl-0">
<div className="input-group input-group-sm"> <InputGroup size="small">
<span className="input-group-addon required"> <InputGroup.Addon required>Service</InputGroup.Addon>
Service
</span>
<Select <Select
key={serviceOptions.toString() + path.ServiceName} key={serviceOptions.toString() + path.ServiceName}
name={`ingress_service_${hostIndex}_${pathIndex}`} name={`ingress_service_${hostIndex}_${pathIndex}`}
options={serviceOptions} options={serviceOptions}
onChange={(e: ChangeEvent<HTMLSelectElement>) => value={{
value: path.ServiceName,
label: path.ServiceName || 'Select a service',
}}
onChange={(serviceOption) =>
handlePathChange( handlePathChange(
hostIndex, hostIndex,
pathIndex, pathIndex,
'ServiceName', 'ServiceName',
e.target.value serviceOption?.value || ''
) )
} }
defaultValue={path.ServiceName} size="sm"
/> />
</div> </InputGroup>
{errors[ {errors[
`hosts[${hostIndex}].paths[${pathIndex}].servicename` `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"> <div className="form-group col-sm-2 col-xl-2 !m-0 !pl-0">
{servicePorts && ( {servicePorts && (
<> <>
<div className="input-group input-group-sm"> <InputGroup size="small">
<span className="input-group-addon required"> <InputGroup.Addon required>
Service port Service port
</span> </InputGroup.Addon>
<Select <Select
key={servicePorts.toString() + path.ServicePort} key={servicePorts.toString() + path.ServicePort}
name={`ingress_servicePort_${hostIndex}_${pathIndex}`} name={`ingress_servicePort_${hostIndex}_${pathIndex}`}
options={ options={
path.ServiceName && servicePorts[path.ServiceName]?.map(
servicePorts[path.ServiceName] (portOption) => ({
? servicePorts[path.ServiceName] ...portOption,
: [ value: portOption.value.toString(),
{ })
label: 'Select port', ) || []
value: '',
},
]
} }
onChange={(e: ChangeEvent<HTMLSelectElement>) => onChange={(option) =>
handlePathChange( handlePathChange(
hostIndex, hostIndex,
pathIndex, pathIndex,
'ServicePort', '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[ {errors[
`hosts[${hostIndex}].paths[${pathIndex}].serviceport` `hosts[${hostIndex}].paths[${pathIndex}].serviceport`
] && ( ] && (
@ -494,30 +554,32 @@ export function IngressForm({
</div> </div>
<div className="form-group col-sm-3 col-xl-2 !m-0 !pl-0"> <div className="form-group col-sm-3 col-xl-2 !m-0 !pl-0">
<div className="input-group input-group-sm"> <InputGroup size="small">
<span className="input-group-addon">Path type</span> <InputGroup.Addon>Path type</InputGroup.Addon>
<Select <Select<Option<string>>
key={servicePorts.toString() + path.PathType} key={servicePorts.toString() + path.PathType}
name={`ingress_pathType_${hostIndex}_${pathIndex}`} name={`ingress_pathType_${hostIndex}_${pathIndex}`}
options={ options={
pathTypes pathTypes?.map((type) => ({
? pathTypes.map((type) => ({
label: type, label: type,
value: type, value: type,
})) })) || []
: []
} }
onChange={(e: ChangeEvent<HTMLSelectElement>) => onChange={(option) =>
handlePathChange( handlePathChange(
hostIndex, hostIndex,
pathIndex, pathIndex,
'PathType', '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[ {errors[
`hosts[${hostIndex}].paths[${pathIndex}].pathType` `hosts[${hostIndex}].paths[${pathIndex}].pathType`
] && ( ] && (
@ -532,9 +594,9 @@ export function IngressForm({
</div> </div>
<div className="form-group col-sm-3 col-xl-3 !m-0 !pl-0"> <div className="form-group col-sm-3 col-xl-3 !m-0 !pl-0">
<div className="input-group input-group-sm"> <InputGroup size="small">
<span className="input-group-addon required">Path</span> <InputGroup.Addon required>Path</InputGroup.Addon>
<input <InputGroup.Input
className="form-control" className="form-control"
name={`ingress_route_${hostIndex}-${pathIndex}`} name={`ingress_route_${hostIndex}-${pathIndex}`}
placeholder="/example" placeholder="/example"
@ -550,7 +612,7 @@ export function IngressForm({
) )
} }
/> />
</div> </InputGroup>
{errors[ {errors[
`hosts[${hostIndex}].paths[${pathIndex}].path` `hosts[${hostIndex}].paths[${pathIndex}].path`
] && ( ] && (
@ -592,7 +654,7 @@ export function IngressForm({
</Button> </Button>
</div> </div>
</div> </div>
</div> </Card>
))} ))}
{namespace && ( {namespace && (

View File

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

View File

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